畅联云平台丨02-Tile滑窗与NMS去重

Tile 滑窗与 NMS 去重:高分辨率人体检测的几何与抑制
本文讲透一件事:当原图远大于检测网络固定输入(如 640×640)时,本项目如何用 Tile 滑窗 保住远处小目标的像素密度,再用 核心区归属 + NMS / prefer-face NMS + 形态过滤 把重叠报告压回「一人一框」。重点不是「切块很常见」,而是网格如何选、正方形 tile 为何强制、中心落在重叠带为何丢弃、IoU 阈值与有脸偏好如何改写淘汰规则。
一、问题定义:整帧缩放为何会「看不见人」
检测网络对目标在输入张量上的像素高度有下限。监控源常见 1920×1080、2560×1440、3840×2160,而 YOLO 输入边长常固定为 640。若整帧 letterbox:
scale_full ≈ 640 / max(W, H)
人身高_in_net ≈ 人身高_原图 × scale_full
数值例子(3840×2160,人身高 80 px):
scale_full = 640/3840 ≈ 0.1667
人在网络上 ≈ 80 × 0.1667 ≈ 13.3 px
13 像素高的人,特征几乎塌成噪声;召回断崖。若把同一人切进约 1280 边长的 tile 再 letterbox 到 640:
scale_tile ≈ 640/1280 = 0.5
人在网络上 ≈ 80 × 0.5 = 40 px
同样的人,像素密度大约翻了三倍。Tile 的本质不是「多画几条网格线」,而是用多次局部推理换取「人物在网络输入里仍有足够像素」。

图:高分辨率帧划成 G×G 网格;tile 边长大于步长,相邻块形成重叠带,跨缝的人至少能完整落入一块。
二、何时启用 Tile:分辨率门槛
按帧规划 Tile 的第一道闸:
含义:
- 宽 < 1280 且 高 < 720:返回空 plan(grid<=0),不做滑窗,整帧一次检测足够;
- 否则进入网格规划。
注意是 AND 不是 OR:一张 1920×600 的超宽条(高不足 720)仍会触发 Tile,因为宽 ≥1280。一张 800×900 的竖图(宽不足 1280)若高 ≥720 也会触发。只有「两边都偏小」才跳过。
失败模式: 若把条件误写成 ||,大量 720p 竖拍会无意义地跑 9~16 次推理;若误写成只看宽,竖屏 1080×1920 可能永远不 Tile,远人永久漏检。
三、网格密度:4K 用 4,其余用 3

「误检更少」来自:同一物理噪声(衣架、海报人形)在更大 tile 里相对更小,分数不易过阈;网格过密时噪声也更容易被「放大」成候选。
四、重叠率、base 与正方形 tile_side
核心公式由按帧规划 Tile 给出:
拆开:
- overlap = 0.25:相对 base 放大 25%,制造重叠带;
- base_w/h = frame / grid(下限 320):无重叠时的「格子」尺寸,也是 步长 step;
- tile_side = max(640, max(base_w, base_h)×1.25):强制 正方形 tile;
- step = base:滑窗步进等于 base,不是 tile。
网格与 tile 边长规划:
PersonFocusYoloTilePlan person_focus_yolo_tile_plan_for_frame(int frame_w, int frame_h) {
PersonFocusYoloTilePlan plan;
if (frame_w < 1280 && frame_h < 720) return plan; // 不切块
plan.grid = (frame_w >= 3840 || frame_h >= 2160) ? 4 : 3;
const double overlap = 0.25;
const int base_w = std::max(320, (int)std::lround((double)frame_w / plan.grid));
const int base_h = std::max(320, (int)std::lround((double)frame_h / plan.grid));
const int tile_side = std::max(640, (int)std::lround(std::max(base_w, base_h) * (1.0 + overlap)));
plan.tile_w = std::min(frame_w, tile_side);
plan.tile_h = std::min(frame_h, tile_side);
plan.step_x = std::max(320, base_w);
plan.step_y = std::max(320, base_h);
return plan;
}
为何强制正方形?
注释写得很直白:避免 2K 下出现类似 800×450 的扁条。扁条 letterbox 到 640 时,纵向有效像素被压得很矮,站立人体在网络里「又矮又糊」。取 max(base_w, base_h) 再 ×1.25,保证短边方向也有足够视野与像素。
数值例子 A:1920×1080,grid =3
base_w = round(1920/3) = 640
base_h = round(1080/3) = 360
tile_side = max(640, max(640,360)×1.25) = max(640, 800) = 800
tile_w = tile_h = 800
step_x = 640, step_y = 360
水平方向:tile 800、步长 640 → 重叠宽度约 (800-640)=160,即每侧约 80 像素的归属争议带。
垂直方向:tile 800、步长 360 → 重叠更大(正方形相对扁格子「探」得更远)。
数值例子 B:3840×2160,grid=4
base_w = 960, base_h = 540
tile_side = max(640, 960×1.25) = 1200
step_x = 960, step_y = 540
人站在两块水平缝上时,只要身体水平跨度不超过重叠量级,至少一块能包住完整躯干。

