Files
photocraft/crates/engine/src/align_cmds.rs
T
aiongg fe28d9d43c Merge PR #7395: Auto-Blend Layers: mask and blend the whole layers, past the canvas too
crates/engine/src/align_cmds.rs (tests module): four interleaved hunks of new tests; resolved as all of ours, then all of theirs, each test whole.
2026-10-22 02:24:08 +00:00

848 lines
44 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Edit › Auto-Align Layers and Auto-Blend Layers, on the selected pixel layers.
//!
//! Both share Photomerge's registration and blending ([`photocraft_algo::panorama`] via
//! `photo_cmds`). Auto-Align matches Harris / steered-BRIEF features between every pair of
//! layers, chains the verified pairs from the reference layer (maximum spanning tree) and
//! bundle-adjusts all of them, then warps each layer. Layers that nearly coincide (a focus or
//! exposure bracket) have nothing to match and register by intensity instead
//! ([`photocraft_algo::direct`], `registration`: auto falls back to it, or ask for it). Projections: Auto, Perspective
//! (homography), Cylindrical and Spherical (rigid motion on the cylinder / sphere, focal length
//! from the homographies or EXIF), Collage (similarity) and Reposition (translation). The
//! reference stays put in the planar projections.
//!
//! Auto-Blend gives each selected layer a mask so the composite shows, per pixel, the sharpest
//! layer (Stack Images, for focus stacking) or one layer per region with seams routed where the
//! layers agree (Panorama). With Seamless Tones and Colors it also adds a merged layer: the
//! panorama blended with Laplacian pyramids so transitions are invisible, the stack fused on the
//! Laplacian pyramid, each coefficient from the layer sharpest at that scale
//! ([`pyramid::fuse_stack`]; `haloControl` keeps a bright object's defocused glow out of the
//! coarse levels).
//! Stack Images can also add a hidden Depth layer, the layer each pixel is sharpest in by depth
//! from focus ([`photocraft_algo::dff`]): black = the bottom layer, white = the top, so that once
//! on a layer mask it drives Lens Blur's depth map.
use photocraft_algo::panorama::Layout;
use photocraft_algo::pyramid;
use photocraft_algo::transform::Interp;
use photocraft_algo::warp::warp_mesh_gray;
use photocraft_doc::{Document, Layer, LayerContent, LayerId, LayerMask};
use photocraft_geom::Rect;
use photocraft_raster::{Surface, to_rgba};
use serde_json::{Value, json};
use crate::commands::CommandSpec;
use crate::photo_cmds::{NO_DETAIL, plane_matrix, register, register_direct, seam_blend, seam_order, warp_placed};
use crate::{EngineError, Result, Session};
fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
EngineError::BadParams { cmd: cmd.into(), msg: msg.into() }
}
/// The selected raster layers, bottom to top.
fn selected_pixel_layers(s: &Session) -> Vec<LayerId> {
let Some(st) = s.active() else { return Vec::new() };
st.selected_layers().into_iter().filter(|id| st.doc.layer(*id).is_some_and(|l| matches!(l.content, LayerContent::Raster(_)))).collect()
}
fn two_layers(s: &Session) -> std::result::Result<(), String> {
s.active().ok_or("no document open")?;
if selected_pixel_layers(s).len() < 2 { Err("select two or more pixel layers in the Layers panel".into()) } else { Ok(()) }
}
fn auto_align(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "edit.autoAlignLayers";
let ids = selected_pixel_layers(s);
if ids.len() < 2 {
return Err(bad(cmd, "select two or more pixel layers"));
}
let projection = p.get("projection").and_then(Value::as_str).unwrap_or("auto");
let layout = Layout::parse(projection)
.ok_or_else(|| bad(cmd, format!("unknown projection `{projection}` (auto|perspective|cylindrical|spherical|collage|reposition)")))?;
let st = s.active().ok_or(EngineError::NoDocument)?;
let doc = st.doc.clone();
let reference = match p.get("reference").and_then(Value::as_u64) {
Some(r) if ids.contains(&LayerId(r)) => LayerId(r),
Some(r) => return Err(bad(cmd, format!("reference {r} is not a selected pixel layer"))),
None => ids[0],
};
let ref_idx = ids.iter().position(|i| *i == reference).unwrap_or(0);
let area = doc.bounds();
let surfs: Vec<&Surface> = ids.iter().map(|id| doc.layer(*id).and_then(|l| l.surface()).ok_or(EngineError::NoLayer(*id))).collect::<Result<_>>()?;
let images: Vec<(&Surface, Rect)> = surfs.iter().map(|s| (*s, area)).collect();
let focal35 = doc.metadata.exif.as_ref().and_then(|e| photocraft_algo::exif::read(e).focal_length_35mm);
let geometric = p.get("geometricCorrection").and_then(Value::as_bool).unwrap_or(false);
let registration = p.get("registration").and_then(Value::as_str).unwrap_or("auto");
if !matches!(registration, "auto" | "features" | "intensity") {
return Err(bad(cmd, "`registration` must be auto|features|intensity"));
}
let no_detail = || EngineError::Other(NO_DETAIL.into());
// Features first (a panorama); layers that nearly coincide (a focus or exposure bracket) have
// nothing to match, and register by intensity instead.
let by_features = (registration != "intensity").then(|| register(&images, layout, Some(ref_idx), geometric, focal35)).flatten();
let (al, used) = match (by_features, registration) {
(Some(al), _) => (al, "features"),
(None, "features") => return Err(no_detail()),
(None, _) => (
register_direct(&images, layout, ref_idx).map_err(|m| if registration == "intensity" && m != NO_DETAIL { bad(cmd, m) } else { no_detail() })?,
"intensity",
),
};
let interp = Interp::parse(p.get("interpolation").and_then(Value::as_str).unwrap_or("bicubic"));
let planar = matches!(al.layout, Layout::Perspective | Layout::Collage | Layout::Reposition);
let mut inliers = vec![0usize; ids.len()];
for &(a, b, n) in &al.pairs {
inliers[a] += n;
inliers[b] += n;
}
// Everything but the reference moves in planar layouts; every layer is reprojected otherwise.
let moves: Vec<usize> = (0..ids.len()).filter(|&i| al.placements[i].is_some() && (i != ref_idx || !planar)).collect();
let failed: Vec<u64> = (0..ids.len()).filter(|&i| al.placements[i].is_none()).map(|i| ids[i].0).collect();
if moves.is_empty() {
return Err(EngineError::Other("Auto-Align couldn't find enough matching detail between the layers".into()));
}
let warped: Vec<(LayerId, Surface, Option<Surface>)> = moves
.iter()
.map(|&i| {
let pl = al.placements[i].as_ref().ok_or_else(|| EngineError::Other("Auto-Align lost a layer's placement".into()))?;
let l = doc.layer(ids[i]).ok_or(EngineError::NoLayer(ids[i]))?;
let surf = l.surface().ok_or(EngineError::NoLayer(ids[i]))?;
let b = surf.content_bounds();
let px = if b.is_empty() { surf.clone() } else { warp_placed(surf, b, (0.0, 0.0), pl, (0.0, 0.0), interp)? };
// Masks warp as masks: one grey channel whose outside keeps its default (reveal-all
// stays revealed), not as colour, which would come back grey + alpha reading 0 outside.
let mask = match l.mask.as_ref() {
Some(m) => Some(match plane_matrix(pl, (0.0, 0.0)) {
Some(h) => crate::transform_cmds::warp_gray(&m.surface, &h, Interp::Bilinear)?,
None => warp_mesh_gray(&m.surface, &|x, y| pl.forward(x, y), Interp::Bilinear),
}),
None => None,
};
Ok((ids[i], px, mask))
})
.collect::<Result<_>>()?;
s.edit("Auto-Align Layers", |doc, _| {
for (id, px, mask) in &warped {
let l = doc.layer_mut(*id).ok_or(EngineError::NoLayer(*id))?;
if let Some(surf) = l.surface_mut() {
*surf = px.clone();
}
if let (Some(m), Some(ms)) = (&mut l.mask, mask) {
m.surface = ms.clone();
}
}
Ok(())
})?;
let model = match al.layout {
Layout::Reposition => "reposition",
Layout::Collage => "collage",
Layout::Perspective | Layout::Auto => "perspective",
Layout::Cylindrical => "cylindrical",
Layout::Spherical => "spherical",
};
let aligned: Vec<Value> = moves
.iter()
.filter_map(|&i| {
let pl = al.placements[i].as_ref()?;
Some(json!({"layer": ids[i].0, "model": model, "matches": inliers[i], "matrix": plane_matrix(pl, (0.0, 0.0)).map(|h| h.0.to_vec())}))
})
.collect();
Ok(
json!({"reference": reference.0, "layout": al.layout.name(), "registration": used, "focal": al.focal, "rms": al.rms, "aligned": aligned, "failed": failed}),
)
}
/// The most memory Auto-Blend's working buffers may take past the canvas: a bound independent
/// of the layers' own bounding boxes, which a sparse layer (a dot at each far corner) can make
/// arbitrarily large, and of how many layers are blended.
const MAX_BLEND_BYTES: u64 = 4 << 30;
/// What Auto-Blend's buffers hold per work-area pixel, as f32: each layer's pixels, luminance,
/// alpha and mask weight, plus the merged picture and the depth map.
fn blend_bytes_per_pixel(layers: u64, channels: u64) -> u64 {
layers.saturating_mul(channels.saturating_add(3)).saturating_add(channels.saturating_mul(2)).saturating_mul(4)
}
/// Auto-Blend's buffers, for allocation errors.
const BLEND_BUFFERS: &str = "Auto-Blend Layers";
fn auto_blend(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "edit.autoBlendLayers";
let ids = selected_pixel_layers(s);
if ids.len() < 2 {
return Err(bad(cmd, "select two or more pixel layers"));
}
let method = p.get("method").and_then(Value::as_str).unwrap_or("panorama");
if !matches!(method, "panorama" | "stack") {
return Err(bad(cmd, "`method` must be panorama|stack"));
}
let seamless = p.get("seamlessTones").and_then(Value::as_bool).unwrap_or(true);
let halo = match p.get("haloControl") {
None | Some(Value::Null) => pyramid::StackFusion::default().halo,
Some(v) => match v.as_f64() {
Some(h) if (0.0..=f64::from(pyramid::HALO_MAX)).contains(&h) => h as f32,
_ => return Err(bad(cmd, format!("`haloControl` must be a number from 0 to {}", pyramid::HALO_MAX))),
},
};
let want_depth = p.get("depthMap").and_then(Value::as_bool).unwrap_or(false);
if want_depth && method != "stack" {
return Err(bad(cmd, "`depthMap` needs `method`: stack"));
}
let st = s.active().ok_or(EngineError::NoDocument)?;
let doc: std::sync::Arc<Document> = st.doc.clone();
let mut union = Rect::EMPTY;
let mut content_px = 0u64;
let px_of = |r: &Rect| u64::from(r.width()) * u64::from(r.height());
for id in &ids {
if let Some(b) = doc.layer(*id).and_then(|l| l.surface()).map(|s| s.content_bounds()) {
union = union.union(&b);
content_px = content_px.saturating_add(px_of(&b));
}
}
// The whole layers, past the canvas too (#5745): Auto-Align leaves a panorama's layers
// partly off the canvas, and Photoshop masks and blends all of each layer, so Reveal All
// shows the whole panorama. Layers so far apart that their bounds are mostly empty, or
// whose buffers would exceed `MAX_BLEND_BYTES`, keep to the canvas.
let canvas = doc.bounds();
let channels = ids.iter().filter_map(|id| doc.layer(*id).and_then(|l| l.surface()).map(|s| s.channels() as u64)).max().unwrap_or(4);
let per_px = blend_bytes_per_pixel(ids.len() as u64, channels);
let fits = px_of(&union).saturating_mul(per_px) <= MAX_BLEND_BYTES && px_of(&union) <= content_px.max(px_of(&canvas)).saturating_mul(2);
let area = if fits { union } else { union.intersect(&canvas) };
if area.is_empty() {
return Err(EngineError::Other("the selected layers are empty".into()));
}
let (w, h) = (area.width() as usize, area.height() as usize);
let mut imgs: Vec<Vec<f32>> = Vec::new();
let mut luma: Vec<Vec<f32>> = Vec::new();
let mut alpha: Vec<Vec<f32>> = Vec::new();
let mut fmt = None;
for id in &ids {
let l = doc.layer(*id).ok_or(EngineError::NoLayer(*id))?;
let surf = l.surface().ok_or(EngineError::NoLayer(*id))?;
let (px, f) = (crate::allocation::read_region(surf, area, BLEND_BUFFERS)?, surf.format());
let n = f.channels();
let (mut lv, mut av) = (crate::allocation::filled(w * h, 0.0f32, BLEND_BUFFERS)?, crate::allocation::filled(w * h, 0.0f32, BLEND_BUFFERS)?);
for i in 0..w * h {
let c = to_rgba(&f, &px[i * n..(i + 1) * n]);
lv[i] = 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2];
av[i] = c[3];
}
if fmt.is_some_and(|g: photocraft_color::PixelFormat| g != f) {
return Err(EngineError::Other("the layers have different pixel formats".into()));
}
fmt = Some(f);
imgs.push(px);
luma.push(lv);
alpha.push(av);
}
let fmt = fmt.ok_or(EngineError::NoDocument)?;
let (weights, blended) = if method == "stack" {
let weights = pyramid::stack_weights(w, h, &luma, &alpha);
let blended = seamless.then(|| {
let refs: Vec<&[f32]> = imgs.iter().map(Vec::as_slice).collect();
let p = pyramid::StackFusion { halo, ..Default::default() };
pyramid::fuse_stack(w, h, fmt.channels(), &refs, &luma, &alpha, p)
});
(weights, blended)
} else {
// Panorama: seams routed through agreement, multi-band blended (photo_cmds).
let surfs: Vec<Surface> =
ids.iter().map(|id| doc.layer(*id).and_then(|l| l.surface()).cloned().ok_or(EngineError::NoLayer(*id))).collect::<Result<_>>()?;
let order = seam_order(&surfs, 0);
// Seamless Tones and Colors also evens out exposure and colour between the layers (gain
// compensation over their overlaps), as in Photomerge.
let b = seam_blend(&surfs, area, &order, seamless, seamless, None);
let weights: Vec<Vec<f32>> = b.layers.iter().map(|(_, m)| m.read_region(area)).collect();
let blended = seamless.then(|| b.composite.read_region(area));
(weights, blended)
};
// The depth map: the layer each pixel is sharpest in, from the focus volume guided by the
// merged picture (or, without one, by the per-pixel pick), as a hidden gray layer, black =
// the bottom layer, white = the top, ready for Lens Blur's depth map once on a mask.
let depth_px = want_depth.then(|| {
let n = fmt.channels();
let guide: Vec<f32> = match &blended {
Some(px) => (0..w * h)
.map(|i| {
let c = to_rgba(&fmt, &px[i * n..(i + 1) * n]);
0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]
})
.collect(),
None => (0..w * h).map(|i| luma.iter().zip(&weights).map(|(l, wt)| l[i] * wt[i]).sum()).collect(),
};
let refs: Vec<&[f32]> = luma.iter().map(Vec::as_slice).collect();
let dm = photocraft_algo::dff::depth_from_focus(&refs, &guide, w, h, &photocraft_algo::dff::DepthParams::default());
let scale = 1.0 / (ids.len() - 1).max(1) as f32;
let mut out = vec![0.0f32; w * h * n];
for (i, d) in dm.depth.iter().enumerate() {
let g = (d * scale).clamp(0.0, 1.0);
photocraft_raster::from_rgba_into(&fmt, [g, g, g, 1.0], &mut out[i * n..(i + 1) * n]);
}
out
});
let top = *ids.last().ok_or_else(|| EngineError::Other("Auto-Blend needs two or more layers".into()))?;
let label = "Auto-Blend Layers";
let (new_layer, depth_layer) = s.edit(label, |doc, active| {
for (id, wt) in ids.iter().zip(&weights) {
// The Background can't have a mask: it becomes a normal layer first, as in Photoshop.
crate::extra_cmds::background_to_layer_for_mask(doc, *id);
let l = doc.layer_mut(*id).ok_or(EngineError::NoLayer(*id))?;
let mut mask = LayerMask::hide_all();
mask.surface.write_region(area, wt);
mask.surface.prune();
l.mask = Some(mask);
}
let mut below = top;
let depth_id = depth_px.as_ref().map(|px| {
let mut l = Layer::raster(doc.next_layer_name("Depth"), fmt);
l.visible = false;
if let Some(surf) = l.surface_mut() {
surf.write_region(area, px);
surf.prune();
}
below = doc.insert_above(Some(below), l);
below
});
let Some(px) = &blended else {
if depth_id.is_some() {
*active = depth_id;
}
return Ok((None, depth_id));
};
let mut l = Layer::raster(doc.next_layer_name("Blended"), fmt);
if let Some(surf) = l.surface_mut() {
surf.write_region(area, px);
surf.prune();
}
let nid = doc.insert_above(Some(below), l);
*active = Some(nid);
Ok((Some(nid), depth_id))
})?;
Ok(json!({"method": method, "layers": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "blended": new_layer.map(|l| l.0), "depth": depth_layer.map(|l| l.0)}))
}
macro_rules! spec {
($id:literal, $label:literal, $params:literal, $run:expr) => {
CommandSpec { id: $id, label: $label, menu: &["Edit"], shortcut: None, params: $params, enabled: two_layers, run: $run, journal: true }
};
}
pub fn specs() -> Vec<CommandSpec> {
vec![
spec!(
"edit.autoAlignLayers",
"Auto-Align Layers…",
r##"{"projection":"auto|perspective|cylindrical|spherical|collage|reposition","reference":layer id?=bottom selected layer,"geometricCorrection":bool=false,"interpolation":"bicubic|bilinear|nearest","registration":"auto|features|intensity"}"##,
auto_align
),
spec!(
"edit.autoBlendLayers",
"Auto-Blend Layers…",
r##"{"method":"panorama|stack","seamlessTones":bool=true,"haloControl":0..8=2,"depthMap":bool=false}"##,
auto_blend
),
]
}
#[cfg(test)]
mod tests {
use super::*;
/// Deterministic texture written into a layer at offset (dx, dy).
fn texture_layer(s: &mut Session, name: &str, dx: i32, dy: i32) -> LayerId {
s.execute("layer.new.layer", json!({"name": name})).unwrap();
s.edit("tex", |doc, active| {
let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap();
let mut seed = 99u64;
let mut rnd = || {
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
(seed >> 33) as f64 / (1u64 << 31) as f64
};
surf.fill_rect(Rect::new(dx, dy, dx + 240, dy + 180), &[0.4, 0.4, 0.4, 1.0]);
for _ in 0..120 {
let (x, y) = ((rnd() * 220.0) as i32, (rnd() * 160.0) as i32);
let (sw, sh) = (4 + (rnd() * 14.0) as i32, 4 + (rnd() * 10.0) as i32);
let v = rnd() as f32;
surf.fill_rect(Rect::new(dx + x, dy + y, dx + x + sw, dy + y + sh), &[v, 1.0 - v, v * 0.5, 1.0]);
}
Ok(())
})
.unwrap();
s.active().unwrap().active_layer.unwrap()
}
fn select(s: &mut Session, ids: &[LayerId]) {
s.execute("layer.select", json!({"layer": ids[0].0})).unwrap();
for id in &ids[1..] {
s.execute("layer.select", json!({"layer": id.0, "mode": "add"})).unwrap();
}
}
#[test]
fn auto_align_repositions_at_all_depths() {
for depth in [8, 16, 32] {
let mut s = Session::new();
s.execute("file.new", json!({"width": 300, "height": 240, "depth": depth, "background": "transparent"})).unwrap();
let a = texture_layer(&mut s, "A", 20, 20);
let b = texture_layer(&mut s, "B", 31, 13);
assert!(!s.is_enabled("edit.autoAlignLayers"), "one layer selected");
select(&mut s, &[a, b]);
assert!(s.is_enabled("edit.autoAlignLayers"));
let r = s.execute("edit.autoAlignLayers", json!({"projection": "reposition"})).unwrap();
assert_eq!(r["reference"], a.0);
let bb = s.active().unwrap().doc.layer(b).unwrap().surface().unwrap().content_bounds();
assert!((bb.x0 - 20).abs() <= 1 && (bb.y0 - 20).abs() <= 1, "depth {depth}: {bb:?} {r}");
s.undo();
let bb = s.active().unwrap().doc.layer(b).unwrap().surface().unwrap().content_bounds();
assert_eq!((bb.x0, bb.y0), (31, 13));
}
}
#[test]
fn auto_align_auto_projection_and_errors() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 300, "height": 240, "background": "transparent"})).unwrap();
let a = texture_layer(&mut s, "A", 20, 20);
let b = texture_layer(&mut s, "B", 26, 25);
select(&mut s, &[a, b]);
let r = s.execute("edit.autoAlignLayers", json!({})).unwrap();
let m = r["aligned"][0]["matrix"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect::<Vec<_>>();
assert!((m[2] + 6.0).abs() < 1.0 && (m[5] + 5.0).abs() < 1.0, "{r}");
assert!(s.execute("edit.autoAlignLayers", json!({"projection": "sideways"})).is_err());
assert!(s.execute("edit.autoAlignLayers", json!({"reference": u64::MAX})).is_err());
}
/// The moving layer's mask is warped as a mask: one grey channel whose outside keeps its
/// default (reveal-all stays revealed, hide-all stays hidden), for planar and mesh warps.
#[test]
fn auto_align_warps_layer_masks_keeping_their_default() {
for (projection, reveal) in [("reposition", true), ("reposition", false), ("cylindrical", true)] {
let mut s = Session::new();
s.execute("file.new", json!({"width": 300, "height": 240, "background": "transparent"})).unwrap();
let a = texture_layer(&mut s, "A", 20, 20);
let b = texture_layer(&mut s, "B", 31, 13);
let (inside, outside) = if reveal { (0.0, 1.0) } else { (1.0, 0.0) };
s.edit("mask", |doc, _| {
let mut m = if reveal { LayerMask::reveal_all() } else { LayerMask::hide_all() };
m.surface.fill_rect(Rect::new(100, 100, 110, 110), &[inside]);
doc.layer_mut(b).unwrap().mask = Some(m);
Ok(())
})
.unwrap();
select(&mut s, &[a, b]);
let r = s.execute("edit.autoAlignLayers", json!({"projection": projection})).unwrap();
let doc = &s.active().unwrap().doc;
let m = doc.layer(b).unwrap().mask.as_ref().unwrap();
assert_eq!(m.surface.format().channels(), 1, "{projection}: {r}");
assert_eq!(m.value(250, 200), outside, "{projection} reveal={reveal}: {r}");
assert_eq!(m.value(5, 5), outside, "{projection} reveal={reveal}: {r}");
if projection == "reposition" {
// The square moved with the layer (by about (-11, +7)).
assert!((m.value(94, 112) - inside).abs() < 0.01, "{r}");
}
}
}
#[test]
fn auto_blend_panorama_masks_and_seamless_layer() {
for depth in [8, 16, 32] {
let mut s = Session::new();
s.execute("file.new", json!({"width": 200, "height": 60, "depth": depth, "background": "transparent"})).unwrap();
s.execute("layer.new.layer", json!({"name": "left"})).unwrap();
s.edit("l", |doc, active| {
doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(0, 0, 130, 60), &[0.2, 0.3, 0.8, 1.0]);
Ok(())
})
.unwrap();
let left = s.active().unwrap().active_layer.unwrap();
s.execute("layer.new.layer", json!({"name": "right"})).unwrap();
s.edit("r", |doc, active| {
doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(70, 0, 200, 60), &[0.9, 0.6, 0.2, 1.0]);
Ok(())
})
.unwrap();
let right = s.active().unwrap().active_layer.unwrap();
select(&mut s, &[left, right]);
let r = s.execute("edit.autoBlendLayers", json!({"method": "panorama"})).unwrap();
let doc = &s.active().unwrap().doc;
let ml = doc.layer(left).unwrap().mask.as_ref().unwrap();
let mr = doc.layer(right).unwrap().mask.as_ref().unwrap();
assert_eq!((ml.value(10, 30), mr.value(10, 30)), (1.0, 0.0), "depth {depth}");
assert_eq!((ml.value(190, 30), mr.value(190, 30)), (0.0, 1.0));
let blended = LayerId(r["blended"].as_u64().unwrap());
let px = doc.layer(blended).unwrap().surface().unwrap().pixel(5, 30);
assert!((px[2] - 0.8).abs() < 0.02, "{px:?}");
s.undo();
assert!(s.active().unwrap().doc.layer(left).unwrap().mask.is_none());
}
}
/// #5745: the Background can't take a mask, so Auto-Blend turns it into a normal layer first.
#[test]
fn auto_blend_turns_the_background_into_a_layer() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 200, "height": 60, "background": "white"})).unwrap();
let bg = s.active().unwrap().doc.background().unwrap().id;
s.execute("layer.new.layer", json!({"name": "right"})).unwrap();
s.edit("r", |doc, active| {
doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(70, 0, 200, 60), &[0.9, 0.6, 0.2, 1.0]);
Ok(())
})
.unwrap();
let right = s.active().unwrap().active_layer.unwrap();
select(&mut s, &[bg, right]);
s.execute("edit.autoBlendLayers", json!({"method": "panorama"})).unwrap();
let doc = &s.active().unwrap().doc;
assert!(!doc.is_background(bg), "the Background became a layer to take its mask");
assert!(doc.layer(bg).unwrap().mask.is_some());
s.undo();
assert!(s.active().unwrap().doc.is_background(bg), "undo restores the Background");
}
/// Seamless Tones and Colors evens out a 10% exposure step between two panorama layers.
#[test]
fn auto_blend_panorama_seamless_matches_exposure() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 200, "height": 60, "background": "transparent"})).unwrap();
let mut ids = Vec::new();
for (name, x0, x1, gain) in [("left", 0, 130, 1.0f32), ("right", 70, 200, 1.1)] {
s.execute("layer.new.layer", json!({ "name": name })).unwrap();
s.edit(name, |doc, active| {
doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(x0, 0, x1, 60), &[0.2 * gain, 0.4 * gain, 0.5 * gain, 1.0]);
Ok(())
})
.unwrap();
ids.push(s.active().unwrap().active_layer.unwrap());
}
select(&mut s, &ids);
let r = s.execute("edit.autoBlendLayers", json!({"method": "panorama"})).unwrap();
let doc = &s.active().unwrap().doc;
let blended = doc.layer(LayerId(r["blended"].as_u64().unwrap())).unwrap().surface().unwrap();
let (a, b) = (blended.pixel(10, 30), blended.pixel(190, 30));
// 0.5 vs 0.55 without gain compensation.
assert!((a[2] - b[2]).abs() < 0.015, "{a:?} {b:?}");
}
/// #5745: the top layer hangs off the canvas's right edge, so cropped to the canvas it lies
/// wholly over the bottom one. It used to get an all-black mask; now it shows on its side.
#[test]
fn auto_blend_panorama_of_a_layer_the_canvas_crops_to_all_overlap() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 200, "height": 60, "background": "transparent"})).unwrap();
s.execute("layer.new.layer", json!({"name": "left"})).unwrap();
s.edit("l", |doc, active| {
doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(0, 0, 200, 60), &[0.2, 0.4, 0.5, 1.0]);
Ok(())
})
.unwrap();
let left = s.active().unwrap().active_layer.unwrap();
s.execute("layer.new.layer", json!({"name": "right"})).unwrap();
s.edit("r", |doc, active| {
doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(120, 0, 320, 60), &[0.2, 0.4, 0.5, 1.0]);
Ok(())
})
.unwrap();
let right = s.active().unwrap().active_layer.unwrap();
select(&mut s, &[left, right]);
s.execute("edit.autoBlendLayers", json!({"method": "panorama", "seamlessTones": false})).unwrap();
let doc = &s.active().unwrap().doc;
let ml = doc.layer(left).unwrap().mask.as_ref().unwrap();
let mr = doc.layer(right).unwrap().mask.as_ref().unwrap();
assert_eq!((ml.value(10, 30), mr.value(10, 30)), (1.0, 0.0));
assert_eq!((ml.value(195, 30), mr.value(195, 30)), (0.0, 1.0));
for x in 0..200 {
assert_eq!(ml.value(x, 30) + mr.value(x, 30), 1.0, "x {x} shows one layer");
}
}
/// #5745: a layer Auto-Align left partly off the canvas is masked and blended over all of
/// its bounds, not cut off at the canvas edge.
#[test]
fn auto_blend_panorama_masks_past_the_canvas() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 200, "height": 60, "background": "transparent"})).unwrap();
s.execute("layer.new.layer", json!({"name": "left"})).unwrap();
s.edit("l", |doc, active| {
doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(0, 0, 150, 60), &[0.2, 0.4, 0.5, 1.0]);
Ok(())
})
.unwrap();
let left = s.active().unwrap().active_layer.unwrap();
s.execute("layer.new.layer", json!({"name": "right"})).unwrap();
s.edit("r", |doc, active| {
doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(120, 0, 320, 60), &[0.2, 0.4, 0.5, 1.0]);
Ok(())
})
.unwrap();
let right = s.active().unwrap().active_layer.unwrap();
select(&mut s, &[left, right]);
let r = s.execute("edit.autoBlendLayers", json!({"method": "panorama"})).unwrap();
let doc = &s.active().unwrap().doc;
let ml = doc.layer(left).unwrap().mask.as_ref().unwrap();
let mr = doc.layer(right).unwrap().mask.as_ref().unwrap();
assert_eq!((ml.value(10, 30), mr.value(10, 30)), (1.0, 0.0));
assert_eq!((ml.value(300, 30), mr.value(300, 30)), (0.0, 1.0), "off the canvas");
let blended = doc.layer(LayerId(r["blended"].as_u64().unwrap())).unwrap().surface().unwrap();
assert_eq!(blended.pixel(300, 30)[3], 1.0, "the merged layer covers the off-canvas part");
}
/// A sparse layer whose bounds are huge (a dot at each far corner) raises its own content
/// estimate, so the extent past the canvas is capped independently: it keeps to the canvas.
#[test]
fn auto_blend_sparse_huge_bounds_keep_to_the_canvas() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 200, "height": 200, "background": "transparent"})).unwrap();
s.execute("layer.new.layer", json!({"name": "a"})).unwrap();
s.edit("a", |doc, active| {
let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap();
surf.fill_rect(Rect::new(0, 0, 150, 200), &[0.2, 0.4, 0.5, 1.0]);
surf.fill_rect(Rect::new(100_000, 100_000, 100_001, 100_001), &[0.2, 0.4, 0.5, 1.0]);
Ok(())
})
.unwrap();
let a = s.active().unwrap().active_layer.unwrap();
s.execute("layer.new.layer", json!({"name": "b"})).unwrap();
s.edit("b", |doc, active| {
doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(50, 0, 200, 200), &[0.2, 0.4, 0.5, 1.0]);
Ok(())
})
.unwrap();
let b = s.active().unwrap().active_layer.unwrap();
select(&mut s, &[a, b]);
let r = s.execute("edit.autoBlendLayers", json!({"method": "panorama"})).unwrap();
let doc = &s.active().unwrap().doc;
let bb = doc.layer(LayerId(r["blended"].as_u64().unwrap())).unwrap().surface().unwrap().content_bounds();
assert!(bb.x1 <= 200 && bb.y1 <= 200, "{bb:?}");
}
/// The budget past the canvas counts every layer's buffers, not just the work-area pixels.
#[test]
fn auto_blend_budget_counts_layers_and_channels() {
// Two RGBA layers: 4 bytes × (2 layers × (4 channels + luma, alpha, weight) + merged + depth).
assert_eq!(blend_bytes_per_pixel(2, 4), 88);
assert!(blend_bytes_per_pixel(5, 4) > blend_bytes_per_pixel(2, 4));
// A 2^27-pixel area would need about 11 GiB for just two RGBA layers: it keeps to the canvas.
assert!((1u64 << 27).saturating_mul(blend_bytes_per_pixel(2, 4)) > MAX_BLEND_BYTES);
assert_eq!(blend_bytes_per_pixel(u64::MAX, 4), u64::MAX, "saturates instead of overflowing");
}
#[test]
fn auto_blend_stack_without_seamless() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 64, "height": 64, "background": "transparent"})).unwrap();
s.execute("layer.new.layer", json!({"name": "sharp"})).unwrap();
s.edit("checker", |doc, active| {
let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap();
for y in (0..64).step_by(2) {
for x in (0..64).step_by(2) {
let v = if (x / 2 + y / 2) % 2 == 0 { 0.1 } else { 0.9 };
surf.fill_rect(Rect::new(x, y, x + 2, y + 2), &[v, v, v, 1.0]);
}
}
Ok(())
})
.unwrap();
let a = s.active().unwrap().active_layer.unwrap();
s.execute("layer.new.layer", json!({"name": "flat"})).unwrap();
s.edit("flat", |doc, active| {
doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(0, 0, 64, 64), &[0.5, 0.5, 0.5, 1.0]);
Ok(())
})
.unwrap();
let flat = s.active().unwrap().active_layer.unwrap();
select(&mut s, &[a, flat]);
let r = s.execute("edit.autoBlendLayers", json!({"method": "stack", "seamlessTones": false})).unwrap();
assert!(r["blended"].is_null());
let doc = &s.active().unwrap().doc;
// The detailed layer is sharper everywhere.
let shown = (0..64).filter(|x| doc.layer(a).unwrap().mask.as_ref().unwrap().value(*x, 32) > 0.5).count();
assert_eq!(shown, 64);
assert_eq!(doc.layer(flat).unwrap().mask.as_ref().unwrap().value(10, 10), 0.0);
assert!(s.execute("edit.autoBlendLayers", json!({"method": "sideways"})).is_err());
}
/// A smooth layer (soft blobs on a gradient) and the same picture a few pixels away: the
/// intensity registration finds the shift to a fraction of a pixel at every motion model.
fn smooth_layer(s: &mut Session, name: &str, dx: i32, dy: i32) -> LayerId {
s.execute("layer.new.layer", json!({"name": name})).unwrap();
s.edit("smooth", |doc, active| {
let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap();
for y in 0..150 {
for x in 0..200 {
let (u, v) = ((x - dx) as f32, (y - dy) as f32);
let blob = (-((u - 90.0).powi(2) + (v - 70.0).powi(2)) / (2.0 * 25.0f32.powi(2))).exp();
let blob2 = (-((u - 150.0).powi(2) + (v - 40.0).powi(2)) / (2.0 * 15.0f32.powi(2))).exp();
let g = 0.2 + 0.5 * blob + 0.25 * blob2 + 0.001 * u;
surf.fill_rect(Rect::new(x, y, x + 1, y + 1), &[g, g * 0.8, g * 0.6, 1.0]);
}
}
Ok(())
})
.unwrap();
s.active().unwrap().active_layer.unwrap()
}
#[test]
fn auto_align_registers_a_smooth_bracket_by_intensity() {
for depth in [8, 16, 32] {
let mut s = Session::new();
s.execute("file.new", json!({"width": 200, "height": 150, "depth": depth, "background": "transparent"})).unwrap();
let a = smooth_layer(&mut s, "A", 0, 0);
let b = smooth_layer(&mut s, "B", 4, 3);
select(&mut s, &[a, b]);
assert!(s.execute("edit.autoAlignLayers", json!({"registration": "sideways"})).is_err());
assert!(s.execute("edit.autoAlignLayers", json!({"projection": "cylindrical", "registration": "intensity"})).is_err());
for (params, model) in [
(json!({"projection": "reposition", "registration": "intensity"}), "reposition"),
(json!({"registration": "intensity"}), "collage"),
(json!({"projection": "perspective", "registration": "intensity"}), "perspective"),
] {
let r = s.execute("edit.autoAlignLayers", params).unwrap();
assert_eq!(r["registration"], "intensity", "depth {depth}: {r}");
assert_eq!(r["aligned"][0]["model"], model);
let m = r["aligned"][0]["matrix"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect::<Vec<_>>();
assert!((m[2] + 4.0).abs() < 0.25 && (m[5] + 3.0).abs() < 0.25, "depth {depth} {model}: {r}");
// B now shows A's picture.
let doc = &s.active().unwrap().doc;
let (pa, pb) = (doc.layer(a).unwrap().surface().unwrap().pixel(90, 70), doc.layer(b).unwrap().surface().unwrap().pixel(90, 70));
assert!((pa[0] - pb[0]).abs() < 0.02, "depth {depth} {model}: {pa:?} vs {pb:?}");
s.undo();
}
}
}
/// Layers with nothing in common are no fit: the intensity registration says so instead of
/// moving them by whatever pose its search box ends at.
#[test]
fn auto_align_rejects_unrelated_layers_instead_of_guessing_a_pose() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 200, "height": 150, "depth": 8, "background": "transparent"})).unwrap();
let a = smooth_layer(&mut s, "A", 0, 0);
s.execute("layer.new.layer", json!({"name": "stripes"})).unwrap();
s.edit("stripes", |doc, active| {
let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap();
for y in 0..150 {
for x in 0..200 {
let g = if (x / 7 + y / 11) % 2 == 0 { 0.9 } else { 0.1 };
surf.fill_rect(Rect::new(x, y, x + 1, y + 1), &[g, g, g, 1.0]);
}
}
Ok(())
})
.unwrap();
let b = s.active().unwrap().active_layer.unwrap();
select(&mut s, &[a, b]);
for registration in ["intensity", "auto"] {
let err = s.execute("edit.autoAlignLayers", json!({"registration": registration})).unwrap_err().to_string();
assert_eq!(err, NO_DETAIL, "{registration}");
}
// A layer that is mostly empty overlaps too little to be placed.
let c = smooth_layer(&mut s, "C", 2, 1);
s.edit("hole", |doc, _| {
let surf = doc.layer_mut(c).unwrap().surface_mut().unwrap();
surf.fill_rect(Rect::new(0, 0, 200, 120), &[0.0, 0.0, 0.0, 0.0]);
Ok(())
})
.unwrap();
select(&mut s, &[a, c]);
assert_eq!(s.execute("edit.autoAlignLayers", json!({"registration": "intensity"})).unwrap_err().to_string(), NO_DETAIL);
}
/// Two layers, each sharp on one half: the merged layer takes each half from the sharp layer.
#[test]
fn auto_blend_stack_fuses_the_sharp_halves() {
let checker = |x: i32, y: i32| if (x / 3 + y / 3) % 2 == 0 { 0.2 } else { 0.8 };
for depth in [8, 16, 32] {
let mut s = Session::new();
s.execute("file.new", json!({"width": 96, "height": 64, "depth": depth, "background": "transparent"})).unwrap();
let mut ids = Vec::new();
for (name, sharp_left) in [("left", true), ("right", false)] {
s.execute("layer.new.layer", json!({"name": name})).unwrap();
s.edit(name, |doc, active| {
let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap();
for y in 0..64 {
for x in 0..96 {
// The sharp half is the checker, the other half its local mean (flat gray).
let v = if (x < 48) == sharp_left { checker(x, y) } else { 0.5 };
surf.fill_rect(Rect::new(x, y, x + 1, y + 1), &[v, v, v, 1.0]);
}
}
Ok(())
})
.unwrap();
ids.push(s.active().unwrap().active_layer.unwrap());
}
select(&mut s, &ids);
for halo in [0.0, 2.0] {
let r = s.execute("edit.autoBlendLayers", json!({"method": "stack", "haloControl": halo})).unwrap();
let doc = &s.active().unwrap().doc;
let blended = doc.layer(LayerId(r["blended"].as_u64().unwrap())).unwrap().surface().unwrap();
for (x, y) in [(10, 20), (20, 40), (76, 20), (85, 40)] {
let px = blended.pixel(x, y);
assert!((px[0] - checker(x, y)).abs() < 0.08, "depth {depth} halo {halo} ({x},{y}): {px:?}");
assert!((px[3] - 1.0).abs() < 1e-3, "{px:?}");
}
// The masks still pick per pixel.
assert_eq!(doc.layer(ids[0]).unwrap().mask.as_ref().unwrap().value(10, 20), 1.0);
assert_eq!(doc.layer(ids[1]).unwrap().mask.as_ref().unwrap().value(85, 40), 1.0);
s.undo();
assert!(s.active().unwrap().doc.layer(ids[0]).unwrap().mask.is_none());
}
for bad in [json!(-1), json!(9), json!("hard")] {
assert!(s.execute("edit.autoBlendLayers", json!({"method": "stack", "haloControl": bad})).is_err(), "{bad}");
}
}
}
/// Three layers, each sharp in one third: the Depth layer says which, black to white from
/// the bottom layer to the top, hidden, and undone with the rest.
#[test]
fn auto_blend_stack_depth_map() {
for depth in [8, 16, 32] {
let mut s = Session::new();
s.execute("file.new", json!({"width": 96, "height": 48, "depth": depth, "background": "transparent"})).unwrap();
let mut ids = Vec::new();
for k in 0..3 {
s.execute("layer.new.layer", json!({"name": format!("L{k}")})).unwrap();
s.edit("third", |doc, active| {
let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap();
for y in 0..48 {
for x in 0..96 {
let v = if x / 32 == k && ((x / 2) + (y / 2)) % 2 == 0 {
0.2
} else if x / 32 == k {
0.8
} else {
0.5
};
surf.fill_rect(Rect::new(x, y, x + 1, y + 1), &[v, v, v, 1.0]);
}
}
Ok(())
})
.unwrap();
ids.push(s.active().unwrap().active_layer.unwrap());
}
select(&mut s, &ids);
assert!(s.execute("edit.autoBlendLayers", json!({"method": "panorama", "depthMap": true})).is_err());
for seamless in [false, true] {
let r = s.execute("edit.autoBlendLayers", json!({"method": "stack", "seamlessTones": seamless, "depthMap": true})).unwrap();
let doc = &s.active().unwrap().doc;
let d = doc.layer(LayerId(r["depth"].as_u64().unwrap())).unwrap();
assert!(!d.visible && d.name.starts_with("Depth"), "depth {depth}: {} visible {} {r}", d.name, d.visible);
let surf = d.surface().unwrap();
for (x, want) in [(8, 0.0), (48, 0.5), (88, 1.0)] {
let px = surf.pixel(x, 24);
assert!((px[0] - want).abs() < 0.2 && (px[3] - 1.0).abs() < 1e-3, "depth {depth} seamless {seamless} x {x}: {px:?}");
}
assert_eq!(r["blended"].is_null(), !seamless);
if seamless {
// The merged layer sits above the depth layer and is the active one.
assert_eq!(s.active().unwrap().active_layer, Some(LayerId(r["blended"].as_u64().unwrap())));
}
s.undo();
assert!(s.active().unwrap().doc.layer(LayerId(r["depth"].as_u64().unwrap())).is_none());
}
}
}
}