ソースに絡まるエスカルゴ

貧弱プログラマの外部記憶装置です。

【python/OpenCV】画像を手書きっぽく加工するやつを作ってみる

 最近SNSなどでとある手書き風のアプリがリリースされちょっと話題になっています。

 iPhoneを持っていないのでどのようなアプリかはわかっていないのですが、結果の動画や画像を見る限りではOpenCVで再現できそうでした。そこでタイトルにあるように似たようなものを作れないかと思い、自分で色々調べて作ってみたのでその備忘録です。

 ちなみに自作したものは以下のような感じになります。

・加工前
f:id:rikoubou:20181115184750j:plain

・加工後
f:id:rikoubou:20181115184805p:plain

 完全に同じにはなりませんが、割と手書きな感じにはなっているかと思います。

 今回の画像加工の手順としては大体「グレースケール化→輪郭線抽出→影部分の抽出→輪郭線と影部分の合成」という順番でやっています。

 ではそれぞれについて説明していきます。読むのが面倒な場合は最後のソースコードのところまで飛ばしてください。


1:グレースケール化
 輪郭線を抽出する前段階として画像をグレースケール化します。輪郭線を抽出する前段階として一般的なものです。
 グレースケール化するには以下の「cv2.cvtColor」関数でできます。

import cv2

image = cv2.imread('hogehoge.jpg') # 画像取得
grayImage = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) # グレースケール


2:輪郭線抽出
 グレースケール化したものから輪郭線を抽出するのは「cv2.Canny」関数でできます。

outlineImage = 255 - cv2.Canny(grayImage, 110, 70)

 cv2.Cannyの第1引数がグレースケール化した画像です。第2引数と第3引数を変化させることで取得する線の調節ができます。
「255」という値から結果を引いているのは、cv2.Cannyの結果として線の部分が白、他が黒になっているためです。255から結果を引くことで色を反転させています。


3:影部分の抽出
 影部分の抽出は以下の手順でやっています。

grayImage = cv2.GaussianBlur(grayImage, (31, 31), 30) # ぼかす
ret, shadowImage = cv2.threshold(grayImage, 40, 220, cv2.THRESH_BINARY) # 閾値で2値化

 GaussianBlur関数でぼかし、そのあと2値化しています。これで影の部分が取得できます。


4:輪郭線と影部分の合成
 2つの画像ができたのであとは合成します。合成には「cv2.bitwise_and」関数を使います。

resultImg = cv2.bitwise_and(outlineImage, shadowImage)


5:ソースコード
 基本的には1〜4の手順で行い、適宜コントラストを減らしたりぼかしを加えたりなどして調節しています。
 今回作成したソースコードは以下の通りです。

 ・2018/11/19追記:画像サイズが奇数ピクセルだとエラーになっていたのでソースコードを修正しました。

・cv2Outline.py

import numpy as np
import cv2
import glob
from datetime import datetime
from time import sleep

RESIZE_SIZE = 4 # 画像を処理する時に使うサイズ(1/n)
CAMERA_SIZE = 2 # カメラ画像の縮小サイズ(1/n)

# カメラフラグでTrueにするとWebカメラの変換になる
CAMERA_FLG = False

IMG_FOLDER_PATH = "./img/*"     # 画像フォルダ
SAVE_FOLDER_PATH = "./result/"  # 出力保存フォルダ


# メイン関数
def main():
    print("--- start ---")

    # カメラの場合との場合分け
    if (CAMERA_FLG):
        # VideoCaptureのインスタンスを作成(引数でカメラを選択できる)
        cap = cv2.VideoCapture(0)
        changeCameraImage(cap)
        closeWindows(cap) # ウインドウを全て閉じる
    else:
        changeLoadImages(IMG_FOLDER_PATH, SAVE_FOLDER_PATH)

    print("--- end ---")


# カメラの映像を変換する関数
def changeCameraImage(cap):
    while True:
        ret, frame = cap.read() # 戻り値のframeがimg
        resultImg = changeImage(frame) # 画像変換

        # 結果をリサイズ
        fx = int(resultImg.shape[1]/CAMERA_SIZE)
        fy = int(resultImg.shape[0]/CAMERA_SIZE)
        resultImg = cv2.resize(resultImg, (fx, fy))

        # 文字を追加
        text = 'Exit is [Q] key'
        cv2.putText(resultImg, text, (0,30), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255,0), 3, cv2.LINE_AA)

        # 加工した画像を表示
        cv2.imshow('resultImg', resultImg)

        # キー入力を1ms待って、keyが「q」だったらbreak
        key = cv2.waitKey(1)&0xff
        if key == ord('q'):
            break


