diff --git a/crates/io/src/raw.rs b/crates/io/src/raw.rs index bf7f0892e6..71e8c8a0be 100644 --- a/crates/io/src/raw.rs +++ b/crates/io/src/raw.rs @@ -65,7 +65,13 @@ pub fn import_raw_tuned(name: &str, bytes: &[u8], tuning: &RawTuning) -> Result< return Err(IoError::Raw(RawError::Malformed(format!("exposure {} EV is out of range", tuning.exposure)))); } let sensor = photocraft_raw::decode(bytes, &limits()).map_err(IoError::Raw)?; - let mut opts = DevelopOptions { limits: limits(), exposure, ..Default::default() }; + let mut opts = DevelopOptions { limits: limits(), ..Default::default() }; + // Use the same camera rendering as normal import. Fit at the camera's default WB and + // exposure before applying user controls, so the fit cannot cancel their adjustments. + if sensor.color.calibrations.is_empty() { + opts.camera_look = fit_preview_look(bytes, &sensor, &opts).ok(); + } + opts.exposure = exposure; let wb_applied = match sensor.camera_wb { Some(m) => { let g = wb_gains(tuning.temperature, tuning.tint); diff --git a/crates/io/tests/raw.rs b/crates/io/tests/raw.rs index eb449a90a4..b9c62270e4 100644 --- a/crates/io/tests/raw.rs +++ b/crates/io/tests/raw.rs @@ -130,6 +130,10 @@ fn jpeg(img: Image) -> Vec { /// An uncompressed NEF-like raw of `cam` with an optional JPEG preview in IFD0. fn nef_uncompressed(cam: &[[f32; 3]], w: usize, h: usize, preview: Option>) -> Vec { + nef_uncompressed_with_wb(cam, w, h, preview, None) +} + +fn nef_uncompressed_with_wb(cam: &[[f32; 3]], w: usize, h: usize, preview: Option>, wb: Option<[u32; 2]>) -> Vec { let data = mosaic(cam, w, [0, 1, 1, 2], 0, 4095); let mut t = TiffBuilder::default(); let strip = t.blob(data.iter().flat_map(|v| v.to_le_bytes()).collect()); @@ -148,6 +152,15 @@ fn nef_uncompressed(cam: &[[f32; 3]], w: usize, h: usize, preview: Option = patches(w, h).into_iter().map(|c| c.map(|v| v * 0.6)).collect(); + let balanced: Vec<_> = cam.iter().map(|c| [c[0] * 2.0, c[1], c[2] * 1.5]).collect(); + let bytes = nef_uncompressed_with_wb(&cam, w, h, Some(camera_rendering(&balanced, w, h)), Some([2048, 1536])); + let normal = import("camera.nef", &bytes).unwrap(); + assert!(fitted(&normal.warnings)); + let sensor = decode(&bytes, &Default::default()).unwrap(); + let look = fit_preview_look(&bytes, &sensor, &DevelopOptions::default()).unwrap(); + for tuning in [RawTuning::default(), RawTuning { exposure: 0.5, ..Default::default() }, RawTuning { exposure: -0.5, temperature: 20.0, tint: -15.0 }] { + let (got, applied) = import_raw_tuned("camera.nef", &bytes, &tuning).unwrap(); + assert!(applied && fitted(&got.warnings) && !fallback(&got.warnings), "{:?}", got.warnings); + assert_eq!(got.document.depth, SampleType::U16); + assert_eq!((got.document.size.width, got.document.size.height), (w as u32, h as u32)); + let gains = photocraft_algo::camera_raw::white_balance_gains(tuning.temperature, tuning.tint).map(f64::from); + let expected = develop_sensor( + &sensor, + &DevelopOptions { + exposure: f64::from(tuning.exposure), + white_balance: WhiteBalance::Multipliers([2.0 * gains[0], gains[1], 1.5 * gains[2]]), + camera_look: Some(look.clone()), + ..Default::default() + }, + ) + .unwrap(); + let expected_bytes: Vec<_> = expected.rgb.iter().flat_map(|v| v.to_ne_bytes()).collect(); + let actual = photocraft_io::document_to_image(&got.document, &mut Vec::new()).unwrap(); + assert_eq!(actual.data(), expected_bytes, "tuning {tuning:?} must not refit away the user controls"); + if tuning == RawTuning::default() { + let original = photocraft_io::document_to_image(&normal.document, &mut Vec::new()).unwrap(); + assert_eq!(actual.data(), original.data()); + } + } +} + #[test] fn unusable_previews_keep_the_neutral_fallback() { let (w, h) = (480, 320); @@ -199,8 +251,13 @@ fn unusable_previews_keep_the_neutral_fallback() { Some(garbage), ]; for (i, p) in previews.into_iter().enumerate() { - let r = import("DSC_0003.NEF", &nef_uncompressed(&cam, w, h, p)).unwrap(); + let bytes = nef_uncompressed(&cam, w, h, p); + let r = import("DSC_0003.NEF", &bytes).unwrap(); assert!(fallback(&r.warnings) && !fitted(&r.warnings), "preview {i}: {:?}", r.warnings); + let (tuned, wb) = + photocraft_io::raw::import_raw_tuned("DSC_0003.NEF", &bytes, &photocraft_io::raw::RawTuning { exposure: 0.5, ..Default::default() }).unwrap(); + assert!(!wb && fallback(&tuned.warnings) && !fitted(&tuned.warnings), "preview {i}: {:?}", tuned.warnings); + assert_eq!(tuned.document.depth, SampleType::U16); } } @@ -211,5 +268,6 @@ fn truncated_raws_with_a_fittable_preview_do_not_panic() { let b = nef_uncompressed(&cam, w, h, Some(camera_rendering(&cam, w, h))); for n in (0..b.len()).step_by(211) { let _ = import("x.nef", &b[..n]); + let _ = photocraft_io::raw::import_raw_tuned("x.nef", &b[..n], &Default::default()); } } diff --git a/crates/raw/README.md b/crates/raw/README.md index 519746927b..7fd1152fb3 100644 --- a/crates/raw/README.md +++ b/crates/raw/README.md @@ -104,6 +104,11 @@ the matrix is plausible and the held-out mean CIE76 ΔE drops to at most 85 % of Pass it as `DevelopOptions::camera_look`; `develop` then notes "colour fitted to the camera's embedded JPEG (mean ΔE …)". The crate cannot decode JPEG itself: `photocraft-io` decodes the smallest preview at least 512 pixels long, fits and develops in one go. +Camera Raw's tuned import fits at the default exposure and as-shot white balance first, +then applies the user's Exposure, Temperature and Tint to that same rendering. +The D5100's measured image area is 4928 × 3264 at (8, 8) in a 4992 × 3280 +sensor plane; it removes the border without changing levels, WB or calibration. +Measured image areas preserve an explicit file crop. Without a usable fit (no preview, an unrelated, monochrome or differently shaped one, or a fit that does not clearly help) the documented neutral fallback stays: the white-balanced camera channels are diff --git a/crates/raw/src/cameras.rs b/crates/raw/src/cameras.rs index 1c605734f1..45eb542cb9 100644 --- a/crates/raw/src/cameras.rs +++ b/crates/raw/src/cameras.rs @@ -93,6 +93,8 @@ const PROFILES: &[Profile] = &[Profile { /// * **D800E** (2 files): the camera's JPEG sits 8 x 6 pixels in from the left /// and top of the 7424 x 4924 sensor data (56 and 4 pixels of margin on the /// right and bottom), the same corner as the D4. +/// * **D5100** (3 files): 8 x 8 pixels in from the left and top of the +/// 4992 x 3280 sensor data, giving the camera JPEG's 4928 x 3264 frame. /// * **ILCE-7** (2 files): 12 x 12 pixels in from the left and top of the /// 6048 x 4024 cRAW data (36 and 12 pixels of margin on the right and bottom). struct ImageArea { @@ -105,6 +107,7 @@ struct ImageArea { } const IMAGE_AREAS: &[ImageArea] = &[ + ImageArea { make: "NIKON CORPORATION", model: "NIKON D5100", size: (4992, 3280), crop: (8, 8, 4928, 3264) }, ImageArea { make: "NIKON CORPORATION", model: "NIKON D800E", size: (7424, 4924), crop: (8, 6, 7360, 4912) }, ImageArea { make: "SONY", model: "ILCE-7", size: (6048, 4024), crop: (12, 12, 6000, 4000) }, ]; @@ -114,7 +117,7 @@ const IMAGE_AREAS: &[ImageArea] = &[ pub(crate) fn apply(s: &mut Sensor) { let (Some(make), Some(model)) = (s.make.as_deref(), s.model.as_deref()) else { return }; let (make, model) = (make.trim(), model.trim()); - if let Some(a) = IMAGE_AREAS.iter().find(|a| a.make == make && a.model == model && a.size == (s.width, s.height)) { + if let Some(a) = IMAGE_AREAS.iter().find(|a| a.make == make && a.model == model && a.size == (s.width, s.height) && s.crop == s.active) { let (x, y, w, h) = a.crop; s.crop = Rect::new(x, y, w, h); } @@ -183,6 +186,30 @@ mod tests { assert!(s.color.balanced_to_xyz_d50([0.5, 1.0, 0.7]).is_some()); } + #[test] + fn d5100_image_area_respects_explicit_crops_and_exact_sensor_size() { + let mut s = nef("NIKON D5100", 4992, 3280); + // The NEF reader trims eight columns of row padding before applying the model area. + s.active = Rect::new(0, 0, 4984, 3280); + s.crop = s.active; + let wb = s.camera_wb; + apply(&mut s); + assert_eq!(s.crop, Rect::new(8, 8, 4928, 3264)); + assert_eq!(s.active, Rect::new(0, 0, 4984, 3280)); + assert_eq!(s.camera_wb, wb); + assert!(s.color.calibrations.is_empty() && s.tone_curve.is_empty()); + assert_eq!(s.white, [16383.0; 3]); + + s.crop = Rect::new(16, 12, 4800, 3200); + apply(&mut s); + assert_eq!(s.crop, Rect::new(16, 12, 4800, 3200)); + for (model, w, h) in [("NIKON D5100", 2496, 1640), ("NIKON D5200", 4992, 3280)] { + let mut other = nef(model, w, h); + apply(&mut other); + assert_eq!(other.crop, other.active); + } + } + #[test] fn measured_image_areas_crop_the_borders_without_touching_the_rest() { let mut s = nef("NIKON D800E", 7424, 4924); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index ede48b72cd..014cf183c8 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first version: every Photoshop 2026 format from its bundle against our readers and writers; Affinity notes moved here from `roadmap.md`) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (D5100 crop and Camera Raw tuning validation) · **Target:** Adobe Photoshop 2026 Every format Photoshop 2026 (27.11.0) reads or writes, with PhotoCraft's support, fidelity and tests. Photoshop's list comes from its `Info.plist` `CFBundleDocumentTypes`, the PiPL read/write @@ -66,7 +66,7 @@ supports many more, including CR3). | DNG (incl. LJPEG and JPEG XL tiles) | yes; lossy DNG no | FILE-215-11 | | Canon CR2 | yes | | | Canon CR3 | detected, unsupported | `crates/raw/src/lib.rs:219` | -| Nikon NEF/NRW (lossless, lossy) | yes; "lossy after split" no | #50 | +| Nikon NEF/NRW (lossless, lossy) | yes; "lossy after split" no | #50; D5100 image area measured on three 14-bit lossy NEFs; [validation](nikon-nef-development.md) | | Sony ARW/SR2/SRF (incl. compressed) | yes | | | Panasonic RW2, Pentax PEF | yes | | | Olympus ORF | uncompressed only | | @@ -75,6 +75,12 @@ supports many more, including CR3). | .xmp sidecars, highlight modes, raw preferences | missing | FILE-215-11 | | Camera Raw as a PSD smart filter | partial | 2026-10-07: WB, Light/Presence, curves, HSL, grading, detail, grain, vignette mapped (FILE-215-12) | +2026-10-11: on #2135's embedded-JPEG rendering work, open-time Camera Raw tuning +preserves the fitted rendering while changing Exposure, Temperature and Tint. Default +tuned import matches normal import exactly; output remains full-resolution 16-bit ProPhoto. +This improves appearance when a usable preview is present, without adding a measured +D5100 colour profile. Counts and the G14 estimate are unchanged. + ## Formats Photoshop doesn't have (ours only) | Format | Read | Write | Notes | @@ -134,4 +140,5 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | D5100 image area and fitted-look preservation in Camera Raw tuning | | 2026-10-10 | major | First version: Photoshop 2026 27.11.0's formats from its bundle, against our codecs; Affinity notes moved from `roadmap.md` | diff --git a/docs/gaps.md b/docs/gaps.md index 9e84b432d8..fd9b8f826e 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (G8: triple-, quadruple- and five-click type selection landed, TYPE-218-8 done; Czech UI localization evidence added to G13) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (G8: triple-, quadruple- and five-click type selection landed, TYPE-218-8 done; Czech UI localization evidence added to G13; G14: D5100 crop and Camera Raw tuning validation) · **Target:** Adobe Photoshop 2026 Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that @@ -99,6 +99,10 @@ what an agent can't finish alone. sidecars, masking and calibration in the Camera Raw dialog, per-camera colour for non-DNG cameras, lens profiles. - **Evidence:** `crates/raw/src/lib.rs:219` (CR3), scorecard FILE-215-11/12, #50. +- **Progress (2026-10-11):** on the embedded-JPEG rendering work in #2135, Camera Raw + tuning retains the fitted look and applies user gains afterwards. Three D5100 NEFs + establish a guarded 4928 × 3264 image area. This is an estimated rendering, not a + measured D5100 colour calibration; [validation](nikon-nef-development.md). - **Estimate:** 120–200 h. ### G15. AI and generative features @@ -221,6 +225,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | D5100 image area and Camera Raw tuning validation; G14 remains open | | 2026-10-11 | minor | G13: Czech dynamic UI text localized; engine messages and native review remain open | | 2026-10-11 | minor | G8: triple/quadruple/five-click type selection done (TYPE-218-8, #3493); Type evidence recounted | | 2026-10-10 | minor | G2: recorded Tilt-Shift computation/filtered-proxy measurements and limits; gap and estimate remain open | diff --git a/docs/nikon-nef-development.md b/docs/nikon-nef-development.md new file mode 100644 index 0000000000..1984b85ea2 --- /dev/null +++ b/docs/nikon-nef-development.md @@ -0,0 +1,60 @@ +# Nikon D5100 NEF development + +Validated 2026-10-11 with three locally supplied NEFs: DSC_0453 (portrait), +DSC_0469 and DSC_0477. Photos, extracted previews and metadata dumps stay local; +regression fixtures are synthetic. + +## Findings and change + +All three files contain 4992 × 3280, 14-bit Nikon lossy type-2 sensor data +with a normal `44 20` linearization table. Their as-shot white balances are +already decoded correctly. They have no colour calibration, so the neutral +fallback treats white-balanced camera channels as linear sRGB and looks flat. +The Nikon `44 40` quarter-span decoder issue addressed elsewhere does not apply. + +This branch uses the existing embedded-JPEG rendering work in +[PhotoCraft #2135](https://github.com/storytold/photocraft/pull/2135), head +`d1a91d1f0dff1ed7ac74767b602051e88198b2e5`. That work fits a matrix and monotone +tone curve with held-out acceptance checks. It supplies the appearance fix; +this follow-up preserves that rendering in `import_raw_tuned`. Fitting happens +at default exposure and camera WB before applying user controls, so changing +Exposure, Temperature or Tint cannot be absorbed by a new fit. Unusable previews +still use the documented neutral fallback, and calibrated sensors skip fitting. + +Aligning green-channel gradients of the developed sensor plane against each +camera's full 4928 × 3264 JPEG identifies the same (8, 8) crop origin. Searching +even offsets x=0…56, y=0…16 gives best normalized correlations of 0.944, 0.698 +and 0.684 for the three files respectively. A model- and sensor-size-matched +D5100 image-area entry removes the magenta edge and applies before orientation; +portrait output is 3264 × 4928. It preserves an explicit file crop and leaves +black, white, WB and colour calibration unchanged. + +The camera JPEG is a rendering reference, not a calibration chart. Its picture +style, local tone mapping and clipping cannot all be reproduced by one matrix +and curve. No D5100 colour matrix or unrelated camera profile was added. + +After applying the measured crop, all three fits pass their held-out acceptance +checks. Mean CIE76 ΔE on the held-out grid against each camera JPEG is: + +| File | Neutral fallback | Fitted rendering | Final upright size | +|---|---:|---:|---| +| DSC_0453 | 13.68 | 2.07 | 3264 × 4928 | +| DSC_0469 | 13.90 | 2.45 | 4928 × 3264 | +| DSC_0477 | 7.62 | 1.62 | 4928 × 3264 | + +These values measure appearance against the file's JPEG, not calibration accuracy. +Full-resolution default, +0.5 EV and Temperature +20/Tint −15 imports were exported +for each file and independently verified as RGB16 TIFFs with the ProPhoto-compatible +ICC profile. Default tuned pixels and ICC bytes equal normal import exactly. Local +whole-frame and 100% edge comparisons were inspected against the camera JPEG and +installed LightCraft 0.6.0: the border is gone and colour/contrast improve, with +remaining differences in sunset highlight saturation and noise/detail rendering. + +## Regression coverage + +Synthetic tests verify identical normal/default-tuned pixels, preserved fitted +appearance with positive/negative exposure and WB edits, exact application of +user gains after fitting, neutral fallback for missing or unsuitable previews, +and graceful truncated-input handling. Image-area tests verify the D5100 crop, +unchanged sensor metadata, explicit-crop precedence and exact model/size guards. +All final imports retain 16-bit RGB in the ProPhoto-compatible working profile.