OpenCVを使って楽譜の中の音階を識別したい

## はじめに
　Advent Calendar 2024として記事を書くことになったが、特に書くことが無さそうだったので、今回は2年ほど前に作った(作りかけの)コードの話を書いていこうと思う。説明に曖昧な部分があるかもしれにが、コードを書いてから時間が経っているので、自分でもうろ覚えということを言い訳としてここに残しておく。
## 背景
　皆さんは、譜読みは得意だろうか。私は大の苦手である。ピアノを習い始めてから現在に至る約15年、音符に色を塗ることで、音階と色を紐づけ解決してきた。しかし、最近になってこの楽譜に色を塗るという行為が非常に面倒に感じてきたのである。そこで、プログラミングを学ぶ人間の端くれとして面倒なことはコンピューターにやらせようと思い立ったわけだ。では早速本編に入る。
## アルゴリズム
　ではまず、アルゴリズムである。見出しをアルゴリズムとしたが、そんな小難しい話ではなく音階を機械的に認識するにあたって何が必要かを考える。最初に思いつくのは五線、そして音符の玉の位置だろう。これらの画像内での座標位置が出来れば音階もわかるだろう。ではこれらの実装に移る。
<details><summary>詳しくは...</summary>

　これだけでは判別できる音符が五線の内側のものだけになり、ト音記号、へ音記号の判定も出来ないので実際に使い物になるコードではない。ト音記号、ヘ音記号の見分けに関しては、機械学習で解決できると考えられるが、学習が上手くいったためしがない。（これがこのコードを長らく触っていなかった理由でもある。）五線外にはみ出た音符を判別する方法は思いついていない。

</details>

## 環境
今回、画像認識を行うにあたってPythonでOpenCVを使用した。
|  | バージョン |
| ------- | ------- |
| OS   | Windows11   |
| Python   | 3.10.8  |
|OpenCV|4.10.0.84 |
## フォルダ構成
　フォルダ構成は以下のとおりである。
 - {debug_imgs}：デバック用に途中経過などを出力するフォルダ
 - {my_modules}：自作モジュールのPythonファイルを入れるフォルダ
 - {results}：結果の出力先になるフォルダ
 - {score}：入力する楽譜画像を格納するフォルダ
.
├── debug_imgs
├── my_modules/
│   ├── module1.py
│   ├── module2.py
│   ├── module3.py
│   └── .....
├── results
├── score
└── main.py

