diff --git a/js/MaskTool.js b/js/MaskTool.js index e4cb4df..9799a32 100644 --- a/js/MaskTool.js +++ b/js/MaskTool.js @@ -1,5 +1,6 @@ import { createModuleLogger } from "./log_system/log_funcs.js"; import { createCanvas } from "./utils/CommonUtils.js"; +import { calculateDistanceTransform } from "./utils/MaskPixelUtils.js"; const log = createModuleLogger('Mask_tool'); export class MaskTool { constructor(canvasInstance, callbacks = {}) { @@ -548,7 +549,7 @@ export class MaskTool { */ createFeatheredMaskFromBinaryData(binaryData, featherRadius, width, height) { // Calculate the fast distance transform - const distanceMap = this._fastDistanceTransform(binaryData, width, height); + const distanceMap = calculateDistanceTransform(binaryData, width, height); // Find the maximum distance to normalize let maxDistance = 0; for (let i = 0; i < distanceMap.length; i++) { @@ -1828,71 +1829,6 @@ export class MaskTool { // 2. Use unified feathering logic return this.createFeatheredMaskFromBinaryData(binaryData, featherRadius, width, height); } - /** - * 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/contracted mask canvas using simple morphological operations * This gives SHARP edges without smoothing, unlike distance transform diff --git a/js/utils/ImageAnalysis.js b/js/utils/ImageAnalysis.js index 66e43ba..39192e3 100644 --- a/js/utils/ImageAnalysis.js +++ b/js/utils/ImageAnalysis.js @@ -1,5 +1,6 @@ import { createModuleLogger } from "../log_system/log_funcs.js"; import { createCanvas } from "./CommonUtils.js"; +import { calculateDistanceTransform } from "./MaskPixelUtils.js"; import { withErrorHandling, createValidationError } from "../ErrorHandler.js"; const log = createModuleLogger('ImageAnalysis'); /** @@ -102,75 +103,6 @@ export function createDistanceFieldMaskSync(image, blendArea) { export const createDistanceFieldMask = withErrorHandling(function (image, blendArea) { return createDistanceFieldMaskSync(image, blendArea); }, 'createDistanceFieldMask'); -/** - * Calculates the Euclidean distance transform of a binary mask. - * Uses a two-pass algorithm for efficiency. - * @param binaryMask - Binary mask where 1 = inside, 0 = outside - * @param width - Width of the mask - * @param height - Height of the mask - * @returns Float32Array containing distance values - */ -function calculateDistanceTransform(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; -} /** * Calculates distance from edges of a rectangle for opaque images. * @param width - Width of the rectangle diff --git a/js/utils/MaskPixelUtils.js b/js/utils/MaskPixelUtils.js new file mode 100644 index 0000000..8c96513 --- /dev/null +++ b/js/utils/MaskPixelUtils.js @@ -0,0 +1,69 @@ +/** + * Calculates the Euclidean distance transform of a binary mask. + * Uses a two-pass algorithm for efficiency. + * @param binaryMask - Binary mask where 1 = inside, 0 = outside + * @param width - Width of the mask + * @param height - Height of the mask + * @returns Float32Array containing distance values + */ +export function calculateDistanceTransform(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; +} diff --git a/src/MaskTool.ts b/src/MaskTool.ts index 702b421..c053dac 100644 --- a/src/MaskTool.ts +++ b/src/MaskTool.ts @@ -1,5 +1,6 @@ import {createModuleLogger} from "./log_system/log_funcs.js"; import { createCanvas } from "./utils/CommonUtils.js"; +import { calculateDistanceTransform } from "./utils/MaskPixelUtils.js"; import type { Canvas } from './Canvas'; import type { Point, CanvasState, Layer } from './types'; @@ -711,7 +712,7 @@ export class MaskTool { */ private createFeatheredMaskFromBinaryData(binaryData: Uint8Array, featherRadius: number, width: number, height: number): HTMLCanvasElement { // Calculate the fast distance transform - const distanceMap = this._fastDistanceTransform(binaryData, width, height); + const distanceMap = calculateDistanceTransform(binaryData, width, height); // Find the maximum distance to normalize let maxDistance = 0; @@ -2271,86 +2272,6 @@ export class MaskTool { return this.createFeatheredMaskFromBinaryData(binaryData, featherRadius, width, height); } - /** - * 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/contracted mask canvas using simple morphological operations * This gives SHARP edges without smoothing, unlike distance transform diff --git a/src/utils/ImageAnalysis.ts b/src/utils/ImageAnalysis.ts index 955a98c..d7da2bb 100644 --- a/src/utils/ImageAnalysis.ts +++ b/src/utils/ImageAnalysis.ts @@ -1,5 +1,6 @@ import { createModuleLogger } from "../log_system/log_funcs.js"; import { createCanvas } from "./CommonUtils.js"; +import { calculateDistanceTransform } from "./MaskPixelUtils.js"; import { withErrorHandling, createValidationError } from "../ErrorHandler.js"; const log = createModuleLogger('ImageAnalysis'); @@ -115,90 +116,6 @@ export const createDistanceFieldMask = withErrorHandling(function(image: HTMLIma return createDistanceFieldMaskSync(image, blendArea); }, 'createDistanceFieldMask'); -/** - * Calculates the Euclidean distance transform of a binary mask. - * Uses a two-pass algorithm for efficiency. - * @param binaryMask - Binary mask where 1 = inside, 0 = outside - * @param width - Width of the mask - * @param height - Height of the mask - * @returns Float32Array containing distance values - */ -function calculateDistanceTransform(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; -} - /** * Calculates distance from edges of a rectangle for opaque images. * @param width - Width of the rectangle diff --git a/src/utils/MaskPixelUtils.ts b/src/utils/MaskPixelUtils.ts new file mode 100644 index 0000000..9df771b --- /dev/null +++ b/src/utils/MaskPixelUtils.ts @@ -0,0 +1,83 @@ +/** + * Calculates the Euclidean distance transform of a binary mask. + * Uses a two-pass algorithm for efficiency. + * @param binaryMask - Binary mask where 1 = inside, 0 = outside + * @param width - Width of the mask + * @param height - Height of the mask + * @returns Float32Array containing distance values + */ +export function calculateDistanceTransform(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; +} diff --git a/tests/test_mask_pixel_characterization.mjs b/tests/test_mask_pixel_characterization.mjs new file mode 100644 index 0000000..8033364 --- /dev/null +++ b/tests/test_mask_pixel_characterization.mjs @@ -0,0 +1,86 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; + +function installCanvasStub(sourceAlpha) { + const originalDocument = Object.getOwnPropertyDescriptor(globalThis, 'document'); + const output = { data: null }; + const context = { + clearRect() {}, + createImageData(width, height) { + return { width, height, data: new Uint8ClampedArray(width * height * 4) }; + }, + drawImage() {}, + getImageData(_x, _y, width, height) { + const data = new Uint8ClampedArray(width * height * 4); + for (let i = 0; i < sourceAlpha.length; i++) { + data[i * 4 + 3] = sourceAlpha[i]; + } + return { width, height, data }; + }, + putImageData(imageData) { + output.data = imageData.data; + }, + }; + const canvas = { + height: 0, + width: 0, + getContext() { + return context; + }, + }; + + Object.defineProperty(globalThis, 'document', { + configurable: true, + value: { + createElement(tagName) { + assert.equal(tagName, 'canvas'); + return canvas; + }, + }, + }); + + return { + output, + restore() { + if (originalDocument) { + Object.defineProperty(globalThis, 'document', originalDocument); + } else { + delete globalThis.document; + } + }, + }; +} + +test('distance-field mask preserves alpha-distance behavior for transparent pixels', async () => { + const stubs = installCanvasStub([255, 128, 0]); + + try { + const { createDistanceFieldMaskSync } = await import('../js/utils/ImageAnalysis.js?characterization'); + createDistanceFieldMaskSync({ width: 3, height: 1 }, 100); + + assert.deepEqual([...stubs.output.data], [ + 255, 255, 255, 255, + 255, 255, 255, 127, + 255, 255, 255, 0, + ]); + } finally { + stubs.restore(); + } +}); + +test('mask consumers use one shared distance transform implementation', async () => { + const { calculateDistanceTransform } = await import('../js/utils/MaskPixelUtils.js?characterization'); + const imageAnalysisSource = await readFile(new URL('../src/utils/ImageAnalysis.ts', import.meta.url), 'utf8'); + const maskToolSource = await readFile(new URL('../src/MaskTool.ts', import.meta.url), 'utf8'); + + assert.deepEqual( + [...calculateDistanceTransform(new Uint8Array([1, 1, 0]), 3, 1)], + [2, 1, 0], + ); + assert.match(imageAnalysisSource, /from "\.\/MaskPixelUtils\.js"/); + assert.match(maskToolSource, /from "\.\/utils\/MaskPixelUtils\.js"/); + assert.match(maskToolSource, /calculateDistanceTransform\(binaryData, width, height\)/); + assert.doesNotMatch(imageAnalysisSource, /function calculateDistanceTransform\(/); + assert.doesNotMatch(maskToolSource, /private _fastDistanceTransform\(/); +});