1use crate::error::{Result, VisionError};
12use image::{DynamicImage, GrayImage, ImageBuffer, Luma, Rgb, RgbImage};
13
14const NUM_BINS: usize = 256;
16
17#[derive(Debug, Clone)]
19pub struct Histogram {
20 pub bins: [u64; NUM_BINS],
22 pub total: u64,
24}
25
26impl Histogram {
27 pub fn new() -> Self {
29 Self {
30 bins: [0u64; NUM_BINS],
31 total: 0,
32 }
33 }
34
35 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 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 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 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 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 pub fn min_value(&self) -> Option<u8> {
95 self.bins.iter().position(|&c| c > 0).map(|i| i as u8)
96 }
97
98 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#[derive(Debug, Clone)]
112pub struct ColorHistogram {
113 pub red: Histogram,
115 pub green: Histogram,
117 pub blue: Histogram,
119}
120
121impl ColorHistogram {
122 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
138pub 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
161pub 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
184pub 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 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 let cdf_min = cdf.iter().copied().find(|&v| v > 0).unwrap_or(0);
222
223 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 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
244pub 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 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 #[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 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 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
380pub fn match_histogram(source: &DynamicImage, reference_hist: &Histogram) -> Result<DynamicImage> {
398 let gray = source.to_luma8();
399 let (width, height) = gray.dimensions();
400
401 let src_hist = compute_histogram(source);
403 let src_cdf = src_hist.cdf();
404
405 let ref_cdf = reference_hist.cdf();
407
408 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
433pub 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
453pub fn compute_cdf(img: &DynamicImage) -> [f64; NUM_BINS] {
467 let hist = compute_histogram(img);
468 hist.cdf()
469}
470
471pub fn otsu_threshold(img: &DynamicImage) -> u8 {
488 let hist = compute_histogram(img);
489 otsu_threshold_from_histogram(&hist)
490}
491
492pub 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 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; let mut sum0 = 0.0f64; 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
536pub 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 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 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 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 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 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 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
708pub 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
731pub 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 let max_prob = norm.iter().copied().fold(0.0f64, f64::max);
765 if max_prob < 1e-15 {
766 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
786pub 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 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 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
859pub 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 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 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#[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 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 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 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 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 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 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 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 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 assert!(
1166 thresholds[0] >= 30 && thresholds[0] <= 128,
1167 "First threshold {} not in [30, 128]",
1168 thresholds[0]
1169 );
1170 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 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}