`embedded_preview_srgb` (the loupe stand-in and the grid thumbnail of an unedited raw) decoded the
largest embedded JPEG whatever the size asked for: a 512 px thumbnail of a Sony A7R VI ARW decoded
its 9984 x 6656 JPEG (5.6 MB) although the file holds a 1616 x 1080 one (0.3 MB).
`lightcraft_raw::embedded_preview_covering(bytes, min_edge)` returns the smallest of a TIFF-based
raw's JPEGs whose long edge is at least `min_edge` and that is a copy of the largest one: aspect
ratio within 1 % (not a letterboxed thumbnail of another shape) and the same number of components
(not the grey semantic masks of an iPhone ProRAW DNG); else the largest, as `embedded_preview`.
The colour-space handling stays in `decode_raw_preview` (a `covering` argument); an unsupported
raw's editing source and the camera-look fit keep the largest JPEG, so a source never depends on
its size level.
On 120 raws of every make in the corpus at 256 / 512 / 1600 px: wherever the largest JPEG is still
chosen the output is identical byte for byte; where a smaller one is (Sony, Nikon, Panasonic,
Adobe DNG) orientation matches, the size is equal or 1-2 px off, mean difference <= 4.7 / 255.
- Rebased onto #1020: its plan route (`preview_window` -> `planned_preview`) now takes the box size and picks
with `embedded_preview_covering` too, so a TIFF raw read through the sparse plan gets the whole file's JPEG.
Co-Authored-By: Claude <noreply@anthropic.com>
Sony ARWs of cameras without measured colour matrices were edited on the
relative scale (6500 K / 0 = as shot). Their SR2SubIFD stores the
multipliers of the camera's presets at 2500, 3200, 4500, 6000 and 8500 K;
lightcraft_raw::kelvin_presets reads the as-shot multipliers on the curve
through them and they take the spectral matrices' place in the #730
design (as-shot Kelvin and tint only; look, Bradford adaptation and the
pixel-identical legacy upgrade unchanged).
Covered: ILCA-99M2, ILCA-77M2, SLT-A33, DSC-RX10, ILCE-3500, ILCE-7S.
Kelvin-dial shots read back 3400 -> 3396, 5200 -> 5193, 4700 -> 4708,
5600 -> 5594 K; Shade 7493 K. Off-curve constants (isotherm tilt 0.74,
tint spacing 1) measured from the 14 Sony bodies with both spectral
matrices and presets.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add correlated dark holdouts that reject misleading training and accept
matching training. Check finite, monotone curves in both cases. Confirm
the regression fails with the error gate removed and passes when restored.
Qualify the improvement claim when no original curve can be fitted, and
explain the source-look process policy and offline smart preview refresh.
Co-Authored-By: Codex <noreply@openai.com>
Use separate luminance pairs for tone fitting so the camera look follows
its JPEG into deep shadows. Keep colour pairs and holdouts unchanged.
Require enough correlated shadow samples and lower held-out error, with
noise and compression floors and the existing median curve fit.
Add synthetic regressions, bump look and render cache versions, and
record the public multi-make and private numeric validation in the docs.
Fixes#1307
Co-Authored-By: Codex <noreply@openai.com>
Photo Merge decoded each raw itself and wrote the reference frame's own colour
tags into the merged DNG. A CR2 (and NEF, ARW and the other raws whose colour is
fitted from the camera JPEG) has none, so an HDR merge of CR2s rendered with no
camera colour: green and about 16 L* dark.
`lightcraft_merge::load_frame_with` takes the raw's colour tags from the caller,
and the engine passes `starting_colour_tags`: the colour the raw opens with
(`starting_colour`) as DNG tags. That is one forward matrix with a colour matrix
and AsShotNeutral that agree with it at the as-shot white, the look's hue/sat
table as ProfileHueSatMap and its tone curve as ProfileToneCurve. Raws with
their own matrices (DNG) keep their tags. The look's chroma curve has no DNG
tag and is left out.
On two CC0 CR2s (EOS 550D, 40D) an HDR merge of three copies against the photo
alone: mean CIE76 dE 26.15 -> 1.07 and 22.89 -> 0.47.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A raw shot in a monochrome picture style records full colour, but its
camera JPEG is grey: neither the full look nor the partial look has
colour to fit, so the photo started from the spectral matrices or the
neutral fallback with the default tone curve, about 1.3 EV darker than
the same body's colour-shot photos. The grey JPEG still shows the
camera's tone curve.
When no look was fitted and the JPEG is black and white (`monochrome`),
fit the quantile tone curve alone on top of the colour the photo gets
otherwise: the camera profile's (in `fit_preview`), else the spectral
matrices or the fallback (`monochrome_look`, after the spectral step).
The chroma curve stays neutral; the partial look's checks apply. A
tone-only look keeps the file's colour model for white balance.
LOOK_VERSION 2 -> 3, RENDER_CACHE_VERSION 42 -> 43.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Between a kept luminance share of 0.15 and 0.25 the table colour is now blended in with a smoothstep,
so noisy shadows show no step between pixels that get the table colour and pixels that keep theirs.
At 0.25 and above pixels render exactly as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
HueSat::apply restores luminance with before / after. Near black, where noise makes some channels
negative, the hue/saturation table can bring after close to 0, and the gain turned single pixels
white (isolated white dots in dark areas, most visible at fit). Luminance is now restored only if
the table kept at least 25 % of it; such colours keep their colour, like black and non-finite ones.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A Sony body shot in Adobe RGB writes ColorSpace Uncalibrated with InteropIndex R03 in the raw's EXIF and leaves the
embedded JPEG untagged, so the look was fitted against the JPEG decoded as sRGB. The EXIF fallback now applies to
all makes (the Nikon maker note still wins); DNGs are skipped, since their previews come from the converter.
Raws without matrices in the file (ARW, NEF, RW2, RAF, CR2, CR3, PEF, SRW,
ORF) were all edited on the relative white-balance scale (6500 K / 0 = as
shot), even when lightcraft_raw::spectral has measured matrices for the
camera. Those 52 bodies (ILCE-7M3, ILCE-7M4, D850, EOS 5D Mark III...) now
edit in Kelvin like a DNG and like Lightroom.
Design:
- One decision on both sides: spectral::find_camera("Make Model") backs
catalog::relative_wb_camera (Photo::relative_wb); files::WbScaleOf decides
the same from the decoded file. A corpus test asserts they agree.
Photo::fitted_look keeps the old predicate for smart previews.
- The matrices only convert: As Shot is the file's multipliers read through
the model's ColorMatrix1/2 (spectral::as_shot_white_through). The look
(JPEG fit / profile / spectral fallback) is unchanged, and the slider
adapts from that white with Bradford. The camera-space rebalance stays
reserved for files with their own matrices.
- Presets resolve to their Kelvin values (WbMode::preset, sources cited).
- The Color panel shows the white balance in effect (effective_wb).
- XMP: crs::Target::for_file takes the camera; matrix cameras read
crs:Temperature as Kelvin (marked "scale": "kelvin"), others keep the
#510 as-shot shift.
Migration (no look changes):
- WhiteBalance.scale: WbScale { Legacy (absent in JSON), Kelvin }. A
white balance without a scale keeps its old meaning. On a spectral-Kelvin
raw (SourceInfo.legacy_relative_wb) the pipeline composes the old
adaptation (6500 -> value) with the new one (as shot -> ...) in Bradford
cone space (local::legacy_white). Von Kries scalings compose exactly, so
the matrix matches to f32 and the pixels are identical
(pipeline legacy_relative_white_balance_renders_as_before_on_a_kelvin_raw,
media old_relative_white_balance_on_a_kelvin_camera_keeps_its_look...).
- Every white-balance writer (develop.set/adjust/quickAdjust/wb/wbPick/
reset) first runs local::normalize_wb: a legacy value becomes the Custom
Kelvin value with the same look and is marked Kelvin. Partials without a
scale (old presets, develop.merge) stay legacy.
- Catalog format 7: Op::SetAsShotWb replaces the 6500/0 as-shot recorded
by older imports when the file is next decoded or reloaded (system op,
not an undo step). Format-version test updated.
- The web wire carries legacy_relative_wb.
Docs: parity (LR-EDIT-COLOR-TEMP), camera-preview-colour, xmp-interop,
lightroom-catalog-import, control-protocol, mcp, whats-new.
Merge origin/main into #560 (fit_preview / camera-look refactors of #528, #583, #568, #616).
`starting_colour` now tries the per-file camera-JPEG fit (with the camera profile when there is
one) first and uses the spectral-sensitivity matrices only when that fit fails, before the neutral
fallback, so no photo with an accepted JPEG fit changes. Tests pin the order through an injectable
fit; docs, parity, ROADMAP and what's new describe it. RENDER_CACHE_VERSION 37 -> 38.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Only proxies of raws that take the per-file camera look (Photo::relative_wb) go stale: JPEG/PNG/DNG proxies are never rebuilt for the look. The opening scan runs once per LOOK_VERSION (a look-version marker in the smart previews folder), not at every library open; Build Smart Previews still refreshes stale proxies. Each proxy is checked and written under one lock, so the background refresh and library.smartPreviews never write the same file at once. LOOK_VERSION 2: main's DRO / ILCE-7CR camera-look changes (#528, #583, #568, #616).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- #540's crs::Target replaces the raw flag: #597/#604's folder sync and sidecar_op and #533's
process tests pass the photo's target instead of a bool.
- #568's DRO test fits through #528's fit_look (proxies now return the clipped mask).
- #541 (French) and #564 (Ukrainian) gain the 15 keyword format strings #578-#592 added.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The ORF decoder supplies the as-shot white balance (ImageProcessing
WB_RBLevels) and no colour matrix, like ARW, NEF, RW2, RAF, CR2/CR3, PEF
and SRW, but ORF was left out of camera_preview::file_local_look and the
catalog's relative_wb, so it rendered with the neutral fallback matrix:
flat, grey and dark. It now gets the same guarded fit to its camera JPEG
and white balance relative to the as-shot look. Corpus (held-out RMS
against the fallback, all accepted): E-1 0.126 -> 0.014, E-400 0.087 ->
0.022, XZ-2 0.061 -> 0.023; compressed ORFs still open from their preview.
Against Lightroom Classic (CC0 E-1 kit, 2000 px, ΔE00): unedited render
11.25 -> 8.30; mean over 86 variants abs 12.10 -> 9.48, effect 3.44 ->
2.80. Every other kit's unedited render is byte-identical.
RENDER_CACHE_VERSION 23.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A smart preview keeps the colour and tone curves the camera-look fit gave it, so a
fit change (#499, #528, #583) left existing previews with the old look. They now
record LOOK_VERSION in the header; an older one is rebuilt from its original when the
library is opened or Build Smart Previews runs, and kept as it is while the original
is offline.
Fitted with `lightcraft-cli calibrate` to 125 usable ILCE-7CR ARWs (of 131,
September 2025 to September 2026, Standard creative style) and their
in-camera JPEGs; aggregate colour statistics only.
Without it, 8 of the 131 photos fail the fit gates and open with the
neutral matrix (dE76 6-26 against the camera JPEG). On 26 withheld photos
(profile fitted to the other 105) mean dE76 goes 4.07 -> 3.29 and both
withheld rejections are accepted (26.0 -> 6.1, 6.2 -> 5.5); the 24
withheld photos whose own fit was accepted are about unchanged (12
improve, mean 3.06 -> 3.08, worst regression 1.8). With the final profile
all 131 are accepted. The nine public raw.pixls.us ILCE-7CR codings stay
accepted (mean 1.59 -> 1.99).
A corpus test checks the public lossless-L sample's fit keeps exactly the
built-in profile's matrix and table.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Three CC0 raw.pixls.us files of one ILCE-7CR (61 MP) scene: lossless
compressed L and M (subsampled YCbCr) and compressed ARW2. A raw corpus
test pins model, colour-filter layout, black level, white level, as-shot
gains and default crop, and checks that the lossless and compressed
mosaics agree inside the crop (green block correlation 0.9976). An engine
corpus test checks each gets a fitted camera look, not the neutral
fallback. All nine public codings decode and fit (mean dE76 1.59 against
their camera JPEGs).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sony's Dynamic Range Optimizer (factory default Auto) brightens the darker
regions of the camera JPEG, not the raw. The tone curve fitted to that JPEG
carried the brightening into the raw's starting look: on 8 ILCE-7CR photos
with DRO on, LightCraft rendered +6.4 L* from Lightroom's default (median
dE76 7.5), against -0.2 L* (3.6) for the 16 with DRO off.
- lightcraft_raw::embedded_preview_dynamic_range_optimized reads Sony
maker-note tag 0xb025 (every documented value: 0 off; 1-3, 8-12 and
16-23, i.e. up to Lv8, on). It was present on all 264 Sony raws checked
(98 bodies); 0xb04f only says on/off and is missing on older bodies.
- With DRO on, the look keeps the colour fitted to the JPEG and its tone
curve is lowered, knot by knot, to Sony's curve without DRO wherever that
is darker (assets/camera-tone/sony-dro-off.json, pooled from 12 CC0
raw.pixls.us ARWs of 10 interchangeable-lens bodies; sources and
derivation in the file and assets/ATTRIBUTION.md). The second-last knot
is lowered only as far as keeps the shoulder past the last knot at or
below the fitted curve, so the result is never brighter anywhere.
Compacts that map raw values darker keep their own curve.
- DRO off, unknown and other makers: unchanged (byte-identical renders on
71 + 35 photos). Render cache version 23.
DRO-on photos now land at +0.8 L* from Lightroom (median dE76 3.3), band by
band like the DRO-off ones. Docs: camera-preview-colour.md, parity row.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A raw whose camera look fails the acceptance gates fell back to the neutral
rendering: dark (9-22 L* below the camera JPEG), about a third of its
colourfulness, hues from the generic camera = sRGB matrix. Before falling
back, fit a partial look: the quantile tone curve (registration-free), the
chroma curve and, on a scene with enough colour, the photo's own matrix at
half weight (no hue/saturation table). It is used only when it cuts the
held-out error to below 0.7 of the neutral fallback's (linear and encoded)
and the JPEG's luminance ranks the pixels like the scene's (Spearman >= 0.5).
The full look's gates and search are unchanged. Render cache version 24.
A raw without colour matrices of its own now gets DNG ColorMatrix1/2 and
ForwardMatrix1/2 for its model when the model is one of the 52 cameras
in rawtoaces-data (Academy Software Foundation, Apache-2.0): 22 Canon,
11 Nikon, 11 Sony, 3 Fujifilm and 5 others, plus the other names its
alias list gives (Rebel T3i and Kiss X5 for the EOS 600D; bodies it
lists as sharing a sensor). tools/spectral_camera_table.py fits, under
Standard Illuminant A and D65, a white-preserving 3x3 to the measured
spectral sensitivities over rawtoaces-data's 190 training reflectances
and writes crates/raw/src/spectral_table.rs. No number comes from Adobe
or from a raw decoder. lightcraft_raw::spectral looks a camera up by
Exif make and model; the DNG colour model does the rest.
camera_preview::starting_colour takes the first available of: the
file's own matrices, a camera profile for the model (fitted to the
JPEG for tone), the spectral matrices, the file-local JPEG fit, the
neutral fallback. A profiled model whose profile fit fails takes its
spectral matrices. White balance stays relative to the as-shot look for
every raw without matrices of its own, so Photo::relative_wb and the
controls are unchanged; calibrate still pools covered models.
Tests: one per precedence step (synthetic), the table's lookup, aliases
and DNG shape, and a corpus test of the order on ILCE-7M4 (profile),
EOS 5D Mark III and D5100 (spectral), EOS 7D (own fit) and a DNG. The
ILCE-7RM2 and GFX100S corpus tests now expect the spectral matrices.
Attribution in assets/ATTRIBUTION.md and NOTICE. RENDER_CACHE_VERSION 23.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
proxies() fitted the decoded camera JPEG into the sensor proxy's box,
which keeps the JPEG's own aspect and can round a pixel short: an
ILCE-7RM4's 1616x1080 preview, decoded at 384x257, fits a 192x128
profile proxy as 191x128, and collect_pairs rejected the mismatch. So
`lightcraft-cli calibrate` skipped every file from this camera and no
profile could be written.
Resize the JPEG to exactly the sensor proxy's size instead; proxies()
has already rejected references whose aspect differs by more than 2%.
Where fitting already landed on the proxy's size the result is
identical (fit is resize at a rounded size), so working renders don't
change: 34 real ILCE-7RM4 ARWs render bit-identically. On 923 files
from one trip calibrate goes from 0 to 843 accepted.
Refs #515
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A clipped raw pixel no longer records the scene's colour (one channel held at
the clip level turns a white sky magenta or blue through the matrix while the
camera JPEG keeps it white). As training pairs such pixels pulled the fitted
colour matrix toward grey and made the chroma curve remove colour at their
display luminance: on the CC0 Z 6II forest sample every chroma sample above
display luminance 0.36 was clipped, and the curve cut colourfulness there to
13-19%.
The proxies now carry a per-pixel clip mask (any covered sensor sample at or
above 0.99, aligned with the CR3 framing). The search on all pixels still
decides whether a photo gets a look and which attempt gives it; only that
attempt is refitted without the clipped pixels, and the look on all pixels is
kept when the refit misses the gates, so no photo loses its look or switches
attempt. Photos without a look get one when the search without the clipped
pixels finds it. Camera-profile pairs leave clipped pixels out too.
Render cache version 20 -> 22.
Quantile matching fits the held-out pixels slightly worse than the conditional-median fit it replaced. On 7 of 1023 archive files (compacts, bridge cameras, early Micro Four Thirds) the look passed the acceptance gates only with the old curve, and the quantile curve lost it. The search now runs with the quantile fit first (all pixels, away from edges, with and without the camera profile) and, only when no look passes, runs again with the earlier conditional-median plus isotonic fit, restored as fit_tone_conditional. The gates are unchanged, so no file loses its look relative to the previous fit, and the quantile curve is used wherever it passes. Tests: the flat-step test stays; a synthetic photo whose JPEG carries per-pixel noise misses the gates with the quantile fit and passes with the fallback, and the order of the two is checked.
The per-photo look fitted its tone curve as the camera JPEG's median per
scene-luminance bin followed by isotonic pooling. On lens-corrected, sharpened
JPEGs of busy scenes neighbouring bins disagree by more than the gap between
them, so pooling produced flat steps: 13 of 31 segments on a CC0 Z 6II sample,
flat segments in 9 of 13 fitted Nikon Z / ILCE-7 samples. Each step maps a
range of scene tones to one output tone, which renders as posterised patches
with hard edges (issue #475, also #211, #454), and the conditional medians
flatten both ends of the curve.
Quantile matching (sorted scene quantiles against sorted JPEG quantiles) is
increasing by construction and insensitive to misregistration. On the same 13
files no fit has a flat segment, all still pass the gates, held-out RMS within
0.005 of before. Regression test: a misregistered synthetic JPEG.
Samsung SRW raws whose raw IFD (private compression 32769 or 32770) stores the samples uncompressed are decoded from the sensor data: 12-bit samples packed two in three bytes (bit order told apart from the samples; NX5, NX10, NX11 and NX20, NX200, NX210, NX1000, NX1100) and 16-bit little-endian words (EX1, WB2000). CFA layout from the raw IFD's CFAPattern; framing, white balance, black and saturation levels from the maker note (value offsets relative to the note; the vectors are stored offset by its EncryptionKey); a one-direction Exif picture size crops to the centre of the framing; black from dark rows or columns when the file states none; saturation plateau below the stated level wins. Compressed codings (32770 with fewer bytes, 32772, 32773) stay preview only with that reason. srw is imported from folders; SRW takes the file-local camera look. RENDER_CACHE_VERSION 17.
CR2 and PEF decode without a camera colour matrix, like ARW, NEF, RW2,
RAF and CR3, but camera_preview::file_local_look (and the catalog's
relative white balance) left them out, so they rendered with the neutral
fallback matrix: the camera preview showed first, then a flat,
desaturated render replaced it. They now use the same guarded fit and
relative WB; a fit that fails the gates keeps the fallback, so a file can
only stay as it was or move closer to its camera JPEG. Not measured on
the corpus here (raw.pixls.us is blocked from this environment).
RENDER_CACHE_VERSION 15 re-renders cached CR2/PEF thumbnails.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
Found running cargo xtask ci with the corpus on Windows. The two changes
landed on the same day: the Fujifilm test (#282) expected the compressed
X-T20 file to keep the neutral fallback because its fit failed the gates
on all pixels (held-out RMS 0.114 against 0.10). The retry away from
edges (#263, issue #232) fits it (0.056), so the fit is accepted at both
sizes. Expect true.
Decode lossless CRX and version 0x100/0x200 C-RAW in portable Rust,
including sensor geometry, timed white balance and black/white levels.
Verify all sensor samples against six independent M50/R100/R8 references.
Any CR3 decode error falls back to the embedded JPEG, as CR3 files did
before this decoder. The full sensor buffer is allocated only after the
first plane decodes, and plane buffers grow row by row. Tile planes
decode in parallel with a windowed bit reader (24 MP: 424 -> 89 ms
lossless, 519 -> 138 ms C-RAW on an M4).
Add guarded CR3 camera-JPEG colour fitting with independent framing
validation, relative white balance and cache invalidation.
Validation: cargo xtask ci.