☰
视频流处理中 list 与 np.ndarray 的分工、转换与内存优化
2026/10/31 13:19:25 网站建设 项目流程

视频流处理这条链路上,list和np.ndarray几乎必然会同时出现。采集端拿到的是一帧一帧的原始字节,塞进list最顺手;推理端要的是[B, H, W, C]的四维张量,必须是np.ndarray;中间还有缓冲、抽帧、批量组装、归一化一堆环节。我见过太多代码在这两种结构之间来回倒腾,跑起来帧率掉一半,内存还飙到几个 G,最后查半天发现只是某一行np.array(frames)多做了一次全量拷贝。这篇内容就围绕「视频流场景下怎么同时用好 list 与 np.array」展开,把两者的分工边界、转换时机、dtype 与内存布局的坑、缓冲区的预分配写法,以及我实际调优时踩过的典型问题,一次讲透。适合已经能用 OpenCV 或类似库读视频、但一做批量处理就卡顿或爆内存的开发者,也适合刚接触 numpy 不久、想搞清楚「为什么不能随手 np.array」的朋友。下面所有代码都可以直接跑,参数计算我会把过程写出来,你按自己的分辨率替换数字就行。

1. 视频流里 list 与 ndarray 的分工边界

1.1 两种结构各自的强项,别硬掰

先把定位说清楚,后面所有的取舍都从这里推导。list是「容器」,它的价值在于可变长度、任意元素类型、O(1) 尾部追加。视频流天生是不定长的:你不知道这一路流会跑多久,不知道哪一帧会因为解码失败被丢掉,不知道下游消费者什么时候来取。这种「长度未知、元素同构但数量动态」的场景,list和collections.deque是最自然的表达。

np.ndarray是「计算单元」,它的价值在于连续内存 + 同构 dtype + 向量化。一旦你要对一帧做 resize、颜色空间转换、归一化、减去均值,或者要把 8 帧拼成一个 batch 送进模型,就必须是 ndarray。用 list 做逐像素循环,1080p 一帧 622 万个元素,Python 层的 for 循环跑完要好几秒,而 numpy 向量化通常几十毫秒。

所以正确的分工是:list 管「帧的集合」,ndarray 管「帧的内容」。list 里装的是 ndarray,而不是 list 里装 list、或者 list 里装 bytes 到最后一刻才转。这个原则听起来简单,但实际代码里最常见的反模式恰恰是把 list 当成计算载体——比如frames[i][j][k]这样三层索引去取像素,或者用sum(frames) / len(frames)做时间平均。这类写法在数据量小的时候没感觉,一上 30fps 连续跑十分钟就开始难受了。

还有一个容易被忽略的点:空 list 和空 ndarray 的语义不同。[]和np.array([])在很多地方行为不一致,前者len()是 0 且能append,后者 shape 是(0,)且 dtype 默认 float64。做帧缓冲时如果初始化成np.array([])再想往里「加帧」,就会陷入反复concatenate的地狱,每加一帧拷一次全量。这个坑我在早期项目里踩过,缓冲区跑到 300 帧之后,单帧追加耗时从 0.1ms 涨到 8ms,曲线是标准的 O(n²)。

提示:判断某个变量该用 list 还是 ndarray,问自己一句「我要不要对它做整体数学运算」。要,就是 ndarray;不要,就是 list。别为了「看起来统一」把所有东西都塞成 ndarray。

1.2 混用产生的三类隐性代价

第一类是拷贝代价。np.array(list_of_frames)和np.stack(list_of_frames)都会分配一块新的连续内存,把每帧的数据逐字节搬过去。1080p RGB 一帧是 1920×1080×3 = 6,220,800 字节,约 5.93 MB。一次 30 帧的批量组装,就是 178 MB 的内存分配加拷贝,按内存带宽 10 GB/s 粗算也要 18ms 左右,还没算上分配器抖动。如果这个动作每 30 帧执行一次,等于每秒白白烧掉 18ms。

第二类是dtype 漂移。np.array([...])在元素 dtype 不一致时会向上提升:list 里混了uint8的帧和一个float32的帧,结果整个数组变成float32;混了不同 shape 的帧,更糟,直接退化成dtype=object的数组,后面所有向量化操作全部失效,而且不会报错,只会在某个下游函数里莫名其妙变慢。我排查过一次线上卡顿,最后定位到某个分支把一帧归一化后的 float 帧 append 回了同一个缓冲 list,导致后续np.stack输出 float64,内存直接翻八倍。

