mirror of
https://github.com/i12bp8/TagTinker.git
synced 2026-08-28 05:14:10 +00:00
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.
This commit is contained in:
+63
-28
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user