Skip to main content

otf_pixels_ops/
trim.rs

1//! [`Trim`] — find the box inside a border of near-background pixels
2//! (sharp's `trim`, libvips' `find_trim`).
3//!
4//! Unlike every other op here, a trim's output shape depends on pixel
5//! values, so it cannot be declared when the graph is built. This is
6//! therefore not an [`Op`](otf_pixels_core::Op) but a search over pixels
7//! already computed: the facade evaluates the pipeline so far, finds the box,
8//! and crops to it.
9//!
10//! # What counts as background
11//!
12//! As in libvips: alpha is first flattened onto the background colour, a 3x3
13//! median removes isolated noise (unless the image is line art, where a
14//! one-pixel line is content), and a pixel is content when any colour channel
15//! differs from the background by at least the threshold. sharp also searches
16//! the alpha channel on its own and takes the union, so a transparent border
17//! is trimmed even where the colour under it is not the background; so does
18//! this.
19
20use otf_pixels_core::{ChannelLayout, PixelsError, Region, Result, Tile};
21
22use crate::convert::to_rgba;
23
24/// Options for finding a trim box.
25#[derive(Debug, Clone, Copy, PartialEq)]
26pub struct Trim {
27    background: Option<[u8; 4]>,
28    threshold: f32,
29    line_art: bool,
30}
31
32impl Default for Trim {
33    /// sharp's defaults: the top-left pixel is the background, the threshold
34    /// is 10, and the image is not line art.
35    fn default() -> Self {
36        Self {
37            background: None,
38            threshold: 10.0,
39            line_art: false,
40        }
41    }
42}
43
44impl Trim {
45    /// Trim this RGBA colour rather than the top-left pixel's.
46    #[must_use]
47    pub const fn with_background(mut self, rgba: [u8; 4]) -> Self {
48        self.background = Some(rgba);
49        self
50    }
51
52    /// How far, in 8-bit units at any depth, a channel may differ from the
53    /// background and still be background.
54    ///
55    /// # Errors
56    ///
57    /// Returns [`PixelsError::InvalidArgument`] unless `threshold` is finite
58    /// and not negative.
59    pub fn with_threshold(mut self, threshold: f32) -> Result<Self> {
60        if !threshold.is_finite() || threshold < 0.0 {
61            return Err(PixelsError::invalid_argument(
62                "threshold",
63                format!("must be finite and not negative, got {threshold}"),
64            ));
65        }
66        self.threshold = threshold;
67        Ok(self)
68    }
69
70    /// Skip the median, so a line one pixel wide counts as content (sharp's
71    /// `lineArt`).
72    #[must_use]
73    pub const fn line_art(mut self, line_art: bool) -> Self {
74        self.line_art = line_art;
75        self
76    }
77
78    /// The box of `tile` that is not background, in the tile's coordinates,
79    /// or `None` if every pixel is background (sharp then leaves the image
80    /// alone).
81    ///
82    /// # Errors
83    ///
84    /// Returns [`PixelsError::InvalidArgument`] for an image narrower and
85    /// shorter than 3 pixels, which sharp refuses too.
86    pub fn find(&self, tile: &Tile<'_>) -> Result<Option<Region>> {
87        let region = tile.region();
88        if region.width < 3 && region.height < 3 {
89            return Err(PixelsError::invalid_argument(
90                "image",
91                format!(
92                    "an image to trim must be at least 3 pixels on a side, got {}x{}",
93                    region.width, region.height
94                ),
95            ));
96        }
97        let format = tile.pixel();
98        let bpp = format.bytes_per_pixel();
99        let alpha = matches!(
100            format.layout(),
101            ChannelLayout::GrayAlpha | ChannelLayout::Rgba
102        );
103
104        // A row as background-flattened colour plus alpha, all 0..=1.
105        let background = match self.background {
106            Some(rgba) => rgba.map(|v| f32::from(v) / 255.0),
107            None => {
108                let first = tile
109                    .row(region.y)
110                    .and_then(|row| row.get(..bpp))
111                    .ok_or_else(|| PixelsError::graph("trim was given an empty tile"))?;
112                to_rgba(first, format)
113            }
114        };
115        let flattened = |y: u32| -> Result<Vec<[f32; 4]>> {
116            let row = tile
117                .row(y)
118                .ok_or_else(|| PixelsError::graph(format!("trim tile is missing row {y}")))?;
119            Ok(row
120                .chunks_exact(bpp)
121                .map(|pixel| {
122                    let [r, g, b, a] = to_rgba(pixel, format);
123                    let over = |c: f32, under: f32| c * a + under * (1.0 - a);
124                    [
125                        over(r, background[0]),
126                        over(g, background[1]),
127                        over(b, background[2]),
128                        a,
129                    ]
130                })
131                .collect())
132        };
133
134        // A difference reaching the threshold is content. The small allowance
135        // keeps an exact 8-bit difference from rounding below it.
136        let level = self.threshold / 255.0 - 1e-6;
137        let content = |pixel: [f32; 4]| {
138            let differs = |c: f32, b: f32| (c - b).abs() >= level;
139            let colour = pixel
140                .iter()
141                .zip(&background)
142                .take(3)
143                .any(|(&c, &b)| differs(c, b));
144            colour || (alpha && differs(pixel[3], background[3]))
145        };
146
147        let (width, height) = (region.width as usize, region.height as usize);
148        let mut found: Option<(usize, usize, usize, usize)> = None;
149        let mut mark = |x: usize, y: usize| {
150            found = Some(match found {
151                None => (x, y, x, y),
152                Some((x0, y0, x1, y1)) => (x0.min(x), y0.min(y), x1.max(x), y1.max(y)),
153            });
154        };
155
156        if self.line_art {
157            for y in 0..height {
158                for (x, &pixel) in flattened(region.y + y as u32)?.iter().enumerate() {
159                    if content(pixel) {
160                        mark(x, y);
161                    }
162                }
163            }
164        } else {
165            // Three rows at a time, the edges repeated as libvips' median
166            // does, so each row is flattened once.
167            let last = height - 1;
168            let mut window = [
169                flattened(region.y)?,
170                flattened(region.y)?,
171                flattened(region.y + u32::from(last > 0))?,
172            ];
173            for y in 0..height {
174                if y > 0 {
175                    window.rotate_left(1);
176                    window[2] = flattened(region.y + (y + 1).min(last) as u32)?;
177                }
178                for x in 0..width {
179                    let columns = [x.saturating_sub(1), x, (x + 1).min(width - 1)];
180                    let mut pixel = [0.0; 4];
181                    for (channel, value) in pixel.iter_mut().enumerate() {
182                        let mut values = [0.0_f32; 9];
183                        for (slot, (row, column)) in values.iter_mut().zip(
184                            window
185                                .iter()
186                                .flat_map(|row| columns.iter().map(move |&c| (row, c))),
187                        ) {
188                            *slot = row
189                                .get(column)
190                                .and_then(|p| p.get(channel))
191                                .copied()
192                                .unwrap_or(0.0);
193                        }
194                        values.sort_unstable_by(f32::total_cmp);
195                        *value = values[4];
196                    }
197                    if content(pixel) {
198                        mark(x, y);
199                    }
200                }
201            }
202        }
203
204        Ok(found.map(|(x0, y0, x1, y1)| {
205            Region::new(
206                region.x + x0 as u32,
207                region.y + y0 as u32,
208                (x1 - x0 + 1) as u32,
209                (y1 - y0 + 1) as u32,
210            )
211        }))
212    }
213}
214
215#[cfg(test)]
216#[allow(
217    clippy::unwrap_used,
218    clippy::indexing_slicing,
219    reason = "tests operate on known-good values and assert shapes directly"
220)]
221mod tests {
222    use super::*;
223    use otf_pixels_core::{PixelFormat, TileBuf};
224
225    /// A `width` x `height` image of `fill` with `paint` over the pixels it
226    /// picks.
227    fn image(
228        width: u32,
229        height: u32,
230        format: PixelFormat,
231        fill: &[u8],
232        paint: impl Fn(u32, u32) -> Option<Vec<u8>>,
233    ) -> TileBuf {
234        let mut bytes = Vec::new();
235        for y in 0..height {
236            for x in 0..width {
237                bytes.extend(paint(x, y).unwrap_or_else(|| fill.to_vec()));
238            }
239        }
240        TileBuf::from_vec(Region::new(0, 0, width, height), format, bytes).unwrap()
241    }
242
243    fn find(trim: Trim, buf: &TileBuf) -> Option<Region> {
244        trim.find(&buf.as_tile().unwrap()).unwrap()
245    }
246
247    #[test]
248    fn a_block_on_a_plain_border_is_found() {
249        let buf = image(12, 10, PixelFormat::Rgb8, &[250, 250, 250], |x, y| {
250            ((3..7).contains(&x) && (2..6).contains(&y)).then(|| vec![20, 30, 40])
251        });
252        assert_eq!(find(Trim::default(), &buf), Some(Region::new(3, 2, 4, 4)));
253    }
254
255    #[test]
256    fn a_lone_pixel_is_noise_unless_the_image_is_line_art() {
257        let buf = image(9, 9, PixelFormat::Gray8, &[255], |x, y| {
258            (x == 4 && y == 5).then(|| vec![0])
259        });
260        assert_eq!(find(Trim::default(), &buf), None);
261        assert_eq!(
262            find(Trim::default().line_art(true), &buf),
263            Some(Region::new(4, 5, 1, 1))
264        );
265    }
266
267    #[test]
268    fn the_threshold_is_the_smallest_difference_that_counts() {
269        let with = |value: u8| {
270            image(5, 5, PixelFormat::Gray8, &[100], move |x, y| {
271                (x >= 2 && y >= 2).then(|| vec![value])
272            })
273        };
274        let trim = Trim::default().line_art(true);
275        assert_eq!(find(trim, &with(109)), None, "9 is under the default 10");
276        assert_eq!(find(trim, &with(110)), Some(Region::new(2, 2, 3, 3)));
277        assert_eq!(find(trim, &with(91)), None, "differences are absolute");
278        let loose = trim.with_threshold(30.0).unwrap();
279        assert_eq!(find(loose, &with(110)), None);
280        assert!(Trim::default().with_threshold(-1.0).is_err());
281        assert!(Trim::default().with_threshold(f32::NAN).is_err());
282    }
283
284    #[test]
285    fn an_explicit_background_replaces_the_top_left_pixel() {
286        // The top-left pixel is content here, so only a given background
287        // finds the black border.
288        let buf = image(6, 6, PixelFormat::Rgb8, &[0, 0, 0], |x, y| {
289            ((x == 0 && y == 0) || ((2..4).contains(&x) && (2..4).contains(&y)))
290                .then(|| vec![255, 0, 0])
291        });
292        let trim = Trim::default().line_art(true);
293        assert_eq!(find(trim, &buf), Some(Region::new(0, 0, 6, 6)));
294        assert_eq!(
295            find(trim.with_background([0, 0, 0, 255]), &buf),
296            Some(Region::new(0, 0, 4, 4))
297        );
298    }
299
300    #[test]
301    fn a_transparent_border_is_trimmed_whatever_colour_it_hides() {
302        let buf = image(8, 8, PixelFormat::Rgba8, &[0, 0, 0, 0], |x, y| {
303            if (2..6).contains(&x) && (3..6).contains(&y) {
304                Some(vec![0, 0, 0, 255])
305            } else {
306                // Invisible noise in the colour channels.
307                Some(vec![(x * 40) as u8, (y * 30) as u8, 7, 0])
308            }
309        });
310        assert_eq!(find(Trim::default(), &buf), Some(Region::new(2, 3, 4, 3)));
311    }
312
313    #[test]
314    fn an_image_of_nothing_but_background_has_no_box() {
315        let buf = image(
316            4,
317            4,
318            PixelFormat::Rgba16,
319            &[1, 2, 3, 4, 5, 6, 7, 8],
320            |_, _| None,
321        );
322        assert_eq!(find(Trim::default(), &buf), None);
323    }
324
325    #[test]
326    fn wide_formats_measure_the_threshold_in_8_bit_units() {
327        // 9.5 in 8-bit units is under the threshold; 16 is over it.
328        let with = |value: u16| {
329            image(
330                4,
331                4,
332                PixelFormat::Gray16,
333                &0_u16.to_ne_bytes(),
334                move |x, _| (x == 3).then(|| value.to_ne_bytes().to_vec()),
335            )
336        };
337        let trim = Trim::default().line_art(true);
338        assert_eq!(find(trim, &with(9 * 257 + 128)), None);
339        assert_eq!(find(trim, &with(16 * 257)), Some(Region::new(3, 0, 1, 4)));
340    }
341
342    #[test]
343    fn a_tiny_image_is_refused_as_sharp_refuses_it() {
344        let buf = image(2, 2, PixelFormat::Gray8, &[0], |_, _| None);
345        assert!(Trim::default().find(&buf.as_tile().unwrap()).is_err());
346        // One long side is enough.
347        let buf = image(1, 5, PixelFormat::Gray8, &[0], |_, _| None);
348        assert!(Trim::default().find(&buf.as_tile().unwrap()).is_ok());
349    }
350}