zenpixels_convert/orient.rs
1//! Physical orientation baking — rotate / flip a whole pixel buffer.
2//!
3//! [`apply_orientation`] takes a (possibly strided) [`PixelSlice`] and an
4//! [`Orientation`] and returns a fresh, tightly-allocated [`PixelBuffer`] with
5//! the pixels physically rearranged. It is the "bake" half of the zen
6//! orientation model: codecs that decode to a raster buffer and are asked to
7//! resolve orientation (`OrientationHint::bakes()` is true) call this; the
8//! cheap coordinate math (`Orientation::forward_map` / `output_dimensions`)
9//! lives in `zenpixels`, and this is the buffer operation that consumes it.
10//!
11//! # Algorithm
12//!
13//! The eight orientations split into two classes:
14//!
15//! * **Non-transposing** (`Identity`, `FlipH`, `FlipV`, `Rotate180`) — the
16//! output has the same dimensions, so each output row maps to exactly one
17//! input row. `Identity`/`FlipV` are pure row copies (memcpy, reordered for
18//! `FlipV`); `FlipH`/`Rotate180` additionally reverse the `bpp`-sized
19//! elements within each row. These are memory-bandwidth bound — a scalar
20//! `copy_from_slice` per row already runs at copy speed.
21//!
22//! * **Transposing** (`Transpose`, `Rotate90`, `Rotate270`, `Transverse`) —
23//! width and height swap, and the access pattern is a matrix transpose, which
24//! is the cache-hostile case: a naïve element loop strides one of the two
25//! buffers by a full row per step and thrashes the cache once the image
26//! exceeds L1/L2. We use the standard fix — **loop tiling (cache blocking)**:
27//! process the image in `TILE`×`TILE` blocks so each block's source and
28//! destination footprints (`TILE*TILE*bpp` bytes each) stay resident while we
29//! transpose them. The orientation's reflection (the `h-1-sy` / `w-1-sx`
30//! terms that turn a bare transpose into a 90°/270° rotation or anti-diagonal
31//! flip) is folded into the per-element destination address via
32//! `forward_map`, so the whole thing is a single pass with no intermediate
33//! buffer.
34//!
35//! For **4-byte pixels** the per-tile transpose is SIMD on every supported
36//! arch: full 4×4 tiles go through magetypes' `f32x4::transpose_4x4` (the
37//! classic `_MM_TRANSPOSE4_PS`-shaped shuffle cascade — SSE on x86, NEON on
38//! aarch64, SIMD128 on wasm), generated once via `#[magetypes(v3, neon,
39//! wasm128, scalar)]` and dispatched by `incant!` at runtime (scalar tier when
40//! no SIMD is available). Each pixel rides as one f32 lane — the kernel only
41//! shuffles whole 32-bit lanes (no float math), so reinterpreting the bytes as
42//! f32 is bit-exact for any 4-byte format, NaN bit patterns included. The
43//! non-multiple-of-4 edge strips and every other element width use the
44//! cache-blocked scalar path, which is also the parity oracle
45//! (`simd_transpose_matches_scalar_reference_rgba8`). (1- and 2-byte SIMD
46//! transpose — the 16×16 `punpck` cascade — is a possible follow-up; gray /
47//! 16-bit currently go scalar.)
48
49use core::cmp::min;
50
51use zenpixels::{InPlacePixels, Orientation, PixelBuffer, PixelSlice, PixelSliceMut};
52
53use crate::error::ConvertError;
54
55// Cross-arch SIMD: the `#[magetypes(...)]` codegen attribute + `incant!`
56// runtime dispatch from the archmage prelude, and the token-parameterized
57// generic `f32x4` from magetypes — whose `transpose_4x4` lowers to SSE
58// `_MM_TRANSPOSE4_PS` on x86, the NEON `zip`/`trn` cascade on aarch64, and the
59// `i32x4.shuffle` cascade on wasm128.
60use archmage::prelude::*;
61use magetypes::simd::generic::f32x4 as GenericF32x4;
62
63/// Side length of the cache-blocking tile for transposing orientations, in
64/// pixels. At `bpp = 4` a 32×32 tile touches 4 KiB of source and 4 KiB of
65/// destination — comfortably inside L1 — while staying large enough to amortise
66/// the per-tile loop overhead.
67const TILE: u32 = 32;
68
69/// Apply `orientation` to `src`, returning a freshly-allocated buffer with the
70/// pixels physically rearranged.
71///
72/// The returned buffer's dimensions are
73/// [`orientation.output_dimensions(src.width(), src.rows())`](Orientation::output_dimensions)
74/// — width and height swap for the four axis-swapping orientations. The pixel
75/// descriptor is preserved exactly (this moves whole `bpp`-sized pixels; it
76/// never touches their contents), so it is format-, channel-, and bit-depth
77/// agnostic. Strided input is handled.
78///
79/// This allocates the output every call. Callers that reuse or pool a target
80/// buffer (e.g. a codec `decode_into`, or an image proxy processing same-size
81/// images) should use [`apply_orientation_into`] to avoid the allocation.
82/// `Orientation::Identity` still allocates and copies (callers that want to skip
83/// the copy entirely should check `orientation.is_identity()` themselves).
84#[must_use]
85pub fn apply_orientation(src: PixelSlice<'_>, orientation: Orientation) -> PixelBuffer {
86 let (ow, oh) = orientation.output_dimensions(src.width(), src.rows());
87 let desc = src.descriptor();
88 let mut out = PixelBuffer::new(ow, oh, desc);
89 // The buffer is constructed to the exact output geometry + descriptor, so
90 // the size/format check inside `apply_orientation_into` cannot fail.
91 apply_orientation_into(src, orientation, out.as_slice_mut())
92 .expect("apply_orientation: freshly allocated buffer matches output geometry");
93 out
94}
95
96/// Apply `orientation` to `src`, writing into a caller-provided `dst` — **no
97/// allocation**, so callers can reuse / pool the target across many calls.
98///
99/// `dst` must already have the oriented geometry
100/// ([`orientation.output_dimensions(src.width(), src.rows())`](Orientation::output_dimensions))
101/// and the same bytes-per-pixel as `src`; otherwise [`ConvertError::BufferSize`]
102/// is returned and `dst` is left untouched. The allocating [`apply_orientation`]
103/// is a thin wrapper over this.
104pub fn apply_orientation_into(
105 src: PixelSlice<'_>,
106 orientation: Orientation,
107 mut dst: PixelSliceMut<'_>,
108) -> Result<(), ConvertError> {
109 let w = src.width();
110 let h = src.rows();
111 let bpp = src.descriptor().bytes_per_pixel();
112 let (ow, oh) = orientation.output_dimensions(w, h);
113
114 let dst_bpp = dst.descriptor().bytes_per_pixel();
115 if dst.width() != ow || dst.rows() != oh || dst_bpp != bpp {
116 return Err(ConvertError::BufferSize {
117 expected: ow as usize * oh as usize * bpp,
118 actual: dst.width() as usize * dst.rows() as usize * dst_bpp,
119 });
120 }
121 if w == 0 || h == 0 || bpp == 0 {
122 return Ok(());
123 }
124
125 {
126 match orientation {
127 Orientation::Identity => {
128 for y in 0..h {
129 dst.row_mut(y).copy_from_slice(src.row(y));
130 }
131 }
132 Orientation::FlipV => {
133 for y in 0..h {
134 dst.row_mut(y).copy_from_slice(src.row(h - 1 - y));
135 }
136 }
137 Orientation::FlipH => {
138 for y in 0..h {
139 reverse_row(src.row(y), dst.row_mut(y), w as usize, bpp);
140 }
141 }
142 Orientation::Rotate180 => {
143 for y in 0..h {
144 reverse_row(src.row(h - 1 - y), dst.row_mut(y), w as usize, bpp);
145 }
146 }
147 // Axis-swapping: cache-blocked transpose with the orientation's
148 // reflection folded into the destination address. The `_` arm makes
149 // this the correct (if unoptimised) fallback for any orientation
150 // added to the `#[non_exhaustive]` enum in future — it scatters by
151 // `forward_map`, which is defined for every variant.
152 Orientation::Transpose
153 | Orientation::Rotate90
154 | Orientation::Rotate270
155 | Orientation::Transverse
156 | _ => {
157 do_transpose(&src, &mut dst, orientation, w, h, bpp);
158 }
159 }
160 }
161 Ok(())
162}
163
164/// Largest bytes-per-pixel the in-place path's per-element temp supports — covers
165/// every current format up to RGBA f32 (16 bytes).
166const MAX_INPLACE_BPP: usize = 16;
167
168/// Bake `orientation` into `dst` **in place**, reusing its allocation — no second
169/// pixel buffer (the transposing orientations would otherwise need a 2× transient).
170///
171/// Consumes the mutable view, permutes the bytes within the backing allocation,
172/// and returns a re-described **tight-stride** `PixelSliceMut` over the same
173/// memory — dimensions swapped for the four transposing orientations. Like the
174/// no-alloc reduction APIs, the returned view carries the new geometry; the
175/// source `PixelBuffer`'s own `width()`/`height()` go stale, so use the returned
176/// view. Square images transpose via an in-place diagonal swap; non-square via
177/// cycle-following (an `n`-element visited scratch — not a 2× pixel buffer).
178///
179/// Returns [`ConvertError::BufferSize`] if `bpp` exceeds 16 (the per-element temp
180/// limit) or if re-describing the output fails.
181pub fn apply_orientation_in_place(
182 dst: &mut PixelBuffer,
183 orientation: Orientation,
184) -> Result<(), ConvertError> {
185 let bpp = dst.descriptor().bytes_per_pixel();
186 if bpp == 0 || bpp > MAX_INPLACE_BPP {
187 return Err(ConvertError::BufferSize {
188 expected: MAX_INPLACE_BPP,
189 actual: bpp,
190 });
191 }
192 // The eight known orientations all have an in-place mapping; a future
193 // `#[non_exhaustive]` variant falls back to the allocating
194 // `apply_orientation` at the caller.
195 if !matches!(
196 orientation,
197 Orientation::Identity
198 | Orientation::FlipH
199 | Orientation::FlipV
200 | Orientation::Rotate180
201 | Orientation::Transpose
202 | Orientation::Rotate90
203 | Orientation::Rotate270
204 | Orientation::Transverse
205 ) {
206 return Err(ConvertError::BufferSize {
207 expected: MAX_INPLACE_BPP,
208 actual: 0,
209 });
210 }
211 dst.transform_in_place(|px| orient_in_place_impl(px, orientation));
212 Ok(())
213}
214
215/// The transform body behind [`apply_orientation_in_place`]: permute the
216/// bytes and return the re-described tight-stride view for
217/// [`PixelBuffer::transform_in_place`] to adopt.
218fn orient_in_place_impl(px: InPlacePixels<'_>, orientation: Orientation) -> PixelSliceMut<'_> {
219 let InPlacePixels {
220 bytes,
221 width: w,
222 rows: h,
223 stride: in_stride,
224 descriptor: desc,
225 color,
226 ..
227 } = px;
228 let bpp = desc.bytes_per_pixel();
229 let (ow, oh) = orientation.output_dimensions(w, h);
230 let tight = w as usize * bpp;
231 let out_stride = ow as usize * bpp;
232 let out_len = out_stride * oh as usize;
233
234 fn rewrap<'b>(
235 bytes: &'b mut [u8],
236 ow: u32,
237 oh: u32,
238 out_stride: usize,
239 desc: zenpixels::PixelDescriptor,
240 color: Option<alloc::sync::Arc<zenpixels::ColorContext>>,
241 ) -> PixelSliceMut<'b> {
242 let out = PixelSliceMut::new(bytes, ow, oh, out_stride, desc)
243 .expect("oriented in-place geometry is always valid");
244 match color {
245 Some(c) => out.with_color_context(c),
246 None => out,
247 }
248 }
249
250 if w == 0 || h == 0 {
251 return rewrap(&mut bytes[..out_len], ow, oh, out_stride, desc, color);
252 }
253
254 // 1. Compact to tight (drop any row padding) so the transpose is a clean
255 // permutation of a contiguous element array.
256 if in_stride != tight {
257 for y in 1..h as usize {
258 bytes.copy_within(y * in_stride..y * in_stride + tight, y * tight);
259 }
260 }
261 let content = &mut bytes[..tight * h as usize];
262
263 // 2/3. Permute in place. Transposing orientations transpose the tight w×h
264 // grid (→ h×w = ow×oh) then add the orientation's reflection.
265 match orientation {
266 Orientation::Identity => {}
267 Orientation::FlipH => inplace_flip_h(content, w, h, bpp),
268 Orientation::FlipV => inplace_flip_v(content, w, h, bpp),
269 Orientation::Rotate180 => inplace_reverse_elements(content, bpp),
270 Orientation::Transpose => inplace_transpose(content, w, h, bpp),
271 Orientation::Rotate90 => {
272 inplace_transpose(content, w, h, bpp);
273 inplace_flip_h(content, ow, oh, bpp); // transpose ∘ FlipH
274 }
275 Orientation::Rotate270 => {
276 inplace_transpose(content, w, h, bpp);
277 inplace_flip_v(content, ow, oh, bpp); // transpose ∘ FlipV
278 }
279 Orientation::Transverse => {
280 inplace_transpose(content, w, h, bpp);
281 inplace_reverse_elements(content, bpp); // transpose ∘ Rotate180
282 }
283 // Pre-checked in `apply_orientation_in_place`; unreachable here.
284 _ => {}
285 }
286
287 rewrap(&mut bytes[..out_len], ow, oh, out_stride, desc, color)
288}
289
290/// Reverse the `bpp`-sized elements within each row, in place (`FlipH`).
291fn inplace_flip_h(a: &mut [u8], w: u32, h: u32, bpp: usize) {
292 let w = w as usize;
293 let row_len = w * bpp;
294 for y in 0..h as usize {
295 let row = &mut a[y * row_len..y * row_len + row_len];
296 let (mut lo, mut hi) = (0usize, w - 1);
297 while lo < hi {
298 let (al, ah) = (lo * bpp, hi * bpp);
299 for k in 0..bpp {
300 row.swap(al + k, ah + k);
301 }
302 lo += 1;
303 hi -= 1;
304 }
305 }
306}
307
308/// Swap row `y` with row `h-1-y`, in place (`FlipV`). No temp row — the two rows
309/// are disjoint, so `split_at_mut` + `swap_with_slice` exchanges them directly.
310fn inplace_flip_v(a: &mut [u8], w: u32, h: u32, bpp: usize) {
311 let row_len = w as usize * bpp;
312 let h = h as usize;
313 let (mut top, mut bot) = (0usize, h - 1);
314 while top < bot {
315 let split = bot * row_len;
316 let (head, tail) = a.split_at_mut(split);
317 head[top * row_len..top * row_len + row_len].swap_with_slice(&mut tail[..row_len]);
318 top += 1;
319 bot -= 1;
320 }
321}
322
323/// Reverse the order of all `bpp`-sized elements in the buffer (`Rotate180` =
324/// `FlipH ∘ FlipV`).
325fn inplace_reverse_elements(a: &mut [u8], bpp: usize) {
326 let n = a.len() / bpp;
327 if n < 2 {
328 return;
329 }
330 let (mut lo, mut hi) = (0usize, n - 1);
331 while lo < hi {
332 let (al, ah) = (lo * bpp, hi * bpp);
333 for k in 0..bpp {
334 a.swap(al + k, ah + k);
335 }
336 lo += 1;
337 hi -= 1;
338 }
339}
340
341/// In-place transpose of a tight `w`×`h` (row-major) grid of `bpp`-byte elements
342/// into `h`×`w`, within the same buffer.
343///
344/// Square is the diagonal swap. Non-square follows the transpose permutation's
345/// cycles (`Wikipedia: in-place matrix transposition`): element index `k = r*w+c`
346/// maps to `c*h+r ≡ (k*h) mod (n-1)`; to fill position `cur` we gather from
347/// `(cur*w) mod (n-1)` (the inverse, since `w*h ≡ 1`), walking each cycle once
348/// with a one-element temp and an `n`-bit visited set. `0` and `n-1` are fixed.
349fn inplace_transpose(a: &mut [u8], w: u32, h: u32, bpp: usize) {
350 if w == h {
351 let n = w as usize;
352 for i in 0..n {
353 for j in (i + 1)..n {
354 let (p, q) = ((i * n + j) * bpp, (j * n + i) * bpp);
355 for k in 0..bpp {
356 a.swap(p + k, q + k);
357 }
358 }
359 }
360 return;
361 }
362
363 let (w, h) = (w as usize, h as usize);
364 let n = w * h;
365 if n <= 1 {
366 return;
367 }
368 let mn1 = n - 1;
369 let mut moved = alloc::vec![false; n];
370 moved[0] = true;
371 moved[mn1] = true;
372 let mut tmp = [0u8; MAX_INPLACE_BPP];
373 let mut start = 1;
374 while start < mn1 {
375 if moved[start] {
376 start += 1;
377 continue;
378 }
379 tmp[..bpp].copy_from_slice(&a[start * bpp..start * bpp + bpp]);
380 let mut cur = start;
381 loop {
382 moved[cur] = true;
383 let prev = (cur * w) % mn1; // element that belongs at `cur`
384 if prev == start {
385 break;
386 }
387 a.copy_within(prev * bpp..prev * bpp + bpp, cur * bpp);
388 cur = prev;
389 }
390 a[cur * bpp..cur * bpp + bpp].copy_from_slice(&tmp[..bpp]);
391 start += 1;
392 }
393}
394
395/// Bench-only A/B handle: bake `orientation` via the cache-blocked **scalar**
396/// transpose, bypassing the SIMD kernel, so `bench_orient` can compare the two
397/// paths on identical input. Only meaningful for the transposing orientations.
398#[cfg(feature = "__bench_orient")]
399#[doc(hidden)]
400#[must_use]
401pub fn __bench_apply_orientation_scalar(
402 src: PixelSlice<'_>,
403 orientation: Orientation,
404) -> PixelBuffer {
405 let w = src.width();
406 let h = src.rows();
407 let desc = src.descriptor();
408 let bpp = desc.bytes_per_pixel();
409 let (ow, oh) = orientation.output_dimensions(w, h);
410 let mut out = PixelBuffer::new(ow, oh, desc);
411 if w == 0 || h == 0 || bpp == 0 {
412 return out;
413 }
414 {
415 let mut dst = out.as_slice_mut();
416 transpose_blocked(&src, &mut dst, orientation, w, h, bpp);
417 }
418 out
419}
420
421/// Copy one row, reversing the order of `bpp`-sized pixels (`FlipH` per row).
422#[inline]
423fn reverse_row(s: &[u8], d: &mut [u8], width: usize, bpp: usize) {
424 for x in 0..width {
425 let si = (width - 1 - x) * bpp;
426 let di = x * bpp;
427 d[di..di + bpp].copy_from_slice(&s[si..si + bpp]);
428 }
429}
430
431/// Scatter one source pixel `(sx, sy)` to its oriented destination.
432#[inline]
433#[allow(clippy::too_many_arguments)] // per-pixel helper; an args struct would add overhead/noise
434fn scatter_pixel(
435 s: &[u8],
436 dst: &mut PixelSliceMut<'_>,
437 orientation: Orientation,
438 sx: u32,
439 sy: u32,
440 w: u32,
441 h: u32,
442 bpp: usize,
443) {
444 let (dx, dy) = orientation.forward_map(sx, sy, w, h);
445 let si = sx as usize * bpp;
446 let di = dx as usize * bpp;
447 dst.row_mut(dy)[di..di + bpp].copy_from_slice(&s[si..si + bpp]);
448}
449
450/// Dispatch the axis-swapping orientations: the SIMD 4×4 register transpose for
451/// 4-byte pixels (the common decoder output), the cache-blocked scalar path
452/// otherwise. `incant!` picks the best tier per target (AVX2 / NEON / WASM
453/// SIMD128 / scalar); the scalar tier is the same algorithm as `transpose_blocked`.
454fn do_transpose(
455 src: &PixelSlice<'_>,
456 dst: &mut PixelSliceMut<'_>,
457 orientation: Orientation,
458 w: u32,
459 h: u32,
460 bpp: usize,
461) {
462 // Only the four known transposing orientations have a `tile_dest` mapping;
463 // a future `#[non_exhaustive]` variant falls through to the scalar scatter.
464 if bpp == 4
465 && matches!(
466 orientation,
467 Orientation::Transpose
468 | Orientation::Rotate90
469 | Orientation::Rotate270
470 | Orientation::Transverse
471 )
472 {
473 // Explicit tier list matching the `#[magetypes(v3, neon, wasm128,
474 // scalar)]` attribute on `transpose4_simd`: a bare `incant!` expands
475 // the full cascade and references a `_v4` variant that was never
476 // generated, breaking `--features avx512` builds (caught by the
477 // feature-powerset CI job). Same convention as the `scan` kernels.
478 incant!(
479 transpose4_simd(src, dst, orientation, w, h),
480 [v3, neon, wasm128, scalar]
481 );
482 return;
483 }
484 transpose_blocked(src, dst, orientation, w, h, bpp);
485}
486
487/// Cache-blocked scalar transpose for the four axis-swapping orientations. The
488/// per-element destination is `orientation.forward_map(sx, sy, w, h)`, which
489/// encodes transpose + whatever reflection the orientation adds; tiling keeps
490/// each block's scattered destination writes inside the cache. This is the
491/// portable path and the parity oracle for the SIMD kernel.
492fn transpose_blocked(
493 src: &PixelSlice<'_>,
494 dst: &mut PixelSliceMut<'_>,
495 orientation: Orientation,
496 w: u32,
497 h: u32,
498 bpp: usize,
499) {
500 let mut tile_y = 0;
501 while tile_y < h {
502 let y_end = min(tile_y + TILE, h);
503 let mut tile_x = 0;
504 while tile_x < w {
505 let x_end = min(tile_x + TILE, w);
506 for sy in tile_y..y_end {
507 let s = src.row(sy);
508 for sx in tile_x..x_end {
509 scatter_pixel(s, dst, orientation, sx, sy, w, h, bpp);
510 }
511 }
512 tile_x += TILE;
513 }
514 tile_y += TILE;
515 }
516}
517
518/// Scalar scatter for the edge strips a 4×4-tiled SIMD pass leaves uncovered:
519/// the right strip (`cols [full_w, w)`) and the bottom strip (`rows [full_h,
520/// h)`, which also covers the bottom-right corner). No overlap between strips.
521#[allow(clippy::too_many_arguments)] // edge-strip helper; mirrors the scatter-loop signature
522fn transpose_edges(
523 src: &PixelSlice<'_>,
524 dst: &mut PixelSliceMut<'_>,
525 orientation: Orientation,
526 w: u32,
527 h: u32,
528 bpp: usize,
529 full_w: u32,
530 full_h: u32,
531) {
532 for sy in 0..full_h {
533 let s = src.row(sy);
534 for sx in full_w..w {
535 scatter_pixel(s, dst, orientation, sx, sy, w, h, bpp);
536 }
537 }
538 for sy in full_h..h {
539 let s = src.row(sy);
540 for sx in 0..w {
541 scatter_pixel(s, dst, orientation, sx, sy, w, h, bpp);
542 }
543 }
544}
545
546// ── SIMD 4×4 register transpose (cross-arch, 4-byte pixels) ──────────────────
547
548/// Destination of transposed-tile row `r` (transposed row index 0..4) for a
549/// source 4×4 tile at `(bx, by)`, as `(dst_row, dst_col_start, reverse_lanes)`.
550/// Derived from `Orientation::forward_map`: a bare `Transpose` writes row `r` to
551/// `dst[bx+r][by..]`; the rotations/anti-diagonal add a row/col reflection.
552/// `by`/`bx` are multiples of 4 with `by+4 ≤ h`, `bx+4 ≤ w`, so the subtractions
553/// never underflow.
554#[inline]
555fn tile_dest(
556 orientation: Orientation,
557 bx: u32,
558 by: u32,
559 r: u32,
560 w: u32,
561 h: u32,
562) -> (u32, u32, bool) {
563 match orientation {
564 Orientation::Transpose => (bx + r, by, false),
565 Orientation::Rotate90 => (bx + r, h - 4 - by, true),
566 Orientation::Rotate270 => (w - 1 - bx - r, by, false),
567 Orientation::Transverse => (w - 1 - bx - r, h - 4 - by, true),
568 _ => unreachable!("tile_dest only handles the four transposing orientations"),
569 }
570}
571
572/// SIMD path: transpose full 4×4 tiles via `f32x4::transpose_4x4` (the classic
573/// `_MM_TRANSPOSE4_PS`-shaped shuffle cascade), scalar for the edges. The
574/// `#[magetypes]` attribute generates one variant per SIMD tier from this single
575/// body; `incant!` in [`do_transpose`] picks the best at runtime.
576///
577/// Each 4-byte pixel rides as one f32 lane. The transpose only *shuffles whole
578/// 32-bit lanes* (no float arithmetic), so the reinterpret is bit-exact for any
579/// 4-byte pixel format, including bit patterns that happen to be NaN.
580#[magetypes(v3, neon, wasm128, scalar)]
581fn transpose4_simd(
582 token: Token,
583 src: &PixelSlice<'_>,
584 dst: &mut PixelSliceMut<'_>,
585 orientation: Orientation,
586 w: u32,
587 h: u32,
588) {
589 #[allow(non_camel_case_types)]
590 type f32x4 = GenericF32x4<Token>;
591
592 let full_w = w & !3; // largest multiple of 4 ≤ w
593 let full_h = h & !3;
594
595 let mut by = 0;
596 while by < full_h {
597 let mut bx = 0;
598 while bx < full_w {
599 let xb = bx as usize * 4;
600 let f0: [f32; 4] =
601 bytemuck::cast::<[u8; 16], _>(src.row(by)[xb..xb + 16].try_into().unwrap());
602 let f1: [f32; 4] =
603 bytemuck::cast::<[u8; 16], _>(src.row(by + 1)[xb..xb + 16].try_into().unwrap());
604 let f2: [f32; 4] =
605 bytemuck::cast::<[u8; 16], _>(src.row(by + 2)[xb..xb + 16].try_into().unwrap());
606 let f3: [f32; 4] =
607 bytemuck::cast::<[u8; 16], _>(src.row(by + 3)[xb..xb + 16].try_into().unwrap());
608 let mut rows = [
609 f32x4::load(token, &f0),
610 f32x4::load(token, &f1),
611 f32x4::load(token, &f2),
612 f32x4::load(token, &f3),
613 ];
614 f32x4::transpose_4x4(&mut rows);
615
616 for r in 0..4u32 {
617 let mut lanes = [0f32; 4];
618 rows[r as usize].store(&mut lanes);
619 let (drow, dcol, rev) = tile_dest(orientation, bx, by, r, w, h);
620 if rev {
621 lanes.reverse();
622 }
623 let bytes: [u8; 16] = bytemuck::cast(lanes);
624 let db = dcol as usize * 4;
625 dst.row_mut(drow)[db..db + 16].copy_from_slice(&bytes);
626 }
627 bx += 4;
628 }
629 by += 4;
630 }
631
632 transpose_edges(src, dst, orientation, w, h, 4, full_w, full_h);
633}
634
635#[cfg(test)]
636mod tests {
637 use super::*;
638 use zenpixels::PixelDescriptor;
639
640 /// Build a tightly-packed source slice from raw bytes.
641 fn slice<'a>(data: &'a [u8], w: u32, h: u32, desc: PixelDescriptor) -> PixelSlice<'a> {
642 PixelSlice::new(data, w, h, w as usize * desc.bytes_per_pixel(), desc).unwrap()
643 }
644
645 /// A 3×2 gray8 image with per-pixel values 0..6:
646 /// row0: 0 1 2
647 /// row1: 3 4 5
648 const SRC_3X2: [u8; 6] = [0, 1, 2, 3, 4, 5];
649
650 /// Expected output of each orientation on `SRC_3X2`, hand-derived from the
651 /// rotation geometry (NOT from `forward_map` — this is the independent
652 /// oracle). `(out_w, out_h, bytes)`.
653 fn expected_3x2(o: Orientation) -> (u32, u32, Vec<u8>) {
654 match o {
655 Orientation::Identity => (3, 2, vec![0, 1, 2, 3, 4, 5]),
656 Orientation::FlipH => (3, 2, vec![2, 1, 0, 5, 4, 3]),
657 Orientation::FlipV => (3, 2, vec![3, 4, 5, 0, 1, 2]),
658 Orientation::Rotate180 => (3, 2, vec![5, 4, 3, 2, 1, 0]),
659 // transposing → dims swap to 2×3
660 Orientation::Transpose => (2, 3, vec![0, 3, 1, 4, 2, 5]),
661 Orientation::Rotate90 => (2, 3, vec![3, 0, 4, 1, 5, 2]),
662 Orientation::Rotate270 => (2, 3, vec![2, 5, 1, 4, 0, 3]),
663 Orientation::Transverse => (2, 3, vec![5, 2, 4, 1, 3, 0]),
664 _ => unreachable!("non-exhaustive Orientation in test oracle"),
665 }
666 }
667
668 #[test]
669 fn all_orientations_match_hand_derived_oracle_gray8() {
670 let desc = PixelDescriptor::GRAY8;
671 for &o in &Orientation::ALL {
672 let out = apply_orientation(slice(&SRC_3X2, 3, 2, desc), o);
673 let (ew, eh, ebytes) = expected_3x2(o);
674 assert_eq!((out.width(), out.height()), (ew, eh), "{o:?} dims");
675 // Compare row-by-row (output stride may be SIMD-aligned, not tight).
676 let s = out.as_slice();
677 for y in 0..eh {
678 let got = s.row(y);
679 let exp = &ebytes[y as usize * ew as usize..][..ew as usize];
680 assert_eq!(got, exp, "{o:?} row {y}");
681 }
682 }
683 }
684
685 #[test]
686 fn all_orientations_match_oracle_rgba8() {
687 // Same geometry, but each pixel carries 4 distinct channel bytes so a
688 // within-pixel byte-order bug would show. pixel v -> [v, v+64, v+128, 255].
689 let desc = PixelDescriptor::RGBA8;
690 let mut src = Vec::new();
691 for v in 0u8..6 {
692 src.extend_from_slice(&[v, v + 64, v + 128, 255]);
693 }
694 for &o in &Orientation::ALL {
695 let out = apply_orientation(slice(&src, 3, 2, desc), o);
696 let (ew, eh, gray) = expected_3x2(o);
697 assert_eq!((out.width(), out.height()), (ew, eh), "{o:?} dims");
698 let s = out.as_slice();
699 for y in 0..eh {
700 let got = s.row(y);
701 for x in 0..ew {
702 let v = gray[(y * ew + x) as usize];
703 let exp = [v, v + 64, v + 128, 255];
704 assert_eq!(&got[x as usize * 4..][..4], &exp, "{o:?} px ({x},{y})");
705 }
706 }
707 }
708 }
709
710 /// Deterministic pseudo-random byte (no Math.random/Date in tests anyway).
711 fn fill(n: usize) -> Vec<u8> {
712 let mut v = Vec::with_capacity(n);
713 let mut s = 0x9e3779b9u32;
714 for _ in 0..n {
715 s = s.wrapping_mul(1664525).wrapping_add(1013904223);
716 v.push((s >> 24) as u8);
717 }
718 v
719 }
720
721 #[test]
722 fn roundtrip_orientation_then_inverse_is_identity() {
723 // apply(apply(img, o), o.inverse()) == img, for every orientation and a
724 // spread of element sizes and odd dimensions.
725 for &desc in &[
726 PixelDescriptor::GRAY8, // 1
727 PixelDescriptor::GRAYA8, // 2
728 PixelDescriptor::RGB8, // 3
729 PixelDescriptor::RGBA8, // 4
730 PixelDescriptor::RGBAF32, // 16
731 ] {
732 let bpp = desc.bytes_per_pixel();
733 for &(w, h) in &[(1u32, 1u32), (17, 13), (33, 31), (64, 48)] {
734 let data = fill(w as usize * h as usize * bpp);
735 for &o in &Orientation::ALL {
736 let once = apply_orientation(slice(&data, w, h, desc), o);
737 let back = apply_orientation(once.as_slice(), o.inverse());
738 assert_eq!(
739 (back.width(), back.height()),
740 (w, h),
741 "{o:?} {desc:?} {w}x{h}"
742 );
743 for y in 0..h {
744 let exp = &data[y as usize * w as usize * bpp..][..w as usize * bpp];
745 assert_eq!(
746 back.as_slice().row(y),
747 exp,
748 "{o:?} {desc:?} {w}x{h} row {y}"
749 );
750 }
751 }
752 }
753 }
754 }
755
756 #[test]
757 fn compose_matches_sequential_application() {
758 // apply(img, a.then(b)) == apply(apply(img, a), b) — ties the baker to
759 // the D4 group algebra in zenpixels.
760 let desc = PixelDescriptor::RGBA8;
761 let (w, h) = (11u32, 7u32);
762 let data = fill(w as usize * h as usize * 4);
763 for &a in &Orientation::ALL {
764 for &b in &Orientation::ALL {
765 let seq =
766 apply_orientation(apply_orientation(slice(&data, w, h, desc), a).as_slice(), b);
767 let fused = apply_orientation(slice(&data, w, h, desc), a.then(b));
768 assert_eq!(
769 (seq.width(), seq.height()),
770 (fused.width(), fused.height()),
771 "{a:?}.then({b:?}) dims"
772 );
773 for y in 0..seq.height() {
774 assert_eq!(
775 seq.as_slice().row(y),
776 fused.as_slice().row(y),
777 "{a:?}.then({b:?}) row {y}"
778 );
779 }
780 }
781 }
782 }
783
784 #[test]
785 fn handles_strided_source() {
786 // A source whose stride exceeds width*bpp must produce the same result
787 // as a tight one (padding bytes must be ignored).
788 let desc = PixelDescriptor::RGBA8;
789 let (w, h) = (5u32, 4u32);
790 let tight_stride = w as usize * 4;
791 let padded_stride = tight_stride + 12;
792 let tight = fill(tight_stride * h as usize);
793 let mut padded = vec![0xABu8; padded_stride * h as usize];
794 for y in 0..h as usize {
795 padded[y * padded_stride..y * padded_stride + tight_stride]
796 .copy_from_slice(&tight[y * tight_stride..][..tight_stride]);
797 }
798 for &o in &Orientation::ALL {
799 // PixelSlice is a non-Copy view; build a fresh one per iteration.
800 let tight_slice = PixelSlice::new(&tight, w, h, tight_stride, desc).unwrap();
801 let padded_slice = PixelSlice::new(&padded, w, h, padded_stride, desc).unwrap();
802 let a = apply_orientation(tight_slice, o);
803 let b = apply_orientation(padded_slice, o);
804 for y in 0..a.height() {
805 assert_eq!(a.as_slice().row(y), b.as_slice().row(y), "{o:?} row {y}");
806 }
807 }
808 }
809
810 /// Gold-standard parity gate: the (SIMD on x86_64) `apply_orientation` must
811 /// match the explicit scalar `transpose_blocked` for 4-byte pixels across
812 /// the four transposing orientations and a spread of dimensions — full 4×4
813 /// tiles (8×8, 16×16, 64×48), edge-only (3×3, 1×1), and mixed full+edge
814 /// (17×13, 9×7, 12×4, 4×12, 5×5). This is what proves the SIMD kernel +
815 /// edge handling are correct against the portable oracle.
816 #[test]
817 fn simd_transpose_matches_scalar_reference_rgba8() {
818 let desc = PixelDescriptor::RGBA8;
819 let dims = [
820 (8u32, 8u32),
821 (16, 16),
822 (64, 48),
823 (17, 13),
824 (9, 7),
825 (12, 4),
826 (4, 12),
827 (3, 3),
828 (1, 1),
829 (5, 5),
830 ];
831 for &(w, h) in &dims {
832 let data = fill(w as usize * h as usize * 4);
833 for &o in &[
834 Orientation::Transpose,
835 Orientation::Rotate90,
836 Orientation::Rotate270,
837 Orientation::Transverse,
838 ] {
839 // Path under test (SIMD on x86_64, scalar elsewhere).
840 let got = apply_orientation(slice(&data, w, h, desc), o);
841 // Explicit scalar reference via the cache-blocked oracle.
842 let (ow, oh) = o.output_dimensions(w, h);
843 let mut reference = PixelBuffer::new(ow, oh, desc);
844 {
845 let src = slice(&data, w, h, desc);
846 let mut d = reference.as_slice_mut();
847 transpose_blocked(&src, &mut d, o, w, h, 4);
848 }
849 for y in 0..oh {
850 assert_eq!(
851 got.as_slice().row(y),
852 reference.as_slice().row(y),
853 "{o:?} {w}x{h} row {y}"
854 );
855 }
856 }
857 }
858 }
859
860 #[test]
861 fn into_writes_caller_buffer_and_is_reusable() {
862 // One target buffer, reused across four transposing orientations (all
863 // share the swapped 13×17 geometry for a 17×13 input) — proves the
864 // no-alloc reuse path and that it matches the allocating version.
865 let desc = PixelDescriptor::RGBA8;
866 let (w, h) = (17u32, 13u32);
867 let data = fill(w as usize * h as usize * 4);
868 let (ow, oh) = Orientation::Rotate90.output_dimensions(w, h);
869 let mut target = PixelBuffer::new(ow, oh, desc);
870 for &o in &[
871 Orientation::Rotate90,
872 Orientation::Rotate270,
873 Orientation::Transverse,
874 Orientation::Transpose,
875 ] {
876 apply_orientation_into(slice(&data, w, h, desc), o, target.as_slice_mut())
877 .expect("into should accept a correctly-sized buffer");
878 let want = apply_orientation(slice(&data, w, h, desc), o);
879 for y in 0..oh {
880 assert_eq!(
881 target.as_slice().row(y),
882 want.as_slice().row(y),
883 "{o:?} row {y}"
884 );
885 }
886 }
887 }
888
889 #[test]
890 fn into_rejects_wrong_sized_dst() {
891 // Rotate90 of 8×6 needs a 6×8 target; an 8×6 buffer (same byte count,
892 // wrong dims) must be rejected with BufferSize, leaving dst untouched.
893 let desc = PixelDescriptor::RGBA8;
894 let (w, h) = (8u32, 6u32);
895 let data = fill(w as usize * h as usize * 4);
896 let mut wrong = PixelBuffer::new(w, h, desc); // 8×6, but Rotate90 → 6×8
897 let result = apply_orientation_into(
898 slice(&data, w, h, desc),
899 Orientation::Rotate90,
900 wrong.as_slice_mut(),
901 );
902 assert!(
903 matches!(result, Err(ConvertError::BufferSize { .. })),
904 "expected BufferSize, got {result:?}"
905 );
906 }
907
908 /// In-place must produce byte-identical output to the proven out-of-place
909 /// `apply_orientation`, across square + non-square, tight + (via
910 /// `PixelBuffer::new`'s aligned stride) padded buffers, every orientation
911 /// and a spread of element sizes. This is the correctness gate for the
912 /// diagonal-swap (square), cycle-following (non-square), and in-place flips.
913 #[test]
914 fn in_place_matches_out_of_place() {
915 let descs = [
916 PixelDescriptor::GRAY8,
917 PixelDescriptor::GRAYA8,
918 PixelDescriptor::RGB8,
919 PixelDescriptor::RGBA8,
920 PixelDescriptor::RGBAF32,
921 ];
922 let dims = [
923 (1u32, 1u32),
924 (2, 2),
925 (4, 4),
926 (8, 8),
927 (32, 32),
928 (3, 5),
929 (5, 3),
930 (17, 13),
931 (13, 17),
932 (16, 9),
933 (9, 16),
934 (7, 1),
935 (1, 7),
936 ];
937 for &desc in &descs {
938 let bpp = desc.bytes_per_pixel();
939 for &(w, h) in &dims {
940 let data = fill(w as usize * h as usize * bpp);
941 for &o in &Orientation::ALL {
942 let want = apply_orientation(slice(&data, w, h, desc), o);
943 // Load `data` into a fresh buffer (its stride may be padded),
944 // then bake in place.
945 let mut buf = PixelBuffer::new(w, h, desc);
946 {
947 let mut s = buf.as_slice_mut();
948 for y in 0..h {
949 s.row_mut(y).copy_from_slice(
950 &data[y as usize * w as usize * bpp..][..w as usize * bpp],
951 );
952 }
953 }
954 apply_orientation_in_place(&mut buf, o)
955 .expect("in_place should accept bpp ≤ 16");
956 assert_eq!(
957 (buf.width(), buf.height()),
958 (want.width(), want.height()),
959 "{o:?} {desc:?} {w}x{h} dims"
960 );
961 for y in 0..buf.height() {
962 assert_eq!(
963 buf.as_slice().row(y),
964 want.as_slice().row(y),
965 "{o:?} {desc:?} {w}x{h} row {y}"
966 );
967 }
968 }
969 }
970 }
971 }
972}