核心课程 / 视觉应用与开发 / EXP-04
EXP-04 · 第 2 阶段 · 目标检测与分类 · 综合性 · 4 学时

工业表面缺陷检测

这一章要机器干一件具体的事:看着一张钢板表面图,把上面的夹杂、划痕圈出来,并说出它是哪一类。它也是全课第一个真正用神经网络的实验 —— 前三章你能一行行读懂判断规则,从这一章起,判断权交给了一个训练出来的模型。

难度 ⭐⭐⭐ · 4 学时 · 综合性 YOLOv8 letterbox DFL + NMS RKNN int8 板端约 13 FPS
01 颜色02 轮廓03 二维码04 检测05 分类06 分割07 跟踪08 人脸09 骨骼10 3D11 体感12 手势

01目标:六类缺陷框出,标类别和分数

🎯 实验目标 · 学完你将能
  • 说出一帧图从「进模型」到「画出红框」要走的四步,以及 score_thr、iou_thr 分别卡在哪一步
  • 用 src/app.py 跑通单图与实时两条链路,读出终端打印的「类别 + 置信度 + 左上右下坐标」三件套
  • 调出不漏不糊的阈值组合:把 score_thr 从 0.25 推到 0.60 再拉回来,说清漏检与误检为什么反着走;并说清这套东西接在产线哪一环、阈值该按什么代价定
你会给它
一张钢板表面图

images/1.jpg:长条、方图、灰度、彩色都行;缺陷只占画面一角也行

→
你会拿到
inclusion 0.826 (181,0,279,302)

终端里每条缺陷一行:类别 + 置信度 + 左上右下坐标;外加一张红框标注图 out1.jpg

⚠️ 重点与难点 重点:letterbox 的补边量与反缩放、DFL 解码 + 逐类 NMS,这两处后处理决定了框准不准。
难点:直觉是「框歪了就是模型不准」,实际是检测器根本不负责画图 —— 它吐的是 640 坐标系里的四个数,贴到原图上要「减 pad、除 scale」。这一步对不上,框会整体漂几十像素,而形状一点没错 —— 于是你会去查一个根本没问题的模型。
本实验的目标:一张钢板表面图喂进去,终端吐出 inclusion 0.826 (181,0,279,302),同时在 out1.jpg 上留下一个贴着缺陷外沿的红框。

02问题:钢板上的六种缺陷,人眼盯不住

冷轧带钢从轧机出来后要过质检工位。质检员要做的事,是在钢板扫过镜头的零点几秒里,看清上面有没有下面这六种毛病:

模型里叫中文长什么样
crazing裂纹细密的网状浅裂,像干涸的泥地
inclusion夹杂嵌进去的深色异物块
patches斑块一大片颜色不均匀
pitted_surface麻点密密麻麻的小坑,小到只有几十个像素
rolled-in_scale氧化铁皮压入压进去的暗色氧化层
scratches划痕一条一条的直线划伤
一张钢板表面缺陷图:画面中部有几道深色的竖向纹理,其余是灰色金属肌理
这就是模型真正拿到的东西(images/1.jpg):中间那几道深色竖向纹理,就是要被框出来的缺陷。人扫一眼就知道「这儿有毛病」,机器拿到的只是每个像素一个 0~255 的灰度数。

同一个画面,人和机器的处境完全不同:

对比人机器
靠什么眼睛看,大脑判一帧图 = 一张像素表,每格带一个 0~255 的灰度数
怎么知道「这儿有毛病」看一眼就知道这片纹理不对劲? —— 没有任何一条人写好的规则能描述「细密的网状裂纹」,谁来判?
怎么给出位置说一句「中间偏上有条划痕」? —— 要说多准?这个位置能不能直接交给机械臂去处理?
盯两小时之后开始漏、开始走神不会累,但换个场景就乱认(见 05 节的域偏移)

这就是这一章的分水岭。前三章(颜色、轮廓、二维码)的判断规则是人写的,你能一行行读懂它为什么这么判;从这一章起,判断权交给了一个训练出来的模型 —— 它更准、更通用,但你再也读不到「它凭什么觉得这里是夹杂」。

机器要顶替质检员,光「看清楚」不够,它得同时回答三件事。这三问就是下一节原理的骨架:

① 东西在哪。缺陷可能只有指甲盖大。机器手里只有一堆灰度数,凭什么在整张图里先框出「这一小块要单独看」?

② 框出一堆之后,留哪个?机器不会只给你一个答案:同一处缺陷它会给十几个框,分数还都不一样。凭什么留这个、删那个?

③ 框是在缩小的图上量的,怎么贴回原图?模型只吃固定尺寸的输入,框是在那张标准图上量出来的 —— 换算回你那张原图,凭什么不偏?

先别急着答这三问。第 03 节先把它跑起来,看一眼成品长什么样。

03现象:缺陷套红框:inclusion 0.826

🎬 现象速览 · 30 秒先看效果

你会看到:一张钢板表面图喂进去,零点几秒就出来了 —— 缺陷处套着红框,框上方标着类别和分数(inclusion 0.83);终端同步打印每条缺陷的类别 + 分数 + 左上右下坐标。

输入:一张钢板表面缺陷原图
① 原图
一堆灰度数
→
送进模型前:塞进 640 方框 方图:几乎不补 长条:上下补黑边 等比缩放,绝不拉扁 这一步在内存里,看不见
② 缩进 640 方框
等比缩放 + 补边
→
输出:同一张图上两处缺陷被红框套住,框上方标着 inclusion 0.83 与 inclusion 0.78
③ 红框 + 类别 + 分数
贴在原图坐标上
实际运行产出:左 = 喂进去的图,中 = 模型只认 640 的方图,所以中间必须过一道缩放(这步在内存里发生,截图看不到,这里画出示意),右 = out1.jpg 上的红框与 inclusion 标签 —— 框在原图坐标系里,说明中间那道缩放被正确地换算了回来。

三步跑出这个画面(先照做,每步为什么下一节拆讲):

① 进目录,确认模型和测试图都在。models/ 里放的是转好的 int8 模型,images/ 里是仓库自带的四张缺陷测试图。

cd ~/ros2_ws/src/xflz_eai_car1/vision/01_industrial_defect_detection
ls models/
ls images/
models/: defect_yolov8n_int8.rknn images/: 1.jpg 2.jpg 3.jpg 4.jpg

② 单图推理,一次把四张图都跑一遍。--model 指模型,--image 指输入图,--save 让它把带框的结果写盘,四张图的输出分别存成 out1.jpg~out4.jpg。远程 SSH 一定要带 --save:不带的话程序会开窗等你按 q,你在 SSH 里就是「死机」。

python src/app.py --model models/defect_yolov8n_int8.rknn --image images/1.jpg --save out1.jpg
python src/app.py --model models/defect_yolov8n_int8.rknn --image images/2.jpg --save out2.jpg
python src/app.py --model models/defect_yolov8n_int8.rknn --image images/3.jpg --save out3.jpg
python src/app.py --model models/defect_yolov8n_int8.rknn --image images/4.jpg --save out4.jpg
note: NEU-DET data.yaml 不在本机,使用内置 6 类名 ← 四条命令都会打印这一行,下面三条省略;中间还有 RKNN 的版本/模型信息,逐行滚过即可 fps=15.6 inclusion 0.826 (181,0,279,302) inclusion 0.763 (96,430,214,641) saved -> out1.jpg fps=16.6 crazing 0.385 (17,487,615,640) saved -> out2.jpg fps=15.8 inclusion 0.689 (422,1,589,636) inclusion 0.393 (393,420,495,639) saved -> out3.jpg fps=16.0 patches 0.848 (-1,439,276,639) patches 0.848 (367,69,550,449) patches 0.648 (-0,65,95,316) patches 0.441 (132,75,332,319) patches 0.348 (1,66,233,309) saved -> out4.jpg ← fps 每跑一次都有小浮动(这台板子上见过 15~17),不是性能抖动

生成的四个文件,打开就是下面四张 —— 四条命令、四类结果:

