175 lines
7.0 KiB
JavaScript
175 lines
7.0 KiB
JavaScript
import { createModuleLogger } from "../log_system/log_funcs.js";
|
|
import { createCanvas } from "../utils/common_utils.js";
|
|
import { calculateDistanceTransform, rasterizeDistanceFieldMask } from "./mask_pixel_utils.js";
|
|
import { withErrorHandling, createValidationError } from "../shared/error_handler.js";
|
|
const log = createModuleLogger('ImageAnalysis');
|
|
export function createDistanceFieldDataSync(image) {
|
|
if (!image) {
|
|
log.error("Image is required for distance field data");
|
|
return null;
|
|
}
|
|
const { canvas: analysisCanvas, ctx: analysisCtx } = createCanvas(image.width, image.height, '2d', { willReadFrequently: true });
|
|
if (!analysisCtx) {
|
|
log.error('Failed to create canvas context for distance field data');
|
|
return null;
|
|
}
|
|
// Draw the source once. Pixel geometry does not depend on blendArea.
|
|
analysisCtx.drawImage(image, 0, 0);
|
|
const imageData = analysisCtx.getImageData(0, 0, analysisCanvas.width, analysisCanvas.height);
|
|
const data = imageData.data;
|
|
const width = analysisCanvas.width;
|
|
const height = analysisCanvas.height;
|
|
let hasTransparency = false;
|
|
for (let i = 0; i < width * height; i++) {
|
|
if (data[i * 4 + 3] < 255) {
|
|
hasTransparency = true;
|
|
break;
|
|
}
|
|
}
|
|
let distanceField = null;
|
|
let binaryMask = null;
|
|
let isOpaqueRectangle = false;
|
|
if (hasTransparency) {
|
|
binaryMask = new Uint8Array(width * height);
|
|
for (let i = 0; i < width * height; i++) {
|
|
binaryMask[i] = data[i * 4 + 3] > 0 ? 1 : 0;
|
|
}
|
|
distanceField = calculateDistanceTransform(binaryMask, width, height);
|
|
}
|
|
else {
|
|
// For a fully opaque image the distance is always the distance to
|
|
// the nearest rectangle edge. Keep this analytic instead of storing
|
|
// another 32-bit value for every pixel.
|
|
isOpaqueRectangle = true;
|
|
}
|
|
let maxDistance = 0;
|
|
if (distanceField) {
|
|
for (let i = 0; i < distanceField.length; i++) {
|
|
if (distanceField[i] > maxDistance) {
|
|
maxDistance = distanceField[i];
|
|
}
|
|
}
|
|
}
|
|
else if (isOpaqueRectangle) {
|
|
maxDistance = Math.floor((Math.min(width, height) - 1) / 2);
|
|
}
|
|
const { canvas: maskCanvas, ctx: maskCtx } = createCanvas(width, height);
|
|
if (!maskCtx) {
|
|
log.error('Failed to create canvas context for distance field mask');
|
|
return null;
|
|
}
|
|
return {
|
|
width,
|
|
height,
|
|
distanceField,
|
|
binaryMask,
|
|
maxDistance,
|
|
isOpaqueRectangle,
|
|
// Reuse this backing canvas when only blendArea changes.
|
|
maskCanvas,
|
|
maskImageData: maskCtx.createImageData(width, height)
|
|
};
|
|
}
|
|
export function rasterizeDistanceFieldMaskSync(data, blendArea) {
|
|
const { maskCanvas, width, height, distanceField, binaryMask, maxDistance } = data;
|
|
const ctx = maskCanvas.getContext('2d');
|
|
if (!ctx) {
|
|
log.error('Failed to create canvas context for distance field mask');
|
|
return maskCanvas;
|
|
}
|
|
const maskData = data.maskImageData ?? ctx.createImageData(width, height);
|
|
data.maskImageData = maskData;
|
|
const threshold = maxDistance * (blendArea / 100);
|
|
if (data.isOpaqueRectangle) {
|
|
rasterizeOpaqueRectangleMask(width, height, threshold, maskData.data);
|
|
}
|
|
else if (distanceField) {
|
|
rasterizeDistanceFieldMask(distanceField, binaryMask, threshold, maskData.data);
|
|
}
|
|
ctx.clearRect(0, 0, width, height);
|
|
ctx.putImageData(maskData, 0, 0);
|
|
return maskCanvas;
|
|
}
|
|
function rasterizeOpaqueRectangleMask(width, height, threshold, outputData) {
|
|
for (let y = 0; y < height; y++) {
|
|
for (let x = 0; x < width; x++) {
|
|
const distance = Math.min(x, width - 1 - x, y, height - 1 - y);
|
|
const pixelIndex = (y * width + x) * 4;
|
|
outputData[pixelIndex] = 255;
|
|
outputData[pixelIndex + 1] = 255;
|
|
outputData[pixelIndex + 2] = 255;
|
|
if (distance <= threshold) {
|
|
const gradientValue = distance / threshold;
|
|
outputData[pixelIndex + 3] = Math.floor(gradientValue * 255);
|
|
}
|
|
else {
|
|
outputData[pixelIndex + 3] = 255;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
/**
|
|
* Creates a distance field mask based on the alpha channel of an image.
|
|
* The mask will have gradients from the edges of visible pixels inward.
|
|
* @param image - The source image to analyze
|
|
* @param blendArea - The percentage (0-100) of the area to apply blending
|
|
* @returns HTMLCanvasElement containing the distance field mask
|
|
*/
|
|
/**
|
|
* Synchronous version of createDistanceFieldMask for use in synchronous rendering
|
|
*/
|
|
export function createDistanceFieldMaskSync(image, blendArea) {
|
|
if (!image) {
|
|
log.error("Image is required for distance field mask");
|
|
return createCanvas(1, 1).canvas;
|
|
}
|
|
if (typeof blendArea !== 'number' || blendArea < 0 || blendArea > 100) {
|
|
log.error("Blend area must be a number between 0 and 100");
|
|
return createCanvas(1, 1).canvas;
|
|
}
|
|
const data = createDistanceFieldDataSync(image);
|
|
if (!data)
|
|
return createCanvas(image.width, image.height).canvas;
|
|
return rasterizeDistanceFieldMaskSync(data, blendArea);
|
|
}
|
|
/**
|
|
* Async version with error handling for use in async contexts
|
|
*/
|
|
export const createDistanceFieldMask = withErrorHandling(function (image, blendArea) {
|
|
return createDistanceFieldMaskSync(image, blendArea);
|
|
}, 'createDistanceFieldMask');
|
|
/**
|
|
* Creates a simple radial gradient mask (fallback for rectangular areas).
|
|
* @param width - Width of the mask
|
|
* @param height - Height of the mask
|
|
* @param blendArea - The percentage (0-100) of the area to apply blending
|
|
* @returns HTMLCanvasElement containing the radial gradient mask
|
|
*/
|
|
export const createRadialGradientMask = withErrorHandling(function (width, height, blendArea) {
|
|
if (typeof width !== 'number' || width <= 0) {
|
|
throw createValidationError("Width must be a positive number", { width });
|
|
}
|
|
if (typeof height !== 'number' || height <= 0) {
|
|
throw createValidationError("Height must be a positive number", { height });
|
|
}
|
|
if (typeof blendArea !== 'number' || blendArea < 0 || blendArea > 100) {
|
|
throw createValidationError("Blend area must be a number between 0 and 100", { blendArea });
|
|
}
|
|
const { canvas, ctx } = createCanvas(width, height);
|
|
if (!ctx) {
|
|
log.error('Failed to create canvas context for radial gradient mask');
|
|
return canvas;
|
|
}
|
|
const centerX = width / 2;
|
|
const centerY = height / 2;
|
|
const maxRadius = Math.sqrt(centerX * centerX + centerY * centerY);
|
|
const innerRadius = maxRadius * (1 - blendArea / 100);
|
|
// Create radial gradient
|
|
const gradient = ctx.createRadialGradient(centerX, centerY, innerRadius, centerX, centerY, maxRadius);
|
|
gradient.addColorStop(0, 'white');
|
|
gradient.addColorStop(1, 'black');
|
|
ctx.fillStyle = gradient;
|
|
ctx.fillRect(0, 0, width, height);
|
|
return canvas;
|
|
}, 'createRadialGradientMask');
|