0.2mm 玻璃暗码做到 99.7% 解码率,这套 Halcon 参数调了很久
六月份接了个活儿。玻璃盖板上的激光暗码,油墨层反光,码只有 0.2mm × 0.2mm,视野却要cover 20mm × 20mm。
算下来,码落到图像上也就 50 个像素出头。
什么概念?10 × 10 的 Data Matrix,一个模块大概 5 像素;12 × 12 的,模块只剩 4 像素刚出头。官方推荐至少 3~4 像素,表面再蹭点粉尘,这点余量根本不够看。
客户要求解码率 99.7% 以上。PM 当时还补了一句:这个指标是要写进验收单的。
我当时到现场第一件事不是开 Halcon,是拿尺子量工作距离,算镜头放大率。算完心里基本有数:解码器能拉的提升有限,真正的胜负在光学和打标工艺。但光学的账要慢慢算,客户产线等不起,先调参数,能拉一点是一点。
这篇文章就是那段调试的复盘。不讲光学,只讲 Halcon 2D 码解码参数怎么配,以及两个让我少走很多弯路的速度技巧。
算子外面那 5 个参数
我们项目里把读码封成一个算子,外面只暴露 5 个东西:
| 参数 | 常用值 / 默认 | 说明 |
|---|---|---|
_hvSymbolType | Data Matrix ECC 200、QR、PDF417、Aztec、DotCode 等 10 种 | 码制 |
_hvModelParameter | standard_recognition/enhanced_recognition/maximum_recognition | 预设档位 |
_hvPolarity | dark_on_light/light_on_dark | 极性 |
_hvTimeout | 200 ms | 单次超时 |
_hvTrain | false/true | 训练模式开关 |
就 5 个。但 HALCON 背后那套 2D 码体系,参数多到能翻十几页文档。我们挑跟现场相关的说。
解码其实就三步
代码骨架大概这样:
// 1. 建模型CreateDataCode2dModel(_hvSymbolType,"default_parameters",_hvModelParameter,&_hvDataCodeHandle);// 2. 设参数SetDataCode2dParam(_hvDataCodeHandle,"polarity",_hvPolarity);SetDataCode2dParam(_hvDataCodeHandle,"timeout",_hvTimeout);// 3. 查找FindDataCode2d(image,&symbolXLD,_hvDataCodeHandle,HTuple(),HTuple(),&resultHandles,&decodedStrings);// 4. 用完清掉ClearDataCode2dModel(_hvDataCodeHandle);先记住几件事。
模型句柄是有状态的。SetDataCode2dParam每调一次,句柄内部就变一次。多线程共用一个句柄,同步自己负责。
default_parameters是总开关。把它设回standard/enhanced/maximum,所有参数连训练状态一起清零。参数调乱了就靠它回滚。
参数不用一次设死。后面随时能改,当前值用GetDataCode2dParam查,支持哪些参数用QueryDataCode2dParams列。
参数怎么分
官方文档把参数分四类。我按自己的经验重新归了下。
1. 三档预设,其实就是三组默认值
| 参数 | standard | enhanced | maximum |
|---|---|---|---|
polarity | dark_on_light | any | any |
contrast_min | 30 | 10 | 10 |
module_size_min | 6 | 2 | 1 |
module_size_max | 20 | 100 | 100 |
finder_pattern_tolerance(DM) | low | low | any |
module_grid | fixed | any | any |
small_modules_robustness | low | low | high |
candidate_selection(DM) | default | extensive | extensive |
档位越高,搜索窗口越宽,越烂的码越想啃下来。代价就是慢。
我那项目里一开始同事图省事直接上maximum_recognition,结果解码是能解,但一拍 400ms 多,产线根本跑不动。后来改成enhanced,配合 ROI 和训练,硬是压到 100ms 以内。
图像质量稳定、打标和光源不飘的,standard完全够。波动大再逐级往上。别一上来就maximum,然后回头抱怨节拍不够。
2. 符号尺寸:让它知道你找的东西长什么样
| 码制 | 关键参数 | 说明 |
|---|---|---|
| Data Matrix ECC 200 | symbol_rows_min/max、symbol_cols_min/max、symbol_shape | 行 [8…144]、列 [10…144],形状rectangle/square/any |
| QR Code | version_min/max或symbol_size_min/max | 版本 1~40 对应 21×21 ~ 177×177 模块 |
| PDF417 | symbol_cols_min/max、symbol_rows_min/max | 列 1~30,行 3~90 |
| Aztec Code | format、symbol_size_min/max | compact/full_range/rune,11~151 模块 |
| DotCode | 行偶数、列奇数 | 列 [5…999],行 [4…998] |
strict_model默认yes,尺寸对不上的符号直接不读。听着狠,其实是好事。码尺寸固定的话,把symbol_size卡死成一个值,这是最便宜的提速手段,没有之一。
文档里有几个坑容易踩:
- 改
symbol_cols/symbol_rows,symbol_shape会被重置成any; symbol_cols_min比symbol_rows_max还大时,symbol_shape会被强制成rectangle;- 参数按传入顺序逐个生效,每步都做一致性检查。顺序不同结果可能不一样。
3. 模块外观:决定读不读得出来
| 参数 | 含义 | standard 默认 |
|---|---|---|
polarity | 深底浅码 / 浅底深码 / 任意 | dark_on_light |
mirrored | 是否镜像 | any |
contrast_min | 前景背景最小灰度差 | 30 |
module_size_min/max | 模块像素尺寸 | 6 / 20 |
module_gap_min/max | 模块间隙no/small/big | no |
small_modules_robustness | 极小模块鲁棒性 | low |
slant_max(DM) | L 形寻边最大倾角 | 10°(enhanced 30°) |
finder_pattern_tolerance(DM) | 寻边图案缺损容忍 | low |
contrast_tolerance | 局部对比度波动容忍 | low |
module_grid | 模块网格固定/可变 | fixed |
module_size_min是耗时第一大户。从 6 降到 1,候选尺度成倍涨。不是真在做微小码,别动它。
模块至少 3~4 像素是官方推荐值。但这话潜台词是:这根本不是参数问题,是选型问题。镜头放大率和相机分辨率一确定,模块像素数就确定了。视野、工作距离、码尺寸,这笔账在选型阶段就得算清楚。算不清楚,后面调参数全是白费劲。
module_gap跟打标工艺绑定。激光打金属容易晕开,模块比理论瘦,module_gap得放开到small甚至big。如果模块退化成孤立小点,先上GrayErosionShape/GrayDilationShape做形态学,别硬刚解码参数。
4. 行为参数:管节拍和内存
| 参数 | 含义 | 默认 |
|---|---|---|
timeout | 超时 ms,false或 ≤0 关闭 | false |
abort | 从其他线程中止当前解码 | true |
strict_model | 尺寸不符是否拒读 | yes |
strict_quiet_zone | 静区不足是否拒读 | no |
persistence | 中间结果持久级别 | 0 |
discard_undecoded_candidates | 丢弃未解码候选省内存 | no |
string_encoding | 解码字符串编码 | latin1 |
candidate_selection(DM/DotCode) | 候选区域生成策略 | default |
decoding_scheme(DM) | default/raw原始码字 | default |
中文是经典坑。默认latin1,码里编了 UTF-8 中文,解出来一定乱码。string_encoding显式设utf8。
timeout 精度大概 10ms,跟机器性能、图像尺寸、timer_mode都有关。设 200ms 不代表真给 200ms。算节拍时留点余量。
训练模式:训一次,后面省很多事
我那项目里,标准模式死活解不出来,开训练才过。
代码里我们用的是两段式 fallback:
// 第一段:正常模式,受 timeout 保护HalconCpp::FindDataCode2d(h0ImageReduce,&hSymbolXLD,_hvDataCodeHandle,HTuple(),HTuple(),&hResultHandles,&hDecodedStrings);// 第二段:失败且开了训练才走if(hDecodedStrings.Length()==0&&_hvTrain=="true"){HalconCpp::FindDataCode2d(h0ImageReduce,&hSymbolXLD,_hvDataCodeHandle,"train","all",&hResultHandles,&hDecodedStrings);}train设all时,Halcon 不按预设窗口硬搜。它放宽搜索,找到符号后反推参数,把模块尺寸、对比度、极性、间隙这些外观特征学进模型。
预设参数其实就是在猜。standard赌你模块不小于 6 像素、对比度大于 30、深码浅底。错一条就读不出。现场图像什么样,只有解出来才知道。
训练模式就是拿一张真实样本,让 Halcon 自己把参数调对。打标深度漂了、光源老化了,训好的模型跟得上。用GetDataCode2dParam查"trained",能看到哪些参数进了训练态。
坑没少踩,说几个印象深的。
训练模式下 timeout 被忽略。文档原话:“the timeout is ignored if FindDataCode2d runs in training mode.” 训练等于一次全量放宽搜索,慢起来没谱。所以第一段正常模式必须保留,训练只当兜底。
训练结果只活在句柄里。进程一退全没。必须落盘:
// 序列化HalconCpp::SerializeDataCode2dModel(_hvDataCodeHandle,&hvSerializeItem);HalconCpp::FwriteSerializedItem(hvFileHandle,hvSerializeItem);// 反序列化HalconCpp::FreadSerializedItem(hvFileHandle,&hvSerializeItem);HalconCpp::DeserializeDataCode2dModel(hvSerializeItem,&_hvDataCodeHandle);我习惯这样:文件存在就反序列化加载;不存在才新建。训练一次,后面直接加载。
别每拍都训练。调试阶段或者离线工具训好、存盘,产线只跑正常解码。每拍都训等于每拍都埋一颗不受超时保护的慢扫描炸弹。产线配置里_hvTrain就该是false。
训练流程我一般这么走:先抓一张有代表性的现场图,不是实验室里那种干干净净的标准图;正常模式先跑,跑不通再开train;训成功之后立刻SerializeDataCode2dModel落盘;再用GetDataCode2dParam复核一遍参数,别把废图特征学进去;最后上线把train关掉,靠落盘模型和timeout保节拍。
缩小解码区域:最划算的一招
这是速度提升里最划算的一招。
// 取第 i 个 ROIHalconCpp::SelectObj(_hoRoi,&hTempROi,i+1);// ROI 仿射跟随if(_bNeedTransRoi){HObject transObj;AffineTransRegion(hTempROi,&transObj,_hHomMatRoi,"nearest_neighbor");HalconCpp::ReduceDomain(_hoImage,transObj,&h0ImageReduce);}else{HalconCpp::ReduceDomain(_hoImage,hTempROi,&h0ImageReduce);}// 只在 domain 内解码FindDataCode2d(h0ImageReduce,...);ReduceDomain不搬像素,只改定义域。算法只在 domain 里跑。500 万像素整图,码只占 300×300 一角,整图搜和 ROI 搜,耗时差着数量级。
官方文档末尾也认了,FindDataCode2d的运行时间主要看三样:搜索区域大小、module_size_min、符号尺寸限制宽窄。这三样里,缩区域是唯一不碰鲁棒性、纯赚速度的操作。收窄module_size和symbol_size也快,但那是拿泛化换的,来个尺寸不一样的码就抓瞎。
还有个隐藏好处。区域小,误读也少。产线图像里经常有看着像码的东西,铭牌、螺钉阵列、散热孔。整图搜索时参数一松,指不定就把哪个给"解"出来了。锁死在码该在的位置,等于用空间信息做硬过滤。
码可能在多个位置,怎么办
多工位、来料方向不定的场景,一个 ROI 罩不住。并行解:
std::vector<std::future<bool>>futureList;std::atomic<bool>g_stop(false);for(inti=0;i<nnn;i++){futureList.emplace_back(std::async(std::launch::async,[this,i,&g_stop]()->bool{if(g_stop)returnfalse;// SelectObj → ReduceDomain → FindDataCode2dif(hDecodedStrings.Length()>0){_hvDecodedDataStrings=hDecodedStrings;_hoSymbolXLDs=hSymbolXLD;g_stop.store(true);returntrue;}returnfalse;}));}std::atomic<bool>做先到先得。哪个 ROI 先解出来,别的线程看到标志就撤,不占 CPU。
模型创建和参数设置留在主线程,只做一次。这不光是快慢问题,是线程安全要求:SetDataCode2dParam会改句柄状态,文档写明跨线程必须同步。工作线程只做ReduceDomain + FindDataCode2d,这两个是 reentrant 的。
要从外面强行中止解码,比如上位机急停,用SetDataCode2dParam(handle, "abort", ...)。文档里说这是唯一一个不用额外同步就能从其他线程发起的操作。
位置会漂的,ROI 得跟着走。_bNeedTransRoi + AffineTransRegion就是干这个:先用定位拿到工件位姿矩阵_hHomMatRoi,示教时画的 ROI 跟着变换到当前位置。画一次,后面自动跟。
上线前我习惯过一遍的清单
最后把散在代码和文档里的东西汇总一下,上线前逐条过:
| # | 检查项 | 原因 |
|---|---|---|
| 1 | 模型创建 1 次,参数设置 1 次,都在主线程 | 句柄有状态,跨线程修改需同步 |
| 2 | 在线timeout必设 | 保节拍;训练模式下失效 |
| 3 | 训练成功立即SerializeDataCode2dModel落盘 | 训练结果随进程消失 |
| 4 | 产线配置train = false | 训练慢且不受 timeout 保护 |
| 5 | 解码前ReduceDomain到最小必要区域 | 运行时间三大因素之首 |
| 6 | 中文内容显式设string_encoding = utf8 | 默认latin1会乱码 |
| 7 | 码尺寸固定时卡窄symbol_size/version | 无损提速 + 防误读 |
| 8 | 长跑任务关注persistence/discard_undecoded_candidates | 控制模型内存增长 |
| 9 | 急停用abort参数跨线程中止 | 唯一线程安全的中止通道 |
| 10 | 析构时ClearDataCode2dModel释放句柄 | 防 Halcon 句柄泄漏 |
流程图我画了一张,放下面:
两个技巧是互补的。缩区域把每拍成本压到底,训练把"到底读不读得出来"的适配成本一次付清、离线付清。一个负责快,一个负责稳,合一起才是能上产线的方案。
写在最后
参数看着多,理清楚就四条线:档位预设管全局,尺寸约束管搜索,外观参数管匹配,行为参数管节拍。
但老实说,解码失败的第一现场,十有八九不在参数表里。
开头那个玻璃暗码,50 像素的码,模块就 4 像素出头,正好卡在生死线上。粉尘再吃掉一点,参数调出花来也救不回来。像素和毫米之间这笔账,才是读码稳定的地基。
我后面专门写了一篇光学选型的笔记:0.2mm 的码怎么在 20mm 视野里活下来,镜头放大率、相机分辨率、工作距离到底怎么配。准备也发出来,感兴趣的可以留意一下。
「像素与毫米」,机器视觉 × 精密加工的工程实录。产线上踩出来的坑,一期填一个。
你那边遇到过什么读码玄学?评论区聊聊,下期选题就从里面挑。