ChordSticker는 악보 사진에서 코드 이름을 읽어 운지법 그림을 붙여주는 도구입니다. 오래 쓰다 보니 가끔 코드를 놓친다는 느낌은 있었지만, 얼마나 놓치는지도 왜 놓치는지도 몰랐습니다. 이번에 처음으로 정답 데이터를 만들어 숫자로 재봤습니다.
결론부터 적으면, 분할 알고리즘을 새로 쓰는 것보다 이미 코드 안에 있던 버그 두 개를 고치는 쪽이 훨씬 효과가 컸습니다. 그리고 그 과정에서 제가 만든 측정 도구 자체에 결함이 있어서 한 번은 결론이 뒤집혔습니다. 그 얘기까지 같이 적습니다.
눈으로 보는 것만으로는 판단이 안 됐다
처음에는 정답 없이 대리 지표만 봤습니다. 인식된 코드 중 몇 개가 내장 코드 사전에서 운지를 찾을 수 있는지, 즉 사전 적중률을 봤습니다. 이 값이 98~99%로 나와서 한동안 잘 되고 있다고 생각했습니다.
그런데 이 지표에는 치명적인 맹점이 있었습니다. 아예 못 찾은 코드는 분모에도 들어가지 않습니다. 검출된 것들 중 몇 개가 사전에 있느냐만 보는 값이라, 악보에 코드가 열 개 있는데 세 개만 찾고 그 세 개가 모두 사전에 있으면 적중률 100%가 나옵니다.
누락을 세려면 정답이 필요했습니다.
정답 138개를 손으로 만들었다
테스트 악보 세 장을 화면에 띄워놓고 코드 이름을 하나하나 옮겨 적었습니다.
| 악보 | 코드 수 | 특징 |
|---|---|---|
| 찬양곡 (14단) | 101 | 볼타 브래킷 4곳, C#m7 같은 샵 코드 다수 |
| 축복송 (3단) | 22 | 손글씨 주석이 섞여 있음 |
| 창작곡 (3단) | 15 | GM7, CM7처럼 대문자 M 표기 |
합쳐서 138개입니다. 순서는 무시하고 개수만 비교하는 방식으로 채점했습니다. 같은 코드가 여러 번 나오면 나온 횟수까지 맞춰야 정답으로 셌습니다.
지표는 세 가지를 봤습니다. 정밀도는 검출한 것 중 맞은 비율, 재현율은 정답 중 찾아낸 비율, F1은 둘의 조화평균입니다.
편집 화면에서 잘못 인식된 코드는 클릭 한 번에 지울 수 있습니다. 하지만 못 찾은 코드는 추가할 방법이 없습니다. 코드를 새로 만드는 기능이 없기 때문입니다. 이 비대칭 때문에 정밀도가 조금 낮아도 재현율이 높은 쪽을 택하는 편이 사용자에게 이득입니다.
버그 하나: M을 근음으로 착각하고 있었다
인식 모델은 단어를 통째로 읽지 않고 문자 하나하나를 위치와 함께 찾아냅니다. 그래서 찾아낸 문자들을 다시 코드 단위로 묶어야 합니다. 이때 쓰던 규칙이 이랬습니다.
if char.isupper() and current_chord_chars and current_chord_chars[-1] != "/":
# 새 코드가 시작된 것으로 보고 끊는다
대문자가 나오면 새 코드가 시작된 것이라는 판정입니다. 근음은 A부터 G까지 대문자로 적히니 맞는 것처럼 보입니다.
문제는 인식 대상 문자 목록에 대문자 M이 들어 있다는 점입니다. 메이저 세븐스를 CM7처럼 적는 표기를 읽기 위해 넣은 클래스인데, M도 대문자라 새 코드로 판정됩니다.
| 악보 표기 | 검출된 문자 | 조립 결과 |
|---|---|---|
| GM7 | G, M, 7 | G + M7 (두 개로 갈라짐) |
| CM7 | C, M, 7 | C + M7 |
세 번째 테스트 악보가 전부 GM7, CM7, Em9 표기였습니다. 여기서 정답 15개 중 상당수가 이 방식으로 쪼개지고 있었습니다. 결과적으로 M7이라는, 근음도 없는 조각이 코드 목록에 남았습니다.
고친 내용은 한 줄입니다. 근음으로 쓸 수 있는 A부터 G까지만 새 코드를 시작하게 했습니다.
ROOT_NOTES = set("ABCDEFG")
if char in ROOT_NOTES and current_chord_chars and current_chord_chars[-1] != "/":
버그 둘: CM7을 사전에서 못 찾고 있었다
위 버그를 고치니 CM7이 온전한 하나의 코드로 나왔습니다. 그런데 다이어그램이 안 붙었습니다.
내장 코드 사전은 메이저 세븐스를 maj7이라는 이름으로만 갖고 있습니다. CM7과 Cmaj7은 완전히 같은 코드지만, 문자열이 다르니 조회에 실패합니다. 사전에는 add2를 add9로 바꿔주는 처리가 이미 있었는데 M7 계열에 대한 처리는 없었습니다.
MAJOR_SEVENTH_PATTERN = re.compile(r"M(7|9|11|13)")
clean_name = MAJOR_SEVENTH_PATTERN.sub(lambda m: "maj" + m.group(1), clean_name)
maj7은 소문자 m으로 시작하므로 이 패턴에 걸리지 않습니다. 기존에 잘 되던 Cmaj7, Am7, Em9가 그대로 동작하는지는 따로 확인했습니다.
고정 22픽셀 상한에 샵이 걸려 사라졌다
세 번째는 성격이 다른 문제입니다. 코드 글자를 후보로 걸러낼 때 이런 조건이 있었습니다.
if (width / float(height)) > 4.5 or height > 22:
continue # 글자가 아니라고 보고 버린다
가로로 길고 납작한 것은 오선 조각이니 버리고, 세로로 22픽셀보다 큰 것도 버립니다. 후자는 가사 글자나 다른 보표가 딸려 들어오는 것을 막으려고 넣은 조건입니다.
그런데 샵 기호는 알파벳보다 세로로 깁니다. 해상도가 큰 악보에서는 알파벳이 18픽셀일 때 샵이 25픽셀쯤 됩니다. 그러면 알파벳은 남고 샵만 사라집니다.
여기서 두 번째 문제가 연쇄적으로 발생합니다. 샵이 지워진 자리에 빈 공간이 생기고, 코드 경계를 판정하는 기준이 12픽셀 이상 비어 있으면 다른 코드였기 때문에 C#m7이 C와 m7으로 갈라졌습니다. 사용 중에 발견된 증상이 정확히 이것이었습니다.
고정 픽셀값을 글자 높이 중앙값의 상대값으로 바꿨습니다.
candidate_heights = [ ... ] # 먼지만 걸러낸 글자 후보들의 높이
height_limit = 2.6 * float(np.median(candidate_heights))
if (width / float(height)) > 4.5 or height > height_limit:
continue
이러면 스캔 해상도가 달라져도 같은 기준으로 동작합니다. 종횡비 조건은 그대로 뒀습니다. 이 조건이 나중에 예상 밖의 역할을 하고 있었다는 것을 알게 됐는데, 그 얘기는 볼타 브래킷 글에 따로 적었습니다.
측정값
정답 138개 기준입니다. YOLO 추론까지 거친 최종 코드 이름으로 채점했습니다.
| 상태 | 검출 | 정답 일치 | 정밀도 | 재현율 | F1 |
|---|---|---|---|---|---|
| 수정 전 | 159 | 130 | 81.8% | 94.2% | 87.5 |
| 버그 두 개 수정 | 156 | 133 | 85.3% | 96.4% | 90.5 |
| + 높이 상한 상대화 | 158 | 134 | 84.8% | 97.1% | 90.5 |
F1이 87.5에서 90.5로, 3.0점 올랐습니다. 정밀도는 3.5%p, 재현율은 2.2%p 개선됐습니다. 세 번째 수정은 F1은 같지만 재현율이 0.7%p 더 높아서, 앞서 말한 이유로 이쪽을 채택했습니다.
악보별로 보면 어디서 개선이 나왔는지 분명합니다.
| 악보 | 수정 전 F1 | 수정 후 F1 | 변화 |
|---|---|---|---|
| 창작곡 (GM7, CM7 표기) | 57.9 | 80.0 | +22.1 |
| 축복송 | 79.2 | 79.2 | 변화 없음 |
개선이 사실상 한 악보에서만 나왔습니다. 대문자 M 표기를 쓰는 악보였고, 나머지는 그 표기가 없어서 영향이 없었습니다. 이 점은 솔직하게 밝혀둡니다. 표기 관습에 따라 효과가 크게 갈리는 수정입니다.
수정 후 실제 인식 개수도 확인했습니다.
| 악보 | 수정 전 | 수정 후 |
|---|---|---|
| 축복송 (3단) | 31개 | 34개 |
| 찬양곡 (14단) | 105개 | 104개 |
| 발라드 (10단) | 80개 | 80개 |
| 창작곡 (3단) | 20개 | 20개 |
샵이 복구된 악보에서 3개가 늘었고, 나머지는 거의 같습니다. 부작용 없이 놓치던 것만 잡았다는 뜻으로 읽었습니다.
알고리즘보다 버그가 먼저였다
이번 작업을 시작할 때는 분할 알고리즘을 새로 쓸 생각이었습니다. 실제로 네 가지 방식을 새로 구현해서 비교했습니다. 결과는 분할 전략 비교 글에 정리했는데, 요약하면 새로 만든 것들이 전부 기존 방식보다 나빴습니다.
개선 3.0점 중 2.9점이 버그 수정에서 나왔습니다. 알고리즘 교체가 아니라 이미 있던 코드의 잘못된 판정 두 줄이었습니다.
그리고 한 가지 더. 중간에 측정 도구 자체에 결함이 있어서 결론이 한 번 뒤집혔습니다. 실제 파이프라인에는 코드 박스를 위아래로 한 번 더 나누는 단계가 있는데, 비교 도구가 그 단계를 빠뜨렸습니다. 그 상태에서는 기존 방식이 실제보다 불리하게 측정되어 새 방식이 더 좋다는 잘못된 결론이 나왔습니다. 빠진 단계를 넣고 다시 돌리니 순위가 완전히 바뀌었습니다.
새로 만든 게 기존보다 좋다는 결과가 나왔을 때 의심을 덜 했습니다. 원하는 답이 나왔기 때문입니다. 비교 도구가 실제 파이프라인과 같은 경로를 타는지 먼저 확인했다면 한 단계를 줄일 수 있었습니다.
남은 문제
정밀도가 84.8%입니다. 오검출의 출처를 추적해 보니 분할 단계가 아니라 코드 리본을 찾는 단계였습니다. 리허설 마크, 템포 표시, 손글씨 주석, 그리고 코드가 아예 없는 악보의 가사 줄까지 코드 표기 영역으로 잡고 있었습니다.
코드가 하나도 없는 악보를 넣어보니 최대 33개를 검출했습니다. 전부 가사에서 나온 것이었습니다. 재현율은 97.1%까지 올라왔으니 다음 작업은 여기여야 합니다.
정답 데이터가 악보 세 장 138개뿐이라는 한계도 있습니다. 이번에 결론이 한 번 뒤집힌 것도 표본이 작아서 측정 도구 결함 하나에 순위가 흔들렸기 때문입니다. 악보를 더 라벨링해서 다시 검증할 계획입니다.