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

垃圾分类:20 个细类变成 4 个桶

上一章模型只认钢板上那六类缺陷。这一章换成日常垃圾:模型认得出 20 个细类(水瓶、电池、胡萝卜…),可垃圾桶只有 4 个。中间这层「翻译」该谁来做 —— 这就是本页最重要的一课:哪些该交给模型,哪些该写成代码。

难度 ⭐⭐ · 4 学时 · 设计性 YOLOv5 检测头 锚框解码 两级标签映射 多线程推理池
01 颜色02 轮廓03 二维码04 检测05 分类06 分割07 跟踪08 人脸09 骨骼10 3D11 体感12 手势

01目标:每行读懂「大类·细类·分数」三段

🎯 实验目标 · 学完你将能
  • 说出一条链八段里哪一段在 NPU 上、其余各是谁在干;以及 20 个细类是怎么变成 4 个桶的
  • 用单图与实时两条命令各跑一次,读出画面上的三段字「大类 细类 分数」并说清每段是哪来的
  • 调出线程数最优点:把 --tpes 从 1 拉到 6 再拉到 8,说清帧率为什么先涨、涨到几个线程就停、再往上为什么反而掉
你会给它
一张垃圾照片

images/1.jpg~4.jpg:一帧里可能有 1 个、也可能有 4 个目标,混着放也行

→
你会拿到
recyclable yilaguan 0.85

每个目标一个红框,框上方三段字:该扔哪个桶 + 它到底是什么 + 模型多确定

⚠️ 重点与难点 重点:YOLOv5 检测头解码(锚框 + 目标置信度 × 类别分数)与「细类 → 大类」的两级映射。
难点:直觉是「模型直接告诉你这该扔哪个桶」,实际是模型只会说 20 个细类名(还都是拼音),「桶」是我们查一张表得来的 —— 这一步在 Python 里,模型里没有。分清这条线,你才知道新增一类垃圾时该改哪里:改表就行,不用碰模型。
另有一处工程坑:多线程时每个线程必须有自己的 RKNNLite 实例,共用一个会直接崩。
本实验的目标:一帧里的每个垃圾都被框出来,旁边写着 recyclable yilaguan 0.85 —— 第一段是查表得的,第二段是模型认的,第三段是模型给的把握。

02问题:模型认得出瓶子,可它不知道该扔哪个桶

教室后面一袋垃圾倒出来:矿泉水瓶、易拉罐、胡萝卜、电池、药片包装、砖块碎。要按四个桶分开。人靠常识,机器只有像素 —— 而且它连「桶」这个概念都没有。

更要紧的是:这一章跟前几章有个根本区别。颜色、轮廓、二维码都是「给一个答案」,而一袋垃圾一帧里常常有好几个东西。纯分类网络(ResNet 那类)对整张图只输出一个标签 —— 四个垃圾它只能说一个。所以这里必须用检测网络。

对比纯分类网络本实验用的检测头
输出什么整张图一个标签每个目标一个框 + 一个类别,可以同时多个
能不能说「在哪」不能,只知道图里有能,给出左上右下坐标
一帧四个垃圾只能说一个,剩下三个丢了四个各报一次(images/1.jpg 就是这样)
谁更适合分拣分拣要抓取,必须知道位置这个 —— 机械臂要的是坐标

模型本身认的是 20 个细类,名字全是拼音(训练时就用的拼音,不是英文也不是中文)。而桶只有 4 个。下面这张对照表就是全部业务规则:

ID细类该扔ID细类该扔
0bailuobo厨余垃圾10shuiping可回收物
1caiye厨余垃圾11tizi厨余垃圾
2chuangkotie有害垃圾12tudo厨余垃圾
3cipian其他垃圾13yaogao有害垃圾
4dianchi有害垃圾14yaopian有害垃圾
5eluanshi其他垃圾15yaopianbaozhuang有害垃圾
6fanqie厨余垃圾16yaoping有害垃圾
7huluobo厨余垃圾17yilaguan可回收物
8jiaonang有害垃圾18zhibei可回收物
9lajiao厨余垃圾19zhuankuai其他垃圾

机器要顶替人,光「认得出」不够,它得同时回答三件事。这三问就是下一节原理的骨架:

① 一帧里有四个垃圾,它凭什么每个都报一遍?纯分类只能吐一个答案。检测网络是「逐个格子报」的 —— 可怎么保证四个都被报出来、又不重不漏?

② 它说 shuiping,谁把这句话翻成「可回收」?模型心里没有桶的概念。这层翻译放在模型里、还是放在代码里,会决定你以后加一种新垃圾时要不要重训模型。

③ 十来帧每秒够不够用?板上只有一个 NPU,一个线程喂不饱它。想更快,能靠堆线程吗?堆到几个才不亏?

先别急着答。第 03 节先把四张图跑一遍,四种桶全看一眼。

03现象:三段输出:recyclable yilaguan 0.85

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

你会看到:每个垃圾被一个红框套住,框上方一行三段字 —— recyclable yilaguan 0.85:该扔哪个桶 / 它是什么 / 模型多确定。四条命令跑完,四个桶全出现过。

三条步骤(第 2 步一次跑四张图):

① 进目录,确认模型和测试图在。

cd ~/ros2_ws/src/xflz_eai_car1/vision/02_garbage_classification
ls models/
ls images/
models/: garbage_yolov5_20cls.rknn images/: 1.jpg 2.jpg 3.jpg 4.jpg

② 单图推理,一次把四张图都跑一遍。四张图的输出分别存成 out1.jpg~out4.jpg。注意:终端只会打印 saved -> out1.jpg 这一行 —— 认出了什么、在哪、多少分,全都画在图上。这一点和上一章的缺陷检测不同,别在终端里找坐标。

