Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3/CR2, Pentax PEF, Samsung SRW, Olympus ORF, Epson ERF and Minolta MRW starting look
ARW, NEF, RW2, RAF, CR3, CR2, PEF, SRW, ORF, ERF and MRW decoding supply sensor data and camera white-balance multipliers, but currently no measured camera colour matrix. Treating camera RGB as linear sRGB produces a dark, muted default rendering. The loader now estimates a file-local starting look from that file's embedded camera JPEG, only when a usable camera matrix is absent. All output pixels still come from the RAW sensor data; there is no JPEG replacement or uniform saturation boost.
Colour and tone are separate
A fixed sensor proxy of at most 96×96 is developed with the file's
WB, black/white levels and active crop. The embedded JPEG is decoded
with DCT scaling and a 128-million-pixel input limit (61 MP bodies'
JPEGs, e.g. the ILCE-7RM6 full frame at 66.5 MP, exceed 64 MP; the codec
checks the cap before decoding at full size, about 200 MB transient),
converted through the codec's colour management, and reduced to the same
size. Aspect ratios must agree within 2%. For colour fitting,
near-black, clipped and nonfinite samples are excluded. Learning a
colour matrix requires at least 256 paired pixels, 5% coloured samples
(linear channel spread >0.05) and a nonsingular input covariance with
bounded condition. An existing camera profile supplies the colour model:
fitting only tone still requires 256 pairs and 5% non-monochrome
samples, but permits channel spread >0.005 so dark or dull scenes can
use the known colours. Any tone curve (full, partial or black-and-white
look) also needs some contrast: the scene luminance of the brightest
1/32 of the pairs must be at least 1.2× that of the darkest 1/32
(MIN_TONE_RANGE); below that, the range check refuses the
curve, which leaves only nearly uniform frames. A curve under 1.5× (the
limit until issue #631) must also render the frame's bright areas, which
the fit and the held-out gates leave out (camera JPEG luminance 0.85 and
up), no further from the camera JPEG than the neutral fallback does (in
luminance for a black-and-white JPEG): fitted to a narrow range, it
extrapolates them. A hazy ILCE-7RM4 burst at 1.46 to 1.49 opened with
the neutral fallback, 18 L* darker than its camera JPEG, while the frame
at 1.51 got a look at a held-out RMS of 0.013; with 1.2 they all do.
Across 135 public Sony files the lowest range is 4.0, so none of them
changes.
Two thirds of the usable pixels train a ridge-regularised 3×3
chromaticity correction (luminance-normalised RGB).
This avoids trying to encode the camera's nonlinear tone curve in a
matrix, which left skin grey in the earlier matrix-only estimate.
Coefficients are bounded by 8. A matrix can't follow the camera's
hue-dependent rendering, though: on a photo of a lime shirt among grey
rocks the best matrix rendered the shirt olive (16° hue error), because
the few saturated pixels have little weight against the many dull ones,
and a matrix fitted to the shirt alone breaks everything else. So a
hue/saturation table (5 values × 72 hues × 5
saturations, the DNG ProfileHueSatMap layout with an
sRGB-encoded value axis, applied in linear ProPhoto RGB after the
baseline exposure) is fitted to the residual hue shifts and saturation
ratios after the matrix: Gaussian kernels around each node (2.5° hue,
0.15 saturation, 0.12 encoded value), weighted by saturation, shrunk
toward identity where the photo has few samples, saturation 0 left as
identity so neutrals stay neutral. The value axis matters: on a sunny
waterfall scene dark yellows had to turn ~12° toward orange while
brighter yellow-greens turned ~9° toward green, and one shift per hue
made both worse. The table restores each pixel's luminance (the tone
curve owns luminance; without that, saturation boosts darkened colours
and the table lost on held-out pixels).
The JPEG's colourfulness also depends on brightness, as a camera's
per-channel tone curve makes it: relative to a luminance-only curve,
shadows were up to 1.4× more colourful and highlights above 0.85
luminance bleached to 0.1–0.3×, so warm sunlit rock rendered cream
instead of white (+4 b* in the brightest fifth of the picture). A
chroma curve (8 nodes over display luminance,
CameraTone::chroma) is fitted to that ratio, including the
bright pixels the colour and tone fits leave out, and applied in the
finish stage right after the tone map (CPU and GPU), scaling chroma
toward luminance so luminance is kept. It is kept only when it lowers
the held-out error; smart previews carry it in their header, and older
headers load without one.
Matrix-only and matrix + table looks are each completed (tone curve,
then chroma curve) and compared on the held-out pixels in gamma-encoded
display values: in linear values a slightly missed bright rock
outweighed a clearly wrong dark shirt. The acceptance gates below still
use linear values. Luminance is fitted separately, by quantile matching:
knot i pairs the median of the i-th 1/32 of the sorted
scene luminances with the median of the i-th 1/32 of the sorted
JPEG luminances. A tone curve is monotone, so it maps each scene
quantile to the same JPEG quantile; this needs no pixel-exact
registration and is increasing by construction. The earlier fit (the
JPEG's median per scene-luminance bin, then pooled-adjacent-violators
isotonic regression) broke on lens-corrected, sharpened JPEGs of busy
scenes: neighbouring bins disagreed by more than the gap between them,
pooling turned them into flat steps (13 of 31 segments on a CC0 Z 6II
forest shot) that rendered as posterised patches with hard edges, and
regression toward the mean flattened both ends of the curve (issue #475,
also #211, #454). On the 1,781 files of the CC0 archive that render, 384
of the 1,023 accepted fits had at least one flat segment (1,073 in
total, up to 14 of 31); with quantile matching 1,017 of the 1,023 have
none. The quantile fit is slightly worse by least squares than the
conditional medians (median held-out RMS 0.0331 against 0.0330, worst
+0.013), and on 7 files (compacts, bridge cameras, early Micro Four
Thirds) the look cleared the acceptance gates only with the old curve.
The search therefore runs once with the quantile fit (all pixels, away
from edges, with and without the camera profile's colour) and, only when
no look passes, once more with the earlier conditional-median fit, kept
as fit_tone_conditional; the gates are the same, so no file
loses its look. Those 7 files render exactly as before, and 6 of them
still have flat segments (35 in total). The curve preserves black,
cannot reverse, and extends beyond observed highlights with a bounded
shoulder.
Clipped sensor pixels are left out of the fit. A raw
pixel with a channel at the clip level (0.99 of white, as in highlight
reconstruction) no longer records the scene's colour: a white overcast
sky with the green channel clipped and red and blue still rising comes
out magenta or blue through the camera matrix, while the camera JPEG
shows it white. As training pairs such pixels pulled the colour matrix
toward grey and made the chroma curve remove colour at their display
luminance. On the CC0 Z 6II forest sample (raw.pixls.us 4160) 24% of the
proxy pixels are clipped; every chroma sample above display luminance
0.36 was one of them, and the curve cut colourfulness there to 13-19%
([1.10, 0.94, 0.69, 0.19, 0.13, 0.19, 0.89, 1.0]); the
colours of the forest were muted by the matrix as well (spread of a*/b*
on the proxy 63% of the JPEG's before the curve, 48% after). Over the
940 accepted looks with a chroma curve, 37% of the curve's downward pull
came from clipped pixels whose JPEG pixel is near neutral, 53% in the
245 curves with a node below 0.4. The proxies therefore carry a clip
mask (any sensor sample a proxy pixel covers, aligned with the CR3
framing), and the look is refitted without those pixels. The search on
all pixels still decides whether a photo gets a look and which attempt
(tone fit, profile or own colour, all pixels or away from edges) gives
it; only that attempt is refitted, and when the refit misses the gates
the look on all pixels is kept, so no photo loses its look or switches
attempt. A photo without a look gets one when the search without the
clipped pixels finds it. Camera-profile pairs (calibrate)
leave clipped pixels out too. On the 1,781 files of the CC0 archive that
render: 1,023 looks before, 1,027 after (none lost, 4 gained); 618
renders change (all with a look), 8 keep their earlier look through the
fallback; median CIEDE2000 to the camera JPEG over the changed files
3.24 -> 3.07 (worse by more than 0.5 on 3, better by more than 0.5 on
59); median colourfulness (mean C* of the render over the camera JPEG's)
0.973 -> 0.982 over the files with a look. The rest of the chroma
curve's pull is mostly misregistration (a near-neutral JPEG pixel with a
matching colour within one proxy pixel: 21%); a registration-free
estimate of the curve (band means of colourfulness by each image's own
luminance) brought the median colourfulness ratio to 0.999 but did not
lower CIEDE2000, and is not used.
The matrix is applied to scene-linear sensor pixels. The curve is
carried in SourceInfo and applied in the pipeline finish
stage, after exposure/WB/local edits. It is never baked into decoded
luminance or clipped to JPEG precision. CPU and GPU use the same tone
LUT. Contrast, whites and blacks adjust the starting look through the
display-domain controls. JPEG neutral rendering and the existing DNG
colour model are unchanged.
One third of the pixels are held out from both fits. The combined model must reduce linear-display squared error by at least 30% and have per-channel RMS error ≤0.10 (0.055 rejected half of the public raw.pixls.us samples tried — ILCE-6400, -6700, -7M4 — whose fits were still 1.5–3× closer to the camera JPEG than the fallback and visibly better). When the fit on all pixels fails these gates, it is tried once more, with the same gates, on the pixels away from edges (where the 3×3 neighbourhood in either proxy spans a luminance ratio above 3). Many camera JPEGs are lens-corrected while the raw is not (Sony "Distortion Comp.: Auto", compacts and kit zooms), and on a detailed scene the misaligned edges alone fail the gate although the colours agree everywhere else (issue #232). Sony's embedded lens tables now correct ILCE-7RM6 renders under Enable Lens Corrections (Sony ARW lens corrections), but this fit still matches its pairs without them (the sensor proxy is uncorrected); aligning the pairs first is a follow-up. The public ILCE-7RM2 samples (raw.pixls.us, one façade shot in seven compression/correction variants, 24–70 mm at 51 mm) are up to 15 px of 1440 off their JPEG; the six with distortion correction on missed the gate at held-out RMS 0.108–0.115 and opened in the grey neutral fallback, 13 L* too dark (ΔE76 16.6–16.9 against the JPEG after registering its geometry). Away from edges they pass at 0.049–0.052 and land at ΔE 3.7–3.8 with lightness within 0.6 L*, like the variant shot with distortion correction off (0.059 on all pixels, ΔE 3.9). The corpus GX80 kit-zoom sample (up to 63 px of 1440 off) passes too: 0.107 → 0.081, ΔE 19.1 → 7.9. Fits accepted on all pixels are unchanged. These are provisional acceptance gates, not a colour-accuracy certification. Singular, unrelated and aspect-mismatched references retain the documented fallback; poor-fitting ones get the partial look below when it is clearly closer to the camera JPEG, and black-and-white ones only their tone curve (below). Both fits use the same fixed proxy at every decode resolution.
Lens-corrected camera JPEGs. Some raws carry the
camera's own lens correction (Fujifilm and Panasonic tables, the
ILCE-7RM4A, Nikon's NikonNEFInfo) and the camera often applies it to its
JPEG. Nikon Z lenses always do, and that JPEG can be zoomed in by up to
9% to fill the frame. The fit used to pair the uncorrected sensor with
the corrected JPEG, so near the edges it compared pixels from different
parts of the scene. Now the sensor proxy is also warped with the file's
correction (into_lens_geometry, pixels that land outside
the picture are dropped), and the fit uses whichever of the two lines up
better with the JPEG, going by the correlation of their high-passed log
luminance. Files whose JPEG isn't corrected keep the plain proxy. On the
29 CC0 corpus raws with a warp the alignment goes up by 0.13 on average
(Panasonic GX80 0.41 to 0.83, Fujifilm X-T20 0.44 to 0.95). Mean ΔE
against the camera JPEG over the 35 corpus raws of those formats goes
from 3.73 to 3.61, and on 299 private Z 6II photos with Nikon's
correction read it goes from 3.22 to 2.68.
A partial look when the full look misses the gates.
The neutral fallback (camera RGB taken as sRGB, the default tone map) is
far from the camera JPEG. Rendered for every photo on this path in the
CC0 archive (1,150 files with a camera JPEG), it is 10.7 L* too dark
(median; about one stop), keeps 0.36 of the camera's colourfulness and
has hues 7° off on the coloured pixels (median CIEDE2000 13.6). Its
white balance is right: on the pixels the camera renders near-neutral
the cast is under 1 a*/b* (median, every maker). The "blue" fallback on
newer Sony bodies (Δb* −22 over the whole frame on the ILCE-7RM5 sample)
is a warm scene losing its colour, not a cast: its near-neutral pixels
are within −1.6 b*. So before falling back, a partial look is fitted
(fit_partial): the quantile tone curve (registration-free,
the largest part of the error), the chroma curve, and, when the scene
has enough colour to learn a matrix (the full look's condition), the
photo's own colour matrix at half weight, the other half the identity
(PARTIAL_MATRIX_WEIGHT). No hue/saturation table. Half
weight because, on the held-out proxy pixels of the 23 colourful photos
without a look, the full matrix left 2 of them more than 2° further from
the camera's hues than the neutral fallback and half weight none (median
hue error 9.6° with the generic colour, 5.1° at half weight, 4.2° at
full weight; on the 1,080 photos with a look 3.9° and 2.7°). Without a
matrix (tone and chroma curve alone, or with one global saturation
scale) about a third of all photos ended up more than 2° further from
the camera's hues than the fallback, as the added colour makes the
generic matrix's hue errors visible. The partial look is tried on all
pixels, then away from edges, and used only when it cuts the held-out
squared error below 0.7 of the neutral fallback's in linear and in
gamma-encoded display values, and when the JPEG's luminance ranks the
pixels like the scene's (Spearman ≥ 0.5,
PARTIAL_MIN_RANK_CORRELATION): a curve matched to the
brightness of an unrelated picture also beats the dark fallback pixel
for pixel. The accepted full looks have 0.55 or more; three old Coolpix
files whose raws decode wrongly have 0.07–0.27 and keep the fallback.
There is no limit on the partial look's own error. On the archive (1,783
renders): all 1,087 full looks are byte-identical; of the 55 photos that
reach the partial fit, 49 get it (40 on all pixels, 9 away from edges;
13 with the damped matrix, 41 with a chroma curve) and 6 keep the
fallback (lens-corrected compacts: RX10, TZ80 1:1, D-LUX 4, G10 16:9,
GF2 ×2). On the 49, median CIEDE2000 to the camera JPEG 14.0 → 2.1, ΔL*
−11.5 → +0.6, colourfulness 0.36 → 0.87, hue error 10.1° → 7.1°; every
one is closer, by at least 1.3. Most of the others without a look never
reach a fit: on 48 files (Nikon D4, Df, D300/D300S, D7000, D5100 and
others) the camera JPEG proxy comes out a pixel narrower than the sensor
proxy and no pairs are collected (the resize in PR #531 fixes this; with
it 46 of them pass the full look's gates, CIEDE2000 17.9 → 2.9).
A tone curve from a black-and-white JPEG. A raw shot
in a monochrome picture style (Canon Monochrome, Sony B/W, Nikon
Monochrome) records full colour, but its camera JPEG is grey, so neither
the full look nor the partial look has colour to fit. Such a photo
started from the spectral matrices or the neutral fallback with the
default tone curve: on a Canon EOS RP, about 1.3 EV darker and flatter
than the same body's photos shot in colour (issue #577). The grey JPEG
still shows the camera's tone curve. When no look was fitted and every
pixel of the JPEG proxy but one in a thousand has its channels within
0.002 of each other (monochrome; a sepia-toned JPEG has
colour and isn't one), the quantile tone curve is fitted alone,
luminance against luminance, on top of the colour the photo gets
otherwise: the model's camera profile when it has one (in
fit_preview, like the profile's colour looks), else its
spectral matrices or the neutral fallback's
(monochrome_look). The chroma curve stays neutral. It has
the partial look's checks: the JPEG must rank the pixels like the scene
(Spearman ≥ 0.5, with tied values sharing their rank, so a flat grey
JPEG relates to nothing), and the curve must cut the held-out squared
luminance error below 0.7 of the default curve's, in linear and
gamma-encoded values, on all pixels or away from edges. Sony's DRO is
handled as for colour JPEGs, and white balance stays relative to the
as-shot look. The check reads pixels, not the picture style: a colour
JPEG of a scene so colourless that every pixel is grey within 0.002
counts too (such a photo gets no look today either). Photos whose JPEG
has colour render as before.
Deep shadows and the camera toe
The colour pairs exclude JPEG luminance below 0.015 (about L* 13).
Using those same pairs for tone ignored the camera's darkest tones.
Below the first knot, CameraTone::apply could only
interpolate in a straight line to black, lifting shadows (#1307).
Tone fitting now also tries a separate luminance sample set from the same binned proxies. Each sensor channel must be finite and between 0.0001 and 1.5; JPEG channels must be finite and nonnegative, with luminance between 0.0003 and 0.85. The lower JPEG floor is about one 8-bit sRGB code above black. It leaves compression-clipped black samples out of the fit. Luminance does not need the colour fit's per-channel ratios or its JPEG channel floor. The existing clip mask and, on an attempt away from edges, the same edge exclusion still apply.
The wider set needs at least 96 dark samples (JPEG luminance below 0.015), including at least 32 held-out dark samples. Two thirds train the curve, with the existing quantile medians or conditional-median fallback. Extra samples are split separately, so the wider set cannot put original colour holdouts into tone training. The held-out dark samples must have luminance rank correlation of at least 0.5, with ties sharing their rank. When the original curve can be fitted, the candidate must reduce its held-out dark error by at least 1%, measured as squared gamma-encoded luminance error. Sparse, flat, uncorrelated or invalid shadow samples keep the original curve.
If the original curve cannot be fitted, the wider curve can still be accepted after the sample-count and rank checks. In either case, the completed look must pass its existing held-out gates: a full look needs finite errors, linear-display squared error below 70% of the neutral fallback's and per-channel RMS no greater than 0.10. Partial and monochrome tone-only looks need luminance rank correlation of at least 0.5 and finite squared errors below 70% of the fallback's in both linear and gamma-encoded values. The monochrome comparison uses luminance; the full and partial comparisons use colour channels. Colour-pair selection, colour fitting and the original colour training split are unchanged.
A near-black frame can have fewer than 256 colour pairs, so none of those attempts reaches tone fitting (#1587). After all existing looks fail, a tone-only fallback uses the wider signal floors above. At least 90% of the JPEG proxy must be below luminance 0.015. It excludes clipped sensor pixels from training and refuses invalid samples and JPEG highlights at or above 0.85. Every clipped pixel must stay at least as close to its JPEG luminance as the default curve leaves it. At least 256 usable pairs must show luminance rank correlation of 0.5 or more. Two thirds train a quantile curve; the held-out third must reduce squared luminance error by 30% in both linear and gamma-encoded values. This fallback keeps the source's spectral or neutral colour and learns no matrix, hue/saturation table or chroma curve. Earlier accepted looks keep their result.
The extra dark knots move the straight-line extension down toward
black without a new serialized curve format or a different CPU/GPU
interpolation rule. LOOK_VERSION 12 refreshes older fitted
smart previews when their originals are online;
RENDER_CACHE_VERSION 69 redraws cached renders.
No process-version bump is needed under the rule for changes to source data.
The decoded source carries the fitted camera tone in
SourceInfo; both V1 and V2 use it in
finish::base_tone. Existing raws edited under V1 or V2 can
therefore change when decoded again. Offline smart previews keep their
stored pixels and tone until rebuilt from the original. The scan on
library opening runs once per look version for each smart previews
folder. If an original was offline at that scan, run Build Smart
Previews after reconnecting it.
Compared with main 701f78ea at a 1200-pixel long edge,
all 15 public raw.pixls.us files across six makes and the private #1021
file retain their accepted look. The darkest 10% moves closer to the
JPEG on all seven public deep-shadow files (at least 10% below L* 13)
and #1021. On Sony RX100 V sample 1324, its mean L* changes from 4.79 to
2.13 against the JPEG's 2.14; on a6700 sample 6735, 7.20 to 5.23 against
4.60. On #1021 (numbers only), 9.28 to 3.25 against 3.50; the 10th
percentile changes from 11.94 to 5.34 against 5.45, and mean CIEDE2000
at 200 pixels changes from 7.25 to 6.37. Five of eight controls are
byte-identical. The largest control changes are on a7 IV sample 6928,
which still has 6.8% below L* 13: mean CIEDE2000 improves by 0.048,
whole-frame mean L* falls by 0.149, and the 5th percentile falls by 1.56
L*. This is a limited camera-JPEG comparison, not a claim of colour
calibration or feature parity with professional tools.
Source metadata and white balance
Previously SourceRef::load() discarded the decoder's
SourceInfo, and render jobs used header-only facts
(including a hard-coded RAW WB). Decoded pixels and source facts now
travel and remain cached together, including across web-worker renders.
Smart previews keep the tone curve in their header (the matrix is
already in their pixels), so an offline original renders and exports
with the same look; proxies built before this fall back to the generic
curve until rebuilt. Pixel-statistics/colour-sampling commands use
decoded facts too. Render cache version 6 invalidates old thumbnails and
loupe renders.
Vendor RGB WB multipliers alone do not identify an absolute illuminant without camera calibration. The generic model inferred approximately 6829 K / −127 tint for one Sony sample; changing only temperature retained that spurious tint and caused a pink cast. Uncalibrated ARWs, NEFs, RW2s and RAFs keep the file's vendor WB at decode and expose relative WB controls around the as-shot look. 6500/0 is the internal neutral reference of that relative scale, not a claim about the capture's measured Kelvin temperature. Presets, picker/auto and manual controls use this reference consistently. Switching from As Shot to Custom first resolves both controls; changing one cannot inherit stale catalog tint. Reset restores the current as-shot reference. Old As Shot numbers in a catalog do not mark an otherwise unchanged photo as edited; Custom WB remains an edit.
Kelvin through the camera's measured matrices (issue
#730). A raw of a camera in imago_raw::spectral
(52 bodies: ILCE-7M3, ILCE-7M4, ILCE-7RM3, D850, EOS 5D Mark III…) edits
white balance in Kelvin like a DNG, whichever look it got: the matrices
read its as-shot multipliers as a temperature and tint
(spectral::as_shot_white_through, the DNG
AsShotNeutral reading with the model's
ColorMatrix1/2) and the Temp slider runs 2000–50000 K with
As Shot at that reading. The matrices do nothing else: the colour look
stays the file's JPEG fit (or the profile, or the spectral fallback),
and the picture is adapted from the as-shot white to the chosen one with
Bradford, as every fitted look is — not re-developed in camera space as
a DNG is. That keeps one adaptation model per camera whether or not a
photo's fit was accepted, and it is what makes the upgrade exact: white
balance written on the relative scale before this (no scale
in the settings' wb, WbScale::Legacy) renders
as the composition of the two Bradford adaptations, which is again one
Bradford adaptation (imago_pipeline::local::legacy_white),
so existing edits are pixel-identical; they are converted to Custom
Kelvin values the first time they are edited
(normalize_wb). The decision is one function on both sides
— imago_catalog::relative_wb_camera(format, camera) from
the catalog's camera string and files::WbScaleOf from the
file — and a test keeps them in step. A photo imported before this keeps
the relative scale's 6500 / 0 as its recorded as-shot white until its
file is next decoded for display or reloaded
(Op::SetAsShotWb, bookkeeping); only the readout waits, not
the look. Raws of cameras without measured matrices stay relative,
unless their own white-balance presets give the scale (next).
Kelvin through the camera's white-balance presets.
Sony ARWs store, in the encrypted SR2SubIFD, the
multipliers of every white-balance preset, five of them at a stated
temperature (2500, 3200, 4500, 6000 and 8500 K). For a camera without
measured matrices whose files carry them
(imago_raw::kelvin_presets: Sony ILCA-99M2, ILCA-77M2,
SLT-A33, DSC-RX10, ILCE-3500, ILCE-7S) they take the matrices' place in
everything above: the as-shot multipliers are placed on the curve
through the five presets (mired linear along it, in log red/green and
blue/green), an offset from the curve is the tint, and the decision, the
Bradford adaptation, the pixel-identical upgrade and
Op::SetAsShotWb are the same. The scale is Sony's own:
files shot on the Kelvin setting read back what the dial said (ILCA-77M2
3400 K → 3396 K and 5200 K → 5193 K, SLT-A33 4700 K → 4708 K, the public
ILCE-3500 sample's 5600 K → 5594 K; with the colour filter one step
towards magenta, the SLT-A33's 3400 K reads 3473 K / +11), and the Shade
preset reads 7493 K on the public ILCE-7S sample. Off the curve, two
constants come from the 14 Sony bodies that have both measured matrices
and presets: the tint is the offset scaled by the locus's own spacing
(the matrices give 0.90–1.04 of it), and the isotherms lean against the
curve's normal (ISOTHERM_TILT 0.74, measured 0.59–0.84). On
150 ILCE-7SM2 photos read both ways, the presets' temperature tracks the
matrices' to ±4 mired around Sony's 8 % higher scale, and the tint to −9
± 4. A listed camera whose file has no presets keeps the Kelvin scale
with As Shot at D65, as a covered camera without multipliers does.
Bodies with measured matrices keep reading through them.
Canon CR2s list their presets by name in the maker note's
ColorData (0x4001), each with Canon's colour
temperature for it: for versions -4 and 13–15 the curve is built the
same way from the Daylight, Shade, Cloudy, Tungsten and Flash rows
(sorted by temperature; Fluorescent, a green light off the locus, and
the Kelvin row, which holds the dial's last setting, are left out). Only
the PowerShot G1 X Mark III is enabled, checked on a reporter's nine
photos, one per white-balance setting: the as-shot levels read 4160 /
4580 / 5995 / 5215 / 2816 / 3703 / 7162 / 5368 / 4813 K (Auto, Daylight,
Shade, Cloudy, Tungsten, Fluorescent at tint +31, Flash, Underwater,
Kelvin 5500) where the camera's own ColorTempAsShot says
4306 / 4703 / 6005 / 5272 / 2770 / 3533 / 7100 / 5347 / 4925 K. Those
photos were shot with the white-balance shift 8 steps (of 9) towards
blue (WBShiftAB −8), which is why a Daylight shot reads
about 4600 K and not the Daylight preset's 5651 K: the levels the camera
applied are bluer than the preset's, and the camera's own number
agrees.
What the matrices say is not verified against a professional photo organizer's Kelvin. On the ILCE-7M3 a Color Temp. series reads 7–10 % below Sony's dial through them, smoothly and monotonically, with tint near the locus (issue #730); whether the matrices or the dial are off is open (a grey card under an incandescent bulb would tell, since the Illuminant A matrix is exact there by construction). Public samples: ILCE-7M3 AWB 4268 K / +7, ILCE-7M4 AWB 4344 K / +26, GFX100S on its Daylight preset 4659 K / +9 (Fujifilm's nominal daylight is about 5300 K: the same direction and size of gap).
Sony's proprietary ColorMatrix is not assumed to be a
DNG XYZ-to-camera matrix. No the vendor profile/matrix or GPL
decoder/calibration source is used. The product remains pure Rust.
Validation and scope
Synthetic tests cover colour with a nonlinear camera tone, preserved scene-linear headroom, invalid/singular/unrelated references, monotone black-preserving curves, hostile serialized knots, decode-to-render/cache metadata propagation, old-catalog WB transitions and CPU/GPU tone equivalence. Private photos are never committed or uploaded.
Five local Sony ILCE-6700 ARWs were accepted. Held-out per-channel linear-display RMS values were 0.04244, 0.03349, 0.04552, 0.02871 and 0.03100. All five unedited supplied JPEG exports stayed pixel-identical to the installed pre-change renderer at equal size/quality. Four supplied JPEGs use Display P3 and two have a narrower crop than the RAW, so they are visual references rather than aligned pixel ground truth.
Public CC0 samples (raw.pixls.us; 10 files, ILCE-6000, -6400 ×2, -6700, -7M3, -7M4 ×2, -7RM4, -7SM3, DSC-RX100M3), rendered at 1200 px and compared with each file's embedded JPEG (mean CIE76 ΔE at 200 px): 7 fits accepted, ΔE 17–26 → 3–10 with matching mean lightness. (The RX100M3 was rejected at the time because the decoder read neither its white balance nor its black level and its render was green; since issue #148 its fit is accepted, held-out RMS 0.153 → 0.087.) Rejected and unchanged: an ILCE-7RM4 dusk scene whose fit was worse than the fallback (ΔE 7.6), and a reduced-size lossless ARW (no fit attempted). Saturated hues can still differ (an orange-red hull renders yellow). With the hue/saturation table, 28 local ILCE-7M4 ARWs (one month of photos, one in 24 picked) went from mean ΔE 4.74 to 4.27 against their camera JPEGs (200 px, CIE76); 27 accepted a table, none got worse overall by more than 0.1, and a few saturated regions moved away (12.4 → 15.2 on one). The lime shirt's hue error dropped from 16° to 6–8°. With the value axis, the chroma curve and gamma-domain selection, the same set plus the shirt photo (29 files) went from mean ΔE 4.23 to 3.54 (4.74 before the table), every file closer than before, saturated areas 7.65 → 6.41; all 29 now use a table. A small object stays hard: in a second shot of the lime shirt only 7 of the 6144 proxy pixels show it, so the table can't learn its colour and the shirt still renders yellower than the JPEG.
Nikon NEF
The same fit, gates and relative WB apply to NEF/NRW (issue #150: NEFs rendered muted and greenish). Public CC0 samples from raw.pixls.us (D750 12/14-bit, D780 12-bit, D850 12/14-bit, D7500 12/14-bit lossless and lossy, Z 50 12-bit) plus five local D7500 shots of an indoor event, rendered at 1200 px and compared with each file's embedded JPEG (mean CIE76 ΔE at 200 px): all 13 decodable files accepted, held-out RMS 0.11–0.24 → 0.01–0.09, ΔE 13–46 → 2.9–7.8 with matching mean lightness. The exception is a mixed-light D7500 scene (faces lit by a purple screen): accepted (RMS 0.156 → 0.057) but only ΔE 12.7 → 12.0, rendered warmer and lighter than the camera JPEG; the linear-display error the gate measures is dominated by the bright screen. Files that still open as the embedded preview (lossy-after-split, uncompressed data labelled compressed) are not fitted.
Measuring this found a decoder bug: 12-bit NEFs store maker note
0x003d BlackLevel in 14-bit units (D750 600, D780/Z 50
1008, D850/D7500 400, while their darkest samples are 150 / ~252 / 99),
so 12-bit files rendered nearly black or with crushed shadows before any
fit. The decoder now scales it to the sample depth; the fits for the
same D7500 scene at 12 and 14 bits then agree (held-out RMS 0.042–0.049
vs 0.050–0.054; before, the 12-bit fits had to desaturate to
compensate).
On one sample, Exposure +1 EV, Saturation +100, Contrast +50,
warm/cool WB, tint, highlights and shadows all changed the output. The
same matrix and curve were selected for 400-pixel and full-resolution
renders. Headless UI checks wait until the loupe reports
source: render and no jobs remain; the camera JPEG stand-in
does not count as verification.
Panasonic RW2
The same fit, gates and relative WB apply to Panasonic RW2, Leica RWL
and the older Panasonic RAW files (all decode as
RawFormat::Rw2; white balance from tags
0x0024–0x0026). Their embedded JPEG
(JpgFromRaw, 1920 px wide) always shows the whole active
area, while the default crop is the aspect ratio set in the camera (4:3
on the 3:2 sensors, 1:1, 16:9, 2.71 on the S1R II…); when the preview
matches the active area rather than the crop, the reference is cropped
to the default crop before fitting.
Public CC0 samples from raw.pixls.us (178 files from 118 Panasonic
and Leica bodies; the four from 2005–2007 carry no preview), rendered at
1200 px and compared with each file's embedded JPEG (mean CIE76 ΔE at
200 px): 140 of 174 fits accepted, from 93 of the 114 bodies with a
preview, median ΔE 5.0 (1.2–21.3) with matching mean lightness (median
|ΔL*| 0.4); 124 of them use a hue/saturation table. (Fitted with the
matrix and tone curve alone, 137 were accepted at median ΔE 6.1; every
one of those is as close or closer with the table and chroma curve, by
0.75 on average.) On the 14 corpus files with a preview the neutral
fallback scored ΔE 9.6–27 and rendered up to 16 L* too dark; 13 fits
were accepted, ΔE 2.8–8.9, lightness within 2 L*. Of the 34 files left
on the neutral fallback, 21 fail the gates, almost all compacts and kit
zooms (TZ/ZS, FZ2500, D-Lux, V-Lux, GF2, GX80 with its 12–32 mm): their
camera JPEG is distortion-corrected, the pixel pairs no longer line up
and the held-out RMS stays above 0.10 (GX80: 0.138 → 0.107); two S1 II
shots just miss it (0.103, 0.107). The other 13 never reach the gates,
mostly nearly colourless scenes (grey walls, concrete: under 5% coloured
pixels) or dark ones (a night sky, a near-black frame). Panasonic's
embedded distortion data (tag 0x0119) now corrects the
render under Enable Profile Corrections (issue #256) but does not yet
align the pairs of this fit, and imago-cli calibrate
doesn't collect RW2 files: the misaligned pairs of distortion-corrected
JPEGs would distort a pooled profile.
Fujifilm RAF
The same guarded fit and relative WB now cover uncompressed, lossless compressed and lossy compressed RAFs, both X-Trans and Bayer. The shared loader serves desktop, CLI, web, export and smart-preview generation. Render cache version 12 refreshes older neutral-colour renders after an updated app is started; existing preview-only catalog entries still need Photo → Reload from Disk.
Verification used the seven supplied X-H2S/X-T4 files and 17 CC0 compressed RAFs (13 camera bodies, including 14/16-bit GFX). Renders at 1200 pixels were compared with each file's embedded JPEG at 200 pixels, in matching orientation, using mean CIE76 ΔE. Without pooled profiles, twenty fits were accepted and every accepted render improved: mean ΔE 16.70 → 4.13. The 16 accepted public files averaged 18.30 → 4.41; the four supplied X-T4 files averaged 10.30 → 3.02. The most difficult accepted public example, GFX100RF, remains at ΔE 11.54, so this is not a parity claim.
The additional supplied folder contains 748 RAFs. Profiles were fitted separately from 201 usable X-H2S and 545 X-T4 photos (722,133 and 1,951,939 colour pairs); one X-T20 file is insufficient for its own profile, and one reference was unusable. The two aggregate profiles are bundled in desktop, CLI and web builds; no private photographs are shipped. Other Fujifilm models use the same guarded per-file fit without a camera-name allowlist or borrowed matrix.
For validation, every fifth usable photo of each model in sorted path order was withheld. Profiles trained on the remaining 160 X-H2S and 436 X-T4 photos were tested on 41 and 109 withheld photos. Mean CIE76 ΔE against the camera JPEG was 9.86 → 1.70 with per-file fitting → 1.62 with the pooled X-H2S profile, and 13.64 → 2.43 → 2.34 for X-T4. The profiles improved 108 of 150 files; the largest regression against per-file fitting was 0.60 ΔE, with no previously accepted fit rejected. These are withheld files from the same limited scene collection, not independent coverage of all subjects or illuminants.
The three original dark X-H2S wrench files have too little colour variation to learn a matrix, but now use the known profile to fit tone: ΔE 8.74 → 2.25, all accepted. The four original X-T4 files and two public X-T4 files remain accepted with the final bundled profile; they regress slightly against their own per-file fits (six-file mean 4.72 → 4.94), while remaining much closer than the neutral fallback (13.47). The public X-T20 reference fails the gates on all pixels (held-out RMS 0.114) and passes on the second attempt away from edges (0.056, from 0.185 uncorrected), so it is fitted too.
RAF inputs also work with imago-cli calibrate. Tests
cover real X-Trans/16-bit Bayer fits, profile use on dull scenes,
per-file fitting after profile rejection, monochrome rejection,
resolution independence, header/decoded relative WB, old-catalog WB
transitions and invalid RAF calibration inputs. A full-size 6240×4160
X-T4 JPEG export also succeeded.
These are camera-JPEG look estimates, not measured spectral calibration or feature parity with professional tools. Camera local tone, picture styles, noise reduction and lens warps cannot all be reproduced by a global matrix/curve; same-aspect crops may evade the aspect check. Skin, mixed-light scenes and saturated highlights can still differ from a supplied JPEG. Broader Sony, Nikon, Panasonic and Fujifilm model/lighting coverage, chart-based calibration, absolute WB and other manufacturers remain unverified. Rejected fits and other RAW formats retain their existing behaviour.
Canon CR3
CR3 uses the same colour gates and relative WB. Its sensor and JPEG proxies are first oriented identically. Canon can crop its JPEG differently from the sensor; no lens distortion correction is applied to the sensor proxy, so a strongly distortion-corrected JPEG can fail the gates. A fixed 96-pixel proxy measures this remaining framing from luminance-gradient directions: scale is limited to 0.9–1.1 and translation to two proxy pixels. At least 192 edges are required; two thirds train the framing and the remaining third must independently reach direction agreement ≥0.90 with an improvement ≥0.04. At least 80% of each edge set must remain usable. Weak, flat, unrelated or already aligned references keep the sensor framing. This alignment affects training correspondences only; the RAW render still uses the actual sensor pixels.
On the public CC0 EOS R100 / RF-S 18–45 mm sample, the measured framing scale was 0.968. Held-out edge agreement rose from 0.7291 to 0.9194, and the unchanged colour gates accepted the fit (held-out RMS 0.10625 → 0.07704). The corpus test asserts acceptance after decoding the sensor RAW, and synthetic tests cover nonlinear camera tone, known crop/translation and unrelated references. The matrix, tone and hue/saturation table are estimated from the file; no third-party camera matrix or decoder calibration source is copied.
This is a per-file camera-look estimate, not measured spectral calibration or feature parity with professional tools. Camera local tone, picture styles, noise reduction and lens warps cannot all be reproduced by a global matrix/curve; same-aspect crops may evade the aspect check. Skin, mixed-light scenes and saturated highlights can still differ from a supplied JPEG. Broader Sony, Nikon and Canon model/lighting coverage, chart-based calibration, absolute WB and other manufacturers remain unverified. Rejected fits and other RAW formats retain their existing behaviour.
Sony ILCE-7RM6 (A7R VI)
Full-frame A7R VI files never reached the fit: their 66.5 MP embedded JPEG exceeded the fit's former 64 MP preview cap, so they opened in the neutral fallback. The cap is now 128 MP. On the full-frame corpus file the holdout RMS went from 0.124 to 0.062, and the render's mean RGB from 85/87/77 to 104/117/79 against the camera JPEG's 108/122/82.
On a local set of 54 ARW6 and lossless ARW photos (the maintainer's
own shots plus the 12 CC0 RawDB ILCE-7RM6 samples), 46 were fitted and 8
were rejected by the gates; the kinds of scene behind the rejections
were not analysed. On 15 withheld photos (every third usable photo in
sorted path order), mean CIE76 ΔE against the camera JPEG was 5.7
(render at 1200 px, comparison at 200 px, matching orientation); in a
separate sanity check at the same settings, the APS-C corpus file scored
5.1 and two portrait RawDB files 4.6 and 6.0 (orientation-matched).
These numbers were measured before the ARW6 companding curve was
replaced by its clean-room derivation
(docs/arw6-compression.md), which decodes highlights about
0.3 % lower; the fit was not re-measured.
A pooled profile was tried and is not bundled. Pooling the 46 photos (158,549 colour pairs) and validating on withheld photos (every third photo, N = 15, trained on the other 31) took mean ΔE from 5.71 with per-file fitting to 6.40 with the profile: 11 of 15 photos regressed, 3 improved and 1 was unchanged, the largest by +4.68, and 2 of the 8 rejected files were newly accepted. Per-file fitting therefore ships for this body. The 42 private photos are one photographer's scenes, 38 of them in the Standard creative style (the 12 RawDB files were not inventoried); revisit when a varied multi-photographer set exists.
DNG previews with embedded lens corrections
A DNG with its own colour model and no non-identity profile tone
curve uses the existing camera_preview::fit_dng_tone path,
including its eligibility and acceptance checks. There is one preview
fit. Files with a look table, a gain table map or an unusable preview
keep their existing behaviour.
When the file carries lens opcodes, that fit develops its sensor proxy through those opcodes before reducing it: its camera preview already contains their distortion and vignetting corrections. This uses the raw decoder's existing opcode implementation, including chained warps. The separate proxy vignetting multiplier is skipped in this case. The editable source still has lens corrections removed, so the pipeline applies them once through Enable Profile Corrections. Developing this proxy requires a full-resolution demosaic; files without such opcodes keep their binned proxy.
Camera profiles (pooled per model)
One photo can show too little of a colour for its own fit to learn
it: in a second shot of the lime shirt only a few dozen proxy pixels
show it, next to a hillside of foliage at the same hue that the camera
renders differently, and the shirt stayed yellow (hue 68° against the
JPEG's 82°; a 192-pixel proxy only reached 74°).
imago-cli calibrate [--max N] [--out DIR] FOLDERS…
therefore pools the colour pairs of many ARWs, NEFs or RAFs per camera
model — each against its own embedded JPEG, at a 192-pixel proxy, at
most 4000 pairs per photo, files spread evenly over the folders — and
fits one matrix (from white-balanced camera RGB, so no single photo's
white point is baked in) and one hue/saturation/value table. Models need
at least 5 usable photos.
Profiles are JSON files <model>.json in
$IMAGO_CAMERA_PROFILES, else
<config>/camera-profiles (macOS
~/Library/Application Support/Imago/camera-profiles); a
local profile replaces a built-in one. Built-in profiles live in
assets/camera-profiles/ (listed in
assets/ATTRIBUTION.md) and are compiled in, so the app, CLI
and web build share them: ILCE-7M4, fitted to 597 photos (2.2 million
colour pairs) shot in 2026, mostly with the Standard creative style and
DRO Auto; ILCE-7CR, 125 photos, and ILCA-99M2, 144 photos (both below);
X-H2S, 201 photos; X-T4, 545 photos; NIKON Z 6_2, 296 photos; NIKON Z 8,
261 photos; NIKON D5300, 264 photos (below). They hold aggregate colour
statistics only. A photo of a profiled model takes its colour from the
profile and fits only its own tone and chroma curves (picture styles
vary per shot; with Sony's DRO on, that tone curve is then lowered as
described below); the acceptance gates still apply. A rejected profile
fit retries the photo's own colour and tone fit before falling back to
neutral rendering. Files are read once per process and validated
(version, bounded invertible matrix, table shape and finite data); a
damaged file is ignored with a warning. The profiles folder's contents
are part of the render cache keys, so thumbnails rendered before a
profile existed are redone; smart previews built before keep their
colour until rebuilt.
Sony ILCE-7CR
Fitted from 125 usable photos of 131 ILCE-7CR ARWs (one photographer,
September 2025 to September 2026, Standard creative style with DRO off,
Lv3 or Auto; 127 uncompressed, 2 compressed, 2 lossless compressed),
448,230 colour pairs. Measured as above: rendered at 1200 px with
imago-cli render, mean CIE76 ΔE against each file's
embedded JPEG at 200 px.
Without a profile, 123 of the 131 photos accept their own fit; the other 8 open with the neutral fallback at ΔE 6–26. For validation every fifth photo in sorted name order (26) was withheld and a profile fitted to the other 105 (100 usable). On the withheld photos mean ΔE went from 4.07 with per-file fitting to 3.29 with the profile. Both withheld photos whose own fit is rejected are accepted (ΔE 26.0 → 6.1 on a night scene, 6.2 → 5.5). Withheld photos whose own fit was accepted are about unchanged and move both ways: 12 of 24 improve, mean ΔE 3.06 → 3.08, largest regression 1.8. On that photo (gold tinsel bokeh) the profile turns a blue light blue again where the per-file fit had made it teal, but renders the gold slightly more saturated than the camera. With the final profile all 131 photos are accepted, and the 8 previously rejected ones land at ΔE 1.3–5.7; that comparison is in-sample.
The nine public raw.pixls.us ILCE-7CR files are one scene in every coding the camera writes (uncompressed, lossless compressed L, M and S, compressed, full frame and APS-C crop). They were not used for fitting. All nine accept their own fit at ΔE 1.3–2.1 and stay accepted with the profile, at ΔE 1.5–2.9 (mean 1.59 → 1.99). On this colourful daylight scene the per-file fit is already close. The subsampled M and S codings, which decode with unity white-balance gains, move most (2.0 → 2.8). This is a camera-JPEG look estimate like the others, not measured calibration or feature parity with professional tools.
Sony ILCA-99M2
Fitted from 144 usable photos of 150 ILCA-99M2 ARWs (one photographer, seven shooting days, Standard creative style, DRO Auto, ISO 100–1600, compressed ARW, no in-camera distortion correction; Zeiss 24–70 mm F2.8 and 85 mm F1.4, Tamron and Sigma lenses), 501,511 colour pairs; the other six, one dark and nearly colourless indoor series, gave no usable colour pairs. Measured as for the ILCE-7CR.
Without a profile all 150 photos accept their own fit (ΔE 1.8–13.5). For validation every fifth photo in sorted name order (30) was withheld and a profile fitted to the other 120 (115 usable). On the withheld photos mean ΔE went from 5.11 with per-file fitting to 4.87 with the profile; 25 of 30 improve (by up to 0.8), the largest regression is 0.24. With the final profile all 150 photos stay accepted, mean ΔE 4.62 → 4.45 (in-sample). The one public raw.pixls.us ILCA-99M2 file (Standard, DRO Auto; not used for fitting) is accepted with and without the profile, ΔE 5.57 → 5.73. Seven shooting days are a narrow sample of scenes and light; this is a camera-JPEG look estimate like the others, not measured calibration or feature parity with professional tools.
Canon EOS R7 and EOS R6 Mark II
With CR3 and CR2 input to calibrate (#1073), tried on
one photographer's library (CR3, RAW and C-RAW, October 2025 to
September 2026) and measured as above, against each file's own full-size
embedded JPEG (6960 × 4640 R7, 6000 × 4000 R6 Mark II):
- Held out: 40 photos per body from different shoots, every fifth withheld (8 per body), profiles fitted on the other 32 (30 R7 photos with 107,286 colour pairs, 28 R6 Mark II with 93,200; both with a hue/saturation table). On the withheld photos the profile changes little: R7 mean ΔE 2.52 → 2.57 (median 2.37 → 2.25; 3 better, 3 worse, 2 unchanged; largest regression +0.51 on an almost colourless photo), R6 Mark II 6.12 → 6.09 (median 5.73 → 5.50; 3 better, 4 worse, 1 unchanged; largest regression +0.48, best gain −0.67). Mean ΔL* 0.00 (R7) and +0.57 (R6 Mark II) either way. Not bundled.
- The profile only replaces the colour inside an accepted per-photo
fit (
fit_preview), so a photo whose own fit is rejected renders the neutral fallback with or without it: 2 of the 16 withheld photos (one per body), plus one with a black-and-white camera JPEG, rendered identically both ways. Unlike the ILCE-7CR, where the profile rescued every rejected photo, here it cannot. - Library-wide (
--max 600over 35,796 CR3s): 410 R7 photos (1.39 million pairs), 119 R6 Mark II (386k) and 38 EOS R100 (129k) gave profiles; 32 photos had no usable camera JPEG or colour (mostly moon, night and other near-colourless scenes) and one did not decode. The R6 Mark II writes its Exif model as "Canon EOS R6m2", the profile's file name too. - Decoding: 4 of the R6 Mark II C-RAW files tried (3 of 40 sampled, 1 of 119 in the library run) are refused with "unsupported raw variant: Canon CRX adaptive QP value 172" (or 175) and open preview-only.
Nikon Z 6II
Fitted from 296 usable photos of 299 Z 6II NEFs (one photographer,
2024 to 2026, Standard and Auto picture controls, lossless 14-bit, with
the NIKKOR Z 20mm f/1.8 S, Z 24-70mm f/4 S and the AF-S 70-200mm f/2.8G
on the FTZ), 1,099,207 colour pairs. The pairs were collected with
Nikon's in-file distortion correction read and the sensor proxy
following it (see "Lens-corrected camera JPEGs" above), since the camera
corrects every JPEG from these lenses. Measured as above: rendered at
1200 px with imago-cli render, mean CIE76 ΔE against each
file's embedded JPEG at 200 px.
For validation every fifth photo in sorted name order (59) was held back and a profile fitted to the other 240 (237 usable). On the held-back photos mean ΔE went from 2.73 with per-file fitting to 2.54 with the profile (median 2.49 to 2.21), 43 closer and 8 farther, the largest by 0.64. With the final profile across all 299 photos it goes from 2.68 to 2.53, but that's in-sample. The few that get noticeably worse are ISO 25600 night shots (up to +2.5), where the camera JPEG's noise reduction changes the colour a lot and a per-photo fit does better.
The public raw.pixls.us Z 6II file
(nef-nikon-z6ii-lossless.nef, Vivid picture control with
the Z 24-200, not used for fitting) is accepted with the profile and
goes from 5.37 to 5.25.
Nikon Z 8
Fitted from 261 usable photos of 278 Z 8 NEFs (one photographer, 2023
and 2026, almost all Standard picture control, lossless 14-bit, with the
NIKKOR Z 24-120mm f/4 S and the AF-S 70-200mm f/2.8G on the FTZ; the
High Efficiency files can't be decoded and were skipped), 969,860 colour
pairs, collected the same way as the Z 6II's with Nikon's distortion
correction read. Holding back every fifth photo (55) and fitting the
other four fifths, mean ΔE on the held-back photos went from 2.55 to
2.33 (median 2.31 to 2.16), 39 closer and 10 farther, the largest by
0.46. The public raw.pixls.us Z 8 file
(nef-nikon-z8-lossless.nef, Z 14-24 f/2.8 S, not used for
fitting) is accepted with the profile and stays about the same, 3.22 to
3.27.
Nikon D5300
Fitted from 264 usable photos of 273 D5300 NEFs (one photographer, 2014 to 2017, mostly Standard picture control with some Portrait and Landscape, lossy 12-bit, nearly all with the AF-S DX 35mm f/1.8G), 964,687 colour pairs. On the 53 held-back photos mean ΔE went from 2.86 to 2.66 (median 2.78 to 2.60), 44 closer and 6 farther, the largest by 0.63. There's no public D5300 sample in the corpus to check it against.
The same was tried for the photographer's D700, D750 and D80 (about 300 photos each) and it didn't help: on held-back photos the D750 stayed at 3.2 and the D700 and D80 got slightly worse, even fitting only their Standard (or normal saturation) photos. Their per-photo fits are already close and those bodies were shot with a mix of picture controls, so they have no profile.
Sony Dynamic Range Optimizer (DRO)
Sony bodies apply DRO, a local tone operator that brightens darker regions, to their JPEG (and the preview embedded in the ARW) only; the raw data are unchanged, and the factory default is DRO Auto. The tone curve fitted to that JPEG carried the brightening into the raw's starting look, so DRO photos opened lighter than in a professional photo organizer, darktable, Photomator or Affinity, which develop the raw without it (issue #244). On 24 ILCE-7CR photos (Standard creative style) with a professional photo organizer 9.5.1 default exports, mean L* at 200 px: with DRO off, a professional photo organizer matches the camera JPEG (+0.15 L*, all within ±1.4) and Imago matches a professional photo organizer (−0.19 L*, median ΔE76 3.6); with DRO Lv3 or Auto (8 photos), a professional photo organizer is 2.4–10.7 L* darker than the camera JPEG and Imago followed the JPEG, +6.4 L* from a professional photo organizer (median ΔE76 7.5). The tone curves fitted to 131 ILCE-7CR photos show where: at scene luminance 0.01, 0.02, 0.05 and 0.1 the median DRO Lv3/Auto curve is 8, 10, 9 and 6 L* above the DRO-off one, and from 0.3 up they agree.
The ARW decoder reads the setting from maker-note tag
0xb025 (ExifTool's Sony tag documentation: 0 Off; 1
Standard, 2 Advanced Auto, 3 Auto, 8–12 Advanced Lv1–5, 16–23 Lv1–8;
other values are treated as unknown). It was present on all 264 Sony
raws checked (98 bodies, DSLR-A100 to ILCE-7CR); the second tag of that
name, 0xb04f, only says off or on and is missing on the
older bodies. With DRO on, the look keeps the colour fitted to the JPEG
(matrix, hue/saturation table, chroma curve) 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, listed in
assets/ATTRIBUTION.md). Past its last knot a curve
continues as a shoulder whose rate comes from its last two knots, so the
second-last knot is lowered only as far as keeps that shoulder at or
below the fitted one; the result is never brighter than the fitted curve
at any scene luminance. Sony's curve is quantile-matched, as one photo's
tone is, over the pooled pairs of 12 CC0 raw.pixls.us ARWs shot with DRO
off and the Standard creative style on 10 interchangeable-lens bodies
(ILCE-3500, -5000, -6600, -7M3, -7S, -7CR, -9M2, ILCA-77M2, SLT-A57,
NEX-F3; the file lists their URLs and SHA-256); pooled the same way from
55 private ILCE-7CR photos with DRO off, the curve differs by at most
1.2 L* between scene luminance 0.01 and 0.5. DRO only brightens, so the
photo's own curve bounds the curve without it from above: the 1″
compacts map raw values darker (with DRO off, the DSC-RX100M2, -RX100M5
and -RX10M4 curves lie 8–16 L* below Sony's at scene luminance 0.18) and
keep their own curve where it is the darker one. Photos with DRO off or
unknown, and other makers, are unchanged.
Measured before → after (mean L* and CIE76 ΔE at 200 px): on the 24 ILCE-7CR photos with a professional photo organizer references, the 8 with DRO on go from +6.4 L* (+4.4 to +10.5) to +0.8 L* (−0.4 to +1.9) against a professional photo organizer's default, median ΔE 7.5 → 3.3; by a professional photo organizer lightness band (L* 0–25, 25–50, 50–75, 75–100) they now differ from it by +2.3, +1.4, −0.8 and −3.0 L* (before +7.8, +9.4, +3.8, −0.8), like the 16 with DRO off (+2.5, −0.1, −2.0, −3.2), which render byte-identically. Of all 131 ILCE-7CR photos, the 56 fitted with DRO on now render 6.1 L* darker than their camera JPEGs on average (0.3 lighter before), as a professional photo organizer renders the measured ones 5.7 L* darker; the 71 with DRO off are byte-identical. Of 116 CC0 Sony files from 100 bodies, 79 were shot with DRO on (71 of them Auto); the 57 changed of the 69 fitted ones render 2.1 L* darker on average (at most 10.8), the 12 others (six compacts, four ILCE-7M4 shots in Vivid, an ILCE-1 and an ILCE-7RM4, whose own curves are already darker) and all 35 DRO-off files are unchanged. The issue's ILCE-7M3 file goes from +0.8 to −6.5 L* against its camera JPEG. Applied to photos shot with DRO off, whose JPEG shows the camera's own curve, the rule would move the 68 fitted ILCE-7CR photos by −0.4 L* (median; at most −4.1) and 25 of 28 fitted CC0 ones by at most −1.8 L*; the exceptions are an ILCE-3500 (−4.7) and the cine bodies FX3 and FX30 (−7.2 and −5.1), whose picture profiles are not the Standard style.
Picture styles other than Standard are lowered to the Standard curve too where it is darker; a professional photo organizer ignores them as well. Nikon Active D-Lighting, Canon Auto Lighting Optimizer and Fujifilm D-Range settings are likely to have the same effect and are not handled yet. Render cache version 23 redraws thumbnails; smart previews built before keep the brightened tone curve in their header until rebuilt.
Spectral camera matrices and the order of precedence
For 52 camera models Imago has colour matrices fitted to the camera's
measured spectral sensitivities
(crates/raw/src/spectral.rs, generated into
spectral_table.rs by
tools/spectral_camera_table.py from rawtoaces-data
v1.1.0, Apache-2.0): 22 Canon, 11 Nikon, 11 Sony, 3 Fujifilm and 5
others, plus the other names rawtoaces-data lists for them (EOS Rebel
T3i and Kiss X5 for the EOS 600D; bodies it lists as sharing a sensor,
such as the GFX100S with the GFX 100), and the bodies
SAME_SENSOR in spectral.rs adds by hand
because the table is generated: Fujifilm's 26.1 MP X-Trans CMOS 4 bodies
(X-T30, X-T30 II, X-Pro3, X100V, X-S10, X-E4) take the X-T3's matrices.
Without them an X-T30 raw whose camera JPEG was shot in Acros (black and
white) got its tone curve on the muted neutral fallback, with a grey sky
where the scene had a deep blue one. Per model the generator fits, under
CIE Standard Illuminant A and under D65, a white-preserving 3×3 from
white-balanced camera RGB to XYZ by the least mean CIE76 error over
rawtoaces-data's 190 training reflectances, and writes DNG
ColorMatrix1/2 and ForwardMatrix1/2. The
existing DNG colour model then interpolates them for the file's as-shot
white, which the vendor white-balance multipliers give through the same
matrices. No number comes from the vendor or from a raw decoder.
A raw starts from the first of these that is available
(camera_preview::starting_colour):
- the file's own colour matrices (DNG);
- a camera profile for its model (bundled or local), with tone and chroma fitted to the file's JPEG;
- the file-local fit to its own JPEG described above;
- the model's spectral matrices, with Imago's default tone curve and no JPEG fit;
- the neutral fallback.
When the file's JPEG is black and white, step 2 fits the tone curve alone with the profile's colour; without a profile, or when that fit fails, 4 and 5 take the tone curve fitted to it (see the black-and-white JPEG paragraph above). If every existing JPEG fit fails, a near-black frame may take the guarded tone-only fallback described above, after its spectral or neutral colour has been chosen.
The spectral matrices only replace the neutral fallback: A photo
whose JPEG fit is accepted (with or without a profile) renders exactly
as before, and a covered model takes its spectral matrices only when
that fit fails the gates or the file has no usable JPEG. White balance
of a covered model is in Kelvin through its matrices whichever look it
got (above, issue #730), and so is that of a model whose presets give
the scale; for every other raw without matrices of its own it stays
relative to the as-shot look (Photo::relative_wb).
imago-cli calibrate still pools the uncorrected camera
colour of covered models, so a profile can be fitted for them.
No colour-difference measurement of the spectral matrices against the camera JPEG, or against the file-local fit, is recorded here yet. Whether they should come before the file-local fit for these models is open until one is.
Smart previews and the look version
A smart preview keeps the result of the fit twice: the colour matrix is baked into its pixels and the tone and chroma curves are stored in its header. When the fit changes, a preview built earlier keeps the old look until it is rebuilt, and the render cache version does not reach it (renders are keyed on the library's files, proxies are files of their own).
The fit therefore has a version, LOOK_VERSION in
crates/engine/src/camera_preview.rs, and every smart
preview records the version that wrote it in its header line
(look_version). Bump the constant in any change
that makes the fit give a different result for the same file:
the matrix, hue/saturation, tone or chroma fit, their gates and
fallbacks, or how a camera profile feeds them. Changes that leave the
fitted look alone don't need it. A header without the field (everything
built before it existed) counts as version 0, and so does a value that
is not a non-negative integer; a value from a newer build is left
alone.
A proxy older than the constant is brought up to date by the existing
Build Smart Previews path (smart_run), which also repairs
damaged proxies:
- only proxies of raws that take the per-file look
(
file_local_look: ARW, NEF, RW2, RAF, CR3, CR2, PEF, SRW, ORF…) go stale; the fit never reaches JPEG, PNG, TIFF or DNG proxies, which are never rebuilt for it; - when the library is opened and its smart previews folder has not
been checked at this
LOOK_VERSION(thelook-versionmarker file in the folder), a background thread looks at those proxies, rebuilds the stale ones from their originals and then writes the marker, so the scan runs once per version bump, not at every opening (no proxy is created, nothing is shown, the UI never waits on a drive); - Build Smart Previews does the same for the photos it is run on
(
refreshedin its result), whatever the marker says; - when the original can't be read (offline drive), the proxy and its
curve are kept exactly as they are (
staleKept). The old version in its header is the mark: the next Build Smart Previews with the original online rebuilds it. A rebuilt proxy carries the current version, so nothing is rebuilt twice. A proxy is only ever rebuilt from the original, never from another proxy; - the two never write the same proxy at once: each proxy is checked and written under one lock.
Photo Merge
A merge (HDR, panorama, HDR panorama) writes a linear DNG of the
sources' camera RGB, with the reference frame's colour tags. A raw
without colour matrices of its own has none to give, so its merge used
to render with no camera colour: an HDR merge of CR2s came out green and
dark (issue #965). Each source now goes through
starting_colour as when it is opened alone, and
starting_colour_tags writes the result as DNG tags: one
forward matrix (from the look's matrix on top of the camera transform)
with AsShotNeutral, the look's brightness of white as
baseline exposure, its hue/saturation table as
ProfileHueSatMap and its tone curve as
ProfileToneCurve. Raws with their own matrices (DNG) keep
their tags. Without a look the tags give the colour the raw falls back
to (spectral matrices or the neutral fallback).
The colour matrices declare the white balance the source shows
(files::WbScaleOf::dng_white), so the DNG opens at the
source's As Shot temperature and tint and a white-balance edit starts
from there: the camera's measured matrices
(imago_raw::spectral) when they give the source's scale,
which reads every white as the source does; else a matrix under which
the as-shot neutral is the white the source's scale shows (Sony's
presets' reading, 6500 K / 0 on the relative scale, the file's own white
otherwise). The forward matrix is used only with the as-shot neutral, so
the render at As Shot doesn't depend on them. A colour matrix derived
from the forward matrix, as first written, declared wherever the look
and the generic camera model put the white: merges of real ILCE-7M3
brackets opened at 6113–7307 K / +115 where the ARWs show 3932–5268 K /
+7, and any white-balance edit turned them green (CIEDE2000 11–13
against the ARW at the same Temp/Tint). With the measured matrices they
open at the ARWs' As Shot, and the same Temp/Tint renders within
CIEDE2000 0.8–1.7 of the ARW for most changes, about 5 for the largest
(5268 → 3200 K, 3932 → 6500 K): the ARW adapts its fitted look with
Bradford, the DNG rebalances in camera space.
The DNG renders close to, not exactly like, the source: a DNG hue/saturation table doesn't keep each pixel's luminance as the fitted table does, and the chroma curve has no DNG tag, so it is left out. On two CC0 CR2s (EOS 550D, 40D), an HDR merge of three copies of the photo against the photo alone (200 px, CIE76): mean ΔE 1.07 and 0.47, 95th percentile 2.65 and 1.15 (26 and 23 before, with no colour tags; 1.47 and 0.66 with the table left out too).
A raw whose white balance is only in its vendor multipliers (CR2,
NEF, ARW…) has no AsShotNeutral of its own, so a frame
carries the multipliers (Frame::wb_multipliers) next to its
colour tags. A panorama, and an HDR merge from its inputs, takes its
white balance from the frames that are in it: the median R/G and the
median B/G of their multipliers (the mean of the middle two for an even
count; frames without usable multipliers, or outside the panorama, do
not vote), written as AsShotNeutral, the reciprocal. Any
AsShotWhiteXY of the source is dropped with it: a reader
takes it first, and the DNG spec has the two exclude each other. So the
result is balanced for what the camera measured for most of its frames,
not for whichever frame came first; an HDR panorama's merged brackets
vote with their bracket's median. With no multipliers anywhere (DNG
sources) the reference or first frame's tags are kept as before. For
frames balanced alike this is the neutral
starting_colour_tags already wrote, so the merged DNG
renders as before; it matters when the camera balanced the frames
differently, and for a caller that loads frames without a colour hook
(load_frame), whose raw frames carried no white balance at
all (issue #1144).
Nikon crop and preview colour metadata
Nikon maker-note CropArea (0x0045) supplies the default
[left, top, width, height] crop. The decoder validates the
rectangle against the active sensor area and falls back to that area for
missing or invalid values. The CFA origin is unchanged; cropping follows
demosaicing.
An embedded JPEG with neither ICC nor EXIF metadata uses Nikon
maker-note ColorSpace (0x001e: 1 = sRGB, 2 = wide-gamut
RGB). This fallback is applied before transfer decoding and resizing,
and used by quick previews, preview-only RAW and the existing
camera-look reference decode. A JPEG's own metadata takes precedence. No
fitting algorithm, training split, highlight reconstruction or
white-balance control changes are included. Render cache version 12
invalidates older previews.
Synthetic tests cover crop bounds/CFA preservation, colour-space tags, fallback transfer decoding and metadata precedence. A private Nikon Z 8 sample reports an 8256 × 5504 default crop from an 8280 × 5520 sensor. No private sample or the vendor asset is included.
Samsung SRW
Samsung SRW decoding (uncompressed files) supplies the as-shot white-balance multipliers from the maker note and no colour matrix, so SRW takes the same fit, gates and relative white balance.
Canon CR2 and Pentax PEF
CR2 and PEF decode without a camera colour matrix too, but were left out of the fit until issue #310 (CR2 files opening flat and desaturated: the camera's preview first, then the neutral fallback). They now use the same fit, gates and relative white balance. This change was not measured on the corpus: the gates keep the fallback for any file whose fit doesn't pass, so a file can only stay as before or move closer to its camera JPEG. Per-model numbers are still to come.
Olympus ORF
The ORF decoder (uncompressed files) supplies the as-shot white
balance (ImageProcessing WB_RBLevels) and no colour matrix,
so ORF takes the same fit, gates and relative white balance. The
reference is the camera's preview JPEG (on the E-1 the 1280 × 960 one
the maker note points to). All three decodable corpus files are accepted
(held-out RMS against the neutral fallback): E-1 0.126 → 0.014, E-400
0.087 → 0.022, XZ-2 0.061 → 0.023. Compressed ORFs (E-M1, E-M10 III)
still open from their embedded JPEG. Against a professional photo
organizer's render of a CC0 E-1 photo (2000 px, ΔE00 of the unedited
render) the starting look moved from 11.25 to 8.30. A professional photo
organizer frames that E-1 file about 4 × 11 sensor pixels differently
from the crop the file records
(CropLeft/CropTop 36, 34); the file's own crop
is kept.