🎬 第十二章 AVDevice设备读写理论及案例实战
📄 一句话概括

本机没有 drawtext,所以水印是直接在像素数组上画一块。 代价是两次格式转换,收益是「这活儿在哪儿都能干」。

📝 本节和前面几节的关系

12.5 把流拉下来解成 YUV,这一节把它画上水印再编回去。 这是「采→处理→编」这条链第一次完整闭环。

一、为什么不用 drawtext

打水印有三条路,本机只通两条。 做法 本机能不能用


滤镜 drawtext 不能,没编进来 滤镜 overlay 叠图 能,要另做一张水印图 直接改像素 能,最直接

drawtext 依赖 freetype 与 harfbuzz 两个外部库, 编译时没带就整个滤镜不存在(第九章查过本机的滤镜表)。这不是参数写错,是功能没有。 overlay 需要一张带 alpha 的水印图, 好处是能叠复杂图案(台标、二维码),坏处是多一个资源要管。 直接改像素最土,但它不依赖任何滤镜——只要能拿到帧,就能画。所以本篇走这条。

二、三步走:解码、画面、编码

中间那一步要用 RGB,所以两头各转一次。 步 做什么 容易错在哪


一 转 rgb24 不转就没法按像素改 二 数组上画 别越界,形状是 (h,w,3) 三 转回 yuv420p 编码器只吃 yuv

转来转去一共两次格式转换,这就是「直接改像素」的代价。 用 overlay 能省掉一次,但要多准备一张图。 注意形状是 (h, w, 3),不是 (3, h, w)——RGB 只有一个平面,行在前、列在后。 而 yuv420p 的 to_ndarray() 是平面竖着拼的,两套形状搞混,画出来的位置一定错。 「别越界」不是客套话:写 rgb[h-15:h, w-50:w] 最稳, 用绝对坐标在换分辨率之后就会写到画面外,而 numpy 的切片不会报错,它只会静默地少画一块。

三、真跑一遍

50 帧,右下角画一块红,编成 H.264。 项 值


帧数 50 裸包字节 9550 文件字节 10827 时长 2.00 秒 容器开销 1277 字节

时长 2.00 秒、帧数 50、帧率 25——三个数对得上,说明 pts 排对了。 这一步是第九章那个坑的复用:nf.pts = n 不能省。 省了的话,出来的文件要么 0 秒,要么时长翻倍。 裸包 9550 字节,平均一帧 191 字节,因为画面几乎是纯色,264 压这种内容非常狠。 换成真实摄像头画面,一帧会在几千字节这个量级。 容器开销 1277 字节:moov 索引、流信息、每帧的时间戳表, 这部分不随画质变,只随帧数与轨道数变。 所以短视频的「文件头占比」总是很大。

四、那个容易忽略的 copy

这一处不写 copy,程序不报错,但结果会错。 写法 结果


直接改 reformat 的数组 改到原帧的共享内存 先 .copy() 再改 安全,多一次拷贝

to_ndarray() 拿到的数组与帧共用内存,不是副本。 不 copy 就改,等于把解码缓存改了——轻则水印画到不该画的地方,重则下一帧解出来就是坏的。 这类 bug 最难查:程序不崩、不报错,只是画面偶尔花一下。 所以凡是「在帧上原地改」的地方,第一反应都该是 copy。 代价是一次整帧拷贝:160×120 才 57 KB,1080p 就是 6 MB, 一秒 25 帧就是 150 MB 的额外带宽。所以能用滤镜做的事,优先用滤镜——它能在内部避免这次拷贝。

五、这条链就是推流的雏形

把这一节的三段与直播里的一比,只差两头。 这一节做的 直播里换成


读文件 读摄像头或读流 画一块红 叠加台标或美颜 编成 h264 存文件 编成 h264 推到服务器

三段骨架一个字都不改,只换两头。 中间这段「解码→处理→编码」完全复用。 第十三章就是把这两头换掉:头换成 RTMP 推流,尾换成摄像头或 OpenCV。 换完之后,这一节学的东西一样有用。 第十二章到此为止。这一章能给的边界很清楚:设备这一层,沙箱只有 gdigrab 一条真路。 但「采到之后的处理」全都真跑过了——重采样、拉流、画图、编码。

六、动手 Demo:给每一帧打上水印,再编成 H.264

解 50 帧,每帧转 RGB 后在右下角画一块 15×50 的红,再转回 yuv420p 编码落盘。

import os
import av

