개발 기록

위치 다이어그램 잡는 팁

악보 위 어느 자리에 그림을 붙일지 정하는 과정에는 서로 다른 좌표계 세 개가 등장합니다. 리본 편집기를 만들면서 좌표계를 하나로 통일한 방법과, 왕복 검증으로 확인한 결과를 적었습니다.

작성 2026-08-03약 10분 분량개발 기록좌표계리본 편집기

ChordSticker에 코드 리본을 직접 지정하는 기능을 붙였습니다. 자동으로 찾은 코드 표기 영역이 틀렸을 때 사용자가 박스를 끌어 고치고, 그 리본에서 다시 인식하는 기능입니다.

기능 자체는 단순한데, 여기서 다루는 좌표가 여러 좌표계를 오갑니다. 하나만 틀려도 다이어그램이 엉뚱한 곳에 붙습니다. 좌표계를 정리한 과정과 검증 방법을 적습니다.

좌표계가 세 개 있었다

인식 과정에서 등장하는 좌표계입니다.

이름정의쓰이는 곳
원본업로드된 이미지 그대로결과 이미지 저장, 다이어그램 합성
작업본원본의 위아래 20픽셀을 잘라낸 것오선 탐지, 코드 띠 탐지, 코드 분할
화면브라우저가 이미지를 축소해 그린 크기편집기의 뱃지 위치, 마우스 입력

작업본이 따로 있는 이유는 스캔 테두리와 그림자입니다. 이미지 맨 위나 맨 아래에 생긴 어두운 띠가 오선으로 오인되는 일이 잦아서, 위아래를 잘라내고 시작합니다.

문제는 이 20픽셀 때문에 같은 위치를 가리키는 두 개의 y값이 존재하게 된다는 점입니다.

원본_y = 작업본_y + 20

오선과 코드 띠를 찾는 코드는 작업본 좌표로 동작하고, 결과를 저장할 때는 원본 좌표로 바꿔서 내보냅니다. 코드 위치를 담는 부분을 보면 이렇게 되어 있습니다.

actual_y1 = job["final_y1"] + crop_margin
actual_y2 = job["final_y2"] + crop_margin

20픽셀이 어디서 새는가

리본 편집기를 붙이면 이 변환이 왕복이 됩니다.

  1. 서버가 찾은 리본을 편집기에 보낸다
  2. 사용자가 박스를 옮긴다
  3. 편집기가 리본을 서버로 보낸다
  4. 서버가 그 리본에서 다시 인식한다

각 단계마다 어느 좌표계인지 정해야 합니다. 그리고 20을 더할 곳과 빼야 할 곳을 하나씩 짝지어야 합니다. 짝이 하나 틀리면 결과가 20픽셀씩 밀립니다.

코드 글자 높이가 16픽셀 정도이므로 20픽셀은 글자 한 줄보다 큽니다. 밀리면 리본이 코드를 벗어나 아무것도 검출되지 않거나, 아래 오선을 물어서 엉뚱한 것을 읽습니다.

리본을 원본 좌표로 통일했다

덧셈과 뺄셈의 짝을 맞추는 대신, 변환이 등장하지 않는 경로를 만들었습니다.

리본을 주고받을 때는 항상 원본 좌표를 씁니다. 서버가 리본을 내보낼 때 한 번만 변환합니다.

ribbons = [
    {"x1": 0, "y1": start + crop_margin, "x2": img_w, "y2": end + crop_margin}
    for start, end in filtered_bands
]

그리고 재인식 경로에서는 작업본을 아예 만들지 않습니다. 리본이 이미 원본 좌표이므로 원본에서 직접 잘라냅니다.

ribbon_img = origin_img[ry1:ry2, rx1:rx2]

for local_x1, y1, local_x2, y2 in split_chords_in_band(ribbon_img, ry1):
    abs_x1 = rx1 + local_x1
    abs_x2 = rx1 + local_x2

