use otf_pixels_core::{ChannelLayout, PixelsError, Region, Result, Tile};
use crate::convert::to_rgba;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Trim {
background: Option<[u8; 4]>,
threshold: f32,
line_art: bool,
}
impl Default for Trim {
fn default() -> Self {
Self {
background: None,
threshold: 10.0,
line_art: false,
}
}
}
impl Trim {
#[must_use]
pub const fn with_background(mut self, rgba: [u8; 4]) -> Self {
self.background = Some(rgba);
self
}
pub fn with_threshold(mut self, threshold: f32) -> Result<Self> {
if !threshold.is_finite() || threshold < 0.0 {
return Err(PixelsError::invalid_argument(
"threshold",
format!("must be finite and not negative, got {threshold}"),
));
}
self.threshold = threshold;
Ok(self)
}
#[must_use]
pub const fn line_art(mut self, line_art: bool) -> Self {
self.line_art = line_art;
self
}
pub fn find(&self, tile: &Tile<'_>) -> Result<Option<Region>> {
let region = tile.region();
if region.width < 3 && region.height < 3 {
return Err(PixelsError::invalid_argument(
"image",
format!(
"an image to trim must be at least 3 pixels on a side, got {}x{}",
region.width, region.height
),
));
}
let format = tile.pixel();
let bpp = format.bytes_per_pixel();
let alpha = matches!(
format.layout(),
ChannelLayout::GrayAlpha | ChannelLayout::Rgba
);
let background = match self.background {
Some(rgba) => rgba.map(|v| f32::from(v) / 255.0),
None => {
let first = tile
.row(region.y)
.and_then(|row| row.get(..bpp))
.ok_or_else(|| PixelsError::graph("trim was given an empty tile"))?;
to_rgba(first, format)
}
};
let flattened = |y: u32| -> Result<Vec<[f32; 4]>> {
let row = tile
.row(y)
.ok_or_else(|| PixelsError::graph(format!("trim tile is missing row {y}")))?;
Ok(row
.chunks_exact(bpp)
.map(|pixel| {
let [r, g, b, a] = to_rgba(pixel, format);
let over = |c: f32, under: f32| c * a + under * (1.0 - a);
[
over(r, background[0]),
over(g, background[1]),
over(b, background[2]),
a,
]
})
.collect())
};
let level = self.threshold / 255.0 - 1e-6;
let content = |pixel: [f32; 4]| {
let differs = |c: f32, b: f32| (c - b).abs() >= level;
let colour = pixel
.iter()
.zip(&background)
.take(3)
.any(|(&c, &b)| differs(c, b));
colour || (alpha && differs(pixel[3], background[3]))
};
let (width, height) = (region.width as usize, region.height as usize);
let mut found: Option<(usize, usize, usize, usize)> = None;
let mut mark = |x: usize, y: usize| {
found = Some(match found {
None => (x, y, x, y),
Some((x0, y0, x1, y1)) => (x0.min(x), y0.min(y), x1.max(x), y1.max(y)),
});
};
if self.line_art {
for y in 0..height {
for (x, &pixel) in flattened(region.y + y as u32)?.iter().enumerate() {
if content(pixel) {
mark(x, y);
}
}
}
} else {
let last = height - 1;
let mut window = [
flattened(region.y)?,
flattened(region.y)?,
flattened(region.y + u32::from(last > 0))?,
];
for y in 0..height {
if y > 0 {
window.rotate_left(1);
window[2] = flattened(region.y + (y + 1).min(last) as u32)?;
}
for x in 0..width {
let columns = [x.saturating_sub(1), x, (x + 1).min(width - 1)];
let mut pixel = [0.0; 4];
for (channel, value) in pixel.iter_mut().enumerate() {
let mut values = [0.0_f32; 9];
for (slot, (row, column)) in values.iter_mut().zip(
window
.iter()
.flat_map(|row| columns.iter().map(move |&c| (row, c))),
) {
*slot = row
.get(column)
.and_then(|p| p.get(channel))
.copied()
.unwrap_or(0.0);
}
values.sort_unstable_by(f32::total_cmp);
*value = values[4];
}
if content(pixel) {
mark(x, y);
}
}
}
}
Ok(found.map(|(x0, y0, x1, y1)| {
Region::new(
region.x + x0 as u32,
region.y + y0 as u32,
(x1 - x0 + 1) as u32,
(y1 - y0 + 1) as u32,
)
}))
}
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::indexing_slicing,
reason = "tests operate on known-good values and assert shapes directly"
)]
mod tests {
use super::*;
use otf_pixels_core::{PixelFormat, TileBuf};
fn image(
width: u32,
height: u32,
format: PixelFormat,
fill: &[u8],
paint: impl Fn(u32, u32) -> Option<Vec<u8>>,
) -> TileBuf {
let mut bytes = Vec::new();
for y in 0..height {
for x in 0..width {
bytes.extend(paint(x, y).unwrap_or_else(|| fill.to_vec()));
}
}
TileBuf::from_vec(Region::new(0, 0, width, height), format, bytes).unwrap()
}
fn find(trim: Trim, buf: &TileBuf) -> Option<Region> {
trim.find(&buf.as_tile().unwrap()).unwrap()
}
#[test]
fn a_block_on_a_plain_border_is_found() {
let buf = image(12, 10, PixelFormat::Rgb8, &[250, 250, 250], |x, y| {
((3..7).contains(&x) && (2..6).contains(&y)).then(|| vec![20, 30, 40])
});
assert_eq!(find(Trim::default(), &buf), Some(Region::new(3, 2, 4, 4)));
}
#[test]
fn a_lone_pixel_is_noise_unless_the_image_is_line_art() {
let buf = image(9, 9, PixelFormat::Gray8, &[255], |x, y| {
(x == 4 && y == 5).then(|| vec![0])
});
assert_eq!(find(Trim::default(), &buf), None);
assert_eq!(
find(Trim::default().line_art(true), &buf),
Some(Region::new(4, 5, 1, 1))
);
}
#[test]
fn the_threshold_is_the_smallest_difference_that_counts() {
let with = |value: u8| {
image(5, 5, PixelFormat::Gray8, &[100], move |x, y| {
(x >= 2 && y >= 2).then(|| vec![value])
})
};
let trim = Trim::default().line_art(true);
assert_eq!(find(trim, &with(109)), None, "9 is under the default 10");
assert_eq!(find(trim, &with(110)), Some(Region::new(2, 2, 3, 3)));
assert_eq!(find(trim, &with(91)), None, "differences are absolute");
let loose = trim.with_threshold(30.0).unwrap();
assert_eq!(find(loose, &with(110)), None);
assert!(Trim::default().with_threshold(-1.0).is_err());
assert!(Trim::default().with_threshold(f32::NAN).is_err());
}
#[test]
fn an_explicit_background_replaces_the_top_left_pixel() {
let buf = image(6, 6, PixelFormat::Rgb8, &[0, 0, 0], |x, y| {
((x == 0 && y == 0) || ((2..4).contains(&x) && (2..4).contains(&y)))
.then(|| vec![255, 0, 0])
});
let trim = Trim::default().line_art(true);
assert_eq!(find(trim, &buf), Some(Region::new(0, 0, 6, 6)));
assert_eq!(
find(trim.with_background([0, 0, 0, 255]), &buf),
Some(Region::new(0, 0, 4, 4))
);
}
#[test]
fn a_transparent_border_is_trimmed_whatever_colour_it_hides() {
let buf = image(8, 8, PixelFormat::Rgba8, &[0, 0, 0, 0], |x, y| {
if (2..6).contains(&x) && (3..6).contains(&y) {
Some(vec![0, 0, 0, 255])
} else {
Some(vec![(x * 40) as u8, (y * 30) as u8, 7, 0])
}
});
assert_eq!(find(Trim::default(), &buf), Some(Region::new(2, 3, 4, 3)));
}
#[test]
fn an_image_of_nothing_but_background_has_no_box() {
let buf = image(
4,
4,
PixelFormat::Rgba16,
&[1, 2, 3, 4, 5, 6, 7, 8],
|_, _| None,
);
assert_eq!(find(Trim::default(), &buf), None);
}
#[test]
fn wide_formats_measure_the_threshold_in_8_bit_units() {
let with = |value: u16| {
image(
4,
4,
PixelFormat::Gray16,
&0_u16.to_ne_bytes(),
move |x, _| (x == 3).then(|| value.to_ne_bytes().to_vec()),
)
};
let trim = Trim::default().line_art(true);
assert_eq!(find(trim, &with(9 * 257 + 128)), None);
assert_eq!(find(trim, &with(16 * 257)), Some(Region::new(3, 0, 1, 4)));
}
#[test]
fn a_tiny_image_is_refused_as_sharp_refuses_it() {
let buf = image(2, 2, PixelFormat::Gray8, &[0], |_, _| None);
assert!(Trim::default().find(&buf.as_tile().unwrap()).is_err());
let buf = image(1, 5, PixelFormat::Gray8, &[0], |_, _| None);
assert!(Trim::default().find(&buf.as_tile().unwrap()).is_ok());
}
}