Files
lightcraft/crates/engine/src/tests_merge.rs
T
Brandon Thomas 2fd769963e Merge PR #1549
# Conflicts:
#	crates/engine/src/tests_merge.rs
#	crates/ui-egui/locales/de-formats.json
#	crates/ui-egui/locales/es-formats.json
#	crates/ui-egui/locales/fr-formats.json
#	crates/ui-egui/locales/ja-formats.json
#	crates/ui-egui/locales/pt-br-formats.json
#	crates/ui-egui/locales/ru-formats.json
#	crates/ui-egui/locales/uk-formats.json
#	crates/ui-egui/locales/zh-hans-formats.json
#	crates/ui-egui/locales/zh-hant-formats.json
#	docs/merge.md
#	docs/parity-checklist.md
#	docs/whats-new.md
2026-10-14 22:33:16 -04:00

229 lines
13 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.
//! Photo Merge through the command layer: synthetic brackets / panorama views on disk → merged
//! DNG next to the sources → imported, selected, Auto Settings / auto crop applied.
use serde_json::json;
use crate::Session;
fn temp_dir(tag: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("lc-merge-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
fn import(s: &mut Session, paths: Vec<String>) -> Vec<u64> {
let r = s.execute("library.import", &json!({"paths": paths})).unwrap();
r["imported"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect()
}
#[test]
fn hdr_merge_command_creates_and_imports_a_dng() {
let dir = temp_dir("hdr");
let mut paths = Vec::new();
for (i, b) in lightcraft_merge::synth::bracket_dngs(480, 320, &[-2.0, 0.0, 2.0]).unwrap().into_iter().enumerate() {
let p = dir.join(format!("IMG_{i}.dng"));
std::fs::write(&p, b).unwrap();
paths.push(p.to_string_lossy().to_string());
}
let mut s = Session::new().with_fs();
let ids = import(&mut s, paths);
assert_eq!(ids.len(), 3);
// preview: rendered PNG, nothing added
let png = dir.join("preview.png");
let pv = s.execute("merge.hdr", &json!({"ids": ids, "deghost": "medium", "preview": true, "showOverlay": true, "previewPath": png})).unwrap();
assert!(png.exists() && pv["previewSize"][0].as_u64().unwrap() > 100, "{pv}");
assert_eq!(s.catalog.photos().count(), 3);
let ev: Vec<f64> = pv["ev"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect();
assert!((ev[0] + 2.0).abs() < 0.1 && (ev[2] - 2.0).abs() < 0.1, "{ev:?}");
// full merge
let r = s.execute("merge.hdr", &json!({"ids": ids, "autoSettings": true, "stack": true})).unwrap();
let path = r["path"].as_str().unwrap();
assert!(path.ends_with("IMG_0-HDR.dng"), "{path}");
let id = lightcraft_catalog::PhotoId(r["id"].as_u64().unwrap());
let p = s.catalog.photo(id).unwrap().clone();
assert_eq!((p.width, p.height), (480, 320));
assert_eq!(p.kind, lightcraft_catalog::MediaKind::Raw);
assert_eq!(s.selection.active, Some(id));
let d = s.develop_of(id).unwrap();
assert!(d.light.exposure != 0.0 || d.light.highlights != 0.0, "auto settings applied");
let img = s.render_now(id, 256, 256).unwrap();
assert_eq!(img.image.width, 256);
// Create Stack: the result tops a collapsed stack of its sources, so the grid shows the result
let st = s.catalog.stack_of(id).expect("merge result stacked").clone();
assert_eq!(st.top(), id);
assert_eq!(st.photos[1..].iter().map(|p| p.0).collect::<Vec<_>>(), ids);
assert!(st.collapsed);
assert_eq!(s.visible_cloned(), vec![id]);
// the second merge doesn't overwrite the first
let r2 = s.execute("merge.hdr", &json!({"ids": ids, "autoSettings": false})).unwrap();
assert!(r2["path"].as_str().unwrap().ends_with("IMG_0-HDR-2.dng"));
let id2 = lightcraft_catalog::PhotoId(r2["id"].as_u64().unwrap());
assert!(s.catalog.stack_of(id2).is_none(), "no stack without the option");
// the type filter finds merge results (and only them)
s.execute("library.filter", &json!({"merged": "hdr"})).unwrap();
let found: std::collections::HashSet<_> = s.catalog.query(&s.filter, &Default::default()).into_iter().collect();
assert_eq!(found, [id, id2].into_iter().collect());
let _ = std::fs::remove_dir_all(&dir);
}
/// An HDR merge keeps the reference frame's lens corrections. The sources carry an off-centre `WarpRectilinear` and
/// `FixVignetteRadial` for their active area and have a default crop; the merged DNG must give "Enable Profile
/// Corrections" the same correction as the reference photo, upright and for a vertical bracket (whose DNG keeps the
/// pixels unturned).
#[test]
fn hdr_merge_keeps_the_reference_frames_lens_corrections() {
use lightcraft_geom::{Orientation, Point};
use lightcraft_pipeline::optics::{embedded_vignette_gain, embedded_warp};
use lightcraft_raw::Opcode;
for orientation in [Orientation::Normal, Orientation::Rotate90] {
let dir = temp_dir(&format!("hdr-lens-{orientation:?}"));
let mut paths = Vec::new();
for (i, b) in lightcraft_merge::synth::bracket_dngs(480, 320, &[-2.0, 0.0, 2.0]).unwrap().into_iter().enumerate() {
let mut raw = lightcraft_raw::decode(&b).unwrap();
raw.crop = lightcraft_raw::Rect::new(10, 6, 452, 304);
raw.orientation = orientation;
raw.metadata.orientation = Some(orientation);
raw.opcodes.list3 = vec![
Opcode::WarpRectilinear { planes: vec![[0.96, 0.05, -0.012, 0.002, 0.001, -0.0005]], center: [0.48, 0.53] },
Opcode::FixVignetteRadial { k: [0.35, -0.05, 0.01, 0.0, 0.0], center: [0.48, 0.53] },
];
let p = dir.join(format!("IMG_{i}.dng"));
std::fs::write(&p, lightcraft_raw::write_dng(&raw, &Default::default()).unwrap()).unwrap();
paths.push(p.to_string_lossy().to_string());
}
let mut s = Session::new().with_fs();
let ids = import(&mut s, paths);
let r = s.execute("merge.hdr", &json!({"ids": ids})).unwrap();
assert_eq!(r["reference"].as_u64(), Some(ids[1]), "{r}");
let merged = s.catalog.photo(lightcraft_catalog::PhotoId(r["id"].as_u64().unwrap())).unwrap().clone();
let reference = s.catalog.photo(lightcraft_catalog::PhotoId(ids[1])).unwrap().clone();
let expect = if orientation == Orientation::Normal { (452, 304) } else { (304, 452) };
assert_eq!(((merged.width, merged.height), (reference.width, reference.height)), (expect, expect));
let a = reference.embedded_lens.expect("the source's correction");
let b = merged.embedded_lens.expect("the merged DNG carries the reference frame's correction");
let (wa, wb) = (a.warp.unwrap(), b.warp.unwrap());
let (va, vb) = (a.vignette.unwrap(), b.vignette.unwrap());
let (w, h) = (expect.0 as f64, expect.1 as f64);
for (x, y) in [(0.0, 0.0), (w, h), (w, 0.0), (0.0, h), (w * 0.3, h * 0.7), (w * 0.5, h * 0.5)] {
let p = Point::new(x, y);
for ch in 0..3 {
let (pa, pb) = (embedded_warp(&wa, p, ch, w, h), embedded_warp(&wb, p, ch, w, h));
assert!((pa.x - pb.x).abs() < 1e-6 && (pa.y - pb.y).abs() < 1e-6, "{orientation:?} {p:?}: {pa:?} vs {pb:?}");
}
let (ga, gb) = (embedded_vignette_gain(&va, p, w, h), embedded_vignette_gain(&vb, p, w, h));
assert!((ga - gb).abs() < 1e-9, "{orientation:?} {p:?}: {ga} vs {gb}");
}
let _ = std::fs::remove_dir_all(&dir);
}
}
/// Raws without colour matrices of their own (CR2, NEF, ARW…) merge into a DNG with the colour they open with,
/// not with no colour at all (issue #965: an HDR merge of CR2s rendered green and dark).
#[test]
fn a_merge_of_raws_without_colour_matrices_keeps_their_colour() {
let dir = temp_dir("hdr-colour");
let neutral = [0.5, 1.0, 0.7];
let mut paths = Vec::new();
for (i, b) in lightcraft_merge::synth::bracket_dngs(240, 160, &[-1.0, 0.0, 1.0]).unwrap().into_iter().enumerate() {
// the same pixels with a white balance but no colour matrices
let mut raw = lightcraft_raw::decode(&b).unwrap();
raw.color = lightcraft_raw::ColorData { as_shot_neutral: Some(neutral), ..Default::default() };
let p = dir.join(format!("IMG_{i}.dng"));
std::fs::write(&p, lightcraft_raw::write_dng(&raw, &Default::default()).unwrap()).unwrap();
paths.push(p.to_string_lossy().to_string());
}
let mut s = Session::new().with_fs();
let ids = import(&mut s, paths);
let r = s.execute("merge.hdr", &json!({"ids": ids, "autoSettings": false})).unwrap();
let merged = lightcraft_raw::decode(&std::fs::read(r["path"].as_str().unwrap()).unwrap()).unwrap();
assert!(lightcraft_raw::color::has_matrix(&merged.color), "{:?}", merged.color);
let got = merged.color.as_shot_neutral.expect("the sources' white balance");
assert!(got.iter().zip(neutral).all(|(a, b)| (a - b).abs() < 1e-3), "{got:?}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn panorama_command_with_auto_crop() {
let dir = temp_dir("pano");
let mut paths = Vec::new();
for (i, v) in lightcraft_merge::synth::pano_views(480, 360, 420.0, &[-25.0, 0.0, 25.0]).unwrap().into_iter().enumerate() {
let img = v.to_srgb8();
let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap();
let p = dir.join(format!("P_{i}.png"));
std::fs::write(&p, png).unwrap();
paths.push(p.to_string_lossy().to_string());
}
let mut s = Session::new().with_fs();
let ids = import(&mut s, paths);
let r = s.execute("merge.panorama", &json!({"ids": ids, "projection": "cylindrical", "autoCrop": true, "autoSettings": false})).unwrap();
assert_eq!(r["projection"], "cylindrical");
assert_eq!(r["used"].as_array().unwrap().len(), 3);
let id = lightcraft_catalog::PhotoId(r["id"].as_u64().unwrap());
let p = s.catalog.photo(id).unwrap().clone();
assert!(p.width > 800 && p.height > 300, "{}×{}", p.width, p.height);
let crop = s.develop_of(id).unwrap().crop.geometry.rect;
assert!(crop.width() < 1.0 && crop.width() * crop.height() > 0.5, "{crop:?}");
// the photos' own resolution, so nothing held it back (issue #1455)
let (mp, native) = (r["megapixels"].as_f64().unwrap(), r["nativeMegapixels"].as_f64().unwrap());
assert!((mp / native - 1.0).abs() < 0.02 && r["limit"].is_null(), "{r}");
// a size asked for says so
let small =
s.execute("merge.panorama", &json!({"ids": ids, "projection": "cylindrical", "autoSettings": false, "maxMegapixels": native / 2.0})).unwrap();
assert_eq!(small["limit"], "asked", "{small}");
assert!(small["megapixels"].as_f64().unwrap() <= native / 2.0 * 1.02, "{small}");
// too few photos
assert!(s.execute("merge.panorama", &json!({"ids": [ids[0]]})).is_err());
let _ = std::fs::remove_dir_all(&dir);
}
/// HDR for Each Bracket Set: two stacks of brackets → two merged DNGs, each next to its first photo; photos that form
/// no set are refused.
#[test]
fn hdr_batch_merges_each_stack() {
let dir = temp_dir("hdr-batch");
let mut paths = Vec::new();
// different exposures, or the import would take set B for duplicates of set A
for (set, evs) in [("A", [-2.0, 0.0, 2.0]), ("B", [-1.5, 0.5, 2.5])] {
for (i, b) in lightcraft_merge::synth::bracket_dngs(240, 160, &evs).unwrap().into_iter().enumerate() {
let p = dir.join(format!("{set}_{i}.dng"));
std::fs::write(&p, b).unwrap();
paths.push(p.to_string_lossy().to_string());
}
}
let mut s = Session::new().with_fs();
let ids = import(&mut s, paths);
assert_eq!(ids.len(), 6);
let err = s.execute("merge.hdrBatch", &json!({"ids": [ids[0]]})).unwrap_err().to_string();
assert!(err.contains("no bracket sets"), "{err}");
s.execute("stack.group", &json!({"ids": ids[..3]})).unwrap();
s.execute("stack.group", &json!({"ids": ids[3..]})).unwrap();
let r = s.execute("merge.hdrBatch", &json!({"ids": [ids[0], ids[3]], "autoSettings": false})).unwrap();
let merged = r["merged"].as_array().unwrap();
assert_eq!(merged.len(), 2, "{r}");
assert!(r["failed"].as_array().unwrap().is_empty(), "{r}");
let names: Vec<&str> = merged.iter().map(|m| m["path"].as_str().unwrap()).collect();
assert!(names[0].ends_with("A_0-HDR.dng") && names[1].ends_with("B_0-HDR.dng"), "{names:?}");
assert_eq!(s.catalog.photos().count(), 8);
let _ = std::fs::remove_dir_all(&dir);
}
/// Issue #1455: a panorama keeps its photos' resolution, held to what fits in memory beside them
/// (never below the 40 MP every panorama used to have), and never above a size asked for.
#[test]
fn panorama_size_is_held_to_memory() {
use crate::merge::{MIN_FITTED_MEGAPIXELS, fit_in_memory};
let full = lightcraft_merge::PanoOptions::default();
let gb = |g: usize| Some(g << 30);
let photos = 5.0 * 51e6; // five 51 MP photos
let mp = |o: lightcraft_merge::PanoOptions| o.max_megapixels;
assert_eq!(mp(fit_in_memory(&full, photos, gb(512))), lightcraft_merge::pano::MAX_MEGAPIXELS, "plenty of memory: the full size");
let m16 = mp(fit_in_memory(&full, photos, gb(16)));
assert!(m16 > MIN_FITTED_MEGAPIXELS && m16 < 512.0, "16 GB: {m16} MP");
assert!(mp(fit_in_memory(&full, photos, gb(32))) > m16, "more memory, more pixels");
assert_eq!(mp(fit_in_memory(&full, photos, gb(4))), MIN_FITTED_MEGAPIXELS, "never below 40 MP");
let asked = lightcraft_merge::PanoOptions { max_megapixels: 20.0, ..full.clone() };
assert_eq!(mp(fit_in_memory(&asked, photos, gb(16))), 20.0, "a size asked for is kept when it fits");
assert_eq!(fit_in_memory(&full, 1e6, None), fit_in_memory(&full, 1e6, gb(8)), "memory unknown: taken as 8 GB");
}