第三类是形状漂移。视频流里出现分辨率切换、或者某一路流解码出异常尺寸的帧,在 list 里是「合法」的,因为 list 不校验元素 shape;但一到np.stack就会抛ValueError: all input arrays must have the same shape。更阴险的情况是用了np.array(frames),某些 numpy 版本在 shape 不一致时会静默生成 object 数组,直到你把它喂给模型才炸。

结构长度可变元素类型校验向量化典型用途
list是否否帧队列、待处理池、结果收集
deque是(定长)否否滑动窗口、环形缓冲
ndarray否是(统一 dtype)是单帧像素运算、批量张量
ndarray 预分配否是是高频写入的环形缓冲池

所以「同时用好」的关键不是二选一,而是在正确的时机做一次转换,且只做一次。转换点应该放在数据流的收窄处——比如「一批帧攒够了,要送进模型」这一刻,而不是每来一帧就转一次。

1.3 一条典型链路的转换点设计

我常用的骨架是这样分层:采集层用queue.Queue或deque存原始帧;组装层负责把 N 帧拼成一个[N, H, W, C]的 ndarray;计算层只吃 ndarray,全程不再回头碰 list;输出层把结果再拆回逐帧结构。

这条链路里只有一个强制转换点,就是组装层。采集层到组装层之间传的是 ndarray 的引用,没有拷贝;组装层产出的 batch 交给计算层也是引用传递。整条链路每帧只发生一次内存分配(batch 那块),而不是每帧都分配一次。

判断转换点位置有个简单标准:看下游消费的粒度。下游是「一次吃一批」,就在攒够一批时转;下游是「一帧一帧流式处理」,那就根本不需要批量转换,直接让每帧保持 ndarray 形态进管道,list 只作为传输队列存在,元素本身就是 ndarray。很多人把「用 list 收集」和「用 ndarray 计算」理解成必须成对出现,其实不是,帧在 list 里的时候就已经是 ndarray 了,这才是最省事的形态。

2. 帧缓冲的核心实现与参数计算

2.1 三种缓冲写法的实测对比

同样是「保留最近 30 帧」,写法差别能拉开一个数量级。第一种,最朴素:

frames = [] while True: ok, frame = cap.read() if not ok: break frames.append(frame) if len(frames) > 30: frames.pop(0) # O(n) 搬移

问题在pop(0),每次都要把后面 29 个引用往前挪,虽然挪的是指针不是像素数据,但 Python 层的循环开销在 30fps 下也很可观。第二种,deque(maxlen=30),追加和淘汰都是 O(1),是滑动窗口的标准答案:

from collections import deque frames = deque(maxlen=30)

第三种,预分配 ndarray 环形缓冲。适合「帧率稳定、分辨率固定、写入频率高」的场景:

import numpy as np class FrameRing: def __init__(self, capacity, shape, dtype=np.uint8): self.buf = np.empty((capacity, *shape), dtype=dtype) self.capacity = capacity self.idx = 0 self.count = 0 def push(self, frame): # 只做一次赋值,不分配新内存 self.buf[self.idx] = frame self.idx = (self.idx + 1) % self.capacity self.count = min(self.count + 1, self.capacity) def latest(self, n): n = min(n, self.count) end = self.idx start = (end - n) % self.capacity if start < end: return self.buf[start:end] # 视图,零拷贝 return np.concatenate([self.buf[start:], self.buf[:end]], axis=0)

这个实现的价值在于push是对预分配内存的原地赋值,不会触发新的分配。代价是容量固定、shape 固定,遇到分辨率切换要重建整个缓冲。我的建议是:分辨率固定用环形缓冲,分辨率可能变(比如多路流混跑)用deque,两个都别用裸 list 做pop(0)。

2.2 内存占用怎么算,先把账算明白

选容量不是拍脑袋。以 1080p RGB 为例,单帧字节数:

  • 宽 × 高 × 通道 = 1920 × 1080 × 3 = 6,220,800 B ≈ 5.93 MB
  • 若用float32存归一化后的帧,再乘 4 = 23.7 MB/帧

