mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-24 09:57:08 -06:00
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:
co-authored by
Gert Pellin
optimalbytee
Brandon Thomas
Claude Opus 5.5
parent
d706acdde9
commit
aa1febaffb
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user