//! Photoshop-style raster effects (Effect › Blur › Radial Blur and Smart Blur, Sharpen › Unsharp //! Mask, and Stylize › Glowing Edges): filters over premultiplied RGBA8 pixels. //! //! - Every distance is in document units (points) and becomes pixels through the raster's //! [`PixelSpace::px`], so an effect looks the same at any zoom and at any Document Raster Effects //! Settings resolution (as Gaussian Blur's radius does). //! - The work per pixel is bounded whatever the parameters: blurs are box approximations whose //! cost doesn't grow with the radius, and sample counts come from the effect's quality. //! - Beyond the raster's edges is transparency. mod blur; mod edges; mod sharpen; use serde_json::Value; use vectorcraft_geom::{Affine, Point, Rect}; use crate::util::{num, text}; /// The Photoshop-style effect ids (all raster effects, see [`crate::is_raster`]). pub const PIXEL_EFFECTS: [&str; 4] = ["blur.radial", "blur.smart", "sharpen.unsharpMask", "stylize.glowingEdges"]; /// A Photoshop-style raster effect with its parameters read and clamped to their ranges. Lengths /// are in document units. #[derive(Clone, Copy, Debug, PartialEq)] pub enum PixelFx { /// Blur › Radial Blur around the object's centre: along arcs (spin, an arc of `amount`°) or /// along rays (zoom, the content scaled by up to about ±`amount`/2 %), averaging 2^`passes` samples. RadialBlur { amount: f64, zoom: bool, passes: u32 }, /// Blur › Smart Blur (Normal mode): each pixel averaged with the neighbours within `radius` /// whose colour differs from its own by no more than `threshold` levels, over a grid of /// `samples` × `samples` neighbours at most. SmartBlur { radius: f64, threshold: f64, samples: u32 }, /// Sharpen › Unsharp Mask: colours pushed away from a Gaussian blur of σ = `radius` by `amount` /// (1 = 100 %), where they differ from it by at least `threshold` levels. UnsharpMask { amount: f64, radius: f64, threshold: f64 }, /// Stylize › Glowing Edges: bright coloured Sobel outlines on black. GlowingEdges { width: f64, brightness: f64, smoothness: f64 }, } /// Where a raster's pixels lie in the document. #[derive(Clone, Copy, Debug, PartialEq)] pub struct PixelSpace { /// Pixel coordinates (x right, y down, (0, 0) the top-left corner of the first pixel) → /// document coordinates. pub to_doc: Affine, /// The size of a pixel in document units. pub px: f64, /// The object's centre (document coordinates): where Radial Blur blurs around. pub center: Point, } /// Effect `id`'s parameters `p` (defaults merged in) as a [`PixelFx`]; `None` for other effects. pub(crate) fn parse(id: &str, p: &Value) -> Option { let quality = |levels: [(&str, u32); 3], default: u32| { let q = text(p, "quality", ""); levels.iter().find(|(name, _)| name.eq_ignore_ascii_case(q)).map_or(default, |(_, v)| *v) }; Some(match id { "blur.radial" => PixelFx::RadialBlur { amount: num(p, "amount", 10.0).clamp(1.0, 100.0), zoom: text(p, "method", "spin").eq_ignore_ascii_case("zoom"), passes: quality([("draft", 4), ("good", 6), ("best", 8)], 6), }, "blur.smart" => PixelFx::SmartBlur { radius: num(p, "radius", 3.0).clamp(0.1, 100.0), threshold: num(p, "threshold", 25.0).clamp(0.1, 100.0), samples: quality([("low", 5), ("medium", 7), ("high", 9)], 7), }, "sharpen.unsharpMask" => PixelFx::UnsharpMask { amount: num(p, "amount", 50.0).clamp(1.0, 500.0) / 100.0, radius: num(p, "radius", 1.0).clamp(0.1, 250.0), threshold: num(p, "threshold", 0.0).clamp(0.0, 255.0), }, "stylize.glowingEdges" => PixelFx::GlowingEdges { width: num(p, "edgeWidth", 2.0).clamp(1.0, 14.0), brightness: num(p, "edgeBrightness", 6.0).clamp(0.0, 20.0), smoothness: num(p, "smoothness", 5.0).clamp(1.0, 15.0), }, _ => return None, }) } impl PixelFx { /// How far beyond `bounds` (the content's, document units) the effect can paint. pub fn outset(&self, bounds: Rect) -> f64 { // From the centre to the farthest corner, and to the nearest side. let far = 0.5 * bounds.width().hypot(bounds.height()); let near = 0.5 * bounds.width().min(bounds.height()); match *self { // A rotated corner reaches the circle through the corners. PixelFx::RadialBlur { zoom: false, .. } => (far - near).max(0.0), PixelFx::RadialBlur { amount, zoom: true, .. } => far * (blur::zoom_extent(amount).exp() - 1.0), PixelFx::SmartBlur { radius, .. } => radius, PixelFx::UnsharpMask { .. } => 0.0, PixelFx::GlowingEdges { width, smoothness, .. } => width.max(smoothness * 3.0), } } /// How far (document units) from a pixel the content that decides it lies; `None` when that /// is anywhere in the object (Radial Blur). pub fn reach(&self) -> Option { match *self { PixelFx::RadialBlur { .. } => None, PixelFx::SmartBlur { radius, .. } => Some(radius), PixelFx::UnsharpMask { radius, .. } => Some(3.0 * radius), PixelFx::GlowingEdges { width, smoothness, .. } => Some(width.max(smoothness * 3.0)), } } /// Run the effect on `data`, `w` × `h` premultiplied RGBA8 pixels lying in the document as /// `space` says. Does nothing when the sizes don't match. pub fn apply(&self, data: &mut [u8], w: usize, h: usize, space: &PixelSpace) { let Some(px) = pixels(data, w, h) else { return }; let to_px = |len: f64| len / space.px.max(1e-9); match *self { PixelFx::RadialBlur { amount, zoom, passes } => blur::radial(px, w, h, space, amount, zoom, passes), PixelFx::SmartBlur { radius, threshold, samples } => blur::smart(px, w, h, to_px(radius), threshold, samples), PixelFx::UnsharpMask { amount, radius, threshold } => sharpen::unsharp(px, w, h, amount, to_px(radius), threshold), PixelFx::GlowingEdges { width, brightness, smoothness } => edges::glow(px, w, h, to_px(width), brightness, to_px(smoothness)), } } } /// `data` as `w` × `h` RGBA pixels, `None` when it isn't that size (or is empty). fn pixels(data: &mut [u8], w: usize, h: usize) -> Option<&mut [[u8; 4]]> { let (px, rest) = data.as_chunks_mut::<4>(); (w > 0 && h > 0 && rest.is_empty() && w.checked_mul(h) == Some(px.len())).then_some(px) } /// The largest box radius the blurs use: wider boxes look the same on any raster we make. const MAX_BOX: usize = 1 << 17; /// Radii of three box blurs approximating a Gaussian of `sigma` pixels. pub fn gauss_boxes(sigma: f64) -> [usize; 3] { let sigma = if sigma.is_finite() { sigma.clamp(0.0, MAX_BOX as f64 / 2.0) } else { 0.0 }; let n = 3.0; let w_ideal = (12.0 * sigma * sigma / n + 1.0).sqrt(); let mut wl = w_ideal.floor() as i64; if wl % 2 == 0 { wl -= 1; } let wu = wl + 2; let m_ideal = (12.0 * sigma * sigma - n * (wl * wl) as f64 - 4.0 * n * wl as f64 - 3.0 * n) / (-4.0 * wl as f64 - 4.0); let m = m_ideal.round() as i64; let r = |i: i64| ((((if i < m { wl } else { wu }) - 1) / 2).max(0) as usize).min(MAX_BOX); [r(0), r(1), r(2)] } /// One horizontal box blur of radius `r` over the rows of `w` values of a plane (running sum, /// zero past the ends). fn box_plane(src: &[f32], dst: &mut [f32], w: usize, r: usize) { if r == 0 { dst.copy_from_slice(src); return; } let norm = 1.0 / (2 * r + 1) as f32; for (row, out) in src.chunks_exact(w).zip(dst.chunks_exact_mut(w)) { let mut acc: f32 = row.iter().take(r + 1).sum(); for (x, o) in out.iter_mut().enumerate() { *o = acc * norm; if let Some(v) = row.get(x + r + 1) { acc += v; } if let Some(v) = x.checked_sub(r).and_then(|i| row.get(i)) { acc -= v; } } } } fn transpose(src: &[T], dst: &mut [T], w: usize, h: usize) { for (y, row) in src.chunks_exact(w).enumerate().take(h) { for (x, v) in row.iter().enumerate() { if let Some(d) = dst.get_mut(x * h + y) { *d = *v; } } } } /// Gaussian-blur a `w` × `h` plane of values in place (three box passes per axis, zero past the /// edges). Does nothing when the sizes don't match. pub fn blur_plane(a: &mut Vec, w: usize, h: usize, sigma: f64) { if sigma < 0.2 || w == 0 || h == 0 || w.checked_mul(h) != Some(a.len()) { return; } let boxes = gauss_boxes(sigma); let mut tmp = vec![0.0; a.len()]; for (len, other) in [(w, h), (h, w)] { for r in boxes { box_plane(a, &mut tmp, len, r); std::mem::swap(a, &mut tmp); } transpose(a, &mut tmp, len, other); std::mem::swap(a, &mut tmp); } } /// Premultiplied RGBA at 16 bits (×257): blurs and averages that keep their tones. pub(super) type Px16 = [u16; 4]; pub(super) fn to16(px: &[[u8; 4]]) -> Vec { px.iter().map(|p| p.map(|v| v as u16 * 257)).collect() } pub(super) fn to8(v: u16) -> u8 { ((v as u32 + 128) / 257).min(255) as u8 } fn add(acc: &mut [u64; 4], p: &Px16) { for (a, v) in acc.iter_mut().zip(p) { *a += *v as u64; } } fn sub(acc: &mut [u64; 4], p: &Px16) { for (a, v) in acc.iter_mut().zip(p) { *a = a.saturating_sub(*v as u64); } } fn mean(acc: &[u64; 4], n: u64) -> Px16 { acc.map(|a| ((a + n / 2) / n.max(1)).min(65535) as u16) } /// One box blur of radius `r` along the rows of `w` pixels (transparent past the ends). fn box_rows(src: &[Px16], dst: &mut [Px16], w: usize, r: usize) { let n = 2 * r as u64 + 1; for (row, out) in src.chunks_exact(w).zip(dst.chunks_exact_mut(w)) { let mut acc = [0u64; 4]; row.iter().take(r + 1).for_each(|p| add(&mut acc, p)); for (x, o) in out.iter_mut().enumerate() { *o = mean(&acc, n); if let Some(p) = row.get(x + r + 1) { add(&mut acc, p); } if let Some(p) = x.checked_sub(r).and_then(|i| row.get(i)) { sub(&mut acc, p); } } } } /// One box blur of radius `r` down the columns of `w`-wide rows (transparent past the ends), row /// by row with a running sum per column. fn box_cols(src: &[Px16], dst: &mut [Px16], w: usize, r: usize) { let n = 2 * r as u64 + 1; let rows: Vec<&[Px16]> = src.chunks_exact(w).collect(); let mut acc = vec![[0u64; 4]; w]; let add_row = |acc: &mut [[u64; 4]], row: &[Px16]| acc.iter_mut().zip(row).for_each(|(a, p)| add(a, p)); rows.iter().take(r + 1).for_each(|row| add_row(&mut acc, row)); for (y, out) in dst.chunks_exact_mut(w).enumerate() { for (o, a) in out.iter_mut().zip(&acc) { *o = mean(a, n); } if let Some(row) = rows.get(y + r + 1) { add_row(&mut acc, row); } if let Some(row) = y.checked_sub(r).and_then(|i| rows.get(i)) { acc.iter_mut().zip(*row).for_each(|(a, p)| sub(a, p)); } } } /// Box-blur `w`-wide rows of pixels in place with radius `r` along both axes. pub(super) fn box_blur(px: &mut [Px16], w: usize, r: usize) { if r == 0 || w == 0 { return; } let mut tmp = vec![[0u16; 4]; px.len()]; box_rows(px, &mut tmp, w, r); box_cols(&tmp, px, w, r); } /// Gaussian-blur `w`-wide rows of premultiplied pixels in place (σ = `sigma` pixels, three box /// passes per axis, transparent past the edges). pub(super) fn gaussian(px: &mut [Px16], w: usize, sigma: f64) { if sigma < 0.2 || w == 0 { return; } let mut tmp = vec![[0u16; 4]; px.len()]; for r in gauss_boxes(sigma) { box_rows(px, &mut tmp, w, r); px.copy_from_slice(&tmp); } for r in gauss_boxes(sigma) { box_cols(px, &mut tmp, w, r); px.copy_from_slice(&tmp); } } /// Gaussian-blur `data`, `w` × `h` premultiplied RGBA8 pixels, in place (σ = `sigma` pixels). Does /// nothing when the sizes don't match. pub fn gaussian_rgba(data: &mut [u8], w: usize, h: usize, sigma: f64) { if let Some(px) = pixels(data, w, h) && sigma >= 0.2 { let mut wide = to16(px); gaussian(&mut wide, w, sigma); for (p, q) in px.iter_mut().zip(&wide) { *p = q.map(to8); } } } #[cfg(test)] mod tests;