use image::{DynamicImage, GenericImageView, RgbaImage};
use std::collections::VecDeque;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum TransparentError {
#[error("Image error: {0}")]
Image(#[from] image::ImageError),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Empty image")]
Empty,
}
#[derive(Debug, Clone)]
pub struct TransparentOptions {
pub background: Option<[u8; 3]>,
pub tolerance: f64,
pub despill: bool,
pub despill_band: u32,
pub shrink: u32,
}
impl Default for TransparentOptions {
fn default() -> Self {
Self {
background: None,
tolerance: 200.0,
despill: false,
despill_band: 3,
shrink: 0,
}
}
}
pub const CORNER_PATCH: u32 = 20;
#[derive(Debug, Clone)]
pub struct TransparentResult {
pub background: [u8; 3],
pub transparent_pixels: u64,
pub opaque_pixels: u64,
}
pub fn estimate_background(img: &image::RgbImage) -> [u8; 3] {
let (w, h) = (img.width(), img.height());
let patch = CORNER_PATCH.min(w / 4).min(h / 4).max(1);
let mut r = Vec::new();
let mut g = Vec::new();
let mut b = Vec::new();
let corners = [
(0u32, 0u32),
(w - patch, 0),
(0, h - patch),
(w - patch, h - patch),
];
for (cx, cy) in corners {
for y in cy..cy + patch {
for x in cx..cx + patch {
let p = img.get_pixel(x, y);
r.push(p[0]);
g.push(p[1]);
b.push(p[2]);
}
}
}
[median(&mut r), median(&mut g), median(&mut b)]
}
fn median(v: &mut [u8]) -> u8 {
v.sort_unstable();
v[v.len() / 2]
}
pub fn parse_hex_color(s: &str) -> Option<[u8; 3]> {
let s = s.trim().trim_start_matches('#');
if s.len() != 6 {
return None;
}
let r = u8::from_str_radix(&s[0..2], 16).ok()?;
let g = u8::from_str_radix(&s[2..4], 16).ok()?;
let b = u8::from_str_radix(&s[4..6], 16).ok()?;
Some([r, g, b])
}
pub fn apply_transparency(
img: &DynamicImage,
opts: &TransparentOptions,
) -> Result<(RgbaImage, TransparentResult), TransparentError> {
let (w, h) = img.dimensions();
if w == 0 || h == 0 {
return Err(TransparentError::Empty);
}
let rgb = img.to_rgb8();
let bg = opts.background.unwrap_or_else(|| estimate_background(&rgb));
let mut out = img.to_rgba8();
apply_transparency_to_rgba(
&mut out,
bg,
opts.tolerance,
opts.despill,
opts.despill_band,
opts.shrink,
);
let mut transparent_pixels = 0u64;
let mut opaque_pixels = 0u64;
for p in out.pixels() {
if p[3] == 0 {
transparent_pixels += 1;
} else {
opaque_pixels += 1;
}
}
Ok((
out,
TransparentResult {
background: bg,
transparent_pixels,
opaque_pixels,
},
))
}
pub fn apply_transparency_to_rgba(
img: &mut RgbaImage,
background: [u8; 3],
tolerance: f64,
despill: bool,
despill_band: u32,
shrink: u32,
) {
let (w, h) = (img.width(), img.height());
if w == 0 || h == 0 {
return;
}
let n = (w as usize) * (h as usize);
let bg = [
background[0] as f64,
background[1] as f64,
background[2] as f64,
];
let tol_sq = tolerance.max(0.0).powi(2);
let mut is_candidate = vec![false; n];
for y in 0..h {
for x in 0..w {
let i = (y as usize) * (w as usize) + (x as usize);
let p = img.get_pixel(x, y);
if p[3] == 0 {
is_candidate[i] = true;
continue;
}
let dr = p[0] as f64 - bg[0];
let dg = p[1] as f64 - bg[1];
let db = p[2] as f64 - bg[2];
if dr * dr + dg * dg + db * db <= tol_sq {
is_candidate[i] = true;
}
}
}
let mut flooded = flood_from_corners(&is_candidate, w, h);
if despill && despill_band > 0 {
apply_despill(img, &flooded, bg, despill_band, w, h);
}
if shrink > 0 {
grow_flood(&mut flooded, shrink, w, h);
}
for y in 0..h {
for x in 0..w {
let i = (y as usize) * (w as usize) + (x as usize);
if flooded[i] {
let mut p = *img.get_pixel(x, y);
p[3] = 0;
img.put_pixel(x, y, p);
}
}
}
}
fn flood_from_corners(is_candidate: &[bool], w: u32, h: u32) -> Vec<bool> {
let n = (w as usize) * (h as usize);
let mut flooded = vec![false; n];
let mut queue: VecDeque<(u32, u32)> = VecDeque::new();
let corners = [(0u32, 0u32), (w - 1, 0), (0, h - 1), (w - 1, h - 1)];
for &(x, y) in &corners {
let i = (y as usize) * (w as usize) + (x as usize);
if is_candidate[i] && !flooded[i] {
flooded[i] = true;
queue.push_back((x, y));
}
}
while let Some((x, y)) = queue.pop_front() {
let nbrs: [(i32, i32); 4] = [(-1, 0), (1, 0), (0, -1), (0, 1)];
for (dx, dy) in nbrs {
let nx = x as i32 + dx;
let ny = y as i32 + dy;
if nx < 0 || ny < 0 || nx >= w as i32 || ny >= h as i32 {
continue;
}
let ni = (ny as usize) * (w as usize) + (nx as usize);
if is_candidate[ni] && !flooded[ni] {
flooded[ni] = true;
queue.push_back((nx as u32, ny as u32));
}
}
}
flooded
}
fn grow_flood(flooded: &mut [bool], radius: u32, w: u32, h: u32) {
let n = (w as usize) * (h as usize);
let mut dist = vec![u32::MAX; n];
let mut queue: VecDeque<(u32, u32)> = VecDeque::new();
for y in 0..h {
for x in 0..w {
let i = (y as usize) * (w as usize) + (x as usize);
if flooded[i] {
dist[i] = 0;
queue.push_back((x, y));
}
}
}
while let Some((x, y)) = queue.pop_front() {
let i = (y as usize) * (w as usize) + (x as usize);
let d_here = dist[i];
if d_here >= radius {
continue;
}
let nbrs: [(i32, i32); 4] = [(-1, 0), (1, 0), (0, -1), (0, 1)];
for (dx, dy) in nbrs {
let nx = x as i32 + dx;
let ny = y as i32 + dy;
if nx < 0 || ny < 0 || nx >= w as i32 || ny >= h as i32 {
continue;
}
let ni = (ny as usize) * (w as usize) + (nx as usize);
if dist[ni] > d_here + 1 {
dist[ni] = d_here + 1;
flooded[ni] = true;
queue.push_back((nx as u32, ny as u32));
}
}
}
}
fn apply_despill(img: &mut RgbaImage, flooded: &[bool], bg: [f64; 3], band: u32, w: u32, h: u32) {
let mut high_ch: Vec<usize> = Vec::new();
let mut low_ch: Vec<usize> = Vec::new();
for (c, &v) in bg.iter().enumerate() {
if v > 128.0 {
high_ch.push(c);
} else {
low_ch.push(c);
}
}
if high_ch.is_empty() || low_ch.is_empty() {
return;
}
let n = (w as usize) * (h as usize);
let mut dist = vec![u32::MAX; n];
let mut queue: VecDeque<(u32, u32)> = VecDeque::new();
for y in 0..h {
for x in 0..w {
let i = (y as usize) * (w as usize) + (x as usize);
if flooded[i] {
dist[i] = 0;
queue.push_back((x, y));
}
}
}
while let Some((x, y)) = queue.pop_front() {
let i = (y as usize) * (w as usize) + (x as usize);
let d_here = dist[i];
if d_here >= band {
continue;
}
let nbrs: [(i32, i32); 4] = [(-1, 0), (1, 0), (0, -1), (0, 1)];
for (dx, dy) in nbrs {
let nx = x as i32 + dx;
let ny = y as i32 + dy;
if nx < 0 || ny < 0 || nx >= w as i32 || ny >= h as i32 {
continue;
}
let ni = (ny as usize) * (w as usize) + (nx as usize);
if flooded[ni] {
continue;
}
if dist[ni] > d_here + 1 {
dist[ni] = d_here + 1;
queue.push_back((nx as u32, ny as u32));
}
}
}
for y in 0..h {
for x in 0..w {
let i = (y as usize) * (w as usize) + (x as usize);
if flooded[i] || dist[i] == u32::MAX {
continue;
}
let p = img.get_pixel(x, y);
if p[3] == 0 {
continue;
}
let high_avg = high_ch.iter().map(|&c| p[c] as f64).sum::<f64>() / high_ch.len() as f64;
let low_avg = low_ch.iter().map(|&c| p[c] as f64).sum::<f64>() / low_ch.len() as f64;
let spill = (high_avg - low_avg).max(0.0);
if spill <= 0.0 {
continue;
}
let mut new_rgba = [p[0], p[1], p[2], p[3]];
for &c in &high_ch {
new_rgba[c] = (p[c] as f64 - spill).clamp(0.0, 255.0).round() as u8;
}
img.put_pixel(x, y, image::Rgba(new_rgba));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use image::{Rgb, RgbImage, Rgba, RgbaImage};
fn solid(w: u32, h: u32, c: [u8; 3]) -> RgbImage {
RgbImage::from_pixel(w, h, Rgb(c))
}
#[test]
fn parse_hex_color_handles_leading_hash() {
assert_eq!(parse_hex_color("#FF00FF"), Some([255, 0, 255]));
assert_eq!(parse_hex_color("ff00ff"), Some([255, 0, 255]));
assert_eq!(parse_hex_color("#fffff"), None);
assert_eq!(parse_hex_color("not a color"), None);
}
#[test]
fn estimate_background_on_uniform_image() {
let img = solid(100, 100, [255, 0, 255]);
assert_eq!(estimate_background(&img), [255, 0, 255]);
}
#[test]
fn pure_background_becomes_fully_transparent() {
let img = DynamicImage::ImageRgb8(solid(40, 40, [255, 0, 255]));
let (out, res) = apply_transparency(&img, &TransparentOptions::default()).unwrap();
assert_eq!(res.background, [255, 0, 255]);
assert_eq!(res.transparent_pixels as u32, out.width() * out.height());
for p in out.pixels() {
assert_eq!(p[3], 0);
}
}
#[test]
fn foreground_pixel_stays_opaque_and_unchanged() {
let mut img = solid(20, 20, [255, 0, 255]);
img.put_pixel(10, 10, Rgb([0, 0, 0]));
let (out, _) = apply_transparency(
&DynamicImage::ImageRgb8(img),
&TransparentOptions::default(),
)
.unwrap();
let px = out.get_pixel(10, 10);
assert_eq!((px[0], px[1], px[2], px[3]), (0, 0, 0, 255));
}
#[test]
fn user_supplied_background_overrides_auto_detect() {
let img = solid(30, 30, [0, 0, 0]);
let opts = TransparentOptions {
background: Some([255, 0, 255]),
..Default::default()
};
let (out, res) = apply_transparency(&DynamicImage::ImageRgb8(img), &opts).unwrap();
assert_eq!(res.background, [255, 0, 255]);
for p in out.pixels() {
assert_eq!(p[3], 255);
assert_eq!((p[0], p[1], p[2]), (0, 0, 0));
}
}
#[test]
fn near_bg_pixel_surrounded_by_foreground_stays_opaque() {
let mut img = solid(20, 20, [255, 0, 255]);
for y in 5..15 {
for x in 5..15 {
img.put_pixel(x, y, Rgb([0, 0, 0]));
}
}
img.put_pixel(10, 10, Rgb([240, 20, 240]));
let (out, _) = apply_transparency(
&DynamicImage::ImageRgb8(img),
&TransparentOptions::default(),
)
.unwrap();
let px = out.get_pixel(10, 10);
assert_eq!(px[3], 255, "interior near-bg pixel must stay opaque");
assert_eq!((px[0], px[1], px[2]), (240, 20, 240));
}
#[test]
fn near_bg_pixel_connected_to_corner_gets_keyed_out() {
let mut img = solid(20, 20, [255, 0, 255]);
for x in 0..20 {
img.put_pixel(x, 0, Rgb([240, 20, 240]));
}
let (out, _) = apply_transparency(
&DynamicImage::ImageRgb8(img),
&TransparentOptions::default(),
)
.unwrap();
for x in 0..20 {
assert_eq!(
out.get_pixel(x, 0)[3],
0,
"top-row x={x} should have been flooded"
);
}
}
#[test]
fn pre_existing_transparent_pixels_stay_transparent() {
let mut rgba = RgbaImage::from_pixel(10, 10, Rgba([255, 0, 255, 255]));
rgba.put_pixel(5, 5, Rgba([123, 45, 67, 0]));
let (out, _) = apply_transparency(
&DynamicImage::ImageRgba8(rgba),
&TransparentOptions::default(),
)
.unwrap();
assert_eq!(out.get_pixel(5, 5)[3], 0);
}
}