Files
vectorcraft/crates/effects/src/pixel/mod.rs
T

309 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<PixelFx> {
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<f64> {
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<T: Copy>(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<f32>, 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<Px16> {
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;