做过多路监控画面拼接的人大概都有过这种体验:四块屏摆在值班台上,眼睛在四个窗口之间来回跳,脑子里还得自己“缝”出一张完整的地图。某园区安保室当时就是这么干的,两个人盯六路,真出了状况,说不清“那辆车是从东门进来的还是从北边绕过去的”。所谓上帝视角,本质上就是把这种靠人脑缝合的活儿交给几何算法——几路固定机位拍到的画面,经过标定、去畸变、单应变换,压到同一个地面平面上,拼成一张俯视全景图,再把检测框、轨迹、告警叠上去,最后变成一张能点、能查、能回放的“电子沙盘”。
这篇内容讲的是多摄像头全景拼接在视频监控场景里的完整落地路径:内参外参怎么标、单应矩阵怎么算、融合带怎么接、实时性怎么抠、远处目标为什么会被拉成面条、傍晚切红外时画面为什么突然变黑白。适合手上已经有几路 RTSP 流、想做出俯视全景的工程师,也适合刚接触计算机视觉、想搞明白 IPM(逆透视映射)到底在做什么的朋友。代码以 Python + OpenCV 为主,参数会给具体数值算例,能直接抄作业。
1. 先想清楚:“上帝视角”到底在解决什么问题
1.1 俯视全景的真实价值不在“好看”,在“可判定”
很多人一开始的想法是“把四路画面拼成一张没有缝的大图”,做完之后发现除了给领导汇报时好看,日常值班并不怎么用。原因很直白:一张静态的俯视图,值班员还是得自己找目标在哪。真正让这套东西产生价值的,是统一坐标系——一旦所有相机都映射到同一张地面地图上,任何一路检测到的目标都有了唯一的米制坐标,跨镜头的轨迹串联、区域入侵判定、距离测量、数量统计才有落点。
我在一个物流场地的项目里吃过这个亏。第一版只做了画面拼接,效果很惊艳,但夜班师傅依然只看原始单路画面。第二版把车辆检测的框底边中心投影到地面坐标上,在俯视图里画成小箭头,多镜头之间用距离做简单关联,值班员第一次能直接看到“这辆车从卸货区开到了 3 号月台,停了 8 分钟”。使用率立刻不一样了。
所以判断一个“上帝视角”需求值不值得做,看三点:机位是否固定、监控对象是否主要在地面活动、是否存在跨镜头的连续追踪需求。三个都是“是”,那就有价值;机位会转动、主要看高空作业、单镜头足够覆盖,那就别折腾,直接装个球机更划算。
1.2 三条技术路线,别一上来就选最难的
| 路线 | 做法 | 优点 | 代价 | 适用场景 |
|---|---|---|---|---|
| 鱼眼展开 | 单颗鱼眼装顶棚,软件展开成俯视图 | 单机位、无接缝、成本最低 | 边缘畸变严重、分辨率被摊薄 | 小房间、电梯轿厢、小型仓格 |
| 多路拼接(IPM) | 多颗普通枪机,标定后单应变换到地面平面 | 分辨率高、覆盖大、可扩展 | 需要标定、接缝处理、远场失真 | 园区、停车场、路口、场地 |
| 三维重建 | 多目 + 深度估计,重建场景点云 | 能处理垂直物体、真三维 | 算力与标定成本高、实时性差 | 高阶数字孪生、立库、精密场地 |
绝大多数项目落在第二条。我一般建议先用单颗鱼眼做原型验证需求是否真实存在,确认有人用,再上多路拼接。这个顺序反过来做,很容易花两周调标定,最后发现业务方只是想要个大屏。
1.3 硬件清单与预算向的取舍
不追求极限的话,一套四路拼接的配置大概是这样:
| 部件 | 建议规格 | 说明 |
|---|---|---|
| 网络摄像机 | 200 万像素以上,定焦,手动锁焦锁光圈 | 自动对焦会在轻微震动后跑焦,标定全废 |
| 安装立杆 | 4~6 m,带防风拉索或加厚壁 | 杆子晃 1 厘米,接缝处就错位 5 个像素 |
| 交换机 | 千兆,支持组播或至少独立 VLAN | 四路 4 Mbps 主码流别和办公网混跑 |
| 计算单元 | 带核显的工控机或入门独显 | 纯 CPU 也能跑,但要降分辨率降帧率 |
| 标定辅材 | 卷尺、地面标记贴、水平尺 | 别小看,标定精度的上限由测量精度决定 |
摄像机最关键的一条:必须能关掉自动曝光、自动白平衡、自动聚焦。我在某停车场项目里被这个坑了三天——白天标定完完美,傍晚画面一变,接缝处亮度差出一条黑边,查了半天以为是融合算法的问题,实际是四台机器各自把曝光往上拉了不同的档位。
2. 拼接的底层逻辑:平面假设、坐标系与参数计算
2.1 为什么“压平”是可行的:地面平面假设
所有基于单应矩阵的俯视拼接,脚下踩的都是同一个前提:监控对象贴着同一个平面活动。地面是平面,车、人的落脚点在这个平面上,那么地面上的点从“相机图像坐标”到“地面米制坐标”之间,就存在一个可逆的射影变换。这个变换用一个 3×3 的矩阵描述,就是单应矩阵 H。
用生活化的话说:你站在二楼的窗户前往下看一块地砖,砖是梯形的;你走到楼顶正上方往下看,砖是矩形。H 描述的就是“从斜着看”到“正着看”这一步换算关系。只要相机不动、地面是平的,这个换算关系就是恒定的,算一次能用很久。
假设不成立的时候会发生什么?一根 1.8 米高的杆子,它的顶点在地面平面之外,用地面单应矩阵去投影,投影结果会沿着“从相机出发经过杆顶的射线”一直延伸到地面上——于是杆子被“推倒”铺在地上,看起来像一道长长的影子。这不是 bug,是几何必然。理解这一点,后面很多“为什么人变成面条了”的疑问就自解了。
2.2 从相机位姿推导单应矩阵:公式与直觉
严谨的写法是这样。相机成像模型:x = K [R | t] X,其中 K 是内参,R、t 是世界坐标到相机坐标的旋转和平移,X 是世界坐标点。地面在世界坐标系里定义为nᵀX = d,n 是平面法向量,d 是原点到平面的距离。把平面条件代入,得到
H = K (R + t·nᵀ / d)把地面上的世界点喂给 H,就得到像素坐标,反解 H 的逆矩阵,就得到“像素 → 地面”的映射。这个式子解释了两个工程直觉:一是相机越高、越接近垂直向下看,H 越接近一个简单的缩放加平移,拼接也越好做;二是相机的俯仰角直接进 R,所以安装角度的微小变化会被放大到接缝上,这就是立杆必须抗风的原因。
实际项目里我基本不用这条公式硬算。测 R、t 需要用水平尺量俯仰角、用卷尺量高度和位置,误差很容易到 2~3 度,折算到 30 米外的地面就是 1.5 米的偏移。更稳的做法是直接从图像点对求 H,让测量误差被最小二乘法摊平。
2.3 关键参数计算:分辨率、覆盖范围、地面采样距离的三角债
这三个量互相牵制,定不下来就没法继续。先给个算例:摄像机水平分辨率 1920 px,传感器宽 5.6 mm,镜头焦距 2.8 mm,那么像素焦距
f_px = f_mm × W_px / sensor_W = 2.8 × 1920 / 5.6 = 960 px在距离 d 处的地面采样距离GSD = d / f_px。d = 10 m 时是 1.04 cm/px,d = 30 m 时是 3.1 cm/px。也就是说同一路画面,近处一个像素代表 1 厘米,远处代表 3 厘米——这就是远处目标糊、近处目标清的根本原因,也是“远处目标被拉长”的伏笔。
反过来定输出画布:想让俯视图每个像素代表 5 厘米(mpp = 0.05),覆盖 40 m × 40 m,画布就是40 / 0.05 = 800像素见方。如果想让 mpp 细到 2 厘米,画布就是 2000×2000,但 30 米外的原始像素只有 3 厘米精度,你只是把糊的地方插值放大了,除了变慢没有任何收益。
我常用的经验法则是:输出 mpp 取“最远有效监控距离”处 GSD 的 0.8 倍左右,既不让近处过度降采样,也不让远处出现大量空插值。以上面的相机为例,最远关注 25 米,GSD 约 2.6 cm,输出 mpp 取 0.03~0.05 都合理。
2.4 顺序错了全盘皆输:先去畸变,再求单应
这是新手最容易翻车的地方。单应变换是射影变换,它假设输入图像是理想针孔成像的结果;而真实镜头有桶形或枕形畸变,尤其在画面边缘,直线会被掰弯。如果你拿带畸变的原图直接求 H,H 会试图用一个线性变换去拟合非线性的弯曲,结果是接缝处怎么调都对不上,而且只有边缘对不上,中间还挺准——这个现象几乎可以作为“忘了去畸变”的指纹。
正确顺序固定为四步:内参标定 → 去畸变 → 求单应 → 融合。前面提到的合并查表优化,正是把“去畸变”和“单应重投影”两趟全图遍历合并成一趟,逻辑上顺序不变,只是计算上合并。
提醒:内参标定必须用这块镜头装在现在这个位置、这个焦距的状态去做。换了机位、动了变焦环,内参就变了,标定必须重来。定焦镜头这点省事,变焦镜头一定要在施工现场锁死变焦环并做好标记。
3. 完整实操:四路相机到一张全景图
3.1 环境准备与项目结构
依赖很简单,OpenCV 4.x + NumPy 就够,要读 RTSP 流的话再加个 FFmpeg 后端。目录按职责拆开,标定产物和运行时分离:
panorama/ ├── calib/ │ ├── intrinsic/ # 每路相机内参 yaml │ ├── points/ # 标定点对 csv(图像坐标 + 地面坐标) │ └── homography/ # 求解出的 H 与合成查表 ├── runtime/ │ ├── capture.py # 拉流与时间戳对齐 │ ├── stitch.py # 查表重投影与融合 │ └── overlay.py # 检测结果落地绘制 └── assets/ └── ground_markers.csv # 地面标记点的米制坐标真值把“标定”和“运行时”彻底分开,是因为标定是个反复试错的过程,而运行时要求稳定。我见过把标定逻辑写进主循环的代码,每次启动都重算一遍 H,结果某天 RANSAC 随机种子不同,拼出来的图和昨天差了几个像素,接缝位置全变了,排查了很久。
3.2 内参标定:棋盘格与张氏标定的实操细节
标准做法是打印一张棋盘格,同一机位拍 15~25 张不同姿态的照片,用cv2.findChessboardCorners提角点,cv2.calibrateCamera解内参和畸变系数。
import cv2 import numpy as np import glob PATTERN = (9, 6) # 内角点数 SQUARE = 0.025 # 方格边长,单位米 objp = np.zeros((PATTERN[0] * PATTERN[1], 3), np.float32) objp[:, :2] = np.mgrid[0:PATTERN[0], 0:PATTERN[1]].T.reshape(-1, 2) * SQUARE obj_points, img_points = [], [] for path in glob.glob("calib/board/*.jpg"): img = cv2.imread(path) gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) ok, corners = cv2.findChessboardCorners(gray, PATTERN, None) if not ok: continue corners = cv2.cornerSubPix( gray, corners, (11, 11), (-1, -1), (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001)) obj_points.append(objp) img_points.append(corners) rms, K, dist, _, _ = cv2.calibrateCamera( obj_points, img_points, gray.shape[::-1], None, None) print("重投影误差 RMS =", rms)重投影误差 RMS 在 0.3 像素以内算合格,超过 1 像素说明有照片角点提错了或者板子拍糊了,删掉那几张重来。不要迷信自动选图,我一般会把每张图的重投影误差单独打出来,把最差的两三张剔掉,RMS 能从 0.8 掉到 0.25。
一个非常实用的技巧:标定板别只在画面中心晃。桶形畸变在边缘最明显,标定图必须覆盖四个角和边缘区域,否则边缘的畸变系数根本约束不住,去畸变之后画面边缘依然是弯的。至少要有 5 张图是让标定板出现在画面四角的。
3.3 外参标定:地面上摆点,比测角度靠谱得多
外参不做公式推导,直接求 H。做法是:在地面上沿着每个相机的视野铺标记点,记录它们的米制坐标,同时在图像上点出对应像素坐标,凑够 8~12 对之后用 RANSAC 求解。
具体操作步骤,我一般是这么干的:
- 选一个世界坐标原点,比如立杆正下方地面。约定 X 轴向东、Y 轴向北,单位米。
- 在每个相机的视野里,用地面标记贴摆出若干点位。近处密一点(3、5、7 米),远处稀一点(12、18、26 米),四个方向都要有,别只摆一条直线——共线的点会让 H 退化。
- 用卷尺或激光测距仪量出每个标记点的 (X, Y),记到
ground_markers.csv。 - 打开对应相机的抓拍图,手动点出每个标记点的像素坐标。这一步是纯体力活,但精度直接决定成品质量,别偷懒用“大概”的位置。
pts_img = np.array([[420, 860], [640, 812], [905, 770], [1180, 745], [380, 640], [660, 600], [980, 566], [1300, 545], [350, 430], [700, 405], [1050, 390], [1420, 380]], dtype=np.float64) pts_gnd = np.array([[-3.0, 3.0], [0.0, 3.0], [3.0, 3.0], [6.0, 3.0], [-3.0, 5.5], [0.0, 5.5], [3.0, 5.5], [6.0, 5.5], [-3.0, 9.0], [0.0, 9.0], [3.0, 9.0], [6.0, 9.0]], dtype=np.float64) H, inliers = cv2.findHomography(pts_img, pts_gnd, cv2.RANSAC, 3.0) print("内点数:", int(inliers.sum()), "/", len(pts_img))内点数少于总点数的 70% 就要警惕,说明有点位点错了或者地面不平。验证方法:随便挑几个没参与求解的地面点,用 H 反算像素坐标,和实际观察位置比,误差在 2~3 个像素以内就合格。
3.4 生成鸟瞰图:把 H 变成画布坐标
求出的 H 输出的是米制坐标,要落到画布像素上还需要一次缩放和平移,而且注意 Y 轴方向反了(图像行号向下增,地面坐标向北增):
MPP = 0.05 # 每像素代表 5 厘米 W_M, H_M = 40.0, 40.0 # 覆盖 40m x 40m ORIGIN_X, ORIGIN_Y = -20.0, -20.0 W_PX, H_PX = int(W_M / MPP), int(H_M / MPP) S = np.array([[1 / MPP, 0, -ORIGIN_X / MPP], [0, -1 / MPP, H_M / MPP + ORIGIN_Y / MPP], [0, 0, 1]], dtype=np.float64) M = S @ H bev = cv2.warpPerspective(frame, M, (W_PX, H_PX), flags=cv2.INTER_LINEAR, borderMode=cv2.BORDER_CONSTANT)S里的那个负号漏掉,整张图会上下翻转,人看起来是倒着走的——这个错我犯过一次,找了一下午。
3.5 合并查表:把两次全图遍历压成一次
这是实时化最关键的一步。按朴素流程,每帧要先undistort(一次全图重采样),再warpPerspective(又一次全图重采样),两次插值还会叠加模糊。合并做法是:先算好“BEV 画布上每个像素应该去原始图像的哪个位置采样”,再把这个目标位置本身通过去畸变的映射表反推回带畸变的原始坐标,最后只做一次remap。
def build_lut(K, dist, M_inv, W_PX, H_PX, w, h): # 1. 去畸变的映射表:畸变图 -> 理想图 map1, map2 = cv2.initUndistortRectifyMap(K, dist, None, K, (w, h), cv2.CV_32FC1) # 2. BEV 画布每个像素 -> 理想图像坐标 xs, ys = np.meshgrid(np.arange(W_PX), np.arange(H_PX)) pts = np.stack([xs.ravel(), ys.ravel(), np.ones(W_PX * H_PX)], axis=0) src = M_inv @ pts src = src[:2] / src[2] mx = src[0].reshape(H_PX, W_PX).astype(np.float32) my = src[1].reshape(H_PX, W_PX).astype(np.float32) # 3. 理想图像坐标 -> 畸变图像坐标 mapx = cv2.remap(map1, mx, my, cv2.INTER_LINEAR, borderMode=cv2.BORDER_CONSTANT, borderValue=-1) mapy = cv2.remap(map2, mx, my, cv2.INTER_LINEAR, borderMode=cv2.BORDER_CONSTANT, borderValue=-1) return mapx, mapy运行时每帧只剩一句cv2.remap(frame, mapx, mapy, cv2.INTER_LINEAR)。实测在 800×800 输出、四路 1080p 输入的配置上,单路从 6.8 ms 降到 2.1 ms,四路加起来省了接近 19 ms,帧率从 18 fps 直接推到 30 fps 出头。查表可以序列化成.npz存盘,启动时加载,省掉几十毫秒的初始化。
3.6 融合带:加权蒙版比想象中够用
四路画面的重叠区不做处理,接缝就是一条硬边,亮度差一点就非常刺眼。工业级的做法是找接缝线(cv2.detail_DpSeamFinder)+ 多频段融合(cv2.detail_MultiBandBlender),效果确实好,但耗时也上去了,而且参数敏感。
我现在的默认选择是基于距离变换的加权平均,简单、快、不敏感,绝大多数监控场景够用:
def weight_map(mask): # 离有效区域边界越远权重越高,接缝自动落在两边权重相等处 return cv2.distanceTransform(mask, cv2.DIST_L2, 5) acc = np.zeros((H_PX, W_PX, 3), np.float32) wsum = np.zeros((H_PX, W_PX, 1), np.float32) for cam in cameras: img = cam.read() bev = cv2.remap(img, cam.mapx, cam.mapy, cv2.INTER_LINEAR) valid = (bev.sum(axis=2) > 0).astype(np.uint8) if not valid.any(): continue w = weight_map(valid)[..., None] acc += bev.astype(np.float32) * w wsum += w panorama = np.where(wsum > 0, acc / np.maximum(wsum, 1e-6), 0).astype(np.uint8)distanceTransform在这里的作用很巧妙:它让每个像素的权重随“离自己视野边缘的距离”线性增长,于是两路重叠区的权重曲线自然交叉,接缝恰好落在交叉点上,而且过渡是渐变的,不会有硬边。
经验:重叠区宽度别低于画面宽度的 10%。太窄的话权重过渡带不够,稍微有点标定误差就会出现“一边亮一边暗”的斑块。四路拼接时我把相邻相机水平视场角重叠设计成 15 度左右,落地效果比较稳定。
3.7 亮度与色彩一致:先把相机调一致,再谈算法
顺序很重要。能在相机端解决的绝不留到算法端。四台机器手动锁定同一组曝光时间、增益、白平衡色温,这一步做好,重叠区的亮度差通常已经在 5 个灰阶以内,后面的补偿只需要微调。
算法端的做法是以一路为基准,其余各路做增益匹配:取重叠区有效像素的均值,算一个比值,然后整幅图乘上去。
def gain_match(src, ref, mask, clip=0.35): m_s = cv2.mean(src, mask=mask)[:3] m_r = cv2.mean(ref, mask=mask)[:3] gains = [np.clip(r / max(s, 1e-3), 1 - clip, 1 + clip) for s, r in zip(m_s, m_r)] out = np.clip(src.astype(np.float32) * np.array(gains, np.float32), 0, 255) return out.astype(np.uint8)clip限幅很重要。不加限幅的话,某一路画面里恰好开进一辆白色货车,均值被拉高,增益算出来就离谱,整路画面变暗,形成周期性闪烁。0.35 的限幅意味着最多调整 ±35%,足够覆盖正常的曝光差,又挡得住异常场景。
4. 常见问题与排查实录
4.1 接缝处错位、重影
这是最高频的问题,原因按概率排序:标定点位点错 > 忘了去畸变 > 相机轻微位移 > 地面不平。
排查顺序我一般这么走。第一步,把两路 BEV 图叠起来看,用半透明混合,如果重合区域的直线(比如地面画的停车线)出现双线,量一下双线间距,固定间距说明是平移偏差,说明标定点有系统性错误;间距随距离增大说明是旋转或缩放偏差,多半是远处点位标错。第二步,回看内参标定的 RMS,超过 1 像素就重标。第三步,去现场看立杆,我遇到过一次是抱箍松了,相机歪了 0.3 度,30 米外偏了 15 厘米。
4.2 远处目标被拉成“面条”
前面几何部分讲过原理,这里说解法。三种应对,可以叠加用:
| 方案 | 做法 | 效果 | 代价 |
|---|---|---|---|
| 限制显示范围 | 只用 BEV 显示近中距离,远处仍看原始画面 | 立竿见影 | 覆盖范围受限 |
| 图标化渲染 | 不显示原始像素,检测框投影成固定大小的图标 | 视觉干净、可读性强 | 丢失外观信息 |
| 多平面近似 | 对 1.5 米高度平面单独求一个 H,用于行人 | 行人形态正常 | 两套映射,复杂度翻倍 |
我自己项目里最终选的是第二种为主 + 第一种为辅。俯视图只负责“谁在哪、往哪走”,细节交给点击图标后弹出的原始画面。这个设计决定之后,值班员的接受度明显提高,因为图标不会变形、不会互相遮挡,还能带车牌尾号和停留时长。
4.3 昼夜切换、逆光、雨雾导致的映射漂移
严格说这不算映射漂移,是画面质量变化导致检测失效,但表现上很像拼接坏了。傍晚相机切到黑白模式,红外补光启动,画面亮度分布完全变了,之前算的增益全部失效,接缝处出现明显的明暗台阶。
处理办法是给每路相机维护两套增益参数,白天一套、夜间一套,用画面平均亮度和饱和度做切换判据。更彻底的做法是干脆关掉自动日夜切换,强制固定模式,接受某一段时间的画面偏暗。雨雾天则是另一种情况,远处对比度骤降,检测框时有时无,这时候融合权重应该随清晰度动态调低,避免把糊掉的远处画面以高权重混进全景。
一条容易被忽略的:时间戳对齐。四路流如果不同步,一辆 60 km/h 的车(16.7 m/s)在 100 ms 的时差里会移动 1.67 米,跨镜头关联直接失效,看起来像是“目标瞬移”。拉流时统一用 RTSP 的绝对时间戳做缓冲对齐,容忍窗口设 80~120 ms,实测效果明显。
4.4 问题速查表
| 现象 | 最可能原因 | 快速验证 | 处理 |
|---|---|---|---|
| 接缝有硬边 | 融合权重图未生效 | 打印 wsum 看是否平滑 | 检查 distanceTransform 输入 mask |
| 整张图上下颠倒 | 缩放矩阵 Y 轴符号错 | 看人是不是倒着走 | S第二行取负 |
| 边缘拉伸严重 | 输出 mpp 过细 | 对比 GSD 与 mpp | mpp 调到 GSD 的 0.8 倍左右 |
| 图中间准、边缘歪 | 未去畸变或内参错 | 看原始图直线是否弯 | 重做内参标定 |
| 某一路整体偏暗 | 增益匹配越界或相机曝光跑偏 | 查该路相机参数 | 锁曝光 + 限幅 |
| 目标跨镜头位置对不上 | 时间不同步 | 观察静止物体是否也对不上 | 时间戳对齐 |
| 白天准夜里歪 | 红外切换导致画面变化 | 对比日夜两套画面 | 参数分套 + 切换判据 |
5. 从画面拼接到信息拼接:让它真正好用
5.1 检测框落地:底边中点是最省事的锚点
有了 H 的逆矩阵,检测框到地面坐标的转换就是一次矩阵乘。锚点选底边中点,因为那最接近目标与地面的接触点:
def box_to_ground(box, H_inv, mpp=0.05, ox=-20.0, oy=-20.0): x1, y1, x2, y2 = box px, py = (x1 + x2) / 2.0, float(y2) v = H_inv @ np.array([px, py, 1.0]) gx, gy = v[0] / v[2], v[1] / v[2] # 米制 -> BEV 画布像素(与前面的 S 保持一致) bx = (gx - ox) / mpp by = (oy + 40.0 - gy) / mpp return bx, by注意这里的H_inv必须是单应矩阵的逆,而不是合成矩阵 M 的逆——虽然两者差一个 S,但坐标系约定不同,混用会导致图标整体偏移。我在代码里把这两个矩阵分别命名成H和M,并在注释里写清楚各自作用域,就是为了防这个。
底边中点有个已知偏差:目标离相机越远,底边中点越偏向画面下方(透视角度的关系),大约会往前偏 0.2~0.5 米。对“在哪个区域”这类判断影响不大,但如果要做精确的测距或停车位判定,就得用目标的三维框底部中心再投影,或者对远场单独做补偿。
5.2 跨镜头关联:距离优先,外观兜底
同一辆车被两个镜头同时看到,怎么知道是同一辆?最土也最有效的办法是按地面坐标做最近邻匹配:
- 把所有镜头当前帧的检测结果按时间戳归到一个批次,容忍 100 ms。
- 计算两两之间的地面欧氏距离,小于阈值(车用 3 米、人用 1.5 米)认为是候选。
- 用匈牙利算法做一对一分配,没有竞争的候选直接合并。
- 距离接近阈值时,再用外观特征(颜色直方图或轻量 ReID 特征)做二次判定。
这套逻辑在车辆场景准确率相当高,因为车不会突然出现在两个相隔 5 米的位置。行人的误关联率会高一些,尤其是在人群密集处,这时候宁可放弃关联,也不要错连——错连出来的轨迹比没有轨迹更误导人。我一般会给轨迹加一个“置信度”字段,只显示置信度高的,低置信度的用虚线圈出来,值班员一眼能看出这是推测。
5.3 输出层:沙盘、回放与告警联动
最终的呈现,我倾向于做成三层结构。底层是拼接出来的全景底图,提供空间感;中间层是实时图标(车、人、告警点),位置由检测结果驱动;顶层是交互(点击图标弹原图、框选区域画电子围栏、拖时间轴回放)。
电子围栏这块有个细节值得说:围栏是在 BEV 画布上画的,存的是画布像素坐标,判定时把目标的地面坐标转到画布坐标再做多边形包含判断(cv2.pointPolygonTest)。这样围栏和相机的个数解耦,加一路相机不用重画围栏。相反,如果按原始画面画围栏,每加一路就要重画一遍,长期维护是灾难。
回放则要求按时间存轨迹而不是存视频。轨迹数据量极小,一辆车一分钟也就几十个点,存一个月毫无压力;需要看画面时再按时间戳去对应的相机录像里取片段。这个设计让“某车昨天下午在场地里的完整路径”这种查询变成毫秒级,比翻录像快几个数量级。
最后分享一个踩过的坑:全景图别直接按 25 fps 全量渲染。真实场景里,值班员看的是“态势”,不是“电影”。我把全景渲染降到 12 fps,检测保持 25 fps,图标位置按最新检测结果插值平滑,观感反而更稳,CPU 占用降了四成。图标平滑用简单的指数滑动平均就够,系数取 0.4 左右,既不抖也不拖。