50、C++项目实战:车牌识别系统

车牌识别,听起来是不是挺高大上的?其实说白了,就是让计算机从一张图片里找到车牌,再把上面的字符读出来。我最早接触这个项目是在一个停车场管理系统里,当时甲方要求识别率必须达到95%以上,嗯,那段时间可没少加班。

今天我就带你手把手拆解这个项目。别怕,核心逻辑并不复杂,我们一步步来。

一、系统整体架构

一个完整的车牌识别系统,通常包含三个核心模块:图像预处理车牌定位字符识别。我个人习惯把流程画成图,这样思路更清晰。

输入图像 图像预处理 车牌定位 字符识别 车牌识别系统流程图 输入 → 灰度化 → 边缘检测 → 形态学处理 → 轮廓筛选 → 字符分割 → OCR识别

你看,整个流程就像一条流水线。图片从左边进来,经过一道道工序,最后右边输出车牌号码。我在实际项目中踩过最大的坑就是——预处理没做好,后面全白搭

二、图像预处理模块

预处理的目的就一个:让车牌区域更明显。你想想看,如果原图光线不好,或者车牌上有污渍,直接做定位肯定不准。

2.1 灰度化与去噪

彩色图像信息量太大,我们先转成灰度图。然后我会用高斯滤波去噪,这一步能平滑掉一些细小的干扰点。

// 灰度化 + 高斯滤波
cv::Mat preprocessImage(const cv::Mat& src) {
    cv::Mat gray, blurred;
    cv::cvtColor(src, gray, cv::COLOR_BGR2GRAY);
    cv::GaussianBlur(gray, blurred, cv::Size(5, 5), 1.5);
    return blurred;
}

嗯,这里要注意:高斯核大小选5x5比较稳妥。我曾经试过3x3,结果噪点没滤干净,后面定位出了好多假车牌。

2.2 边缘检测

车牌区域通常有丰富的边缘信息——字符和背景的对比度很高。我习惯用Sobel算子做水平边缘检测,因为车牌字符大多是水平排列的。

// Sobel水平边缘检测
cv::Mat detectEdges(const cv::Mat& gray) {
    cv::Mat grad_x;
    cv::Sobel(gray, grad_x, CV_16S, 1, 0, 3);
    cv::Mat abs_grad_x;
    cv::convertScaleAbs(grad_x, abs_grad_x);
    return abs_grad_x;
}
💡 小技巧:做完Sobel后,可以用一个阈值做二值化。我一般选OTSU自动阈值,省得手动调参。

三、车牌定位模块

定位是整个系统的核心。说白了,就是从一堆边缘信息里,找到那个最像车牌的区域。

3.1 形态学操作

边缘检测后的图像有很多断点,我们需要用闭运算(先膨胀再腐蚀)把字符连成一片。这样车牌区域就会变成一个白色的矩形块。

// 形态学闭运算
cv::Mat morphClose(const cv::Mat& binary) {
    cv::Mat kernel = cv::getStructuringElement(cv::MORPH_RECT, cv::Size(17, 5));
    cv::Mat closed;
    cv::morphologyEx(binary, closed, cv::MORPH_CLOSE, kernel);
    return closed;
}

为什么核的宽度比高度大?因为车牌是宽扁的矩形。这个尺寸我调了很久才找到最优解,你直接拿去用就行。

3.2 轮廓筛选

找到所有轮廓后,我们根据几个特征来筛选:

  • 宽高比:标准车牌宽高比约3.14:1,允许一定误差
  • 面积:太小或太大的都排除
  • 矩形度:轮廓面积与最小外接矩形面积的比值
std::vector<cv::Rect> filterPlates(const std::vector<std::vector<cv::Point>>& contours) {
    std::vector<cv::Rect> candidates;
    for (const auto& contour : contours) {
        cv::Rect rect = cv::boundingRect(contour);
        double aspect = (double)rect.width / rect.height;
        double area = rect.area();
        // 宽高比在2.5~4.0之间,面积在合理范围
        if (aspect > 2.5 && aspect < 4.0 && area > 1000 && area < 50000) {
            candidates.push_back(rect);
        }
    }
    return candidates;
}
⚠️ 避坑指南:我曾经遇到过车标和进气格栅被误识别为车牌的情况。后来加了一个条件——车牌区域的纹理密度要足够高(字符多),才把误检率降下来。

四、字符分割与识别

定位到车牌后,我们需要把每个字符单独切出来,然后逐个识别。

4.1 字符分割

