☰
Qt C++与Halcon联合开发:简易OCR字符识别Demo实战
2026/10/15 8:50:45 网站建设 项目流程

最近接了个挺实在的活儿:要在 Qt 的界面里,把标签上打印的一串字符编号快速识别出来,再跟后台数据库比对。说实话,这类需求在机器视觉里太常见了,产线质检、包装追溯、物流分拣都能碰上。我最后落地的方案就是标题里这套组合——Qt C++ 和 Halcon 联合开发,做一个简易 OCR 字符识别 Demo。一个负责界面和业务逻辑,一个负责图像处理和字符读取,各干各的强项,活儿干起来很顺。

这篇就当是给做类似事情的开发者留一份踩坑记录。适合已经会用 Qt 写基础界面、但对 Halcon 的 C++ 接口还不算熟的工程师。我会把怎么配置环境、怎么把 QImage 和 Halcon 的 HObject 互相转、怎么选 OCR 模型、怎么调参数提高识别率,以及我在实际调试中掉进去的坑,都摊开来说。整个方案不依赖深度学习,也没有复杂的网络训练,就是老老实实把传统图像处理和 Halcon 内置的 OCR 能力用到位。

1. 项目概述与整体设计思路

1.1 为什么偏偏选 Halcon + Qt 这对组合

先回答一个很多人会问的问题:OpenCV 免费开源,Python 生态也一堆 OCR 库,为什么还要用 Halcon?

我的理由很直接:Halcon 的算子库在工业视觉里打磨了太多年,尤其是 OCR、条码识别、blob 分析这类基础功能,封装得相当成熟。一个read_ocr_class_mlp加一个do_ocr_multi_class_mlp,分类器是现成的,不需要自己攒训练集、调模型结构。对“简易 OCR”这个目标来说,Halcon 的内置能力属于开箱即用。

而 Qt 强在工程化和界面交互。Halcon 自己也带一个 HWindow 窗口,但真要做一个像样的工业上位机,按钮、表格、参数配置面板、日志输出、网络通信这些还是 Qt 的地盘。C++ 这边用 Qt 做壳,再通过 Halcon 的 C++ 接口halconcpp把图像处理逻辑接进去,两者配合非常舒服。

另一个考量是数据和结果的可控性。OCR 这种东西,识别错了往往比识别不出来更麻烦——点错了、串了行、把 0 读成 O,在产线上都会造成严重后果。Halcon 的算子会返回每个字符的置信度,做逻辑判断非常方便,我完全可以根据置信度阈值做拦截和人工复核。这在纯开源方案里反而要自己造不少轮子。

1.2 这个 Demo 到底拆成了哪些模块

我的架构思路很简单,先把整条链路拆开,再逐个模块实现。整套流程大概是这样:

模块职责关键算子/组件
图像加载读取本地图片或相机帧,转成 Halcon 能处理的格式Qt QImage / Halcon read_image
图像预处理灰度化、增强对比度、阈值分割、形态学处理rgb1_to_gray, threshold, connection
字符定位把“字在哪”找出来,切分出字符区域threshold + select_shape
OCR 识别对字符区域进行分类,输出文本和置信度read_ocr_class_mlp, do_ocr_multi_class_mlp
结果展示在界面上画框显示文本、存入日志QPainter / CSV 文件

这么设计的好处是模块边界清楚。以后要识别条码、二维码,或者换成深度学习文本检测,只需要替换中间某个环节,界面和主流程基本不动。我在一开始写代码时就刻意避免了把所有逻辑堆在一个类里,这样现场维护的人看了头不痛。

2. 开发环境与工程配置

2.1 版本选型是第一个坑

做 Halcon 项目,版本选型要注意几个问题,不然编译链接的时候会非常痛苦。

第一是 Halcon 的 Architecture 版本。目前绝大多数 Windows 部署环境用的是 x64-win64,少部分老设备还跑 x86-win32。这个必须和你的 Qt 构建套件、编译器的位数保持一致。我用的是 64 位 Qt + 64 位 Halcon,一旦配错,后面报错全都莫名其妙。

