Merge remote-tracking branch 'origin/main' into pr-3144

# Conflicts:
#	docs/raw-parity.md
This commit is contained in:
FredApps
2026-11-04 12:47:08 +01:00
5 changed files with 108 additions and 27 deletions
+2 -1
View File
@@ -790,7 +790,8 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
let rm6: Vec<crate::Opcode> = if linear_rgb || !super::arw_lens::is_3_2(crop) {
Vec::new()
} else {
super::arw_lens_rm6::lens_opcodes(raw, &model, Rect::new(0, 0, w, h), reference)
let lens = metadata.lens_model.as_deref();
super::arw_lens_rm6::lens_opcodes(raw, &model, lens, metadata.focal_length, Rect::new(0, 0, w, h), reference)
};
if !rm6.is_empty()
&& let Some(record) = sony_lens.as_mut()
+78 -18
View File
@@ -62,6 +62,32 @@ const FULL_FRAME_CROP: &[(&str, (f64, f64))] = &[("ILCE-7RM6", (9984.0, 6656.0))
/// The verified APS-C bodies (Exif `Model`): their knots span the file's own default crop, the last knot at its corner.
/// Measured on the ILCE-6700 (docs/sony-ilce7rm6-lens-corrections.md → ILCE-6700).
const APSC_BODIES: &[&str] = &["ILCE-6700"];
/// A lens whose tables follow its body's convention with another knot spacing and unit, at the focal lengths up to
/// `max_focal_mm`; measured like the body, against the camera JPEG.
struct LensException {
/// Exif `Model` of the body.
body: &'static str,
/// Exif `LensModel`, as the body records it.
lens: &'static str,
max_focal_mm: f64,
knot_spacing: f64,
unit: f64,
}
/// The measured lens exceptions (docs/sony-ilce7rm6-lens-corrections.md → ILCE-6700).
const LENS_EXCEPTIONS: &[LensException] = &[
// Sigma 10-18 mm F2.8 DC DN at its wide end: 97 photos at 10 mm and 2 at 10.4-10.5 mm. The body's rule leaves
// 1.36-1.38 px there; from 11.7 mm on the body's rule fits better. `lens` is the only LensModel in 336 files of
// this lens (native E-mount, no adapter); the 116 measured copies carry body firmware "ILCE-6700 v2.00"
LensException { body: "ILCE-6700", lens: "10-18mm F2.8 DC DN | Contemporary 023", max_focal_mm: 10.5, knot_spacing: 416.0, unit: 14250.0 },
];
/// The exception for the body `model` with the lens `lens` at `focal_mm`, if one is measured.
fn lens_exception(model: &str, lens: Option<&str>, focal_mm: Option<f64>) -> Option<&'static LensException> {
let (lens, focal) = (lens?.trim(), focal_mm.filter(|f| f.is_finite() && *f > 0.0)?);
LENS_EXCEPTIONS.iter().find(|e| e.body == model.trim() && e.lens == lens && focal <= e.max_focal_mm)
}
/// 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
@@ -179,18 +205,19 @@ fn fill_zoom(source: &dyn Fn(f64) -> f64, x: f64, y: f64) -> Option<f64> {
/// 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> {
gated_warp_opcode(t, knot_spacing, active, crop, lateral_ca, FIT_ERROR_PX_AT_1440)
gated_warp_opcode(t, knot_spacing, DISTORTION_UNIT, active, crop, lateral_ca, FIT_ERROR_PX_AT_1440)
}
/// [`warp_opcode`] with the fit gate `fit_error_px` (px at a 1440 px long edge) instead of 0.25 px.
fn gated_warp_opcode(t: &LensTables, knot_spacing: f64, active: Rect, crop: Rect, lateral_ca: bool, fit_error_px: f64) -> Option<Opcode> {
/// [`warp_opcode`] with the distortion unit `unit` instead of 15000 and the fit gate `fit_error_px` (px at a 1440 px
/// long edge) instead of 0.25 px.
fn gated_warp_opcode(t: &LensTables, knot_spacing: f64, unit: f64, active: Rect, crop: Rect, lateral_ca: bool, fit_error_px: f64) -> 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 source = |r: f64| r * (1.0 + interpolate(v, knot_spacing, r) / 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);
@@ -226,12 +253,16 @@ fn gated_warp_opcode(t: &LensTables, knot_spacing: f64, active: Rect, crop: Rect
/// 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> {
///
/// `lens` (Exif `LensModel`) and `focal_mm` (Exif `FocalLength`) select a measured lens exception's spacing and unit.
pub(crate) fn lens_opcodes(raw: &Ifd, model: &str, lens: Option<&str>, focal_mm: Option<f64>, active: Rect, crop: Rect) -> Vec<Opcode> {
let tables = read_tables(raw);
let knots = tables.distortion.as_ref().map_or(0, Vec::len);
let Some(spacing) = knot_spacing(model, knots, crop) else { return Vec::new() };
let op = if APSC_BODIES.contains(&model.trim()) {
gated_warp_opcode(&tables, spacing, active, crop, false, APSC_FIT_ERROR_PX_AT_1440)
let op = if let Some(e) = lens_exception(model, lens, focal_mm) {
gated_warp_opcode(&tables, e.knot_spacing, e.unit, active, crop, false, APSC_FIT_ERROR_PX_AT_1440)
} else if APSC_BODIES.contains(&model.trim()) {
gated_warp_opcode(&tables, spacing, DISTORTION_UNIT, active, crop, false, APSC_FIT_ERROR_PX_AT_1440)
} else {
warp_opcode(&tables, spacing, active, crop, false)
};
@@ -372,7 +403,7 @@ mod tests {
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}");
assert!(lens_opcodes(&ifd, "ILCE-7RM6", None, None, 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])] {
@@ -406,7 +437,7 @@ mod tests {
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}");
assert!(lens_opcodes(&ifd, "ILCE-7RM6", None, None, FF_ACTIVE, FF_CROP).is_empty(), "{v}");
}
// degenerate geometry
let ff = tables_with_distortion(&values(&FF_DISTORTION));
@@ -455,7 +486,7 @@ mod tests {
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());
assert!(lens_opcodes(&ifd, "ILCE-7RM6", None, None, 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());
@@ -509,7 +540,7 @@ mod tests {
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 ops = lens_opcodes(&ifd, "ILCE-7RM6", None, None, 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);
@@ -578,12 +609,12 @@ mod tests {
let square = Rect { width: A6700_CROP.height, ..A6700_CROP };
assert_eq!(knot_spacing("ILCE-6700", 11, square), None);
let ifd = sony_lens_ifd(&A6700_DISTORTION, &APSC_VIGNETTING, &APSC_CA, [1, 2, 1]);
let ops = lens_opcodes(&ifd, "ILCE-6700", A6700_CROP, A6700_CROP);
let gated = gated_warp_opcode(&read_tables(&ifd), s, A6700_CROP, A6700_CROP, false, APSC_FIT_ERROR_PX_AT_1440);
let ops = lens_opcodes(&ifd, "ILCE-6700", None, None, A6700_CROP, A6700_CROP);
let gated = gated_warp_opcode(&read_tables(&ifd), s, DISTORTION_UNIT, A6700_CROP, A6700_CROP, false, APSC_FIT_ERROR_PX_AT_1440);
assert_eq!(ops, gated.into_iter().collect::<Vec<_>>());
assert_eq!(ops.len(), 1);
// other APS-C bodies stay unverified
assert!(lens_opcodes(&ifd, "ILCE-6600", A6700_CROP, A6700_CROP).is_empty());
assert!(lens_opcodes(&ifd, "ILCE-6600", None, None, A6700_CROP, A6700_CROP).is_empty());
}
/// The APS-C fit gate: a table the polynomial fits within 0.44 px is applied on the ILCE-6700, while the
@@ -594,17 +625,46 @@ mod tests {
let t = read_tables(&ifd);
let s = knot_spacing("ILCE-6700", 11, A6700_DECODED_CROP).unwrap();
assert_eq!(warp_opcode(&t, s, A6700_ACTIVE, A6700_DECODED_CROP, false), None);
let ops = lens_opcodes(&ifd, "ILCE-6700", A6700_ACTIVE, A6700_DECODED_CROP);
let ops = lens_opcodes(&ifd, "ILCE-6700", None, None, A6700_ACTIVE, A6700_DECODED_CROP);
assert!(matches!(ops.as_slice(), [Opcode::WarpRectilinear { planes, .. }] if planes.len() == 1), "{ops:?}");
}
const SIGMA_10_18: &str = "10-18mm F2.8 DC DN | Contemporary 023";
/// The Sigma 10-18 mm exception: its spacing and unit at 10-10.5 mm, the body's rule from 11.7 mm (and without a
/// lens or focal length, on another body or another lens).
#[test]
fn lens_exception_applies_only_to_its_lens_body_and_focal_range() {
let ifd = sony_lens_ifd(&A6700_DISTORTION, &APSC_VIGNETTING, &APSC_CA, [1, 2, 1]);
let t = read_tables(&ifd);
let geometry = (A6700_ACTIVE, A6700_DECODED_CROP);
let exception = gated_warp_opcode(&t, 416.0, 14250.0, geometry.0, geometry.1, false, APSC_FIT_ERROR_PX_AT_1440);
let body = lens_opcodes(&ifd, "ILCE-6700", None, None, geometry.0, geometry.1);
assert!(exception.is_some() && body.len() == 1);
assert_ne!(body.first(), exception.as_ref());
for focal in [10.0, 10.5] {
let ops = lens_opcodes(&ifd, "ILCE-6700", Some(SIGMA_10_18), Some(focal), geometry.0, geometry.1);
assert_eq!(ops.first(), exception.as_ref(), "{focal} mm");
}
for (lens, focal) in [(Some(SIGMA_10_18), Some(11.7)), (Some(SIGMA_10_18), None), (None, Some(10.0)), (Some("E 10-20mm F4 PZ G"), Some(10.0))]
{
let ops = lens_opcodes(&ifd, "ILCE-6700", lens, focal, geometry.0, geometry.1);
assert_eq!(ops, body, "{lens:?} {focal:?}");
}
// hostile focal lengths fall back to the body's rule
for focal in [f64::NAN, f64::INFINITY, -10.0, 0.0] {
assert_eq!(lens_opcodes(&ifd, "ILCE-6700", Some(SIGMA_10_18), Some(focal), geometry.0, geometry.1), body);
}
assert_eq!(lens_exception("ILCE-6600", Some(SIGMA_10_18), Some(10.0)).map(|e| e.unit), None);
}
#[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());
assert!(lens_opcodes(&ifd, "ILCE-7M4", None, None, FF_ACTIVE, FF_CROP).is_empty());
assert!(lens_opcodes(&ifd, "", None, None, 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);
let ops = lens_opcodes(&ifd, " ILCE-7RM6 ", None, None, 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);
}
+1
View File
@@ -90,6 +90,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w
| Date | Change | Summary |
|---|---|---|
| 2026-10-23 | patch | Gap 8: the Sigma 10–18 mm on the ILCE-6700 gets a measured lens exception at 10–10.5 mm (0.55 px worst over 99 photos, was about 1.4 px) |
| 2026-10-29 | minor | Import thumbnails render when the camera JPEG is too small, retaining preview-only/error fallbacks; import review: remembered thumbnail size and density-aware previews (#2237, part of #2056); pre-import loupe/keyboard culling remains open; no ranked gap closes |
| 2026-10-24 | minor | Gap 8: lens profile work is tracked in #329; hours unchanged |
| 2026-10-25 | patch | Import Copy and Move report rename-sequence exhaustion per file without losing earlier transfers; no ranked gap or readiness changes |
+1 -1
View File
@@ -246,7 +246,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) and ILCE-7M3 (3 lenses), full-frame Bayer 3:2 ILCE-7CR files with correction Auto or forced Auto (3 lenses; see sony-lens-corrections.md for residuals and limits) the ILCE-7RM6 (3 lenses, 54 photos) and the APS-C ILCE-6700 (3 lenses, 30 photos; 1.38 px at a 10 mm wide end, 0.73 px elsewhere; `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`) |
| 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 ILCE-7M3 (3 lenses), full-frame Bayer 3:2 ILCE-7CR files with correction Auto or forced Auto (3 lenses; see sony-lens-corrections.md for residuals and limits) the ILCE-7RM6 (3 lenses, 54 photos) and the APS-C ILCE-6700 (3 lenses, 145 photos; 0.73 px worst, with a measured Sigma 10–18 mm exception at 10–10.5 mm; `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 | ✅ | ✅ |
+26 -7
View File
@@ -146,6 +146,24 @@ full-frame 0.25 px gate. Measured against the camera JPEG, the fitted polynomial
0.22–0.73 px against 0.23–0.68 px on the 27 photos, 1.35–1.38 px at 10 mm), so APS-C bodies use a 0.5 px gate
(`APSC_FIT_ERROR_PX_AT_1440`).
**Lens exception: Sigma 10–18 mm F2.8 DC DN at 10–10.5 mm** (`LENS_EXCEPTIONS`, matched on Exif `LensModel`
`10-18mm F2.8 DC DN | Contemporary 023` and `FocalLength` ≤ 10.5 mm). Its wide end keeps 1.36–1.38 px under the
body's rule. On 115 further private photos of this lens (97 at 10 mm, 2 at 10.4–10.5 mm, 16 at 11.7–12.5 mm), a grid
over spacing 372–426 px and unit 14000–16500 was measured on the exact table. The photos at 10 mm were split by
shooting location: 63 to choose the values, 34 to check them. A larger spacing with a smaller unit fits about as well
(a ridge in the grid): 420 px / 14000 was best on the 63 choice photos (0.445 px worst) but put 2 of the 34 check photos
above 0.75 px, so the pick is **416 px / 14250**, within 0.51 px on both sets.
As rendered (`lightcraft-cli render`, Enable Lens Corrections on):
| Sigma 10–18 mm | p95 at 1440 px |
|---|---|
| 10 mm, 97 photos (one night photo has too little detail to measure) | 0.38–0.55 px, median 0.44 (body's rule: about 1.4 px) |
| 10.4–10.5 mm, 2 photos | 0.36 px |
| 11.7–12.5 mm, 16 photos (body's rule, the exception does not apply) | 0.48–0.72 px, median 0.57 |
At 11.7 mm and longer an exception of this kind fits worse than the body's rule (412 px / 14500 on the grid: 0.73–1.19 px against 0.48–0.69 px), so it stops at
10.5 mm, the longest measured focal length where it helps; 10.6–11.6 mm is unmeasured and takes the body's rule.
## Two conventions
A second Sony decoder, `crates/raw/src/vendor/arw_lens.rs`
@@ -327,13 +345,14 @@ the resampled source by the source, framing and output size, so tone, colour and
- **ILCE-7RM6 and ILCE-6700 only.** Other Sony bodies record the same tags; their knot spacing (defined here by the
full-frame crop, or an APS-C body's own 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)).
- **ILCE-6700 at strong barrel distortion:** 1.36–1.38 px at the Sigma 10–18 mm's 10 mm end (above). The two public
raw.pixls.us ILCE-6700 files (one scene, E 16–55 mm F2.8 G at 16 mm, not used for fitting) both carry the same
table (−1829 at the last knot). On the compressed file the correction leaves p95 3.1 px (uncorrected 5.1 px, exact
table 3.4 px); with the same spacing this lens fits at unit 16000 (0.43 px), another lens-dependent unit (below).
On the lossless file the table, on its padded 6656 × 4608 frame, misses the 0.5 px fit gate, so that photo stays
uncorrected (5.3 px). Its vignetting and lateral CA tables are read, not applied: measured in #2801 (the camera JPEG
doesn't compensate vignetting, as on the ILCE-7RM6).
- **ILCE-6700 at strong barrel distortion:** the body's rule leaves 1.36–1.38 px at the Sigma 10–18 mm's 10 mm end;
that lens has a measured exception (above). The two public raw.pixls.us ILCE-6700 files (one scene, E 16–55 mm
F2.8 G at 16 mm, not used for fitting) both carry the same table (−1829 at the last knot). On the compressed file
the correction leaves p95 3.1 px (uncorrected 5.1 px, exact table 3.4 px); with the same spacing this lens fits at
unit 16000 (0.43 px), another lens-dependent unit (below). On the lossless file the table, on its padded
6656 × 4608 frame, misses the 0.5 px fit gate, so that photo stays uncorrected (5.3 px). Other wide lenses with
similarly strong tables are unmeasured. Its vignetting and lateral CA tables are read, not applied: measured in
#2801 (the camera JPEG doesn't compensate vignetting, as on the ILCE-7RM6).
- **APS-C distortion is unverified in camera on the ILCE-7RM6:** 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.