1use ff_format::{PixelFormat, VideoFrame};
15
16pub struct ScopeAnalyzer;
20
21pub struct Histogram {
23 pub r: [u32; 256],
25 pub g: [u32; 256],
27 pub b: [u32; 256],
29 pub luma: [u32; 256],
31}
32
33pub struct RgbParade {
38 pub r: Vec<Vec<f32>>,
40 pub g: Vec<Vec<f32>>,
42 pub b: Vec<Vec<f32>>,
44}
45
46impl ScopeAnalyzer {
47 #[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 #[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 #[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 #[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 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 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 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 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 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 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 ¶de.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 ¶de.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 ¶de.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 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 ¶de.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 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}