c = av.open("_media32/tiny.mp4")
st = c.streams.video[0]

o = av.open("_media32/watermark.mp4", "w")
vs = o.add_stream("libx264", rate=25)
vs.width, vs.height, vs.pix_fmt = 160, 120, "yuv420p"

n = 0
for pk in c.demux(st):
    if pk.size == 0:
        break
    for fr in st.codec_context.decode(pk):
        # 1) 转到能按像素改的格式,注意 copy
        rgb = fr.reformat(format="rgb24").to_ndarray().copy()
        # 2) 画一块红(行在前、列在后)
        rgb[100:115, 100:150] = (255, 0, 0)
        # 3) 转回编码器要的格式,pts 一定要给
        nf = av.VideoFrame.from_ndarray(rgb, format="rgb24")
        nf = nf.reformat(format="yuv420p")
        nf.pts = n
        for op in vs.encode(nf):
            o.mux(op)
        n += 1
for op in vs.encode(None):
    o.mux(op)
c.close()
o.close()
print(n, os.path.getsize("_media32/watermark.mp4"))

实跑输出:

--------------------------------------------------------------------
给每一帧打上水印,再编成 H.264
--------------------------------------------------------------------

**① 为什么不用 drawtext**

  做法                  本机能不能用
  ----------------------------------------------------
  滤镜 drawtext         **不能,这个滤镜没编进来**
  滤镜 overlay 叠图     能,但要另做一张水印图
  直接改像素            能,最直接

**本机没有 drawtext**(第九章查过)。**所以这里走第三条:把帧转成 RGB,直接在数组上画一块。**

**② 三步走:解码 → 画面 → 编码**

  步     做什么           容易错在哪
  ------------------------------------------------------
  一     转 rgb24         不转就没法按像素改
  二     数组上画         别越界,注意形状是 (h,w,3)
  三     转回 yuv420p     编码器只吃 yuv

**转来转去一共两次格式转换**,这就是「直接改像素」的代价。
**用 overlay 滤镜能省掉一次,但要多准备一张图。**

**③ 真跑一遍:50 帧,右下角一块红**

  项           值
  --------------------------
  帧数         50
  裸包字节     9550
  文件字节     10827
  时长         2.00 秒
  容器开销     1277 字节

**时长 2.00 秒,帧数 50——说明 pts 对齐了。**
**这一步是第九章那个坑的复用:`nf.pts = n` 不能省。**

**④ 一个容易忽略的细节:copy**

  写法                          结果
  ------------------------------------------------------------------
  `fr.reformat(...)` 直接改     改的是原帧的共享内存,会污染后续
  先 `.copy()` 再改             安全,但多一次拷贝

**这里必须先 copy**,因为 `to_ndarray()` 拿到的数组与帧共用内存。
**不 copy 就改,等于把解码缓存改了**——下一帧可能就不对。

**⑤ 这条链就是推流的雏形**

  这一节做的           直播里换成
  ---------------------------------------------
  读文件               读摄像头或读流
  画一块红             叠加台标或美颜
  编成 h264 存文件     编成 h264 推到服务器

**三段骨架一个字都不改,只换两头。** 第十三章就是把这两头换掉,
**中间这段「解码→处理→编码」完全复用。**

50 帧、2.00 秒对得上,说明 nf.pts = n 起了作用。 to_ndarray() 不是副本,改之前必须 .copy(),否则污染解码缓存。

30 秒实验

  1. 把 rgb[100:115, 100:150] 改成 rgb[-15:, -50:],换台机器换分辨率也不会越界。
  2. 去掉 .copy(),把 50 帧画不同颜色的块,看后面几帧是否被污染。
  3. 把水印改成半透明(rgb[...] = rgb[...] // 2 + 127),看压缩后还能不能看清。

常见误区

  1. 直接改 to_ndarray() 的数组。 那是共享内存,要 copy。
  2. 忘了设 nf.pts。 编码出来要么 0 秒,要么时长翻倍。
  3. 把 rgba 的形状当 (3,h,w)。 rgb24 是 (h,w,3),yuv 才是平面拼。
自测三问(点开对答案) 1. 本机为什么不能用 drawtext? —— 它依赖 freetype 等外部库,编译时没带,滤镜整个不存在。 2. 直接改像素打水印的代价是什么? —— 要多两次格式转换,还要多一次整帧拷贝。 3. `nf.pts = n` 省了会怎样? —— 时间戳全一样,出来的文件要么 0 秒要么时长翻倍。

相关笔记