Skip to content

Measure the per-pipeline dither defaults instead of choosing them - #34

Merged
defl merged 5 commits into
mainfrom
feat/dither-preset-defaults
Aug 2, 2026
Merged

Measure the per-pipeline dither defaults instead of choosing them#34
defl merged 5 commits into
mainfrom
feat/dither-preset-defaults

Conversation

@defl

@defl defl commented Aug 2, 2026

Copy link
Copy Markdown
Owner

Replaces the hand-picked defaults for the three image pipelines (general, B&W, faces) with the best-scoring configuration found by sweeping the existing settings. Defaults only — no new settings, and every value here is already expressible in config.json today.

The new defaults

pipeline algorithm LUT serpentine adaptive sat vivid DRC space
general atkinson hue_aware OKLAB OKLAB
B&W atkinson bw off OKLAB
faces atkinson hue_aware off CIELAB

Measured against the settings previously deployed:

pipeline improvement
general +4.7 % (neutral_leak 19.6 → 16.4)
B&W +18.5 %
faces +6.2 %, blue ink in saturated lips 2.9 % → 0.9 %

Method

Each row is the winner of a sweep over algorithm × LUT × saturation space × DRC space × adaptive-vivid × serpentine, scored per pipeline with the repo's own image_compare plus one added metric. Swept at 640×480, then the top five re-ranked at 1600×1200 — the error-diffusion cascade is resolution-sensitive, so the cheap sweep can't be trusted to pick the final winner.

Only those six dimensions were swept. The tonal chain (CLAHE, unsharp, gamma, midtone) is untouched and still hand-tuned; the face pipeline keeps its gentler CLAHE and stronger unsharp.

Full method, numbers and dead ends: docs/dither_search.md.

Three things worth reviewing carefully

The DRC space is now set per pipeline. OKLAB won for general and B&W and scored worse on faces. There is no single right answer to apply globally, which is a slightly awkward outcome but it's what the numbers say.

No OKLAB or CAM16-UCS LUT survived anywhere. On flat colour swatches they give ~6× less blue ink in warm tones, which makes them look like the obvious fix for the blue-lips artifact. On photographs they leak markedly more colour into neutrals and score 10–26 % worse. That reversal is the most interesting result here and is documented in §4d/§4e.

The corpus had no saturated lips at all — its most likely portrait carried 0.13 % of pixels above C* 38 and none above C* 50, so the artifact could not be measured on real images. Two freely-licensed portraits were added to fix that (CC BY 2.0 / CC BY-SA 4.0, attributed in images/test/CREDITS.md per the existing convention). This is also why the existing metrics never flagged the artifact.

Compatibility

Defaults only. An existing install keeps whatever is in its config.json — the stored blob merges over these defaults exactly as before. A fresh install, or a pipeline field never set, picks up the new values. Changing them does change cache_slug, so cached renders rebuild once.

Also here

  • tools/dither_search.py — the search harness, so this is reproducible and re-runnable rather than a one-off.
  • One test fix: test_image_field_with_partial_blob_falls_back_to_default asserted against a named preset rather than DEFAULT_IMAGE_CONFIG, so it failed whenever the default was retuned even though the behaviour under test was unchanged.

Caveats

  • Scoring weights (PROFILE_WEIGHTS) are a judgement call, not a measurement. A different weighting reorders the tables.
  • Everything is scored against palette_measured_rgb, whose provenance is weak on every model. A CIELAB-vs-OKLAB comparison is therefore partly a comparison of how each space tolerates wrong anchors.
  • These were applied to a live server and are being looked at on real frames; if they look worse in practice, that beats the metric.

843 passed, ruff and pyright clean, config.json.example regenerated.

🤖 Generated with Claude Code

defl and others added 5 commits August 1, 2026 19:55
…ive result

Investigates why warm mid-tones pick up blue ink, and searches the existing
configuration space for better general / B&W / faces presets. The headline
result is negative and the reason is the point: the search finds nothing because
the test corpus does not contain the failure it is meant to fix.

Two claims in dithering.md turn out to be wrong, both now evidenced:

1. `hue_aware` does NOT fix the warm-skin cascade. It is bit-identical to
   `euclidean` on every swatch and across the whole corpus. §5a presents it as
   the fix for exactly this artifact. The gate evaluates the hue of the value
   being looked up, not of the source pixel — error diffusion has already walked
   the accumulated value into genuinely bluish territory, where blue IS the
   hue-appropriate ink and the gate correctly permits it. Tightening the cutoff
   from 95 to 60 degrees changes 250 of 32768 LUT cells and nothing visible.

2. The tonal chain makes the artifact worse, not better. Flat lip colour goes
   22.4% blue with no prep and 30.6% with the production chain.

