refactor: centralize mask pixel primitives

This commit is contained in:
Dariusz L
2026-08-13 05:33:47 +02:00
parent 42c4de43e4
commit f60b7c0d8d
9 changed files with 293 additions and 97 deletions
+2 -6
View File
@@ -3,6 +3,7 @@ import { createModuleLogger } from "./log_system/log_funcs.js";
import { showErrorNotification } from "./utils/NotificationUtils.js";
import { webSocketManager } from "./utils/WebSocketManager.js";
import { scaleImageToFit, loadImage, tensorToImageData, createImageFromImageData } from "./utils/ImageUtils.js";
import { fillInverseAlphaMask } from "./utils/MaskPixelUtils.js";
const log = createModuleLogger('CanvasIO');
export class CanvasIO {
constructor(canvas) {
@@ -68,12 +69,7 @@ export class CanvasIO {
log.debug(`Finished rendering layers`);
const visibilityData = visibilityCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
const maskData = maskCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
for (let i = 0; i < visibilityData.data.length; i += 4) {
const alpha = visibilityData.data[i + 3];
const maskValue = 255 - alpha;
maskData.data[i] = maskData.data[i + 1] = maskData.data[i + 2] = maskValue;
maskData.data[i + 3] = 255;
}
fillInverseAlphaMask(visibilityData, maskData);
maskCtx.putImageData(maskData, 0, 0);
this.canvas.outputAreaShape = originalShape;
// Use optimized getMaskForOutputArea() instead of getMask() for better performance
+2 -6
View File
@@ -10,6 +10,7 @@ import { app } from "../../scripts/app.js";
import { ComfyApp } from "../../scripts/app.js";
import { ClipboardManager } from "./utils/ClipboardManager.js";
import { createDistanceFieldMaskSync } from "./utils/ImageAnalysis.js";
import { fillInverseAlphaMask } from "./utils/MaskPixelUtils.js";
const log = createModuleLogger('CanvasLayers');
export class CanvasLayers {
constructor(canvas) {
@@ -1538,12 +1539,7 @@ export class CanvasLayers {
// Konwertuj przezroczystość warstw na maskę
const visibilityData = visibilityCtx.getImageData(0, 0, bounds.width, bounds.height);
const maskData = maskCtx.getImageData(0, 0, bounds.width, bounds.height);
for (let i = 0; i < visibilityData.data.length; i += 4) {
const alpha = visibilityData.data[i + 3];
const maskValue = 255 - alpha; // Odwróć alpha żeby stworzyć maskę
maskData.data[i] = maskData.data[i + 1] = maskData.data[i + 2] = maskValue;
maskData.data[i + 3] = 255; // Solidna maska
}
fillInverseAlphaMask(visibilityData, maskData);
maskCtx.putImageData(maskData, 0, 0);
// Aplikuj maskę narzędzia jeśli istnieje - używaj zoptymalizowanej metody
const toolMaskCanvas = this.canvas.maskTool.getMaskForOutputArea();
+6 -35
View File
@@ -1,6 +1,6 @@
import { createModuleLogger } from "./log_system/log_funcs.js";
import { createCanvas, createCanvasWithContext } from "./utils/CommonUtils.js";
import { calculateDistanceTransform } from "./utils/MaskPixelUtils.js";
import { applyLuminanceAsAlpha, calculateDistanceTransform, imageDataToBinaryMask } from "./utils/MaskPixelUtils.js";
const log = createModuleLogger('Mask_tool');
export class MaskTool {
constructor(canvasInstance, callbacks = {}) {
@@ -438,11 +438,7 @@ export class MaskTool {
const { canvas, ctx } = createCanvasWithContext(width, height);
this.drawShapeOnCanvas(ctx, points);
const maskImage = ctx.getImageData(0, 0, width, height);
const binaryData = new Uint8Array(width * height);
for (let i = 0; i < binaryData.length; i++) {
binaryData[i] = maskImage.data[i * 4] > 0 ? 1 : 0;
}
return binaryData;
return imageDataToBinaryMask(maskImage, width, height, 0);
}
/**
* Creates output canvas with image data
@@ -1235,10 +1231,7 @@ export class MaskTool {
tempCtx.fill('evenodd'); // Use evenodd to handle holes correctly
}
const maskImage = tempCtx.getImageData(0, 0, width, height);
const binaryData = new Uint8Array(width * height);
for (let i = 0; i < binaryData.length; i++) {
binaryData[i] = maskImage.data[i * 4] > 0 ? 1 : 0;
}
const binaryData = imageDataToBinaryMask(maskImage, width, height, 0);
let resultMask;
const scaledExpansionValue = Math.round(Math.abs(expansionValue * zoom));
if (expansionValue >= 0) {
@@ -1466,15 +1459,7 @@ export class MaskTool {
throw new Error("Could not create mask processing context");
ctx.drawImage(image, 0, 0);
const imgData = ctx.getImageData(0, 0, image.width, image.height);
const data = imgData.data;
for (let i = 0; i < data.length; i += 4) {
const r = data[i], g = data[i + 1], b = data[i + 2];
const lum = Math.round(0.299 * r + 0.587 * g + 0.114 * b);
data[i] = 255; // force white color (color channels ignored downstream)
data[i + 1] = 255;
data[i + 2] = 255;
data[i + 3] = lum; // alpha encodes mask strength: white -> strong, black -> 0
}
applyLuminanceAsAlpha(imgData);
ctx.putImageData(imgData, 0, 0);
// Clear target area and apply to chunked system at centered position
this.clearMaskInArea(centerX, centerY, image.width, image.height);
@@ -1527,16 +1512,7 @@ export class MaskTool {
}
sourceMaskCtx.drawImage(image, 0, 0, sourceWidth, sourceHeight);
const sourceImageData = sourceMaskCtx.getImageData(0, 0, sourceWidth, sourceHeight);
const sourceData = sourceImageData.data;
for (let i = 0; i < sourceData.length; i += 4) {
const luminance = Math.round(0.299 * sourceData[i] +
0.587 * sourceData[i + 1] +
0.114 * sourceData[i + 2]);
sourceData[i] = 255;
sourceData[i + 1] = 255;
sourceData[i + 2] = 255;
sourceData[i + 3] = luminance;
}
applyLuminanceAsAlpha(sourceImageData);
sourceMaskCtx.putImageData(sourceImageData, 0, 0);
ctx.save();
ctx.translate(maskWidth / 2, maskHeight / 2);
@@ -1852,12 +1828,7 @@ export class MaskTool {
* Creates a feathered mask from existing ImageData (used when combining expansion + feather)
*/
_createFeatheredMaskFromImageData(imageData, featherRadius, width, height) {
const data = imageData.data;
const binaryData = new Uint8Array(width * height);
// Convert ImageData to binary mask
for (let i = 0; i < width * height; i++) {
binaryData[i] = data[i * 4 + 3] > 0 ? 1 : 0; // 1 = inside, 0 = outside
}
const binaryData = imageDataToBinaryMask(imageData, width, height, 3);
// Use unified feathering logic
return this.createFeatheredMaskFromBinaryData(binaryData, featherRadius, width, height);
}
+37
View File
@@ -1,3 +1,40 @@
/**
* Converts RGB luminance into an opaque white mask with luminance as alpha.
*/
export function applyLuminanceAsAlpha(imageData) {
const data = imageData.data;
for (let i = 0; i < data.length; i += 4) {
const luminance = Math.round(0.299 * data[i] +
0.587 * data[i + 1] +
0.114 * data[i + 2]);
data[i] = 255;
data[i + 1] = 255;
data[i + 2] = 255;
data[i + 3] = luminance;
}
}
/**
* Fills an opaque white mask from the inverse alpha of visibility data.
*/
export function fillInverseAlphaMask(visibilityData, maskData) {
for (let i = 0; i < visibilityData.data.length; i += 4) {
const maskValue = 255 - visibilityData.data[i + 3];
maskData.data[i] = maskValue;
maskData.data[i + 1] = maskValue;
maskData.data[i + 2] = maskValue;
maskData.data[i + 3] = 255;
}
}
/**
* Converts one ImageData channel into a binary mask.
*/
export function imageDataToBinaryMask(imageData, width, height, channel) {
const binaryMask = new Uint8Array(width * height);
for (let i = 0; i < binaryMask.length; i++) {
binaryMask[i] = imageData.data[i * 4 + channel] > 0 ? 1 : 0;
}
return binaryMask;
}
/**
* Calculates the Euclidean distance transform of a binary mask.
* Uses a two-pass algorithm for efficiency.
+2 -6
View File
@@ -3,6 +3,7 @@ import { createModuleLogger } from "./log_system/log_funcs.js";
import { showErrorNotification } from "./utils/NotificationUtils.js";
import { webSocketManager } from "./utils/WebSocketManager.js";
import { scaleImageToFit, loadImage, tensorToImageData, createImageFromImageData } from "./utils/ImageUtils.js";
import { fillInverseAlphaMask } from "./utils/MaskPixelUtils.js";
import type { Canvas } from './Canvas';
import type { Layer, Shape } from './types';
@@ -80,12 +81,7 @@ export class CanvasIO {
log.debug(`Finished rendering layers`);
const visibilityData = visibilityCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
const maskData = maskCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
for (let i = 0; i < visibilityData.data.length; i += 4) {
const alpha = visibilityData.data[i + 3];
const maskValue = 255 - alpha;
maskData.data[i] = maskData.data[i + 1] = maskData.data[i + 2] = maskValue;
maskData.data[i + 3] = 255;
}
fillInverseAlphaMask(visibilityData, maskData);
maskCtx.putImageData(maskData, 0, 0);
+2 -6
View File
@@ -20,6 +20,7 @@ import {app} from "../../scripts/app.js";
import {ComfyApp} from "../../scripts/app.js";
import { ClipboardManager } from "./utils/ClipboardManager.js";
import { createDistanceFieldMaskSync } from "./utils/ImageAnalysis.js";
import { fillInverseAlphaMask } from "./utils/MaskPixelUtils.js";
import type { Canvas } from './Canvas';
import type { Layer, Point, AddMode, ClipboardPreference } from './types';
@@ -1807,12 +1808,7 @@ export class CanvasLayers {
// Konwertuj przezroczystość warstw na maskę
const visibilityData = visibilityCtx.getImageData(0, 0, bounds.width, bounds.height);
const maskData = maskCtx.getImageData(0, 0, bounds.width, bounds.height);
for (let i = 0; i < visibilityData.data.length; i += 4) {
const alpha = visibilityData.data[i + 3];
const maskValue = 255 - alpha; // Odwróć alpha żeby stworzyć maskę
maskData.data[i] = maskData.data[i + 1] = maskData.data[i + 2] = maskValue;
maskData.data[i + 3] = 255; // Solidna maska
}
fillInverseAlphaMask(visibilityData, maskData);
maskCtx.putImageData(maskData, 0, 0);
// Aplikuj maskę narzędzia jeśli istnieje - używaj zoptymalizowanej metody
+10 -38
View File
@@ -1,6 +1,10 @@
import {createModuleLogger} from "./log_system/log_funcs.js";
import { createCanvas, createCanvasWithContext } from "./utils/CommonUtils.js";
import { calculateDistanceTransform } from "./utils/MaskPixelUtils.js";
import {
applyLuminanceAsAlpha,
calculateDistanceTransform,
imageDataToBinaryMask
} from "./utils/MaskPixelUtils.js";
import type { Canvas } from './Canvas';
import type { Point, CanvasState, Layer } from './types';
@@ -591,11 +595,7 @@ export class MaskTool {
this.drawShapeOnCanvas(ctx, points);
const maskImage = ctx.getImageData(0, 0, width, height);
const binaryData = new Uint8Array(width * height);
for (let i = 0; i < binaryData.length; i++) {
binaryData[i] = maskImage.data[i * 4] > 0 ? 1 : 0;
}
return binaryData;
return imageDataToBinaryMask(maskImage, width, height, 0);
}
/**
@@ -1533,10 +1533,7 @@ export class MaskTool {
}
const maskImage = tempCtx.getImageData(0, 0, width, height);
const binaryData = new Uint8Array(width * height);
for (let i = 0; i < binaryData.length; i++) {
binaryData[i] = maskImage.data[i * 4] > 0 ? 1 : 0;
}
const binaryData = imageDataToBinaryMask(maskImage, width, height, 0);
let resultMask: Uint8Array;
const scaledExpansionValue = Math.round(Math.abs(expansionValue * zoom));
@@ -1819,15 +1816,7 @@ export class MaskTool {
ctx.drawImage(image, 0, 0);
const imgData = ctx.getImageData(0, 0, image.width, image.height);
const data = imgData.data;
for (let i = 0; i < data.length; i += 4) {
const r = data[i], g = data[i + 1], b = data[i + 2];
const lum = Math.round(0.299 * r + 0.587 * g + 0.114 * b);
data[i] = 255; // force white color (color channels ignored downstream)
data[i + 1] = 255;
data[i + 2] = 255;
data[i + 3] = lum; // alpha encodes mask strength: white -> strong, black -> 0
}
applyLuminanceAsAlpha(imgData);
ctx.putImageData(imgData, 0, 0);
// Clear target area and apply to chunked system at centered position
@@ -1887,18 +1876,7 @@ export class MaskTool {
sourceMaskCtx.drawImage(image, 0, 0, sourceWidth, sourceHeight);
const sourceImageData = sourceMaskCtx.getImageData(0, 0, sourceWidth, sourceHeight);
const sourceData = sourceImageData.data;
for (let i = 0; i < sourceData.length; i += 4) {
const luminance = Math.round(
0.299 * sourceData[i] +
0.587 * sourceData[i + 1] +
0.114 * sourceData[i + 2],
);
sourceData[i] = 255;
sourceData[i + 1] = 255;
sourceData[i + 2] = 255;
sourceData[i + 3] = luminance;
}
applyLuminanceAsAlpha(sourceImageData);
sourceMaskCtx.putImageData(sourceImageData, 0, 0);
ctx.save();
@@ -2297,13 +2275,7 @@ export class MaskTool {
* Creates a feathered mask from existing ImageData (used when combining expansion + feather)
*/
private _createFeatheredMaskFromImageData(imageData: ImageData, featherRadius: number, width: number, height: number): HTMLCanvasElement {
const data = imageData.data;
const binaryData = new Uint8Array(width * height);
// Convert ImageData to binary mask
for (let i = 0; i < width * height; i++) {
binaryData[i] = data[i * 4 + 3] > 0 ? 1 : 0; // 1 = inside, 0 = outside
}
const binaryData = imageDataToBinaryMask(imageData, width, height, 3);
// Use unified feathering logic
return this.createFeatheredMaskFromBinaryData(binaryData, featherRadius, width, height);
+42
View File
@@ -1,3 +1,45 @@
/**
* Converts RGB luminance into an opaque white mask with luminance as alpha.
*/
export function applyLuminanceAsAlpha(imageData: ImageData): void {
const data = imageData.data;
for (let i = 0; i < data.length; i += 4) {
const luminance = Math.round(
0.299 * data[i] +
0.587 * data[i + 1] +
0.114 * data[i + 2]
);
data[i] = 255;
data[i + 1] = 255;
data[i + 2] = 255;
data[i + 3] = luminance;
}
}
/**
* Fills an opaque white mask from the inverse alpha of visibility data.
*/
export function fillInverseAlphaMask(visibilityData: ImageData, maskData: ImageData): void {
for (let i = 0; i < visibilityData.data.length; i += 4) {
const maskValue = 255 - visibilityData.data[i + 3];
maskData.data[i] = maskValue;
maskData.data[i + 1] = maskValue;
maskData.data[i + 2] = maskValue;
maskData.data[i + 3] = 255;
}
}
/**
* Converts one ImageData channel into a binary mask.
*/
export function imageDataToBinaryMask(imageData: ImageData, width: number, height: number, channel: 0 | 3): Uint8Array {
const binaryMask = new Uint8Array(width * height);
for (let i = 0; i < binaryMask.length; i++) {
binaryMask[i] = imageData.data[i * 4 + channel] > 0 ? 1 : 0;
}
return binaryMask;
}
/**
* Calculates the Euclidean distance transform of a binary mask.
* Uses a two-pass algorithm for efficiency.
+190
View File
@@ -2,6 +2,13 @@ import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
import { MaskTool } from '../js/MaskTool.js';
import {
applyLuminanceAsAlpha,
fillInverseAlphaMask,
imageDataToBinaryMask,
} from '../js/utils/MaskPixelUtils.js';
function installCanvasStub(sourceAlpha) {
const originalDocument = Object.getOwnPropertyDescriptor(globalThis, 'document');
const output = { data: null };
@@ -52,6 +59,58 @@ function installCanvasStub(sourceAlpha) {
};
}
function installPixelCanvasStub(sourcePixels) {
const originalDocument = Object.getOwnPropertyDescriptor(globalThis, 'document');
const outputs = [];
const context = {
beginPath() {},
closePath() {},
drawImage() {},
fill() {},
getImageData(_x, _y, width, height) {
return { width, height, data: new Uint8ClampedArray(sourcePixels) };
},
lineTo() {},
moveTo() {},
putImageData(imageData) {
outputs.push([...imageData.data]);
},
restore() {},
rotate() {},
save() {},
scale() {},
translate() {},
};
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 {
outputs,
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]);
@@ -81,6 +140,137 @@ 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(maskToolSource, /applyLuminanceAsAlpha\(imgData\)/);
assert.match(maskToolSource, /applyLuminanceAsAlpha\(sourceImageData\)/);
assert.match(maskToolSource, /imageDataToBinaryMask\(maskImage, width, height, 0\)/);
assert.match(maskToolSource, /imageDataToBinaryMask\(imageData, width, height, 3\)/);
assert.doesNotMatch(imageAnalysisSource, /function calculateDistanceTransform\(/);
assert.doesNotMatch(maskToolSource, /private _fastDistanceTransform\(/);
});
test('shared mask pixel helpers preserve luminance, inverse alpha, and explicit channels', () => {
const imageData = {
width: 2,
height: 1,
data: new Uint8ClampedArray([
255, 0, 0, 0,
0, 255, 0, 255,
]),
};
applyLuminanceAsAlpha(imageData);
assert.deepEqual([...imageData.data], [255, 255, 255, 76, 255, 255, 255, 150]);
const visibilityData = {
width: 3,
height: 1,
data: new Uint8ClampedArray([
0, 0, 0, 0,
0, 0, 0, 128,
0, 0, 0, 255,
]),
};
const maskData = {
width: 3,
height: 1,
data: new Uint8ClampedArray(12),
};
fillInverseAlphaMask(visibilityData, maskData);
assert.deepEqual([...maskData.data], [
255, 255, 255, 255,
127, 127, 127, 255,
0, 0, 0, 255,
]);
const channelData = {
width: 2,
height: 1,
data: new Uint8ClampedArray([
255, 0, 0, 0,
0, 0, 0, 255,
]),
};
assert.deepEqual([...imageDataToBinaryMask(channelData, 2, 1, 0)], [1, 0]);
assert.deepEqual([...imageDataToBinaryMask(channelData, 2, 1, 3)], [0, 1]);
});
test('MaskTool converts shape pixels through the existing red-channel binary contract', () => {
const stubs = installPixelCanvasStub([
0, 255, 0, 0,
255, 0, 0, 0,
]);
const maskTool = Object.create(MaskTool.prototype);
try {
assert.deepEqual(
[...maskTool.createBinaryMaskFromShape([{ x: 0, y: 0 }], 2, 1)],
[0, 1]
);
} finally {
stubs.restore();
}
});
test('MaskTool preserves luminance-to-alpha behavior for input and layer masks', () => {
const pixels = [
0, 0, 0, 255,
255, 0, 0, 255,
0, 255, 0, 255,
0, 0, 255, 255,
];
const expected = [
255, 255, 255, 0,
255, 255, 255, 76,
255, 255, 255, 150,
255, 255, 255, 29,
];
const inputStubs = installPixelCanvasStub(pixels);
const inputMaskTool = Object.create(MaskTool.prototype);
inputMaskTool.canvasInstance = {
outputAreaBounds: { x: 0, y: 0, width: 2, height: 2 },
canvasState: { saveMaskState() {} },
render() {},
};
inputMaskTool.clearMaskInArea = () => {};
inputMaskTool.applyMaskCanvasToChunks = () => {};
inputMaskTool.updateActiveMaskCanvas = () => {};
try {
inputMaskTool.setMask({ width: 2, height: 2 }, true);
assert.deepEqual(inputStubs.outputs[0], expected);
} finally {
inputStubs.restore();
}
const layerStubs = installPixelCanvasStub(pixels);
const layerMaskTool = Object.create(MaskTool.prototype);
layerMaskTool.canvasInstance = {
canvasState: { saveMaskState() {} },
render() {},
};
layerMaskTool.clearMaskInArea = () => {};
layerMaskTool.applyMaskCanvasToChunks = () => {};
layerMaskTool.updateActiveMaskCanvas = () => {};
try {
layerMaskTool.setMaskForLayer(
{ width: 2, height: 2, naturalWidth: 2, naturalHeight: 2 },
{ x: 0, y: 0, width: 2, height: 2, originalWidth: 2, originalHeight: 2, rotation: 0 }
);
assert.deepEqual(layerStubs.outputs[0], expected);
} finally {
layerStubs.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');
assert.match(canvasIOSource, /from "\.\/utils\/MaskPixelUtils\.js"/);
assert.match(canvasLayersSource, /from "\.\/utils\/MaskPixelUtils\.js"/);
assert.match(canvasIOSource, /fillInverseAlphaMask\(visibilityData, maskData\)/);
assert.match(canvasLayersSource, /fillInverseAlphaMask\(visibilityData, maskData\)/);
assert.doesNotMatch(canvasIOSource, /const maskValue = 255 - alpha/);
assert.doesNotMatch(canvasLayersSource, /const maskValue = 255 - alpha/);
});