缓冲 30 帧,uint8是 178 MB,float32是 712 MB。再乘上 batch 那一份(batch 通常是缓冲的一个切片,如果走np.stack会再分配一份),实际峰值内存轻松破 G。4K 更夸张,3840 × 2160 × 3 = 23.7 MB/帧,uint8缓冲 30 帧就是 711 MB。

带宽也要算。30fps × 5.93 MB = 178 MB/s,约 1.4 Gbit/s 的持续读写压力。如果你的处理链路每帧还要多做一次全量拷贝,等于凭空多出 1.4 Gbit/s 的内存带宽占用。这就是为什么「少一次np.array」在视频场景下收益特别明显——它省的不是 CPU 周期,是内存带宽。

注意:能用uint8就别用float32存原始帧。归一化放在真正要计算的那一步做,而且尽量在 batch 维度上一次性做,别在逐帧 append 之前做。uint8到float32是 4 倍内存,这个账在缓冲层算一次就够了。

2.3 dtype 一致性必须在上游守住

我现在的习惯是:帧一进缓冲区就断言 dtype 和 shape。

def push(self, frame): assert frame.dtype == np.uint8, f"unexpected dtype {frame.dtype}" assert frame.shape == self.buf.shape[1:], f"shape {frame.shape} != {self.buf.shape[1:]}" self.buf[self.idx] = frame ...

线上环境可以把 assert 换成日志加丢帧,但一定要有这道关卡。因为 dtype 和 shape 的异常一旦漏到下游,症状会变得完全不像病因:比如某帧被写成了float64,np.stack的结果整体升格,模型推理的输入 scale 从 0-255 变成 0-1,输出精度崩掉但你只看得到准确率下降,查一周都查不到这里。

还有一种更隐蔽的情况:帧是 ndarray 但不是连续内存。比如你做了frame[:, ::-1]翻转,得到的是一个负步长的视图,np.ascontiguousarray才能变成连续内存。某些底层库(图像编码、推理引擎)要求输入连续,遇到非连续会自动内部拷贝一次,性能无声无息地掉。我一般的做法是在组装 batch 之后加一行batch = np.ascontiguousarray(batch),它对自己已经连续的数组是零开销的空操作,对非连续数组才真正拷贝。

3. 批量组装:stack、asarray、concatenate 怎么选

3.1 三者的行为差异一次说清

函数是否分配新内存输入要求输出 shape适用场景
np.array(seq)是元素 shape 一致(N, ...)元素是同构数值时用
np.stack(seq)是元素 shape 完全一致(N, ...)多一维帧批量组装首选
np.asarray(seq)不一定元素同构同上已是 ndarray 时避免重复拷贝
np.concatenate(seq)是除拼接轴外一致沿轴合并拼接不同片段的帧序列

关键在np.asarray和np.array的区别:前者在输入已经是 ndarray 时直接返回原对象(不拷贝),后者总是拷贝。所以如果你的 list 里的元素本来就是 ndarray,np.asarray(frames)有可能帮你省掉一次拷贝——注意是「有可能」,因为当输入是 list 而不是 ndarray 时,asarray内部仍然要新建数组。真正的零拷贝只有在「输入本身就是 ndarray」时才成立。

np.stack和np.array的差别则在语义:stack明确表达「沿新轴堆叠」,行为更可预测;array在 shape 不一致时会走 object 数组那条暗路。我现在的代码里,只要是帧批量组装,一律np.stack(frames, axis=0),不给自己留意外。

batch = np.stack(frames, axis=0) # (N, H, W, C) batch = np.ascontiguousarray(batch)

3.2 别在循环里 concatenate

这是最经典的反模式,一定要单独拎出来说:

# 反面写法 buf = np.empty((0, H, W, C), dtype=np.uint8) for frame in stream: buf = np.concatenate([buf, frame[None]], axis=0) # 每帧拷全量

第 k 帧的时候要拷贝前 k-1 帧的数据,总拷贝量是 O(n²)。n=300 的时候,累计拷贝的数据量是单帧的 45000 倍。实测 1080p 下这个写法跑到 200 帧左右就明显卡顿。