python src/app.py --model models/garbage_yolov5_20cls.rknn --image images/1.jpg --save out1.jpg
python src/app.py --model models/garbage_yolov5_20cls.rknn --image images/2.jpg --save out2.jpg
python src/app.py --model models/garbage_yolov5_20cls.rknn --image images/3.jpg --save out3.jpg
python src/app.py --model models/garbage_yolov5_20cls.rknn --image images/4.jpg --save out4.jpg
saved -> out1.jpg saved -> out2.jpg saved -> out3.jpg saved -> out4.jpg ← 就这四行。逐目标信息不打印,全在图上(见下面四张) ← 还会看到 RKNN 的版本/模型信息与一条 dynamic_shapes 警告,属正常,不影响结果

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

images/1.jpg 的输出:四个目标各一个红框,标 kitchen_waste huluobo 0.89、kitchen_waste huluobo 0.86、other_waste zhuankuai 0.86、other_waste zhuankuai 0.59
out1.jpg
一帧四个目标
厨余 + 其他
images/2.jpg 的输出:两个目标,标 recyclable yilaguan 0.85 与 hazardous_waste dianchi 0.76
out2.jpg
易拉罐 + 电池
可回收 + 有害
images/3.jpg 的输出:两个目标,标 hazardous_waste yaopianbaozhuang 0.79 与 recyclable shuiping 0.66
out3.jpg
药片包装 + 水瓶
有害 + 可回收
images/4.jpg 的输出:两个目标都标 hazardous_waste yaoping,分数 0.92 与 0.85
out4.jpg
两个药瓶
都是有害

③ 开实时流,让框跟着手走。要开两个终端:一号窗起相机驱动,二号窗跑 --ros。实时模式下终端会打印帧率,这是第 06 节的入口。

终端一 · 起相机驱动

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/02_garbage_classification
python src/app.py --ros
30帧平均帧率: 15.x 30帧平均帧率: 16.x ← 每 30 帧打一次,落在 15~18 区间(默认 tpes=2)。验收门是总平均 ≥ 15 FPS

浏览器打开(<板IP> 换成板子的局域网地址):

http://<板IP>:8080/garbage_cam.mjpg
浏览器里出现滚动画面,把垃圾逐个放到镜头前,红框与三段字跟着动

🔍 看什么:四样东西 —— ① 一帧里有几个目标,是不是每个都被框了;② 三段字里哪一段是桶、哪一段是拼音;③ 分数高的是不是看得更清的那个(out1.jpg 里 0.89 的胡萝卜比 0.59 的碎块更清楚);④ 同一张图里有没有两个框套住同一个东西。

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

扩展 · 四张图逐条对照,看四种桶怎么凑齐

这四张图不是随手拍的:它们刚好把四个桶各演了一遍。下面把每张图上框里的字原样抄下来(这些字在图上,不在终端),再对回第 02 节那张表 ——

输出文件图上写着(原样)对应第 02 节看出来什么
out1.jpg
images/1.jpg
kitchen_waste huluobo 0.89
kitchen_waste huluobo 0.86
other_waste zhuankuai 0.86
other_waste zhuankuai 0.59
厨余 + 其他 一帧四个目标,逐个报了出来 —— 纯分类网络在这里只会给一个答案。分数最低的 0.59 也正好是形状最不规则的那块碎料;两块砖块读数分别是 0.86 和 0.59,说明同一类的两个目标各自独立判定,谁也没被合并掉
out2.jpg
images/2.jpg
recyclable yilaguan 0.85
hazardous_waste dianchi 0.76
可回收 + 有害 易拉罐和电池长得完全不像,模型分开认对了 —— 而且落在两个不同的桶上,正好证明大类是逐目标判的,不是整图一个
out3.jpg
images/3.jpg
hazardous_waste yaopianbaozhuang 0.79
recyclable shuiping 0.66
有害 + 可回收 药片包装和塑料水瓶看起来都是「塑料」,桶却不同 —— 这就是查表的价值:外观相似的东西可以进不同的桶,规则写在表里
out4.jpg
images/4.jpg
hazardous_waste yaoping 0.92
hazardous_waste yaoping 0.85
有害 ×2 两个药瓶都认成 yaoping、都归有害,分数 0.92 与 0.85 略有差别 —— 各自算各自的
🔍 四张图凑齐了四个桶

可回收(yilaguan / shuiping)、厨余(huluobo)、有害(dianchi / yaoping / yaopianbaozhuang)、其他(zhuankuai)—— 四种桶全出现过了。20 个细类里其余的没拍到,但走的都是同一条链,只是那张表里指向不同的桶。

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

① out1.jpg 一帧报了四个框。四个框是怎么「同时」产生的?不是模型看了四遍,是它在每个格子上都猜了一次,猜中的才留下 —— 这是第 04 节步骤 2 要拆的。

② 四张图里没有出现「同一个东西被两个框套住」。这是好事,说明去重生效了;但去重调过头就会出现反例 —— 两块贴在一起的砖块会被合成一个。调过头会怎样,第 06 节让你亲手试。

04原理:一条链八段,只有一段在 NPU 里

CORE IDEA · 一句话原理

模型在 NPU 里只干一件事:把 640 的方图变成 3 组各带 75 个数的张量。剩下的 —— 把数还原成框、筛分数、去重、把细类翻成桶、换算回原图、画框推流 —— 全是我们自己写的代码。

打个比方:模型像一个只负责「报菜名」的师傅,一口气喊出「三号桌有个水瓶,八成五把握」;至于水瓶该收回哪一格柜子、要不要复核,是前台照柜位表安排的。哪里像:师傅确实只报他认识的菜名(20 个细类),从不提「柜子」;哪里不像:师傅喊的是位置和把握,不是文字 —— 那些数字得我们自己解码才读得懂。

