ff_decode/analysis/
histogram_extractor.rs1use std::path::{Path, PathBuf};
4use std::time::Duration;
5
6use ff_format::PixelFormat;
7
8use crate::{DecodeError, VideoDecoder};
9
10#[derive(Debug, Clone)]
17pub struct FrameHistogram {
18 pub timestamp: Duration,
20 pub r: [u32; 256],
22 pub g: [u32; 256],
24 pub b: [u32; 256],
26 pub luma: [u32; 256],
28}
29
30pub struct HistogramExtractor {
51 input: PathBuf,
52 interval_frames: u32,
53}
54
55impl HistogramExtractor {
56 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 #[must_use]
78 pub fn interval_frames(self, n: u32) -> Self {
79 Self {
80 interval_frames: n,
81 ..self
82 }
83 }
84
85 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
129pub(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 #[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 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}