Files
effectcraft/crates/raster/src/scopes.rs
T

556 lines
20 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Video scopes (the Lumetri Scopes panel): waveform (RGB, Luma, YC), vectorscope (YUV, HLS),
//! histogram and parade (RGB, YUV), computed from a frame as density plots.
//!
//! The maths is the textbook broadcast definition: luma `Y = Kr·R + Kg·G + Kb·B` with the
//! ITU-R BT.601 / BT.709 / BT.2020 coefficients, colour differences `Cb = (B − Y) / (2(1 − Kb))`
//! and `Cr = (R − Y) / (2(1 − Kr))` (each in −0.5…0.5). Values are read straight (un-premultiplied)
//! from the frame. Every scope is a [`Scope`]: a `width × height` grid of three density planes
//! (one per trace colour) on a logarithmic scale where the busiest cell is 1, ready to be tinted
//! and drawn.
//!
//! Scales: **8-bit** maps 0…1 to the plot (0…255 graticule); **float** maps −0.25…1.25 so
//! super-white and sub-black values stay visible. **Clamp** limits the signal to 0…1 first.
use rayon::prelude::*;
use crate::Image;
/// Luma / colour-difference coefficients.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ColorStandard {
Rec601,
#[default]
Rec709,
Rec2020,
}
impl ColorStandard {
pub const ALL: [ColorStandard; 3] = [ColorStandard::Rec601, ColorStandard::Rec709, ColorStandard::Rec2020];
/// (Kr, Kb).
pub fn coefficients(self) -> (f32, f32) {
match self {
ColorStandard::Rec601 => (0.299, 0.114),
ColorStandard::Rec709 => (0.2126, 0.0722),
ColorStandard::Rec2020 => (0.2627, 0.0593),
}
}
pub fn name(self) -> &'static str {
match self {
ColorStandard::Rec601 => "rec601",
ColorStandard::Rec709 => "rec709",
ColorStandard::Rec2020 => "rec2020",
}
}
pub fn from_name(s: &str) -> Option<ColorStandard> {
match s.to_ascii_lowercase().replace([' ', '.', '-', '_'], "").as_str() {
"rec601" | "601" | "bt601" => Some(ColorStandard::Rec601),
"rec709" | "709" | "bt709" => Some(ColorStandard::Rec709),
"rec2020" | "2020" | "bt2020" => Some(ColorStandard::Rec2020),
_ => None,
}
}
/// (Y, Cb, Cr) of a straight RGB colour.
pub fn ycbcr(self, rgb: [f32; 3]) -> [f32; 3] {
let (kr, kb) = self.coefficients();
let y = kr * rgb[0] + (1.0 - kr - kb) * rgb[1] + kb * rgb[2];
[y, (rgb[2] - y) / (2.0 * (1.0 - kb)), (rgb[0] - y) / (2.0 * (1.0 - kr))]
}
}
/// Which scope.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ScopeKind {
WaveformRgb,
WaveformLuma,
/// Luma (plane 0) and chroma amplitude `√(Cb² + Cr²)` (plane 2).
WaveformYc,
/// Cb across, Cr up.
VectorscopeYuv,
/// Hue around the circle, saturation outwards (HLS).
VectorscopeHls,
/// R, G, B histograms (bins across, counts up).
Histogram,
ParadeRgb,
ParadeYuv,
}
impl ScopeKind {
pub const ALL: [ScopeKind; 8] = [
ScopeKind::WaveformRgb,
ScopeKind::WaveformLuma,
ScopeKind::WaveformYc,
ScopeKind::VectorscopeYuv,
ScopeKind::VectorscopeHls,
ScopeKind::Histogram,
ScopeKind::ParadeRgb,
ScopeKind::ParadeYuv,
];
pub fn name(self) -> &'static str {
match self {
ScopeKind::WaveformRgb => "waveformRgb",
ScopeKind::WaveformLuma => "waveformLuma",
ScopeKind::WaveformYc => "waveformYc",
ScopeKind::VectorscopeYuv => "vectorscopeYuv",
ScopeKind::VectorscopeHls => "vectorscopeHls",
ScopeKind::Histogram => "histogram",
ScopeKind::ParadeRgb => "paradeRgb",
ScopeKind::ParadeYuv => "paradeYuv",
}
}
pub fn label(self) -> &'static str {
match self {
ScopeKind::WaveformRgb => "Waveform (RGB)",
ScopeKind::WaveformLuma => "Waveform (Luma)",
ScopeKind::WaveformYc => "Waveform (YC)",
ScopeKind::VectorscopeYuv => "Vectorscope YUV",
ScopeKind::VectorscopeHls => "Vectorscope HLS",
ScopeKind::Histogram => "Histogram",
ScopeKind::ParadeRgb => "Parade (RGB)",
ScopeKind::ParadeYuv => "Parade (YUV)",
}
}
pub fn from_name(s: &str) -> Option<ScopeKind> {
let n = s.to_ascii_lowercase().replace([' ', '(', ')', '_', '-'], "");
Self::ALL.iter().copied().find(|k| k.name().to_ascii_lowercase() == n || k.label().to_ascii_lowercase().replace([' ', '(', ')'], "") == n)
}
/// Vectorscopes are square.
pub fn is_vectorscope(self) -> bool {
matches!(self, ScopeKind::VectorscopeYuv | ScopeKind::VectorscopeHls)
}
}
/// Scope options.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ScopeOpts {
pub standard: ColorStandard,
/// Float scale (−0.25…1.25) instead of 8-bit (0…1).
pub float: bool,
/// Clamp the signal to 0…1 before plotting.
pub clamp: bool,
/// Plot size.
pub width: u32,
pub height: u32,
}
impl Default for ScopeOpts {
fn default() -> Self {
ScopeOpts { standard: ColorStandard::Rec709, float: false, clamp: true, width: 256, height: 256 }
}
}
impl ScopeOpts {
/// Signal range shown on the value axis.
pub fn range(&self) -> (f32, f32) {
if self.float { (-0.25, 1.25) } else { (0.0, 1.0) }
}
/// Plot row (0 = top) of a value, or `None` when off-scale.
fn row(&self, v: f32) -> Option<usize> {
let (lo, hi) = self.range();
let f = (v - lo) / (hi - lo);
if !(0.0..=1.0).contains(&f) {
return None;
}
let r = ((1.0 - f) * (self.height - 1) as f32).round() as usize;
Some(r.min(self.height as usize - 1))
}
}
/// A computed scope: three density planes (`planes[c][y * width + x]`, 0…1).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Scope {
pub width: u32,
pub height: u32,
pub planes: [Vec<f32>; 3],
/// Raw counts before normalisation (same layout), for statistics and tests.
pub counts: [Vec<u32>; 3],
}
impl Scope {
fn new(w: u32, h: u32) -> Scope {
let n = w as usize * h as usize;
Scope { width: w, height: h, planes: [vec![0.0; n], vec![0.0; n], vec![0.0; n]], counts: [vec![0; n], vec![0; n], vec![0; n]] }
}
fn merge(mut self, o: Scope) -> Scope {
for c in 0..3 {
for (a, b) in self.counts[c].iter_mut().zip(&o.counts[c]) {
*a += *b;
}
}
self
}
fn normalise(&mut self) {
// Logarithmic density (like a phosphor trace) so faint traces stay visible next to
// dense ones.
let max = self.counts.iter().flat_map(|p| p.iter()).copied().max().unwrap_or(0).max(1) as f32;
let k = 1.0 / (1.0 + max).ln();
for c in 0..3 {
self.planes[c] = self.counts[c].iter().map(|&n| if n == 0 { 0.0 } else { (0.25 + 0.75 * (1.0 + n as f32).ln() * k).min(1.0) }).collect();
}
}
/// Total count of plane `c`.
pub fn total(&self, c: usize) -> u64 {
self.counts[c].iter().map(|&n| n as u64).sum()
}
/// The cell of plane `c` with the most samples, as (x, y).
pub fn peak(&self, c: usize) -> (u32, u32) {
let (i, _) = self.counts[c].iter().enumerate().max_by_key(|(_, n)| **n).unwrap_or((0, &0));
(i as u32 % self.width.max(1), i as u32 / self.width.max(1))
}
/// Straight RGBA8 picture: plane 0 red, 1 green, 2 blue (`mono`: all planes white).
pub fn to_rgba8(&self, mono: bool) -> Vec<u8> {
let n = self.width as usize * self.height as usize;
let mut out = vec![0u8; n * 4];
for i in 0..n {
let (r, g, b) = (self.planes[0][i], self.planes[1][i], self.planes[2][i]);
let (r, g, b) = if mono {
let m = r.max(g).max(b);
(m, m, m)
} else {
(r, g, b)
};
let a = r.max(g).max(b);
out[i * 4] = (r.min(1.0) * 255.0) as u8;
out[i * 4 + 1] = (g.min(1.0) * 255.0) as u8;
out[i * 4 + 2] = (b.min(1.0) * 255.0) as u8;
out[i * 4 + 3] = (a.min(1.0) * 255.0) as u8;
}
out
}
}
/// Straight RGB of pixel `i` (transparent pixels read as black), clamped when asked.
#[inline]
fn rgb(img: &Image, i: usize, clamp: bool) -> [f32; 3] {
let p = img.data[i];
let mut c = if p[3] > 1e-6 { [p[0] / p[3], p[1] / p[3], p[2] / p[3]] } else { [0.0; 3] };
// Composite over black, as a monitor shows the frame.
let a = p[3].clamp(0.0, 1.0);
for v in &mut c {
*v *= a;
if clamp {
*v = v.clamp(0.0, 1.0);
}
}
c
}
/// Compute a scope of `img`.
pub fn compute(img: &Image, kind: ScopeKind, o: &ScopeOpts) -> Scope {
let (w, h) = (o.width.max(8), o.height.max(8));
let o = ScopeOpts { width: w, height: h, ..*o };
if img.is_empty() {
return Scope::new(w, h);
}
let iw = img.width as usize;
let rows: Vec<usize> = (0..img.height as usize).collect();
let mut s = rows
.par_chunks(16)
.map(|chunk| {
let mut s = Scope::new(w, h);
for &y in chunk {
for x in 0..iw {
plot(&mut s, kind, &o, rgb(img, y * iw + x, o.clamp), x as f32 / iw as f32);
}
}
s
})
.reduce(|| Scope::new(w, h), Scope::merge);
s.normalise();
s
}
#[inline]
fn bump(s: &mut Scope, c: usize, x: usize, y: usize) {
let i = y * s.width as usize + x.min(s.width as usize - 1);
s.counts[c][i] += 1;
}
/// Add one pixel (straight RGB, at horizontal position `fx` 0…1 in the frame).
fn plot(s: &mut Scope, kind: ScopeKind, o: &ScopeOpts, c: [f32; 3], fx: f32) {
let w = s.width as usize;
let col = |fx: f32, x0: usize, span: usize| x0 + ((fx * span as f32) as usize).min(span - 1);
match kind {
ScopeKind::WaveformRgb => {
let x = col(fx, 0, w);
for (ch, v) in c.iter().enumerate() {
if let Some(y) = o.row(*v) {
bump(s, ch, x, y);
}
}
}
ScopeKind::WaveformLuma => {
let x = col(fx, 0, w);
let yv = o.standard.ycbcr(c)[0];
if let Some(y) = o.row(yv) {
for ch in 0..3 {
bump(s, ch, x, y);
}
}
}
ScopeKind::WaveformYc => {
let x = col(fx, 0, w);
let [yv, cb, cr] = o.standard.ycbcr(c);
if let Some(y) = o.row(yv) {
bump(s, 0, x, y);
bump(s, 1, x, y);
}
if let Some(y) = o.row((cb * cb + cr * cr).sqrt()) {
bump(s, 2, x, y);
}
}
ScopeKind::ParadeRgb | ScopeKind::ParadeYuv => {
let third = (w / 3).max(1);
let vals = if kind == ScopeKind::ParadeRgb {
c
} else {
let [y, cb, cr] = o.standard.ycbcr(c);
// Colour differences centred on mid-scale.
[y, cb + 0.5, cr + 0.5]
};
for (ch, v) in vals.iter().enumerate() {
let x = col(fx, ch * third, third);
if let Some(y) = o.row(*v) {
// RGB parade: tinted per channel; YUV: Y white, Cb blue, Cr red.
match (kind, ch) {
(ScopeKind::ParadeYuv, 0) => {
for p in 0..3 {
bump(s, p, x, y);
}
}
(ScopeKind::ParadeYuv, 1) => bump(s, 2, x, y),
(ScopeKind::ParadeYuv, _) => bump(s, 0, x, y),
_ => bump(s, ch, x, y),
}
}
}
}
ScopeKind::VectorscopeYuv => {
let [_, cb, cr] = o.standard.ycbcr(c);
if let Some((x, y)) = vec_cell(s, cb, cr) {
for ch in 0..3 {
bump(s, ch, x, y);
}
}
}
ScopeKind::VectorscopeHls => {
let (hue, _, sat) = hls(c);
let a = hue.to_radians();
if let Some((x, y)) = vec_cell(s, sat * 0.5 * a.cos(), sat * 0.5 * a.sin()) {
for ch in 0..3 {
bump(s, ch, x, y);
}
}
}
ScopeKind::Histogram => {
let (lo, hi) = o.range();
for (ch, v) in c.iter().enumerate() {
let f = (v - lo) / (hi - lo);
if (0.0..=1.0).contains(&f) {
let x = ((f * (w - 1) as f32).round() as usize).min(w - 1);
// Histograms keep their counts in row 0; the bars are drawn by `histogram_bars`.
bump(s, ch, x, 0);
}
}
}
}
}
/// Vectorscope cell of (u, v) in −0.5…0.5 (the disc fills the plot; +v up).
fn vec_cell(s: &Scope, u: f32, v: f32) -> Option<(usize, usize)> {
let (w, h) = (s.width as f32, s.height as f32);
let x = ((u + 0.5) * (w - 1.0)).round();
let y = ((0.5 - v) * (h - 1.0)).round();
if x < 0.0 || y < 0.0 || x >= w || y >= h {
return None;
}
Some((x as usize, y as usize))
}
/// (hue degrees, lightness, saturation) of straight RGB (HLS).
pub fn hls(c: [f32; 3]) -> (f32, f32, f32) {
let (r, g, b) = (c[0], c[1], c[2]);
let max = r.max(g).max(b);
let min = r.min(g).min(b);
let l = (max + min) * 0.5;
let d = max - min;
if d < 1e-6 {
return (0.0, l, 0.0);
}
let s = if l > 0.5 { d / (2.0 - max - min).max(1e-6) } else { d / (max + min).max(1e-6) };
let h = if max == r {
((g - b) / d).rem_euclid(6.0)
} else if max == g {
(b - r) / d + 2.0
} else {
(r - g) / d + 4.0
};
(h * 60.0, l, s.min(1.0))
}
/// Histogram bars: per channel, `bins` counts across the value range (counts in a
/// [`ScopeKind::Histogram`] scope's first row).
pub fn histogram_bars(s: &Scope) -> [Vec<u32>; 3] {
let w = s.width as usize;
[s.counts[0][..w].to_vec(), s.counts[1][..w].to_vec(), s.counts[2][..w].to_vec()]
}
/// Where a vectorscope target (75 % colour bars' primaries and secondaries) sits, as (u, v) in
/// −0.5…0.5, for the graticule.
pub fn vector_targets(standard: ColorStandard, hls_mode: bool) -> Vec<(&'static str, [f32; 2])> {
let cols: [(&str, [f32; 3]); 6] = [
("R", [0.75, 0.0, 0.0]),
("Mg", [0.75, 0.0, 0.75]),
("B", [0.0, 0.0, 0.75]),
("Cy", [0.0, 0.75, 0.75]),
("G", [0.0, 0.75, 0.0]),
("Yl", [0.75, 0.75, 0.0]),
];
cols.iter()
.map(|(n, c)| {
if hls_mode {
let (h, _, s) = hls(*c);
let a = h.to_radians();
(*n, [s * 0.5 * a.cos(), s * 0.5 * a.sin()])
} else {
let [_, cb, cr] = standard.ycbcr(*c);
(*n, [cb, cr])
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn solid(w: u32, h: u32, c: [f32; 3]) -> Image {
Image::filled(w, h, [c[0], c[1], c[2], 1.0])
}
#[test]
fn waveform_of_flat_grey_is_one_line() {
let img = solid(64, 32, [0.5, 0.5, 0.5]);
let o = ScopeOpts { width: 64, height: 101, ..Default::default() };
let s = compute(&img, ScopeKind::WaveformLuma, &o);
// 0.5 sits on row 50 of 0..=100; every sample lands there.
assert_eq!(s.total(0), 64 * 32);
for x in 0..64 {
assert_eq!(s.counts[0][50 * 64 + x], 32);
}
}
#[test]
fn waveform_rgb_ramps_follow_columns() {
// Horizontal ramp: column x has value x / (w - 1).
let w = 101;
let mut img = Image::new(w, 4);
for y in 0..4 {
for x in 0..w {
let v = x as f32 / (w - 1) as f32;
img.set(x, y, [v, 1.0 - v, 0.0, 1.0]);
}
}
let o = ScopeOpts { width: w, height: 101, ..Default::default() };
let s = compute(&img, ScopeKind::WaveformRgb, &o);
for x in 0..w as usize {
// Red: row 100 - x; green: row x.
assert_eq!(s.counts[0][(100 - x) * w as usize + x], 4, "red column {x}");
assert_eq!(s.counts[1][x * w as usize + x], 4, "green column {x}");
}
}
#[test]
fn luma_coefficients_per_standard() {
let red = [1.0, 0.0, 0.0];
assert!((ColorStandard::Rec601.ycbcr(red)[0] - 0.299).abs() < 1e-6);
assert!((ColorStandard::Rec709.ycbcr(red)[0] - 0.2126).abs() < 1e-6);
assert!((ColorStandard::Rec2020.ycbcr(red)[0] - 0.2627).abs() < 1e-6);
// Pure red has Cr = +0.5 in every standard, Cb negative.
for st in ColorStandard::ALL {
let [_, cb, cr] = st.ycbcr(red);
assert!((cr - 0.5).abs() < 1e-6);
assert!(cb < 0.0);
// White and grey have no chroma.
let [_, cb, cr] = st.ycbcr([0.6, 0.6, 0.6]);
assert!(cb.abs() < 1e-6 && cr.abs() < 1e-6);
}
}
#[test]
fn vectorscope_places_red_on_its_target() {
let img = solid(16, 16, [1.0, 0.0, 0.0]);
let o = ScopeOpts { width: 101, height: 101, ..Default::default() };
let s = compute(&img, ScopeKind::VectorscopeYuv, &o);
let [_, cb, cr] = ColorStandard::Rec709.ycbcr([1.0, 0.0, 0.0]);
let (x, y) = s.peak(0);
assert_eq!(x, ((cb + 0.5) * 100.0).round() as u32);
assert_eq!(y, ((0.5 - cr) * 100.0).round() as u32);
// Grey sits in the centre.
let g = compute(&solid(8, 8, [0.3, 0.3, 0.3]), ScopeKind::VectorscopeYuv, &o);
assert_eq!(g.peak(0), (50, 50));
// HLS: fully saturated green at 120° on the rim.
let h = compute(&solid(8, 8, [0.0, 1.0, 0.0]), ScopeKind::VectorscopeHls, &o);
let a = 120f32.to_radians();
assert_eq!(h.peak(0), (((0.5 * a.cos() + 0.5) * 100.0).round() as u32, ((0.5 - 0.5 * a.sin()) * 100.0).round() as u32));
}
#[test]
fn histogram_counts_and_clamp() {
// Half black, half white.
let mut img = Image::new(10, 10);
for y in 0..10 {
for x in 0..10 {
let v = if x < 5 { 0.0 } else { 1.0 };
img.set(x, y, [v, v, v, 1.0]);
}
}
let o = ScopeOpts { width: 256, height: 64, ..Default::default() };
let s = compute(&img, ScopeKind::Histogram, &o);
let bars = histogram_bars(&s);
for b in &bars {
assert_eq!(b[0], 50);
assert_eq!(b[255], 50);
assert_eq!(b.iter().sum::<u32>(), 100);
}
// Super-white: off scale in 8-bit unclamped, on the float scale, pinned to 1 clamped.
let hot = solid(4, 4, [1.2, 1.2, 1.2]);
let unclamped = ScopeOpts { clamp: false, ..o };
assert_eq!(compute(&hot, ScopeKind::Histogram, &unclamped).total(0), 0);
let fl = ScopeOpts { clamp: false, float: true, ..o };
let f = histogram_bars(&compute(&hot, ScopeKind::Histogram, &fl));
let expect = (((1.2 + 0.25) / 1.5) * 255.0f32).round() as usize;
assert_eq!(f[0][expect], 16);
let c = histogram_bars(&compute(&hot, ScopeKind::Histogram, &o));
assert_eq!(c[0][255], 16);
}
#[test]
fn parade_splits_channels_into_thirds() {
let img = solid(30, 3, [1.0, 0.5, 0.0]);
let o = ScopeOpts { width: 90, height: 101, ..Default::default() };
let s = compute(&img, ScopeKind::ParadeRgb, &o);
// Red trace in the first third at the top, green mid-height in the second, blue at the
// bottom of the third.
let (rx, ry) = s.peak(0);
let (gx, gy) = s.peak(1);
let (bx, by) = s.peak(2);
assert!(rx < 30 && ry == 0);
assert!((30..60).contains(&gx) && gy == 50);
assert!((60..90).contains(&bx) && by == 100);
let yuv = compute(&solid(30, 3, [0.5, 0.5, 0.5]), ScopeKind::ParadeYuv, &o);
// Grey: Y = 0.5 and both colour differences at mid-scale.
assert_eq!(yuv.peak(1).1, 50);
assert_eq!(yuv.peak(2).1, 50);
}
#[test]
fn yc_waveform_has_chroma_trace() {
let img = solid(8, 8, [1.0, 0.0, 0.0]);
let o = ScopeOpts { width: 8, height: 101, ..Default::default() };
let s = compute(&img, ScopeKind::WaveformYc, &o);
let [y, cb, cr] = ColorStandard::Rec709.ycbcr([1.0, 0.0, 0.0]);
assert_eq!(s.peak(0).1, ((1.0 - y) * 100.0).round() as u32);
assert_eq!(s.peak(2).1, ((1.0 - (cb * cb + cr * cr).sqrt()) * 100.0).round() as u32);
}
}