영상 하나로 3D 골목을 만들었다 — 가우시안 스플랫과 두 번의 실패담
19초짜리 골목길 영상 하나에서 3D 장면을 뽑아 웹에 띄우고, GitHub Pages로 공개까지 해봤다. 결과물은 여기: https://lightnine999.github.io/room-3d-alley/
과정 자체는 “영상 → 사진 24장 → 점 → 알갱이 → 웹 주소” 로 단순한데, 중간에 원리를 잘못 짚어서 벽까지 다 날려버린 실패가 하나 있었다. 그게 오늘 배운 것 중 제일 값졌다.
만든 것
MapAnything(facebook/map-anything-apache, Apache-2.0)이라는 모델에 사진 24장을 넣으면 점(point cloud) 300만 개 정도가 나온다. 이 점들을 “가우시안 스플랫”이라는 형식으로 바꿔서 브라우저에 띄우면, 사진을 찍었던 공간을 아무 각도로나 돌려볼 수 있다. three.js + Spark(@sparkjsdev/spark) 라이브러리 조합이다.
계산은 코랩 T4 GPU에서 하고, 알갱이로 바꾸고 파일을 줄이는 건 맥북에서 했다. 스킬 문서(room-3d)가 이렇게 나눠서 하라고 미리 알려줬는데, 실제로 해보니 코랩 vCPU 2개보다 M5 Pro 코어 15개가 훨씬 빠른 게 맞았다.
알갱이 하나가 68바이트인 이유
가우시안 스플랫 .ply 파일의 원래 칸은 62개다. 자리(3) + 법선(3) + 기본색(3) + 각도별 색(45) + 불투명도(1) + 크기(3) + 회전(4). 각도별 색 45칸은 보는 방향에 따라 색이 달라지게 하는 학습 결과인데, 우리는 그 학습을 안 했다. 안 했으면 0으로 채우는 게 아니라 헤더에서 아예 뺀다 — 채우면 파일이 3.6배가 된다.
그러면 칸이 17개, 알갱이 하나에 정확히 68바이트(17칸 × 4바이트)가 나온다. 숫자가 딱 안 맞으면 헤더 어딘가가 잘못됐다는 뜻이라, 이게 검증 기준이 된다.
실패담: 두께로 전선을 지우려다 벽까지 날렸다
골목을 복원하니 전신주 전선이 하늘에 흩날리는 점으로 남았다. 처음엔 “전선은 가늘다”는 생각으로 접근했다. 점을 3D 격자에 채우고, binary_opening(형태학적 열림 연산)으로 얇은 부분을 깎아내면 되겠다 싶었다.
결과: 점 273만 개 중 96%가 사라졌다. 벽까지 다 날아갔다.
원인은 명확했다. 벽도 사진에서 복원한 표면이라 두께가 1~2칸밖에 안 된다. 전선과 두께가 똑같은 것이다. 부피가 있는 물체를 다루는 연산을, 두께가 없는 얇은 껍데기(shell) 데이터에 썼으니 안 맞은 거였다.
1
2
3
# 이게 실패한 접근
occ_opened = ndimage.binary_opening(occ, structure=structure, iterations=2)
# -> 벽도 전선도 "얇으니까" 똑같이 깎여나감
두께가 아니라 모양으로 구분해야 했다. 벽은 국소적으로 평면(두 방향은 넓고 한 방향만 얇음)이고, 전선은 선(한 방향만 길고 나머지 두 방향은 얇음)이다. 각 점 주변 이웃 15개를 모아 주성분분석(PCA)을 하면 고유값 세 개가 나오는데,
- 평면(벽): 큰 고유값 두 개 + 작은 고유값 하나
- 선(전선): 큰 고유값 하나 + 작은 고유값 두 개
이 비율(선형성 vs 평면성)로 판정하니 벽은 그대로 남고 전선만 10.25% 정도 깨끗하게 걸러졌다.
1
2
3
4
5
eigvals = np.linalg.eigvalsh(cov) # (N, 3) 오름차순
l0, l1, l2 = eigvals[:, 0], eigvals[:, 1], eigvals[:, 2]
linearity = (l2 - l1) / (l2 + eps)
planarity = (l1 - l0) / (l2 + eps)
is_wire = (linearity >= 0.55) & (planarity <= 0.35)
구멍은 라플라스 방정식으로 채웠다
전선을 지우고 나니 벽에 카메라가 못 본 구멍이 남았다. 이건 점 격자에 binary_closing으로 빈틈을 메운 다음, 새로 채운 칸의 색을 모른다는 문제가 생긴다.
해법은 이웃 칸의 색을 반복해서 평균 내는 것 — 이게 라플라스 방정식을 고정 경계 조건(원래 있던 색)으로 푸는 것과 같다. 60번 반복하니 벽 톤에 자연스럽게 맞춰 채워졌다. 처음엔 채운 칸마다 점을 1개씩만 찍었는데 그물망처럼 듬성듬성 보여서, 실제 밀도(칸당 12.6개)에 맞춰 6개 → 12개로 올렸더니 훨씬 자연스러워졌다.
배경 프로세스가 51분 걸린 미스터리
파이썬 스크립트를 run_in_background로 돌렸는데 51분이 걸렸다. 내부에서 실제로 쓴 CPU 시간은 39초였다. macOS가 포커스 없는 백그라운드 프로세스를 App Nap으로 조용히 던져놓은 거였다. caffeinate -i python3 script.py로 감싸니 29초에 끝났다. 같은 코드, 51분 대 29초 — 절전 기능이 이렇게 티 안 나게 발목을 잡을 수 있다는 걸 처음 겪었다.
깃허브에 올릴 때 걸린 것 둘
하나, 이메일 프라이버시. git push가 GH007: Your push would publish a private email address로 막혔다. author 이메일만 GitHub noreply 주소로 바꿔서 다시 커밋해도 또 막혔는데, committer 이메일도 따로 바꿔야 했다. git commit --amend --author=...는 author만 바꾸고 committer는 그대로 둔다는 걸 몰랐다.
둘, 마크다운 물결표. README에 “1~2초”라고 두 번 썼는데, GitHub이 두 물결표(~) 사이를 전부 취소선으로 읽어버려서 “2초, 남의 폰도 1”가 지워지고 화면엔 “12초”만 남았다. 숫자 범위 표기에 ~를 쓰면 안 된다는 걸 실전에서 배웠다. 1-2초로 바꾸니 해결.
결과
- 알갱이 397만 개,
.sog18.8MB, 폰에서 첫 화면까지 1-2초 - 저장소: https://github.com/Lightnine999/room-3d-alley
- 쓴 것: MapAnything(Apache-2.0), three.js(MIT), Spark(MIT), splat-transform(MIT)