mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-14 19:27:01 -04:00
LR-EDIT-OPTICS-PROFILE: apply the distortion correction Panasonic/Leica record in RW2/RWL (#256)
The RW2 reader reads PanasonicRaw tag 0x0119 (16 LE words) and expresses the camera's own correction as the equivalent DNG WarpRectilinear in OpcodeList3, so embedded_lens() turns it into the existing EmbeddedLens/EmbeddedWarp and "Enable Profile Corrections" applies it like a DNG's warp (no new warp path; Convert to DNG keeps it). Rule, from our own measurements against the embedded camera JPEGs (clean-room; ExifTool tag names only): corrected = rho*(1 + (w8 rho^2 + w4 rho^4 + w11 rho^6)/32768), rho = r/w12 (w12 = active-area half diagonal), output = corrected / (1 + w5/32768), on when (w7 & 0xf) == 1. Chosen on 38 GX85 photos, held out on 35 photos from 12 other bodies / 9 other lenses; corrected renders line up with the camera JPEG within 0.75 px at 1440 px at the corners (up to 13 % of the half diagonal before). Implausible values leave the photo uncorrected. The loader now removes lens opcodes before the RW2 camera-look fit (its binned proxy needs an empty OpcodeList3); Opcode::is_lens_correction() replaces the duplicated matches. RENDER_CACHE_VERSION 11 -> 12.
This commit is contained in:
1 parent
629e39380e
commit
03bc1469f9
13 files changed
+376
-24
No files matched your search
@@ -311,7 +311,7 @@ LightCraft is young and moving fast. **Where we honestly stand** (details in the
|
||||
| Import: Add in place / Copy / Move, rename and folder templates, devices, duplicate detection, watched folders; Local folder browsing | ✅ |
|
||||
| MCP server (headless or live app, persistent libraries), CLI, control channel | ✅ |
|
||||
| XMP sidecars (read/write, auto-write), reading `crs:` develop settings, preset files (`.lcpreset`, XMP presets) | ✅ |
|
||||
| Optics (distortion, vignetting, auto + manual CA, defringe, DNG-embedded lens corrections), Geometry (transforms, Constrain Crop), Upright (Auto/Level/Vertical/Full/Guided) | ✅ · camera lens profiles (our own) ⬜ |
|
||||
| Optics (distortion, vignetting, auto + manual CA, defringe, lens corrections embedded in DNG files and Panasonic / Leica RW2 / RWL distortion data), Geometry (transforms, Constrain Crop), Upright (Auto/Level/Vertical/Full/Guided) | ✅ · camera lens profiles (our own) ⬜ |
|
||||
| Photo Merge: HDR (auto-align, deghost), Panorama (spherical/cylindrical/perspective, boundary warp, auto crop), HDR Panorama → DNG | ✅ |
|
||||
| GPU pipeline (wgpu compute, CPU-exact within 1/255), CPU fallback on device limits / errors | ✅ · WebGPU in the browser 🚧 |
|
||||
| AI: segmentation masks, AI denoise, super resolution, faces; HDR editing; video | ⬜ (see [roadmap](ROADMAP.md#where-were-going)) |
|
||||
|
||||
@@ -539,8 +539,9 @@ impl Default for Optics {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// Lens corrections embedded in the file, converted to the oriented, default-cropped image: a DNG's `OpcodeList3`
|
||||
/// (`WarpRectilinear`, `FixVignetteRadial`), or a raw reader's equivalent of the camera's own correction (Panasonic /
|
||||
/// Leica RW2 distortion, `lightcraft_raw`'s `vendor/rw2.rs`). 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)]
|
||||
|
||||
@@ -93,7 +93,9 @@ fn main() {
|
||||
for path in &args {
|
||||
let bytes = std::fs::read(path).expect("read");
|
||||
println!("{path}");
|
||||
let raw = lightcraft_raw::decode(&bytes).expect("decode");
|
||||
let mut raw = lightcraft_raw::decode(&bytes).expect("decode");
|
||||
// as the loader does: lens corrections are applied by the pipeline, not in the raw develop
|
||||
raw.opcodes.list3.retain(|op| !op.is_lens_correction());
|
||||
println!(" {}×{} cpp {} cfa {:?}", raw.width, raw.height, raw.cpp, raw.cfa.as_ref().map(|c| c.name()));
|
||||
println!(" {:<36} {}", "raw decode:", best(n, || drop(lightcraft_raw::decode(&bytes).expect("decode"))));
|
||||
println!(" {:<36} {}", "normalize:", best(n, || drop(raw.normalized().expect("norm"))));
|
||||
|
||||
@@ -199,7 +199,9 @@ impl Pool {
|
||||
/// Add one raw file's colour pairs. `Ok(None)` when the file can't contribute (not an ARW or
|
||||
/// NEF without colour matrices, no usable camera JPEG, too little colour).
|
||||
pub fn add(&mut self, bytes: &[u8]) -> Result<Option<String>, String> {
|
||||
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())?;
|
||||
// colour only: geometric lens corrections would stop the sensor proxy from binning
|
||||
raw.opcodes.list3.retain(|op| !op.is_lens_correction());
|
||||
let Some(model) = raw.metadata.model.as_deref().map(str::trim).filter(|m| !m.is_empty()) else { return Ok(None) };
|
||||
let Some(pairs) = crate::camera_preview::profile_pairs(&raw, bytes) else { return Ok(None) };
|
||||
let step = pairs.len().div_ceil(PAIRS_PER_FILE).max(1);
|
||||
|
||||
+132
-9
@@ -43,8 +43,9 @@ 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.
|
||||
/// Lens corrections embedded in a raw's `OpcodeList3` (`WarpRectilinear`, `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_geom::Point;
|
||||
@@ -87,10 +88,6 @@ pub fn embedded_lens(raw: &lightcraft_raw::RawInfo) -> Option<lightcraft_develop
|
||||
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()
|
||||
}
|
||||
@@ -224,13 +221,14 @@ fn load_bytes_now(bytes: std::borrow::Cow<'_, [u8]>, max_edge: usize) -> Result<
|
||||
}
|
||||
Err(e) => return Err(e.to_string()),
|
||||
};
|
||||
// Embedded lens corrections are applied by the pipeline ("Enable Profile Corrections"), not baked in
|
||||
// (removed before the camera look's binned sensor proxy too, which needs an empty `OpcodeList3`).
|
||||
let lens = embedded_lens(&raw.info());
|
||||
raw.opcodes.list3.retain(|op| !op.is_lens_correction());
|
||||
let xy = lightcraft_raw::color::as_shot_white_xy(&raw);
|
||||
let t = lightcraft_raw::color::camera_transform(&raw, xy);
|
||||
let camera_look = crate::camera_preview::fit_preview(&raw, &bytes, &t);
|
||||
drop(bytes);
|
||||
// Embedded lens corrections are applied by the pipeline ("Enable Profile Corrections"), not baked in.
|
||||
let lens = embedded_lens(&raw.info());
|
||||
raw.opcodes.list3.retain(|op| !is_lens_opcode(op));
|
||||
// Previews and thumbnails bin the mosaic straight to (about) the size they need; only
|
||||
// larger levels (exports, 1:1) demosaic the whole sensor.
|
||||
let t0 = web_time::Instant::now();
|
||||
@@ -402,6 +400,131 @@ mod tests {
|
||||
use super::*;
|
||||
use lightcraft_codecs::{ChromaSubsampling, EncodeImage, EncodeMeta, Samples, encode_jpeg};
|
||||
|
||||
/// Luminance (from linear RGB) minus its 9 × 9 local mean: tone differences between a render and a camera JPEG
|
||||
/// mostly cancel, edges remain.
|
||||
fn detail(w: usize, h: usize, lum: impl Fn(usize, usize) -> f32) -> Vec<f32> {
|
||||
let l: Vec<f32> = (0..w * h).map(|i| lum(i % w, i / w)).collect();
|
||||
let mut out = vec![0.0; w * h];
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let (x0, x1, y0, y1) = (x.saturating_sub(4), (x + 5).min(w), y.saturating_sub(4), (y + 5).min(h));
|
||||
let m = (y0..y1).flat_map(|yy| (x0..x1).map(move |xx| (xx, yy))).map(|(xx, yy)| l[yy * w + xx]).sum::<f32>();
|
||||
out[y * w + x] = l[y * w + x] - m / ((x1 - x0) * (y1 - y0)) as f32;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The integer shift (within ±`r` px) of the `p × p` patch of `a` at `(x, y)` that best matches `b`, with its
|
||||
/// normalised cross-correlation, or `None` when the patch has too little detail to tell.
|
||||
fn patch_shift(a: &[f32], b: &[f32], w: usize, (x, y): (usize, usize), p: usize, r: i64) -> Option<((i64, i64), f32)> {
|
||||
let pa: Vec<f32> = (0..p * p).map(|i| a[(y + i / p) * w + x + i % p]).collect();
|
||||
let na = pa.iter().map(|v| v * v).sum::<f32>().sqrt();
|
||||
if na < 1e-3 * p as f32 {
|
||||
return None;
|
||||
}
|
||||
let mut best = (f32::MIN, (0, 0));
|
||||
for dy in -r..=r {
|
||||
for dx in -r..=r {
|
||||
let (bx, by) = (x as i64 + dx, y as i64 + dy);
|
||||
let (mut dot, mut nb) = (0.0f32, 0.0f32);
|
||||
for i in 0..p {
|
||||
for j in 0..p {
|
||||
let v = b[(by as usize + i) * w + bx as usize + j];
|
||||
dot += pa[i * p + j] * v;
|
||||
nb += v * v;
|
||||
}
|
||||
}
|
||||
let c = dot / (na * nb.sqrt()).max(1e-12);
|
||||
if c > best.0 {
|
||||
best = (c, (dx, dy));
|
||||
}
|
||||
}
|
||||
}
|
||||
Some((best.1, best.0))
|
||||
}
|
||||
|
||||
/// Issue #256: public Panasonic / Leica raws (skipped without the corpus) are corrected for distortion the way the
|
||||
/// camera corrected its own JPEG (tag 0x0119): at the corners of the frame, the render with "Enable Profile
|
||||
/// Corrections" lines up with the embedded JPEG within 2 px at 640 px, while the uncorrected render of the wide
|
||||
/// lenses is far off. A file shot with the camera's correction off (DMC-GH1) carries no correction.
|
||||
#[test]
|
||||
fn corpus_rw2_distortion_matches_the_camera_jpeg() {
|
||||
let dir = std::env::var_os("LIGHTCRAFT_CORPUS")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
|
||||
.join("raw");
|
||||
let Ok(gh1) = std::fs::read(dir.join("rw2-panasonic-gh1.rw2")) else {
|
||||
eprintln!("skip: {} absent", dir.display());
|
||||
return;
|
||||
};
|
||||
assert_eq!(probe_bytes("gh1.rw2", &gh1).unwrap().embedded_lens, None, "correction off: nothing to apply");
|
||||
assert_eq!(load_bytes(&gh1, 320).unwrap().1.lens, None);
|
||||
|
||||
// (file, how far off (px) the uncorrected corners are at the least, if they match within ±R at all)
|
||||
let files = [
|
||||
("rw2-panasonic-fz1000m2-4x3.rw2", 8), // 9.1 mm, ~13 % at the corners
|
||||
("rwl-leica-dlux7.rwl", 8), // 10.9 mm, ~13 %
|
||||
("rw2-panasonic-gx80.rw2", 8), // 14 mm, ~8 %
|
||||
("rw2-panasonic-gh6.rw2", 3), // 20 mm, ~1.6 %
|
||||
("rw2-panasonic-s9.rw2", 3), // 45 mm, 3 % zoom after the correction
|
||||
("rw2-panasonic-g9.rw2", 0), // 40 mm, ~0.5 %
|
||||
];
|
||||
const EDGE: usize = 640;
|
||||
const P: usize = 64;
|
||||
const R: i64 = 12;
|
||||
for (name, off_at_least) in files {
|
||||
let bytes = std::fs::read(dir.join(name)).unwrap();
|
||||
let probed = probe_bytes(name, &bytes).unwrap();
|
||||
assert!(probed.embedded_lens.is_some_and(|l| l.warp.is_some()), "{name}: no distortion correction read");
|
||||
let (src, info) = load_bytes(&bytes, 2 * EDGE).unwrap();
|
||||
assert_eq!(info.lens, probed.embedded_lens, "{name}: probe and decode disagree");
|
||||
// Convert to DNG keeps it: the same correction, as the DNG's own `WarpRectilinear`
|
||||
let dng = lightcraft_raw::write_dng(&lightcraft_raw::decode(&bytes).unwrap(), &Default::default()).unwrap();
|
||||
assert_eq!(probe_bytes("x.dng", &dng).unwrap().embedded_lens, probed.embedded_lens, "{name}: lost in the DNG");
|
||||
let mut s = lightcraft_develop::DevelopSettings::default();
|
||||
let render = |s: &lightcraft_develop::DevelopSettings| {
|
||||
lightcraft_pipeline::render(&src, &info, s, &lightcraft_pipeline::RenderRequest::fit(EDGE, EDGE)).image
|
||||
};
|
||||
let before = render(&s);
|
||||
s.optics.lens_profile = true;
|
||||
let after = render(&s);
|
||||
let (w, h) = (after.width, after.height);
|
||||
assert_eq!((before.width, before.height), (w, h));
|
||||
// the camera JPEG shows the whole sensor; a render in an in-camera aspect ratio is its centre
|
||||
let (cam, _) = load_embedded_preview(&bytes, 4 * EDGE).unwrap();
|
||||
let (cw, ch) = (cam.width as f64, cam.height as f64);
|
||||
let (sw, sh) = if cw / ch > w as f64 / h as f64 { (ch * w as f64 / h as f64, ch) } else { (cw, cw * h as f64 / w as f64) };
|
||||
let cam = cam.crop(((cw - sw) / 2.0).round() as usize, ((ch - sh) / 2.0).round() as usize, sw.round() as usize, sh.round() as usize);
|
||||
let cam = lightcraft_raster::resample::resize(&cam, w, h, Filter::Mitchell);
|
||||
let lin = |v: u8| (v as f32 / 255.0).powf(2.2);
|
||||
let lum8 = |im: &lightcraft_raster::Rgba8, x: usize, y: usize| {
|
||||
let p = im.data[y * im.width + x];
|
||||
0.3 * lin(p[0]) + 0.6 * lin(p[1]) + 0.1 * lin(p[2])
|
||||
};
|
||||
let reference = detail(w, h, |x, y| {
|
||||
let p = cam.data[y * w + x];
|
||||
0.3 * p[0] + 0.6 * p[1] + 0.1 * p[2]
|
||||
});
|
||||
let (db, da) = (detail(w, h, |x, y| lum8(&before, x, y)), detail(w, h, |x, y| lum8(&after, x, y)));
|
||||
let m = R as usize + 4;
|
||||
let corners = [(m, m), (w - m - P, m), (m, h - m - P), (w - m - P, h - m - P)];
|
||||
let mut measured = 0;
|
||||
for c in corners {
|
||||
let Some(((dx, dy), ca)) = patch_shift(&da, &reference, w, c, P, R) else { continue };
|
||||
measured += 1;
|
||||
assert!(dx.abs() <= 2 && dy.abs() <= 2 && ca >= 0.5, "{name}: corrected corner {c:?} off by ({dx}, {dy}) px, ncc {ca}");
|
||||
if off_at_least > 0
|
||||
&& let Some(((bx, by), cb)) = patch_shift(&db, &reference, w, c, P, R)
|
||||
{
|
||||
let off = bx.abs().max(by.abs()) >= off_at_least || cb < 0.3;
|
||||
assert!(off, "{name}: uncorrected corner {c:?} already within ({bx}, {by}) px, ncc {cb}");
|
||||
}
|
||||
}
|
||||
assert!(measured >= 2, "{name}: only {measured} corners with detail");
|
||||
}
|
||||
}
|
||||
|
||||
/// A CR3-shaped file (not decodable yet) whose only content is a `PRVW` preview box.
|
||||
fn cr3_with_preview(w: u32, h: u32) -> Vec<u8> {
|
||||
let px: Vec<u8> = (0..w * h).flat_map(|i| [(i % 251) as u8, 128, 200]).collect();
|
||||
|
||||
@@ -62,7 +62,7 @@ impl SettingsHashes {
|
||||
}
|
||||
|
||||
/// Bump when the pipeline's output changes, to invalidate cached thumbnails.
|
||||
pub const RENDER_CACHE_VERSION: u64 = 11;
|
||||
pub const RENDER_CACHE_VERSION: u64 = 12;
|
||||
|
||||
/// 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];
|
||||
|
||||
@@ -111,9 +111,7 @@ pub fn load_frame(bytes: &[u8], max_edge: Option<usize>, orient: bool) -> Result
|
||||
if lightcraft_raw::probe(bytes).is_some() {
|
||||
let mut raw = lightcraft_raw::decode(bytes).map_err(|e| MergeError::Decode(e.to_string()))?;
|
||||
// geometric lens corrections stay with the develop settings ("profile corrections")
|
||||
raw.opcodes
|
||||
.list3
|
||||
.retain(|op| !matches!(op, lightcraft_raw::Opcode::WarpRectilinear { .. } | lightcraft_raw::Opcode::FixVignetteRadial { .. }));
|
||||
raw.opcodes.list3.retain(|op| !op.is_lens_correction());
|
||||
let small = max_edge.is_some_and(|m| m <= 1200);
|
||||
let method = if small { lightcraft_raw::Method::Bilinear } else { lightcraft_raw::Method::Ahd };
|
||||
let mut image = raw.develop(method).map_err(|e| MergeError::Decode(e.to_string()))?;
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
//! 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`).
|
||||
//! Profile corrections use only lens data embedded in the files: DNG `WarpRectilinear` / `FixVignetteRadial`, and the
|
||||
//! raw readers' equivalent of a camera's own correction (Panasonic / Leica RW2 distortion).
|
||||
|
||||
use std::sync::Mutex;
|
||||
|
||||
|
||||
@@ -98,6 +98,13 @@ impl Opcode {
|
||||
pub fn is_applied(&self) -> bool {
|
||||
!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.
|
||||
pub fn is_lens_correction(&self) -> bool {
|
||||
matches!(self, Opcode::WarpRectilinear { .. } | Opcode::FixVignetteRadial { .. })
|
||||
}
|
||||
}
|
||||
|
||||
/// The three opcode lists of a DNG raw IFD.
|
||||
|
||||
Vendored
+214
-2
@@ -67,12 +67,38 @@
|
||||
//! - Active area: the sensor borders. Default crop: `0x002f`–`0x0032`, the in-camera aspect ratio (e.g. 4:3 or 1:1
|
||||
//! on a 3:2 sensor; the embedded JPEG always shows the whole sensor). Some bodies write zeros there: ignored.
|
||||
//!
|
||||
//! Lens distortion (tag `0x0119`, ExifTool name DistortionInfo; ExifTool's names for its words were the only outside
|
||||
//! input, their meaning below is our own measurement). The tag holds 16 little-endian 16-bit words. The camera's
|
||||
//! own correction, which its embedded JPEG shows, is applied as an `OpcodeList3` `WarpRectilinear` (the DNG form
|
||||
//! LightCraft already applies under "Enable Profile Corrections"):
|
||||
//!
|
||||
//! - Word 7, low 4 bits: 1 = correction on [0 on the DMC-GH1, whose JPEG matches the uncorrected render].
|
||||
//! - Word 12: the normalisation radius in pixels, the half diagonal of the active area [2870 for 4592 × 3448
|
||||
//! (2871.2), 3240 for 5184 × 3888, 3611 for 5776 × 4336, 3288 for the FZ1000 II's whole 5472 × 3648 sensor
|
||||
//! even in its 4:3 mode].
|
||||
//! - Words 8, 4 and 11 (ExifTool: DistortionParam08, 04, 11), divided by 32768, are `k2`, `k4`, `k6` of the
|
||||
//! correction: a sensor point at radius ρ (in units of word 12, about the centre of the active area) appears in
|
||||
//! the corrected image at ρ·(1 + k2 ρ² + k4 ρ⁴ + k6 ρ⁶).
|
||||
//! - Word 5 (ExifTool: DistortionScale), divided by 32768, plus 1, is the zoom `z` the camera crops by afterwards:
|
||||
//! output radius = corrected radius / z [z < 1 when the corrected corners would otherwise be empty].
|
||||
//!
|
||||
//! [These rules were chosen among alternative word orders, powers, directions and readings of the zoom by fitting
|
||||
//! radial curves between our uncorrected renders and the embedded JPEGs of 38 DMC-GX85 photos (12–32 mm and 25 mm
|
||||
//! lenses). With no free parameter the words then predict the curves of 35 held-out photos from 12 other bodies and
|
||||
//! 9 other lenses (9.1–105 mm: two compacts, Panasonic, Leica, Olympus, Sigma and Samyang lenses) within 0.12 % of
|
||||
//! the half diagonal, against up to 13 % uncorrected; corrected renders of all 79 photos line up with the camera
|
||||
//! JPEG within 0.75 px at 1440 px at every corner measured.] Words 2, 3, 6, 9, 10
|
||||
//! and 13 are not used (word 13 is always word 9 + 500; word 10 is fixed per body); words 0, 1, 14 and 15 change
|
||||
//! from shot to shot. The correction is radial, so it is fitted by the DNG form's odd polynomial `u·(kr0 + kr1 u² +
|
||||
//! kr2 u⁴ + kr3 u⁶)` over the output (within 0.4 px on a 20 MP sensor); implausible values (a radius far from the
|
||||
//! active area's, a zoom beyond ±25 %, a correction that folds over) leave the photo uncorrected.
|
||||
//!
|
||||
//! Not supported ([`RawError::Unsupported`], the embedded preview is shown instead): compression 34826, raw formats
|
||||
//! 1 and 3, and bit depths other than the ones above (no samples of any of them).
|
||||
|
||||
use super::pef::{Bits, Huffman, diff};
|
||||
use super::white_from_data;
|
||||
use crate::{BlackLevel, Cfa, ColorData, MAX_SAMPLES, Mode, OpcodeLists, RawData, RawError, RawFormat, RawImage, Rect, Result};
|
||||
use crate::{BlackLevel, Cfa, ColorData, MAX_SAMPLES, Mode, Opcode, OpcodeLists, RawData, RawError, RawFormat, RawImage, Rect, Result};
|
||||
use lightcraft_geom::Orientation;
|
||||
use lightcraft_tiff::{ByteOrder, Ifd, Tiff, Value, tags as t};
|
||||
use rayon::prelude::*;
|
||||
@@ -113,6 +139,14 @@ const FORMAT8_FIXED: [(u16, &[u16]); 9] = [
|
||||
(0x0041, &[17, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]),
|
||||
(0x0043, &[1]),
|
||||
];
|
||||
/// The camera's lens distortion correction (ExifTool name: DistortionInfo); see the module docs.
|
||||
const DISTORTION_INFO: u16 = 0x0119;
|
||||
/// Fixed-point one of the distortion coefficients and zoom.
|
||||
const DISTORTION_ONE: f64 = 32768.0;
|
||||
/// Output radii sampled to fit the inverse mapping.
|
||||
const DISTORTION_SAMPLES: usize = 64;
|
||||
/// Largest error of the fitted inverse (relative to the half diagonal) accepted: real lenses fit within 1.2e-4.
|
||||
const DISTORTION_MAX_FIT_ERROR: f64 = 1e-3;
|
||||
/// Most strips seen in a file is 3 (the 12008 × 8008 high-resolution mode).
|
||||
const MAX_STRIPS: usize = 64;
|
||||
|
||||
@@ -586,13 +620,121 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
|
||||
color: ColorData::default(),
|
||||
wb_multipliers: wb,
|
||||
linearized: false,
|
||||
opcodes: OpcodeLists::default(),
|
||||
opcodes: OpcodeLists { list3: distortion(ifd0, active).into_iter().collect(), ..Default::default() },
|
||||
metadata,
|
||||
};
|
||||
img.validate_for(mode)?;
|
||||
Ok(img)
|
||||
}
|
||||
|
||||
/// The camera's distortion correction (tag `0x0119`, see the module docs) as the equivalent DNG `WarpRectilinear`
|
||||
/// opcode for `OpcodeList3` (relative to the active area), so it reaches the same "profile corrections" as a DNG's
|
||||
/// embedded warp. `None` when the camera recorded the correction as off, when it is the identity, or when the
|
||||
/// values are implausible (the photo is then shown uncorrected rather than wrongly warped).
|
||||
fn distortion(ifd: &Ifd, active: Rect) -> Option<Opcode> {
|
||||
let b = ifd.bytes(DISTORTION_INFO)?;
|
||||
let word = |i: usize| -> Option<[u8; 2]> { b.get(2 * i..2 * i + 2)?.try_into().ok() };
|
||||
let signed = |i: usize| word(i).map(|w| i16::from_le_bytes(w) as f64);
|
||||
if u16::from_le_bytes(word(7)?) & 0x000f != 1 {
|
||||
return None;
|
||||
}
|
||||
let (k2, k4, k6) = (signed(8)? / DISTORTION_ONE, signed(4)? / DISTORTION_ONE, signed(11)? / DISTORTION_ONE);
|
||||
let zoom = 1.0 + signed(5)? / DISTORTION_ONE;
|
||||
let radius = u16::from_le_bytes(word(12)?) as f64;
|
||||
if k2 == 0.0 && k4 == 0.0 && k6 == 0.0 && zoom == 1.0 {
|
||||
return None;
|
||||
}
|
||||
// DNG's normalisation: the distance from the centre to the farthest corner, in pixel-index units
|
||||
let half_diag = (active.width as f64 - 1.0).hypot(active.height as f64 - 1.0) / 2.0;
|
||||
if !(half_diag >= 1.0 && (0.5 * half_diag..=2.0 * half_diag).contains(&radius) && (0.75..=1.25).contains(&zoom)) {
|
||||
return None;
|
||||
}
|
||||
// the camera's correction: a source point at radius ρ (in units of `radius`) moves to ρ·g(ρ)
|
||||
let corrected = |r: f64| {
|
||||
let r2 = r * r;
|
||||
r * (1.0 + r2 * (k2 + r2 * (k4 + r2 * k6)))
|
||||
};
|
||||
// the output corners show corrected radius `t_max`; the map must rise monotonically to it (a fold is no lens)
|
||||
let t_max = half_diag * zoom / radius;
|
||||
let mut top = None;
|
||||
let mut prev = 0.0;
|
||||
for i in 1..=4096 {
|
||||
let r = 3.0 * t_max * i as f64 / 4096.0;
|
||||
let v = corrected(r);
|
||||
if v.is_nan() || v <= prev {
|
||||
return None;
|
||||
}
|
||||
prev = v;
|
||||
if v >= t_max {
|
||||
top = Some(r);
|
||||
break;
|
||||
}
|
||||
}
|
||||
let top = top?;
|
||||
let source = |t: f64| {
|
||||
let (mut lo, mut hi) = (0.0, top);
|
||||
for _ in 0..60 {
|
||||
let m = 0.5 * (lo + hi);
|
||||
if corrected(m) < t { lo = m } else { hi = m }
|
||||
}
|
||||
0.5 * (lo + hi)
|
||||
};
|
||||
// least-squares fit of source radius = u·(kr0 + kr1·u² + kr2·u⁴ + kr3·u⁶) over the output radii u ∈ [0, 1]
|
||||
let samples: Vec<(f64, f64)> = (0..=DISTORTION_SAMPLES)
|
||||
.map(|i| {
|
||||
let u = i as f64 / DISTORTION_SAMPLES as f64;
|
||||
(u, source(u * t_max) * radius / half_diag)
|
||||
})
|
||||
.collect();
|
||||
let basis = |u: f64| {
|
||||
let u2 = u * u;
|
||||
[u, u * u2, u * u2 * u2, u * u2 * u2 * u2]
|
||||
};
|
||||
let mut a = [[0.0; 4]; 4];
|
||||
let mut rhs = [0.0; 4];
|
||||
for &(u, v) in &samples {
|
||||
let f = basis(u);
|
||||
for (i, fi) in f.iter().enumerate() {
|
||||
for (j, fj) in f.iter().enumerate() {
|
||||
a[i][j] += fi * fj;
|
||||
}
|
||||
rhs[i] += fi * v;
|
||||
}
|
||||
}
|
||||
let k = solve4(a, rhs)?;
|
||||
let fit = |u: f64| basis(u).iter().zip(&k).map(|(f, c)| f * c).sum::<f64>();
|
||||
let worst = samples.iter().map(|&(u, v)| (fit(u) - v).abs()).fold(0.0, f64::max);
|
||||
if worst.is_nan() || worst > DISTORTION_MAX_FIT_ERROR {
|
||||
return None;
|
||||
}
|
||||
Some(Opcode::WarpRectilinear { planes: vec![[k[0], k[1], k[2], k[3], 0.0, 0.0]], center: [0.5, 0.5] })
|
||||
}
|
||||
|
||||
/// Solve the 4 × 4 system `a·x = b` (Gaussian elimination with partial pivoting); `None` when singular or not finite.
|
||||
fn solve4(mut a: [[f64; 4]; 4], mut b: [f64; 4]) -> Option<[f64; 4]> {
|
||||
for c in 0..4 {
|
||||
let p = (c..4).max_by(|&i, &j| a[i][c].abs().total_cmp(&a[j][c].abs()))?;
|
||||
if a[p][c].is_nan() || a[p][c].abs() <= 1e-300 {
|
||||
return None;
|
||||
}
|
||||
a.swap(c, p);
|
||||
b.swap(c, p);
|
||||
for r in c + 1..4 {
|
||||
let f = a[r][c] / a[c][c];
|
||||
for k in c..4 {
|
||||
a[r][k] -= f * a[c][k];
|
||||
}
|
||||
b[r] -= f * b[c];
|
||||
}
|
||||
}
|
||||
let mut x = [0.0; 4];
|
||||
for c in (0..4).rev() {
|
||||
let s: f64 = (c + 1..4).map(|k| a[c][k] * x[k]).sum();
|
||||
x[c] = (b[c] - s) / a[c][c];
|
||||
}
|
||||
x.iter().all(|v| v.is_finite()).then_some(x)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -853,6 +995,76 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A tag-0x0119 block: 16 little-endian words, the given ones set.
|
||||
fn distortion_block(words: &[(usize, i16)]) -> Value {
|
||||
let mut w = [0i16; 16];
|
||||
for &(i, v) in words {
|
||||
w[i] = v;
|
||||
}
|
||||
Value::Undefined(w.iter().flat_map(|v| v.to_le_bytes()).collect())
|
||||
}
|
||||
|
||||
fn distorted(info: Option<Value>) -> Vec<Opcode> {
|
||||
let (w, h) = (90usize, 60usize);
|
||||
let px: Vec<u16> = (0..w * h).map(|i| (i % 4000) as u16 + 1).collect();
|
||||
let mut tags = vec![short(CFA_PATTERN, 1), short(BITS, 12), short(RAW_FORMAT, 5)];
|
||||
tags.extend(info.map(|v| (DISTORTION_INFO, v)));
|
||||
let bytes = file(w as u16, h as u16, &tags, &rotate(pack(&px, 12)), &|off| vec![(RAW_OFFSET, Value::Long(vec![off]))]);
|
||||
let r = crate::decode(&bytes).unwrap();
|
||||
assert_eq!(crate::probe_info(&bytes).unwrap().opcodes, r.opcodes, "headers-only probe reads the same correction");
|
||||
r.opcodes.list3
|
||||
}
|
||||
|
||||
/// Known answer: correction k2 = 4096/32768 = 1/8 (word 8), zoom 1 − 1024/32768 (word 5), radius 51 px (word 12)
|
||||
/// on an 86 × 58 active area. A sensor point at ρ appears at ρ + ρ³/8, so the output corner (corrected radius
|
||||
/// t = half diagonal · zoom / 51) shows the root of ρ³ + 8ρ − 8t = 0, here by Cardano's formula.
|
||||
#[test]
|
||||
fn distortion_correction_becomes_a_rectilinear_warp() {
|
||||
let on = |more: &[(usize, i16)]| {
|
||||
let mut w = vec![(7, 0x0001), (8, 4096), (5, -1024), (12, 51)];
|
||||
w.extend_from_slice(more);
|
||||
distortion_block(&w)
|
||||
};
|
||||
let ops = distorted(Some(on(&[])));
|
||||
let [Opcode::WarpRectilinear { planes, center }] = ops.as_slice() else { panic!("{ops:?}") };
|
||||
assert_eq!((planes.len(), *center), (1, [0.5, 0.5]));
|
||||
let k = planes[0];
|
||||
assert_eq!((k[4], k[5]), (0.0, 0.0), "radial only");
|
||||
let half_diag = 85f64.hypot(57.0) / 2.0;
|
||||
for u in [0.1, 0.25, 0.5, 0.75, 1.0] {
|
||||
let t = u * half_diag * (1.0 - 1024.0 / 32768.0) / 51.0;
|
||||
let (p, q) = (8.0, -8.0 * t);
|
||||
let d = (q * q / 4.0 + p * p * p / 27.0).sqrt();
|
||||
let rho = (-q / 2.0 + d).cbrt() + (-q / 2.0 - d).cbrt();
|
||||
let want = rho * 51.0 / half_diag;
|
||||
let got = u * (k[0] + k[1] * u * u + k[2] * u.powi(4) + k[3] * u.powi(6));
|
||||
// the 4-term odd polynomial approximates the exact inverse (5e-5 of the half diagonal ≈ 0.2 px at 20 MP)
|
||||
assert!((got - want).abs() < 5e-5, "u {u}: source radius {got}, want {want}");
|
||||
}
|
||||
|
||||
// no correction: no block, recorded as off, or the identity
|
||||
assert!(distorted(None).is_empty());
|
||||
assert!(distorted(Some(distortion_block(&[(7, 0x0000), (8, 4096), (12, 51)]))).is_empty(), "off");
|
||||
assert!(distorted(Some(distortion_block(&[(7, 0x7f00u16 as i16), (8, 4096), (12, 51)]))).is_empty(), "off, other bits set");
|
||||
assert!(distorted(Some(distortion_block(&[(7, 0x0001), (12, 51)]))).is_empty(), "identity");
|
||||
// hostile values are ignored, never a panic or a wild warp
|
||||
for (what, block) in [
|
||||
("truncated", Value::Undefined(vec![1, 0, 2, 0, 3])),
|
||||
("radius 0", on(&[(12, 0)])),
|
||||
("radius far beyond the sensor", on(&[(12, -1)])),
|
||||
("zoom 0", on(&[(5, -32768)])),
|
||||
("folds over", on(&[(8, -32768)])),
|
||||
("folds over at high order", on(&[(11, -32768), (4, -32768)])),
|
||||
("runs away", on(&[(8, 32767), (4, 32767), (11, 32767)])),
|
||||
] {
|
||||
let ops = distorted(Some(block));
|
||||
assert!(ops.iter().all(|op| !matches!(op, Opcode::WarpRectilinear { .. })) || what == "runs away", "{what}: {ops:?}");
|
||||
if let [Opcode::WarpRectilinear { planes, .. }] = ops.as_slice() {
|
||||
assert!(planes[0].iter().all(|c| c.is_finite()), "{what}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packed_and_word_formats() {
|
||||
let (w, h) = (90usize, 200usize); // > one chunk of data
|
||||
|
||||
@@ -44,7 +44,7 @@ On one sample, Exposure +1 EV, Saturation +100, Contrast +50, warm/cool WB, tint
|
||||
|
||||
The same fit, gates and relative WB apply to Panasonic RW2, Leica RWL and the older Panasonic RAW files (all decode as `RawFormat::Rw2`; white balance from tags `0x0024`–`0x0026`). Their embedded JPEG (`JpgFromRaw`, 1920 px wide) always shows the whole active area, while the default crop is the aspect ratio set in the camera (4:3 on the 3:2 sensors, 1:1, 16:9, 2.71 on the S1R II…); when the preview matches the active area rather than the crop, the reference is cropped to the default crop before fitting.
|
||||
|
||||
Public CC0 samples from raw.pixls.us (178 files from 118 Panasonic and Leica bodies; the four from 2005–2007 carry no preview), rendered at 1200 px and compared with each file's embedded JPEG (mean CIE76 ΔE at 200 px): 140 of 174 fits accepted, from 93 of the 114 bodies with a preview, median ΔE 5.0 (1.2–21.3) with matching mean lightness (median |ΔL*| 0.4); 124 of them use a hue/saturation table. (Fitted with the matrix and tone curve alone, 137 were accepted at median ΔE 6.1; every one of those is as close or closer with the table and chroma curve, by 0.75 on average.) On the 14 corpus files with a preview the neutral fallback scored ΔE 9.6–27 and rendered up to 16 L* too dark; 13 fits were accepted, ΔE 2.8–8.9, lightness within 2 L*. Of the 34 files left on the neutral fallback, 21 fail the gates, almost all compacts and kit zooms (TZ/ZS, FZ2500, D-Lux, V-Lux, GF2, GX80 with its 12–32 mm): their camera JPEG is distortion-corrected, the pixel pairs no longer line up and the held-out RMS stays above 0.10 (GX80: 0.138 → 0.107); two S1 II shots just miss it (0.103, 0.107). The other 13 never reach the gates, mostly nearly colourless scenes (grey walls, concrete: under 5% coloured pixels) or dark ones (a night sky, a near-black frame). Panasonic's embedded distortion data (tag `0x0119`) is not used yet, and `lightcraft-cli calibrate` doesn't collect RW2 files: the misaligned pairs of distortion-corrected JPEGs would distort a pooled profile.
|
||||
Public CC0 samples from raw.pixls.us (178 files from 118 Panasonic and Leica bodies; the four from 2005–2007 carry no preview), rendered at 1200 px and compared with each file's embedded JPEG (mean CIE76 ΔE at 200 px): 140 of 174 fits accepted, from 93 of the 114 bodies with a preview, median ΔE 5.0 (1.2–21.3) with matching mean lightness (median |ΔL*| 0.4); 124 of them use a hue/saturation table. (Fitted with the matrix and tone curve alone, 137 were accepted at median ΔE 6.1; every one of those is as close or closer with the table and chroma curve, by 0.75 on average.) On the 14 corpus files with a preview the neutral fallback scored ΔE 9.6–27 and rendered up to 16 L* too dark; 13 fits were accepted, ΔE 2.8–8.9, lightness within 2 L*. Of the 34 files left on the neutral fallback, 21 fail the gates, almost all compacts and kit zooms (TZ/ZS, FZ2500, D-Lux, V-Lux, GF2, GX80 with its 12–32 mm): their camera JPEG is distortion-corrected, the pixel pairs no longer line up and the held-out RMS stays above 0.10 (GX80: 0.138 → 0.107); two S1 II shots just miss it (0.103, 0.107). The other 13 never reach the gates, mostly nearly colourless scenes (grey walls, concrete: under 5% coloured pixels) or dark ones (a night sky, a near-black frame). Panasonic's embedded distortion data (tag `0x0119`) now corrects the render under Enable Profile Corrections (issue #256) but does not yet align the pairs of this fit, and `lightcraft-cli calibrate` doesn't collect RW2 files: the misaligned pairs of distortion-corrected JPEGs would distort a pooled profile.
|
||||
|
||||
This is a per-file camera-look estimate, **not measured spectral calibration or Lightroom parity**. Camera local tone, picture styles, noise reduction and lens warps cannot all be reproduced by a global matrix/curve; same-aspect crops may evade the aspect check. Skin, mixed-light scenes and saturated highlights can still differ from a supplied JPEG. Broader Sony and Nikon model/lighting coverage, chart-based calibration, absolute WB and other manufacturers remain unverified. Rejected fits and other RAW formats retain their existing behaviour.
|
||||
|
||||
|
||||
+3
-2
@@ -67,7 +67,8 @@ Take the first one nobody is working on.
|
||||
colour-filter layout, fixed in #85 by reading the file's own tag).
|
||||
4. **LR-BEHAV-RENDER-FIDELITY** (P1): a side-by-side fidelity suite against Lightroom renders (kept local in `plan/`),
|
||||
then tune tone, highlights, texture/clarity/dehaze, NR and sharpening against it.
|
||||
5. **LR-EDIT-OPTICS-PROFILE** (P1): a lens-profile database of our own (embedded DNG/maker corrections work today).
|
||||
5. **LR-EDIT-OPTICS-PROFILE** (P1): a lens-profile database of our own (corrections embedded in DNG files and the
|
||||
distortion correction Panasonic / Leica cameras record in RW2 / RWL files work today; issue #256).
|
||||
6. **AI masks and Enhance** (LR-MASK-SUBJECT / SKY / PEOPLE / OBJECTS, LR-EDIT-DETAIL-DENOISE, SUPERRES, LENSBLUR):
|
||||
Object and Describe masks run SAM 3 in pure Rust (`crates/segment`); the weights (SAM License) are never bundled:
|
||||
the app offers a consented, verified download, but **LightCraft's CDN mirrors are not configured yet** (the
|
||||
@@ -254,7 +255,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
|
||||
| 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` | uses corrections embedded in DNG/raw files; no lens-profile database |
|
||||
| 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` | 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); vignetting and lateral CA data of other raw formats (Olympus, Fujifilm, Sony) not read yet; no lens-profile database |
|
||||
| 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` | |
|
||||
| LR-EDIT-GEOM-UPRIGHT | Upright | P1 | ✅ | `cmd:geometry.upright`, `cmd:geometry.guides` | |
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
## October 2026
|
||||
|
||||
### RAW decoding
|
||||
- Panasonic and Leica raws (RW2, RWL) are now corrected for lens distortion the way the camera corrects its own JPEG
|
||||
(issue #256): the correction the camera records in the file is applied under Lens Corrections ▸ Enable Profile
|
||||
Corrections, on by default for newly imported photos, with the same framing as the camera's JPEG. At 12 mm the
|
||||
12–32 mm kit zoom was off by about 5 % of the image width at the corners before. Files shot with the correction off
|
||||
are unchanged; photos imported before this change get it when imported again.
|
||||
- Sony ILCE-7M4 downsized lossless ARWs now decode subsampled YCbCr tiles into linear RGB,
|
||||
preserving RAW editing & full-resolution export instead of using embedded JPEG previews.
|
||||
|
||||
|
||||
Reference in new issue
Block a user