# 画像を読み込んで変換する関数
def changeLoadImages(imgFolderPath, saveFolderPath):
    images = glob.glob(imgFolderPath)

    for fname in images:
        frame = cv2.imread(fname) # 画像取得
        resultImg = changeImage(frame) # 画像変換

        # 画像保存
        saveImgByTime(saveFolderPath, resultImg)
        sleep(1)


# 画像を変換する関数
def changeImage(colorImg):
    gray = cv2.cvtColor(colorImg, cv2.COLOR_BGR2GRAY) # グレースケール
    gray = cv2.GaussianBlur(gray, (15, 15), 15) # ぼかす

    # 輪郭線処理
    img_diff = outine(gray) # 輪郭線抽出
    img_diff = cv2.GaussianBlur(img_diff, (11, 11), 8) # ぼかす
    ret, img_diff = cv2.threshold(img_diff, 170, 240, cv2.THRESH_BINARY) # 閾値で2値化

    # 影部分の処理
    gray = cv2.GaussianBlur(gray, (31, 31), 30) # ぼかす
    ret, gray = cv2.threshold(gray, 40, 220, cv2.THRESH_BINARY) # 閾値で2値化
    gray = lowContrast(gray) # コントラストを落とす
    
    # 輪郭線と影部分の画像を合成
    resultImg = cv2.bitwise_and(img_diff, gray)

    return resultImg


# 画像の輪郭線を抽出する関数
def outine(grayImg):
    # リサイズ
    fx = int(grayImg.shape[1]/RESIZE_SIZE)
    fy = int(grayImg.shape[0]/RESIZE_SIZE)

    grayChangeImg = cv2.resize(grayImg, (fx, fy))
    # 輪郭線抽出
    result = 255 - cv2.Canny(grayChangeImg, 110, 70)
    # リサイズして元に戻す
    result = cv2.resize(result, (grayImg.shape[1], grayImg.shape[0]))
    return result


# コントラストを落とす関数
def lowContrast(img):
    # ルックアップテーブルの生成
    min_table = 50
    max_table = 230
    diff_table = max_table - min_table
    look_up_table = np.arange(256, dtype = 'uint8' )
 
    for i in range(0, 255):
        look_up_table[i] = min_table + i * (diff_table) / 255
 
    # コントラストを低減
    result = cv2.LUT(img, look_up_table)
    return result


# 画像を時刻で保存する関数
def saveImgByTime(dirPath, img):
    # 時刻を取得
    date = datetime.now().strftime("%Y%m%d_%H%M%S")
    path = dirPath + date + ".png"
    cv2.imwrite(path, img) # ファイル保存


# キャプチャをリリースして、ウィンドウをすべて閉じる関数
def closeWindows(cap):
    cap.release()
    cv2.destroyAllWindows()


if __name__ == '__main__':
    main()

 少し説明すると「cv2Outline.py」として保存したファイルと同じ場所に「img」、「result」のフォルダをそれぞれ作成します。そして「img」フォルダに加工前の画像を入れて「python3 cv2Outline.py」を実行すると「result」フォルダに結果が保存されます。

 また以下の部分の「False」を「True」に書き換えて実行するとWebカメラの表示に切り替わります。

# カメラフラグでTrueにするとWebカメラの変換になる
CAMERA_FLG = False

 2018/11/19追記:また画像が小さいといい感じに輪郭線の抽出ができていないようだったため、画像サイズに合わせてある程度計算で適切に処理するようなバージョンも作りました。

・alt_cv2Outline.py

import numpy as np
import cv2
import glob
from datetime import datetime
from time import sleep

CAMERA_SIZE = 2 # カメラ画像の縮小サイズ(1/n)

BLUR_VALUE = 25000 # ブラーをかけるための定数
PIXEL_VALUE = 300000 # 拡大/縮小の基準となるピクセル

# カメラフラグでTrueにするとWebカメラの変換になる
CAMERA_FLG = False

IMG_FOLDER_PATH = "./img/*"     # 画像フォルダ
SAVE_FOLDER_PATH = "./result/"  # 出力保存フォルダ

# メイン関数
def main():
    print("--- start ---")

    # カメラの場合との場合分け
    if (CAMERA_FLG):
        # VideoCaptureのインスタンスを作成(引数でカメラを選択できる)
        cap = cv2.VideoCapture(0)
        changeCameraImage(cap)
        closeWindows(cap) # ウインドウを全て閉じる
    else:
        changeLoadImages(IMG_FOLDER_PATH, SAVE_FOLDER_PATH)

    print("--- end ---")


