feat(importer): phase 5 — color correction sliders

Brightness, contrast, saturation, and gamma adjustments before palette
quantization. Pure-function applyColorCorrection in src/lib keeps the CLI
and browser importer in sync. Inserted post-resize so slider moves stay
responsive on large sources; precision loss is negligible through a
32-color palette.

UI is a collapsible "Color correction" section with four sliders, live
values, a "Reset" button, and an "on" badge when any value differs from
default. CLI gains --brightness, --contrast, --saturation, --gamma
(all -100..+100 except gamma which is 0.1..3.0).

9 unit tests pin down identity, saturate-to-0-or-255 clamping, greyscale
at saturation=-100, gamma midtone direction, and alpha preservation.
This commit is contained in:
tiennm99 committed 2026-04-17 11:56:35 +07:00
1 parent 4a5b6ef903
commit 6c35771445
6 files changed
+224 -9

No files matched your search

@@ -2,7 +2,7 @@
## Overview
- **Priority:** P2 (medium)
- **Status:** Todo
- **Status:** Done
- Sliders to adjust brightness, contrast, saturation, and gamma so photos map better onto our 32-color palette.
## Key Insights
@@ -43,10 +43,10 @@ srcRgba → transform → colorCorrect → resize → palette
4. CLI flags.
## Todo
- [ ] `image-color-correction.js` + tests
- [ ] Sliders + reset button
- [ ] CLI flags
- [ ] Verify no jank on 512×512 with live slider
- [x] `image-color-correction.js` + 9 tests
- [x] Collapsible section with 4 sliders + reset
- [x] CLI `--brightness`, `--contrast`, `--saturation`, `--gamma`
- [x] Inserted post-resize (before palette) for slider responsiveness on large sources
## Success Criteria
- Default sliders → output identical to previous pipeline (regression guard).
@@ -19,7 +19,7 @@ Grow the in-app Image Import panel into a full pre-placement pipeline: resize, t
| 2 | [Overlay preview on canvas](phase-02-overlay-preview.md) | Done | High — visualize alignment before spending credits |
| 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) | Todo | Medium — photos benefit most |
| 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 |
Later / parked: multi-algorithm color distance (Lab/Oklab), Kuwahara smoothing, template save/load, repair mode. Revisit after Phase 6.
+16 -2
View File
@@ -16,6 +16,7 @@ import { CANVAS_WIDTH, CANVAS_HEIGHT } from '../src/lib/constants.js';
import { rgbaToPalette } from '../src/lib/image-to-palette.js';
import { resizeRgba } from '../src/lib/image-resize.js';
import { transformRgba } from '../src/lib/image-transform.js';
import { applyColorCorrection } from '../src/lib/image-color-correction.js';
const { values, positionals } = parseArgs({
args: process.argv.slice(2),
@@ -30,12 +31,16 @@ const { values, positionals } = parseArgs({
'flip-h': { type: 'boolean', default: false },
'flip-v': { type: 'boolean', default: false },
rotate: { type: 'string', default: '0' }, // 0 | 90 | 180 | 270 (CW)
brightness: { type: 'string', default: '0' },
contrast: { type: 'string', default: '0' },
saturation: { type: 'string', default: '0' },
gamma: { type: 'string', default: '1' },
},
allowPositionals: true,
});
if (positionals.length < 1) {
console.error('Usage: node scripts/image-to-colors.js <input> [-o output.json] [--alpha-threshold 128] [--dither|--dither-method <name>] [--width N] [--height N] [--method nearest|bilinear|box] [--flip-h] [--flip-v] [--rotate 0|90|180|270]');
console.error('Usage: node scripts/image-to-colors.js <input> [-o output.json] [--alpha-threshold 128] [--dither|--dither-method <name>] [--width N] [--height N] [--method nearest|bilinear|box] [--flip-h] [--flip-v] [--rotate 0|90|180|270] [--brightness -100..100] [--contrast -100..100] [--saturation -100..100] [--gamma 0.1..3]');
process.exit(1);
}
@@ -80,10 +85,19 @@ if (outW > CANVAS_WIDTH || outH > CANVAS_HEIGHT) {
console.warn(`Warning: output ${outW}x${outH} exceeds canvas ${CANVAS_WIDTH}x${CANVAS_HEIGHT}. Upload will fail at the boundary.`);
}
const working = (outW === srcW && outH === srcH)
const resized = (outW === srcW && outH === srcH)
? transformed.rgba
: resizeRgba(transformed.rgba, srcW, srcH, outW, outH, values.method);
const brightness = parseFloat(values.brightness);
const contrast = parseFloat(values.contrast);
const saturation = parseFloat(values.saturation);
const gamma = parseFloat(values.gamma);
const hasCorrection = brightness !== 0 || contrast !== 0 || saturation !== 0 || gamma !== 1;
const working = hasCorrection
? applyColorCorrection(resized, outW, outH, { brightness, contrast, saturation, gamma })
: resized;
// 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 });
+65 -1
View File
@@ -3,6 +3,7 @@
import { rgbaToPalette, paletteToRgba, DITHER_METHODS } from '../../lib/image-to-palette.js';
import { resizeRgba } from '../../lib/image-resize.js';
import { transformRgba } from '../../lib/image-transform.js';
import { applyColorCorrection } from '../../lib/image-color-correction.js';
import { createImageUploader } from '../../lib/image-uploader.js';
let { open, cursorPos, getCommittedColor, setOverlay, onClose, onCredits } = $props();
@@ -41,6 +42,13 @@
let flipV = $state(false);
let rotation = $state(0); // 0 | 90 | 180 | 270
// Color correction
let brightness = $state(0); // -100..+100
let contrast = $state(0); // -100..+100
let saturation = $state(0); // -100..+100
let gamma = $state(1); // 0.1..3.0
let correctionOpen = $state(false); // collapsible
// Run state
let status = $state('idle'); // 'idle' | 'running' | 'paused' | 'done' | 'error'
let placed = $state(0);
@@ -97,7 +105,10 @@
const resized = (resizeW === transformed.width && resizeH === transformed.height)
? transformed.rgba
: resizeRgba(transformed.rgba, transformed.width, transformed.height, resizeW, resizeH, resampleMethod);
const idx = rgbaToPalette(resized, resizeW, resizeH, { method: ditherMethod });
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 });
paletteIndices = idx;
let count = 0;
for (let i = 0; i < idx.length; i++) if (idx[i] >= 0) count++;
@@ -160,6 +171,13 @@
}
}
function resetCorrection() {
brightness = 0;
contrast = 0;
saturation = 0;
gamma = 1;
}
$effect(() => { if (open && paletteIndices) renderPreview(); });
// Push overlay state to the canvas renderer whenever its inputs change.
@@ -354,6 +372,39 @@
</label>
</div>
<div class="section">
<button class="section-head" onclick={() => correctionOpen = !correctionOpen}>
{correctionOpen ? '▾' : '▸'} Color correction
{#if brightness || contrast || saturation || gamma !== 1}<span class="badge">on</span>{/if}
</button>
{#if correctionOpen}
<div class="slider-row">
<span class="lbl">brightness</span>
<input type="range" min="-100" max="100" step="1" bind:value={brightness} />
<span class="val">{brightness}</span>
</div>
<div class="slider-row">
<span class="lbl">contrast</span>
<input type="range" min="-100" max="100" step="1" bind:value={contrast} />
<span class="val">{contrast}</span>
</div>
<div class="slider-row">
<span class="lbl">saturation</span>
<input type="range" min="-100" max="100" step="1" bind:value={saturation} />
<span class="val">{saturation}</span>
</div>
<div class="slider-row">
<span class="lbl">gamma</span>
<input type="range" min="0.1" max="3" step="0.05" bind:value={gamma} />
<span class="val">{gamma.toFixed(2)}</span>
</div>
<div class="row">
<button onclick={resetCorrection}
disabled={!brightness && !contrast && !saturation && gamma === 1}>Reset</button>
</div>
{/if}
</div>
{#if total > 0}
<div class="progress">
<div class="bar"><div class="fill" style="width: {pct}%"></div></div>
@@ -430,6 +481,19 @@
.row button:disabled { opacity: 0.35; cursor: default; }
.row button.active { background: #2d4d78; border-color: #3b6ba8; color: #fff; }
.section { display: flex; flex-direction: column; gap: 6px; border-top: 1px solid #2a2a2a; padding-top: 8px; }
.section-head {
display: flex; align-items: center; gap: 6px; padding: 2px 0;
background: transparent; border: 0; color: #ddd; cursor: pointer;
font-size: 0.9rem; text-align: left;
}
.section-head:hover { color: #fff; }
.badge { background: #2d4d78; color: #fff; font-size: 0.7rem; padding: 1px 6px; border-radius: 8px; }
.slider-row { display: flex; align-items: center; gap: 8px; }
.slider-row input[type="range"] { flex: 1; }
.slider-row .val { font-size: 0.8rem; color: #aaa; min-width: 34px; text-align: right; }
.file-btn {
display: inline-block; padding: 6px 12px; background: #2563eb; border-radius: 6px;
cursor: pointer; color: #fff; font-weight: 500;
+63
View File
@@ -0,0 +1,63 @@
/**
* Pure RGBA color correction: brightness, contrast, saturation, gamma.
*
* Order of operations: brightness → contrast → saturation → gamma.
* All input values are in "human" ranges; the function converts internally.
*
* Identity (defaults) returns a copy of the input so callers can safely mutate.
*/
/**
* @param {Uint8Array|Uint8ClampedArray|number[]} rgba
* @param {number} width
* @param {number} height
* @param {Object} [opts]
* @param {number} [opts.brightness=0] -100..+100
* @param {number} [opts.contrast=0] -100..+100
* @param {number} [opts.saturation=0] -100..+100 (-100 = greyscale, 0 = identity, +100 = 2× saturation)
* @param {number} [opts.gamma=1] (0.1..3.0 typical; 1 = identity)
* @returns {Uint8ClampedArray}
*/
export function applyColorCorrection(rgba, width, height, opts = {}) {
const { brightness = 0, contrast = 0, saturation = 0, gamma = 1 } = opts;
const out = new Uint8ClampedArray(rgba);
if (brightness === 0 && contrast === 0 && saturation === 0 && gamma === 1) return out;
const bOffset = brightness * 2.55;
const cFactor = (259 * (contrast + 255)) / (255 * (259 - contrast));
const sFactor = 1 + saturation / 100;
const applyGamma = gamma !== 1 && gamma > 0;
const gammaInv = applyGamma ? 1 / gamma : 1;
for (let i = 0; i < out.length; i += 4) {
let r = out[i], g = out[i + 1], b = out[i + 2];
if (brightness !== 0) {
r += bOffset; g += bOffset; b += bOffset;
}
if (contrast !== 0) {
r = cFactor * (r - 128) + 128;
g = cFactor * (g - 128) + 128;
b = cFactor * (b - 128) + 128;
}
if (saturation !== 0) {
// Rec. 601 luma
const lum = 0.299 * r + 0.587 * g + 0.114 * b;
r = lum + sFactor * (r - lum);
g = lum + sFactor * (g - lum);
b = lum + sFactor * (b - lum);
}
if (applyGamma) {
const rc = r < 0 ? 0 : r > 255 ? 255 : r;
const gc = g < 0 ? 0 : g > 255 ? 255 : g;
const bc = b < 0 ? 0 : b > 255 ? 255 : b;
r = Math.pow(rc / 255, gammaInv) * 255;
g = Math.pow(gc / 255, gammaInv) * 255;
b = Math.pow(bc / 255, gammaInv) * 255;
}
out[i] = r; out[i + 1] = g; out[i + 2] = b;
// alpha (out[i + 3]) passes through from the copy.
}
return out;
}
+74
View File
@@ -0,0 +1,74 @@
import { describe, it, expect } from 'vitest';
import { applyColorCorrection } from '../../src/lib/image-color-correction.js';
function solid(w, h, r, g, b, a = 255) {
const out = new Uint8ClampedArray(w * h * 4);
for (let i = 0; i < w * h; i++) {
out[i * 4] = r; out[i * 4 + 1] = g; out[i * 4 + 2] = b; out[i * 4 + 3] = a;
}
return out;
}
describe('applyColorCorrection', () => {
it('identity (all defaults) returns a copy of the input', () => {
const src = solid(2, 2, 100, 150, 200, 220);
const out = applyColorCorrection(src, 2, 2);
expect(out).toEqual(src);
expect(out).not.toBe(src);
});
it('brightness +100 clamps grey pixels to 255', () => {
const src = solid(1, 1, 128, 128, 128);
const out = applyColorCorrection(src, 1, 1, { brightness: 100 });
// 128 + 255 = 383, clamped to 255
expect([out[0], out[1], out[2]]).toEqual([255, 255, 255]);
});
it('brightness -100 clamps grey pixels to 0', () => {
const src = solid(1, 1, 128, 128, 128);
const out = applyColorCorrection(src, 1, 1, { brightness: -100 });
expect([out[0], out[1], out[2]]).toEqual([0, 0, 0]);
});
it('contrast +100 pushes dark darker and bright brighter', () => {
const dark = solid(1, 1, 50, 50, 50);
const bright = solid(1, 1, 200, 200, 200);
const dOut = applyColorCorrection(dark, 1, 1, { contrast: 100 });
const bOut = applyColorCorrection(bright, 1, 1, { contrast: 100 });
expect(dOut[0]).toBeLessThan(50);
expect(bOut[0]).toBeGreaterThan(200);
});
it('contrast 0 is identity (within rounding)', () => {
const src = solid(1, 1, 100, 100, 100);
const out = applyColorCorrection(src, 1, 1, { contrast: 0 });
expect([out[0], out[1], out[2]]).toEqual([100, 100, 100]);
});
it('saturation -100 produces greyscale (R=G=B)', () => {
const src = solid(1, 1, 200, 80, 30);
const out = applyColorCorrection(src, 1, 1, { saturation: -100 });
expect(out[0]).toBe(out[1]);
expect(out[1]).toBe(out[2]);
});
it('gamma 1 is identity; gamma > 1 brightens midtones', () => {
const src = solid(1, 1, 128, 128, 128);
const id = applyColorCorrection(src, 1, 1, { gamma: 1 });
expect(id[0]).toBe(128);
const bright = applyColorCorrection(src, 1, 1, { gamma: 2.2 });
expect(bright[0]).toBeGreaterThan(128);
});
it('gamma < 1 darkens midtones', () => {
const src = solid(1, 1, 128, 128, 128);
const out = applyColorCorrection(src, 1, 1, { gamma: 0.5 });
expect(out[0]).toBeLessThan(128);
});
it('alpha channel is preserved unchanged', () => {
const src = solid(1, 1, 50, 100, 150, 77);
const out = applyColorCorrection(src, 1, 1, { brightness: 50, contrast: 50, saturation: 50, gamma: 2.2 });
expect(out[3]).toBe(77);
});
});