Skip to main content

scirs2_vision/
histogram.rs

1//! Histogram operations for image analysis and enhancement
2//!
3//! This module provides comprehensive histogram-based image processing:
4//! - Histogram computation (1D grayscale and per-channel color)
5//! - Histogram equalization (global and CLAHE/adaptive)
6//! - Histogram matching/specification
7//! - Cumulative distribution function (CDF)
8//! - Otsu's threshold selection (single and multi-level)
9//! - Histogram backprojection
10
11use crate::error::{Result, VisionError};
12use image::{DynamicImage, GrayImage, ImageBuffer, Luma, Rgb, RgbImage};
13
14/// Number of bins for 8-bit histograms
15const NUM_BINS: usize = 256;
16
17/// A 1D histogram with 256 bins for 8-bit images
18#[derive(Debug, Clone)]
19pub struct Histogram {
20    /// Bin counts
21    pub bins: [u64; NUM_BINS],
22    /// Total number of pixels
23    pub total: u64,
24}
25
26impl Histogram {
27    /// Create a new empty histogram
28    pub fn new() -> Self {
29        Self {
30            bins: [0u64; NUM_BINS],
31            total: 0,
32        }
33    }
34
35    /// Get the normalized histogram (probability distribution)
36    pub fn normalized(&self) -> [f64; NUM_BINS] {
37        let mut result = [0.0f64; NUM_BINS];
38        if self.total > 0 {
39            for (i, bin) in self.bins.iter().enumerate() {
40                result[i] = *bin as f64 / self.total as f64;
41            }
42        }
43        result
44    }
45
46    /// Compute the cumulative distribution function
47    pub fn cdf(&self) -> [f64; NUM_BINS] {
48        let norm = self.normalized();
49        let mut cdf = [0.0f64; NUM_BINS];
50        cdf[0] = norm[0];
51        for i in 1..NUM_BINS {
52            cdf[i] = cdf[i - 1] + norm[i];
53        }
54        cdf
55    }
56
57    /// Compute the cumulative distribution function (unnormalized, as counts)
58    pub fn cdf_counts(&self) -> [u64; NUM_BINS] {
59        let mut cdf = [0u64; NUM_BINS];
60        cdf[0] = self.bins[0];
61        for i in 1..NUM_BINS {
62            cdf[i] = cdf[i - 1] + self.bins[i];
63        }
64        cdf
65    }
66
67    /// Get the mean intensity
68    pub fn mean(&self) -> f64 {
69        if self.total == 0 {
70            return 0.0;
71        }
72        let mut sum = 0u64;
73        for (i, &count) in self.bins.iter().enumerate() {
74            sum += i as u64 * count;
75        }
76        sum as f64 / self.total as f64
77    }
78
79    /// Get the variance of intensity
80    pub fn variance(&self) -> f64 {
81        if self.total == 0 {
82            return 0.0;
83        }
84        let mean = self.mean();
85        let mut var_sum = 0.0f64;
86        for (i, &count) in self.bins.iter().enumerate() {
87            let diff = i as f64 - mean;
88            var_sum += diff * diff * count as f64;
89        }
90        var_sum / self.total as f64
91    }
92
93    /// Get the minimum non-zero bin index
94    pub fn min_value(&self) -> Option<u8> {
95        self.bins.iter().position(|&c| c > 0).map(|i| i as u8)
96    }
97
98    /// Get the maximum non-zero bin index
99    pub fn max_value(&self) -> Option<u8> {
100        self.bins.iter().rposition(|&c| c > 0).map(|i| i as u8)
101    }
102}
103
104impl Default for Histogram {
105    fn default() -> Self {
106        Self::new()
107    }
108}
109
110/// Per-channel color histogram
111#[derive(Debug, Clone)]
112pub struct ColorHistogram {
113    /// Red channel histogram
114    pub red: Histogram,
115    /// Green channel histogram
116    pub green: Histogram,
117    /// Blue channel histogram
118    pub blue: Histogram,
119}
120
121impl ColorHistogram {
122    /// Create a new empty color histogram
123    pub fn new() -> Self {
124        Self {
125            red: Histogram::new(),
126            green: Histogram::new(),
127            blue: Histogram::new(),
128        }
129    }
130}
131
132impl Default for ColorHistogram {
133    fn default() -> Self {
134        Self::new()
135    }
136}
137
138// ---------------------------------------------------------------------------
139// Histogram computation
140// ---------------------------------------------------------------------------
141
142/// Compute histogram of a grayscale image
143///
144/// # Arguments
145///
146/// * `img` - Input image (will be converted to grayscale if needed)
147///
148/// # Returns
149///
150/// Histogram with 256 bins
151pub fn compute_histogram(img: &DynamicImage) -> Histogram {
152    let gray = img.to_luma8();
153    let mut hist = Histogram::new();
154    for pixel in gray.pixels() {
155        hist.bins[pixel[0] as usize] += 1;
156        hist.total += 1;
157    }
158    hist
159}
160
161/// Compute per-channel histogram of a color image
162///
163/// # Arguments
164///
165/// * `img` - Input image (will be converted to RGB if needed)
166///
167/// # Returns
168///
169/// ColorHistogram with separate R, G, B histograms
170pub fn compute_color_histogram(img: &DynamicImage) -> ColorHistogram {
171    let rgb = img.to_rgb8();
172    let mut hist = ColorHistogram::new();
173    for pixel in rgb.pixels() {
174        hist.red.bins[pixel[0] as usize] += 1;
175        hist.red.total += 1;
176        hist.green.bins[pixel[1] as usize] += 1;
177        hist.green.total += 1;
178        hist.blue.bins[pixel[2] as usize] += 1;
179        hist.blue.total += 1;
180    }
181    hist
182}
183
184// ---------------------------------------------------------------------------
185// Histogram equalization
186// ---------------------------------------------------------------------------
187
188/// Apply global histogram equalization to a grayscale image
189///
190/// Redistributes intensity values to achieve a more uniform histogram,
191/// improving contrast especially in images with narrow intensity ranges.
192///
193/// # Arguments
194///
195/// * `img` - Input image
196///
197/// # Returns
198///
199/// Equalized grayscale image
200pub fn equalize_histogram(img: &DynamicImage) -> Result<DynamicImage> {
201    let gray = img.to_luma8();
202    let (width, height) = gray.dimensions();
203    let total_pixels = (width as u64) * (height as u64);
204
205    if total_pixels == 0 {
206        return Err(VisionError::InvalidParameter(
207            "Image has zero pixels".to_string(),
208        ));
209    }
210
211    // Build histogram
212    let mut hist = Histogram::new();
213    for pixel in gray.pixels() {
214        hist.bins[pixel[0] as usize] += 1;
215    }
216    hist.total = total_pixels;
217
218    let cdf = hist.cdf_counts();
219
220    // Find first non-zero CDF value
221    let cdf_min = cdf.iter().copied().find(|&v| v > 0).unwrap_or(0);
222
223    // Build lookup table
224    let mut lut = [0u8; NUM_BINS];
225    let denom = total_pixels.saturating_sub(cdf_min);
226    if denom > 0 {
227        for i in 0..NUM_BINS {
228            let mapped = ((cdf[i].saturating_sub(cdf_min)) as f64 / denom as f64 * 255.0)
229                .round()
230                .clamp(0.0, 255.0);
231            lut[i] = mapped as u8;
232        }
233    }
234
235    // Apply LUT
236    let mut result = ImageBuffer::new(width, height);
237    for (x, y, pixel) in gray.enumerate_pixels() {
238        result.put_pixel(x, y, Luma([lut[pixel[0] as usize]]));
239    }
240
241    Ok(DynamicImage::ImageLuma8(result))
242}
243
244/// Apply CLAHE (Contrast Limited Adaptive Histogram Equalization)
245///
246/// Divides the image into tiles, applies contrast-limited equalization
247/// per tile, and bilinearly interpolates at boundaries.
248///
249/// # Arguments
250///
251/// * `img` - Input image
252/// * `tile_size` - Width/height of each tile in pixels (typically 8)
253/// * `clip_limit` - Contrast limit factor (>= 1.0; 1.0 = no clipping)
254///
255/// # Returns
256///
257/// Enhanced image
258pub fn clahe(img: &DynamicImage, tile_size: u32, clip_limit: f32) -> Result<DynamicImage> {
259    if tile_size == 0 {
260        return Err(VisionError::InvalidParameter(
261            "Tile size must be positive".to_string(),
262        ));
263    }
264    if clip_limit < 1.0 {
265        return Err(VisionError::InvalidParameter(
266            "Clip limit must be >= 1.0".to_string(),
267        ));
268    }
269
270    let gray = img.to_luma8();
271    let (width, height) = gray.dimensions();
272
273    let nx = width.div_ceil(tile_size);
274    let ny = height.div_ceil(tile_size);
275
276    // Build per-tile histograms
277    let mut histograms = vec![vec![[0u32; NUM_BINS]; nx as usize]; ny as usize];
278
279    for y in 0..height {
280        for x in 0..width {
281            let tx = (x / tile_size) as usize;
282            let ty = (y / tile_size) as usize;
283            histograms[ty][tx][gray.get_pixel(x, y)[0] as usize] += 1;
284        }
285    }
286
287    // Clip and redistribute
288    #[allow(clippy::needless_range_loop)]
289    for ty in 0..ny as usize {
290        for tx in 0..nx as usize {
291            let tw = tile_size.min(width.saturating_sub(tx as u32 * tile_size));
292            let th = tile_size.min(height.saturating_sub(ty as u32 * tile_size));
293            let area = tw * th;
294            if area == 0 {
295                continue;
296            }
297
298            let limit = (clip_limit * area as f32 / NUM_BINS as f32) as u32;
299            let mut excess = 0u32;
300
301            for bin in histograms[ty][tx].iter_mut() {
302                if *bin > limit {
303                    excess += *bin - limit;
304                    *bin = limit;
305                }
306            }
307
308            let per_bin = excess / NUM_BINS as u32;
309            let mut residual = excess % NUM_BINS as u32;
310            for bin in histograms[ty][tx].iter_mut() {
311                *bin += per_bin;
312                if residual > 0 {
313                    *bin += 1;
314                    residual -= 1;
315                }
316            }
317        }
318    }
319
320    // Compute CDFs per tile
321    let mut cdfs = vec![vec![[0u32; NUM_BINS]; nx as usize]; ny as usize];
322    for ty in 0..ny as usize {
323        for tx in 0..nx as usize {
324            let tw = tile_size.min(width.saturating_sub(tx as u32 * tile_size));
325            let th = tile_size.min(height.saturating_sub(ty as u32 * tile_size));
326            let area = tw * th;
327
328            cdfs[ty][tx][0] = histograms[ty][tx][0];
329            for i in 1..NUM_BINS {
330                cdfs[ty][tx][i] = cdfs[ty][tx][i - 1] + histograms[ty][tx][i];
331            }
332
333            for v in cdfs[ty][tx].iter_mut() {
334                *v = (*v * 255).checked_div(area).unwrap_or(0);
335            }
336        }
337    }
338
339    // Bilinear interpolation of tile CDFs
340    let mut result = ImageBuffer::new(width, height);
341
342    for y in 0..height {
343        for x in 0..width {
344            let val = gray.get_pixel(x, y)[0] as usize;
345            let tx = x / tile_size;
346            let ty = y / tile_size;
347
348            let fx = (x % tile_size) as f32 / tile_size as f32;
349            let fy = (y % tile_size) as f32 / tile_size as f32;
350
351            let mapped = if tx >= nx - 1 && ty >= ny - 1 {
352                cdfs[ty as usize][tx as usize][val] as f32
353            } else if tx >= nx - 1 {
354                let top = cdfs[ty as usize][tx as usize][val] as f32;
355                let bot = cdfs[((ty + 1) as usize).min((ny - 1) as usize)][tx as usize][val] as f32;
356                (1.0 - fy) * top + fy * bot
357            } else if ty >= ny - 1 {
358                let left = cdfs[ty as usize][tx as usize][val] as f32;
359                let right =
360                    cdfs[ty as usize][((tx + 1) as usize).min((nx - 1) as usize)][val] as f32;
361                (1.0 - fx) * left + fx * right
362            } else {
363                let tl = cdfs[ty as usize][tx as usize][val] as f32;
364                let tr = cdfs[ty as usize][(tx + 1) as usize][val] as f32;
365                let bl = cdfs[(ty + 1) as usize][tx as usize][val] as f32;
366                let br = cdfs[(ty + 1) as usize][(tx + 1) as usize][val] as f32;
367
368                let top = (1.0 - fx) * tl + fx * tr;
369                let bot = (1.0 - fx) * bl + fx * br;
370                (1.0 - fy) * top + fy * bot
371            };
372
373            result.put_pixel(x, y, Luma([mapped.clamp(0.0, 255.0) as u8]));
374        }
375    }
376
377    Ok(DynamicImage::ImageLuma8(result))
378}
379
380// ---------------------------------------------------------------------------
381// Histogram matching / specification
382// ---------------------------------------------------------------------------
383
384/// Match the histogram of a source image to a reference histogram
385///
386/// The output image has the same spatial structure as `source` but with
387/// an intensity distribution that approximates `reference_hist`.
388///
389/// # Arguments
390///
391/// * `source` - Input image
392/// * `reference_hist` - Target histogram to match
393///
394/// # Returns
395///
396/// Image with matched histogram
397pub fn match_histogram(source: &DynamicImage, reference_hist: &Histogram) -> Result<DynamicImage> {
398    let gray = source.to_luma8();
399    let (width, height) = gray.dimensions();
400
401    // CDF of the source
402    let src_hist = compute_histogram(source);
403    let src_cdf = src_hist.cdf();
404
405    // CDF of the reference
406    let ref_cdf = reference_hist.cdf();
407
408    // Build mapping: for each source level, find the reference level whose
409    // CDF value is closest to the source CDF value.
410    let mut lut = [0u8; NUM_BINS];
411    for i in 0..NUM_BINS {
412        let target_cdf = src_cdf[i];
413        let mut best_j = 0usize;
414        let mut best_diff = f64::INFINITY;
415        for (j, &rcdf_val) in ref_cdf.iter().enumerate() {
416            let diff = (rcdf_val - target_cdf).abs();
417            if diff < best_diff {
418                best_diff = diff;
419                best_j = j;
420            }
421        }
422        lut[i] = best_j as u8;
423    }
424
425    let mut result = ImageBuffer::new(width, height);
426    for (x, y, pixel) in gray.enumerate_pixels() {
427        result.put_pixel(x, y, Luma([lut[pixel[0] as usize]]));
428    }
429
430    Ok(DynamicImage::ImageLuma8(result))
431}
432
433/// Match the histogram of a source image to a reference image
434///
435/// Convenience wrapper that computes the reference histogram automatically.
436///
437/// # Arguments
438///
439/// * `source` - Input image to transform
440/// * `reference` - Reference image whose histogram to match
441///
442/// # Returns
443///
444/// Image with histogram matched to reference
445pub fn match_histogram_image(
446    source: &DynamicImage,
447    reference: &DynamicImage,
448) -> Result<DynamicImage> {
449    let ref_hist = compute_histogram(reference);
450    match_histogram(source, &ref_hist)
451}
452
453// ---------------------------------------------------------------------------
454// CDF helpers
455// ---------------------------------------------------------------------------
456
457/// Compute the cumulative distribution function for a grayscale image
458///
459/// # Arguments
460///
461/// * `img` - Input image
462///
463/// # Returns
464///
465/// Array of 256 CDF values in [0.0, 1.0]
466pub fn compute_cdf(img: &DynamicImage) -> [f64; NUM_BINS] {
467    let hist = compute_histogram(img);
468    hist.cdf()
469}
470
471// ---------------------------------------------------------------------------
472// Otsu's threshold
473// ---------------------------------------------------------------------------
474
475/// Find the optimal threshold using Otsu's method
476///
477/// Minimises intra-class variance (equivalently maximises inter-class variance)
478/// to find the best binarisation threshold.
479///
480/// # Arguments
481///
482/// * `img` - Input image
483///
484/// # Returns
485///
486/// Optimal threshold value [0, 255]
487pub fn otsu_threshold(img: &DynamicImage) -> u8 {
488    let hist = compute_histogram(img);
489    otsu_threshold_from_histogram(&hist)
490}
491
492/// Compute Otsu threshold from a pre-computed histogram
493pub fn otsu_threshold_from_histogram(hist: &Histogram) -> u8 {
494    if hist.total == 0 {
495        return 128;
496    }
497
498    let prob = hist.normalized();
499
500    // Total mean
501    let mut total_mean = 0.0f64;
502    for (i, &p) in prob.iter().enumerate() {
503        total_mean += i as f64 * p;
504    }
505
506    let mut best_threshold = 0u8;
507    let mut best_variance = 0.0f64;
508
509    let mut w0 = 0.0f64; // weight of class 0
510    let mut sum0 = 0.0f64; // cumulative sum for class 0
511
512    for (t, &prob_t) in prob.iter().enumerate().take(255) {
513        w0 += prob_t;
514        if w0 == 0.0 {
515            continue;
516        }
517        let w1 = 1.0 - w0;
518        if w1 == 0.0 {
519            break;
520        }
521
522        sum0 += t as f64 * prob_t;
523        let mu0 = sum0 / w0;
524        let mu1 = (total_mean - sum0) / w1;
525
526        let between_var = w0 * w1 * (mu0 - mu1) * (mu0 - mu1);
527        if between_var > best_variance {
528            best_variance = between_var;
529            best_threshold = t as u8;
530        }
531    }
532
533    best_threshold
534}
535
536/// Find multiple thresholds using multi-level Otsu's method
537///
538/// Extends Otsu's binarization to multiple classes by exhaustively
539/// searching for the thresholds that maximise inter-class variance.
540///
541/// # Arguments
542///
543/// * `img` - Input image
544/// * `num_thresholds` - Number of thresholds (number of classes = num_thresholds + 1)
545///
546/// # Returns
547///
548/// Sorted vector of threshold values
549pub fn multi_otsu_threshold(img: &DynamicImage, num_thresholds: usize) -> Result<Vec<u8>> {
550    if num_thresholds == 0 {
551        return Err(VisionError::InvalidParameter(
552            "Number of thresholds must be positive".to_string(),
553        ));
554    }
555    if num_thresholds > 4 {
556        return Err(VisionError::InvalidParameter(
557            "Multi-Otsu supports up to 4 thresholds (5 classes)".to_string(),
558        ));
559    }
560
561    let hist = compute_histogram(img);
562    if hist.total == 0 {
563        return Ok(vec![128; num_thresholds]);
564    }
565
566    let prob = hist.normalized();
567
568    if num_thresholds == 1 {
569        return Ok(vec![otsu_threshold_from_histogram(&hist)]);
570    }
571
572    // Precompute partial sums for efficiency
573    // P[k] = sum(prob[0..=k]), S[k] = sum(i*prob[i] for i in 0..=k)
574    let mut p = [0.0f64; NUM_BINS];
575    let mut s = [0.0f64; NUM_BINS];
576    p[0] = prob[0];
577    s[0] = 0.0;
578    for i in 1..NUM_BINS {
579        p[i] = p[i - 1] + prob[i];
580        s[i] = s[i - 1] + i as f64 * prob[i];
581    }
582
583    let total_mean = s[NUM_BINS - 1];
584
585    // Helper: inter-class variance contribution for a range [lo..hi]
586    // Mean of class: s_range / p_range
587    // Weight: p_range
588    let class_variance = |lo: usize, hi: usize| -> f64 {
589        let p_lo = if lo == 0 { 0.0 } else { p[lo - 1] };
590        let s_lo = if lo == 0 { 0.0 } else { s[lo - 1] };
591        let p_range = p[hi] - p_lo;
592        let s_range = s[hi] - s_lo;
593        if p_range < 1e-15 {
594            return 0.0;
595        }
596        let mu = s_range / p_range;
597        p_range * (mu - total_mean) * (mu - total_mean)
598    };
599
600    match num_thresholds {
601        2 => {
602            let mut best = 0.0f64;
603            let mut best_t = (0u8, 0u8);
604            for t1 in 1..254u8 {
605                for t2 in (t1 + 1)..255u8 {
606                    let var = class_variance(0, t1 as usize)
607                        + class_variance(t1 as usize + 1, t2 as usize)
608                        + class_variance(t2 as usize + 1, 255);
609                    if var > best {
610                        best = var;
611                        best_t = (t1, t2);
612                    }
613                }
614            }
615            Ok(vec![best_t.0, best_t.1])
616        }
617        3 => {
618            let mut best = 0.0f64;
619            let mut best_t = (0u8, 0u8, 0u8);
620            // Use step to reduce exhaustive search for 3 thresholds
621            for t1 in (1..253u8).step_by(2) {
622                for t2 in ((t1 + 1)..254u8).step_by(2) {
623                    for t3 in ((t2 + 1)..255u8).step_by(2) {
624                        let var = class_variance(0, t1 as usize)
625                            + class_variance(t1 as usize + 1, t2 as usize)
626                            + class_variance(t2 as usize + 1, t3 as usize)
627                            + class_variance(t3 as usize + 1, 255);
628                        if var > best {
629                            best = var;
630                            best_t = (t1, t2, t3);
631                        }
632                    }
633                }
634            }
635            // Refine around best found with step 1
636            let refine_range = 3i16;
637            for d1 in -refine_range..=refine_range {
638                let t1 = (best_t.0 as i16 + d1).clamp(1, 252) as u8;
639                for d2 in -refine_range..=refine_range {
640                    let t2 = (best_t.1 as i16 + d2).clamp(t1 as i16 + 1, 253) as u8;
641                    for d3 in -refine_range..=refine_range {
642                        let t3 = (best_t.2 as i16 + d3).clamp(t2 as i16 + 1, 254) as u8;
643                        let var = class_variance(0, t1 as usize)
644                            + class_variance(t1 as usize + 1, t2 as usize)
645                            + class_variance(t2 as usize + 1, t3 as usize)
646                            + class_variance(t3 as usize + 1, 255);
647                        if var > best {
648                            best = var;
649                            best_t = (t1, t2, t3);
650                        }
651                    }
652                }
653            }
654            Ok(vec![best_t.0, best_t.1, best_t.2])
655        }
656        4 => {
657            // 4 thresholds: coarse search with step 4, then refine
658            let mut best = 0.0f64;
659            let mut best_t = (0u8, 0u8, 0u8, 0u8);
660            for t1 in (1..252u8).step_by(4) {
661                for t2 in ((t1 + 1)..253u8).step_by(4) {
662                    for t3 in ((t2 + 1)..254u8).step_by(4) {
663                        for t4 in ((t3 + 1)..255u8).step_by(4) {
664                            let var = class_variance(0, t1 as usize)
665                                + class_variance(t1 as usize + 1, t2 as usize)
666                                + class_variance(t2 as usize + 1, t3 as usize)
667                                + class_variance(t3 as usize + 1, t4 as usize)
668                                + class_variance(t4 as usize + 1, 255);
669                            if var > best {
670                                best = var;
671                                best_t = (t1, t2, t3, t4);
672                            }
673                        }
674                    }
675                }
676            }
677            // Refine
678            let refine_range = 4i16;
679            for d1 in -refine_range..=refine_range {
680                let t1 = (best_t.0 as i16 + d1).clamp(1, 251) as u8;
681                for d2 in -refine_range..=refine_range {
682                    let t2 = (best_t.1 as i16 + d2).clamp(t1 as i16 + 1, 252) as u8;
683                    for d3 in -refine_range..=refine_range {
684                        let t3 = (best_t.2 as i16 + d3).clamp(t2 as i16 + 1, 253) as u8;
685                        for d4 in -refine_range..=refine_range {
686                            let t4 = (best_t.3 as i16 + d4).clamp(t3 as i16 + 1, 254) as u8;
687                            let var = class_variance(0, t1 as usize)
688                                + class_variance(t1 as usize + 1, t2 as usize)
689                                + class_variance(t2 as usize + 1, t3 as usize)
690                                + class_variance(t3 as usize + 1, t4 as usize)
691                                + class_variance(t4 as usize + 1, 255);
692                            if var > best {
693                                best = var;
694                                best_t = (t1, t2, t3, t4);
695                            }
696                        }
697                    }
698                }
699            }
700            Ok(vec![best_t.0, best_t.1, best_t.2, best_t.3])
701        }
702        _ => Err(VisionError::InvalidParameter(format!(
703            "Unsupported number of thresholds: {num_thresholds}"
704        ))),
705    }
706}
707
708/// Apply Otsu's threshold to binarise an image
709///
710/// # Arguments
711///
712/// * `img` - Input image
713///
714/// # Returns
715///
716/// (binary image, threshold value)
717pub fn binarize_otsu(img: &DynamicImage) -> Result<(DynamicImage, u8)> {
718    let threshold = otsu_threshold(img);
719    let gray = img.to_luma8();
720    let (width, height) = gray.dimensions();
721
722    let mut result = ImageBuffer::new(width, height);
723    for (x, y, pixel) in gray.enumerate_pixels() {
724        let val = if pixel[0] > threshold { 255u8 } else { 0u8 };
725        result.put_pixel(x, y, Luma([val]));
726    }
727
728    Ok((DynamicImage::ImageLuma8(result), threshold))
729}
730
731// ---------------------------------------------------------------------------
732// Histogram backprojection
733// ---------------------------------------------------------------------------
734
735/// Compute histogram backprojection
736///
737/// Projects a histogram model onto an image, producing a probability map
738/// indicating how well each pixel matches the model histogram.
739/// Commonly used for object detection/tracking.
740///
741/// # Arguments
742///
743/// * `img` - Input image
744/// * `model_hist` - Model histogram (should be normalized)
745/// * `channel` - Which channel to use for backprojection (0=R/Gray, 1=G, 2=B)
746///
747/// # Returns
748///
749/// Grayscale probability map
750pub fn backproject_histogram(
751    img: &DynamicImage,
752    model_hist: &Histogram,
753    channel: usize,
754) -> Result<DynamicImage> {
755    if channel > 2 {
756        return Err(VisionError::InvalidParameter(
757            "Channel must be 0 (R/Gray), 1 (G), or 2 (B)".to_string(),
758        ));
759    }
760
761    let norm = model_hist.normalized();
762
763    // Find max for normalisation to [0, 255]
764    let max_prob = norm.iter().copied().fold(0.0f64, f64::max);
765    if max_prob < 1e-15 {
766        // Empty histogram, return black image
767        let gray = img.to_luma8();
768        let (w, h) = gray.dimensions();
769        return Ok(DynamicImage::ImageLuma8(ImageBuffer::new(w, h)));
770    }
771
772    let rgb = img.to_rgb8();
773    let (width, height) = rgb.dimensions();
774    let mut result = ImageBuffer::new(width, height);
775
776    for (x, y, pixel) in rgb.enumerate_pixels() {
777        let idx = pixel[channel] as usize;
778        let prob = norm[idx] / max_prob;
779        let val = (prob * 255.0).clamp(0.0, 255.0) as u8;
780        result.put_pixel(x, y, Luma([val]));
781    }
782
783    Ok(DynamicImage::ImageLuma8(result))
784}
785
786/// Compute histogram backprojection using hue-saturation model
787///
788/// Uses the H and S channels of HSV for more robust colour-based
789/// backprojection.
790///
791/// # Arguments
792///
793/// * `img` - Input image
794/// * `hue_hist` - Histogram for hue channel
795/// * `sat_hist` - Histogram for saturation channel
796///
797/// # Returns
798///
799/// Grayscale probability map
800pub fn backproject_hs_histogram(
801    img: &DynamicImage,
802    hue_hist: &Histogram,
803    sat_hist: &Histogram,
804) -> Result<DynamicImage> {
805    let rgb = img.to_rgb8();
806    let (width, height) = rgb.dimensions();
807
808    let h_norm = hue_hist.normalized();
809    let s_norm = sat_hist.normalized();
810
811    let h_max = h_norm.iter().copied().fold(0.0f64, f64::max).max(1e-15);
812    let s_max = s_norm.iter().copied().fold(0.0f64, f64::max).max(1e-15);
813
814    let mut result = ImageBuffer::new(width, height);
815
816    for y in 0..height {
817        for x in 0..width {
818            let pixel = rgb.get_pixel(x, y);
819            let r = pixel[0] as f32 / 255.0;
820            let g = pixel[1] as f32 / 255.0;
821            let b = pixel[2] as f32 / 255.0;
822
823            let max_c = r.max(g).max(b);
824            let min_c = r.min(g).min(b);
825            let delta = max_c - min_c;
826
827            // Hue
828            let h = if delta < 1e-6 {
829                0.0
830            } else if (max_c - r).abs() < 1e-6 {
831                let mut hh = 60.0 * ((g - b) / delta);
832                if hh < 0.0 {
833                    hh += 360.0;
834                }
835                hh
836            } else if (max_c - g).abs() < 1e-6 {
837                60.0 * ((b - r) / delta + 2.0)
838            } else {
839                60.0 * ((r - g) / delta + 4.0)
840            };
841
842            // Saturation
843            let s = if max_c < 1e-6 { 0.0 } else { delta / max_c };
844
845            let h_idx = ((h / 360.0 * 255.0) as usize).min(255);
846            let s_idx = ((s * 255.0) as usize).min(255);
847
848            let h_prob = h_norm[h_idx] / h_max;
849            let s_prob = s_norm[s_idx] / s_max;
850
851            let combined = (h_prob * s_prob * 255.0).clamp(0.0, 255.0) as u8;
852            result.put_pixel(x, y, Luma([combined]));
853        }
854    }
855
856    Ok(DynamicImage::ImageLuma8(result))
857}
858
859// ---------------------------------------------------------------------------
860// Equalize histogram for color images (per-channel)
861// ---------------------------------------------------------------------------
862
863/// Apply histogram equalization to each channel of a colour image independently
864///
865/// # Arguments
866///
867/// * `img` - Input colour image
868///
869/// # Returns
870///
871/// Equalized colour image
872pub fn equalize_histogram_color(img: &DynamicImage) -> Result<DynamicImage> {
873    let rgb = img.to_rgb8();
874    let (width, height) = rgb.dimensions();
875    let total = (width as u64) * (height as u64);
876
877    if total == 0 {
878        return Err(VisionError::InvalidParameter(
879            "Image has zero pixels".to_string(),
880        ));
881    }
882
883    // Build per-channel histograms
884    let mut hists = [[0u64; NUM_BINS]; 3];
885    for pixel in rgb.pixels() {
886        hists[0][pixel[0] as usize] += 1;
887        hists[1][pixel[1] as usize] += 1;
888        hists[2][pixel[2] as usize] += 1;
889    }
890
891    // CDFs and LUTs
892    let mut luts = [[0u8; NUM_BINS]; 3];
893    for ch in 0..3 {
894        let mut cdf = [0u64; NUM_BINS];
895        cdf[0] = hists[ch][0];
896        for i in 1..NUM_BINS {
897            cdf[i] = cdf[i - 1] + hists[ch][i];
898        }
899        let cdf_min = cdf.iter().copied().find(|&v| v > 0).unwrap_or(0);
900        let denom = total.saturating_sub(cdf_min);
901        if denom > 0 {
902            for i in 0..NUM_BINS {
903                luts[ch][i] = ((cdf[i].saturating_sub(cdf_min) as f64 / denom as f64) * 255.0)
904                    .round()
905                    .clamp(0.0, 255.0) as u8;
906            }
907        }
908    }
909
910    let mut result = RgbImage::new(width, height);
911    for (x, y, pixel) in rgb.enumerate_pixels() {
912        result.put_pixel(
913            x,
914            y,
915            Rgb([
916                luts[0][pixel[0] as usize],
917                luts[1][pixel[1] as usize],
918                luts[2][pixel[2] as usize],
919            ]),
920        );
921    }
922
923    Ok(DynamicImage::ImageRgb8(result))
924}
925
926// ---------------------------------------------------------------------------
927// Tests
928// ---------------------------------------------------------------------------
929
930#[cfg(test)]
931mod tests {
932    use super::*;
933
934    fn make_gray_image(width: u32, height: u32, fill: u8) -> DynamicImage {
935        let mut img = GrayImage::new(width, height);
936        for pixel in img.pixels_mut() {
937            *pixel = Luma([fill]);
938        }
939        DynamicImage::ImageLuma8(img)
940    }
941
942    fn make_gradient_image(width: u32, height: u32) -> DynamicImage {
943        let mut img = GrayImage::new(width, height);
944        let divisor = if width > 1 { (width - 1) as f32 } else { 1.0 };
945        for y in 0..height {
946            for x in 0..width {
947                let val = ((x as f32 / divisor) * 255.0).min(255.0) as u8;
948                img.put_pixel(x, y, Luma([val]));
949            }
950        }
951        DynamicImage::ImageLuma8(img)
952    }
953
954    fn make_bimodal_image(width: u32, height: u32) -> DynamicImage {
955        let mut img = GrayImage::new(width, height);
956        for y in 0..height {
957            for x in 0..width {
958                let val = if x < width / 2 { 50u8 } else { 200u8 };
959                img.put_pixel(x, y, Luma([val]));
960            }
961        }
962        DynamicImage::ImageLuma8(img)
963    }
964
965    fn make_rgb_image(width: u32, height: u32) -> DynamicImage {
966        let mut img = RgbImage::new(width, height);
967        for y in 0..height {
968            for x in 0..width {
969                let r = ((x as f32 / width as f32) * 255.0) as u8;
970                let g = ((y as f32 / height as f32) * 255.0) as u8;
971                let b = 128u8;
972                img.put_pixel(x, y, Rgb([r, g, b]));
973            }
974        }
975        DynamicImage::ImageRgb8(img)
976    }
977
978    #[test]
979    fn test_compute_histogram_uniform() {
980        let img = make_gray_image(10, 10, 128);
981        let hist = compute_histogram(&img);
982        assert_eq!(hist.total, 100);
983        assert_eq!(hist.bins[128], 100);
984        assert_eq!(hist.bins[0], 0);
985    }
986
987    #[test]
988    fn test_compute_histogram_gradient() {
989        let img = make_gradient_image(256, 1);
990        let hist = compute_histogram(&img);
991        assert_eq!(hist.total, 256);
992        // Gradient should spread across bins
993        let non_zero = hist.bins.iter().filter(|&&c| c > 0).count();
994        assert!(non_zero > 100);
995    }
996
997    #[test]
998    fn test_histogram_mean_uniform() {
999        let img = make_gray_image(10, 10, 100);
1000        let hist = compute_histogram(&img);
1001        assert!((hist.mean() - 100.0).abs() < 0.01);
1002    }
1003
1004    #[test]
1005    fn test_histogram_variance_uniform() {
1006        let img = make_gray_image(10, 10, 100);
1007        let hist = compute_histogram(&img);
1008        assert!(hist.variance() < 0.01);
1009    }
1010
1011    #[test]
1012    fn test_histogram_min_max() {
1013        let img = make_gradient_image(256, 1);
1014        let hist = compute_histogram(&img);
1015        assert_eq!(hist.min_value(), Some(0));
1016        assert_eq!(hist.max_value(), Some(255));
1017    }
1018
1019    #[test]
1020    fn test_histogram_cdf() {
1021        let img = make_gray_image(10, 10, 100);
1022        let hist = compute_histogram(&img);
1023        let cdf = hist.cdf();
1024        // CDF should be 0 before 100, 1.0 at and after 100
1025        assert!(cdf[99] < 0.01);
1026        assert!((cdf[100] - 1.0).abs() < 0.01);
1027        assert!((cdf[255] - 1.0).abs() < 0.01);
1028    }
1029
1030    #[test]
1031    fn test_compute_color_histogram() {
1032        let img = make_rgb_image(20, 20);
1033        let hist = compute_color_histogram(&img);
1034        assert_eq!(hist.red.total, 400);
1035        assert_eq!(hist.green.total, 400);
1036        assert_eq!(hist.blue.total, 400);
1037    }
1038
1039    #[test]
1040    fn test_equalize_histogram() {
1041        let img = make_gradient_image(256, 10);
1042        let eq = equalize_histogram(&img).expect("equalization should succeed");
1043        assert_eq!(eq.width(), 256);
1044        assert_eq!(eq.height(), 10);
1045    }
1046
1047    #[test]
1048    fn test_equalize_histogram_uniform_stays_similar() {
1049        let img = make_gray_image(10, 10, 128);
1050        let eq = equalize_histogram(&img).expect("equalization should succeed");
1051        let gray = eq.to_luma8();
1052        // All pixels should map to same value
1053        let val = gray.get_pixel(0, 0)[0];
1054        for pixel in gray.pixels() {
1055            assert_eq!(pixel[0], val);
1056        }
1057    }
1058
1059    #[test]
1060    fn test_clahe_basic() {
1061        let img = make_gradient_image(64, 64);
1062        let result = clahe(&img, 8, 2.0).expect("CLAHE should succeed");
1063        assert_eq!(result.width(), 64);
1064        assert_eq!(result.height(), 64);
1065    }
1066
1067    #[test]
1068    fn test_clahe_invalid_params() {
1069        let img = make_gray_image(10, 10, 100);
1070        assert!(clahe(&img, 0, 2.0).is_err());
1071        assert!(clahe(&img, 8, 0.5).is_err());
1072    }
1073
1074    #[test]
1075    fn test_match_histogram() {
1076        // Source: dark image, Reference: bright histogram
1077        let source = make_gray_image(20, 20, 50);
1078        let mut ref_hist = Histogram::new();
1079        ref_hist.bins[200] = 100;
1080        ref_hist.total = 100;
1081
1082        let matched = match_histogram(&source, &ref_hist).expect("matching should succeed");
1083        let gray = matched.to_luma8();
1084        // All pixels should be near 200
1085        let val = gray.get_pixel(0, 0)[0];
1086        assert!(val >= 180, "Expected bright pixels, got {val}");
1087    }
1088
1089    #[test]
1090    fn test_match_histogram_image() {
1091        let source = make_gray_image(10, 10, 50);
1092        let reference = make_gray_image(10, 10, 200);
1093        let matched = match_histogram_image(&source, &reference).expect("matching should succeed");
1094        let gray = matched.to_luma8();
1095        let val = gray.get_pixel(0, 0)[0];
1096        assert!(val >= 180, "Expected bright pixels, got {val}");
1097    }
1098
1099    #[test]
1100    fn test_compute_cdf() {
1101        let img = make_gray_image(10, 10, 100);
1102        let cdf = compute_cdf(&img);
1103        assert!((cdf[255] - 1.0).abs() < 1e-10);
1104        assert!(cdf[99] < 0.01);
1105    }
1106
1107    #[test]
1108    fn test_otsu_bimodal() {
1109        let img = make_bimodal_image(100, 10);
1110        let t = otsu_threshold(&img);
1111        // Threshold should fall between the two peaks (inclusive bounds)
1112        assert!(
1113            (50..=200).contains(&t),
1114            "Otsu threshold {t} not between 50 and 200"
1115        );
1116    }
1117
1118    #[test]
1119    fn test_binarize_otsu() {
1120        let img = make_bimodal_image(100, 10);
1121        let (binary, threshold) = binarize_otsu(&img).expect("binarize should succeed");
1122        assert!(
1123            (50..=200).contains(&threshold),
1124            "Threshold {threshold} not in [50, 200]"
1125        );
1126        let gray = binary.to_luma8();
1127        // Left half (value 50) should be <= threshold -> 0,
1128        // right half (value 200) should be > threshold -> 255
1129        assert_eq!(gray.get_pixel(10, 5)[0], 0);
1130        assert_eq!(gray.get_pixel(90, 5)[0], 255);
1131    }
1132
1133    #[test]
1134    fn test_multi_otsu_single() {
1135        let img = make_bimodal_image(100, 10);
1136        let thresholds = multi_otsu_threshold(&img, 1).expect("single threshold should work");
1137        assert_eq!(thresholds.len(), 1);
1138        assert!(
1139            thresholds[0] >= 50 && thresholds[0] <= 200,
1140            "Threshold {} not in [50, 200]",
1141            thresholds[0]
1142        );
1143    }
1144
1145    #[test]
1146    fn test_multi_otsu_two() {
1147        // Create a trimodal image
1148        let mut img = GrayImage::new(300, 10);
1149        for y in 0..10 {
1150            for x in 0..100 {
1151                img.put_pixel(x, y, Luma([30]));
1152            }
1153            for x in 100..200 {
1154                img.put_pixel(x, y, Luma([128]));
1155            }
1156            for x in 200..300 {
1157                img.put_pixel(x, y, Luma([220]));
1158            }
1159        }
1160        let dyn_img = DynamicImage::ImageLuma8(img);
1161        let thresholds = multi_otsu_threshold(&dyn_img, 2).expect("two thresholds should work");
1162        assert_eq!(thresholds.len(), 2);
1163        assert!(thresholds[0] < thresholds[1]);
1164        // First threshold between 30 and 128 (inclusive)
1165        assert!(
1166            thresholds[0] >= 30 && thresholds[0] <= 128,
1167            "First threshold {} not in [30, 128]",
1168            thresholds[0]
1169        );
1170        // Second threshold between 100 and 220 (inclusive)
1171        assert!(
1172            thresholds[1] >= 100 && thresholds[1] <= 220,
1173            "Second threshold {} not in [100, 220]",
1174            thresholds[1]
1175        );
1176    }
1177
1178    #[test]
1179    fn test_multi_otsu_invalid() {
1180        let img = make_gray_image(10, 10, 100);
1181        assert!(multi_otsu_threshold(&img, 0).is_err());
1182        assert!(multi_otsu_threshold(&img, 5).is_err());
1183    }
1184
1185    #[test]
1186    fn test_backproject_histogram() {
1187        let img = make_rgb_image(20, 20);
1188        let mut model = Histogram::new();
1189        model.bins[128] = 100;
1190        model.total = 100;
1191
1192        let result = backproject_histogram(&img, &model, 2).expect("backproject should succeed");
1193        assert_eq!(result.width(), 20);
1194        assert_eq!(result.height(), 20);
1195        // Blue channel is constant at 128, so all should be bright
1196        let gray = result.to_luma8();
1197        for pixel in gray.pixels() {
1198            assert_eq!(pixel[0], 255);
1199        }
1200    }
1201
1202    #[test]
1203    fn test_backproject_invalid_channel() {
1204        let img = make_gray_image(10, 10, 100);
1205        let model = Histogram::new();
1206        assert!(backproject_histogram(&img, &model, 5).is_err());
1207    }
1208
1209    #[test]
1210    fn test_equalize_histogram_color() {
1211        let img = make_rgb_image(20, 20);
1212        let result = equalize_histogram_color(&img).expect("color equalization should succeed");
1213        assert_eq!(result.width(), 20);
1214        assert_eq!(result.height(), 20);
1215    }
1216
1217    #[test]
1218    fn test_histogram_normalized_sums_to_one() {
1219        let img = make_gradient_image(128, 4);
1220        let hist = compute_histogram(&img);
1221        let norm = hist.normalized();
1222        let sum: f64 = norm.iter().sum();
1223        assert!(
1224            (sum - 1.0).abs() < 1e-10,
1225            "Normalized histogram sum = {sum}"
1226        );
1227    }
1228
1229    #[test]
1230    fn test_backproject_hs_histogram() {
1231        let img = make_rgb_image(20, 20);
1232        let hue_hist = Histogram {
1233            bins: [1u64; NUM_BINS],
1234            total: NUM_BINS as u64,
1235        };
1236        let sat_hist = Histogram {
1237            bins: [1u64; NUM_BINS],
1238            total: NUM_BINS as u64,
1239        };
1240        let result = backproject_hs_histogram(&img, &hue_hist, &sat_hist)
1241            .expect("HS backproject should succeed");
1242        assert_eq!(result.width(), 20);
1243        assert_eq!(result.height(), 20);
1244    }
1245}