From ad9b6dd2fe55dd92d162c6f8c7fc40057e8efbf8 Mon Sep 17 00:00:00 2001 From: i12bp8 Date: Sun, 26 Apr 2026 20:59:03 +0200 Subject: [PATCH] web-image-prep: hue-aligned tri-colour quantiser + serpentine dither The old quantiser pulled accent ink toward any chromatic pixel using a flat chroma bonus, which smeared red across shadows and produced flat red blobs without depth. Two fixes: - Distance now weights L (lightness) ~1.6x more than chroma, so dark pixels go to black and light pixels to white before colour competes. That preserves the photograph's tonal structure. - Accent attraction is the squared *signed projection* of the pixel's chroma onto the accent direction. Red ink only pulls red-leaning pixels (positive a*); blue/green never picks red. The squaring gives a smooth ramp, so faint reds get sparse red dither and saturated reds get dense red - matching how a real risograph layers a single chromatic ink under W/B halftones. Also flipped Floyd-Steinberg to a serpentine scan with mirrored kernel on reverse rows, killing the diagonal worm artefacts that gave smooth gradients an obviously-dithered look. --- web-image-prep/index.html | 91 +++++++++++++++++++++++++++------------ 1 file changed, 63 insertions(+), 28 deletions(-) diff --git a/web-image-prep/index.html b/web-image-prep/index.html index 5f97654..54f3989 100644 --- a/web-image-prep/index.html +++ b/web-image-prep/index.html @@ -722,22 +722,48 @@ function quantize(img, ACC) { buf[i*3+2] = srgb2lin(src[i*4+2]); } - /* Saturation-aware nearest in Oklab. The accent palette gets a tiny bonus - * proportional to chroma so the algorithm uses W+B for neutral pixels and - * mixes accent in only where the photo actually has colour — just like a - * real risograph or newsprint print. */ + /* Hue-aligned, lightness-weighted nearest-palette search. + * + * 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: + * + * 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. + * + * 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. + * + * 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. + */ + 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; + function nearest(r, g, b) { const lab = rgb2oklab(r, g, b); + const L = lab[0], a = lab[1], bp = lab[2]; let best = 0, bd = Infinity; for (let p = 0; p < paletteRGB.length; p++) { - const dl = lab[0] - paletteLab[p][0]; - const da = lab[1] - paletteLab[p][1]; - const db = lab[2] - paletteLab[p][2]; - let d = dl*dl + da*da + db*db; + const dl = L - paletteLab[p][0]; + const da = a - paletteLab[p][1]; + const db = bp - paletteLab[p][2]; + let d = L_WEIGHT * dl*dl + da*da + db*db; if (useColor && p === 2) { - /* Pull accent closer when the source pixel itself is chromatic. */ - const chroma = Math.hypot(lab[1], lab[2]); - d -= chroma * (0.012 + tilt * 0.018); + /* 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; } if (d < bd) { bd = d; best = p; } } @@ -764,23 +790,32 @@ function quantize(img, ACC) { } } else { const k = KERNELS[S.dither] || KERNELS.fs; - for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) { - 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); - idx[i] = p; - /* Diffuse residual error in linear RGB so neighbouring pixels can pick - * a different palette entry and the three colours visually blend. */ - const er = r - paletteRGB[p][0]; - const eg = g - paletteRGB[p][1]; - const eb = b - paletteRGB[p][2]; - for (const [dx, dy, w] of k) { - const nx = x+dx, ny = y+dy; - if (nx < 0 || nx >= W || ny < 0 || ny >= H) continue; - const j = (ny*W + nx) * 3; - buf[j] += er * w; - buf[j+1] += eg * w; - buf[j+2] += eb * w; + /* Serpentine scan: even rows left-to-right, odd rows right-to-left. + * Mirroring the kernel on reverse rows breaks up the diagonal worm + * artefacts that plain Floyd-Steinberg leaves in smooth gradients, + * which is the single biggest visible difference between "looks + * like a screenshot of a Mac dither" and a clean photograph. */ + for (let y = 0; y < H; y++) { + const rev = (y & 1) === 1; + const xStart = rev ? W - 1 : 0; + const xEnd = rev ? -1 : W; + const xStep = rev ? -1 : 1; + 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); + idx[i] = p; + const er = r - paletteRGB[p][0]; + const eg = g - paletteRGB[p][1]; + const eb = b - paletteRGB[p][2]; + for (const [dx, dy, w] of k) { + const nx = x + (rev ? -dx : dx), ny = y + dy; + if (nx < 0 || nx >= W || ny < 0 || ny >= H) continue; + const j = (ny*W + nx) * 3; + buf[j] += er * w; + buf[j+1] += eg * w; + buf[j+2] += eb * w; + } } } }