diff --git a/js/CanvasIO.js b/js/CanvasIO.js index 88b8937..86fde89 100644 --- a/js/CanvasIO.js +++ b/js/CanvasIO.js @@ -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 diff --git a/js/CanvasLayers.js b/js/CanvasLayers.js index c1cc2e2..861849b 100644 --- a/js/CanvasLayers.js +++ b/js/CanvasLayers.js @@ -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(); diff --git a/js/MaskTool.js b/js/MaskTool.js index bc3a093..04a91f9 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 { 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); } diff --git a/js/utils/MaskPixelUtils.js b/js/utils/MaskPixelUtils.js index 8c96513..bfd62bd 100644 --- a/js/utils/MaskPixelUtils.js +++ b/js/utils/MaskPixelUtils.js @@ -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. diff --git a/src/CanvasIO.ts b/src/CanvasIO.ts index 78e5e8f..783e728 100644 --- a/src/CanvasIO.ts +++ b/src/CanvasIO.ts @@ -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); diff --git a/src/CanvasLayers.ts b/src/CanvasLayers.ts index d1eaee7..beed5e5 100644 --- a/src/CanvasLayers.ts +++ b/src/CanvasLayers.ts @@ -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 diff --git a/src/MaskTool.ts b/src/MaskTool.ts index 64942b9..8029dec 100644 --- a/src/MaskTool.ts +++ b/src/MaskTool.ts @@ -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); diff --git a/src/utils/MaskPixelUtils.ts b/src/utils/MaskPixelUtils.ts index 9df771b..eed6662 100644 --- a/src/utils/MaskPixelUtils.ts +++ b/src/utils/MaskPixelUtils.ts @@ -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. diff --git a/tests/test_mask_pixel_characterization.mjs b/tests/test_mask_pixel_characterization.mjs index 8033364..d4d3107 100644 --- a/tests/test_mask_pixel_characterization.mjs +++ b/tests/test_mask_pixel_characterization.mjs @@ -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/); +});