基于Qt与Halcon的插件式机器视觉框架设计与实现
2026/9/21 14:42:10 网站建设 项目流程

1. 为什么我要做一套插件式机器视觉框架

1.1 从三个真实痛点说起

做机器视觉项目这些年,最让我头疼的从来不是某个算法写不出来,而是项目之间的重复劳动。第一个痛点:每接一个新项目,就要把相机采集、图像预处理、模板匹配、结果输出这套流程重新搭一遍,代码复制来复制去,最后连自己都分不清哪个版本是最新的。第二个痛点:客户现场要加一个缺陷检测功能,我得在原有代码里硬塞进去,改完还要重新编译整个工程,调试周期被拉得很长。第三个痛点:团队里有人擅长Halcon算子调优,有人擅长Qt界面开发,但代码耦合在一起,谁都不敢轻易动别人的模块。

这三个痛点归结起来就是一句话:视觉项目缺少一套真正意义上的模块化底座。市面上当然有成熟的商用视觉平台,但要么价格高,要么封闭得厉害,想加一个自定义算子都费劲。所以我决定自己动手,基于Qt和Halcon搭一套插件式开发框架,把采集、处理、显示、通信这些环节全部拆成独立插件,做到即插即用、源码开放。

这套框架适合谁用?如果你正在做工业视觉检测项目,手头有Halcon授权,又希望有一套可维护、可扩展的代码底座,那这套思路可以直接参考。如果你是刚入行的视觉工程师,想理解一个完整视觉系统是怎么组织起来的,那这套框架的架构设计也值得一看。下面我把整个框架的设计思路、核心实现和踩过的坑,完整地分享出来。

1.2 框架的整体定位与能力边界

先明确这套框架能做什么、不做什么。它能做的是:提供一套插件管理内核,负责插件的加载、卸载、生命周期管理;提供标准化的插件接口,让每个功能模块以统一的方式接入;提供主界面框架,包括图像显示窗口、工具面板、日志输出区;提供Halcon与Qt之间的数据桥接层,解决HObject和QImage之间的转换问题。

它不做的是:不替代Halcon本身的算法能力,所有图像处理仍然调用Halcon算子;不提供完整的业务逻辑,具体检测流程需要根据项目需求在插件中实现;不绑定特定相机品牌,采集插件可以按需替换。这样定位的好处是框架足够轻,不会变成一个臃肿的“万能平台”,同时扩展性又足够强,任何新功能都可以通过新增插件的方式接入。

2. 框架整体架构与模块拆解

2.1 分层设计:内核层、接口层、插件层、界面层

整个框架我分成四层来设计。内核层是插件管理器,负责扫描插件目录、加载动态库、解析插件元信息、维护插件实例列表。接口层定义了一组纯虚基类,包括IPlugin、IImageSource、IProcessor、IDisplay、ICommunication等,所有插件都必须实现对应接口。插件层是具体的功能实现,比如海康相机采集插件、Halcon模板匹配插件、图像显示插件、TCP通信插件等。界面层是主窗口,负责把各个插件的配置界面和运行状态整合到一起。

这样分层之后,各层之间的依赖关系非常清晰:界面层依赖接口层,插件层也依赖接口层,内核层只依赖接口层。插件之间不直接互相调用,而是通过内核层提供的消息总线进行通信。这就避免了插件之间的强耦合,任何一个插件出问题,都不会导致整个系统崩溃。

2.2 插件接口设计:一个插件应该长什么样

插件接口的设计是整个框架的核心。我定义的IPlugin基类包含以下几个纯虚函数:

class IPlugin { public: virtual ~IPlugin() {} virtual QString name() const = 0; virtual QString version() const = 0; virtual QString description() const = 0; virtual bool initialize() = 0; virtual bool start() = 0; virtual bool stop() = 0; virtual void release() = 0; virtual QWidget* configWidget() = 0; };

每个插件必须提供名称、版本、描述这些元信息,方便内核层管理和界面层展示。initialize负责资源初始化,start和stop控制运行状态,release负责资源释放。configWidget返回插件的配置界面,如果没有配置界面就返回nullptr。

对于图像处理类插件,我再定义一个IProcessor接口继承自IPlugin:

class IProcessor : public IPlugin { public: virtual bool setInput(const HalconCpp::HObject& image) = 0; virtual bool process() = 0; virtual HalconCpp::HObject result() const = 0; virtual QJsonObject parameters() const = 0; virtual void setParameters(const QJsonObject& params) = 0; };

