Skip to main content

otf_pixels_ops/
geometry.rs

1//! Geometry ops: [`Crop`], [`Flip`] and [`Flop`].
2//!
3//! These three are pure **layout** ops: every output pixel is some input pixel,
4//! moved. They copy whole pixels as opaque byte runs and never inspect sample
5//! values, so one implementation is correct for every pixel format in SPEC
6//! §Pixel formats — including formats added later.
7//!
8//! That is why they do not use [`dispatch_sample!`]: the per-tile
9//! monomorphized dispatch of ADR-0002 exists so that *arithmetic* kernels get
10//! specialized inner loops, and there is no arithmetic here. Introducing a
11//! dispatch would multiply codegen by three to reach identical machine code.
12//! Ops that do sample math — `modulate`, `convolve`, `resize` in M4 — are where
13//! that dispatch belongs.
14//!
15//! [`dispatch_sample!`]: otf_pixels_core::dispatch_sample
16
17use otf_pixels_core::{
18    AccessPattern, ImageDescriptor, Op, PixelsError, Region, Result, Tile, TileMut,
19};
20
21/// Fetch the sole input descriptor an op was given.
22fn sole_input<'a>(op: &str, inputs: &'a [ImageDescriptor]) -> Result<&'a ImageDescriptor> {
23    inputs
24        .first()
25        .ok_or_else(|| PixelsError::graph(format!("`{op}` takes one input, got none")))
26}
27
28/// Fetch the sole input tile an op was given.
29fn sole_tile<'a, 'b>(op: &str, inputs: &'a [Tile<'b>]) -> Result<&'a Tile<'b>> {
30    inputs
31        .first()
32        .ok_or_else(|| PixelsError::graph(format!("`{op}` takes one input tile, got none")))
33}
34
35/// Extract a rectangular window of an image.
36///
37/// Crop is a coordinate remap, not a copy: it shifts the origin of the region
38/// its input is asked for, so under the M2 scheduler no pixel outside the
39/// window is ever decoded or computed. Cropping the corner off a huge image
40/// costs the corner, not the image.
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub struct Crop {
43    window: Region,
44}
45
46impl Crop {
47    /// Crop to `window`, in input image coordinates.
48    ///
49    /// The window is validated against the actual input when the op is chained
50    /// onto an image, not here, since the input shape is not yet known.
51    ///
52    /// # Errors
53    ///
54    /// Returns [`PixelsError::InvalidArgument`] if the window is empty.
55    pub fn new(window: Region) -> Result<Self> {
56        if window.is_empty() {
57            return Err(PixelsError::invalid_argument(
58                "window",
59                format!("crop window {window} has no pixels"),
60            ));
61        }
62        Ok(Self { window })
63    }
64
65    /// Crop to a window given as origin and size.
66    ///
67    /// # Errors
68    ///
69    /// As [`Crop::new`].
70    pub fn at(x: u32, y: u32, width: u32, height: u32) -> Result<Self> {
71        Self::new(Region::new(x, y, width, height))
72    }
73
74    /// The window this op extracts, in input coordinates.
75    #[must_use]
76    pub const fn window(&self) -> Region {
77        self.window
78    }
79}
80
81impl Op for Crop {
82    /// Never rescaled, and the most important of the exclusions: a crop
83    /// window is given in input pixels. `crop(1000, 1000, 100, 100)` against a
84    /// source decoded eight times smaller would return a different part of the
85    /// picture — correctly shaped, and the wrong content.
86    fn rescaled(&self) -> Option<std::sync::Arc<dyn Op>> {
87        None
88    }
89    fn name(&self) -> &'static str {
90        "crop"
91    }
92
93    fn output_descriptor(&self, inputs: &[ImageDescriptor]) -> Result<ImageDescriptor> {
94        let input = sole_input("crop", inputs)?;
95        if !input.region().contains(self.window) {
96            return Err(PixelsError::invalid_argument(
97                "window",
98                format!("crop window {} lies outside the {input} input", self.window),
99            ));
100        }
101        input.resized(self.window.width, self.window.height)
102    }
103
104    fn input_regions(&self, output: Region, _: &[ImageDescriptor]) -> Result<Vec<Region>> {
105        // The output's origin is the window's origin in input space.
106        Ok(vec![output.translate(
107            i64::from(self.window.x),
108            i64::from(self.window.y),
109        )])
110    }
111
112    fn access_pattern(&self) -> AccessPattern {
113        // Row order is preserved, so a crop can sit in a streaming segment.
114        AccessPattern::Sequential
115    }
116
117    fn compute(&self, inputs: &[Tile<'_>], output: &mut TileMut<'_>) -> Result<()> {
118        let input = sole_tile("crop", inputs)?;
119        let out_region = output.region();
120        let bpp = output.pixel().bytes_per_pixel();
121        let span = out_region.width as usize * bpp;
122        // Output coordinates are window-relative; input coordinates are not.
123        let (dx, dy) = (self.window.x, self.window.y);
124        for y in out_region.y..out_region.y.saturating_add(out_region.height) {
125            let source_y = y.checked_add(dy).ok_or_else(|| {
126                PixelsError::invalid_argument("window", "crop origin overflows the image")
127            })?;
128            let Some(src_row) = input.row(source_y) else {
129                return Err(PixelsError::graph(format!(
130                    "crop input {} is missing row {source_y}",
131                    input.region()
132                )));
133            };
134            let start = (out_region.x.saturating_add(dx) - input.region().x) as usize * bpp;
135            let Some(from) = src_row.get(start..start + span) else {
136                return Err(PixelsError::graph(
137                    "crop input row is too short for the window",
138                ));
139            };
140            let Some(into) = output.row_mut(y) else {
141                return Err(PixelsError::graph(format!(
142                    "crop output is missing row {y}"
143                )));
144            };
145            into.copy_from_slice(from);
146        }
147        Ok(())
148    }
149}
150
151/// Mirror an image vertically: the top row becomes the bottom row.
152///
153/// # Access pattern and order
154///
155/// `Flip` is [`AccessPattern::Sequential`]: it reads exactly one input row per
156/// output row, reads no neighbours, and wants full-width strips — square tiles
157/// would buy it nothing. `AccessPattern` describes tile *shape*, and on shape
158/// `Flip` is as sequential as `crop`.
159///
160/// What `Flip` reverses is tile *order*, and that is deliberately not declared
161/// here. [`Op::input_regions`] already states the mapping exactly — output
162/// rows `y0..y1` come from input rows `H-y1..H-y0` — so the scheduler can
163/// derive the order itself and act on it only where it matters: against a
164/// forward-only source it inserts a materialization buffer, and against a
165/// random-access source (tiled TIFF, a memory buffer) it inserts nothing and
166/// the pipeline streams (ADR-0009).
167///
168/// The point is that reversal is a property of the *seam*, not of this op. A
169/// static "always materialize" flag here would force a full-image buffer even
170/// where the upstream could serve bands bottom-up perfectly well.
171///
172/// [`Op::input_regions`]: otf_pixels_core::Op::input_regions
173#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
174pub struct Flip;
175
176impl Op for Flip {
177    /// A mirror permutes pixels and carries no length, so it means the same
178    /// thing at any resolution.
179    fn rescaled(&self) -> Option<std::sync::Arc<dyn Op>> {
180        Some(std::sync::Arc::new(Self))
181    }
182    fn name(&self) -> &'static str {
183        "flip"
184    }
185
186    fn output_descriptor(&self, inputs: &[ImageDescriptor]) -> Result<ImageDescriptor> {
187        sole_input("flip", inputs).copied()
188    }
189
190    fn input_regions(&self, output: Region, inputs: &[ImageDescriptor]) -> Result<Vec<Region>> {
191        let input = sole_input("flip", inputs)?;
192        // Output row y comes from input row (height - 1 - y), so the requested
193        // band mirrors about the image's horizontal axis.
194        let bottom = u64::from(input.height);
195        if output.bottom() > bottom {
196            return Err(PixelsError::invalid_argument(
197                "output",
198                format!("{output} extends past the {input} input"),
199            ));
200        }
201        // Cast is safe: bottom <= input.height, which is a u32.
202        let y = (bottom - output.bottom()) as u32;
203        Ok(vec![Region::new(output.x, y, output.width, output.height)])
204    }
205
206    fn access_pattern(&self) -> AccessPattern {
207        // One input row per output row, no neighbours: full-width strips suit
208        // it exactly. The reversal is in the order, which `input_regions`
209        // already expresses and the scheduler handles at the seam (ADR-0009).
210        AccessPattern::Sequential
211    }
212
213    fn compute(&self, inputs: &[Tile<'_>], output: &mut TileMut<'_>) -> Result<()> {
214        let input = sole_tile("flip", inputs)?;
215        let (out_region, in_region) = (output.region(), input.region());
216        if out_region.height != in_region.height || out_region.width != in_region.width {
217            return Err(PixelsError::graph(format!(
218                "flip input {in_region} does not match output {out_region}"
219            )));
220        }
221        for offset in 0..out_region.height {
222            let out_y = out_region.y + offset;
223            // Mirror within the band: last input row feeds the first output row.
224            let in_y = in_region.y + (in_region.height - 1 - offset);
225            let Some(from) = input.row(in_y) else {
226                return Err(PixelsError::graph(format!(
227                    "flip input is missing row {in_y}"
228                )));
229            };
230            let Some(into) = output.row_mut(out_y) else {
231                return Err(PixelsError::graph(format!(
232                    "flip output is missing row {out_y}"
233                )));
234            };
235            into.copy_from_slice(from);
236        }
237        Ok(())
238    }
239}
240
241/// Mirror an image horizontally: the left column becomes the right column.
242#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
243pub struct Flop;
244
245impl Op for Flop {
246    /// As [`Flip`]: a permutation with no length in it.
247    fn rescaled(&self) -> Option<std::sync::Arc<dyn Op>> {
248        Some(std::sync::Arc::new(Self))
249    }
250    fn name(&self) -> &'static str {
251        "flop"
252    }
253
254    fn output_descriptor(&self, inputs: &[ImageDescriptor]) -> Result<ImageDescriptor> {
255        sole_input("flop", inputs).copied()
256    }
257
258    fn input_regions(&self, output: Region, inputs: &[ImageDescriptor]) -> Result<Vec<Region>> {
259        let input = sole_input("flop", inputs)?;
260        let right = u64::from(input.width);
261        if output.right() > right {
262            return Err(PixelsError::invalid_argument(
263                "output",
264                format!("{output} extends past the {input} input"),
265            ));
266        }
267        // Cast is safe: right <= input.width, which is a u32.
268        let x = (right - output.right()) as u32;
269        Ok(vec![Region::new(x, output.y, output.width, output.height)])
270    }
271
272    fn access_pattern(&self) -> AccessPattern {
273        // Each output row depends only on the corresponding input row, so a
274        // horizontal mirror still streams in row order.
275        AccessPattern::Sequential
276    }
277
278    fn compute(&self, inputs: &[Tile<'_>], output: &mut TileMut<'_>) -> Result<()> {
279        let input = sole_tile("flop", inputs)?;
280        let (out_region, in_region) = (output.region(), input.region());
281        if out_region.width != in_region.width || out_region.height != in_region.height {
282            return Err(PixelsError::graph(format!(
283                "flop input {in_region} does not match output {out_region}"
284            )));
285        }
286        let bpp = output.pixel().bytes_per_pixel();
287        let width = out_region.width as usize;
288        for offset in 0..out_region.height {
289            let Some(from) = input.row(in_region.y + offset) else {
290                return Err(PixelsError::graph("flop input is missing a row"));
291            };
292            let Some(into) = output.row_mut(out_region.y + offset) else {
293                return Err(PixelsError::graph("flop output is missing a row"));
294            };
295            // Reverse whole pixels, not bytes: the samples within a pixel keep
296            // their channel order.
297            for x in 0..width {
298                let src = x * bpp;
299                let dst = (width - 1 - x) * bpp;
300                let (Some(pixel), Some(slot)) =
301                    (from.get(src..src + bpp), into.get_mut(dst..dst + bpp))
302                else {
303                    return Err(PixelsError::graph("flop row is too short"));
304                };
305                slot.copy_from_slice(pixel);
306            }
307        }
308        Ok(())
309    }
310}
311
312#[cfg(test)]
313#[allow(
314    clippy::unwrap_used,
315    clippy::indexing_slicing,
316    clippy::panic,
317    reason = "tests operate on known-good values and assert shapes directly"
318)]
319mod tests {
320    use super::*;
321    use otf_pixels_core::{
322        BufferSource, ErrorCode, Format, Image, PixelFormat, Producer, TileBuf, evaluate,
323    };
324    use std::sync::Arc;
325
326    /// A `width` x `height` Gray8 image whose pixel values are `y * width + x`.
327    fn ramp(width: u32, height: u32) -> Image {
328        ramp_with(width, height, PixelFormat::Gray8)
329    }
330
331    fn ramp_with(width: u32, height: u32, pixel: PixelFormat) -> Image {
332        let desc = ImageDescriptor::new(width, height, pixel).unwrap();
333        let len = desc.byte_len().unwrap();
334        let bytes: Vec<u8> = (0..len).map(|i| i as u8).collect();
335        let buffer = TileBuf::from_vec(desc.region(), pixel, bytes).unwrap();
336        let source = BufferSource::new(desc, Arc::new(buffer)).unwrap();
337        Image::from_producer(Arc::new(source) as Arc<dyn Producer>, Format::Raw)
338    }
339
340    #[test]
341    fn crop_extracts_the_window() {
342        // 4x4 ramp: rows are 0..3, 4..7, 8..11, 12..15.
343        let image = ramp(4, 4)
344            .apply(Arc::new(Crop::at(1, 1, 2, 2).unwrap()))
345            .unwrap();
346        assert_eq!(image.descriptor().width, 2);
347        assert_eq!(image.descriptor().height, 2);
348        assert_eq!(evaluate(&image).unwrap().bytes(), &[5, 6, 9, 10]);
349    }
350
351    #[test]
352    fn crop_at_the_origin_and_the_far_corner() {
353        let top_left = ramp(4, 4)
354            .apply(Arc::new(Crop::at(0, 0, 2, 2).unwrap()))
355            .unwrap();
356        assert_eq!(evaluate(&top_left).unwrap().bytes(), &[0, 1, 4, 5]);
357        let bottom_right = ramp(4, 4)
358            .apply(Arc::new(Crop::at(2, 2, 2, 2).unwrap()))
359            .unwrap();
360        assert_eq!(evaluate(&bottom_right).unwrap().bytes(), &[10, 11, 14, 15]);
361        // The whole image is a legal window.
362        let whole = ramp(2, 2)
363            .apply(Arc::new(Crop::at(0, 0, 2, 2).unwrap()))
364            .unwrap();
365        assert_eq!(evaluate(&whole).unwrap().bytes(), &[0, 1, 2, 3]);
366    }
367
368    #[test]
369    fn a_window_outside_the_image_is_rejected_at_build_time() {
370        let err = ramp(4, 4)
371            .apply(Arc::new(Crop::at(3, 3, 2, 2).unwrap()))
372            .unwrap_err();
373        assert_eq!(err.code(), ErrorCode::InvalidArgument);
374        let err = ramp(4, 4)
375            .apply(Arc::new(Crop::at(5, 0, 1, 1).unwrap()))
376            .unwrap_err();
377        assert_eq!(err.code(), ErrorCode::InvalidArgument);
378    }
379
380    #[test]
381    fn an_empty_window_is_rejected_at_construction() {
382        assert_eq!(
383            Crop::at(0, 0, 0, 4).unwrap_err().code(),
384            ErrorCode::InvalidArgument
385        );
386        assert_eq!(
387            Crop::at(0, 0, 4, 0).unwrap_err().code(),
388            ErrorCode::InvalidArgument
389        );
390    }
391
392    #[test]
393    fn crop_demands_only_its_window() {
394        // The point of crop: demand propagation asks for the window, not the
395        // whole image, so upstream work is proportional to the output.
396        let crop = Crop::at(10, 20, 4, 4).unwrap();
397        let input = ImageDescriptor::new(100, 100, PixelFormat::Gray8).unwrap();
398        let regions = crop
399            .input_regions(Region::from_size(4, 4), &[input])
400            .unwrap();
401        assert_eq!(regions, vec![Region::new(10, 20, 4, 4)]);
402    }
403
404    #[test]
405    fn flip_mirrors_rows() {
406        // 2x3 ramp: rows are [0,1], [2,3], [4,5].
407        let image = ramp(2, 3).apply(Arc::new(Flip)).unwrap();
408        assert_eq!(evaluate(&image).unwrap().bytes(), &[4, 5, 2, 3, 0, 1]);
409    }
410
411    #[test]
412    fn flop_mirrors_columns() {
413        // 3x2 ramp: rows are [0,1,2], [3,4,5].
414        let image = ramp(3, 2).apply(Arc::new(Flop)).unwrap();
415        assert_eq!(evaluate(&image).unwrap().bytes(), &[2, 1, 0, 5, 4, 3]);
416    }
417
418    #[test]
419    fn flop_reverses_pixels_not_bytes() {
420        // 2x1 RGB8: pixels are (0,1,2) and (3,4,5). Reversing bytes would give
421        // 5,4,3,2,1,0 — reversing pixels gives 3,4,5,0,1,2.
422        let image = ramp_with(2, 1, PixelFormat::Rgb8)
423            .apply(Arc::new(Flop))
424            .unwrap();
425        assert_eq!(evaluate(&image).unwrap().bytes(), &[3, 4, 5, 0, 1, 2]);
426    }
427
428    #[test]
429    fn flip_preserves_pixels_within_rows() {
430        // 2x2 RGBA8: flip swaps rows wholesale, leaving each row's bytes intact.
431        let image = ramp_with(2, 2, PixelFormat::Rgba8)
432            .apply(Arc::new(Flip))
433            .unwrap();
434        assert_eq!(
435            evaluate(&image).unwrap().bytes(),
436            &[8, 9, 10, 11, 12, 13, 14, 15, 0, 1, 2, 3, 4, 5, 6, 7]
437        );
438    }
439
440    #[test]
441    fn flip_and_flop_are_their_own_inverses() {
442        for pixel in [PixelFormat::Gray8, PixelFormat::Rgb8, PixelFormat::Rgba16] {
443            let original = evaluate(&ramp_with(3, 4, pixel)).unwrap();
444            let flipped = ramp_with(3, 4, pixel)
445                .apply(Arc::new(Flip))
446                .unwrap()
447                .apply(Arc::new(Flip))
448                .unwrap();
449            assert_eq!(
450                evaluate(&flipped).unwrap().bytes(),
451                original.bytes(),
452                "flip² for {pixel}"
453            );
454            let flopped = ramp_with(3, 4, pixel)
455                .apply(Arc::new(Flop))
456                .unwrap()
457                .apply(Arc::new(Flop))
458                .unwrap();
459            assert_eq!(
460                evaluate(&flopped).unwrap().bytes(),
461                original.bytes(),
462                "flop² for {pixel}"
463            );
464        }
465    }
466
467    #[test]
468    fn flip_and_flop_preserve_dimensions_and_format() {
469        for pixel in PixelFormat::ALL {
470            let image = ramp_with(3, 5, *pixel).apply(Arc::new(Flip)).unwrap();
471            assert_eq!(image.descriptor().width, 3, "{pixel}");
472            assert_eq!(image.descriptor().height, 5, "{pixel}");
473            assert_eq!(image.descriptor().pixel, *pixel);
474        }
475    }
476
477    #[test]
478    fn flip_demand_mirrors_the_requested_band() {
479        let input = ImageDescriptor::new(4, 10, PixelFormat::Gray8).unwrap();
480        // The top two output rows come from the bottom two input rows.
481        let regions = Flip
482            .input_regions(Region::new(0, 0, 4, 2), &[input])
483            .unwrap();
484        assert_eq!(regions, vec![Region::new(0, 8, 4, 2)]);
485        // A middle band mirrors about the centre.
486        let regions = Flip
487            .input_regions(Region::new(0, 3, 4, 2), &[input])
488            .unwrap();
489        assert_eq!(regions, vec![Region::new(0, 5, 4, 2)]);
490    }
491
492    #[test]
493    fn flop_demand_mirrors_the_requested_band() {
494        let input = ImageDescriptor::new(10, 4, PixelFormat::Gray8).unwrap();
495        let regions = Flop
496            .input_regions(Region::new(0, 0, 2, 4), &[input])
497            .unwrap();
498        assert_eq!(regions, vec![Region::new(8, 0, 2, 4)]);
499    }
500
501    #[test]
502    fn demand_beyond_the_input_is_an_error_not_a_wrap() {
503        let input = ImageDescriptor::new(4, 4, PixelFormat::Gray8).unwrap();
504        let err = Flip
505            .input_regions(Region::new(0, 0, 4, 8), &[input])
506            .unwrap_err();
507        assert_eq!(err.code(), ErrorCode::InvalidArgument);
508        let err = Flop
509            .input_regions(Region::new(0, 0, 8, 4), &[input])
510            .unwrap_err();
511        assert_eq!(err.code(), ErrorCode::InvalidArgument);
512    }
513
514    #[test]
515    fn ops_declare_their_access_patterns() {
516        // ADR-0003: these declarations drive tile negotiation in M2.
517        assert_eq!(
518            Crop::at(0, 0, 1, 1).unwrap().access_pattern(),
519            AccessPattern::Sequential
520        );
521        assert_eq!(Flop.access_pattern(), AccessPattern::Sequential);
522        // Shape, not order: a vertical mirror reads one input row per output
523        // row and wants strips, so it is Sequential despite consuming them
524        // bottom-up. The reversal lives in `input_regions` (ADR-0009).
525        assert_eq!(Flip.access_pattern(), AccessPattern::Sequential);
526    }
527
528    #[test]
529    fn flip_demand_is_a_pure_region_remap() {
530        // The property ADR-0009 rests on: flip states its mapping exactly, so
531        // a random-access upstream can serve it in output order with no buffer.
532        // Every output band maps to an input band of identical size.
533        let input = ImageDescriptor::new(4, 100, PixelFormat::Gray8).unwrap();
534        for y in (0..100).step_by(10) {
535            let output = Region::new(0, y, 4, 10);
536            let regions = Flip.input_regions(output, &[input]).unwrap();
537            assert_eq!(regions.len(), 1);
538            assert_eq!(regions[0].height, output.height, "band size is preserved");
539            assert_eq!(regions[0].width, output.width);
540            // Mirrored about the horizontal axis.
541            assert_eq!(regions[0].y, 100 - y - 10);
542        }
543    }
544
545    #[test]
546    fn ops_reject_being_called_with_no_input() {
547        assert!(Flip.output_descriptor(&[]).is_err());
548        assert!(Flop.output_descriptor(&[]).is_err());
549        assert!(
550            Crop::at(0, 0, 1, 1)
551                .unwrap()
552                .output_descriptor(&[])
553                .is_err()
554        );
555        assert!(
556            Flip.compute(
557                &[],
558                &mut TileBuf::zeroed(Region::from_size(1, 1), PixelFormat::Gray8)
559                    .unwrap()
560                    .as_tile_mut()
561                    .unwrap()
562            )
563            .is_err()
564        );
565    }
566
567    #[test]
568    fn geometry_ops_compose() {
569        // crop then flip then flop, on a 4x4 ramp.
570        let image = ramp(4, 4)
571            .apply(Arc::new(Crop::at(1, 1, 2, 2).unwrap()))
572            .unwrap()
573            .apply(Arc::new(Flip))
574            .unwrap()
575            .apply(Arc::new(Flop))
576            .unwrap();
577        // Window is [[5,6],[9,10]]; flip -> [[9,10],[5,6]]; flop -> [[10,9],[6,5]].
578        assert_eq!(evaluate(&image).unwrap().bytes(), &[10, 9, 6, 5]);
579    }
580}