This commit is contained in:
Brandon Thomas committed 2026-10-13 11:48:34 -04:00
commit 03b6c8a95d
10 files changed
+235 -13

No files matched your search

+6 -2
View File
@@ -130,11 +130,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,
@@ -183,6 +186,7 @@ impl MergeJob {
orientation: r.orientation,
metadata: r.metadata,
baseline_exposure: r.baseline_exposure,
lens: Vec::new(),
};
f.orient();
merged.push(f);
@@ -214,7 +218,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 {
+51
View File
@@ -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);
}
}
/// 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]
+25 -3
View File
@@ -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<Opcode>,
}
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();
}
}
}
@@ -120,7 +126,21 @@ pub fn load_frame_with(bytes: &[u8], max_edge: Option<usize>, orient: bool, colo
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());
if let Some(color) = colour(&mut raw, bytes) {
raw.color = color;
@@ -146,6 +166,7 @@ pub fn load_frame_with(bytes: &[u8], max_edge: Option<usize>, orient: bool, colo
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);
@@ -176,6 +197,7 @@ pub fn load_frame_with(bytes: &[u8], max_edge: Option<usize>, orient: bool, colo
orientation: Orientation::from_exif(d.orientation),
metadata,
baseline_exposure: 0.0,
lens: Vec::new(),
};
if orient {
f.orient();
+3
View File
@@ -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<lightcraft_raw::Opcode>,
}
#[inline]
@@ -277,6 +279,7 @@ pub fn merge_hdr(mut frames: Vec<Frame>, opts: &HdrOptions, progress: &Progress)
orientation: rf.orientation,
metadata: rf.metadata.clone(),
baseline_exposure: rf.baseline_exposure,
lens: rf.lens.clone(),
})
}
+9 -5
View File
@@ -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};
@@ -59,7 +61,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,
@@ -67,6 +70,7 @@ pub fn write_linear_dng(
metadata: &Metadata,
baseline_exposure: f64,
samples: DngSamples,
lens: &[Opcode],
) -> Result<Vec<u8>> {
let (w, h) = (img.width, img.height);
if w == 0 || h == 0 {
@@ -107,7 +111,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 };
@@ -122,7 +126,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);
@@ -145,7 +149,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());
}
}
+1 -1
View File
@@ -48,7 +48,7 @@ pub fn bracket_dngs(w: usize, h: usize, evs: &[f64]) -> Result<Vec<Vec<u8>>> {
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()
}
+126
View File
@@ -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<Opcode> {
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::<f64>()
};
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());
}
}
+8 -1
View File
@@ -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.
+1 -1
View File
@@ -347,7 +347,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; 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`) | 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-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) |
+5
View File
@@ -2,6 +2,11 @@
## 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.
### Slider drags on large raws
- Dragging a slider no longer re-renders the edited photo's filmstrip thumbnail on every frame; it follows once you
let go. On a 64 MP raw the first of those renders decoded the whole file again, which held up the loupe's