diff --git a/js/MaskTool.js b/js/MaskTool.js index 04a91f9..477ecd5 100644 --- a/js/MaskTool.js +++ b/js/MaskTool.js @@ -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; } /** diff --git a/js/utils/ImageAnalysis.js b/js/utils/ImageAnalysis.js index 39192e3..ed5ce5a 100644 --- a/js/utils/ImageAnalysis.js +++ b/js/utils/ImageAnalysis.js @@ -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); diff --git a/js/utils/MaskPixelUtils.js b/js/utils/MaskPixelUtils.js index bfd62bd..532b788 100644 --- a/js/utils/MaskPixelUtils.js +++ b/js/utils/MaskPixelUtils.js @@ -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. diff --git a/src/MaskTool.ts b/src/MaskTool.ts index 8029dec..3afc8cc 100644 --- a/src/MaskTool.ts +++ b/src/MaskTool.ts @@ -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; } diff --git a/src/utils/ImageAnalysis.ts b/src/utils/ImageAnalysis.ts index d7da2bb..c2bd234 100644 --- a/src/utils/ImageAnalysis.ts +++ b/src/utils/ImageAnalysis.ts @@ -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); diff --git a/src/utils/MaskPixelUtils.ts b/src/utils/MaskPixelUtils.ts index eed6662..f0daa24 100644 --- a/src/utils/MaskPixelUtils.ts +++ b/src/utils/MaskPixelUtils.ts @@ -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. diff --git a/tests/test_mask_pixel_characterization.mjs b/tests/test_mask_pixel_characterization.mjs index d4d3107..2b457ee 100644 --- a/tests/test_mask_pixel_characterization.mjs +++ b/tests/test_mask_pixel_characterization.mjs @@ -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');