mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-24 10:09:01 -06:00
LR-OPT-PROFILES: decode Sony embedded distortion tables
This commit is contained in:
+3
-2
@@ -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 Sony Bayer ARW 16-sample tables (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 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
|
||||
|
||||
@@ -62,7 +62,7 @@ impl SettingsHashes {
|
||||
}
|
||||
|
||||
/// Bump when the pipeline's output changes, to invalidate cached thumbnails.
|
||||
pub const RENDER_CACHE_VERSION: u64 = 16;
|
||||
pub const RENDER_CACHE_VERSION: u64 = 17;
|
||||
|
||||
/// 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];
|
||||
|
||||
Vendored
+14
-5
@@ -425,6 +425,15 @@ fn default_crop(raw: &Ifd, mn: Option<&makernote::MakerNote>, w: usize, h: usize
|
||||
{
|
||||
return Rect::new(*x as usize, *y as usize, *cw as usize, *ch as usize).clipped(w, h);
|
||||
}
|
||||
// The standard default crop is in raw-array coordinates. SonyCropTopLeft can instead start at
|
||||
// zero after sensor margins (e.g. ILCE-7RM4A: x=0 versus DefaultCropOrigin x=32). Prefer the
|
||||
// explicit standard origin so the image and its embedded distortion share the correct centre.
|
||||
if let (Some([x, y]), Some([cw, ch])) = (raw.u64s(CROP_TOP_LEFT).as_deref(), raw.u64s(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);
|
||||
}
|
||||
match mn.and_then(|m| m.ifd.u64s(MN_FULL_IMAGE_SIZE)).as_deref() {
|
||||
// only a plausible trim: never more than 64 pixels per side, never an enlargement
|
||||
Some([fh, fw]) if *fw as usize <= w && *fh as usize <= h && *fw as usize + 64 >= w && *fh as usize + 64 >= h => {
|
||||
@@ -527,10 +536,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
|
||||
[(v[0] / g) as f32, 1.0, (v[3] / g) as f32]
|
||||
})
|
||||
.or_else(|| mn.as_ref().and_then(|m| tag2010_wb(&model, m.ifd.bytes(MN_TAG2010)?, m.order)));
|
||||
let crop = match (raw.u64s(CROP_TOP_LEFT).as_deref(), raw.u64s(CROP_SIZE).as_deref()) {
|
||||
(Some([x, y]), Some([cw, ch])) if *cw > 0 && *ch > 0 => Rect::new(*x as usize, *y as usize, *cw as usize, *ch as usize).clipped(w, h),
|
||||
_ => default_crop(raw, mn.as_ref(), w, h),
|
||||
};
|
||||
let crop = default_crop(raw, mn.as_ref(), w, h);
|
||||
let mut metadata = lightcraft_meta::from_tiff(&tiff);
|
||||
metadata.width = Some(crop.width as u32);
|
||||
metadata.height = Some(crop.height as u32);
|
||||
@@ -552,7 +558,10 @@ 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(),
|
||||
opcodes: OpcodeLists {
|
||||
list3: if linear_rgb { Vec::new() } else { super::arw_lens::distortion(raw, Rect::new(0, 0, w, h), crop).into_iter().collect() },
|
||||
..Default::default()
|
||||
},
|
||||
metadata,
|
||||
};
|
||||
img.validate_for(mode)?;
|
||||
|
||||
Vendored
+209
@@ -0,0 +1,209 @@
|
||||
//! 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;
|
||||
|
||||
/// The 16-entry layout only. Unknown layouts and implausible/non-smooth maps are left uncorrected.
|
||||
/// A camera setting of Off does not remove the lens data: the user can enable it in the developer.
|
||||
pub(super) fn distortion(ifd: &Ifd, active: Rect, crop: Rect) -> Option<Opcode> {
|
||||
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(&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(&table(&WIDE), AREA, AREA).unwrap() else { panic!("warp") };
|
||||
let Opcode::WarpRectilinear { planes: b, center } = distortion(&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(&bad, AREA, AREA).is_none());
|
||||
}
|
||||
assert!(distortion(&table(&TELE), Rect::new(0, 0, 1, 1), AREA).is_none());
|
||||
assert!(distortion(&table(&TELE), AREA, Rect::new(0, 0, 9504, 5346)).is_none());
|
||||
assert!(distortion(&table(&TELE), AREA, Rect::new(99999, 0, 10, 10)).is_none());
|
||||
}
|
||||
#[test]
|
||||
fn header_and_full_decode_preserve_correction_through_dng() {
|
||||
use lightcraft_tiff::{ByteOrder, IfdBuilder, ImageData, TiffWriter, tags as t};
|
||||
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("synthetic correction test".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(), 1);
|
||||
assert_eq!(full.crop, Rect::new(2, 2, 30, 20));
|
||||
let dng = crate::write_dng(&full, &Default::default()).unwrap();
|
||||
assert_eq!(crate::decode(&dng).unwrap().opcodes.list3, full.opcodes.list3);
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+1
@@ -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;
|
||||
|
||||
+4
-2
@@ -75,7 +75,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 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
|
||||
@@ -272,7 +274,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; 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 Bayer ARW raw-IFD distortion tables (`0x7037`, 16-sample layout, native 3:2 framing) use the same warp path, validated on ILCE-7RM4A with FE 24–105 and FE 200–600 lenses (see `docs/sony-lens-corrections.md`); 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` | |
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
# Sony embedded distortion corrections
|
||||
|
||||
LightCraft reads the signed 16-sample distortion table in the raw image IFD (`0x7037`) of Sony ARWs.
|
||||
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 tested files are Bayer ARWs from the 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 standard
|
||||
`DefaultCropOrigin` / `DefaultCropSize` take precedence over Sony crop tags: on the tested A7R IVA
|
||||
files the former start at raw x=32 and the latter at x=0. The standard origin aligns with Sony
|
||||
exports; the old precedence shifted content by 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.
|
||||
Reference in New Issue
Block a user