简介:一款基于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_cast<uint8_t>(src[i] >> 4); } }这个转换放在采集线程还是显示线程,取决于数据量。500 万像素单帧约 10 MB,一次遍历是几十毫秒量级,放在采集回调里可以避免再拷贝一份源数据。转换完成后要尽快把FrameData.buffer释放或还回 SDK,否则回调积压会造成延迟直线升高。
2.4 各厂商 SDK 配合时的版本一致性
| 厂商 SDK | 初始化/采集方式 | 常见问题 |
|---|---|---|
| Basler Pylon | CInstantCamera + RegisterImageEventHandler | 版本和相机固件不匹配时设备枚举不到 |
| 海康 MVS | MV_CC_RegisterImageCallBackEx | SDK 版本与运行库必须严格对应,否则加载失败 |
| 大恒图像 | 类似 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.h,UI 层完全不感知当前是 Basler 还是海康。CMake 里用option(WITH_BASLER ...)控制适配器是否参与编译,避免没装某个 SDK 时整个工程挂掉。下面是一个可用的入口 CMakeLists:
cmake_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_BASLER=1) 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。如果依赖 DLL,CMake 不会自动拷贝运行时,所以我会在 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 Release,exe 会出现在build/bin,DLL 的拷贝可以写在install()规则中,避免“Debug 路径下有 dll,Release 没有”的问题。热词里常有人搜“cmake输出路径去掉debug”,就是希望在 IDE 生成时不自动追加配置子目录。Visual Studio 生成器还会在bin/后追加$(Configuration),彻底要去掉需要额外配置,但我不推荐,因为多配置生成器不同配置的 DLL 混在一起反而更难排查。
3.3 用预编译头解决 SDK 头文件重的问题
显微镜显示软件的 UI 通常不大,但相机 SDK 的头文件非常重,每次都全量解析很慢。CMake 3.16 开始可以用target_precompile_headers:
target_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_64 |
| CMAKE_BUILD_TYPE | 单配置生成器的构建类型 | Release / Debug |
| CMAKE_RUNTIME_OUTPUT_DIRECTORY | exe 和 DLL 输出目录 | ${CMAKE_BINARY_DIR}/bin |
| CMAKE_CXX_STANDARD | 语言标准 | 17 或 20 |
| CMAKE_AUTOMOC | 自动处理 Q_OBJECT 元数据 | ON |
实际用 CMake 做这类项目时,我见过最多的坑是CMAKE_PREFIX_PATH写错层级。Qt 6 的 bin 不在D:/Qt下,而是D:/Qt/6.5.0/msvc2019_64,CMake 需要的是“包含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 保存最新 QImage,paintEvent 里缩放绘制:
// 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 | 是否需要转换 |
|---|---|---|---|
| Mono8 | 8 | Format_Grayscale8 | 否,直接包装 |
| Mono12 | 12/16 | 无直接对应 | 右移4位转 Grayscale8 |
| BayerRG8 | 8 | 无直接对应 | demosaic 成 RGB888 |
| BGR8 | 24 | Format_RGB888(注意通道序) | 需要 BGR 翻转 |
| YUV422 | 16 | 无直接对应 | 转 RGB888 |
BGR8 转Format_RGB888特别容易看花眼。工业相机常输出 BGR 顺序,Qt 的Format_RGB888严格按 R-G-B 排列,直接用会得到红蓝互换的画面。可以用QImage::rgbSwapped()纠色,但要注意它要求输入格式必须是Format_RGB32或Format_RGB888等,某些旧的索引格式不支持。
4.4 帧率与内存之间的取舍
实时显示帧率通常设定在 30 fps,显微镜下外部光源不闪烁,其实 15~25 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 角标上,比一遍遍看日志直观得多。
本文还有配套的精品资源,点击获取