Skip to main content

spatialrust_vision/
analysis.rs

1//! Thresholding, histograms, contrast enhancement, and integral images.
2
3use 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/// Point-wise threshold transformation.
14#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
15pub enum ThresholdType {
16    /// `max_value` above the threshold, zero otherwise.
17    Binary,
18    /// Zero above the threshold, `max_value` otherwise.
19    BinaryInv,
20    /// Clamp values above the threshold to the threshold.
21    Truncate,
22    /// Preserve values above the threshold, zero otherwise.
23    ToZero,
24    /// Zero values above the threshold, preserve the others.
25    ToZeroInv,
26}
27
28/// Adaptive neighborhood statistic.
29#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
30pub enum AdaptiveThresholdMethod {
31    /// Uniform neighborhood mean.
32    Mean,
33    /// Gaussian-weighted neighborhood mean.
34    Gaussian,
35}
36
37/// Applies a fixed threshold independently to every component.
38#[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    // Select the mode outside the hot loop and convert constant outputs once.
57    // In particular, integer binary thresholds need no per-component rounding.
58    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        // Keep small/empty inputs from initializing the global worker pool.
139        1
140    }
141}
142
143/// Selects an Otsu threshold for an 8-bit grayscale image and applies it.
144pub 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
155/// Selects an Otsu threshold for a 16-bit grayscale image and applies it.
156pub 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
172/// Applies adaptive binary thresholding to an 8-bit grayscale image.
173pub 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
237/// Computes a configurable one-channel histogram, optionally through a mask.
238pub 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/// Computes the exact 256-bin histogram of an 8-bit grayscale image.
283#[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        // One fixed-size histogram per bounded band; no shared counters.
290        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
319/// Equalizes an 8-bit grayscale histogram.
320pub 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
349/// Applies contrast-limited adaptive histogram equalization to grayscale u8.
350pub 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/// Summed-area table with a zero top row and left column.
443#[derive(Clone, Debug, PartialEq)]
444pub struct IntegralImage {
445    source_width: usize,
446    source_height: usize,
447    data: Vec<f64>,
448}
449
450impl IntegralImage {
451    /// Source image width, excluding the zero border.
452    #[must_use]
453    pub const fn source_width(&self) -> usize {
454        self.source_width
455    }
456
457    /// Source image height, excluding the zero border.
458    #[must_use]
459    pub const fn source_height(&self) -> usize {
460        self.source_height
461    }
462
463    /// Integral table width (`source_width + 1`).
464    #[must_use]
465    pub const fn width(&self) -> usize {
466        self.source_width + 1
467    }
468
469    /// Integral table height (`source_height + 1`).
470    #[must_use]
471    pub const fn height(&self) -> usize {
472        self.source_height + 1
473    }
474
475    /// Row-major integral values.
476    #[must_use]
477    pub fn as_slice(&self) -> &[f64] {
478        &self.data
479    }
480
481    /// Returns the sum over half-open source rectangle `[x0,x1) × [y0,y1)`.
482    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
494/// Computes a summed-area table for one selected channel.
495pub 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
662// Horizontal geometry is independent of the image row. Share one compact
663// table across bounded output bands, preserving the original f64 operation order.
664struct 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}