Skip to main content

oximedia_gpu/
histogram.rs

1//! Multi-channel image histogram analysis.
2//!
3//! Provides per-channel statistics (min, max, mean, std-dev, entropy,
4//! percentiles) and whole-image exposure checks.
5
6// ---------------------------------------------------------------------------
7// ChannelHistogram
8// ---------------------------------------------------------------------------
9
10/// Per-channel histogram data with summary statistics.
11#[derive(Debug, Clone)]
12pub struct ChannelHistogram {
13    /// Raw bin counts; `bins[v]` is the number of pixels with value `v`.
14    pub bins: [u32; 256],
15    /// Channel index (0-based).
16    pub channel: u8,
17    /// Minimum pixel value present in this channel.
18    pub min_value: u8,
19    /// Maximum pixel value present in this channel.
20    pub max_value: u8,
21    /// Mean pixel value.
22    pub mean: f64,
23    /// Standard deviation of pixel values.
24    pub std_dev: f64,
25}
26
27impl ChannelHistogram {
28    /// Compute a histogram for one channel of a packed multi-channel image.
29    ///
30    /// # Arguments
31    ///
32    /// * `data` - Raw pixel bytes for the whole image.
33    /// * `channel` - Zero-based channel index (e.g. 0=R, 1=G, 2=B).
34    /// * `stride` - Offset between the start of consecutive rows in bytes
35    ///   (use `width * num_channels` for packed images).
36    /// * `num_channels` - Total number of interleaved channels per pixel.
37    #[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        // Iterate over all pixels: stride may differ from num_channels for
54        // padded rows, but for packed images stride == width * num_channels.
55        // We iterate byte-by-byte and pick channel `ch` from each pixel.
56        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        // Handle any remaining bytes when data.len() is not a multiple of stride
75        // (only relevant when stride == 0, handled above, or non-rectangular).
76
77        let mean = if count > 0 {
78            sum as f64 / count as f64
79        } else {
80            0.0
81        };
82
83        // Variance pass
84        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        // Min / max from bins
103        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    /// Return the pixel value at the given percentile.
127    ///
128    /// # Arguments
129    ///
130    /// * `p` - Percentile in `[0.0, 1.0]` (e.g. 0.5 for the median).
131    #[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    /// Compute the Shannon entropy of this channel's histogram (in bits).
150    ///
151    /// Returns `0.0` for a uniform (all-same-value) distribution.
152    #[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// ---------------------------------------------------------------------------
172// ImageHistogram
173// ---------------------------------------------------------------------------
174
175/// Multi-channel image histogram.
176#[derive(Debug, Clone)]
177pub struct ImageHistogram {
178    /// Per-channel histogram data.
179    pub channels: Vec<ChannelHistogram>,
180    /// Image width in pixels.
181    pub width: u32,
182    /// Image height in pixels.
183    pub height: u32,
184}
185
186impl ImageHistogram {
187    /// Compute a histogram for a packed RGB or RGBA image.
188    ///
189    /// The number of channels is inferred from `data.len() / (width * height)`.
190    /// For standard packed RGB use 3 channels; for RGBA use 4.
191    #[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    /// Compute a histogram for a single-channel (grayscale) image.
209    #[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    /// Return `true` if any channel has a mean below 64 (underexposed).
222    #[must_use]
223    pub fn is_underexposed(&self) -> bool {
224        self.channels.iter().any(|ch| ch.mean < 64.0)
225    }
226
227    /// Return `true` if any channel has a mean above 192 (overexposed).
228    #[must_use]
229    pub fn is_overexposed(&self) -> bool {
230        self.channels.iter().any(|ch| ch.mean > 192.0)
231    }
232
233    /// Return the index of the channel with the highest mean pixel value.
234    #[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// ---------------------------------------------------------------------------
249// Tests
250// ---------------------------------------------------------------------------
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255
256    // ------------------------------------------------------------------
257    // ChannelHistogram tests
258    // ------------------------------------------------------------------
259
260    #[test]
261    fn test_compute_mean_single_channel() {
262        // 4 pixels, all value 128.
263        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        // 2 pixels: [255, 0, 0, 255, 0, 0] → red channel mean = 255, green = 0.
274        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        // All pixels the same value → only one bin filled → entropy = 0.
284        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        // 50 pixels at 0 and 50 pixels at 255 → entropy ≈ 1.0 bit.
295        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        // 100 pixels: 50 at 0, 50 at 255.
307        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        // 50th percentile should be 0 (cumulative count at 0 reaches target).
313        let p50 = hist.percentile(0.5);
314        // Exactly half the pixels are 0, so the 50th percentile lands at 0 or 255
315        // depending on rounding; just assert it's one of the two values.
316        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    // ------------------------------------------------------------------
330    // ImageHistogram tests
331    // ------------------------------------------------------------------
332
333    #[test]
334    fn test_from_rgb_2x2() {
335        // 2×2 image: all pixels (255, 0, 128)
336        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        // All pixels at 10 → mean = 10, well below 64.
348        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        // All pixels at 250 → mean = 250, well above 192.
357        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        // All pixels at 128.
366        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        // Red=200, Green=50, Blue=100 → dominant = Red (channel 0).
375        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        // Red=128, Green=128, Blue=10 → Blue < 64 → underexposed.
391        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}