SEワンタンの独学備忘録

IT関連の独学した内容や資格試験に対する取り組みの備忘録

【Python】白地画像から要素のみ切り抜き分割するアプリケーション


思いつきでつくった。動作の保証はできません。
OpenCVを使っていますが、初めて使ってみたので詳しくは理解していないところも多いです。そもそもpythonも素人。

概要

アプリケーション概要

画像から要素がある部分のみ検出して、要素ごとの画像に切り抜き出力する。


サンプル元画像

↓↓↓↓↓↓↓↓↓↓↓↓

構成技術

開発OS:Windows10
開発エディタ:VSCode

主要言語:Python
画像処理:OpenCV
GUI画面:tkinter

今回はexe化してますが、VSCodeなどのエディタがあればその場で実行もできるはずです。

ソースコード

pic_clipping.py

画面部のソース。
以下のような画面を生成します。

・pic_clipping.py

# coding: utf-8
import os,sys
import tkinter as tk
import tkinter.ttk as ttk
import numpy as np
from tkinter import font
from tkinter import filedialog
from tkinter import messagebox
from pic_clipping_logic import pic_clipping

global txt1,txt2,txt3,txt4,root
labellist=["画像ファイルのパス","出力先のフォルダパス"]
pramlabel=["輪郭の検出閾値","最大検出サイズ","最小検出サイズ","白地を透過する"]

def re_enter(event):
    clipping()

def clipping():
    # [0]画像ファイルのパス、[1]出力先のフォルダパス
    path_list = np.array([txt1.get(), txt2.get()])
    # [0]輪郭の検出閾値、[1]最大検出サイズ、[2]最小検出サイズ
    pram_list = np.array([f2txt1.get(), f2txt2.get(),f2txt3.get()])
    transparent = var.get()

    try:
        pic_clipping(path_list,pram_list,transparent)
    except:
        messagebox.showerror("エラー","エラーが発生しました。")
    else:
        messagebox.showinfo("完了","画像の切り抜き処理が完了しました。")

# ファイル指定の関数
def filedialog_clicked():
    fTyp = [("画像ファイル","*.jpg;*.jpeg;*.png")]
    #iFile = os.path.abspath(os.path.dirname(__file__))
    # AP化するとカレントディレクトリが一時フォルダになるため固定
    iFilePath = filedialog.askopenfilename(filetype = fTyp, initialdir = "./")
    txt1.delete(0,"end")
    txt1.insert(0,iFilePath)

# フォルダ指定の関数
def dirdialog_clicked():
    #iDir = os.path.abspath(os.path.dirname(__file__))
    # AP化するとカレントディレクトリが一時フォルダになるため固定
    iDirPath = filedialog.askdirectory(initialdir = "./")
    txt2.delete(0,"end")
    txt2.insert(0,iDirPath)

root = tk.Tk()
root.attributes("-topmost", True)
root.title("pic clipping")
root.geometry("300x300")

font1 = font.Font(family='Helvetica', size=10, weight='bold')
# Frame1の作成
frame1 = ttk.Frame(root, padding=10)
frame1.grid(row=0, column=1, sticky=tk.W)

# 1 対象画像のパステキストボックス
label1 = tk.Label(frame1, text=labellist[0], fg="black", font=font1)
label1.grid(row = 1, column = 0, sticky=tk.W)
txt1 = tk.Entry(frame1,width=30)
txt1.grid(row = 2, column = 0)
IDirButton = ttk.Button(frame1, text="..", command=filedialog_clicked,width=2)
IDirButton.grid(row = 2, column = 1)

# 2 出力先のパステキストボックス
label2 = tk.Label(frame1, text=labellist[1], fg="black", font=font1)
label2.grid(row = 3, column = 0,sticky=tk.W)
txt2 = tk.Entry(frame1,width=30)
txt2.grid(row = 4, column = 0)
IDirButton = ttk.Button(frame1, text="..", command=dirdialog_clicked,width=2)
IDirButton.grid(row = 4, column = 1)

# 切り取り処理のボタン生成
btn = tk.Button(frame1, text="切り取り", command=clipping, height=1,width=8)
btn.grid(row = 5, column = 0,sticky=tk.W)

label3 = tk.Label(frame1, text="※日本語パスに対応してません", fg="red", font=font1)
label3.grid(row = 6, column = 0,columnspan = 3, sticky=tk.W)

font2 = font.Font(family='Helvetica', size=12, weight='bold')

frame2 = ttk.Frame(root, padding=10)
frame2.grid(row=1, column=1, sticky=tk.W)

oplabel = tk.Label(frame2, text="オプション", fg="black", font=font2)
oplabel.grid(row = 0, column = 0,sticky=tk.W)

f2label1 = tk.Label(frame2, text=pramlabel[0], fg="black", font=font1)
f2label1.grid(row = 1, column = 0,sticky=tk.W)
f2txt1 = tk.Entry(frame2,width=5)
f2txt1.grid(row = 1, column = 1,sticky=tk.W)
f2txt1.insert(0,200)

