Crop: a turned crop rotates only what the frame keeps (#5006) (#5007)

A turned crop rotated the whole canvas, then cropped to the frame. With
Delete Cropped Pixels on, every layer, mask and channel is now trimmed to
the frame's source (plus the interpolation kernel's reach) before the
turn, so the rotation costs the frame's size: a 512x442 frame turned 3°
on a 3859x2594 two-layer document goes from 541 ms to 42 ms.

Rotate Canvas also fills the Background with shared tiles instead of a
canvas-sized float buffer.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
David Mann
2026-10-19 14:20:42 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 1a03735cc6
commit e306bcd685
3 changed files with 136 additions and 2 deletions
+40
View File
@@ -0,0 +1,40 @@
//! End-to-end timing of `image.crop`, upright and turned, on a two-layer document.
//! `cargo run --release -p photocraft-engine --example bench_crop -- [image] [reps]`
//! Without an image a 3859×2594 noise photo stands in (the size of the Great Wave scan).
use std::time::{Duration, Instant};
use photocraft_engine::Session;
use serde_json::{Value, json};
fn main() {
let args: Vec<String> = std::env::args().collect();
let reps: usize = args.get(2).and_then(|v| v.parse().ok()).unwrap_or(3);
let mut s = Session::new();
match args.get(1) {
Some(path) => {
let bytes = std::fs::read(path).expect("read the image");
let doc = photocraft_io::import(path, &bytes).expect("decode the image").document;
s.add_document(doc, None);
}
None => {
s.execute("file.new", json!({"width": 3859, "height": 2594, "background": "white"})).expect("new");
s.execute("filter.noise.addNoise", json!({"amount": 50})).expect("noise");
}
}
s.execute("layer.duplicate", json!({})).expect("a second layer");
let cases: [(&str, Value); 3] = [
("upright 512x442", json!({"x": 40, "y": 40, "width": 512, "height": 442})),
("turned 3° 512x442", json!({"x": 40, "y": 40, "width": 512, "height": 442, "angle": 3})),
("turned 3° 3000x2000", json!({"x": 400, "y": 300, "width": 3000, "height": 2000, "angle": 3})),
];
for (label, p) in cases {
let mut best = Duration::MAX;
for _ in 0..reps {
let t = Instant::now();
s.execute("image.crop", p.clone()).expect("crop");
best = best.min(t.elapsed());
s.execute("edit.undo", json!({})).expect("undo");
}
println!("{label:24} best of {reps}: {best:.2?}");
}
}
+94 -1
View File
@@ -668,6 +668,21 @@ fn rotated_crop(s: &mut Session, r: Rect, angle: f64, delete: bool, content_awar
rotated: true,
};
crate::analysis_cmds::compound(s, "Crop", |s| {
// Pixels the crop deletes needn't be turned first: keeping only what lands in the frame
// makes the rotation cost the frame's size, not the canvas's (a small turned crop of a
// 10 MP photo took over half a second).
if delete {
let keep = turned_crop_source(size, new, angle, target);
s.edit("Crop", |doc, _| {
for_each_surface(&mut doc.layers, true, &mut |surf, _| *surf = crop_surface(surf, keep));
for ch in doc.channels.iter_mut().chain(doc.quick_mask.as_mut()) {
ch.surface = crop_surface(&ch.surface, keep);
}
// The crop drops the selection anyway.
doc.selection = None;
Ok(())
})?;
}
crate::mode_cmds::rotate_arbitrary(s, &json!({"angle": angle, "direction": "ccw"}))?;
// Color Sampler points follow the crop's origin shift too (the rotation moved them above).
s.set_sampler_map(photocraft_geom::Affine::translate(-f64::from(target.x0), -f64::from(target.y0)));
@@ -685,6 +700,26 @@ fn rotated_crop(s: &mut Session, r: Rect, angle: f64, delete: bool, content_awar
Ok(crop_result(s, json!({ "x": r.x0, "y": r.y0, "width": r.width(), "height": r.height(), "angle": angle })))
}
/// The pixels a turned crop reads, in document coordinates before the turn: the source of the
/// `target` frame (on the `new` canvas the rotation by `-angle` makes of `old`), grown by the widest
/// interpolation kernel's reach so every sample the frame's pixels take is kept.
fn turned_crop_source(old: Size, new: Size, angle: f64, target: Rect) -> Rect {
const KERNEL_REACH: f64 = 4.0;
// The inverse of `rotate_arbitrary`'s map: x = c·u + s·v + cx, y = −s·u + c·v + cy, where
// (u, v) is the point relative to the new canvas's centre.
let (sn, cs) = (-angle).to_radians().sin_cos();
let (cx, cy) = (f64::from(old.width) / 2.0, f64::from(old.height) / 2.0);
let (nx, ny) = (f64::from(new.width) / 2.0, f64::from(new.height) / 2.0);
let (mut x0, mut y0, mut x1, mut y1) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
for (px, py) in [(target.x0, target.y0), (target.x1, target.y0), (target.x0, target.y1), (target.x1, target.y1)] {
let (u, v) = (f64::from(px) - nx, f64::from(py) - ny);
let (x, y) = (cs * u + sn * v + cx, -sn * u + cs * v + cy);
(x0, y0, x1, y1) = (x0.min(x), y0.min(y), x1.max(x), y1.max(y));
}
// `as` saturates: a frame near the coordinate limits keeps more, never less.
Rect::new((x0 - KERNEL_REACH).floor() as i32, (y0 - KERNEL_REACH).floor() as i32, (x1 + KERNEL_REACH).ceil() as i32, (y1 + KERNEL_REACH).ceil() as i32)
}
/// Image → Trim.
fn trim(s: &mut Session, p: &Value) -> Result<Value> {
let d = s.active().ok_or(EngineError::NoDocument)?;
@@ -1295,7 +1330,10 @@ mod tests {
for y in 0..20 {
for x in 0..10 {
let (a, b) = (layer_rgba(&s, l, x, y), layer_rgba(&reference, l, x, y));
assert!(a.iter().zip(b).all(|(p, q)| (p - q).abs() < 1e-3), "{depth} layer {l} ({x}, {y}): {a:?} vs {b:?}");
// A fully transparent pixel's colour is invisible, and the turned crop
// doesn't turn the pixels it deletes, which the warp spreads colour from.
let n = if b[3] > 1e-3 { 4 } else { 1 };
assert!(a.iter().rev().zip(b.iter().rev()).take(n).all(|(p, q)| (p - q).abs() < 1e-3), "{depth} layer {l} ({x}, {y}): {a:?} vs {b:?}");
}
}
}
@@ -1336,6 +1374,61 @@ mod tests {
}
}
/// A turned crop that deletes the cropped pixels turns only what lands in the frame; inside the
/// new canvas every layer, mask and channel is exactly what turning everything gives (the
/// frame spans several tiles, at several depths and angles).
#[test]
fn turned_crop_deleting_pixels_matches_turning_the_whole_canvas() {
use photocraft_color::PixelFormat;
let pixels = || (0..500).flat_map(|y| (0..700).map(move |x| (x, y)));
for depth in [8, 16, 32] {
for angle in [3.0, -7.5, 33.0, 181.0] {
let mut sessions = [true, false].map(|delete| {
let mut s = Session::new();
s.execute("file.new", json!({"width": 700, "height": 500, "depth": depth, "background": "white"})).unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
s.edit("paint", |doc, active| {
let wave = |x: i32, y: i32, k: i32| ((x * 7 + y * 13 + k * 31) % 97) as f32 / 96.0;
let all = Rect::new(0, 0, 700, 500);
let rgba: Vec<f32> = pixels().flat_map(|(x, y)| [wave(x, y, 0), wave(x, y, 1), wave(x, y, 2), wave(y, x, 3)]).collect();
let gray: Vec<f32> = pixels().map(|(x, y)| wave(y, x, 4)).collect();
let l = doc.layer_mut(active.unwrap()).unwrap();
l.surface_mut().unwrap().write_region(all, &rgba);
let mut mask = photocraft_doc::LayerMask::reveal_all();
mask.surface.write_region(all, &gray);
l.mask = Some(mask);
let bg = doc.background_mut().unwrap().surface_mut().unwrap();
bg.write_region(all, &pixels().flat_map(|(x, y)| [wave(x, y, 5), wave(x, y, 6), wave(x, y, 7), 1.0]).collect::<Vec<_>>());
let fmt = PixelFormat::new(ColorMode::Grayscale, doc.pixel_format().sample, false);
let mut ch = Surface::new(fmt);
ch.write_region(all, &gray);
doc.channels.push(photocraft_doc::AlphaChannel::new("Alpha 1", ch));
Ok(())
})
.unwrap();
let p = json!({"x": 150, "y": 120, "width": 330, "height": 290, "angle": angle, "deleteCroppedPixels": delete});
s.execute("image.crop", p).unwrap();
s
});
let [deleted, kept] = &mut sessions;
let (a, b) = (doc(deleted), doc(kept));
assert_eq!(a.size, b.size, "{depth} {angle}");
let canvas = a.bounds();
for (la, lb) in a.layers.iter().zip(&b.layers) {
let (sa, sb) = (la.surface().unwrap(), lb.surface().unwrap());
assert!(sa.to_interleaved(canvas) == sb.to_interleaved(canvas), "{depth} {angle}: layer {}", la.name);
if let (Some(ma), Some(mb)) = (&la.mask, &lb.mask) {
assert!(ma.surface.to_interleaved(canvas) == mb.surface.to_interleaved(canvas), "{depth} {angle}: mask of {}", la.name);
}
}
assert!(a.channels[0].surface.to_interleaved(canvas) == b.channels[0].surface.to_interleaved(canvas), "{depth} {angle}: alpha channel");
// Deleting keeps nothing past the canvas.
let layer = a.layers[1].surface().unwrap();
assert!(canvas.contains_rect(&layer.content_bounds()), "{depth} {angle}: {:?}", layer.content_bounds());
}
}
}
/// Angle 0 (or a whole turn) is the plain crop: no resampling, the same document.
#[test]
fn crop_at_angle_zero_is_the_plain_crop() {
+2 -1
View File
@@ -112,7 +112,8 @@ pub(crate) fn rotate_arbitrary(s: &mut Session, p: &Value) -> Result<Value> {
let rotated = photocraft_algo::transform::warp_surface(surf, src, &h, interp)?;
let mut base = Surface::new(fmt);
let fill = photocraft_raster::from_rgba(&fmt, bg);
base.write_region(canvas, &fill.repeat(canvas.width() as usize * canvas.height() as usize));
// One shared tile per full tile, not a canvas-sized float buffer.
base.fill_rect(canvas, &fill);
crate::transform_cmds::composite_over(&mut base, &rotated);
base.prune();
*surf = base;