1use otf_pixels_core::{
20 AccessPattern, ImageDescriptor, Op, PixelsError, Region, Result, SampleKind, Tile, TileMut,
21};
22
23use crate::resample::MAX_CHANNELS;
24
25const ONE: i32 = 1 << 14;
27
28#[derive(Debug, Clone, PartialEq)]
30pub struct Kernel {
31 width: u32,
32 height: u32,
33 taps: Vec<f32>,
36 quantized: Vec<i32>,
38}
39
40impl Kernel {
41 pub const MAX_SIZE: u32 = 63;
48
49 pub fn new(width: u32, height: u32, taps: &[f32]) -> Result<Self> {
61 if width == 0 || height == 0 {
62 return Err(PixelsError::invalid_argument(
63 "kernel",
64 format!("kernel {width}x{height} is empty"),
65 ));
66 }
67 if width % 2 == 0 || height % 2 == 0 {
68 return Err(PixelsError::invalid_argument(
72 "kernel",
73 format!("kernel {width}x{height} must have odd dimensions"),
74 ));
75 }
76 if width > Self::MAX_SIZE || height > Self::MAX_SIZE {
77 return Err(PixelsError::invalid_argument(
78 "kernel",
79 format!(
80 "kernel {width}x{height} exceeds the {}x{} maximum",
81 Self::MAX_SIZE,
82 Self::MAX_SIZE
83 ),
84 ));
85 }
86 let expected = width as usize * height as usize;
87 if taps.len() != expected {
88 return Err(PixelsError::invalid_argument(
89 "kernel",
90 format!("{width}x{height} needs {expected} taps, got {}", taps.len()),
91 ));
92 }
93 if taps.iter().any(|t| !t.is_finite()) {
94 return Err(PixelsError::invalid_argument(
95 "kernel",
96 "every tap must be finite",
97 ));
98 }
99
100 let sum: f32 = taps.iter().sum();
101 let divisor = if sum.abs() < 1e-6 { 1.0 } else { sum };
104 let normalized: Vec<f32> = taps.iter().map(|t| t / divisor).collect();
105 let quantized = normalized
106 .iter()
107 .map(|&t| {
108 if t >= 0.0 {
109 (t * ONE as f32 + 0.5) as i32
110 } else {
111 (t * ONE as f32 - 0.5) as i32
112 }
113 })
114 .collect();
115
116 Ok(Self {
117 width,
118 height,
119 taps: normalized,
120 quantized,
121 })
122 }
123
124 pub fn square(size: u32, taps: &[f32]) -> Result<Self> {
130 Self::new(size, size, taps)
131 }
132
133 pub fn blur(size: u32) -> Result<Self> {
139 let count = size as usize * size as usize;
140 Self::square(size, &vec![1.0; count])
141 }
142
143 pub fn gaussian(sigma: f32) -> Result<Self> {
150 if !sigma.is_finite() || sigma <= 0.0 {
151 return Err(PixelsError::invalid_argument(
152 "sigma",
153 format!("must be finite and positive, got {sigma}"),
154 ));
155 }
156 let radius = (sigma * 3.0).ceil().max(1.0) as u32;
159 let size = radius * 2 + 1;
160 let mut taps = Vec::with_capacity((size * size) as usize);
161 let denominator = 2.0 * sigma * sigma;
162 for y in 0..size {
163 for x in 0..size {
164 let dx = x as f32 - radius as f32;
165 let dy = y as f32 - radius as f32;
166 taps.push((-(dx * dx + dy * dy) / denominator).exp());
167 }
168 }
169 Self::square(size, &taps)
170 }
171
172 pub fn sharpen(amount: f32) -> Result<Self> {
178 if !amount.is_finite() {
179 return Err(PixelsError::invalid_argument(
180 "amount",
181 format!("must be finite, got {amount}"),
182 ));
183 }
184 let a = amount;
185 Self::square(3, &[0.0, -a, 0.0, -a, 1.0 + 4.0 * a, -a, 0.0, -a, 0.0])
186 }
187
188 #[must_use]
190 pub const fn width(&self) -> u32 {
191 self.width
192 }
193
194 #[must_use]
196 pub const fn height(&self) -> u32 {
197 self.height
198 }
199
200 #[must_use]
202 pub const fn radius_x(&self) -> u32 {
203 self.width / 2
204 }
205
206 #[must_use]
208 pub const fn radius_y(&self) -> u32 {
209 self.height / 2
210 }
211
212 #[must_use]
214 pub fn taps(&self) -> &[f32] {
215 &self.taps
216 }
217}
218
219#[derive(Debug, Clone, PartialEq)]
221pub struct Convolve {
222 kernel: Kernel,
223}
224
225impl Convolve {
226 #[must_use]
228 pub const fn new(kernel: Kernel) -> Self {
229 Self { kernel }
230 }
231
232 #[must_use]
234 pub const fn kernel(&self) -> &Kernel {
235 &self.kernel
236 }
237}
238
239impl Op for Convolve {
240 fn rescaled(&self) -> Option<std::sync::Arc<dyn Op>> {
246 None
247 }
248 fn name(&self) -> &'static str {
249 "convolve"
250 }
251
252 fn output_descriptor(&self, inputs: &[ImageDescriptor]) -> Result<ImageDescriptor> {
253 let input = inputs
254 .first()
255 .ok_or_else(|| PixelsError::graph("`convolve` takes one input, got none"))?;
256 Ok(*input)
257 }
258
259 fn input_regions(&self, output: Region, inputs: &[ImageDescriptor]) -> Result<Vec<Region>> {
260 let input = inputs
261 .first()
262 .ok_or_else(|| PixelsError::graph("`convolve` takes one input, got none"))?;
263
264 let x = output.x.saturating_sub(self.kernel.radius_x());
267 let y = output.y.saturating_sub(self.kernel.radius_y());
268 let right = (output.x + output.width + self.kernel.radius_x()).min(input.width);
269 let bottom = (output.y + output.height + self.kernel.radius_y()).min(input.height);
270 Ok(vec![Region::new(
271 x,
272 y,
273 right.saturating_sub(x),
274 bottom.saturating_sub(y),
275 )])
276 }
277
278 fn access_pattern(&self) -> AccessPattern {
279 AccessPattern::Spatial
280 }
281
282 fn compute(&self, inputs: &[Tile<'_>], output: &mut TileMut<'_>) -> Result<()> {
283 let input = inputs
284 .first()
285 .ok_or_else(|| PixelsError::graph("`convolve` takes one input tile, got none"))?;
286 if input.pixel() != output.pixel() {
287 return Err(PixelsError::graph(format!(
288 "`convolve` input is {} but output is {}",
289 input.pixel(),
290 output.pixel()
291 )));
292 }
293
294 let format = output.pixel();
295 let channels = format.channels();
296 if channels > MAX_CHANNELS {
297 return Err(PixelsError::unsupported(format!(
298 "convolve handles up to {MAX_CHANNELS} channels, {format} has {channels}"
299 )));
300 }
301 let region = output.region();
302 let source = input.region();
303 let sample = format.sample_kind();
304
305 for y in region.y..region.y + region.height {
306 let mut row_out: Vec<u8> =
307 Vec::with_capacity(region.width as usize * format.bytes_per_pixel());
308 for x in region.x..region.x + region.width {
309 match sample {
310 SampleKind::U8 => {
311 let mut accumulators = [0_i32; MAX_CHANNELS];
312 self.gather_u8(input, source, x, y, channels, &mut accumulators);
313 for slot in accumulators.iter().take(channels) {
314 let value = (*slot + ONE / 2) >> 14;
315 row_out.push(value.clamp(0, 255) as u8);
316 }
317 }
318 SampleKind::U16 => {
319 let mut accumulators = [0.0_f32; MAX_CHANNELS];
320 self.gather_wide(input, source, (x, y), channels, 2, &mut accumulators);
321 for slot in accumulators.iter().take(channels) {
322 let value = slot.clamp(0.0, 65535.0) + 0.5;
323 row_out.extend_from_slice(&(value as u16).to_ne_bytes());
324 }
325 }
326 SampleKind::F32 => {
327 let mut accumulators = [0.0_f32; MAX_CHANNELS];
328 self.gather_wide(input, source, (x, y), channels, 4, &mut accumulators);
329 for slot in accumulators.iter().take(channels) {
330 row_out.extend_from_slice(&slot.to_ne_bytes());
331 }
332 }
333 }
334 }
335 if let Some(target) = output.row_mut(y) {
336 let len = target.len().min(row_out.len());
337 if let (Some(to), Some(from)) = (target.get_mut(..len), row_out.get(..len)) {
338 to.copy_from_slice(from);
339 }
340 }
341 }
342 Ok(())
343 }
344}
345
346impl Convolve {
347 fn gather_u8(
349 &self,
350 input: &Tile<'_>,
351 source: Region,
352 x: u32,
353 y: u32,
354 channels: usize,
355 accumulators: &mut [i32; MAX_CHANNELS],
356 ) {
357 for ky in 0..self.kernel.height {
358 let sy = clamp_coordinate(y, ky, self.kernel.radius_y(), source.y, source.height);
359 let Some(row) = input.row(sy) else { continue };
360 for kx in 0..self.kernel.width {
361 let sx = clamp_coordinate(x, kx, self.kernel.radius_x(), source.x, source.width);
362 let tap = self
363 .kernel
364 .quantized
365 .get((ky * self.kernel.width + kx) as usize)
366 .copied()
367 .unwrap_or(0);
368 let at = (sx - source.x) as usize * channels;
369 let Some(pixel) = row.get(at..at + channels) else {
370 continue;
371 };
372 for (channel, &value) in pixel.iter().enumerate() {
373 if let Some(slot) = accumulators.get_mut(channel) {
374 *slot += i32::from(value) * tap;
375 }
376 }
377 }
378 }
379 }
380
381 fn gather_wide(
383 &self,
384 input: &Tile<'_>,
385 source: Region,
386 at: (u32, u32),
387 channels: usize,
388 sample_bytes: usize,
389 accumulators: &mut [f32; MAX_CHANNELS],
390 ) {
391 let (x, y) = at;
392 for ky in 0..self.kernel.height {
393 let sy = clamp_coordinate(y, ky, self.kernel.radius_y(), source.y, source.height);
394 let Some(row) = input.row(sy) else { continue };
395 for kx in 0..self.kernel.width {
396 let sx = clamp_coordinate(x, kx, self.kernel.radius_x(), source.x, source.width);
397 let tap = self
398 .kernel
399 .taps
400 .get((ky * self.kernel.width + kx) as usize)
401 .copied()
402 .unwrap_or(0.0);
403 let base = (sx - source.x) as usize * channels * sample_bytes;
404 for channel in 0..channels {
405 let at = base + channel * sample_bytes;
406 let value = if sample_bytes == 2 {
407 f32::from(u16::from_ne_bytes([
408 row.get(at).copied().unwrap_or(0),
409 row.get(at + 1).copied().unwrap_or(0),
410 ]))
411 } else {
412 let mut bytes = [0_u8; 4];
413 for (slot, offset) in bytes.iter_mut().zip(0..4) {
414 *slot = row.get(at + offset).copied().unwrap_or(0);
415 }
416 f32::from_ne_bytes(bytes)
417 };
418 if let Some(slot) = accumulators.get_mut(channel) {
419 *slot += value * tap;
420 }
421 }
422 }
423 }
424 }
425}
426
427const fn clamp_coordinate(centre: u32, tap: u32, radius: u32, start: u32, len: u32) -> u32 {
433 let wanted = centre as i64 + tap as i64 - radius as i64;
434 let low = start as i64;
435 let high = start as i64 + len as i64 - 1;
436 if wanted < low {
437 start
438 } else if wanted > high {
439 (start + len).saturating_sub(1)
440 } else {
441 wanted as u32
442 }
443}
444
445#[cfg(test)]
446#[allow(
447 clippy::unwrap_used,
448 clippy::expect_used,
449 clippy::indexing_slicing,
450 clippy::panic,
451 reason = "tests operate on known-good values and assert shapes directly"
452)]
453mod tests {
454 use super::*;
455 use otf_pixels_core::{PixelFormat, TileBuf};
456
457 fn apply(
458 op: &dyn Op,
459 input: &ImageDescriptor,
460 bytes: &[u8],
461 ) -> Result<(ImageDescriptor, Vec<u8>)> {
462 let out_desc = op.output_descriptor(std::slice::from_ref(input))?;
463 let source = TileBuf::from_vec(input.region(), input.pixel, bytes.to_vec())?;
464 let mut target = TileBuf::for_image(&out_desc)?;
465 op.compute(&[source.as_tile()?], &mut target.as_tile_mut()?)?;
466 Ok((out_desc, target.into_bytes()))
467 }
468
469 fn flat(width: u32, height: u32, format: PixelFormat, value: u8) -> (ImageDescriptor, Vec<u8>) {
470 let descriptor = ImageDescriptor::new(width, height, format).unwrap();
471 let bytes = vec![value; descriptor.byte_len().unwrap()];
472 (descriptor, bytes)
473 }
474
475 #[test]
476 fn the_identity_kernel_changes_nothing() {
477 let kernel = Kernel::square(3, &[0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0]).unwrap();
478 let descriptor = ImageDescriptor::new(9, 7, PixelFormat::Rgb8).unwrap();
479 let bytes: Vec<u8> = (0..descriptor.byte_len().unwrap())
480 .map(|i| (i * 7 % 251) as u8)
481 .collect();
482 let (_, out) = apply(&Convolve::new(kernel), &descriptor, &bytes).unwrap();
483 assert_eq!(out, bytes, "the identity kernel changed the image");
484 }
485
486 #[test]
487 fn a_blur_preserves_a_flat_image() {
488 for size in [3_u32, 5, 7] {
491 let kernel = Kernel::blur(size).unwrap();
492 let (desc, bytes) = flat(16, 16, PixelFormat::Rgb8, 173);
493 let (_, out) = apply(&Convolve::new(kernel), &desc, &bytes).unwrap();
494 assert!(
495 out.iter().all(|&v| v == 173),
496 "{size}x{size} blur changed a flat field: {:?}",
497 &out[..6]
498 );
499 }
500 }
501
502 #[test]
503 fn a_blur_preserves_flatness_at_the_edges_too() {
504 let kernel = Kernel::blur(5).unwrap();
507 let (desc, bytes) = flat(8, 8, PixelFormat::Gray8, 200);
508 let (_, out) = apply(&Convolve::new(kernel), &desc, &bytes).unwrap();
509 assert!(
510 out.iter().all(|&v| v == 200),
511 "corners darkened: {:?}",
512 &out[..8]
513 );
514 }
515
516 #[test]
517 fn a_gaussian_is_normalized_and_symmetric() {
518 let kernel = Kernel::gaussian(1.5).unwrap();
519 let sum: f32 = kernel.taps().iter().sum();
520 assert!((sum - 1.0).abs() < 1e-4, "gaussian sums to {sum}");
521
522 let w = kernel.width() as usize;
523 for y in 0..w {
524 for x in 0..w {
525 let mirrored = kernel.taps()[y * w + (w - 1 - x)];
526 assert!(
527 (kernel.taps()[y * w + x] - mirrored).abs() < 1e-6,
528 "gaussian is not symmetric"
529 );
530 }
531 }
532 }
533
534 #[test]
535 fn a_blur_actually_blurs() {
536 let descriptor = ImageDescriptor::new(8, 1, PixelFormat::Gray8).unwrap();
539 let mut bytes = vec![0_u8; 8];
540 for slot in bytes.iter_mut().skip(4) {
541 *slot = 255;
542 }
543 let kernel = Kernel::blur(3).unwrap();
544 let (_, out) = apply(&Convolve::new(kernel), &descriptor, &bytes).unwrap();
545 assert!(
546 out[3] > 0 && out[3] < 255,
547 "the edge did not soften: {out:?}"
548 );
549 assert!(
550 out[0] == 0,
551 "far from the edge should be unchanged: {out:?}"
552 );
553 }
554
555 #[test]
556 fn an_edge_detector_is_not_normalized_away() {
557 let kernel = Kernel::square(3, &[0.0, -1.0, 0.0, -1.0, 4.0, -1.0, 0.0, -1.0, 0.0]).unwrap();
559 assert!(
560 kernel.taps().iter().all(|t| t.is_finite()),
561 "zero-sum kernel produced non-finite taps"
562 );
563 let (desc, bytes) = flat(8, 8, PixelFormat::Gray8, 128);
564 let (_, out) = apply(&Convolve::new(kernel), &desc, &bytes).unwrap();
565 assert!(out.iter().all(|&v| v == 0), "got {:?}", &out[..8]);
567 }
568
569 #[test]
570 fn sharpening_overshoot_is_clamped_not_wrapped() {
571 let descriptor = ImageDescriptor::new(8, 1, PixelFormat::Gray8).unwrap();
572 let mut bytes = vec![10_u8; 8];
573 bytes[4] = 250;
574 let kernel = Kernel::sharpen(3.0).unwrap();
575 let (_, out) = apply(&Convolve::new(kernel), &descriptor, &bytes).unwrap();
576 assert_eq!(out[4], 255, "the peak did not clamp high: {out:?}");
581 assert_eq!(out[3], 0, "the neighbour did not clamp low: {out:?}");
582 }
583
584 #[test]
585 fn even_and_oversized_kernels_are_rejected() {
586 assert!(Kernel::new(2, 3, &[0.0; 6]).is_err(), "even width");
587 assert!(Kernel::new(3, 4, &[0.0; 12]).is_err(), "even height");
588 assert!(Kernel::new(0, 3, &[]).is_err(), "zero width");
589 let huge = Kernel::MAX_SIZE + 2;
590 assert!(
591 Kernel::new(huge, 3, &vec![0.0; (huge * 3) as usize]).is_err(),
592 "oversized"
593 );
594 }
595
596 #[test]
597 fn a_tap_count_mismatch_is_an_error() {
598 assert!(Kernel::new(3, 3, &[0.0; 8]).is_err());
599 assert!(Kernel::new(3, 3, &[0.0; 10]).is_err());
600 }
601
602 #[test]
603 fn non_finite_taps_and_sigmas_are_rejected() {
604 assert!(Kernel::square(3, &[f32::NAN; 9]).is_err());
605 assert!(Kernel::gaussian(0.0).is_err());
606 assert!(Kernel::gaussian(-1.0).is_err());
607 assert!(Kernel::gaussian(f32::NAN).is_err());
608 assert!(Kernel::sharpen(f32::INFINITY).is_err());
609 }
610
611 #[test]
612 fn demand_is_grown_by_the_radius_and_clamped() {
613 let input = ImageDescriptor::new(20, 20, PixelFormat::Gray8).unwrap();
614 let op = Convolve::new(Kernel::blur(5).unwrap());
615
616 let demand = op
618 .input_regions(Region::new(10, 10, 4, 4), std::slice::from_ref(&input))
619 .unwrap();
620 assert_eq!(demand[0], Region::new(8, 8, 8, 8));
621
622 let corner = op
624 .input_regions(Region::new(0, 0, 4, 4), std::slice::from_ref(&input))
625 .unwrap();
626 assert_eq!(corner[0], Region::new(0, 0, 6, 6));
627
628 for y in 0..input.height {
630 for x in 0..input.width {
631 let r = op
632 .input_regions(Region::new(x, y, 1, 1), std::slice::from_ref(&input))
633 .unwrap()[0];
634 assert!(
635 r.x + r.width <= input.width && r.y + r.height <= input.height,
636 "demand {r} leaves the image"
637 );
638 }
639 }
640 }
641
642 #[test]
643 fn the_output_is_independent_of_how_the_image_is_tiled() {
644 let kernel = Kernel::blur(3).unwrap();
645 let op = Convolve::new(kernel);
646 let descriptor = ImageDescriptor::new(17, 13, PixelFormat::Rgb8).unwrap();
647 let bytes: Vec<u8> = (0..descriptor.byte_len().unwrap())
648 .map(|i| (i * 31 % 251) as u8)
649 .collect();
650 let (out_desc, whole) = apply(&op, &descriptor, &bytes).unwrap();
651 let source = TileBuf::from_vec(descriptor.region(), descriptor.pixel, bytes).unwrap();
652
653 for (tw, th) in [(4_u32, 4_u32), (1, 13), (17, 1), (5, 3)] {
654 let mut target = TileBuf::for_image(&out_desc).unwrap();
655 let mut y = 0;
656 while y < out_desc.height {
657 let h = th.min(out_desc.height - y);
658 let mut x = 0;
659 while x < out_desc.width {
660 let w = tw.min(out_desc.width - x);
661 let region = Region::new(x, y, w, h);
662 let demand = op
663 .input_regions(region, std::slice::from_ref(&descriptor))
664 .unwrap();
665 let mut cut = TileBuf::zeroed(demand[0], descriptor.pixel).unwrap();
666 otf_pixels_core::copy_region(
667 &source.as_tile().unwrap(),
668 &mut cut.as_tile_mut().unwrap(),
669 demand[0],
670 )
671 .unwrap();
672 let mut sub = TileBuf::zeroed(region, out_desc.pixel).unwrap();
673 op.compute(&[cut.as_tile().unwrap()], &mut sub.as_tile_mut().unwrap())
674 .unwrap();
675 otf_pixels_core::copy_region(
676 &sub.as_tile().unwrap(),
677 &mut target.as_tile_mut().unwrap(),
678 region,
679 )
680 .unwrap();
681 x += w;
682 }
683 y += h;
684 }
685 assert_eq!(
686 target.bytes(),
687 whole.as_slice(),
688 "tiling at {tw}x{th} changed the pixels"
689 );
690 }
691 }
692
693 #[test]
694 fn wide_formats_convolve_too() {
695 for format in [PixelFormat::Gray16, PixelFormat::Rgb16, PixelFormat::RgbF32] {
696 let descriptor = ImageDescriptor::new(8, 8, format).unwrap();
697 let bytes = vec![0_u8; descriptor.byte_len().unwrap()];
698 let op = Convolve::new(Kernel::blur(3).unwrap());
699 let (_, out) = apply(&op, &descriptor, &bytes).unwrap();
700 assert_eq!(out.len(), bytes.len(), "{format}");
701 }
702 }
703}