Skip to main content

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