Skip to content

feat(it8): average the white balance over the grey wedge's midtone band - #124

Open
toonoumi wants to merge 1 commit into
mainfrom
feat/wb-neutral-range-average
Open

feat(it8): average the white balance over the grey wedge's midtone band#124
toonoumi wants to merge 1 commit into
mainfrom
feat/wb-neutral-range-average

Conversation

@toonoumi

@toonoumi toonoumi commented Aug 13, 2026

Copy link
Copy Markdown
Owner

The problem

The camera fit read its white balance off one patch — _pick_wb_id's lightest near-neutral. Since #123 made profiles bake their calibration neutral, that single measurement's noise, dust or specular sheen rides into every frame the profile is applied to.

On the maintainer's chart — LaserSoft ISO 12641-2 advanced, Provia 100F, block A49L72 — the patch it picked was C49: L*87.25, chroma 1.40. That's the near-white left end of a dye ramp, not a designed neutral, and the patch most likely to clip a channel. The chart's real neutrals, a 96-step grey wedge in rows G–J, contributed nothing.

The change

fit_camera_matrix now averages the WB over the midtone band of the grey ramp — WB_L_RANGE, L* 24–64, which is rows H–I of that wedge and roughly GS16GS22 of a classic 24-step strip. The band avoids both ends deliberately: above it a channel clips and the chart's Dmin flattens; below it veiling flare and black-level error bend the channel ratios, which is exactly the quantity being estimated.

Each patch is corrected against its reference Lab, not forced to equal-RGB. This is not an optimisation. The wedge is not colorimetrically neutral — across H49–I72 its chroma runs 3.0–4.8 with mean a* −3.51, b* −2.09 — and a systematic cast does not average out. Forcing those patches to grey would bake the cast into the neutral with the sign flipped. So each patch is measured against M⁻¹·X_ref, the device value that would actually produce its measured colour, which is what makes non-neutral candidates safe to average in the first place.

Because balancing is diagonal and M is a full 3×3 least squares, wb enters the render only through the D50 pin — the diag(wb) factors cancel out of the correction (spec §4.3). So this is a short fixed point that settles in one or two passes rather than fighting the matrix fit, with per-channel MAD outlier rejection so one dusty patch can't pull it.

The anchor patch is unchanged: it still sets the exposure scale, the white-relative reference normalisation, and the cLUT residual anchor. Only the channel ratios move.

Measured on the real chart

Sampling DSC07095.ARW against E210616.cxf through the real sampling + fit path. The block quad was aligned by minimising within-patch variance (an objective that uses no reference data, so it cannot bias the comparison), and the ΔE recomputation was cross-checked against the fit's own avg_de to the third decimal.

anchor patch (unchanged): C49
WB patches averaged     : H49–I64 (40 patches)     <- 8 darkest trimmed by MAD

wb_mult  OLD (single patch) : [0.86492 1.0 1.24015]
wb_mult  NEW (H+I averaged) : [0.81688 1.0 1.14740]

dE2000 mean                                 OLD      NEW    change
ALL 288 patches                           3.803    3.492     -8.2%
GREY wedge G-J                            4.298    2.647    -38.4%
  ...of which, the 40 averaged            4.490    1.228    -72.7%
  ...greys NOT averaged (G, J, I65-72)    4.161    3.660    -12.0%
COLOUR rows A-F,K-L (held out of the WB)  3.555    3.914    +10.1%

This is a trade, not a free win. The neutral axis improves a lot — including the greys that were not in the average, which is genuine generalisation and not just fitting the patches we optimised. Overall ΔE improves 8.2%. But the saturated colour patches, which are held out of the WB estimate entirely, get 10% worse. That follows from the algebra in §4.3: wb acts only through the D50 pin, so it is a diagonal in XYZ that trades error between the neutral axis and everything else. Pinning white where the greys say it is moves error onto the chromatic patches.

Which band is best (same shot, same alignment)

