☰
C++编程模板匹配超详细的识别手写数字实现示例
2026/11/3 3:11:11 网站建设 项目流程

前言


先纠正一个题面前提:这里的「模板匹配」不是 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。如果输入的二值化极性可能相反,要么统一极性,要么取绝对值——但取绝对值会引入新的混淆,推荐在预处理阶段统一。


二、预处理:二值化、裁剪、归一化


模板匹配要求场景中的目标与模板「同尺寸、同位置基准」。手写数字随意落在画面任何位置、大小不一,所以必须先做三步:



  1. 灰度化与二值化:把彩色图转成灰度,再按阈值分成前景(笔迹)与背景。简单场景可用固定阈值(如 128),光照不均时应用自适应阈值或 Otsu 方法。二值化的目的是去掉纸张纹理和阴影。

  2. 裁剪到外接矩形:扫描所有前景像素,求最小和最大的 x、y,把数字从画面中「抠」出来。这样后续匹配只关心笔迹本身,不受留白影响。

  3. 缩放到统一尺寸:把所有样本缩放到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)。当放大倍数较大时会出现明显的块状锯齿;手写数字笔画细,最近邻可能把一笔细线抽断。双线性插值会保留更多灰度信息,但也让二值图重新出现中间值,是否合适取决于后续度量。


四、识别流程与阈值


一个可用的离线流程是:



  1. 收集每个数字的若干张样本,统一预处理到 28×28;

  2. 对同一数字的多张样本求平均,或保留多张作为「多模板」;

  3. 待识别图像同样预处理;

  4. 与每个类别的每张模板算 ZNCC,取全局最高分;

  5. 若最高分低于阈值,输出「拒识」而不是猜测。


多模板能显著缓解书写差异:数字「7」可能带横杠也可能不带,单模板只能覆盖一种。代价是计算量随模板数线性增长,且模板之间可能互相竞争。


阈值没有通用值,必须在自己的验证集上选取。做法是统计正确识别的分数分布与错误识别的分数分布,找一个尽量分开两者的截断点。不要凭感觉写0.8或0.9,也不要引用别人的数字——不同预处理、不同字体、不同扫描设备下分布完全不同。


五、这种方法的局限与替代方案


模板匹配对以下变化几乎没有鲁棒性:




变化影响缓解手段



平移搜索窗口能覆盖已由滑动搜索处理

缩放尺寸不匹配,分数骤降预处理归一化,或多尺度搜索

旋转/倾斜形状错位需要旋转不变特征

笔画粗细相关性下降细化、距离变换

书写风格差异同类别分数低多模板、特征分类器

光照/极性影响二值化自适应阈值、统一极性



如果只是做课程作业或小规模演示,模板匹配足够。如果是真实产品,建议转向:



  • 特征 + 传统分类器:提取 HOG、投影直方图、Zernike 矩等特征,训练 SVM 或随机森林;

  • 卷积神经网络:在 MNIST 这类数据集上训练,能学到对形变更鲁棒的表示;

  • 现成库:OpenCV 提供matchTemplate等函数,封装了多种匹配模式并做了优化。具体函数签名与枚举名以其官方文档为准,不要凭记忆拼参数。


即便使用 OpenCV,预处理和阈值策略仍然要自己设计,库只负责「给定两张同尺寸图,算相似度」这一层。


常见坑点



  1. ❌ 模板与待识别图像尺寸不一致就直接算:


``cpp double s = zncc_at(digit, 0, 0, tpl); // 尺寸不同`` ✅ 预处理阶段统一缩放到相同尺寸,并在函数入口检查。



  1. ❌ 用 SSD 直接比较,被整体亮度带偏:


``cpp double score = 0; for (...) score += (s - t) * (s - t);`` ✅ 用 ZNCC 先减均值、再归一化方差。



  1. ❌ 滑动窗口越界:


``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`。



  1. ❌ 方差为 0 时直接除:


``cpp return cov / std::sqrt(var_s * var_t);`✅ 先判断var_s > 0 && var_t > 0`,否则返回 0 或标记无效。



  1. ❌ 只用一个模板覆盖一个数字:


``cpp templates[7] = load("seven.png");`` ✅ 每个类别保留多张样本,覆盖不同写法。



  1. ❌ 不设拒识阈值,永远输出最近类别:


``cpp return best_label;`` ✅ 最高分低于阈值时返回「未知」。



  1. ❌ 用整数除法做坐标缩放:


``cpp int sx = x / new_w * src.width;`✅ 先乘后除:x * src.width / new_w`,并注意溢出范围。



  1. ❌ 在灰度图结构里让负尺寸参与size_t计算:


``cpp GrayImage(int w, int h) : pixels(w * h, 0) {}`✅ 先校验w > 0 && h > 0`,再转换并分配。


总结




环节关键决定常见错误



灰度/二值固定阈值还是自适应极性不统一

裁剪最大连通域而非全图前景噪点撑大外接框

归一化统一尺寸与缩放算法训练/推理不一致

相似度ZNCC 抗亮度与对比度用 SSD 受光照影响

搜索边界+tpl <= scene越界读取

分类多模板 + 拒识阈值强行给出类别



模板匹配的价值在于把「识别」拆成可解释的两步:先定义相似度,再搜索最优位置。它的上限也很清楚——只能处理平移,对形变和风格差异无能为力。把本文的代码当作理解图像相似度的练习是合适的;要解决真实的手写识别问题,应在同一套预处理之上换用特征与学习模型。文中所有代码均按 C++17 标准语义书写,未在本机编译或运行。




需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询