refactor: share distance-field mask rasterization

This commit is contained in:
Dariusz L
2026-08-13 05:43:15 +02:00
parent 212d6b7728
commit c461da1481
7 changed files with 133 additions and 114 deletions
+2 -28
View File
@@ -1,6 +1,6 @@
import { createModuleLogger } from "./log_system/log_funcs.js";
import { createCanvas, createCanvasWithContext } from "./utils/CommonUtils.js";
import { applyLuminanceAsAlpha, calculateDistanceTransform, imageDataToBinaryMask } from "./utils/MaskPixelUtils.js";
import { applyLuminanceAsAlpha, calculateDistanceTransform, imageDataToBinaryMask, rasterizeDistanceFieldMask } from "./utils/MaskPixelUtils.js";
const log = createModuleLogger('Mask_tool');
export class MaskTool {
constructor(canvasInstance, callbacks = {}) {
@@ -500,33 +500,7 @@ export class MaskTool {
const outputData = tempCtx.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 isInside = binaryData[i] === 1;
if (!isInside) {
// 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;
}
}
rasterizeDistanceFieldMask(distanceMap, binaryData, threshold, outputData.data);
return outputData;
}
/**
+4 -29
View File
@@ -1,6 +1,6 @@
import { createModuleLogger } from "../log_system/log_funcs.js";
import { createCanvas } from "./CommonUtils.js";
import { calculateDistanceTransform } from "./MaskPixelUtils.js";
import { calculateDistanceTransform, rasterizeDistanceFieldMask } from "./MaskPixelUtils.js";
import { withErrorHandling, createValidationError } from "../ErrorHandler.js";
const log = createModuleLogger('ImageAnalysis');
/**
@@ -42,10 +42,11 @@ export function createDistanceFieldMaskSync(image, blendArea) {
}
}
let distanceField;
let binaryMask = null;
let maxDistance;
if (hasTransparency) {
// For images with transparency, use alpha-based distance transform
const binaryMask = new Uint8Array(width * height);
binaryMask = new Uint8Array(width * height);
for (let i = 0; i < width * height; i++) {
binaryMask[i] = data[i * 4 + 3] > 0 ? 1 : 0;
}
@@ -65,33 +66,7 @@ export function createDistanceFieldMaskSync(image, blendArea) {
// Create the gradient mask based on blendArea
const maskData = ctx.createImageData(width, height);
const threshold = maxDistance * (blendArea / 100);
for (let i = 0; i < width * height; i++) {
const distance = distanceField[i];
const alpha = data[i * 4 + 3];
if (alpha === 0) {
// Transparent pixels remain transparent
maskData.data[i * 4] = 255;
maskData.data[i * 4 + 1] = 255;
maskData.data[i * 4 + 2] = 255;
maskData.data[i * 4 + 3] = 0;
}
else if (distance <= threshold) {
// Edge area - apply gradient alpha
const gradientValue = distance / threshold;
const alphaValue = Math.floor(gradientValue * 255);
maskData.data[i * 4] = 255;
maskData.data[i * 4 + 1] = 255;
maskData.data[i * 4 + 2] = 255;
maskData.data[i * 4 + 3] = alphaValue;
}
else {
// Inner area - full alpha (no blending effect)
maskData.data[i * 4] = 255;
maskData.data[i * 4 + 1] = 255;
maskData.data[i * 4 + 2] = 255;
maskData.data[i * 4 + 3] = 255;
}
}
rasterizeDistanceFieldMask(distanceField, binaryMask, threshold, maskData.data);
// Clear canvas and put the mask data
ctx.clearRect(0, 0, width, height);
ctx.putImageData(maskData, 0, 0);
+24
View File
@@ -35,6 +35,30 @@ export function imageDataToBinaryMask(imageData, width, height, channel) {
}
return binaryMask;
}
/**
* Rasterizes a distance field into a white RGBA mask with feathered alpha.
* A null binary mask means every pixel is inside the mask.
*/
export function rasterizeDistanceFieldMask(distanceMap, binaryMask, threshold, outputData) {
for (let i = 0; i < distanceMap.length; i++) {
const distance = distanceMap[i];
const isInside = binaryMask === null || binaryMask[i] === 1;
const pixelIndex = i * 4;
outputData[pixelIndex] = 255;
outputData[pixelIndex + 1] = 255;
outputData[pixelIndex + 2] = 255;
if (!isInside) {
outputData[pixelIndex + 3] = 0;
}
else if (distance <= threshold) {
const gradientValue = distance / threshold;
outputData[pixelIndex + 3] = Math.floor(gradientValue * 255);
}
else {
outputData[pixelIndex + 3] = 255;
}
}
}
/**
* Calculates the Euclidean distance transform of a binary mask.
* Uses a two-pass algorithm for efficiency.
+3 -28
View File
@@ -3,7 +3,8 @@ import { createCanvas, createCanvasWithContext } from "./utils/CommonUtils.js";
import {
applyLuminanceAsAlpha,
calculateDistanceTransform,
imageDataToBinaryMask
imageDataToBinaryMask,
rasterizeDistanceFieldMask
} from "./utils/MaskPixelUtils.js";
import type { Canvas } from './Canvas';
import type { Point, CanvasState, Layer } from './types';
@@ -664,33 +665,7 @@ export class MaskTool {
// 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 isInside = binaryData[i] === 1;
if (!isInside) {
// 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;
}
}
rasterizeDistanceFieldMask(distanceMap, binaryData, threshold, outputData.data);
return outputData;
}
+4 -29
View File
@@ -1,6 +1,6 @@
import { createModuleLogger } from "../log_system/log_funcs.js";
import { createCanvas } from "./CommonUtils.js";
import { calculateDistanceTransform } from "./MaskPixelUtils.js";
import { calculateDistanceTransform, rasterizeDistanceFieldMask } from "./MaskPixelUtils.js";
import { withErrorHandling, createValidationError } from "../ErrorHandler.js";
const log = createModuleLogger('ImageAnalysis');
@@ -49,11 +49,12 @@ export function createDistanceFieldMaskSync(image: HTMLImageElement, blendArea:
}
let distanceField: Float32Array;
let binaryMask: Uint8Array | null = null;
let maxDistance: number;
if (hasTransparency) {
// For images with transparency, use alpha-based distance transform
const binaryMask = new Uint8Array(width * height);
binaryMask = new Uint8Array(width * height);
for (let i = 0; i < width * height; i++) {
binaryMask[i] = data[i * 4 + 3] > 0 ? 1 : 0;
}
@@ -74,33 +75,7 @@ export function createDistanceFieldMaskSync(image: HTMLImageElement, blendArea:
// Create the gradient mask based on blendArea
const maskData = ctx.createImageData(width, height);
const threshold = maxDistance * (blendArea / 100);
for (let i = 0; i < width * height; i++) {
const distance = distanceField[i];
const alpha = data[i * 4 + 3];
if (alpha === 0) {
// Transparent pixels remain transparent
maskData.data[i * 4] = 255;
maskData.data[i * 4 + 1] = 255;
maskData.data[i * 4 + 2] = 255;
maskData.data[i * 4 + 3] = 0;
} else if (distance <= threshold) {
// Edge area - apply gradient alpha
const gradientValue = distance / threshold;
const alphaValue = Math.floor(gradientValue * 255);
maskData.data[i * 4] = 255;
maskData.data[i * 4 + 1] = 255;
maskData.data[i * 4 + 2] = 255;
maskData.data[i * 4 + 3] = alphaValue;
} else {
// Inner area - full alpha (no blending effect)
maskData.data[i * 4] = 255;
maskData.data[i * 4 + 1] = 255;
maskData.data[i * 4 + 2] = 255;
maskData.data[i * 4 + 3] = 255;
}
}
rasterizeDistanceFieldMask(distanceField, binaryMask, threshold, maskData.data);
// Clear canvas and put the mask data
ctx.clearRect(0, 0, width, height);
+30
View File
@@ -40,6 +40,36 @@ export function imageDataToBinaryMask(imageData: ImageData, width: number, heigh
return binaryMask;
}
/**
* Rasterizes a distance field into a white RGBA mask with feathered alpha.
* A null binary mask means every pixel is inside the mask.
*/
export function rasterizeDistanceFieldMask(
distanceMap: Float32Array,
binaryMask: Uint8Array | null,
threshold: number,
outputData: Uint8ClampedArray
): void {
for (let i = 0; i < distanceMap.length; i++) {
const distance = distanceMap[i];
const isInside = binaryMask === null || binaryMask[i] === 1;
const pixelIndex = i * 4;
outputData[pixelIndex] = 255;
outputData[pixelIndex + 1] = 255;
outputData[pixelIndex + 2] = 255;
if (!isInside) {
outputData[pixelIndex + 3] = 0;
} else if (distance <= threshold) {
const gradientValue = distance / threshold;
outputData[pixelIndex + 3] = Math.floor(gradientValue * 255);
} else {
outputData[pixelIndex + 3] = 255;
}
}
}
/**
* Calculates the Euclidean distance transform of a binary mask.
* Uses a two-pass algorithm for efficiency.
@@ -7,6 +7,7 @@ import {
applyLuminanceAsAlpha,
fillInverseAlphaMask,
imageDataToBinaryMask,
rasterizeDistanceFieldMask,
} from '../js/utils/MaskPixelUtils.js';
function installCanvasStub(sourceAlpha) {
@@ -140,6 +141,8 @@ test('mask consumers use one shared distance transform implementation', async ()
assert.match(imageAnalysisSource, /from "\.\/MaskPixelUtils\.js"/);
assert.match(maskToolSource, /from "\.\/utils\/MaskPixelUtils\.js"/);
assert.match(maskToolSource, /calculateDistanceTransform\(binaryData, width, height\)/);
assert.match(imageAnalysisSource, /rasterizeDistanceFieldMask\(distanceField, binaryMask, threshold, maskData\.data\)/);
assert.match(maskToolSource, /rasterizeDistanceFieldMask\(distanceMap, binaryData, threshold, outputData\.data\)/);
assert.match(maskToolSource, /applyLuminanceAsAlpha\(imgData\)/);
assert.match(maskToolSource, /applyLuminanceAsAlpha\(sourceImageData\)/);
assert.match(maskToolSource, /imageDataToBinaryMask\(maskImage, width, height, 0\)/);
@@ -193,6 +196,34 @@ test('shared mask pixel helpers preserve luminance, inverse alpha, and explicit
assert.deepEqual([...imageDataToBinaryMask(channelData, 2, 1, 3)], [0, 1]);
});
test('shared distance-field rasterizer preserves masked, opaque, and zero-threshold pixels', () => {
const outputData = new Uint8ClampedArray(16);
rasterizeDistanceFieldMask(
new Float32Array([0, 1, 2, 3]),
new Uint8Array([0, 1, 1, 1]),
2,
outputData
);
assert.deepEqual([...outputData], [
255, 255, 255, 0,
255, 255, 255, 127,
255, 255, 255, 255,
255, 255, 255, 255,
]);
const opaqueOutput = new Uint8ClampedArray(8);
rasterizeDistanceFieldMask(
new Float32Array([0, 1]),
null,
0,
opaqueOutput
);
assert.deepEqual([...opaqueOutput], [
255, 255, 255, 0,
255, 255, 255, 255,
]);
});
test('MaskTool converts shape pixels through the existing red-channel binary contract', () => {
const stubs = installPixelCanvasStub([
0, 255, 0, 0,
@@ -263,6 +294,41 @@ test('MaskTool preserves luminance-to-alpha behavior for input and layer masks',
}
});
test('MaskTool feather rasterization preserves edge interpolation and threshold-zero behavior', () => {
const stubs = installCanvasStub([]);
const maskTool = Object.create(MaskTool.prototype);
try {
const feathered = maskTool.applyFeatherToDistanceMap(
new Float32Array([0, 1, 2, 3]),
new Uint8Array([0, 1, 1, 1]),
2,
4,
1
);
assert.deepEqual([...feathered.data], [
255, 255, 255, 0,
255, 255, 255, 127,
255, 255, 255, 255,
255, 255, 255, 255,
]);
const zeroThreshold = maskTool.applyFeatherToDistanceMap(
new Float32Array([0, 1]),
new Uint8Array([1, 1]),
0,
2,
1
);
assert.deepEqual([...zeroThreshold.data], [
255, 255, 255, 0,
255, 255, 255, 255,
]);
} finally {
stubs.restore();
}
});
test('CanvasIO and CanvasLayers preserve inverse-alpha mask generation', async () => {
const canvasIOSource = await readFile(new URL('../src/CanvasIO.ts', import.meta.url), 'utf8');
const canvasLayersSource = await readFile(new URL('../src/CanvasLayers.ts', import.meta.url), 'utf8');