核心课程 / 视觉入门课 / EXP-06
EXP-06 · 第 3 阶段 · 语义分割 · 设计性 · 4 学时

语义分割背景替换:不只框住人,还要把人的轮廓抠出来

视频会议里的虚拟背景,不是给人套一个方框,而是逐个像素判断「这一点算不算人」。这个实验把它跑通:把画面里的人抠出来,背景换成黑色、纯色,或一张风景图 —— 而终端会打印一个和画面对不上的数字,第 02 节就从那个数字问起。

难度 ⭐⭐ · 4 学时 yolov8n-seg13 输出解码 mc@proto 掩码3 worker 推理池
01 颜色02 轮廓03 二维码04 检测05 分类06 分割07 跟踪08 人脸09 骨骼10 3D11 体感12 手势

01目标:终端报 4 个实例,画面只留 3 个人

🎯 实验目标 · 学完你将能
  • 说清「检测给一个框、分割给一张逐像素掩码」的差别,并解释终端报 4 个实例、画面却只留 3 个人的原因
  • 用 python src/app.py --image ... --save out.jpg 跑通「抠图 → 换背景」,并用 black 模式看清边缘那一圈旧背景的暗边
  • 调出预期效果:把 model.classes、model.mask_thr、background.mode 各改一次,说清画面哪一处跟着变
⚠️ 重点与难点 重点:13 输出怎么收敛成「N 个实例」、mc@proto 怎么把 32 个数变成一张掩码。
难点:直觉是「报了几个就该留下几个」,实际是检出(模型干的)和保留(classes 干的)是两件事; 以及 mask_thr 不是「精度旋钮」,它是「宁缺 / 宁滥」的一条取舍线 —— 调到 0.7 能让边缘干净,也能让手指消失。
INPUT · 你给它
images/bus.jpg

810×1080 的街景:三名行人 + 一辆蓝色巴士。也可以换成 --ros 的相机画面。

→
OUTPUT · 你拿到
[seg] 4 instances

终端两行 + 一张 out.jpg:三名行人贴着轮廓留下,巴士和整条街道一起变黑。

本实验的目标:相机对着人,人被完整抠出来贴到黑底 / 纯色 / 风景图上,而终端说「4 个」,画面里只有 3 个。

02问题:虚拟背景不是套框,可为什么报了 4 个只留 3 个

你天天在用的东西:线上会议点一下「背景虚化」,人就还在,身后那面墙变成了另一个房间。你只看到结果,中间发生了什么完全看不见 —— 这就是黑盒。

本实验要打开这个黑盒。第一次跑完,终端会出现这么两行(下一节完整演示):

[seg] 4 instances
saved -> out.jpg

数字是 4,画面里是 3 个人,还有一整辆巴士凭空消失了。这个对不上的落差,就是这一节要问的三个问题。

检测:给你一个矩形框 person 0.94 [x1,y1,x2,y2] 四个数就交差了 ? 分割:给你每个像素的归属 mask (H x W 的黑白图) 白 = 是人,黑 = 不是 框里还混着 70% 的墙和地 —— 用框换背景,人的轮廓会变成方的
图 2-1|同一个人的两种输出。左边只有四个数,右边有几十万个判断 —— 后面的速度差、边缘问题,全部来自这一次形态变化。

把黑盒拆成两栏,左边是你看到的,右边是机器必须完成的:

对比平时应用(黑盒,你只看到结果)内部原理(打开黑盒,机器要做的)
怎么换背景人还在、背景变了,中间一步都看不见 ? —— 需要一张和画面同样大的黑白图,白的地方留原图、黑的地方贴新背景
画面里有几个东西程序从不说自己「看见了几个」 ? —— 模型一次给出 4 个实例:3 个 person + 1 个 bus
为什么只留 3 个你根本看不出「谁被丢掉了」 ? —— 有一处地方按 COCO 编号做了筛选:0 是 person,5 是 bus

这三个问号,拆成三个具体的问题:

① 每个像素归谁。框只有四个数,掩码有几十万个格子。机器凭什么说出「这一根头发是人、紧挨着的那块墙不是」?

② 报了 4 个,为什么只剩 3 个。终端明明写 [seg] 4 instances,out.jpg 里整辆巴士却一起黑了 —— 是谁把第 4 个丢掉的?

③ 边缘那圈暗边是哪来的。换成纯色或风景背景后,每个人轮廓外都挂着一圈旧背景的残影。是模型不干净,还是有个参数在替你取舍?

所以先看它跑出来什么样 —— 四张真图一次摆齐。

三个问题其实是一条链上的三处:掩码怎么来、谁被放行、边缘怎么定。第 04 节的六个步骤就是依次回答它们。

03现象:人贴着轮廓留下,巴士和街道一起变黑

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

你会看到:原图里三名行人站在一辆蓝色巴士前面;跑完之后,三名行人被贴着轮廓留下来,巴士和整条街道一起变黑。换 image 模式,人就被贴到了另一张照片里。终端只有两行要紧的:[seg] 4 instances 和 saved -> out.jpg。

四张真图,一条链走完(先照做,每步为什么下一节拆讲):

原始画面:三名行人站在一辆蓝色巴士前,街边有建筑和行道树
① 输入 images/bus.jpg(810×1080):人和巴士都在
black 模式结果:三名行人被完整保留,其余画面全黑,蓝色巴士消失
② mode: black:人留下,巴士整辆没了
solid 模式结果:三名行人贴在一整块亮绿色背景上,轮廓外有一圈暗边
③ mode: solid + color: [0,200,0]:轮廓外那圈暗边一目了然
image 模式结果:三名行人被贴到一张 COCO 教室照片上
④ mode: image:背景换成 images/ 里另一张 COCO 图

