Merge PR #794
# Conflicts: # crates/engine/src/media.rs # crates/pipeline/src/optics.rs # docs/parity-checklist.md
This commit is contained in:
commit
8339028a80
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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(¢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::<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 { .. })));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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` | |
|
||||
|
|
|
|||
Loading…
Reference in New Issue