代码解析(二):detect_video.py —— 一个纯函数 + 一段主循环

上一篇把 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) 无

三点值得说:

  1. 面积下界从 10 降到 3,是为了捞回远处的灯条。代价是更多的单像素噪点会进入候选------这类取舍只能靠看结果决定,没有理论最优值。
  2. 长宽比下界终于有了意义。 上一篇指出练习版的 1.0 是死代码;任务一改成 1.5,它真的开始干活了:把接近正方形的干扰物挡在外面。同一个参数,从「写了等于没写」变成「真的在筛」,这是参数设计上的进步。
  3. 填充率被丢掉了。 练习版用它过滤「形状不对但尺寸对」的干扰物,任务版没用。这可能是经验上认为不必要,也可能是移植时漏了。建议用数据来决定 :把 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 %

两个反直觉的点:

  1. 被当成「算法主体」的 findContours 只占 7.8% ,而一个取通道的 split 占了 27.6%。原因很实在:掩膜前景只占全图 0.336% ,轮廓处理的对象极小;而颜色转换、通道分离要遍历全部 200 万像素。耗时跟着「全图逐像素操作」走,不跟着「算法复杂度」走。
  2. 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. 小结与改进清单

这个文件做对的三件事:

  1. 检测逻辑被拆成纯函数,能脱离视频单独测试和调用;
  2. 边界处理齐全,而且和 README 的声明逐条对得上;
  3. 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,跨机绝对值不可直接比较。

相关推荐
爱勇宝41 分钟前
《道德经》第 13 章:别让评价系统控制你的心
前端·后端·产品
掘金者阿豪1 小时前
集中式与分布式数据库怎么选?从架构原理到电科金仓的实践思考
前端·后端
张彦峰ZYF1 小时前
升级了不等于变好了:AI回归测试的固定评测集、版本对比、质量阈值与CI门禁
人工智能·后端·模型或提示词升级·语言模型没有编译器与确定性输出·总体、切片与回退清单·红线、护栏与目标·ci门禁把判定写进合并路径
程序员沉梦听雨1 小时前
【搜索引擎】Kibana使用指南
java·后端·学习笔记
Cosolar2 小时前
极简智库(MiniDocs):docsify、VitePress、Notion 等知识库搭建服务的完美替代
前端·后端·架构
Goboy2 小时前
夯爆了,我被一个一句话接入的免费可观测平台策反了,那一刻我决定放弃用了3年的Prometheus
后端·docker·kubernetes
回家路上绕了弯3 小时前
Agent in the Sandbox:当 AI 有了自己的开发环境
后端
陈随易3 小时前
TanStarter,从搭建到支付,一步到位开始赚美元
前端·后端·程序员
Rain的Java大神之路3 小时前
公交App上“还有2站,约5分钟”怎么实现?90%的人答错这道大厂面试题:GPS漂移、ETA不准、10万QPS热点读全拆透(附完整代码)
java·后端·面试