优化去白边
This commit is contained in:
1 parent
a49eba8eab
commit
94d2e243ef
2 files changed
+461
-150
No files matched your search
@@ -15,7 +15,7 @@ export interface BorderTrimResult {
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测像素是否为边框色(白色或灰色)
|
||||
* 检测像素是否为边框色(白色、灰色或透明/黑色)
|
||||
*/
|
||||
export function isBorderColor(
|
||||
r: number,
|
||||
@@ -26,15 +26,27 @@ export function isBorderColor(
|
||||
grayMinValue?: number;
|
||||
grayMaxValue?: number;
|
||||
maxColorDiff?: number;
|
||||
alpha?: number; // alpha 通道值
|
||||
} = {}
|
||||
): boolean {
|
||||
const {
|
||||
whiteThreshold = 240,
|
||||
grayMinValue = 160,
|
||||
grayMaxValue = 240,
|
||||
maxColorDiff = 20,
|
||||
whiteThreshold = 230, // 降低阈值,更容易检测白边
|
||||
grayMinValue = 150, // 降低阈值,检测更多灰色
|
||||
grayMaxValue = 255, // 扩展到纯白
|
||||
maxColorDiff = 25, // 稍微放宽颜色差异
|
||||
alpha = 255, // 默认不透明
|
||||
} = options;
|
||||
|
||||
// 检查是否为透明像素(PNG 透明背景)
|
||||
if (alpha < 128) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 检查是否为黑色或接近黑色(PNG 透明背景渲染后可能是黑色)
|
||||
if (r <= 10 && g <= 10 && b <= 10) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 检查是否为白色(高亮度)
|
||||
if (r >= whiteThreshold && g >= whiteThreshold && b >= whiteThreshold) {
|
||||
return true;
|
||||
@@ -102,11 +114,13 @@ export function isWhiteBorderPixel(
|
||||
* 从四个方向向内扫描,找到第一个非边框行/列
|
||||
*
|
||||
* @param imageData - Canvas ImageData
|
||||
* @param borderRatio - 判定为边框行/列的边框色占比阈值(默认 0.9)
|
||||
* @param borderRatio - 判定为边框行/列的边框色占比阈值(默认 0.5,更积极裁剪)
|
||||
* @param debug - 是否输出调试日志
|
||||
*/
|
||||
export function trimBorders(
|
||||
imageData: ImageData,
|
||||
borderRatio: number = 0.9
|
||||
borderRatio: number = 0.5,
|
||||
debug: boolean = false
|
||||
): BorderTrimResult {
|
||||
const { width, height, data } = imageData;
|
||||
|
||||
@@ -115,11 +129,16 @@ export function trimBorders(
|
||||
let borderCount = 0;
|
||||
for (let x = 0; x < width; x++) {
|
||||
const idx = (y * width + x) * 4;
|
||||
if (isBorderColor(data[idx], data[idx + 1], data[idx + 2])) {
|
||||
const alpha = data[idx + 3];
|
||||
if (isBorderColor(data[idx], data[idx + 1], data[idx + 2], { alpha })) {
|
||||
borderCount++;
|
||||
}
|
||||
}
|
||||
return borderCount / width > borderRatio;
|
||||
const ratio = borderCount / width;
|
||||
// if (debug && y < 5) {
|
||||
// console.log(`[trimBorders] Row ${y}: borderCount=${borderCount}/${width}, ratio=${ratio.toFixed(2)}`);
|
||||
// }
|
||||
return ratio > borderRatio;
|
||||
};
|
||||
|
||||
// 检测一列是否为边框列
|
||||
@@ -127,11 +146,16 @@ export function trimBorders(
|
||||
let borderCount = 0;
|
||||
for (let y = 0; y < height; y++) {
|
||||
const idx = (y * width + x) * 4;
|
||||
if (isBorderColor(data[idx], data[idx + 1], data[idx + 2])) {
|
||||
const alpha = data[idx + 3];
|
||||
if (isBorderColor(data[idx], data[idx + 1], data[idx + 2], { alpha })) {
|
||||
borderCount++;
|
||||
}
|
||||
}
|
||||
return borderCount / height > borderRatio;
|
||||
const ratio = borderCount / height;
|
||||
// if (debug && x < 5) {
|
||||
// console.log(`[trimBorders] Col ${x}: borderCount=${borderCount}/${height}, ratio=${ratio.toFixed(2)}`);
|
||||
// }
|
||||
return ratio > borderRatio;
|
||||
};
|
||||
|
||||
// 从顶部向下扫描
|
||||
@@ -158,6 +182,10 @@ export function trimBorders(
|
||||
right--;
|
||||
}
|
||||
|
||||
// if (debug) {
|
||||
// console.log(`[trimBorders] Result: top=${top}, bottom=${bottom}, left=${left}, right=${right}`);
|
||||
// }
|
||||
|
||||
return { top, right, bottom, left };
|
||||
}
|
||||
|
||||
@@ -166,17 +194,20 @@ export function trimBorders(
|
||||
*
|
||||
* @param sourceCanvas - 原始 Canvas
|
||||
* @param minSize - 裁剪后最小尺寸(默认 10)
|
||||
* @param debug - 是否输出调试日志
|
||||
* @returns 裁剪后的 Canvas,如果裁剪后太小则返回 null
|
||||
*/
|
||||
export function trimCanvasBorders(
|
||||
sourceCanvas: HTMLCanvasElement,
|
||||
minSize: number = 10
|
||||
minSize: number = 10,
|
||||
debug: boolean = false
|
||||
): HTMLCanvasElement | null {
|
||||
const ctx = sourceCanvas.getContext('2d', { willReadFrequently: true });
|
||||
if (!ctx) return null;
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, sourceCanvas.width, sourceCanvas.height);
|
||||
const borders = trimBorders(imageData);
|
||||
|
||||
const borders = trimBorders(imageData, 0.5, debug);
|
||||
|
||||
// 计算裁剪后的尺寸
|
||||
const trimmedWidth = borders.right - borders.left + 1;
|
||||
@@ -210,6 +241,74 @@ export function trimCanvasBorders(
|
||||
return trimmedCanvas;
|
||||
}
|
||||
|
||||
/**
|
||||
* 独立的去白边方法
|
||||
* 从图片 URL 加载图片,去除四边白边后返回新的 data URL
|
||||
*
|
||||
* @param imageUrl - 图片 URL 或 data URL
|
||||
* @param options - 配置选项
|
||||
* @returns 去除白边后的 data URL,如果失败返回原 URL
|
||||
*/
|
||||
export async function removeWhiteBorder(
|
||||
imageUrl: string,
|
||||
options: {
|
||||
borderRatio?: number; // 边框判定阈值(默认 0.3,更激进)
|
||||
debug?: boolean;
|
||||
} = {}
|
||||
): Promise<string> {
|
||||
const { borderRatio = 0.3, debug = false } = options;
|
||||
|
||||
try {
|
||||
const img = await loadImage(imageUrl);
|
||||
const { naturalWidth: width, naturalHeight: height } = img;
|
||||
|
||||
// 创建 Canvas
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
if (!ctx) return imageUrl;
|
||||
|
||||
ctx.drawImage(img, 0, 0);
|
||||
const imageData = ctx.getImageData(0, 0, width, height);
|
||||
|
||||
const borders = trimBorders(imageData, borderRatio, debug);
|
||||
|
||||
// 计算裁剪后的尺寸
|
||||
const trimmedWidth = borders.right - borders.left + 1;
|
||||
const trimmedHeight = borders.bottom - borders.top + 1;
|
||||
|
||||
// 如果没有裁剪(尺寸相同),直接返回原图
|
||||
if (trimmedWidth === width && trimmedHeight === height) {
|
||||
return imageUrl;
|
||||
}
|
||||
|
||||
// 创建裁剪后的 Canvas
|
||||
const trimmedCanvas = document.createElement('canvas');
|
||||
trimmedCanvas.width = trimmedWidth;
|
||||
trimmedCanvas.height = trimmedHeight;
|
||||
const trimmedCtx = trimmedCanvas.getContext('2d');
|
||||
if (!trimmedCtx) return imageUrl;
|
||||
|
||||
trimmedCtx.drawImage(
|
||||
canvas,
|
||||
borders.left,
|
||||
borders.top,
|
||||
trimmedWidth,
|
||||
trimmedHeight,
|
||||
0,
|
||||
0,
|
||||
trimmedWidth,
|
||||
trimmedHeight
|
||||
);
|
||||
|
||||
return trimmedCanvas.toDataURL('image/png', 0.92);
|
||||
} catch (error) {
|
||||
console.error('[removeWhiteBorder] Error:', error);
|
||||
return imageUrl;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载图片
|
||||
*/
|
||||
|
||||
@@ -4,16 +4,21 @@
|
||||
* 专门用于检测和拆分不规则照片墙图片
|
||||
* 特点:图片大小不一、位置不规则、有白色边框、灰色/白色背景间隔
|
||||
*
|
||||
* 算法思路:
|
||||
* 1. 检测背景颜色(灰色或白色间隔区域)
|
||||
* 2. 找到非背景的连通区域
|
||||
* 3. 为每个连通区域计算边界矩形
|
||||
* 4. 过滤掉太小的区域(噪点)
|
||||
* 5. 提取每个区域的图片
|
||||
* 算法思路(增强版 - 边缘 Flood Fill):
|
||||
* 1. 计算图像梯度(Sobel 边缘检测)
|
||||
* 2. 从四角采样检测背景颜色
|
||||
* 3. 从图像边缘开始 Flood Fill,标记"从边缘可达"的背景区域
|
||||
* - 使用梯度作为边界,防止填充进入图片内部
|
||||
* - 即使图片内容有白色,只要不与边缘连通就不会被标记为背景
|
||||
* 4. 膨胀操作连接相邻区域
|
||||
* 5. 连通区域标记,找到各个图片区域
|
||||
* 6. 过滤太小的区域(噪点)
|
||||
* 7. 合并重叠区域,提取图片
|
||||
*
|
||||
* 优化:
|
||||
* - 大图片自动降采样检测,避免阻塞主线程
|
||||
* - 异步处理,定期 yield 给浏览器
|
||||
* - 边缘检测失效时自动回退到颜色匹配算法
|
||||
*/
|
||||
|
||||
import type { ImageElement } from '../types/photo-wall.types';
|
||||
@@ -21,14 +26,14 @@ import {
|
||||
loadImage,
|
||||
isBackgroundPixel,
|
||||
isWhiteBorderPixel,
|
||||
extractAndTrimRegion,
|
||||
removeWhiteBorder,
|
||||
} from './image-border-utils';
|
||||
|
||||
/** 检测时的最大尺寸,超过此尺寸会降采样 */
|
||||
const MAX_DETECTION_SIZE = 1500;
|
||||
/** 检测时的最大尺寸,超过此尺寸会降采样(降低此值可提升性能) */
|
||||
const MAX_DETECTION_SIZE = 800;
|
||||
|
||||
/** 每处理多少像素后 yield 一次 */
|
||||
const YIELD_INTERVAL = 500000;
|
||||
/** 每处理多少像素后 yield 一次(降低此值可提升 UI 响应性) */
|
||||
const YIELD_INTERVAL = 100000;
|
||||
|
||||
/**
|
||||
* 让出主线程,避免阻塞 UI
|
||||
@@ -53,10 +58,221 @@ export interface PhotoWallDetectionResult {
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建二值化遮罩(异步版本)
|
||||
* 计算像素的灰度值
|
||||
*/
|
||||
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 = 30;
|
||||
// 颜色相似度阈值
|
||||
const colorThreshold = 40;
|
||||
|
||||
// 初始化:所有像素默认为前景 (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];
|
||||
|
||||
// 判断是否为背景:
|
||||
// 1. 颜色与背景相似
|
||||
// 2. 梯度不太大(不是边缘)
|
||||
// 3. 或者是纯白/灰色(通用背景色)
|
||||
const isBgColor = isSimilarToBackground(r, g, b, bgColor, colorThreshold);
|
||||
const isGenericBg = isBackgroundPixel(r, g, b) || isWhiteBorderPixel(r, g, b);
|
||||
const isLowGradient = grad < gradientThreshold;
|
||||
|
||||
if ((isBgColor || isGenericBg) && isLowGradient) {
|
||||
// 标记为背景
|
||||
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 createBinaryMask(imageData: ImageData): Promise<Uint8Array> {
|
||||
async function createBinaryMaskByColor(imageData: ImageData): Promise<Uint8Array> {
|
||||
const { width, height, data } = imageData;
|
||||
const mask = new Uint8Array(width * height);
|
||||
let processedPixels = 0;
|
||||
@@ -320,99 +536,6 @@ function mergeOverlappingBoxes(
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* 扩展边界矩形以包含白色边框
|
||||
*/
|
||||
function expandBoxesToIncludeBorder(
|
||||
boxes: Array<{ x: number; y: number; width: number; height: number }>,
|
||||
imageData: ImageData,
|
||||
padding: number = 5
|
||||
): Array<{ x: number; y: number; width: number; height: number }> {
|
||||
const { width: imgWidth, height: imgHeight, data } = imageData;
|
||||
|
||||
return boxes.map((box) => {
|
||||
// 向外扩展,寻找白色边框
|
||||
let { x, y, width, height } = box;
|
||||
|
||||
// 向左扩展
|
||||
while (x > 0) {
|
||||
let hasWhite = false;
|
||||
for (let py = y; py < y + height && py < imgHeight; py++) {
|
||||
const idx = (py * imgWidth + (x - 1)) * 4;
|
||||
if (isWhiteBorderPixel(data[idx], data[idx + 1], data[idx + 2])) {
|
||||
hasWhite = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (hasWhite) {
|
||||
x--;
|
||||
width++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 向右扩展
|
||||
while (x + width < imgWidth) {
|
||||
let hasWhite = false;
|
||||
for (let py = y; py < y + height && py < imgHeight; py++) {
|
||||
const idx = (py * imgWidth + (x + width)) * 4;
|
||||
if (isWhiteBorderPixel(data[idx], data[idx + 1], data[idx + 2])) {
|
||||
hasWhite = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (hasWhite) {
|
||||
width++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 向上扩展
|
||||
while (y > 0) {
|
||||
let hasWhite = false;
|
||||
for (let px = x; px < x + width && px < imgWidth; px++) {
|
||||
const idx = ((y - 1) * imgWidth + px) * 4;
|
||||
if (isWhiteBorderPixel(data[idx], data[idx + 1], data[idx + 2])) {
|
||||
hasWhite = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (hasWhite) {
|
||||
y--;
|
||||
height++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 向下扩展
|
||||
while (y + height < imgHeight) {
|
||||
let hasWhite = false;
|
||||
for (let px = x; px < x + width && px < imgWidth; px++) {
|
||||
const idx = ((y + height) * imgWidth + px) * 4;
|
||||
if (isWhiteBorderPixel(data[idx], data[idx + 1], data[idx + 2])) {
|
||||
hasWhite = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (hasWhite) {
|
||||
height++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 添加少量 padding
|
||||
return {
|
||||
x: Math.max(0, x - padding),
|
||||
y: Math.max(0, y - padding),
|
||||
width: Math.min(imgWidth - x + padding, width + padding * 2),
|
||||
height: Math.min(imgHeight - y + padding, height + padding * 2),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测照片墙中的图片区域
|
||||
@@ -452,54 +575,113 @@ export async function detectPhotoWallRegions(
|
||||
throw new Error('Failed to get canvas context');
|
||||
}
|
||||
|
||||
// 添加灰色边框,确保 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);
|
||||
|
||||
// 绘制原图到中心
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
const imageData = ctx.getImageData(0, 0, width, height);
|
||||
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] Creating binary mask...');
|
||||
let mask = await createBinaryMask(imageData);
|
||||
// 1. 计算梯度(边缘检测)
|
||||
console.log('[PhotoWallSplitter] Computing gradient...');
|
||||
const gradient = await computeGradientMagnitude(imageData);
|
||||
|
||||
await yieldToMain();
|
||||
|
||||
// 2. 膨胀操作,连接相邻区域
|
||||
// 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, width, height, 3);
|
||||
mask = await dilate(mask, paddedWidth, paddedHeight, 3);
|
||||
|
||||
await yieldToMain();
|
||||
|
||||
// 3. 连通区域标记
|
||||
// 5. 连通区域标记
|
||||
console.log('[PhotoWallSplitter] Labeling connected components...');
|
||||
const { labels, count } = await labelConnectedComponents(mask, width, height);
|
||||
const { labels, count } = await labelConnectedComponents(mask, paddedWidth, paddedHeight);
|
||||
console.log(`[PhotoWallSplitter] Found ${count} raw regions`);
|
||||
|
||||
await yieldToMain();
|
||||
|
||||
// 4. 计算边界矩形
|
||||
const rawBoxes = computeBoundingBoxes(labels, width, height, count);
|
||||
// 6. 计算边界矩形
|
||||
const rawBoxes = computeBoundingBoxes(labels, paddedWidth, paddedHeight, count);
|
||||
|
||||
// 5. 过滤太小的区域(注意:面积阈值也需要按比例缩放)
|
||||
// 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`);
|
||||
|
||||
// 6. 合并重叠的矩形
|
||||
// 8. 合并重叠的矩形
|
||||
const mergedBoxes = mergeOverlappingBoxes(filteredBoxes);
|
||||
console.log(`[PhotoWallSplitter] After merging: ${mergedBoxes.length} regions`);
|
||||
|
||||
// 7. 扩展边界以包含白色边框
|
||||
const expandedBoxes = expandBoxesToIncludeBorder(mergedBoxes, imageData);
|
||||
// 9. 不再扩展边界(之前会扩展太多白色区域)
|
||||
// 后续 splitPhotoWall 会使用 removeWhiteBorder 裁剪白边
|
||||
|
||||
// 8. 如果有降采样,将坐标映射回原始尺寸
|
||||
const finalBoxes = scale < 1
|
||||
? expandedBoxes.map((box) => ({
|
||||
x: Math.round(box.x / scale),
|
||||
y: Math.round(box.y / scale),
|
||||
width: Math.round(box.width / scale),
|
||||
height: Math.round(box.height / scale),
|
||||
}))
|
||||
: expandedBoxes;
|
||||
// 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,
|
||||
@@ -529,23 +711,53 @@ export async function splitPhotoWall(imageUrl: string): Promise<ImageElement[]>
|
||||
for (let i = 0; i < sortedRegions.length; i++) {
|
||||
const region = sortedRegions[i];
|
||||
|
||||
// 使用共享工具提取并裁剪区域边框
|
||||
const trimResult = extractAndTrimRegion(img, region);
|
||||
// 先提取区域(不裁剪)
|
||||
const regionCanvas = document.createElement('canvas');
|
||||
regionCanvas.width = region.width;
|
||||
regionCanvas.height = region.height;
|
||||
const regionCtx = regionCanvas.getContext('2d');
|
||||
if (!regionCtx) continue;
|
||||
|
||||
if (!trimResult) {
|
||||
console.log(`[PhotoWallSplitter] Region ${i} too small after trimming, skipping`);
|
||||
regionCtx.drawImage(
|
||||
img,
|
||||
region.x,
|
||||
region.y,
|
||||
region.width,
|
||||
region.height,
|
||||
0,
|
||||
0,
|
||||
region.width,
|
||||
region.height
|
||||
);
|
||||
|
||||
// 转换为 data URL
|
||||
const rawImageData = regionCanvas.toDataURL('image/png', 0.92);
|
||||
|
||||
// 使用独立的去白边方法
|
||||
const trimmedImageData = await removeWhiteBorder(rawImageData, {
|
||||
borderRatio: 0.3, // 更激进的裁剪
|
||||
});
|
||||
|
||||
// 获取裁剪后的尺寸
|
||||
const trimmedImg = await loadImage(trimmedImageData);
|
||||
const trimmedWidth = trimmedImg.naturalWidth;
|
||||
const trimmedHeight = trimmedImg.naturalHeight;
|
||||
|
||||
// 过滤太小的图片
|
||||
if (trimmedWidth < 50 || trimmedHeight < 50) {
|
||||
console.log(`[PhotoWallSplitter] Region ${i} too small after trimming (${trimmedWidth}x${trimmedHeight}), skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
elements.push({
|
||||
id: `photo-wall-${Date.now()}-${i}`,
|
||||
imageData: trimResult.canvas.toDataURL('image/png', 0.92),
|
||||
imageData: trimmedImageData,
|
||||
originalIndex: i,
|
||||
width: trimResult.width,
|
||||
height: trimResult.height,
|
||||
width: trimmedWidth,
|
||||
height: trimmedHeight,
|
||||
});
|
||||
|
||||
console.log(`[PhotoWallSplitter] Region ${i}: ${region.width}x${region.height} -> ${trimResult.width}x${trimResult.height}`);
|
||||
console.log(`[PhotoWallSplitter] Region ${i}: ${region.width}x${region.height} -> ${trimmedWidth}x${trimmedHeight}`);
|
||||
}
|
||||
|
||||
console.log(`[PhotoWallSplitter] Split into ${elements.length} images`);
|
||||
|
||||
Reference in new issue
Block a user