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.
This commit is contained in:
Dariusz L
2025-07-26 00:16:11 +02:00
parent 4c4856f9e7
commit ccfa2b6cfb
2 changed files with 454 additions and 192 deletions

View File

@@ -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);
}
}

View File

@@ -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)
*/