여기서 쓴 요령이 하나 있습니다. 코드 분할 함수는 두 번째 인자로 시작 y값을 받아서 결과 y에 더해줍니다. 원래는 작업본 안에서의 띠 위치를 넘기던 자리인데, 여기에 리본의 원본 y좌표를 넘기면 돌아오는 y가 곧바로 원본 좌표가 됩니다. 나중에 20을 더할 일이 없어집니다.

x는 리본 왼쪽 끝을 더하는 것으로 끝납니다. 자동 탐지 경로에서는 리본이 이미지 폭 전체를 차지해서 x 오프셋이 0이었기 때문에 이 덧셈이 없었는데, 사용자가 리본의 좌우를 좁힐 수 있게 되면서 필요해졌습니다.

결과적으로 조립 단계에 y 오프셋을 인자로 받는 함수 하나를 두고, 자동 탐지 경로는 20을, 리본 경로는 0을 넘깁니다.

왜 이 방식이 안전한가

변환 지점이 두 곳으로 줄었습니다. 리본을 내보낼 때 한 번, 조립할 때 한 번입니다. 그리고 두 번째는 상수 0이라 실질적으로 한 곳입니다. 중간 단계마다 좌표계를 확인할 필요가 없어졌습니다.

왕복 검증으로 확인했다

좌표가 맞는지 확인하려고 편집기 UI를 만들기 전에 검증부터 했습니다. 방법은 단순합니다.

자동으로 찾은 리본을 그대로 재인식 경로에 넣으면 같은 결과가 나와야 합니다. 리본을 바꾸지 않았으니 인식 결과도 같아야 합니다. 좌표가 어긋나 있으면 여기서 바로 깨집니다.

악보자동 탐지리본 수리본으로 재인식일치
발라드 (10단)80개1080개일치
찬양곡 (14단)104개14104개일치
축복송 (3단)34개334개일치
창작곡 (3단)20개320개일치
반주 악보 A (8단)48개848개일치
반주 악보 B (7단)60개760개일치

개수만 비교한 것이 아니라 코드 이름 목록을 순서까지 그대로 비교했습니다. 여섯 장 모두 완전히 같았습니다.

UI를 먼저 만들었다면 화면에서 박스 위치가 이상할 때 원인이 좌표 변환인지 브라우저 렌더링인지 구분하기 어려웠을 것입니다. 서버 쪽 좌표만 따로 검증해 두면 그 뒤에 생기는 문제는 브라우저 쪽으로 범위가 좁혀집니다.

편집기가 보내는 값은 믿을 수 없다

사용자가 꼭짓점을 자유롭게 끌 수 있으면 이상한 값이 들어옵니다. 오른쪽 손잡이를 왼쪽 끝을 지나 끌면 x1이 x2보다 커집니다. 이미지 밖으로 끌면 음수가 됩니다.

서버에서 한 번에 정리하는 함수를 뒀습니다. 처리하는 경우는 이렇습니다.

입력처리 결과
x1이 x2보다 큼 (좌우 뒤집힘)작은 값을 x1으로 교환
y1이 y2보다 큼 (상하 뒤집힘)교환
이미지 경계 밖 (음수, 초과)경계 안으로 자름
가로 12픽셀 또는 세로 8픽셀 미만버림
문자열로 온 숫자숫자로 변환
키가 빠졌거나 값이 없음버림
정렬되지 않은 순서위에서 아래, 왼쪽에서 오른쪽으로 정렬

마지막 정렬이 중요합니다. 인식 결과는 라인 번호를 붙여 내보내는데, 리본 순서가 뒤죽박죽이면 라인 번호가 악보 순서와 어긋납니다. 사용자가 리본을 추가한 순서와 악보에서의 위치는 무관하므로, 서버에서 다시 정렬해야 합니다.

이 함수는 만든 직후에 위 일곱 가지 경우를 하나씩 넣어 결과를 확인했습니다. 뒤집힌 입력이 정상 사각형으로 나오고, 너무 얇은 것과 키가 빠진 것은 빈 목록으로 나오는 것까지 봤습니다.

