Add shape mask preview system for expansion and feather sliders

Implements a real-time blue outline preview for shape mask expansion and feather adjustments in CustomShapeMenu, providing immediate visual feedback while dragging sliders. Adds a dedicated shape preview canvas and efficient morphological operations in MaskTool for sharp, fast previews. Also synchronizes the preview with viewport changes and refines mask expansion/contraction logic for improved accuracy and performance.
This commit is contained in:
Dariusz L
2025-07-26 00:15:44 +02:00
parent 24ef702f16
commit 4c4856f9e7
2 changed files with 513 additions and 218 deletions

View File

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

View File

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