Skip to main content

otf_pixels_core/
shrink.rs

1//! Shrink-on-load: deciding, from the whole graph, that the source can be
2//! produced smaller.
3//!
4//! # Why this is a graph pass and not a decoder option
5//!
6//! Some formats can produce a reduced image far more cheaply than a full one —
7//! JPEG most of all, where the low-frequency corner of a DCT block is a
8//! smaller version of that block. But the useful size is the *pipeline's*
9//! target, which is not known when the source is opened: `Image::from_stream`
10//! runs before `.resize(200, 150)` is ever called. Only once the graph is
11//! complete does the answer exist, and this pass is where it is computed.
12//!
13//! # When it fires
14//!
15//! Three conditions, all necessary:
16//!
17//! 1. The graph has exactly **one source**. With two, only one would shrink,
18//!    and a `composite` would align pictures of different sizes.
19//! 2. Every op is [`Op::scale_covariant`] — it means the same thing against a
20//!    reduced input. `crop` and `composite` carry coordinates in source
21//!    pixels; `convolve` carries a kernel in pixels. None of them do.
22//! 3. Re-deriving every descriptor from the reduced source leaves the **root
23//!    descriptor unchanged**. This is what distinguishes `resize(200, 150)`,
24//!    which pins its output size and therefore absorbs the reduction, from a
25//!    bare `flip`, which would simply emit a smaller image.
26//!
27//! Condition 3 is checked by simulation before anything is committed, because
28//! reducing a source is irreversible — a stream cannot be rewound.
29//!
30//! # When it does not fire
31//!
32//! Nothing fails. A pipeline that cannot shrink decodes at full resolution,
33//! which is what it did before this pass existed. That is deliberate: `crop`
34//! is a legal thing to do to a JPEG, and refusing it to protect an
35//! optimization would be the wrong trade. Whether it fired is reported in
36//! [`RunStats`], so a pipeline that expected the fast path and did not get it
37//! is diagnosable rather than silently slow.
38//!
39//! [`Op::scale_covariant`]: crate::Op::scale_covariant
40//! [`RunStats`]: crate::RunStats
41
42use crate::{Image, ImageDescriptor, Node, NodeId, Result};
43use std::collections::HashMap;
44use std::sync::Arc;
45
46/// A source resolution the planner lowered.
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub struct Reduction {
49    /// The size the source would have decoded at.
50    pub from: (u32, u32),
51    /// The size it will decode at instead.
52    pub to: (u32, u32),
53}
54
55impl Reduction {
56    /// How many times fewer pixels the source now produces.
57    #[must_use]
58    pub fn factor(&self) -> f64 {
59        let before = f64::from(self.from.0) * f64::from(self.from.1);
60        let after = (f64::from(self.to.0) * f64::from(self.to.1)).max(1.0);
61        before / after
62    }
63}
64
65/// Rebuild `image` over a reduced source, if the graph permits it.
66///
67/// Returns the graph to evaluate and what was decided. The returned graph is
68/// `image` itself when no reduction applies, so a caller can use the result
69/// unconditionally.
70///
71/// # Errors
72///
73/// Returns [`PixelsError::Graph`] if the graph cannot be walked, or propagates
74/// a producer's failure to commit to a reduction it had offered.
75///
76/// [`PixelsError::Graph`]: crate::PixelsError::Graph
77pub fn shrink_on_load(image: &Image) -> Result<(Image, Option<Reduction>)> {
78    let root = Arc::clone(image.node());
79    let order = topological_order(&root);
80
81    // Condition 2: every op must survive a change of input resolution, and
82    // hand back an instance carrying no state bound to the old one. Each node
83    // gets its own copy, which is then used for both the simulation and the
84    // rebuild — so the tables an op memoizes while being simulated are the
85    // tables the evaluated graph uses.
86    let mut rescaled: HashMap<NodeId, Arc<dyn crate::Op>> = HashMap::new();
87    for node in &order {
88        let Some(op) = node.op() else { continue };
89        let Some(copy) = op.rescaled() else {
90            return Ok((image.clone(), None));
91        };
92        rescaled.insert(node.id(), copy);
93    }
94
95    // Condition 1: exactly one source.
96    let sources: Vec<&Arc<Node>> = order
97        .iter()
98        .filter(|node| node.producer().is_some())
99        .collect();
100    let [source] = sources.as_slice() else {
101        return Ok((image.clone(), None));
102    };
103    let Some(producer) = source.producer() else {
104        return Ok((image.clone(), None));
105    };
106
107    let full = source.descriptor();
108    let target = (root.descriptor().width, root.descriptor().height);
109    let Some(reduced) = producer.reduced_descriptor(target) else {
110        return Ok((image.clone(), None));
111    };
112    if (reduced.width, reduced.height) == (full.width, full.height) {
113        return Ok((image.clone(), None));
114    }
115
116    // Condition 3: simulate the whole graph over the reduced source and check
117    // the root is unmoved. Nothing is committed until this holds.
118    let Some(simulated) = simulate(&order, source.id(), reduced, &rescaled) else {
119        return Ok((image.clone(), None));
120    };
121    let Some(&new_root) = simulated.get(&root.id()) else {
122        return Ok((image.clone(), None));
123    };
124    if (new_root.width, new_root.height) != (root.descriptor().width, root.descriptor().height) {
125        return Ok((image.clone(), None));
126    }
127
128    // Committed from here: the producer will not decode at full size again.
129    producer.reduce_to(reduced)?;
130    let rebuilt = rebuild(
131        &root,
132        source.id(),
133        producer,
134        image,
135        &rescaled,
136        &mut HashMap::new(),
137    )?;
138
139    Ok((
140        rebuilt,
141        Some(Reduction {
142            from: (full.width, full.height),
143            to: (reduced.width, reduced.height),
144        }),
145    ))
146}
147
148/// Re-derive every node's descriptor with the source replaced.
149///
150/// Returns `None` if any op rejects the reduced shapes — an op that cannot
151/// apply is a reason not to reduce, not an error: the un-reduced pipeline is
152/// still perfectly valid.
153fn simulate(
154    order: &[Arc<Node>],
155    source: NodeId,
156    reduced: ImageDescriptor,
157    rescaled: &HashMap<NodeId, Arc<dyn crate::Op>>,
158) -> Option<HashMap<NodeId, ImageDescriptor>> {
159    let mut descriptors: HashMap<NodeId, ImageDescriptor> = HashMap::with_capacity(order.len());
160    for node in order {
161        if node.id() == source {
162            descriptors.insert(node.id(), reduced);
163            continue;
164        }
165        // Every op node was rescaled above or the whole pass bailed, so a
166        // miss here is a logic error. Refusing to reduce is the safe answer:
167        // carrying the node's old descriptor forward instead would compare
168        // the unreduced root against itself and wave the reduction through,
169        // which is precisely the bug this line replaced.
170        let op = rescaled.get(&node.id())?;
171        let inputs: Vec<ImageDescriptor> = node
172            .inputs()
173            .iter()
174            .filter_map(|input| descriptors.get(&input.id()).copied())
175            .collect();
176        if inputs.len() != node.inputs().len() {
177            return None;
178        }
179        descriptors.insert(node.id(), op.output_descriptor(&inputs).ok()?);
180    }
181    Some(descriptors)
182}
183
184/// Build a fresh graph of the same ops over the now-reduced producer.
185///
186/// Nodes are immutable and shared, so the reduction cannot be applied in
187/// place: a rebuilt graph is how the new descriptors reach every node. Shared
188/// sub-graphs stay shared, through the memo.
189fn rebuild(
190    node: &Arc<Node>,
191    source: NodeId,
192    producer: &Arc<dyn crate::Producer>,
193    original: &Image,
194    rescaled: &HashMap<NodeId, Arc<dyn crate::Op>>,
195    memo: &mut HashMap<NodeId, Image>,
196) -> Result<Image> {
197    if let Some(built) = memo.get(&node.id()) {
198        return Ok(built.clone());
199    }
200    let built = if node.id() == source {
201        // The producer now reports the reduced descriptor, so this picks it up
202        // with no further arrangement.
203        Image::from_producer(Arc::clone(producer), original.metadata()?.format)
204    } else {
205        // The rescaled copy, not the original: the original's tables are
206        // bound to the shape it was built against.
207        let op = rescaled
208            .get(&node.id())
209            .ok_or_else(|| crate::PixelsError::graph("an op was rebuilt without being rescaled"))?;
210        let inputs = node
211            .inputs()
212            .iter()
213            .map(|input| rebuild(input, source, producer, original, rescaled, memo))
214            .collect::<Result<Vec<_>>>()?;
215        Image::combine(&inputs, Arc::clone(op))?
216    };
217    memo.insert(node.id(), built.clone());
218    Ok(built)
219}
220
221/// Every node reachable from `root`, inputs before the nodes that consume them.
222fn topological_order(root: &Arc<Node>) -> Vec<Arc<Node>> {
223    let mut order = Vec::new();
224    let mut seen = std::collections::HashSet::new();
225    visit(root, &mut seen, &mut order);
226    order
227}
228
229fn visit(
230    node: &Arc<Node>,
231    seen: &mut std::collections::HashSet<NodeId>,
232    order: &mut Vec<Arc<Node>>,
233) {
234    if !seen.insert(node.id()) {
235        return;
236    }
237    for input in node.inputs() {
238        visit(input, seen, order);
239    }
240    order.push(Arc::clone(node));
241}
242
243#[cfg(test)]
244#[allow(
245    clippy::unwrap_used,
246    clippy::expect_used,
247    clippy::indexing_slicing,
248    clippy::panic,
249    reason = "tests operate on known-good values and assert shapes directly"
250)]
251mod tests {
252    use super::*;
253    use crate::{
254        AccessPattern, DecodeCapability, Format, Op, PixelFormat, PixelsError, Producer, Region,
255        TileMut,
256    };
257    use std::sync::Mutex;
258
259    /// A producer that can halve itself, once.
260    #[derive(Debug)]
261    struct Shrinkable {
262        descriptor: Mutex<ImageDescriptor>,
263        reduced: Mutex<bool>,
264    }
265
266    impl Shrinkable {
267        fn new(width: u32, height: u32) -> Arc<Self> {
268            Arc::new(Self {
269                descriptor: Mutex::new(
270                    ImageDescriptor::new(width, height, PixelFormat::Gray8).unwrap(),
271                ),
272                reduced: Mutex::new(false),
273            })
274        }
275
276        fn shape(&self) -> ImageDescriptor {
277            *self.descriptor.lock().unwrap()
278        }
279    }
280
281    impl Producer for Shrinkable {
282        fn name(&self) -> &'static str {
283            "shrinkable"
284        }
285        fn descriptor(&self) -> ImageDescriptor {
286            self.shape()
287        }
288        fn capability(&self) -> DecodeCapability {
289            DecodeCapability::Regions
290        }
291        fn produce(&self, _: Region, _: &mut TileMut<'_>) -> Result<()> {
292            Ok(())
293        }
294        fn reduced_descriptor(&self, target: (u32, u32)) -> Option<ImageDescriptor> {
295            let full = self.shape();
296            let (half_width, half_height) = (full.width / 2, full.height / 2);
297            if half_width >= target.0 && half_height >= target.1 {
298                ImageDescriptor::new(half_width, half_height, full.pixel).ok()
299            } else {
300                None
301            }
302        }
303        fn reduce_to(&self, descriptor: ImageDescriptor) -> Result<()> {
304            *self.descriptor.lock().unwrap() = descriptor;
305            *self.reduced.lock().unwrap() = true;
306            Ok(())
307        }
308    }
309
310    /// An op that resizes to a fixed size, like the real `resize`.
311    #[derive(Debug)]
312    struct FixedResize {
313        width: u32,
314        height: u32,
315        covariant: bool,
316    }
317
318    impl Op for FixedResize {
319        fn name(&self) -> &'static str {
320            "fixed-resize"
321        }
322        fn access_pattern(&self) -> AccessPattern {
323            AccessPattern::Spatial
324        }
325        fn output_descriptor(&self, inputs: &[ImageDescriptor]) -> Result<ImageDescriptor> {
326            let input = inputs
327                .first()
328                .ok_or_else(|| PixelsError::graph("no input"))?;
329            ImageDescriptor::new(self.width, self.height, input.pixel)
330        }
331        fn input_regions(&self, _: Region, inputs: &[ImageDescriptor]) -> Result<Vec<Region>> {
332            Ok(vec![inputs[0].region()])
333        }
334        fn compute(&self, _: &[Tile<'_>], _: &mut TileMut<'_>) -> Result<()> {
335            Ok(())
336        }
337        fn rescaled(&self) -> Option<Arc<dyn Op>> {
338            self.covariant.then(|| {
339                Arc::new(Self {
340                    width: self.width,
341                    height: self.height,
342                    covariant: true,
343                }) as Arc<dyn Op>
344            })
345        }
346    }
347
348    use crate::Tile;
349
350    /// An op that keeps its input's shape, like `flip`.
351    #[derive(Debug)]
352    struct SameShape {
353        covariant: bool,
354    }
355
356    impl Op for SameShape {
357        fn name(&self) -> &'static str {
358            "same-shape"
359        }
360        fn access_pattern(&self) -> AccessPattern {
361            AccessPattern::Sequential
362        }
363        fn output_descriptor(&self, inputs: &[ImageDescriptor]) -> Result<ImageDescriptor> {
364            inputs
365                .first()
366                .copied()
367                .ok_or_else(|| PixelsError::graph("no input"))
368        }
369        fn input_regions(&self, output: Region, _: &[ImageDescriptor]) -> Result<Vec<Region>> {
370            Ok(vec![output])
371        }
372        fn compute(&self, _: &[Tile<'_>], _: &mut TileMut<'_>) -> Result<()> {
373            Ok(())
374        }
375        fn rescaled(&self) -> Option<Arc<dyn Op>> {
376            self.covariant
377                .then(|| Arc::new(Self { covariant: true }) as Arc<dyn Op>)
378        }
379    }
380
381    fn source(width: u32, height: u32) -> (Image, Arc<Shrinkable>) {
382        let producer = Shrinkable::new(width, height);
383        let image = Image::from_producer(Arc::clone(&producer) as Arc<dyn Producer>, Format::Jpeg);
384        (image, producer)
385    }
386
387    #[test]
388    fn a_resize_pipeline_shrinks_its_source() {
389        let (image, producer) = source(800, 600);
390        let pipeline = image
391            .apply(Arc::new(FixedResize {
392                width: 100,
393                height: 75,
394                covariant: true,
395            }))
396            .unwrap();
397
398        let (rebuilt, reduction) = shrink_on_load(&pipeline).unwrap();
399        let reduction = reduction.expect("a resize to 1/8 should shrink the source");
400        assert_eq!(reduction.from, (800, 600));
401        assert_eq!(reduction.to, (400, 300));
402        assert!((reduction.factor() - 4.0).abs() < 0.001);
403
404        // The producer now reports the reduced size, and the rebuilt graph
405        // still produces exactly what was asked for.
406        assert_eq!(producer.shape().width, 400);
407        assert_eq!(
408            (rebuilt.descriptor().width, rebuilt.descriptor().height),
409            (100, 75)
410        );
411    }
412
413    #[test]
414    fn an_op_that_is_not_scale_covariant_blocks_the_reduction() {
415        let (image, producer) = source(800, 600);
416        // Stands for `crop` or `convolve`: correctly shaped, wrong picture.
417        let pipeline = image
418            .apply(Arc::new(FixedResize {
419                width: 100,
420                height: 75,
421                covariant: false,
422            }))
423            .unwrap();
424
425        let (rebuilt, reduction) = shrink_on_load(&pipeline).unwrap();
426        assert!(reduction.is_none(), "a non-covariant op must block it");
427        assert_eq!(producer.shape().width, 800, "the source was reduced anyway");
428        assert_eq!(rebuilt.descriptor().width, 100);
429    }
430
431    #[test]
432    fn a_pipeline_with_no_resize_keeps_its_size() {
433        // Every op is covariant, but nothing pins the output size, so
434        // reducing the source would just emit a smaller image.
435        let (image, producer) = source(800, 600);
436        let pipeline = image
437            .apply(Arc::new(SameShape { covariant: true }))
438            .unwrap();
439
440        let (rebuilt, reduction) = shrink_on_load(&pipeline).unwrap();
441        assert!(
442            reduction.is_none(),
443            "reducing here would change the output size"
444        );
445        assert_eq!(producer.shape().width, 800);
446        assert_eq!(rebuilt.descriptor().width, 800);
447    }
448
449    #[test]
450    fn a_source_that_cannot_reduce_is_left_alone() {
451        let descriptor = ImageDescriptor::new(64, 64, PixelFormat::Gray8).unwrap();
452        let buffer = Arc::new(crate::TileBuf::for_image(&descriptor).unwrap());
453        let image = Image::from_producer(
454            Arc::new(crate::BufferSource::new(descriptor, buffer).unwrap()),
455            Format::Raw,
456        );
457        let pipeline = image
458            .apply(Arc::new(FixedResize {
459                width: 8,
460                height: 8,
461                covariant: true,
462            }))
463            .unwrap();
464
465        let (_, reduction) = shrink_on_load(&pipeline).unwrap();
466        assert!(reduction.is_none(), "pixels in memory have one resolution");
467    }
468
469    #[test]
470    fn the_reduction_never_goes_below_the_target() {
471        // The stub halves only while the result still covers the target, so a
472        // target just over half the source must leave it alone.
473        let (image, producer) = source(800, 600);
474        let pipeline = image
475            .apply(Arc::new(FixedResize {
476                width: 401,
477                height: 301,
478                covariant: true,
479            }))
480            .unwrap();
481
482        let (_, reduction) = shrink_on_load(&pipeline).unwrap();
483        assert!(reduction.is_none());
484        assert_eq!(producer.shape().width, 800);
485    }
486
487    #[test]
488    fn a_shared_subgraph_stays_shared_through_the_rebuild() {
489        let (image, _) = source(800, 600);
490        let resized = image
491            .apply(Arc::new(FixedResize {
492                width: 100,
493                height: 75,
494                covariant: true,
495            }))
496            .unwrap();
497        // Two ops over one resize: the rebuilt graph must not duplicate the
498        // shared prefix into two independent decodes of the same stream.
499        let branch = resized
500            .apply(Arc::new(SameShape { covariant: true }))
501            .unwrap();
502
503        let before = branch.node().node_count();
504        let (rebuilt, reduction) = shrink_on_load(&branch).unwrap();
505        assert!(reduction.is_some());
506        assert_eq!(
507            rebuilt.node().node_count(),
508            before,
509            "the rebuild changed the graph's shape"
510        );
511    }
512}