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