From ccfa2b6cfbf72fcbc92c3be417b416e3ee976e3f Mon Sep 17 00:00:00 2001 From: Dariusz L Date: Sat, 26 Jul 2025 00:16:11 +0200 Subject: [PATCH] Add shape mask preview system for expansion and feather sliders Implements a real-time blue outline preview when adjusting the expansion and feather sliders in the custom shape mask UI. The preview updates dynamically while dragging, and applies the mask only on mouse release. Adds a viewport change listener to keep the preview in sync during zooming or panning. Refactors mask expansion/contraction to use fast morphological operations for sharp edges and updates mask drawing to handle holes correctly. --- js/CustomShapeMenu.js | 109 +++++++-- js/MaskTool.js | 537 ++++++++++++++++++++++++++++-------------- 2 files changed, 454 insertions(+), 192 deletions(-) diff --git a/js/CustomShapeMenu.js b/js/CustomShapeMenu.js index 32f226f..99e606a 100644 --- a/js/CustomShapeMenu.js +++ b/js/CustomShapeMenu.js @@ -141,8 +141,12 @@ export class CustomShapeMenu { this.canvas.shapeMaskExpansionValue = value; expansionValueDisplay.textContent = value > 0 ? `+${value}px` : `${value}px`; }; - // Add debouncing for expansion slider + // Add preview system for expansion slider let expansionTimeout = null; + let isExpansionDragging = false; + expansionSlider.onmousedown = () => { + isExpansionDragging = true; + }; expansionSlider.oninput = () => { updateExpansionSliderDisplay(); if (this.canvas.autoApplyShapeMask) { @@ -150,13 +154,31 @@ export class CustomShapeMenu { if (expansionTimeout) { clearTimeout(expansionTimeout); } - // Apply mask immediately for visual feedback (without saving state) - this.canvas.maskTool.applyShapeMask(false); // false = don't save state + if (isExpansionDragging) { + // Show blue preview line while dragging - NO mask application + const featherValue = this.canvas.shapeMaskFeather ? this.canvas.shapeMaskFeatherValue : 0; + this.canvas.maskTool.showShapePreview(this.canvas.shapeMaskExpansionValue, featherValue); + } + else { + // Apply mask immediately for programmatic changes (not user dragging) + this.canvas.maskTool.hideShapePreview(); + this.canvas.maskTool.applyShapeMask(false); + this.canvas.render(); + } + // Clear any pending timeout - we only apply mask on mouseup now + if (expansionTimeout) { + clearTimeout(expansionTimeout); + expansionTimeout = null; + } + } + }; + expansionSlider.onmouseup = () => { + isExpansionDragging = false; + if (this.canvas.autoApplyShapeMask) { + // Apply final mask immediately when user releases slider + this.canvas.maskTool.hideShapePreview(); + this.canvas.maskTool.applyShapeMask(true); this.canvas.render(); - // Save state after 500ms of no changes - expansionTimeout = window.setTimeout(() => { - this.canvas.canvasState.saveMaskState(); - }, 500); } }; updateExpansionSliderDisplay(); @@ -214,22 +236,35 @@ export class CustomShapeMenu { this.canvas.shapeMaskFeatherValue = value; featherValueDisplay.textContent = `${value}px`; }; - // Add debouncing for feather slider + // Add preview system for feather slider (mirrors expansion slider) let featherTimeout = null; + let isFeatherDragging = false; + featherSlider.onmousedown = () => { + isFeatherDragging = true; + }; featherSlider.oninput = () => { updateFeatherSliderDisplay(); if (this.canvas.autoApplyShapeMask) { - // Clear previous timeout - if (featherTimeout) { - clearTimeout(featherTimeout); + if (isFeatherDragging) { + // Show blue preview line while dragging + const expansionValue = this.canvas.shapeMaskExpansion ? this.canvas.shapeMaskExpansionValue : 0; + this.canvas.maskTool.showShapePreview(expansionValue, this.canvas.shapeMaskFeatherValue); } - // Apply mask immediately for visual feedback (without saving state) - this.canvas.maskTool.applyShapeMask(false); // false = don't save state + else { + // Apply immediately for programmatic changes + this.canvas.maskTool.hideShapePreview(); + this.canvas.maskTool.applyShapeMask(false); + this.canvas.render(); + } + } + }; + featherSlider.onmouseup = () => { + isFeatherDragging = false; + if (this.canvas.autoApplyShapeMask) { + // Apply final mask when user releases slider + this.canvas.maskTool.hideShapePreview(); + this.canvas.maskTool.applyShapeMask(true); // true = save state this.canvas.render(); - // Save state after 500ms of no changes - featherTimeout = window.setTimeout(() => { - this.canvas.canvasState.saveMaskState(); - }, 500); } }; updateFeatherSliderDisplay(); @@ -246,6 +281,8 @@ export class CustomShapeMenu { } this.uiInitialized = true; this._updateUI(); + // Add viewport change listener to update shape preview when zooming/panning + this._addViewportChangeListener(); } _createCheckbox(textFn, clickHandler) { const container = document.createElement('div'); @@ -314,4 +351,42 @@ export class CustomShapeMenu { } }); } + /** + * Add viewport change listener to update shape preview when zooming/panning + */ + _addViewportChangeListener() { + // Store previous viewport state to detect changes + let previousViewport = { + x: this.canvas.viewport.x, + y: this.canvas.viewport.y, + zoom: this.canvas.viewport.zoom + }; + // Check for viewport changes in render loop + const checkViewportChange = () => { + if (this.canvas.maskTool.shapePreviewVisible) { + const current = this.canvas.viewport; + // Check if viewport has changed + if (current.x !== previousViewport.x || + current.y !== previousViewport.y || + current.zoom !== previousViewport.zoom) { + // Update shape preview with current expansion/feather values + const expansionValue = this.canvas.shapeMaskExpansionValue || 0; + const featherValue = this.canvas.shapeMaskFeather ? (this.canvas.shapeMaskFeatherValue || 0) : 0; + this.canvas.maskTool.showShapePreview(expansionValue, featherValue); + // Update previous viewport state + previousViewport = { + x: current.x, + y: current.y, + zoom: current.zoom + }; + } + } + // Continue checking if UI is still active + if (this.uiInitialized) { + requestAnimationFrame(checkViewportChange); + } + }; + // Start the viewport change detection + requestAnimationFrame(checkViewportChange); + } } diff --git a/js/MaskTool.js b/js/MaskTool.js index b0a82c7..2779467 100644 --- a/js/MaskTool.js +++ b/js/MaskTool.js @@ -28,6 +28,15 @@ export class MaskTool { this.previewCtx = previewCtx; this.previewVisible = false; this.previewCanvasInitialized = false; + // Initialize shape preview system + this.shapePreviewCanvas = document.createElement('canvas'); + const shapePreviewCtx = this.shapePreviewCanvas.getContext('2d', { willReadFrequently: true }); + if (!shapePreviewCtx) { + throw new Error("Failed to get 2D context for shape preview canvas"); + } + this.shapePreviewCtx = shapePreviewCtx; + this.shapePreviewVisible = false; + this.isPreviewMode = false; this.initMaskCanvas(); } initPreviewCanvas() { @@ -176,6 +185,336 @@ export class MaskTool { } clearPreview() { this.previewCtx.clearRect(0, 0, this.previewCanvas.width, this.previewCanvas.height); + this.clearShapePreview(); + } + /** + * Initialize shape preview canvas for showing blue outline during slider adjustments + * Canvas is pinned to viewport and covers the entire visible area + */ + initShapePreviewCanvas() { + if (this.shapePreviewCanvas.parentElement) { + this.shapePreviewCanvas.parentElement.removeChild(this.shapePreviewCanvas); + } + // Canvas covers entire viewport - pinned to screen, not world + this.shapePreviewCanvas.width = this.canvasInstance.canvas.width; + this.shapePreviewCanvas.height = this.canvasInstance.canvas.height; + // Pin canvas to viewport - no world coordinate positioning + this.shapePreviewCanvas.style.position = 'absolute'; + this.shapePreviewCanvas.style.left = '0px'; + this.shapePreviewCanvas.style.top = '0px'; + this.shapePreviewCanvas.style.width = '100%'; + this.shapePreviewCanvas.style.height = '100%'; + this.shapePreviewCanvas.style.pointerEvents = 'none'; + this.shapePreviewCanvas.style.zIndex = '15'; // Above regular preview + this.shapePreviewCanvas.style.imageRendering = 'pixelated'; // Sharp rendering + if (this.canvasInstance.canvas.parentElement) { + this.canvasInstance.canvas.parentElement.appendChild(this.shapePreviewCanvas); + } + } + /** + * Show blue outline preview of expansion/contraction during slider adjustment + */ + showShapePreview(expansionValue, featherValue = 0) { + if (!this.canvasInstance.outputAreaShape?.points || this.canvasInstance.outputAreaShape.points.length < 3) { + return; + } + if (!this.shapePreviewCanvas.parentElement) + this.initShapePreviewCanvas(); + this.isPreviewMode = true; + this.shapePreviewVisible = true; + this.shapePreviewCanvas.style.display = 'block'; + this.clearShapePreview(); + const shape = this.canvasInstance.outputAreaShape; + const viewport = this.canvasInstance.viewport; + const screenPoints = shape.points.map(p => ({ + x: (p.x - viewport.x) * viewport.zoom, + y: (p.y - viewport.y) * viewport.zoom + })); + // This function now returns Point[][] to handle islands. + const allContours = this._calculatePreviewPointsScreen([screenPoints], expansionValue, viewport.zoom); + // Draw main expansion/contraction preview + this.shapePreviewCtx.strokeStyle = '#4A9EFF'; + this.shapePreviewCtx.lineWidth = 2; + this.shapePreviewCtx.setLineDash([4, 4]); + this.shapePreviewCtx.globalAlpha = 0.8; + for (const contour of allContours) { + if (contour.length < 2) + continue; + this.shapePreviewCtx.beginPath(); + this.shapePreviewCtx.moveTo(contour[0].x, contour[0].y); + for (let i = 1; i < contour.length; i++) { + this.shapePreviewCtx.lineTo(contour[i].x, contour[i].y); + } + this.shapePreviewCtx.closePath(); + this.shapePreviewCtx.stroke(); + } + // Draw feather preview + if (featherValue > 0) { + const allFeatherContours = this._calculatePreviewPointsScreen(allContours, -featherValue, viewport.zoom); + this.shapePreviewCtx.strokeStyle = '#4A9EFF'; + this.shapePreviewCtx.lineWidth = 1; + this.shapePreviewCtx.setLineDash([3, 5]); + this.shapePreviewCtx.globalAlpha = 0.6; + for (const contour of allFeatherContours) { + if (contour.length < 2) + continue; + this.shapePreviewCtx.beginPath(); + this.shapePreviewCtx.moveTo(contour[0].x, contour[0].y); + for (let i = 1; i < contour.length; i++) { + this.shapePreviewCtx.lineTo(contour[i].x, contour[i].y); + } + this.shapePreviewCtx.closePath(); + this.shapePreviewCtx.stroke(); + } + } + log.debug(`Shape preview shown with expansion: ${expansionValue}px, feather: ${featherValue}px`); + } + /** + * Hide shape preview and switch back to normal mode + */ + hideShapePreview() { + this.isPreviewMode = false; + this.shapePreviewVisible = false; + this.clearShapePreview(); + this.shapePreviewCanvas.style.display = 'none'; + log.debug("Shape preview hidden"); + } + /** + * Clear shape preview canvas + */ + clearShapePreview() { + if (this.shapePreviewCtx) { + this.shapePreviewCtx.clearRect(0, 0, this.shapePreviewCanvas.width, this.shapePreviewCanvas.height); + } + } + /** + * Update shape preview canvas position and scale when viewport changes + * This ensures the preview stays synchronized with the world coordinates + */ + updateShapePreviewPosition() { + if (!this.shapePreviewCanvas.parentElement || !this.shapePreviewVisible) { + return; + } + const viewport = this.canvasInstance.viewport; + const bufferSize = 300; + // Calculate world position (output area + buffer) + const previewX = -bufferSize; // World coordinates + const previewY = -bufferSize; + // Convert to screen coordinates + const screenX = (previewX - viewport.x) * viewport.zoom; + const screenY = (previewY - viewport.y) * viewport.zoom; + // Update position and scale + this.shapePreviewCanvas.style.left = `${screenX}px`; + this.shapePreviewCanvas.style.top = `${screenY}px`; + const previewWidth = this.canvasInstance.width + (bufferSize * 2); + const previewHeight = this.canvasInstance.height + (bufferSize * 2); + this.shapePreviewCanvas.style.width = `${previewWidth * viewport.zoom}px`; + this.shapePreviewCanvas.style.height = `${previewHeight * viewport.zoom}px`; + } + /** + * Ultra-fast dilation using Distance Transform + thresholding (Manhattan distance for speed) + */ + _fastDilateDT(mask, width, height, radius) { + const INF = 1e9; + const dist = new Float32Array(width * height); + // 1. Initialize: 0 for foreground, INF for background + for (let i = 0; i < width * height; ++i) { + dist[i] = mask[i] ? 0 : INF; + } + // 2. Forward pass: top-left -> bottom-right + for (let y = 0; y < height; ++y) { + for (let x = 0; x < width; ++x) { + const i = y * width + x; + if (mask[i]) + continue; + if (x > 0) + dist[i] = Math.min(dist[i], dist[y * width + (x - 1)] + 1); + if (y > 0) + dist[i] = Math.min(dist[i], dist[(y - 1) * width + x] + 1); + } + } + // 3. Backward pass: bottom-right -> top-left + for (let y = height - 1; y >= 0; --y) { + for (let x = width - 1; x >= 0; --x) { + const i = y * width + x; + if (mask[i]) + continue; + if (x < width - 1) + dist[i] = Math.min(dist[i], dist[y * width + (x + 1)] + 1); + if (y < height - 1) + dist[i] = Math.min(dist[i], dist[(y + 1) * width + x] + 1); + } + } + // 4. Thresholding: if distance <= radius, it's part of the expanded mask + const expanded = new Uint8Array(width * height); + for (let i = 0; i < width * height; ++i) { + expanded[i] = dist[i] <= radius ? 1 : 0; + } + return expanded; + } + /** + * Ultra-fast erosion using Distance Transform + thresholding + */ + _fastErodeDT(mask, width, height, radius) { + const INF = 1e9; + const dist = new Float32Array(width * height); + // 1. Initialize: 0 for background, INF for foreground (inverse of dilation) + for (let i = 0; i < width * height; ++i) { + dist[i] = mask[i] ? INF : 0; + } + // 2. Forward pass: top-left -> bottom-right + for (let y = 0; y < height; ++y) { + for (let x = 0; x < width; ++x) { + const i = y * width + x; + if (!mask[i]) + continue; + if (x > 0) + dist[i] = Math.min(dist[i], dist[y * width + (x - 1)] + 1); + if (y > 0) + dist[i] = Math.min(dist[i], dist[(y - 1) * width + x] + 1); + } + } + // 3. Backward pass: bottom-right -> top-left + for (let y = height - 1; y >= 0; --y) { + for (let x = width - 1; x >= 0; --x) { + const i = y * width + x; + if (!mask[i]) + continue; + if (x < width - 1) + dist[i] = Math.min(dist[i], dist[y * width + (x + 1)] + 1); + if (y < height - 1) + dist[i] = Math.min(dist[i], dist[(y + 1) * width + x] + 1); + } + } + // 4. Thresholding: if distance > radius, it's part of the eroded mask + const eroded = new Uint8Array(width * height); + for (let i = 0; i < width * height; ++i) { + eroded[i] = dist[i] > radius ? 1 : 0; + } + return eroded; + } + /** + * Calculate preview points using screen coordinates for pinned canvas. + * This version now accepts multiple contours and returns multiple contours. + */ + _calculatePreviewPointsScreen(contours, expansionValue, zoom) { + if (contours.length === 0 || expansionValue === 0) + return contours; + const width = this.canvasInstance.canvas.width; + const height = this.canvasInstance.canvas.height; + const tempCanvas = document.createElement('canvas'); + tempCanvas.width = width; + tempCanvas.height = height; + const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true }); + // Draw all contours to create the initial mask + tempCtx.fillStyle = 'white'; + for (const points of contours) { + if (points.length < 3) + continue; + tempCtx.beginPath(); + tempCtx.moveTo(points[0].x, points[0].y); + for (let i = 1; i < points.length; i++) { + tempCtx.lineTo(points[i].x, points[i].y); + } + tempCtx.closePath(); + tempCtx.fill('evenodd'); // Use evenodd to handle holes correctly + } + const maskImage = tempCtx.getImageData(0, 0, width, height); + const binaryData = new Uint8Array(width * height); + for (let i = 0; i < binaryData.length; i++) { + binaryData[i] = maskImage.data[i * 4] > 0 ? 1 : 0; + } + let resultMask; + const scaledExpansionValue = Math.round(Math.abs(expansionValue * zoom)); + if (expansionValue >= 0) { + resultMask = this._fastDilateDT(binaryData, width, height, scaledExpansionValue); + } + else { + resultMask = this._fastErodeDT(binaryData, width, height, scaledExpansionValue); + } + // Extract all contours (outer and inner) from the resulting mask + const allResultContours = this._traceAllContours(resultMask, width, height); + return allResultContours.length > 0 ? allResultContours : contours; + } + /** + * Calculate preview points in world coordinates using morphological operations + * This version works directly with mask canvas coordinates + */ + /** + * Traces all contours (outer and inner islands) from a binary mask. + * @returns An array of contours, where each contour is an array of points. + */ + _traceAllContours(mask, width, height) { + const contours = []; + const visited = new Uint8Array(mask.length); // Keep track of visited pixels + for (let y = 1; y < height - 1; y++) { + for (let x = 1; x < width - 1; x++) { + const idx = y * width + x; + // Check for a potential starting point: a foreground pixel that hasn't been visited + // and is on a boundary (next to a background pixel). + if (mask[idx] === 1 && visited[idx] === 0) { + // Check if it's a boundary pixel + const isBoundary = mask[idx - 1] === 0 || + mask[idx + 1] === 0 || + mask[idx - width] === 0 || + mask[idx + width] === 0; + if (isBoundary) { + // Found a new contour, let's trace it. + const contour = this._traceSingleContour({ x, y }, mask, width, height, visited); + if (contour.length > 2) { + // --- Path Simplification --- + const simplifiedContour = []; + const simplificationFactor = Math.max(1, Math.floor(contour.length / 200)); + for (let i = 0; i < contour.length; i += simplificationFactor) { + simplifiedContour.push(contour[i]); + } + contours.push(simplifiedContour); + } + } + } + } + } + return contours; + } + /** + * Traces a single contour from a starting point using Moore-Neighbor algorithm. + */ + _traceSingleContour(startPoint, mask, width, height, visited) { + const contour = []; + let { x, y } = startPoint; + // Neighbor checking order (clockwise) + const neighbors = [ + { dx: 0, dy: -1 }, // N + { dx: 1, dy: -1 }, // NE + { dx: 1, dy: 0 }, // E + { dx: 1, dy: 1 }, // SE + { dx: 0, dy: 1 }, // S + { dx: -1, dy: 1 }, // SW + { dx: -1, dy: 0 }, // W + { dx: -1, dy: -1 } // NW + ]; + let initialNeighborIndex = 0; + do { + let foundNext = false; + for (let i = 0; i < 8; i++) { + const neighborIndex = (initialNeighborIndex + i) % 8; + const nx = x + neighbors[neighborIndex].dx; + const ny = y + neighbors[neighborIndex].dy; + const nIdx = ny * width + nx; + if (nx >= 0 && nx < width && ny >= 0 && ny < height && mask[nIdx] === 1) { + contour.push({ x, y }); + visited[y * width + x] = 1; // Mark current point as visited + x = nx; + y = ny; + initialNeighborIndex = (neighborIndex + 5) % 8; + foundNext = true; + break; + } + } + if (!foundNext) + break; // End if no next point found + } while (x !== startPoint.x || y !== startPoint.y); + return contour; } clear() { this.maskCtx.clearRect(0, 0, this.maskCanvas.width, this.maskCanvas.height); @@ -297,7 +636,7 @@ export class MaskTool { this.maskCtx.lineTo(maskPoints[i].x, maskPoints[i].y); } this.maskCtx.closePath(); - this.maskCtx.fill(); + this.maskCtx.fill('evenodd'); // Use evenodd to handle holes correctly } else if (needsExpansion && !needsFeather) { // Expansion only: use the new distance transform expansion @@ -504,8 +843,8 @@ export class MaskTool { return points; } /** - * Creates an expanded/contracted mask canvas using distance transform - * Supports both positive values (expansion) and negative values (contraction) + * Creates an expanded/contracted mask canvas using simple morphological operations + * This gives SHARP edges without smoothing, unlike distance transform */ _createExpandedMaskCanvas(points, expansionValue, width, height) { // 1. Create a binary mask on a temporary canvas. @@ -520,191 +859,39 @@ export class MaskTool { binaryCtx.lineTo(points[i].x, points[i].y); } binaryCtx.closePath(); - binaryCtx.fill(); + binaryCtx.fill('evenodd'); // Use evenodd to handle holes correctly const maskImage = binaryCtx.getImageData(0, 0, width, height); const binaryData = new Uint8Array(width * height); for (let i = 0; i < binaryData.length; i++) { - binaryData[i] = maskImage.data[i * 4] > 0 ? 0 : 1; // 0 = inside, 1 = outside + binaryData[i] = maskImage.data[i * 4] > 0 ? 1 : 0; // 1 = inside, 0 = outside } - // 2. Calculate the distance transform using the original Felzenszwalb algorithm - const distanceMap = this._distanceTransform(binaryData, width, height); - // 3. Create the final output canvas with the expanded/contracted mask + // 2. Apply fast morphological operations for sharp edges + let resultMask; + const absExpansionValue = Math.abs(expansionValue); + if (expansionValue >= 0) { + // EXPANSION: Use new fast dilation algorithm + resultMask = this._fastDilateDT(binaryData, width, height, absExpansionValue); + } + else { + // CONTRACTION: Use new fast erosion algorithm + resultMask = this._fastErodeDT(binaryData, width, height, absExpansionValue); + } + // 3. Create the final output canvas with sharp edges const outputCanvas = document.createElement('canvas'); outputCanvas.width = width; outputCanvas.height = height; const outputCtx = outputCanvas.getContext('2d', { willReadFrequently: true }); const outputData = outputCtx.createImageData(width, height); - const absExpansionValue = Math.abs(expansionValue); - const isExpansion = expansionValue >= 0; - for (let i = 0; i < distanceMap.length; i++) { - const dist = distanceMap[i]; - let alpha = 0; - if (isExpansion) { - // Positive values: EXPANSION (rozszerzanie) - if (dist === 0) { // Inside the original shape - alpha = 1.0; - } - else if (dist < absExpansionValue) { // In the expansion region - alpha = 1.0; // Solid expansion - } - } - else { - // Negative values: CONTRACTION (zmniejszanie) - // Use distance transform but with inverted logic for contraction - if (dist === 0) { // Inside the original shape - // For contraction, only keep pixels that are far enough from the edge - // We need to check if this pixel is more than absExpansionValue away from any edge - // Simple approach: use the distance transform but only keep pixels - // that are "deep inside" the shape (far from edges) - // This is much faster than morphological erosion - // Since dist=0 means we're inside, we need to calculate inward distance - // For now, use a simplified approach: assume pixels are kept if they're not too close to edge - // This is a placeholder - we'll use the distance transform result differently - alpha = 1.0; // We'll refine this below - } - // Actually, let's use a much simpler approach for contraction: - // Just shrink the shape by moving all edge pixels inward by absExpansionValue - // This is done by only keeping pixels that have distance > absExpansionValue from outside - // Reset alpha and use proper contraction logic - alpha = 0; - if (dist === 0) { // We're inside the shape - // Check if we're far enough from the edge by looking at surrounding area - const x = i % width; - const y = Math.floor(i / width); - // Check if we're near an edge by looking in the full contraction radius - let nearEdge = false; - const checkRadius = absExpansionValue + 1; // Full radius for accurate contraction - for (let dy = -checkRadius; dy <= checkRadius && !nearEdge; dy++) { - for (let dx = -checkRadius; dx <= checkRadius && !nearEdge; dx++) { - const nx = x + dx; - const ny = y + dy; - if (nx >= 0 && nx < width && ny >= 0 && ny < height) { - const ni = ny * width + nx; - if (binaryData[ni] === 1) { // Found an outside pixel - const distToEdge = Math.sqrt(dx * dx + dy * dy); - if (distToEdge <= absExpansionValue) { - nearEdge = true; - } - } - } - } - } - if (!nearEdge) { - alpha = 1.0; // Keep this pixel - it's far enough from edges - } - } - } - const a = Math.max(0, Math.min(255, Math.round(alpha * 255))); - outputData.data[i * 4 + 3] = a; // Set alpha - // Set color to white - outputData.data[i * 4] = 255; - outputData.data[i * 4 + 1] = 255; - outputData.data[i * 4 + 2] = 255; + for (let i = 0; i < resultMask.length; i++) { + const alpha = resultMask[i] === 1 ? 255 : 0; // Sharp binary mask - no smoothing + outputData.data[i * 4] = 255; // R + outputData.data[i * 4 + 1] = 255; // G + outputData.data[i * 4 + 2] = 255; // B + outputData.data[i * 4 + 3] = alpha; // A - sharp edges } outputCtx.putImageData(outputData, 0, 0); return outputCanvas; } - /** - * Original Felzenszwalb distance transform - more accurate than the fast version for expansion - */ - _distanceTransform(data, width, height) { - const INF = 1e20; - const d = new Float32Array(width * height); - // 1. Transform along columns - for (let x = 0; x < width; x++) { - const f = new Float32Array(height); - for (let y = 0; y < height; y++) { - f[y] = data[y * width + x] === 0 ? 0 : INF; - } - const dt = this._edt1D(f); - for (let y = 0; y < height; y++) { - d[y * width + x] = dt[y]; - } - } - // 2. Transform along rows - for (let y = 0; y < height; y++) { - const f = new Float32Array(width); - for (let x = 0; x < width; x++) { - f[x] = d[y * width + x]; - } - const dt = this._edt1D(f); - for (let x = 0; x < width; x++) { - d[y * width + x] = Math.sqrt(dt[x]); // Final Euclidean distance - } - } - return d; - } - _edt1D(f) { - const n = f.length; - const d = new Float32Array(n); - const v = new Int32Array(n); - const z = new Float32Array(n + 1); - let k = 0; - v[0] = 0; - z[0] = -Infinity; - z[1] = Infinity; - for (let q = 1; q < n; q++) { - let s; - do { - const p = v[k]; - s = ((f[q] + q * q) - (f[p] + p * p)) / (2 * q - 2 * p); - } while (s <= z[k] && --k >= 0); - k++; - v[k] = q; - z[k] = s; - z[k + 1] = Infinity; - } - k = 0; - for (let q = 0; q < n; q++) { - while (z[k + 1] < q) - k++; - const dx = q - v[k]; - d[q] = dx * dx + f[v[k]]; - } - return d; - } - /** - * Morphological erosion - similar to the Python WAS Suite implementation - * This is much more efficient and accurate for contraction than distance transform - */ - _morphologicalErosion(binaryMask, width, height, iterations) { - let currentMask = new Uint8Array(binaryMask); - let tempMask = new Uint8Array(width * height); - // Apply erosion for the specified number of iterations (pixels) - for (let iter = 0; iter < iterations; iter++) { - // Clear temp mask - tempMask.fill(0); - // Apply erosion with a 3x3 kernel (cross pattern) - for (let y = 1; y < height - 1; y++) { - for (let x = 1; x < width - 1; x++) { - const idx = y * width + x; - if (currentMask[idx] === 0) { // Only process pixels that are inside (0 = inside) - // Check if all neighbors in the cross pattern are also inside - const top = currentMask[(y - 1) * width + x]; - const bottom = currentMask[(y + 1) * width + x]; - const left = currentMask[y * width + (x - 1)]; - const right = currentMask[y * width + (x + 1)]; - const center = currentMask[idx]; - // Keep pixel only if all cross neighbors are inside (0) - if (top === 0 && bottom === 0 && left === 0 && right === 0 && center === 0) { - tempMask[idx] = 0; // Keep as inside - } - else { - tempMask[idx] = 1; // Erode to outside - } - } - else { - tempMask[idx] = 1; // Already outside, stay outside - } - } - } - // Swap masks for next iteration - const swap = currentMask; - currentMask = tempMask; - tempMask = swap; - } - return currentMask; - } /** * Creates a feathered mask from existing ImageData (used when combining expansion + feather) */