diff --git a/crates/raw/src/vendor/nef.rs b/crates/raw/src/vendor/nef.rs index 3489c57b07..7453750870 100644 --- a/crates/raw/src/vendor/nef.rs +++ b/crates/raw/src/vendor/nef.rs @@ -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 { + 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 { 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] diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index ac14ed41ab..ee05a4f559 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -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 | diff --git a/docs/raw-parity.md b/docs/raw-parity.md index af98899897..3c514e0602 100644 --- a/docs/raw-parity.md +++ b/docs/raw-parity.md @@ -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 | diff --git a/docs/whats-new.md b/docs/whats-new.md index 1419d4148c..4340ce27b7 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -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.