From 94d2e243efdc5cea67b0f9948eda680949486105 Mon Sep 17 00:00:00 2001 From: liquid Date: Fri, 2 Jan 2026 10:18:59 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=8E=BB=E7=99=BD=E8=BE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../drawnix/src/utils/image-border-utils.ts | 125 ++++- .../drawnix/src/utils/photo-wall-splitter.ts | 486 +++++++++++++----- 2 files changed, 461 insertions(+), 150 deletions(-) diff --git a/packages/drawnix/src/utils/image-border-utils.ts b/packages/drawnix/src/utils/image-border-utils.ts index b2b242af..dc95d9e6 100644 --- a/packages/drawnix/src/utils/image-border-utils.ts +++ b/packages/drawnix/src/utils/image-border-utils.ts @@ -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 { + 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; + } +} + /** * 加载图片 */ diff --git a/packages/drawnix/src/utils/photo-wall-splitter.ts b/packages/drawnix/src/utils/photo-wall-splitter.ts index 63c77408..fae18cf7 100644 --- a/packages/drawnix/src/utils/photo-wall-splitter.ts +++ b/packages/drawnix/src/utils/photo-wall-splitter.ts @@ -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 { + 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 { + 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 { +async function createBinaryMaskByColor(imageData: ImageData): Promise { 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 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`);