Skip to content

Instantly share code, notes, and snippets.

@ziofat
Created December 1, 2025 03:31
Show Gist options
  • Select an option

  • Save ziofat/b21096f6599b143cb5567204c09534b2 to your computer and use it in GitHub Desktop.

Select an option

Save ziofat/b21096f6599b143cb5567204c09534b2 to your computer and use it in GitHub Desktop.
A script to deskew card photos
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();
#!/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