Skip to main content

Crate zenpixels_convert

Crate zenpixels_convert 

Source
Expand description

Transfer-function-aware pixel conversion for zenpixels.

This crate provides all the conversion logic that was split out of the zenpixels interchange crate: row-level format conversion, gamut mapping, codec format negotiation, and HDR tone mapping.

§Re-exports

All interchange types from zenpixels are re-exported at the crate root, so downstream code can depend on zenpixels-convert alone.

§Core concepts

  • Format negotiation: best_match picks the cheapest conversion target from a codec’s supported formats for a given source descriptor.

  • Row conversion: RowConverter pre-computes a conversion plan and converts rows with no per-row allocation, using SIMD where available.

  • Codec helpers: adapt::adapt_for_encode negotiates format and converts pixel data in one call, returning Cow::Borrowed when the input already matches a supported format.

  • Extension traits: TransferFunctionExt, ColorPrimariesExt, and PixelBufferConvertExt add conversion methods to interchange types.

§Codec compliance guide

This section describes how to write a codec that integrates correctly with the zenpixels ecosystem. A “codec” here means any decoder or encoder crate that produces or consumes pixel data.

§Design principles

  1. Codecs own I/O; zenpixels-convert owns pixel math. A codec reads and writes its container format. All pixel format conversion, transfer function application, gamut mapping, and alpha handling is done by zenpixels-convert. Codecs should not re-implement conversion logic.

  2. PixelFormat is byte layout; PixelDescriptor is full meaning. PixelFormat describes the physical byte arrangement (channel count, order, depth). PixelDescriptor adds color interpretation: transfer function, primaries, alpha mode, signal range. Codecs register PixelDescriptor values because negotiation needs the full picture.

  3. No silent lossy conversions. Every operation that destroys information (alpha removal, depth reduction, gamut clipping) requires an explicit policy via ConvertOptions. Codecs must not silently clamp, truncate, or discard data.

  4. Pixels and metadata travel together. ColorContext rides on PixelBuffer via Arc so ICC/CICP metadata follows pixel data through the pipeline. finalize_for_output couples converted pixels with matching encoder metadata atomically.

  5. Provenance enables lossless round-trips. The cost model tracks where data came from (Provenance). A JPEG u8 decoded to f32 for resize reports zero loss when converting back to u8, because the origin precision was u8 all along.

§The pixel lifecycle

Every image processing pipeline follows this flow:

┌──────────┐    ┌───────────┐    ┌───────────┐    ┌──────────┐
│  Decode   │───>│ Negotiate │───>│  Convert  │───>│  Encode  │
│          │    │           │    │           │    │          │
│ Produces: │    │ Picks:    │    │ Uses:     │    │ Consumes:│
│ PixelBuf  │    │ best fmt  │    │ RowConv.  │    │ EncReady │
│ ColorCtx  │    │ from list │    │ per-row   │    │ metadata │
│ ColorOrig │    │           │    │           │    │          │
└──────────┘    └───────────┘    └───────────┘    └──────────┘

§Step 1: Decode

The decoder produces pixel data in one of its natively supported formats, wraps it in a PixelBuffer, and extracts color metadata from the file.

// Decode raw pixels
let pixels: Vec<u8> = my_codec_decode(&file_bytes)?;
let desc = PixelDescriptor::RGB8_SRGB;
let buffer = PixelBuffer::from_vec(pixels, width, height, desc)?;

// Extract color metadata for CMS integration
let color_ctx = match (icc_chunk, cicp_chunk) {
    (Some(icc), Some(cicp)) =>
        Some(Arc::new(ColorContext::from_icc_and_cicp(icc, cicp))),
    (Some(icc), None) =>
        Some(Arc::new(ColorContext::from_icc(icc))),
    (None, Some(cicp)) =>
        Some(Arc::new(ColorContext::from_cicp(cicp))),
    (None, None) => None,
};

// Track provenance for re-encoding decisions
let origin = ColorOrigin::from_icc_and_cicp(icc, cicp);
// or: ColorOrigin::from_icc(icc)
// or: ColorOrigin::from_cicp(cicp)
// or: ColorOrigin::from_gama_chrm()  // PNG gAMA+cHRM
// or: ColorOrigin::assumed()         // no color metadata in file

