diff --git a/src/CustomShapeMenu.ts b/src/CustomShapeMenu.ts index f589858..25ecd2d 100644 --- a/src/CustomShapeMenu.ts +++ b/src/CustomShapeMenu.ts @@ -177,8 +177,13 @@ export class CustomShapeMenu { expansionValueDisplay.textContent = value > 0 ? `+${value}px` : `${value}px`; }; - // Add debouncing for expansion slider + // Add preview system for expansion slider let expansionTimeout: number | null = null; + let isExpansionDragging = false; + + expansionSlider.onmousedown = () => { + isExpansionDragging = true; + }; expansionSlider.oninput = () => { updateExpansionSliderDisplay(); @@ -189,14 +194,32 @@ export class CustomShapeMenu { clearTimeout(expansionTimeout); } - // Apply mask immediately for visual feedback (without saving state) - this.canvas.maskTool.applyShapeMask(false); // false = don't save state - this.canvas.render(); + 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(); + } - // Save state after 500ms of no changes - expansionTimeout = window.setTimeout(() => { - this.canvas.canvasState.saveMaskState(); - }, 500); + // 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(); } }; @@ -267,26 +290,38 @@ export class CustomShapeMenu { featherValueDisplay.textContent = `${value}px`; }; - // Add debouncing for feather slider + // Add preview system for feather slider (mirrors expansion slider) let featherTimeout: number | null = 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); + } else { + // Apply immediately for programmatic changes + this.canvas.maskTool.hideShapePreview(); + this.canvas.maskTool.applyShapeMask(false); + this.canvas.render(); } - - // Apply mask immediately for visual feedback (without saving state) - this.canvas.maskTool.applyShapeMask(false); // false = don't save state + } + }; + + 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); } }; @@ -306,6 +341,9 @@ export class CustomShapeMenu { this.uiInitialized = true; this._updateUI(); + + // Add viewport change listener to update shape preview when zooming/panning + this._addViewportChangeListener(); } private _createCheckbox(textFn: () => string, clickHandler: () => void): HTMLDivElement { @@ -383,4 +421,49 @@ export class CustomShapeMenu { } }); } + + /** + * Add viewport change listener to update shape preview when zooming/panning + */ + private _addViewportChangeListener(): void { + // 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/src/MaskTool.ts b/src/MaskTool.ts index a21789d..9fdd9f0 100644 --- a/src/MaskTool.ts +++ b/src/MaskTool.ts @@ -27,6 +27,12 @@ export class MaskTool { private previewVisible: boolean; public x: number; public y: number; + + // Shape mask preview system + private shapePreviewCanvas: HTMLCanvasElement; + private shapePreviewCtx: CanvasRenderingContext2D; + public shapePreviewVisible: boolean; + private isPreviewMode: boolean; constructor(canvasInstance: Canvas & { canvasState: CanvasState, width: number, height: number }, callbacks: MaskToolCallbacks = {}) { this.canvasInstance = canvasInstance; @@ -59,6 +65,16 @@ export class MaskTool { 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(); } @@ -235,6 +251,375 @@ export class MaskTool { clearPreview(): void { 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(): void { + 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: number, featherValue: number = 0): void { + 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(): void { + this.isPreviewMode = false; + this.shapePreviewVisible = false; + this.clearShapePreview(); + this.shapePreviewCanvas.style.display = 'none'; + log.debug("Shape preview hidden"); + } + + /** + * Clear shape preview canvas + */ + clearShapePreview(): void { + 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(): void { + 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) + */ + private _fastDilateDT(mask: Uint8Array, width: number, height: number, radius: number): Uint8Array { + 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 + */ + private _fastErodeDT(mask: Uint8Array, width: number, height: number, radius: number): Uint8Array { + 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. + */ + private _calculatePreviewPointsScreen(contours: Point[][], expansionValue: number, zoom: number): Point[][] { + 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: Uint8Array; + 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. + */ + private _traceAllContours(mask: Uint8Array, width: number, height: number): Point[][] { + const contours: Point[][] = []; + 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: Point[] = []; + 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. + */ + private _traceSingleContour(startPoint: Point, mask: Uint8Array, width: number, height: number, visited: Uint8Array): Point[] { + const contour: Point[] = []; + 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(): void { @@ -384,7 +769,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 const expandedMaskCanvas = this._createExpandedMaskCanvas(maskPoints, this.canvasInstance.shapeMaskExpansionValue, this.maskCanvas.width, this.maskCanvas.height); @@ -622,8 +1007,8 @@ export class MaskTool { } /** - * 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 */ private _createExpandedMaskCanvas(points: Point[], expansionValue: number, width: number, height: number): HTMLCanvasElement { // 1. Create a binary mask on a temporary canvas. @@ -639,218 +1024,45 @@ 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: Uint8Array; + 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 - */ - private _distanceTransform(data: Uint8Array, width: number, height: number): Float32Array { - 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; - } - - private _edt1D(f: Float32Array): Float32Array { - 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: number; - 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 - */ - private _morphologicalErosion(binaryMask: Uint8Array, width: number, height: number, iterations: number): Uint8Array { - 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) */