images/1.jpg 的输出 out1.jpg:两处深色竖向夹杂各被一个红框套住,标注 inclusion 0.83 与 inclusion 0.76
out1.jpg
两处夹杂,两个紧框
0.83 / 0.76
images/2.jpg 的输出 out2.jpg:整幅布满网状裂纹,只有一个巨大的红框盖住下半张图,标注 crazing 0.38
out2.jpg
一片网状裂纹
一个大框,0.38
images/3.jpg 的输出 out3.jpg:右侧一条亮带被两个红框各套一次,标注 inclusion 0.69 与 inclusion 0.39
out3.jpg
同一条亮带
被报了两次
images/4.jpg 的输出 out4.jpg:一片斑块上叠着五个红框,分数从 0.85 排到 0.35
out4.jpg
一片斑块
报了五个框

③ 开实时流,让框跟着工件动。要开两个终端:一号窗起相机驱动,二号窗跑 --ros 模式(两个终端的环境变量都要重来一遍,漏了就是互相看不见)。

终端一 · 起相机驱动

source /opt/ros/humble/setup.bash && source ~/ros2_ws/install/setup.bash
export ROS_DOMAIN_ID=N
ros2 launch ascamera hp60c.launch.py

终端二 · 跑实时检测

source /opt/ros/humble/setup.bash && source ~/ros2_ws/install/setup.bash
export ROS_DOMAIN_ID=N
cd ~/ros2_ws/src/xflz_eai_car1/vision/01_industrial_defect_detection
python src/app.py --model models/defect_yolov8n_int8.rknn --ros
frame 30 avg_fps=13.2 frame 60 avg_fps=13.0

然后在同一局域网的浏览器里打开(<板IP> 换成实验板的实际局域网地址):

http://<板IP>:8080/defect.mjpg
浏览器里出现滚动的画面,缺陷处叠着红框与类别标签

🔍 看什么:三样东西要对上 —— ① 框的位置贴不贴缺陷外沿;② 框旁的类别名是不是 crazing / inclusion / patches / pitted_surface / rolled-in_scale / scratches 六种之一;③ 后面的分数(0.83 表示模型有多确定)。这三样正好对应第 02 节的三问,下一节就拆它们的来历。

这就是本实验最终要呈现的现象。此时照做、先看懂它就行;第 05 节会带你走一遍「命令 → 函数 → 文件」的对账,亲手复现同一个画面 —— 跑出来、对得上,实操才算完成。

扩展 · 这四张图各认出了什么,还差哪三类

上面四张图不只是"跑通了",它们刚好把第 02 节那六类里的三类各演了一遍。把终端原文和类别对上,就能看出模型的强弱:

输出文件终端原文对应第 02 节这张图说明了什么
out1.jpg
images/1.jpg
inclusion 0.826 (181,0,279,302)
inclusion 0.763 (96,430,214,641)
夹杂
inclusion
两处互相独立的深色夹杂,各一个框、分数都高。这是「标准成功案例」
out2.jpg
images/2.jpg
crazing 0.385 (17,487,615,640) 裂纹
crazing
整幅都是网状裂纹,模型只能给一个几乎盖住下半张图的大框,分数只有 0.385 —— 弥漫性缺陷本来就框不住边界
out3.jpg
images/3.jpg
inclusion 0.689 (422,1,589,636)
inclusion 0.393 (393,420,495,639)
夹杂
inclusion
同一处亮带被报了两次(0.39 那个还嵌在 0.69 里面)—— 去重没把它们合成一个
out4.jpg
images/4.jpg
patches 0.848 (-1,439,276,639)
patches 0.848 (367,69,550,449)
patches 0.648 (-0,65,95,316)
patches 0.441 (132,75,332,319)
patches 0.348 (1,66,233,309)
斑块
patches
一片斑块报了五个框,分数从 0.85 一路排到 0.35 —— 框的数量和分数就是这么来的
🔍 四张图只认出三类,这很正常

四张测试图覆盖了 夹杂 / 裂纹 / 斑块 三类;第 02 节表里另外三类 —— 麻点 pitted_surface、氧化铁皮压入 rolled-in_scale、划痕 scratches —— 这四张里没有。不是模型不会认,是这四张图上就没有那三种缺陷。想看它们,得自己找图或用自己产线的数据重训(见第 05 节进阶 3)。

其中两张图里藏了两个「后面要用的现象」,先记住:

① 图 3 和图 4 都在同一处缺陷上报了多个框。为什么不是只报一个?这正是第 04 节步骤 3 要解决的事(先按分数筛、再按重叠去重),也是第 06 节那两个滑杆要你亲手拖的东西。

② 图 2 只给了 0.39 分,框还大得离谱。分数低不代表没有缺陷 —— 网状裂纹本来就布满整幅、没有清晰边界,模型只能"框个大概"。这是一条边界:弥漫性缺陷不适合用框来描述,第 08 节会再说它。

04原理:它是怎么找出这些缺陷的

CORE IDEA · 一句话原理

先用人工画好的框教会网络「什么样的纹理算哪类缺陷」,再把学会的模型压成 int8 塞进 NPU —— 这样它才能在板子上一边看图,一边吐出带分数的框。

打个比方:像师傅带徒弟 —— 徒弟先看几百张标好的样板(标注),练到手熟(训练),出门前把心得压缩成一页小抄(量化),上工后扫一眼就能指出「这儿有夹杂,八成把握」(推理)。哪里像:确实是靠「看样板学会的」,没人给它写过一条判断规则;哪里不像:徒弟能解释他为什么觉得像,模型不能 —— 它只给分数,理由读不出来。

这一节分两层。上层:这个模型是怎么来的(标注 → 训练 → 量化,你不必动手,但得知道每一步在干什么,否则「调参」就变成了瞎试);下层:它在板子上怎么做事(四步,这是第 02 节三问的答案)。先看全景,再往下钻。