브라우저 쪽 좌표 변환

브라우저에서는 이미지가 화면 크기에 맞춰 축소되어 그려집니다. 자연 크기 1600픽셀짜리 이미지가 900픽셀로 보이는 상황에서, 마우스 위치를 원본 좌표로 바꿔야 합니다.

배율을 계산해서 곱하는 방식은 실수가 나기 쉽습니다. 이미지가 반응형으로 줄어들면 배율이 계속 바뀌고, 어느 시점의 배율을 썼는지 추적해야 합니다.

대신 비율만 씁니다. 리본은 원본 픽셀로 저장하고, 화면에 그릴 때는 백분율로 바꿉니다.

left = (ribbon.x1 / naturalWidth) * 100    // % 단위
top  = (ribbon.y1 / naturalHeight) * 100

마우스 입력은 반대 방향으로 같은 비율을 씁니다.

const rect = frame.getBoundingClientRect();
const x = ((clientX - rect.left) / rect.width) * naturalWidth;
const y = ((clientY - rect.top) / rect.height) * naturalHeight;

배율 변수를 따로 두지 않으니 어긋날 여지가 없습니다. 단 하나의 조건이 있습니다. 기준이 되는 프레임이 이미지를 정확히 감싸야 합니다. 프레임에 여백이 있거나 이미지가 프레임 안에서 레터박스로 들어가면, 프레임 비율과 이미지 비율이 달라져서 전부 어긋납니다.

그래서 프레임에는 패딩을 주지 않고, 이미지는 폭 100%에 높이 자동으로 놓아 프레임 높이가 이미지 높이가 되도록 했습니다.

최소 크기를 양쪽에서 같게

서버는 가로 12픽셀, 세로 8픽셀 미만 리본을 버립니다. 클라이언트에서 같은 값으로 막지 않으면, 사용자가 아주 얇은 리본을 만들었을 때 서버가 조용히 버리고 사용자는 이유를 모릅니다. 두 곳의 숫자를 같게 맞추고 주석으로 서로를 가리키게 했습니다.

원본 이미지가 남아 있지 않았다

마지막으로, 기능을 붙이려다 발견한 구조적 문제입니다.

재인식에는 원본 이미지가 필요합니다. 그런데 업로드 처리를 보니 분석이 끝난 직후 업로드 파일을 삭제하고 있었습니다.

finally {
  await removeFile(req.file?.path);
}

보관하던 것은 인식 위치가 빨간 사각형으로 표시된 이미지뿐이었습니다. 이 이미지로 다시 인식하면 그려 넣은 사각형까지 글자로 읽으려 하니 쓸 수 없습니다.

그래서 원본도 함께 보관하도록 바꿨습니다. 보관 기간과 접근 제한은 기존 결과 이미지와 같은 규칙을 씁니다. 무작위 파일명, 서명이 포함된 접근 주소, 기간 경과 후 자동 삭제입니다.

결과적으로 리본 편집기 기능 하나를 붙이는 데 서버 두 곳과 브라우저를 모두 손봐야 했습니다. 좌표계 정리가 절반, 원본 보관이 나머지 절반이었습니다.

정리

좌표계가 여러 개일 때 실수를 줄이는 방법은 변환을 잘 맞추는 것이 아니라 변환이 나타나는 지점을 줄이는 것이었습니다. 리본을 원본 좌표로 통일하고, 분할 함수에 시작 y를 넘기는 방식으로 재인식 경로에서는 변환을 없앴습니다.

그리고 UI보다 좌표 검증을 먼저 했습니다. 자동 탐지 결과를 되먹여 같은 값이 나오는지 보는 검증이 여섯 장에서 전부 통과한 뒤에 화면을 만들었습니다. 순서를 반대로 했다면 문제의 원인을 좁히기 어려웠을 것입니다.