mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-24 10:09:01 -06:00
LR-MERGE-HDR: keep the reference frame's lens corrections in HDR merges
Merged HDR DNGs lost the camera's embedded lens correction: load_frame strips the lens opcodes from each source (frames are merged uncorrected) and write_linear_dng wrote no OpcodeList3, so Optics ▸ Enable Lens Corrections did nothing on the result (Sony ILCE-7M3/7RM4A, Panasonic/Leica, DNGs with their own opcodes). Each raw frame now keeps the lens corrections it applies (the DNG 1.6 skip rule already applied), re-expressed for the default crop develop keeps by the new Opcode::cropped: the centre moves into the crop and coefficients are rescaled for the crop's farthest corner. HdrResult carries the reference frame's and the DNG writes them as its OpcodeList3. Frames turned before merging (mixed orientations) and panoramas carry none. Tests: cropped corrections give the crop of the corrected whole (WarpRectilinear, WarpRectilinear2 with and without reciprocal, FixVignetteRadial); an HDR merge of cropped, corrected brackets, upright and vertical, gives the same profile correction as its reference photo (fails without the fix). Real brackets not measured yet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015gRtdWF1E9358FfzhzYqCS
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -110,7 +116,21 @@ pub fn load_frame(bytes: &[u8], max_edge: Option<usize>, 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<usize>, 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<usize>, orient: bool) -> Result
|
||||
orientation: Orientation::from_exif(d.orientation),
|
||||
metadata,
|
||||
baseline_exposure: 0.0,
|
||||
lens: Vec::new(),
|
||||
};
|
||||
if orient {
|
||||
f.orient();
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Vec<u8>> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user