1use pulp::Arch;
4use rayon::prelude::*;
5use spatialrust_image::{Image, ImageView};
6
7use crate::border::{fetch, map_index};
8use crate::dispatch::{
9 bounded_workers, items_per_worker, should_parallelize, LARGE_PARALLEL_COMPONENTS,
10};
11use crate::{BorderMode, PixelComponent, VisionError, VisionResult};
12
13#[derive(Clone, Debug, PartialEq)]
15pub struct Kernel2D {
16 width: usize,
17 height: usize,
18 anchor_x: usize,
19 anchor_y: usize,
20 coefficients: Vec<f64>,
21}
22
23impl Kernel2D {
24 pub fn try_new(width: usize, height: usize, coefficients: Vec<f64>) -> VisionResult<Self> {
26 Self::try_new_with_anchor(width, height, width / 2, height / 2, coefficients)
27 }
28
29 pub fn try_new_with_anchor(
31 width: usize,
32 height: usize,
33 anchor_x: usize,
34 anchor_y: usize,
35 coefficients: Vec<f64>,
36 ) -> VisionResult<Self> {
37 if width == 0 || height == 0 {
38 return Err(VisionError::InvalidParameter("kernel dimensions must be non-zero".into()));
39 }
40 let expected = width
41 .checked_mul(height)
42 .ok_or_else(|| VisionError::InvalidParameter("kernel dimensions overflow".into()))?;
43 if coefficients.len() != expected {
44 return Err(VisionError::ShapeMismatch(format!(
45 "kernel needs {expected} coefficients, found {}",
46 coefficients.len()
47 )));
48 }
49 if anchor_x >= width || anchor_y >= height {
50 return Err(VisionError::InvalidParameter(format!(
51 "kernel anchor ({anchor_x}, {anchor_y}) is outside {width}x{height}"
52 )));
53 }
54 if coefficients.iter().any(|value| !value.is_finite()) {
55 return Err(VisionError::InvalidParameter("kernel coefficients must be finite".into()));
56 }
57 Ok(Self { width, height, anchor_x, anchor_y, coefficients })
58 }
59
60 #[must_use]
62 pub const fn width(&self) -> usize {
63 self.width
64 }
65
66 #[must_use]
68 pub const fn height(&self) -> usize {
69 self.height
70 }
71
72 #[must_use]
74 pub const fn anchor(&self) -> (usize, usize) {
75 (self.anchor_x, self.anchor_y)
76 }
77
78 #[must_use]
80 pub fn coefficients(&self) -> &[f64] {
81 &self.coefficients
82 }
83
84 #[must_use]
86 pub fn reversed(&self) -> Self {
87 let mut coefficients = self.coefficients.clone();
88 coefficients.reverse();
89 Self {
90 width: self.width,
91 height: self.height,
92 anchor_x: self.width - 1 - self.anchor_x,
93 anchor_y: self.height - 1 - self.anchor_y,
94 coefficients,
95 }
96 }
97}
98
99#[derive(Clone, Debug, PartialEq)]
101pub struct Kernel1D {
102 anchor: usize,
103 coefficients: Vec<f64>,
104}
105
106impl Kernel1D {
107 pub fn try_new(coefficients: Vec<f64>) -> VisionResult<Self> {
109 let anchor = coefficients.len() / 2;
110 Self::try_new_with_anchor(coefficients, anchor)
111 }
112
113 pub fn try_new_with_anchor(coefficients: Vec<f64>, anchor: usize) -> VisionResult<Self> {
115 if coefficients.is_empty() {
116 return Err(VisionError::InvalidParameter("kernel must not be empty".into()));
117 }
118 if anchor >= coefficients.len() {
119 return Err(VisionError::InvalidParameter(format!(
120 "kernel anchor {anchor} is outside length {}",
121 coefficients.len()
122 )));
123 }
124 if coefficients.iter().any(|value| !value.is_finite()) {
125 return Err(VisionError::InvalidParameter("kernel coefficients must be finite".into()));
126 }
127 Ok(Self { anchor, coefficients })
128 }
129
130 #[must_use]
132 pub fn len(&self) -> usize {
133 self.coefficients.len()
134 }
135
136 #[must_use]
138 pub fn is_empty(&self) -> bool {
139 self.coefficients.is_empty()
140 }
141
142 #[must_use]
144 pub const fn anchor(&self) -> usize {
145 self.anchor
146 }
147
148 #[must_use]
150 pub fn coefficients(&self) -> &[f64] {
151 &self.coefficients
152 }
153}
154
155pub fn filter2d<T: PixelComponent, const CHANNELS: usize>(
157 input: ImageView<'_, T, CHANNELS>,
158 kernel: &Kernel2D,
159 delta: f64,
160 border: BorderMode<T, CHANNELS>,
161) -> VisionResult<Image<T, CHANNELS>> {
162 validate_delta(delta)?;
163 let accumulators = correlate(input, kernel, delta, border);
164 let output = accumulators.into_iter().map(T::from_f64).collect();
165 Ok(Image::try_new_with_metadata(input.width(), input.height(), output, input.metadata())?)
166}
167
168pub fn filter2d_f32<T: PixelComponent, const CHANNELS: usize>(
170 input: ImageView<'_, T, CHANNELS>,
171 kernel: &Kernel2D,
172 delta: f64,
173 border: BorderMode<T, CHANNELS>,
174) -> VisionResult<Image<f32, CHANNELS>> {
175 validate_delta(delta)?;
176 let output =
177 correlate(input, kernel, delta, border).into_iter().map(|value| value as f32).collect();
178 Ok(Image::try_new_with_metadata(input.width(), input.height(), output, input.metadata())?)
179}
180
181pub fn convolve2d<T: PixelComponent, const CHANNELS: usize>(
183 input: ImageView<'_, T, CHANNELS>,
184 kernel: &Kernel2D,
185 delta: f64,
186 border: BorderMode<T, CHANNELS>,
187) -> VisionResult<Image<T, CHANNELS>> {
188 filter2d(input, &kernel.reversed(), delta, border)
189}
190
191pub fn separable_filter<T: PixelComponent, const CHANNELS: usize>(
193 input: ImageView<'_, T, CHANNELS>,
194 kernel_x: &Kernel1D,
195 kernel_y: &Kernel1D,
196 delta: f64,
197 border: BorderMode<T, CHANNELS>,
198) -> VisionResult<Image<T, CHANNELS>> {
199 let output = separable_accumulators(input, kernel_x, kernel_y, delta, border)?
200 .into_iter()
201 .map(T::from_f64)
202 .collect();
203 Ok(Image::try_new_with_metadata(input.width(), input.height(), output, input.metadata())?)
204}
205
206pub fn separable_filter_f32<T: PixelComponent, const CHANNELS: usize>(
208 input: ImageView<'_, T, CHANNELS>,
209 kernel_x: &Kernel1D,
210 kernel_y: &Kernel1D,
211 delta: f64,
212 border: BorderMode<T, CHANNELS>,
213) -> VisionResult<Image<f32, CHANNELS>> {
214 let output = separable_accumulators(input, kernel_x, kernel_y, delta, border)?
215 .into_iter()
216 .map(|value| value as f32)
217 .collect();
218 Ok(Image::try_new_with_metadata(input.width(), input.height(), output, input.metadata())?)
219}
220
221pub fn box_blur<T: PixelComponent, const CHANNELS: usize>(
223 input: ImageView<'_, T, CHANNELS>,
224 kernel_width: usize,
225 kernel_height: usize,
226 border: BorderMode<T, CHANNELS>,
227) -> VisionResult<Image<T, CHANNELS>> {
228 if kernel_width == 0 || kernel_height == 0 {
229 return Err(VisionError::InvalidParameter("box kernel dimensions must be non-zero".into()));
230 }
231 let x = Kernel1D::try_new(vec![1.0 / kernel_width as f64; kernel_width])?;
232 let y = Kernel1D::try_new(vec![1.0 / kernel_height as f64; kernel_height])?;
233 separable_filter(input, &x, &y, 0.0, border)
234}
235
236pub fn gaussian_blur<T: PixelComponent, const CHANNELS: usize>(
238 input: ImageView<'_, T, CHANNELS>,
239 kernel_width: usize,
240 kernel_height: usize,
241 sigma_x: f64,
242 sigma_y: f64,
243 border: BorderMode<T, CHANNELS>,
244) -> VisionResult<Image<T, CHANNELS>> {
245 let x = gaussian_kernel(kernel_width, sigma_x)?;
246 let y = gaussian_kernel(kernel_height, sigma_y)?;
247 separable_filter(input, &x, &y, 0.0, border)
248}
249
250#[derive(Clone, Debug, Default)]
255pub struct GaussianBlurU8Workspace {
256 horizontal: Vec<u16>,
257 high_precision_horizontal: Vec<u32>,
258 kernel_x: Vec<u16>,
259 kernel_y: Vec<u16>,
260 kernel_x_key: Option<(usize, u64, u8)>,
261 kernel_y_key: Option<(usize, u64, u8)>,
262}
263
264impl GaussianBlurU8Workspace {
265 #[must_use]
267 pub const fn new() -> Self {
268 Self {
269 horizontal: Vec::new(),
270 high_precision_horizontal: Vec::new(),
271 kernel_x: Vec::new(),
272 kernel_y: Vec::new(),
273 kernel_x_key: None,
274 kernel_y_key: None,
275 }
276 }
277
278 #[must_use]
280 pub fn capacity(&self) -> usize {
281 self.horizontal.capacity().max(self.high_precision_horizontal.capacity())
282 }
283
284 #[must_use]
286 pub fn allocated_bytes(&self) -> usize {
287 self.horizontal.capacity() * std::mem::size_of::<u16>()
288 + self.high_precision_horizontal.capacity() * std::mem::size_of::<u32>()
289 + (self.kernel_x.capacity() + self.kernel_y.capacity()) * std::mem::size_of::<u16>()
290 }
291}
292
293pub fn gaussian_blur_u8<const CHANNELS: usize>(
299 input: ImageView<'_, u8, CHANNELS>,
300 kernel_width: usize,
301 kernel_height: usize,
302 sigma_x: f64,
303 sigma_y: f64,
304 border: BorderMode<u8, CHANNELS>,
305) -> VisionResult<Image<u8, CHANNELS>> {
306 validate_specialized_gaussian_size(kernel_width)?;
307 validate_specialized_gaussian_size(kernel_height)?;
308 validate_gaussian_sigma(sigma_x)?;
309 validate_gaussian_sigma(sigma_y)?;
310 let len = gaussian_output_len::<CHANNELS>(input.width(), input.height())?;
311 let mut output = vec![0; len];
312 let mut workspace = GaussianBlurU8Workspace::new();
313 gaussian_blur_u8_into(
314 input,
315 kernel_width,
316 kernel_height,
317 sigma_x,
318 sigma_y,
319 border,
320 &mut output,
321 &mut workspace,
322 )?;
323 Ok(Image::try_new_with_metadata(input.width(), input.height(), output, input.metadata())?)
324}
325
326#[allow(clippy::too_many_arguments)]
328pub fn gaussian_blur_u8_into<const CHANNELS: usize>(
329 input: ImageView<'_, u8, CHANNELS>,
330 kernel_width: usize,
331 kernel_height: usize,
332 sigma_x: f64,
333 sigma_y: f64,
334 border: BorderMode<u8, CHANNELS>,
335 output: &mut [u8],
336 workspace: &mut GaussianBlurU8Workspace,
337) -> VisionResult<()> {
338 let len = gaussian_output_len::<CHANNELS>(input.width(), input.height())?;
339 if output.len() != len {
340 return Err(VisionError::ShapeMismatch(format!(
341 "Gaussian output needs {len} elements, found {}",
342 output.len()
343 )));
344 }
345 validate_specialized_gaussian_size(kernel_width)?;
346 validate_specialized_gaussian_size(kernel_height)?;
347 validate_gaussian_sigma(sigma_x)?;
348 validate_gaussian_sigma(sigma_y)?;
349 if len == 0 {
350 return Ok(());
351 }
352 if kernel_width == 7 || kernel_height == 7 {
353 return gaussian_blur_u8_high_precision_into(
354 input,
355 kernel_width,
356 kernel_height,
357 sigma_x,
358 sigma_y,
359 border,
360 output,
361 workspace,
362 );
363 }
364
365 prepare_fixed_gaussian_kernel(
366 kernel_width,
367 sigma_x,
368 &mut workspace.kernel_x_key,
369 &mut workspace.kernel_x,
370 );
371 prepare_fixed_gaussian_kernel(
372 kernel_height,
373 sigma_y,
374 &mut workspace.kernel_y_key,
375 &mut workspace.kernel_y,
376 );
377 workspace.horizontal.resize(len, 0);
378 let row_len = input.width() * CHANNELS;
379 let horizontal = &mut workspace.horizontal;
380 let kernel_x = workspace.kernel_x.as_slice();
381 let arch = Arch::new();
382 if should_parallelize(len, input.height(), LARGE_PARALLEL_COMPONENTS) {
383 gaussian_blur_u8_parallel_bands(
384 arch,
385 input,
386 kernel_x,
387 workspace.kernel_y.as_slice(),
388 border,
389 output,
390 horizontal,
391 );
392 return Ok(());
393 }
394 arch.dispatch(|| {
395 for (y, row) in horizontal.chunks_mut(row_len).enumerate() {
396 gaussian_horizontal_row(input, y, kernel_x, border, row);
397 }
398 });
399
400 let kernel_y = workspace.kernel_y.as_slice();
401 arch.dispatch(|| {
402 for (y, row) in output.chunks_mut(row_len).enumerate() {
403 gaussian_vertical_row(
404 horizontal,
405 input.width(),
406 input.height(),
407 y,
408 kernel_y,
409 border,
410 row,
411 );
412 }
413 });
414 Ok(())
415}
416
417#[allow(clippy::too_many_arguments)]
418fn gaussian_blur_u8_parallel_bands<const CHANNELS: usize>(
419 arch: Arch,
420 input: ImageView<'_, u8, CHANNELS>,
421 kernel_x: &[u16],
422 kernel_y: &[u16],
423 border: BorderMode<u8, CHANNELS>,
424 output: &mut [u8],
425 horizontal: &mut Vec<u16>,
426) {
427 let width = input.width();
428 let height = input.height();
429 let row_len = width * CHANNELS;
430 let workers = bounded_workers(
431 horizontal.len(),
432 height,
433 LARGE_PARALLEL_COMPONENTS,
434 rayon::current_num_threads(),
435 );
436 let rows_per_worker = items_per_worker(height, workers);
437 let radius_y = kernel_y.len() / 2;
438 let scratch_rows = rows_per_worker + radius_y * 2;
439 let scratch_len = scratch_rows * row_len;
440 horizontal.resize(workers * scratch_len, 0);
441 horizontal
442 .par_chunks_mut(scratch_len)
443 .zip(output.par_chunks_mut(rows_per_worker * row_len))
444 .enumerate()
445 .for_each(|(band, (scratch, output_rows))| {
446 let start_y = band * rows_per_worker;
447 let output_row_count = output_rows.len() / row_len;
448 let used_scratch_rows = output_row_count + radius_y * 2;
449 arch.dispatch(|| {
450 for (local_y, row) in
451 scratch[..used_scratch_rows * row_len].chunks_mut(row_len).enumerate()
452 {
453 let source_y = start_y as isize + local_y as isize - radius_y as isize;
454 if let Some(mapped_y) = map_index(source_y, height, border) {
455 gaussian_horizontal_row(input, mapped_y, kernel_x, border, row);
456 } else {
457 let constant = match border {
458 BorderMode::Constant(pixel) => pixel,
459 _ => unreachable!("only constant borders can map outside the image"),
460 };
461 for pixel in row.chunks_exact_mut(CHANNELS) {
462 for channel in 0..CHANNELS {
463 pixel[channel] = u16::from(constant[channel]) * 256;
464 }
465 }
466 }
467 }
468 for (local_y, row) in output_rows.chunks_mut(row_len).enumerate() {
469 gaussian_vertical_row(
470 scratch,
471 width,
472 used_scratch_rows,
473 local_y + radius_y,
474 kernel_y,
475 BorderMode::<u8, CHANNELS>::Replicate,
476 row,
477 );
478 }
479 });
480 });
481}
482
483fn gaussian_output_len<const CHANNELS: usize>(width: usize, height: usize) -> VisionResult<usize> {
484 width
485 .checked_mul(height)
486 .and_then(|pixels| pixels.checked_mul(CHANNELS))
487 .ok_or_else(|| VisionError::InvalidDimensions("Gaussian output size overflows".into()))
488}
489
490fn validate_specialized_gaussian_size(size: usize) -> VisionResult<()> {
491 if !matches!(size, 3 | 5 | 7) {
492 return Err(VisionError::InvalidParameter(
493 "specialized Gaussian kernel size must be 3, 5, or 7".into(),
494 ));
495 }
496 Ok(())
497}
498
499fn validate_gaussian_sigma(sigma: f64) -> VisionResult<()> {
500 if !sigma.is_finite() || sigma <= 0.0 {
501 return Err(VisionError::InvalidParameter(
502 "Gaussian sigma must be finite and positive".into(),
503 ));
504 }
505 Ok(())
506}
507
508const HIGH_PRECISION_GAUSSIAN_BITS: u8 = 15;
509const HIGH_PRECISION_GAUSSIAN_SCALE: u32 = 1 << HIGH_PRECISION_GAUSSIAN_BITS;
510
511#[allow(clippy::too_many_arguments)]
512fn gaussian_blur_u8_high_precision_into<const CHANNELS: usize>(
513 input: ImageView<'_, u8, CHANNELS>,
514 kernel_width: usize,
515 kernel_height: usize,
516 sigma_x: f64,
517 sigma_y: f64,
518 border: BorderMode<u8, CHANNELS>,
519 output: &mut [u8],
520 workspace: &mut GaussianBlurU8Workspace,
521) -> VisionResult<()> {
522 prepare_high_precision_gaussian_kernel(
523 kernel_width,
524 sigma_x,
525 &mut workspace.kernel_x_key,
526 &mut workspace.kernel_x,
527 );
528 prepare_high_precision_gaussian_kernel(
529 kernel_height,
530 sigma_y,
531 &mut workspace.kernel_y_key,
532 &mut workspace.kernel_y,
533 );
534
535 let row_len = input.width() * CHANNELS;
536 let height = input.height();
537 workspace.high_precision_horizontal.resize(output.len(), 0);
538 let horizontal = &mut workspace.high_precision_horizontal;
539 let kernel_x = workspace.kernel_x.as_slice();
540 let workers = bounded_workers(
541 output.len(),
542 height,
543 LARGE_PARALLEL_COMPONENTS,
544 rayon::current_num_threads(),
545 );
546 if workers > 1 {
547 let rows_per_worker = items_per_worker(height, workers);
548 horizontal.par_chunks_mut(rows_per_worker * row_len).enumerate().for_each(
549 |(chunk, rows)| {
550 let start_y = chunk * rows_per_worker;
551 for (offset, row) in rows.chunks_mut(row_len).enumerate() {
552 gaussian_horizontal_high_precision_row(
553 input,
554 start_y + offset,
555 kernel_x,
556 border,
557 row,
558 );
559 }
560 },
561 );
562 } else {
563 for (y, row) in horizontal.chunks_mut(row_len).enumerate() {
564 gaussian_horizontal_high_precision_row(input, y, kernel_x, border, row);
565 }
566 }
567
568 let kernel_y = workspace.kernel_y.as_slice();
569 if workers > 1 {
570 let rows_per_worker = items_per_worker(height, workers);
571 output.par_chunks_mut(rows_per_worker * row_len).enumerate().for_each(|(chunk, rows)| {
572 let start_y = chunk * rows_per_worker;
573 for (offset, row) in rows.chunks_mut(row_len).enumerate() {
574 gaussian_vertical_high_precision_row(
575 horizontal,
576 input.width(),
577 height,
578 start_y + offset,
579 kernel_y,
580 border,
581 row,
582 );
583 }
584 });
585 } else {
586 for (y, row) in output.chunks_mut(row_len).enumerate() {
587 gaussian_vertical_high_precision_row(
588 horizontal,
589 input.width(),
590 height,
591 y,
592 kernel_y,
593 border,
594 row,
595 );
596 }
597 }
598 Ok(())
599}
600
601fn prepare_high_precision_gaussian_kernel(
602 size: usize,
603 sigma: f64,
604 cached_key: &mut Option<(usize, u64, u8)>,
605 cached_kernel: &mut Vec<u16>,
606) {
607 let key = (size, sigma.to_bits(), HIGH_PRECISION_GAUSSIAN_BITS);
608 if *cached_key == Some(key) {
609 return;
610 }
611 let center = (size / 2) as f64;
612 let denominator = 2.0 * sigma * sigma;
613 let mut fixed = (0..size)
614 .map(|index| {
615 let offset = index as f64 - center;
616 (-(offset * offset) / denominator).exp()
617 })
618 .collect::<Vec<_>>();
619 let sum = fixed.iter().sum::<f64>();
620 let mut fixed = fixed
621 .drain(..)
622 .map(|weight| (weight * f64::from(HIGH_PRECISION_GAUSSIAN_SCALE) / sum).round() as i32)
623 .collect::<Vec<_>>();
624 let adjustment = HIGH_PRECISION_GAUSSIAN_SCALE as i32 - fixed.iter().sum::<i32>();
625 fixed[size / 2] += adjustment;
626 cached_kernel.clear();
627 cached_kernel.extend(fixed.into_iter().map(|weight| weight as u16));
628 *cached_key = Some(key);
629}
630
631fn gaussian_horizontal_high_precision_row<const CHANNELS: usize>(
632 input: ImageView<'_, u8, CHANNELS>,
633 y: usize,
634 kernel: &[u16],
635 border: BorderMode<u8, CHANNELS>,
636 output: &mut [u32],
637) {
638 let width = input.width();
639 let radius = kernel.len() / 2;
640 let source = input.row(y).expect("Gaussian source row in bounds");
641 let constant = match border {
642 BorderMode::Constant(pixel) => pixel,
643 _ => [0; CHANNELS],
644 };
645 for x in 0..width {
646 let interior = x >= radius && x + radius < width;
647 for channel in 0..CHANNELS {
648 let mut sum = 0_u32;
649 for (tap, &weight) in kernel.iter().enumerate() {
650 let value = if interior {
651 source[(x + tap - radius) * CHANNELS + channel]
652 } else {
653 let source_x = x as isize + tap as isize - radius as isize;
654 map_index(source_x, width, border)
655 .map_or(constant[channel], |mapped| source[mapped * CHANNELS + channel])
656 };
657 sum += u32::from(value) * u32::from(weight);
658 }
659 output[x * CHANNELS + channel] = sum;
660 }
661 }
662}
663
664fn gaussian_vertical_high_precision_row<const CHANNELS: usize>(
665 horizontal: &[u32],
666 width: usize,
667 height: usize,
668 y: usize,
669 kernel: &[u16],
670 border: BorderMode<u8, CHANNELS>,
671 output: &mut [u8],
672) {
673 let row_len = width * CHANNELS;
674 let radius = kernel.len() / 2;
675 let interior = y >= radius && y + radius < height;
676 let constant = match border {
677 BorderMode::Constant(pixel) => {
678 pixel.map(|value| u32::from(value) * HIGH_PRECISION_GAUSSIAN_SCALE)
679 }
680 _ => [0; CHANNELS],
681 };
682 let round = 1_u64 << (u32::from(HIGH_PRECISION_GAUSSIAN_BITS) * 2 - 1);
683 let shift = u32::from(HIGH_PRECISION_GAUSSIAN_BITS) * 2;
684 for scalar_x in 0..row_len {
685 let channel = scalar_x % CHANNELS;
686 let mut sum = 0_u64;
687 for (tap, &weight) in kernel.iter().enumerate() {
688 let value = if interior {
689 horizontal[(y + tap - radius) * row_len + scalar_x]
690 } else {
691 let source_y = y as isize + tap as isize - radius as isize;
692 map_index(source_y, height, border)
693 .map_or(constant[channel], |mapped| horizontal[mapped * row_len + scalar_x])
694 };
695 sum += u64::from(value) * u64::from(weight);
696 }
697 output[scalar_x] = ((sum + round) >> shift).min(255) as u8;
698 }
699}
700
701fn prepare_fixed_gaussian_kernel(
702 size: usize,
703 sigma: f64,
704 cached_key: &mut Option<(usize, u64, u8)>,
705 cached_kernel: &mut Vec<u16>,
706) {
707 let key = (size, sigma.to_bits(), 8);
708 if *cached_key == Some(key) {
709 return;
710 }
711 let center = (size / 2) as f64;
712 let denominator = 2.0 * sigma * sigma;
713 let mut weights = (0..size)
714 .map(|index| {
715 let offset = index as f64 - center;
716 (-(offset * offset) / denominator).exp()
717 })
718 .collect::<Vec<_>>();
719 let sum = weights.iter().sum::<f64>();
720 let mut fixed =
721 weights.drain(..).map(|weight| (weight * 256.0 / sum).round() as i32).collect::<Vec<_>>();
722 let adjustment = 256 - fixed.iter().sum::<i32>();
723 fixed[size / 2] += adjustment;
724 cached_kernel.clear();
725 cached_kernel.extend(fixed.into_iter().map(|weight| weight as u16));
726 *cached_key = Some(key);
727}
728
729#[inline(always)]
730fn gaussian_horizontal_row<const CHANNELS: usize>(
731 input: ImageView<'_, u8, CHANNELS>,
732 y: usize,
733 kernel: &[u16],
734 border: BorderMode<u8, CHANNELS>,
735 output: &mut [u16],
736) {
737 let width = input.width();
738 let radius = kernel.len() / 2;
739 let source = input.row(y).expect("Gaussian source row in bounds");
740 let constant = match border {
741 BorderMode::Constant(pixel) => pixel,
742 _ => [0; CHANNELS],
743 };
744 if width <= radius * 2 {
745 for x in 0..width {
746 gaussian_horizontal_border_pixel(source, width, x, kernel, border, constant, output);
747 }
748 return;
749 }
750 for x in 0..radius {
751 gaussian_horizontal_border_pixel(source, width, x, kernel, border, constant, output);
752 }
753 for x in width - radius..width {
754 gaussian_horizontal_border_pixel(source, width, x, kernel, border, constant, output);
755 }
756
757 let start = radius * CHANNELS;
758 let end = (width - radius) * CHANNELS;
759 match kernel {
760 [outer, center, _] => {
761 let (outer, center) = (u32::from(*outer), u32::from(*center));
762 for index in start..end {
763 output[index] = (u32::from(source[index]) * center
764 + (u32::from(source[index - CHANNELS]) + u32::from(source[index + CHANNELS]))
765 * outer) as u16;
766 }
767 }
768 [outer, inner, center, _, _] => {
769 let (outer, inner, center) = (u32::from(*outer), u32::from(*inner), u32::from(*center));
770 for index in start..end {
771 output[index] = (u32::from(source[index]) * center
772 + (u32::from(source[index - CHANNELS]) + u32::from(source[index + CHANNELS]))
773 * inner
774 + (u32::from(source[index - 2 * CHANNELS])
775 + u32::from(source[index + 2 * CHANNELS]))
776 * outer) as u16;
777 }
778 }
779 [outer, middle, inner, center, _, _, _] => {
780 let (outer, middle, inner, center) =
781 (u32::from(*outer), u32::from(*middle), u32::from(*inner), u32::from(*center));
782 for index in start..end {
783 output[index] = (u32::from(source[index]) * center
784 + (u32::from(source[index - CHANNELS]) + u32::from(source[index + CHANNELS]))
785 * inner
786 + (u32::from(source[index - 2 * CHANNELS])
787 + u32::from(source[index + 2 * CHANNELS]))
788 * middle
789 + (u32::from(source[index - 3 * CHANNELS])
790 + u32::from(source[index + 3 * CHANNELS]))
791 * outer) as u16;
792 }
793 }
794 _ => unreachable!("specialized Gaussian kernel is validated"),
795 }
796}
797
798fn gaussian_horizontal_border_pixel<const CHANNELS: usize>(
799 source: &[u8],
800 width: usize,
801 x: usize,
802 kernel: &[u16],
803 border: BorderMode<u8, CHANNELS>,
804 constant: [u8; CHANNELS],
805 output: &mut [u16],
806) {
807 let radius = kernel.len() / 2;
808 for channel in 0..CHANNELS {
809 let mut sum = 0_u32;
810 for (tap, &weight) in kernel.iter().enumerate() {
811 let source_x = x as isize + tap as isize - radius as isize;
812 let value = map_index(source_x, width, border)
813 .map_or(constant[channel], |mapped| source[mapped * CHANNELS + channel]);
814 sum += u32::from(value) * u32::from(weight);
815 }
816 output[x * CHANNELS + channel] = sum as u16;
817 }
818}
819
820#[inline(always)]
821fn gaussian_round_u8(sum: u32) -> u8 {
822 ((sum + 32_768) >> 16).min(255) as u8
823}
824
825fn gaussian_vertical_border_row<const CHANNELS: usize>(
826 horizontal: &[u16],
827 width: usize,
828 height: usize,
829 y: usize,
830 kernel: &[u16],
831 border: BorderMode<u8, CHANNELS>,
832 output: &mut [u8],
833) {
834 let row_len = width * CHANNELS;
835 let radius = kernel.len() / 2;
836 let constant = match border {
837 BorderMode::Constant(pixel) => pixel.map(|value| u32::from(value) * 256),
838 _ => [0; CHANNELS],
839 };
840 for scalar_x in 0..row_len {
841 let channel = scalar_x % CHANNELS;
842 let mut sum = 0_u32;
843 for (tap, &weight) in kernel.iter().enumerate() {
844 let source_y = y as isize + tap as isize - radius as isize;
845 let value = map_index(source_y, height, border).map_or(constant[channel], |mapped| {
846 u32::from(horizontal[mapped * row_len + scalar_x])
847 });
848 sum += value * u32::from(weight);
849 }
850 output[scalar_x] = gaussian_round_u8(sum);
851 }
852}
853
854#[allow(clippy::too_many_arguments)]
855#[inline(always)]
856fn gaussian_vertical_row<const CHANNELS: usize>(
857 horizontal: &[u16],
858 width: usize,
859 height: usize,
860 y: usize,
861 kernel: &[u16],
862 border: BorderMode<u8, CHANNELS>,
863 output: &mut [u8],
864) {
865 let row_len = width * CHANNELS;
866 let radius = kernel.len() / 2;
867 if y < radius || y + radius >= height {
868 gaussian_vertical_border_row(horizontal, width, height, y, kernel, border, output);
869 return;
870 }
871 match kernel {
872 [outer, center, _] => {
873 let (outer, center) = (u32::from(*outer), u32::from(*center));
874 let above = (y - 1) * row_len;
875 let current = y * row_len;
876 let below = (y + 1) * row_len;
877 for index in 0..row_len {
878 output[index] = gaussian_round_u8(
879 u32::from(horizontal[current + index]) * center
880 + (u32::from(horizontal[above + index])
881 + u32::from(horizontal[below + index]))
882 * outer,
883 );
884 }
885 }
886 [outer, inner, center, _, _] => {
887 let (outer, inner, center) = (u32::from(*outer), u32::from(*inner), u32::from(*center));
888 let row0 = (y - 2) * row_len;
889 let row1 = (y - 1) * row_len;
890 let row2 = y * row_len;
891 let row3 = (y + 1) * row_len;
892 let row4 = (y + 2) * row_len;
893 for index in 0..row_len {
894 output[index] = gaussian_round_u8(
895 u32::from(horizontal[row2 + index]) * center
896 + (u32::from(horizontal[row1 + index])
897 + u32::from(horizontal[row3 + index]))
898 * inner
899 + (u32::from(horizontal[row0 + index])
900 + u32::from(horizontal[row4 + index]))
901 * outer,
902 );
903 }
904 }
905 [outer, middle, inner, center, _, _, _] => {
906 let (outer, middle, inner, center) =
907 (u32::from(*outer), u32::from(*middle), u32::from(*inner), u32::from(*center));
908 let row0 = (y - 3) * row_len;
909 let row1 = (y - 2) * row_len;
910 let row2 = (y - 1) * row_len;
911 let row3 = y * row_len;
912 let row4 = (y + 1) * row_len;
913 let row5 = (y + 2) * row_len;
914 let row6 = (y + 3) * row_len;
915 for index in 0..row_len {
916 output[index] = gaussian_round_u8(
917 u32::from(horizontal[row3 + index]) * center
918 + (u32::from(horizontal[row2 + index])
919 + u32::from(horizontal[row4 + index]))
920 * inner
921 + (u32::from(horizontal[row1 + index])
922 + u32::from(horizontal[row5 + index]))
923 * middle
924 + (u32::from(horizontal[row0 + index])
925 + u32::from(horizontal[row6 + index]))
926 * outer,
927 );
928 }
929 }
930 _ => unreachable!("specialized Gaussian kernel is validated"),
931 }
932}
933
934fn gaussian_kernel(size: usize, sigma: f64) -> VisionResult<Kernel1D> {
935 if size == 0 || size % 2 == 0 {
936 return Err(VisionError::InvalidParameter(
937 "Gaussian kernel size must be positive and odd".into(),
938 ));
939 }
940 if !sigma.is_finite() || sigma <= 0.0 {
941 return Err(VisionError::InvalidParameter(
942 "Gaussian sigma must be finite and positive".into(),
943 ));
944 }
945 let center = (size / 2) as f64;
946 let denominator = 2.0 * sigma * sigma;
947 let mut coefficients = (0..size)
948 .map(|index| {
949 let offset = index as f64 - center;
950 (-(offset * offset) / denominator).exp()
951 })
952 .collect::<Vec<_>>();
953 let sum = coefficients.iter().sum::<f64>();
954 for value in &mut coefficients {
955 *value /= sum;
956 }
957 Kernel1D::try_new(coefficients)
958}
959
960fn validate_delta(delta: f64) -> VisionResult<()> {
961 if !delta.is_finite() {
962 return Err(VisionError::InvalidParameter("filter delta must be finite".into()));
963 }
964 Ok(())
965}
966
967fn correlate<T: PixelComponent, const CHANNELS: usize>(
968 input: ImageView<'_, T, CHANNELS>,
969 kernel: &Kernel2D,
970 delta: f64,
971 border: BorderMode<T, CHANNELS>,
972) -> Vec<f64> {
973 let mut output = Vec::with_capacity(input.width() * input.height() * CHANNELS);
974 for y in 0..input.height() {
975 for x in 0..input.width() {
976 let mut sums = [delta; CHANNELS];
977 for ky in 0..kernel.height {
978 for kx in 0..kernel.width {
979 let pixel = fetch(
980 input,
981 x as isize + kx as isize - kernel.anchor_x as isize,
982 y as isize + ky as isize - kernel.anchor_y as isize,
983 border,
984 );
985 let weight = kernel.coefficients[ky * kernel.width + kx];
986 for channel in 0..CHANNELS {
987 sums[channel] += pixel[channel].to_f64() * weight;
988 }
989 }
990 }
991 output.extend_from_slice(&sums);
992 }
993 }
994 output
995}
996
997fn separable_accumulators<T: PixelComponent, const CHANNELS: usize>(
998 input: ImageView<'_, T, CHANNELS>,
999 kernel_x: &Kernel1D,
1000 kernel_y: &Kernel1D,
1001 delta: f64,
1002 border: BorderMode<T, CHANNELS>,
1003) -> VisionResult<Vec<f64>> {
1004 validate_delta(delta)?;
1005 let mut horizontal = vec![0.0; input.width() * input.height() * CHANNELS];
1006 for y in 0..input.height() {
1007 for x in 0..input.width() {
1008 for (kx, &weight) in kernel_x.coefficients.iter().enumerate() {
1009 let pixel = fetch(
1010 input,
1011 x as isize + kx as isize - kernel_x.anchor as isize,
1012 y as isize,
1013 border,
1014 );
1015 for channel in 0..CHANNELS {
1016 horizontal[(y * input.width() + x) * CHANNELS + channel] +=
1017 pixel[channel].to_f64() * weight;
1018 }
1019 }
1020 }
1021 }
1022
1023 let constant_horizontal = match border {
1024 BorderMode::Constant(pixel) => {
1025 let sum_x = kernel_x.coefficients.iter().sum::<f64>();
1026 std::array::from_fn(|channel| pixel[channel].to_f64() * sum_x)
1027 }
1028 _ => [0.0; CHANNELS],
1029 };
1030 let mut output = Vec::with_capacity(horizontal.len());
1031 for y in 0..input.height() {
1032 for x in 0..input.width() {
1033 let mut sums = [delta; CHANNELS];
1034 for (ky, &weight) in kernel_y.coefficients.iter().enumerate() {
1035 let source_y = y as isize + ky as isize - kernel_y.anchor as isize;
1036 if let Some(mapped_y) = map_index(source_y, input.height(), border) {
1037 let offset = (mapped_y * input.width() + x) * CHANNELS;
1038 for channel in 0..CHANNELS {
1039 sums[channel] += horizontal[offset + channel] * weight;
1040 }
1041 } else {
1042 for channel in 0..CHANNELS {
1043 sums[channel] += constant_horizontal[channel] * weight;
1044 }
1045 }
1046 }
1047 output.extend_from_slice(&sums);
1048 }
1049 }
1050 Ok(output)
1051}
1052
1053#[cfg(test)]
1054mod tests {
1055 use super::{
1056 box_blur, convolve2d, filter2d, gaussian_blur, gaussian_blur_u8, gaussian_blur_u8_into,
1057 separable_filter, GaussianBlurU8Workspace, Kernel1D, Kernel2D,
1058 };
1059 use crate::BorderMode;
1060 use proptest::prelude::*;
1061 use spatialrust_image::{Image, ImageRegion, ImageView};
1062
1063 #[test]
1064 fn filter2d_is_correlation_and_convolution_reverses() {
1065 let image = Image::<u8, 1>::try_new(3, 1, vec![1, 2, 4]).unwrap();
1066 let kernel = Kernel2D::try_new_with_anchor(2, 1, 0, 0, vec![1.0, 10.0]).unwrap();
1067 let correlation = filter2d(image.view(), &kernel, 0.0, BorderMode::Replicate).unwrap();
1068 let convolution = convolve2d(image.view(), &kernel, 0.0, BorderMode::Replicate).unwrap();
1069 assert_eq!(correlation.as_slice(), &[21, 42, 44]);
1070 assert_eq!(convolution.as_slice(), &[11, 12, 24]);
1071 }
1072
1073 #[test]
1074 fn separable_matches_outer_product_with_constant_border() {
1075 let image = Image::<u8, 1>::try_new(2, 2, vec![1, 2, 3, 4]).unwrap();
1076 let x = Kernel1D::try_new(vec![0.25, 0.5, 0.25]).unwrap();
1077 let y = Kernel1D::try_new(vec![0.25, 0.5, 0.25]).unwrap();
1078 let kernel = Kernel2D::try_new(
1079 3,
1080 3,
1081 vec![0.0625, 0.125, 0.0625, 0.125, 0.25, 0.125, 0.0625, 0.125, 0.0625],
1082 )
1083 .unwrap();
1084 let expected = filter2d(image.view(), &kernel, 0.0, BorderMode::Constant([9])).unwrap();
1085 let actual =
1086 separable_filter(image.view(), &x, &y, 0.0, BorderMode::Constant([9])).unwrap();
1087 assert_eq!(actual, expected);
1088 }
1089
1090 #[test]
1091 fn roi_and_packed_filter_results_match() {
1092 let parent = Image::<u8, 1>::try_new(5, 3, (0..15).collect()).unwrap();
1093 let roi = parent.view().subview(ImageRegion::new(1, 1, 3, 2)).unwrap();
1094 let packed = Image::<u8, 1>::try_new(3, 2, vec![6, 7, 8, 11, 12, 13]).unwrap();
1095 let kernel = Kernel2D::try_new(3, 1, vec![0.25, 0.5, 0.25]).unwrap();
1096 assert_eq!(
1097 filter2d(roi, &kernel, 0.0, BorderMode::Reflect101).unwrap(),
1098 filter2d(packed.view(), &kernel, 0.0, BorderMode::Reflect101).unwrap()
1099 );
1100 }
1101
1102 #[test]
1103 fn normalized_blurs_preserve_constant_images() {
1104 let image = Image::<f32, 3>::from_pixel(4, 3, [2.0, 4.0, 8.0]).unwrap();
1105 let box_output = box_blur(image.view(), 4, 2, BorderMode::Replicate).unwrap();
1106 let gaussian = gaussian_blur(image.view(), 5, 3, 1.2, 0.8, BorderMode::Reflect101).unwrap();
1107 assert_eq!(box_output, image);
1108 for (actual, expected) in gaussian.as_slice().iter().zip(image.as_slice()) {
1109 assert!((actual - expected).abs() < 1e-5);
1110 }
1111 }
1112
1113 #[test]
1114 fn specialized_gaussian_matches_generic_for_strided_rgb_input() {
1115 let width = 17;
1116 let height = 11;
1117 let stride = width * 3 + 7;
1118 let mut storage = vec![199_u8; stride * height];
1119 for y in 0..height {
1120 for x in 0..width {
1121 for channel in 0..3 {
1122 storage[y * stride + x * 3 + channel] =
1123 ((x * 31 + y * 17 + channel * 73) & 255) as u8;
1124 }
1125 }
1126 }
1127 let view = spatialrust_image::ImageView::new(width, height, stride, &storage).unwrap();
1128 for &(size, sigma) in &[(3, 0.8), (5, 1.2), (7, 2.0)] {
1129 for border in [
1130 BorderMode::Replicate,
1131 BorderMode::Reflect,
1132 BorderMode::Reflect101,
1133 BorderMode::Wrap,
1134 BorderMode::Constant([11, 23, 47]),
1135 ] {
1136 let expected = gaussian_blur(view, size, size, sigma, sigma, border).unwrap();
1137 let actual = gaussian_blur_u8(view, size, size, sigma, sigma, border).unwrap();
1138 assert!(expected
1139 .as_slice()
1140 .iter()
1141 .zip(actual.as_slice())
1142 .all(|(&left, &right)| left.abs_diff(right) <= 1));
1143 }
1144 }
1145 }
1146
1147 #[test]
1148 fn specialized_gaussian_reuses_workspace_and_validates_output() {
1149 let image =
1150 Image::<u8, 3>::try_new(8, 6, (0..144).map(|value| value as u8).collect()).unwrap();
1151 let mut output = vec![0; 8 * 6 * 3];
1152 let mut workspace = GaussianBlurU8Workspace::new();
1153 gaussian_blur_u8_into(
1154 image.view(),
1155 5,
1156 5,
1157 1.2,
1158 1.2,
1159 BorderMode::Reflect101,
1160 &mut output,
1161 &mut workspace,
1162 )
1163 .unwrap();
1164 let capacity = workspace.capacity();
1165 let allocated_bytes = workspace.allocated_bytes();
1166 assert!(allocated_bytes > 0);
1167 gaussian_blur_u8_into(
1168 image.view(),
1169 5,
1170 5,
1171 1.2,
1172 1.2,
1173 BorderMode::Reflect101,
1174 &mut output,
1175 &mut workspace,
1176 )
1177 .unwrap();
1178 assert_eq!(workspace.capacity(), capacity);
1179 assert_eq!(workspace.allocated_bytes(), allocated_bytes);
1180 assert!(gaussian_blur_u8_into(
1181 image.view(),
1182 5,
1183 5,
1184 1.2,
1185 1.2,
1186 BorderMode::Reflect101,
1187 &mut output[..10],
1188 &mut workspace,
1189 )
1190 .is_err());
1191 assert!(gaussian_blur_u8(image.view(), 9, 5, 1.2, 1.2, BorderMode::Reflect101,).is_err());
1192 }
1193
1194 #[test]
1195 fn specialized_gaussian_parallel_bands_match_generic_across_seams() {
1196 let width = 600;
1197 let height = 600;
1198 let stride = width * 3 + 7;
1199 let mut storage = vec![199_u8; stride * height];
1200 for y in 0..height {
1201 for x in 0..width {
1202 for channel in 0..3 {
1203 storage[y * stride + x * 3 + channel] =
1204 ((x * 31 + y * 17 + channel * 73) & 255) as u8;
1205 }
1206 }
1207 }
1208 let view = ImageView::<u8, 3>::new(width, height, stride, &storage).unwrap();
1209 let expected = gaussian_blur(view, 5, 5, 1.2, 1.2, BorderMode::Reflect101).unwrap();
1210 let actual = gaussian_blur_u8(view, 5, 5, 1.2, 1.2, BorderMode::Reflect101).unwrap();
1211 assert!(expected
1212 .as_slice()
1213 .iter()
1214 .zip(actual.as_slice())
1215 .all(|(&left, &right)| left.abs_diff(right) <= 1));
1216
1217 let constant = Image::<u8, 3>::from_pixel(width, height, [11, 23, 47]).unwrap();
1218 let constant_blur =
1219 gaussian_blur_u8(constant.view(), 5, 5, 1.2, 1.2, BorderMode::Constant([11, 23, 47]))
1220 .unwrap();
1221 assert_eq!(constant_blur, constant);
1222 }
1223
1224 proptest! {
1225 #![proptest_config(ProptestConfig::with_cases(300))]
1226
1227 #[test]
1228 fn high_precision_7x7_matches_generic_with_strided_input(
1229 width in 1_usize..33,
1230 height in 1_usize..33,
1231 padding in 0_usize..8,
1232 sigma_x in 0.3_f64..4.0,
1233 sigma_y in 0.3_f64..4.0,
1234 seed in any::<u64>(),
1235 ) {
1236 let stride = width * 3 + padding;
1237 let mut storage = vec![173_u8; stride * height];
1238 let mut state = seed;
1239 for y in 0..height {
1240 for value in &mut storage[y * stride..y * stride + width * 3] {
1241 state = state.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
1242 *value = (state >> 32) as u8;
1243 }
1244 }
1245 let input = ImageView::<u8, 3>::new(width, height, stride, &storage).unwrap();
1246 let expected = gaussian_blur(
1247 input,
1248 7,
1249 7,
1250 sigma_x,
1251 sigma_y,
1252 BorderMode::Reflect101,
1253 )
1254 .unwrap();
1255 let mut output = vec![0_u8; width * height * 3];
1256 let mut workspace = GaussianBlurU8Workspace::new();
1257 gaussian_blur_u8_into(
1258 input,
1259 7,
1260 7,
1261 sigma_x,
1262 sigma_y,
1263 BorderMode::Reflect101,
1264 &mut output,
1265 &mut workspace,
1266 )
1267 .unwrap();
1268 prop_assert!(expected
1269 .as_slice()
1270 .iter()
1271 .zip(&output)
1272 .all(|(&left, &right)| left.abs_diff(right) <= 1));
1273 let capacity = workspace.capacity();
1274 gaussian_blur_u8_into(
1275 input,
1276 7,
1277 7,
1278 sigma_x,
1279 sigma_y,
1280 BorderMode::Reflect101,
1281 &mut output,
1282 &mut workspace,
1283 )
1284 .unwrap();
1285 prop_assert_eq!(workspace.capacity(), capacity);
1286 }
1287 }
1288
1289 #[test]
1290 fn invalid_kernels_are_rejected() {
1291 assert!(Kernel2D::try_new(0, 1, Vec::new()).is_err());
1292 assert!(Kernel2D::try_new(2, 2, vec![1.0; 3]).is_err());
1293 assert!(Kernel1D::try_new(vec![f64::NAN]).is_err());
1294 }
1295}