怎么跑出这四张(三条命令):第一条进目录、确认模型和素材在;第二条单图分割,把结果存成 out.jpg;第三条改配置后再跑一次,就换了一种背景。

cd ~/ros2_ws/src/xflz_eai_car1/vision/06_semantic_segmentation
ls models/ images/
models/: yolov8n-seg.rknn yolov8n-seg.onnx coco_80_labels_list.txt images/: bus.jpg + 20 张 COCO 量化校准图
python src/app.py --image images/bus.jpg --save out.jpg
[seg] 4 instances saved -> out.jpg ← 打开 out.jpg 就是上面第 ② 张图

启动时还会刷几行 W RKNN: … not match with rknn runtime version 和 query RKNN_QUERY_INPUT_DYNAMIC_RANGE error。这是静态 shape 模型的正常提示,不影响结果 —— 别以为出错了。

🔍 看什么:盯三处。

  • 巴士为什么整辆没了 —— 不是模型没认出车(它认出了,就在那 4 个实例里),是被配置挡在了外面。第 04 节步骤 6 会指给你看。
  • 人的边缘 —— 头发、手臂和背景交界处是贴着轮廓走的,不是方框。但仔细看第 ③ 张:轮廓外面挂着一圈颜色略暗的边,这就是第 02 节的第三问。
  • 最右边那个人 —— 只露出半个身子、还被人挡住,照样被留下了。同一张画面里的三个人都能抠,说明这一步与人数无关。

这就是本实验最终要呈现的现象。此时照做、先看懂它就行;第 05 节会带你亲手跑出同样的画面,并补上实时相机那一路。

04原理:六格链路,把 32 个数拼成一个人的轮廓

CORE IDEA · 一句话原理

检测只回答「框在哪」,分割再补一句「框里每一个像素算不算人」。而模型并不直接画这张图 —— 它给每个实例 32 个数,让 32 张公共模板图加权叠出轮廓。

打个比方:模型预先备好 32 张「形状印章」,谁要用就报一份配料表 —— 每个物体只回传 32 个系数,说明「这 32 张印章各用多少」。 揭个盖:印章盖下去就定了,而这里是数值加权,同一份配料表在不同画面上叠出来的轮廓会跟着变;而且叠出来的是 0~1 的概率,不是硬边 —— 所以才需要 mask_thr 这条线把它切成「是 / 不是」。

这一节用六个步骤走完整条链。第 02 节的三个问题各有落点:① 每个像素归谁 → 步骤 4;② 报了 4 个为什么只剩 3 个 → 步骤 3 和步骤 6;③ 边缘那圈暗边哪来的 → 步骤 5。步骤 1、2 是把画面送进模型的必要准备。

① 补成 640 见方 810x1080 -> 480x640 左右各补 80 像素黑边,再转 RGB ② 模型吐出 13 张张量 box 64 cls 80 sum 1 mc 32 proto 32 x 160 x 160 3 个检测层,每层 4 组,加 1 张原型表 ③ 收敛成 4 个实例 #1 person #2 person #3 person #4 bus 8400 格 -> 4 个 每个实例带 32 个掩码系数 ④ 32 个数拼出轮廓 (N, 32) x (32, 25600) = (N, 25600) -> sigmoid -> (N,160,160) 概率图,不是黑白图 每人一张 160x160 的 0~1 小图 ⑤ 放大 4 倍并切黑白 160 -> 640 双线性放大 -> 框外清零 -> 大于 mask_thr ⑥ 选类并合成 + 黑 / 纯色 / 图 = out.jpg 只放行 classes 里的 3 个人 六格只有第 ② 格在 NPU 里,其余五格都是 CPU 上的 numpy 与 OpenCV
图 4-1|六格链路。数据形态变了六次:原图 → 640 见方 → 13 张张量 → N 个实例 → N 张 160×160 概率图 → N 张 640 见方黑白图 → 一张合成图。每格对应下面一个「步骤」。

1640×480 的画面,模型为什么非要一个 640 见方的黑边方块?

吃什么:任意尺寸的一帧 BGR 图(bus.jpg 是 810×1080)。做什么:按长边等比缩到 640,空出来的地方填纯黑,再把 BGR 换成 RGB。吐什么:一张 640×640×3 的 uint8 图。

算式很短:ratio = 640 / max(h, w) = 640 / 1080 ≈ 0.593,于是画面缩成 480×640,左右各补 (640-480)//2 = 80 像素黑边。这也是训练时喂给模型的固定形状 —— 神经网络只认死尺寸。

动它会怎样:填充色必须是 0 而不是常见的 114 灰边,通道必须是 RGB 而不是 OpenCV 默认的 BGR。这两处任何一个错,模型看到的分布就和训练时不一样 —— 症状不是报错,而是掩码整体错位或糊掉,很难反查。

2一次吐出 13 张张量,哪张是框、哪张是形状?

吃什么:那张 640 见方的图。做什么:过一遍 yolov8n-seg(3.2M 参数,3 个检测层)。吐什么:13 个 numpy 数组。

YOLOv8-seg 网络结构:Backbone 的 Conv/C2f/SPPF、Neck 的 Upsample/Concat/C2f,Head 分出三个 Segment 分支
图 4-2|结构图上的三个 Segment,就是每层挂着的掩码分支
输出(每层 4 组)是什么
box 644 条边 × 16 个 bin 的分布,用来还原框
cls 80这一格属于 80 个 COCO 类各自的分数
cls_sum 180 个分数的和,分割模型特有
mc 32掩码系数:形状要用多少(步骤 4 的主角)

第 13 张是 proto:32×160×160 的原型表,全局只有一份,不属于任何一个检测层。

