diff --git a/crates/catalog/src/tests.rs b/crates/catalog/src/tests.rs index 20f4b728..5196c7ca 100644 --- a/crates/catalog/src/tests.rs +++ b/crates/catalog/src/tests.rs @@ -466,7 +466,7 @@ fn set_embedded_lens_is_undoable_and_journaled() { photo: Box::new(Photo::new(a, Source::File { path: "/x.rw2".into() }, "x.rw2", "RW2", 4000, 3000, "2026-10-06T00:00:00")), }) .unwrap(); - let lens = lightcraft_develop::EmbeddedLens { warp: Some(Default::default()), vignette: None }; + let lens = lightcraft_develop::EmbeddedLens { warp: Some(Default::default()), vignette: None, stages: Default::default() }; let op = Op::SetEmbeddedLens { id: a, lens: Some(Box::new(lens)) }; let op: Op = serde_json::from_str(&serde_json::to_string(&op).unwrap()).unwrap(); let inv = c.apply(op).unwrap(); diff --git a/crates/develop/src/settings.rs b/crates/develop/src/settings.rs index 8ff0c131..57c818a9 100644 --- a/crates/develop/src/settings.rs +++ b/crates/develop/src/settings.rs @@ -656,6 +656,33 @@ impl Default for Optics { pub struct EmbeddedLens { pub warp: Option, pub vignette: Option, + /// A DNG 1.6 `WarpRectilinear2` chain (up to [`WARP_STAGES`] stages, in `OpcodeList3` order; each stage warps + /// the result of the one before it). When any is set, `warp` is `None`: the chain holds every warp opcode. + pub stages: [Option; WARP_STAGES], +} + +/// Most warp stages an [`EmbeddedLens`] chain keeps. +pub const WARP_STAGES: usize = 3; + +/// One DNG `WarpRectilinear2` (or a `WarpRectilinear` in a chain with one): per plane (R, G, B) +/// `[kr0 .. kr14, kt0, kt1, min_valid_radius, max_valid_radius]`. For an output point at offset +/// `d = (p − center) / radius` with `r = |d|` and `f = Σ krₚ·clamp(r, min, max)ᵖ`, the source point is +/// `center + radius · (d·f + tangential(d))`, or `d / f` for the radial part when `reciprocal`. +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct EmbeddedWarpStage { + pub planes: [[f64; 19]; 3], + /// Optical centre, normalized to the oriented image (0..1). + pub center: Point, + /// Normalisation radius as a fraction of the oriented image's long edge. + pub radius: f64, + pub reciprocal: bool, +} + +impl EmbeddedLens { + /// Whether a `WarpRectilinear2` chain is set. + pub fn has_stages(&self) -> bool { + self.stages.iter().any(Option::is_some) + } } /// DNG `WarpRectilinear`: per plane (R, G, B) `[kr0, kr1, kr2, kr3, kt0, kt1]`. For an output (corrected) point at diff --git a/crates/engine/src/cmd/convert.rs b/crates/engine/src/cmd/convert.rs index 2b1b368b..bb4519a3 100644 --- a/crates/engine/src/cmd/convert.rs +++ b/crates/engine/src/cmd/convert.rs @@ -381,6 +381,7 @@ mod tests { lightcraft_develop::EmbeddedLens { warp: Some(lightcraft_develop::EmbeddedWarp { planes: [[1.0, -0.1, 0.03, 0.0, 0.0, 0.0]; 3], ..Default::default() }), vignette: None, + stages: Default::default(), } } fn old_rw2() -> lightcraft_catalog::Photo { diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index aa04cde5..c7a429a3 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -44,12 +44,13 @@ fn meta_of(m: &lightcraft_meta::Metadata) -> (Meta, Option) { (meta, m.capture_time.as_ref().map(|d| d.to_iso())) } -/// Lens corrections embedded in a raw's `OpcodeList3` (`WarpRectilinear`, `FixVignetteRadial`: a DNG's own, or the +/// Lens corrections embedded in a raw's `OpcodeList3` (`WarpRectilinear`, `WarpRectilinear2`, `FixVignetteRadial`: a DNG's own, or the /// raw reader's equivalent of the camera's correction, e.g. Panasonic / Leica RW2 distortion), re-expressed for the /// default-cropped, EXIF-oriented image. These are the only "profile" corrections LightCraft applies. pub fn embedded_lens(raw: &lightcraft_raw::RawInfo) -> Option { - use lightcraft_develop::{EmbeddedLens, EmbeddedVignette, EmbeddedWarp}; + use lightcraft_develop::{EmbeddedLens, EmbeddedVignette, EmbeddedWarp, EmbeddedWarpStage, WARP_STAGES}; use lightcraft_geom::Point; + use lightcraft_raw::Opcode; let (aw, ah) = (raw.active_area.width as f64, raw.active_area.height as f64); if aw < 2.0 || ah < 2.0 { return None; @@ -69,21 +70,54 @@ pub fn embedded_lens(raw: &lightcraft_raw::RawInfo) -> Option = lightcraft_raw::opcodes::applied_ops(&raw.opcodes.list3).collect(); + // A DNG with a WarpRectilinear2 keeps every warp opcode as a chain of stages (each warps the one before's output); + // without one the first WarpRectilinear is the lens warp, as it always was. + let chain = ops.iter().any(|op| matches!(op, Opcode::WarpRectilinear2 { .. })); + let mut stages = Vec::new(); + for op in ops { match op { - lightcraft_raw::Opcode::WarpRectilinear { planes, center } if !planes.is_empty() && lens.warp.is_none() => { + Opcode::WarpRectilinear2 { planes, center, reciprocal, .. } if !planes.is_empty() => { + let (center, radius) = centre(*center); + let p = |i: usize| planes[i.min(planes.len() - 1)]; + stages.push(EmbeddedWarpStage { planes: [p(0), p(1), p(2)], center, radius, reciprocal: *reciprocal }); + } + Opcode::WarpRectilinear { planes, center } if chain && !planes.is_empty() => { + // WarpRectilinear is the even-power case: kr0 + kr1 r² + kr2 r⁴ + kr3 r⁶, no radius limit + let (center, radius) = centre(*center); + let p = |i: usize| { + let k = planes[i.min(planes.len() - 1)]; + let mut s = [0.0; lightcraft_raw::opcodes::WR2_LEN]; + s[0] = k[0]; + s[2] = k[1]; + s[4] = k[2]; + s[6] = k[3]; + s[15] = k[4]; + s[16] = k[5]; + s[18] = 1e6; + s + }; + stages.push(EmbeddedWarpStage { planes: [p(0), p(1), p(2)], center, radius, reciprocal: false }); + } + Opcode::WarpRectilinear { planes, center } if !chain && !planes.is_empty() && lens.warp.is_none() => { let (center, radius) = centre(*center); let p = |i: usize| planes[i.min(planes.len() - 1)]; lens.warp = Some(EmbeddedWarp { planes: [p(0), p(1), p(2)], center, radius }); } - lightcraft_raw::Opcode::FixVignetteRadial { k, center } if lens.vignette.is_none() => { + Opcode::FixVignetteRadial { k, center } if lens.vignette.is_none() => { let (center, radius) = centre(*center); lens.vignette = Some(EmbeddedVignette { k: *k, center, radius }); } _ => {} } } - if lens.warp.is_none() && lens.vignette.is_none() { + // (more stages than a lens keeps: the first ones; real files carry two) + for (slot, st) in lens.stages.iter_mut().zip(stages.into_iter().take(WARP_STAGES)) { + *slot = Some(st); + } + if lens.warp.is_none() && lens.vignette.is_none() && !lens.has_stages() { return None; } Some(lightcraft_pipeline::optics::reorient_lens(&lens, raw.orientation, cw, ch)) diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index fb42d7b9..8c09c3e6 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -62,7 +62,7 @@ impl SettingsHashes { } /// Bump when the pipeline's output changes, to invalidate cached thumbnails. -pub const RENDER_CACHE_VERSION: u64 = 57; +pub const RENDER_CACHE_VERSION: u64 = 58; /// Thumbnails render at one of these long edges (so window/cell size changes reuse the cache). pub const THUMB_SIZES: [usize; 4] = [128, 256, 384, 512]; diff --git a/crates/geom/src/real.rs b/crates/geom/src/real.rs index 07da59f3..dafa76fc 100644 --- a/crates/geom/src/real.rs +++ b/crates/geom/src/real.rs @@ -21,12 +21,23 @@ pub trait Real: { /// `if |v| < tiny { tiny } else { v }`: the guard of a denominator against zero ([`crate::Homography::apply`]). fn clamp_tiny(self, tiny: f64) -> Self; + /// The square root of a non-negative value (a negative one gives NaN, or for an [`Interval`] its lower bound + /// is taken as 0, since the value is a sum of squares). + fn sqrt_nonneg(self) -> Self; + /// `self` limited to `lo..=hi` (`lo <= hi`). + fn clamp_range(self, lo: f64, hi: f64) -> Self; } impl Real for f64 { fn clamp_tiny(self, tiny: f64) -> f64 { if self.abs() < tiny { tiny } else { self } } + fn sqrt_nonneg(self) -> f64 { + self.sqrt() + } + fn clamp_range(self, lo: f64, hi: f64) -> f64 { + if lo <= hi { self.clamp(lo, hi) } else { self } + } } /// A closed interval `[lo, hi]` with outward rounding, or *undecided* (both NaN). @@ -165,6 +176,23 @@ impl Real for Interval { fn clamp_tiny(self, tiny: f64) -> Interval { if self.lo >= tiny || self.hi <= -tiny { self } else { Interval::UNDECIDED } } + + /// `sqrt` is monotonic and correctly rounded, so one ulp outward encloses the `f64` result. + fn sqrt_nonneg(self) -> Interval { + if !self.is_decided() || self.hi < 0.0 { + return Interval::UNDECIDED; + } + Interval::outward(self.lo.max(0.0).sqrt(), self.hi.sqrt()) + } + + /// Clamping is monotonic and exact. + fn clamp_range(self, lo: f64, hi: f64) -> Interval { + let ordered = lo <= hi; + if !self.is_decided() || !ordered { + return Interval::UNDECIDED; + } + Interval::new(self.lo.clamp(lo, hi), self.hi.clamp(lo, hi)) + } } #[cfg(test)] @@ -201,6 +229,10 @@ mod tests { prop_assert!(k == 0.0 || contains(ia / k, a / k)); let c = ib.clamp_tiny(1.0); prop_assert!(!c.is_decided() || contains(c, b.clamp_tiny(1.0))); + let sq = (ia * ia).sqrt_nonneg(); + prop_assert!(!sq.is_decided() || contains(sq, (a * a).sqrt())); + let cl = ib.clamp_range(-1.0, 2.0); + prop_assert!(contains(cl, b.clamp_range(-1.0, 2.0))); } } diff --git a/crates/gpu/src/render.rs b/crates/gpu/src/render.rs index 45a5a5f6..9f4fd283 100644 --- a/crates/gpu/src/render.rs +++ b/crates/gpu/src/render.rs @@ -592,7 +592,8 @@ pub fn render( // 1. geometry (on the CPU when the source exceeds the device's buffer limit) let sampled = match &cached { Some(e) => e.sampled.clone(), - None if gpu.fits(src.data.len() * 3) => { + // (a WarpRectilinear2 chain also goes to the CPU: its degree-14 polynomial needs f64) + None if gpu.fits(src.data.len() * 3) && !plan.frame.warp.as_ref().is_some_and(|w| w.needs_f64()) => { let upload = || gpu.upload(rgb_words(src)); // (a source too big for a view's stages to keep is the one shared copy: a window is // cut from the photo's own pixels, and what its stages keep is its own sampled pixels) @@ -1058,6 +1059,7 @@ mod tests { radius: 0.6, }), vignette: None, + stages: Default::default(), }; let mut s = DevelopSettings::default(); s.optics.lens_profile = true; diff --git a/crates/gpu/tests/equivalence.rs b/crates/gpu/tests/equivalence.rs index b10836c3..fff9301b 100644 --- a/crates/gpu/tests/equivalence.rs +++ b/crates/gpu/tests/equivalence.rs @@ -517,6 +517,7 @@ fn embedded_lens_perspective_edges_match() { radius: 0.6, }), vignette: Some(EmbeddedVignette { k: [0.4, -0.1, 0.02, 0.0, 0.0], center: Point::new(0.5, 0.5), radius: 0.6 }), + stages: Default::default(), }; let info = SourceInfo { lens: Some(lens), ..raw }; let mut s = DevelopSettings::default(); diff --git a/crates/pipeline/src/optics.rs b/crates/pipeline/src/optics.rs index f495532e..843f1e82 100644 --- a/crates/pipeline/src/optics.rs +++ b/crates/pipeline/src/optics.rs @@ -11,13 +11,14 @@ //! dimensionless scales), so a 400 px preview and a full-size export are warped identically. //! //! Also here: automatic lateral chromatic aberration estimation ([`estimate_lateral_ca`]) and [`defringe`]. -//! Profile corrections use lens data embedded in the files (DNG `WarpRectilinear` / `FixVignetteRadial`, and the -//! raw readers' equivalent of a camera's own correction), or a lens database profile in the same form (`lightcraft_lensdb`). +//! Profile corrections use lens data embedded in the files (DNG `WarpRectilinear` / `WarpRectilinear2` / +//! `FixVignetteRadial`, and the raw readers' equivalent of a camera's own correction, e.g. Panasonic / Leica RW2 +//! distortion), or a lens database profile in the same form (`lightcraft_lensdb`). use std::sync::Mutex; use lightcraft_color::perceptual::{oklab_from_2020, oklab_to_2020}; -use lightcraft_develop::{DevelopSettings, EmbeddedLens, EmbeddedVignette, EmbeddedWarp}; +use lightcraft_develop::{DevelopSettings, EmbeddedLens, EmbeddedVignette, EmbeddedWarp, EmbeddedWarpStage}; use lightcraft_geom::{Affine, Homography, Interval, Orientation, Point, Real}; use lightcraft_raster::{Plane, Rgb32f}; @@ -103,7 +104,13 @@ impl Warp { self.persp != Homography::IDENTITY || self.k1 != 0.0 || self.ca.iter().any(|c| *c != 0.0) - || (self.lens_dist != 0.0 && self.lens.is_some_and(|l| l.warp.is_some())) + || (self.lens_dist != 0.0 && self.lens.is_some_and(|l| l.warp.is_some() || l.has_stages())) + } + + /// Whether the embedded lens warp is a `WarpRectilinear2` chain, whose degree-14 polynomials are ill-conditioned + /// in `f32` (terms of 1e5 cancelling to 1): a renderer that works in `f32` must sample on the CPU. + pub fn needs_f64(&self) -> bool { + self.lens_dist != 0.0 && self.lens.is_some_and(|l| l.has_stages()) } /// Whether the colour planes are sampled at different positions. @@ -111,6 +118,8 @@ impl Warp { self.ca[0] != self.ca[1] || self.ca[1] != self.ca[2] || (self.lens_dist != 0.0 && self.lens.and_then(|l| l.warp).is_some_and(|w| w.planes[0] != w.planes[1] || w.planes[1] != w.planes[2])) + || (self.lens_dist != 0.0 + && self.lens.is_some_and(|l| l.stages.iter().flatten().any(|s| s.planes[0] != s.planes[1] || s.planes[1] != s.planes[2]))) } pub fn has_gain(&self) -> bool { @@ -170,6 +179,16 @@ impl Warp { let (sx, sy) = embedded_warp_real(&wp, qx, qy, ch, self.w, self.h); (qx, qy) = (qx + (sx - qx) * self.lens_dist, qy + (sy - qy) * self.lens_dist); } + if self.lens_dist != 0.0 + && let Some(l) = self.lens.filter(|l| l.has_stages()) + { + // each opcode warps the image the one before it made, so the last one maps the output position first + let (mut sx, mut sy) = (qx, qy); + for st in l.stages.iter().rev().flatten() { + (sx, sy) = embedded_stage_real(st, sx, sy, ch, self.w, self.h); + } + (qx, qy) = (qx + (sx - qx) * self.lens_dist, qy + (sy - qy) * self.lens_dist); + } let k = self.ca[ch]; if k != 0.0 { (qx, qy) = ((qx - c.x) * (1.0 + k) + c.x, (qy - c.y) * (1.0 + k) + c.y); @@ -280,6 +299,33 @@ fn embedded_warp_real(wp: &EmbeddedWarp, x: T, y: T, ch: usize, w: f64, (sx * m + cx, sy * m + cy) } +/// DNG `WarpRectilinear2` source position for corrected position `p` (px) and plane `ch`. +pub fn embedded_stage(st: &EmbeddedWarpStage, p: Point, ch: usize, w: f64, h: f64) -> Point { + let (x, y) = embedded_stage_real(st, p.x, p.y, ch, w, h); + Point::new(x, y) +} + +/// [`embedded_stage`] for any [`Real`]: DNG specification 1.6.0.0, "WarpRectilinear2" — the radial term is +/// `f(clamp(r, min_valid_radius, max_valid_radius))` with `f(r) = Σ krₚ rᵖ` (p = 0..14), used as `d·f` or, with +/// `reciprocalRadial`, `d/f`; the tangential terms are those of `WarpRectilinear` (with the unclamped `r`). +fn embedded_stage_real(st: &EmbeddedWarpStage, x: T, y: T, ch: usize, w: f64, h: f64) -> (T, T) { + let (cx, cy) = (st.center.x * w, st.center.y * h); + let m = (st.radius * w.max(h)).max(1e-9); + let k = &st.planes[ch.min(2)]; + let dx = (x - cx) / m; + let dy = (y - cy) / m; + let r2 = dx * dx + dy * dy; + let r = r2.sqrt_nonneg().clamp_range(k[17], k[18]); + let mut f = r * k[14] + k[13]; + for &c in k[..13].iter().rev() { + f = f * r + c; + } + let (rx, ry) = if st.reciprocal { (dx / f, dy / f) } else { (dx * f, dy * f) }; + let sx = rx + dx * (k[15] * 2.0) * dy + (r2 + dx * 2.0 * dx) * k[16]; + let sy = ry + dx * (k[16] * 2.0) * dy + (r2 + dy * 2.0 * dy) * k[15]; + (sx * m + cx, sy * m + cy) +} + /// DNG `FixVignetteRadial` gain at source position `p` (px). pub fn embedded_vignette_gain(v: &EmbeddedVignette, p: Point, w: f64, h: f64) -> f64 { let (cx, cy) = (v.center.x * w, v.center.y * h); @@ -316,7 +362,20 @@ pub fn reorient_lens(lens: &EmbeddedLens, o: Orientation, w: f64, h: f64) -> Emb EmbeddedWarp { planes, center: map_pt(wp.center), radius: wp.radius * long_ratio } }); let vignette = lens.vignette.map(|v| EmbeddedVignette { k: v.k, center: map_pt(v.center), radius: v.radius * long_ratio }); - EmbeddedLens { warp, vignette } + let stages = lens.stages.map(|st| { + st.map(|st| { + let c = Point::new(st.center.x * w, st.center.y * h); + let c2 = o.map(c.x, c.y, w, h); + let planes = st.planes.map(|mut k| { + // same rotation of the tangential vector as for `WarpRectilinear` + let e = o.map(c.x + k[16], c.y + k[15], w, h); + (k[15], k[16]) = (e.1 - c2.1, e.0 - c2.0); + k + }); + EmbeddedWarpStage { planes, center: map_pt(st.center), radius: st.radius * long_ratio, reciprocal: st.reciprocal } + }) + }); + EmbeddedLens { warp, vignette, stages } } // ------------------------------------------------------------------------------------------ lateral CA @@ -646,7 +705,7 @@ mod tests { }; let planes = [plane(), plane(), plane()]; let warp = EmbeddedWarp { planes, center: Point::new(r.range(0.4, 0.6), r.range(0.4, 0.6)), radius: r.range(0.5, 0.8) }; - wp.lens = Some(EmbeddedLens { warp: Some(warp), vignette: None }); + wp.lens = Some(EmbeddedLens { warp: Some(warp), vignette: None, ..Default::default() }); wp.lens_dist = r.maybe(0.0, 2.0); } wp @@ -758,6 +817,7 @@ mod tests { radius: 0.6, }), vignette: None, + ..Default::default() }; let mut cases: Vec = Vec::new(); let mut s = DevelopSettings::default(); @@ -863,6 +923,7 @@ mod tests { let lens = EmbeddedLens { warp: Some(EmbeddedWarp { planes: [[1.0, 0.01, 0.0, 0.0, 0.001, 0.002]; 3], center: Point::new(0.4, 0.5), radius: 0.6 }), vignette: Some(EmbeddedVignette { k: [0.2, 0.0, 0.0, 0.0, 0.0], center: Point::new(0.4, 0.5), radius: 0.6 }), + ..Default::default() }; let r = reorient_lens(&lens, Orientation::Rotate90, 300.0, 200.0); // rotate 90° cw: (x, y) → (h − y, x) @@ -879,6 +940,140 @@ mod tests { assert!(s1.dist(m(s0)) < 1e-6, "{s1:?} vs {:?}", m(s0)); } + /// A `WarpRectilinear2` stage with the same coefficients on every plane. + fn stage(kr: &[(usize, f64)], center: Point, radius: f64, reciprocal: bool) -> EmbeddedWarpStage { + let mut k = [0.0; 19]; + for &(i, v) in kr { + k[i] = v; + } + k[18] = 1.0; + EmbeddedWarpStage { planes: [k; 3], center, radius, reciprocal } + } + + fn chain(stages: &[EmbeddedWarpStage]) -> Warp { + let mut wp = Warp::identity(300.0, 200.0); + let mut lens = EmbeddedLens::default(); + for (slot, st) in lens.stages.iter_mut().zip(stages) { + *slot = Some(*st); + } + wp.lens = Some(lens); + wp.lens_dist = 1.0; + wp + } + + #[test] + fn warp_rectilinear2_stages_identity_chain_order_and_reciprocal() { + let c = Point::new(0.5, 0.5); + let p = Point::new(40.0, 25.0); + // identity coefficients (kr0 = 1), plain and reciprocal, leave positions alone and don't move pixels + for reciprocal in [false, true] { + let wp = chain(&[stage(&[(0, 1.0)], c, 0.6, reciprocal)]); + assert!(wp.moves_pixels() && !wp.per_channel()); + assert!(wp.corrected_to_source(p, 1).dist(p) < 1e-9); + } + // the radial factor at r is f(r); with kr0 = 1, kr1 = 0.5: s = d·(1 + 0.5 r) (and d / (1 + 0.5 r) reciprocal) + let m = 0.6 * 300.0; + let d = Point::new((p.x - 150.0) / m, (p.y - 100.0) / m); + let r = d.x.hypot(d.y); + let fwd = chain(&[stage(&[(0, 1.0), (1, 0.5)], c, 0.6, false)]).corrected_to_source(p, 0); + assert!((fwd.x - (150.0 + m * d.x * (1.0 + 0.5 * r))).abs() < 1e-9); + let rec = chain(&[stage(&[(0, 1.0), (1, 0.5)], c, 0.6, true)]).corrected_to_source(p, 0); + assert!((rec.y - (100.0 + m * d.y / (1.0 + 0.5 * r))).abs() < 1e-9); + // list order: [A, B] maps an output position through B first, then A + let a = stage(&[(0, 1.0), (2, -0.3)], c, 0.6, false); + let b = stage(&[(0, 1.1), (1, 0.2)], Point::new(0.45, 0.5), 0.6, false); + let ab = chain(&[a, b]); + let by_hand = chain(&[a]).corrected_to_source(chain(&[b]).corrected_to_source(p, 1), 1); + assert!(ab.corrected_to_source(p, 1).dist(by_hand) < 1e-9); + assert!(ab.corrected_to_source(p, 1).dist(chain(&[b, a]).corrected_to_source(p, 1)) > 1e-3, "order matters"); + // strength scales the correction; a lens without a chain or warp does not move pixels + let mut half = ab.clone(); + half.lens_dist = 0.5; + let (full, mid) = (ab.corrected_to_source(p, 1), half.corrected_to_source(p, 1)); + assert!(mid.dist(Point::new((p.x + full.x) / 2.0, (p.y + full.y) / 2.0)) < 1e-9); + assert!(ab.needs_f64() && !chain(&[]).moves_pixels() && !Warp::identity(3.0, 3.0).needs_f64()); + // planes that differ (lateral CA) are sampled separately + let mut st = stage(&[(0, 1.0)], c, 0.6, false); + st.planes[0][0] = 1.01; + assert!(chain(&[st]).per_channel()); + } + + #[test] + fn warp_rectilinear2_valid_radius_clamps_the_polynomial() { + let c = Point::new(0.5, 0.5); + let mut st = stage(&[(0, 1.0), (1, 1.0)], c, 0.5, false); + st.planes.iter_mut().for_each(|k| (k[17], k[18]) = (0.2, 0.4)); + let wp = chain(&[st]); + let m = 0.5 * 300.0; + // far out (r > 0.4): f stays f(0.4) = 1.4 + let p = Point::new(10.0, 100.0); + let d = (p.x - 150.0) / m; + assert!(d.abs() > 0.4); + assert!((wp.corrected_to_source(p, 0).x - (150.0 + m * d * 1.4)).abs() < 1e-9); + // near the centre (r < 0.2): f stays f(0.2) = 1.2 + let p = Point::new(150.0 + 0.1 * m, 100.0); + assert!((wp.corrected_to_source(p, 0).x - (150.0 + 0.1 * m * 1.2)).abs() < 1e-9); + } + + #[test] + fn warp_rectilinear2_interval_framing_encloses_every_f64_result() { + let mut r = Rng(11); + for _ in 0..200 { + let mut stages = Vec::new(); + for _ in 0..2 { + let mut st = stage(&[], Point::new(r.range(0.4, 0.6), r.range(0.4, 0.6)), r.range(0.5, 0.8), r.next().is_multiple_of(3)); + for k in st.planes.iter_mut() { + k[0] = 1.0 + r.range(-0.05, 0.05); + for v in k[1..15].iter_mut() { + *v = r.maybe(-0.5, 0.5); + } + (k[15], k[16]) = (r.range(-0.003, 0.003), r.range(-0.003, 0.003)); + (k[17], k[18]) = (r.maybe(0.0, 0.3), r.range(0.5, 1.0)); + } + stages.push(st); + } + let wp = chain(&stages); + let a = lightcraft_geom::Affine([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]); + for _ in 0..20 { + let (x0, y0) = (r.range(0.0, 1.0) * wp.w, r.range(0.0, 1.0) * wp.h); + let (bw, bh) = (r.range(0.0, 20.0), r.range(0.0, 20.0)); + let (ix, iy) = (Interval::new(x0, x0 + bw), Interval::new(y0, y0 + bh)); + let (_, (sx, sy)) = wp.framing(&a, ix, iy); + if !(sx.is_decided() && sy.is_decided()) { + continue; + } + for i in 0..=4 { + for j in 0..=4 { + let (x, y) = ((x0 + bw * i as f64 / 4.0).clamp(ix.lo(), ix.hi()), (y0 + bh * j as f64 / 4.0).clamp(iy.lo(), iy.hi())); + let (_, (fx, fy)) = wp.framing(&a, x, y); + assert!( + sx.lo() <= fx && fx <= sx.hi() && sy.lo() <= fy && fy <= sy.hi(), + "({x}, {y}) → ({fx}, {fy}) outside {sx:?} × {sy:?}" + ); + } + } + } + } + } + + #[test] + fn reorient_rotates_a_warp_rectilinear2_stage_like_the_point() { + let mut st = stage(&[(0, 1.0), (1, 0.05), (2, -0.1)], Point::new(0.4, 0.5), 0.6, false); + st.planes.iter_mut().for_each(|k| (k[15], k[16]) = (0.001, 0.002)); + let mut lens = EmbeddedLens::default(); + lens.stages[0] = Some(st); + let p = Point::new(50.0, 30.0); + let m = |q: Point| { + let (x, y) = Orientation::Rotate90.map(q.x, q.y, 300.0, 200.0); + Point::new(x, y) + }; + let r = reorient_lens(&lens, Orientation::Rotate90, 300.0, 200.0); + let s0 = embedded_stage(&st, p, 0, 300.0, 200.0); + let s1 = embedded_stage(&r.stages[0].unwrap(), m(p), 0, 200.0, 300.0); + assert!(s1.dist(m(s0)) < 1e-6, "{s1:?} vs {:?}", m(s0)); + assert_eq!(reorient_lens(&lens, Orientation::Normal, 300.0, 200.0), lens); + } + #[test] fn defringe_removes_purple_at_edges_only() { let mut s = DevelopSettings::default(); diff --git a/crates/pipeline/src/tests_geometry.rs b/crates/pipeline/src/tests_geometry.rs index 99c8a140..d7105acf 100644 --- a/crates/pipeline/src/tests_geometry.rs +++ b/crates/pipeline/src/tests_geometry.rs @@ -140,6 +140,7 @@ fn profile_corrections_use_embedded_lens_only_when_enabled() { let lens = EmbeddedLens { warp: Some(EmbeddedWarp { planes: [[1.0, -0.08, 0.0, 0.0, 0.0, 0.0]; 3], center: Point::new(0.5, 0.5), radius: 0.6 }), vignette: Some(EmbeddedVignette { k: [0.5, 0.0, 0.0, 0.0, 0.0], center: Point::new(0.5, 0.5), radius: 0.6 }), + ..Default::default() }; let info = SourceInfo { lens: Some(lens), ..SourceInfo::default() }; let mut s = DevelopSettings::default(); diff --git a/crates/raw/src/opcodes.rs b/crates/raw/src/opcodes.rs index 6e4f29f6..1d3a2c54 100644 --- a/crates/raw/src/opcodes.rs +++ b/crates/raw/src/opcodes.rs @@ -1,8 +1,13 @@ //! DNG opcode lists (`OpcodeList1/2/3`, DNG 1.7 chapter 7). Opcode lists are always big-endian. //! //! Parsed: every opcode (unknown ones are kept as [`Opcode::Unknown`]). Applied: `WarpRectilinear`, -//! `FixVignetteRadial`, `FixBadPixelsConstant`, `FixBadPixelsList`, `MapTable`, `MapPolynomial`, `GainMap`, -//! `DeltaPerRow/Column`, `ScalePerRow/Column`. `TrimBounds` and `WarpFisheye` are recorded but not applied. +//! `WarpRectilinear2` (opcode 14, DNG 1.6; see [`Opcode::WarpRectilinear2`]), `FixVignetteRadial`, +//! `FixBadPixelsConstant`, `FixBadPixelsList`, `MapTable`, `MapPolynomial`, `GainMap`, `DeltaPerRow/Column`, +//! `ScalePerRow/Column`. `TrimBounds` and `WarpFisheye` are recorded but not applied. +//! +//! Opcodes run in list order, except for the DNG 1.6 skip rule ([`applied_ops`]): a `WarpRectilinear` or +//! `WarpFisheye` that immediately follows an *optional* `WarpRectilinear2` is skipped (the older opcode is the +//! fallback for readers that don't know opcode 14). //! //! Value convention: list 1 runs on raw sample values (16-bit scale), lists 2 and 3 on values normalised to //! [0, 1]; table/polynomial/delta opcodes are defined on the normalised range and are rescaled accordingly. @@ -23,6 +28,15 @@ pub struct Area { pub col_pitch: u32, } +/// Values per coefficient set of `WarpRectilinear2`: 15 radial (`kr0..kr14`), 2 tangential (`kt0`, `kt1`), +/// `min_valid_radius`, `max_valid_radius`. +pub const WR2_LEN: usize = 19; +/// Most coefficient sets (planes) a `WarpRectilinear2` may carry. +const WR2_MAX_PLANES: usize = 4; +/// Largest magnitude accepted for a `WarpRectilinear2` coefficient (a real lens fit is far below this; it keeps the +/// polynomial finite for any radius up to 1). +const WR2_MAX_COEF: f64 = 1e9; + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub enum Opcode { /// Per-plane radial (kr0..kr3) + tangential (kt0, kt1) coefficients and the relative optical centre. @@ -34,6 +48,16 @@ pub enum Opcode { planes: Vec<[f64; 4]>, center: [f64; 2], }, + /// DNG 1.6 `WarpRectilinear2` (opcode 14; DNG specification 1.6.0.0, chapter 7, "WarpRectilinear2"). Per plane + /// `[kr0 .. kr14, kt0, kt1, min_valid_radius, max_valid_radius]` ([`WR2_LEN`] values, see [`wr2_factor`]), + /// the relative optical centre, and `reciprocalRadial`. `optional` is opcode flag bit 0, which turns on the skip + /// rule for the next warp opcode. + WarpRectilinear2 { + planes: Vec<[f64; WR2_LEN]>, + center: [f64; 2], + reciprocal: bool, + optional: bool, + }, FixVignetteRadial { k: [f64; 5], center: [f64; 2], @@ -99,14 +123,37 @@ impl Opcode { !matches!(self, Opcode::Unknown { .. } | Opcode::TrimBounds { .. } | Opcode::WarpFisheye { .. }) } - /// Whether this is a lens correction (`WarpRectilinear`, `FixVignetteRadial`): from a DNG, or the equivalent of - /// a camera's own correction recorded in its raw (Panasonic RW2). Developers that apply these through the - /// "profile corrections" of the develop settings remove them before demosaicing. + /// Whether this is a lens correction (`WarpRectilinear`, `WarpRectilinear2`, `FixVignetteRadial`): from a DNG, or + /// the equivalent of a camera's own correction recorded in its raw (Panasonic RW2). Developers that apply these + /// through the "profile corrections" of the develop settings remove them before demosaicing. pub fn is_lens_correction(&self) -> bool { - matches!(self, Opcode::WarpRectilinear { .. } | Opcode::FixVignetteRadial { .. }) + matches!(self, Opcode::WarpRectilinear { .. } | Opcode::WarpRectilinear2 { .. } | Opcode::FixVignetteRadial { .. }) } } +/// The opcodes of a list that a reader applies, in order: all of them, minus the DNG 1.6 skip rule (specification +/// 1.6.0.0, chapter 7) — a `WarpRectilinear` or `WarpFisheye` immediately after a `WarpRectilinear2` that is marked +/// optional is not applied. A `WarpRectilinear2` that could not be parsed ([`Opcode::Unknown`]) triggers no skip. +pub fn applied_ops(list: &[Opcode]) -> impl Iterator { + let mut skip = false; + list.iter().filter(move |op| { + let skipped = skip && matches!(op, Opcode::WarpRectilinear { .. } | Opcode::WarpFisheye { .. }); + skip = matches!(op, Opcode::WarpRectilinear2 { optional: true, .. }); + !skipped + }) +} + +/// `WarpRectilinear2` radial factor `r_real / r_ideal` for one coefficient set `k` at ideal radius `r` +/// (specification 1.6.0.0, "WarpRectilinear2"): `f(r) = Σ kr_p · r^p` (p = 0..14) evaluated at `r` clamped to +/// `[min_valid_radius, max_valid_radius]`, so `r_real = r · f` — or `r / f` when `reciprocalRadial` is non-zero. +/// `None` when the factor is not finite (a degenerate set, or `f = 0` with `reciprocalRadial`). +pub fn wr2_factor(k: &[f64; WR2_LEN], reciprocal: bool, r: f64) -> Option { + let rc = r.clamp(k[17].min(k[18]), k[17].max(k[18])); + let f = k[..15].iter().rev().fold(0.0, |acc, &c| acc * rc + c); + let f = if reciprocal { 1.0 / f } else { f }; + f.is_finite().then_some(f) +} + /// The three opcode lists of a DNG raw IFD. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] pub struct OpcodeLists { @@ -184,6 +231,27 @@ fn parse_one(id: u32, flags: u32, params: &[u8]) -> Option { } Opcode::WarpFisheye { planes, center: [f(&mut r)?, f(&mut r)?] } } + 14 => { + let n = r.u32()? as usize; + // N sets of 19 doubles, then the centre (2 doubles) and reciprocalRadial (LONG); nothing else + if n == 0 || n > WR2_MAX_PLANES || params.len() != 4 + n * WR2_LEN * 8 + 16 + 4 { + return None; + } + let mut planes = Vec::with_capacity(n); + for _ in 0..n { + let mut k = [0.0; WR2_LEN]; + for v in k.iter_mut() { + *v = f(&mut r).filter(|v| v.abs() <= WR2_MAX_COEF)?; + } + // the spec requires 0 <= min_valid_radius < max_valid_radius <= 1 + if !(k[17] >= 0.0 && k[17] < k[18] && k[18] <= 1.0) { + return None; + } + planes.push(k); + } + let center = [f(&mut r).filter(|v| v.abs() <= 4.0)?, f(&mut r).filter(|v| v.abs() <= 4.0)?]; + Opcode::WarpRectilinear2 { planes, center, reciprocal: r.u32()? != 0, optional: flags & 1 != 0 } + } 3 => Opcode::FixVignetteRadial { k: [f(&mut r)?, f(&mut r)?, f(&mut r)?, f(&mut r)?, f(&mut r)?], center: [f(&mut r)?, f(&mut r)?] }, 4 => Opcode::FixBadPixelsConstant { constant: r.u32()?, bayer_phase: r.u32()? }, 5 => { @@ -268,6 +336,13 @@ fn params(op: &Opcode) -> (u32, u32, Vec) { f64s(&mut o, center); 2 } + Opcode::WarpRectilinear2 { planes, center, reciprocal, optional } => { + u32s(&mut o, &[planes.len() as u32]); + planes.iter().for_each(|p| f64s(&mut o, p)); + f64s(&mut o, center); + u32s(&mut o, &[*reciprocal as u32]); + return (14, *optional as u32, o); + } Opcode::FixVignetteRadial { k, center } => { f64s(&mut o, k); f64s(&mut o, center); @@ -332,7 +407,7 @@ pub fn write_list(list: &[Opcode]) -> Vec { let mut out = (list.len() as u32).to_be_bytes().to_vec(); for op in list { let (id, flags, p) = params(op); - for v in [id, 0x0103_0000, flags, p.len() as u32] { + for v in [id, if id == 14 { 0x0106_0000 } else { 0x0103_0000 }, flags, p.len() as u32] { out.extend_from_slice(&v.to_be_bytes()); } out.extend_from_slice(&p); @@ -517,9 +592,10 @@ pub fn apply_list3(list: &[Opcode], img: &mut Rgb32f) { return; } let (w, h) = (img.width, img.height); - for op in list { + for op in applied_ops(list) { match op { Opcode::WarpRectilinear { planes, center } => *img = warp_rectilinear(img, planes, *center), + Opcode::WarpRectilinear2 { planes, center, reciprocal, .. } => *img = warp_rectilinear2(img, planes, *center, *reciprocal), Opcode::WarpFisheye { .. } | Opcode::TrimBounds { .. } | Opcode::Unknown { .. } => {} other => { let mut flat: Vec = img.data.iter().flat_map(|p| *p).collect(); @@ -558,6 +634,35 @@ pub fn warp_rectilinear(img: &Rgb32f, planes: &[[f64; 6]], center: [f64; 2]) -> out } +/// DNG 1.6 `WarpRectilinear2`: the `WarpRectilinear` geometry (centre and normalisation as there), with the radial +/// factor of [`wr2_factor`] in place of the even polynomial in `r²`. A pixel whose factor is not finite stays put. +pub fn warp_rectilinear2(img: &Rgb32f, planes: &[[f64; WR2_LEN]], center: [f64; 2], reciprocal: bool) -> Rgb32f { + let (w, h) = (img.width, img.height); + let Some(last) = planes.len().checked_sub(1) else { return img.clone() }; + let mut out = Rgb32f::new(w, h); + let (cx, cy) = (center[0] * (w as f64 - 1.0), center[1] * (h as f64 - 1.0)); + let m = max_radius(cx, cy, w as f64 - 1.0, h as f64 - 1.0); + lightcraft_raster::par_rows(&mut out.data, w, |y, row| { + for (x, px) in row.iter_mut().enumerate() { + for (c, v) in px.iter_mut().enumerate() { + let Some(k) = planes.get(c.min(last)) else { continue }; + let dx = (x as f64 - cx) / m; + let dy = (y as f64 - cy) / m; + let r2 = dx * dx + dy * dy; + let (sx, sy) = match wr2_factor(k, reciprocal, r2.sqrt()) { + Some(f) => { + (dx * f + k[15] * 2.0 * dx * dy + k[16] * (r2 + 2.0 * dx * dx), dy * f + k[16] * 2.0 * dx * dy + k[15] * (r2 + 2.0 * dy * dy)) + } + None => (dx, dy), + }; + let (fx, fy) = (cx + m * sx, cy + m * sy); + *v = img.sample_bilinear(fx as f32 + 0.5, fy as f32 + 0.5)[c]; + } + } + }); + out +} + #[cfg(test)] pub(crate) mod tests { use super::*; @@ -734,6 +839,12 @@ pub(crate) mod tests { Opcode::DeltaPerColumn { area: a, deltas: vec![0.3] }, Opcode::ScalePerRow { area: a, scales: vec![1.1] }, Opcode::ScalePerColumn { area: a, scales: vec![0.9, 1.0] }, + Opcode::WarpRectilinear2 { + planes: vec![wr2_set(&[(0, 0.5), (3, 0.1)]), wr2_set(&[(0, 0.25)])], + center: [0.5, 0.49], + reciprocal: true, + optional: true, + }, Opcode::Unknown { id: 77, flags: 1, params: vec![1, 2, 3] }, ]; assert_eq!(parse_list(&write_list(&list)), list); @@ -750,4 +861,175 @@ pub(crate) mod tests { apply_list(&l, &mut buf, 4, 3, 1, None, 1.0); } } + + /// `WarpRectilinear2` parameter bytes (spec 1.6.0.0: N, then per set kr0..kr14, kt0, kt1, min/max valid radius, + /// then the centre and `reciprocalRadial`). + fn wr2_params(sets: &[[f64; WR2_LEN]], center: [f64; 2], reciprocal: u32) -> Vec { + let mut p = (sets.len() as u32).to_be_bytes().to_vec(); + for s in sets { + p.extend(be(s)); + } + p.extend(be(¢er)); + p.extend_from_slice(&reciprocal.to_be_bytes()); + p + } + + fn wr2_set(kr: &[(usize, f64)]) -> [f64; WR2_LEN] { + let mut k = [0.0; WR2_LEN]; + for &(i, v) in kr { + k[i] = v; + } + k[18] = 1.0; + k + } + + #[test] + fn warp_rectilinear2_parses_the_spec_layout() { + let mut k = wr2_set(&[(0, 1.0), (1, 0.25), (14, -2.0)]); + k[15] = 0.001; + k[16] = -0.002; + k[17] = 0.1; + k[18] = 0.9; + let blob = ListWriter::new().op(14, &wr2_params(&[k, wr2_set(&[(0, 1.0)])], [0.5, 0.25], 1)).finish(); + let list = parse_list(&blob); + assert_eq!( + list, + vec![Opcode::WarpRectilinear2 { planes: vec![k, wr2_set(&[(0, 1.0)])], center: [0.5, 0.25], reciprocal: true, optional: false }] + ); + assert!(list[0].is_applied() && list[0].is_lens_correction()); + // written back as opcode 14, version 1.6.0.0, optional flag kept + let again = vec![Opcode::WarpRectilinear2 { planes: vec![k], center: [0.5, 0.5], reciprocal: false, optional: true }]; + let bytes = write_list(&again); + assert_eq!(bytes.get(4..8), Some(&14u32.to_be_bytes()[..])); + assert_eq!(bytes.get(8..12), Some(&0x0106_0000u32.to_be_bytes()[..])); + assert_eq!(parse_list(&bytes), again); + } + + #[test] + fn warp_rectilinear2_identity_leaves_the_image() { + let img = Rgb32f::from_fn(21, 15, |x, y| [x as f32 / 21.0, y as f32 / 15.0, 0.25]); + for reciprocal in [false, true] { + let mut i = img.clone(); + let p = wr2_params(&[wr2_set(&[(0, 1.0)])], [0.5, 0.5], reciprocal as u32); + apply_list3(&parse_list(&ListWriter::new().op(14, &p).finish()), &mut i); + for (a, b) in i.data.iter().zip(&img.data) { + for c in 0..3 { + assert!((a[c] - b[c]).abs() < 1e-5, "{a:?} {b:?}"); + } + } + } + } + + #[test] + fn warp_rectilinear2_matches_warp_rectilinear_on_even_terms() { + // kr0 + kr2 r^2 + kr4 r^4 is WarpRectilinear's kr0 + kr1 r^2 + kr2 r^4 + let img = Rgb32f::from_fn(31, 31, |x, y| [(x * 7 % 13) as f32 / 13.0, (y * 5 % 11) as f32 / 11.0, ((x + y) % 4) as f32 / 4.0]); + let a = warp_rectilinear(&img, &[[1.0, -0.3, 0.1, 0.0, 0.01, -0.02]], [0.5, 0.5]); + let mut k = wr2_set(&[(0, 1.0), (2, -0.3), (4, 0.1)]); + k[15] = 0.01; + k[16] = -0.02; + let b = warp_rectilinear2(&img, &[k], [0.5, 0.5], false); + for (p, q) in a.data.iter().zip(&b.data) { + for c in 0..3 { + assert!((p[c] - q[c]).abs() < 1e-5); + } + } + // odd terms, the reciprocal and the valid-radius clamp give the factor the spec defines + let k = wr2_set(&[(0, 1.0), (1, 0.5)]); + assert_eq!(wr2_factor(&k, false, 0.5), Some(1.25)); + assert_eq!(wr2_factor(&k, true, 0.5), Some(0.8)); + let mut k = k; + k[18] = 0.5; // max_valid_radius + assert_eq!(wr2_factor(&k, false, 0.9), Some(1.25)); + k[17] = 0.25; // min_valid_radius + assert_eq!(wr2_factor(&k, false, 0.0), Some(1.125)); + assert_eq!(wr2_factor(&wr2_set(&[]), true, 0.3), None, "1 / 0"); + } + + #[test] + fn malformed_warp_rectilinear2_is_unknown_not_a_panic() { + let good = wr2_params(&[wr2_set(&[(0, 1.0)])], [0.5, 0.5], 0); + let is_wr2 = |p: &[u8]| matches!(parse_list(&ListWriter::new().op(14, p).finish()).as_slice(), [Opcode::WarpRectilinear2 { .. }]); + assert!(is_wr2(&good)); + // every truncation and any trailing byte + for n in 0..good.len() { + assert!(!is_wr2(&good[..n]), "{n}"); + } + assert!(!is_wr2(&[&good[..], &[0u8][..]].concat())); + // N = 0, N too big for the data, N beyond the plane limit + for n in [0u32, 2, 5, u32::MAX] { + let mut p = good.clone(); + p[..4].copy_from_slice(&n.to_be_bytes()); + assert!(!is_wr2(&p), "N = {n}"); + } + // non-finite or absurd values, in a coefficient, the valid range and the centre + let patch = |at: usize, v: f64| { + let mut p = good.clone(); + p[at..at + 8].copy_from_slice(&v.to_be_bytes()); + p + }; + for (at, v) in [ + (4, f64::NAN), + (4 + 8, f64::INFINITY), + (4 + 8 * 3, 1e300), + (4 + 8 * 17, -0.5), + (4 + 8 * 18, 1.5), + (4 + 8 * 17, 1.0), + (4 + 19 * 8, f64::NAN), + (4 + 19 * 8 + 8, 1e30), + ] { + assert!(!is_wr2(&patch(at, v)), "{at} {v}"); + } + // refused ones stay Unknown and are not applied + let list = parse_list(&ListWriter::new().op(14, &good[..10]).finish()); + assert!(matches!(list.as_slice(), [Opcode::Unknown { id: 14, .. }])); + let mut img = Rgb32f::filled(5, 5, [0.5; 3]); + apply_list3(&list, &mut img); + assert_eq!(img.get(2, 2), [0.5; 3]); + // a hostile but accepted set (huge radial terms) still gives a finite image + let k = wr2_set(&[(0, 1.0), (14, 1e9), (13, -1e9)]); + let img = Rgb32f::from_fn(9, 9, |x, y| [x as f32 / 9.0, y as f32 / 9.0, 0.5]); + for reciprocal in [false, true] { + let out = warp_rectilinear2(&img, &[k], [0.5, 0.5], reciprocal); + assert!(out.data.iter().all(|p| p.iter().all(|v| v.is_finite()))); + } + } + + #[test] + fn warp_list_order_and_the_optional_skip_rule() { + let wr2 = |optional| Opcode::WarpRectilinear2 { planes: vec![wr2_set(&[(0, 1.0)])], center: [0.5, 0.5], reciprocal: false, optional }; + let wr = Opcode::WarpRectilinear { planes: vec![[1.0, 0.0, 0.0, 0.0, 0.0, 0.0]], center: [0.5, 0.5] }; + let fish = Opcode::WarpFisheye { planes: vec![[1.0, 0.0, 0.0, 0.0]], center: [0.5, 0.5] }; + let vig = Opcode::FixVignetteRadial { k: [0.0; 5], center: [0.5, 0.5] }; + let d = std::mem::discriminant::; + let ids = |l: &[Opcode]| applied_ops(l).map(d).collect::>(); + // WarpRectilinear2 (optional) then WarpRectilinear / WarpFisheye: the second is skipped + assert_eq!(ids(&[wr2(true), wr.clone()]), vec![d(&wr2(true))]); + assert_eq!(ids(&[wr2(true), fish.clone()]), vec![d(&wr2(true))]); + // not optional: both run, in list order + assert_eq!(ids(&[wr2(false), wr.clone()]), vec![d(&wr2(false)), d(&wr)]); + // only the opcode immediately after is skipped + assert_eq!(ids(&[wr2(true), vig.clone(), wr.clone()]), vec![d(&wr2(true)), d(&vig), d(&wr)]); + // two in a row: both run, and the WarpRectilinear after the optional second one is skipped + assert_eq!(ids(&[wr2(true), wr2(true), wr.clone()]), vec![d(&wr2(true)); 2]); + // a WarpRectilinear before the WarpRectilinear2 runs, in list order + assert_eq!(ids(&[wr.clone(), wr2(true)]), vec![d(&wr), d(&wr2(true))]); + // the same from bytes: an optional (flag 1) opcode 14 followed by opcode 1 + let w2 = wr2_params(&[wr2_set(&[(0, 1.0)])], [0.5, 0.5], 0); + let mut one = 1u32.to_be_bytes().to_vec(); + one.extend(be(&[1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.5, 0.5])); + let mut blob = 2u32.to_be_bytes().to_vec(); + for (id, flags, p) in [(14u32, 1u32, &w2), (1, 1, &one)] { + for v in [id, 0x0106_0000, flags, p.len() as u32] { + blob.extend_from_slice(&v.to_be_bytes()); + } + blob.extend_from_slice(p); + } + let list = parse_list(&blob); + assert_eq!(list.len(), 2); + assert_eq!(applied_ops(&list).count(), 1); + // a refused (Unknown) opcode 14 triggers no skip + let bad = Opcode::Unknown { id: 14, flags: 1, params: vec![] }; + assert!(applied_ops(&[bad, wr]).any(|o| matches!(o, Opcode::WarpRectilinear { .. }))); + } } diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index eea61ed1..f326ec65 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -210,7 +210,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-EDIT-DETAIL-SUPERRES | Super resolution | P2 | ⬜ | | | | LR-EDIT-DETAIL-AISHARPEN | AI sharpen | OOS | 🚫 | | | | LR-EDIT-OPTICS-CA | Remove chromatic aberration | P1 | ✅ | `crates/ui-egui/src/panels/edit.rs` (checkbox), `ctl:optics.caRed`, `ctl:optics.caBlue` | | -| LR-EDIT-OPTICS-PROFILE | Lens profile corrections | P1 | 🟡 | `ctl:optics.profileDistortion`, `ctl:optics.profileVignetting`, `crates/pipeline/src/optics.rs`, `crates/raw/src/vendor/rw2.rs`, `crates/engine/src/crs.rs` | uses corrections embedded in the file: DNG `WarpRectilinear` / `FixVignetteRadial`, and the camera's own distortion correction in Panasonic / Leica RW2 / RWL files (tag `0x0119`, read as the equivalent `WarpRectilinear`; on 79 photos from 13 bodies and 11 lenses the corrected render lines up with the camera JPEG within 0.75 px at 1440 px at the corners, from up to 13 % of the half diagonal uncorrected; issue #256); an imported preset's `LensProfileEnable=0` leaves them on, as Lightroom applies built-in corrections regardless (inferred); Sony ILCE-7RM4A Bayer ARW raw-IFD distortion tables (`0x7037`, 16-sample layout, native 3:2 framing) use the same warp path, enabled only for the independently validated ILCE-7RM4A with FE 24–105 and FE 200–600 lenses; other Sony models remain uncorrected (see `docs/sony-lens-corrections.md`); vignetting and lateral CA data of other raw formats (Olympus, Fujifilm, Sony) not read yet; Reload from Disk reads the file's lens data for photos catalogued without it and turns the correction on for those still at import settings (issue #314); lens-profile database: a lensfun-format XML database in `LIGHTCRAFT_LENSFUN_DIR` (none is bundled) supplies distortion (poly3 / poly5 / PTLens), TCA and vignetting for lenses it lists (`crates/lensdb`, `crates/engine/src/lens_profiles.rs`, `docs/lens-profiles.md`); no settings switch or bundled database yet | +| LR-EDIT-OPTICS-PROFILE | Lens profile corrections | P1 | 🟡 | `ctl:optics.profileDistortion`, `ctl:optics.profileVignetting`, `crates/pipeline/src/optics.rs`, `crates/raw/src/vendor/rw2.rs`, `crates/engine/src/crs.rs` | uses corrections embedded in the file: DNG `WarpRectilinear` / `WarpRectilinear2` (opcode 14, DNG 1.6: degree-14 radial polynomial, valid-radius clamp, reciprocal option, applied in list order with the spec's skip rule for a `WarpRectilinear` after an optional one, so Pixel DNGs' two-stage warp is corrected; up to 3 chained stages, sampled on the CPU because the polynomials need `f64`; issue #635) / `FixVignetteRadial`, and the camera's own distortion correction in Panasonic / Leica RW2 / RWL files (tag `0x0119`, read as the equivalent `WarpRectilinear`; on 79 photos from 13 bodies and 11 lenses the corrected render lines up with the camera JPEG within 0.75 px at 1440 px at the corners, from up to 13 % of the half diagonal uncorrected; issue #256); an imported preset's `LensProfileEnable=0` leaves them on, as Lightroom applies built-in corrections regardless (inferred); Sony ILCE-7RM4A Bayer ARW raw-IFD distortion tables (`0x7037`, 16-sample layout, native 3:2 framing) use the same warp path, enabled only for the independently validated ILCE-7RM4A with FE 24–105 and FE 200–600 lenses; other Sony models remain uncorrected (see `docs/sony-lens-corrections.md`); vignetting and lateral CA data of other raw formats (Olympus, Fujifilm, Sony) not read yet; Reload from Disk reads the file's lens data for photos catalogued without it and turns the correction on for those still at import settings (issue #314); lens-profile database: a lensfun-format XML database in `LIGHTCRAFT_LENSFUN_DIR` (none is bundled) supplies distortion (poly3 / poly5 / PTLens), TCA and vignetting for lenses it lists (`crates/lensdb`, `crates/engine/src/lens_profiles.rs`, `docs/lens-profiles.md`); no settings switch or bundled database yet | ) | LR-EDIT-OPTICS-DEFRINGE | Defringe | P1 | ✅ | `ctl:optics.defringe*` | no fringe eyedropper | | LR-EDIT-OPTICS-MANUAL | Manual distortion / vignetting | P1 | ✅ | `ctl:optics.distortion`, `ctl:optics.vignetting`, `ctl:optics.vignettingMidpoint` | |