zenpixels 0.2.14

Pixel format interchange types for zen* codecs
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
# zenpixels [![CI](https://img.shields.io/github/actions/workflow/status/imazen/zenpixels/ci.yml?style=flat-square)](https://github.com/imazen/zenpixels/actions/workflows/ci.yml) [![crates.io](https://img.shields.io/crates/v/zenpixels?style=flat-square)](https://crates.io/crates/zenpixels) [![lib.rs](https://img.shields.io/crates/v/zenpixels?style=flat-square&label=lib.rs&color=blue)](https://lib.rs/crates/zenpixels) [![docs.rs](https://img.shields.io/docsrs/zenpixels?style=flat-square)](https://docs.rs/zenpixels) [![license](https://img.shields.io/crates/l/zenpixels?style=flat-square)](https://github.com/imazen/zenpixels#license)

Pixel format types and transfer-function-aware conversion for Rust image codecs.

A JPEG decoder gives you `RGB8` in sRGB. An AVIF decoder gives you `RGBA16` in BT.2020 PQ. A resize library wants `RGBF32` in linear light. Without shared types, every codec pair needs hand-rolled conversion — and gets transfer functions wrong, silently drops alpha, or writes "sRGB" in the ICC profile while the pixels are linear.

zenpixels makes pixel format descriptions first-class types that travel with the data. The conversion crate handles transfer functions, gamut matrices, depth scaling, and alpha compositing so codecs don't have to.

Two crates: **zenpixels** (types, buffers, metadata) and **zenpixels-convert** (all the math). Both are `no_std + alloc`, `forbid(unsafe_code)`, no system dependencies.

```toml
# Types only — for codec crates
zenpixels = "0.2.14"

# Types + conversion — for processing pipelines
zenpixels-convert = "0.2.14"
```

## Quick start