动它会怎样:这 13 张布局与解码代码是死契约(postprocess.py 用 len(outputs)//3 分组)。换模型版本前先数输出数,少一张就会在解码时直接抛形状错误。

3几千个格子 × 80 个类别,怎么收敛成「画面里有 4 个东西」?

先猜一下:640 见方的图被切成 80×80、40×40、20×20 三种网格,一共 6400+1600+400 个格子。模型会吐出多少个候选框?几百个?还是几千个?—— 先写下你的答案,再看下面。

答案是 8400 个格子每格都有候选,几乎全是噪音。收敛靠三步:

  • 还原框:64 个数是 4 条边各 16 个 bin 的分布,取加权期望得到偏移量,再加上格子中心与步长,得到 8400 个框。
  • 筛分数:用 cls 最大分 × cls_sum 去比 score_thr = 0.25,绝大多格子在这一次比较里出局。
  • 压重复:同一个物体周围一圈格子都会给框,用 NMS(iou_thr = 0.45)按重叠度只留一个。

剩下的就是那 4 个实例:3 个 person + 1 个 bus。终端打印的 4 就是这一步的结果。

动它会怎样:score_thr 调低 → 混进大量误检,画面里出现半透明的碎掩码;调高 → 只露出半个身子的人直接漏掉,整帧可能变黑。iou_thr 调低 → 画面里挤在一起的两个人都被并成一个。

432 个数乘 32 张模板图,怎么就叠出一个人的轮廓?

这是第 02 节第一问的答案,也是全节最反直觉的一步。模型没有画出这个人的轮廓,它只是报了一份 32 个数的「配料表」。

为什么不让模型直接画图
640×640 的掩码太大

一张全分辨率概率图就有 40 万个数。要输出它,中间层得堆到百万级参数 —— 边缘小板上跑不动。

→
于是拆成两段
32 个数 × 32 张公共模板

mc(N×32)乘 proto(32×25600),一次矩阵乘就还原出 N 张 160×160 —— 参数量降了两个数量级。

乘完再套一个 sigmoid,把数值压进 0~1。所以每人拿到的是一张概率小图:越靠近身体中心越接近 1,越往外越小,边界附近正好落在 0.5 上下。

动它会怎样:这一步没有可调参数 —— 它由 mc 和 proto 决定。这也是它最容易失效的地方:模板只有 160×160,一个人只占几十个像素、或者细到「一根手指」时,32 张低分辨率模板根本表达不出那个形状,边缘必然是圆钝的。

5160×160 的小图,凭什么贴到 640×640 上刚好合身?

三件事按顺序做,缺一件就会错位或多出残片:

  1. 放大 4 倍:160 → 640,双线性插值。所以边缘天生是一条模糊的过渡带,不是刀切的硬边。
  2. 框外清零:把这个实例的检测框以外全部置 0。少了这一步,一个人的掩码会顺着模板溢出到别人的框里,两人贴在一起时就粘成一团。
  3. 切黑白:seg_img > mask_thr(默认 0.5)把概率图变成 True/False。

为什么 0.5 是默认值:sigmoid 在 0 处的取值正好是 0.5,所以 0.5 这条线基本落在「模型认为的边界」上。调高(0.7)→ 只保留更有把握的内部,边缘往里缩,头发和手指先丢;调低(0.3)→ 边界往外胀,把旧背景带进来一圈 —— 这就是第 02 节第三问那圈暗边的来源。

动它会怎样:mask_thr 是取舍而非精度。要「完整包含」时调到 0.3(量面积),要「一定有把握」时调到 0.7(确认这里确实有人)。0.5 是大多数场景的起点。

64 个实例里只留 3 个人,再把背景换掉 —— 到底谁在决定?

这是第 02 节第二问的答案,也是这个实验最容易被忽略的一处:1 个实例被丢掉,发生在后处理最末一段,不在模型里。

  • 选类:把 N 张掩码按 model.classes 挑行([0] 只留 person),再把挑中的若干张按位或成一张 —— 只要有一个实例在这一点上是人,这一点就是前景。多人场景下所有人才会都被保留。
  • 缩回原图:这时掩码还是 640 见方。用步骤 1 存下的 pad_x / pad_y / ratio 反算,再用最近邻缩回 810×1080。必须最近邻:双线性会把 0/1 糊成小数,边缘又多一圈灰。
  • 合成:compose() 做一次逐像素选择 —— 掩码为真的地方留原图,为假的地方填背景。三种模式只差「填什么」:black 填 0,solid 填 background.color,image 填一张缩放到同尺寸的背景图。

动它会怎样:classes 改成 [5] → 巴士留下、三个人变成黑色剪影(第 06 节给了这张图);改成空列表 [] → 80 类全保留,什么都不会消失。background.color 是 BGR 顺序,想要红写 [0,0,255]。

现在再看第 03 节那张黑底图:巴士整辆消失,不是模型没认出车 —— 步骤 3 明明检出了 4 个,是步骤 6 的 classes 只放行了 person。

先认识 4 个词

掩码 / 蒙版 mask

和画面同样大(或按比例缩放)的黑白图:白 = 留原图,黑 = 换成背景。什么时候失效:两个人紧贴着走,两张掩码会按位或粘成一整团。

原型掩码 proto

模型内部 32 张 160×160 的公共灰度图,所有物体共用,谁都不是专属。什么时候失效:只占几十像素的小目标,低分辨率模板表达不出来。

掩码系数 mc

每个实例的 32 个数,说明「这 32 张模板各用多少」。什么时候失效:实例被 NMS 误合并时,这份系数就属于错的框。

掩码阈值 mask_thr

把 0~1 概率图切成「是 / 不是」的那条线,默认 0.5,正好是 sigmoid 的中点。什么时候失效:环境光一变,模型输出的置信度整体偏移,同一条线就不在边界上了。

什么时候会翻车:五个真实边界选读 1 分钟
  • 一个人都没检出 → 整帧变黑。代码里写得很直白:没有前景掩码时它就造一张全零的,于是背景模式把整个画面都换掉了。屏幕上只剩一片黑,看起来像「坏了」,其实是没检出人 —— 退后一点、露出上半身再试。
  • 杂乱背景 → 边缘挂残片。树影、栏杆、花纹墙最容易在轮廓外留一圈碎点,因为它们和人体边缘的纹理太像。调高 mask_thr 能压掉一部分,代价是头发先丢。
  • 夜间 / 强逆光 → 掩码整体碎裂。模型的训练集以白天街景为主,光线条件一变,置信度整体下移,同一条 0.5 就不再落在边界上。
  • 两人贴身 → 掩码粘连。按位或会把两个人并成一个人形,中间那道缝补上了。想要分开,得绕过 foreground_mask() 直接逐实例取掩码。
  • 太远 / 只露一角 → 漏检。框都没生成,就没有后面的步骤可言。这不是分割的问题,是检测的极限。
步数据变成什么为什么非它不可 / 少了会怎样谁在干动参数会怎样
1任意尺寸 → 640×640×3 RGB,两侧黑边模型只认死尺寸;补 114 灰边或忘了转 RGB,掩码整体错位letterbox()
to_rgb()
改 imgsz 必须同步重转模型,否则形状对不上
2一张图 → 13 个数组模型本体,唯一跑在 NPU 上的那格predict()无参数;换模型要核输出数 = 13
38400 个格子 → 4 个实例(框 + 类 + 32 系数)不筛就把 8400 个噪音框全画出来dfl_decode / nmsscore_thr↓ 误检;↑ 漏检;iou_thr↓ 两人并一个
4(N,32) × (32,25600) → N 张 160×160 概率图不这么分两段,掩码输出的参数量就压不进边缘设备mc @ proto
sigmoid
无参数;小目标边缘必然圆钝
5160 → 640,框外清零,大于 mask_thr 才是人不清零 → 掩码溢出到别人的框;不放大 → 与原图对不上cv2.resize
框裁剪 / 阈值
mask_thr↑ 收缩丢手指;↓ 外胀带残片
6N 张 → 一张原图尺寸前景掩码 → 一张合成图不选类 → 巴士也留下;不缩回原图 → 尺寸错foreground_mask
compose
classes 决定留谁;mode 决定填什么

这张表五列,只看第 2、5 两列就能回答第 02 节的三问:第 2 列告诉你数据变成了什么,第 5 列告诉你哪个旋钮管哪一处。函数名放第四列,想追代码时再看 —— 它们都在 src/postprocess.py 与 src/app.py 里。

05实操:单图 / 三种背景 / 三线程三条命令

学员敲的是一条命令,脑子里却没有「这一帧经过了谁」。先把这张对照表摆出来 —— 每一行都是第 04 节某一格的落地,第 5 列写明它对应原理的哪一步。

环节函数(文件)吃哪个参数对应原理第几步
取流cv2.imread / Ros2Camera.read(app.py:71 / ros2_source.py:82)--image --ros链外(纯工程)
进推理池RKNNPool.put/get(rknnpool.py:70)pool.workers链外,只影响帧率
预处理prepare_input(preprocess.py:45)model.imgsz步骤 1
推理RKNNBackend.predict(backend.py:42)pool.core_mask步骤 2
解码decode_outputs(postprocess.py:81)score_thr iou_thr mask_thr步骤 3、4、5
掩码映射回原图letterbox 反算 + cv2.resize(app.py:36-41)(无,自动跟随)步骤 6
选类与合成foreground_mask / compose(app.py:32 / background.py:21)model.classes background.*步骤 6
推流MjpegServer.set_frame(mjpeg_server.py:61)server.port stream_name链外(纯工程)

第 03 节那四张图,现在亲手复现一遍。

  1. 单图跑一遍,先看清 4 和 3约 3 分钟

    进目录,确认模型(yolov8n-seg.rknn,约 7MB)和素材都在。第一条命令不产出图,只是确认素材齐全。

    cd ~/ros2_ws/src/xflz_eai_car1/vision/06_semantic_segmentation
    ls models/ images/
    models/: yolov8n-seg.rknn yolov8n-seg.onnx coco_80_labels_list.txt images/: bus.jpg + 20 张 COCO 量化校准图

    单图模式:读一张图、只起 1 个 worker、跑一次、存盘退出。参数 --image 给输入,--save 给输出文件名。

    python src/app.py --image images/bus.jpg --save out.jpg
    [seg] 4 instances saved -> out.jpg

    打开 out.jpg:三名行人在,巴士和街道全黑。顺手试一次只给 --image 不给 --save —— 它会提示 use --save or --display for single image 然后退出,不会把图显示在你眼前。

  2. 换三种背景,比一比边缘约 5 分钟

    背景由 background 这一段决定:mode 选模式,color 只在 solid 用(BGR 顺序),image 只在 image 用。这段是配置内容,不用执行:

    background:
      mode: black          # black | solid | image
      color: [0, 0, 0]     # solid 的填充色,BGR 顺序
      image: ""            # image 模式的背景图

    改一次配置跑一次,换个输出文件名好放在一起比(out_black.png 存出来的就是第 03 节第 ② 张):

    python src/app.py --image images/bus.jpg --save out_black.png
    [seg] 4 instances saved -> out_black.png

    一定先试 black。纯黑最容易暴露问题:边缘有没有毛边、有没有把旧背景的残片带进来,一眼就看清。

    然后试 solid:把 mode 改成 solid、color 改成 [0, 200, 0](BGR → 亮绿),重跑一次。那圈暗边会变得非常明显 —— 它就是第 04 节步骤 5 说的「往外胀」。

    最后试 image:mode 改 image,image 填一张现成的图,例如 images/000000075612.jpg(images/ 里那 20 张 COCO 图随便挑一张就行,不用另外找素材)。路径是按「你运行命令时所在的目录」解析的 —— 只要按本节在项目根下运行,写 images/xxx.jpg 就对了;写成不存在的文件,合成时会抛 cannot read background image。

  3. 接上相机,看三线程把帧率抢回来约 8 分钟

    板上标准走法是 ROS 话题(HP60C 直连读不了,opencv 未编 libv4l)。两个终端,先起驱动再跑程序,两边的 ROS_DOMAIN_ID 必须一致(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 /opt/ros/humble/setup.bash && source ~/ros2_ws/install/setup.bash
    export ROS_DOMAIN_ID=N
    cd ~/ros2_ws/src/xflz_eai_car1/vision/06_semantic_segmentation
    python src/app.py --ros
    [seg] 30帧平均帧率: xx.x det=1 ← workers: 1 时约 20~40,workers: 3 时约 40~60 ← 浏览器打开 http://<板IP>:8080/seg.mjpg

    想验证多线程真的起作用:把 pool.workers 从 3 改成 1,重跑一遍,对比终端那行帧率。注意 core_mask 默认是 auto(驱动自己调度),改成 round_robin 才会按 tid % 3 把三个 worker 分别钉在 NPU 的三个核上。帧率明显偏低时,这两个值一起看。

    对着镜头挥挥手、抓抓头发 —— 看边缘跟不跟得住。头发最难:如果头发边缘也抠得干净,说明效果很好。

    浏览器里的实时 MJPEG 画面:一个人对着镜头伸出手臂,身体被完整抠出,背景全黑
    图 5-1|浏览器里的实时画面:手臂伸到镜头前也没被切掉,背景一直是黑的(那次运行用 --port 开在 8081)
🔁 现象呼应这三步跑完出现的画面,就是第 03 节「🎬 现象速览」里的那四张:人贴轮廓留下、巴士消失、边缘一圈暗边、[seg] 4 instances。两处对上了,说明取流、预处理、解码、掩码映射、合成这五段你已完整走通。

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

命令 / 参数经过的函数(文件)影响链上哪一段 / 改坏了会怎样
--image X --save Ymain() 单图分支(app.py:70)→ imwrite(:86)取流方式;只给 --image 会提示并退出
--rosRos2Camera.open/read(ros2_source.py:50)换成话题取流;驱动没起就打印 cannot open ros topic 并退出
--video / --cameracv2.VideoCapture(app.py:106)板上 --camera 直连会失败(opencv 未编 libv4l)
--config / --port / --displayload_config(config.py:24)/MjpegServer(mjpeg_server.py:38)换配置文件、换推流端口、开本地窗口
model.imgszprepare_input(preprocess.py:45)步骤 1;改它必须同步重转模型
model.score_thrdecode_outputs(postprocess.py:83)步骤 3;调低误检、调高漏检
model.iou_thrnms(postprocess.py:50)步骤 3;调低会把并排的两个人合成一个
model.mask_thrdecode_outputs(postprocess.py:85、:130)步骤 5;边缘胀缩的唯一旋钮
model.classesforeground_mask(postprocess.py:133)步骤 6;决定 4 个里留下几个
pool.workers / core_maskRKNNPool / backend_factory(rknnpool.py:16)只影响帧率,不影响掩码质量
background.mode/color/imagecompose(background.py:21)步骤 6 输出的那一层;image 路径写错直接抛错
server.port / stream_nameMjpegServer(mjpeg_server.py:38)推流地址;端口被占用时启动即报错

你该看到的画面

画面 A · black
人浮在纯黑里

人形完整,边缘略有过渡(正常)。若有一圈明显光晕或旧背景残片 → 掩码偏大,调高 mask_thr。

画面 B · solid
纯色背景

证件照效果。注意 color 是 BGR:想要红写 [0,0,255],写成 [255,0,0] 会得到蓝。

画面 C · image
换成风景图

背景图会被缩放到画面大小。人站在风景里,效果最接近视频会议。

异常 · 整帧全黑
一个人都没检出

不是坏了:没有前景掩码时程序会造一张全零的,于是整幅画面都被换掉。退后、露上半身、或降低 score_thr。

🔍 为什么分割比检测慢这么多,必须上线程池?检测只输出几个框,分割要算几十万个像素的掩码 —— 数据量差了几万倍。所以这里用 3 个 worker 各持独立 RKNN 实例去抢 NPU 三个核:单 worker 约 20~40 FPS,3 个能到 40~60 FPS。注意 render() 里的掩码映射与合成跑在主线程,是另一个瓶颈。

踩坑速查

现象原因怎么办
掩码整体错位、偏到一边填充色或通道顺序和训练时不一致确认 letterbox 填 0、且 to_rgb 已执行(转换侧 rgb2bgr=False)
提示 python: command not found没有激活板上的 venv(板端 Python 3.10 在 ~/ros2_venv)source ~/ros2_venv/bin/activate,或直接用 ~/ros2_venv/bin/python src/app.py …
--ros 一跑就报 ImportError: librcl_action.so忘了 source ROS 环境,venv 里的 rclpy 找不到底层库先 source /opt/ros/humble/setup.bash 和 source ~/ros2_ws/install/setup.bash,再激活 venv
启动刷一屏 W RKNN: … version … not match、dynamic range模型由 toolkit 2.3.0 转换、板上运行时 2.1.0;静态 shape 模型本就不支持动态范围查询不用管,属正常提示 —— 只要最后打出 [seg] N instances 就说明推理成功
人抠出来了但边缘一圈碎渣掩码边界外扩,把旧背景带进来model.mask_thr 从 0.5 调到 0.6~0.7
人只剩个身子,手指和头发没了mask_thr 太高降到 0.4 左右再看
画面里别人的东西也被抠出来classes 没限定model.classes: [0] 只留 person;想看全部就写 []
帧率只有二十几只开了 1 个 worker,或没绑核pool.workers: 3;再试 core_mask: round_robin
跑到一半程序崩了多个 worker 共用同一个 RKNN 实例每个 worker 必须独立实例(本仓 RKNNPool 已这么做,别自己改回去)
背景图不显示路径不是相对项目根,或文件不存在确认文件在、尺寸别太怪;改成绝对路径最稳
--ros 起不来,报 cannot open ros topic驱动没起,或两个终端 ROS_DOMAIN_ID 不一致,或有人 remap 过话题先起 ascamera;两边 export 同一个 N;话题保持 /ascamera_hp60c/camera_publisher/rgb0/image
启动就打印一段 pip 安装指引后退出缺 mjpeg_streamer按提示装:pip install -i https://pypi.tuna.tsinghua.edu.cn/simple mjpeg_streamer

进阶(可选)

进阶 1 · 把背景虚化而不是替换约 20 分钟

比换背景更自然的效果是把背景糊掉 —— 人清晰、身后柔化,很多会议软件用的就是这个。

改动很小:合成那一步,把「背景源」从新图换成模糊后的原图。也就是 compose() 里 image 模式的那张 bg,换成 cv2.GaussianBlur(原图, (25,25), 0)。核心公式没变,只是 I_bg 换了。

观察点:核越大越糊;但核太大时人的边缘会和背景糊在一起,反而露出「剪贴感」。

进阶 2 · 把掩码存成带透明通道的 PNG约 20 分钟

把掩码当成 Alpha 通道塞进 PNG,就得到一张「透明背景人像」,可以直接贴进 PPT 或海报 —— 这是设计工作流的标准产物。

BGRA = 原图 + alpha 通道(掩码 * 255)
cv2.imwrite('cutout.png', BGRA)

注意必须存 .png:JPG 不支持透明,会把透明区域变成白块。

进阶 3 · 用同一套掩码数人头约 25 分钟

既然每个实例都带类别,数人只是把解码结果按类统计一下:对 res.classes 做 np.unique(..., return_counts=True),取类 0 的数量,就是画面里的人数。

想验证它和画面对得上:bus.jpg 上会得到 {0: 3, 5: 1} —— 3 个 person、1 个 bus,正好解释终端那句 4 instances 与画面上 3 个人的落差。

注意这只能数「这一帧」,要跨帧知道「还是不是同一个人」,得等 EXP-07 的跟踪。

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

你要产出三样:模块职责表 → 控制流流程图 → 时序图。提示词已给全,直接复制去问 AI;产出后与真源码逐条对照,差异处就是设计决策。

提示词 1 · 模块职责分析

请阅读这个语义分割项目的五个文件:src/app.py、src/preprocess.py、src/postprocess.py、src/background.py、src/rknnpool.py。
它们共同完成「读一帧图 → 抠出人 → 换成新背景」。
请输出一张表,每行一个模块,四列:模块名 / 一句话职责 / 被谁调用 / 从哪个参数取值。
要求指出每个模块的关键函数所在行号;说不清「谁调谁」的地方请明确标注,不要猜。

提示词 2 · 控制流流程图

请按「主线程处理一帧」的视角,为 src/app.py 的 main() 画一张控制流流程图。
必须包含:四个输入分支(--image / --video / --camera / --ros)如何汇合;主循环里取帧失败、推理结果为空这两条异常路径;退出循环后释放池与服务。
每个框里写函数名,判断框写条件,不要用「处理」「分析」这类空词。

提示词 3 · 时序图

请为实时模式(--ros)画一张时序图,参与者必须使用真实名字:ros2_camera 节点、Ros2Camera 回调、RKNNPool 的 worker 线程、主线程、MjpegServer。
按时间顺序标出:话题消息到达、入队、worker 取帧推理、结果出队、render 合成、set_frame 推流。
并标注主线程与 worker 是并行流水线(不是同步等待)。

自查三问:每个模块都能指到真实文件与行号?流程图里有分支和退出条件?时序图的参与者是真名而不是「系统 / 用户」?

三处必然的差异,先给你对答案(这些差异就是工程要解决的问题):

差异点AI 版常见写法工程版(本仓)为什么工程这么写
类别过滤放在哪在解码阶段就用 classes 过滤,只保留想要的类解码不管类,foreground_mask() 才按 classes 挑行(postprocess.py:133)日志要报告全部检出(4 instances),抠图只留 person。提前过滤会让终端那句 4 变成 3,反而看不出「有东西被丢掉了」
掩码要不要按框裁剪上采样后直接阈值,全图都算先把这个实例的框外清零,再阈值(postprocess.py:124-130)32 张低分辨率模板拼出来的掩码会溢出到别人的框里;不清零,两人靠近时掩码会粘成一片
多线程怎么写ThreadPoolExecutor.map 每帧新建任务,共享一个模型对象固定 N 个常驻 worker,每个持独立 RKNN 实例,round_robin 时按 tid%3 绑核(rknnpool.py:16-23)NPU 上下文不能跨线程共享,共享实例会直接崩;绑核是为了避免多个 worker 抢同一个核
背景合成的写法addWeighted 或三通道各算一次浮点乘out[~fg_mask] = 填充值 的布尔索引(background.py:28-39)掩码本来就是 0/1,不需要浮点 alpha 混合;布尔索引一次写完三个通道,省掉 40 万次乘法

改一处、先预测再验证:把 model.classes 改成 [5],先写下你认为 out.jpg 会变成什么样,再跑一次对答案。

06互动:拖 mask_thr 和 classes,看 4 变 3、边缘胀缩

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

第 05 节里改一次配置只能出一个结果,还得重跑。这里把步骤 5 和步骤 6 搬到页面上:同一帧合成画面、同一套 4 个实例,两个参数随便拖,结果实时重算 —— 不用设备也能看清「谁决定留下谁、谁决定边缘在哪」。

⚡ 参数实验台 · mask_thr 与 classes 同时作用在合成结果上
0.50
默认 mask_thr = 0.50、classes = [0]:检出 4 个实例,只放行 3 个 person,bus 与背景一起变黑。
互动 6-1|画面里的四个虚线框就是「模型已检出的 4 个实例」:青色 = 被 classes 放行,红色 = 被挡在外面;黑色是 mode: black 的背景。掩码按 sigmoid(到轮廓的距离) 逐像素重算,不是贴图切换:调 mask_thr 就是在给那张概率图换一条切线。

怎么读出规律:先只看 classes —— 4 个实例的检出数不变,变的只是「放行几个」;再看 mask_thr —— 放行的实例不变,变的是每个人轮廓的胖瘦。两件事互不干扰,这正是第 04 节步骤 5 与步骤 6 的分工。

拖到 0.7~0.8
轮廓缩水

只剩身体核心:头顶、手臂末端、脚先消失。这是「宁缺」的一侧。

停在 0.5
边界落在轮廓上

sigmoid 的中点,边缘基本贴合真实边界。默认值就是它。

拖到 0.3~0.4
边缘往外糊

轮廓外挂上一圈旧背景的颜色 —— 第 02 节第三问那圈暗边,就是它。

切到 [5]
人和车互换

车留下、人变黑剪影。检出一直是 4 个,变的只是放行名单。

切到 [5] 只留 bus 时,真机上跑出来是这样 —— 巴士完整保留,三名行人变成纯黑剪影。注意这时终端打的仍然是 [seg] 4 instances,一个字都没变:检出数压根不受 classes 影响。

classes 只保留 bus 时的 black 模式结果:蓝色巴士完整保留,三名行人位置变成纯黑剪影
图 6-1|板上实测 classes: [5]:车在、人变黑剪影,日志仍是 4

三种典型设置

mask_thr你会看到什么时候用
0.3(低)白区外胀,头发丝都算进来了,但同时带进一圈背景残片要「完整包含」时,比如量伤口的面积
0.5边界大致落在真实轮廓上默认值,绝大多数场景从这里起步
0.8(高)白区收缩,只剩身体核心,手指和发梢丢失只要「一定有把握的区域」时,比如确认这里确实有人
mask_thr 回答的是「有多确定才算人」,classes 回答的是「谁有资格被算人」。前者改边缘,后者改名单,一次只动一个,画面变化才对得上号。

07验证:三道小测

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

回页首对照「实验目标」逐条自检:① 能不能说清检测与分割的差别、并解释「报 4 留 3」→ 试 Q1 与 Q2;② bus.jpg 的单图流程跑通没有、out.jpg 与第 03 节那四张对得上没有,翻回第 05 节自查;③ 三个参数是否都亲手改过一次、能否说出各自改哪里 → 试 Q3,并回第 06 节把 mask_thr 拖到两端看一眼。答不上的条目回到对应小节补完再交卷 —— 三条全 ✓,这一课就完成了。

Q1模型为什么不直接输出一张 640×640 的掩码,而要给每个实例 32 个数去乘 32 张 160×160 的原型图?
因为 32 个数精度更高,边缘更细
因为直接输出全分辨率掩码的参数量和内存都太大,两段式能把输出压到 32 个系数 + 一张公共原型表
因为每张原型图分别对应一个 COCO 类别
对。一张 640×640 的概率图就有 40 万个数,要在网络中间直接吐出它,参数量会涨到边缘设备扛不住。代价也很明确:原型只有 160×160,边缘天生是模糊的过渡带,小目标和细结构(手指、发丝)表达不出来 —— 所以才需要 mask_thr 去切。第三个选项是常见误解:32 张原型是所有物体共用的形状基元,不分类别。
Q2终端打印 [seg] 4 instances,可 out.jpg 里只有 3 个人、巴士整辆变黑。原因是什么?
模型没认出巴士,4 个都是人
模型检出了 4 个(3 person + 1 bus),但 classes: [0] 只把 person 的掩码并进前景,bus 被判成背景换掉了
NMS 把巴士当成重复框抑制掉了
对。这里的两个数说的是两件事:4 是模型检出的实例数(打印在解码之后),3 是被保留的实例数(由 foreground_mask() 按 classes 决定)。把配置改成 [5],画面就反过来:车在、人变黑剪影。记住这条分界,就不会再被日志和画面打架搞晕。
Q3换背景后每个人轮廓外都挂着一圈旧背景的暗边。想让它收紧,该动哪个参数?
把 model.score_thr 调高
把 model.mask_thr 从 0.5 调到 0.6~0.7
把 pool.workers 改成 1
对。暗边说明掩码比真实轮廓大了一圈,那是 0~1 概率图在阈值处切得太靠外。提高 mask_thr 会让白区收缩、边缘收紧 —— 但别贪心:过了 0.8,手指和发梢会先消失。score_thr 管的是「这个框要不要」,改它只会让整个人消失,不会修边缘;workers 只影响帧率。

08章节总结:检出是模型的活,留谁是 classes 的活

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

概念它解决了什么和别的关系
letterbox + RGB把任意尺寸的画面变成模型认得的 640 见方上游是输入帧,下游决定掩码能不能正着映射回原图;pad_x/pad_y/ratio 一路传到步骤 6 才用完
13 输出一次推理同时给出框、类别与形状信息上游靠步骤 2 的 NPU 推理产出,下游被步骤 3 拆成三层四组逐层解码
DFL + NMS把 8400 个格子的候选压成 4 个实例直接决定 score_thr/iou_thr 的后果:误检、漏检、两人并一个
mc @ proto用 32 个数 + 32 张公共模板还原出逐像素轮廓上游吃步骤 3 留下的系数,下游给出 160×160 概率图;它是「不直接输出掩码」换来的速度,代价是边缘模糊
mask_thr把概率切成黑白,决定边界落在哪上游接上采样后的概率图,下游直接决定边缘胀缩与前景像素数
foreground_mask按 classes 挑实例、按位或成一张前景掩码上游吃 N 张实例掩码,下游是合成唯一要用的 0/1 图;「报 4 留 3」就发生在这里
compose 合成白处留原图、黑处贴背景,一台虚拟背景机成型最末端:mode 只决定「填什么」,不改变掩码质量
推理池 pool.workers用多核把分割多出来的那几万倍计算量抢回一部分帧率横向于整条链:只影响 FPS,不影响掩码;3 worker 约 40~60,1 worker 约 20~40
⚠ 三个反直觉的地方 ① 直觉是终端说 4 个实例、画面就该有 4 个东西,实际是「检出」由模型决定、「保留」由 classes 决定 —— 日志和画面本来就可以不一致,这恰恰是你调试的依据;
② 直觉是mask_thr 越高越准,实际是它只是把切线往「宁缺」那头挪:0.7 修掉边缘残影,0.8 开始丢手指,本来就没有「更准」这回事;
③ 直觉是分割比检测慢是因为模型更大,实际是慢在要算几十万个像素 —— 模型只有 3.2M 参数,但输出从「几个框」变成「一整张图」。
💡 四条要注意 ① background.color 是 BGR:想要红写 [0,0,255],写成 [255,0,0] 得到的是蓝;
② 数一数就知道的陷阱:proto 是 160×160,模型空间是 640×640,正好 1/4 —— 所以掩码必须放大 4 倍,边缘那道模糊过渡带是放大带来的,不是 bug;
③ 两个「4」不要混:8400 是格子数(80²+40²+20²),4 是 NMS 之后的实例数,4 instances 里的 4 是后者;
④ core_mask 默认是 auto,不是 round_robin —— 想让 3 个 worker 各钉一个 NPU 核,得自己去配置里改;另外 --save 在 --ros 模式下不生效(会打印一行提示然后忽略)。

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

类型具体项缺了会怎样 / 怎么补
环境ROS 2 Humble 两级 source(/opt/ros/humble/setup.bash + ~/ros2_ws/install/setup.bash),相机节点 ascamera hp60c.launch.py,两个终端 ROS_DOMAIN_ID 一致只影响 --ros 实时模式:起不来会打印 cannot open ros topic;单图 --image 不受影响
Python 库numpy、opencv-python、pyyaml、rknnlite 2.3.2、mjpeg_streamer缺 mjpeg_streamer 会在启动时打印安装指引并 exit 2(进口处就检查);缺 rknnlite 则模型加载失败
模型 / 资源models/yolov8n-seg.rknn(int8,13 输出,约 7MB)与 images/bus.jpg;image 模式还需要一张背景图模型缺了没有任何输出;没有 bus.jpg 就对不上第 03 节那四张;背景图路径写错会在合成时抛 cannot read background image
配置config/seg_config.yaml:imgsz: 640、score_thr: 0.25、iou_thr: 0.45、mask_thr: 0.5、classes: [0]、background.mode: black、pool.workers: 3、core_mask: auto文件缺失或 key 不全,load_config 直接抛 ConfigError;mode 拼错、或 mode: image 却没写 image 路径,同样在校验时被拦下
硬件 / 前置实验RK3576 板(3 核 NPU,3 worker 才到 40~60 FPS)+ EXP-04 的检测基础(框是怎么来的)+ EXP-05 的推理池概念单核上开 3 个 worker 不会更快;不清楚框从哪来,就理解不了「分割 = 检测 + 掩码」这句话

这套代码用在哪儿

  • 视频会议 / 直播虚拟背景:每帧出 person 掩码 → 白处留原图、黑处贴背景图 → 推 http://<板IP>:8080/seg.mjpg;进阶 1 把背景源换成高斯模糊的原图,立刻变成「人清晰、背景柔化」。
  • 证件照 / 海报抠图:把掩码当 Alpha 通道存成 cutout.png,产出一张透明背景人像,直接交给设计贴图 —— 必须 PNG,JPG 不支持透明。
  • 工位人形检测与安全区判定:机器人只需知道「人形区域在哪」,就用这张前景掩码做避让区;classes 换成需要的类,同一套代码还能抠设备、车辆。
  • 人流计数:进阶 3 用 np.unique(res.classes, ...) 数 person 实例,适合固定机位的粗粒度统计;要跨帧认人就得接 EXP-07。
核心记忆点:分割 = 检测 + 掩码。classes 决定「留谁」,mask_thr 决定「边缘在哪」—— 前者改名单,后者改胖瘦,一次只动一个。

下一个实验要把这张「这一帧谁是人」的掩码,变成「跨帧还是不是同一个人」的编号 —— 你现在拿到的每个实例掩码,就是 DeepSORT 的起点。