mirror of
https://github.com/storytold/lightcraft.git
synced 2026-11-02 06:27:53 -06:00
Photo Merge decoded each raw itself and wrote the reference frame's own colour tags into the merged DNG. A CR2 (and NEF, ARW and the other raws whose colour is fitted from the camera JPEG) has none, so an HDR merge of CR2s rendered with no camera colour: green and about 16 L* dark. `lightcraft_merge::load_frame_with` takes the raw's colour tags from the caller, and the engine passes `starting_colour_tags`: the colour the raw opens with (`starting_colour`) as DNG tags. That is one forward matrix with a colour matrix and AsShotNeutral that agree with it at the as-shot white, the look's hue/sat table as ProfileHueSatMap and its tone curve as ProfileToneCurve. Raws with their own matrices (DNG) keep their tags. The look's chroma curve has no DNG tag and is left out. On two CC0 CR2s (EOS 550D, 40D) an HDR merge of three copies against the photo alone: mean CIE76 dE 26.15 -> 1.07 and 22.89 -> 0.47. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
120 lines
6.2 KiB
Rust
120 lines
6.2 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);
|
||
}
|
||
|
||
/// 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:?}");
|
||
// too few photos
|
||
assert!(s.execute("merge.panorama", &json!({"ids": [ids[0]]})).is_err());
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|