Skip to main content

otf_pixels_ops/
convolve.rs

1//! [`Convolve`] — arbitrary small kernels, with blur and sharpen presets.
2//!
3//! # Edges
4//!
5//! Samples outside the image are taken as the nearest edge pixel (clamp).
6//! That is the choice that does not invent detail: zero-padding darkens
7//! borders, and reflection fabricates structure that was never there. It is
8//! also what makes the op's demand exactly the output region grown by the
9//! radius and clamped — nothing outside the input is ever requested, which
10//! `Op::input_regions` requires.
11//!
12//! # Fixed point
13//!
14//! Per ADR-0011 the 8-bit path quantizes the kernel into `i32` once, at
15//! construction, and accumulates in `i32`. Wider formats accumulate in `f32`.
16//! The kernel is normalized by its own sum, so a blur preserves brightness
17//! and an edge detector summing to zero is left alone.
18
19use otf_pixels_core::{
20    AccessPattern, ImageDescriptor, Op, PixelsError, Region, Result, SampleKind, Tile, TileMut,
21};
22
23use crate::resample::MAX_CHANNELS;
24
25/// Fixed-point scale for quantized kernel taps.
26const ONE: i32 = 1 << 14;
27
28/// A convolution kernel: odd-sized, square or rectangular, small.
29#[derive(Debug, Clone, PartialEq)]
30pub struct Kernel {
31    width: u32,
32    height: u32,
33    /// The taps, row-major, normalized so they sum to 1 (or to 0 if the
34    /// original did).
35    taps: Vec<f32>,
36    /// The same taps in fixed point, for the 8-bit path.
37    quantized: Vec<i32>,
38}
39
40impl Kernel {
41    /// The largest kernel accepted, per axis.
42    ///
43    /// "Arbitrary small kernels" (SPEC §Core ops) needs a number attached to
44    /// it. Beyond this a separable or frequency-domain implementation is the
45    /// right answer, and silently accepting a 999-tap kernel would be an
46    /// invitation to a very slow surprise.
47    pub const MAX_SIZE: u32 = 63;
48
49    /// Build a kernel from `width * height` taps in row-major order.
50    ///
51    /// Taps are normalized by their sum, so `[1; 9]` and `[1.0/9.0; 9]` are the
52    /// same blur. A kernel summing to zero — an edge detector — is left
53    /// unnormalized rather than divided by zero.
54    ///
55    /// # Errors
56    ///
57    /// Returns [`PixelsError::InvalidArgument`] if the dimensions are even or
58    /// zero, exceed [`Kernel::MAX_SIZE`], disagree with the tap count, or if
59    /// any tap is not finite.
60    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            // An even kernel has no centre tap, so "the pixel this output
69            // corresponds to" is ambiguous and the image shifts by half a
70            // pixel. Refusing is better than picking a convention silently.
71            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        // A zero-sum kernel is an edge detector, not a mistake; dividing by
102        // its sum would be dividing by zero.
103        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    /// A square kernel of `size` taps per side.
125    ///
126    /// # Errors
127    ///
128    /// As [`Kernel::new`].
129    pub fn square(size: u32, taps: &[f32]) -> Result<Self> {
130        Self::new(size, size, taps)
131    }
132
133    /// A box blur of `size` by `size`.
134    ///
135    /// # Errors
136    ///
137    /// As [`Kernel::new`].
138    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    /// A Gaussian blur approximated at `sigma`, sized to 3 sigma each way.
144    ///
145    /// # Errors
146    ///
147    /// Returns [`PixelsError::InvalidArgument`] if `sigma` is not finite and
148    /// positive, or if the implied kernel exceeds [`Kernel::MAX_SIZE`].
149    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        // 3 sigma captures ~99.7% of the distribution; truncating closer
157        // leaves a visible discontinuity at the kernel edge.
158        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    /// A 3x3 sharpen: the identity plus a Laplacian scaled by `amount`.
173    ///
174    /// # Errors
175    ///
176    /// Returns [`PixelsError::InvalidArgument`] if `amount` is not finite.
177    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    /// The kernel width in taps.
189    #[must_use]
190    pub const fn width(&self) -> u32 {
191        self.width
192    }
193
194    /// The kernel height in taps.
195    #[must_use]
196    pub const fn height(&self) -> u32 {
197        self.height
198    }
199
200    /// How far the kernel reaches from its centre, horizontally.
201    #[must_use]
202    pub const fn radius_x(&self) -> u32 {
203        self.width / 2
204    }
205
206    /// How far the kernel reaches from its centre, vertically.
207    #[must_use]
208    pub const fn radius_y(&self) -> u32 {
209        self.height / 2
210    }
211
212    /// The normalized taps, row-major.
213    #[must_use]
214    pub fn taps(&self) -> &[f32] {
215        &self.taps
216    }
217}
218
219/// Apply a convolution kernel.
220#[derive(Debug, Clone, PartialEq)]
221pub struct Convolve {
222    kernel: Kernel,
223}
224
225impl Convolve {
226    /// Convolve with `kernel`.
227    #[must_use]
228    pub const fn new(kernel: Kernel) -> Self {
229        Self { kernel }
230    }
231
232    /// The kernel this op applies.
233    #[must_use]
234    pub const fn kernel(&self) -> &Kernel {
235        &self.kernel
236    }
237}
238
239impl Op for Convolve {
240    /// Never rescaled. The kernel is measured in pixels, so the same 3x3 blur
241    /// over a source decoded eight times smaller is eight times the blur
242    /// relative to image content. The output would be the right size and the
243    /// wrong picture, which is exactly the failure the conservative default
244    /// exists to prevent.
245    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        // Grown by the radius and clamped: edge handling is this op's business
265        // (Op::input_regions), so it never asks for pixels that do not exist.
266        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    /// Accumulate the 8-bit neighbourhood of one pixel in fixed point.
348    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    /// Accumulate a 16-bit or float neighbourhood in `f32`.
382    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
427/// Offset a coordinate by a kernel tap, clamping to the available window.
428///
429/// Clamping to the *tile* is correct because `input_regions` already clamped
430/// the tile to the image: at an image edge the tile stops there too, so
431/// clamping to the tile is clamping to the image.
432const 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        // Normalization exists so a blur does not darken. On a flat field any
489        // drift is immediately visible as a changed value.
490        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        // Clamped edges are what make this true; zero-padding would darken
505        // the border, which is the classic convolution bug.
506        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        // A hard edge must soften. Without this the tests above would all pass
537        // for an op that did nothing at all.
538        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        // A zero-sum kernel would divide by zero if normalized blindly.
558        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        // A flat field has no edges, so an edge detector returns zero.
566        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        // A strong sharpen around a spike overshoots hard in both directions:
577        // the peak saturates and its neighbours undershoot below zero. Both
578        // must clamp. Wrapping would turn the bright pixel black and the dark
579        // ones white, which is the most alarming possible failure mode.
580        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        // Interior: grown by 2 in every direction.
617        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        // Corner: clamped rather than reaching outside the image.
623        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        // Every tile of every size must stay inside.
629        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}