计算摄影中的 Python 图像处理简介

已发表: 2022-03-11

计算摄影是关于通过计算增强摄影过程。 虽然我们通常倾向于认为这仅适用于最终结果的后期处理(类似于照片编辑),但可能性要丰富得多,因为可以在摄影过程的每个步骤中启用计算——从场景照明开始,继续镜头,最终甚至在拍摄图像的显示上。

这很重要,因为与普通相机相比,它允许以不同的方式做更多的事情。 这也很重要,因为当今最流行的相机类型 - 移动相机 - 与其较大的兄弟相机(数码单反相机)相比并不是特别强大,但它通过利用设备上可用的计算能力设法做得很好.

我们将看两个计算可以增强摄影效果的例子——更准确地说,我们将看到如何简单地拍摄更多照片并使用一些 Python 将它们组合起来,可以在移动相机硬件无法提供的两种情况下创造出很好的结果真正闪耀——低光和高动态范围。

低光摄影

假设我们想拍摄场景的低光照片,但相机的光圈(镜头)小且曝光时间有限。 这是手机摄像头的典型情况,在低光照场景下,可能会产生这样的图像(使用 iPhone 6 摄像头拍摄):

弱光环境下的情侣玩具图片

如果我们尝试提高对比度,结果如下,这也是相当糟糕的:

与上面相同的图像,更亮,但有分散注意力的视觉噪音

发生什么了? 所有这些噪音是从哪里来的?

答案是噪音来自传感器——该设备试图确定光线何时照射到它以及光线的强度。 然而,在弱光下,它必须大大提高其灵敏度才能记录任何东西,而高灵敏度意味着它也开始检测误报——根本不存在的光子。 (作为旁注,这个问题不仅影响设备,还影响我们人类:下次你在一个黑暗的房间里时,花点时间注意你视野中存在的噪音。)

成像设备中总会存在一定量的噪声; 但是,如果信号(有用信息)具有高强度,则噪声将可以忽略不计(高信噪比)。 当信号较低时(例如在低光照条件下),噪声会突出(低信噪比)。

尽管如此,我们仍然可以克服噪点问题,即使有所有相机限制,以获得比上述更好的照片。

为此,我们需要考虑随着时间的推移会发生什么:信号将保持不变(相同的场景,我们假设它是静态的),而噪声将是完全随机的。 这意味着,如果我们对场景进行多次拍摄,它们将具有不同版本的噪点,但有用的信息相同。

因此,如果我们对一段时间内拍摄的许多图像进行平均,则噪声将被抵消,而信号将不受影响。

下图显示了一个简化示例:我们有一个受噪声影响的信号(三角形),我们尝试通过对受不同噪声影响的同一信号的多个实例进行平均来恢复该信号。

三角形的四面板演示,表示添加了噪声的三角形的分散图像,表示 50 个实例的平均值的锯齿状三角形,以及 1000 个实例的平均值,看起来与原始三角形几乎相同。

我们看到,尽管噪声强到足以在任何单个实例中完全扭曲信号,但平均逐渐降低了噪声,我们恢复了原始信号。

让我们看看这个原则是如何应用于图像的:首先,我们需要在相机允许的最大曝光下为主体拍摄多张照片。 为获得最佳效果,请使用允许手动拍摄的应用程序。 重要的是从同一位置拍摄照片,因此(临时)三脚架会有所帮助。

更多的照片通常意味着更好的质量,但确切的数量取决于情况:有多少光线,相机的灵敏度等。一个好的范围可以在 10 到 100 之间。

一旦我们有了这些图像(如果可能的话,以原始格式),我们可以在 Python 中读取和处理它们。

对于那些不熟悉 Python 中的图像处理的人,我们应该提一下,图像表示为字节值 (0-255) 的二维数组,即单色或灰度图像。 彩色图像可以被认为是一组三个这样的图像,一个用于每个颜色通道(R、G、B),或者实际上是一个由垂直位置、水平位置和颜色通道(0、1、2)索引的 3D 数组.

