Optie研

パソコンで絵や動画を作る方法について

Python3 & OpenCV で画像処理を学ぶ[5] 〜 AfterEffects/Photoshopにある描画モードを実装する

1. はじめに

 前回は、画像間演算の基礎を学びました。

 今回はその応用として、AfterEffectsなどで利用されている様々な描画モード(合成モード、ブレンドモード)を実装する実験を行い、一部を除き、一通り再現しました。

 注意点として。基本的にインターネット上で挙げられている計算式(例えばこちら https://stackoverflow.com/questions/5919663/how-does-photoshop-blend-two-images-together の回答など)に準じることにしますが、Adobe公式によるものではない(公式は発表していない?)ことも多いので、いくらか誤りを含むかもしれません。また、実装の計算式はアプリケーションごとにも違いがあるため、結果に微妙な違いを含みうることをご了承ください。

 また、描画モード関数の実装は、可読性を最優先した結果いくらか冗長な書き方になっているかと思いますが、ご了承ください。

import numpy as np
from matplotlib import pyplot as plt
import matplotlib.gridspec as gridspec
import seaborn as sns
import cv2

%config InlineBackend.figure_format = 'retina'

2. 合成モード実装実験

2.1 下準備

 あらかじめ、今回描画に使う関数をクラスにまとめておくことにします。

 「二枚の画像を受け取って、ブレンドした画像を返す関数」を渡して生成するクラスです。
合成結果を表示するblend_showメソッドを準備します。

class BlendMode:
    def __init__(self, f):
        """
        合成モードを, ベクトル値関数 f:(x,y)→z の形で与える.
        f は [0~1] かつ同サイズの RGB_img 配列を入出力とすることを要請する.
        """
        self.f = f

    def blend(self, bg_img, fg_img):
        """
        入力 : 前景画像, 背景画像
        出力 : 前景画像 + 背景画像 + 合成結果 の表示
        """
        bg_img = bg_img / 255
        fg_img = fg_img / 255
        
        result = self.f(bg_img, fg_img).clip(0,1)
    
        fig = plt.figure(figsize=(10,9))
        gs = gridspec.GridSpec(3,2)
        
        ax1 = fig.add_subplot(gs[0,0])
        ax2 = fig.add_subplot(gs[0,1])
        ax3 = fig.add_subplot(gs[1:3,:])
        
        sns.set_style('ticks')

        ax1.set_xticks([]), ax1.set_yticks([])
        ax2.set_xticks([]), ax2.set_yticks([])

        ax1.set_title('Background'), ax1.imshow(bg_img)    
        ax2.set_title('Foreground'), ax2.imshow(fg_img)
        ax3.set_title('Result Img'), ax3.imshow(result)
        plt.show()

あとは関数を書いてこのクラスのインスタンスに投げるだけですね。

今回利用する画像素材もまとめて読み込んでおきます。

wiz = cv2.cvtColor(cv2.imread('img_src/Wizdomiot.png'), cv2.COLOR_BGR2RGB)
sozai_grad = cv2.cvtColor(cv2.imread('img_src/RP_grad.png'), cv2.COLOR_BGR2RGB)
sozai_noise = cv2.cvtColor(cv2.imread('img_src/PurpleBlockNoise.png'), cv2.COLOR_BGR2RGB)

imgs_dict = {'Wizdomiot.png':wiz,'sozai_Grad.png':sozai_grad, 'sozai_Noize.png':sozai_noise}

以下に使用素材を示します。

fig = plt.figure(figsize=(18,7))

for (i, img) in enumerate(imgs_dict.items()):
    ax = fig.add_subplot(131 + i)
    ax.set_xticks([]), ax.set_yticks([])
    ax.set_title(str(i) + ". " + img[0])
    ax.imshow(img[1])

plt.show()

f:id:Optie_f:20180315194357p:plain

また、前回同様、RGB値はすべて 0 と 1 の間の実数として考えます。

$ 0 \leq {RGB} \leq 1$

そして、入出力の画素のRGB値を以下のように書くことにします。

前景(上のレイヤー): ${RGB}_1$
背景(下のレイヤー): ${RGB}_2$
出力(演算結果)  : ${RGB}$

また、${RGB}$ という表記では、実際の計算はR,G,Bチャンネルそれぞれ個別に行われることを示します。例えば、

${RGB} = {RGB}_1 + {RGB}_2$

と書いたとき、実際の計算では、

$R = R_1 + R_2$
$G = G_1 + G_2$
$B = B_1 + B_2$

というように行われるということです。

2.2 「減算」族 〜 重ねると暗くなる

まずは「減算」系の演算です。重ねることで暗くなるという特徴があり、具体的には以下です。

  • 比較(暗)
  • 乗算
  • 焼き込みカラー
  • 焼き込みリニア
  • カラー比較(暗)

2.2.1 比較(暗) Darken

def Darken(bg_img, fg_img):
    result = np.zeros(bg_img.shape)
    
    # boolean array
    is_BG_darker = bg_img < fg_img
    
    result[is_BG_darker] = bg_img[is_BG_darker]
    result[~is_BG_darker] = fg_img[~is_BG_darker]
    
    return result


darken = BlendMode(Darken)
darken.blend(wiz,sozai_noise)

f:id:Optie_f:20180315194403p:plain

 比較(暗) Darken は、RGBチャンネルごとに低い方の値を出力にします。

$ {RGB} = min(RGB_1, RGB_2) $

 したがって、上記の例では、背景の魔法陣の白い部分(R,G,B全て強い)と、前景のノイズの明るい部分(Rが強く, G,Bは弱い)とでの比較の結果として、魔法陣のR成分だけが残っている様子などを見てとることができます。

2.2.2 乗算 Multiply

def Multiply(bg_img, fg_img):
    result = bg_img * fg_img
    return result


multiply = BlendMode(Multiply)
multiply.blend(wiz,sozai_noise)

f:id:Optie_f:20180315194406p:plain

 乗算 multiplyは、二枚の画素値を掛けます。

$ {RGB} = RGB_1 \times RGB_2 $

 RGB値が0~1までであったことを思い出すと、上記の例では、魔法陣の白い部分 ≒ 1 と黒い部分 ≒ 0 が、ノイズの画素値と掛け算されることで、ノイズから魔法陣が浮き上がったかのような結果になっていますね。

2.2.3 焼き込みカラー Color Burn

def ColorBurn(bg_img, fg_img):
    result = np.zeros(bg_img.shape)
    
    bg_inverse = 1 - bg_img
    non_zero = fg_img!=0  # masking array to avoid division by 0
    
    result[non_zero] = 1 - bg_inverse[non_zero]/fg_img[non_zero]
    result[~non_zero] = 0
    
    return result


colorburn = BlendMode(ColorBurn)
colorburn.blend(wiz,sozai_noise)

f:id:Optie_f:20180315194408p:plain

 焼き込みカラー Color Burnは、「(反転した背景 ÷ 前景) を反転する」という計算を行います。

$ {RGB} = 1- \dfrac{(1 - RGB_2)}{RGB_1} $

 まず、区間[0,1]では、 $(1 - {RGB})$ は反転にあたります。このことは、$y=1-x$ のグラフを描いてみると理解しやすいと思います。
 また、区間[0,1]での割り算を考えるとやや理解しにくいのですが、 シンプルに分数として考えると、以下のようになりそうです。

分母:小 分母:大
分子:小 ≒1 <1
分子:大 >1 ≒1

最大値を 1 とすると、分母>分子 のとき以外はすべて1付近になると考えてよさそうです。そこで、「分母のピクセルが明るく、分子のピクセルが暗い」かどうか を基準とすれば多少見通しがよくなりそうです。

 上記の例に即して考えてみましょう。
まず背景を反転すると、「魔法陣が暗くなり、その外側が白っぽくなる」ことは想像がつきます。それを分母=前景のノイズで割ると、「魔法陣の暗さがやや変化し、外側はより明るくなるか100%白に飛ぶ」と考えられます。そこからもう一度反転することで、上記の結果になります。

 結論として、「背景の最も明るい部分は残し、それ以外の部分は急激に暗くなる」描画モードということができそうです。

補足: AfterEffectsの結果と比較したところ、この計算式は、AfterEffects的には「焼き込みカラー(クラシック)」のもののようです。(クラシック)と付く描画モードは基本的に後方互換性のためにあるようですが、「焼き込みカラー(クラシック)」と「焼き込みカラー」では微妙に結果が異なりました。

2.2.4 焼き込みリニア Linear Burn

def LinearBurn(bg_img, fg_img):
    result = np.zeros(bg_img.shape)
    
    bg_inverse = 1 - bg_img
    fg_inverse = 1 - fg_img
    
    result = 1 - (bg_inverse + fg_inverse)
    return result


linearburn = BlendMode(LinearBurn)
linearburn.blend(wiz,sozai_noise)

f:id:Optie_f:20180315194412p:plain

 焼き込みリニア Linear Burnは、「(反転背景 + 反転前景) を反転する」という計算を行います。

$ {RGB} = 1- \big((1 - {RGB}_1) + (1 - {RGB}_2) \big) $

 反転することで、本来は暗部であったピクセルが明部になります。その状態で加算をして、再び反転して元の世界に戻ってくるということで、「互いの暗部を足し合わせる」というような見方をできそうです。

2.2.5 カラー比較(暗) Darker Color

def DarkerColor(bg_img, fg_img):
    result = np.zeros(bg_img.shape)
    
    if len(bg_img.shape)==3:  # is Input RGB array?
        bg_RGB_sum = bg_img.sum(axis=2, keepdims=True)
        fg_RGB_sum = fg_img.sum(axis=2, keepdims=True)
        
        is_BG_darker = (bg_RGB_sum < fg_RGB_sum).repeat(3,axis=2)  # 配列のサイズ合わせ
        
        result[is_BG_darker] = bg_img[is_BG_darker]
        result[~is_BG_darker] = fg_img[~is_BG_darker]
        
    else:
        result = Darken(bg_img, fg_img)
    
    return result


darkercolor = BlendMode(DarkerColor)
darkercolor.blend(wiz,sozai_noise)

f:id:Optie_f:20180315194414p:plain

 カラー比較(暗) Darker Color は、RGBの合計値の低い方のピクセルを出力にします。

$ {RGB} = \left\{ \begin{array}{l} {RGB}_1 & (R_1 + G_1 + B_1 < R_2 + G_2 + B_2) \\ {RGB}_2 & (otherwise) \\ \end{array} \right. $

 したがって、「上か下のレイヤーのピクセルがそのまま結果に出る(個々のピクセルは合成で不変)」という特徴があります。

2.3.「加算」族 〜 重ねると明るくなる

  • 比較(明)
  • スクリーン
  • 覆い焼きカラー
  • 覆い焼きリニア(加算)
  • カラー比較(明)

2.3.1 比較(明) Lighten

def Lighten(bg_img, fg_img):
    result = np.zeros(bg_img.shape)
    
    is_BG_lighter = bg_img > fg_img
    
    result[is_BG_lighter] = bg_img[is_BG_lighter]
    result[~is_BG_lighter] = fg_img[~is_BG_lighter]
    
    return result


lighten = BlendMode(Lighten)
lighten.blend(wiz,sozai_noise)

f:id:Optie_f:20180315194415p:plain

 比較(明) Lighten は、RGBチャンネルごとに高い方の値を出力にします。

$ {RGB} = max(RGB_1, RGB_2) $

 したがって、上記の例では、背景の魔法陣の白い部分(R,G,B全て強い)がそのまま残っていることと、ノイズにG成分がないために、背景下部の緑色発光のG成分がノイズの上に加わるような結果を見てとることができます。

2.3.2 スクリーン Screen

def Screen(bg_img, fg_img):
    result = np.zeros(bg_img.shape)
    result = 1 - ((1 - bg_img) * (1 - fg_img))
    return result


screen = BlendMode(Screen)
screen.blend(wiz,sozai_noise)

f:id:Optie_f:20180315194421p:plain

 スクリーン Screen は、「反転して乗算し、また反転して戻す」演算です。

$ {RGB} = 1 - (1-RGB_1)*(1-RGB_2) $

 よく加算と比されることが多いですが、計算式の通り、実質的に乗算そのものです。焼き込み(リニア)のように、反転した世界での計算なので、乗算で暗くなる代わりに明るくなっています。

 「加算と比べてスクリーンは白飛びしない」事実は、「スクリーンで白になる = 反転世界の乗算で0になる」と理解するとわかりやすいです。
 加算はすぐ 1 を超えます。しかし掛け算は、少なくともどちらか一方が 0 でない限り 0 にならないため、スクリーン演算ではどちらか一方の画素値が 1 でない限り 1 にはならず、結果として白飛びしません。

2.3.3 覆い焼きカラー Color Dodge

def ColorDodge(bg_img, fg_img):
    result = np.zeros(bg_img.shape)
    
    fg_reverse = 1 - fg_img
    non_zero = fg_reverse!=0
    
    result[non_zero] = bg_img[non_zero]/fg_reverse[non_zero]
    result[~non_zero] = 1
    
    return result

colordodge = BlendMode(ColorDodge)
colordodge.blend(wiz,sozai_noise)

f:id:Optie_f:20180315194420p:plain

 覆い焼きカラー Color Dodgeは、「背景 ÷ 反転した前景」という計算を行います。

$ {RGB} = \dfrac{(RGB_2)}{1-RGB_1} $

 こちらも除算なので捉えにくいですが、上記の例を眺めながら雰囲気で考えると、「前景のうち明るい部分が反転して低い値になり、それが分母になる」ということと、分数では分母が小さければ値は大きくなることから、「前景の明るい部分が背景に写る」演算とでも理解すればよいでしょうか。
 前景のうち暗い部分は反転して 1 に近い値になり、 1 で割っても値は変わらないので、前景の暗い部分ほど結果に反映されないということのようです。

 また、焼き込みカラーと同様の議論ですが、分数の分母を0に近づけていくと値の増え方が急であるのと同様に、明部から暗部にかけての影響度の変化がきついということが考えられます。実際に、スクリーンなどと比べると、上の例では前景ノイズの暗い部分がほとんど反映されていないことを見て取れるかと思います。

2.3.4 覆い焼きリニアLinear Dodge

def Add(bg_img, fg_img):
    result = bg_img + fg_img
    return result
add = BlendMode(Add)
add.blend(wiz,sozai_noise)

f:id:Optie_f:20180315194431p:plain

 覆い焼きリニア Linear Dodgeは加算です。1を超えた値は1に抑えられます。

$ {RGB} = {RGB}_2 + {RGB}_1 $

 ただの加算なので、あまり書くことがありません…… このような仰々しい名前が付いているのは、焼き込みリニアと対であることを意識したものかと思われます。

焼き込みリニア :
$ {RGB} = 1- \big((1 - {RGB}_1) + (1 - {RGB}_2) \big) $

2.3.5 カラー比較(明) Lighter Color

def LighterColor(bg_img, fg_img):
    result = np.zeros(bg_img.shape)
    
    if len(bg_img.shape)==3:
        bg_RGB_sum = bg_img.sum(axis=2, keepdims=True)
        fg_RGB_sum = fg_img.sum(axis=2, keepdims=True)
        
        is_BG_lighter = (bg_RGB_sum > fg_RGB_sum).repeat(3,axis=2)
        
        result[is_BG_lighter] = bg_img[is_BG_lighter]
        result[~is_BG_lighter] = fg_img[~is_BG_lighter]
        
    else:
        result = Lighten(bg_img, fg_img)
    
    return result


lightercolor = BlendMode(LighterColor)
lightercolor.blend(wiz,sozai_noise)

f:id:Optie_f:20180315194427p:plain

 カラー比較(明) Lighter Color は、RGBの合計値の高い方のピクセルを出力にします。

${RGB} = \left\{ \begin{array}{l} {RGB}_1 & (R_1 + G_1 + B_1 > R_2 + G_2 + B_2) \\ {RGB}_2 & (otherwise) \\ \end{array} \right. $

 カラー比較(暗)と全く同様に「上か下のレイヤーのピクセルがそのまま結果に出る(個々のピクセルは合成で不変)」という特徴があります。

2.4「複雑」族 〜 50%グレーを境に、暗部は暗く・明部は明るく

 このカテゴリの描画モードは、基本的に50%グレーを境に暗部をより暗く・明部をより明るくするために、コントラストが上がる傾向にあります。

  • オーバーレイ
  • ソフトライト
  • ハードライト
  • リニアライト
  • ビビッドライト
  • ピンライト
  • ハードミックス

 もっと言うと、これまで見てきた描画モードを組み合わせて「明部では加算系の描画モード、暗部では減算系の描画モード」という計算を行うものが多いです。具体的に見ていきましょう。

2.4.1 オーバーレイ Overlay

def Overlay(bg_img, fg_img):
    result = np.zeros(bg_img.shape)
    
    darker = bg_img < 0.5
    bg_inverse = 1 - bg_img
    fg_inverse = 1 - fg_img
    
    result[darker] = bg_img[darker] * fg_img[darker] * 2
    result[~darker] = 1 - bg_inverse[~darker] * fg_inverse[~darker] * 2
    
    return result


overlay = BlendMode(Overlay)
overlay.blend(wiz,sozai_grad)

f:id:Optie_f:20180315194434p:plain

 オーバーレイ Overlay は、「背景の暗部では乗算、背景の明部ではスクリーン」という演算を行います。

${RGB} = \left\{ \begin{array}{l} 2 ( {RGB}_1 \times {RGB}_2) & ({RGB}_2 < 0.5) \\ 1 - 2 (1 - {RGB}_1 ) ( 1 - {RGB}_2) & (otherwise) \\ \end{array} \right. $

 2をかけているのは、明部と暗部でそれぞれ区間幅が0.5であるための正規化です。

2.4.2 ソフトライト Soft Light

def SoftLight(bg_img, fg_img):
    result = np.zeros(bg_img.shape)
    
    darker = fg_img < 0.5
    
    result[darker] = 2 * fg_img[darker] * bg_img[darker] + 2 * (0.5 - fg_img[darker]) * np.square(bg_img[darker])
    result[~darker] = 2 * (1 - fg_img[~darker]) * bg_img[~darker] + 2 * (fg_img[~darker] - 0.5) * np.sqrt(bg_img[~darker])
    
    return result


softlight = BlendMode(SoftLight)
softlight.blend(wiz,sozai_grad)

f:id:Optie_f:20180315194429p:plain

 ソフトライト Soft Light は若干ややこしく、またアプリケーションごとに微妙に異なるようなのですが、Photoshopの場合は、「前景の画素値に応じて、背景にチャンネル単位のガンマ補正を行う」という処理が行われているようです。

${RGB} = \left\{ \begin{array}{l} 2 \times {RGB}_1 \times {RGB}_2 + 2 \times (0.5-{RGB}_1) \times {RGB}_2^2 & ({RGB}_1 < 0.5) \\ 2 \times (1 - {RGB}_1) \times {RGB}_2 + 2 \times ({RGB}_1-0.5) \times {RGB}_2^\frac{1}{2} & (otherwise) \\ \end{array} \right. $

まず、$ RGB_2^{2} $, $ RGB_2^\frac{1}{2} $ は、それぞれガンマ補正とみなすことができます。

ガンマ補正とは、(出力) = ${RGB}^\gamma$ とする変換で、$\gamma < 1$ のときは階調が全体的に明るくなり、$\gamma > 1$ のときは全体的に暗くなるものでした。($0\leq{RGB}\leq1$ であるため)

それを踏まえて、上の式を日本語で直感的に書くと、

背景 $ RGB_2 $ に対するガンマ補正 ${RGB}^\gamma$を考える。
${RGB}_1 \leq 0.5$ のとき、$\gamma = 2$ (暗) と $\gamma = 1$ の間を、${RGB}_1$の値で内分する。
${RGB}_1 \geq 0.5$ のとき、$\gamma = \frac{1}{2}$ (明) と$\gamma = 1$ の間を、${RGB}_1$の値で内分する。
ただし、${RGB}_1 = 0.5$ のときには ${RGB} = {RGB}_2$ で連続的に繋がるように、内分の比をそれぞれ $0.5$ ずらして調整する。

ということになると思います。

そう考えると、この描画モードの特徴は、

最大でも$RGB_2^\frac{1}{2}$まで、最低でも$RGB_2^2$までに値が収まる。

ということが言えそうです。したがって、他の描画モードと比べると穏やかな結果になるということですね。

2.4.3 ハードライト Hard Light

def HardLight(bg_img, fg_img):
    result = np.zeros(bg_img.shape)
    
    darker = fg_img < 0.5
    bg_inverse = 1 - bg_img
    fg_inverse = 1 - fg_img
    
    result[darker] = 2 * bg_img[darker] * fg_img[darker]
    result[~darker] = 1 - 2 * bg_inverse[~darker] * fg_inverse[~darker]
    
    return result


hadlight = BlendMode(HardLight)
hadlight.blend(wiz,sozai_grad)

f:id:Optie_f:20180315201914p:plain

 ハードライト Hard Light は、オーバーレイで前景と背景を入れ替えた計算を行います。つまり、オーバーレイは背景の明暗で場合分けをしていましたが、ハードライトでは前景の明暗で場合分けで行うということです。

${RGB} = \left\{ \begin{array}{l} 2 ( {RGB}_1 \times {RGB}_2) & ({RGB}_1 < 0.5) \\ 1 - 2 (1 - {RGB}_1 ) ( 1 - {RGB}_2) & (otherwise) \\ \end{array} \right. $

というわけで、上の例でレイヤーを入れ替えてみると、

hadlight.blend(sozai_grad, wiz)

f:id:Optie_f:20180315201957p:plain

先ほどのオーバーレイと同じ結果が出てきました。

2.4.4 ビビッドライト Vivid Light

def VividLight(bg_img, fg_img):
    result = np.zeros(bg_img.shape)
    
    darker = fg_img < 0.5
    
    result[darker] = ColorBurn(bg_img[darker], 2*fg_img[darker])
    result[~darker] = ColorDodge(bg_img[~darker], 2*(fg_img[~darker]-0.5))
    
    return result


vividlight = BlendMode(VividLight)
vividlight.blend(wiz,sozai_noise)

f:id:Optie_f:20180315194436p:plain

 ビビッドライト VividLight は、「前景の暗部では焼き込みカラー、前景の明部では覆い焼きカラー」という演算を行います。

${RGB} = \left\{ \begin{array}{l} 1-\dfrac{(1-RGB_2)}{2 \times {RGB}_1} & ({RGB}_1 < 0.5) \\ \dfrac{RGB_2}{\big(1-2 ( RGB_1 -0.5 ) \big)} & (otherwise) \\ \end{array} \right. $

「除算の性質上、暗部だけ強く乗る焼き込みカラー/明部だけ強く残る覆い焼きカラー」の組み合わせによって、極めてコントラストが高くなります。

2.4.5 リニアライト Linear Light

def LinearLight(bg_img, fg_img):
    result = np.zeros(bg_img.shape)
    
    darker = fg_img < 0.5
    
    result[darker] = LinearBurn(bg_img[darker], 2*fg_img[darker])
    result[~darker] = Add(bg_img[~darker], 2*(fg_img[~darker]-0.5))
    
    return result


linearlight = BlendMode(LinearLight)
linearlight.blend(wiz,sozai_noise)

f:id:Optie_f:20180315194443p:plain

 リニアライト Linear Light は、「前景の暗部では焼き込みリニア、前景の明部では覆い焼きリニア」という演算を行います。

${RGB} = \left\{ \begin{array}{l} 1-\big( (1-RGB_2) + (1 - 2 \times {RGB}_1) \big) & ({RGB}_1 < 0.5) \\ RGB_2 + 2( RGB_1 -0.5 ) & (otherwise) \\ \end{array} \right. $

焼き込みリニアは「反転して加算」、覆い焼きリニアはそのまま加算でした。 前景の明部と暗部が、背景にそれぞれ足しこまれるという感じですね。

2.4.6 ピンライト Pin Light

def PinLight(bg_img, fg_img):
    result = np.zeros(bg_img.shape)
    
    darker = fg_img < 0.5
    
    result[darker] = Darken(bg_img[darker], 2*fg_img[darker])
    result[~darker] = Lighten(bg_img[~darker], 2*(fg_img[~darker]-0.5))
    
    return result


pinlight = BlendMode(PinLight)
pinlight.blend(wiz,sozai_noise)

f:id:Optie_f:20180315194441p:plain

 ピンライト Pin Light は、「前景の暗部では比較(暗)、前景の明部では比較(明)」という演算を行います。

${RGB} = \left\{ \begin{array}{l} min( RGB_2, 2 \times {RGB}_1 ) & ({RGB}_1 < 0.5) \\ max\big( RGB_2, 2( RGB_1 -0.5 )\big) & (otherwise) \\ \end{array} \right. $

前景または背景の値が残るため、どぎつくはなりにくい感じでしょうか。。。

2.4.7 ハードミックス Hard Mix

def HardMix(bg_img, fg_img):
    result = np.zeros(bg_img.shape)
    
    vivid = VividLight(bg_img, fg_img)
    
    darker = vivid < 0.5
    
    result[darker] = 0
    result[~darker] = 1
    
    return result


hardmix = BlendMode(HardMix)
hardmix.blend(wiz,sozai_noise)

f:id:Optie_f:20180315194442p:plain

 ハードミックス HardMix は、ビビッドライトの演算結果をチャンネルごとに二値化します。

${RGB} = \left\{ \begin{array}{l} 0 & \big( VividLight({RGB}_2, {RGB}_1) < 0.5\big) \\ 1 & (otherwise) \\ \end{array} \right. $

結果、最大でも8色になることが著しい特徴です。

2.5. 「その他」族 〜 差を使ったり、その他

その他です。あまり普段は使わないものが多いです。

  • 除外
  • 減算
  • 除算

2.5.1 差 Difference

def Difference(bg_img, fg_img):
    result = np.zeros(bg_img.shape)
    
    result = np.absolute(bg_img - fg_img)
    
    return result


difference = BlendMode(Difference)
difference.blend(wiz,sozai_noise)

f:id:Optie_f:20180315194448p:plain

Difference は、前景と背景の値の差をとります。

$ {RGB} = abs({RGB}_2 - {RGB}_1) $

absは絶対値のことです。差ですね……距離と言い換えてもいいかもしれません。(マンハッタン距離ですね)

2枚の画像が完全に同じであるかを、差で重ねて完全に黒くなるかどうかで判定するという使い方があります。

2.5.2 除外 Exclusion

def Exclusion(bg_img, fg_img):
    result = np.zeros(bg_img.shape)
    
    result = bg_img + fg_img - 2 * bg_img * fg_img
    
    return result


exclusion = BlendMode(Exclusion)
exclusion.blend(wiz,sozai_noise)

f:id:Optie_f:20180315194457p:plain

除外 Difference は、前景と背景の相加平均と相乗平均の差をとって2倍するような演算をします。

$ {RGB} = 2 \times \big( \dfrac{{RGB}_2 + {RGB}_1}{2} - {RGB}_2 \times {RGB}_1 \big) $

参考までに、相加平均・相乗平均について以下に記します。

相加平均: $\dfrac{A+B}{2}$

相乗平均: $\sqrt{AB}$

また、

大小関係: $\dfrac{A+B}{2} \geq \sqrt{AB}$ (等しくなるのは $A=B$ のとき)

であり、「相加平均 - 相乗平均」は$A=B$のときに$0$となるので、相加・相乗の差は、 $A$ と $B$ との"距離"を別の形で表現したものと見ることができそうです。
(「相加 - 相乗」は三角不等式を満たさないので、厳密には 「距離」 と呼べませんが……) 上の式では√がついてないので、実際には相乗"平均"でもないのですが……それはそうとして、上の結果を先ほどの「差」と比較して見てみると、こちらも二枚のRGB値の間の "距離" が、また別の形で結果に現れているとみることはできそうです。

相乗平均でないために $A=B$ のときに $0$ となってくれるわけではないので、画像の同一性判定には使えません。

2.5.3 減算 Substract

def Substract(bg_img, fg_img):
    result = np.zeros(bg_img.shape)
    
    result = bg_img - fg_img
    result[result<0] =  0
    
    return result


substract = BlendMode(Substract)
substract.blend(wiz,sozai_noise)

f:id:Optie_f:20180315194459p:plain

減算 Substract は、背景から前景を引き算します。

$ {RGB} = {RGB}_2 - {RGB}_1 $
(ただし、0を下回った値は0)

ノイズが赤っぽいので、赤成分が引かれて緑っぽくなっています。

2.5.4 除算 Division

def Division(bg_img, fg_img):
    result = np.zeros(bg_img.shape)
    
    non_zero = fg_img!=0
    
    result[non_zero] = bg_img[non_zero]/fg_img[non_zero]
    result[~non_zero] = 1
    
    return result


division = BlendMode(Division)
division.blend(wiz,sozai_noise)

f:id:Optie_f:20180315194502p:plain

除算 Division は、前景で背景を割ります。

$ {RGB} = \dfrac{{RGB}_2}{{RGB}_1} $

割り算は分子・分母によって値が急激に変わったり、あっさり1を超えたりするので扱いが難しいですね……。

2.6 「HSL」族 (失敗)

このカテゴリでは、RGBではなく 色相/彩度/輝度 に関わる合成を行うのですが、輝度の扱いがHSVのVなどとは異なる(人間の分光比視感度を考慮した)もののようで、色空間の変換がすぐにはできず、今回再現に成功しませんでした

  • 色相
  • 彩度
  • カラー
  • 輝度

一応参考までに、書いたコードと結果および、AfterEffectsでの結果を併記しながら、以下に見ていきます。

2.6.1 色相 Hue

def Hue(bg_img, fg_img):
    result = np.zeros(bg_img.shape)
    
    fg_img = (fg_img*255).astype('uint8')
    bg_img = (bg_img*255).astype('uint8')
    
    bg_hsv = cv2.cvtColor(bg_img, cv2.COLOR_RGB2HSV)
    fg_hsv = cv2.cvtColor(fg_img, cv2.COLOR_RGB2HSV)
    
    bg_hsv[:,:,0] = fg_hsv[:,:,0]
    
    img_rgb = cv2.cvtColor(bg_hsv, cv2.COLOR_HSV2RGB)
    
    result = img_rgb/255
    
    return result

hue = BlendMode(Hue)
hue.blend(wiz,sozai_noise)

f:id:Optie_f:20180315194506p:plain

下のレイヤーの輝度と彩度を維持したまま、上のレイヤーの色相だけそのまま移します。

HSVでは色相ごとの輝度の違いは考慮されていないので、HSV系のエフェクトで色相を動かすよりこちらの方が良さそうな感じがします。

AfterEffectsでの結果 : f:id:Optie_f:20180315194527j:plain

2.6.2 彩度 Saturation

def Saturation(bg_img, fg_img):
    result = np.zeros(bg_img.shape)
    
    fg_img = (fg_img*255).astype('uint8')
    bg_img = (bg_img*255).astype('uint8')

    bg_hsv = cv2.cvtColor(bg_img, cv2.COLOR_RGB2HSV)
    fg_hsv = cv2.cvtColor(fg_img, cv2.COLOR_RGB2HSV)
    
    bg_hsv[:,:,1] = fg_hsv[:,:,1]
    
    img_rgb = cv2.cvtColor(bg_hsv, cv2.COLOR_HSV2RGB)
    
    result = img_rgb/255
    return result

saturation = BlendMode(Saturation)
saturation.blend(wiz,sozai_noise)

f:id:Optie_f:20180315194508p:plain

下のレイヤーの輝度と色相を維持したまま、上のレイヤーの彩度だけそのまま移します。

実際的には、情報量を増やすために彩度にムラをつけるというような用途になるでしょうか。

上の結果を見ると、100%白の場合は本来彩度がないために?ややバグっているように見えます。

AfterEffectsでの結果 : f:id:Optie_f:20180315194538j:plain

2.6.3 カラー Color

def Saturation(bg_img, fg_img):
    result = np.zeros(bg_img.shape)
    
    fg_img = (fg_img*255).astype('uint8')
    bg_img = (bg_img*255).astype('uint8')
    
    
    bg_hsv = cv2.cvtColor(bg_img, cv2.COLOR_RGB2HSV_FULL)
    fg_hsv = cv2.cvtColor(fg_img, cv2.COLOR_RGB2HSV_FULL)
    
    bg_hsv[:,:,:2] = fg_hsv[:,:,:2]
    
    img_rgb = cv2.cvtColor(bg_hsv, cv2.COLOR_HSV2RGB_FULL)
    
    result = img_rgb/255
    return result

saturation = BlendMode(Saturation)
saturation.blend(wiz,sozai_noise)

f:id:Optie_f:20180315194511p:plain

下のレイヤーの輝度を維持したまま、上のレイヤーの色相と彩度をそのまま移します。

イラストなどを描くときに使えそうな感じでしょうか。

AfterEffectsでの結果 :

f:id:Optie_f:20180315194522j:plain

2.6.4 輝度 luminosity

def Saturation(bg_img, fg_img):
    result = np.zeros(bg_img.shape)
    
    fg_img = (fg_img*255).astype('uint8')
    bg_img = (bg_img*255).astype('uint8')
    
    
    bg_hsv = cv2.cvtColor(bg_img, cv2.COLOR_RGB2HSV_FULL)
    fg_hsv = cv2.cvtColor(fg_img, cv2.COLOR_RGB2HSV_FULL)
    
    bg_hsv[:,:,2] = fg_hsv[:,:,2]
    
    img_rgb = cv2.cvtColor(bg_hsv, cv2.COLOR_HSV2RGB_FULL)
    
    result = img_rgb/255
    return result

saturation = BlendMode(Saturation)
saturation.blend(wiz, sozai_noise)

f:id:Optie_f:20180315194515p:plain

下のレイヤーの色相・彩度を維持したまま、上のレイヤーの輝度をそのまま移します。

あまり使いどころがなさそうな感じがしますが、上下を入れ替えると「カラー」と同じになります。

AfterEffectsでの結果 :

f:id:Optie_f:20180315194532j:plain

3. おわりに

3.1 まとめ

今回は、自分で実装することを通じて、普段利用している描画モードについて理解を深めることに成功しました。 また、ブログという形でこうして書くことで、各描画モードをそれぞれ個別に考察する機会ができたのがよかったです。

また、描画モードは数が多く見えますが、処理的には対応関係にあるものが多いということがわかりました。 そこで、描画モード間の関係を以下のような表に整理してみましたので、参考までに。

f:id:Optie_f:20180315194543p:plain

3.2 課題

色相/彩度/輝度について調べ直したところ、RGBからの変換方式によってHSLやHSVなどと色々異なるモデルが存在することがわかりました。 今回はもう体力がないので、このあたりの考察は今後の課題とします。

https://en.wikipedia.org/wiki/HSL_and_HSV#Hue_and_chroma

4. 参考文献