mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-14 19:27:01 -04:00
Merge PR #1423
This commit is contained in:
commit
03b6c8a95d
10 files changed
+235
-13
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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};
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user