Files
DarkRoom/docs/dev/camera-profiles.md
T
dtourolle 05ac2416c6 Spec DCP camera profiles (D20)
The library's Canon 6D DNGs were written by Lightroom and embed Adobe
Standard with its HueSatMap and LookTable; DarkRoom renders them through
the matrix alone, which is most of why the same file looks flatter here
than in Lightroom.

camera-profiles.md designs the deferred half of FR-DEV-3e: the tables
applied by a camera_profile scene operation after exposure, the profile
taken from the DNG or from a matched .dcp, tables carried with the
decoded image like the matrix, a look-strength control, and copying an
embedded profile out where its policy allows. FR-DEV-3e gains item 4
and D20 records the placement and what was rejected.
2026-10-02 22:37:58 -04:00

14 KiB
Raw Blame History

Camera profiles — DCP tables on top of the matrix

Design for the deferred half of FR-DEV-3e (requirements.md): the HueSatMap and LookTable of a DNG camera profile, read from the DNG that carries one or from a .dcp file, and applied after the matrix. Draft of 2026-10-02, recorded as D20.


1. What we are matching

Lightroom renders a raw through a profile before any slider moves. A profile is the matrix DarkRoom already applies, plus two lookup tables indexed by hue, saturation and value:

  • ProfileHueSatMap — a calibration. It corrects what a 3×3 cannot: a sensor whose reds and oranges sit in the wrong place relative to its blues, which no linear map fixes. Two copies, one per calibration illuminant, interpolated like the matrices.
  • ProfileLookTable — a rendering intent. The "Adobe Standard" look: richer blues and greens, warmer yellows, skin held where it is. The difference between Adobe Standard, Adobe Color and Adobe Vivid is mostly this table.

Without them a raw renders through the matrix alone, which is accurate on a ColorChecker and reads as flat next to Lightroom. The first complaint FR-DEV-3e's rationale names is exactly this, and the per-body base curves D19 retired were the cheaper stand-in for it.

What the library holds (catalog of 2026-10-02): 17,286 of its raws are Canon EOS 6D. The 9,348 DNGs were written by Lightroom 6.14 and every one sampled embeds Adobe Standard with both tables — HueSatMapDims 90 30 1, LookTableDims 36 8 16, ProfileEmbedPolicy 0 ("allow copying"), no ProfileToneCurve. The 7,938 CR2s from the same body carry no profile. So the tables Lightroom used are already on disk for half the library, and their licence lets them be applied to the other half.

2. The model, as the DNG specification states it

Each table is a grid of (hueShift°, satScale, valScale) triples over HSV, stored with saturation varying fastest, then hue, then value:

index = v · (hueDivs · satDivs) + h · satDivs + s

