개발 기록

볼타 브래킷 하나가 코드 네 개를 한 덩어리로 만들었다

악보의 1번·2번 반복 괄호를 그리는 긴 가로줄이 코드 인식을 망가뜨리는 과정을 측정값으로 추적했습니다. 글자 높이의 17.7배 폭으로 병합된 박스를 찾아내고, 획만 골라 지우는 방법으로 해결했습니다.

작성 2026-08-03약 10분 분량개발 기록영상 처리악보 기호

악보에서 반복 구간을 표시할 때 오선 위에 긴 가로줄을 긋고 왼쪽 끝을 아래로 꺾어 1., 2. 같은 번호를 붙입니다. 볼타 브래킷이라고 부릅니다.

이 브래킷이 코드 표기 영역 안에 그려지기 때문에, 코드를 찾는 과정에 그대로 끼어듭니다. 그 결과로 코드 네 개가 한 덩어리로 묶이는 일이 있었습니다. 원인을 추적하고 고친 과정을 적습니다.

증상: 코드 여러 개가 한 박스로 묶인다

분할 방식을 새로 만들어 테스트하던 중에 발견했습니다. 특정 악보에서 코드 박스가 비정상적으로 넓게 잡혔고, 그 안에 코드가 여러 개 들어 있었습니다.

처음에는 자간이 좁은 악보라서 코드 사이 공백이 판정 기준에 못 미친 것으로 생각했습니다. 그런데 다른 악보에서는 같은 문제가 없었고, 문제가 나는 악보에는 공통적으로 볼타 브래킷이 있었습니다.

눈으로 찾지 말고 숫자로 찾기

악보를 확대해 가며 문제 지점을 찾는 것은 비효율적이었습니다. 대신 비정상적으로 넓은 박스를 프로그램으로 뽑았습니다. 판정 기준은 박스 가로폭이 그 띠의 글자 높이 중앙값의 3.5배를 넘는 경우입니다.

코드 이름은 길어도 F#m7b5 정도이고, 글자 높이의 3.5배를 넘는 일은 드뭅니다. 넘는다면 무언가 잘못 묶인 것입니다.

14단짜리 찬양곡 악보에서 나온 결과입니다.

단가로 범위글자 높이 대비 폭
4단617 ~ 90017.7배
6단662 ~ 88813.3배
1단764 ~ 8354.9배
3단759 ~ 8295.0배

4단과 6단이 확연히 튑니다. 280픽셀에 이르는 하나의 박스이고, 실제로 그 자리에 볼타 브래킷이 있었습니다. 나머지 4~5배짜리는 분수 코드처럼 이름이 긴 정상 케이스였습니다.

브래킷이 필터를 빠져나간 이유

글자 후보를 고르는 단계에는 가로로 길고 납작한 것을 버리는 조건이 있습니다. 오선 조각이나 밑줄을 걸러내려고 넣은 것입니다.

문제는 볼타 브래킷의 모양입니다. 긴 가로줄 하나만 있으면 납작해서 걸러집니다. 그런데 브래킷은 왼쪽 끝에 아래로 꺾인 세로 갈고리가 붙어 있습니다. 이 갈고리 때문에 하나의 연결된 덩어리로 인식되면서 높이가 글자와 비슷해집니다.

그러면 이런 상태가 됩니다.

대상가로세로종횡비
알파벳 한 글자약 12픽셀약 16픽셀0.8
오선 조각200픽셀2픽셀100
볼타 브래킷280픽셀약 16픽셀17.5

제가 만든 필터는 종횡비 조건에 높이 조건을 and로 묶어 두었습니다. 가로로 길면서 동시에 납작해야 버리는 규칙이었습니다. 브래킷은 가로로 길지만 납작하지 않아서 통과했습니다.

통과한 브래킷의 픽셀은 세로 투영에서 가로 전 구간을 채웁니다. 그 구간에는 빈 열이 하나도 없으므로, 코드 경계를 판정할 수 없게 됩니다. 브래킷이 지나가는 동안의 코드가 전부 하나로 이어집니다.

컴포넌트를 버리지 않고 획만 지운다

가장 단순한 해결은 브래킷 덩어리를 통째로 버리는 것입니다. 하지만 위험합니다. 브래킷 선에 코드 글자가 닿아 있으면 글자까지 같이 사라집니다. 볼타 번호 1., 2.는 브래킷에 거의 붙어 있습니다.

