news 2026/8/6 7:01:49

工业相机应用开发:从图像采集到检测结果通信的工程化实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
工业相机应用开发:从图像采集到检测结果通信的工程化实践

一、工业相机程序不只是“采图加算法”

在很多工业视觉项目中,C++ 程序并不直接提供操作界面,而是以后台进程、Windows 服务或 Linux 守护进程的形式运行。

程序主要负责三件事:

  1. 对接工业相机,稳定获取图像。
  2. 调用视觉算法,生成检测结果。
  3. 通过 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; };

除图像队列外,下面这些队列也应设置容量上限:

  • 检测请求队列。
  • 算法任务队列。
  • 检测结果队列。
  • 图像保存队列。
  • 上位机消息发送队列。

队列满时不能简单忽略,应产生明确的错误码或报警。


九、建立统一检测任务模型

相机程序不能只返回一个简单的truefalse

每次检测都应具有唯一任务编号,用于关联:

  • 上位机命令。
  • 相机触发。
  • 图像帧。
  • 算法结果。
  • 通信应答。
  • 保存的图像文件。

检测请求模型示例:

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。
  • 错误码。
  • 检测耗时。
  • 产品编号。
  • 任务序号。

但不适合直接传输复杂缺陷列表和长字符串。

可以设计如下寄存器映射:

地址名称方向说明
40001CommandCode上位机→视觉程序命令编号
40002CommandSequence上位机→视觉程序命令序号
40003ProductCode上位机→视觉程序产品编号
40004RecipeCode上位机→视觉程序配方编号
40010AckSequence视觉程序→上位机已接收命令序号
40011AckResult视觉程序→上位机接收结果
40020ResultSequence视觉程序→上位机检测结果序号
40021InspectionFinished视觉程序→上位机检测完成
40022InspectionResult视觉程序→上位机0 未知、1 OK、2 NG
40023ErrorCode视觉程序→上位机错误码
40024ProcessingTime视觉程序→上位机检测耗时
40030CameraState视觉程序→上位机相机状态
40031ServiceHeartbeat视觉程序→上位机程序心跳

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 示例,逐步变成可以长期运行、方便维护、能够接入整机系统的工业视觉服务。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/8/6 7:00:35

Unity粒子特效模块化设计:从参数调整到可复用资产库构建

1. 项目概述&#xff1a;从“一次性特效”到“可复用资产”的思维跃迁在Unity项目里摸爬滚打这么多年&#xff0c;我见过太多同行&#xff08;包括早期的我自己&#xff09;对待粒子特效的态度&#xff1a;需要烟花了&#xff0c;就新建一个Particle System&#xff0c;调调颜色…

作者头像 李华
网站建设 2026/8/6 7:00:28

Web开发者必备:从IP端口到安全组,掌握网络配置全链路

1. 从一行代码到整个网络&#xff1a;为什么Web开发者必须懂网络配置&#xff1f;你刚写完一个漂亮的登录页面&#xff0c;前端Vue组件渲染丝滑&#xff0c;后端Spring Boot接口响应迅速。本地localhost:8080测试一切正常&#xff0c;你信心满满地打包部署到服务器。结果&#…

作者头像 李华
网站建设 2026/8/6 6:56:33

Excel数据合并与拆分:5种传统方法详解与避坑指南

1. 项目概述&#xff1a;为什么我们还在谈论“传统方法”&#xff1f;在数据处理的日常里&#xff0c;Excel的合并与拆分是个老生常谈却又永不过时的话题。你可能已经看过无数关于Power Query、VBA宏甚至Python脚本的“高效”教程&#xff0c;它们确实强大。但今天&#xff0c;…

作者头像 李华
网站建设 2026/8/6 6:56:08

Godot推箱子游戏开发:CharacterBody2D实现精准碰撞与平滑移动

1. 项目概述&#xff1a;为什么用CharacterBody2D做推箱子是个好主意&#xff1f;最近在社区里看到不少朋友在讨论用Godot做推箱子游戏&#xff0c;很多教程还在用RigidBody2D或者Area2D来处理玩家和箱子的交互&#xff0c;结果不是物理反馈太“飘”&#xff0c;就是碰撞检测卡…

作者头像 李华
网站建设 2026/8/6 6:54:32

开源数据抓取工具openClaw的商业化路径与挑战分析

1. 从Clawdbot到openClaw&#xff1a;一个开源项目的商业化十字路口最近在开源社区和开发者圈子里&#xff0c;一个关于数据抓取工具的消息引起了我的注意&#xff1a;Clawdbot又改名了&#xff0c;这次定稿为“openClaw”&#xff0c;并且透露出可能走向商业化的信号。对于一个…

作者头像 李华