做视频流处理的人大多有过这么一段经历写个脚本读一段本地素材或者摄像头画面一帧一帧地append到 list 里跑起来没感觉有任何问题等数据量上来之后机器开始卡顿内存曲线像被人往上拽的绳子。我第一次撞上这个问题是在处理一段十分钟的高清素材每帧 1920×1080×3 的 uint8粗算一帧六兆多十分钟按 25fps 算下来一万五千帧全塞进 list 就是接近 90GB。当时我还觉得 list 是轻量容器结果被现实教育得相当彻底。这个标题里把np.arr和list摆在一起讲的不是谁替代谁的问题而是一条视频流从解码出来、到进入算法、再到结果落地这一路上到底该用什么容器装帧、什么时候转、转几次、转的代价在哪里。视频流这个场景的特殊性在于数据量大、帧数多、还要按时间顺序处理任何一个环节多复制一次开销都会被帧数放大成灾难。我见过太多代码在list和ndarray之间来回倒腾每一趟都在悄悄复制几 GB 数据跑小样本完全看不出问题一上真机就原形毕露。这篇内容适合两类人看一类是刚接触视频处理、习惯用 list 存一切的新手看完能建立对内存布局的基本概念另一类是被性能问题折磨过、想搞清楚转换成本的熟手里面关于视图、dtype 膨胀、跨进程传参的内容应该能对上你的痛点。下文所有代码和参数都基于常见的视频解码库与 NumPy 生态换成别的技术栈思路同样成立。1. 视频帧在内存里长什么样list 的指针数组与 ndarray 的连续块1.1 一个 ndarray 对象除了数据还背了什么很多人以为np.array就是一块内存其实一个 ndarray 对象本身是一个结构体里面有指向数据缓冲区的指针、数据类型 dtype、形状 shape、步长 strides、维度数 ndim还有若干标志位。这个对象头的开销在几百字节量级跟它指向的数据比起来微不足道。真正的重点在于数据缓冲区是一整块连续内存且所有元素类型一致、大小固定。这个设计带来的直接结果就是数组里的第 i 个元素地址可以直接算出来基址加上 i 乘以元素字节数。CPU 预取数据的时候可以顺着地址一路读下去缓存命中率高向量化指令也用得上。视频帧作为一张 H×W×C 的图像天生就是一个规整的多维数组这正是 ndarray 擅长的事情。1.2 list.append 一帧到底发生了什么list 的底层是一个指针数组每个槽位存的是一个 PyObject 指针八字节。当你frames.append(frame)时真正被放进去的不是那六兆图像数据而是指向那个 ndarray 对象的指针。所以从 list 本身看一千帧也就八 KB 的指针开销完全不是问题。问题出在被指向的那些对象上。每一帧都是一个独立的 ndarray各自带一块独立分配的数据缓冲区散落在堆内存的各个角落。一千帧就是一千次独立分配一千个对象头一千块不连续的内存区域。当你想把这一千帧当成一个整体做运算的时候麻烦就来了它们不连续形状不统一成一个数组任何整体操作都必须靠 Python 层的循环去逐个访问。1.3 为什么视频流把这个差异放大了静态图像处理一次就一张怎么存都无所谓。视频流的可怕之处在于重复次数。假设你的处理流程需要遍历所有帧做一次归一化用 Python for 循环逐帧调用 NumPy 运算每次调用都有解释器开销、参数检查开销、临时对象分配开销单帧可能几十微秒看着不多一万帧就是零点几秒再加上来回创建中间数组内存带宽被反复占用真实耗时往往是纯向量化版本的几倍到十几倍。我在一台普通笔记本上做过对照实验处理 500 张 720p 的帧做均值归一化纯 Python 循环逐帧算大约 1.8 秒一次性 stack 成(500, 720, 1280, 3)的数组后用广播做同样的事耗时 0.15 秒左右。差距的根源就是 ndarray 的连续性让底层可以走优化过的循环而 list 只能一个一个喂。2. 帧缓冲区的四种写法内存账要提前算2.1 全量 append最省事也最容易翻车frames [] while True: ok, frame cap.read() if not ok: break frames.append(frame)这段代码几乎是所有教程的标准开头也是内存问题的头号来源。它的特点是完全不需要预先知道总帧数写起来最自然。代价是内存曲线随帧数线性上升而且每一帧独立分配碎片化严重。处理本地小文件没问题处理长视频或者无上限的实时流必须有截断机制。我做过的粗算1080p 彩色帧约 6.2MB如果预分配成一个大数组一万帧需要大约 62GB任何消费级机器都扛不住。所以全量缓存这个方案本质上只适合短视频片段或者抽帧后的关键帧集合。2.2 预分配 ndarray一次到位import numpy as np buf np.empty((max_frames, h, w, 3), dtypenp.uint8) count 0 while count max_frames: ok, frame cap.read() if not ok: break buf[count] frame count 1 buf buf[:count]用np.empty一次申请好整块内存之后每一帧通过索引赋值写进去。这样做的关键收益是内存连续、零碎片、后续可以直接切片成批送进算法。buf[count] frame这一步会做一次数据拷贝从解码器的缓冲区复制到你自己的大数组里这是必要的拷贝因为解码器下帧可能复用它的内部缓冲。最后buf[:count]是一个视图不复制数据只是换了形状描述。有个细节要注意np.empty不会初始化数据如果你的实际帧数小于 max_frames切片截断之后不会残留垃圾这一点是安全的。但如果直接拿整个 buf 去做运算末尾那些未初始化的区域会污染结果所以切片截断这一步不能省。2.3 deque 定长窗口只要最近 N 帧from collections import deque window deque(maxlen30) while True: ok, frame cap.read() if not ok: break window.append(frame)deque带maxlen时会自动弹出最旧的元素天然适合做时间窗口类的处理比如运动检测、光流估计、滑动平均。它和 list 一样存的是对象引用所以内存占用取决于窗口长度乘以单帧大小可控。deque的追加和弹出都是 O(1)比 list 的pop(0)高效得多。我见过有人在 list 上用pop(0)维护窗口帧数一多性能就塌了因为pop(0)要把后面所有元素前移一位复杂度是 O(n)。2.4 四种写法对照表方案内存可控性连续性适用场景主要风险list 全量 append差随帧数线性涨不连续短片段、抽帧结果长视频直接爆内存预分配 ndarray好上限明确连续批量处理、模型推理需预先知道尺寸和上限deque 定长窗口好窗口固定不连续实时滑窗、流式检测不适合全量回溯内存映射文件最好走磁盘连续超长视频、离线批处理IO 速度成瓶颈选择的核心依据是你到底需不需要回头看。需要保留全部帧做批处理就预分配只关心最近若干帧就 deque帧数超出内存上限就走内存映射。3. numpy 转换的三个入口array、asarray、stack 的代价差在哪3.1 np.array 的默认拷贝行为np.array(list_of_frames)会把 list 里所有帧的数据复制到一块新的连续内存里。这个行为是保险的因为新数组和原始对象完全解耦改原始帧不会影响新数组。代价是峰值内存翻倍转换过程中原 list 里的每帧还在新数组又占一份瞬时占用是两倍单份大小。一万帧的 1080p 数据如果中途做一次全量转换峰值就是一百多 GB机器当场就跪。所以在大数据量场景下转换时机比转换方式更值得琢磨。我通常的策略是分块转换每次从 list 里取 32 帧或 64 帧转成数组送进算法处理完就释放峰值内存只有一份批量大小而不是全量。3.2 np.asarray 的省事与陷阱arr np.asarray(maybe_array)asarray的语义是如果已经是 ndarray 且 dtype 匹配就直接返回原对象不复制。这个特性在函数入口做类型统一时非常好用省掉了一次无谓拷贝。但它也埋了坑如果调用方以为拿到的是副本在函数内部改了数组内容实际上改的是调用方传进来的原始数据。我就遇到过预处理函数内部用asarray之后做了原地归一化结果把上游缓存的历史帧全改坏了下游拿到的帧数值全是 0 到 1 之间的浮点而它们本该是 0 到 255 的整数。这种 bug 排查起来极其费劲因为数据本身不报错只是结果莫名其妙地不对。判断原则很简单只读场景用asarray需要修改的场景明确用np.array(..., copyTrue)让拷贝这件事写在代码里不要靠记忆。3.3 np.stack 和 np.concatenate 选哪个np.stack(list_of_frames)沿新轴堆叠把 N 个(H,W,C)变成(N,H,W,C)这是视频批处理最常用的形式。np.concatenate(list_of_frames, axis0)沿已有轴拼接把 N 个(H,W,C)变成(N*H, W, C)这是把多帧拼成一张长图的形式。这两个千万别搞混。我见过把视频帧用concatenate拼完之后直接喂给期待四维输入的模型形状对不上报错然后有人手动加了一维结果语义完全乱了模型看到的是被拉长的一张图不是 N 帧。还有np.vstack对二维数据是拼接对三维数据相当于沿 axis 0 拼容易和 stack 混淆但结果不同。视频场景下我基本只用stack语义最清晰。3.4 转换前后的连续性检查arr np.stack(frames) print(arr.flags[C_CONTIGUOUS]) print(arr.strides)stack产出的数组默认是 C 连续行优先对绝大多数基于 C 或 C 的底层库都友好。但如果中间经过了转置、切片、步长变换连续性可能被破坏。某些底层库在遇到不连续数组时会静默地先复制一份你以为没拷贝实际上底层偷偷做了性能莫名其妙地慢一截还查不出原因。养成习惯在把数组交给外部库之前看一眼flags。不确定就用np.ascontiguousarray(arr)显式整理一次这个函数在已经连续的情况下不做任何复制是零成本的保险。4. 流水线分工读帧用 list、算帧用 array、回写再回 list4.1 读帧阶段的容器选择读帧阶段其实没必要立刻转 ndarray 大数组。解码器每读一帧就产出一个独立数组你如果每读一帧就拼接到一个大数组上每次拼接都要重新分配并复制全部已有数据这就是典型的 O(n²) 灾难。我见过有人这么写all_frames np.empty((0, h, w, 3), dtypenp.uint8) while True: ok, frame cap.read() if not ok: break all_frames np.concatenate([all_frames, frame[None]], axis0)这段代码在小样本上跑得动帧数一上来就基本停摆因为每次concatenate都复制了之前所有帧。正确的做法是先append到 list攒够一批再一次性 stack或者直接预分配大数组按下标写。list 在这里的角色是低成本的临时待办队列它的追加是摊还 O(1)不会有复制放大。4.2 预处理必须向量化批量的 ndarray 拿到手之后所有逐帧才能做的事都应该改写成对整批的操作。比如归一化逐帧写是for i in range(n): batch[i] (batch[i].astype(np.float32) - mean) / std整批写是batch_f batch.astype(np.float32) batch_f - mean batch_f / std后者的好处不只是快还在于中间结果的分配次数更少。注意astype一定会复制一份新数组这是把 uint8 转 float32 的必要步骤。紧接着的两个就地运算-和/复用了同一块内存没有额外分配。如果写成batch_f (batch_f - mean) / std中间会先产生一个临时数组再产生第二个峰值内存多出一份。对一万帧的批次来说这一份就是几百兆到几个 G。4.3 结果回写与帧序对齐算法处理完之后结果可能是新的形状比如每帧输出一个检测框列表或者降采样之后的图像。这时候如果结果需要按原始顺序和时间戳对齐就要留意索引的对应关系。批处理常见的坑是最后一批不满补齐了 padding结果数组长度大于实际帧数回写时索引越界或者错位。我的习惯是每一批都记录(start_index, actual_count)回写时只取前actual_count条绝不依赖批大小整除这种假设。这个习惯帮我避免过一次很隐蔽的错位 bug视频行人在跨批次的时间点连续移动但因为一批里补了零后面所有帧的检测结果都偏了两帧肉眼几乎看不出来只有精确比对时间戳才发现。5. 滑窗与环形缓冲deque 配 ndarray 的实操细节5.1 用 deque 维护定长窗口from collections import deque import numpy as np WIN 16 window deque(maxlenWIN) prev None while True: ok, frame cap.read() if not ok: break gray frame[:, :, 1] # 取一个通道做演示 window.append(gray) if len(window) WIN: continue diff np.stack(window).astype(np.int16) - prev if prev is not None else None prev np.stack(window).astype(np.int16)这段代码里有两个值得说的地方。第一window里存的是切片得到的灰度通道切片是视图视图持有原帧的引用这意味着窗口里 16 个元素全都指向 16 个完整的彩色帧彩色帧的其他通道不会被释放。内存占用是窗口长度乘整帧大小不是窗口长度乘灰度图大小。如果灰度图只有整帧的三分之一你实际浪费了两倍的内存。解决办法是在 append 之前先copy()把切片变成独立数组。第二np.stack(window)每轮都重新堆叠一次整个窗口如果窗口是 16 帧每秒处理 25 帧堆叠操作就是每秒 25 次、每次复制 16 帧。这个开销不一定不能接受但如果能把堆叠频率降下来比如用环形索引的预分配数组性能会好很多。5.2 什么时候得到的是视图判断切片是视图还是拷贝看两个因素基础切片纯索引和冒号通常是视图花式索引布尔数组、整数数组一定是拷贝。arr[:, :, 1]是视图arr[mask]是拷贝arr[2]对三维数组来说也是视图。视图本身很省内存因为它不复制数据。但视图的生命周期会让底层的整块内存无法释放——只要还有一个视图活着它的 base 数组就不能被回收。这就是所谓的切片泄漏后面第六节会详细讲。5.3 滑窗求均值的两种写法对比同样是求最近 16 帧的均值写法 A 每轮stack再mean写法 B 维护一个累积和追加时加、弹出时减写法每轮复杂度内存分配数值精度每轮 stack meanO(W·H·W·C)每轮新分配高累积和增减O(H·W·C)无新分配有累积误差累积和版本快得多但代价是浮点误差会累积运行几十分钟后可能偏差肉眼可见。视频帧的像素值范围有限用 float64 做累积可以把误差压到可忽略但内存翻倍。这是一个典型的空间换精度问题没有普适答案要看你的任务对数值偏差容忍到什么程度。我做长时间的亮度统计时用的是分段重算每累积 300 帧重新从头算一次基准值把误差清零。6. 四个真实踩坑视图引用、dtype 膨胀、拷贝链、跨进程传参6.1 切片视图拖住整个大数组这是最隐蔽的一个。构造场景你从一批帧的大数组里切出若干小块存进 list准备后续处理。big np.empty((1000, 1080, 1920, 3), dtypenp.uint8) patches [] for i in range(100): patches.append(big[i, 400:600, 800:1000, :])看起来patches里只有 100 个小块每块 200×200×3 才 120KB总共 12MB。实际上每个小块都是视图都持有big的引用只要patches还活着整个 1000 帧的大数组就释放不掉。你以为是 12MB实际是 6GB。排查方法很直接patch.base is not None且patch.base.nbytes远大于patch.nbytes基本就是这个问题。修复只需要在切完立刻copy()。patches.append(big[i, 400:600, 800:1000, :].copy())一行copy()换来的是内存能正常释放。我现在的习惯是只要切出来的东西的生命周期可能长于原数组就无条件copy()不省这一步。6.2 uint8 到 float32 的四倍膨胀帧的原始 dtype 通常是 uint8一个像素一个字节。做归一化或者送进模型时转成 float32一个像素四个字节内存直接四倍。一个 1080p 的彩色帧从 6.2MB 变成 25MB一万帧从 62GB 变成 248GB。这个膨胀不是理论问题是很多代码在流水线里卡死的直接原因。缓解思路有几种一是分块处理永远不要把全量帧同时转成浮点二是用 float16内存减半精度对大多数视觉任务够用但要确认底层库支持三是把归一化挪到模型内部让数据保持 uint8 一路传到最后一步再转。第三种做法现在挺流行模型图里自带归一化层端到端省掉一次全量浮点转换。6.3 拷贝链路上无意的多次复制一条流水线上可能有这么几跳解码器缓冲 → list 元素 → stack 成批 → astype 转浮点 → 传给算法内部再复制一次。每一跳都是一次全量拷贝一万帧的批数据在每一跳都是几百兆到几个 G。峰值内存和总带宽开销都会被放大。我的做法是在关键节点打日志记录数组的id、nbytes、base状态画出数据到底被复制了几次。很多次优化就是靠这个笨办法找到的明明逻辑上只有一次转换实际跑了四次拷贝把其中两次多余的去掉耗时直接减半。不要靠猜要靠日志和np.shares_memory之类的工具去验证你的假设。6.4 多进程传帧的序列化成本把帧数组切成几份分给多个进程处理看起来是并行的好办法但要注意进程间传数据要序列化。一个 1080p 的批数组跨进程传递序列化和反序列化的开销可能比计算本身还大尤其是小批量高频传递的场景。我试过把批大小从 8 调到 64总耗时反而下降因为固定开销被摊薄了。如果数据量确实大可以考虑共享内存方案多个进程直接读同一块内存区省掉序列化。代价是要自己管同步和生命周期写起来没那么轻松。我的经验是批大小先调优序列化开销能接受就用普通传参实在不行再上共享内存不要一上来就选最复杂的方案。还有一点容易忽略子进程里如果对数组做了原地修改主进程能不能看到取决于你用的是共享内存还是普通传参。普通传参是拷贝主进程看不到修改这个行为很多人会想当然地搞反。最后分享一个我自己一直在用的小习惯在流水线的每个阶段入口打印一行shape、dtype、nbytes、flags[C_CONTIGUOUS]和base is not None就这五项。听起来很土但它能在几秒内告诉你数据有没有被意外转成浮点、有没有变成非连续、有没有被视图挂住。这个习惯帮我省掉的排查时间比任何调试工具都多。