这样设计的好处是,处理类插件的输入输出格式统一,内核层可以像搭积木一样把它们串起来。参数用QJsonObject传递,既方便序列化保存,又方便界面层动态生成配置控件。

2.3 插件通信机制:消息总线怎么搭

插件之间不能直接调用,那它们怎么交换数据?我的方案是引入一个轻量级的消息总线。总线基于Qt的信号槽机制实现,但做了一层封装,让插件不需要知道彼此的存在。

具体做法是:内核层维护一个MessageBus单例,插件通过总线发布消息和订阅消息。消息格式定义为:

struct Message { QString topic; QString sender; QVariant payload; qint64 timestamp; };

比如采集插件完成一帧图像采集后,向总线发布topic为“image.captured”的消息,payload是图像数据。处理插件订阅这个topic,收到消息后自动触发处理流程。处理完成后发布“image.processed”消息,显示插件订阅后刷新界面。

这种发布订阅模式的好处是,新增一个插件只需要订阅自己关心的topic,不需要修改任何已有代码。我实测下来,即使总线上同时有十几个插件在收发消息,延迟也在毫秒级,完全满足工业视觉的实时性要求。

2.4 为什么选择Qt 5.15.2而不是Qt 6

Qt版本的选择上我纠结过一阵。Qt 6确实有很多新特性,比如更好的高DPI支持、更现代的C++标准。但最终我选了Qt 5.15.2,原因有三个。第一,Halcon的C++接口对Qt 6的兼容性在当时还不够稳定,特别是HObject和Qt图形对象的交互上容易出问题。第二,Qt 5.15.2是LTS版本,社区资料丰富,遇到问题容易找到解决方案。第三,很多工业现场的工控机还在跑Windows 7或Windows 10 LTSC,Qt 6对老系统的支持不如Qt 5.15.2完善。

安装Qt 5.15.2的时候有个坑要注意:离线安装包现在官方不直接提供了,需要从归档目录找。安装时记得勾选MSVC 2019 64-bit组件和Qt Charts模块,后者在画实时曲线的时候会用到。如果你用的是MinGW编译器,Halcon的库可能链接不上,因为Halcon官方提供的lib是MSVC编译的,所以编译器一定要选MSVC。

3. 核心模块实现细节与实操要点

3.1 Halcon与Qt的数据桥接:HObject转QImage的正确姿势

这是整个框架里最容易出问题的地方。Halcon的图像格式是HObject,Qt显示用的是QImage,两者之间的转换如果处理不好,要么颜色不对,要么内存泄漏,要么性能极差。

我试过三种方案。第一种是逐像素拷贝,用get_image_pointer1获取Halcon图像指针,然后逐像素写入QImage。这种方法最直观,但速度慢,一张500万像素的彩色图转换要几十毫秒。第二种是用Halcon的write_image把图像存成临时文件,再用QImage加载。这种方法更慢,而且频繁读写磁盘对工控机硬盘不友好。第三种是我最终采用的方案:直接用get_image_pointer1拿到指针,然后用QImage的构造函数直接包装这块内存,不做拷贝。

