diff --git a/web-image-prep/index.html b/web-image-prep/index.html index 54f3989..c78ce00 100644 --- a/web-image-prep/index.html +++ b/web-image-prep/index.html @@ -722,36 +722,42 @@ function quantize(img, ACC) { buf[i*3+2] = srgb2lin(src[i*4+2]); } - /* Hue-aligned, lightness-weighted nearest-palette search. + /* Modern e-paper tri-colour quantiser - the goal is the "newsprint + * illusion" where the eye fuses W/B halftone density into apparent + * grey shades and the single chromatic ink only appears as a faint + * accent over warm regions, NOT as a solid colour fill. * - * The naive Oklab nearest treated red and black as equidistant from a - * dark-red pixel, so accent ink got smeared across shadow areas, washing - * out depth. Two changes fix that: + * Three knobs make this work, all tuned for the Oklab metric used + * below (typical photo chroma magnitude ~0.05..0.25, never anywhere + * near the accent's own ~0.20): * - * 1. Distance weights L (lightness) ~1.6x more than chroma, so shadows - * always pick black and highlights always pick white before colour - * ever wins. That keeps tonal structure of the photo intact. + * 1. L_WEIGHT - lightness dominates the distance so shadows pick + * black and highlights pick white before chroma is even a + * tie-breaker. This is what keeps the tonal structure intact. * - * 2. Accent gets a bonus proportional to the *signed projection* of the - * pixel's chroma onto the accent direction. Red ink is only pulled - * toward pixels whose hue actually leans red (positive a*); a blue - * or green pixel never picks red. Squaring the projection gives a - * smooth ramp so faintly-red regions get sparse red dither and - * saturated reds get dense red, matching how a real risograph layers - * a single chromatic ink under W/B halftones. + * 2. CHROMA_CAP - the source image's a/b axes are pre-scaled + * toward grey BEFORE dithering. With only one chromatic ink + * to spend, we don't want to "owe" the error diffuser more + * red than the paper can deliver - that's the bug that made + * everything red. Treating the source as low-saturation lets + * FS spread the residual through black and white naturally. * - * The result is a photo-grade tri-colour quantisation where red/yellow - * lives where it belongs and depth/shading is carried by W+B mixing. + * 3. accentPull - very small bonus for hue-aligned pixels, just + * enough to break ties in favour of accent in the most-saturated + * regions. The bonus uses the *signed projection* of the pixel's + * chroma onto the accent direction so opposite-hue pixels (a + * blue sky) never pick red. */ + const L_WEIGHT = 3.5; + /* Map source chroma 1.0 -> accent's chroma * 1.1 so even the most + * saturated source pixel never sits beyond what one ink can render. */ const accentLab = useColor ? paletteLab[2] : null; const accentMag = accentLab ? Math.hypot(accentLab[1], accentLab[2]) || 1e-9 : 1; - /* Strength of the accent attraction. Detail slider can tilt -50%..+50%. */ - const accentPull = 0.85 + tilt * 0.6; - const L_WEIGHT = 1.6; + const CHROMA_CAP = useColor ? Math.min(1, (accentMag * 1.1)) : 1; + /* Detail slider tilts attraction +/- ~30%. */ + const accentPull = 0.06 * (1 + tilt * 0.5); - function nearest(r, g, b) { - const lab = rgb2oklab(r, g, b); - const L = lab[0], a = lab[1], bp = lab[2]; + function nearest(L, a, bp) { let best = 0, bd = Infinity; for (let p = 0; p < paletteRGB.length; p++) { const dl = L - paletteLab[p][0]; @@ -759,8 +765,6 @@ function quantize(img, ACC) { const db = bp - paletteLab[p][2]; let d = L_WEIGHT * dl*dl + da*da + db*db; if (useColor && p === 2) { - /* Signed projection of pixel chroma onto accent chroma direction. - * Positive only when the pixel hue is aligned with the accent. */ const proj = (a * accentLab[1] + bp * accentLab[2]) / accentMag; const align = Math.max(0, proj); d -= align * align * accentPull; @@ -770,6 +774,18 @@ function quantize(img, ACC) { return best; } + /* Sample the (already-tone-mapped) linear-RGB working pixel into the + * Oklab the dither operates on, with chroma contracted toward grey + * so we don't ask for more colour than three inks can deliver. */ + function sampleLab(r, g, b) { + const lab = rgb2oklab(r, g, b); + if (useColor) { + lab[1] *= CHROMA_CAP; + lab[2] *= CHROMA_CAP; + } + return lab; + } + if (S.dither === "thr" || S.dither === "bayer8") { const m = S.dither === "bayer8" ? BAYER8 : null; for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) { @@ -786,7 +802,8 @@ function quantize(img, ACC) { continue; } } - idx[i] = nearest(r, g, b); + const lab = sampleLab(r, g, b); + idx[i] = nearest(lab[0], lab[1], lab[2]); } } else { const k = KERNELS[S.dither] || KERNELS.fs; @@ -803,7 +820,8 @@ function quantize(img, ACC) { for (let x = xStart; x !== xEnd; x += xStep) { const i = y*W + x; const r = buf[i*3], g = buf[i*3+1], b = buf[i*3+2]; - const p = nearest(r, g, b); + const lab = sampleLab(r, g, b); + const p = nearest(lab[0], lab[1], lab[2]); idx[i] = p; const er = r - paletteRGB[p][0]; const eg = g - paletteRGB[p][1];