On flat swatches OKLAB/CAM16-UCS give ~6x less blue ink than the CIELAB LUTs,
matching the field observation that only oklab avoids blue lips. On the photo
corpus they rank *below* CIELAB — and §5 of the doc explains that contradiction:
the corpus has no saturated red lips at all. The high-chroma warm population is
dominated by a synthetic RGB gradient plus warm backgrounds and clothing; the
one portrait most likely to have lipstick contributes 0.13% of pixels above
C* 38 and nothing above C* 50. So that metric is measuring gradients, and
optimising it optimises noise. It also explains why the existing metrics never
flagged this: no test image exhibits it.

tools/dither_search.py renders the real corpus through the production renderer,
splits it with the server's own classifiers so each image lands in the pipeline
that would really handle it, and ranks candidates with the repo's own
image_compare so numbers stay comparable to test_dither_quality_metrics. It adds
one metric to close a hole — neutral_blue_fraction only inspects source-neutral
pixels — split by chroma into warm_blue (skin-dominated) and lips_blue (the
actual artifact), because a single broad band averages a 6x effect away.

Only actionable win: B&W prefers Atkinson over Floyd-Steinberg with the bw LUT
(39.841 vs 40.222, ~1%). General and faces are already at the optimum of this
search space.

No production settings changed — this commit is the investigation and the tool.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 2bbac44ee94e04269730937e898af78f4a11e5e8)
The first pass swept algorithm x LUT only — 2 of roughly 20 available settings —
and concluded the presets were already optimal. That was wrong, and the omitted
dimensions are exactly the ones that matter: adaptive_saturate_space,
drc_l_space / drc_chroma_space, adaptive_vivid and serpentine.

The evidence was already in the previous commit and I did not follow it: the
artifact gets WORSE under the tonal chain (22.4% -> 30.6% blue on lip colour),
and that chain IS adaptive-saturate + DRC. Sweeping the dither while holding the
amplifier fixed could not find the fix.

Measured directly on lip swatches:
  production (hue_aware, sat=cielab, drc=cielab)   9.39% blue
  oklab LUT                                        2.20%
  oklab LUT + drc=oklab                            1.47%
i.e. 6.4x better on existing settings, with the DRC space worth 1.5x on its own.

Also fixes the synthetic corpus. Rendering each swatch as its own uniform image
reported a suspicious 0.00% blue: a uniform image has zero dynamic range and the
pipeline starts with ImageOps.autocontrast, which stretches a single histogram
value to arbitrary output — nothing lip-coloured reached the dither. Swatches are
now tiled into one sheet with explicit black/white anchor tiles so autocontrast
has real endpoints, and metrics are taken per tile region.

Full two-stage search on the sheet:
  general  atkinson_hue_aware 40.756 -> stucki+cam16ucs_hue_aware drc=oklab 38.733  (5.0%)
  bw       floyd_steinberg_bw 35.572 -> stucki+bw drc=oklab            35.197  (1.1%)
  faces    atkinson_hue_aware 65.153 -> stucki+cam16ucs                62.383  (4.3%)

Robust across both measurement methods: Stucki over Atkinson/Floyd-Steinberg
everywhere, CAM16-UCS or OKLAB over the CIELAB LUTs, drc=oklab for general/bw.

One contradiction left open and documented rather than papered over: the sheet
prefers adaptive_saturate=off while the dither-only test prefers cielab. The
difference is the PIL prep phase, and CLAHE on flat tiles is a degenerate case —
it manufactures local contrast where a real lip has texture. So the sheet is
trustworthy for algorithm and LUT, not for the saturation setting.

No production presets changed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit df7ece10bcdafb48f14955cc663a47b97c657eab)
Validates the previous commit's recommendation against the real photo corpus,
head-to-head with the shipped presets. It does not survive.

  general  production 59.466 -> swatch winner 62.053 (-4.4%), conservative 64.142 (-7.9%)
  bw       production 40.222 -> 39.988 (+0.6% better)
  faces    production 61.041 -> swatch winner 67.141 (-10.0%), conservative 72.575 (-18.9%)

On faces the "fix" is nearly 19% worse and makes the very artifact it targets
worse: lips_blue 1.22% -> 3.19%. The regression concentrates in neutral_leak,
15 -> 23 on both colour profiles: the CAM16-UCS/OKLAB LUTs leak markedly more
colour into near-neutral areas — skies, walls, grey clothing — on real images.

The two corpora are blind in opposite directions, which is the real lesson:

  swatch sheet   sees saturated warm colour and the cascade in flat areas;
                 cannot see neutrals (one tile), texture, or sane CLAHE
  photo corpus   sees neutrals, texture and a realistic tonal chain;
                 cannot see saturated lips (none exist in it)

Optimising against either alone yields a config that is worse in the other's
blind spot. Only the B&W change wins on both and is safe to adopt.

Retracts the "switch the colour LUTs" recommendation. Two recommendations in
this document have now been walked back — "already optimal" (under-searched) and
this one (unvalidated) — and the doc says so explicitly, so the next reader
treats anything past the B&W result as a hypothesis with a test attached.

The hue_aware finding is unaffected and still stands on its own evidence.

No production presets changed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit dd0e26ac449b4b75931ea1dbe0972a00c94020ee)
…tled

