💬Comments welcome. To leave a note, select any text and click the note / highlight button that pops up — or open the panel with the tab at the top-right (‹). Notes are visible only inside our private review group.
jump to

22.19 Rendering a raw DNG

A raw DNG is not a picture. It is a grid of sensor measurements plus a recipe for turning them into one (this is the thesis of the format appendix). "Render it" hides two different jobs that this book keeps deliberately separate:

  1. The radiometric job — recover a scene-linear image: values proportional to the light that fell on the sensor, white-balanced and in a known color space (we use linear sRGB primaries). This is the array every algorithm in the book wants — HDR merging, white balance, blur/deblur, denoising, demosaicking, Poisson editing — because in linear light the physics is honest (see Draft — 03-01 Image representation).
  2. The cosmetic job — produce a pleasing displayed image that matches what the photographer saw in Lightroom. A scene-linear image shown directly looks dull and dark: it is missing the camera/Lightroom look — a baseline tone curve that lifts the midtones, a gentle highlight roll-off, and a touch of saturation. We call that final step the beauty pass.
💡 The one rule

Do all processing in scene-linear; apply the beauty pass only at the very end, for display. Never blend, blur, merge, or solve on the beautified image, and never bake the beauty pass into a file you will later compute on. The beauty pass is a display transform, not data.

22.19.1 The pipeline, step by step

Inputs: the original Bayer DNG (libraw/rawpy can read it) and its embedded XMP develop settings written by Lightroom. Our reference implementation lives in dng_linear_correct.py (the radiometric job, baked once into a demosaicked LinearRaw DNG) and figures/imageops.py (load_linear_dng, then linear_to_display, the beauty pass).

1 — Read the Lightroom develop settings (XMP). From the DNG's XMP packet, read the crs: fields: Exposure2012 (in stops), Temperature and Tint (white balance), WhiteBalance (As Shot / Custom), and the crop rectangle (HasCrop, CropTop/Left/Bottom/Right, CropAngle). These are the photographer's artistic decisions; honoring them is what makes the render match Lightroom rather than some neutral default.

2 — Demosaic to camera-native linear. Demosaic the Bayer mosaic with no gamma, no auto-brighten, and no white balance yet: rawpy.postprocess(gamma=(1,1), no_auto_bright=True, user_wb=[1,1,1,1], output_color=raw, output_bps=16). The result is linear, camera-native RGB, black/white-level normalized to $[0,1]$.

3 — White balance from Temperature/Tint. Convert Lightroom's Temperature/Tint to camera-channel multipliers using Adobe's own dng_temperature algorithm (the kTempTable/kTintScale from the DNG SDK: temperature→$xy$ chromaticity, then through the DNG ColorMatrix/CameraCalibration to camera-neutral multipliers), and multiply the camera RGB by them (green normalized to 1). Using Temperature/Tint — not the camera's as-shot neutral — is what reproduces a custom white balance set in Lightroom.

4 — Color: camera-native linear → linear sRGB. This is the subtle step. Using the DNG's ForwardMatrix (camera → XYZ D50) and CameraCalibration (CC), the Adobe forward transform is

$$ \text{cameraToXYZ}_{D50} \;=\; \mathrm{FM}\cdot \operatorname{diag}\!\big(1/\text{refNeutral}\big)\cdot (\mathrm{AB}\cdot \mathrm{CC})^{-1}, \qquad \text{refNeutral} = (\mathrm{AB}\cdot \mathrm{CC})^{-1}\cdot \text{AsShotNeutral}, $$

with AB the AnalogBalance (identity for us). Then $\text{linear sRGB} = M_{\text{XYZ(D50)}\to\text{sRGB}}\cdot \text{cameraToXYZ}_{D50}\cdot \text{cameraRGB}$. The reference-neutral normalization and the inverse of CC are both essential — see Easy mistakes. After white balance is baked (step 3), AsShotNeutral is neutral $[1,1,1]$, so refNeutral $= \mathrm{CC}^{-1}\mathbf{1}$.

5 — Exposure. Multiply the linear image by $2^{\,\text{Exposure2012}}$. In linear light, exposure is an honest multiply (see Draft — 03-03 Point operations).

6 — Crop (and straighten). Apply the crop rectangle and any straightening angle.

The result of steps 1–6 is the scene-linear ground truth. This is what load_linear_dng(name, output="linear") returns, and what every algorithm figure runs on. Showing it directly is where it looks dull — that is correct, not a bug.

Reference implementation (steps 1–6, pseudocode)
s     = read_xmp(dng)                         # crs:Exposure2012, Temperature, Tint, Crop*
cam   = rawpy.postprocess(dng,               # demosaic only — camera-native LINEAR
          output_color=raw, gamma=(1,1), no_auto_bright=True,
          user_wb=[1,1,1,1], output_bps=16) / white_level     # → [0,1]
