十一、附录 A:使用“曲线”过滤器弯曲颜色空间
从第 3 章“使用 OpenCV 处理图像”开始,我们的Cameo
演示应用合并了一种称为曲线的图像处理效果,用于模拟某些物体的色偏。 摄影胶片。 本附录描述了曲线的概念及其使用 SciPy 的实现。
曲线是一种重新映射颜色的技术。 使用曲线时,目标像素处的通道值是(仅)源像素处的相同通道值的函数。 而且,我们不直接定义函数; 而是,对于每个函数,我们定义一组必须通过插值拟合的控制点。 在伪代码中,对于 BGR 图像,我们具有以下内容:
dst.b = funcB(src.b) where funcB interpolates pointsB
dst.g = funcG(src.g) where funcG interpolates pointsG
dst.r = funcR(src.r) where funcR interpolates pointsR
尽管应避免控制点处的不连续坡度,但会产生曲线,但这种插值方式可能会因实现方式而异。 只要控制点数量足够,我们将使用三次样条插值。
让我们先来看一下如何实现插值。
定义曲线
我们迈向基于曲线的过滤器的第一步是将控制点转换为函数。 大部分工作都是通过名为scipy.interp1d
的 SciPy 函数完成的,该函数接受两个数组(x
和y
坐标)并返回一个对点进行插值的函数。 作为scipy.interp1d
的可选参数,我们可以指定kind
插值; 支持的选项包括'linear'
,'nearest'
,'zero'
,'slinear'
(球形线性),'quadratic'
和'cubic'
。 另一个可选参数bounds_error
可以设置为False
,以允许外插和内插。
让我们编辑我们在Cameo
演示中使用的utils.py
脚本,并添加一个将scipy.interp1d
包裹起来的函数,该函数的接口稍微简单一些:
def createCurveFunc(points):
"""Return a function derived from control points."""
if points is None:
return None
numPoints = len(points)
if numPoints < 2:
return None
xs, ys = zip(*points)
if numPoints < 3:
kind = 'linear'
elif numPoints < 4:
kind = 'quadratic'
else:
kind = 'cubic'
return scipy.interpolate.interp1d(xs, ys, kind,
bounds_error = False)
我们的函数不是使用两个单独的坐标数组,而是采用(x
,y
)对的数组,这可能是指定控制点的一种更易读的方式。 必须对数组进行排序,以使x
从一个索引增加到下一个索引。 通常,为获得自然效果,y
值也应增加,并且第一个和最后一个控制点应为(0, 0)
和(255, 255)
,以保留黑白。 注意,我们将x
视为通道的输入值,并将y
视为对应的输出值。 例如,(128, 160)
将使通道的中间色调变亮。
请注意,三次插值至少需要四个控制点。 如果只有三个控制点,则退回到二次插值;如果只有两个控制点,则退回到线性插值。 为了获得自然效果,应避免这些后备情况。
在本章的其余部分中,我们力求以有效且井井有条的方式使用由createCurveFunc
函数生成的曲线。
缓存和应用曲线
现在,我们可以获得插入任意控制点的曲线的函数。 但是,此函数可能很昂贵。 我们不希望每个通道每个像素运行一次(例如,如果应用于640 x 480
视频的三个通道,则每帧运行 921,600 次)。 幸运的是,我们通常只处理 256 个可能的输入值(每个通道 8 位),并且可以廉价地预先计算并存储许多输出值。 然后,我们的每通道每像素成本只是对缓存的输出值的查找。
让我们编辑utils.py
文件并添加一个将为给定函数创建查找数组的函数:
def createLookupArray(func, length=256):
"""Return a lookup for whole-number inputs to a function.
The lookup values are clamped to [0, length - 1].
"""
if func is None:
return None
lookupArray = numpy.empty(length)
i = 0
while i < length:
func_i = func(i)
lookupArray[i] = min(max(0, func_i), length - 1)
i += 1
return lookupArray
我们还添加一个函数,该函数会将查找数组(例如前一个函数的结果)应用于另一个数组(例如图像):
def applyLookupArray(lookupArray, src, dst):
"""Map a source to a destination using a lookup."""
if lookupArray is None:
return
dst[:] = lookupArray[src]
请注意,createLookupArray
中的方法仅限于输入值为整数(非负整数)的输入值,因为该输入值用作数组的索引。 applyLookupArray
函数通过使用源数组的值作为查找数组的索引来工作。 Python 的切片符号([:]
)用于将查找的值复制到目标数组中。
让我们考虑另一个优化。 如果我们要连续应用两个或更多曲线怎么办? 执行多次查找效率低下,并且可能导致精度降低。 我们可以通过在创建查找数组之前将两个曲线函数组合为一个函数来避免这些问题。 让我们再次编辑utils.py
并添加以下函数,该函数返回两个给定函数的组合:
def createCompositeFunc(func0, func1):
"""Return a composite of two functions."""
if func0 is None:
return func1
if func1 is None:
return func0
return lambda x: func0(func1(x))
createCompositeFunc
中的方法仅限于采用单个参数的输入函数。 参数必须是兼容类型。 请注意,使用 Python 的lambda
关键字创建匿名函数。
以下是最终的优化问题。 如果我们想对图像的所有通道应用相同的曲线怎么办? 在这种情况下,拆分和合并通道很浪费,因为我们不需要区分通道。 我们只需要applyLookupArray
使用的一维索引。 为此,我们可以使用numpy.ravel
函数,该函数将一维接口返回到预先存在的给定数组(可能是多维数组)。 返回类型为numpy.view
,其接口与numpy.array
几乎相同,除了numpy.view
仅拥有对数据的引用,而非副本。
NumPy 数组具有flatten
方法,但这将返回一个副本。
numpy.ravel
适用于具有任意数量通道的图像。 因此,当我们希望所有通道都相同时,它可以抽象出灰度图像和彩色图像之间的差异。
现在,我们已经解决了与曲线使用有关的几个重要的优化问题,让我们考虑如何组织代码,以便为诸如Cameo
之类的应用提供简单且可重用的界面。
设计面向对象的曲线过滤器
由于我们为每个曲线缓存了一个查找数组,因此基于曲线的过滤器具有与之关联的数据。 因此,我们将它们实现为类,而不仅仅是函数。 让我们制作一对曲线过滤器类,以及一些可以应用任何函数而不仅仅是曲线函数的相应高级类:
VFuncFilter
:这是一个用函数实例化的类,然后可以使用apply
将其应用于图像。 该函数适用于灰度图像的 V(值)通道或彩色图像的所有通道。VCurveFilter
:这是VFuncFilter
的子类。 而不是使用函数实例化,而是使用一组控制点实例化,这些控制点在内部用于创建曲线函数。BGRFuncFilter
:这是一个用最多四个函数实例化的类,然后可以使用apply
将其应用于 BGR 图像。 这些函数之一适用于所有通道,而其他三个函数均适用于单个通道。 首先应用整体函数,然后再应用每通道函数。BGRCurveFilter
:这是BGRFuncFilter
的子类。 而不是使用四个函数实例化,而是使用四组控制点实例化,这些控制点在内部用于创建曲线函数。
此外,所有这些类都接受数字类型的构造器参数,例如numpy.uint8
,每个通道 8 位。 此类型用于确定查找数组中应包含多少个条目。 数值类型应为整数类型,并且查找数组将覆盖从 0 到该类型的最大值(包括该值)的范围。
首先,让我们看一下VFuncFilter
和VCurveFilter
的实现,它们都可以添加到filters.py
中:
class VFuncFilter(object):
"""A filter that applies a function to V (or all of BGR)."""
def __init__(self, vFunc=None, dtype=numpy.uint8):
length = numpy.iinfo(dtype).max + 1
self._vLookupArray = utils.createLookupArray(vFunc, length)
def apply(self, src, dst):
"""Apply the filter with a BGR or gray source/destination."""
srcFlatView = numpy.ravel(src)
dstFlatView = numpy.ravel(dst)
utils.applyLookupArray(self._vLookupArray, srcFlatView,
dstFlatView)
class VCurveFilter(VFuncFilter):
"""A filter that applies a curve to V (or all of BGR)."""
def __init__(self, vPoints, dtype=numpy.uint8):
VFuncFilter.__init__(self, utils.createCurveFunc(vPoints),
dtype)
在这里,我们正在内部使用几个以前的函数:utils.createCurveFunc
,utils.createLookupArray
和utils.applyLookupArray
。 我们还使用numpy.iinfo
根据给定的数字类型确定相关的查找值范围。
现在,让我们看一下BGRFuncFilter
和BGRCurveFilter
的实现,它们也都可以添加到filters.py
中:
class BGRFuncFilter(object):
"""A filter that applies different functions to each of BGR."""
def __init__(self, vFunc=None, bFunc=None, gFunc=None,
rFunc=None, dtype=numpy.uint8):
length = numpy.iinfo(dtype).max + 1
self._bLookupArray = utils.createLookupArray(
utils.createCompositeFunc(bFunc, vFunc), length)
self._gLookupArray = utils.createLookupArray(
utils.createCompositeFunc(gFunc, vFunc), length)
self._rLookupArray = utils.createLookupArray(
utils.createCompositeFunc(rFunc, vFunc), length)
def apply(self, src, dst):
"""Apply the filter with a BGR source/destination."""
b, g, r = cv2.split(src)
utils.applyLookupArray(self._bLookupArray, b, b)
utils.applyLookupArray(self._gLookupArray, g, g)
utils.applyLookupArray(self._rLookupArray, r, r)
cv2.merge([b, g, r], dst)
class BGRCurveFilter(BGRFuncFilter):
"""A filter that applies different curves to each of BGR."""
def __init__(self, vPoints=None, bPoints=None,
gPoints=None, rPoints=None, dtype=numpy.uint8):
BGRFuncFilter.__init__(self,
utils.createCurveFunc(vPoints),
utils.createCurveFunc(bPoints),
utils.createCurveFunc(gPoints),
utils.createCurveFunc(rPoints), dtype)
同样,我们正在内部使用几个以前的函数:utils.createCurvFunc
,utils.createCompositeFunc
,utils.createLookupArray
和utils.applyLookupArray
。 我们还使用numpy.iinfo
,cv2.split
和cv2.merge
。
这四个类可以按原样使用,在实例化时将自定义函数或控制点作为参数传递。 或者,我们可以创建其他子类,这些子类对某些功能或控制点进行硬编码。 这样的子类可以实例化而无需任何参数。
现在,让我们看一下子类的一些示例。
模拟摄影胶片
曲线的常用用法是模拟数字前摄影中常见的调色板。 每种类型的胶卷都有自己独特的颜色(或灰色)表示法,但我们可以概括一些与数字传感器的区别。 电影往往会损失细节和阴影饱和度,而数字往往会遭受高光的这些缺陷。 而且,胶片在光谱的不同部分上往往具有不均匀的饱和度,因此每张胶片都有某些弹出或跳出的颜色。
因此,当我们想到漂亮的电影照片时,我们可能会想到明亮的且具有某些主导色彩的场景(或副本)。 在另一个极端,也许我们还记得曝光不足的胶卷的暗淡外观,而实验室技术人员的努力并不能改善它。
在本节中,我们将使用曲线创建四个不同的类似于电影的过滤器。 它们受到三种胶片和冲洗技术的启发:
- 柯达波特拉(Kodak Portra),这是一系列针对肖像和婚礼进行了优化的电影。
- Fuji Provia,一个通用电影家族。
- 富士·维尔维亚(Fuji Velvia),针对风景优化的电影系列。
- 交叉处理是一种非标准的胶片处理技术,有时用于在时装和乐队摄影中产生低劣的外观。
每个电影模拟效果都实现为BGRCurveFilter
的非常简单的子类。 在这里,我们只需重写构造器即可为每个通道指定一组控制点。 控制点的选择基于摄影师 Petteri Sulonen 的建议。 有关更多信息,请参见他在这个页面上有关胶片状曲线的文章。
Portra,Provia 和 Velvia 效果应产生看起来正常的图像。 除了前后比较之外,这些效果应该不明显。
让我们从 Portra 过滤器开始,检查四个胶片仿真过滤器中每个过滤器的实现。
模拟柯达 Portra
Portra 具有宽广的高光范围,倾向于暖色(琥珀色),而阴影则较冷(蓝色)。 作为人像电影,它倾向于使人们的肤色更白皙。 而且,它会夸大某些常见的衣服颜色,例如乳白色(例如婚纱)和深蓝色(例如西装或牛仔裤)。 让我们将 Portra 过滤器的此实现添加到filters.py
:
class BGRPortraCurveFilter(BGRCurveFilter):
"""A filter that applies Portra-like curves to BGR."""
def __init__(self, dtype=numpy.uint8):
BGRCurveFilter.__init__(
self,
vPoints = [(0,0),(23,20),(157,173),(255,255)],
bPoints = [(0,0),(41,46),(231,228),(255,255)],
gPoints = [(0,0),(52,47),(189,196),(255,255)],
rPoints = [(0,0),(69,69),(213,218),(255,255)],
dtype = dtype)
从柯达到富士,接下来我们将模拟 Provia。
模拟富士 Provia
普罗维亚(Provia)具有很强的对比度,并且在大多数色调中略微凉爽(蓝色)。 天空,水和阴影比太阳增强更多。 让我们将 Provia 过滤器的此实现添加到filters.py
:
class BGRProviaCurveFilter(BGRCurveFilter):
"""A filter that applies Provia-like curves to BGR."""
def __init__(self, dtype=numpy.uint8):
BGRCurveFilter.__init__(
self,
bPoints = [(0,0),(35,25),(205,227),(255,255)],
gPoints = [(0,0),(27,21),(196,207),(255,255)],
rPoints = [(0,0),(59,54),(202,210),(255,255)],
dtype = dtype)
接下来是我们的 Fuji Velvia 过滤器。
模拟富士 Velvia
Velvia 具有深阴影和鲜艳的色彩。 它通常可以在白天产生蔚蓝的天空,在日落时产生深红色的云。 这种效果很难模拟,但是这是我们可以添加到filters.py
的尝试:
class BGRVelviaCurveFilter(BGRCurveFilter):
"""A filter that applies Velvia-like curves to BGR."""
def __init__(self, dtype=numpy.uint8):
BGRCurveFilter.__init__(
self,
vPoints = [(0,0),(128,118),(221,215),(255,255)],
bPoints = [(0,0),(25,21),(122,153),(165,206),(255,255)],
gPoints = [(0,0),(25,21),(95,102),(181,208),(255,255)],
rPoints = [(0,0),(41,28),(183,209),(255,255)],
dtype = dtype)
现在,让我们来看一下交叉处理的外观!
模拟交叉处理
交叉处理会在阴影中产生强烈的蓝色或绿蓝色调,在高光区域产生强烈的黄色或绿黄色。 黑色和白色不一定要保留。 而且,对比度非常高。 交叉处理的照片看起来很不舒服。 人们看起来黄疸,而无生命的物体看起来很脏。 让我们编辑filters.py
并添加以下交叉处理过滤器的实现:
class BGRCrossProcessCurveFilter(BGRCurveFilter):
"""A filter that applies cross-process-like curves to BGR."""
def __init__(self, dtype=numpy.uint8):
BGRCurveFilter.__init__(
self,
bPoints = [(0,20),(255,235)],
gPoints = [(0,0),(56,39),(208,226),(255,255)],
rPoints = [(0,0),(56,22),(211,255),(255,255)],
dtype = dtype)
现在我们已经看过一些有关如何实现胶片仿真过滤器的示例,我们将包装本附录,以便您可以回到第 3 章“使用 OpenCV 处理图像”中的Cameo
应用的主要实现。
总结
在scipy.interp1d
函数的基础上,我们实现了一系列曲线过滤器,这些过滤器高效(由于使用查找数组)并且易于扩展(由于面向对象的设计)。 我们的工作包括专用曲线过滤器,可以使数字图像看起来更像胶卷照。 这些过滤器可以很容易地集成到诸如Cameo
之类的应用中,如第 3 章,“用 OpenCV 处理图像”中使用我们的 Portra 胶片仿真过滤器所示。