垃圾分类:20 个细类变成 4 个桶
上一章模型只认钢板上那六类缺陷。这一章换成日常垃圾:模型认得出 20 个细类(水瓶、电池、胡萝卜…),可垃圾桶只有 4 个。中间这层「翻译」该谁来做 —— 这就是本页最重要的一课:哪些该交给模型,哪些该写成代码。
01目标:每行读懂「大类·细类·分数」三段
- 说出一条链八段里哪一段在 NPU 上、其余各是谁在干;以及 20 个细类是怎么变成 4 个桶的
- 用单图与实时两条命令各跑一次,读出画面上的三段字「大类 细类 分数」并说清每段是哪来的
- 调出线程数最优点:把
--tpes从 1 拉到 6 再拉到 8,说清帧率为什么先涨、涨到几个线程就停、再往上为什么反而掉
images/1.jpg~4.jpg:一帧里可能有 1 个、也可能有 4 个目标,混着放也行
每个目标一个红框,框上方三段字:该扔哪个桶 + 它到底是什么 + 模型多确定
难点:直觉是「模型直接告诉你这该扔哪个桶」,实际是模型只会说 20 个细类名(还都是拼音),「桶」是我们查一张表得来的 —— 这一步在 Python 里,模型里没有。分清这条线,你才知道新增一类垃圾时该改哪里:改表就行,不用碰模型。
另有一处工程坑:多线程时每个线程必须有自己的 RKNNLite 实例,共用一个会直接崩。
recyclable yilaguan 0.85 —— 第一段是查表得的,第二段是模型认的,第三段是模型给的把握。02问题:模型认得出瓶子,可它不知道该扔哪个桶
教室后面一袋垃圾倒出来:矿泉水瓶、易拉罐、胡萝卜、电池、药片包装、砖块碎。要按四个桶分开。人靠常识,机器只有像素 —— 而且它连「桶」这个概念都没有。
更要紧的是:这一章跟前几章有个根本区别。颜色、轮廓、二维码都是「给一个答案」,而一袋垃圾一帧里常常有好几个东西。纯分类网络(ResNet 那类)对整张图只输出一个标签 —— 四个垃圾它只能说一个。所以这里必须用检测网络。
| 对比 | 纯分类网络 | 本实验用的检测头 |
|---|---|---|
| 输出什么 | 整张图一个标签 | 每个目标一个框 + 一个类别,可以同时多个 |
| 能不能说「在哪」 | 不能,只知道图里有 | 能,给出左上右下坐标 |
| 一帧四个垃圾 | 只能说一个,剩下三个丢了 | 四个各报一次(images/1.jpg 就是这样) |
| 谁更适合分拣 | 分拣要抓取,必须知道位置 | 这个 —— 机械臂要的是坐标 |
模型本身认的是 20 个细类,名字全是拼音(训练时就用的拼音,不是英文也不是中文)。而桶只有 4 个。下面这张对照表就是全部业务规则:
| ID | 细类 | 该扔 | ID | 细类 | 该扔 |
|---|---|---|---|---|---|
| 0 | bailuobo | 厨余垃圾 | 10 | shuiping | 可回收物 |
| 1 | caiye | 厨余垃圾 | 11 | tizi | 厨余垃圾 |
| 2 | chuangkotie | 有害垃圾 | 12 | tudo | 厨余垃圾 |
| 3 | cipian | 其他垃圾 | 13 | yaogao | 有害垃圾 |
| 4 | dianchi | 有害垃圾 | 14 | yaopian | 有害垃圾 |
| 5 | eluanshi | 其他垃圾 | 15 | yaopianbaozhuang | 有害垃圾 |
| 6 | fanqie | 厨余垃圾 | 16 | yaoping | 有害垃圾 |
| 7 | huluobo | 厨余垃圾 | 17 | yilaguan | 可回收物 |
| 8 | jiaonang | 有害垃圾 | 18 | zhibei | 可回收物 |
| 9 | lajiao | 厨余垃圾 | 19 | zhuankuai | 其他垃圾 |
机器要顶替人,光「认得出」不够,它得同时回答三件事。这三问就是下一节原理的骨架:
① 一帧里有四个垃圾,它凭什么每个都报一遍?纯分类只能吐一个答案。检测网络是「逐个格子报」的 —— 可怎么保证四个都被报出来、又不重不漏?
② 它说 shuiping,谁把这句话翻成「可回收」?模型心里没有桶的概念。这层翻译放在模型里、还是放在代码里,会决定你以后加一种新垃圾时要不要重训模型。
③ 十来帧每秒够不够用?板上只有一个 NPU,一个线程喂不饱它。想更快,能靠堆线程吗?堆到几个才不亏?
先别急着答。第 03 节先把四张图跑一遍,四种桶全看一眼。
03现象:三段输出:recyclable yilaguan 0.85
你会看到:每个垃圾被一个红框套住,框上方一行三段字 —— recyclable yilaguan 0.85:该扔哪个桶 / 它是什么 / 模型多确定。四条命令跑完,四个桶全出现过。
三条步骤(第 2 步一次跑四张图):
① 进目录,确认模型和测试图在。
cd ~/ros2_ws/src/xflz_eai_car1/vision/02_garbage_classification ls models/ ls images/
② 单图推理,一次把四张图都跑一遍。四张图的输出分别存成 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
生成的四个文件,打开就是下面四张 —— 四条命令、四种桶:
out1.jpg一帧四个目标
厨余 + 其他
out2.jpg易拉罐 + 电池
可回收 + 有害
out3.jpg药片包装 + 水瓶
有害 + 可回收
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
浏览器打开(<板IP> 换成板子的局域网地址):
http://<板IP>:8080/garbage_cam.mjpg
🔍 看什么:四样东西 —— ① 一帧里有几个目标,是不是每个都被框了;② 三段字里哪一段是桶、哪一段是拼音;③ 分数高的是不是看得更清的那个(out1.jpg 里 0.89 的胡萝卜比 0.59 的碎块更清楚);④ 同一张图里有没有两个框套住同一个东西。
这就是本实验最终要呈现的现象。此时照做、先看懂它就行;第 05 节会带你走一遍「命令 → 函数 → 文件」的对账,亲手复现同一个画面 —— 跑出来、对得上,实操才算完成。
扩展 · 四张图逐条对照,看四种桶怎么凑齐
这四张图不是随手拍的:它们刚好把四个桶各演了一遍。下面把每张图上框里的字原样抄下来(这些字在图上,不在终端),再对回第 02 节那张表 ——
| 输出文件 | 图上写着(原样) | 对应第 02 节 | 看出来什么 |
|---|---|---|---|
out1.jpgimages/1.jpg |
kitchen_waste huluobo 0.89kitchen_waste huluobo 0.86other_waste zhuankuai 0.86other_waste zhuankuai 0.59 |
厨余 + 其他 | 一帧四个目标,逐个报了出来 —— 纯分类网络在这里只会给一个答案。分数最低的 0.59 也正好是形状最不规则的那块碎料;两块砖块读数分别是 0.86 和 0.59,说明同一类的两个目标各自独立判定,谁也没被合并掉 |
out2.jpgimages/2.jpg |
recyclable yilaguan 0.85hazardous_waste dianchi 0.76 |
可回收 + 有害 | 易拉罐和电池长得完全不像,模型分开认对了 —— 而且落在两个不同的桶上,正好证明大类是逐目标判的,不是整图一个 |
out3.jpgimages/3.jpg |
hazardous_waste yaopianbaozhuang 0.79recyclable shuiping 0.66 |
有害 + 可回收 | 药片包装和塑料水瓶看起来都是「塑料」,桶却不同 —— 这就是查表的价值:外观相似的东西可以进不同的桶,规则写在表里 |
out4.jpgimages/4.jpg |
hazardous_waste yaoping 0.92hazardous_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 里
模型在 NPU 里只干一件事:把 640 的方图变成 3 组各带 75 个数的张量。剩下的 —— 把数还原成框、筛分数、去重、把细类翻成桶、换算回原图、画框推流 —— 全是我们自己写的代码。
打个比方:模型像一个只负责「报菜名」的师傅,一口气喊出「三号桌有个水瓶,八成五把握」;至于水瓶该收回哪一格柜子、要不要复核,是前台照柜位表安排的。哪里像:师傅确实只报他认识的菜名(20 个细类),从不提「柜子」;哪里不像:师傅喊的是位置和把握,不是文字 —— 那些数字得我们自己解码才读得懂。
这一节先看整条链的八段(哪段在板子的 NPU 里、哪段在 Python 里),再按第 02 节的三问往里钻:① 一帧多个怎么逐个报(步骤 2)→ ② 拼音怎么变桶(步骤 3)→ ③ 怎么更快(步骤 4)。步骤 1 是三者共同的前置:得先把图摆成模型认识的样子。
1送进去之前,为什么必须补黑边、还得换颜色顺序?
先猜一下:把 1080p 的照片直接喂进去,行不行?
不行,模型只认 640×640。做法是等比缩放 + 补边,和上一章一样。但这页有个更严的细节:补边必须是纯黑(0)。模型训练时用的就是黑色填充 —— 推理时填成灰的,那圈灰边会真的参与特征计算,边缘就容易冒出假框。
第二个细节是颜色顺序。OpenCV 读进来是 BGR,而这个模型训练时用的是 RGB(转换时定了 rgb2bgr=False),喂之前必须换一次。弄反了不会报错,症状是「框的位置全对、类别全错」—— 模型看到的「红」其实是蓝。
宽高各自缩放,比例不同。圆的水瓶变椭圆,细长的电池变胖 —— 形状一变就不认识。
算一个统一比例缩放,空出来的地方补 0。补边量要记下来 —— 第 ⑥ 段要靠它把框换回原图。
2一帧四个垃圾,它凭什么每个都报一遍?
先猜一下:它是先找出所有物体、再一个个认,还是先猜一堆框再看框里是什么?
都不是。它是在每个格子上都猜一次:把 640 的图切三种粗细的格子 —— 80×80、40×40、20×20,每个格子先摆 3 个「预设框」(叫 anchor,是从训练数据里统计出来的常见长宽比),然后对每个「格子 × 预设框」都报一套数:框要怎么调、这里有没有东西、是 20 类里的哪一类。
所以第 ③ 段出来的三个张量,每个都是 [1, 75, h, w]:75 = 3 个锚框 ×(4 个框参数 + 1 个目标置信度 + 20 类分数)。
那「四个目标」是怎么出来的?就是四个地方各自猜中了。一帧里 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 会卡死,别用 |
这四步在代码里分别是谁
这张表只看第三列和第五列 —— 左两列是「哪一步、屏幕上看到什么」,右两列是「谁在干、动了会怎样」。
| 步 | 画面/数据变成了什么 | 谁在干(函数 · 文件) | 关键参数 | 动了会怎样 |
|---|---|---|---|---|
| 步骤 1 ①② |
任意尺寸 BGR → 640×640 方图(补黑边)→ 通道换成 RGB | letterbox() · to_rgb()( src/preprocess.py) |
infer.imgsz: 640infer.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.45sigmoid: Falseanchors.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: 2pool.core_mask: autoserver.port: 8080 |
tpes 超过最优点(本板约 6)→ 不涨反降;core_mask 改 round_robin → 卡死;stream_name 加 .mjpg → 浏览器 404 |
先认识 6 个词
这六个词后面每一步都会出现。前四个属于「模型怎么说」,后两个属于「我们怎么接」。
网络最后那段,把特征变成「框 + 类别」。为什么需要:一帧多个目标时,纯分类只能给一个答案。什么时候不够用:需要像素级轮廓时要换分割(第 6 章)。
事先摆好的「常见长宽比」参考框,网络只猜相对它怎么调。为什么需要:从零猜绝对坐标太难,猜「往右挪一点、放大 1.2 倍」容易得多。什么时候出错:锚框和模型导出时不匹配 → 框普遍偏移。
「这个格子里到底有没有东西」,0~1。为什么需要:绝大多数格子是空白,先判有无能砍掉大量无用框。什么时候会被骗:背景纹理像目标时它会虚高,所以要再乘类别分数。
同一类里按分数排队,重叠超阈值的只留最高分。为什么是「逐类」:不同类允许重叠,跨类抑制会误删真目标。什么时候失效:两个同类目标贴得极近,会被合成一个。
模型认 20 个细类,业务只用 4 个大类,中间靠一张表。为什么需要:模型换不了那么勤,业务规则却常变。什么时候出事:表行序和细类编号错位 —— 全错且不报错。
多个线程各持一份模型实例,轮流占 NPU 并让取帧/后处理与推理重叠。为什么需要:单线程喂不饱 NPU。什么时候白干:线程数超过最优点,只剩排队与内存开销。
为什么不干脆用纯分类网络?选读 1 分钟
纯分类(ResNet 那类)更小更快,很多场景也够用。但分拣不行:分拣臂要抓取,必须知道「东西在画面哪个位置」,纯分类给不出坐标。
还有个更实际的原因:一帧里常常不止一个目标。纯分类面对四个垃圾只能吐一个标签,剩下三个直接丢了 —— 你没法让机械臂「抓那个没被报出来的」。
代价是检测头更重:本实验的模型每帧要算两万多次。所以才需要第 06 节那个线程池。
这套方法什么时候不管用选读 1 分钟,决定你能不能当手册用
- 细类不在那 20 个里:模型只认识训练过的 20 种。拿个「纸杯」给它,它会硬塞进最像的一类,而不会说「我不认识」。要加就得重训。
- 物体太小或太远:80×80 那层一格就是 8 个像素,太小的目标在特征图上直接消失。
- 严重遮挡、叠在一起:逐类 NMS 会把贴在一起的两个同类目标合成一个 —— 一摞药瓶很可能只报出一个。
- 只想知道「这一片有没有脏东西」:那是分割的活(第 6 章),用框反而粗糙。
现在回头看第 02 节那一袋垃圾:四个垃圾之所以能各报一次,是因为检测头在每个格子上都猜了一次(步骤 2);shuiping 之所以能变成「可回收」,是因为第 ⑦ 段查了一张表(步骤 3);十来帧之所以能涨到二十帧以上,是因为线程池把 NPU 与后处理重叠了起来(步骤 4)。三问都填上了。
05实操:单图、实时、线程数三条命令
全部在板子上执行。复制、粘贴、回车就行,每条命令后面都标了「跑完看到什么算对」。
-
进目录,跑单测,看清两张表约 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 节那张表该扔哪个桶(可回收)。三处对上,你就掌握了这页的核心。 -
单图推理,一次跑四张图约 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 节那四张图。 -
实时流,再对比线程数约 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 节要拿来对如果三行不是「低 → 高 → 略降」,先别怀疑代码:确认三次都是同一个场景、光照没变(画面里东西越多越慢)。
命令 → 函数 → 文件:你敲的每一行到底经过了谁
跑通之后要能回答:这个参数属于链上哪一段,改它会影响哪一格?这张表就是这段链条的「户口本」。
| 命令 / 参数 | 经过的函数(文件) | 产出 / 影响链上哪一段 |
|---|---|---|
--model ….rknn | rknn_backend_factory()(src/rknnpool.py)→ backend.load() | 选模型并加载到 NPU。改它 = 换整个第 ③ 段认人的能力 |
--image / --ros | Detector.detect()(src/detector.py);--ros 走 Ros2Camera(src/ros2_source.py) | 决定第 ① 段「帧从哪来」。话题 /ascamera_hp60c/…/rgb0/image 严禁 remap |
--save out1.jpg | make_process_fn 画框(src/app.py L24-35)→ cv2.imwrite() | 第 ⑧ 段落盘。单图模式的全部结果都在这个文件里 |
infer.imgsz: 640 | prepare_input()(src/preprocess.py) | 步骤 1:缩放比与补边量都由它算 |
infer.score_thr: 0.25 | decode_rknn_outputs()(src/postprocess.py) | 步骤 2:分数线。调高漏检、调低误检 |
infer.nms_thr: 0.45 | classwise_nms()(src/postprocess.py) | 步骤 2:去重线。调低会把挨得近的两个真目标合成一个 |
infer.sigmoid: False | 解码分支(src/postprocess.py) | 步骤 2:obj/类别分数要不要再压一次。开关错了分数会全 0 或全接近 1 |
config/anchors.txt | load_anchors()(src/config.py)→ 解码 | 步骤 2:9 组锚框。与导出时不匹配 → 框整体偏移 |
config/category_map.txt | map_categories()(src/postprocess.py) | 步骤 3:细类 → 桶。行序错一位,全体映射偏移 |
--tpes 6 | RKNNPool(…, tpes, …)(src/rknnpool.py) | 步骤 4:工作线程数。超过最优点就不涨反降 |
pool.core_mask: auto | init_runtime(core_mask=…)(src/detector.py) | 步骤 4:核怎么分。改 round_robin 会卡死 |
--port 8080 | MjpegServer(src/mjpeg_server.py) | 第 ⑧ 段推流。实验 1~7 共用 8080,不能同时跑 |
看不到的环节也要交代清楚:NPU 里那 3 组张量是怎么算出来的,源码里看不到 —— 那是 .rknn 里的指令。所以第 ③ 段在这个仓库里没有对应函数,别去找。
你该看到的画面
知道「正常」长什么样,才知道「异常」是哪一处出的问题。
红框贴着物体边界,框上方 大类 细类 分数 三段都有。类别名是拼音,不是英文也不是中文。
移动物体时框一起动、不闪。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 帧以上。你手里那三个实测数正好可以拿来对。
三种读法,对应三个不同的问题:
| 线程数 | 你会看到 | 说明什么 |
|---|---|---|
--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 |
[1, 75, h, w]。这个 75 是怎么来的?hazardous_waste shuiping 0.85(水瓶被判成有害垃圾)。最该先改哪里?score_thr 调高,让这个框消失config/category_map.txt 里 shuiping 那一行的数字shuiping),错的是「它该去哪个桶」—— 那是查表的结果。改 category_map.txt 对应那一行的数字,立刻生效,不用碰模型。这正是两级标签设计的意义:模型管「是什么」,表管「扔哪儿」。调 score_thr 只是让目标消失,问题还在。--tpes 从 6 拉到 8,帧率反而略降。这说明了什么?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 里;模型不负责画图,也不负责说「桶」 |
② 直觉是一帧四个目标要让模型跑四遍,实际是一次推理里 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调低(宁可多报,交人工复核)—— 阈值属于业务策略,不属于模型。 - 换一套细类就是换一个行业:这个模式能搬去任何「模型识别细类、业务按规则归大类」的场景 —— 快递按区域分、零件按工序分、作物按等级分。只要归类规则由人定,就该写在表里,而不是训进模型。
下一章换个更细的问题:这一章给的是「框」,可如果想知道「这个人形到底占了哪些像素」,框就不够用了 —— 那要用分割。