## 画像の読み込みと出力
　画像の読み込みはosモジュールで画像のファイルパスを作成し、OpenCVのimread関数で読み込ませている。
　出力はosモジュールで作成したパスと画像データをimwrite関数に引数として渡している。
　utils.pyは結果画像を出力するときに、resultフォルダにあるファイルが上書きされないように、現在の時刻をファイル名に追加するモジュールである。
``` python:main.py
import cv2
import os
import copy
from my_module import staff_notation_detect, get_note, get_verticle_line, utils

# パスのベースを作成
DS = os.sep
BASE_PATH = os.path.dirname(__file__) + DS
# 楽譜画像のパスを生成
scor_img = BASE_PATH + 'score' + DS + 'input0.png'
#画像の読み込み
scr = cv2.imread(scor_img)
result_img = copy.copy(scr)

# 指定したデータを指定したファイル名で出力
def debug_image(img, imgname = 'result.png'):
	global BASE_PATH
	# 画像を出力
	cv2.imwrite(BASE_PATH + imgname, img)


#ここに画像処理を書く


"""
出力
"""
#検出結果を表示
result_image_path = utils.get_unique_filename('results/result', 0) + ".png"
print("exported: " + result_image_path)
debug_image(result_img, result_image_path)
```
```python:utils.py
import datetime

def get_unique_filename(base_filename, seconds):
    # 現在の日時を取得
    now = datetime.datetime.now()

    # 秒数を追加
    delta = datetime.timedelta(seconds=seconds)
    new_time = now + delta

    # フォーマットを指定して新しいファイル名を返す
    return f"{base_filename}_{new_time.strftime('%Y%m%d_%H%M%S')}"

```
## 五線認識
　main.pyの中でstaff_notation_detect.pyモジュールを呼び出し、結果をresult_imgに書き込んでいる。
　staff_notation_detect.pyの中身はそれぞれ線検出の前処理、重複する線の削除、クラスター判別、main.pyに呼び出される関数に分かれている。
- 線検出の前処理
　直線の検出を行ってくれるOpenCVのHoughLinesP関数に渡す画像データの前処理を行っている。まずグレースケール化を行い、途切れている線があった場合に備えてフィルタをかけてぼかしている。その後、白黒反転させ二値化する。一見意味無さそうな白黒反転をしないとうまく検出が出来ない。
- 重複する線の削除
　HoughLinesP関数で出力されたデータでは一つの直線を複数の直線として認識してしまっている。例えば3ピクセルの幅の直線があった場合,それを一つの直線ではなく1ピクセルの幅の直線が3本あるように認識してしまう。これらを解消するために連続している(繋がっている)直線があれば一つの直線になるように一部の直線を削除している。
- クラスター判別
　このままではただ直線の座標が分かっただけのため、どの直線が一つの五線セットなのかをここで判別し、タグ付けしている。クラスター分析にはscikit-learnのDBSCANモジュールを使っている。
```python:main.py
"""
五線検出
"""
staff_notation, lines_deleted_2d, lines_2d = staff_notation_detect.sta_nota_detec(scr)
#書き込み赤
for line in staff_notation:
	x1, y1, x2, y2 , c = line
	result_img = cv2.line(result_img, (x1, y1), (x2, y2), (0,0,255), 1)
```
```python:staff_notation_detect.py
import numpy as np
import cv2
import math
import copy
from sklearn.cluster import DBSCAN

"""
重複する線を削除
"""
def sort_and_delete_lines(lines):
	#ソート
	lines_2d = []
	lines_deleted_2d0 = []
	arr_shape = lines.shape
	num_elements = np.prod(arr_shape)
	lines_2d = lines.reshape(-1, arr_shape[-1])
	sorted_lines_2d = sorted(lines_2d, key = lambda x:x[1])

	lines_2d = copy.deepcopy(sorted_lines_2d)

	list_num = 0
	same_line_num = 1
	n = 0
	#余分な線の削除
	for line in sorted_lines_2d:
		if list_num + 1 != len(sorted_lines_2d):
			if sorted_lines_2d[list_num][1] + 1 == sorted_lines_2d[list_num + 1][1]:
				same_line_num += 1
				
			else:
				choosed_list_num = list_num - math.floor(same_line_num/2)
				same_line_num = 0
				lines_deleted_2d0.append(sorted_lines_2d[choosed_list_num])
				n += 1
		else:
			choosed_list_num = list_num - math.floor(same_line_num/2)
			same_line_num = 0
			lines_deleted_2d0.append(sorted_lines_2d[choosed_list_num])
			n += 1
		
		list_num += 1
	return lines_deleted_2d0 , lines_2d

"""
線検出の前処理
"""
def get_lines_pre(scr):
	scr_gray = cv2.cvtColor(scr, cv2.COLOR_RGB2GRAY)

	# 途切れてるところがつながるようにぼかす
	kval = 3
	# 要素が1の配列を作成し、要素数で割る(フィルタリングの重みの作成)
	kernel = np.ones((kval,kval),np.float32)/(kval*kval)
	# フィルタを適応
	scr_gray = cv2.filter2D(scr_gray,-1,kernel)
	# 白黒反転
	line_dst = cv2.bitwise_not(scr_gray)

	# 閾値指定してフィルタリング(二値化)
	retval_line, line_dst = cv2.threshold(line_dst, 30, 255, cv2.THRESH_BINARY)
	return line_dst

"""
クラスター判別
"""
def staff_notation_cluster(lines):
	return_lines = []
	judge_cluster_lines = []
	for i in lines:
		judge_cluster_lines.append([i[1]])
	for i in lines:
		return_lines.append([i[0], i[1], i[2],i[3]])
	
	#五線譜の間隔の平均を求める
	gap_ave = 0
	gap_counter = 0
	for i in range(len(lines)):
		if(i == len(lines) - 1):
			break
		gap_counter += 1
		if(gap_counter == 5):
			gap_counter = 0
		else:
			gap_ave += lines[i][1] - lines[i + 1][1]
	gap_ave = gap_ave/(len(lines)/5 * 4)
	gap_ave = abs(gap_ave)
	gap_ave = round(gap_ave)

	# DBSCANによるクラスタリング (epsは近傍距離の閾値、min_samplesは最小サンプル数)
	dbscan = DBSCAN(eps=gap_ave*3, min_samples=2)
	# クラスターの予測
	predicted_clusters = dbscan.fit_predict(judge_cluster_lines)
	for i in range(len(judge_cluster_lines)):
		return_lines[i].append(predicted_clusters[i])
	return return_lines
 
def sta_nota_detec(scr):
	width = scr.shape[1]
	#前処理
	line_dst = get_lines_pre(scr)
	# 線を検出																	# 検出する線の長さmin # 同じ直線とみなす線の間隔
	lines = cv2.HoughLinesP(line_dst, rho=1, theta=np.pi/360, threshold=100, minLineLength=width/2, maxLineGap=0)
	# 重複する線を削除
	lines_deleted_2d, lines_2d = sort_and_delete_lines(lines)
	#クラスター判別
	staff_notation = staff_notation_cluster(lines_deleted_2d)
	return staff_notation, lines_deleted_2d, lines_2d

```
## 音符の玉認識
　main.pyの中でget_note.pyモジュールを呼び出し、結果をresult_imgに書き込んでいる。get_note.pyではグレースケール化を行い、先ほど検出した五線の上を白色で上書し、ガウアシアンフィルタでぼかし、二値化を行う。これでおおよそ音符の玉以外が消える。この後にOpenCVのfindContours関数で輪郭抽出を行う。その後、五線間の長さの平均を利用して輪郭抽出の結果を選別する。
```python:main.py
"""
音符の玉認識
"""
balls = get_note.get_ball(scr, lines_deleted_2d, staff_notation)
#print("balls",balls)
#書き込み緑
for ball in balls:
	x, y, w, h = ball
	result_img = cv2.rectangle(result_img, (x, y), (x + w, y + h), (0, 255, 0), 1)
```
```python:get_note.py
import cv2
import numpy as np

def get_ball(scr, lines_deleted_2d, staff_notion):
    #画像のサイズを取得
    height, width, channels = scr.shape
    image_size = height * width

    #グレースケール化 ①
    dst = cv2.cvtColor(scr, cv2.COLOR_RGB2GRAY)
    #五線を消す

    for line in lines_deleted_2d:
        x1, y1, x2, y2 = line
        dst = cv2.line(dst, (x1, y1), (x2, y2), (255,255,255), 1)

    dst = cv2.GaussianBlur(dst, (3, 3), sigmaX=3)
    #二値化
    retval_line, dst = cv2.threshold(dst, 150, 255, cv2.THRESH_BINARY)
    #白黒反転　③
    dst = cv2.bitwise_not(dst)
    #もっかい二値化
    retval, dst = cv2.threshold(dst, 0, 255, cv2.THRESH_BINARY | cv2.THRESH_OTSU)
    #輪郭を抽出
    cnt, hierarchy = cv2.findContours(dst, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)
    QnoteBall = []
    
    #五線譜の間隔の平均を求める
    gap_ave = 0
    gap_counter = 0
    for i in range(len(lines_deleted_2d)):
        if(i == len(lines_deleted_2d) - 1):
            break
        gap_counter += 1
        if(gap_counter == 5):
            gap_counter = 0
        else:
            gap_ave += lines_deleted_2d[i][1] - lines_deleted_2d[i + 1][1]
    gap_ave = gap_ave/(len(lines_deleted_2d)/5 * 4)
    gap_ave = abs(gap_ave)
    gap_ave = round(gap_ave)


    #大きいor小さい領域は削除
    for i, count in enumerate(cnt):
        x,y,w,h = cv2.boundingRect(count)
        if h > gap_ave * 0.65 and h < gap_ave * 1.45:
            if w > gap_ave and w < gap_ave * 2.5:
                QnoteBall.append([x, y, w, h])
    return QnoteBall
```
## 結果
　楽譜の権利の関係が怖いので実行例は載せないが、結果としては楽譜によって成功する時としない時の差が激しい。五線の認識はどれも上手くいくが、音符の認識が上手くいかない時がある。考えられる理由としては、音符の玉の部分の分離が上手くいかず、抽出される輪郭が大きくなり、大きすぎる領域として除外されてしまっているということだ。これらの問題を解決するのに一番早いのはやはり機械学習だろう。
## 最後に
　今回は自分の過去の駄作について紹介した。改めて見返しているとコードが粗だらけで、この記事を書いている中でいくつもの改善点を見つけた。今後余裕があれば、コード全体を書き直したり機械学習を使っての画像認識を行ってみようと思う。