mirror of
https://github.com/storytold/lightcraft.git
synced 2026-11-11 16:31:32 -06:00
Merge PR #1770
# Conflicts: # crates/raw/src/vendor/nef.rs # docs/parity-checklist.md # docs/raw-parity.md # docs/whats-new.md
This commit is contained in:
Vendored
+80
-10
@@ -73,7 +73,8 @@ fn default_crop(mn: Option<&makernote::MakerNote>, w: usize, h: usize) -> Rect {
|
||||
|
||||
/// A body that records no CropArea, measured on its own files: the image area inside the sensor data (where the
|
||||
/// camera's full-size JPEG sits when the two are aligned) and, when the data's own top would be wrong, the level
|
||||
/// where its channels clip.
|
||||
/// where its channels clip. Without that level, the white is measured inside the image area only, so what lies
|
||||
/// beyond it can't pass for saturation.
|
||||
struct MeasuredBody {
|
||||
model: &'static str,
|
||||
/// Sensor data size and sample depth of the measured files.
|
||||
@@ -112,6 +113,18 @@ struct MeasuredBody {
|
||||
/// one of its own, together with models of the same readout (data and JPEG size) that agree on the same frame
|
||||
/// (*siblings*). The D4's 12-bit mode reads out the 14-bit area measured above; the Df, the same readout, agrees on
|
||||
/// (8, 6) in 6 files. Bodies whose crop holds only picture beyond the camera frame have no entry (no change).
|
||||
/// * **NIKON D5000** (issue #1627; 2 CC0 compressed 12-bit NEFs from raw.pixls.us): the D90's sensor, with the
|
||||
/// same 4352 × 2868 data and the same flat ~255 filler from column 4310, which shows as a band on the right. Both
|
||||
/// samples had Auto Distortion Control on, which warps their JPEGs around the centre, so only the centre aligns:
|
||||
/// a 200 px patch there sits where the D90's area puts it, (10, 10), on both. Green tops out at the D90's 3840.
|
||||
/// * **NIKON D600 and D610** (issue #1627; 4 CC0 NEFs of each from raw.pixls.us, 12- and 14-bit, lossless and
|
||||
/// lossy): the data is 6080 × 4028. Columns 6034–6041 are filler at a flat 150 (12-bit) or 601 (14-bit),
|
||||
/// 6042–6047 hold noise up to the top of the range, and only the last 32 are dark, so the masked-column test keeps
|
||||
/// the first 14 and they develop as a bright band on the right. The camera's 6016 × 4016 JPEG sits at (8, 6) in
|
||||
/// all 8 files. The noise columns also read as a saturation plateau: the white measured from the whole data came
|
||||
/// out at 3174–3182 (12-bit) and 12707–12729 (14-bit) on the D600's files while their pictures reach 3450–3731 and
|
||||
/// 14902–16308, so their brightest samples were cut off as clipped. Measured inside the image area it is the
|
||||
/// data's top, or the real plateau where the picture clips (4080 and 16320 on the D610's).
|
||||
const MEASURED_BODIES: &[MeasuredBody] = &[
|
||||
MeasuredBody { model: "NIKON D4", size: (4992, 3292), bits: 14, crop: (8, 6, 4928, 3280), white: Some(15520.0) },
|
||||
MeasuredBody { model: "NIKON D7100", size: (6036, 4020), bits: 14, crop: (18, 10, 6000, 4000), white: None },
|
||||
@@ -163,12 +176,32 @@ const MEASURED_BODIES: &[MeasuredBody] = &[
|
||||
// 6 files; masked: left 2, right 42 (the D4's readout)
|
||||
MeasuredBody { model: "NIKON Df", size: (4992, 3292), bits: 12, crop: (8, 6, 4928, 3280), white: None },
|
||||
MeasuredBody { model: "NIKON Df", size: (4992, 3292), bits: 14, crop: (8, 6, 4928, 3280), white: None },
|
||||
MeasuredBody { model: "NIKON D5000", size: (4352, 2868), bits: 12, crop: (10, 10, 4288, 2848), white: Some(3830.0) },
|
||||
MeasuredBody { model: "NIKON D600", size: (6080, 4028), bits: 12, crop: (8, 6, 6016, 4016), white: None },
|
||||
MeasuredBody { model: "NIKON D600", size: (6080, 4028), bits: 14, crop: (8, 6, 6016, 4016), white: None },
|
||||
MeasuredBody { model: "NIKON D610", size: (6080, 4028), bits: 12, crop: (8, 6, 6016, 4016), white: None },
|
||||
MeasuredBody { model: "NIKON D610", size: (6080, 4028), bits: 14, crop: (8, 6, 6016, 4016), white: None },
|
||||
];
|
||||
|
||||
fn measured_body(model: &str, w: usize, h: usize, bits: u32) -> Option<&'static MeasuredBody> {
|
||||
MEASURED_BODIES.iter().find(|b| b.model == model.trim() && b.size == (w, h) && b.bits == bits)
|
||||
}
|
||||
|
||||
/// The samples of the image area `crop`, every few rows (an odd step, so both rows of the CFA are in), for measuring
|
||||
/// the white level where the picture is.
|
||||
fn area_samples(d: &[u16], w: usize, crop: Rect) -> Vec<u16> {
|
||||
let step = (crop.width.saturating_mul(crop.height) / 2_000_000).max(1) | 1;
|
||||
(crop.y..crop.y.saturating_add(crop.height))
|
||||
.step_by(step)
|
||||
.filter_map(|y| {
|
||||
let at = y.checked_mul(w)?.checked_add(crop.x)?;
|
||||
d.get(at..at.checked_add(crop.width)?)
|
||||
})
|
||||
.flatten()
|
||||
.copied()
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn raw_ifd(tiff: &Tiff) -> Option<&Ifd> {
|
||||
tiff.all_ifds()
|
||||
.into_iter()
|
||||
@@ -252,16 +285,18 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
|
||||
let model = ifd0.string(t::MODEL).unwrap_or_default();
|
||||
let body = measured_body(&model, w, h, bits);
|
||||
let active_w = trailing_masked_columns(samples, w, bits);
|
||||
// the white level is measured from the whole image (or known for a measured body); the probe (no samples in its
|
||||
// result) takes the nominal one
|
||||
let white = match (body.and_then(|b| b.white), mode) {
|
||||
(Some(white), _) => white,
|
||||
(None, Mode::Full) => white_from_data(samples, bits),
|
||||
(None, Mode::Header) => ((1u32 << bits.clamp(1, 16)) - 1) as f32,
|
||||
let measured = match body {
|
||||
Some(&MeasuredBody { crop: (x, y, cw, ch), .. }) if x + cw <= active_w && y + ch <= h => Some(Rect::new(x, y, cw, ch)),
|
||||
_ => None,
|
||||
};
|
||||
let crop = match body {
|
||||
Some(&MeasuredBody { crop: (x, y, cw, ch), .. }) if x + cw <= active_w && y + ch <= h => Rect::new(x, y, cw, ch),
|
||||
_ => default_crop(mn.as_ref(), active_w, h),
|
||||
let crop = measured.unwrap_or_else(|| default_crop(mn.as_ref(), active_w, h));
|
||||
// the white level is measured from the whole image (only its measured area, for a body with one), or known for a
|
||||
// measured body; the probe (no samples in its result) takes the nominal one
|
||||
let white = match (body.and_then(|b| b.white), mode, measured) {
|
||||
(Some(white), _, _) => white,
|
||||
(None, Mode::Full, Some(area)) => white_from_data(&area_samples(samples, w, area), bits),
|
||||
(None, Mode::Full, None) => white_from_data(samples, bits),
|
||||
(None, Mode::Header, _) => ((1u32 << bits.clamp(1, 16)) - 1) as f32,
|
||||
};
|
||||
let mut metadata = lightcraft_meta::from_tiff(&tiff);
|
||||
metadata.width = Some(crop.width as u32);
|
||||
@@ -634,6 +669,41 @@ mod tests {
|
||||
assert_eq!((d90.metadata.width, d90.metadata.height), (Some(4288), Some(2848)));
|
||||
}
|
||||
|
||||
/// Issue #1627: the D600's and D610's data runs 14 columns of filler and noise past the image before the dark
|
||||
/// ones; they develop as a bright band on the right unless the measured area leaves them out, and the noise must
|
||||
/// not read as the white level. The D5000 has the D90's filler.
|
||||
#[test]
|
||||
fn measured_d600_d610_and_d5000_drop_the_columns_past_the_image() {
|
||||
let file = |model: &str, (w, h): (usize, usize), bits: u16, row: &dyn Fn(&mut [u16])| {
|
||||
let mut px = vec![0u16; w * h];
|
||||
px.chunks_mut(w).for_each(row);
|
||||
let mut nef = nef(1, bits, vec![px.iter().flat_map(|v| v.to_be_bytes()).collect()], w as u32, h as u32, h as u32);
|
||||
let at = nef.windows(11).position(|b| b == b"NIKON TEST\0").unwrap();
|
||||
nef[at..at + 11].copy_from_slice(format!("{model:\0<11}").as_bytes());
|
||||
crate::decode(&nef).unwrap()
|
||||
};
|
||||
for (model, bits, fill, top) in [("NIKON D610", 12, 150, 4095), ("NIKON D610", 14, 601, 16383), ("NIKON D600", 14, 601, 16383)] {
|
||||
let d = file(model, (6080, 4028), bits, &|row| {
|
||||
row[..6034].fill(fill * 2);
|
||||
row[6034..6042].fill(fill);
|
||||
// the noise columns: often at the top of the range, which made a saturation plateau of their own
|
||||
row[6042..6048].fill(top);
|
||||
});
|
||||
assert_eq!(d.crop, Rect::new(8, 6, 6016, 4016), "{model} {bits}");
|
||||
assert_eq!((d.metadata.width, d.metadata.height), (Some(6016), Some(4016)));
|
||||
assert_eq!(d.white, vec![top as f32], "{model} {bits}: the white is measured inside the image, which isn't clipped");
|
||||
let developed = d.develop(crate::Method::Bilinear).unwrap();
|
||||
assert_eq!((developed.width, developed.height), (6016, 4016));
|
||||
}
|
||||
let d5000 = file("NIKON D5000", (4352, 2868), 12, &|row| {
|
||||
row.fill(40);
|
||||
row[4310..].fill(255);
|
||||
});
|
||||
assert_eq!(d5000.crop, Rect::new(10, 10, 4288, 2848));
|
||||
assert_eq!(d5000.white, vec![3830.0]);
|
||||
assert_eq!(file("NIKON D5100", (4352, 2868), 12, &|row| row.fill(40)).crop, Rect::new(0, 0, 4352, 2868));
|
||||
}
|
||||
|
||||
/// Issue #708: the active width is judged from the first [`MASK_ROWS`] rows in both modes, so the header probe
|
||||
/// (which reads only those rows' strips) and the full decode agree whether the masked columns are there or not.
|
||||
#[test]
|
||||
|
||||
@@ -803,6 +803,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove
|
||||
| 2026-10-15 | patch | LR-VIEW-BEFOREAFTER: side by side and stacked panes share click-zoom animation for whole-frame images, window tiles and input; render requests keep the final target (#1821); status unchanged |
|
||||
| 2026-10-14 | patch | LR-META-XMP: automatic sidecar writes that fail (folder offline or read-only) wait and are written again once the files are reachable, with a notice (`cmd:library.retrySidecars`); status unchanged |
|
||||
| 2026-10-13 | minor | LR-BEHAV-WINDOW (new, 🟡): the desktop app's window opens at the size it was left at, zoomed / maximized if it was; full screen and the place on the desktop are not remembered |
|
||||
| 2026-10-14 | minor | LR-IMP-CAMERA-COVERAGE: Nikon D600, D610 and D5000 NEFs drop the filler and noise columns on the right (a band), and the D600 and D610 measure their white level inside the image (#1627); status unchanged (🟡) |
|
||||
| 2026-10-13 | patch | LR-VIEW-COMPARE, LRC-LIB-COMPARE: ⌘/Ctrl- and Shift-clicks on the filmstrip in Compare change the selection as in the grid; the two photos shown stay selected and the active photo is unchanged (#187); statuses unchanged |
|
||||
| 2026-10-13 | minor | KEYC-SELECTONLYACTIVE: Edit → Select Only Active Photo (⌘⇧D) lands as a new row; status 🟡 (headless-tested, native macOS menu path unverified) |
|
||||
| 2026-10-13 | patch | LRC-LIB-CATALOG-IMPORT: a catalog whose tables are all still in its `-wal` file (main file schema format 0) opens instead of failing as an unsupported schema format; status unchanged |
|
||||
|
||||
+3
-2
@@ -124,7 +124,7 @@ checked, and Canon, the largest bucket, is the weakest of the big four.
|
||||
| CR3 | HEIF / HDR PQ | ⬜ | HEVC tracks recognised, not decoded |
|
||||
| CR3 | dual-pixel raw | ⬜ | never substituted |
|
||||
| CRW | all | ⬜ | preview |
|
||||
| NEF | uncompressed, lossless, lossy type 1 / 2, lossy after split | ✅ | D5100, D7000, D7500, Z f corpus; ~60 NEFs by hand; #633; D4 and D90 image area and white level measured against the camera JPEG |
|
||||
| NEF | uncompressed, lossless, lossy type 1 / 2, lossy after split | ✅ | D5100, D7000, D7500, Z f corpus; ~60 NEFs by hand; #633; D4, D90, D5000, D600 and D610 image area measured against the camera JPEG (white level too for the D4, D90 and D5000) |
|
||||
| NEF | packed 14-bit stored under a compressed tag (Z 6II uncompressed) | ⬜ | #769; fix in PR #671 (not merged) |
|
||||
| NEF | High Efficiency / HE★ (JPEG XS) | ⬜ | named, preview (#193); needs a JPEG XS decoder |
|
||||
| ARW | uncompressed, cRAW (ARW2), lossless LJ92 (L / M / S) | ✅ | 86 bodies (#535); M/S black level 1024; the Hasselblad Lunar, a NEX-7 under Make HASSELBLAD, routed by Sony's raw coding (#1275) |
|
||||
@@ -182,7 +182,7 @@ sample files. A pointer for contributors, not legal advice.
|
||||
| Black and white levels | per model, calibrated | read from each file (masked columns, maker notes, SR2SubIFD, CMP1) | 🟡 per-body bugs keep surfacing: A450–A700 SR2 black level and M/S ARW pedestal (#535, fixed); 5D Mark IV CR2 renders dark (#455, open); white-level pile-up in phone DNGs (#548, fixed); CR2 white level taken from saturated masked rows (#1272, fixed) |
|
||||
| Crop masks and aspect | ✅ | DefaultCrop, Sony recorded size, Canon AspectInfo, Panasonic in-camera crops, Nikon CropArea | 🟡 7RM3 / 7RM4 crop origin 8–32 px off (cosmetic) |
|
||||
| Black and white levels | per model, calibrated | read from each file (masked columns, maker notes, SR2SubIFD, CMP1) | 🟡 per-body bugs keep surfacing: A450–A700 SR2 black level and M/S ARW pedestal (#535, fixed); 5D Mark IV CR2 renders dark (#455, open); white-level pile-up in phone DNGs (#548, fixed) |
|
||||
| Crop masks and aspect | ✅ | DefaultCrop, Sony recorded size, Canon AspectInfo, Panasonic in-camera crops, Nikon CropArea, measured Nikon bodies without it (D4, D90) | 🟡 7RM3 / 7RM4 crop origin 8–32 px off (cosmetic) |
|
||||
| Crop masks and aspect | ✅ | DefaultCrop, Sony recorded size, Canon AspectInfo, Panasonic in-camera crops, Nikon CropArea, measured Nikon bodies without it (D4, D90, D5000, D600, D610) | 🟡 7RM3 / 7RM4 crop origin 8–32 px off (cosmetic) |
|
||||
| Lens metadata | ✅ read for profile selection | EXIF lens fields read; used only for the in-file corrections below | 🟡 |
|
||||
| Highlight reconstruction | ✅ | neutral clip, chromaticity diffusion (`highlight.rs`) | 🟡 tuned by eye |
|
||||
| AI denoise | ✅ Bayer and X-Trans, model bundled | Bayer only, user-installed weights, pure-Rust CPU / GPU | 🟡 no X-Trans, no shippable weights |
|
||||
@@ -279,6 +279,7 @@ Beyond decoding:
|
||||
| 2026-10-13 | minor | Bundled camera profiles 4 → 5 (ILCA-99M2) |
|
||||
| 2026-10-14 | minor | 21 Sony and Leica models verified (one CC0 raw.pixls.us file each, pinned; `sony_leica_corpus.rs`): 258 verified, 920 unverified; Leica on a verified body 21% → 72%, Sony 91% → 92%, use-weighted 85% and 43% |
|
||||
| 2026-10-14 | minor | 17 uncompressed Olympus and Samsung models verified on one CC0 file each (`olympus_samsung_corpus.rs`): 254 verified, 932 unverified, 224 preview; OM / Olympus 3% → 6% (5% on verified bodies); use-weighted 85% and 42% unchanged; duplicated summary rows from a merge removed |
|
||||
| 2026-10-14 | minor | Nikon D600 and D610 image area (6016 × 4016 at 8, 6) measured on 4 CC0 NEFs of each, and the D90's area and white level applied to the D5000 (same sensor and filler, 2 CC0 NEFs): the filler and noise columns on the right no longer develop as a band, and for these bodies the white level is measured inside the image area, issue #1627 |
|
||||
| 2026-10-11 | minor | Leica Q3 verified by a recorded check of 38 private DNGs (no decode failure; mean ΔE to the camera JPEG 6.3–13.9): 236 verified, 941 unverified, Leica bucket on a verified body 5% → 21%, weighted shares unchanged |
|
||||
| 2026-10-12 | patch | CR2 white level from the active area, not the saturated masked rows above it (#1272: EOS 50D, 60D, 450D (Rebel XSi), 500D (Rebel T1i), 550D (Rebel T2i), 1200D (Rebel T5) and 100D (Rebel SL1)); scores unchanged |
|
||||
| 2026-10-12 | minor | Canon PowerShot G9: as-shot white balance from WBInfo (`0x0029`, the row of the preset in ShotInfo), issue #1273; render unchanged (the camera-look fit already absorbed it), As Shot now the camera's; counts unchanged |
|
||||
|
||||
@@ -98,6 +98,13 @@
|
||||
16 GB, 355 MP with 32 GB) and the message says so. The merge also frees each photo's pixels once they are
|
||||
projected, so it needs less memory than before at any size (an 81 MP stitch: 4.4 GB instead of 6.2 GB).
|
||||
|
||||
### Nikon D600, D610 and D5000
|
||||
- Raws from the Nikon D600, D610 and D5000 no longer show a bright line down the right edge (issue #1627). Their
|
||||
sensor data runs a few columns of filler past the picture, which stayed in the image; these bodies now develop
|
||||
at the camera JPEG's size (6016 × 4016 for the D600 and D610, 4288 × 2848 for the D5000, as the D90 already
|
||||
did). On the D600 and D610 the white level is also measured inside the picture, so the filler can't pass for
|
||||
clipped highlights.
|
||||
|
||||
### Hard-edged Heal and Clone spots
|
||||
- A spot with Feather at 0 no longer draws black pixels exactly on its radius. This also fixes spots at 0% Opacity
|
||||
changing pixels along their edge. The hard edge now leaves pixels on the radius unchanged.
|
||||
|
||||
Reference in New Issue
Block a user