mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-24 09:57:08 -06:00
security(algo): bound working sets sized by caller-controlled extents (#8946)
* security(algo): bound working sets sized by caller-controlled extents Zero-width inpaint grids, content-aware fill mosaics of turned thin images, Liquify fields over huge canvases, tiny-scale reduction weights, warp tile bookkeeping and per-tile source footprints, and windowed filters whose halo follows an unclamped radius or a stacked Filter Gallery reach all asked for memory sized by file or parameter input. Each is now bounded; halo arithmetic saturates instead of overflowing. * security(algo): budget variants by dimensions, size-check footprints first, widen the tile-halo maths CodeRabbit follow-up: - fill_cow settles the mosaic budget on variant dimensions only, so a variant's image and mask buffers are not built when the mosaic would exceed MAX_MOSAIC_PIXELS (complete falls back to the image itself). - warp validates the footprint's pixel area against MAX_FOOTPRINT_PIXELS before src.has_tiles_in, so an oversized footprint returns without the tile-grid scan. - auto_tile widens the input-derived halo arithmetic to i64 and clamps before the cast, so an i32::MAX radius cannot overflow in debug builds and the 2048 tile cap stands. * fix(algo): drop the duplicate mosaic_size call left behind in fill_cow The variant-budget rework kept both the new dimensions-based budget and the old variants-based one; the old call no longer compiled after mosaic_size started taking dimensions. Keep the comment that explains the fallback.
This commit is contained in:
@@ -114,6 +114,21 @@ struct Variant {
|
|||||||
ok: Vec<bool>,
|
ok: Vec<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Most pixels the mosaic of the image and its variants may span. It is one dense rectangle, so
|
||||||
|
/// thin images turned to different angles leave it mostly empty: the cost grows with the square
|
||||||
|
/// of their length, not with their pixels.
|
||||||
|
const MAX_MOSAIC_PIXELS: usize = 1 << 25;
|
||||||
|
|
||||||
|
/// Size of the mosaic of an `w`×`h` image and `extra` variant sizes separated by `gap`, or `None`
|
||||||
|
/// when it spans more than [`MAX_MOSAIC_PIXELS`] (or overflows).
|
||||||
|
fn mosaic_size(w: usize, h: usize, gap: usize, extra: &[(usize, usize)]) -> Option<(usize, usize)> {
|
||||||
|
let col_w = extra.iter().map(|v| v.0).max().unwrap_or(0);
|
||||||
|
let col_h = extra.iter().try_fold(0usize, |n, v| n.checked_add(v.1)?.checked_add(gap))?;
|
||||||
|
let mw = w.checked_add(gap)?.checked_add(col_w)?;
|
||||||
|
let mh = h.max(col_h);
|
||||||
|
(mw.checked_mul(mh)? <= MAX_MOSAIC_PIXELS).then_some((mw, mh))
|
||||||
|
}
|
||||||
|
|
||||||
/// A source buffer and which of its pixels may be sampled.
|
/// A source buffer and which of its pixels may be sampled.
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
struct Src<'a> {
|
struct Src<'a> {
|
||||||
@@ -149,13 +164,20 @@ fn sample(src: Src, x: f32, y: f32, out: &mut [f32]) -> bool {
|
|||||||
|
|
||||||
/// Transformed copy of the source: rotation (degrees) and scale about the centre, optional mirror.
|
/// Transformed copy of the source: rotation (degrees) and scale about the centre, optional mirror.
|
||||||
/// `Err` when cancelled.
|
/// `Err` when cancelled.
|
||||||
fn variant(src: Src, deg: f32, scale: f32, mirror: bool, ctl: &Interrupt) -> Result<Variant, Cancelled> {
|
/// Dimensions of the [`variant`] copy: the same bounding-box maths without building its pixels.
|
||||||
let Src { w, h, ch, .. } = src;
|
fn variant_dims(w: usize, h: usize, deg: f32, scale: f32) -> (usize, usize) {
|
||||||
let (s, c) = (-deg.to_radians()).sin_cos();
|
let (s, c) = (-deg.to_radians()).sin_cos();
|
||||||
// Output size: the rotated, scaled bounding box.
|
|
||||||
let (fw, fh) = (w as f32 * scale, h as f32 * scale);
|
let (fw, fh) = (w as f32 * scale, h as f32 * scale);
|
||||||
let ow = ((fw * c.abs() + fh * s.abs()).round() as usize).max(1);
|
let ow = ((fw * c.abs() + fh * s.abs()).round() as usize).max(1);
|
||||||
let oh = ((fw * s.abs() + fh * c.abs()).round() as usize).max(1);
|
let oh = ((fw * s.abs() + fh * c.abs()).round() as usize).max(1);
|
||||||
|
(ow, oh)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn variant(src: Src, deg: f32, scale: f32, mirror: bool, ctl: &Interrupt) -> Result<Variant, Cancelled> {
|
||||||
|
let Src { w, h, ch, .. } = src;
|
||||||
|
// Output size: the rotated, scaled bounding box.
|
||||||
|
let (ow, oh) = variant_dims(w, h, deg, scale);
|
||||||
|
let (s, c) = (-deg.to_radians()).sin_cos();
|
||||||
let (icx, icy) = ((w as f32 - 1.0) / 2.0, (h as f32 - 1.0) / 2.0);
|
let (icx, icy) = ((w as f32 - 1.0) / 2.0, (h as f32 - 1.0) / 2.0);
|
||||||
let (ocx, ocy) = ((ow as f32 - 1.0) / 2.0, (oh as f32 - 1.0) / 2.0);
|
let (ocx, ocy) = ((ow as f32 - 1.0) / 2.0, (oh as f32 - 1.0) / 2.0);
|
||||||
let mut out = Variant { w: ow, h: oh, img: vec![0.0; ow * oh * ch], ok: vec![false; ow * oh] };
|
let mut out = Variant { w: ow, h: oh, img: vec![0.0; ow * oh * ch], ok: vec![false; ow * oh] };
|
||||||
@@ -243,17 +265,27 @@ fn fill_cow(
|
|||||||
let gap = 2 * params.patch_radius + 2;
|
let gap = 2 * params.patch_radius + 2;
|
||||||
let ok: Vec<bool> = par::map(w * h, true, |i| !hole[i] && source[i]);
|
let ok: Vec<bool> = par::map(w * h, true, |i| !hole[i] && source[i]);
|
||||||
let src = Src { w, h, ch, img: &img, ok: &ok };
|
let src = Src { w, h, ch, img: &img, ok: &ok };
|
||||||
// Variants: the identity (which holds the hole) first, then the adaptations.
|
// Variants: the identity (which holds the hole) first, then the adaptations. Their pixels are
|
||||||
let mut extra: Vec<Variant> = Vec::new();
|
// expensive, so the mosaic budget is settled on their dimensions before any variant is built;
|
||||||
|
// when the variants would not fit, the image itself is completed without them.
|
||||||
|
let mut specs: Vec<(f32, f32, bool)> = Vec::new();
|
||||||
if opts.mirror {
|
if opts.mirror {
|
||||||
extra.push(variant(src, 0.0, 1.0, true, ctl)?);
|
specs.push((0.0, 1.0, true));
|
||||||
}
|
}
|
||||||
for ° in &opts.rotations {
|
for ° in &opts.rotations {
|
||||||
extra.push(variant(src, deg, 1.0, false, ctl)?);
|
specs.push((deg, 1.0, false));
|
||||||
}
|
}
|
||||||
if opts.scale {
|
if opts.scale {
|
||||||
extra.push(variant(src, 0.0, 0.8, false, ctl)?);
|
specs.push((0.0, 0.8, false));
|
||||||
extra.push(variant(src, 0.0, 1.25, false, ctl)?);
|
specs.push((0.0, 1.25, false));
|
||||||
|
}
|
||||||
|
let dims: Vec<(usize, usize)> = specs.iter().map(|&(deg, scale, _)| variant_dims(w, h, deg, scale)).collect();
|
||||||
|
let size = if specs.is_empty() { None } else { mosaic_size(w, h, gap, &dims) };
|
||||||
|
let mut extra: Vec<Variant> = Vec::new();
|
||||||
|
if size.is_some() {
|
||||||
|
for &(deg, scale, mirror) in &specs {
|
||||||
|
extra.push(variant(src, deg, scale, mirror, ctl)?);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let role = |s: usize| {
|
let role = |s: usize| {
|
||||||
if hole[s] {
|
if hole[s] {
|
||||||
@@ -265,14 +297,9 @@ fn fill_cow(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
// Mosaic: identity at the left, variants in a column to its right, gaps that are no image.
|
// Mosaic: identity at the left, variants in a column to its right, gaps that are no image.
|
||||||
// Without variants it is the image itself.
|
// Without variants it is the image itself; so it is when the variants would not fit
|
||||||
let (mw, mh, mosaic, roles): (usize, usize, Option<Vec<f32>>, Vec<Px>) = if extra.is_empty() {
|
// (`size` above is then None and `extra` is empty).
|
||||||
(w, h, None, par::map(w * h, true, role))
|
let (mw, mh, mosaic, roles): (usize, usize, Option<Vec<f32>>, Vec<Px>) = if let Some((mw, mh)) = size {
|
||||||
} else {
|
|
||||||
let col_w = extra.iter().map(|v| v.w).max().unwrap_or(0);
|
|
||||||
let col_h: usize = extra.iter().map(|v| v.h + gap).sum();
|
|
||||||
let mw = w + gap + col_w;
|
|
||||||
let mh = h.max(col_h);
|
|
||||||
let mut mimg = vec![0.0f32; mw * mh * ch];
|
let mut mimg = vec![0.0f32; mw * mh * ch];
|
||||||
let mut roles = vec![Px::Void; mw * mh];
|
let mut roles = vec![Px::Void; mw * mh];
|
||||||
for y in 0..h {
|
for y in 0..h {
|
||||||
@@ -297,6 +324,8 @@ fn fill_cow(
|
|||||||
oy += v.h + gap;
|
oy += v.h + gap;
|
||||||
}
|
}
|
||||||
(mw, mh, Some(mimg), roles)
|
(mw, mh, Some(mimg), roles)
|
||||||
|
} else {
|
||||||
|
(w, h, None, par::map(w * h, true, role))
|
||||||
};
|
};
|
||||||
drop((extra, ok));
|
drop((extra, ok));
|
||||||
ctl.check()?;
|
ctl.check()?;
|
||||||
@@ -695,6 +724,20 @@ pub fn default_brush_size(hole: Rect) -> f64 {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use super::mosaic_size;
|
||||||
|
|
||||||
|
fn blank(w: usize, h: usize) -> (usize, usize) {
|
||||||
|
(w, h)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_thin_image_with_turned_variants_does_not_get_a_quadratic_mosaic() {
|
||||||
|
// 40000×1 plus its 90° copy (1×40000): about 1.6 billion mosaic cells for 80000 pixels.
|
||||||
|
assert_eq!(mosaic_size(40_000, 1, 8, &[blank(1, 40_000)]), None);
|
||||||
|
assert_eq!(mosaic_size(usize::MAX, 1, 8, &[blank(1, 1)]), None);
|
||||||
|
assert_eq!(mosaic_size(100, 100, 8, &[blank(100, 100)]), Some((208, 108)));
|
||||||
|
}
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
/// Vertical stripes (period 6) in grey, with a hole in the middle.
|
/// Vertical stripes (period 6) in grey, with a hole in the middle.
|
||||||
|
|||||||
@@ -319,6 +319,10 @@ fn peel_affordable(w: usize, px: &[Px], hb: Bounds, sources: usize) -> bool {
|
|||||||
|
|
||||||
/// Bounds of the hole pixels of a role grid.
|
/// Bounds of the hole pixels of a role grid.
|
||||||
fn hole_bounds(w: usize, h: usize, px: &[Px]) -> Option<Bounds> {
|
fn hole_bounds(w: usize, h: usize, px: &[Px]) -> Option<Bounds> {
|
||||||
|
// A zero-width grid holds no pixels whatever its height; don't size a row table by it.
|
||||||
|
if w == 0 || h == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
// Per row the first and last hole column; the union of the rows' spans.
|
// Per row the first and last hole column; the union of the rows' spans.
|
||||||
let rows: Vec<Option<(usize, usize)>> = par::map(h, w * h >= PAR_MIN, |y| {
|
let rows: Vec<Option<(usize, usize)>> = par::map(h, w * h >= PAR_MIN, |y| {
|
||||||
let row = px.get(y * w..(y + 1) * w)?;
|
let row = px.get(y * w..(y + 1) * w)?;
|
||||||
@@ -2047,6 +2051,14 @@ mod tests {
|
|||||||
assert!((a.gain.0..=a.gain.1).contains(&g) && b.abs() <= a.bias + 1e-6);
|
assert!((a.gain.0..=a.gain.1).contains(&g) && b.abs() <= a.bias + 1e-6);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A zero-width image has no pixels however tall it is claimed to be: no per-row table.
|
||||||
|
#[test]
|
||||||
|
fn zero_width_images_complete_without_sizing_by_height() {
|
||||||
|
let p = CompleteParams::default();
|
||||||
|
assert_eq!(complete(0, 1 << 40, 3, &[], &[], &p), Some(Vec::new()));
|
||||||
|
assert_eq!(complete(1 << 40, 0, 3, &[], &[], &p), Some(Vec::new()));
|
||||||
|
}
|
||||||
|
|
||||||
/// Smooth gradient plus deterministic fine noise.
|
/// Smooth gradient plus deterministic fine noise.
|
||||||
fn texture(w: usize, h: usize) -> Vec<f32> {
|
fn texture(w: usize, h: usize) -> Vec<f32> {
|
||||||
let mut rng = Rng::new(7);
|
let mut rng = Rng::new(7);
|
||||||
|
|||||||
+22
-11
@@ -560,7 +560,7 @@ pub enum FilterParams {
|
|||||||
impl FilterParams {
|
impl FilterParams {
|
||||||
/// Pixels read outside an output tile.
|
/// Pixels read outside an output tile.
|
||||||
pub fn halo(&self) -> Halo {
|
pub fn halo(&self) -> Halo {
|
||||||
let g = |sigma: f32| Halo::Radius((sigma.max(0.0) * 3.0).ceil() as i32 + 1);
|
let g = |sigma: f32| Halo::Radius(((sigma.max(0.0) * 3.0).ceil() as i32).saturating_add(1));
|
||||||
match self {
|
match self {
|
||||||
FilterParams::GaussianBlur { radius } | FilterParams::HighPass { radius } => g(*radius),
|
FilterParams::GaussianBlur { radius } | FilterParams::HighPass { radius } => g(*radius),
|
||||||
FilterParams::UnsharpMask { radius, .. } => Halo::Radius(sharpen::exact::halo(*radius)),
|
FilterParams::UnsharpMask { radius, .. } => Halo::Radius(sharpen::exact::halo(*radius)),
|
||||||
@@ -570,9 +570,9 @@ impl FilterParams {
|
|||||||
| FilterParams::Median { radius }
|
| FilterParams::Median { radius }
|
||||||
| FilterParams::DustAndScratches { radius, .. }
|
| FilterParams::DustAndScratches { radius, .. }
|
||||||
| FilterParams::Minimum { radius, .. }
|
| FilterParams::Minimum { radius, .. }
|
||||||
| FilterParams::Maximum { radius, .. } => Halo::Radius(radius.max(0.0).ceil() as i32 + 1),
|
| FilterParams::Maximum { radius, .. } => Halo::Radius((radius.max(0.0).ceil() as i32).saturating_add(1)),
|
||||||
FilterParams::MotionBlur { distance, .. } => Halo::Radius((distance.abs() / 2.0).ceil() as i32 + 2),
|
FilterParams::MotionBlur { distance, .. } => Halo::Radius((distance.abs() / 2.0).ceil() as i32 + 2),
|
||||||
FilterParams::Mosaic { cell_size } => Halo::Radius(cell_size.max(1.0).ceil() as i32 + 1),
|
FilterParams::Mosaic { cell_size } => Halo::Radius((cell_size.max(1.0).ceil() as i32).saturating_add(1)),
|
||||||
FilterParams::Emboss { height, .. } => Halo::Radius(height.abs().ceil() as i32 + 2),
|
FilterParams::Emboss { height, .. } => Halo::Radius(height.abs().ceil() as i32 + 2),
|
||||||
FilterParams::FindEdges => Halo::Radius(1),
|
FilterParams::FindEdges => Halo::Radius(1),
|
||||||
FilterParams::AddNoise { .. } | FilterParams::Solarize | FilterParams::Invert | FilterParams::Desaturate => Halo::Radius(0),
|
FilterParams::AddNoise { .. } | FilterParams::Solarize | FilterParams::Invert | FilterParams::Desaturate => Halo::Radius(0),
|
||||||
@@ -697,7 +697,7 @@ impl FilterParams {
|
|||||||
|
|
||||||
/// Halos of the second filter batch.
|
/// Halos of the second filter batch.
|
||||||
fn halo_ext(p: &FilterParams) -> Halo {
|
fn halo_ext(p: &FilterParams) -> Halo {
|
||||||
let r = |v: f32| Halo::Radius(v.max(0.0).ceil() as i32 + 1);
|
let r = |v: f32| Halo::Radius((v.max(0.0).ceil() as i32).saturating_add(1));
|
||||||
match p {
|
match p {
|
||||||
FilterParams::ColorHalftone { max_radius, .. } => r(max_radius * 3.0),
|
FilterParams::ColorHalftone { max_radius, .. } => r(max_radius * 3.0),
|
||||||
FilterParams::Crystallize { cell_size, .. } => r(cell_size.max(1.0) * 3.0),
|
FilterParams::Crystallize { cell_size, .. } => r(cell_size.max(1.0) * 3.0),
|
||||||
@@ -718,8 +718,9 @@ fn halo_ext(p: &FilterParams) -> Halo {
|
|||||||
FilterParams::Relight { intensity, .. } if *intensity == 0.0 => Halo::Radius(0),
|
FilterParams::Relight { intensity, .. } if *intensity == 0.0 => Halo::Radius(0),
|
||||||
FilterParams::Relight { .. } => Halo::Radius(relight::halo_radius_max()),
|
FilterParams::Relight { .. } => Halo::Radius(relight::halo_radius_max()),
|
||||||
FilterParams::ReduceNoise { .. } => r(denoise::reach()),
|
FilterParams::ReduceNoise { .. } => r(denoise::reach()),
|
||||||
FilterParams::SmartBlur { radius, .. } => r(*radius),
|
// The kernels clamp the radius to 100 px: size the source window by what they use.
|
||||||
FilterParams::LensBlur { radius, .. } => r(*radius),
|
FilterParams::SmartBlur { radius, .. } => r(radius.min(100.0)),
|
||||||
|
FilterParams::LensBlur { radius, .. } => r(radius.min(100.0)),
|
||||||
FilterParams::ShapeBlur { radius, .. } => r(shape_blur::radius(*radius)),
|
FilterParams::ShapeBlur { radius, .. } => r(shape_blur::radius(*radius)),
|
||||||
FilterParams::TiltShift { blur, .. } => r(gallery::reach(*blur)),
|
FilterParams::TiltShift { blur, .. } => r(gallery::reach(*blur)),
|
||||||
FilterParams::IrisBlur { pins } => r(gallery::reach(pins.iter().map(|p| p.blur).fold(0.0, f32::max))),
|
FilterParams::IrisBlur { pins } => r(gallery::reach(pins.iter().map(|p| p.blur).fold(0.0, f32::max))),
|
||||||
@@ -998,10 +999,18 @@ pub fn apply_in_with(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Results do not depend on the tiling, so wide-halo filters use bigger tiles to keep the
|
/// Results do not depend on the tiling, so wide-halo filters use bigger tiles to keep the
|
||||||
/// re-read margin (and its cost) below ~2× the tile area.
|
/// re-read margin (and its cost) below ~2× the tile area. The radius is input-derived, so the
|
||||||
|
/// halo arithmetic is done in `i64` and the result capped: an `i32::MAX` radius must not
|
||||||
|
/// overflow in debug builds, and must not produce a tile bigger than the hard cap.
|
||||||
fn auto_tile(params: &FilterParams, bounds: Rect) -> i32 {
|
fn auto_tile(params: &FilterParams, bounds: Rect) -> i32 {
|
||||||
match params.halo_for(bounds) {
|
match params.halo_for(bounds) {
|
||||||
Halo::Radius(r) => TILE.max((2 * r + 63) / 64 * 64).min(2048),
|
// The radius is input-derived: widen to i64 before the arithmetic and cap the
|
||||||
|
// conversion, so an i32::MAX radius cannot overflow (debug panic) and cannot make a
|
||||||
|
// tile bigger than the hard cap below (131_072 = 2048 * 64, one tile row).
|
||||||
|
Halo::Radius(r) => {
|
||||||
|
let halo = ((2 * i64::from(r) + 63) / 64 * 64).min(131_072);
|
||||||
|
TILE.max(halo as i32).min(2048)
|
||||||
|
}
|
||||||
Halo::Bounds => TILE,
|
Halo::Bounds => TILE,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1095,14 +1104,16 @@ fn apply_tiled_with_impl(
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let halo = params.halo_for(bounds);
|
let halo = params.halo_for(bounds);
|
||||||
if matches!(params, FilterParams::UnsharpMask { .. }) {
|
// Every windowed filter: a Filter Gallery stack sums its effects' reach, and a radius is
|
||||||
let Halo::Radius(reach) = halo else { return None };
|
// caller-supplied, so a short parameter set can ask for a huge source window per tile.
|
||||||
|
if let Halo::Radius(reach) = halo {
|
||||||
let (w, h) = (area.width().min(tile as u32) as usize, area.height().min(tile as u32) as usize);
|
let (w, h) = (area.width().min(tile as u32) as usize, area.height().min(tile as u32) as usize);
|
||||||
let read_samples = w.checked_add(2 * reach as usize)?.checked_mul(h.checked_add(2 * reach as usize)?)?.checked_mul(fmt.channels())?;
|
let read_samples = w.checked_add(2 * reach as usize)?.checked_mul(h.checked_add(2 * reach as usize)?)?.checked_mul(fmt.channels())?;
|
||||||
let tile_count = u64::from(area.width()).div_ceil(tile as u64).checked_mul(u64::from(area.height()).div_ceil(tile as u64))?;
|
let tile_count = u64::from(area.width()).div_ceil(tile as u64).checked_mul(u64::from(area.height()).div_ceil(tile as u64))?;
|
||||||
// Reject hostile source-window sizes before Image::read allocates them.
|
// Reject hostile source-window sizes before Image::read allocates them.
|
||||||
// Ordinary filter parameters and automatic tiles fit these bounds.
|
// Ordinary filter parameters and automatic tiles fit these bounds.
|
||||||
if read_samples > 512 * 1024 * 1024 || read_samples.checked_mul(std::mem::size_of::<f32>())? > isize::MAX as usize || tile_count > 1_048_576 {
|
let too_many_tiles = matches!(params, FilterParams::UnsharpMask { .. }) && tile_count > 1_048_576;
|
||||||
|
if read_samples > 512 * 1024 * 1024 || read_samples.checked_mul(std::mem::size_of::<f32>())? > isize::MAX as usize || too_many_tiles {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -209,11 +209,20 @@ pub fn auto_cell(bounds: Rect) -> f64 {
|
|||||||
if bounds.size().area() <= 4_200_000 { 2.0 } else { 4.0 }
|
if bounds.size().area() <= 4_200_000 { 2.0 } else { 4.0 }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Most nodes of a field's displacement grid (12 bytes each with the freeze mask): 192 MB.
|
||||||
|
const MAX_FIELD_NODES: usize = 1 << 24;
|
||||||
|
|
||||||
impl LiquifyField {
|
impl LiquifyField {
|
||||||
pub fn new(bounds: Rect, cell: f64) -> Self {
|
pub fn new(bounds: Rect, cell: f64) -> Self {
|
||||||
let cell = cell.clamp(1.0, 64.0);
|
let mut cell = cell.clamp(1.0, 64.0);
|
||||||
let w = (f64::from(bounds.width()) / cell).ceil() as usize + 2;
|
let nodes = |cell: f64| ((f64::from(bounds.width()) / cell).ceil() as usize + 2, (f64::from(bounds.height()) / cell).ceil() as usize + 2);
|
||||||
let h = (f64::from(bounds.height()) / cell).ceil() as usize + 2;
|
let (mut w, mut h) = nodes(cell);
|
||||||
|
// The extent is the document canvas, which a file declares: past the node budget the
|
||||||
|
// field gets coarser (it is interpolated anyway) instead of asking for gigabytes.
|
||||||
|
while w.saturating_mul(h) > MAX_FIELD_NODES {
|
||||||
|
cell *= 2.0;
|
||||||
|
(w, h) = nodes(cell);
|
||||||
|
}
|
||||||
LiquifyField { bounds, cell, w, h, d: vec![[0.0; 2]; w * h], freeze: vec![0.0; w * h], cursor: None }
|
LiquifyField { bounds, cell, w, h, d: vec![[0.0; 2]; w * h], freeze: vec![0.0; w * h], cursor: None }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1122,6 +1131,15 @@ mod tests {
|
|||||||
assert!(dd < 1e-3, "slow and fast pointer fields differ by {dd} px");
|
assert!(dd < 1e-3, "slow and fast pointer fields differ by {dd} px");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_huge_canvas_gets_a_coarser_field_not_a_huge_one() {
|
||||||
|
// 400000² px at 2 px per node is 4·10¹⁰ nodes.
|
||||||
|
let f = LiquifyField::new(Rect::new(0, 0, 400_000, 400_000), 2.0);
|
||||||
|
assert!(f.w * f.h <= MAX_FIELD_NODES && f.d.len() == f.w * f.h && f.freeze.len() == f.d.len());
|
||||||
|
assert!(f.cell > 64.0);
|
||||||
|
assert_eq!(LiquifyField::new(Rect::new(0, 0, 100, 100), 2.0).cell, 2.0);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_stroke_interrupted_by_a_jump_restarts_its_spacing() {
|
fn a_stroke_interrupted_by_a_jump_restarts_its_spacing() {
|
||||||
// A segment that does not start where the last one ended begins a new run of dabs there.
|
// A segment that does not start where the last one ended begins a new run of dabs there.
|
||||||
|
|||||||
+61
-12
@@ -159,8 +159,37 @@ fn cover(o: i32, scale: f64) -> (i32, i32) {
|
|||||||
(a, ((o + 1) as f64 / scale + 1e-9).floor().max(a as f64 + 1.0) as i32)
|
(a, ((o + 1) as f64 / scale + 1e-9).floor().max(a as f64 + 1.0) as i32)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Unclamped weights of output sample `o` over source samples `lo..lo + len`.
|
/// The source samples worth a weight each: the edge, or the content with one sample of slack on
|
||||||
fn weights(o: i32, scale: f64, kernel: Kernel, edge: Option<(i32, i32)>) -> (i32, Vec<f64>) {
|
/// each side to carry the weight of everything beyond it (read as the default pixel).
|
||||||
|
fn keep_range(edge: Option<(i32, i32)>, content: (i32, i32)) -> (i64, i64) {
|
||||||
|
let (a, b) = edge.unwrap_or((content.0.saturating_sub(1), content.1.saturating_add(1)));
|
||||||
|
(i64::from(a), i64::from(b.max(a)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds `v` spread evenly over the source samples `[a, b)` to `w`, which holds the samples
|
||||||
|
/// from `lo`: samples beyond either end of it add to that end slot instead of needing a slot.
|
||||||
|
fn spread(w: &mut [f64], lo: i64, a: i64, b: i64, v: f64) {
|
||||||
|
let Some(last) = w.len().checked_sub(1) else { return };
|
||||||
|
let each = v / (b - a).max(1) as f64;
|
||||||
|
let end = lo + w.len() as i64;
|
||||||
|
let (ia, ib) = (a.max(lo), b.min(end));
|
||||||
|
if ia < ib {
|
||||||
|
for slot in w.get_mut((ia - lo) as usize..(ib - lo) as usize).into_iter().flatten() {
|
||||||
|
*slot += each;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if a < lo {
|
||||||
|
w[0] += each * (b.min(lo) - a) as f64;
|
||||||
|
}
|
||||||
|
if b > end {
|
||||||
|
w[last] += each * (b - a.max(end)) as f64;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Weights of output sample `o` over source samples `lo..lo + len`, unclamped except that
|
||||||
|
/// reductions fold what lies beyond the edge (or the `content`) onto its ends, so a tiny scale
|
||||||
|
/// doesn't cost a weight per source sample it covers.
|
||||||
|
fn weights(o: i32, scale: f64, kernel: Kernel, edge: Option<(i32, i32)>, content: (i32, i32)) -> (i32, Vec<f64>) {
|
||||||
match kernel {
|
match kernel {
|
||||||
Kernel::Nearest => {
|
Kernel::Nearest => {
|
||||||
let u = (o as f64 + 0.5) / scale - 0.5;
|
let u = (o as f64 + 0.5) / scale - 0.5;
|
||||||
@@ -168,7 +197,11 @@ fn weights(o: i32, scale: f64, kernel: Kernel, edge: Option<(i32, i32)>) -> (i32
|
|||||||
}
|
}
|
||||||
Kernel::Bilinear if scale < 0.5 => {
|
Kernel::Bilinear if scale < 0.5 => {
|
||||||
let (a, b) = cover(o, scale);
|
let (a, b) = cover(o, scale);
|
||||||
(a, vec![1.0 / (b - a) as f64; (b - a) as usize])
|
let (ka, kb) = keep_range(edge, content);
|
||||||
|
let (lo, hi) = (i64::from(a).clamp(ka, kb), (i64::from(b) - 1).clamp(ka, kb));
|
||||||
|
let mut w = vec![0.0; (hi - lo + 1) as usize];
|
||||||
|
spread(&mut w, lo, i64::from(a), i64::from(b), 1.0);
|
||||||
|
(lo as i32, w)
|
||||||
}
|
}
|
||||||
Kernel::Bilinear => {
|
Kernel::Bilinear => {
|
||||||
let u = position(o, scale, edge);
|
let u = position(o, scale, edge);
|
||||||
@@ -179,18 +212,18 @@ fn weights(o: i32, scale: f64, kernel: Kernel, edge: Option<(i32, i32)>) -> (i32
|
|||||||
// Averaged down to four times the target first (pixels of `inner`), then filtered.
|
// Averaged down to four times the target first (pixels of `inner`), then filtered.
|
||||||
let inner = 4.0 * scale;
|
let inner = 4.0 * scale;
|
||||||
let inner_edge = edge.map(|(a, b)| ((a as f64 * inner - 1e-9).ceil() as i32, ((b + 1) as f64 * inner - 1e-9).ceil() as i32 - 1));
|
let inner_edge = edge.map(|(a, b)| ((a as f64 * inner - 1e-9).ceil() as i32, ((b + 1) as f64 * inner - 1e-9).ceil() as i32 - 1));
|
||||||
let (k0, kw) = weights(o, 0.25, Kernel::Keys { a, stretch }, inner_edge);
|
let (k0, kw) = weights(o, 0.25, Kernel::Keys { a, stretch }, inner_edge, (i32::MIN / 4, i32::MAX / 4));
|
||||||
let clamp = |k: i32| inner_edge.map_or(k, |(a, b)| k.clamp(a, b.max(a)));
|
let clamp = |k: i32| inner_edge.map_or(k, |(a, b)| k.clamp(a, b.max(a)));
|
||||||
let boxes: Vec<(i32, i32)> = (k0..).zip(&kw).map(|(k, _)| cover(clamp(k), inner)).collect();
|
let boxes: Vec<(i32, i32)> = (k0..).zip(&kw).map(|(k, _)| cover(clamp(k), inner)).collect();
|
||||||
let lo = boxes.iter().map(|b| b.0).min().unwrap_or(0);
|
let (ka, kb) = keep_range(edge, content);
|
||||||
let hi = boxes.iter().map(|b| b.1).max().unwrap_or(lo);
|
let lo = i64::from(boxes.iter().map(|b| b.0).min().unwrap_or(0));
|
||||||
let mut w = vec![0.0; (hi - lo).max(0) as usize];
|
let hi = i64::from(boxes.iter().map(|b| b.1).max().unwrap_or(lo as i32));
|
||||||
|
let (klo, khi) = (lo.clamp(ka, kb), (hi - 1).clamp(ka, kb));
|
||||||
|
let mut w = vec![0.0; (khi - klo + 1) as usize];
|
||||||
for ((a, b), v) in boxes.into_iter().zip(kw) {
|
for ((a, b), v) in boxes.into_iter().zip(kw) {
|
||||||
for slot in w.get_mut((a - lo) as usize..(b - lo) as usize).into_iter().flatten() {
|
spread(&mut w, klo, i64::from(a), i64::from(b), v);
|
||||||
*slot += v / (b - a) as f64;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
(lo, w)
|
(klo as i32, w)
|
||||||
}
|
}
|
||||||
Kernel::Keys { a, stretch } => {
|
Kernel::Keys { a, stretch } => {
|
||||||
let u = position(o, scale, edge);
|
let u = position(o, scale, edge);
|
||||||
@@ -225,7 +258,7 @@ fn taps(dst0: i32, dst1: i32, scale: f64, kernel: Kernel, edge: Option<(i32, i32
|
|||||||
let kernel = if scale == 1.0 { Kernel::Nearest } else { kernel };
|
let kernel = if scale == 1.0 { Kernel::Nearest } else { kernel };
|
||||||
(dst0..dst1)
|
(dst0..dst1)
|
||||||
.map(|o| {
|
.map(|o| {
|
||||||
let (lo, w) = weights(o, scale, kernel, edge);
|
let (lo, w) = weights(o, scale, kernel, edge, content);
|
||||||
let hi = lo + w.len() as i32 - 1;
|
let hi = lo + w.len() as i32 - 1;
|
||||||
// Fold the weights of taps beyond the edge onto the edge sample.
|
// Fold the weights of taps beyond the edge onto the edge sample.
|
||||||
let start = clamp(lo);
|
let start = clamp(lo);
|
||||||
@@ -724,6 +757,22 @@ mod tests {
|
|||||||
assert!((p[0] - 0.5).abs() < 0.05, "{p:?}");
|
assert!((p[0] - 0.5).abs() < 0.05, "{p:?}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A reduction by a tiny scale covers millions of source samples per output sample; only the
|
||||||
|
/// ones in the content get a weight, the rest fold onto its ends or into `outside`.
|
||||||
|
#[test]
|
||||||
|
fn tiny_scales_do_not_get_a_weight_per_covered_sample() {
|
||||||
|
for kernel in [Kernel::Bilinear, Kernel::Keys { a: -0.5, stretch: 1.0 }] {
|
||||||
|
for edge in [None, Some((0, 99))] {
|
||||||
|
let t = taps(0, 3, 1e-6, kernel, edge, (0, 99));
|
||||||
|
for t in &t {
|
||||||
|
assert!(t.weights.len() <= 100, "{}", t.weights.len());
|
||||||
|
let total = t.weights.iter().sum::<f32>() + t.outside;
|
||||||
|
assert!((total - 1.0).abs() < 1e-3, "{total}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn transparent_edges_do_not_darken() {
|
fn transparent_edges_do_not_darken() {
|
||||||
let mut s = Surface::new(PixelFormat::RGBA32F);
|
let mut s = Surface::new(PixelFormat::RGBA32F);
|
||||||
|
|||||||
@@ -640,3 +640,12 @@ fn mix_selection_reads_the_selection_once_per_tile() {
|
|||||||
assert!(a.iter().zip(&b).all(|(x, y)| x.to_bits() == y.to_bits()), "alpha {alpha}: results differ");
|
assert!(a.iter().zip(&b).all(|(x, y)| x.to_bits() == y.to_bits()), "alpha {alpha}: results differ");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn blur_halos_follow_the_clamped_radius_and_never_overflow() {
|
||||||
|
let smart = |radius| FilterParams::SmartBlur { radius, threshold: 25.0, quality: BlurQuality::default(), mode: SmartBlurMode::default() };
|
||||||
|
assert_eq!(smart(1e9).halo(), Halo::Radius(101));
|
||||||
|
assert_eq!(smart(f32::INFINITY).halo(), Halo::Radius(101));
|
||||||
|
let gaussian = FilterParams::GaussianBlur { radius: f32::INFINITY };
|
||||||
|
assert_eq!(gaussian.halo(), Halo::Radius(i32::MAX));
|
||||||
|
}
|
||||||
|
|||||||
+28
-1
@@ -164,6 +164,11 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2
|
|||||||
|
|
||||||
/// [`warp_triangles`], reporting progress per destination tile to `ctl`. A cancelled `ctl` skips
|
/// [`warp_triangles`], reporting progress per destination tile to `ctl`. A cancelled `ctl` skips
|
||||||
/// the remaining tiles; the caller discards the result.
|
/// the remaining tiles; the caller discards the result.
|
||||||
|
/// Destination tiles one triangle render may keep bookkeeping for (a 1M-tile render is 69 Gpx).
|
||||||
|
const MAX_DST_TILES: u64 = 1 << 20;
|
||||||
|
/// Source pixels one destination tile may read densely.
|
||||||
|
const MAX_FOOTPRINT_PIXELS: u64 = 1 << 25;
|
||||||
|
|
||||||
fn triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2])], tris: &[[usize; 3]], interp: Interp, ctl: &Interrupt) -> Surface {
|
fn triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2])], tris: &[[usize; 3]], interp: Interp, ctl: &Interrupt) -> Surface {
|
||||||
let mut fmt = src.format();
|
let mut fmt = src.format();
|
||||||
let converted;
|
let converted;
|
||||||
@@ -190,6 +195,12 @@ fn triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2])], tris
|
|||||||
y1 = y1.max(v.0[1]);
|
y1 = y1.max(v.0[1]);
|
||||||
}
|
}
|
||||||
let dst = Rect::new(x0.max(-lim).floor() as i32 - 1, y0.max(-lim).floor() as i32 - 1, x1.min(lim).ceil() as i32 + 1, y1.min(lim).ceil() as i32 + 1);
|
let dst = Rect::new(x0.max(-lim).floor() as i32 - 1, y0.max(-lim).floor() as i32 - 1, x1.min(lim).ceil() as i32 + 1, y1.min(lim).ceil() as i32 + 1);
|
||||||
|
// Bookkeeping is kept per destination tile, so a mesh flung across the whole coordinate range
|
||||||
|
// would ask for millions of entries before drawing anything: draw nothing instead.
|
||||||
|
let ts_px = photocraft_geom::TILE_SIZE as u64;
|
||||||
|
if u64::from(dst.width()).div_ceil(ts_px).saturating_add(1).saturating_mul(u64::from(dst.height()).div_ceil(ts_px).saturating_add(1)) > MAX_DST_TILES {
|
||||||
|
return out;
|
||||||
|
}
|
||||||
// Triangles: (vertex indices), binned per destination tile.
|
// Triangles: (vertex indices), binned per destination tile.
|
||||||
let tiles: Vec<Rect> = dst.tiles().map(|tc| tc.rect().intersect(&dst)).filter(|r| !r.is_empty()).collect();
|
let tiles: Vec<Rect> = dst.tiles().map(|tc| tc.rect().intersect(&dst)).filter(|r| !r.is_empty()).collect();
|
||||||
let mut bins: Vec<Vec<[usize; 3]>> = vec![Vec::new(); tiles.len()];
|
let mut bins: Vec<Vec<[usize; 3]>> = vec![Vec::new(); tiles.len()];
|
||||||
@@ -304,7 +315,11 @@ fn triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2])], tris
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let foot = Rect::new(fx0.floor() as i32 - 3, fy0.floor() as i32 - 3, fx1.ceil() as i32 + 3, fy1.ceil() as i32 + 3).intersect(&src_rect);
|
let foot = Rect::new(fx0.floor() as i32 - 3, fy0.floor() as i32 - 3, fx1.ceil() as i32 + 3, fy1.ceil() as i32 + 3).intersect(&src_rect);
|
||||||
if foot.is_empty() || !src.has_tiles_in(foot) {
|
// Sparse components far apart in the source can land in one tile: the dense window
|
||||||
|
// between them is not read (the tile stays empty). The pixel-area cap is checked first:
|
||||||
|
// `has_tiles_in` walks the source tile grid, so an oversized footprint must return
|
||||||
|
// without paying for that scan.
|
||||||
|
if foot.is_empty() || foot.size().area() > MAX_FOOTPRINT_PIXELS || !src.has_tiles_in(foot) {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let mut px = src.read_region(foot);
|
let mut px = src.read_region(foot);
|
||||||
@@ -510,6 +525,18 @@ mod tests {
|
|||||||
surf
|
surf
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_mesh_flung_across_the_coordinate_range_gets_no_per_tile_bookkeeping() {
|
||||||
|
// About 8000 × 8000 destination tiles from three vertices.
|
||||||
|
let src = sample(SampleType::U8);
|
||||||
|
let far = 1_000_000.0;
|
||||||
|
let verts = [([-far, -far], [0.0, 0.0]), ([far, -far], [40.0, 0.0]), ([0.0, far], [0.0, 24.0])];
|
||||||
|
let t = std::time::Instant::now();
|
||||||
|
let out = warp_triangles(&src, Rect::new(0, 0, 40, 24), &verts, &[[0, 1, 2]], Interp::Nearest);
|
||||||
|
assert!(out.content_bounds().is_empty());
|
||||||
|
assert!(t.elapsed().as_secs() < 5);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_thin_layer_stretched_into_a_curve_keeps_the_curve() {
|
fn a_thin_layer_stretched_into_a_curve_keeps_the_curve() {
|
||||||
// #5793: a 3 px wide column stretched 100x and bent into an arch. The grid was sized in
|
// #5793: a 3 px wide column stretched 100x and bent into an arch. The grid was sized in
|
||||||
|
|||||||
Reference in New Issue
Block a user