M4.2: optics — manual distortion/vignetting, lateral CA (auto + manual), defringe, DNG-embedded profile corrections

- pipeline::optics::Warp: one inverse map (lens-corrected → source) inside the single geometry resample:
  manual radial distortion, embedded DNG WarpRectilinear (per plane), per-channel lateral CA scale,
  radial vignetting gain (manual + embedded FixVignetteRadial); all in normalized radii
- automatic lateral CA estimate: radial edge profiles matched sub-pixel per plane, robust LSQ fit of
  displacement = α·r (cached per source)
- defringe: purple/green hue ranges (OkLab hue) desaturated next to high-contrast edges, width relative
  to the image long edge
- "Enable Lens Corrections" applies only lens data embedded in DNGs (opcodes are no longer baked at decode;
  stored on the photo record, on by default at import when present). No LCP data anywhere.
- new controls: optics.caRed/caBlue, defringe hue ranges; Optics panel: Manual sub-section, Defringe flyout
- tests: barrel grid straightened, CA removal (auto + manual), embedded corrections toggle/scale, preview vs
  export resolution independence, CA estimator accuracy, orientation of embedded lens data

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-01 00:57:47 -04:00
parent 55c5c6f0c6
commit a0da99660d
12 changed files with 1060 additions and 15 deletions

View File

