294 lines
10 KiB
Rust
294 lines
10 KiB
Rust
//! Post-capture image effects: rounded corners and drop shadow.
|
||
//!
|
||
//! Pipeline:
|
||
//! RgbaImage (captured)
|
||
//! → apply_rounded_corners() – clips corners to transparent via tiny-skia mask
|
||
//! → apply_drop_shadow() – composites a blurred shadow beneath the image
|
||
//! → final RgbaImage (may be larger when shadow is added)
|
||
//!
|
||
//! Performance notes:
|
||
//! - Box blur uses a sliding-window algorithm: O(W*H) regardless of radius.
|
||
//! Three passes of the box filter approximate a Gaussian.
|
||
//! - Pixel format conversions between RgbaImage and tiny-skia Pixmap are done
|
||
//! with a single pass each way.
|
||
//! - This module is called from a background thread in review.rs so the UI
|
||
//! never blocks.
|
||
|
||
use crate::config::EffectsConfig;
|
||
use image::RgbaImage;
|
||
use tiny_skia::{
|
||
BlendMode, Color, FillRule, Paint, Path, PathBuilder, Pixmap, PixmapPaint, Transform,
|
||
};
|
||
|
||
/// Apply all configured effects in order. Returns a new image.
|
||
pub fn apply_effects(img: RgbaImage, cfg: &EffectsConfig) -> RgbaImage {
|
||
let img = if cfg.rounded_corners {
|
||
apply_rounded_corners(img, cfg.corner_radius)
|
||
} else {
|
||
img
|
||
};
|
||
if cfg.drop_shadow {
|
||
apply_drop_shadow(
|
||
img,
|
||
cfg.shadow_blur_radius,
|
||
cfg.shadow_offset_x,
|
||
cfg.shadow_offset_y,
|
||
cfg.shadow_color,
|
||
)
|
||
} else {
|
||
img
|
||
}
|
||
}
|
||
|
||
// ─── Rounded corners ─────────────────────────────────────────────────────────
|
||
|
||
pub fn apply_rounded_corners(img: RgbaImage, radius: f32) -> RgbaImage {
|
||
let (w, h) = img.dimensions();
|
||
let mut mask = Pixmap::new(w, h).expect("mask pixmap");
|
||
let path = rounded_rect_path(0.0, 0.0, w as f32, h as f32, radius);
|
||
let mut paint = Paint::default();
|
||
paint.set_color(Color::WHITE);
|
||
paint.anti_alias = true;
|
||
mask.fill_path(&path, &paint, FillRule::Winding, Transform::identity(), None);
|
||
|
||
let mut pixmap = rgba_image_to_pixmap(&img);
|
||
let mut dst_paint = PixmapPaint::default();
|
||
dst_paint.blend_mode = BlendMode::DestinationIn;
|
||
pixmap.draw_pixmap(0, 0, mask.as_ref(), &dst_paint, Transform::identity(), None);
|
||
pixmap_to_rgba_image(pixmap)
|
||
}
|
||
|
||
// ─── Drop shadow ─────────────────────────────────────────────────────────────
|
||
|
||
pub fn apply_drop_shadow(
|
||
img: RgbaImage,
|
||
blur_radius: f32,
|
||
offset_x: f32,
|
||
offset_y: f32,
|
||
shadow_color: [u8; 4],
|
||
) -> RgbaImage {
|
||
let (iw, ih) = img.dimensions();
|
||
let br = blur_radius.ceil() as u32;
|
||
|
||
let extra_left = br.saturating_sub((-offset_x).max(0.0) as u32);
|
||
let extra_top = br.saturating_sub((-offset_y).max(0.0) as u32);
|
||
let extra_right = br + offset_x.max(0.0) as u32;
|
||
let extra_bottom = br + offset_y.max(0.0) as u32;
|
||
|
||
let canvas_w = iw + extra_left + extra_right;
|
||
let canvas_h = ih + extra_top + extra_bottom;
|
||
|
||
// 1. Place the image silhouette at the shadow position.
|
||
let mut shadow_pixmap = Pixmap::new(canvas_w, canvas_h).expect("shadow pixmap");
|
||
let img_pixmap = rgba_image_to_pixmap(&img);
|
||
let shadow_x = (extra_left as f32 + offset_x) as i32;
|
||
let shadow_y = (extra_top as f32 + offset_y) as i32;
|
||
|
||
let mut sp = PixmapPaint::default();
|
||
sp.blend_mode = BlendMode::Source;
|
||
shadow_pixmap.draw_pixmap(shadow_x, shadow_y, img_pixmap.as_ref(), &sp, Transform::identity(), None);
|
||
|
||
// 2. Tint the silhouette with the shadow colour.
|
||
tint_pixmap_as_shadow(&mut shadow_pixmap, shadow_color);
|
||
|
||
// 3. Blur the shadow (sliding-window box blur, 3 passes).
|
||
let shadow_img = pixmap_to_rgba_image(shadow_pixmap);
|
||
let blurred = box_blur_rgba(&shadow_img, br);
|
||
let blurred_pixmap = rgba_image_to_pixmap(&blurred);
|
||
|
||
// 4. Composite: shadow first, image on top.
|
||
let mut canvas = Pixmap::new(canvas_w, canvas_h).expect("canvas pixmap");
|
||
let mut p = PixmapPaint::default();
|
||
p.blend_mode = BlendMode::Source;
|
||
canvas.draw_pixmap(0, 0, blurred_pixmap.as_ref(), &p, Transform::identity(), None);
|
||
|
||
let mut p2 = PixmapPaint::default();
|
||
p2.blend_mode = BlendMode::SourceOver;
|
||
canvas.draw_pixmap(extra_left as i32, extra_top as i32, img_pixmap.as_ref(), &p2, Transform::identity(), None);
|
||
|
||
pixmap_to_rgba_image(canvas)
|
||
}
|
||
|
||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||
|
||
fn rounded_rect_path(x: f32, y: f32, w: f32, h: f32, r: f32) -> Path {
|
||
let r = r.min(w / 2.0).min(h / 2.0);
|
||
let mut pb = PathBuilder::new();
|
||
pb.move_to(x + r, y);
|
||
pb.line_to(x + w - r, y);
|
||
pb.quad_to(x + w, y, x + w, y + r);
|
||
pb.line_to(x + w, y + h - r);
|
||
pb.quad_to(x + w, y + h, x + w - r, y + h);
|
||
pb.line_to(x + r, y + h);
|
||
pb.quad_to(x, y + h, x, y + h - r);
|
||
pb.line_to(x, y + r);
|
||
pb.quad_to(x, y, x + r, y);
|
||
pb.close();
|
||
pb.finish().expect("rounded rect path")
|
||
}
|
||
|
||
fn rgba_image_to_pixmap(img: &RgbaImage) -> Pixmap {
|
||
let (w, h) = img.dimensions();
|
||
let mut pixmap = Pixmap::new(w, h).expect("pixmap alloc");
|
||
let pixels = pixmap.pixels_mut();
|
||
for (i, px) in img.pixels().enumerate() {
|
||
let [r, g, b, a] = px.0;
|
||
let af = a as f32 / 255.0;
|
||
pixels[i] = tiny_skia::PremultipliedColorU8::from_rgba(
|
||
(r as f32 * af) as u8,
|
||
(g as f32 * af) as u8,
|
||
(b as f32 * af) as u8,
|
||
a,
|
||
)
|
||
.unwrap_or(tiny_skia::PremultipliedColorU8::TRANSPARENT);
|
||
}
|
||
pixmap
|
||
}
|
||
|
||
fn pixmap_to_rgba_image(pixmap: Pixmap) -> RgbaImage {
|
||
let (w, h) = (pixmap.width(), pixmap.height());
|
||
let mut out = RgbaImage::new(w, h);
|
||
for (i, px) in pixmap.pixels().iter().enumerate() {
|
||
let x = (i as u32) % w;
|
||
let y = (i as u32) / w;
|
||
let a = px.alpha();
|
||
let (r, g, b) = if a == 0 {
|
||
(0, 0, 0)
|
||
} else {
|
||
let af = a as f32 / 255.0;
|
||
(
|
||
(px.red() as f32 / af).round().min(255.0) as u8,
|
||
(px.green() as f32 / af).round().min(255.0) as u8,
|
||
(px.blue() as f32 / af).round().min(255.0) as u8,
|
||
)
|
||
};
|
||
out.put_pixel(x, y, image::Rgba([r, g, b, a]));
|
||
}
|
||
out
|
||
}
|
||
|
||
fn tint_pixmap_as_shadow(pixmap: &mut Pixmap, color: [u8; 4]) {
|
||
let [sr, sg, sb, _] = color;
|
||
for px in pixmap.pixels_mut() {
|
||
let a = px.alpha();
|
||
if a > 0 {
|
||
let af = a as f32 / 255.0;
|
||
*px = tiny_skia::PremultipliedColorU8::from_rgba(
|
||
(sr as f32 * af) as u8,
|
||
(sg as f32 * af) as u8,
|
||
(sb as f32 * af) as u8,
|
||
a,
|
||
)
|
||
.unwrap_or(tiny_skia::PremultipliedColorU8::TRANSPARENT);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ─── Sliding-window box blur (O(W*H) regardless of radius) ───────────────────
|
||
//
|
||
// Classic algorithm: maintain a running sum over a window of (2r+1) pixels.
|
||
// When the window slides by one pixel, subtract the pixel leaving the window
|
||
// and add the pixel entering it. Three passes (H→V→H or H→V→H) approximate
|
||
// a Gaussian kernel.
|
||
|
||
fn box_blur_rgba(img: &RgbaImage, radius: u32) -> RgbaImage {
|
||
if radius == 0 {
|
||
return img.clone();
|
||
}
|
||
// Three passes of H+V to approximate a Gaussian.
|
||
let mut buf = sliding_horizontal(img, radius);
|
||
buf = sliding_vertical(&buf, radius);
|
||
buf = sliding_horizontal(&buf, radius);
|
||
buf = sliding_vertical(&buf, radius);
|
||
buf
|
||
}
|
||
|
||
/// Horizontal sliding-window box blur, single pass.
|
||
fn sliding_horizontal(img: &RgbaImage, radius: u32) -> RgbaImage {
|
||
let (w, h) = img.dimensions();
|
||
let r = radius as i32;
|
||
let diam = (2 * r + 1) as u32;
|
||
let mut out = RgbaImage::new(w, h);
|
||
|
||
for y in 0..h {
|
||
// Accumulator for the current window.
|
||
let mut sr = 0u32;
|
||
let mut sg = 0u32;
|
||
let mut sb = 0u32;
|
||
let mut sa = 0u32;
|
||
|
||
// Seed the window around x=0.
|
||
for dx in -r..=r {
|
||
let sx = dx.clamp(0, w as i32 - 1) as u32;
|
||
let p = img.get_pixel(sx, y).0;
|
||
sr += p[0] as u32;
|
||
sg += p[1] as u32;
|
||
sb += p[2] as u32;
|
||
sa += p[3] as u32;
|
||
}
|
||
|
||
for x in 0..w {
|
||
out.put_pixel(x, y, image::Rgba([
|
||
(sr / diam) as u8,
|
||
(sg / diam) as u8,
|
||
(sb / diam) as u8,
|
||
(sa / diam) as u8,
|
||
]));
|
||
|
||
// Slide: remove left edge, add right edge.
|
||
let remove_x = (x as i32 - r).clamp(0, w as i32 - 1) as u32;
|
||
let add_x = (x as i32 + r + 1).clamp(0, w as i32 - 1) as u32;
|
||
let rp = img.get_pixel(remove_x, y).0;
|
||
let ap = img.get_pixel(add_x, y).0;
|
||
sr = sr.saturating_sub(rp[0] as u32) + ap[0] as u32;
|
||
sg = sg.saturating_sub(rp[1] as u32) + ap[1] as u32;
|
||
sb = sb.saturating_sub(rp[2] as u32) + ap[2] as u32;
|
||
sa = sa.saturating_sub(rp[3] as u32) + ap[3] as u32;
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Vertical sliding-window box blur, single pass.
|
||
fn sliding_vertical(img: &RgbaImage, radius: u32) -> RgbaImage {
|
||
let (w, h) = img.dimensions();
|
||
let r = radius as i32;
|
||
let diam = (2 * r + 1) as u32;
|
||
let mut out = RgbaImage::new(w, h);
|
||
|
||
for x in 0..w {
|
||
let mut sr = 0u32;
|
||
let mut sg = 0u32;
|
||
let mut sb = 0u32;
|
||
let mut sa = 0u32;
|
||
|
||
for dy in -r..=r {
|
||
let sy = dy.clamp(0, h as i32 - 1) as u32;
|
||
let p = img.get_pixel(x, sy).0;
|
||
sr += p[0] as u32;
|
||
sg += p[1] as u32;
|
||
sb += p[2] as u32;
|
||
sa += p[3] as u32;
|
||
}
|
||
|
||
for y in 0..h {
|
||
out.put_pixel(x, y, image::Rgba([
|
||
(sr / diam) as u8,
|
||
(sg / diam) as u8,
|
||
(sb / diam) as u8,
|
||
(sa / diam) as u8,
|
||
]));
|
||
|
||
let remove_y = (y as i32 - r).clamp(0, h as i32 - 1) as u32;
|
||
let add_y = (y as i32 + r + 1).clamp(0, h as i32 - 1) as u32;
|
||
let rp = img.get_pixel(x, remove_y).0;
|
||
let ap = img.get_pixel(x, add_y ).0;
|
||
sr = sr.saturating_sub(rp[0] as u32) + ap[0] as u32;
|
||
sg = sg.saturating_sub(rp[1] as u32) + ap[1] as u32;
|
||
sb = sb.saturating_sub(rp[2] as u32) + ap[2] as u32;
|
||
sa = sa.saturating_sub(rp[3] as u32) + ap[3] as u32;
|
||
}
|
||
}
|
||
out
|
||
}
|