그래서 덩어리 단위가 아니라 획 단위로 지우기로 했습니다. 긴 가로 획만 추출해서 원본에서 빼는 방식입니다.

k = round(6.0 * 글자높이중앙값)
긴가로획 = cv2.morphologyEx(이진영상, cv2.MORPH_OPEN, np.ones((1, k), np.uint8))
결과 = cv2.subtract(이진영상, 긴가로획)

가로 1픽셀 높이의 긴 커널로 열림 연산을 하면, 그 길이 이상 연속된 가로 획만 남습니다. 이것을 원본에서 빼면 긴 가로줄만 사라지고 글자는 그대로 남습니다.

숫자와 알파벳이 걸릴 일이 없는 이유

커널 길이를 글자 높이의 6배로 잡았습니다. 어떤 숫자나 알파벳에도 자기 높이의 6배에 이르는 연속 가로 획은 없습니다. E의 가로 획도, 7의 윗변도 글자 폭을 넘지 못합니다. 그래서 이 연산은 글자를 건드리지 않습니다.

적용 결과입니다.

단제거 전제거 후
4단17.7배 (617~900)4.8배 (687~764)
6단13.3배 (662~888)목록에서 사라짐

두 곳 모두 정상 범위로 돌아왔습니다.

커널 길이를 잘못 잡아 다른 코드를 잃었다

처음에는 커널 길이를 글자 높이의 2.5배로 잡았습니다. 브래킷을 잡는 데는 충분했습니다. 그런데 다른 악보에서 정상 코드가 사라졌습니다.

손글씨 주석이 섞인 악보에서 Am/E가 A와 m/E로 갈라졌습니다. 2.5배 커널이 짧아서 손글씨 획이나 글자 일부까지 긁어냈고, 그 과정에서 Am/E의 획이 끊겼습니다.

커널 길이를 바꿔가며 측정했습니다.

커널 길이손글씨 악보 F1볼타 병합
글자 높이의 2.5배65.6 (퇴행)해결
글자 높이의 4.0배69.0 (퇴행)해결
글자 높이의 6.0배73.7 (퇴행 없음)해결

6.0배가 부작용 없이 브래킷만 잡는 지점이었습니다. 브래킷은 글자 높이의 13~18배 길이이므로 6배 커널로도 충분히 걸립니다. 여유가 크기 때문에 더 안전한 쪽을 택할 수 있었습니다.

그런데 원래 코드는 이미 막고 있었다

여기서 예상하지 못한 사실을 알게 됐습니다. 기존 코드의 필터를 다시 읽어보니 조건이 or였습니다.

if (width / float(height)) > 4.5 or height > 22:
    continue

종횡비가 4.5를 넘으면 높이와 무관하게 버립니다. 브래킷은 종횡비 17.5이므로 이 조건만으로 걸러집니다. 즉 기존 코드에는 볼타 문제가 없었습니다.

제가 상대 임계 방식을 만들면서 이 조건을 and로 바꿔 약화시켰고, 그래서 브래킷이 통과하게 된 것입니다. 획 제거 연산은 제가 만든 문제를 되돌린 것에 불과했습니다.

왜 or를 and로 바꿨나

상대 임계로 옮기는 과정에서 조건을 다시 쓸 때, 가로로 길지만 높이가 정상인 글자를 잘못 버릴까 걱정해서 높이 조건을 덧붙였습니다. 그런 글자는 실제로 존재하지 않는데도 방어적으로 조건을 넣었고, 그 결과 진짜 걸러야 할 것을 놓쳤습니다.

최종적으로 채택한 것은 기존 or 조건을 그대로 유지하면서 절대 높이 상한만 상대값으로 바꾼 버전입니다. 획 제거 연산은 채택 코드에 넣지 않았습니다. 필요가 없기 때문입니다.

남은 것

이 작업에서 실제로 남은 것은 세 가지입니다.

그리고 방어적으로 조건을 덧붙일 때는 그 조건이 막으려는 사례가 실제로 존재하는지 확인해야 한다는 것을 배웠습니다. 존재하지 않는 위험을 막으려다 실재하는 위험을 통과시켰습니다.