6.1. 为何使用数组

Image 类是进行像素处理的合适工具,因为它上面的每个方法都在一次快速调用中直接操作摄像头的原生像素缓冲区。应用程序对一帧所做的大部分工作 —— 阈值处理、色块查找、AprilTag 检测、边缘滤波 —— 都已经存在于其中。

图像库 没有 暴露的,是 OpenMV 应用程序会遇到的其余数值计算工作:

  • 并非像素的传感器缓冲区 —— ADC 采样、来自 IMU(惯性测量单元)的各轴数据、麦克风音频,

  • 从图像中派生出、但没有任何内置方法返回的数字 —— 一个直方图列、两帧的自定义混合、目录未涵盖的逐像素变换,

  • 小型线性代数 —— 用于校正镜头的标定矩阵、用于融合 IMU 的旋转,

  • 信号处理数学 —— 振动缓冲区的频率成分、对传感器输出施加的平滑、分类器需要作为输入的特征向量。

所有这些都需要相同的形式:一个数字缓冲区,对其每个元素施加一个运算。用 Python for 循环来写显然是一种办法::

for i in range(len(samples)):
    samples[i] = samples[i] * cal

这个循环能用。它也很慢。Python 是一门解释型语言,Python 循环的每一次迭代都要承担运行一次解释器的代价:查找 samples、读取元素 i、相乘、写回、推进循环计数器、检查循环条件。在一千个传感器采样的缓冲区上,这些解释器开销累加起来,会让一个本质上很快的运算耗费数十毫秒。

每当脚本访问一个缓冲区时,这种开销都会咬一口。一个 QVGA 灰度帧有 76,800 个像素;100 Hz 的加速度计每秒输出一百个三轴采样;麦克风每 64 毫秒填满一个 1024 采样的缓冲区。对其中任何一个使用纯 Python for 循环,都会把一个本该耗费几微秒的工作变成耗费数十毫秒 —— 而在图像大小的缓冲区上还会再慢大约十倍。

6.1.1. 库函数比循环更快

解决办法是把该运算表达为针对整个缓冲区的一次函数调用,而不是对其元素进行 Python 循环。numpy 正是如此:一个数组数学库,其中每个运算都是一个已经优化过的、从头到尾遍历缓冲区一次的函数。np.multiply(samples, cal) 会在一次调用内把 samples 的每个元素乘以 cal —— 与循环所做的算术相同,但没有逐次迭代的解释器开销。同样的 1000 元素乘法,作为 Python 循环要耗费数十毫秒,作为 numpy 调用则只需数十微秒。

numpy 在各个方面提供的都是这笔交易:求和、求均值、sin、exp、矩阵乘法、信号处理原语 —— 每一个都是一个一次性对整个缓冲区进行操作的库函数。代价是数据必须存活于 numpy 的数组类型中,并且运算必须针对该数组来表达,而不是一次一个地针对其元素。

6.1.2. 为何列表不行

Python list 无法胜任。一个列表可以容纳任意混合的对象 —— 整数、浮点数、字符串、其他列表 —— 而读取它的库函数仍然必须查看每个槽位以弄清其中是什么,并在进行任何算术之前把值取出来。那种逐槽位的开销正是 Python 循环所付出的代价。列表并不适合快速的数组数学运算。

6.1.3. 为何 bytearray 也不够

bytearray 具有正确的 形式 —— 一个类型化缓冲区、每个元素一字节、全部在一个连续块中。它正是大多数面向字节的外设 API 所返回的东西。它所缺少的是 数学bytearray * 2 会重复该缓冲区,而不是把每个值翻倍,并且 bytearray + bytearray 逐元素地讲也没有合理的含义。

把类型化缓冲区与逐元素数学结合起来的数据结构就是 ndarray。盒子内部是什么,以及每个字段如何塑造其快速路径行为,正是本章其余部分所依托的基础。