一般来讲,图像调色模块都会提供“曲线”工具,这是一个极其灵活的功能,绝大部分的调色都可以通过该工具实现,但是曲线功能的交互相对而言比较复杂。出于简便性和效率方面的考量,调色模块往往还会提供一些具有很强的功能倾向性,但是交互很简单,一个滑竿就可以搞定的功能,如“对比度,色温,色调,高光,阴影,黑点,白点,等等等等”。
本文要讲的S型曲线就常用于增强对比度,对于灰蒙蒙的图片,提高对比度能够起到一定去灰,增加通透感的作用。
一般来说,用于增强对比度的S型曲线要具备以下一些性质:
- 曲线要位于
[0, 1]
之间。 - 过
(0, 0)
和(1, 1)
两个点。 - 有一个旋转支点,位于
y=x
上,记为(pivot, pivot)
。
下面提供一些符合以上性质的S型曲线设计思路。
一、指数型S曲线
1. 必过(0.5, 0.5)的指数曲线
公式:
y
=
x
p
x
p
+
(
1
−
x
)
p
y = \frac{x^p}{x^p+(1-x)^p}
y=xp+(1−x)pxp
其导数:
y
′
=
p
x
p
(
1
−
x
)
p
x
(
1
−
x
)
[
x
p
+
(
1
−
x
)
p
]
2
y' = \frac{px^p(1-x)^p}{x(1-x)[x^p+(1-x)^p]^2}
y′=x(1−x)[xp+(1−x)p]2pxp(1−x)p
该曲线的性质:
- 当p>1时是正S型曲线;当0<p<1时是反S型曲线;当p=1时变为y=x。
- 旋转支点必为
(0.5, 0.5)
,也就是说该公式没有提供参数可以让我们任意指定旋转支点。 - 支点处的斜率等于
p
,将x=0.5代入导数公式即可得。 - 当p>1时,x=0和x=1处的导数为0,同样带入导数公式可得。
2. 可过任意支点的指数曲线
公式(分段函数):
y
=
p
i
v
o
t
(
x
p
i
v
o
t
)
p
,
0
<
x
<
=
p
i
v
o
t
y
=
1
−
(
1
−
p
i
v
o
t
)
(
1
−
x
1
−
p
i
v
o
t
)
p
,
p
i
v
o
t
<
x
<
=
1
\begin{aligned} y &= pivot(\frac{x}{pivot})^p, {0<x<=pivot} \\[2ex] y &= 1-(1-pivot)(\frac{1-x}{1-pivot})^p, {pivot<x<=1} \end{aligned}
yy=pivot(pivotx)p,0<x<=pivot=1−(1−pivot)(1−pivot1−x)p,pivot<x<=1
其导数:
y
′
=
p
(
x
p
i
v
o
t
)
p
−
1
,
0
<
x
<
=
p
i
v
o
t
y
′
=
p
(
1
−
x
1
−
p
i
v
o
t
)
p
−
1
,
p
i
v
o
t
<
x
<
=
1
\begin{aligned} y' &= p(\frac{x}{pivot})^{p-1}, {0<x<=pivot} \\[2ex] y' &= p(\frac{1-x}{1-pivot})^{p-1}, {pivot<x<=1} \end{aligned}
y′y′=p(pivotx)p−1,0<x<=pivot=p(1−pivot1−x)p−1,pivot<x<=1
该曲线的性质:
- 当p>1时是正S型曲线;当0<p<1时是反S型曲线;当p=1时变为y=x。
- 旋转支点为
(pivot, pivot)
。 - 支点处的斜率等于
p
,且支点处的函数值和斜率都是连续的,将x=pivot代入上述公式和导数公式即可得。 - 当p>1时,x=0和x=1处的导数为0。
使用该曲线调整一张图片示例如下(pivot=0.435,p=2),上图是原图,下图是调节后的效果。
容易看出,增加对比度可有效提高图片通透感,减弱灰蒙蒙的感觉。
指数型S曲线代码:
# -*- coding: utf-8 -*-
import cv2
import numpy as np
import matplotlib.pyplot as plt
def power_symmetric(x, p):
xp = np.power(x, p)
xip = np.power(1. - x, p)
return xp / (xp + xip)
def power_low(x, p, pivot):
return pivot * np.power(x / pivot, p)
def power_high(x, p, pivot):
pivot = 1. - pivot
return 1. - pivot * np.power((1. - x) / pivot, p)
def main():
pivot = 0.4
x = np.linspace(0, 1, 1000)
index_low = x <= pivot
index_high = ~index_low
# symmetric power style s curve
plt.figure(figsize=(6, 6))
for p in [0.2, 0.5, 1, 2, 5]:
y = power_symmetric(x, p)
plt.plot(x, y, label='power = %0.1f' % p)
plt.grid()
plt.legend(loc='best')
plt.title('symmetric power style s-curve')
plt.savefig('symmetric_power_style_s_curve.png', dpi=300)
# power style s curve
y = x.copy()
plt.figure(figsize=(6, 6))
for p in [0.2, 0.5, 1, 2, 5]:
y[index_low] = power_low(x[index_low], p, pivot)
y[index_high] = power_high(x[index_high], p, pivot)
plt.plot(x, y, label='power = %0.1f' % p)
plt.grid()
plt.legend(loc='best')
plt.title('power style s-curve, pivot=%0.1f' % pivot)
plt.savefig('power_style_s_curve_pivot=%0.1f.png' % pivot, dpi=300)
def main_enhance_contrast():
pivot = 0.435
p = 2
image = cv2.imread('gray.jpg')
image_adjust = image / 255.
index = image_adjust <= pivot
image_adjust[index] = power_low(image_adjust[index], p, pivot)
index = ~index
image_adjust[index] = power_high(image_adjust[index], p, pivot)
image_adjust = np.uint8(np.clip(np.round(image_adjust * 255.), 0, 255))
image_concat = np.concatenate([image, image_adjust], axis=0)
cv2.imwrite('enhance_contrast.png', image_concat)
if __name__ == '__main__':
main()
main_enhance_contrast()
二、基于分段线性函数的S曲线
1. 分段线性函数
公式(分段线性函数):
y
=
x
k
,
0
<
x
<
=
p
i
v
o
t
∗
k
k
+
1
y
=
k
(
x
−
p
)
+
p
,
p
i
v
o
t
∗
k
k
+
1
<
x
<
=
p
i
v
o
t
∗
k
+
1
k
+
1
y
=
x
−
1
k
+
1
,
p
i
v
o
t
∗
k
+
1
k
+
1
<
x
<
=
1
\begin{aligned} y &= \frac{x}{k}, {0<x<=\frac{pivot*k}{k+1}} \\[2ex] y & = k(x-p)+p, {\frac{pivot*k}{k+1} < x <=\frac{pivot*k+1}{k+1}} \\[2ex] y &= \frac{x-1}{k}+1, {\frac{pivot*k+1}{k+1}<x<=1} \end{aligned}
yyy=kx,0<x<=k+1pivot∗k=k(x−p)+p,k+1pivot∗k<x<=k+1pivot∗k+1=kx−1+1,k+1pivot∗k+1<x<=1
三条直线的设计原则比较简单,利用点斜式直线公式求解即可:
- 暗部直线(low-line):过(0, 0),斜率为 1/k
- 中灰部直线(middle-line):过(pivot, pivot),斜率为k
- 亮部直线(high-line):过(1, 1),斜率为 1/k
然后根据暗部和中灰部联立,以及中灰部与亮部联立,分别可以求出两个分界点的坐标,也已经在上面公式中了。
这个分段线性函数其实也可以用来做对比度调节,只是我们很多时候会希望曲线的变化能圆润一些,不要那么有棱角。
2. 给分段线性函数加上圆润的过渡
方法:在直线交叉的地方使用一个圆弧进行过渡,圆弧与两直线分别相切,相切就代表函数值和一阶导均连续。根据这个条件就可进行相应的圆的方程计算。但需注意实际上我们计算的是一个半圆,所以需要仔细判别半圆方程根号前的正负号。
下面以low-middle的过渡为例进行说明。
此时有三个关键点,依次是:(0,0),low-middle两直线交点,(pivot, pivot)。
以交点为中心,向两侧端点分别延展一定长度,比例记为perc,可以得到分别位于low-line上的x1,和位于middle-line上的x2,进而可得与x1对应的函数值y1,斜率k1,以及与x2对应的y2,k2。这就是我们计算圆弧所需的参数集合:(x1, y1, k1, x2, y2, k2)
因为low-line和middle-line分别是圆的切线,所以可以过切点分别做两线的垂线,交点就是圆心,进而可以根据圆心与切点之间的距离求出半径,此时圆方程的所有参数就求出来了。
过圆心的两直线方程:
y
=
−
x
−
x
1
k
1
+
y
1
y
=
−
x
−
x
2
k
2
+
y
2
\begin{aligned} y &= -\frac{x-x_1}{k_1}+y_1 \\[2ex] y &= -\frac{x-x_2}{k_2}+y_2 \end{aligned}
yy=−k1x−x1+y1=−k2x−x2+y2
圆心求解后记为(a, b),半径记为r,求解得:
a
=
k
2
x
1
−
k
1
x
2
+
k
1
k
2
(
y
1
−
y
2
)
k
2
−
k
1
b
=
−
a
−
x
1
k
1
+
y
1
r
=
(
a
−
x
1
)
2
+
(
b
−
y
1
)
2
\begin{aligned} a &= \frac{k_2x_1-k_1x_2+k_1k_2(y_1-y_2)}{k_2-k_1} \\[2ex] b &= -\frac{a-x_1}{k_1}+y_1 \\[2ex] r &= \sqrt{(a-x_1)^2+(b-y_1)^2} \end{aligned}
abr=k2−k1k2x1−k1x2+k1k2(y1−y2)=−k1a−x1+y1=(a−x1)2+(b−y1)2
半圆的方程为:
y
=
±
r
2
−
(
x
−
a
)
2
+
b
y = \pm \sqrt{r^2 - (x-a)^2}+b
y=±r2−(x−a)2+b
注:该方法实际可以作为任意两段曲线平滑过渡的通用方案。
该曲线的一些性质:
- 当k>1时是正S型曲线;当0<k<1时是反S型曲线;当k=1时应当直接使用y=x(注意这里不是退化成y=x,所以需要写if else语句去直接使用y=x,因为当k=1时,圆的方程是无解的)。
- 旋转支点为
(pivot, pivot)
。 - 如果以y=x为轴将曲线放平(也就是顺时针旋转45度),那么两节点之间的曲线凸包是左右对称的。
- 在x=0和x=1处,曲线的导数是1/k,因为两端点处仍是直线方程形态。
基于分段线性函数的S型曲线代码:
# -*- coding: utf-8 -*-
import numpy as np
import matplotlib.pyplot as plt
def get_low_line(x, k):
return 1 / k * x
def get_high_line(x, k):
return 1 / k * (x - 1.) + 1.
def get_middle_line(x, k, pivot):
return k * (x - pivot) + pivot
def linear_interpolate(x_beg, x_end, perc):
return perc * (x_end - x_beg) + x_beg
def get_arc_params(x1, y1, k1, x2, y2, k2):
a = (k2 * x1 - k1 * x2 + k1 * k2 * (y1 - y2)) / (k2 - k1)
b = -(a - x1) / k1 + y1
r_square = (a - x1) * (a - x1) + (b - y1) * (b - y1)
return a, b, r_square
def get_low_arc_params(k, pivot, perc):
x_border = pivot * k / (k + 1) # left border
x1 = linear_interpolate(x_border, 0, perc) # located at low line
y1 = get_low_line(x1, k)
k1 = 1 / k
x2 = linear_interpolate(x_border, pivot, perc) # located at middle line
y2 = get_middle_line(x2, k, pivot)
k2 = k
a, b, r_square = get_arc_params(x1, y1, k1, x2, y2, k2)
return a, b, r_square, x1, x2
def get_high_arc_params(k, pivot, perc):
x_border = (pivot * k + 1) / (k + 1) # right border
x1 = linear_interpolate(x_border, pivot, perc) # located at middle line
y1 = get_middle_line(x1, k, pivot)
k1 = k
x2 = linear_interpolate(x_border, 1., perc) # located at high line
y2 = get_high_line(x2, k)
k2 = 1 / k
a, b, r_square = get_arc_params(x1, y1, k1, x2, y2, k2)
return a, b, r_square, x1, x2
def get_arc(x, a, b, r_square, sign):
return sign * np.sqrt(r_square - (x - a) * (x - a)) + b
def main_line():
pivot = 0.4
x = np.linspace(0, 1, 1000)
y = x.copy()
plt.figure(figsize=(6, 6))
for k in [0.2, 0.5, 1, 2, 5]:
x_border_left = pivot * k / (k + 1)
x_border_right = (pivot * k + 1) / (k + 1)
index_low = x <= x_border_left
index_mid = (x > x_border_left) & (x < x_border_right)
index_high = x >= x_border_right
y[index_low] = get_low_line(x, k)[index_low]
y[index_mid] = get_middle_line(x, k, pivot)[index_mid]
y[index_high] = get_high_line(x, k)[index_high]
plt.plot(x, y, label='k = %0.1f' % k)
plt.grid()
plt.legend(loc='best')
plt.title('piecewise lines, pivot=%0.1f' % pivot)
plt.savefig('piecewise_lines_pivot=%0.1f.png' % pivot, dpi=300)
def main_all():
pivot = 0.4
perc = 0.5
x = np.linspace(0, 1, 1000)
y = x.copy()
plt.figure(figsize=(6, 6))
for k in [0.2, 0.5, 1, 2, 5]:
if np.abs(k - 1) < 1e-5:
y = x.copy()
else:
a_low, b_low, r_square_low, x_low, x_mid_left = \
get_low_arc_params(k, pivot, perc)
a_high, b_high, r_square_high, x_mid_right, x_high = \
get_high_arc_params(k, pivot, perc)
# low line
index = (x >= 0) & (x <= x_low)
y[index] = get_low_line(x, k)[index]
# low-middle arc
index = (x > x_low) & (x <= x_mid_left)
sign = np.sign(1 - k)
y[index] = get_arc(x, a_low, b_low, r_square_low, sign)[index]
# middle line
index = (x > x_mid_left) & (x <= x_mid_right)
y[index] = get_middle_line(x, k, pivot)[index]
# middle-high arc
index = (x > x_mid_right) & (x <= x_high)
sign = np.sign(k - 1)
y[index] = get_arc(x, a_high, b_high, r_square_high, sign)[index]
# high line
index = (x > x_high) & (x <= 1)
y[index] = get_high_line(x, k)[index]
plt.plot(x, y, label='k = %0.1f' % k)
plt.grid()
plt.legend(loc='best')
plt.title('line based piecewise s-curve, pivot=%0.1f, perc=%0.1f' % (
pivot, perc))
plt.savefig('line_based_piecewise_s_curve_pivot=%0.1f_perc=%0.1f.png' % (
pivot, perc), dpi=300)
if __name__ == '__main__':
main_line()
main_all()
三、基于sigmoid类型函数的S曲线
这里以sigmoid为例,具备此类函数性质的其他函数也可以采用类似方案设计,如tanh。
sigmoid函数如下:
y
=
1
1
+
e
−
x
y = \frac{1}{1+e^{-x}}
y=1+e−x1
sigmoid函数不能直接用来做对比度调节,因为它的函数值在左右两侧分别渐进于0和1,而不能直接等于,另外它很难退化为直线形态。
(PS:尽管有时候我们也需要把黑白点拉离(0, 0)和(1, 1),但那是综合调节的结果,或是曲线这种灵活性很高的功能调节的结果,作为单一小项的对比度来说,不建议耦合此类操作)
如果想要利用好这种天然呈现S形状的函数,就要做一些改造以及方案适配。
首先改造基础形态,增加旋转支点相关的参数:
y
=
1
1
+
e
−
k
(
x
−
p
i
v
o
t
)
+
p
i
v
o
t
−
0.5
y=\frac{1}{1+e^{-k(x-pivot)}}+pivot-0.5
y=1+e−k(x−pivot)1+pivot−0.5
其导数为:
y
′
=
k
e
−
k
(
x
−
p
i
v
o
t
)
(
1
+
e
−
k
(
x
−
p
i
v
o
t
)
)
2
y' = \frac{ke^{-k(x-pivot)}}{(1+e^{-k(x-pivot)})^2}
y′=(1+e−k(x−pivot))2ke−k(x−pivot)
导数还有个简便的计算方法是:
y
=
y
−
(
p
i
v
o
t
−
0.5
)
y
′
=
k
y
(
1
−
y
)
y = y-(pivot-0.5) \\[2ex] y' = ky(1-y)
y=y−(pivot−0.5)y′=ky(1−y)
pivot用于调节旋转支点就不用多解释了,k用于调节S形态的强弱。当pivot=0.4,k=8时示例如下:
接下来我们给曲线增加一个随x变化的校正,让其满足开头所说的S型曲线的几个标准,如下图:
大致的原理是:采用归一化的一阶导差异作为修正因子,对函数值进行修正。
(PS:下面是一种可用的方案,但不一定很好,肯定还有更好的)
具体步骤是(仅描述左半支,右半支同理):
- 将导数公式画出来易知,pivot处导数最大,记为slope_max。
- 计算0处的一阶导,记为slope0。
- 计算左半支最大一阶导差异slope0_diff = slope_max - slope0。
- 计算左半支最大函数值差异y0_diff = 0.0 - y0。
- 那么左半支任意点的修正量是:
a d j u s t m e n t = ( s l o p e _ m a x − s l o p e s l o p e 0 _ d i f f ) 2 ∗ y 0 _ d i f f adjustment = (\frac{slope\_max - slope}{slope0\_diff })^2 *y0\_diff adjustment=(slope0_diffslope_max−slope)2∗y0_diff
在上图蓝线的基础上,作如下修正即可得到黄线
y
=
y
+
a
d
j
u
s
t
m
e
n
t
y = y+adjustment
y=y+adjustment
整体效果如下:
该曲线的一些性质:
- 该曲线无法退化成严格的y=x。
- k与S形态强弱的对应关系不是很有规律,需要单独设计一个k的变化函数。
- 旋转支点为
(pivot, pivot)
。 - 当pivot离开0.5较远,且k较大时,上述校正方案会出现穿越[0, 1]区间的情况,这是个问题。
基于sigmoid类型函数的S曲线代码如下:
# -*- coding: utf-8 -*-
import numpy as np
import matplotlib.pyplot as plt
def get_sigmoid(x, k, pivot):
return 1.0 / (1.0 + np.exp(-k * (x - pivot))) + pivot - 0.5
def get_sigmoid_derivative(x, k, pivot):
y = get_sigmoid(x, k, pivot) - (pivot - 0.5)
return k * y * (1.0 - y)
def get_sigmoid_s_curve(x, k, pivot):
y = get_sigmoid(x, k, pivot)
y0 = get_sigmoid(0.0, k, pivot)
y1 = get_sigmoid(1.0, k, pivot)
slope = get_sigmoid_derivative(x, k, pivot)
slope0 = get_sigmoid_derivative(0.0, k, pivot)
slope1 = get_sigmoid_derivative(1.0, k, pivot)
slope_max = get_sigmoid_derivative(pivot, k, pivot)
y0_diff = 0.0 - y0
y1_diff = 1.0 - y1
slope0_diff = slope_max - slope0
slope1_diff = slope_max - slope1
# process low range
index = x <= pivot
scale = (slope_max - slope) / slope0_diff
scale = scale ** 2
y[index] = (y + scale * y0_diff)[index]
# process high range
index = ~index
scale = (slope_max - slope) / slope1_diff
scale = scale ** 2
y[index] = (y + scale * y1_diff)[index]
return y
def main():
pivot = 0.4
x = np.linspace(0, 1, 1000)
plt.figure(figsize=(6, 6))
for k in [0.1, 2, 4.1, 8, 16]:
y = get_sigmoid_s_curve(x, k, pivot)
plt.plot(x, y, label='k = %0.1f' % k)
plt.grid()
plt.legend(loc='best')
plt.title('sigmoid style s-curve, pivot=%0.1f' % pivot)
plt.savefig('sigmoid_style_s_curve_pivot=%0.1f.png' % pivot, dpi=300)
def main_compare_adjustment():
pivot = 0.4
k = 8
x = np.linspace(0, 1, 1000)
plt.figure(figsize=(6, 6))
y_before_adjust = get_sigmoid(x, k, pivot)
y_after_adjust = get_sigmoid_s_curve(x, k, pivot)
plt.plot(x, y_before_adjust, label='before adjust')
plt.plot(x, y_after_adjust, label='after adjust')
plt.grid()
plt.legend(loc='best')
plt.title('sigmoid adjustment')
plt.savefig('sigmoid_adjustment.png', dpi=300)
if __name__ == '__main__':
main()
main_compare_adjustment()
各种S型曲线设计的特点分析
这里所谓的特点分析只是我自己的一些理解,大家当然是可以有不同看法。
- 指数型S曲线在两端点处导数值为0,这意味着曲线有比较快的饱和倾向,或者换句话讲,当增强S形态时,两端点处的色彩分辨力会快速下降,一般来说这不是一个好的性质。
- 基于分段线性函数的S曲线在两端点的分辨力上要好于指数型,但是由于这是分段的,并且其中包含直线成分,尽管曲线处处函数值与一阶导数都连续,但顺滑程度看起来仍然不如其他。
- 基于sigmoid类型的S曲线尽管有上面提到的诸多问题,但是如果通过限制了参数范围避开这些问题,那么该曲线在端点分辨力上,以及整体顺滑度上都处于一种还不错的状态。