46、C++项目实战:OCR文字识别(简单版)
OCR,全称光学字符识别。说白了,就是让电脑看懂图片里的文字。
你可能觉得这玩意儿很高深,得用深度学习、神经网络才行。其实不然。对于印刷体、字体规整、背景干净的场景,我们完全可以用C++写一个轻量级的版本。我在几年前的一个自动化录入项目里就干过这事——客户要求把扫描件里的编号自动提取出来,又不能上云,只能本地跑。嗯,那时候我就捣鼓出了今天要讲的这套思路。
核心思路:模板匹配法
简单版OCR的核心,说白了就是“比大小”。
我们把每个字符做成一张小图片(模板),然后在待识别的图片上滑动窗口,看哪个模板匹配度最高。谁长得最像,就认成谁。
核心流程:
- 图像预处理——转灰度、二值化、去噪
- 字符分割——把一行文字切成单个字符
- 模板匹配——拿切出来的字符跟模板库比对
- 输出结果——把匹配到的字符拼成字符串
第一步:图像预处理
原始图片直接拿来匹配,效果通常很差。光照不均、背景杂色都会干扰结果。我习惯先做三步处理:
- 灰度化:彩色图转灰度,减少计算量
- 二值化:灰度图转黑白,让文字更清晰
- 去噪:去掉孤立的小黑点
这里我用的是最简单的大津法(Otsu)做二值化。它自动算阈值,不用手动调。我在项目里试过,对于白底黑字的文档,效果相当稳。
// 大津法二值化
cv::Mat binarize(const cv::Mat& gray) {
cv::Mat binary;
double thresh = cv::threshold(gray, binary, 0, 255,
cv::THRESH_BINARY | cv::THRESH_OTSU);
return binary;
}
小技巧:如果图片有倾斜,可以先做一下旋转校正。我常用霍夫变换检测文本行的角度,然后反旋转。这一步能大幅提升识别率。
第二步:字符分割
预处理完了,接下来要把一行文字切成一个个独立的字符。怎么做?
你想想看,文字在图片上是有“投影”的。垂直方向投影,字符之间的间隙就是0值。我们找到这些间隙,一切就完事了。
// 垂直投影分割
std::vector<cv::Rect> segmentChars(const cv::Mat& binary) {
std::vector<cv::Rect> chars;
cv::Mat proj;
cv::reduce(binary, proj, 0, cv::REDUCE_AVG); // 垂直投影
bool inChar = false;
int start = 0;
for (int i = 0; i < proj.cols; ++i) {
float val = proj.at<float>(0, i);
if (val > 0 && !inChar) {
start = i;
inChar = true;
} else if (val == 0 && inChar) {
chars.push_back(cv::Rect(start, 0, i - start, binary.rows));
inChar = false;
}
}
return chars;
}
注意:如果字符之间有粘连(比如“rn”连在一起),投影法会失效。我曾经遇到过这种情况,后来改用“连通域分析”才搞定。简单版里我们先假设字符间距足够大。
第三步:模板匹配
这是核心环节。我们把每个字符的模板存成小图片,然后用cv::matchTemplate去比对。匹配方法我推荐归一化相关系数(TM_CCOEFF_NORMED),它对光照变化不敏感。
// 模板匹配
char matchChar(const cv::Mat& charImg,
const std::map<char, cv::Mat>& templates) {
double bestScore = -1.0;
char bestChar = '?';
for (const auto& [ch, tmpl] : templates) {
// 把字符缩放到模板大小
cv::Mat resized;
cv::resize(charImg, resized, tmpl.size());
cv::Mat result;
cv::matchTemplate(resized, tmpl, result, cv::TM_CCOEFF_NORMED);
double score;
cv::minMaxLoc(result, nullptr, &score);
if (score > bestScore) {
bestScore = score;
bestChar = ch;
}
}
return bestChar;
}
这里有个细节:模板库怎么来?我建议自己生成。用系统字体渲染出每个字符的图片,保存下来。这样模板干净、标准,匹配效果最好。
模板库生成示例:
// 用FreeType或OpenCV的putText生成模板
for (char c = 'A'; c <= 'Z'; ++c) {
cv::Mat img(40, 30, CV_8UC1, cv::Scalar(255));
cv::putText(img, std::string(1, c), cv::Point(2, 30),
cv::FONT_HERSHEY_SIMPLEX, 1.0, cv::Scalar(0), 2);
templates[c] = img;
}
完整流程串起来
把上面三步串起来,就是一个完整的OCR流程。我习惯封装成一个类,方便复用。
class SimpleOCR {
public:
SimpleOCR() { loadTemplates(); }
std::string recognize(const cv::Mat& img) {
// 1. 预处理
cv::Mat gray, binary;
cv::cvtColor(img, gray, cv::COLOR_BGR2GRAY);
binary = binarize(gray);
// 2. 分割
auto chars = segmentChars(binary);
// 3. 匹配
std::string result;
for (const auto& rect : chars) {
cv::Mat charImg = binary(rect);
result += matchChar(charImg, templates_);
}
return result;
}
private:
std::map<char, cv::Mat> templates_;
// ... 其他辅助函数
};
性能与局限
这个简单版OCR能跑多快?我测试过,一张A4大小的文档(300dpi),大概200-300毫秒。对于实时性要求不高的场景,完全够用。
但它的局限也很明显:
| 场景 | 效果 | 原因 |
|---|---|---|
| 印刷体、标准字体 | 优秀(>98%) | 模板匹配天然适合 |
| 手写体 | 差(<30%) | 字形变化太大 |
| 倾斜、扭曲文字 | 一般(60-80%) | 需要额外校正 |
| 多字体混合 | 较差 | 模板库不够全 |
我的建议:如果你要识别的文字字体固定、背景干净,这个方案性价比极高。如果场景复杂,还是上Tesseract或者深度学习吧。但作为C++项目练手,这个实现能让你把图像处理、模板匹配、工程封装都练一遍,非常值。
嗯,今天就聊到这儿。OCR这东西,入门不难,做好很难。但掌握了这套基础,你再去看那些高级方案,会发现核心思想其实是一样的——预处理、分割、识别。万变不离其宗。