Skip to main content

zenpixels_convert/
lib.rs

1//! Transfer-function-aware pixel conversion for zenpixels.
2//!
3//! This crate provides all the conversion logic that was split out of the
4//! `zenpixels` interchange crate: row-level format conversion, gamut mapping,
5//! codec format negotiation, and HDR tone mapping.
6//!
7//! # Re-exports
8//!
9//! All interchange types from `zenpixels` are re-exported at the crate root,
10//! so downstream code can depend on `zenpixels-convert` alone.
11//!
12//! # Core concepts
13//!
14//! - **Format negotiation**: [`best_match`] picks the cheapest conversion
15//!   target from a codec's supported formats for a given source descriptor.
16//!
17//! - **Row conversion**: [`RowConverter`] pre-computes a conversion plan and
18//!   converts rows with no per-row allocation, using SIMD where available.
19//!
20//! - **Codec helpers**: [`adapt::adapt_for_encode`] negotiates format and converts
21//!   pixel data in one call, returning `Cow::Borrowed` when the input
22//!   already matches a supported format.
23//!
24//! - **Extension traits**: [`TransferFunctionExt`], [`ColorPrimariesExt`],
25//!   and `PixelBufferConvertExt` add conversion methods to interchange types.
26//!
27//! # Codec compliance guide
28//!
29//! This section describes how to write a codec that integrates correctly with
30//! the zenpixels ecosystem. A "codec" here means any decoder or encoder crate
31//! that produces or consumes pixel data.
32//!
33//! ## Design principles
34//!
35//! 1. **Codecs own I/O; zenpixels-convert owns pixel math.** A codec reads
36//!    and writes its container format. All pixel format conversion, transfer
37//!    function application, gamut mapping, and alpha handling is done by
38//!    `zenpixels-convert`. Codecs should not re-implement conversion logic.
39//!
40//! 2. **`PixelFormat` is byte layout; `PixelDescriptor` is full meaning.**
41//!    `PixelFormat` describes the physical byte arrangement (channel count,
42//!    order, depth). `PixelDescriptor` adds color interpretation: transfer
43//!    function, primaries, alpha mode, signal range. Codecs register
44//!    `PixelDescriptor` values because negotiation needs the full picture.
45//!
46//! 3. **No silent lossy conversions.** Every operation that destroys
47//!    information (alpha removal, depth reduction, gamut clipping) requires
48//!    an explicit policy via [`ConvertOptions`]. Codecs must not silently
49//!    clamp, truncate, or discard data.
50//!
51//! 4. **Pixels and metadata travel together.** [`ColorContext`] rides on
52//!    [`PixelBuffer`] via `Arc` so ICC/CICP metadata follows pixel data
53//!    through the pipeline. [`finalize_for_output`] couples converted pixels
54//!    with matching encoder metadata atomically.
55//!
56//! 5. **Provenance enables lossless round-trips.** The cost model tracks
57//!    where data came from ([`Provenance`]). A JPEG u8 decoded to f32 for
58//!    resize reports zero loss when converting back to u8, because the
59//!    origin precision was u8 all along.
60//!
61//! ## The pixel lifecycle
62//!
63//! Every image processing pipeline follows this flow:
64//!
65//! ```text
66//! ┌──────────┐    ┌───────────┐    ┌───────────┐    ┌──────────┐
67//! │  Decode   │───>│ Negotiate │───>│  Convert  │───>│  Encode  │
68//! │          │    │           │    │           │    │          │
69//! │ Produces: │    │ Picks:    │    │ Uses:     │    │ Consumes:│
70//! │ PixelBuf  │    │ best fmt  │    │ RowConv.  │    │ EncReady │
71//! │ ColorCtx  │    │ from list │    │ per-row   │    │ metadata │
72//! │ ColorOrig │    │           │    │           │    │          │
73//! └──────────┘    └───────────┘    └───────────┘    └──────────┘
74//! ```
75//!
76//! ### Step 1: Decode
77//!
78//! The decoder produces pixel data in one of its natively supported formats,
79//! wraps it in a [`PixelBuffer`], and extracts color metadata from the file.
80//!
81//! ```rust,ignore
82//! // Decode raw pixels
83//! let pixels: Vec<u8> = my_codec_decode(&file_bytes)?;
84//! let desc = PixelDescriptor::RGB8_SRGB;
85//! let buffer = PixelBuffer::from_vec(pixels, width, height, desc)?;
86//!
87//! // Extract color metadata for CMS integration
88//! let color_ctx = match (icc_chunk, cicp_chunk) {
89//!     (Some(icc), Some(cicp)) =>
90//!         Some(Arc::new(ColorContext::from_icc_and_cicp(icc, cicp))),
91//!     (Some(icc), None) =>
92//!         Some(Arc::new(ColorContext::from_icc(icc))),
93//!     (None, Some(cicp)) =>
94//!         Some(Arc::new(ColorContext::from_cicp(cicp))),
95//!     (None, None) => None,
96//! };
97//!
98//! // Track provenance for re-encoding decisions
99//! let origin = ColorOrigin::from_icc_and_cicp(icc, cicp);
100//! // or: ColorOrigin::from_icc(icc)
101//! // or: ColorOrigin::from_cicp(cicp)
102//! // or: ColorOrigin::from_gama_chrm()  // PNG gAMA+cHRM
103//! // or: ColorOrigin::assumed()         // no color metadata in file
104//! ```
105//!
106//! **Rules for decoders:**
107//!
108//! - Register only formats the decoder natively produces. Do not list formats
109//!   that require internal conversion — let the caller convert via
110//!   [`RowConverter`]. If your JPEG decoder outputs u8 sRGB only, register
111//!   `RGB8_SRGB`, not `RGBF32_LINEAR`.
112//!
113//! - Extract all available color metadata. Both ICC and CICP can coexist
114//!   (AVIF/HEIF containers carry both). Record all of it on [`ColorContext`].
115//!
116//! - Build a [`ColorOrigin`] that records *how* the file described its color,
117//!   not what the pixels are. This is immutable and used only at encode time
118//!   for provenance decisions (e.g., "re-embed the original ICC profile").
119//!
120//! - Set `effective_bits` correctly on `FormatEntry`. A 10-bit AVIF source
121//!   decoded to u16 has `effective_bits = 10`, not 16. A JPEG decoded to f32
122//!   with debiased dequantization has `effective_bits = 10`. Getting this
123//!   wrong makes the cost model over- or under-value precision.
124//!
125//! - Set `can_overshoot = true` only when output values exceed `[0.0, 1.0]`.
126//!   This is rare — only JPEG f32 decode with preserved IDCT ringing.
127//!
128//! ### Step 2: Negotiate
129//!
130//! Before encoding, the pipeline must pick a format the encoder accepts.
131//! Negotiation uses the two-axis cost model (effort vs. loss) weighted by
132//! [`ConvertIntent`].
133//!
134//! Three entry points, from simplest to most flexible:
135//!
136//! - **[`best_match`]**: Pass a source descriptor, a list of supported
137//!   descriptors, and an intent. Good for simple encode paths.
138//!
139//! - **[`best_match_with`]**: Like `best_match`, but each candidate carries
140//!   a consumer cost ([`FormatOption`]). Use this when the encoder has fast
141//!   internal conversion paths (e.g., a JPEG encoder with a fused f32→u8+DCT
142//!   kernel can advertise `RGBF32_LINEAR` with low consumer cost).
143//!
144//! - **[`negotiate`]**: Full control. Explicit [`Provenance`] (so the cost
145//!   model knows the data's true origin) plus consumer costs. Use this in
146//!   processing pipelines where data has been widened from a lower-precision
147//!   source (e.g., JPEG u8 decoded to f32 for resize — provenance says "u8
148//!   origin", so converting back to u8 reports zero loss).
149//!
150//! ```rust,ignore
151//! // Simple: "what format should I encode to?"
152//! let target = best_match(
153//!     buffer.descriptor(),
154//!     &encoder_supported,
155//!     ConvertIntent::Fastest,
156//! ).ok_or("no compatible format")?;
157//!
158//! // With provenance: "this f32 data came from u8 JPEG"
159//! let provenance = Provenance::with_origin_depth(ChannelType::U8);
160//! let target = negotiate(
161//!     current_desc,
162//!     provenance,
163//!     options.iter().copied(),
164//!     ConvertIntent::Fastest,
165//! );
166//! ```
167//!
168//! **Rules for negotiation:**
169//!
170//! - Use [`ConvertIntent::Fastest`] when encoding. The encoder knows what it
171//!   wants; get there with minimal work.
172//!
173//! - Use [`ConvertIntent::LinearLight`] for resize, blur, anti-aliasing.
174//!   These operations need linear light for gamma-correct results.
175//!
176//! - Use [`ConvertIntent::Blend`] for compositing. This ensures premultiplied
177//!   alpha for correct Porter-Duff math.
178//!
179//! - Use [`ConvertIntent::Perceptual`] for sharpening, contrast, saturation.
180//!   These are perceptual operations that work best in sRGB or Oklab space.
181//!
182//! - Track provenance when data has been widened. If you decoded a JPEG (u8)
183//!   into f32 for processing, tell the cost model via
184//!   `Provenance::with_origin_depth(ChannelType::U8)`. Otherwise it will
185//!   penalize the f32→u8 conversion as lossy when it's actually a lossless
186//!   round-trip.
187//!
188//! - If an operation genuinely expands the data's gamut (e.g., saturation
189//!   boost in BT.2020 that pushes colors outside sRGB), call
190//!   [`Provenance::invalidate_primaries`] with the current working primaries.
191//!   Otherwise the cost model will incorrectly report gamut narrowing as
192//!   lossless.
193//!
194//! ### Step 3: Convert
195//!
196//! Once a target format is chosen, convert pixel data row-by-row.
197//!
198//! ```rust,ignore
199//! let converter = RowConverter::new(source_desc, target_desc)?;
200//! for y in 0..height {
201//!     converter.convert_row(src_row, dst_row, width);
202//! }
203//! ```
204//!
205//! Or use the convenience function that combines negotiation and conversion:
206//!
207//! ```rust,ignore
208//! let adapted = adapt_for_encode(
209//!     raw_bytes, descriptor, width, rows, stride,
210//!     &encoder_supported,
211//! )?;
212//! // adapted.data is Cow::Borrowed if no conversion needed
213//! ```
214//!
215//! **Rules for conversion:**
216//!
217//! - Use [`RowConverter`], not hand-rolled conversion. It handles transfer
218//!   functions, gamut matrices, alpha mode changes, depth scaling, Oklab,
219//!   and byte swizzle correctly. It pre-computes the plan so there is
220//!   zero per-row overhead.
221//!
222//! - For policy-sensitive conversions (when you need to control what lossy
223//!   operations are allowed), use [`adapt_for_encode_explicit`] with
224//!   [`ConvertOptions`]. This validates policies *before* doing work and
225//!   returns specific errors like [`ConvertError::AlphaNotOpaque`] or
226//!   [`ConvertError::DepthReductionForbidden`].
227//!
228//! - The conversion system handles three tiers internally:
229//!   (a) Direct SIMD kernels for common pairs (byte swizzle, depth shift,
230//!   transfer LUTs).
231//!   (b) Composed multi-step plans for less common pairs.
232//!   (c) Hub path through linear sRGB f32 as a universal fallback.
233//!
234//! ### Step 4: Encode
235//!
236//! The encoder receives pixel data in a format it natively supports and
237//! must embed correct color metadata.
238//!
239//! For the atomic path (recommended), use [`finalize_for_output`]:
240//!
241//! ```rust,ignore
242//! let ready = finalize_for_output(
243//!     &buffer,
244//!     &color_origin,
245//!     OutputProfile::SameAsOrigin,
246//!     target_format,
247//!     &cms,
248//! )?;
249//!
250//! // Pixels and metadata are guaranteed to match
251//! encoder.write_pixels(ready.pixels())?;
252//! encoder.write_icc(ready.metadata().icc.as_deref())?;
253//! encoder.write_cicp(ready.metadata().cicp)?;
254//! ```
255//!
256//! **Rules for encoders:**
257//!
258//! - Register only formats the encoder natively accepts. If your JPEG encoder
259//!   takes u8 sRGB, register `RGB8_SRGB`. Don't also list `RGBA8_SRGB`
260//!   unless you actually handle RGBA natively (not just by stripping alpha
261//!   internally). Let the conversion system handle format changes.
262//!
263//! - If you have fast internal conversion paths, advertise them via
264//!   [`FormatOption::with_cost`]. Example: a JPEG encoder with a fused
265//!   f32→DCT path can accept `RGBF32_LINEAR` at `ConversionCost::new(5, 0)`,
266//!   so negotiation will route f32 data directly to the encoder instead of
267//!   doing a redundant f32→u8 conversion first.
268//!
269//! - Use [`finalize_for_output`] to bundle pixels and metadata atomically.
270//!   This prevents the most common color management bug: pixel values that
271//!   don't match the embedded ICC/CICP.
272//!
273//! - Always embed color metadata when the format supports it. Check
274//!   `CodecFormats::icc_encode` and `CodecFormats::cicp` for your codec's
275//!   capabilities. Omitting color metadata causes browsers and OS viewers
276//!   to assume sRGB, which corrupts Display P3 and HDR content.
277//!
278//! - The [`OutputProfile`] enum controls what gets embedded:
279//!   - [`OutputProfile::SameAsOrigin`]: Re-embed the original ICC/CICP from
280//!     the source file. Used for transcoding without color changes.
281//!   - [`OutputProfile::Named`]: Use a well-known CICP profile (sRGB, P3,
282//!     BT.2020). Uses hardcoded gamut matrices, no CMS needed.
283//!   - [`OutputProfile::Icc`]: Use specific ICC profile bytes. Requires a
284//!     [`ColorManagement`] implementation.
285//!
286//! ## Format registry
287//!
288//! Every codec must declare its capabilities in a `CodecFormats` struct,
289//! typically as a `pub static`. This serves as the single source of truth
290//! for what the codec can produce and consume. See the `pipeline::registry`
291//! module (requires the `pipeline` feature) for the full format table and
292//! examples for each codec.
293//!
294//! ```rust,ignore
295//! pub static MY_CODEC: CodecFormats = CodecFormats {
296//!     name: "mycodec",
297//!     decode_outputs: &[
298//!         FormatEntry::standard(PixelDescriptor::RGB8_SRGB),
299//!         FormatEntry::standard(PixelDescriptor::RGBA8_SRGB),
300//!     ],
301//!     encode_inputs: &[
302//!         FormatEntry::standard(PixelDescriptor::RGB8_SRGB),
303//!     ],
304//!     icc_decode: true,   // extracts ICC profiles
305//!     icc_encode: true,   // embeds ICC profiles
306//!     cicp: false,        // no CICP support
307//! };
308//! ```
309//!
310//! ## Cost model
311//!
312//! Format negotiation uses a two-axis cost model separating **effort**
313//! (CPU work) from **loss** (information destroyed). These are independent:
314//! a fast conversion can be very lossy (f32 HDR → u8 clamp), and a slow
315//! conversion can be lossless (u8 sRGB → f32 linear).
316//!
317//! [`ConvertIntent`] controls how the axes are weighted:
318//!
319//! | Intent         | Effort weight | Loss weight | Use case |
320//! |----------------|---------------|-------------|----------|
321//! | `Fastest`      | 4x            | 1x          | Encoding |
322//! | `LinearLight`  | 1x            | 4x          | Resize, blur |
323//! | `Blend`        | 1x            | 4x          | Compositing |
324//! | `Perceptual`   | 1x            | 3x          | Color grading |
325//!
326//! Cost components are additive: total = transfer_cost + depth_cost +
327//! layout_cost + alpha_cost + primaries_cost + consumer_cost +
328//! suitability_loss. The lowest-scoring candidate wins.
329//!
330//! ## CMS integration
331//!
332//! Named profile conversions (sRGB ↔ Display P3 ↔ BT.2020) use hardcoded
333//! 3×3 gamut matrices and need no CMS backend. ICC-to-ICC transforms
334//! require a [`ColorManagement`] implementation, which is a compile-time
335//! feature (e.g., `cms-moxcms`, `cms-lcms2`).
336//!
337//! Codecs that handle ICC profiles must:
338//! 1. Extract ICC bytes on decode and store them on [`ColorContext`].
339//! 2. Record provenance on [`ColorOrigin`].
340//! 3. On encode, let [`finalize_for_output`] handle the ICC transform
341//!    (if the target profile differs from the source) or pass-through
342//!    (if `SameAsOrigin`).
343//!
344//! ## Error handling
345//!
346//! [`ConvertError`] provides specific variants so codecs can handle each
347//! failure mode:
348//!
349//! - [`ConvertError::NoMatch`] — no supported format works for this source.
350//!   The codec's format list may be too restrictive.
351//! - [`ConvertError::NoPath`] — no conversion kernel exists between formats.
352//!   Unusual; most pairs are covered.
353//! - [`ConvertError::AlphaNotOpaque`] — `DiscardIfOpaque` policy was set
354//!   but the data has semi-transparent pixels.
355//! - [`ConvertError::DepthReductionForbidden`] — `Forbid` policy prevents
356//!   narrowing (e.g., f32→u8).
357//! - [`ConvertError::AllocationFailed`] — buffer allocation failed (OOM).
358//! - [`ConvertError::CmsError`] — CMS transform failed (invalid ICC profile,
359//!   unsupported color space, etc.).
360//!
361//! Codecs should match on specific variants and return actionable errors
362//! to callers. Do not flatten `ConvertError` into a generic string.
363//!
364//! ## Checklist
365//!
366//! - [ ] Declare `CodecFormats` with correct `effective_bits` and `can_overshoot`
367//! - [ ] Decode: extract ICC + CICP → [`ColorContext`]
368//! - [ ] Decode: record provenance → [`ColorOrigin`]
369//! - [ ] Encode: negotiate via [`best_match`] or [`adapt::adapt_for_encode`]
370//! - [ ] Encode: convert via [`RowConverter`] (not hand-rolled)
371//! - [ ] Encode: embed metadata via [`finalize_for_output`]
372//! - [ ] Encode: embed ICC/CICP when the format supports it
373//! - [ ] Handle [`ConvertError`] variants specifically
374//! - [ ] Test round-trip: native format → encode → decode = lossless
375//! - [ ] Test negotiation: `best_match(my_format, my_supported, Fastest)` picks identity
376
377#![cfg_attr(not(feature = "std"), no_std)]
378#![forbid(unsafe_code)]
379
380extern crate alloc;
381
382whereat::define_at_crate_info!(path = "zenpixels-convert/");
383
384// Re-export all interchange types from zenpixels.
385pub use zenpixels::*;
386
387// Conversion modules.
388pub(crate) mod convert;
389pub mod error;
390pub(crate) mod negotiate;
391
392pub mod adapt;
393pub mod cms;
394#[cfg(feature = "cms-moxcms")]
395pub mod cms_moxcms;
396pub mod converter;
397pub mod ext;
398pub mod gamut;
399pub mod hdr;
400pub mod oklab;
401pub mod output;
402#[cfg(feature = "pipeline")]
403pub mod pipeline;
404
405// Re-export key conversion types at crate root.
406pub use adapt::adapt_for_encode_explicit;
407pub use convert::{ConvertPlan, convert_row};
408pub use converter::RowConverter;
409pub use error::ConvertError;
410pub use negotiate::{
411    ConversionCost, ConvertIntent, FormatOption, Provenance, best_match, best_match_with,
412    conversion_cost, conversion_cost_with_provenance, ideal_format, negotiate,
413};
414#[cfg(feature = "pipeline")]
415pub use pipeline::{
416    CodecFormats, ConversionPath, FormatEntry, LossBucket, MatrixStats, OpCategory, OpRequirement,
417    PathEntry, QualityThreshold, generate_path_matrix, matrix_stats, optimal_path,
418};
419
420// Re-export extension traits.
421#[cfg(feature = "rgb")]
422pub use ext::PixelBufferConvertTypedExt;
423pub use ext::{ColorPrimariesExt, PixelBufferConvertExt, TransferFunctionExt};
424
425// Re-export gamut conversion utilities.
426pub use gamut::{
427    GamutMatrix, apply_matrix_f32, apply_matrix_row_f32, apply_matrix_row_rgba_f32,
428    conversion_matrix,
429};
430
431// Re-export HDR types and tone mapping.
432#[cfg(feature = "std")]
433pub use hdr::exposure_tonemap;
434pub use hdr::{
435    ContentLightLevel, HdrMetadata, MasteringDisplay, reinhard_inverse, reinhard_tonemap,
436};
437
438// Re-export CMS traits and implementations.
439pub use cms::{ColorManagement, RowTransform};
440#[cfg(feature = "cms-moxcms")]
441pub use cms_moxcms::MoxCms;
442
443// Re-export output types.
444pub use output::{EncodeReady, OutputMetadata, OutputProfile, finalize_for_output};