Rules for decoders:

  • Register only formats the decoder natively produces. Do not list formats that require internal conversion — let the caller convert via RowConverter. If your JPEG decoder outputs u8 sRGB only, register RGB8_SRGB, not RGBF32_LINEAR.

  • Extract all available color metadata. Both ICC and CICP can coexist (AVIF/HEIF containers carry both). Record all of it on ColorContext.

  • Build a ColorOrigin that records how the file described its color, not what the pixels are. This is immutable and used only at encode time for provenance decisions (e.g., “re-embed the original ICC profile”).

  • Set effective_bits correctly on FormatEntry. A 10-bit AVIF source decoded to u16 has effective_bits = 10, not 16. A JPEG decoded to f32 with debiased dequantization has effective_bits = 10. Getting this wrong makes the cost model over- or under-value precision.

  • Set can_overshoot = true only when output values exceed [0.0, 1.0]. This is rare — only JPEG f32 decode with preserved IDCT ringing.

§Step 2: Negotiate

Before encoding, the pipeline must pick a format the encoder accepts. Negotiation uses the two-axis cost model (effort vs. loss) weighted by ConvertIntent.

Three entry points, from simplest to most flexible:

  • best_match: Pass a source descriptor, a list of supported descriptors, and an intent. Good for simple encode paths.

  • best_match_with: Like best_match, but each candidate carries a consumer cost (FormatOption). Use this when the encoder has fast internal conversion paths (e.g., a JPEG encoder with a fused f32→u8+DCT kernel can advertise RGBF32_LINEAR with low consumer cost).

  • negotiate: Full control. Explicit Provenance (so the cost model knows the data’s true origin) plus consumer costs. Use this in processing pipelines where data has been widened from a lower-precision source (e.g., JPEG u8 decoded to f32 for resize — provenance says “u8 origin”, so converting back to u8 reports zero loss).

// Simple: "what format should I encode to?"
let target = best_match(
    buffer.descriptor(),
    &encoder_supported,
    ConvertIntent::Fastest,
).ok_or("no compatible format")?;

// With provenance: "this f32 data came from u8 JPEG"
let provenance = Provenance::with_origin_depth(ChannelType::U8);
let target = negotiate(
    current_desc,
    provenance,
    options.iter().copied(),
    ConvertIntent::Fastest,
);

Rules for negotiation:

  • Use ConvertIntent::Fastest when encoding. The encoder knows what it wants; get there with minimal work.

  • Use ConvertIntent::LinearLight for resize, blur, anti-aliasing. These operations need linear light for gamma-correct results.

  • Use ConvertIntent::Blend for compositing. This ensures premultiplied alpha for correct Porter-Duff math.

  • Use ConvertIntent::Perceptual for sharpening, contrast, saturation. These are perceptual operations that work best in sRGB or Oklab space.

  • Track provenance when data has been widened. If you decoded a JPEG (u8) into f32 for processing, tell the cost model via Provenance::with_origin_depth(ChannelType::U8). Otherwise it will penalize the f32→u8 conversion as lossy when it’s actually a lossless round-trip.

  • If an operation genuinely expands the data’s gamut (e.g., saturation boost in BT.2020 that pushes colors outside sRGB), call Provenance::invalidate_primaries with the current working primaries. Otherwise the cost model will incorrectly report gamut narrowing as lossless.

§Step 3: Convert

Once a target format is chosen, convert pixel data row-by-row.

let converter = RowConverter::new(source_desc, target_desc)?;
for y in 0..height {
    converter.convert_row(src_row, dst_row, width);
}

Or use the convenience function that combines negotiation and conversion:

let adapted = adapt_for_encode(
    raw_bytes, descriptor, width, rows, stride,
    &encoder_supported,
)?;
// adapted.data is Cow::Borrowed if no conversion needed

Rules for conversion:

  • Use RowConverter, not hand-rolled conversion. It handles transfer functions, gamut matrices, alpha mode changes, depth scaling, Oklab, and byte swizzle correctly. It pre-computes the plan so there is zero per-row overhead.

  • For policy-sensitive conversions (when you need to control what lossy operations are allowed), use adapt_for_encode_explicit with ConvertOptions. This validates policies before doing work and returns specific errors like ConvertError::AlphaNotOpaque or ConvertError::DepthReductionForbidden.

  • The conversion system handles three tiers internally: (a) Direct SIMD kernels for common pairs (byte swizzle, depth shift, transfer LUTs). (b) Composed multi-step plans for less common pairs. (c) Hub path through linear sRGB f32 as a universal fallback.

§Step 4: Encode

The encoder receives pixel data in a format it natively supports and must embed correct color metadata.

For the atomic path (recommended), use finalize_for_output:

let ready = finalize_for_output(
    &buffer,
    &color_origin,
    OutputProfile::SameAsOrigin,
    target_format,
    &cms,
)?;

// Pixels and metadata are guaranteed to match
encoder.write_pixels(ready.pixels())?;
encoder.write_icc(ready.metadata().icc.as_deref())?;
encoder.write_cicp(ready.metadata().cicp)?;

Rules for encoders:

  • Register only formats the encoder natively accepts. If your JPEG encoder takes u8 sRGB, register RGB8_SRGB. Don’t also list RGBA8_SRGB unless you actually handle RGBA natively (not just by stripping alpha internally). Let the conversion system handle format changes.

  • If you have fast internal conversion paths, advertise them via FormatOption::with_cost. Example: a JPEG encoder with a fused f32→DCT path can accept RGBF32_LINEAR at ConversionCost::new(5, 0), so negotiation will route f32 data directly to the encoder instead of doing a redundant f32→u8 conversion first.

  • Use finalize_for_output to bundle pixels and metadata atomically. This prevents the most common color management bug: pixel values that don’t match the embedded ICC/CICP.

  • Always embed color metadata when the format supports it. Check CodecFormats::icc_encode and CodecFormats::cicp for your codec’s capabilities. Omitting color metadata causes browsers and OS viewers to assume sRGB, which corrupts Display P3 and HDR content.

  • The OutputProfile enum controls what gets embedded:

§Format registry

Every codec must declare its capabilities in a CodecFormats struct, typically as a pub static. This serves as the single source of truth for what the codec can produce and consume. See the pipeline::registry module (requires the pipeline feature) for the full format table and examples for each codec.

pub static MY_CODEC: CodecFormats = CodecFormats {
    name: "mycodec",
    decode_outputs: &[
        FormatEntry::standard(PixelDescriptor::RGB8_SRGB),
        FormatEntry::standard(PixelDescriptor::RGBA8_SRGB),
    ],
    encode_inputs: &[
        FormatEntry::standard(PixelDescriptor::RGB8_SRGB),
    ],
    icc_decode: true,   // extracts ICC profiles
    icc_encode: true,   // embeds ICC profiles
    cicp: false,        // no CICP support
};

§Cost model

Format negotiation uses a two-axis cost model separating effort (CPU work) from loss (information destroyed). These are independent: a fast conversion can be very lossy (f32 HDR → u8 clamp), and a slow conversion can be lossless (u8 sRGB → f32 linear).

ConvertIntent controls how the axes are weighted:

IntentEffort weightLoss weightUse case
Fastest4x1xEncoding
LinearLight1x4xResize, blur
Blend1x4xCompositing
Perceptual1x3xColor grading

Cost components are additive: total = transfer_cost + depth_cost + layout_cost + alpha_cost + primaries_cost + consumer_cost + suitability_loss. The lowest-scoring candidate wins.

§CMS integration

Named profile conversions (sRGB ↔ Display P3 ↔ BT.2020) use hardcoded 3×3 gamut matrices and need no CMS backend. ICC-to-ICC transforms require a ColorManagement implementation, which is a compile-time feature (e.g., cms-moxcms, cms-lcms2).

Codecs that handle ICC profiles must:

  1. Extract ICC bytes on decode and store them on ColorContext.
  2. Record provenance on ColorOrigin.
  3. On encode, let finalize_for_output handle the ICC transform (if the target profile differs from the source) or pass-through (if SameAsOrigin).

§Error handling

ConvertError provides specific variants so codecs can handle each failure mode:

Codecs should match on specific variants and return actionable errors to callers. Do not flatten ConvertError into a generic string.

§Checklist

  • Declare CodecFormats with correct effective_bits and can_overshoot
  • Decode: extract ICC + CICP → ColorContext
  • Decode: record provenance → ColorOrigin
  • Encode: negotiate via best_match or adapt::adapt_for_encode
  • Encode: convert via RowConverter (not hand-rolled)
  • Encode: embed metadata via finalize_for_output
  • Encode: embed ICC/CICP when the format supports it
  • Handle ConvertError variants specifically
  • Test round-trip: native format → encode → decode = lossless
  • Test negotiation: best_match(my_format, my_supported, Fastest) picks identity

Re-exports§

pub use adapt::adapt_for_encode_explicit;
pub use converter::RowConverter;
pub use error::ConvertError;
pub use ext::ColorPrimariesExt;
pub use ext::PixelBufferConvertExt;
pub use ext::TransferFunctionExt;
pub use gamut::GamutMatrix;
pub use gamut::apply_matrix_f32;
pub use gamut::apply_matrix_row_f32;
pub use gamut::apply_matrix_row_rgba_f32;
pub use gamut::conversion_matrix;
pub use hdr::exposure_tonemap;
pub use hdr::HdrMetadata;
pub use hdr::reinhard_inverse;
pub use hdr::reinhard_tonemap;
pub use cms::ColorManagement;
pub use cms::RowTransform;
pub use output::EncodeReady;
pub use output::OutputMetadata;
pub use output::OutputProfile;
pub use output::finalize_for_output;

