旷视CV Master计算摄影学训练营课程笔记1

Aug 9,2021   8064 words   29 min


本篇博客对应旷视CV Master训练营《计算摄影学》专题第一次课,课程视频可以点此查看。笔记在课程内容的基础上加入了一些自己的理解,不一定完全正确,欢迎互相交流。

0.课程简单介绍

1.什么是计算摄影

计算摄影是目前手机相机领域很火的概念。我们可以首先通过两个新闻感性的了解一下什么是计算摄影:新闻1新闻2。然后回到我们的课程。如下图所示,计算摄影可以概括为一句话就是用计算的方式看世界。 更进一步说就是研究从光子到像素的这个过程,如上图所示。我们一般获得一张图像,直接用手机一拍或者用API接口一调,图片就被获取到了。但是这个看似简单过程,里面有非常大的学问,涉及到了非常多的步骤。

而下图展示了传感器将接收到的光转换为电信号的过程。在每一个传感器的上面都覆盖了一层微透镜(图中蓝色部分),把入射的光进行能量集中,起到汇聚光线的作用。然后下面是一层Color filter(滤光片),用于过滤不同颜色的光,以获取彩色图像。

下图展示了传感器获取彩色图像所用的Bayer Pattern(拜尔阵列)。 这一层的作用就是过滤光线,使得某个像素只能接收某一种颜色的光,如红、绿、蓝等。而且如果仔细观察可以发现,接收的绿光的像素个数是红光和蓝光的两倍。这是因为人眼对于绿光最为敏感。之后,再通过插值算法,实现彩色图像生成,这个过程可以叫做demosaic。

所以我们可以简单总结,计算摄影的核心是在成像的全链路中引入计算的方法,得到(用其它方法难以获得的)图像。 或者换句话说,是“以软补硬”,通过软件算法,修复一些因为硬件缺陷而产生的问题。

所以,摄影本身就是一个拥有巨大计算量的过程。 那么如何评价一个影像的好坏和质量呢?可以从TIMCDS这几个指标入手,如下所示。 一些小DEMO。 在实际的手机上,比如可以通过计算的手段模拟出大光圈的效果,比如手机的人像模式。又比如局部的延时摄影,前景清晰,背景长曝光。

2.相机的Raw图

(1) 什么是Raw图

所谓raw图可以理解为是相机传感器直接读取的数值(线性的、未经处理的传感器读出值),没有经过任何的后处理。相当于我们感知之后就直接把数据拿出来了,没有后续的computation过程。

(2) Raw图的读取

在Python中,有很方便的Rawpy库提供了方便的功能,官方地址是这里。我们可以直接pip install rawpy进行安装。安装完成后,按照下图的代码就可以读取Raw图了,拿到的应该是uint16类型的数据。需要注意的是,只是说是uint16类型的数据,并不代表真实拍摄时是16bit量化。对于一般手机而言,最大值为1023,也就是10bit量化。 而且在上图中可以看到,Raw图的数值是随着曝光时间线性变化的,但jpg就不是。当然,如果仔细观察会发现,raw图的均值也不是线性变化的,而是减去了64这个数值之后,才是线性的。那么这个64,就是传感器的black level。可以简单理解为传感器的底噪。我们可以通过标定获得它的值。

下面,展示了利用Rawpy读取Raw图并进行可视化的代码与示例。

from matplotlib import pyplot as plt  # 可视化相关
import rawpy  # Raw图解析相关
import numpy as np  # 矩阵运算相关

# 读取并解析Raw图
img = rawpy.imread("keyboard_raw.dng")
array_data = img.raw_image_visible

# 遍历像素统计灰度直方图
bins = [0] * 1024
for i in range(array_data.shape[0]):
    print(i + 1, '/', array_data.shape[0])
    for j in range(array_data.shape[1]):
        bins[array_data[i, j]] += 1

# 输出相关信息检查灰度统计是否正确
print('Image width:', array_data.shape[0])
print('Image height:', array_data.shape[1])
print('Total pixel:', array_data.shape[0] * array_data.shape[1])
print('Total pixel in practice:', np.sum(bins))
print('Datatype:', array_data.dtype)

# 绘制灰度直方图
plt.figure(1)
plt.bar(range(len(bins)), bins)

# 绘制Raw图
plt.figure(2)
plt.imshow(array_data, cmap='gray')

# 可视化
plt.show()