WB source                        ALL   COLOUR     GREY   wb_mult
single patch C49 (before)      3.803    3.555    4.298   [0.8649 1 1.2401]
G only        (highlight)      3.117    3.172    3.006   [0.8452 1 1.2112]
G+H                            3.094    3.331    2.618   [0.8373 1 1.1881]
H only                         3.234    3.600    2.501   [0.8276 1 1.1666]
H+I   (shipped default)        3.492    3.914    2.647   [0.8169 1 1.1474]
I only                         3.872    4.283    3.051   [0.7906 1 1.1374]
I+J           (shadow)         4.289    4.684    3.497   [0.7806 1 1.1199]
G+H+I+J   (whole wedge)        3.296    3.686    2.515   [0.8229 1 1.1612]

Every band beats the single patch overall. But the estimate drifts monotonically as the band moves into the shadows (wb_mult[0] 0.865 → 0.781), exactly the veiling-flare/black-level signature §2 predicts — and accuracy degrades with it. G+H is the optimum: essentially the same neutral accuracy as the shipped H+I (2.618 vs 2.647) with markedly better colour (3.331 vs 3.914) and the best overall (3.094 vs 3.492).

The shipped default is H+I because that is what was specified. Changing WB_L_RANGE to (44.0, 88.0) selects G+H instead; the sweep above is the evidence for doing so.

Compatibility

  • Fewer than 3 usable candidates ⇒ the old single-patch formula, bit-for-bit, with wb_ids == [wb_id].
  • CameraFit gains wb_ids; it's transient (built by the wizard, consumed by the builders), so no persistence or migration.
  • color_management.py and dcp_profile.py are untouched — they consume fit.wb_mult, so the better neutral flows into the ICC CCRn tag and the DCP AsShotNeutral automatically.
  • The wizard's summary line now reads "white-balanced on H49–I72 (48 patches)", still a bare id on the fallback.
  • Two escape hatches, no UI: wb_ids=[...] for an explicit list (skips the neutrality/band gates), wb_l_range=(lo,hi) for a different band. wb_ids=[] disables averaging.

Tests

1076 passed, 9 skipped, 0 failed (suite run in chunks; the full run's pre-existing order-dependent hang is unrelated).

10 new tests in tests/test_it8_profile.py: exact-neutral parity, the systematic-cast regression (asserts the naive average is biased and the corrected one isn't), noise averaging over a seed sweep, outlier rejection, band selection and both overrides, single-patch fallback, invalid/noise-floor exclusion, anchor-independence + convergence, and that the averaged neutral reaches both baked containers.

One fixture note worth flagging for review: a pure grey ramp is rank-deficient for a 3×3 fit, so the wedge fixtures include colour patches — with any accidentally near-neutral one filtered out, so the wedge stays the only candidate set.

🤖 Generated with Claude Code

The camera fit read its white balance off ONE patch — `_pick_wb_id`'s lightest
near-neutral. Since profiles started baking their calibration neutral, that single
measurement's noise, dust or specular sheen rides into every frame the profile is
applied to.

On the maintainer's chart (LaserSoft ISO 12641-2 advanced, block A49-L72) the patch
it picked was `C49`: L*87.25, chroma 1.40 — the near-white left end of a dye ramp,
not a designed neutral, and the one most likely to clip a channel. The chart's
96-step grey wedge in rows G-J contributed nothing at all.

`fit_camera_matrix` now averages over the midtone band of the ramp (`WB_L_RANGE`,
L* 24-64: rows H-I of that wedge, roughly GS16-GS22 of a classic strip) — below the
highlight end where a channel clips, above the shadows where veiling flare and
black-level error bend the channel ratios, which is the very quantity being
estimated.

Each patch is corrected against the colour the REFERENCE says it is, not forced to
equal-RGB. That is not an optimisation: the wedge carries a systematic cast (chroma
3.0-4.8 across H49-I72, mean a* -3.51, b* -2.09), and a systematic cast does not
average out — a naive ratio average would bake it into the neutral with the sign
flipped. Because `wb` enters the fit only through the D50 pin, the correction is a
short fixed point that settles in one or two passes, with MAD outlier rejection so
one bad patch cannot pull it.

Measured on the real chart shot: the grey wedge's mean chroma error against its
reference drops 3.548 -> 1.367 (max 4.370 -> 2.345). The anchor patch is unchanged —
it still sets the exposure/white-relative normalisation and the cLUT residual anchor;
only the channel ratios move. Charts with fewer than three usable candidates keep the
old single-patch behaviour exactly.

Spec: spec/wb-neutral-range-average.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant