개발 취재 노트

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% 적합이었다 .

해결

  1. 영상 종횡비(16:9)를 반영해 세로 격자 간격에 w/h를 곱한 올바른 격자로 다시 측정했다.
  2. gridSize 20~100을 스캔해 실제 배치 기준 격자(half-grid, 가로 0.03125/세로 0.05556)를 역산해 확정했다 .
  3. 각 무용수 좌표를 가장 가까운 격자점에 스냅하고, 같은 칸에 겹치면 가까운 빈 칸으로 이동시켰다. 그 결과 1034개 점 전부 격자에 안착, 무대 3% 초과 이동 7점(최대 3.6%), 충돌 6건 정리로 마무리됐다 .
  4. 이후 "더 촘촘히 놓아보자"는 요청에 따라 {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개 점 전부 안착" 식으로 스스로 완료를 보고했지만 , 사용자는 다음 단계 지시로만 넘어갔고, "원본에 덮어쓸까요?"라는 질문에는 답하지 않은 채 대화가 모바일 핫스팟 구성으로 넘어갔다 . 구현자의 "완료 보고"와 사용자의 "실제 확인"은 별개라는 걸 보여주는 사례다.

취재 후기 — 정사각형은 어디서 늘어났나

정하늘
격자가 비뚤어졌다는데, 좌표값 자체는 저장할 때부터 이상했어?
정바다
아마 안무가가 대충 찍었겠지. 사람 손으로 찍는 건데 오차가 나는 게 당연하잖아.
박도은
그런데 오차 크기가 가로 4%, 세로 7%로 다르던데? 그것부터 이상하지 않아 ?
정바다
그냥 세로 방향 손이 더 떨렸나 보지.
정하늘
추측 말고 격자 정의부터 보자. 코드에 cell = gridSize × (w/800)이라고 돼 있었대 .
박도현
그게 핵심이야. 가로 간격은 gridSize/800인데, 세로는 거기에 종횡비를 곱해야 해. 16:9니까 세로가 거의 두 배로 벌어지는 거지 .
박도은
아, 그러니까 화면에서는 똑같은 정사각형 칸인데 좌표 숫자로 보면 세로가 늘어난 직사각형이 된다는 거네?
정하늘
맞아. 가로 0.0625, 세로 0.1111 . 종횡비 보정을 안 하면 비교 자체가 틀어져.
정바다
그럼 그게 끝 아니야? 종횡비만 곱하면 다 맞아야지.
박도현
아니, 그걸로 다시 재보니까 여전히 x 4%, y 7% 오차가 남았어 . 뭔가 더 있어.
박도은
분포를 보니까 반 칸 위치에 몰려 있더라고. 반 칸 격자로 다시 재니까 91%가 맞았어 .
정하늘
그럼 에디터가 반 칸에 스냅하는 기능이 있었던 거야?
박도현
그게 반전이야 — 에디터에 드래그 스냅 기능 자체가 없어 . 안무가가 화면 격자의 선과 칸 중앙을 눈대중으로 찍은 거지.
정바다
그럼 처음에 내가 "그냥 손이 떨렸다"고 한 것도 틀린 말은 아니었네?
박도은
반은 맞고 반은 틀렸지. 떨린 게 아니라 격자 간격 계산 자체가 틀렸던 게 먼저고, 그 다음에 눈대중 오차가 얹힌 거야.
정하늘
그리고 gridSize 값 자체가 파일에 없어서 20~100을 다 스캔해서 역산해야 했다며 ?
박도현
그래, gridSize 50의 반 칸, 즉 gridSize 25 격자가 x 90%·y 91%로 가장 잘 맞았어. 그 값으로 1034개 점을 스냅했고.
박도은
그래서 결론은, 정규화 좌표에서 격자를 그릴 땐 세로에 종횡비를 곱해야 한다는 거고, 동시에 "격자에 거의 맞는데 안 맞는다"면 반 칸 격자일 가능성도 봐야 한다는 거네.
정바다
그 정도면 다음에 비슷한 거 봐도 바로 의심할 수 있겠다.