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    #[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        // Iterate over all pixels: stride may differ from num_channels for
55        // padded rows, but for packed images stride == width * num_channels.
56        // We iterate byte-by-byte and pick channel `ch` from each pixel.
57        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        // Handle any remaining bytes when data.len() is not a multiple of stride
76        // (only relevant when stride == 0, handled above, or non-rectangular).
77
78        let mean = if count > 0 {
79            sum as f64 / count as f64
80        } else {
81            0.0
82        };
83
84        // Variance pass
85        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        // Min / max from bins
104        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    /// Return the pixel value at the given percentile.
128    ///
129    /// # Arguments
130    ///
131    /// * `p` - Percentile in `[0.0, 1.0]` (e.g. 0.5 for the median).
132    #[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    /// Compute the Shannon entropy of this channel's histogram (in bits).
151    ///
152    /// Returns `0.0` for a uniform (all-same-value) distribution.
153    #[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// ---------------------------------------------------------------------------
174// ImageHistogram
175// ---------------------------------------------------------------------------
176
177/// Multi-channel image histogram.
178#[derive(Debug, Clone)]
179pub struct ImageHistogram {
180    /// Per-channel histogram data.
181    pub channels: Vec<ChannelHistogram>,
182    /// Image width in pixels.
183    pub width: u32,
184    /// Image height in pixels.
185    pub height: u32,
186}
187
188impl ImageHistogram {
189    /// Compute a histogram for a packed RGB or RGBA image.
190    ///
191    /// The number of channels is inferred from `data.len() / (width * height)`.
192    /// For standard packed RGB use 3 channels; for RGBA use 4.
193    #[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    /// Compute a histogram for a single-channel (grayscale) image.
211    #[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    /// Return `true` if any channel has a mean below 64 (underexposed).
224    #[must_use]
225    pub fn is_underexposed(&self) -> bool {
226        self.channels.iter().any(|ch| ch.mean < 64.0)
227    }
228
229    /// Return `true` if any channel has a mean above 192 (overexposed).
230    #[must_use]
231    pub fn is_overexposed(&self) -> bool {
232        self.channels.iter().any(|ch| ch.mean > 192.0)
233    }
234
235    /// Return the index of the channel with the highest mean pixel value.
236    #[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// ---------------------------------------------------------------------------
251// Tests
252// ---------------------------------------------------------------------------
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257
258    // ------------------------------------------------------------------
259    // ChannelHistogram tests
260    // ------------------------------------------------------------------
261
262    #[test]
263    fn test_compute_mean_single_channel() {
264        // 4 pixels, all value 128.
265        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        // 2 pixels: [255, 0, 0, 255, 0, 0] → red channel mean = 255, green = 0.
276        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        // All pixels the same value → only one bin filled → entropy = 0.
286        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        // 50 pixels at 0 and 50 pixels at 255 → entropy ≈ 1.0 bit.
297        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        // 100 pixels: 50 at 0, 50 at 255.
309        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        // 50th percentile should be 0 (cumulative count at 0 reaches target).
315        let p50 = hist.percentile(0.5);
316        // Exactly half the pixels are 0, so the 50th percentile lands at 0 or 255
317        // depending on rounding; just assert it's one of the two values.
318        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    // ------------------------------------------------------------------
332    // ImageHistogram tests
333    // ------------------------------------------------------------------
334
335    #[test]
336    fn test_from_rgb_2x2() {
337        // 2×2 image: all pixels (255, 0, 128)
338        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        // All pixels at 10 → mean = 10, well below 64.
350        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        // All pixels at 250 → mean = 250, well above 192.
359        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        // All pixels at 128.
368        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        // Red=200, Green=50, Blue=100 → dominant = Red (channel 0).
377        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        // Red=128, Green=128, Blue=10 → Blue < 64 → underexposed.
393        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}