Lookup follows the DNG SDK's RefBaselineHueSatMap:

  • HSV is the SDK's: v = max(r,g,b), s = (v − min)/v, h ∈ [0, 6) from which channel leads. Grey has s = 0 and is untouched by construction.
  • Hue wraps: hueDivs samples over 360°, the last interpolating to the first.
  • Saturation samples 0..=1 at satDivs points; linear between.
  • Value samples 0..=1 at valDivs points when valDivs > 1; a table with valDivs = 1 is "2.5-D" and ignores value. ProfileHueSatMapEncoding / ProfileLookTableEncoding = 1 means the value axis is indexed by the sRGB-encoded value; 0 (the default, and the 6D's) means linear.
  • Apply: h += hueShift · 6/360, s = min(s · satScale, 1), v ·= valScale; back to RGB.
  • Space: linear ProPhoto (ROMM) primaries, D50 white — the space the forward matrix lands in.
  • Two illuminants: HueSatMapData1/2 are interpolated entry by entry, with the same mired weight the matrices use. LookTable is single.

Two departures, both forced by D19's unbounded scene-linear values (the SDK runs these on [0, 1]):

  1. Value is not clamped. The SDK writes min(v · valScale, 1); here v · valScale, unbounded. For lookup only, the value axis reads min(v, 1), so a highlight above 1.0 uses the table's brightest row. Where the encoding is sRGB the scale is defined on the encoded value; it is applied as the ratio decode(enc(v′)·valScale)/v′ at v′ = min(v, 1), so a value above 1.0 gets the brightest row's ratio rather than a clip.
  2. A colour outside ProPhoto passes through. A negative component has no SDK HSV. Such a colour is outside every surface colour a camera records under normal light. It is left unmodified rather than floored, because flooring it clips a value D19 says nothing may clip.

3. Where it sits

… camera matrix ─► vignetting(5) ─► exposure(20) ─► camera_profile(25) ─► contrast(30) ─► … ─► view transform
                                                     │
                              working → ProPhoto ─► HueSatMap ─► LookTable ─► ProPhoto → working

A scene operation at order 25, not part of the matrix snippet. Three reasons:

  • The matrix stays what it is. cam_to_srgb is unchanged and still runs where D19 put it, and so does every reader of it: the mask pass's copy, the white-balance picker's, the camera-space tap. The tables add a conversion into ProPhoto and back inside their own fragment, through two constant matrices (§3.1). A photograph with no profile composes exactly the shader it does today.
  • It commutes with what runs before it. HSV hue and saturation are invariant under a uniform gain, and vignetting and exposure are uniform gains. So a 2.5-D table — every Adobe HueSatMap seen, and the 6D's — gives the same answer before or after them. That lets one position serve both tables, which is where the second reason matters:
  • The look sees exposure. The SDK applies LookTable after its exposure ramp, so a look that desaturates highlights finds the highlights the photographer chose. At 25 it does too. Contrast, tone and the colour controls come after it, as they do in Camera Raw.

Tables are per source, like the matrix. They are decoded with the raw, interpolated once at decode (the HueSatMap blend uses the as-shot neutral, as the matrix does) and carried on DemosaicedImage next to color_matrix. dr-gpu uploads them to a storage buffer at @binding(8) and writes their dimensions into the base uniform block. Every render path that reaches AdjustPass therefore gets them without being told: develop, export, previews, the tablet. A path that had to call a setter on the graph would be a path that one day forgot to, and an export that differed from the screen would be the result.

3.1 The two constants

P = row-normalise(XYZ→sRGB · Bradford(D50→D65) · ProPhoto→XYZ(D50)) takes ProPhoto to the working space; the fragment uses P⁻¹ going in and P coming out. These are the same constants forward_to_srgb composes for the forward-matrix route, so for a profile with forward matrices the round trip is exact: the working colour is P · FM-space colour and P⁻¹ recovers the SDK's ProPhoto. Row normalisation keeps working-space white at ProPhoto white, so a neutral stays at s = 0. For the colour-matrix route, P⁻¹ gives the ProPhoto rendering of the same colour, which is what the SDK derives too.

4. Where a profile comes from

In this order, first match wins:

  1. The profile embedded in the DNG being opened. It is what the file says, and it was made for the matrices the file carries. Read from the root IFD through rawler's parsed IFD, as read_dng_matrices already reads the forward matrices — no second TIFF parser.
  2. A .dcp in the profiles directory whose UniqueCameraModel matches the body — compared case-insensitively against the DNG's UniqueCameraModel where there is one, and against make + " " + model otherwise ("Canon EOS 6D"). A DCP is a whole profile: its matrices replace the file's, because its tables were built against its forward matrix. The file's as-shot neutral is kept. If several match, the first by file name wins, so the choice is stable.
  3. None. The matrix alone, as today.

The profiles directory is profiles/ under the platform data directory (dr_plat::dirs), loaded once per process. A DCP is a TIFF with the magic IIRC (0x4352) in place of 42; rawler's GenericTiffReader already accepts it.

Copying an embedded profile out. A DNG whose profile has ProfileEmbedPolicy 0 ("allow copying") or 3 ("no restrictions") can have that profile saved as a .dcp into the profiles directory. That is how the 6D's CR2s get Adobe Standard: open a 6D DNG, choose Use this profile for every Canon EOS 6D. Policies 1 ("embed if used") and 2 ("embed never") offer no such action. The written file carries the profile's name, copyright and policy unchanged.

Nothing is shipped. Adobe's profiles are Adobe's; the application ships no .dcp and copies none on its own. A profile reaches the directory because the photographer put it there or asked for it to be copied from their own file.

5. The control

A develop operation, camera_profile, [colour], order 25, hand-written (rust:) because it reads a buffer no declaration can name:

Parameter Kind Default Meaning
apply Bool on Use the profile's tables, or the matrix alone
look Scalar 0–200 100 Strength of the LookTable, as Lightroom's Amount

look scales the look's deltas: hueShift · a, 1 + (satScale − 1)·a, 1 + (valScale − 1)·a, with a = look/100, scales floored at 0. At 200 the look is twice as strong, which is the "more vivid than Adobe Standard" this started from. The HueSatMap is a calibration and is not scaled: apply is its only switch.

Always composed while apply is on, as the view transform is: a profile at its defaults is the rendering, not an edit, so an untouched photograph writes no parameters and still renders through its profile. The fragment branches on the uniform that says whether the source has tables, so a JPEG, or a raw with none, pays one uniform read. The branch is uniform across the dispatch.

Mask layers. A layer may offset look (blended as a setting, which is linear) but not apply; the photograph has one profile.

The panel says which profile is in use, as the lens line does: Adobe Standard (in the file), Adobe Standard (Canon EOS 6D.dcp), or No profile for this camera — matrix only. The copy-out action sits on that line.

6. Not done, and why

  • ProfileToneCurve is read and ignored. D19 gives tone to the view transform, one for every body, and rejected per-body curves as its defaults. A profile's curve is a per-body curve. If it comes back, it comes back as an option of the view transform, not as a stage here. The 6D's Adobe Standard has none, so the case that matters today loses nothing.
  • BaselineExposure is not applied (it is not today either). Adobe Standard was tuned with it, and the 6D's is +0.25 EV. Separate change; it moves every photograph's brightness.
  • The interpolation follows the as-shot neutral, not the white-balance slider, as the matrix does. Camera Raw re-blends on every temperature change; doing so here means the matrix moves too, which is its own change.
  • Masks select on the matrix's colour. A colour-range mask sees colour before the profile, as it sees colour before every other operation. Deterministic, and a mask is drawn on the picture the user sees only approximately anyway.
  • The profiles directory does not sync. A CR2 rendered on a desktop with a copied 6D profile and on a tablet without one will differ. The panel line says which profile each device used, so the difference is visible rather than silent. Syncing the directory with the library is the follow-up.
  • Rec.2020 working primaries stay deferred (D19); nothing here depends on them.

7. What it costs

  • Every DNG with an embedded profile renders differently — more saturated, which is the point. Previews rendered before the change keep the old look until rendered again, as with D19.
  • Tablet and desktop must be released together. No schema change, and the sidecar gains only ordinary parameters, but two builds render the same DNG differently.
  • One storage-buffer binding in every generated shader's layout (a one-entry placeholder when there are no tables), and two vec4 slots in the base uniform block.
  • Per pixel: two 3×3 multiplies, two HSV round trips, and 4 + 8 buffer reads (bilinear HueSatMap, trilinear LookTable). Small next to the fused pass it joins.

8. Acceptance

  • Parsing. The 6D DNG's embedded profile parses to 90×30×1 and 36×8×16, its policy to 0, its name to "Adobe Standard"; a .dcp written from it parses back to the same tables bit for bit.
  • The CPU reference matches the SDK's algorithm: grey passes through; a table of (0°, 1, 1) everywhere is the identity to 1e-6; a uniform satScale of 1.2 scales HSV saturation by 1.2; hue interpolation wraps between the last and first column.
  • The shader agrees with the CPU reference within 1e-4 on a ramp across hue, saturation and values up to 16.0, on a device.
  • Scene-referred. A value above 1.0 leaves the stage above 1.0 (scene_referred_until_the_view covers the operation).
  • Neutral. apply off, or no tables, composes a shader whose output equals today's to the bit.
  • Two illuminants. A HueSatMap at blend weight 0 is Data1, at 1 is Data2.
  • Matching. An embedded profile beats a directory one; a DCP for "Canon EOS 6D" matches a CR2 whose rawler make/model is "Canon"/"EOS 6D"; no match leaves the matrix.
  • Subjective. A 6D DNG rendered here at defaults is visibly closer to the same file in Lightroom 6 with Adobe Standard than the matrix-only render, side by side.

9. Vivid presets

Independent of the tables, and shipped in the same change: a Vivid section of read-only presets (presets/vivid.drpl) for the "more colourful than the default" request. They use only operations every photograph has — vibrance, saturation, the colour mixer, colour grading, contrast — so they work on JPEGs and on bodies with no profile, and change only what they name (FR-DEV-6):

  • Vivid — vibrance and a little saturation and contrast: the general-purpose one.
  • Vivid, strong — the same, pushed, with deeper blacks.
  • Vivid landscape — greens, blues and azure skies, skin bands left alone.
  • Vivid warm — oranges and yellows up, a warm highlight cast: golden hour.
  • Vivid portrait — vibrance (which protects skin) with the orange and red bands held back.

They are bounded by the existing bundled.rs tests: every key names a real parameter, every value is inside its control's range, and every preset changes something.

10. Build order

  1. dr-decode: parse the tables (embedded and .dcp), the profiles directory, matching, blending, the .dcp writer. CPU reference of the lookup. Unit tests against the library's 6D DNG, skipped when it is absent.
  2. dr-pipeline: the camera_profile operation, the base-block slots, @binding(8), the WGSL lookup; composition tests.
  3. dr-gpu: carry the tables on DemosaicedImage, upload and bind them; the shader-versus-CPU test on a device.
  4. dr-ui: profile line, copy-out action, labels; the profiles directory set at start-up on desktop and Android.
  5. The Vivid presets.