`zenpixels` gives you a [`PixelBuffer`] that carries its [`PixelDescriptor`]
(format + color semantics) so the bytes never travel without a description of
what they mean. Allocate one, hand the rows to a codec, recover the `Vec` when
done. (`Rgba<u8>` comes from the [`rgb`](https://crates.io/crates/rgb) crate
and needs the `rgb` feature; the descriptor-based path below works without it.)

```rust
use zenpixels::{PixelBuffer, PixelDescriptor};
use rgb::Rgba;

// Typed buffer — format enforced at compile time, SIMD-aligned rows.
// A typed buffer knows its byte layout but leaves color semantics
// Unknown: the `rgb` crate's `Rgba<u8>` says nothing about sRGB vs
// linear, so stamp the encoding when you know it. `with_descriptor`
// keeps the layout and only changes the transfer/primaries.
let pixels = vec![Rgba::new(255u8, 128, 0, 255); 64 * 48];
let buf = PixelBuffer::<Rgba<u8>>::from_pixels(pixels, 64, 48)?
    .with_descriptor(PixelDescriptor::RGBA8_SRGB);

// Row and byte access live on the view; `as_slice()` is the entry point.
// `row(y)` is unpadded; `as_strided_bytes()` is the whole backing buffer
// (stride included) — zero-copy passthrough to a codec or GPU upload.
let view = buf.as_slice();
let first_row: &[u8] = view.row(0);
let strided: &[u8]   = view.as_strided_bytes();

// Recover the allocation for pool reuse.
let raw: Vec<u8> = buf.into_vec();
# Ok::<(), zenpixels::At<zenpixels::BufferError>>(())
```

Don't know the format at compile time? Build a [`PixelDescriptor`] from a
[`PixelFormat`] plus the color semantics, then use a type-erased
`PixelBuffer`:

```rust
use zenpixels::{PixelBuffer, PixelDescriptor, PixelFormat, TransferFunction, ColorPrimaries};

let desc = PixelDescriptor::RGB8_SRGB
    .with_transfer(TransferFunction::Linear)   // same bytes, linear light
    .with_primaries(ColorPrimaries::DisplayP3);

let buf = PixelBuffer::new(64, 48, desc);      // panics on OOM; try_new for fallible
assert_eq!(buf.descriptor().format, PixelFormat::Rgb8);
```

### Wrapping a decoder's `Vec<u8>` (no copy)

`PixelBuffer::new` *allocates*. A codec that already decoded into its own
`Vec<u8>` wants the opposite: take ownership of that allocation and stamp a
descriptor on it, without a copy. That is [`PixelBuffer::from_vec`] — the
constructor every zen codec reaches for at the end of decode.

```rust
use zenpixels::{PixelBuffer, PixelDescriptor};

let (width, height) = (64u32, 48u32);
// A decoder produced these — tightly packed RGBA8, 4 bytes/pixel.
let decoded: Vec<u8> = vec![0u8; width as usize * height as usize * 4];

// Consumes `decoded` (no copy); the descriptor's tight stride
// (width * bytes_per_pixel) is assumed — `from_vec` does NOT pad rows.
let buf = PixelBuffer::from_vec(decoded, width, height, PixelDescriptor::RGBA8_SRGB)?;
assert_eq!(buf.stride(), 64 * 4);              // bytes, not pixels — see below
# Ok::<(), zenpixels::At<zenpixels::BufferError>>(())
```

`from_vec` returns [`BufferError::InsufficientData`] (wrapped as
`At<BufferError>`) if the `Vec` is shorter than `aligned_stride(width) * height`
plus the leading bytes skipped for channel alignment. It does **not** accept an
explicit stride: rows are assumed tightly packed at
`width * bytes_per_pixel`. For padded/strided bytes you don't own — a decoder's
scratch row buffer, a crop of a parent image, a GPU-readback strip — borrow a
view with an explicit stride instead (next section).

> **Stride is always measured in BYTES**, never pixels or elements
> (`stride()` returns `usize`; `aligned_stride(width) = width * bytes_per_pixel`).
> So for 64-pixel-wide RGBA8 the tight stride is `64 * 4 = 256`, and for
> 16-bit RGB it is `64 * 6 = 384`. Passing a pixel count where a byte stride is
> expected is the single most common silent-corruption bug at the codec
> boundary.

### Borrowing strided bytes (explicit stride)

When the bytes have row padding (SIMD alignment, a sub-region of a larger
image) or you only want to borrow rather than own them, wrap a
[`PixelSlice`] with an explicit byte stride:

```rust
use zenpixels::{PixelSlice, PixelDescriptor};

let (width, rows) = (64u32, 48u32);
let stride_bytes = 256usize;                   // e.g. SIMD-padded; >= width*bpp
let backing: Vec<u8> = vec![0u8; stride_bytes * rows as usize];

// Borrows `backing` (lifetime-bound, zero-copy). `stride_bytes` is BYTES
// between row starts, and must be a multiple of bytes_per_pixel.
let view = PixelSlice::new(&backing, width, rows, stride_bytes, PixelDescriptor::RGBA8_SRGB)?;
assert_eq!(view.width(), 64);
# Ok::<(), zenpixels::At<zenpixels::BufferError>>(())
```

Multi-byte samples (U16/F16/F32) are read in **native byte order** — a decoder
holding big-endian container data (e.g. 16-bit PNG) must byte-swap before
wrapping. `PixelSlice::new` validates length, stride, and channel alignment,
returning `At<BufferError>` (`InsufficientData` / `StrideTooSmall` /
`StrideNotPixelAligned` / `AlignmentViolation`) on a mismatch.

## Format conversion (zenpixels-convert)

```rust
use zenpixels::PixelBuffer;
use zenpixels_convert::{RowConverter, best_match, ConvertIntent};

// `src: PixelBuffer` — pick the cheapest target format the encoder supports
let source_desc = src.descriptor();
let target = best_match(source_desc, &encoder_formats, ConvertIntent::Fastest)
    .ok_or("no compatible format")?;

// Allocate the destination, then convert row by row — no per-row allocation
let (w, h) = (src.width(), src.height());
let mut dst = PixelBuffer::new(w, h, target);
let src_view = src.as_slice();
let mut dst_view = dst.as_slice_mut();
let mut converter = RowConverter::new(source_desc, target)?;
for y in 0..h {
    converter.convert_row(src_view.row(y), dst_view.row_mut(y), w);
}
```

> Most callers want the high-level `to_rgba8()` / `to_rgb8()` extension (shown in
> the codec READMEs); reach for `RowConverter` only when you need a specific
> target format or zero per-row allocation.

### Color profile conversion

Built-in: named-profile pairs (sRGB ↔ Display P3 ↔ BT.2020 ↔ Adobe RGB) use
hardcoded matrices with fused SIMD kernels — LUT-decode + SIMD matrix +
SIMD polynomial encode for u16, fused matlut for u8. No CMS backend needed.

```rust
use zenpixels::{PixelDescriptor, ColorPrimaries};
use zenpixels_convert::{RowConverter, ConvertOptions};

let p3  = PixelDescriptor::RGB8_SRGB.with_primaries(ColorPrimaries::DisplayP3);
let srgb = PixelDescriptor::RGB8_SRGB;

let mut conv = RowConverter::new_explicit(
    p3, srgb, &ConvertOptions::permissive(),
)?;
conv.convert_row(p3_row, srgb_row, width);
```

Custom ICC profiles (vendor-specific, LUT-based, perceptual intent) need a
CMS backend. Pass one via `PluggableCms` — the plan delegates to the plugin
when the profiles differ, or uses the built-in matlut fast path when both
sides are well-known.

```rust
use zenpixels_convert::{RowConverter, ConvertOptions, cms::PluggableCms};

let cms: &dyn PluggableCms = &MoxCms;  // or any backend
let mut conv = RowConverter::new_explicit_with_cms(
    source_desc, target_desc,
    &ConvertOptions::permissive(),
    Some(cms),
)?;
```

For HDR and wide-gamut pipelines that defer tone/gamut mapping, use
`with_clip_out_of_gamut(false)` — f32 sRGB transfers then preserve
negative and supernormal values through the conversion.

## Type system

The core split: *what the bytes are* vs. *what the bytes mean*.

**`PixelFormat`** is a flat enum of byte layouts — channel count, depth, memory order. No color semantics.

```
Rgb8, Rgba8, Rgb16, Rgba16, RgbF16, RgbaF16, RgbF32, RgbaF32,
Gray8, Gray16, GrayF16, GrayAF16, GrayF32, GrayA8, GrayA16, GrayAF32,
Bgra8, Rgbx8, Bgrx8, Cmyk8, OklabF32, OklabaF32
```

F16 variants are descriptor-only today — typed `Pixel` impls land when Rust
stable ships native `f16`.

**`PixelDescriptor`** wraps a `PixelFormat` with everything needed to interpret the color data:

```rust
pub struct PixelDescriptor {
    pub format: PixelFormat,
    pub transfer: TransferFunction,    // Linear, Srgb, Bt709, Pq, Gamma22, Hlg, Unknown
    pub alpha: Option<AlphaMode>,      // Undefined, Straight, Premultiplied, Opaque
    pub primaries: ColorPrimaries,     // Bt709, DisplayP3, Bt2020, AdobeRgb, Unknown
    pub signal_range: SignalRange,     // Full or Narrow
}
```

Every field's type is re-exported at the crate root — there is no submodule to
reach into. The canonical import for hand-building a descriptor:

```rust
use zenpixels::{
    PixelDescriptor,        // the struct itself
    PixelFormat,            // the byte-layout enum
    TransferFunction,       // Linear, Srgb, Bt709, Pq, Gamma22, Hlg, Unknown
    AlphaMode,              // Undefined, Straight, Premultiplied, Opaque
    ColorPrimaries,         // Bt709, DisplayP3, Bt2020, AdobeRgb, Unknown
    SignalRange,            // Full, Narrow
};
```

Every buffer carries one. Every codec declares which ones it produces and consumes. Predefined constants cover the common cases — each expands to a complete descriptor, so check the **alpha** column before handing bytes to a codec: a codec that trusts the wrong alpha mode silently corrupts pixels (treating straight alpha as premultiplied, or reading the padding byte of an `X` format as coverage).

| Constant | format | transfer | alpha | primaries | range |
|---|---|---|---|---|---|
| `RGB8_SRGB` | `Rgb8` | `Srgb` | `None` (no alpha channel) | `Bt709` | `Full` |
| `RGBA8_SRGB` | `Rgba8` | `Srgb` | `Some(Straight)` | `Bt709` | `Full` |
| `RGB16_SRGB` | `Rgb16` | `Srgb` | `None` | `Bt709` | `Full` |
| `RGBA16_SRGB` | `Rgba16` | `Srgb` | `Some(Straight)` | `Bt709` | `Full` |
| `RGBF32_LINEAR` | `RgbF32` | `Linear` | `None` | `Bt709` | `Full` |
| `RGBAF32_LINEAR` | `RgbaF32` | `Linear` | `Some(Straight)` | `Bt709` | `Full` |
| `GRAY8_SRGB` | `Gray8` | `Srgb` | `None` | `Bt709` | `Full` |
| `GRAYA8_SRGB` | `GrayA8` | `Srgb` | `Some(Straight)` | `Bt709` | `Full` |
| `BGRA8_SRGB` | `Bgra8` | `Srgb` | `Some(Straight)` | `Bt709` | `Full` |
| `RGBX8_SRGB` | `Rgbx8` | `Srgb` | `Some(Undefined)` (padding byte, **not** coverage) | `Bt709` | `Full` |
| `BGRX8_SRGB` | `Bgrx8` | `Srgb` | `Some(Undefined)` (padding byte) | `Bt709` | `Full` |
| `RGB16_BT2100_PQ` | `Rgb16` | `Pq` | `None` | `Bt2020` | `Full` |
| `RGB16_BT2100_HLG` | `Rgb16` | `Hlg` | `None` | `Bt2020` | `Full` |
| `OKLABF32` | `OklabF32` | `Unknown` | `None` | `Bt709` | `Full` |
| `OKLABAF32` | `OklabaF32` | `Unknown` | `Some(Straight)` | `Bt709` | `Full` |
| `CMYK8` | `Cmyk8` | `Unknown` | `None` | `Bt709` | `Full` |

`None` means the format has no alpha channel at all; `Some(Undefined)` (the `X` formats) means the lane is present but its bytes are padding, not coverage. Every constant that does carry alpha defaults to **straight** (unassociated) — never premultiplied. Transfer-agnostic siblings (`RGB8`, `RGBA8`, `RGB16`, … without the `_SRGB`/`_LINEAR` suffix) are identical except `transfer = Unknown`; reach for those when you don't yet know the encoding and want to stamp it later with `with_transfer(...)`.

### CICP and ICC

`Cicp` carries ITU-T H.273 code points (used by AVIF, HEIF, JPEG XL, AV1). Named constants for `SRGB`, `DISPLAY_P3`, `BT2100_PQ`, `BT2100_HLG`. Human-readable name lookups via `color_primaries_name()` etc.

`ColorContext` bundles ICC profile bytes and/or CICP codes. Travels with pixel data via `Arc` — cheap to clone, cheap to share across pipeline stages.

`ColorOrigin` is the immutable provenance record: *how the source file described its color*, not what the pixels currently are. Used at encode time to decide whether to re-embed the original profile.

**CICP → ICC synthesis** (zenpixels-convert): `synthesize_icc_for_cicp(Cicp)` resolves an embeddable ICC profile for any assigned H.273 combination — bundled compressed database (`icc-db` feature, on by default; ~36 KB asset, lazily decoded per transfer group, no CMS required), with PQ/HLG HDR included. `synthesize_gray_icc_for_cicp` is the GRAY-class sibling for single-channel output (libpng rejects gray images paired with RGB-class profiles). The sRGB default answers `NotNeeded`; everything the database serves round-trips back through `extract_cicp` / `identify_common`.

### Orientation

`Orientation` is the canonical EXIF orientation enum for the zen ecosystem. `#[repr(u8)]` with EXIF values 1-8, so `o as u8` gives the tag value directly.

All 8 elements of the D4 dihedral group, with full composition algebra:

```rust
use zenpixels::Orientation;

let combined = Orientation::Rotate90.then(Orientation::FlipH);
assert_eq!(combined, Orientation::Transpose);

let undone = Orientation::Rotate90.compose(Orientation::Rotate90.inverse());
assert_eq!(undone, Orientation::Identity);

let (w, h) = Orientation::Rotate90.output_dimensions(1920, 1080);
assert_eq!((w, h), (1080, 1920));
```

The buffer-baking half lives in zenpixels-convert: `apply_orientation`
(fresh buffer, SIMD-tiled transpose for 4-byte pixels), `apply_orientation_into`
(caller-provided target, no allocation), and `apply_orientation_in_place`
(reuses the buffer's own allocation; see below).

## Pixel buffers

`PixelBuffer`, `PixelSlice`, and `PixelSliceMut` carry their `PixelDescriptor` and optional `ColorContext`. Generic over `P: Pixel` for compile-time type safety, with zero-cost `.erase()` / `.try_typed::<Q>()` for dynamic dispatch.

```rust
// Typed buffer — format enforced at compile time
let buf = PixelBuffer::<Rgba<u8>>::from_pixels(pixels, width, height)?;

// Type-erased for codec dispatch
let erased = buf.erase();

// Recover the type
let typed = erased.try_typed::<Rgba<u8>>().unwrap();
```

### Data access

Row and byte accessors live on `PixelSlice` / `PixelSliceMut`; reach them from a buffer via `as_slice()` / `as_slice_mut()`. `as_contiguous_bytes()`, `copy_to_contiguous_bytes()`, and the `rows(y, count)` / `crop_view(...)` windowing methods are also available directly on `PixelBuffer`.

Row-level: `row(y)` returns pixel bytes without padding. `row_with_stride(y)` includes padding.

Bulk: `as_strided_bytes()` returns the full backing `&[u8]` including stride padding — zero-copy passthrough to GPU uploads, codec writers, or anything that takes a buffer + stride. `as_contiguous_bytes()` returns `Some` only when rows are tightly packed. `contiguous_bytes()` returns `Cow` — borrows when tight, copies to strip padding otherwise.

Views: `sub_rows(y, count)` and `crop_view(x, y, w, h)` are zero-copy. `crop_copy()` allocates.

### Dimensions and descriptor

Read a buffer's geometry and color semantics directly — these accessors exist
on `PixelBuffer`, `PixelSlice`, and `PixelSliceMut` alike:

```rust
let w: u32          = buf.width();        // pixels
let h: u32          = buf.height();       // pixels
let s: usize        = buf.stride();       // BYTES between row starts
let d: PixelDescriptor = buf.descriptor();   // returned BY VALUE (it is Copy)
```

`descriptor()` returns `PixelDescriptor` **by value**, not `&PixelDescriptor` —
the descriptor is a small `Copy` struct, so read its fields freely
(`buf.descriptor().format`, `.transfer`, `.alpha`, …).

### Allocation

`PixelBuffer::new(w, h, desc)` / `try_new()` allocate a zero-filled buffer with
tight stride; `new_simd_aligned()` / `try_new_simd_aligned()` pad rows for SIMD;
[`from_vec(data, w, h, desc)`](#wrapping-a-decoders-vecu8-no-copy) wraps an
existing `Vec<u8>` (tight stride, no copy). The `try_*` variants return
`Result<_, At<BufferError>>` (a [`whereat`](https://crates.io/crates/whereat)
location wrapper around [`BufferError`] — `AllocationFailed`, `InvalidDimensions`,
`InsufficientData`, `StrideTooSmall`, …); the un-prefixed forms panic on failure
(see [allocation policy](https://docs.rs/zenpixels/latest/zenpixels/#allocation-policy)).
All constructors validate dimensions, stride, and alignment. `into_vec()`
recovers the allocation for pool reuse.

### In-place layout transforms

Layout-changing in-place work goes through one atomic primitive:
`PixelBuffer::transform_in_place` runs a transform over the buffer's own
bytes and adopts the resulting geometry/descriptor/color context in the
same call — the buffer and its bytes can never disagree (there is
deliberately no slice-level equivalent). zenpixels-convert builds three
operations on it, all allocation-free:

- `buf.reduce_to_load_bearing_format_in_place(force)` — bit-exact narrowing
  (drop an all-opaque alpha lane, collapse `R==G==B` to gray, narrow
  bit-replicated U16 to U8) driven by a SIMD content scan;
- `try_adapt_in_place(&mut buf, target)` — metadata re-tags, `Rgba8`↔`Bgra8`
  SIMD B↔R swap, and contract-exact alpha-lane drops (RGBX→RGB and friends);
- `apply_orientation_in_place(&mut buf, orientation)` — orientation baking
  without a second pixel buffer.

The analysis half is allocation-free too: `slice.determine_load_bearing()`
reports `uses_alpha` / `uses_chroma` / `uses_low_bits` (~75 GiB/s fused
scan) so encoders can route formats without rewriting anything.

### Interop

With `imgref` feature: `From<ImgRef<P>>`, `From<ImgVec<P>>`, `as_imgref()`, `try_as_imgref::<P>()` and mutable counterparts. With `rgb` feature: `Pixel` impls for `Rgb<u8>`, `Rgba<u8>`, `Gray<u8>`, `BGRA<u8>`, and their `u16`/`f32` variants.

## Conversion

`zenpixels-convert` re-exports everything from `zenpixels`, so downstream code can depend on it alone.

### Row conversion

`RowConverter` pre-computes a conversion plan from a source/target descriptor pair. Three tiers:

1. **Direct kernels** for common pairs (byte swizzle, depth shift, transfer function LUTs)
2. **Composed plans** for less common pairs (e.g., `RGB8_SRGB` to `RGBA16_LINEAR`)
3. **Hub path** through linear sRGB f32 as universal fallback

### Format negotiation

The cost model separates **effort** (CPU work) from **loss** (information destroyed). `ConvertIntent` controls weighting:

| Intent | Effort | Loss | Use case |
|---|---|---|---|
| `Fastest` | 4x | 1x | Encoding — get there fast |
| `LinearLight` | 1x | 4x | Resize, blur — need linear math |
| `Blend` | 1x | 4x | Compositing — premultiplied alpha |
| `Perceptual` | 1x | 3x | Color grading, sharpening |

`Provenance` tracking lets the cost model know that f32 data decoded from a u8 JPEG has zero loss converting back to u8.

Three entry points: `best_match()` (simple), `best_match_with()` (with consumer costs), `negotiate()` (full control with provenance).

### No silent lossy conversions

Every operation that destroys information requires an explicit policy via `ConvertOptions`:

- **Alpha removal**: `DiscardIfOpaque`, `CompositeOnto { r, g, b }`, `DiscardUnchecked`, or `Forbid`
- **Depth reduction**: `Round`, `Truncate`, or `Forbid`
- **RGB to gray**: requires explicit luma coefficients (`Bt709`, `Bt601`, `Bt2020`, or `DisplayP3`), or `None` to forbid. Y' (encoded luma) semantic — round-trips bit-exactly for `R==G==B`.

Convenience constructors: `ConvertOptions::forbid_lossy()` (safe default) and `ConvertOptions::permissive()` (sensible lossy defaults), with `with_alpha_policy()`, `with_depth_policy()`, etc. for customization.

### Atomic output assembly

`finalize_for_output` couples converted pixels with matching encoder metadata in one step. Prevents the bug where pixel values don't match the embedded ICC/CICP profile.

### Gamut, HDR, Oklab

**Gamut matrices** — 3x3 row-major f32 between BT.709, Display P3, BT.2020. No CMS needed for named-profile conversions.

**HDR** — Reinhard and exposure tone mapping, `ContentLightLevel` and `MasteringDisplay` metadata.

**Oklab** — primaries-aware `rgb_to_lms_matrix()` / `lms_to_rgb_matrix()`, scalar `rgb_to_oklab()` / `oklab_to_rgb()`, public LMS/XYZ/Oklab matrices. Non-sRGB sources get correct LMS matrices without an intermediate sRGB step.

**CMS** — `PluggableCms` trait (dyn-compatible, accepts `ColorProfileSource` directly — CICP, named profiles, or raw ICC bytes) plugs an external backend into `RowConverter`. `RowTransformMut` is the `&mut self` row-level transform returned by plugins. The `cms-moxcms` feature provides a concrete backend using [moxcms](https://crates.io/crates/moxcms), supporting u8/u16/f32 transforms with automatic profile identification. The older `ColorManagement` / `RowTransform` traits (ICC-bytes-only, `&self`) are retained for backward compatibility.

**ICC identification** — `zenpixels::icc::identify_common(icc_bytes)` recognizes 298 well-known RGB + 69 grayscale profiles via normalized FNV-1a hash lookup (~100ns), including every profile the CICP database itself embeds in encoded output. Returns primaries, transfer function, and `IdentificationUse` (whether matrix+TRC substitution is safe vs CMS-only). Covers sRGB, Display P3, BT.2020, Adobe RGB variants across ICC v2–v5; `extract_cicp` reads embedded `cICP` tags directly.

### Pipeline planner

`CodecFormats` declares each codec's decode outputs and encode inputs, ICC/CICP support, effective bits, and overshoot behavior. The `pipeline` feature enables the format registry, operation requirements, and path solver for multi-step conversion planning.

## Planar support

With the `planar` feature: `PlaneLayout`, `PlaneDescriptor`, `PlaneSemantic`, `Subsampling` (4:2:0/4:2:2/4:4:4/4:1:1), `YuvMatrix`, and `MultiPlaneImage` container. Handles YCbCr, Oklab planes, gain maps, and separate alpha planes.

## Features

### zenpixels

| Feature | Default | What it enables |
|---|---|---|
| `std` | yes | Standard library (currently a no-op; everything is `no_std + alloc`) |
| `icc` | yes | `icc` module — hash-based ICC profile identification (~100ns) |
| `rgb` | | `Pixel` impls for `rgb` crate types, typed `from_pixels()` constructors |
| `imgref` | | `From<ImgRef>` / `From<ImgVec>` conversions (implies `rgb`) |
| `planar` | | Multi-plane image types (YCbCr, Oklab, gain maps) |
| `serde` | | `Serialize`/`Deserialize` derives on all core types |

### zenpixels-convert

| Feature | Default | What it enables |
|---|---|---|
| `std` | yes | Standard library |
| `icc-db` | yes | Bundled CICP→ICC profile database (~36 KB asset) behind `synthesize_icc_for_cicp` / `synthesize_gray_icc_for_cicp`; disable for size-sensitive builds (they answer `NeedsCms` instead) |
| `avx512` | | 16-wide AVX-512F f16 conversion kernels (runtime-dispatched) |
| `rgb` | | `Pixel` impls for `rgb` crate types, typed convenience methods (`to_rgb8()`, `to_rgba8()`, etc.) |
| `imgref` | | `ImgRef`/`ImgVec` conversions (implies `rgb`) |
| `planar` | | Multi-plane image types |
| `pipeline` | | Pipeline planner: format registry, operation requirements, path solver |
| `cms-moxcms` | | ICC profile transforms via [moxcms](https://crates.io/crates/moxcms) (implies `std`) |
| `serde` | | Forwards to `zenpixels/serde` |

## Build time

`zenpixels` itself compiles in **~0.28s** (release, 7950X). The cold `cargo build --release -p zenpixels` wall is ~1.9s, but 1.6s of that is the serial prerequisite chain `proc-macro2 → syn → bytemuck_derive → bytemuck` — costs most real Rust projects already pay for something else. Edit-rebuild cycles only pay the 0.3s.

## MSRV

`zenpixels` requires Rust 1.85+. `zenpixels-convert` requires Rust 1.89+ (for the safe SIMD intrinsics it uses). 2024 edition.

## Image tech I maintain

| | |
|:--|:--|
| State of the art codecs* | [zenjpeg] · [zenpng] · [zenwebp] · [zengif] · [zenavif] ([rav1d-safe] · [zenrav1e] · [zenavif-parse] · [zenavif-serialize]) · [zenjxl] ([jxl-encoder] · [zenjxl-decoder]) · [zentiff] · [zenbitmaps] · [heic] · [zenraw] · [zenpdf] · [ultrahdr] · [mozjpeg-rs] · [webpx] |
| Compression | [zenflate] · [zenzop] |
| Processing | [zenresize] · [zenfilters] · [zenquant] · [zenblend] |
| Metrics | [zensim] · [fast-ssim2] · [butteraugli] · [resamplescope-rs] · [codec-eval] · [codec-corpus] |
| Pixel types & color | **zenpixels** · [zenpixels-convert] · [linear-srgb] · [garb] |
| Pipeline | [zenpipe] · [zencodec] · [zencodecs] · [zenlayout] · [zennode] |
| ImageResizer | [ImageResizer] (C#) — 24M+ NuGet downloads across all packages |
| [Imageflow][] | Image optimization engine (Rust) — [.NET][imageflow-dotnet] · [node][imageflow-node] · [go][imageflow-go] — 9M+ NuGet downloads across all packages |
| [Imageflow Server][] | [The fast, safe image server](https://www.imazen.io/) (Rust+C#) — 552K+ NuGet downloads, deployed by Fortune 500s and major brands |

<sub>* as of 2026</sub>

### General Rust awesomeness

[archmage] · [magetypes] · [enough] · [whereat] · [zenbench] · [cargo-copter]

[And other projects](https://www.imazen.io/open-source) · [GitHub @imazen](https://github.com/imazen) · [GitHub @lilith](https://github.com/lilith) · [lib.rs/~lilith](https://lib.rs/~lilith) · [NuGet](https://www.nuget.org/profiles/imazen) (over 30 million downloads / 87 packages)

## License

Apache-2.0 OR MIT

[zenjpeg]: https://github.com/imazen/zenjpeg
[zenpng]: https://github.com/imazen/zenpng
[zenwebp]: https://github.com/imazen/zenwebp
[zengif]: https://github.com/imazen/zengif
[zenavif]: https://github.com/imazen/zenavif
[zenjxl]: https://github.com/imazen/zenjxl
[zentiff]: https://github.com/imazen/zentiff
[zenbitmaps]: https://github.com/imazen/zenbitmaps
[heic]: https://github.com/imazen/heic-decoder-rs
[zenraw]: https://github.com/imazen/zenraw
[zenpdf]: https://github.com/imazen/zenpdf
[ultrahdr]: https://github.com/imazen/ultrahdr
[jxl-encoder]: https://github.com/imazen/jxl-encoder
[zenjxl-decoder]: https://github.com/imazen/zenjxl-decoder
[rav1d-safe]: https://github.com/imazen/rav1d-safe
[zenrav1e]: https://github.com/imazen/zenrav1e
[mozjpeg-rs]: https://github.com/imazen/mozjpeg-rs
[zenavif-parse]: https://github.com/imazen/zenavif-parse
[zenavif-serialize]: https://github.com/imazen/zenavif-serialize
[webpx]: https://github.com/imazen/webpx
[zenflate]: https://github.com/imazen/zenflate
[zenzop]: https://github.com/imazen/zenzop
[zenresize]: https://github.com/imazen/zenresize
[zenfilters]: https://github.com/imazen/zenfilters
[zenquant]: https://github.com/imazen/zenquant
[zenblend]: https://github.com/imazen/zenblend
[zensim]: https://github.com/imazen/zensim
[fast-ssim2]: https://github.com/imazen/fast-ssim2
[butteraugli]: https://github.com/imazen/butteraugli
[zenpixels-convert]: https://github.com/imazen/zenpixels
[linear-srgb]: https://github.com/imazen/linear-srgb
[garb]: https://github.com/imazen/garb
[zenpipe]: https://github.com/imazen/zenpipe
[zencodec]: https://github.com/imazen/zencodec
[zencodecs]: https://github.com/imazen/zencodecs
[zenlayout]: https://github.com/imazen/zenlayout
[zennode]: https://github.com/imazen/zennode
[Imageflow]: https://github.com/imazen/imageflow
[Imageflow Server]: https://github.com/imazen/imageflow-server
[imageflow-dotnet]: https://github.com/imazen/imageflow-dotnet
[imageflow-node]: https://github.com/imazen/imageflow-node
[imageflow-go]: https://github.com/imazen/imageflow-go
[ImageResizer]: https://github.com/imazen/resizer
[archmage]: https://github.com/imazen/archmage
[magetypes]: https://github.com/imazen/archmage
[enough]: https://github.com/imazen/enough
[whereat]: https://github.com/lilith/whereat
[zenbench]: https://github.com/imazen/zenbench
[cargo-copter]: https://github.com/imazen/cargo-copter
[resamplescope-rs]: https://github.com/imazen/resamplescope-rs
[codec-eval]: https://github.com/imazen/codec-eval
[codec-corpus]: https://github.com/imazen/codec-corpus