这一节先看整条链的八段(哪段在板子的 NPU 里、哪段在 Python 里),再按第 02 节的三问往里钻:① 一帧多个怎么逐个报(步骤 2)→ ② 拼音怎么变桶(步骤 3)→ ③ 怎么更快(步骤 4)。步骤 1 是三者共同的前置:得先把图摆成模型认识的样子。

一条链八段:紫 = 在 NPU 里(模型) 青 = 在 Python 里(我们写的代码) ① 取到一帧 任意尺寸、BGR 顺序 cv2.imread / camera ② 摆成 640 方图 等比补黑边 + 换 RGB letterbox · to_rgb ③ NPU 推理 出 3 组张量,各 75 个数 backend.predict() ④ 还原成框 靠锚框把偏移算成坐标 decode_scale() ⑤ 筛分数 + 去重 两道阈值 + 逐类 NMS classwise_nms() ⑥ 换回原图坐标 减补边、除缩放比 unletterbox() ⑦ 细类 → 桶 查一行映射表 map_categories() ⑧ 画框 + 推流 写三段字、MJPEG make_process_fn 八段里只有第 ③ 段在 NPU 上跑 —— 连「把 75 个数还原成框」都是我们自己算的 想改「什么算一个桶」→ 改第 ⑦ 段那张表,完全不用碰模型 想改「它认得出什么」→ 才需要重训模型。这就是两者的分界线
端到端八段。模型只占一段,其余七段是代码 —— 这条分界线决定了你以后改需求时该动哪里。

1送进去之前,为什么必须补黑边、还得换颜色顺序?

先猜一下:把 1080p 的照片直接喂进去,行不行?

不行,模型只认 640×640。做法是等比缩放 + 补边,和上一章一样。但这页有个更严的细节:补边必须是纯黑(0)。模型训练时用的就是黑色填充 —— 推理时填成灰的,那圈灰边会真的参与特征计算,边缘就容易冒出假框。

第二个细节是颜色顺序。OpenCV 读进来是 BGR,而这个模型训练时用的是 RGB(转换时定了 rgb2bgr=False),喂之前必须换一次。弄反了不会报错,症状是「框的位置全对、类别全错」—— 模型看到的「红」其实是蓝。

直接 resize 成 640×640
形状被拉变形

宽高各自缩放,比例不同。圆的水瓶变椭圆,细长的电池变胖 —— 形状一变就不认识。

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

算一个统一比例缩放,空出来的地方补 0。补边量要记下来 —— 第 ⑥ 段要靠它把框换回原图。

2一帧四个垃圾,它凭什么每个都报一遍?

先猜一下:它是先找出所有物体、再一个个认,还是先猜一堆框再看框里是什么?

都不是。它是在每个格子上都猜一次:把 640 的图切三种粗细的格子 —— 80×80、40×40、20×20,每个格子先摆 3 个「预设框」(叫 anchor,是从训练数据里统计出来的常见长宽比),然后对每个「格子 × 预设框」都报一套数:框要怎么调、这里有没有东西、是 20 类里的哪一类。

所以第 ③ 段出来的三个张量,每个都是 [1, 75, h, w]:75 = 3 个锚框 ×(4 个框参数 + 1 个目标置信度 + 20 类分数)。

解码 = 锚框(先验)+ 网络猜的偏移(修正) 80×80 的网格(一格 = 8 像素) ① 每格摆 3 个锚框 小的管小目标,大的管整片 9 组锚框分给 3 个尺度, 每尺度 3 组 ② 网络只猜「怎么调」 中心往哪挪(压进本格子内) 宽高放大多少倍 ③ 锚框 + 偏移 = 最终框 + = 最终框 ④ 分数是两道相乘 目标置信度(这里有没有东西) × 类别分数(是 20 类里哪一类) 两道都过 score_thr 才留下 再按类去重(逐类 NMS)
锚框给「大概长什么样」,网络只负责「往哪挪、放大多少」。这比从零猜一个绝对坐标稳得多 —— 也是 anchor-based 方法的全部理由。

那「四个目标」是怎么出来的?就是四个地方各自猜中了。一帧里 80²+40²+20² = 8400 个位置、每个 3 个锚框,总共两万多次猜测,压在水瓶上的那几个报出了高分 —— 它从来不是「看四遍」,是一次性对全图下注。

为什么还要去重?因为压在一个物体上的不止一个格子,同一处会被报好几次,分数还都不一样。于是两道过滤:先按 score_thr=0.25 扔掉低分的,再按 nms_thr=0.45 逐类去重 —— 同一类里重叠超过 45% 的,只留分数最高的。注意是「逐类」:跨类不抑制,因为一个水瓶完全可能同时出现在「可回收」和「其他」两个候选里,让业务侧去裁决。

3它说 shuiping,谁把这句话翻成「可回收」?

先猜一下:这个映射是模型里学出来的,还是外面查表查的?

外面查表。模型输出的只有 0~19 的编号:编号靠 config/labels.txt 翻成拼音名;至于「这个编号该扔哪个桶」,全靠 config/category_map.txt —— 一个每行一个数字的文本文件,行号对应细类编号,值是 1~4 的桶号。

这正是本页最重要的一课:模型的原子能力是「认 20 个细类」,业务的消费单位是「4 个桶」,两者之间用一张表解耦。好处很实在 ——

某个桶的规则变了
改表,不动模型

政策调整(某类改判有害),只要 category_map.txt 改一个数字,明天就能上线。

同一外观、不同桶
表里写就行

药片包装和塑料水瓶都是「塑料」,但一个有害一个可回收 —— 这种事模型学不出来,只能人定。

想从严管有害垃圾
按桶设阈值

大类为「有害」的目标可以把 score_thr 调低 —— 宁可多报。这属于业务策略,也在代码层。

表错一行会怎样?全错。因为映射是按行号对齐的:category_map.txt 第 11 行对应 labels.txt 第 11 行的 shuiping。中间插一行,后面全体错位 —— 水瓶会被判成有害。这是本节最容易踩、也最难查的坑:不报错,只是全错。

