Files
photocraft/crates/algo/src/oil.rs
T
David Mann 249eae743f Integration: make Oil Paint's round_i32 exact for every f32 (#8556)
round_i32 added 0.5 in f32, which rounds up at the binade crossing:
0.49999997 (0.5 - 2^-25) + 0.5 becomes 1.0, so round_i32 gave 1 (and -1 for
the negative) where f32::round gives 0. The sum is now taken in f64, where
it is exact for every f32 the i32 cast does not saturate; still no roundf call.

rounding_matches_the_standard_library now covers +-0.49999997, the ties and
8388607.5, and a new test compares every f32 bit pattern in [-4096, 4096]
against f32::round (0.6 s). Both fail on the previous head. The code comment
and docs/oil-paint-performance.md no longer claim the f32 sum is exact.

Co-authored-by: ebosido444-ai <316167330+ebosido444-ai@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-22 21:31:24 +02:00

274 lines
12 KiB
Rust

//! Oil Paint: flow-guided smoothing plus a lit bristle relief.
//!
//! The approach follows published non-photorealistic rendering work rather
//! than any product: the structure tensor of the luminance (Brox et al.) gives
//! a smooth stroke direction field, and a line integral convolution (Cabral &
//! Leedom 1993) smears colour along it, as in flow-based abstraction (Kyprianidis
//! & Döllner 2008). Convolving white noise along the same field produces the
//! bristle streaks, which together with the smoothed luminance form a height
//! map shaded by a directional light.
use photocraft_color::ColorMode;
use photocraft_geom::Rect;
use crate::Ctx;
use crate::fxutil::{MAXC, gauss_blur_n, ncol, premul_window, unpremul_px};
use crate::image::Image;
use crate::noise::hash01;
pub(crate) struct OilSpec {
pub stylization: f32,
pub cleanliness: f32,
pub scale: f32,
pub bristle_detail: f32,
pub lighting: bool,
pub angle: f32,
pub shine: f32,
}
fn stroke_len(stylization: f32) -> f32 {
1.5 + stylization.clamp(0.1, 10.0) * 2.0
}
/// `f32::round` (half away from zero) as an integer, without the `roundf` call `f32::round`
/// makes on the baseline x86-64 target. The sum is taken in `f64`, where `v ± 0.5` is exact for
/// every `f32` the cast does not saturate (an `f32` sum is not: `0.49999997 + 0.5` rounds up to
/// `1.0`), so the result equals `v.round() as i32` for every `f32`, NaN (0) and the saturating
/// ends included.
#[inline]
fn round_i32(v: f32) -> i32 {
let v = f64::from(v);
(v + 0.5f64.copysign(v)) as i32
}
/// [`round_i32`] clamped into `0..n`.
#[inline]
fn round_index(v: f32, n: usize) -> usize {
round_i32(v).clamp(0, n as i32 - 1) as usize
}
fn tensor_sigma(scale: f32) -> f32 {
0.8 + scale.clamp(0.1, 10.0) * 0.6
}
/// Pixels read beyond an output tile.
pub(crate) fn reach(stylization: f32, scale: f32) -> f32 {
stroke_len(stylization) + 3.0 * tensor_sigma(scale) + 3.0 * 2.5 + 4.0
}
pub(crate) fn oil_paint(src: &Image, out: Rect, ctx: &Ctx, spec: &OilSpec) -> Vec<f32> {
let n = src.ch;
let win = src.rect;
let (ww, wh) = (win.width() as usize, win.height() as usize);
let alpha = ctx.alpha;
let cc = ncol(ctx, n);
// 1. Premultiplied colour, cleaned (pre-smoothed) by `cleanliness`.
let mut p = premul_window(src, win, alpha);
gauss_blur_n(&mut p, ww, wh, n, spec.cleanliness.clamp(0.0, 10.0) * 0.25);
// 2. Luminance-like intensity for the structure tensor (channel mean works in every model).
let lum: Vec<f32> = p.chunks_exact(n).map(|px| px[..cc].iter().sum::<f32>() / cc as f32).collect();
// 3. Smoothed structure tensor (E, F, G).
let mut efg = vec![0.0f32; ww * wh * 3];
for y in 1..wh.saturating_sub(1) {
for x in 1..ww - 1 {
let l = |dx: isize, dy: isize| lum[((y as isize + dy) as usize) * ww + (x as isize + dx) as usize];
let gx = (l(1, -1) + 2.0 * l(1, 0) + l(1, 1)) - (l(-1, -1) + 2.0 * l(-1, 0) + l(-1, 1));
let gy = (l(-1, 1) + 2.0 * l(0, 1) + l(1, 1)) - (l(-1, -1) + 2.0 * l(0, -1) + l(1, -1));
let o = (y * ww + x) * 3;
efg[o] = gx * gx;
efg[o + 1] = gx * gy;
efg[o + 2] = gy * gy;
}
}
gauss_blur_n(&mut efg, ww, wh, 3, tensor_sigma(spec.scale));
// 4. Stroke direction = minor eigenvector (along edges).
let tangent: Vec<(f32, f32)> = efg
.as_chunks::<3>()
.0
.iter()
.map(|t| {
let (e, f, g) = (t[0], t[1], t[2]);
let l1 = 0.5 * (e + g + ((e - g) * (e - g) + 4.0 * f * f).sqrt());
let (tx, ty) = (l1 - e, -f);
let m = (tx * tx + ty * ty).sqrt();
// Near-isotropic structure has no stable direction; treat as flat.
if m < 1e-6 || (l1 - (e + g - l1)) < 1e-6 {
// Flat areas: gentle diagonal strokes.
(std::f32::consts::FRAC_1_SQRT_2, std::f32::consts::FRAC_1_SQRT_2)
} else {
(tx / m, ty / m)
}
})
.collect();
// 5. Line integral convolution over `out` grown by 1 (for the relief gradient).
let r1 = out.inflate(1).intersect(&win);
let (rw, rh) = (r1.width() as usize, r1.height() as usize);
let len = stroke_len(spec.stylization);
let h = (len / 10.0).max(1.0);
let steps = (len / h).ceil() as i32;
let mut col = vec![0.0f32; rw * rh * n];
let mut bristle = vec![0.0f32; rw * rh];
let sample = |x: f32, y: f32, acc: &mut [f32], k: f32| {
let x = x.clamp(0.0, (ww - 1) as f32);
let y = y.clamp(0.0, (wh - 1) as f32);
let (x0, y0) = (x as usize, y as usize);
let (x1, y1) = ((x0 + 1).min(ww - 1), (y0 + 1).min(wh - 1));
let (ax, ay) = (x - x0 as f32, y - y0 as f32);
let w00 = (1.0 - ax) * (1.0 - ay) * k;
let w10 = ax * (1.0 - ay) * k;
let w01 = (1.0 - ax) * ay * k;
let w11 = ax * ay * k;
let (i00, i10, i01, i11) = ((y0 * ww + x0) * n, (y0 * ww + x1) * n, (y1 * ww + x0) * n, (y1 * ww + x1) * n);
for c in 0..acc.len() {
acc[c] += p[i00 + c] * w00 + p[i10 + c] * w10 + p[i01 + c] * w01 + p[i11 + c] * w11;
}
};
let mut acc = [0.0f32; MAXC];
for ry in 0..rh {
for rx in 0..rw {
let (dx, dy) = ((r1.x0 - win.x0) as f32 + rx as f32, (r1.y0 - win.y0) as f32 + ry as f32);
acc[..n].fill(0.0);
let mut wsum = 0.0;
let mut nsum = 0.0;
sample(dx, dy, &mut acc[..n], 1.0);
wsum += 1.0;
nsum += hash01(win.x0 + dx as i32, win.y0 + dy as i32, 71, 0);
for dir in [1.0f32, -1.0] {
let (mut qx, mut qy) = (dx, dy);
let (mut px, mut py) = (0.0f32, 0.0f32);
for k in 1..=steps {
let ti = round_index(qy, wh) * ww + round_index(qx, ww);
let (mut tx, mut ty) = tangent[ti];
if k == 1 {
tx *= dir;
ty *= dir;
} else if tx * px + ty * py < 0.0 {
tx = -tx;
ty = -ty;
}
px = tx;
py = ty;
qx += tx * h;
qy += ty * h;
let wk = 1.0 - k as f32 / (steps + 1) as f32;
sample(qx, qy, &mut acc[..n], wk);
wsum += wk;
nsum += wk * hash01(win.x0 + round_i32(qx), win.y0 + round_i32(qy), 71, 0);
}
}
let o = (ry * rw + rx) * n;
for c in 0..n {
col[o + c] = acc[c] / wsum;
}
bristle[ry * rw + rx] = nsum / wsum - 0.5;
}
}
// 6. Relief lighting.
let (ls, lc) = spec.angle.to_radians().sin_cos();
let elev = 45f32.to_radians();
let light = [lc * elev.cos(), -ls * elev.cos(), elev.sin()];
let half = {
let v = [light[0], light[1], light[2] + 1.0];
let m = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
[v[0] / m, v[1] / m, v[2] / m]
};
let amp = spec.bristle_detail.clamp(0.0, 10.0) / 10.0;
let shine = spec.shine.clamp(0.0, 10.0) / 10.0;
let sub = ctx.mode == ColorMode::Cmyk;
let lab = ctx.mode == ColorMode::Lab;
let height = |rx: usize, ry: usize| -> f32 {
let o = (ry * rw + rx) * n;
let l = col[o..o + cc].iter().sum::<f32>() / cc as f32;
let l = if sub { 1.0 - l } else { l };
l * 2.0 + bristle[ry * rw + rx] * amp * 6.0
};
let flat_spec = half[2].powf(40.0);
let mut res = Vec::with_capacity(out.width() as usize * out.height() as usize * n);
for y in out.y0..out.y1 {
for x in out.x0..out.x1 {
let (rx, ry) = ((x - r1.x0) as usize, (y - r1.y0) as usize);
let o = (ry * rw + rx) * n;
let mut px = [0.0f32; MAXC];
px[..n].copy_from_slice(&col[o..o + n]);
unpremul_px(&mut px[..n], alpha);
// Painting restyles colour, not coverage: keep the layer's own alpha (no faded
// canvas edges from smearing in transparent margins).
if alpha {
px[n - 1] = src.get(x, y, n - 1);
}
if spec.lighting {
let hx = height((rx + 1).min(rw - 1), ry) - height(rx.saturating_sub(1), ry);
let hy = height(rx, (ry + 1).min(rh - 1)) - height(rx, ry.saturating_sub(1));
let nv = [-hx * 0.5, -hy * 0.5, 1.0];
let m = (nv[0] * nv[0] + nv[1] * nv[1] + 1.0).sqrt();
let ndl = (nv[0] * light[0] + nv[1] * light[1] + nv[2] * light[2]) / m;
let ndh = ((nv[0] * half[0] + nv[1] * half[1] + nv[2] * half[2]) / m).max(0.0);
let shade = 1.0 + (ndl - light[2]) * 1.2;
let spec_v = shine * (ndh.powf(40.0) - flat_spec).max(0.0) * 0.8;
let chans = if lab { 1 } else { cc };
for v in px.iter_mut().take(chans) {
*v = if sub { 1.0 - ((1.0 - *v) * shade + spec_v) } else { *v * shade + spec_v };
}
}
res.extend_from_slice(&px[..n]);
}
}
res
}
#[cfg(test)]
mod tests {
use super::{round_i32, round_index};
/// The LIC looks up tangents and bristle noise by rounded sample position; the replacements
/// for `f32::round` must agree with it over every position a tile can produce, and agree with
/// `f32::clamp` at the ends, including non-finite input.
#[test]
fn rounding_matches_the_standard_library() {
let mut v = -40.0f32;
while v <= 2100.0 {
assert_eq!(round_i32(v), v.round() as i32, "round_i32({v})");
for n in [1usize, 2, 33, 256, 2048] {
let want = (v.round().clamp(0.0, (n - 1) as f32)) as usize;
assert_eq!(round_index(v, n), want, "round_index({v}, {n})");
}
v += 0.25;
}
// The binade crossings where an `f32` sum `v + 0.5` rounds up (0.5 - 2^-25), the ties, and
// the largest non-integer `f32`.
let below_half = f32::from_bits(0x3eff_ffff);
for v in [below_half, -below_half, 0.5, -0.5, 1.5, -1.5, 2.5, -2.5, 8_388_607.5, -8_388_607.5, 8_388_608.0, 3.0e9, -3.0e9] {
assert_eq!(round_i32(v), v.round() as i32, "round_i32({v:e})");
for n in [1usize, 2, 33] {
let want = (v.round().clamp(0.0, (n - 1) as f32)) as usize;
assert_eq!(round_index(v, n), want, "round_index({v:e}, {n})");
}
}
assert_eq!(round_i32(f32::NAN), f32::NAN.round() as i32);
for n in [1usize, 8] {
assert_eq!(round_index(f32::NEG_INFINITY, n), (f32::NEG_INFINITY.clamp(0.0, (n - 1) as f32)) as usize);
assert_eq!(round_index(f32::INFINITY, n), ((n - 1) as f32) as usize);
}
}
/// Every `f32` bit pattern in [-4096, 4096] (both signs, about 2.3e9 values, split across
/// threads) rounds exactly like `f32::round`.
#[test]
fn rounding_matches_the_standard_library_for_every_f32_up_to_4096() {
use rayon::prelude::*;
let last = 4096.0f32.to_bits();
let bad = (0..=last >> 20)
.into_par_iter()
.filter_map(|hi| {
let lo = hi << 20;
let hi_end = (lo | 0xf_ffff).min(last);
(lo..=hi_end).find_map(|bits| {
let v = f32::from_bits(bits);
[v, -v].into_iter().find(|v| round_i32(*v) != v.round() as i32)
})
})
.min_by(|a, b| a.abs().total_cmp(&b.abs()));
assert_eq!(bad, None, "round_i32 differs from f32::round");
}
}