正确写法永远是「先收集,后组装」:

buf = [] for frame in stream: buf.append(frame) if len(buf) == batch_size: batch = np.stack(buf, axis=0) buf.clear() # 注意是 clear,不是重新赋值 yield batch

buf.clear()保留原 list 对象,避免反复创建;更重要的是一旦stack完成,list 里那些帧的引用就可以释放,内存不会累积。

3.3 归一化与预处理的时机选择

归一化放哪一步,直接影响内存峰值。三个选项:

  1. 逐帧归一化后存 list —— 峰值最高,8 倍内存(float32 且原帧也没释放),坚决不选。
  2. 组装 batch 后整体归一化 —— 峰值 = 原始 batch + float batch,约 5 倍单 batch。
  3. 用float32的预分配 batch 缓冲区原地写入 —— 峰值最低,但代码复杂。

多数场景用第 2 种就够了:

batch = np.stack(frames, axis=0) # uint8, (N,H,W,C) batch_f = batch.astype(np.float32) # 此时内存翻倍 batch_f *= (1.0 / 255.0) # 原地操作,不再分配 batch_f -= mean # 原地 batch_f /= std # 原地

注意*=、-=这些原地运算符和batch_f = batch_f / 255.0的区别,后者会再分配一个新数组。在 batch 级别上,少一次分配就是几十 MB。如果是 float32 大 batch,记得把中间的batch(uint8 那份)及时del掉,让引用计数归零。

另外,astype默认copy=True,如果你确认后面不再用原始 uint8 数据,可以考虑batch.astype(np.float32, copy=False)——但只有在 dtype 本来就匹配时才真的不拷贝,所以别指望它能省事,老老实实写完astype就行。

4. 完整可跑的视频流处理骨架

4.1 采集与消费解耦:线程 + 有界队列

视频解码通常比推理慢,同步读帧会让整个管道被 IO 拖住。标准做法是一个采集线程往有界队列里塞帧,主线程从队列取。有界很重要,无界队列在消费端卡住的时候会把内存吃光。

import threading import queue import numpy as np class FrameSource: def __init__(self, cap, maxsize=64): self.cap = cap self.q = queue.Queue(maxsize=maxsize) self.stop_flag = threading.Event() self.worker = threading.Thread(target=self._run, daemon=True) def _run(self): while not self.stop_flag.is_set(): ok, frame = self.cap.read() if not ok: self.q.put(None) # 用 None 做结束哨兵 break try: self.q.put(frame, timeout=0.5) except queue.Full: continue # 丢帧优于阻塞采集 def start(self): self.worker.start() def get(self, timeout=1.0): return self.q.get(timeout=timeout)

队列深度 64 帧,1080p 下就是 64 × 5.93 MB ≈ 380 MB。这个数字要按你的内存预算调,一般 16 到 32 就够缓冲了,64 已经是比较宽松的值。设太大会掩盖下游的性能问题——看起来不卡,其实是在拿内存换时间,一旦流变长就可能 OOM。

queue.Full时选择丢帧而不是阻塞,是因为视频流是实时数据,延迟一帧的价值远低于丢掉它。如果业务要求「一帧都不能少」,那就得改成阻塞加重试,同时接受采集端被拖慢的事实,这是业务取舍,没有对错。

4.2 批次组装与推理调用

把上面的零件拼起来,主循环长这样:

def run_pipeline(cap, batch_size=8, ring_cap=32): src = FrameSource(cap, maxsize=32) src.start() ring = FrameRing(ring_cap, shape=(1080, 1920, 3), dtype=np.uint8) pending = [] while True: frame = src.get() if frame is None: break # 尺寸统一在这里做,一次 resize,后面不再变 if frame.shape[:2] != (1080, 1920): frame = resize_keep_ratio(frame, 1920, 1080) ring.push(frame) pending.append(frame) if len(pending) < batch_size: continue batch = np.stack(pending, axis=0) # 唯一的批量转换点 pending.clear() batch = np.ascontiguousarray(batch) batch_f = batch.astype(np.float32) * (1.0 / 255.0) results = infer(batch_f) # 你的模型调用 handle(results) src.stop_flag.set()

