From bc42b62cc4e6732c3b3216b60f61dd1818eb673b Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Tue, 13 Oct 2026 23:35:25 +0300 Subject: [PATCH] Pictures: Remove Background with a classical matte The Picture Format > Remove Background button was a disabled placeholder. picture.removeBackground now flood-fills from every edge pixel through similar colours: each step must be close to the pixel before it (so gradients and soft shadows follow) and to the edge colour it started from (so it can't leak into the subject). That region turns transparent with a one-pixel soft edge, and the picture points at a new PNG; undo brings the original back. Tolerance 0-100 (default 20); the ribbon offers normal, looser and tighter. Pictures whose edges are all subject are left alone, and pictures over 16 megapixels are refused. Co-authored-by: Cursor --- crates/engine/src/cmd/background.rs | 242 ++++++++++++++++++++++++++++ crates/engine/src/cmd/mod.rs | 2 + crates/ui-egui/src/ribbon.rs | 10 +- docs/gaps.md | 3 +- 4 files changed, 253 insertions(+), 4 deletions(-) create mode 100644 crates/engine/src/cmd/background.rs diff --git a/crates/engine/src/cmd/background.rs b/crates/engine/src/cmd/background.rs new file mode 100644 index 00000000..ab881bc0 --- /dev/null +++ b/crates/engine/src/cmd/background.rs @@ -0,0 +1,242 @@ +//! Picture Format ▸ Remove Background: a classical matte, no model. The background is what the +//! picture's edges reach through similar colours: a flood fill from every edge pixel, each step +//! close to the pixel before it (gradients and soft shadows) and to the edge colour it started +//! from (so it can't leak into the subject). It turns transparent, with a one-pixel soft edge. + +use std::collections::VecDeque; + +use deckcraft_model::{ShapeId, ShapeKind}; +use serde_json::{Value, json}; + +use super::*; +use crate::{Result, Session}; + +/// Larger pictures are refused rather than matted slowly (16 megapixels). +const MAX_PIXELS: u64 = 16_000_000; + +pub fn specs() -> Vec { + vec![cmd!( + "picture.removeBackground", + "Remove Background", + ["Picture Format", "Adjust"], + None, + "{tolerance?: 0..100 (how different a colour may be from the edge's, default 20), ids?}", + has_selection, + remove_background + )] +} + +fn dist(a: [u8; 4], b: [u8; 4]) -> f64 { + let d = |i: usize| f64::from(a[i]) - f64::from(b[i]); + (d(0) * d(0) + d(1) * d(1) + d(2) * d(2)).sqrt() +} + +/// Make the background of `img` transparent. `tolerance` is 0–100. Returns how many pixels +/// became transparent. +pub fn matte(img: &mut image::RgbaImage, tolerance: f64) -> usize { + let (w, h) = (img.width() as usize, img.height() as usize); + if w == 0 || h == 0 { + return 0; + } + // Distances run 0–441 (black to white); the step limit follows gradients, the seed limit + // keeps the fill on the edge's colour. + let seed_tol = tolerance.clamp(0.0, 100.0) * 4.41; + let step_tol = (seed_tol * 0.35).max(6.0); + let px = |img: &image::RgbaImage, i: usize| img.get_pixel((i % w) as u32, (i / w) as u32).0; + // 0: subject, 1: background; the seed colour each background pixel was reached from. + let mut bg = vec![false; w * h]; + let mut seed = vec![[0u8; 4]; w * h]; + let mut queue = VecDeque::new(); + let push_edge = |i: usize, bg: &mut [bool], seed: &mut [[u8; 4]], q: &mut VecDeque| { + if let Some(b) = bg.get_mut(i) + && !*b + { + *b = true; + if let Some(s) = seed.get_mut(i) { + *s = px(img, i); + } + q.push_back(i); + } + }; + for x in 0..w { + push_edge(x, &mut bg, &mut seed, &mut queue); + push_edge((h - 1) * w + x, &mut bg, &mut seed, &mut queue); + } + for y in 0..h { + push_edge(y * w, &mut bg, &mut seed, &mut queue); + push_edge(y * w + w - 1, &mut bg, &mut seed, &mut queue); + } + while let Some(i) = queue.pop_front() { + let (x, y) = (i % w, i / w); + let here = px(img, i); + let from = seed.get(i).copied().unwrap_or(here); + let mut near = [None; 4]; + if x > 0 { + near[0] = Some(i - 1); + } + if x + 1 < w { + near[1] = Some(i + 1); + } + if y > 0 { + near[2] = Some(i - w); + } + if y + 1 < h { + near[3] = Some(i + w); + } + for j in near.into_iter().flatten() { + if bg.get(j).copied().unwrap_or(true) { + continue; + } + let c = px(img, j); + // Transparent pixels are background already. + if c[3] < 16 || (dist(c, here) <= step_tol && dist(c, from) <= seed_tol) { + if let Some(b) = bg.get_mut(j) { + *b = true; + } + if let Some(s) = seed.get_mut(j) { + *s = from; + } + queue.push_back(j); + } + } + } + // A background edge that is the subject's colour throughout (the subject touches the + // edge on every side) is no background: leave such a picture alone. + let n = bg.iter().filter(|b| **b).count(); + if n == w * h { + return 0; + } + let is_bg = |x: usize, y: usize| bg.get(y * w + x).copied().unwrap_or(false); + let mut soft = vec![]; + for y in 0..h { + for x in 0..w { + if is_bg(x, y) { + continue; + } + let touching = + (x > 0 && is_bg(x - 1, y)) || (x + 1 < w && is_bg(x + 1, y)) || (y > 0 && is_bg(x, y - 1)) || (y + 1 < h && is_bg(x, y + 1)); + if touching { + soft.push((x, y)); + } + } + } + for (i, b) in bg.iter().enumerate() { + if *b { + img.get_pixel_mut((i % w) as u32, (i / w) as u32).0[3] = 0; + } + } + for (x, y) in soft { + let p = img.get_pixel_mut(x as u32, y as u32); + p.0[3] = (u16::from(p.0[3]) * 2 / 3) as u8; + } + n +} + +fn remove_background(s: &mut Session, p: &Value) -> Result { + let tolerance = f64_or(p, "tolerance", 20.0); + let ids = targets(s, p)?; + let mut done: Vec<(ShapeId, String, Vec)> = vec![]; + { + let st = s.doc()?; + for id in &ids { + let Some(sh) = st.shape(*id) else { continue }; + let ShapeKind::Picture { fill } = &sh.kind else { continue }; + let Some(item) = st.doc.media(fill.media) else { continue }; + let Ok(reader) = image::ImageReader::new(std::io::Cursor::new(item.data.as_slice())).with_guessed_format() else { continue }; + let Ok((iw, ih)) = reader.into_dimensions() else { continue }; + if u64::from(iw) * u64::from(ih) > MAX_PIXELS { + return Err(bad("picture.removeBackground", format!("{iw}×{ih} pixels is too large; compress the picture first"))); + } + let Ok(dynimg) = image::load_from_memory(&item.data) else { continue }; + let mut img = dynimg.to_rgba8(); + if matte(&mut img, tolerance) == 0 { + continue; + } + let mut png = vec![]; + if image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).is_err() { + continue; + } + let stem = item.name.rsplit_once('.').map_or(item.name.as_str(), |(a, _)| a); + done.push((*id, format!("{stem}-no-background.png"), png)); + } + } + if done.is_empty() { + return Err(bad("picture.removeBackground", "no background found: select a picture whose edges show its background")); + } + let n = done.len(); + s.edit(|doc, sel| { + let added: Vec<(ShapeId, deckcraft_model::MediaId)> = + done.into_iter().map(|(id, name, png)| (id, doc.add_media(&name, "image/png", png))).collect(); + let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad("picture.removeBackground", "no slide"))?; + for (id, media) in added { + if let Some(sh) = deckcraft_model::find_shape_mut(shapes, id) + && let ShapeKind::Picture { fill } = &mut sh.kind + { + fill.media = media; + } + } + Ok(()) + })?; + Ok(json!({"pictures": n})) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A red disc on a white-to-grey gradient. + fn disc() -> image::RgbaImage { + image::RgbaImage::from_fn(80, 60, |x, y| { + let (dx, dy) = (f64::from(x) - 40.0, f64::from(y) - 30.0); + if dx * dx + dy * dy < 15.0 * 15.0 { + image::Rgba([220, 30, 30, 255]) + } else { + let g = 255 - (x / 2) as u8; + image::Rgba([g, g, g, 255]) + } + }) + } + + #[test] + fn matte_clears_the_gradient_and_keeps_the_subject() { + let mut img = disc(); + let n = matte(&mut img, 20.0); + assert!(n > 80 * 60 / 2, "{n}"); + assert_eq!(img.get_pixel(0, 0).0[3], 0); + assert_eq!(img.get_pixel(79, 59).0[3], 0, "the darker end of the gradient"); + assert_eq!(img.get_pixel(40, 30).0, [220, 30, 30, 255]); + // The disc's rim is softened, not cut. + let rim = img.get_pixel(40, 16).0[3]; + assert!(rim > 0 && rim < 255 || img.get_pixel(40, 15).0[3] < 255, "{rim}"); + // A picture with nothing but subject at its edges is left alone. + let mut flat = image::RgbaImage::from_pixel(10, 10, image::Rgba([10, 200, 10, 255])); + assert_eq!(matte(&mut flat, 20.0), 0); + assert!(flat.pixels().all(|p| p.0[3] == 255)); + } + + #[test] + fn remove_background_replaces_the_picture_and_undoes() { + let mut png = vec![]; + image::DynamicImage::ImageRgba8(disc()).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let mut s = Session::with_new(); + let r = s.execute("insert.picture", &json!({"data": base64_encode(&png), "name": "disc.png"})).unwrap(); + let id = r["id"].as_u64().unwrap(); + let media_of = |s: &Session| match &s.doc().unwrap().shape(ShapeId(id as u32)).unwrap().kind { + ShapeKind::Picture { fill } => fill.media, + _ => panic!("not a picture"), + }; + let before = media_of(&s); + s.execute("picture.removeBackground", &json!({"ids": [id]})).unwrap(); + let after = media_of(&s); + assert_ne!(before, after); + let item = s.doc().unwrap().doc.media(after).unwrap().clone(); + assert_eq!(item.name, "disc-no-background.png"); + let img = image::load_from_memory(&item.data).unwrap().to_rgba8(); + assert_eq!(img.get_pixel(0, 0).0[3], 0); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(media_of(&s), before); + // Not a picture: refused. + s.execute("shape.insert", &json!({"preset": "rect", "rect": [0, 0, 50, 50]})).unwrap(); + assert!(s.execute("picture.removeBackground", &json!({})).is_err()); + } +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index ce909b02..178a8dea 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -2,6 +2,7 @@ pub mod animation; pub mod arrange; +pub mod background; pub mod design; pub mod edit; pub mod file; @@ -130,6 +131,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(slide::specs()); v.extend(insert::specs()); v.extend(shape::specs()); + v.extend(background::specs()); v.extend(arrange::specs()); v.extend(selection::specs()); v.extend(merge::specs()); diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 428eff0b..c61b2eda 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -2049,8 +2049,14 @@ fn size_group(app: &mut SlideApp, ui: &mut Ui) { fn picture_format(app: &mut SlideApp, ui: &mut Ui) { group(ui, |ui| { - let r = big_button(ui, Icon::RemoveBackground, "Remove\nBackground", false); - let _ = r.on_hover_text("Remove Background (coming soon)"); + let r = big_button(ui, Icon::RemoveBackground, "Remove\nBackground", enabled(app, "picture.removeBackground")); + egui::Popup::menu(&r).show(|ui| { + for (label, tol) in [("Remove Background", 20.0), ("Remove More (looser)", 35.0), ("Remove Less (tighter)", 10.0)] { + if ui.button(label).clicked() { + run(app, "picture.removeBackground", json!({"tolerance": tol})); + } + } + }); let r = big_button(ui, Icon::Corrections, "Corrections", true); egui::Popup::menu(&r).show(|ui| { for b in [-0.4, -0.2, 0.0, 0.2, 0.4] { diff --git a/docs/gaps.md b/docs/gaps.md index a2894b42..808d2d1b 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -185,8 +185,7 @@ Detail in [animation-parity.md](animation-parity.md). 10–16 h; the pane is a * ### Smaller missing rows Zoom (summary/section/slide, 6–10 h), reuse slides (2–4 h), laser pointer (1–2 h), transition -sounds (1–2 h), artistic effects (6–10 h), remove background (6–10 h; needs a model or a classical -matte), compress pictures (2–3 h), insert screenshot (2–3 h), record audio/screen and record slide +sounds (1–2 h), artistic effects (6–10 h), compress pictures (2–3 h), insert screenshot (2–3 h), record audio/screen and record slide show (8–12 h), compare presentations (6–10 h), password protection (4–6 h, ECMA-376 agile encryption), ink to shape/text (6–10 h), icons and 3-D models (8–14 h, needs an open icon set).