CSS3ブレンドモードのPython実装と色空間の話

# CSS3のブレンドモードとは？

Photoshopなどの画像編集ツールに搭載されている「ブレンドモード（描画モード）」機能ですが、CSS3でも`mix-blend-mode`というプロパティで実装されています。
CSS3なので当然各ブラウザ（IE以外）で見た目を統一する必要があるので、W3Cの仕様に各ブレンドモードの計算式が載っています。

[Compositing and Blending](https://www.w3.org/TR/compositing-1/)
[Compositing and Blending 日本語訳](https://triple-underscore.github.io/compositing-ja.html)

なお、策定にはAdobeの社員も関わっているらしく、おそらくですがPhotoshopなどのAdobe製品のブレンドモードと同じ計算式が使われているものと思われます。

# 意外と難しい"Non-separable blend"

W3Cの仕様を読み解いていくと、ブレンドモードは"Separable blend"と”Non-separable blend"の２つに分類されます。
”Non-separable blend"は、成分ごとに個別に扱わずに，すべての色成分の組合せを考慮するブレンドです。

"Separable blend"は`screen`や`overlay`など、単純な数式を使うものなので何の問題もありません。
問題は”Non-separable blend"に分類される`hue`, `saturation`, `color`, `luminosity`の4つです。


例えば`hue`モードですが、ソース画像の色相(Hue)を、背景画像の輝度を維持しつつ適用するというモードです。
下の２つのイメージを`hue`モードで合成してみましょう。

<img alt="backdrop.png" src="https://qiita-image-store.s3.amazonaws.com/0/140075/6c480904-163b-e45a-ac0c-11263a29a75b.png"> ![ducky.png](https://qiita-image-store.s3.amazonaws.com/0/140075/e1d279b2-7e08-07b3-75e9-9ee0bdc97887.png)

合成結果は以下のとおりです。

![hue.png](https://qiita-image-store.s3.amazonaws.com/0/140075/ed159d96-d9c4-6b3e-ad62-ef9c5f70c187.png)


「色相を移植するんだからHSV色空間に変換して、H成分を移植して・・・」という単純な話ではありません。
色相を移植しつつ、元画像の輝度(Luminance)を維持しなければなりません、つまり合成結果に対してモノクロ化すると以下のようにならなくてはいけないのです。

![hue.png](https://qiita-image-store.s3.amazonaws.com/0/140075/ed159d96-d9c4-6b3e-ad62-ef9c5f70c187.png)　<i class="fa fa-arrow-right" aria-hidden="true"></i> ![hue_mono.png](https://qiita-image-store.s3.amazonaws.com/0/140075/9fdff460-9f66-9841-4b84-fd5fe02d3ac1.png)

あひるが完全に消えましたね。


# 明度(Value)と明度(Lightness)と輝度(Luminance)の話

色相(Hue)を扱う色空間は、よく使われるのがHSV色空間とHSL色空間の２つです。
HSV色空間は色相(Hue)、彩度(Saturation)、明度(Value)の３で構成されます。

[HSV色空間 -Wikipedia](https://ja.wikipedia.org/wiki/HSV%E8%89%B2%E7%A9%BA%E9%96%93)


HSL色空間は色相(Hue)、彩度(Saturation)、輝度(Lightness)の３つで構成されています。

[HSL色空間 -Wikipedia](https://ja.wikipedia.org/wiki/HLS%E8%89%B2%E7%A9%BA%E9%96%93)

Wikipediaでは、HSL色空間の輝度(Lightness)と記載されていますが、これは輝度(Luminance)とは異なります。
ここらへんの単語の混乱は、いろいろなページで見られるので注意が必要です。

RGBから輝度(Luminance)へ変換する一般的な数式は以下のとおりです。

```
r * 0.298912 + g * 0.586611 + b * 0.114478
```

HSLのL(Lightness)への変換式は以下の通り。

```
(MAX(r, g, b) - MIN(r, g, b)) / 2
```

ハッキリいって全く違いますね。
Lightnessの訳は「明度」や「輝度」など資料によって訳され方がまちまちですので、混乱しないようにすることが重要です。

# SetSatの解釈について

W3Cの計算式に`SetSat`という擬似コードの関数が記載されています。

```
SetSat(C, s)
    if(Cmax > Cmin)
        Cmid = (((Cmid - Cmin) x s) / (Cmax - Cmin))
        Cmax = s
    else
        Cmid = Cmax = 0
    Cmin = 0
    return C;
```

この擬似コードを最初に見た時は、意味がわかりませんでした。
`Cmin`はRGB成分の中の最小値、`Cmax`は最大値、`Cmid`は中央値となります。

例を出すとC=RGB(0,8, 0.6, 0.3)とするなら、`Cmin`はB要素(0.3)、`Cmax`はR要素(0.8)、`Cmid`はG要素(0.6)となります。
計算式は以下のとおりになります。

```
G = (((G - B) x s) / (R - B))
R = s
B = 0.0
```

という事になります。
ちなみにRGB(0.1, 0.1, 0.5)やRGB(0.5, 0.5, 0.1)のように`Cmin`や`Cmax`が２つある場合はどうなるかは書いていません。
正解はどうなるかはわかりませんが、以下の条件さえ満たせば、値は何でも良いようです。

```
max(Cred, Cgreen, Cblue) - min(Cred, Cgreen, Cblue) == s
```

# Python(pillow)での実装

計算式がわかったので、Pythonの画像ライブラリである[pillow](https://pillow.readthedocs.io/en/4.0.x/)で実装します。pillowは実質的にPython標準の画像ライブラリと言って良いでしょう。OpenCVのように高度な機能はありませんが、様々なライブラリへデータコンバートが可能で、コンパクトで高速な画像処理が特徴です。

[PIL/Pillow チートシート](http://qiita.com/pashango2/items/145d858eff3c505c100a#_reference-35595a161656dd8d793c)で記載されているように、ブレンドモードの実装は`ImageMath`モジュールを使います。

例として`hard-light`の実装をしてみましょう。

```Python
from PIL import ImageMath


def _hard_light(a, b):
    _cl = 2 * a * b / 255
    _ch = 2.0 * (a + b - a * b / 255.0) - 255.0
    return _cl * (b < 128) + _ch * (b >= 128)

bands = []
for cb, cs in zip(backdrop.split(), source.split()):
    t = ImageMath.eval(
        "func(float(a), float(b))",
        func=_hard_light,
        a=cb, b=cs
    ).convert("L")
    bands += [t]
    
Image.merge("RGB", bands)
```

`ImageMath`モジュールを使うと、要素の計算を数値のように計算できるので便利です。
"Separable blend"のような単純な処理に関しては、こんな感じで実装ができます。

問題は"Non-separable blend"です、意外と計算量も多くトリッキーな処理もあり結構難しいです。
実装に関しては後でコードを公開していますので、興味がる方は見てもらうとして、`ImageMath`モジュールの非公開な関数を駆使して実装しています。
他言語で移植するのであれば、GLSLなどを使用すると思います、参考になれば幸いです。



# 「Image4Layer」モジュール

これらの処理を「Image4Layer」というモジュール名でパッケージ化しました。
画像処理系などで`overlay`の合成などはよく使うので、結構使いドコロはあると思います。

https://github.com/pashango2/Image4Layer

インストールはpipで簡単に行なえます、実行にはpillow(PIL)があらかじめインストールされている必要があります。

```
$pip install image4layer
```

使い方は簡単です、color-dodgeモードで合成する例です。

```python
from PIL import Image
from image4layer import Image4Layer

source = Image.open("ducky.png")
backdrop = Image.open("backdrop.png")

Image4Layer.color_dodge(backdrop, source)
```

![color_dodge.png](https://qiita-image-store.s3.amazonaws.com/0/140075/eaefde56-a08a-01ff-4777-14bc7d78d0c1.png)


簡単ですね、以下、対応しているブレンドモード一覧です。

| ブレンドモード | 画像 |
|:-:|:-:|
| Image4Layer.normal | ![normal.png](https://qiita-image-store.s3.amazonaws.com/0/140075/b49f5c7e-8b76-e0f7-15b9-e7b5b69fd737.png) |
| Image4Layer.multiply | ![multiply.png](https://qiita-image-store.s3.amazonaws.com/0/140075/fa4ba7bd-ddf9-ac37-6b19-284cf570c02e.png) |
| Image4Layer.screen  | ![screen.png](https://qiita-image-store.s3.amazonaws.com/0/140075/5b9625e3-31e5-2ec5-01fe-5867ccad46d7.png) |
| Image4Layer.overlay  | ![overlay.png](https://qiita-image-store.s3.amazonaws.com/0/140075/21b544d1-7909-22e4-c1a6-d4613b664007.png) |
| Image4Layer.darken  | ![darken.png](https://qiita-image-store.s3.amazonaws.com/0/140075/2234b21b-c846-7163-becd-ce0ae09e19e8.png) |
| Image4Layer.lighten | ![lighten.png](https://qiita-image-store.s3.amazonaws.com/0/140075/3832f80b-cb75-d4be-a202-be37350b6830.png) |
| Image4Layer.color_dodge  | ![color_dodge.png](https://qiita-image-store.s3.amazonaws.com/0/140075/eaefde56-a08a-01ff-4777-14bc7d78d0c1.png) |
| Image4Layer.color_burn | ![color_burn.png](https://qiita-image-store.s3.amazonaws.com/0/140075/7081d766-8ca3-7cf7-b964-a016fe24db8a.png) |
| Image4Layer.hard_light | ![hard_light.png](https://qiita-image-store.s3.amazonaws.com/0/140075/c5aba540-43fc-ca90-64d1-17e73462bcf6.png) |
| Image4Layer.soft_light | ![soft_light.png](https://qiita-image-store.s3.amazonaws.com/0/140075/416fc1a0-8678-ce9f-cac4-ff3521c0039f.png) |
| Image4Layer.difference | ![diff.png](https://qiita-image-store.s3.amazonaws.com/0/140075/0910c1ca-7d65-0ef8-d5f6-be4bd87fce61.png) |
| Image4Layer.exclusion | ![exc.png](https://qiita-image-store.s3.amazonaws.com/0/140075/cb218465-4ce3-818e-9474-536e0e3d926e.png) |
| Image4Layer.hue | ![hue.png](https://qiita-image-store.s3.amazonaws.com/0/140075/ed159d96-d9c4-6b3e-ad62-ef9c5f70c187.png) |
| Image4Layer.saturation | ![sat.png](https://qiita-image-store.s3.amazonaws.com/0/140075/a15f42e0-d787-c2d9-b224-52dabdc5c083.png) |
| Image4Layer.color | ![color.png](https://qiita-image-store.s3.amazonaws.com/0/140075/f5e9d4c1-8bbf-31c6-0760-e57263eca97a.png) |
| Image4Layer.luminosity | ![lum.png](https://qiita-image-store.s3.amazonaws.com/0/140075/2ecc9225-9d87-5edc-1e05-76b4263063de.png) |

CSS3にはありませんが、Photoshopに搭載されているブレンドモードもついでに実装しました。

| ブレンドモード | 画像 |
|:-:|:-:|
| Image4Layer.vivid_light | ![vivid.png](https://qiita-image-store.s3.amazonaws.com/0/140075/2491896d-8133-b84a-62f4-a8460149ea3b.png) |
| Image4Layer.pin_light  | ![pin.png](https://qiita-image-store.s3.amazonaws.com/0/140075/0bc0758d-62f9-0c1a-387d-cb4f3d501361.png) |
| Image4Layer.linear_dodge | ![linear.png](https://qiita-image-store.s3.amazonaws.com/0/140075/f3856ba7-f075-4319-1b50-f2e4fb6ac636.png) |
| Image4Layer.subtract  |  ![sub.png](https://qiita-image-store.s3.amazonaws.com/0/140075/7e8f54c8-5e5d-3f8b-a48b-b9c8456e38b0.png) |

ライセンスはMITですので、商用、個人利用とわず無料で使えます。

#### 追記
Version0.4にPython2で動作しないことと、RGBA同士の演算にバグがありました、0.43で修正されています。

# あとがき

[Compositing and Blending 日本語訳](https://triple-underscore.github.io/compositing-ja.html)には、想定される描画と実際にブラウザの表示があるのですが、Chromeでアクセスすると以下のようになります。

![範囲を選択_040.png](https://qiita-image-store.s3.amazonaws.com/0/140075/dc7e2982-b9ea-b62c-deee-c9bd7c3d2dea.png)

あれ・・・？色味が結構ちがくない？
ここら辺の表示は各ブラウザによって色味がかなり違ってきそうですね。

#### 追記

Chromeのバージョンによって色味は違いそうです、私の環境はUbuntu環境で、どうやらbackdrop.pngに設定されているアルファ値の合成にバグがあるようです。

ちなみにImage4Lyaerの表示結果はChromeの結果とほぼ一緒です、おそくらく同じ実装をしているものと思います。
何か情報があれば、随時記事にしていきます。