mult  = wb_multipliers(s.Temperature, s.Tint, color_tags)     # Adobe dng_temperature
cam  *= mult                                  # white balance (green-normalized)
CC, FM, AB = color_tags.CameraCalibration2, ForwardMatrix2, AnalogBalance(or I)
refN  = inv(AB·CC) · AsShotNeutral            # AsShotNeutral now [1,1,1] (WB baked)
M     = XYZ_D50_to_sRGB · FM · diag(1/refN) · inv(AB·CC)
lin   = clip(cam · Mᵀ, 0, 1)                  # linear sRGB primaries
lin  *= 2**s.Exposure2012                     # exposure (a multiply in linear)
lin   = crop(lin, s.Crop*)                    # crop + straighten
# `lin` is the scene-linear ground truth. Beauty pass (step 7) is display-only.

(The two reference 3×3s: XYZ_D50_to_sRGB is the standard Bradford-adapted matrix; FM/CC come from the DNG tags, interpolated between the two calibration illuminants by correlated color temperature — we use the higher-CCT pair, index 2, e.g. D65.)

7 — The beauty pass (display only). To display, encode to sRGB and apply the camera/Lightroom look: a baseline tone curve that lifts the midtones (~1–1.6 stops — the single biggest reason scene-linear looks dark), a highlight roll-off, and modest saturation. Our linear_to_display uses a smooth, monotone baseline curve (recovered empirically by fitting our linear-render luminance to Lightroom-JPG luminance across a test set) with a highlight-weighted hybrid roll-off: midtones are toned hue-preserving so saturated colors stay saturated, but as luminance approaches white the curve goes per-channel so bright skies and the sun desaturate cleanly to white instead of taking on a magenta/cyan cast. Saturation is a mild luma-preserving boost that eases off in the highlights.

fig-dng-beauty-pass
Figure 22.19.1. The same scene-linear DNG render, before and after the beauty pass. Left of each pair: the radiometrically-correct scene-linear image, sRGB-encoded — dull and dark, because it carries no camera tone curve. Right: after the beauty pass (baseline midtone-lifting tone curve + highlight roll-off + mild saturation), which is what a viewer expects. The pixels feeding any algorithm are the left image; the right is display only.

22.19.2 Where Lightroom's "look" actually comes from

Lightroom's render is not just the color matrix. With an "Adobe Standard" or "Adobe Color" DNG camera profile (DCP), the chain is: matrix → HueSatMap (an HSV calibration table) → LookTable (the creative "look") → the baseline tone curve. Our pipeline reproduces the matrix faithfully and approximates the baseline tone curve with the beauty pass, but it does not apply the profile's HueSatMap/LookTable. In testing, the matrix-plus-beauty render matches the Lightroom JPG closely; applying our own re-implementation of the HueSatMap/LookTable helped only marginally and risked worse color (a too-purple blue), because the LookTable's value-encoding and HSV conventions are easy to get subtly wrong. So we deliberately stop at matrix + beauty. This is the main thing we would ask an Adobe engineer to confirm: (a) that our ForwardMatrix·diag(1/refNeutral)·CC⁻¹ composition is the correct forward transform, and (b) whether skipping HueSatMap/LookTable is an acceptable approximation or a real error for our use.

22.19.3 What we would ask an Adobe engineer to check

In decreasing order of how much it would change our output:

  1. The forward transform. Is cameraToXYZ_D50 = FM · diag(1/refNeutral) · (AB·CC)⁻¹, with refNeutral = (AB·CC)⁻¹ · AsShotNeutral, the correct composition? Our bug was applying FM · CC (un-inverted, no reference-neutral) — we want confirmation the corrected form matches Camera Raw, and that picking the index-2 (higher-CCT) FM/CC rather than CCT-interpolating between illuminants 1 and 2 is acceptable for daylight scenes.
  2. Skipping HueSatMap / LookTable. We apply the matrix + a baseline tone curve but not the profile's HueSatMap or LookTable. Is matrix-plus-baseline-curve a defensible approximation of "Adobe Standard"/"Adobe Color," or does omitting those tables introduce errors that matter (and if we did apply them, what are the exact value-encoding and HSV-space conventions for the LookTable)?
  3. The baseline tone curve. We recovered a baseline curve empirically (fitting our linear-render luminance to the Lightroom-JPG luminance). Is there a canonical baseline tone curve for the current Process Version we should use instead, and is it applied per-channel, on luminance, or in some other space?
  4. Highlight roll-off. We desaturate highlights toward white with a luminance-weighted blend toward a per-channel curve. How does Camera Raw actually roll highlights to white?

22.19.4 Two kinds of DNG, and the special cases

22.19.5 Easy mistakes (most of which we made)

This pipeline is a minefield, and a wrong render looks almost right. In rough order of how badly each bit us:

fig-dng-mistakes
Figure 22.19.2. Easy ways to get it wrong, versus the correct pipeline — the same photo throughout. Top row, three failures: double gamma (milky and washed); as-shot white balance (ignores the Lightroom WB — the sky and whites drift); dropped exposure (beauty pass without baking the LR exposure — too dark). Bottom row: the incomplete render (correct scene-linear, but no beauty pass, so dull); the correct pipeline (LR exposure/WB/crop, corrected color, beauty pass); and the reference Lightroom JPG. Each failure looks superficially plausible, which is exactly why these bugs survive.