mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-27 11:37:13 -06:00
# 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
229 lines
13 KiB
Rust
229 lines
13 KiB
Rust
//! 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");
|
||
}
|