我们将使用两个库:NumPy (http://www.numpy.org/) 和 OpenCV (https://opencv.org/)。 第一个允许我们非常有效地对数组执行计算(代码非常短),而 OpenCV 在这种情况下处理图像文件的读/写,但功能更强大,提供了许多高级图形程序——其中一些我们将在文章后面使用。

 import os import numpy as np import cv2 folder = 'source_folder' # We get all the image files from the source folder files = list([os.path.join(folder, f) for f in os.listdir(folder)]) # We compute the average by adding up the images # Start from an explicitly set as floating point, in order to force the # conversion of the 8-bit values from the images, which would otherwise overflow average = cv2.imread(files[0]).astype(np.float) for file in files[1:]: image = cv2.imread(file) # NumPy adds two images element wise, so pixel by pixel / channel by channel average += image # Divide by count (again each pixel/channel is divided) average /= len(files) # Normalize the image, to spread the pixel intensities across 0..255 # This will brighten the image without losing information output = cv2.normalize(average, None, 0, 255, cv2.NORM_MINMAX) # Save the output cv2.imwrite('output.png', output)

结果(应用了自动对比度)表明噪点消失了,与原始图像相比有了很大的改进。

玩具的原始照片,这次更亮更清晰,几乎没有可辨别的噪音

但是,我们仍然注意到一些奇怪的伪影,例如绿色框架和网格状图案。 这一次,它不是随机噪声,而是固定模式噪声。 发生了什么?

上图左上角的特写

左上角特写,显示绿框和网格图案

同样,我们可以将其归咎于传感器。 在这种情况下,我们看到传感器的不同部分对光的反应不同,从而产生可见的图案。 这些模式中的一些元素是规则的,很可能与传感器基板(金属/硅)以及它如何反射/吸收入射光子有关。 其他元素,例如白色像素,只是有缺陷的传感器像素,它们可能对光过于敏感或过于不敏感。

幸运的是,也有一种方法可以消除这种噪音。 它被称为暗帧减法。

为此,我们需要图案噪声本身的图像,如果我们拍摄黑暗,就可以获得这一点。 是的,没错——只需盖住相机孔并以最大曝光时间和 ISO 值拍摄大量照片(比如 100 张),然后按上述方法处理它们。

当对许多黑色帧(实际上不是黑色的,由于随机噪声)进行平均时,我们最终会得到固定模式噪声。 我们可以假设这个固定的噪声将保持不变,所以这一步只需要一次:生成的图像可以重复用于所有未来的低光拍摄。

以下是 iPhone 6 图案噪点的右上方(对比度调整)的样子:

上一张图像中显示的部分帧的图案噪声

再次,我们注意到网格状纹理,甚至是看起来像卡住的白色像素。

一旦我们有了这个暗帧噪声的值(在average_noise变量中),我们就可以在归一化之前简单地从我们的镜头中减去它:

 average -= average_noise output = cv2.normalize(average, None, 0, 255, cv2.NORM_MINMAX) cv2.imwrite('output.png', output)

这是我们的最终照片:

再拍一张照片,这次完全没有证据表明是在弱光下拍摄的

高动态范围

小型(移动)相机的另一个限制是它的动态范围小,这意味着它可以捕捉细节的光强度范围相当小。

换句话说,相机只能从场景中捕捉到窄带的光强; 该条带下方的强度显示为纯黑色,而其上方的强度显示为纯白色,并且这些区域的任何细节都丢失了。

但是,相机(或摄影师)可以使用一个技巧,那就是调整曝光时间(传感器暴露在光线下的时间),以有效地控制到达传感器的总光量向上或向下移动范围,以便为给定场景捕获最合适的范围。

但这是一种妥协。 许多细节未能进入最终照片。 在下面的两张图片中,我们看到使用不同曝光时间拍摄的同一场景:非常短的曝光(1/1000 秒)、中等曝光(1/50 秒)和长时间曝光(1/4 秒)。

同一张花朵图片的三个版本,一个太暗以至于大部分照片都是黑色的,一个看起来很正常,尽管光线有点不合适,第三个是光线太高以至于很难看到照片中的花朵前景

如您所见,这三幅图像都无法捕捉到所有可用的细节:灯丝仅在第一张照片中可见,一些花朵细节在中间或最后一张照片中可见但不可见两个都。

好消息是我们可以做一些事情,而且它涉及到使用一些 Python 代码构建多个镜头。

我们将采用的方法基于 Paul Debevec 等人的工作,他在此处的论文中描述了该方法。 该方法的工作原理如下:

首先,它需要对同一场景(静止)进行多次拍摄,但曝光时间不同。 同样,与前一种情况一样,我们需要一个三脚架或支架来确保相机完全不动。 我们还需要一个手动拍摄应用程序(如果使用手机),以便我们可以控制曝光时间并防止相机自动调整。 所需的照片数量取决于图像中存在的强度范围(从三个以上),并且曝光时间应该在该范围内间隔开,以便我们感兴趣的细节在至少一张照片中清楚地显示出来。

接下来,使用一种算法根据相同像素在不同曝光时间内的颜色来重建相机的响应曲线。 这基本上让我们可以在一个点的真实场景亮度、曝光时间和相应像素在捕获图像中的值之间建立映射。 我们将使用 OpenCV 库中 Debevec 方法的实现。

 # Read all the files with OpenCV files = ['1.jpg', '2.jpg', '3.jpg', '4.jpg', '5.jpg'] images = list([cv2.imread(f) for f in files]) # Compute the exposure times in seconds exposures = np.float32([1. / t for t in [1000, 500, 100, 50, 10]]) # Compute the response curve calibration = cv2.createCalibrateDebevec() response = calibration.process(images, exposures)

响应曲线如下所示:

将响应曲线显示为像素曝光 (log) 与像素值的关系图

在垂直轴上,我们有一个点的场景亮度和曝光时间的累积效应,而在水平轴上,我们有相应像素将具有的值(每个通道 0 到 255)。

这条曲线允许我们执行相反的操作(这是该过程的下一步)——给定像素值和曝光时间,我们可以计算场景中每个点的真实亮度。 该亮度值称为辐照度,它测量落在单位传感器区域上的光能量。 与图像数据不同,它使用浮点数表示,因为它反映了更广泛的值范围(因此,高动态范围)。 一旦我们有了辐照度图像(HDR 图像),我们就可以简单地保存它:

 # Compute the HDR image merge = cv2.createMergeDebevec() hdr = merge.process(images, exposures, response) # Save it to disk cv2.imwrite('hdr_image.hdr', hdr)

对于我们这些有幸拥有 HDR 显示器(这种情况越来越普遍)的人来说,也许可以直接将这张照片的所有荣耀形象化。 不幸的是,HDR 标准仍处于起步阶段,因此对于不同的显示器,执行此操作的过程可能会有所不同。

对于我们其他人来说,好消息是我们仍然可以利用这些数据,尽管正常显示需要图像具有字节值 (0-255) 通道。 虽然我们需要放弃辐照度图的一些丰富性,但至少我们可以控制如何去做。

这个过程称为色调映射,它涉及将浮点辐照度图(具有高值范围)转换为标准字节值图像。 有一些技术可以做到这一点,以便保留许多额外的细节。 举个例子说明这是如何工作的,想象一下,在我们将浮点范围压缩为字节值之前,我们增强(锐化)了 HDR 图像中存在的边缘。 增强这些边缘将有助于在低动态范围图像中保留它们(以及它们提供的隐含细节)。

OpenCV 提供了一组这些色调映射运算符,例如 Drago、Durand、Mantiuk 或 Reinhardt。 下面是一个示例,说明如何使用这些运算符之一 (Durand) 及其产生的结果。

 durand = cv2.createTonemapDurand(gamma=2.5) ldr = durand.process(hdr) # Tonemap operators create floating point images with values in the 0..1 range # This is why we multiply the image with 255 before saving cv2.imwrite('durand_image.png', ldr * 255) 

上述计算的结果显示为图像

如果您需要对流程进行更多控制,还可以使用 Python 创建自己的运算符。 例如,这是使用自定义运算符获得的结果,该运算符在将值范围缩小到 8 位之前移除以极少像素表示的强度(随后是自动对比步骤):

遵循上述过程产生的图像

这是上述运算符的代码:

 def countTonemap(hdr, min_fraction=0.0005): counts, ranges = np.histogram(hdr, 256) min_count = min_fraction * hdr.size delta_range = ranges[1] - ranges[0] image = hdr.copy() for i in range(len(counts)): if counts[i] < min_count: image[image >= ranges[i + 1]] -= delta_range ranges -= delta_range return cv2.normalize(image, None, 0, 1, cv2.NORM_MINMAX)

结论

我们已经看到了如何使用一些 Python 和几个支持库,我们可以突破物理相机的限制以改善最终结果。 我们讨论的两个示例都使用多个低质量镜头来创建更好的东西,但是对于不同的问题和限制还有许多其他方法。

虽然许多拍照手机都有存储或内置应用程序来处理这些特定示例,但显然手动编程并享受更高水平的控制和理解并不难。

如果您对移动设备上的图像计算感兴趣,请查看由 Toptaler 和精英 OpenCV 开发人员 Altaibayar Tseveenbayar 编写的 OpenCV 教程:在 iOS 中使用 MSER 进行实时对象检测。