mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-26 11:09:23 -06:00
Merge PR #824
# Conflicts: # crates/engine/src/files.rs # docs/gaps.md # docs/parity-checklist.md # docs/raw-parity.md
This commit is contained in:
@@ -2406,4 +2406,112 @@ pub(crate) mod tests {
|
||||
assert!(q.rendered.is_ok(), "{:?}", q.rendered.err());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A Sony ARW of `model`: a 64 × 48 uncompressed 14-bit CFA strip in a SubIFD (as `crates/raw/tests/vendor_robust.rs`
|
||||
/// lays it out) whose IFD carries the full-frame 16–35 mm distortion table (tag 0x7037).
|
||||
fn sony_arw(model: &str) -> Vec<u8> {
|
||||
use lightcraft_tiff::tags::{self as t, photometric};
|
||||
use lightcraft_tiff::{ByteOrder, IfdBuilder, ImageData, TiffWriter, Value};
|
||||
// 3:2, the aspect Sony's lens opcodes are limited to
|
||||
let (w, h) = (72u32, 48u32);
|
||||
let mut raw = IfdBuilder::new();
|
||||
raw.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0]));
|
||||
raw.set(t::IMAGE_WIDTH, Value::Long(vec![w]));
|
||||
raw.set(t::IMAGE_LENGTH, Value::Long(vec![h]));
|
||||
raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![14]));
|
||||
raw.set(t::COMPRESSION, Value::Short(vec![1]));
|
||||
raw.set(t::PHOTOMETRIC, Value::Short(vec![photometric::CFA]));
|
||||
raw.set(t::CFA_REPEAT_PATTERN_DIM, Value::Short(vec![2, 2]));
|
||||
raw.set(t::CFA_PATTERN_EP, Value::Byte(vec![0, 1, 1, 2]));
|
||||
raw.set(0x7037, Value::SShort(vec![16, 0, 2, 7, 16, 28, 43, 61, 83, 109, 139, 175, 217, 267, 325, 395, 479]));
|
||||
raw.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![2000u16.to_le_bytes().repeat((w * h) as usize)] });
|
||||
let mut ifd0 = IfdBuilder::new();
|
||||
ifd0.set(t::MAKE, Value::Ascii("SONY".into()));
|
||||
ifd0.set(t::MODEL, Value::Ascii(model.into()));
|
||||
ifd0.add_sub_ifd(raw);
|
||||
TiffWriter::new(ByteOrder::Little, false).write(&[ifd0]).unwrap()
|
||||
}
|
||||
|
||||
/// Sony's embedded distortion correction is on by default: an ARW of a verified body imports with "Enable Lens
|
||||
/// Corrections" on and still counts as unedited; another body's tables aren't read, so its correction is off.
|
||||
#[test]
|
||||
fn arw_with_lens_data_imports_with_profile_corrections_on() {
|
||||
let dir = std::env::temp_dir().join(format!("lc-arw-lens-default-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let mut s = crate::Session::new().with_fs();
|
||||
for (model, on) in [("ILCE-7RM6", true), ("ILCE-7M4", false)] {
|
||||
let path = dir.join(format!("{model}.arw"));
|
||||
std::fs::write(&path, sony_arw(model)).unwrap();
|
||||
let r = s.execute("library.import", &serde_json::json!({"paths": [path.to_string_lossy()]})).unwrap();
|
||||
let p = s.catalog.photo(lightcraft_catalog::PhotoId(r["imported"][0].as_u64().unwrap())).unwrap();
|
||||
assert_eq!((p.format.as_str(), p.embedded_lens.is_some(), p.develop.optics.lens_profile), ("ARW", on, on), "{model}");
|
||||
assert!(!p.is_edited(), "{model}: as imported");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// RW2 and DNG keep their default: the correction is on when the file carries one (RW2 distortion, a DNG's
|
||||
/// opcodes), off when it doesn't.
|
||||
#[test]
|
||||
fn rw2_and_dng_defaults_unchanged() {
|
||||
use lightcraft_catalog::{Photo, PhotoId, Source};
|
||||
let s = crate::Session::new();
|
||||
let lens = lightcraft_develop::EmbeddedLens { warp: Some(Default::default()), ..Default::default() };
|
||||
for format in ["RW2", "DNG"] {
|
||||
for embedded in [None, Some(lens)] {
|
||||
let name = format!("x.{format}");
|
||||
let mut p = Photo::new(PhotoId(1), Source::File { path: format!("/{name}") }, &name, format, 64, 48, "");
|
||||
p.kind = MediaKind::Raw;
|
||||
p.embedded_lens = embedded;
|
||||
crate::import::apply_import_defaults(&s, &mut p);
|
||||
assert_eq!(p.develop.optics.lens_profile, embedded.is_some(), "{format} {embedded:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stored settings stay as they are when the library opens again: an ARW catalogued before its lens data was read
|
||||
/// (none, correction off) keeps the correction off and counts as unedited, and one whose user switched the
|
||||
/// correction off keeps it off.
|
||||
#[test]
|
||||
fn existing_photo_settings_are_not_rewritten() {
|
||||
use lightcraft_catalog::{Op, PhotoId};
|
||||
let dir = std::env::temp_dir().join(format!("lc-arw-lens-existing-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let (lib, files) = (dir.join("lib"), dir.join("files"));
|
||||
std::fs::create_dir_all(&files).unwrap();
|
||||
let mut s = crate::Session::new().with_fs();
|
||||
s.open_library(&lib, false).unwrap();
|
||||
let import = |s: &mut crate::Session, name: &str| {
|
||||
let path = files.join(name);
|
||||
// trailing bytes of its own: not a duplicate of the other file
|
||||
std::fs::write(&path, [sony_arw("ILCE-7RM6"), name.as_bytes().to_vec()].concat()).unwrap();
|
||||
let r = s.execute("library.import", &serde_json::json!({"paths": [path.to_string_lossy()]})).unwrap();
|
||||
PhotoId(r["imported"][0].as_u64().unwrap())
|
||||
};
|
||||
let old = import(&mut s, "old.arw");
|
||||
// as a release that didn't read the Sony tables catalogued it
|
||||
let mut d = (*s.catalog.photo(old).unwrap().develop).clone();
|
||||
d.optics.lens_profile = false;
|
||||
let ops = vec![
|
||||
Op::SetEmbeddedLens { id: old, lens: None },
|
||||
Op::SetDevelop { id: old, settings: std::sync::Arc::new(d), label: "x".into(), edited: None },
|
||||
];
|
||||
s.commit("x", Op::Batch { ops }).unwrap();
|
||||
let off = import(&mut s, "off.arw");
|
||||
s.execute("develop.merge", &serde_json::json!({"settings": {"optics": {"lens_profile": false}}})).unwrap();
|
||||
let before: Vec<_> = [old, off].iter().map(|id| s.catalog.photo(*id).unwrap().clone()).collect();
|
||||
assert!(before.iter().all(|p| !p.develop.optics.lens_profile));
|
||||
s.persist().unwrap();
|
||||
drop(s);
|
||||
let mut s = crate::Session::new().with_fs();
|
||||
s.open_library(&lib, false).unwrap();
|
||||
for b in &before {
|
||||
let p = s.catalog.photo(b.id).unwrap();
|
||||
assert_eq!((&p.develop, &p.embedded_lens), (&b.develop, &b.embedded_lens), "{}", p.file_name);
|
||||
}
|
||||
assert!(!s.catalog.photo(old).unwrap().is_edited(), "still as imported");
|
||||
drop(s);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ impl SettingsHashes {
|
||||
}
|
||||
|
||||
/// Bump when the pipeline's output changes, to invalidate cached thumbnails.
|
||||
pub const RENDER_CACHE_VERSION: u64 = 62;
|
||||
pub const RENDER_CACHE_VERSION: u64 = 63;
|
||||
|
||||
/// 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];
|
||||
|
||||
@@ -524,7 +524,7 @@ fn gain_at(points_v: u32, points_h: u32, spacing: [f64; 2], origin: [f64; 2], ma
|
||||
}
|
||||
|
||||
/// Radius normaliser: distance from the centre to the farthest image corner.
|
||||
fn max_radius(cx: f64, cy: f64, w: f64, h: f64) -> f64 {
|
||||
pub(crate) fn max_radius(cx: f64, cy: f64, w: f64, h: f64) -> f64 {
|
||||
[(0.0, 0.0), (w, 0.0), (0.0, h), (w, h)].iter().map(|&(x, y)| (x - cx).hypot(y - cy)).fold(0.0, f64::max).max(1e-9)
|
||||
}
|
||||
|
||||
|
||||
Vendored
+50
-3
@@ -2,7 +2,8 @@
|
||||
//!
|
||||
//! Sources: H. Dietz, "Sony ARW2 Compression: Artifacts And Credible Repair" (IS&T Electronic Imaging 2016) for
|
||||
//! the ARW2 ("cRAW") scheme, and the ExifTool Sony tag-name documentation for the meaning of the raw-IFD tags
|
||||
//! (`0x7010` tone-curve thresholds, `0x7310` black levels, `0x7313` WB levels, `0x74c7/0x74c8` crop).
|
||||
//! (`0x7010` tone-curve thresholds, `0x7310` black levels, `0x7313` WB levels, `0x74c7/0x74c8` crop, `0x7031`–`0x7037`
|
||||
//! lens-correction tables: see [`super::arw_lens`] for the ILCE-7RM4A and [`super::arw_lens_rm6`] for the ILCE-7RM6).
|
||||
//!
|
||||
//! ARW2, as described in the paper:
|
||||
//! 1. Sensor values are tone-mapped to 11-bit codes by a five-segment piecewise-linear curve whose step doubles at
|
||||
@@ -770,7 +771,9 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
|
||||
// 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.
|
||||
// it stays on the optical centre whichever crop frames the image. The ILCE-7RM6's tables follow other
|
||||
// conventions, measured against its camera JPEGs ([`super::arw_lens_rm6`]); the two allowlists are
|
||||
// disjoint, so at most one of them produces an opcode.
|
||||
opcodes: OpcodeLists {
|
||||
// DefaultCrop can retain native geometry while Sony's tags select an aspect crop.
|
||||
// Both the image framing and the table geometry must stay within the validated 3:2 scope.
|
||||
@@ -778,7 +781,11 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
|
||||
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()
|
||||
let active = Rect::new(0, 0, w, h);
|
||||
super::arw_lens::distortion(&model, raw, active, geometry)
|
||||
.into_iter()
|
||||
.chain(super::arw_lens_rm6::lens_opcodes(raw, &model, active, geometry))
|
||||
.collect()
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
@@ -867,6 +874,46 @@ mod tests {
|
||||
TiffWriter::default().write(&[raw]).unwrap()
|
||||
}
|
||||
|
||||
/// The ILCE-7RM6's distortion table reaches `OpcodeList3`, in the headers-only probe and in the full decode, as
|
||||
/// one plane, also with a CA table (lateral CA is off by default); another body's tables don't (see
|
||||
/// [`super::super::arw_lens_rm6`]).
|
||||
#[test]
|
||||
fn lens_tables_reach_opcode_list3() {
|
||||
use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value};
|
||||
let file = |model: &str, ca: bool| {
|
||||
let mut raw = IfdBuilder::new();
|
||||
raw.set(t::MAKE, Value::Ascii("SONY".into()));
|
||||
raw.set(t::MODEL, Value::Ascii(model.into()));
|
||||
// 3:2, the aspect the opcode is limited to
|
||||
raw.set(t::IMAGE_WIDTH, Value::Long(vec![96]));
|
||||
raw.set(t::IMAGE_LENGTH, Value::Long(vec![64]));
|
||||
raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![12]));
|
||||
raw.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1]));
|
||||
raw.set(t::PHOTOMETRIC, Value::Short(vec![photometric::CFA]));
|
||||
raw.set(t::COMPRESSION, Value::Short(vec![32767]));
|
||||
raw.set(TONE_CURVE, Value::Short(vec![8000, 10400, 12900, 14100]));
|
||||
let table = vec![16, 0, 2, 7, 16, 28, 43, 61, 83, 109, 139, 175, 217, 267, 325, 395, 479];
|
||||
raw.set(super::super::arw_lens_rm6::DISTORTION_PARAMS, Value::SShort(table));
|
||||
if ca {
|
||||
let table = vec![
|
||||
32, 242, 234, 224, 212, 196, 176, 154, 132, 108, 86, 66, 46, 30, 16, 8, 4, -276, -258, -238, -212, -184, -148, -114, -74, -40,
|
||||
-6, 20, 42, 56, 60, 54, 32,
|
||||
];
|
||||
raw.set(super::super::arw_lens_rm6::CA_PARAMS, Value::SShort(table));
|
||||
}
|
||||
raw.set_image(ImageData::Strips { rows_per_strip: 64, strips: vec![vec![0u8; 96 * 64]] });
|
||||
TiffWriter::default().write(&[raw]).unwrap()
|
||||
};
|
||||
for mode in [Mode::Header, Mode::Full] {
|
||||
for ca in [false, true] {
|
||||
let list3 = |model| decode(&file(model, ca), mode).unwrap().opcodes.list3;
|
||||
let ops = list3("ILCE-7RM6");
|
||||
assert!(matches!(ops.as_slice(), [crate::Opcode::WarpRectilinear { planes, .. }] if planes.len() == 1), "{mode:?} {ca}: {ops:?}");
|
||||
assert!(list3("ILCE-7M4").is_empty(), "{mode:?} {ca}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_larger_or_smaller_than_one_byte_per_pixel_is_the_packed_variant() {
|
||||
for strip in [32 * 4 + 8, 32 * 4 * 5 / 4 - 1, 32 * 4 - 1] {
|
||||
|
||||
+538
@@ -0,0 +1,538 @@
|
||||
//! Sony ARW lens corrections from the tables in the raw IFD, as the equivalent DNG `OpcodeList3` opcodes, so they
|
||||
//! reach the same "profile corrections" (`optics.lens_profile`) as a DNG's embedded warp.
|
||||
//!
|
||||
//! These are the ILCE-7RM6 conventions, measured against the camera JPEG. [`super::arw_lens`] holds the ILCE-7RM4A
|
||||
//! conventions, measured against Sony's Imaging Edge exports (unit 16384, the table indexed at the output radius).
|
||||
//! Each is selected by camera model; no single unit fits every lens under either. See both docs:
|
||||
//! docs/sony-ilce7rm6-lens-corrections.md (→ Two conventions) and docs/sony-lens-corrections.md.
|
||||
//!
|
||||
//! Sony records three count-prefixed signed 16-bit tables in the raw IFD, each with an in-camera on/off tag (ExifTool
|
||||
//! Sony tag names): distortion [`DISTORTION_PARAMS`] (flag [`DISTORTION_FLAG`]), vignetting [`VIGNETTING_PARAMS`]
|
||||
//! ([`VIGNETTING_FLAG`]) and lateral chromatic aberration [`CA_PARAMS`] ([`CA_FLAG`]). The first value is the knot
|
||||
//! count: 16 / 16 / 32 in full-frame files of the ILCE-7RM6, 11 / 11 / 22 in its APS-C crop mode, the rest padding;
|
||||
//! the CA table holds red's knots, then blue's.
|
||||
//!
|
||||
//! Their meaning was measured by registering blocks of the camera JPEG in each file on LightCraft's uncorrected render
|
||||
//! (and raw data, for CA) over 54 ILCE-7RM6 photos with four lenses, 41 of them shot with in-camera distortion
|
||||
//! correction on; nothing else (clean room). The conventions, with their worst residuals at a 1440 px long edge:
|
||||
//! - knot `i` sits at sensor radius `i · s` from the default-crop centre, `s` = the body's full-frame default-crop
|
||||
//! half diagonal / 15 (the same pixel spacing in APS-C mode: the knots are fixed on the sensor); linear in between,
|
||||
//! the last segment extended beyond the last knot.
|
||||
//! - distortion: a corrected radius `r` (px) reads the source radius `S(r) = r · (1 + v(r) / 15000)` (positive:
|
||||
//! pincushion), and the camera then zooms (output radius = `z` · corrected radius) so the output fills the default
|
||||
//! crop and reads only inside it: the largest `S(ρ/z)/ρ` over the crop border radii `ρ` is 1. 0.596 px over the 34
|
||||
//! measurable corrected photos (budget 0.75 px; corners and edge midpoints 0.570 px).
|
||||
//! - `WarpRectilinear`'s 4-term odd polynomial fits that map within 2.35·10⁻⁴ of the DNG radius (0.220 px) on every
|
||||
//! photo, at its 65 fit samples. A table it fits worse than 0.25 px at the samples the crop shows is refused (the
|
||||
//! photo stays uncorrected rather than wrongly warped). Between samples the error is somewhat larger: 0.071 against
|
||||
//! 0.049 px for the full-frame 16–35 mm example in the tests.
|
||||
//! - vignetting: read but never applied. The camera JPEGs keep the uncorrected render's radial falloff (median
|
||||
//! corner/centre ratio 0.996 over 39 photos) and no radial gain model fits the tables.
|
||||
//! - lateral CA: red's (blue's) source radius is `g · (1 + c(g) / 3.36·10⁶)` with `g` the green source radius and `c`
|
||||
//! the red (blue) half on the same knots. The unit is **provisional**: the measurement is borderline (0.399 px
|
||||
//! worst against a 0.3 px budget; per-photo best units scatter from 1.5·10⁶ to 7.2·10⁶, about ±30 % scene bias),
|
||||
//! but it cuts the rms raw-CFA CA over 46 photos from 0.079 to 0.052 px. The table is parsed and [`warp_opcode`]
|
||||
//! converts it to the warp's R and B planes, but they are **not emitted by default**: [`lens_opcodes`] passes
|
||||
//! `lateral_ca: false`, because applied after demosaic they made the render's CA worse (numbers there). To turn
|
||||
//! them on in code, pass `true` there. When on, a CA value beyond [`CA_MAX_RELATIVE`] or a colour plane that fails
|
||||
//! the fit gate above drops both colour planes, and G alone is applied to all three channels.
|
||||
//!
|
||||
//! The correction is produced whatever the in-camera flag says. Only bodies listed in [`FULL_FRAME_CROP`] get it: the
|
||||
//! unit and the knot spacing are verified on the ILCE-7RM6 alone. Another body is added by repeating the measurement
|
||||
//! on its photos shot with distortion correction on and listing its full-frame default crop. The method and the
|
||||
//! numbers: docs/sony-ilce7rm6-lens-corrections.md.
|
||||
|
||||
use super::fit_radial_warp;
|
||||
use crate::opcodes::max_radius;
|
||||
use crate::{Opcode, Rect};
|
||||
use lightcraft_tiff::Ifd;
|
||||
use std::ops::RangeInclusive;
|
||||
|
||||
pub(crate) const DISTORTION_PARAMS: u16 = 0x7037;
|
||||
pub(crate) const VIGNETTING_PARAMS: u16 = 0x7032;
|
||||
pub(crate) const CA_PARAMS: u16 = 0x7035;
|
||||
pub(crate) const DISTORTION_FLAG: u16 = 0x7036;
|
||||
pub(crate) const VIGNETTING_FLAG: u16 = 0x7031;
|
||||
pub(crate) const CA_FLAG: u16 = 0x7034;
|
||||
|
||||
/// The bodies whose tables are verified (Exif `Model`), with their full-frame default crop (width, height) in px.
|
||||
const FULL_FRAME_CROP: &[(&str, (f64, f64))] = &[("ILCE-7RM6", (9984.0, 6656.0))];
|
||||
/// The distortion table's value of 1 (relative radius); a global least-squares fit over the 34 photos gives 15000.7.
|
||||
const DISTORTION_UNIT: f64 = 15000.0;
|
||||
/// The CA table's value of 1 (relative to the green source radius). Provisional: the median best unit of the 20
|
||||
/// cleanest photos; per-photo units scatter from 1.5·10⁶ to 7.2·10⁶ (the measurement's scene bias is about ±30 %).
|
||||
const CA_UNIT: f64 = 3.36e6;
|
||||
/// Largest plausible CA value relative to the green source radius (|c| / [`CA_UNIT`]): about 12 px at the full-frame
|
||||
/// corner; the largest value in the 54 measured photos' tables is 1724 (FE 24–70 mm), 1724 / 3.36·10⁶ ≈ 5.1·10⁻⁴.
|
||||
/// A table beyond it is refused.
|
||||
const CA_MAX_RELATIVE: f64 = 2e-3;
|
||||
/// Crop border radii scanned by the frame-filling zoom.
|
||||
const ZOOM_SCAN: usize = 4096;
|
||||
/// Largest polynomial fit error accepted at the fit samples the crop shows, px at a 1440 px long edge.
|
||||
const FIT_ERROR_PX_AT_1440: f64 = 0.25;
|
||||
|
||||
/// A file's lens tables: the first `count` values of each (CA: red, blue); `None` when absent or malformed.
|
||||
/// `vignetting` and `flags` are parsed for the tests and the docs only: don't build an opcode from them (the camera
|
||||
/// applies no vignetting correction to its JPEGs, and the flags don't decide anything).
|
||||
#[derive(Debug)]
|
||||
// vignetting and the flags never become an opcode
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
pub(crate) struct LensTables {
|
||||
pub distortion: Option<Vec<f64>>,
|
||||
pub vignetting: Option<Vec<f64>>,
|
||||
pub ca: Option<(Vec<f64>, Vec<f64>)>,
|
||||
/// In-camera on/off of distortion, vignetting and CA correction, as recorded.
|
||||
pub flags: [Option<i64>; 3],
|
||||
}
|
||||
|
||||
/// The values of a count-prefixed integer table whose count is in `counts` and fits the array.
|
||||
fn table(raw: &Ifd, tag: u16, counts: RangeInclusive<usize>) -> Option<Vec<f64>> {
|
||||
let v = raw.value(tag)?;
|
||||
let count = usize::try_from(v.get_i64(0)?).ok().filter(|n| counts.contains(n) && *n < v.count())?;
|
||||
(1..=count).map(|i| v.get_i64(i).map(|x| x as f64)).collect()
|
||||
}
|
||||
|
||||
/// The lens tables of the raw IFD `raw`.
|
||||
pub(crate) fn read_tables(raw: &Ifd) -> LensTables {
|
||||
let ca = table(raw, CA_PARAMS, 4..=32).filter(|v| v.len().is_multiple_of(2)).map(|v| {
|
||||
let (red, blue) = v.split_at(v.len() / 2);
|
||||
(red.to_vec(), blue.to_vec())
|
||||
});
|
||||
LensTables {
|
||||
distortion: table(raw, DISTORTION_PARAMS, 2..=16),
|
||||
vignetting: table(raw, VIGNETTING_PARAMS, 2..=16),
|
||||
ca,
|
||||
flags: [raw.i64(DISTORTION_FLAG), raw.i64(VIGNETTING_FLAG), raw.i64(CA_FLAG)],
|
||||
}
|
||||
}
|
||||
|
||||
/// The knot spacing (px) of a verified body: its full-frame default-crop half diagonal / 15.
|
||||
fn knot_spacing(model: &str) -> Option<f64> {
|
||||
let model = model.trim();
|
||||
FULL_FRAME_CROP.iter().find(|(m, _)| *m == model).map(|&(_, (w, h))| w.hypot(h) / 30.0)
|
||||
}
|
||||
|
||||
/// The knot table at radius `r` px: knot `i` at `i · spacing`, linear in between, the last segment extended beyond
|
||||
/// the last knot. NaN with fewer than two knots.
|
||||
fn interpolate(knots: &[f64], spacing: f64, r: f64) -> f64 {
|
||||
let t = r / spacing;
|
||||
// `as` saturates, so a huge `t` lands on the last segment; NaN fails `t > 0`
|
||||
let i = if t > 0.0 { (t as usize).min(knots.len().saturating_sub(2)) } else { 0 };
|
||||
match (knots.get(i), knots.get(i + 1)) {
|
||||
(Some(a), Some(b)) => a + (b - a) * (t - i as f64),
|
||||
_ => f64::NAN,
|
||||
}
|
||||
}
|
||||
|
||||
/// The camera's frame-filling zoom `z` for the source radius map `source` and a crop of half size `x × y`: the
|
||||
/// largest `source(ρ/z)/ρ` over the border radii `ρ` in [min(x, y), hypot(x, y)] is 1, so the output crop reads
|
||||
/// only inside the source crop and touches it (at the corner for pincushion, the short-edge midpoint for barrel).
|
||||
/// Bisection over [0.5, 2]; `None` when that range holds no solution or a ratio is not finite.
|
||||
fn fill_zoom(source: &dyn Fn(f64) -> f64, x: f64, y: f64) -> Option<f64> {
|
||||
let (near, far) = (x.min(y), x.hypot(y));
|
||||
let overshoots = |z: f64| -> Option<bool> {
|
||||
let mut worst = f64::NEG_INFINITY;
|
||||
for k in 0..=ZOOM_SCAN {
|
||||
let rb = near + k as f64 * (far - near) / ZOOM_SCAN as f64;
|
||||
let ratio = source(rb / z) / rb;
|
||||
if !ratio.is_finite() {
|
||||
return None;
|
||||
}
|
||||
worst = worst.max(ratio);
|
||||
}
|
||||
Some(worst > 1.0)
|
||||
};
|
||||
let (mut lo, mut hi) = (0.5, 2.0);
|
||||
if !overshoots(lo)? || overshoots(hi)? {
|
||||
return None;
|
||||
}
|
||||
for _ in 0..100 {
|
||||
let z = 0.5 * (lo + hi);
|
||||
if overshoots(z)? { lo = z } else { hi = z }
|
||||
}
|
||||
Some(0.5 * (lo + hi))
|
||||
}
|
||||
|
||||
/// The distortion and CA tables as a `WarpRectilinear` relative to the active area `active` (centred on the default
|
||||
/// crop `crop`, as the camera corrects), with knots `knot_spacing` px apart: planes R, G, B with `lateral_ca`, or G
|
||||
/// alone (applied to all three) without it, or when the CA table is absent, implausible or a colour plane's fit
|
||||
/// fails. `None` when the distortion table is absent or
|
||||
/// all zero (CA alone is not applied: it is relative to green), the zoom or the G fit fails. A fit fails when it is
|
||||
/// worse than 0.25 px at a 1440 px long edge at one of the fit samples within the crop (a padded active area, as in
|
||||
/// lossless files, reaches radii that are never shown).
|
||||
pub(crate) fn warp_opcode(t: &LensTables, knot_spacing: f64, active: Rect, crop: Rect, lateral_ca: bool) -> Option<Opcode> {
|
||||
let v = t.distortion.as_deref().filter(|v| v.iter().any(|&x| x != 0.0))?;
|
||||
let (aw, ah) = (active.width as f64, active.height as f64);
|
||||
if aw < 2.0 || ah < 2.0 || crop.width == 0 || crop.height == 0 {
|
||||
return None;
|
||||
}
|
||||
let (cw, ch) = (crop.width as f64, crop.height as f64);
|
||||
let source = |r: f64| r * (1.0 + interpolate(v, knot_spacing, r) / DISTORTION_UNIT);
|
||||
let z = fill_zoom(&source, cw / 2.0, ch / 2.0)?;
|
||||
// DNG's normalisation: pixel-index units, radius 1 = the farthest active-area corner
|
||||
let (cx, cy) = (crop.x as f64 + (cw - 1.0) / 2.0, crop.y as f64 + (ch - 1.0) / 2.0);
|
||||
let m = max_radius(cx, cy, aw - 1.0, ah - 1.0);
|
||||
let max_fit_error = FIT_ERROR_PX_AT_1440 * (cw.max(ch) / 1440.0) / m;
|
||||
let shown = (cw / 2.0).hypot(ch / 2.0) / m;
|
||||
// output radius (px) → green source radius
|
||||
let green = |r: f64| source(r / z);
|
||||
let plane = |map: &dyn Fn(f64) -> f64| {
|
||||
let [k0, k1, k2, k3] = fit_radial_warp(&|u| map(u * m) / m, max_fit_error, shown)?;
|
||||
Some([k0, k1, k2, k3, 0.0, 0.0])
|
||||
};
|
||||
let g = plane(&green)?;
|
||||
// red and blue: relative to the green source radius, at that radius
|
||||
let colour = |c: &[f64]| {
|
||||
plane(&|r| {
|
||||
let s = green(r);
|
||||
s * (1.0 + interpolate(c, knot_spacing, s) / CA_UNIT)
|
||||
})
|
||||
};
|
||||
// a NaN fails the comparison: refused too
|
||||
let plausible = |c: &[f64]| c.iter().all(|v| v.abs() <= CA_MAX_RELATIVE * CA_UNIT);
|
||||
let rgb = t.ca.as_ref().filter(|(red, blue)| lateral_ca && plausible(red) && plausible(blue));
|
||||
let rgb = rgb.and_then(|(red, blue)| Some(vec![colour(red)?, g, colour(blue)?]));
|
||||
Some(Opcode::WarpRectilinear { planes: rgb.unwrap_or_else(|| vec![g]), center: [cx / (aw - 1.0), cy / (ah - 1.0)] })
|
||||
}
|
||||
|
||||
/// The `OpcodeList3` lens corrections of a Sony raw IFD for the body `model` (Exif `Model`); empty for a body whose
|
||||
/// tables are not verified.
|
||||
///
|
||||
/// Distortion only: the lateral CA planes are off (`lateral_ca: false`). Our demosaic already hides about 70 % of the
|
||||
/// lens CA (the render shows 0.29 × the CA measured on the raw CFA), while the warp, which runs after demosaic, moves
|
||||
/// R and B by 0.84 × the table. Over the 43 verified photos with enough corner detail, the render's corner R/G and B/G
|
||||
/// CA went from rms 0.032 / max 0.125 px (off) to 0.052 / 0.219 px (on) at a 1440 px long edge; only the 16–35 mm
|
||||
/// improved. Follow-up: warp the CFA colour planes before demosaic, then turn the planes on here.
|
||||
pub(crate) fn lens_opcodes(raw: &Ifd, model: &str, active: Rect, crop: Rect) -> Vec<Opcode> {
|
||||
let Some(spacing) = knot_spacing(model) else { return Vec::new() };
|
||||
warp_opcode(&read_tables(raw), spacing, active, crop, false).into_iter().collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use lightcraft_tiff::{Entry, Value};
|
||||
|
||||
/// The FF 16–35 mm example (corpus `arw-sony-ilce7rm6-ff-land-lossless.arw`, docs/sony-ilce7rm6-lens-corrections.md).
|
||||
const FF_DISTORTION: [i16; 17] = [16, 0, 2, 7, 16, 28, 43, 61, 83, 109, 139, 175, 217, 267, 325, 395, 479];
|
||||
const FF_VIGNETTING: [i16; 17] = [16, 0, 80, 336, 672, 1120, 1648, 2240, 2880, 3600, 4336, 5104, 5872, 6640, 7392, 8160, 8976];
|
||||
const FF_CA: [i16; 33] = [
|
||||
32, 242, 234, 224, 212, 196, 176, 154, 132, 108, 86, 66, 46, 30, 16, 8, 4, -276, -258, -238, -212, -184, -148, -114, -74, -40, -6, 20, 42,
|
||||
56, 60, 54, 32,
|
||||
];
|
||||
/// The CA table of corpus `arw6-sony-ilce7rm6-ff-land-hq.arw` (the same scene; same distortion table).
|
||||
const FF_HQ_CA: [i16; 33] = [
|
||||
32, 242, 234, 222, 208, 192, 172, 152, 128, 106, 82, 62, 42, 26, 12, 4, 2, -270, -254, -234, -208, -180, -146, -112, -72, -38, -4, 22, 44,
|
||||
58, 64, 56, 36,
|
||||
];
|
||||
/// The APS-C example (corpus `arw6-sony-ilce7rm6-apsc-land-hq.arw`): counts 11 / 11 / 22, then padding.
|
||||
const APSC_DISTORTION: [i16; 17] = [11, 0, -10, -36, -78, -134, -202, -277, -356, -435, -512, -583, -583, -583, -583, -583, -583];
|
||||
const APSC_VIGNETTING: [i16; 17] = [11, 0, 48, 208, 496, 880, 1360, 1952, 2624, 3568, 4832, 6176, 0, 0, 0, 0, 0];
|
||||
const APSC_CA: [i16; 33] = [
|
||||
22, 790, 764, 734, 702, 666, 624, 582, 544, 510, 484, 470, -432, -404, -372, -330, -286, -230, -174, -114, -58, -6, 34, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0,
|
||||
];
|
||||
/// The APS-C lossless corpus file `arw-sony-ilce7rm6-apsc-land-lossless.arw` (same scene; its tables differ slightly).
|
||||
const APSC_LOSSLESS_DISTORTION: [i16; 17] = [11, 0, -10, -36, -78, -134, -202, -277, -356, -435, -511, -583, -583, -583, -583, -583, -583];
|
||||
const APSC_LOSSLESS_CA: [i16; 33] = [
|
||||
22, 792, 766, 738, 706, 670, 630, 588, 548, 514, 488, 476, -436, -408, -376, -334, -290, -234, -178, -118, -62, -12, 30, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0,
|
||||
];
|
||||
/// ARW6 geometry (the decoder's raw image size and default crop).
|
||||
const FF_ACTIVE: Rect = Rect { x: 0, y: 0, width: 10016, height: 6672 };
|
||||
const FF_CROP: Rect = Rect { x: 12, y: 8, width: 9984, height: 6656 };
|
||||
const APSC_ACTIVE: Rect = Rect { x: 0, y: 0, width: 6592, height: 4372 };
|
||||
const APSC_CROP: Rect = Rect { x: 16, y: 10, width: 6528, height: 4352 };
|
||||
/// The G planes of the examples on their ARW6 geometry, and the R and B planes with each file's CA table
|
||||
/// (docs/sony-ilce7rm6-lens-corrections.md).
|
||||
const FF_G: [f64; 4] = [0.9714495823, 0.022508032, 0.0006090085237, 0.005683284548];
|
||||
const FF_HQ_R: [f64; 4] = [0.9715153129, 0.02236266369, 0.0007060224765, 0.005666341563];
|
||||
const FF_HQ_B: [f64; 4] = [0.97137946, 0.02277016389, 0.0003830082563, 0.005727820598];
|
||||
const APSC_G: [f64; 4] = [1.01627551, -0.0618606692, 0.02768349621, -0.005595798468];
|
||||
const APSC_R: [f64; 4] = [1.016503991, -0.06206460153, 0.0278447625, -0.005644118171];
|
||||
const APSC_B: [f64; 4] = [1.016155702, -0.06162477627, 0.02753315326, -0.005554915901];
|
||||
|
||||
fn knots() -> f64 {
|
||||
knot_spacing("ILCE-7RM6").unwrap()
|
||||
}
|
||||
|
||||
fn ifd(tags: Vec<(u16, Value)>) -> Ifd {
|
||||
let mut entries: Vec<Entry> = tags.into_iter().map(|(tag, value)| Entry { tag, value, offset: 0 }).collect();
|
||||
entries.sort_by_key(|e| e.tag);
|
||||
Ifd { entries, ..Default::default() }
|
||||
}
|
||||
|
||||
fn sony_lens_ifd(distortion: &[i16], vignetting: &[i16], ca: &[i16], flags: [u16; 3]) -> Ifd {
|
||||
ifd(vec![
|
||||
(DISTORTION_PARAMS, Value::SShort(distortion.to_vec())),
|
||||
(VIGNETTING_PARAMS, Value::SShort(vignetting.to_vec())),
|
||||
(CA_PARAMS, Value::SShort(ca.to_vec())),
|
||||
(DISTORTION_FLAG, Value::Short(vec![flags[0]])),
|
||||
(VIGNETTING_FLAG, Value::Short(vec![flags[1]])),
|
||||
(CA_FLAG, Value::Short(vec![flags[2]])),
|
||||
])
|
||||
}
|
||||
|
||||
fn tables_with_distortion(v: &[f64]) -> LensTables {
|
||||
LensTables { distortion: Some(v.to_vec()), vignetting: None, ca: None, flags: [None; 3] }
|
||||
}
|
||||
|
||||
fn values(table: &[i16]) -> Vec<f64> {
|
||||
table[1..=table[0] as usize].iter().map(|&v| v as f64).collect()
|
||||
}
|
||||
|
||||
/// The warp's planes and centre against the recipe's numbers (docs/sony-ilce7rm6-lens-corrections.md); returns the planes.
|
||||
fn assert_warp(op: Option<Opcode>, center: [f64; 2], k: &[[f64; 4]]) -> Vec<[f64; 6]> {
|
||||
let Some(Opcode::WarpRectilinear { planes, center: c }) = op else { panic!("no warp: {op:?}") };
|
||||
assert_eq!(planes.len(), k.len());
|
||||
assert!((c[0] - center[0]).abs() < 1e-9 && (c[1] - center[1]).abs() < 1e-9, "centre {c:?}, want {center:?}");
|
||||
for (p, k) in planes.iter().zip(k) {
|
||||
for (i, want) in k.iter().enumerate() {
|
||||
assert!((p[i] - want).abs() < 1e-6, "k{i} {}, want {want} ({p:?})", p[i]);
|
||||
}
|
||||
assert_eq!((p[4], p[5]), (0.0, 0.0), "radial only");
|
||||
}
|
||||
planes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_the_three_tables_and_flags() {
|
||||
let t = read_tables(&sony_lens_ifd(&FF_DISTORTION, &FF_VIGNETTING, &FF_CA, [0, 2, 1]));
|
||||
let d = t.distortion.unwrap();
|
||||
assert_eq!((d.len(), d[0], d[15]), (16, 0.0, 479.0));
|
||||
let v = t.vignetting.unwrap();
|
||||
assert_eq!((v.len(), v[1], v[15]), (16, 80.0, 8976.0));
|
||||
let (red, blue) = t.ca.unwrap();
|
||||
assert_eq!((red.len(), red[0], red[15]), (16, 242.0, 4.0));
|
||||
assert_eq!((blue.len(), blue[0], blue[15]), (16, -276.0, 32.0));
|
||||
assert_eq!(t.flags, [Some(0), Some(2), Some(1)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apsc_tables_use_only_the_first_count_values() {
|
||||
let t = read_tables(&sony_lens_ifd(&APSC_DISTORTION, &APSC_VIGNETTING, &APSC_CA, [0, 2, 1]));
|
||||
let d = t.distortion.unwrap();
|
||||
assert_eq!((d.len(), d[10]), (11, -583.0));
|
||||
assert_eq!(t.vignetting.unwrap().len(), 11);
|
||||
let (red, blue) = t.ca.unwrap();
|
||||
assert_eq!((red.len(), red[10]), (11, 470.0));
|
||||
assert_eq!((blue.len(), blue[0], blue[10]), (11, -432.0, 34.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_or_malformed_tables_yield_no_opcode() {
|
||||
let mut short = FF_DISTORTION.to_vec();
|
||||
short.pop();
|
||||
let mut count_17 = FF_DISTORTION.to_vec();
|
||||
count_17[0] = 17;
|
||||
count_17.push(500);
|
||||
let mut too_large = FF_DISTORTION.to_vec();
|
||||
too_large[0] = 600;
|
||||
for (what, table) in [
|
||||
("count 15 with 3 values", vec![15, 0, 2, 7]),
|
||||
("count 17", count_17),
|
||||
("empty", vec![]),
|
||||
("count 16 with 15 values", short),
|
||||
("count 1", vec![1, 5]),
|
||||
("count 0", vec![0, 5, 6]),
|
||||
("negative count", vec![-16, 0, 2, 7]),
|
||||
("count larger than the array", too_large),
|
||||
] {
|
||||
let ifd = ifd(vec![(DISTORTION_PARAMS, Value::SShort(table))]);
|
||||
let t = read_tables(&ifd);
|
||||
assert!(t.distortion.is_none(), "{what}");
|
||||
assert!(warp_opcode(&t, knots(), FF_ACTIVE, FF_CROP, false).is_none(), "{what}");
|
||||
assert!(lens_opcodes(&ifd, "ILCE-7RM6", FF_ACTIVE, FF_CROP).is_empty(), "{what}");
|
||||
}
|
||||
// not an integer array, or absent
|
||||
for value in [Value::Ascii("16 0 2 7".into()), Value::Double(vec![2.0, 0.0, 1.0])] {
|
||||
assert!(read_tables(&ifd(vec![(DISTORTION_PARAMS, value)])).distortion.is_none());
|
||||
}
|
||||
let none = read_tables(&ifd(vec![]));
|
||||
assert!(none.distortion.is_none() && none.vignetting.is_none() && none.ca.is_none() && none.flags == [None; 3]);
|
||||
// the CA table needs an even count of 4..=32
|
||||
for ca in [vec![3, 1, 2, 3], vec![34; 35], vec![2, 1, 2]] {
|
||||
assert!(read_tables(&ifd(vec![(CA_PARAMS, Value::SShort(ca))])).ca.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_tables_are_rejected() {
|
||||
let folded = [0.0, 2000.0, 1000.0, 3000.0, 2000.0, 4000.0, 3000.0, 5000.0, 4000.0, 6000.0, 5000.0, 7000.0, 6000.0, 8000.0, 7000.0, 9000.0];
|
||||
for (what, table) in [
|
||||
("huge", vec![(i64::MAX / 4) as f64; 16]),
|
||||
("most negative", vec![i64::MIN as f64; 16]),
|
||||
("zig-zag", folded.to_vec()),
|
||||
("all zero", vec![0.0; 16]),
|
||||
("one knot", vec![100.0]),
|
||||
("no knots", vec![]),
|
||||
("not finite", vec![0.0, f64::NAN, 20.0]),
|
||||
("infinite", vec![0.0, f64::INFINITY, 20.0]),
|
||||
] {
|
||||
assert!(warp_opcode(&tables_with_distortion(&table), knots(), FF_ACTIVE, FF_CROP, false).is_none(), "{what}");
|
||||
}
|
||||
// the same through the IFD, as 64-bit values
|
||||
for v in [i64::MAX / 4, i64::MIN, i64::MAX] {
|
||||
let mut table = vec![v; 17];
|
||||
table[0] = 16;
|
||||
let ifd = ifd(vec![(DISTORTION_PARAMS, Value::SLong8(table))]);
|
||||
assert!(lens_opcodes(&ifd, "ILCE-7RM6", FF_ACTIVE, FF_CROP).is_empty(), "{v}");
|
||||
}
|
||||
// degenerate geometry
|
||||
let ff = tables_with_distortion(&values(&FF_DISTORTION));
|
||||
for (what, knots, active, crop) in [
|
||||
("empty crop", knots(), FF_ACTIVE, Rect::new(12, 8, 0, 6656)),
|
||||
("1 × 1 active area", knots(), Rect::new(0, 0, 1, 1), Rect::new(0, 0, 1, 1)),
|
||||
("knot spacing NaN", f64::NAN, FF_ACTIVE, FF_CROP),
|
||||
("knot spacing 0", 0.0, FF_ACTIVE, FF_CROP),
|
||||
] {
|
||||
assert!(warp_opcode(&ff, knots, active, crop, false).is_none(), "{what}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ff_example_table_becomes_the_measured_warp() {
|
||||
let t = tables_with_distortion(&values(&FF_DISTORTION));
|
||||
assert_warp(warp_opcode(&t, knots(), FF_ACTIVE, FF_CROP, false), [0.4996005991, 0.5], &[FF_G]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apsc_example_table_becomes_the_measured_warp() {
|
||||
let t = tables_with_distortion(&values(&APSC_DISTORTION));
|
||||
let op = warp_opcode(&t, knots(), APSC_ACTIVE, APSC_CROP, false);
|
||||
assert_warp(op, [0.4975724473, 0.5], &[APSC_G]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ca_table_shifts_red_and_blue_planes() {
|
||||
let t = read_tables(&sony_lens_ifd(&FF_DISTORTION, &FF_VIGNETTING, &FF_HQ_CA, [0, 2, 1]));
|
||||
let planes = assert_warp(warp_opcode(&t, knots(), FF_ACTIVE, FF_CROP, true), [0.4996005991, 0.5], &[FF_HQ_R, FF_G, FF_HQ_B]);
|
||||
assert_ne!(planes[0][0], planes[1][0]);
|
||||
// G is the distortion plane alone
|
||||
let g = warp_opcode(&tables_with_distortion(&values(&FF_DISTORTION)), knots(), FF_ACTIVE, FF_CROP, false);
|
||||
assert!(matches!(g, Some(Opcode::WarpRectilinear { planes: ref p, .. }) if p == &[planes[1]]), "{g:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apsc_ca_planes_match_the_measured_warp() {
|
||||
let t = read_tables(&sony_lens_ifd(&APSC_DISTORTION, &APSC_VIGNETTING, &APSC_CA, [0, 2, 1]));
|
||||
assert_warp(warp_opcode(&t, knots(), APSC_ACTIVE, APSC_CROP, true), [0.4975724473, 0.5], &[APSC_R, APSC_G, APSC_B]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ca_without_distortion_is_not_applied() {
|
||||
let ifd = ifd(vec![(CA_PARAMS, Value::SShort(FF_HQ_CA.to_vec()))]);
|
||||
let t = read_tables(&ifd);
|
||||
assert!(t.ca.is_some() && t.distortion.is_none());
|
||||
assert!(warp_opcode(&t, knots(), FF_ACTIVE, FF_CROP, true).is_none());
|
||||
assert!(lens_opcodes(&ifd, "ILCE-7RM6", FF_ACTIVE, FF_CROP).is_empty());
|
||||
// an all-zero distortion table counts as absent
|
||||
let zero = LensTables { ca: t.ca, ..tables_with_distortion(&[0.0; 16]) };
|
||||
assert!(warp_opcode(&zero, knots(), FF_ACTIVE, FF_CROP, true).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejected_ca_keeps_the_single_green_plane() {
|
||||
let green = assert_warp(
|
||||
warp_opcode(&tables_with_distortion(&values(&FF_DISTORTION)), knots(), FF_ACTIVE, FF_CROP, false),
|
||||
[0.4996005991, 0.5],
|
||||
&[FF_G],
|
||||
);
|
||||
let ok = values(&FF_HQ_CA);
|
||||
let huge = vec![(i64::MAX / 4) as f64; 16];
|
||||
for (what, red, blue) in [
|
||||
("huge red", huge.clone(), ok[16..].to_vec()),
|
||||
("huge blue", ok[..16].to_vec(), huge),
|
||||
("most negative blue", ok[..16].to_vec(), vec![i64::MIN as f64; 16]),
|
||||
("not finite red", vec![0.0, f64::NAN, 20.0], ok[16..].to_vec()),
|
||||
("one knot", vec![100.0], vec![100.0]),
|
||||
] {
|
||||
let t = LensTables { ca: Some((red, blue)), ..tables_with_distortion(&values(&FF_DISTORTION)) };
|
||||
let Some(Opcode::WarpRectilinear { planes, .. }) = warp_opcode(&t, knots(), FF_ACTIVE, FF_CROP, true) else { panic!("{what}: no warp") };
|
||||
assert_eq!(planes, green, "{what}");
|
||||
}
|
||||
// through the IFD: an odd count, or values that overflow the fit
|
||||
let mut odd = FF_HQ_CA.to_vec();
|
||||
odd[0] = 31;
|
||||
let mut huge_ifd = vec![i64::MAX / 4; 33];
|
||||
huge_ifd[0] = 32;
|
||||
// a constant 32767 (the SShort maximum) fits as well as G does and would shift red ~59 px at the full-res
|
||||
// corner: refused as implausible (bound 2·10⁻³ relative = 6720)
|
||||
let mut max = vec![32767; 33];
|
||||
max[0] = 32;
|
||||
for (what, ca) in [("odd count", Value::SShort(odd)), ("huge", Value::SLong8(huge_ifd)), ("constant 32767", Value::SShort(max))] {
|
||||
let ifd = ifd(vec![(DISTORTION_PARAMS, Value::SShort(FF_DISTORTION.to_vec())), (CA_PARAMS, ca)]);
|
||||
let op = warp_opcode(&read_tables(&ifd), knots(), FF_ACTIVE, FF_CROP, true);
|
||||
assert!(matches!(op, Some(Opcode::WarpRectilinear { ref planes, .. }) if planes == &green), "{what}: {op:?}");
|
||||
}
|
||||
// values up to the bound are kept
|
||||
let mut at_bound = vec![6720; 33];
|
||||
at_bound[0] = 32;
|
||||
let ifd = ifd(vec![(DISTORTION_PARAMS, Value::SShort(FF_DISTORTION.to_vec())), (CA_PARAMS, Value::SShort(at_bound))]);
|
||||
let op = warp_opcode(&read_tables(&ifd), knots(), FF_ACTIVE, FF_CROP, true);
|
||||
assert!(matches!(op, Some(Opcode::WarpRectilinear { ref planes, .. }) if planes.len() == 3), "{op:?}");
|
||||
}
|
||||
|
||||
/// The default: a valid CA table is read and converts (above), but `lens_opcodes` emits the G plane alone.
|
||||
#[test]
|
||||
fn lens_opcodes_emit_the_green_plane_only() {
|
||||
let ifd = sony_lens_ifd(&FF_DISTORTION, &FF_VIGNETTING, &FF_HQ_CA, [1, 2, 1]);
|
||||
let with_ca = warp_opcode(&read_tables(&ifd), knots(), FF_ACTIVE, FF_CROP, true);
|
||||
assert!(matches!(with_ca, Some(Opcode::WarpRectilinear { ref planes, .. }) if planes.len() == 3), "{with_ca:?}");
|
||||
let ops = lens_opcodes(&ifd, "ILCE-7RM6", FF_ACTIVE, FF_CROP);
|
||||
let [Opcode::WarpRectilinear { planes, .. }] = ops.as_slice() else { panic!("{ops:?}") };
|
||||
assert_eq!(planes.len(), 1);
|
||||
let green = warp_opcode(&tables_with_distortion(&values(&FF_DISTORTION)), knots(), FF_ACTIVE, FF_CROP, false);
|
||||
assert_eq!(ops.first(), green.as_ref());
|
||||
}
|
||||
|
||||
/// The corpus lossless files' own tables on their decoded geometry (padded active areas; the corpus test checks
|
||||
/// the G plane from the files): R, G, B with the CA planes enabled.
|
||||
#[test]
|
||||
fn lossless_examples_ca_planes_match_the_measured_warp() {
|
||||
let ff = read_tables(&sony_lens_ifd(&FF_DISTORTION, &FF_VIGNETTING, &FF_CA, [0, 2, 1]));
|
||||
let ff_active = Rect { x: 0, y: 0, width: 10240, height: 7168 };
|
||||
assert_warp(
|
||||
warp_opcode(&ff, knots(), ff_active, FF_CROP, true),
|
||||
[0.488670768629749, 0.4653969582810102],
|
||||
&[
|
||||
[0.971770511193381, 0.023073568897462152, 0.00879158563716919, 0.0030468782543633132],
|
||||
[0.9717033132764651, 0.023249297636569695, 0.008638979168841135, 0.003091041831374855],
|
||||
[0.9716312798157171, 0.02356676356656341, 0.00830169937204417, 0.003182927930635947],
|
||||
],
|
||||
);
|
||||
let apsc = read_tables(&sony_lens_ifd(&APSC_LOSSLESS_DISTORTION, &APSC_VIGNETTING, &APSC_LOSSLESS_CA, [0, 2, 1]));
|
||||
let apsc_active = Rect { x: 0, y: 0, width: 6656, height: 4608 };
|
||||
assert_warp(
|
||||
warp_opcode(&apsc, knots(), apsc_active, APSC_CROP, true),
|
||||
[0.49278737791134486, 0.4743868026915563],
|
||||
&[
|
||||
[1.0165959888027158, -0.0690974265041942, 0.036328960724338874, -0.009486844437228997],
|
||||
[1.016366854640611, -0.0688824189782517, 0.03615745253291194, -0.009438825010476538],
|
||||
[1.0162457267369314, -0.0686225599869027, 0.035974238433727206, -0.009384155126102897],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// The lossless files' padded active area (10240 × 7168) puts the farthest active-area corner (DNG radius 1) well
|
||||
/// beyond the crop's (u 0.925): the gate looks only at the radii the crop shows. The FF example × 1.5 fits within
|
||||
/// 0.12 px at 1440 at the samples there, 0.27 px at all 65.
|
||||
#[test]
|
||||
fn padded_active_area_gates_only_the_displayed_radii() {
|
||||
let strong: Vec<f64> = values(&FF_DISTORTION).iter().map(|v| v * 1.5).collect();
|
||||
let padded = Rect { x: 0, y: 0, width: 10240, height: 7168 };
|
||||
let op = warp_opcode(&tables_with_distortion(&strong), knots(), padded, FF_CROP, false);
|
||||
assert!(matches!(op, Some(Opcode::WarpRectilinear { .. })), "{op:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_model_gets_no_opcodes() {
|
||||
let ifd = sony_lens_ifd(&FF_DISTORTION, &FF_VIGNETTING, &FF_CA, [0, 2, 1]);
|
||||
assert!(lens_opcodes(&ifd, "ILCE-7M4", FF_ACTIVE, FF_CROP).is_empty());
|
||||
assert!(lens_opcodes(&ifd, "", FF_ACTIVE, FF_CROP).is_empty());
|
||||
// the camera's in-camera flag (0 = off here) doesn't matter: the table is applied
|
||||
let ops = lens_opcodes(&ifd, " ILCE-7RM6 ", FF_ACTIVE, FF_CROP);
|
||||
assert_eq!(ops, warp_opcode(&read_tables(&ifd), knots(), FF_ACTIVE, FF_CROP, false).into_iter().collect::<Vec<_>>());
|
||||
assert_eq!(ops.len(), 1);
|
||||
}
|
||||
}
|
||||
Vendored
+158
@@ -6,6 +6,7 @@ mod arw6_curve;
|
||||
#[cfg(test)]
|
||||
pub(crate) mod arw6_testenc;
|
||||
mod arw_lens;
|
||||
pub(crate) mod arw_lens_rm6;
|
||||
pub mod cr2;
|
||||
pub mod cr3;
|
||||
pub mod crx;
|
||||
@@ -112,6 +113,114 @@ pub(crate) fn white_from_data(data: &[u16], bits: u32) -> f32 {
|
||||
}
|
||||
}
|
||||
|
||||
/// Output radii sampled to fit a radial warp.
|
||||
const DISTORTION_SAMPLES: usize = 64;
|
||||
|
||||
/// Least-squares radial warp from a camera's forward distortion map.
|
||||
///
|
||||
/// `corrected` maps a source radius to the output radius it lands at; it must rise monotonically up to `t_max`, the
|
||||
/// output radius of the image corners. Radii use the caller's unit; pass `WarpRectilinear`'s normalisation (distance
|
||||
/// from the centre to the **farthest** corner = 1) so the result is directly a `WarpRectilinear` plane. The map is
|
||||
/// scanned at 4096 points up to a source radius of `3 * t_max`: it must reach `t_max` within that range and rise at
|
||||
/// every scan point (monotonicity is checked only there), otherwise `None`; a NaN at a scan point is rejected the
|
||||
/// same way, other non-finite values by the fit. The map is inverted by bisection and
|
||||
/// `source = u·(k0 + k1u² + k2u⁴ + k3u⁶)` is fitted over output radii `u·t_max`, `u` in [0, 1] ([`fit_radial_warp`]):
|
||||
/// the returned `[k0, k1, k2, k3]` give the source radius in the caller's unit as a function of `u`. `None` too when
|
||||
/// the worst fit error exceeds `max_fit_error`.
|
||||
pub(crate) fn warp_from_forward_map(corrected: &dyn Fn(f64) -> f64, t_max: f64, max_fit_error: f64) -> Option<[f64; 4]> {
|
||||
if !(t_max.is_finite() && t_max > 0.0) {
|
||||
return None;
|
||||
}
|
||||
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)
|
||||
};
|
||||
fit_radial_warp(&|u| source(u * t_max), max_fit_error, 1.0)
|
||||
}
|
||||
|
||||
/// Least-squares `source = u·(k0 + k1u² + k2u⁴ + k3u⁶)` through `source_of_u` sampled at `u = i/64`, i = 0..=64
|
||||
/// (`u` = output radius, `WarpRectilinear` units, so the result is directly a plane's `[k0, k1, k2, k3]`). `None` when
|
||||
/// a sample is not finite, the normal equations are singular, or the worst fit error at the samples up to the first
|
||||
/// one at or beyond `gate_until` (the radii the image shows; 1 = all) exceeds `max_fit_error`.
|
||||
pub(crate) fn fit_radial_warp(source_of_u: &dyn Fn(f64) -> f64, max_fit_error: f64, gate_until: f64) -> Option<[f64; 4]> {
|
||||
let samples: Vec<(f64, f64)> = (0..=DISTORTION_SAMPLES)
|
||||
.map(|i| {
|
||||
let u = i as f64 / DISTORTION_SAMPLES as f64;
|
||||
(u, source_of_u(u))
|
||||
})
|
||||
.collect();
|
||||
if samples.iter().any(|&(_, v)| !v.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
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>();
|
||||
// rounded up to a sample, so the band between the last sample inside and `gate_until` itself is checked too;
|
||||
// capped at 1 (`min` also turns a NaN into 1: every sample is gated)
|
||||
let n = DISTORTION_SAMPLES as f64;
|
||||
let gate_until = (gate_until * n).ceil().min(n) / n;
|
||||
let worst = samples.iter().filter(|&&(u, _)| u <= gate_until).map(|&(u, v)| (fit(u) - v).abs()).fold(0.0, f64::max);
|
||||
(worst <= max_fit_error).then_some(k)
|
||||
}
|
||||
|
||||
/// 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::*;
|
||||
@@ -161,4 +270,53 @@ mod tests {
|
||||
let w = white_from_data(&d, 14);
|
||||
assert!((13500.0..=13583.0).contains(&w), "{w}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warp_from_forward_map_recovers_identity_and_rejects_folds() {
|
||||
let k = warp_from_forward_map(&|r| r, 1.0, 1e-9).unwrap();
|
||||
assert!((k[0] - 1.0).abs() < 1e-9 && k[1].abs() < 1e-9 && k[2].abs() < 1e-9 && k[3].abs() < 1e-9);
|
||||
let barrel = |r: f64| r * (1.0 + 0.05 * r * r); // mild barrel: source < output radius
|
||||
let t_max = 0.9;
|
||||
let k = warp_from_forward_map(&barrel, t_max, 1e-3).unwrap();
|
||||
assert!(k[0] < t_max && k[0] > 0.8 && k[1] < 0.0); // source radius at u = 1 is below t_max
|
||||
for i in 0..=20 {
|
||||
let u = i as f64 / 20.0;
|
||||
let s = u * (k[0] + u * u * (k[1] + u * u * (k[2] + u * u * k[3])));
|
||||
assert!((barrel(s) - u * t_max).abs() < 1e-5, "u {u}");
|
||||
}
|
||||
assert!(warp_from_forward_map(&|r| if r < 0.5 { r } else { 0.5 - (r - 0.5) }, 1.0, 1e-3).is_none()); // fold, never reaches t_max
|
||||
// a tiny dip (two scan points wide, 1e-4 deep) that still reaches t_max and fits well: only the monotonicity check rejects it
|
||||
let dip = |r: f64| {
|
||||
if r < 0.5 {
|
||||
r
|
||||
} else if r < 0.5015 {
|
||||
0.5 - 0.05 * (r - 0.5)
|
||||
} else {
|
||||
r - 0.001575
|
||||
}
|
||||
};
|
||||
assert!(warp_from_forward_map(&dip, 1.0, 1e-2).is_none());
|
||||
assert!(warp_from_forward_map(&|_| f64::NAN, 1.0, 1e-3).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fit_radial_warp_identity_and_gate() {
|
||||
let k = fit_radial_warp(&|u| u, 1e-9, 1.0).unwrap();
|
||||
assert!((k[0] - 1.0).abs() < 1e-9 && k[1].abs() < 1e-9 && k[2].abs() < 1e-9 && k[3].abs() < 1e-9, "{k:?}");
|
||||
assert!(fit_radial_warp(&|u| if u > 0.5 { f64::NAN } else { u }, 1.0, 1.0).is_none(), "a NaN sample");
|
||||
assert!(fit_radial_warp(&|u| if u > 0.5 { f64::INFINITY } else { u }, 1.0, 1.0).is_none(), "an infinite sample");
|
||||
assert!(fit_radial_warp(&|u: f64| u.sqrt(), 1.0, 1.0).is_some());
|
||||
assert!(fit_radial_warp(&|u: f64| u.sqrt(), 0.0, 1.0).is_none(), "not a polynomial: the gate rejects it");
|
||||
// a step at u = 1 alone: worst fit error 0.057 over all samples, 0.006 over u <= 0.9 (the fit is the same)
|
||||
let step = |u: f64| if u > 0.99 { 1.1 } else { u };
|
||||
assert!(fit_radial_warp(&step, 0.01, 1.0).is_none());
|
||||
assert_eq!(fit_radial_warp(&step, 0.01, 0.9), fit_radial_warp(&step, 1.0, 1.0), "gated only where shown");
|
||||
// the first sample at or beyond `gate_until` is gated too (the band up to it is shown): 0.023 up to
|
||||
// u = 62/64, 0.032 at 63/64
|
||||
assert!(fit_radial_warp(&step, 0.03, 62.0 / 64.0).is_some(), "a sample exactly at the bound");
|
||||
assert!(fit_radial_warp(&step, 0.03, 0.98).is_none(), "63/64 is the next sample beyond 0.98");
|
||||
// capped at 1, and a NaN gates every sample (fails closed)
|
||||
assert_eq!(fit_radial_warp(&step, 1.0, f64::INFINITY), fit_radial_warp(&step, 1.0, 1.0));
|
||||
assert!(fit_radial_warp(&step, 0.01, f64::NAN).is_none(), "a NaN gate");
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+3
-80
@@ -145,8 +145,6 @@ const FORMAT8_FIXED: [(u16, &[u16]); 9] = [
|
||||
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).
|
||||
@@ -650,87 +648,12 @@ fn distortion(ifd: &Ifd, active: Rect) -> Option<Opcode> {
|
||||
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;
|
||||
}
|
||||
// work in DNG-normalised units (corner distance = 1), where the output corners sit at radius `zoom`
|
||||
let scale = radius / half_diag;
|
||||
let k = super::warp_from_forward_map(&|s| corrected(s / scale) * scale, zoom, DISTORTION_MAX_FIT_ERROR)?;
|
||||
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::*;
|
||||
|
||||
@@ -626,6 +626,87 @@ fn corpus_sony_16x9_crops() {
|
||||
eprintln!("Sony 16:9 crops checked on {seen} files");
|
||||
}
|
||||
|
||||
/// Sony ILCE-7RM6: the distortion table in the raw IFD (`0x7037`) becomes a one-plane `WarpRectilinear` in
|
||||
/// `OpcodeList3` (`vendor::arw_lens_rm6`; the lateral CA table is read but its planes are off by default, and their
|
||||
/// numbers for these files are checked in `arw_lens_rm6`'s unit tests). Expected: the conversion recipe measured on 54
|
||||
/// ILCE-7RM6 photos, evaluated in f64 for each file's tables, decoded active area (the padded lossless tiles) and
|
||||
/// default crop. All four files carry distortion correction off in camera; the table is applied anyway.
|
||||
#[test]
|
||||
fn corpus_sony_lens_tables_become_opcodes() {
|
||||
let dir = corpus_root().join("raw");
|
||||
// (file, active area, default crop x, y, width, height, opcode centre, G-plane k0..k3)
|
||||
let cases = [
|
||||
// not pinned in `cargo xtask corpus` (at most two pins per PR; the ARW 6.0 decoder pins the two Compressed files
|
||||
// below): checked only where the file is present (RawDB `ILCE-7RM6_FF_land_RAW.ARW`, CC0)
|
||||
(
|
||||
"arw-sony-ilce7rm6-ff-land-lossless.arw",
|
||||
(10240, 7168),
|
||||
(12, 8, 9984, 6656),
|
||||
[0.488670768629749, 0.4653969582810102],
|
||||
[0.9717033132764651, 0.023249297636569695, 0.008638979168841135, 0.003091041831374855],
|
||||
),
|
||||
// pinned in `cargo xtask corpus` (RawDB `ILCE-7RM6_APSC_land_RAW.ARW`, CC0)
|
||||
(
|
||||
"arw-sony-ilce7rm6-apsc-land-lossless.arw",
|
||||
(6656, 4608),
|
||||
(16, 10, 6528, 4352),
|
||||
[0.49278737791134486, 0.4743868026915563],
|
||||
[1.016366854640611, -0.0688824189782517, 0.03615745253291194, -0.009438825010476538],
|
||||
),
|
||||
// pinned by the ARW 6.0 decoder (RawDB `ILCE-7RM6_FF_land_RAW-C.ARW`, CC0): the same scene as the full-frame
|
||||
// lossless file, its table one step off at knot 12 (268, not 267), on the decoder's unpadded geometry
|
||||
(
|
||||
"arw6-sony-ilce7rm6-ff-land-c.arw",
|
||||
(10016, 6672),
|
||||
(12, 8, 9984, 6656),
|
||||
[0.49960059910134796, 0.5],
|
||||
[0.9714600903266707, 0.02234210164294959, 0.001142578769532717, 0.005292120046651031],
|
||||
),
|
||||
// pinned by the ARW 6.0 decoder (RawDB `ILCE-7RM6_APSC_land_RAW-C-HD.ARW`, CC0)
|
||||
(
|
||||
"arw6-sony-ilce7rm6-apsc-land-hq.arw",
|
||||
(6592, 4372),
|
||||
(16, 10, 6528, 4352),
|
||||
[0.4975724472765893, 0.5],
|
||||
[1.01627551, -0.0618606692, 0.02768349621, -0.005595798468],
|
||||
),
|
||||
];
|
||||
let mut seen = 0;
|
||||
for (name, (aw, ah), crop, center, k) in cases {
|
||||
let bytes = match std::fs::read(dir.join(name)) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
eprintln!("skip: {name} absent");
|
||||
continue;
|
||||
}
|
||||
Err(e) => panic!("{name}: {e}"),
|
||||
};
|
||||
let img = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
let (a, c) = (img.active_area, img.crop);
|
||||
eprintln!("{name}: active area {a:?}, crop {c:?}");
|
||||
assert_eq!((a.x, a.y, a.width, a.height), (0, 0, aw, ah), "{name}: active area");
|
||||
assert_eq!((c.x, c.y, c.width, c.height), crop, "{name}: default crop");
|
||||
assert_eq!(probe_info(&bytes).unwrap().opcodes, img.opcodes, "{name}: the headers-only probe reads the same correction");
|
||||
let warps: Vec<_> = img
|
||||
.opcodes
|
||||
.list3
|
||||
.iter()
|
||||
.filter_map(|op| match op {
|
||||
lightcraft_raw::Opcode::WarpRectilinear { planes, center } => Some((planes, center)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let [(planes, got)] = warps.as_slice() else { panic!("{name}: {:?}", img.opcodes.list3) };
|
||||
assert_eq!(planes.len(), 1, "{name}: one plane (G; lateral CA off by default)");
|
||||
assert!((got[0] - center[0]).abs() < 1e-6 && (got[1] - center[1]).abs() < 1e-6, "{name}: centre {got:?}, want {center:?}");
|
||||
for (i, want) in k.iter().enumerate() {
|
||||
assert!((planes[0][i] - want).abs() < 1e-6, "{name}: k{i} {}, want {want}", planes[0][i]);
|
||||
}
|
||||
seen += 1;
|
||||
}
|
||||
eprintln!("Sony lens tables checked on {seen} files");
|
||||
}
|
||||
|
||||
/// The embedded JPEG as linear RGB, reduced to `gw × gh` cells.
|
||||
fn jpeg_cells(jpeg: &[u8], gw: usize, gh: usize) -> Vec<[f64; 3]> {
|
||||
use zune_core::{bytestream::ZCursor, colorspace::ColorSpace, options::DecoderOptions};
|
||||
|
||||
@@ -945,7 +945,7 @@
|
||||
"No edits yet.": "Noch keine Bearbeitungen.",
|
||||
"No exposure information": "Keine Belichtungsinformationen",
|
||||
"No filter presets yet": "Noch keine Filtervorgaben",
|
||||
"No lens data embedded in this file (DNG lens opcodes only).": "Keine Objektivdaten in dieser Datei eingebettet (nur DNG-Objektiv-Opcodes).",
|
||||
"No lens data embedded in this file.": "Keine Objektivdaten in dieser Datei eingebettet.",
|
||||
"No masks yet. Choose a mask type above.": "Noch keine Masken. Wähle oben einen Maskentyp.",
|
||||
"No matching commands": "Keine passenden Befehle",
|
||||
"No matching photos": "Keine passenden Fotos",
|
||||
|
||||
@@ -920,7 +920,7 @@
|
||||
"No edits yet.": "Aún no hay ediciones.",
|
||||
"No exposure information": "Sin información de exposición",
|
||||
"No filter presets yet": "Aún no hay ajustes preestablecidos de filtro",
|
||||
"No lens data embedded in this file (DNG lens opcodes only).": "Este archivo no incluye datos de objetivo (solo opcodes de objetivo DNG).",
|
||||
"No lens data embedded in this file.": "Este archivo no incluye datos de objetivo.",
|
||||
"No masks yet. Choose a mask type above.": "Aún no hay máscaras. Elegir un tipo de máscara arriba.",
|
||||
"No matching commands": "Ningún comando coincide",
|
||||
"No matching photos": "No hay fotos coincidentes",
|
||||
|
||||
@@ -897,7 +897,7 @@
|
||||
"No edits yet.": "Aucune retouche pour l'instant.",
|
||||
"No exposure information": "Aucune information d'exposition",
|
||||
"No filter presets yet": "Aucun préréglage de filtre pour l'instant",
|
||||
"No lens data embedded in this file (DNG lens opcodes only).": "Aucune donnée d'objectif intégrée à ce fichier (opcodes d'objectif DNG uniquement).",
|
||||
"No lens data embedded in this file.": "Aucune donnée d'objectif intégrée à ce fichier.",
|
||||
"No masks yet. Choose a mask type above.": "Aucun masque pour l'instant. Choisissez un type de masque ci-dessus.",
|
||||
"No matching commands": "Aucune commande correspondante",
|
||||
"No matching photos": "Aucune photo correspondante",
|
||||
|
||||
@@ -949,7 +949,7 @@
|
||||
"No edits yet.": "まだ編集していません",
|
||||
"No exposure information": "露出情報がありません",
|
||||
"No filter presets yet": "フィルタープリセットはまだありません",
|
||||
"No lens data embedded in this file (DNG lens opcodes only).": "このファイルにレンズ情報は含まれていません(DNGのレンズ情報のみ対応)",
|
||||
"No lens data embedded in this file.": "このファイルにレンズ情報は含まれていません",
|
||||
"No masks yet. Choose a mask type above.": "マスクはありません。上から種類を選択してください",
|
||||
"No matching photos": "一致する写真がありません",
|
||||
"No metadata found": "メタデータがありません",
|
||||
|
||||
@@ -909,7 +909,7 @@
|
||||
"No edits yet.": "Ainda sem edições.",
|
||||
"No exposure information": "Sem informações de exposição",
|
||||
"No filter presets yet": "Ainda não há predefinições de filtro",
|
||||
"No lens data embedded in this file (DNG lens opcodes only).": "Nenhum dado de lente incorporado neste arquivo (somente opcodes de lente DNG).",
|
||||
"No lens data embedded in this file.": "Nenhum dado de lente incorporado neste arquivo.",
|
||||
"No masks yet. Choose a mask type above.": "Ainda não há máscaras. Escolha um tipo de máscara acima.",
|
||||
"No matching commands": "Nenhum comando corresponde",
|
||||
"No matching photos": "Nenhuma foto correspondente",
|
||||
|
||||
@@ -752,7 +752,7 @@
|
||||
"No edits yet.": "Правок пока нет.",
|
||||
"No exposure information": "Нет данных об экспозиции",
|
||||
"No filter presets yet": "Пресетов фильтров пока нет",
|
||||
"No lens data embedded in this file (DNG lens opcodes only).": "В этом файле нет данных об объективе (только опкоды объектива DNG).",
|
||||
"No lens data embedded in this file.": "В этом файле нет данных об объективе.",
|
||||
"No masks yet. Choose a mask type above.": "Масок пока нет. Выберите тип маски выше.",
|
||||
"No matching commands": "Подходящих команд нет",
|
||||
"No matching photos": "Нет подходящих фото",
|
||||
|
||||
@@ -1086,7 +1086,7 @@
|
||||
"No edits yet.": "Змін поки немає.",
|
||||
"No exposure information": "Немає інформації про експозицію",
|
||||
"No filter presets yet": "Наборів налаштувань фільтрів поки немає",
|
||||
"No lens data embedded in this file (DNG lens opcodes only).": "У цьому файлі немає даних об’єктива (лише інструкції об’єктива DNG).",
|
||||
"No lens data embedded in this file.": "У цьому файлі немає даних об’єктива.",
|
||||
"No masks yet. Choose a mask type above.": "Масок поки немає. Виберіть тип маски вище.",
|
||||
"No matching photos": "Немає відповідних фото",
|
||||
"No metadata found": "Метаданих не знайдено",
|
||||
|
||||
@@ -971,7 +971,7 @@
|
||||
"No edits yet.": "尚未编辑。",
|
||||
"No exposure information": "无曝光信息",
|
||||
"No filter presets yet": "暂无筛选器预设",
|
||||
"No lens data embedded in this file (DNG lens opcodes only).": "此文件中未嵌入镜头数据 (仅支持 DNG 镜头操作码)。",
|
||||
"No lens data embedded in this file.": "此文件中未嵌入镜头数据。",
|
||||
"No masks yet. Choose a mask type above.": "尚无蒙版。请在上方选择蒙版类型。",
|
||||
"No matching photos": "没有匹配的照片",
|
||||
"No metadata found": "未找到元数据",
|
||||
|
||||
@@ -971,7 +971,7 @@
|
||||
"No edits yet.": "尚未編輯。",
|
||||
"No exposure information": "沒有曝光資訊",
|
||||
"No filter presets yet": "尚無篩選預設集",
|
||||
"No lens data embedded in this file (DNG lens opcodes only).": "此檔案未內嵌鏡頭資料(僅支援 DNG 鏡頭操作碼)。",
|
||||
"No lens data embedded in this file.": "此檔案未內嵌鏡頭資料。",
|
||||
"No masks yet. Choose a mask type above.": "尚無遮色片。請在上方選擇遮色片類型。",
|
||||
"No matching photos": "沒有符合的照片",
|
||||
"No metadata found": "找不到中繼資料",
|
||||
|
||||
@@ -371,9 +371,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
|
||||
}
|
||||
if lp && !has_lens {
|
||||
ui.label(
|
||||
egui::RichText::new(crate::i18n::tr("No lens data embedded in this file (DNG lens opcodes only)."))
|
||||
.size(11.0)
|
||||
.color(Tokens::get(ui.ctx()).text_dim),
|
||||
egui::RichText::new(crate::i18n::tr("No lens data embedded in this file.")).size(11.0).color(Tokens::get(ui.ctx()).text_dim),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -17,7 +17,7 @@ Matrix-only and matrix + table looks are each completed (tone curve, then chroma
|
||||
|
||||
The matrix is applied to scene-linear sensor pixels. The curve is carried in `SourceInfo` and applied in the pipeline finish stage, after exposure/WB/local edits. It is never baked into decoded luminance or clipped to JPEG precision. CPU and GPU use the same tone LUT. Contrast, whites and blacks adjust the starting look through the display-domain controls. JPEG neutral rendering and the existing DNG colour model are unchanged.
|
||||
|
||||
One third of the pixels are held out from both fits. The combined model must reduce linear-display squared error by at least 30% and have per-channel RMS error ≤0.10 (0.055 rejected half of the public raw.pixls.us samples tried — ILCE-6400, -6700, -7M4 — whose fits were still 1.5–3× closer to the camera JPEG than the fallback and visibly better). When the fit on all pixels fails these gates, it is tried once more, with the same gates, on the pixels away from edges (where the 3×3 neighbourhood in either proxy spans a luminance ratio above 3). Many camera JPEGs are lens-corrected while the raw is not (Sony "Distortion Comp.: Auto", compacts and kit zooms), and on a detailed scene the misaligned edges alone fail the gate although the colours agree everywhere else (issue #232). The public ILCE-7RM2 samples (raw.pixls.us, one façade shot in seven compression/correction variants, 24–70 mm at 51 mm) are up to 15 px of 1440 off their JPEG; the six with distortion correction on missed the gate at held-out RMS 0.108–0.115 and opened in the grey neutral fallback, 13 L* too dark (ΔE76 16.6–16.9 against the JPEG after registering its geometry). Away from edges they pass at 0.049–0.052 and land at ΔE 3.7–3.8 with lightness within 0.6 L*, like the variant shot with distortion correction off (0.059 on all pixels, ΔE 3.9). The corpus GX80 kit-zoom sample (up to 63 px of 1440 off) passes too: 0.107 → 0.081, ΔE 19.1 → 7.9. Fits accepted on all pixels are unchanged. These are provisional acceptance gates, not a colour-accuracy certification. Singular, unrelated and aspect-mismatched references retain the documented fallback; poor-fitting ones get the partial look below when it is clearly closer to the camera JPEG, and black-and-white ones only their tone curve (below). Both fits use the same fixed proxy at every decode resolution.
|
||||
One third of the pixels are held out from both fits. The combined model must reduce linear-display squared error by at least 30% and have per-channel RMS error ≤0.10 (0.055 rejected half of the public raw.pixls.us samples tried — ILCE-6400, -6700, -7M4 — whose fits were still 1.5–3× closer to the camera JPEG than the fallback and visibly better). When the fit on all pixels fails these gates, it is tried once more, with the same gates, on the pixels away from edges (where the 3×3 neighbourhood in either proxy spans a luminance ratio above 3). Many camera JPEGs are lens-corrected while the raw is not (Sony "Distortion Comp.: Auto", compacts and kit zooms), and on a detailed scene the misaligned edges alone fail the gate although the colours agree everywhere else (issue #232). Sony's embedded lens tables now correct ILCE-7RM6 renders under Enable Lens Corrections ([Sony ARW lens corrections](sony-ilce7rm6-lens-corrections.md)), but this fit still matches its pairs without them (the sensor proxy is uncorrected); aligning the pairs first is a follow-up. The public ILCE-7RM2 samples (raw.pixls.us, one façade shot in seven compression/correction variants, 24–70 mm at 51 mm) are up to 15 px of 1440 off their JPEG; the six with distortion correction on missed the gate at held-out RMS 0.108–0.115 and opened in the grey neutral fallback, 13 L* too dark (ΔE76 16.6–16.9 against the JPEG after registering its geometry). Away from edges they pass at 0.049–0.052 and land at ΔE 3.7–3.8 with lightness within 0.6 L*, like the variant shot with distortion correction off (0.059 on all pixels, ΔE 3.9). The corpus GX80 kit-zoom sample (up to 63 px of 1440 off) passes too: 0.107 → 0.081, ΔE 19.1 → 7.9. Fits accepted on all pixels are unchanged. These are provisional acceptance gates, not a colour-accuracy certification. Singular, unrelated and aspect-mismatched references retain the documented fallback; poor-fitting ones get the partial look below when it is clearly closer to the camera JPEG, and black-and-white ones only their tone curve (below). Both fits use the same fixed proxy at every decode resolution.
|
||||
|
||||
**A partial look when the full look misses the gates.** The neutral fallback (camera RGB taken as sRGB, the default tone map) is far from the camera JPEG. Rendered for every photo on this path in the CC0 archive (1,150 files with a camera JPEG), it is 10.7 L\* too dark (median; about one stop), keeps 0.36 of the camera's colourfulness and has hues 7° off on the coloured pixels (median CIEDE2000 13.6). Its white balance is right: on the pixels the camera renders near-neutral the cast is under 1 a\*/b\* (median, every maker). The "blue" fallback on newer Sony bodies (Δb\* −22 over the whole frame on the ILCE-7RM5 sample) is a warm scene losing its colour, not a cast: its near-neutral pixels are within −1.6 b\*. So before falling back, a partial look is fitted (`fit_partial`): the quantile tone curve (registration-free, the largest part of the error), the chroma curve, and, when the scene has enough colour to learn a matrix (the full look's condition), the photo's own colour matrix at half weight, the other half the identity (`PARTIAL_MATRIX_WEIGHT`). No hue/saturation table. Half weight because, on the held-out proxy pixels of the 23 colourful photos without a look, the full matrix left 2 of them more than 2° further from the camera's hues than the neutral fallback and half weight none (median hue error 9.6° with the generic colour, 5.1° at half weight, 4.2° at full weight; on the 1,080 photos with a look 3.9° and 2.7°). Without a matrix (tone and chroma curve alone, or with one global saturation scale) about a third of all photos ended up more than 2° further from the camera's hues than the fallback, as the added colour makes the generic matrix's hue errors visible. The partial look is tried on all pixels, then away from edges, and used only when it cuts the held-out squared error below 0.7 of the neutral fallback's in linear and in gamma-encoded display values, and when the JPEG's luminance ranks the pixels like the scene's (Spearman ≥ 0.5, `PARTIAL_MIN_RANK_CORRELATION`): a curve matched to the brightness of an unrelated picture also beats the dark fallback pixel for pixel. The accepted full looks have 0.55 or more; three old Coolpix files whose raws decode wrongly have 0.07–0.27 and keep the fallback. There is no limit on the partial look's own error. On the archive (1,783 renders): all 1,087 full looks are byte-identical; of the 55 photos that reach the partial fit, 49 get it (40 on all pixels, 9 away from edges; 13 with the damped matrix, 41 with a chroma curve) and 6 keep the fallback (lens-corrected compacts: RX10, TZ80 1:1, D-LUX 4, G10 16:9, GF2 ×2). On the 49, median CIEDE2000 to the camera JPEG 14.0 → 2.1, ΔL\* −11.5 → +0.6, colourfulness 0.36 → 0.87, hue error 10.1° → 7.1°; every one is closer, by at least 1.3. Most of the others without a look never reach a fit: on 48 files (Nikon D4, Df, D300/D300S, D7000, D5100 and others) the camera JPEG proxy comes out a pixel narrower than the sensor proxy and no pairs are collected (the resize in PR #531 fixes this; with it 46 of them pass the full look's gates, CIEDE2000 17.9 → 2.9).
|
||||
|
||||
|
||||
+2
-1
@@ -22,7 +22,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app
|
||||
| 5 B | **AI masks: Subject, Sky, People, Background, Landscape** are heuristics; Object / Describe run SAM 3 but its download has no mirror configured | `crates/segment`; ai-masks.md; MASK rows 67% | masking is how most Lightroom users edit today | 40–70 + model decision | [target-app-parity](target-app-parity.md#ai) |
|
||||
| 6 B | **Compressed ORF** (every Olympus / OM body since ~2008) | measured: 66 of 78 Olympus / OM models preview-only; `orf.rs` refuses; OM bucket 3% of photographers, 3% of them fully work | +2.6 points; best gain per hour of any raw gap (~0.2) | 8–15 | [raw-parity](raw-parity.md#compressed-orf-a-clean-room-starting-point) (the two Olympus patents that describe the method) |
|
||||
| 7 | **Nikon High Efficiency NEF** (HE / HE★ on Z 8, Z 9, Z 6III, Z f, Z50II, Z5II when set to HE) | named and preview only (#193); needs a pure-Rust JPEG XS decoder | +1.1 points (only shots taken in HE; lossless works on the same bodies); low gain per hour (~0.04), so not a beta blocker | 20–40 | [raw-parity](raw-parity.md) |
|
||||
| 8 B | **No lens profile database** (LR-EDIT-OPTICS-PROFILE): only DNG opcodes, RW2 in-file distortion, Sony ILCE-7RM4A distortion and Nikon Z 6II / Z 8 / Z f distortion (`docs/nikon-lens-corrections.md`) | Adobe ships 3,635 lens profiles; we never use them | distortion / vignetting correction is a default step for many | 40–80 + calibration shots | [raw-parity](raw-parity.md#lens-profiles) |
|
||||
| 8 B | **No lens profile database** (LR-EDIT-OPTICS-PROFILE): only DNG opcodes, RW2 in-file distortion, Sony ILCE-7RM4A and ILCE-7RM6 distortion (`docs/sony-ilce7rm6-lens-corrections.md`) and Nikon Z 6II / Z 8 / Z f distortion (`docs/nikon-lens-corrections.md`) | Adobe ships 3,635 lens profiles; we never use them | distortion / vignetting correction is a default step for many | 40–80 + calibration shots | [raw-parity](raw-parity.md#lens-profiles) |
|
||||
| 9 B | **Tests don't run in CI on macOS, Windows or Linux x64**: only FreeBSD runs `cargo test`; the others build packages | `.github/workflows/` (freebsd, packaging-lint, release, windows-arm64); the linux-riscv64 release job added in 0.6.0 (#844) runs only `lightcraft-cli --version` under QEMU | regressions reach users on the main platforms; `cargo xtask ci` runs only locally | 6–12 | [target-app-parity](target-app-parity.md#by-dimension) |
|
||||
| 10 B | **Per-model verification thin, plus cheap per-body decode fixes**: 236 of 1,446 Adobe models verified (16%), ~42% of photographers on a verified body; Nikon 5 bodies verified, Canon 12, Leica 14 (the Q3 by a recorded check of 38 private DNGs). Cheap fixes: Z 6II packed 14-bit NEF (#769, PR #671), Sony A100 / F828 regression (#702), DSLR-A200…A390 / A850 12-bit ARW (#535) | measured: [raw/coverage.tsv](raw/coverage.tsv) | per-body bugs (levels, crops, WB) surface only from users | 25–45 | [raw-parity](raw-parity.md#by-maker-measured) |
|
||||
| 11 | **Enhance: Raw Details, Super Resolution; AI denoise without shippable weights or X-Trans** | ENH rows 0%; denoise Bayer only, RawNIND weights GPL and opt-in | Fujifilm users get no AI denoise; nobody gets it out of the box | 30–60 + model decision | [raw-parity](raw-parity.md#demosaic-levels-crops-and-lens-metadata) |
|
||||
@@ -94,3 +94,4 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w
|
||||
| 2026-10-12 | minor | Catalog migration reports offline records as additions, keeps the destination/result readable, skips missing-original probes and stages only masters needed for virtual copies. Approximate rendering and database/table limits remain. |
|
||||
| 2026-10-13 | minor | Catalog import review: offline Local records join All Photos, redundant operation/settings copies removed, and result invalidation across library switches covered. Approximate rendering and database/table limits remain. |
|
||||
| 2026-10-14 | patch | LRC-LIB-CATALOG-IMPORT: reconciled with upstream phased progress, cancellation kinds, WAL-only import, translations and current progress tracking. Status and summary counts unchanged. |
|
||||
| 2026-10-10 | minor | Gap 8: Sony ILCE-7RM6 in-file distortion added next to the ILCE-7RM4A's, its lateral CA parsed but off by default (docs/sony-ilce7rm6-lens-corrections.md); the gap stays open (no lens database), hours unchanged |
|
||||
|
||||
@@ -213,7 +213,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
|
||||
| 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/raw/src/vendor/raf_lens.rs`, `crates/engine/src/crs.rs` | uses corrections embedded in the file: DNG `WarpRectilinear` / `WarpRectilinear2` (opcode 14, DNG 1.6: degree-14 radial polynomial, valid-radius clamp, reciprocal option, applied in list order with the spec's skip rule for a `WarpRectilinear` after an optional one, so Pixel DNGs' two-stage warp is corrected; up to 3 chained stages, sampled on the CPU because the polynomials need `f64`; issue #635) / `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`); Nikon NEF distortion from NikonNEFInfo (`0xc7d5`: polynomial, frame-filling zoom and the optical-centre offset left by in-body stabilisation; `crates/raw/src/vendor/nef_lens.rs`, `docs/nikon-lens-corrections.md`) for the Z 6II, Z 8 and Z f, full-frame image area only: F-mount lenses on the FTZ match the camera JPEG within 0.2 px median, Z lenses within 0.5–1.5 px except the 24-70 f/4's wide end, where the camera applies a further correction that is not in the readable data (7 px median at 24 mm, from 16 px / 106 px p95 uncorrected); Fujifilm RAF raw-IFD tables (`0xf00b` distortion, `0xf010` vignetting; `crates/raw/src/vendor/raf_lens.rs`, `docs/fuji-lens-corrections.md`) become a `WarpRectilinear` with the camera's frame-filling zoom and a `FixVignetteRadial` gain for 59 body models with sample files checked against the camera JPEG (the table indexed by the source radius with last knot at the corner; on 67 body/lens combinations the corrected render's radial shape error against the camera JPEG falls from a median of 0.72 % to 0.05 % of the half diagonal, largest 0.42 %, and the vignetting gain of the JPEG tracks the table at a median correlation of 0.987); files whose table scale is not the recorded image's half diagonal (X-S20, X-T30 III, X-M5, X-T5 crops) and six unmatched bodies stay uncorrected; Fujifilm's lateral CA table (`0xf00f`) is real but is not applied, because our demosaic already registers the planes and the stored shift over-corrects; vignetting and lateral CA data of Olympus and Sony raws 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); lens-profile database: a lensfun-format XML database in `LIGHTCRAFT_LENSFUN_DIR` (none is bundled) supplies distortion (poly3 / poly5 / PTLens), TCA and vignetting for lenses it lists (`crates/lensdb`, `crates/engine/src/lens_profiles.rs`, `docs/lens-profiles.md`); no settings switch or bundled database yet |
|
||||
| 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/raw/src/vendor/raf_lens.rs`, `crates/engine/src/crs.rs` | uses corrections embedded in the file: DNG `WarpRectilinear` / `WarpRectilinear2` (opcode 14, DNG 1.6: degree-14 radial polynomial, valid-radius clamp, reciprocal option, applied in list order with the spec's skip rule for a `WarpRectilinear` after an optional one, so Pixel DNGs' two-stage warp is corrected; up to 3 chained stages, sampled on the CPU because the polynomials need `f64`; issue #635) / `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; ILCE-7RM6 verified against the camera JPEG on 54 photos with three lenses (`docs/sony-ilce7rm6-lens-corrections.md`); other Sony models remain uncorrected (see `docs/sony-lens-corrections.md`); Nikon NEF distortion from NikonNEFInfo (`0xc7d5`: polynomial, frame-filling zoom and the optical-centre offset left by in-body stabilisation; `crates/raw/src/vendor/nef_lens.rs`, `docs/nikon-lens-corrections.md`) for the Z 6II, Z 8 and Z f, full-frame image area only: F-mount lenses on the FTZ match the camera JPEG within 0.2 px median, Z lenses within 0.5–1.5 px except the 24-70 f/4's wide end, where the camera applies a further correction that is not in the readable data (7 px median at 24 mm, from 16 px / 106 px p95 uncorrected); Fujifilm RAF raw-IFD tables (`0xf00b` distortion, `0xf010` vignetting; `crates/raw/src/vendor/raf_lens.rs`, `docs/fuji-lens-corrections.md`) become a `WarpRectilinear` with the camera's frame-filling zoom and a `FixVignetteRadial` gain for 59 body models with sample files checked against the camera JPEG (the table indexed by the source radius with last knot at the corner; on 67 body/lens combinations the corrected render's radial shape error against the camera JPEG falls from a median of 0.72 % to 0.05 % of the half diagonal, largest 0.42 %, and the vignetting gain of the JPEG tracks the table at a median correlation of 0.987); files whose table scale is not the recorded image's half diagonal (X-S20, X-T30 III, X-M5, X-T5 crops) and six unmatched bodies stay uncorrected; Fujifilm's lateral CA table (`0xf00f`) is real but is not applied, because our demosaic already registers the planes and the stored shift over-corrects; vignetting and lateral CA data of Olympus and Sony raws 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); lens-profile database: a lensfun-format XML database in `LIGHTCRAFT_LENSFUN_DIR` (none is bundled) supplies distortion (poly3 / poly5 / PTLens), TCA and vignetting for lenses it lists (`crates/lensdb`, `crates/engine/src/lens_profiles.rs`, `docs/lens-profiles.md`); no settings switch or bundled database yet |
|
||||
)
|
||||
| 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` | |
|
||||
|
||||
+4
-2
@@ -6,7 +6,8 @@ Raw processing is the deepest single dimension of Lightroom parity: a photograph
|
||||
does not decode, or decodes with the wrong colour, cannot switch. This document measures it model by
|
||||
model. The numbers feed [target-app-parity.md](target-app-parity.md); individual shortfalls are
|
||||
ranked in [gaps.md](gaps.md). Details per format: [cr3.md](cr3.md),
|
||||
[raf-compression.md](raf-compression.md), [sony-lens-corrections.md](sony-lens-corrections.md), [nikon-lens-corrections.md](nikon-lens-corrections.md),
|
||||
[raf-compression.md](raf-compression.md), [sony-lens-corrections.md](sony-lens-corrections.md),
|
||||
[sony-ilce7rm6-lens-corrections.md](sony-ilce7rm6-lens-corrections.md), [nikon-lens-corrections.md](nikon-lens-corrections.md),
|
||||
[camera-preview-colour.md](camera-preview-colour.md), [denoise.md](denoise.md).
|
||||
|
||||
## Headline
|
||||
@@ -219,7 +220,7 @@ essentials user (the default look is what they keep).
|
||||
| Item | Adobe | LightCraft |
|
||||
|---|---|---|
|
||||
| Profile database | 3,635 profile files, 56 makers, auto-selected | **0** (Adobe LCPs never used; no database of our own) |
|
||||
| In-file corrections | ✅ | DNG opcodes (WarpRectilinear, FixVignetteRadial, GainMap…) applied; Panasonic / Leica RW2 distortion (79 photos, 13 bodies, 11 lenses); Sony tag 0x7037 distortion for the ILCE-7RM4A (2 lenses); Nikon NikonNEFInfo distortion (tag `0xc7d5`, about the sensor-shifted optical centre) for the Z 6II and Z f: F-mount lenses on the FTZ within 0.2 px of the camera JPEG, Z lenses within 0.5–1.5 px except the 24-70's wide end (7 px median; `docs/nikon-lens-corrections.md`) |
|
||||
| In-file corrections | ✅ | DNG opcodes (WarpRectilinear, FixVignetteRadial, GainMap…) applied; Panasonic / Leica RW2 distortion (79 photos, 13 bodies, 11 lenses); Sony tag 0x7037 distortion for the ILCE-7RM4A (2 lenses) and the ILCE-7RM6 (3 lenses, 54 photos; `sony-ilce7rm6-lens-corrections.md`); Nikon NikonNEFInfo distortion (tag `0xc7d5`, about the sensor-shifted optical centre) for the Z 6II and Z f: F-mount lenses on the FTZ within 0.2 px of the camera JPEG, Z lenses within 0.5–1.5 px except the 24-70's wide end (7 px median; `docs/nikon-lens-corrections.md`) |
|
||||
| Vignetting / lateral CA from maker notes | ✅ | ⬜ |
|
||||
| Manual distortion, vignetting, CA, defringe | ✅ | ✅ |
|
||||
|
||||
@@ -284,3 +285,4 @@ Beyond decoding:
|
||||
| 2026-10-11 | minor | EOS R5 Mark II verified (CRX QP-map first literal and limited-length escape, odd-height Bayer planes; 4 CC0 files pinned, QP maps of 33,665 private files): 235 verified, 942 unverified; use-weighted 85% (42% on verified bodies); decoding 77%, RAW readiness unchanged at ~56% |
|
||||
| 2026-10-10 (later) | major | Model-by-model measurement against Adobe's published list (1,446 models: 234 verified, 943 unverified, 233 preview, 36 unsupported); `docs/raw/` data and re-measure script; use-weighted share 84% (40% on verified bodies); variant matrix; colour and lens sections with scores; ranked next cameras; RAW readiness ~56% from written parts |
|
||||
| 2026-10-10 | major | Created: formats, makers, demosaic, colour and lens checklists; target counts from the installed Lightroom 9.6 bundle listing |
|
||||
| 2026-10-10 (later) | minor | Lens profiles: Sony ILCE-7RM6 in-file distortion next to the ILCE-7RM4A's (its lateral CA parsed, off by default); lens score unchanged (~10%: one more body) |
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
# Sony ARW lens corrections
|
||||
|
||||
Sony cameras record three lens-correction tables in the raw IFD of every ARW: distortion, vignetting and lateral
|
||||
chromatic aberration (CA). LightCraft reads them and turns distortion into a DNG `WarpRectilinear` opcode, so it
|
||||
reaches the same **Optics ▸ Enable Lens Corrections** (`optics.lens_profile`) as a DNG's embedded warp and Panasonic's
|
||||
RW2 distortion data. Lateral CA is parsed and converts to the warp's red and blue planes, but it is **off by default**:
|
||||
applied after demosaic it made the render's CA worse (Verification). Vignetting is read but not applied (below). The
|
||||
conversion is verified on the **ILCE-7RM6** only; the ILCE-7RM4A has its own conversion, with other conventions
|
||||
([Two conventions](#two-conventions)), and other Sony bodies' tables give no correction until they are verified.
|
||||
|
||||
The correction is on by default for new imports, as for any file with embedded lens data (`Photo::camera_defaults`).
|
||||
Photos imported before this change keep their settings: **Photo → Reload from Disk** adds the lens data and, when the
|
||||
photo is still at its import settings, turns the correction on as one undoable step; the automatic reload after an
|
||||
external editor saves the file does the same. Edited photos keep their settings. The Profile Distortion slider
|
||||
scales the warp as for DNG and RW2.
|
||||
|
||||
## The tables
|
||||
|
||||
ExifTool's names (EXIF table, `SubIFD` group); each table is a count-prefixed array of signed 16-bit values
|
||||
(`SSHORT`, 17 / 17 / 33 values), and each has an in-camera on/off tag:
|
||||
|
||||
| Table | Tag | Flag tag | Count (full frame / APS-C crop mode) | Flag values seen |
|
||||
|---|---|---|---|---|
|
||||
| Distortion (`DistortionCorrParams`) | `0x7037` | `0x7036` | 16 / 11 | 0 (off) or 1 (on) |
|
||||
| Vignetting (`VignettingCorrParams`) | `0x7032` | `0x7031` | 16 / 11 | 2 on all 54 photos |
|
||||
| Lateral CA (`ChromaticAberrationCorrParams`) | `0x7035` | `0x7034` | 32 / 22 (red, then blue) | 1 on all 54 photos |
|
||||
|
||||
The first value is the knot count; only the first `count` values are used. Full-frame files carry 16 / 16 / 32 knots;
|
||||
APS-C crop-mode files 11 / 11 / 22, padded to the same array length (distortion with its last value repeated,
|
||||
vignetting and CA with zeros). The CA table holds red's `count / 2` knots, then blue's. The corrections are built
|
||||
whatever the flags say: the flag only records what the camera did to its own JPEG. Tables with a count outside
|
||||
2–16 (CA: an odd count or outside 4–32), a count that doesn't fit the array, or non-integer values are ignored.
|
||||
|
||||
## Measured conventions
|
||||
|
||||
None of this is documented; it was measured against the camera's own JPEGs (method below). Residuals are in px at a
|
||||
1440 px long edge.
|
||||
|
||||
- **Knots and centre.** Knot `i` sits at sensor radius `i · s` from the default-crop centre, with
|
||||
`s = hypot(9984, 6656) / 30` = 399.98 px: the full-frame default crop's half diagonal divided by 15, so knot 15 is
|
||||
the full-frame crop corner. The spacing is fixed on the sensor: APS-C files use the same pixel spacing (verified on
|
||||
CA, rms 0.004–0.010 px against 0.011–0.014 px for `i/15` of the APS-C half diagonal; `i/10` of it, 392 px, fits as
|
||||
well). Values are interpolated linearly between
|
||||
knots; beyond the last knot the last segment is extended (it matters only in the outermost ~0.4 knot spacing at
|
||||
the corners of a zoomed-out barrel correction). The centre is the default-crop centre: the measured translation
|
||||
between our render and the camera JPEG has median 0.013 px, max 0.216 px.
|
||||
- **Distortion** is relative and indexed at the corrected radius, in units of 1/15000: a corrected radius `r`
|
||||
reads the source radius `S(r) = r · (1 + v(r) / 15000)` (positive values: pincushion; negative: barrel). The camera
|
||||
then zooms so the output fills the default crop: output radius = `z` · corrected radius, with `z` chosen so that
|
||||
the largest `S(ρ / z) / ρ` over the crop's border radii `ρ` is 1 (binding at the corner for pincushion, at the
|
||||
short-edge midpoint for barrel). `z` was 0.971–1.035 on these photos. Without the zoom every hypothesis fails
|
||||
(24.6 px); with it, the worst residual over the 34 measurable corrected photos was 0.596 px (rms 0.143; corners and
|
||||
edge midpoints 0.570 px), within the 0.75 px budget for a unit between about 14700 and 15350. A global
|
||||
least-squares fit gives 15000.7.
|
||||
- **Lateral CA** is relative to the green source radius, at that radius: red's source radius is
|
||||
`g · (1 + c_R(g) / 3.36·10⁶)`, with `g` the green source radius (after the distortion correction above) and `c_R`
|
||||
the red half on the same knots; blue likewise with the second half. Positive values: that colour's image is larger
|
||||
than green's. **The unit is provisional.** The measurement is borderline: on the 15 photos with CA above 0.15 px
|
||||
that a single unit explains, the best (minimax) unit 2.26·10⁶ leaves 0.305 px against a 0.3 px budget; per-photo
|
||||
best units scatter from 1.5·10⁶ to 7.2·10⁶ (about ±30 % scene bias: the RawDB landscape and portrait shots of one
|
||||
scene, with nearly identical tables, measure 3.4–3.5·10⁶ and 4.5·10⁶). The chosen 3.36·10⁶ is the median of the 20
|
||||
cleanest photos: worst 0.399 px on the 15, and the rms raw CA over all 46 measured photos falls from 0.079 to
|
||||
0.052 px. Swapping the halves or an absolute form (`v · norm / U`) is clearly worse. **Not applied by default**
|
||||
(Verification).
|
||||
- **Vignetting: not applied.** The camera JPEGs show no vignetting compensation relative to our uncorrected render:
|
||||
over the 39 photos whose measurement reaches the corners, the corner/centre ratio JPEG / render has median 0.996
|
||||
(p10 0.907, p90 1.087), while the tables would predict a corner gain with median +32 % for `1 + v / 2¹⁴` (+16 %
|
||||
for `v / 2¹⁵`, +63 % for `v / 2¹³`). Regressing the measured corner EV on the predicted one gives slope 0.009 ±
|
||||
0.063. No model (`1 + v/U`, `U/(U − v)`, `2^(v/U)`) at any unit from 2¹³ to 2¹⁸ reaches 3 %; the identity is the
|
||||
best of all. The raw data isn't pre-compensated either: the read noise of three dark high-ISO frames is flat from
|
||||
centre to corner, where a digital gain from these tables would raise it by 60–80 %. So the table probably describes
|
||||
the lens's natural falloff, but this camera's JPEGs don't apply it, and nothing shows its unit or model. Options:
|
||||
(a) skip it, so renders match the camera JPEG (the current ruling); (b) apply it with an unverified model for a
|
||||
Lightroom-like look; (c) shoot flat fields (a white wall at several apertures, one lens) and measure the model.
|
||||
|
||||
## Conversion to `WarpRectilinear`
|
||||
|
||||
`crates/raw/src/vendor/arw_lens_rm6.rs` builds one `WarpRectilinear` in `OpcodeList3`, centred on the default-crop centre
|
||||
(DNG pixel-index units relative to the active area; radius 1 = the farthest active-area corner, `m`):
|
||||
|
||||
1. `S` from the distortion table and the zoom `z` (bisection on [0.5, 2], 4097 border radii); the green map is
|
||||
`G(r) = S(r / z)`. Red and blue, when the CA planes are on: `G(r) · (1 + c(G(r)) / 3.36·10⁶)`.
|
||||
2. Each map is sampled at `u = i / 64`, i = 0…64 (65 samples of `G(u · m) / m`) and fitted by least squares with
|
||||
DNG's 4-term odd polynomial `u · (k0 + k1 u² + k2 u⁴ + k3 u⁶)` (`vendor::fit_radial_warp`, shared with RW2).
|
||||
3. **Fit gate:** a plane is refused when its fit is worse than 0.25 px at a 1440 px long edge at a sample the crop
|
||||
shows: the samples up to the first one at or beyond the crop corner. The lossless files' padded active area
|
||||
(10240 × 7168 for a 9984 × 6656 crop) reaches radii that are never shown (`u` up to 0.925 is shown there).
|
||||
Over all 54 photos the fit's worst error at all 65 samples was 0.220 px (a lossless file; ARW6 files at most
|
||||
0.175 px). Between samples the error is somewhat larger: 0.071 against 0.049 px for the full-frame 16–35 mm
|
||||
example in the tests.
|
||||
4. **By default one plane, G**, applied to all three channels: `lens_opcodes` calls `warp_opcode(…, lateral_ca:
|
||||
false)`. With `lateral_ca: true` (a code change) the planes are `[R, G, B]`, unless the CA table is absent or
|
||||
malformed, a CA value is implausible (|c| above 2·10⁻³ of the radius, i.e. 6720; the largest value in the 54
|
||||
photos' tables is 1724) or a colour plane fails the gate: then G alone again. The G plane is the same either way.
|
||||
No opcode when the distortion table is absent or all zero (CA alone is never applied: it is relative to green),
|
||||
the zoom has no solution in [0.5, 2] or the G fit fails. A refused table leaves the photo uncorrected rather than
|
||||
wrongly warped.
|
||||
|
||||
The opcode then goes through the same pipeline stage as a DNG's (`crates/pipeline/src/optics.rs`): it rotates with
|
||||
the photo (portrait files are covered) and is carried by DNG export, as RW2's is.
|
||||
|
||||
## Which bodies get it
|
||||
|
||||
Only bodies listed in `FULL_FRAME_CROP` (`arw_lens_rm6.rs`) get an opcode: today `ILCE-7RM6` (9984 × 6656). The unit and
|
||||
the knot spacing are verified on that body alone; the spacing is defined by its full-frame crop, and a body with
|
||||
another sensor size or another unit would be warped wrongly. Other bodies get no correction from this conversion; the
|
||||
ILCE-7RM4A gets its own, with other conventions (below). To add a body: collect photos of it shot with distortion
|
||||
correction on (several lenses and focal lengths), repeat the camera-JPEG registration below against the uncorrected
|
||||
render, confirm the unit, the knot spacing and the zoom rule (or measure the body's own), then add its full-frame
|
||||
default crop to the table with a test.
|
||||
|
||||
## Two conventions
|
||||
|
||||
A second Sony decoder, `crates/raw/src/vendor/arw_lens.rs`
|
||||
([Sony embedded distortion corrections](sony-lens-corrections.md), PR #498), reads the same `0x7037` table on the
|
||||
**ILCE-7RM4A**, validated against Sony Imaging Edge exports (distortion On/Off pairs at 200 and 600 mm with the
|
||||
FE 200–600 mm; corrected TIFFs at 24 mm with the FE 24–105 mm). Its conventions differ from the ones above
|
||||
(`r_o` = output radius about the crop centre, `v` = the table, linear between knots):
|
||||
|
||||
| | ILCE-7RM6 (this page, `arw_lens_rm6.rs`) | ILCE-7RM4A (`arw_lens.rs`) |
|
||||
|---|---|---|
|
||||
| Knots | `i · 399.98` px, fixed on the sensor; last segment extended | `i / 15` of the crop's half diagonal; radius clamped to 1 |
|
||||
| Table indexed at | the corrected radius `r_o / z` | the output radius `r_o` |
|
||||
| Unit | 15000 | 16384 |
|
||||
| Zoom | fill: the largest `S(ρ / z) / ρ` over the border is 1 | `1 / max f` over the border radii |
|
||||
| Aspect crops | applied; the zoom for the recorded crop is unverified | refused (native 3:2 only) |
|
||||
| Tables | distortion (16 knots; 11 in APS-C); lateral CA parsed, off by default; vignetting read, not applied | distortion, 16 entries only |
|
||||
|
||||
`arw::decode` passes both the same active area and crop (the DNG-style default crop, on the ILCE-7RM6 the same as
|
||||
Sony's crop tags); their model lists don't overlap, so a photo gets at most one of them: an ILCE-7RM6 this page's
|
||||
conversion, an ILCE-7RM4A the other, every other body neither.
|
||||
|
||||
**Cross-check.** The ILCE-7RM4A formula, scored on the 34 measurable corrected ILCE-7RM6 photos exactly as in
|
||||
Measured conventions (radial bins, px at 1440, max / rms; "c" = the worst corner or edge-midpoint point):
|
||||
|
||||
| Variant | Unit | All photos | Corners max / median | 24–70 GM II (23) | 100 mm Macro (8) | 200–600 G (3) |
|
||||
|---|---|---|---|---|---|---|
|
||||
| **A**: this page | 15000 | **0.596 / 0.143** | 0.570 / 0.099 | 0.596 / 0.100, c 0.474 | 0.361 / 0.135, c 0.336 | 0.588 / 0.325, c 0.570 |
|
||||
| **B1**: the ILCE-7RM4A formula | 16384 | **1.429 / 0.319** | 1.913 / 0.204 | 1.429 / 0.385, c 1.913 | 0.289 / 0.049, c 0.234 | 0.264 / 0.115, c 0.320 |
|
||||
| **B2**: B1 at its best single unit | 15680 | 0.940 / 0.223 | 1.202 / 0.098 | 0.940 / 0.215, c 1.202 | 0.327 / 0.100, c 0.278 | 0.683 / 0.428, c 0.637 |
|
||||
| **B3**: A's indexing and zoom at 16384 | 16384 | 1.450 / 0.455 | 1.845 / 0.350 | 1.450 / 0.539, c 1.845 | 0.284 / 0.062, c 0.238 | 0.654 / 0.329, c 0.586 |
|
||||
|
||||
- B1 misses the 0.75 px budget on these photos, but only on the 24–70 GM II: at 24–28 mm it under-corrects the barrel
|
||||
in the outer half (up to 1.43 px at 0.7–0.85 of the half diagonal), at 45–70 mm the pincushion by a nearly uniform
|
||||
0.2–0.4 px. On the 100 mm and the 200–600 it fits better than A, and on the 200–600, the one lens both validations
|
||||
share, its unit agrees with this body's (best unit under its indexing 16524–16602). So the data does not show the
|
||||
two bodies differing.
|
||||
- The zoom rules agree (within 0.0035 on every photo); the unit and the table index make the difference. Under A's
|
||||
indexing the 24–70 GM II's barrel and pincushion ends want the same unit (14749 / 14860, 0.7 % apart); under B's
|
||||
they need 14792 / 15705 (6 % apart).
|
||||
- APS-C: B refuses 11-entry tables. Its knots read literally on the APS-C crop (`i / 15` of its half diagonal,
|
||||
261.5 px) fit the APS-C RawDB files' lateral CA 4 × worse on 3 of 6 photos than the sensor-fixed 400 px; `i / 10`
|
||||
(392 px) can't be told from 400 px.
|
||||
|
||||
**Open question: no single unit fits every lens under either convention.** The 200–600 wants a 5–7 % larger unit (a
|
||||
weaker correction) than the 24–70 GM II's pincushion end with similar table values (+409 … +561 against +406 … +454
|
||||
at the corner), under both indexings and every knot spacing tried (0.94–1.12 ×: the ratio of the two lenses' best
|
||||
units stays 1.053–1.073). A's 15000 is the compromise that keeps every photo within 0.6 px; 16384 is the 200–600's
|
||||
value. That rests on 3 measurable 200–600 photos (594–600 mm). The ILCE-7RM4A's own wide-angle evidence is its
|
||||
weakest: its On/Off pairs are all 200–600, and its 24 mm check (LightCraft export against Sony's TIFF) is median 0.35 /
|
||||
95th percentile 1.04 px at 2376 px. On its `WIDE` unit-test table the two formulas differ by up to 2.49 px at 1440 at
|
||||
the corner (computed from the formulas, not measured).
|
||||
|
||||
**What would settle it:** Sony Imaging Edge On/Off exports of the 24–70 GM II (or any wide zoom's wide end) on either
|
||||
body, which would show whether the wide-end disagreement is the body or the formula; and more lenses, and more
|
||||
200–600 photos, on the ILCE-7RM6, to tell a per-lens unit from a convention. Until then each body keeps the
|
||||
conventions it was validated with.
|
||||
|
||||
## Method
|
||||
|
||||
Photos: 54 ILCE-7RM6 ARWs: 42 private, and the 12 CC0 samples of the
|
||||
[dnglab RawDB ILCE-7RM6 set](https://rawdb.dnglab.org/sets/Sony/ILCE-7RM6) (`ILCE-7RM6_{FF,APSC}_{land,port}_RAW{,-C,-C-HD}.ARW`:
|
||||
full frame and APS-C, landscape and portrait, lossless, Compressed and Compressed HQ). `cargo xtask corpus --download`
|
||||
fetches three of them: the APS-C lossless file as `arw-sony-ilce7rm6-apsc-land-lossless.arw`, and the ARW 6.0
|
||||
decoder's full-frame Compressed and APS-C Compressed HQ files (`arw6-sony-ilce7rm6-ff-land-c.arw`,
|
||||
`arw6-sony-ilce7rm6-apsc-land-hq.arw`). The corpus test `corpus_sony_lens_tables_become_opcodes` checks all three, and
|
||||
the full-frame lossless file where it is present.
|
||||
Four lenses: FE 24–70 mm F2.8 GM II (27 photos), FE 16–35 mm F2.8 GM II (12, all RawDB), FE 100 mm F2.8 Macro GM OSS
|
||||
(9), FE 200–600 mm F5.6–6.3 G OSS (6). 41 photos have in-camera distortion correction on (all private); 13 have it
|
||||
off (the 12 RawDB files and one private photo). Only aggregate numbers per lens are reported. The scripts are local
|
||||
(the gitignored `plan/arw6/lens/`), not in the repository; the steps are described so they can be redone.
|
||||
|
||||
- **Oracle:** the camera JPEG inside each ARW (`JpgFromRaw`, 9984 × 6656 / 6528 × 4352, stored unrotated, turned to
|
||||
the photo's orientation). Tag values were read with ExifTool used as a tool. Clean room: no lens-correction code of
|
||||
any other project and no Adobe data was read.
|
||||
- **Distortion (camera-JPEG block registration).** Our render at half the JPEG size and the JPEG (2 × 2 box-reduced)
|
||||
are both reduced to a quarter of the JPEG size; on the green channel, every 40 px a 64 px JPEG block is located in
|
||||
the render by normalised cross-correlation with parabolic sub-pixel refinement. Pass 1 searches coarse-to-fine
|
||||
(±28 px at 1/16 size, then ±6 px); pass 2 (used for every number) searches ±6 px around pass 1's smooth radial fit
|
||||
(data-derived, needed for a macro of a periodic screen). Blocks with low contrast (std < 2), NCC < 0.8 or a
|
||||
tangential displacement above 4 px are dropped. A robust fit of `p_render = g(r) · p_jpeg + t` (`g` an even
|
||||
4-term polynomial) gives the translation `t`; the measured radius ratio is the median of 32 radial bins (≥ 10
|
||||
blocks each). A photo is measurable with ≥ 300 blocks and ≥ 15 bins reaching 0.7 of the half diagonal: 34 of the
|
||||
41 corrected photos (two black frames, a near-black night frame, two low-detail frames and two whose blocks reach
|
||||
only 0.54 of the half diagonal are not). 108 hypotheses (radius norm, knot placement, relative or absolute, table
|
||||
indexed at the corrected or the source radius, zoom rule), each with its best unit, were compared.
|
||||
- **Lateral CA (raw CFA).** The demosaiced render shows only 25–60 % of the lens's CA and the JPEG's 4:2:2 chroma
|
||||
and noise reduction hide it, so CA was measured on the raw: from LightCraft's uncompressed CFA DNG export, the
|
||||
red (blue) plane against green interpolated at the same sites (4-neighbour mean), square-root encoded, 2 × 2
|
||||
binned; 40 px blocks every 24 px, ±3 px NCC search, radial component, median per 16 radial bins.
|
||||
- **Vignetting (exposure brackets).** The JPEG is resampled into the render's geometry; per pixel and channel, the
|
||||
exposure at which eight renders bracketed −0.3 … +1.8 EV equal the JPEG value is interpolated (smooth areas, JPEG
|
||||
values 12–240, monotone brackets). That cancels the fitted tone curve, since exposure is a scene-linear gain before
|
||||
it. Medians in 16 annuli give the JPEG's falloff relative to our uncorrected render.
|
||||
|
||||
## Verification
|
||||
|
||||
Every photo rendered with Enable Lens Corrections on and off at half the JPEG size (50 of the 54 are Compressed or
|
||||
Compressed HQ ARW 6.0 files, so the renders used a local build of this code merged with the ARW6 decoder), then the same block
|
||||
registration (passes 1 and 2) against the camera JPEG, and R and B against G inside each render (80 px blocks every
|
||||
128 full-resolution px, ±4 px search; per corner the median radial shift of the 12 nearest blocks). px at a 1440 px
|
||||
long edge; aggregates only.
|
||||
|
||||
**Distortion, photos corrected in camera** (our render with the correction on vs the camera JPEG; budget 0.75 px).
|
||||
"Corners + midpoints" is the 2-D displacement at the four corners and four edge midpoints, global translation
|
||||
included; "bins" the radial residual over 32 radial bins. A photo counts as measurable as in the method above.
|
||||
|
||||
| Lens | Photos (measurable) | Focal lengths | Corners + midpoints max / rms | Bins max / rms | Uncorrected, corners (per-photo max) |
|
||||
|---|---|---|---|---|---|
|
||||
| FE 24–70 mm F2.8 GM II | 26 (23) | 24–70 mm | 0.553 / 0.125 | 0.503 / 0.066 | 1.1–17.3 |
|
||||
| FE 100 mm F2.8 Macro GM OSS | 9 (7) | 100 mm | 0.409 / 0.111 | 0.317 / 0.097 | 0.6–4.8 |
|
||||
| FE 200–600 mm F5.6–6.3 G OSS | 6 (3) | 594–600 mm | 0.679 / 0.321 | 0.498 / 0.274 | 7.5–10.8 |
|
||||
| **All** | **41 (33)** | | **0.679** / 0.150 (206 points) | 0.503 / 0.109 | 0.6–17.3 |
|
||||
|
||||
No photo is over budget; the global translation is at most 0.056 px. The 8 not measurable are black, near-black or
|
||||
low-detail frames, or frames whose blocks don't reach 0.7 of the half diagonal. That is one more than in the
|
||||
measurement (33 measurable against 34): one 100 mm photo keeps only 255 of the 300 blocks needed when it is matched on
|
||||
the corrected render, as block coverage differs slightly between the two renders. The 200–600 mm keeps the systematic
|
||||
residual the measurement found for it (its own best unit is 15659, see Limits).
|
||||
|
||||
**Controls, correction off in camera** (13 photos: the 12 RawDB files, full frame and APS-C, and one private 24–70 mm
|
||||
at 58 mm). Their JPEG keeps the lens's distortion, so our corrected render must differ from it by the model's own shift:
|
||||
it does, by up to 10.5 px (full frame) and 11.4 px (APS-C), and matches that shift within 0.639 px at the corners and
|
||||
midpoints (bins 0.259 px; full-frame RawDB files ≤ 0.218 px, APS-C ≤ 0.639 px). This checks that the pipeline renders
|
||||
the model (also for APS-C, barrel, zoom 0.984), not that the APS-C model is right.
|
||||
|
||||
**Portrait photos:** 8 corrected (orientation 6 and 8) within 0.232 px, 6 controls within 0.369 px of the model's
|
||||
shift: the warp turns with the photo.
|
||||
|
||||
**Lateral CA in the render** (radial R-vs-G and B-vs-G shift, median of the 12 blocks nearest each corner; 43 photos
|
||||
with enough detail at all four corners; budget 0.3 px):
|
||||
|
||||
| Lens | Photos | Off: max R / B | Off: rms | On: max R / B | On: rms | Camera JPEG: max / rms |
|
||||
|---|---|---|---|---|---|---|
|
||||
| FE 24–70 mm F2.8 GM II | 23 | 0.089 / 0.071 | 0.028 | 0.219 / 0.148 | 0.069 | 0.150 / 0.036 |
|
||||
| FE 16–35 mm F2.8 GM II | 12 | 0.125 / 0.054 | 0.041 | 0.030 / 0.051 | 0.020 | 0.058 / 0.016 |
|
||||
| FE 100 mm F2.8 Macro GM OSS | 6 | 0.020 / 0.050 | 0.013 | 0.061 / 0.157 | 0.044 | 0.021 / 0.004 |
|
||||
| FE 200–600 mm F5.6–6.3 G OSS | 2 | 0.033 / 0.101 | 0.032 | 0.146 / 0.075 | 0.070 | 0.113 / 0.039 |
|
||||
| **All** | **43** | 0.125 / 0.101 | 0.032 | **0.219** / 0.157 | 0.052 | 0.150 / 0.028 |
|
||||
|
||||
Within budget everywhere, but **the correction makes the render's own CA larger on average** (rms 0.032 → 0.052 px;
|
||||
smaller only for the 16–35 mm). The reason is where it runs: over the radial bins beyond 0.3 of the half diagonal
|
||||
(418 bins), our demosaiced render shows only 0.29 × the lens CA measured on the raw CFA (correlation 0.85), while the
|
||||
warp after demosaic moves R and B by 0.84 × the table (correlation 0.96). On the raw data the table halves the CA (rms
|
||||
0.062 → 0.035 px); applied after a demosaic that already hid most of it, it overshoots (rms 0.021 → 0.035 px over the
|
||||
same bins). All of this is far below one pixel at 1440 px.
|
||||
|
||||
**Ruling: the lateral CA planes are off by default.** `lens_opcodes` emits the G plane alone; the conversion stays in
|
||||
the code and its unit tests (`warp_opcode(…, lateral_ca: true)`), so it can be turned on once CA is corrected where
|
||||
the table applies. **Follow-up:** warp the raw CFA's red and blue sites before demosaic (the table halves the raw CA,
|
||||
rms 0.062 → 0.035 px), then turn the planes on and repeat this check. Optics ▸ Remove Chromatic Aberration
|
||||
(`optics.remove_ca`, an automatic estimate from the image) remains available for visible fringes. The distortion
|
||||
numbers above were measured with the three-plane warp, whose G plane is the one the default emits.
|
||||
|
||||
**Fit gate margins** (worst of R, G and B at the gated samples; gate 0.25 px): the 4 lossless files 0.160 px (margin
|
||||
0.090; 0.220 px at all 65 samples, the padded radii never shown), the 46 full-frame ARW6 files 0.175 px (margin 0.075),
|
||||
the 4 APS-C ARW6 files 0.035 px. The largest CA value in the 54 tables is 1724 (FE 24–70 mm), a quarter of the 6720
|
||||
bound. So every photo passes: with the CA planes on (the verification renders) each got the three-plane warp, and the
|
||||
corrected and uncorrected renders differ on all 54; by default each gets the one-plane distortion warp.
|
||||
|
||||
## Performance
|
||||
|
||||
`LIGHTCRAFT_PROFILE=1` renders of the full-frame lossless file and the full-frame Compressed HQ file (corpus
|
||||
`arw-sony-ilce7rm6-ff-land-lossless.arw`, `arw6-sony-ilce7rm6-ff-land-hq.arw`), on a 4-core laptop shared with other
|
||||
builds (so absolute times are high); the fastest of 3–5 runs. The warp runs in the `sample` stage:
|
||||
|
||||
| Render | Off | One plane (the default; measured with manual Distortion 1) | Three planes (CA planes on) |
|
||||
|---|---|---|---|
|
||||
| Full size 9984 × 6656, lossless | 267 ms | — | 3098 ms |
|
||||
| Full size 9984 × 6656, Compressed HQ | 403 ms | 1187 ms | 4014 ms |
|
||||
| 1920 px long edge (2496 × 1664 source), Compressed HQ | 23.7 ms | 45.4 ms | 112.0 ms |
|
||||
|
||||
The default one-plane warp takes the sample stage from 24 to 45 ms at 1920 px (inferred from the manual Distortion
|
||||
run: the same single-plane path, one plane for all three channels). The three colour planes cost about 2.5–3× that,
|
||||
as for an RW2 or DNG with three planes (the same path): the price of turning the CA planes on. The stage cache keys
|
||||
the resampled source by the source, framing and output size, so tone, colour and exposure drags don't re-run it.
|
||||
|
||||
## Limits
|
||||
|
||||
- **ILCE-7RM6 only.** Other Sony bodies record the same tags; their knot spacing (defined here by the full-frame crop),
|
||||
unit and zoom rule are unverified, so they get no correction from this conversion (the ILCE-7RM4A's distortion has
|
||||
its own: [Two conventions](#two-conventions)).
|
||||
- **APS-C distortion is unverified in camera:** no APS-C photo with distortion correction on was available; the APS-C
|
||||
knot spacing is verified on CA only, and the APS-C zoom follows the full-frame rule.
|
||||
- **Aspect-ratio crops** (in-camera 4:3, 16:9, 1:1) were not measured; the correction uses the crop the file records.
|
||||
- **One distortion unit for every lens.** Fitted per lens the unit is 14826 (24–70 mm, 23 photos, very consistent),
|
||||
15659 (200–600 mm, 3 photos) and 16017 (100 mm, tiny tables); with the single 15000 the 200–600 mm keeps a systematic
|
||||
residual of up to 0.59 px (within budget, above the method's 0.03 px noise floor). Something lens-dependent is not
|
||||
captured; per-lens units would need per-lens verification.
|
||||
- **Lateral CA off by default.** Its unit is provisional (above), and a warp after demosaic, as for RW2 and DNG,
|
||||
overshoots: our demosaic already hides about 70 % of the lens CA, and with the planes on the rms corner CA rose
|
||||
from 0.032 to 0.052 px (Verification). Correcting CA before demosaic is the follow-up.
|
||||
- **Vignetting not applied** (above).
|
||||
- **DNG export** carries the warp in `OpcodeList3`, as for RW2: a DNG reader applies it like any embedded lens
|
||||
correction.
|
||||
- **Existing imports** need Photo → Reload from Disk to pick up the lens data.
|
||||
- The camera-JPEG colour fit still matches its pairs without the correction (docs/camera-preview-colour.md).
|
||||
|
||||
## Sources
|
||||
|
||||
- [ExifTool EXIF tag names](https://exiftool.org/TagNames/EXIF.html): the names and types of tags `0x7031`–`0x7037`
|
||||
(ExifTool 13.59 was used to read the tag values). Their meaning, units and conventions were measured as above.
|
||||
- [dnglab RawDB, Sony ILCE-7RM6](https://rawdb.dnglab.org/sets/Sony/ILCE-7RM6): the 12 CC0 samples (data only;
|
||||
dnglab's code was not consulted).
|
||||
@@ -1,5 +1,10 @@
|
||||
# Sony embedded distortion corrections
|
||||
|
||||
The **ILCE-7RM6** (A7R VI) has its own conversion, with other conventions measured against its camera JPEGs:
|
||||
[Sony ARW lens corrections](sony-ilce7rm6-lens-corrections.md). On ILCE-7RM6 photos the formula below misses the 24–70 GM II by
|
||||
up to 1.43 px at 1440 px while fitting the 100 mm and 200–600 better, and no single unit fits every lens under either
|
||||
convention; that page's *Two conventions* section has the comparison and what would settle it.
|
||||
|
||||
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.
|
||||
|
||||
@@ -218,6 +218,18 @@
|
||||
- Compare and Reference View panes say "Rendering…" (or that the photo can't be opened) the same way, so a
|
||||
magnified preview isn't taken for the photo when checking focus at 1:1.
|
||||
|
||||
### Lens corrections
|
||||
- Sony A7R VI (ILCE-7RM6) raws are corrected for lens distortion from the table the camera records in each file,
|
||||
under Optics ▸ Enable Lens Corrections, on by default for newly imported photos. On 33 photos shot with the camera's
|
||||
correction on (24–70 mm, 100 mm macro, 200–600 mm) the render lines up with the camera's JPEG within 0.68 px at
|
||||
1440 px at the corners, where it was up to 17 px off before. The camera's lateral chromatic aberration table is read
|
||||
too, but not applied by default: our demosaic already hides most of that colour fringing, and the table on top made
|
||||
it slightly worse. The vignetting table is read but not applied: the camera's own JPEGs don't apply it either. This
|
||||
covers lossless, Compressed and Compressed HQ (ARW 6.0) ARWs alike. The A7R IVA (ILCE-7RM4A) has its own distortion
|
||||
correction, checked against Sony's exports; other Sony bodies record the same tables, but they stay uncorrected until
|
||||
they are checked the same way. Photos imported earlier get the correction with Photo ▸ Reload from Disk (when still
|
||||
at their import settings). See `docs/sony-ilce7rm6-lens-corrections.md`.
|
||||
|
||||
### JPEG XL export
|
||||
- Export as JPEG XL (issue #247): Format ▸ JPEG XL in the Export dialog, `format: "jxl"` for agents and the CLI
|
||||
(`render in.raw -o out.jxl`). Quality as for JPEG, or Lossless; Effort (Fast / Normal) trades time for a
|
||||
|
||||
@@ -448,6 +448,13 @@ const RAW_SAMPLES: &[(&str, &str, &str)] = &[
|
||||
"https://raw.pixls.us/data/Sony/DSLR-A900/_DSC7969.ARW",
|
||||
"ac7c1532df77c321e8010aa1be60c1b9f245b1ac0db6f38fe617a73aae81af50",
|
||||
),
|
||||
// Sony ILCE-7RM6 lossless, CC0 1.0 (RawDB): the APS-C file, for its lens tables (`vendor::arw_lens_rm6`). The
|
||||
// full-frame lossless file is not pinned (at most two pins per PR); the two ARW6 pins below cover Compressed files.
|
||||
(
|
||||
"arw-sony-ilce7rm6-apsc-land-lossless.arw",
|
||||
"https://rawdb.dnglab.org/api/download/Sony/ILCE-7RM6/raw_modes/ILCE-7RM6_APSC_land_RAW.ARW",
|
||||
"7aaf0b3941509d7364a6f03844783155ebc912d246d1c456e5cdb4693ec09fbc",
|
||||
),
|
||||
// CR2 colour-filter layouts differ by model (issue #85): CR2CFAPattern 3 (GBRG) and 1 (RGGB) samples
|
||||
(
|
||||
"cr2-canon-40d.cr2",
|
||||
|
||||
Reference in New Issue
Block a user