mirror of
https://github.com/tiennm99/rplace.git
synced 2026-10-11 12:28:59 +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
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user