mirror of
https://github.com/Azornes/Comfyui-LayerForge.git
synced 2026-03-21 20:52:12 -03:00
Add custom shape mask menu with expansion and feathering
Introduces a CustomShapeMenu UI component for managing custom output area shape masks, including options for auto-applying the mask, expansion/contraction, and feathering. Updates Canvas and MaskTool to support these new mask operations, and ensures the menu is shown or hidden based on shape presence. Adds distance transform-based algorithms for accurate mask expansion and feathering.
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
import { api } from "../../scripts/api.js";
|
||||
import { MaskTool } from "./MaskTool.js";
|
||||
import { ShapeTool } from "./ShapeTool.js";
|
||||
import { CustomShapeMenu } from "./CustomShapeMenu.js";
|
||||
import { CanvasState } from "./CanvasState.js";
|
||||
import { CanvasInteractions } from "./CanvasInteractions.js";
|
||||
import { CanvasLayers } from "./CanvasLayers.js";
|
||||
@@ -64,7 +65,13 @@ export class Canvas {
|
||||
this.requestSaveState = () => { };
|
||||
this.maskTool = new MaskTool(this, { onStateChange: this.onStateChange });
|
||||
this.shapeTool = new ShapeTool(this);
|
||||
this.customShapeMenu = new CustomShapeMenu(this);
|
||||
this.outputAreaShape = null;
|
||||
this.autoApplyShapeMask = false;
|
||||
this.shapeMaskExpansion = false;
|
||||
this.shapeMaskExpansionValue = 0;
|
||||
this.shapeMaskFeather = false;
|
||||
this.shapeMaskFeatherValue = 0;
|
||||
this.canvasMask = new CanvasMask(this);
|
||||
this.canvasState = new CanvasState(this);
|
||||
this.canvasSelection = new CanvasSelection(this);
|
||||
|
||||
@@ -86,6 +86,14 @@ export class CanvasRenderer {
|
||||
this.renderInteractionElements(ctx);
|
||||
this.canvas.shapeTool.render(ctx);
|
||||
this.renderLayerInfo(ctx);
|
||||
// Update custom shape menu position and visibility
|
||||
if (this.canvas.outputAreaShape) {
|
||||
this.canvas.customShapeMenu.show();
|
||||
this.canvas.customShapeMenu.updateScreenPosition();
|
||||
}
|
||||
else {
|
||||
this.canvas.customShapeMenu.hide();
|
||||
}
|
||||
ctx.restore();
|
||||
if (this.canvas.canvas.width !== this.canvas.offscreenCanvas.width ||
|
||||
this.canvas.canvas.height !== this.canvas.offscreenCanvas.height) {
|
||||
|
||||
317
js/CustomShapeMenu.js
Normal file
317
js/CustomShapeMenu.js
Normal file
@@ -0,0 +1,317 @@
|
||||
import { createModuleLogger } from "./utils/LoggerUtils.js";
|
||||
const log = createModuleLogger('CustomShapeMenu');
|
||||
export class CustomShapeMenu {
|
||||
constructor(canvas) {
|
||||
this.canvas = canvas;
|
||||
this.element = null;
|
||||
this.worldX = 0;
|
||||
this.worldY = 0;
|
||||
this.uiInitialized = false;
|
||||
}
|
||||
show() {
|
||||
if (!this.canvas.outputAreaShape) {
|
||||
return;
|
||||
}
|
||||
this._createUI();
|
||||
if (this.element) {
|
||||
this.element.style.display = 'block';
|
||||
}
|
||||
// Position in top-left corner of viewport (closer to edge)
|
||||
const viewLeft = this.canvas.viewport.x;
|
||||
const viewTop = this.canvas.viewport.y;
|
||||
this.worldX = viewLeft + (8 / this.canvas.viewport.zoom);
|
||||
this.worldY = viewTop + (8 / this.canvas.viewport.zoom);
|
||||
this.updateScreenPosition();
|
||||
}
|
||||
hide() {
|
||||
if (this.element) {
|
||||
this.element.remove();
|
||||
this.element = null;
|
||||
this.uiInitialized = false;
|
||||
}
|
||||
}
|
||||
updateScreenPosition() {
|
||||
if (!this.element)
|
||||
return;
|
||||
const screenX = (this.worldX - this.canvas.viewport.x) * this.canvas.viewport.zoom;
|
||||
const screenY = (this.worldY - this.canvas.viewport.y) * this.canvas.viewport.zoom;
|
||||
this.element.style.transform = `translate(${screenX}px, ${screenY}px)`;
|
||||
}
|
||||
_createUI() {
|
||||
if (this.uiInitialized)
|
||||
return;
|
||||
this.element = document.createElement('div');
|
||||
this.element.id = 'layerforge-custom-shape-menu';
|
||||
this.element.style.cssText = `
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
background-color: #333;
|
||||
color: white;
|
||||
padding: 8px 15px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 15px rgba(0,0,0,0.5);
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-family: sans-serif;
|
||||
font-size: 12px;
|
||||
z-index: 1001;
|
||||
border: 1px solid #555;
|
||||
user-select: none;
|
||||
min-width: 200px;
|
||||
`;
|
||||
// Create menu content
|
||||
const lines = [
|
||||
"🎯 Custom Output Area Active",
|
||||
"Press Shift+S to modify shape",
|
||||
"Shape defines generation area"
|
||||
];
|
||||
lines.forEach(line => {
|
||||
const lineElement = document.createElement('div');
|
||||
lineElement.textContent = line;
|
||||
lineElement.style.cssText = `
|
||||
margin: 2px 0;
|
||||
line-height: 18px;
|
||||
`;
|
||||
this.element.appendChild(lineElement);
|
||||
});
|
||||
// Add main auto-apply checkbox
|
||||
const checkboxContainer = this._createCheckbox(() => `${this.canvas.autoApplyShapeMask ? "☑" : "☐"} Auto-apply shape mask`, () => {
|
||||
this.canvas.autoApplyShapeMask = !this.canvas.autoApplyShapeMask;
|
||||
if (this.canvas.autoApplyShapeMask) {
|
||||
this.canvas.maskTool.applyShapeMask();
|
||||
log.info("Auto-apply shape mask enabled - mask applied automatically");
|
||||
}
|
||||
else {
|
||||
this.canvas.maskTool.removeShapeMask();
|
||||
log.info("Auto-apply shape mask disabled - mask removed automatically");
|
||||
}
|
||||
this._updateUI();
|
||||
this.canvas.render();
|
||||
});
|
||||
this.element.appendChild(checkboxContainer);
|
||||
// Add expansion checkbox (only visible when auto-apply is enabled)
|
||||
const expansionContainer = this._createCheckbox(() => `${this.canvas.shapeMaskExpansion ? "☑" : "☐"} Expand/Contract mask`, () => {
|
||||
this.canvas.shapeMaskExpansion = !this.canvas.shapeMaskExpansion;
|
||||
this._updateUI();
|
||||
if (this.canvas.autoApplyShapeMask) {
|
||||
this.canvas.maskTool.applyShapeMask();
|
||||
this.canvas.render();
|
||||
}
|
||||
});
|
||||
expansionContainer.id = 'expansion-checkbox';
|
||||
this.element.appendChild(expansionContainer);
|
||||
// Add expansion slider container (only visible when expansion is enabled)
|
||||
const expansionSliderContainer = document.createElement('div');
|
||||
expansionSliderContainer.id = 'expansion-slider-container';
|
||||
expansionSliderContainer.style.cssText = `
|
||||
margin: 6px 0;
|
||||
padding: 4px 8px;
|
||||
display: none;
|
||||
`;
|
||||
const expansionSliderLabel = document.createElement('div');
|
||||
expansionSliderLabel.textContent = 'Expansion amount:';
|
||||
expansionSliderLabel.style.cssText = `
|
||||
font-size: 11px;
|
||||
margin-bottom: 4px;
|
||||
color: #ccc;
|
||||
`;
|
||||
const expansionSlider = document.createElement('input');
|
||||
expansionSlider.type = 'range';
|
||||
expansionSlider.min = '-300';
|
||||
expansionSlider.max = '300';
|
||||
expansionSlider.value = '0';
|
||||
expansionSlider.style.cssText = `
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: #555;
|
||||
outline: none;
|
||||
border-radius: 2px;
|
||||
`;
|
||||
const expansionValueDisplay = document.createElement('div');
|
||||
expansionValueDisplay.style.cssText = `
|
||||
font-size: 10px;
|
||||
text-align: center;
|
||||
margin-top: 2px;
|
||||
color: #aaa;
|
||||
`;
|
||||
const updateExpansionSliderDisplay = () => {
|
||||
const value = parseInt(expansionSlider.value);
|
||||
this.canvas.shapeMaskExpansionValue = value;
|
||||
expansionValueDisplay.textContent = value > 0 ? `+${value}px` : `${value}px`;
|
||||
};
|
||||
// Add debouncing for expansion slider
|
||||
let expansionTimeout = null;
|
||||
expansionSlider.oninput = () => {
|
||||
updateExpansionSliderDisplay();
|
||||
if (this.canvas.autoApplyShapeMask) {
|
||||
// Clear previous timeout
|
||||
if (expansionTimeout) {
|
||||
clearTimeout(expansionTimeout);
|
||||
}
|
||||
// Apply mask immediately for visual feedback (without saving state)
|
||||
this.canvas.maskTool.applyShapeMask(false); // false = don't save state
|
||||
this.canvas.render();
|
||||
// Save state after 500ms of no changes
|
||||
expansionTimeout = window.setTimeout(() => {
|
||||
this.canvas.canvasState.saveMaskState();
|
||||
}, 500);
|
||||
}
|
||||
};
|
||||
updateExpansionSliderDisplay();
|
||||
expansionSliderContainer.appendChild(expansionSliderLabel);
|
||||
expansionSliderContainer.appendChild(expansionSlider);
|
||||
expansionSliderContainer.appendChild(expansionValueDisplay);
|
||||
this.element.appendChild(expansionSliderContainer);
|
||||
// Add feather checkbox (only visible when auto-apply is enabled)
|
||||
const featherContainer = this._createCheckbox(() => `${this.canvas.shapeMaskFeather ? "☑" : "☐"} Feather edges`, () => {
|
||||
this.canvas.shapeMaskFeather = !this.canvas.shapeMaskFeather;
|
||||
this._updateUI();
|
||||
if (this.canvas.autoApplyShapeMask) {
|
||||
this.canvas.maskTool.applyShapeMask();
|
||||
this.canvas.render();
|
||||
}
|
||||
});
|
||||
featherContainer.id = 'feather-checkbox';
|
||||
this.element.appendChild(featherContainer);
|
||||
// Add feather slider container (only visible when feather is enabled)
|
||||
const featherSliderContainer = document.createElement('div');
|
||||
featherSliderContainer.id = 'feather-slider-container';
|
||||
featherSliderContainer.style.cssText = `
|
||||
margin: 6px 0;
|
||||
padding: 4px 8px;
|
||||
display: none;
|
||||
`;
|
||||
const featherSliderLabel = document.createElement('div');
|
||||
featherSliderLabel.textContent = 'Feather amount:';
|
||||
featherSliderLabel.style.cssText = `
|
||||
font-size: 11px;
|
||||
margin-bottom: 4px;
|
||||
color: #ccc;
|
||||
`;
|
||||
const featherSlider = document.createElement('input');
|
||||
featherSlider.type = 'range';
|
||||
featherSlider.min = '0';
|
||||
featherSlider.max = '300';
|
||||
featherSlider.value = '0';
|
||||
featherSlider.style.cssText = `
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: #555;
|
||||
outline: none;
|
||||
border-radius: 2px;
|
||||
`;
|
||||
const featherValueDisplay = document.createElement('div');
|
||||
featherValueDisplay.style.cssText = `
|
||||
font-size: 10px;
|
||||
text-align: center;
|
||||
margin-top: 2px;
|
||||
color: #aaa;
|
||||
`;
|
||||
const updateFeatherSliderDisplay = () => {
|
||||
const value = parseInt(featherSlider.value);
|
||||
this.canvas.shapeMaskFeatherValue = value;
|
||||
featherValueDisplay.textContent = `${value}px`;
|
||||
};
|
||||
// Add debouncing for feather slider
|
||||
let featherTimeout = null;
|
||||
featherSlider.oninput = () => {
|
||||
updateFeatherSliderDisplay();
|
||||
if (this.canvas.autoApplyShapeMask) {
|
||||
// Clear previous timeout
|
||||
if (featherTimeout) {
|
||||
clearTimeout(featherTimeout);
|
||||
}
|
||||
// Apply mask immediately for visual feedback (without saving state)
|
||||
this.canvas.maskTool.applyShapeMask(false); // false = don't save state
|
||||
this.canvas.render();
|
||||
// Save state after 500ms of no changes
|
||||
featherTimeout = window.setTimeout(() => {
|
||||
this.canvas.canvasState.saveMaskState();
|
||||
}, 500);
|
||||
}
|
||||
};
|
||||
updateFeatherSliderDisplay();
|
||||
featherSliderContainer.appendChild(featherSliderLabel);
|
||||
featherSliderContainer.appendChild(featherSlider);
|
||||
featherSliderContainer.appendChild(featherValueDisplay);
|
||||
this.element.appendChild(featherSliderContainer);
|
||||
// Add to DOM
|
||||
if (this.canvas.canvas.parentElement) {
|
||||
this.canvas.canvas.parentElement.appendChild(this.element);
|
||||
}
|
||||
else {
|
||||
log.error("Could not find parent node to attach custom shape menu.");
|
||||
}
|
||||
this.uiInitialized = true;
|
||||
this._updateUI();
|
||||
}
|
||||
_createCheckbox(textFn, clickHandler) {
|
||||
const container = document.createElement('div');
|
||||
container.style.cssText = `
|
||||
margin: 6px 0 2px 0;
|
||||
padding: 4px 8px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
line-height: 18px;
|
||||
`;
|
||||
container.onmouseover = () => {
|
||||
container.style.backgroundColor = '#555';
|
||||
};
|
||||
container.onmouseout = () => {
|
||||
container.style.backgroundColor = 'transparent';
|
||||
};
|
||||
const updateText = () => {
|
||||
container.textContent = textFn();
|
||||
};
|
||||
updateText();
|
||||
container.onclick = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
clickHandler();
|
||||
updateText();
|
||||
};
|
||||
return container;
|
||||
}
|
||||
_updateUI() {
|
||||
if (!this.element)
|
||||
return;
|
||||
// Update expansion checkbox visibility
|
||||
const expansionCheckbox = this.element.querySelector('#expansion-checkbox');
|
||||
if (expansionCheckbox) {
|
||||
expansionCheckbox.style.display = this.canvas.autoApplyShapeMask ? 'block' : 'none';
|
||||
}
|
||||
// Update expansion slider container visibility
|
||||
const expansionSliderContainer = this.element.querySelector('#expansion-slider-container');
|
||||
if (expansionSliderContainer) {
|
||||
expansionSliderContainer.style.display =
|
||||
(this.canvas.autoApplyShapeMask && this.canvas.shapeMaskExpansion) ? 'block' : 'none';
|
||||
}
|
||||
// Update feather checkbox visibility
|
||||
const featherCheckbox = this.element.querySelector('#feather-checkbox');
|
||||
if (featherCheckbox) {
|
||||
featherCheckbox.style.display = this.canvas.autoApplyShapeMask ? 'block' : 'none';
|
||||
}
|
||||
// Update feather slider container visibility
|
||||
const featherSliderContainer = this.element.querySelector('#feather-slider-container');
|
||||
if (featherSliderContainer) {
|
||||
featherSliderContainer.style.display =
|
||||
(this.canvas.autoApplyShapeMask && this.canvas.shapeMaskFeather) ? 'block' : 'none';
|
||||
}
|
||||
// Update checkbox texts
|
||||
const checkboxes = this.element.querySelectorAll('div[style*="cursor: pointer"]');
|
||||
checkboxes.forEach((checkbox, index) => {
|
||||
if (index === 0) { // Main checkbox
|
||||
checkbox.textContent = `${this.canvas.autoApplyShapeMask ? "☑" : "☐"} Auto-apply shape mask`;
|
||||
}
|
||||
else if (index === 1) { // Expansion checkbox
|
||||
checkbox.textContent = `${this.canvas.shapeMaskExpansion ? "☑" : "☐"} Expand/Contract mask`;
|
||||
}
|
||||
else if (index === 2) { // Feather checkbox
|
||||
checkbox.textContent = `${this.canvas.shapeMaskFeather ? "☑" : "☐"} Feather edges`;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
492
js/MaskTool.js
492
js/MaskTool.js
@@ -270,4 +270,496 @@ export class MaskTool {
|
||||
this.canvasInstance.render();
|
||||
log.info(`MaskTool added mask overlay at correct canvas position (${destX}, ${destY}) without clearing existing mask.`);
|
||||
}
|
||||
applyShapeMask(saveState = true) {
|
||||
if (!this.canvasInstance.outputAreaShape?.points || this.canvasInstance.outputAreaShape.points.length < 3) {
|
||||
log.warn("Cannot apply shape mask: shape is not defined or has too few points.");
|
||||
return;
|
||||
}
|
||||
if (saveState) {
|
||||
this.canvasInstance.canvasState.saveMaskState();
|
||||
}
|
||||
const shape = this.canvasInstance.outputAreaShape;
|
||||
const destX = -this.x;
|
||||
const destY = -this.y;
|
||||
// Clear the entire mask canvas first
|
||||
this.maskCtx.clearRect(0, 0, this.maskCanvas.width, this.maskCanvas.height);
|
||||
// Create points relative to the mask canvas's coordinate system (by applying the offset)
|
||||
const maskPoints = shape.points.map(p => ({ x: p.x + destX, y: p.y + destY }));
|
||||
// Check if we need expansion or feathering
|
||||
const needsExpansion = this.canvasInstance.shapeMaskExpansion && this.canvasInstance.shapeMaskExpansionValue !== 0;
|
||||
const needsFeather = this.canvasInstance.shapeMaskFeather && this.canvasInstance.shapeMaskFeatherValue > 0;
|
||||
if (!needsExpansion && !needsFeather) {
|
||||
// Simple case: just draw the original shape
|
||||
this.maskCtx.fillStyle = 'white';
|
||||
this.maskCtx.beginPath();
|
||||
this.maskCtx.moveTo(maskPoints[0].x, maskPoints[0].y);
|
||||
for (let i = 1; i < maskPoints.length; i++) {
|
||||
this.maskCtx.lineTo(maskPoints[i].x, maskPoints[i].y);
|
||||
}
|
||||
this.maskCtx.closePath();
|
||||
this.maskCtx.fill();
|
||||
}
|
||||
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);
|
||||
this.maskCtx.drawImage(expandedMaskCanvas, 0, 0);
|
||||
}
|
||||
else if (!needsExpansion && needsFeather) {
|
||||
// Feather only: apply feathering to the original shape
|
||||
const featheredMaskCanvas = this._createFeatheredMaskCanvas(maskPoints, this.canvasInstance.shapeMaskFeatherValue, this.maskCanvas.width, this.maskCanvas.height);
|
||||
this.maskCtx.drawImage(featheredMaskCanvas, 0, 0);
|
||||
}
|
||||
else {
|
||||
// Both expansion and feather: first expand, then apply feather to the expanded shape
|
||||
// Step 1: Create expanded shape
|
||||
const expandedMaskCanvas = this._createExpandedMaskCanvas(maskPoints, this.canvasInstance.shapeMaskExpansionValue, this.maskCanvas.width, this.maskCanvas.height);
|
||||
// Step 2: Extract points from the expanded canvas and apply feathering
|
||||
// For now, we'll apply feathering to the expanded canvas directly
|
||||
// This is a simplified approach - we could extract the outline points for more precision
|
||||
const tempCtx = expandedMaskCanvas.getContext('2d', { willReadFrequently: true });
|
||||
const expandedImageData = tempCtx.getImageData(0, 0, expandedMaskCanvas.width, expandedMaskCanvas.height);
|
||||
// Apply feathering to the expanded shape
|
||||
const featheredMaskCanvas = this._createFeatheredMaskFromImageData(expandedImageData, this.canvasInstance.shapeMaskFeatherValue, this.maskCanvas.width, this.maskCanvas.height);
|
||||
this.maskCtx.drawImage(featheredMaskCanvas, 0, 0);
|
||||
}
|
||||
if (this.onStateChange) {
|
||||
this.onStateChange();
|
||||
}
|
||||
this.canvasInstance.render();
|
||||
log.info(`Applied shape mask with expansion: ${needsExpansion}, feather: ${needsFeather}.`);
|
||||
}
|
||||
/**
|
||||
* Removes mask in the area of the custom output area shape
|
||||
*/
|
||||
removeShapeMask() {
|
||||
if (!this.canvasInstance.outputAreaShape?.points || this.canvasInstance.outputAreaShape.points.length < 3) {
|
||||
log.warn("Shape has insufficient points for mask removal");
|
||||
return;
|
||||
}
|
||||
this.canvasInstance.canvasState.saveMaskState();
|
||||
const shape = this.canvasInstance.outputAreaShape;
|
||||
const destX = -this.x;
|
||||
const destY = -this.y;
|
||||
this.maskCtx.save();
|
||||
this.maskCtx.globalCompositeOperation = 'destination-out';
|
||||
this.maskCtx.translate(destX, destY);
|
||||
this.maskCtx.beginPath();
|
||||
this.maskCtx.moveTo(shape.points[0].x, shape.points[0].y);
|
||||
for (let i = 1; i < shape.points.length; i++) {
|
||||
this.maskCtx.lineTo(shape.points[i].x, shape.points[i].y);
|
||||
}
|
||||
this.maskCtx.closePath();
|
||||
this.maskCtx.fill();
|
||||
this.maskCtx.restore();
|
||||
if (this.onStateChange) {
|
||||
this.onStateChange();
|
||||
}
|
||||
this.canvasInstance.render();
|
||||
log.info(`Removed shape mask with ${shape.points.length} points`);
|
||||
}
|
||||
_createFeatheredMaskCanvas(points, featherRadius, width, height) {
|
||||
// 1. Create a binary mask on a temporary canvas.
|
||||
const binaryCanvas = document.createElement('canvas');
|
||||
binaryCanvas.width = width;
|
||||
binaryCanvas.height = height;
|
||||
const binaryCtx = binaryCanvas.getContext('2d', { willReadFrequently: true });
|
||||
binaryCtx.fillStyle = 'white';
|
||||
binaryCtx.beginPath();
|
||||
binaryCtx.moveTo(points[0].x, points[0].y);
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
binaryCtx.lineTo(points[i].x, points[i].y);
|
||||
}
|
||||
binaryCtx.closePath();
|
||||
binaryCtx.fill();
|
||||
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 ? 1 : 0; // 1 = inside, 0 = outside
|
||||
}
|
||||
// 2. Calculate the fast distance transform (from ImageAnalysis.ts approach).
|
||||
const distanceMap = this._fastDistanceTransform(binaryData, width, height);
|
||||
// Find the maximum distance to normalize
|
||||
let maxDistance = 0;
|
||||
for (let i = 0; i < distanceMap.length; i++) {
|
||||
if (distanceMap[i] > maxDistance) {
|
||||
maxDistance = distanceMap[i];
|
||||
}
|
||||
}
|
||||
// 3. Create the final output canvas with the complete mask (solid + feather).
|
||||
const outputCanvas = document.createElement('canvas');
|
||||
outputCanvas.width = width;
|
||||
outputCanvas.height = height;
|
||||
const outputCtx = outputCanvas.getContext('2d', { willReadFrequently: true });
|
||||
const outputData = outputCtx.createImageData(width, height);
|
||||
// Use featherRadius as the threshold for the gradient
|
||||
const threshold = Math.min(featherRadius, maxDistance);
|
||||
for (let i = 0; i < distanceMap.length; i++) {
|
||||
const distance = distanceMap[i];
|
||||
const originalAlpha = maskImage.data[i * 4 + 3];
|
||||
if (originalAlpha === 0) {
|
||||
// Transparent pixels remain transparent
|
||||
outputData.data[i * 4] = 255;
|
||||
outputData.data[i * 4 + 1] = 255;
|
||||
outputData.data[i * 4 + 2] = 255;
|
||||
outputData.data[i * 4 + 3] = 0;
|
||||
}
|
||||
else if (distance <= threshold) {
|
||||
// Edge area - apply gradient alpha (from edge inward)
|
||||
const gradientValue = distance / threshold;
|
||||
const alphaValue = Math.floor(gradientValue * 255);
|
||||
outputData.data[i * 4] = 255;
|
||||
outputData.data[i * 4 + 1] = 255;
|
||||
outputData.data[i * 4 + 2] = 255;
|
||||
outputData.data[i * 4 + 3] = alphaValue;
|
||||
}
|
||||
else {
|
||||
// Inner area - full alpha (no blending effect)
|
||||
outputData.data[i * 4] = 255;
|
||||
outputData.data[i * 4 + 1] = 255;
|
||||
outputData.data[i * 4 + 2] = 255;
|
||||
outputData.data[i * 4 + 3] = 255;
|
||||
}
|
||||
}
|
||||
outputCtx.putImageData(outputData, 0, 0);
|
||||
return outputCanvas;
|
||||
}
|
||||
/**
|
||||
* Fast distance transform using the simple two-pass algorithm from ImageAnalysis.ts
|
||||
* Much faster than the complex Felzenszwalb algorithm
|
||||
*/
|
||||
_fastDistanceTransform(binaryMask, width, height) {
|
||||
const distances = new Float32Array(width * height);
|
||||
const infinity = width + height; // A value larger than any possible distance
|
||||
// Initialize distances
|
||||
for (let i = 0; i < width * height; i++) {
|
||||
distances[i] = binaryMask[i] === 1 ? infinity : 0;
|
||||
}
|
||||
// Forward pass (top-left to bottom-right)
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const idx = y * width + x;
|
||||
if (distances[idx] > 0) {
|
||||
let minDist = distances[idx];
|
||||
// Check top neighbor
|
||||
if (y > 0) {
|
||||
minDist = Math.min(minDist, distances[(y - 1) * width + x] + 1);
|
||||
}
|
||||
// Check left neighbor
|
||||
if (x > 0) {
|
||||
minDist = Math.min(minDist, distances[y * width + (x - 1)] + 1);
|
||||
}
|
||||
// Check top-left diagonal
|
||||
if (x > 0 && y > 0) {
|
||||
minDist = Math.min(minDist, distances[(y - 1) * width + (x - 1)] + Math.sqrt(2));
|
||||
}
|
||||
// Check top-right diagonal
|
||||
if (x < width - 1 && y > 0) {
|
||||
minDist = Math.min(minDist, distances[(y - 1) * width + (x + 1)] + Math.sqrt(2));
|
||||
}
|
||||
distances[idx] = minDist;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Backward pass (bottom-right to top-left)
|
||||
for (let y = height - 1; y >= 0; y--) {
|
||||
for (let x = width - 1; x >= 0; x--) {
|
||||
const idx = y * width + x;
|
||||
if (distances[idx] > 0) {
|
||||
let minDist = distances[idx];
|
||||
// Check bottom neighbor
|
||||
if (y < height - 1) {
|
||||
minDist = Math.min(minDist, distances[(y + 1) * width + x] + 1);
|
||||
}
|
||||
// Check right neighbor
|
||||
if (x < width - 1) {
|
||||
minDist = Math.min(minDist, distances[y * width + (x + 1)] + 1);
|
||||
}
|
||||
// Check bottom-right diagonal
|
||||
if (x < width - 1 && y < height - 1) {
|
||||
minDist = Math.min(minDist, distances[(y + 1) * width + (x + 1)] + Math.sqrt(2));
|
||||
}
|
||||
// Check bottom-left diagonal
|
||||
if (x > 0 && y < height - 1) {
|
||||
minDist = Math.min(minDist, distances[(y + 1) * width + (x - 1)] + Math.sqrt(2));
|
||||
}
|
||||
distances[idx] = minDist;
|
||||
}
|
||||
}
|
||||
}
|
||||
return distances;
|
||||
}
|
||||
/**
|
||||
* Creates an expanded mask using distance transform - much better for complex shapes
|
||||
* than the centroid-based approach. This version only does expansion without transparency calculations.
|
||||
*/
|
||||
_calculateExpandedPoints(points, expansionValue) {
|
||||
if (points.length < 3 || expansionValue === 0)
|
||||
return points;
|
||||
// For expansion, we need to create a temporary canvas to use the distance transform approach
|
||||
// This will give us much better results for complex shapes than the centroid method
|
||||
const tempCanvas = this._createExpandedMaskCanvas(points, expansionValue, this.maskCanvas.width, this.maskCanvas.height);
|
||||
// Extract the expanded shape outline from the canvas
|
||||
// For now, return the original points as a fallback - the real expansion happens in the canvas
|
||||
// The calling code will use the canvas directly instead of these points
|
||||
return points;
|
||||
}
|
||||
/**
|
||||
* Creates an expanded/contracted mask canvas using distance transform
|
||||
* Supports both positive values (expansion) and negative values (contraction)
|
||||
*/
|
||||
_createExpandedMaskCanvas(points, expansionValue, width, height) {
|
||||
// 1. Create a binary mask on a temporary canvas.
|
||||
const binaryCanvas = document.createElement('canvas');
|
||||
binaryCanvas.width = width;
|
||||
binaryCanvas.height = height;
|
||||
const binaryCtx = binaryCanvas.getContext('2d', { willReadFrequently: true });
|
||||
binaryCtx.fillStyle = 'white';
|
||||
binaryCtx.beginPath();
|
||||
binaryCtx.moveTo(points[0].x, points[0].y);
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
binaryCtx.lineTo(points[i].x, points[i].y);
|
||||
}
|
||||
binaryCtx.closePath();
|
||||
binaryCtx.fill();
|
||||
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
|
||||
}
|
||||
// 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
|
||||
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;
|
||||
}
|
||||
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)
|
||||
*/
|
||||
_createFeatheredMaskFromImageData(imageData, featherRadius, width, height) {
|
||||
const data = imageData.data;
|
||||
const binaryData = new Uint8Array(width * height);
|
||||
// Convert ImageData to binary mask
|
||||
for (let i = 0; i < width * height; i++) {
|
||||
binaryData[i] = data[i * 4 + 3] > 0 ? 1 : 0; // 1 = inside, 0 = outside
|
||||
}
|
||||
// Calculate the fast distance transform
|
||||
const distanceMap = this._fastDistanceTransform(binaryData, width, height);
|
||||
// Find the maximum distance to normalize
|
||||
let maxDistance = 0;
|
||||
for (let i = 0; i < distanceMap.length; i++) {
|
||||
if (distanceMap[i] > maxDistance) {
|
||||
maxDistance = distanceMap[i];
|
||||
}
|
||||
}
|
||||
// Create the final output canvas with feathering applied
|
||||
const outputCanvas = document.createElement('canvas');
|
||||
outputCanvas.width = width;
|
||||
outputCanvas.height = height;
|
||||
const outputCtx = outputCanvas.getContext('2d', { willReadFrequently: true });
|
||||
const outputData = outputCtx.createImageData(width, height);
|
||||
// Use featherRadius as the threshold for the gradient
|
||||
const threshold = Math.min(featherRadius, maxDistance);
|
||||
for (let i = 0; i < distanceMap.length; i++) {
|
||||
const distance = distanceMap[i];
|
||||
const originalAlpha = data[i * 4 + 3];
|
||||
if (originalAlpha === 0) {
|
||||
// Transparent pixels remain transparent
|
||||
outputData.data[i * 4] = 255;
|
||||
outputData.data[i * 4 + 1] = 255;
|
||||
outputData.data[i * 4 + 2] = 255;
|
||||
outputData.data[i * 4 + 3] = 0;
|
||||
}
|
||||
else if (distance <= threshold) {
|
||||
// Edge area - apply gradient alpha (from edge inward)
|
||||
const gradientValue = distance / threshold;
|
||||
const alphaValue = Math.floor(gradientValue * 255);
|
||||
outputData.data[i * 4] = 255;
|
||||
outputData.data[i * 4 + 1] = 255;
|
||||
outputData.data[i * 4 + 2] = 255;
|
||||
outputData.data[i * 4 + 3] = alphaValue;
|
||||
}
|
||||
else {
|
||||
// Inner area - full alpha (no blending effect)
|
||||
outputData.data[i * 4] = 255;
|
||||
outputData.data[i * 4 + 1] = 255;
|
||||
outputData.data[i * 4 + 2] = 255;
|
||||
outputData.data[i * 4 + 3] = 255;
|
||||
}
|
||||
}
|
||||
outputCtx.putImageData(outputData, 0, 0);
|
||||
return outputCanvas;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {ComfyApp} from "../../scripts/app.js";
|
||||
import {removeImage} from "./db.js";
|
||||
import {MaskTool} from "./MaskTool.js";
|
||||
import {ShapeTool} from "./ShapeTool.js";
|
||||
import {CustomShapeMenu} from "./CustomShapeMenu.js";
|
||||
import {CanvasState} from "./CanvasState.js";
|
||||
import {CanvasInteractions} from "./CanvasInteractions.js";
|
||||
import {CanvasLayers} from "./CanvasLayers.js";
|
||||
@@ -65,7 +66,13 @@ export class Canvas {
|
||||
layers: Layer[];
|
||||
maskTool: MaskTool;
|
||||
shapeTool: ShapeTool;
|
||||
customShapeMenu: CustomShapeMenu;
|
||||
outputAreaShape: Shape | null;
|
||||
autoApplyShapeMask: boolean;
|
||||
shapeMaskExpansion: boolean;
|
||||
shapeMaskExpansionValue: number;
|
||||
shapeMaskFeather: boolean;
|
||||
shapeMaskFeatherValue: number;
|
||||
node: ComfyNode;
|
||||
offscreenCanvas: HTMLCanvasElement;
|
||||
offscreenCtx: CanvasRenderingContext2D | null;
|
||||
@@ -112,7 +119,13 @@ export class Canvas {
|
||||
this.requestSaveState = () => {};
|
||||
this.maskTool = new MaskTool(this, {onStateChange: this.onStateChange});
|
||||
this.shapeTool = new ShapeTool(this);
|
||||
this.customShapeMenu = new CustomShapeMenu(this);
|
||||
this.outputAreaShape = null;
|
||||
this.autoApplyShapeMask = false;
|
||||
this.shapeMaskExpansion = false;
|
||||
this.shapeMaskExpansionValue = 0;
|
||||
this.shapeMaskFeather = false;
|
||||
this.shapeMaskFeatherValue = 0;
|
||||
this.canvasMask = new CanvasMask(this);
|
||||
this.canvasState = new CanvasState(this);
|
||||
this.canvasSelection = new CanvasSelection(this);
|
||||
|
||||
@@ -101,6 +101,7 @@ export class CanvasInteractions {
|
||||
const worldCoords = this.canvas.getMouseWorldCoordinates(e);
|
||||
const viewCoords = this.canvas.getMouseViewCoordinates(e);
|
||||
|
||||
|
||||
if (this.interaction.mode === 'drawingMask') {
|
||||
this.canvas.maskTool.handleMouseDown(worldCoords, viewCoords);
|
||||
this.canvas.render();
|
||||
@@ -987,4 +988,5 @@ export class CanvasInteractions {
|
||||
|
||||
await this.canvas.canvasLayers.clipboardManager.handlePaste('mouse', preference);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -107,6 +107,14 @@ export class CanvasRenderer {
|
||||
this.renderInteractionElements(ctx);
|
||||
this.canvas.shapeTool.render(ctx);
|
||||
this.renderLayerInfo(ctx);
|
||||
|
||||
// Update custom shape menu position and visibility
|
||||
if (this.canvas.outputAreaShape) {
|
||||
this.canvas.customShapeMenu.show();
|
||||
this.canvas.customShapeMenu.updateScreenPosition();
|
||||
} else {
|
||||
this.canvas.customShapeMenu.hide();
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
|
||||
@@ -262,6 +270,7 @@ export class CanvasRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
drawGrid(ctx: any) {
|
||||
const gridSize = 64;
|
||||
const lineWidth = 0.5 / this.canvas.viewport.zoom;
|
||||
|
||||
386
src/CustomShapeMenu.ts
Normal file
386
src/CustomShapeMenu.ts
Normal file
@@ -0,0 +1,386 @@
|
||||
import {createModuleLogger} from "./utils/LoggerUtils.js";
|
||||
import type { Canvas } from './Canvas';
|
||||
|
||||
const log = createModuleLogger('CustomShapeMenu');
|
||||
|
||||
export class CustomShapeMenu {
|
||||
private canvas: Canvas;
|
||||
private element: HTMLDivElement | null;
|
||||
private worldX: number;
|
||||
private worldY: number;
|
||||
private uiInitialized: boolean;
|
||||
|
||||
constructor(canvas: Canvas) {
|
||||
this.canvas = canvas;
|
||||
this.element = null;
|
||||
this.worldX = 0;
|
||||
this.worldY = 0;
|
||||
this.uiInitialized = false;
|
||||
}
|
||||
|
||||
show(): void {
|
||||
if (!this.canvas.outputAreaShape) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._createUI();
|
||||
|
||||
if (this.element) {
|
||||
this.element.style.display = 'block';
|
||||
}
|
||||
|
||||
// Position in top-left corner of viewport (closer to edge)
|
||||
const viewLeft = this.canvas.viewport.x;
|
||||
const viewTop = this.canvas.viewport.y;
|
||||
this.worldX = viewLeft + (8 / this.canvas.viewport.zoom);
|
||||
this.worldY = viewTop + (8 / this.canvas.viewport.zoom);
|
||||
|
||||
this.updateScreenPosition();
|
||||
}
|
||||
|
||||
hide(): void {
|
||||
if (this.element) {
|
||||
this.element.remove();
|
||||
this.element = null;
|
||||
this.uiInitialized = false;
|
||||
}
|
||||
}
|
||||
|
||||
updateScreenPosition(): void {
|
||||
if (!this.element) return;
|
||||
|
||||
const screenX = (this.worldX - this.canvas.viewport.x) * this.canvas.viewport.zoom;
|
||||
const screenY = (this.worldY - this.canvas.viewport.y) * this.canvas.viewport.zoom;
|
||||
|
||||
this.element.style.transform = `translate(${screenX}px, ${screenY}px)`;
|
||||
}
|
||||
|
||||
private _createUI(): void {
|
||||
if (this.uiInitialized) return;
|
||||
|
||||
this.element = document.createElement('div');
|
||||
this.element.id = 'layerforge-custom-shape-menu';
|
||||
this.element.style.cssText = `
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
background-color: #333;
|
||||
color: white;
|
||||
padding: 8px 15px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 15px rgba(0,0,0,0.5);
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-family: sans-serif;
|
||||
font-size: 12px;
|
||||
z-index: 1001;
|
||||
border: 1px solid #555;
|
||||
user-select: none;
|
||||
min-width: 200px;
|
||||
`;
|
||||
|
||||
// Create menu content
|
||||
const lines = [
|
||||
"🎯 Custom Output Area Active",
|
||||
"Press Shift+S to modify shape",
|
||||
"Shape defines generation area"
|
||||
];
|
||||
|
||||
lines.forEach(line => {
|
||||
const lineElement = document.createElement('div');
|
||||
lineElement.textContent = line;
|
||||
lineElement.style.cssText = `
|
||||
margin: 2px 0;
|
||||
line-height: 18px;
|
||||
`;
|
||||
this.element!.appendChild(lineElement);
|
||||
});
|
||||
|
||||
// Add main auto-apply checkbox
|
||||
const checkboxContainer = this._createCheckbox(
|
||||
() => `${this.canvas.autoApplyShapeMask ? "☑" : "☐"} Auto-apply shape mask`,
|
||||
() => {
|
||||
this.canvas.autoApplyShapeMask = !this.canvas.autoApplyShapeMask;
|
||||
|
||||
if (this.canvas.autoApplyShapeMask) {
|
||||
this.canvas.maskTool.applyShapeMask();
|
||||
log.info("Auto-apply shape mask enabled - mask applied automatically");
|
||||
} else {
|
||||
this.canvas.maskTool.removeShapeMask();
|
||||
log.info("Auto-apply shape mask disabled - mask removed automatically");
|
||||
}
|
||||
|
||||
this._updateUI();
|
||||
this.canvas.render();
|
||||
}
|
||||
);
|
||||
this.element.appendChild(checkboxContainer);
|
||||
|
||||
// Add expansion checkbox (only visible when auto-apply is enabled)
|
||||
const expansionContainer = this._createCheckbox(
|
||||
() => `${this.canvas.shapeMaskExpansion ? "☑" : "☐"} Expand/Contract mask`,
|
||||
() => {
|
||||
this.canvas.shapeMaskExpansion = !this.canvas.shapeMaskExpansion;
|
||||
this._updateUI();
|
||||
|
||||
if (this.canvas.autoApplyShapeMask) {
|
||||
this.canvas.maskTool.applyShapeMask();
|
||||
this.canvas.render();
|
||||
}
|
||||
}
|
||||
);
|
||||
expansionContainer.id = 'expansion-checkbox';
|
||||
this.element.appendChild(expansionContainer);
|
||||
|
||||
// Add expansion slider container (only visible when expansion is enabled)
|
||||
const expansionSliderContainer = document.createElement('div');
|
||||
expansionSliderContainer.id = 'expansion-slider-container';
|
||||
expansionSliderContainer.style.cssText = `
|
||||
margin: 6px 0;
|
||||
padding: 4px 8px;
|
||||
display: none;
|
||||
`;
|
||||
|
||||
const expansionSliderLabel = document.createElement('div');
|
||||
expansionSliderLabel.textContent = 'Expansion amount:';
|
||||
expansionSliderLabel.style.cssText = `
|
||||
font-size: 11px;
|
||||
margin-bottom: 4px;
|
||||
color: #ccc;
|
||||
`;
|
||||
|
||||
const expansionSlider = document.createElement('input');
|
||||
expansionSlider.type = 'range';
|
||||
expansionSlider.min = '-300';
|
||||
expansionSlider.max = '300';
|
||||
expansionSlider.value = '0';
|
||||
expansionSlider.style.cssText = `
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: #555;
|
||||
outline: none;
|
||||
border-radius: 2px;
|
||||
`;
|
||||
|
||||
const expansionValueDisplay = document.createElement('div');
|
||||
expansionValueDisplay.style.cssText = `
|
||||
font-size: 10px;
|
||||
text-align: center;
|
||||
margin-top: 2px;
|
||||
color: #aaa;
|
||||
`;
|
||||
|
||||
const updateExpansionSliderDisplay = () => {
|
||||
const value = parseInt(expansionSlider.value);
|
||||
this.canvas.shapeMaskExpansionValue = value;
|
||||
expansionValueDisplay.textContent = value > 0 ? `+${value}px` : `${value}px`;
|
||||
};
|
||||
|
||||
// Add debouncing for expansion slider
|
||||
let expansionTimeout: number | null = null;
|
||||
|
||||
expansionSlider.oninput = () => {
|
||||
updateExpansionSliderDisplay();
|
||||
|
||||
if (this.canvas.autoApplyShapeMask) {
|
||||
// Clear previous timeout
|
||||
if (expansionTimeout) {
|
||||
clearTimeout(expansionTimeout);
|
||||
}
|
||||
|
||||
// Apply mask immediately for visual feedback (without saving state)
|
||||
this.canvas.maskTool.applyShapeMask(false); // false = don't save state
|
||||
this.canvas.render();
|
||||
|
||||
// Save state after 500ms of no changes
|
||||
expansionTimeout = window.setTimeout(() => {
|
||||
this.canvas.canvasState.saveMaskState();
|
||||
}, 500);
|
||||
}
|
||||
};
|
||||
|
||||
updateExpansionSliderDisplay();
|
||||
|
||||
expansionSliderContainer.appendChild(expansionSliderLabel);
|
||||
expansionSliderContainer.appendChild(expansionSlider);
|
||||
expansionSliderContainer.appendChild(expansionValueDisplay);
|
||||
this.element.appendChild(expansionSliderContainer);
|
||||
|
||||
// Add feather checkbox (only visible when auto-apply is enabled)
|
||||
const featherContainer = this._createCheckbox(
|
||||
() => `${this.canvas.shapeMaskFeather ? "☑" : "☐"} Feather edges`,
|
||||
() => {
|
||||
this.canvas.shapeMaskFeather = !this.canvas.shapeMaskFeather;
|
||||
this._updateUI();
|
||||
|
||||
if (this.canvas.autoApplyShapeMask) {
|
||||
this.canvas.maskTool.applyShapeMask();
|
||||
this.canvas.render();
|
||||
}
|
||||
}
|
||||
);
|
||||
featherContainer.id = 'feather-checkbox';
|
||||
this.element.appendChild(featherContainer);
|
||||
|
||||
// Add feather slider container (only visible when feather is enabled)
|
||||
const featherSliderContainer = document.createElement('div');
|
||||
featherSliderContainer.id = 'feather-slider-container';
|
||||
featherSliderContainer.style.cssText = `
|
||||
margin: 6px 0;
|
||||
padding: 4px 8px;
|
||||
display: none;
|
||||
`;
|
||||
|
||||
const featherSliderLabel = document.createElement('div');
|
||||
featherSliderLabel.textContent = 'Feather amount:';
|
||||
featherSliderLabel.style.cssText = `
|
||||
font-size: 11px;
|
||||
margin-bottom: 4px;
|
||||
color: #ccc;
|
||||
`;
|
||||
|
||||
const featherSlider = document.createElement('input');
|
||||
featherSlider.type = 'range';
|
||||
featherSlider.min = '0';
|
||||
featherSlider.max = '300';
|
||||
featherSlider.value = '0';
|
||||
featherSlider.style.cssText = `
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: #555;
|
||||
outline: none;
|
||||
border-radius: 2px;
|
||||
`;
|
||||
|
||||
const featherValueDisplay = document.createElement('div');
|
||||
featherValueDisplay.style.cssText = `
|
||||
font-size: 10px;
|
||||
text-align: center;
|
||||
margin-top: 2px;
|
||||
color: #aaa;
|
||||
`;
|
||||
|
||||
const updateFeatherSliderDisplay = () => {
|
||||
const value = parseInt(featherSlider.value);
|
||||
this.canvas.shapeMaskFeatherValue = value;
|
||||
featherValueDisplay.textContent = `${value}px`;
|
||||
};
|
||||
|
||||
// Add debouncing for feather slider
|
||||
let featherTimeout: number | null = null;
|
||||
|
||||
featherSlider.oninput = () => {
|
||||
updateFeatherSliderDisplay();
|
||||
|
||||
if (this.canvas.autoApplyShapeMask) {
|
||||
// Clear previous timeout
|
||||
if (featherTimeout) {
|
||||
clearTimeout(featherTimeout);
|
||||
}
|
||||
|
||||
// Apply mask immediately for visual feedback (without saving state)
|
||||
this.canvas.maskTool.applyShapeMask(false); // false = don't save state
|
||||
this.canvas.render();
|
||||
|
||||
// Save state after 500ms of no changes
|
||||
featherTimeout = window.setTimeout(() => {
|
||||
this.canvas.canvasState.saveMaskState();
|
||||
}, 500);
|
||||
}
|
||||
};
|
||||
|
||||
updateFeatherSliderDisplay();
|
||||
|
||||
featherSliderContainer.appendChild(featherSliderLabel);
|
||||
featherSliderContainer.appendChild(featherSlider);
|
||||
featherSliderContainer.appendChild(featherValueDisplay);
|
||||
this.element.appendChild(featherSliderContainer);
|
||||
|
||||
// Add to DOM
|
||||
if (this.canvas.canvas.parentElement) {
|
||||
this.canvas.canvas.parentElement.appendChild(this.element);
|
||||
} else {
|
||||
log.error("Could not find parent node to attach custom shape menu.");
|
||||
}
|
||||
|
||||
this.uiInitialized = true;
|
||||
this._updateUI();
|
||||
}
|
||||
|
||||
private _createCheckbox(textFn: () => string, clickHandler: () => void): HTMLDivElement {
|
||||
const container = document.createElement('div');
|
||||
container.style.cssText = `
|
||||
margin: 6px 0 2px 0;
|
||||
padding: 4px 8px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
line-height: 18px;
|
||||
`;
|
||||
|
||||
container.onmouseover = () => {
|
||||
container.style.backgroundColor = '#555';
|
||||
};
|
||||
|
||||
container.onmouseout = () => {
|
||||
container.style.backgroundColor = 'transparent';
|
||||
};
|
||||
|
||||
const updateText = () => {
|
||||
container.textContent = textFn();
|
||||
};
|
||||
|
||||
updateText();
|
||||
container.onclick = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
clickHandler();
|
||||
updateText();
|
||||
};
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
private _updateUI(): void {
|
||||
if (!this.element) return;
|
||||
|
||||
// Update expansion checkbox visibility
|
||||
const expansionCheckbox = this.element.querySelector('#expansion-checkbox') as HTMLElement;
|
||||
if (expansionCheckbox) {
|
||||
expansionCheckbox.style.display = this.canvas.autoApplyShapeMask ? 'block' : 'none';
|
||||
}
|
||||
|
||||
// Update expansion slider container visibility
|
||||
const expansionSliderContainer = this.element.querySelector('#expansion-slider-container') as HTMLElement;
|
||||
if (expansionSliderContainer) {
|
||||
expansionSliderContainer.style.display =
|
||||
(this.canvas.autoApplyShapeMask && this.canvas.shapeMaskExpansion) ? 'block' : 'none';
|
||||
}
|
||||
|
||||
// Update feather checkbox visibility
|
||||
const featherCheckbox = this.element.querySelector('#feather-checkbox') as HTMLElement;
|
||||
if (featherCheckbox) {
|
||||
featherCheckbox.style.display = this.canvas.autoApplyShapeMask ? 'block' : 'none';
|
||||
}
|
||||
|
||||
// Update feather slider container visibility
|
||||
const featherSliderContainer = this.element.querySelector('#feather-slider-container') as HTMLElement;
|
||||
if (featherSliderContainer) {
|
||||
featherSliderContainer.style.display =
|
||||
(this.canvas.autoApplyShapeMask && this.canvas.shapeMaskFeather) ? 'block' : 'none';
|
||||
}
|
||||
|
||||
// Update checkbox texts
|
||||
const checkboxes = this.element.querySelectorAll('div[style*="cursor: pointer"]');
|
||||
checkboxes.forEach((checkbox, index) => {
|
||||
if (index === 0) { // Main checkbox
|
||||
checkbox.textContent = `${this.canvas.autoApplyShapeMask ? "☑" : "☐"} Auto-apply shape mask`;
|
||||
} else if (index === 1) { // Expansion checkbox
|
||||
checkbox.textContent = `${this.canvas.shapeMaskExpansion ? "☑" : "☐"} Expand/Contract mask`;
|
||||
} else if (index === 2) { // Feather checkbox
|
||||
checkbox.textContent = `${this.canvas.shapeMaskFeather ? "☑" : "☐"} Feather edges`;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
563
src/MaskTool.ts
563
src/MaskTool.ts
@@ -351,4 +351,567 @@ export class MaskTool {
|
||||
this.canvasInstance.render();
|
||||
log.info(`MaskTool added mask overlay at correct canvas position (${destX}, ${destY}) without clearing existing mask.`);
|
||||
}
|
||||
|
||||
applyShapeMask(saveState: boolean = true): void {
|
||||
if (!this.canvasInstance.outputAreaShape?.points || this.canvasInstance.outputAreaShape.points.length < 3) {
|
||||
log.warn("Cannot apply shape mask: shape is not defined or has too few points.");
|
||||
return;
|
||||
}
|
||||
if (saveState) {
|
||||
this.canvasInstance.canvasState.saveMaskState();
|
||||
}
|
||||
|
||||
const shape = this.canvasInstance.outputAreaShape;
|
||||
const destX = -this.x;
|
||||
const destY = -this.y;
|
||||
|
||||
// Clear the entire mask canvas first
|
||||
this.maskCtx.clearRect(0, 0, this.maskCanvas.width, this.maskCanvas.height);
|
||||
|
||||
// Create points relative to the mask canvas's coordinate system (by applying the offset)
|
||||
const maskPoints = shape.points.map(p => ({ x: p.x + destX, y: p.y + destY }));
|
||||
|
||||
// Check if we need expansion or feathering
|
||||
const needsExpansion = this.canvasInstance.shapeMaskExpansion && this.canvasInstance.shapeMaskExpansionValue !== 0;
|
||||
const needsFeather = this.canvasInstance.shapeMaskFeather && this.canvasInstance.shapeMaskFeatherValue > 0;
|
||||
|
||||
if (!needsExpansion && !needsFeather) {
|
||||
// Simple case: just draw the original shape
|
||||
this.maskCtx.fillStyle = 'white';
|
||||
this.maskCtx.beginPath();
|
||||
this.maskCtx.moveTo(maskPoints[0].x, maskPoints[0].y);
|
||||
for (let i = 1; i < maskPoints.length; i++) {
|
||||
this.maskCtx.lineTo(maskPoints[i].x, maskPoints[i].y);
|
||||
}
|
||||
this.maskCtx.closePath();
|
||||
this.maskCtx.fill();
|
||||
} 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);
|
||||
this.maskCtx.drawImage(expandedMaskCanvas, 0, 0);
|
||||
} else if (!needsExpansion && needsFeather) {
|
||||
// Feather only: apply feathering to the original shape
|
||||
const featheredMaskCanvas = this._createFeatheredMaskCanvas(maskPoints, this.canvasInstance.shapeMaskFeatherValue, this.maskCanvas.width, this.maskCanvas.height);
|
||||
this.maskCtx.drawImage(featheredMaskCanvas, 0, 0);
|
||||
} else {
|
||||
// Both expansion and feather: first expand, then apply feather to the expanded shape
|
||||
// Step 1: Create expanded shape
|
||||
const expandedMaskCanvas = this._createExpandedMaskCanvas(maskPoints, this.canvasInstance.shapeMaskExpansionValue, this.maskCanvas.width, this.maskCanvas.height);
|
||||
|
||||
// Step 2: Extract points from the expanded canvas and apply feathering
|
||||
// For now, we'll apply feathering to the expanded canvas directly
|
||||
// This is a simplified approach - we could extract the outline points for more precision
|
||||
const tempCtx = expandedMaskCanvas.getContext('2d', { willReadFrequently: true })!;
|
||||
const expandedImageData = tempCtx.getImageData(0, 0, expandedMaskCanvas.width, expandedMaskCanvas.height);
|
||||
|
||||
// Apply feathering to the expanded shape
|
||||
const featheredMaskCanvas = this._createFeatheredMaskFromImageData(expandedImageData, this.canvasInstance.shapeMaskFeatherValue, this.maskCanvas.width, this.maskCanvas.height);
|
||||
this.maskCtx.drawImage(featheredMaskCanvas, 0, 0);
|
||||
}
|
||||
|
||||
if (this.onStateChange) {
|
||||
this.onStateChange();
|
||||
}
|
||||
this.canvasInstance.render();
|
||||
log.info(`Applied shape mask with expansion: ${needsExpansion}, feather: ${needsFeather}.`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes mask in the area of the custom output area shape
|
||||
*/
|
||||
removeShapeMask(): void {
|
||||
if (!this.canvasInstance.outputAreaShape?.points || this.canvasInstance.outputAreaShape.points.length < 3) {
|
||||
log.warn("Shape has insufficient points for mask removal");
|
||||
return;
|
||||
}
|
||||
|
||||
this.canvasInstance.canvasState.saveMaskState();
|
||||
const shape = this.canvasInstance.outputAreaShape;
|
||||
const destX = -this.x;
|
||||
const destY = -this.y;
|
||||
|
||||
this.maskCtx.save();
|
||||
this.maskCtx.globalCompositeOperation = 'destination-out';
|
||||
this.maskCtx.translate(destX, destY);
|
||||
|
||||
this.maskCtx.beginPath();
|
||||
this.maskCtx.moveTo(shape.points[0].x, shape.points[0].y);
|
||||
for (let i = 1; i < shape.points.length; i++) {
|
||||
this.maskCtx.lineTo(shape.points[i].x, shape.points[i].y);
|
||||
}
|
||||
this.maskCtx.closePath();
|
||||
this.maskCtx.fill();
|
||||
this.maskCtx.restore();
|
||||
|
||||
if (this.onStateChange) {
|
||||
this.onStateChange();
|
||||
}
|
||||
this.canvasInstance.render();
|
||||
log.info(`Removed shape mask with ${shape.points.length} points`);
|
||||
}
|
||||
|
||||
private _createFeatheredMaskCanvas(points: Point[], featherRadius: number, width: number, height: number): HTMLCanvasElement {
|
||||
// 1. Create a binary mask on a temporary canvas.
|
||||
const binaryCanvas = document.createElement('canvas');
|
||||
binaryCanvas.width = width;
|
||||
binaryCanvas.height = height;
|
||||
const binaryCtx = binaryCanvas.getContext('2d', { willReadFrequently: true })!;
|
||||
|
||||
binaryCtx.fillStyle = 'white';
|
||||
binaryCtx.beginPath();
|
||||
binaryCtx.moveTo(points[0].x, points[0].y);
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
binaryCtx.lineTo(points[i].x, points[i].y);
|
||||
}
|
||||
binaryCtx.closePath();
|
||||
binaryCtx.fill();
|
||||
|
||||
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 ? 1 : 0; // 1 = inside, 0 = outside
|
||||
}
|
||||
|
||||
// 2. Calculate the fast distance transform (from ImageAnalysis.ts approach).
|
||||
const distanceMap = this._fastDistanceTransform(binaryData, width, height);
|
||||
|
||||
// Find the maximum distance to normalize
|
||||
let maxDistance = 0;
|
||||
for (let i = 0; i < distanceMap.length; i++) {
|
||||
if (distanceMap[i] > maxDistance) {
|
||||
maxDistance = distanceMap[i];
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Create the final output canvas with the complete mask (solid + feather).
|
||||
const outputCanvas = document.createElement('canvas');
|
||||
outputCanvas.width = width;
|
||||
outputCanvas.height = height;
|
||||
const outputCtx = outputCanvas.getContext('2d', { willReadFrequently: true })!;
|
||||
const outputData = outputCtx.createImageData(width, height);
|
||||
|
||||
// Use featherRadius as the threshold for the gradient
|
||||
const threshold = Math.min(featherRadius, maxDistance);
|
||||
|
||||
for (let i = 0; i < distanceMap.length; i++) {
|
||||
const distance = distanceMap[i];
|
||||
const originalAlpha = maskImage.data[i * 4 + 3];
|
||||
|
||||
if (originalAlpha === 0) {
|
||||
// Transparent pixels remain transparent
|
||||
outputData.data[i * 4] = 255;
|
||||
outputData.data[i * 4 + 1] = 255;
|
||||
outputData.data[i * 4 + 2] = 255;
|
||||
outputData.data[i * 4 + 3] = 0;
|
||||
} else if (distance <= threshold) {
|
||||
// Edge area - apply gradient alpha (from edge inward)
|
||||
const gradientValue = distance / threshold;
|
||||
const alphaValue = Math.floor(gradientValue * 255);
|
||||
outputData.data[i * 4] = 255;
|
||||
outputData.data[i * 4 + 1] = 255;
|
||||
outputData.data[i * 4 + 2] = 255;
|
||||
outputData.data[i * 4 + 3] = alphaValue;
|
||||
} else {
|
||||
// Inner area - full alpha (no blending effect)
|
||||
outputData.data[i * 4] = 255;
|
||||
outputData.data[i * 4 + 1] = 255;
|
||||
outputData.data[i * 4 + 2] = 255;
|
||||
outputData.data[i * 4 + 3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
outputCtx.putImageData(outputData, 0, 0);
|
||||
return outputCanvas;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fast distance transform using the simple two-pass algorithm from ImageAnalysis.ts
|
||||
* Much faster than the complex Felzenszwalb algorithm
|
||||
*/
|
||||
private _fastDistanceTransform(binaryMask: Uint8Array, width: number, height: number): Float32Array {
|
||||
const distances = new Float32Array(width * height);
|
||||
const infinity = width + height; // A value larger than any possible distance
|
||||
|
||||
// Initialize distances
|
||||
for (let i = 0; i < width * height; i++) {
|
||||
distances[i] = binaryMask[i] === 1 ? infinity : 0;
|
||||
}
|
||||
|
||||
// Forward pass (top-left to bottom-right)
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const idx = y * width + x;
|
||||
if (distances[idx] > 0) {
|
||||
let minDist = distances[idx];
|
||||
|
||||
// Check top neighbor
|
||||
if (y > 0) {
|
||||
minDist = Math.min(minDist, distances[(y - 1) * width + x] + 1);
|
||||
}
|
||||
|
||||
// Check left neighbor
|
||||
if (x > 0) {
|
||||
minDist = Math.min(minDist, distances[y * width + (x - 1)] + 1);
|
||||
}
|
||||
|
||||
// Check top-left diagonal
|
||||
if (x > 0 && y > 0) {
|
||||
minDist = Math.min(minDist, distances[(y - 1) * width + (x - 1)] + Math.sqrt(2));
|
||||
}
|
||||
|
||||
// Check top-right diagonal
|
||||
if (x < width - 1 && y > 0) {
|
||||
minDist = Math.min(minDist, distances[(y - 1) * width + (x + 1)] + Math.sqrt(2));
|
||||
}
|
||||
|
||||
distances[idx] = minDist;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Backward pass (bottom-right to top-left)
|
||||
for (let y = height - 1; y >= 0; y--) {
|
||||
for (let x = width - 1; x >= 0; x--) {
|
||||
const idx = y * width + x;
|
||||
if (distances[idx] > 0) {
|
||||
let minDist = distances[idx];
|
||||
|
||||
// Check bottom neighbor
|
||||
if (y < height - 1) {
|
||||
minDist = Math.min(minDist, distances[(y + 1) * width + x] + 1);
|
||||
}
|
||||
|
||||
// Check right neighbor
|
||||
if (x < width - 1) {
|
||||
minDist = Math.min(minDist, distances[y * width + (x + 1)] + 1);
|
||||
}
|
||||
|
||||
// Check bottom-right diagonal
|
||||
if (x < width - 1 && y < height - 1) {
|
||||
minDist = Math.min(minDist, distances[(y + 1) * width + (x + 1)] + Math.sqrt(2));
|
||||
}
|
||||
|
||||
// Check bottom-left diagonal
|
||||
if (x > 0 && y < height - 1) {
|
||||
minDist = Math.min(minDist, distances[(y + 1) * width + (x - 1)] + Math.sqrt(2));
|
||||
}
|
||||
|
||||
distances[idx] = minDist;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return distances;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an expanded mask using distance transform - much better for complex shapes
|
||||
* than the centroid-based approach. This version only does expansion without transparency calculations.
|
||||
*/
|
||||
private _calculateExpandedPoints(points: Point[], expansionValue: number): Point[] {
|
||||
if (points.length < 3 || expansionValue === 0) return points;
|
||||
|
||||
// For expansion, we need to create a temporary canvas to use the distance transform approach
|
||||
// This will give us much better results for complex shapes than the centroid method
|
||||
const tempCanvas = this._createExpandedMaskCanvas(points, expansionValue, this.maskCanvas.width, this.maskCanvas.height);
|
||||
|
||||
// Extract the expanded shape outline from the canvas
|
||||
// For now, return the original points as a fallback - the real expansion happens in the canvas
|
||||
// The calling code will use the canvas directly instead of these points
|
||||
return points;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an expanded/contracted mask canvas using distance transform
|
||||
* Supports both positive values (expansion) and negative values (contraction)
|
||||
*/
|
||||
private _createExpandedMaskCanvas(points: Point[], expansionValue: number, width: number, height: number): HTMLCanvasElement {
|
||||
// 1. Create a binary mask on a temporary canvas.
|
||||
const binaryCanvas = document.createElement('canvas');
|
||||
binaryCanvas.width = width;
|
||||
binaryCanvas.height = height;
|
||||
const binaryCtx = binaryCanvas.getContext('2d', { willReadFrequently: true })!;
|
||||
|
||||
binaryCtx.fillStyle = 'white';
|
||||
binaryCtx.beginPath();
|
||||
binaryCtx.moveTo(points[0].x, points[0].y);
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
binaryCtx.lineTo(points[i].x, points[i].y);
|
||||
}
|
||||
binaryCtx.closePath();
|
||||
binaryCtx.fill();
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
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;
|
||||
}
|
||||
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)
|
||||
*/
|
||||
private _createFeatheredMaskFromImageData(imageData: ImageData, featherRadius: number, width: number, height: number): HTMLCanvasElement {
|
||||
const data = imageData.data;
|
||||
const binaryData = new Uint8Array(width * height);
|
||||
|
||||
// Convert ImageData to binary mask
|
||||
for (let i = 0; i < width * height; i++) {
|
||||
binaryData[i] = data[i * 4 + 3] > 0 ? 1 : 0; // 1 = inside, 0 = outside
|
||||
}
|
||||
|
||||
// Calculate the fast distance transform
|
||||
const distanceMap = this._fastDistanceTransform(binaryData, width, height);
|
||||
|
||||
// Find the maximum distance to normalize
|
||||
let maxDistance = 0;
|
||||
for (let i = 0; i < distanceMap.length; i++) {
|
||||
if (distanceMap[i] > maxDistance) {
|
||||
maxDistance = distanceMap[i];
|
||||
}
|
||||
}
|
||||
|
||||
// Create the final output canvas with feathering applied
|
||||
const outputCanvas = document.createElement('canvas');
|
||||
outputCanvas.width = width;
|
||||
outputCanvas.height = height;
|
||||
const outputCtx = outputCanvas.getContext('2d', { willReadFrequently: true })!;
|
||||
const outputData = outputCtx.createImageData(width, height);
|
||||
|
||||
// Use featherRadius as the threshold for the gradient
|
||||
const threshold = Math.min(featherRadius, maxDistance);
|
||||
|
||||
for (let i = 0; i < distanceMap.length; i++) {
|
||||
const distance = distanceMap[i];
|
||||
const originalAlpha = data[i * 4 + 3];
|
||||
|
||||
if (originalAlpha === 0) {
|
||||
// Transparent pixels remain transparent
|
||||
outputData.data[i * 4] = 255;
|
||||
outputData.data[i * 4 + 1] = 255;
|
||||
outputData.data[i * 4 + 2] = 255;
|
||||
outputData.data[i * 4 + 3] = 0;
|
||||
} else if (distance <= threshold) {
|
||||
// Edge area - apply gradient alpha (from edge inward)
|
||||
const gradientValue = distance / threshold;
|
||||
const alphaValue = Math.floor(gradientValue * 255);
|
||||
outputData.data[i * 4] = 255;
|
||||
outputData.data[i * 4 + 1] = 255;
|
||||
outputData.data[i * 4 + 2] = 255;
|
||||
outputData.data[i * 4 + 3] = alphaValue;
|
||||
} else {
|
||||
// Inner area - full alpha (no blending effect)
|
||||
outputData.data[i * 4] = 255;
|
||||
outputData.data[i * 4 + 1] = 255;
|
||||
outputData.data[i * 4 + 2] = 255;
|
||||
outputData.data[i * 4 + 3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
outputCtx.putImageData(outputData, 0, 0);
|
||||
return outputCanvas;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user