第二是 Qt 版本。Qt 5.12 到 5.15 这几代 LTS 我比较推荐,稳定、资料多、跟各种第三方库的兼容性也比 6.x 更省心。当然你用 Qt 6 也没问题,只要注意接口差异就行,我这个 Demo 用 Qt 5.15,直接用 MSVC 编译套件。

第三是 Halcon 的许可证。工业场景基本都买了正式授权,个人学习可以用官方试用版。试用版在启动时或者偶发调用某个算子时会弹出提示,这个别慌,属于正常现象,确认授权有效就行。

2.2 工程配置的几条硬命令

在 Qt Creator 里新建一个普通 C++ 项目,然后在.pro文件里加这几行,是最基础的一步:

HALCON_ROOT = C:/Program Files/Halcon/22.11 INCLUDEPATH += $$HALCON_ROOT/include LIBS += -L$$HALCON_ROOT/lib/x64-win64 -lhalconcpp -lhalcon

这里有个容易忽略的坑:Halcon 的 C++ 接口动态库不止一个。运行时程序会去找halconcpp.dll和halcon.dll,如果它们不在 exe 所在目录,也不在系统 PATH 里,程序一启动就在加载阶段崩溃。所以要么把两个 DLL 拷到构建输出目录,要么在 PATH 里加上 Halcon 的bin/x64-win64目录。

系统环境变量HALCONROOT最好也配上。虽然用.pro里的绝对路径能编译,但不同机器上 Halcon 安装位置可能不一样,配好HALCONROOT后,.pro里可以直接写$$(HALCONROOT),换台电脑重新编译也不用到处改路径。

3. 核心细节:图片格式转换与图像预处理

3.1 QImage 和 HObject 互转,这是界面集成的关键

Qt 界面上加载图片一般用的是 QImage,Halcon 处理图像则要用 HObject/HImage。两边不打通,程序就是两张皮。我在第一次写这个转换时踩了不少坑,下面把当前版本可用的方法讲清楚。

QImage 转 Halcon 的 HObject,我用的是 Halcon 的GenImageInterleaved。这个算子能直接接受 RGB 交织排列的数据,正好对应 QImage 的Format_RGB888格式:

HObject hoImage; QImage img = ...; // 确保是 Format_RGB888 img = img.convertToFormat(QImage::Format_RGB888); // 这里要注意,QImage 的行字节数可能是对齐过的,不能默认 width*3 int bytesPerLine = img.bytesPerLine(); auto *ptr = img.bits(); Hlong p = (Hlong)ptr; GenImageInterleaved(&hoImage, p, "rgb", img.width(), img.height(), 0, 8);

这里有几个细节。第一,QImage 的bits()返回的是内存起始地址,传指针时需要强转成Hlong。第二,GenImageInterleaved内部要求数据按行连续存储,如果 QImage 因为内存对齐导致每行末尾有多余字节,某些版本的 Halcon 会读错行。稳妥的做法是先从 QImage 拷一份连续 RGB 数据出来,再去构造 HObject。

反方向,HObject 转回 QImage 用于界面显示,可以这样拿像素指针:

HTuple type, width, height; HTuple ptrR, ptrG, ptrB; GetImagePointer3(hoImage, &ptrR, &ptrG, &ptrB, &type, &width, &height);

拿到三个通道的指针后,把它们合并成 QImage 的 RGB888 缓冲。注意 Halcon 默认图像的坐标原点在左上,但内存扫描顺序同样是自上而下,这个跟 QImage 是一致的,不需要额外翻转。

如果你只需要在界面上显示预处理后的黑白区域,也可以直接把区域转成 QImage 单通道灰度再画,效率更高。我一般会保留彩色原图用于结果展示,灰度图只存在处理管线里。

3.2 预处理是 OCR 的灵魂,别一上来就识别

很多新手最容易犯的错,就是图片读进来直接丢给 OCR 算子,然后抱怨识别率低。实际上,OCR 识别器就像一个阅读员,你把一张光线偏暗、文字边缘发虚、背景还有噪点的纸递给他,他看不清楚当然会读错。预处理干的事,就是先把这张纸收拾干净。

我常用的预处理链路是固定的:灰度化、反色(必要时)、增强对比度、阈值分割、连通域拆分、区域筛选。

灰度化直接用Rgb1ToGray,简单可靠。增强对比度我会用ScaleImageMax或者Emphasize。如果你的字符是白底黑字,灰度值分布会比较合理;如果拿到的是黑底白字那种负片效果,需要先做一次InvertImage,不然阈值参数会变得很别扭。

阈值分割是关键中的关键。Halcon 的Threshold算子允许你手动指定灰度上下限。我第一次调的时候全凭肉眼,后来发现最稳的办法是在 HDevelop 里打开灰度直方图看峰值分布。字符和背景的灰度峰分得越开,阈值就越容易卡准。

下面这段就是我经常用的操作序列:

HObject gray, region, connected, selected; Rgb1ToGray(hoImage, &gray); // 增强对比度,让字符边缘更硬 Emphasize(gray, &gray, 7, 7, 20); // 阈值分割,参数根据直方图调整 Threshold(gray, &region, 80, 200); // 把零散区域拆开,再按字符尺寸筛掉噪声 Connection(region, &connected); SelectShape(connected, &selected, "area", "and", 30, 99999);

这里Emphasize的尺寸参数不宜太大,否则字符内部会出现杂乱的纹理响应。Threshold的下限宁可切得严一点,把背景大块噪点过滤掉;如果字符偏淡,可以适当加一次膨胀或闭运算把断笔连上。所谓“简易”,不是指步骤简陋,而是指每一步都选最简单的算子做最明确的事。

4. OCR 识别实现与参数优化

4.1 用内置分类器把字符读出来

预处理做完,字符区域已经是一个一个独立的连通域了。下一步是让 Halcon 的 OCR 分类器把这些区域分类成文本。

Halcon 自带了一批训练好的 OCR 分类器,最常用的是这个几个:

分类器名称说明
Industrial_0-9_NoRej只识别数字 0-9
Industrial_0-9A-Z_NoRej数字加大写字母
Universal_0-9A-Z_NoRej更通用的数字字母混合

我需要先读进来:

HOCRBox 或 HOCRMlp? // 实际用的是 handle 方式: HTuple ocrHandle; ReadOcrClassMlp("Industrial_0-9A-Z_NoRej", &ocrHandle);

这里面的NoRej表示“不拒绝未知字符”,也就是说分类器哪怕不确定是什么,也会硬选一个答案。如果你不想看到乱识别出来的字符,可以换带拒绝模式的模型,但这样可能把正常的字符也拒掉。我的经验是优先用 NoRej 模型,然后结合置信度来判断到底可信不可信。

识别前还有一个顺序问题:Connection出来的区域顺序是乱的,需要先排序,不然你读出来的字符串会乱序。排序我用SortRegion,指定按行方向排列,然后再从左到右:

HObject sortedRegions; SortRegion(selected, &sortedRegions, "character", "true", "row"); HTuple classes, confidences; DoOcrMultiClassMlp(sortedRegions, hoImage, ocrHandle, &classes, &confidences);

DoOcrMultiClassMlp是对多个字符区域批量分类,返回每个区域对应的 Class 和 Confidence。Confidence 取值范围是 0 到 1,越接近 1 越可信。

4.2 参数调优:从 50% 识别率到 95%,我都做了什么

我实际测试时遇到过很典型的情况:拿一个相机拍的样品图,直接走默认参数,粗算识别率只有一半,错误清一色集中在 8/B、0/O、1/I 这种外形相近的字符上。

后来问题出在哪呢?主要是阈值分割没卡好,字符区域边缘带了多余的毛刺,分类器看“胖”了。此外字符内部还有背景孔洞,本来是 0 的轮廓里多了一块白色噪声,也被当成了其他结构。

