refactor: share distance-field mask rasterization
This commit is contained in:
+2
-28
@@ -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;
|
||||
}
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user