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 icc_profiles;
401pub mod oklab;
402pub mod output;
403#[cfg(feature = "pipeline")]
404pub mod pipeline;
405
406// Re-export key conversion types at crate root.
407pub use adapt::adapt_for_encode_explicit;
408pub use convert::{ConvertPlan, convert_row};
409pub use converter::RowConverter;
410pub use error::ConvertError;
411pub use negotiate::{
412 ConversionCost, ConvertIntent, FormatOption, Provenance, best_match, best_match_with,
413 conversion_cost, conversion_cost_with_provenance, ideal_format, negotiate,
414};
415#[cfg(feature = "pipeline")]
416pub use pipeline::{
417 CodecFormats, ConversionPath, FormatEntry, LossBucket, MatrixStats, OpCategory, OpRequirement,
418 PathEntry, QualityThreshold, generate_path_matrix, matrix_stats, optimal_path,
419};
420
421// Re-export extension traits.
422#[cfg(feature = "rgb")]
423pub use ext::PixelBufferConvertTypedExt;
424pub use ext::{ColorPrimariesExt, PixelBufferConvertExt, TransferFunctionExt};
425
426// Re-export gamut conversion utilities.
427pub use gamut::{
428 GamutMatrix, apply_matrix_f32, apply_matrix_row_f32, apply_matrix_row_rgba_f32,
429 conversion_matrix,
430};
431
432// Re-export HDR types and tone mapping.
433#[cfg(feature = "std")]
434pub use hdr::exposure_tonemap;
435pub use hdr::{
436 ContentLightLevel, HdrMetadata, MasteringDisplay, reinhard_inverse, reinhard_tonemap,
437};
438
439// Re-export CMS traits and implementations.
440pub use cms::{ColorManagement, RowTransform};
441#[cfg(feature = "cms-moxcms")]
442pub use cms_moxcms::MoxCms;
443
444// Re-export output types.
445pub use output::{EncodeReady, OutputMetadata, OutputProfile, finalize_for_output};