Skip to main content

otf_pixels_ops/
composite.rs

1//! [`Composite`] — draw one image over another.
2//!
3//! This is the first op with two inputs, which makes it the first place
4//! `Op::arity` and the two-element `input_regions` vector do real work.
5//!
6//! # Alpha
7//!
8//! SPEC §Formats: alpha is unassociated at API boundaries. Source-over is
9//! therefore computed in premultiplied form internally and converted back on
10//! the way out, because straight-alpha compositing has no correct one-line
11//! form — the naive `f*a + b*(1-a)` is only right when the backdrop is opaque.
12//!
13//! Getting this wrong shows up as a dark halo around soft edges, which is the
14//! single most common compositing bug and looks like a bad matte rather than
15//! like arithmetic.
16
17use otf_pixels_core::{
18    ImageDescriptor, Op, PixelFormat, PixelsError, Region, Result, SampleKind, Tile, TileMut,
19};
20
21/// How the source is combined with the backdrop.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
23#[non_exhaustive]
24pub enum Blend {
25    /// Porter-Duff source-over: the source is drawn on top of the backdrop.
26    #[default]
27    Over,
28    /// The source replaces the backdrop entirely within its rectangle.
29    Source,
30}
31
32/// Draw `overlay` over `base` at an offset.
33///
34/// Input order is `[base, overlay]`, matching the reading order of "composite
35/// this over that".
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
37pub struct Composite {
38    x: i64,
39    y: i64,
40    blend: Blend,
41}
42
43impl Composite {
44    /// Draw the overlay with its top-left corner at `(x, y)` in base
45    /// coordinates.
46    ///
47    /// Negative offsets are allowed: the overlay is clipped to the base rather
48    /// than rejected, which is what makes "centre a watermark" expressible
49    /// without the caller doing the clipping arithmetic.
50    #[must_use]
51    pub const fn at(x: i64, y: i64, blend: Blend) -> Self {
52        Self { x, y, blend }
53    }
54
55    /// Draw the overlay over the base at the origin with source-over.
56    #[must_use]
57    pub const fn over() -> Self {
58        Self::at(0, 0, Blend::Over)
59    }
60
61    /// The offset the overlay is drawn at.
62    #[must_use]
63    pub const fn offset(&self) -> (i64, i64) {
64        (self.x, self.y)
65    }
66
67    /// The blend mode.
68    #[must_use]
69    pub const fn blend(&self) -> Blend {
70        self.blend
71    }
72
73    /// The region of the overlay that lands inside `output`, in overlay
74    /// coordinates, or `None` if the overlay misses this region entirely.
75    fn overlay_region(&self, output: Region, overlay: &ImageDescriptor) -> Option<Region> {
76        // Output coordinates are base coordinates; subtract the offset to get
77        // overlay coordinates, then intersect with the overlay's own bounds.
78        let left = (i64::from(output.x) - self.x).max(0);
79        let top = (i64::from(output.y) - self.y).max(0);
80        let right = (i64::from(output.x + output.width) - self.x).min(i64::from(overlay.width));
81        let bottom = (i64::from(output.y + output.height) - self.y).min(i64::from(overlay.height));
82
83        if right <= left || bottom <= top {
84            return None;
85        }
86        Some(Region::new(
87            left as u32,
88            top as u32,
89            (right - left) as u32,
90            (bottom - top) as u32,
91        ))
92    }
93}
94
95impl Op for Composite {
96    /// Never rescaled, for two independent reasons: the overlay position is in
97    /// pixels, and the second input is a separate image that would not be
98    /// reduced alongside the first.
99    fn rescaled(&self) -> Option<std::sync::Arc<dyn Op>> {
100        None
101    }
102    fn name(&self) -> &'static str {
103        "composite"
104    }
105
106    fn arity(&self) -> usize {
107        2
108    }
109
110    fn output_descriptor(&self, inputs: &[ImageDescriptor]) -> Result<ImageDescriptor> {
111        let (base, overlay) = pair(inputs)?;
112        if base.pixel != overlay.pixel {
113            return Err(PixelsError::unsupported(format!(
114                "composite needs matching formats: base is {}, overlay is {}",
115                base.pixel, overlay.pixel
116            )));
117        }
118        if base.pixel.sample_kind() != SampleKind::U8 {
119            return Err(PixelsError::unsupported(format!(
120                "composite is implemented for 8-bit formats; got {}",
121                base.pixel
122            )));
123        }
124        // The output is the base's shape: compositing draws *onto* something,
125        // so the backdrop defines the canvas.
126        Ok(*base)
127    }
128
129    fn input_regions(&self, output: Region, inputs: &[ImageDescriptor]) -> Result<Vec<Region>> {
130        let (_, overlay) = pair(inputs)?;
131        // The base is needed wherever the output is. The overlay is needed only
132        // where it actually lands — a watermark in the corner must not make the
133        // scheduler pull the whole overlay for every tile.
134        let wanted = self
135            .overlay_region(output, overlay)
136            // An empty region still has to be a valid one, so a miss is
137            // reported as a 1x1 at the origin rather than a zero-sized region
138            // the tile machinery would reject.
139            .unwrap_or_else(|| Region::new(0, 0, 1, 1));
140        Ok(vec![output, wanted])
141    }
142
143    fn compute(&self, inputs: &[Tile<'_>], output: &mut TileMut<'_>) -> Result<()> {
144        let base = inputs
145            .first()
146            .ok_or_else(|| PixelsError::graph("`composite` needs a base tile"))?;
147        let overlay = inputs
148            .get(1)
149            .ok_or_else(|| PixelsError::graph("`composite` needs an overlay tile"))?;
150
151        let format = output.pixel();
152        let channels = format.channels();
153        let region = output.region();
154
155        // The backdrop always shows through, so start from it.
156        for y in region.y..region.y + region.height {
157            let (Some(source), Some(target)) = (base.row(y), output.row_mut(y)) else {
158                continue;
159            };
160            let len = target.len().min(source.len());
161            if let (Some(to), Some(from)) = (target.get_mut(..len), source.get(..len)) {
162                to.copy_from_slice(from);
163            }
164        }
165
166        let overlay_area = overlay.region();
167        let has_alpha = matches!(format, PixelFormat::GrayA8 | PixelFormat::Rgba8);
168
169        for y in region.y..region.y + region.height {
170            // Where in the overlay this output row comes from.
171            let source_y = i64::from(y) - self.y;
172            if source_y < 0 {
173                continue;
174            }
175            let source_y = source_y as u32;
176            if source_y < overlay_area.y || source_y >= overlay_area.y + overlay_area.height {
177                continue;
178            }
179            let Some(over_row) = overlay.row(source_y) else {
180                continue;
181            };
182            let Some(target) = output.row_mut(y) else {
183                continue;
184            };
185
186            for x in region.x..region.x + region.width {
187                let source_x = i64::from(x) - self.x;
188                if source_x < 0 {
189                    continue;
190                }
191                let source_x = source_x as u32;
192                if source_x < overlay_area.x || source_x >= overlay_area.x + overlay_area.width {
193                    continue;
194                }
195
196                let from = (source_x - overlay_area.x) as usize * channels;
197                let to = (x - region.x) as usize * channels;
198                let Some(over) = over_row.get(from..from + channels) else {
199                    continue;
200                };
201
202                match self.blend {
203                    Blend::Source => {
204                        if let Some(slot) = target.get_mut(to..to + channels) {
205                            slot.copy_from_slice(over);
206                        }
207                    }
208                    Blend::Over => {
209                        if !has_alpha {
210                            // No alpha means fully opaque, so source-over is
211                            // just replacement.
212                            if let Some(slot) = target.get_mut(to..to + channels) {
213                                slot.copy_from_slice(over);
214                            }
215                            continue;
216                        }
217                        let alpha = channels - 1;
218                        let sa = u32::from(over.get(alpha).copied().unwrap_or(255));
219                        let ba = u32::from(target.get(to + alpha).copied().unwrap_or(255));
220                        // Porter-Duff over, on unassociated alpha:
221                        //   out_a = sa + ba*(1-sa)
222                        //   out_c = (sc*sa + bc*ba*(1-sa)) / out_a
223                        // Computing it premultiplied and dividing back out is
224                        // what avoids the dark halo the naive form produces
225                        // over a translucent backdrop.
226                        let inverse = 255 - sa;
227                        let out_a = sa * 255 + ba * inverse;
228                        if out_a == 0 {
229                            // Fully transparent result: colour is undefined, so
230                            // leave it zeroed rather than dividing by zero.
231                            for channel in 0..channels {
232                                if let Some(slot) = target.get_mut(to + channel) {
233                                    *slot = 0;
234                                }
235                            }
236                            continue;
237                        }
238                        for channel in 0..alpha {
239                            let sc = u32::from(over.get(channel).copied().unwrap_or(0));
240                            let bc = u32::from(target.get(to + channel).copied().unwrap_or(0));
241                            let numerator = sc * sa * 255 + bc * ba * inverse;
242                            let value = (numerator + out_a / 2) / out_a;
243                            if let Some(slot) = target.get_mut(to + channel) {
244                                *slot = value.min(255) as u8;
245                            }
246                        }
247                        if let Some(slot) = target.get_mut(to + alpha) {
248                            *slot = ((out_a + 127) / 255).min(255) as u8;
249                        }
250                    }
251                }
252            }
253        }
254        Ok(())
255    }
256}
257
258/// Fetch the two input descriptors a composite was given.
259fn pair(inputs: &[ImageDescriptor]) -> Result<(&ImageDescriptor, &ImageDescriptor)> {
260    match (inputs.first(), inputs.get(1)) {
261        (Some(base), Some(overlay)) => Ok((base, overlay)),
262        _ => Err(PixelsError::graph(format!(
263            "`composite` takes two inputs, got {}",
264            inputs.len()
265        ))),
266    }
267}
268
269#[cfg(test)]
270#[allow(
271    clippy::unwrap_used,
272    clippy::expect_used,
273    clippy::indexing_slicing,
274    clippy::panic,
275    reason = "tests operate on known-good values and assert shapes directly"
276)]
277mod tests {
278    use super::*;
279    use otf_pixels_core::TileBuf;
280
281    fn solid(
282        width: u32,
283        height: u32,
284        format: PixelFormat,
285        pixel: &[u8],
286    ) -> (ImageDescriptor, Vec<u8>) {
287        let descriptor = ImageDescriptor::new(width, height, format).unwrap();
288        let mut bytes = Vec::with_capacity(descriptor.byte_len().unwrap());
289        for _ in 0..(width * height) {
290            bytes.extend_from_slice(pixel);
291        }
292        (descriptor, bytes)
293    }
294
295    /// Composite two whole images.
296    fn apply(
297        op: &Composite,
298        base: (&ImageDescriptor, &[u8]),
299        overlay: (&ImageDescriptor, &[u8]),
300    ) -> Result<(ImageDescriptor, Vec<u8>)> {
301        let inputs = [*base.0, *overlay.0];
302        let out_desc = op.output_descriptor(&inputs)?;
303        let base_buf = TileBuf::from_vec(base.0.region(), base.0.pixel, base.1.to_vec())?;
304        let over_buf = TileBuf::from_vec(overlay.0.region(), overlay.0.pixel, overlay.1.to_vec())?;
305        let mut target = TileBuf::for_image(&out_desc)?;
306        op.compute(
307            &[base_buf.as_tile()?, over_buf.as_tile()?],
308            &mut target.as_tile_mut()?,
309        )?;
310        Ok((out_desc, target.into_bytes()))
311    }
312
313    #[test]
314    fn an_opaque_overlay_replaces_the_backdrop() {
315        let (base_desc, base) = solid(4, 4, PixelFormat::Rgba8, &[10, 20, 30, 255]);
316        let (over_desc, over) = solid(4, 4, PixelFormat::Rgba8, &[200, 100, 50, 255]);
317        let (_, out) = apply(&Composite::over(), (&base_desc, &base), (&over_desc, &over)).unwrap();
318        for pixel in out.chunks_exact(4) {
319            assert_eq!(pixel, [200, 100, 50, 255], "opaque overlay did not replace");
320        }
321    }
322
323    #[test]
324    fn a_fully_transparent_overlay_leaves_the_backdrop_alone() {
325        let (base_desc, base) = solid(4, 4, PixelFormat::Rgba8, &[10, 20, 30, 255]);
326        let (over_desc, over) = solid(4, 4, PixelFormat::Rgba8, &[200, 100, 50, 0]);
327        let (_, out) = apply(&Composite::over(), (&base_desc, &base), (&over_desc, &over)).unwrap();
328        for pixel in out.chunks_exact(4) {
329            assert_eq!(
330                pixel,
331                [10, 20, 30, 255],
332                "transparent overlay changed the base"
333            );
334        }
335    }
336
337    #[test]
338    fn half_alpha_over_an_opaque_backdrop_is_the_midpoint() {
339        let (base_desc, base) = solid(4, 4, PixelFormat::Rgba8, &[0, 0, 0, 255]);
340        let (over_desc, over) = solid(4, 4, PixelFormat::Rgba8, &[255, 255, 255, 128]);
341        let (_, out) = apply(&Composite::over(), (&base_desc, &base), (&over_desc, &over)).unwrap();
342        for pixel in out.chunks_exact(4) {
343            assert_eq!(pixel[3], 255, "an opaque backdrop must stay opaque");
344            assert_eq!(pixel[0], 128, "expected the midpoint, got {pixel:?}");
345        }
346    }
347
348    #[test]
349    fn compositing_onto_a_translucent_backdrop_does_not_darken() {
350        // The dark-halo bug: the naive `f*a + b*(1-a)` is only correct over an
351        // opaque backdrop. Over a translucent one it under-weights the
352        // backdrop's colour and the result creeps toward black.
353        let (base_desc, base) = solid(2, 2, PixelFormat::Rgba8, &[255, 255, 255, 128]);
354        let (over_desc, over) = solid(2, 2, PixelFormat::Rgba8, &[255, 255, 255, 128]);
355        let (_, out) = apply(&Composite::over(), (&base_desc, &base), (&over_desc, &over)).unwrap();
356        for pixel in out.chunks_exact(4) {
357            // White over white is white at any alpha. The naive formula gives
358            // 191 here instead.
359            assert_eq!(
360                &pixel[..3],
361                [255, 255, 255],
362                "white over white darkened to {pixel:?}"
363            );
364            assert!(pixel[3] > 128, "alpha should accumulate, got {}", pixel[3]);
365        }
366    }
367
368    #[test]
369    fn two_transparent_pixels_do_not_divide_by_zero() {
370        let (base_desc, base) = solid(2, 2, PixelFormat::Rgba8, &[9, 9, 9, 0]);
371        let (over_desc, over) = solid(2, 2, PixelFormat::Rgba8, &[7, 7, 7, 0]);
372        let (_, out) = apply(&Composite::over(), (&base_desc, &base), (&over_desc, &over)).unwrap();
373        for pixel in out.chunks_exact(4) {
374            assert_eq!(pixel[3], 0, "result should stay transparent");
375        }
376    }
377
378    #[test]
379    fn the_overlay_is_placed_at_its_offset() {
380        let (base_desc, base) = solid(4, 4, PixelFormat::Rgba8, &[0, 0, 0, 255]);
381        let (over_desc, over) = solid(2, 2, PixelFormat::Rgba8, &[255, 0, 0, 255]);
382        let op = Composite::at(1, 1, Blend::Over);
383        let (_, out) = apply(&op, (&base_desc, &base), (&over_desc, &over)).unwrap();
384
385        let pixel_at = |x: usize, y: usize| -> &[u8] { &out[(y * 4 + x) * 4..(y * 4 + x) * 4 + 4] };
386        assert_eq!(pixel_at(0, 0), [0, 0, 0, 255], "outside the overlay");
387        assert_eq!(pixel_at(1, 1), [255, 0, 0, 255], "inside the overlay");
388        assert_eq!(pixel_at(2, 2), [255, 0, 0, 255], "inside the overlay");
389        assert_eq!(pixel_at(3, 3), [0, 0, 0, 255], "outside the overlay");
390    }
391
392    #[test]
393    fn a_negative_offset_clips_rather_than_failing() {
394        // "Centre a watermark larger than the base" must work without the
395        // caller doing the clipping arithmetic.
396        let (base_desc, base) = solid(4, 4, PixelFormat::Rgba8, &[0, 0, 0, 255]);
397        let (over_desc, over) = solid(4, 4, PixelFormat::Rgba8, &[255, 0, 0, 255]);
398        let op = Composite::at(-2, -2, Blend::Over);
399        let (_, out) = apply(&op, (&base_desc, &base), (&over_desc, &over)).unwrap();
400
401        let pixel_at = |x: usize, y: usize| -> &[u8] { &out[(y * 4 + x) * 4..(y * 4 + x) * 4 + 4] };
402        assert_eq!(
403            pixel_at(0, 0),
404            [255, 0, 0, 255],
405            "clipped overlay should cover here"
406        );
407        assert_eq!(pixel_at(3, 3), [0, 0, 0, 255], "beyond the clipped overlay");
408    }
409
410    #[test]
411    fn an_overlay_entirely_outside_the_base_changes_nothing() {
412        let (base_desc, base) = solid(4, 4, PixelFormat::Rgba8, &[1, 2, 3, 255]);
413        let (over_desc, over) = solid(2, 2, PixelFormat::Rgba8, &[255, 0, 0, 255]);
414        let op = Composite::at(100, 100, Blend::Over);
415        let (_, out) = apply(&op, (&base_desc, &base), (&over_desc, &over)).unwrap();
416        assert_eq!(out, base, "a missed overlay changed the base");
417    }
418
419    #[test]
420    fn demand_asks_only_for_the_overlay_that_lands() {
421        // A watermark in one corner must not make every tile pull the whole
422        // overlay — that is the difference between a demand-driven engine and
423        // a loop.
424        let base = ImageDescriptor::new(1000, 1000, PixelFormat::Rgba8).unwrap();
425        let overlay = ImageDescriptor::new(50, 50, PixelFormat::Rgba8).unwrap();
426        let op = Composite::at(900, 900, Blend::Over);
427
428        let far = op
429            .input_regions(Region::new(0, 0, 100, 100), &[base, overlay])
430            .unwrap();
431        assert_eq!(
432            far[0],
433            Region::new(0, 0, 100, 100),
434            "base demand is the output"
435        );
436        assert!(
437            far[1].width <= 1 && far[1].height <= 1,
438            "a tile the overlay misses should not demand it: {}",
439            far[1]
440        );
441
442        let near = op
443            .input_regions(Region::new(900, 900, 50, 50), &[base, overlay])
444            .unwrap();
445        assert_eq!(near[1], Region::new(0, 0, 50, 50), "overlapping tile");
446    }
447
448    #[test]
449    fn the_source_blend_ignores_alpha() {
450        let (base_desc, base) = solid(2, 2, PixelFormat::Rgba8, &[9, 9, 9, 255]);
451        let (over_desc, over) = solid(2, 2, PixelFormat::Rgba8, &[1, 2, 3, 0]);
452        let op = Composite::at(0, 0, Blend::Source);
453        let (_, out) = apply(&op, (&base_desc, &base), (&over_desc, &over)).unwrap();
454        for pixel in out.chunks_exact(4) {
455            assert_eq!(pixel, [1, 2, 3, 0], "Source should copy verbatim");
456        }
457    }
458
459    #[test]
460    fn an_opaque_format_composites_as_replacement() {
461        let (base_desc, base) = solid(2, 2, PixelFormat::Rgb8, &[0, 0, 0]);
462        let (over_desc, over) = solid(2, 2, PixelFormat::Rgb8, &[5, 6, 7]);
463        let (_, out) = apply(&Composite::over(), (&base_desc, &base), (&over_desc, &over)).unwrap();
464        for pixel in out.chunks_exact(3) {
465            assert_eq!(pixel, [5, 6, 7]);
466        }
467    }
468
469    #[test]
470    fn mismatched_formats_are_an_error() {
471        let base = ImageDescriptor::new(4, 4, PixelFormat::Rgba8).unwrap();
472        let overlay = ImageDescriptor::new(4, 4, PixelFormat::Rgb8).unwrap();
473        assert!(
474            Composite::over()
475                .output_descriptor(&[base, overlay])
476                .is_err()
477        );
478    }
479
480    #[test]
481    fn wide_formats_are_unsupported_rather_than_wrong() {
482        let base = ImageDescriptor::new(4, 4, PixelFormat::Rgba16).unwrap();
483        assert!(Composite::over().output_descriptor(&[base, base]).is_err());
484    }
485
486    #[test]
487    fn one_input_is_a_graph_error() {
488        let base = ImageDescriptor::new(4, 4, PixelFormat::Rgba8).unwrap();
489        assert!(Composite::over().output_descriptor(&[base]).is_err());
490        assert_eq!(Composite::over().arity(), 2);
491    }
492
493    #[test]
494    fn the_output_is_independent_of_how_the_image_is_tiled() {
495        let (base_desc, base) = solid(16, 12, PixelFormat::Rgba8, &[40, 80, 120, 200]);
496        let (over_desc, over) = solid(7, 5, PixelFormat::Rgba8, &[255, 0, 0, 128]);
497        let op = Composite::at(3, 2, Blend::Over);
498        let (out_desc, whole) = apply(&op, (&base_desc, &base), (&over_desc, &over)).unwrap();
499
500        let base_buf = TileBuf::from_vec(base_desc.region(), base_desc.pixel, base).unwrap();
501        let over_buf = TileBuf::from_vec(over_desc.region(), over_desc.pixel, over).unwrap();
502
503        for (tw, th) in [(4_u32, 4_u32), (1, 12), (16, 1), (5, 3)] {
504            let mut target = TileBuf::for_image(&out_desc).unwrap();
505            let mut y = 0;
506            while y < out_desc.height {
507                let h = th.min(out_desc.height - y);
508                let mut x = 0;
509                while x < out_desc.width {
510                    let w = tw.min(out_desc.width - x);
511                    let region = Region::new(x, y, w, h);
512                    let demand = op.input_regions(region, &[base_desc, over_desc]).unwrap();
513
514                    let mut base_cut = TileBuf::zeroed(demand[0], base_desc.pixel).unwrap();
515                    otf_pixels_core::copy_region(
516                        &base_buf.as_tile().unwrap(),
517                        &mut base_cut.as_tile_mut().unwrap(),
518                        demand[0],
519                    )
520                    .unwrap();
521                    let mut over_cut = TileBuf::zeroed(demand[1], over_desc.pixel).unwrap();
522                    otf_pixels_core::copy_region(
523                        &over_buf.as_tile().unwrap(),
524                        &mut over_cut.as_tile_mut().unwrap(),
525                        demand[1],
526                    )
527                    .unwrap();
528
529                    let mut sub = TileBuf::zeroed(region, out_desc.pixel).unwrap();
530                    op.compute(
531                        &[base_cut.as_tile().unwrap(), over_cut.as_tile().unwrap()],
532                        &mut sub.as_tile_mut().unwrap(),
533                    )
534                    .unwrap();
535                    otf_pixels_core::copy_region(
536                        &sub.as_tile().unwrap(),
537                        &mut target.as_tile_mut().unwrap(),
538                        region,
539                    )
540                    .unwrap();
541                    x += w;
542                }
543                y += h;
544            }
545            assert_eq!(
546                target.bytes(),
547                whole.as_slice(),
548                "tiling at {tw}x{th} changed the pixels"
549            );
550        }
551    }
552}