Files
wallen0322-ComfyUI-AE-Anima…/js/background_extractor.js
T

416 lines
16 KiB
JavaScript

class BackgroundExtractor {
constructor(canvas, backgroundLayer) {
Object.assign(this, {
canvas, backgroundLayer,
width: canvas.width,
height: canvas.height,
isDrawing: false,
tool: 'brush',
brushSize: 20,
lastX: 0, lastY: 0
});
this.selectionCanvas = document.createElement('canvas');
Object.assign(this.selectionCanvas, { width: this.width, height: this.height });
this.selectionCtx = this.selectionCanvas.getContext('2d');
this.handlers = {
mousedown: this.onMouseDown.bind(this),
mousemove: this.onMouseMove.bind(this),
mouseup: this.onMouseUp.bind(this),
mouseleave: this.onMouseUp.bind(this)
};
this.selectionCtx.fillStyle = 'black';
this.selectionCtx.fillRect(0, 0, this.width, this.height);
this.bindEvents();
}
bindEvents() {
Object.entries(this.handlers).forEach(([event, handler]) => {
this.canvas.addEventListener(event, handler);
});
}
unbindEvents() {
Object.entries(this.handlers).forEach(([event, handler]) => {
this.canvas.removeEventListener(event, handler);
});
}
onMouseDown(e) {
this.isDrawing = true;
const { x, y } = this.getMousePos(e);
[this.lastX, this.lastY] = [x, y];
}
onMouseMove(e) {
if (!this.isDrawing) return;
const { x, y } = this.getMousePos(e);
this.draw(this.lastX, this.lastY, x, y);
[this.lastX, this.lastY] = [x, y];
}
onMouseUp() { this.isDrawing = false; }
getMousePos(e) {
const rect = this.canvas.getBoundingClientRect();
const [scaleX, scaleY] = [this.canvas.width / rect.width, this.canvas.height / rect.height];
return {
x: (e.clientX - rect.left) * scaleX,
y: (e.clientY - rect.top) * scaleY
};
}
draw(x1, y1, x2, y2) {
this.selectionCtx.save();
if (this.tool === 'brush') {
this.selectionCtx.globalCompositeOperation = 'source-over';
this.selectionCtx.strokeStyle = 'white';
} else {
this.selectionCtx.globalCompositeOperation = 'destination-out';
}
this.selectionCtx.lineWidth = this.brushSize;
this.selectionCtx.lineCap = 'round';
this.selectionCtx.lineJoin = 'round';
this.selectionCtx.beginPath();
this.selectionCtx.moveTo(x1, y1);
this.selectionCtx.lineTo(x2, y2);
this.selectionCtx.stroke();
this.selectionCtx.restore();
}
clear() {
this.selectionCtx.fillStyle = 'black';
this.selectionCtx.fillRect(0, 0, this.width, this.height);
}
fill() {
this.selectionCtx.fillStyle = 'white';
this.selectionCtx.fillRect(0, 0, this.width, this.height);
}
// 提取前景和填充背景
async extract() {
if (!this.backgroundLayer.img) {
throw new Error('背景图像未加载');
}
// 1. 创建临时画布绘制背景
const tempCanvas = document.createElement('canvas');
tempCanvas.width = this.width;
tempCanvas.height = this.height;
const tempCtx = tempCanvas.getContext('2d');
// 绘制背景图像
const bgImg = this.backgroundLayer.img;
tempCtx.drawImage(bgImg, 0, 0, this.width, this.height);
// 获取图像数据
const bgImageData = tempCtx.getImageData(0, 0, this.width, this.height);
const bgData = bgImageData.data;
// 获取选区mask数据
const maskImageData = this.selectionCtx.getImageData(0, 0, this.width, this.height);
const maskData = maskImageData.data;
// 2. 创建前景图像
const foregroundCanvas = document.createElement('canvas');
foregroundCanvas.width = this.width;
foregroundCanvas.height = this.height;
const fgCtx = foregroundCanvas.getContext('2d');
const fgImageData = fgCtx.createImageData(this.width, this.height);
const fgData = fgImageData.data;
// 提取选区内的像素到前景
for (let i = 0; i < bgData.length; i += 4) {
const maskValue = maskData[i]; // R通道作为mask
if (maskValue > 128) { // 选区内
fgData[i] = bgData[i]; // R
fgData[i + 1] = bgData[i + 1]; // G
fgData[i + 2] = bgData[i + 2]; // B
fgData[i + 3] = 255; // Alpha = 不透明
} else {
fgData[i + 3] = 0; // Alpha = 透明
}
}
fgCtx.putImageData(fgImageData, 0, 0);
// 3. 创建填充后的背景
const filledBgCanvas = document.createElement('canvas');
filledBgCanvas.width = this.width;
filledBgCanvas.height = this.height;
const filledCtx = filledBgCanvas.getContext('2d');
const filledImageData = filledCtx.createImageData(this.width, this.height);
const filledData = filledImageData.data;
// 复制原始背景数据
for (let i = 0; i < bgData.length; i++) {
filledData[i] = bgData[i];
}
// 4. 对选区内的区域进行模糊填充
const blurRadius = 10; // 模糊半径
for (let y = 0; y < this.height; y++) {
for (let x = 0; x < this.width; x++) {
const idx = (y * this.width + x) * 4;
const maskValue = maskData[idx];
if (maskValue > 128) { // 选区内的像素需要填充
// 从周围非选区像素采样并模糊
let sumR = 0, sumG = 0, sumB = 0, count = 0;
// 采样周围区域
for (let dy = -blurRadius; dy <= blurRadius; dy++) {
for (let dx = -blurRadius; dx <= blurRadius; dx++) {
const nx = x + dx;
const ny = y + dy;
if (nx >= 0 && nx < this.width && ny >= 0 && ny < this.height) {
const nidx = (ny * this.width + nx) * 4;
const nmask = maskData[nidx];
// 只从选区外的像素采样
if (nmask < 128) {
const distance = Math.sqrt(dx * dx + dy * dy);
const weight = Math.max(0, blurRadius - distance);
sumR += bgData[nidx] * weight;
sumG += bgData[nidx + 1] * weight;
sumB += bgData[nidx + 2] * weight;
count += weight;
}
}
}
}
if (count > 0) {
filledData[idx] = Math.round(sumR / count);
filledData[idx + 1] = Math.round(sumG / count);
filledData[idx + 2] = Math.round(sumB / count);
filledData[idx + 3] = 255;
}
}
}
}
filledCtx.putImageData(filledImageData, 0, 0);
// 5. 额外应用高斯模糊使填充更自然
const blurCanvas = await this.applyGaussianBlur(filledBgCanvas, maskImageData, 5);
return {
foreground: foregroundCanvas.toDataURL('image/png'),
background: blurCanvas.toDataURL('image/png'),
mask: this.selectionCanvas.toDataURL('image/png')
};
}
// 应用高斯模糊到选区区域
async applyGaussianBlur(sourceCanvas, maskImageData, iterations) {
const result = document.createElement('canvas');
result.width = sourceCanvas.width;
result.height = sourceCanvas.height;
const ctx = result.getContext('2d');
ctx.drawImage(sourceCanvas, 0, 0);
const maskData = maskImageData.data;
for (let iter = 0; iter < iterations; iter++) {
const imageData = ctx.getImageData(0, 0, this.width, this.height);
const data = imageData.data;
const tempData = new Uint8ClampedArray(data);
for (let y = 1; y < this.height - 1; y++) {
for (let x = 1; x < this.width - 1; x++) {
const idx = (y * this.width + x) * 4;
const maskValue = maskData[idx];
// 只对选区内和边缘区域进行模糊
if (maskValue > 64) {
// 3x3 高斯核
const kernel = [
[1, 2, 1],
[2, 4, 2],
[1, 2, 1]
];
const kernelSum = 16;
let sumR = 0, sumG = 0, sumB = 0;
for (let ky = -1; ky <= 1; ky++) {
for (let kx = -1; kx <= 1; kx++) {
const nx = x + kx;
const ny = y + ky;
const nidx = (ny * this.width + nx) * 4;
const weight = kernel[ky + 1][kx + 1];
sumR += tempData[nidx] * weight;
sumG += tempData[nidx + 1] * weight;
sumB += tempData[nidx + 2] * weight;
}
}
data[idx] = sumR / kernelSum;
data[idx + 1] = sumG / kernelSum;
data[idx + 2] = sumB / kernelSum;
}
}
}
ctx.putImageData(imageData, 0, 0);
}
return result;
}
getSelectionCanvas() { return this.selectionCanvas; }
setTool(tool) { this.tool = tool; }
setBrushSize(size) { this.brushSize = Math.max(1, Math.min(100, size)); }
destroy() { this.unbindEvents(); }
}
// 创建背景提取UI面板
function createBackgroundExtractorUI(container, extractor, onExtract, onClose) {
const panel = document.createElement('div');
panel.style.cssText = `
position: absolute;
top: 10px;
right: 10px;
background: rgba(30, 30, 30, 0.95);
border: 1px solid #3c3c3c;
border-radius: 6px;
padding: 10px;
z-index: 1000;
min-width: 200px;
`;
const title = document.createElement('div');
title.textContent = '✂️ 背景区域提取';
title.style.cssText = 'color: #fff; font-size: 12px; font-weight: bold; margin-bottom: 8px;';
panel.appendChild(title);
const info = document.createElement('div');
info.innerHTML = `
<div style="color: #888; font-size: 9px; margin-bottom: 8px; line-height: 1.4;">
在背景上绘制要提取的区域<br>
提取后会自动模糊填充空洞
</div>
`;
panel.appendChild(info);
// 工具选择
const toolRow = document.createElement('div');
toolRow.style.cssText = 'display: flex; gap: 4px; margin-bottom: 6px;';
const brushBtn = document.createElement('button');
brushBtn.textContent = '🖌️ 画笔';
brushBtn.style.cssText = 'flex: 1; padding: 4px; background: #27ae60; border: none; color: #fff; border-radius: 3px; cursor: pointer; font-size: 10px;';
brushBtn.onclick = () => {
extractor.setTool('brush');
brushBtn.style.background = '#27ae60';
eraserBtn.style.background = '#555';
};
const eraserBtn = document.createElement('button');
eraserBtn.textContent = '🧹 橡皮';
eraserBtn.style.cssText = 'flex: 1; padding: 4px; background: #555; border: none; color: #fff; border-radius: 3px; cursor: pointer; font-size: 10px;';
eraserBtn.onclick = () => {
extractor.setTool('eraser');
eraserBtn.style.background = '#e74c3c';
brushBtn.style.background = '#555';
};
toolRow.appendChild(brushBtn);
toolRow.appendChild(eraserBtn);
panel.appendChild(toolRow);
// 画笔大小
const sizeRow = document.createElement('div');
sizeRow.style.cssText = 'margin-bottom: 6px;';
const sizeLabel = document.createElement('div');
sizeLabel.textContent = '画笔大小: 20';
sizeLabel.style.cssText = 'color: #888; font-size: 10px; margin-bottom: 3px;';
const sizeSlider = document.createElement('input');
sizeSlider.type = 'range';
sizeSlider.min = '1';
sizeSlider.max = '100';
sizeSlider.value = '20';
sizeSlider.style.cssText = 'width: 100%;';
sizeSlider.oninput = () => {
const size = parseInt(sizeSlider.value);
extractor.setBrushSize(size);
sizeLabel.textContent = `画笔大小: ${size}`;
};
sizeRow.appendChild(sizeLabel);
sizeRow.appendChild(sizeSlider);
panel.appendChild(sizeRow);
// 操作按钮
const actionRow = document.createElement('div');
actionRow.style.cssText = 'display: flex; gap: 4px; margin-bottom: 6px;';
const fillBtn = document.createElement('button');
fillBtn.textContent = '⬜ 全选';
fillBtn.style.cssText = 'flex: 1; padding: 4px; background: #3498db; border: none; color: #fff; border-radius: 3px; cursor: pointer; font-size: 10px;';
fillBtn.onclick = () => extractor.fill();
const clearBtn = document.createElement('button');
clearBtn.textContent = '🗑️ 清空';
clearBtn.style.cssText = 'flex: 1; padding: 4px; background: #e74c3c; border: none; color: #fff; border-radius: 3px; cursor: pointer; font-size: 10px;';
clearBtn.onclick = () => extractor.clear();
actionRow.appendChild(fillBtn);
actionRow.appendChild(clearBtn);
panel.appendChild(actionRow);
// 提取按钮
const extractBtn = document.createElement('button');
extractBtn.textContent = '✂️ 提取为新层';
extractBtn.style.cssText = 'width: 100%; padding: 6px; background: #e67e22; border: none; color: #fff; border-radius: 3px; cursor: pointer; font-size: 11px; font-weight: bold; margin-bottom: 4px;';
extractBtn.onclick = async () => {
extractBtn.disabled = true;
extractBtn.textContent = '⏳ 处理中...';
try {
const result = await extractor.extract();
if (onExtract) {
onExtract(result);
}
alert('✅ 提取成功!\n新前景层已创建\n背景已模糊填充');
panel.remove();
if (onClose) onClose();
} catch (error) {
alert('❌ 提取失败: ' + error.message);
extractBtn.disabled = false;
extractBtn.textContent = '✂️ 提取为新层';
}
};
panel.appendChild(extractBtn);
// 关闭按钮
const closeBtn = document.createElement('button');
closeBtn.textContent = '✕ 取消';
closeBtn.style.cssText = 'width: 100%; padding: 4px; background: #95a5a6; border: none; color: #fff; border-radius: 3px; cursor: pointer; font-size: 10px;';
closeBtn.onclick = () => {
panel.remove();
if (onClose) onClose();
};
panel.appendChild(closeBtn);
container.appendChild(panel);
return panel;
}
export { BackgroundExtractor, createBackgroundExtractorUI };