OpenCVでトゥーンな加工をする

# はじめに
ある日突然、漫画のような画作りをしたくなった。
理由はない。これが人生というものなのかもしれない。

元画像は[**カラーコーンとマッチョ(縦型写真)**](https://freephotomuscle.com/archives/photo/7838)（を縮小したもの）とする。

|元画像　macho.jpg|
|:-:|
|![macho.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/520216/cb8e308d-ad54-9b0e-bd92-22715c14c970.jpeg)|


# 加工いろいろ
## 減色

減色にはいろいろな方法があるが、ここではかの有名な[画像処理100本ノック](https://github.com/yoyoyo-yo/Gasyori100knock)を改変した独自関数を使う。

```Python:減色
def color_subtraction(image, div=4):
    th1 = 256 / div
    th2 = 256 / (div-1)
    return np.clip(image // th1 * th2 , 0, 255).astype(np.uint8)

# こちらが画像処理100本ノック版
def color_subtraction_100knock(image, div=4):
    th = 256 // div
    return np.clip(image// th * th + th // 2, 0, 255)
```
画像処理100本ノックの減色関数との違いは以下の画像を見ていただければわかると思う。減色されても輝度0や輝度255が存在するという特徴がある。

|画像処理100本ノック|我が減色関数|
|:-:|:-:|
|![gensyoku1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/520216/4bff5bb9-b6c2-c3f0-51c0-45ae87373b2d.png)|![gensyoku2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/520216/08125043-0132-20b2-0ab2-fd3b26f5f58f.png)|

これを画像に食わせるとこうなる。

|元画像|減色|
|:-:|:-:|
|![macho.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/520216/cb8e308d-ad54-9b0e-bd92-22715c14c970.jpeg)|![img_gensyoku.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/520216/8de4b8b7-8685-d864-242a-d68255e3d083.png)|

これだけで十分な気がしてきた。

## ぼかし
加工の前にぼかし処理をすることが多い。画像を平滑化し、細々としたノイズを取り除くのだ。
`cv2.GaussianBlur()`の第2引数はカーネルサイズで正の奇数を取る。第3引数と第4引数はX方向およびY方向のガウシアンの標準偏差で0でも可。また、第3引数を0にすれば第4引数は省略可。


```Python:ガウシアンぼかし
def blur(image, k=5):
    return cv2.GaussianBlur(image, (k,k), 0)
```

|元画像|元画像を平滑化|
|:-:|:-:|
|![macho.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/520216/cb8e308d-ad54-9b0e-bd92-22715c14c970.jpeg)|![img_blur.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/520216/6d3abe3a-1181-4ea8-83f2-524414d8c74d.png)|
|**減色して平滑化**|**平滑化して減色**|
|![img_gensyoku_blur.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/520216/4ea1957d-8d4a-3a5e-36d0-9e9f2373344c.png)|![img_blur_gensyoku.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/520216/7783c248-94a5-4f7a-2cab-afd64432b3f2.png)|


## エッジ検出
エッジ検出にも様々な方法がある。ここでは`cv2.Canny()`を使う。
`cv2.Canny()`には二つの閾値を指定する必要があるが、自動でいい感じに設定する方法があるのでそれに従う。わかりやすい解説もあるのでそちらに目を通していただければ。

　　[cv2.Canny(): Canny法によるエッジ検出の自動化](https://qiita.com/kotai2003/items/662c33c15915f2a8517e)

`cv2.Canny()`は白黒二値のグレースケール画像を返す。ここでは再利用しやすいようにカラー画像として返すようにしている。

```Python:
def auto_canny(image):
    sigma = 0.33
    med = np.median(image)
    th1 = int(max(0, (1-sigma)*med))
    th2 = int(max(255, (1+sigma)*med))
    canny = cv2.Canny(image, th1, th2)
    canny = cv2.cvtColor(canny, cv2.COLOR_GRAY2BGR)
    return canny
```

|元画像をエッジ検出|平滑化画像をエッジ検出|
|:-:|:-:|
|![img_canny.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/520216/e0994be4-51b5-fa1b-7ef4-658771db11cd.png)|![img_blur_canny.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/520216/932dc4e5-1e9b-2b56-bacc-5e0c0a467385.png)|

腕や足の指など複雑な色変化の部分が平滑化されたことによってエッジ検出されなくなったことがわかる。


## 膨張・収縮
ここでは線を太く強調するために膨張を使う。膨張→収縮や収縮→膨張とやってノイズを除去するというのが主たる使い方。
輝度の計算なので白に対して膨張・収縮の言葉を使う。白を膨張することで白オブジェクト内の黒いノイズを除去、白を収縮することで黒地にある白いノイズを除去、というわけ。
今回はエッジ検出結果（黒地に白線）に対しておこなうのでそのまま使うことができるが、場合によってはネガポジ反転する必要が出てくるかもしれない。

```Python:
# 膨張
def dilate(image, k=3):
    kernel = np.ones((k,k), np.uint8)
    return cv2.dilate(image, kernel)

# 収縮
def erode(image, k=3):
    kernel = np.ones((k,k), np.uint8)
    return cv2.erode(image, kernel)
```

|膨張前|膨張後|
|:-:|:-:|
|![img_blur_canny.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/520216/932dc4e5-1e9b-2b56-bacc-5e0c0a467385.png)|![img_blur_canny_dilate.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/520216/b7f3e2e5-a85c-1329-3db0-b1d64587e5ca.png)|


## より一般的なフィルタリング

膨張のところではカーネルを`kernel = np.ones((k,k), np.uint8)`と定めた。ガウシアンぼかしではカーネルのサイズを指定した。カーネルとは元画像の各画素に対して計算する行列のこと。これを理解すれば畳み込みニューラルネットワークの理解にもつながる。

自分でカーネルを指定してフィルタリングするには`cv2.filter2D()`を使う。
ググると、エンボス加工で左上から光が当たっているように見える画像処理や横方向にのみぼかしがあってスピード感あふれる画像処理を見つけることができるが、そういったカーネルを使っているわけだ。

今回、線を手描き風にするために「太い線の中央部分は輝度を高く、端の部分は輝度を低くする」カーネルを自作しようと思ったのだがうまくいかなかった。その後、私が作ろうとしていたのは普通のぼかし処理であることに気づいた。

|平滑化前|平滑化後|
|:-:|:-:|
|![img_blur_canny_dilate.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/520216/b7f3e2e5-a85c-1329-3db0-b1d64587e5ca.png)|![img_blur_canny_dilate_blur.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/520216/f2083388-fbbe-caa1-b6ca-1afb1228dfc0.png)|


# エッジを線として画像と合成する

このようにして得られた線画と減色画像を合成させればトゥーンな画像を作ることができるのではないだろうか。
条件によって画像を合成するには`numpy.where()`を使う。[スプライト関数](https://qiita.com/mo256man/items/f7524dd34718a01fb3df)でも使ったな。

```Python:基本的な考え方
# cannyは黒地に白線なので出力時にネガポジ反転している
result = np.where(canny==255, 255-canny, image)
```


### 細いエッジ
|エッジ|エッジを合成|
|:-:|:-:|
|![img_blur_canny.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/520216/932dc4e5-1e9b-2b56-bacc-5e0c0a467385.png)|![result1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/520216/bdd54690-4f7a-3685-4800-17236216cc8d.png)|

三角コーンの「マッチョ」の文字に主線が入っているといかにも漫画っぽくなるな。


### 太いエッジ
|エッジ|エッジを合成|
|:-:|:-:|
|![img_blur_canny_dilate.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/520216/b7f3e2e5-a85c-1329-3db0-b1d64587e5ca.png)|![result2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/520216/cd4573df-aca8-ed2a-b237-23203c96d07c.png)|

これは迫力がある。


### 太いエッジをぼかす その1

|エッジ|エッジを合成|
|:-:|:-:|
|![img_blur_canny_dilate_blur.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/520216/f2083388-fbbe-caa1-b6ca-1afb1228dfc0.png)|![result3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/520216/162576bf-9e3a-7f94-2a5f-880ddd42b2ff.png)|

ぼかしを入れたエッジでは輝度=255の領域がごくわずかなため、期待外れの結果となってしまった。


### 太いエッジをぼかす その2

`np.where(canny==255, 255-canny, image)`ではなく`np.where(canny>0, 255-canny, image)`としてみた。

|エッジ|エッジを合成|
|:-:|:-:|
|![img_blur_canny_dilate_blur.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/520216/f2083388-fbbe-caa1-b6ca-1afb1228dfc0.png)|![result4.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/520216/40608cc0-b4d4-d903-f242-d5737b22e972.png)|

これも望んだ状態とは違う。エッジの黒や灰色を元画像と合成する色と考えてはいけない。元画像をどれだけ黒く（暗く）するかの値として考える必要がある。

## 任意の割合で暗くする関数

グレースケール画像を輝度として計算するにあたり次のような考え方をする。

　　x(元の色) × 255(白) / 255 = x(元の色)
　　x(元の色) × 0(黒) / 255 = 0(黒)
　　x(元の色) × y(灰色) / 255 = z(元の色より暗い色)

この方法では元の色より明るくすることができないが今回はこれで良しとする。色空間をHSVに変換してごちゃごちゃやるの好きじゃないのよね。
同サイズの二つの行列の要素ごとの積から成る行列をアダマール積というが、numpyでこの計算をするには単に`A * B`とすればよい。
計算順序も重要だ。OpenCVで画像として認識されるnumpy配列は`dtype=np.uint8`。`uint8`にいろいろな計算をして8ビットを超えてしまったり小数部分がキャンセルされてしまっては正しい結果にならない。それを避けるにはいったん浮動小数点で計算して最後に`uint8`に戻せばよい。

```Python
# 正しく動く
def darken1(image, edge):
    result = 1 / 256.0 * image * edge   # float64
    return result.astype(np.uint8)      # uint8にして返す

# 正しく動く
def darken2(image, edge):
    result = image / 256.0 * edge       # float64
    return result.astype(np.uint8)      # uint8にして返す

# 正しく動かない
def darken3(image, edge):
    result = image * edge / 256.0
    # 最終結果はfloat64だが、image*edgeがuint8なのでこの時点で正しくない
    return result.astype(np.uint8)

# 正しく動く
def darken4(image, edge):
    result = image * edge.astype(np.float16) / 256.0
    # どちらか片方でもfloatにすると計算結果がfloatになる
    return result.astype(np.uint8)      # uint8にして返す
```

これにより白黒二値画像だけでなく灰色を持つ平滑化エッジも合成することができようになった。

|エッジ（ネガポジ反転）|エッジを合成|
|:-:|:-:|
|![img_blur_canny_dilate_blur_np.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/520216/cb8ca925-d1b4-717a-ebd2-f646b97e7637.png)|![result5.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/520216/5be64322-8ba3-030b-a72a-69f3d045c222.png)|

# ソース

<details><summary>折りたたみ</summary>


```Python:
import cv2
import numpy as np

def color_subtraction(image, div=4):
    th1 = 256 / div
    th2 = 256 / (div-1)
    return np.clip(image // th1 * th2 , 0, 255).astype(np.uint8)

def blur(image, k=5):
    return cv2.GaussianBlur(image, (k,k), 0)

def auto_canny(image):
    sigma = 0.33
    med = np.median(image)
    th1 = int(max(0, (1-sigma)*med))
    th2 = int(max(255, (1+sigma)*med))
    result = cv2.Canny(image, th1, th2)
    return cv2.cvtColor(result, cv2.COLOR_GRAY2BGR)

def dilate(image, k=3):
    kernel = np.ones((k,k),np.uint8)
    return cv2.dilate(image, kernel)

def erode(image, k=3):
    kernel = np.ones((k,k),np.uint8)
    return cv2.erode(image, kernel)

def darken(image, edge):
    result = 1 / 256.0 * image * edge   # float64
    return result.astype(np.uint8)      # uint8にして返す


filename = "macho.jpg"
img = cv2.imread(filename)

# ベース画像　平滑化して減色する
img_blur = blur(img)
img_blur_subtract = color_subtraction(img_blur)

# エッジ画像　平滑化してエッジ検出し膨張させさらに平滑化する
img_blur_canny = auto_canny(img_blur)
img_blur_canny_dilate = dilate(img_blur_canny)
img_blur_canny_dilate_blur = blur(img_blur_canny_dilate)

toon = darken(img_blur_subtract, 255-img_blur_canny_dilate_blur)
cv2.imshow("toon", toon)
cv2.waitKey(0)
cv2.destroyAllWindows()
```
</details>


# 終わりに

本当は[大神](https://www.capcom.co.jp/o-kami/)のように筆でシュッと描いたような筆致にしたかったのだが、それは叶わなかった。
それでもこのサインペンで描いたようなタッチ、往年の高橋留美子に似てなくもないと言えないこともないのでは…すみませんすみません、石を投げないでください。

|うる星やつら<br>少年サンデーコミックス（旧版）12巻|
|:-:|
|![urusei.jpg](https://images-na.ssl-images-amazon.com/images/I/51v5LT+62+L._SX321_BO1,204,203,200_.jpg)<br>amazonより|