ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

工业相机C++ Demo:枚举、取流与像素转换实战

工业相机C++ Demo:枚举、取流与像素转换实战

简介:面向机器视觉工程师与C++开发者的海康工业相机SDK示例工程,基于Qt构建图形界面,用于演示相机枚举、连接控制、参数配置、图像采集与实时显示等核心流程,适合需要快速理解并接入海康相机API的初中级开发者参考。压缩包共239个文件,约11.02MB,以C/C++头文件、静态库与动态库、少量cpp源码、Visual Studio工程文件及Qt的ui、pro、qrc资源为主,另含编译产物与可执行程序,便于直接对照工程结构梳理调用关系。已有1300人学习下载,说明其在工业视觉入门与项目验证场景中具有一定参考价值。示例覆盖触发模式、分辨率、帧率、曝光时间等参数设置,也包含图像缓存管理、多相机设备发现和接口调用错误捕获反馈;读者可据此搭建可运行的采集界面,并在此基础上扩展图像处理、特征检测或自定义交互控件,减少从零摸索SDK的时间。

1. 工业相机 C++ Demo 的价值:一台相机从通电到出图的完整流程

某产线检测工位上,厂商自带的客户端能流畅预览,自己写的采集程序一接上就花屏、丢帧,甚至第二次启动直接报设备被占用。这种时候最需要的不是翻几百页手册,而是一份能编译、能跑通、每一步都看得见的 C++ Demo 源代码。这份工业相机 SDK 的 Demo 工程把「枚举设备—打开句柄—写参数—开流—取帧—转像素格式—落盘」整条链路拆开摆在你面前,包含单帧与连续采集、软硬触发、异常回调这几条最常见的分支。它适合正在写机器视觉上位机、检测设备软件或产线集成插件的 C++ 开发者,也适合刚接手某个老采集模块、需要快速搞清楚 SDK 调用顺序的人。下面按工程骨架、设备连接、取流链路、踩坑排查、二次改造的顺序往下拆。

2. 工程骨架:Demo 目录分层与编译链接关系

拿到一份 SDK 压缩包,先别急着点开 main.cpp 看逻辑。我一般先扫一遍目录,搞清楚哪部分是厂商给的、哪部分是要自己改的,这一步定下来,后面换相机型号、换厂商的时候能省很多重写工作。这份 Demo 的分层做得比较规矩,头文件、导入库、业务胶水层、第三方依赖是分开的,先把这层关系理顺,编译期那一堆链接错误基本就少一半。

2.1 目录分层:include、lib、src 各自负责什么

工程展开大概是这样一棵树,我按中性名字重新标了一遍,你手上的头文件名可能叫别的,按语义对应就行:

IndustrialCameraDemo/ ├── include/ # SDK 对外头文件,只声明不实现 │ ├── CameraCtrl.h # 句柄创建、打开、取流、参数读写 │ ├── CameraDefine.h # 结构体、像素格式枚举、错误码 │ └── CameraStatus.h # 状态与异常类型 ├── lib/ │ ├── win64/ # x64 导入库 .lib + 运行时 .dll │ └── win32/ # 32 位,一般不用,但别删 ├── src/ │ ├── main.cpp # 入口,只做流程编排 │ ├── device/DeviceEnumerator.cpp # 枚举与设备信息 │ ├── grab/Grabber.cpp # 开流、回调、帧队列 │ ├── convert/PixelConvert.cpp # 像素格式转换 │ └── util/Logger.cpp # 日志,出问题第一现场 ├── thirdparty/opencv/ # 只用来转换和显示,不是必需 ├── config/camera.ini # 曝光、增益、触发方式 └── CMakeLists.txt

关键点在 src 这一层:它是一层胶水,业务代码通过 Grabber 这个类拿帧,不直接引用 SDK 的类型。这么做的好处很直接——后面要接第二家厂商的相机,只要再实现一个同样接口的 Grabber,上层检测逻辑一行不改。很多 Demo 翻车就翻在这,main.cpp 里到处是 SDK 的结构体和返回码判断,等到要加第二台相机时全得推倒。头文件里那些 CAM_ 开头的类型只出现在 src/device 和 src/grab 两个目录里,其他地方一律用自定义的 Frame 结构体,这条纪律我建议一开始就立住。

提示:include 目录只读。调试期间为了打印日志去改厂商头文件,下次升级 SDK 时会被覆盖,改动必须落在 src 里。