图:每个 tile 的边缘重叠带不「认领」框;只有中心落在非重叠核心区(core)的检测框才保留,从源头减少跨块重复。
五、 ROI 起点如何摆:居中回退
直觉:
- 理想起点 = g×step - (tile-step)/2,让重叠关于格子对称;
- 再夹到 [0, W-tile],避免右下角越界;
- roi.width < 320 || roi.height < 320 的残片直接跳过。
失败模式: 若起点只写 gx*step 且不回退,最后一列可能只剩很窄的条;若回退公式用错符号,整行网格会整体错位,框呈「周期性平移」。
六、核心区过滤:中心不在 core 就丢
重叠带来的副作用是:同一人会在相邻 tile 各报一次。本项目不只靠事后 NMS,还在合并 前做 中心归属:
要点:
- 用框中心,不用整框 IoU;半身残框若中心仍落在 core,仍可保留;
- 画幅边缘的 tile:贴边一侧 margin=0,否则靠边的人中心永远进不了 core;
- margin = (tile - step)/2 正是重叠带的一半宽度。
中心是否落在非重叠 core:
bool person_focus_yolo_box_center_in_tile_core(int frame_w, int frame_h, const cv::Rect &tile_roi,
const PersonFocusYoloTilePlan &plan,
const PersonFocusPersonBox &box) {
const float cx = box.x + box.w * 0.5f;
const float cy = box.y + box.h * 0.5f;
const int margin_x = std::max(0, (plan.tile_w - plan.step_x) / 2);
const int margin_y = std::max(0, (plan.tile_h - plan.step_y) / 2);
const int left = tile_roi.x + (tile_roi.x > 0 ? margin_x : 0);
const int top = tile_roi.y + (tile_roi.y > 0 ? margin_y : 0);
const int right = tile_roi.x + tile_roi.width - (tile_roi.x + tile_roi.width < frame_w ? margin_x : 0);
const int bottom = tile_roi.y + tile_roi.height - (tile_roi.y + tile_roi.height < frame_h ? margin_y : 0);
return cx >= (float)left && cx < (float)right && cy >= (float)top && cy < (float)bottom;
}数值例子(接 1920×1080)
某内陆 tile:x0=560, y0=180, w=800, h=800(示意),margin_x=(800-640)/2=80,margin_y=(800-360)/2=220。
core: left=560+80=640, right=560+800-80=1280
top=180+220=400, bottom=180+800-220=760
若人中心 (cx,cy)=(620,500):落在左重叠带 → 本 tile 丢弃(应归左侧邻居)。
若中心 (700,500):在 core → 保留。
失败模式:

Core 过滤是 硬几何先验:先减少候选,再让 NMS 收拾残余。
七、坐标映射:tile 局部 → 全图
每个 tile 裁切后再 letterbox 进网络。框还原顺序必须是:
- letterbox 坐标 → 去 pad、除 scale → tile 图像坐标;
- 加上 (tile_x0, tile_y0) → 全图坐标;
- 与全图边界求交。
写成:
x_full = (x_lb - pad_x) / scale + tile_x0
y_full = (y_lb - pad_y) / scale + tile_y0
w_full = w_lb / scale
h_full = h_lb / scale
调试口诀:
- 在单 tile 图上画框 → 已偏:解码/letterbox;
- tile 对、全图挤左上:忘加 tile_x0/y0;
- 整片平移:pad 符号反了;
- 网格状周期错位:某一行/列的 ROI 原点算错。
八、标准 NMS:分数排序 + IoU 淘汰
块内解码后,以及全图合并后,都会走 OpenCV cv::dnn::NMSBoxes。
全局人体框合并使用同一套 IoU 阈值逻辑。
这里 score 下界写成 0.01f:进入该函数的框通常已过检测阈,NMS 阶段不再二次狠滤分数,专注 IoU 去重。
IoU 定义与手算
IoU = 交集面积 / 并集面积
= inter / (area_a + area_b - inter)
prefer-face 路径会自实现 IoU 计算(与 OpenCV 语义一致):
手算例子:
框 A:(100,100,80,200),框 B:(120,120,80,200)
inter = 60 × 180 = 10800
ua = 16000 + 16000 - 10800 = 21200
IoU ≈ 0.509
若 nms_iou=0.45,B 会被抑制;若阈值升到 0.55,B 幸存——并肩两人更安全,但跨 tile 重复也可能留下。