显示的传感器原始数据影像如下所示。 可以看到,我们可视化的Raw图中,最大值为1020左右,和上面说的是对应的。其对应的灰度直方图如下所示。 可以看到,数值分布确实是在0到1023之间。而且我们还可以看一下输出,可以看到,数据类型确实为uint16。

(3) Raw图的显影

Raw图的显影可以理解为是将Raw图的数据转换为普通8bit图像的过程。需要注意的是,这里的显影和刚刚的Raw图可视化是不同的。可视化只是将原始数据不经过任何处理的显示出来,而显影则是一种“转换”过程。最直观的区别就是量化级数不同,raw图一般是10bit,普通jpg是8bit,需要转换。另外不同之处在于raw图是全色影像,需要经过计算才能变成多波段彩色影像。此外,还要考虑底噪的影响。具体来说,还需要经过下图所示的步骤。 至少要经过blc、rgbgain、demosaic、gamma最基本的这四步,才可以得到一个8bit彩色影像。如果要更好的效果,当然需要考虑更多因素。在Rawpy中,提供了一个简单的“软解”函数postprocess(),可以对Raw数据进行解析。

下面展示了利用Rawpy对Raw图进行显影的过程。

from matplotlib import pyplot as plt  # 可视化相关
import rawpy  # Raw图解析相关

# 读取并解析Raw图
img = rawpy.imread("lawson_raw.dng")
array_data = img.raw_image_visible

# 利用Rawpy进行软解
pp_data = img.postprocess(use_camera_wb=True)

# 输出相关信息
print("Raw datatype:", array_data.dtype)
print("Postprocess datatype:", pp_data.dtype)

# 可视化
plt.figure(1)
plt.imshow(array_data, cmap='gray')
plt.figure(2)
plt.imshow(pp_data, cmap='gray')
plt.show()

如下图所示,从输出可以看到,经过显影之后,数据类型从uint16变成了uint8,并且也从单通道变成了RGB三通道。 可视化的Raw图和显影之后的图如下所示。 至此,我们就简单的完成了对Raw图的显影。

(4) 新的问题(lens shading与black level)

我们可以将我们显影的Raw图和手机输出的jpg进行对比,如下,其实还是可以发现有较大差别的。这种差异简单来说是因为软解的时候,跳过了一些处理步骤,导致了这种差异。 这里最大的区别就是图像整体亮度不同,而且我们软解的影像四周都比中心暗。这种效应就被称为lens shading。对于相机而言,它可以看做是一个孔,那么对于一些直射进来的光线,是没有什么遮挡的,反应在图像上就相对较亮。而斜射进来的光线或多或少在镜头中被遮挡,从而进光量达不到直射的强度,导致了亮度变暗。这个问题在课程中也有提到。 左边是软解Raw的结果,右边是相机直出的结果,可以看到左边明显偏暗。我们可以按照下面进行处理。 分别补偿lens shading以及gain,之后效果就会好很多了,如下。 但是仔细观察,还是会发现一些不一样的细节,如下所示。 那么这些不一样的原因有很多。也正如课程中所说,当你开始探究这些差异的时候,就进了计算摄影的深坑。

对于我们自己拍摄的图片,也可以进行这种补偿。下面代码实现了这种补偿。

from matplotlib import pyplot as plt  # 可视化相关
import rawpy  # Raw图解析相关
import numpy as np  # 矩阵运算相关
import cv2  # 影像读取相关

# 读取Raw图
img = rawpy.imread("lawson_raw.dng")

# 直接解析
out_pp = img.postprocess(use_camera_wb=True)

# 先进行处理再解析
pixels = img.raw_image_visible.astype('float32')
# lens shading
xs, ys = np.mgrid[:pixels.shape[0], :pixels.shape[1]]
xs = np.float32(xs) - xs.mean()
ys = np.float32(ys) - ys.mean()
rs = (xs ** 2 + ys ** 2) ** 0.5 / xs.shape[0]
shading = 1 + rs ** 2 * 3
# black level
bl = 64
pixels = bl + (pixels - bl) * 3 * shading
img.raw_image_visible[:] = pixels.clip(0, 1023)

# 利用Rawpy进行软解
out = img.postprocess(use_camera_wb=True)

# 读取手机直出图像作为对比
img_phone = cv2.imread("lawson_phone.jpg")
img_phone_rgb = cv2.cvtColor(img_phone, cv2.COLOR_BGR2RGB)

# 可视化
plt.figure(1)
plt.title("postprocess")
plt.imshow(out_pp, cmap='gray')
plt.figure(2)
plt.title("postprocess with lsc")
plt.imshow(out, cmap='gray')
plt.figure(3)
plt.title("phone")
plt.imshow(img_phone_rgb, cmap='gray')
plt.show()