# カメラの映像を変換する関数
def changeCameraImage(cap):
    while True:
        ret, frame = cap.read() # 戻り値のframeがimg
        resultImg = changeImage(frame) # 画像変換

        # 結果をリサイズ
        fx = int(resultImg.shape[1]/CAMERA_SIZE)
        fy = int(resultImg.shape[0]/CAMERA_SIZE)
        resultImg = cv2.resize(resultImg, (fx, fy))

        # 文字を追加
        text = 'Exit is [Q] key'
        cv2.putText(resultImg, text, (0,30), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255,0), 3, cv2.LINE_AA)

        # 加工した画像を表示
        cv2.imshow('resultImg', resultImg)

        # キー入力を1ms待って、keyが「q」だったらbreak
        key = cv2.waitKey(1)&0xff
        if key == ord('q'):
            break


# 画像を読み込んで変換する関数
def changeLoadImages(imgFolderPath, saveFolderPath):
    images = glob.glob(imgFolderPath)

    for fname in images:
        frame = cv2.imread(fname) # 画像取得
        resultImg = changeImage(frame) # 画像変換

        # 画像保存
        saveImgByTime(saveFolderPath, resultImg)
        sleep(1)


# 画像を変換する関数
def changeImage(colorImg):
    gray = cv2.cvtColor(colorImg, cv2.COLOR_BGR2GRAY) # グレースケール

    # ピクセル数からぼかす値を計算
    allPixel = colorImg.shape[1] * colorImg.shape[0]
    bokashi = calcBlurValue(allPixel)
    gray = cv2.GaussianBlur(gray, (bokashi, bokashi), bokashi) # ぼかす

    # 輪郭線処理
    img_diff = outine(gray, allPixel) # 輪郭線抽出

    # 輪郭線用のぼかし計算
    bokashiOutline = bokashi
    if bokashi > 4:
        bokashiOutline = bokashi - 4

    img_diff = cv2.GaussianBlur(img_diff, (bokashiOutline, bokashiOutline), bokashiOutline) # ぼかす
    ret, img_diff = cv2.threshold(img_diff, 170, 240, cv2.THRESH_BINARY) # 閾値で2値化

    # 影部分の処理
    gray = cv2.GaussianBlur(gray, (bokashi, bokashi), bokashi) # ぼかす
    ret, gray = cv2.threshold(gray, 40, 220, cv2.THRESH_BINARY) # 閾値で2値化
    gray = lowContrast(gray) # コントラストを落とす
    
    # 輪郭線と影部分の画像を合成
    resultImg = cv2.bitwise_and(img_diff, gray)

    return resultImg


# ブラーをかける値を計算する関数
def calcBlurValue(allPixel):
    result = int(np.sqrt(allPixel/BLUR_VALUE))
    if (result%2 == 0):
        result = result + 1
    return result


# 画像の輪郭線を抽出する関数
def outine(grayImg, allPixel):
    # リサイズ
    z =  np.sqrt(PIXEL_VALUE / (grayImg.shape[1] * grayImg.shape[0]))
    if (z > 1):
        z = 1

    fx = int(grayImg.shape[1] * z)
    fy = int(grayImg.shape[0] * z)

    grayChangeImg = cv2.resize(grayImg, (fx, fy))
    # 輪郭線抽出
    result = 255 - cv2.Canny(grayChangeImg, 100, 50)
    # リサイズして元に戻す
    result = cv2.resize(result, (grayImg.shape[1], grayImg.shape[0]))
    return result


# コントラストを落とす関数
def lowContrast(img):
    # ルックアップテーブルの生成
    min_table = 50
    max_table = 230
    diff_table = max_table - min_table
    look_up_table = np.arange(256, dtype = 'uint8' )
 
    for i in range(0, 255):
        look_up_table[i] = min_table + i * (diff_table) / 255
 
    # コントラストを低減
    result = cv2.LUT(img, look_up_table)
    return result


# 画像を時刻で保存する関数
def saveImgByTime(dirPath, img):
    # 時刻を取得
    date = datetime.now().strftime("%Y%m%d_%H%M%S")
    path = dirPath + date + ".png"
    cv2.imwrite(path, img) # ファイル保存
    print("saved: " + date + ".png")


# キャプチャをリリースして、ウィンドウをすべて閉じる関数
def closeWindows(cap):
    cap.release()
    cv2.destroyAllWindows()


if __name__ == '__main__':
    main()

 画像の大きさに合わせてそれぞれのソースコードを使い分ければいいかなと思っています。(どうしても画像のサイズや単純なものが写っているかどうかに影響してくるので…)


 以上が今回作ってみたものです。OpenCVpythonがあれば手軽に色々な加工ができて面白いです。

 また今回作ったソースコードも一応公開しておきます。


・参考資料