mirror of
https://github.com/tiennm99/rplace.git
synced 2026-10-11 03:13:48 +00:00
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:
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,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 });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user