改进方法是三步:

第一,把预处理参数调到字符区域干净、稳定。这一步我可以非常明确地说,OCR 项目 70% 的精力都在这里。我专门在 HDevelop 里一条条算子执行,确认每一步输出的 Region 都不带明显噪点。

第二,给字符区域做一次形态学补洞。Halcon 的FillUp可以把字符内部关闭的孔洞直接填掉。不过要注意,如果字符本来就设计成开放结构,比如某些字体中的数字 4,填洞可能会改变形状,所以还是得看图决定。

第三,调整模型的字符集约束。如果识别对象确定只有数字,就用Industrial_0-9_NoRej,别用数字字母混合模型。模型知道候选集合越小,判断越果断,误识别率越低。这里有个教训:不要贪心用 Universal 模型,简单场景用专用模型反而更好。

4.3 更进阶的做法:给分类器追加训练样本

如果你的字符字体比较特殊,或者打印质量不太稳定,通用模型的天花板很明显。这时候别急着上深度学习,Halcon 的分类器其实支持追加训练。

流程是:先把识别错的字符区域截出来,人工标记正确字符,然后用AppendSamplesOcrClassMlp把这些样本加到当前分类器里,再执行TrainfOcrClassMlp让分类器更新权重。这个过程可以反复迭代,每次加几十个样本,识别率就会往上走一点。

我实际项目里给固定产线的字体做过一次追加训练,效果很明显,原本总是混淆的 7 和 1 在重新训练后基本不再出错。如果你只是临时用这个 Demo,可以先不改分类器,把训练接口的代码留在工程里,后续有需要再接上。

5. Qt 界面集成与交互体验

5.1 界面布局:别把图片显示做成附带功能

工业软件界面不需要花哨,但信息层次要清楚。我用的布局是:左侧主窗口显示当前图片,右侧设置面板,底部日志和识别结果。

图片显示我建议用自定义 QWidget 加 QPainter 绘图,而不是直接用 QLabel。原因很简单:后续要叠加检测框、文字标注,QLabel 不方便;用 QWidget 重写 paintEvent,画原图、画 ROI、画识别框、画置信度颜色标记,都很自由。

如果你还需要鼠标框选局部区域识别,那就在 QWidget 里处理 mousePressEvent 和 mouseReleaseEvent,把两个坐标转成图像像素坐标,再框出一个矩形 ROI 传给 Halcon。

5.2 识别放进线程里,别卡死界面

图像识别不是瞬时操作,尤其图片像素很大,预处理算子串起来跑,快的几十毫秒,慢的能到一秒多。如果你在 UI 线程里同步执行,点一次识别界面就白屏转圈,体验非常糟。

我用QtConcurrent::run把整个识别流程丢到后台线程,识别完成后再通过信号槽把结果传回界面。这里有个必须注意的点:HObject 本质上是对 Halcon 内部数据的引用,跨线程传递要小心。最稳妥的方式是只在线程内部创建和持有 HObject,线程结束前把结果转成 QImage 等 Qt 原生数据再传回去,不要把 HObject 直接塞进信号槽。

简单示意:

QFutureWatcher<QString>* watcher = new QFutureWatcher<QString>(this); connect(watcher, &QFutureWatcher<QString>::finished, this, [=]() { QString text = watcher->result(); ui->resultEdit->setText(text); watcher->deleteLater(); }); watcher->setFuture(QtConcurrent::run([=]() { return runOcr(currentQImage); }));

这样做的好处是界面始终可响应,用户可以在识别期间继续调整参数按钮,不用干等。

5.3 展示识别结果和参数配置

识别完成后,我不仅显示最终文本,还会把每个字符的置信度画在图片上,方便人工判断哪些字符可能读错。低于阈值比如 0.8 的字符框,用红色画,正常用绿色画。这个视觉反馈在调参阶段非常有用,你能一眼看出是哪一类字符容易被分类器混淆。

