ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Qt6+CMake构建工业相机图像显示软件:从SDK适配到实时渲染

Qt6+CMake构建工业相机图像显示软件:从SDK适配到实时渲染 简介一款基于C与Qt 6框架、采用CMake构建的显微镜图像显示软件完整源码包主要面向需要集成各类工业相机的软件开发者与科研人员用于解决显微成像过程中不同品牌、分辨率、接口相机的图像采集与实时显示问题。压缩包共313个文件约6.58MB其中既有87个.h头文件和85个.cpp源文件也包括Qt界面文件(.ui)、资源文件(.qrc)、图片素材(png/jpg)以及编译配置(.cmake/.pri)还附带HIK MVS和Machine Vision Camera两份SDK开发手册(chm)方便查阅相机二次开发细节。目前已有67人学习下载。整个项目模块划分清晰覆盖相机参数设置对话框、图像处理线程、测量数据集管理和工具栏等完整功能可直接在Qt 6环境中编译运行。从相机枚举、参数设置到图像回调与界面刷新完整演示了工业相机应用的核心流程也展示了如何利用CMake实现跨平台构建。通过研读这份源码读者不仅能掌握QWidget界面布局、信号槽通信、多线程图像处理等常见技术还能了解工业相机SDK接入与工程搭建思路是学习Qt进阶与实践工业视觉应用的实用参考资料。1. 显微镜图像显示软件为什么把宝押在 Qt 6 CMake 上显微镜图像显示软件表面看是“取流 显示”真做起来却被工业相机的 SDK 支配。实验室里可能同时有 Basler、海康威视、大恒等品牌厂商自带 Demo 只能单机用插件体系也不统一。用 C Qt 6 搭界面、CMake 管构建是目前最稳的路线Qt 负责跨平台显示和控件CMake 负责把各家 SDK 的差异隔离在编译期。这个标题里的“简单”指的是业务逻辑简单不是工程结构简单。如果你正要写这样一个软件核心工作只有三件抽象相机接口、搭好 CMake 工程、处理显示线程。适合刚接触工业视觉、需要快速做内部工具的 C 工程师。2. 工业相机抽象层让 Basler、海康的 SDK 共用一个采集接口2.1 先定义回调接口而不是先选 SDK我一般会先写一个 C 纯虚类把采集动作抽成 open / start / stop。为什么因为工业相机 SDK 的初始化方式和回调线程模型差异很大。Basler Pylon 用 CInstantCamera 加上事件处理器海康 MVS 用 MV_CC_RegisterImageCallBackEx 注册回调回调里拿到的帧头结构也不一样。如果显示层直接跟某一个 SDK 耦合换相机就要改 UI 代码。定义接口如下// camera_interface.h #pragma once #include cstddef #include cstdint #include string struct FrameData { const uint8_t* buffer nullptr; size_t size 0; int width 0; int height 0; int stride 0; int pixelFormat 0; // 自定义像素格式枚举值 }; class FrameObserver { public: virtual void onFrame(const FrameData frame) 0; }; class CameraInterface { public: virtual ~CameraInterface() default; // config 可以是 JSON 字符串、IP 或设备序列号 virtual bool open(const std::string config) 0; virtual bool start() 0; virtual bool stop() 0; virtual bool close() 0; virtual std::string modelName() const 0; virtual void setObserver(FrameObserver* observer) 0; };这段代码要盯住两个地方。FrameData 刻意不用厂商自己的类型pixelFormat 用自定义枚举上层不需要 include 任何 SDK 头文件。stride 表示每行字节数用来处理某些相机的行对齐不是 width * bytesPerPixel 的情况。FrameObserver::onFrame 执行在采集线程里所以只能做拷贝和发信号不能做耗时转换。2.2 用 Basler Pylon 实现一个最小适配器有了接口再实现 Basler 适配器就清晰了。下面是最小版本省略了错误处理和参数调优// basler_camera.cpp #include basler_camera.h #include pylon/PylonIncludes.h class BaslerCamera : public CameraInterface { public: bool open(const std::string) override { m_device.Attach(Pylon::CTlFactory::GetInstance().CreateFirstDevice()); m_device.Open(); return true; } void setObserver(FrameObserver* observer) override { m_observer observer; } bool start() override { m_device.RegisterImageEventHandler(m_imageHandler, Pylon::RegistrationMode_ReplaceAll, Pylon::Cleanup_None); m_device.StartGrabbing(); return true; } void stop() override { m_device.StopGrabbing(); m_device.UnregisterImageEventHandler(m_imageHandler); } private: Pylon::CInstantCamera m_device; BaslerImageHandler* m_imageHandler nullptr; FrameObserver* m_observer nullptr; };这里BaslerImageHandler是继承Pylon::CImageEventHandler的类在OnImageGrabbed里把CGrabResultPtr的数据转成FrameData再交给m_observer。注意RegisterImageEventHandler的第三个参数Cleanup_None表示事件对象由 handler 自己管理传错会出现回调拿不到完整图像。各个 SDK 的“注册回调”和“开始采集”顺序不同Basler 可以先注册后StartGrabbing海康 MVS 则要先打开设备、注册回调、再启动流。实现适配器时一定要把这三步拆开。2.3 像素格式不归一化后面每次都要踩坑工业相机输出最常见的像素格式是 Mono8、Mono12、BayerRG8、BGR8。显微镜常用黑白相机所以 Mono8 是首选彩色 CMOS 往往输出 Bayer 格式显示前必须先插值。我一般在采集回调里就统一成 Mono8 或 RGB888因为 QImage 直接支持这两种不需要显示像素时再查一个格式表。比如 Mono12 虽然只用到高 12 位但底层用 16 位容器承载简单做法是右移 4 位转成 8 位灰阶// mono12_to_mono8.cpp void convertMono12ToMono8(const uint16_t* src, uint8_t* dst, size_t pixels) { for (size_t i 0; i pixels; i) { dst[i] static_castuint8_t(src[i] 4); } }这个转换放在采集线程还是显示线程取决于数据量。500 万像素单帧约 10 MB一次遍历是几十毫秒量级放在采集回调里可以避免再拷贝一份源数据。转换完成后要尽快把FrameData.buffer释放或还回 SDK否则回调积压会造成延迟直线升高。2.4 各厂商 SDK 配合时的版本一致性厂商 SDK初始化/采集方式常见问题Basler PylonCInstantCamera RegisterImageEventHandler版本和相机固件不匹配时设备枚举不到海康 MVSMV_CC_RegisterImageCallBackExSDK 版本与运行库必须严格对应否则加载失败大恒图像类似 MVS 的 C 回调解耦接口32/64 位混用容易崩溃“海康威视工业相机和视觉软件的版本号要对应吗”这个问题每次都会被问答案是要而且必须严格对应。MVS SDK 里的MvCameraControl.dll、驱动组件和上层接口是一套整体单独替换某个文件会报“找不到指定模块”。CMake 里引用 SDK 时最好把 SDK 的 bin 目录里的运行时 DLL 一起拷贝到输出目录而不是让用户去 SDK 目录里手动翻。3. CMake 工程搭建把 Qt 6 和相机 SDK 装进同一套构建3.1 先列目录再写 CMakeLists一个能长期维护的工程结构应该把界面、采集、第三方库分开。我常用的结构是microscope_viewer/ ├── CMakeLists.txt ├── cmake/ │ ├── FindBaslerPylon.cmake │ └── FindMVS.cmake ├── src/ │ ├── core/ │ │ ├── camera_interface.h │ │ └── camera_factory.cpp │ ├── adapters/ │ │ ├── basler_camera.cpp │ │ └── mvs_camera.cpp │ └── ui/ │ ├── main_window.cpp │ └── display_widget.cpp └── tools/ └── virtual_camera.cpp这个结构让每个 SDK 适配器只依赖 core 里的camera_interface.hUI 层完全不感知当前是 Basler 还是海康。CMake 里用option(WITH_BASLER ...)控制适配器是否参与编译避免没装某个 SDK 时整个工程挂掉。下面是一个可用的入口 CMakeListscmake_minimum_required(VERSION 3.21) project(microscope_viewer LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) find_package(Qt6 REQUIRED COMPONENTS Widgets Gui) set(CMAKE_AUTOMOC ON) set(CMAKE_AUTORCC ON) add_executable(microscope_viewer src/main.cpp src/ui/main_window.cpp src/ui/display_widget.cpp src/core/camera_interface.h src/core/camera_factory.cpp src/adapters/basler_camera.cpp ) target_include_directories(microscope_viewer PRIVATE src) target_link_libraries(microscope_viewer PRIVATE Qt6::Widgets Qt6::Gui ) if(WITH_BASLER) include(cmake/FindBaslerPylon.cmake) target_link_libraries(microscope_viewer PRIVATE Pylon::Pylon) target_compile_definitions(microscope_viewer PRIVATE HAVE_BASLER1) endif()这里find_package(Qt6 REQUIRED COMPONENTS Widgets Gui)会优先读取CMAKE_PREFIX_PATH指向的 Qt 安装目录。很多新手在 Qt 6 上栽跟头就是因为 Qt5 和 Qt6 的包名都支持 Widgets但Qt6::Widgets与Qt5::Widgets不能混用。AUTOMOC必须打开因为 UI 里用了 Q_OBJECT 宏MOC 负责生成元数据。3.2 用 Find 模块包装相机 SDK工业相机 SDK 很少直接提供 CMake config 文件所以需要自己写 find 模块。Basler Pylon 安装后库文件和头文件路径相对固定可以这样写# cmake/FindBaslerPylon.cmake find_path(PYLON_INCLUDE_DIR PylonIncludes.h PATH_SUFFIXES include) find_library(PYLON_LIBRARY NAMES pylon PATH_SUFFIXES lib) include(FindPackageHandleStandardArgs) find_package_handle_standard_args(BaslerPylon DEFAULT_MSG PYLON_LIBRARY PYLON_INCLUDE_DIR) add_library(Pylon::Pylon UNKNOWN IMPORTED) set_target_properties(Pylon::Pylon PROPERTIES IMPORTED_LOCATION ${PYLON_LIBRARY} INTERFACE_INCLUDE_DIRECTORIES ${PYLON_INCLUDE_DIR})UNKNOWN IMPORTED表示只提供库文件路径不区分 static 和 shared。如果依赖 DLLCMake 不会自动拷贝运行时所以我会在 install 规则里把 SDK 的 bin 目录文件复制到可执行文件旁。构建产品都放在同一个 bin/ 下很方便set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin) set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib) set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib)之后执行cmake --build build --config Releaseexe 会出现在build/binDLL 的拷贝可以写在install()规则中避免“Debug 路径下有 dllRelease 没有”的问题。热词里常有人搜“cmake输出路径去掉debug”就是希望在 IDE 生成时不自动追加配置子目录。Visual Studio 生成器还会在bin/后追加$(Configuration)彻底要去掉需要额外配置但我不推荐因为多配置生成器不同配置的 DLL 混在一起反而更难排查。3.3 用预编译头解决 SDK 头文件重的问题显微镜显示软件的 UI 通常不大但相机 SDK 的头文件非常重每次都全量解析很慢。CMake 3.16 开始可以用target_precompile_headerstarget_precompile_headers(microscope_viewer PRIVATE src/core/camera_interface.h src/ui/main_window.h )这能明显缩短增量编译时间但要注意 PCH 里的头文件不应依赖宏开关。比如camera_interface.h里如果有条件编译放进 PCH 后可能带来“宏定义不一致”的奇怪错误。所以我把最稳定的 Qt 头放进 PCH相机 SDK 头文件留在各自适配器里。3.4 关键变量表与一个提示下表列出了 CMake 配置里最常动的几个变量和它们的用途变量作用常用值示例CMAKE_PREFIX_PATH指定 Qt/第三方库根目录D:/Qt/6.5.0/msvc2019_64CMAKE_BUILD_TYPE单配置生成器的构建类型Release / DebugCMAKE_RUNTIME_OUTPUT_DIRECTORYexe 和 DLL 输出目录${CMAKE_BINARY_DIR}/binCMAKE_CXX_STANDARD语言标准17 或 20CMAKE_AUTOMOC自动处理 Q_OBJECT 元数据ON实际用 CMake 做这类项目时我见过最多的坑是CMAKE_PREFIX_PATH写错层级。Qt 6 的 bin 不在D:/Qt下而是D:/Qt/6.5.0/msvc2019_64CMake 需要的是“包含lib/cmake/的目录”。find_package找不到时先查这个变量是否指向正确位置。另外不要在一个工程里同时find_package(Qt5)和find_package(Qt6)两个版本的 moc 生成的代码不能混用。提示Visual Studio 生成器下CMAKE_BUILD_TYPE不生效要使用--config Release指定。4. Qt 图像显示管线从采集回调到 QWidget 不丢帧4.1 用 FrameBridge 把采集线程和 UI 线程连起来相机回调往往跑在采集线程里QWidget 只能在主线程绘制直接在线程里操作 QPixmap 轻则闪烁重则崩溃。标准做法是准备一个 QObject 桥接对象采集回调只负责深拷贝一张 QImage然后通过信号跨线程触发 UI 更新// frame_bridge.h #pragma once #include QObject #include QImage #include camera_interface.h class FrameBridge : public QObject { Q_OBJECT public: void onFrame(const FrameData frame) { QImage raw(frame.buffer, frame.width, frame.height, frame.stride, QImage::Format_Grayscale8); QImage safe raw.copy(); // 深拷贝避免 SDK 复用缓冲 emit frameReady(safe); } signals: void frameReady(const QImage image); };把FrameBridge::onFrame交给CameraInterface的 observer再把frameReady信号连到主窗口里的DisplayWidget槽上。信号槽连接默认为队列连接onFrame 从采集线程发出信号主线程槽函数会在下一个事件循环里取最新帧。关键在raw.copy()工业相机回调返回后 buffer 随时可能被驱动复用QImage 构造函数只是包装外部数据不深拷贝的话画面会出现撕裂和花屏。4.2 paintEvent 只绘制最新帧用双缓冲思想保证 UI 不被回调拖死。DisplayWidget 保存最新 QImagepaintEvent 里缩放绘制// display_widget.cpp void DisplayWidget::onFrameReady(const QImage image) { m_currentFrame image; update(); } void DisplayWidget::paintEvent(QPaintEvent* event) { if (m_currentFrame.isNull()) return; QPainter painter(this); QImage scaled m_currentFrame.scaled(size(), Qt::KeepAspectRatio, Qt::SmoothTransformation); painter.drawImage((width() - scaled.width()) / 2, (height() - scaled.height()) / 2, scaled); }这里m_currentFrame image;只需要共享引用因为跨线程队列投递 QImage 时 Qt 已经生成了安全的副本。如果相机帧率高于 UI 刷新率update()会合并多次重绘paintEvent始终拿最新的一帧旧帧被丢弃这就是常见的“只显示最新帧”策略。这个策略对显微镜软件很合适本来就要看当前视野不需要排队播放历史帧。4.3 像素格式和 QImage Format 对照表下面这张表是适配器里最常用的映射决定了Format_*参数怎么填相机输出格式位深QImage Format是否需要转换Mono88Format_Grayscale8否直接包装Mono1212/16无直接对应右移4位转 Grayscale8BayerRG88无直接对应demosaic 成 RGB888BGR824Format_RGB888注意通道序需要 BGR 翻转YUV42216无直接对应转 RGB888BGR8 转Format_RGB888特别容易看花眼。工业相机常输出 BGR 顺序Qt 的Format_RGB888严格按 R-G-B 排列直接用会得到红蓝互换的画面。可以用QImage::rgbSwapped()纠色但要注意它要求输入格式必须是Format_RGB32或Format_RGB888等某些旧的索引格式不支持。4.4 帧率与内存之间的取舍实时显示帧率通常设定在 30 fps显微镜下外部光源不闪烁其实 1525 fps 的刷新率完全够。如果采集线程以满帧率回调UI 不一定来得及重绘多余的信号会积压在事件队列里。所以我在 FrameBridge 里同一时间只保留最新帧并对 onFrame 做节流比如用一个原子变量判断是否已有帧在队列里。要严格测量帧率可以在回调里计数每秒在主线程显示一次不要直接在paintEvent里计数因为update()会合并调用得不到真实采集帧率。这个显示管线的总原则是显示层做的处理越少帧率越稳。凡是能离线做的事比如自动白平衡、3A、降噪都不要放到实时路径上。显微镜软件需要保留原始图像的细节所以在采集线程只做必要的 Mono8 转 8 位变换剩下的缩放交给paintEvent的 QPainter。5. 用虚拟相机验证工业相机链路连接、线程和显示是否都对了在没有实体相机时虚拟相机是最快的调试手段。实现一个继承CameraInterface的 VirtualCamera在generateFrame里画一张分辨率测试卡生成FrameData后交给 observer。这样整条显示链路可以和真实相机走完全相同的代码路径SDK 没装好也能先开发 UI。// virtual_camera.cpp #include QPainter class VirtualCamera : public CameraInterface { public: void setObserver(FrameObserver* observer) override { m_observer observer; } void generateFrame() { const int w 640, h 480; QImage img(w, h, QImage::Format_Grayscale8); img.fill(128); QPainter p(img); p.setPen(QPen(Qt::black, 2)); for (int r 30; r 300; r 20) { p.drawEllipse(QPoint(w/2, h/2), r, r); } p.end(); FrameData fd; fd.buffer img.constBits(); fd.width w; fd.height h; fd.stride img.bytesPerLine(); fd.size img.sizeInBytes(); m_observer-onFrame(fd); } private: FrameObserver* m_observer nullptr; };注意 QImage 的生命周期只在generateFrame调用内onFrame 是同步回调所以fd.buffer在这个调用里仍然有效。如果 FrameBridge 在 onFrame 里做了深拷贝那么局部 img 销毁也不影响显示。这个细节恰好可以用来检验你的显示管线是否正确如果虚拟相机看到花屏或崩溃往往是深拷贝缺失和实体相机无关。接入真实相机后我习惯在界面上留一个帧率计数标签同时在相机适配器里记录最近 100 帧的平均耗时。虚拟相机生成帧几乎零开销帧率能轻松到 500 fps 以上说明显示链路没有瓶颈真实相机只有 20 fps往往卡在像素格式转换或 SDK 的回调缓冲不足。此时优先检查三点有没有开硬件触发像素格式是不是 Mono8回调里是不是悄悄做了大矩阵拷贝。把帧率计数放在 UI 角标上比一遍遍看日志直观得多。本文还有配套的精品资源点击获取
返回列表