From 0d6bfb01d68cf9b71a20731b242c923c41db7ef4 Mon Sep 17 00:00:00 2001 From: Dariusz L Date: Sun, 27 Jul 2025 17:23:08 +0200 Subject: [PATCH] Optimize mask handling and shape mask UX for output area Replaces full-canvas mask operations with getMaskForOutputArea() for significant performance improvements when processing masks for the output area. Refines shape mask removal and application logic to ensure correct mask state when changing expansion values or custom shapes, including auto-removal and re-application of masks. Adds throttling to shape preview rendering for better UI responsiveness. Improves mask removal to eliminate antialiasing artifacts and updates SAM mask integration to use correct output area positioning. --- js/CanvasIO.js | 69 ++++++++-------- js/CanvasInteractions.js | 15 ++++ js/CanvasLayers.js | 114 +++++++++------------------ js/CustomShapeMenu.js | 16 +++- js/MaskTool.js | 99 +++++++++++++++++++---- js/SAMDetectorIntegration.js | 22 ++++-- src/CanvasIO.ts | 85 +++++++++----------- src/CanvasInteractions.ts | 17 ++++ src/CanvasLayers.ts | 144 +++++++++++----------------------- src/CustomShapeMenu.ts | 19 ++++- src/MaskTool.ts | 125 +++++++++++++++++++++++++---- src/SAMDetectorIntegration.ts | 25 ++++-- 12 files changed, 448 insertions(+), 302 deletions(-) diff --git a/js/CanvasIO.js b/js/CanvasIO.js index 7c3c704..eea43a3 100644 --- a/js/CanvasIO.js +++ b/js/CanvasIO.js @@ -77,43 +77,42 @@ export class CanvasIO { } maskCtx.putImageData(maskData, 0, 0); this.canvas.outputAreaShape = originalShape; - const toolMaskCanvas = this.canvas.maskTool.getMask(); + // Use optimized getMaskForOutputArea() instead of getMask() for better performance + // This only processes chunks that overlap with the output area + const toolMaskCanvas = this.canvas.maskTool.getMaskForOutputArea(); if (toolMaskCanvas) { - const tempMaskCanvas = document.createElement('canvas'); - tempMaskCanvas.width = this.canvas.width; - tempMaskCanvas.height = this.canvas.height; - const tempMaskCtx = tempMaskCanvas.getContext('2d', { willReadFrequently: true }); - if (!tempMaskCtx) - throw new Error("Could not create temp mask context"); - tempMaskCtx.clearRect(0, 0, tempMaskCanvas.width, tempMaskCanvas.height); - const maskX = this.canvas.maskTool.x; - const maskY = this.canvas.maskTool.y; - log.debug(`Extracting mask from world position (${maskX}, ${maskY}) for output area (0,0) to (${this.canvas.width}, ${this.canvas.height})`); - const sourceX = Math.max(0, -maskX); // Where in the mask canvas to start reading - const sourceY = Math.max(0, -maskY); - const destX = Math.max(0, maskX); // Where in the output canvas to start writing - const destY = Math.max(0, maskY); - const copyWidth = Math.min(toolMaskCanvas.width - sourceX, // Available width in source - this.canvas.width - destX // Available width in destination - ); - const copyHeight = Math.min(toolMaskCanvas.height - sourceY, // Available height in source - this.canvas.height - destY // Available height in destination - ); - if (copyWidth > 0 && copyHeight > 0) { - log.debug(`Copying mask region: source(${sourceX}, ${sourceY}) to dest(${destX}, ${destY}) size(${copyWidth}, ${copyHeight})`); - tempMaskCtx.drawImage(toolMaskCanvas, sourceX, sourceY, copyWidth, copyHeight, // Source rectangle - destX, destY, copyWidth, copyHeight // Destination rectangle - ); + log.debug(`Using optimized output area mask (${toolMaskCanvas.width}x${toolMaskCanvas.height}) instead of full mask`); + // The optimized mask is already sized and positioned for the output area + // So we can draw it directly without complex positioning calculations + const tempMaskData = toolMaskCanvas.getContext('2d', { willReadFrequently: true })?.getImageData(0, 0, toolMaskCanvas.width, toolMaskCanvas.height); + if (tempMaskData) { + // Ensure the mask data is in the correct format (white with alpha) + for (let i = 0; i < tempMaskData.data.length; i += 4) { + const alpha = tempMaskData.data[i + 3]; + tempMaskData.data[i] = tempMaskData.data[i + 1] = tempMaskData.data[i + 2] = 255; + tempMaskData.data[i + 3] = alpha; + } + // Create a temporary canvas to hold the processed mask + const tempMaskCanvas = document.createElement('canvas'); + tempMaskCanvas.width = this.canvas.width; + tempMaskCanvas.height = this.canvas.height; + const tempMaskCtx = tempMaskCanvas.getContext('2d', { willReadFrequently: true }); + if (!tempMaskCtx) + throw new Error("Could not create temp mask context"); + // Put the processed mask data into a canvas that matches the output area size + const outputMaskCanvas = document.createElement('canvas'); + outputMaskCanvas.width = toolMaskCanvas.width; + outputMaskCanvas.height = toolMaskCanvas.height; + const outputMaskCtx = outputMaskCanvas.getContext('2d', { willReadFrequently: true }); + if (!outputMaskCtx) + throw new Error("Could not create output mask context"); + outputMaskCtx.putImageData(tempMaskData, 0, 0); + // Draw the optimized mask at the correct position (output area bounds) + const bounds = this.canvas.outputAreaBounds; + tempMaskCtx.drawImage(outputMaskCanvas, bounds.x, bounds.y); + maskCtx.globalCompositeOperation = 'source-over'; + maskCtx.drawImage(tempMaskCanvas, 0, 0); } - const tempMaskData = tempMaskCtx.getImageData(0, 0, this.canvas.width, this.canvas.height); - for (let i = 0; i < tempMaskData.data.length; i += 4) { - const alpha = tempMaskData.data[i + 3]; - tempMaskData.data[i] = tempMaskData.data[i + 1] = tempMaskData.data[i + 2] = 255; - tempMaskData.data[i + 3] = alpha; - } - tempMaskCtx.putImageData(tempMaskData, 0, 0); - maskCtx.globalCompositeOperation = 'source-over'; - maskCtx.drawImage(tempMaskCanvas, 0, 0); } if (outputMode === 'ram') { const imageData = tempCanvas.toDataURL('image/png'); diff --git a/js/CanvasInteractions.js b/js/CanvasInteractions.js index 4ab202e..ddc0b11 100644 --- a/js/CanvasInteractions.js +++ b/js/CanvasInteractions.js @@ -118,6 +118,11 @@ export class CanvasInteractions { if (e.shiftKey) { // Clear custom shape when starting canvas resize if (this.canvas.outputAreaShape) { + // If auto-apply shape mask is enabled, remove the mask before clearing the shape + if (this.canvas.autoApplyShapeMask) { + log.info("Removing shape mask before clearing custom shape for canvas resize"); + this.canvas.maskTool.removeShapeMask(); + } this.canvas.outputAreaShape = null; this.canvas.render(); } @@ -822,6 +827,11 @@ export class CanvasInteractions { const boundingBox = this.canvas.shapeTool.getBoundingBox(); if (boundingBox && boundingBox.width > 1 && boundingBox.height > 1) { this.canvas.saveState(); + // If there's an existing custom shape and auto-apply shape mask is enabled, remove the previous mask + if (this.canvas.outputAreaShape && this.canvas.autoApplyShapeMask) { + log.info("Removing previous shape mask before defining new custom shape"); + this.canvas.maskTool.removeShapeMask(); + } this.canvas.outputAreaShape = { ...shape, points: shape.points.map((p) => ({ @@ -866,6 +876,11 @@ export class CanvasInteractions { } // Update mask canvas to ensure it covers the new output area position this.canvas.maskTool.updateMaskCanvasForOutputArea(); + // If auto-apply shape mask is enabled, automatically apply the mask with current settings + if (this.canvas.autoApplyShapeMask) { + log.info("Auto-applying shape mask to new custom shape with current settings"); + this.canvas.maskTool.applyShapeMask(); + } this.canvas.saveState(); this.canvas.render(); } diff --git a/js/CanvasLayers.js b/js/CanvasLayers.js index ce7fed6..197c8c1 100644 --- a/js/CanvasLayers.js +++ b/js/CanvasLayers.js @@ -843,48 +843,24 @@ export class CanvasLayers { if (applyMask) { const imageData = tempCtx.getImageData(0, 0, tempCanvas.width, tempCanvas.height); const data = imageData.data; - const toolMaskCanvas = this.canvas.maskTool.getMask(); + // Use optimized getMaskForOutputArea() for better performance + // This only processes chunks that overlap with the output area + const toolMaskCanvas = this.canvas.maskTool.getMaskForOutputArea(); if (toolMaskCanvas) { - const tempMaskCanvas = document.createElement('canvas'); - tempMaskCanvas.width = bounds.width; - tempMaskCanvas.height = bounds.height; - const tempMaskCtx = tempMaskCanvas.getContext('2d', { willReadFrequently: true }); - if (!tempMaskCtx) { - reject(new Error("Could not create mask canvas context")); - return; + log.debug(`Using optimized output area mask (${toolMaskCanvas.width}x${toolMaskCanvas.height}) for _generateCanvasBlob`); + // The optimized mask is already sized and positioned for the output area + // So we can apply it directly without complex positioning calculations + const maskImageData = toolMaskCanvas.getContext('2d', { willReadFrequently: true })?.getImageData(0, 0, toolMaskCanvas.width, toolMaskCanvas.height); + if (maskImageData) { + const maskData = maskImageData.data; + for (let i = 0; i < data.length; i += 4) { + const originalAlpha = data[i + 3]; + const maskAlpha = maskData[i + 3] / 255; + const invertedMaskAlpha = 1 - maskAlpha; + data[i + 3] = originalAlpha * invertedMaskAlpha; + } + tempCtx.putImageData(imageData, 0, 0); } - tempMaskCtx.clearRect(0, 0, tempMaskCanvas.width, tempMaskCanvas.height); - // Pozycja maski w świecie (bez przesunięcia względem bounds) - const maskWorldX = this.canvas.maskTool.x; - const maskWorldY = this.canvas.maskTool.y; - // Pozycja maski względem output bounds (gdzie ma być narysowana w output canvas) - const maskX = maskWorldX - bounds.x; - const maskY = maskWorldY - bounds.y; - const sourceX = Math.max(0, -maskX); - const sourceY = Math.max(0, -maskY); - const destX = Math.max(0, maskX); - const destY = Math.max(0, maskY); - const copyWidth = Math.min(toolMaskCanvas.width - sourceX, bounds.width - destX); - const copyHeight = Math.min(toolMaskCanvas.height - sourceY, bounds.height - destY); - if (copyWidth > 0 && copyHeight > 0) { - tempMaskCtx.drawImage(toolMaskCanvas, sourceX, sourceY, copyWidth, copyHeight, destX, destY, copyWidth, copyHeight); - } - const tempMaskData = tempMaskCtx.getImageData(0, 0, bounds.width, bounds.height); - for (let i = 0; i < tempMaskData.data.length; i += 4) { - const alpha = tempMaskData.data[i + 3]; - tempMaskData.data[i] = tempMaskData.data[i + 1] = tempMaskData.data[i + 2] = 255; - tempMaskData.data[i + 3] = alpha; - } - tempMaskCtx.putImageData(tempMaskData, 0, 0); - const maskImageData = tempMaskCtx.getImageData(0, 0, bounds.width, bounds.height); - const maskData = maskImageData.data; - for (let i = 0; i < data.length; i += 4) { - const originalAlpha = data[i + 3]; - const maskAlpha = maskData[i + 3] / 255; - const invertedMaskAlpha = 1 - maskAlpha; - data[i + 3] = originalAlpha * invertedMaskAlpha; - } - tempCtx.putImageData(imageData, 0, 0); } } tempCanvas.toBlob((blob) => { @@ -963,43 +939,31 @@ export class CanvasLayers { maskData.data[i + 3] = 255; // Solidna maska } maskCtx.putImageData(maskData, 0, 0); - // Aplikuj maskę narzędzia jeśli istnieje - const toolMaskCanvas = this.canvas.maskTool.getMask(); + // Aplikuj maskę narzędzia jeśli istnieje - używaj zoptymalizowanej metody + const toolMaskCanvas = this.canvas.maskTool.getMaskForOutputArea(); if (toolMaskCanvas) { - const tempMaskCanvas = document.createElement('canvas'); - tempMaskCanvas.width = bounds.width; - tempMaskCanvas.height = bounds.height; - const tempMaskCtx = tempMaskCanvas.getContext('2d', { willReadFrequently: true }); - if (!tempMaskCtx) { - reject(new Error("Could not create temp mask context")); - return; + log.debug(`[getFlattenedMaskAsBlob] Using optimized output area mask (${toolMaskCanvas.width}x${toolMaskCanvas.height})`); + // Zoptymalizowana maska jest już odpowiednio pozycjonowana dla output area + // Możemy ją zastosować bezpośrednio + const tempMaskData = toolMaskCanvas.getContext('2d', { willReadFrequently: true })?.getImageData(0, 0, toolMaskCanvas.width, toolMaskCanvas.height); + if (tempMaskData) { + // Konwertuj dane maski do odpowiedniego formatu + for (let i = 0; i < tempMaskData.data.length; i += 4) { + const alpha = tempMaskData.data[i + 3]; + tempMaskData.data[i] = tempMaskData.data[i + 1] = tempMaskData.data[i + 2] = alpha; + tempMaskData.data[i + 3] = 255; // Solidna alpha + } + // Stwórz tymczasowy canvas dla przetworzonej maski + const tempMaskCanvas = document.createElement('canvas'); + tempMaskCanvas.width = toolMaskCanvas.width; + tempMaskCanvas.height = toolMaskCanvas.height; + const tempMaskCtx = tempMaskCanvas.getContext('2d', { willReadFrequently: true }); + if (tempMaskCtx) { + tempMaskCtx.putImageData(tempMaskData, 0, 0); + maskCtx.globalCompositeOperation = 'screen'; + maskCtx.drawImage(tempMaskCanvas, 0, 0); + } } - tempMaskCtx.clearRect(0, 0, tempMaskCanvas.width, tempMaskCanvas.height); - // Pozycja maski w świecie (bez przesunięcia względem bounds) - const maskWorldX = this.canvas.maskTool.x; - const maskWorldY = this.canvas.maskTool.y; - // Pozycja maski względem output bounds (gdzie ma być narysowana w output canvas) - const maskX = maskWorldX - bounds.x; - const maskY = maskWorldY - bounds.y; - log.debug(`[getFlattenedMaskAsBlob] Mask world position (${maskWorldX}, ${maskWorldY}) relative to bounds (${maskX}, ${maskY})`); - const sourceX = Math.max(0, -maskX); - const sourceY = Math.max(0, -maskY); - const destX = Math.max(0, maskX); - const destY = Math.max(0, maskY); - const copyWidth = Math.min(toolMaskCanvas.width - sourceX, bounds.width - destX); - const copyHeight = Math.min(toolMaskCanvas.height - sourceY, bounds.height - destY); - if (copyWidth > 0 && copyHeight > 0) { - tempMaskCtx.drawImage(toolMaskCanvas, sourceX, sourceY, copyWidth, copyHeight, destX, destY, copyWidth, copyHeight); - } - const tempMaskData = tempMaskCtx.getImageData(0, 0, bounds.width, bounds.height); - for (let i = 0; i < tempMaskData.data.length; i += 4) { - const alpha = tempMaskData.data[i + 3]; - tempMaskData.data[i] = tempMaskData.data[i + 1] = tempMaskData.data[i + 2] = alpha; - tempMaskData.data[i + 3] = 255; // Solidna alpha - } - tempMaskCtx.putImageData(tempMaskData, 0, 0); - maskCtx.globalCompositeOperation = 'screen'; - maskCtx.drawImage(tempMaskCanvas, 0, 0); } log.info("=== MASK BLOB GENERATED ==="); maskCanvas.toBlob((blob) => { diff --git a/js/CustomShapeMenu.js b/js/CustomShapeMenu.js index 8d7304e..a1a0be0 100644 --- a/js/CustomShapeMenu.js +++ b/js/CustomShapeMenu.js @@ -148,13 +148,17 @@ export class CustomShapeMenu { margin-top: 2px; color: #aaa; `; + let expansionValueBeforeDrag = this.canvas.shapeMaskExpansionValue; const updateExpansionSliderDisplay = () => { const value = parseInt(expansionSlider.value); this.canvas.shapeMaskExpansionValue = value; expansionValueDisplay.textContent = value > 0 ? `+${value}px` : `${value}px`; }; let isExpansionDragging = false; - expansionSlider.onmousedown = () => { isExpansionDragging = true; }; + expansionSlider.onmousedown = () => { + isExpansionDragging = true; + expansionValueBeforeDrag = this.canvas.shapeMaskExpansionValue; // Store value before dragging + }; expansionSlider.oninput = () => { updateExpansionSliderDisplay(); if (this.canvas.autoApplyShapeMask) { @@ -172,6 +176,16 @@ export class CustomShapeMenu { expansionSlider.onmouseup = () => { isExpansionDragging = false; if (this.canvas.autoApplyShapeMask) { + const finalValue = parseInt(expansionSlider.value); + // If value changed during drag, remove old mask with previous expansion value + if (expansionValueBeforeDrag !== finalValue) { + // Temporarily set the previous value to remove the old mask properly + const tempValue = this.canvas.shapeMaskExpansionValue; + this.canvas.shapeMaskExpansionValue = expansionValueBeforeDrag; + this.canvas.maskTool.removeShapeMask(); + this.canvas.shapeMaskExpansionValue = tempValue; // Restore current value + log.info(`Removed old shape mask with expansion: ${expansionValueBeforeDrag}px before applying new value: ${finalValue}px`); + } this.canvas.maskTool.hideShapePreview(); this.canvas.maskTool.applyShapeMask(true); this.canvas.render(); diff --git a/js/MaskTool.js b/js/MaskTool.js index 16499a0..3f1053b 100644 --- a/js/MaskTool.js +++ b/js/MaskTool.js @@ -3,6 +3,7 @@ const log = createModuleLogger('Mask_tool'); export class MaskTool { constructor(canvasInstance, callbacks = {}) { this.ACTIVE_MASK_UPDATE_DELAY = 16; // ~60fps throttling + this.SHAPE_PREVIEW_THROTTLE_DELAY = 16; // ~60fps throttling for preview this.canvasInstance = canvasInstance; this.mainCanvas = canvasInstance.canvas; this.onStateChange = callbacks.onStateChange || null; @@ -50,6 +51,9 @@ export class MaskTool { // Initialize performance optimization flags this.activeMaskNeedsUpdate = false; this.activeMaskUpdateTimeout = null; + // Initialize shape preview throttling + this.shapePreviewThrottleTimeout = null; + this.pendingPreviewParams = null; this.initMaskCanvas(); } // Temporary compatibility getters - will be replaced with chunked system @@ -537,6 +541,7 @@ export class MaskTool { /** * Prepares shape mask configuration data - eliminates duplication between applyShapeMask and removeShapeMask * Returns all necessary data for shape mask operations including world coordinates and temporary canvas setup + * Now uses precise expansion calculation based on actual user values */ prepareShapeMaskConfiguration() { // Validate shape @@ -551,12 +556,14 @@ export class MaskTool { x: bounds.x + extensionOffset.x + p.x, y: bounds.y + extensionOffset.y + p.y })); - // Create a temporary canvas large enough to contain the shape and any expansion - const maxExpansion = Math.max(300, Math.abs(this.canvasInstance.shapeMaskExpansionValue || 0)); - const tempCanvasWidth = bounds.width + (maxExpansion * 2); - const tempCanvasHeight = bounds.height + (maxExpansion * 2); - const tempOffsetX = maxExpansion; - const tempOffsetY = maxExpansion; + // Use precise expansion calculation - only actual user value + small safety margin + const userExpansionValue = Math.abs(this.canvasInstance.shapeMaskExpansionValue || 0); + const safetyMargin = 10; // Small safety margin for precise operations + const preciseExpansion = userExpansionValue + safetyMargin; + const tempCanvasWidth = bounds.width + (preciseExpansion * 2); + const tempCanvasHeight = bounds.height + (preciseExpansion * 2); + const tempOffsetX = preciseExpansion; + const tempOffsetY = preciseExpansion; // Adjust shape points for the temporary canvas const tempShapePoints = worldShapePoints.map(p => ({ x: p.x - bounds.x + tempOffsetX, @@ -567,7 +574,7 @@ export class MaskTool { bounds, extensionOffset, worldShapePoints, - maxExpansion, + maxExpansion: preciseExpansion, tempCanvasWidth, tempCanvasHeight, tempOffsetX, @@ -943,6 +950,25 @@ export class MaskTool { * Show blue outline preview of expansion/contraction during slider adjustment */ showShapePreview(expansionValue, featherValue = 0) { + // Store the parameters for throttled execution + this.pendingPreviewParams = { expansionValue, featherValue }; + // If there's already a pending preview update, don't schedule another one + if (this.shapePreviewThrottleTimeout !== null) { + return; + } + // Schedule the preview update with throttling + this.shapePreviewThrottleTimeout = window.setTimeout(() => { + if (this.pendingPreviewParams) { + this.executeShapePreview(this.pendingPreviewParams.expansionValue, this.pendingPreviewParams.featherValue); + this.pendingPreviewParams = null; + } + this.shapePreviewThrottleTimeout = null; + }, this.SHAPE_PREVIEW_THROTTLE_DELAY); + } + /** + * Executes the actual shape preview rendering - separated from showShapePreview for throttling + */ + executeShapePreview(expansionValue, featherValue = 0) { if (!this.canvasInstance.outputAreaShape?.points || this.canvasInstance.outputAreaShape.points.length < 3) { return; } @@ -975,7 +1001,7 @@ export class MaskTool { const allFeatherContours = this._calculatePreviewPointsScreen(allContours, -featherValue, viewport.zoom); this.drawContoursForPreview(this.shapePreviewCtx, allFeatherContours, '#4A9EFF', 1, [3, 5], 0.6); } - log.debug(`Shape preview shown with expansion: ${expansionValue}px, feather: ${featherValue}px at bounds (${bounds.x}, ${bounds.y})`); + log.debug(`Shape preview executed with expansion: ${expansionValue}px, feather: ${featherValue}px at bounds (${bounds.x}, ${bounds.y})`); } /** * Hide shape preview and switch back to normal mode @@ -1230,6 +1256,47 @@ export class MaskTool { } return this.activeMaskCanvas; } + /** + * Gets mask only for the output area - optimized for performance + * Returns only the portion of the mask that overlaps with the output area + * This is much more efficient than returning the entire mask when there are many chunks + */ + getMaskForOutputArea() { + const bounds = this.canvasInstance.outputAreaBounds; + // Create canvas sized to output area + const outputMaskCanvas = document.createElement('canvas'); + outputMaskCanvas.width = bounds.width; + outputMaskCanvas.height = bounds.height; + const outputMaskCtx = outputMaskCanvas.getContext('2d', { willReadFrequently: true }); + if (!outputMaskCtx) { + throw new Error("Failed to get 2D context for output area mask canvas"); + } + // Calculate which chunks overlap with the output area + const outputLeft = bounds.x; + const outputTop = bounds.y; + const outputRight = bounds.x + bounds.width; + const outputBottom = bounds.y + bounds.height; + const chunkBounds = this.calculateChunkBounds(outputLeft, outputTop, outputRight, outputBottom); + // Only process chunks that overlap with output area + for (let chunkY = chunkBounds.minY; chunkY <= chunkBounds.maxY; chunkY++) { + for (let chunkX = chunkBounds.minX; chunkX <= chunkBounds.maxX; chunkX++) { + const chunkKey = `${chunkX},${chunkY}`; + const chunk = this.maskChunks.get(chunkKey); + if (chunk && !chunk.isEmpty) { + // Calculate intersection between chunk and output area + const intersection = this.calculateChunkIntersection(chunk, outputLeft, outputTop, outputRight, outputBottom); + if (intersection) { + // Draw only the intersecting portion + outputMaskCtx.drawImage(chunk.canvas, intersection.destX, intersection.destY, intersection.destWidth, intersection.destHeight, // Source from chunk + intersection.srcX, intersection.srcY, intersection.srcWidth, intersection.srcHeight // Destination on output canvas + ); + } + } + } + } + log.debug(`Generated output area mask (${bounds.width}x${bounds.height}) from ${chunkBounds.maxX - chunkBounds.minX + 1}x${chunkBounds.maxY - chunkBounds.minY + 1} chunks`); + return outputMaskCanvas; + } resize(width, height) { this.initPreviewCanvas(); const oldMask = this.maskCanvas; @@ -1512,20 +1579,22 @@ export class MaskTool { const needsExpansion = this.canvasInstance.shapeMaskExpansion && this.canvasInstance.shapeMaskExpansionValue !== 0; // Create a removal mask canvas - always hard-edged to ensure complete removal let removalMaskCanvas; + // Add safety margin to ensure complete removal of antialiasing artifacts + const safetyMargin = 2; // 2px margin to remove any antialiasing remnants if (needsExpansion) { - // If expansion was active, remove the expanded area with a hard edge - removalMaskCanvas = this._createExpandedMaskCanvas(config.tempShapePoints, this.canvasInstance.shapeMaskExpansionValue, config.tempCanvasWidth, config.tempCanvasHeight); + // If expansion was active, remove exactly the user's expansion value + safety margin + const userExpansionValue = this.canvasInstance.shapeMaskExpansionValue; + const expandedValue = Math.abs(userExpansionValue) + safetyMargin; + removalMaskCanvas = this._createExpandedMaskCanvas(config.tempShapePoints, expandedValue, config.tempCanvasWidth, config.tempCanvasHeight); } else { - // If no expansion, just remove the base shape with a hard edge - const { canvas, ctx } = this.createCanvas(config.tempCanvasWidth, config.tempCanvasHeight); - removalMaskCanvas = canvas; - this.drawShapeOnCanvas(ctx, config.tempShapePoints, 'evenodd'); + // If no expansion, remove the base shape with safety margin only + removalMaskCanvas = this._createExpandedMaskCanvas(config.tempShapePoints, safetyMargin, config.tempCanvasWidth, config.tempCanvasHeight); } // Now remove the shape mask from the chunked system this.removeMaskCanvasFromChunks(removalMaskCanvas, config.bounds.x - config.tempOffsetX, config.bounds.y - config.tempOffsetY); // Update the active mask canvas to show the changes - this.updateActiveMaskCanvas(); + this.updateActiveMaskCanvas(true); // Force full update to ensure all chunks are properly updated if (this.onStateChange) { this.onStateChange(); } diff --git a/js/SAMDetectorIntegration.js b/js/SAMDetectorIntegration.js index 97b7744..1a9cdb7 100644 --- a/js/SAMDetectorIntegration.js +++ b/js/SAMDetectorIntegration.js @@ -289,16 +289,26 @@ async function handleSAMDetectorResult(node, resultImage) { showNotification("Failed to load SAM Detector result. The mask file may not be available.", "#c54747", 5000); return; } - // Create temporary canvas for mask processing (same as CanvasMask) + // Create temporary canvas for mask processing with correct positioning log.debug("Creating temporary canvas for mask processing"); + // Get the output area bounds to position the mask correctly + const bounds = canvas.outputAreaBounds; + log.debug("Output area bounds for SAM mask positioning:", bounds); + // Create canvas sized to match the result image dimensions const tempCanvas = document.createElement('canvas'); - tempCanvas.width = canvas.width; - tempCanvas.height = canvas.height; + tempCanvas.width = resultImage.width; + tempCanvas.height = resultImage.height; const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true }); if (tempCtx) { - tempCtx.drawImage(resultImage, 0, 0, canvas.width, canvas.height); - log.debug("Processing image data to create mask"); - const imageData = tempCtx.getImageData(0, 0, canvas.width, canvas.height); + // Draw the result image at its natural size (no scaling) + tempCtx.drawImage(resultImage, 0, 0); + log.debug("Processing image data to create mask", { + imageWidth: resultImage.width, + imageHeight: resultImage.height, + boundsX: bounds.x, + boundsY: bounds.y + }); + const imageData = tempCtx.getImageData(0, 0, tempCanvas.width, tempCanvas.height); const data = imageData.data; // Convert to mask format (same as CanvasMask) for (let i = 0; i < data.length; i += 4) { diff --git a/src/CanvasIO.ts b/src/CanvasIO.ts index 126d502..4ff2e0b 100644 --- a/src/CanvasIO.ts +++ b/src/CanvasIO.ts @@ -92,57 +92,46 @@ export class CanvasIO { this.canvas.outputAreaShape = originalShape; - const toolMaskCanvas = this.canvas.maskTool.getMask(); + // Use optimized getMaskForOutputArea() instead of getMask() for better performance + // This only processes chunks that overlap with the output area + const toolMaskCanvas = this.canvas.maskTool.getMaskForOutputArea(); if (toolMaskCanvas) { + log.debug(`Using optimized output area mask (${toolMaskCanvas.width}x${toolMaskCanvas.height}) instead of full mask`); - const tempMaskCanvas = document.createElement('canvas'); - tempMaskCanvas.width = this.canvas.width; - tempMaskCanvas.height = this.canvas.height; - const tempMaskCtx = tempMaskCanvas.getContext('2d', { willReadFrequently: true }); - if (!tempMaskCtx) throw new Error("Could not create temp mask context"); - - tempMaskCtx.clearRect(0, 0, tempMaskCanvas.width, tempMaskCanvas.height); - - - const maskX = this.canvas.maskTool.x; - const maskY = this.canvas.maskTool.y; - - log.debug(`Extracting mask from world position (${maskX}, ${maskY}) for output area (0,0) to (${this.canvas.width}, ${this.canvas.height})`); - - const sourceX = Math.max(0, -maskX); // Where in the mask canvas to start reading - const sourceY = Math.max(0, -maskY); - const destX = Math.max(0, maskX); // Where in the output canvas to start writing - const destY = Math.max(0, maskY); - - const copyWidth = Math.min( - toolMaskCanvas.width - sourceX, // Available width in source - this.canvas.width - destX // Available width in destination - ); - const copyHeight = Math.min( - toolMaskCanvas.height - sourceY, // Available height in source - this.canvas.height - destY // Available height in destination - ); - - if (copyWidth > 0 && copyHeight > 0) { - log.debug(`Copying mask region: source(${sourceX}, ${sourceY}) to dest(${destX}, ${destY}) size(${copyWidth}, ${copyHeight})`); - - tempMaskCtx.drawImage( - toolMaskCanvas, - sourceX, sourceY, copyWidth, copyHeight, // Source rectangle - destX, destY, copyWidth, copyHeight // Destination rectangle - ); + // The optimized mask is already sized and positioned for the output area + // So we can draw it directly without complex positioning calculations + const tempMaskData = toolMaskCanvas.getContext('2d', { willReadFrequently: true })?.getImageData(0, 0, toolMaskCanvas.width, toolMaskCanvas.height); + if (tempMaskData) { + // Ensure the mask data is in the correct format (white with alpha) + for (let i = 0; i < tempMaskData.data.length; i += 4) { + const alpha = tempMaskData.data[i + 3]; + tempMaskData.data[i] = tempMaskData.data[i + 1] = tempMaskData.data[i + 2] = 255; + tempMaskData.data[i + 3] = alpha; + } + + // Create a temporary canvas to hold the processed mask + const tempMaskCanvas = document.createElement('canvas'); + tempMaskCanvas.width = this.canvas.width; + tempMaskCanvas.height = this.canvas.height; + const tempMaskCtx = tempMaskCanvas.getContext('2d', { willReadFrequently: true }); + if (!tempMaskCtx) throw new Error("Could not create temp mask context"); + + // Put the processed mask data into a canvas that matches the output area size + const outputMaskCanvas = document.createElement('canvas'); + outputMaskCanvas.width = toolMaskCanvas.width; + outputMaskCanvas.height = toolMaskCanvas.height; + const outputMaskCtx = outputMaskCanvas.getContext('2d', { willReadFrequently: true }); + if (!outputMaskCtx) throw new Error("Could not create output mask context"); + + outputMaskCtx.putImageData(tempMaskData, 0, 0); + + // Draw the optimized mask at the correct position (output area bounds) + const bounds = this.canvas.outputAreaBounds; + tempMaskCtx.drawImage(outputMaskCanvas, bounds.x, bounds.y); + + maskCtx.globalCompositeOperation = 'source-over'; + maskCtx.drawImage(tempMaskCanvas, 0, 0); } - - const tempMaskData = tempMaskCtx.getImageData(0, 0, this.canvas.width, this.canvas.height); - for (let i = 0; i < tempMaskData.data.length; i += 4) { - const alpha = tempMaskData.data[i + 3]; - tempMaskData.data[i] = tempMaskData.data[i + 1] = tempMaskData.data[i + 2] = 255; - tempMaskData.data[i + 3] = alpha; - } - tempMaskCtx.putImageData(tempMaskData, 0, 0); - - maskCtx.globalCompositeOperation = 'source-over'; - maskCtx.drawImage(tempMaskCanvas, 0, 0); } if (outputMode === 'ram') { const imageData = tempCanvas.toDataURL('image/png'); diff --git a/src/CanvasInteractions.ts b/src/CanvasInteractions.ts index d82415e..79f7e51 100644 --- a/src/CanvasInteractions.ts +++ b/src/CanvasInteractions.ts @@ -170,6 +170,11 @@ export class CanvasInteractions { if (e.shiftKey) { // Clear custom shape when starting canvas resize if (this.canvas.outputAreaShape) { + // If auto-apply shape mask is enabled, remove the mask before clearing the shape + if (this.canvas.autoApplyShapeMask) { + log.info("Removing shape mask before clearing custom shape for canvas resize"); + this.canvas.maskTool.removeShapeMask(); + } this.canvas.outputAreaShape = null; this.canvas.render(); } @@ -945,6 +950,12 @@ export class CanvasInteractions { if (boundingBox && boundingBox.width > 1 && boundingBox.height > 1) { this.canvas.saveState(); + // If there's an existing custom shape and auto-apply shape mask is enabled, remove the previous mask + if (this.canvas.outputAreaShape && this.canvas.autoApplyShapeMask) { + log.info("Removing previous shape mask before defining new custom shape"); + this.canvas.maskTool.removeShapeMask(); + } + this.canvas.outputAreaShape = { ...shape, points: shape.points.map((p: any) => ({ @@ -999,6 +1010,12 @@ export class CanvasInteractions { // Update mask canvas to ensure it covers the new output area position this.canvas.maskTool.updateMaskCanvasForOutputArea(); + // If auto-apply shape mask is enabled, automatically apply the mask with current settings + if (this.canvas.autoApplyShapeMask) { + log.info("Auto-applying shape mask to new custom shape with current settings"); + this.canvas.maskTool.applyShapeMask(); + } + this.canvas.saveState(); this.canvas.render(); } diff --git a/src/CanvasLayers.ts b/src/CanvasLayers.ts index b2993af..38d2408 100644 --- a/src/CanvasLayers.ts +++ b/src/CanvasLayers.ts @@ -982,60 +982,26 @@ export class CanvasLayers { const imageData = tempCtx.getImageData(0, 0, tempCanvas.width, tempCanvas.height); const data = imageData.data; - const toolMaskCanvas = this.canvas.maskTool.getMask(); + // Use optimized getMaskForOutputArea() for better performance + // This only processes chunks that overlap with the output area + const toolMaskCanvas = this.canvas.maskTool.getMaskForOutputArea(); if (toolMaskCanvas) { - const tempMaskCanvas = document.createElement('canvas'); - tempMaskCanvas.width = bounds.width; - tempMaskCanvas.height = bounds.height; - const tempMaskCtx = tempMaskCanvas.getContext('2d', { willReadFrequently: true }); - if (!tempMaskCtx) { - reject(new Error("Could not create mask canvas context")); - return; + log.debug(`Using optimized output area mask (${toolMaskCanvas.width}x${toolMaskCanvas.height}) for _generateCanvasBlob`); + + // The optimized mask is already sized and positioned for the output area + // So we can apply it directly without complex positioning calculations + const maskImageData = toolMaskCanvas.getContext('2d', { willReadFrequently: true })?.getImageData(0, 0, toolMaskCanvas.width, toolMaskCanvas.height); + if (maskImageData) { + const maskData = maskImageData.data; + + for (let i = 0; i < data.length; i += 4) { + const originalAlpha = data[i + 3]; + const maskAlpha = maskData[i + 3] / 255; + const invertedMaskAlpha = 1 - maskAlpha; + data[i + 3] = originalAlpha * invertedMaskAlpha; + } + tempCtx.putImageData(imageData, 0, 0); } - - tempMaskCtx.clearRect(0, 0, tempMaskCanvas.width, tempMaskCanvas.height); - - // Pozycja maski w świecie (bez przesunięcia względem bounds) - const maskWorldX = this.canvas.maskTool.x; - const maskWorldY = this.canvas.maskTool.y; - - // Pozycja maski względem output bounds (gdzie ma być narysowana w output canvas) - const maskX = maskWorldX - bounds.x; - const maskY = maskWorldY - bounds.y; - - const sourceX = Math.max(0, -maskX); - const sourceY = Math.max(0, -maskY); - const destX = Math.max(0, maskX); - const destY = Math.max(0, maskY); - const copyWidth = Math.min(toolMaskCanvas.width - sourceX, bounds.width - destX); - const copyHeight = Math.min(toolMaskCanvas.height - sourceY, bounds.height - destY); - - if (copyWidth > 0 && copyHeight > 0) { - tempMaskCtx.drawImage( - toolMaskCanvas, - sourceX, sourceY, copyWidth, copyHeight, - destX, destY, copyWidth, copyHeight - ); - } - - const tempMaskData = tempMaskCtx.getImageData(0, 0, bounds.width, bounds.height); - for (let i = 0; i < tempMaskData.data.length; i += 4) { - const alpha = tempMaskData.data[i + 3]; - tempMaskData.data[i] = tempMaskData.data[i + 1] = tempMaskData.data[i + 2] = 255; - tempMaskData.data[i + 3] = alpha; - } - tempMaskCtx.putImageData(tempMaskData, 0, 0); - - const maskImageData = tempMaskCtx.getImageData(0, 0, bounds.width, bounds.height); - const maskData = maskImageData.data; - - for (let i = 0; i < data.length; i += 4) { - const originalAlpha = data[i + 3]; - const maskAlpha = maskData[i + 3] / 255; - const invertedMaskAlpha = 1 - maskAlpha; - data[i + 3] = originalAlpha * invertedMaskAlpha; - } - tempCtx.putImageData(imageData, 0, 0); } } @@ -1126,56 +1092,34 @@ export class CanvasLayers { } maskCtx.putImageData(maskData, 0, 0); - // Aplikuj maskę narzędzia jeśli istnieje - const toolMaskCanvas = this.canvas.maskTool.getMask(); + // Aplikuj maskę narzędzia jeśli istnieje - używaj zoptymalizowanej metody + const toolMaskCanvas = this.canvas.maskTool.getMaskForOutputArea(); if (toolMaskCanvas) { - const tempMaskCanvas = document.createElement('canvas'); - tempMaskCanvas.width = bounds.width; - tempMaskCanvas.height = bounds.height; - const tempMaskCtx = tempMaskCanvas.getContext('2d', { willReadFrequently: true }); - if (!tempMaskCtx) { - reject(new Error("Could not create temp mask context")); - return; + log.debug(`[getFlattenedMaskAsBlob] Using optimized output area mask (${toolMaskCanvas.width}x${toolMaskCanvas.height})`); + + // Zoptymalizowana maska jest już odpowiednio pozycjonowana dla output area + // Możemy ją zastosować bezpośrednio + const tempMaskData = toolMaskCanvas.getContext('2d', { willReadFrequently: true })?.getImageData(0, 0, toolMaskCanvas.width, toolMaskCanvas.height); + if (tempMaskData) { + // Konwertuj dane maski do odpowiedniego formatu + for (let i = 0; i < tempMaskData.data.length; i += 4) { + const alpha = tempMaskData.data[i + 3]; + tempMaskData.data[i] = tempMaskData.data[i + 1] = tempMaskData.data[i + 2] = alpha; + tempMaskData.data[i + 3] = 255; // Solidna alpha + } + + // Stwórz tymczasowy canvas dla przetworzonej maski + const tempMaskCanvas = document.createElement('canvas'); + tempMaskCanvas.width = toolMaskCanvas.width; + tempMaskCanvas.height = toolMaskCanvas.height; + const tempMaskCtx = tempMaskCanvas.getContext('2d', { willReadFrequently: true }); + if (tempMaskCtx) { + tempMaskCtx.putImageData(tempMaskData, 0, 0); + + maskCtx.globalCompositeOperation = 'screen'; + maskCtx.drawImage(tempMaskCanvas, 0, 0); + } } - - tempMaskCtx.clearRect(0, 0, tempMaskCanvas.width, tempMaskCanvas.height); - - // Pozycja maski w świecie (bez przesunięcia względem bounds) - const maskWorldX = this.canvas.maskTool.x; - const maskWorldY = this.canvas.maskTool.y; - - // Pozycja maski względem output bounds (gdzie ma być narysowana w output canvas) - const maskX = maskWorldX - bounds.x; - const maskY = maskWorldY - bounds.y; - - log.debug(`[getFlattenedMaskAsBlob] Mask world position (${maskWorldX}, ${maskWorldY}) relative to bounds (${maskX}, ${maskY})`); - - const sourceX = Math.max(0, -maskX); - const sourceY = Math.max(0, -maskY); - const destX = Math.max(0, maskX); - const destY = Math.max(0, maskY); - - const copyWidth = Math.min(toolMaskCanvas.width - sourceX, bounds.width - destX); - const copyHeight = Math.min(toolMaskCanvas.height - sourceY, bounds.height - destY); - - if (copyWidth > 0 && copyHeight > 0) { - tempMaskCtx.drawImage( - toolMaskCanvas, - sourceX, sourceY, copyWidth, copyHeight, - destX, destY, copyWidth, copyHeight - ); - } - - const tempMaskData = tempMaskCtx.getImageData(0, 0, bounds.width, bounds.height); - for (let i = 0; i < tempMaskData.data.length; i += 4) { - const alpha = tempMaskData.data[i + 3]; - tempMaskData.data[i] = tempMaskData.data[i + 1] = tempMaskData.data[i + 2] = alpha; - tempMaskData.data[i + 3] = 255; // Solidna alpha - } - tempMaskCtx.putImageData(tempMaskData, 0, 0); - - maskCtx.globalCompositeOperation = 'screen'; - maskCtx.drawImage(tempMaskCanvas, 0, 0); } log.info("=== MASK BLOB GENERATED ==="); diff --git a/src/CustomShapeMenu.ts b/src/CustomShapeMenu.ts index 07310b8..5b44fab 100644 --- a/src/CustomShapeMenu.ts +++ b/src/CustomShapeMenu.ts @@ -187,6 +187,8 @@ export class CustomShapeMenu { color: #aaa; `; + let expansionValueBeforeDrag = this.canvas.shapeMaskExpansionValue; + const updateExpansionSliderDisplay = () => { const value = parseInt(expansionSlider.value); this.canvas.shapeMaskExpansionValue = value; @@ -195,7 +197,10 @@ export class CustomShapeMenu { let isExpansionDragging = false; - expansionSlider.onmousedown = () => { isExpansionDragging = true; }; + expansionSlider.onmousedown = () => { + isExpansionDragging = true; + expansionValueBeforeDrag = this.canvas.shapeMaskExpansionValue; // Store value before dragging + }; expansionSlider.oninput = () => { updateExpansionSliderDisplay(); @@ -214,6 +219,18 @@ export class CustomShapeMenu { expansionSlider.onmouseup = () => { isExpansionDragging = false; if (this.canvas.autoApplyShapeMask) { + const finalValue = parseInt(expansionSlider.value); + + // If value changed during drag, remove old mask with previous expansion value + if (expansionValueBeforeDrag !== finalValue) { + // Temporarily set the previous value to remove the old mask properly + const tempValue = this.canvas.shapeMaskExpansionValue; + this.canvas.shapeMaskExpansionValue = expansionValueBeforeDrag; + this.canvas.maskTool.removeShapeMask(); + this.canvas.shapeMaskExpansionValue = tempValue; // Restore current value + log.info(`Removed old shape mask with expansion: ${expansionValueBeforeDrag}px before applying new value: ${finalValue}px`); + } + this.canvas.maskTool.hideShapePreview(); this.canvas.maskTool.applyShapeMask(true); this.canvas.render(); diff --git a/src/MaskTool.ts b/src/MaskTool.ts index 021e416..d1b4ef0 100644 --- a/src/MaskTool.ts +++ b/src/MaskTool.ts @@ -61,6 +61,11 @@ export class MaskTool { private activeMaskUpdateTimeout: number | null; private readonly ACTIVE_MASK_UPDATE_DELAY = 16; // ~60fps throttling + // Performance optimization for shape preview + private shapePreviewThrottleTimeout: number | null; + private pendingPreviewParams: { expansionValue: number, featherValue: number } | null; + private readonly SHAPE_PREVIEW_THROTTLE_DELAY = 16; // ~60fps throttling for preview + constructor(canvasInstance: Canvas & { canvasState: CanvasState, width: number, height: number }, callbacks: MaskToolCallbacks = {}) { this.canvasInstance = canvasInstance; this.mainCanvas = canvasInstance.canvas; @@ -118,6 +123,10 @@ export class MaskTool { this.activeMaskNeedsUpdate = false; this.activeMaskUpdateTimeout = null; + // Initialize shape preview throttling + this.shapePreviewThrottleTimeout = null; + this.pendingPreviewParams = null; + this.initMaskCanvas(); } @@ -691,6 +700,7 @@ export class MaskTool { /** * Prepares shape mask configuration data - eliminates duplication between applyShapeMask and removeShapeMask * Returns all necessary data for shape mask operations including world coordinates and temporary canvas setup + * Now uses precise expansion calculation based on actual user values */ private prepareShapeMaskConfiguration(): { shape: any, @@ -720,12 +730,15 @@ export class MaskTool { y: bounds.y + extensionOffset.y + p.y })); - // Create a temporary canvas large enough to contain the shape and any expansion - const maxExpansion = Math.max(300, Math.abs(this.canvasInstance.shapeMaskExpansionValue || 0)); - const tempCanvasWidth = bounds.width + (maxExpansion * 2); - const tempCanvasHeight = bounds.height + (maxExpansion * 2); - const tempOffsetX = maxExpansion; - const tempOffsetY = maxExpansion; + // Use precise expansion calculation - only actual user value + small safety margin + const userExpansionValue = Math.abs(this.canvasInstance.shapeMaskExpansionValue || 0); + const safetyMargin = 10; // Small safety margin for precise operations + const preciseExpansion = userExpansionValue + safetyMargin; + + const tempCanvasWidth = bounds.width + (preciseExpansion * 2); + const tempCanvasHeight = bounds.height + (preciseExpansion * 2); + const tempOffsetX = preciseExpansion; + const tempOffsetY = preciseExpansion; // Adjust shape points for the temporary canvas const tempShapePoints = worldShapePoints.map(p => ({ @@ -738,7 +751,7 @@ export class MaskTool { bounds, extensionOffset, worldShapePoints, - maxExpansion, + maxExpansion: preciseExpansion, tempCanvasWidth, tempCanvasHeight, tempOffsetX, @@ -1184,6 +1197,28 @@ export class MaskTool { * Show blue outline preview of expansion/contraction during slider adjustment */ showShapePreview(expansionValue: number, featherValue: number = 0): void { + // Store the parameters for throttled execution + this.pendingPreviewParams = { expansionValue, featherValue }; + + // If there's already a pending preview update, don't schedule another one + if (this.shapePreviewThrottleTimeout !== null) { + return; + } + + // Schedule the preview update with throttling + this.shapePreviewThrottleTimeout = window.setTimeout(() => { + if (this.pendingPreviewParams) { + this.executeShapePreview(this.pendingPreviewParams.expansionValue, this.pendingPreviewParams.featherValue); + this.pendingPreviewParams = null; + } + this.shapePreviewThrottleTimeout = null; + }, this.SHAPE_PREVIEW_THROTTLE_DELAY); + } + + /** + * Executes the actual shape preview rendering - separated from showShapePreview for throttling + */ + private executeShapePreview(expansionValue: number, featherValue: number = 0): void { if (!this.canvasInstance.outputAreaShape?.points || this.canvasInstance.outputAreaShape.points.length < 3) { return; } @@ -1225,7 +1260,7 @@ export class MaskTool { this.drawContoursForPreview(this.shapePreviewCtx, allFeatherContours, '#4A9EFF', 1, [3, 5], 0.6); } - log.debug(`Shape preview shown with expansion: ${expansionValue}px, feather: ${featherValue}px at bounds (${bounds.x}, ${bounds.y})`); + log.debug(`Shape preview executed with expansion: ${expansionValue}px, feather: ${featherValue}px at bounds (${bounds.x}, ${bounds.y})`); } /** @@ -1516,6 +1551,58 @@ export class MaskTool { return this.activeMaskCanvas; } + /** + * Gets mask only for the output area - optimized for performance + * Returns only the portion of the mask that overlaps with the output area + * This is much more efficient than returning the entire mask when there are many chunks + */ + getMaskForOutputArea(): HTMLCanvasElement { + const bounds = this.canvasInstance.outputAreaBounds; + + // Create canvas sized to output area + const outputMaskCanvas = document.createElement('canvas'); + outputMaskCanvas.width = bounds.width; + outputMaskCanvas.height = bounds.height; + const outputMaskCtx = outputMaskCanvas.getContext('2d', { willReadFrequently: true }); + + if (!outputMaskCtx) { + throw new Error("Failed to get 2D context for output area mask canvas"); + } + + // Calculate which chunks overlap with the output area + const outputLeft = bounds.x; + const outputTop = bounds.y; + const outputRight = bounds.x + bounds.width; + const outputBottom = bounds.y + bounds.height; + + const chunkBounds = this.calculateChunkBounds(outputLeft, outputTop, outputRight, outputBottom); + + // Only process chunks that overlap with output area + for (let chunkY = chunkBounds.minY; chunkY <= chunkBounds.maxY; chunkY++) { + for (let chunkX = chunkBounds.minX; chunkX <= chunkBounds.maxX; chunkX++) { + const chunkKey = `${chunkX},${chunkY}`; + const chunk = this.maskChunks.get(chunkKey); + + if (chunk && !chunk.isEmpty) { + // Calculate intersection between chunk and output area + const intersection = this.calculateChunkIntersection(chunk, outputLeft, outputTop, outputRight, outputBottom); + + if (intersection) { + // Draw only the intersecting portion + outputMaskCtx.drawImage( + chunk.canvas, + intersection.destX, intersection.destY, intersection.destWidth, intersection.destHeight, // Source from chunk + intersection.srcX, intersection.srcY, intersection.srcWidth, intersection.srcHeight // Destination on output canvas + ); + } + } + } + } + + log.debug(`Generated output area mask (${bounds.width}x${bounds.height}) from ${chunkBounds.maxX - chunkBounds.minX + 1}x${chunkBounds.maxY - chunkBounds.minY + 1} chunks`); + return outputMaskCanvas; + } + resize(width: number, height: number): void { this.initPreviewCanvas(); const oldMask = this.maskCanvas; @@ -1861,26 +1948,34 @@ export class MaskTool { // Create a removal mask canvas - always hard-edged to ensure complete removal let removalMaskCanvas: HTMLCanvasElement; + // Add safety margin to ensure complete removal of antialiasing artifacts + const safetyMargin = 2; // 2px margin to remove any antialiasing remnants + if (needsExpansion) { - // If expansion was active, remove the expanded area with a hard edge + // If expansion was active, remove exactly the user's expansion value + safety margin + const userExpansionValue = this.canvasInstance.shapeMaskExpansionValue; + const expandedValue = Math.abs(userExpansionValue) + safetyMargin; removalMaskCanvas = this._createExpandedMaskCanvas( config.tempShapePoints, - this.canvasInstance.shapeMaskExpansionValue, + expandedValue, config.tempCanvasWidth, config.tempCanvasHeight ); } else { - // If no expansion, just remove the base shape with a hard edge - const { canvas, ctx } = this.createCanvas(config.tempCanvasWidth, config.tempCanvasHeight); - removalMaskCanvas = canvas; - this.drawShapeOnCanvas(ctx, config.tempShapePoints, 'evenodd'); + // If no expansion, remove the base shape with safety margin only + removalMaskCanvas = this._createExpandedMaskCanvas( + config.tempShapePoints, + safetyMargin, + config.tempCanvasWidth, + config.tempCanvasHeight + ); } // Now remove the shape mask from the chunked system this.removeMaskCanvasFromChunks(removalMaskCanvas, config.bounds.x - config.tempOffsetX, config.bounds.y - config.tempOffsetY); // Update the active mask canvas to show the changes - this.updateActiveMaskCanvas(); + this.updateActiveMaskCanvas(true); // Force full update to ensure all chunks are properly updated if (this.onStateChange) { this.onStateChange(); diff --git a/src/SAMDetectorIntegration.ts b/src/SAMDetectorIntegration.ts index 46a7631..6f9d549 100644 --- a/src/SAMDetectorIntegration.ts +++ b/src/SAMDetectorIntegration.ts @@ -333,18 +333,31 @@ async function handleSAMDetectorResult(node: ComfyNode, resultImage: HTMLImageEl return; } - // Create temporary canvas for mask processing (same as CanvasMask) + // Create temporary canvas for mask processing with correct positioning log.debug("Creating temporary canvas for mask processing"); + + // Get the output area bounds to position the mask correctly + const bounds = canvas.outputAreaBounds; + log.debug("Output area bounds for SAM mask positioning:", bounds); + + // Create canvas sized to match the result image dimensions const tempCanvas = document.createElement('canvas'); - tempCanvas.width = canvas.width; - tempCanvas.height = canvas.height; + tempCanvas.width = resultImage.width; + tempCanvas.height = resultImage.height; const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true }); if (tempCtx) { - tempCtx.drawImage(resultImage, 0, 0, canvas.width, canvas.height); + // Draw the result image at its natural size (no scaling) + tempCtx.drawImage(resultImage, 0, 0); - log.debug("Processing image data to create mask"); - const imageData = tempCtx.getImageData(0, 0, canvas.width, canvas.height); + log.debug("Processing image data to create mask", { + imageWidth: resultImage.width, + imageHeight: resultImage.height, + boundsX: bounds.x, + boundsY: bounds.y + }); + + const imageData = tempCtx.getImageData(0, 0, tempCanvas.width, tempCanvas.height); const data = imageData.data; // Convert to mask format (same as CanvasMask)