4种在生产中扰乱计算机视觉模型的方法
简介
本文不是关于模型的质量。它甚至不涉及扩展、负载平衡和其他DevOp。它是关于一个更普遍但有时会遗漏的事情:处理不可预测的用户输入。
在训练模型时,数据科学家几乎总是有一个受控的数据环境。这意味着使用已经准备好的数据集,或者有时间和资源手动收集、合并、清理和检查数据。通过准确地执行此操作,可以提高基于这些数据训练的模型的质量。
假设模型足够好,可以部署到生产中。在最好的情况下,仍然可以控制环境(经典的服务器端部署)。但即便如此,用户也会从各种设备和来源上传图像。在边缘部署的情况下,还有一层复杂性:无法控制环境。
在这两种情况下,模型都应该立即响应,开发人员没有时间手动检查数据。因此,至关重要的是:
· 随时准备数据
· 尽可能接近训练时间进行预处理
否则,实际生产模型的质量可能会比预期的低很多。这种情况之所以发生,是因为人们在数据中引入了偏见,而这是模型所没有预料到的。
真实案例
在接下来的内容中,将介绍一家公司开发计算机视觉模型时遇到的4个问题。在将其传递给算法(主要是神经网络),以下部分都与图像处理有关:
· 存储在EXIF中的方向
· 非标准颜色配置文件
· 图像库中的差异
· 调整算法
对于每一个项目,都会提供一个案例研究和一段代码,在生产中解决这个问题。
存储在EXIF中的方向
如今,人们使用移动相机拍照(约91%,而且这个数字还在增长)。
通常情况下,移动设备会以预先确定的固定方向存储图像,而不管拍照时相机的实际位置是什么。恢复初始方向所需的旋转角度作为元信息存储在EXIF中。
在移动设备或现代桌面软件上观看此类图像可能没问题,因为它们可以处理这些EXIF信息。但是以编程方式加载图像时,许多库读取原始像素数据并忽略元信息。这会导致错误的图像方向。在下面的示例中,我使用了PIL,它是用于图像处理的最流行的Python库之一。
input_image = "data/cup.jpg"
image = PIL.Image.open(input_image)
np_image_initial = np.array(image)
向神经网络发送这样的图像可能会产生完全随机的结果。要解决这个问题,还需要读取EXIF信息,并相应地旋转/镜像图像。
# get image EXIF
image_exif = image.getexif()
# retrieve orientation byte
orientation = image_exif.get(0x0112)
print(orientation)
# 6
# get the corresponding transformation
method = {
2: PIL.Image.FLIP_LEFT_RIGHT,
3: PIL.Image.ROTATE_180,
4: PIL.Image.FLIP_TOP_BOTTOM,
5: PIL.Image.TRANSPOSE,
6: PIL.Image.ROTATE_270,
7: PIL.Image.TRANSVERSE,
8: PIL.Image.ROTATE_90,
}.get(orientation)
if method is not None:
# replace original orientation
image_exif[0x0112] = 1
# apply transformation
image = image.transpose(method)
np_image_correct = np.array(image)
PIL允许读取EXIF元信息并对其进行解析。它知道在哪里寻找方向。存储必要信息的字节是0x0112,并且文档说明了如何处理每个值,以及如何处理图像来恢复初始方向。
使用EXIF代码读取并正确旋转的杯子图像
这个问题似乎已经解决了,对吗?嗯,还没有。
让我们试试另一张照片。这一次,我将使用现代的HEIF图像格式,而不是旧的JPEG(请注意,需要一个特殊的PIL插件)。默认情况下,iPhone以这种格式拍摄HDR图片。
图中显示了站在门口的女孩的图像及其元数据
一切看起来都一样。让我们试着用一种天真的方式从代码中读取它,而不看EXIF。
input_image = "data/person.heic"
image = PIL.Image.open(input_image)
np_image_initial = np.array(image)
图像:
原始图像方向正确!但EXIF建议将其逆时针旋转90度!所以方位恢复代码会失败。
这是一个已知的问题,iPhone在HEIC拍摄时,由于某些不清楚的原因,iPhone存储的原始像素是正确的,但在以这种格式拍照时,仍然在EXIF中保持传感器方向。
因此,当图像在iPhone上以HEIC格式拍摄时,应该修正算法并省略旋转。
# get image EXIF
image_exif = image.getexif()
# get image format
image_format = image.format
# retrieve orientation byte
orientation = image_exif.get(0x0112)
print(orientation)
# 6
print(image_format)
# HEIF
# get the corresponding transformation
method = {
2: PIL.Image.FLIP_LEFT_RIGHT,
3: PIL.Image.ROTATE_180,
4: PIL.Image.FLIP_TOP_BOTTOM,
5: PIL.Image.TRANSPOSE,
6: PIL.Image.ROTATE_270,
7: PIL.Image.TRANSVERSE,
8: PIL.Image.ROTATE_90,
}.get(orientation)
if method is not None:
# replace original orientation
image_exif[0x0112] = 1
# apply transformation if not HEIF
if image_format != "HEIF":
image = image.transpmemoryviewe(method)
np_image_correct = np.array(image)
当然,来自其他设备的图像定向可能会有更多问题,而这段代码可能并不详尽。但它清楚地表明了一个人应对用户输入的谨慎程度,以及即使没有恶意意图,用户输入的不可预测性。
非标准颜色配置文件
元信息隐藏了另一个挑战。图片可能会被拍摄并存储在不同的颜色空间和颜色配置文件中。
有了颜色空间,它或多或少是清晰的。它定义了用于存储每个像素的图像颜色信息的通道。最常见的两种颜色空间是RGB(红-绿-蓝)和CMYK(青-品红-黄-黑)。人们几乎不会弄错CMYK中的图像,因为它们有不同数量的通道:RGB是3。
因此,由于输入通道数量错误,将其发送到网络会立即中断。所以这种从CMYK到RGB的转换很少被忘记。
颜色配置文件要复杂得多。这是输入(摄像头)或输出(显示)设备的特征。它说明了特定于设备的记录或显示颜色的方式。
RGB颜色空间有很多不同的配置文件:sRGB、Adobe RGB、Display P3等。每个配置文件定义了自己的方式,将原始RGB值映射到人眼感知的真实颜色。
这导致了一个事实,即相同的原始RGB值可能意味着不同图片中的不同颜色。要解决这个问题,需要仔细地将所有图像的颜色配置文件转换为一个选定的标准。
通常,它是一个sRGB配置文件,因为由于历史原因,它是所有网站的默认颜色配置文件。
在iPhone上拍摄的照片通常有一个显示P3颜色的配置文件。让我们从代码中读取图像,看看它在进行颜色配置文件转换和不进行颜色配置文件转换时是什么样子。
input_image = "data/city.heic"
image_initial = PIL.Image.open(input_image)
# open default ICC profile which is sRGB
working_icc_profile = PIL.ImageCms.getOpenProfile(
"data/SRGB.icc"
)
# read ICC profile from the image
image_icc_profile = PIL.ImageCms.getOpenProfile(
io.BytesIO(image_initial.info["icc_profile"])
)
# convert an image to default ICC profile
image_converted = PIL.ImageCms.profileToProfile(
im=image_initial,
inputProfile=image_icc_profile,
outputProfile=working_icc_profile,
renderingIntent=PIL.ImageCms.INTENT_PERCEPTUAL,
outputMode="RGB"
)
image_initial_np = np.array(image_initial)
image_converted_np = np.array(image_converted)
可以看到,通过转换读取的图像更生动,更接近原始图像。根据图片和颜色配置,这种差异可能更大或更小。
发送到神经网络的颜色(即像素值)可能会影响最终质量并破坏预测。因此,恰当地与他们合作至关重要。
图像库中的差异
正确读取图像是非常重要的。但与此同时,我们应该尽快做到这一点。因为在云计算时代,时间就是金钱。此外,客户不想等待太久。
这里的最终解决方案是切换到C/C++。有时,在产生式推理环境中,它可能完全有意义。但如果你想留在Python生态系统中,有很多选择。每个图像库都有自己的功能和速度。
直到现在,我只使用了PIL模块。为了进行比较,我选择了另外两个流行的库:OpenCV和Scikit image。
让我们看看每个库读取不同大小的JPEG图像的速度。
def read_cv2(file):
image_cv2 = cv2.cvtColor(
cv2.imread(
file,
cv2.IMREAD_UNCHANGED
),
cv2.COLOR_BGR2RGB
)
return image_cv2
def read_pil(file):
image_pil = PIL.Image.open(file)
_ = np.array(image_pil)
return image_pil
def read_skimage(file):
image_skimage = skimage.io.imread(file)
return image_skimage
num_repeats = 10
# read 37 JPEG images of different sizes and measure time
times_read_cv2 = measure_read_times(read_cv2, num_repeats)
times_read_pil = measure_read_times(read_pil, num_repeats)
times_read_skimage = measure_read_times(read_skimage, num_repeats)
对于小图像,几乎没有区别。但对于大型的,OpenCV的速度大约是PIL和Scikit图像的1.5倍。根据图像内容和格式(JPEG、PNG等),这种差异可能从1.4x到2.0x不等。但总的来说,OpenCV要快得多。
网络上还有其他可靠的基准给出了大致相同的数字。对于图像书写来说,这种差异可能更为显著:OpenCV的速度要快4到10倍。
另一个非常常见的操作是调整大小。人们几乎总是在将图像发送到神经网络之前调整图像的大小。这就是OpenCV真正闪耀的地方。
times_resize_cv2 = measure_time(
f=lambda: cv2.resize(image_cv2, (1000, 1000), interpolation=cv2.INTER_LINEAR),
num=20,
)
times_resize_pil = measure_time(
f=lambda: image_pil.resize((1000, 1000), resample=PIL.Image.LINEAR),
num=20,
)
times_resize_skimage = measure_time(
f=lambda: skimage.transform.resize(image_skimage, (1000, 1000)),
num=20,
)
print(f"cv2: {np.mean(times_resize_cv2):.3f}s")
print(f"pil: {np.mean(times_resize_pil):.3f}s")
print(f"skimage: {np.mean(times_resize_skimage):.3f}s")
# cv2: 0.006s
# pil: 0.135s
# skimage: 4.531s
在这里,我拍摄了一张7360x4100的图像,并将其调整为1000x1000。OpenCV比PIL快22倍,比Scikit image快755倍!
选择正确的库可以节省大量时间。需要注意的是,相同的调整大小算法可能会在不同的实现中产生不同的结果:
image_cv2_resized = cv2.resize(
image_cv2,
(1000, 1000),
interpolation=cv2.INTER_LINEAR
)
image_pil_resized = image_pil.resize(
(1000, 1000),
resample=PIL.Image.LINEAR
)
print(
"Original images are the same: ",
(image_cv2 == np.array(image_pil)).all()
)
print(
"Resized images are the same: ",
(image_cv2_resized == np.array(image_pil_resized)).all()
)
mae = np.abs(image_cv2_resized.astype(int) -
np.array(image_pil_resized, dtype=int)).mean()
print(
"Mean Absolute Difference between resized images: ",
f"{mae:.2f}"
)
# Original images are the same: True
# Resized images are the same: False
# Mean Absolute Difference between resized images: 5.37
在这里,人们可以注意到,我对OpenCV和PIL都使用线性插值进行下采样。原始图像是相同的。但结果不同。差别非常显著:每种像素颜色平均255个像素中有5个不同。
因此,如果在训练和推理过程中使用不同的库来调整大小,可能会影响模型的质量。所以我们应该密切关注它。
调整算法
除了不同库之间的速度差异外,即使在一个库中,也有不同的调整大小算法。我们应该选择哪一个?至少,这取决于是否要减小图像大小(下采样)或增加图像大小(上采样)。
有许多调整图像大小的算法。它们产生的图像质量和速度不同。我只看这5个,它们足够好、足够快,并且在主流库中得到支持。
下面提供的结果也与一些指南和示例一致。
image = cv2.cvtColor(
cv2.imread(
input_image,
cv2.IMREAD_UNCHANGED
),
cv2.COLOR_BGR2RGB
)
# define list of algorithms
algos = {
"AREA": cv2.INTER_AREA,
"NEAREST": cv2.INTER_NEAREST,
"LINEAR": cv2.INTER_LINEAR,
"CUBIC": cv2.INTER_CUBIC,
"LANCZOS": cv2.INTER_LANCZOS4,
}
# draw original image
plt.imshow(image[1500:2500, 5000:6000])
for algo in algos:
# downsample the original image with selected algorithm
image_resized = cv2.resize(image, (736, 410), interpolation=algos[algo])
# draw results
plt.imshow(image_resized[150:250, 500:600])
对于下采样,“area”算法看起来最好。它产生的噪音和伪影最少。事实上,它是OpenCV进行下采样的首选。
现在让我们进行上采样——将采样后的图像恢复到原始大小,看看在这种情况下哪种算法最有效。
# create downsampled image with preferred "area" algorithm
image_downsampled = cv2.resize(image, (736, 410), interpolation=cv2.INTER_AREA)
# draw original image
plt.imshow(image[1500:2500, 5000:6000])
for algo in algos:
# upsample the downsampled image back to the original size
# with the selected resizing algorithm
image_resized = cv2.resize(
image_downsampled,
(7360, 4100),
interpolation=algos[algo]
)
# draw results
plt.imshow(image_resized[1500:2500, 5000:6000])
对于上采样,算法会产生更一致的结果。尽管如此,“cubic”插值看起来模糊程度最低,最接近原始(“lanczos”提供了类似的结果,但速度要慢得多)。
所以这里的最终结论是使用“area”插值进行下采样,使用“cubic”算法进行上采样。
请注意,正确的调整算法选择在训练期间也很重要,因为它可以提高整体图像质量。更重要的是,训练和推理阶段的调整算法应该是相同的,否则模型可能会出问题。
结论
这篇文章,描述了在计算机视觉领域工作多年期间多次遇到的真实案例和问题。如果处理不当,它们中的每一个都可能会显著降低生产中的模型质量。
感谢阅读!