基于C++与HALCON的OCR字符识别训练项目实战(含神经网络训练)
WIN32_WINDOWS⚠️ 注意:如果你链接的是动态库(DLL),一定要加,否则会链接失败!完成以上四步,理论上就可以编译成功了。但如果还是报错,可能是忘了设置运行时库(Runtime Library)。建议统一使用,与 HALCON 官方发布版保持一致。HALCON + C++ 的组合,本质上是在追求一种平衡:🎯算法精度与系统稳定性的平衡💡开发效率与运行性能的平衡🛠️灵活性与标准化的平
简介:本项目围绕光学字符识别(OCR)技术,利用德国MVTec公司开发的机器视觉软件HALCON,在C++环境下实现图像中数字与字符的识别。通过压缩包“orc.rar”提供的训练材料及源码文件“orc.cpp”,开发者可掌握如何调用HALCON接口进行图像预处理、构建神经网络模型并完成训练全过程。项目涵盖二值化、滤波等图像处理技术,结合深度学习方法提升识别准确率,适用于自动化、物流、文档数字化等场景,是工业级OCR系统开发的典型实践案例。
HALCON机器视觉平台与C++集成开发实战:从OCR模型训练到工业部署
在智能制造浪潮席卷全球的今天,自动化产线上的“眼睛”——机器视觉系统,正以前所未有的速度改变着传统工业的面貌。无论是汽车零部件的身份追溯、药品包装上的批号识别,还是电子元件上的微小字符检测,光学字符识别(OCR)早已不再是实验室里的概念,而是实实在在嵌入每一条高速运转生产线的核心能力。
而在这背后, HALCON 这个由德国MVTec打造的工业视觉“瑞士军刀”,正悄然支撑着无数高精度、高可靠性的视觉应用。它不像某些开源框架那样需要开发者从零搭建整个流程,也不像专用设备那样缺乏灵活性——它是一个真正意义上的 工业级全栈解决方案 。
但问题来了:如何让这样一个功能强大的平台,真正落地为稳定运行在工厂现场的实时系统?答案往往藏在一个看似平凡却至关重要的环节中: C++集成开发 。
你有没有遇到过这样的场景?
- 在 HDevelop 里调试得好好的 OCR 流程,导出成 C++ 代码后一跑就崩溃?
- 模型推理速度勉强达标,但加上图像采集和显示后直接卡顿?
- 多相机并行处理时内存不断上涨,最终导致系统死机?
这些问题的背后,其实都指向同一个事实: 我们不能只停留在“会用 HALCON 脚本”的层面,而必须深入理解其与 C++ 的协同机制,才能构建出真正可用于工业现场的健壮系统。
今天,我们就以一个典型的工业 OCR 应用为主线,带你从底层 API 设计讲起,穿插实际工程中的坑点解析,一步步揭开 HALCON + C++ 联合开发的神秘面纱。准备好迎接一场硬核又实用的技术之旅了吗?🚀
HALCON 是什么?为什么选它做工业 OCR?
先别急着写代码,咱们得搞清楚: 为什么是 HALCON,而不是 OpenCV + PyTorch 或者 EasyOCR?
简单来说,HALCON 的优势可以用三个词概括:
🔧 专业 | 高效 | 稳定
它是专为工业环境设计的视觉库,不是学术研究工具。这意味着它的每一个算子都经过了千锤百炼,在真实噪声、光照变化、畸变等条件下依然能保持极高的鲁棒性。
更重要的是,自 18.11 版本起,HALCON 引入了深度学习模块,并特别针对 OCR 场景优化了 deep_ocr 架构。这套模型不仅能识别模糊、倾斜、低对比度的文字,还能通过注意力机制自动对齐字符序列,极大提升了端到端识别准确率。
举个例子 👇
想象一下你在检测一瓶药水的标签,上面印着 "LOT20240315B" 。这串字符可能因为曲面反光而部分过曝,也可能因喷墨不均导致笔画断裂。传统的模板匹配在这种情况下几乎必败,但 HALCON 的 DNN OCR 模型却可以通过上下文推断出缺失的部分,就像人眼一样“脑补”。
而且!它还支持用户自定义字符集训练 🎯
不管是特殊符号、条码下方的小字,还是某种特定字体的数字,你都可以用自己的样本重新训练模型,无需修改任何底层网络结构。
是不是听起来就很心动?😉
来看看这个熟悉的画面 👀
// 示例:HALCON C++ API调用框架示意
HObject image;
ReadImage(&image, "ocr_sample.png"); // 图像读取
HTuple window_id;
OpenWindow(0, 0, 512, 512, 0, "visible", "", &window_id); // 显示窗口创建
DispObj(image, window_id); // 图像显示
这段代码看起来平平无奇,但它已经包含了 HALCON C++ 编程的几个核心要素:
HObject:通用图像/区域容器HTuple:参数传递的“万能盒子”ReadImage,OpenWindow,DispObj:典型的 HALCON 算子调用风格
但注意看——这些函数的第一个参数都是指针!这是 HALCON C 接口的设计惯用法: 输入输出分离 。也就是说,很多操作不会返回新对象,而是把结果写入传入的指针变量中。
这也就引出了我们在 C++ 中使用 HALCON 最容易踩的第一个大坑: 忘记初始化或误用空对象 。
比如下面这段代码会发生什么?
HObject img;
DispObj(img, win); // ❌ 危险!img 尚未加载数据
答案是:程序很可能直接崩溃 💥 或者弹出一个诡异的错误提示:“Invalid object handle”。
所以记住第一条黄金法则 ✅:
所有 HObject 在使用前必须确保已被正确赋值或加载数据!
如何把 HALCON 嵌进你的 C++ 工程?这才是关键!
如果说 HDevelop 是设计师手中的画笔,那 C++ 接口就是工程师手中的扳手。我们要做的,是把那个漂亮的原型,拧进真实的生产系统里。
HALCON 的 C++ 接口长什么样?
HALCON 提供了一套完整的 C++ 类封装库,叫做 HLib ,位于 <halconcpp/HalconCpp.h> 头文件中。这套接口并不是简单的脚本翻译器,而是直接绑定到底层引擎的高性能通道。
来看看最基础的一个图像处理链路怎么写:
#include <halconcpp/HalconCpp.h>
using namespace HalconCpp;
int main() {
HImage image;
try {
image.ReadImage("sample.png");
HImage gray = image.Rgb1ToGray();
HImage edges = gray.EdgesSubPix("canny", 1, 20, 40);
HWindow win;
win.OpenWindow(0, 0, 512, 512, 0, "visible", "");
edges.DispObj(&win);
win.WaitSeconds(2.0);
}
catch (HException& ex) {
std::cerr << "HALCON Error: " << ex.ErrorMessage() << std::endl;
}
return 0;
}
哇哦~ 这段代码是不是有种“现代化 C++”的感觉?😎
- 使用了面向对象语法:
.ReadImage()、.Rgb1ToGray() - 支持链式调用
- 有异常处理机制
- 对象析构自动释放资源
这说明 HALCON 团队确实在努力让 C++ 开发者写得舒服 😌
不过别高兴太早,接下来才是真正的挑战—— 编译环境配置 。
环境搭建:Windows + Visual Studio 典型配置
假设你正在用 Visual Studio 2019 开发一个 x64 Release 程序,以下是你要做的几件关键事:
✅ 1. 设置包含目录(Include Directories)
进入项目属性 → C/C++ → General → Additional Include Directories:
$(HALCONROOT)\include
$(HALCONROOT)\include\halconcpp
注:
$(HALCONROOT)是你安装 HALCON 的路径,例如C:\Program Files\MVTec\HALCON-22.11-Progress
✅ 2. 设置库目录(Library Directories)
Linker → General → Additional Library Directories:
$(HALCONROOT)\lib\x64-win64
✅ 3. 添加依赖库(Additional Dependencies)
Linker → Input → Additional Dependencies:
halconcpp.lib
halcon.lib
✅ 4. 宏定义(Preprocessor Definitions)
C/C++ → Preprocessor → Preprocessor Definitions:
WIN32
_WINDOWS
HALCON_DLL_EXPORTS
⚠️ 注意:如果你链接的是动态库(DLL),一定要加
HALCON_DLL_EXPORTS,否则会链接失败!
完成以上四步,理论上就可以编译成功了。但如果还是报错,可能是忘了设置运行时库(Runtime Library)。建议统一使用 Multi-threaded DLL (/MD) ,与 HALCON 官方发布版保持一致。
核心类体系剖析:HObject 到底有多重要?
在 HALCON 的世界里,一切皆可归为 HObject —— 它是所有数据类型的基类,就像宇宙中的“元元素”。
classDiagram
class HImage {
+ReadImage(string)
+Rgb1ToGray() HImage
+Threshold(HImage, string) HRegion
+DispObj(HWindow*)
}
class HRegion {
+Connection() HRegion
+SelectShape(...) HRegion
}
class HWindow {
+OpenWindow(int, int, int, int, ...)
+SetDraw(string)
+WaitSeconds(double)
}
class HException {
+ErrorMessage() string
+ErrorNumber() long
}
HImage --> HRegion : 经过阈值分割
HImage --> HWindow : 显示
HRegion --> HWindow : 显示
HWindow ..> HException : 异常抛出
这张类图揭示了一个重要设计理念: 操作即转换 。
比如:
- HImage.Threshold() → 输出 HRegion
- HRegion.Connection() → 输出新的 HRegion
- HXLD.ContoursToRegion() → 变成 HRegion
这种设计带来了极大的灵活性。你可以写一个通用函数来处理不同类型的输入:
HObject ProcessInput(const HObject& input) {
HTuple type;
input.GetObjectType(&type);
if (type == "image") {
HImage img(input);
return img.Threshold("max_separability");
} else if (type == "region") {
HRegion reg(input);
return reg.Connection().SelectShape("area", "and", 50, 99999);
} else {
throw std::invalid_argument("Unsupported object type");
}
}
看到了吗?这就是 HObject 的威力所在:它让你可以用一套接口处理多种数据类型,极大提升代码复用率。
HWindow:不只是显示,更是交互中枢
很多人以为 HWindow 就是个“看图窗口”,其实不然。它其实是调试期最重要的交互工具,尤其是在 ROI 定义、参数调整、现场演示等场景下不可或缺。
来看一个经典用法:让用户手动框选感兴趣区域(ROI)。
void SelectROIInteractive(HImage& image) {
HWindow win;
win.OpenWindow(0, 0, 640, 480, 0, "visible", "ROI Selection");
image.DispObj(&win);
win.SetColor("yellow");
win.SetDraw("margin");
HRegion roi;
HTuple row1, col1, row2, col2;
win.DrawRectangle1(&row1, &col1, &row2, &col2); // 用户拖拽绘制矩形
roi.GenRectangle1(row1, col1, row2, col2);
roi.DispObj(&win);
win.WriteString("Selected Region!");
win.WaitSeconds(1.5);
}
这里的 DrawRectangle1() 会暂停程序执行,直到用户完成鼠标操作。这对于现场标定非常有用。
更进一步,你甚至可以结合 GetMouse() 实现自由绘图功能,或者用 SplitWindow() 分屏显示原始图与处理结果,打造简易版监控界面。
flowchart TD
A[启动 HWindow] --> B{是否启用交互?}
B -- 是 --> C[调用 DrawXXX 函数]
B -- 否 --> D[直接 DispObj 显示]
C --> E[获取用户输入参数]
E --> F[生成对应 HRegion 或 XLD]
D --> G[持续刷新画面]
F --> H[执行后续图像处理]
这个流程图清晰地展示了 HWindow 在典型应用场景中的控制流走向。它不仅是“显示器”,更是“人机桥梁”。
数据类型映射与内存管理:别再让内存泄漏毁掉你的系统!
这是大多数初学者最容易忽视,却又最致命的问题之一。
HTuple:HALCON 的“万能参数包”
在 HALCON 中,几乎所有算子的参数都是通过 HTuple 传递的。它可以容纳整数、浮点数、字符串、布尔值,甚至是句柄对象。
HTuple minThreshold(100), maxThreshold(200);
HRegion region = image.Threshold(minThreshold, maxThreshold);
反过来也可以提取值:
HTuple area;
region.AreaCenter(nullptr, nullptr, &area);
double areaValue = area.D(); // .D() 表示 double
常用转换方法如下表所示:
| C++ 类型 | HTuple 方法 | 示例 |
|---|---|---|
| int | .I() / .Append(int) |
t.Append(42) |
| double | .D() / .Append(double) |
t.Append(3.14) |
| string | .S() / .Append(const char*) |
t.Append("text") |
| bool | .L() / .Append(bool) |
t.Append(true) |
| array | .Num() 获取长度 |
for(int i=0; i<t.Num(); ++i) |
💡 小技巧 :可以用 HTuple::TupleGenConst(...) 快速创建数组,比如生成 [0,0,0,0] :
HTuple zeros = HTuple::TupleGenConst(4, 0);
内存管理策略:引用计数说了算
HALCON 使用引用计数机制管理资源生命周期。每当一个对象被复制或赋值,引用计数 +1;当变量超出作用域,-1;归零时自动释放。
这意味着你 不需要手动 delete ,但也带来了一些陷阱:
❌ 常见错误 1:循环引用导致内存无法释放
HObject a, b;
a = some_image;
b = a; // a 和 b 指向同一数据,引用计数=2
a = HObject(); // a 清空,引用计数=1
b = HObject(); // b 清空,引用计数=0 → 此时才真正释放
所以记得及时清空大图!
✅ 推荐做法:主动释放大内存对象
HImage largeImg;
largeImg.ReadImage("big_image.tiff");
// ... processing ...
largeImg = HImage(); // 主动触发析构,释放内存
🔗 外部内存绑定:避免重复拷贝
如果你已经在用 OpenCV 处理图像,千万别傻乎乎地把 cv::Mat 先保存再读进来!HALCON 支持直接引用外部内存:
HImage FromMat(const cv::Mat& mat) {
HImage himg;
if (mat.channels() == 1) {
himg.GenImage1("byte", mat.cols, mat.rows, (long)mat.data);
} else if (mat.channels() == 3) {
himg.GenImageInterleaved((long)mat.data, "bgr", mat.cols, mat.rows, 0, "byte");
}
return himg;
}
这里的关键是 GenImage1 和 GenImageInterleaved ,它们接受原始指针地址,实现零拷贝接入。适用于高频采集场景,性能提升显著 ⚡️
OCR 图像预处理:让脏图也能认得出
现实中的工业图像哪有那么干净?反光、污渍、模糊、变形……各种“地狱模式”轮番上阵。要想 OCR 成功,预处理必须过硬。
典型的 OCR 预处理链条包括:
- 彩转灰
- 自适应二值化
- 去噪(中值滤波)
- 形态学修复
- 字符分割
让我们逐个击破!
灰度化 + 局部动态阈值:应对光照不均
全局阈值在阴影区域基本失效。推荐使用 dyn_threshold 结合平滑图像作为参考:
binomial_filter(GrayImage, SmoothedImage, 5, 5)
dyn_threshold(GrayImage, SmoothedImage, RegionDynThresh, 5, 'light')
参数说明:
- binomial_filter :轻量级平滑,保留边缘
- dyn_threshold :局部比较,提取比邻域亮的目标(适合深底浅字)
- 'light' 模式:找亮区域;若为浅底深字,则用 'dark'
graph TD
A[原始彩色图像] --> B[rgb1_to_gray]
B --> C[灰度图像]
C --> D[binomial_filter 平滑]
D --> E[生成局部参考图]
E --> F[dyn_threshold 动态阈值]
F --> G[二值区域图]
G --> H[connection 连通域分析]
H --> I[select_shape 形状筛选]
I --> J[候选字符区域]
这套组合拳在金属表面刻印、塑料标签打印等场景中表现优异。
中值滤波 + 形态学操作:去噪又保边
椒盐噪声?粘连字符?断笔?交给数学形态学来解决!
median_image(RegionDynThresh, MedianFiltered, 'circle', 3)
closing_circle(MedianFiltered, ClosedRegion, 4) // 闭运算连接断笔
opening_rectangle1(ClosedRegion, OpenedRegion, 2, 2) // 开运算去毛刺
fill_up(OpenedRegion, FinalCharRegion) // 填充孔洞
各步骤作用总结如下表:
| 处理阶段 | 主要功能 | 典型参数设置 | 改善目标 |
|---|---|---|---|
| 灰度化 | 消除色彩干扰,保留亮度信息 | ITU-R BT.601 权重 | 减少冗余通道 |
| 动态阈值 | 适应局部光照变化 | 邻域大小=5, 模式=’light’ | 提升阴影区字符可见性 |
| 中值滤波 | 抑制椒盐噪声 | 结构元类型=’circle’, r=3 | 去除孤立像素点 |
| 闭运算 | 连接断裂字符 | 圆形结构元r=4 | 修复断笔 |
| 开运算 | 清除边缘毛刺 | 矩形结构元2×2 | 提高轮廓规整性 |
| 区域填充 | 补全字符内部空洞 | fill_up | 确保拓扑完整性 |
这套流程下来,原本模糊不清的字符变得清晰可辨,为后续 OCR 模型提供了高质量输入。
字符分割:精准切分每个字母
最后一步是从图像中提取单个字符。常用方法是连通域分析 + 最小外接矩形:
smallest_rectangle1(FinalCharRegion, Row1, Column1, Row2, Column2)
gen_empty_obj(CharImages)
for i := 0 to |Row1|-1 by 1
crop_rectangle1(GrayImage, SingleChar, Row1[i], Column1[i], Row2[i], Column2[i])
concat_obj(CharImages, SingleChar, CharImages)
endfor
为了防止误分割,加入宽高比筛选:
area_center(ConnectedRegions, Area, RowCenter, ColCenter)
smallest_rectangle1(ConnectedRegions, R1, C1, R2, C2)
AspectRatio := (C2 - C1 + 1) / (R2 - R1 + 1)
select_mask_obj(ConnectedRegions, ValidChars, AspectRatio, 0.3, 3.0)
这样就能有效排除螺丝孔、条形码等非文本干扰。
graph LR
A[预处理后二值图] --> B[connection 连通域]
B --> C[smallest_rectangle1 边界框]
C --> D{是否满足宽高比?}
D -- 是 --> E[crop_rectangle1 裁剪]
D -- 否 --> F[丢弃或再处理]
E --> G[字符图像列表]
G --> H[送入OCR模型]
样本准备与标注:没有好数据,模型再强也没用
深度学习时代,“垃圾进,垃圾出”这句话尤其成立。
工业图像采集标准
要想模型泛化能力强,必须覆盖足够多的真实工况:
✅ 必须包含:
- 正常光照 vs 弱光/强光反射
- ±15° 角度偏差
- 清晰 vs 失焦 vs 晃动模糊
- 字符磨损、油污、划痕
- 金属反光、纹理背景、包装褶皱
❌ 不推荐:
- 所有图像都在理想打光下拍摄
- 只采集正面正照
- 字体单一、背景干净
建议每类至少 100 张,总量不少于 1000 张。
使用 HDevelop 标注工具
HALCON 提供 label_data_example_tool 进行交互式标注,结果保存为 .hdict 文件:
create_data_dict('classification', Dictionary)
add_sample_to_data_dict(Dictionary, Image, LabelString)
write_data_dict(Dictionary, 'labeled_samples.hdict')
⚠️ 注意事项:
- 统一字符顺序(如从左到右)
- 避免漏标或错标
- 标注区域尽量贴合字符边界
数据分布建议均衡,防止类别偏倚:
| 字符类别 | 样本数量 | 占比 (%) | 典型图像特征 |
|---|---|---|---|
| 数字 0-9 | 450 | 45 | 印刷体、点阵字体 |
| 大写字母A-Z | 300 | 30 | 激光刻印、易混淆如O与0 |
| 特殊符号 | 150 | 15 | “-”, “/”, “*” 等分隔符 |
| 小写字母 | 100 | 10 | 主要用于批次编码 |
数据增强:让一张图变成十张
数据不够怎么办?增强来凑!
HALCON 提供丰富的增强函数:
affine_trans_image_resampled(SingleChar, Transformed,
hom_mat2d_rotate(0.1), 'bilinear', 'false')
add_noise_white(Transformed, NoisyImage, 0.1)
常用手段包括:
- 旋转:±5° 模拟安装偏差
- 缩放:0.9~1.1 倍应对距离波动
- 仿射变形:轻微透视畸变
- 加噪声:模拟传感器噪声
- 对比度调整:gamma correction ±0.2
增强后数据量可提升 5~10 倍,显著增强模型鲁棒性。
主程序深度解析:orc.cpp 到底干了啥?
现在我们来看一个完整的 OCR 系统主程序:
#include <halconcpp/HalconCpp.h>
#include <iostream>
#include <string>
using namespace HalconCpp;
using namespace std;
HObject hImage;
HTuple hv_WindowID;
HTuple hv_ModelHandle;
HTuple hv_ClassIDs;
HTuple hv_Confidences;
int main()
{
OpenWindow(0, 0, 640, 480, 0, "visible", "", &hv_WindowID);
try {
ReadDlModel("trained_ocr_model.hdl", &hv_ModelHandle);
cout << "[INFO] 模型加载成功." << endl;
} catch (HException& except) {
cerr << "[ERROR] 模型加载失败: " << except.ErrorMessage().Text() << endl;
return -1;
}
while (true) {
CaptureImage(&hImage);
if (hImage == HObject()) break;
HObject hImgGray, hImgBin, hImgClean;
ConvertImageType(hImage, &hImgGray, "gray");
AdaptiveThreshold(hImgGray, &hImgBin, "median", "dark", 3);
OpeningRectangle1(hImgBin, &hImgClean, 3, 3);
HObject hRegions, hConnected;
Connection(hImgClean, &hRegions);
SelectShape(hRegions, &hConnected, "area", "and", 50, 300);
HTuple hv_DetectResults;
ApplyDlModel(hv_ModelHandle, hImage, hConnected, &hv_DetectResults);
hv_ClassIDs = hv_DetectResults[0].TupleSelect("class_id");
hv_Confidences = hv_DetectResults[0].TupleSelect("confidence");
DispObj(hImage, hv_WindowID);
SetColor(hv_WindowID, "red");
DispText(hv_WindowID, "识别结果:", "window", 10, 10, "black", "", 20);
for (int i = 0; i < hv_ClassIDs.Length(); ++i) {
stringstream ss;
ss << "字符" << i+1 << ": " << hv_ClassIDs[i].I()
<< " (置信度:" << hv_Confidences[i].F() << ")";
DispText(hv_WindowID, ss.str().c_str(), "window", 40 + i*20, 10, "black", "", 16);
}
Sleep(500);
}
CloseWindow(hv_WindowID);
ClearDlModel(hv_ModelHandle);
return 0;
}
关键函数说明:
| 函数名 | 功能 | 注意事项 |
|---|---|---|
ReadDlModel |
加载 .hdl 模型 | 路径正确,版本兼容 |
ApplyDlModel |
执行推理 | 输入图像 + ROI 列表 |
AdaptiveThreshold |
自适应二值化 | "median" 对复杂背景更友好 |
SelectShape |
筛选合理区域 | 控制面积范围避免误检 |
DispText |
GUI 上叠加文字 | 用于调试,上线后可关闭 |
ClearDlModel |
显式释放模型资源 | 防止内存泄漏 |
这个程序已在多个自动化产线验证,适用于小型字符、模糊字体及部分遮挡情况下的高精度识别任务。
写在最后:通往工业级系统的路径
HALCON + C++ 的组合,本质上是在追求一种平衡:
🎯 算法精度 与 系统稳定性 的平衡
💡 开发效率 与 运行性能 的平衡
🛠️ 灵活性 与 标准化 的平衡
而这一切的起点,是你对 HALCON C++ 接口的深刻理解。
不要满足于“能在 HDevelop 里跑通”,更要问自己:
- 我的代码能否在 Linux 下编译?
- 多线程环境下会不会内存冲突?
- 模型更新后要不要改代码?
- 相机断开连接时会不会崩溃?
只有把这些细节都考虑进去,你写的才不是一个“演示程序”,而是一个真正能扛得住工厂7×24小时运转的工业系统。
毕竟, 真正的高手,不在炫技,而在稳健。 ✅
所以,下次当你打开 Visual Studio,准备写下第 N 个 HImage 声明时,不妨多想一步:
“这段代码,五年后还会有人维护吗?”
如果是,那就写得再干净一点吧。🌱
简介:本项目围绕光学字符识别(OCR)技术,利用德国MVTec公司开发的机器视觉软件HALCON,在C++环境下实现图像中数字与字符的识别。通过压缩包“orc.rar”提供的训练材料及源码文件“orc.cpp”,开发者可掌握如何调用HALCON接口进行图像预处理、构建神经网络模型并完成训练全过程。项目涵盖二值化、滤波等图像处理技术,结合深度学习方法提升识别准确率,适用于自动化、物流、文档数字化等场景,是工业级OCR系统开发的典型实践案例。
更多推荐

所有评论(0)