1020 lines
31 KiB
TypeScript
1020 lines
31 KiB
TypeScript
/**
|
||
* 灵感图智能拆图器
|
||
*
|
||
* 支持两种布局格式:
|
||
* 1. 紧凑网格布局(新):等大小图片 + 细白线分割,用于生产图
|
||
* 2. 不规则布局(旧):不同大小图片 + 灰色背景间隔
|
||
*
|
||
* 检测策略:
|
||
* 1. 优先尝试网格检测(检测横竖白线分割)
|
||
* 2. 如果网格检测失败,回退到边缘 Flood Fill 算法
|
||
*
|
||
* 网格检测算法:
|
||
* 1. 扫描图像查找连续的白色横线和竖线
|
||
* 2. 根据线的位置计算网格结构
|
||
* 3. 直接按网格分割图片
|
||
*
|
||
* 边缘 Flood Fill 算法(用于不规则布局):
|
||
* 1. 计算图片梯度(Sobel 边缘检测)
|
||
* 2. 从四角采样检测背景颜色
|
||
* 3. 从图片边缘开始 Flood Fill,标记背景区域
|
||
* 4. 连通区域标记,找到各个图片区域
|
||
*/
|
||
|
||
import type { ImageElement } from '../types/photo-wall.types';
|
||
import {
|
||
loadImage,
|
||
isBackgroundPixel,
|
||
isWhiteBorderPixel,
|
||
removeWhiteBorder,
|
||
} from '@aitu/utils';
|
||
import { mergeSplitLines } from './image-split-core';
|
||
|
||
/** 检测时的最大尺寸,超过此尺寸会降采样(降低此值可提升性能) */
|
||
const MAX_DETECTION_SIZE = 800;
|
||
|
||
/** 每处理多少像素后 yield 一次(降低此值可提升 UI 响应性) */
|
||
const YIELD_INTERVAL = 100000;
|
||
|
||
/** 白线检测阈值 */
|
||
const WHITE_LINE_THRESHOLD = 240;
|
||
|
||
/** 最小白线长度比例(相对于图片宽/高) */
|
||
const MIN_LINE_RATIO = 0.8;
|
||
|
||
/**
|
||
* 让出主线程,避免阻塞 UI
|
||
*/
|
||
function yieldToMain(): Promise<void> {
|
||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||
}
|
||
|
||
/**
|
||
* 网格检测结果
|
||
*/
|
||
interface GridDetectionResult {
|
||
success: boolean;
|
||
rows?: number;
|
||
cols?: number;
|
||
horizontalLines?: number[];
|
||
verticalLines?: number[];
|
||
}
|
||
|
||
/**
|
||
* 检测图片中的网格结构(基于白色分割线)
|
||
* 用于处理紧凑网格布局的生产图
|
||
*/
|
||
function detectGridStructure(imageData: ImageData): GridDetectionResult {
|
||
const { width, height, data } = imageData;
|
||
|
||
// console.log('[GridDetection] Starting grid detection...');
|
||
|
||
// 扫描横向白线(y 位置)
|
||
const horizontalWhiteRows: number[] = [];
|
||
for (let y = 0; y < height; y++) {
|
||
let whiteCount = 0;
|
||
for (let x = 0; x < width; x++) {
|
||
const idx = (y * width + x) * 4;
|
||
const r = data[idx];
|
||
const g = data[idx + 1];
|
||
const b = data[idx + 2];
|
||
if (r >= WHITE_LINE_THRESHOLD && g >= WHITE_LINE_THRESHOLD && b >= WHITE_LINE_THRESHOLD) {
|
||
whiteCount++;
|
||
}
|
||
}
|
||
// 如果这一行超过 80% 是白色,认为是分割线
|
||
if (whiteCount / width >= MIN_LINE_RATIO) {
|
||
horizontalWhiteRows.push(y);
|
||
}
|
||
}
|
||
|
||
// 扫描纵向白线(x 位置)
|
||
const verticalWhiteCols: number[] = [];
|
||
for (let x = 0; x < width; x++) {
|
||
let whiteCount = 0;
|
||
for (let y = 0; y < height; y++) {
|
||
const idx = (y * width + x) * 4;
|
||
const r = data[idx];
|
||
const g = data[idx + 1];
|
||
const b = data[idx + 2];
|
||
if (r >= WHITE_LINE_THRESHOLD && g >= WHITE_LINE_THRESHOLD && b >= WHITE_LINE_THRESHOLD) {
|
||
whiteCount++;
|
||
}
|
||
}
|
||
// 如果这一列超过 80% 是白色,认为是分割线
|
||
if (whiteCount / height >= MIN_LINE_RATIO) {
|
||
verticalWhiteCols.push(x);
|
||
}
|
||
}
|
||
|
||
// console.log(`[GridDetection] Found ${horizontalWhiteRows.length} horizontal white rows`);
|
||
// console.log(`[GridDetection] Found ${verticalWhiteCols.length} vertical white cols`);
|
||
|
||
// 合并连续的白线为单一分割线(取中点)
|
||
// 使用共享的 mergeSplitLines 函数,允许 3px 间隔
|
||
const horizontalLines = mergeSplitLines(horizontalWhiteRows, 3);
|
||
const verticalLines = mergeSplitLines(verticalWhiteCols, 3);
|
||
|
||
// console.log(`[GridDetection] Merged to ${horizontalLines.length} horizontal lines:`, horizontalLines);
|
||
// console.log(`[GridDetection] Merged to ${verticalLines.length} vertical lines:`, verticalLines);
|
||
|
||
// 验证是否构成有效网格
|
||
// 需要至少 1 条横线和 1 条竖线(形成 2x2 网格)
|
||
// 或者 0 条横线但有竖线(1 行多列),或者 0 条竖线但有横线(多行 1 列)
|
||
const hasValidGrid =
|
||
(horizontalLines.length >= 1 && verticalLines.length >= 0) ||
|
||
(horizontalLines.length >= 0 && verticalLines.length >= 1);
|
||
|
||
if (!hasValidGrid) {
|
||
// console.log('[GridDetection] No valid grid structure found');
|
||
return { success: false };
|
||
}
|
||
|
||
// 计算行列数
|
||
const rows = horizontalLines.length + 1;
|
||
const cols = verticalLines.length + 1;
|
||
|
||
// 验证网格是否合理(2-12 个单元格)
|
||
const cellCount = rows * cols;
|
||
if (cellCount < 2 || cellCount > 16) {
|
||
// console.log(`[GridDetection] Invalid cell count: ${cellCount}`);
|
||
return { success: false };
|
||
}
|
||
|
||
// console.log(`[GridDetection] Detected ${rows}x${cols} grid (${cellCount} cells)`);
|
||
|
||
return {
|
||
success: true,
|
||
rows,
|
||
cols,
|
||
horizontalLines,
|
||
verticalLines,
|
||
};
|
||
}
|
||
|
||
// 使用 image-split-core.ts 中的 mergeSplitLines 函数
|
||
|
||
/**
|
||
* 根据网格结构生成区域
|
||
*/
|
||
function generateGridRegions(
|
||
width: number,
|
||
height: number,
|
||
horizontalLines: number[],
|
||
verticalLines: number[]
|
||
): Array<{ x: number; y: number; width: number; height: number }> {
|
||
const regions: Array<{ x: number; y: number; width: number; height: number }> = [];
|
||
|
||
// 添加边界
|
||
const yPositions = [0, ...horizontalLines, height];
|
||
const xPositions = [0, ...verticalLines, width];
|
||
|
||
// 生成每个单元格的区域
|
||
for (let row = 0; row < yPositions.length - 1; row++) {
|
||
for (let col = 0; col < xPositions.length - 1; col++) {
|
||
const x = xPositions[col];
|
||
const y = yPositions[row];
|
||
const nextX = xPositions[col + 1];
|
||
const nextY = yPositions[row + 1];
|
||
|
||
// 跳过分割线(取内部区域)
|
||
const padding = 2; // 跳过分割线的像素
|
||
const regionX = x + (col === 0 ? padding : padding);
|
||
const regionY = y + (row === 0 ? padding : padding);
|
||
const regionWidth = nextX - x - padding * 2;
|
||
const regionHeight = nextY - y - padding * 2;
|
||
|
||
if (regionWidth > 20 && regionHeight > 20) {
|
||
regions.push({
|
||
x: regionX,
|
||
y: regionY,
|
||
width: regionWidth,
|
||
height: regionHeight,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
return regions;
|
||
}
|
||
|
||
/**
|
||
* 检测结果
|
||
*/
|
||
export interface PhotoWallDetectionResult {
|
||
/** 检测到的图片数量 */
|
||
count: number;
|
||
/** 每个图片的边界矩形 */
|
||
regions: Array<{
|
||
x: number;
|
||
y: number;
|
||
width: number;
|
||
height: number;
|
||
}>;
|
||
}
|
||
|
||
/**
|
||
* 计算像素的灰度值
|
||
*/
|
||
function getGrayValue(r: number, g: number, b: number): number {
|
||
return 0.299 * r + 0.587 * g + 0.114 * b;
|
||
}
|
||
|
||
/**
|
||
* 计算图片的梯度幅值(用于边缘检测,异步版本)
|
||
* 使用 Sobel 算子
|
||
*/
|
||
async function computeGradientMagnitude(imageData: ImageData): Promise<Float32Array> {
|
||
const { width, height, data } = imageData;
|
||
const gradient = new Float32Array(width * height);
|
||
let processedPixels = 0;
|
||
|
||
// 预计算灰度值以提升性能
|
||
const grayValues = new Float32Array(width * height);
|
||
for (let i = 0; i < width * height; i++) {
|
||
const idx = i * 4;
|
||
grayValues[i] = getGrayValue(data[idx], data[idx + 1], data[idx + 2]);
|
||
}
|
||
|
||
for (let y = 1; y < height - 1; y++) {
|
||
for (let x = 1; x < width - 1; x++) {
|
||
const idx = y * width + x;
|
||
|
||
// Sobel 算子 - 使用预计算的灰度值
|
||
// Gx = [-1 0 1; -2 0 2; -1 0 1]
|
||
const gx =
|
||
-grayValues[idx - width - 1] + grayValues[idx - width + 1] +
|
||
-2 * grayValues[idx - 1] + 2 * grayValues[idx + 1] +
|
||
-grayValues[idx + width - 1] + grayValues[idx + width + 1];
|
||
|
||
// Gy = [-1 -2 -1; 0 0 0; 1 2 1]
|
||
const gy =
|
||
-grayValues[idx - width - 1] - 2 * grayValues[idx - width] - grayValues[idx - width + 1] +
|
||
grayValues[idx + width - 1] + 2 * grayValues[idx + width] + grayValues[idx + width + 1];
|
||
|
||
gradient[idx] = Math.sqrt(gx * gx + gy * gy);
|
||
|
||
// 定期 yield 给浏览器
|
||
processedPixels++;
|
||
if (processedPixels % YIELD_INTERVAL === 0) {
|
||
await yieldToMain();
|
||
}
|
||
}
|
||
}
|
||
|
||
return gradient;
|
||
}
|
||
|
||
/**
|
||
* 检测背景颜色(从图片四角采样)
|
||
*/
|
||
function detectBackgroundColor(imageData: ImageData): { r: number; g: number; b: number } {
|
||
const { width, height, data } = imageData;
|
||
const samples: Array<{ r: number; g: number; b: number }> = [];
|
||
const sampleSize = Math.min(20, Math.floor(Math.min(width, height) / 10));
|
||
|
||
// 从四角采样
|
||
const corners = [
|
||
{ startX: 0, startY: 0 },
|
||
{ startX: width - sampleSize, startY: 0 },
|
||
{ startX: 0, startY: height - sampleSize },
|
||
{ startX: width - sampleSize, startY: height - sampleSize },
|
||
];
|
||
|
||
for (const corner of corners) {
|
||
for (let dy = 0; dy < sampleSize; dy++) {
|
||
for (let dx = 0; dx < sampleSize; dx++) {
|
||
const x = corner.startX + dx;
|
||
const y = corner.startY + dy;
|
||
if (x >= 0 && x < width && y >= 0 && y < height) {
|
||
const idx = (y * width + x) * 4;
|
||
samples.push({
|
||
r: data[idx],
|
||
g: data[idx + 1],
|
||
b: data[idx + 2],
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 计算平均值
|
||
const avg = samples.reduce(
|
||
(acc, s) => ({ r: acc.r + s.r, g: acc.g + s.g, b: acc.b + s.b }),
|
||
{ r: 0, g: 0, b: 0 }
|
||
);
|
||
const count = samples.length;
|
||
|
||
return {
|
||
r: Math.round(avg.r / count),
|
||
g: Math.round(avg.g / count),
|
||
b: Math.round(avg.b / count),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 检查像素是否与背景色相似
|
||
*/
|
||
function isSimilarToBackground(
|
||
r: number, g: number, b: number,
|
||
bgColor: { r: number; g: number; b: number },
|
||
colorThreshold: number = 30
|
||
): boolean {
|
||
const dr = Math.abs(r - bgColor.r);
|
||
const dg = Math.abs(g - bgColor.g);
|
||
const db = Math.abs(b - bgColor.b);
|
||
return dr <= colorThreshold && dg <= colorThreshold && db <= colorThreshold;
|
||
}
|
||
|
||
/**
|
||
* 使用边缘 Flood Fill 创建二值化遮罩(增强版)
|
||
* 从图片边缘开始填充,使用梯度作为边界
|
||
* 背景 = 0,前景(图片区域)= 1
|
||
*
|
||
* 性能优化:
|
||
* - 使用 Uint32Array 存储队列,避免对象创建开销
|
||
* - 使用索引代替 shift(),避免 O(n) 操作
|
||
* - 预先标记已入队像素,避免重复入队
|
||
*
|
||
* 关键改进:白色边框也被视为可穿透区域,因为它属于照片的外围
|
||
*/
|
||
async function createBinaryMaskWithEdgeFloodFill(
|
||
imageData: ImageData,
|
||
gradient: Float32Array,
|
||
bgColor: { r: number; g: number; b: number }
|
||
): Promise<Uint8Array> {
|
||
const { width, height, data } = imageData;
|
||
const mask = new Uint8Array(width * height);
|
||
const inQueue = new Uint8Array(width * height); // 标记是否已入队
|
||
|
||
// 梯度阈值(边缘检测)- 提高阈值以穿过白色边框
|
||
const gradientThreshold = 50;
|
||
// 颜色相似度阈值
|
||
const colorThreshold = 50;
|
||
|
||
// 初始化:所有像素默认为前景 (1)
|
||
mask.fill(1);
|
||
|
||
// 使用 Uint32Array 存储队列(每个元素 = y * width + x)
|
||
// 预估最大队列大小为图片面积的一半
|
||
const maxQueueSize = Math.ceil((width * height) / 2);
|
||
const queue = new Uint32Array(maxQueueSize);
|
||
let queueHead = 0;
|
||
let queueTail = 0;
|
||
|
||
// 入队辅助函数
|
||
const enqueue = (x: number, y: number) => {
|
||
if (x < 0 || x >= width || y < 0 || y >= height) return;
|
||
const idx = y * width + x;
|
||
if (inQueue[idx]) return;
|
||
inQueue[idx] = 1;
|
||
queue[queueTail % maxQueueSize] = idx;
|
||
queueTail++;
|
||
};
|
||
|
||
// 添加四边的像素到队列
|
||
for (let x = 0; x < width; x++) {
|
||
enqueue(x, 0);
|
||
enqueue(x, height - 1);
|
||
}
|
||
for (let y = 1; y < height - 1; y++) {
|
||
enqueue(0, y);
|
||
enqueue(width - 1, y);
|
||
}
|
||
|
||
let processedPixels = 0;
|
||
|
||
while (queueHead < queueTail) {
|
||
const idx = queue[queueHead % maxQueueSize];
|
||
queueHead++;
|
||
|
||
const x = idx % width;
|
||
const y = Math.floor(idx / width);
|
||
|
||
const pixelIdx = idx * 4;
|
||
const r = data[pixelIdx];
|
||
const g = data[pixelIdx + 1];
|
||
const b = data[pixelIdx + 2];
|
||
const grad = gradient[idx];
|
||
|
||
// 判断是否为背景/可穿透区域:
|
||
const isBgColor = isSimilarToBackground(r, g, b, bgColor, colorThreshold);
|
||
const isGenericBg = isBackgroundPixel(r, g, b);
|
||
const isWhiteBorder = isWhiteBorderPixel(r, g, b, 240); // 白色边框
|
||
const isLowGradient = grad < gradientThreshold;
|
||
|
||
// 关键改进:白色边框区域即使梯度较高也应该被穿透
|
||
// 因为白色边框是照片的外围,不是照片的内容
|
||
const canPassThrough =
|
||
// 背景色且低梯度 - 可以穿透
|
||
((isBgColor || isGenericBg) && isLowGradient) ||
|
||
// 白色边框 - 无论梯度如何都可以穿透(但需要与已知背景相邻)
|
||
isWhiteBorder;
|
||
|
||
if (canPassThrough) {
|
||
// 标记为背景
|
||
mask[idx] = 0;
|
||
|
||
// 继续扩展到邻居(4-连通)
|
||
enqueue(x + 1, y);
|
||
enqueue(x - 1, y);
|
||
enqueue(x, y + 1);
|
||
enqueue(x, y - 1);
|
||
}
|
||
|
||
// 定期 yield 给浏览器
|
||
processedPixels++;
|
||
if (processedPixels % YIELD_INTERVAL === 0) {
|
||
await yieldToMain();
|
||
}
|
||
}
|
||
|
||
return mask;
|
||
}
|
||
|
||
/**
|
||
* 创建二值化遮罩(原始颜色匹配版本,作为后备)
|
||
* 背景 = 0,前景(图片区域)= 1
|
||
*/
|
||
async function createBinaryMaskByColor(imageData: ImageData): Promise<Uint8Array> {
|
||
const { width, height, data } = imageData;
|
||
const mask = new Uint8Array(width * height);
|
||
let processedPixels = 0;
|
||
|
||
for (let y = 0; y < height; y++) {
|
||
for (let x = 0; x < width; x++) {
|
||
const idx = (y * width + x) * 4;
|
||
const r = data[idx];
|
||
const g = data[idx + 1];
|
||
const b = data[idx + 2];
|
||
|
||
// 背景(灰色)或纯白(边框外围)标记为 0
|
||
if (isBackgroundPixel(r, g, b) || isWhiteBorderPixel(r, g, b)) {
|
||
mask[y * width + x] = 0;
|
||
} else {
|
||
mask[y * width + x] = 1;
|
||
}
|
||
|
||
// 定期 yield 给浏览器
|
||
processedPixels++;
|
||
if (processedPixels % YIELD_INTERVAL === 0) {
|
||
await yieldToMain();
|
||
}
|
||
}
|
||
}
|
||
|
||
return mask;
|
||
}
|
||
|
||
/**
|
||
* 形态学操作:膨胀(异步版本)
|
||
* 用于连接相邻的前景像素
|
||
*/
|
||
async function dilate(mask: Uint8Array, width: number, height: number, radius: number = 2): Promise<Uint8Array> {
|
||
const result = new Uint8Array(mask.length);
|
||
let processedPixels = 0;
|
||
|
||
for (let y = 0; y < height; y++) {
|
||
for (let x = 0; x < width; x++) {
|
||
let hasNeighbor = false;
|
||
|
||
// 检查邻域
|
||
for (let dy = -radius; dy <= radius && !hasNeighbor; dy++) {
|
||
for (let dx = -radius; dx <= radius && !hasNeighbor; dx++) {
|
||
const nx = x + dx;
|
||
const ny = y + dy;
|
||
if (nx >= 0 && nx < width && ny >= 0 && ny < height) {
|
||
if (mask[ny * width + nx] === 1) {
|
||
hasNeighbor = true;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
result[y * width + x] = hasNeighbor ? 1 : 0;
|
||
|
||
// 定期 yield 给浏览器
|
||
processedPixels++;
|
||
if (processedPixels % YIELD_INTERVAL === 0) {
|
||
await yieldToMain();
|
||
}
|
||
}
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* 连通区域标记(使用 Flood Fill,异步版本)
|
||
* 返回每个像素的标签(0 = 背景,1+ = 不同的连通区域)
|
||
*/
|
||
async function labelConnectedComponents(
|
||
mask: Uint8Array,
|
||
width: number,
|
||
height: number
|
||
): Promise<{ labels: Int32Array; count: number }> {
|
||
const labels = new Int32Array(mask.length);
|
||
let currentLabel = 0;
|
||
let processedPixels = 0;
|
||
|
||
const queue: Array<{ x: number; y: number }> = [];
|
||
|
||
for (let y = 0; y < height; y++) {
|
||
for (let x = 0; x < width; x++) {
|
||
const idx = y * width + x;
|
||
|
||
// 跳过背景和已标记的像素
|
||
if (mask[idx] === 0 || labels[idx] !== 0) {
|
||
continue;
|
||
}
|
||
|
||
// 开始新的连通区域
|
||
currentLabel++;
|
||
queue.push({ x, y });
|
||
|
||
while (queue.length > 0) {
|
||
const { x: cx, y: cy } = queue.shift()!;
|
||
const cidx = cy * width + cx;
|
||
|
||
if (cx < 0 || cx >= width || cy < 0 || cy >= height) continue;
|
||
if (mask[cidx] === 0 || labels[cidx] !== 0) continue;
|
||
|
||
labels[cidx] = currentLabel;
|
||
|
||
// 8-连通邻域
|
||
queue.push({ x: cx + 1, y: cy });
|
||
queue.push({ x: cx - 1, y: cy });
|
||
queue.push({ x: cx, y: cy + 1 });
|
||
queue.push({ x: cx, y: cy - 1 });
|
||
queue.push({ x: cx + 1, y: cy + 1 });
|
||
queue.push({ x: cx - 1, y: cy - 1 });
|
||
queue.push({ x: cx + 1, y: cy - 1 });
|
||
queue.push({ x: cx - 1, y: cy + 1 });
|
||
|
||
// 定期 yield 给浏览器
|
||
processedPixels++;
|
||
if (processedPixels % YIELD_INTERVAL === 0) {
|
||
await yieldToMain();
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return { labels, count: currentLabel };
|
||
}
|
||
|
||
/**
|
||
* 计算每个连通区域的边界矩形
|
||
*/
|
||
function computeBoundingBoxes(
|
||
labels: Int32Array,
|
||
width: number,
|
||
height: number,
|
||
count: number
|
||
): Array<{ x: number; y: number; width: number; height: number; area: number }> {
|
||
// 初始化边界
|
||
const boxes: Array<{
|
||
minX: number;
|
||
maxX: number;
|
||
minY: number;
|
||
maxY: number;
|
||
pixelCount: number;
|
||
}> = [];
|
||
|
||
for (let i = 0; i < count; i++) {
|
||
boxes.push({
|
||
minX: Infinity,
|
||
maxX: -Infinity,
|
||
minY: Infinity,
|
||
maxY: -Infinity,
|
||
pixelCount: 0,
|
||
});
|
||
}
|
||
|
||
// 遍历标签,更新边界
|
||
for (let y = 0; y < height; y++) {
|
||
for (let x = 0; x < width; x++) {
|
||
const label = labels[y * width + x];
|
||
if (label > 0) {
|
||
const box = boxes[label - 1];
|
||
box.minX = Math.min(box.minX, x);
|
||
box.maxX = Math.max(box.maxX, x);
|
||
box.minY = Math.min(box.minY, y);
|
||
box.maxY = Math.max(box.maxY, y);
|
||
box.pixelCount++;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 转换为标准格式
|
||
return boxes
|
||
.map((box) => ({
|
||
x: box.minX,
|
||
y: box.minY,
|
||
width: box.maxX - box.minX + 1,
|
||
height: box.maxY - box.minY + 1,
|
||
area: box.pixelCount,
|
||
}))
|
||
.filter((box) => box.width > 0 && box.height > 0);
|
||
}
|
||
|
||
/**
|
||
* 合并重叠或相邻的矩形
|
||
*/
|
||
function mergeOverlappingBoxes(
|
||
boxes: Array<{ x: number; y: number; width: number; height: number; area: number }>,
|
||
overlapThreshold: number = 0.3,
|
||
proximityThreshold: number = 20
|
||
): Array<{ x: number; y: number; width: number; height: number }> {
|
||
if (boxes.length === 0) return [];
|
||
|
||
// 按面积降序排序
|
||
const sorted = [...boxes].sort((a, b) => b.area - a.area);
|
||
const merged: Array<{ x: number; y: number; width: number; height: number }> = [];
|
||
const used = new Set<number>();
|
||
|
||
for (let i = 0; i < sorted.length; i++) {
|
||
if (used.has(i)) continue;
|
||
|
||
let box = { ...sorted[i] };
|
||
used.add(i);
|
||
|
||
// 尝试合并其他矩形
|
||
let changed = true;
|
||
while (changed) {
|
||
changed = false;
|
||
for (let j = 0; j < sorted.length; j++) {
|
||
if (used.has(j)) continue;
|
||
|
||
const other = sorted[j];
|
||
|
||
// 检查是否重叠或相邻
|
||
const overlapX = Math.max(
|
||
0,
|
||
Math.min(box.x + box.width, other.x + other.width) - Math.max(box.x, other.x)
|
||
);
|
||
const overlapY = Math.max(
|
||
0,
|
||
Math.min(box.y + box.height, other.y + other.height) - Math.max(box.y, other.y)
|
||
);
|
||
const overlapArea = overlapX * overlapY;
|
||
const otherArea = other.width * other.height;
|
||
|
||
// 检查是否需要合并
|
||
const isOverlapping = overlapArea > otherArea * overlapThreshold;
|
||
const isProximate =
|
||
Math.abs(box.x - other.x - other.width) < proximityThreshold ||
|
||
Math.abs(other.x - box.x - box.width) < proximityThreshold ||
|
||
Math.abs(box.y - other.y - other.height) < proximityThreshold ||
|
||
Math.abs(other.y - box.y - box.height) < proximityThreshold;
|
||
|
||
if (isOverlapping || (overlapArea > 0 && isProximate)) {
|
||
// 合并
|
||
const newX = Math.min(box.x, other.x);
|
||
const newY = Math.min(box.y, other.y);
|
||
const newRight = Math.max(box.x + box.width, other.x + other.width);
|
||
const newBottom = Math.max(box.y + box.height, other.y + other.height);
|
||
|
||
box = {
|
||
x: newX,
|
||
y: newY,
|
||
width: newRight - newX,
|
||
height: newBottom - newY,
|
||
area: (newRight - newX) * (newBottom - newY),
|
||
};
|
||
|
||
used.add(j);
|
||
changed = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
merged.push({
|
||
x: box.x,
|
||
y: box.y,
|
||
width: box.width,
|
||
height: box.height,
|
||
});
|
||
}
|
||
|
||
return merged;
|
||
}
|
||
|
||
|
||
/**
|
||
* 检测灵感图中的图片区域
|
||
* 优先使用网格检测(适用于紧凑网格布局),失败时回退到边缘 Flood Fill
|
||
*/
|
||
export async function detectPhotoWallRegions(
|
||
imageUrl: string,
|
||
options: {
|
||
minRegionSize?: number; // 最小区域大小(像素)
|
||
minRegionRatio?: number; // 最小区域占比(相对于图片面积)
|
||
} = {}
|
||
): Promise<PhotoWallDetectionResult> {
|
||
const { minRegionSize = 5000, minRegionRatio = 0.01 } = options;
|
||
|
||
const img = await loadImage(imageUrl);
|
||
const { naturalWidth: originalWidth, naturalHeight: originalHeight } = img;
|
||
const totalArea = originalWidth * originalHeight;
|
||
|
||
// console.log(`[PhotoWallSplitter] Image size: ${originalWidth}x${originalHeight}`);
|
||
|
||
// 计算是否需要降采样
|
||
const maxDimension = Math.max(originalWidth, originalHeight);
|
||
const scale = maxDimension > MAX_DETECTION_SIZE ? MAX_DETECTION_SIZE / maxDimension : 1;
|
||
const width = Math.round(originalWidth * scale);
|
||
const height = Math.round(originalHeight * scale);
|
||
|
||
// if (scale < 1) {
|
||
// console.log(`[PhotoWallSplitter] Downscaling to ${width}x${height} (scale: ${scale.toFixed(2)})`);
|
||
// }
|
||
|
||
// 创建 Canvas 获取像素数据(可能是降采样后的)
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = width;
|
||
canvas.height = height;
|
||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||
if (!ctx) {
|
||
throw new Error('Failed to get canvas context');
|
||
}
|
||
|
||
// 绘制图片
|
||
ctx.drawImage(img, 0, 0, width, height);
|
||
const originalImageData = ctx.getImageData(0, 0, width, height);
|
||
|
||
// =============================================
|
||
// 策略 1:优先尝试网格检测(适用于紧凑网格布局)
|
||
// =============================================
|
||
// console.log('[PhotoWallSplitter] Trying grid detection first...');
|
||
const gridResult = detectGridStructure(originalImageData);
|
||
|
||
if (gridResult.success && gridResult.horizontalLines && gridResult.verticalLines) {
|
||
// console.log(`[PhotoWallSplitter] Grid detection succeeded! ${gridResult.rows}x${gridResult.cols} grid`);
|
||
|
||
// 根据网格结构生成区域
|
||
const gridRegions = generateGridRegions(
|
||
width,
|
||
height,
|
||
gridResult.horizontalLines,
|
||
gridResult.verticalLines
|
||
);
|
||
|
||
// 映射回原始尺寸
|
||
const finalRegions = gridRegions.map((region) => {
|
||
if (scale < 1) {
|
||
return {
|
||
x: Math.round(region.x / scale),
|
||
y: Math.round(region.y / scale),
|
||
width: Math.round(region.width / scale),
|
||
height: Math.round(region.height / scale),
|
||
};
|
||
}
|
||
return region;
|
||
});
|
||
|
||
// console.log(`[PhotoWallSplitter] Grid detection found ${finalRegions.length} regions`);
|
||
return {
|
||
count: finalRegions.length,
|
||
regions: finalRegions,
|
||
};
|
||
}
|
||
|
||
// =============================================
|
||
// 策略 2:回退到边缘 Flood Fill 算法
|
||
// =============================================
|
||
// console.log('[PhotoWallSplitter] Grid detection failed, falling back to flood fill...');
|
||
|
||
// 添加灰色边框,确保 Flood Fill 能从边缘正确开始
|
||
const borderSize = Math.max(10, Math.round(Math.min(width, height) * 0.02));
|
||
const paddedWidth = width + borderSize * 2;
|
||
const paddedHeight = height + borderSize * 2;
|
||
|
||
const paddedCanvas = document.createElement('canvas');
|
||
paddedCanvas.width = paddedWidth;
|
||
paddedCanvas.height = paddedHeight;
|
||
const paddedCtx = paddedCanvas.getContext('2d', { willReadFrequently: true });
|
||
if (!paddedCtx) {
|
||
throw new Error('Failed to get padded canvas context');
|
||
}
|
||
|
||
// 填充灰色背景
|
||
paddedCtx.fillStyle = '#E0E0E0';
|
||
paddedCtx.fillRect(0, 0, paddedWidth, paddedHeight);
|
||
|
||
// 绘制原图到中心
|
||
paddedCtx.drawImage(canvas, borderSize, borderSize);
|
||
|
||
const imageData = paddedCtx.getImageData(0, 0, paddedWidth, paddedHeight);
|
||
// console.log(`[PhotoWallSplitter] Added ${borderSize}px gray border, detection size: ${paddedWidth}x${paddedHeight}`);
|
||
|
||
await yieldToMain();
|
||
|
||
// 1. 计算梯度(边缘检测)
|
||
// console.log('[PhotoWallSplitter] Computing gradient...');
|
||
const gradient = await computeGradientMagnitude(imageData);
|
||
|
||
await yieldToMain();
|
||
|
||
// 2. 检测背景颜色
|
||
const bgColor = detectBackgroundColor(imageData);
|
||
// console.log(`[PhotoWallSplitter] Detected background color: RGB(${bgColor.r}, ${bgColor.g}, ${bgColor.b})`);
|
||
|
||
// 3. 使用边缘 Flood Fill 创建二值化遮罩
|
||
// console.log('[PhotoWallSplitter] Creating binary mask with edge flood fill...');
|
||
let mask = await createBinaryMaskWithEdgeFloodFill(imageData, gradient, bgColor);
|
||
|
||
await yieldToMain();
|
||
|
||
// 统计前景像素数量,判断是否检测有效
|
||
const foregroundCount = mask.reduce((sum, val) => sum + val, 0);
|
||
const totalPaddedPixels = paddedWidth * paddedHeight;
|
||
const foregroundRatio = foregroundCount / totalPaddedPixels;
|
||
// console.log(`[PhotoWallSplitter] Foreground ratio: ${(foregroundRatio * 100).toFixed(1)}%`);
|
||
|
||
// 如果前景占比过高(>95%)或过低(<5%),说明边缘检测可能失效,回退到颜色匹配
|
||
if (foregroundRatio > 0.95 || foregroundRatio < 0.05) {
|
||
// console.log('[PhotoWallSplitter] Edge flood fill ineffective, falling back to color matching...');
|
||
mask = await createBinaryMaskByColor(imageData);
|
||
}
|
||
|
||
await yieldToMain();
|
||
|
||
// 4. 膨胀操作,连接相邻区域
|
||
// console.log('[PhotoWallSplitter] Dilating mask...');
|
||
mask = await dilate(mask, paddedWidth, paddedHeight, 3);
|
||
|
||
await yieldToMain();
|
||
|
||
// 5. 连通区域标记
|
||
// console.log('[PhotoWallSplitter] Labeling connected components...');
|
||
const { labels, count } = await labelConnectedComponents(mask, paddedWidth, paddedHeight);
|
||
// console.log(`[PhotoWallSplitter] Found ${count} raw regions`);
|
||
|
||
await yieldToMain();
|
||
|
||
// 6. 计算边界矩形
|
||
const rawBoxes = computeBoundingBoxes(labels, paddedWidth, paddedHeight, count);
|
||
|
||
// 7. 过滤太小的区域(注意:面积阈值也需要按比例缩放)
|
||
const scaledMinArea = Math.max(minRegionSize * scale * scale, totalArea * scale * scale * minRegionRatio);
|
||
const filteredBoxes = rawBoxes.filter((box) => box.area >= scaledMinArea);
|
||
// console.log(`[PhotoWallSplitter] After filtering: ${filteredBoxes.length} regions`);
|
||
|
||
// 8. 合并重叠的矩形
|
||
const mergedBoxes = mergeOverlappingBoxes(filteredBoxes);
|
||
// console.log(`[PhotoWallSplitter] After merging: ${mergedBoxes.length} regions`);
|
||
|
||
// 9. 不再扩展边界(之前会扩展太多白色区域)
|
||
// 后续 splitPhotoWall 会使用 removeWhiteBorder 裁剪白边
|
||
|
||
// 10. 将坐标映射回原始尺寸(需要减去边框偏移,添加少量 padding)
|
||
const smallPadding = 2; // 添加 2px padding 确保不会裁掉边缘
|
||
const finalBoxes = mergedBoxes.map((box) => {
|
||
// 先减去边框偏移,添加少量 padding
|
||
const x = Math.max(0, box.x - borderSize - smallPadding);
|
||
const y = Math.max(0, box.y - borderSize - smallPadding);
|
||
// 限制在原图范围内
|
||
const right = Math.min(width, box.x + box.width - borderSize + smallPadding);
|
||
const bottom = Math.min(height, box.y + box.height - borderSize + smallPadding);
|
||
const w = Math.max(0, right - x);
|
||
const h = Math.max(0, bottom - y);
|
||
|
||
// 如果有降采样,按比例放大
|
||
if (scale < 1) {
|
||
return {
|
||
x: Math.round(x / scale),
|
||
y: Math.round(y / scale),
|
||
width: Math.round(w / scale),
|
||
height: Math.round(h / scale),
|
||
};
|
||
}
|
||
return { x, y, width: w, height: h };
|
||
}).filter((box) => box.width > 0 && box.height > 0);
|
||
|
||
return {
|
||
count: finalBoxes.length,
|
||
regions: finalBoxes,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 递归拆分单个区域
|
||
* 如果区域内还能检测到多个子区域,继续递归拆分
|
||
* @param imageUrl 当前区域的图片 URL
|
||
* @param depth 当前递归深度
|
||
* @param maxDepth 最大递归深度
|
||
* @param offsetX 当前区域相对于原始图片的 X 偏移量
|
||
* @param offsetY 当前区域相对于原始图片的 Y 偏移量
|
||
*/
|
||
async function splitRegionRecursively(
|
||
imageUrl: string,
|
||
depth = 0,
|
||
maxDepth = 5,
|
||
offsetX = 0,
|
||
offsetY = 0
|
||
): Promise<ImageElement[]> {
|
||
// 防止无限递归
|
||
if (depth >= maxDepth) {
|
||
const img = await loadImage(imageUrl);
|
||
return [{
|
||
id: `photo-wall-${Date.now()}-${Math.random().toString(36).substring(2, 6)}`,
|
||
imageData: imageUrl,
|
||
originalIndex: 0,
|
||
width: img.naturalWidth,
|
||
height: img.naturalHeight,
|
||
sourceX: offsetX,
|
||
sourceY: offsetY,
|
||
}];
|
||
}
|
||
|
||
// 检测当前图片中的区域
|
||
const detection = await detectPhotoWallRegions(imageUrl);
|
||
|
||
// 如果检测到 0 或 1 个区域,说明无法继续拆分,返回当前图片
|
||
if (detection.count <= 1) {
|
||
|
||
// 去白边后返回
|
||
const trimmedImageData = await removeWhiteBorder(imageUrl, { borderRatio: 0.3 });
|
||
const trimmedImg = await loadImage(trimmedImageData);
|
||
const trimmedWidth = trimmedImg.naturalWidth;
|
||
const trimmedHeight = trimmedImg.naturalHeight;
|
||
|
||
// 过滤太小的图片
|
||
if (trimmedWidth < 50 || trimmedHeight < 50) {
|
||
return [];
|
||
}
|
||
|
||
return [{
|
||
id: `photo-wall-${Date.now()}-${Math.random().toString(36).substring(2, 6)}`,
|
||
imageData: trimmedImageData,
|
||
originalIndex: 0,
|
||
width: trimmedWidth,
|
||
height: trimmedHeight,
|
||
sourceX: offsetX,
|
||
sourceY: offsetY,
|
||
}];
|
||
}
|
||
|
||
// 检测到多个区域,提取每个区域并递归处理
|
||
const img = await loadImage(imageUrl);
|
||
const allElements: ImageElement[] = [];
|
||
|
||
for (let i = 0; i < detection.regions.length; i++) {
|
||
const region = detection.regions[i];
|
||
|
||
// 提取区域
|
||
const regionCanvas = document.createElement('canvas');
|
||
regionCanvas.width = region.width;
|
||
regionCanvas.height = region.height;
|
||
const regionCtx = regionCanvas.getContext('2d');
|
||
if (!regionCtx) continue;
|
||
|
||
regionCtx.drawImage(
|
||
img,
|
||
region.x,
|
||
region.y,
|
||
region.width,
|
||
region.height,
|
||
0,
|
||
0,
|
||
region.width,
|
||
region.height
|
||
);
|
||
|
||
const regionImageUrl = regionCanvas.toDataURL('image/jpeg', 0.92);
|
||
|
||
// 递归拆分这个区域,累积位置偏移
|
||
const subElements = await splitRegionRecursively(
|
||
regionImageUrl,
|
||
depth + 1,
|
||
maxDepth,
|
||
offsetX + region.x, // 累积 X 偏移
|
||
offsetY + region.y // 累积 Y 偏移
|
||
);
|
||
allElements.push(...subElements);
|
||
}
|
||
|
||
return allElements;
|
||
}
|
||
|
||
/**
|
||
* 拆分灵感图图片(递归版本)
|
||
* 会递归拆分直到每个区域都无法再拆分为止
|
||
*/
|
||
export async function splitPhotoWall(imageUrl: string): Promise<ImageElement[]> {
|
||
const elements = await splitRegionRecursively(imageUrl, 0, 5);
|
||
|
||
// 重新分配 ID 和 index
|
||
const finalElements = elements.map((el, index) => ({
|
||
...el,
|
||
id: `photo-wall-${Date.now()}-${index}`,
|
||
originalIndex: index,
|
||
}));
|
||
|
||
// 按面积降序排序(大图在前)
|
||
finalElements.sort((a, b) => b.width * b.height - a.width * a.height);
|
||
|
||
return finalElements;
|
||
}
|