Skip to main content

zenpixels_convert/
path.rs

1//! Conversion path solver — find the optimal (source, operation, output) path.
2//!
3//! Given a source format, an operation category, and an output format, the solver
4//! finds the cheapest conversion chain that satisfies quality constraints.
5//!
6//! The algorithm:
7//! 1. Get the operation's [`OpRequirement`](crate::OpRequirement)
8//! 2. Generate candidate working formats
9//! 3. For each candidate: compute source→working cost + suitability + working→output cost
10//! 4. Filter by quality threshold
11//! 5. Pick lowest total cost among qualifying paths
12
13use alloc::vec::Vec;
14
15use crate::PixelDescriptor;
16use crate::negotiate::{
17    ConversionCost, Provenance, conversion_cost_with_provenance, suitability_loss, weighted_score,
18};
19use crate::op_format::OpCategory;
20use crate::registry::{CodecFormats, FormatEntry};
21
22/// Perceptual loss buckets, calibrated against CIEDE2000 measurements.
23///
24/// Promoted from test-only type to public API for use with [`QualityThreshold`].
25#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
26pub enum LossBucket {
27    /// ΔE < 0.5 — below just-noticeable difference. Model loss ≤ 10.
28    Lossless,
29    /// ΔE 0.5–2.0 — visible only in side-by-side comparison. Model loss 11–50.
30    NearLossless,
31    /// ΔE 2.0–5.0 — minor visible differences. Model loss 51–150.
32    LowLoss,
33    /// ΔE 5.0–15.0 — clearly visible quality difference. Model loss 151–400.
34    Moderate,
35    /// ΔE > 15.0 — severe quality degradation. Model loss > 400.
36    High,
37}
38
39impl LossBucket {
40    /// Classify a model loss value into a bucket.
41    pub fn from_model_loss(loss: u16) -> Self {
42        if loss <= 10 {
43            Self::Lossless
44        } else if loss <= 50 {
45            Self::NearLossless
46        } else if loss <= 150 {
47            Self::LowLoss
48        } else if loss <= 400 {
49            Self::Moderate
50        } else {
51            Self::High
52        }
53    }
54
55    /// Maximum model loss value for this bucket.
56    pub fn max_loss(self) -> u16 {
57        match self {
58            Self::Lossless => 10,
59            Self::NearLossless => 50,
60            Self::LowLoss => 150,
61            Self::Moderate => 400,
62            Self::High => u16::MAX,
63        }
64    }
65}
66
67/// Quality threshold for path selection.
68#[derive(Clone, Copy, Debug)]
69pub enum QualityThreshold {
70    /// Zero information loss (ULP-proven round-trip where available).
71    Lossless,
72    /// Below JND (ΔE < 0.5, model loss ≤ 10).
73    SubPerceptual,
74    /// Minimal visible loss (ΔE < 2.0, model loss ≤ 50).
75    NearLossless,
76    /// Fastest path within the given loss bucket.
77    MaxBucket(LossBucket),
78}
79
80impl QualityThreshold {
81    /// Maximum allowed total loss for this threshold.
82    fn max_loss(self) -> u16 {
83        match self {
84            Self::Lossless => 0,
85            Self::SubPerceptual => 10,
86            Self::NearLossless => 50,
87            Self::MaxBucket(bucket) => bucket.max_loss(),
88        }
89    }
90}
91
92/// A complete conversion path through the pipeline.
93#[derive(Clone, Debug)]
94pub struct ConversionPath {
95    /// Source format (from decoder).
96    pub source_format: PixelDescriptor,
97    /// Convert source to this format before the operation.
98    pub working_format: PixelDescriptor,
99    /// Convert working format to this for the encoder.
100    pub output_format: PixelDescriptor,
101    /// Cost of source → working conversion.
102    pub source_to_working: ConversionCost,
103    /// Suitability loss of the working format for the operation.
104    pub working_suitability: u16,
105    /// Cost of working → output conversion.
106    pub working_to_output: ConversionCost,
107    /// Total weighted score (lower is better).
108    pub total_score: u32,
109    /// Total loss across all conversions + suitability.
110    pub total_loss: u16,
111    /// Whether this path is proven lossless by ULP test.
112    pub proven_lossless: bool,
113}
114
115impl ConversionPath {
116    /// The loss bucket this path falls into.
117    pub fn loss_bucket(&self) -> LossBucket {
118        LossBucket::from_model_loss(self.total_loss)
119    }
120}
121
122/// Find the optimal conversion path for a (source, operation, output) triple.
123///
124/// Returns `None` if no path satisfies the quality threshold.
125///
126/// # Arguments
127///
128/// * `source` - Source pixel format (from decoder)
129/// * `provenance` - Origin precision of the source data
130/// * `operation` - What operation will be performed
131/// * `output` - Target pixel format (for encoder)
132/// * `threshold` - Maximum acceptable quality loss
133pub fn optimal_path(
134    source: PixelDescriptor,
135    provenance: Provenance,
136    operation: OpCategory,
137    output: PixelDescriptor,
138    threshold: QualityThreshold,
139) -> Option<ConversionPath> {
140    let intent = operation.to_intent();
141    let candidates = operation.candidate_working_formats(source);
142    let max_loss = threshold.max_loss();
143
144    let mut best: Option<ConversionPath> = None;
145
146    for working in candidates {
147        let s2w = conversion_cost_with_provenance(source, working, provenance);
148        let suit = suitability_loss(working, intent);
149        let w2o = conversion_cost_with_provenance(
150            working,
151            output,
152            provenance_after_operation(provenance, working),
153        );
154
155        let total_loss = s2w.loss.saturating_add(suit).saturating_add(w2o.loss);
156
157        // Filter by threshold.
158        if total_loss > max_loss {
159            continue;
160        }
161
162        let total_effort = s2w.effort as u32 + w2o.effort as u32;
163        let total_score = weighted_score(total_effort, total_loss as u32 + suit as u32, intent);
164
165        let path = ConversionPath {
166            source_format: source,
167            working_format: working,
168            output_format: output,
169            source_to_working: s2w,
170            working_suitability: suit,
171            working_to_output: w2o,
172            total_score,
173            total_loss,
174            proven_lossless: false,
175        };
176
177        match &best {
178            Some(current) if path.total_score < current.total_score => best = Some(path),
179            None => best = Some(path),
180            _ => {}
181        }
182    }
183
184    best
185}
186
187/// Compute provenance after the operation transforms data.
188///
189/// After an operation processes data in the working format, the provenance
190/// origin depth is the *working format's* depth (the operation "consumes"
191/// the original precision and produces new data at working precision).
192fn provenance_after_operation(original: Provenance, working: PixelDescriptor) -> Provenance {
193    Provenance::with_origin(working.channel_type(), original.origin_primaries)
194}
195
196/// An entry in the full path matrix.
197#[derive(Clone, Debug)]
198pub struct PathEntry {
199    /// Source codec name.
200    pub source_codec: &'static str,
201    /// Source pixel format.
202    pub source_format: PixelDescriptor,
203    /// Effective bits of the source data.
204    pub source_effective_bits: u8,
205    /// Operation category.
206    pub operation: OpCategory,
207    /// Output codec name.
208    pub output_codec: &'static str,
209    /// Output pixel format.
210    pub output_format: PixelDescriptor,
211    /// The optimal conversion path (None if no path within threshold).
212    pub path: Option<ConversionPath>,
213}
214
215/// Generate optimal paths for all (source, op, output) triples across codec pairs.
216///
217/// This produces the complete conversion matrix. With 9 codecs × ~5 avg formats ×
218/// 13 ops × 9 codecs × ~3 avg formats ≈ ~15,000 triples. Most collapse to a
219/// handful of distinct working formats.
220pub fn generate_path_matrix(
221    source_codecs: &[&CodecFormats],
222    operations: &[OpCategory],
223    output_codecs: &[&CodecFormats],
224    threshold: QualityThreshold,
225) -> Vec<PathEntry> {
226    let mut entries = Vec::new();
227
228    for source_codec in source_codecs {
229        for source_entry in source_codec.decode_outputs {
230            let provenance = provenance_from_entry(source_entry);
231
232            for &operation in operations {
233                for output_codec in output_codecs {
234                    for output_entry in output_codec.encode_inputs {
235                        let path = optimal_path(
236                            source_entry.descriptor,
237                            provenance,
238                            operation,
239                            output_entry.descriptor,
240                            threshold,
241                        );
242
243                        entries.push(PathEntry {
244                            source_codec: source_codec.name,
245                            source_format: source_entry.descriptor,
246                            source_effective_bits: source_entry.effective_bits,
247                            operation,
248                            output_codec: output_codec.name,
249                            output_format: output_entry.descriptor,
250                            path,
251                        });
252                    }
253                }
254            }
255        }
256    }
257
258    entries
259}
260
261/// Derive provenance from a codec's format entry.
262///
263/// Uses `effective_bits` to determine the origin depth: if effective_bits ≤ 8,
264/// origin is U8; if ≤ 16, origin is U16; otherwise F32.
265fn provenance_from_entry(entry: &FormatEntry) -> Provenance {
266    use crate::ChannelType;
267
268    let origin_depth = if entry.effective_bits <= 8 {
269        ChannelType::U8
270    } else if entry.effective_bits <= 16 {
271        ChannelType::U16
272    } else {
273        ChannelType::F32
274    };
275    Provenance::with_origin_depth(origin_depth)
276}
277
278/// Summary statistics for a path matrix.
279#[derive(Clone, Debug, Default)]
280pub struct MatrixStats {
281    /// Total number of (source, op, output) triples evaluated.
282    pub total_triples: usize,
283    /// Number of triples with a valid path within threshold.
284    pub paths_found: usize,
285    /// Number of triples where no path met the threshold.
286    pub no_path: usize,
287    /// Distribution of paths by loss bucket.
288    pub by_bucket: [usize; 5],
289    /// Number of distinct working formats used across all paths.
290    pub distinct_working_formats: usize,
291}
292
293/// Compute summary statistics for a path matrix.
294pub fn matrix_stats(entries: &[PathEntry]) -> MatrixStats {
295    use alloc::collections::BTreeSet;
296
297    let mut stats = MatrixStats {
298        total_triples: entries.len(),
299        ..Default::default()
300    };
301    let mut working_formats = BTreeSet::new();
302
303    for entry in entries {
304        match &entry.path {
305            Some(path) => {
306                stats.paths_found += 1;
307                let bucket_idx = match path.loss_bucket() {
308                    LossBucket::Lossless => 0,
309                    LossBucket::NearLossless => 1,
310                    LossBucket::LowLoss => 2,
311                    LossBucket::Moderate => 3,
312                    LossBucket::High => 4,
313                };
314                stats.by_bucket[bucket_idx] += 1;
315
316                // Encode working format as bytes for BTreeSet (PixelDescriptor doesn't impl Ord).
317                let wf = path.working_format;
318                let alpha_byte = match wf.alpha() {
319                    None => 0u8,
320                    Some(a) => a as u8,
321                };
322                let key = (
323                    wf.channel_type() as u8,
324                    wf.layout() as u8,
325                    alpha_byte,
326                    wf.transfer() as u8,
327                    wf.primaries as u8,
328                );
329                working_formats.insert(key);
330            }
331            None => stats.no_path += 1,
332        }
333    }
334
335    stats.distinct_working_formats = working_formats.len();
336    stats
337}
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342    use crate::registry;
343    use crate::{AlphaMode, ChannelType, TransferFunction};
344
345    #[test]
346    fn passthrough_identity_is_lossless() {
347        let src = PixelDescriptor::RGB8_SRGB;
348        let provenance = Provenance::from_source(src);
349        let path = optimal_path(
350            src,
351            provenance,
352            OpCategory::Passthrough,
353            src,
354            QualityThreshold::Lossless,
355        );
356        assert!(
357            path.is_some(),
358            "passthrough identity should always find a path"
359        );
360        let path = path.unwrap();
361        assert_eq!(path.working_format, src);
362        assert_eq!(path.total_loss, 0);
363    }
364
365    #[test]
366    fn resize_sharp_uses_f32_linear() {
367        let src = PixelDescriptor::RGB8_SRGB;
368        let provenance = Provenance::from_source(src);
369        let path = optimal_path(
370            src,
371            provenance,
372            OpCategory::ResizeSharp,
373            PixelDescriptor::RGB8_SRGB,
374            QualityThreshold::MaxBucket(LossBucket::Moderate),
375        );
376        assert!(path.is_some());
377        let path = path.unwrap();
378        assert_eq!(path.working_format.channel_type(), ChannelType::F32);
379        assert_eq!(path.working_format.transfer(), TransferFunction::Linear);
380    }
381
382    #[test]
383    fn jpeg_to_jpeg_passthrough() {
384        let src = PixelDescriptor::RGB8_SRGB;
385        let provenance = Provenance::with_origin_depth(ChannelType::U8);
386        let path = optimal_path(
387            src,
388            provenance,
389            OpCategory::Passthrough,
390            PixelDescriptor::RGB8_SRGB,
391            QualityThreshold::Lossless,
392        );
393        assert!(path.is_some());
394        assert_eq!(path.unwrap().total_loss, 0);
395    }
396
397    #[test]
398    fn composite_uses_premultiplied() {
399        let src = PixelDescriptor::RGBA8_SRGB;
400        let provenance = Provenance::from_source(src);
401        let path = optimal_path(
402            src,
403            provenance,
404            OpCategory::Composite,
405            PixelDescriptor::RGBA8_SRGB,
406            QualityThreshold::MaxBucket(LossBucket::Moderate),
407        );
408        assert!(path.is_some());
409        let path = path.unwrap();
410        assert_eq!(path.working_format.alpha(), Some(AlphaMode::Premultiplied));
411    }
412
413    #[test]
414    fn loss_bucket_classification() {
415        assert_eq!(LossBucket::from_model_loss(0), LossBucket::Lossless);
416        assert_eq!(LossBucket::from_model_loss(10), LossBucket::Lossless);
417        assert_eq!(LossBucket::from_model_loss(11), LossBucket::NearLossless);
418        assert_eq!(LossBucket::from_model_loss(50), LossBucket::NearLossless);
419        assert_eq!(LossBucket::from_model_loss(51), LossBucket::LowLoss);
420        assert_eq!(LossBucket::from_model_loss(150), LossBucket::LowLoss);
421        assert_eq!(LossBucket::from_model_loss(151), LossBucket::Moderate);
422        assert_eq!(LossBucket::from_model_loss(400), LossBucket::Moderate);
423        assert_eq!(LossBucket::from_model_loss(401), LossBucket::High);
424    }
425
426    #[test]
427    fn generate_jpeg_to_jpeg_matrix() {
428        let ops = [
429            OpCategory::Passthrough,
430            OpCategory::ResizeGentle,
431            OpCategory::ResizeSharp,
432        ];
433        let matrix = generate_path_matrix(
434            &[&registry::JPEG],
435            &ops,
436            &[&registry::JPEG],
437            QualityThreshold::MaxBucket(LossBucket::Moderate),
438        );
439
440        // JPEG decode has 4 formats × 3 ops × 10 encode formats = 120 triples
441        assert!(!matrix.is_empty());
442
443        let stats = matrix_stats(&matrix);
444        assert!(stats.paths_found > 0, "should find at least some paths");
445    }
446
447    #[test]
448    fn full_matrix_produces_results() {
449        let all_ops = [
450            OpCategory::Passthrough,
451            OpCategory::ResizeGentle,
452            OpCategory::ResizeSharp,
453        ];
454        let codecs: Vec<&CodecFormats> = registry::ALL_CODECS.to_vec();
455        let matrix = generate_path_matrix(
456            &codecs,
457            &all_ops,
458            &codecs,
459            QualityThreshold::MaxBucket(LossBucket::High),
460        );
461
462        let stats = matrix_stats(&matrix);
463        assert!(stats.total_triples > 100, "should have many triples");
464        assert!(stats.paths_found > 0, "should find paths");
465        // Most triples should have valid paths with High threshold
466        assert!(
467            stats.paths_found as f64 / stats.total_triples as f64 > 0.5,
468            "most triples should have valid paths: {}/{}",
469            stats.paths_found,
470            stats.total_triples
471        );
472    }
473
474    #[test]
475    fn quality_threshold_filters_correctly() {
476        let src = PixelDescriptor::RGBF32_LINEAR;
477        let provenance = Provenance::with_origin_depth(ChannelType::F32);
478
479        // Strict lossless threshold: f32→u8 should not qualify
480        let lossless_path = optimal_path(
481            src,
482            provenance,
483            OpCategory::Passthrough,
484            PixelDescriptor::RGB8_SRGB,
485            QualityThreshold::Lossless,
486        );
487
488        // Relaxed threshold: should find a path
489        let relaxed_path = optimal_path(
490            src,
491            provenance,
492            OpCategory::Passthrough,
493            PixelDescriptor::RGB8_SRGB,
494            QualityThreshold::MaxBucket(LossBucket::Moderate),
495        );
496
497        // f32 origin → u8 has loss, so lossless should fail but relaxed should work
498        assert!(lossless_path.is_none(), "f32→u8 should not be lossless");
499        assert!(
500            relaxed_path.is_some(),
501            "f32→u8 should work with relaxed threshold"
502        );
503    }
504}