4只有一个 NPU,怎么让它从十来帧跑到二十帧以上?

先猜一下:开两个线程,帧率能翻倍吗?

不能翻倍,但确实会明显涨。原因有两个,容易被混淆:一是 NPU 不止一路并行能力,单线程时用不满;二是「等 NPU」和「干别的活」可以重叠 —— 一个线程在 NPU 上推理时,另一个线程正好在取帧、洗数据、画框、推流。后者往往比前者贡献更大,这也是为什么最优线程数会超过核数。

代码里用生产者 — 消费者实现:主线程把帧丢进队列,--tpes N 个工作线程各自从队列取帧、各自推理,结果按序取回。三条硬约束:

约束为什么违反了会怎样
每线程独立实例RKNNLite 的推理上下文绑定 NPU 核,不是线程安全的共用一个实例 → 段错误或结果串台。代码里每个线程各自建一份
线程数超过最优点会掉NPU 并行能力与内存都有限,超出的线程只能排队,还要付上下文切换拖到 8 线程反而比 6 线程慢(第 06 节亲手验证)
核分配用 auto手动轮转在这块板上会把两个线程分到同一个核配置里已注明:round_robin 会卡死,别用
🔍 一个容易搞混的分工 模型不负责画框、也不负责说「桶」。它只吐两万多个数字。你在画面上看到的一切 —— 框的位置、三段字、颜色、推流 —— 都是第 ④~⑧ 段用 Python 做出来的。所以「结果不对」要先分清:是模型认错了(③ 段),还是我们解码 / 查表 / 换算错了(④~⑧ 段)。前者要换模型,后者改代码就能修。

这四步在代码里分别是谁

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

步画面/数据变成了什么谁在干(函数 · 文件)关键参数动了会怎样
步骤 1
①②
任意尺寸 BGR → 640×640 方图(补黑边)→ 通道换成 RGB letterbox() · to_rgb()
(src/preprocess.py)
infer.imgsz: 640
infer.preprocess: letterbox
补边改成灰 → 边缘出现假框;漏掉换 RGB → 框对、类别全错
步骤 2
③④⑤
3 组 [1,75,h,w] 张量 → 两万多个候选 → 去重后剩几个框 decode_scale() · decode_rknn_outputs() · classwise_nms()(src/postprocess.py) score_thr: 0.25 nms_thr: 0.45
sigmoid: False
anchors.txt
score_thr 调高 → 模糊目标漏掉;nms_thr 调低 → 挨得近的两个真目标被合成一个;anchors 与导出不一致 → 框整体偏移;sigmoid 开关错 → 分数全 0 或全接近 1
步骤 3
⑦
细类编号 → (桶号, 桶英文名),同时保留拼音名用来标注 map_categories()(src/postprocess.py)
config/category_map.txt
该表每行一个 1~4 的数字,行序 = 细类编号 表中间插一行 → 后面全体错位(水瓶被判成有害),且不报错
步骤 4
③⑧
单线程串行 → N 个线程轮流占 NPU、并把取帧与后处理重叠起来 RKNNPool._worker(src/rknnpool.py)
make_process_fn(src/app.py)
pool.tpes: 2
pool.core_mask: auto
server.port: 8080
tpes 超过最优点(本板约 6)→ 不涨反降;core_mask 改 round_robin → 卡死;stream_name 加 .mjpg → 浏览器 404

先认识 6 个词

这六个词后面每一步都会出现。前四个属于「模型怎么说」,后两个属于「我们怎么接」。

检测头 head

网络最后那段,把特征变成「框 + 类别」。为什么需要:一帧多个目标时,纯分类只能给一个答案。什么时候不够用:需要像素级轮廓时要换分割(第 6 章)。

锚框 anchor

事先摆好的「常见长宽比」参考框,网络只猜相对它怎么调。为什么需要:从零猜绝对坐标太难,猜「往右挪一点、放大 1.2 倍」容易得多。什么时候出错:锚框和模型导出时不匹配 → 框普遍偏移。

目标置信度 obj

「这个格子里到底有没有东西」,0~1。为什么需要:绝大多数格子是空白,先判有无能砍掉大量无用框。什么时候会被骗:背景纹理像目标时它会虚高,所以要再乘类别分数。

逐类 NMS

同一类里按分数排队,重叠超阈值的只留最高分。为什么是「逐类」:不同类允许重叠,跨类抑制会误删真目标。什么时候失效:两个同类目标贴得极近,会被合成一个。

两级标签

模型认 20 个细类,业务只用 4 个大类,中间靠一张表。为什么需要:模型换不了那么勤,业务规则却常变。什么时候出事:表行序和细类编号错位 —— 全错且不报错。

推理池 pool

多个线程各持一份模型实例,轮流占 NPU 并让取帧/后处理与推理重叠。为什么需要:单线程喂不饱 NPU。什么时候白干:线程数超过最优点,只剩排队与内存开销。

为什么不干脆用纯分类网络?选读 1 分钟

纯分类(ResNet 那类)更小更快,很多场景也够用。但分拣不行:分拣臂要抓取,必须知道「东西在画面哪个位置」,纯分类给不出坐标。

还有个更实际的原因:一帧里常常不止一个目标。纯分类面对四个垃圾只能吐一个标签,剩下三个直接丢了 —— 你没法让机械臂「抓那个没被报出来的」。

代价是检测头更重:本实验的模型每帧要算两万多次。所以才需要第 06 节那个线程池。

