LoginSignup
2
6

More than 1 year has passed since last update.

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

Posted at

はじめに

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

元画像はカラーコーンとマッチョ(縦型写真)(を縮小したもの)とする。

元画像 macho.jpg
macho.jpg

加工いろいろ

減色

減色にはいろいろな方法があるが、ここではかの有名な画像処理100本ノックを改変した独自関数を使う。

減色
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 gensyoku2.png

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

元画像 減色
macho.jpg img_gensyoku.png

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

ぼかし

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

ガウシアンぼかし
def blur(image, k=5):
    return cv2.GaussianBlur(image, (k,k), 0)
元画像 元画像を平滑化
macho.jpg img_blur.png
減色して平滑化 平滑化して減色
img_gensyoku_blur.png img_blur_gensyoku.png

エッジ検出

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

  cv2.Canny(): Canny法によるエッジ検出の自動化

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

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 img_blur_canny.png

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

膨張・収縮

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

# 膨張
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 img_blur_canny_dilate.png

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

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

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

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

平滑化前 平滑化後
img_blur_canny_dilate.png img_blur_canny_dilate_blur.png

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

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

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

細いエッジ

エッジ エッジを合成
img_blur_canny.png result1.png

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

太いエッジ

エッジ エッジを合成
img_blur_canny_dilate.png result2.png

これは迫力がある。

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

エッジ エッジを合成
img_blur_canny_dilate_blur.png result3.png

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

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

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

エッジ エッジを合成
img_blur_canny_dilate_blur.png result4.png

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

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

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

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

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

# 正しく動く
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 result5.png

ソース

折りたたみ
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()

終わりに

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

うる星やつら
少年サンデーコミックス(旧版)12巻
urusei.jpg
amazonより
2
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
6