Created
December 1, 2025 03:31
-
-
Save ziofat/b21096f6599b143cb5567204c09534b2 to your computer and use it in GitHub Desktop.
A script to deskew card photos
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import * as cv from '@u4/opencv4nodejs'; | |
| import * as fs from 'fs'; | |
| import * as path from 'path'; | |
| import { execSync } from 'child_process'; | |
| // --- 配置 --- | |
| const CARD_WIDTH = 1080; // 输出卡片的宽度(像素) | |
| const CARD_HEIGHT = 1512; // 输出卡片的高度(像素),保持标准扑克牌比例 (63mm x 88mm) | |
| function processImage(filePath, finalWebpPath, outputDir, debugDir, debugMode, quality) { | |
| const filename = path.basename(filePath); | |
| const tempPngPath = path.join(outputDir, `${path.parse(filename).name}_temp.png`); | |
| const debugFile = name => path.join(debugDir, `${path.parse(path.basename(finalWebpPath)).name}_${name}.jpg`); | |
| try { | |
| const originalImage = cv.imread(filePath); | |
| if (originalImage.empty) { | |
| console.log(`无法读取图片: ${filename}`); | |
| return; | |
| } | |
| // --- 步骤 1: 获取卡片二值化 "面具" --- | |
| const grayImage = originalImage.cvtColor(cv.COLOR_BGR2GRAY); | |
| const blurredImage = grayImage.gaussianBlur(new cv.Size(7, 7), 0); | |
| // 默认使用 otsu 阈值化 | |
| const otsuMask = blurredImage.threshold(0, 255, cv.THRESH_BINARY + cv.THRESH_OTSU); | |
| let isDarkCard = false; | |
| let standardizedMask = otsuMask; | |
| if (otsuMask.at(5, 5) === 255) { | |
| // 1a. 使用自适应阈值 | |
| const adaptiveMask = blurredImage.adaptiveThreshold( | |
| 255, | |
| cv.ADAPTIVE_THRESH_GAUSSIAN_C, | |
| cv.THRESH_BINARY, | |
| 51, | |
| 8 | |
| ); | |
| standardizedMask = adaptiveMask.bitwiseNot(); | |
| isDarkCard = true; | |
| } | |
| if (debugMode) cv.imwrite(debugFile('a_standardized_mask'), standardizedMask); | |
| // 1c. 中值滤波移除椒盐噪声 | |
| const medianBlurredMask = standardizedMask.medianBlur(isDarkCard ? 15 : 45); | |
| if (debugMode) cv.imwrite(debugFile('a0_median_blurred_mask'), medianBlurredMask); | |
| // 1d. 使用温和的 "开" 运算来分离卡片与外部噪点 | |
| const openKernel = cv.getStructuringElement(cv.MORPH_RECT, isDarkCard ? new cv.Size(3, 3) : new cv.Size(45, 45)); | |
| const openedMask = medianBlurredMask.morphologyEx(openKernel, cv.MORPH_OPEN); | |
| if (debugMode) cv.imwrite(debugFile('a1_opened_mask'), openedMask); | |
| // 1e. 找到所有轮廓,并过滤掉面积过小的纯粹噪点 | |
| const allContours = openedMask.findContours(cv.RETR_EXTERNAL, cv.CHAIN_APPROX_SIMPLE); | |
| const significantContours = allContours.filter(c => c.area > (isDarkCard ? 500 : 5000)); | |
| if (significantContours.length === 0) { | |
| console.log(`[${filename}] 过滤后未找到有效轮廓,跳过。`); | |
| return; | |
| } | |
| // 1f. 将所有有效轮廓的点合并,并计算这个总点集的凸包 | |
| const allPoints = significantContours.flatMap(c => c.getPoints()); | |
| const hullOfAllPoints = new cv.Contour(allPoints); | |
| const hullContour = hullOfAllPoints.convexHull(); | |
| // --- 步骤 2: 使用最终的凸包轮廓创建并平滑实心蒙版 --- | |
| const solidMask = new cv.Mat(originalImage.rows, originalImage.cols, cv.CV_8UC1, 0); | |
| solidMask.drawContours([hullContour.getPoints()], -1, new cv.Vec3(255, 255, 255), cv.FILLED); | |
| if (debugMode) cv.imwrite(debugFile('a4_solid_mask_from_hull'), solidMask); | |
| // --- 使用高斯模糊和阈值法来平滑蒙版的圆角 --- | |
| const blurredSolidMask = solidMask.gaussianBlur(new cv.Size(21, 21), 0); | |
| const smoothedSolidMask = blurredSolidMask.threshold(128, 255, cv.THRESH_BINARY); | |
| if (debugMode) cv.imwrite(debugFile('a5_smoothed_solid_mask'), smoothedSolidMask); | |
| // --- 步骤 3: 使用修复后的轮廓获取精确角点 --- | |
| const rotatedRect = hullContour.minAreaRect(); | |
| const { center, size, angle } = rotatedRect; | |
| const angleRad = angle * Math.PI / 180.0; | |
| const cos = Math.cos(angleRad); | |
| const sin = Math.sin(angleRad); | |
| const halfWidth = size.width / 2; | |
| const halfHeight = size.height / 2; | |
| const boxPoints = [ | |
| new cv.Point2(center.x + (-halfWidth * cos - (-halfHeight) * sin), center.y + (-halfWidth * sin + (-halfHeight) * cos)), | |
| new cv.Point2(center.x + (halfWidth * cos - (-halfHeight) * sin), center.y + (halfWidth * sin + (-halfHeight) * cos)), | |
| new cv.Point2(center.x + (halfWidth * cos - halfHeight * sin), center.y + (halfWidth * sin + halfHeight * cos)), | |
| new cv.Point2(center.x + (-halfWidth * cos - halfHeight * sin), center.y + (-halfWidth * sin + halfHeight * cos)) | |
| ]; | |
| if (debugMode) { | |
| const minAreaRectImage = originalImage.copy(); | |
| minAreaRectImage.drawContours([boxPoints], -1, new cv.Vec3(0, 255, 0), 2); | |
| cv.imwrite(debugFile('b_min_area_rect'), minAreaRectImage); | |
| } | |
| // --- 步骤 4: 排序角点并计算变换矩阵 --- | |
| const sortedPoints = boxPoints.sort((a, b) => a.y - b.y); | |
| const topPoints = sortedPoints.slice(0, 2).sort((a, b) => a.x - b.x); | |
| const bottomPoints = sortedPoints.slice(2, 4).sort((a, b) => a.x - b.x); | |
| const tl = topPoints[0]; | |
| const tr = topPoints[1]; | |
| const bl = bottomPoints[0]; | |
| const br = bottomPoints[1]; | |
| const srcPoints = [tl, tr, br, bl]; | |
| if (debugMode) { | |
| const finalBoxImage = originalImage.copy(); | |
| finalBoxImage.drawContours([srcPoints], -1, new cv.Vec3(255, 0, 0), 3); | |
| cv.imwrite(debugFile('c_final_bounding_box'), finalBoxImage); | |
| } | |
| const dstPoints = [ | |
| new cv.Point2(0, 0), | |
| new cv.Point2(CARD_WIDTH - 1, 0), | |
| new cv.Point2(CARD_WIDTH - 1, CARD_HEIGHT - 1), | |
| new cv.Point2(0, CARD_HEIGHT - 1), | |
| ]; | |
| const transformMatrix = cv.getPerspectiveTransform(srcPoints, dstPoints); | |
| // --- 步骤 5: 应用变换并创建平滑的 Alpha Mask --- | |
| const warpedImage = originalImage.warpPerspective(transformMatrix, new cv.Size(CARD_WIDTH, CARD_HEIGHT)); | |
| // 使用平滑后的蒙版进行变换,以生成最终的 Alpha 通道 | |
| const warpedMask = smoothedSolidMask.warpPerspective(transformMatrix, new cv.Size(CARD_WIDTH, CARD_HEIGHT)); | |
| if (debugMode) cv.imwrite(debugFile('d_warped_alpha_mask'), warpedMask); | |
| const smoothedMaskForAlpha = warpedMask.gaussianBlur(new cv.Size(5, 5), 0); | |
| if (debugMode) cv.imwrite(debugFile('d2_smoothed_mask_for_alpha'), smoothedMaskForAlpha); | |
| // --- 步骤 6: 合并通道并使用 nconvert 进行最终压缩 --- | |
| const bgraImage = warpedImage.cvtColor(cv.COLOR_BGR2BGRA); | |
| const [b, g, r, a] = bgraImage.split(); | |
| const finalImageWithAlpha = new cv.Mat([b, g, r, smoothedMaskForAlpha]); | |
| cv.imwrite(tempPngPath, finalImageWithAlpha); | |
| const nconvertCommand = `nconvert -overwrite -out webp -q ${quality} -o "${finalWebpPath}" "${tempPngPath}"`; | |
| if (debugMode) { | |
| console.log(`[${filename}] 正在执行: ${nconvertCommand}`); | |
| } | |
| execSync(nconvertCommand, { stdio: 'ignore' }); | |
| console.log(`[${filename}] -> ${path.basename(finalWebpPath)} 处理完成!`); | |
| } catch (error) { | |
| console.error(`处理文件 ${filename} 时发生错误:`, error); | |
| } finally { | |
| if (fs.existsSync(tempPngPath)) { | |
| fs.unlinkSync(tempPngPath); | |
| } | |
| } | |
| } | |
| function main() { | |
| // 1. 解析命令行参数 | |
| const args = process.argv.slice(2); | |
| const debugMode = args.includes('--debug'); | |
| let quality = 85; // 默认压缩质量 | |
| const qualityArgIndex = args.findIndex(arg => arg === '--quality'); | |
| if (qualityArgIndex > -1 && args[qualityArgIndex + 1]) { | |
| const qualityValue = parseInt(args[qualityArgIndex + 1], 10); | |
| if (!isNaN(qualityValue) && qualityValue >= 1 && qualityValue <= 100) { | |
| quality = qualityValue; | |
| } | |
| } | |
| const backsArgIndex = args.findIndex(arg => arg === '--backs'); | |
| let backsArg = null; | |
| if (backsArgIndex > -1 && args[backsArgIndex + 1]) { | |
| backsArg = args[backsArgIndex + 1]; | |
| } | |
| const inputPath = args.find(arg => !arg.startsWith('--') && !/^\d+$/.test(arg) && arg !== backsArg); | |
| if (!inputPath) { | |
| console.error("错误: 请提供一个输入文件或目录的路径。"); | |
| console.log("\n用法:"); | |
| console.log(" deskew /path/to/your/directory"); | |
| console.log("\n选项:"); | |
| console.log(" --debug 输出处理过程中的调试图片"); | |
| console.log(" --quality <1-100> 设置WebP输出质量 (默认: 85)"); | |
| console.log(" --backs <num,num,...|all> 指定牌背模式"); | |
| return; | |
| } | |
| if (!fs.existsSync(inputPath)) { | |
| console.error(`错误: 路径不存在: ${inputPath}`); | |
| return; | |
| } | |
| const stats = fs.statSync(inputPath); | |
| if (!stats.isDirectory()) { | |
| console.error("错误: 当前版本只支持处理整个目录,不支持单个文件。"); | |
| return; | |
| } | |
| const outputDir = path.join(path.dirname(inputPath), `raw`); | |
| const filesToProcess = fs.readdirSync(inputPath) | |
| .filter(f => /\.(jpg|jpeg|png)$/i.test(f)) | |
| .map(f => path.join(inputPath, f)); | |
| if (filesToProcess.length === 0) { | |
| console.log("在指定路径下未找到可处理的图片文件。"); | |
| return; | |
| } | |
| let debugDir = null; | |
| if (!fs.existsSync(outputDir)) { | |
| fs.mkdirSync(outputDir, { recursive: true }); | |
| } | |
| if (debugMode) { | |
| debugDir = path.join(outputDir, 'debug'); | |
| if (!fs.existsSync(debugDir)) { | |
| fs.mkdirSync(debugDir, { recursive: true }); | |
| } | |
| } | |
| // --- 智能重命名逻辑 --- | |
| const tasks = []; | |
| const pad = (num) => num.toString().padStart(2, '0'); | |
| if (backsArg === 'all') { | |
| // 模式: 全部正反交替 | |
| console.log("模式: 全部正反交替"); | |
| for (let i = 0; i < filesToProcess.length; i += 2) { | |
| const cardIndex = pad(i / 2 + 1); | |
| tasks.push({ | |
| input: filesToProcess[i], | |
| output: path.join(outputDir, `face-${cardIndex}.webp`) | |
| }); | |
| if (filesToProcess[i + 1]) { | |
| tasks.push({ | |
| input: filesToProcess[i + 1], | |
| output: path.join(outputDir, `back-${cardIndex}.webp`) | |
| }); | |
| } | |
| } | |
| } else if (backsArg) { | |
| // 模式: 指定牌背 (数字列表) | |
| console.log(`模式: 指定牌背 (数字列表: ${backsArg})`); | |
| const uniqueBacksSet = new Set(backsArg.split(',').map(s => parseInt(s.trim(), 10))); | |
| let faceCounter = 0; | |
| let fileIndex = 0; | |
| while (fileIndex < filesToProcess.length) { | |
| if (fileIndex === filesToProcess.length - 1) { | |
| tasks.push({ | |
| input: filesToProcess[fileIndex], | |
| output: path.join(outputDir, 'back-00.webp') | |
| }); | |
| break; | |
| } | |
| faceCounter++; | |
| tasks.push({ | |
| input: filesToProcess[fileIndex], | |
| output: path.join(outputDir, `face-${pad(faceCounter)}.webp`) | |
| }); | |
| fileIndex++; | |
| if (uniqueBacksSet.has(faceCounter)) { | |
| if (fileIndex < filesToProcess.length) { | |
| tasks.push({ | |
| input: filesToProcess[fileIndex], | |
| output: path.join(outputDir, `back-${pad(faceCounter)}.webp`) | |
| }); | |
| fileIndex++; | |
| } else { | |
| console.warn(`警告: face-${pad(faceCounter)} 被指定了专属牌背,但后面没有更多图片了。`); | |
| } | |
| } | |
| } | |
| } else { | |
| // 模式: 单牌背 (默认) | |
| console.log("模式: 单牌背 (全部正面 + 1张背面)"); | |
| const total = filesToProcess.length; | |
| if (total > 0) { | |
| filesToProcess.slice(0, -1).forEach((file, i) => { | |
| tasks.push({ | |
| input: file, | |
| output: path.join(outputDir, `face-${pad(i + 1)}.webp`) | |
| }); | |
| }); | |
| tasks.push({ | |
| input: filesToProcess[total - 1], | |
| output: path.join(outputDir, 'back-00.webp') | |
| }); | |
| } | |
| } | |
| // --- 执行处理任务 --- | |
| console.log(`找到 ${filesToProcess.length} 张图片,生成 ${tasks.length} 个处理任务...`); | |
| for (const task of tasks) { | |
| processImage(task.input, task.output, outputDir, debugDir, debugMode, quality); | |
| if (global.gc) { | |
| global.gc(); | |
| console.log(`[内存] 已为 ${path.basename(task.input)} 执行垃圾回收。`); | |
| } | |
| } | |
| console.log('所有图片处理完毕!'); | |
| console.log(`输出文件已保存至: ${outputDir}`); | |
| // --- 调用 merge.sh 脚本 --- | |
| try { | |
| console.log('\n开始拼接图片...'); | |
| // 注意: process.argv[1] 在 node process.js 调用时是 'process.js' | |
| // 为了在任何地方都能正确找到脚本,我们假设 merge.sh 和 process.js 在同一目录 | |
| const scriptDir = path.dirname(import.meta.url.replace('file://', '')); | |
| const mergeScriptPath = path.join(scriptDir, 'merge.sh'); | |
| if (fs.existsSync(mergeScriptPath)) { | |
| execSync(`bash "${mergeScriptPath}" "${outputDir}"`, { stdio: 'inherit' }); | |
| } else { | |
| console.error(`错误: 未找到 merge.sh 脚本,路径: ${mergeScriptPath}`); | |
| } | |
| } catch (error) { | |
| console.error('拼接图片时出错:', error); | |
| } | |
| } | |
| main(); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| # ============================================================================== | |
| # 图像批量处理脚本 (缩放与拼接) | |
| # | |
| # 功能: | |
| # 1. 将指定文件夹下已命名好的 .webp 文件 (如 face-01.webp, back-00.webp) | |
| # 使用 ImageMagick 缩放至 250x350 像素。 | |
| # 2. 使用 ImageMagick 将缩放后的 face-* 和 back-* 图片分别拼接成两张大图。 | |
| # | |
| # 前提: | |
| # 源文件夹中的图片必须已经手动命名为 "face-xx.webp" 和 "back-xx.webp" 格式。 | |
| # | |
| # 使用方法: | |
| # 1. 将此脚本保存为 merge.sh。 | |
| # 2. 在终端中给予执行权限: chmod +x merge.sh | |
| # 3. 运行脚本,可选择性提供源文件夹路径: | |
| # ./merge.sh (处理当前目录下的图片) | |
| # ./merge.sh /path/to/images (处理指定目录下的图片) | |
| # | |
| # 依赖: imagemagick | |
| # ============================================================================== | |
| # 设置 PATH 环境变量,确保脚本能找到所有需要的命令 | |
| export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin" | |
| # --- 脚本安全设置 --- | |
| set -e # 如果任何命令失败,立即退出脚本 | |
| set -o pipefail # 如果管道中的任何命令失败,则整个管道失败 | |
| # --- 1. 初始化和验证 --- | |
| # 检查依赖工具是否存在 | |
| if ! command -v magick &> /dev/null; then | |
| echo "错误: 'magick' 命令未找到 (ImageMagick 的一部分)。" | |
| echo "请使用 Homebrew 安装: brew install imagemagick" | |
| exit 1 | |
| fi | |
| # 设置源文件夹,如果未提供第一个参数,则默认为当前目录 "." | |
| SRC_DIR="${1:-.}" | |
| # 检查源文件夹是否存在 | |
| if [ ! -d "$SRC_DIR" ]; then | |
| echo "错误: 源文件夹 '$SRC_DIR' 不存在或不是一个目录。" | |
| exit 1 | |
| fi | |
| echo "源文件夹设置为: $SRC_DIR" | |
| # --- 2. 设置目录 --- | |
| RESIZED_DIR="$(dirname "$SRC_DIR")/resized" | |
| FINAL_DIR="$(dirname "$SRC_DIR")/final_output" | |
| # 创建输出目录 | |
| mkdir -p "$RESIZED_DIR" "$FINAL_DIR" | |
| echo "创建工作目录..." | |
| # 检查源文件夹中是否有 .webp 文件 | |
| if ! ls "$SRC_DIR"/*.webp 1> /dev/null 2>&1; then | |
| echo "错误: 在文件夹 '$SRC_DIR' 中未找到任何 .webp 文件。" | |
| rm -rf "$RESIZED_DIR" "$FINAL_DIR" # 清理空目录 | |
| exit 1 | |
| fi | |
| # --- 3. 批量缩放 --- | |
| echo "步骤 1/3: 使用 ImageMagick 缩放图片至 250x350..." | |
| # 使用 for 循环逐一处理每个文件 | |
| for file in "$SRC_DIR"/*.webp; do | |
| # 检查以确保它是一个文件(而不是匹配失败的文字字符串) | |
| if [ -f "$file" ]; then | |
| # 获取不带路径的文件名 (例如: "face-01.webp") | |
| base_filename=$(basename "$file") | |
| echo " -> 正在处理 $base_filename" | |
| # [修改] 使用 ImageMagick (magick 命令) 进行缩放 | |
| # -resize 250x350! 感叹号表示强制拉伸到指定尺寸,忽略原始宽高比 | |
| magick "$file" -resize 250x350! -quality 80 -strip "$RESIZED_DIR/$base_filename" | |
| fi | |
| done | |
| echo "图片缩放完成,文件已存入 '$RESIZED_DIR' 目录。" | |
| # --- 4. 拼接图片 --- | |
| echo "步骤 2/3: 使用 ImageMagick (montage) 拼接图片..." | |
| # 拼接 face 图片 | |
| # -tile 8x: 每行8个,自动计算行数 | |
| # -geometry +0+0: 图片之间无间距 | |
| # -background none: 设置背景为透明,以防图片尺寸不一时出现白边 | |
| if ls "$RESIZED_DIR"/face-*.webp 1> /dev/null 2>&1; then | |
| montage "$RESIZED_DIR"/face-*.webp -tile 8x -geometry 250x350+0+0 -background none -quality 80 -strip "$FINAL_DIR/faces.webp" | |
| echo "已成功创建 'faces.webp'。" | |
| else | |
| echo "未找到 face-* 文件,跳过拼接 faces.webp。" | |
| fi | |
| # 拼接 back 图片 | |
| if ls "$RESIZED_DIR"/back-*.webp 1> /dev/null 2>&1; then | |
| montage "$RESIZED_DIR"/back-*.webp -tile 8x -geometry 250x350+0+0 -background none -quality 80 -strip "$FINAL_DIR/backs.webp" | |
| echo "已成功创建 'backs.webp'。" | |
| else | |
| echo "未找到 back-* 文件,跳过拼接 backs.webp。" | |
| fi | |
| # --- 5. 清理 --- | |
| echo "步骤 3/3: 清理临时目录..." | |
| rm -rf "$RESIZED_DIR" | |
| echo "临时目录已删除。" | |
| echo "" | |
| echo "🎉 处理完成!" | |
| echo "最终文件已保存在 '$FINAL_DIR' 目录下。" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment