Skip to main content

ff_decode/analysis/
histogram_extractor.rs

1//! Per-channel color histogram extraction for video files.
2
3use std::path::{Path, PathBuf};
4use std::time::Duration;
5
6use ff_format::PixelFormat;
7
8use crate::{DecodeError, VideoDecoder};
9
10/// Per-channel color histogram for a single video frame.
11///
12/// Each array has 256 bins (one per 8-bit intensity level).  For an `N × M`
13/// frame the sum of any channel's bins equals `N × M`.
14///
15/// Luma is computed as `Y = 0.299 R + 0.587 G + 0.114 B` (BT.601 coefficients).
16#[derive(Debug, Clone)]
17pub struct FrameHistogram {
18    /// Presentation timestamp of the sampled frame.
19    pub timestamp: Duration,
20    /// Red-channel bin counts.
21    pub r: [u32; 256],
22    /// Green-channel bin counts.
23    pub g: [u32; 256],
24    /// Blue-channel bin counts.
25    pub b: [u32; 256],
26    /// Luma bin counts (BT.601 weighted average of R, G, B).
27    pub luma: [u32; 256],
28}
29
30/// Extracts per-channel color histograms at configurable frame intervals.
31///
32/// Decodes the input video via [`VideoDecoder`] with `RGB24` output conversion
33/// so that histogram accumulation is a simple one-pass loop with no additional
34/// format dispatch.  `FFmpeg`'s `histogram` filter is deliberately **not** used
35/// because it produces video output rather than structured data.
36///
37/// # Examples
38///
39/// ```ignore
40/// use ff_decode::HistogramExtractor;
41///
42/// let histograms = HistogramExtractor::new("video.mp4")
43///     .interval_frames(30)
44///     .run()?;
45///
46/// for h in &histograms {
47///     println!("Frame at {:?}: r[255]={}", h.timestamp, h.r[255]);
48/// }
49/// ```
50pub struct HistogramExtractor {
51    input: PathBuf,
52    interval_frames: u32,
53}
54
55impl HistogramExtractor {
56    /// Creates a new extractor for the given video file.
57    ///
58    /// The default sampling interval is every frame (`interval_frames = 1`).
59    /// Call [`interval_frames`](Self::interval_frames) to sample less frequently.
60    pub fn new(input: impl AsRef<Path>) -> Self {
61        Self {
62            input: input.as_ref().to_path_buf(),
63            interval_frames: 1,
64        }
65    }
66
67    /// Sets the frame sampling interval.
68    ///
69    /// A value of `N` means one histogram is computed per `N` decoded frames.
70    /// For example, `interval_frames(30)` on a 30 fps video yields roughly one
71    /// histogram per second.
72    ///
73    /// Passing `0` causes [`run`](Self::run) to return
74    /// [`DecodeError::AnalysisFailed`].
75    ///
76    /// Default: `1` (every frame).
77    #[must_use]
78    pub fn interval_frames(self, n: u32) -> Self {
79        Self {
80            interval_frames: n,
81            ..self
82        }
83    }
84
85    /// Runs histogram extraction and returns one [`FrameHistogram`] per
86    /// sampled frame.
87    ///
88    /// Frames are decoded as RGB24 internally; all pixel format conversion is
89    /// handled by `FFmpeg`'s software scaler.
90    ///
91    /// # Errors
92    ///
93    /// - [`DecodeError::AnalysisFailed`] — `interval_frames` is `0`, the input
94    ///   file is not found, or a decode error occurs.
95    /// - Any [`DecodeError`] propagated from [`VideoDecoder`].
96    pub fn run(self) -> Result<Vec<FrameHistogram>, DecodeError> {
97        if self.interval_frames == 0 {
98            return Err(DecodeError::AnalysisFailed {
99                reason: "interval_frames must be non-zero".to_string(),
100            });
101        }
102        if !self.input.exists() {
103            return Err(DecodeError::AnalysisFailed {
104                reason: format!("file not found: {}", self.input.display()),
105            });
106        }
107
108        let mut decoder = VideoDecoder::open(&self.input)
109            .output_format(PixelFormat::Rgb24)
110            .build()?;
111
112        let mut results: Vec<FrameHistogram> = Vec::new();
113        let mut frame_index: u32 = 0;
114
115        while let Some(frame) = decoder.decode_one()? {
116            if frame_index.is_multiple_of(self.interval_frames)
117                && let Some(hist) = compute_rgb24_histogram(&frame)
118            {
119                results.push(hist);
120            }
121            frame_index += 1;
122        }
123
124        log::debug!("histogram extraction complete frames={}", results.len());
125        Ok(results)
126    }
127}
128
129/// Computes R, G, B, and luma histograms for a single `RGB24` frame.
130///
131/// Returns `None` when the frame is not `RGB24` or when plane data is
132/// unavailable.
133pub(super) fn compute_rgb24_histogram(frame: &ff_format::VideoFrame) -> Option<FrameHistogram> {
134    if frame.format() != PixelFormat::Rgb24 {
135        return None;
136    }
137    let width = frame.width() as usize;
138    let height = frame.height() as usize;
139    let plane = frame.plane(0)?;
140    let stride = frame.stride(0)?;
141
142    let mut r = [0u32; 256];
143    let mut g = [0u32; 256];
144    let mut b = [0u32; 256];
145    let mut luma = [0u32; 256];
146
147    for row in 0..height {
148        let row_start = row * stride;
149        for col in 0..width {
150            let offset = row_start + col * 3;
151            let rv = plane[offset];
152            let gv = plane[offset + 1];
153            let bv = plane[offset + 2];
154            // f32 can represent all u8 values exactly (mantissa is 23 bits, u8 needs only 8).
155            #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
156            let lv = (0.299_f32
157                .mul_add(
158                    f32::from(rv),
159                    0.587_f32.mul_add(f32::from(gv), 0.114 * f32::from(bv)),
160                )
161                .round() as usize)
162                .min(255);
163            r[usize::from(rv)] += 1;
164            g[usize::from(gv)] += 1;
165            b[usize::from(bv)] += 1;
166            luma[lv] += 1;
167        }
168    }
169
170    Some(FrameHistogram {
171        timestamp: frame.timestamp().as_duration(),
172        r,
173        g,
174        b,
175        luma,
176    })
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    #[test]
184    fn histogram_extractor_missing_file_should_return_analysis_failed() {
185        let result = HistogramExtractor::new("does_not_exist_99999.mp4").run();
186        assert!(
187            matches!(result, Err(DecodeError::AnalysisFailed { .. })),
188            "expected AnalysisFailed for missing file, got {result:?}"
189        );
190    }
191
192    #[test]
193    fn histogram_extractor_zero_interval_should_return_analysis_failed() {
194        let result = HistogramExtractor::new("irrelevant.mp4")
195            .interval_frames(0)
196            .run();
197        assert!(
198            matches!(result, Err(DecodeError::AnalysisFailed { .. })),
199            "expected AnalysisFailed for interval_frames=0, got {result:?}"
200        );
201    }
202
203    #[test]
204    fn histogram_solid_red_frame_should_have_r255_peak() {
205        use ff_format::{PixelFormat, PooledBuffer, Timestamp, VideoFrame};
206
207        let w = 4u32;
208        let h = 4u32;
209        let stride = w as usize * 3;
210        // Solid red: R=255, G=0, B=0.
211        let mut data = vec![0u8; stride * h as usize];
212        for pixel in data.chunks_mut(3) {
213            pixel[0] = 255;
214        }
215        let frame = VideoFrame::new(
216            vec![PooledBuffer::standalone(data)],
217            vec![stride],
218            w,
219            h,
220            PixelFormat::Rgb24,
221            Timestamp::default(),
222            false,
223        )
224        .unwrap();
225
226        let hist = compute_rgb24_histogram(&frame).unwrap();
227        let total = w * h;
228        assert_eq!(
229            hist.r[255], total,
230            "r[255] should equal total pixels for solid-red frame"
231        );
232        assert_eq!(
233            hist.g[0], total,
234            "g[0] should equal total pixels for solid-red frame"
235        );
236        assert_eq!(
237            hist.b[0], total,
238            "b[0] should equal total pixels for solid-red frame"
239        );
240    }
241
242    #[test]
243    fn histogram_bin_sum_should_equal_total_pixels() {
244        use ff_format::{PixelFormat, PooledBuffer, Timestamp, VideoFrame};
245
246        let w = 8u32;
247        let h = 6u32;
248        let stride = w as usize * 3;
249        let mut data = vec![0u8; stride * h as usize];
250        for (i, pixel) in data.chunks_mut(3).enumerate() {
251            pixel[0] = (i.wrapping_mul(17) % 256) as u8;
252            pixel[1] = (i.wrapping_mul(37) % 256) as u8;
253            pixel[2] = (i.wrapping_mul(53) % 256) as u8;
254        }
255        let frame = VideoFrame::new(
256            vec![PooledBuffer::standalone(data)],
257            vec![stride],
258            w,
259            h,
260            PixelFormat::Rgb24,
261            Timestamp::default(),
262            false,
263        )
264        .unwrap();
265
266        let hist = compute_rgb24_histogram(&frame).unwrap();
267        let total = w * h;
268        assert_eq!(
269            hist.r.iter().sum::<u32>(),
270            total,
271            "r bin sum should equal total pixels"
272        );
273        assert_eq!(
274            hist.g.iter().sum::<u32>(),
275            total,
276            "g bin sum should equal total pixels"
277        );
278        assert_eq!(
279            hist.b.iter().sum::<u32>(),
280            total,
281            "b bin sum should equal total pixels"
282        );
283        assert_eq!(
284            hist.luma.iter().sum::<u32>(),
285            total,
286            "luma bin sum should equal total pixels"
287        );
288    }
289}