上一篇把 opencv_basics/ 的六个练习文件拆完了,这一篇进入任务一的主体。 本篇范围 :task1_lightbar/detect_video.py,227 行。
1. 文件结构:一眼就能看懂的那种
227 行只切成两块,这是整个仓库里结构最清爽的文件:
代码块
scss
detect_lightbars(frame) -> list[dict] # 第 8-102 行:纯检测,无 I/O
main() # 第 104-223 行:读视频、调检测、画图、写视频
代码块
css
flowchart TD
M[main: 参数校验] --> O[VideoCapture 打开输入]
O --> W[VideoWriter 创建输出]
W --> L{逐帧循环}
L --> R[capture.read]
R --> D["detect_lightbars(frame)"]
D --> A[绘制候选框 + 面积/长宽比]
A --> V[叠加帧号/数量/耗时]
V --> WW[writer.write]
WW --> L
L -->|读取失败| F["finally: release 两个句柄"]
为什么这个划分值得专门表扬? detect_lightbars 的输入是一帧图像(numpy 数组),输出是一个列表,没有文件、没有视频、没有全局状态。这意味着:
- 它能脱离视频单独测试------喂一张图,断言检测到几个灯条;
- 它能被
extract_frame.py抽出来的单帧反复调用,调参时不用等视频跑完; - 它将来能被搬到实车代码里,只要有人提供一帧图像。
对比练习目录里的 contours.py:那个文件把「读掩膜、筛轮廓、画框、存图」全写在一个 main 里,改一个阈值就要重跑整条链路。从练习版到任务版,最大的进步不是算法变强了,而是职责被切开了。
2. `detect_lightbars():从练习版到任务版的三处升级**
把 blue_mask.py / contours.py 和这个函数并排看,有三处是明显「学会了」的痕迹。
(1) 用 _ 丢掉不需要的通道。
python
ini
# 任务版
blue_channel, _, red_channel = cv2.split(frame)
# 练习版
blue_channel, green_channel, red_channel = cv2.split(frame)
# 之后 green_channel 再也没被用过
同一个意思,但读代码的人不会再去猜「green 到底用在哪了」。
(2) cv2.threshold 直接解包,而不是靠下标。
python
ini
# 任务版
_, difference_mask = cv2.threshold(difference, 40, 255, cv2.THRESH_BINARY)
# 练习版
difference_mask = cv2.threshold(blue_difference, 40, 255, cv2.THRESH_BINARY)[1]
语义完全一样,但 _, difference_mask = ... 把「返回值有两个」这件事写在了变量绑定里,[1] 则要靠读者记住。这种小改动的收益是降低理解成本,在 200 行的文件里还看不出来,在一个 2000 行的文件里就是可读性差距。
(3) 返回数据,而不是直接画图。
python
go
detections.append(
{
"box": box,
"center": center,
"area": area,
"ratio": aspect_ratio,
}
)
return detections
这是三处改动里最重要的。检测函数只负责「找到什么」,画框、写文字、算数量全交给 main。带来的直接好处是可以在不产生任何图像的情况下验证算法:
python
scss
detections = detect_lightbars(frame)
print(len(detections), [d["ratio"] for d in detections])
但有一个小坑要提醒:detections 里的数值是 numpy 标量。 center、area、ratio 都是 numpy 类型(np.float32 / np.float64),box 是 numpy 数组。它们能正常参与计算和绘制,但不能直接 json.dumps(json.dumps 是把数据变成一段文本的工具 ------会抛 TypeError: Object of type float32 is not JSON serializable。这一点在任务三要写日志时会重新碰到。如果想一次解决,把返回值换成 NamedTuple 或 dataclass,并在构造时 float(...) 一次:
python
css
from dataclasses import dataclass
@dataclass
class Lightbar:
box: "np.ndarray"
center_x: float
center_y: float
area: float
ratio: float
(类比结构体)
字段有名字之后,detection["ratio"] 会变成 lightbar.ratio,拼错名字时 IDE 立刻报错,而不是等到运行时 KeyError。
3. 筛选条件:和练习版的三点差异
同一套「面积 + 长宽比」思路,两个文件的常数并不一样:
| 条件 | contours.py(练习) |
detect_video.py(任务一) |
|---|---|---|
| 面积下界 | 10 | 3 |
| 面积上界 | 5000 | 100000 |
| 长宽比下界 | 1.0(空条件) | 1.5(有实际作用) |
| 长宽比上界 | 15.0 | 20.0 |
填充率 fill_ratio |
有(≥ 0.15) | 无 |
三点值得说:
- 面积下界从 10 降到 3,是为了捞回远处的灯条。代价是更多的单像素噪点会进入候选------这类取舍只能靠看结果决定,没有理论最优值。
- 长宽比下界终于有了意义。 上一篇指出练习版的
1.0是死代码;任务一改成1.5,它真的开始干活了:把接近正方形的干扰物挡在外面。同一个参数,从「写了等于没写」变成「真的在筛」,这是参数设计上的进步。 - 填充率被丢掉了。 练习版用它过滤「形状不对但尺寸对」的干扰物,任务版没用。这可能是经验上认为不必要,也可能是移植时漏了。建议用数据来决定 :把
fill_ratio算出来但只统计、不筛选,跑一遍完整视频,看它到底砍掉了多少候选、其中有没有真灯条。如果它砍掉的全是噪点,就正式加进流程;如果它误伤了真灯条,就名正言顺地删掉。这样一来,「要不要这个判据」就有依据了。
另外注意:这三个阈值((80,120,110)、(135,255,255)、40)在 blue_mask.py 和这里各写了一份,数值目前一致,但是复制而不是引用。哪天只改了其中一处,两个文件就会给出不同结果,而且没有任何报错提示。这是上一篇「阈值该抽到配置文件」的同一个结论,在这里出现了第二次。
4. main():边界处理比算法更值得看
任务一的 README.md 里列了四条「边界处理」,逐条都能在代码里找到对应行,这种文档与代码对齐的做法值得保留:
| README 声明 | 代码位置 | 是否落实 |
|---|---|---|
| 输入路径不存在时提示并退出 | 112--114 行 input_path.exists() |
✅ |
| 视频无法打开时提示并退出 | 118--120 行 capture.isOpened() |
✅ |
| 输出视频无法创建时提示并退出 | 139--142 行 writer.isOpened() |
✅ |
| 视频结束或空帧时正常结束 | 152--153 行 if not success: break |
✅ |
无有效灯条时写入 lightbars=0 |
195 行 len(detections) |
✅ |
其中两行是我认为这个文件里最有价值的写法:
python
ini
fps = capture.get(cv2.CAP_PROP_FPS)
if fps <= 0:
fps = 30.0
CAP_PROP_FPS 是会返回 0 的。 可变帧率视频、部分容器格式、某些编码,读出来就是 0。而 cv2.VideoWriter 收到 fps=0 时,要么打不开、要么生成一个时长离谱的文件。这几行兜底把一类「莫名其妙的失败」直接消灭了。(唯一的小遗憾是它静默兜底,不打印任何提示,建议加一句 print 说明用的是默认帧率。)
python
ini
output_path.parent.mkdir(parents=True, exist_ok=True)
这正是上一篇在 extract_frame.py 里点名缺失的那一行。 有了它,output/ 目录不存在时会被自动创建,而不是让 VideoWriter 无声无息地失败。任务版做到了,练习版没做到------同一个作者在同一个项目里的前后差异,正好说明「这类保护是在踩过坑之后才补上的」。
还有一个隐藏的进步:try/finally。
python
python
try:
while True:
...
finally:
capture.release()
writer.release()
循环中间无论怎么退出(正常结束、break、异常),两个句柄都会被释放。这一点在下面讲 VideoWriter 时还会用到。
5. 耗时统计:这次真的包多了
这是本篇的核心问题。先看代码:
python
ini
while True:
start_time = time.perf_counter() # ← 计时从这里开始
success, frame = capture.read() # ← 视频解码在这里
if not success:
break
detections = detect_lightbars(frame) # ← 算法在这里
for detection in detections:
... # 画框、写 area/ratio
elapsed_ms = (time.perf_counter() - start_time) * 1000 # ← 计时在这里结束
先肯定一点:用 time.perf_counter() 是正确的选择。 它是单调时钟,不受系统时间调整影响,分辨率也比 time.time() 高,测短间隔就该用它。很多教程里用 time.time() 测毫秒级耗时,那是错的。
但计时的起止点选错了。 start_time 打在 capture.read() 之前 ,所以 elapsed_ms 包含的是:
代码块
ini
elapsed_ms = 视频解码 + 灯条检测 + 绘制候选框
而它排除的是:三段叠加文字(帧号/数量/耗时)和 writer.write()。
问题是画面上的标签写的是 time=xx ms,README 里写的是「处理耗时」。读者(包括三个月后的你自己)会自然地理解成「检测算法耗时」,但它实际上是一个混合值,而且解码往往是大头。
后果很具体:假设解码 8 ms、检测 3 ms,画面显示 time=11.00 ms。你会觉得算法偏慢,于是花时间去优化 findContours 的调用方式------而优化空间其实在解码路径上,怎么调算法都没用。
改法:把两段时间分开测、分开显示。
python
ini
t0 = time.perf_counter()
success, frame = capture.read()
t_read = (time.perf_counter() - t0) * 1000
if not success:
break
t1 = time.perf_counter()
detections = detect_lightbars(frame)
t_detect = (time.perf_counter() - t1) * 1000
然后在画面上叠加两个标签:
python
python
cv2.putText(frame, f"detect={t_detect:.2f} ms", (30, 120), ...)
cv2.putText(frame, f"decode={t_read:.2f} ms", (30, 160), ...)
这个改动带来的收益不只是数字更准,而是调参方向变清楚了。 如果 detect 只有 3 ms 而 decode 有 8 ms,那说明瓶颈在 I/O,任何算法级别的优化都是白费力气;如果 detect 占到 20 ms 以上,才值得去看形态学核、轮廓数量这些地方。
一个通用的判断标准:计时窗口应该尽可能只包住你想优化的那段代码。 边界要么紧贴算法调用,要么明确命名为「单帧总耗时」并接受它包含 I/O------最怕的是名字叫「处理耗时」、实际混着解码。
##实测结果(1920×1080,300 帧,合成素材)
| 阶段 | 平均 | P95 | 占比 |
|---|---|---|---|
解码 capture.read |
14.8 ms | 21.6 ms | 26.6 % |
检测 detect_lightbars |
40.9 ms | 56.6 ms | 73.4 % |
画面上显示的混合 time |
55.7 ms | --- | 100 % |
吞吐 17.9 fps(素材 30 fps,约 0.6 倍速);灯条平均 4.00 根,零检出帧 0/300。
分阶段:40.9 ms 花在哪
| 阶段 | 耗时 | 占检测耗时 |
|---|---|---|
split 分离通道 |
7.02 ms | 27.6 % |
cvtColor BGR→HSV |
4.55 ms | 17.9 % |
| 1×1 形态学 OPEN+CLOSE | 4.12 ms | 16.3 % |
inRange HSV 分割 |
3.52 ms | 13.9 % |
threshold 二值化 |
2.30 ms | 9.1 % |
findContours |
1.98 ms | 7.8 % |
subtract B-R 差分 |
1.30 ms | 5.1 % |
bitwise_and 求交 |
1.10 ms | 4.3 % |
两个反直觉的点:
- 被当成「算法主体」的
findContours只占 7.8% ,而一个取通道的split占了 27.6%。原因很实在:掩膜前景只占全图 0.336% ,轮廓处理的对象极小;而颜色转换、通道分离要遍历全部 200 万像素。耗时跟着「全图逐像素操作」走,不跟着「算法复杂度」走。 - 1×1 形态学那两步确实改变了 0 个像素 (
np.array_equal验证),但占了 16.3% 的检测耗时。我原来写「开销可以忽略」------实测把这个说法否掉了,已改。
两处优化,验证过结果不变
| 优化 | 前 | 后 | 节省 |
|---|---|---|---|
A. numpy 视图索引代替 cv2.split |
8.00 ms | 5.27 ms | 2.73 ms(34%) |
| B. 删掉 1×1 形态学两步 | 4.01 ms | 0 ms | 4.01 ms |
| 组合(含 findContours 全链路) | 31.5 ms | 26.4 ms | −16% |
两者都用 np.array_equal 逐像素比对过,轮廓结果也一致------不承担风险的纯性能优化。】
另外,这个文件里还有两处「每帧白做一次」的小事:
python
ini
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (1, 1))
mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel)
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)
- 结构元是常量,却在每一帧里重新构造一次。它是纯函数,提到模块级或函数外即可。
- 更根本的是上一篇的结论:1×1 结构元让这两次调用变成恒等变换,所以这几行的实际计算量是「每帧两次无效运算」。单看开销可以忽略(1×1 的腐蚀膨胀几乎不耗时),但它是「这一步其实没在做事」的又一个证据。
6. VideoWriter:四个坑堵住了三个半
写出 0 字节、或者写出来打不开的视频,几乎是每个用 OpenCV 存视频的人都踩过的坑。这个文件的处理方式值得逐条对照:
| 常见成因 | 现象 | 本文件的处理 |
|---|---|---|
忘记 release() |
文件尾部索引没写入,0 字节或无法播放 | ✅ finally 中释放(216--218 行) |
| 声明尺寸与实际写入帧不一致 | 帧被静默丢弃,可能得到 0 字节 | ✅ 尺寸取自 capture,且直接写原帧不缩放 |
fps 传 0 或负数 |
isOpened() 失败或写出异常文件 |
✅ if fps <= 0: fps = 30.0 |
| 编码器不可用 | isOpened() 返回 False |
✅ 139--142 行有检查并退出 |
四个成因堵住了三个半。 剩下那半个是:isOpened() 返回 True 并不保证每一次 write() 都成功------磁盘写满、编码器在特定帧上出错、路径权限中途变化,write() 都不会有任何返回值告诉你出问题了。最稳的收尾是在 release() 之后检查产物:
python
css
finally:
capture.release()
writer.release()
if not output_path.exists() or output_path.stat().st_size == 0:
print(f"输出文件为空,可能编码器不可用: {output_path}")
return 1
这样就形成闭环:创建时检查能不能写,结束时检查到底写出来没有。
两个和交付相关的实用提醒:
(1) mp4v 的兼容性有限。 代码用的是:
python
ini
codec = cv2.VideoWriter_fourcc(*"mp4v")
mp4v 是 MPEG-4 Part 2,播放器和桌面软件基本都能放,但浏览器 <video> 标签和很多在线预览不认它。如果这个标注视频要放进问卷、网页或者需要别人在线打开,建议重新编码成 H.264:
bash
css
ffmpeg -i lightbar_result.mp4 -c:v libx264 -pix_fmt yuv420p lightbar_result_h264.mp4
(直接在 OpenCV 里改用 avc1 也行,但它依赖 OpenCV 构建时是否带 H.264 编码器,isOpened() 会直接失败------这也正是上面那张表里第 4 条要检查的原因。)
(2) 部分编码器要求宽高为偶数。 H.264 系尤其如此,奇数尺寸会被截断或直接失败。当前代码把 capture 的宽高原样传给 VideoWriter,如果输入是 1281×721 这类尺寸,就可能出现「打开成功但写出来是坏的」。加一行保护很便宜:
python
arduino
width -= width % 2
height -= height % 2
7. 一个容易被忽略的问题:分不清「播完了」和「读挂了」
python
ini
success, frame = capture.read()
if not success:
break
read() 返回 False 有两种情况:视频正常结束 ,或者中途解码失败 。这两种情况在这里被合并成了同一个 break,而最后的输出只有:
代码块
makefile
处理完成,总帧数: 1204
如果视频本该有 3000 帧、在第 1204 帧解码出错,你看到的提示和「完整处理完」一模一样。结果是:你以为跑了全片,实际上只跑了前 40% ,然后拿着不完整的统计去写实验记录。
低成本改法:拿容器里记录的帧数做个对照。
python
python
expected_frames = int(capture.get(cv2.CAP_PROP_FRAME_COUNT))
# ... 循环结束后 ...
print(f"处理完成,总帧数: {frame_number}")
if expected_frames > 0 and frame_number < expected_frames * 0.99:
print(f"注意: 容器记录 {expected_frames} 帧,实际只处理 {frame_number} 帧,可能中途解码失败")
顺便说一句:
CAP_PROP_FRAME_COUNT对某些容器也不准(返回 0 或估计值),所以这里用「0.99 容差 + 只在明显偏少时警告」的方式,避免噪音。校验的价值在于发现异常,而不是追求绝对精确。
顺带一个体感问题 :main 在整个视频跑完之前不打印任何东西。几百帧还好,几千帧的视频会让你怀疑是不是卡死了。在循环里加一句每 100 帧打印进度,成本一行:
python
python
if frame_number % 100 == 0:
print(f"已处理 {frame_number} 帧 ...")
8. 参数为什么还写在代码里
现在这三个阈值写在 detect_lightbars 里:
python
ini
lower_blue = (80, 120, 110)
upper_blue = (135, 255, 255)
...
_, difference_mask = cv2.threshold(difference, 40, 255, cv2.THRESH_BINARY)
而 task1_lightbar/README.md 第 19--23 行又抄了一份:
代码块
markdown
- HSV:`(80, 120, 110)` 至 `(135, 255, 255)`
- B-R 差分阈值:`40`
这就是「两份真相」:改代码不会改文档,改文档不会改代码。今天两边一致,靠的是记得同步。
有意思的是,这个仓库里已经有正确答案了 ------task3_serial/config.json:
json
json
{
"pattern_size": [9, 6],
"square_size_m": 0.025,
"tag_size_m": 0.163,
"serial_port": "COM11",
"serial_baudrate": 115200,
"calibration_file": "../task2_apriltag/output/camera_params.npz"
}
任务三的相机、串口、Tag 参数全在配置文件里,任务一的阈值却硬编码。同一个项目里两种做法并存,正好可以作为「为什么要抽配置」的例证。
最小的改法是用命令行参数,不动现有结构:
python
arduino
def main():
parser = argparse.ArgumentParser()
parser.add_argument("input")
parser.add_argument("output")
parser.add_argument("--hsv-lower", default="80,120,110")
parser.add_argument("--hsv-upper", default="135,255,255")
parser.add_argument("--b-r-threshold", type=int, default=40)
parser.add_argument("--min-area", type=float, default=3)
parser.add_argument("--max-area", type=float, default=100000)
parser.add_argument("--min-ratio", type=float, default=1.5)
parser.add_argument("--max-ratio", type=float, default=20.0)
好处是:调参不用改代码,实验记录里可以直接把命令行原样抄下来,「这个结果是用哪组参数跑出来的」变得可复现。
9. 小结与改进清单
这个文件做对的三件事:
- 检测逻辑被拆成纯函数,能脱离视频单独测试和调用;
- 边界处理齐全,而且和 README 的声明逐条对得上;
VideoWriter的常见坑(释放、尺寸、fps兜底、创建检查)基本都堵住了。
按性价比排序的改进清单:
| 优先级 | 改动 | 收益 | 实测 |
|---|---|---|---|
| 高 | 计时窗口拆成 decode 与 detect 两段 |
优化重心不再被混合值掩盖 | 差距 36 %(55.7 vs 40.9 ms) |
| 高 | 删除 1×1 形态学两步 | 结果零变化,白省 16 % 检测耗时 | −4.01 ms/帧 |
| 高 | 用 numpy 视图索引代替 cv2.split |
少一次全图通道复制 | −2.73 ms/帧(34 %) |
| 高 | 循环结束后用 CAP_PROP_FRAME_COUNT 校验帧数 |
能发现「只跑了一部分」的静默失败 | --- |
| 中 | 阈值改命令行参数或配置文件 | 参数可复现,消除 README 双重真相 | --- |
| 中 | release() 后校验输出文件非空 |
关闭「写坏了但提示成功」的最后一个缺口 | --- |
| 中 | 保留 fill_ratio 但先用数据评估 |
用证据决定这个判据的去留 | --- |
| 低 | 结构元提到模块级、main() -> int、进度打印 |
代码整洁与使用体感 | --- |
| 低 | 检测结果改 dataclass |
字段名有提示,且天然支持 JSON 序列化 | --- |
前三条合起来是 −5.19 ms/帧(−16 %) ,且已验证结果逐像素一致------属于「不承担风险的性能优化」,值得优先做。
下一部分预告
第 3 部分讲 task2_apriltag/ 的三个文件(共 433 行):calibrate_camera.py 的棋盘格标定与重投影误差怎么算、detect_tag.py 的四角点顺序为什么决定位姿对不对、estimate_pose.py 里坐标系约定与「深度 z ≠ 直线距离」的由来
实测数据(由本目录脚本产出,可复现)
| 数据 | 数值 | 产出脚本 |
|---|---|---|
| 解码 / 检测 / 混合 三段耗时 | 14.8 / 40.9 / 55.7 ms | blog/tools/bench_detect.py |
| 检测占比 | 73.4 % | 同上 |
| 端到端吞吐 | 17.9 fps(素材 30 fps) | 同上 |
| 灯条数分布 | 平均 4.00,零检出帧 0/300 | 同上 |
findContours 占检测耗时 |
7.8 % | blog/tools/profile_stages.py |
split 占检测耗时 |
27.6 % | 同上 |
| 1×1 形态学占检测耗时 | 16.3 %(掩膜差异 0 像素) | 同上 |
视图索引替代 split |
8.00 → 5.27 ms | blog/tools/micro_opt.py |
| 两处优化组合 | 31.5 → 26.4 ms(−16 %),结果逐像素一致 | 同上 |
环境:OpenCV 4.13.0 / Python 3.13 / 合成 1920×1080 素材。项目记录环境为 OpenCV 5.0.0 / Python 3.10.12,跨机绝对值不可直接比较。