refactor: improve tile loading, pixel color handling, and error logic

* Refactored tile range calculation into a reusable `Utils.calculateTileRange` method.
* Added retry logic with exponential backoff in `getTilePixelColor` to handle transient bitmap read errors.
* Introduced `waitForTiles` method to ensure all required tiles are loaded before painting.
* Improved error messages for painting failures, including tile loading and pixel read errors.
* Fixed potential infinite loop in tile processing by adding timeout handling.
* Updated color resolution logic to use `availableColors` parameter name for clarity.
* Enhanced debugging logs for transparency checks and tile loading.
* Fixed diffCount calculation to correctly reflect newCount minus oldCount.
* Updated `updateStats` call to remove unnecessary parameter.
* Improved error logging with `console.error` for unexpected exceptions during image processing.
* Enhanced user feedback with additional `updateUI` calls for error states such as batch failures and tile load issues.
* Added debug logging to better track pixel painting decisions, comparing current and target colors.
* Introduced stop-checks in painting process when critical errors occur, ensuring proper UI updates and state management.
* Refined skipped pixel reporting to include unavailable colors and improved log clarity.

These changes make the overlay painting process more robust by ensuring reliable tile loading, correct pixel comparison, and accurate stats. Error handling and debugging are significantly improved, making failures easier to detect, diagnose, and recover from.
This commit is contained in:
kyuketski committed 2025-09-01 14:09:50 +03:00
1 parent 0dbcbee966
commit 7885a05ae1
1 file changed
+216 -80
+216 -80
View File
@@ -684,13 +684,15 @@
const { x: startPixelX, y: startPixelY } = this.startCoords.pixel;
const { x: startRegionX, y: startRegionY } = this.startCoords.region;
const endPixelX = startPixelX + imageWidth;
const endPixelY = startPixelY + imageHeight;
const startTileX = startRegionX + Math.floor(startPixelX / this.tileSize);
const startTileY = startRegionY + Math.floor(startPixelY / this.tileSize);
const endTileX = startRegionX + Math.floor(endPixelX / this.tileSize);
const endTileY = startRegionY + Math.floor(endPixelY / this.tileSize);
const { startTileX, startTileY, endTileX, endTileY } = Utils.calculateTileRange(
startRegionX,
startRegionY,
startPixelX,
startPixelY,
imageWidth,
imageHeight,
this.tileSize
);
const totalTiles = (endTileX - startTileX + 1) * (endTileY - startTileY + 1);
console.log(`🔄 Processing ${totalTiles} overlay tiles...`);
@@ -869,7 +871,9 @@
// Returns [r,g,b,a] for a pixel inside a region tile (tileX, tileY are region coords)
async getTilePixelColor(tileX, tileY, pixelX, pixelY) {
const tileKey = `${tileX},${tileY}`;
// Prefer cached ImageData if available
const alphaThresh = state.customTransparencyThreshold || CONFIG.TRANSPARENCY_THRESHOLD;
// 1. Prefer cached ImageData if available
const cached = this.originalTilesData.get(tileKey);
if (cached && cached.data && cached.w > 0 && cached.h > 0) {
const x = Math.max(0, Math.min(cached.w - 1, pixelX));
@@ -877,56 +881,74 @@
const idx = (y * cached.w + x) * 4;
const d = cached.data;
const a = d[idx + 3];
const alphaThresh = state.customTransparencyThreshold || CONFIG.TRANSPARENCY_THRESHOLD;
if (!state.paintTransparentPixels && a < alphaThresh) {
// Treat as transparent / unavailable
// Lightweight debug: show when transparency causes skip (only if verbose enabled)
if (window._overlayDebug)
console.debug('getTilePixelColor: transparent pixel, skipping', tileKey, x, y, a);
console.debug('OverlayManager: pixel transparent (cached), skipping', tileKey, x, y, a);
return null;
}
return [d[idx], d[idx + 1], d[idx + 2], a];
}
// Fallback: draw stored bitmap to canvas and read single pixel
const bitmap = this.originalTiles.get(tileKey);
if (!bitmap) return null;
try {
let canvas, ctx;
if (typeof OffscreenCanvas !== 'undefined') {
canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
ctx = canvas.getContext('2d');
} else {
canvas = document.createElement('canvas');
canvas.width = bitmap.width;
canvas.height = bitmap.height;
ctx = canvas.getContext('2d');
// 2. Fallback: use bitmap, with retry
const maxRetries = 3;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
const bitmap = this.originalTiles.get(tileKey);
if (!bitmap) {
if (attempt === maxRetries) {
console.warn('OverlayManager: no bitmap for', tileKey, 'after', maxRetries, 'attempts');
} else {
await Utils.sleep(50 * attempt); // экспоненциальная задержка
}
continue;
}
ctx.imageSmoothingEnabled = false;
ctx.drawImage(bitmap, 0, 0);
const x = Math.max(0, Math.min(bitmap.width - 1, pixelX));
const y = Math.max(0, Math.min(bitmap.height - 1, pixelY));
const data = ctx.getImageData(x, y, 1, 1).data;
const a = data[3];
const alphaThresh = state.customTransparencyThreshold || CONFIG.TRANSPARENCY_THRESHOLD;
if (!state.paintTransparentPixels && a < alphaThresh) {
if (window._overlayDebug)
console.debug(
'getTilePixelColor: transparent pixel (fallback), skipping',
tileKey,
x,
y,
a
try {
let canvas, ctx;
if (typeof OffscreenCanvas !== 'undefined') {
canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
ctx = canvas.getContext('2d');
} else {
canvas = document.createElement('canvas');
canvas.width = bitmap.width;
canvas.height = bitmap.height;
ctx = canvas.getContext('2d');
}
ctx.imageSmoothingEnabled = false;
ctx.drawImage(bitmap, 0, 0);
const x = Math.max(0, Math.min(bitmap.width - 1, pixelX));
const y = Math.max(0, Math.min(bitmap.height - 1, pixelY));
const data = ctx.getImageData(x, y, 1, 1).data;
const a = data[3];
if (!state.paintTransparentPixels && a < alphaThresh) {
if (window._overlayDebug)
console.debug('OverlayManager: pixel transparent (fallback)', tileKey, x, y, a);
return null;
}
return [data[0], data[1], data[2], a];
} catch (e) {
console.warn('OverlayManager: failed to read pixel (attempt', attempt, ')', tileKey, e);
if (attempt < maxRetries) {
await Utils.sleep(50 * attempt);
} else {
console.error(
'OverlayManager: failed to read pixel after',
maxRetries,
'attempts',
tileKey
);
return null;
}
}
return [data[0], data[1], data[2], a];
} catch (e) {
console.warn('OverlayManager.getTilePixelColor failed for', tileKey, pixelX, pixelY, e);
return null;
}
// 3. Если всё провалилось — можно вернуть null или [0,0,0,0]
// Лучше null — чтобы не вводить в заблуждение
return null;
}
async _compositeTileOptimized(originalBlob, overlayBitmap) {
@@ -951,6 +973,69 @@
quality: 0.95, // Slight compression for faster processing
});
}
/**
* Wait until all required tiles are loaded and cached
* @param {number} startRegionX
* @param {number} startRegionY
* @param {number} pixelWidth
* @param {number} pixelHeight
* @param {number} startPixelX
* @param {number} startPixelY
* @param {number} timeoutMs
* @returns {Promise<boolean>} true if tiles are ready
*/
async waitForTiles(
startRegionX,
startRegionY,
pixelWidth,
pixelHeight,
startPixelX = 0,
startPixelY = 0,
timeoutMs = 10000
) {
const { startTileX, startTileY, endTileX, endTileY } = Utils.calculateTileRange(
startRegionX,
startRegionY,
startPixelX,
startPixelY,
pixelWidth,
pixelHeight,
this.tileSize
);
const requiredTiles = [];
for (let ty = startTileY; ty <= endTileY; ty++) {
for (let tx = startTileX; tx <= endTileX; tx++) {
requiredTiles.push(`${tx},${ty}`);
}
}
if (requiredTiles.length === 0) return true;
const startTime = Date.now();
while (Date.now() - startTime < timeoutMs) {
if (state.stopFlag) {
console.log('waitForTiles: stopped by user');
return false;
}
const missing = requiredTiles.filter((key) => !this.originalTiles.has(key));
if (missing.length === 0) {
console.log(`✅ All ${requiredTiles.length} required tiles are loaded`);
return true;
}
await Utils.sleep(100);
}
console.warn(
`❌ Timeout waiting for tiles: ${requiredTiles.length} required,
${requiredTiles.filter((k) => this.originalTiles.has(k)).length} loaded`
);
return false;
}
}
const overlayManager = new OverlayManager();
@@ -1202,6 +1287,37 @@
_turnstileWidgetId: null,
_lastSitekey: null,
/**
* Calculate the range of tile coordinates (in region space) that cover a given image area.
* @param {number} startRegionX - Base region X
* @param {number} startRegionY - Base region Y
* @param {number} startPixelX - Starting pixel X within the region grid
* @param {number} startPixelY - Starting pixel Y within the region grid
* @param {number} width - Image width in pixels
* @param {number} height - Image height in pixels
* @param {number} tileSize - Size of a tile (default 1000)
* @returns {{ startTileX: number, startTileY: number, endTileX: number, endTileY: number }}
*/
calculateTileRange(
startRegionX,
startRegionY,
startPixelX,
startPixelY,
width,
height,
tileSize = 1000
) {
const endPixelX = startPixelX + width;
const endPixelY = startPixelY + height;
return {
startTileX: startRegionX + Math.floor(startPixelX / tileSize),
startTileY: startRegionY + Math.floor(startPixelY / tileSize),
endTileX: startRegionX + Math.floor((endPixelX - 1) / tileSize),
endTileY: startRegionY + Math.floor((endPixelY - 1) / tileSize),
};
},
async loadTurnstile() {
// If Turnstile is already present, just resolve.
if (window.turnstile) {
@@ -1775,8 +1891,8 @@
return r >= wt && g >= wt && b >= wt;
},
resolveColor(targetRgb, palette, exactMatch = false) {
if (!palette || palette.length === 0) return { id: null, rgb: targetRgb };
resolveColor(targetRgb, availableColors, exactMatch = false) {
if (!availableColors || availableColors.length === 0) return { id: null, rgb: targetRgb };
const cacheKey = `${targetRgb[0]},${targetRgb[1]},${targetRgb[2]}|${
state.colorMatchingAlgorithm
@@ -1786,7 +1902,7 @@
// точное совпадение
if (exactMatch) {
const match = palette.find(
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 };
@@ -1801,7 +1917,7 @@
targetRgb[1] >= whiteThreshold &&
targetRgb[2] >= whiteThreshold
) {
const whiteEntry = palette.find(
const whiteEntry = availableColors.find(
(c) =>
c.rgb[0] >= whiteThreshold && c.rgb[1] >= whiteThreshold && c.rgb[2] >= whiteThreshold
);
@@ -1813,13 +1929,13 @@
}
// поиск ближайшего цвета
let bestId = palette[0].id;
let bestRgb = [...palette[0].rgb];
let bestId = availableColors[0].id;
let bestRgb = [...availableColors[0].rgb];
let bestScore = Infinity;
if (state.colorMatchingAlgorithm === 'legacy') {
for (let i = 0; i < palette.length; i++) {
const c = palette[i];
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];
@@ -1842,8 +1958,8 @@
const targetChroma = Math.sqrt(at * at + bt * bt);
const penaltyWeight = state.enableChromaPenalty ? state.chromaPenaltyWeight || 0.15 : 0;
for (let i = 0; i < palette.length; i++) {
const c = palette[i];
for (let i = 0; i < availableColors.length; i++) {
const c = availableColors[i];
const [r, g, b] = c.rgb;
const [L2, a2, b2] = Utils._lab(r, g, b);
const dL = Lt - L2,
@@ -4079,7 +4195,7 @@
refreshChargesBtn.disabled = true;
try {
await updateStats();
await updateStats(true);
} catch (error) {
console.error('Error refreshing charges:', error);
} finally {
@@ -4765,7 +4881,7 @@
Utils.t('colorsUpdated', {
oldCount: oldCount,
newCount: newCount,
diffCount: oldCount - newCount,
diffCount: newCount - oldCount,
}),
'success'
);
@@ -5845,7 +5961,7 @@
// Keep state.imageData.processor as the original-based source; painting uses paletted pixels already stored
updateStats(true);
updateStats();
updateUI('resizeSuccess', 'success', {
width: newWidth,
height: newHeight,
@@ -6142,7 +6258,8 @@
try {
await processImage();
return true;
} catch {
} catch (e) {
console.error('Unexpected error:', e);
updateUI('paintingError', 'error');
return false;
} finally {
@@ -6171,7 +6288,7 @@
state.stopFlag = true;
state.running = false;
stopBtn.disabled = true;
updateUI('paintingStopped', 'warning');
updateUI('paintingStoppedByUser', 'warning');
if (state.imageLoaded && state.paintedPixels > 0) {
Utils.saveProgress();
@@ -6449,6 +6566,7 @@
} else {
console.error(`❌ Batch failed permanently after retries. Stopping painting.`);
state.stopFlag = true;
updateUI('paintingBatchFailed', 'error');
}
pixelBatch.pixels = [];
@@ -6460,6 +6578,22 @@
const { x: startX, y: startY } = state.startPosition;
const { x: regionX, y: regionY } = state.region;
const tilesReady = await overlayManager.waitForTiles(
regionX,
regionY,
width,
height,
startX,
startY,
10000 // timeout 10s
);
if (!tilesReady) {
updateUI('overlayTilesNotLoaded', 'error');
state.stopFlag = true;
return;
}
let pixelBatch = null;
let skippedPixels = { transparent: 0, white: 0, alreadyPainted: 0, colorUnavailable: 0 };
@@ -6489,14 +6623,14 @@
);
if (strictSkipUnavailable && !colorCheck.id)
return { eligible: false, reason: 'colorUnavailable' };
return { eligible: false, reason: 'colorUnavailable', r, g, b, a, colorId: colorCheck.id };
return { eligible: true, r, g, b, a, colorId: colorCheck.id };
}
function skipPixel(reason, id, rgb, x, y) {
if (reason !== 'transparent') {
console.log(`Skipped ${reason} pixel (id: ${id}, (${rgb.join(', ')})) at (${x}, ${y})`);
console.log(`Skipped pixel for ${reason} (id: ${id}, (${rgb.join(', ')})) at (${x}, ${y})`);
}
skippedPixels[reason]++;
}
@@ -6549,8 +6683,8 @@
continue;
}
console.log(`[DEBUG] Pixel at (${pixelX}, ${pixelY}) eligible: RGB=${targetPixelInfo.r}, ${targetPixelInfo.g}, ${targetPixelInfo.b},
alpha=${targetPixelInfo.a}, colorId=${targetColorId}`);
// console.log(`[DEBUG] Pixel at (${pixelX}, ${pixelY}) eligible: RGB=${targetPixelInfo.r}, ${targetPixelInfo.g}, ${targetPixelInfo.b},
// alpha=${targetPixelInfo.a}, colorId=${targetColorId}`);
if (
!pixelBatch ||
@@ -6563,7 +6697,7 @@
pixelBatch.pixels.length
} pixels (switching to region ${regionX + adderX},${regionY + adderY})`
);
const success = await flushBatch(pixelBatch);
const success = await flushPixelBatch(pixelBatch);
if (success) {
if (
@@ -6579,6 +6713,7 @@
} else {
console.error(`❌ Batch failed permanently after retries. Stopping painting.`);
state.stopFlag = true;
updateUI('paintingBatchFailed', 'error');
break outerLoop;
}
}
@@ -6602,9 +6737,16 @@
if (existingColorRGBA && Array.isArray(existingColorRGBA)) {
const [er, eg, eb] = existingColorRGBA;
const existingColor = Utils.resolveColor([er, eg, eb], state.availableColors);
if (existingColor.id === targetColorId) {
const isMatch = existingColor.id === targetColorId;
console.debug(
`[COMPARE] Pixel at (${pixelX}, ${pixelY})\n` +
` ├── Current color: rgb(${er}, ${eg}, ${eb}) (id: ${existingColor.id})\n` +
` ├── Target color: rgb(${targetPixelInfo.r}, ${targetPixelInfo.g}, ${targetPixelInfo.b}) (id: ${targetColorId})\n` +
` └── Status: ${isMatch ? '✅ Already painted → SKIP' : '🔴 Needs paint → PAINT'}\n` +
` 📍 Absolute position: (${startX + x}, ${startY + y}) in region (${regionX + adderX}, ${regionY + adderY})`
);
if (isMatch) {
skipPixel(
'alreadyPainted',
targetColorId,
@@ -6614,14 +6756,12 @@
);
continue;
}
} else {
console.log(
`[DEBUG] Pixel (${pixelX}, ${pixelY}) has no existing color or invalid data:`,
existingColorRGBA
);
}
} catch (e) {
console.error(`[DEBUG] Error checking existing pixel at (${pixelX}, ${pixelY}):`, e);
updateUI('paintingPixelCheckFailed', 'error', { x: pixelX, y: pixelY });
state.stopFlag = true;
break outerLoop;
}
pixelBatch.pixels.push({
@@ -6631,10 +6771,7 @@
localX: x,
localY: y,
});
console.log(
`[DEBUG] pixelBatch.pixels.push Pixel (${pixelX}, ${pixelY}) Target color id: ${targetColorId}, target RGB: [${targetPixelInfo.r}, ${targetPixelInfo.g}, ${targetPixelInfo.b}]`
);
debugger;
const maxBatchSize = calculateBatchSize();
if (pixelBatch.pixels.length >= maxBatchSize) {
const modeText =
@@ -6648,6 +6785,7 @@
if (!success) {
console.error(`❌ Batch failed permanently after retries. Stopping painting.`);
state.stopFlag = true;
updateUI('paintingBatchFailed', 'error');
break outerLoop;
}
@@ -6691,10 +6829,6 @@
}
if (state.stopFlag) {
if (state.chargesThresholdInterval) {
clearInterval(state.chargesThresholdInterval);
}
updateUI('paintingStopped', 'warning');
// Save progress when stopped to preserve painted map
Utils.saveProgress();
} else {
@@ -6717,12 +6851,14 @@
console.log(` Skipped - Transparent: ${skippedPixels.transparent}`);
console.log(` Skipped - White (disabled): ${skippedPixels.white}`);
console.log(` Skipped - Already painted: ${skippedPixels.alreadyPainted}`);
console.log(` Skipped - Color Unavailable: ${skippedPixels.colorUnavailable}`);
console.log(
` Total processed: ${
state.paintedPixels +
skippedPixels.transparent +
skippedPixels.white +
skippedPixels.alreadyPainted
skippedPixels.alreadyPainted +
skippedPixels.colorUnavailable
}`
);