Skip to main content

otf_pixels_ops/
rotate.rs

1//! [`Rotate`] — quarter-turn rotation.
2//!
3//! Like [`Crop`], [`Flip`] and [`Flop`], this is a pure layout op: every output
4//! pixel is some input pixel, moved. It copies whole pixels as opaque byte runs
5//! and never inspects sample values, so one implementation covers every pixel
6//! format including ones added later.
7//!
8//! [`Crop`]: crate::Crop
9//! [`Flip`]: crate::Flip
10//! [`Flop`]: crate::Flop
11//!
12//! # Only quarter turns
13//!
14//! SPEC §Core ops says multiples of 90 degrees, and that restriction is what
15//! keeps this exact. An arbitrary angle needs resampling, which means a filter,
16//! an edge policy and an output-size convention — three decisions that belong
17//! to a `rotate_arbitrary` op rather than being smuggled in here.
18
19use otf_pixels_core::{
20    AccessPattern, ImageDescriptor, Op, PixelsError, Region, Result, Tile, TileMut,
21};
22
23/// A quarter-turn rotation, clockwise.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
25pub enum Quarter {
26    /// No rotation.
27    #[default]
28    None,
29    /// 90 degrees clockwise.
30    Clockwise90,
31    /// 180 degrees.
32    Half,
33    /// 270 degrees clockwise, i.e. 90 anticlockwise.
34    Clockwise270,
35}
36
37impl Quarter {
38    /// The rotation for `degrees`, which must be a multiple of 90.
39    ///
40    /// Negative and out-of-range angles are normalized, so -90 and 270 are the
41    /// same rotation.
42    ///
43    /// # Errors
44    ///
45    /// Returns [`PixelsError::InvalidArgument`] if `degrees` is not a multiple
46    /// of 90. Rounding to the nearest quarter turn would silently rotate an
47    /// image differently from what was asked.
48    pub fn from_degrees(degrees: i32) -> Result<Self> {
49        if degrees % 90 != 0 {
50            return Err(PixelsError::invalid_argument(
51                "degrees",
52                format!("rotation must be a multiple of 90, got {degrees}"),
53            ));
54        }
55        Ok(match degrees.rem_euclid(360) / 90 {
56            1 => Self::Clockwise90,
57            2 => Self::Half,
58            3 => Self::Clockwise270,
59            _ => Self::None,
60        })
61    }
62
63    /// Whether this rotation exchanges width and height.
64    #[must_use]
65    pub const fn transposes(self) -> bool {
66        matches!(self, Self::Clockwise90 | Self::Clockwise270)
67    }
68
69    /// The rotation that undoes this one.
70    #[must_use]
71    pub const fn inverse(self) -> Self {
72        match self {
73            Self::None => Self::None,
74            Self::Clockwise90 => Self::Clockwise270,
75            Self::Half => Self::Half,
76            Self::Clockwise270 => Self::Clockwise90,
77        }
78    }
79}
80
81/// Rotate an image by a multiple of 90 degrees.
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
83pub struct Rotate {
84    quarter: Quarter,
85}
86
87impl Rotate {
88    /// Rotate by `quarter`.
89    #[must_use]
90    pub const fn new(quarter: Quarter) -> Self {
91        Self { quarter }
92    }
93
94    /// Rotate by `degrees`, which must be a multiple of 90.
95    ///
96    /// # Errors
97    ///
98    /// As [`Quarter::from_degrees`].
99    pub fn degrees(degrees: i32) -> Result<Self> {
100        Ok(Self::new(Quarter::from_degrees(degrees)?))
101    }
102
103    /// The rotation this op applies.
104    #[must_use]
105    pub const fn quarter(&self) -> Quarter {
106        self.quarter
107    }
108
109    /// Map an output coordinate back to the input coordinate it came from.
110    ///
111    /// `input` is the size of the *input* image, which the mapping needs
112    /// because a rotation reflects across an axis whose position depends on it.
113    const fn source_of(&self, x: u32, y: u32, input_width: u32, input_height: u32) -> (u32, u32) {
114        match self.quarter {
115            Quarter::None => (x, y),
116            // Clockwise 90: output (x, y) comes from input (x', y') where the
117            // top-left of the output is the bottom-left of the input.
118            Quarter::Clockwise90 => (y, input_height.saturating_sub(1).saturating_sub(x)),
119            Quarter::Half => (
120                input_width.saturating_sub(1).saturating_sub(x),
121                input_height.saturating_sub(1).saturating_sub(y),
122            ),
123            Quarter::Clockwise270 => (input_width.saturating_sub(1).saturating_sub(y), x),
124        }
125    }
126}
127
128impl Op for Rotate {
129    /// A rotation by a multiple of 90 degrees carries no length or
130    /// coordinate: it permutes pixels, so it means the same thing whatever
131    /// resolution they arrive at, and holds no state to discard.
132    fn rescaled(&self) -> Option<std::sync::Arc<dyn Op>> {
133        Some(std::sync::Arc::new(Self::new(self.quarter)))
134    }
135    fn name(&self) -> &'static str {
136        "rotate"
137    }
138
139    fn output_descriptor(&self, inputs: &[ImageDescriptor]) -> Result<ImageDescriptor> {
140        let input = inputs
141            .first()
142            .ok_or_else(|| PixelsError::graph("`rotate` takes one input, got none"))?;
143        if self.quarter.transposes() {
144            input.resized(input.height, input.width)
145        } else {
146            Ok(*input)
147        }
148    }
149
150    fn input_regions(&self, output: Region, inputs: &[ImageDescriptor]) -> Result<Vec<Region>> {
151        let input = inputs
152            .first()
153            .ok_or_else(|| PixelsError::graph("`rotate` takes one input, got none"))?;
154
155        // The rotated image of a rectangle is a rectangle, so the demand is
156        // exact rather than a bounding box: map the two opposite corners and
157        // normalize. Nothing outside the output tile is ever requested.
158        let (x0, y0) = self.source_of(output.x, output.y, input.width, input.height);
159        let last_x = output.x + output.width.saturating_sub(1);
160        let last_y = output.y + output.height.saturating_sub(1);
161        let (x1, y1) = self.source_of(last_x, last_y, input.width, input.height);
162
163        let (left, right) = (x0.min(x1), x0.max(x1));
164        let (top, bottom) = (y0.min(y1), y0.max(y1));
165        Ok(vec![Region::new(
166            left,
167            top,
168            right - left + 1,
169            bottom - top + 1,
170        )])
171    }
172
173    fn access_pattern(&self) -> AccessPattern {
174        // A quarter turn reads a column per output row, so a full-width strip
175        // would demand the whole input height. Square tiles keep the working
176        // set to the tile itself — the same reasoning as ADR-0003's spatial
177        // case, and the reason `Flip` is *not* spatial: a mirror still reads
178        // one input row per output row.
179        if self.quarter.transposes() {
180            AccessPattern::Spatial
181        } else {
182            AccessPattern::Sequential
183        }
184    }
185
186    fn compute(&self, inputs: &[Tile<'_>], output: &mut TileMut<'_>) -> Result<()> {
187        let input = inputs
188            .first()
189            .ok_or_else(|| PixelsError::graph("`rotate` takes one input tile, got none"))?;
190        if input.pixel() != output.pixel() {
191            return Err(PixelsError::graph(format!(
192                "`rotate` input is {} but output is {}",
193                input.pixel(),
194                output.pixel()
195            )));
196        }
197
198        let bytes = output.pixel().bytes_per_pixel();
199        let region = output.region();
200        let source = input.region();
201
202        // The input tile covers `source`, which is the demand computed above.
203        // Reconstructing the whole-image size from it is what lets the same
204        // mapping work per tile as for the whole image.
205        let (input_width, input_height) = whole_input_size(self.quarter, region, source);
206
207        for y in region.y..region.y + region.height {
208            // Collected first, then written: `row_mut` borrows the output
209            // mutably, so the input row lookups have to finish before it.
210            let mut row_bytes = Vec::with_capacity(region.width as usize * bytes);
211            for x in region.x..region.x + region.width {
212                let (sx, sy) = self.source_of(x, y, input_width, input_height);
213                let Some(from) = input.row(sy) else {
214                    row_bytes.resize(row_bytes.len() + bytes, 0);
215                    continue;
216                };
217                let at = (sx.saturating_sub(source.x) as usize) * bytes;
218                match from.get(at..at + bytes) {
219                    Some(pixel) => row_bytes.extend_from_slice(pixel),
220                    None => row_bytes.resize(row_bytes.len() + bytes, 0),
221                }
222            }
223            if let Some(target) = output.row_mut(y) {
224                let len = target.len().min(row_bytes.len());
225                if let (Some(to), Some(from)) = (target.get_mut(..len), row_bytes.get(..len)) {
226                    to.copy_from_slice(from);
227                }
228            }
229        }
230        Ok(())
231    }
232}
233
234/// Recover the input image's size from an output tile and the region it maps to.
235///
236/// The rotation mapping needs the *image's* dimensions, not the tile's, because
237/// it reflects across an axis at the image edge. The scheduler hands over only
238/// regions, so the size is reconstructed from the two together — which is exact
239/// because `input_regions` computed the region from that same size.
240const fn whole_input_size(quarter: Quarter, output: Region, source: Region) -> (u32, u32) {
241    match quarter {
242        Quarter::None => (source.x + source.width, source.y + source.height),
243        Quarter::Clockwise90 => (
244            source.x + source.width,
245            // x was reflected: source.y = height - 1 - (output.x + width - 1).
246            source.y + source.height + output.x,
247        ),
248        Quarter::Half => (
249            source.x + source.width + output.x,
250            source.y + source.height + output.y,
251        ),
252        Quarter::Clockwise270 => (source.x + source.width + output.y, source.y + source.height),
253    }
254}
255
256#[cfg(test)]
257#[allow(
258    clippy::unwrap_used,
259    clippy::expect_used,
260    clippy::indexing_slicing,
261    clippy::panic,
262    reason = "tests operate on known-good values and assert shapes directly"
263)]
264mod tests {
265    use super::*;
266    use otf_pixels_core::{PixelFormat, TileBuf};
267
268    /// Run an op over a whole image.
269    fn apply(
270        op: &dyn Op,
271        input: &ImageDescriptor,
272        bytes: &[u8],
273    ) -> Result<(ImageDescriptor, Vec<u8>)> {
274        let out_desc = op.output_descriptor(std::slice::from_ref(input))?;
275        let source = TileBuf::from_vec(input.region(), input.pixel, bytes.to_vec())?;
276        let mut target = TileBuf::for_image(&out_desc)?;
277        op.compute(&[source.as_tile()?], &mut target.as_tile_mut()?)?;
278        Ok((out_desc, target.into_bytes()))
279    }
280
281    /// An image whose every pixel encodes its own coordinates.
282    fn coded(width: u32, height: u32) -> (ImageDescriptor, Vec<u8>) {
283        let descriptor = ImageDescriptor::new(width, height, PixelFormat::Rgb8).unwrap();
284        let mut bytes = Vec::new();
285        for y in 0..height {
286            for x in 0..width {
287                bytes.extend_from_slice(&[x as u8, y as u8, 0]);
288            }
289        }
290        (descriptor, bytes)
291    }
292
293    #[test]
294    fn degrees_normalize_to_quarter_turns() {
295        assert_eq!(Quarter::from_degrees(0).unwrap(), Quarter::None);
296        assert_eq!(Quarter::from_degrees(90).unwrap(), Quarter::Clockwise90);
297        assert_eq!(Quarter::from_degrees(180).unwrap(), Quarter::Half);
298        assert_eq!(Quarter::from_degrees(270).unwrap(), Quarter::Clockwise270);
299        assert_eq!(Quarter::from_degrees(360).unwrap(), Quarter::None);
300        assert_eq!(Quarter::from_degrees(-90).unwrap(), Quarter::Clockwise270);
301        assert_eq!(Quarter::from_degrees(450).unwrap(), Quarter::Clockwise90);
302    }
303
304    #[test]
305    fn an_angle_that_is_not_a_quarter_turn_is_an_error() {
306        // Rounding to the nearest quarter would rotate the image differently
307        // from what was asked, silently.
308        for degrees in [1, 45, 89, -30, 100] {
309            assert!(
310                Quarter::from_degrees(degrees).is_err(),
311                "{degrees} should be rejected"
312            );
313        }
314    }
315
316    #[test]
317    fn rotating_transposes_the_shape_only_for_quarter_turns() {
318        let input = ImageDescriptor::new(30, 20, PixelFormat::Rgb8).unwrap();
319        for (quarter, expected) in [
320            (Quarter::None, (30, 20)),
321            (Quarter::Clockwise90, (20, 30)),
322            (Quarter::Half, (30, 20)),
323            (Quarter::Clockwise270, (20, 30)),
324        ] {
325            let out = Rotate::new(quarter)
326                .output_descriptor(std::slice::from_ref(&input))
327                .unwrap();
328            assert_eq!((out.width, out.height), expected, "{quarter:?}");
329        }
330    }
331
332    #[test]
333    fn four_quarter_turns_return_the_original() {
334        // The cheapest complete check on the coordinate mapping: any error in
335        // any single turn fails to cancel over four.
336        let (desc, bytes) = coded(7, 5);
337        let mut current = (desc, bytes.clone());
338        for _ in 0..4 {
339            current = apply(&Rotate::new(Quarter::Clockwise90), &current.0, &current.1).unwrap();
340        }
341        assert_eq!(current.0.width, 7);
342        assert_eq!(current.0.height, 5);
343        assert_eq!(current.1, bytes, "four turns did not return the original");
344    }
345
346    #[test]
347    fn a_rotation_and_its_inverse_cancel() {
348        let (desc, bytes) = coded(9, 4);
349        for quarter in [
350            Quarter::None,
351            Quarter::Clockwise90,
352            Quarter::Half,
353            Quarter::Clockwise270,
354        ] {
355            let (mid_desc, mid) = apply(&Rotate::new(quarter), &desc, &bytes).unwrap();
356            let (back_desc, back) =
357                apply(&Rotate::new(quarter.inverse()), &mid_desc, &mid).unwrap();
358            assert_eq!((back_desc.width, back_desc.height), (9, 4), "{quarter:?}");
359            assert_eq!(back, bytes, "{quarter:?} did not cancel with its inverse");
360        }
361    }
362
363    #[test]
364    fn clockwise_ninety_moves_the_corners_where_it_should() {
365        // The sign of a rotation is exactly the thing that is easy to get
366        // backwards and hard to notice, so pin one corner explicitly.
367        let (desc, bytes) = coded(4, 3);
368        let (out_desc, out) = apply(&Rotate::new(Quarter::Clockwise90), &desc, &bytes).unwrap();
369        assert_eq!((out_desc.width, out_desc.height), (3, 4));
370
371        // Clockwise: the input's bottom-left corner becomes the output's
372        // top-left.
373        let top_left = &out[0..3];
374        assert_eq!(top_left, [0, 2, 0], "expected input (0,2) at output (0,0)");
375    }
376
377    #[test]
378    fn half_turn_is_flip_composed_with_flop() {
379        let (desc, bytes) = coded(6, 5);
380        let (_, rotated) = apply(&Rotate::new(Quarter::Half), &desc, &bytes).unwrap();
381        let (mid_desc, flipped) = apply(&crate::Flip, &desc, &bytes).unwrap();
382        let (_, both) = apply(&crate::Flop, &mid_desc, &flipped).unwrap();
383        assert_eq!(rotated, both, "180 degrees is not flip then flop");
384    }
385
386    #[test]
387    fn the_output_is_independent_of_how_the_image_is_tiled() {
388        // The same guarantee resize carries: a rotation must give the same
389        // pixels whether it runs in one tile or in many, or SPEC §Guarantees 2
390        // is false. This is also what exercises `whole_input_size`.
391        for quarter in [
392            Quarter::None,
393            Quarter::Clockwise90,
394            Quarter::Half,
395            Quarter::Clockwise270,
396        ] {
397            let (desc, bytes) = coded(13, 11);
398            let op = Rotate::new(quarter);
399            let (out_desc, whole) = apply(&op, &desc, &bytes).unwrap();
400            let source = TileBuf::from_vec(desc.region(), desc.pixel, bytes.clone()).unwrap();
401            let mut target = TileBuf::for_image(&out_desc).unwrap();
402
403            for (tw, th) in [(4_u32, 4_u32), (1, 11), (13, 1), (5, 3)] {
404                let mut y = 0;
405                while y < out_desc.height {
406                    let h = th.min(out_desc.height - y);
407                    let mut x = 0;
408                    while x < out_desc.width {
409                        let w = tw.min(out_desc.width - x);
410                        let region = Region::new(x, y, w, h);
411                        let demand = op
412                            .input_regions(region, std::slice::from_ref(&desc))
413                            .unwrap();
414                        let mut cut = TileBuf::zeroed(demand[0], desc.pixel).unwrap();
415                        otf_pixels_core::copy_region(
416                            &source.as_tile().unwrap(),
417                            &mut cut.as_tile_mut().unwrap(),
418                            demand[0],
419                        )
420                        .unwrap();
421                        let mut sub = TileBuf::zeroed(region, out_desc.pixel).unwrap();
422                        op.compute(&[cut.as_tile().unwrap()], &mut sub.as_tile_mut().unwrap())
423                            .unwrap();
424                        otf_pixels_core::copy_region(
425                            &sub.as_tile().unwrap(),
426                            &mut target.as_tile_mut().unwrap(),
427                            region,
428                        )
429                        .unwrap();
430                        x += w;
431                    }
432                    y += h;
433                }
434                assert_eq!(
435                    target.bytes(),
436                    whole.as_slice(),
437                    "{quarter:?} at {tw}x{th} tiles differed from the whole-image result"
438                );
439            }
440        }
441    }
442
443    #[test]
444    fn demand_never_reaches_outside_the_input() {
445        let input = ImageDescriptor::new(17, 9, PixelFormat::Rgb8).unwrap();
446        for quarter in [
447            Quarter::None,
448            Quarter::Clockwise90,
449            Quarter::Half,
450            Quarter::Clockwise270,
451        ] {
452            let op = Rotate::new(quarter);
453            let out = op.output_descriptor(std::slice::from_ref(&input)).unwrap();
454            for y in 0..out.height {
455                for x in 0..out.width {
456                    let demand = op
457                        .input_regions(Region::new(x, y, 1, 1), std::slice::from_ref(&input))
458                        .unwrap();
459                    let r = demand[0];
460                    assert!(
461                        r.x + r.width <= input.width && r.y + r.height <= input.height,
462                        "{quarter:?} demand {r} leaves a {}x{} input",
463                        input.width,
464                        input.height
465                    );
466                }
467            }
468        }
469    }
470
471    #[test]
472    fn rotation_works_for_every_pixel_format() {
473        // A layout op copies opaque byte runs, so this should hold for every
474        // format including ones added later — that is the claim being tested.
475        for &format in PixelFormat::ALL {
476            let descriptor = ImageDescriptor::new(5, 3, format).unwrap();
477            let len = descriptor.byte_len().unwrap();
478            let bytes: Vec<u8> = (0..len).map(|i| (i % 253) as u8).collect();
479            let op = Rotate::new(Quarter::Clockwise90);
480            let (mid_desc, mid) = apply(&op, &descriptor, &bytes).unwrap();
481            let (_, back) = apply(&Rotate::new(Quarter::Clockwise270), &mid_desc, &mid).unwrap();
482            assert_eq!(
483                back, bytes,
484                "{format} did not round-trip through a rotation"
485            );
486        }
487    }
488}