From 6c35771445ce7d68c7fe1e38caa1e9fcda83f997 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Fri, 17 Apr 2026 11:56:35 +0700 Subject: [PATCH] =?UTF-8?q?feat(importer):=20phase=205=20=E2=80=94=20color?= =?UTF-8?q?=20correction=20sliders?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../phase-05-color-correction.md | 10 +-- .../plan.md | 2 +- scripts/image-to-colors.js | 18 ++++- src/client/components/ImageImporter.svelte | 66 ++++++++++++++++- src/lib/image-color-correction.js | 63 ++++++++++++++++ test/lib/image-color-correction.test.js | 74 +++++++++++++++++++ 6 files changed, 224 insertions(+), 9 deletions(-) create mode 100644 src/lib/image-color-correction.js create mode 100644 test/lib/image-color-correction.test.js diff --git a/plans/260417-1125-image-importer-enhancements/phase-05-color-correction.md b/plans/260417-1125-image-importer-enhancements/phase-05-color-correction.md index c78d390..01e1a3a 100644 --- a/plans/260417-1125-image-importer-enhancements/phase-05-color-correction.md +++ b/plans/260417-1125-image-importer-enhancements/phase-05-color-correction.md @@ -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). diff --git a/plans/260417-1125-image-importer-enhancements/plan.md b/plans/260417-1125-image-importer-enhancements/plan.md index 6ea4612..a3fc357 100644 --- a/plans/260417-1125-image-importer-enhancements/plan.md +++ b/plans/260417-1125-image-importer-enhancements/plan.md @@ -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. diff --git a/scripts/image-to-colors.js b/scripts/image-to-colors.js index a23a9ed..48bca1f 100644 --- a/scripts/image-to-colors.js +++ b/scripts/image-to-colors.js @@ -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 [-o output.json] [--alpha-threshold 128] [--dither|--dither-method ] [--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 [-o output.json] [--alpha-threshold 128] [--dither|--dither-method ] [--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 }); diff --git a/src/client/components/ImageImporter.svelte b/src/client/components/ImageImporter.svelte index 7a0e457..c96658c 100644 --- a/src/client/components/ImageImporter.svelte +++ b/src/client/components/ImageImporter.svelte @@ -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 @@ +
+ + {#if correctionOpen} +
+ brightness + + {brightness} +
+
+ contrast + + {contrast} +
+
+ saturation + + {saturation} +
+
+ gamma + + {gamma.toFixed(2)} +
+
+ +
+ {/if} +
+ {#if total > 0}
@@ -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; diff --git a/src/lib/image-color-correction.js b/src/lib/image-color-correction.js new file mode 100644 index 0000000..8b301fe --- /dev/null +++ b/src/lib/image-color-correction.js @@ -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; +} diff --git a/test/lib/image-color-correction.test.js b/test/lib/image-color-correction.test.js new file mode 100644 index 0000000..a92edba --- /dev/null +++ b/test/lib/image-color-correction.test.js @@ -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); + }); +});