use std::path::{Path, PathBuf};
use std::time::Duration;
use ff_format::PixelFormat;
use crate::{DecodeError, VideoDecoder};
#[derive(Debug, Clone)]
pub struct FrameHistogram {
pub timestamp: Duration,
pub r: [u32; 256],
pub g: [u32; 256],
pub b: [u32; 256],
pub luma: [u32; 256],
}
pub struct HistogramExtractor {
input: PathBuf,
interval_frames: u32,
}
impl HistogramExtractor {
pub fn new(input: impl AsRef<Path>) -> Self {
Self {
input: input.as_ref().to_path_buf(),
interval_frames: 1,
}
}
#[must_use]
pub fn interval_frames(self, n: u32) -> Self {
Self {
interval_frames: n,
..self
}
}
pub fn run(self) -> Result<Vec<FrameHistogram>, DecodeError> {
if self.interval_frames == 0 {
return Err(DecodeError::AnalysisFailed {
reason: "interval_frames must be non-zero".to_string(),
});
}
if !self.input.exists() {
return Err(DecodeError::AnalysisFailed {
reason: format!("file not found: {}", self.input.display()),
});
}
let mut decoder = VideoDecoder::open(&self.input)
.output_format(PixelFormat::Rgb24)
.build()?;
let mut results: Vec<FrameHistogram> = Vec::new();
let mut frame_index: u32 = 0;
while let Some(frame) = decoder.decode_one()? {
if frame_index.is_multiple_of(self.interval_frames)
&& let Some(hist) = compute_rgb24_histogram(&frame)
{
results.push(hist);
}
frame_index += 1;
}
log::debug!("histogram extraction complete frames={}", results.len());
Ok(results)
}
}
pub(super) fn compute_rgb24_histogram(frame: &ff_format::VideoFrame) -> Option<FrameHistogram> {
if frame.format() != PixelFormat::Rgb24 {
return None;
}
let width = frame.width() as usize;
let height = frame.height() as usize;
let plane = frame.plane(0)?;
let stride = frame.stride(0)?;
let mut r = [0u32; 256];
let mut g = [0u32; 256];
let mut b = [0u32; 256];
let mut luma = [0u32; 256];
for row in 0..height {
let row_start = row * stride;
for col in 0..width {
let offset = row_start + col * 3;
let rv = plane[offset];
let gv = plane[offset + 1];
let bv = plane[offset + 2];
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let lv = (0.299_f32
.mul_add(
f32::from(rv),
0.587_f32.mul_add(f32::from(gv), 0.114 * f32::from(bv)),
)
.round() as usize)
.min(255);
r[usize::from(rv)] += 1;
g[usize::from(gv)] += 1;
b[usize::from(bv)] += 1;
luma[lv] += 1;
}
}
Some(FrameHistogram {
timestamp: frame.timestamp().as_duration(),
r,
g,
b,
luma,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn histogram_extractor_missing_file_should_return_analysis_failed() {
let result = HistogramExtractor::new("does_not_exist_99999.mp4").run();
assert!(
matches!(result, Err(DecodeError::AnalysisFailed { .. })),
"expected AnalysisFailed for missing file, got {result:?}"
);
}
#[test]
fn histogram_extractor_zero_interval_should_return_analysis_failed() {
let result = HistogramExtractor::new("irrelevant.mp4")
.interval_frames(0)
.run();
assert!(
matches!(result, Err(DecodeError::AnalysisFailed { .. })),
"expected AnalysisFailed for interval_frames=0, got {result:?}"
);
}
#[test]
fn histogram_solid_red_frame_should_have_r255_peak() {
use ff_format::{PixelFormat, PooledBuffer, Timestamp, VideoFrame};
let w = 4u32;
let h = 4u32;
let stride = w as usize * 3;
let mut data = vec![0u8; stride * h as usize];
for pixel in data.chunks_mut(3) {
pixel[0] = 255;
}
let frame = VideoFrame::new(
vec![PooledBuffer::standalone(data)],
vec![stride],
w,
h,
PixelFormat::Rgb24,
Timestamp::default(),
false,
)
.unwrap();
let hist = compute_rgb24_histogram(&frame).unwrap();
let total = w * h;
assert_eq!(
hist.r[255], total,
"r[255] should equal total pixels for solid-red frame"
);
assert_eq!(
hist.g[0], total,
"g[0] should equal total pixels for solid-red frame"
);
assert_eq!(
hist.b[0], total,
"b[0] should equal total pixels for solid-red frame"
);
}
#[test]
fn histogram_bin_sum_should_equal_total_pixels() {
use ff_format::{PixelFormat, PooledBuffer, Timestamp, VideoFrame};
let w = 8u32;
let h = 6u32;
let stride = w as usize * 3;
let mut data = vec![0u8; stride * h as usize];
for (i, pixel) in data.chunks_mut(3).enumerate() {
pixel[0] = (i.wrapping_mul(17) % 256) as u8;
pixel[1] = (i.wrapping_mul(37) % 256) as u8;
pixel[2] = (i.wrapping_mul(53) % 256) as u8;
}
let frame = VideoFrame::new(
vec![PooledBuffer::standalone(data)],
vec![stride],
w,
h,
PixelFormat::Rgb24,
Timestamp::default(),
false,
)
.unwrap();
let hist = compute_rgb24_histogram(&frame).unwrap();
let total = w * h;
assert_eq!(
hist.r.iter().sum::<u32>(),
total,
"r bin sum should equal total pixels"
);
assert_eq!(
hist.g.iter().sum::<u32>(),
total,
"g bin sum should equal total pixels"
);
assert_eq!(
hist.b.iter().sum::<u32>(),
total,
"b bin sum should equal total pixels"
);
assert_eq!(
hist.luma.iter().sum::<u32>(),
total,
"luma bin sum should equal total pixels"
);
}
}