去年帮一个做系统集成的朋友处理路口的车流统计需求,对方给的需求单短得离谱:能不能用摄像头把每个方向过了多少车、堵不堵、有没有人闯红灯算出来。听起来像是调个库就能解决的活,真正动手做 python 实现基于计算机视觉的交通路口智能监控系统的时候才发现,难点根本不在“识别出一辆车”,而在后面那一长串的东西:同一辆车在画面里出现几百帧怎么只算一次、逆光的时候车身糊成一团怎么不丢、晚上路灯在湿路面上反光怎么不当成车、只有一张显卡怎么同时喂四路视频流。这套系统本质上是一个把视频像素转换成结构化交通数据的管道,输入是连续的摄像头码流,输出是流量数字、排队长度和带截图的事件记录。它适合两类人参考:一类是做过基础目标检测、想往完整业务系统走的中级开发者,另一类是手里已经有一批路口相机、想把它们的数据真正用起来的工程实施人员。下面我按自己实际搭这套东西的顺序,从架构选择一路讲到现场调试踩的坑。
1. 从路口需求倒推系统架构
1.1 路口场景里真正值钱的到底有哪几类数据
很多人一上来就盯着“识别车辆”这一个动作,跑通一个 demo 就以为项目成了,结果交付时对方问“那这个路口的排队长度怎么算”“左转待转区的滞留车怎么区分”,立刻就卡住。路口这类场景,业务方真正愿意为下面四类数据掏钱,而且它们的计算路径完全不同。
第一类是流量,也就是某个方向、某条车道在单位时间内通过的车数,最好还能分车型。这类数据靠“过线计数”就能拿到,技术门槛最低,但对准确性要求最高,因为这类数字通常直接进报表,差 5% 就会被质疑。
第二类是排队与拥堵状态。它不是数数,而是判断“现在这条进口道上停着的车队有多长”,通常用某个检测区域内静止目标的连续像素长度或者总占用来估算。这里的关键其实是“静止判定”,不是检测本身。
第三类是异常事件,包括违停、逆行、异常停车、行人闯入机动车道、非机动车违规。这一类是整套系统里最容易做、也最容易出错的部分,因为它依赖规则逻辑而不是模型精度,规则写错了就会出现一夜之间几百条误报。
第四类是轨迹与速度,用来还原车辆行驶路径,支持事后回溯和事故分析。它的数据量最大,存储和查询的设计反而比算法更费心思。
注意:不要一开始就承诺“识别车牌”。车牌识别是另一条独立技术线,涉及补光、角度、字符识别模型,和交通流监控的相机安装参数是冲突的——交通流要看全景,车牌要看特写,同一个机位很难两全。
1.2 为什么用 Python 而不是一开始就上 C++
我知道很多人会说这种实时视频处理就该用 C++ 写,Python 光 GIL 就够呛。我的实际经验是:在原型验证和中小规模落地(几路到几十路)阶段,Python 的迭代速度带来的收益远远盖过它的性能损失。原因很实在——整套系统里真正吃 CPU/GPU 的部分(矩阵运算、模型推理、图像缩放、编解码)全都在 C/C++ 写好的库里面,比如 NumPy、OpenCV、各种推理引擎,Python 只是那一层薄薄的调度代码。你写的 Python 逻辑只是“取帧、调推理、比对坐标、写数据库”,这些操作耗时占比可能不到 5%。
真正的性能瓶颈从来不在语言上,而在于你有没有做对三件事:抽帧策略、批处理推理、避免无谓的内存拷贝。我见过一个用 Python 写的四路系统,在一张入门级显卡上稳定跑到实时,也见过一个用 C++ 重写的同样功能,因为每帧都在做全图 resize 和三次内存拷贝,反而更慢。
当然要承认边界。当你要做端侧部署、要在算力紧张的边缘盒子上跑、或者路数上到几百路需要做多进程多机调度,那确实需要把核心链路往编译型语言或者专用推理引擎上搬。我的建议是:先用 Python 把业务逻辑、规则、数据模型全部跑通,等规则稳定了,再把最热的那一段推理和跟踪抽出来单独优化。顺序反过来,很容易花两个月优化了一个最后被改掉的算法。
1.3 分层拆解:把一坨代码拆成五层
这套系统如果写成一个大 while 循环,两周之后你自己都看不懂。我当时按数据处理的方向拆成了五层,每层之间只通过结构化的数据对象通信,这样任何一层替换都不影响其他层。
| 层级 | 职责 | 关键实现 | 常见替换方案 |
|---|---|---|---|
| 采集层 | 拉流、解码、断流重连、时间戳对齐 | 独立读帧线程 + 队列 | 换成 FFmpeg 管道或硬解码 |
| 推理层 | 目标检测、可选的分割/分类 | 单阶段检测器 + 推理后端 | 换模型、换后端、加 TensorRT |
| 跟踪与逻辑层 | 目标 ID 维持、越线判定、区域状态机 | 多目标跟踪 + 几何判定 | 换跟踪器、改规则 |
| 数据层 | 结构化指标、事件记录、截图留存 | 关系库 + 文件系统 | 换时序库、上消息队列 |
| 展示层 | 实时画面、统计看板、事件回放 | Web 服务 + 画框推流 | 换前端框架、换推流协议 |
分层的最大好处是排障时能快速定位。画面里框的位置歪了,是推理层的问题;框对了但计数不对,是逻辑层的问题;数对了但报表不更新,是数据层的问题。如果没有这层隔离,一个计数错误你要从摄像头一路查到数据库。
1.4 关键选型背后的取舍逻辑
选型这块我踩过的最大的坑是“追新”。第一版我用的是一个结构比较复杂的检测器,精度确实高一点,但单帧推理时间是我现在用的三倍,四路视频直接卡成幻灯片。后来换成一个轻量的单阶段检测器,精度掉了大概两个点,但在路口这个特定场景里,因为目标大、光照相对稳定、类别少,实际效果几乎看不出差别,速度却翻了倍。
跟踪器的选择更微妙。最早我用的是基于外观特征匹配的方案,它需要额外跑一个特征提取网络,等于每帧多一次推理,成本直接翻倍;后来换成只依赖运动信息的关联方式,用卡尔曼预测加 IoU 匹配,速度快很多,代价是遮挡严重时会丢 ID。我最后的折中是:在路口这种目标运动规律强、遮挡时间短的场景里,纯运动匹配完全够用,把省下来的算力留给检测分辨率和帧率更划算。
推理后端这块,一开始直接用框架原生推理,简单省事;等到路数上去之后换成中间格式加专用运行时,速度大概提升 30% 到 50%,具体幅度跟显卡型号和输入分辨率关系很大。这个优化我建议放到后期做,因为转换过程会引入一些数值差异,前期做会让你分不清是模型问题还是转换问题。
2. 环境搭建与工程骨架
2.1 硬件配置和算力预算怎么算
不要凭感觉买卡。先把预算算出来,算法很朴素:一路 1080p 视频原始 25 帧每秒,你不可能也不需要每帧都处理,路口场景里车辆位移在相邻帧之间只有几个像素,全帧率处理纯属浪费。假设每 4 帧取 1 帧,一路就是 6.25 帧每秒的有效处理量,四路合计 25 帧每秒。也就是说,单帧总耗时预算 = 1000 / 25 = 40 毫秒,这里面还要扣掉解码、画框、写库的时间,留给推理的大概只有 25 到 30 毫秒。
把这个预算和候选模型的实测单帧耗时对一下,就知道需要什么级别的卡。当时我在 640 输入分辨率下实测单帧推理 12 毫秒左右,加上前后处理总共 20 毫秒出头,四路跑下来 GPU 占用大概六成,还有余量。如果换成 960 输入,单帧涨到 30 毫秒以上,四路就顶不住了,只能把抽帧间隔拉到每 8 帧取 1 帧,代价是快速通过的车辆可能在两帧之间就跨过了判定线,导致漏计。
CPU 这边容易被忽略的是解码。四路 1080p 用软解码,很容易把四核跑满,风扇狂转。如果条件允许,用带硬件解码的方案能省下大量 CPU;如果没有,至少要保证读帧线程不和主处理线程抢同一个核。
2.2 依赖安装:几个必踩的版本坑
我一个同事在另一台机器上复现环境,折腾了一整天,最后发现是三个包的版本互相打架。这类问题有几个固定套路,提前避开能省很多时间。
第一,图像库和推理框架对数值库版本很敏感。数值库大版本升级时改了不少默认行为,一些老版本图像库加载时会直接报二进制不兼容。稳妥做法是把数值库版本钉死在推理框架官方说明里推荐的那个范围,不要图省事直接装最新。
第二,桌面版和服务器版图像库别混装。带界面的版本依赖一堆图形库,在没有桌面的服务器上装完 import 就报错。跑在服务器上的机器统一装无界面版本,本地调试需要显示窗口时再单独装。
第三,GPU 版推理运行时和驱动版本要匹配。这个东西报错信息很隐蔽,往往表现成“初始化成功但推理结果全是零”,或者干脆静默回退到 CPU 上跑,你看着任务管理器里 CPU 满载还以为是自己代码写得烂。
# 建议的安装顺序:先数值库,再图像库,最后推理框架 pip install "numpy<2.0" pip install opencv-python-headless pip install torch torchvision --index-url <框架官方给出的对应源> pip install onnxruntime-gpu# requirements.txt 片段(版本号按你的驱动和框架文档核对后再定) numpy==1.26.4 opencv-python-headless==4.9.0.80 onnxruntime-gpu==1.17.1 fastapi==0.110.0 uvicorn==0.29.0 sqlalchemy==2.0.29提示:装完之后一定跑一段二十行的验证脚本,打印 GPU 是否可用、推理一次耗时多少、输出张量的形状和数值范围是否正常。这一步花五分钟,能省掉后面半天的“为什么结果全是零”。
2.3 工程目录结构:为三个月后的自己设计
目录结构这件事,写 demo 的时候觉得无所谓,等项目跑到线上、需要加一个新事件类型的时候,你就会感谢当初分了目录的自己。我的做法是按“数据流向”组织,而不是按“文件类型”组织。
traffic_monitor/ ├── configs/ │ ├── cameras.yaml # 每个路口的相机地址、分辨率、方向 │ └── rules.yaml # 判定线坐标、区域多边形、阈值 ├── core/ │ ├── capture.py # 读帧线程、重连、时间戳 │ ├── detector.py # 模型加载与推理封装 │ ├── tracker.py # 跟踪器封装 │ └── geometry.py # 多边形判定、透视变换、距离计算 ├── logic/ │ ├── counter.py # 越线计数状态机 │ ├── events.py # 事件规则引擎 │ └── metrics.py # 流量、排队、拥堵指标聚合 ├── storage/ │ ├── models.py # 数据表定义 │ └── writer.py # 批量写入与截图落盘 ├── web/ │ ├── api.py # 查询接口 │ └── stream.py # 画框推流 └── main.py配置文件用 YAML 而不是硬编码,是因为路口坐标几乎每周都在微调。相机位置稍微挪一点、判定线偏十个像素,计数结果就能差出一截。把这些东西外置,现场人员改配置重启就行,不用找你改代码。
3. 视觉核心链路:从像素到事件
3.1 视频接入远比想象中脆弱
这是整条链路上最不“酷”但最要命的部分。我第一版直接用最简单的方式打开视频流,在办公室测试一周没出问题,装到现场第二天就开始抽风——画面卡住不动,但程序还在正常跑,日志一点错误都没有。
问题的根源是网络摄像头的缓冲机制。默认情况下读取接口会在内部缓存若干帧,网络稍微抖一下,你的程序读到的是几秒前的画面,而且它会一直落后,越积越多。直接设置缓冲区大小的属性在很多后端上根本不生效,这个坑我试过好几次。
真正管用的做法是:用一个独立的读线程死循环取帧,只保留最新一帧,主处理线程从共享变量里拿。这样无论处理多慢,读到的永远是当前画面,最多是丢掉一些帧,不会累积延迟。同时要加超时判定——如果超过若干秒没有拿到新帧,就主动释放资源重连,而不是傻等。
import threading, time, cv2 class FrameGrabber(threading.Thread): def __init__(self, url, stale_timeout=5.0): super().__init__(daemon=True) self.url = url self.stale_timeout = stale_timeout self.frame = None self.stamp = 0.0 self._stop = threading.Event() def _open(self): cap = cv2.VideoCapture(self.url, cv2.CAP_FFMPEG) cap.set(cv2.CAP_PROP_BUFFERSIZE, 1) # 部分后端无效,但设了不亏 return cap def run(self): cap = self._open() while not self._stop.is_set(): ok, frame = cap.read() if not ok: cap.release() time.sleep(1.0) cap = self._open() continue self.frame = frame self.stamp = time.time()外层再套一个健康检查:定时比对当前时间和最后一帧时间戳,超过阈值就判定断流,触发重连并记一条日志。这套逻辑看起来笨,但现场的稳定性提升非常明显。
3.2 预处理和推理:那些决定精度的细节
预处理最常见的错误是直接缩放。检测模型训练时通常用的是保持长宽比、空白填充的输入方式,你在推理时直接拉伸成正方形,物体比例就变了,尤其是横向很宽的车型,宽度被压缩之后检测框会明显偏移。保持长宽比做等比缩放再填充灰边,是必须做的事。
输入分辨率的选择也是一个权衡。640 输入下,远处车道的车可能只有十几个像素宽,很容易漏检;960 输入下远处的车能检出,但算力消耗翻倍。我的做法是:对近端需要精确计数的车道用较高分辨率,对远端只看流量趋势的区域接受漏检。如果只有一路输入尺寸,那就优先保证计数区域内的目标清晰,因为流量数字是要进报表的。
类别过滤要在推理之后立刻做,不要等到跟踪阶段。路口的检测目标是有限的几类:小汽车、大型车、两轮车、行人。其他类别全部丢掉,能显著减少跟踪器的负担和误匹配。
NMS 阈值这个参数,路口场景和通用场景不太一样。因为车流密集时会互相遮挡,阈值太低会把并排的两辆车合成一个框,导致计数少一辆;阈值太高又会对同一辆车生成重复框。我实测下来,稍微保守一点、允许框之间有少量重叠,配合跟踪器的去重,比激进合并要稳。
3.3 跟踪器:让 ID 稳定的几个实操要点
跟踪是整个系统的黏合剂。没有跟踪,你只能在每一帧上独立判断“有没有车压线”,结果就是同一辆车压线的十几帧里被数了十几次。加上跟踪之后,逻辑变成“某个 ID 的状态从线的一侧变到另一侧”,只会计一次。
ID 切换是最影响计数准确率的问题。常见原因有三个,我都遇到过。
一是检测框抖动。同一辆车在相邻帧的框大小差十几个像素,关联时算重叠度就对不上了。解决办法是在跟踪前对框做一次平滑,或者给关联算法留更大的容忍度。
二是低速目标的位移太小。车辆在路口排队等红灯时几乎是静止的,运动预测失效,这时候要靠位置和框大小维持关联,不能只依赖位移。
三是目标被大车完全遮挡后又出现。这种只能靠外观特征救,但代价是每帧多一次推理。我的取舍是:路口场景里遮挡时间通常在 6 到 10 帧以内,用“丢失后保留 ID 若干帧”的策略,等它重新出现时优先匹配最近的失联轨迹,命中率还不错,成本几乎为零。
# 跟踪调用示意:高置信度框和低置信度框一起送进去, # 低分框用来补遮挡和模糊目标,是减少 ID 断裂的关键 tracks = tracker.update(det_high, det_low) for t in tracks: tid, box = t.track_id, t.to_tlbr() state = counter.update(tid, box) # 交给计数状态机3.4 虚拟线圈与越线判定:计数不重复的核心
计数这件事,外行觉得“车过了线加一”就完了,实际做起来全是细节。我用的是双线状态机方案,比单线判定稳得多。
具体做法是:在每条车道上画两条平行的虚拟线,方向从 A 到 B。每个跟踪 ID 维护一个状态字段,记录它当前位于哪一侧。当某帧发现这个 ID 的位置从 A 侧移动到了 B 侧,才算一次有效通过;如果它只是压过 A 线又退回去,状态不变,不计数。这个设计能过滤掉大量因为框抖动产生的假穿越。
判定“点在哪一侧”用的是叉积符号,画线的两个端点构成一个向量,目标中心点到起点的向量与它做叉积,符号决定方位。注意不同车道的行车方向可能相反,所以每条线都要单独配置方向,否则会出现某个方向的车全被漏掉。
def side_of_line(point, p1, p2): # 返回值符号表示点在直线的哪一侧 return (p2[0] - p1[0]) * (point[1] - p1[1]) - \ (p2[1] - p1[1]) * (point[0] - p1[0]) class LineCounter: def __init__(self, p1, p2, direction=1, min_frames=3): self.p1, self.p2 = p1, p2 self.direction = direction # 期望的穿越方向 self.min_frames = min_frames # 至少稳定这么多帧才认可 self.state = {} # tid -> (side, frames) def update(self, tid, center): s = self.side_of_line(center, self.p1, self.p2) * self.direction prev = self.state.get(tid) if prev is None: self.state[tid] = [s, 1] return False if (s > 0) == (prev[0] > 0): prev[1] += 1 return False # 符号翻转:需累计稳定帧数,避免抖动误判 if prev[1] >= self.min_frames: self.state[tid] = [s, 1] return True self.state[tid] = [s, 1] return False还有一个容易漏的点:状态字典要定期清理。跟踪器给每个目标分配 ID,长时间运行后 ID 会一直增长,如果不清理已消失目标的状态,内存会缓慢爬升,跑上一周就能吃掉几个 G。我的做法是跟踪器确认轨迹结束时就回调删除对应状态。
3.5 像素到米的标定与速度估算
速度估算是很多项目的加分项,也是误差最大的项。原理很简单:位移除以时间,但“位移”必须是现实世界的距离,不是像素。
做法是透视变换。在画面上找四个点(比如车道线的四个角),量出这四个点在实际路面上的距离,构造变换矩阵,把像素坐标映射到俯视视角的米制坐标。这样得到的位移再除以帧时间差,就是速度。
误差来源有三个,都得处理。一是相机抖动,风吹或者大车经过引起的震动会让画面整体漂移,映射结果跟着抖,解决办法是做背景点对齐或者干脆把相机固定死。二是标定误差,四个点的实际距离靠卷尺量,误差两三个百分点很正常,反映到速度上是同样比例。三是时间戳不准,如果用处理时间而不是帧采集时间算时间差,遇到帧堆积时速度会严重偏低,所以每帧必须带上采集时间戳。
我的经验是:速度这个指标用于超速抓拍需要很高的精度,用于交通流分析只要趋势对就行。如果只是做路口的平均速度统计,误差 10% 以内完全可以接受,不要为了两三个百分点去折腾复杂的标定流程。
4. 业务逻辑层:把数字变成业务能看懂的东西
4.1 流量统计与拥堵指数的计算口径
流量统计看着简单,口径问题能吵一整天。按车道算还是按方向算?按 5 分钟还是 15 分钟聚合?大型车和小汽车要不要分开?这些必须在动手前和业务方定死,否则做完了人家说“我们要的是标准车当量”,你所有数字全得重算。
我的做法是底层只记录最原子的事件:时间戳、相机编号、车道编号、车型、方向、过线时刻。所有聚合指标都从这张原子表里算,不同口径只是不同的查询语句。这样业务方改口径的时候不用重跑视频,改个 SQL 就行。
排队长度的估算,我用的是一个巧妙的偷懒办法:在每条进口道上划一块检测区域,统计区域内静止超过 N 秒的目标数量,再乘以经验车长。这个方法不需要精确测量每辆车的尺寸,只要区域内目标数和实际排队长度有稳定相关就行。实测下来和人工计数对比,误差能控制在两辆车以内,对拥堵预警完全够用。
拥堵指数则用区域占用率做归一化:区域内被目标框覆盖的面积比例除以总面积,超过某个阈值就标黄,再高标红。这个指标的好处是不依赖计数准确性,即使漏检一两辆车,占用率也不会剧烈波动。
4.2 事件规则:宁可漏报,不要误报
事件这块我的态度很明确:误报比漏报更伤害系统信任度。你一天推两百条误报,值班人员第三天就把通知关掉了,那这套系统等于白做。所以所有事件规则我都设置了比较保守的触发条件。
| 事件类型 | 判定逻辑 | 关键阈值 | 典型误报来源 |
|---|---|---|---|
| 违停 | 目标在禁停区内静止超过 T 秒 | T 取 60 到 180 秒 | 等红灯的正常车、临时上下客 |
| 逆行 | 跟踪方向与车道规定方向夹角超过阈值 | 夹角 120 度以上、连续 10 帧 | ID 切换导致的轨迹跳变 |
| 异常停车 | 目标在非停车区域静止且后方排队增长 | 静止 30 秒 + 后方 3 车以上 | 事故、抛锚、临时等待 |
| 行人闯入 | 行人目标中心点进入机动车道多边形 | 连续 5 帧 | 车道边缘误标、倒影 |
| 闯红灯 | 停止线区域状态 + 信号灯状态 | 需信号灯接入或固定配时 | 黄灯末期过渡、相机抖动 |
最麻烦的是闯红灯判定,因为它必须知道信号灯状态。两个办法:一是从信号机取状态,最准但需要接口对接;二是用固定配时表推算,简单但一旦配时调整就全错。我一般先用配时表上线,同时把信号灯区域的画面裁出来做一个简易的颜色分类,两者对不上时告警提示人工核对。
所有事件都必须带截图证据。没有截图的告警等于没有告警,因为值班人员无法快速判断真假。截图策略是:触发时刻往前推若干帧和往后推若干帧各存一张,加上触发时刻的全景图,打包成一个事件记录。存储压力不小,一张 1080p 的图压缩后两三百 KB,一天几千条事件就是 1G 左右,保留策略必须提前设计。
4.3 数据落地:写入策略决定系统能不能长跑
第一版我犯了一个典型错误——每一帧的统计结果都往数据库里写一条。四路视频,每秒二十多次写入,数据库两天就卡了。后来改成三层策略:
原子事件立即写,但走批量提交,攒够一批或者超时若干毫秒再落盘;聚合指标定时算,每 5 分钟汇总一次写入结果表,原始原子数据保留一段时间后归档;截图走文件系统,数据库只存路径,绝对不要把二进制塞进表里,否则备份和迁移都会变成灾难。
表结构上,原子事件表按时间分区,建好时间索引,因为几乎所有查询都带时间范围。相机、车道这些维度信息单独建一张小表,不要在主表里重复存字符串,否则几千万行之后占用会翻倍。
4.4 可视化:让不会看代码的人也能用
界面这块我没花太多精力做花哨的东西,就三块:实时画面带框和计数叠加、当日各方向流量柱状图、事件列表带截图缩略图。
实时画面用最朴素的逐帧输出成图片流就行,浏览器直接能看,不需要额外的播放器或者流媒体服务。画框的时候注意不要在每一帧上做太多绘制操作,文字渲染很耗 CPU,可以只画框,把计数文字放在页面其他的 DOM 元素里更新。
接口层用轻量 Web 框架,几个查询路由搞定:按时间范围查流量、按类型查事件、按事件 ID 取截图。数据库查询一定要加 LIMIT 和分页,我吃过一次亏,前端传了个空的时间范围,后端直接全表扫描,页面转了半分钟最后超时。
5. 常见问题与排查实录
5.1 检测不稳:夜间、逆光、雨天的应对
夜间是这个项目最大的敌人。白天调得再好,天一黑精度就往下掉。原因很直接:训练数据里夜间样本少,而且路灯光照下车辆颜色特征被压缩,边缘也不清晰。
我的应对是三个层面。数据层面,把现场夜间画面截一批下来,人工标注几百张,混进训练集做微调,这一步效果最明显,通常能把夜间漏检率降一半以上。参数层面,夜间适当降低置信度阈值,代价是误检增加,但因为后面有跟踪器做时序过滤,短时的误检基本会被滤掉。工程层面,如果相机支持,开宽动态或者调整曝光,让车身不要过曝成一片白,这一步往往比调模型更有效。
逆光的情况更麻烦,早上和傍晚太阳角度低的时候,画面里一大片过曝,车开进去就消失。这种物理限制很难靠算法解决,只能靠安装角度避开正对太阳,或者加装遮光罩。我在项目里遇到过西向进口道每天下午四点以后基本失效的情况,最后是建议调整机位高度和俯角解决的。
雨天还有个隐蔽问题:路面积水反光会被误检成目标。尤其是夜里灯光在湿路面拉出的长条反光,形状还真的像车。这类误检的特征是位置固定在同一个地方、形状基本不变、出现时间短。我在逻辑层加了一个过滤器:如果某个检测框在同一个位置反复出现又消失、持续时间极短、且没有产生任何位移,就判定为反光,不计入。这个规则很土但很有效。
5.2 计数不准:从现象反推原因
计数问题最考验排障思路,因为同一个现象可能有完全不同的原因。我整理了实际遇到过的几类,配合定位方法。
| 现象 | 最可能原因 | 验证方法 | 处理办法 |
|---|---|---|---|
| 数量偏多 10% 到 30% | 重复计数,ID 切换或状态未清 | 导出轨迹看是否有同一辆车两个 ID | 打开丢失保留帧数、清理状态 |
| 数量偏少 | 高速车取帧间隔太大,跳线 | 打印每帧目标数,看高峰时是否漏 | 提高有效帧率或加宽判定的容差带 |
| 某个方向全是零 | 判定线方向配反 | 手动喂一张图看叉积符号 | 改方向参数 |
| 早晚各有一个高峰异常 | 光照变化导致的检出率波动 | 分时段统计检出数对比 | 分时段设阈值、补夜间样本 |
| 大型车被计成两辆 | 拖挂车被拆成两个框 | 看检测框是否断开 | 提高 NMS 阈值、加框合并后处理 |
有个细节值得单独说:判定线不要贴着停止线画。我一开始把虚拟线直接画在停止线上,结果排队车辆的前保险杠刚好停在线上,来回抖动几次就被数了好几遍。后来把线往后退了两个车身,让车必须是真正驶过才算,问题就没了。这条经验值好几天的调试时间。
5.3 性能与稳定性:跑一天和跑一周的区别
demo 跑十分钟很流畅,跑一周就各种问题。我遇到过的几个:
帧堆积。表现是画面延迟越来越大,最后延迟几分钟。根因是读帧快但处理慢,中间没有丢弃机制。解决办法就是前面说的只保留最新帧,其余全丢。宁可丢帧,也不要积压。
内存缓慢增长。表现是跑两天内存从 1G 涨到 6G。三个常见来源:跟踪器里未清理的历史轨迹、状态字典里不删除的已消失 ID、以及日志对象的累积。排查方法是隔一段时间打印各字典的长度,一眼就能看出谁在涨。
显存泄漏。这个最隐蔽,表现是跑十几个小时后突然报显存不足。多半是因为某些中间张量被意外持有,或者每帧创建了新的会话/上下文没有释放。做法是把推理调用包在一个固定的上下文里复用,避免频繁创建销毁。
进程莫名其妙挂掉。视频解码是重灾区,某些码流格式会让底层库直接崩溃,Python 层根本捕获不到。工业级的做法是加一个守护进程,主进程挂了自动重启,同时记录上次处理到的位置。别指望能百分百消灭崩溃,要指望崩溃后能自己爬起来。
5.4 部署环境的几个隐形坑
部署到现场机房,和在自己机器上跑完全是两回事。时间同步是一个——多台设备的日志时间对不上,事件排查会变成噩梦,务必统一时间源。磁盘空间是另一个——截图和录像不设上限,磁盘写满之后数据库直接崩,必须做定期清理和磁盘水位告警。还有权限问题,服务跑在低权限账户下,写文件、开端口都可能被拦,提前确认好。
网络方面,摄像头到服务器的链路质量决定了系统稳定性。如果中间隔着交换机和长距离网线,丢包和延迟抖动会很常见,这种情况下读帧线程的重连逻辑就是生命线,一定要测。
6. 现场调试与后续扩展
6.1 相机安装和标定:前期省事,后期省命
这个项目里我最大的体会是:算法能补救的空间远比想象小,机位选对了能省掉一半的算法工作。几个具体的建议。
安装高度和俯角要给足。太低的机位会让远处的车全部糊成一团,太高的机位又会让人和车在画面上重叠严重,遮挡问题恶化。俯角在 30 到 45 度之间通常比较舒服,能看到车顶也能看到车身侧面。
避开正对光源。前面说过逆光的问题,其实顺光也有问题——阳光直射镜头会产生光晕。理想情况是光源在侧面。
覆盖范围要包含完整的判定区域。虚拟线要画在画面中下部,因为那部分车辆像素多、检测稳;如果判定线画在画面最上方,车在那里只有几十个像素,检测框抖动会非常严重。
标定这块,我习惯的做法是在画面上叠加一层半透明的配置图层,把判定线、检测区域、禁停区都画出来,用鼠标点几下就能配好,坐标直接存 YAML。这个工具大概花半天写,但每次现场调整都能省一小时,非常值得。
6.2 三个我认为值得做的扩展方向
第一个是多相机协同。单个路口只能看到局部,如果有上下游两个相机,可以用车辆经过的时间差推算路段行程时间和平均速度,这个数据对交通分析的价值比单点流量高得多。实现上需要解决跨相机的目标重识别问题,简单版本可以用车型加颜色加时间窗匹配,精度不高但足够做趋势统计。
第二个是轨迹数据的二次利用。系统跑起来之后,积累的轨迹数据其实是一座金矿,可以用来分析冲突点(两条轨迹在时空上非常接近)、识别高风险行为、甚至优化信号配时。这块不需要新增硬件,纯粹是数据挖掘的活。
第三个是自适应阈值。目前所有阈值都是人工配的,但实际上不同时段、不同天气下的最优阈值差异很大。可以按小时统计检出数量的分布,自动调整置信度和判定容差,让系统自己适应环境变化。这个功能上线之后,运维的工作量能降一大截。
我在实际交付这个项目的过程中,感受最深的一点是:大部分时间不是花在算法上,而是花在“让系统在没人管的情况下连续跑一个月不出事”上。模型精度从 90% 提到 93% 带来的业务价值,远不如把断流重连、状态清理、磁盘告警这几件枯燥的事做扎实。如果你正准备入手类似的项目,我的建议是先花两天把采集层和容错做稳,再去调模型,这个顺序反了会在后期付出几倍的代价。