@ -146,6 +146,10 @@ pub struct Photo {
/// Hash of the file's bytes (hex), for duplicate detection and preview-cache keys.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub content_hash: Option<String>,
/// Lens corrections embedded in the file (DNG `WarpRectilinear` / `FixVignetteRadial`), applied when
/// "Enable Profile Corrections" is on.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub embedded_lens: Option<lightcraft_develop::EmbeddedLens>,
}
impl Photo {
@ -173,6 +177,7 @@ impl Photo {
duration: None,
as_shot_wb: None,
content_hash: None,
embedded_lens: None,
}
}
pub fn is_edited(&self) -> bool {

View File

@ -213,7 +213,13 @@ controls! {
"optics.profileDistortion" => optics.profile_distortion, "Profile Distortion", Optics, 0, 200, 100, 1, 0, Plain;
"optics.profileVignetting" => optics.profile_vignetting, "Profile Vignetting", Optics, 0, 200, 100, 1, 0, Plain;
"optics.defringePurple" => optics.defringe_purple_amount, "Purple Amount", Optics, 0, 20, 0, 1, 0, Gradient { from: "#7a7a7a", to: "#a040d0" };
"optics.defringePurpleHueLo" => optics.defringe_purple_hue_lo, "Purple Hue Low", Optics, 0, 90, 30, 1, 0, Gradient { from: "#6040e0", to: "#d040b0" };
"optics.defringePurpleHueHi" => optics.defringe_purple_hue_hi, "Purple Hue High", Optics, 10, 100, 70, 1, 0, Gradient { from: "#6040e0", to: "#d040b0" };
"optics.defringeGreen" => optics.defringe_green_amount, "Green Amount", Optics, 0, 20, 0, 1, 0, Gradient { from: "#7a7a7a", to: "#40c040" };
"optics.defringeGreenHueLo" => optics.defringe_green_hue_lo, "Green Hue Low", Optics, 0, 90, 40, 1, 0, Gradient { from: "#c0d030", to: "#30c0b0" };
"optics.defringeGreenHueHi" => optics.defringe_green_hue_hi, "Green Hue High", Optics, 10, 100, 60, 1, 0, Gradient { from: "#c0d030", to: "#30c0b0" };
"optics.caRed" => optics.ca_red, "Red/Cyan Fringe", Optics, -100, 100, 0, 1, 0, Gradient { from: "#30c0c0", to: "#e04040" };
"optics.caBlue" => optics.ca_blue, "Blue/Yellow Fringe", Optics, -100, 100, 0, 1, 0, Gradient { from: "#d0c030", to: "#4060e0" };
"geometry.vertical" => geometry.vertical, "Vertical", Geometry, -100, 100, 0, 1, 0, Centered;
"geometry.horizontal" => geometry.horizontal, "Horizontal", Geometry, -100, 100, 0, 1, 0, Centered;
"geometry.rotate" => geometry.rotate, "Rotate", Geometry, -10, 10, 0, 0.1, 1, Centered;

View File

@ -419,6 +419,10 @@ pub struct Optics {
pub distortion: f64,
pub vignetting: f64,
pub vignetting_midpoint: f64,
/// Manual lateral chromatic aberration (red/cyan, blue/yellow fringes), −100..100, added to the
/// automatic estimate when `remove_ca` is on.
pub ca_red: f64,
pub ca_blue: f64,
}
impl Default for Optics {
@ -437,10 +441,42 @@ impl Default for Optics {
distortion: 0.0,
vignetting: 0.0,
vignetting_midpoint: 50.0,
ca_red: 0.0,
ca_blue: 0.0,
}
}
}
/// Lens corrections embedded in a DNG file (`OpcodeList3`: `WarpRectilinear`, `FixVignetteRadial`), converted
/// to the oriented, default-cropped image. This is camera/file data (stored on the photo record, not in the develop
/// settings); "Enable Profile Corrections" applies it, scaled by the profile distortion/vignetting amounts.
/// LightCraft never uses Adobe LCP lens profiles.
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct EmbeddedLens {
pub warp: Option<EmbeddedWarp>,
pub vignette: Option<EmbeddedVignette>,
}
/// DNG `WarpRectilinear`: per plane (R, G, B) `[kr0, kr1, kr2, kr3, kt0, kt1]`. For an output (corrected) point at
/// offset `d = (p − center) / radius`, the source point is `center + radius · (d·f(r²) + tangential(d))`.
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct EmbeddedWarp {
pub planes: [[f64; 6]; 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,
}
/// DNG `FixVignetteRadial`: gain `1 + k0 r² + k1 r⁴ + … + k4 r¹⁰` with `r = |p − center| / radius`.
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct EmbeddedVignette {
pub k: [f64; 5],
pub center: Point,
pub radius: f64,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Upright {

View File

@ -34,6 +34,54 @@ 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 DNG's `OpcodeList3` (`WarpRectilinear`, `FixVignetteRadial`), re-expressed for
/// the default-cropped, EXIF-oriented image. These are the only "profile" corrections LightCraft applies.
pub fn embedded_lens(raw: &lightcraft_raw::RawImage) -> Option<lightcraft_develop::EmbeddedLens> {
use lightcraft_develop::{EmbeddedLens, EmbeddedVignette, EmbeddedWarp};
use lightcraft_geom::Point;
let (aw, ah) = (raw.active_area.width as f64, raw.active_area.height as f64);
if aw < 2.0 || ah < 2.0 {
return None;
}
let c = raw.crop.clipped(raw.active_area.width, raw.active_area.height);
let (cx0, cy0, cw, ch) =
if c.width == 0 || c.height == 0 { (0.0, 0.0, aw, ah) } else { (c.x as f64, c.y as f64, c.width as f64, c.height as f64) };
let long = cw.max(ch);
// opcode centres are relative to the (uncropped) active area, in pixel-index units
let centre = |rel: [f64; 2]| -> (Point, f64) {
let (px, py) = (rel[0] * (aw - 1.0), rel[1] * (ah - 1.0));
let m = [(0.0, 0.0), (aw - 1.0, 0.0), (0.0, ah - 1.0), (aw - 1.0, ah - 1.0)]
.iter()
.map(|&(x, y)| (x - px).hypot(y - py))
.fold(0.0, f64::max)
.max(1e-9);
(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 {
match op {
lightcraft_raw::Opcode::WarpRectilinear { planes, center } if !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() => {
let (center, radius) = centre(*center);
lens.vignette = Some(EmbeddedVignette { k: *k, center, radius });
}
_ => {}
}
}
if lens.warp.is_none() && lens.vignette.is_none() {
return None;
}
Some(lightcraft_pipeline::optics::reorient_lens(&lens, raw.orientation, cw, ch))
}
fn is_lens_opcode(op: &lightcraft_raw::Opcode) -> bool {
matches!(op, lightcraft_raw::Opcode::WarpRectilinear { .. } | lightcraft_raw::Opcode::FixVignetteRadial { .. })
}
fn ext_upper(name: &str) -> String {
std::path::Path::new(name).extension().map(|e| e.to_string_lossy().to_uppercase()).unwrap_or_default()
}
@ -54,7 +102,9 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> {
}
let (t, tint) = xy_to_temp_tint(lightcraft_raw::color::as_shot_white_xy(&raw));
let as_shot_wb = Some((t.round(), tint.round()));
let embedded_lens = embedded_lens(&raw);
return Ok(ProbeInfo {
embedded_lens,
width: w,
height: h,
format: ext_upper(name),
@ -91,13 +141,17 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> {
meta,
as_shot_wb: None,
content_hash,
embedded_lens: None,
})
}
/// Decode a file into a linear Rec.2020 image no larger than `max_edge`, oriented.
pub fn load_bytes(bytes: &[u8], max_edge: usize) -> Result<(Rgb32f, SourceInfo), String> {
if lightcraft_raw::probe(bytes).is_some() {
let raw = lightcraft_raw::decode(bytes).map_err(|e| e.to_string())?;
let mut raw = lightcraft_raw::decode(bytes).map_err(|e| e.to_string())?;
// Embedded lens corrections are applied by the pipeline ("Enable Profile Corrections"), not baked in.
let lens = embedded_lens(&raw);
raw.opcodes.list3.retain(|op| !is_lens_opcode(op));
let method = if max_edge <= 600 { lightcraft_raw::Method::Bilinear } else { lightcraft_raw::Method::Ahd };
let mut img = raw.develop(method).map_err(|e| e.to_string())?;
let xy = lightcraft_raw::color::as_shot_white_xy(&raw);
@ -116,7 +170,7 @@ pub fn load_bytes(bytes: &[u8], max_edge: usize) -> Result<(Rgb32f, SourceInfo),
});
let img = fit(&img, max_edge, max_edge, Filter::Box).oriented(raw.orientation);
let (temp, tint) = xy_to_temp_tint(xy);
return Ok((img, SourceInfo { raw: true, as_shot_temp: temp.round(), as_shot_tint: tint.round() }));
return Ok((img, SourceInfo { raw: true, as_shot_temp: temp.round(), as_shot_tint: tint.round(), lens }));
}
let d = lightcraft_codecs::decode(bytes, lightcraft_codecs::DecodeOptions::fit(max_edge as u32, max_edge as u32)).map_err(|e| e.to_string())?;
let img = d.to_working();

View File

@ -219,6 +219,11 @@ pub fn import(s: &mut Session, paths: &[String], mode: ImportMode) -> crate::Res
if let Some((t, tint)) = info.as_shot_wb {
p.develop = std::sync::Arc::new(lightcraft_develop::DevelopSettings::for_raw(t, tint));
}
p.embedded_lens = info.embedded_lens;
if p.embedded_lens.is_some() {
// built-in (file-embedded) lens corrections are on by default, like the camera intended
std::sync::Arc::make_mut(&mut p.develop).optics.lens_profile = true;
}
report.imported.push(id.0);
ops.push(Op::AddPhoto { photo: Box::new(p) });
}

View File

@ -220,9 +220,13 @@ pub fn content_key(p: &Photo) -> String {
pub fn source_info(p: &Photo) -> SourceInfo {
// Procedural demo scenes are scene-referred HDR (like raw files): use the filmic tone map.
if matches!(p.source, Source::Demo { .. }) {
return SourceInfo { raw: true, as_shot_temp: 6500.0, as_shot_tint: 0.0 };
return SourceInfo { raw: true, as_shot_temp: 6500.0, as_shot_tint: 0.0, lens: None };
}
if p.kind == MediaKind::Raw {
SourceInfo { raw: true, as_shot_temp: 5500.0, as_shot_tint: 0.0, lens: p.embedded_lens }
} else {
SourceInfo::default()
}
if p.kind == MediaKind::Raw { SourceInfo { raw: true, as_shot_temp: 5500.0, as_shot_tint: 0.0 } } else { SourceInfo::default() }
}
impl crate::Session {
@ -301,6 +305,8 @@ pub struct ProbeInfo {
pub as_shot_wb: Option<(f64, f64)>,
/// Hash of the file's bytes (hex), for duplicate detection.
pub content_hash: Option<String>,
/// Lens corrections embedded in the file (DNG opcodes).
pub embedded_lens: Option<lightcraft_develop::EmbeddedLens>,
}
pub type FileProbe = Arc<dyn Fn(&str) -> Result<ProbeInfo, String> + Send + Sync>;

View File

@ -1,11 +1,20 @@
//! Output framing: user orientation, crop + straighten, flips — and the mapping between output
//! pixels and normalized source coordinates (used by masks, spots and on-canvas tools).
//! Output framing: user orientation, lens corrections and perspective ([`Warp`]), crop + straighten,
//! flips — and the mapping between output pixels and normalized coordinates (used by masks, spots and
//! on-canvas tools).
//!
//! Normalized coordinates (masks, spots, crop) refer to the *transformed* image: the oriented source after
//! lens correction and perspective, at the source's size. Everything is sampled in one resample.
use lightcraft_develop::DevelopSettings;
use lightcraft_develop::{DevelopSettings, EmbeddedLens};
use lightcraft_geom::{Affine, CropGeometry, Orientation, Point, Rect};
use lightcraft_raster::resample::{Filter, resize};
use lightcraft_raster::{Rgb32f, par_rows};
use crate::optics::{Warp, reorient_lens};
/// Linear value of areas outside the source after a warp (a light neutral, like an empty canvas).
pub const BLANK: [f32; 3] = [0.55, 0.55, 0.55];
/// The geometric frame of a render.
#[derive(Clone, Debug)]
pub struct Frame {
@ -20,14 +29,65 @@ pub struct Frame {
pub crop: CropGeometry,
pub flip_h: bool,
pub flip_v: bool,
/// Lens corrections / perspective (None = identity).
pub warp: Option<Warp>,
}
impl Frame {
pub fn new(src_w: usize, src_h: usize, s: &DevelopSettings, apply_crop: bool) -> Frame {
Frame::with_lens(src_w, src_h, s, apply_crop, None)
}
/// Like [`Frame::new`], with the source's embedded lens corrections (relative to the EXIF-oriented source).
pub fn with_lens(src_w: usize, src_h: usize, s: &DevelopSettings, apply_crop: bool, lens: Option<&EmbeddedLens>) -> Frame {
let orient = s.orientation;
let (ow, oh) = if orient.swaps_axes() { (src_h as f64, src_w as f64) } else { (src_w as f64, src_h as f64) };
let crop = if apply_crop { s.crop.geometry } else { CropGeometry::default() };
Frame { src_w, src_h, orient, ow, oh, crop, flip_h: apply_crop && s.crop.flip_h, flip_v: apply_crop && s.crop.flip_v }
let lens = lens.map(|l| reorient_lens(l, orient, src_w as f64, src_h as f64));
let warp = Warp::from_settings(ow, oh, s, lens.as_ref());
Frame {
src_w,
src_h,
orient,
ow,
oh,
crop,
flip_h: apply_crop && s.crop.flip_h,
flip_v: apply_crop && s.crop.flip_v,
warp: (!warp.is_identity()).then_some(warp),
}
}
/// Add automatically estimated lateral CA (`[α_R, α_B]`, see [`crate::optics::estimate_lateral_ca`]).
pub fn add_lateral_ca(&mut self, ca: [f64; 2]) {
if ca == [0.0; 2] {
return;
}
let w = self.warp.get_or_insert_with(|| Warp::identity(self.ow, self.oh));
w.ca[0] += ca[0];
w.ca[2] += ca[1];
}
/// Map a point of the transformed image (normalized) to the lens-corrected, pre-perspective image (normalized).
pub fn transformed_to_corrected(&self, p: Point) -> Point {
match &self.warp {
Some(w) => {
let q = w.to_corrected(Point::new(p.x * self.ow, p.y * self.oh));
Point::new(q.x / self.ow, q.y / self.oh)
}
None => p,
}
}
/// Inverse of [`Frame::transformed_to_corrected`].
pub fn corrected_to_transformed(&self, p: Point) -> Point {
match &self.warp {
Some(w) => {
let q = w.from_corrected(Point::new(p.x * self.ow, p.y * self.oh));
Point::new(q.x / self.ow, q.y / self.oh)
}
None => p,
}
}
/// Aspect ratio (w/h) of the output.
@ -92,7 +152,11 @@ impl Frame {
} else {
(o.clone(), 1.0)
};
let xf = Affine::scale(s, base.height as f64 / o.height as f64) * self.out_to_oriented(w, h);
let (sx, sy) = (s, base.height as f64 / o.height as f64);
if let Some(wp) = &self.warp {
return sample_warped(&base, sx, sy, wp, self.out_to_oriented(w, h), w, h);
}
let xf = Affine::scale(sx, sy) * self.out_to_oriented(w, h);
let identity_like = self.crop.is_identity() && !self.flip_h && !self.flip_v && base.width == w && base.height == h;
if identity_like {
return base;
@ -118,6 +182,38 @@ impl Frame {
}
}
/// One resample through the warp: `o2t` maps output px → transformed px, the warp maps those to source px
/// (scaled by `sx, sy` into `base`).
fn sample_warped(base: &Rgb32f, sx: f64, sy: f64, wp: &Warp, o2t: Affine, w: usize, h: usize) -> Rgb32f {
let per_channel = wp.per_channel();
let gain = wp.has_gain();
let mut out = Rgb32f::new(w, h);
par_rows(&mut out.data, w, |y, row| {
for (x, px) in row.iter_mut().enumerate() {
let t = o2t.apply(Point::new(x as f64 + 0.5, y as f64 + 0.5));
let c = wp.to_corrected(t);
let g = wp.corrected_to_source(c, 1);
if !wp.inside(g) {
*px = BLANK;
continue;
}
let mut v = base.sample_bilinear((g.x * sx) as f32, (g.y * sy) as f32);
if per_channel {
for ch in [0usize, 2] {
let q = wp.corrected_to_source(c, ch);
v[ch] = base.sample_bilinear((q.x * sx) as f32, (q.y * sy) as f32)[ch];
}
}
if gain {
let k = wp.gain(g);
v = v.map(|c| c * k);
}
*px = v;
}
});
out
}
/// Whole-image rectangle in normalized coordinates.
pub const FULL: Rect = Rect::UNIT;

View File

@ -5,7 +5,8 @@
//! requested size, and its histogram.
//!
//! Stage order (see `docs/pipeline.md`):
//! 1. geometry — user orientation, crop + straighten, flips; one resample at output resolution
//! 1. geometry — user orientation, lens corrections (distortion, CA, vignetting), perspective, crop +
//! straighten, flips; one resample at output resolution; then defringe
//! 2. scene-linear — white balance, exposure, dehaze, local tone (highlights/shadows), texture,
//! clarity, local adjustments (masks)
//! 3. tone map — contrast / whites / blacks filmic curve on luminance, highlight desaturation
@ -22,6 +23,7 @@ mod finish;
pub mod geometry;
mod local;
pub mod masks;
pub mod optics;
pub mod profiles;
pub mod spots;
mod tone;
@ -34,6 +36,8 @@ pub use tone::ToneMap;
/// Facts about the source the settings are interpreted against.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct SourceInfo {
/// Lens corrections embedded in the file (DNG opcodes), relative to the EXIF-oriented source.
pub lens: Option<lightcraft_develop::EmbeddedLens>,
/// Raw sources use absolute Kelvin white balance; rendered sources use a relative scale around
/// their as-shot white.
pub raw: bool,
@ -43,7 +47,7 @@ pub struct SourceInfo {
impl Default for SourceInfo {
fn default() -> Self {
Self { raw: false, as_shot_temp: 6500.0, as_shot_tint: 0.0 }
Self { raw: false, as_shot_temp: 6500.0, as_shot_tint: 0.0, lens: None }
}
}
@ -95,6 +99,15 @@ pub fn output_size(src_w: usize, src_h: usize, s: &DevelopSettings, req: &Render
geometry::Frame::new(src_w, src_h, s, req.apply_crop).fit(req.max_w, req.max_h)
}
/// The geometric frame `render` uses for `src` (lens data, automatic CA estimate included).
pub fn frame_for(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings, apply_crop: bool) -> geometry::Frame {
let mut frame = geometry::Frame::with_lens(src.width, src.height, s, apply_crop, info.lens.as_ref());
if s.optics.remove_ca && s.section_enabled("optics") {
frame.add_lateral_ca(optics::estimate_lateral_ca(src));
}
frame
}
/// Render `src` with settings `s`.
pub fn render(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings, req: &RenderRequest) -> Rendered {
let prof = std::env::var_os("LIGHTCRAFT_PROFILE").is_some();
@ -107,13 +120,14 @@ pub fn render(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings, req: &Render
};
let mut t = prof.then(std::time::Instant::now);
let s = &*profiles::effective(s);
let frame = geometry::Frame::new(src.width, src.height, s, req.apply_crop);
let frame = frame_for(src, info, s, req.apply_crop);
let (w, h) = frame.fit(req.max_w, req.max_h);
let mut img = frame.sample(src, w, h);
lap("sample", &mut t);
let px_per_long = frame.px_per_long(w);
local::scene_linear_pre(&mut img, info, s);
optics::defringe(&mut img, s, px_per_long / optics::DEFRINGE_REF_LONG);
spots::apply(&mut img, &s.spots, &frame, px_per_long);
local::denoise(&mut img, s, src.width.max(src.height), w.max(h));
lap("wb/exposure/spots/nr", &mut t);
@ -162,3 +176,5 @@ pub(crate) fn for_rows<T: Send>(data: &mut [T], w: usize, f: impl Fn(usize, &mut
#[cfg(test)]
mod tests;
#[cfg(test)]
mod tests_geometry;

View File

@ -0,0 +1,609 @@
//! Lens corrections (Optics) and the geometric warp.
//!
//! [`Warp`] is the complete inverse map from the *transformed* image (what the user sees before the crop:
//! after lens correction and perspective) back to the source pixels, evaluated once per output pixel inside the
//! single geometry resample:
//!
//! transformed px → (inverse perspective) → lens-corrected px → (manual distortion, embedded DNG warp,
//! per-channel lateral CA scale) → source px
//!
//! plus a radial vignetting gain evaluated at the source position. Every parameter is relative (normalized radii,
//! 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 only lens data embedded in DNG files (`WarpRectilinear` / `FixVignetteRadial`).
use std::sync::Mutex;
use lightcraft_color::perceptual::{oklab_from_2020, oklab_to_2020};
use lightcraft_develop::{DevelopSettings, EmbeddedLens, EmbeddedVignette, EmbeddedWarp};
use lightcraft_geom::{Homography, Orientation, Point};
use lightcraft_raster::{Plane, Rgb32f};
use crate::for_rows;
/// Manual "Distortion" ±100 → radial coefficient (r normalized to the half diagonal).
const DISTORTION_K: f64 = 0.2;
/// Manual vignetting ±100 → stops of gain at the corners.
const VIGNETTE_STOPS: f64 = 1.5;
/// Manual CA ±100 → radial scale difference of a colour plane.
const CA_SCALE: f64 = 0.003;
/// The inverse map transformed → source, in pixels of the (user-)oriented source image `w × h`.
#[derive(Clone, Debug, PartialEq)]
pub struct Warp {
pub w: f64,
pub h: f64,
/// Forward perspective (lens-corrected → transformed), in centred coordinates `(p − centre) / (long edge / 2)`.
pub persp: Homography,
/// Its inverse (transformed → lens-corrected).
pub persp_inv: Homography,
/// Manual radial distortion: source radius = r·(1 + k1·r²), r relative to the half diagonal.
pub k1: f64,
/// Manual vignetting: gain = 2^(amount·r^power) stops at source radius r (half-diagonal units).
pub vig_stops: f64,
pub vig_power: f64,
/// Lateral CA: per-channel radial scale about the image centre (R, G, B).
pub ca: [f64; 3],
/// Embedded DNG lens corrections (already in this oriented frame) and their strengths (1 = as recorded).
pub lens: Option<EmbeddedLens>,
pub lens_dist: f64,
pub lens_vig: f64,
}
impl Warp {
pub fn identity(w: f64, h: f64) -> Warp {
Warp {
w,
h,
persp: Homography::IDENTITY,
persp_inv: Homography::IDENTITY,
k1: 0.0,
vig_stops: 0.0,
vig_power: 4.0,
ca: [0.0; 3],
lens: None,
lens_dist: 0.0,
lens_vig: 0.0,
}
}
/// The lens part of the warp from the settings (optics section) and optional embedded lens data.
pub fn from_settings(w: f64, h: f64, s: &DevelopSettings, lens: Option<&EmbeddedLens>) -> Warp {
let mut wp = Warp::identity(w, h);
if !s.section_enabled("optics") {
return wp;
}
let o = &s.optics;
wp.k1 = -DISTORTION_K * o.distortion / 100.0;
wp.vig_stops = VIGNETTE_STOPS * o.vignetting / 100.0;
wp.vig_power = 1.0 + 6.0 * (o.vignetting_midpoint / 100.0).clamp(0.0, 1.0);
wp.ca = [CA_SCALE * o.ca_red / 100.0, 0.0, CA_SCALE * o.ca_blue / 100.0];
if o.lens_profile
&& let Some(l) = lens
{
wp.lens = Some(*l);
wp.lens_dist = (o.profile_distortion / 100.0).clamp(0.0, 2.0);
wp.lens_vig = (o.profile_vignetting / 100.0).clamp(0.0, 2.0);
}
wp
}
fn half_long(&self) -> f64 {
self.w.max(self.h) / 2.0
}
fn centre(&self) -> Point {
Point::new(self.w / 2.0, self.h / 2.0)
}
/// Whether pixels move at all.
pub fn moves_pixels(&self) -> bool {
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()))
}
/// Whether the colour planes are sampled at different positions.
pub fn per_channel(&self) -> bool {
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]))
}
pub fn has_gain(&self) -> bool {
self.vig_stops != 0.0 || (self.lens_vig != 0.0 && self.lens.is_some_and(|l| l.vignette.is_some()))
}
pub fn is_identity(&self) -> bool {
!self.moves_pixels() && !self.has_gain()
}
/// Transformed px → lens-corrected px (inverse perspective).
pub fn to_corrected(&self, p: Point) -> Point {
if self.persp == Homography::IDENTITY {
return p;
}
let (c, l) = (self.centre(), self.half_long());
let q = self.persp_inv.apply(Point::new((p.x - c.x) / l, (p.y - c.y) / l));
Point::new(c.x + q.x * l, c.y + q.y * l)
}
/// Lens-corrected px → transformed px (forward perspective).
pub fn from_corrected(&self, p: Point) -> Point {
if self.persp == Homography::IDENTITY {
return p;
}
let (c, l) = (self.centre(), self.half_long());
let q = self.persp.apply(Point::new((p.x - c.x) / l, (p.y - c.y) / l));
Point::new(c.x + q.x * l, c.y + q.y * l)
}
/// Lens-corrected px → source px for colour plane `ch` (0 = R, 1 = G, 2 = B).
pub fn corrected_to_source(&self, p: Point, ch: usize) -> Point {
let c = self.centre();
let hd = (self.w.hypot(self.h) / 2.0).max(1e-9);
let mut q = p;
if self.k1 != 0.0 {
let (dx, dy) = (q.x - c.x, q.y - c.y);
let r2 = (dx * dx + dy * dy) / (hd * hd);
let f = 1.0 + self.k1 * r2;
q = Point::new(c.x + dx * f, c.y + dy * f);
}
if self.lens_dist != 0.0
&& let Some(wp) = self.lens.and_then(|l| l.warp)
{
let s = embedded_warp(&wp, q, ch, self.w, self.h);
q = Point::new(q.x + (s.x - q.x) * self.lens_dist, q.y + (s.y - q.y) * self.lens_dist);
}
let k = self.ca[ch];
if k != 0.0 {
q = Point::new(c.x + (q.x - c.x) * (1.0 + k), c.y + (q.y - c.y) * (1.0 + k));
}
q
}
/// Transformed px → source px for plane `ch`.
pub fn to_source(&self, p: Point, ch: usize) -> Point {
self.corrected_to_source(self.to_corrected(p), ch)
}
/// Vignetting compensation gain at source position `src` (px).
pub fn gain(&self, src: Point) -> f32 {
let mut g = 1.0;
if self.vig_stops != 0.0 {
let hd = (self.w.hypot(self.h) / 2.0).max(1e-9);
let r = (src.dist(self.centre()) / hd).min(1.5);
g *= (self.vig_stops * r.powf(self.vig_power)).exp2();
}
if self.lens_vig != 0.0
&& let Some(v) = self.lens.and_then(|l| l.vignette)
{
g *= 1.0 + (embedded_vignette_gain(&v, src, self.w, self.h) - 1.0) * self.lens_vig;
}
g as f32
}
/// Whether a source position lies on the image (with a half-pixel margin).
pub fn inside(&self, s: Point) -> bool {
s.x >= -0.5 && s.y >= -0.5 && s.x <= self.w + 0.5 && s.y <= self.h + 0.5
}
}
/// DNG `WarpRectilinear` source position for corrected position `p` (px) and plane `ch`.
pub fn embedded_warp(wp: &EmbeddedWarp, p: Point, ch: usize, w: f64, h: f64) -> Point {
let (cx, cy) = (wp.center.x * w, wp.center.y * h);
let m = (wp.radius * w.max(h)).max(1e-9);
let [kr0, kr1, kr2, kr3, kt0, kt1] = wp.planes[ch.min(2)];
let dx = (p.x - cx) / m;
let dy = (p.y - cy) / m;
let r2 = dx * dx + dy * dy;
let f = kr0 + r2 * (kr1 + r2 * (kr2 + r2 * kr3));
let sx = dx * f + kt0 * 2.0 * dx * dy + kt1 * (r2 + 2.0 * dx * dx);
let sy = dy * f + kt1 * 2.0 * dx * dy + kt0 * (r2 + 2.0 * dy * dy);
Point::new(cx + m * sx, cy + m * sy)
}
/// 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);
let m = (v.radius * w.max(h)).max(1e-9);
let r2 = ((p.x - cx).powi(2) + (p.y - cy).powi(2)) / (m * m);
let mut g = 1.0;
let mut rp = r2;
for k in v.k {
g += k * rp;
rp *= r2;
}
g
}
/// Re-express embedded lens data (relative to a `w × h` image) for the image after orientation `o`.
pub fn reorient_lens(lens: &EmbeddedLens, o: Orientation, w: f64, h: f64) -> EmbeddedLens {
if o == Orientation::Normal {
return *lens;
}
let (ow, oh) = if o.swaps_axes() { (h, w) } else { (w, h) };
let long_ratio = w.max(h) / ow.max(oh); // 1 (rotations keep the long edge)
let map_pt = |c: Point| {
let (x, y) = o.map(c.x * w, c.y * h, w, h);
Point::new(x / ow, y / oh)
};
let warp = lens.warp.map(|wp| {
let c = Point::new(wp.center.x * w, wp.center.y * h);
let c2 = o.map(c.x, c.y, w, h);
let planes = wp.planes.map(|[a, b, cc, d, kt0, kt1]| {
// tangential coefficients form the vector (kt1, kt0), which rotates/reflects with the image
let e = o.map(c.x + kt1, c.y + kt0, w, h);
[a, b, cc, d, e.1 - c2.1, e.0 - c2.0]
});
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 }
}
// ------------------------------------------------------------------------------------------ lateral CA
/// Estimate lateral chromatic aberration: the radial magnification of the red and blue planes relative to green
/// (`[α_R, α_B]`, dimensionless; red edges sit at `r·(1 + α_R)`). Strong, radially oriented green edges are
/// located in each plane with sub-pixel 1-D matching of normalized edge profiles; a weighted, outlier-trimmed
/// least-squares fit of displacement = α·r gives the scale. Results are cached per image.
pub fn estimate_lateral_ca(img: &Rgb32f) -> [f64; 2] {
static CACHE: Mutex<Vec<(u64, [f64; 2])>> = Mutex::new(Vec::new());
let key = fingerprint(img);
if let Ok(c) = CACHE.lock()
&& let Some((_, v)) = c.iter().find(|(k, _)| *k == key)
{
return *v;
}
let v = estimate_lateral_ca_uncached(img);
if let Ok(mut c) = CACHE.lock() {
c.push((key, v));
if c.len() > 16 {
c.remove(0);
}
}
v
}
fn fingerprint(img: &Rgb32f) -> u64 {
let mut h: u64 = 0xcbf2_9ce4_8422_2325 ^ ((img.width as u64) << 32 | img.height as u64);
let n = img.data.len();
for i in 0..512usize {
let p = img.data[(i.wrapping_mul(2_654_435_761) ^ i) % n.max(1)];
for c in p {
h ^= c.to_bits() as u64;
h = h.wrapping_mul(0x0100_0000_01b3);
}
}
h
}
fn estimate_lateral_ca_uncached(img: &Rgb32f) -> [f64; 2] {
let (w, h) = (img.width, img.height);
if w < 32 || h < 32 {
return [0.0; 2];
}
let plane = |c: usize| -> Plane { img.map(|p| p[c]) };
let planes = [plane(0), plane(1), plane(2)];
let g = &planes[1];
let (cx, cy) = (w as f64 / 2.0, h as f64 / 2.0);
let hd = (w as f64).hypot(h as f64) / 2.0;
// candidate edge points, best per grid cell (spread over the frame)
const CELLS: usize = 24;
const PER_CELL: usize = 12;
let mut cells: Vec<Vec<(f32, usize, usize)>> = vec![Vec::new(); CELLS * CELLS];
let at = |x: usize, y: usize| g.data[y * w + x];
for y in (6..h - 6).step_by(2) {
for x in (6..w - 6).step_by(2) {
let gx = at(x + 1, y) - at(x - 1, y);
let gy = at(x, y + 1) - at(x, y - 1);
let mag = gx.hypot(gy);
let mean = (at(x + 1, y) + at(x - 1, y) + at(x, y + 1) + at(x, y - 1)) * 0.25 + 0.02;
let rel = mag / mean;
if rel < 0.2 {
continue;
}
let (vx, vy) = (x as f64 - cx, y as f64 - cy);
let rr = vx.hypot(vy);
if rr < 0.2 * hd {
continue;
}
let cos = ((gx as f64 * vx + gy as f64 * vy) / (mag as f64 * rr)).abs();
if cos < 0.85 {
continue;
}
let cell = (y * CELLS / h) * CELLS + x * CELLS / w;
let v = &mut cells[cell];
if v.len() < PER_CELL {
v.push((rel, x, y));
} else if let Some(min) = v.iter_mut().min_by(|a, b| a.0.total_cmp(&b.0))
&& min.0 < rel
{
*min = (rel, x, y);
}
}
}
let pts: Vec<(f32, usize, usize)> = cells.into_iter().flatten().collect();
// sub-pixel displacement of R and B against G along the radial direction
const HALF: i32 = 4; // profile half length (px)
const SUB: i32 = 20; // samples per px
const MAXD: i32 = 3; // max displacement (px)
let sample = |p: &Plane, x: f64, y: f64| -> f32 {
let fx = x.clamp(0.0, (w - 1) as f64);
let fy = y.clamp(0.0, (h - 1) as f64);
let (x0, y0) = (fx.floor() as usize, fy.floor() as usize);
let (x1, y1) = ((x0 + 1).min(w - 1), (y0 + 1).min(h - 1));
let (tx, ty) = ((fx - x0 as f64) as f32, (fy - y0 as f64) as f32);
let a = p.data[y0 * w + x0] + (p.data[y0 * w + x1] - p.data[y0 * w + x0]) * tx;
let b = p.data[y1 * w + x0] + (p.data[y1 * w + x1] - p.data[y1 * w + x0]) * tx;
a + (b - a) * ty
};
let znorm = |v: &mut [f32]| -> bool {
let n = v.len() as f32;
let m = v.iter().sum::<f32>() / n;
let sd = (v.iter().map(|x| (x - m) * (x - m)).sum::<f32>() / n).sqrt();
if sd < 1e-5 {
return false;
}
v.iter_mut().for_each(|x| *x = (*x - m) / sd);
true
};
let mut obs: [Vec<(f64, f64, f64)>; 2] = [Vec::new(), Vec::new()];
for &(wt, x, y) in &pts {
let (vx, vy) = (x as f64 - cx, y as f64 - cy);
let rr = vx.hypot(vy);
let (ux, uy) = (vx / rr, vy / rr);
let (px, py) = (x as f64, y as f64);
let mut gp: Vec<f32> = (-HALF..=HALF).map(|t| sample(g, px + ux * t as f64, py + uy * t as f64)).collect();
if !znorm(&mut gp) {
continue;
}
for (k, ch) in [0usize, 2].into_iter().enumerate() {
let reach = HALF + MAXD;
let fine: Vec<f32> =
(-reach * SUB..=reach * SUB).map(|i| sample(&planes[ch], px + ux * i as f64 / SUB as f64, py + uy * i as f64 / SUB as f64)).collect();
let mut best = (f32::MAX, 0i32);
for d in -MAXD * SUB..=MAXD * SUB {
let mut prof: Vec<f32> = (-HALF..=HALF).map(|t| fine[((t + reach) * SUB + d) as usize]).collect();
if !znorm(&mut prof) {
continue;
}
let ssd: f32 = prof.iter().zip(&gp).map(|(a, b)| (a - b) * (a - b)).sum();
if ssd < best.0 {
best = (ssd, d);
}
}
// normalized SSD = 2n(1 − corr): require a good match
let n = (2 * HALF + 1) as f32;
if best.0 < 0.3 * n && best.1.abs() < MAXD * SUB {
obs[k].push((rr, best.1 as f64 / SUB as f64, wt as f64));
}
}
}
let fit = |o: &[(f64, f64, f64)]| -> f64 {
if o.len() < 12 {
return 0.0;
}
let solve = |o: &[&(f64, f64, f64)]| {
let (num, den) = o.iter().fold((0.0, 0.0), |(n, d), (r, dd, wt)| (n + wt * dd * r, d + wt * r * r));
if den > 0.0 { num / den } else { 0.0 }
};
let all: Vec<&(f64, f64, f64)> = o.iter().collect();
let mut a = solve(&all);
for _ in 0..2 {
let mut res: Vec<f64> = o.iter().map(|(r, d, _)| (d - a * r).abs()).collect();
res.sort_by(f64::total_cmp);
let mad = res[res.len() / 2].max(0.05);
let keep: Vec<&(f64, f64, f64)> = o.iter().filter(|(r, d, _)| (d - a * r).abs() <= 2.5 * mad).collect();
if keep.len() < 8 {
break;
}
a = solve(&keep);
}
a
};
[fit(&obs[0]), fit(&obs[1])]
}
// ------------------------------------------------------------------------------------------ defringe
/// OkLab hue (degrees) spanned by the purple hue slider (0..100) and the green one.
const PURPLE_HUES: (f32, f32) = (240.0, 360.0);
const GREEN_HUES: (f32, f32) = (90.0, 210.0);
fn smoothstep(e0: f32, e1: f32, x: f32) -> f32 {
let t = ((x - e0) / (e1 - e0)).clamp(0.0, 1.0);
t * t * (3.0 - 2.0 * t)
}
/// Membership of hue `h` (deg) in `[lo, hi]` (deg) with soft shoulders.
fn hue_weight(h: f32, lo: f32, hi: f32) -> f32 {
let feather = 8.0;
let mid = (lo + hi) / 2.0;
let half = (hi - lo) / 2.0;
let mut d = (h - mid).abs() % 360.0;
if d > 180.0 {
d = 360.0 - d;
}
1.0 - smoothstep(half, half + feather, d)
}
/// Long edge (px) at which defringe widths are specified: fringes are 1–7 px wide on a ~4000 px image.
pub const DEFRINGE_REF_LONG: f64 = 4000.0;
/// Defringe: desaturate purple / green colour fringes that sit next to high-contrast edges. `scale` is output
/// pixels per reference pixel (output long edge / [`DEFRINGE_REF_LONG`]) so the fringe width is the same
/// fraction of the image at any size.
pub fn defringe(img: &mut Rgb32f, s: &DevelopSettings, scale: f64) {
let o = &s.optics;
if !s.section_enabled("optics") || (o.defringe_purple_amount <= 0.0 && o.defringe_green_amount <= 0.0) {
return;
}
let (w, h) = (img.width, img.height);
let max_amt = o.defringe_purple_amount.max(o.defringe_green_amount);
// fringe width in source px grows with the amount (1..7 px), in output px at least one
let radius = ((1.0 + max_amt * 0.3) * scale).round().max(1.0) as usize;
// perceptual lightness and its local range (dilated contrast)
let lab: Vec<[f32; 3]> = img.data.iter().map(|p| oklab_from_2020([p[0].max(0.0), p[1].max(0.0), p[2].max(0.0)])).collect();
let l = Plane { width: w, height: h, data: lab.iter().map(|p| p[0]).collect() };
let (lo, hi) = (min_filter(&l, radius, false), min_filter(&l, radius, true));
let range = |lo: f64, hi: f64, span: (f32, f32)| {
let a = span.0 + (span.1 - span.0) * (lo / 100.0) as f32;
let b = span.0 + (span.1 - span.0) * (hi / 100.0) as f32;
(a.min(b), a.max(b))
};
let pr = range(o.defringe_purple_hue_lo, o.defringe_purple_hue_hi, PURPLE_HUES);
let gr = range(o.defringe_green_hue_lo, o.defringe_green_hue_hi, GREEN_HUES);
let ps = (o.defringe_purple_amount / 8.0).min(1.0) as f32;
let gs = (o.defringe_green_amount / 8.0).min(1.0) as f32;
for_rows(&mut img.data, w, |y, row| {
for (x, px) in row.iter_mut().enumerate() {
let i = y * w + x;
let edge = smoothstep(0.08, 0.25, hi.data[i] - lo.data[i]);
if edge <= 0.0 {
continue;
}
let [ll, a, b] = lab[i];
let c = a.hypot(b);
let chroma_w = smoothstep(0.015, 0.05, c);
if chroma_w <= 0.0 {
continue;
}
let hue = b.atan2(a).to_degrees().rem_euclid(360.0);
let k = (ps * hue_weight(hue, pr.0, pr.1)).max(gs * hue_weight(hue, gr.0, gr.1)) * edge * chroma_w;
if k <= 0.0 {
continue;
}
let f = 1.0 - k.min(1.0);
*px = oklab_to_2020([ll, a * f, b * f]).map(|v| v.max(0.0));
}
});
}
/// Separable square min (or max) filter of radius `r`.
fn min_filter(p: &Plane, r: usize, max: bool) -> Plane {
let (w, h) = (p.width, p.height);
let pick = |a: f32, b: f32| if max { a.max(b) } else { a.min(b) };
let mut tmp = Plane::new(w, h);
for_rows(&mut tmp.data, w, |y, row| {
let src = &p.data[y * w..(y + 1) * w];
for (x, v) in row.iter_mut().enumerate() {
let (a, b) = (x.saturating_sub(r), (x + r).min(w - 1));
*v = src[a..=b].iter().copied().fold(src[x], pick);
}
});
let mut out = Plane::new(w, h);
for_rows(&mut out.data, w, |y, row| {
let (a, b) = (y.saturating_sub(r), (y + r).min(h - 1));
for (x, v) in row.iter_mut().enumerate() {
let mut m = tmp.data[y * w + x];
for yy in a..=b {
m = pick(m, tmp.data[yy * w + x]);
}
*v = m;
}
});
out
}
#[cfg(test)]
mod tests {
use super::*;
/// A grid of dark lines on white with lateral CA: red magnified by `ar`, blue by `ab` (about the centre).
fn ca_grid(w: usize, h: usize, ar: f64, ab: f64) -> Rgb32f {
let (cx, cy) = (w as f64 / 2.0, h as f64 / 2.0);
let val = |x: f64, y: f64| -> f32 {
// smooth-edged discs on a grid (edges in every direction)
let cell = w as f64 / 10.0;
let (fx, fy) = ((x / cell).fract() - 0.5, (y / cell).fract() - 0.5);
let d = fx.hypot(fy) * cell - cell * 0.28;
(0.05 + 0.85 * (d / 1.2).clamp(-0.5, 0.5) + 0.425) as f32
};
Rgb32f::from_fn(w, h, |x, y| {
let (x, y) = (x as f64 + 0.5, y as f64 + 0.5);
let at = |a: f64| val(cx + (x - cx) / (1.0 + a), cy + (y - cy) / (1.0 + a));
[at(ar), at(0.0), at(ab)]
})
}
#[test]
fn ca_estimate_recovers_scale() {
let img = ca_grid(600, 400, 0.004, -0.003);
let [r, b] = estimate_lateral_ca_uncached(&img);
assert!((r - 0.004).abs() < 0.0008, "red {r}");
assert!((b + 0.003).abs() < 0.0008, "blue {b}");
let clean = ca_grid(600, 400, 0.0, 0.0);
let [r, b] = estimate_lateral_ca_uncached(&clean);
assert!(r.abs() < 0.0005 && b.abs() < 0.0005, "{r} {b}");
}
#[test]
fn warp_identity_and_distortion_direction() {
let s = DevelopSettings::default();
let wp = Warp::from_settings(300.0, 200.0, &s, None);
assert!(wp.is_identity());
let mut s = s;
s.optics.distortion = 50.0; // corrects barrel: corners sample from further in
let wp = Warp::from_settings(300.0, 200.0, &s, None);
let q = wp.to_source(Point::new(0.0, 0.0), 1);
assert!(q.x > 0.0 && q.y > 0.0);
let c = wp.to_source(Point::new(150.0, 100.0), 1);
assert!(c.dist(Point::new(150.0, 100.0)) < 1e-9);
}
#[test]
fn manual_vignette_brightens_corners() {
let mut s = DevelopSettings::default();
s.optics.vignetting = 100.0;
let wp = Warp::from_settings(300.0, 200.0, &s, None);
assert!((wp.gain(Point::new(150.0, 100.0)) - 1.0).abs() < 1e-6);
assert!(wp.gain(Point::new(0.0, 0.0)) > 2.0);
}
#[test]
fn reorient_keeps_centre_mapping() {
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 }),
};
let r = reorient_lens(&lens, Orientation::Rotate90, 300.0, 200.0);
// rotate 90° cw: (x, y) → (h − y, x)
let c = r.warp.unwrap().center;
assert!((c.x - 0.5).abs() < 1e-9 && (c.y - 0.4).abs() < 1e-9, "{c:?}");
// a point's warped position transforms like the point itself
let p = Point::new(50.0, 30.0);
let s0 = embedded_warp(&lens.warp.unwrap(), p, 0, 300.0, 200.0);
let m = |q: Point| {
let (x, y) = Orientation::Rotate90.map(q.x, q.y, 300.0, 200.0);
Point::new(x, y)
};
let s1 = embedded_warp(&r.warp.unwrap(), m(p), 0, 200.0, 300.0);
assert!(s1.dist(m(s0)) < 1e-6, "{s1:?} vs {:?}", m(s0));
}
#[test]
fn defringe_removes_purple_at_edges_only() {
let mut s = DevelopSettings::default();
s.optics.defringe_purple_amount = 10.0;
// white | black edge with a purple column next to it, and a purple patch far from edges
let purple = [0.25f32, 0.05, 0.45];
let mut img = Rgb32f::from_fn(40, 20, |x, _| if x < 20 { [0.9; 3] } else { [0.02; 3] });
for y in 0..20 {
img.set(20, y, purple);
}
let flat = Rgb32f::filled(40, 20, purple);
let mut flat2 = flat.clone();
defringe(&mut img, &s, 1.0);
defringe(&mut flat2, &s, 1.0);
let chroma = |p: [f32; 3]| {
let l = oklab_from_2020(p);
l[1].hypot(l[2])
};
assert!(chroma(img.get(20, 10)) < chroma(purple) * 0.3);
assert!((chroma(flat2.get(20, 10)) - chroma(purple)).abs() < 1e-4);
}
}

View File

@ -0,0 +1,179 @@
//! Geometry / optics tests on synthetic images with known distortions.
use lightcraft_develop::{DevelopSettings, EmbeddedLens, EmbeddedVignette, EmbeddedWarp};
use lightcraft_geom::Point;
use lightcraft_raster::resample::{Filter, resize};
use lightcraft_raster::{Rgb32f, Rgba8};
use crate::{RenderRequest, SourceInfo, render};
/// A light grid: dark lines every `step` px (soft, ~3 px wide) on a light background.
pub(crate) fn grid_value(p: Point, step: f64) -> f32 {
let d = |v: f64| {
let m = v.rem_euclid(step);
m.min(step - m)
};
let dist = d(p.x).min(d(p.y));
(0.05 + 0.75 * ((dist - 1.0) / 1.5).clamp(0.0, 1.0)) as f32
}
/// Render a source of `w × h` where each pixel shows `pattern` at `undistort(pixel centre)`.
pub(crate) fn synth(w: usize, h: usize, undistort: impl Fn(Point) -> Point + Sync, pattern: impl Fn(Point) -> [f32; 3] + Sync) -> Rgb32f {
Rgb32f::from_fn(w, h, |x, y| pattern(undistort(Point::new(x as f64 + 0.5, y as f64 + 0.5))))
}
/// Sub-pixel x of the dark vertical line nearest `x0` on row `y` (green channel darkness centroid).
pub(crate) fn line_x(img: &Rgba8, y: usize, x0: f64, win: f64) -> f64 {
let (a, b) = ((x0 - win).max(0.0) as usize, ((x0 + win) as usize).min(img.width - 1));
let row: Vec<f64> = (a..=b).map(|x| img.get(x, y)[1] as f64).collect();
let max = row.iter().cloned().fold(0.0, f64::max);
let (mut s, mut sw) = (0.0, 0.0);
for (i, v) in row.iter().enumerate() {
let wgt = (max - v).max(0.0).powi(2);
s += wgt * (a + i) as f64;
sw += wgt;
}
s / sw.max(1e-9) + 0.5
}
/// Sub-pixel y of the dark horizontal line nearest `y0` in column `x`.
pub(crate) fn line_y(img: &Rgba8, x: usize, y0: f64, win: f64) -> f64 {
let (a, b) = ((y0 - win).max(0.0) as usize, ((y0 + win) as usize).min(img.height - 1));
let col: Vec<f64> = (a..=b).map(|y| img.get(x, y)[1] as f64).collect();
let max = col.iter().cloned().fold(0.0, f64::max);
let (mut s, mut sw) = (0.0, 0.0);
for (i, v) in col.iter().enumerate() {
let wgt = (max - v).max(0.0).powi(2);
s += wgt * (a + i) as f64;
sw += wgt;
}
s / sw.max(1e-9) + 0.5
}
fn spread(v: &[f64]) -> f64 {
v.iter().cloned().fold(f64::MIN, f64::max) - v.iter().cloned().fold(f64::MAX, f64::min)
}
/// Barrel distortion with coefficient `k` (< 0): distorted radius = r·(1 + k·r²), r in half-diagonal units.
fn barrel_undistort(w: f64, h: f64, k: f64) -> impl Fn(Point) -> Point + Sync {
let (cx, cy, hd) = (w / 2.0, h / 2.0, w.hypot(h) / 2.0);
move |p: Point| {
let (dx, dy) = ((p.x - cx) / hd, (p.y - cy) / hd);
let rd = dx.hypot(dy);
if rd < 1e-12 {
return p;
}
// solve r·(1 + k r²) = rd (Newton)
let mut r = rd;
for _ in 0..20 {
let f = r * (1.0 + k * r * r) - rd;
r -= f / (1.0 + 3.0 * k * r * r);
}
let s = r / rd;
Point::new(cx + dx * s * hd, cy + dy * s * hd)
}
}
#[test]
fn manual_distortion_straightens_barrel_grid() {
let (w, h) = (480usize, 320usize);
// the Distortion slider at +60 corrects exactly this barrel
let k = -0.2 * 0.6;
let src = synth(w, h, barrel_undistort(w as f64, h as f64, k), |p| [grid_value(p, 40.0); 3]);
let rows = [12usize, 150, 308];
let measure = |s: &DevelopSettings| {
let img = render(&src, &SourceInfo::default(), s, &RenderRequest::fit(w, h)).image;
let xs: Vec<f64> = rows.iter().map(|&y| line_x(&img, y, 40.0, 15.0)).collect();
let ys: Vec<f64> = [12usize, 220, 468].iter().map(|&x| line_y(&img, x, 40.0, 15.0)).collect();
(spread(&xs), spread(&ys))
};
let (bx, by) = measure(&DevelopSettings::default());
assert!(bx > 4.0 && by > 3.0, "the synthetic barrel should bend lines: {bx} {by}");
let mut s = DevelopSettings::default();
s.optics.distortion = 60.0;
let (cx, cy) = measure(&s);
assert!(cx < 1.0 && cy < 1.0, "corrected lines should be straight: {cx} {cy}");
}
/// CA: red plane magnified by `ar`, blue by `ab`, on a neutral disc grid.
fn ca_source(w: usize, h: usize, ar: f64, ab: f64) -> Rgb32f {
let (cx, cy) = (w as f64 / 2.0, h as f64 / 2.0);
let disc = move |p: Point| -> f32 {
let cell = 40.0;
let (fx, fy) = ((p.x / cell).rem_euclid(1.0) - 0.5, (p.y / cell).rem_euclid(1.0) - 0.5);
let d = fx.hypot(fy) * cell - 11.0;
(0.04 + 0.8 * (d / 1.2 + 0.5).clamp(0.0, 1.0)) as f32
};
Rgb32f::from_fn(w, h, |x, y| {
let p = Point::new(x as f64 + 0.5, y as f64 + 0.5);
let at = |a: f64| disc(Point::new(cx + (p.x - cx) / (1.0 + a), cy + (p.y - cy) / (1.0 + a)));
[at(ar), at(0.0), at(ab)]
})
}
fn fringe(img: &Rgba8) -> f64 {
img.data.iter().map(|p| (p[0] as f64 - p[1] as f64).abs() + (p[2] as f64 - p[1] as f64).abs()).sum::<f64>() / img.len() as f64
}
#[test]
fn remove_ca_aligns_colour_planes() {
let (w, h) = (640usize, 420usize);
let src = ca_source(w, h, 0.005, -0.004);
let before = render(&src, &SourceInfo::default(), &DevelopSettings::default(), &RenderRequest::fit(w, h)).image;
let mut s = DevelopSettings::default();
s.optics.remove_ca = true;
let after = render(&src, &SourceInfo::default(), &s, &RenderRequest::fit(w, h)).image;
let (fb, fa) = (fringe(&before), fringe(&after));
assert!(fa < fb * 0.35, "fringing {fb:.2} → {fa:.2}");
// manual sliders can do the same: +ca_red shrinks... the red plane is sampled further out
let mut m = DevelopSettings::default();
m.optics.ca_red = 0.005 / 0.003 * 100.0;
m.optics.ca_blue = -0.004 / 0.003 * 100.0;
let manual = render(&src, &SourceInfo::default(), &m, &RenderRequest::fit(w, h)).image;
assert!(fringe(&manual) < fb * 0.35, "manual {:.2}", fringe(&manual));
}
#[test]
fn profile_corrections_use_embedded_lens_only_when_enabled() {
let (w, h) = (300usize, 200usize);
let src = synth(w, h, |p| p, |p| [grid_value(p, 30.0); 3]);
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 }),
};
let info = SourceInfo { lens: Some(lens), ..SourceInfo::default() };
let mut s = DevelopSettings::default();
let off = render(&src, &info, &s, &RenderRequest::fit(w, h)).image;
let plain = render(&src, &SourceInfo::default(), &s, &RenderRequest::fit(w, h)).image;
assert_eq!(off.data, plain.data, "profile corrections off: embedded data ignored");
s.optics.lens_profile = true;
let on = render(&src, &info, &s, &RenderRequest::fit(w, h)).image;
assert_ne!(on.data, off.data);
// corners brightened by the embedded vignette correction
assert!(on.get(2, 2)[1] as i32 >= off.get(2, 2)[1] as i32);
s.optics.profile_distortion = 0.0;
s.optics.profile_vignetting = 0.0;
let zero = render(&src, &info, &s, &RenderRequest::fit(w, h)).image;
assert_eq!(zero.data, off.data, "0 % = no correction");
}
fn mean_abs_diff_downscaled(small: &Rgba8, big: &Rgba8) -> f32 {
let down = resize(&big.to_linear(), small.width, small.height, Filter::Box).to_srgb8();
small.data.iter().zip(&down.data).map(|(a, b)| (0..3).map(|i| (a[i] as f32 - b[i] as f32).abs()).sum::<f32>() / 3.0).sum::<f32>()
/ small.len() as f32
}
#[test]
fn optics_preview_matches_export() {
let src = lightcraft_scenes::demo_library()[2].render(960, 640);
let mut s = DevelopSettings::default();
s.optics.distortion = 40.0;
s.optics.vignetting = 60.0;
s.optics.vignetting_midpoint = 30.0;
s.optics.ca_red = 30.0;
s.optics.defringe_purple_amount = 8.0;
let small = render(&src, &SourceInfo::default(), &s, &RenderRequest::fit(240, 240)).image;
let big = render(&src, &SourceInfo::default(), &s, &RenderRequest::fit(960, 960)).image;
let err = mean_abs_diff_downscaled(&small, &big);
assert!(err < 4.0, "mean abs error {err}");
}

View File

@ -98,7 +98,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let Some(photo) = app.session.catalog.photo(id).cloned() else { return };
let d = (*photo.develop).clone();
let crop_tool = app.ui.right == RightPanel::Crop;
let frame = Frame::new(photo.width.max(1) as usize, photo.height.max(1) as usize, &d, !crop_tool);
let frame = Frame::with_lens(photo.width.max(1) as usize, photo.height.max(1) as usize, &d, !crop_tool, photo.embedded_lens.as_ref());
let aspect = frame.aspect() as f32;
let ppp = ui.ctx().pixels_per_point();
let area = canvas.shrink(if crop_tool { 48.0 } else { 24.0 });

View File

@ -213,19 +213,52 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
ui.add_space(8.0);
});
section(app, ui, &d, "optics", "Optics", |app, ui, d| {
let has_lens = app.session.catalog.photo(id).is_some_and(|p| p.embedded_lens.is_some());
egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 4, bottom: 4 }).show(ui, |ui| {
let mut ca = d.optics.remove_ca;
if ui.checkbox(&mut ca, "Remove Chromatic Aberration").changed() {
let _ = app.run("develop.merge", json!({"settings": {"optics": {"remove_ca": ca}}, "label": "Remove CA"}));
}
let mut lp = d.optics.lens_profile;
if ui.checkbox(&mut lp, "Use Lens Corrections").changed() {
if ui.checkbox(&mut lp, "Enable Lens Corrections").changed() {
let _ = app.run("develop.merge", json!({"settings": {"optics": {"lens_profile": lp}}, "label": "Lens Corrections"}));
}
if lp && !has_lens {
ui.label(
egui::RichText::new("No lens data embedded in this file (DNG lens opcodes only).")
.size(11.0)
.color(Tokens::get(ui.ctx()).text_dim),
);
}
});
for c in ["optics.distortion", "optics.vignetting", "optics.vignettingMidpoint", "optics.defringePurple", "optics.defringeGreen"] {
if d.optics.lens_profile {
for c in ["optics.profileDistortion", "optics.profileVignetting"] {
control(app, ui, d, c, has_lens);
}
}
sub_title(ui, "Manual");
for c in ["optics.distortion", "optics.vignetting"] {
control(app, ui, d, c, true);
}
control(app, ui, d, "optics.vignettingMidpoint", d.optics.vignetting != 0.0);
ui.add_space(6.0);
let open = app.ui.flyout_open("defringe");
if flyout_row(ui, "defringe", "Defringe", Icon::Picker, open).clicked() {
app.ui.toggle_flyout("defringe");
}
if open {
control(app, ui, d, "optics.defringePurple", true);
let on = d.optics.defringe_purple_amount > 0.0;
control(app, ui, d, "optics.defringePurpleHueLo", on);
control(app, ui, d, "optics.defringePurpleHueHi", on);
control(app, ui, d, "optics.defringeGreen", true);
let on = d.optics.defringe_green_amount > 0.0;
control(app, ui, d, "optics.defringeGreenHueLo", on);
control(app, ui, d, "optics.defringeGreenHueHi", on);
sub_title(ui, "Lateral Chromatic Aberration");
control(app, ui, d, "optics.caRed", true);
control(app, ui, d, "optics.caBlue", true);
}
ui.add_space(8.0);
});
ui.add_space(40.0);