Pythonで楽譜付き演奏動画から楽譜を作る

# はじめに
この記事は以下の記事の続きです。基本的にはこの記事のほうが良くできてるはずですが興味があれば見てやってください。
また、記事内のことで質問や、コードの改善点等がありましたらコメントを頂けると幸いです。
https://qiita.com/lionreo00/items/8882fae58f0e044244c8 

# 目的
　前回記事では、動画内でTabが載せられているものの、Tabの画像データが公開されていない場合にそのデータを作るpythonコードを載せた。ただし、前回記事の最後に書いた通り、GUIがなく、また誤操作の保険も効いていなかった。
　本記事はそれを解決、改良したものになる。

# 目次
<!-- タイトルとアンカー名を編集 -->
1. [注意点](#注意点)
1. [環境](#環境)
1. [実行例](#実行例)
1. [使用手順](#使用手順)
1. [コード](#コード)
1. [改善点](#改善点)
1. [最後に](#最後に)
1. [アプリケーション共有リンク](#アプリケーション共有リンク)

<!-- 各チャプター -->
<a id="#注意点"></a>
# 注意点
- 本記事で記す方法が使えるのは動画内で楽譜の表示される位置がある程度一定の場合のみである。これは、本記事のコードが、楽譜を検知して追従するなどせず、一定のエリアを切り取る機能しか持たないからである。
- 本記事のコードを私的に利用していただくのは自由であるが、その動作について責任は負いかねる。自身で把握できる限りのバグ修正はしたが保証はできない。また、バグがあったらこっそり教えてほしい。
 
<a id="#環境"></a>
# 環境
Windows 10
Anaconda
python 3.10.13

<a id="#実行例"></a>
# 実行例
入力フォルダには以下のものを十数枚コピーしたものが入る。(この画像は私のギターと私の作ったTabが映っているのみであるため、著作権の心配は問題ない)

![sampleimage.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3537209/3fff4118-b753-67a9-1fb1-69cc8f59e05f.jpeg)

この画像では黒枠だが、実際にはYoutubeのバナーやおすすめ動画など、そういったものになるだろう。
出力結果は以下の通り

![result1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3537209/87623dd7-6420-b71a-7bfa-30b66270cc4d.png)

<a id="#reference"></a>
# 使用手順

本記事はpythonを利用したことがある人向けのものであるため、利用したことがない場合はpythonのインストールから頑張ってみてほしい。
必要なライブラリはコード内に記載している通りである。実行ファイル化する場合はpyinstallerがおすすめだ。
1. 楽譜付きの演奏動画を再生し、楽譜が更新されるごとにスクリーンショットを撮る
1. 対象のスクリーンショットを特定のフォルダに移す
1. pythonコード（後述のapp.py）を実行
1. ウィンドウが表示されるため、ここで入力フォルダ（スクリーンショットの入っているフォルダ）と出力先フォルダを指定
1. 開始ボタンを押す
1. トリミング用のウィンドウが出てくるため、ここでドラッグ操作を用いて切り取り範囲を選択
1. 自動でフォルダ内の残りの画像をすべて切り取り、結合して画像として出力する。 
1. ウィンドウを閉じて終了
------------------
実際に動作させたときの動画（ここでは実行ファイル化してある）↓
<blockquote class="twitter-tweet" data-media-max-width="560"><p lang="ja" dir="ltr">Tab付き演奏動画のスクリーンショット（複数）からTabを作るアプリできた <a href="https://t.co/7SNsNiF5XU">pic.twitter.com/7SNsNiF5XU</a></p>&mdash; ヌザタ (@Reo99870770) <a href="https://twitter.com/Reo99870770/status/1760654508546466081?ref_src=twsrc%5Etfw">February 22, 2024</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

<a id="#コード"></a>
# コード
言い訳がましいが、私は本職のプログラマーではないため、至らぬ点が多いかと思う。
そういった部分を見つけたまたは言いたくなった方はコメントをくださるとありがたい。

まずは、実際の画像処理を行うTabMakerクラスについてのコード。
ここでは、基本的な機能の定義と、切り取り範囲設定の簡易GUIを定義している。
```python:TabMaker.py
import pathlib
import cv2
import numpy as np
import os

class tab_maker():
    
    #画像保存先を受け取り起動。画像リストを生成
    def  __init__(self,input_dir="",output_dir="",vseparate=6,vspacing=5):
        
        #出力ディレクトリの設定
        self.output_dir = output_dir
        
        #入力ディレクトリの設定と画像の読み込み
        if input_dir != "":
            self.input_dir = input_dir
            
            #画像パスの読み込み(png>jpg)
            self.input_list = list(pathlib.Path(self.input_dir).glob('**/*.png'))
            if len(self.input_list) == 0:
                self.input_list = list(pathlib.Path(self.input_dir).glob('**/*.jpg'))
            
            print("Find " + str(len(self.input_list)) + " img files.")
            
        #出力時に何枚ずつで結果を生成するか
        self.vseparate = vseparate
        
        #出力時の画像の間の縦スペースの設定
        self.vspacing = vspacing
        
        #トリミング位置
        self.crop_pos_leftup = []
        self.crop_pos_rightdown = []
        
    #画像ファイルの再設定
    def set_inputdir(self,new_input_dir):
        self.input_dir = new_input_dir
        
        #画像パスの読み込み(png>jpg)
        self.input_list = list(pathlib.Path(self.input_dir).glob('**/*.png'))
        if len(self.input_list) == 0:
            self.input_list = list(pathlib.Path(self.input_dir).glob('**/*.jpg'))
        
        print("Find " + str(len(self.input_list)) + " img files.")
    
    #出力先ファイルの再設定
    def set_outputdir(self,new_output_dir):
        self.output_dir = new_output_dir 
    
    #画像リストから画像を全取得
    def get_images(self,printname=False):
        #リストの初期化
        self.img_list = []
        
        #画像を一枚ずつ読み込み
        for i in range(len(self.input_list)):
            img_file_name = str(self.input_list[i])
            img = self.imread(img_file_name,printname=printname)
            height,width = img.shape[:2]
            
            #画像サイズが同じかどうかをチェック
            if i==0:
                self.height = height
                self.width = width
                self.img_list.append(img)
            else:
                if(self.height == height and self.width == width):
                    self.img_list.append(img)
                else:
                    print("This image was excluded because it had a different size from the first image.")
                    print("異なるサイズの画像があったため除外しました")
    
    #余白や、つなげる枚数の設定を更新
    def set_outputvariables(self,vseparate,vspacing):
        self.vseparate = vseparate
        self.vspacing = vspacing
                    
    #画像を指定の二点をもとに切り取る
    def crop_img(self,img,left_up,right_down):
        return img[left_up[1]:right_down[1],left_up[0]:right_down[0]]
    
    #保持しているすべての画像を指定の二点をもとに切り取る
    def crop_img_all(self,left_up,right_down):
        new_img_list = []
        for img in self.img_list:
            crop_img = self.crop_img(img,left_up, right_down)
            new_img_list.append(crop_img)
        self.img_list = new_img_list
    
    #画像を読み込み（日本語対応）　ただし倍率を50%で読み込む（座標確認用）
    def imread(self,filename, flags=cv2.IMREAD_COLOR, dtype=np.uint8,printname=False):
        try:
            n = np.fromfile(filename, dtype)
            img = cv2.imdecode(n, flags)
            height,width = img.shape[:2]
            
            #読み込み画像名を表示
            if printname == True:
                print(str(filename) + ":Image Size:(" + str(height)+","+str(width)+")")
                
            img = cv2.resize(img,(int(width / 2), int(height / 2)))
            return img
        except Exception as e:
            print(e)
            return None
    
    #ディレクトリが日本語を含む場合に備えたimwrite
    def imwrite(self,filename, img, params=None):
        try:
            ext = os.path.splitext(filename)[1]
            result, n = cv2.imencode(ext, img, params)
    
            if result:
                with open(filename, mode='w+b') as f:
                    n.tofile(f)
                return True
            else:
                return False
        except Exception as e:
            print(e)
            return False
        
    #切り取り範囲指定
    def get_crop_pos(self):
        
        #切り取り座標をリセット
        self.crop_pos_leftup = []
        self.crop_pos_rightdown = []
        
        #保持画像の一枚目をコピーして保存
        self.get_crop_pos_img = self.img_list[0].copy()
        
        #画像の表示
        cv2.imshow("set_crop_area",self.get_crop_pos_img)
        #sampleWindow内でクリックが発生したとき、click_pos関数を呼び出す
        cv2.setMouseCallback("set_crop_area",self.click_pos_crop)
        
        #ループフラグ
        self.flag = True
        while self.flag:
            #ウィンドウが閉じられていたらFalseを返す
            if not cv2.getWindowProperty("set_crop_area", cv2.WND_PROP_VISIBLE) >= 1:
                return False
            cv2.waitKey(10)
        
        return True
        
    #クリック時に動作する関数
    def click_pos_crop(self,event, x, y, flags, params):
        
        if event == cv2.EVENT_LBUTTONDOWN:              #左クリック時にその座標を保存
            self.crop_pos_leftup = (int(x),int(y))
        elif event == cv2.EVENT_MOUSEMOVE:
            if len(self.crop_pos_leftup) != 0:          #マウスを動かしている間はその座標に沿って四角を描画
                self.get_crop_pos_img = self.img_list[0].copy()
                cv2.rectangle(img = self.get_crop_pos_img,
                              pt1 = self.crop_pos_leftup,
                              pt2 = (int(x),int(y)),
                              color=(125,125,125),
                              thickness=3)
                #表示画像を更新
                cv2.imshow("set_crop_area",self.get_crop_pos_img)
        elif event == cv2.EVENT_LBUTTONUP:              #左ボタンを離したとき、座標を保存。ウィンドウを閉じるフラグをTrueに
            self.crop_pos_rightdown = (int(x),int(y))
            self.flag = False
            cv2.destroyAllWindows()
            
    #画像の結合を行う
    #vseparate:何枚づつで縦結合するか
    #vspacing:縦方向の余白
    def concat(self,vseparate=6,vspacing=5):
        img_list_copy = self.img_list.copy()

        #足りない枚数
        append_num = len(img_list_copy)%vseparate
        
        #足りない枚数分白紙画像を追加
        for i in range(vseparate - append_num):
            white_img=np.full((img_list_copy[0].shape[0],img_list_copy[0].shape[1],img_list_copy[0].shape[2]),255,np.uint8)
            img_list_copy.append(white_img)
    
        #余白の追加
        if vspacing > 0:
            for i,img in enumerate(img_list_copy):
                img_list_copy[i]=cv2.copyMakeBorder(img, vspacing, vspacing, 0, 0,cv2.BORDER_CONSTANT,value=[255,255,255])
        
        #画像の結合と保存
        number = 1
        while len(img_list_copy) >= vseparate:
            self.concat_img = cv2.vconcat(img_list_copy[:vseparate])
            del img_list_copy[:vseparate]
            print("出力:" + self.output_dir + "/result" + str(number) + ".png")
            print(self.imwrite(self.output_dir + "/result" + str(number) + ".png",
                        self.concat_img))
            number=number+1    
            
    #座標を切り取りに対応した形式に整頓する
    def crop_coodinate_cleanup(self,left_up,right_down):
        new_left_up = []
        new_right_down = []
        
        #同じ値の座標であれば切り取れないであるためFalse
        if(left_up[0] == right_down[0]) or (left_up[1] == right_down[1]):
            return False
        
        #x座標について
        if (left_up[0] < right_down[0]):
            new_left_up.append(left_up[0])
            new_right_down.append(right_down[0])
        else:
            new_left_up.append(right_down[0])
            new_right_down.append(left_up[0])
            
        #y座標について
        if (left_up[1] < right_down[1]):
            new_left_up.append(left_up[1])
            new_right_down.append(right_down[1])
        else:
            new_left_up.append(right_down[1])
            new_right_down.append(left_up[1])
        
        self.crop_pos_leftup = new_left_up
        self.crop_pos_rightdown = new_right_down
        
        return True
    
    #実行部
    def main(self):
        
        try:
            #画像が一つも見つからなければ終了
            if len(self.input_list) == 0:
                print("No image")
                return 0
            
            #画像を取得
            self.get_images()
            
            #トリミング範囲を読み込む
            check = self.get_crop_pos()
            if check is False:
                return False
            
            #トリミング範囲の整頓、検査
            check = self.crop_coodinate_cleanup(self.crop_pos_leftup, self.crop_pos_rightdown)
            if check is False:
                return False
            
            #トリミングの実行
            self.crop_img_all(self.crop_pos_leftup, 
                                  self.crop_pos_rightdown)
            
            #画像の結合
            self.concat(vseparate = self.vseparate,
                        vspacing = self.vspacing)
            
            return True
        
        except Exception as e:
            #エラー発生時はエラーをコンソールに表示、実行失敗のFalseを返す
            print(e)
            return False
```

次に、GUI部分のコードである。
```python:app.py
import os
import tkinter
from tkinter import *
from tkinter import ttk
from tkinter import messagebox
from tkinter import filedialog
from TabMaker import tab_maker
import ctypes

#Windowsでtkinterウィンドウの解像度を良くする呪文。
try:
    ctypes.windll.shcore.SetProcessDpiAwareness(True)
except:
    pass

class Application():
    def __init__(self):
        #tabmakerクラスのインスタンス化
        self.tm = tab_maker()
        
        # rootの作成
        root = Tk()
        root.geometry("600x300")
        root.title("TabMaker")
        
        # 入力フォルダ用のフレームの作成
        input_folder_frame = ttk.Frame(root, padding=10)
        input_folder_frame.grid(row=0, column=0,columnspan=2,sticky=E)

        # 入力フォルダ用ラベルの作成
        IDirLabel = ttk.Label(input_folder_frame, text="元画像フォルダ参照＞＞", padding=(5, 2))
        IDirLabel.pack(side=LEFT)
        
        # 入力フォルダ用エントリーの作成
        self.input_folder_entry = StringVar(value="未選択")
        IDirEntry = ttk.Entry(input_folder_frame, textvariable=self.input_folder_entry, width=30)
        IDirEntry.pack(side=LEFT)

        # 入力フォルダ用ボタンの作成
        IDirButton = ttk.Button(input_folder_frame, text="参照", command=self.Idirdialog_clicked)
        IDirButton.pack(side=LEFT)
        
        # 出力フォルダ用のフレームの作成
        output_folder_frame = ttk.Frame(root, padding=10)
        output_folder_frame.grid(row=1, column=0,columnspan=2,sticky=E)

        # 出力フォルダ用ラベルの作成
        ODirLabel = ttk.Label(output_folder_frame, text="出力フォルダ参照＞＞", padding=(5, 2))
        ODirLabel.pack(side=LEFT)

        #出力フォルダ用エントリーの作成
        self.output_folder_entry = StringVar(value="未選択")
        ODirEntry = ttk.Entry(output_folder_frame, textvariable=self.output_folder_entry, width=30)
        ODirEntry.pack(side=LEFT)

        # 出力フォルダ用ボタンの作成
        ODirButton = ttk.Button(output_folder_frame, text="参照", command=self.Odirdialog_clicked)
        ODirButton.pack(side=LEFT)
        
        #インプットディレクトリ用フレームの作成
        finded_imgnum_frame = ttk.Frame(root,padding=10)
        finded_imgnum_frame.grid(row=2,column=0,sticky=E)
        
        #見つかったファイル数を示すテキストの配置
        self.finded_imgnum_label =  ttk.Label(finded_imgnum_frame,text="画像　：　0枚")
        self.finded_imgnum_label.pack(side=LEFT)
        
        #画像結合時の設定を行うフレーム
        output_img_setting_frame = ttk.Frame(root,padding = 10)
        output_img_setting_frame.grid(row=3,column=0,rowspan=2,columnspan=2,sticky=E)

        #vsepareteの説明
        vseparate_label = ttk.Label(output_img_setting_frame,
                                         text="画像一枚あたりの行数：")
        vseparate_label.pack(side=LEFT)

        #vseparate選択コンボボックス
        self.vseparate = IntVar(value=7)
        vseparate_value_list=[1,2,3,4,5,6,7,8,9,10]
        vseparate_combobox = ttk.Combobox(output_img_setting_frame,
                                               width=5,
                                               value=vseparate_value_list,
                                               textvariable=self.vseparate,
                                               state="readonly")
        vseparate_combobox.pack(side=LEFT)

        #vspacingの説明
        vspacing_label = ttk.Label(output_img_setting_frame,
                                   text="画像間の余白：")
        vspacing_label.pack(side=LEFT)

        #vspacing選択コンボボックス
        self.vspacing = IntVar(value=5)
        vspacing_value_list = [5,10,15,20,25,30]
        vspacing_combobox = ttk.Combobox(output_img_setting_frame,
                                         width=5,
                                         value=vspacing_value_list,
                                         textvariable=self.vspacing,
                                         state="readonly")
        vspacing_combobox.pack(side=LEFT)

        #実行ボタンフレームの作成
        exebutton_frame = ttk.Frame(root, padding=10)
        exebutton_frame.grid(row=5,column=1,sticky=W)

        # 実行ボタンの設置
        exebutton= ttk.Button(exebutton_frame, text="開始", command=self.conductMain)
        exebutton.pack(fill = "x", padx=30, side = TOP)  
    
        #アプリケーションの開始
        root.mainloop()
    
    #入力フォルダ指定の関数
    def Idirdialog_clicked(self):
        iDir = os.path.abspath(os.path.dirname(__file__))
        iDirPath = filedialog.askdirectory(initialdir = iDir)
        
        #フォルダ選択がキャンセルされた場合は以後の処理を飛ばす
        if iDirPath == "":
            return
        
        #テキストボックスに反映
        self.input_folder_entry.set(iDirPath)
        
        #tab_makerクラスに入力フォルダのディレクトリを与える
        self.tm.set_inputdir(iDirPath)
        
        #見つかった写真の数を表示する
        self.finded_imgnum_label["text"] = "画像　：　"+str(len(self.tm.input_list)) + "枚"
        
    # 出力フォルダ指定の関数
    def Odirdialog_clicked(self):
        oDir = os.path.abspath(os.path.dirname(__file__))
        oDirPath = filedialog.askdirectory(initialdir = oDir)
        
        #フォルダ選択がキャンセルされた場合は以後の処理を飛ばす
        if oDirPath == "":
            return
        
        #テキストボックスに反映
        self.output_folder_entry.set(oDirPath)
        
        #tab_makerクラスに出力ディレクトリを与える
        self.tm.set_outputdir(oDirPath)
        
    # 開始ボタン押下時の実行関数
    def conductMain(self):
        #画像読込先ディレクトリが存在するかの確認
        if not os.path.isdir(self.input_folder_entry.get()) :   
            messagebox.showwarning("エラー",
                                   "画像読み込み先のフォルダが見つかりません")
            return
        
        #結果出力先ディレクトリが存在するかの確認
        if not os.path.isdir(self.output_folder_entry.get()) :   
            messagebox.showwarning("エラー",
                                   "結果出力先のフォルダが見つかりません")
            return
        
        #画像の枚数を確認
        if len(self.tm.input_list) == 0:
            messagebox.showerror("エラー",
                                 "指定フォルダに画像がありません")

        #実行
        self.tm.set_outputvariables(vseparate=self.vseparate.get(),vspacing=self.vspacing.get())
        result = self.tm.main()
        
        if result == True:
            messagebox.showinfo("完了",
                                "実行完了")
        else:
            messagebox.showerror("失敗",
                                 "実行失敗")
    
if __name__ == "__main__":
    App = Application()

```

<a id="#改善点"></a>
# 改善点
今回のコードはいづれアプリケーション化することを見据えたように改良したものである。ほとんどの機能は使ったが、画像一枚に入れる元画像数や余白調整の機能をGUIに反映できていないため、気が向いたらこれらを改善する。

<a id="#最後に"></a>
# 最後に
　ここでは感想を述べさせていただこうかと思う。
　まず、GUIの実装に従って、使用者の誤操作を想定したコードにした。これが無茶苦茶大変だった。簡易GUIを途中で閉じた場合や、架空のフォルダパスが指定された場合、フォルダを指定せずに実行した場合など多岐にわたる。世のアプリケーションを作るエンジニアの方々には頭が上がらない。
 　本記事のコードは手元ではアプリケーション化できているため、いづれは何らかの方法で配布したいと考えている。（その場合は記事を更新する）多くの人が使ってくれることを望むため、ぜひ共有してほしいと思う。

  追記：コード、共有ファイル内のアプリケーションの両方を出力時の設定に対応させました。

  <a id="アプリケーション共有リンク"></a>
  # アプリケーション共有リンク
  pythonコード二つと、実行ファイル化したものを以下のGoogleDriveで公開しておく。
  pythonを使ったことのない方はこちらのapp.exeを利用していただくといいかと思う。
  https://drive.google.com/drive/folders/1N3cf93F5xACZJZ90u09CBFrJepsenugm?usp=drive_link