Skip to main content

rusty_esp_image_core/
ops.rs

1//! Pixel operations — scalar, bounds-checked, and the oracle for every faster
2//! twin.
3//!
4//! The conversions and downscales a camera pipeline needs moved to
5//! `rusty_esp_dsp::pixel` (D0, 2026-09-02) and are re-exported here at the
6//! paths this crate always had, so a caller sees no change; the crop and
7//! rotate helpers, which only this crate uses, stay. Colour conversion is
8//! BT.601 full-range (the JFIF convention), fixed-point with an 8-bit
9//! fraction — the same arithmetic on every platform.
10
11use rusty_esp_core::error::{Error, Result};
12use rusty_esp_core::frame::{Geometry, PixelFormat};
13
14pub use rusty_esp_dsp::pixel::{
15    downscale2x_gray8, downscale2x_rgb565, pack_rgb565, rgb565_to_rgb888, rgb888_to_rgb565,
16    unpack_rgb565, yuv_to_rgb, yuyv_to_gray8, yuyv_to_rgb565, yuyv_to_rgb888,
17};
18
19fn expect_len(buf: &[u8], needed: usize) -> Result<()> {
20    if buf.len() < needed {
21        Err(Error::BufferTooSmall { needed })
22    } else {
23        Ok(())
24    }
25}
26
27/// Crop a packed frame to the rectangle at (`x`, `y`) of size `w`×`h`.
28pub fn crop(
29    src: &[u8],
30    geometry: Geometry,
31    x: u32,
32    y: u32,
33    w: u32,
34    h: u32,
35    dst: &mut [u8],
36) -> Result<Geometry> {
37    let bpp = geometry
38        .format
39        .packed_bits_per_pixel()
40        .ok_or(Error::Unsupported)? as usize
41        / 8;
42    if x.checked_add(w).is_none_or(|r| r > geometry.width)
43        || y.checked_add(h).is_none_or(|b| b > geometry.height)
44    {
45        return Err(Error::InvalidGeometry);
46    }
47    let out = Geometry::new(w, h, geometry.format)?;
48    expect_len(src, geometry.byte_len().ok_or(Error::Unsupported)?)?;
49    let row_out = w as usize * bpp;
50    expect_len(dst, row_out * h as usize)?;
51    let stride = geometry.width as usize * bpp;
52    for row in 0..h as usize {
53        let s = (y as usize + row) * stride + x as usize * bpp;
54        dst[row * row_out..(row + 1) * row_out].copy_from_slice(&src[s..s + row_out]);
55    }
56    Ok(out)
57}
58
59/// Rotate a Gray8 image 90° clockwise. Output is `height`×`width`.
60pub fn rotate90_gray8(src: &[u8], width: u32, height: u32, dst: &mut [u8]) -> Result<Geometry> {
61    let (w, h) = (width as usize, height as usize);
62    expect_len(src, w * h)?;
63    expect_len(dst, w * h)?;
64    // A source ROW becomes a destination COLUMN. The original recomputed
65    // `x * h` on every inner trip -- `x` IS the inner variable, so that
66    // multiply could not be hoisted -- and bounds-checked both sides per
67    // pixel. Walking the row forward against a destination cursor that steps
68    // by `h` says the same thing with an add.
69    if w != 0 && h != 0 {
70        let dst = &mut dst[..w * h];
71        let src = &src[..w * h];
72        // CHIP ARM: an 8x8 byte transpose in eight `ee.vzip` instructions,
73        // with the destination column's reversal absorbed by loading each
74        // tile's source rows bottom-to-top. It takes only `w % 8 == 0`,
75        // `h % 8 == 0` and both bases 16-byte aligned -- which is what makes
76        // every 64-bit access it issues 8-byte aligned -- and declines
77        // otherwise, leaving the tiled loop below.
78        if pie::rotate90_gray8(src, width, height, dst) {
79            return Geometry::new(height, width, PixelFormat::Gray8);
80        }
81        // A transpose cannot make both sides sequential, so TILE it and make
82        // both sides local instead. Walking whole rows stores every pixel of
83        // a row to a different destination line: the line is fetched, one
84        // byte is written, and it is evicted long before the next row wants
85        // it. An 8x8 tile touches 8 source lines and 8 destination lines for
86        // its 64 pixels, and the destination run inside a tile is contiguous.
87        // EIGHT. Sixteen measured +422% against a 0.0% null arm on an ESP32-S3
88        // (2026-09-19) -- a cliff, not a slope, and worth re-deriving before
89        // anyone widens it again.
90        const T: usize = 8;
91        let mut x0 = 0;
92        while x0 < w {
93            let xe = (x0 + T).min(w);
94            let mut y0 = 0;
95            while y0 < h {
96                let ye = (y0 + T).min(h);
97                for x in x0..xe {
98                    // (x, y) -> (h - 1 - y, x) in an image of width h, so
99                    // column `x` of the output is one contiguous run.
100                    let col = &mut dst[x * h..x * h + h];
101                    for y in y0..ye {
102                        col[h - 1 - y] = src[y * w + x];
103                    }
104                }
105                y0 += T;
106            }
107            x0 += T;
108        }
109    }
110    Geometry::new(height, width, PixelFormat::Gray8)
111}
112
113/// Rotate a packed image 180° in place-compatible fashion (into `dst`).
114pub fn rotate180(src: &[u8], bytes_per_pixel: usize, dst: &mut [u8]) -> Result<usize> {
115    if bytes_per_pixel == 0 || src.len() % bytes_per_pixel != 0 {
116        return Err(Error::InvalidGeometry);
117    }
118    expect_len(dst, src.len())?;
119    let n = src.len() / bytes_per_pixel;
120    // `(n - 1 - i) * bytes_per_pixel` is a descending cursor written as a
121    // multiply: it steps down by exactly `bytes_per_pixel` every trip. A
122    // REVERSED chunk walk says that directly -- no multiply, no per-pixel
123    // bounds check -- and the fixed-width arms name a length the compiler can
124    // see instead of a runtime one.
125    //
126    // NOT a measured win, and the record is worth keeping straight. A
127    // same-build A/B against the original shape (both arms in one ESP32-S3
128    // binary) read 137 726 -> 125 221 ps/px, -9.1%, on 2026-09-19. The same
129    // A/B in the FIVE builds after that read the two arms identical to within
130    // 8 ps. One probe said win, five said wash: the -9.1% was codegen that
131    // did not survive two more crates entering the binary, not this rewrite.
132    // It is kept because it is byte-identical, says what it means, and is no
133    // worse -- not because it is faster.
134    let dst = &mut dst[..src.len()];
135    macro_rules! reversed {
136        ($bpp:expr) => {{
137            for (d, s) in dst.chunks_exact_mut($bpp).rev().zip(src.chunks_exact($bpp)) {
138                d.copy_from_slice(s);
139            }
140        }};
141    }
142    match bytes_per_pixel {
143        1 => reversed!(1),
144        2 => reversed!(2),
145        3 => reversed!(3),
146        4 => reversed!(4),
147        bpp => reversed!(bpp),
148    }
149    Ok(n)
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    #[test]
157    fn downscale_crop_rotate() {
158        // 4x2 gray
159        let src = [0u8, 4, 8, 12, 4, 8, 12, 16];
160        let mut dst = [0u8; 2];
161        let g = downscale2x_gray8(&src, 4, 2, &mut dst).unwrap();
162        assert_eq!((g.width, g.height), (2, 1));
163        assert_eq!(dst, [4, 12]);
164
165        // 2x2 rgb565 of one colour averages to itself
166        let c = pack_rgb565(200, 100, 50).to_le_bytes();
167        let src: std::vec::Vec<u8> = c.iter().copied().cycle().take(8).collect();
168        let mut dst = [0u8; 2];
169        downscale2x_rgb565(&src, 2, 2, &mut dst).unwrap();
170        assert_eq!(dst, c);
171
172        // crop 1x2 out of a 3x2 gray8 image at x=1
173        let src = [1u8, 2, 3, 4, 5, 6];
174        let geo = Geometry::new(3, 2, PixelFormat::Gray8).unwrap();
175        let mut dst = [0u8; 2];
176        let g = crop(&src, geo, 1, 0, 1, 2, &mut dst).unwrap();
177        assert_eq!((g.width, g.height), (1, 2));
178        assert_eq!(dst, [2, 5]);
179        assert_eq!(
180            crop(&src, geo, 3, 0, 1, 1, &mut dst),
181            Err(Error::InvalidGeometry)
182        );
183
184        // rotate 90 cw: 3x2 -> 2x3
185        let mut r = [0u8; 6];
186        let g = rotate90_gray8(&src, 3, 2, &mut r).unwrap();
187        assert_eq!((g.width, g.height), (2, 3));
188        assert_eq!(r, [4, 1, 5, 2, 6, 3]);
189
190        let mut r = [0u8; 6];
191        rotate180(&src, 1, &mut r).unwrap();
192        assert_eq!(r, [6, 5, 4, 3, 2, 1]);
193        let mut r2 = [0u8; 6];
194        rotate180(&[1, 2, 3, 4, 5, 6], 2, &mut r2).unwrap();
195        assert_eq!(r2, [5, 6, 3, 4, 1, 2]);
196    }
197}
198
199/// The chip twin of [`rotate90_gray8`], and its off-chip stand-in.
200///
201/// Both arms exist so the caller's scalar loop is never dead code: with
202/// `pie-s3` off, or on a target that is not an ESP32-S3, the helper declines
203/// from a const-foldable body.
204mod pie {
205    #[cfg(all(feature = "pie-s3", target_arch = "xtensa"))]
206    pub fn rotate90_gray8(src: &[u8], width: u32, height: u32, dst: &mut [u8]) -> bool {
207        rusty_esp_dsp_esp::pie_s3::rotate90_gray8(src, width, height, dst).is_ok()
208    }
209
210    #[cfg(not(all(feature = "pie-s3", target_arch = "xtensa")))]
211    pub fn rotate90_gray8(_: &[u8], _: u32, _: u32, _: &mut [u8]) -> bool {
212        false
213    }
214}