1use spatialrust_image::{Image, ImageView};
4
5use crate::border::fetch;
6use crate::{BorderMode, PixelComponent, VisionError, VisionResult};
7
8#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
10pub enum ThresholdType {
11 Binary,
13 BinaryInv,
15 Truncate,
17 ToZero,
19 ToZeroInv,
21}
22
23#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
25pub enum AdaptiveThresholdMethod {
26 Mean,
28 Gaussian,
30}
31
32pub fn threshold<T: PixelComponent, const CHANNELS: usize>(
34 input: ImageView<'_, T, CHANNELS>,
35 threshold: f64,
36 max_value: f64,
37 threshold_type: ThresholdType,
38) -> VisionResult<Image<T, CHANNELS>> {
39 if !threshold.is_finite() || !max_value.is_finite() {
40 return Err(VisionError::InvalidParameter("threshold and max_value must be finite".into()));
41 }
42 let mut output = Vec::with_capacity(input.width() * input.height() * CHANNELS);
43 for y in 0..input.height() {
44 for x in 0..input.width() {
45 let pixel = input.get(x, y).expect("input coordinate in bounds");
46 output.extend(std::array::from_fn::<_, CHANNELS, _>(|channel| {
47 T::from_f64(apply_threshold(
48 pixel[channel].to_f64(),
49 threshold,
50 max_value,
51 threshold_type,
52 ))
53 }));
54 }
55 }
56 Ok(Image::try_new_with_metadata(input.width(), input.height(), output, input.metadata())?)
57}
58
59pub fn otsu_threshold_u8(
61 input: ImageView<'_, u8, 1>,
62 max_value: u8,
63 threshold_type: ThresholdType,
64) -> VisionResult<(u8, Image<u8, 1>)> {
65 let histogram = histogram_u8(input);
66 let selected = otsu_from_histogram(&histogram, input.width() * input.height()) as u8;
67 let output = threshold(input, f64::from(selected), f64::from(max_value), threshold_type)?;
68 Ok((selected, output))
69}
70
71pub fn otsu_threshold_u16(
73 input: ImageView<'_, u16, 1>,
74 max_value: u16,
75 threshold_type: ThresholdType,
76) -> VisionResult<(u16, Image<u16, 1>)> {
77 let mut histogram = vec![0_u64; 65_536];
78 for y in 0..input.height() {
79 for x in 0..input.width() {
80 histogram[input.get(x, y).expect("coordinate in bounds")[0] as usize] += 1;
81 }
82 }
83 let selected = otsu_from_histogram(&histogram, input.width() * input.height()) as u16;
84 let output = threshold(input, f64::from(selected), f64::from(max_value), threshold_type)?;
85 Ok((selected, output))
86}
87
88pub fn adaptive_threshold(
90 input: ImageView<'_, u8, 1>,
91 max_value: u8,
92 method: AdaptiveThresholdMethod,
93 threshold_type: ThresholdType,
94 block_size: usize,
95 c: f64,
96 border: BorderMode<u8, 1>,
97) -> VisionResult<Image<u8, 1>> {
98 if !matches!(threshold_type, ThresholdType::Binary | ThresholdType::BinaryInv) {
99 return Err(VisionError::InvalidParameter(
100 "adaptive threshold supports only binary and binary-inverse output".into(),
101 ));
102 }
103 if block_size <= 1 || block_size % 2 == 0 {
104 return Err(VisionError::InvalidParameter(
105 "adaptive block size must be odd and greater than one".into(),
106 ));
107 }
108 if !c.is_finite() {
109 return Err(VisionError::InvalidParameter("adaptive constant must be finite".into()));
110 }
111 let weights = match method {
112 AdaptiveThresholdMethod::Mean => vec![1.0 / (block_size * block_size) as f64; block_size],
113 AdaptiveThresholdMethod::Gaussian => gaussian_weights(block_size),
114 };
115 let radius = (block_size / 2) as isize;
116 let mut output = Vec::with_capacity(input.width() * input.height());
117 for y in 0..input.height() {
118 for x in 0..input.width() {
119 let mut local = 0.0;
120 for ky in 0..block_size {
121 for kx in 0..block_size {
122 let pixel = fetch(
123 input,
124 x as isize + kx as isize - radius,
125 y as isize + ky as isize - radius,
126 border,
127 )[0];
128 let weight = match method {
129 AdaptiveThresholdMethod::Mean => weights[kx],
130 AdaptiveThresholdMethod::Gaussian => weights[kx] * weights[ky],
131 };
132 local += f64::from(pixel) * weight;
133 }
134 }
135 let source = i64::from(input.get(x, y).expect("coordinate in bounds")[0]);
136 let local = cv_round(local).clamp(0, 255);
137 let delta = match threshold_type {
138 ThresholdType::Binary => c.ceil() as i64,
139 ThresholdType::BinaryInv => c.floor() as i64,
140 _ => unreachable!("adaptive threshold type checked above"),
141 };
142 let selected = match threshold_type {
143 ThresholdType::Binary => source - local > -delta,
144 ThresholdType::BinaryInv => source - local <= -delta,
145 _ => unreachable!("adaptive threshold type checked above"),
146 };
147 output.push(if selected { max_value } else { 0 });
148 }
149 }
150 Ok(Image::try_new_with_metadata(input.width(), input.height(), output, input.metadata())?)
151}
152
153pub fn histogram<T: PixelComponent, const CHANNELS: usize>(
155 input: ImageView<'_, T, CHANNELS>,
156 channel: usize,
157 bins: usize,
158 range: (f64, f64),
159 mask: Option<ImageView<'_, u8, 1>>,
160) -> VisionResult<Vec<u64>> {
161 if channel >= CHANNELS {
162 return Err(VisionError::InvalidParameter(format!(
163 "histogram channel {channel} is outside {CHANNELS} channels"
164 )));
165 }
166 if bins == 0 {
167 return Err(VisionError::InvalidParameter("histogram bins must be non-zero".into()));
168 }
169 if !range.0.is_finite() || !range.1.is_finite() || range.1 <= range.0 {
170 return Err(VisionError::InvalidParameter(
171 "histogram range must be finite and increasing".into(),
172 ));
173 }
174 if let Some(mask) = mask {
175 if mask.width() != input.width() || mask.height() != input.height() {
176 return Err(VisionError::ShapeMismatch(
177 "histogram mask dimensions must match the image".into(),
178 ));
179 }
180 }
181 let mut result = vec![0_u64; bins];
182 let scale = bins as f64 / (range.1 - range.0);
183 for y in 0..input.height() {
184 for x in 0..input.width() {
185 if mask.is_some_and(|mask| mask.get(x, y).expect("mask coordinate in bounds")[0] == 0) {
186 continue;
187 }
188 let value = input.get(x, y).expect("coordinate in bounds")[channel].to_f64();
189 if value >= range.0 && value < range.1 {
190 let bin = ((value - range.0) * scale).floor() as usize;
191 result[bin.min(bins - 1)] += 1;
192 }
193 }
194 }
195 Ok(result)
196}
197
198#[must_use]
200pub fn histogram_u8(input: ImageView<'_, u8, 1>) -> Vec<u64> {
201 let mut result = vec![0_u64; 256];
202 for y in 0..input.height() {
203 for x in 0..input.width() {
204 result[input.get(x, y).expect("coordinate in bounds")[0] as usize] += 1;
205 }
206 }
207 result
208}
209
210pub fn equalize_histogram(input: ImageView<'_, u8, 1>) -> VisionResult<Image<u8, 1>> {
212 let histogram = histogram_u8(input);
213 let total = input.width() * input.height();
214 if total == 0 {
215 return Ok(Image::try_new_with_metadata(0, 0, Vec::new(), input.metadata())?);
216 }
217 let first = histogram.iter().position(|&count| count != 0).expect("non-empty image");
218 if histogram[first] as usize == total {
219 return Ok(Image::try_new_with_metadata(
220 input.width(),
221 input.height(),
222 vec![first as u8; total],
223 input.metadata(),
224 )?);
225 }
226 let scale = 255.0 / (total as f64 - histogram[first] as f64);
227 let mut cumulative = 0_u64;
228 let mut lookup = [0_u8; 256];
229 for (value, &count) in histogram.iter().enumerate() {
230 cumulative += count;
231 lookup[value] = if value <= first {
232 0
233 } else {
234 cv_round((cumulative - histogram[first]) as f64 * scale).clamp(0, 255) as u8
235 };
236 }
237 map_u8(input, &lookup)
238}
239
240pub fn clahe(
242 input: ImageView<'_, u8, 1>,
243 clip_limit: f64,
244 tiles_x: usize,
245 tiles_y: usize,
246) -> VisionResult<Image<u8, 1>> {
247 if !clip_limit.is_finite() || clip_limit < 0.0 {
248 return Err(VisionError::InvalidParameter(
249 "CLAHE clip limit must be finite and non-negative".into(),
250 ));
251 }
252 if tiles_x == 0 || tiles_y == 0 {
253 return Err(VisionError::InvalidParameter(
254 "CLAHE tile grid dimensions must be non-zero".into(),
255 ));
256 }
257 if input.width() == 0 || input.height() == 0 {
258 return Ok(Image::try_new_with_metadata(0, 0, Vec::new(), input.metadata())?);
259 }
260 let tile_width = input.width().div_ceil(tiles_x);
261 let tile_height = input.height().div_ceil(tiles_y);
262 let tile_area = tile_width * tile_height;
263 let clip_count = if clip_limit > 0.0 {
264 ((clip_limit * tile_area as f64 / 256.0) as usize).max(1)
265 } else {
266 0
267 };
268 let mut lookups = vec![[0_u8; 256]; tiles_x * tiles_y];
269 for tile_y in 0..tiles_y {
270 for tile_x in 0..tiles_x {
271 let mut histogram = [0_usize; 256];
272 for local_y in 0..tile_height {
273 for local_x in 0..tile_width {
274 let x = tile_x * tile_width + local_x;
275 let y = tile_y * tile_height + local_y;
276 let value = fetch(input, x as isize, y as isize, BorderMode::Reflect101)[0];
277 histogram[value as usize] += 1;
278 }
279 }
280 if clip_count > 0 {
281 clip_histogram(&mut histogram, clip_count);
282 }
283 let scale = 255.0 / tile_area as f64;
284 let mut cumulative = 0_usize;
285 for (value, &count) in histogram.iter().enumerate() {
286 cumulative += count;
287 lookups[tile_y * tiles_x + tile_x][value] =
288 cv_round(cumulative as f64 * scale).clamp(0, 255) as u8;
289 }
290 }
291 }
292 interpolate_clahe(input, &lookups, tiles_x, tiles_y, tile_width, tile_height)
293}
294
295#[derive(Clone, Debug, PartialEq)]
297pub struct IntegralImage {
298 source_width: usize,
299 source_height: usize,
300 data: Vec<f64>,
301}
302
303impl IntegralImage {
304 #[must_use]
306 pub const fn source_width(&self) -> usize {
307 self.source_width
308 }
309
310 #[must_use]
312 pub const fn source_height(&self) -> usize {
313 self.source_height
314 }
315
316 #[must_use]
318 pub const fn width(&self) -> usize {
319 self.source_width + 1
320 }
321
322 #[must_use]
324 pub const fn height(&self) -> usize {
325 self.source_height + 1
326 }
327
328 #[must_use]
330 pub fn as_slice(&self) -> &[f64] {
331 &self.data
332 }
333
334 pub fn sum_region(&self, x0: usize, y0: usize, x1: usize, y1: usize) -> VisionResult<f64> {
336 if x0 > x1 || y0 > y1 || x1 > self.source_width || y1 > self.source_height {
337 return Err(VisionError::InvalidParameter(
338 "integral region is outside source dimensions".into(),
339 ));
340 }
341 let stride = self.width();
342 Ok(self.data[y1 * stride + x1] - self.data[y0 * stride + x1] - self.data[y1 * stride + x0]
343 + self.data[y0 * stride + x0])
344 }
345}
346
347pub fn integral_image<T: PixelComponent, const CHANNELS: usize>(
349 input: ImageView<'_, T, CHANNELS>,
350 channel: usize,
351) -> VisionResult<IntegralImage> {
352 if channel >= CHANNELS {
353 return Err(VisionError::InvalidParameter(format!(
354 "integral channel {channel} is outside {CHANNELS} channels"
355 )));
356 }
357 let width = input
358 .width()
359 .checked_add(1)
360 .ok_or_else(|| VisionError::InvalidDimensions("integral width overflows".into()))?;
361 let height = input
362 .height()
363 .checked_add(1)
364 .ok_or_else(|| VisionError::InvalidDimensions("integral height overflows".into()))?;
365 let len = width
366 .checked_mul(height)
367 .ok_or_else(|| VisionError::InvalidDimensions("integral allocation overflows".into()))?;
368 let mut data = vec![0.0; len];
369 for y in 0..input.height() {
370 let mut row_sum = 0.0;
371 for x in 0..input.width() {
372 row_sum += input.get(x, y).expect("coordinate in bounds")[channel].to_f64();
373 data[(y + 1) * width + x + 1] = data[y * width + x + 1] + row_sum;
374 }
375 }
376 Ok(IntegralImage { source_width: input.width(), source_height: input.height(), data })
377}
378
379fn apply_threshold(
380 value: f64,
381 threshold: f64,
382 max_value: f64,
383 threshold_type: ThresholdType,
384) -> f64 {
385 match threshold_type {
386 ThresholdType::Binary => {
387 if value > threshold {
388 max_value
389 } else {
390 0.0
391 }
392 }
393 ThresholdType::BinaryInv => {
394 if value > threshold {
395 0.0
396 } else {
397 max_value
398 }
399 }
400 ThresholdType::Truncate => value.min(threshold),
401 ThresholdType::ToZero => {
402 if value > threshold {
403 value
404 } else {
405 0.0
406 }
407 }
408 ThresholdType::ToZeroInv => {
409 if value > threshold {
410 0.0
411 } else {
412 value
413 }
414 }
415 }
416}
417
418fn otsu_from_histogram(histogram: &[u64], total: usize) -> usize {
419 if total == 0 {
420 return 0;
421 }
422 let global_mean = histogram
423 .iter()
424 .enumerate()
425 .map(|(value, &count)| value as f64 * count as f64)
426 .sum::<f64>();
427 let mut background_count = 0_u64;
428 let mut background_sum = 0.0;
429 let mut best_variance = -1.0;
430 let mut best = 0;
431 for (value, &count) in histogram.iter().enumerate() {
432 background_count += count;
433 if background_count == 0 {
434 continue;
435 }
436 let foreground_count = total as u64 - background_count;
437 if foreground_count == 0 {
438 break;
439 }
440 background_sum += value as f64 * count as f64;
441 let background_mean = background_sum / background_count as f64;
442 let foreground_mean = (global_mean - background_sum) / foreground_count as f64;
443 let difference = background_mean - foreground_mean;
444 let variance = background_count as f64 * foreground_count as f64 * difference * difference;
445 if variance > best_variance {
446 best_variance = variance;
447 best = value;
448 }
449 }
450 best
451}
452
453fn gaussian_weights(size: usize) -> Vec<f64> {
454 let exact: Option<&[f64]> = match size {
455 1 => Some(&[1.0]),
456 3 => Some(&[0.25, 0.5, 0.25]),
457 5 => Some(&[0.0625, 0.25, 0.375, 0.25, 0.0625]),
458 7 => Some(&[0.03125, 0.109375, 0.21875, 0.28125, 0.21875, 0.109375, 0.03125]),
459 9 => Some(&[
460 0.015625, 0.05078125, 0.1171875, 0.19921875, 0.234375, 0.19921875, 0.1171875,
461 0.05078125, 0.015625,
462 ]),
463 _ => None,
464 };
465 if let Some(exact) = exact {
466 return exact.to_vec();
467 }
468 let sigma = 0.3 * ((size as f64 - 1.0) * 0.5 - 1.0) + 0.8;
469 let center = (size / 2) as f64;
470 let mut weights = (0..size)
471 .map(|index| {
472 let offset = index as f64 - center;
473 (-(offset * offset) / (2.0 * sigma * sigma)).exp()
474 })
475 .collect::<Vec<_>>();
476 let sum = weights.iter().sum::<f64>();
477 weights.iter_mut().for_each(|weight| *weight /= sum);
478 weights
479}
480
481fn map_u8(input: ImageView<'_, u8, 1>, lookup: &[u8; 256]) -> VisionResult<Image<u8, 1>> {
482 let mut output = Vec::with_capacity(input.width() * input.height());
483 for y in 0..input.height() {
484 for x in 0..input.width() {
485 output.push(lookup[input.get(x, y).expect("coordinate in bounds")[0] as usize]);
486 }
487 }
488 Ok(Image::try_new_with_metadata(input.width(), input.height(), output, input.metadata())?)
489}
490
491fn clip_histogram(histogram: &mut [usize; 256], clip_limit: usize) {
492 let mut clipped = 0_usize;
493 for count in histogram.iter_mut() {
494 if *count > clip_limit {
495 clipped += *count - clip_limit;
496 *count = clip_limit;
497 }
498 }
499 let batch = clipped / 256;
500 if batch > 0 {
501 histogram.iter_mut().for_each(|count| *count += batch);
502 }
503 let residual = clipped - batch * 256;
504 let step = 256_usize.checked_div(residual).unwrap_or(1).max(1);
505 for index in (0..256).step_by(step).take(residual) {
506 histogram[index] += 1;
507 }
508}
509
510fn interpolate_clahe(
511 input: ImageView<'_, u8, 1>,
512 lookups: &[[u8; 256]],
513 tiles_x: usize,
514 tiles_y: usize,
515 tile_width: usize,
516 tile_height: usize,
517) -> VisionResult<Image<u8, 1>> {
518 let mut output = Vec::with_capacity(input.width() * input.height());
519 for y in 0..input.height() {
520 let ty = y as f64 / tile_height as f64 - 0.5;
521 let y0_raw = ty.floor() as isize;
522 let ya = ty - ty.floor();
523 let y0 = y0_raw.clamp(0, tiles_y as isize - 1) as usize;
524 let y1 = (y0_raw + 1).clamp(0, tiles_y as isize - 1) as usize;
525 for x in 0..input.width() {
526 let tx = x as f64 / tile_width as f64 - 0.5;
527 let x0_raw = tx.floor() as isize;
528 let xa = tx - tx.floor();
529 let x0 = x0_raw.clamp(0, tiles_x as isize - 1) as usize;
530 let x1 = (x0_raw + 1).clamp(0, tiles_x as isize - 1) as usize;
531 let value = input.get(x, y).expect("coordinate in bounds")[0] as usize;
532 let top = f64::from(lookups[y0 * tiles_x + x0][value]) * (1.0 - xa)
533 + f64::from(lookups[y0 * tiles_x + x1][value]) * xa;
534 let bottom = f64::from(lookups[y1 * tiles_x + x0][value]) * (1.0 - xa)
535 + f64::from(lookups[y1 * tiles_x + x1][value]) * xa;
536 output.push(cv_round(top * (1.0 - ya) + bottom * ya).clamp(0, 255) as u8);
537 }
538 }
539 Ok(Image::try_new_with_metadata(input.width(), input.height(), output, input.metadata())?)
540}
541
542fn cv_round(value: f64) -> i64 {
543 let floor = value.floor();
544 let fraction = value - floor;
545 if fraction < 0.5 {
546 floor as i64
547 } else if fraction > 0.5 {
548 floor as i64 + 1
549 } else {
550 let base = floor as i64;
551 if base % 2 == 0 {
552 base
553 } else {
554 base + 1
555 }
556 }
557}
558
559#[cfg(test)]
560mod tests {
561 use super::{
562 adaptive_threshold, clahe, equalize_histogram, histogram, integral_image,
563 otsu_threshold_u8, threshold, AdaptiveThresholdMethod, ThresholdType,
564 };
565 use crate::BorderMode;
566 use spatialrust_image::{Image, ImageRegion};
567
568 #[test]
569 fn fixed_threshold_types_match_definitions_on_roi() {
570 let image = Image::<u16, 1>::try_new(5, 1, vec![0, 10, 20, 30, 40]).unwrap();
571 let roi = image.view().subview(ImageRegion::new(1, 0, 3, 1)).unwrap();
572 assert_eq!(
573 threshold(roi, 20.0, 100.0, ThresholdType::Binary).unwrap().as_slice(),
574 &[0, 0, 100]
575 );
576 assert_eq!(
577 threshold(roi, 20.0, 100.0, ThresholdType::Truncate).unwrap().as_slice(),
578 &[10, 20, 20]
579 );
580 }
581
582 #[test]
583 fn otsu_separates_bimodal_values() {
584 let image = Image::<u8, 1>::try_new(6, 1, vec![10, 10, 10, 200, 200, 200]).unwrap();
585 let (selected, output) =
586 otsu_threshold_u8(image.view(), 255, ThresholdType::Binary).unwrap();
587 assert_eq!(selected, 10);
588 assert_eq!(output.as_slice(), &[0, 0, 0, 255, 255, 255]);
589 }
590
591 #[test]
592 fn adaptive_threshold_rejects_even_blocks() {
593 let image = Image::<u8, 1>::from_pixel(3, 3, [10]).unwrap();
594 assert!(adaptive_threshold(
595 image.view(),
596 255,
597 AdaptiveThresholdMethod::Mean,
598 ThresholdType::Binary,
599 2,
600 0.0,
601 BorderMode::Replicate
602 )
603 .is_err());
604 }
605
606 #[test]
607 fn histogram_honors_channel_range_and_mask() {
608 let image =
609 Image::<f32, 2>::try_new(2, 2, vec![0.0, 0.2, 0.5, 0.7, 0.9, 1.1, 1.0, 0.4]).unwrap();
610 let mask = Image::<u8, 1>::try_new(2, 2, vec![1, 0, 1, 1]).unwrap();
611 assert_eq!(
612 histogram(image.view(), 0, 2, (0.0, 1.0), Some(mask.view())).unwrap(),
613 vec![1, 1]
614 );
615 }
616
617 #[test]
618 fn equalization_spreads_low_contrast_values() {
619 let image = Image::<u8, 1>::try_new(4, 1, vec![10, 10, 20, 30]).unwrap();
620 assert_eq!(equalize_histogram(image.view()).unwrap().as_slice(), &[0, 0, 128, 255]);
621 }
622
623 #[test]
624 fn clahe_preserves_dimensions_and_constant_input() {
625 let image = Image::<u8, 1>::from_pixel(7, 5, [42]).unwrap();
626 let output = clahe(image.view(), 2.0, 3, 2).unwrap();
627 assert_eq!((output.width(), output.height()), (7, 5));
628 }
629
630 #[test]
631 fn integral_region_matches_direct_sum() {
632 let image = Image::<f32, 1>::try_new(3, 2, vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]).unwrap();
633 let integral = integral_image(image.view(), 0).unwrap();
634 assert_eq!((integral.width(), integral.height()), (4, 3));
635 assert_eq!(integral.sum_region(1, 0, 3, 2).unwrap(), 16.0);
636 }
637}