1use rayon::prelude::*;
4use spatialrust_image::{Image, ImageView};
5
6use crate::border::fetch;
7use crate::dispatch::{
8 bounded_workers, items_per_worker, should_parallelize, LARGE_PARALLEL_COMPONENTS,
9 LIGHT_PARALLEL_COMPONENTS,
10};
11use crate::{BorderMode, PixelComponent, VisionError, VisionResult};
12
13#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
15pub enum ThresholdType {
16 Binary,
18 BinaryInv,
20 Truncate,
22 ToZero,
24 ToZeroInv,
26}
27
28#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
30pub enum AdaptiveThresholdMethod {
31 Mean,
33 Gaussian,
35}
36
37#[inline]
39pub fn threshold<T: PixelComponent, const CHANNELS: usize>(
40 input: ImageView<'_, T, CHANNELS>,
41 threshold: f64,
42 max_value: f64,
43 threshold_type: ThresholdType,
44) -> VisionResult<Image<T, CHANNELS>> {
45 if !threshold.is_finite() || !max_value.is_finite() {
46 return Err(VisionError::InvalidParameter("threshold and max_value must be finite".into()));
47 }
48 let workers = analysis_workers(
49 input.width() * input.height() * CHANNELS,
50 input.height(),
51 LIGHT_PARALLEL_COMPONENTS,
52 );
53 if workers == 1 {
54 return threshold_scalar(input, threshold, max_value, threshold_type);
55 }
56 let zero = T::from_f64(0.0);
59 match threshold_type {
60 ThresholdType::Binary => {
61 let max = T::from_f64(max_value);
62 map_rows(input, workers, |value| if value.to_f64() > threshold { max } else { zero })
63 }
64 ThresholdType::BinaryInv => {
65 let max = T::from_f64(max_value);
66 map_rows(input, workers, |value| if value.to_f64() > threshold { zero } else { max })
67 }
68 ThresholdType::Truncate => {
69 map_rows(input, workers, |value| T::from_f64(value.to_f64().min(threshold)))
70 }
71 ThresholdType::ToZero => map_rows(input, workers, |value| {
72 let value = value.to_f64();
73 if value > threshold {
74 T::from_f64(value)
75 } else {
76 zero
77 }
78 }),
79 ThresholdType::ToZeroInv => map_rows(input, workers, |value| {
80 let value = value.to_f64();
81 if value > threshold {
82 zero
83 } else {
84 T::from_f64(value)
85 }
86 }),
87 }
88}
89
90fn map_rows<T: PixelComponent, const CHANNELS: usize>(
91 input: ImageView<'_, T, CHANNELS>,
92 workers: usize,
93 transform: impl Fn(T) -> T + Sync,
94) -> VisionResult<Image<T, CHANNELS>> {
95 let row_len = input.width() * CHANNELS;
96 let len = row_len * input.height();
97 let mut output = vec![T::from_f64(0.0); len];
98 let rows = items_per_worker(input.height(), workers);
99 output.par_chunks_mut(rows * row_len).enumerate().for_each(|(band, output)| {
100 for (row, target) in output.chunks_mut(row_len).enumerate() {
101 let source = input.row(band * rows + row).expect("validated image row");
102 for (to, &from) in target.iter_mut().zip(source) {
103 *to = transform(from);
104 }
105 }
106 });
107 Ok(Image::try_new_with_metadata(input.width(), input.height(), output, input.metadata())?)
108}
109
110#[inline]
111fn threshold_scalar<T: PixelComponent, const CHANNELS: usize>(
112 input: ImageView<'_, T, CHANNELS>,
113 threshold: f64,
114 max_value: f64,
115 threshold_type: ThresholdType,
116) -> VisionResult<Image<T, CHANNELS>> {
117 let mut output = Vec::with_capacity(input.width() * input.height() * CHANNELS);
118 for y in 0..input.height() {
119 for x in 0..input.width() {
120 let pixel = input.get(x, y).expect("input coordinate in bounds");
121 output.extend(std::array::from_fn::<_, CHANNELS, _>(|channel| {
122 T::from_f64(apply_threshold(
123 pixel[channel].to_f64(),
124 threshold,
125 max_value,
126 threshold_type,
127 ))
128 }));
129 }
130 }
131 Ok(Image::try_new_with_metadata(input.width(), input.height(), output, input.metadata())?)
132}
133
134fn analysis_workers(components: usize, rows: usize, threshold: usize) -> usize {
135 if should_parallelize(components, rows, threshold) {
136 bounded_workers(components, rows, threshold, rayon::current_num_threads())
137 } else {
138 1
140 }
141}
142
143pub fn otsu_threshold_u8(
145 input: ImageView<'_, u8, 1>,
146 max_value: u8,
147 threshold_type: ThresholdType,
148) -> VisionResult<(u8, Image<u8, 1>)> {
149 let histogram = histogram_u8(input);
150 let selected = otsu_from_histogram(&histogram, input.width() * input.height()) as u8;
151 let output = threshold(input, f64::from(selected), f64::from(max_value), threshold_type)?;
152 Ok((selected, output))
153}
154
155pub fn otsu_threshold_u16(
157 input: ImageView<'_, u16, 1>,
158 max_value: u16,
159 threshold_type: ThresholdType,
160) -> VisionResult<(u16, Image<u16, 1>)> {
161 let mut histogram = vec![0_u64; 65_536];
162 for y in 0..input.height() {
163 for x in 0..input.width() {
164 histogram[input.get(x, y).expect("coordinate in bounds")[0] as usize] += 1;
165 }
166 }
167 let selected = otsu_from_histogram(&histogram, input.width() * input.height()) as u16;
168 let output = threshold(input, f64::from(selected), f64::from(max_value), threshold_type)?;
169 Ok((selected, output))
170}
171
172pub fn adaptive_threshold(
174 input: ImageView<'_, u8, 1>,
175 max_value: u8,
176 method: AdaptiveThresholdMethod,
177 threshold_type: ThresholdType,
178 block_size: usize,
179 c: f64,
180 border: BorderMode<u8, 1>,
181) -> VisionResult<Image<u8, 1>> {
182 if !matches!(threshold_type, ThresholdType::Binary | ThresholdType::BinaryInv) {
183 return Err(VisionError::InvalidParameter(
184 "adaptive threshold supports only binary and binary-inverse output".into(),
185 ));
186 }
187 if block_size <= 1 || block_size % 2 == 0 {
188 return Err(VisionError::InvalidParameter(
189 "adaptive block size must be odd and greater than one".into(),
190 ));
191 }
192 if !c.is_finite() {
193 return Err(VisionError::InvalidParameter("adaptive constant must be finite".into()));
194 }
195 let weights = match method {
196 AdaptiveThresholdMethod::Mean => vec![1.0 / (block_size * block_size) as f64; block_size],
197 AdaptiveThresholdMethod::Gaussian => gaussian_weights(block_size),
198 };
199 let radius = (block_size / 2) as isize;
200 let mut output = Vec::with_capacity(input.width() * input.height());
201 for y in 0..input.height() {
202 for x in 0..input.width() {
203 let mut local = 0.0;
204 for ky in 0..block_size {
205 for kx in 0..block_size {
206 let pixel = fetch(
207 input,
208 x as isize + kx as isize - radius,
209 y as isize + ky as isize - radius,
210 border,
211 )[0];
212 let weight = match method {
213 AdaptiveThresholdMethod::Mean => weights[kx],
214 AdaptiveThresholdMethod::Gaussian => weights[kx] * weights[ky],
215 };
216 local += f64::from(pixel) * weight;
217 }
218 }
219 let source = i64::from(input.get(x, y).expect("coordinate in bounds")[0]);
220 let local = cv_round(local).clamp(0, 255);
221 let delta = match threshold_type {
222 ThresholdType::Binary => c.ceil() as i64,
223 ThresholdType::BinaryInv => c.floor() as i64,
224 _ => unreachable!("adaptive threshold type checked above"),
225 };
226 let selected = match threshold_type {
227 ThresholdType::Binary => source - local > -delta,
228 ThresholdType::BinaryInv => source - local <= -delta,
229 _ => unreachable!("adaptive threshold type checked above"),
230 };
231 output.push(if selected { max_value } else { 0 });
232 }
233 }
234 Ok(Image::try_new_with_metadata(input.width(), input.height(), output, input.metadata())?)
235}
236
237pub fn histogram<T: PixelComponent, const CHANNELS: usize>(
239 input: ImageView<'_, T, CHANNELS>,
240 channel: usize,
241 bins: usize,
242 range: (f64, f64),
243 mask: Option<ImageView<'_, u8, 1>>,
244) -> VisionResult<Vec<u64>> {
245 if channel >= CHANNELS {
246 return Err(VisionError::InvalidParameter(format!(
247 "histogram channel {channel} is outside {CHANNELS} channels"
248 )));
249 }
250 if bins == 0 {
251 return Err(VisionError::InvalidParameter("histogram bins must be non-zero".into()));
252 }
253 if !range.0.is_finite() || !range.1.is_finite() || range.1 <= range.0 {
254 return Err(VisionError::InvalidParameter(
255 "histogram range must be finite and increasing".into(),
256 ));
257 }
258 if let Some(mask) = mask {
259 if mask.width() != input.width() || mask.height() != input.height() {
260 return Err(VisionError::ShapeMismatch(
261 "histogram mask dimensions must match the image".into(),
262 ));
263 }
264 }
265 let mut result = vec![0_u64; bins];
266 let scale = bins as f64 / (range.1 - range.0);
267 for y in 0..input.height() {
268 for x in 0..input.width() {
269 if mask.is_some_and(|mask| mask.get(x, y).expect("mask coordinate in bounds")[0] == 0) {
270 continue;
271 }
272 let value = input.get(x, y).expect("coordinate in bounds")[channel].to_f64();
273 if value >= range.0 && value < range.1 {
274 let bin = ((value - range.0) * scale).floor() as usize;
275 result[bin.min(bins - 1)] += 1;
276 }
277 }
278 }
279 Ok(result)
280}
281
282#[must_use]
284pub fn histogram_u8(input: ImageView<'_, u8, 1>) -> Vec<u64> {
285 let workers =
286 analysis_workers(input.width() * input.height(), input.height(), LARGE_PARALLEL_COMPONENTS);
287 if workers > 1 {
288 let rows = items_per_worker(input.height(), workers);
289 let partials: Vec<[u64; 256]> = (0..input.height().div_ceil(rows))
291 .into_par_iter()
292 .map(|band| {
293 let mut histogram = [0_u64; 256];
294 for y in band * rows..((band + 1) * rows).min(input.height()) {
295 for &value in input.row(y).expect("validated image row") {
296 histogram[usize::from(value)] += 1;
297 }
298 }
299 histogram
300 })
301 .collect();
302 let mut result = vec![0_u64; 256];
303 for partial in partials {
304 for (count, value) in result.iter_mut().zip(partial) {
305 *count += value;
306 }
307 }
308 return result;
309 }
310 let mut result = vec![0_u64; 256];
311 for y in 0..input.height() {
312 for &value in input.row(y).expect("validated image row") {
313 result[usize::from(value)] += 1;
314 }
315 }
316 result
317}
318
319pub fn equalize_histogram(input: ImageView<'_, u8, 1>) -> VisionResult<Image<u8, 1>> {
321 let histogram = histogram_u8(input);
322 let total = input.width() * input.height();
323 if total == 0 {
324 return Ok(Image::try_new_with_metadata(0, 0, Vec::new(), input.metadata())?);
325 }
326 let first = histogram.iter().position(|&count| count != 0).expect("non-empty image");
327 if histogram[first] as usize == total {
328 return Ok(Image::try_new_with_metadata(
329 input.width(),
330 input.height(),
331 vec![first as u8; total],
332 input.metadata(),
333 )?);
334 }
335 let scale = 255.0 / (total as f64 - histogram[first] as f64);
336 let mut cumulative = 0_u64;
337 let mut lookup = [0_u8; 256];
338 for (value, &count) in histogram.iter().enumerate() {
339 cumulative += count;
340 lookup[value] = if value <= first {
341 0
342 } else {
343 cv_round((cumulative - histogram[first]) as f64 * scale).clamp(0, 255) as u8
344 };
345 }
346 map_u8(input, &lookup)
347}
348
349pub fn clahe(
351 input: ImageView<'_, u8, 1>,
352 clip_limit: f64,
353 tiles_x: usize,
354 tiles_y: usize,
355) -> VisionResult<Image<u8, 1>> {
356 if !clip_limit.is_finite() || clip_limit < 0.0 {
357 return Err(VisionError::InvalidParameter(
358 "CLAHE clip limit must be finite and non-negative".into(),
359 ));
360 }
361 if tiles_x == 0 || tiles_y == 0 {
362 return Err(VisionError::InvalidParameter(
363 "CLAHE tile grid dimensions must be non-zero".into(),
364 ));
365 }
366 if input.width() == 0 || input.height() == 0 {
367 return Ok(Image::try_new_with_metadata(0, 0, Vec::new(), input.metadata())?);
368 }
369 let tile_width = input.width().div_ceil(tiles_x);
370 let tile_height = input.height().div_ceil(tiles_y);
371 let tile_area = tile_width * tile_height;
372 let clip_count = if clip_limit > 0.0 {
373 ((clip_limit * tile_area as f64 / 256.0) as usize).max(1)
374 } else {
375 0
376 };
377 let tile_count = tiles_x
378 .checked_mul(tiles_y)
379 .ok_or_else(|| VisionError::InvalidDimensions("CLAHE tile grid overflows".into()))?;
380 if tile_count > isize::MAX as usize / std::mem::size_of::<[u8; 256]>() {
381 return Err(VisionError::InvalidDimensions("CLAHE lookup allocation overflows".into()));
382 }
383 let mut lookups = vec![[0_u8; 256]; tile_count];
384 let workers =
385 analysis_workers(input.width() * input.height(), tile_count, LARGE_PARALLEL_COMPONENTS);
386 let fill = |start: usize, lookups: &mut [[u8; 256]]| {
387 for (offset, lookup) in lookups.iter_mut().enumerate() {
388 let tile = start + offset;
389 clahe_tile_lookup(
390 input,
391 tile % tiles_x,
392 tile / tiles_x,
393 tile_width,
394 tile_height,
395 clip_count,
396 lookup,
397 );
398 }
399 };
400 if workers > 1 {
401 let tiles = items_per_worker(tile_count, workers);
402 lookups
403 .par_chunks_mut(tiles)
404 .enumerate()
405 .for_each(|(band, lookups)| fill(band * tiles, lookups));
406 } else {
407 fill(0, &mut lookups);
408 }
409 interpolate_clahe(input, &lookups, tiles_x, tiles_y, tile_width, tile_height)
410}
411
412#[allow(clippy::too_many_arguments)]
413fn clahe_tile_lookup(
414 input: ImageView<'_, u8, 1>,
415 tile_x: usize,
416 tile_y: usize,
417 tile_width: usize,
418 tile_height: usize,
419 clip_count: usize,
420 lookup: &mut [u8; 256],
421) {
422 let mut histogram = [0_usize; 256];
423 for local_y in 0..tile_height {
424 for local_x in 0..tile_width {
425 let x = tile_x * tile_width + local_x;
426 let y = tile_y * tile_height + local_y;
427 let value = fetch(input, x as isize, y as isize, BorderMode::Reflect101)[0];
428 histogram[value as usize] += 1;
429 }
430 }
431 if clip_count > 0 {
432 clip_histogram(&mut histogram, clip_count);
433 }
434 let scale = 255.0 / (tile_width * tile_height) as f64;
435 let mut cumulative = 0_usize;
436 for (value, &count) in histogram.iter().enumerate() {
437 cumulative += count;
438 lookup[value] = cv_round(cumulative as f64 * scale).clamp(0, 255) as u8;
439 }
440}
441
442#[derive(Clone, Debug, PartialEq)]
444pub struct IntegralImage {
445 source_width: usize,
446 source_height: usize,
447 data: Vec<f64>,
448}
449
450impl IntegralImage {
451 #[must_use]
453 pub const fn source_width(&self) -> usize {
454 self.source_width
455 }
456
457 #[must_use]
459 pub const fn source_height(&self) -> usize {
460 self.source_height
461 }
462
463 #[must_use]
465 pub const fn width(&self) -> usize {
466 self.source_width + 1
467 }
468
469 #[must_use]
471 pub const fn height(&self) -> usize {
472 self.source_height + 1
473 }
474
475 #[must_use]
477 pub fn as_slice(&self) -> &[f64] {
478 &self.data
479 }
480
481 pub fn sum_region(&self, x0: usize, y0: usize, x1: usize, y1: usize) -> VisionResult<f64> {
483 if x0 > x1 || y0 > y1 || x1 > self.source_width || y1 > self.source_height {
484 return Err(VisionError::InvalidParameter(
485 "integral region is outside source dimensions".into(),
486 ));
487 }
488 let stride = self.width();
489 Ok(self.data[y1 * stride + x1] - self.data[y0 * stride + x1] - self.data[y1 * stride + x0]
490 + self.data[y0 * stride + x0])
491 }
492}
493
494pub fn integral_image<T: PixelComponent, const CHANNELS: usize>(
496 input: ImageView<'_, T, CHANNELS>,
497 channel: usize,
498) -> VisionResult<IntegralImage> {
499 if channel >= CHANNELS {
500 return Err(VisionError::InvalidParameter(format!(
501 "integral channel {channel} is outside {CHANNELS} channels"
502 )));
503 }
504 let width = input
505 .width()
506 .checked_add(1)
507 .ok_or_else(|| VisionError::InvalidDimensions("integral width overflows".into()))?;
508 let height = input
509 .height()
510 .checked_add(1)
511 .ok_or_else(|| VisionError::InvalidDimensions("integral height overflows".into()))?;
512 let len = width
513 .checked_mul(height)
514 .ok_or_else(|| VisionError::InvalidDimensions("integral allocation overflows".into()))?;
515 let mut data = vec![0.0; len];
516 for y in 0..input.height() {
517 let mut row_sum = 0.0;
518 for x in 0..input.width() {
519 row_sum += input.get(x, y).expect("coordinate in bounds")[channel].to_f64();
520 data[(y + 1) * width + x + 1] = data[y * width + x + 1] + row_sum;
521 }
522 }
523 Ok(IntegralImage { source_width: input.width(), source_height: input.height(), data })
524}
525
526fn apply_threshold(
527 value: f64,
528 threshold: f64,
529 max_value: f64,
530 threshold_type: ThresholdType,
531) -> f64 {
532 match threshold_type {
533 ThresholdType::Binary => {
534 if value > threshold {
535 max_value
536 } else {
537 0.0
538 }
539 }
540 ThresholdType::BinaryInv => {
541 if value > threshold {
542 0.0
543 } else {
544 max_value
545 }
546 }
547 ThresholdType::Truncate => value.min(threshold),
548 ThresholdType::ToZero => {
549 if value > threshold {
550 value
551 } else {
552 0.0
553 }
554 }
555 ThresholdType::ToZeroInv => {
556 if value > threshold {
557 0.0
558 } else {
559 value
560 }
561 }
562 }
563}
564
565fn otsu_from_histogram(histogram: &[u64], total: usize) -> usize {
566 if total == 0 {
567 return 0;
568 }
569 let global_mean = histogram
570 .iter()
571 .enumerate()
572 .map(|(value, &count)| value as f64 * count as f64)
573 .sum::<f64>();
574 let mut background_count = 0_u64;
575 let mut background_sum = 0.0;
576 let mut best_variance = -1.0;
577 let mut best = 0;
578 for (value, &count) in histogram.iter().enumerate() {
579 background_count += count;
580 if background_count == 0 {
581 continue;
582 }
583 let foreground_count = total as u64 - background_count;
584 if foreground_count == 0 {
585 break;
586 }
587 background_sum += value as f64 * count as f64;
588 let background_mean = background_sum / background_count as f64;
589 let foreground_mean = (global_mean - background_sum) / foreground_count as f64;
590 let difference = background_mean - foreground_mean;
591 let variance = background_count as f64 * foreground_count as f64 * difference * difference;
592 if variance > best_variance {
593 best_variance = variance;
594 best = value;
595 }
596 }
597 best
598}
599
600fn gaussian_weights(size: usize) -> Vec<f64> {
601 let exact: Option<&[f64]> = match size {
602 1 => Some(&[1.0]),
603 3 => Some(&[0.25, 0.5, 0.25]),
604 5 => Some(&[0.0625, 0.25, 0.375, 0.25, 0.0625]),
605 7 => Some(&[0.03125, 0.109375, 0.21875, 0.28125, 0.21875, 0.109375, 0.03125]),
606 9 => Some(&[
607 0.015625, 0.05078125, 0.1171875, 0.19921875, 0.234375, 0.19921875, 0.1171875,
608 0.05078125, 0.015625,
609 ]),
610 _ => None,
611 };
612 if let Some(exact) = exact {
613 return exact.to_vec();
614 }
615 let sigma = 0.3 * ((size as f64 - 1.0) * 0.5 - 1.0) + 0.8;
616 let center = (size / 2) as f64;
617 let mut weights = (0..size)
618 .map(|index| {
619 let offset = index as f64 - center;
620 (-(offset * offset) / (2.0 * sigma * sigma)).exp()
621 })
622 .collect::<Vec<_>>();
623 let sum = weights.iter().sum::<f64>();
624 weights.iter_mut().for_each(|weight| *weight /= sum);
625 weights
626}
627
628fn map_u8(input: ImageView<'_, u8, 1>, lookup: &[u8; 256]) -> VisionResult<Image<u8, 1>> {
629 let workers =
630 analysis_workers(input.width() * input.height(), input.height(), LIGHT_PARALLEL_COMPONENTS);
631 if workers > 1 {
632 return map_rows(input, workers, |value| lookup[usize::from(value)]);
633 }
634 let mut output = Vec::with_capacity(input.width() * input.height());
635 for y in 0..input.height() {
636 for x in 0..input.width() {
637 output.push(lookup[input.get(x, y).expect("coordinate in bounds")[0] as usize]);
638 }
639 }
640 Ok(Image::try_new_with_metadata(input.width(), input.height(), output, input.metadata())?)
641}
642
643fn clip_histogram(histogram: &mut [usize; 256], clip_limit: usize) {
644 let mut clipped = 0_usize;
645 for count in histogram.iter_mut() {
646 if *count > clip_limit {
647 clipped += *count - clip_limit;
648 *count = clip_limit;
649 }
650 }
651 let batch = clipped / 256;
652 if batch > 0 {
653 histogram.iter_mut().for_each(|count| *count += batch);
654 }
655 let residual = clipped - batch * 256;
656 let step = 256_usize.checked_div(residual).unwrap_or(1).max(1);
657 for index in (0..256).step_by(step).take(residual) {
658 histogram[index] += 1;
659 }
660}
661
662struct ClaheHorizontal {
665 left: usize,
666 right: usize,
667 weight: f64,
668}
669
670fn interpolate_clahe(
671 input: ImageView<'_, u8, 1>,
672 lookups: &[[u8; 256]],
673 tiles_x: usize,
674 tiles_y: usize,
675 tile_width: usize,
676 tile_height: usize,
677) -> VisionResult<Image<u8, 1>> {
678 let workers =
679 analysis_workers(input.width() * input.height(), input.height(), LARGE_PARALLEL_COMPONENTS);
680 if workers == 1 {
681 return interpolate_clahe_scalar(input, lookups, tiles_x, tiles_y, tile_width, tile_height);
682 }
683 let horizontal: Vec<_> = (0..input.width())
684 .map(|x| {
685 let tx = x as f64 / tile_width as f64 - 0.5;
686 let raw = tx.floor() as isize;
687 ClaheHorizontal {
688 left: raw.clamp(0, tiles_x as isize - 1) as usize,
689 right: (raw + 1).clamp(0, tiles_x as isize - 1) as usize,
690 weight: tx - tx.floor(),
691 }
692 })
693 .collect();
694 let rows = items_per_worker(input.height(), workers);
695 let mut output = vec![0; input.width() * input.height()];
696 output.par_chunks_mut(rows * input.width()).enumerate().for_each(|(band, output)| {
697 for (row, target) in output.chunks_mut(input.width()).enumerate() {
698 let y = band * rows + row;
699 let ty = y as f64 / tile_height as f64 - 0.5;
700 let raw = ty.floor() as isize;
701 let ya = ty - ty.floor();
702 let y0 = raw.clamp(0, tiles_y as isize - 1) as usize;
703 let y1 = (raw + 1).clamp(0, tiles_y as isize - 1) as usize;
704 let source = input.row(y).expect("validated image row");
705 for ((to, &from), geometry) in target.iter_mut().zip(source).zip(&horizontal) {
706 let value = usize::from(from);
707 let xa = geometry.weight;
708 let top = f64::from(lookups[y0 * tiles_x + geometry.left][value]) * (1.0 - xa)
709 + f64::from(lookups[y0 * tiles_x + geometry.right][value]) * xa;
710 let bottom = f64::from(lookups[y1 * tiles_x + geometry.left][value]) * (1.0 - xa)
711 + f64::from(lookups[y1 * tiles_x + geometry.right][value]) * xa;
712 *to = cv_round(top * (1.0 - ya) + bottom * ya).clamp(0, 255) as u8;
713 }
714 }
715 });
716 Ok(Image::try_new_with_metadata(input.width(), input.height(), output, input.metadata())?)
717}
718
719fn interpolate_clahe_scalar(
720 input: ImageView<'_, u8, 1>,
721 lookups: &[[u8; 256]],
722 tiles_x: usize,
723 tiles_y: usize,
724 tile_width: usize,
725 tile_height: usize,
726) -> VisionResult<Image<u8, 1>> {
727 let mut output = Vec::with_capacity(input.width() * input.height());
728 for y in 0..input.height() {
729 let ty = y as f64 / tile_height as f64 - 0.5;
730 let y0_raw = ty.floor() as isize;
731 let ya = ty - ty.floor();
732 let y0 = y0_raw.clamp(0, tiles_y as isize - 1) as usize;
733 let y1 = (y0_raw + 1).clamp(0, tiles_y as isize - 1) as usize;
734 for x in 0..input.width() {
735 let tx = x as f64 / tile_width as f64 - 0.5;
736 let x0_raw = tx.floor() as isize;
737 let xa = tx - tx.floor();
738 let x0 = x0_raw.clamp(0, tiles_x as isize - 1) as usize;
739 let x1 = (x0_raw + 1).clamp(0, tiles_x as isize - 1) as usize;
740 let value = input.get(x, y).expect("coordinate in bounds")[0] as usize;
741 let top = f64::from(lookups[y0 * tiles_x + x0][value]) * (1.0 - xa)
742 + f64::from(lookups[y0 * tiles_x + x1][value]) * xa;
743 let bottom = f64::from(lookups[y1 * tiles_x + x0][value]) * (1.0 - xa)
744 + f64::from(lookups[y1 * tiles_x + x1][value]) * xa;
745 output.push(cv_round(top * (1.0 - ya) + bottom * ya).clamp(0, 255) as u8);
746 }
747 }
748 Ok(Image::try_new_with_metadata(input.width(), input.height(), output, input.metadata())?)
749}
750
751fn cv_round(value: f64) -> i64 {
752 let floor = value.floor();
753 let fraction = value - floor;
754 if fraction < 0.5 {
755 floor as i64
756 } else if fraction > 0.5 {
757 floor as i64 + 1
758 } else {
759 let base = floor as i64;
760 if base % 2 == 0 {
761 base
762 } else {
763 base + 1
764 }
765 }
766}
767
768#[cfg(test)]
769mod tests {
770 use super::{
771 adaptive_threshold, clahe, equalize_histogram, histogram, integral_image,
772 otsu_threshold_u8, threshold, AdaptiveThresholdMethod, ThresholdType,
773 };
774 use crate::BorderMode;
775 use spatialrust_image::{Image, ImageRegion};
776
777 fn pool(threads: usize) -> rayon::ThreadPool {
778 rayon::ThreadPoolBuilder::new().num_threads(threads).build().unwrap()
779 }
780
781 const MODES: [ThresholdType; 5] = [
782 ThresholdType::Binary,
783 ThresholdType::BinaryInv,
784 ThresholdType::Truncate,
785 ThresholdType::ToZero,
786 ThresholdType::ToZeroInv,
787 ];
788
789 #[test]
790 fn parallel_threshold_preserves_strided_roi_channels_and_metadata() {
791 let image = Image::<u16, 3>::try_new(
792 514,
793 262,
794 (0..514 * 262 * 3).map(|i| (i * 37 % 65_536) as u16).collect(),
795 )
796 .unwrap();
797 let roi = image.view().subview(ImageRegion::new(1, 2, 511, 257)).unwrap();
798 let serial = pool(1);
799 for threads in [2, 6] {
800 let parallel = pool(threads);
801 for mode in MODES {
802 let expected = serial.install(|| threshold(roi, 32_768.0, 66_000.0, mode).unwrap());
803 let actual = parallel.install(|| threshold(roi, 32_768.0, 66_000.0, mode).unwrap());
804 let reference: Vec<u16> = (0..roi.height())
805 .flat_map(|y| roi.row(y).unwrap())
806 .map(|&value| {
807 <u16 as crate::PixelComponent>::from_f64(super::apply_threshold(
808 f64::from(value),
809 32_768.0,
810 66_000.0,
811 mode,
812 ))
813 })
814 .collect();
815 assert!(expected.as_slice() == reference, "original definition, mode={mode:?}");
816 assert!(
817 actual.as_slice() == expected.as_slice(),
818 "threads={threads}, mode={mode:?}"
819 );
820 assert_eq!(actual.metadata(), roi.metadata());
821 assert_eq!((actual.width(), actual.height()), (511, 257));
822 }
823 }
824 }
825
826 #[test]
827 fn parallel_float_threshold_preserves_nan_infinity_and_signed_zero_bits() {
828 let (width, height, stride) = (129, 261, 129 * 4 + 7);
829 let mut data = vec![12345_f32; stride * height];
830 let values = [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -0.0, 0.0, 0.5, 1.0, -7.0];
831 for y in 0..height {
832 for i in 0..width * 4 {
833 data[y * stride + i] = values[(y + i) % values.len()];
834 }
835 }
836 let input =
837 spatialrust_image::ImageView::<f32, 4>::new(width, height, stride, &data).unwrap();
838 let serial = pool(1);
839 let parallel = pool(6);
840 for mode in MODES {
841 let expected = serial.install(|| threshold(input, 0.5, 1.0, mode).unwrap());
842 let actual = parallel.install(|| threshold(input, 0.5, 1.0, mode).unwrap());
843 let reference: Vec<f32> = (0..height)
844 .flat_map(|y| input.row(y).unwrap())
845 .map(|&value| super::apply_threshold(f64::from(value), 0.5, 1.0, mode) as f32)
846 .collect();
847 assert!(
848 expected.as_slice().iter().zip(&reference).all(|(a, b)| a.to_bits() == b.to_bits()),
849 "original definition, mode={mode:?}"
850 );
851 assert!(
852 actual
853 .as_slice()
854 .iter()
855 .zip(expected.as_slice())
856 .all(|(a, b)| a.to_bits() == b.to_bits()),
857 "mode={mode:?}"
858 );
859 }
860 }
861
862 #[test]
863 fn parallel_histogram_excludes_padding_and_has_exact_integer_counts() {
864 let (width, height, stride) = (1025, 1027, 1042);
865 let mut data = vec![255; stride * height];
866 let mut expected = vec![0_u64; 256];
867 for y in 0..height {
868 for x in 0..width {
869 let value = ((x + y * 7) % 255) as u8;
870 data[y * stride + x] = value;
871 expected[usize::from(value)] += 1;
872 }
873 }
874 let input =
875 spatialrust_image::ImageView::<u8, 1>::new(width, height, stride, &data).unwrap();
876 for threads in [1, 2, 6] {
877 let actual = pool(threads).install(|| super::histogram_u8(input));
878 assert_eq!(actual, expected);
879 assert_eq!(actual[255], 0);
880 assert_eq!(actual.iter().sum::<u64>(), (width * height) as u64);
881 }
882 }
883
884 #[test]
885 fn analysis_handles_empty_single_row_and_small_inputs_with_multiple_workers() {
886 let parallel = pool(6);
887 for (width, height) in [(0, 0), (1, 1), (31, 3), (100_003, 1)] {
888 let image = Image::<u8, 1>::try_new(width, height, vec![7; width * height]).unwrap();
889 let output = parallel
890 .install(|| threshold(image.view(), 7.0, 255.0, ThresholdType::BinaryInv).unwrap());
891 assert!(output.as_slice().iter().all(|&value| value == 255));
892 let histogram = parallel.install(|| super::histogram_u8(image.view()));
893 assert_eq!(histogram[7], (width * height) as u64);
894 }
895 }
896
897 #[test]
898 fn fixed_threshold_types_match_definitions_on_roi() {
899 let image = Image::<u16, 1>::try_new(5, 1, vec![0, 10, 20, 30, 40]).unwrap();
900 let roi = image.view().subview(ImageRegion::new(1, 0, 3, 1)).unwrap();
901 assert_eq!(
902 threshold(roi, 20.0, 100.0, ThresholdType::Binary).unwrap().as_slice(),
903 &[0, 0, 100]
904 );
905 assert_eq!(
906 threshold(roi, 20.0, 100.0, ThresholdType::Truncate).unwrap().as_slice(),
907 &[10, 20, 20]
908 );
909 }
910
911 #[test]
912 fn otsu_separates_bimodal_values() {
913 let image = Image::<u8, 1>::try_new(6, 1, vec![10, 10, 10, 200, 200, 200]).unwrap();
914 let (selected, output) =
915 otsu_threshold_u8(image.view(), 255, ThresholdType::Binary).unwrap();
916 assert_eq!(selected, 10);
917 assert_eq!(output.as_slice(), &[0, 0, 0, 255, 255, 255]);
918 }
919
920 #[test]
921 fn adaptive_threshold_rejects_even_blocks() {
922 let image = Image::<u8, 1>::from_pixel(3, 3, [10]).unwrap();
923 assert!(adaptive_threshold(
924 image.view(),
925 255,
926 AdaptiveThresholdMethod::Mean,
927 ThresholdType::Binary,
928 2,
929 0.0,
930 BorderMode::Replicate
931 )
932 .is_err());
933 }
934
935 #[test]
936 fn histogram_honors_channel_range_and_mask() {
937 let image =
938 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();
939 let mask = Image::<u8, 1>::try_new(2, 2, vec![1, 0, 1, 1]).unwrap();
940 assert_eq!(
941 histogram(image.view(), 0, 2, (0.0, 1.0), Some(mask.view())).unwrap(),
942 vec![1, 1]
943 );
944 }
945
946 #[test]
947 fn equalization_spreads_low_contrast_values() {
948 let image = Image::<u8, 1>::try_new(4, 1, vec![10, 10, 20, 30]).unwrap();
949 assert_eq!(equalize_histogram(image.view()).unwrap().as_slice(), &[0, 0, 128, 255]);
950 }
951
952 #[test]
953 fn clahe_preserves_dimensions_and_constant_input() {
954 let image = Image::<u8, 1>::from_pixel(7, 5, [42]).unwrap();
955 let output = clahe(image.view(), 2.0, 3, 2).unwrap();
956 assert_eq!((output.width(), output.height()), (7, 5));
957 }
958
959 #[test]
960 fn parallel_equalization_preserves_padded_roi_and_metadata() {
961 use spatialrust_image::{ColorRange, ColorSpace, ImageMetadata, ImageView};
962 let (width, height, stride) = (1029, 1029, 1043);
963 let mut data = vec![255; stride * height];
964 for y in 0..height {
965 for x in 0..width {
966 data[y * stride + x] = ((x * 17 + y * 31) % 129 + 32) as u8;
967 }
968 }
969 let metadata = ImageMetadata {
970 color_space: ColorSpace::Gray,
971 color_range: ColorRange::Full,
972 ..Default::default()
973 };
974 let input = ImageView::<u8, 1>::new_with_metadata(width, height, stride, &data, metadata)
975 .unwrap()
976 .subview(ImageRegion::new(2, 1, 1025, 1027))
977 .unwrap();
978 let expected = pool(1).install(|| equalize_histogram(input).unwrap());
979 for workers in [2, 6] {
980 let actual = pool(workers).install(|| equalize_histogram(input).unwrap());
981 assert!(actual.as_slice() == expected.as_slice(), "workers={workers}");
982 assert_eq!(actual.metadata(), metadata);
983 assert_eq!((actual.width(), actual.height()), (1025, 1027));
984 }
985 }
986
987 #[test]
988 fn parallel_clahe_matches_scalar_for_odd_roi_tiles_and_clip_limits() {
989 use spatialrust_image::{ColorRange, ColorSpace, ImageMetadata};
990 let metadata = ImageMetadata {
991 color_space: ColorSpace::Gray,
992 color_range: ColorRange::Limited,
993 ..Default::default()
994 };
995 let image = Image::<u8, 1>::try_new_with_metadata(
996 1029,
997 1031,
998 (0..1029 * 1031).map(|i| ((i * 37 + i / 1029 * 17) % 251) as u8).collect(),
999 metadata,
1000 )
1001 .unwrap();
1002 let input = image.view().subview(ImageRegion::new(2, 3, 1025, 1027)).unwrap();
1003 let serial = pool(1);
1004 let two = pool(2);
1005 let six = pool(6);
1006 for (tiles_x, tiles_y) in [(8, 8), (7, 5), (1, 1)] {
1007 for clip in [0.0, 0.01, 2.0, 1e100] {
1008 let expected = serial.install(|| clahe(input, clip, tiles_x, tiles_y).unwrap());
1009 for parallel in [&two, &six] {
1010 let actual = parallel.install(|| clahe(input, clip, tiles_x, tiles_y).unwrap());
1011 assert!(
1012 actual.as_slice() == expected.as_slice(),
1013 "workers={}, grid={tiles_x}x{tiles_y}, clip={clip}",
1014 parallel.current_num_threads()
1015 );
1016 assert_eq!(actual.metadata(), metadata);
1017 assert_eq!((actual.width(), actual.height()), (1025, 1027));
1018 }
1019 }
1020 }
1021 }
1022
1023 #[test]
1024 fn parallel_clahe_interpolation_keeps_original_rounding_at_narrow_borders() {
1025 let lookups: Vec<[u8; 256]> = (0..4)
1026 .map(|tile| {
1027 std::array::from_fn(|value| match tile {
1028 0 => value as u8,
1029 1 => (255 - value) as u8,
1030 2 => (value / 2) as u8,
1031 _ => (value / 2 + 128) as u8,
1032 })
1033 })
1034 .collect();
1035 let parallel = pool(6);
1036 for (width, height) in [(1, 1_000_003), (2, 500_003), (1_000_003, 1)] {
1037 let image = Image::<u8, 1>::try_new(
1038 width,
1039 height,
1040 (0..width * height).map(|i| (i % 256) as u8).collect(),
1041 )
1042 .unwrap();
1043 let tile_width = width.div_ceil(2);
1044 let tile_height = height.div_ceil(2);
1045 let expected = super::interpolate_clahe_scalar(
1046 image.view(),
1047 &lookups,
1048 2,
1049 2,
1050 tile_width,
1051 tile_height,
1052 )
1053 .unwrap();
1054 let actual = parallel.install(|| {
1055 super::interpolate_clahe(image.view(), &lookups, 2, 2, tile_width, tile_height)
1056 .unwrap()
1057 });
1058 assert!(actual.as_slice() == expected.as_slice(), "dimensions={width}x{height}");
1059 }
1060 }
1061
1062 #[test]
1063 fn contrast_fallbacks_constant_images_and_rounding_fixture_remain_exact() {
1064 let serial = pool(1);
1065 let parallel = pool(6);
1066 for (width, height) in [(0, 0), (1, 1), (7, 5), (100_003, 1), (1, 100_003)] {
1067 let image = Image::<u8, 1>::from_pixel(width, height, [42]).unwrap();
1068 let expected = serial.install(|| clahe(image.view(), 2.0, 13, 11).unwrap());
1069 let actual = parallel.install(|| clahe(image.view(), 2.0, 13, 11).unwrap());
1070 assert!(actual.as_slice() == expected.as_slice());
1071 assert!(parallel
1072 .install(|| equalize_histogram(image.view()).unwrap())
1073 .as_slice()
1074 .iter()
1075 .all(|&value| value == 42));
1076 }
1077 let constant = Image::<u8, 1>::from_pixel(1025, 1027, [42]).unwrap();
1078 let actual = parallel.install(|| clahe(constant.view(), 0.0, 7, 5).unwrap());
1079 assert!(actual.as_slice().iter().all(|&value| value == 255));
1080 let fixture = Image::<u8, 1>::try_new(3, 2, vec![0, 40, 80, 120, 160, 200]).unwrap();
1081 assert_eq!(
1082 clahe(fixture.view(), 0.0, 1, 1).unwrap().as_slice(),
1083 &[42, 85, 128, 170, 212, 255]
1084 );
1085 }
1086
1087 #[test]
1088 fn clahe_rejects_overflowing_lookup_grids_before_allocation() {
1089 let image = Image::<u8, 1>::from_pixel(1, 1, [42]).unwrap();
1090 for (x, y) in [(usize::MAX, 2), (isize::MAX as usize / 256 + 1, 1)] {
1091 assert!(matches!(
1092 clahe(image.view(), 2.0, x, y),
1093 Err(crate::VisionError::InvalidDimensions(_))
1094 ));
1095 }
1096 for clip in [-1.0, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
1097 assert!(clahe(image.view(), clip, 8, 8).is_err());
1098 }
1099 assert!(clahe(image.view(), 2.0, 0, 8).is_err());
1100 assert!(clahe(image.view(), 2.0, 8, 0).is_err());
1101 }
1102
1103 #[test]
1104 fn integral_region_matches_direct_sum() {
1105 let image = Image::<f32, 1>::try_new(3, 2, vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]).unwrap();
1106 let integral = integral_image(image.view(), 0).unwrap();
1107 assert_eq!((integral.width(), integral.height()), (4, 3));
1108 assert_eq!(integral.sum_region(1, 0, 3, 2).unwrap(), 16.0);
1109 }
1110}