补偿之后对比如下。 可以看到相比于没有补偿效果是好很多了。但是和手机直出的图片相比,还是有一些差别。这是由于不同的相机补偿参数不一样导致的。在补偿代码中,有非常多的常数,这些常数是怎么来的呢?答案是标定出来的。既然是标定出来的,也就是说基本每个传感器都会有自己独特的一套参数,类似于相机内参。这里用的参数是课程中给出来的,不一定适合我们的手机(小米10至尊纪念版)。这也就是为什么Rawpy在简单处理函数中处理效果不好的原因。因为不同相机参数不同,没有普适的数值。这种不同参数也造就了不同不同厂商对于相机的调教风格。

3.手动进行postprocess

正如前面说的,软解最基本可以分为减black level、应用rgbgain、应用gamma、进行demosaic。下面简单分别进行介绍一下。

(1) 减black level

为什么黑色不是0?一方面当然是由于各种噪声,导致读出来的值存在干扰。另一方面这其实是在设计的时候故意而为之的。如果不加一个偏置,由于传感器热噪声等很多因素,很有可能读出来负数。但是保存的数据类型为uint类型,所以负数也会保存为0。这样显而易见会丢失信息。

在上面的代码中,我们频繁见到64这个black level。而且也说了,这个值是标定出来的。所以我们完全也可以自己来标定自己相机的black level。其实也是非常简单的。我们只需要将相机的ISO调到最低,然后对着纯黑的场景拍摄一张图像。从理论上而言,我们得到的像素值都为0。但因为有噪声的影响,肯定不为0。对于这张不全为0的有噪声的影像,取平均值,这样获得的平均灰度就可以当作是black level的值了。 下面是读取Raw影像并且计算Black level的代码。

from matplotlib import pyplot as plt  # 可视化相关
import rawpy  # Raw图解析相关
import numpy as np

# 读取Raw图
img = rawpy.imread("black_raw.dng")
array_data = img.raw_image_visible

# 遍历像素统计灰度直方图
bins = [0] * 1024
for i in range(array_data.shape[0]):
    print(i + 1, '/', array_data.shape[0])
    for j in range(array_data.shape[1]):
        bins[array_data[i, j]] += 1

for i in range(55, 75):
    print(i, ":", bins[i])

mean_value = np.mean(array_data)
print("Mean value:", mean_value)

# 绘制图像
plt.figure(1)
plt.bar(range(55, 75), bins[55:75])
plt.figure(2)
plt.title("array_data")
plt.imshow(array_data)
plt.show()

如下是Raw影像的可视化效果以及灰度分布直方图。可以看到这是一个非常瘦的高斯分布。 最后,可以计算出Black level如下图所示,约为63.97。

(2) 应用rgbgain

如上图所示,由于在传感器表面加了彩色绿光片,而且再加上Bayer Patter,导致了不同像素天生感光能力是不一样的。因此,在进行进一步处理之前,还需要对RGB色彩进行补偿。

(3) 应用gamma

在上面我们对RGB各分量进行了补偿以后,还需要对色彩进行进一步的校正,这就是gamma色彩校正。简单来说就是人眼感受到的光强并非和物理光强是线性关系。更多相关内容可以参考这个网页

(4) 进行demosaic

Demosaic简单来说就是根据Bayer Pattern获取到的R、G、B像素值,通过算法生成每个像素的RGB值,从而形成三通道RGB影像。 一个简单的双线性Demosaic算法示意如下。 首先如左图所示,每个红色和蓝色的上、下、左、右周围都有四个绿色,所以很简单的,我们取这四个绿色的平均值,将其作为绿色赋给对应的红色或者蓝色。这样原本红色的像素就有红色和绿色,原本蓝色的像素就有蓝色和绿色,原本绿色的现在还是只有绿色。或者换句话说,影像中所有像素的绿色就都补齐了。然后我们可以考虑填补红色,如右图所示。对于上面中间的绿色像素,我们就找左右两边的红色像素,然后取平均,作为该绿色像素的红色分量。同理,第二行左边的绿色像素也找离它最近的两个红色(上下两个),然后取平均。而对于蓝色像素,则寻找四个角的红色像素,然后取个平均作为该蓝色像素的红色分量。经过这样的操作,影像中所有像素的红色分量就都被补齐了。最后,我们可以类似地补全蓝色分量,就可以把每个像素的RGB分量都补全了。

