commit
4539014a42
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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)?;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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` | |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue