use std::path::Path;
use image::{GrayImage, RgbImage, Rgba, RgbaImage};
use crate::geometry::{BoundingBox, Padding, alpha_bounding_box, crop_rgba_image, pad_rgba_image};
use crate::{OutlineError, OutlineResult};
pub fn compose_foreground(rgb: &RgbImage, alpha: &GrayImage) -> OutlineResult<RgbaImage> {
let expected = rgb.dimensions();
let found = alpha.dimensions();
if expected != found {
return Err(OutlineError::AlphaMismatch { expected, found });
}
let (w, h) = rgb.dimensions();
let mut rgba = RgbaImage::new(w, h);
for ((rgb_px, alpha_px), out_px) in rgb.pixels().zip(alpha.pixels()).zip(rgba.pixels_mut()) {
*out_px = Rgba([rgb_px[0], rgb_px[1], rgb_px[2], alpha_px[0]]);
}
Ok(rgba)
}
#[derive(Debug)]
pub struct ForegroundHandle {
image: RgbaImage,
}
impl ForegroundHandle {
pub(crate) fn new(image: RgbaImage) -> Self {
Self { image }
}
pub fn image(&self) -> &RgbaImage {
&self.image
}
pub fn dimensions(&self) -> (u32, u32) {
self.image.dimensions()
}
pub fn into_image(self) -> RgbaImage {
self.image
}
pub fn save(&self, path: impl AsRef<Path>) -> OutlineResult<()> {
self.image.save(path)?;
Ok(())
}
pub fn bounding_box(&self) -> Option<BoundingBox> {
self.bounding_box_with(1)
}
pub fn bounding_box_with(&self, threshold: u8) -> Option<BoundingBox> {
alpha_bounding_box(&self.image, threshold)
}
pub fn pad(self, padding: impl Into<Padding>) -> Self {
let image = pad_rgba_image(&self.image, padding.into(), [0, 0, 0, 0]);
Self { image }
}
pub fn crop(self, bounds: BoundingBox) -> Option<Self> {
if !crate::geometry::crop_bounds_fit_image(bounds, self.image.width(), self.image.height())
{
return None;
}
let image = crop_rgba_image(&self.image, bounds);
Some(Self { image })
}
pub fn crop_to_content(self) -> Option<Self> {
self.crop_to_content_with(1)
}
pub fn crop_to_content_with(self, threshold: u8) -> Option<Self> {
let bounds = alpha_bounding_box(&self.image, threshold)?;
self.crop(bounds)
}
}
#[cfg(test)]
mod tests {
use super::*;
use image::{Luma, Rgb};
fn rgb_image(w: u32, h: u32, color: [u8; 3]) -> RgbImage {
RgbImage::from_pixel(w, h, Rgb(color))
}
fn gray_image(w: u32, h: u32, value: u8) -> GrayImage {
GrayImage::from_pixel(w, h, Luma([value]))
}
mod compose_foreground {
use super::*;
mod unit {
use super::*;
#[test]
fn dimensions_match_success() {
let rgb = rgb_image(2, 2, [255, 0, 0]);
let alpha = gray_image(2, 2, 128);
let result = compose_foreground(&rgb, &alpha).unwrap();
assert_eq!(result.dimensions(), (2, 2));
}
#[test]
fn alpha_too_small_returns_error() {
let rgb = rgb_image(4, 4, [255, 0, 0]);
let alpha = gray_image(2, 2, 128);
let err = compose_foreground(&rgb, &alpha).unwrap_err();
match err {
OutlineError::AlphaMismatch { expected, found } => {
assert_eq!(expected, (4, 4));
assert_eq!(found, (2, 2));
}
other => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn alpha_too_large_returns_error() {
let rgb = rgb_image(2, 2, [255, 0, 0]);
let alpha = gray_image(4, 4, 128);
let err = compose_foreground(&rgb, &alpha).unwrap_err();
match err {
OutlineError::AlphaMismatch { expected, found } => {
assert_eq!(expected, (2, 2));
assert_eq!(found, (4, 4));
}
other => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn full_transparency() {
let rgb = rgb_image(2, 2, [255, 128, 64]);
let alpha = gray_image(2, 2, 0);
let result = compose_foreground(&rgb, &alpha).unwrap();
for px in result.pixels() {
assert_eq!(px.0, [255, 128, 64, 0]);
}
}
#[test]
fn full_opacity() {
let rgb = rgb_image(2, 2, [100, 150, 200]);
let alpha = gray_image(2, 2, 255);
let result = compose_foreground(&rgb, &alpha).unwrap();
for px in result.pixels() {
assert_eq!(px.0, [100, 150, 200, 255]);
}
}
#[test]
fn rgb_channels_preserved() {
let rgb = rgb_image(1, 1, [10, 20, 30]);
let alpha = gray_image(1, 1, 128);
let result = compose_foreground(&rgb, &alpha).unwrap();
let px = result.get_pixel(0, 0);
assert_eq!(px.0[0], 10); assert_eq!(px.0[1], 20); assert_eq!(px.0[2], 30); assert_eq!(px.0[3], 128); }
#[test]
fn per_pixel_values_correct() {
let mut rgb = RgbImage::new(2, 2);
rgb.put_pixel(0, 0, Rgb([10, 20, 30]));
rgb.put_pixel(1, 0, Rgb([40, 50, 60]));
rgb.put_pixel(0, 1, Rgb([70, 80, 90]));
rgb.put_pixel(1, 1, Rgb([100, 110, 120]));
let mut alpha = GrayImage::new(2, 2);
alpha.put_pixel(0, 0, Luma([0]));
alpha.put_pixel(1, 0, Luma([85]));
alpha.put_pixel(0, 1, Luma([170]));
alpha.put_pixel(1, 1, Luma([255]));
let result = compose_foreground(&rgb, &alpha).unwrap();
assert_eq!(result.get_pixel(0, 0).0, [10, 20, 30, 0]);
assert_eq!(result.get_pixel(1, 0).0, [40, 50, 60, 85]);
assert_eq!(result.get_pixel(0, 1).0, [70, 80, 90, 170]);
assert_eq!(result.get_pixel(1, 1).0, [100, 110, 120, 255]);
}
}
mod prop {
use super::*;
use proptest::prelude::*;
proptest! {
#[test]
fn dimensions_preserved(
w in 1u32..20,
h in 1u32..20,
rgb_val in proptest::num::u8::ANY,
alpha_val in proptest::num::u8::ANY
) {
let rgb = RgbImage::from_pixel(w, h, Rgb([rgb_val, rgb_val, rgb_val]));
let alpha = GrayImage::from_pixel(w, h, Luma([alpha_val]));
let result = compose_foreground(&rgb, &alpha).unwrap();
prop_assert_eq!(result.dimensions(), (w, h));
}
#[test]
fn rgb_channels_preserved(
r in proptest::num::u8::ANY,
g in proptest::num::u8::ANY,
b in proptest::num::u8::ANY,
alpha_val in proptest::num::u8::ANY
) {
let rgb = RgbImage::from_pixel(1, 1, Rgb([r, g, b]));
let alpha = GrayImage::from_pixel(1, 1, Luma([alpha_val]));
let result = compose_foreground(&rgb, &alpha).unwrap();
let px = result.get_pixel(0, 0);
prop_assert_eq!(px.0[0], r);
prop_assert_eq!(px.0[1], g);
prop_assert_eq!(px.0[2], b);
}
#[test]
fn alpha_from_grayscale(
alpha_val in proptest::num::u8::ANY
) {
let rgb = RgbImage::from_pixel(1, 1, Rgb([128, 128, 128]));
let alpha = GrayImage::from_pixel(1, 1, Luma([alpha_val]));
let result = compose_foreground(&rgb, &alpha).unwrap();
let px = result.get_pixel(0, 0);
prop_assert_eq!(px.0[3], alpha_val);
}
#[test]
fn dimension_mismatch_errors(
rgb_w in 1u32..10,
rgb_h in 1u32..10,
alpha_w in 1u32..10,
alpha_h in 1u32..10
) {
prop_assume!(rgb_w != alpha_w || rgb_h != alpha_h);
let rgb = RgbImage::from_pixel(rgb_w, rgb_h, Rgb([128, 128, 128]));
let alpha = GrayImage::from_pixel(alpha_w, alpha_h, Luma([128]));
let result = compose_foreground(&rgb, &alpha);
prop_assert!(result.is_err());
if let Err(crate::OutlineError::AlphaMismatch { expected, found }) = result {
prop_assert_eq!(expected, (rgb_w, rgb_h));
prop_assert_eq!(found, (alpha_w, alpha_h));
}
}
#[test]
fn output_matches_inputs_exactly(
r in proptest::num::u8::ANY,
g in proptest::num::u8::ANY,
b in proptest::num::u8::ANY,
a in proptest::num::u8::ANY
) {
let rgb = RgbImage::from_pixel(1, 1, Rgb([r, g, b]));
let alpha = GrayImage::from_pixel(1, 1, Luma([a]));
let result = compose_foreground(&rgb, &alpha).unwrap();
let px = result.get_pixel(0, 0);
prop_assert_eq!(px.0, [r, g, b, a]);
}
}
}
}
#[test]
fn foreground_handle_dimensions_reports_current_canvas() {
let foreground = ForegroundHandle {
image: RgbaImage::from_pixel(4, 3, image::Rgba([0, 0, 0, 0])),
};
assert_eq!(foreground.dimensions(), (4, 3));
let cropped = foreground
.crop(BoundingBox::new(1, 1, 2, 2))
.expect("bounds are inside the image");
assert_eq!(cropped.dimensions(), (2, 2));
let padded = cropped.pad(Padding::new(1, 2, 3, 4));
assert_eq!(padded.dimensions(), (6, 8));
}
#[test]
fn foreground_handle_bounding_box_and_crop_work_on_alpha() {
let mut foreground = ForegroundHandle {
image: RgbaImage::from_pixel(4, 4, image::Rgba([0, 0, 0, 0])),
};
foreground
.image
.put_pixel(2, 1, image::Rgba([10, 20, 30, 200]));
let bounds = foreground.bounding_box().expect("alpha content");
assert_eq!(bounds, BoundingBox::new(2, 1, 1, 1));
let cropped = foreground.crop_to_content().expect("still has content");
assert_eq!(cropped.dimensions(), (1, 1));
assert_eq!(cropped.image().get_pixel(0, 0).0, [10, 20, 30, 200]);
}
#[test]
fn foreground_handle_crop_uses_requested_bounds() {
let mut foreground = ForegroundHandle {
image: RgbaImage::from_pixel(4, 4, image::Rgba([0, 0, 0, 0])),
};
foreground
.image
.put_pixel(2, 1, image::Rgba([10, 20, 30, 200]));
let cropped = foreground
.crop(BoundingBox::new(1, 1, 2, 2))
.expect("bounds are inside the image");
assert_eq!(cropped.dimensions(), (2, 2));
assert_eq!(cropped.image().get_pixel(1, 0).0, [10, 20, 30, 200]);
}
#[test]
fn foreground_handle_crop_rejects_invalid_bounds() {
let foreground = ForegroundHandle {
image: RgbaImage::from_pixel(4, 4, image::Rgba([0, 0, 0, 0])),
};
assert!(foreground.crop(BoundingBox::new(4, 4, 2, 2)).is_none());
let foreground = ForegroundHandle {
image: RgbaImage::from_pixel(4, 4, image::Rgba([0, 0, 0, 0])),
};
assert!(foreground.crop(BoundingBox::new(0, 0, 0, 1)).is_none());
}
#[test]
fn foreground_handle_crop_to_content_with_ignores_low_alpha() {
let mut foreground = ForegroundHandle {
image: RgbaImage::from_pixel(5, 3, image::Rgba([0, 0, 0, 0])),
};
foreground
.image
.put_pixel(1, 1, image::Rgba([10, 20, 30, 32]));
foreground
.image
.put_pixel(3, 1, image::Rgba([40, 50, 60, 200]));
let cropped = foreground
.crop_to_content_with(128)
.expect("foreground has content above threshold");
assert_eq!(cropped.dimensions(), (1, 1));
assert_eq!(cropped.image().get_pixel(0, 0).0, [40, 50, 60, 200]);
}
}