我用的方法是垂直投影法。把车牌图像垂直方向上的像素值累加,字符之间的间隙会出现波谷,从波谷位置切开就行。

std::vector<cv::Mat> splitChars(const cv::Mat& plate) {
    cv::Mat gray, binary;
    cv::cvtColor(plate, gray, cv::COLOR_BGR2GRAY);
    cv::threshold(gray, binary, 0, 255, cv::THRESH_BINARY | cv::THRESH_OTSU);
    
    std::vector<cv::Mat> chars;
    // 垂直投影,找波谷位置
    std::vector<int> v_proj(plate.cols, 0);
    for (int x = 0; x < plate.cols; ++x) {
        for (int y = 0; y < plate.rows; ++y) {
            if (binary.at<uchar>(y, x) == 0) v_proj[x]++;
        }
    }
    // 根据投影值分割...(省略具体实现)
    return chars;
}

4.2 OCR识别

字符识别我推荐用Tesseract OCR库,它支持中文车牌字符。不过要注意,Tesseract默认配置对车牌识别效果一般,需要做一些定制:

  • 设置白名单:只识别数字和大写字母(中文省份简称单独处理)
  • 调整页面分割模式(PSM)为单字符模式
  • 对每个字符图像做归一化处理(统一大小、去边框)
// 使用Tesseract识别单个字符
std::string recognizeChar(const cv::Mat& charImg) {
    tesseract::TessBaseAPI tess;
    tess.Init(NULL, "eng", tesseract::OEM_LSTM_ONLY);
    tess.SetVariable("tessedit_char_whitelist", "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ");
    tess.SetPageSegMode(tesseract::PSM_SINGLE_CHAR);
    
    tess.SetImage(charImg.data, charImg.cols, charImg.rows, 1, charImg.step);
    char* out = tess.GetUTF8Text();
    std::string result(out);
    delete[] out;
    return result;
}

🔑 核心要点:识别率上不去的时候,别急着调OCR参数。先回头看看预处理和定位有没有做好。我80%的识别问题,最后都发现是前面步骤的锅。

五、性能优化与工程实践

项目做完了,但离上线还有距离。这里分享几个我在工程化过程中积累的经验:

问题 解决方案 效果
处理速度慢 缩小输入图像尺寸(保持宽高比) 速度提升3倍,识别率几乎不变
夜间识别率低 加入自适应直方图均衡化(CLAHE) 夜间识别率从60%提升到85%
倾斜车牌 用Hough变换检测倾斜角,做仿射变换校正 倾斜车牌识别率提升至90%
多线程处理 用OpenMP并行处理多路视频流 4路视频实时处理无压力

我记得有一次上线前测试,发现系统对蓝色车牌的识别率明显低于黄色车牌。查了半天,原来是预处理时蓝色通道的权重没调好。你看,细节决定成败。

六、完整代码框架

最后,给你一个完整的类框架,把上面所有模块串起来:

class LicensePlateRecognizer {
public:
    LicensePlateRecognizer() {
        // 初始化Tesseract
        tess_.Init(NULL, "eng", tesseract::OEM_LSTM_ONLY);
        tess_.SetVariable("tessedit_char_whitelist", 
                         "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ");
    }
    
    std::string recognize(const cv::Mat& src) {
        // 1. 预处理
        cv::Mat processed = preprocessImage(src);
        // 2. 边缘检测
        cv::Mat edges = detectEdges(processed);
        // 3. 形态学操作
        cv::Mat closed = morphClose(edges);
        // 4. 找轮廓并筛选
        std::vector<cv::Rect> plates = findPlates(closed);
        if (plates.empty()) return "";
        // 5. 取最大候选区域
        cv::Rect plateRect = plates[0];
        cv::Mat plateImg = src(plateRect);
        // 6. 字符分割
        std::vector<cv::Mat> chars = splitChars(plateImg);
        // 7. 逐个识别
        std::string result;
        for (auto& c : chars) {
            result += recognizeChar(c);
        }
        return result;
    }
    
private:
    tesseract::TessBaseAPI tess_;
    // ... 各模块实现
};

这个框架可以直接拿去用。你只需要把各模块的函数实现补全,再调调参数,一个能跑的车牌识别系统就出来了。

做这个项目,我最大的体会是:计算机视觉项目,80%的时间花在图像预处理和参数调优上。别指望一个算法搞定所有问题,多试试不同的组合,找到最适合你场景的那一套。

好了,今天的分享就到这里。如果你在实现过程中遇到什么问题,欢迎交流。


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