ChordSticker의 인식 과정은 크게 네 단계입니다. 오선을 찾고, 그 위쪽 코드 표기 띠를 잘라내고, 띠 안에서 코드 하나하나를 분리하고, 분리된 조각을 문자 검출 모델에 넣습니다.
이 중 세 번째 단계가 병목이라는 심증이 있었습니다. 잘라진 조각 단위로는 문자 인식이 거의 틀리지 않는데, 조각을 잘못 자르면 그 뒤가 다 무의미해집니다. 그래서 분할 방식을 네 가지 새로 만들어 기존 방식과 정면 비교했습니다.
결과는 예상과 달랐습니다. 새로 만든 네 가지가 전부 기존 방식보다 나빴습니다.
문제는 문자 인식이 아니라 그루핑이었다
증상은 세 가지로 나타났습니다.
- 잘림 —
Am7이A와m7으로 갈라진다 - 붙음 — 옆 코드까지 한 덩어리로 묶인다
- 누락 — 코드가 아예 검출되지 않는다
원인을 추적해 보니 세 증상이 모두 한 곳에서 나왔습니다. 기존 분할은 고정 픽셀값으로 판정합니다. 글자 덩어리 중 세로 22픽셀보다 큰 것은 버리고, 가로로 12픽셀 이상 비어 있으면 다른 코드로 봅니다.
글자가 크면 같은 코드 안 글자 사이 간격도 12픽셀을 넘어 잘리고, 글자가 작으면 코드 사이 간격이 12픽셀에 못 미쳐 붙습니다. 상수 하나가 두 증상을 동시에 만들고 있었습니다.
비교한 다섯 가지 방식
| 이름 | 방식 |
|---|---|
| 기존 | 연결 요소 분석으로 글자 후보를 고른 뒤, 세로 투영에서 12픽셀 이상 비면 코드 경계로 판정 |
| 상대 임계 | 같은 구조지만 모든 상수를 글자 높이 중앙값의 배수로 바꿈. 공백 임계는 0.55배 |
| 닫힘 연산 | 글자 높이의 0.45배 폭으로 가로 닫힘 연산을 걸어, 코드 안 글자는 붙이고 코드 사이는 남긴 뒤 덩어리를 그대로 박스로 씀 |
| 닫힘 + 가드 | 위와 같지만, 조립된 이름이 코드 사전에 없으면 그 박스를 버림 |
| 통째로 넣기 | 분할을 아예 하지 않고 리본 전체를 256픽셀 타일로 잘라 문자 검출에 넣은 뒤, 검출된 문자 좌표를 간격으로 클러스터링 |
여기에 최소 수정판 두 개를 더 넣었습니다. 기존 방식에서 세로 상한만 상대값으로 바꾼 것, 그리고 공백 임계까지 상대값으로 바꾼 것입니다.
측정 결과
정답 138개(악보 세 장) 기준입니다. 문자 인식 관련 버그 두 개를 고친 상태에서 측정했습니다.
| 전략 | 검출 | 정답 일치 | 정밀도 | 재현율 | F1 |
|---|---|---|---|---|---|
| 기존 + 상한만 상대화 | 158 | 134 | 84.8% | 97.1% | 90.5 |
| 기존 + 상한·공백 모두 상대화 | 158 | 134 | 84.8% | 97.1% | 90.5 |
| 기존 (그대로) | 156 | 133 | 85.3% | 96.4% | 90.5 |
| 상대 임계 | 161 | 133 | 82.6% | 96.4% | 89.0 |
| 닫힘 + 가드 | 164 | 132 | 80.5% | 95.7% | 87.4 |
| 닫힘 연산 | 169 | 132 | 78.1% | 95.7% | 86.0 |
| 통째로 넣기 | 144 | 105 | 72.9% | 76.1% | 74.5 |
기존 방식과 최소 수정판이 공동 1위입니다. 새로 만든 네 가지는 모두 아래에 있습니다.
흥미로운 점은 상한만 상대화한 것과 공백까지 상대화한 것의 수치가 완전히 같다는 것입니다. 공백 임계를 상대값으로 바꾸는 작업은 이 표본에서 아무 효과가 없었습니다. 그래서 변경이 더 적은 쪽을 채택했습니다.
통째로 넣기는 왜 완패했나
가장 기대했던 방식이 가장 크게 실패했습니다. 재현율 76.1%로, 정답 138개 중 33개를 놓쳤습니다.
원인은 모델의 학습 조건과 입력 조건이 어긋난 데 있었습니다. 문자 검출 모델은 코드 하나가 담긴 작은 조각으로 학습됐습니다. 조각은 글자 높이가 32픽셀이 되도록 정규화되어 256×128 캔버스 가운데에 놓입니다.
리본 전체를 넣으면 이 정규화가 깨집니다. 가로 1600픽셀, 세로 30픽셀인 리본을 같은 함수에 통과시키면 이렇게 됩니다.
| 단계 | 크기 | 글자 높이 |
|---|---|---|
| 입력 | 1600 × 30 | 약 20픽셀 |
| 세로를 32픽셀로 맞춤 | 1707 × 32 | 32픽셀 |
| 256×128 안에 들어가도록 축소 | 256 × 4 | 4픽셀 |
가로가 캔버스보다 훨씬 길어서 비율을 유지한 채 축소되고, 그 과정에서 글자가 4픽셀로 뭉개집니다. 학습 때의 32픽셀과 8배 차이입니다. 검출이 안 되는 것이 당연합니다.
그래서 타일로 잘라 글자 높이를 32픽셀로 유지한 채 넣어봤습니다. 이번에는 입력 규격이 맞았는데도 결과가 나빴습니다. 여러 코드가 한 타일에 들어간 상태는 학습 분포와 여전히 다르기 때문입니다. 이 방향은 재학습 없이는 어렵다고 판단해 접었습니다.
닫힘 연산은 왜 나빴나
가로 닫힘 연산은 원리가 깔끔합니다. 글자 높이의 0.45배 폭으로 연산을 걸면 코드 안 글자 간격은 메워지고 코드 사이 간격은 남습니다. 상대 폭이라 해상도에 무관합니다.
실제로 재현율은 나쁘지 않았습니다(95.7%). 문제는 정밀도 78.1%였습니다. 검출을 169개나 해서 오검출이 37개 나왔습니다.
덩어리를 그대로 박스로 쓰기 때문에 글자가 아닌 것도 덩어리가 되면 박스가 됩니다. 리허설 마크, 템포 표시, 손글씨 주석이 전부 코드 후보로 올라왔습니다.
여기에 제가 처음에 넣은 세로 닫힘 연산이 문제를 하나 더 만들었습니다. 괄호 텐션처럼 위아래로 떨어진 글자를 붙이려고 넣었는데, 2단으로 적힌 서로 다른 코드까지 하나로 뭉갰습니다.
악보에 Dm7이 위, Bb(add2)가 아래에 2단으로 적힌 자리가 있었습니다. 세로 닫힘 연산이 두 코드를 한 덩어리로 묶고, 두 줄의 글자가 섞인 상태로 문자 검출에 들어가 B7(add2)라는 엉뚱한 이름이 나왔습니다. 코드 두 개가 하나의 오답으로 바뀐 셈입니다.
세로 방향 분리는 파이프라인 뒷단에 이미 담당하는 단계가 있었습니다. 앞단에서 붙여버리면 그 단계가 할 일을 빼앗는 셈이라, 세로 닫힘 연산은 제거했습니다.
가드가 오류를 소실로 바꿨다
정밀도를 올리려고 가드를 붙였습니다. 조립된 이름이 코드 사전에 없으면 그 박스를 버리는 규칙입니다. 사전에 없는 이름은 어차피 다이어그램이 안 붙으니 버려도 손실이 없다고 생각했습니다.
실제로 F1은 86.0에서 87.4로 올랐습니다. 그런데 사용해 보니 화면에서 코드가 사라졌습니다.
추적해 보니 이런 연쇄였습니다. 문자 검출이 F(add2)를 F(add22)로 읽었습니다. 숫자 2가 두 번 검출된 것입니다. 이 이름은 사전에 없으니 가드가 박스를 통째로 버렸습니다. 결과적으로 그 자리에 코드가 있었다는 사실 자체가 화면에서 없어졌습니다.
가드가 없으면 F(add22)라는 잘못된 이름이 뱃지로 표시됩니다. 다이어그램은 안 붙지만, 사용자가 그것을 보고 F(add2)로 고칠 수 있습니다. 가드가 있으면 고칠 대상 자체가 사라집니다. 편집 화면에 코드를 추가하는 기능이 없으니, 가드는 고칠 수 있는 오류를 고칠 수 없는 소실로 바꿉니다.
F1이 1.4점 오르는 대신 사용자가 손쓸 수 없는 누락이 생긴다면 나쁜 거래입니다. 가드는 폐기했습니다.
AND 하나가 볼타 브래킷을 통과시켰다
상대 임계 방식이 특정 악보에서 여러 코드를 하나로 묶는 문제가 있었습니다. 원인이 예상 밖이었습니다.
기존 코드의 글자 후보 필터는 이렇습니다.
if (width / float(height)) > 4.5 or height > 22:
continue
제가 상대 임계 방식을 만들 때 이 조건을 이렇게 바꿨습니다.
if h < 0.30 * h_med or h > 2.6 * h_med:
continue
if (w / float(h)) > 4.5 and h < 0.8 * h_med:
continue
종횡비 조건에 and를 붙여 약화시킨 것입니다. 그러면 가로로 아주 긴 것도 세로 높이가 글자만 하면 통과합니다. 볼타 브래킷이 정확히 그런 모양입니다. 긴 가로줄에 세로 갈고리가 붙어 있어서 높이가 글자와 비슷합니다.
기존 방식은 or였기 때문에 종횡비만으로 브래킷을 걸러내고 있었습니다. 제가 그 안전장치를 없앤 것입니다. 자세한 측정값은 볼타 브래킷 글에 적었습니다.
속도는 판단 근거가 되지 못했다
처음에는 서버 부하도 비교 항목이었습니다. 결과적으로는 판단에 영향을 주지 못했습니다.
| 전략 | 분할 시간 (악보 6장 합계) | 문자 검출 호출 |
|---|---|---|
| 기존 | 약 23밀리초 | 조각 수만큼 |
| 상대 임계 | 약 19밀리초 | 조각 수만큼 |
| 닫힘 연산 | 약 22밀리초 | 조각 수만큼 |
| 통째로 넣기 | 약 17밀리초 | 타일 수만큼 (오히려 적음) |
분할 자체는 어떤 방식이든 악보 여섯 장 합쳐서 20밀리초 안팎입니다. 전체 처리 시간의 대부분은 문자 검출이고, 분할 방식이 호출 횟수를 크게 바꾸지도 않았습니다. 정확도만 보고 정하면 되는 문제였습니다.
결론
채택한 것은 기존 방식에서 세로 상한만 상대값으로 바꾼 것입니다. 변경량은 한 줄입니다.
- if (width / float(height)) > 4.5 or height > 22:
+ height_limit = 2.6 * float(np.median(candidate_heights))
+ if (width / float(height)) > 4.5 or height > height_limit:
기존 방식과 F1은 같지만 재현율이 0.7%p 높습니다. 종횡비 조건은 or로 유지해서 볼타 브래킷 차단 기능을 그대로 남겼습니다.
폐기한 것은 통째로 넣기, 닫힘 연산, 가드입니다. 상대 임계 방식은 원래 목표였던 해상도 무관성은 달성했지만, 그 대가로 기존 안전장치를 잃어서 순위가 내려갔습니다.
한계
정답이 악보 세 장 138개뿐입니다. 이 표본에는 볼타 브래킷이 있는 악보가 한 장 있고, 대문자 M 표기를 쓰는 악보가 한 장 있습니다. 다른 표기 관습을 가진 악보가 많아지면 순위가 바뀔 수 있습니다.
실제로 이번에도 표본이 작아서 문제가 있었습니다. 측정 도구에서 한 단계를 빠뜨렸을 때 순위가 완전히 뒤집혔는데, 표본이 컸다면 그렇게까지 흔들리지 않았을 것입니다. 악보를 더 라벨링하는 것이 다음 할 일 중 하나입니다.