# Conflicts:
#	crates/raw/src/vendor/arw.rs
This commit is contained in:
Brandon Thomas 2026-10-10 03:16:18 -04:00
commit 4539014a42
9 changed files with 427 additions and 11 deletions

View File

@ -36,7 +36,7 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78).
| User | Readiness | What blocks them |
|---|---:|---|
| JPEG / DNG shooter, single machine | ~85% | Fidelity polish, AI masks |
| Nikon / Sony / Panasonic / older-Canon raw shooter | ~65% | Camera colour fidelity and coverage (ARW, NEF and RW2 preview estimates are only a starting point); Panasonic compacts and kit zooms also miss their embedded distortion correction |
| Nikon / Sony / Panasonic / older-Canon raw shooter | ~65% | Camera colour fidelity and coverage (ARW, NEF and RW2 preview estimates are only a starting point); embedded distortion is supported for Panasonic / Leica RW2 and the Sony ILCE-7RM4A (Bayer ARW, 24–105 / 200–600 tested); other lens metadata needs coverage and validation |
| Fujifilm raw shooter | ~65% | Uncompressed and lossless/lossy compressed RAF decoded; camera colour calibration remains missing. Verified on 13 bodies, including 14/16-bit GFX; see `docs/raf-compression.md` |
| Canon CR3 / Olympus compressed-raw shooter | ~45% | CR3 lossless Bayer and version 0x100/0x200 C-RAW develop from sensor data (M50, R100, R8 verified pixel for pixel); other CRX variants and compressed ORF open as embedded JPEG previews. Camera colour and broader model verification remain |
| Lightroom Classic power user | ~45% | Print, Book, Map, publish, tethering |
@ -57,7 +57,8 @@ Priorities, in order. Each points at tracker rows in [`docs/parity.md`](docs/par
checked for plausible colour; fix per-model bugs (#85).
4. **Render fidelity suite** (LR-BEHAV-RENDER-FIDELITY, P1): measure our output against Lightroom on the same CC0 raws
(references stay in the local `plan/`), then tune against the numbers.
5. **Lens profiles of our own** (LR-EDIT-OPTICS-PROFILE, P1).
5. **Lens profiles of our own** (LR-EDIT-OPTICS-PROFILE, P1): DNG, Panasonic / Leica RW2 and Sony ILCE-7RM4A Bayer ARW
embedded distortion are supported; broaden validation and decode remaining vignetting / lateral CA metadata.
6. **AI model strategy** (maintainer decision): which permissively licensed models (or our own training) for
segmentation masks and denoise. Bayer denoise inference is implemented in pure Rust with an opt-in GPL model; release policy and independently licensed weights remain open. Unblocks M12 and Enhance. Face detection and recognition run on our own checked Rust engine. Object / Describe masks now
run Meta's SAM 3 in pure Rust (optional, user-consented download under the non-OSI SAM License; CDN mirrors still

View File

@ -62,7 +62,7 @@ impl SettingsHashes {
}
/// Bump when the pipeline's output changes, to invalidate cached thumbnails.
pub const RENDER_CACHE_VERSION: u64 = 38;
pub const RENDER_CACHE_VERSION: u64 = 39;
/// Thumbnails render at one of these long edges (so window/cell size changes reuse the cache).
pub const THUMB_SIZES: [usize; 4] = [128, 256, 384, 512];

View File

@ -485,19 +485,27 @@ fn word16_order(strip: &[u8], bits: u32, file: lightcraft_tiff::ByteOrder) -> li
if n > 0 && over_other == 0 && over_file * 100 > n { other } else { file }
}
/// The image area for files without Sony's crop tags (`0x74c7/0x74c8`, written since about 2017): the DNG-style
/// default crop when the raw IFD has one, else the image size the camera records (see [`recorded_size_crop`]).
/// The raw IFD's DNG-style `DefaultCropOrigin` / `DefaultCropSize`, clipped to the `w × h` frame.
///
/// `centred` places that recorded-size window in the middle of the frame (on even offsets, keeping the CFA phase)
/// instead of at the left: the packed 12-bit frame (DSLR-A900) has no padding at either edge, and its camera JPEG
/// is centred on the frame.
fn default_crop(raw: &Ifd, mn: Option<&makernote::MakerNote>, exif_size: Option<(u64, u64)>, w: usize, h: usize, centred: bool) -> Rect {
let full = Rect::new(0, 0, w, h);
fn dng_default_crop(raw: &Ifd, w: usize, h: usize, centred: bool) -> Option<Rect> {
if let (Some([x, y]), Some([cw, ch])) = (raw.u64s(t::DEFAULT_CROP_ORIGIN).as_deref(), raw.u64s(t::DEFAULT_CROP_SIZE).as_deref())
&& *cw > 0
&& *ch > 0
{
return Rect::new(*x as usize, *y as usize, *cw as usize, *ch as usize).clipped(w, h);
return Some(Rect::new(*x as usize, *y as usize, *cw as usize, *ch as usize).clipped(w, h));
}
None
}
/// The image area for files without Sony's crop tags (`0x74c7/0x74c8`, written since about 2017): the DNG-style
/// default crop when the raw IFD has one, else the image size the camera records (see [`recorded_size_crop`]).
fn default_crop(raw: &Ifd, mn: Option<&makernote::MakerNote>, exif_size: Option<(u64, u64)>, w: usize, h: usize) -> Rect {
let full = Rect::new(0, 0, w, h);
if let Some(crop) = dng_default_crop(raw, w, h) {
return crop;
}
match mn.and_then(|m| m.ifd.u64s(MN_FULL_IMAGE_SIZE)).as_deref() {
Some([fh, fw]) => recorded_size_crop((*fw, *fh), exif_size, w, h, centred).unwrap_or(full),
@ -681,7 +689,19 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
// makes a neutral surface magenta. WB edits remain relative to this as-shot RGB.
wb_multipliers: if linear_rgb { Some([1.0; 3]) } else { wb },
linearized: false,
opcodes: OpcodeLists::default(),
// The distortion table is centred on the DNG-style default crop when the file has one: on the ILCE-7RM4A
// it starts at x = 32 where Sony's crop tags (the image crop, unchanged) start at 0, and the warp was
// validated against Sony's exports with that centre. The opcode's centre is in active-area coordinates, so
// it stays on the optical centre whichever crop frames the image.
opcodes: OpcodeLists {
list3: if linear_rgb {
Vec::new()
} else {
let geometry = dng_default_crop(raw, w, h).unwrap_or(crop);
super::arw_lens::distortion(&model, raw, Rect::new(0, 0, w, h), geometry).into_iter().collect()
},
..Default::default()
},
metadata,
};
img.validate_for(mode)?;

230
crates/raw/src/vendor/arw_lens.rs vendored Normal file
View File

@ -0,0 +1,230 @@
//! Sony's plain raw-IFD distortion table, expressed as a DNG warp for the shared optics pipeline.
//!
//! Tag identity/layout: https://exiftool.org/TagNames/EXIF.html (`0x7037`, signed shorts).
//! Geometry inferred independently from files and Sony Imaging Edge exports; see docs/sony-lens-corrections.md.
//! No lens database or external decoder code. The unrelated MakerNote table has a different scale.
use crate::{Opcode, Rect};
use lightcraft_tiff::{Ifd, Value};
const DISTORTION: u16 = 0x7037;
const SAMPLES: usize = 1024;
const MAX_ERROR: f64 = 0.0005;
/// Only independently validated camera models and the 16-entry layout. Unknown models/layouts
/// and implausible/non-smooth maps are left uncorrected. See docs/sony-lens-corrections.md.
/// A camera setting of Off does not remove the lens data: the user can enable it in the developer.
pub(super) fn distortion(model: &str, ifd: &Ifd, active: Rect, crop: Rect) -> Option<Opcode> {
// A matching table shape does not establish its scale or radial normalization on another body.
// Keep this exact: ILCE-7RM4 and ILCE-7RM4A are distinct models, not interchangeable aliases.
if model != "ILCE-7RM4A" {
return None;
}
let Value::SShort(values) = &ifd.get(DISTORTION)?.value else { return None };
let [16, table @ ..] = values.as_slice() else { return None };
let table: &[i16; 16] = table.try_into().ok()?;
if table.iter().all(|v| *v == 0) || table.iter().any(|v| !(-4096..=4096).contains(v)) {
return None;
}
let crop = crop.clipped(active.width, active.height);
if active.width < 2 || active.height < 2 || crop.width < 2 || crop.height < 2 {
return None;
}
// Only native 3:2 framing has been validated. An in-camera aspect crop may retain a table
// normalized to the full sensor frame; do not silently reinterpret it as the smaller crop.
if (crop.width as f64 / crop.height as f64 - 1.5).abs() > 0.005 {
return None;
}
// Interpolate fractional radial scale at evenly spaced radii from centre to cropped corner.
let scale = |r: f64| {
let at = r.clamp(0.0, 1.0) * 15.0;
let i = (at.floor() as usize).min(14);
let a = f64::from(table[i]);
let b = f64::from(table[i + 1]);
1.0 + (a + (b - a) * (at - i as f64)) / 16384.0
};
let (cw, ch) = (crop.width as f64 - 1.0, crop.height as f64 - 1.0);
let diagonal = cw.hypot(ch);
// Zoom until the entire rectangular output boundary is inside the source. For a piecewise-linear
// scale its maximum on that boundary occurs at its nearest radius or at one of the remaining knots.
let nearest = cw.min(ch) / diagonal;
let max_scale = (0..16).map(|i| i as f64 / 15.0).filter(|&r| r >= nearest).map(scale).fold(scale(nearest), f64::max);
let zoom = 1.0 / max_scale;
let basis = |r: f64| {
let r2 = r * r;
[r, r * r2, r * r2 * r2, r * r2 * r2 * r2]
};
let mut matrix = [[0.0; 4]; 4];
let mut rhs = [0.0; 4];
let mut previous = -1.0;
for j in 0..=SAMPLES {
let r = j as f64 / SAMPLES as f64;
let source = r * scale(r) * zoom;
if source <= previous {
return None;
}
previous = source;
let terms = basis(r);
for (i, &a) in terms.iter().enumerate() {
rhs[i] += a * source;
for (k, &b) in terms.iter().enumerate() {
matrix[i][k] += a * b;
}
}
}
let mut k = solve4(matrix, rhs)?;
// Keep the polynomial within a fraction of a preview pixel of the file's table, and forbid folds.
for j in 0..=SAMPLES {
let r = j as f64 / SAMPLES as f64;
let fit = basis(r).iter().zip(k).map(|(a, b)| a * b).sum::<f64>();
let r2 = r * r;
let derivative = k[0] + r2 * (3.0 * k[1] + r2 * (5.0 * k[2] + r2 * 7.0 * k[3]));
if (fit - r * scale(r) * zoom).abs() > MAX_ERROR || derivative <= 0.0 {
return None;
}
}
// DNG uses the farthest *active-area* corner, not the cropped diagonal. Preserve the same mapping
// after the engine translates the opcode into default-cropped and EXIF-oriented coordinates.
let (ax, ay) = (active.width as f64 - 1.0, active.height as f64 - 1.0);
let (cx, cy) = (crop.x as f64 + cw / 2.0, crop.y as f64 + ch / 2.0);
let farthest = cx.max(ax - cx).hypot(cy.max(ay - cy));
let ratio2 = (2.0 * farthest / diagonal).powi(2);
for (i, coefficient) in k.iter_mut().enumerate() {
*coefficient *= ratio2.powi(i as i32);
}
Some(Opcode::WarpRectilinear { planes: vec![[k[0], k[1], k[2], k[3], 0.0, 0.0]], center: [cx / ax, cy / ay] })
}
/// Small fixed-size least-squares system; partial pivoting, no input-sized allocations.
fn solve4(mut a: [[f64; 4]; 4], mut b: [f64; 4]) -> Option<[f64; 4]> {
for col in 0..4 {
let pivot = (col..4).max_by(|&i, &j| a[i][col].abs().total_cmp(&a[j][col].abs()))?;
if !a[pivot][col].is_finite() || a[pivot][col].abs() < 1e-12 {
return None;
}
a.swap(col, pivot);
b.swap(col, pivot);
for row in col + 1..4 {
let factor = a[row][col] / a[col][col];
for j in col..4 {
a[row][j] -= factor * a[col][j];
}
b[row] -= factor * b[col];
}
}
let mut x = [0.0; 4];
for i in (0..4).rev() {
x[i] = (b[i] - (i + 1..4).map(|j| a[i][j] * x[j]).sum::<f64>()) / a[i][i];
}
x.iter().all(|v| v.is_finite()).then_some(x)
}
#[cfg(test)]
mod tests {
use super::*;
use lightcraft_tiff::Entry;
fn ifd(value: Value) -> Ifd {
Ifd { entries: vec![Entry { tag: DISTORTION, value, offset: 0 }], ..Default::default() }
}
fn table(values: &[i16]) -> Ifd {
ifd(Value::SShort(std::iter::once(16).chain(values.iter().copied()).collect()))
}
const TELE: [i16; 16] = [-2, 1, 7, 17, 31, 48, 68, 93, 120, 152, 187, 225, 265, 309, 356, 403];
const WIDE: [i16; 16] = [14, 0, -27, -59, -106, -158, -223, -291, -367, -440, -519, -593, -667, -731, -792, -839];
const AREA: Rect = Rect { x: 0, y: 0, width: 9504, height: 6336 };
#[test]
fn matches_independent_sony_reference_geometry() {
// On/off feature correspondences from Imaging Edge 4.1 at 600 mm, independently fitted.
let Opcode::WarpRectilinear { planes, center } = distortion("ILCE-7RM4A", &table(&TELE), AREA, AREA).unwrap() else { panic!("warp") };
assert_eq!(center, [0.5, 0.5]);
let reference = [0.9760159044, 0.0254891644, -0.0005984764, -0.0010120638];
for i in 0..=100 {
let r = i as f64 / 100.0;
let evaluate = |k: &[f64]| r * (k[0] + r * r * (k[1] + r * r * (k[2] + r * r * k[3])));
assert!((evaluate(&planes[0]) - evaluate(&reference)).abs() < 0.00015);
}
}
#[test]
fn barrel_framing_and_offset_crop_keep_the_same_geometry() {
let crop = Rect::new(32, 20, 9504, 6336);
let active = Rect::new(0, 0, 9600, 6376);
let Opcode::WarpRectilinear { planes: a, .. } = distortion("ILCE-7RM4A", &table(&WIDE), AREA, AREA).unwrap() else { panic!("warp") };
let Opcode::WarpRectilinear { planes: b, center } = distortion("ILCE-7RM4A", &table(&WIDE), active, crop).unwrap() else { panic!("warp") };
assert!(a[0][0] > 1.02 && a[0][0] < 1.03, "barrel correction expands the middle");
assert!((center[0] * 9599.0 - 4783.5).abs() < 1e-10);
assert!((center[1] * 6375.0 - 3187.5).abs() < 1e-10);
let ratio = (4815.5f64.hypot(3187.5)) / (4751.5f64.hypot(3167.5));
for i in 0..4 {
assert!((a[0][i] - b[0][i] / ratio.powi(2 * i as i32)).abs() < 1e-10);
}
}
#[test]
fn refuses_missing_unknown_malformed_or_unusable_tables() {
for bad in [
Ifd::default(),
table(&[]),
table(&TELE[..15]),
table(&[0; 16]),
table(&[i16::MIN; 16]),
table(&[0, 4096, -4096, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]),
ifd(Value::SShort(vec![15; 17])),
ifd(Value::Short(vec![16; 17])),
ifd(Value::Double(vec![f64::NAN; 17])),
] {
assert!(distortion("ILCE-7RM4A", &bad, AREA, AREA).is_none());
}
assert!(distortion("ILCE-7RM4A", &table(&TELE), Rect::new(0, 0, 1, 1), AREA).is_none());
assert!(distortion("ILCE-7RM4A", &table(&TELE), AREA, Rect::new(0, 0, 9504, 5346)).is_none());
assert!(distortion("ILCE-7RM4A", &table(&TELE), AREA, Rect::new(99999, 0, 10, 10)).is_none());
}
#[test]
fn refuses_unvalidated_models_even_with_a_valid_table() {
let valid = table(&TELE);
assert!(distortion("ILCE-7RM4A", &valid, AREA, AREA).is_some());
for model in ["", "ILCE-7M3", "ILCE-7M4", "ILCE-7RM4", "ILCE-7RM5", "ILCE-9M2", "DSC-RX100M3", "ILCE-7RM4A unknown"] {
assert!(distortion(model, &valid, AREA, AREA).is_none(), "{model}");
}
}
#[test]
fn header_and_full_decode_preserve_correction_through_dng() {
use lightcraft_tiff::{ByteOrder, IfdBuilder, ImageData, TiffWriter, tags as t};
for (model, expected) in [("ILCE-7RM4A", 1), ("ILCE-7RM4", 0), ("ILCE-7M3", 0), ("", 0)] {
for order in [ByteOrder::Little, ByteOrder::Big] {
let mut raw = IfdBuilder::new();
raw.set(t::MAKE, Value::Ascii("SONY".into()));
raw.set(t::MODEL, Value::Ascii(model.into()));
raw.set(t::IMAGE_WIDTH, Value::Long(vec![32]));
raw.set(t::IMAGE_LENGTH, Value::Long(vec![24]));
raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![16]));
raw.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1]));
raw.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA]));
raw.set(t::COMPRESSION, Value::Short(vec![1]));
raw.set(t::WHITE_LEVEL, Value::Long(vec![16383]));
raw.set(0x74c7, Value::Long(vec![0, 2]));
raw.set(t::DEFAULT_CROP_ORIGIN, Value::Long(vec![2, 2]));
raw.set(t::DEFAULT_CROP_SIZE, Value::Long(vec![30, 20]));
raw.set(0x74c8, Value::Long(vec![30, 20]));
raw.set(DISTORTION, Value::SShort(std::iter::once(16).chain(TELE).collect()));
// Zero pixels have the same byte representation in both orders.
raw.set_image(ImageData::Strips { rows_per_strip: 24, strips: vec![vec![0; 32 * 24 * 2]] });
let file = TiffWriter { order, ..Default::default() }.write(&[raw]).unwrap();
let header = super::super::arw::decode(&file, crate::Mode::Header).unwrap();
let full = super::super::arw::decode(&file, crate::Mode::Full).unwrap();
assert_eq!(header.info(), full.info());
assert_eq!(full.opcodes.list3.len(), expected, "{model} {order:?}");
// the image keeps Sony's crop tags; the warp is centred on the DNG default crop (x 2..32 of 32)
assert_eq!(full.crop, Rect::new(0, 2, 30, 20));
if let Some(Opcode::WarpRectilinear { center, .. }) = full.opcodes.list3.first() {
assert!((center[0] - 16.5 / 31.0).abs() < 1e-12 && (center[1] - 11.5 / 23.0).abs() < 1e-12, "{center:?}");
}
let dng = crate::write_dng(&full, &Default::default()).unwrap();
assert_eq!(crate::decode(&dng).unwrap().opcodes.list3, full.opcodes.list3);
}
}
}
}

