개발 취재 노트
0~1 정규화 좌표로 격자를 그리면 왜 정사각형이 직사각형으로 비뚤어질까
무용 연습실에서 여러 사람이 각자 폰으로 같은 안무 영상을 보며 대형(포메이션)과 동선을 맞춰 보는 웹 앱(formation)을 만들고 있었다. 영상 위에 격자를 띄워 무용수 위치를 찍는 기능이 있는데, 좌표를 격자에 맞춰 배치하려고 보니 배치가 격자와 맞지 않고 비뚤어 보인다는 지적이 나왔다.
결론
캔버스에서 화면 픽셀 기준으로는 정사각형인 격자 셀도, x·y를 각각 독립적으로 0~1로 정규화하면 정규화 좌표계에서는 영상 종횡비만큼 가로세로 간격이 달라진다. 이 프로젝트의 격자 정의는 cell = gridSize × (w/800) 였고, 영상이 1920×1080(16:9)이라 가로 간격은 50/800 = 0.0625, 세로 간격은 0.0625 × (16/9) ≈ 0.1111로 거의 두 배 차이가 났다 . 화면에서는 똑같은 정사각형 칸이지만, 좌표 숫자만 보면 전혀 다른 모양이 된다.
세로 간격 계산에 종횡비(w/h)를 곱해야 화면상의 정사각형과 좌표계의 격자가 일치한다. 이를 빠뜨리면 격자·원·정사각형이 늘어난 형태로 계산된다 — 캔버스/Canvas2D, WebGL·셰이더의 UV 좌표, SVG viewBox, 게임 엔진의 정규화 월드 좌표 등 가로·세로를 독립적으로 0~1로 나누는 모든 곳에서 같은 원인으로 재발할 수 있는 문제다.
상황
formation은 무용 연습 영상 위에 격자를 띄우고, 시점(키프레임)마다 무용수 위치를 찍어 대형과 동선을 시각화·편집하는 브라우저 앱이다. 좌표는 무대 기준 x, y 모두 0~1로 정규화해 저장한다 . 영상 해상도는 1920×1080(16:9)이고 캔버스는 이 영상 종횡비에 맞춰 그려진다 .
이 세션에서는 그보다 먼저 좌표 지터 제거·정렬 스냅 작업(formation2.json v1)을 진행하고 있었는데, 사용자가 "격자 사이즈도 확인돼? 격자에 맞춰서 배치를 하려고 하는데 지금은 격자에 맞춰지지 않고 좀 비뚤어"라고 새로 지적하면서 격자 조사가 시작됐다 .
증상
무용수 12명, 83개 키프레임으로 짜인 안무 데이터의 좌표가 격자에 딱 맞지 않고 틀어져 있었다. 처음엔 단순히 "안무가가 눈대중으로 찍어서 오차가 났다"는 쪽으로만 보였지만, 실측해 보니 x는 중앙값 4%, y는 7%가 격자선에서 벗어나 있었다 — 가로세로 오차 크기가 꽤 다르다는 점이 단순 오차로 넘기기엔 이상했다.
원인
원인은 두 가지가 겹쳐 있었다.
1) 종횡비로 인한 격자 간격 불일치. 격자 정의 cell = gridSize × (w/800) 을 정규화 좌표로 풀면 가로 간격은 gridSize/800으로 고정이지만, 세로 간격은 거기에 종횡비(w/h)를 곱한 값이 된다. 16:9 영상에서는 가로 0.0625, 세로 0.1111 — 화면에선 같은 정사각형 칸인데 좌표계 숫자로는 세로가 거의 두 배다 . 이걸 모르고 "가로세로 같은 간격의 전체 칸 격자"라고 가정해 스냅 검사를 하면, 실제로는 맞는 좌표도 안 맞는 것처럼 보인다.
2) 에디터에 드래그 스냅이 없었다. 격자 간격을 올바르게 다시 계산한 뒤에도 x 4%·y 7%의 오차가 남았는데, 분포를 보니 값들이 전체 칸이 아니라 반 칸(half-cell) 근처에 몰려 있었다 . 반 칸 격자(가로 0.03125, 세로 0.0556) 기준으로 다시 맞춰보니 91%가 들어맞았다(전체 칸 기준은 55%) . 에디터(formation7.html:120) 코드를 확인하자 드래그 시 스냅 기능이 전혀 없다는 게 나왔다 — 즉 안무가는 화면에 보이는 격자의 선과 칸 중앙을 눈대중으로 찍었고, 그 과정에서 소폭 어긋난 것이었다.
여기에 더해 실제 사용된 gridSize 값이 데이터 파일에 저장되지 않는 구포맷(bare array)이어서, 20~100 전체 범위를 스캔해 어떤 격자 크기가 가장 잘 맞는지 통계적으로 역산해야 했다 . 결과는 화면 gridSize 50의 반 칸(=gridSize 25 격자, 가로 0.03125/세로 0.05556)으로 x 90%·y 91% 적합이었다 .
해결
- 영상 종횡비(16:9)를 반영해 세로 격자 간격에
w/h를 곱한 올바른 격자로 다시 측정했다. - gridSize 20~100을 스캔해 실제 배치 기준 격자(half-grid, 가로 0.03125/세로 0.05556)를 역산해 확정했다 .
- 각 무용수 좌표를 가장 가까운 격자점에 스냅하고, 같은 칸에 겹치면 가까운 빈 칸으로 이동시켰다. 그 결과 1034개 점 전부 격자에 안착, 무대 3% 초과 이동 7점(최대 3.6%), 충돌 6건 정리로 마무리됐다 .
- 이후 "더 촘촘히 놓아보자"는 요청에 따라
{gridSize, keyframes}신형 포맷으로 저장하며 격자를 50→25로 좁혀(스냅 간격 절반) 다시 적용했고, 최대 이동이 3.6%→1.7%로 줄고 3% 초과 이동은 0건이 됐다 .
코드로 요약하면, 세로 간격 계산에 종횡비 보정이 빠져 있던 것이 핵심이다.
// 틀린 가정: 가로세로 간격이 같다고 보고 비교
stepX = gridSize / canvasWidthNorm // 0.0625
stepY = gridSize / canvasWidthNorm // 똑같이 0.0625로 비교 → 실제와 안 맞음
// 올바른 계산: 세로에 종횡비(w/h)를 곱한다
stepX = gridSize / 800 // 0.0625
stepY = (gridSize / 800) * (w / h) // 0.0625 * 16/9 ≈ 0.1111
덤 — 같은 사건의 곁가지 교훈
같은 격자 역산·스냅 처리를 다른 안무 파일(환상동화.json)에도 적용했는데, 동일한 half-grid(x 98%·y 93% 적합)로 확인돼 927개 점 전부 안착, 최대 이동 1.4%, 충돌 0건으로 formation보다도 더 깔끔하게 끝났다 . 다만 이 세션 안에서 사용자가 비교 뷰어를 직접 열어 결과를 눈으로 확인하고 승인했다는 명시적 발언은 없다. 어시스턴트는 매 단계 "완벽해요", "927개 점 전부 안착" 식으로 스스로 완료를 보고했지만 , 사용자는 다음 단계 지시로만 넘어갔고, "원본에 덮어쓸까요?"라는 질문에는 답하지 않은 채 대화가 모바일 핫스팟 구성으로 넘어갔다 . 구현자의 "완료 보고"와 사용자의 "실제 확인"은 별개라는 걸 보여주는 사례다.
취재 후기 — 정사각형은 어디서 늘어났나
cell = gridSize × (w/800)이라고 돼 있었대 .gridSize/800인데, 세로는 거기에 종횡비를 곱해야 해. 16:9니까 세로가 거의 두 배로 벌어지는 거지 .