几个细节值得说。第一,resize放在入环之前,保证环里的帧 shape 完全一致,push的断言就永远不会触发。第二,pending和ring是两份引用,指向同一批 ndarray 对象,所以并没有额外内存;pending.clear()之后那些帧还活在ring里,符合「保留最近 N 帧」的语义。第三,如果某一帧 resize 失败返回None,必须在那之前continue掉,不能让None进 list——这正是后面排查表里那条!_src.empty()报错的来源。

4.3 关键参数的取值依据

batch_size取 8 还是 32,看两件事:模型的最优吞吐点和单帧延迟容忍度。经验值上,batch_size从 1 涨到 8,吞吐通常能提升 2 到 3 倍;从 8 涨到 32,边际收益快速衰减,而单帧的排队延迟线性上涨。30fps 输入下,batch=8 意味着最坏情况要等 8/30 ≈ 267ms 才凑够一批,如果业务对延迟敏感(比如实时交互),这个值太大;如果只是离线分析,32 甚至 64 都可以。

ring_cap的取值取决于下游要不要回溯历史帧。做简单检测,保留 1 帧就够;做时序模型(比如多帧融合),保留模型感受野对应的帧数加一点余量即可。同一段视频重复缓存在两个地方是纯浪费。

采集队列maxsize建议不超过2 × batch_size,因为它的作用只是吸收抖动,不是存历史。这个值设大了,端到端延迟会悄悄膨胀,而且很难从代码上看出来——你只会觉得「结果比画面慢半拍」。

5. 零拷贝技巧与进阶写法

5.1 frombuffer 与 memoryview:绕过 Python 层

如果视频帧来自原始字节流(网络包、管道输出、硬件编码器回调),完全不需要先拼成 list 再转。np.frombuffer直接在字节对象上建立只读视图:

raw = b'\x00' * (1920 * 1080 * 3) frame = np.frombuffer(raw, dtype=np.uint8).reshape(1080, 1920, 3)

这行代码不复制任何像素数据,frame是raw的视图,改frame就是改raw——所以它是 read-only 的,写入前要.copy()。对于「收到就送进模型推理」的场景,连 copy 都省了。

memoryview更底层一层,做切片时不产生新对象:

mv = memoryview(raw) chunk = mv[0 : 1920 * 1080 * 3] # 零拷贝切片 frame = np.frombuffer(chunk, dtype=np.uint8).reshape(1080, 1920, 3)

在处理「一包数据里有多帧拼接」的协议时,memoryview切片比raw[a:b]快得多,因为后者会真的复制字节。这个差别在 4K 帧上非常明显。

5.2 视图与拷贝的判断口诀

numpy 里判断一个操作是视图还是拷贝,有个实用口诀:基础切片(步长为 1 或正步长)是视图,花式索引和布尔索引是拷贝,reshape在不改变内存布局时是视图、跨步时是拷贝,astype永远是拷贝,copy()当然是拷贝。

视频场景里最容易吃亏的是reshape和transpose。frame.transpose(2, 0, 1)把 HWC 变 CHW,是视图,没有拷贝,但得到的是非连续数组;紧接着送进要求连续内存的库,就会触发一次隐式拷贝。所以我的习惯是:转置之后立刻np.ascontiguousarray,把这次拷贝显式化,至少你知道它发生了,而不是在 profiler 里看到一个说不清来源的耗时。

chw = frame.transpose(2, 0, 1) # 视图,非连续 chw = np.ascontiguousarray(chw) # 显式拷贝一次

5.3 list 与 ndarray 互相拆装的正确姿势

从 ndarray 拆回 list,如果只是要「逐帧遍历」,根本不用拆:

for frame in batch: # 产生的是视图,shape (H,W,C) process(frame)

注意for frame in batch每次迭代产生的是一个视图对象,虽然不拷贝数据,但每次都要构造新的 ndarray 对象头,在百万次迭代的规模下有可测的开销。如果你只是要读几个统计量,向量化算完再拆更划算:

means = batch.mean(axis=(1, 2, 3)) # 一次算完所有帧 for i, m in enumerate(means): log(i, m)

反过来,从 ndarray 变 list 用list(batch)或者batch.tolist()。前者是浅拷贝,元素还是 ndarray 视图;后者是深拷贝,转成嵌套 Python list,慢得离谱且丢精度,除非要序列化成 JSON,否则别用。这一点很反直觉:名字里带to_的函数往往意味着「转成另一套类型体系」,代价比看起来大得多。

