一、工业相机程序不只是“采图加算法”
在很多工业视觉项目中,C++ 程序并不直接提供操作界面,而是以后台进程、Windows 服务或 Linux 守护进程的形式运行。
程序主要负责三件事:
- 对接工业相机,稳定获取图像。
- 调用视觉算法,生成检测结果。
- 通过 TCP、Modbus TCP、OPC UA 等常用协议,将结果发送给上位机或控制系统。
上位机负责参数配置、状态展示、报警显示、配方管理和生产数据记录,C++ 相机程序则更接近一个独立的视觉服务。
典型系统结构如下:
工业相机 ↓ C++ 图像采集程序 ↓ 图像预处理与视觉算法 ↓ 检测结果与运行状态 ↓ TCP / Modbus TCP / OPC UA / MQTT ↓ 上位机、PLC 或 MES
在项目初期,程序通常只需要完成以下流程:
打开相机 → 获取一张图像 → 执行算法 → 返回 OK 或 NG
但进入长期运行阶段后,经常会遇到下面这些问题:
- 相机断线后无法自动恢复。
- 相机 SDK 回调线程被算法阻塞,造成丢图。
- 图像指针离开回调函数后失效。
- 算法处理速度低于采集速度,内存不断增长。
- 硬件触发次数与收到的图像数量不一致。
- 多台相机同时运行时,图像和工件对应关系混乱。
- 检测结果已经生成,但上位机通信中断。
- 上位机重复发送检测命令,程序重复执行任务。
- 通信恢复后,不确定上一条结果是否已经被上位机接收。
- 相机重连后,曝光、增益和触发模式没有恢复。
- 上位机下发错误参数,导致相机进入异常状态。
- 相机异常、算法异常和通信异常混在一起,现场难以定位。
因此,工业相机程序不能只围绕 SDK 接口编写,还需要建立完整的采集、处理、通信和异常恢复机制。
一句话概括:程序不仅要“得到检测结果”,还要保证结果来自正确的图像,并且可靠地交付给正确的上位机任务。
二、程序的职责边界
无界面的工业视觉程序通常承担以下职责:
- 枚举和识别工业相机。
- 打开、关闭和配置相机。
- 管理连续采集、软触发和硬件触发。
- 接收和管理图像缓冲区。
- 将图像投递到算法处理线程。
- 生成检测结果。
- 保存必要的原始图和结果图。
- 管理相机断线和自动重连。
- 对外提供设备状态和统计信息。
- 接收上位机下发的检测命令和参数。
- 将检测结果发送给上位机。
- 记录运行日志、异常日志和通信日志。
上位机通常负责:
- 操作界面。
- 产品和配方选择。
- 参数编辑。
- 检测结果展示。
- 报警展示。
- 历史记录查询。
- 用户权限。
- 生产报表。
- 与整机其他模块进行协调。
需要明确的是,C++ 相机程序不是上位机页面的一个附属线程,而应作为相对独立的设备服务运行。
即使上位机关闭或重启,相机程序也应能够根据项目要求:
- 保持运行。
- 停止新任务但保持连接。
- 缓存未发送结果。
- 等待上位机重新连接。
- 对通信异常产生独立报警。
三、推荐的工程结构
以下以 C++17、CMake 和 OpenCV 为例,不包含 Qt 或其他界面框架。
src/ VisionService/ main.cpp Application.cpp ServiceHost.cpp SignalHandler.cpp Core/ Interfaces/ ICamera.h ICameraFactory.h IInspectionPipeline.h IResultPublisher.h ICommandServer.h Models/ CameraInfo.h CameraConfig.h ImageFrame.h InspectionRequest.h InspectionResult.h ServiceStatus.h Common/ ErrorCode.h Result.h BlockingQueue.h ThreadSafeValue.h CameraDrivers/ VendorA/ VendorACamera.cpp VendorACamera.h VendorAFactory.cpp VendorB/ VendorBCamera.cpp VendorBCamera.h VendorBFactory.cpp Mock/ MockCamera.cpp MockCamera.h Acquisition/ CameraManager.cpp AcquisitionService.cpp TriggerCoordinator.cpp FrameMatcher.cpp CameraHealthMonitor.cpp Vision/ ImageConverter.cpp ImagePreprocessor.cpp InspectionPipeline.cpp AlgorithmManager.cpp CalibrationService.cpp Communication/ ProtocolManager.cpp Tcp/ TcpCommandServer.cpp TcpResultPublisher.cpp TcpMessageCodec.cpp Modbus/ ModbusTcpServer.cpp ModbusRegisterMap.cpp OpcUa/ OpcUaServer.cpp Mqtt/ MqttResultPublisher.cpp Infrastructure/ Configuration/ Logging/ ImageStorage/ Database/ Metrics/ tests/ CoreTests/ AcquisitionTests/ VisionTests/ CommunicationTests/
程序内部推荐按照以下链路运行:
上位机命令 ↓ 通信协议适配层 ↓ 检测任务管理器 ↓ 相机触发与图像采集 ↓ 图像处理队列 ↓ 视觉算法 ↓ 检测结果 ↓ 通信协议适配层 ↓ 上位机
相机驱动、算法模块和通信模块应相互隔离。
算法模块不应直接调用相机 SDK,通信模块也不应持有相机句柄。
四、统一封装相机 SDK
不同品牌工业相机的 SDK 差别较大,但基本功能相似:
- 枚举设备。
- 创建相机句柄。
- 打开和关闭设备。
- 设置曝光和增益。
- 配置触发模式。
- 开始和停止采集。
- 获取图像。
- 注册异常回调。
建议先定义统一接口。
#pragma once #include <cstdint> #include <functional> #include <memory> #include <string> #include <vector> enum class CameraState { Disconnected, Connecting, Connected, Grabbing, Reconnecting, Faulted }; enum class TriggerMode { Continuous, Software, Hardware }; enum class PixelFormat { Mono8, BayerRG8, BayerBG8, RGB8, BGR8, Unknown }; struct CameraInfo { std::string id; std::string name; std::string serialNumber; std::string modelName; std::string transportType; }; struct CameraConfig { double exposureUs = 5000.0; double gain = 0.0; double frameRate = 20.0; TriggerMode triggerMode = TriggerMode::Continuous; std::string triggerSource = "Line0"; std::string triggerActivation = "RisingEdge"; int acquisitionTimeoutMs = 1000; }; struct ImageFrame { std::shared_ptr<std::uint8_t[]> data; std::size_t dataSize = 0; int width = 0; int height = 0; int stride = 0; PixelFormat pixelFormat = PixelFormat::Unknown; std::uint64_t frameId = 0; std::uint64_t cameraTimestamp = 0; std::string cameraId; }; using FrameCallback = std::function<void(ImageFrame)>; class ICamera { public: virtual ~ICamera() = default; virtual const CameraInfo& info() const = 0; virtual CameraState state() const = 0; virtual void open() = 0; virtual void close() noexcept = 0; virtual void applyConfig( const CameraConfig& config) = 0; virtual CameraConfig readConfig() const = 0; virtual void startGrabbing( FrameCallback callback) = 0; virtual void stopGrabbing() noexcept = 0; virtual void softwareTrigger() = 0; virtual bool isDeviceAccessible() const = 0; };业务层只依赖ICamera。
如果后续更换相机品牌,只需要新增对应的 SDK 适配器,不需要重写检测流程和通信流程。
五、使用 RAII 管理 SDK 资源
工业相机 SDK 一般具有固定的资源使用顺序:
创建句柄 → 打开设备 → 配置参数 → 开始采集 → 停止采集 → 关闭设备 → 销毁句柄
如果每个步骤都依赖手工释放,程序异常退出时很容易出现资源泄漏。
C++ 项目中应使用 RAII 管理相机句柄。
class CameraHandle { public: CameraHandle() = default; ~CameraHandle() { reset(); } CameraHandle(const CameraHandle&) = delete; CameraHandle& operator=(const CameraHandle&) = delete; CameraHandle(CameraHandle&& other) noexcept : handle_(other.handle_) { other.handle_ = nullptr; } CameraHandle& operator=(CameraHandle&& other) noexcept { if (this != &other) { reset(); handle_ = other.handle_; other.handle_ = nullptr; } return *this; } void reset(void* newHandle = nullptr) noexcept { if (handle_ != nullptr) { // Vendor_DestroyHandle(handle_); } handle_ = newHandle; } void* get() const noexcept { return handle_; } private: void* handle_ = nullptr; };程序中的相机对象、线程、网络连接和文件句柄,都应采用类似方式管理生命周期。
六、图像内存必须明确所有权
相机 SDK 返回的图像数据通常由 SDK 内部管理。
常见情况包括:
- 图像指针只在回调函数内有效。
- 下一帧到达后,上一帧内存会被覆盖。
- 图像缓冲区需要调用 SDK 接口归还。
- SDK 内部使用循环缓冲区。
下面这种写法存在风险:
void onFrame( const unsigned char* data, int width, int height) { cv::Mat image( height, width, CV_8UC1, const_cast<unsigned char*>(data)); frameQueue.push(image); }cv::Mat在这里没有复制图像数据。回调结束后,image.data可能已经失效。
如果图像需要交给其他线程处理,应复制图像数据,或者使用受控内存池。
ImageFrame copyFrame( const std::uint8_t* source, std::size_t size, int width, int height, int stride, PixelFormat format, std::uint64_t frameId, const std::string& cameraId) { auto buffer = std::shared_ptr<std::uint8_t[]>( new std::uint8_t[size], std::default_delete<std::uint8_t[]>()); std::memcpy( buffer.get(), source, size); ImageFrame frame; frame.data = std::move(buffer); frame.dataSize = size; frame.width = width; frame.height = height; frame.stride = stride; frame.pixelFormat = format; frame.frameId = frameId; frame.cameraId = cameraId; return frame; }图像复制会增加内存带宽消耗,但在项目初期,优先保证数据正确更重要。
后期如果图像较大、相机较多或帧率较高,可以再使用固定内存池或 SDK 用户缓冲区机制进行优化。
七、采集线程和算法线程必须分离
相机 SDK 回调通常运行在厂商内部线程中。
回调函数的原则是:尽快完成图像接收,然后立即返回。
不应在采集回调中执行:
- OpenCV 复杂处理。
- 深度学习推理。
- 图像保存。
- 网络通信。
- 数据库写入。
- 等待上位机应答。
- 大量日志输出。
- 长时间持有互斥锁。
推荐的回调流程:
收到图像 → 检查图像完整性 → 复制或接管图像内存 → 记录帧编号 → 放入图像队列 → 返回
示例:
void AcquisitionService::onFrameReceived( const RawFrame& rawFrame) { try { ImageFrame frame = frameConverter_.copyFrom(rawFrame); if (!frameQueue_.tryPush(std::move(frame))) { statistics_.queueDropCount.fetch_add( 1, std::memory_order_relaxed); } statistics_.receivedFrameCount.fetch_add( 1, std::memory_order_relaxed); } catch (const std::exception& ex) { logger_.error( "接收图像异常:{}", ex.what()); } }算法线程独立消费队列:
void InspectionWorker::run() { while (!stopRequested_.load()) { auto frame = frameQueue_.waitPop( std::chrono::milliseconds(200)); if (!frame.has_value()) { continue; } try { InspectionResult result = pipeline_.execute(*frame); resultQueue_.push( std::move(result)); } catch (const std::exception& ex) { logger_.error( "图像处理异常,FrameId={},错误={}", frame->frameId, ex.what()); } } }结果通信同样建议使用独立线程,避免网络发送阻塞算法线程。
八、所有任务队列都必须有容量限制
图像队列不能无限增长。
假设相机每秒采集 30 帧,而算法每秒只能处理 20 帧,队列将以每秒 10 帧的速度增加。
运行时间越长:
- 内存占用越大。
- 图像处理延迟越长。
- 检测结果与实际工件的位置偏差越大。
- 最终可能导致程序崩溃。
因此,图像队列必须是有界队列。
template<typename T> class BlockingQueue { public: explicit BlockingQueue(std::size_t capacity) : capacity_(capacity) { } bool tryPush(T value) { std::lock_guard<std::mutex> lock(mutex_); if (stopped_ || queue_.size() >= capacity_) { return false; } queue_.push(std::move(value)); condition_.notify_one(); return true; } std::optional<T> waitPop( std::chrono::milliseconds timeout) { std::unique_lock<std::mutex> lock(mutex_); const bool ready = condition_.wait_for( lock, timeout, [this] { return stopped_ || !queue_.empty(); }); if (!ready || queue_.empty()) { return std::nullopt; } T value = std::move(queue_.front()); queue_.pop(); return value; } void stop() { std::lock_guard<std::mutex> lock(mutex_); stopped_ = true; condition_.notify_all(); } private: std::size_t capacity_; std::queue<T> queue_; std::mutex mutex_; std::condition_variable condition_; bool stopped_ = false; };除图像队列外,下面这些队列也应设置容量上限:
- 检测请求队列。
- 算法任务队列。
- 检测结果队列。
- 图像保存队列。
- 上位机消息发送队列。
队列满时不能简单忽略,应产生明确的错误码或报警。
九、建立统一检测任务模型
相机程序不能只返回一个简单的true或false。
每次检测都应具有唯一任务编号,用于关联:
- 上位机命令。
- 相机触发。
- 图像帧。
- 算法结果。
- 通信应答。
- 保存的图像文件。
检测请求模型示例:
struct InspectionRequest { std::uint64_t requestId = 0; std::string productId; std::string recipeId; std::string stationId; std::string cameraId; std::uint64_t triggerSequence = 0; int timeoutMs = 2000; std::chrono::steady_clock::time_point createdAt; };检测结果模型示例:
enum class InspectionStatus { Success, ImageTimeout, CameraOffline, AlgorithmFailed, CommunicationFailed, Cancelled }; struct DefectItem { std::string code; std::string name; double score = 0.0; int x = 0; int y = 0; int width = 0; int height = 0; }; struct InspectionResult { std::uint64_t requestId = 0; std::uint64_t frameId = 0; std::string productId; std::string recipeId; std::string cameraId; InspectionStatus status = InspectionStatus::Success; bool passed = false; std::string resultCode; std::string message; double processingTimeMs = 0.0; std::vector<DefectItem> defects; std::string originalImagePath; std::string resultImagePath; };典型流程如下:
上位机发送检测请求 → 程序生成或校验 RequestId → 检查相机状态和配方状态 → 执行软触发或等待硬件触发 → 接收图像 → 图像与 RequestId 匹配 → 执行算法 → 生成 InspectionResult → 发送结果 → 等待上位机确认
十、检测命令必须具备幂等性
上位机与相机程序之间发生通信超时时,上位机可能重新发送同一条检测命令。
如果程序没有重复命令识别机制,同一个工件可能被检测两次。
建议上位机为每个命令提供唯一序号:
{ "messageType": "inspection_request", "sequence": 10251, "requestId": 8800123, "productId": "P1008", "recipeId": "Recipe-A", "cameraId": "TopCamera" }C++ 程序收到命令后应判断:
- 当前序号是否已经处理。
- 当前
requestId是否已经存在。 - 是否已有相同任务正在执行。
- 是否已经生成对应结果。
对于重复请求,可以返回之前的结果,而不是再次触发相机。
std::optional<InspectionResult> InspectionHistory::findResult( std::uint64_t requestId) const { std::lock_guard<std::mutex> lock(mutex_); auto iterator = results_.find(requestId); if (iterator == results_.end()) { return std::nullopt; } return iterator->second; }这类机制通常称为幂等处理,是保证通信可靠性的关键。
十一、设计统一的通信抽象层
业务代码不应直接依赖某一种通信协议。
可以定义统一的结果发布接口:
class IResultPublisher { public: virtual ~IResultPublisher() = default; virtual bool isConnected() const = 0; virtual void publishResult( const InspectionResult& result) = 0; virtual void publishStatus( const ServiceStatus& status) = 0; virtual void publishAlarm( const AlarmMessage& alarm) = 0; };上位机命令接收接口:
using InspectionRequestHandler = std::function<void(InspectionRequest)>; using ConfigCommandHandler = std::function<void(ConfigCommand)>; class ICommandServer { public: virtual ~ICommandServer() = default; virtual void start() = 0; virtual void stop() noexcept = 0; virtual void setInspectionRequestHandler( InspectionRequestHandler handler) = 0; virtual void setConfigCommandHandler( ConfigCommandHandler handler) = 0; };不同协议分别实现这些接口:
TcpCommandServer ModbusTcpServer OpcUaServer MqttCommandSubscriber
业务层只处理检测请求和检测结果,不关心底层使用 TCP 还是 OPC UA。
十二、自定义 TCP 协议设计
自定义 TCP 是工业项目中较常见的通信方式。
优点是:
- 实现相对直接。
- 传输内容灵活。
- 适合发送复杂检测结果。
- 可以发送缺陷列表、坐标和文件路径。
但 TCP 是字节流协议,没有天然消息边界。不能假设一次send对应一次recv。
推荐定义固定消息头:
#pragma pack(push, 1) struct MessageHeader { std::uint32_t magic = 0x5649534E; std::uint16_t version = 1; std::uint16_t messageType = 0; std::uint32_t bodyLength = 0; std::uint64_t sequence = 0; std::uint32_t checksum = 0; }; #pragma pack(pop)一个完整消息可以采用:
固定消息头 + 消息正文
正文可以使用:
- JSON。
- Protocol Buffers。
- MessagePack。
- 自定义二进制结构。
如果检测结果内容较复杂,JSON 调试方便。
示例:
{ "messageType": "inspection_result", "sequence": 10252, "requestId": 8800123, "cameraId": "TopCamera", "status": "success", "passed": false, "resultCode": "NG_SCRATCH", "processingTimeMs": 38.6, "defects": [ { "code": "SCRATCH", "score": 0.93, "x": 520, "y": 318, "width": 80, "height": 15 } ], "imagePath": "2026-07-31/P1008/8800123.jpg" }TCP 通信层至少应处理:
- 粘包和拆包。
- 消息长度校验。
- 协议版本。
- 心跳。
- 超时。
- 断线重连。
- 重复序号。
- 消息校验。
- 发送队列积压。
- 上位机确认。
十三、结果确认机制
不能把send返回成功视为上位机已经收到并处理结果。
完整结果流程应为:
检测结果生成 → 放入发送队列 → 通信线程发送 → 上位机收到结果 → 上位机返回 Ack → 程序标记结果已确认
上位机确认消息示例:
{ "messageType": "result_ack", "sequence": 10253, "requestId": 8800123, "accepted": true }等待确认期间,程序应保留结果。
struct PendingResult { InspectionResult result; int retryCount = 0; std::chrono::steady_clock::time_point lastSentAt; };如果超时未收到确认,可以按照策略重发:
第一次发送失败:立即重试 第二次失败:500 ms 后重试 第三次失败:1 s 后重试 后续失败:进入待恢复队列并产生通信报警
重发时仍使用同一个requestId,上位机也应进行幂等处理。
十四、Modbus TCP 通信设计
如果上位机或 PLC 主要使用寄存器交互,可以采用 Modbus TCP。
Modbus TCP 适合传输:
- 相机在线状态。
- 程序运行状态。
- 检测请求标志。
- 检测完成标志。
- OK 或 NG。
- 错误码。
- 检测耗时。
- 产品编号。
- 任务序号。
但不适合直接传输复杂缺陷列表和长字符串。
可以设计如下寄存器映射:
| 地址 | 名称 | 方向 | 说明 |
|---|---|---|---|
| 40001 | CommandCode | 上位机→视觉程序 | 命令编号 |
| 40002 | CommandSequence | 上位机→视觉程序 | 命令序号 |
| 40003 | ProductCode | 上位机→视觉程序 | 产品编号 |
| 40004 | RecipeCode | 上位机→视觉程序 | 配方编号 |
| 40010 | AckSequence | 视觉程序→上位机 | 已接收命令序号 |
| 40011 | AckResult | 视觉程序→上位机 | 接收结果 |
| 40020 | ResultSequence | 视觉程序→上位机 | 检测结果序号 |
| 40021 | InspectionFinished | 视觉程序→上位机 | 检测完成 |
| 40022 | InspectionResult | 视觉程序→上位机 | 0 未知、1 OK、2 NG |
| 40023 | ErrorCode | 视觉程序→上位机 | 错误码 |
| 40024 | ProcessingTime | 视觉程序→上位机 | 检测耗时 |
| 40030 | CameraState | 视觉程序→上位机 | 相机状态 |
| 40031 | ServiceHeartbeat | 视觉程序→上位机 | 程序心跳 |
Modbus 命令流程建议采用序号,而不是只使用一个启动位。
错误方式:
上位机将 Start 写为 1 → 视觉程序开始检测 → 视觉程序将 Start 清零
该方式在网络异常时容易出现命令重复或命令丢失。
推荐方式:
上位机写入 CommandCode → 上位机增加 CommandSequence → 视觉程序发现新的 CommandSequence → 视觉程序校验命令 → 视觉程序更新 AckSequence → 检测完成后更新 ResultSequence
命令是否为新命令,应通过序号判断。
十五、OPC UA 与 MQTT 的适用场景
OPC UA
OPC UA 适合设备状态和结构化数据较多的项目。
可以对外提供以下节点:
VisionService.Camera.TopCamera.State VisionService.Camera.TopCamera.FrameRate VisionService.Camera.TopCamera.Exposure VisionService.Inspection.CurrentRequestId VisionService.Inspection.LastResult VisionService.Inspection.LastErrorCode VisionService.Statistics.TotalCount VisionService.Statistics.OkCount VisionService.Statistics.NgCount
OPC UA 的优点是数据结构清晰,客户端可以订阅变量变化。
如果项目已经有 OPC UA 基础设施,使用 OPC UA 会比重新设计自定义协议更方便。
MQTT
MQTT 更适合:
- 将检测结果上传到服务器。
- 与 MES 或数据平台交互。
- 多设备集中监控。
- 发布状态、报警和统计数据。
主题可以设计为:
factory/line1/station2/vision/status factory/line1/station2/vision/result factory/line1/station2/vision/alarm
MQTT 不建议直接承担严格实时的相机触发控制。
对于实时检测命令,通常仍采用 TCP、Modbus TCP 或现场总线;MQTT 更适合数据上报。
十六、相机参数由上位机下发时的处理流程
上位机可能需要设置:
- 曝光时间。
- 增益。
- 触发模式。
- 触发源。
- 图像宽度和高度。
- ROI。
- 算法阈值。
- 配方编号。
参数不能收到后立即写入相机。
推荐流程:
接收参数命令 → 校验协议字段 → 校验参数范围 → 检查当前是否允许修改 → 暂停检测任务 → 停止相机采集 → 写入相机参数 → 回读相机参数 → 应用算法参数 → 恢复采集 → 返回参数应用结果
参数命令模型:
struct CameraParameterCommand { std::uint64_t sequence = 0; std::string cameraId; std::optional<double> exposureUs; std::optional<double> gain; std::optional<double> frameRate; std::optional<TriggerMode> triggerMode; std::optional<std::string> triggerSource; };返回结果:
struct CommandResult { std::uint64_t sequence = 0; bool accepted = false; bool finished = false; int errorCode = 0; std::string message; };参数写入后必须回读。
部分相机参数会按照步进值自动调整,因此应使用允许误差进行比较。
十七、相机断线重连
相机断线可能由以下原因造成:
- 相机掉电。
- 网线松动。
- 交换机重启。
- USB 接口异常。
- 相机被其他程序占用。
- 网络拥塞。
- SDK 内部异常。
连接管理器应负责:
- 检查相机是否在线。
- 检查是否长时间没有收到图像。
- 停止采集。
- 释放失效句柄。
- 重新枚举相机。
- 根据序列号查找目标设备。
- 重新打开相机。
- 恢复相机参数。
- 恢复图像回调。
- 重新开始采集。
- 发布相机恢复状态。
状态变化可以设计为:
Disconnected → Connecting → Connected → Grabbing → Reconnecting → Connected → Grabbing
重连应采用退避策略:
第 1 次失败:1 秒后重试 第 2 次失败:2 秒后重试 第 3 次失败:5 秒后重试 后续失败:每 10 秒重试
重连成功后不能直接继续之前的检测任务。
断线前正在执行的任务应根据业务规则处理为:
- 相机离线失败。
- 图像超时。
- 任务取消。
- 等待人工重新触发。
不能把重连后的第一张图像错误关联到断线前的工件。
十八、多相机设备管理
多相机项目不能依赖枚举顺序识别设备。
错误方式:
第 0 台设备 = 顶部相机 第 1 台设备 = 侧面相机
设备重启后,枚举顺序可能发生变化。
推荐使用相机序列号作为硬件唯一标识。
配置文件示例:
{ "cameras": [ { "role": "TopCamera", "serialNumber": "CAM001234", "enabled": true, "configFile": "top_camera.json" }, { "role": "SideCamera", "serialNumber": "CAM005678", "enabled": true, "configFile": "side_camera.json" } ] }程序启动时按照序列号查找相机。
每台相机应具有独立的:
- 相机对象。
- 连接状态。
- 图像队列。
- 帧统计。
- 采集线程。
- 错误信息。
- 重连任务。
不要让所有相机共用一个大锁。一台相机异常不应阻塞其他相机。
十九、图像与检测任务的匹配
在硬件触发或多工位系统中,不能简单认为“下一张图像就是当前任务的图像”。
图像匹配可以结合:
- 任务编号。
- 触发序号。
- 图像帧编号。
- 相机时间戳。
- 上位机时间戳。
- PLC 工件编号。
- 编码器位置。
可以为每台相机维护等待图像的任务队列:
struct PendingCapture { InspectionRequest request; std::chrono::steady_clock::time_point deadline; };收到图像后,由FrameMatcher完成匹配:
std::optional<InspectionRequest> FrameMatcher::match( const ImageFrame& frame) { std::lock_guard<std::mutex> lock(mutex_); if (pendingRequests_.empty()) { return std::nullopt; } InspectionRequest request = pendingRequests_.front(); pendingRequests_.pop(); return request; }上面的 FIFO 方式只适合严格顺序触发的场景。
高速、多相机或可能乱序的系统,应使用触发序号和时间戳进行匹配。
二十、图像保存不能阻塞检测
图像保存涉及:
- 图像格式转换。
- JPEG 或 PNG 编码。
- 目录创建。
- 磁盘写入。
- 网络磁盘访问。
这些操作耗时不稳定,不能放在相机回调或算法主线程中。
建议建立独立存图队列:
struct SaveImageTask { std::uint64_t requestId = 0; cv::Mat image; std::filesystem::path filePath; bool isNgImage = false; };图像保存策略应配置化:
- 保存所有原图。
- 只保存 NG 图。
- 保存 NG 原图和结果图。
- 按比例抽样保存。
- 调试模式保存全部图像。
- 磁盘不足时停止保存。
- 磁盘不足时继续检测但产生报警。
- 按日期、产品和工位建立目录。
建议目录结构:
images/ 2026-07-31/ Recipe-A/ OK/ NG/
文件名应包含关键索引:
RequestId_CameraId_Result_Timestamp.jpg
例如:
8800123_TopCamera_NG_20260731_102215381.jpg
二十一、服务状态与心跳
即使程序没有界面,也必须对外提供运行状态。
状态模型示例:
enum class ServiceState { Starting, Ready, Running, Degraded, Faulted, Stopping }; struct ServiceStatus { ServiceState state = ServiceState::Starting; bool upperComputerConnected = false; int onlineCameraCount = 0; int configuredCameraCount = 0; std::uint64_t totalInspectionCount = 0; std::uint64_t okCount = 0; std::uint64_t ngCount = 0; int activeErrorCode = 0; std::string activeErrorMessage; };上位机应能够获取:
- 程序是否启动。
- 相机是否在线。
- 相机是否正在采集。
- 当前配方。
- 当前任务编号。
- 最近一次结果。
- 最近一次错误。
- 接收帧率。
- 算法处理时间。
- 图像队列长度。
- 通信连接状态。
心跳可以采用递增计数值:
VisionHeartbeat = 1 VisionHeartbeat = 2 VisionHeartbeat = 3 ...
相比固定写入1,递增心跳更容易判断程序是否仍在运行。
二十二、错误码应统一管理
错误信息不能只依赖文本字符串。
建议建立统一错误码:
enum class ErrorCode : int { Success = 0, CameraNotFound = 1001, CameraOpenFailed = 1002, CameraDisconnected = 1003, CameraConfigFailed = 1004, ImageTimeout = 2001, ImageIncomplete = 2002, FrameQueueFull = 2003, AlgorithmFailed = 3001, AlgorithmTimeout = 3002, RecipeLoadFailed = 3003, ProtocolError = 4001, UpperComputerOffline = 4002, ResultAckTimeout = 4003, InvalidCommand = 5001, DuplicateCommand = 5002, InvalidParameter = 5003 };上位机根据错误码显示对应说明。
错误记录至少包含:
- 错误码。
- 错误名称。
- 发生时间。
- 相机编号。
- 任务编号。
- 当前状态。
- SDK 原始错误码。
- 错误描述。
- 建议处理方式。
例如:
错误码:2001 错误名称:ImageTimeout 相机:TopCamera 任务编号:8800123 触发模式:Hardware 等待时间:1000 ms 最后帧号:15822 建议:检查外部触发信号、相机供电和网络连接
二十三、后台服务的启动与停止
程序没有界面后,更要重视启动和退出顺序。
推荐启动流程:
读取配置文件 → 初始化日志 → 初始化通信服务 → 枚举并打开相机 → 加载算法和配方 → 创建采集线程 → 创建算法线程 → 创建结果发送线程 → 启动健康监控 → 对外发布 Ready 状态
推荐停止流程:
停止接收新命令 → 取消未开始任务 → 等待正在处理的任务结束 → 停止相机采集 → 停止算法线程 → 停止存图线程 → 发送最后状态 → 关闭通信连接 → 关闭相机 → 刷新日志 → 退出程序
不要在收到退出信号后直接调用std::exit,否则可能导致:
- 相机句柄未释放。
- 日志没有写入磁盘。
- 图像文件损坏。
- 未确认结果丢失。
- 上位机不知道服务已经停止。
可以监听系统信号:
std::atomic_bool g_stopRequested = false; void signalHandler(int) { g_stopRequested.store(true); } int main() { std::signal(SIGINT, signalHandler); std::signal(SIGTERM, signalHandler); Application app; app.start(); while (!g_stopRequested.load()) { std::this_thread::sleep_for( std::chrono::milliseconds(200)); } app.stop(); return 0; }在 Windows 中,也可以进一步封装为 Windows Service;在 Linux 中可以使用 systemd 管理程序。
二十四、性能统计
程序应持续统计以下数据:
- 相机实际接收帧率。
- 图像不完整数量。
- 相机 SDK 丢帧数量。
- 软件队列丢帧数量。
- 算法平均处理时间。
- 算法最大处理时间。
- 图像队列长度。
- 结果队列长度。
- 存图队列长度。
- 上位机通信延迟。
- 结果确认超时次数。
- 相机重连次数。
- 当前内存占用。
建议区分不同阶段耗时:
命令接收时间 → 相机触发时间 → 图像到达时间 → 算法开始时间 → 算法结束时间 → 结果发送时间 → 上位机确认时间
这样才能判断节拍问题发生在哪个环节。
例如:
任务编号:8800123 命令到触发:2.1 ms 触发到收图:18.4 ms 算法处理:36.8 ms 结果编码:0.6 ms 网络发送:1.3 ms 上位机确认:4.2 ms 总耗时:63.4 ms
二十五、常见问题与规避方式
25.1 在相机回调中运行算法
问题:算法阻塞 SDK 采集线程,造成图像缓存溢出。
建议:采集回调只投递图像,算法独立运行。
25.2 直接传递 SDK 图像指针
问题:图像数据可能被 SDK 回收或覆盖。
建议:复制图像,或建立明确的缓冲区所有权机制。
25.3 检测请求没有唯一编号
问题:通信重试时可能重复检测,结果也无法准确对应工件。
建议:所有任务使用唯一requestId。
25.4 把 TCP 发送成功当成结果交付成功
问题:只能说明数据进入本机网络栈,不能证明上位机已处理。
建议:增加结果确认和超时重发机制。
25.5 使用单一启动标志控制检测
问题:网络异常时容易重复触发或漏触发。
建议:使用命令序号和应答序号。
25.6 相机断线后只重新打开设备
问题:相机参数和回调可能没有恢复。
建议:重新打开后完整恢复参数、回调和采集状态。
25.7 多台相机依赖枚举顺序
问题:相机角色可能发生错位。
建议:使用序列号绑定业务角色。
25.8 图像保存阻塞算法
问题:磁盘波动会直接影响检测节拍。
建议:使用独立存图队列和线程。
25.9 通信线程直接操作相机
问题:协议处理和设备操作耦合,后续难以维护。
建议:通信层只生成业务命令,由任务服务执行。
25.10 所有异常只返回一个 NG
问题:无法区分产品不合格和系统故障。
建议:区分正常 NG、采图失败、算法失败和通信失败。
二十六、项目检查清单
架构检查
- 已建立统一相机接口。
- 业务层不直接依赖相机 SDK。
- 相机、算法和通信模块相互隔离。
- 程序不依赖界面线程运行。
- 支持 Windows 服务或 Linux 守护进程模式。
- 支持模拟相机或离线图像测试。
图像采集检查
- 相机句柄使用 RAII 管理。
- 图像数据所有权明确。
- 采集回调中没有耗时业务。
- 图像队列具有容量上限。
- 能够检测图像超时。
- 能够检测不完整图像。
- 能够统计帧编号连续性。
- 支持相机断线重连。
检测任务检查
- 每个任务具有唯一编号。
- 图像能够关联对应任务。
- 检测任务具有超时。
- 重复命令能够识别。
- 已完成任务结果能够查询。
- 任务失败具有明确错误码。
- 产品 NG 与程序异常能够区分。
通信检查
- 通信协议具有版本号。
- TCP 协议处理粘包和拆包。
- 命令具有序号。
- 结果具有序号。
- 支持心跳。
- 支持断线重连。
- 结果发送后等待确认。
- 结果确认超时能够重发。
- 上位机重复确认不会产生异常。
- 通信队列具有容量上限。
参数检查
- 参数具有范围校验。
- 参数写入后执行回读。
- 参数修改具有命令结果。
- 运行中禁止修改危险参数。
- 相机重连后恢复参数。
- 配方版本可以追溯。
- 配方切换失败时禁止继续检测。
日志与统计检查
- 记录程序启动和退出。
- 记录相机连接和断开。
- 记录上位机连接状态。
- 记录命令序号和任务编号。
- 记录算法处理时间。
- 记录结果发送和确认。
- 记录图像队列溢出。
- 记录相机重连次数。
- 日志能够按日期滚动。
- 日志不会无限占用磁盘。
二十七、小结
无界面的 C++ 工业相机程序,本质上是一个面向设备运行的视觉服务。
它的重点不是提供图像显示窗口,而是建立一套稳定的数据处理链路:
上位机命令 → 检测任务 → 相机触发 → 图像采集 → 算法处理 → 检测结果 → 通信发送 → 上位机确认
项目中需要重点做好以下工作:
- 使用统一接口隔离相机厂商 SDK。
- 使用 RAII 管理相机和通信资源。
- 明确图像内存所有权。
- 将采集、算法、存图和通信线程分开。
- 使用有界队列控制内存和处理延迟。
- 使用任务编号关联命令、图像和结果。
- 使用命令序号解决重复请求问题。
- 使用结果确认机制保证数据可靠交付。
- 根据数据特点选择 TCP、Modbus TCP、OPC UA 或 MQTT。
- 对相机断线和上位机断线分别进行恢复。
- 用统一错误码区分相机、算法和通信故障。
- 对处理时间、队列长度、帧率和通信延迟进行持续统计。
当图像采集、检测任务和通信协议形成明确边界后,程序才能从一个简单的相机 SDK 示例,逐步变成可以长期运行、方便维护、能够接入整机系统的工业视觉服务。