mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-26 02:47:07 -06:00
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.
848 lines
44 KiB
Rust
848 lines
44 KiB
Rust
//! 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());
|
||
}
|
||
}
|
||
}
|
||
}
|