Files
FredApps eab130ecee Docs: drop the Last updated / Change header line that every PR conflicted on (#3790)
* Docs: drop the Last updated / Change header line that every PR conflicted on

* xtask parity: the header's Changes field holds only the revision-history link; name the kept fields

* xtask parity: reject a field after the header's Changes link
2026-11-05 13:54:05 +01:00

11 KiB

Calibrated Contrast and Blacks

Changes: revision history

Process V4 calibrates global Contrast and Blacks on the camera-look path: a raw with SourceInfo::camera_tone. Contrast acts per channel. Blacks blends a per-channel result with luminance scaling. Whites in V4 behaves exactly as V2, for positive and negative amounts. Whites calibration is a planned follow-up.

This is the Contrast and Blacks split of the original V4 calibration work. The serialized number is defined by the Process::V4 arm in Process::version(); callers use the enum. Open PR #944 also proposes V4 and V4, #1283 proposes V4 for Dehaze, and #1286 follows #1283. Maintainers must assign the final process numbers and inheritance in merge order. V4 here inherits V2 only.

Raw files without a camera curve, rendered sources such as JPEG and TIFF, and local mask adjustments keep their V2 operators. This process does not change decoding, source metadata, smart-preview formats, library opening, or Update and Reset checks.

Render order

The camera tone curve uses V2 Whites with Contrast and Blacks set to zero. The renderer maps luminance through that curve, applies its chroma scaling and highlight desaturation, then applies calibrated Contrast followed by calibrated Blacks, before the colour stage. When both calibrated sliders are zero, no new operator or colour conversion runs. With any Whites value and both calibrated sliders at zero, the result is byte-identical to V2. Combined edits use V2 Whites before the two calibrated operators.

The operators convert linear Rec.2020 to linear ProPhoto RGB (D50, with Bradford white-point adaptation), encode values with the sRGB transfer curve, apply the fitted curves, and convert back. The matrices come from RgbSpace::to_space. CPU finish.rs and GPU finish.wgsl use the same matrices and operator tables. CPU previews compose per-channel operations into tables indexed by linear input. The computation is pointwise, so a render window needs no whole-photo measurement.

Operator family

For a slider amount a between -100 and 100, with t = abs(a) / 100:

  • Positive amounts use F_a(v) = v + s_pos(t) * D_pos(v).
  • Negative amounts use F_a(v) = max(0, v - s_neg(t) * D_neg(v)).
  • Each displacement shape is piecewise linear on 12 knots: 0, .02, .05, .09, .14, .2, .28, .38, .5, .65, .82 and 1. Both shapes satisfy D(1) = 0. Contrast also fixes D(0) = 0.
  • The amount law is s(t) = ((1 - exp(-t / tau)) / (1 - exp(-1 / tau)))^q, with separate fitted tau and q for each sign. Zero amount is the identity.

Contrast applies F to each encoded ProPhoto channel. For Blacks, the luminance result scales linear RGB by F(Y) / Y, where the linear ProPhoto luminance coefficients are [0.2880402, 0.7118741, 0.0000857] and F(Y) includes encoding and decoding. Blacks blends that result with the per-channel result in linear light, using one fitted weight per sign.

Each active operator has 1024 table entries over encoded values between 0 and 1. The entries store linear output and are interpolated linearly. A running maximum prevents a fitted curve from reversing tone order. Neither shipped fit folds back. Non-finite amounts are skipped, and finite amounts are clamped to the slider range.

When the resolved per-channel weight is exactly one or zero in f32, the renderer selects only the per-channel or luminance operation. In particular, negative Blacks is per channel. For 8-bit CPU renders, per-channel Contrast and Blacks share one table with 16,384 intervals over linear input. If any operator uses luminance, the renderer retains the original evaluation for every operator, including Contrast before positive Blacks. Preview renders use the existing sRGB encoding table and the source operator tables without the additional linear-input approximation. U16 and F32 exports retain the original operator tables and exact sRGB encoding. Metal retains the original tables too.

Camera chroma scaling can create mixed-sign ProPhoto components from nonnegative source pixels. Their luminance terms can nearly cancel. A small Contrast approximation error can then cross the 1e-6 luminance cutoff or be amplified by F(Y) / Y. A scalar interpolation error bound does not bound the rendered error after this scaling. Exact evaluation avoids introducing this disagreement with the earlier CPU path and Metal. It retains their pre-existing rounding differences near the cutoff.

HDR and gamut limits

A per-channel component above 1 passes unchanged. A component below zero keeps its distance below the image of zero. Luminance scaling leaves a pixel unchanged when its luminance is above 1 or at most 1e-6. Otherwise it scales the entire pixel, including components above

  1. Blacks blends these two results, so an above-white component can move when the pixel's luminance is below white. The functions are continuous at white. HDR tone mapping and Whites retain V2's behavior. GPU renders use 8-bit previews; high-bit-depth exports use the CPU. These are numerical rules, not measured Lightroom HDR parity.

Fit provenance and reproduction

The coefficients were fitted to black-box observations of the owner's Lightroom exports: 16-bit sRGB exports of Sony ILCE-7CR photos, paired with the same photo's Neutral or another slider amount exported in the same batch. Only exported pixels were used. No Adobe code, profiles, tables or application assets were used. The photos were split into tune photos used for fitting and check photos held out for measurement.

Slider Fit Mode Tune Check
Contrast v2-contrast-pc Per channel 155 pairs, 20 photos 61 pairs, 6 photos
Blacks v2-blacks-mix Per-channel/luminance mix 148 pairs, 19 photos 60 pairs, 6 photos

The exports and the optimizer that reads them remain private. Public tools in this change contain the operator family, sanitized fits with coefficients and aggregate errors, and the generator. Fit files contain no photo names, paths or per-photo values. Their provenance records the original fit filename and SHA-256. The Rust constants record each sanitized fit's SHA-256 as well.

The sanitizer omits error groups and individual bands with fewer than three distinct contributing photos. Published rows record the distinct photo counts for the group and its retained bands. Repeated pairs from one photo do not satisfy the threshold. Coefficients and overall counts remain available. These omissions do not change the fits or the full paired agreement measurements below.

python3 -m pip install -r tools/requirements-light-v3.txt
python3 tools/light_v3_constants.py rust
python3 tools/light_v3_constants.py rust --check

--check must reproduce crates/pipeline/src/light_v3_params.rs byte for byte. The generator writes reference outputs from tools/light_v3_family.py to 12 significant digits, avoiding platform-dependent final bits in NumPy. Rust tests compare the port against those references. sanitize accepts only Contrast and Blacks fits and writes their public aggregate form. python3 -m unittest discover -s tools -p 'test_light_v3_constants.py' checks the publication threshold with synthetic inputs and scans all committed fit files.

Measured agreement

The private agreement harness compares the log2-luminance response of each export pair with LightCraft's response to the same two recipes. The reported error, in stops, is the mean over rows (amount, reference and size) of the median over photos. Both builds use the same photos; a registration rejected in either run is omitted from the paired comparison. The historical V2 baseline is main at 76b53778. The six check photos were not used to fit the operators.

Slider V2 tune Calibrated tune V2 check Calibrated check
Contrast 0.168 0.059 0.136 0.038
Blacks 0.540 0.112 0.424 0.077
Whites 0.176 0.176 0.075 0.075

Contrast and Blacks values are the source calibration's measured results, reproduced by this split's verification. Whites uses V2. A single camera and one person's photos do not establish fidelity across cameras. Blacks still has larger errors in the darkest band. Exposure, Highlights, Shadows, Whites and other operators need further work. Local adjustments and sources without a camera curve have no new calibration.

Cost and stability

Each active operator has a 4 KiB source table, at most 8 KiB for both. CPU previews also build one composed table of 128 KiB when all active operators are per channel. Each of its 16,384 intervals stores an intercept and slope. The composed per-channel path uses three lookups per pixel and two colour matrix multiplications. A luminance mix builds no composed table. It evaluates the original encoding and source tables, luminance scaling and blend. There is no extra image pass, source read, per-photo state or statistic. Zero Contrast and Blacks skip the new work. Metal and high-bit-depth CPU exports allocate only the source tables.

Release measurements on an M3 Max used two Rayon threads and nine interleaved samples per case against main 701f78ea and round 2 41074392. The two public raw files were from a Sony ILCE-7M3 and Nikon D750. Fit used a 2560-pixel source preview in a 1920 by 1280 viewport; 1:1 windows used native source pixels in that viewport. Each worker decoded once and warmed its cache; every timed sample changed exposure. Decode, cache preparation and file encoding are excluded.

With Contrast +40 and Blacks -30, CPU wall times were 1.22 to 1.28 times main and 1.01 to 1.024 times round 2. A 27-sample confirmation of the slowest Nikon window case measured 75.33 ms on main, 94.76 ms on round 2 and 96.45 ms here. Its ratio is 1.28 times main and 1.018 times round 2. The fused path remains, with a small measured cost increase in this run.

With Contrast +40 and Blacks +30, CPU wall times were 1.97 to 2.02 times main and 1.20 to 1.28 times round 2. Blacks +30 alone took 1.61 to 1.64 times main and 1.17 to 1.19 times round 2. These positive-Blacks cases exceed the approximate 1.25-times-main cost target. They need the original operator evaluation because the extra preview interpolation can change the luminance cutoff result or have its error amplified by luminance scaling.

Metal medians for these settings were 3.25 to 4.97 ms, with the original GPU operators. CPU neutral controls were 0.99 to 1.00 times main. The general cargo xtask bench run also completed before and after the fix, with no CPU-time regression above its 20% threshold. Other work on the shared machine was not controlled. These measurements do not establish tail latency or performance on other GPU backends.

V1 and V2 keep their rendering and persistent cache keys. Persistent keys from process V4 on include the process this build actually renders with, so saved settings from a newer build cannot reuse pixels generated by a different resolved process. V4 keeps V2's Highlights and Shadows base. New imports and Reset select the latest process; Update to Current Process remains one undoable settings change and never downgrades a future number.

Revision history

Date Change Summary
2026-10-13 minor Preserve the original evaluation for luminance mixes, including preceding Contrast, and cover two uniform cutoff regressions
2026-10-13 minor Compose CPU preview lookups, preserve exact deep exports, and require three distinct photos per published error aggregate
2026-10-12 new Split Contrast and Blacks calibration, preserve V2 Whites and legacy rendering