这套方法什么时候不管用选读 1 分钟,决定你能不能当手册用
  • 细类不在那 20 个里:模型只认识训练过的 20 种。拿个「纸杯」给它,它会硬塞进最像的一类,而不会说「我不认识」。要加就得重训。
  • 物体太小或太远:80×80 那层一格就是 8 个像素,太小的目标在特征图上直接消失。
  • 严重遮挡、叠在一起:逐类 NMS 会把贴在一起的两个同类目标合成一个 —— 一摞药瓶很可能只报出一个。
  • 只想知道「这一片有没有脏东西」:那是分割的活(第 6 章),用框反而粗糙。

现在回头看第 02 节那一袋垃圾:四个垃圾之所以能各报一次,是因为检测头在每个格子上都猜了一次(步骤 2);shuiping 之所以能变成「可回收」,是因为第 ⑦ 段查了一张表(步骤 3);十来帧之所以能涨到二十帧以上,是因为线程池把 NPU 与后处理重叠了起来(步骤 4)。三问都填上了。

05实操:单图、实时、线程数三条命令

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

  1. 进目录,跑单测,看清两张表约 4 分钟

    先站到正确的位置,跑一遍单元测试确认依赖齐了(仓库自带 62 个用例,全部用假后端跑,不加载真权重),再把两张表打印出来 —— 这两张表就是本实验的全部业务规则。

    cd ~/ros2_ws/src/xflz_eai_car1/vision/02_garbage_classification
    python -m pytest tests/ -v
    62 passed ← 全绿说明配置加载、预处理、解码、NMS、映射、线程池都能跑

    然后看两张表:labels.txt 是 20 个细类(行号 = 类别编号),category_map.txt 每行一个 1~4 的数字,行序跟 labels 严格对齐。

    cat config/labels.txt
    cat config/category_map.txt
    bailuobo caiye chuangkotie ← 20 行,全是拼音;category_map 也是 20 行,每行一个 1~4

    对齐这件事亲手验一次最牢:两个文件并排看,第 11 行的细类是什么(shuiping)、第 11 行的数字是几(1)、按第 02 节那张表该扔哪个桶(可回收)。三处对上,你就掌握了这页的核心。

  2. 单图推理,一次跑四张图约 10 秒

    --model 指模型,--image 指输入图,--save 把带框结果写盘。远程 SSH 一定要带 --save:不带会开窗等你按键。

    python src/app.py --model models/garbage_yolov5_20cls.rknn --image images/1.jpg --save out1.jpg
    python src/app.py --model models/garbage_yolov5_20cls.rknn --image images/2.jpg --save out2.jpg
    python src/app.py --model models/garbage_yolov5_20cls.rknn --image images/3.jpg --save out3.jpg
    python src/app.py --model models/garbage_yolov5_20cls.rknn --image images/4.jpg --save out4.jpg
    saved -> out1.jpg saved -> out2.jpg saved -> out3.jpg saved -> out4.jpg

    打开 out1.jpg,四行字的格式是固定的:{大类} {细类} {分数} —— 大类在前(分拣指令看这个),细类在中间(调试定位看这个),分数在后(质检门控看这个)。这四个文件就是第 03 节那四张图。

  3. 实时流,再对比线程数约 8 分钟

    要开两个终端。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 都要重来一遍 —— 漏了就是两边互相看不见。

    终端二 · 跑实时检测(先用默认 2 线程)

    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/02_garbage_classification
    python src/app.py --ros
    30帧平均帧率: 15.x 30帧平均帧率: 16.x ← 每 30 帧打一次;跑够几轮后 Ctrl+C,最后一行会打「总平均帧率」

    浏览器打开 http://<板IP>:8080/garbage_cam.mjpg,然后 Ctrl+C 退出,改线程数再跑一次 —— 这是本实验唯一需要你亲手比出来的数据:

    python src/app.py --ros --tpes 1
    python src/app.py --ros --tpes 6
    python src/app.py --ros --tpes 8
    总平均帧率: 8~10 总平均帧率: 20 以上 总平均帧率: 略低于 6 线程 ← 三行的「形态」才是判据:低 → 高 → 略降。具体数值按你的板子会有出入 ← 请把你自己的三个数记下来,第 06 节要拿来对

    如果三行不是「低 → 高 → 略降」,先别怀疑代码:确认三次都是同一个场景、光照没变(画面里东西越多越慢)。

🔁 现象呼应 这三步跑完出现的画面,就是第 03 节「🎬 现象速览」里的那个:每个垃圾一个红框、框上一行三段字、四张图凑齐四个桶。两处对上了,说明整条链你已完整走通。

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

跑通之后要能回答:这个参数属于链上哪一段,改它会影响哪一格?这张表就是这段链条的「户口本」。

命令 / 参数经过的函数(文件)产出 / 影响链上哪一段
--model …​.rknnrknn_backend_factory()(src/rknnpool.py)→ backend.load()选模型并加载到 NPU。改它 = 换整个第 ③ 段认人的能力
--image / --rosDetector.detect()(src/detector.py);--ros 走 Ros2Camera(src/ros2_source.py)决定第 ① 段「帧从哪来」。话题 /ascamera_hp60c/…/rgb0/image 严禁 remap
--save out1.jpgmake_process_fn 画框(src/app.py L24-35)→ cv2.imwrite()第 ⑧ 段落盘。单图模式的全部结果都在这个文件里
infer.imgsz: 640prepare_input()(src/preprocess.py)步骤 1:缩放比与补边量都由它算
infer.score_thr: 0.25decode_rknn_outputs()(src/postprocess.py)步骤 2:分数线。调高漏检、调低误检
infer.nms_thr: 0.45classwise_nms()(src/postprocess.py)步骤 2:去重线。调低会把挨得近的两个真目标合成一个
infer.sigmoid: False解码分支(src/postprocess.py)步骤 2:obj/类别分数要不要再压一次。开关错了分数会全 0 或全接近 1
config/anchors.txtload_anchors()(src/config.py)→ 解码步骤 2:9 组锚框。与导出时不匹配 → 框整体偏移
config/category_map.txtmap_categories()(src/postprocess.py)步骤 3:细类 → 桶。行序错一位,全体映射偏移
--tpes 6RKNNPool(…, tpes, …)(src/rknnpool.py)步骤 4:工作线程数。超过最优点就不涨反降
pool.core_mask: autoinit_runtime(core_mask=…)(src/detector.py)步骤 4:核怎么分。改 round_robin 会卡死
--port 8080MjpegServer(src/mjpeg_server.py)第 ⑧ 段推流。实验 1~7 共用 8080,不能同时跑

