Files
WPlace-AutoBOT/Extension/scripts/image-processor.js
T
2025-09-20 00:22:09 +08:00

741 lines
23 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();
// 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
* @param {number} newWidth - Target width
* @param {number} newHeight - Target height
* @returns {Uint8ClampedArray} Resized image data
*/
resize(newWidth, newHeight) {
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;
tempCtx.imageSmoothingEnabled = false;
tempCtx.drawImage(this.canvas, 0, 0, newWidth, newHeight);
this.canvas.width = newWidth;
this.canvas.height = newHeight;
this.ctx.imageSmoothingEnabled = false;
this.ctx.drawImage(tempCanvas, 0, 0);
return this.ctx.getImageData(0, 0, newWidth, newHeight).data;
}
/**
* 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;
}
/**
* 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;
}
// LAB algorithm
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 [nr, ng, nb] = this.findClosestPaletteColor(
r0, g0, b0, palette, algorithm, { enableChromaPenalty, chromaPenaltyWeight }
);
const i4 = idx * 4;
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 isEligible =
(paintTransparentPixels || a >= transparencyThreshold) &&
(paintWhitePixels || !this.isWhitePixel(r, g, b, whiteThreshold));
if (!isEligible) {
data[i + 3] = 0; // Make ineligible pixels transparent
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._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;
}
}
// 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');