feat(importer): phase 6 — skip-white + paint-transparent toggles

Two quick quality-of-life options for logo and background-heavy imports:

- Skip-white: near-white pixels (r,g,b >= threshold, default 230) are
  marked transparent in the output so they don't consume credits. Useful
  when the source has a white background you don't want to paint.
- Paint-transparent: fully-transparent source pixels are treated as opaque
  white before quantization. Useful if the canvas background is white and
  you want empty regions of a logo to blend in.

Both toggles compose with every existing dither method; paint-transparent
runs first, so combining the two leaves originally-transparent pixels as
skip (since they're synthesized white, then the skip-white pass drops
them).

UI adds the two checkboxes and a threshold slider shown only when
skip-white is on. CLI gains --skip-white, --white-threshold,
--paint-transparent. 4 new unit tests pin the behavior including the
paint+skip interaction.
This commit is contained in:
tiennm99 committed 2026-04-17 11:58:55 +07:00
1 parent 6c35771445
commit d924a66c50
6 files changed
+129 -15

No files matched your search

@@ -2,7 +2,7 @@
## Overview
- **Priority:** P3 (low, easy win)
- **Status:** Todo
- **Status:** Done
- Two cheap toggles: treat near-white pixels as transparent (useful for logos on white backgrounds), and optionally paint pixels with alpha < threshold as white instead of skipping.
## Requirements
@@ -40,10 +40,10 @@ Logic:
5. Tests: near-white pixel → -1 iff skipWhite on; low-alpha pixel → -1 iff paintTransparent off.
## Todo
- [ ] Extend `rgbaToPalette` options
- [ ] UI toggles + threshold
- [ ] CLI flags
- [ ] Tests
- [x] Extend `rgbaToPalette` options (`skipWhite`, `whiteThreshold`, `paintTransparent`)
- [x] UI toggles + threshold slider
- [x] CLI `--skip-white`, `--white-threshold`, `--paint-transparent`
- [x] Tests (4 new cases in image-to-palette.test.js)
## Success Criteria
- With defaults, output matches pre-change output.
@@ -20,7 +20,7 @@ Grow the in-app Image Import panel into a full pre-placement pipeline: resize, t
| 3 | [Transforms (flip / rotate)](phase-03-transforms.md) | Done | Medium — quick fixes without re-editing source |
| 4 | [More dithering algorithms](phase-04-dithering-algorithms.md) | Done | Medium — different looks per source |
| 5 | [Color correction sliders](phase-05-color-correction.md) | Done | Medium — photos benefit most |
| 6 | [Skip-white + paint-transparent toggles](phase-06-skip-white.md) | Todo | Low — easy win, often useful for logos |
| 6 | [Skip-white + paint-transparent toggles](phase-06-skip-white.md) | Done | Low — easy win, often useful for logos |
Later / parked: multi-algorithm color distance (Lab/Oklab), Kuwahara smoothing, template save/load, repair mode. Revisit after Phase 6.
+10 -1
View File
@@ -35,6 +35,9 @@ const { values, positionals } = parseArgs({
contrast: { type: 'string', default: '0' },
saturation: { type: 'string', default: '0' },
gamma: { type: 'string', default: '1' },
'skip-white': { type: 'boolean', default: false },
'white-threshold': { type: 'string', default: '230' },
'paint-transparent': { type: 'boolean', default: false },
},
allowPositionals: true,
});
@@ -100,7 +103,13 @@ const working = hasCorrection
// sharp raw buffer has channels=4 after ensureAlpha; same layout as Canvas ImageData.
const ditherMethod = values['dither-method'] ?? (values.dither ? 'floyd' : 'none');
const indices = rgbaToPalette(working, outW, outH, { alphaThreshold, method: ditherMethod });
const indices = rgbaToPalette(working, outW, outH, {
alphaThreshold,
method: ditherMethod,
skipWhite: values['skip-white'],
whiteThreshold: parseInt(values['white-threshold'], 10),
paintTransparent: values['paint-transparent'],
});
const pixels = Array.from(indices);
const opaque = pixels.reduce((n, p) => n + (p >= 0 ? 1 : 0), 0);
+27 -1
View File
@@ -26,6 +26,9 @@
let originY = $state(0);
let skipMatching = $state(true);
let ditherMethod = $state('none'); // see DITHER_METHODS
let skipWhite = $state(false);
let whiteThreshold = $state(230);
let paintTransparent = $state(false);
// Resize controls — init to source dims on file load
let resizeW = $state(0);
@@ -108,7 +111,11 @@
const corrected = (brightness !== 0 || contrast !== 0 || saturation !== 0 || gamma !== 1)
? applyColorCorrection(resized, resizeW, resizeH, { brightness, contrast, saturation, gamma })
: resized;
const idx = rgbaToPalette(corrected, resizeW, resizeH, { method: ditherMethod });
const idx = rgbaToPalette(corrected, resizeW, resizeH, {
method: ditherMethod,
skipWhite, whiteThreshold,
paintTransparent,
});
paletteIndices = idx;
let count = 0;
for (let i = 0; i < idx.length; i++) if (idx[i] >= 0) count++;
@@ -362,6 +369,25 @@
<input type="checkbox" bind:checked={skipMatching} />
Skip pixels that already match
</label>
<div class="row">
<label class="checkbox">
<input type="checkbox" bind:checked={skipWhite} />
Skip white
</label>
{#if skipWhite}
<label style="flex: 1;">
threshold
<input type="range" min="128" max="255" step="1" bind:value={whiteThreshold} />
<span class="val" style="min-width: 28px;">{whiteThreshold}</span>
</label>
{/if}
</div>
<label class="row checkbox" title="Treat fully-transparent source pixels as opaque white before quantizing.">
<input type="checkbox" bind:checked={paintTransparent} />
Paint transparent as white
</label>
<div class="row">
<label>Dither
<select bind:value={ditherMethod} title="error-diffusion (floyd/atkinson/…) or ordered (bayer-*)">
+46 -7
View File
@@ -38,20 +38,59 @@ export function nearestColorIndex(r, g, b) {
* @param {number} [options.alphaThreshold=128]
* @param {boolean} [options.dither=false] - legacy; prefer `method`
* @param {('none'|'floyd'|'atkinson'|'jarvis'|'burkes'|'sierra'|'sierra-lite'|'bayer-2'|'bayer-4'|'bayer-8')} [options.method]
* @param {boolean} [options.skipWhite=false] - mark near-white source pixels as transparent
* @param {number} [options.whiteThreshold=230] - r,g,b >= this counts as "white"
* @param {boolean} [options.paintTransparent=false] - treat transparent pixels as opaque white before quantizing
* @returns {Int16Array} length = width*height
*/
export function rgbaToPalette(rgba, width, height, options = {}) {
const { alphaThreshold = 128, dither = false } = options;
const {
alphaThreshold = 128,
dither = false,
skipWhite = false,
whiteThreshold = 230,
paintTransparent = false,
} = options;
const method = options.method ?? (dither ? 'floyd' : 'none');
if (method === 'none') return quantizeNearest(rgba, width, height, alphaThreshold);
if (ERROR_DIFFUSION_KERNELS[method]) {
return runErrorDiffusion(rgba, width, height, alphaThreshold, ERROR_DIFFUSION_KERNELS[method]);
// Substitute transparent→opaque white up-front so the rest of the pipeline
// sees a consistent buffer. We only clone when substitutions actually occur.
let working = rgba;
if (paintTransparent) {
const cloned = new Uint8ClampedArray(rgba);
let changed = false;
for (let i = 0; i < cloned.length; i += 4) {
if (cloned[i + 3] < alphaThreshold) {
cloned[i] = 255; cloned[i + 1] = 255; cloned[i + 2] = 255; cloned[i + 3] = 255;
changed = true;
}
}
if (changed) working = cloned;
}
if (BAYER_MATRICES[method]) {
return runOrderedDither(rgba, width, height, alphaThreshold, BAYER_MATRICES[method]);
let out;
if (method === 'none') {
out = quantizeNearest(working, width, height, alphaThreshold);
} else if (ERROR_DIFFUSION_KERNELS[method]) {
out = runErrorDiffusion(working, width, height, alphaThreshold, ERROR_DIFFUSION_KERNELS[method]);
} else if (BAYER_MATRICES[method]) {
out = runOrderedDither(working, width, height, alphaThreshold, BAYER_MATRICES[method]);
} else {
throw new Error(`Unknown dither method: ${method}. Valid: ${DITHER_METHODS.join(', ')}.`);
}
throw new Error(`Unknown dither method: ${method}. Valid: ${DITHER_METHODS.join(', ')}.`);
// Post-pass: mark near-white pixels as skip, using the (possibly synthesized) working buffer.
if (skipWhite) {
const count = width * height;
for (let i = 0; i < count; i++) {
if (out[i] < 0) continue;
const o = i * 4;
if (working[o] >= whiteThreshold && working[o + 1] >= whiteThreshold && working[o + 2] >= whiteThreshold) {
out[i] = -1;
}
}
}
return out;
}
function quantizeNearest(rgba, width, height, alphaThreshold) {
+40
View File
@@ -82,6 +82,46 @@ describe('rgbaToPalette', () => {
});
});
describe('rgbaToPalette skip-white / paint-transparent', () => {
it('skipWhite marks near-white source pixels as -1', () => {
const src = new Uint8ClampedArray([
255, 255, 255, 255, // white
240, 240, 240, 255, // near-white (>= default threshold 230)
100, 0, 0, 255, // red-ish
]);
const idx = rgbaToPalette(src, 3, 1, { skipWhite: true });
expect(idx[0]).toBe(-1);
expect(idx[1]).toBe(-1);
expect(idx[2]).not.toBe(-1);
});
it('skipWhite respects a custom whiteThreshold', () => {
const src = new Uint8ClampedArray([
240, 240, 240, 255, // under custom threshold
255, 255, 255, 255, // above
]);
const idx = rgbaToPalette(src, 2, 1, { skipWhite: true, whiteThreshold: 250 });
expect(idx[0]).not.toBe(-1);
expect(idx[1]).toBe(-1);
});
it('paintTransparent maps transparent pixels to palette white (31)', () => {
const src = new Uint8ClampedArray([
100, 100, 100, 10, // transparent
255, 255, 255, 255, // white
]);
const idx = rgbaToPalette(src, 2, 1, { paintTransparent: true });
expect(idx[0]).toBe(31); // ffffff
expect(idx[1]).toBe(31);
});
it('paintTransparent + skipWhite: transparent pixels end up skipped', () => {
const src = new Uint8ClampedArray([100, 100, 100, 10]);
const idx = rgbaToPalette(src, 1, 1, { paintTransparent: true, skipWhite: true });
expect(idx[0]).toBe(-1);
});
});
describe('nearestColorIndex', () => {
it('exact palette match returns that index', () => {
// Palette 0 is #6d001a → (0x6d, 0x00, 0x1a)