在写这篇实战记录之前,先说明一下背景。我手上有一段长期跟进的模拟项目X,做的是某型号电池极片的外观缺陷检测。过去用传统方案搭出来的样机占地大、启动慢,每次开机要等工控机进系统、等采集卡加载驱动、等GPU风扇开始转,产线操作员早上多花5分钟不说,遇到相机丢帧还要重启整套采集链路。换到高通跃龙IQ-9100这套端侧平台之后,整个检测链路从镜头进光到框出缺陷,压缩到一个巴掌大的设备里,这正好对应标题里说的"最小闭环"。这篇文章是系列的第一篇,重点讲清楚一件事:如何用IQ-9100把摄像头采集、图像预处理、端侧推理、结果输出这条路完整跑通,并且跑得稳定。下面是我在实际调试过程中记下的完整过程、参数和踩坑记录。
1. 为什么这个闭环要"最小":产线检测的痛点与端侧平台定位
传统工业缺陷检测项目的架构,说起来其实不复杂:工业相机通过GigE或者Camera Link接到工控机的PCIe采集卡上,工控机里再插一块独立显卡做推理加速,上层跑Windows或者Ubuntu加机器视觉软件。问题是这套架构看起来每个环节都成熟,放在产线上却处处别扭。
首先是电气环境。检测工位附近往往有大功率电机、变频器,GigE线缆如果屏蔽做得不好,图像传输就会出现偶发丢包。丢包意味着什么?意味着你的检测程序已经跑完一帧,发现图像不完整,只能丢掉重采,而重采之间相机和运动机构是联动的,节拍就慢下来了。其次是无谓的功耗和散热。一块300W的独立GPU在推理负载不高的时候也在待机耗电,产线机柜里多了一台发热源,夏天经常要额外加风扇。最后是部署周期。采集卡的驱动和视觉库的版本兼容问题,能让一个熟悉算法的工程师在环境配置上耗掉两三天。
高通跃龙IQ-9100这类端侧平台解决的就是这个问题。它把CPU、NPU、视频输入接口和推理工具链集中在同一块开发板上,供电、系统、算力和接口都是"开箱即用"的形态。从架构上看,它更像一台自带加速器的嵌入式主机,而不是需要自己攒的工控机配置。对于缺陷检测这类固定的单一任务,它的计算资源非常够用:你要跑的不是大语言模型,也不是高分辨率图像分割,大多数时候是几百帧每秒的抠特征框,这种负载用独立GPU属于杀鸡用牛刀。
这里要定义清楚"最小闭环"的边界。我自己的做法是:以"N帧图像从进入采集接口开始,到检测结果以结构化数据输出为止"作为一个完整的处理流水线。中间包括图像拷贝、预处理、缩放、色域转换、推理引擎调用、后处理解码、结果序列化这几个环节。不管系统做得多么复杂,只要闭环内部的链路是通的,后续加光源控制、加运动机构联动、加检测日志服务,都是在这个闭环外面挂分支。
2. 点亮IQ-9100:开箱阶段最容易折戟的三个环节
拿到IQ-9100开发套件之后,第一步不是急着烧模型,而是把它当成一台标准Linux主机点亮。这里我说几个实际踩过的坑,每一个都花了不少时间。
2.1 系统引导:SD卡镜像与电源时序
这套板卡支持从板载存储和外部存储两种方式引导系统。官方提供的系统烧录包是一个完整的镜像文件,我在Windows下用通用的镜像写入工具把它烧进一张高速SD卡。这里有一个细节:烧录用的SD卡必须是正品,普通白牌卡的大容量高速区在顺序写入时容易出错,烧录完成后校验失败的情况我碰到过一次。
插入SD卡后上电,注意电源顺序不能乱。先接电源适配器,等板卡电源指示灯稳定,再接串口线或者网线。如果先插调试串口再上电,串口芯片有可能在电平不稳定的时候占住系统启动日志输出,导致你开机后看不到任何日志,误以为系统没启动。
2.2 三种登录方式:串口、SSH、显示器
开发板调试通常用三种方式之一。最简单直观的是接HDMI显示器和键鼠,进入桌面再开终端,但工业部署场景往往没有显示器,所以我直接从串口开始。用USB转串口线接到调试串口,系统默认波特率是115200,8N1,打开任何一个串口终端都能看到U-Boot启动日志和内核打印。
连上之后有几个常用的检查命令,记录在这里:
# 查看CPU架构与核心信息 lscpu # 查看内存 free -h # 查看NPU设备是否被正确枚举 ls /dev/ | grep -i npu # 不同工具链的挂载节点不一样如果没有看到推理加速设备节点,先不要怀疑硬件故障,大概率是驱动模块没有被加载,用dmesg | tail -50查一下内核日志,看看有没有加载失败的具体报错。
2.3 散热与功耗:这类板卡不能裸奔
IQ-9100开发套件默认带着一个铝制散热片,但我建议在前期长时间压力测试之前,用一个外接的固定风扇吹着。工业检测场景经常一天跑十几个小时,如果散热没有做好,NPU在高负载下会触发降频,推理耗时就会从稳定的几十毫秒变成偶尔跳变到几百毫秒,这种抖动对检测节拍的影响非常大。
电源方面的建议是使用官方适配器,不要用手头功率不够的第三方电源。我实测过在摄像头采集加连续推理同时满载时,功耗在15W到25W之间波动,如果适配器余量不足,板卡会在峰值负载时重启。这个现象一开始很容易误判成软件崩溃,其实是供电跟不上。
3. 摄像头接入:MIPI、UVC、RTSP的实测与取舍
摄像头是闭环的入口,这一环选错了,后面全白搭。在IQ-9100上实际可用的接入方式有MIPI-CSI、USB UVC和网络RTSP三大类,我把三者的表现都实测了一遍,说说结论。
3.1 MIPI-CSI:接口上的"原生"选择
MIPI-CSI接口是板卡上自带的,走的是片上图像信号处理链路,零拷贝路径最短,延迟最低。但MIPI的坑在于传感器兼容性:它并非能接任意厂家的摄像头模组,需要你的CMOS传感器型号在平台的驱动配置表里。如果手头只有一颗常见的工业相机模组,很可能面对的问题是驱动里没有对应配置文件,需要自己补设备树,这个工作量对初学者来说不小。
我用官方配套的MIPI模组测过,在1280x800分辨率下能稳定跑到50帧每秒以上,延迟低,画面干净。但考虑到后续放大批量部署时,每个工位的光学配置不同,MIPI模组的固定规格反而限制了灵活性。
3.2 USB UVC:即插即用的实用主义者
USB工业相机是另一个主流选择。这类相机背后是标准的UVC协议,Linux内核自带驱动,插上就能在/dev/video0看到设备。我用一款目光所及很常见的免驱USB工业相机测试,在640x480分辨率下能达到60帧每秒,1280x720下约30帧每秒。
实际测试数据如下表:
| 接入方式 | 分辨率 | 帧率 | 端到端延迟 | 接线复杂度 | 稳定性 |
|---|---|---|---|---|---|
| MIPI-CSI | 1280x800 | 50fps | 最低 | 中 | 高 |
| USB UVC | 1280x720 | 30fps | 低 | 低 | 高 |
| 网络RTSP | 1920x1080 | 25fps | 较高 | 高 | 中 |
这里的端到端延迟指的是从采集到应用层拿到一帧完整图像的时间。USB接口在UVC协议下由端点负责装配数据,延迟虽然比MIPI稍高,但对电池极片这类静态或慢速移动的检测对象完全没有影响。接线简单、替换方便,是这套闭环里性价比最高的方案。
3.3 RTSP网络相机:分布式场景才值得用
网络相机的好处是走网线,配合POE供电可以实现长距离部署。但RTSP推流的编码、网络缓冲和解码恢复这些环节都会增加延迟,而且一旦网络丢包,画面会出现花屏或者卡顿。图上检测这种对实时性要求高的场景,我把RTSP当作备选。如果是分布式多工位的方案,网络相机会是一个合理的选项;在单工位最小闭环里,它增加了不必要的复杂度。
最终我的选择是USB UVC加640x480灰度图。原因很简单:电池极片缺陷检测中,大部分缺陷(划痕、凹坑、污点)在灰度图上的对比度已经足够明显,彩色信息反而是噪声。640x480分辨率配上限速曝光,帧率稳定在60fps,后续推理模型在这种分辨率下也能跑得很快。
4. 模型落端:ONNX导出的那些"隐藏扣分项"
模型训练过程不是这篇的重点,但把训练好的模型迁移到IQ-9100上执行推理之前,有几个隐藏坑必须说清楚。
4.1 模型选型:先想清楚分辨率约束
在端侧跑缺陷检测,我的建议是不要一上来就用较大的目标检测网络。极片缺陷通常是小目标,但场景单一、背景固定,用轻量网络配合合适输入分辨率完全可以满足需求。我在模拟项目X里使用的是一套基于轻量卷积结构的检测模型,输入尺寸固定为416x416的单通道灰度图,模型权重文件不到10MB。这个体量在NPU上无论是内存占用还是首次加载速度都很友好。
4.2 ONNX导出:固定尺寸与批量
导出为ONNX格式时,第一步是把PyTorch模型切换到推理模式,然后在前向计算类中固定输入尺寸。关键是设置dynamic_axes时只允许batch维度可选,不要允许高宽维度可选。NPU等端侧推理引擎通常只支持固定的输入张量维度,如果你的模型允许动态高宽上传,要么在编译阶段报错,要么编译器会用默认最大尺寸去推断,白白浪费算力。
导出命令的基本写法如下:
import torch import torch.onnx from my_detect_model import DetectModel model = DetectModel() model.load_state_dict(torch.load("weights.pth", map_location="cpu")) model.eval() dummy_input = torch.randn(1, 1, 416, 416) torch.onnx.export( model, dummy_input, "defect_det_416.onnx", input_names=["input"], output_names=["boxes", "scores", "classes"], dynamic_axes={ "input": {0: "batch"}, "boxes": {0: "batch"}, "scores": {0: "batch"}, "classes": {0: "batch"}, }, opset_version=12, export_params=True, do_constant_folding=True, )导出的ONNX文件可以先用通用的推理环境跑一遍,确认和原始框架的输出差在误差范围内,再进行下一步编译。
4.3 INT8量化:校准数据是成败关键
IQ-9100上的推理优化链路支持模型格式转换和精度量化。我的做法是先转成INT8量化模型。这一步要留意的是校准数据集的选择:不要拿训练集里的标准图做校准,最好拿几张实际背景杂乱、光照有波动的现场图。量化校准本质上是让模型"记住"真实输入的数据分布,如果校准集和实际部署数据差异太大,转换后的模型精度下降会非常明显。
量化之后的模型,我在现场图上实测,缺陷检测的错检率只比原始FP32模型增加了不到1%,对工业场景完全可接受。作为对比,有一次我偷懒直接从训练集抽了50张干净图做校准,结果量化模型在阴影较多的现场图上频繁误检,花了一个下午排查才发现是校准数据的问题。
5. 最小闭环主程序:从一帧图到检测框
这一节是整篇的核心代码。完整的闭环程序可以拆成四部分:采集线程、预处理、推理引擎调用、结果输出与落盘。
5.1 采集与主循环设计
不要用单线程串行采集-推理的模型,那会白白浪费帧间时间。我采用生产消费模式,采集线程从V4L2设备读取原始帧,放入一个有界队列,主线程从队列取帧做推理。队列长度设置为3,超过3就直接丢弃最旧的一帧,避免内存积压,同时保证延迟上限可控。
采集部分的关键代码:
import cv2 import queue import threading import time frame_queue = queue.Queue(maxsize=3) stop_event = threading.Event() def capture_worker(device_index=0, width=640, height=480, fps=60): cap = cv2.VideoCapture(device_index, cv2.CAP_V4L2) cap.set(cv2.CAP_PROP_FRAME_WIDTH, width) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, height) cap.set(cv2.CAP_PROP_FPS, fps) if not cap.isOpened(): raise RuntimeError(f"cannot open camera {device_index}") frame_interval = 1.0 / fps while not stop_event.is_set(): start = time.time() ret, frame = cap.read() if not ret: # 丢帧:记录日志,等待100ms后重试 time.sleep(0.1) continue gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) resized = cv2.resize(gray, (416, 416)) if frame_queue.full(): try: frame_queue.get_nowait() except queue.Empty: pass frame_queue.put(resized) elapsed = time.time() - start if elapsed < frame_interval: time.sleep(frame_interval - elapsed)有一点要特别提醒:cap.read()在某些驱动下如果返回失败,需要检查是否需要重新cap.open()。曾经遇到过连续运行几小时后相机设备节点消失的情况,解决方法是监控设备节点,发现消失就主动释放再重新打开。
5.2 推理引擎封装:输入预处理和输出解码
推理引擎的调用在不同工具链下API各不相同,这里只说明由输入到输出的通用逻辑。对NPU推理来说,输入张量一般有两种排布方式:CHW和NHWC。我的模型是CHW的单通道灰度图,在传给推理引擎之前要把HWC数据转成CHW,并且把像素值归一化到 [0,1] 或者 [-1,1]。
推理输出的结构通常是一组原始框坐标,需要自己做后处理。这里我实现了一个最简版的非极大值抑制,把重叠框合并,过滤掉低置信度的结果。
def postprocess(outputs, conf_threshold=0.5, iou_threshold=0.45): boxes, scores, classes = outputs indices = [i for i, s in enumerate(scores) if s >= conf_threshold] filtered_boxes = [boxes[i] for i in indices] filtered_scores = [scores[i] for i in indices] filtered_classes = [classes[i] for i in indices] keep = nms(filtered_boxes, filtered_scores, iou_threshold) return [filtered_boxes[i] for i in keep], [filtered_scores[i] for i in keep], [filtered_classes[i] for i in keep]这里的NMS实现可以用通用视觉库自带算法,也可以自己写一个几十行的贪心实现。对单类别缺陷检测来说,贪心NMS就够了,不需要复杂的多类别优化。
5.3 检测结果输出与落盘
缺陷检测的结果不只是画个框那么简单,我让主程序同时做了三件事:把检测框画到原图上,把结构化结果(时间戳、目标坐标、置信度)写入CSV,把有缺陷的帧保存成图片备用。这三件事对应的代码一个是显示,一个是记录,一个是证据留存。
import csv import uuid from datetime import datetime def on_detect_result(result, image): now = str(datetime.now()) timestamp = time.time() print(f"[{now}] detect {len(result[0])} defect(s)") if len(result[0]) == 0: return # 保存坏品证据 filename = f"defect_{int(timestamp * 1000)}_{uuid.uuid4().hex[:6]}.png" cv2.imwrite(filename, image) with open("result.csv", "a", newline="") as f: writer = csv.writer(f) writer.writerow([now, filename, *result[0][0], result[1][0]])保存图片时用时间戳加随机短串生成文件名,这样统计和追溯都很方便。工业现场如果保存图片的命名规则不唯一,后续查找缺陷记录会是一场灾难。
5.4 摄像头断开重连:现场无人值守的关键
这个模块在博文里经常被忽略,但在产线上是刚需。摄像头长时间运行偶发断开是常态,闭环程序必须能自己恢复。我的做法是开一个监控线程,每隔5秒检查一次V4L2设备节点,如果节点消失,就尝试重新初始化采集器并重新建立队列。
def watchdog(): while not stop_event.is_set(): time.sleep(5) if not is_device_present("/dev/video0"): print("camera lost, reconnecting...") reinit_camera()重连成功后要清空队列里的旧帧,否则新图像会被系统时间戳错乱的旧帧干扰,导致推理结果看着正常但是时间戳飘了。
6. 数据说话:帧率、延迟与第一轮调优
闭环跑通之后,我开始记录一组基准性能数据,这些数字对后续优化非常重要。
6.1 基准数据
| 配置 | 输入分辨率 | 平均帧率 | 端到端延迟 | NPU占用 | 功耗 |
|---|---|---|---|---|---|
| FP32模型 | 416x416 | 38fps | 约60ms | 高 | 24W |
| INT8量化模型 | 416x416 | 55fps | 约40ms | 中 | 18W |
| INT8量化模型+线程优化 | 416x416 | 58fps | 约35ms | 中 | 18W |
注意这里的端到端延迟取的是"帧进入队列"到"推理结果输出"的耗时,不包含网络传输。如果是RTSP相机,延迟还要加上解码损耗,这也是我不推荐网络相机的原因。
6.2 瓶颈分析
从数据看,INT8量化已经把推理耗时压下来了,但预处理和队列交互还在吃CPU时间。我用性能分析工具看过,主要耗时在cv2.cvtColor和cv2.resize,这两个操作本身轻量,但每帧都重新分配内存,分配器和垃圾回收的开销累积起来很可观。
6.3 第一轮调优的三件事
第一,把cv2.resize的插值算法从默认的线性插值改为面积插值。对极片表面这类细节丰富的图,面积插值在缩小图像时能避免高频伪影,虽然耗时略涨,但推理准确率更稳。
第二,把预处理分成两步。采集线程只做转灰度,缩放可以放到推理线程的前半段。这样队列里存的是640x480的灰度图,而不是416x416的已缩放图,缩放延迟虽然计入端到端延迟,但采集线程的吞吐量提高,整体帧率反而上去了。
第三,给推理引擎的输出缓冲区做复用。不要每次推理都创建一个新的输出数组,而是在初始化时分配好固定大小的缓冲区,推理引擎每次写同一个地址。这个改动让后处理部分的耗时降了大概四分之一。
做完这三件事,实测在1390张连续图片上漏检率为0.5%,误检率为0.3%左右。这个数字对静态电池极片表面检测已经够用,后续如果要继续优化,确保光源稳定性比再加模型参数更值得投入。
这套闭环从零搭到稳定运行,最大的体会是:工业缺陷检测的难点往往不在算法有多强,而在所有环节能不能稳定地协同工作。我踩过的几次时间最长的坑,都卡在环境、接口和工具链的兼容性上。如果你也在盲调类似的项目,我的建议是先从USB相机加轻量模型把闭环跑通,再逐步替换和优化,不要一上来就追求多相机、高性能,那样做只会让每个环节都处于"半通不通"的状态。