mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-24 01:47: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>,
|
||||
}
|
||||
|
||||
/// 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.
|
||||
#[derive(Clone, Copy)]
|
||||
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.
|
||||
/// `Err` when cancelled.
|
||||
fn variant(src: Src, deg: f32, scale: f32, mirror: bool, ctl: &Interrupt) -> Result<Variant, Cancelled> {
|
||||
let Src { w, h, ch, .. } = src;
|
||||
/// Dimensions of the [`variant`] copy: the same bounding-box maths without building its pixels.
|
||||
fn variant_dims(w: usize, h: usize, deg: f32, scale: f32) -> (usize, usize) {
|
||||
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 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);
|
||||
(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 (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] };
|
||||
@@ -243,17 +265,27 @@ fn fill_cow(
|
||||
let gap = 2 * params.patch_radius + 2;
|
||||
let ok: Vec<bool> = par::map(w * h, true, |i| !hole[i] && source[i]);
|
||||
let src = Src { w, h, ch, img: &img, ok: &ok };
|
||||
// Variants: the identity (which holds the hole) first, then the adaptations.
|
||||
let mut extra: Vec<Variant> = Vec::new();
|
||||
// Variants: the identity (which holds the hole) first, then the adaptations. Their pixels are
|
||||
// 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 {
|
||||
extra.push(variant(src, 0.0, 1.0, true, ctl)?);
|
||||
specs.push((0.0, 1.0, true));
|
||||
}
|
||||
for ° in &opts.rotations {
|
||||
extra.push(variant(src, deg, 1.0, false, ctl)?);
|
||||
specs.push((deg, 1.0, false));
|
||||
}
|
||||
if opts.scale {
|
||||
extra.push(variant(src, 0.0, 0.8, false, ctl)?);
|
||||
extra.push(variant(src, 0.0, 1.25, false, ctl)?);
|
||||
specs.push((0.0, 0.8, false));
|
||||
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| {
|
||||
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.
|
||||
// Without variants it is the image itself.
|
||||
let (mw, mh, mosaic, roles): (usize, usize, Option<Vec<f32>>, Vec<Px>) = if extra.is_empty() {
|
||||
(w, h, None, par::map(w * h, true, role))
|
||||
} 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);
|
||||
// Without variants it is the image itself; so it is when the variants would not fit
|
||||
// (`size` above is then None and `extra` is empty).
|
||||
let (mw, mh, mosaic, roles): (usize, usize, Option<Vec<f32>>, Vec<Px>) = if let Some((mw, mh)) = size {
|
||||
let mut mimg = vec![0.0f32; mw * mh * ch];
|
||||
let mut roles = vec![Px::Void; mw * mh];
|
||||
for y in 0..h {
|
||||
@@ -297,6 +324,8 @@ fn fill_cow(
|
||||
oy += v.h + gap;
|
||||
}
|
||||
(mw, mh, Some(mimg), roles)
|
||||
} else {
|
||||
(w, h, None, par::map(w * h, true, role))
|
||||
};
|
||||
drop((extra, ok));
|
||||
ctl.check()?;
|
||||
@@ -695,6 +724,20 @@ pub fn default_brush_size(hole: Rect) -> f64 {
|
||||
|
||||
#[cfg(test)]
|
||||
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::*;
|
||||
|
||||
/// 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.
|
||||
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.
|
||||
let rows: Vec<Option<(usize, usize)>> = par::map(h, w * h >= PAR_MIN, |y| {
|
||||
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);
|
||||
}
|
||||
|
||||
/// 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.
|
||||
fn texture(w: usize, h: usize) -> Vec<f32> {
|
||||
let mut rng = Rng::new(7);
|
||||
|
||||
+22
-11
@@ -560,7 +560,7 @@ pub enum FilterParams {
|
||||
impl FilterParams {
|
||||
/// Pixels read outside an output tile.
|
||||
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 {
|
||||
FilterParams::GaussianBlur { radius } | FilterParams::HighPass { radius } => g(*radius),
|
||||
FilterParams::UnsharpMask { radius, .. } => Halo::Radius(sharpen::exact::halo(*radius)),
|
||||
@@ -570,9 +570,9 @@ impl FilterParams {
|
||||
| FilterParams::Median { radius }
|
||||
| FilterParams::DustAndScratches { 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::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::FindEdges => Halo::Radius(1),
|
||||
FilterParams::AddNoise { .. } | FilterParams::Solarize | FilterParams::Invert | FilterParams::Desaturate => Halo::Radius(0),
|
||||
@@ -697,7 +697,7 @@ impl FilterParams {
|
||||
|
||||
/// Halos of the second filter batch.
|
||||
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 {
|
||||
FilterParams::ColorHalftone { max_radius, .. } => r(max_radius * 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 { .. } => Halo::Radius(relight::halo_radius_max()),
|
||||
FilterParams::ReduceNoise { .. } => r(denoise::reach()),
|
||||
FilterParams::SmartBlur { radius, .. } => r(*radius),
|
||||
FilterParams::LensBlur { radius, .. } => r(*radius),
|
||||
// The kernels clamp the radius to 100 px: size the source window by what they use.
|
||||
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::TiltShift { blur, .. } => r(gallery::reach(*blur)),
|
||||
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
|
||||
/// 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 {
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -1095,14 +1104,16 @@ fn apply_tiled_with_impl(
|
||||
return None;
|
||||
}
|
||||
let halo = params.halo_for(bounds);
|
||||
if matches!(params, FilterParams::UnsharpMask { .. }) {
|
||||
let Halo::Radius(reach) = halo else { return None };
|
||||
// Every windowed filter: a Filter Gallery stack sums its effects' reach, and a radius is
|
||||
// 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 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))?;
|
||||
// Reject hostile source-window sizes before Image::read allocates them.
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,11 +209,20 @@ pub fn auto_cell(bounds: Rect) -> f64 {
|
||||
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 {
|
||||
pub fn new(bounds: Rect, cell: f64) -> Self {
|
||||
let cell = cell.clamp(1.0, 64.0);
|
||||
let w = (f64::from(bounds.width()) / cell).ceil() as usize + 2;
|
||||
let h = (f64::from(bounds.height()) / cell).ceil() as usize + 2;
|
||||
let mut cell = cell.clamp(1.0, 64.0);
|
||||
let nodes = |cell: f64| ((f64::from(bounds.width()) / cell).ceil() as usize + 2, (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 }
|
||||
}
|
||||
|
||||
@@ -1122,6 +1131,15 @@ mod tests {
|
||||
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]
|
||||
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.
|
||||
|
||||
+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)
|
||||
}
|
||||
|
||||
/// Unclamped weights of output sample `o` over source samples `lo..lo + len`.
|
||||
fn weights(o: i32, scale: f64, kernel: Kernel, edge: Option<(i32, i32)>) -> (i32, Vec<f64>) {
|
||||
/// The source samples worth a weight each: the edge, or the content with one sample of slack on
|
||||
/// 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 {
|
||||
Kernel::Nearest => {
|
||||
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 => {
|
||||
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 => {
|
||||
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.
|
||||
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 (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 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 hi = boxes.iter().map(|b| b.1).max().unwrap_or(lo);
|
||||
let mut w = vec![0.0; (hi - lo).max(0) as usize];
|
||||
let (ka, kb) = keep_range(edge, content);
|
||||
let lo = i64::from(boxes.iter().map(|b| b.0).min().unwrap_or(0));
|
||||
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 slot in w.get_mut((a - lo) as usize..(b - lo) as usize).into_iter().flatten() {
|
||||
*slot += v / (b - a) as f64;
|
||||
}
|
||||
spread(&mut w, klo, i64::from(a), i64::from(b), v);
|
||||
}
|
||||
(lo, w)
|
||||
(klo as i32, w)
|
||||
}
|
||||
Kernel::Keys { a, stretch } => {
|
||||
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 };
|
||||
(dst0..dst1)
|
||||
.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;
|
||||
// Fold the weights of taps beyond the edge onto the edge sample.
|
||||
let start = clamp(lo);
|
||||
@@ -724,6 +757,22 @@ mod tests {
|
||||
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]
|
||||
fn transparent_edges_do_not_darken() {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
#[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
|
||||
/// 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 {
|
||||
let mut fmt = src.format();
|
||||
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]);
|
||||
}
|
||||
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.
|
||||
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()];
|
||||
@@ -304,7 +315,11 @@ fn triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2])], tris
|
||||
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);
|
||||
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;
|
||||
}
|
||||
let mut px = src.read_region(foot);
|
||||
@@ -510,6 +525,18 @@ mod tests {
|
||||
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]
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user