2.2 编译链路:运行时库和 dll 拷贝是两个高频问题

我习惯用 CMake 管工程,比手搓 vcxproj 好维护,也方便后面加单元测试。下面这段是能直接用的骨架:

cmake_minimum_required(VERSION 3.16) project(IndustrialCameraDemo CXX) set(CMAKE_CXX_STANDARD 14) set(CMAKE_CXX_STANDARD_REQUIRED ON) # 运行时库必须和 SDK 的 .lib 保持一致(通常是 /MD), # 混用 /MT 会在链接期报 LNK2038 runtime mismatch set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreadedDLL") set(SDK_ROOT "${CMAKE_SOURCE_DIR}") add_executable(camera_demo src/main.cpp src/device/DeviceEnumerator.cpp src/grab/Grabber.cpp src/convert/PixelConvert.cpp src/util/Logger.cpp ) target_include_directories(camera_demo PRIVATE ${SDK_ROOT}/include ${SDK_ROOT}/thirdparty/opencv/include ) target_link_directories(camera_demo PRIVATE ${SDK_ROOT}/lib/win64 ${SDK_ROOT}/thirdparty/opencv/lib ) target_link_libraries(camera_demo PRIVATE CameraCtrl # 相机 SDK 导入库 opencv_world4100 # 只做转换和 imshow,world 库省事 ) # 构建后把 dll 拷到 exe 同目录,否则运行时报"找不到 xxx.dll" add_custom_command(TARGET camera_demo POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_directory ${SDK_ROOT}/lib/win64 $<TARGET_FILE_DIR:camera_demo> )

几个参数值得单独说一下。CMAKE_MSVC_RUNTIME_LIBRARY需要 CMake 3.15 以上,并且要保证 CMP0091 策略是 NEW,否则这行不生效,还是按老的 /MT 编译。target_link_directories里的路径区分 win64 和 win32,Debug 和 Release 用的 .lib 有时也不是同一个,混着链接会出现「函数能进去但一调就崩」的玄学现象,血泪经验是工程里只保留一个平台一个配置。最后那个 POST_BUILD 拷贝很实用,SDK 的 dll 往往不止一个,靠手工复制迟早漏。

2.3 入口与线程模型:主线程只做编排

main.cpp 我建议写得很薄,所有 SDK 调用都收进 Grabber 里。下面这个骨架就是标准的三段式:

