有一种令人难以置信的技术,只需两个内存副本就可以将 Pillow 图像转换为 NumPy 数组!等等,“只需两个内存副本”是什么意思?是不是可以在只复制一次内存或根本不复制内存的情况下在库之间转换数据?这似乎不可思议,但是更传统的图像转换方法工作速度慢1.5-2.5倍(如果你需要一个可变对象)。今天,我将深入研究这两个库并告诉你为什么会发生这种情况。此外,我将向你展示一种获得相同结果但速度更快的方法。不会有任何存储库或包,只有事实和最后的工作代码。第一件事:关键概念Pillow是一个 Python 图像库。它支持不同的格式,提供延迟加载,并允许从文件访问元数据。长话短说,它可以完成你加载/保存图像所需的一切。NumPy是一个 Python 库,用于处理多维数组。它是一系列科学、计算机视觉和机器学习库(如 SciPy、Pandas、Astropy 等)的基础库。https://numpy.org/OpenCV是最流行的计算机视觉库,具有广泛的功能。它没有自己的图像内部存储格式,而是使用 NumPy 数组。使用这个库的常见场景是当你需要将图像从 Pillow 转换为 NumPy 以便你可以使用 OpenCV 使用它时。https://opencv.org/今天我将在 64 位操作系统下的 Raspberry Pi 4 1800 MHz 上运行基准测试。毕竟,如果不是在 Raspberry 上,你还有什么地方需要计算机视觉?NumPy 转换的工作原理以下是将 Pillow 图像转换为 NumPy 的两种最常见方法。如果你谷歌一下,你可能会找到其中之一:numpy.array(im) — 从图像复制到 NumPy 数组。numpy.asarray(im) — 与numpy.array(im, copy=False) 相同。据说,它不会复制,而是使用原始对象的内存。但比那复杂一点。有人会认为在第二种情况下,NumPy 数组变成了原始图像的一种表示,如果更改 NumPy 数组,图像也会发生变化。事实上,事实并非如此:In [1]: from PIL import Image
In [2]: import numpy
In [3]: im = Image.open('./canyon.jpg').resize((4096, 4096))
In [4]: n = numpy.asarray(im)
In [5]: n[:, :, 0] = 255
ValueError: assignment destination is read-only
In [6]: n.flags
Out[6]:
C_CONTIGUOUS : True
F_CONTIGUOUS : False
OWNDATA : False
WRITEABLE : False
ALIGNED : True
WRITEBACKIFCOPY : False
UPDATEIFCOPY : False
如果你使用numpy.array()函数,这与你得到的结果大不相同:In [7]: n = numpy.array(im)
In [8]: n[:, :, 0] = 255
In [9]: n.flags Out[9]:
C_CONTIGUOUS : True
F_CONTIGUOUS : False
OWNDATA : True
WRITEABLE : True
ALIGNED : True
WRITEBACKIFCOPY : False
UPDATEIFCOPY : False
但是,asarray()函数的运行速度要快得多:In [10]: %timeit -n 10 n = numpy.array(im) 257 ms ± 1.27 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
In [11]: %timeit -n 10 n = numpy.asarray(im) 179 ms ± 786 ?s per loop (mean ± std. dev. of 7 runs, 10 loops each)
内存副本较少,但有代价:你无法更改数组。尽管如此,与单拷贝相比,转换时间仍然非常长。让我们弄清楚为什么会这样。NumPy 数组接口如果你查看 Pillow 背后的依赖项和代码,你将找不到任何提及 NumPy 的内容。(嗯,你会的,但只在评论中。) NumPy 也是如此。那么图像如何从一种格式转换为另一种格式呢?事实证明 NumPy 有一个特殊的接口。参考此接口的说明:https://numpy.org/doc/stable/reference/arrays.interface.html你为对象创建一个特殊的属性,你可以在其中向 NumPy 解释它应该如何检索数据,并以这种方式检索数据。
def __array_interface__(self):
shape, typestr = _conv_type_shape(self)
return {
"shape": shape,
"typestr": typestr,
"version": 3,
"data": self.tobytes(),
}
_conv_type_shape()描述了应该获取的数组的类型和大小。但最有趣的事情发生在tobytes()方法中。如果你检查此方法执行的时间,很明显,通常情况下,NumPy不会从自身添加任何内容:In [12]: %timeit -n 10 n = im.tobytes() 179 ms ± 1.27 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
时间与asarray()函数的时间完全相同。好吧,看来我们刚刚找到了罪魁祸首。唯一剩下的就是改变函数调用或加速它,对吧?但事情没那么简单。Pillow 和 NumPy 中的内存组织NumPy 中的内存模型非常简单:数组被视为从某个位置开始的连续内存块。此外,还有为每个维度单独设置偏移量的步幅。在 Pillow 中,这是另一回事。图像存储在多个块中,每个块包含多个图像行。每个像素占用 1 或 4 个字节(不是从 1 到 4,正好是 1 或 4)。这意味着某些字节不用于某些颜色模式。例如,对于 RGB,不使用每个像素中的最后一个字节。对于具有 Alpha 通道(LA 模式)的黑白图像,不使用中间两个字节,因此 Alpha 通道位于像素的最后一个字节。我告诉你这个是因为我不希望你有一种错觉,即可以在不重写其中一个库的情况下完全解决这个问题。现在你明白为什么我们需要tobytes()方法了:它将 Pillow 图像的内部表示转换为无遗漏的连续字节流,而这正是 NumPy 可以使用的。获取字节对象后,NumPy 可以进行复制或以只读模式使用它。我不确定这样做是否可以避免 Python 中的对象不变性,或者 C API 级别是否存在一些真正的限制。无论哪种方式,例如,如果输入对象是bytearray而不是bytes,则数组将不是只读的。但是让我们看一下**tobytes()**的简化版本:def tobytes(self):
self.load()
# unpack data
e = Image._getencoder(self.mode, "raw", self.mode)
e.setimage(self.im)
data, bufsize, s = [], 65536, 0
while not s:
l, s, d = e.encode(bufsize)
data.append(d)
if s < 0:
raise RuntimeError(f"encoder error {s} in tobytes") return b"".join(data)
在这里我们可以看到“原始”编码器已经创建,它产生了至少 65 KB 内存的图像块。这是第一个内存副本:在函数结束时,我们在数据数组中将整个图像分成小块。最后一行是第二个内存副本:所有的块都被收集到一个大字节串中。那么,谁是罪魁祸首,如何处理?重要的是要记住,库是以这样一种方式编写的,即有一个接口,但彼此之间没有明确的用途。我相信在这种情况下,这几乎是最佳解决方案。但是,如果我们没有这样的限制,但想要获得最大可能的速度怎么办?我想强调的是,我们不能放弃编码器:谁知道它对我们隐藏了哪些实现细节?将其全部转移到 Python 级别或用 C 重写它的一部分是最后的手段。在tobytes()中预先分配一个所需大小的缓冲区,然后向其写入块似乎更为合理。但是很明显编码器接口不是这样工作的:它已经返回了打包到bytes对象中的块。但是,如果你不存储所有这些块,而是立即将其复制到缓冲区,则数据不会从 L2 缓存中清除,并且会很快到达正确的位置。它看起来像这样:def to_mem(im):
im.load()
e = Image._getencoder(im.mode, "raw", im.mode)
e.setimage(im.im)
mem = ... # we don't know yet
bufsize, offset, s = 65536, 0, 0
while not s:
l, s, d = e.encode(bufsize)
mem[offset:offset + len(d)] = d
offset += len(d)
if s < 0:
raise RuntimeError(f"encoder error {s} in tobytes")
return mem
我们要什么来代替内存?理想情况下,它应该是一个 NumPy 数组。创建它不是问题;我们已经看到了它在**array_interface 中的**参数:In [13]: shape, typestr = Image._conv_type_shape(im)
In [14]: data = numpy.empty(shape, dtype=numpy.dtype(typestr))
但是,如果你尝试使用它的平面版本而不是mem,它将无法工作:In [15]: mem = data.reshape((data.size,))
In [16]: mem[0:4] = b'abcd' ValueError: invalid literal for int() with base 10: b'abcd'
在这种情况下,将字节放入字节数组似乎很奇怪。但请记住,首先,不仅字节可以位于左侧,其次,该库称为 NumPy,这意味着它可以处理数字。幸运的是,NumPy 使你可以直接从 Python 访问数组的直接内存。这是它的数据属性:In [17]: data.data
Out[17]: <memory at 0x7f78854d68>
In [18]: data.data[0] = 255
NotImplementedError: sub-views are not implemented
In [19]: data.data.shape
Out[19]: (4096, 4096, 3)
In [20]: data.data[0, 0, 0] = 255
有一个memoryview对象。但是这个内存视图很奇怪:它也是多维的,就像 NumPy 数组本身一样,并且它与数组本身具有相同的对象类型。幸运的是,使用cast方法很容易修复:In [21]: mem = data.data.cast('B', (data.data.nbytes,))
In [22]: mem.nbytes == mem.shape[0]
Out[22]: True
In [23]: mem[0], mem[1]
Out[23]: (255, 0)
In [24]: mem[0:4] = b'1234'
In [25]: mem[0], mem[1]
Out[25]: (49, 50)
现在让我们把它们放在一起:def to_numpy(im):
im.load()
# unpack data
e = Image._getencoder(im.mode, 'raw', im.mode)
e.setimage(im.im)
# NumPy buffer for the result
shape, typestr = Image._conv_type_shape(im)
data = numpy.empty(shape, dtype=numpy.dtype(typestr))
mem = data.data.cast('B', (data.data.nbytes,))
bufsize, s, offset = 65536, 0, 0
while not s:
l, s, d = e.encode(bufsize)
mem[offset:offset + len(d)] = d
offset += len(d)
if s < 0:
raise RuntimeError("encoder error %d in tobytes" % s)
return data
并检查:In [26]: n = to_numpy(im)
In [27]: numpy.all(n == numpy.array(im))
Out[27]: True
In [28]: n.flags
Out[28]:
C_CONTIGUOUS : True
F_CONTIGUOUS : False
OWNDATA : True
WRITEABLE : True
ALIGNED : True
WRITEBACKIFCOPY : False
UPDATEIFCOPY : False
In [29]: %timeit -n 10 n = to_numpy(im) 101 ms ± 260 ?s per loop (mean ± std. dev. of 7 runs, 10 loops each)
使用相同的功能和更少的分配数量,速度提高了 2.5 倍。基准我为这次测试选择的图像非常大。这不是因为to_numpy对于较小的图像不能更快地工作。(确实如此!)问题是,一般而言,在内存分配方面很难实现任何类型的恒定运行时。分配器可以向系统请求新的内存或提供预分配的内存。它可以决定用零填充它或保持原样。从这个角度来看,处理大数组至少给了我们一个稳定的结果:我们总是得到最坏的情况。这是代码:In [30]: for i in range(6, 0, -1):
...: i = 128 * 2 ** i
...: print(f'Size: {i}x{i} {i*i // 1024} KPx')
...: im = Image.new('RGB', (i, i))
...: print(' numpy.array()')
...: %timeit n = numpy.array(im)
...: print(' numpy.asarray()')
...: %timeit n = numpy.asarray(im)
...: print(' to_numpy()')
...: %timeit n = to_numpy(im)
...: im = None
...:
结果:
结果,我们设法消除了不必要的内存分配,将过程从 1.5 倍加速到 2.5 倍,并在此过程中弄清楚 NumPy 如何处理内存。