View File

@ -1,6 +1,7 @@
//! Vendor raw formats (TIFF-based) and helpers shared by them.
pub mod arw;
mod arw_lens;
pub mod cr2;
pub mod cr3;
pub mod crx;

View File

@ -896,3 +896,38 @@ fn corpus_raws_keep_their_container_and_are_not_thumbnail_shells() {
}
eprintln!("{checked} corpus raws keep their container");
}
/// Matching Sony table dimensions are not evidence of a shared correction model. Exercise the
/// actual corpus headers and decoded files as well as the synthetic model-boundary unit tests.
#[test]
fn sony_embedded_distortion_is_limited_to_validated_models() {
let root = corpus_root().join("raw");
let mut checked = 0;
for (name, model, expected) in [
("arw-sony-a7rm4a-compressed.arw", "ILCE-7RM4A", 1),
("arw-sony-a9m2-compressed.arw", "ILCE-9M2", 0),
("arw-sony-a7m3-compressed.arw", "ILCE-7M3", 0),
("arw-sony-a7m3-uncompressed.arw", "ILCE-7M3", 0),
("arw-sony-a7m4-14bit.arw", "ILCE-7M4", 0),
("arw-sony-a7m4-lossless-l.arw", "ILCE-7M4", 0),
("arw-sony-a7m4-lossless-m.arw", "ILCE-7M4", 0),
("arw-sony-a7m4-lossless-s.arw", "ILCE-7M4", 0),
("arw-sony-a7rm2-12bit-uncompressed.arw", "ILCE-7RM2", 0),
("arw-sony-rx100.arw", "DSC-RX100", 0),
("arw-sony-rx100m3.arw", "DSC-RX100M3", 0),
] {
let path = root.join(name);
if !path.exists() {
eprintln!("skip: {} absent", path.display());
continue;
}
let bytes = std::fs::read(&path).unwrap();
let full = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}"));
assert_eq!(probe_info(&bytes).unwrap(), full.info(), "{name}");
assert_eq!(full.metadata.model.as_deref(), Some(model), "{name}");
assert_eq!(full.opcodes.list3.len(), expected, "{name}: only the validated Sony model gets a distortion warp");
eprintln!("{name}: {expected} warp(s); header and full decode agree");
checked += 1;
}
eprintln!("Sony distortion model boundary: {checked} corpus files checked");
}