int main(int argc, char** argv) { Logger::init("camera_demo.log"); // 先起日志,SDK 内部报错也能落盘 DeviceList devs; if (!DeviceEnumerator::list(devs, CAM_IF_GIGE | CAM_IF_USB) || devs.empty()) { LOG_ERROR("no device found"); return -1; } for (const auto& d : devs) { LOG_INFO("found model=%s sn=%s ip=%s", d.model.c_str(), d.serial.c_str(), d.ip.c_str()); } CameraConfig cfg = CameraConfig::load("config/camera.ini"); // 曝光、增益从配置读 Grabber grabber; if (!grabber.open(devs.front(), cfg)) { // 打开 + 写参数 LOG_ERROR("open failed"); return -2; } if (!grabber.start()) { // 内部起取流线程 return -3; } cv::namedWindow("preview"); Frame f; while (running) { // 业务线程消费帧 if (!grabber.pop(f, 500)) continue; // 500ms 超时,没帧就继续等 cv::Mat bgr = PixelConvert::toBgr(f); if (!bgr.empty()) { cv::imshow("preview", bgr); cv::waitKey(1); } } grabber.stop(); grabber.close(); return 0; }

这里定死了一个线程模型:主线程负责枚举、打开、显示和业务判断,取流由 Grabber 内部的回调线程或工作线程完成,两边只通过一个有界队列交换 Frame。为什么强调有界,因为检测算法一旦卡住(比如模型推理耗时突增),无界队列会一路吃内存直到进程被杀,有界队列丢几帧反而更安全。pop的超时参数建议给 200 到 500 毫秒,太短会空转烧 CPU,太长会导致退出时卡住不响应。

注意:SDK 的回调线程不是你能控制的线程,在那里面做 imwrite、跑推理、等锁,帧率掉一半是轻的,严重时 SDK 内部缓冲耗尽直接断流。

3. 设备枚举与连接:句柄的创建、打开与销毁顺序

这一章是整个 Demo 里最容易被跳过、又最容易出问题的地方。相机连不上,八成不是网络或驱动的事,而是句柄、访问模式、关闭顺序这三样里有一个没按规矩来。下面按枚举、打开配参、关闭回收三段拆开看,每段都给出可以抄的写法和参数含义。

3.1 枚举:按接口类型过滤,用序列号做唯一键

枚举本身不复杂,但有两个细节决定后面顺不顺。一是初始化函数在一个进程里只该调一次,二是返回值要区分「SDK 没起来」和「真没找到相机」,否则排查时方向会完全跑偏。

// 返回 false 表示 SDK 初始化或枚举失败,不是"没找到相机" bool DeviceEnumerator::list(DeviceList& out, uint32_t interfaceMask) { out.clear(); if (!CAM_Initialize()) { // 全局初始化,进程内调一次 LOG_ERROR("sdk init failed"); return false; } CAM_DeviceInfoList_t list{}; // interfaceMask 按位或:GigE | USB | CameraLink // 只查一种能少等几百毫秒,产线上我一般只查实际用的那种 if (CAM_EnumDevices(&list, interfaceMask) != CAM_OK) { LOG_ERROR("enum failed"); CAM_Finalize(); return false; } for (uint32_t i = 0; i < list.nDeviceNum; ++i) { CAM_DeviceInfo_t info{}; if (CAM_GetDeviceInfo(list.pDeviceInfo[i], &info) != CAM_OK) continue; Device d; d.index = i; d.model = info.modelName; d.serial = info.serialNumber; // 唯一键,别用 index d.ip = info.gigEInfo.currentIp; // USB 设备这里是空串 out.push_back(d); } LOG_INFO("enum done, %zu device(s)", out.size()); return true; }

参数上要留意interfaceMask的取值,网口相机和 USB 相机走的是不同枚举通道,混在一起查没问题但会慢;nDeviceNum是本次枚举到的数量,超过 8 台的产线建议先把日志级别调高,确认每台都被列出来。真正要记住的是唯一键的选择:index只是本次枚举的序号,相机断电重启、插拔网线后顺序会变,用它做配置文件索引,第二天开机就是对不上。序列号才是稳定的,配置文件里存序列号,启动时按序列号匹配。

3.2 打开与参数配置:先把范围读出来再写

打开设备之后紧接着就是写参数。曝光、增益、触发模式、像素格式这四项基本覆盖了 90% 的场景,但直接写死值很容易被 SDK 拒绝,因为每个型号的允许范围不一样。

bool Grabber::open(const Device& d, const CameraConfig& cfg) { CAM_DeviceInfo_t info{}; if (!DeviceEnumerator::resolve(d, info)) return false; if (CAM_CreateHandle(&handle_, &info) != CAM_OK) return false; // 句柄,非裸指针 // 独占模式:本进程独占设备,别的程序打不开,产线上更安全 if (CAM_OpenDevice(handle_, CAM_ACCESS_EXCLUSIVE) != CAM_OK) { CAM_DestroyHandle(handle_); handle_ = nullptr; return false; } setEnum ("TriggerMode", "Off"); // 连续采集,先关触发 setFloat("ExposureTime", cfg.exposureUs, true); // true = 写前先取 min/max 做钳位 setFloat("Gain", cfg.gainDb, true); // 单位 dB,不是倍数 setEnum ("PixelFormat", cfg.pixelFormat); // Mono8 或 BayerRG8 setInt ("AcquisitionFrameRate", cfg.fps); setEnum ("ExposureAuto", "Off"); // 自动曝光会让帧亮度跳,检测场景别开 return true; } // 写浮点参数的通用封装:越界是高频错误,提前钳一下 bool Grabber::setFloat(const char* node, double v, bool clamp) { if (clamp) { double lo = 0, hi = 0; if (CAM_GetFloatRange(handle_, node, &lo, &hi) == CAM_OK) { if (v < lo) v = lo; if (v > hi) v = hi; } } return CAM_SetFloatValue(handle_, node, v) == CAM_OK; }

CAM_ACCESS_EXCLUSIVE这个访问模式值得展开说。它有三种常见取值:独占、控制(可读可写但不独占)、监控(只读)。产线检测程序用独占,避免和调试客户端抢;如果只是想读状态做监控面板,用监控模式,别把相机锁死。曝光时间的单位是微秒,写 10000 是 10 毫秒,不是 10 秒,这个单位搞错会导致画面全黑,且报错信息往往只有一句「参数无效」。增益在多数工业相机上是 dB 值,写 10 表示放大约 3.16 倍,不是 10 倍,按倍数思路配出来全是噪点。

提示:自动曝光、自动增益在检测场景里默认关掉。它们会让同一批工件的图像亮度不一致,阈值分割的参数就没法固定。

3.3 关闭顺序:四步不能颠倒

关闭这一段代码只有几行,但顺序错了现象很怪:程序正常退出,下次启动报「设备被占用」,任务管理器里还找不到残留进程。

void Grabber::close() { if (!handle_) return; if (grabbing_) stop(); // 1. 停流,等回调线程退出 CAM_CloseDevice(handle_); // 2. 关设备,释放网络/USB 连接 CAM_DestroyHandle(handle_); // 3. 销毁句柄 handle_ = nullptr; } // 进程退出前再调一次全局清理 void Grabber::finalize() { CAM_Finalize(); }

顺序为什么不能反:stop要先停掉取流并等待回调线程真正退出,如果回调还在跑就CloseDevice,轻则崩溃;CloseDevice之后再DestroyHandle是规定动作,句柄没销毁就退出进程,相机的网络连接会挂在半开状态,下次连不上就是这个原因。另外析构函数里必须调close,用 RAII 包一层,否则某个 return 分支漏掉,问题就复现了。我一般再补一个信号处理,收到退出信号时先把running置 false,主循环退出后再走正常关闭流程。

4. 取流链路:回调抓帧、主动取帧与像素格式转换

取流这部分决定程序能跑多稳。同一份 Demo 里通常会给你两条路:回调方式和主动取帧方式。它们不是谁替代谁的关系,选错了不是性能差一点,而是丢帧、花屏、内存涨这些症状一起上来。再加上取到帧之后的像素格式转换,这一段是整个工程里代码密度最高的地方。

4.1 回调取流:回调里只做拷贝,别的一概不干

回调方式的吞吐最高,适合高帧率、多相机的场景。原则只有一条:回调函数要尽可能快地返回。

// __stdcall 是 SDK 约定的调用方式,漏了会在运行期直接崩 static void __stdcall onFrame(CAM_Frame* frame, void* user) { auto* self = static_cast<Grabber*>(user); if (!frame || !frame->pBufAddr) return; // 关键:在这里只做一次内存拷贝,然后立刻返回 Frame f; f.w = frame->width; f.h = frame->height; f.format = frame->pixelFormat; f.seq = frame->frameNum; f.data.assign(static_cast<uint8_t*>(frame->pBufAddr), static_cast<uint8_t*>(frame->pBufAddr) + frame->size); // 有界队列,满了丢最旧的一帧,不阻塞回调线程 self->queue_.pushDropOldest(std::move(f)); } bool Grabber::start() { if (CAM_RegisterFrameCallBack(handle_, onFrame, this) != CAM_OK) return false; if (CAM_StartGrabbing(handle_) != CAM_OK) return false; grabbing_ = true; return true; }

frame->pBufAddr指向的是 SDK 内部缓冲,回调一返回这块内存就可能被下一帧覆盖。所以拷贝是必须的,assign那一步就是复制,不能只存指针。frameNum建议一路带到业务层,检测结果和帧号对不上时能立刻定位是丢帧还是逻辑问题。user参数是注册回调时传进去的 this 指针,这是 C 风格回调里传上下文的标准做法。

队列的丢帧策略要写清楚:pushDropOldest表示满了丢最旧,适合实时预览和在线检测,因为旧帧没有价值;如果做的是离线录包,就要用阻塞队列或写磁盘,不能丢。这两种需求在一份工程里最好用模板参数区分,别混着用。

4.2 主动取帧:简单直接,适合低频场景

主动方式是自己调函数去要帧,逻辑线性,调试友好,缺点是跑不高帧率。

bool Grabber::grabOne(Frame& out, int timeoutMs) { CAM_Frame frame{}; int ret = CAM_GetOneFrameTimeout(handle_, &frame, timeoutMs); if (ret != CAM_OK) { // 超时不算错误,触发模式下没来信号就是会超时,继续循环即可 return false; } out.w = frame.width; out.h = frame.height; out.format = frame.pixelFormat; out.data.assign(static_cast<uint8_t*>(frame.pBufAddr), static_cast<uint8_t*>(frame.pBufAddr) + frame.size); return true; }

timeoutMs给多少有讲究。软触发场景下,等触发信号的间隔可能几百毫秒,超时给 1000 比较稳;连续采集场景给 100 到 200 就够。返回超时的时候不要当致命错误处理,更不能 break 出循环,否则相机稍微卡一下就整个采集停了。我一般会统计连续超时次数,连续超 10 次才认为链路异常,触发重连。

两种方式的选择标准很实在:单相机、帧率 30 以下、逻辑简单,用主动取帧;多相机、高帧率、或者要做硬件触发同步,用回调。别为了「看起来专业」硬上回调,回调里的线程安全问题能把人折腾很久。

4.3 像素格式转换:Bayer 顺序搞错就是花屏

取到的原始帧格式取决于相机配置。Mono8 是灰度,BayerRG8 是彩色原始数据,RGB8 是打包好的彩色。转换代码不长,但每个分支都有坑。

cv::Mat PixelConvert::toBgr(const Frame& f) { switch (f.format) { case CAM_PIXEL_MONO8: // clone 一定要有,否则 Mat 还指着 f.data,f 析构后就是野指针 return cv::Mat(f.h, f.w, CV_8UC1, const_cast<uint8_t*>(f.data.data())).clone(); case CAM_PIXEL_BAYERRG8: // BayerRG / BayerBG / BayerGR / BayerGB 四种排列,选错就是红蓝互换 return cv::cvtColor(cv::Mat(f.h, f.w, CV_8UC1, const_cast<uint8_t*>(f.data.data())), cv::COLOR_BayerRG2BGR); case CAM_PIXEL_RGB8: return cv::cvtColor(cv::Mat(f.h, f.w, CV_8UC3, const_cast<uint8_t*>(f.data.data())), cv::COLOR_RGB2BGR); default: LOG_WARN("unsupported pixel format: %d", f.format); return {}; // 不认识的格式别硬转,先看日志 } }

clone这步是高频翻车点。直接构造的 Mat 是浅拷贝,指向f.data的内存,函数返回后f一析构,Mat 就成了野指针,现象是显示正常、过几秒花屏或者直接崩。Bayer 的四种排列对应 OpenCV 四个不同的常量,相机配置成 BayerRG8 而代码里写 BayerBG2BGR,出来的图红蓝互换,检测红色工件时全军覆没。还有一个隐蔽问题:有些相机支持行对齐,一行的实际字节数大于width * 通道数,这时候Mat(h, w, type, ptr)会错位,需要按 stride 逐行拷贝,判断方法是看图像是不是斜的。

5. 避坑与排查:连不上、花屏、内存涨的五条记录

前面把正常路径走完了,这一章全是异常路径。工业现场环境比办公室复杂得多,网线长度、电磁干扰、供电波动都会变成程序里的怪现象。下面五条是我在实际项目里反复遇到的,按「现象、原因、解决」写清楚,遇到同类问题时可以直接对照。

5.1 枚举不到相机,但 ping 得通

现象:命令行 ping 相机 IP 正常,Demo 打开后设备列表是空的,日志里没有任何错误。

原因:网口相机走的是厂商的发现协议,不是普通 IP 通信。常见触发点是网卡没开巨型帧、系统防火墙拦了组播包、主机有多张网卡而 SDK 只绑定了其中一张。另外相机和主机不在同一网段时,ping 也可能靠路由通而发现协议不通。

解决:把相机所在网卡的巨型帧(Jumbo Frame)开到 9KB,关闭该网卡的防火墙,多网卡机器在 SDK 里显式指定网卡或把无关网卡临时禁用。排查顺序就是:同网段、巨帧、防火墙、网卡绑定,四步走完 90% 能好。

5.2 第二次启动报设备被占用

现象:第一次运行正常,关掉程序再启动,OpenDevice返回失败,错误码指向访问冲突。

原因:上次退出时没走完整关闭流程。可能是调试时直接点了停止按钮,或者某个异常分支 return 掉了没执行 close。相机的连接处于半开状态,超时前不会释放。

解决:所有退出路径统一走stop → CloseDevice → DestroyHandle,用 RAII 保证析构必调。调试期间被杀进程后,等 30 秒到 1 分钟让相机侧超时,或者给相机断电重启。根治办法是加一个「进程启动时先尝试清理」的逻辑,或者干脆用独占模式让问题第一时间暴露而不是悄悄共享。

5.3 采几十帧后花屏、图像错位

现象:前几十帧完全正常,之后开始出现横向撕裂、颜色错乱,重启程序又好了。

原因:回调函数里做了耗时操作(写文件、跑算法、抢锁),SDK 的缓冲池被耗尽后开始复用正在使用的缓冲。另一种可能是队列无界,内存一路涨,最后分配失败。

解决:回调里只做一次assign拷贝,所有处理挪到业务线程;队列改有界,满了丢最旧帧并记一次计数,方便观察丢帧率。判断是不是这个原因,看日志里队列长度是不是持续增长,增长就是消费跟不上生产。

5.4 长时间运行内存持续上涨

现象:跑两小时内存从 200MB 涨到 2GB,帧率还正常。

原因:每帧new一块缓冲、或者cv::Mat没释放、或者日志里把每帧数据都打出来了。也有一种情况是帧队列的容量按「帧数」限制但没按「字节数」限制,分辨率一高就失控。

解决:预分配固定数量的缓冲做池子,循环复用;队列同时限制帧数和总字节数;日志只在异常时打帧内容摘要,正常帧只打帧号和耗时。用任务管理器看内存曲线是最快的判断手段,阶梯式上涨基本就是没释放。

5.5 拔网线后程序卡死不恢复

现象:现场网线被碰掉,程序界面定格,插回去也不恢复,只能重启。

原因:没有注册异常回调,取流接口在链路断开后一直返回超时或阻塞,主循环卡在里面出不来。

解决:注册断线回调,回调里只置一个状态位,业务线程检测到状态位后停止取流、销毁句柄,然后按固定间隔(比如 2 秒一次,最多 30 次)重新枚举并按序列号重连。重连成功后要重新下发一遍参数,因为设备重启后参数会回到默认值,这一点很容易漏。

注意:断线重连不要放在回调线程里做,回调线程被阻塞会导致 SDK 内部的异常处理队列堆满,后续状态都收不到。

6. 二次开发改造:把 Demo 变成可复用的采集服务

Demo 跑通只是起点,真正交付时它得能被人调用、能被配置、能同时管几台相机。我的做法是先抽一层接口,把 SDK 彻底关在里面,上层只认ICamera。这样后面接第二家厂商、加模拟相机做单元测试,都不用动检测逻辑。

// 上层只依赖这个接口,SDK 类型一律不出现 class ICamera { public: virtual ~ICamera() = default; virtual bool open(const CameraConfig& cfg) = 0; virtual bool start() = 0; virtual bool pop(Frame& out, int timeoutMs) = 0; // 阻塞取帧 virtual void stop() = 0; virtual void close() = 0; virtual CameraStats stats() const = 0; // 帧率、丢帧数、错误码 };

CameraStats这个返回值看着不起眼,实际很值钱。产线上出问题时,第一句话往往是「刚才那批图怎么不对」,有帧率曲线和丢帧计数,能立刻判断是相机侧的问题还是算法侧的问题;没有的话就只能靠猜。我习惯把 stats 每 5 秒打一条日志,出问题回溯特别快。

接口之外,改造集中在四个方向,按投入产出比排一下:

改造点做法收益
参数配置外置ini/json 按序列号分组存曝光、增益、触发方式换型不用重新编译
多相机管理一个 CameraManager 持有多个 ICamera,各自独立线程一台掉线不影响其他工位
帧总线采集线程只投递,算法线程按需订阅算法升级不动采集代码
状态上报stats 定时上报 + 断线重连状态机现场问题可回溯

多相机这块有个细节值得提前设计:几台相机做同步采集时,靠软件触发很难做到严格同步,帧号对不齐。如果检测需要多视角同时刻图像,得走硬件触发线,一台做主站发信号,其他做从站,代码里只要把触发源和触发模式配好,回调里按帧号做配对就行。这个改造在 Demo 阶段加进去成本很低,等上线后再加就得改结构。

还有一点是编译产物。产线机器往往不让装一堆运行库,工程属性里的运行库类型、字符集、目标平台都要和现场机器对齐,发布时把 dll 按目录打包,别指望现场手工拷。我现在的习惯是每次发版前在一台干净的机器上跑一遍完整流程:枚举、开流、采一千帧、拔网线、插回去、看能不能自动恢复,跑通了才发出去。这套动作看着笨,但省下来的现场排查时间远超这点付出。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进