前言
先纠正一个题面前提:这里的「模板匹配」不是 C++ 语言里的模板(template)机制,而是图像处理中的template matching——拿一张小图(模板)在大图(场景)上逐位置滑动,计算相似度,找出最匹配的位置。两者只是中文译名撞车,和泛型编程没有关系。
第二个需要澄清的点是:用模板匹配做手写数字识别,是一种基线方法,不是工业级方案。它对书写者的笔画粗细、倾斜角度、数字在画面中的位置和大小都非常敏感;同一个人换个写法,相似度就可能掉到很低的水平。真正的手写数字识别通常走向特征(如 HOG)加分类器(SVM、随机森林)或卷积神经网络。本文讲模板匹配,是因为它实现简单、每一步都可解释,适合作为理解「图像相似度」的入口。
本文会给出一个不依赖任何第三方库的完整实现:灰度图结构、零均值归一化互相关(zero-mean normalized cross-correlation, ZNCC)、滑动搜索、多模板分类,以及二值化、裁剪、缩放这三个预处理步骤。所有代码以 C++17 为基准,按标准语义推演,未经过编译执行。
一、模板匹配在做什么
形式化地说,场景图S大小为W×H,模板图T大小为w×h,且w≤W、h≤H。匹配就是枚举所有左上角偏移(ox, oy),其中0≤ox≤W-w、0≤oy≤H-h,在每个位置取场景中与模板同尺寸的子窗口,计算相似度score(ox, oy),最后取分数最高的位置。
这一定义隐含了两个成本:
- 时间复杂度是
O((W-w+1)·(H-h+1)·w·h),即「位置数 × 模板像素数」; - 它只处理平移,不处理旋转、缩放、透视和弹性形变。
对 28×28 的模板和 100×100 的场景,位置数约 5 千,每个位置 784 次乘加,总量约 400 万次运算——对现代 CPU 很小。但如果模板是 200×200、场景是 1920×1080,计算量会上升到百亿级,必须用积分图、频域方法或 GPU 加速。
三种相似度度量
| 度量 | 公式核心 | 对亮度变化 | 对对比度变化 | 计算量 |
|---|
| 平方差 SSD | Σ(S-T)² | 敏感 | 敏感 | 小 |
| 互相关 CC | Σ(S·T) | 敏感 | 敏感 | 小 |
| 归一化互相关 NCC | Σ(S·T) / √(ΣS²·ΣT²) | 较敏感 | 不敏感 | 中 |
| 零均值归一化 ZNCC | Σ(S-S̄)(T-T̄) / √(Σ(S-S̄)²·Σ(T-T̄)²) | 不敏感 | 不敏感 | 中 |
手写数字识别里,同一数字的墨迹深浅、纸张明暗差异很大,SSD 和普通 CC 会被整体亮度带偏。ZNCC 先减去均值再除以标准差,相当于把两个窗口都标准化,只比较「图案形状」,因此是更合适的选择。它的取值范围是[-1, 1],1 表示完全同形,0 表示不相关,-1 表示完全反相。
需要注意:ZNCC 对反相(黑底白字与白底黑字)会给出 -1,而不是 1。如果输入的二值化极性可能相反,要么统一极性,要么取绝对值——但取绝对值会引入新的混淆,推荐在预处理阶段统一。
二、预处理:二值化、裁剪、归一化
模板匹配要求场景中的目标与模板「同尺寸、同位置基准」。手写数字随意落在画面任何位置、大小不一,所以必须先做三步:
- 灰度化与二值化:把彩色图转成灰度,再按阈值分成前景(笔迹)与背景。简单场景可用固定阈值(如 128),光照不均时应用自适应阈值或 Otsu 方法。二值化的目的是去掉纸张纹理和阴影。
- 裁剪到外接矩形:扫描所有前景像素,求最小和最大的 x、y,把数字从画面中「抠」出来。这样后续匹配只关心笔迹本身,不受留白影响。
- 缩放到统一尺寸:把所有样本缩放到
28×28或32×32。最近邻缩放实现最简单,双线性更平滑但会引入中间灰度。模板与待识别图像必须使用完全相同的尺寸和缩放方式。
这三步里,裁剪最容易出错:如果图像里混入一个噪点,外接矩形会被撑大,数字在归一化后变得很小,匹配分数整体下降。工程上会先做去噪(中值滤波、连通域面积过滤),或者用最大连通域的外接框,而不是全图前景的外接框。
三、完整实现
下面的代码把图像结构、ZNCC、滑动搜索和分类串起来。输入是已经过预处理的同尺寸灰度图。
// 适用于 C++17
#include <cmath>
#include <cstddef>
#include <cstdint>
#include <limits>
#include <stdexcept>
#include <utility>
#include <vector>
struct GrayImage {
int width = 0;
int height = 0;
std::vector<std::uint8_t> pixels;
GrayImage() = default;
GrayImage(int w, int h) : width(w), height(h) {
if (w <= 0 || h <= 0) {
throw std::invalid_argument("image size must be positive");
}
pixels.resize(
static_cast<std::size_t>(w) * static_cast<std::size_t>(h), 0);
}
std::uint8_t at(int x, int y) const {
return pixels[static_cast<std::size_t>(y) * width + x];
}
std::uint8_t& at(int x, int y) {
return pixels[static_cast<std::size_t>(y) * width + x];
}
};
// 在 scene 的 (ox, oy) 处取与 tpl 同尺寸的窗口,计算 ZNCC
double zncc_at(const GrayImage& scene, int ox, int oy, const GrayImage& tpl) {
const double n = static_cast<double>(tpl.width) * tpl.height;
double sum_s = 0.0, sum_t = 0.0;
double sum_ss = 0.0, sum_tt = 0.0, sum_st = 0.0;
for (int y = 0; y < tpl.height; ++y) {
for (int x = 0; x < tpl.width; ++x) {
const double s = scene.at(ox + x, oy + y);
const double t = tpl.at(x, y);
sum_s += s;
sum_t += t;
sum_ss += s * s;
sum_tt += t * t;
sum_st += s * t;
}
}
const double cov = sum_st - sum_s * sum_t / n;
const double var_s = sum_ss - sum_s * sum_s / n;
const double var_t = sum_tt - sum_t * sum_t / n;
// 任一窗口是常数图(方差为 0)时相关系数无定义,按不相关处理
if (var_s <= 0.0 || var_t <= 0.0) {
return 0.0;
}
return cov / std::sqrt(var_s * var_t);
}
struct MatchResult {
int x = 0;
int y = 0;
double score = -2.0;
};
MatchResult best_match(const GrayImage& scene, const GrayImage& tpl) {
if (tpl.width > scene.width || tpl.height > scene.height) {
throw std::invalid_argument("template is larger than scene");
}
MatchResult best;
for (int oy = 0; oy + tpl.height <= scene.height; ++oy) {
for (int ox = 0; ox + tpl.width <= scene.width; ++ox) {
const double s = zncc_at(scene, ox, oy, tpl);
if (s > best.score) {
best = MatchResult{ox, oy, s};
}
}
}
return best;
}
// 模板集合中每个下标对应一个类别,返回得分最高的类别
int classify(const GrayImage& digit, const std::vector<GrayImage>& templates) {
int best_label = -1;
double best_score = -2.0;
for (std::size_t i = 0; i < templates.size(); ++i) {
if (templates[i].width != digit.width ||
templates[i].height != digit.height) {
throw std::invalid_argument("template size mismatch");
}
const double s = zncc_at(digit, 0, 0, templates[i]);
if (s > best_score) {
best_score = s;
best_label = static_cast<int>(i);
}
}
return best_label;
}
int main() {
GrayImage query(28, 28);
// 这里应填入预处理后的像素;示例省略具体数据来源
for (int y = 0; y < query.height; ++y) {
for (int x = 0; x < query.width; ++x) {
const bool ink = (x >= 10 && x <= 17 && y >= 4 && y <= 23);
query.at(x, y) = ink ? 255 : 0;
}
}
std::vector<GrayImage> templates;
templates.reserve(10);
for (int d = 0; d < 10; ++d) {
GrayImage t(28, 28);
// 真实实现中从文件或内存加载每个数字的模板
templates.push_back(std::move(t));
}
const int label = classify(query, templates);
(void)label;
return 0;
}这段代码有几个刻意的设计:
GrayImage构造函数先校验尺寸再分配。顺序不能反:w为负数时static_cast<std::size_t>(w)会变成一个巨大的数,若先分配就会触发长度异常甚至耗尽内存。zncc_at用double累加。模板不大时误差可以忽略;如果模板达到数百万像素,应考虑两遍算法或long double,减少大数相减带来的抵消误差。classify遍历所有模板取最高分。实际系统还会设一个最低分阈值,低于阈值判为「无法识别」,而不是硬塞一个类别。
预处理代码
裁剪与缩放可以各写一个短函数:
// 适用于 C++17
#include <algorithm>
#include <cstdint>
#include <stdexcept>
GrayImage crop_to_content(const GrayImage& src, std::uint8_t threshold) {
int min_x = src.width, min_y = src.height, max_x = -1, max_y = -1;
for (int y = 0; y < src.height; ++y) {
for (int x = 0; x < src.width; ++x) {
if (src.at(x, y) >= threshold) {
min_x = std::min(min_x, x);
min_y = std::min(min_y, y);
max_x = std::max(max_x, x);
max_y = std::max(max_y, y);
}
}
}
if (max_x < min_x || max_y < min_y) {
throw std::runtime_error("blank image");
}
GrayImage dst(max_x - min_x + 1, max_y - min_y + 1);
for (int y = 0; y < dst.height; ++y) {
for (int x = 0; x < dst.width; ++x) {
dst.at(x, y) = src.at(min_x + x, min_y + y);
}
}
return dst;
}
GrayImage resize_nearest(const GrayImage& src, int new_w, int new_h) {
if (new_w <= 0 || new_h <= 0) {
throw std::invalid_argument("target size must be positive");
}
GrayImage dst(new_w, new_h);
for (int y = 0; y < new_h; ++y) {
const int sy = y * src.height / new_h;
for (int x = 0; x < new_w; ++x) {
const int sx = x * src.width / new_w;
dst.at(x, y) = src.at(sx, sy);
}
}
return dst;
}resize_nearest用的是整数映射:目标像素(x, y)取源图(x·srcW/newW, y·srcH/newH)。当放大倍数较大时会出现明显的块状锯齿;手写数字笔画细,最近邻可能把一笔细线抽断。双线性插值会保留更多灰度信息,但也让二值图重新出现中间值,是否合适取决于后续度量。
四、识别流程与阈值
一个可用的离线流程是:
- 收集每个数字的若干张样本,统一预处理到 28×28;
- 对同一数字的多张样本求平均,或保留多张作为「多模板」;
- 待识别图像同样预处理;
- 与每个类别的每张模板算 ZNCC,取全局最高分;
- 若最高分低于阈值,输出「拒识」而不是猜测。
多模板能显著缓解书写差异:数字「7」可能带横杠也可能不带,单模板只能覆盖一种。代价是计算量随模板数线性增长,且模板之间可能互相竞争。
阈值没有通用值,必须在自己的验证集上选取。做法是统计正确识别的分数分布与错误识别的分数分布,找一个尽量分开两者的截断点。不要凭感觉写0.8或0.9,也不要引用别人的数字——不同预处理、不同字体、不同扫描设备下分布完全不同。
五、这种方法的局限与替代方案
模板匹配对以下变化几乎没有鲁棒性:
| 变化 | 影响 | 缓解手段 |
|---|
| 平移 | 搜索窗口能覆盖 | 已由滑动搜索处理 |
| 缩放 | 尺寸不匹配,分数骤降 | 预处理归一化,或多尺度搜索 |
| 旋转/倾斜 | 形状错位 | 需要旋转不变特征 |
| 笔画粗细 | 相关性下降 | 细化、距离变换 |
| 书写风格差异 | 同类别分数低 | 多模板、特征分类器 |
| 光照/极性 | 影响二值化 | 自适应阈值、统一极性 |
如果只是做课程作业或小规模演示,模板匹配足够。如果是真实产品,建议转向:
- 特征 + 传统分类器:提取 HOG、投影直方图、Zernike 矩等特征,训练 SVM 或随机森林;
- 卷积神经网络:在 MNIST 这类数据集上训练,能学到对形变更鲁棒的表示;
- 现成库:OpenCV 提供
matchTemplate等函数,封装了多种匹配模式并做了优化。具体函数签名与枚举名以其官方文档为准,不要凭记忆拼参数。
即便使用 OpenCV,预处理和阈值策略仍然要自己设计,库只负责「给定两张同尺寸图,算相似度」这一层。
常见坑点
- ❌ 模板与待识别图像尺寸不一致就直接算:
``cpp double s = zncc_at(digit, 0, 0, tpl); // 尺寸不同`` ✅ 预处理阶段统一缩放到相同尺寸,并在函数入口检查。
- ❌ 用 SSD 直接比较,被整体亮度带偏:
``cpp double score = 0; for (...) score += (s - t) * (s - t);`` ✅ 用 ZNCC 先减均值、再归一化方差。
- ❌ 滑动窗口越界:
``cpp for (int oy = 0; oy < scene.height; ++oy) for (int ox = 0; ox < scene.width; ++ox) s = scene.at(ox + x, oy + y); // 越界`✅ 上界写成oy + tpl.height <= scene.height`。
- ❌ 方差为 0 时直接除:
``cpp return cov / std::sqrt(var_s * var_t);`✅ 先判断var_s > 0 && var_t > 0`,否则返回 0 或标记无效。
- ❌ 只用一个模板覆盖一个数字:
``cpp templates[7] = load("seven.png");`` ✅ 每个类别保留多张样本,覆盖不同写法。
- ❌ 不设拒识阈值,永远输出最近类别:
``cpp return best_label;`` ✅ 最高分低于阈值时返回「未知」。
- ❌ 用整数除法做坐标缩放:
``cpp int sx = x / new_w * src.width;`✅ 先乘后除:x * src.width / new_w`,并注意溢出范围。
- ❌ 在灰度图结构里让负尺寸参与
size_t计算:
``cpp GrayImage(int w, int h) : pixels(w * h, 0) {}`✅ 先校验w > 0 && h > 0`,再转换并分配。
总结
| 环节 | 关键决定 | 常见错误 |
|---|
| 灰度/二值 | 固定阈值还是自适应 | 极性不统一 |
| 裁剪 | 最大连通域而非全图前景 | 噪点撑大外接框 |
| 归一化 | 统一尺寸与缩放算法 | 训练/推理不一致 |
| 相似度 | ZNCC 抗亮度与对比度 | 用 SSD 受光照影响 |
| 搜索 | 边界+tpl <= scene | 越界读取 |
| 分类 | 多模板 + 拒识阈值 | 强行给出类别 |
模板匹配的价值在于把「识别」拆成可解释的两步:先定义相似度,再搜索最优位置。它的上限也很清楚——只能处理平移,对形变和风格差异无能为力。把本文的代码当作理解图像相似度的练习是合适的;要解决真实的手写识别问题,应在同一套预处理之上换用特征与学习模型。文中所有代码均按 C++17 标准语义书写,未在本机编译或运行。