diff --git a/crates/engine/src/merge.rs b/crates/engine/src/merge.rs index 53c7f546e..9dfa1b7e6 100644 --- a/crates/engine/src/merge.rs +++ b/crates/engine/src/merge.rs @@ -128,11 +128,14 @@ impl MergeJob { return Err("cancelled".into()); } let sub = |a: f32, b: f32| move |f: f32, s: &str| progress(a + (b - a) * f, s); + // the reference frame's lens corrections (HDR); panoramas carry none + let mut lens = Vec::new(); let (image, color, orientation, metadata, baseline, samples, crop, mut info, ghost) = match &self.kind { MergeKind::Hdr { hdr } => { let frames = self.load(false, progress, (0.0, 0.3))?; let p = sub(0.3, 0.9); - let r = lightcraft_merge::merge_hdr(frames, hdr, &p).map_err(err)?; + let mut r = lightcraft_merge::merge_hdr(frames, hdr, &p).map_err(err)?; + lens = std::mem::take(&mut r.lens); let info = json!({ "reference": self.sources[r.reference].0.0, "ev": r.ev, "evExif": r.ev_exif, @@ -181,6 +184,7 @@ impl MergeJob { orientation: r.orientation, metadata: r.metadata, baseline_exposure: r.baseline_exposure, + lens: Vec::new(), }; f.orient(); merged.push(f); @@ -212,7 +216,7 @@ impl MergeJob { metadata.caption = Some(format!("Merged from {}", names.join(", "))); } metadata.software = Some("LightCraft Photo Merge".into()); - let dng = lightcraft_merge::write_linear_dng(&image, &color, orientation, &metadata, baseline, samples).map_err(err)?; + let dng = lightcraft_merge::write_linear_dng(&image, &color, orientation, &metadata, baseline, samples, &lens).map_err(err)?; info["width"] = json!(image.width); info["height"] = json!(image.height); let preview = match self.preview { diff --git a/crates/engine/src/tests_merge.rs b/crates/engine/src/tests_merge.rs index a0784dd15..42049a4e8 100644 --- a/crates/engine/src/tests_merge.rs +++ b/crates/engine/src/tests_merge.rs @@ -67,6 +67,57 @@ fn hdr_merge_command_creates_and_imports_a_dng() { 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); + } +} + #[test] fn panorama_command_with_auto_crop() { let dir = temp_dir("pano"); diff --git a/crates/merge/src/frame.rs b/crates/merge/src/frame.rs index 360c7424e..b7c583220 100644 --- a/crates/merge/src/frame.rs +++ b/crates/merge/src/frame.rs @@ -6,7 +6,7 @@ use lightcraft_geom::Orientation; use lightcraft_meta::Metadata; use lightcraft_raster::Rgb32f; use lightcraft_raster::resample::{Filter, fit}; -use lightcraft_raw::ColorData; +use lightcraft_raw::{ColorData, Opcode}; use crate::{MergeError, Result}; @@ -36,6 +36,10 @@ pub struct Frame { pub metadata: Metadata, /// Raw files: the source's `BaselineExposure` (EV), carried into the output. pub baseline_exposure: f64, + /// Raw files: the lens corrections the source applies (`WarpRectilinear`, `WarpRectilinear2`, `FixVignetteRadial`, + /// in order), re-expressed for `image` (the default crop, not yet turned), for the merged DNG to carry. Empty for + /// other sources and once [`Frame::orient`] has turned the pixels. + pub lens: Vec, } impl Frame { @@ -53,6 +57,7 @@ impl Frame { orientation: Orientation::Normal, metadata: Metadata::default(), baseline_exposure: 0.0, + lens: Vec::new(), } } @@ -63,11 +68,12 @@ impl Frame { self.image.height } - /// Apply the display orientation to the pixels. + /// Apply the display orientation to the pixels. The lens corrections, written for the unturned pixels, are dropped. pub fn orient(&mut self) { if self.orientation != Orientation::Normal { self.image = self.image.oriented(self.orientation); self.orientation = Orientation::Normal; + self.lens.clear(); } } } @@ -110,7 +116,21 @@ pub fn load_frame(bytes: &[u8], max_edge: Option, orient: bool) -> Result let exposure = exif_exposure(&metadata); if lightcraft_raw::probe(bytes).is_some() { let mut raw = lightcraft_raw::decode(bytes).map_err(|e| MergeError::Decode(e.to_string()))?; - // geometric lens corrections stay with the develop settings ("profile corrections") + // Lens corrections stay with the develop settings ("profile corrections"): frames are merged uncorrected, and + // the output DNG carries the reference frame's corrections, re-expressed for the default crop `develop` keeps. + // The DNG 1.6 skip rule is applied here, so no `WarpRectilinear2` is left optional. + let (aw, ah) = (raw.active_area.width, raw.active_area.height); + let crop = raw.develop_crop(aw, ah); + let lens = lightcraft_raw::opcodes::applied_ops(&raw.opcodes.list3) + .filter(|op| op.is_lens_correction()) + .filter_map(|op| op.cropped(aw, ah, crop)) + .map(|op| match op { + Opcode::WarpRectilinear2 { planes, center, reciprocal, .. } => { + Opcode::WarpRectilinear2 { planes, center, reciprocal, optional: false } + } + op => op, + }) + .collect(); raw.opcodes.list3.retain(|op| !op.is_lens_correction()); let small = max_edge.is_some_and(|m| m <= 1200); let method = if small { lightcraft_raw::Method::Bilinear } else { lightcraft_raw::Method::Ahd }; @@ -133,6 +153,7 @@ pub fn load_frame(bytes: &[u8], max_edge: Option, orient: bool) -> Result orientation: raw.orientation, metadata: if raw.metadata == Metadata::default() { metadata } else { raw.metadata.clone() }, baseline_exposure: raw.color.baseline_exposure, + lens, }; if f.exposure.is_none() { f.exposure = exif_exposure(&f.metadata); @@ -163,6 +184,7 @@ pub fn load_frame(bytes: &[u8], max_edge: Option, orient: bool) -> Result orientation: Orientation::from_exif(d.orientation), metadata, baseline_exposure: 0.0, + lens: Vec::new(), }; if orient { f.orient(); diff --git a/crates/merge/src/hdr.rs b/crates/merge/src/hdr.rs index 7c596c4a7..59aa5286c 100644 --- a/crates/merge/src/hdr.rs +++ b/crates/merge/src/hdr.rs @@ -83,6 +83,8 @@ pub struct HdrResult { pub orientation: lightcraft_geom::Orientation, pub metadata: lightcraft_meta::Metadata, pub baseline_exposure: f64, + /// The reference frame's lens corrections, for `radiance` ([`Frame::lens`]). + pub lens: Vec, } #[inline] @@ -277,6 +279,7 @@ pub fn merge_hdr(mut frames: Vec, opts: &HdrOptions, progress: &Progress) orientation: rf.orientation, metadata: rf.metadata.clone(), baseline_exposure: rf.baseline_exposure, + lens: rf.lens.clone(), }) } diff --git a/crates/merge/src/output.rs b/crates/merge/src/output.rs index e020ac45d..5d4b02e27 100644 --- a/crates/merge/src/output.rs +++ b/crates/merge/src/output.rs @@ -8,12 +8,14 @@ //! still camera RGB. //! - Other sources: the "camera" is the file's linear RGB space; `ColorMatrix1` = XYZ(D65) → RGB, //! `ForwardMatrix1` = RGB → XYZ(D50), `AsShotNeutral` = (1, 1, 1). +//! - Lens corrections (an HDR merge's: the reference frame's, [`crate::Frame::lens`]) go in `OpcodeList3`, where the +//! develop settings' profile corrections find them as they do in the sources. use lightcraft_color::{D50, D65, Mat3, bradford}; use lightcraft_geom::Orientation; use lightcraft_meta::Metadata; use lightcraft_raster::Rgb32f; -use lightcraft_raw::{BlackLevel, ColorData, DngCompression, DngWriteOptions, OpcodeLists, RawData, RawFormat, RawImage, Rect}; +use lightcraft_raw::{BlackLevel, ColorData, DngCompression, DngWriteOptions, Opcode, OpcodeLists, RawData, RawFormat, RawImage, Rect}; use crate::frame::FrameColor; use crate::{MergeError, Result}; @@ -58,7 +60,8 @@ pub fn color_data(color: &FrameColor) -> ColorData { } } -/// Encode `img` (scene-linear, 1.0 = source white) as a LinearRaw DNG. +/// Encode `img` (scene-linear, 1.0 = source white) as a LinearRaw DNG, with `lens` (lens corrections for `img`, not +/// yet turned by `orientation`) as its `OpcodeList3`. pub fn write_linear_dng( img: &Rgb32f, color: &FrameColor, @@ -66,6 +69,7 @@ pub fn write_linear_dng( metadata: &Metadata, baseline_exposure: f64, samples: DngSamples, + lens: &[Opcode], ) -> Result> { let (w, h) = (img.width, img.height); if w == 0 || h == 0 { @@ -106,7 +110,7 @@ pub fn write_linear_dng( color: cd, wb_multipliers: None, linearized: false, - opcodes: OpcodeLists::default(), + opcodes: OpcodeLists { list3: lens.to_vec(), ..Default::default() }, metadata: meta, }; let compression = DngCompression::Deflate { tile: 256, half: samples == DngSamples::Half }; @@ -121,7 +125,7 @@ mod tests { fn linear_dng_round_trips_through_the_raw_decoder() { let img = Rgb32f::from_fn(80, 60, |x, y| [x as f32 / 20.0, y as f32 / 30.0, 0.3]); let color = FrameColor::Linear { to_xyz_d50: bradford(D65, D50).mul(&lightcraft_color::SRGB.to_xyz()) }; - let bytes = write_linear_dng(&img, &color, Orientation::Normal, &Metadata::default(), 0.0, DngSamples::Half).unwrap(); + let bytes = write_linear_dng(&img, &color, Orientation::Normal, &Metadata::default(), 0.0, DngSamples::Half, &[]).unwrap(); let raw = lightcraft_raw::decode(&bytes).unwrap(); assert_eq!((raw.width, raw.height, raw.cpp), (80, 60, 3)); let gain = 2f32.powf(raw.color.baseline_exposure as f32); @@ -144,7 +148,7 @@ mod tests { for c in 0..3 { assert!((red[c] / k - want[c]).abs() < 5e-3, "{red:?} vs {want:?}"); } - let u16 = write_linear_dng(&img, &color, Orientation::Normal, &Metadata::default(), 0.0, DngSamples::U16).unwrap(); + let u16 = write_linear_dng(&img, &color, Orientation::Normal, &Metadata::default(), 0.0, DngSamples::U16, &[]).unwrap(); assert!(lightcraft_raw::decode(&u16).is_ok()); } } diff --git a/crates/merge/src/synth.rs b/crates/merge/src/synth.rs index d15646ecb..223cb3021 100644 --- a/crates/merge/src/synth.rs +++ b/crates/merge/src/synth.rs @@ -48,7 +48,7 @@ pub fn bracket_dngs(w: usize, h: usize, evs: &[f64]) -> Result>> { iso: Some(100), ..Default::default() }; - write_linear_dng(&img, &srgb_color(), Orientation::Normal, &meta, 0.0, DngSamples::U16) + write_linear_dng(&img, &srgb_color(), Orientation::Normal, &meta, 0.0, DngSamples::U16, &[]) }) .collect() } diff --git a/crates/raw/src/opcodes.rs b/crates/raw/src/opcodes.rs index 1d3a2c546..bcc59ab57 100644 --- a/crates/raw/src/opcodes.rs +++ b/crates/raw/src/opcodes.rs @@ -129,6 +129,76 @@ impl Opcode { pub fn is_lens_correction(&self) -> bool { matches!(self, Opcode::WarpRectilinear { .. } | Opcode::WarpRectilinear2 { .. } | Opcode::FixVignetteRadial { .. }) } + + /// This lens correction re-expressed for the part `crop` of the `width × height` image it was written for: applied + /// to the cropped image it gives the crop of the corrected whole. The centre moves into the crop and the + /// coefficients are rescaled for the crop's farthest corner, the radius every coordinate is normalised by. Centres + /// use the warps' convention (0 and 1 at the first and last pixel), also for `FixVignetteRadial`, as the develop + /// pipeline reads them. `None` for other opcodes, an image or crop under 2 × 2, or a crop outside the image. + pub fn cropped(&self, width: usize, height: usize, crop: crate::Rect) -> Option { + let fits = crop.x.checked_add(crop.width).is_some_and(|r| r <= width) && crop.y.checked_add(crop.height).is_some_and(|b| b <= height); + if width < 2 || height < 2 || crop.width < 2 || crop.height < 2 || !fits { + return None; + } + let (aw, ah) = (width as f64 - 1.0, height as f64 - 1.0); + let (cw, ch) = (crop.width as f64 - 1.0, crop.height as f64 - 1.0); + // the centre in the crop, and the ratio of the crop's normalising radius to the image's + let reframe = |c: [f64; 2]| -> Option<([f64; 2], f64)> { + let (px, py) = (c[0] * aw, c[1] * ah); + let (qx, qy) = (px - crop.x as f64, py - crop.y as f64); + let s = max_radius(qx, qy, cw, ch) / max_radius(px, py, aw, ah); + (s.is_finite() && s > 0.0 && qx.is_finite() && qy.is_finite()).then_some(([qx / cw, qy / ch], s)) + }; + match self { + // source radius r·(kr0 + kr1 r² + kr2 r⁴ + kr3 r⁶) and tangential terms quadratic in r: with r = r'·s, + // kr_i scales by s^2i and kt by s + Opcode::WarpRectilinear { planes, center } => { + let (center, s) = reframe(*center)?; + let planes = planes + .iter() + .map(|k| { + let mut k = *k; + for (i, v) in k[..4].iter_mut().enumerate() { + *v *= s.powi(2 * i as i32); + } + k[4] *= s; + k[5] *= s; + k + }) + .collect(); + Some(Opcode::WarpRectilinear { planes, center }) + } + // radial factor Σ kr_i r^i between the valid radii: kr_i scales by s^i, the radii by 1/s + Opcode::WarpRectilinear2 { planes, center, reciprocal, optional } => { + let (center, s) = reframe(*center)?; + let planes = planes + .iter() + .map(|k| { + let mut k = *k; + for (i, v) in k[..15].iter_mut().enumerate() { + *v *= s.powi(i as i32); + } + k[15] *= s; + k[16] *= s; + k[17] /= s; + k[18] /= s; + k + }) + .collect(); + Some(Opcode::WarpRectilinear2 { planes, center, reciprocal: *reciprocal, optional: *optional }) + } + // gain 1 + Σ k_i r^(2i+2) + Opcode::FixVignetteRadial { k, center } => { + let (center, s) = reframe(*center)?; + let mut k = *k; + for (i, v) in k.iter_mut().enumerate() { + *v *= s.powi(2 * i as i32 + 2); + } + Some(Opcode::FixVignetteRadial { k, center }) + } + _ => None, + } + } } /// The opcodes of a list that a reader applies, in order: all of them, minus the DNG 1.6 skip rule (specification @@ -1032,4 +1102,60 @@ pub(crate) mod tests { let bad = Opcode::Unknown { id: 14, flags: 1, params: vec![] }; assert!(applied_ops(&[bad, wr]).any(|o| matches!(o, Opcode::WarpRectilinear { .. }))); } + + /// `cropped` re-expresses a lens correction for a crop: the corrected crop is the crop of the corrected whole. A + /// linear ramp makes bilinear sampling exact, so only the borders (where the warp reaches past the crop) differ. + #[test] + fn cropped_lens_corrections_correct_the_crop_like_the_whole() { + let (w, h) = (90usize, 64usize); + let crop = crate::Rect::new(7, 5, 70, 50); + let ramp = Rgb32f::from_fn(w, h, |x, y| [0.002 * x as f32 + 0.001 * y as f32 + 0.1, 0.3 + 0.0015 * y as f32, 0.2]); + let mut wr2 = [0.0; WR2_LEN]; + wr2[..5].copy_from_slice(&[1.0, 0.01, -0.03, 0.0, 0.01]); + wr2[15] = 0.002; + wr2[16] = -0.001; + wr2[17] = 0.05; + wr2[18] = 0.9; + let warps = [ + Opcode::WarpRectilinear { planes: vec![[0.97, 0.04, -0.01, 0.002, 0.003, -0.002]], center: [0.47, 0.53] }, + Opcode::WarpRectilinear2 { planes: vec![wr2], center: [0.52, 0.46], reciprocal: false, optional: true }, + Opcode::WarpRectilinear2 { planes: vec![wr2], center: [0.52, 0.46], reciprocal: true, optional: false }, + ]; + for op in &warps { + let mut whole = ramp.clone(); + apply_list3(std::slice::from_ref(op), &mut whole); + let re = op.cropped(w, h, crop).unwrap(); + let mut part = ramp.clone().into_crop(crop.x, crop.y, crop.width, crop.height); + apply_list3(std::slice::from_ref(&re), &mut part); + let mut worst = 0f32; + for y in 6..crop.height - 6 { + for x in 6..crop.width - 6 { + let (a, b) = (whole.data[(y + crop.y) * w + x + crop.x], part.data[y * crop.width + x]); + worst = (0..3).map(|c| (a[c] - b[c]).abs()).fold(worst, f32::max); + } + } + assert!(worst < 1e-4, "{op:?} → {re:?}: {worst}"); + } + // the vignette, in the warps' centre convention the develop pipeline reads it with + let gain = |k: &[f64; 5], c: [f64; 2], w: usize, h: usize, x: f64, y: f64| { + let (aw, ah) = (w as f64 - 1.0, h as f64 - 1.0); + let (px, py) = (c[0] * aw, c[1] * ah); + let r2 = ((x - px).powi(2) + (y - py).powi(2)) / max_radius(px, py, aw, ah).powi(2); + 1.0 + (0..5).map(|i| k[i] * r2.powi(i as i32 + 1)).sum::() + }; + let (k, center) = ([-0.3, 0.1, -0.05, 0.02, -0.01], [0.45, 0.55]); + let Some(Opcode::FixVignetteRadial { k: k2, center: c2 }) = (Opcode::FixVignetteRadial { k, center }).cropped(w, h, crop) else { + panic!("vignette") + }; + for (x, y) in [(0.0, 0.0), (69.0, 49.0), (33.5, 20.0), (69.0, 0.0), (12.0, 40.0)] { + let (a, b) = (gain(&k, center, w, h, x + crop.x as f64, y + crop.y as f64), gain(&k2, c2, crop.width, crop.height, x, y)); + assert!((a - b).abs() < 1e-12, "({x}, {y}): {a} vs {b}"); + } + // no change for a full-frame "crop"; nothing for other opcodes, tiny images or a crop past the edge + assert_eq!(warps[0].cropped(w, h, crate::Rect::new(0, 0, w, h)).unwrap(), warps[0]); + assert!(Opcode::TrimBounds { top: 0, left: 0, bottom: 2, right: 2 }.cropped(w, h, crop).is_none()); + assert!(warps[0].cropped(1, h, crate::Rect::new(0, 0, 1, h)).is_none()); + assert!(warps[0].cropped(w, h, crate::Rect::new(30, 0, 70, 50)).is_none()); + assert!(warps[0].cropped(w, h, crate::Rect::new(usize::MAX, 0, 70, 50)).is_none()); + } } diff --git a/docs/merge.md b/docs/merge.md index e78c356fb..7c298cb10 100644 --- a/docs/merge.md +++ b/docs/merge.md @@ -27,6 +27,11 @@ homography (the simplest model that explains ≥ 97 % of the matches). Exposure 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. +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 +Corrections works on the result as on the sources; a vertical bracket keeps its pixels unturned and its +corrections. **Panorama** (`pano/`): MOPS-style features, pairwise RANSAC homographies with Brown & Lowe's verification, focal from homographies (or EXIF 35 mm), rotations chained along a maximum spanning @@ -54,7 +59,9 @@ scale in `BaselineExposure`; raw sources keep their DNG colour tags (camera RGB) ## Limits (known gaps) - No lens-distortion model in the panorama bundle adjustment (rectilinear lenses assumed); no - 360° wrap-around; flat (translating-camera) scans are fitted with the rotation model. + 360° wrap-around; flat (translating-camera) scans are fitted with the rotation model. Panoramas and HDR + 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). - Boundary Warp is a separable stretch, not a content-preserving mesh warp; Fill Edges is diffusion, not patch synthesis. diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 77ae26e4c..ca4b71d5a 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -346,7 +346,7 @@ 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` | auto align, deghost None–High + overlay, auto settings, float DNG, Create Stack; JPEG brackets treated as linear | +| 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; measured on synthetic brackets only (docs/merge.md) | | 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) | diff --git a/docs/whats-new.md b/docs/whats-new.md index fb8b285a3..fa14499a7 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -1,5 +1,12 @@ # What's new in LightCraft +## 2026-10-13 + +### HDR merges keep the lens correction +- An HDR made with Photo ▸ Photo Merge ▸ HDR now keeps the lens correction the photos carry (Sony's, Panasonic's + and Leica's, and the one some DNGs carry, e.g. from drones): Optics ▸ Enable Lens Corrections straightens it as it + does the photos it was made from. HDRs merged before this don't have it; merge them again. + ## 2026-10-11 ### JPEG XL export