oxideav-pixfmt 0.1.11

Pure-Rust pixel-format conversions, palette quantisation, and dithering — the oxideav framework's shared pixfmt layer
Documentation

oxideav-pixfmt

CI crates.io docs.rs License: MIT

Pure-Rust pixel-format conversions, palette quantisation, and dithering.

Two APIs in one crate:

  • Low-level, framework-agnostic — functions in yuv, rgb, gray, and pal8 operate 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 a VideoFrame from oxideav-core and 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

[dependencies]
oxideav-pixfmt = "0.1"

Nightly users who want the std::simd path:

oxideav-pixfmt = { version = "0.1", features = ["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 oxideav_pixfmt::yuv::{yuv420_to_rgb24, YuvMatrix};

// Your decoded YUV planes. Y is full resolution, U/V are each
// ceil(w/2) × ceil(h/2) (odd sizes are fine).
let (w, h) = (1920usize, 1080usize);
let (cw, ch) = (w.div_ceil(2), h.div_ceil(2));
let y_plane: Vec<u8> = /* w * h bytes */ vec![0; w * h];
let u_plane: Vec<u8> = /* cw * ch bytes */ vec![128; cw * ch];
let v_plane: Vec<u8> = /* cw * ch bytes */ vec![128; cw * ch];

let mut rgb = vec![0u8; w * h * 3];
yuv420_to_rgb24(&y_plane, &u_plane, &v_plane, &mut rgb, w, h, YuvMatrix::BT709);

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 oxideav_pixfmt::rgb::{swizzle4, BGRA_POS, RGBA_POS};

let src: Vec<u8> = /* w * h * 4 bytes of RGBA */ vec![0; 1920 * 1080 * 4];
let mut dst = vec![0u8; src.len()];
swizzle4(&src, RGBA_POS, &mut dst, BGRA_POS, 1920 * 1080);

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 oxideav_pixfmt::yuv::nv12_uv_split;

// NV12: one Y plane + one interleaved UV plane (cw * ch pixels, 2 bytes each).
let (cw, ch) = (960, 540);
let uv: Vec<u8> = vec![0; cw * ch * 2];

let mut u_plane = vec![0u8; cw * ch];
let mut v_plane = vec![0u8; cw * ch];
nv12_uv_split(&uv, &mut u_plane, &mut v_plane, cw, ch);

nv21_vu_split, nv12_uv_merge, and nv21_vu_merge cover the other directions.

Palette quantisation (animated-GIF / APNG style)

use oxideav_pixfmt::pal8::quantise_rgb24_to_pal8;
use oxideav_pixfmt::{generate_palette, Dither, FrameInfo, PaletteGenOptions, PaletteStrategy};
# fn collect_reference_frames() -> Vec<(&'static oxideav_core::VideoFrame, FrameInfo)> { Vec::new() }

// 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: Vec<(&oxideav_core::VideoFrame, FrameInfo)> = collect_reference_frames();
let palette = generate_palette(
    &frames,
    &PaletteGenOptions {
        max_colors: 255,                     // u8 — 1..=255
        strategy: PaletteStrategy::MedianCut,
        transparency: None,
    },
).expect("palette generation");

// Quantise a tightly-packed RGB24 buffer against the palette.
let (w, h) = (320, 240);
let rgb24: Vec<u8> = vec![0; w * h * 3];
let mut indices = vec![0u8; w * h];
quantise_rgb24_to_pal8(&rgb24, &mut indices, w, h, &palette, Dither::FloydSteinberg);

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 oxideav_pixfmt::{
    blit_alpha_mask, modulate_alpha, over_buffer, over_premul,
    over_straight, premultiply, unpremultiply,
};

// Per-pixel composite (premultiplied or straight):
let out = over_premul([128, 0, 0, 128], [0, 0, 255, 255]);   // semi-red over blue
let out = over_straight([255, 0, 0, 128], [0, 0, 255, 255]); // straight-alpha equivalent

// Premultiply / unpremultiply roundtrip (lossless at A=255, lossy at low A):
let p = premultiply([200, 100, 50, 128]);
let s = unpremultiply(p);

// Modulate the alpha channel by an opacity value:
let dim = modulate_alpha([200, 100, 50, 255], 128); // 50% opacity

// Blit a coloured glyph mask onto an RGBA framebuffer with edge-clipping:
let (w, h) = (320, 240);
let mut canvas = vec![0u8; w * h * 4];
let glyph: Vec<u8> = /* mw * mh u8 alpha */ vec![255; 8 * 8];
blit_alpha_mask(
    &mut canvas, w as u32, h as u32, w * 4,
    /* x = */ 16, /* y = */ 24,
    &glyph, 8, 8, 8,
    [255, 255, 255, 255], // colour the mask in white, fully opaque
);

// Bulk over-composite two same-size buffers:
let mut dst = vec![0u8; w * h * 4];
let src = vec![0u8; w * h * 4];
over_buffer(&mut dst, &src, w as u32, h as u32, w * 4, /* premultiplied = */ true);

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 oxideav_pixfmt::float::{f32_to_unorm, plane_le16_to_f32le, unorm_to_f32};

// 12-bit codes → linear-light floats (pure normalisation, no transfer).
let codes: Vec<u8> = (0..4096u16).flat_map(|c| c.to_le_bytes()).collect();
let mut lin = vec![0u8; 4096 * 4];
plane_le16_to_f32le(&codes, &mut lin, 4096, 12);
assert_eq!(unorm_to_f32(4095, 12), 1.0);
// Back to integer: saturating, round-to-nearest, NaN → 0.
assert_eq!(f32_to_unorm(1.7, 8), 255);
assert_eq!(f32_to_unorm(f32::NAN, 16), 0);

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 oxideav_core::{PixelFormat, VideoFrame, VideoPlane};
use oxideav_pixfmt::{convert, ColorSpace, ConvertOptions, FrameInfo};

let src = VideoFrame {
    pts: None,
    planes: vec![
        VideoPlane { stride: 1920, data: vec![16; 1920 * 1080] },
        VideoPlane { stride: 960,  data: vec![128; 960 * 540]   },
        VideoPlane { stride: 960,  data: vec![128; 960 * 540]   },
    ],
};
let src_info = FrameInfo::new(PixelFormat::Yuv420P, 1920, 1080);

let dst = convert(
    &src,
    src_info,
    PixelFormat::Rgb24,
    &ConvertOptions {
        color_space: ColorSpace::Bt709Limited,
        ..Default::default()
    },
).expect("convert");

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 the ConvertContext::signal override (with_range / with_matrix / with_signal) take precedence over the format label and color_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 with Error::Unsupported. This is how a full-range Yuva420P or Yuv420P10Le HEIF payload (no YuvJ* label exists for those) is decoded correctly. An unsignalled frame converts exactly as before.
  • Threads. ConvertContext::execution is an oxideav_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 oxideav_core::{ColorRange, ExecutionContext, MatrixCoefficients, PixelFormat};
use oxideav_pixfmt::{convert_with, ConvertContext, ConvertOptions, FrameInfo};
# let frame = oxideav_core::VideoFrame { pts: None, planes: vec![] };
# let info = FrameInfo::new(PixelFormat::Yuv420P, 1920, 1080);

let ctx = ConvertContext::new()
    .with_range(ColorRange::Full)              // e.g. from the nclx box
    .with_matrix(MatrixCoefficients::BT709)
    .with_execution(ExecutionContext::auto());
let rgba = convert_with(&frame, info, PixelFormat::Rgba, &ConvertOptions::default(), &ctx)?;
# Ok::<(), Box<dyn std::error::Error>>(())

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, pick std::simd over 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:

cargo bench --features bench                              # all suites
cargo bench --features bench --bench yuv_rgb             # just YUV encode/decode
cargo bench --features bench --bench pixel_ops          # RGB swizzle, NV12, chroma resample, gray, deep-RGB
cargo bench --features bench --bench alpha              # Porter-Duff over/blit/premultiply
cargo bench --features bench --bench depth_gray        # bit-depth ladder + RGB→Gray8 projection
cargo bench --features bench --bench heif_12mp         # 12 MP still: the production-HEIF conversion set through convert()
cargo bench --features bench --bench deep_gray_12mp    # 12 MP still: the depth-preserving grey / packed-16 / 16-bit YUV routes, before and after
OXIDEAV_PIXFMT_FORCE_SCALAR=1 cargo bench --features bench  # scalar baseline for comparison

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):

cargo +nightly bench --features bench,nightly
OXIDEAV_PIXFMT_FORCE_PORTABLE_SIMD=1 cargo +nightly bench --features bench,nightly

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).

cargo +nightly fuzz run convert_geometry

License

MIT — see LICENSE.