1#[derive(Debug, Clone)]
12pub struct ChannelHistogram {
13 pub bins: [u32; 256],
15 pub channel: u8,
17 pub min_value: u8,
19 pub max_value: u8,
21 pub mean: f64,
23 pub std_dev: f64,
25}
26
27impl ChannelHistogram {
28 #[must_use]
38 #[allow(clippy::manual_checked_ops)]
39 pub fn compute(data: &[u8], channel: u8, stride: usize, num_channels: usize) -> Self {
40 let ch = channel as usize;
41 let mut bins = [0u32; 256];
42
43 if num_channels == 0 || data.is_empty() {
44 return Self {
45 bins,
46 channel,
47 min_value: 0,
48 max_value: 0,
49 mean: 0.0,
50 std_dev: 0.0,
51 };
52 }
53
54 let total_rows = data.len().checked_div(stride).unwrap_or(0);
58
59 let mut count = 0u64;
60 let mut sum = 0u64;
61
62 for row in 0..total_rows {
63 let row_start = row * stride;
64 let row_end = (row_start + stride).min(data.len());
65 let mut byte_idx = row_start + ch;
66 while byte_idx < row_end {
67 let v = data[byte_idx];
68 bins[v as usize] += 1;
69 count += 1;
70 sum += u64::from(v);
71 byte_idx += num_channels;
72 }
73 }
74
75 let mean = if count > 0 {
79 sum as f64 / count as f64
80 } else {
81 0.0
82 };
83
84 let mut sq_sum = 0.0f64;
86 for row in 0..total_rows {
87 let row_start = row * stride;
88 let row_end = (row_start + stride).min(data.len());
89 let mut byte_idx = row_start + ch;
90 while byte_idx < row_end {
91 let v = f64::from(data[byte_idx]);
92 let d = v - mean;
93 sq_sum += d * d;
94 byte_idx += num_channels;
95 }
96 }
97 let std_dev = if count > 1 {
98 (sq_sum / count as f64).sqrt()
99 } else {
100 0.0
101 };
102
103 let mut min_value = 0u8;
105 let mut max_value = 0u8;
106 let mut found_min = false;
107 for (i, &b) in bins.iter().enumerate() {
108 if b > 0 {
109 if !found_min {
110 min_value = i as u8;
111 found_min = true;
112 }
113 max_value = i as u8;
114 }
115 }
116
117 Self {
118 bins,
119 channel,
120 min_value,
121 max_value,
122 mean,
123 std_dev,
124 }
125 }
126
127 #[must_use]
133 pub fn percentile(&self, p: f64) -> u8 {
134 let total: u64 = self.bins.iter().map(|&b| u64::from(b)).sum();
135 if total == 0 {
136 return 0;
137 }
138
139 let target = (p.clamp(0.0, 1.0) * total as f64) as u64;
140 let mut cumulative = 0u64;
141 for (i, &count) in self.bins.iter().enumerate() {
142 cumulative += u64::from(count);
143 if cumulative > target {
144 return i as u8;
145 }
146 }
147 255
148 }
149
150 #[must_use]
154 #[allow(clippy::manual_checked_ops)]
155 pub fn entropy(&self) -> f64 {
156 let total: u64 = self.bins.iter().map(|&b| u64::from(b)).sum();
157 if total == 0 {
158 return 0.0;
159 }
160
161 let total_f = total as f64;
162 self.bins
163 .iter()
164 .filter(|&&b| b > 0)
165 .map(|&b| {
166 let p = f64::from(b) / total_f;
167 -p * p.log2()
168 })
169 .sum()
170 }
171}
172
173#[derive(Debug, Clone)]
179pub struct ImageHistogram {
180 pub channels: Vec<ChannelHistogram>,
182 pub width: u32,
184 pub height: u32,
186}
187
188impl ImageHistogram {
189 #[must_use]
194 pub fn from_rgb(data: &[u8], width: u32, height: u32) -> Self {
195 let pixels = (width * height) as usize;
196 let num_channels = data.len().checked_div(pixels).unwrap_or(3);
197 let stride = width as usize * num_channels;
198
199 let channels = (0..num_channels as u8)
200 .map(|ch| ChannelHistogram::compute(data, ch, stride, num_channels))
201 .collect();
202
203 Self {
204 channels,
205 width,
206 height,
207 }
208 }
209
210 #[must_use]
212 pub fn from_gray(data: &[u8], width: u32, height: u32) -> Self {
213 let stride = width as usize;
214 let ch = ChannelHistogram::compute(data, 0, stride, 1);
215
216 Self {
217 channels: vec![ch],
218 width,
219 height,
220 }
221 }
222
223 #[must_use]
225 pub fn is_underexposed(&self) -> bool {
226 self.channels.iter().any(|ch| ch.mean < 64.0)
227 }
228
229 #[must_use]
231 pub fn is_overexposed(&self) -> bool {
232 self.channels.iter().any(|ch| ch.mean > 192.0)
233 }
234
235 #[must_use]
237 pub fn dominant_channel(&self) -> u8 {
238 self.channels
239 .iter()
240 .enumerate()
241 .max_by(|(_, a), (_, b)| {
242 a.mean
243 .partial_cmp(&b.mean)
244 .unwrap_or(std::cmp::Ordering::Equal)
245 })
246 .map_or(0, |(i, _)| i as u8)
247 }
248}
249
250#[cfg(test)]
255mod tests {
256 use super::*;
257
258 #[test]
263 fn test_compute_mean_single_channel() {
264 let data = vec![128u8; 4];
266 let hist = ChannelHistogram::compute(&data, 0, 4, 1);
267 assert!((hist.mean - 128.0).abs() < 1e-9);
268 assert_eq!(hist.bins[128], 4);
269 assert_eq!(hist.min_value, 128);
270 assert_eq!(hist.max_value, 128);
271 }
272
273 #[test]
274 fn test_compute_mean_rgb() {
275 let data = vec![255u8, 0, 0, 255, 0, 0];
277 let hist_r = ChannelHistogram::compute(&data, 0, 6, 3);
278 let hist_g = ChannelHistogram::compute(&data, 1, 6, 3);
279 assert!((hist_r.mean - 255.0).abs() < 1e-9);
280 assert!((hist_g.mean - 0.0).abs() < 1e-9);
281 }
282
283 #[test]
284 fn test_entropy_uniform_image_is_zero() {
285 let data = vec![200u8; 100];
287 let hist = ChannelHistogram::compute(&data, 0, 100, 1);
288 assert!(
289 hist.entropy() < 1e-9,
290 "entropy of uniform image should be ~0"
291 );
292 }
293
294 #[test]
295 fn test_entropy_two_equally_likely_values() {
296 let mut data = vec![0u8; 100];
298 for b in data.iter_mut().take(50) {
299 *b = 255;
300 }
301 let hist = ChannelHistogram::compute(&data, 0, 100, 1);
302 let e = hist.entropy();
303 assert!((e - 1.0).abs() < 1e-6, "expected ~1.0 bit entropy, got {e}");
304 }
305
306 #[test]
307 fn test_percentile_median() {
308 let mut data = vec![0u8; 100];
310 for i in 50..100 {
311 data[i] = 255;
312 }
313 let hist = ChannelHistogram::compute(&data, 0, 100, 1);
314 let p50 = hist.percentile(0.5);
316 assert!(
319 p50 == 0 || p50 == 255,
320 "median should be 0 or 255, got {p50}"
321 );
322 }
323
324 #[test]
325 fn test_std_dev_constant_image() {
326 let data = vec![100u8; 64];
327 let hist = ChannelHistogram::compute(&data, 0, 64, 1);
328 assert!(hist.std_dev < 1e-9, "std_dev of constant image should be 0");
329 }
330
331 #[test]
336 fn test_from_rgb_2x2() {
337 let data: Vec<u8> = (0..4).flat_map(|_| vec![255u8, 0u8, 128u8]).collect();
339 let img = ImageHistogram::from_rgb(&data, 2, 2);
340
341 assert_eq!(img.channels.len(), 3);
342 assert!((img.channels[0].mean - 255.0).abs() < 1e-9);
343 assert!((img.channels[1].mean - 0.0).abs() < 1e-9);
344 assert!((img.channels[2].mean - 128.0).abs() < 1e-9);
345 }
346
347 #[test]
348 fn test_underexposed_detection() {
349 let data = vec![10u8; 100];
351 let img = ImageHistogram::from_gray(&data, 10, 10);
352 assert!(img.is_underexposed());
353 assert!(!img.is_overexposed());
354 }
355
356 #[test]
357 fn test_overexposed_detection() {
358 let data = vec![250u8; 100];
360 let img = ImageHistogram::from_gray(&data, 10, 10);
361 assert!(img.is_overexposed());
362 assert!(!img.is_underexposed());
363 }
364
365 #[test]
366 fn test_normal_exposure_neither() {
367 let data = vec![128u8; 100];
369 let img = ImageHistogram::from_gray(&data, 10, 10);
370 assert!(!img.is_underexposed());
371 assert!(!img.is_overexposed());
372 }
373
374 #[test]
375 fn test_dominant_channel() {
376 let data: Vec<u8> = (0..4).flat_map(|_| vec![200u8, 50u8, 100u8]).collect();
378 let img = ImageHistogram::from_rgb(&data, 2, 2);
379 assert_eq!(img.dominant_channel(), 0);
380 }
381
382 #[test]
383 fn test_from_gray_single_channel() {
384 let data = vec![77u8; 25];
385 let img = ImageHistogram::from_gray(&data, 5, 5);
386 assert_eq!(img.channels.len(), 1);
387 assert!((img.channels[0].mean - 77.0).abs() < 1e-9);
388 }
389
390 #[test]
391 fn test_underexposed_rgb_one_channel_low() {
392 let data: Vec<u8> = (0..4).flat_map(|_| vec![128u8, 128u8, 10u8]).collect();
394 let img = ImageHistogram::from_rgb(&data, 2, 2);
395 assert!(img.is_underexposed());
396 }
397}