oxideav-pixfmt
Pure-Rust pixel-format conversions, palette quantisation, and dithering.
Two APIs in one crate:
- Low-level, framework-agnostic — functions in
yuv,rgb,gray, andpal8operate directly on&[u8]/&mut [u8]buffers. No frame type or allocator is imposed — drop them into whatever image/video stack you already have. - High-level —
convert()takes aVideoFramefromoxideav-coreand dispatches on(src.format, dst_format). Convenient when you're already inside the oxideav framework.
Zero C dependencies. Zero FFI. Runtime-selected AVX2 (x86_64) and NEON (aarch64) paths with a scalar fixed-point fallback on every other target.
Install
[]
= "0.1"
Nightly users who want the std::simd path:
= { = "0.1", = ["nightly"] }
What's supported
| category | formats / operations |
|---|---|
| RGB / BGR family | Rgb24, Bgr24, Rgba, Bgra, Argb, Abgr — all-to-all swizzles |
| Deep RGB | Rgb48Le ↔ Rgb24, Rgba64Le ↔ Rgba, Rgb48Le ↔ Rgba64Le (colour words verbatim, opaque 65535 synthesis / drop); Rgb48Le / Rgba64Le → every planar YUV(A) member in one banded 16-bit encode (chroma subsampled at 16 bits, planes narrowed afterwards) |
| Deep matrix | the 16-bit planar tier ({Yuv,Yuva} × {420,422,444} P16Le + Yuv440P16Le) ↔ Rgb48Le / Rgba64Le at full 16-bit precision (Q30 k-coefficient construction, chroma resampled at 16 bits, Yuva alpha word verbatim); the 10/12-bit family stages in losslessly via the exact widen |
| Planar GBR | the full ten-member ladder — Gbrp8 / Gbrap8 (byte planes) through Gbrp10/12/14Le to the full-width Gbrp16Le / Gbrap16Le — ↔ both Rgb48Le and Rgba64Le (ladder widen / truncation; alpha synthesised opaque / dropped when the shapes differ) and ↔ Rgb24 / Rgba; plus Gray8 / Gray10Le / Gray12Le / Gray16Le interop (luminance projection out — at 16 bits for the deep words — and the rescaled broadcast in) and Gbrp8 ↔ Gbrap8 alpha append / drop |
| YUV planar ↔ RGB | Yuv420P / Yuv422P / Yuv444P ↔ Rgb24 / Rgba, plus the full-range YuvJ* families direct ↔ RGB; any width / height (chroma ceil(w/2) × ceil(h/2)); optional row-band threads via convert_with |
| Colour signalling | the frame's ColorSignal record / a ConvertContext override select range (full range on Yuva* and 10/12/16-bit layouts) and the H.273 matrix (1, 4, 5, 6, 7, 9; identity → planar GBR) |
| Bit-depth ladder | planar YUV 8 ↔ 10 ↔ 12 ↔ 16 bit (same layout, exact round-trips; Yuv*P16Le is full-scale 65535); Gray8 ↔ Gray10Le ↔ Gray12Le ↔ Gray16Le — see Depth-preserving routes for the widen / narrow rule |
| Chroma subsampling | 4:4:4 ↔ 4:2:2 ↔ 4:2:0 (SIMD-accelerated up- and down-sample); 4:4:0 (full-width, half-height chroma) ↔ every other siting via a vertical pair-average / row broadcast composed through 4:4:4 |
| 4:4:0 planar | Yuv440P / Yuv440P10Le / Yuv440P12Le / Yuv440P16Le are full members of the planar-family engine (direct to every planar YUV(A) member, Rgb24 / Rgba / Gray8, Yuv411P, and Yuv440P16Le ↔ Rgb48Le on the deep matrix); odd widths and heights legal |
| Scene-referred float | GrayF32Le / RgbF32Le / RgbaF32Le / GbrpF32Le / GbrapF32Le (binary32 LE, linear light, no full-scale): all-to-all inside the family with no clamping (speculars above 1.0, negative excursions and NaN payloads survive), direct ↔ 18 integer shapes (Gray8/10/12/16Le, Rgb24 / Rgba / Rgb48Le / Rgba64Le, the ten-member Gbrp*/Gbrap* ladder) by pure normalisation code / (2^bits − 1) in and saturating round-to-nearest out (NaN → 0; every integer code round-trips exactly; no transfer function is applied — use transfer for that), and direct ↔ the 16-bit planar YUV(A) tier through the deep matrix so 8/10/12-bit YUV stages in at full precision |
| Direct planar ↔ planar | Yuv420P / Yuv422P / Yuv444P all-to-all + same on YuvJ* and on the 16-bit Yuv*P16Le trio (no RGB hop) |
| Semi-planar | NV12 / NV21 ↔ Yuv420P / Rgb24 / Rgba |
| Packed 4:2:2 | Yuyv422 / Uyvy422 ↔ Yuv422P / Rgb24 / Rgba + Yuyv ↔ Uyvy swap |
| Full ↔ limited range | YuvJ420P / YuvJ422P / YuvJ444P ↔ Yuv* |
| Gray ↔ YUV | Gray8 ↔ every YUV family (luma extraction / neutral-chroma synthesis); Gray10Le / Gray12Le / Gray16Le ↔ every planar YUV(A) member through the luma plane only, range-moved at the deeper of the two depths (Gray16Le ↔ Yuv444P16Le: 0 ↔ 4096, 65535 ↔ 60160) |
| Gray ↔ RGB | Gray8 → all six packed RGB orders; Rgb24 / Rgba → Gray8 (luminance); Gray10Le / Gray12Le / Gray16Le ↔ Rgb48Le / Rgba64Le (bit-exact broadcast out, the 16-bit Y' row in — a 65536-code ramp round-trips exactly) |
| Grayscale / mono | Gray8 / Gray16Le, MonoBlack / MonoWhite ↔ Gray8 |
| Grey + alpha | Ya8 ↔ Gray8 / Rgb24 / Rgba (luma broadcast, alpha carried through); Ya16Le ↔ Ya8 (exact ×257 / high-byte), Gray16Le (luma word verbatim), Gray8, Rgba64Le (bit-exact broadcast + alpha carry) and Rgb24 / Rgba |
| CMYK | Cmyk and CmykInverted ↔ Rgb24 / Rgba (uncalibrated device formula, lossless RGB round-trip); Cmyk ↔ CmykInverted exact per-byte complement |
| YUV + alpha | the full 12-member Yuva family — Yuva420P / Yuva422P / Yuva444P plus the deep 10/12/16-bit members at all three chroma sitings — ↔ siblings / each other / Rgb24 / Rgba / Gray8; every ordered pair inside the family is direct; alpha bit-exact at same depth, MSB-widened / truncated across depths |
| Significant bits | source frames may carry the per-plane significant-bits side-channel (e.g. 12-bit luma + 10-bit chroma on a P16Le surface): convert() treats marked planes at their recorded depth, rejects invalid records with Error::Invalid, and never propagates a stale record |
| Palette | Pal8 ↔ Rgb24 / Rgba, nearest-colour quantisation with optional dither; frames carry their table in-band via the VideoFrame palette side-channel, and per-entry alpha via the palette-alpha record (Pal8 → Rgba* honours it: transparent entry → alpha 0, uncovered entries opaque; Pal8 → Rgb* / Gray8 drop it) |
| Colour matrices | BT.601 / BT.709 / BT.2020, limited (studio) / full (JPEG) range |
| Dither strategies | None, 8×8 ordered Bayer, Floyd–Steinberg |
| Alpha / compositing | Porter-Duff "over" (premul + straight), premul/unpremul, alpha-mask blit |
| Format introspection | FormatInfo::of(fmt) → planes / bit-depth / ChromaSubsampling typed view |
| Planar-family engine | a computed dispatch tier makes every ordered pair inside the uniform planar YUV(A) family ({Yuv,Yuva} × {420,422,444} × {8,10,12,16}, plus Yuv440P* at the four depths) a direct single-step conversion: depth move + chroma resample (at the deeper of the two depths) + alpha handling fused in one op |
| Staged fallback | pairs without a direct entry route through ONE fidelity-chosen pivot (deep YUV moves pivot through the 16-bit tier, alpha-carrying deep moves through Yuva*P16Le) — all 4830 ordered format pairs convert (1820 direct); supports() / supports_direct() / staged_pivot() report availability and the route; every staged route passes the depth / alpha / palette invariants below |
Roadmap
The pixel-format universe used by general-purpose video tooling runs
to roughly two hundred entries; this crate currently covers the 70
PixelFormat variants oxideav-core defines, and the conversion
matrix is fully closed: every one of the 70 × 69 = 4830 ordered
pairs resolves — 1820 directly, the rest through one staged pivot. The
formats below are planned — they're not implemented yet, but they
have real callers in the codecs/containers we want to support, so the
PixelFormat
variants and convert() paths will land over time.
Tier 1 — short-term targets:
| family | additions |
|---|---|
| 16-bit packed RGB | Rgb565Le/Be, Rgb555Le/Be, Rgb444Le/Be (+ BGR mirrors) |
| Padded 4-byte packed RGB | 0Rgb, Rgb0, 0Bgr, Bgr0 (no-alpha 32-bit, alignment-friendly) |
| GBR planar | shipped in full — the ten-member ladder (Gbrp8 / Gbrap8 through Gbrap16Le) with alpha-crossing deep-packed hops and Gray8 interop (see table above) |
| Legacy planar YUV | Yuv440P shipped at 8/10/12/16 bits (see table above); Yuv410P and the YuvJ440P full-range mirror still pending a core variant |
| 4:2:2 / 4:4:4 NV | Nv16, Nv24 — common on Android / embedded |
| Alpha-bearing YUV | shipped in full — the 8-bit trio and the deep 10/12/16-bit members at all three chroma sitings, Yuva420P* included (see table above) |
Tier 2 — mid-term:
| family | additions |
|---|---|
| Big-endian mirrors | Rgb48Be, Rgba64Be, Gray16Be, Yuv420P10Be, … of every *Le we ship |
| Higher-precision YUV | Yuv420P9/14Le, same for 422 / 444 (the 8 ↔ 10 ↔ 12 ↔ 16 ladder, 16-bit chroma resample, direct high-bit ↔ RGB / Gray8 interop AND the full-precision 16-bit deep matrix shipped — see table above) |
| 10/12/16-bit semi-pl. | P010Le, P012Le, P016Le — HEVC Main10, Dolby Vision |
| DCI / cinema | Xyz12Le |
| 8-bit low-bpp packed | Rgb8 (3-3-2), Rgb4, Bgr4Byte |
Out of scope (no plans):
- Hardware-opaque surfaces (
cuda,vaapi,vdpau,qsv,videotoolbox,vulkan,drm_prime,mediacodec, …) — these are zero-copy GPU descriptors, not something a CPU pixfmt layer would convert. The framework will surface them at the codec/IO boundary instead, leaving the GPU contents untouched. - Bayer mosaic patterns (
bayer_*) — a RAW-camera concern, outside this crate's video-pipeline scope. - Niche packed YUV (
Ayuv64Le,Vuya,Vuyx) — open to PRs if a consumer needs them.
Low-level API — work on your own buffers
Every hot path is exposed as a function over &[u8] / &mut [u8]. The
buffer layout is always tightly packed (no stride padding) — strip and
re-apply stride yourself if your frames carry one.
YUV420P → Rgb24 (BT.709 limited range)
use ;
// Your decoded YUV planes. Y is full resolution, U/V are each
// ceil(w/2) × ceil(h/2) (odd sizes are fine).
let = ;
let = ;
let y_plane: = /* w * h bytes */ vec!;
let u_plane: = /* cw * ch bytes */ vec!;
let v_plane: = /* cw * ch bytes */ vec!;
let mut rgb = vec!;
yuv420_to_rgb24;
YuvMatrix::BT601 / YuvMatrix::BT709 default to limited (studio)
range. Call .with_range(false) to pick full-range (JPEG-style) coefficients,
or YuvMatrix::from_color_space(ColorSpace::Bt601Full) if you already
have a ColorSpace value.
RGB swizzle (RGBA ↔ BGRA / ARGB / ABGR, RGB ↔ BGR)
use ;
let src: = /* w * h * 4 bytes of RGBA */ vec!;
let mut dst = vec!;
swizzle4;
RGBA_POS, BGRA_POS, ARGB_POS, ABGR_POS describe where each
component sits in a 4-byte packed pixel; swizzle4 emits the permuted
output in a single AVX2 pshufb per 8 pixels. For 3-byte packings the
mirrored function swizzle3 takes RGB_POS / BGR_POS. Alpha-promote
and alpha-drop variants are rgb3_to_rgba4 and rgba4_to_rgb3.
NV12 → YUV420P (split the interleaved UV plane)
use nv12_uv_split;
// NV12: one Y plane + one interleaved UV plane (cw * ch pixels, 2 bytes each).
let = ;
let uv: = vec!;
let mut u_plane = vec!;
let mut v_plane = vec!;
nv12_uv_split;
nv21_vu_split, nv12_uv_merge, and nv21_vu_merge cover the other
directions.
Palette quantisation (animated-GIF / APNG style)
use quantise_rgb24_to_pal8;
use ;
#
// Build a palette from one or more Rgb24 / Rgba reference frames.
// `generate_palette` takes &[(&VideoFrame, FrameInfo)] (format and size
// live in FrameInfo, not on the frame); see `palette::Palette` if you
// want to construct one from a raw colour list instead.
let frames: = collect_reference_frames;
let palette = generate_palette.expect;
// Quantise a tightly-packed RGB24 buffer against the palette.
let = ;
let rgb24: = vec!;
let mut indices = vec!;
quantise_rgb24_to_pal8;
Decode back with pal8::expand_row_to_rgb24 or expand_row_to_rgba,
which take a row of palette indices plus the Palette and emit the
corresponding RGB scanline.
Alpha-blending and compositing
Porter-Duff "over" primitives — the small bricks font renderers, subtitle compositors and overlay pipelines build on top of:
use ;
// Per-pixel composite (premultiplied or straight):
let out = over_premul; // semi-red over blue
let out = over_straight; // straight-alpha equivalent
// Premultiply / unpremultiply roundtrip (lossless at A=255, lossy at low A):
let p = premultiply;
let s = unpremultiply;
// Modulate the alpha channel by an opacity value:
let dim = modulate_alpha; // 50% opacity
// Blit a coloured glyph mask onto an RGBA framebuffer with edge-clipping:
let = ;
let mut canvas = vec!;
let glyph: = /* mw * mh u8 alpha */ vec!;
blit_alpha_mask;
// Bulk over-composite two same-size buffers:
let mut dst = vec!;
let src = vec!;
over_buffer;
All u8 × u8 math goes through a bit-exact rounded (a × b + 128) / 256
shift trick — no division on the hot path, no third-party deps.
Scene-referred float samples
use ;
// 12-bit codes → linear-light floats (pure normalisation, no transfer).
let codes: = .flat_map.collect;
let mut lin = vec!;
plane_le16_to_f32le;
assert_eq!;
// Back to integer: saturating, round-to-nearest, NaN → 0.
assert_eq!;
assert_eq!;
High-level API — VideoFrame in, VideoFrame out
If you're already using oxideav-core, the one-line form handles every
conversion through the same dispatch table:
use ;
use ;
let src = VideoFrame ;
let src_info = new;
let dst = convert.expect;
convert_in_place_if_same(src, src_info, dst_format) is a zero-copy passthrough
you can call first to skip convert() when the source already matches.
When no direct (src, dst) table entry exists, convert() resolves
the pair through one staged pivot, chosen in a fidelity-aware
order: YUV pivots for YUV → YUV moves (the route never touches a
colour matrix — e.g. Yuyv422 → Yuv420P keeps luma byte-exact), RGB
pivots otherwise with alpha-capable before alpha-less (Yuva420P → Bgra carries alpha bit-exact) and deep before 8-bit when either
endpoint is deeper than 8 bits (Gbrp10Le → Gbrp12Le round-trips
exactly through Rgb48Le; deep YUV → YUV moves pivot through the
16-bit Yuv*P16Le tier so Yuv420P16Le → Yuv422P10Le keeps the top
10 bits of every sample). Staged routes are exactly as correct as
their two legs but can round twice; supports(src, dst) and
supports_direct(src, dst) let callers tell the two cases apart.
Colour science
The matrix is selected at runtime via ConvertOptions::color_space (for
convert()) or YuvMatrix (for the low-level entry points):
| variant | primaries | range | use case |
|---|---|---|---|
Bt601Limited |
BT.601 | 16–235 | SD video, MPEG/H.264 default |
Bt601Full |
BT.601 | 0–255 | JPEG with YCbCr SOF |
Bt709Limited |
BT.709 | 16–235 | HD video |
Bt709Full |
BT.709 | 0–255 | full-range HD, certain codecs |
Bt2020Limited |
BT.2020 | 16–235 | UHDTV, HDR carriage (NCL) |
Bt2020Full |
BT.2020 | 0–255 | full-range UHDTV |
BT.2020 uses the non-constant-luminance Y'CbCr coefficients from
ITU-R BT.2020-2 Table 4 (kr = 0.2627, kb = 0.0593); the same matrix
is reused for the BT.2100-3 Table 6 HDR signal format.
The Q15 fixed-point matrices are verified three ways in the test
suite: against an independent f64 model built straight from the
k-coefficient construction (±1 code, encode and decode, all six
variants), against pinned spec-derived primary anchor codes (BT.601
limited red = (81, 90, 240), BT.709 limited red = (63, 102, 240), …),
and — on machines that have one — against the ffmpeg binary invoked
as a black-box CLI validator (±2 on matrixed paths, bit-exact on pure
repack paths).
The 16-bit planar tier additionally carries a full-precision deep
matrix: the same k-coefficient construction evaluated in Q30 over
i64 directly on 16-bit samples, used by the Yuv(a)*P16Le ↔
Rgb48Le / Rgba64Le rows. Limited-range offsets follow the n-bit
digital representation of the BT-series specs (offsets and spans
scale by 2^(n−8); at n = 16 luma is 4096 + [0, 56064] and chroma is
centred on the exact achromatic code 32768), so full-scale white
encodes to Y = 60160 exactly and r = g = b content pins chroma to
32768. The Q30 kernels are verified against an independent f64 model
to ±1 LSB at 16-bit scale across all six matrix variants.
Colour signalling, range and threads — convert_with
convert() takes the range from the pixel format (YuvJ* full, every
other YUV layout limited) and the matrix weights from
ConvertOptions::color_space. convert_with(src, info, dst, &opts, &ctx) adds a ConvertContext (#[non_exhaustive], builder methods —
so ConvertOptions keeps its struct-literal shape and new knobs never
break callers):
- Colour signal. The source frame's
VideoFrame::color_signal()record (oxideav-core 0.1.37, read for YUV sources only) and theConvertContext::signaloverride (with_range/with_matrix/with_signal) take precedence over the format label andcolor_space, field by field (override, then frame, then label). H.273 matrices 1 (BT.709), 4 (FCC), 5 / 6 (BT.601), 7 (SMPTE ST 240) and 9 (BT.2020 NCL) are applied through the same Q15 / Q30 kernels; 0 (identity) routes the planes as G, B, R through the planar GBR family (limited range scaled like luma, per H.273 equations 27–29); other code points reject YUV ↔ RGB hops withError::Unsupported. This is how a full-rangeYuva420PorYuv420P10LeHEIF payload (noYuvJ*label exists for those) is decoded correctly. An unsignalled frame converts exactly as before. - Threads.
ConvertContext::executionis anoxideav_core::ExecutionContext; the default is serial (the core threading contract),with_threads(n)/ExecutionContext::auto()lets the row-band engine run bands of whole chroma rows on scoped threads. Output is byte-identical at every budget.
use ;
use ;
# let frame = VideoFrame ;
# let info = new;
let ctx = new
.with_range // e.g. from the nclx box
.with_matrix
.with_execution;
let rgba = convert_with?;
# Ok::
Odd dimensions
Every subsampled planar / semi-planar layout (4:2:0, 4:2:2, 4:4:0 at
every depth, the Yuva* family, NV12 / NV21, YuvJ*) accepts any
width and height in both directions. Chroma planes hold
ceil(w / wsub) × ceil(h / hsub) samples — the component-dimension
rule of ITU-T T.81 A.1.1 and oxideav-core's
PixelFormat::plane_dimensions. Decoding maps luma (x, y) to chroma
(x / wsub, y / hsub), so a trailing odd column / row reuses the last
chroma sample; encoding and chroma downsampling average each block with
the missing positions replicating the last column / row. The staged
signal texts (H.273 §8.7, the BT-series 4:2:0 definitions) place chroma
samples for pictures whose luma dimensions are multiples of the
subsampling and say nothing about a trailing partial block, so edge
replication is this crate's documented choice. Yuv411P still needs a
multiple-of-4 width and packed Yuyv422 / Uyvy422 an even width; a
plane shorter than its geometry is Error::Invalid, never a panic.
Range rescaling between YuvJ* (full) and Yuv* (limited) planes is
exposed both through convert() and directly as
yuv::{limited_to_full_luma, limited_to_full_chroma, full_to_limited_luma, full_to_limited_chroma}
so callers can flip range without going through RGB.
Depth-preserving routes
Three rulings govern every route the planner picks; tests/route_depth.rs
checks them over all 4830 ordered pairs and asserts zero violations.
Route-depth invariant. When both endpoints carry more than 8 bits
per channel (10 / 12 / 14 / 16-bit integer or binary32 float), every
intermediate format on the route carries at least
min(depth(src), depth(dst)) bits per channel; when both endpoints
carry alpha, no intermediate drops it; no route pivots through Pal8;
float ↔ float never pivots through an integer format. staged_pivot(src, dst) reports the pivot a staged pair takes (None for direct pairs).
Before round 476 the deep grey words, Ya16Le and the planar GBR ladder
reached the 10 / 12-bit YUV family through Gray8 / Rgb24 / Rgba —
320 pairs silently lost 8 bits mid-route; all of them are direct now.
Matrix selection at depth. Rgb48Le / Rgba64Le → Gray10Le /
Gray12Le / Gray16Le evaluate the Y' row of the selected primaries
(BT.601 / BT.709 / BT.2020 through ConvertOptions::color_space, the
same rule the 8-bit Rgb24 → Gray8 row follows: the range half of the
variant is ignored because the grey words are a full-range space) in
Q30 at 16-bit scale, rounded once, then narrowed to the target width.
The result is within 1 LSB of the f64 projection (pinned over a
4096-sample structured sweep and 10^6 random samples) and exact for
r = g = b. Grey ↔ YUV never touches a matrix: the luma plane moves
alone, the chroma planes are the exact neutral mid-code
1 << (bits − 1) and alpha is opaque full-scale.
Rescale convention. Moving a sample between widths widens by MSB
replication — v << d | v >> (bits − d), i.e. ×257 from 8 to 16 — so
zero maps to zero and peak to peak, and narrows by truncation (drop the
low bits), which is the exact inverse: widen → narrow and narrow → widen
→ narrow round-trips are lossless at every rung of the ladder (8 / 10 /
12 / 14 / 16). A route that both rescales and range-moves (grey ↔ YUV
across depths) widens first, applies the range move at the deeper of
the two depths (H.273 equations 27–29 scaled by 2^(n−8): limited luma
is 16·2^(n−8) .. 235·2^(n−8)), then narrows — so no source code is
merged before the rescale. A full-range colour signal (the frame's
record or a ConvertContext override) turns the range move off and
the luma move becomes the bare ladder move (Gray16Le ↔ Yuv*P16Le is a
verbatim copy). The 8-bit grey ↔ 8-bit YUV rows keep their Q15 kernels.
Transfer functions
The transfer module exposes opto-electronic and electro-optical
transfer functions on f32. They are orthogonal to the YUV/RGB matrix
above — pair them as the HDR / SDR pipeline requires:
| function | spec source |
|---|---|
bt709_oetf (+ inverse) |
ITU-R BT.2020-2 Table 4 (10-bit α, β) |
bt2020_12_oetf |
ITU-R BT.2020-2 Table 4 (12-bit α, β) |
bt1886_eotf (+ inverse) |
ITU-R BT.1886 Annex 1, γ = 2.40 |
pq_eotf / pq_inverse_eotf |
SMPTE ST 2084 / ITU-R BT.2100-3 Table 4 |
hlg_oetf (+ inverse) |
ITU-R BT.2100-3 Table 5 |
hlg_apply_ootf |
BT.2100-3 Table 5 HLG OOTF row |
hlg_system_gamma(l_w) |
BT.2100-3 Note 5f |
PQ peak luminance is exposed as PQ_PEAK_CDM2 = 10_000.0 (signal
value 1.0 maps to that luminance per BT.2100-3 Table 4). The HLG
inverse OETF returns relative scene light; pass it through
hlg_apply_ootf with the user gain α and system gamma γ to obtain
display-light values.
Performance
Every converter has a scalar Q15 fixed-point reference. The YUV ↔ RGB
SIMD paths (AVX2 and NEON, both directions) evaluate the same integer
arithmetic and are byte-identical to it — tests/engine_identity.rs
pins every convert() output byte of the YUV(A) ↔ RGB(A) rows to a
per-pixel oracle. Dispatch is lazy and cached on first call per
process.
1920×1080, single Intel i9-14900K core, AVX2 path:
| operation | scalar | AVX2 (this crate) |
|---|---|---|
yuv420_to_rgb24 |
3.14 ms | 720 µs (8.0 GiB/s) |
yuv444_to_rgb24 |
1.24 ms (720p) | 296 µs (8.7 GiB/s, 720p) |
rgb24_to_yuv420 |
2.20 ms (720p) | 547 µs (5.7 GiB/s, 720p) |
rgb24_to_yuv422 |
— | 5.9 GiB/s (720p) |
swizzle4 (RGBA ↔ BGRA) |
3.5 GiB/s | 29 GiB/s |
rgb3_to_rgba4 |
4.2 GiB/s | 33.6 GiB/s |
chroma_420_to_444 |
2.1 GiB/s | 48.8 GiB/s |
chroma_422_to_444 |
2.1 GiB/s | 42.8 GiB/s |
gray8_to_rgba |
7.0 GiB/s | 43.7 GiB/s |
rgb48_to_rgb24 |
4.5 GiB/s | 14.8 GiB/s |
nv12_uv_split |
— | 32.3 GiB/s |
The YUV decode path processes 16 pixels per AVX2 iteration; encode runs
a fused 2-row luma + 2×2-chroma loop that does one pshufb deinterleave
per 8 pixels and pair-sums the chroma via pmaddubsw.
12-megapixel still (4032×3024) through the high-level convert(),
Apple M4 Max, NEON path (cargo bench --features bench --bench heif_12mp). Each row is the whole call — plane access, kernel, output
allocation — i.e. what an image pipeline pays per decoded HEIF picture.
"r462" is the pre-round baseline; "serial" is convert() (the default,
one thread); "threaded" is convert_with with
ExecutionContext::auto() (row bands on scoped threads, same bytes).
| conversion | r462 baseline | serial | threaded |
|---|---|---|---|
Yuv420P → Rgb24 |
4.48 ms | 4.01 ms | 0.73 ms |
YuvJ420P → Rgb24 |
4.47 ms | 4.05 ms | 0.76 ms |
Yuv420P → Rgba |
15.2 ms | 6.26 ms | 1.07 ms |
Yuv420P10Le → Rgb24 |
7.04 ms | 6.17 ms | 1.39 ms |
Yuv420P10Le → Rgb48Le |
34.9 ms | 25.3 ms | 3.27 ms |
Yuva420P → Rgba |
17.4 ms | 4.95 ms | 0.96 ms |
Yuv444P → Rgb24 |
4.03 ms | 3.39 ms | 0.60 ms |
Rgb24 → Yuv420P |
8.12 ms | 3.87 ms | 0.58 ms |
Gray8 → Rgb24 |
0.57 ms | 0.56 ms | — |
Yuv420P → Gray8 |
1.39 ms | 1.34 ms | — |
Rgb24 → Gray8 |
2.63 ms | 2.54 ms | — |
The Gray rows are single-pass memory copies / projections and are not banded. The wins come from the row-band engine (tight planes borrowed instead of copied, RGBA interleaved in the decode pass, RGBA input split a few rows at a time), the exact NEON encoder, and the one-pass deep matrix for 10/12-bit sources.
Depth-preserving routes, 12-megapixel still (4032×3024), Apple M4
Max (cargo bench --features bench --bench deep_gray_12mp). "before"
is the route the planner took before round 476 — two convert calls
staged through Gray8 (or Yuv444P16Le for the last row) — measured
in the same binary; "serial" / "threaded" are the direct rows.
| conversion | before (staged) | serial | threaded |
|---|---|---|---|
Gray16Le → Rgb48Le |
2.54 ms | 1.05 ms | 0.70 ms |
Rgb48Le → Gray16Le |
19.0 ms | 13.7 ms | 1.84 ms |
Yuv444P16Le → Gray16Le |
3.18 ms | 3.30 ms | 0.61 ms |
Yuv420P16Le → Gray16Le |
3.25 ms | 3.56 ms | 0.76 ms |
Gray16Le → Yuv444P16Le |
3.87 ms | 4.67 ms | 1.71 ms |
Gray16Le → Yuv420P10Le |
3.29 ms | 3.87 ms | 0.80 ms |
Rgb48Le → Yuv420P16Le |
— | 25.1 ms | 2.94 ms |
Rgb48Le → Yuv420P10Le |
29.4 ms | 27.8 ms | 5.78 ms |
Gbrp16Le → Gray16Le |
38.9 ms | 10.4 ms | 1.78 ms |
Gray16Le → Gbrp16Le |
16.0 ms | 0.99 ms | 0.98 ms |
Gbrp10Le → Yuv420P10Le |
49.9 ms (via Rgba64Le) |
47.9 ms (via Rgb48Le) |
8.96 ms |
The "before" rows were 8-bit routes (cheap because they threw the low
byte away before the range move); the direct rows keep all 16 bits —
the luma move is one composed table lookup per sample, the packed rows
run the Q30 matrix — on the row-band engine. The 8-bit headline rows
above were re-measured in the same session and are unchanged. The
Gbrp10Le → Yuv420P10Le row is still staged (two banded legs through
Rgb48Le); a fused GBR → YUV encode is the next step for that family.
Porter-Duff compositing (scalar, Apple M-series single core, indicative):
| operation | scalar throughput |
|---|---|
over_premul (per-pixel, 1 Mpx) |
~3.5 GiB/s |
over_straight (per-pixel, 1 Mpx) |
~2.1 GiB/s |
over_buffer premul (1920×1080) |
~6.3 GiB/s |
over_buffer straight (1920×1080) |
~2.1 GiB/s |
blit_alpha_mask 16×16 glyph |
~276 MiB/s |
blit_alpha_mask 64×64 glyph |
~282 MiB/s |
premultiply (1 Mpx) |
~5.6 GiB/s |
unpremultiply (1 Mpx, includes divide) |
~4.7 GiB/s |
modulate_alpha (1 Mpx) |
~6.3 GiB/s |
The compositing primitives are still scalar (no SIMD path yet); the
numbers above are the headroom a future hand-vectorised pass would
land against. over_straight is the floor because each pixel pays for
the divide-by-out.a rebuild.
Dispatch summary
| target | path |
|---|---|
| x86_64 + AVX2 | AVX2 intrinsics (pshufb, pmaddubsw, vpermq, …) |
| aarch64 + NEON | NEON decode (vst3_u8) and encode (vld3q_u8, exact 32-bit lanes) |
| nightly + | std::simd path via the nightly feature (portable 8-wide) |
| everything | scalar fixed-point — golden reference used by the SIMD tests |
Runtime controls
These env vars are consulted once per process (before the first call), then cached:
OXIDEAV_PIXFMT_FORCE_SCALAR=1— pin every path to scalar. Useful for benchmark baselines and correctness debugging.OXIDEAV_PIXFMT_FORCE_PORTABLE_SIMD=1— with--features nightly, pickstd::simdover the hand-written intrinsics.
Benchmarks
Criterion is an optional dependency behind the bench feature (kept off
the default graph so the test binaries don't link its alloca native
dep), so the benches need --features bench:
OXIDEAV_PIXFMT_FORCE_SCALAR=1
Indicative depth_gray single-core numbers (Apple M-series, scalar):
8 → 10-bit plane widen 84 GiB/s, 10 → 8-bit narrow 48 GiB/s,
10 ↔ 12-bit rescale 11.7 / 34.4 GiB/s, rgb24_to_gray8 17.3 GiB/s.
Portable-SIMD numbers (nightly only):
OXIDEAV_PIXFMT_FORCE_PORTABLE_SIMD=1
Fuzzing
A cargo-fuzz harness lives
under fuzz/ and runs daily in CI. The convert_geometry
target does not feed arbitrary bytes at a parser — there is none. It
instead constructs a structurally-valid source frame from the fuzzer's
input (a source pixel format — all 70 enum variants are buildable,
small / odd dimensions, extra stride padding, and optional hostile
side-channel records: fuzz-length palettes on Pal8 and
significant-bits records with zero bits, above-nominal values, and
wrong lengths) and drives every
(src, dst) conversion, direct and staged alike, asserting
that none panics, integer-overflows, reads out of bounds, or aborts. A
converter may legitimately return Err for geometry it cannot represent
(e.g. a chroma plane one sample short of its rounded-up grid, a 4:1:1
width that is not a multiple of 4, an unimplemented signalled matrix)
or for an invalid record; only a
crash is a finding. This
target's first run caught an out-of-bounds chroma read on subsampled
YUV → RGB at odd dimensions. Subsampled sources are built on either the
legal rounded-up chroma grid (odd sizes must convert) or a truncated
one (must be rejected), and the sweep also drives convert_with with
fuzz-chosen thread budgets and colour-signal overrides (range, and
matrix code points including identity and reserved values).
License
MIT — see LICENSE.