1use 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#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
26pub enum LossBucket {
27 Lossless,
29 NearLossless,
31 LowLoss,
33 Moderate,
35 High,
37}
38
39impl LossBucket {
40 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 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#[derive(Clone, Copy, Debug)]
69pub enum QualityThreshold {
70 Lossless,
72 SubPerceptual,
74 NearLossless,
76 MaxBucket(LossBucket),
78}
79
80impl QualityThreshold {
81 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#[derive(Clone, Debug)]
94pub struct ConversionPath {
95 pub source_format: PixelDescriptor,
97 pub working_format: PixelDescriptor,
99 pub output_format: PixelDescriptor,
101 pub source_to_working: ConversionCost,
103 pub working_suitability: u16,
105 pub working_to_output: ConversionCost,
107 pub total_score: u32,
109 pub total_loss: u16,
111 pub proven_lossless: bool,
113}
114
115impl ConversionPath {
116 pub fn loss_bucket(&self) -> LossBucket {
118 LossBucket::from_model_loss(self.total_loss)
119 }
120}
121
122pub 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 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
187fn provenance_after_operation(original: Provenance, working: PixelDescriptor) -> Provenance {
193 Provenance::with_origin(working.channel_type(), original.origin_primaries)
194}
195
196#[derive(Clone, Debug)]
198pub struct PathEntry {
199 pub source_codec: &'static str,
201 pub source_format: PixelDescriptor,
203 pub source_effective_bits: u8,
205 pub operation: OpCategory,
207 pub output_codec: &'static str,
209 pub output_format: PixelDescriptor,
211 pub path: Option<ConversionPath>,
213}
214
215pub 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
261fn 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#[derive(Clone, Debug, Default)]
280pub struct MatrixStats {
281 pub total_triples: usize,
283 pub paths_found: usize,
285 pub no_path: usize,
287 pub by_bucket: [usize; 5],
289 pub distinct_working_formats: usize,
291}
292
293pub 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 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 &[®istry::JPEG],
435 &ops,
436 &[®istry::JPEG],
437 QualityThreshold::MaxBucket(LossBucket::Moderate),
438 );
439
440 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 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 let lossless_path = optimal_path(
481 src,
482 provenance,
483 OpCategory::Passthrough,
484 PixelDescriptor::RGB8_SRGB,
485 QualityThreshold::Lossless,
486 );
487
488 let relaxed_path = optimal_path(
490 src,
491 provenance,
492 OpCategory::Passthrough,
493 PixelDescriptor::RGB8_SRGB,
494 QualityThreshold::MaxBucket(LossBucket::Moderate),
495 );
496
497 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}