参数配置位置我建议做成面板输入框,至少包含:灰度阈值上下限、是否反色、允许字符集、置信度阈值。现场调试的时候,操作人员不一定能改代码,但改输入框很简单,省得每次调参来回编译。

结果输出方面,我会把识别文本、图片路径、识别耗时、各字符置信度一起追加到 CSV 文件里。这样积累一段时间后,你能从统计上看出识别准确率和哪些图片错了,对后续优化非常有参考价值。

6. 常见问题与排查技巧实录

6.1 链接和运行环境相关的坑

  • 编译报找不到halconcpp.lib:先检查 INCLUDEPATH 和 LIBS 路径有没有拼对,再看 Halcon 的 x64/x86 版本和 Qt 编译器位数是否一致。
  • 程序双击后马上闪退:大概率是halconcpp.dll或halcon.dll不在 exe 目录。把 Halcon 的 bin 目录加入 PATH,或直接把 DLL 拷到 build 目录。
  • 运行时报错带 “license not found” 或类似字样:检查授权文件是否有效,重启开发电脑有时也能解决授权服务未启动的问题。

6.2 图片格式转换出现的斜条纹和颜色错乱

QImage 在内存中的行字节数并不一定等于 width*3,因为有字节对齐。如果直接用bits()和GenImageInterleaved传原始宽度,而数据中间夹了填充字节,图像就会出现斜切条纹。解决方法是先把 QImage 按convertToFormat转成标准 RGB888,再手动拷贝到一个无填充的临时缓冲区。

颜色错乱的问题也常见,主要原因是 RGB 通道顺序。Halcon 里"rgb"表示按 R、G、B 顺序,QImage 的Format_RGB888也是这个顺序,但有些代码从 BGR 转过来没对齐,就会红蓝互换。先用一张背景纯红的图片测试,看到红色正常再往下走。

6.3 识别结果乱序和精度低

乱序几乎都是没做SortRegion。OCR 分类器是逐区域识别的,区域顺序就是结果顺序。排序方向参数"row"是按行从左上到右下排,适合横向文本;如果是竖排字符,可能需要按列排序。

精度低的问题,我建议先别急着改参数,把预处理每步的中间结果都存出来看一遍。我习惯在代码里留一个开关,把灰度图、阈值分割图、筛选后的区域图分别保存到 debug 目录。看到底是分割碎了、粘连了,还是区域框选错了,一目了然。

6.4 内存和指针的大坑

HObject 的复制不是深拷贝,它更像一个引用计数器,两个变量可能指向同一份图像数据。如果你在跨线程或多次处理中同时修改同一个 HObject,会出不可预期的崩溃。需要真正独立数据时,用CopyObj做一次深拷贝。

QImage 的bits()返回指针也有生命周期问题。如果 QImage 是局部变量,函数返回后指针就失效了,再去转 HObject 或画图就会越界。把 QImage 生命周期延长到整个识别流程结束,或者提前把数据拷出来,都要有意识处理好。

7. 几点个人体会与扩展方向

项目做完后我最大的体会是:所谓“简易 OCR”,真正的工作量并不在调几个 Halcon 算子,而在把图片喂给分类器之前那一段预处理链路的可靠性。你能把阈值分割做好,把区域筛选选对,剩下的识别其实就剩最后一个算子的事。

如果后续想在这个 Demo 上加东西,方向其实很多。比如针对固定字体做分类器的追加训练,把通用的识别能力收敛到你的具体场景;或者把识别目标从单行字符扩展成多行表格,用行列投影的方法切分区域;再就是跟条码模块结合,一个产品上同时读标签码和印刷字符,用同一个界面统一管理。

最后再分享一个小技巧:识别参数最好做成配置文件或者界面输入框,不要写死在代码里。我在现场调参时深有体会,操作人员要试好几组阈值才能稳定,如果每次都要改代码重编译,效率太低了。把参数外置,识别逻辑本身保持稳定,后面不管换产线还是换字体,都只是调参的事,不涉及改程序。

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

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

立即咨询