mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-24 10:09:01 -06:00
Merge PR #1715
# Conflicts: # docs/gaps.md # docs/parity-checklist.md # docs/target-app-parity.md
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
//! lightcraft-cli mcp [--connect [ADDR]] [--demo] [--compact] [FILES/FOLDERS…]
|
||||
//! lightcraft-cli render <in> -o <out> [--set control=value]… [--settings FILE.json] [--preset ID] [--size N] [--quality Q]
|
||||
//! lightcraft-cli snapshot [--library DIR | --demo] [--script FILE.jsonl] [-o OUT.png] [--size WxH] [--scale S] [FILES…]
|
||||
//! lightcraft-cli merge hdr|panorama|hdr-panorama [OPTIONS] FILES…
|
||||
//! lightcraft-cli merge hdr|panorama|hdr-panorama|hdr-batch [OPTIONS] FILES…
|
||||
//! lightcraft-cli synth-merge hdr|panorama -o DIR
|
||||
//! lightcraft-cli denoise-serve [--model ID] [--models DIR] [--listen ADDR] [--token-env VAR] [--run-on auto|gpu|cpu]
|
||||
//! lightcraft-cli commands [--json]
|
||||
@@ -79,9 +79,10 @@ USAGE:
|
||||
-o, --output OUT PNG path (a final screenshot is written here if the script took none)
|
||||
--size WxH window size in points (default 1600x1000)
|
||||
--scale S pixels per point (default 1)
|
||||
lightcraft-cli merge hdr|panorama|hdr-panorama [OPTIONS] FILES…
|
||||
lightcraft-cli merge hdr|panorama|hdr-panorama|hdr-batch [OPTIONS] FILES…
|
||||
Photo Merge: writes <first>-HDR.dng / -Pano.dng / -HDR-Pano.dng next to the first file and
|
||||
prints the result as JSON. Options:
|
||||
prints the result as JSON. hdr-batch merges each bracket set among the files (photos taken
|
||||
less than a second apart at different exposures), e.g. a whole folder. Options:
|
||||
--deghost none|low|medium|high --no-align --bracket N (HDR panorama)
|
||||
--projection auto|spherical|cylindrical|perspective --boundary-warp 0..100
|
||||
--auto-crop --fill-edges --no-auto-settings
|
||||
@@ -372,7 +373,8 @@ fn merge(args: &[String]) -> Result<(), String> {
|
||||
Some("hdr") => "merge.hdr",
|
||||
Some("panorama" | "pano") => "merge.panorama",
|
||||
Some("hdr-panorama") => "merge.hdrPanorama",
|
||||
_ => return Err("merge: expected hdr, panorama or hdr-panorama".into()),
|
||||
Some("hdr-batch") => "merge.hdrBatch",
|
||||
_ => return Err("merge: expected hdr, panorama, hdr-panorama or hdr-batch".into()),
|
||||
};
|
||||
let mut p = serde_json::Map::new();
|
||||
let mut files = Vec::new();
|
||||
|
||||
@@ -231,6 +231,60 @@ impl Catalog {
|
||||
}
|
||||
}
|
||||
|
||||
impl Catalog {
|
||||
/// The bracket sets among `ids`, an HDR merge each: every stack one of them is in (its photos, less earlier merge
|
||||
/// results), then the unstacked ones in capture order, a set running while each starts within about a second of the
|
||||
/// previous one's end (its shutter time; capture times are whole seconds). A run is a set when its exposures span at
|
||||
/// least ½ EV: a burst at one exposure is no bracket. Photos without a capture time or shutter speed are left out.
|
||||
pub fn bracket_sets(&self, ids: &[PhotoId]) -> Vec<Vec<PhotoId>> {
|
||||
let merged = |p: &crate::Photo| crate::query::merged_kind(&p.file_name).is_some();
|
||||
let mut sets: Vec<Vec<PhotoId>> = Vec::new();
|
||||
let mut stacks = HashSet::new();
|
||||
let mut loose = Vec::new();
|
||||
for id in ids {
|
||||
match self.stack_of(*id) {
|
||||
Some(s) if stacks.insert(s.id) => {
|
||||
let set: Vec<PhotoId> = s.photos.iter().copied().filter(|p| self.photos.get(p).is_some_and(|p| !merged(p))).collect();
|
||||
if set.len() >= 2 {
|
||||
sets.push(set);
|
||||
}
|
||||
}
|
||||
Some(_) => {}
|
||||
None => loose.push(*id),
|
||||
}
|
||||
}
|
||||
// (capture second, file name, id, shutter seconds, EV)
|
||||
let mut timed: Vec<(i64, &str, PhotoId, f64, f64)> = loose
|
||||
.iter()
|
||||
.filter_map(|id| self.photos.get(id))
|
||||
.filter(|p| !p.deleted && !merged(p))
|
||||
.filter_map(|p| {
|
||||
let t = iso_seconds(p.captured.as_deref()?)?;
|
||||
let s = crate::model::parse_shutter_seconds(&p.meta.shutter)?;
|
||||
let n = p.meta.aperture.filter(|n| n.is_finite() && *n > 0.0).unwrap_or(1.0) as f64;
|
||||
let iso = p.meta.iso.filter(|i| *i > 0).unwrap_or(100) as f64;
|
||||
Some((t, p.file_name.as_str(), p.id, s, (s * iso / (n * n)).log2()))
|
||||
})
|
||||
.collect();
|
||||
timed.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| cmp_name(a.1, b.1)).then(a.2.cmp(&b.2)));
|
||||
timed.dedup_by_key(|x| x.2);
|
||||
let mut runs: Vec<Vec<(i64, &str, PhotoId, f64, f64)>> = Vec::new();
|
||||
for x in timed {
|
||||
match runs.last_mut() {
|
||||
Some(r) if r.last().is_some_and(|l| (x.0 - l.0) as f64 <= l.3.ceil() + 1.0) => r.push(x),
|
||||
_ => runs.push(vec![x]),
|
||||
}
|
||||
}
|
||||
for r in runs {
|
||||
let (lo, hi) = r.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), x| (lo.min(x.4), hi.max(x.4)));
|
||||
if r.len() >= 2 && hi - lo >= 0.5 {
|
||||
sets.push(r.iter().map(|x| x.2).collect());
|
||||
}
|
||||
}
|
||||
sets
|
||||
}
|
||||
}
|
||||
|
||||
/// Seconds since 1970-01-01 for an ISO 8601 local time (`2026-04-01T10:00:00`, `2026-04-01 10:00`,
|
||||
/// `2026-04-01`; fractions and zone suffixes are ignored). `None` if it doesn't parse.
|
||||
pub fn iso_seconds(s: &str) -> Option<i64> {
|
||||
@@ -358,6 +412,46 @@ mod tests {
|
||||
assert_eq!(back.clone().alloc_stack_id(), c.clone().alloc_stack_id());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bracket_sets_by_time_and_exposure() {
|
||||
// (time, shutter, ISO, file name)
|
||||
let shots = [
|
||||
("2026-08-01T09:20:36", "1/125", 640, "A1.ARW"), // a handheld bracket
|
||||
("2026-08-01T09:20:36", "1/125", 200, "A2.ARW"),
|
||||
("2026-08-01T09:20:37", "1/125", 2000, "A3.ARW"),
|
||||
("2026-08-01T09:20:37", "", 0, "A3-HDR.dng"), // an earlier merge of it
|
||||
("2026-08-01T09:20:44", "1/125", 800, "B1.ARW"), // 7 s later: the next bracket
|
||||
("2026-08-01T09:20:44", "1/125", 250, "B2.ARW"),
|
||||
("2026-08-01T09:20:50", "1/1000", 160, "C1.ARW"), // a burst at one exposure
|
||||
("2026-08-01T09:20:50", "1/1000", 160, "C2.ARW"),
|
||||
("2026-06-27T23:34:59", "2", 320, "N1.ARW"), // 2 s night frames, 2.1 s apart
|
||||
("2026-06-27T23:35:01", "2", 100, "N2.ARW"),
|
||||
("2026-06-27T23:35:04", "2", 800, "N3.ARW"),
|
||||
("2026-06-27T23:36:00", "1/40", 250, "S1.ARW"), // stacked by hand: merged whatever its times
|
||||
("2026-06-27T23:39:00", "1/40", 250, "S2.ARW"),
|
||||
];
|
||||
let mut c = Catalog::new();
|
||||
let mut ids = Vec::new();
|
||||
for (t, sh, iso, name) in shots {
|
||||
let id = c.alloc_photo_id();
|
||||
let mut p = Photo::new(id, Source::Demo { scene: 1 }, name, "ARW", 60, 40, "2026-01-01T00:00:00");
|
||||
p.captured = Some(t.into());
|
||||
p.meta.shutter = sh.into();
|
||||
p.meta.iso = Some(iso);
|
||||
p.meta.aperture = Some(5.6);
|
||||
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
ids.push(id);
|
||||
}
|
||||
let op = c.group_ops(ids[11], &[ids[12]], true).unwrap();
|
||||
c.apply(op).unwrap();
|
||||
let sets = c.bracket_sets(&ids);
|
||||
assert_eq!(sets, vec![vec![ids[11], ids[12]], vec![ids[8], ids[9], ids[10]], vec![ids[0], ids[1], ids[2]], vec![ids[4], ids[5]]]);
|
||||
// a stack's merge result on top is left out; a stack selected twice is merged once
|
||||
let op = c.stack_with_ops(ids[3], &[ids[0], ids[1], ids[2]]).unwrap();
|
||||
c.apply(op).unwrap();
|
||||
assert_eq!(c.bracket_sets(&[ids[3], ids[0]]), vec![vec![ids[0], ids[1], ids[2]]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_stack_by_capture_time() {
|
||||
let times = ["2026-04-01T10:00:00", "2026-04-01T10:00:02", "2026-04-01T10:00:09", "2026-04-01T11:00:00", "2026-04-01T11:00:01"];
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Photo Merge commands: `merge.hdr`, `merge.panorama`, `merge.hdrPanorama`.
|
||||
//! Photo Merge commands: `merge.hdr`, `merge.panorama`, `merge.hdrPanorama`, `merge.hdrBatch`.
|
||||
//!
|
||||
//! Run synchronously here (CLI, MCP, control channel); the desktop UI plans the same job with
|
||||
//! [`Session::plan_merge`] and runs it on a worker thread. `preview: true` merges ≤ 1024 px frames
|
||||
@@ -40,6 +40,26 @@ fn run(s: &mut Session, id: &str, p: &Value) -> Result<Value> {
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
/// `merge.hdrBatch`: an HDR merge of each bracket set among the photos ([`lightcraft_catalog::Catalog::bracket_sets`]),
|
||||
/// one after the other. A set that fails is reported and the others still run.
|
||||
fn run_batch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const ID: &str = "merge.hdrBatch";
|
||||
let (kind, finish) = crate::merge::parse("merge.hdr", p)?;
|
||||
let ids: Vec<PhotoId> = s.targets(p);
|
||||
let sets = s.catalog.bracket_sets(&ids);
|
||||
if sets.is_empty() {
|
||||
return Err(bad(ID, crate::merge::NO_BRACKET_SETS));
|
||||
}
|
||||
let (mut merged, mut failed) = (Vec::new(), Vec::new());
|
||||
for set in sets {
|
||||
match s.merge_now(kind.clone(), finish.clone(), &set) {
|
||||
Ok(v) => merged.push(v),
|
||||
Err(e) => failed.push(json!({"photos": set.iter().map(|p| p.0).collect::<Vec<_>>(), "error": e.to_string()})),
|
||||
}
|
||||
}
|
||||
Ok(json!({"merged": merged, "failed": failed}))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
@@ -69,5 +89,14 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
can_merge,
|
||||
|s, p| run(s, "merge.hdrPanorama", p)
|
||||
),
|
||||
cmd!(
|
||||
"merge.hdrBatch",
|
||||
"HDR for Each Bracket Set",
|
||||
["Photo", "Photo Merge"],
|
||||
None,
|
||||
"{ids?, align=true, deghost=none|low|medium|high, autoSettings=true, stack=false}: an HDR merge of each bracket set among the photos (each stack, then photos taken less than a second apart at exposures ≥ ½ EV apart) → {merged: [{id, path, …}], failed: [{photos, error}]}",
|
||||
can_merge,
|
||||
run_batch
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
+16
-14
@@ -20,6 +20,10 @@ use serde_json::{Value, json};
|
||||
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
/// Why `merge.hdrBatch` found nothing to merge.
|
||||
pub const NO_BRACKET_SETS: &str =
|
||||
"no bracket sets among these photos: select stacks, or photos taken less than a second apart at different exposures";
|
||||
|
||||
/// Long edge of the frames of a preview merge.
|
||||
pub const PREVIEW_EDGE: usize = 1024;
|
||||
|
||||
@@ -105,20 +109,18 @@ impl MergeJob {
|
||||
use rayon::prelude::*;
|
||||
let done = std::sync::atomic::AtomicUsize::new(0);
|
||||
let n = self.sources.len();
|
||||
let frames: std::result::Result<Vec<Frame>, String> = self
|
||||
.sources
|
||||
.par_iter()
|
||||
.map(|(_, path)| {
|
||||
let bytes = (self.read)(path)?;
|
||||
// a raw without colour matrices of its own starts from the colour it has when opened alone (#965)
|
||||
let f = lightcraft_merge::load_frame_with(&bytes, self.preview, orient, &crate::camera_preview::starting_colour_tags)
|
||||
.map_err(|e| format!("{path}: {e}"));
|
||||
let k = done.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
|
||||
progress(span.0 + (span.1 - span.0) * k as f32 / n as f32, "Reading photos");
|
||||
f
|
||||
})
|
||||
.collect();
|
||||
frames
|
||||
let one = |(_, path): &(PhotoId, String)| {
|
||||
let bytes = (self.read)(path)?;
|
||||
// a raw without colour matrices of its own starts from the colour it has when opened alone (#965)
|
||||
let f = lightcraft_merge::load_frame_with(&bytes, self.preview, orient, &crate::camera_preview::starting_colour_tags)
|
||||
.map_err(|e| format!("{path}: {e}"));
|
||||
let k = done.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
|
||||
progress(span.0 + (span.1 - span.0) * k as f32 / n as f32, "Reading photos");
|
||||
f
|
||||
};
|
||||
// Full-size photos are decoded one at a time (the demosaic is parallel inside): decoding several 60 MP raws at
|
||||
// once multiplied the decoders' scratch memory, and a 7-frame bracket ran out of memory.
|
||||
if self.preview.is_some() { self.sources.par_iter().map(one).collect() } else { self.sources.iter().map(one).collect() }
|
||||
}
|
||||
|
||||
/// Run the merge. `progress(fraction, stage)` returns false to cancel.
|
||||
|
||||
@@ -168,3 +168,34 @@ fn panorama_command_with_auto_crop() {
|
||||
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);
|
||||
}
|
||||
|
||||
+29
-18
@@ -12,9 +12,11 @@
|
||||
//! 4. **Weights.** `wᵢ = kᵢ · (1 − smoothstep(0.80·clip, 0.96·clip, max channel))`: longer
|
||||
//! exposures get more weight (shot-noise optimal for a linear sensor), saturated pixels none
|
||||
//! (all channels together, so a clipped channel never shifts colour). The darkest frame keeps a
|
||||
//! tiny weight so fully saturated pixels still get a value.
|
||||
//! 5. **Deghost** (None/Low/Medium/High): on a ≤ 1024 px version, a per-pixel consistency test
|
||||
//! against the reference: the reference radiance (or, where the reference is clipped/too dark,
|
||||
//! tiny weight so fully saturated pixels still get a value. Where the reference is at or near its clip, the scene is
|
||||
//! at least that bright: a frame reading far darker there saw something that moved (a star, the moon, a light) and is
|
||||
//! left out, so a clipped highlight of the reference is never replaced by content from another moment.
|
||||
//! 5. **Deghost** (None/Low/Medium/High): on a ≤ 2048 px version, a per-pixel consistency test
|
||||
//! against the reference: the reference radiance (or, where the reference is clipped or black,
|
||||
//! the nearest well-exposed frame's) predicts what each frame should have recorded; frames that
|
||||
//! disagree by more than a threshold (in log units, with a noise floor) are masked there. Masks
|
||||
//! are dilated and feathered (Gaussian), upsampled, and remove the frame's weight. Their union
|
||||
@@ -87,6 +89,10 @@ pub struct HdrResult {
|
||||
pub lens: Vec<lightcraft_raw::Opcode>,
|
||||
}
|
||||
|
||||
/// Long edge (at most) of the frames the deghost test compares: small moving things (a star at 200 mm) still span a few
|
||||
/// pixels there.
|
||||
const DEGHOST_EDGE: usize = 2048;
|
||||
|
||||
#[inline]
|
||||
fn smoothstep(a: f32, b: f32, x: f32) -> f32 {
|
||||
let t = ((x - a) / (b - a)).clamp(0.0, 1.0);
|
||||
@@ -161,15 +167,9 @@ pub fn merge_hdr(mut frames: Vec<Frame>, opts: &HdrOptions, progress: &Progress)
|
||||
alignments[i] = a;
|
||||
}
|
||||
check(progress, 0.35, "Resampling")?;
|
||||
let warped: Vec<(usize, Rgb32f, Vec<bool>)> = (0..n)
|
||||
.into_par_iter()
|
||||
.filter(|&i| i != reference && alignments[i].model != "identity")
|
||||
.map(|i| {
|
||||
let (img, v) = align::warp(&frames[i].image, &alignments[i].h);
|
||||
(i, img, v)
|
||||
})
|
||||
.collect();
|
||||
for (i, img, v) in warped {
|
||||
// one frame at a time (the warp is parallel inside): each warped copy replaces its frame before the next is made
|
||||
for i in (0..n).filter(|&i| i != reference && alignments[i].model != "identity") {
|
||||
let (img, v) = align::warp(&frames[i].image, &alignments[i].h);
|
||||
frames[i].image = img;
|
||||
valid[i] = Some(v);
|
||||
}
|
||||
@@ -215,6 +215,9 @@ pub fn merge_hdr(mut frames: Vec<Frame>, opts: &HdrOptions, progress: &Progress)
|
||||
let i_px = y * w + x;
|
||||
let mut acc = [0f64; 3];
|
||||
let mut ws = 0f64;
|
||||
// the reference at or near its clip: the radiance is at least this
|
||||
let mr = maxc(fr[reference].image.data[i_px]);
|
||||
let bound = if mr >= 0.80 * clips[reference] { mr / k[reference] } else { 0.0 };
|
||||
for i in 0..n {
|
||||
if vd[i].as_ref().is_some_and(|v| !v[i_px]) {
|
||||
continue;
|
||||
@@ -227,6 +230,9 @@ pub fn merge_hdr(mut frames: Vec<Frame>, opts: &HdrOptions, progress: &Progress)
|
||||
}
|
||||
if i == reference {
|
||||
wt += 1e-9;
|
||||
} else if bound > 0.0 {
|
||||
// a frame far darker than that saw something that moved there
|
||||
wt *= smoothstep(0.35, 0.6, m / k[i] / bound);
|
||||
}
|
||||
if i != reference
|
||||
&& let Some((maps, fac)) = gs
|
||||
@@ -362,35 +368,40 @@ fn deghost_maps(
|
||||
) -> (Vec<Plane>, usize) {
|
||||
let n = frames.len();
|
||||
let (w, h) = (frames[0].width(), frames[0].height());
|
||||
let fac = w.max(h).div_ceil(1024).max(1);
|
||||
let fac = w.max(h).div_ceil(DEGHOST_EDGE).max(1);
|
||||
// the dilation/feather radius was chosen for a 1024 px map: keep it the same share of the picture
|
||||
let r = (r as f32 * (w.max(h) as f32 / fac as f32 / 1024.0).max(1.0)).round() as usize;
|
||||
let small: Vec<(Rgb32f, Vec<f32>)> = (0..n).into_par_iter().map(|i| shrink(&frames[i].image, valid[i].as_deref(), fac)).collect();
|
||||
let (sw, sh) = (small[0].0.width, small[0].0.height);
|
||||
let rpos = order.iter().position(|&i| i == reference).unwrap_or(0);
|
||||
let good = |i: usize, p: usize| -> bool {
|
||||
// `dark`: the share of the clip below which a frame's (averaged) pixel is too noisy to predict from
|
||||
let good = |i: usize, p: usize, dark: f32| -> bool {
|
||||
let f = &frames[i];
|
||||
let m = maxc(small[i].0.data[p]);
|
||||
let dark = if f.raw { 0.01 } else { 0.04 };
|
||||
small[i].1[p] > 0.99 && m < 0.8 * f.clip && m > dark * f.clip
|
||||
};
|
||||
let dark = |i: usize| if frames[i].raw { 0.01 } else { 0.04 };
|
||||
// predicted reference radiance per pixel
|
||||
let pred: Vec<[f32; 3]> = (0..sw * sh)
|
||||
.into_par_iter()
|
||||
.map(|p| {
|
||||
let rad = |i: usize| small[i].0.data[p].map(|v| v / k[i]);
|
||||
if good(reference, p) {
|
||||
// the reference predicts down to a tenth of the other frames' floor (the map's averaging tames its noise): in a
|
||||
// dark sky the brighter frames must not predict themselves, or their ghosts would look consistent
|
||||
if good(reference, p, dark(reference) * 0.1) {
|
||||
return rad(reference);
|
||||
}
|
||||
let bright = maxc(small[reference].0.data[p]) >= 0.8 * frames[reference].clip;
|
||||
if bright {
|
||||
for q in (0..rpos).rev() {
|
||||
if good(order[q], p) {
|
||||
if good(order[q], p, dark(order[q])) {
|
||||
return rad(order[q]);
|
||||
}
|
||||
}
|
||||
rad(order[0])
|
||||
} else {
|
||||
for &i in &order[rpos + 1..] {
|
||||
if good(i, p) {
|
||||
if good(i, p, dark(i)) {
|
||||
return rad(i);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,3 +161,48 @@ fn deghost_removes_a_moving_object() {
|
||||
let mean = high.ghost.data.iter().sum::<f32>() / high.ghost.data.len() as f32;
|
||||
assert!(mean < 0.08, "the overlay is local (mean {mean})");
|
||||
}
|
||||
|
||||
/// A dark night sky (≈ 0.3 % of the reference's white) with a soft star of peak `peak` (Gaussian, σ = 3 px) at (cx, cy)
|
||||
/// in truth coordinates.
|
||||
fn night_with_star(cx: f32, cy: f32, peak: f32) -> Rgb32f {
|
||||
Rgb32f::from_fn(W + 2 * M, H + 2 * M, |x, y| {
|
||||
let sky = 0.003 * (1.0 + 0.3 * (x as f32 / (W + 2 * M) as f32));
|
||||
let d2 = (x as f32 + 0.5 - cx).powi(2) + (y as f32 + 0.5 - cy).powi(2);
|
||||
[sky + peak * (-d2 / 18.0).exp(); 3]
|
||||
})
|
||||
}
|
||||
|
||||
/// Night brackets of a star that moves between the frames (the sky turns during a tripod bracket): its core clips in the
|
||||
/// reference and in the brighter frame, not in the darkest one.
|
||||
fn moving_star_frames(xs: [f32; 3]) -> Vec<Frame> {
|
||||
let ks = [0.0625f32, 1.0, 8.0];
|
||||
(0..3).map(|i| shoot(&night_with_star(xs[i], 210.0 + M as f32, 10.0), ks[i], 0.0, 0.0, 30 + i as u64)).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_clipped_star_of_the_reference_is_not_erased_by_frames_where_it_moved() {
|
||||
let xs = [280.0 + M as f32, 320.0 + M as f32, 360.0 + M as f32];
|
||||
let r = merge_hdr(moving_star_frames(xs), &HdrOptions { align: false, deghost: Deghost::None }, &no_progress).unwrap();
|
||||
assert_eq!(r.reference, 1);
|
||||
// the reference saw the star clipped here, so the scene is at least that bright; the frames where the star had moved
|
||||
// read the dark sky there and must not replace it (which left a black hole in real night brackets)
|
||||
let core = r.radiance.get(320, 210);
|
||||
let m = core[0].max(core[1]).max(core[2]);
|
||||
assert!(m > 0.9, "the reference's clipped star core was erased: {core:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deghost_removes_a_bright_frames_ghost_in_a_dark_sky() {
|
||||
let xs = [280.0 + M as f32, 320.0 + M as f32, 360.0 + M as f32];
|
||||
let r = merge_hdr(moving_star_frames(xs), &HdrOptions { align: false, deghost: Deghost::High }, &no_progress).unwrap();
|
||||
// where only the brightest frame had the star, the sky is what the reference saw: no ring of its unclipped halo
|
||||
let mut worst = 0f32;
|
||||
for y in 196..224 {
|
||||
for x in 346..374 {
|
||||
let p = r.radiance.get(x, y);
|
||||
worst = worst.max(p[1]);
|
||||
}
|
||||
}
|
||||
assert!(worst < 0.02, "the brightest frame's star is left as a ghost (peak {worst})");
|
||||
assert!(r.ghost.get(360, 210) > 0.5, "the overlay marks the ghost");
|
||||
}
|
||||
|
||||
@@ -19,20 +19,21 @@ const MARGIN: usize = 6;
|
||||
|
||||
pub(crate) fn ahd(m: &Mosaic) -> Rgb32f {
|
||||
let (w, h) = (m.w, m.h);
|
||||
let tiles: Vec<(usize, usize)> = (0..h.div_ceil(TILE)).flat_map(|ty| (0..w.div_ceil(TILE)).map(move |tx| (tx * TILE, ty * TILE))).collect();
|
||||
let results: Vec<(usize, usize, usize, usize, Vec<[f32; 3]>)> = tiles
|
||||
.par_iter()
|
||||
.map(|&(x0, y0)| {
|
||||
let (tw, th) = (TILE.min(w - x0), TILE.min(h - y0));
|
||||
(x0, y0, tw, th, tile(m, x0, y0, tw, th))
|
||||
})
|
||||
.collect();
|
||||
let mut out = Rgb32f::new(w, h);
|
||||
for (x0, y0, tw, th, px) in results {
|
||||
for y in 0..th {
|
||||
out.data[(y0 + y) * w + x0..(y0 + y) * w + x0 + tw].copy_from_slice(&px[y * tw..(y + 1) * tw]);
|
||||
}
|
||||
if w == 0 || h == 0 {
|
||||
return out;
|
||||
}
|
||||
// one row of tiles per task, written straight into the picture (collecting the tiles first held a second copy)
|
||||
out.data.par_chunks_mut(w * TILE).enumerate().for_each(|(ty, band)| {
|
||||
let (y0, th) = (ty * TILE, band.len() / w);
|
||||
for x0 in (0..w).step_by(TILE) {
|
||||
let tw = TILE.min(w - x0);
|
||||
let px = tile(m, x0, y0, tw, th);
|
||||
for y in 0..th {
|
||||
band[y * w + x0..y * w + x0 + tw].copy_from_slice(&px[y * tw..(y + 1) * tw]);
|
||||
}
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
|
||||
@@ -637,6 +637,7 @@
|
||||
"HDR panorama": "HDR-Panorama",
|
||||
"HDR panoramas": "HDR-Panoramen",
|
||||
"HDR with Last Settings": "HDR mit letzten Einstellungen",
|
||||
"HDR for Each Bracket Set": "HDR für jede Belichtungsreihe",
|
||||
"HDR…": "HDR…",
|
||||
"Has Edits": "Enthält Bearbeitungen",
|
||||
"Has GPS": "Enthält GPS",
|
||||
|
||||
@@ -641,6 +641,7 @@
|
||||
"HDR panorama": "Panorámica HDR",
|
||||
"HDR panoramas": "panorámicas HDR",
|
||||
"HDR with Last Settings": "HDR con los últimos ajustes",
|
||||
"HDR for Each Bracket Set": "HDR para cada horquillado",
|
||||
"HDR…": "HDR…",
|
||||
"Has Edits": "Con ediciones",
|
||||
"Has GPS": "Con GPS",
|
||||
|
||||
@@ -601,6 +601,7 @@
|
||||
"HDR panorama": "Panorama HDR",
|
||||
"HDR panoramas": "panoramas HDR",
|
||||
"HDR with Last Settings": "HDR avec les derniers réglages",
|
||||
"HDR for Each Bracket Set": "HDR pour chaque bracketing",
|
||||
"HDR…": "HDR…",
|
||||
"Has Edits": "Avec retouches",
|
||||
"Has GPS": "Avec GPS",
|
||||
|
||||
@@ -507,6 +507,7 @@
|
||||
"HDR panorama": "HDRパノラマ",
|
||||
"HDR panoramas": "HDRパノラマ",
|
||||
"HDR with Last Settings": "前回の設定でHDR合成",
|
||||
"HDR for Each Bracket Set": "ブラケットごとにHDR合成",
|
||||
"Has Edits": "編集",
|
||||
"Has GPS": "GPS情報",
|
||||
"High Contrast B&W": "ハイコントラスト白黒",
|
||||
|
||||
@@ -610,6 +610,7 @@
|
||||
"HDR panorama": "Panorama HDR",
|
||||
"HDR panoramas": "panoramas HDR",
|
||||
"HDR with Last Settings": "HDR com os últimos ajustes",
|
||||
"HDR for Each Bracket Set": "HDR para cada bracketing",
|
||||
"HDR…": "HDR…",
|
||||
"Has Edits": "Tem edições",
|
||||
"Has GPS": "Tem GPS",
|
||||
|
||||
@@ -502,6 +502,7 @@
|
||||
"HDR Panorama with Last Settings": "HDR-панорама с последними настройками",
|
||||
"HDR Panorama…": "HDR-панорама…",
|
||||
"HDR with Last Settings": "HDR с последними настройками",
|
||||
"HDR for Each Bracket Set": "HDR для каждого брекетинга",
|
||||
"HDR…": "HDR…",
|
||||
"Heal": "Лечение",
|
||||
"Heal the small, soft dark spots sensor dust leaves on smooth areas": "Убирать мелкие мягкие тёмные пятна от пыли на матрице на ровных участках",
|
||||
|
||||
@@ -749,6 +749,7 @@
|
||||
"HDR panorama": "HDR-панорама",
|
||||
"HDR panoramas": "HDR-панорами",
|
||||
"HDR with Last Settings": "HDR з останніми налаштуваннями",
|
||||
"HDR for Each Bracket Set": "HDR для кожної серії експозицій",
|
||||
"Has Edits": "Має зміни",
|
||||
"Has GPS": "Має GPS",
|
||||
"High Contrast B&W": "Контрастне чорно-біле",
|
||||
|
||||
@@ -740,6 +740,7 @@
|
||||
"HDR Panorama with Last Settings": "使用上次设置合并 HDR 全景",
|
||||
"HDR Panorama…": "HDR 全景…",
|
||||
"HDR with Last Settings": "使用上次设置合并 HDR",
|
||||
"HDR for Each Bracket Set": "为每组包围曝光合并 HDR",
|
||||
"HDR…": "HDR…",
|
||||
"Heal": "修复",
|
||||
"Heal the small, soft dark spots sensor dust leaves on smooth areas": "修复传感器灰尘在平滑区域留下的小而柔和的暗斑",
|
||||
|
||||
@@ -740,6 +740,7 @@
|
||||
"HDR Panorama with Last Settings": "使用上次設定合併 HDR 全景",
|
||||
"HDR Panorama…": "HDR 全景…",
|
||||
"HDR with Last Settings": "使用上次設定合併 HDR",
|
||||
"HDR for Each Bracket Set": "為每組包圍曝光合併 HDR",
|
||||
"HDR…": "HDR…",
|
||||
"Heal": "修復",
|
||||
"Heal the small, soft dark spots sensor dust leaves on smooth areas": "修復感光元件灰塵在平滑區域留下的小型柔邊暗點",
|
||||
|
||||
@@ -1276,6 +1276,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"merge.hdrLast" => crate::merge::start_last(app, "merge.hdr"),
|
||||
"merge.hdrBatch" => crate::merge::start_batch(app, p),
|
||||
"merge.panoramaLast" => crate::merge::start_last(app, "merge.panorama"),
|
||||
"merge.hdrPanoramaLast" => crate::merge::start_last(app, "merge.hdrPanorama"),
|
||||
"dialog.mergeHdr" => crate::merge::open(app, "merge.hdr"),
|
||||
|
||||
@@ -110,6 +110,8 @@ pub struct MergeState {
|
||||
pub ids: Vec<PhotoId>,
|
||||
/// The options of the last merge started, per kind ("merge.hdr" …): Merge with Last Settings.
|
||||
pub last: std::collections::HashMap<String, MergeDialog>,
|
||||
/// HDR for Each Bracket Set: the sets still to merge after the running one.
|
||||
pub batch: std::collections::VecDeque<Vec<PhotoId>>,
|
||||
}
|
||||
|
||||
impl MergeState {
|
||||
@@ -194,6 +196,31 @@ pub fn start_last(app: &mut LightcraftApp, command: &str) -> Result<Value, Strin
|
||||
start_final(app, &opts)
|
||||
}
|
||||
|
||||
/// HDR for Each Bracket Set (`merge.hdrBatch`): merge each bracket set among the photos in the background, one after
|
||||
/// the other, with the options last used for HDR (the defaults the first time).
|
||||
pub fn start_batch(app: &mut LightcraftApp, p: &Value) -> Result<Value, String> {
|
||||
if app.merge.final_task.is_some() {
|
||||
return Err("a merge is already running".into());
|
||||
}
|
||||
let ids = app.session.targets(p);
|
||||
let sets = app.session.catalog.bracket_sets(&ids);
|
||||
if sets.is_empty() {
|
||||
return Err(lightcraft_engine::merge::NO_BRACKET_SETS.into());
|
||||
}
|
||||
let n = sets.len();
|
||||
app.merge.batch = sets.into();
|
||||
next_in_batch(app)?;
|
||||
Ok(json!({"started": true, "sets": n}))
|
||||
}
|
||||
|
||||
/// Start the next bracket set of a batch, if any.
|
||||
fn next_in_batch(app: &mut LightcraftApp) -> Result<(), String> {
|
||||
let Some(ids) = app.merge.batch.pop_front() else { return Ok(()) };
|
||||
app.merge.ids = ids;
|
||||
let opts = app.merge.last.get("merge.hdr").cloned().unwrap_or_else(|| MergeDialog::for_command("merge.hdr"));
|
||||
start_final(app, &opts).map(|_| ())
|
||||
}
|
||||
|
||||
/// Per frame: keep the preview in sync with the dialog's options, collect finished jobs.
|
||||
pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
// preview for the open dialog
|
||||
@@ -272,12 +299,23 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
app.merge.last_result = Some(v);
|
||||
app.toast(ctx, crate::i18n::tr_format!("{what} merge added", what = what));
|
||||
}
|
||||
Err(_) if t.cancel.load(Ordering::Relaxed) => app.toast(ctx, crate::i18n::tr_format!("{what} merge cancelled", what = what)),
|
||||
Err(_) if t.cancel.load(Ordering::Relaxed) => {
|
||||
// cancelling one merge of a batch stops the batch
|
||||
app.merge.batch.clear();
|
||||
app.toast(ctx, crate::i18n::tr_format!("{what} merge cancelled", what = what))
|
||||
}
|
||||
Err(e) => {
|
||||
app.ui.status = e.to_string();
|
||||
app.toast(ctx, crate::i18n::tr_format!("{what} merge failed: {e}", e = e, what = what));
|
||||
}
|
||||
}
|
||||
// a failed set doesn't stop the others
|
||||
while !app.merge.batch.is_empty() {
|
||||
match next_in_batch(app) {
|
||||
Ok(()) => break,
|
||||
Err(e) => app.toast(ctx, crate::i18n::tr_format!("{what} merge failed: {e}", e = e, what = what)),
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
let (f, stage) = t.progress.lock().map(|g| g.clone()).unwrap_or_default();
|
||||
|
||||
@@ -360,6 +360,51 @@ fn final_merge_shows_a_row_and_cancels() {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// HDR for Each Bracket Set runs the merges one after the other, each with its activity row; cancelling one stops the
|
||||
/// rest.
|
||||
#[test]
|
||||
fn batch_merge_runs_each_set_in_turn() {
|
||||
let dir = std::env::temp_dir().join(format!("lc-activity-batch-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
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]), ("C", [-1.0, 1.0, 3.0])] {
|
||||
for (i, bytes) in lightcraft_merge::synth::bracket_dngs(1600, 1200, &evs).unwrap().into_iter().enumerate() {
|
||||
let p = dir.join(format!("{set}_{i}.dng"));
|
||||
std::fs::write(&p, bytes).unwrap();
|
||||
paths.push(p.to_string_lossy().to_string());
|
||||
}
|
||||
}
|
||||
let mut session = lightcraft_engine::Session::new().with_fs();
|
||||
let r = session.execute("library.import", &json!({"paths": paths})).unwrap();
|
||||
let ids: Vec<u64> = r["imported"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect();
|
||||
assert_eq!(ids.len(), 9);
|
||||
for set in ids.chunks(3) {
|
||||
session.execute("stack.group", &json!({"ids": set})).unwrap();
|
||||
}
|
||||
session.execute("library.select", &json!({"ids": ids})).unwrap();
|
||||
let app = LightcraftApp::new(session, Services { png: None, ..Default::default() });
|
||||
let mut h = Headless::new(app, [1200.0, 800.0], 1.0);
|
||||
h.settle(T);
|
||||
let r = crate::menus::run_ui_command(&mut h.app, "merge.hdrBatch", &json!({})).unwrap().unwrap();
|
||||
assert_eq!(r["sets"], 3);
|
||||
assert_eq!(h.app.merge.batch.len(), 2, "one running, two queued");
|
||||
let rows = h.app.session.activity.list();
|
||||
assert_eq!((rows.len(), rows.first().map(|r| r.kind)), (1, Some("merge")));
|
||||
// the first merge finishes and the second starts by itself
|
||||
assert!(h.step_until(T, |h| h.app.merge.batch.len() == 1 && h.app.merge.final_task.is_some()));
|
||||
assert_eq!(h.app.session.catalog.len(), 10);
|
||||
// cancelling the second drops the third
|
||||
let rows = h.app.session.activity.list();
|
||||
h.app.session.activity.cancel(rows[0].id).unwrap();
|
||||
assert!(h.step_until(T, |h| h.app.merge.final_task.is_none()));
|
||||
assert!(h.app.merge.batch.is_empty());
|
||||
assert_eq!(h.app.session.catalog.len(), 10, "nothing more merged");
|
||||
assert!(h.app.session.activity.list().is_empty());
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_missing_shows_an_indeterminate_row() {
|
||||
let mut h = demo();
|
||||
|
||||
+3
-1
@@ -42,6 +42,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app
|
||||
| 25 | **File writes**: AVIF import; Render to DNG as a catalog command (lossy DNG and the Render to DNG export format landed) (PSD export landed with Edit in PhotoCraft, #953) | file-format-parity.md | interchange edge cases | 19–34 | [file-format-parity](file-format-parity.md) |
|
||||
| 26 | **Performance at scale**: 85k-photo library opens in 1.7–4.7 s; 100k grid test pending; colour NR at half resolution | ROADMAP log, gpu-pipeline.md | large-catalog users | 15–30 | [target-app-parity](target-app-parity.md#by-dimension) |
|
||||
| 27 | **Windows / Linux runtime coverage**: Windows installer UI unverified on Windows (#79); GPU path proven on Apple and on one AMD GPU on Linux (Radeon 8060S, Vulkan); NVIDIA, Intel and DX12 unverified; the Linux riscv64 tarball (new in 0.6.0, #844) is cross-compiled and never run beyond a QEMU smoke test | ROADMAP, issues, `release.yml` | non-Mac users find bugs first | 13–27 | [hardware-parity](hardware-parity.md) |
|
||||
| 29 | **HDR merge leftovers** (LR-MERGE-HDR): AI Denoise doesn't run on merged (linear) DNGs; JPEG brackets are treated as linear (no camera response curve); every frame is held as 32-bit float, ~0.86 GB per 67 MP frame (half-float frames would halve it) | tested on Sony A7R VI brackets 2026-10-14 ([merge.md](merge.md)); Lightroom's Denoise supports HDR DNGs | HDR shooters who denoise their merges, merge JPEGs, or merge 7+ frames of high-resolution raws on 16 GB machines | 10–20 (+ denoise linear-RGB contract, gap 11) | [merge](merge.md) |
|
||||
| 28 | **Small Classic shortcuts and keys**: rating `[` `]`, flag cycling, filter-bar keys, F1 help, panel keys (Tab, F5–F8) | [ui-parity](ui-parity.md#shortcuts) | Classic muscle memory | 4–8 | [ui-parity](ui-parity.md) |
|
||||
|
||||
Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform work they need around them,
|
||||
@@ -49,7 +50,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w
|
||||
|
||||
## Gaps by kind
|
||||
|
||||
- **Feature:** 5, 8, 11, 12, 13, 15, 16, 18, 19
|
||||
- **Feature:** 5, 8, 11, 12, 13, 15, 16, 18, 19, 29
|
||||
- **UI / UX:** 4, 23, 24, 28
|
||||
- **File format:** 2, 6, 7, 10, 13, 20, 25 (raw ones in [raw-parity.md](raw-parity.md))
|
||||
- **Hardware:** 14, 17, 22, 27
|
||||
@@ -76,6 +77,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w
|
||||
|---|---|---|
|
||||
| 2026-10-14 | minor | Gap 21: de, es, fr, ja and uk catalogs filled (97–99%); 55–90 h → 40–70 h |
|
||||
| 2026-10-14 | minor | Gap 10: 17 uncompressed Olympus and Samsung models verified on one CC0 file each (254 models) |
|
||||
| 2026-10-14 | minor | Gap 29 added: the HDR merge, tested on real 67 MP brackets, keeps moving clipped highlights and merges in batches now; AI Denoise on merged DNGs, JPEG response curves and per-frame memory remain |
|
||||
| 2026-10-13 | patch | Gaps 10, 22 and 23 each had two rows (two branches edited them; the merges kept both): the older copies are removed, and `cargo xtask parity` (in `ci`) now fails when a gap number is listed twice |
|
||||
| 2026-10-13 | minor | Closed recently: Sony ILCA-99M2 profile |
|
||||
| 2026-10-14 | minor | Gap 10: 21 Sony and Leica models verified on one CC0 file each (258 models) |
|
||||
|
||||
+20
-5
@@ -10,12 +10,17 @@ All algorithms are our own implementations from published papers (cited in the m
|
||||
*HDR Panorama…* from the photo context menu (or the `dialog.merge*` commands). The dialog previews
|
||||
the merge at ≤ 1024 px and re-runs the preview when an option changes; *Merge* runs the full merge
|
||||
in the background (a row in the activity stack shows its progress, with ✕ to stop it). The result is written next to the first photo as
|
||||
`<name>-HDR.dng`, `-Pano.dng` or `-HDR-Pano.dng`, imported and selected.
|
||||
`<name>-HDR.dng`, `-Pano.dng` or `-HDR-Pano.dng`, imported and selected. *Photo Merge ▸ HDR for Each Bracket Set*
|
||||
merges many brackets in one go with the last HDR options: one HDR per stack among the selected photos, then per
|
||||
bracket (photos taken less than a second apart, after the shutter time, at exposures at least ½ EV apart; a burst at
|
||||
one exposure is skipped), one after the other in the activity stack.
|
||||
- **Commands** (CLI, MCP, control channel):
|
||||
- `merge.hdr {ids?, align=true, deghost=none|low|medium|high, autoSettings=true, stack=false, preview=false, showOverlay=false, previewPath?}`
|
||||
- `merge.panorama {ids?, projection=auto|spherical|cylindrical|perspective, boundaryWarp=0..100, autoCrop=false, fillEdges=false, autoSettings=true, maxMegapixels=40, preview, previewPath?}`
|
||||
- `merge.hdrPanorama {ids?, bracket=0 (from EXIF), …both sets of options}`
|
||||
- **CLI:** `lightcraft-cli merge hdr|panorama|hdr-panorama [options] FILES…`;
|
||||
- `merge.hdrBatch {ids?, align, deghost, autoSettings, stack}` → `{merged: [{id, path, …}], failed: [{photos, error}]}`
|
||||
- **CLI:** `lightcraft-cli merge hdr|panorama|hdr-panorama|hdr-batch [options] FILES…` (`hdr-batch` finds the brackets
|
||||
among the files, e.g. a whole folder);
|
||||
`lightcraft-cli synth-merge hdr|panorama -o DIR` writes synthetic inputs (procedural scene).
|
||||
|
||||
## What it does
|
||||
@@ -25,8 +30,11 @@ reference. Auto Align: features on exposure-normalised luminance, RANSAC transla
|
||||
homography (the simplest model that explains ≥ 97 % of the matches). Exposure ratios are measured
|
||||
(median log ratio of well-exposed pixels between brightness neighbours), EXIF is the fallback.
|
||||
Radiance is a weighted mean in linear light, weight `k · (1 − smoothstep(0.8·clip, 0.96·clip, max
|
||||
channel))`. Deghost: a per-pixel consistency test against the reference radiance (with a noise
|
||||
floor), dilated and feathered masks; the union is the deghost overlay. Output: 16-bit float DNG.
|
||||
channel))`. Where the reference is at or near its clip the scene is at least that bright, so a frame that reads far
|
||||
darker there (an object moved: a star, the moon, a passing light) is left out and the clipped highlight stays (always
|
||||
on: a moving star otherwise left a black hole). Deghost: a per-pixel consistency test on ≤ 2048 px versions against the
|
||||
reference radiance (with a noise floor; the reference predicts down to 0.1 % of its clip, so a brighter frame's ghost in
|
||||
a night sky is caught), dilated and feathered masks; the union is the deghost overlay. Output: 16-bit float DNG.
|
||||
Lens corrections: frames are merged uncorrected, and the DNG's `OpcodeList3` carries the reference frame's
|
||||
(`WarpRectilinear`, `WarpRectilinear2`, `FixVignetteRadial`: a DNG's own, or a camera's correction a raw reader
|
||||
converts, e.g. Sony, Panasonic), re-expressed for its default crop (`Opcode::cropped`). So Optics ▸ Enable Lens
|
||||
@@ -52,7 +60,10 @@ scale in `BaselineExposure`; raw sources keep their DNG colour tags (camera RGB)
|
||||
|
||||
- HDR, 3 brackets ±2 EV with 14-bit noise and handheld shifts: alignment error < 0.3 px, exposure
|
||||
error < 0.02 EV, radiance median log error < 0.03 (noise-limited, bias < 0.01), recovered
|
||||
highlights < 0.05; moving object: ghost error 0.2–2.0 without deghosting, < 0.06 with High.
|
||||
highlights < 0.05; moving object: ghost error 0.2–2.0 without deghosting, < 0.06 with High; a star that moves
|
||||
across a night sky keeps its clipped core and, with High, leaves no ghost of the brightest frame.
|
||||
- Real brackets (Sony A7R VI, 67 MP, 2026-10-14): frames agree with the merged radiance within ~1 % from 3 to 90 % of
|
||||
their clip in all three channels; measured exposure steps match EXIF within 0.03 EV.
|
||||
- Panorama, 5 views over 182° with a 1.3× exposure drift: relative rotations < 0.1°, focal < 1 %,
|
||||
BA RMS ≈ 0.35 px; median log error vs ground truth < 0.02; no seam steps.
|
||||
|
||||
@@ -63,9 +74,13 @@ scale in `BaselineExposure`; raw sources keep their DNG colour tags (camera RGB)
|
||||
panoramas carry no lens corrections, nor does an HDR whose frames don't share an orientation (they are turned
|
||||
before merging).
|
||||
- No camera response calibration for JPEG brackets (sRGB decoding is taken as linear light).
|
||||
- AI Denoise doesn't run on merged DNGs (they are linear RGB; the denoiser takes Bayer raws), unlike Lightroom's.
|
||||
- Boundary Warp is a separable stretch, not a content-preserving mesh warp; Fill Edges is diffusion,
|
||||
not patch synthesis.
|
||||
- *Create Stack*: the result goes on top of a collapsed stack of its sources (`Catalog::stack_with_ops`); its
|
||||
caption also lists them.
|
||||
- Panorama output is limited to `maxMegapixels` (default 40) — blending memory is ~25 bytes/pixel.
|
||||
- HDR memory: every frame is held as 32-bit float RGB (~0.86 GB per 67 MP frame at the peak, plus ~1.2 GB); full-size
|
||||
photos are decoded one at a time. Measured on 67 MP raws: 3 frames 3.8 GB, 4 frames 4.7 GB, 5 frames 5.5 GB (was
|
||||
4.8 / 6.3 GB for 3 / 4). Half-float frames would halve the per-frame share.
|
||||
- The browser build has no file access for merges yet.
|
||||
|
||||
@@ -42,7 +42,7 @@ cited here still exists. Formerly docs/parity.md.
|
||||
| L. Presets (PRE) | 6 | 0 | 1 | 1 | 2/2 (100%) | 2/2 (100%) |
|
||||
| M. Versions & history (VER) | 5 | 0 | 0 | 0 | 1/1 (100%) | 3/3 (100%) |
|
||||
| N. Copy / paste / sync (SYNC) | 5 | 0 | 0 | 0 | 3/3 (100%) | 1/1 (100%) |
|
||||
| O. Merge (MERGE) | 4 | 0 | 0 | 0 | — | — |
|
||||
| O. Merge (MERGE) | 3 | 1 | 0 | 0 | — | — |
|
||||
| P. Enhance (ENH) | 0 | 0 | 2 | 0 | — | — |
|
||||
| Q. HDR (HDR) | 2 | 3 | 0 | 0 | — | — |
|
||||
| R. Video (VID) | 0 | 0 | 5 | 1 | — | 0/2 (0%) |
|
||||
@@ -353,10 +353,10 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
|
||||
|
||||
| Id | Feature | Tier | Status | Evidence | Missing / notes |
|
||||
|---|---|---|---|---|---|
|
||||
| LR-MERGE-HDR | HDR merge | P2 | ✅ | `cmd:merge.hdr`, `cmd:dialog.mergeHdr`, `crates/merge/src/frame.rs` (`Frame::lens`) | auto align, deghost None–High + overlay, auto settings, float DNG, Create Stack; the DNG keeps the reference frame's embedded lens corrections (tested by `hdr_merge_keeps_the_reference_frames_lens_corrections`); JPEG brackets treated as linear; raws without colour matrices of their own (CR2, NEF, ARW…) keep the colour they open with, written as DNG tags (`starting_colour_tags`; the look's chroma curve has no tag; panoramas too), issue #965 |
|
||||
| LR-MERGE-HDR | HDR merge | P2 | 🟡 | `cmd:merge.hdr`, `cmd:dialog.mergeHdr`, `crates/merge/src/frame.rs` (`Frame::lens`), `crates/merge/src/hdr.rs` | auto align, deghost None–High + overlay, auto settings, float DNG, Create Stack; the DNG keeps the reference frame's embedded lens corrections (tested by `hdr_merge_keeps_the_reference_frames_lens_corrections`); raws without colour matrices of their own (CR2, NEF, ARW…) keep the colour they open with, written as DNG tags (`starting_colour_tags`; the look's chroma curve has no tag; panoramas too), issue #965. Tested on real Sony A7R VI brackets (67 MP, 2026-10-14): frames agree with the merged radiance within ~1 % in all channels, exposure steps match EXIF within 0.03 EV. A clipped highlight of the reference that moved in other frames (a star at 200 mm) stays instead of becoming a black hole, and deghost (≤ 2048 px maps) catches a brighter frame's ghost in a dark sky (`a_clipped_star_of_the_reference_is_not_erased_by_frames_where_it_moved`, `deghost_removes_a_bright_frames_ghost_in_a_dark_sky`). 🟡 because: AI Denoise doesn't run on merged DNGs (Bayer only; Lightroom's does), JPEG brackets are treated as linear (no camera response curve), and every frame is held as 32-bit float (~0.86 GB per 67 MP frame, so 7 frames need ~7 GB); gap 29 |
|
||||
| LR-MERGE-PANO | Panorama | P2 | ✅ | `cmd:merge.panorama`, `cmd:dialog.mergePanorama` | spherical/cylindrical/perspective + auto, boundary warp, auto crop, fill edges (diffusion), DNG; no lens model / 360° wrap |
|
||||
| LR-MERGE-HDRPANO | HDR panorama | P2 | ✅ | `cmd:merge.hdrPanorama`, `cmd:dialog.mergeHdrPanorama` | brackets grouped by EXIF |
|
||||
| LR-MERGE-HEADLESS | Merge with last settings | P2 | ✅ | `cmd:merge.hdrLast`, `cmd:merge.panoramaLast`, `cmd:merge.hdrPanoramaLast` | no dialog; the options of the last merge of that kind (defaults the first time) |
|
||||
| LR-MERGE-HEADLESS | Merge with last settings | P2 | ✅ | `cmd:merge.hdrLast`, `cmd:merge.panoramaLast`, `cmd:merge.hdrPanoramaLast`, `cmd:merge.hdrBatch`, `crates/catalog/src/stacks.rs` (`bracket_sets`) | no dialog; the options of the last merge of that kind (defaults the first time). HDR for Each Bracket Set merges a batch like Lightroom's merge of stacks: one HDR per stack among the photos, then per bracket (photos taken less than a second apart, after the shutter time, at exposures ≥ ½ EV apart; bursts at one exposure are skipped), one after the other in the activity stack (cancelling one stops the rest; a failed set doesn't); CLI `merge hdr-batch FILES…` |
|
||||
|
||||
## P. Enhance (ENH)
|
||||
|
||||
@@ -562,7 +562,7 @@ visible menu bar yet: the menu model is only exposed through the control channel
|
||||
| MENU-PHOTO-FLIP | Flip horizontal / vertical | P0 | ✅ | `cmd:photo.flipHorizontal`, `cmd:photo.flipVertical` | |
|
||||
| MENU-PHOTO-CREATEVERSION | Create Version… | P1 | ✅ | `cmd:version.create` | no name prompt |
|
||||
| MENU-PHOTO-STACK | Stack submenu | P1 | ✅ | `cmd:stack.group`, `cmd:stack.ungroup`, `cmd:dialog.autoStack` | |
|
||||
| MENU-PHOTO-MERGE | Photo merge submenu | P2 | ✅ | `cmd:dialog.mergeHdr`, `cmd:dialog.mergePanorama`, `cmd:dialog.mergeHdrPanorama`, `cmd:merge.hdrLast` | |
|
||||
| MENU-PHOTO-MERGE | Photo merge submenu | P2 | ✅ | `cmd:dialog.mergeHdr`, `cmd:dialog.mergePanorama`, `cmd:dialog.mergeHdrPanorama`, `cmd:merge.hdrLast`, `cmd:merge.hdrBatch` | |
|
||||
| MENU-PHOTO-ENHANCE | Enhance… | P2 | ⬜ | | |
|
||||
| MENU-PHOTO-AUTO | Auto settings | P0 | ✅ | `cmd:develop.auto` | |
|
||||
| MENU-PHOTO-BW | Convert to B&W | P0 | ✅ | `cmd:develop.treatment` | |
|
||||
@@ -798,6 +798,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove
|
||||
|---|---|---|
|
||||
| 2026-10-14 | minor | LR-IMP-CAMERA-COVERAGE: 17 uncompressed Olympus and Samsung Adobe models verified on one CC0 file each; status unchanged (🟡) |
|
||||
| 2026-10-13 | minor | LR-EDIT-SECTION-SOLO: Solo Mode for Edit sections (Window ▸ Edit Sections, section-header menu, ⌥-click); KEYC-PANELS notes it |
|
||||
| 2026-10-14 | minor | LR-MERGE-HDR ✅ → 🟡: tested on real 67 MP Sony brackets; moving clipped highlights (black-hole stars) and dark-sky ghosts fixed, merge memory cut ~25 %; AI Denoise on merged DNGs, JPEG response curves and per-frame memory remain (gap 29). LR-MERGE-HEADLESS, MENU-PHOTO-MERGE: HDR for Each Bracket Set (`merge.hdrBatch`) |
|
||||
| 2026-10-13 | patch | LR-VIEW-COMPARE, LRC-LIB-COMPARE: ⌘/Ctrl- and Shift-clicks on the filmstrip in Compare change the selection as in the grid; the two photos shown stay selected and the active photo is unchanged (#187); statuses unchanged |
|
||||
| 2026-10-13 | minor | KEYC-SELECTONLYACTIVE: Edit → Select Only Active Photo (⌘⇧D) lands as a new row; status 🟡 (headless-tested, native macOS menu path unverified) |
|
||||
| 2026-10-13 | patch | LRC-LIB-CATALOG-IMPORT: a catalog whose tables are all still in its `-wal` file (main file schema format 0) opens instead of failing as an unsupported schema format; status unchanged |
|
||||
|
||||
@@ -75,7 +75,7 @@ from the checklist sections and the deep-dive documents.
|
||||
| Remove, heal, Enhance (denoise, Raw Details, Super Resolution, Lens Blur) | 7% | 45% | 30% | 70–130 h | checklist I 75%, P 0% |
|
||||
| Presets, profiles, versions, history, sync | 5% | 95% | 85% | 8–15 h | |
|
||||
| Export | 6% | 89% | 75% | 15–30 h | checklist S 88.9% |
|
||||
| Merge and HDR | 3% | 85% | 60% | 20–35 h | merge 100%, HDR 70% (no HDR display) |
|
||||
| Merge and HDR | 3% | 85% | 60% | 20–35 h | merge 88% (HDR merge 🟡: AI Denoise on merged DNGs, JPEG response, memory; gap 29), HDR 70% (no HDR display) |
|
||||
| Views, compare, zoom, culling | 5% | 90% | 70% | 15–30 h | zoom detail P0 gap |
|
||||
| Classic output modules (Map, Book, Slideshow, Print, Web, publish, tethering) | 4% | 15% | 10% | 90–150 h | Classic extras 48% counts many small keys; the modules themselves are mostly ⬜ |
|
||||
| Video | 1% | 0% | 0% | 25–45 h | catalogued only |
|
||||
@@ -302,6 +302,7 @@ P2 items 12–25 h; look tuning, performance, packaging, hardening 25–45 h.
|
||||
|---|---|---|
|
||||
| 2026-10-14 | minor | Localization ~52% → ~55% (mean of the 12 key languages): de, es, fr, ja and uk catalogs filled |
|
||||
| 2026-10-12 | minor | UI/UX remaining 34–58 h → 32–54 h (canvas tools by keyboard); headline numbers unchanged |
|
||||
| 2026-10-14 | minor | Merge and HDR: merge 100% → 88% (LR-MERGE-HDR ✅ → 🟡 after testing on real 67 MP brackets; gap 29); area percentages unchanged |
|
||||
| 2026-10-12 | minor | UI/UX remaining 40–70 h → 34–58 h (accessibility audit landed, ui-parity.md); percentages unchanged |
|
||||
| 2026-10-12 | minor | 22 more CR3 bodies verified on one CC0 file each (raw-parity.md): use-weighted share whose camera decodes 85% → 92%, RAW decoding 77% → 83%; full 60.7% → 61.2% (~61%), mainstream depth 72.1% → 74.0% (~49% → ~51%), essentials depth 77.5% → 78.0% (~64% → ~65%) |
|
||||
| 2026-10-12 | minor | 117 Nikon and Fujifilm bodies verified (raw-parity.md): use-weighted share whose camera decodes 85% → 86%, RAW decoding 77% (unchanged); mainstream depth 72.1% → 72.4%, essentials depth 77.5% → 77.6%; headline numbers unchanged |
|
||||
|
||||
@@ -2,6 +2,15 @@
|
||||
|
||||
## 2026-10-14
|
||||
|
||||
### HDR merges of moving lights, and many brackets at once
|
||||
- A star, the moon or a light that moves between the photos of a bracket (a night bracket on a tripod) no longer
|
||||
leaves a black hole where the middle photo had it clipped. With Deghost, the brightest photo's copy of it in a
|
||||
dark sky is now removed too.
|
||||
- Photo ▸ Photo Merge ▸ HDR for Each Bracket Set merges every bracket among the selected photos with the last HDR
|
||||
settings, one after the other: each stack, and photos taken less than a second apart at different exposures.
|
||||
From the command line: `lightcraft-cli merge hdr-batch FOLDER`.
|
||||
- Merging uses about a quarter less memory: three 67 MP raws need 3.8 GB instead of 4.8 GB.
|
||||
|
||||
### Hard-edged Heal and Clone spots
|
||||
- A spot with Feather at 0 no longer draws black pixels exactly on its radius. This also fixes spots at 0% Opacity
|
||||
changing pixels along their edge. The hard edge now leaves pixels on the radius unchanged.
|
||||
|
||||
Reference in New Issue
Block a user