View File

@ -76,7 +76,9 @@ Take the first one nobody is working on.
4b. **LR-VIEW-ZOOM** (P0): finish the sharp zoomed views of issue #323: windows for compare, overlays and soft proofing
(Before/After has them); check sharpness and the wide-stage margin on real raw photos (only synthetic scenes so far).
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).
distortion correction Panasonic / Leica cameras record in RW2 / RWL files work today; issue #256). Sony ILCE-7RM4A Bayer
ARW 16-sample tables in native 3:2 framing now feed the same distortion path; broader coverage and Sony
vignetting / lateral CA remain (see `docs/sony-lens-corrections.md`).
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
@ -274,7 +276,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`, `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); 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); 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`, `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); 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` | |

View File

@ -0,0 +1,117 @@
# Sony embedded distortion corrections
LightCraft reads the signed 16-sample distortion table in the raw image IFD (`0x7037`) of
Sony **ILCE-7RM4A (A7R IVA)** Bayer ARWs. Other camera models are deliberately left uncorrected,
even when they contain a table with the same layout.
It converts that table into `OpcodeList3` / `WarpRectilinear`, so the existing profile correction control,
CPU/GPU optics path, EXIF orientation handling, and DNG export use the same correction.
This adds **distortion only**. Sony vignetting and lateral chromatic-aberration tables are not decoded.
The enabled and independently validated model is ILCE-7RM4A with the FE 24–105mm F4 G OSS and
FE 200–600mm F5.6–6.3 G OSS. Linear YCbCr ARWs and non-3:2 camera crops are excluded pending validation. An aspect crop may
retain the full-frame radial normalization; the decoder does not guess that relationship. Older files that carry
only encrypted correction metadata, different table lengths, and rejected tables remain uncorrected.
The camera's distortion Off setting does not erase its table. Newly imported photos use LightCraft's existing
embedded-profile default (enabled); the profile correction control can disable or adjust it.
## Clean-room evidence
The [ExifTool EXIF tag-name documentation](https://exiftool.org/TagNames/EXIF.html) identifies
`0x7037` as 17 signed 16-bit integers. The first value is the count, 16, followed by radial samples.
No external photo decoder implementation or Adobe lens profile was consulted.
Sony Imaging Edge Edit 4.1 was used only as a black-box reference on local scratch copies.
The observed inverse mapping, with radius `r` normalized to the default crop's half diagonal, is:
```
f(r) = 1 + interpolate(table, r) / 16384
source = center + (output - center) * zoom * f(r)
zoom = 1 / max(f(r)) over radii on the rectangular output boundary
```
The border radii range from the shorter half edge / half diagonal to 1. Table knots span 0 through 1.
Linear interpolation approximates the observed map closely; we do not claim to reproduce Sony's
internal interpolator. A fixed-size weighted least-squares fit expresses source radius as
`r * (k0 + k1*r² + k2*r⁴ + k3*r⁶)`. The fit is rejected if radial error exceeds 0.0005 of the
half diagonal (about 0.43 px at a 1440 px 3:2 preview), or the mapping folds. Table values are bounded
and the input must have precisely the supported signed-short layout. Crop offset and normalization
are converted into DNG's active-area coordinate system before creating the opcode. The warp's centre
and normalization come from the standard `DefaultCropOrigin` / `DefaultCropSize` when the file has them,
not from Sony's crop tags: on the tested A7R IVA files the former start at raw x=32 and the latter at
x=0, and the standard origin is the one that aligns with Sony's exports. The image itself keeps the
decoder's crop precedence for every Sony body (Sony's crop tags, then the default crop, then the
recorded image size), so the framing is unchanged; the opcode's centre is in active-area coordinates,
so the warp stays on the optical centre either way. On the A7R IVA that framing still sits 32 raw
pixels left of Sony's exports (eight pixels in a 2376-pixel-wide export).
The main raw-IFD table is important: the similarly named MakerNote table has different values and
must not be substituted into this formula. Main raw-IFD and SR2SubIFD values agreed in the test files.
## Validation
At 2376×1584, independent SIFT correspondences between Sony distortion On and Off TIFF exports gave:
| Lens / focal length | Median residual | 95th percentile | Maximum matched radius |
|---|---:|---:|---:|
| FE 200–600 / 200 mm | 0.043 px | 0.191 px | 0.97 half diagonals |
| FE 200–600 / 600 mm | 0.055 px | 0.231 px | 0.95 half diagonals |
These use the fixed 16384 scale and automatic border framing, not fitted per-image scale or zoom.
A median translation below 0.004 px was removed from the residuals. Correspondences do not cover
all pixels; these are geometric feature residuals, not photometric equality or a worst-case guarantee.
Across 20 sampled RAWs (16 at 24–105 mm, four at 200–600 mm), the polynomial's maximum deviation
from the interpolated file table was below 0.12 px at 1440 px. That measures approximation error,
not agreement with an independent renderer. Camera JPEG and Apple Core Image comparisons support
the wide-lens interpretation but are weaker evidence than same-renderer On/Off pairs.
Actual LightCraft exports after crop normalization were also compared directly with Sony's corrected
TIFFs. At 2376 px wide, median / 95th-percentile feature residuals were 0.35 / 1.04 px at 24 mm,
0.15 / 0.40 px at 200 mm, and 0.13 / 0.56 px at 600 mm (median translation below 0.024 px).
The uncorrected medians at 200 and 600 mm were 8.63 and 10.82 px respectively. Different colour,
sharpening, and demosaicing affect feature localization; these are geometry checks, not colour parity.
An isolated desktop run on Apple M1 Metal detected the profile, rendered without GPU fallback or
warnings, and exposed the existing Optics controls. Existing library edits are not reset: once the
source has loaded, enable lens corrections if they were previously disabled.
Tests cover signedness/length rejection, zero and malformed tables, independent 600 mm reference
geometry, barrel framing, offset crop normalization, both TIFF byte orders, header/full consistency,
and preservation through DNG export. No private photographs or reference TIFFs are committed.
## Camera coverage and regression boundary
The model check is exact: `ILCE-7RM4` (A7R IV without the A suffix), `ILCE-7M3`, unknown/missing
model names, and every other model remain unsupported by this distortion decoder. Matching tag
numbers and table lengths do not establish the scale, interpolation or crop normalization on another
body. Support can expand after independent geometry validation; do not add model-name prefix matching.
The original nine Sony samples listed by `cargo xtask corpus --download` were downloaded from its
CC0 URLs. The manifest now also includes CC0 samples 4822 (A7R IVA / 24–105 mm) and 3989
(A9 II / 200–600 mm), with their SHA-256 hashes recorded alongside the URLs. All eleven files
are checked with `sony_embedded_distortion_is_limited_to_validated_models`:
| Camera | Corpus samples | Raw-IFD table observed | Expected correction |
|---|---|---|---|
| ILCE-7RM4A | Compressed (4822) | 16 samples | One distortion warp |
| ILCE-9M2 | Compressed (3989) | 16 samples | None |
| ILCE-7M3 | Compressed, uncompressed | 16 samples | None |
| ILCE-7M4 | 14-bit, lossless L/M/S | 11 samples in the 14-bit file; 16 in L/M/S | None |
| ILCE-7RM2 | 12-bit uncompressed | 16 samples | None |
| DSC-RX100 | One | No raw-IFD table | None |
| DSC-RX100M3 | One | 11 samples | None |
These are **model-boundary regression checks, not new geometric validation of those models**. In particular,
refusing the A7 III tables resolves the unverified corner warp identified in review. The lossless M/S
files are linear YCbCr and remain excluded independently of the model check. Header and full decode
must agree for every sample. Synthetic tests also verify both TIFF byte orders, exact model matching,
that the warp is centred on the standard default crop while the image keeps Sony's crop, and that DNG export preserves an accepted warp without adding one to rejected
models. The existing independent Sony geometry tests continue to cover the enabled A7R IVA path.
Re-exporting the 24, 200 and 600 mm reference files after the model restriction produced byte-identical
corrected PNGs to the earlier validated exports. Isolated headless app checks showed the A7R IVA profile
controls present and the A7 III profile controls absent, with no notices and completed RAW renders.
Before enabling another body, compare corrected and uncorrected renders against independent references
across multiple lenses/focal lengths and photographs with features near the edges and corners. Record
residuals and spatial coverage, and check crop/aspect and compression variants. Embedded JPEGs are
useful when the camera actually applied distortion correction; an Off JPEG cannot certify an On warp.
Prefer same-renderer Sony On/Off exports when the camera setting or geometry is ambiguous. Table-fit
error alone only checks our polynomial approximation, not whether the table's interpretation is correct.

View File

@ -313,6 +313,16 @@ fn cmd_ci() -> Result<(), String> {
/// The third field is the SHA-256 of the file as downloaded; `cargo xtask corpus --download` checks it.
/// One per format / compression variant we decode or deliberately report as unsupported (preview only).
const RAW_SAMPLES: &[(&str, &str, &str)] = &[
(
"arw-sony-a7rm4a-compressed.arw",
"https://raw.pixls.us/getfile.php/4822/nice/Sony%20-%20ILCE-7RM4A%20-%2014bit%2014bit%20compressed%20%283:2%29.ARW",
"690c774f1d7bc1db3fa8c2489762743d8e7586e50b6f65d0b6a61cb467972c67",
),
(
"arw-sony-a9m2-compressed.arw",
"https://raw.pixls.us/getfile.php/3989/nice/Sony%20-%20ILCE-9M2%20-%2014bit%2014bit%20compressed%20%283:2%29.ARW",
"161c2a9da2b5f1e50be6117a0b4da0ce249660b5d7de13568d301c4034716b97",
),
(
"arw-sony-a7m3-compressed.arw",
"https://raw.pixls.us/getfile.php/2414/nice/Sony%20-%20ILCE-7M3%20-%2014bit%2014bit%20compressed%20%283:2%29.ARW",