QImage HObjectToQImage(const HalconCpp::HObject& hobj) { HalconCpp::HTuple width, height; HalconCpp::GetImageSize(hobj, &width, &height); HalconCpp::HTuple pointer, type, widthT, heightT; HalconCpp::GetImagePointer1(hobj, &pointer, &type, &widthT, &heightT); // 灰度图直接构造 if (type[0].S() == "byte") { return QImage((uchar*)pointer[0].L(), width[0].I(), height[0].I(), width[0].I(), QImage::Format_Grayscale8).copy(); } // 其他类型需要先转换 HalconCpp::HObject converted; HalconCpp::ConvertImageType(hobj, &converted, "byte"); return HObjectToQImage(converted); }

注意最后那个.copy(),如果不加,QImage只是引用了Halcon的内存,一旦Halcon对象被释放,QImage就会指向野指针,程序直接崩溃。这个坑我踩过,调试了大半天才找到原因。

对于彩色图像,Halcon通常用三个通道分别存储,需要先用compose3合成或者分别取三个通道的指针,然后构造QImage::Format_RGB888。这里有个细节:Halcon的通道顺序是R、G、B,而QImage::Format_RGB888也是R、G、B,所以直接按顺序填充即可。但如果用Format_BGR888就要注意顺序反转。

3.2 插件动态加载:QPluginLoader的实战细节

Qt提供了QPluginLoader来加载动态库插件,用起来很简单,但有几个细节不注意就会翻车。

第一,插件类的声明必须用Q_PLUGIN_METADATA宏,并且接口类必须用Q_DECLARE_INTERFACE宏声明。这两个宏缺一不可,否则QPluginLoader的instance()会返回nullptr。

// 接口声明 class IPlugin { // ... }; Q_DECLARE_INTERFACE(IPlugin, "com.visionframework.IPlugin/1.0") // 插件实现 class CameraPlugin : public QObject, public IPlugin { Q_OBJECT Q_PLUGIN_METADATA(IID "com.visionframework.IPlugin" FILE "camera.json") Q_INTERFACES(IPlugin) // ... };

第二,插件动态库的编译选项要和主程序一致。特别是MSVC的运行时库选项,如果主程序用/MD而插件用/MT,加载时会直接失败。我建议统一用/MD,并且在项目属性里把“C/C++ -> 代码生成 -> 运行库”设为“多线程DLL(/MD)”。

第三,插件目录的扫描要用QDir遍历,但要注意过滤掉调试符号文件。Windows下动态库是.dll,Linux下是.so,macOS下是.dylib。我写了一个辅助函数来根据平台返回正确的后缀名。

第四,插件加载失败时一定要打印QPluginLoader的errorString(),否则你只知道加载失败,不知道原因。常见的失败原因包括:缺少依赖库、接口IID不匹配、Qt版本不一致、编译器版本不一致。

3.3 图像显示模块:缩放、拖拽与ROI交互

图像显示看起来简单,做好用却不容易。我要求显示模块支持鼠标滚轮缩放、按住拖拽平移、绘制ROI区域、显示十字光标和像素坐标。

缩放和平移的实现思路是:维护一个缩放因子scale和一个平移偏移offset,在paintEvent里根据这两个参数计算图像的绘制区域。鼠标滚轮事件里调整scale,注意要以鼠标位置为中心缩放,而不是以图像中心缩放,否则用户体验很差。

void ImageView::wheelEvent(QWheelEvent* event) { double delta = event->angleDelta().y() / 120.0; double factor = pow(1.1, delta); // 以鼠标位置为中心缩放 QPoint mousePos = event->pos(); QPointF imagePos = (mousePos - offset) / scale; scale *= factor; offset = mousePos - imagePos * scale; update(); }

ROI交互稍微复杂一些。我定义了一个ROI基类,支持矩形、圆形、多边形三种类型。鼠标按下时开始绘制,移动时更新ROI,释放时完成绘制。ROI的数据用Halcon的HObject表示,方便直接传给Halcon算子做区域处理。

这里有个性能优化的点:如果图像很大,每次paintEvent都重新缩放绘制会很卡。我的做法是先把QImage缩放到当前显示尺寸缓存起来,只有在scale变化时才重新生成缓存。这样拖动的时候只是移动缓存图像,帧率能稳定在60fps。

3.4 参数配置持久化:QSettings与JSON的取舍

每个插件都有自己的参数,比如相机曝光时间、增益、模板匹配的分数阈值等。这些参数需要保存下来,下次启动时自动加载。

我对比过QSettings和JSON两种方案。QSettings的好处是跨平台,Windows下写注册表,Linux下写配置文件,用起来简单。但QSettings有个问题:它存储的是键值对,嵌套结构支持不好,而且注册表在工业现场有时候会被安全软件拦截。

最终我选了JSON方案。每个插件的参数用QJsonObject表示,保存时序列化到插件目录下的config.json文件。加载时反序列化,如果文件不存在就用默认参数。这样每个插件的配置独立,拷贝插件目录就能带走所有配置,部署起来很方便。

bool PluginBase::saveConfig() { QJsonObject params = parameters(); QFile file(m_configPath); if (!file.open(QIODevice::WriteOnly)) { return false; } file.write(QJsonDocument(params).toJson()); return true; }

注意JSON的浮点数精度问题。QJsonValue存储double类型,但序列化后可能丢失精度。对于需要高精度的参数,比如标定参数,我建议用字符串存储,加载时再转回double。

4. 完整实操流程:从零搭起一个检测插件

4.1 环境准备与工程配置

先列一下我用的环境:Windows 10 64位、Qt 5.15.2 MSVC2019 64bit、Halcon 20.11 Progress、Visual Studio 2019。Halcon版本其实18.11以上都可以,接口基本兼容。

工程配置的关键点在于.pro文件。需要把Halcon的include路径和lib路径加进去:

HALCON_ROOT = C:/Program Files/MVTec/HALCON-20.11-Progress INCLUDEPATH += $$HALCON_ROOT/include INCLUDEPATH += $$HALCON_ROOT/include/halconcpp LIBS += -L$$HALCON_ROOT/lib/x64-win64 -lhalconcpp LIBS += -L$$HALCON_ROOT/bin/x64-win64 -lhalcon

注意Halcon的库分halcon和halconcpp两个,前者是C接口,后者是C++接口。我们用的是C++接口,所以两个都要链接。另外运行时需要把Halcon的bin目录加到PATH环境变量,或者把dll拷贝到exe同级目录。

4.2 创建一个模板匹配插件

假设我们要做一个模板匹配插件,功能是:加载模板图像,创建模板,然后在输入图像中查找匹配区域,输出匹配分数和位置。

第一步,创建插件类,继承QObject和IProcessor。在initialize里加载模板文件并创建模板句柄:

bool TemplateMatchPlugin::initialize() { if (m_templatePath.isEmpty()) { return false; } try { HalconCpp::ReadImage(&m_templateImage, m_templatePath.toStdString().c_str()); HalconCpp::CreateShapeModel(m_templateImage, "auto", m_angleStart, m_angleExtent, "auto", "auto", "use_polarity", "auto", "auto", &m_modelID); return true; } catch (HalconCpp::HException& e) { qWarning() << "模板创建失败:" << e.ErrorMessage().Text(); return false; } }

第二步,实现process函数,在输入图像中查找模板:

bool TemplateMatchPlugin::process() { if (m_inputImage.IsInitialized() == false) { return false; } try { HalconCpp::FindShapeModel(m_inputImage, m_modelID, m_angleStart, m_angleExtent, m_minScore, m_numMatches, m_maxOverlap, "least_squares", 0, 0.9, &m_row, &m_col, &m_angle, &m_score); return true; } catch (HalconCpp::HException& e) { qWarning() << "匹配失败:" << e.ErrorMessage().Text(); return false; } }

第三步,实现configWidget,提供界面让用户调整minScore、numMatches等参数。我用Qt Designer画了一个简单的界面,包含滑块和数值框,参数变化时实时更新到插件内部变量。

第四步,编译插件为动态库。在.pro文件里加上:

TEMPLATE = lib CONFIG += plugin DESTDIR = $$PWD/../bin/plugins

编译完成后,把生成的dll放到主程序的plugins目录下,启动主程序就能自动加载了。

4.3 插件串联与流程编排

单个插件跑通之后,下一步是把多个插件串起来形成完整流程。我在主界面里做了一个流程编排面板,用户可以从插件列表里拖拽插件到流程画布上,然后用连线表示数据流向。

流程的执行逻辑是:采集插件触发后,按照连线顺序依次调用下游插件的process函数。每个插件的输出作为下一个插件的输入。如果某个插件返回false,流程中断并记录日志。

这里有个设计决策:流程是线性执行还是并行执行?我选择了线性执行,因为工业视觉检测通常是串行流程,而且线性执行更容易调试和定位问题。如果确实需要并行,可以在插件内部自己开线程。

流程配置保存为JSON文件,包含插件列表、连接关系、每个插件的参数。加载流程时,先实例化所有插件,再建立连接,最后按顺序启动。

4.4 实测性能数据与优化记录

我用一套500万像素黑白相机做了实测。单帧图像采集耗时约15ms,模板匹配耗时约25ms,缺陷检测耗时约40ms,显示刷新耗时约5ms。整个流程跑下来单帧约85ms,换算成帧率约11.7fps。

这个性能对于大部分工业检测场景够用了,但如果要跑更高帧率,有几个优化方向。第一,把图像采集和处理放到不同线程,采集线程只管拿图,处理线程从队列里取图处理,这样采集和处理可以重叠。第二,模板匹配的搜索区域可以限制在ROI内,减少搜索范围。第三,如果多个插件处理同一张图,可以用Halcon的并行算子或者多线程分别处理不同区域。

我试过把采集和处理分线程,帧率提升到了18fps左右。再往上就要考虑用Halcon的GPU加速了,但那需要额外的授权。

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

5.1 插件加载失败排查表

现象可能原因排查方法
QPluginLoader::instance返回nullptrIID不匹配检查Q_DECLARE_INTERFACE和Q_PLUGIN_METADATA的IID是否一致
加载时报“无法找到入口点”编译器版本不一致确认主程序和插件用同一版本MSVC编译
加载时报“不是有效的Win32程序”位数不一致确认主程序和插件都是64位或都是32位
加载成功但initialize失败依赖库缺失用Dependency Walker检查插件dll的依赖
插件列表里看不到插件目录扫描路径错误检查QDir的路径和过滤规则

这个表是我在实际调试中总结出来的,基本上覆盖了90%的插件加载问题。特别提醒一点:Qt的调试版和发布版插件不能混用,debug版主程序只能加载debug版插件,release版同理。这个坑很隐蔽,因为加载失败时错误信息不明显。

5.2 Halcon异常处理与资源释放

Halcon的C++接口用异常来报错,所有算子调用都要包在try-catch里。我见过很多新手代码不写try-catch,一旦Halcon报错程序直接崩溃,连错误信息都看不到。

try { HalconCpp::FindShapeModel(...); } catch (HalconCpp::HException& e) { QString errorMsg = QString::fromLocal8Bit(e.ErrorMessage().Text()); qCritical() << "Halcon错误:" << errorMsg; // 记录到日志文件 logError(errorMsg); }

资源释放同样重要。Halcon的HObject、HTuple这些对象在析构时会自动释放,但如果插件被卸载时还有Halcon对象没释放,可能会导致内存泄漏。我的做法是在插件的release函数里显式调用ClearObj清理所有Halcon对象。

还有一个容易忽略的点:Halcon的窗口句柄。如果插件里创建了Halcon窗口,卸载前一定要关闭,否则窗口会残留在屏幕上。

5.3 图像显示花屏与内存越界

图像显示花屏通常有两个原因。第一个是QImage的stride和Halcon图像的width不一致。Halcon图像每行是连续存储的,没有padding,所以QImage构造时bytesPerLine参数要传width,不能传width*4之类的值。第二个是图像格式不匹配,比如把三通道图像当成单通道显示,或者把浮点图像当成byte显示。

内存越界的问题更隐蔽。如果QImage引用了Halcon的内存但没有copy,Halcon对象释放后QImage还在绘制,就会访问已释放的内存。这种问题有时候不崩溃,只是显示乱码,很难定位。我的经验是:只要QImage的生命周期可能超过Halcon对象,就一定要copy。

5.4 多线程下的Halcon调用注意事项

Halcon的算子大部分是线程安全的,但有几个例外。比如同一个HObject对象不能在多个线程同时读写,同一个Halcon窗口不能在多个线程同时操作。我的做法是:每个线程用自己的HObject副本,窗口操作统一在主线程做。

另外,Halcon的异常在多线程下要特别注意。如果一个线程抛出的异常没有被捕获,整个进程都会终止。所以每个线程的入口函数都要包try-catch,把异常转换成错误码返回。

5.5 插件版本兼容性管理

随着项目迭代,插件接口可能会变化。如果新版本主程序加载了旧版本插件,可能会因为接口不匹配而崩溃。我的解决方案是在IPlugin接口里加一个apiVersion()函数,主程序加载插件时先检查版本号,不匹配就拒绝加载并提示用户更新插件。

版本号我用整数表示,比如1表示第一版接口,2表示第二版。每次接口有破坏性变更就递增版本号。这样主程序可以同时兼容多个版本的插件,只要在代码里做条件分支处理即可。

6. 框架扩展方向与个人实践体会

这套框架目前已经在我自己的几个项目里跑了一年多,整体稳定性不错。后续我打算从几个方向继续完善。一是增加流程编排的可视化程度,支持条件分支和循环,这样复杂流程也能用拖拽方式搭建。二是增加插件市场功能,把常用插件打包成安装包,一键安装。三是增加远程调试功能,方便现场部署时排查问题。

如果你也想搭一套类似的框架,我的建议是先从最小可用版本开始,不要一上来就追求大而全。先把插件加载和消息总线跑通,然后写一两个简单的插件验证流程,再逐步增加功能。框架的价值在于长期积累,用得越久,插件库越丰富,开发新项目的速度就越快。

另外提醒一点:Halcon的授权是绑定机器的,开发机和现场工控机都需要单独授权。如果现场机器没有Halcon授权,可以考虑把Halcon处理部分做成独立服务,通过TCP通信,这样授权只需要装在服务端。这个方案我试过,延迟增加约5ms,但部署灵活性大大提升。

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

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

立即咨询