Measure the per-pipeline dither defaults instead of choosing them - #34
Merged
Conversation
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.jsontoday.The new defaults
Measured against the settings previously deployed:
neutral_leak19.6 → 16.4)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_compareplus 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.mdper 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 changecache_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.test_image_field_with_partial_blob_falls_back_to_defaultasserted against a named preset rather thanDEFAULT_IMAGE_CONFIG, so it failed whenever the default was retuned even though the behaviour under test was unchanged.Caveats
PROFILE_WEIGHTS) are a judgement call, not a measurement. A different weighting reorders the tables.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.843 passed, ruff and pyright clean,
config.json.exampleregenerated.🤖 Generated with Claude Code