# Conflicts:
#	crates/engine/src/media.rs
#	crates/pipeline/src/optics.rs
#	docs/parity-checklist.md
This commit is contained in:
Claude 2026-10-11 12:00:57 +00:00
commit 8339028a80
No known key found for this signature in database
12 changed files with 599 additions and 24 deletions

View File

@ -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();

View File

@ -656,6 +656,33 @@ impl Default for Optics {
pub struct EmbeddedLens {
pub warp: Option<EmbeddedWarp>,
pub vignette: Option<EmbeddedVignette>,
/// 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<EmbeddedWarpStage>; 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

View File

@ -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 {

View File

@ -44,12 +44,13 @@ fn meta_of(m: &lightcraft_meta::Metadata) -> (Meta, Option<String>) {
(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<lightcraft_develop::EmbeddedLens> {
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_develop
(Point::new((px + 0.5 - cx0) / cw, (py + 0.5 - cy0) / ch), m / long)
};
let mut lens = EmbeddedLens::default();
for op in &raw.opcodes.list3 {
// opcodes in list order, minus the ones the DNG 1.6 skip rule drops (a WarpRectilinear that follows an optional
// WarpRectilinear2)
let ops: Vec<&Opcode> = 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))

View File

@ -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];

View File

@ -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)));
}
}

View File

@ -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;

View File

@ -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();

View File

@ -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<T: 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<T: 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<DevelopSettings> = 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();

View File

@ -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();

View File

@ -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<Item = &Opcode> {
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<f64> {
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> {
}
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<u8>) {
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<u8> {
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<f32> = 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<u8> {
let mut p = (sets.len() as u32).to_be_bytes().to_vec();
for s in sets {
p.extend(be(s));
}
p.extend(be(&center));
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::<Opcode>;
let ids = |l: &[Opcode]| applied_ops(l).map(d).collect::<Vec<_>>();
// 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 { .. })));
}
}

View File

@ -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` | |