mirror of
https://github.com/tiennm99/rplace.git
synced 2026-10-14 18:19:15 +00:00
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:
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.
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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-*)">
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user