f2label2 = tk.Label(frame2, text=pramlabel[1], fg="black", font=font1)
f2label2.grid(row = 2, column = 0,sticky=tk.W)
f2txt2 = tk.Entry(frame2,width=5)
f2txt2.grid(row = 2, column = 1,sticky=tk.W)
f2txt2.insert(0,10000)

f2label3 = tk.Label(frame2, text=pramlabel[2], fg="black", font=font1)
f2label3.grid(row = 3, column = 0,sticky=tk.W)
f2txt3 = tk.Entry(frame2,width=5)
f2txt3.grid(row = 3, column = 1,sticky=tk.W)
f2txt3.insert(0,1500)

f2label3 = tk.Label(frame2, text=pramlabel[3], fg="black", font=font1)
f2label3.grid(row = 4, column = 0,sticky=tk.W)
var = tk.BooleanVar()
var.set( False )
chk = tk.Checkbutton(frame2,variable = var)
chk.grid(row = 4, column = 1,sticky=tk.W)

root.bind('<Return>', re_enter)
root.mainloop()

tkinterは何回か使ってますが、その場その場でやりたいことに使っているだけなので未だにちゃんとした書き方というのが分かっていない。
フォルダやファイルの選択に関してはVSCode上だとコメントアウトした実装で問題なかったのですが、exe化すると意図したフォルダが開かれなかったのでダサい感じになりますがカレントディレクトリ直書きにしました。


【参考リンク】

  • ウィジェットの配置

【Python/tkinter】ウィジェットの配置(grid) | イメージングソリューション

  • ファイルやフォルダの選択の実装

【Python】tkinterでファイル&フォルダパス指定画面を作成する - Qiita

  • チェックボックスの実装

【Python】チェックボックスを作成する(Checkbutton) | 鎖プログラム

pic_clipping_logic.py

主に画像処理のロジック部分。
OpenCVを使用していますがまぁ良く分かってない。

・pic_clipping_logic.py

# coding: utf-8
from tkinter import Image
import numpy as np
import copy
import cv2

def pic_clipping(path_list,pram_list,transparent):
    print("pic_clipping")
    # 画像の読み込み
    img = cv2.imread (path_list[0])
    # グレースケール化
    gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
    # 白黒二極化
    ret, thresh = cv2.threshold(gray, int(pram_list[0]), 255, cv2.THRESH_BINARY)
    # 輪郭の抽出
    contours, hierarchy = cv2.findContours(thresh, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)

    maxsize = int(pram_list[1])
    minsize = int(pram_list[2])
    # スラッシュの置換
    dst_path = path_list[1].replace('/','\\\\')

    # 元画像のコピー
    output = copy.copy(img)
    for i in range(len(contours)):
        cnt = contours[i]
        # 誤検出と思われる大きすぎるサイズの排除、ノイズと思われる小さいサイズの排除
        if cv2.contourArea(cnt) > maxsize or cv2.contourArea(cnt) < minsize:
            continue
        # 元画像に検出部分をマークする
        output = cv2.drawContours(output, [cnt], 0, (0,255,0), -1)
        # 輪郭からバンディングボックスを計算
        rect = cv2.boundingRect(cnt)
        # 検出対象を矩形でトリミングする
        crop_img = img[rect[1]:rect[1]+rect[3]-1, rect[0]:rect[0]+rect[2]-1]

        # 白地の透過処理を行うかどうか
        if transparent:
            dst = cv2.cvtColor(crop_img, cv2.COLOR_BGR2BGRA)
            #dst[:,:, 3] = np.where(np.all(dst == 255, axis=-1), 0, 255)
            # 透過色の下限値
            color_lower = np.array([200, 200, 200, 255])
            # 透過色の上限値
            color_upper = np.array([255, 255, 255, 255]) 
            img_mask = cv2.inRange(dst, color_lower, color_upper) 
            img_bool = cv2.bitwise_not(dst, dst, mask=img_mask) 
            cv2.imwrite(dst_path+'\\picture'+str(i)+'.png',img_bool)
        else:
            cv2.imwrite(dst_path+'\\picture'+str(i)+'.png',crop_img)
    cv2.imwrite(dst_path+'\\detection_parts.png',output)

流れとしては画像の輪郭を抽出、抽出した輪郭を矩形でトリミングして透過する場合は透過処理を行う。
全体が輪郭として抽出されてしまったり、なにもないように見えるところを抽出してまうようなことがあったので、抽出面積で出力処理を行うかの判定を挟みました。
輪郭抽出のcv2.RETR_TREEが最適だったのかは分からない。

透過処理に関しては白の透過のみでいいかと思って、RGB[255, 255, 255]のみだと白く見えても結構透過されない部分が多かったので範囲指定としました。
下限上限の値をいじれば白以外の透過もできると思います。

