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}