6. 常见问题与排查速查

6.1 报错对照表

现象大概率原因处理方式
ValueError: all input arrays must have the same shapelist 里混入了不同分辨率的帧入环前统一 resize,加 shape 断言
dtype=object静默出现元素 shape 或 dtype 不一致改用np.stack,加 dtype 断言
!_src.empty()list 里混入None读帧失败时continue,别 append
内存持续上涨不回落list 引用未释放,或归一化后仍持有原帧clear()而非重新赋值;及时del
帧率随运行时间下降循环内concatenate,O(n²) 拷贝改成「收集 + 一次 stack」
单帧耗时突然翻倍帧变成非连续数组,下游隐式拷贝np.ascontiguousarray
数值范围异常(0-1 而不是 0-255)某帧被提前归一化后混入 uint8 缓冲dtype 断言,归一化只在 batch 层做

6.2 几个只有实际跑过才知道的坑

坑一:list.clear()不等于释放内存。如果别的变量还引用着里面的帧,对象不会回收。我遇到过一次「缓冲清了但内存不降」,最后发现是日志模块把每帧的引用存进了一个全局deque做调试展示。这类问题用gc看不出来,得用引用计数排查,或者干脆把调试引用的深度限制住。

坑二:多线程写同一个预分配缓冲区会踩内存。FrameRing.push不是线程安全的,self.idx的取模和自增在多线程下会乱序,表现为某些帧被覆盖、某些位子是旧的。要么加锁,要么每个线程一个独立 buffer 最后合并。视频处理里我更倾向于后者,锁的争用在 30fps 下虽然不致命,但会让性能曲线变得难以预测。

坑三:OpenCV 读出来的帧是 BGR,别在 list 里混着放 BGR 和 RGB。这个错误在混用多个来源(摄像头 + 视频文件 + 网络流)时特别容易出现。统一在入口处转成一种色彩顺序,之后全程不再转换,比在每个下游函数里做兼容要可靠得多。

坑四:np.stack的默认 axis=0 是对的,但拼接历史帧时别忘了顺序。环形缓冲latest(n)返回的切片在跨越回绕点时,需要concatenate两段来保证时间顺序正确。上面FrameRing.latest里那个start < end的判断就是干这个的,写错了时序模型的效果会莫名变差,而且很难从数据上直接看出来。

提示:所有涉及「帧顺序」的逻辑,都建议在调试时打印时间戳序列验证一次。这在一次性代码里显得多余,但一旦进入生产环境,顺序错乱是最难定位的那类问题。

6.3 我个人的几条硬规则

第一,缓冲区容量和 batch size 都写成参数,不要硬编码。不同流的分辨率、帧率、延迟要求都不一样,硬编码意味着每次换场景都要改代码、重新测。

第二,每个跨层转换点都加一条轻量断言,assert frame.ndim == 3 and frame.dtype == np.uint8。断言的运行时开销在微秒级,相比它挡掉的排查时间,性价比高得离谱。生产环境可以关掉,但开发阶段一定要开。

第三,先测通再优化。很多人一上来就上预分配环形缓冲、零拷贝、共享内存,结果 bug 一堆查不出来。正确的顺序是:先用deque加一次np.stack跑通,确认业务逻辑正确,再用 profiler 找出真正的瓶颈——十次里有八次瓶颈不在你以​为的地方。

第四,归一化的位置一旦定下就别动。这条规则看起来教条,但我见过太多「临时在某处加一个/255调试」最后忘记删的案例。所有数值变换集中在组装层之后的一小段代码里,其他地方一律只做形状和 dtype 的统一。

这套写法我在几路并发的视频分析任务里用过,1080p、30fps、batch=8 的配置下,单进程的常驻内存稳定在 1.2 GB 左右,CPU 占用峰值不到 60%,跑十几个小时不涨。真正把内存压下来的不是某个技巧,而是「每帧只转一次、每次分配都看得见」这两条原则。你如果现在正在被np.array的隐式拷贝折磨,先把调用点全搜出来,数一数同一帧被转了几次,答案多半就在那个数字里。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询