Skip to main content

ff_decode/scope/
mod.rs

1//! Video scope analysis tools.
2//!
3//! Provides frame-level pixel analysis for video quality and colour monitoring.
4//! All functions operate directly on [`ff_format::VideoFrame`] data — no `FFmpeg`
5//! dependency; pure Rust pixel arithmetic.
6//!
7//! Currently implemented:
8//! - [`ScopeAnalyzer::waveform`] — luminance waveform monitor (Y values per column)
9//! - [`ScopeAnalyzer::vectorscope`] — Cb/Cr chroma scatter data
10//! - [`ScopeAnalyzer::rgb_parade`] — per-channel RGB waveform (parade)
11//! - [`ScopeAnalyzer::histogram`] — 256-bin luminance and per-channel RGB histogram
12//!
13
14use ff_format::{PixelFormat, VideoFrame};
15
16/// Scope analysis utilities for decoded video frames.
17///
18/// All methods are associated functions (no instance state).
19pub struct ScopeAnalyzer;
20
21/// 256-bin luminance and per-channel RGB histogram.
22pub struct Histogram {
23    /// Red channel bin counts (8-bit value → bin index).
24    pub r: [u32; 256],
25    /// Green channel bin counts (8-bit value → bin index).
26    pub g: [u32; 256],
27    /// Blue channel bin counts (8-bit value → bin index).
28    pub b: [u32; 256],
29    /// Luminance bin counts (Y plane for YUV frames; BT.601-derived for others).
30    pub luma: [u32; 256],
31}
32
33/// Per-channel waveform monitor data (RGB parade).
34///
35/// Each channel has the same shape as [`ScopeAnalyzer::waveform`]:
36/// outer index = column (x), inner values are normalised channel values `[0.0, 1.0]`.
37pub struct RgbParade {
38    /// Red channel: column-major waveform values in `[0.0, 1.0]`.
39    pub r: Vec<Vec<f32>>,
40    /// Green channel: column-major waveform values in `[0.0, 1.0]`.
41    pub g: Vec<Vec<f32>>,
42    /// Blue channel: column-major waveform values in `[0.0, 1.0]`.
43    pub b: Vec<Vec<f32>>,
44}
45
46impl ScopeAnalyzer {
47    /// Compute waveform monitor data for `frame`.
48    ///
49    /// Returns a [`Vec`] of length `frame.width()`. Each inner [`Vec`] contains
50    /// the normalised Y (luma) values `[0.0, 1.0]` of every pixel in that column,
51    /// ordered top-to-bottom.
52    ///
53    /// Only `yuv420p`, `yuv422p`, and `yuv444p` pixel formats are supported.
54    /// Returns an empty [`Vec`] for unsupported formats or if Y-plane data is
55    /// unavailable.
56    #[must_use]
57    pub fn waveform(frame: &VideoFrame) -> Vec<Vec<f32>> {
58        match frame.format() {
59            PixelFormat::Yuv420p | PixelFormat::Yuv422p | PixelFormat::Yuv444p => {}
60            _ => return Vec::new(),
61        }
62
63        let Some(y_data) = frame.plane(0) else {
64            return Vec::new();
65        };
66        let Some(stride) = frame.stride(0) else {
67            return Vec::new();
68        };
69
70        let w = frame.width() as usize;
71        let h = frame.height() as usize;
72        let mut result = vec![Vec::with_capacity(h); w];
73
74        for row in 0..h {
75            for col in 0..w {
76                let luma = f32::from(y_data[row * stride + col]) / 255.0;
77                result[col].push(luma);
78            }
79        }
80
81        result
82    }
83
84    /// Compute vectorscope data for `frame`.
85    ///
86    /// Returns a [`Vec`] of `(cb, cr)` pairs, one per chroma sample, with both
87    /// values normalised to `[-0.5, 0.5]`.
88    ///
89    /// Chroma dimensions vary by format:
90    /// - `yuv420p` — `(width/2) × (height/2)` samples
91    /// - `yuv422p` — `(width/2) × height` samples
92    /// - `yuv444p` — `width × height` samples
93    ///
94    /// Returns an empty [`Vec`] for unsupported formats or if chroma plane data
95    /// is unavailable.
96    #[must_use]
97    pub fn vectorscope(frame: &VideoFrame) -> Vec<(f32, f32)> {
98        let w = frame.width() as usize;
99        let h = frame.height() as usize;
100
101        let (cb_w, cb_h) = match frame.format() {
102            PixelFormat::Yuv420p => (w.div_ceil(2), h.div_ceil(2)),
103            PixelFormat::Yuv422p => (w.div_ceil(2), h),
104            PixelFormat::Yuv444p => (w, h),
105            _ => return Vec::new(),
106        };
107
108        let Some(u_plane) = frame.plane(1) else {
109            return Vec::new();
110        };
111        let Some(v_plane) = frame.plane(2) else {
112            return Vec::new();
113        };
114        let Some(u_stride) = frame.stride(1) else {
115            return Vec::new();
116        };
117        let Some(v_stride) = frame.stride(2) else {
118            return Vec::new();
119        };
120
121        let mut result = Vec::with_capacity(cb_w * cb_h);
122        for row in 0..cb_h {
123            for col in 0..cb_w {
124                let cb = f32::from(u_plane[row * u_stride + col]) / 255.0 - 0.5;
125                let cr = f32::from(v_plane[row * v_stride + col]) / 255.0 - 0.5;
126                result.push((cb, cr));
127            }
128        }
129        result
130    }
131
132    /// Compute RGB parade data for `frame`.
133    ///
134    /// Each pixel is converted from YUV to RGB using the BT.601 full-range matrix
135    /// before sampling. Returns an [`RgbParade`] whose `r`, `g`, and `b` fields
136    /// each have the same column-major shape as [`ScopeAnalyzer::waveform`].
137    ///
138    /// Only `yuv420p`, `yuv422p`, and `yuv444p` pixel formats are supported.
139    /// Returns `RgbParade { r: vec![], g: vec![], b: vec![] }` for unsupported
140    /// formats or if plane data is unavailable.
141    #[must_use]
142    pub fn rgb_parade(frame: &VideoFrame) -> RgbParade {
143        let width = frame.width() as usize;
144        let height = frame.height() as usize;
145        let fmt = frame.format();
146
147        match fmt {
148            PixelFormat::Yuv420p | PixelFormat::Yuv422p | PixelFormat::Yuv444p => {}
149            _ => {
150                return RgbParade {
151                    r: vec![],
152                    g: vec![],
153                    b: vec![],
154                };
155            }
156        }
157
158        let Some(luma) = frame.plane(0) else {
159            return RgbParade {
160                r: vec![],
161                g: vec![],
162                b: vec![],
163            };
164        };
165        let Some(u_plane) = frame.plane(1) else {
166            return RgbParade {
167                r: vec![],
168                g: vec![],
169                b: vec![],
170            };
171        };
172        let Some(v_plane) = frame.plane(2) else {
173            return RgbParade {
174                r: vec![],
175                g: vec![],
176                b: vec![],
177            };
178        };
179        let Some(luma_stride) = frame.stride(0) else {
180            return RgbParade {
181                r: vec![],
182                g: vec![],
183                b: vec![],
184            };
185        };
186        let Some(u_stride) = frame.stride(1) else {
187            return RgbParade {
188                r: vec![],
189                g: vec![],
190                b: vec![],
191            };
192        };
193        let Some(v_stride) = frame.stride(2) else {
194            return RgbParade {
195                r: vec![],
196                g: vec![],
197                b: vec![],
198            };
199        };
200
201        let mut red_cols = vec![Vec::with_capacity(height); width];
202        let mut grn_cols = vec![Vec::with_capacity(height); width];
203        let mut blu_cols = vec![Vec::with_capacity(height); width];
204
205        for row in 0..height {
206            for col in 0..width {
207                let (chr_row, chr_col) = match fmt {
208                    PixelFormat::Yuv420p => (row / 2, col / 2),
209                    PixelFormat::Yuv422p => (row, col / 2),
210                    _ => (row, col),
211                };
212
213                let yy = f32::from(luma[row * luma_stride + col]);
214                let uu = f32::from(u_plane[chr_row * u_stride + chr_col]) - 128.0;
215                let vv = f32::from(v_plane[chr_row * v_stride + chr_col]) - 128.0;
216
217                let r = (yy + 1.402 * vv).clamp(0.0, 255.0) / 255.0;
218                let g = (yy - 0.344 * uu - 0.714 * vv).clamp(0.0, 255.0) / 255.0;
219                let b = (yy + 1.772 * uu).clamp(0.0, 255.0) / 255.0;
220
221                red_cols[col].push(r);
222                grn_cols[col].push(g);
223                blu_cols[col].push(b);
224            }
225        }
226
227        RgbParade {
228            r: red_cols,
229            g: grn_cols,
230            b: blu_cols,
231        }
232    }
233
234    /// Compute a 256-bin histogram for each channel and for luminance.
235    ///
236    /// For YUV frames luma is read directly from the Y plane; R, G, and B are
237    /// computed via BT.601 full-range conversion. Bins are indexed by the raw
238    /// 8-bit value `[0, 255]`.
239    ///
240    /// Only `yuv420p`, `yuv422p`, and `yuv444p` pixel formats are supported.
241    /// Returns a zeroed [`Histogram`] for unsupported formats or if plane data
242    /// is unavailable.
243    #[must_use]
244    pub fn histogram(frame: &VideoFrame) -> Histogram {
245        let mut hist = Histogram {
246            r: [0; 256],
247            g: [0; 256],
248            b: [0; 256],
249            luma: [0; 256],
250        };
251
252        let width = frame.width() as usize;
253        let height = frame.height() as usize;
254        let fmt = frame.format();
255
256        match fmt {
257            PixelFormat::Yuv420p | PixelFormat::Yuv422p | PixelFormat::Yuv444p => {}
258            _ => return hist,
259        }
260
261        let Some(luma_plane) = frame.plane(0) else {
262            return hist;
263        };
264        let Some(u_plane) = frame.plane(1) else {
265            return hist;
266        };
267        let Some(v_plane) = frame.plane(2) else {
268            return hist;
269        };
270        let Some(luma_stride) = frame.stride(0) else {
271            return hist;
272        };
273        let Some(u_stride) = frame.stride(1) else {
274            return hist;
275        };
276        let Some(v_stride) = frame.stride(2) else {
277            return hist;
278        };
279
280        for row in 0..height {
281            for col in 0..width {
282                let (chr_row, chr_col) = match fmt {
283                    PixelFormat::Yuv420p => (row / 2, col / 2),
284                    PixelFormat::Yuv422p => (row, col / 2),
285                    _ => (row, col),
286                };
287
288                let y_px = luma_plane[row * luma_stride + col];
289                let u_px = u_plane[chr_row * u_stride + chr_col];
290                let v_px = v_plane[chr_row * v_stride + chr_col];
291
292                hist.luma[usize::from(y_px)] += 1;
293
294                // BT.601 full-range integer approximation (10-bit scaling).
295                let yy_int = i32::from(y_px);
296                let u_diff = i32::from(u_px) - 128;
297                let v_diff = i32::from(v_px) - 128;
298
299                let red_bin =
300                    usize::try_from((yy_int + ((1436 * v_diff) >> 10)).clamp(0, 255)).unwrap_or(0);
301                let grn_bin = usize::try_from(
302                    (yy_int - ((352 * u_diff) >> 10) - ((731 * v_diff) >> 10)).clamp(0, 255),
303                )
304                .unwrap_or(0);
305                let blu_bin =
306                    usize::try_from((yy_int + ((1815 * u_diff) >> 10)).clamp(0, 255)).unwrap_or(0);
307
308                hist.r[red_bin] += 1;
309                hist.g[grn_bin] += 1;
310                hist.b[blu_bin] += 1;
311            }
312        }
313
314        hist
315    }
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321    use ff_format::{PixelFormat, PooledBuffer, Timestamp, VideoFrame};
322
323    fn make_yuv420p_frame(w: u32, h: u32, fill_y: u8) -> VideoFrame {
324        let stride = w as usize;
325        let uv_stride = (w as usize + 1) / 2;
326        let uv_h = (h as usize + 1) / 2;
327        VideoFrame::new(
328            vec![
329                PooledBuffer::standalone(vec![fill_y; stride * h as usize]),
330                PooledBuffer::standalone(vec![128u8; uv_stride * uv_h]),
331                PooledBuffer::standalone(vec![128u8; uv_stride * uv_h]),
332            ],
333            vec![stride, uv_stride, uv_stride],
334            w,
335            h,
336            PixelFormat::Yuv420p,
337            Timestamp::default(),
338            true,
339        )
340        .unwrap()
341    }
342
343    #[test]
344    fn waveform_grey_frame_should_return_half_luma_values() {
345        let frame = make_yuv420p_frame(4, 4, 128);
346        let wf = ScopeAnalyzer::waveform(&frame);
347        assert_eq!(wf.len(), 4, "result must have one inner Vec per column");
348        for col in &wf {
349            assert_eq!(col.len(), 4, "each column must have one value per row");
350            for &v in col {
351                let expected = 128.0 / 255.0;
352                assert!(
353                    (v - expected).abs() < 1e-6,
354                    "grey Y=128 must map to {expected:.6}, got {v}"
355                );
356            }
357        }
358    }
359
360    #[test]
361    fn waveform_gradient_frame_should_have_increasing_column_means() {
362        // Build a 4×4 frame where column c has Y = c * 64 (0, 64, 128, 192).
363        let w = 4u32;
364        let h = 4u32;
365        let stride = w as usize;
366        let uv_stride = (w as usize + 1) / 2;
367        let uv_h = (h as usize + 1) / 2;
368        let mut y_plane = vec![0u8; stride * h as usize];
369        for row in 0..h as usize {
370            for col in 0..w as usize {
371                y_plane[row * stride + col] = (col as u8) * 64;
372            }
373        }
374        let frame = VideoFrame::new(
375            vec![
376                PooledBuffer::standalone(y_plane),
377                PooledBuffer::standalone(vec![128u8; uv_stride * uv_h]),
378                PooledBuffer::standalone(vec![128u8; uv_stride * uv_h]),
379            ],
380            vec![stride, uv_stride, uv_stride],
381            w,
382            h,
383            PixelFormat::Yuv420p,
384            Timestamp::default(),
385            true,
386        )
387        .unwrap();
388
389        let wf = ScopeAnalyzer::waveform(&frame);
390        assert_eq!(wf.len(), 4);
391        let means: Vec<f32> = wf
392            .iter()
393            .map(|col| col.iter().sum::<f32>() / col.len() as f32)
394            .collect();
395        for i in 1..means.len() {
396            assert!(
397                means[i] > means[i - 1],
398                "column means must increase left to right: {means:?}"
399            );
400        }
401    }
402
403    #[test]
404    fn waveform_dimensions_should_match_frame_resolution() {
405        let frame = make_yuv420p_frame(16, 8, 100);
406        let wf = ScopeAnalyzer::waveform(&frame);
407        assert_eq!(wf.len(), 16, "must have one Vec per column (width)");
408        for col in &wf {
409            assert_eq!(
410                col.len(),
411                8,
412                "each column must have one value per row (height)"
413            );
414        }
415    }
416
417    #[test]
418    fn waveform_unsupported_format_should_return_empty() {
419        let frame = VideoFrame::empty(4, 4, PixelFormat::Rgba).unwrap();
420        let wf = ScopeAnalyzer::waveform(&frame);
421        assert!(
422            wf.is_empty(),
423            "unsupported pixel format must return empty Vec, got len={}",
424            wf.len()
425        );
426    }
427
428    #[test]
429    fn waveform_yuv422p_should_be_supported() {
430        let w = 4u32;
431        let h = 4u32;
432        let y_stride = w as usize;
433        let uv_stride = (w as usize + 1) / 2;
434        let frame = VideoFrame::new(
435            vec![
436                PooledBuffer::standalone(vec![200u8; y_stride * h as usize]),
437                PooledBuffer::standalone(vec![128u8; uv_stride * h as usize]),
438                PooledBuffer::standalone(vec![128u8; uv_stride * h as usize]),
439            ],
440            vec![y_stride, uv_stride, uv_stride],
441            w,
442            h,
443            PixelFormat::Yuv422p,
444            Timestamp::default(),
445            true,
446        )
447        .unwrap();
448        let wf = ScopeAnalyzer::waveform(&frame);
449        assert_eq!(wf.len(), 4, "yuv422p must return result of length=width");
450    }
451
452    #[test]
453    fn vectorscope_grey_frame_should_return_near_zero_pairs() {
454        // U=V=128 → (128/255-0.5, 128/255-0.5) ≈ (0.00196, 0.00196)
455        let frame = make_yuv420p_frame(4, 4, 128);
456        let vs = ScopeAnalyzer::vectorscope(&frame);
457        assert_eq!(vs.len(), 4, "yuv420p 4×4 → 2×2 chroma = 4 pairs");
458        for &(cb, cr) in &vs {
459            let expected = 128.0_f32 / 255.0 - 0.5;
460            assert!(
461                (cb - expected).abs() < 1e-6,
462                "cb must be ≈{expected:.6}, got {cb}"
463            );
464            assert!(
465                (cr - expected).abs() < 1e-6,
466                "cr must be ≈{expected:.6}, got {cr}"
467            );
468        }
469    }
470
471    #[test]
472    fn vectorscope_yuv420p_should_have_quarter_sample_count() {
473        let frame = make_yuv420p_frame(8, 6, 100);
474        let vs = ScopeAnalyzer::vectorscope(&frame);
475        // chroma: (8+1)/2=4 × (6+1)/2=3 = 12
476        assert_eq!(vs.len(), 12, "yuv420p 8×6 must produce 4×3=12 chroma pairs");
477    }
478
479    #[test]
480    fn vectorscope_yuv422p_should_have_half_width_sample_count() {
481        let w = 4u32;
482        let h = 4u32;
483        let y_stride = w as usize;
484        let uv_stride = (w as usize + 1) / 2;
485        let frame = VideoFrame::new(
486            vec![
487                PooledBuffer::standalone(vec![200u8; y_stride * h as usize]),
488                PooledBuffer::standalone(vec![128u8; uv_stride * h as usize]),
489                PooledBuffer::standalone(vec![128u8; uv_stride * h as usize]),
490            ],
491            vec![y_stride, uv_stride, uv_stride],
492            w,
493            h,
494            PixelFormat::Yuv422p,
495            Timestamp::default(),
496            true,
497        )
498        .unwrap();
499        let vs = ScopeAnalyzer::vectorscope(&frame);
500        // chroma: 2×4 = 8 pairs
501        assert_eq!(vs.len(), 8, "yuv422p 4×4 must produce 2×4=8 chroma pairs");
502    }
503
504    #[test]
505    fn vectorscope_yuv444p_should_have_full_sample_count() {
506        let w = 4u32;
507        let h = 4u32;
508        let stride = w as usize;
509        let frame = VideoFrame::new(
510            vec![
511                PooledBuffer::standalone(vec![50u8; stride * h as usize]),
512                PooledBuffer::standalone(vec![128u8; stride * h as usize]),
513                PooledBuffer::standalone(vec![128u8; stride * h as usize]),
514            ],
515            vec![stride, stride, stride],
516            w,
517            h,
518            PixelFormat::Yuv444p,
519            Timestamp::default(),
520            true,
521        )
522        .unwrap();
523        let vs = ScopeAnalyzer::vectorscope(&frame);
524        assert_eq!(vs.len(), 16, "yuv444p 4×4 must produce 4×4=16 chroma pairs");
525    }
526
527    #[test]
528    fn vectorscope_unsupported_format_should_return_empty() {
529        let frame = VideoFrame::empty(4, 4, PixelFormat::Rgba).unwrap();
530        let vs = ScopeAnalyzer::vectorscope(&frame);
531        assert!(
532            vs.is_empty(),
533            "unsupported pixel format must return empty Vec, got len={}",
534            vs.len()
535        );
536    }
537
538    // YUV (full-range BT.601) values for a pure red frame (R=255,G=0,B=0):
539    //   Y=76, Cb=85, Cr=255
540    // Decoded: r≈(76+1.402*(255-128))/255≈0.996, g≈0.0, b≈0.0
541    fn make_red_yuv420p_frame(w: u32, h: u32) -> VideoFrame {
542        let stride = w as usize;
543        let uv_stride = w.div_ceil(2) as usize;
544        let uv_h = h.div_ceil(2) as usize;
545        VideoFrame::new(
546            vec![
547                PooledBuffer::standalone(vec![76u8; stride * h as usize]),
548                PooledBuffer::standalone(vec![85u8; uv_stride * uv_h]),
549                PooledBuffer::standalone(vec![255u8; uv_stride * uv_h]),
550            ],
551            vec![stride, uv_stride, uv_stride],
552            w,
553            h,
554            PixelFormat::Yuv420p,
555            Timestamp::default(),
556            true,
557        )
558        .unwrap()
559    }
560
561    #[test]
562    fn rgb_parade_red_frame_should_have_high_r_and_low_g_b() {
563        let frame = make_red_yuv420p_frame(4, 4);
564        let parade = ScopeAnalyzer::rgb_parade(&frame);
565        assert_eq!(parade.r.len(), 4, "r must have one Vec per column");
566        assert_eq!(parade.g.len(), 4, "g must have one Vec per column");
567        assert_eq!(parade.b.len(), 4, "b must have one Vec per column");
568        for col in 0..4 {
569            for &rv in &parade.r[col] {
570                assert!(
571                    rv > 0.9,
572                    "red channel must be near 1.0 for red frame, got {rv}"
573                );
574            }
575            for &gv in &parade.g[col] {
576                assert!(
577                    gv < 0.1,
578                    "green channel must be near 0.0 for red frame, got {gv}"
579                );
580            }
581            for &bv in &parade.b[col] {
582                assert!(
583                    bv < 0.1,
584                    "blue channel must be near 0.0 for red frame, got {bv}"
585                );
586            }
587        }
588    }
589
590    #[test]
591    fn rgb_parade_white_frame_should_have_all_channels_at_one() {
592        // Y=255, Cb=128, Cr=128 → R=G=B=1.0
593        let frame = make_yuv420p_frame(4, 4, 255);
594        let parade = ScopeAnalyzer::rgb_parade(&frame);
595        for col in 0..4 {
596            for (&rv, (&gv, &bv)) in parade.r[col]
597                .iter()
598                .zip(parade.g[col].iter().zip(parade.b[col].iter()))
599            {
600                assert!(
601                    (rv - 1.0).abs() < 1e-5,
602                    "r must be 1.0 for white frame, got {rv}"
603                );
604                assert!(
605                    (gv - 1.0).abs() < 1e-5,
606                    "g must be 1.0 for white frame, got {gv}"
607                );
608                assert!(
609                    (bv - 1.0).abs() < 1e-5,
610                    "b must be 1.0 for white frame, got {bv}"
611                );
612            }
613        }
614    }
615
616    #[test]
617    fn rgb_parade_unsupported_format_should_return_empty() {
618        let frame = VideoFrame::empty(4, 4, PixelFormat::Rgba).unwrap();
619        let parade = ScopeAnalyzer::rgb_parade(&frame);
620        assert!(
621            parade.r.is_empty() && parade.g.is_empty() && parade.b.is_empty(),
622            "unsupported format must return empty parade"
623        );
624    }
625
626    #[test]
627    fn rgb_parade_dimensions_should_match_frame_resolution() {
628        let frame = make_yuv420p_frame(8, 6, 100);
629        let parade = ScopeAnalyzer::rgb_parade(&frame);
630        assert_eq!(parade.r.len(), 8, "r must have width columns");
631        for col in &parade.r {
632            assert_eq!(col.len(), 6, "each column must have height rows");
633        }
634    }
635
636    #[test]
637    fn waveform_yuv444p_should_be_supported() {
638        let w = 4u32;
639        let h = 4u32;
640        let stride = w as usize;
641        let frame = VideoFrame::new(
642            vec![
643                PooledBuffer::standalone(vec![50u8; stride * h as usize]),
644                PooledBuffer::standalone(vec![128u8; stride * h as usize]),
645                PooledBuffer::standalone(vec![128u8; stride * h as usize]),
646            ],
647            vec![stride, stride, stride],
648            w,
649            h,
650            PixelFormat::Yuv444p,
651            Timestamp::default(),
652            true,
653        )
654        .unwrap();
655        let wf = ScopeAnalyzer::waveform(&frame);
656        assert_eq!(wf.len(), 4, "yuv444p must return result of length=width");
657    }
658
659    #[test]
660    fn histogram_uniform_luma_should_concentrate_in_one_bin() {
661        // Y=128, Cb=Cr=128 (grey) — luma bin 128 must hold all pixels.
662        let frame = make_yuv420p_frame(4, 4, 128);
663        let hist = ScopeAnalyzer::histogram(&frame);
664        assert_eq!(
665            hist.luma[128], 16,
666            "all 16 pixels must land in luma bin 128"
667        );
668        let non_128: u32 = hist
669            .luma
670            .iter()
671            .enumerate()
672            .filter(|&(i, _)| i != 128)
673            .map(|(_, &v)| v)
674            .sum();
675        assert_eq!(non_128, 0, "all other luma bins must be zero");
676    }
677
678    #[test]
679    fn histogram_total_luma_count_should_equal_pixel_count() {
680        let frame = make_yuv420p_frame(8, 6, 200);
681        let hist = ScopeAnalyzer::histogram(&frame);
682        let total: u32 = hist.luma.iter().sum();
683        assert_eq!(total, 8 * 6, "total luma bin counts must equal pixel count");
684    }
685
686    #[test]
687    fn histogram_total_rgb_counts_should_equal_pixel_count() {
688        let frame = make_yuv420p_frame(4, 4, 100);
689        let hist = ScopeAnalyzer::histogram(&frame);
690        let r_total: u32 = hist.r.iter().sum();
691        let g_total: u32 = hist.g.iter().sum();
692        let b_total: u32 = hist.b.iter().sum();
693        assert_eq!(r_total, 16, "r bin counts must equal pixel count");
694        assert_eq!(g_total, 16, "g bin counts must equal pixel count");
695        assert_eq!(b_total, 16, "b bin counts must equal pixel count");
696    }
697
698    #[test]
699    fn histogram_unsupported_format_should_return_zeroed() {
700        let frame = VideoFrame::empty(4, 4, PixelFormat::Rgba).unwrap();
701        let hist = ScopeAnalyzer::histogram(&frame);
702        let all_zero = hist.luma.iter().all(|&v| v == 0)
703            && hist.r.iter().all(|&v| v == 0)
704            && hist.g.iter().all(|&v| v == 0)
705            && hist.b.iter().all(|&v| v == 0);
706        assert!(all_zero, "unsupported format must return zeroed histogram");
707    }
708}