Closes the corpus gap identified in the previous commit. The test set contained
no saturated red lips at all — its most likely portrait carried 0.13 % of pixels
above C* 38 and none above C* 50 — so the artifact this investigation is about
could not be measured on photographs.

Two freely-licensed Wikimedia Commons portraits added, both attributed in
images/test/CREDITS.md with licence, author, description and personality-rights
notes per the existing convention:

  Brunette_red_lipstick.jpg (CC BY 2.0, THOR) — red lips against a grey backdrop
  with dark hair and black clothing, so it carries the saturated warm colour AND
  the neutrals in one frame. This is the combination neither corpus had: the
  swatch sheet has one neutral tile, the photo set had no saturated lips.

  Applying_red_lipstick_model_Eve_Casini.jpg (CC BY-SA 4.0, Stephencdickson) —
  extreme close-up, 58.7 % of pixels above C* 38, peak warm chroma 76.

With the blind spot closed the verdict hardens rather than softens:

  faces 640x480     production 58.257  swatch winner 65.321 (-12.1%)  conservative 73.491 (-26.2%)
  faces 3200x1600   production 62.067  swatch winner 68.539 (-10.4%)  conservative 74.972 (-20.8%)

And on the metric that motivated the whole exercise, measured on real lipstick
at full panel resolution: production 0.64 % blue, swatch winner 1.88 %,
conservative 3.09 %. The shipped pipeline already handles real lipstick well and
the proposed change makes it ~5x worse.

Full panel resolution was checked deliberately — lips occupy far more pixels
there, approaching the flat-field case that produced the 6.4x result — and it
changes nothing. So the flat-swatch catastrophe (30 % blue) does not occur in
photographs: a uniform colour field is the pathological case for error
diffusion, and real lips carry texture, specular highlight and gradient that
break the cascade before it can run.

The LUT recommendation is now ruled out rather than merely unproven. The B&W
change (Stucki + bw LUT + drc=oklab) remains the one adoptable result. If blue
lips are still seen in the field, the doc now points at the likelier suspect:
the LUTs are built from palette_measured_rgb, whose anchors are unverified.

No production presets changed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit d381ba3b1040ae1dd466cb5db96fb89ea5a4767a)
Replaces the hand-picked defaults for the three image pipelines with the
best-scoring configuration found by sweeping the existing settings.

  pipeline  algorithm  LUT        serp  adaptive sat  vivid  DRC space
  general   atkinson   hue_aware  yes   OKLAB         yes    OKLAB
  bw        atkinson   bw         yes   off           no     OKLAB
  faces     atkinson   hue_aware  yes   off           no     CIELAB

Each row is the winner of a sweep over algorithm x LUT x saturation space x DRC
space x adaptive-vivid x serpentine, scored per pipeline on the test corpus with
the repo's own image_compare plus one added metric (see below), swept at 640x480
and the top five re-ranked at 1600x1200 because the error-diffusion cascade is
resolution-sensitive. Method and full numbers in docs/dither_search.md.

Only those six dimensions were swept. The tonal chain — CLAHE, unsharp, gamma,
midtone — is untouched and still hand-tuned, and the face pipeline keeps its
gentler CLAHE and stronger/wider unsharp.

Measured against the settings previously deployed:
  general  +4.7%   (neutral_leak 19.6 -> 16.4)
  bw      +18.5%
  faces   +6.2%,  blue ink in saturated lips 2.9% -> 0.9%

Three findings worth keeping in view:

The DRC space is now set PER PIPELINE. OKLAB won for general and B&W and scored
worse on faces, so there is no single right answer to apply globally.

Faces turn both chroma boosters off. Skin and lips are where boosting hurts, and
this is the change that most reduces the blue-lips artifact.

No OKLAB or CAM16-UCS *LUT* survived anywhere. On flat colour swatches they give
6x less blue ink in warm tones, which is why they look like the obvious fix; on
photographs they leak markedly more colour into neutrals and score 10-26% worse.
That reversal is documented in dither_search.md 4d/4e — the corpus originally had
no saturated lips at all, so the artifact could not be measured on real images
until two freely-licensed lipstick portraits were added (CC BY 2.0 / CC BY-SA
4.0, credited in images/test/CREDITS.md).

This changes DEFAULTS ONLY. An existing install keeps whatever is in its
config.json; the stored blob merges over these defaults as before. A fresh
install, or a pipeline field that has never been set, picks up the new values.
Changing them does change cache_slug, so cached renders rebuild once.

test_image_field_with_partial_blob_falls_back_to_default asserted against a
named preset rather than DEFAULT_IMAGE_CONFIG, so it failed whenever the default
was retuned even though the behaviour under test was unchanged. Now asserts the
contract it means to.

843 passed, ruff and pyright clean; config.json.example regenerated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@defl
defl merged commit f1defb0 into main Aug 2, 2026
19 checks passed
@defl
defl deleted the feat/dither-preset-defaults branch August 2, 2026 04:01
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