【参考リンク】

  • OpenCVによる画像ファイルの読み込み、書き出し

Python, OpenCVで画像ファイルの読み込み、保存(imread, imwrite) | note.nkmk.me

  • 画像の輪郭を抽出

画像処理をマスターしよう!PythonでOpenCVを使う方法を紹介! | TechTeacher Blog
【python+opencv】抽出した輪郭を塗りつぶす | kiseno-log
OpenCV - 輪郭の特徴分析について - pystyle

  • 画像の透過

OpenCVを使って初めての背景透過
Pythonで画像の余白を削除する(OpenCV編)
Python/OpenCVで任意色を透過させたpng画像に変換 | WATLAB -Python, 信号処理, 画像処理, AI, 工学, Web-

  • 画像のトリミング

領域(輪郭)の特徴 — OpenCV-Python Tutorials 1 documentation
【Python】OpenCVで画像を切り出す方法 | naoの学習&学習

pyinstaller
python -m PyInstaller pic_clipping.py --onefile --noconsole --icon=ico\pic_clipping.ico

exe化はVSCodeのpyinstallerで実行。
おまけ程度にアイコンを付けました。

アプリケーション仕様

基本操作

画像ファイルのパスと出力先のフォルダパスを指定して切り取りボタンを押下すれば処理が実行されます。
なお、筆者の怠慢でファイル名及びパス中の日本語には対応していません。適当に英名に直して読み込んでください。
コピペよりボタンから選択した方が無難です。エラーが起きるかも。

読み込みを行う画像ファイルは、一旦jpgpngのみに対応。敢えて縛らなければ他の画像ファイル形式でも大丈夫かもしれません。

出力ファイル

出力ファイルはdetection_parts.pngpicture[n].pngに二種が出力されます。
detection_parts.pngは輪郭として抽出された部分を塗りつぶして出力しています。認識されない部分が多い場合には後述するオプションの値をいじればある程度は認識対象を調整できるかもしれません。

picture[n].pngはトリミングした個別の画像ファイルです。連番が飛ぶのは誤検出と認識した番号を考慮していないから。

出力形式は選択できるようにもできたと思いますが、透過もあるので.png固定。
変えたい場合にはソースをいじる必要があります。

オプション

輪郭の検出閾値

輪郭の検出を行う閾値を設定します。
0に近いほど濃い色のみ検出して、255に近いほど薄い色も検出するという動作になるはずです。
例えば、デフォルトで設定している「200」ではサンプル画像の以下の黄色が対象として検出されませんが、「240」ぐらいに設定すると検出されるようになりました。

高くしすぎると背景の白がキレイではない場合には誤検出が頻発するかもしれません。

最大検出サイズ

画像のフレームを輪郭として検出して出力してしまうのが嫌だったので最大サイズを設定できるようにしました。
切り抜き対象部分が大きい場合にはこちらの値を大きくすると対象として検出されるようになるかもしれません。

最小検出サイズ

背景のみのなにもない部分を輪郭として検出して出力するが嫌だったので最小サイズを設定できるようにしました。
切り抜き対象部分が小さい場合にはこちらの値を小さくすると対象として検出されるようになるかもしれません。

白地を透過する

トリミングした出力画像の白地部分を透過させるオプションです。
輪郭の外側だけではなく、内側にも白地がある場合には透過されてしまします。
そんなに検証していないのでうまく動作しないことも結構あるかも。
白以外を透過させたい場合にはソースコードから修正する必要があります。

エラーについて

筆者の怠慢で、なんでエラーが起きているのか分かるようにはなっていません。
問題があった場合には以下の出力だけされるようになっています。

エラーが発生する可能性が高そうなところとしてはパス指定がこちらの想定する形になっていないこと。
パスが全て英名になっていてボタンから選択すれば多くは解決するのではないかと思います。

後はオプションが数値以外とか。
画像ファイル自体に問題がある場合には現状どんな問題が起きうるのは分かっていません。

対応しきれていない部分

他にもいくらでもあると思いますが、明確になっている部分だけ。

検出対象の輪郭が飛び地になっている場合

例えば、サンプル画像でいう以下のような部分のこと。

こちらを処理すると以下のようにトリミングされます。

こちらはパラメータなどを調整しても現状対応できていません。単なる技術力不足です。

飛んでいても大きい輪郭の矩形に含まれる場合には結果的にうまく検出できているように見えます。

要素に明確に内側外側がある場合

サンプル画像でいう以下のような部分のこと。

こちらを処理すると以下のように二つにトリミングされます。

内側のファイルは消せばいいので、基本的には大きな問題にならないと思いますが、外枠の内側も輪郭として検出されるため2枚目のような画像が出力されます。


・GitHubソース
github.com
・アプリ本体
Release pic_clipping · wantanblog/pic_clipping_app · GitHub

ダウンロードはできるはず。
アプリの利用やソースのコピペはご自由にどうぞですが、動作その他の責任は負えません。