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:
Dylan M. Taylor
2026-10-23 15:22:24 -04:00
committed by GitHub
parent 143c3977c2
commit ecd1a692a3
7 changed files with 213 additions and 44 deletions
+60 -17
View File
@@ -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 &deg in &opts.rotations { for &deg 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.
+12
View File
@@ -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
View File
@@ -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;
} }
} }
+21 -3
View File
@@ -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
View File
@@ -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);
+9
View File
@@ -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
View File
@@ -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