ff_analysis/analysis/
histogram_extractor.rs1use std::path::{Path, PathBuf};
4use std::time::Duration;
5
6use ff_format::PixelFormat;
7
8use ff_decode::VideoDecoder;
9
10use crate::AnalysisError;
11
12#[derive(Debug, Clone)]
19pub struct FrameHistogram {
20 pub timestamp: Duration,
22 pub r: [u32; 256],
24 pub g: [u32; 256],
26 pub b: [u32; 256],
28 pub luma: [u32; 256],
30}
31
32pub struct HistogramExtractor {
53 input: PathBuf,
54 interval_frames: u32,
55}
56
57impl HistogramExtractor {
58 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 #[must_use]
80 pub fn interval_frames(self, n: u32) -> Self {
81 Self {
82 interval_frames: n,
83 ..self
84 }
85 }
86
87 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
132pub(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 #[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 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}