PC 侧 · 有显卡,训练与转换在这里做 —— 本课不需要你动手 ① 数据标注 人工在每张缺陷图上画框、选类别 6 类 · YOLO txt 格式 每行:cls cx cy w h(归一化) 产物:labels/*.txt ② 训练 yolov8n 一轮轮看标注图 640 / 100 epoch / batch 8 猜错就调权重,反复 100 轮 产物:best.pt(权重) ③ 导出 + 量化 先导 ONNX(9 个输出张量) rknn-toolkit2 压成 int8 200 张 val 图当校准尺 产物:…int8.rknn 转换产物拷到板子上 板端 RK3576 · 6TOPS NPU —— 本课你动手的就是这两段 ④ 推理(在 NPU 上跑) letterbox → 换 RGB → 送进 NPU 拿到 9 个张量(80/40/20 各 3 个) DFL 解码 → 筛分数 → 逐类 NMS 产物:三五个候选框 ⑤ 输出(换算 + 画图) 减 pad、除 scale,换回原图坐标 画红框 + 写类别 + 写分数 一处缺陷一行、一框 产物:inclusion 0.826 (…) 前 3 段的产物都已随仓库交付 —— 你从第 ④ 段开始动手,第 05 节的对账表也是对着 ④⑤ 列的
技术框架全景。上半段是「模型怎么来的」,下半段是「模型在板子上怎么做事」;本课只在 ④⑤ 两段动手。
阶段这一步在干什么产物本课要求
① 数据标注人工在缺陷图上画框、标类别,把「人的判断」变成机器能读的数字labels/*.txt,每行 cls cx cy w h知道格式与三条注意事项即可
② 训练让网络反复看标注图、猜错就调权重,直到能自己猜对best.pt + PR 曲线 / 混淆矩阵知道参数怎么给、指标怎么看
③ 导出 + 量化先转成 ONNX,再压成 int8,让它在 NPU 上跑得动best.onnx → …int8.rknn知道为什么要压、怎么判断没压坏
④ 推理板子上把图喂进去,拿到几千个候选框并删到几个三五个框要能说清四步(下面步骤 1~4)
⑤ 输出把框换算回原图坐标,画出来给人看带框的图 + 终端那行字要能解释「形对位漂」

标注的框是谁画的?为什么必须这么标

模型不会自己知道「这段纹理叫裂纹」。它知道的全部,就是人给它画过的那些框 —— 标注是唯一的知识来源。一张钢板图上可能有 1 个框,也可能有 5 个,每个框配一个类别名。

训练时的标注可视化:八张钢板缺陷图上各画着红框与类别标签,能看出缺陷尺度差异很大
真实标注长这样(训练时按批可视化出来的):每张图上有人画的红框和类别名。注意缺陷大小差别极大 —— 这直接决定了后面得用三个尺度去扫

框存进 .txt 时不是像素坐标,而是四个 0~1 的小数:

字段含义拿一帧真结果反推一遍
cls类别序号(0 起)1 = inclusion(第 2 个类)
cx cy框中心的坐标 ÷ 图宽、图高框 (181,0,279,302) → 中心 (230,151) → 0.3594 0.2359
w h框的宽、高 ÷ 图宽、图高宽 98、高 302,图是 640×640 → 0.1531 0.4719

所以那一行标注就是 1 0.3594 0.2359 0.1531 0.4719。为什么非归一化不可?因为同一批图要缩到 640 再训练、也可能换成别的尺寸推理 —— 用 0~1 表示,换尺寸时不用重算;用像素表示,换尺寸就全错。

⚠ 标注的三条硬规矩 ① 必须归一化:混进像素坐标,训练会直接崩或者 mAP 趋近 0。仓库里 src/dataset.py 的 validate_dataset 专门检查这一点。
② 框要紧贴缺陷外沿:画松了模型学到的就是「大概这一片」;画紧了遇到同类缺陷又框不住 —— 一致性比精确更重要,同一类缺陷要保持同样的松紧。
③ 类别不能标错,也不能漏:标错的框比不标更糟,模型会把错误当真理学进去。所以工业数据集比通用数据集难标 —— 缺陷小、类间相似(裂纹和划痕就很容易混)。

模型是怎么被教出来的?训练参数给什么、结果怎么看

训练就是反复做一件事:看图 → 猜框和类别 → 跟标注比 → 猜错就调权重,重复 100 轮。参数在 config/defect_config.yaml 的 train 段,也封装在 scripts/train.sh 里。下面是配置内容,不用执行:

train:
  imgsz: 640
  epochs: 100
  batch: 8
  model: yolov8n.pt
参数本课取值它在管什么给大了会怎样
imgsz640训练输入边长。精度与速度的折中800 也能训,但 640²→800² 计算量增约 56%,板端帧率掉下来
epochs100把全部图看几遍太少欠拟合(框不准);太多过拟合(训练集好看、新图变差)
batch8一次喂几张图再更新权重显存不够会 OOM;GTX1060 这个级别建议 ≤8
modelyolov8n用哪个体量的网络(n = nano 最小)换 s/m 更准但更慢,板端可能跑不到实时

训练完看什么?不是看 loss 降没降,而是看三样:PR 曲线(每类的准召折中)、混淆矩阵(谁被认成了谁)、mAP50(一个总分)。下面两张都是这次训练的真实产物:

PR 曲线:六类缺陷各一条曲线,右上角标注各类 AP50,all classes 0.741 mAP@0.5
PR 曲线:越靠右上越好。pitted_surface 0.933 最好,crazing 只 0.408 —— 最难的就是网状裂纹
混淆矩阵:对角线为正确预测,crazing 只对 35 个、漏掉 48 个,inclusion 全部 100 个都认对
混淆矩阵:对角线越深越好。crazing 只认对 35 个、漏掉 48 个;inclusion 100 个全对
🔍 这两张图解释了第 03 节的怪现象

还记得 out2.jpg 里那片网状裂纹吗?模型只给了 0.385 分、框还盖住半张图。现在知道为什么了:训练侧 crazing 的 AP 只有 0.408、100 个样本漏了 48 个 —— 它不是这一帧没发挥好,是这一类本来就最难。反过来看 inclusion:混淆矩阵里 100 个全对、PR 曲线 AP 0.708,所以 out1.jpg 能给到 0.83。

指标的口径要交代清楚:随仓库交付的权重在 val 集上评到 P=0.657 / R=0.706 / mAP50=0.732;上面 PR 曲线图上印的是 0.741。两个数不必相等 —— 一个是交付权重的评测结果,一个是训练过程里那一轮的曲线,不同轮次、不同划分都会差一点。所以指标永远要连着「哪份权重、哪份数据」一起说。

为什么要压成 int8?压完怎么知道没压坏

为什么要压:训练出来的 best.pt 里每个参数是 32 位浮点。这样的模型在 PC 上跑没问题,但板子上的 NPU 是按 int8 设计的 —— 同样 6TOPS,int8 的吞吐最高。压成 int8 后模型体积、带宽都降到约四分之一,这才跑得出第 03 节看到的 13 FPS。

量化 = 把「无限精细的数」装进「256 个格子」 float32:每个数占 4 字节,取值几乎连续 -3.21 +4.78 模型权重的实际取值范围(换一批数据会变) 按这个范围算尺子:scale =(最大值 − 最小值)÷ 255 这一步要用真实图跑一遍才能定 —— 所以需要「校准集」 int8:每个数占 1 字节,只能落在 256 个格子上 -128 +127 误差最多半个格子 → 精度掉一点点,速度、体积赢一大截
量化的全部秘密就一张图:找一个能罩住所有真实数值的范围,把它切成 256 格。范围定得准不准,取决于校准集。

怎么压:三步,一条命令。yolo export 先把 best.pt 导成 ONNX(9 个输出张量),再让 rknn-toolkit2 在 200 张 val 图上跑一遍定出那把尺子(这一步叫校准),最后落成 .rknn。校准集为什么不能随便找图?因为尺子是照这些图的数值范围定的 —— 拿办公桌的照片去校准,尺子就量不准钢板的数值,模型当场变傻。

怎么知道没压坏:拿同一份 val 数据,分别用 ONNX 和 RKNN 各评一次 mAP50,比差值。工程判据是 掉点 ≤ 3%:

比什么怎么量判据
精度掉了多少src/eval_map.py 加 --backend onnx / --backend rknn 各跑一次,看两个 mAP50差值 ≤ 3% 算合格(对照基准 0.732,即不低于约 0.702)
掉点超标怎么办先查校准集:是不是用了和真实画面差太远的图、数量够不够加大 --calib-count、校准图改用 val 集;必要时 --no-quantize 先定位是不是量化本身的问题
模型的物理表现看文件大小与 fpsint8 模型应明显更小、更快;只小不快的,通常不是量化问题而是没走 NPU

回到板子上:一帧图要走六次形态变化

上面三段是「模型怎么来的」。现在看它在板子上怎么做事 —— 这才是第 02 节三问的答案,也是第 05 节对账表对应的那四步。按第 02 节的三问走:① 东西在哪(步骤 1、2)→ ② 框出一堆,留哪个(步骤 3)→ ③ 怎么贴回原图(步骤 4)。

① 原图(尺寸随意) 长条 1600×200 也行,方图也行 cv2.imread() / cap.read() ② 640×640 的 RGB 张量 等比缩放 + 上下补边 + 换通道 letterbox() → to_rgb() ③ 9 个原始张量 80×80 40×40 20×20 3 个尺度 × 3 个张量 RKNNLite.inference() ④ 几千个候选框 同一处缺陷被圈了十几次 dfl_decode() · box_process() ⑤ 筛完分、去完重 只剩三五个,一处缺陷一个框 decode_rknn() · classwise_nms() ⑥ 回到原图坐标 inclusion 0.826 减 pad、除 scale,再画上去 unletterbox() · draw() 六次形态变化:①② 准备输入 · ③④ 猜 · ⑤ 删 · ⑥ 换算并画 格子里的函数名对应源码文件,第 05 节有逐行对账
一帧钢板图在检测器里的完整旅程。六格 = 数据形态变了六次;①② 属于步骤 1,③④ 属于步骤 2,⑤ 属于步骤 3,⑥ 属于步骤 4。

1640 的方图、RGB 顺序 —— 它凭什么只认这个格式?

先猜一下:把一张 1600×200 的长条钢板照片,直接拉成 640×640 的方图喂进去,行不行?

不行。网络学的是「这个形状像划痕」,一拉,细长的划痕就被横向压扁了 —— 形状一变它就不认识了。所以做法是等比缩放:按两条边里更「挤」的那条算比例,缩完把空出来的地方补上,凑满 640 的方框。这套动作叫 letterbox。

直接拉成 640×640
形状被改了

1600×200 → 横向压到 1/2.5、纵向拉 3.2 倍。
一道细长划痕变成一条胖带子,网络认不出来。

→
等比缩放 + 补边
形状一点没变

scale = min(640/h, 640/w) = 0.4 → 缩放后 640×80,上下各补 280 像素。
划痕还是那道细长划痕。

补边用的颜色也不是随便填的 —— 配置里 pad_color: [0, 0, 0] 是纯黑。为什么不是训练时常用的 114 灰?因为这块板子上的模型是 int8 量化的:填充值会真的进卷积计算,填充值一变,边缘那圈特征的分布就跟着偏,框就整体错位。转换、校准、推理三处的填充值必须是同一个数。

还有两个「必须」,答案都在转换脚本里那几行:

下面是 src/convert_rknn.py 里 rknn.config(...) 的节选(已省略中间几行参数),这是源码片段,不用执行:

rknn.config(
    mean_values=[[0, 0, 0]], std_values=[[255, 255, 255]],   # 归一化在这里做,Python 侧不要再除 255
    quant_img_RGB2BGR=False,                                 # 输入已为 RGB,别再转一次色域
    target_platform=target, single_core_mode=True, model_pruning=True,
)

为什么必须 RGB:quant_img_RGB2BGR=False 表示转换器不做通道交换 —— 因为导出模型时输入约定就是 RGB。所以推理侧必须先 to_rgb() 把 OpenCV 读进来的 BGR 换成 RGB。两侧约定不一致的典型症状是「框的位置对、类别全错、分数普遍很低」 —— 形状信息还在,颜色信息全乱了。

为什么不能自己除 255:std_values=[[255,255,255]] 说明这个归一化是 RKNN 运行时内部做的。你在 Python 里再除一次,输入就被压到 0~1,而模型以为收到的是 0~255 —— 分数会整体塌掉。

2几万个像素里,它凭什么先找到「这儿有东西」?

先猜一下:它是不是先找边缘、再把边缘连成块,像第 02 节的多边形轮廓那样?

不是。它不做任何显式的「找边缘」。它把 640 的图切三种粗细的格子:80×80、40×40、20×20,总共 80²+40²+20² = 8400 个位置;每个位置都自己报一个答案 ——「我这块里有个东西,中心在这儿、宽高这么大」,外加一个「我有多确定」。

为什么要三种粗细?因为缺陷的尺度差得远:麻点只有几十个像素,20×20 的格子根本盖不住它;一整片斑块可能占半张图,80×80 的细格子又「看不全」。三个尺度各管一段。

每个位置报出来的数,就是图里那 9 个张量(3 个尺度 × 3 个张量):

张量形状装的是什么
box(1, 64, h, w)框的原始分布:4 条边 × 16 个 bin。不是直接给偏移量,而是给 16 个格子的概率
cls(1, 6, h, w)6 类缺陷各自的分数(已经过 sigmoid)
cls_sum(1, 1, h, w)分数之和,可忽略的分支

为什么框要给「16 个 bin 的概率」这么绕?这叫 DFL:与其让网络猜一个精确数字,不如让它对 16 个候选值各表个态,再按概率加权求和 —— dfl_decode() 干的就是这件事。好处是边界模糊的目标(细纹理、麻点)也能给出连续、稳定的框,而不是在两个整数之间来回跳。

这段「16 bin 加权」是留在 CPU 上算的,不放进模型图里。原因很实际:RKNN 转换器对动态索引、切片拼合这类算子支持得不好,硬塞进去要么转不过去、要么效率很低。

3同一处缺陷被圈了十几次,凭什么只留一个?

先猜一下:8400 个位置各报一个框,其中真正压在这处缺陷上的大概有多少个?

好几十个。所以解码完你手里是几千个框,绝大部分是垃圾。清理分两步,顺序不能反:

第一步,按分数卡一刀。分数低于 score_thr(默认 0.25)的框直接丢掉。这一步便宜、粗暴,先把大头去掉。

第二步,按重叠去重。两个框重叠多少,用 IoU 量:完全重合是 1,完全不沾是 0。做法是逐类做 NMS —— 同一类里按分数排队,最高的留下,跟它 IoU 超过 iou_thr(默认 0.45)的同类框全删掉。

为什么强调「逐类」?因为不同类的框允许重叠:一片 patches 里完全可能套着一条 scratches。如果跨类一起去重,两个真缺陷会被合成一个,而且你还看不出是哪里丢的。

这两个参数一动,画面立刻变脸 —— 这也是第 06 节要你亲手拖的东西:iou_thr 调到 0.30,挨得近的两处真缺陷(比如两条平行划痕)会被当成同一个,只剩一个框;调到 0.70,同一处缺陷又会冒出两三个重复框。

4框是在小图上量的,怎么贴回原图还对得准?

先猜一下:既然只是缩了个比例,把坐标乘回去是不是就完事了?

差一步。缩放之前还补了边,补边把内容整体推到了画面中间,所以换算得先「减掉补边」再「除比例」:

原图坐标 = (640 图上的坐标 − pad) ÷ scale

拿一张 1600×200 的图算一遍:scale = min(640/200, 640/1600) = 0.4,缩放后是 640×80,上下各补 pad_y = (640−80)/2 = 280。假设模型在 640 图上给出 y1 = 300,那么原图上 y1 = (300 − 280) ÷ 0.4 = 50。

如果忘了减那 280 呢?算出来是 300 ÷ 0.4 = 750,比正确答案大了 700 像素。而且所有框会朝同一个方向、偏同样的距离 —— 这种「系统性平移」一眼就能认出来,它是本实验第一大坑。

🔍 一个容易搞混的分工 检测器不负责画图。它吐出来的只是「类别 + 分数 + 四个数」,红框是 draw() 后面用 cv2.rectangle 画的。所以画面上「框错了」有两种可能:模型算错了,或者我们画的时候坐标换错了。先分清这两件事,排查能省一半时间 —— 形状对、位置整体漂,基本都是后者。

这四步在代码里分别是谁

这张表只看第三列和第五列就够了 —— 左两列是「哪一步、屏幕上看到什么」,右两列是「谁在干、动了会怎样」。

步画面/数据变成了什么谁在干(函数 · 文件)关键参数动了会怎样
步骤 1
①②
任意尺寸的图 → 640×640 的方图,空出来的地方补黑边,形状一点不变 letterbox()
to_rgb()
(src/preprocess.py)
infer.imgsz: 640
infer.pad_color: [0,0,0]
填充值改成 114(训练默认)→ 与量化时不一致,框整体偏移;不补边直接拉伸 → 细长划痕被压扁,直接漏检
步骤 1
②
方图 → 4 维数组 (1,640,640,3),通道从 BGR 换成 RGB np.newaxis(补维)
(src/infer.py)
无,写死在代码里 漏掉换通道 → 位置对、类别全错、分数普遍很低;自己再除 255 → 分数整体塌陷
步骤 2
③
640 的图 → 9 个原始张量:80/40/20 三个尺度各出 3 个 RKNNLite.inference()
(src/infer.py)
rknn.target_platform: rk3576(转换期定死) 输入漏成 3 维 → 报 need 4dims input;换官方 ultralytics 导的 ONNX(不是 9 输出)→ 解码对不上
步骤 2
④
9 个张量 → 几千个候选框,同一处缺陷被圈十几次 dfl_decode() · box_process()
(src/postprocess.py)
无(16 bin 与 stride 固定) 跳过 DFL 直接取整 → 细纹理缺陷的框会抖、会缩,边缘糊的目标框不准
步骤 3
⑤
几千个 → 三五个,一处缺陷一个框 decode_rknn() · classwise_nms()
(src/postprocess.py)
infer.score_thr: 0.25
infer.iou_thr: 0.45
score_thr 调到 0.60 → 细小划痕成片消失(漏检↑);iou_thr 调到 0.30 → 挨得近的两处真缺陷被合成一个
步骤 4
⑥
框换回原图坐标,画上红框与 inclusion 0.826 unletterbox()(src/preprocess.py)
draw()(src/app.py)
无(pad 与 scale 由 letterbox 带回) 少减 pad → 系统性平移(1600×200 的图上会漂 700 像素);pad 加减反了 → 往反方向漂

先认识 6 个词

这六个词后面每一步都会出现,花两分钟认全,之后就不用反复查了。前三个属于「模型怎么来的」,后三个属于「它在板子上怎么做事」。

归一化标注 normalized label

把框写成四个 0~1 的小数(中心 x/y + 宽高),而不是像素。为什么需要:同一批图要缩到 640 训练、也可能换尺寸推理,用比例表示就不用重算。什么时候出问题:混进像素坐标,训练会崩或 mAP 趋近 0。

mAP50 平均精度

把每类的 PR 曲线下的面积(AP)平均起来,一个总分。为什么需要:单看准确率会被"什么都不报"骗过去。什么时候不可比:不同数据集、不同划分算出来的 mAP 不能横着比。

校准集 calibration set

量化时用来"定尺子"的一批真实图(本课 200 张 val)。为什么需要:int8 的取值格子按这批图的数值范围来切。什么时候失效:校准图与真实画面差太远,尺子就量偏,掉点会超标。

置信度 score

模型有多确定「这里是划痕」,0.83 表示它挺有把握。用来排序和筛选。什么时候不可信:这只是模型自己的把握,不等于真的对 —— 对着桌面拍,它照样理直气壮地乱圈。

IoU 交并比

两个框重叠了多少:交集面积 ÷ 并集面积,完全重合是 1。NMS 删谁全靠它。什么时候失灵:两处真缺陷挨得很近时,它们之间的 IoU 也很高,会被当成同一个。

逐类 NMS 非极大值抑制

按分数排队,留最高的,把跟它 IoU 超阈值的同类框删掉。为什么需要:8400 个位置各报一个框,不去重没人看得懂。什么时候失效:它只删同类框 —— 所以跨类重叠是允许的,这正是「逐类」的意义。

letterbox 等比补边

等比缩放到目标尺寸,空出来的地方补边,保证形状不变。为什么需要:直接拉伸会让网络认不出被压扁的目标。什么时候出错:填充值或补边量与转换、校准两侧不一致,框就整体漂。

YOLOv8 这个名字是什么意思,9 个输出又是怎么来的选读 2 分钟

YOLO 是 "You Only Look Once",只看一眼。早期做检测要在同一张图上扫很多遍,YOLO 只看一遍就能给出所有框 —— 所以它快,能实时。

v8 是第八代,n 是 nano,最小号。这次用的 yolov8n 参数最少、最快,之所以选它,就是因为要在板子的 NPU 上跑到实时。

相对上一代,它的关键变化是四点:去掉预置锚框(anchor-free,中心点落在哪个格子就由哪个格子负责)、C2f 模块替代 C3、分类与回归解耦成两个分支、以及用 DFL 做框回归。主干 CSPDarknet 出 80/40/20 三个尺度的特征图,PAN-FPN 把它们融合,所以最后是 3 个检测层 × 3 个张量 = 9 个输出。

YOLOv8 整体结构图:左侧 Backbone(CSPDarknet,P1~P5 五级下采样),中间 Neck(PAN-FPN 自顶向下与自底向上融合),右侧 Head(三个尺度的解耦检测头,输出 Bbox 与 Cls)
YOLOv8 的三段:Backbone 抽特征 → Neck 把三个尺度融在一起 → Head 在每个尺度上分别输出框与类别。9 个输出就来自最右边那三个尺度的检测头

这张图不影响你跑通、也不影响你调参。等你哪天要自己训模型、或者要换更小的模型时,再回来细看。

这套方法什么时候不管用选读 1 分钟,决定你能不能当手册用

什么时候会失效,怎么救:

  • 换光照、换材质、换产线:模型的判断是从训练数据里长出来的,照明一变,同一处缺陷的像素分布就变了。救法是用新场景的数据重训,没有别的捷径 —— 调阈值不行,这不是阈值问题。
  • 缺陷小到只有十几个像素:20×20 那层的格子一个就覆盖 32 个原图像素,太小的目标在特征图上直接消失。救法是提高 imgsz(代价是 FPS 掉)或者把大图切块分别检测。
  • int8 量化后掉点超过 3%:先看校准集。校准图必须贴近真实产线画面 —— 用错场景的图去校准,等于拿错尺子量。加大 --calib-count 或改用 val 集作校准。
  • 两个缺陷贴得极近:逐类 NMS 会把它们合成一个。这是 NMS 的固有代价,只能调 iou_thr 折中。

现在回头看第 02 节那张图:中间那几道深色竖纹之所以能被框住、还标对了 inclusion,走的就是这四步 —— 先把长条图等比缩进 640 的方框(步骤 1),让网络在 8400 个位置上各报一个框(步骤 2),按分数卡一刀、再按 IoU 去重(步骤 3),最后把四个数减掉补边、除以比例,换算回原图(步骤 4)。第 02 节的三个问号,到这里都填上了。

05实操:单图、实时、换后端三条命令

全部在板子上执行。三个步骤从简单到复杂,复制、粘贴、回车就行。每条命令后面都标了「跑完看到什么算对」。

  1. 进目录,先确认环境没坏约 3 分钟

    先站到正确的位置,跑一遍单元测试确认依赖齐了(仓库自带 42 个用例;没有数据集的机器上,数据集那几条会自动跳过),再看一眼推理段的关键参数。

    cd ~/ros2_ws/src/xflz_eai_car1/vision/01_industrial_defect_detection
    python -m pytest -v
    42 passed ← 全绿就说明 OpenCV / numpy / rknnlite 都到位了

    然后用一条命令把 infer 段读出来 —— 这四个数是本章最常改的东西:

    PYTHONPATH=./src python -c "from config import load_config; cfg = load_config('config/defect_config.yaml'); print(cfg['infer'])"
    {'imgsz': 640, 'score_thr': 0.25, 'iou_thr': 0.45, 'pad_color': [0, 0, 0]}

    配置加载带契约式校验:infer 段缺了某个键,加载时就直接抛 ConfigError,而不是等推理时静默出错。想亲眼看看,把 yaml 里的 infer 段整段删掉再跑一次这条命令就知道了。

  2. 单图推理,先看到红框约 10 秒

    --model 指模型文件,--image 指输入图,--save 把带框结果写盘。第一次跑会看到一行 note: NEU-DET data.yaml 不在本机,使用内置 6 类名 —— 板上没有训练数据集,程序内置了这 6 个类名兜底,属正常现象。

    python src/app.py --model models/defect_yolov8n_int8.rknn --image images/1.jpg --save out1.jpg
    fps=15.6 inclusion 0.826 (181,0,279,302) inclusion 0.763 (96,430,214,641) saved -> out1.jpg

    终端每一行就是一处缺陷:类别 + 分数 + 左上右下坐标。打开 out1.jpg,红框应该贴着缺陷外沿。另外三张图跑出来有什么区别,第 03 节的扩展块里已经逐条列过。

    接着把同一个文件换成 .onnx 跑一次 —— 程序按扩展名自动切后端:.rknn 走 NPU,.onnx 走 CPU。这一步是第 7 条命令,也是下一节进阶的入口。

    python src/app.py --model train/weights/best.onnx --image images/1.jpg --save out_onnx.jpg
    saved -> out_onnx.jpg ← 同一张图、同样的框,但 fps 明显掉下来(具体多少看你机器的 CPU)
  3. 开实时流,让框跟着工件动约 5 分钟

    要开两个终端。source 两句把 ROS2 系统环境与本工作区环境装进当前终端,缺任何一条都找不到相机包;export ROS_DOMAIN_ID=N 把节点通信频道拨到 N(0~101 任选一个,每台设备各选各的,别都设成同一个值)。

    终端一 · 起相机驱动

    source /opt/ros/humble/setup.bash && source ~/ros2_ws/install/setup.bash
    export ROS_DOMAIN_ID=N
    ros2 launch ascamera hp60c.launch.py
    日志持续滚动,无 ERROR / 无红字

    二号窗跑检测。source 两句和 DOMAIN_ID 都要重来一遍 —— 这是新手最容易漏的地方,漏了就是两边互相看不见,程序一直等首帧。

    终端二 · 跑实时检测

    source /opt/ros/humble/setup.bash && source ~/ros2_ws/install/setup.bash
    export ROS_DOMAIN_ID=N
    cd ~/ros2_ws/src/xflz_eai_car1/vision/01_industrial_defect_detection
    python src/app.py --model models/defect_yolov8n_int8.rknn --ros
    frame 30 avg_fps=13.2 frame 60 avg_fps=13.0 ← 每 30 帧打一次平均帧率,板端稳定在 13 FPS 上下

    同一局域网内用浏览器打开下面这个地址(<板IP> 换成板子的局域网地址,不是 127.0.0.1):

    http://<板IP>:8080/defect.mjpg
    浏览器里出现滚动画面,把带缺陷的样板在镜头前慢慢移动,红框跟着一起动

    13 FPS 是这块板子的真实水平:一个在 PC 上训出来的模型,被压成 int8、塞进 6TOPS 的 NPU,还能一秒看 13 帧 —— 这已经够实时了。

🔁 现象呼应 这三步跑完出现的画面,就是第 03 节「🎬 现象速览」里的那个:缺陷被红框套住、类别与分数标对、out1.jpg 已存盘。两处对上了,说明代码框架、流程、步骤你已完整走通。

命令 → 函数 → 文件:你敲的每一行到底经过了谁

跑通之后要能回答一个问题:这个参数属于链上哪一段,改它会影响哪一格的画面?下面这张表就是这段链条的「户口本」,四个步骤里的每个函数都能在这里找到出处。

命令 / 参数经过的函数(文件)产出 / 影响链上哪一段
--model …​.rknn_make_backend()(src/app.py)→ RKNNBackend(src/infer.py)选后端:.rknn 走 NPU,.onnx 自动落到 CPU 后端 —— 决定步骤 2 那格的执行位置
--image / --video / --camera / --rosrun_frame()(src/app.py);--ros 走 Ros2Camera.read()(src/ros2_source.py)决定第一步「原图」从哪来。--ros 时帧来自话题 /ascamera_hp60c/camera_publisher/rgb0/image
--save out1.jpgdraw()(src/app.py)→ 写盘步骤 4 之后那一步:把框真正画到像素上。不带它就会开窗等你按 q
infer.imgsz: 640letterbox()(src/preprocess.py)步骤 1:缩放比例与补边量都由它算出来。改了它,训练时的 imgsz 也必须跟着改
infer.pad_color: [0,0,0]letterbox()(src/preprocess.py)步骤 1 的补边值,同时参与步骤 4 的反换算。改成 114 → 边缘特征分布偏移,框整体错位
infer.score_thr: 0.25decode_rknn()(src/postprocess.py)步骤 3 第一刀:低于它的框直接丢。往低调捡回弱小缺陷也带回假框,往高调反过来
infer.iou_thr: 0.45classwise_nms()(src/postprocess.py)步骤 3 第二刀:同类框重叠超过它就删。调到 0.30,挨得近的两处真缺陷被合成一个
(写死在代码里,无参数)to_rgb()、np.newaxis(src/preprocess.py / src/infer.py)步骤 1 尾段:换通道顺序、补第 4 维。这两步没法用配置关掉
(写死在代码里,无参数)dfl_decode()、box_process()(src/postprocess.py)步骤 2 尾段:把 9 个张量解成候选框
(写死在代码里,无参数)unletterbox()(src/preprocess.py)步骤 4:减 pad、除 scale,换回原图坐标

看不到的环节也要交代清楚:NPU 内部那 9 个张量是怎么算出来的,源码里是看不到的 —— 那是 .rknn 模型里的指令,我们只能拿到它的输出。所以步骤 2 的前半段在这个仓库里没有对应函数,别去找。

你该看到的画面

知道「正常」长什么样,才知道「异常」是哪一处出的问题。

单图 · 正常
框贴外沿,标签清楚

红框贴着缺陷的边界,框上方一行 inclusion 0.83。数字与终端打印的那三件套一致。

实时 · 正常
框稳定跟随

移动工件时框一起动、不闪。抖动明显多半是画面太暗或离焦,不是模型坏了。

异常 · 满屏乱框
对着桌面 / 手

这是正常的。模型只认识钢板上的六类缺陷,遇到没学过的画面会硬套 —— 这叫域偏移,不是 bug。

重点与易错点

按「我看到的症状」找对应行。左边是你遇到的现象,右边是该做的事。

你看到的多半是什么原因怎么办
框的位置整体偏移,但形状是对的填充值与转换/校准侧不一致,或反换算时少减了 pad查 padding:部署侧保持 pad_color: [0,0,0],转换侧 quant_img_RGB2BGR=False
框的位置对,但类别全错、分数都很低颜色通道反了 —— BGR 喂给了要 RGB 的模型确认 to_rgb() 被调用;转换侧不要开 RGB2BGR
报 need 4dims input静态 shape 模型要 4 维 NHWC,你给了 3 维用 np.newaxis 补成 (1,H,W,3)。跑 app.py 不会遇到,手写推理才会
报 core mode not supportedcore_mask 传了 None显式传 core_mask=0(自动选核);2.3.2 上传 None 会直接报错
浏览器打不开 :8080/defect.mjpg端口被别的实验占了(实验 1~7 共用 8080)先关掉占用 8080 的进程;IP 用板子的局域网地址,不是 127.0.0.1
--ros 跑起来一直黑屏相机驱动没起,或两个终端的 ROS_DOMAIN_ID 不一样先起相机;两个终端写同一个 N;话题不要 remap
程序不动,SSH 里像死机了--image 模式没加 --save,程序开窗等你按 q带 --save out1.jpg;已经卡住的按 Ctrl+C 退出
远程一行命令报 python: command not found非交互 shell 不读 .bashrc,ros2_venv 没被激活改用 bash -lc "…",或写全路径 /home/iflytek/ros2_venv/bin/python;交互式登录后敲命令不受影响
画面上出现一堆问号方块OpenCV 的 putText 不支持中文窗口里的字统一用英文,这不是乱码 bug
💡 排查口诀先看模型,再看输入,最后看输出。① 模型文件在不在、是不是 9 输出;② 送进去的是不是 640 的 RGB、有没有多除一次 255;③ 出来的框坐标在不在原图范围内。三步走完,九成的坑都能定位。

进阶与深读

基础三步跑通之后再看这里。做不完不影响成绩,做了有加分。

进阶 1 · 用 onnx 和 rknn 各评一次分,看量化掉了多少约 30 分钟

同一个模型有两种格式:.onnx 是浮点原版,准但慢;.rknn 是 int8 量化版,快但会掉一点精度。src/eval_map.py 的 --backend 参数可以在两者之间切换,跑两次就能量出「为了快牺牲了多少准」。

python src/eval_map.py --backend rknn --model models/defect_yolov8n_int8.rknn --data <NEU-DET 数据集目录>
python src/eval_map.py --backend onnx --model train/weights/best.onnx --data <NEU-DET 数据集目录>
逐类 AP50 表格 mAP50 = 0.7xx ← 两次的差就是量化损失,验收线是 ≤ 3%

随仓库交付的权重在 val 集上是 P=0.657 / R=0.706 / mAP50=0.732。超过 3% 掉点,先查校准集:是不是用了跟真实画面差太远的图、数量够不够(--calib-count 默认 200)。

这两个分数只能在「同一份权重、同一份数据、同一段评测代码」下互比。拿它去跟别人的模型比谁强没有意义 —— 不同数据集算出来的 mAP 不可比。

进阶 2 · 把检测结果变成质检报表约 40 分钟

在 src/app.py 的主循环里,把每帧的检测结果按类别累加,每隔一批帧打印一次统计:

scratches: 12 处
inclusion: 3 处
patches: 1 处

这就是产线上「缺陷统计 + 质检报告生成」的形态。做法是找到返回检测结果列表的那一行,在它后面加累加逻辑,别动别的地方。

再往前走一步:把 Detector.detect() 返回的框转成 ROS2 的检测消息发出去,机械臂分拣或报警灯就能直接接 —— 这是第 11、12 章要干的事。

进阶 3 · 想自己训练一个模型约 3 小时,需要 PC 加显卡

完整链路是:准备数据集 → PC 上训练 → 导出 ONNX → 转成 RKNN → 拷到板子上跑。四个入口都在仓库里:scripts/train.sh、scripts/export_onnx.sh、src/convert_rknn.py、src/eval_map.py。

三个硬性前提,缺一不可:

  • 必须用仓库里那个改过的 ultralytics(RKNN 优化 fork,format='rknn')。官方版导出的 ONNX 不是 9 输出,转到 RKNN 就解不出来。
  • 标注必须是 YOLO 归一化格式(类别 cx cy w h,全是 0~1 之间的小数)。写成像素坐标训练「能跑完」,但 mAP 会趋近 0 —— 这种失败最难查,因为它不报错。
  • 量化校准图要用 val 集、贴近真实产线画面,格式 jpg/png/bmp、绝对路径、不带中文。

这次的模型已经交付在 models/ 里,不重训也能完成全部实验内容。

代码复盘:让 AI 带你把这套代码读明白

这一节不逐行讲代码。给你三组提示词,由你自己指挥 AI 产出三张图,再拿真源码去对 —— 图对不对,反过来证明你读没读懂。

🧪 Vibe Coding · 自己把这段代码读明白约 20 分钟

你要产出三样:模块职责表 → 控制流流程图 → 时序图。提示词已给全,直接复制去问 AI。

下面三个块是给 AI 的提示词,不是终端命令 —— 复制到对话框里用。

提示词 1 · 模块职责分析

请阅读这几个文件:src/app.py、src/infer.py、src/preprocess.py、
src/postprocess.py、src/ros2_source.py、config/defect_config.yaml。
这是一个基于 RKNN 的 YOLOv8 工业缺陷检测项目。
请输出一张模块职责表,回答三个问题:
1)每个文件负责哪一件事,用一句话说清;
2)谁调用谁,从入口 src/app.py 往下画出调用关系;
3)每个可调参数(imgsz、score_thr、iou_thr、pad_color)
   分别由哪个函数读取。
输出格式:markdown 表格,四列是「模块 / 职责一句话 / 被谁调用 /
关键函数」,每条职责后面用 文件名:行号 标注出处。

提示词 2 · 控制流流程图

请为 src/app.py 的主循环画一张控制流流程图,按「处理一帧」为单位描述。
要求:
1)入口是拿到一帧,出口是画完框并输出(存盘 / 推流 / 显示);
2)必须画出所有判断分支,包括读帧失败、以及没有检测到任何目标;
3)每个框里写真实函数名(letterbox、to_rgb、inference、
   dfl_decode、classwise_nms、unletterbox、draw),
   不要写「预处理模块」这类空词;
4)用 mermaid 的 flowchart 语法输出。

提示词 3 · 时序图

请为「ROS2 相机实时检测」这条链路画一张时序图,参与者包括:
相机驱动节点、Ros2Camera(订阅者)、Detector、
RKNNLite(NPU)、MjpegServer。
要求:
1)按时间顺序列出至少 5 次交互,从相机发布图像开始,
   到浏览器拿到一帧结束;
2)标出真实话题名与回调函数名(_on_image、read、detect、inference);
3)标出单帧耗时或频率的量级(板端约 13 FPS,单帧约 77 毫秒);
4)用 mermaid 的 sequenceDiagram 语法输出。

自查三问:模块表里每一条都能指到真实的 文件:行号?流程图有分支、有退出条件?时序图里的参与者是真实的类名与话题名,而不是「用户 / 系统」这种空词?

再拿 AI 的版本和真源码对一遍,下面这五处差异几乎是必然出现的 —— 差异处就是设计决策:

差异点AI 版常见写法工程版(本仓)为什么工程这样写
填充值pad=114(ultralytics 训练默认)pad_color: [0,0,0](config/defect_config.yaml → letterbox())填充值会参与卷积计算,必须与量化、校准时的取值一致,否则边缘特征偏移、框整体错位
归一化代码里写 img / 255.0不除。归一化交给 rknn.config(mean_values=0, std_values=255)(src/convert_rknn.py)量化模型内部已含 scale,Python 侧再除一次会把输入压到 0~1,分数整体塌陷
NMS 放在哪直接 model.predict(),后处理在库里DFL 解码与逐类 NMS 全在 src/postprocess.py,模型只出 9 个原始张量RKNN 转换器对动态索引、切片拼合支持差,后处理留在 CPU 更稳
通道顺序cv2.imread 读完直接喂显式 to_rgb()(src/preprocess.py)+ 转换侧 quant_img_RGB2BGR=False两侧约定必须一致;不一致的症状是「框准、认错」
NPU 初始化init_runtime() 不传参init_runtime(core_mask=0)(src/infer.py)2.3.2 上 core_mask 传 None 直接报 core mode not supported

最后一个动作:改一处、先预测再验证。把 score_thr 从 0.25 改成 0.60,先在纸上写下你认为会发生什么,再跑一次对答案。猜错了最有价值 —— 那说明你对第 04 节步骤 3 的理解还差一层。

06互动:拖 score_thr,看漏检误检怎么拉扯

📌 这一节是「掌握检查」:亲手改一个参数,看现象是否朝你预期的方向变,还要能说出为什么 —— 说得出,才算掌握。

不改代码,只改两个数字,就能亲手感受「漏检」和「误检」是怎么互相拉扯的。先看它们在哪 —— config/defect_config.yaml 的 infer 段,下面是配置内容,不用执行:

infer:
  imgsz: 640
  score_thr: 0.25
  iou_thr: 0.45
  pad_color: [0, 0, 0]

改完保存,重跑一次 python src/app.py --model models/defect_yolov8n_int8.rknn --image images/1.jpg --save out1.jpg 就能看到变化。下面这个实验台把同一帧的七种情况摆在一起,你先在页面上拖一遍,再去板子上验证。

⚡ 参数实验台 · 两个阈值怎么把框筛掉
0.25
0.45
模拟一帧:上排 4 处真缺陷,下排 3 个低分假框 scra .87 patc .71 patc .66 incl .44 pitt .19 假 .13 假 .09 假 .06 阈值 0.25 → 保留 3 个框
默认 0.25:三处明显缺陷被框住,最弱的麻点被滤掉。

两种改法,后果正好相反:

改成什么你会看到什么时候该这么改
score_thr: 0.05
往低调
框变多。漏掉的弱小缺陷被抓回来,但同时冒出一堆假框质检要求「一条都不能漏」的时候。宁可多报,让人工复核
score_thr: 0.60
往高调
框变少。留下的都很确定,但细小的划痕消失了误报太多、产线老是误停线的时候。宁可少报,也得报得准
iou_thr: 0.30
往低调
去重更狠:那一对重叠的 patches 框被合成一个,但挨得近的两处真缺陷也可能一起被合掉一般不用动。0.45 是工程上试出来的默认值

你刚才发现的:漏检和误检的代价是反着来的 —— 一个降下去,另一个就升上来。这叫召回和精确的权衡,任何检测器都躲不开。

所以在产线上真正要问的不是「哪个参数最好」,而是:漏掉一个缺陷和误报一个缺陷,哪个代价更大?想清楚这个,参数就定了。

这一节的结论:阈值不是「调准」的,是「按代价选的」—— score_thr 管漏多少,iou_thr 管合多少,两者都得对着产线的代价表来定,而不是对着截图调到好看。

07验证:四道小测

✅ 目标对账 · 验证掌握得有多好

回页首对照「🎯 实验目标」逐类自检 —— 四类都要对上题,缺一项就回对应小节补完再交卷:

目标类别页首那一条说的是对应题
原理说出四步各在干什么、两个阈值各卡在哪一步Q2
实践跑通两条链路,读出「类别 + 分数 + 坐标」三件套Q1
应用说清接在产线哪一环、阈值按什么代价定Q4
重难点框歪了不一定是模型的错(形对位漂先查换算)Q2

另外 Q3 考的是边界:这套模型什么时候不管用。四题全 ✓,这一章就完成了。

Q1你把 score_thr 从 0.25 调到 0.70,最可能发生什么?
检测框变多,能抓到更多细小的麻点
框的位置会整体往右下偏移
检测框变少,细小的划痕与麻点容易漏掉
对。score_thr 是「及格线」,调高等于要求模型更有把握才敢报。代价是信心不足的弱小缺陷被整批过滤掉,漏检上升。框位置偏移跟这个参数无关 —— 那是补边量与反换算的事。
Q2检测框形状是对的,但整体往一个方向偏了几百像素。最该先怀疑哪一处?
相机没对焦,画面糊了
补边量 pad 前后对不上,或反换算时忘了减它
模型训练轮数不够
对。「形对位漂」几乎一定是缩放链路的问题:模型给的是 640 坐标系里的四个数,要按 (坐标 − pad) ÷ scale 换回原图。忘了减 pad,所有框会朝同一方向偏同样的距离 —— 系统性偏移,一看就露馅。回看第 04 节步骤 4 那道 1600×200 的算例。
Q3你把相机对着自己的手,画面上冒出一堆乱框。这说明什么?
模型坏了,必须重新转换一次 RKNN
相机驱动有问题,重新 launch 一次就好
模型只学过钢板上的六类缺陷,没见过手 —— 属正常现象
对。这叫域偏移:模型的能力边界就是它的训练数据。它只见过热轧带钢的六类缺陷,遇到没学过的画面只能硬套,于是圈出一堆莫名其妙的东西。想让它在你的场景好用,得用那个场景的数据重新训练 —— 调阈值救不回来。
Q4产线反馈:误报太多,几乎每天都要因为「明明没缺陷」停一次线。按这一页的判断,先动哪个参数、往哪调?
把 score_thr 往上调 —— 宁可少报,也别让假框把线停了
把 iou_thr 往上调 —— 让去重更宽松,多留下一些框
把 imgsz 调大 —— 图更清楚,误报自然就少了
对。误报多 = 假框被放过 = 及格线太低,所以动 score_thr 往上调。代价是漏检上升 —— 但这正是「阈值按代价选」:这条线最怕停线,那就接受偶尔漏报,让下游人工复核兜住。iou_thr 管的是同一处缺陷报几次,不管真假;imgsz 是输入尺寸,跟误报没有直接关系。反过来,如果这条线「一条都不能漏」,就该往低调、配人工复核 —— 两个方向的代价,你得先问清楚产线怕哪一个。

08章节总结:缩进去、算一遍、挑最像的、缩回来

这页讲了什么,它们怎么串起来

概念它解决了什么和别的关系
归一化标注
cls cx cy w h
把「人画的框」变成机器能读的数字;用 0~1 比例表示,换输入尺寸不用重算整条链的最上游 —— 没有标注就没有模型;格式错了训练直接崩,后面全部白做
训练
yolov8n / 640 / 100 epoch
让网络从标注里长出判断力,而不是靠人写规则吃标注,吐 best.pt;参数里的 imgsz 必须与推理侧 infer.imgsz 一致
int8 量化 + 校准集把 32 位浮点压成 1 字节,体积带宽降到约 1/4,才跑得出 13 FPS吃 best.pt → ONNX → .rknn;校准集不准 → 掉点超标;判据是 mAP50 掉 ≤3%
mAP50 / PR 曲线 / 混淆矩阵回答「这个模型到底行不行」;也能解释某类为什么分数低(crazing AP 0.408)量化前后各评一次,差值就是量化损失;指标必须连着「哪份权重、哪份数据」一起说
letterbox
等比缩放 + 补边
模型只吃 640 的方图,而照片任意尺寸;直接拉伸会把细长的划痕压扁,形状一变网络就不认上游是原图;下游所有框坐标都活在 640 坐标系里,必须由它再换回来
RGB 顺序 + 不归一化让 Python 侧的输入与量化模型内部的 scale 对齐由转换侧 quant_img_RGB2BGR=False、std_values=255 定死;弄反了就是「框准、认错」或「分数塌陷」
9 个原始张量 + DFL80×80 抓麻点、40×40 中等、20×20 抓大片斑块;16 bin 概率加权让边界模糊的目标也稳吃 640 的 RGB 张量;吐出几千个候选框,交给筛分与 NMS
score_thr
置信度阈值
先粗暴卡一刀,把不可能的都丢掉在 NMS 之前;和 EXP-01 的 HSV 色彩范围本质是同一件事 —— 多少算通过
IoU + 逐类 NMS同一处缺陷被圈十几次,按分数排队、留最高的、删重叠的同类框吃筛分后的框;iou_thr 调到 0.30 会误删挨得近的两处真缺陷
unletterbox
(坐标 − pad) ÷ scale
把 640 图上的坐标换回原图,框才贴得住缺陷外沿是 letterbox 的逆运算;单一地漏减 pad,就是系统性平移
draw
画框(不是模型干的)
检测器只吐「类别 + 分数 + 四个数」,红框是后面代码画的所以「框错了」有两种可能:模型算错,或我们画的时候换错 —— 先分清这个
⚠ 三个反直觉的地方 ① 直觉是框歪了就是模型不准,实际是检测器根本不画图 —— 形状对、位置整体漂,多半是补边量前后没对上、反换算少减了 pad,查这条链路比查模型快得多。
② 直觉是缩到 640 就是把图拉成 640×640,实际是等比缩放再补边,因为网络学的是「这个形状像划痕」,一拉扁形状就变了,它反而认不出。
③ 直觉是阈值调高更保险、报的都是真的,实际是score_thr 一高,信心不足的细小划痕直接被滤掉 —— 漏检和误检的代价反着走,产线上要问的不是「哪个值最好」,而是「漏一个和误报一个,哪个更贵」。
💡 三条要注意 ① score_thr: 0.25 是 0~1 的分数不是 25%;imgsz: 640 是输入的边长不是面积;pad 的单位是像素,在 640 图上算、往原图上减 —— 三个量的单位混了,偏移就会莫名其妙。
② 填充值 pad_color 在转换、校准、推理三处必须是同一个数;实验 1~7 共用 8080 端口,不能同时跑;iou_thr 调到 0.30 会把挨得近的两处真缺陷合成一个。
③ onnx 与 rknn 的 mAP50 差要 ≤ 3% 才算量化合格,而这两个分数只能在同一份权重、同一份数据下互比;单图模式必须带 --save,否则 SSH 里就是「假死」。

前置依赖 · 缺一样就跑不起来

类型具体项缺了会怎样 / 怎么补
环境实时模式要 ROS2:source /opt/ros/humble/setup.bash + source ~/ros2_ws/install/setup.bash + export ROS_DOMAIN_ID=N,两个终端都要重来一遍只 source 第一个 → 找不到 ascamera 包;两个终端 N 不一致 → 互相看不见,--ros 一直等首帧
Python 库板载 ros2_venv 里的 rknnlite 2.3.2、onnxruntime、OpenCV、NumPy、PyYAML缺 rknnlite → .rknn 直接加载失败;缺 onnxruntime → 换后端那条命令跑不起来
模型 / 资源models/defect_yolov8n_int8.rknn(int8 单核 + 剪枝,200 张 val 校准);train/weights/best.onnx(9 输出,换后端对比用);测试图 images/1.jpg~4.jpg看不到这些,说明目录不对或文件没拷全。板上不需要 NEU-DET 数据集 —— app.py 内置了 6 类名兜底,看到 note: 那行属正常
配置config/defect_config.yaml 的 infer 段:imgsz: 640、score_thr: 0.25、iou_thr: 0.45、pad_color: [0,0,0]缺键 → 加载时直接抛 ConfigError(契约式校验);imgsz 与训练的 640 不一致 → 框全乱且不报错
硬件 / 前置实验6TOPS NPU 才有约 13 FPS;实时模式要板载 HP60C 相机 + ros2 launch ascamera hp60c.launch.py;前置 EXP-01~03 的「阈值思维」没相机 → 只能跑单图/视频,--ros 无输入;没读前三章也能跑通,但会少了「多少分算通过」这个直觉

这套代码用在哪儿

  • 钢材质检工位:产线相机逐帧送进去,每条结果给「类别 + 置信度 + 左上右下坐标」,质检员照着复核。score_thr 按代价定:要「一条都不能漏」就用低阈值配人工复核,误报多到停线就调高。
  • 缺陷统计与质检报表:在主循环里按类别累加,周期性打印 scratches: 12 处 / inclusion: 3 处,直接生成班次报表 —— 这是产线上真正在跑的形态。
  • 分拣与报警联动:把检测框转成 ROS2 检测消息发出去,机械臂按类别分拣、或点亮报警灯。第 11、12 章的体感控制会用到同一套「视觉输出喂给控制」的模式。
  • 换场景就是换权重:它对桌面、对手只会硬套(域偏移)。想用在别的产线,得走完「数据集 → PC 训练 → 导出 ONNX → 转 RKNN」四步 —— 三个入口脚本都在仓库里。

下一章换个角度:如果模型认出的东西太细(矿泉水瓶、香蕉皮、废电池),而垃圾桶只分四类,中间这层「翻译」谁来做?

核心记忆点:进和出必须互逆 —— 缩进去时补了多少边,缩回来就得减多少。框的位置不对,先查这条链路,再怀疑模型。