在网上冲浪的时候我们经常会看到一些点开与不点开会呈现不同结果的图片,我们称这种图片为"幻影坦克图"。因为它利用到了光学欺骗的原理,与红警中的幻影坦克相似,故得其名。今天,尝试了解下背后的原理,并自己动手实现一下这个功能。

原理

由于幻影坦克图需要涉及到alpha混合,所以我们使用的图像格式必须带有alpha通道。所以我们的首选就是png图像格式,我们将利用它的透明度来实现。最为核心的部分就是alpha通道作用于图片的计算公式:

Color=ColorAlpha+Color(1Alpha) \begin{aligned} Color_{合} = Color_{前}*Alpha + Color_{后}*(1-Alpha) \end{aligned}
其中ColorColor_{合}是混合后所被看到的颜色,ColorColor_{前}是前景色,ColorColor_{后}是背景色

我们可以通过改变背景色从而得到图像在背景上呈现的颜色,为了更好的背景效果,我们选择黑底与白底,分别是:(r_w,g_w,b_w,a_w) = (1,1,1,1)(r_b,g_b,b_b,a_b) = (0,0,0,1)。现在我们可以计算出指定的带有透明度的图片分别在白底(r1,g1,b1)和黑底(r2,g2,b2)上呈现的颜色:

{r1=rα+(1α)g1=gα+(1α)b1=bα+(1α)r2=rαg2=gαb2=bα \begin{cases} r_1 = r*\alpha + (1-\alpha) \\ g_1 = g*\alpha + (1-\alpha) \\ b_1 = b*\alpha + (1-\alpha) \\ \\ r_2 = r*\alpha \\ g_2 = g*\alpha \\ b_2 = b*\alpha \end{cases}
但是实际上我们想要把两张图片做成幻影坦克图,我们的(r1,g1,b1,1)(r2,g2,b2,1)都应该是已知的,实际上我们要求解的应该是幻影坦克图(r,g,b,a),所以我们调整方程组可得:
{α=1r1+r2α=1g1+g2α=1b1+b2r=r2α=r21r1+r2g=g21g1+g2b=b21b1+b2 \begin{cases} \alpha = 1-r_1+r_2 \\ \alpha = 1-g_1+g_2 \\ \alpha = 1-b_1+b_2 \\ \\ r = \frac{r_2}{\alpha} = \frac{r_2}{1-r_1+r_2} \\ g = \frac{g_2}{1-g_1+g_2} \\ b = \frac{b_2}{1-b_1+b_2} \end{cases}
现在我们发现透明度有三种不同的计算方式,此时我们该如何处理呢?在正常情况下,我们难以满足这三个式子相等。但是在灰度图下可以做到r = g = b,此时灰度图的亮度信息由像素亮度决定。同样的我们也需要注意到0 <= a <= 1的大小关系,因此我们必须有r1 >= r2。为了避免像素值也超出值域,我们在实现时,需要将r1压缩到[128,255],将r2压缩到[0,127]

现在我们可以计算出我们想要的幻影坦克图片的每一个像素信息了。

代码实现

from PIL import Image
import numpy as np

def mix(img1, img2, output):
    # 灰度图转化RGB图
    img1 = Image.open(img1).convert("L").convert("RGB")
    img2 = Image.open(img2).convert("L").convert("RGB")
    # 调整图像大小
    img2 = img2.resize(img1.size)
    # 归一化
    imgarr1 = np.array(img1,dtype=np.float32) / 255.0
    imgarr2 = np.array(img2,dtype=np.float32) / 255.0
    # 压制像素 r1>= r2
    imgarr1 = 0.5 + 0.5 * imgarr1
    imgarr2 = 0.5 * imgarr2
    # 透明度计算
    alpha = 1 - imgarr1 + imgarr2
    alpha = np.clip(alpha,0,1)
    # 计算幻影坦克图颜色值
    rgb = imgarr2 / (alpha + 1e-6)
    rgb = np.clip(rgb,0,1)
    # 合并RGB与Alpha
    rgba = np.dstack((rgb,alpha.mean(axis=2)))
    # 输出图片
    img = Image.fromarray((rgba * 255).astype(np.uint8),"RGBA")
    img.save(output)

mix("D:/Photo/111.png","D:/Photo/123.png","D:/Micro/test.png")

我们可以看到以下效果:

表图

里图

优化

我们通过将图片转换成灰度图的形式从而实现了幻影坦克图,但是我们却丢失了RGB通道的颜色。有没有什么方式能够尽可能的保留原来的色彩信息呢?

我们回到这个公式:

{α=1r1+r2α=1g1+g2α=1b1+b2r=r2α=r21r1+r2g=g21g1+g2b=b21b1+b2 \begin{cases} \alpha = 1-r_1+r_2 \\ \alpha = 1-g_1+g_2 \\ \alpha = 1-b_1+b_2 \\ \\ r = \frac{r_2}{\alpha} = \frac{r_2}{1-r_1+r_2} \\ g = \frac{g_2}{1-g_1+g_2} \\ b = \frac{b_2}{1-b_1+b_2} \end{cases}
由于在RGBA模式下,一个像素只能有一个alpha通道,也就是说三个色彩通道只能使用一个alpha。所以我们怎么才能让$1-r_1+r_2\approx1-g_1+g_2\approx1-b_1+b_2\to r\approx g \approx b $呢?

我们可以通过两种方式来实现:

  • 调整图片整体亮度

    我们知道,图像的亮度调整通常是通过线性放缩实现的:

    RGBnew=RGBoriginalk (0<k<1) \begin{aligned} RGB_{new} = RGB_{original} * k\space(0<k<1) \end{aligned}
    所以RGB值之间的差值也是随着线性放缩变化的。当我们的亮度趋近于0时,rgbr\approx g \approx b是成立的,我们可以通过减少亮度,从而提升幻影坦克图的视觉效果。

  • 设置插值平衡色彩与灰度的混合比例

    我们可以通过设置差值,来平衡色彩和灰度的混合比例,使得透明度的计算结果在RGB三个通道上趋于一致,,我们基于这个公式可以求出合适的插值:

    rnew=rlerp+gray(1lerp)gray=0.299r+0.587g+0.114b \begin{aligned} r_{new} &= r*lerp + gray*(1-lerp) \\ gray &= 0.299*r + 0.587*g + 0.114*b \end{aligned}
    通过这种方式我们可以强制对齐alpha通道,并减少通道差异。

所以接下来,我们需要考虑合适的合适的亮度与色彩插值,以实现更好的效果。

对于亮度,我们有

image.png

由于人的眼睛对光的线性变化的感受是非线性的,所以即使我们把黑底图的亮度系数调暗到0.22,视觉上也只会认为颜色变暗了0.5。所以[0.18,0.22]是我们选取亮度最合适的区间。

对于色彩插值参数,参数越大,灰度平衡的比例就越小,图片能更好的保留色彩。我们希望白底图能够保留较多的色彩,且黑底图能够更加隐蔽,所以我们设置:

  • 表图 lerp = [0.6,0.8]
  • 里图 lerp = [0.2,0.4]

综上所述我们可以实现我们最终的效果了

最终实现

from PIL import Image,ImageEnhance
import numpy as np

class Phantom:
    def __init__(self):
        self.default = {
            'brightnessW': 1,
            'brightnessB': 0.8,
            'lerpW':0.8,
            'lerpB':0.8
        }

    def loadImage(self,img1,img2):	# 加载图片
        img1 = Image.open(img1).convert("RGB")
        img2 = Image.open(img2).convert("RGB")
        img2 = img2.resize(img1.size)
        return img1,img2

    def adjustBrightnedd(self,img,brightness):	# 亮度调节
        if brightness == 1:
            return img
        return ImageEnhance.Brightness(img).enhance(brightness)

    def blendColor(self,arr,lerp):	# 色彩插值平衡
        if lerp == 1.0:
            return arr
        gray = np.dot(arr,[0.299,0.587,0.114])
        return arr * lerp + gray[...,None] * (1-lerp)

    def generate(self,img1,img2,outpath,**kwargs):	
        params = {**self.default,**kwargs}
        # 加载图片
        img1, img2 = self.loadImage(img1,img2)
        # 亮度调整
        img1 = self.adjustBrightnedd(img1,params['brightnessW'])
        img2 = self.adjustBrightnedd(img2,params['brightnessB'])
        # 归一化
        arr1 = np.array(img1, dtype=np.float32) / 255.0
        arr2 = np.array(img2, dtype=np.float32) / 255.0
        # 压制像素 值域区分
        arr1 = 0.5 + 0.5 * arr1
        arr2 = 0.5 * arr2
        # 色彩差值平衡
        arr1 = self.blendColor(arr1,params['lerpW'])
        arr2 = self.blendColor(arr2,params['lerpB'])
        # 透明度计算
        alpha = np.clip(1-arr1+arr2,0,1)
        # 幻影坦克图像素计算
        rgb = np.clip(arr2/(alpha + 1e-6),0,1)
        rgba = np.dstack((rgb, alpha.mean(axis=2)))
        # 转化图片
        img = Image.fromarray((rgba * 255).astype(np.uint8), "RGBA")
        img.save(outpath)


if __name__ == '__main__':
    ph = Phantom()
    ph.generate("D:/Photo/q.jpg","D:/Photo/w.jpg","D:/Micro/test.png")

效果挺好滴,展示一下:

表图

里图