图:按分数排序后,与最高分框 IoU 超过阈值的候选被抑制;阈值过高留重复,过低误伤贴靠两人。
Soft-NMS 一句话
标准 NMS「超阈即删」;Soft-NMS「超阈即降分」。本项目路径用经典硬 NMS(及下面的 prefer-face 变体),监控人体通常够用。
九、 prefer-face NMS:有脸的框优先留下
纯分数 NMS 的痛点:跨缝时 半身残框分数偶发更高,完整框反而被删;或 无脸噪声框 压过 有脸真人框。因此有优先有脸的人体框 NMS:
关键机制:
- 按人体 score 降序扫描;
- IoU ≤ NMS IoU:不冲突,继续;
- IoU > NMS IoU:看两边脸集合是否「互不相同」:
- 一侧有对方没有的脸 → 保留有独特脸的一侧;
- 两侧都有独特脸 → 两边都留(避免两人贴靠被合成一个);
- 都无独特脸但都有脸且 IoU 很高(重复 IoU)→ 重复框决胜 决胜;
- 一侧有脸一侧无脸 → 倾向留有脸,无脸仅在高度重叠时被删。
若 nms_iou=0.45,则 dup_iou=max(0.57,0.58)=0.58(再与 0.92 取 min)。也就是说:「有脸 vs 无脸」的删除门槛比普通 NMS 更严,避免 ROI 扫脸失败时误删真人。
决胜函数 重复框决胜 还看面积比与更靠上的框(站立人头部更靠上):
最后还有:被抑制但带有 保留集里没有的脸 的框可以复活——防止「大框吞小框」丢掉旁边另一张脸。

图:IoU 冲突时,优先保留框内有脸(或有独特脸)的人体框;两侧都有独特脸则并存,避免贴靠两人被压成一个。
失败模式:
- face_scores 与 boxes 长度不一致 → 函数退回普通 NMSBoxes,有脸偏好失效;
- 扫脸阈值过高 → 真人都「无脸」,prefer-face 退化成面积/分数战;
- 重复 IoU 过低 → 有脸框过于强势,贴靠路人被误删。
十、形态过滤:长宽比与最大占比
NMS 之后(或前后配合)还有人体框几何过滤:
规则表:

直觉:
- 无脸 时比例要像站立人体,挡广告牌横条、细杆;
- 有脸 时放宽:蹲姿、侧躺、被裁切的半身仍可能是真人;
- 0.85 帧:接近整幅的「人」多半是误检或场景分割错误,直接丢。
数值例子
1920×1080 帧:max_w=1632, max_h=918。
框 1800×1000:超限 → 丢。
框 40×200,ar=5.0,无脸 → 丢;若有脸且 ar=4.2 → 可留。
失败模式: 夜间低分导致大量「无脸」,比例过滤偏严,蹲着的人被杀光;或 max 比例放宽到 1.0,过曝光斑整幅框涌进跟踪。
十一、算力账本:Tile 不是免费的
粗算(忽略重叠与 batch 效率):

重叠使实际裁切面积更大,但 GPU batch 可摊薄 kernel 启动。工程上常配合:隔帧检测、分数阈、动态是否 Tile。选型不是「网格越大越好」,而是「人物在 640 输入上是否 ≥ 约 30~40 px」与时延预算的交点。
与「整帧改用 1280 输入」对比:
- 中心少数人、算力紧:先试更大输入边长;
- 人散布四角、4K 远景:倾向 Tile;
- 两者可折中:Tile 内仍用 640,而不是 Tile 后再拉到过大边长。
十二、端到端故事线(带数字)
- 3840×2160 帧进入,w≥3840 → grid=4,overlap=0.25,tile_side=1200,step=(960,540);
- 收集至多 16 个 ROI,每块 letterbox→YOLO→逆变换到全图;
- 对每条框问:中心是否在该 tile 的 core?否 → 丢;
- 全图框列表做 NMSBoxes 或 prefer-face NMS;
- 过滤:无脸 ar∈[0.55,3.5],有脸放宽;边长 ≤0.85 帧;
- 输出给后续模块:原图像素系、尽量一人一框。
任一步偷懒(无重叠、无 core、无 NMS、比例过滤关掉)都会在跟踪里以「闪烁双人」「ID 乱跳」爆发。
十三、参数耦合怎么拧

一次只改一个旋钮,并在「远广场」「门口近景」「两人并肩」三类片段回归。
十四、常见故障对照表

十五、与「只靠全局 NMS」的本质差别
许多教程只写「切块 + NMS」。本项目多了两层:
- 几何归属(core):重叠带不认领,从源头减候选;
- 语义偏好(face):同样重叠时,有脸/独特脸说了算,而不是裸分数。
这使 Tile 在商场、展厅等人脸可检场景更稳:残缺高分框更难打败完整有脸框。
十六、重叠率的定量直觉
无重叠时,人中心恰好在 x=base_w 的缝上,左右各只看到约半身。若人体宽 W_person,每侧可见宽约 W_person/2。检测器对「半身」分数常系统性偏低。
设需要「至少一侧看到完整人」,则重叠宽度应 ≳ W_person。对本项目:
overlap_width ≈ tile_side - step ≈ 0.25 × max(base_w, base_h) (正方形情形下还受 max 影响)
1080p 例子中水平重叠约 160 px:对肩宽 ~80–120 的远人对付得了;对近景大肩宽 ~300 的人,仍可能双边残缺——此时依赖 prefer-face 与更大面积框决胜,或接受近景本可用整帧检测(但 1080p 已过 Tile 门槛时仍会切)。overlap=0.25 是召回与算力的工程折中,不是数学最优解。