看不到的环节也要交代清楚:NPU 里那 3 组张量是怎么算出来的,源码里看不到 —— 那是 .rknn 里的指令。所以第 ③ 段在这个仓库里没有对应函数,别去找。

你该看到的画面

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

单图 · 正常
框贴外沿,三段字齐全

红框贴着物体边界,框上方 大类 细类 分数 三段都有。类别名是拼音,不是英文也不是中文。

实时 · 正常
框稳定跟随,帧率 ≥15

移动物体时框一起动、不闪。30帧平均帧率 稳定在 15 以上 —— 这是验收门。

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

这是正常的。模型只认识那 20 种垃圾,遇到没见过的画面会硬套 —— 域偏移,不是 bug。

重点与易错点

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

你看到的多半是什么原因怎么办
框的位置对,但类别全错RGB / BGR 弄反了 —— 模型要 RGB,OpenCV 给的是 BGR确认先 to_rgb() 再喂;转换侧 rgb2bgr=False
框整体偏移,形状还行anchors.txt 与模型导出时用的不一致换回导出配套的那份 anchors;不要自己改数字
分数全是 0,或全都 0.9 以上infer.sigmoid 与导出版本不符(多做/少做了一次)把 sigmoid 在 false / true 之间切换对比
水瓶被判成「有害垃圾」category_map.txt 行序错位(多/少了一行)逐行与 labels.txt 对齐;不报错的错最难查
多线程跑一会儿就崩几个线程共用了同一个 RKNNLite 实例每线程独立实例(RKNNPool 已实现,别改成共享)
--tpes 8 反而更慢线程数远超最优点,只剩排队与内存开销降到 4~6 试;用「线程数 vs 帧率」曲线找最优点
浏览器 404 / 打不开流8080 被别的实验占了,或 URL 写错换 --port;URL 是 /garbage_cam.mjpg(配置里 stream_name 不带 .mjpg,库自动补)
--ros 一直没画面相机驱动没起,或两个终端 ROS_DOMAIN_ID 不一致先起 ascamera;两个终端写同一个 N;话题不要 remap
单图跑完,终端里找不到识别结果它本来就不打印 —— 只有 saved -> 一行打开 out1.jpg 看图上的三段字
远程一行命令报 python: command not found非交互 shell 不读 .bashrc,venv 没激活改用 bash -lc "…" 或 venv 全路径;交互式登录不受影响
💡 排查口诀先看图,再看表,最后看模型。① 图上有没有框、框在不在位置(链子通不通);② 三段字里桶对不对(查表对不对);③ 细类本身对不对(模型认没认错)。三步走完,九成的坑都能定位。

进阶与深读

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

进阶 1 · 把三段字改成中文约 15 分钟

现在画面上是 recyclable yilaguan 0.85。想让分拣员一眼看懂,可以改成中文。

做法只改一处:src/app.py 里拼标注文本的那一行(make_process_fn 内),把大类英文名和拼音细类换成中文。但有个坑:OpenCV 的 putText 不支持中文,直接写中文会显示成一堆问号 —— 要么换支持中文字体的绘制方式,要么退一步用拼音首字母(如 HSB YLG 0.85)。这一步会让你真正理解「画字」和「认字」是两件完全不同的事。

进阶 2 · 给有害垃圾单独设阈值约 30 分钟

业务上「有害垃圾漏检」比「可回收误报」严重得多。做法:在 make_process_fn 里按大类分支 —— 大类为「有害」时放宽 score_thr(比如 0.15),其它类维持 0.25,观察误检率变化。

这解释了为什么阈值不该是一个全局常数:它属于业务策略,不属于模型。写成按类配置,比在 yaml 里改一个全局数字更接近真实产线。

进阶 3 · 把结果接给分拣臂约 40 分钟

四大类就是分拣指令:四个仓。在 make_process_fn 拿到 CategoryDetection 之后,把 (大类, 框中心坐标) 通过串口或 ROS2 话题发出去,机械臂就能按类别抓取。

第 11、12 章会用到同一套「视觉输出喂给控制」的模式。注意:真实抓取还需要深度或手眼标定才能把像素坐标变成三维坐标 —— 那是第 10 章的活。

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

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

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

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

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

提示词 1 · 模块职责分析

请阅读这几个文件:src/app.py、src/detector.py、src/preprocess.py、
src/postprocess.py、src/rknnpool.py、src/mjpeg_server.py、
config/garbage_config.yaml、config/labels.txt、config/category_map.txt。
这是一个基于 RKNN 的垃圾分类检测项目,20 个细类映射到 4 个大类。
请输出一张模块职责表,回答三个问题:
1)每个文件负责哪一件事,用一句话说清;
2)从入口 src/app.py 往下,谁调用谁;
3)category_map.txt 的行序与 labels.txt 是什么关系,错位会怎样。
输出格式:markdown 表格,四列是「模块 / 职责一句话 / 被谁调用 /
关键函数」,每条职责后面用 文件名:行号 标注出处。

提示词 2 · 控制流流程图

请为「一帧图的处理链」画一张控制流流程图,从读帧开始到推流结束。
要求:
1)必须体现「模型只占一段」:把 NPU 推理那一步单独标出来,
   其余步骤标成 Python 侧;
2)分支必须画全:读帧失败、一个目标都没检出、检出多个目标;
3)每个框里写真实函数名(letterbox、to_rgb、predict、
   decode_scale、classwise_nms、unletterbox、map_categories);
