6.5. 形状与步幅

ndarray 内部的数据是一整块紧凑排列的数字。位于这块数据前面的描述符决定了如何把这一平坦的数据块读取为一个张量。

6.5.1. 描述符记录了什么

有五个值描述了如何将数据块读取为张量:

a = np.array([[1, 2, 3], [4, 5, 6]], dtype=np.uint8)

a.ndim       # 2     - number of dimensions
a.shape      # (2, 3)- length along each dimension
a.itemsize   # 1     - bytes per element (from dtype)
a.size       # 6     - total number of elements
a.strides    # (3, 1)- step pattern through the buffer

ndinfo() 辅助函数会在一次调用中打印出所有这些值,再加上底层缓冲区的地址。两个缓冲区地址相同的数组是在共享内存:

np.ndinfo(a)
# class: ndarray
# shape: (2, 3)
# strides: (3, 1)
# itemsize: 1
# data pointer: 0x...
# type: uint8

6.5.2. 步幅详解

步幅是指在数据块中沿给定轴移动一个元素需要跨越多少字节。对于上面那个 2x3 的 uint8 数组,步幅为 (3, 1):向下移动一行跳过 3 字节,向右移动一列跳过 1 字节。这等同于说各行是首尾相接、从左到右存储的:

memory: [ 1 ][ 2 ][ 3 ][ 4 ][ 5 ][ 6 ]
          ^ row 0          ^ row 1
          <------- 3 bytes ---->

为了读取 a[i, j]numpy 从数据块起始处计算 i * strides[0] + j * strides[1],并从那里读取 itemsize 个字节。同样的公式可以扩展到任意维数。

这种布局——各行首尾相接存储、最后一个轴沿内存变化最快——称为行优先(row-major)顺序。numpy 在摄像头上分配的每个数组都使用这种布局。

6.5.3. 行优先带来的影响

“各行首尾相接存储”引出了两点结论,它们在摄像头上塑造缓冲区时很重要。

最后一个轴是连续的。a[0, 0] 走到 a[0, 1] 只触及相邻的下一个字节。从 a[0, 0] 走到 a[1, 0] 则会跨越一整行。

最后一个轴是整数组运算的快轴。 摄像头上的 numpy 始终在最内层遍历最后一个轴,无论哪个轴恰好更长。桌面版 numpy 库会悄悄地重排其循环,把最长的轴放到最内层;而摄像头不会,因此一个在桌面版 numpy 上会被掩盖过去的布局选择,在这里仍会耗费时间。np.sum(m, axis=1) 折叠最后一个轴,并沿连续方向运行;np.sum(m, axis=0) 则不然。当应用程序可以自行决定如何布置缓冲区时,应把长轴放在最后,这样沿它进行的运算就能保持在内层循环中。

如果布局一开始就不对,transpose()(或 .T 简写)可以在不复制数据的情况下修正它——它只是交换步幅:

a = b.T            # now iterates fast

性能 中有完整的性能讨论。

6.5.4. 重塑、转置、切片——描述符的编辑

任何只重写描述符的操作都是免费的。reshape 在同一个数据块上换上新的 shapestridestranspose 反转步幅。a[::2] 将某个步幅翻倍。它们各自返回同一底层缓冲区的一个视图

任何必须遍历数据并写入新缓冲区的操作都是复制。目前的规则是:描述符的编辑是免费的,数据遍历则不是。

6.5.5. 关于 ndim 的说明

摄像头上的 numpy 构建时支持的最大 ndim 为 4。会产生更高维数组的操作会引发 ValueError。摄像头端绝大多数工作都是一维或二维的,所以这个限制很少成为问题。