Files
WPlace-AutoBOT/Extension/scripts/image-processor.js
T
MiraiSeori 00d69fafbe Refactor: Remove legacy edit panel styles from neon and neon-light themes
- Deleted extensive CSS rules for the edit panel, including overlay, container, header, main area, toolbar, and minimap styles.
- Cleaned up unused styles to streamline the codebase and improve maintainability.
2025-11-19 13:21:17 +08:00

3019 lines
92 KiB
JavaScript

// ==UserScript==
// @name WPlace Image Processor
// @namespace http://tampermonkey.net/
// @version 2025-09-16.1
// @description Image processing and color management for WPlace AutoBot
// @author Wbot
// @match https://wplace.live/*
// @grant none
// ==/UserScript==
/**
* ImageProcessor - Handles image loading, processing, color conversion, and dithering for WPlace AutoBot
* Extracted from Auto-Image.js for better modularity and reusabilit
*/
class ImageProcessor {
constructor(imageSrc = null) {
this.imageSrc = imageSrc;
this.img = null;
this.canvas = null;
this.ctx = null;
// Dithering buffers
this._ditherWorkBuf = null;
this._ditherEligibleBuf = null;
// Color cache for performance optimization
this._colorCache = new Map();
this._labCache = new Map();
this._hsvCache = new Map();
this._hslCache = new Map();
this._xyzCache = new Map();
this._luvCache = new Map();
this._yuvCache = new Map();
this._oklabCache = new Map();
this._lchCache = new Map();
// Configuration constants
this.TRANSPARENCY_THRESHOLD = 128;
this.WHITE_THRESHOLD = 230;
this.COLOR_CACHE_LIMIT = 15000;
}
/**
* Load image from source
* @returns {Promise<void>}
*/
async load() {
if (!this.imageSrc) {
throw new Error('No image source provided');
}
return new Promise((resolve, reject) => {
this.img = new Image();
this.img.crossOrigin = 'anonymous';
this.img.onload = () => {
this.canvas = document.createElement('canvas');
this.ctx = this.canvas.getContext('2d');
this.canvas.width = this.img.width;
this.canvas.height = this.img.height;
this.ctx.drawImage(this.img, 0, 0);
resolve();
};
this.img.onerror = reject;
this.img.src = this.imageSrc;
});
}
/**
* Get image dimensions
* @returns {{width: number, height: number}}
*/
getDimensions() {
if (!this.canvas) {
throw new Error('Image not loaded. Call load() first.');
}
return {
width: this.canvas.width,
height: this.canvas.height,
};
}
/**
* Get pixel data from the image
* @returns {Uint8ClampedArray} RGBA pixel data
*/
getPixelData() {
if (!this.ctx) {
throw new Error('Image not loaded. Call load() first.');
}
return this.ctx.getImageData(0, 0, this.canvas.width, this.canvas.height).data;
}
/**
* Resize image to new dimensions with specified resampling method
* @param {number} newWidth - Target width
* @param {number} newHeight - Target height
* @param {string} method - Resampling method: 'nearest', 'bilinear', 'box', 'median', 'dominant'
* @returns {Uint8ClampedArray} Resized image data
*/
resize(newWidth, newHeight, method = 'nearest') {
if (!this.canvas || !this.ctx) {
throw new Error('Image not loaded. Call load() first.');
}
const tempCanvas = document.createElement('canvas');
const tempCtx = tempCanvas.getContext('2d');
tempCanvas.width = newWidth;
tempCanvas.height = newHeight;
// Use the specified resampling method
const resampledCanvas = this.resampleImage(this.canvas, newWidth, newHeight, method);
this.canvas.width = newWidth;
this.canvas.height = newHeight;
this.ctx.clearRect(0, 0, newWidth, newHeight);
this.ctx.drawImage(resampledCanvas, 0, 0);
return this.ctx.getImageData(0, 0, newWidth, newHeight).data;
}
/**
* Resample image using specified method (adapted from wplace_helper)
* @param {HTMLCanvasElement|HTMLImageElement} source - Source image/canvas
* @param {number} dstW - Destination width
* @param {number} dstH - Destination height
* @param {string} method - Resampling method
* @returns {HTMLCanvasElement} Resampled canvas
*/
resampleImage(source, dstW, dstH, method = 'nearest') {
const srcW = source.width;
const srcH = source.height;
const factor = srcW / dstW;
const isInteger = Math.abs(factor - Math.round(factor)) < 1e-6;
// For non-integer factors, only nearest and bilinear are available
if (!isInteger) {
if (method === 'bilinear') {
return this.resampleBilinear(source, dstW, dstH);
}
return this.resampleNearest(source, dstW, dstH);
}
const intFactor = Math.max(1, Math.round(factor));
switch (method) {
case 'bilinear':
return this.resampleBilinear(source, dstW, dstH);
case 'box':
return this.resampleBox(source, dstW, dstH, intFactor);
case 'median':
return this.resampleMedian(source, dstW, dstH, intFactor);
case 'dominant':
return this.resampleDominant(source, dstW, dstH, intFactor);
case 'nearest':
default:
return this.resampleNearest(source, dstW, dstH);
}
}
/**
* Nearest neighbor resampling
*/
resampleNearest(source, dstW, dstH) {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d', { willReadFrequently: true });
canvas.width = dstW;
canvas.height = dstH;
ctx.imageSmoothingEnabled = false;
ctx.clearRect(0, 0, dstW, dstH);
ctx.drawImage(source, 0, 0, dstW, dstH);
return canvas;
}
/**
* Bilinear resampling
*/
resampleBilinear(source, dstW, dstH) {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d', { willReadFrequently: true });
canvas.width = dstW;
canvas.height = dstH;
ctx.imageSmoothingEnabled = true;
ctx.clearRect(0, 0, dstW, dstH);
ctx.drawImage(source, 0, 0, dstW, dstH);
return canvas;
}
/**
* Box filter resampling (average of pixels in block)
*/
resampleBox(source, dstW, dstH, factor) {
const srcCanvas = document.createElement('canvas');
const srcCtx = srcCanvas.getContext('2d', { willReadFrequently: true });
srcCanvas.width = source.width;
srcCanvas.height = source.height;
srcCtx.drawImage(source, 0, 0);
const srcData = srcCtx.getImageData(0, 0, source.width, source.height).data;
const dstCanvas = document.createElement('canvas');
const dstCtx = dstCanvas.getContext('2d', { willReadFrequently: true });
dstCanvas.width = dstW;
dstCanvas.height = dstH;
const dstImageData = dstCtx.createImageData(dstW, dstH);
const dstData = dstImageData.data;
const sw = source.width;
const sh = source.height;
for (let y = 0; y < dstH; y++) {
const sy0 = y * factor;
const sy1 = Math.min(sh, sy0 + factor);
for (let x = 0; x < dstW; x++) {
const sx0 = x * factor;
const sx1 = Math.min(sw, sx0 + factor);
let r = 0, g = 0, b = 0, a = 0, cnt = 0;
for (let yy = sy0; yy < sy1; yy++) {
let p = (yy * sw + sx0) * 4;
for (let xx = sx0; xx < sx1; xx++) {
r += srcData[p];
g += srcData[p + 1];
b += srcData[p + 2];
a += srcData[p + 3];
cnt++;
p += 4;
}
}
const q = (y * dstW + x) * 4;
dstData[q] = Math.min(255, Math.max(0, Math.round(r / cnt)));
dstData[q + 1] = Math.min(255, Math.max(0, Math.round(g / cnt)));
dstData[q + 2] = Math.min(255, Math.max(0, Math.round(b / cnt)));
dstData[q + 3] = Math.min(255, Math.max(0, Math.round(a / cnt)));
}
}
dstCtx.putImageData(dstImageData, 0, 0);
return dstCanvas;
}
/**
* Median filter resampling (median color in block)
*/
resampleMedian(source, dstW, dstH, factor) {
const srcCanvas = document.createElement('canvas');
const srcCtx = srcCanvas.getContext('2d', { willReadFrequently: true });
srcCanvas.width = source.width;
srcCanvas.height = source.height;
srcCtx.drawImage(source, 0, 0);
const srcData = srcCtx.getImageData(0, 0, source.width, source.height).data;
const dstCanvas = document.createElement('canvas');
const dstCtx = dstCanvas.getContext('2d', { willReadFrequently: true });
dstCanvas.width = dstW;
dstCanvas.height = dstH;
const dstImageData = dstCtx.createImageData(dstW, dstH);
const dstData = dstImageData.data;
const sw = source.width;
const sh = source.height;
const rHist = new Uint32Array(16);
const gHist = new Uint32Array(16);
const bHist = new Uint32Array(16);
const aHist = new Uint32Array(16);
const binToByte = (bin) => (bin * 17) | 0;
for (let y = 0; y < dstH; y++) {
const sy0 = y * factor;
const sy1 = Math.min(sh, sy0 + factor);
for (let x = 0; x < dstW; x++) {
rHist.fill(0); gHist.fill(0); bHist.fill(0); aHist.fill(0);
const sx0 = x * factor;
const sx1 = Math.min(sw, sx0 + factor);
for (let yy = sy0; yy < sy1; yy++) {
let p = (yy * sw + sx0) * 4;
for (let xx = sx0; xx < sx1; xx++) {
rHist[srcData[p] >> 4]++;
gHist[srcData[p + 1] >> 4]++;
bHist[srcData[p + 2] >> 4]++;
aHist[srcData[p + 3] >> 4]++;
p += 4;
}
}
const half = ((sx1 - sx0) * (sy1 - sy0)) >> 1;
const medianFrom = (hist) => {
let acc = 0;
for (let i = 0; i < 16; i++) {
acc += hist[i];
if (acc > half) return binToByte(i);
}
return binToByte(15);
};
const q = (y * dstW + x) * 4;
dstData[q] = medianFrom(rHist);
dstData[q + 1] = medianFrom(gHist);
dstData[q + 2] = medianFrom(bHist);
dstData[q + 3] = medianFrom(aHist);
}
}
dstCtx.putImageData(dstImageData, 0, 0);
return dstCanvas;
}
/**
* Dominant color resampling (most frequent color in block)
*/
resampleDominant(source, dstW, dstH, factor) {
const srcCanvas = document.createElement('canvas');
const srcCtx = srcCanvas.getContext('2d', { willReadFrequently: true });
srcCanvas.width = source.width;
srcCanvas.height = source.height;
srcCtx.drawImage(source, 0, 0);
const srcData = srcCtx.getImageData(0, 0, source.width, source.height).data;
const dstCanvas = document.createElement('canvas');
const dstCtx = dstCanvas.getContext('2d', { willReadFrequently: true });
dstCanvas.width = dstW;
dstCanvas.height = dstH;
const dstImageData = dstCtx.createImageData(dstW, dstH);
const dstData = dstImageData.data;
const sw = source.width;
const sh = source.height;
const counts = new Uint32Array(4096);
for (let y = 0; y < dstH; y++) {
const sy0 = y * factor;
const sy1 = Math.min(sh, sy0 + factor);
for (let x = 0; x < dstW; x++) {
counts.fill(0);
const sx0 = x * factor;
const sx1 = Math.min(sw, sx0 + factor);
let bestIdx = 0, bestCount = -1;
for (let yy = sy0; yy < sy1; yy++) {
let p = (yy * sw + sx0) * 4;
for (let xx = sx0; xx < sx1; xx++) {
const r = srcData[p] >> 4;
const g = srcData[p + 1] >> 4;
const b = srcData[p + 2] >> 4;
const idx = (r << 8) | (g << 4) | b;
const c = (counts[idx] = (counts[idx] + 1) >>> 0);
if (c > bestCount) {
bestCount = c;
bestIdx = idx;
}
p += 4;
}
}
const r = ((bestIdx >> 8) & 0xF) * 17;
const g = ((bestIdx >> 4) & 0xF) * 17;
const b = (bestIdx & 0xF) * 17;
const q = (y * dstW + x) * 4;
dstData[q] = r;
dstData[q + 1] = g;
dstData[q + 2] = b;
dstData[q + 3] = 255;
}
}
dstCtx.putImageData(dstImageData, 0, 0);
return dstCanvas;
}
/**
* Generate preview with resizing
* @param {number} width - Preview width
* @param {number} height - Preview height
* @returns {string} Base64 data URL of the preview
*/
generatePreview(width, height) {
if (!this.canvas) {
throw new Error('Image not loaded. Call load() first.');
}
const previewCanvas = document.createElement('canvas');
const previewCtx = previewCanvas.getContext('2d');
previewCanvas.width = width;
previewCanvas.height = height;
previewCtx.imageSmoothingEnabled = false;
previewCtx.drawImage(this.canvas, 0, 0, width, height);
return previewCanvas.toDataURL();
}
// =============================================
// COLOR PROCESSING METHODS
// =============================================
/**
* Convert RGB to LAB color space
* @param {number} r - Red value (0-255)
* @param {number} g - Green value (0-255)
* @param {number} b - Blue value (0-255)
* @returns {number[]} LAB values [L, a, b]
* @private
*/
_rgbToLab(r, g, b) {
// Normalize RGB values
let x = r / 255.0;
let y = g / 255.0;
let z = b / 255.0;
// Apply gamma correction
x = x > 0.04045 ? Math.pow((x + 0.055) / 1.055, 2.4) : x / 12.92;
y = y > 0.04045 ? Math.pow((y + 0.055) / 1.055, 2.4) : y / 12.92;
z = z > 0.04045 ? Math.pow((z + 0.055) / 1.055, 2.4) : z / 12.92;
// Convert to XYZ using sRGB matrix
let X = x * 0.4124564 + y * 0.3575761 + z * 0.1804375;
let Y = x * 0.2126729 + y * 0.7151522 + z * 0.0721750;
let Z = x * 0.0193339 + y * 0.1191920 + z * 0.9503041;
// Normalize for D65 illuminant
X /= 0.95047;
Y /= 1.00000;
Z /= 1.08883;
// Convert to LAB
X = X > 0.008856 ? Math.pow(X, 1/3) : (7.787 * X + 16/116);
Y = Y > 0.008856 ? Math.pow(Y, 1/3) : (7.787 * Y + 16/116);
Z = Z > 0.008856 ? Math.pow(Z, 1/3) : (7.787 * Z + 16/116);
const L = 116 * Y - 16;
const a = 500 * (X - Y);
const b2 = 200 * (Y - Z);
return [L, a, b2];
}
/**
* Get LAB values with caching
* @param {number} r - Red value
* @param {number} g - Green value
* @param {number} b - Blue value
* @returns {number[]} Cached LAB values
* @private
*/
_getLab(r, g, b) {
const key = (r << 16) | (g << 8) | b;
let v = this._labCache.get(key);
if (!v) {
v = this._rgbToLab(r, g, b);
this._labCache.set(key, v);
}
return v;
}
/**
* Convert RGB to HSV color space
* @param {number} r - Red value (0-255)
* @param {number} g - Green value (0-255)
* @param {number} b - Blue value (0-255)
* @returns {number[]} HSV values [H (0-360), S (0-1), V (0-1)]
* @private
*/
_rgbToHsv(r, g, b) {
r /= 255;
g /= 255;
b /= 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const delta = max - min;
let h = 0;
const s = max === 0 ? 0 : delta / max;
const v = max;
if (delta !== 0) {
if (max === r) {
h = ((g - b) / delta) % 6;
} else if (max === g) {
h = (b - r) / delta + 2;
} else {
h = (r - g) / delta + 4;
}
h *= 60;
if (h < 0) h += 360;
}
return [h, s, v];
}
/**
* Convert HSV to RGB color space
* @param {number} h - Hue value (0-360)
* @param {number} s - Saturation value (0-1)
* @param {number} v - Value/Brightness (0-1)
* @returns {number[]} RGB values [R, G, B] (0-255)
* @private
*/
_hsvToRgb(h, s, v) {
const c = v * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m = v - c;
let r = 0, g = 0, b = 0;
if (h >= 0 && h < 60) {
r = c; g = x; b = 0;
} else if (h >= 60 && h < 120) {
r = x; g = c; b = 0;
} else if (h >= 120 && h < 180) {
r = 0; g = c; b = x;
} else if (h >= 180 && h < 240) {
r = 0; g = x; b = c;
} else if (h >= 240 && h < 300) {
r = x; g = 0; b = c;
} else if (h >= 300 && h < 360) {
r = c; g = 0; b = x;
}
return [
Math.round((r + m) * 255),
Math.round((g + m) * 255),
Math.round((b + m) * 255)
];
}
/**
* Convert RGB to HSL color space
* @param {number} r - Red value (0-255)
* @param {number} g - Green value (0-255)
* @param {number} b - Blue value (0-255)
* @returns {number[]} HSL values [H (0-360), S (0-1), L (0-1)]
* @private
*/
_rgbToHsl(r, g, b) {
r /= 255;
g /= 255;
b /= 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const delta = max - min;
let h = 0;
let s = 0;
const l = (max + min) / 2;
if (delta !== 0) {
s = l > 0.5 ? delta / (2 - max - min) : delta / (max + min);
if (max === r) {
h = ((g - b) / delta) % 6;
} else if (max === g) {
h = (b - r) / delta + 2;
} else {
h = (r - g) / delta + 4;
}
h *= 60;
if (h < 0) h += 360;
}
return [h, s, l];
}
/**
* Convert HSL to RGB color space
* @param {number} h - Hue value (0-360)
* @param {number} s - Saturation value (0-1)
* @param {number} l - Lightness value (0-1)
* @returns {number[]} RGB values [R, G, B] (0-255)
* @private
*/
_hslToRgb(h, s, l) {
const c = (1 - Math.abs(2 * l - 1)) * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m = l - c / 2;
let r = 0, g = 0, b = 0;
if (h >= 0 && h < 60) {
r = c; g = x; b = 0;
} else if (h >= 60 && h < 120) {
r = x; g = c; b = 0;
} else if (h >= 120 && h < 180) {
r = 0; g = c; b = x;
} else if (h >= 180 && h < 240) {
r = 0; g = x; b = c;
} else if (h >= 240 && h < 300) {
r = x; g = 0; b = c;
} else if (h >= 300 && h < 360) {
r = c; g = 0; b = x;
}
return [
Math.round((r + m) * 255),
Math.round((g + m) * 255),
Math.round((b + m) * 255)
];
}
/**
* Convert RGB to XYZ color space
* @param {number} r - Red value (0-255)
* @param {number} g - Green value (0-255)
* @param {number} b - Blue value (0-255)
* @returns {number[]} XYZ values [X, Y, Z]
* @private
*/
_rgbToXyz(r, g, b) {
// Normalize RGB values
let x = r / 255.0;
let y = g / 255.0;
let z = b / 255.0;
// Apply gamma correction
x = x > 0.04045 ? Math.pow((x + 0.055) / 1.055, 2.4) : x / 12.92;
y = y > 0.04045 ? Math.pow((y + 0.055) / 1.055, 2.4) : y / 12.92;
z = z > 0.04045 ? Math.pow((z + 0.055) / 1.055, 2.4) : z / 12.92;
// Convert to XYZ using sRGB matrix
const X = x * 0.4124564 + y * 0.3575761 + z * 0.1804375;
const Y = x * 0.2126729 + y * 0.7151522 + z * 0.0721750;
const Z = x * 0.0193339 + y * 0.1191920 + z * 0.9503041;
return [X, Y, Z];
}
/**
* Convert XYZ to RGB color space
* @param {number} x - X value
* @param {number} y - Y value
* @param {number} z - Z value
* @returns {number[]} RGB values [R, G, B] (0-255)
* @private
*/
_xyzToRgb(x, y, z) {
// Convert XYZ to linear RGB
let r = x * 3.2404542 + y * -1.5371385 + z * -0.4985314;
let g = x * -0.9692660 + y * 1.8760108 + z * 0.0415560;
let b = x * 0.0556434 + y * -0.2040259 + z * 1.0572252;
// Apply gamma correction
r = r > 0.0031308 ? 1.055 * Math.pow(r, 1.0 / 2.4) - 0.055 : 12.92 * r;
g = g > 0.0031308 ? 1.055 * Math.pow(g, 1.0 / 2.4) - 0.055 : 12.92 * g;
b = b > 0.0031308 ? 1.055 * Math.pow(b, 1.0 / 2.4) - 0.055 : 12.92 * b;
// Clamp and convert to 0-255 range
return [
Math.round(Math.max(0, Math.min(1, r)) * 255),
Math.round(Math.max(0, Math.min(1, g)) * 255),
Math.round(Math.max(0, Math.min(1, b)) * 255)
];
}
/**
* Convert RGB to LUV color space
* @param {number} r - Red value (0-255)
* @param {number} g - Green value (0-255)
* @param {number} b - Blue value (0-255)
* @returns {number[]} LUV values [L, u, v]
* @private
*/
_rgbToLuv(r, g, b) {
const [X, Y, Z] = this._rgbToXyz(r, g, b);
// Reference white D65
const Xn = 0.95047;
const Yn = 1.00000;
const Zn = 1.08883;
const yr = Y / Yn;
const L = yr > 0.008856 ? 116 * Math.pow(yr, 1/3) - 16 : 903.3 * yr;
const denom = X + 15 * Y + 3 * Z;
const denomN = Xn + 15 * Yn + 3 * Zn;
const up = denom === 0 ? 0 : (4 * X) / denom;
const vp = denom === 0 ? 0 : (9 * Y) / denom;
const upN = (4 * Xn) / denomN;
const vpN = (9 * Yn) / denomN;
const u = 13 * L * (up - upN);
const v = 13 * L * (vp - vpN);
return [L, u, v];
}
/**
* Convert LUV to RGB color space
* @param {number} l - L value
* @param {number} u - u value
* @param {number} v - v value
* @returns {number[]} RGB values [R, G, B] (0-255)
* @private
*/
_luvToRgb(l, u, v) {
// Reference white D65
const Xn = 0.95047;
const Yn = 1.00000;
const Zn = 1.08883;
const upN = (4 * Xn) / (Xn + 15 * Yn + 3 * Zn);
const vpN = (9 * Yn) / (Xn + 15 * Yn + 3 * Zn);
const Y = l > 8 ? Yn * Math.pow((l + 16) / 116, 3) : Yn * l / 903.3;
const up = u / (13 * l) + upN;
const vp = v / (13 * l) + vpN;
const X = Y * 9 * up / (4 * vp);
const Z = Y * (12 - 3 * up - 20 * vp) / (4 * vp);
return this._xyzToRgb(X, Y, Z);
}
/**
* Convert RGB to YUV color space
* @param {number} r - Red value (0-255)
* @param {number} g - Green value (0-255)
* @param {number} b - Blue value (0-255)
* @returns {number[]} YUV values [Y, U, V]
* @private
*/
_rgbToYuv(r, g, b) {
const Y = 0.299 * r + 0.587 * g + 0.114 * b;
const U = -0.14713 * r - 0.28886 * g + 0.436 * b;
const V = 0.615 * r - 0.51499 * g - 0.10001 * b;
return [Y, U, V];
}
/**
* Convert YUV to RGB color space
* @param {number} y - Y (luminance) value
* @param {number} u - U (chrominance) value
* @param {number} v - V (chrominance) value
* @returns {number[]} RGB values [R, G, B] (0-255)
* @private
*/
_yuvToRgb(y, u, v) {
const r = y + 1.13983 * v;
const g = y - 0.39465 * u - 0.58060 * v;
const b = y + 2.03211 * u;
return [
Math.round(Math.max(0, Math.min(255, r))),
Math.round(Math.max(0, Math.min(255, g))),
Math.round(Math.max(0, Math.min(255, b)))
];
}
/**
* Convert RGB to Oklab color space (improved LAB)
* @param {number} r - Red value (0-255)
* @param {number} g - Green value (0-255)
* @param {number} b - Blue value (0-255)
* @returns {number[]} Oklab values [L, a, b]
* @private
*/
_rgbToOklab(r, g, b) {
// Normalize RGB
r /= 255;
g /= 255;
b /= 255;
// Apply gamma correction
r = r > 0.04045 ? Math.pow((r + 0.055) / 1.055, 2.4) : r / 12.92;
g = g > 0.04045 ? Math.pow((g + 0.055) / 1.055, 2.4) : g / 12.92;
b = b > 0.04045 ? Math.pow((b + 0.055) / 1.055, 2.4) : b / 12.92;
// Linear RGB to Oklab
const l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b;
const m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b;
const s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b;
const l_ = Math.cbrt(l);
const m_ = Math.cbrt(m);
const s_ = Math.cbrt(s);
return [
0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_,
1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_,
0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_
];
}
/**
* Convert Oklab to RGB color space
* @param {number} l - L value
* @param {number} a - a value
* @param {number} b - b value
* @returns {number[]} RGB values [R, G, B] (0-255)
* @private
*/
_oklabToRgb(l, a, b) {
const l_ = l + 0.3963377774 * a + 0.2158037573 * b;
const m_ = l - 0.1055613458 * a - 0.0638541728 * b;
const s_ = l - 0.0894841775 * a - 1.2914855480 * b;
const l3 = l_ * l_ * l_;
const m3 = m_ * m_ * m_;
const s3 = s_ * s_ * s_;
let r = 4.0767416621 * l3 - 3.3077115913 * m3 + 0.2309699292 * s3;
let g = -1.2684380046 * l3 + 2.6097574011 * m3 - 0.3413193965 * s3;
let b2 = -0.0041960863 * l3 - 0.7034186147 * m3 + 1.7076147010 * s3;
// Apply gamma correction
r = r > 0.0031308 ? 1.055 * Math.pow(r, 1.0 / 2.4) - 0.055 : 12.92 * r;
g = g > 0.0031308 ? 1.055 * Math.pow(g, 1.0 / 2.4) - 0.055 : 12.92 * g;
b2 = b2 > 0.0031308 ? 1.055 * Math.pow(b2, 1.0 / 2.4) - 0.055 : 12.92 * b2;
return [
Math.round(Math.max(0, Math.min(1, r)) * 255),
Math.round(Math.max(0, Math.min(1, g)) * 255),
Math.round(Math.max(0, Math.min(1, b2)) * 255)
];
}
/**
* Convert LAB to LCH color space (cylindrical representation)
* @param {number} l - L value
* @param {number} a - a value
* @param {number} b - b value
* @returns {number[]} LCH values [L, C, H]
* @private
*/
_labToLch(l, a, b) {
const c = Math.sqrt(a * a + b * b);
let h = Math.atan2(b, a) * 180 / Math.PI;
if (h < 0) h += 360;
return [l, c, h];
}
/**
* Convert LCH to LAB color space
* @param {number} l - L value
* @param {number} c - C (chroma) value
* @param {number} h - H (hue) value (0-360)
* @returns {number[]} LAB values [L, a, b]
* @private
*/
_lchToLab(l, c, h) {
const hRad = h * Math.PI / 180;
const a = c * Math.cos(hRad);
const b = c * Math.sin(hRad);
return [l, a, b];
}
/**
* Find closest color in palette using specified algorithm
* @param {number} r - Red value
* @param {number} g - Green value
* @param {number} b - Blue value
* @param {Array} palette - Array of RGB arrays [[r,g,b], ...]
* @param {string} algorithm - 'legacy' or 'lab'
* @param {Object} options - Additional options for color matching
* @returns {number[]} Closest RGB color [r, g, b]
*/
findClosestPaletteColor(r, g, b, palette, algorithm = 'lab', options = {}) {
if (!palette || palette.length === 0) {
return [0, 0, 0];
}
const { enableChromaPenalty = false, chromaPenaltyWeight = 0.15 } = options;
if (algorithm === 'legacy') {
let menorDist = Infinity;
let cor = [0, 0, 0];
for (let i = 0; i < palette.length; i++) {
const [pr, pg, pb] = palette[i];
const rmean = (pr + r) / 2;
const rdiff = pr - r;
const gdiff = pg - g;
const bdiff = pb - b;
const dist = Math.sqrt(
(((512 + rmean) * rdiff * rdiff) >> 8) +
4 * gdiff * gdiff +
(((767 - rmean) * bdiff * bdiff) >> 8)
);
if (dist < menorDist) {
menorDist = dist;
cor = [pr, pg, pb];
}
}
return cor;
}
// HSV algorithm
if (algorithm === 'hsv') {
const [ht, st, vt] = this._rgbToHsv(r, g, b);
let best = null;
let bestDist = Infinity;
for (let i = 0; i < palette.length; i++) {
const [pr, pg, pb] = palette[i];
const [hp, sp, vp] = this._rgbToHsv(pr, pg, pb);
const dh = Math.min(Math.abs(ht - hp), 360 - Math.abs(ht - hp)) / 360; // Normalize hue distance
const ds = st - sp;
const dv = vt - vp;
const dist = dh * dh + ds * ds + dv * dv;
if (dist < bestDist) {
bestDist = dist;
best = palette[i];
if (bestDist === 0) break;
}
}
return best || [0, 0, 0];
}
// HSL algorithm
if (algorithm === 'hsl') {
const [ht, st, lt] = this._rgbToHsl(r, g, b);
let best = null;
let bestDist = Infinity;
for (let i = 0; i < palette.length; i++) {
const [pr, pg, pb] = palette[i];
const [hp, sp, lp] = this._rgbToHsl(pr, pg, pb);
const dh = Math.min(Math.abs(ht - hp), 360 - Math.abs(ht - hp)) / 360; // Normalize hue distance
const ds = st - sp;
const dl = lt - lp;
const dist = dh * dh + ds * ds + dl * dl;
if (dist < bestDist) {
bestDist = dist;
best = palette[i];
if (bestDist === 0) break;
}
}
return best || [0, 0, 0];
}
// XYZ algorithm
if (algorithm === 'xyz') {
const [xt, yt, zt] = this._rgbToXyz(r, g, b);
let best = null;
let bestDist = Infinity;
for (let i = 0; i < palette.length; i++) {
const [pr, pg, pb] = palette[i];
const [xp, yp, zp] = this._rgbToXyz(pr, pg, pb);
const dx = xt - xp;
const dy = yt - yp;
const dz = zt - zp;
const dist = dx * dx + dy * dy + dz * dz;
if (dist < bestDist) {
bestDist = dist;
best = palette[i];
if (bestDist === 0) break;
}
}
return best || [0, 0, 0];
}
// LUV algorithm
if (algorithm === 'luv') {
const [lt, ut, vt] = this._rgbToLuv(r, g, b);
let best = null;
let bestDist = Infinity;
for (let i = 0; i < palette.length; i++) {
const [pr, pg, pb] = palette[i];
const [lp, up, vp] = this._rgbToLuv(pr, pg, pb);
const dl = lt - lp;
const du = ut - up;
const dv = vt - vp;
const dist = dl * dl + du * du + dv * dv;
if (dist < bestDist) {
bestDist = dist;
best = palette[i];
if (bestDist === 0) break;
}
}
return best || [0, 0, 0];
}
// YUV algorithm
if (algorithm === 'yuv') {
const [yt, ut, vt] = this._rgbToYuv(r, g, b);
let best = null;
let bestDist = Infinity;
for (let i = 0; i < palette.length; i++) {
const [pr, pg, pb] = palette[i];
const [yp, up, vp] = this._rgbToYuv(pr, pg, pb);
const dy = yt - yp;
const du = ut - up;
const dv = vt - vp;
const dist = dy * dy + du * du + dv * dv;
if (dist < bestDist) {
bestDist = dist;
best = palette[i];
if (bestDist === 0) break;
}
}
return best || [0, 0, 0];
}
// Oklab algorithm
if (algorithm === 'oklab') {
const [lt, at, bt] = this._rgbToOklab(r, g, b);
let best = null;
let bestDist = Infinity;
for (let i = 0; i < palette.length; i++) {
const [pr, pg, pb] = palette[i];
const [lp, ap, bp] = this._rgbToOklab(pr, pg, pb);
const dl = lt - lp;
const da = at - ap;
const db = bt - bp;
const dist = dl * dl + da * da + db * db;
if (dist < bestDist) {
bestDist = dist;
best = palette[i];
if (bestDist === 0) break;
}
}
return best || [0, 0, 0];
}
// LCH algorithm
if (algorithm === 'lch') {
const [Lt, at, bt] = this._getLab(r, g, b);
const [lt, ct, ht] = this._labToLch(Lt, at, bt);
let best = null;
let bestDist = Infinity;
for (let i = 0; i < palette.length; i++) {
const [pr, pg, pb] = palette[i];
const [Lp, ap, bp] = this._getLab(pr, pg, pb);
const [lp, cp, hp] = this._labToLch(Lp, ap, bp);
const dl = lt - lp;
const dc = ct - cp;
const dh = Math.min(Math.abs(ht - hp), 360 - Math.abs(ht - hp)); // Circular hue distance
const dist = dl * dl + dc * dc + (dh * dh) / 360; // Normalize hue
if (dist < bestDist) {
bestDist = dist;
best = palette[i];
if (bestDist === 0) break;
}
}
return best || [0, 0, 0];
}
// LAB algorithm (default)
const [Lt, at, bt] = this._getLab(r, g, b);
const targetChroma = Math.sqrt(at * at + bt * bt);
let best = null;
let bestDist = Infinity;
for (let i = 0; i < palette.length; i++) {
const [pr, pg, pb] = palette[i];
const [Lp, ap, bp] = this._getLab(pr, pg, pb);
const dL = Lt - Lp;
const da = at - ap;
const db = bt - bp;
let dist = dL * dL + da * da + db * db;
if (enableChromaPenalty && targetChroma > 20) {
const candChroma = Math.sqrt(ap * ap + bp * bp);
if (candChroma < targetChroma) {
const chromaDiff = targetChroma - candChroma;
dist += chromaDiff * chromaDiff * chromaPenaltyWeight;
}
}
if (dist < bestDist) {
bestDist = dist;
best = palette[i];
if (bestDist === 0) break;
}
}
return best || [0, 0, 0];
}
/**
* Check if a pixel is considered white based on threshold
* @param {number} r - Red value
* @param {number} g - Green value
* @param {number} b - Blue value
* @param {number} threshold - White threshold (default: 230)
* @returns {boolean} True if pixel is white
*/
isWhitePixel(r, g, b, threshold = this.WHITE_THRESHOLD) {
return r >= threshold && g >= threshold && b >= threshold;
}
/**
* Resolve target RGB to closest available color with caching
* @param {number[]} targetRgb - Target RGB array [r, g, b]
* @param {Array} availableColors - Available colors with id and rgb properties
* @param {Object} options - Matching options
* @returns {Object} {id: number|null, rgb: number[]}
*/
resolveColor(targetRgb, availableColors, options = {}) {
const {
exactMatch = false,
algorithm = 'lab',
enableChromaPenalty = false,
chromaPenaltyWeight = 0.15,
whiteThreshold = this.WHITE_THRESHOLD
} = options;
if (!availableColors || availableColors.length === 0) {
return { id: null, rgb: targetRgb };
}
const cacheKey = `${targetRgb[0]},${targetRgb[1]},${targetRgb[2]}|${algorithm}|${enableChromaPenalty ? 'c' : 'nc'}|${chromaPenaltyWeight}|${exactMatch ? 'exact' : 'closest'}`;
if (this._colorCache.has(cacheKey)) {
return this._colorCache.get(cacheKey);
}
// Check for exact match
if (exactMatch) {
const match = availableColors.find(
(c) => c.rgb[0] === targetRgb[0] && c.rgb[1] === targetRgb[1] && c.rgb[2] === targetRgb[2]
);
const result = match ? { id: match.id, rgb: [...match.rgb] } : { id: null, rgb: targetRgb };
this._colorCache.set(cacheKey, result);
return result;
}
// Check for white pixel matching
if (
targetRgb[0] >= whiteThreshold &&
targetRgb[1] >= whiteThreshold &&
targetRgb[2] >= whiteThreshold
) {
const whiteEntry = availableColors.find(
(c) => c.rgb[0] >= whiteThreshold && c.rgb[1] >= whiteThreshold && c.rgb[2] >= whiteThreshold
);
if (whiteEntry) {
const result = { id: whiteEntry.id, rgb: [...whiteEntry.rgb] };
this._colorCache.set(cacheKey, result);
return result;
}
}
// Find nearest color
let bestId = availableColors[0].id;
let bestRgb = [...availableColors[0].rgb];
let bestScore = Infinity;
if (algorithm === 'legacy') {
for (let i = 0; i < availableColors.length; i++) {
const c = availableColors[i];
const [r, g, b] = c.rgb;
const rmean = (r + targetRgb[0]) / 2;
const rdiff = r - targetRgb[0];
const gdiff = g - targetRgb[1];
const bdiff = b - targetRgb[2];
const dist = Math.sqrt(
(((512 + rmean) * rdiff * rdiff) >> 8) +
4 * gdiff * gdiff +
(((767 - rmean) * bdiff * bdiff) >> 8)
);
if (dist < bestScore) {
bestScore = dist;
bestId = c.id;
bestRgb = [...c.rgb];
if (dist === 0) break;
}
}
} else {
const [Lt, at, bt] = this._getLab(targetRgb[0], targetRgb[1], targetRgb[2]);
const targetChroma = Math.sqrt(at * at + bt * bt);
const penaltyWeight = enableChromaPenalty ? chromaPenaltyWeight : 0;
for (let i = 0; i < availableColors.length; i++) {
const c = availableColors[i];
const [r, g, b] = c.rgb;
const [L2, a2, b2] = this._getLab(r, g, b);
const dL = Lt - L2;
const da = at - a2;
const db = bt - b2;
let dist = dL * dL + da * da + db * db;
if (penaltyWeight > 0 && targetChroma > 20) {
const candChroma = Math.sqrt(a2 * a2 + b2 * b2);
if (candChroma < targetChroma) {
const cd = targetChroma - candChroma;
dist += cd * cd * penaltyWeight;
}
}
if (dist < bestScore) {
bestScore = dist;
bestId = c.id;
bestRgb = [...c.rgb];
if (dist === 0) break;
}
}
}
const result = { id: bestId, rgb: bestRgb };
this._colorCache.set(cacheKey, result);
// Limit cache size
if (this._colorCache.size > this.COLOR_CACHE_LIMIT) {
const firstKey = this._colorCache.keys().next().value;
this._colorCache.delete(firstKey);
}
return result;
}
// =============================================
// DITHERING METHODS (Floyd-Steinberg)
// =============================================
/**
* Ensure dithering buffers are properly sized
* @param {number} n - Number of pixels
* @returns {Object} {work: Float32Array, eligible: Uint8Array}
* @private
*/
_ensureDitherBuffers(n) {
if (!this._ditherWorkBuf || this._ditherWorkBuf.length !== n * 3) {
this._ditherWorkBuf = new Float32Array(n * 3);
}
if (!this._ditherEligibleBuf || this._ditherEligibleBuf.length !== n) {
this._ditherEligibleBuf = new Uint8Array(n);
}
return { work: this._ditherWorkBuf, eligible: this._ditherEligibleBuf };
}
/**
* Apply Floyd-Steinberg dithering to image data
* @param {Uint8ClampedArray} imageData - RGBA image data
* @param {number} width - Image width
* @param {number} height - Image height
* @param {Array} palette - Color palette for dithering
* @param {Object} options - Dithering options
* @returns {Object} {data: Uint8ClampedArray, totalValidPixels: number}
*/
applyFloydSteinbergDithering(imageData, width, height, palette, options = {}) {
const {
paintTransparentPixels = false,
paintWhitePixels = true,
transparencyThreshold = this.TRANSPARENCY_THRESHOLD,
whiteThreshold = this.WHITE_THRESHOLD,
algorithm = 'lab',
enableChromaPenalty = false,
chromaPenaltyWeight = 0.15,
mask = null
} = options;
const data = new Uint8ClampedArray(imageData);
const n = width * height;
const { work, eligible } = this._ensureDitherBuffers(n);
let totalValidPixels = 0;
// Initialize working buffers
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const idx = y * width + x;
const i4 = idx * 4;
const r = data[i4];
const g = data[i4 + 1];
const b = data[i4 + 2];
const a = data[i4 + 3];
const masked = mask && mask[idx];
const isEligible =
!masked &&
(paintTransparentPixels || a >= transparencyThreshold) &&
(paintWhitePixels || !this.isWhitePixel(r, g, b, whiteThreshold));
eligible[idx] = isEligible ? 1 : 0;
work[idx * 3] = r;
work[idx * 3 + 1] = g;
work[idx * 3 + 2] = b;
if (!isEligible) {
data[i4 + 3] = 0; // Make ineligible pixels transparent
}
}
}
// Error diffusion function
const diffuse = (nx, ny, er, eg, eb, factor) => {
if (nx < 0 || nx >= width || ny < 0 || ny >= height) return;
const nidx = ny * width + nx;
if (!eligible[nidx]) return;
const base = nidx * 3;
work[base] = Math.min(255, Math.max(0, work[base] + er * factor));
work[base + 1] = Math.min(255, Math.max(0, work[base + 1] + eg * factor));
work[base + 2] = Math.min(255, Math.max(0, work[base + 2] + eb * factor));
};
// Apply Floyd-Steinberg dithering
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const idx = y * width + x;
if (!eligible[idx]) continue;
const base = idx * 3;
const r0 = work[base];
const g0 = work[base + 1];
const b0 = work[base + 2];
const i4 = idx * 4;
const a = data[i4 + 3];
// If painting transparent pixels AND this pixel is transparent, keep it transparent
if (paintTransparentPixels && a < transparencyThreshold) {
data[i4] = 0;
data[i4 + 1] = 0;
data[i4 + 2] = 0;
data[i4 + 3] = 0;
totalValidPixels++;
continue;
}
const [nr, ng, nb] = this.findClosestPaletteColor(
r0, g0, b0, palette, algorithm, { enableChromaPenalty, chromaPenaltyWeight }
);
data[i4] = nr;
data[i4 + 1] = ng;
data[i4 + 2] = nb;
data[i4 + 3] = 255;
totalValidPixels++;
// Calculate and diffuse error
const er = r0 - nr;
const eg = g0 - ng;
const eb = b0 - nb;
diffuse(x + 1, y, er, eg, eb, 7 / 16);
diffuse(x - 1, y + 1, er, eg, eb, 3 / 16);
diffuse(x, y + 1, er, eg, eb, 5 / 16);
diffuse(x + 1, y + 1, er, eg, eb, 1 / 16);
}
} return { data, totalValidPixels };
}
/**
* Apply simple color quantization without dithering
* @param {Uint8ClampedArray} imageData - RGBA image data
* @param {number} width - Image width
* @param {number} height - Image height
* @param {Array} palette - Color palette for quantization
* @param {Object} options - Quantization options
* @returns {Object} {data: Uint8ClampedArray, totalValidPixels: number}
*/
applySimpleQuantization(imageData, width, height, palette, options = {}) {
const {
paintTransparentPixels = false,
paintWhitePixels = true,
transparencyThreshold = this.TRANSPARENCY_THRESHOLD,
whiteThreshold = this.WHITE_THRESHOLD,
algorithm = 'lab',
enableChromaPenalty = false,
chromaPenaltyWeight = 0.15
} = options;
const data = new Uint8ClampedArray(imageData);
let totalValidPixels = 0;
for (let i = 0; i < data.length; i += 4) {
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
const a = data[i + 3];
const isTransparent = a < transparencyThreshold;
const isEligible =
(paintTransparentPixels || !isTransparent) &&
(paintWhitePixels || !this.isWhitePixel(r, g, b, whiteThreshold));
if (!isEligible) {
data[i + 3] = 0; // Make ineligible pixels transparent
continue;
}
// If painting transparent pixels AND this pixel is transparent, keep it transparent
if (paintTransparentPixels && isTransparent) {
data[i] = 0;
data[i + 1] = 0;
data[i + 2] = 0;
data[i + 3] = 0;
totalValidPixels++;
continue;
}
const [nr, ng, nb] = this.findClosestPaletteColor(
r, g, b, palette, algorithm, { enableChromaPenalty, chromaPenaltyWeight }
);
data[i] = nr;
data[i + 1] = ng;
data[i + 2] = nb;
data[i + 3] = 255;
totalValidPixels++;
}
return { data, totalValidPixels };
}
// =============================================
// UTILITY METHODS
// =============================================
/**
* Extract available colors from DOM elements
* @returns {Array|null} Array of color objects with id and rgb properties
*/
extractAvailableColors() {
const colorElements = document.querySelectorAll('.color-option, [data-color-id]');
if (colorElements.length === 0) return null;
const colors = [];
colorElements.forEach((element) => {
const colorId = element.dataset.colorId || element.getAttribute('data-color-id');
const computedStyle = window.getComputedStyle(element);
const backgroundColor = computedStyle.backgroundColor;
if (backgroundColor && colorId) {
const rgbMatch = backgroundColor.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
if (rgbMatch) {
colors.push({
id: parseInt(colorId),
rgb: [parseInt(rgbMatch[1]), parseInt(rgbMatch[2]), parseInt(rgbMatch[3])]
});
}
}
});
return colors.length > 0 ? colors : null;
}
/**
* Create file uploader for images
* @returns {Promise<string>} Promise resolving to image data URL
*/
createImageUploader() {
return new Promise((resolve) => {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/png,image/jpeg';
input.onchange = () => {
if (input.files && input.files[0]) {
const fr = new FileReader();
fr.onload = () => resolve(fr.result);
fr.readAsDataURL(input.files[0]);
} else {
resolve(null);
}
};
input.click();
});
}
/**
* Extract available colors from DOM palette
* @param {Object} colorMap - The color map object (optional, will use window.CONFIG if not provided)
* @returns {Array|null} Array of available colors or null if none found
*/
extractAvailableColors(colorMap = null) {
const colorElements = document.querySelectorAll('.tooltip button[id^="color-"]');
if (colorElements.length === 0) {
console.log('❌ No color elements found on page');
return null;
}
// Use provided colorMap or fallback to window.CONFIG
const effectiveColorMap = colorMap || (window.CONFIG?.COLOR_MAP);
// Separate available and unavailable colors
const availableColors = [];
const unavailableColors = [];
Array.from(colorElements).forEach((el) => {
const id = Number.parseInt(el.id.replace('color-', ''));
if (id === 0) return; // Skip transparent color
const rgbStr = el.style.backgroundColor.match(/\d+/g);
if (!rgbStr || rgbStr.length < 3) {
console.warn(`Skipping color element ${el.id} — cannot parse RGB`);
return;
}
const rgb = rgbStr.map(Number);
// Find color name from COLOR_MAP
let name = `Unknown Color ${id}`;
if (effectiveColorMap) {
const colorInfo = Object.values(effectiveColorMap).find((color) => color.id === id);
name = colorInfo ? colorInfo.name : name;
}
const colorData = { id, name, rgb };
// Check if color is available (no SVG overlay means available)
if (!el.querySelector('svg')) {
availableColors.push(colorData);
} else {
unavailableColors.push(colorData);
}
});
// Console log detailed color information
console.log('=== CAPTURED COLORS STATUS ===');
console.log(`Total available colors: ${availableColors.length}`);
console.log(`Total unavailable colors: ${unavailableColors.length}`);
console.log(`Total colors scanned: ${availableColors.length + unavailableColors.length}`);
if (availableColors.length > 0) {
console.log('\n--- AVAILABLE COLORS ---');
availableColors.forEach((color, index) => {
console.log(
`${index + 1
}. ID: ${color.id}, Name: "${color.name}", RGB: (${color.rgb[0]}, ${color.rgb[1]}, ${color.rgb[2]})`
);
});
}
if (unavailableColors.length > 0) {
console.log('\n--- UNAVAILABLE COLORS ---');
unavailableColors.forEach((color, index) => {
console.log(
`${index + 1
}. ID: ${color.id}, Name: "${color.name}", RGB: (${color.rgb[0]}, ${color.rgb[1]}, ${color.rgb[2]}) [LOCKED]`
);
});
}
console.log('=== END COLOR STATUS ===');
return availableColors;
}
/**
* Clear all caches to free memory
*/
clearCaches() {
this._colorCache.clear();
this._labCache.clear();
this._hsvCache?.clear();
this._hslCache?.clear();
this._xyzCache?.clear();
this._luvCache?.clear();
this._yuvCache?.clear();
this._oklabCache?.clear();
this._lchCache?.clear();
this._ditherWorkBuf = null;
this._ditherEligibleBuf = null;
}
/**
* Clean up resources
*/
cleanup() {
this.clearCaches();
if (this.canvas) {
this.canvas.width = 0;
this.canvas.height = 0;
}
this.img = null;
this.canvas = null;
this.ctx = null;
}
/**
* Apply pre-blur to canvas (before downscaling)
* @param {HTMLCanvasElement} canvas - Source canvas
* @param {string} mode - Blur mode: 'none', 'box', 'gaussian', 'kuwahara'
* @param {number} radius - Blur radius
* @returns {HTMLCanvasElement} Blurred canvas
*/
applyPreBlur(canvas, mode, radius) {
const r = Math.max(0, Math.floor(radius));
if (!mode || mode === 'none' || r <= 0) return canvas;
if (mode === 'box') return this.boxBlur(canvas, r);
if (mode === 'gaussian') return this.gaussianBlur(canvas, r);
if (mode === 'kuwahara') return this.kuwaharaFilter(canvas, r);
return canvas;
}
/**
* Box blur filter
* @param {HTMLCanvasElement} canvas - Source canvas
* @param {number} r - Blur radius
* @returns {HTMLCanvasElement} Blurred canvas
*/
boxBlur(canvas, r) {
const w = canvas.width, h = canvas.height;
if (r <= 0 || w === 0 || h === 0) return canvas;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
const imgData = ctx.getImageData(0, 0, w, h);
const src = imgData.data;
const tmp = new Uint8ClampedArray(src.length);
const win = 2 * r + 1;
// Horizontal pass
for (let y = 0; y < h; y++) {
let rs = 0, gs = 0, bs = 0, as = 0;
const yoff = y * w * 4;
// Initialize window
for (let k = -r; k <= r; k++) {
const xx = Math.max(0, k);
const p = yoff + xx * 4;
rs += src[p];
gs += src[p + 1];
bs += src[p + 2];
as += src[p + 3];
}
for (let x = 0; x < w; x++) {
const i = yoff + x * 4;
tmp[i] = (rs / win) | 0;
tmp[i + 1] = (gs / win) | 0;
tmp[i + 2] = (bs / win) | 0;
tmp[i + 3] = (as / win) | 0;
const x0 = Math.max(0, x - r);
const x1 = Math.min(w - 1, x + r + 1);
const p0 = yoff + x0 * 4;
const p1 = yoff + x1 * 4;
rs += src[p1] - src[p0];
gs += src[p1 + 1] - src[p0 + 1];
bs += src[p1 + 2] - src[p0 + 2];
as += src[p1 + 3] - src[p0 + 3];
}
}
// Vertical pass
const out = new Uint8ClampedArray(src.length);
for (let x = 0; x < w; x++) {
let rs = 0, gs = 0, bs = 0, as = 0;
const xoff = x * 4;
// Initialize window
for (let k = -r; k <= r; k++) {
const yy = Math.max(0, k);
const p = yy * w * 4 + xoff;
rs += tmp[p];
gs += tmp[p + 1];
bs += tmp[p + 2];
as += tmp[p + 3];
}
for (let y = 0; y < h; y++) {
const i = y * w * 4 + xoff;
out[i] = (rs / win) | 0;
out[i + 1] = (gs / win) | 0;
out[i + 2] = (bs / win) | 0;
out[i + 3] = (as / win) | 0;
const y0 = Math.max(0, y - r);
const y1 = Math.min(h - 1, y + r + 1);
const p0 = y0 * w * 4 + xoff;
const p1 = y1 * w * 4 + xoff;
rs += tmp[p1] - tmp[p0];
gs += tmp[p1 + 1] - tmp[p0 + 1];
bs += tmp[p1 + 2] - tmp[p0 + 2];
as += tmp[p1 + 3] - tmp[p0 + 3];
}
}
imgData.data.set(out);
ctx.putImageData(imgData, 0, 0);
return canvas;
}
/**
* Gaussian blur (approximated with multiple box blurs)
* @param {HTMLCanvasElement} canvas - Source canvas
* @param {number} r - Blur radius
* @returns {HTMLCanvasElement} Blurred canvas
*/
gaussianBlur(canvas, r) {
// Approximate Gaussian with 3 box blur passes
let result = canvas;
for (let i = 0; i < 3; i++) {
result = this.boxBlur(result, Math.max(1, r));
}
return result;
}
/**
* Kuwahara filter - edge-preserving smoothing filter
* @param {HTMLCanvasElement} canvas - Source canvas
* @param {number} r - Radius (size of regions)
* @returns {HTMLCanvasElement} Filtered canvas
*/
kuwaharaFilter(canvas, r) {
const w = canvas.width | 0;
const h = canvas.height | 0;
if (r <= 0 || w === 0 || h === 0) return canvas;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
const img = ctx.getImageData(0, 0, w, h);
const s = img.data;
const out = new Uint8ClampedArray(s.length);
// Build integral images for fast region sum calculation
const sw = w + 1;
const sh = h + 1;
const N = sw * sh;
const iR = new Float64Array(N);
const iG = new Float64Array(N);
const iB = new Float64Array(N);
const iC = new Float64Array(N);
const iL = new Float64Array(N);
const iL2 = new Float64Array(N);
const idx = (x, y) => y * sw + x;
// Build integral images
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const p = (y * w + x) * 4;
const a = s[p + 3] | 0;
const c = (a >= 128) ? 1 : 0;
const r0 = s[p] | 0;
const g0 = s[p + 1] | 0;
const b0 = s[p + 2] | 0;
const lum = 0.299 * r0 + 0.587 * g0 + 0.114 * b0;
const ii = idx(x + 1, y + 1);
const left = idx(x, y + 1);
const up = idx(x + 1, y);
const upleft = idx(x, y);
iR[ii] = iR[left] + iR[up] - iR[upleft] + (c ? r0 : 0);
iG[ii] = iG[left] + iG[up] - iG[upleft] + (c ? g0 : 0);
iB[ii] = iB[left] + iB[up] - iB[upleft] + (c ? b0 : 0);
iC[ii] = iC[left] + iC[up] - iC[upleft] + c;
iL[ii] = iL[left] + iL[up] - iL[upleft] + (c ? lum : 0);
iL2[ii] = iL2[left] + iL2[up] - iL2[upleft] + (c ? lum * lum : 0);
}
}
const rectSum = (ii, x0, y0, x1, y1) => {
const a = idx(x0, y0);
const b = idx(x1, y0);
const c = idx(x0, y1);
const d = idx(x1, y1);
return ii[d] - ii[b] - ii[c] + ii[a];
};
const clampByte = (v) => (v < 0 ? 0 : v > 255 ? 255 : v | 0);
// Process each pixel
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const p = (y * w + x) * 4;
const a = s[p + 3] | 0;
// Skip transparent pixels
if (a < 128) {
out[p] = s[p];
out[p + 1] = s[p + 1];
out[p + 2] = s[p + 2];
out[p + 3] = a;
continue;
}
// Define 4 regions (quadrants)
const x0 = Math.max(0, x - r);
const x1 = x;
const x2 = Math.min(w - 1, x + r);
const y0 = Math.max(0, y - r);
const y1 = y;
const y2 = Math.min(h - 1, y + r);
const regions = [
{ x0: x0, y0: y0, x1: x1, y1: y1 }, // Top-left
{ x0: x1, y0: y0, x1: x2, y1: y1 }, // Top-right
{ x0: x0, y0: y1, x1: x1, y1: y2 }, // Bottom-left
{ x0: x1, y0: y1, x1: x2, y1: y2 } // Bottom-right
];
let bestVar = 1e20;
let mR = s[p];
let mG = s[p + 1];
let mB = s[p + 2];
// Find region with minimum variance
for (let k = 0; k < 4; k++) {
const rx0 = regions[k].x0;
const ry0 = regions[k].y0;
const rx1 = regions[k].x1;
const ry1 = regions[k].y1;
const ex0 = rx0;
const ey0 = ry0;
const ex1 = rx1 + 1;
const ey1 = ry1 + 1;
const cnt = rectSum(iC, ex0, ey0, ex1, ey1);
if (cnt <= 0) continue;
const sumL = rectSum(iL, ex0, ey0, ex1, ey1);
const sumL2 = rectSum(iL2, ex0, ey0, ex1, ey1);
const meanL = sumL / cnt;
const variance = (sumL2 / cnt) - meanL * meanL;
if (variance < bestVar) {
bestVar = variance;
const sr = rectSum(iR, ex0, ey0, ex1, ey1);
const sg = rectSum(iG, ex0, ey0, ex1, ey1);
const sb = rectSum(iB, ex0, ey0, ex1, ey1);
mR = clampByte((sr / cnt) | 0);
mG = clampByte((sg / cnt) | 0);
mB = clampByte((sb / cnt) | 0);
}
}
out[p] = mR;
out[p + 1] = mG;
out[p + 2] = mB;
out[p + 3] = 255;
}
}
img.data.set(out);
ctx.putImageData(img, 0, 0);
return canvas;
}
/**
* Apply unsharp mask (sharpening)
* @param {HTMLCanvasElement} canvas - Source canvas
* @param {number} amount - Sharpen amount (0-300, percentage)
* @param {number} radius - Blur radius for mask
* @param {number} threshold - Threshold for sharpening (0-64)
* @returns {HTMLCanvasElement} Sharpened canvas
*/
applyUnsharpMask(canvas, amount, radius, threshold) {
const w = canvas.width, h = canvas.height;
if (w === 0 || h === 0 || amount <= 0 || radius <= 0) return canvas;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
const imgData = ctx.getImageData(0, 0, w, h);
const orig = imgData.data;
// Create blurred version
const blurCanvas = document.createElement('canvas');
blurCanvas.width = w;
blurCanvas.height = h;
const blurCtx = blurCanvas.getContext('2d', { willReadFrequently: true });
blurCtx.drawImage(canvas, 0, 0);
const blurred = this.gaussianBlur(blurCanvas, radius);
const blurImgData = blurred.getContext('2d').getImageData(0, 0, w, h);
const blurData = blurImgData.data;
const k = amount / 100;
const clampByte = (v) => Math.min(255, Math.max(0, v));
for (let i = 0; i < orig.length; i += 4) {
const dr = orig[i] - blurData[i];
const dg = orig[i + 1] - blurData[i + 1];
const db = orig[i + 2] - blurData[i + 2];
if (Math.abs(dr) >= threshold) orig[i] = clampByte(orig[i] + k * dr);
if (Math.abs(dg) >= threshold) orig[i + 1] = clampByte(orig[i + 1] + k * dg);
if (Math.abs(db) >= threshold) orig[i + 2] = clampByte(orig[i + 2] + k * db);
}
ctx.putImageData(imgData, 0, 0);
return canvas;
}
// =============================================
// MULTI-ALGORITHM DITHERING SYSTEM
// =============================================
/**
* Apply dithering with multiple algorithm support
* @param {Uint8ClampedArray} imageData - RGBA image data
* @param {number} width - Image width
* @param {number} height - Image height
* @param {Array} palette - Color palette for dithering
* @param {Object} options - Dithering options
* @returns {Object} {data: Uint8ClampedArray, totalValidPixels: number}
*/
applyDithering(imageData, width, height, palette, options = {}) {
const {
method = 'floyd',
strength = 0.5,
posterizeLevels = 0,
paintTransparentPixels = false,
paintWhitePixels = true,
transparencyThreshold = this.TRANSPARENCY_THRESHOLD,
whiteThreshold = this.WHITE_THRESHOLD,
algorithm = 'lab',
enableChromaPenalty = false,
chromaPenaltyWeight = 0.15,
mask = null
} = options;
// Apply posterization if requested
let workingData = new Uint8ClampedArray(imageData);
if (posterizeLevels >= 2) {
workingData = this._applyPosterize(workingData, width, height, posterizeLevels);
}
// Error diffusion methods
const errorDiffusionMethods = {
'floyd': this._getFloydSteinbergKernel(),
'falsefloydsteinberg': this._getFalseFloydSteinbergKernel(),
'atkinson': this._getAtkinsonKernel(),
'jarvis': this._getJarvisKernel(),
'stucki': this._getStuckiKernel(),
'burkes': this._getBurkesKernel(),
'sierra': this._getSierraKernel(),
'twosierra': this._getTwoSierraKernel(),
'sierralite': this._getSierraLiteKernel()
};
// Ordered dithering methods
const orderedMethods = ['bayer2', 'bayer4', 'bayer8', 'random'];
if (errorDiffusionMethods[method]) {
return this._applyErrorDiffusion(workingData, width, height, palette, errorDiffusionMethods[method], options);
} else if (orderedMethods.includes(method)) {
return this._applyOrderedDither(workingData, width, height, palette, method, strength, options);
} else {
// Fallback to Floyd-Steinberg
console.warn(`Unknown dithering method: ${method}, falling back to Floyd-Steinberg`);
return this._applyErrorDiffusion(workingData, width, height, palette, this._getFloydSteinbergKernel(), options);
}
}
/**
* Apply posterization (color quantization by levels)
* @private
*/
_applyPosterize(data, width, height, levels) {
const result = new Uint8ClampedArray(data);
const step = 256 / levels;
for (let i = 0; i < result.length; i += 4) {
for (let c = 0; c < 3; c++) {
const value = result[i + c];
result[i + c] = Math.floor(value / step) * step;
}
}
return result;
}
/**
* Generic error diffusion dithering
* @private
*/
_applyErrorDiffusion(imageData, width, height, palette, kernel, options) {
const data = new Uint8ClampedArray(imageData);
const n = width * height;
const { work, eligible } = this._ensureDitherBuffers(n);
let totalValidPixels = 0;
const {
paintTransparentPixels = false,
paintWhitePixels = true,
transparencyThreshold = this.TRANSPARENCY_THRESHOLD,
whiteThreshold = this.WHITE_THRESHOLD,
algorithm = 'lab',
enableChromaPenalty = false,
chromaPenaltyWeight = 0.15,
mask = null
} = options;
// Initialize working buffers
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const idx = y * width + x;
const i4 = idx * 4;
const r = data[i4];
const g = data[i4 + 1];
const b = data[i4 + 2];
const a = data[i4 + 3];
const masked = mask && mask[idx];
const isEligible =
!masked &&
(paintTransparentPixels || a >= transparencyThreshold) &&
(paintWhitePixels || !this.isWhitePixel(r, g, b, whiteThreshold));
eligible[idx] = isEligible ? 1 : 0;
work[idx * 3] = r;
work[idx * 3 + 1] = g;
work[idx * 3 + 2] = b;
if (!isEligible) {
data[i4 + 3] = 0;
}
}
}
// Error diffusion function
const diffuse = (nx, ny, er, eg, eb, factor) => {
if (nx < 0 || nx >= width || ny < 0 || ny >= height) return;
const nidx = ny * width + nx;
if (!eligible[nidx]) return;
const base = nidx * 3;
work[base] = Math.min(255, Math.max(0, work[base] + er * factor));
work[base + 1] = Math.min(255, Math.max(0, work[base + 1] + eg * factor));
work[base + 2] = Math.min(255, Math.max(0, work[base + 2] + eb * factor));
};
// Apply error diffusion
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const idx = y * width + x;
if (!eligible[idx]) continue;
const base = idx * 3;
const r0 = work[base];
const g0 = work[base + 1];
const b0 = work[base + 2];
const i4 = idx * 4;
const a = data[i4 + 3];
if (paintTransparentPixels && a < transparencyThreshold) {
data[i4] = 0;
data[i4 + 1] = 0;
data[i4 + 2] = 0;
data[i4 + 3] = 0;
totalValidPixels++;
continue;
}
const [nr, ng, nb] = this.findClosestPaletteColor(
r0, g0, b0, palette, algorithm, { enableChromaPenalty, chromaPenaltyWeight }
);
data[i4] = nr;
data[i4 + 1] = ng;
data[i4 + 2] = nb;
data[i4 + 3] = 255;
totalValidPixels++;
const er = r0 - nr;
const eg = g0 - ng;
const eb = b0 - nb;
// Apply error diffusion kernel (kernel is an object with numbered properties)
for (let k = 0; kernel[k]; k++) {
const { dx, dy, weight } = kernel[k];
diffuse(x + dx, y + dy, er, eg, eb, weight / kernel.divisor);
}
}
}
return { data, totalValidPixels };
}
/**
* Apply ordered (threshold) dithering
* @private
*/
_applyOrderedDither(imageData, width, height, palette, method, strength, options) {
const data = new Uint8ClampedArray(imageData);
let totalValidPixels = 0;
const {
paintTransparentPixels = false,
paintWhitePixels = true,
transparencyThreshold = this.TRANSPARENCY_THRESHOLD,
whiteThreshold = this.WHITE_THRESHOLD,
algorithm = 'lab',
enableChromaPenalty = false,
chromaPenaltyWeight = 0.15
} = options;
// Get threshold matrix
let matrix;
if (method === 'bayer2') matrix = this._getBayer2x2();
else if (method === 'bayer4') matrix = this._getBayer4x4();
else if (method === 'bayer8') matrix = this._getBayer8x8();
else if (method === 'random') matrix = null; // Random doesn't use matrix
const matrixSize = matrix ? matrix.length : 0;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const i = (y * width + x) * 4;
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
const a = data[i + 3];
const isTransparent = a < transparencyThreshold;
const isEligible =
(paintTransparentPixels || !isTransparent) &&
(paintWhitePixels || !this.isWhitePixel(r, g, b, whiteThreshold));
if (!isEligible) {
data[i + 3] = 0;
continue;
}
if (paintTransparentPixels && isTransparent) {
data[i] = 0;
data[i + 1] = 0;
data[i + 2] = 0;
data[i + 3] = 0;
totalValidPixels++;
continue;
}
// Get threshold value
let threshold;
if (method === 'random') {
threshold = Math.random();
} else {
const mx = x % matrixSize;
const my = y % matrixSize;
threshold = matrix[my][mx];
}
// Apply strength modulation
threshold = threshold * strength;
// Add threshold noise to colors
const noise = (threshold - 0.5) * 64 * strength;
const nr = Math.max(0, Math.min(255, r + noise));
const ng = Math.max(0, Math.min(255, g + noise));
const nb = Math.max(0, Math.min(255, b + noise));
const [pr, pg, pb] = this.findClosestPaletteColor(
nr, ng, nb, palette, algorithm, { enableChromaPenalty, chromaPenaltyWeight }
);
data[i] = pr;
data[i + 1] = pg;
data[i + 2] = pb;
data[i + 3] = 255;
totalValidPixels++;
}
}
return { data, totalValidPixels };
}
// Dithering kernel definitions
_getFloydSteinbergKernel() {
return {
divisor: 16,
0: { dx: 1, dy: 0, weight: 7 },
1: { dx: -1, dy: 1, weight: 3 },
2: { dx: 0, dy: 1, weight: 5 },
3: { dx: 1, dy: 1, weight: 1 }
};
}
_getFalseFloydSteinbergKernel() {
return {
divisor: 8,
0: { dx: 1, dy: 0, weight: 3 },
1: { dx: 0, dy: 1, weight: 3 },
2: { dx: 1, dy: 1, weight: 2 }
};
}
_getAtkinsonKernel() {
return {
divisor: 8,
0: { dx: 1, dy: 0, weight: 1 },
1: { dx: 2, dy: 0, weight: 1 },
2: { dx: -1, dy: 1, weight: 1 },
3: { dx: 0, dy: 1, weight: 1 },
4: { dx: 1, dy: 1, weight: 1 },
5: { dx: 0, dy: 2, weight: 1 }
};
}
_getJarvisKernel() {
return {
divisor: 48,
0: { dx: 1, dy: 0, weight: 7 },
1: { dx: 2, dy: 0, weight: 5 },
2: { dx: -2, dy: 1, weight: 3 },
3: { dx: -1, dy: 1, weight: 5 },
4: { dx: 0, dy: 1, weight: 7 },
5: { dx: 1, dy: 1, weight: 5 },
6: { dx: 2, dy: 1, weight: 3 },
7: { dx: -2, dy: 2, weight: 1 },
8: { dx: -1, dy: 2, weight: 3 },
9: { dx: 0, dy: 2, weight: 5 },
10: { dx: 1, dy: 2, weight: 3 },
11: { dx: 2, dy: 2, weight: 1 }
};
}
_getStuckiKernel() {
return {
divisor: 42,
0: { dx: 1, dy: 0, weight: 8 },
1: { dx: 2, dy: 0, weight: 4 },
2: { dx: -2, dy: 1, weight: 2 },
3: { dx: -1, dy: 1, weight: 4 },
4: { dx: 0, dy: 1, weight: 8 },
5: { dx: 1, dy: 1, weight: 4 },
6: { dx: 2, dy: 1, weight: 2 },
7: { dx: -2, dy: 2, weight: 1 },
8: { dx: -1, dy: 2, weight: 2 },
9: { dx: 0, dy: 2, weight: 4 },
10: { dx: 1, dy: 2, weight: 2 },
11: { dx: 2, dy: 2, weight: 1 }
};
}
_getBurkesKernel() {
return {
divisor: 32,
0: { dx: 1, dy: 0, weight: 8 },
1: { dx: 2, dy: 0, weight: 4 },
2: { dx: -2, dy: 1, weight: 2 },
3: { dx: -1, dy: 1, weight: 4 },
4: { dx: 0, dy: 1, weight: 8 },
5: { dx: 1, dy: 1, weight: 4 },
6: { dx: 2, dy: 1, weight: 2 }
};
}
_getSierraKernel() {
return {
divisor: 32,
0: { dx: 1, dy: 0, weight: 5 },
1: { dx: 2, dy: 0, weight: 3 },
2: { dx: -2, dy: 1, weight: 2 },
3: { dx: -1, dy: 1, weight: 4 },
4: { dx: 0, dy: 1, weight: 5 },
5: { dx: 1, dy: 1, weight: 4 },
6: { dx: 2, dy: 1, weight: 2 },
7: { dx: -1, dy: 2, weight: 2 },
8: { dx: 0, dy: 2, weight: 3 },
9: { dx: 1, dy: 2, weight: 2 }
};
}
_getTwoSierraKernel() {
return {
divisor: 16,
0: { dx: 1, dy: 0, weight: 4 },
1: { dx: 2, dy: 0, weight: 3 },
2: { dx: -2, dy: 1, weight: 1 },
3: { dx: -1, dy: 1, weight: 2 },
4: { dx: 0, dy: 1, weight: 3 },
5: { dx: 1, dy: 1, weight: 2 },
6: { dx: 2, dy: 1, weight: 1 }
};
}
_getSierraLiteKernel() {
return {
divisor: 4,
0: { dx: 1, dy: 0, weight: 2 },
1: { dx: 0, dy: 1, weight: 1 },
2: { dx: 1, dy: 1, weight: 1 }
};
}
// Bayer matrices for ordered dithering
_getBayer2x2() {
return [
[0/4, 2/4],
[3/4, 1/4]
];
}
_getBayer4x4() {
return [
[0/16, 8/16, 2/16, 10/16],
[12/16, 4/16, 14/16, 6/16],
[3/16, 11/16, 1/16, 9/16],
[15/16, 7/16, 13/16, 5/16]
];
}
_getBayer8x8() {
return [
[0/64, 32/64, 8/64, 40/64, 2/64, 34/64, 10/64, 42/64],
[48/64, 16/64, 56/64, 24/64, 50/64, 18/64, 58/64, 26/64],
[12/64, 44/64, 4/64, 36/64, 14/64, 46/64, 6/64, 38/64],
[60/64, 28/64, 52/64, 20/64, 62/64, 30/64, 54/64, 22/64],
[3/64, 35/64, 11/64, 43/64, 1/64, 33/64, 9/64, 41/64],
[51/64, 19/64, 59/64, 27/64, 49/64, 17/64, 57/64, 25/64],
[15/64, 47/64, 7/64, 39/64, 13/64, 45/64, 5/64, 37/64],
[63/64, 31/64, 55/64, 23/64, 61/64, 29/64, 53/64, 21/64]
];
}
// ====== POST-PROCESSING METHODS ======
/**
* Apply edge overlay to the image
* @param {Uint8ClampedArray} imageData - RGBA pixel data
* @param {number} width - Image width
* @param {number} height - Image height
* @param {string} algorithm - Edge detection algorithm: 'sobel', 'prewitt', 'roberts'
* @param {number} threshold - Edge detection threshold (0-255, default 60)
* @param {number} thickness - Edge line thickness (1-6, default 1)
* @param {boolean} thinEdges - Apply non-maximum suppression (default false)
* @returns {Uint8ClampedArray} Image data with edge overlay applied
*/
applyEdgeOverlay(imageData, width, height, algorithm = 'sobel', threshold = 60, thickness = 1, thinEdges = false) {
// Detect edges with optional NMS thinning
const edges = this._detectEdges(imageData, width, height, algorithm, threshold, thinEdges);
const result = new Uint8ClampedArray(imageData);
// Apply thickness by dilating edges (if thickness > 1)
const processed = this._dilateEdges(edges, width, height, thickness);
for (let i = 0; i < processed.length; i++) {
if (processed[i] > 128) {
const idx = i * 4;
result[idx] = 0; // R
result[idx + 1] = 0; // G
result[idx + 2] = 0; // B
result[idx + 3] = 255; // A (keep alpha)
}
}
return result;
}
/**
* Detect edges using specified algorithm
* @private
*/
_detectEdges(imageData, width, height, algorithm, threshold = 60, applyNMS = false) {
const gray = new Uint8ClampedArray(width * height);
const magnitude = new Float32Array(width * height);
const direction = applyNMS ? new Int8Array(width * height) : null;
// Convert to grayscale
for (let i = 0; i < imageData.length; i += 4) {
const idx = i / 4;
gray[idx] = Math.round(0.299 * imageData[i] + 0.587 * imageData[i + 1] + 0.114 * imageData[i + 2]);
}
// Apply edge detection and store magnitude + direction
switch (algorithm) {
case 'sobel':
this._sobelEdgeDetection(gray, magnitude, direction, width, height);
break;
case 'prewitt':
this._prewittEdgeDetection(gray, magnitude, direction, width, height);
break;
case 'roberts':
this._robertsEdgeDetection(gray, magnitude, direction, width, height);
break;
case 'laplacian':
this._laplacianEdgeDetection(gray, magnitude, width, height);
break;
default:
this._sobelEdgeDetection(gray, magnitude, direction, width, height);
}
const edges = new Uint8ClampedArray(width * height);
// Apply non-maximum suppression if requested
if (applyNMS && direction) {
for (let y = 1; y < height - 1; y++) {
for (let x = 1; x < width - 1; x++) {
const i = y * width + x;
const mag = magnitude[i];
if (mag < threshold) continue;
const dir = direction[i];
let m1 = 0, m2 = 0;
// Compare with neighbors along gradient direction
if (dir === 0) { // Horizontal
m1 = magnitude[i - 1];
m2 = magnitude[i + 1];
} else { // Vertical
m1 = magnitude[i - width];
m2 = magnitude[i + width];
}
// Keep only if local maximum
if (mag >= m1 && mag >= m2) {
edges[i] = 255;
}
}
}
} else {
// Simple thresholding
for (let i = 0; i < edges.length; i++) {
edges[i] = magnitude[i] > threshold ? 255 : 0;
}
}
return edges;
}
/**
* Sobel edge detection
* @private
*/
_sobelEdgeDetection(gray, magnitude, direction, width, height) {
const sobelX = [-1, 0, 1, -2, 0, 2, -1, 0, 1];
const sobelY = [-1, -2, -1, 0, 0, 0, 1, 2, 1];
for (let y = 1; y < height - 1; y++) {
for (let x = 1; x < width - 1; x++) {
let gx = 0, gy = 0;
let idx = 0;
for (let ky = -1; ky <= 1; ky++) {
for (let kx = -1; kx <= 1; kx++) {
const pixelIdx = (y + ky) * width + (x + kx);
gx += sobelX[idx] * gray[pixelIdx];
gy += sobelY[idx] * gray[pixelIdx];
idx++;
}
}
const i = y * width + x;
magnitude[i] = Math.abs(gx) + Math.abs(gy);
// Store gradient direction for NMS
if (direction) {
const ax = Math.abs(gx);
const ay = Math.abs(gy);
direction[i] = ax >= ay ? 0 : 1; // 0=horizontal, 1=vertical
}
}
}
}
/**
* Prewitt edge detection
* @private
*/
_prewittEdgeDetection(gray, magnitude, direction, width, height) {
const prewittX = [-1, 0, 1, -1, 0, 1, -1, 0, 1];
const prewittY = [1, 1, 1, 0, 0, 0, -1, -1, -1];
for (let y = 1; y < height - 1; y++) {
for (let x = 1; x < width - 1; x++) {
let gx = 0, gy = 0;
let idx = 0;
for (let ky = -1; ky <= 1; ky++) {
for (let kx = -1; kx <= 1; kx++) {
const pixelIdx = (y + ky) * width + (x + kx);
gx += prewittX[idx] * gray[pixelIdx];
gy += prewittY[idx] * gray[pixelIdx];
idx++;
}
}
const i = y * width + x;
magnitude[i] = Math.abs(gx) + Math.abs(gy);
if (direction) {
const ax = Math.abs(gx);
const ay = Math.abs(gy);
direction[i] = ax >= ay ? 0 : 1;
}
}
}
}
/**
* Roberts edge detection
* @private
*/
_robertsEdgeDetection(gray, magnitude, direction, width, height) {
for (let y = 0; y < height - 1; y++) {
for (let x = 0; x < width - 1; x++) {
const idx = y * width + x;
const gx = gray[idx] - gray[idx + width + 1];
const gy = gray[idx + 1] - gray[idx + width];
magnitude[idx] = Math.abs(gx) + Math.abs(gy);
if (direction) {
const ax = Math.abs(gx);
const ay = Math.abs(gy);
direction[idx] = ax >= ay ? 0 : 1;
}
}
}
}
/**
* Laplacian edge detection
* @private
*/
_laplacianEdgeDetection(gray, magnitude, width, height) {
// Laplacian kernel for edge detection
const laplacianKernel = [0, -1, 0, -1, 4, -1, 0, -1, 0];
for (let y = 1; y < height - 1; y++) {
for (let x = 1; x < width - 1; x++) {
let sum = 0;
let idx = 0;
for (let ky = -1; ky <= 1; ky++) {
for (let kx = -1; kx <= 1; kx++) {
const pixelIdx = (y + ky) * width + (x + kx);
sum += laplacianKernel[idx] * gray[pixelIdx];
idx++;
}
}
magnitude[y * width + x] = Math.abs(sum);
}
}
}
/**
* Dilate edges to make them thicker
* @private
*/
_dilateEdges(edges, width, height, thickness = 1) {
const t = Math.max(1, Math.min(6, thickness || 1));
// WPlace uses thickness - 1 for dilation count
// thickness=1 means no dilation (keep original edges)
// thickness=2 means dilate 1 time, etc.
const dilationCount = t - 1;
if (dilationCount <= 0) return new Uint8ClampedArray(edges);
const dilated = new Uint8ClampedArray(edges);
const tmp = new Uint8ClampedArray(edges.length);
for (let k = 0; k < dilationCount; k++) {
tmp.fill(0);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const i = y * width + x;
if (dilated[i] > 128) {
tmp[i] = 255;
continue;
}
// Check 4-connected neighbors
if ((x > 0 && dilated[i - 1] > 128) ||
(x + 1 < width && dilated[i + 1] > 128) ||
(y > 0 && dilated[i - width] > 128) ||
(y + 1 < height && dilated[i + width] > 128)) {
tmp[i] = 255;
}
}
}
dilated.set(tmp);
}
return dilated;
}
/**
* Apply outline to the image
* @param {Uint8ClampedArray} imageData - RGBA pixel data
* @param {number} width - Image width
* @param {number} height - Image height
* @param {number} thickness - Outline thickness in pixels (0-10)
* @returns {Uint8ClampedArray} Image data with outline applied
*/
applyOutline(imageData, width, height, thickness) {
// Morphological dilation approach from wplace_helper
const t = Math.max(0, Math.floor(thickness || 0));
if (t <= 0) return new Uint8ClampedArray(imageData);
const w = width;
const h = height;
const data = new Uint8ClampedArray(imageData);
// Create binary mask: 1 = opaque (alpha >= 128), 0 = transparent
const mask = new Uint8Array(w * h);
for (let i = 0, p = 0; i < mask.length; i++, p += 4) {
mask[i] = data[p + 3] >= 128 ? 1 : 0;
}
// Dilate the mask t times
const dil = new Uint8Array(mask);
const tmp = new Uint8Array(mask.length);
function dilateOnce() {
tmp.fill(0);
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const i = y * w + x;
if (dil[i]) {
tmp[i] = 1;
continue;
}
// Check 4-connected neighbors
if ((x > 0 && dil[i - 1]) ||
(x + 1 < w && dil[i + 1]) ||
(y > 0 && dil[i - w]) ||
(y + 1 < h && dil[i + w])) {
tmp[i] = 1;
}
}
}
dil.set(tmp);
}
// Apply dilation t times
for (let k = 0; k < t; k++) {
dilateOnce();
}
// Draw outline where dilated but not original
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const i = y * w + x;
if (dil[i] && !mask[i]) {
const p = i * 4;
data[p] = 0; // R
data[p + 1] = 0; // G
data[p + 2] = 0; // B
data[p + 3] = 255; // A
}
}
}
return data;
}
/**
* Simplify regions by merging small color areas
* @param {Uint8ClampedArray} imageData - RGBA pixel data
* @param {number} width - Image width
* @param {number} height - Image height
* @param {number} minArea - Minimum area in pixels (0-1000)
* @returns {Uint8ClampedArray} Simplified image data
*/
simplifyRegions(imageData, width, height, minArea) {
if (minArea <= 0) return new Uint8ClampedArray(imageData);
const thr = Math.max(1, Math.floor(minArea || 0));
if (width === 0 || height === 0) return new Uint8ClampedArray(imageData);
const result = new Uint8ClampedArray(imageData);
const visited = new Uint8Array(width * height);
const qx = new Int32Array(width * height);
const qy = new Int32Array(width * height);
let qs = 0, qe = 0;
function idx(x, y) { return (y * width + x) << 2; }
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const li = y * width + x;
if (visited[li]) continue;
const p = li << 2;
const a = result[p + 3] | 0;
if (a < 128) {
visited[li] = 1;
continue;
}
const r0 = result[p] | 0;
const g0 = result[p + 1] | 0;
const b0 = result[p + 2] | 0;
const key = ((r0 << 16) | (g0 << 8) | b0) >>> 0;
qs = 0;
qe = 0;
qx[qe] = x;
qy[qe] = y;
qe++;
const comp = [];
const borderColors = {};
visited[li] = 1;
while (qs < qe) {
const cx0 = qx[qs];
const cy0 = qy[qs];
qs++;
const pi = idx(cx0, cy0);
comp.push(pi);
const neighbors = [[1, 0], [-1, 0], [0, 1], [0, -1]];
for (let k = 0; k < 4; k++) {
const nx = cx0 + neighbors[k][0];
const ny = cy0 + neighbors[k][1];
if (nx < 0 || ny < 0 || nx >= width || ny >= height) continue;
const l2 = ny * width + nx;
if (visited[l2]) continue;
const p2 = idx(nx, ny);
const a2 = result[p2 + 3] | 0;
if (a2 < 128) {
visited[l2] = 1;
continue;
}
const r = result[p2] | 0;
const g = result[p2 + 1] | 0;
const b = result[p2 + 2] | 0;
const k2 = ((r << 16) | (g << 8) | b) >>> 0;
if (k2 === key) {
visited[l2] = 1;
qx[qe] = nx;
qy[qe] = ny;
qe++;
} else {
borderColors[k2] = (borderColors[k2] || 0) + 1;
}
}
}
if (comp.length > 0 && comp.length < thr) {
let bestKey = -1;
let bestCnt = -1;
for (const ks in borderColors) {
const c = borderColors[ks] | 0;
if (c > bestCnt) {
bestCnt = c;
bestKey = Number(ks) | 0;
}
}
if (bestKey >= 0) {
const nr = (bestKey >>> 16) & 255;
const ng = (bestKey >>> 8) & 255;
const nb = bestKey & 255;
for (let i = 0; i < comp.length; i++) {
const p3 = comp[i];
result[p3] = nr;
result[p3 + 1] = ng;
result[p3 + 2] = nb;
result[p3 + 3] = 255;
}
}
}
}
}
return result;
}
/**
* Flood fill to find connected regions
* @private
*/
/**
* Erode edges by shrinking them inward
* @param {Uint8ClampedArray} imageData - RGBA pixel data
* @param {number} width - Image width
* @param {number} height - Image height
* @param {number} amount - Erosion amount in pixels (0-20)
* @returns {Uint8ClampedArray} Eroded image data
*/
erodeEdges(imageData, width, height, amount) {
if (amount <= 0) return new Uint8ClampedArray(imageData);
const result = new Uint8ClampedArray(imageData);
for (let iteration = 0; iteration < amount; iteration++) {
const temp = new Uint8ClampedArray(result);
for (let y = 1; y < height - 1; y++) {
for (let x = 1; x < width - 1; x++) {
const idx = (y * width + x) * 4;
const alpha = temp[idx + 3];
// Check if any neighbor is transparent
let hasTransparentNeighbor = false;
for (let dy = -1; dy <= 1 && !hasTransparentNeighbor; dy++) {
for (let dx = -1; dx <= 1; dx++) {
const nidx = ((y + dy) * width + (x + dx)) * 4;
if (temp[nidx + 3] < 128) {
hasTransparentNeighbor = true;
break;
}
}
}
if (hasTransparentNeighbor && alpha > 128) {
result[idx + 3] = 0; // Make transparent
}
}
}
}
return result;
}
/**
* Apply mode filter (median-like filtering) - optimized from wplace_helper
* @param {Uint8ClampedArray} imageData - RGBA pixel data
* @param {number} width - Image width
* @param {number} height - Image height
* @param {number} radius - Filter radius (1-10)
* @returns {Uint8ClampedArray} Filtered image data
*/
applyModeFilter(imageData, width, height, radius) {
if (radius <= 0 || width === 0 || height === 0) return new Uint8ClampedArray(imageData);
const n = radius | 0;
if (n <= 1) return new Uint8ClampedArray(imageData);
const left = Math.floor((n - 1) / 2);
const right = n - left - 1;
const result = new Uint8ClampedArray(imageData);
const out = new Uint8ClampedArray(imageData);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const i = (y * width + x) * 4;
const a = imageData[i + 3] | 0;
// Skip transparent pixels
if (a < 128) {
out[i] = imageData[i];
out[i + 1] = imageData[i + 1];
out[i + 2] = imageData[i + 2];
out[i + 3] = a;
continue;
}
const counts = {};
let best = 0;
let bR = imageData[i];
let bG = imageData[i + 1];
let bB = imageData[i + 2];
for (let dy = -left; dy <= right; dy++) {
const yy = y + dy;
if (yy < 0 || yy >= height) continue;
for (let dx = -left; dx <= right; dx++) {
const xx = x + dx;
if (xx < 0 || xx >= width) continue;
const j = (yy * width + xx) * 4;
if ((imageData[j + 3] | 0) < 128) continue;
const key = ((imageData[j] | 0) << 16) | ((imageData[j + 1] | 0) << 8) | (imageData[j + 2] | 0);
const c = (counts[key] || 0) + 1;
counts[key] = c;
if (c > best) {
best = c;
bR = imageData[j];
bG = imageData[j + 1];
bB = imageData[j + 2];
}
}
}
out[i] = bR;
out[i + 1] = bG;
out[i + 2] = bB;
out[i + 3] = 255;
}
}
// Copy result back
for (let i = 0; i < out.length; i += 4) {
result[i] = out[i];
result[i + 1] = out[i + 1];
result[i + 2] = out[i + 2];
}
return result;
}
// ====== COLOR CORRECTION METHODS ======
/**
* Apply color correction to the image
* @param {Uint8ClampedArray} imageData - RGBA pixel data
* @param {number} brightness - Brightness adjustment (-100 to 100)
* @param {number} contrast - Contrast adjustment (-100 to 100)
* @param {number} saturation - Saturation adjustment (-100 to 100)
* @param {number} hue - Hue rotation (-180 to 180 degrees)
* @param {number} gamma - Gamma correction (0.5 to 3.0)
* @returns {Uint8ClampedArray} Color-corrected image data
*/
applyColorCorrection(imageData, brightness = 0, contrast = 0, saturation = 0, hue = 0, gamma = 1.0) {
const result = new Uint8ClampedArray(imageData);
for (let i = 0; i < result.length; i += 4) {
let r = result[i];
let g = result[i + 1];
let b = result[i + 2];
// Brightness
if (brightness !== 0) {
r = Math.max(0, Math.min(255, r + (brightness * 2.55)));
g = Math.max(0, Math.min(255, g + (brightness * 2.55)));
b = Math.max(0, Math.min(255, b + (brightness * 2.55)));
}
// Contrast
if (contrast !== 0) {
const factor = (contrast + 100) / 100;
r = Math.max(0, Math.min(255, (r - 128) * factor + 128));
g = Math.max(0, Math.min(255, (g - 128) * factor + 128));
b = Math.max(0, Math.min(255, (b - 128) * factor + 128));
}
// Saturation
if (saturation !== 0) {
const [h, s, l] = this._rgbToHsl(r, g, b);
const newS = Math.max(0, Math.min(100, s + saturation));
[r, g, b] = this._hslToRgb(h, newS, l);
}
// Hue rotation
if (hue !== 0) {
const [h, s, l] = this._rgbToHsl(r, g, b);
const newH = (h + hue) % 360;
[r, g, b] = this._hslToRgb(newH, s, l);
}
// Gamma correction
if (gamma !== 1.0) {
const invGamma = 1.0 / gamma;
r = Math.pow(r / 255, invGamma) * 255;
g = Math.pow(g / 255, invGamma) * 255;
b = Math.pow(b / 255, invGamma) * 255;
}
result[i] = Math.round(r);
result[i + 1] = Math.round(g);
result[i + 2] = Math.round(b);
}
return result;
}
/**
* Convert RGB to HSL
* @private
*/
_rgbToHsl(r, g, b) {
r /= 255;
g /= 255;
b /= 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
let h = 0, s = 0;
const l = (max + min) / 2;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
case g: h = ((b - r) / d + 2) / 6; break;
case b: h = ((r - g) / d + 4) / 6; break;
}
}
return [h * 360, s * 100, l * 100];
}
/**
* Convert HSL to RGB
* @private
*/
_hslToRgb(h, s, l) {
h /= 360;
s /= 100;
l /= 100;
let r, g, b;
if (s === 0) {
r = g = b = l;
} else {
const hue2rgb = (p, q, t) => {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1/6) return p + (q - p) * 6 * t;
if (t < 1/2) return q;
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
return p;
};
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
r = hue2rgb(p, q, h + 1/3);
g = hue2rgb(p, q, h);
b = hue2rgb(p, q, h - 1/3);
}
return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)];
}
/**
* Color matching helper
* @private
*/
_colorMatch(color1, color2, tolerance = 10) {
return Math.abs(color1[0] - color2[0]) <= tolerance &&
Math.abs(color1[1] - color2[1]) <= tolerance &&
Math.abs(color1[2] - color2[2]) <= tolerance;
}
}
// Create global instance
window.WPlaceImageProcessor = ImageProcessor;
// Create global instance for Auto-Image.js compatibility
window.globalImageProcessor = new ImageProcessor();
// Legacy compatibility - expose key methods globally for backward compatibility
window.ImageProcessor = ImageProcessor;
console.log('✅ WPlace Image Processor loaded and ready');