Modules§

adapt
Codec adapter functions — the fastest path to a compliant encoder.
buffer
Opaque pixel buffer abstraction.
cicp
CICP (Coding-Independent Code Points) color description.
cms
Color Management System (CMS) traits.
color
Color profile types for CMS integration.
converter
Pre-computed row converter.
descriptor
Pixel format descriptor types.
error
Error types for pixel format conversion.
ext
Extension traits that add conversion methods to zenpixels interchange types.
gamut
Color gamut conversion matrices for BT.709, BT.2020, and Display P3.
hdr
HDR processing utilities.
oklab
Oklab color space conversion constants and scalar reference functions.
orientation
EXIF orientation as an element of the D4 dihedral group.
output
Atomic output preparation for encoders.
pixel_types
Custom pixel types for gray+alpha formats.
policy
Conversion policy types for explicit control over lossy operations.

Macros§

at
Start tracing an error with crate metadata for repository links.

Structs§

At
An error with location tracking - wraps any error type.
Bgrx
32-bit BGR pixel with padding byte (BGRx).
Cicp
CICP color description (ITU-T H.273).
ColorContext
Color space metadata for pixel data.
ColorOrigin
Immutable record of how the source file described its color.
ContentLightLevel
HDR content light level metadata (CEA-861.3 / CTA-861-H).
ConversionCost
Two-axis conversion cost: computational effort vs. information loss.
ConvertOptions
Explicit options for pixel format conversion. All lossy operations require a policy choice — no silent defaults.
ConvertPlan
Pre-computed conversion plan.
FormatOption
A supported format with optional consumer-provided cost override.
GrayAlpha8
Grayscale + alpha, 8-bit.
GrayAlpha16
Grayscale + alpha, 16-bit.
GrayAlphaF32
Grayscale + alpha, f32.
MasteringDisplay
Mastering display color volume metadata (SMPTE ST 2086).
PixelBuffer
Owned pixel buffer with format metadata.
PixelDescriptor
Compact pixel format descriptor.
PixelSlice
Borrowed view of pixel data.
PixelSliceMut
Mutable borrowed view of pixel data.
Provenance
Tracks where pixel data came from, so the cost model can distinguish “f32 that was widened from u8 JPEG” (lossless round-trip back to u8) from “f32 that was decoded from a 16-bit EXR” (lossy truncation to u8).
Rgbx
32-bit RGB pixel with padding byte (RGBx).

Enums§

AlphaMode
Alpha channel interpretation.
AlphaPolicy
Policy for alpha channel removal. Required when converting from a layout with alpha to one without.
BufferError
Errors from pixel buffer operations.
ByteOrder
RGB-family byte order. Only meaningful when color model is RGB.
ChannelLayout
Channel layout (number and meaning of channels).
ChannelType
Channel storage type.
ColorModel
What the channels represent, independent of channel count or byte order.
ColorPrimaries
Color primaries (CIE xy chromaticities of R, G, B).
ColorProfileSource
A source color profile — either ICC bytes or CICP parameters.
ColorProvenance
How the source file described its color information.
ConvertIntent
What the consumer plans to do with the converted pixels.
DepthPolicy
Policy for bit depth reduction (U16→U8, etc.).
GrayExpand
How to expand grayscale channels to RGB.
LumaCoefficients
Luma coefficients for RGB→Gray conversion.
NamedProfile
Well-known color profiles that any CMS should recognize.
Orientation
Image orientation — the 8-element D4 dihedral group.
PixelFormat
Physical pixel layout for match-based format dispatch.
SignalRange
Signal range for pixel values.
TransferFunction
Electro-optical transfer function.

Traits§

Pixel
Compile-time pixel format descriptor.
ResultAtExt
Extension trait for adding location tracking to Result<T, At<E>>.

Functions§

at
Wrap any value in At<E> and capture the caller’s location.
best_match
Pick the best conversion target from supported for the given source.
best_match_with
Pick the best conversion target from options, accounting for consumer-provided cost overrides.
conversion_cost
Compute the two-axis conversion cost for fromto.
conversion_cost_with_provenance
Compute the two-axis conversion cost with explicit provenance.
convert_row
Convert one row of width pixels using a pre-computed plan.
ideal_format
Recommend the ideal working format for a given intent, based on the source format.
negotiate
Fully-parameterized format negotiation.