简介:这是一份面向计算机视觉与算法实践者的C++拼图求解项目源码,核心思路是通过提取拼图碎片的边缘形状特征完成自动匹配与拼接,适合具备一定OpenCV基础、希望深入理解图像处理与几何匹配的开发者学习参考。资源包共53个文件,约239.07MB,以32个tiff图像样本、6个cpp源文件、5个头文件为主,另含Xcode工程配置与说明文档,涵盖拼图、碎片、边、并查集等模块,结构清晰便于按功能阅读。项目依赖OpenCV 2.4.x版本,描述中给出了OSX与Ubuntu下的编译安装思路,可作为环境搭建的参考。目前已有750人学习下载。通过阅读源码,读者可掌握边缘检测、形状描述、碎片匹配与并查集合并等关键实现,并借助内置的Toy Story、Angry Birds、horses等样例图像验证算法效果,适合作为课程设计或视觉算法练手项目。
1. 从一堆散落碎片到完整图像:PuzzleSolver 到底在解什么题
如果你玩过实体拼图,大概体会过那种“明明知道就差最后几块,但怎么都塞不进去”的抓狂。把这件事搬到程序里,难度其实不降反升——因为计算机没有“直觉”,它只能靠像素和几何关系去猜。PuzzleSolver 就是干这个的:它用计算机视觉的方法,通过分析每块拼图碎片的边缘形状,自动判断哪些碎片应该相邻,最终把一张被切碎的图重新拼回去。核心语言是 C++,意味着它对性能有要求,不是那种跑一张图等半天的玩具脚本。
这个资源适合两类人:一类是正在找计算机视觉实战项目的学生或自学者,想看看边缘检测、轮廓匹配、几何变换这些知识点怎么串成一个完整系统;另一类是做图像处理相关工作的工程师,需要一个可拆解、可改参数的拼图求解框架来验证自己的思路。它解决的不是“识别图片里有什么”,而是“这些碎片之间的空间关系是什么”——这是一个典型的组合优化问题,只不过用视觉特征来驱动搜索。
我拿到这个项目的第一反应是:它不像那些只跑一个 demo 就结束的课程作业,而是把“碎片预处理 → 边缘特征提取 → 配对打分 → 全局拼装”这条链路都铺开了。你可以只改其中某一环,比如换一种边缘描述子,或者换一种搜索策略,然后观察最终拼装结果的变化。这种可插拔的结构,比一个黑盒式的“一键拼图”有价值得多。
2. 边缘形状怎么变成可计算的配对分数:从轮廓到匹配代价
2.1 碎片预处理与轮廓提取
拼图碎片在图像里通常是一块不规则多边形区域,背景可能是纯色也可能是复杂纹理。第一步不是急着找边缘,而是先把碎片从背景里分离出来。常见做法是转灰度、做阈值分割或者用 Canny 边缘检测拿到二值轮廓,然后用findContours提取外轮廓。这里有个容易翻车的地方:如果碎片本身颜色和背景接近,阈值分割会漏掉一部分边缘,导致轮廓不闭合。我一般会先做一次形态学闭运算,把断裂的边缘连上,再提取轮廓。
// 读取碎片图像并提取主轮廓 cv::Mat src = cv::imread("piece_01.png"); cv::Mat gray, binary; cv::cvtColor(src, gray, cv::COLOR_BGR2GRAY); cv::threshold(gray, binary, 0, 255, cv::THRESH_BINARY_INV | cv::THRESH_OTSU); // 闭运算连接断裂边缘,kernel 大小根据碎片分辨率调整 cv::Mat kernel = cv::getStructuringElement(cv::MORPH_ELLIPSE, cv::Size(5, 5)); cv::morphologyEx(binary, binary, cv::MORPH_CLOSE, kernel); std::vector<std::vector<cv::Point>> contours; cv::findContours(binary, contours, cv::RETR_EXTERNAL, cv::CHAIN_APPROX_NONE); // 取面积最大的轮廓作为碎片主体,过滤噪点 auto maxIt = std::max_element(contours.begin(), contours.end(), [](const auto& a, const auto& b) { return cv::contourArea(a) < cv::contourArea(b); }); std::vector<cv::Point> pieceContour = *maxIt;这段代码的逻辑是:先二值化把碎片变成白色前景、黑色背景,闭运算用来修补边缘缺口,最后取最大轮廓。参数上,THRESH_OTSU适合背景和前景灰度差异明显的情况;如果背景复杂,得换成自适应阈值或者手动指定阈值。MORPH_CLOSE的 kernel 尺寸不要太大,否则会把碎片内部的孔洞也填上,影响后续边缘描述。
2.2 边缘描述子与配对代价函数
拿到轮廓之后,关键问题变成:怎么描述一条边缘的形状,才能让“应该相邻的两条边”得分高?常见做法是把轮廓按曲率或者弧长分段,然后对每一段提取特征。PuzzleSolver 里用的是基于边缘点序列的匹配方式——对两块碎片的轮廓,分别取它们的外边界点,然后计算两条曲线在形状上的相似度。具体来说,会把轮廓点重采样成固定数量的点,然后计算点对之间的欧氏距离和切线方向差异,加权求和得到一个配对代价。
// 将轮廓重采样为固定点数,便于后续配对计算 std::vector<cv::Point2f> resampleContour(const std::vector<cv::Point>& contour, int N) { std::vector<cv::Point2f> result; double perimeter = cv::arcLength(contour, true); double step = perimeter / N; double accumulated = 0.0; size_t idx = 0; for (int i = 0; i < N; ++i) { double target = i * step; while (accumulated < target && idx < contour.size() - 1) { accumulated += cv::norm(contour[idx + 1] - contour[idx]); ++idx; } result.push_back(cv::Point2f(contour[idx])); } return result; } // 计算两条边缘的配对代价:距离 + 方向差异 double edgeMatchingCost(const std::vector<cv::Point2f>& edgeA, const std::vector<cv::Point2f>& edgeB) { double cost = 0.0; for (size_t i = 0; i < edgeA.size(); ++i) { // 对应点之间的欧氏距离 double dist = cv::norm(edgeA[i] - edgeB[i]); // 切线方向差异,用相邻点向量近似 cv::Point2f dirA = edgeA[(i + 1) % edgeA.size()] - edgeA[i]; cv::Point2f dirB = edgeB[(i + 1) % edgeB.size()] - edgeB[i]; double angleDiff = std::abs(std::atan2(dirA.y, dirA.x) - std::atan2(dirB.y, dirB.x)); cost += dist + 0.5 * angleDiff; // 权重 0.5 可调 } return cost / edgeA.size(); }重采样的目的是消除不同碎片轮廓点数不一致带来的干扰。配对代价里,距离项衡量两条边在空间上是否贴合,方向项衡量走向是否一致。权重 0.5 是我习惯的起点,实际调的时候如果发现方向项太敏感,可以降到 0.2 左右;如果碎片边缘噪声大,距离项反而要降权。这个代价函数不是唯一解,你也可以换成 Hu 矩、Fourier 描述子或者基于深度学习的嵌入向量,但那样就需要额外训练数据了。
2.3 全局拼装:从配对矩阵到最终布局
有了所有碎片两两之间的配对代价,接下来就是搜索一个全局一致的排列。暴力枚举所有排列在碎片数量超过 8 块时基本不可行,常见做法是贪心加回溯:先选代价最小的一对作为种子,然后逐步把剩余碎片往已有布局的边缘上贴,每一步都选当前代价最小的候选,如果出现冲突就回退。PuzzleSolver 里实现了一个简化版的搜索,核心是维护一个“已放置碎片集合”和“待放置碎片列表”,每次迭代尝试所有可能的邻接关系。
// 贪心+回溯的简化拼装流程 struct Placement { int pieceId; int edgeId; // 当前碎片用于连接的边 int targetId; // 目标碎片 int targetEdge; // 目标碎片用于连接的边 double cost; }; std::vector<Placement> solvePuzzle(const std::vector<Piece>& pieces, const std::vector<std::vector<double>>& costMatrix) { std::vector<Placement> layout; std::vector<bool> placed(pieces.size(), false); // 选全局代价最小的一对作为起点 auto [a, b, c] = findBestPair(costMatrix); layout.push_back({a, 0, b, 0, c}); placed[a] = placed[b] = true; while (std::count(placed.begin(), placed.end(), true) < pieces.size()) { double bestCost = std::numeric_limits<double>::max(); Placement best{}; bool found = false; for (size_t i = 0; i < pieces.size(); ++i) { if (placed[i]) continue; for (const auto& p : layout) { // 尝试将未放置碎片 i 接到已放置碎片 p 的某条边上 for (int e = 0; e < 4; ++e) { double cost = costMatrix[i][p.pieceId]; if (cost < bestCost) { bestCost = cost; best = {static_cast<int>(i), e, p.pieceId, p.edgeId, cost}; found = true; } } } } if (!found) break; // 无法继续,可能碎片不完整 layout.push_back(best); placed[best.pieceId] = true; } return layout; }这段代码里,costMatrix是预先算好的两两配对代价矩阵,findBestPair负责找初始种子。贪心的局限在于局部最优可能导致后面无解,所以实际用的时候我会加一个简单的回溯:如果某一步所有候选代价都超过阈值,就撤销上一步重新选。阈值一般设成平均代价的 1.5 到 2 倍,具体看碎片边缘的噪声水平。
3. 编译、运行与参数调优:把 PuzzleSolver 跑起来的完整路径
3.1 环境依赖与编译配置
这个项目依赖 OpenCV,版本建议 4.x,因为用到了cv::Point2f和arcLength这些在 3.x 里也有但行为略有差异的接口。C++ 标准至少 C++14,因为代码里用了结构化绑定和auto返回类型推导。编译方式我习惯用 CMake,比手写 g++ 命令省心,尤其是要链接 OpenCV 的多个模块时。
cmake_minimum_required(VERSION 3.10) project(PuzzleSolver) set(CMAKE_CXX_STANDARD 14) set(CMAKE_CXX_STANDARD_REQUIRED ON) find_package(OpenCV REQUIRED) add_executable(puzzle_solver src/main.cpp src/contour_extractor.cpp src/edge_matcher.cpp src/puzzle_assembler.cpp ) target_include_directories(puzzle_solver PRIVATE ${OpenCV_INCLUDE_DIRS}) target_link_libraries(puzzle_solver ${OpenCV_LIBS})CMakeLists 里关键是find_package(OpenCV REQUIRED),它会自动找到系统里安装的 OpenCV 路径。如果 OpenCV 装在非标准位置,需要手动指定OpenCV_DIR。编译命令就是常规的mkdir build && cd build && cmake .. && make。我遇到过在某个环境里find_package找到的是旧版 OpenCV,导致链接时报符号缺失,解决办法是在 CMake 里加set(OpenCV_DIR "/your/opencv/install/lib/cmake/opencv4")强制指定。
3.2 输入数据组织与运行参数
PuzzleSolver 的输入是一组碎片图像,通常放在一个文件夹里,命名可以是piece_01.png、piece_02.png这样。程序启动时会遍历文件夹,对每张图提取轮廓和边缘描述子,然后构建代价矩阵,最后输出拼装顺序和每块碎片的相对位置。运行时的关键参数包括:重采样点数 N、配对代价里的距离权重和方向权重、贪心搜索的代价阈值。
| 参数 | 含义 | 常用范围 | 调整建议 |
|---|---|---|---|
| N | 轮廓重采样点数 | 50~200 | 碎片边缘复杂时取大,简单时取小 |
| w_dist | 距离项权重 | 0.5~1.0 | 边缘噪声大时降低 |
| w_angle | 方向项权重 | 0.2~0.5 | 碎片形状差异大时提高 |
| cost_threshold | 回溯触发阈值 | 平均代价的 1.5~2 倍 | 碎片数量多时适当放宽 |
这些参数在代码里通常以常量或者配置文件的形式存在。我一般会先跑一遍默认值,看输出布局和预期差多少,然后只调一个参数,观察变化方向。比如发现拼出来的碎片总是错位一格,可能是方向权重太低,导致边缘走向不匹配的碎片被误选。
3.3 输出解读与中间结果可视化
程序输出一般是一个文本文件,记录每块碎片的 ID 和它在最终画布上的位置(比如左上角坐标或者相对于某块碎片的偏移)。但光看文本很难判断对不对,所以我会加一段可视化代码,把拼装结果画出来,和原始完整图像对比。OpenCV 的imshow或者直接imwrite都行。
// 将拼装结果绘制到一张大图上,便于肉眼验证 cv::Mat canvas = cv::Mat::zeros(2000, 2000, CV_8UC3); for (const auto& p : layout) { cv::Mat pieceImg = cv::imread("pieces/piece_" + std::to_string(p.pieceId) + ".png"); cv::Rect roi(p.x, p.y, pieceImg.cols, pieceImg.rows); pieceImg.copyTo(canvas(roi)); } cv::imwrite("result.png", canvas);这里p.x和p.y是根据拼装顺序推算出来的坐标,实际代码里需要根据边缘匹配时的相对位移累加。可视化最大的好处是能一眼看出“哪块放错了”,然后回头去查那块碎片的边缘提取是不是有问题。我踩过的一个坑是:碎片图像本身有透明通道,imread默认读成三通道,透明区域变成黑色,导致轮廓提取时多出一圈黑边。解决办法是加cv::IMREAD_UNCHANGED然后单独处理 alpha 通道。
4. 避坑与排查:边缘匹配里那些让人怀疑人生的瞬间
4.1 轮廓提取不完整,导致配对代价全是噪声
现象:所有碎片两两之间的配对代价都差不多,没有明显的“低代价对”,拼装结果随机性很大。原因:碎片边缘在二值化后出现断裂,findContours拿到的轮廓不连续,重采样后的点序列跳过了断裂区域,导致边缘描述失真。解决:在二值化之后加一次闭运算,kernel 大小从 3x3 开始试,如果碎片边缘本身很细,可以先用cv::dilate膨胀一次再提取轮廓。另外检查THRESH_OTSU是否合适,背景复杂时换成自适应阈值。
4.2 重采样点数 N 选得不对,匹配敏感度异常
现象:N 设成 50 时拼装结果还行,设成 200 反而变差。原因:N 太大时,重采样点会落在轮廓的微小锯齿上,这些锯齿在不同碎片上的分布是随机的,导致距离项被噪声主导。解决:N 的选择要和碎片边缘的平滑程度匹配。如果轮廓本身有较多高频细节,先做一次高斯平滑或者用cv::approxPolyDP做多边形逼近,再重采样。我一般从 100 开始试,上下调整 50 看结果稳定性。
4.3 贪心搜索陷入局部最优,后面无解
现象:前几块拼得很准,到后面几块时找不到低代价的候选,程序直接退出,输出不完整布局。原因:贪心策略在早期选了一个局部最优但全局不利的配对,导致剩余碎片的边缘无法匹配。解决:加回溯机制,当某一步的最小代价超过阈值时,撤销上一步的放置,换第二优的候选。阈值不要设得太死,可以用当前已放置碎片的平均代价乘以 1.8 作为动态阈值。如果碎片数量超过 20 块,考虑换成基于图匹配或者遗传算法的全局搜索。
4.4 碎片旋转角度未归一化,边缘方向对不上
现象:两块明明应该相邻的碎片,配对代价却很高,可视化后发现它们的边缘走向差了一个固定角度。原因:碎片在输入图像里可能带有任意旋转,而边缘匹配假设所有碎片已经对齐到同一坐标系。解决:在提取轮廓后,计算轮廓的最小外接矩形或者主轴方向,把所有碎片旋转到统一朝向再提取边缘描述子。OpenCV 的cv::minAreaRect和cv::getRotationMatrix2D可以完成这个归一化。注意旋转后要重新提取轮廓,因为插值会改变边缘像素。
4.5 内存占用随碎片数量爆炸
现象:碎片数量到 30 块以上时,程序内存占用飙升,甚至被系统杀掉。原因:代价矩阵是 N×N 的,每对碎片之间还要存储边缘点序列用于计算,如果每个碎片保留完整轮廓点,内存增长很快。解决:代价矩阵用float而不是double存储,边缘点序列在计算完配对代价后及时释放,只保留重采样后的固定点数版本。如果碎片特别多,可以分阶段计算:先按轮廓面积或者颜色直方图做粗筛,只对可能相邻的碎片计算精细代价。
5. 进阶玩法:把边缘匹配换成形状上下文,以及一个验证习惯
默认的边缘匹配用的是点对距离加方向差异,这在碎片边缘比较规整时够用,但如果碎片边缘有大量不规则凹凸,比如手工剪裁的拼图,点对匹配的鲁棒性会下降。一个常见的升级方向是换成形状上下文(Shape Context):对每个轮廓点,统计它相对于其他点的极坐标直方图,然后用卡方距离或者推土机距离来衡量两个点集的相似度。这样做的好处是对局部形变不敏感,代价是计算量更大,需要配合 KD 树或者近似最近邻来加速。
// 形状上下文简化版:对每个点统计相对极坐标直方图 std::vector<std::vector<double>> computeShapeContext( const std::vector<cv::Point2f>& contour, int nBinsR, int nBinsTheta) { std::vector<std::vector<double>> hist(contour.size(), std::vector<double>(nBinsR * nBinsTheta, 0.0)); for (size_t i = 0; i < contour.size(); ++i) { for (size_t j = 0; j < contour.size(); ++j) { if (i == j) continue; cv::Point2f diff = contour[j] - contour[i]; double r = cv::norm(diff); double theta = std::atan2(diff.y, diff.x); int rBin = std::min(static_cast<int>(r / 10.0), nBinsR - 1); int tBin = static_cast<int>((theta + CV_PI) / (2 * CV_PI) * nBinsTheta); hist[i][rBin * nBinsTheta + tBin] += 1.0; } // 归一化直方图 double sum = std::accumulate(hist[i].begin(), hist[i].end(), 0.0); if (sum > 0) for (auto& v : hist[i]) v /= sum; } return hist; }这段代码里,nBinsR和nBinsTheta分别控制径向和角度的分箱数,常用 5 和 12。r / 10.0里的 10 是距离缩放因子,需要根据碎片实际尺寸调整。形状上下文的匹配代价用卡方距离计算,然后替换掉原来的edgeMatchingCost函数即可。注意这个版本没有做旋转不变性,如果碎片有旋转,需要先归一化朝向,或者在角度分箱时做循环移位取最小代价。
验证拼装结果对不对,我自己的习惯是:不要只看最终输出图,而是把每一步的配对代价打印出来,画一条代价随放置顺序变化的曲线。如果曲线在某个位置突然跳高,说明那一步的配对很可能有问题,回头去查那两块碎片的边缘提取和归一化。另外,我会故意把碎片顺序打乱重新跑一遍,看结果是否一致——如果两次结果差异很大,说明算法对输入顺序敏感,需要检查搜索策略的随机性来源。
从那以后我每次跑拼图类项目,都会先把碎片图像做一次统一的旋转归一化和边缘平滑,再开始配对计算。这个习惯帮我省掉了至少一半的调试时间。希望帮到你。
本文还有配套的精品资源,点击获取