4)用 mermaid 的 flowchart 语法输出。

提示词 3 · 线程时序图

请为「多线程推理池」画一张时序图,参与者包括:
主循环、输入队列、工作线程 1、工作线程 2、NPU 核 A、NPU 核 B、输出队列。
要求:
1)按时间顺序列出至少 6 次交互,体现生产者-消费者;
2)标出每个线程各自持有一个 RKNNLite 实例,不是共享;
3)标出线程数超过 NPU 并行能力时会发生什么,排队从哪里开始;
4)用 mermaid 的 sequenceDiagram 语法输出。

自查三问:模块表里每条都能指到真实的 文件:行号?流程图把 NPU 那一段单独标出来了吗?时序图里两个线程是不是各有自己的实例?

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

差异点AI 版常见写法工程版(本仓)为什么工程这样写
桶从哪来让模型直接输出 4 类模型出 20 细类,map_categories() 查 category_map.txt业务规则会变、模型不好重训。解耦后改表即上线
锚框anchor-free,直接回归中心点YOLOv5 式 anchor-based,9 组锚框 + 偏移回归这是模型导出时就定死的结构,后处理必须配套,改不了
线程与实例全局一个模型对象,多线程共用每线程一个 RKNNLite(rknnpool.py 内建后端工厂)NPU 上下文非线程安全,共用会崩
核分配按线程号取模轮转核core_mask: auto,交给 rknnlite 分配轮转在这块板上会把两个线程分到同一核,直接卡死
推流引第三方 mjpeg_streamer标准库实现 MjpegServer(multipart 响应)零额外依赖,离线环境也能跑

最后一个动作:改一处、先预测再验证。把 category_map.txt 里 shuiping 那一行的数字从 1 改成 3,先写下你认为画面会变成什么样,再跑一次单图。猜对了,说明你真懂了查表;猜错了,回去看第 04 节步骤 3。

06互动:线程数 1→8,帧率为什么涨又为什么跌

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

要改的只有 config/garbage_config.yaml 里的 pool.tpes(命令行 --tpes 可直接覆盖)。下面是配置内容,不用执行:

pool:
  tpes: 2
  core_mask: auto

下面这个实验台把「线程数 → 帧率」画出来。曲线是流水线模型仿真的(NPU 并行能力 + 取帧/后处理与推理的重叠 + 超量线程的排队开销),两个锚点来自板端实测:2 线程 15~18 帧、6 线程 20 帧以上。你手里那三个实测数正好可以拿来对。

⚡ 参数实验台 · 线程数 vs 帧率
2
1 2 3 4 5 6 7 8 tpes 24 12 验收门 15 FPS 青线 = 流水线模型仿真;竖虚线 = 当前线程数
2 线程:推理与取帧重叠起来,比单线程快一倍左右。

三种读法,对应三个不同的问题:

线程数你会看到说明什么
--tpes 1帧率个位数到十来帧,达不到 15 的验收门NPU 算的时候 CPU 在等,CPU 干活时 NPU 在等 —— 两边交替空转
--tpes 2~3明显上涨,2 线程约为单线程的两倍一个线程推理时,另一个正好在准备下一帧 / 画框 / 推流 —— 重叠才是第一波收益
--tpes 6到顶,20 帧以上(本板实测的最优点就在这附近)重叠已经榨干,再往上就进入排队
--tpes 8反而比 6 线程略低多出的线程只能排队,还要付内存与切换开销 —— 过犹不及

你刚才发现的:线程数不是「越多越快」,它有两个上限 —— 物理上限是 NPU 的并行能力,经济上限是最优点(本板大约 4~6)。

所以在产线上真正要问的不是「开到多少最快」,而是:这条产线要求多少帧?为那几帧多开线程,值不值多占的内存?想清楚这个,参数就定了。

这一节的结论:线程数是对着「并行能力 + 重叠深度」配的,不是对着「越多越好」配的 —— 到顶之后,再加线程只是在排队。

07验证:四道小测

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

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

目标类别页首那一条说的是对应题
原理说出八段里哪一段在 NPU、其余谁在干Q1
重难点桶不是模型说的,是查表来的Q2
应用一帧多个目标怎么逐个报出来Q3
实践线程数为何先涨后停再掉Q4
Q1模型输出的每个尺度是 [1, 75, h, w]。这个 75 是怎么来的?
75 = 20 个细类 + 4 个桶 + 51 个冗余位
75 = 3 个锚框 ×(4 个框参数 + 1 个目标置信度 + 20 类分数)
75 = 640 的缩放比换算出来的固定通道数
对。3 ×(4+1+20)= 75。这 75 个数里没有「桶」 —— 桶是后来查表得来的,所以任何提到「桶在模型输出里」的选项都不对。四个框参数是中心偏移和宽高偏移,不是绝对坐标。
Q2画面显示 hazardous_waste shuiping 0.85(水瓶被判成有害垃圾)。最该先改哪里?
重新训练模型,让它把水瓶认对
把 score_thr 调高,让这个框消失
config/category_map.txt 里 shuiping 那一行的数字
对。细类认对了(shuiping),错的是「它该去哪个桶」—— 那是查表的结果。改 category_map.txt 对应那一行的数字,立刻生效,不用碰模型。这正是两级标签设计的意义:模型管「是什么」,表管「扔哪儿」。调 score_thr 只是让目标消失,问题还在。
Q3一帧四个垃圾,模型是「看了四遍」才报出四个框的吗?
是,每个目标单独跑一次推理
不是,它一次性在整图的每个格子上都猜了,猜中的地方留下框
不是,它先判断图里有几个东西,再逐个定位
对。一次前向推理,8400 个位置 × 3 个锚框同时下注,压在各目标上的那几处报出高分。所以「一帧多个」几乎不影响速度 —— 报 1 个和报 4 个,耗时差不多。这也是检测网络比分四次分类快得多的原因。
Q4把 --tpes 从 6 拉到 8,帧率反而略降。这说明了什么?
程序有 bug,线程数应该越多越快
并行与重叠已经吃满,多出的线程只能排队,还多付内存与切换开销
模型太小,用不上这么多线程,应该换更大的模型
对。线程数有两个上限:物理上限是 NPU 的并行能力,经济上限是最优点(本板约 4~6)。超过之后多出来的线程只会排队,同时吃掉内存、增加上下文切换 —— 所以回调。记住这条,换任何板子都能自己找最优点。

