Keep fitted Camera Raw colour during tuning and crop Nikon D5100 borders (#3201)

* raw: fit non-DNG colour to the camera's embedded JPEG

* raw: preserve fitted Camera Raw look and crop D5100 borders

---------

Co-authored-by: Gert Pellin <gert@pellin.be>
Co-authored-by: optimalbytee <optimalbytee@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
optimalbytee
2026-10-11 19:12:19 -04:00
committed by GitHub
co-authored by Gert Pellin optimalbytee Brandon Thomas Claude Opus 5.5
parent d706acdde9
commit aa1febaffb
7 changed files with 174 additions and 6 deletions
+7 -1
View File
@@ -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);
+59 -1
View File
@@ -130,6 +130,10 @@ fn jpeg(img: Image) -> Vec<u8> {
/// 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<u8>>) -> Vec<u8> {
nef_uncompressed_with_wb(cam, w, h, preview, None)
}
fn nef_uncompressed_with_wb(cam: &[[f32; 3]], w: usize, h: usize, preview: Option<Vec<u8>>, wb: Option<[u32; 2]>) -> Vec<u8> {
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<Vec<u8
(33422, Val::Byte(vec![0, 1, 1, 2])),
]);
let mut ifd0 = vec![(271, Val::Ascii("NIKON CORPORATION".into())), (330, Val::Ifds(vec![raw]))];
if let Some([r, b]) = wb {
let mut note = TiffBuilder::default();
let note_ifd = note.ifd(vec![(0x000C, Val::Rational(vec![(r, 1024), (b, 1024), (1, 1), (1, 1)]))]);
note.chain = vec![note_ifd];
let mut maker = b"Nikon\0\x02\x10\0\0".to_vec();
maker.extend(note.build());
let exif = t.ifd(vec![(37500, Val::Undefined(maker))]);
ifd0.push((34665, Val::Ifds(vec![exif])));
}
if let Some(j) = preview {
let len = j.len() as u32;
let b = t.blob(j);
@@ -176,6 +189,45 @@ fn uncalibrated_raw_takes_its_colour_from_the_camera_jpeg() {
assert_eq!(r.document.depth, SampleType::U16);
}
#[test]
fn camera_raw_tuning_keeps_the_default_look_and_applies_user_gains_after_fitting() {
use photocraft_io::raw::{RawTuning, fit_preview_look, import_raw_tuned};
use photocraft_raw::{DevelopOptions, WhiteBalance, decode, develop_sensor};
let (w, h) = (480, 320);
let cam: Vec<_> = 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());
}
}
+5
View File
@@ -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
+28 -1
View File
@@ -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);
+9 -2
View File
@@ -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` |
+6 -1
View File
@@ -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 |
+60
View File
@@ -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.