46、C++项目实战:OCR文字识别(简单版)

OCR,全称光学字符识别。说白了,就是让电脑看懂图片里的文字。

你可能觉得这玩意儿很高深,得用深度学习、神经网络才行。其实不然。对于印刷体、字体规整、背景干净的场景,我们完全可以用C++写一个轻量级的版本。我在几年前的一个自动化录入项目里就干过这事——客户要求把扫描件里的编号自动提取出来,又不能上云,只能本地跑。嗯,那时候我就捣鼓出了今天要讲的这套思路。

核心思路:模板匹配法

简单版OCR的核心,说白了就是“比大小”。

我们把每个字符做成一张小图片(模板),然后在待识别的图片上滑动窗口,看哪个模板匹配度最高。谁长得最像,就认成谁。

核心流程:

  1. 图像预处理——转灰度、二值化、去噪
  2. 字符分割——把一行文字切成单个字符
  3. 模板匹配——拿切出来的字符跟模板库比对
  4. 输出结果——把匹配到的字符拼成字符串
输入图片 预处理 字符分割 模板匹配 输出文字 模板库

第一步:图像预处理

原始图片直接拿来匹配,效果通常很差。光照不均、背景杂色都会干扰结果。我习惯先做三步处理:

  • 灰度化:彩色图转灰度,减少计算量
  • 二值化:灰度图转黑白,让文字更清晰
  • 去噪:去掉孤立的小黑点

这里我用的是最简单的大津法(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这东西,入门不难,做好很难。但掌握了这套基础,你再去看那些高级方案,会发现核心思想其实是一样的——预处理、分割、识别。万变不离其宗。


公众号:蓝海资料掘金营,微信 deep3321