zenpixels_convert/negotiate.rs
1//! Format negotiation — pick the best conversion target.
2//!
3//! The cost model separates **effort** (computational work) from **loss**
4//! (information destroyed). The [`ConvertIntent`] controls how these axes
5//! are weighted: `Fastest` prioritizes low effort, while `LinearLight` and
6//! `Blend` prioritize low loss.
7//!
8//! Consumers that can perform conversions internally (e.g., a JPEG encoder
9//! with a fused f32→u8+DCT path) can express this via [`FormatOption`]
10//! with a custom [`ConversionCost`], so the negotiation picks their fast
11//! path instead of doing a redundant conversion.
12//!
13//! # How negotiation works
14//!
15//! For each candidate target in the supported format list, the cost model
16//! computes five independent cost components:
17//!
18//! 1. **Transfer cost**: Effort of applying/removing EOTF (sRGB→linear,
19//! PQ→linear, etc.). Unknown↔anything is free (relabeling).
20//! 2. **Depth cost**: Effort of depth conversion (u8→f32, u16→u8). Loss
21//! considers provenance — if the data was originally u8 and was widened
22//! to f32 for processing, converting back to u8 has zero loss.
23//! 3. **Layout cost**: Effort of adding/removing/swizzling channels.
24//! RGB→Gray is very lossy (500); BGRA→RGBA is cheap (5, swizzle only).
25//! 4. **Alpha cost**: Effort of alpha mode conversion. Straight→premul is
26//! cheap; premul→straight involves division (worse rounding at low alpha).
27//! 5. **Primaries cost**: Effort of gamut matrix (3×3 multiply). Loss
28//! considers provenance — narrowing to a gamut that contains the origin
29//! gamut has near-zero loss.
30//!
31//! These five costs are summed, then the consumer's cost override is added,
32//! then a suitability penalty (e.g., "u8 sRGB is bad for linear-light
33//! resize") is added to the loss axis. Finally, effort and loss are
34//! combined into a single score via [`ConvertIntent`]-specific weights.
35//! The candidate with the lowest score wins.
36//!
37//! # Provenance
38//!
39//! [`Provenance`] is the key to avoiding unnecessary quality loss. Without
40//! it, converting f32→u8 always reports high loss. With it, the cost model
41//! knows the data was originally u8 (e.g., from JPEG) and the round-trip
42//! is lossless.
43//!
44//! Update provenance when operations change the data's effective precision
45//! or gamut:
46//!
47//! - JPEG u8 → f32 for resize → back to u8: **No update needed.** Origin
48//! is still u8.
49//! - sRGB data → convert to BT.2020 → saturation boost → back to sRGB:
50//! **Call `invalidate_primaries(Bt2020)`** because the data now genuinely
51//! uses the wider gamut.
52//! - 16-bit PNG → process in f32 → back to u16: **No update needed.**
53//! Origin is still u16.
54//!
55//! # Consumer cost overrides
56//!
57//! [`FormatOption::with_cost`] lets codecs advertise fast internal paths.
58//! Example: a JPEG encoder with a fused f32→u8+DCT kernel can accept
59//! f32 data directly, saving the caller a separate f32→u8 conversion:
60//!
61//! ```rust,ignore
62//! let options = &[
63//! FormatOption::from(PixelDescriptor::RGB8_SRGB), // native: zero cost
64//! FormatOption::with_cost(
65//! PixelDescriptor::RGBF32_LINEAR,
66//! ConversionCost::new(5, 0), // fast fused path
67//! ),
68//! ];
69//! ```
70//!
71//! Without the override, the negotiator would prefer RGB8_SRGB (no
72//! conversion needed) even when the source is already f32. With the
73//! override, it sees that delivering f32 directly costs only 5 effort
74//! (the encoder's fused path) vs. 40+ effort (our f32→u8 conversion).
75//!
76//! # Suitability penalties
77//!
78//! The cost model adds quality-of-operation penalties independent of
79//! conversion cost. For example, bilinear resize in sRGB u8 produces
80//! gamma-darkening artifacts (measured ΔE ≈ 13.7) regardless of how
81//! cheap the u8→u8 identity "conversion" is. `LinearLight` intent
82//! penalizes non-linear formats by 120 loss points, steering the
83//! negotiator toward f32 linear even when u8 sRGB is cheaper to deliver.
84
85use core::ops::Add;
86
87use crate::{
88 AlphaMode, ChannelLayout, ChannelType, ColorPrimaries, PixelDescriptor, TransferFunction,
89};
90
91// ---------------------------------------------------------------------------
92// Public types
93// ---------------------------------------------------------------------------
94
95/// Tracks where pixel data came from, so the cost model can distinguish
96/// "f32 that was widened from u8 JPEG" (lossless round-trip back to u8)
97/// from "f32 that was decoded from a 16-bit EXR" (lossy truncation to u8).
98///
99/// Without provenance, `depth_cost(f32 → u8)` always reports high loss.
100/// With provenance, it can see that the data's *true* precision is u8,
101/// so the round-trip is lossless.
102///
103/// # Gamut provenance
104///
105/// `origin_primaries` tracks the gamut of the original source, enabling
106/// lossless round-trip detection for gamut conversions. For example,
107/// sRGB data placed in BT.2020 for processing can round-trip back to
108/// sRGB losslessly — but only if no operations expanded the actual color
109/// usage (e.g., saturation boost filling the wider gamut). When an
110/// operation does expand gamut usage, the caller must update provenance
111/// via [`invalidate_primaries`](Self::invalidate_primaries) to reflect
112/// that the data now genuinely uses the wider gamut.
113#[derive(Clone, Copy, Debug, PartialEq, Eq)]
114#[non_exhaustive]
115pub struct Provenance {
116 /// The channel depth of the original source data.
117 ///
118 /// For a JPEG (u8 sRGB) decoded into f32 for resize, this is `U8`.
119 /// For an EXR (f32) loaded directly, this is `F32`.
120 /// For a 16-bit PNG, this is `U16`.
121 pub origin_depth: ChannelType,
122
123 /// The color primaries of the original source data.
124 ///
125 /// For a standard sRGB JPEG, this is `Bt709`. For a Display P3 image,
126 /// this is `DisplayP3`. Used to detect when converting to a narrower
127 /// gamut is lossless (the source fits entirely within the target).
128 ///
129 /// **Important:** If an operation expands the data's gamut usage
130 /// (e.g., saturation boost in BT.2020 that pushes colors outside
131 /// the original sRGB gamut), call [`invalidate_primaries`](Self::invalidate_primaries)
132 /// to update this to the current working primaries. Otherwise the
133 /// cost model will incorrectly report the gamut narrowing as lossless.
134 pub origin_primaries: ColorPrimaries,
135}
136
137impl Provenance {
138 /// Assume the descriptor's properties *are* the true origin characteristics.
139 ///
140 /// This is the conservative default: if you don't know the data's history,
141 /// assume its current format is its true origin.
142 #[inline]
143 pub fn from_source(desc: PixelDescriptor) -> Self {
144 Self {
145 origin_depth: desc.channel_type(),
146 origin_primaries: desc.primaries,
147 }
148 }
149
150 /// Create provenance with an explicit origin depth. Primaries default to BT.709.
151 ///
152 /// Use this when the data has been widened from a known source depth.
153 /// For example, a JPEG (u8) decoded into f32 for resize:
154 ///
155 /// ```rust,ignore
156 /// let provenance = Provenance::with_origin_depth(ChannelType::U8);
157 /// ```
158 #[inline]
159 pub const fn with_origin_depth(origin_depth: ChannelType) -> Self {
160 Self {
161 origin_depth,
162 origin_primaries: ColorPrimaries::Bt709,
163 }
164 }
165
166 /// Create provenance with explicit origin depth and primaries.
167 #[inline]
168 pub const fn with_origin(origin_depth: ChannelType, origin_primaries: ColorPrimaries) -> Self {
169 Self {
170 origin_depth,
171 origin_primaries,
172 }
173 }
174
175 /// Create provenance with an explicit origin primaries. Depth defaults to
176 /// the descriptor's current channel type.
177 #[inline]
178 pub fn with_origin_primaries(desc: PixelDescriptor, primaries: ColorPrimaries) -> Self {
179 Self {
180 origin_depth: desc.channel_type(),
181 origin_primaries: primaries,
182 }
183 }
184
185 /// Mark the gamut provenance as invalid (matches current format).
186 ///
187 /// Call this after any operation that expands the data's color usage
188 /// beyond the original gamut. For example, if sRGB data is converted
189 /// to BT.2020 and then saturation is boosted to fill the wider gamut,
190 /// the origin is no longer sRGB — the data genuinely uses BT.2020.
191 ///
192 /// After this call, converting to a narrower gamut (e.g., back to sRGB)
193 /// will correctly report gamut clipping loss.
194 #[inline]
195 pub fn invalidate_primaries(&mut self, current: ColorPrimaries) {
196 self.origin_primaries = current;
197 }
198}
199
200/// What the consumer plans to do with the converted pixels.
201///
202/// Shifts the format negotiation cost model to prefer formats
203/// suited for the intended operation.
204#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
205#[non_exhaustive]
206pub enum ConvertIntent {
207 /// Minimize conversion effort. Good for encoding — the codec
208 /// already knows what it wants, just get there cheaply.
209 #[default]
210 Fastest,
211
212 /// Pixel-accurate operations that need linear light:
213 /// resize, blur, anti-aliasing, mipmap generation.
214 /// Prefers f32 Linear. Straight alpha is fine.
215 LinearLight,
216
217 /// Compositing and blending (Porter-Duff, layer merge).
218 /// Prefers f32 Linear with Premultiplied alpha.
219 Blend,
220
221 /// Perceptual adjustments: sharpening, contrast, saturation.
222 /// Prefers sRGB space (perceptually uniform).
223 Perceptual,
224}
225
226/// Two-axis conversion cost: computational effort vs. information loss.
227///
228/// These are independent concerns:
229/// - A fast conversion can be very lossy (f32 HDR → u8 sRGB clamp).
230/// - A slow conversion can be lossless (u8 sRGB → f32 Linear).
231/// - A consumer's fused path can be fast with the same loss as our path.
232///
233/// [`ConvertIntent`] controls how the two axes are weighted for ranking.
234#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
235pub struct ConversionCost {
236 /// Computational work (cycles, not quality). Lower is faster.
237 pub effort: u16,
238 /// Information destroyed (precision, gamut, channels). Lower is more faithful.
239 pub loss: u16,
240}
241
242impl ConversionCost {
243 /// Zero cost — identity conversion.
244 pub const ZERO: Self = Self { effort: 0, loss: 0 };
245
246 /// Create a cost with explicit effort and loss.
247 pub const fn new(effort: u16, loss: u16) -> Self {
248 Self { effort, loss }
249 }
250}
251
252impl Add for ConversionCost {
253 type Output = Self;
254 fn add(self, rhs: Self) -> Self {
255 Self {
256 effort: self.effort.saturating_add(rhs.effort),
257 loss: self.loss.saturating_add(rhs.loss),
258 }
259 }
260}
261
262/// A supported format with optional consumer-provided cost override.
263///
264/// Use this with [`best_match_with`] when the consumer can handle some
265/// formats more efficiently than the default conversion path.
266///
267/// # Example
268///
269/// A JPEG encoder with a fast internal f32→u8 path:
270///
271/// ```rust,ignore
272/// use zenpixels::{FormatOption, ConversionCost};
273/// use zenpixels::PixelDescriptor;
274///
275/// let options = &[
276/// FormatOption::from(PixelDescriptor::RGB8_SRGB), // native, zero cost
277/// FormatOption::with_cost(
278/// PixelDescriptor::RGBF32_LINEAR,
279/// ConversionCost::new(5, 0), // fast fused path, no extra loss
280/// ),
281/// ];
282/// ```
283#[derive(Clone, Copy, Debug)]
284pub struct FormatOption {
285 /// The pixel format the consumer can accept.
286 pub descriptor: PixelDescriptor,
287 /// Additional cost the consumer incurs to handle this format
288 /// after we deliver it. Zero for native formats.
289 pub consumer_cost: ConversionCost,
290}
291
292impl FormatOption {
293 /// Create an option with explicit consumer cost.
294 pub const fn with_cost(descriptor: PixelDescriptor, consumer_cost: ConversionCost) -> Self {
295 Self {
296 descriptor,
297 consumer_cost,
298 }
299 }
300}
301
302impl From<PixelDescriptor> for FormatOption {
303 fn from(descriptor: PixelDescriptor) -> Self {
304 Self {
305 descriptor,
306 consumer_cost: ConversionCost::ZERO,
307 }
308 }
309}
310
311// ---------------------------------------------------------------------------
312// Public functions
313// ---------------------------------------------------------------------------
314
315/// Pick the best conversion target from `supported` for the given source.
316///
317/// Returns `None` only if `supported` is empty.
318///
319/// This is the simple API — all consumer costs are assumed zero,
320/// and provenance is inferred from the source descriptor (conservative).
321/// Use [`best_match_with`] for consumer cost overrides, or
322/// [`negotiate`] for full control over provenance and consumer costs.
323pub fn best_match(
324 source: PixelDescriptor,
325 supported: &[PixelDescriptor],
326 intent: ConvertIntent,
327) -> Option<PixelDescriptor> {
328 negotiate(
329 source,
330 Provenance::from_source(source),
331 supported.iter().map(|&d| FormatOption::from(d)),
332 intent,
333 )
334}
335
336/// Pick the best conversion target from `options`, accounting for
337/// consumer-provided cost overrides.
338///
339/// Returns `None` only if `options` is empty.
340///
341/// Each [`FormatOption`] specifies a format the consumer can accept
342/// and what it costs the consumer to handle that format internally.
343/// The total cost is `our_conversion + consumer_cost`, weighted by intent.
344///
345/// Provenance is inferred from the source descriptor. Use [`negotiate`]
346/// when the data has been widened from a lower-precision origin.
347pub fn best_match_with(
348 source: PixelDescriptor,
349 options: &[FormatOption],
350 intent: ConvertIntent,
351) -> Option<PixelDescriptor> {
352 negotiate(
353 source,
354 Provenance::from_source(source),
355 options.iter().copied(),
356 intent,
357 )
358}
359
360/// Fully-parameterized format negotiation.
361///
362/// This is the most flexible entry point: it takes explicit provenance
363/// (so the cost model knows the data's true origin precision) and
364/// consumer cost overrides (so fused conversion paths are accounted for).
365///
366/// # When to use
367///
368/// Use this when the current pixel format doesn't represent the data's
369/// true precision. For example, a JPEG image (u8 sRGB) decoded into f32
370/// for gamma-correct resize: the data is *currently* f32, but its origin
371/// precision is u8. Converting back to u8 for JPEG encoding is lossless
372/// (within ±1 LSB), and the cost model should reflect that.
373///
374/// ```rust,ignore
375/// let provenance = Provenance::with_origin_depth(ChannelType::U8);
376/// let target = negotiate(
377/// current_f32_desc,
378/// provenance,
379/// encoder_options.iter().copied(),
380/// ConvertIntent::Fastest,
381/// );
382/// ```
383pub fn negotiate(
384 source: PixelDescriptor,
385 provenance: Provenance,
386 options: impl Iterator<Item = FormatOption>,
387 intent: ConvertIntent,
388) -> Option<PixelDescriptor> {
389 best_of(
390 source,
391 provenance,
392 options.map(|o| (o.descriptor, o.consumer_cost)),
393 intent,
394 )
395}
396
397/// Recommend the ideal working format for a given intent, based on the source format.
398///
399/// Unlike [`best_match`], this isn't constrained to a list — it returns what
400/// the consumer *should* be working in for optimal results.
401///
402/// Key principles:
403/// - **Fastest** preserves the source format (identity).
404/// - **LinearLight** upgrades to f32 Linear for gamma-correct operations.
405/// - **Blend** upgrades to f32 Linear with premultiplied alpha.
406/// - **Perceptual** keeps u8 sRGB for SDR-8 sources (LUT-fast), upgrades others to f32 sRGB.
407/// - Never downgrades precision — a u16 source won't be recommended as u8.
408pub fn ideal_format(source: PixelDescriptor, intent: ConvertIntent) -> PixelDescriptor {
409 match intent {
410 ConvertIntent::Fastest => source,
411
412 ConvertIntent::LinearLight => {
413 if source.channel_type() == ChannelType::F32
414 && source.transfer() == TransferFunction::Linear
415 {
416 return source;
417 }
418 PixelDescriptor::new(
419 ChannelType::F32,
420 source.layout(),
421 source.alpha(),
422 TransferFunction::Linear,
423 )
424 }
425
426 ConvertIntent::Blend => {
427 let alpha = if source.layout().has_alpha() {
428 Some(AlphaMode::Premultiplied)
429 } else {
430 source.alpha()
431 };
432 if source.channel_type() == ChannelType::F32
433 && source.transfer() == TransferFunction::Linear
434 && source.alpha() == alpha
435 {
436 return source;
437 }
438 PixelDescriptor::new(
439 ChannelType::F32,
440 source.layout(),
441 alpha,
442 TransferFunction::Linear,
443 )
444 }
445
446 ConvertIntent::Perceptual => {
447 let tier = precision_tier(source);
448 match tier {
449 PrecisionTier::Sdr8 => {
450 if source.transfer() == TransferFunction::Srgb
451 || source.transfer() == TransferFunction::Unknown
452 {
453 return source;
454 }
455 PixelDescriptor::new(
456 ChannelType::U8,
457 source.layout(),
458 source.alpha(),
459 TransferFunction::Srgb,
460 )
461 }
462 _ => PixelDescriptor::new(
463 ChannelType::F32,
464 source.layout(),
465 source.alpha(),
466 TransferFunction::Srgb,
467 ),
468 }
469 }
470 }
471}
472
473/// Compute the two-axis conversion cost for `from` → `to`.
474///
475/// This is the cost of *our* conversion kernels — it doesn't include
476/// any consumer-side cost. Intent-independent.
477///
478/// Provenance is inferred from `from` (conservative: assumes current
479/// depth is the true origin). Use [`conversion_cost_with_provenance`]
480/// when the data has been widened from a lower-precision source.
481#[must_use]
482pub fn conversion_cost(from: PixelDescriptor, to: PixelDescriptor) -> ConversionCost {
483 conversion_cost_with_provenance(from, to, Provenance::from_source(from))
484}
485
486/// Compute the two-axis conversion cost with explicit provenance.
487///
488/// Like [`conversion_cost`], but uses the provided [`Provenance`] to
489/// determine whether a depth narrowing is actually lossy. For example,
490/// `f32 → u8` reports zero loss when `provenance.origin_depth == U8`,
491/// because the data was originally u8 and the round-trip is lossless.
492#[must_use]
493pub fn conversion_cost_with_provenance(
494 from: PixelDescriptor,
495 to: PixelDescriptor,
496 provenance: Provenance,
497) -> ConversionCost {
498 transfer_cost(from.transfer(), to.transfer())
499 + depth_cost(
500 from.channel_type(),
501 to.channel_type(),
502 provenance.origin_depth,
503 )
504 + layout_cost(from.layout(), to.layout())
505 + alpha_cost(from.alpha(), to.alpha(), from.layout(), to.layout())
506 + primaries_cost(from.primaries, to.primaries, provenance.origin_primaries)
507}
508
509// ---------------------------------------------------------------------------
510// Internal scoring
511// ---------------------------------------------------------------------------
512
513/// Score a candidate target for ranking. Lower is better.
514pub(crate) fn score_target(
515 source: PixelDescriptor,
516 provenance: Provenance,
517 target: PixelDescriptor,
518 consumer_cost: ConversionCost,
519 intent: ConvertIntent,
520) -> u32 {
521 let our_cost = conversion_cost_with_provenance(source, target, provenance);
522 let total_effort = our_cost.effort as u32 + consumer_cost.effort as u32;
523 let total_loss =
524 our_cost.loss as u32 + consumer_cost.loss as u32 + suitability_loss(target, intent) as u32;
525 weighted_score(total_effort, total_loss, intent)
526}
527
528/// Find the best target from an iterator of (descriptor, consumer_cost) pairs.
529fn best_of(
530 source: PixelDescriptor,
531 provenance: Provenance,
532 options: impl Iterator<Item = (PixelDescriptor, ConversionCost)>,
533 intent: ConvertIntent,
534) -> Option<PixelDescriptor> {
535 let mut best: Option<(PixelDescriptor, u32)> = None;
536
537 for (target, consumer_cost) in options {
538 let score = score_target(source, provenance, target, consumer_cost, intent);
539 match best {
540 Some((_, best_score)) if score < best_score => best = Some((target, score)),
541 None => best = Some((target, score)),
542 _ => {}
543 }
544 }
545
546 best.map(|(desc, _)| desc)
547}
548
549/// Blend effort and loss into a single ranking score based on intent.
550///
551/// - `Fastest`: effort matters 4x more than loss
552/// - `LinearLight`/`Blend`: loss matters 4x more than effort
553/// - `Perceptual`: loss matters 3x more than effort
554pub(crate) fn weighted_score(effort: u32, loss: u32, intent: ConvertIntent) -> u32 {
555 match intent {
556 ConvertIntent::Fastest => effort * 4 + loss,
557 ConvertIntent::LinearLight | ConvertIntent::Blend => effort + loss * 4,
558 ConvertIntent::Perceptual => effort + loss * 3,
559 }
560}
561
562/// How unsuitable a target format is for the given intent.
563///
564/// This is a quality-of-operation penalty, not a conversion penalty.
565/// For example, u8 data processed with LinearLight resize produces
566/// gamma-incorrect results — that's a quality loss independent of
567/// how cheap the u8→u8 identity conversion is.
568pub(crate) fn suitability_loss(target: PixelDescriptor, intent: ConvertIntent) -> u16 {
569 match intent {
570 ConvertIntent::Fastest => 0,
571 ConvertIntent::LinearLight => linear_light_suitability(target),
572 ConvertIntent::Blend => {
573 let mut s = linear_light_suitability(target);
574 // Straight alpha requires per-pixel division during compositing.
575 // Calibrated: blending in straight alpha causes severe fringe artifacts
576 // at semi-transparent edges (measured ΔE=17.2, High bucket).
577 if target.layout().has_alpha() && target.alpha() == Some(AlphaMode::Straight) {
578 s += 200;
579 }
580 s
581 }
582 ConvertIntent::Perceptual => perceptual_suitability(target),
583 }
584}
585
586/// Suitability penalty for LinearLight operations (resize, blur).
587/// f32 Linear is ideal; any gamma-encoded format produces gamma
588/// darkening artifacts that dominate over quantization.
589///
590/// # Calibration notes (CIEDE2000 measurements)
591///
592/// **Non-linear (gamma-encoded) formats:**
593/// Bilinear resize in sRGB measures p95 ΔE ≈ 13.7 regardless of bit depth
594/// (u8=13.7, u16=13.7, f16=13.7, f32 sRGB=13.7).
595/// Gamma darkening is the dominant error — precision barely matters.
596///
597/// **Linear formats:**
598/// Only quantization matters. f32=0, f16=0.022, u8=0.213.
599#[allow(unreachable_patterns)] // non_exhaustive: future variants
600fn linear_light_suitability(target: PixelDescriptor) -> u16 {
601 if target.transfer() == TransferFunction::Linear {
602 // Linear space: only quantization error.
603 match target.channel_type() {
604 ChannelType::F32 => 0,
605 ChannelType::F16 => 5, // 10 mantissa bits, measured ΔE=0.022
606 ChannelType::U16 => 5, // 16 bits, negligible quantization
607 ChannelType::U8 => 40, // severe banding in darks, measured ΔE=0.213
608 _ => 50,
609 }
610 } else {
611 // Non-linear (sRGB, BT.709, PQ, HLG): gamma darkening dominates.
612 // All measure p95 ΔE ≈ 13.7 for resize regardless of precision.
613 120
614 }
615}
616
617/// Suitability penalty for perceptual operations (sharpening, color grading).
618/// sRGB-encoded data is ideal; Linear f32 is slightly off.
619fn perceptual_suitability(target: PixelDescriptor) -> u16 {
620 if target.channel_type() == ChannelType::F32 && target.transfer() == TransferFunction::Linear {
621 return 15;
622 }
623 if matches!(
624 target.transfer(),
625 TransferFunction::Pq | TransferFunction::Hlg
626 ) {
627 return 10;
628 }
629 0
630}
631
632// ---------------------------------------------------------------------------
633// Component cost functions (effort + loss)
634// ---------------------------------------------------------------------------
635
636/// Cost of transfer function conversion.
637fn transfer_cost(from: TransferFunction, to: TransferFunction) -> ConversionCost {
638 if from == to {
639 return ConversionCost::ZERO;
640 }
641 match (from, to) {
642 // Unknown → anything: relabeling, no actual math.
643 (TransferFunction::Unknown, _) | (_, TransferFunction::Unknown) => {
644 ConversionCost::new(1, 0)
645 }
646
647 // sRGB ↔ Linear: well-optimized EOTF/OETF, lossless in f32.
648 (TransferFunction::Srgb, TransferFunction::Linear)
649 | (TransferFunction::Linear, TransferFunction::Srgb) => ConversionCost::new(5, 0),
650
651 // BT.709 ↔ sRGB/Linear: slightly different curve, near-lossless.
652 (TransferFunction::Bt709, TransferFunction::Srgb)
653 | (TransferFunction::Srgb, TransferFunction::Bt709)
654 | (TransferFunction::Bt709, TransferFunction::Linear)
655 | (TransferFunction::Linear, TransferFunction::Bt709) => ConversionCost::new(8, 0),
656
657 // PQ/HLG ↔ anything else: expensive and lossy (range/gamut clipping).
658 _ => ConversionCost::new(80, 300),
659 }
660}
661
662/// Cost of channel depth conversion, considering the data's origin precision.
663///
664/// The `origin_depth` comes from [`Provenance`] and tells us the true
665/// precision of the data. This matters because:
666///
667/// - `f32 → u8` with origin `U8`: the data was u8 JPEG decoded to f32
668/// for processing. Round-trip back to u8 is lossless (±1 LSB).
669/// - `f32 → u8` with origin `F32`: the data has true f32 precision
670/// (e.g., EXR or HDR AVIF). Truncating to u8 destroys highlight detail.
671///
672/// **Rule:** If `target depth >= origin depth`, the narrowing has zero loss
673/// because the target can represent everything the original data contained.
674/// The effort cost is always based on the *current* depth conversion work.
675fn depth_cost(from: ChannelType, to: ChannelType, origin_depth: ChannelType) -> ConversionCost {
676 if from == to {
677 return ConversionCost::ZERO;
678 }
679
680 let effort = depth_effort(from, to);
681 let loss = depth_loss(to, origin_depth);
682
683 ConversionCost::new(effort, loss)
684}
685
686/// Computational effort for a depth conversion (independent of provenance).
687#[allow(unreachable_patterns)] // non_exhaustive: future variants
688fn depth_effort(from: ChannelType, to: ChannelType) -> u16 {
689 match (from, to) {
690 // Integer widen/narrow
691 (ChannelType::U8, ChannelType::U16) | (ChannelType::U16, ChannelType::U8) => 10,
692 // Float ↔ integer
693 (ChannelType::U16, ChannelType::F32) | (ChannelType::F32, ChannelType::U16) => 25,
694 (ChannelType::U8, ChannelType::F32) | (ChannelType::F32, ChannelType::U8) => 40,
695 // F16 ↔ F32 (hardware or fast table conversion)
696 (ChannelType::F16, ChannelType::F32) | (ChannelType::F32, ChannelType::F16) => 15,
697 // F16 ↔ integer (via F32 intermediate)
698 (ChannelType::F16, ChannelType::U8) | (ChannelType::U8, ChannelType::F16) => 30,
699 (ChannelType::F16, ChannelType::U16) | (ChannelType::U16, ChannelType::F16) => 25,
700 // remaining catch-all handles unknown future types
701 _ => 100,
702 }
703}
704
705/// Information loss when converting to `target_depth`, given the data's
706/// `origin_depth`. If the target can represent the origin precision,
707/// loss is zero.
708#[allow(unreachable_patterns)] // non_exhaustive: future variants
709fn depth_loss(target_depth: ChannelType, origin_depth: ChannelType) -> u16 {
710 let target_bits = channel_bits(target_depth);
711 let origin_bits = channel_bits(origin_depth);
712
713 if target_bits >= origin_bits {
714 // Target can hold everything the origin had — no loss.
715 return 0;
716 }
717
718 // Target has less precision than the origin — lossy.
719 //
720 // Calibrated from CIEDE2000 measurements (perceptual_loss.rs).
721 // In sRGB space, quantization to 8 bits produces ΔE < 0.5 (below JND)
722 // because sRGB OETF provides perceptually uniform quantization.
723 // The suitability_loss function handles the separate concern of
724 // operating in a lower-precision format (gamma darkening in u8, etc).
725 match (origin_depth, target_depth) {
726 (ChannelType::U16, ChannelType::U8) => 10, // measured ΔE=0.14, sub-JND
727 (ChannelType::F32, ChannelType::U8) => 10, // measured ΔE=0.14, sub-JND in sRGB
728 (ChannelType::F32, ChannelType::U16) => 5, // 23→16 mantissa bits, negligible
729 (ChannelType::F32, ChannelType::F16) => 20, // 23→10 mantissa bits, small loss
730 (ChannelType::F16, ChannelType::U8) => 8, // measured ΔE=0.000 (f16 >8 bits precision)
731 (ChannelType::U16, ChannelType::F16) => 30, // 16→10 mantissa bits, moderate loss
732 _ => 50,
733 }
734}
735
736/// Nominal precision bits for a channel type (for ordering, not bit-exact).
737///
738/// F16 has 10 mantissa bits (~3.3 decimal digits) — between U8 (8 bits) and
739/// U16 (16 bits).
740#[allow(unreachable_patterns)] // non_exhaustive: future variants
741pub(crate) fn channel_bits(ct: ChannelType) -> u16 {
742 match ct {
743 ChannelType::U8 => 8,
744 ChannelType::F16 => 11, // 10 mantissa + 1 implicit
745 ChannelType::U16 => 16,
746 ChannelType::F32 => 32,
747 _ => 0,
748 }
749}
750
751/// Cost of color primaries (gamut) conversion.
752///
753/// Gamut hierarchy: BT.2020 ⊃ Display P3 ⊃ BT.709/sRGB.
754///
755/// Key principle: narrowing is only lossy if the data actually uses the
756/// wider gamut. Provenance tracks whether the data originally came from
757/// a narrower gamut (and hasn't been modified to use the wider one).
758fn primaries_cost(
759 from: ColorPrimaries,
760 to: ColorPrimaries,
761 origin: ColorPrimaries,
762) -> ConversionCost {
763 if from == to {
764 return ConversionCost::ZERO;
765 }
766
767 // Unknown ↔ anything: relabeling only, no actual math.
768 if matches!(from, ColorPrimaries::Unknown) || matches!(to, ColorPrimaries::Unknown) {
769 return ConversionCost::new(1, 0);
770 }
771
772 // Widening (e.g., sRGB → P3 → BT.2020): 3x3 matrix, lossless.
773 if to.contains(from) {
774 return ConversionCost::new(10, 0);
775 }
776
777 // Narrowing (e.g., BT.2020 → sRGB): check if origin fits in target.
778 // If the data originally came from sRGB and was placed in BT.2020
779 // without modifying colors, converting back to sRGB is near-lossless
780 // (only numerical precision of the 3x3 matrix round-trip).
781 if to.contains(origin) {
782 return ConversionCost::new(10, 5);
783 }
784
785 // True gamut clipping: data uses the wider gamut and target is narrower.
786 // Loss depends on how much wider the source is.
787 match (from, to) {
788 (ColorPrimaries::DisplayP3, ColorPrimaries::Bt709) => ConversionCost::new(15, 80),
789 (ColorPrimaries::Bt2020, ColorPrimaries::DisplayP3) => ConversionCost::new(15, 100),
790 (ColorPrimaries::Bt2020, ColorPrimaries::Bt709) => ConversionCost::new(15, 200),
791 _ => ConversionCost::new(15, 150),
792 }
793}
794
795/// Cost of layout conversion.
796fn layout_cost(from: ChannelLayout, to: ChannelLayout) -> ConversionCost {
797 if from == to {
798 return ConversionCost::ZERO;
799 }
800 match (from, to) {
801 // Swizzle: cheap, lossless.
802 (ChannelLayout::Bgra, ChannelLayout::Rgba) | (ChannelLayout::Rgba, ChannelLayout::Bgra) => {
803 ConversionCost::new(5, 0)
804 }
805
806 // Add alpha: cheap, lossless (fill opaque).
807 (ChannelLayout::Rgb, ChannelLayout::Rgba) | (ChannelLayout::Rgb, ChannelLayout::Bgra) => {
808 ConversionCost::new(10, 0)
809 }
810
811 // Drop alpha: cheap but lossy (alpha channel destroyed).
812 (ChannelLayout::Rgba, ChannelLayout::Rgb) | (ChannelLayout::Bgra, ChannelLayout::Rgb) => {
813 ConversionCost::new(15, 50)
814 }
815
816 // Gray → RGB: replicate, lossless.
817 (ChannelLayout::Gray, ChannelLayout::Rgb) => ConversionCost::new(8, 0),
818 (ChannelLayout::Gray, ChannelLayout::Rgba) | (ChannelLayout::Gray, ChannelLayout::Bgra) => {
819 ConversionCost::new(10, 0)
820 }
821
822 // Color → Gray: luma calculation, very lossy (color info destroyed).
823 (ChannelLayout::Rgb, ChannelLayout::Gray)
824 | (ChannelLayout::Rgba, ChannelLayout::Gray)
825 | (ChannelLayout::Bgra, ChannelLayout::Gray) => ConversionCost::new(30, 500),
826
827 // GrayAlpha → RGBA: replicate gray, lossless.
828 (ChannelLayout::GrayAlpha, ChannelLayout::Rgba)
829 | (ChannelLayout::GrayAlpha, ChannelLayout::Bgra) => ConversionCost::new(15, 0),
830
831 // RGBA → GrayAlpha: luma + drop color, very lossy.
832 (ChannelLayout::Rgba, ChannelLayout::GrayAlpha)
833 | (ChannelLayout::Bgra, ChannelLayout::GrayAlpha) => ConversionCost::new(30, 500),
834
835 // Gray ↔ GrayAlpha.
836 (ChannelLayout::Gray, ChannelLayout::GrayAlpha) => ConversionCost::new(8, 0),
837 (ChannelLayout::GrayAlpha, ChannelLayout::Gray) => ConversionCost::new(10, 50),
838
839 // GrayAlpha → Rgb: replicate + drop alpha.
840 (ChannelLayout::GrayAlpha, ChannelLayout::Rgb) => ConversionCost::new(12, 50),
841
842 // RGB ↔ Oklab: matrix + cube root, lossless at f32.
843 (ChannelLayout::Rgb, ChannelLayout::Oklab) | (ChannelLayout::Oklab, ChannelLayout::Rgb) => {
844 ConversionCost::new(80, 0)
845 }
846 (ChannelLayout::Rgba, ChannelLayout::OklabA)
847 | (ChannelLayout::OklabA, ChannelLayout::Rgba) => ConversionCost::new(80, 0),
848
849 // Oklab ↔ alpha variants.
850 (ChannelLayout::Oklab, ChannelLayout::OklabA) => ConversionCost::new(10, 0),
851 (ChannelLayout::OklabA, ChannelLayout::Oklab) => ConversionCost::new(15, 50),
852
853 // Cross-model with alpha changes.
854 (ChannelLayout::Rgb, ChannelLayout::OklabA) => ConversionCost::new(90, 0),
855 (ChannelLayout::OklabA, ChannelLayout::Rgb) => ConversionCost::new(90, 50),
856 (ChannelLayout::Oklab, ChannelLayout::Rgba) => ConversionCost::new(90, 0),
857 (ChannelLayout::Rgba, ChannelLayout::Oklab) => ConversionCost::new(90, 50),
858
859 _ => ConversionCost::new(100, 500),
860 }
861}
862
863/// Cost of alpha mode conversion.
864fn alpha_cost(
865 from_alpha: Option<AlphaMode>,
866 to_alpha: Option<AlphaMode>,
867 from_layout: ChannelLayout,
868 to_layout: ChannelLayout,
869) -> ConversionCost {
870 if !to_layout.has_alpha() || !from_layout.has_alpha() || from_alpha == to_alpha {
871 return ConversionCost::ZERO;
872 }
873 match (from_alpha, to_alpha) {
874 // Straight → Premul: per-pixel multiply, tiny rounding loss.
875 (Some(AlphaMode::Straight), Some(AlphaMode::Premultiplied)) => ConversionCost::new(20, 5),
876 // Premul → Straight: per-pixel divide, worse rounding at low alpha.
877 (Some(AlphaMode::Premultiplied), Some(AlphaMode::Straight)) => ConversionCost::new(25, 10),
878 _ => ConversionCost::ZERO,
879 }
880}
881
882// ---------------------------------------------------------------------------
883// Precision tier (used by ideal_format only)
884// ---------------------------------------------------------------------------
885
886#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
887enum PrecisionTier {
888 Sdr8 = 0,
889 Sdr16 = 1,
890 LinearF32 = 2,
891 Hdr = 3,
892}
893
894#[allow(unreachable_patterns)] // non_exhaustive: future variants
895fn precision_tier(desc: PixelDescriptor) -> PrecisionTier {
896 if matches!(
897 desc.transfer(),
898 TransferFunction::Pq | TransferFunction::Hlg
899 ) {
900 return PrecisionTier::Hdr;
901 }
902 match desc.channel_type() {
903 ChannelType::U8 => PrecisionTier::Sdr8,
904 ChannelType::U16 | ChannelType::F16 => PrecisionTier::Sdr16,
905 ChannelType::F32 => PrecisionTier::LinearF32,
906 _ => PrecisionTier::Sdr8,
907 }
908}
909
910// ---------------------------------------------------------------------------
911// Tests
912// ---------------------------------------------------------------------------
913
914#[cfg(test)]
915mod tests {
916 use super::*;
917
918 #[test]
919 fn exact_match_wins() {
920 let src = PixelDescriptor::RGB8_SRGB;
921 let supported = &[PixelDescriptor::RGBA8_SRGB, PixelDescriptor::RGB8_SRGB];
922 assert_eq!(
923 best_match(src, supported, ConvertIntent::Fastest),
924 Some(PixelDescriptor::RGB8_SRGB)
925 );
926 }
927
928 #[test]
929 fn empty_list_returns_none() {
930 let src = PixelDescriptor::RGB8_SRGB;
931 assert_eq!(best_match(src, &[], ConvertIntent::Fastest), None);
932 }
933
934 #[test]
935 fn prefers_same_depth_over_cross_depth() {
936 let src = PixelDescriptor::RGB8_SRGB;
937 let supported = &[PixelDescriptor::RGBF32_LINEAR, PixelDescriptor::RGBA8_SRGB];
938 assert_eq!(
939 best_match(src, supported, ConvertIntent::Fastest),
940 Some(PixelDescriptor::RGBA8_SRGB)
941 );
942 }
943
944 #[test]
945 fn bgra_rgba_swizzle_is_cheap() {
946 let src = PixelDescriptor::BGRA8_SRGB;
947 let supported = &[PixelDescriptor::RGB8_SRGB, PixelDescriptor::RGBA8_SRGB];
948 assert_eq!(
949 best_match(src, supported, ConvertIntent::Fastest),
950 Some(PixelDescriptor::RGBA8_SRGB)
951 );
952 }
953
954 #[test]
955 fn gray_to_rgb_preferred_over_rgba() {
956 let src = PixelDescriptor::GRAY8_SRGB;
957 let supported = &[PixelDescriptor::RGBA8_SRGB, PixelDescriptor::RGB8_SRGB];
958 assert_eq!(
959 best_match(src, supported, ConvertIntent::Fastest),
960 Some(PixelDescriptor::RGB8_SRGB)
961 );
962 }
963
964 #[test]
965 fn transfer_only_diff_is_cheap() {
966 let src = PixelDescriptor::new(
967 ChannelType::U8,
968 ChannelLayout::Rgb,
969 None,
970 TransferFunction::Unknown,
971 );
972 let target = PixelDescriptor::RGB8_SRGB;
973 let supported = &[target, PixelDescriptor::RGBF32_LINEAR];
974 assert_eq!(
975 best_match(src, supported, ConvertIntent::Fastest),
976 Some(target)
977 );
978 }
979
980 // Two-axis cost tests.
981
982 #[test]
983 fn conversion_cost_identity_is_zero() {
984 let cost = conversion_cost(PixelDescriptor::RGB8_SRGB, PixelDescriptor::RGB8_SRGB);
985 assert_eq!(cost, ConversionCost::ZERO);
986 }
987
988 #[test]
989 fn widening_has_zero_loss() {
990 let cost = conversion_cost(PixelDescriptor::RGB8_SRGB, PixelDescriptor::RGBF32_LINEAR);
991 assert_eq!(cost.loss, 0);
992 assert!(cost.effort > 0);
993 }
994
995 #[test]
996 fn narrowing_has_nonzero_loss() {
997 let cost = conversion_cost(PixelDescriptor::RGBF32_LINEAR, PixelDescriptor::RGB8_SRGB);
998 assert!(cost.loss > 0, "f32→u8 should report data loss");
999 assert!(cost.effort > 0);
1000 }
1001
1002 #[test]
1003 fn consumer_override_shifts_preference() {
1004 // Source is f32 Linear. Without consumer cost, we'd need to convert to u8.
1005 // With a consumer that accepts f32 cheaply, we skip our conversion.
1006 let src = PixelDescriptor::RGBF32_LINEAR;
1007 let options = &[
1008 FormatOption::from(PixelDescriptor::RGB8_SRGB),
1009 FormatOption::with_cost(PixelDescriptor::RGBF32_LINEAR, ConversionCost::new(5, 0)),
1010 ];
1011 // Even Fastest should prefer the zero-conversion path with low consumer cost.
1012 assert_eq!(
1013 best_match_with(src, options, ConvertIntent::Fastest),
1014 Some(PixelDescriptor::RGBF32_LINEAR)
1015 );
1016 }
1017}