当然上面介绍的方法是比较简单的,还有一些更高级的Demosaic方法以及不同的倾向,如下图所示。在一些复杂的场景中,不同算法会有不同的效果。 不同的方向选择是很重要的,如下图所示。 如上图所示,左边有一系列的数值,如果我们采用竖直方向上的像素进行插值,那么插值的结果当然是没问题的,但是如果采用了水平方向进行插值,那么插值出来的结果就会出现拐弯的情况,显然这是不对的。可以看出,插值方向的选择直接影响了插值结果,同时正确的插值方向又受影像内容本身影响。所以如何根据影像内容选择正确的插值方向便是一个值得研究的问题。相关研究也有很多。如下是一种采用颜色梯度相关性的图像先验信息进行处理的方法。 这里利用到的一个图像先验是,图像的高频分量(边界等灰度变化明显的区域)其颜色一般是“灰”的。或者将的再通俗一些就是一般而言,物体边界都是黑色系的,而非五颜六色的(除了一些本身颜色很突出的物体)。这样,我们就可以运行多种demosaic算法,然后设计一种评价指标,看高频分量是不是彩色的,最接近于黑白的即认为是效果最好的。所以核心思想就是运行多个算法,然后挑个最好的。

另外说一句题外话,造成这种物体边缘轮廓呈现黑白色现象的原因,个人理解是因为物体的边缘部分一般情况下它的法向都不是朝向我们眼睛的方向,这也就导致了照射在物体边缘的光线只有很少一部分能够反射到我们眼睛中来。这样,自然而然的,我们看到的物体边缘部分就变暗了,也就变黑、变灰了。同时这也就是为什么在绘画中绘制物体轮廓都用黑色或者灰色等颜色进行绘制,而不用其它颜色的原因(除了一些特殊需要表达的场合,比如物体颜色和背景相近,为了区分的场合等),如下。 可以看到琪亚娜的轮廓就是用黑色线条绘制的,而班长的线条有一部分则是用浅色绘制的,突出班长发光的感觉。

另外,简单介绍一下yuv编码,如下图所示。 简单可以理解为是一种储存数据的手段,可以通过相关函数直接转换到RGB空间。

(5) 综合代码

所以,我们可以简单的将上面几步利用代码重新整理合并,手动软解过程如下所示。 前两行对应Black level补偿、RGB补偿,然后是Gamma补偿以及最后的Demosaic。这里面这些数字是通过标定得到的,不同相机会有不同的数值。

通过上面的代码,可以解析出下面的图片。 整体还行,但是感觉有种“雾感”。

(6) Raw图去噪

最简单的方法可以利用多帧降噪,如下图所示。 在黑夜中拍摄某张影像,jpg直出一片黑,什么也看不到。 但如果我们查看Raw图,可以看到还是有很多细节的。只是相机的算法非常“保守”,宁愿一片黑也不愿意有很大的噪声。 然后我们叠加8张Raw图取平均,可以看到效果就好了很多。 再经过一定后处理,一张全黑的图就“奇迹”般的有了内容。 这种取平均的方法虽然简单,但其实也还比较有效。但是唯一一个比较大的缺点就是,相机得要一动不动地拍很多张。这在实际生活中基本是无法保证的。针对这个情况,现阶段的解决思路是,手机在拍摄预览阶段,其实就已经在采集Raw图数据了。并且在拍照的时候,尽可能多拍几张(这也就是为什么手机在夜景模式下拍照时,让你拿稳手机等待一段时间的原因)。 在有了多张影像后,再进行配准,找到可以叠加的部分进行平均降噪,最后输出。

另外,目前也有很多人尝试用神经网络进行去噪。这样,网络训练好以后,输入一张影像,就可以输出一张去噪后的影像,实现端到端的方式。在一定程度上解决这种多张拍摄的问题。 既然神经网络需要训练,就不可避免的涉及到噪声-无噪声影像对,这其实是个关键问题。我们可以用下图中的方式进行处理。

4.课程作业

至此,本笔记到此结束。文中提到的相关代码和数据上传到了Github,感兴趣可以点此查看,欢迎Fork或Star。

5.参考资料

  • [1] https://pypi.org/project/rawpy/
  • [2] https://github.com/letmaik/rawpy-notebooks
  • [3] https://www.zhihu.com/question/27467127
  • [4] https://github.com/zhaoxuhui/RawImageProcessingDemo

本文作者原创,未经许可不得转载,谢谢配合

返回顶部