08章节总结:模型只管认细类,查表才管扔哪个桶

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

概念它解决了什么和别的关系
检测头
3 尺度 × 75 通道
一帧多个目标要各自报一次,纯分类只能给一个答案吃 640 的 RGB 张量;吐 3 组张量,交给锚框解码
锚框 anchor不让网络从零猜绝对坐标,只猜「相对预设框怎么调」,更稳9 组锚框分给 3 个尺度;anchors.txt 与导出不一致 → 框整体偏移
目标置信度 × 类别分数先判「这里有没有东西」,再判「是什么」,两道一起过滤噪声两道都过 score_thr 才留下;sigmoid 开关错会让分数全 0 或全接近 1
逐类 NMS同一处被报十几次,按分数留最高的、删重叠的同类框跨类不抑制 —— 这是「逐类」的意义;nms_thr 调低会误删挨得近的真目标
letterbox pad=0把任意尺寸摆成 640 方图,且补边颜色必须与训练一致上游是原图,下游靠它的补边量把框换回原图(第 ⑥ 段)
BGR → RGB模型要 RGB,OpenCV 给 BGR弄反的症状是「框对、类别全错」,由转换侧 rgb2bgr=False 定死
两级标签
20 细类 → 4 桶
模型的能力边界与业务规则解耦:改表就能改业务,不用重训表在 category_map.txt,行序 = 细类编号;错一位全错且不报错
推理池 RKNNPool单线程喂不饱 NPU,多线程让推理与取帧/后处理重叠每线程独立实例;tpes 超最优点不涨反降;core_mask 别用轮转
MJPEG 推流浏览器直接看实时流,零额外依赖、零解码延迟stream_name 不带 .mjpg;实验 1~7 共用 8080
画框与标注把数字变成人能看的三段字(桶 / 细类 / 分数)全部在 Python 里;模型不负责画图,也不负责说「桶」
⚠ 三个反直觉的地方 ① 直觉是模型会告诉你该扔哪个桶,实际是它只会念 20 个拼音名,「桶」是我们查一张 txt 得来的 —— 所以改业务规则改表就行,不用碰模型。
② 直觉是一帧四个目标要让模型跑四遍,实际是一次推理里 8400 个位置同时下注,报几个都一样快。
③ 直觉是线程越多越快,实际是并行与重叠吃满之后就只是排队 —— 最优点通常在 4~6,不在「越多越好」。
💡 三条要注意 ① category_map.txt 的行序必须与 labels.txt 严格对齐,错一行全错、而且不报错 —— 这是本实验最难查的坑;score_thr 是 0~1 的分数不是百分比;stream_name 不要带 .mjpg(库会自动补)。
② 多线程时每个线程必须有自己的 RKNNLite 实例;pool.core_mask 保持 auto,改轮转会卡死;实验 1~7 共用 8080 端口,不能同时跑。
③ 单图模式的终端只打印 saved ->,不打印识别结果 —— 结果全在图上;验收门是实时模式总平均 ≥ 15 FPS;--video 模式仓库没提供测试视频,别试。

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

类型具体项缺了会怎样 / 怎么补
环境实时模式要 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、OpenCV、NumPy、PyYAML;推流用标准库,不需要外部包缺 rknnlite → .rknn 加载直接失败;单测用假后端,但模块导入仍要它
模型models/garbage_yolov5_20cls.rknn(20 细类,640×640,3 尺度输出)看不到它就说明目录不对。板上不需要训练集 —— 推理只依赖这个 .rknn
配置config/ 下四个文件:garbage_config.yaml、labels.txt、category_map.txt、anchors.txt缺 labels → 拼音名变编号;缺 category_map → 没有桶;缺 anchors → 框整体偏移。四个缺一个都跑不通
硬件 / 前置实验实时模式要板载 HP60C 相机 + ros2 launch ascamera hp60c.launch.py;前置 EXP-04 的检测链路概念没相机 → 只能跑单图(没有测试视频,视频模式用不了);没读第 4 章 → 解码那节会吃力

这套代码用在哪儿

  • 垃圾分拣线:传送带上方架相机,四类结果直接驱动四个料仓的推杆或机械臂。--tpes 按产线节拍调 —— 验收门其实就是「每秒能过几个」。
  • 智能垃圾桶:桶盖上方一个摄像头,识别到人手里的东西就开对应的盖子。这类场景帧率要求低,单线程就够,重点在「细类要覆盖你那儿真有的垃圾」。
  • 有害垃圾专项筛查:只关心电池、药瓶那几类,可以把它们的 score_thr 调低(宁可多报,交人工复核)—— 阈值属于业务策略,不属于模型。
  • 换一套细类就是换一个行业:这个模式能搬去任何「模型识别细类、业务按规则归大类」的场景 —— 快递按区域分、零件按工序分、作物按等级分。只要归类规则由人定,就该写在表里,而不是训进模型。

下一章换个更细的问题:这一章给的是「框」,可如果想知道「这个人形到底占了哪些像素」,框就不够用了 —— 那要用分割。

核心记忆点:模型只负责「是什么」,「该怎么办」永远是你写的代码。想改业务,先找表;表改不动了,才轮到重训模型。