refactor: share mask distance transform

This commit is contained in:
Dariusz L
2026-08-13 03:42:49 +02:00
parent 73d8e696cb
commit 2ab249efde
7 changed files with 244 additions and 300 deletions
+2 -66
View File
@@ -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
+1 -69
View File
@@ -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
+69
View File
@@ -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;
}
+2 -81
View File
@@ -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
+1 -84
View File
@@ -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
+83
View File
@@ -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;
}
@@ -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\(/);
});