HYPESEOULENGLISH   지도 열기 ▸

서울 6,394칸을 하룻밤에 칠하기: HypeSeoul 기술 회고

코드 조각은 실제 코드에서 뽑았고, 줄인 것은 "(요약)"이라고 적었다.

한 줄 요약

실제 지도 데이터로 서울을 3D로 세우고, 우리 그림체를 학습한 이미지 모델로 그 화면을 칸마다 다시 칠한 뒤, 그 위에 차·지하철·배·사람·행사·낮밤이 돌아가는 살아 있는 층을 얹었다. 지하철 일부(1·2·3호선, 경의중앙선)는 서울시 실제 운행 데이터로 움직인다. 그게 hypeseoul.xyz다.

서울 전체 1차 완성본
서울 전체 1차 완성본

왜 만들었나

이것저것 보다가 우연히 뉴욕 지도 하나를 봤다. 위성지도를 바탕으로 뉴욕 전체를 아이소메트릭 픽셀아트처럼 다시 그린, Andy Coenen의 isometric.nyc다. 실제 지형을 렌더하고, 파인튜닝한 이미지 모델로 칸마다 다시 그리는 방식이다.

서울 버전의 기준은 단순했다. 서울 항공뷰를 바탕으로 서울을 재현하되, 한강과 랜드마크가 보여야 했다. 그리고 하나가 더 걸렸다. 뉴욕 지도는 멋있지만 움직이는 차나 사람이 없어서 재미는 좀 없었다.

HypeSeoul은 이 영감을 받아 독립적으로 만든 프로젝트다. 원작자와의 공식 협업이나 제휴는 없다. 원작의 공개 제작기에서 방법(3D로 렌더한 화면을 이미지 편집 모델로 다시 칠하기, 학습 쌍 규모)을 참고했고, 원작 코드는 한 줄도 가져오지 않았다. 엔진·학습·타일 도구는 전부 새로 짰다. 우리가 더하고 싶었던 건 세 가지였다. 그림 밑에 픽셀 단위로 맞춰진 3D 서울, 그 위에서 움직이는 도시, 그리고 서울 시간에 맞춰 바뀌는 낮과 밤. 하다 보니 지도가 원래 사이트보다 커졌고, 결국 지도가 서비스 자체가 됐다.

전체 구조

오프라인 파이프라인

 OSM(건물·도로·물·공원) ┐
 Overture Maps(빠진 건물) ├─ bake ─> seoul.bin (건물 약 58.5만, 지형, 토지이용 지도)
 국토부 GIS건물통합정보(층수) │
 DEM(지형)               ┘
            │
            v
 three.js 엔진 ── Playwright로 1024×1024 칸 촬영 (0.3 m/px, 칸 간격 768px, 겹침 256px)
            │
            v
 FLUX.2 klein base 4B + 편집 LoRA (Modal A100 ×10, 8스텝, guidance 1)
            │
            v
 water_qa → water_fix → forest_grade → harmonize → shade        (자동 보정 체인)
            │
            ├── 랜드마크·다리·주차장 패치 (GPT로 그 자리만 다시 그림 → ECC 정합)
            v
 build_pyramid: 768px RGBA WebP, z0~8, 서울 경계로 알파
            │                         lights.py: 엔진 건물 데이터 → 불빛 피라미드
            v                                  │
 정적 스토리지(R2) <────────────────────────────┘
            │
            v
 브라우저: paint.mjs가 타일을 받아 엔진 화면 위에 합성

모든 것을 묶는 것: 같은 카메라

엔진 카메라는 정사영(orthographic)에 각도가 고정이다(yaw −0.42, pitch 0.56 rad). 그래서 그림 한 장은 화면에서 평행 이동과 확대만 한다. 세계 좌표(x, z, 높이 y)에서 그림 픽셀로 가는 변환이 아핀 하나로 끝난다. 0.3 m/px에서 브라우저로 직접 잰 값이다.

// engine/preview.mjs — 캔버스 px = anchorPx + AX·dx + AZ·dz + AY·dy (미터당 px)
const AX = [3.0436, -.7220], AZ = [1.3592, 1.6167], AY = [0, -2.8242], Y0 = 18.8516, REF = .3;

같은 상수가 Python 쪽(lm_prep, lights, shade, build_pyramid)에도 그대로 들어 있다. 엔진 물 픽셀로 그림의 물을 검사하고, OSM 건물 외곽선을 그림 위에 빨갛게 투영하고, 엔진 건물로 창문 불빛을 계산하고, 차가 그림 속 도로를 달리는 것이 전부 이 행렬 하나로 된다. 이 글의 거의 모든 해결책은 "같은 카메라"를 지킨 덕분에 가능했다.

프레임마다 도는 패스

engine/scene.mjs의 render()는 이 순서다.

function render() {
  scene.background.copy(bgDay).lerp(bgNight, U.uNight.value);
  applyView();
  renderShadow();                                        // 1. 해 그림자 맵
  renderer.setRenderTarget(rt); renderer.render(scene, cam);   // 2. 저해상도 본 패스(+깊이)
  placePainting();                                       // 3. 그림 타일 → 4. 움직이는 것 마스크
  renderGlow();                                          // 5. 빛 효과(가산)
  renderer.setRenderTarget(null); renderer.render(postScene, postCam);   // 6. 후처리 합성
}
 shadowRT (4096², 폰 2048²) ─┐
 rt: 저해상도 색+깊이 ───────┼─> POST: 깊이 외곽선 → AO → 밤 번짐 → 색 보정
 paint.rt: 그림 타일(기기 해상도) ┤        → 그림 합성 mix(c, paint, a·(1−mask))
 rtMove: 움직이는 것 마스크 ──┤        → + glow
 rtGlow: 빛 효과(레이어 2) ────┘
// POST_FS (발췌)
float l = lin(texture2D(tDepth, vUv - vec2(px.x, 0.0)).r), r = lin(texture2D(tDepth, vUv + vec2(px.x, 0.0)).r);
float u = lin(texture2D(tDepth, vUv - vec2(0.0, px.y)).r), dn = lin(texture2D(tDepth, vUv + vec2(0.0, px.y)).r);
float e = max(max(l + r - 2.0 * d, u + dn - 2.0 * d), 0.0);
float edge = step(uEdge, e);
c = mix(c, c * .5, edge * .85);
// 마스크 재질: 엔진 셰이더와 같은 변환, 카메라 쪽으로 아주 조금 당김 (요약)
const moveMat = new THREE.ShaderMaterial({ side: THREE.DoubleSide, depthWrite: false, depthFunc: THREE.LessEqualDepth,
  vertexShader: `... gl_Position.z -= .0004 * gl_Position.w; }`,
  fragmentShader: 'void main() { gl_FragColor = vec4(1.0); }' });
// POST_FS: 그림은 마스크가 없는 곳만 덮는다
vec4 p = texture2D(tPaint, vUv);
float mv = texture2D(tMove, vUv).r;
c = mix(c, p.rgb / max(p.a, 1e-3), p.a * (1.0 - mv));
c += texture2D(tGlow, vUv).rgb;

1. 데이터: 그림보다 먼저 고쳐야 했던 것

엔진 입력은 OSM 건물·도로·공원·물과 지형(DEM)이다. OSM에 빠진 건물은 Overture Maps로 채웠다. 처음 OSM에 없는 서울 건물 197,982채를 붙였고, 외곽은 0.6m 키웠다(Overture 외곽선이 실제 지붕보다 작게 잡혀서). 층수는 면적으로 추정했다.

추출 범위가 잘려 있었다. 서울 칸의 18%가 빈 땅으로 칠해진 걸 보고 찾았다. 건물·도로 추출이 위도 37.44–37.64, 경도 126.83–127.17로 잘려 있었고, 굽기(bake) 단계도 ±16km 한계에 건물 좌표를 Int16으로 저장하고 있었다. 범위를 37.40–37.73 / 126.74–127.20으로 넓히고, 16km를 넘으면 좌표를 Int32로 쓰게 바꿨다(엔진은 그대로 읽음). 칸 격자를 처음 만들 때 지형 높이를 빼고 계산해서 북한산(836m) 같은 높은 산이 화면 위로 밀려 칸 76개가 빠진 것도 이때 같이 잡았다.

높이. "별로 높지 않은 건물을 고층 아파트처럼 그린 곳이 많다"는 지적에서 시작했다. 건물 58.5만 채 중 실제 높이가 있는 건 9.2만(16%)뿐이었다. 나머지는 추정이었고, 예를 들어 용도 미상 3,000㎡ 이상은 8~18층, 주거 650㎡ 이상은 10~22층으로 올렸다. 넓고 낮은 시장·학교·빌라 묶음이 고층 덩어리가 됐고, 모델은 그걸 성실하게 아파트로 그렸다.

국토부 GIS건물통합정보(서울 등록 건물 68.7만, EPSG:5186)를 우리 건물에 붙였다.

다시 그릴 칸은 벽 면적 변화율로 골랐다(height_diff.py). 1% 초과 3,269칸, 2% 2,629칸, 4% 1,747칸, 8% 788칸. 4% 기준 1,747칸만 다시 찍고 다시 칠했다(사전 추정 약 $8). 서남쪽의 15~25층 아파트 단지가 3~5층 빌라 골목으로 돌아왔다.

층수 반영 전후
층수 반영 전후

데이터와 그림이 서로를 검증한 셈이다. 모델의 실수처럼 보인 것 상당수가 입력의 실수였다.

2. 엔진: 그림 밑에 깔린 3D 서울

데이터는 Web Worker에서 청크 메시로 만든다. 건물은 셰이더 하나로 칠한다.

if (uMask > .5) col = vec3(1.0, 0.0, 0.0);   // ground-truth mask: every built thing is red

움직이는 것은 전부 인스턴싱이다(engine/life.mjs). 차와 버스는 실제 도로 위를 달린다. 도로는 길이에 비례해 고르는데, 서울 전체 도로가 약 4.2만 개라 누적 길이 배열을 이진 탐색한다. 경로 위 위치도 누적 거리 이진 탐색이다. 지하철은 처음에 노선색 통짜 상자를 경로 한 점의 각도로 돌려서 곡선에서 톱니가 생겼다. 차량마다 앞뒤 대차 두 점을 경로에 올려 위치와 방향을 정하게 바꿨다.

// 각 차량은 앞뒤 대차(±8m) 두 점 위에 선다. 차량 간격 20m
const dc = t.d - t.dir * c * 20; run.at(dc + 8, f); run.at(dc - 8, b);
const x = (f.x + b.x) / 2, z = (f.z + b.z) / 2, a = Math.atan2(f.x - b.x, f.z - b.z);

OSM에 터널 태그 없이 covered=yes나 layer<0으로만 표시된 선로가 464개 way 있었다(경의선숲길 밑 경의중앙선 등). 여기서 열차가 지상으로 달리고 있어서, 그 구간에서는 열차를 숨겨 터널로 들어가는 것처럼 만들었다. 보행자는 시간의 순수 함수로 움직인다. 화면 밖에서 갱신을 멈췄다가 다시 들어와도 그 시각의 위치가 바로 맞는다.

3. 그림 모델: GPT 한도에서 직접 학습으로

처음에는 엔진 렌더를 GPT 이미지로 칸마다 다시 그렸다(1024px, 0.3 m/px, 768px 간격, 이미 그린 옆 칸 띠를 붙여 이음새를 맞춤). 여의도 하나는 아름다웠다. 문제는 두 가지였다. GPT가 한강 물길을 옮기고 없는 연못을 만들고 공원에 빌딩을 세웠고, 하루 약 348장에서 이미지 한도에 걸렸다. 서울 전체는 약 6,000칸(밤 별도)이다. 생성 전에 칸 수 × 장당 비용 × 한도 × 일수를 먼저 계산할 것. 이게 첫 교훈이었다.

그림 조각을 패턴처럼 깔아 보기도 했다. 구역 지도(빌라/아파트/상업/공업/주차/학교/공터/문화재 8종)를 만들고, 종류별 그림 시트 4변형을 셰이더가 150m 축척으로 깔았다. 그래도 3D 상자 건물이 보이는 한 그림이 되지 않았다. 결론은 "그림의 패턴화 = 우리 그림체를 학습한 모델"이었다.

공개 모델 FLUX.2 klein 4B(Apache 2.0)에 편집 LoRA를 학습시켰다. 입력(reference)은 엔진 렌더, 정답(target)은 같은 칸을 그린 그림이다.

위성 / 엔진 / 학습 전 모델 비교
위성 / 엔진 / 학습 전 모델 비교
회차 장비·도구 자료 설정 속도 결과
r1 맥북 M3 Pro, mflux 92쌍 768px, rank 16, 8비트, 12에포크(1,104단계) 77초/단계 약 4밤 예상. 입력·정답 불일치 발견 후 중단
r2 맥북 33쌍 512px, 396단계 57초/단계 증류 모델에서 물을 지어냄. 학습 부족 판단
r3 Kaggle T4×2(무료), ai-toolkit 33쌍 참조 512px, 1,000단계, rank 32/64 31~35초/단계 디테일이 거칢. 운하·숲 섬 남음
r4 Modal A100-80GB, ai-toolkit 33쌍 참조·정답 1024px, 양자화 없음, rank 64, 1,500단계 2.41초/단계 채택

막혔던 지점을 하나씩 적는다.

스크립트 버그로 첫 밤을 날렸다. 이어받을 체크포인트를 찾는 줄이 아무것도 못 찾으면 현재 폴더 목록을 내놓았고, 그 폴더를 체크포인트로 열려다 1초 만에 죽었다. 약 3시간 뒤에야 알았다.

학습 데이터 자체가 틀렸다. 위성사진(검토용으로만 씀)을 우리 카메라 각도로 펴서 대조해 보니, OSM에 건물이 빠진 빈 땅을 정답 그림이 숲으로 칠해 두었다. 모델이 "빈 땅 = 숲"을 배우고 있었다. 게다가 학습 입력을 다시 찍는 사이에 엔진이 빈 블록을 가짜 건물로 채우게 바뀌어 있어서, 입력과 정답이 안 맞는 쌍으로 학습하고 있었다. pairscore.py로 "원래 입력에 빠진 실제 건물 <6%, 정답이 지어낸 것 <20%"를 통과한 쌍만 남겨 33쌍으로 줄였다.

물을 지어내는 버릇. base 모델로 학습한 LoRA를 빠른 증류(distilled) 모델에 끼우면 큰 도로를 운하로 바꿨다. 랜덤 시드 4개 모두 같았다. 우연이 아니라 학습된 버릇이었다. 같은 LoRA를 base 모델로 돌리면 운하가 사라졌다. 최종 변환도 학습과 같은 FLUX.2-klein-base-4B에서 돌린다.

T4에서 매번 정확히 3단계에서 OOM. 처음엔 fp16이 빠르길래 썼다(예열 후 실측 fp16 24 TFLOPS, bf16 2.5. T4는 bf16을 흉내만 낸다). 그런데 fp16에서 loss가 NaN이 됐다. FLUX 계열에서 알려진 문제다. fp32는 정상이지만 58초/단계로 맥북과 같았다. bf16은 26초/단계인데 3단계에서 메모리가 터졌다. 메모리 조각 모음, rank 16, 모델 절반 CPU 오프로드를 다 해 봐도 매번 3단계였다. 학습 도구 코드를 직접 읽어 보니 FLUX.2 편집 학습에서 참조 이미지를 원본 크기 그대로 쓰고 있었다. 참조 1024px, 정답 512px이니 참조 쪽 토큰이 4배였다. 참조를 512로 줄이자 27초/단계로 끝까지 돌았다. 같은 단계에서 반복해서 죽으면 설정이 아니라 입력 크기다.

A100에서 끝냈다. 24GB 이상이면 1024 참조를 그대로 넣을 수 있다. 채택한 설정의 핵심은 이렇다.

# gen/finetune-20261019/modal/train_modal.py가 만드는 ai-toolkit 설정 (발췌)
network: {type: lora, linear: 64, linear_alpha: 32}
train: {batch_size: 1, steps: 1500, lr: 0.0001, optimizer: adamw8bit,
        gradient_checkpointing: true, dtype: bf16, noise_scheduler: flowmatch}
model: {arch: flux2_klein_4b, name_or_path: black-forest-labs/FLUX.2-klein-base-4B,
        quantize: false, quantize_te: true}

r4는 rank 32와 64를 동시에 1,500단계(약 65분) 돌렸다. rank 32는 500·1,000·1,500단계 모두 망원에 운하, 용산 공사장에 연못을 그려 탈락했다. rank 64도 1,000단계에서는 연못·운하가 나왔다. 물 버릇이 사라진 게 아니라 흔들리다 1,500단계에서 잡힌 것이다. 학습에 안 쓴 16칸(여의도 8, 용산 8)에서 지어낸 물이 0이었고, 시험 3칸 × 시드 2개에서도 0/6이었다.

검증하다 다시 학습시킨 실수. 학습된 LoRA를 불러 샘플만 뽑으려 했는데, ai-toolkit이 불러온 LoRA에 저장된 단계 수를 무시하고 0단계부터 다시 학습을 시작했다. 30분 뒤에 알았다. 로컬 프로세스를 꺼도 원격 앱은 남아 있어서 따로 멈춰야 했다. 고친 뒤로는 start_step을 학습 단계 수로 고정한다. 서울 전체 변환도 같은 방식이다. "학습이 이미 끝난 학습 작업"을 띄워서 샘플 생성만 시킨다.

# convert: 학습 없이 샘플만 (start_step = steps)
return run(name, steps, rank, 1024, 100000, W='/vol/conv', lora=f', pretrained_lora_path: {lora_path}',
           items=items, extra_train=f'\n        skip_first_sample: true\n        start_step: {steps}')
r4 최종 비교 시트
r4 최종 비교 시트

4. 서울 전체 변환: 51분

추론 비용은 스텝과 guidance로 줄였다. A100에서 6칸 × 4방식을 실측했다.

방식 칸당 시간 관찰
8스텝, guidance 3 8.6초 기준
8스텝, guidance 1 4.1초 땅·물결 질감이 아주 조금 납작. 구조·건물·색은 거의 같음
6스텝, guidance 3 5.8초
6스텝, guidance 1 3.2초
4스텝, guidance 1 2.3초 땅이 흐려짐

guidance 1은 대조 안내를 끈 설정이다. 같은 8스텝에서 시간이 절반 아래로 줄었고, 물 지어냄은 어느 방식에도 없었다. 8스텝·g1이면 6,000칸이 약 6.8시간(약 $17)이라 남은 무료 크레딧 안에 들어왔다. 이걸로 정했다.

밤 작업은 이렇게 돌았다. 01:01 엔진 촬영 시작(헤드리스 Chromium, 1024×1024 뷰포트, UI·나무·움직이는 층을 숨기고 카메라 높이를 고정해서 칸당 약 0.7초). 02:37 Modal A100 10대 동시 변환 시작. 컨테이너당 628~629칸, 칸당 4.9초, 51분 만에 6,394칸(603km²) 전부. 오류·OOM·NaN 0, 빠진 칸 0.

Modal 누적 사용은 $35.88(무료 크레딧 $30 포함)이었고, 예상($31)보다 높았던 건 컨테이너마다 모델을 올리는 시간 때문이다.

5. 이어 붙이기와 자동 보정

칸마다 따로 그린 그림을 한 장으로 만드는 체인이다(run_post.sh).

water_qa → water_fix → forest_grade → harmonize → shade → build_pyramid(R=96) → manifest

물 검사. 엔진 렌더와 그림의 픽셀 위치가 같으니 "엔진에서 물인 자리가 그림에서도 파란가"를 셀 수 있다. 엔진 물 마스크 위치에서 그림 픽셀이 B > R + 30 && B > G인 비율이 0.85 미만이면 실패다. 물 있는 칸 949개 중 물이 80% 이상인 칸 194개에서 176개가 실패했다. 강가가 하나도 안 보이는 한강 한가운데 칸을 모델이 단풍 숲으로 칠했다. 학습 쌍에 물만 있는 칸이 없었던 탓으로 본다.

물만 있는 칸이 숲으로 그려진 문제
물만 있는 칸이 숲으로 그려진 문제

물 보정 v2. v1은 엔진 물의 밝기 띠를 색 기준으로 썼는데, 그 밝기가 칸(카메라 위치)마다 달라서 한강에 네모가 보였다. 빈 칸의 잔물결을 한 조각에서 거울 복사해 채운 것도 확대하면 반복이 보였다. v2는 물 자리를 세 층으로 다시 만든다. 도시 전체에 같은 파랑(42, 92, 120), 칸 좌표가 아니라 캔버스 좌표로 깐 느린 밝기 장(칸 사이로 이어짐), 그리고 잔물결(그 칸이 물을 지켰으면 그 칸의 고주파 성분 60%, 아니면 칸 ID로 시드를 정한 절차적 노이즈).

# water_fix.py (발췌)
det = OWN * (o - lp(o, 6)) if blue >= 0.85 else ripples(t)
new = np.clip(PM + field(t)[..., None] + det, 0, 255); out = o * (1 - a) + new * a

격자 지우기(harmonize). 칸마다 색이 조금씩 달라 격자가 보였다. 칸마다, 채널마다 3×3 제어점의 로그 게인을 미지수로 두고, 이웃 칸의 겹침 띠(3구간씩)가 같아지도록 희소 최소제곱(lsqr)으로 풀었다. 변화 없음(게인 0)으로 당기는 항과 제어점 사이를 매끄럽게 하는 항을 같이 넣었다. 1/8 축소로 풀고, 게인 장은 쌍선형으로 펴서 원본에 곱한다. 겹침 차이가 약 1/4로 줄었다.

좁은 넘김(build_pyramid). 처음엔 256px 겹침 전체를 선형 페이드했더니, 두 칸이 같은 건물을 조금 다르게 그린 곳에서 반투명 "유령 겹침"이 생겼다. 지금은 칸마다 겹침 한가운데 768px 셀을 자기 몫으로 갖고, 셀 경계 양쪽 96px에서만 섞는다.

def ramp():  # 셀 로컬 좌표 -R/2..C+R/2에서 자기 칸의 가중치 (R=96, C=768)
    u = np.arange(-R // 2, C + R // 2, dtype=np.float32)
    return np.clip(np.minimum(u + R / 2, C + R / 2 - u) / R, 0, 1)

그 위에 랜드마크 패치(RGBA)를 세계 좌표로 합성하고, 알파는 실제 서울 경계(OSM 행정 경계를 같은 카메라와 지형 높이로 투영, 20m 간격으로 촘촘하게)에서 160px 페더로 정한다. 타일 계단 모양 대신 서울 모양으로 끝난다. 아래 레벨은 premultiplied 알파로 2×2 평균을 내서 만든다.

지형 명암(shade). 엔진은 지형을 올리지만 그림은 산을 평평한 무늬로 그렸다. 칸 픽셀마다 고정 카메라를 지형에 역투영하고(고정점 반복 3회), 지형 노멀에 화면 왼쪽 위에서 오는 빛을 곱했다. 처음엔 80m DEM 격자의 쌍선형 기울기가 칸마다 튀어서 네모난 명암이 생겼다. 살짝 흐린 지형을 3차 보간으로 읽어 노멀을 계산하니 북한산·남산이 입체로 살아났다.

모델의 버릇들. 숲은 엔진 숲이 거의 검정에 빨간 점이라 산 전체가 주황·빨강으로 칠해졌다. 엔진 숲 자리의 빨강·주황을 올리브 쪽으로 당겼다(머스터드 → 라임 → 올리브+주황 점, 세 번째 채택, 4,658칸). 물만 있는 칸에서 늘 칸 왼쪽 위 같은 자리에 작은 나무 섬을 그리는 버릇도 있었다. 그 자리를 전 칸 검사해서 7곳을 지웠다. 검사 기준은 "물에 둘러싸인 외딴 덩어리 + 주변 물과 색이 확실히 다름"인데, 덩어리 판정은 엄격하게, 둘레는 느슨하게 해야 파란 나무 섬까지 잡혔다.

6. 다시 그리기: 표시하고, 그리고, 정합하고, 패치로

모델은 랜드마크에 약하다. 롯데월드·DDP 같은 건물이 일반 건물로 나왔다. 그 자리만 GPT로 다시 그리는 도구를 만들었다.

  1. lm_prep.py: 서울 그림에서 주변을 자르고, OSM 외곽선과 높이를 같은 카메라와 지형으로 투영해 빨갛게 표시한다. 앞에 선 건물은 엔진 건물의 실제 높이로 가림 윤곽을 계산해 표시에서 뺀다. 안 그러면 GPT가 앞 건물까지 지운다.
  2. GPT가 표시된 부분만 다시 그린다.
  3. lm_apply.py: GPT 결과는 조금 밀리거나 커질 수 있다. 표시 바깥(40px 띄운 영역)만 보고 ECC 아핀 정합을 한 뒤, 표시 부분만 페더 알파 패치로 저장한다.
# lm_apply.py (요약): 표시 밖 영역만으로 ECC 아핀 정합
outside = (~binary_dilation(mk, iterations=40)).astype(np.uint8)
_, warp = cv2.findTransformECC(blur(crop), blur(gpt), np.eye(2, 3, dtype=np.float32), cv2.MOTION_AFFINE,
                               (cv2.TERM_CRITERIA_EPS | cv2.TERM_CRITERIA_COUNT, 200, 1e-6), outside, 5)
ga = cv2.warpAffine(g, warp, (Wd, H), flags=cv2.INTER_LINEAR + cv2.WARP_INVERSE_MAP)

정합 오차는 첫 11곳이 0.4px 미만, 여의도 8장 0.5px 이내, 궁궐 9장 0.6px 이내였다. GPT가 표시 높이보다 높게 그린 안테나 끝이 페더에 걸려 흐려지는 문제가 있어서, 표시에 붙어 있는 확실한 변화(90px 안)는 함께 살리는 옵션(GROW)을 넣었다. 패치는 세계 좌표로 저장해서 피라미드를 다시 만들어도 자리가 유지된다.

여의도: 예쁜 조각보다 이어진 전체. 여의도는 처음부터 GPT로 칸마다 공들여 그린 구역이었다. 그런데 칸 경계마다 강변선이 계단처럼 어긋나고 공원 길이 끊겼다. 두 그림이 같은 카메라라서 평행 이동 하나(41464, 40850)로 정확히 겹친다는 걸 이용해 강변 자르기, 선착장 정리, 저해상도 밑그림 보정까지 며칠을 버텼다. 그 과정에서 밑그림 폭(12,544px)과 칸 격자 폭(13,312px)을 섞어 쓴 버그도 찾았다. 그래도 결론은 "아예 다시 그려야 한다"였다. 공들인 여의도 그림을 끄고, 매끈하게 이어진 학습 모델 그림 위에 63빌딩·IFC·파크원·여의도공원(넓어서 4조각)만 다시 그렸다.

여의도: GPT 칸 모자이크 vs 학습 모델
여의도: GPT 칸 모자이크 vs 학습 모델

한강 다리. 칸마다 그린 그림은 긴 다리를 칸마다 조금씩 다르게 그린다. 겹침 영역에서 두 칸을 직접 비교하니 다리 위치가 최대 40px 넘게 다르고, 폭과 차선 디자인도 달랐다. 수학적으로 맞추는 건 절반만 통했다.

다시 그린 한강 다리
다시 그린 한강 다리

10-26에는 같은 도구에 월드 폴리곤을 직접 넣는 'poly' 소스를 추가해 한강공원 주차장 17곳과 강남·성수 일대 랜드마크 9곳을 다시 그렸다(26장 전부 1차 통과). 실제로 바이트가 바뀐 타일은 190개였고 그것만 다시 올렸다.

출시 직전 점검에서 63빌딩이 틀렸다. 금색 탑 아래에 거대한 금색 기단이 생겨서 앞에 있던 중층 건물들을 덮어버렸다. 원인은 데이터였다. 건축물대장 높이를 채우는 과정에서 단지 전체 외곽선(115×133m)에 탑 높이 249m가 붙었고, 그 외곽선을 그대로 빨갛게 세워서 표시했다. GPT는 표시를 꽉 채우라는 지시대로 거기에 건물을 그렸다. 같은 단지 안에 120m짜리 상자 두 개도 데이터 오류였다.

그래서 표시를 두 색으로 나눴다. - 빨강: 진짜 남길 것. 탑 윤곽(250m)과 낮은 기단(14m)이다. - 파랑: 데이터 오류로 학습 모델이 그려 버린 가짜 건물. "여기엔 건물이 없다, 뒤에 있는 강변 공원과 길을 이어 그려라"라고 지시했다.

처음엔 GPT가 파랑을 무시하고 회색 판상 건물을 그대로 남겼다. 그래서 GPT에 넘기는 참고 그림에서 파랑 영역을 미리 OpenCV 인페인팅으로 뭉개서 넘겼다. 원본에 보이는 건물이 없으니 그대로 따라 그릴 것도 없다. 앞에 선 건물은 엔진 건물 높이로 가림 윤곽을 계산해서 표시에서 뺐다(occlude='front').

63빌딩: 왼쪽 파랑을 미리 지운 참고 그림, 오른쪽 결과
63빌딩: 왼쪽 파랑을 미리 지운 참고 그림, 오른쪽 결과

IFC는 반대 문제였다. 원래 그림의 큰 탑이 표시보다 넓었다. 그래서 새로 그린 뾰족한 탑 옆으로 옛 탑의 평평한 옥상이 반투명하게 비쳤다. 같은 방법을 표시 전체로 넓혀서 고쳤다(prefill=True). GPT에 넘기는 참고 그림에서 표시 영역 전체를 미리 지우면, 옛 건물을 따라 그릴 근거가 없어진다.

IFC: 왼쪽 원래 그림, 오른쪽 다시 그린 것
IFC: 왼쪽 원래 그림, 오른쪽 다시 그린 것

교훈은 랜드마크를 덮을 때 표시 범위가 곧 덮어쓸 범위라는 것이다.

이 규칙으로 성수의 기업 사옥 9곳을 새로 그렸다. 젠틀몬스터 본사 하우스 노웨어, 아크로 서울포레스트 D타워, 팩토리얼 성수, W미션, 디스이즈네버댓 사옥, 클리오, 무신사 E1·N1 같은 곳이다. 위치는 카카오맵 좌표에 가장 가까운 OSM 외곽선으로 잡고, 높이는 조사 자료를 썼다. 표시 전체를 미리 지우고 앞 건물은 표시에서 뺐다. 8곳은 한 번에 통과했다. 디올 성수는 2층짜리라 지도 크기에서는 거의 보이지 않아서 뺐다.

성수 사옥: 표시와 GPT 결과
성수 사옥: 표시와 GPT 결과

이어서 건축상을 받았거나 위에서 봐도 알아볼 만한 건물 14곳을 17장으로 나눠 그렸다. 국립중앙박물관, 예술의전당, 올림픽공원 평화의문과 KSPO 돔, 전쟁기념관, 아모레퍼시픽 본사, 서울식물원, 종로타워, 서울역 구역사, 이화여대 ECC, 문화비축기지, LG아트센터, 세종문화회관, 삼성타운, 서울스퀘어다. 준비 단계에서 세 가지가 걸렸다. - 삼성타운을 반경으로 찾았더니 메리츠타워까지 주변 빌딩 13동이 딸려 왔다. 높이 150m 넘는 3동만 남겼다. - 지하에 묻힌 ECC는 주변 건물이 전부 '앞에 선 건물'로 판정돼서 표시가 0%가 됐다. ECC만 가림 처리를 껐다. - 박물관(약 400m)과 문화비축기지는 한 장에 담으면 GPT 출력이 원래 해상도보다 작아져서 흐려진다. 그래서 겹치는 두 조각으로 나눴다.

17장 중 15장이 한 번에 통과했다. 올림픽공원 두 장은 평화의문과 돔의 설명을 한 문장에 같이 넣은 탓에, 돔 자리에 문을 그리고 문 자리에 돔을 덧그렸다. 장마다 그것만 그리라는 설명으로 바꿔서 다시 그렸다.

건축 명소 17장: 표시와 GPT 결과 주변을 먼저 보고, 부분마다 실제 높이를 쓰고, 지도에 올린 뒤 주변까지 확인한다.

7. 야경은 그림이 아니라 데이터로

밤 그림을 다시 생성하면 비용이 두 배다. 대신 그림을 어둡게 물들이고, 켜지는 것만 따로 계산해 더한다(lights.py).

9,704칸을 로컬에서 6분, 0원에 계산했다(439MB). 첫 결과는 오피스가 모눈종이처럼 고르게 하얬고 다리가 흰 굵은 선이었다. 꺼진 층과 밝기 차이, 덜 파란 흰빛, 다리 가로등 점으로 풀었다.

남산타워에서는 다른 함정이 나왔다. 모델이 타워를 사무실 상자 위에 얹어 그려서 다시 그렸는데, 야경은 여전히 옛 상자 자리에 창문을 켰다. 그림을 고치면 그 그림에서 계산한 층도 같이 고쳐야 한다. 타워 자리의 엔진 건물 8채는 창문을 끄고, 그림 자체를 비추게 했다. 축 근처의 흰·회색 픽셀만 골라(주황 단풍은 제외) 아래는 푸른 흰빛에서 위로 라일락, 높이 53% 지점에 전망대 링, 꼭대기에 빨간 항공등. 궁궐도 상자 대신 그림을 은은하게 비추되, 녹지는 어둡게 남겼다. 축소 레벨을 만들 때 밝기를 단계마다 1.25배씩 올렸더니 멀리서 주황 덩어리가 돼서, 네 칸 중 가장 밝은 값을 넘지 않게 바꿨다.

서울 야경
서울 야경

8. 브라우저 타일 로더

그림은 768px WebP 타일 피라미드(z0~8)다. 타일 원본은 약 2.0GB, 불빛 439MB이고, 정적 스토리지에 파일 22,186개로 올렸다. engine/paint.mjs가 매 프레임 보이는 타일을 화면 사각형으로 그린다.

레벨은 기기 픽셀 기준으로 고른다.

const zf = M.maxZoom - Math.log2(1 / (k * DPR));   // k = 최대 레벨 캔버스 px당 화면 px
const z = Math.max(0, Math.min(M.maxZoom, Math.ceil(zf - .25))), zb = Math.max(0, z - 2);

v1은 자기 텍스처가 올 때까지 안 그리고, 밑에 3단계 아래(8배 흐린) 고정 백스톱만 깔았다. 그래서 이동하면 흐린 사각형이 1~2초 보이고, 줌인하면 이미 받아 둔 중간 해상도를 안 썼고, 밤에는 불빛이 늦게 와서 어두운 사각형이 생겼다. v2는 이렇게 바꿨다.

function pump() {
  for (const j of jobs.values()) {
    const stale = j.ctl ? !j.got && frame - j.frame > 20 : j.frame !== frame;
    if (stale) { if (j.ctl) { j.dead = true; j.ctl.abort(); } jobs.delete(j.url); }
  }
  const todo = [...jobs.values()].filter(j => !j.ctl).sort((a, b) => a.prio - b.prio);
  for (const j of todo) { if (active >= MAX_NET) break; start(j); }
}

실제 타일로 빌드한 로컬에서 확인했다. 새 위치 첫 로딩은 4초 안에 밤 그림까지 완성, 이동은 1초 안에 채워짐, 흐린 경계 없음, 줌인 즉시 선명. 엔진이 서는 동안 보여 주는 2D 미리보기도 고정 1.2초가 아니라 타일이 화면의 95%를 덮을 때까지(최대 6초) 유지하게 바꿨다.

물 흐름. 같은 셰이더에서 엔진 토지이용 지도(물)를 다시 읽는다. 캔버스 아핀의 역행렬로 타일 픽셀을 세계 좌표로 되돌려 물 여부를 보니, 파란 유리 빌딩은 건드리지 않는다. 물 위에서만 강 방향(화면 기준 약 (1, 0.22))으로 늘린 노이즈 세 겹이 서쪽으로 흐르고, 그림을 1~2px 흔든다. 멀리서는 꺼진다.

9. 렉 잡기

서울 전체를 한 번에 올리자 첫 화면까지 10~15초, 프레임 중앙값 18ms에 가끔 70~100ms가 튀었다. ?perf로 파트별 프레임 시간을 쟀다.

for (let cx = cx0; cx <= cx1; cx++) for (let cz = cz0; cz <= cz1; cz++) {
  const r = range.get(`${cx},${cz}`); if (!r) continue;
  dm.set(SM.subarray(r[0] * 16, r[1] * 16), k * 16);   // 칸 하나 = 연속 구간 하나
  k += r[1] - r[0];
}
mesh.count = k;   // + addUpdateRange(0, k * 16)로 바뀐 앞부분만 업로드

같은 여의도 화면에서 인스턴스 170만 → 약 5천, 삼각형 2,690만 → 680만, JS 메모리(안정 후) 2.2GB → 0.75GB, 46 → 73fps.

10. 모바일 GPU

화질. 렌더러 pixelRatio가 1이라 레티나와 폰에서 1배로 그려 늘리고 있었다. 최종 화면과 그림 레이어는 기기 해상도(최대 2배)로, 엔진 본 패스는 픽셀 룩을 위해 그대로 저해상도로 뒀다. 대신 6K 모니터에서 2배면 한 프레임이 24Mpx라 밤 화면이 검게 나온 적이 있어서 출력 픽셀을 약 8.3Mpx로 묶었다.

DPR = Math.max(1, Math.min(2, devicePixelRatio || 1, Math.sqrt(8.3e6 / (w * h))));

깜빡임. 아이폰 화면 녹화를 프레임 단위로 보니, 밤에 그림 타일이 네모 단위로 깜빡이다 그림이 통째로 사라지고 엔진만 남았다. GPU 메모리 초과로 판단했다. 원인은 세 가지가 겹친 것이었다. 2배 해상도라 더 고운 레벨을 받아서 캐시가 빨리 찼고, 밤 불빛 텍스처가 예산 계산에서 빠져 있었고, 밉맵이 텍스처마다 약 33%를 더 먹었다.

768² RGBA8 타일 한 장은 약 2.4MB, 밉맵을 붙이면 약 3.1MB다(단순 계산, 실측 아님). 수정 전 폰 예산 72장은 그림만 약 227MB였고 밤에는 불빛이 그만큼 더 붙을 수 있었다. 수정 후는 불빛까지 합쳐 56장, 밉맵 없이 약 132MB다.

const COARSE = matchMedia('(pointer: coarse)').matches || new URLSearchParams(location.search).has('coarse');
const MAX_PYR = COARSE ? 56 : 140;
// 업로드: 폰은 밉맵 없음 (레벨을 기기 픽셀로 고르니 크게 줄일 일이 없다)
tx.minFilter = COARSE ? THREE.LinearFilter : THREE.LinearMipmapLinearFilter; tx.generateMipmaps = !COARSE;

검증은 데스크톱에서 ?coarse로 폰 예산을 흉내 내고 밤 화면을 40스텝 패닝하며 덮힘 비율(cover)을 기록했다. 패닝 중 0.8~1, 멈추면 1. 배포된 코드도 확인했다. 아이폰 실기기에서는 아직 다시 확인하지 못했다.

열기구 끌기. 메뉴 열기구는 스프링으로 화면 자리를 따라간다(평소 k=9, 감쇠 4.2, 잡았을 때 k=60, 감쇠 11, 지도가 밀리면 바람처럼 끌려감). 폰에서는 손가락 끌기가 처음 몇 px만 따라오고 멈췄다. 지도 영역이 touch-action: pan-y로 세로 스와이프를 페이지 스크롤에 넘기고 있어서 브라우저가 포인터를 취소한 것이다. 풍선 위에 투명한 잡기 영역을 따라다니게 해서 풀었다.

11. 실제 지하철 위치: 하루 1,000번으로 모두에게 보여주기

처음 지하철은 가짜였다. 지상으로 드러난 선로 구간을 이어 붙이고, 2.6km마다 열차 한 대를 놓고 16~22m/s 사이 랜덤 속도로 왕복시켰다. 지하로 들어가는 구간에서는 숨겼다. 출시 직전에 이걸 실제 운행 데이터로 바꿨다.

데이터. 서울 열린데이터광장의 실시간 열차 위치 API다. 좌표를 주지 않고 "어느 열차가 어느 역에 진입/도착/출발했다"를 준다. 열차 번호, 역 이름, 상·하행(2호선은 내·외선), 상태, 수신 시각이 온다. 키 하나로 하루 1,000번까지 부를 수 있고, 약관상 키를 남에게 공개하거나 공유하면 안 된다.

어느 노선을 받을까. 지도에 지상 선로가 보이는 노선부터 셌다. 노선별 지상 길이는 1호선 계열 195km, 경의중앙 99km, 2호선 31km, 경춘 22km, 3호선 12km, 공항철도 9km였다. 이 중 1·2·3호선과 경의중앙선, 4개만 실시간으로 받는다. 나머지는 가짜 열차로 남겼다.

구조: 방문자는 서울시를 부르지 않는다.

방문자 브라우저 ──(5분마다)──▶ /api/subway  (Vercel 함수 + CDN 캐시 10분)
                                   │  10분에 한 번만
                                   ▼
                          서울시 실시간 열차 위치 API (노선 4개 = 4번)

키는 Vercel 환경변수에만 있다. 함수는 응답에 s-maxage=600을 붙여서 CDN이 10분 동안 같은 사본을 나눠준다. 그래서 방문자가 몇 명이든 서울시 API는 한 지역에서 10분에 4번, 하루 576번만 불린다.

// api/subway.js (요약)
res.setHeader('cache-control', 'public, max-age=60, s-maxage=600, stale-while-revalidate=300');

한도에 걸리거나 서울시 서버가 빈 응답을 주면 함수는 직전의 정상 데이터를 계속 내준다. 그것마저 없으면 화면은 그 노선만 가짜 열차로 되돌린다. 열차가 통째로 사라지는 일은 없게 했다. CDN 사본은 지역마다 따로 생긴다. 그래서 해외 트래픽이 붙으면 지역 수만큼 호출이 늘어나는데, 이건 열린데이터광장 '활용사례' 등록으로 한도를 풀 계획이다.

역 이름을 지도 위 점으로. API는 역 이름만 준다. 처음에는 Overpass API에서 역 좌표를 받으려 했는데 두 번 다 타임아웃이 났다. 그래서 지도를 만들 때 쓴 OSM 추출 파일(pbf)에서 역 노드 524개를 직접 뽑았다(tools/extract-stations.cjs). 투영은 지도와 같은 식을 쓴다.

const proj = (lat, lon) => [(lon - LON0) * KX, -(lat - LAT0) * KZ];   // 지도와 같은 원점·축척

"신촌"처럼 이름이 같은 역이 여러 개면, 그 노선의 지상 선로에 가장 가까운 후보를 고른다. 선로에서 220m 넘게 떨어진 역은 지하 역으로 보고, 거기 있는 열차는 그리지 않는다. 터널 속에 있는 것으로 치는 거다.

상행이 어느 쪽인가. 데이터의 상·하행이 화면의 선로 방향 중 어느 쪽인지는 아무 데도 없다. 그래서 2분 간격으로 스냅샷을 두 번 찍어서(호출 8번) 역 A에서 B로 움직인 열차 97편을 모았다. "이 노선의 이 방향은 이 근처에서 이쪽으로 간다"는 표본이 된다. 실행 중에는 열차가 있는 역에서 가장 가까운 같은 노선·같은 방향 표본을 찾고, 그 이동 방향과 선로 접선을 내적해서 부호를 정한다.

// engine/realtrains.mjs (요약)
const dir = Math.sign(tangentAt(station).dot(nearestLearntMove(line, updn, station)));
const off = { 0: -250, 1: 0, 2: 30, 3: -800 }[status];   // 진입, 도착, 출발, 전역 출발
const d = station.d + dir * (off + Math.min(7000, secondsSinceReport * 11));

10분 사이를 메우기. 데이터는 최대 10분 전 것이다. 받은 시각부터 지하철 평균 속도(11m/s, 정차 포함)로 계속 달리게 했다. 새 데이터가 오면 같은 열차 번호끼리 6초에 걸쳐 새 위치로 미끄러지게 해서 순간이동하지 않는다. 실시간 열차가 들어오면, 그 노선의 가짜 열차 자리(instanced mesh 슬롯)를 비우고 실제 열차로 채운다. 남는 슬롯은 화면 밖(y=-999)으로 치운다.

배포에서 막힌 것. 정적 사이트 설정(outputDirectory: ".", 빈 빌드 명령)에서는 api/ 폴더를 넣어도 함수가 만들어지지 않고 404가 났다. vercel.json에 functions를 명시하자 살아났다.

결과와 한계. 배포 직후 기준으로 운행 중인 열차는 155편이었고, 그중 지상에 있어서 그려진 건 45편이었다. 예전 가짜 열차는 약 140편이 고르게 흩어져 있었다. 진짜 데이터로 바꾸자 오히려 지도가 휑해졌다. 대부분 열차가 그 순간 지하에 있기 때문이다. 정직하게 보여주는 것과 북적여 보이는 것 사이에서 아직 고르는 중이다. 붙이고 나서 바로 버그 두 개가 나왔다. - 처음에 진행 상한을 3km로 뒀더니, 10분 데이터 사이에 열차가 선로 한가운데서 멈춰 섰다. - 선로 끝을 넘어간 열차들이 끝자락에 겹쳐 서 있었다.

상한을 7km로 늘리고, 선로 밖으로 나간 열차는 숨기는 걸로 고쳤다.

정확히 말하면 완전한 실시간은 아니다. 데이터는 최대 10분 전 것이고, 그 사이는 예측으로 움직인다. 그래서 소개 문구도 "실시간 지하철"이 아니라 "실제 운행 데이터로 움직이는 지하철"로 쓴다.

숫자로 보는 제작

항목 값
서울 칸 수 첫 완성 6,394칸(603km²), 범위·지형 보정 후 6,470칸
칸 규격 1024px, 0.3 m/px, 768px 간격(겹침 256px), 경계 섞기 96px
학습 데이터 검사 통과 33쌍 (엔진 렌더 ↔ 그림)
채택 모델 FLUX.2 klein base 4B 편집 LoRA, rank 64(alpha 32), 1,500단계, lr 1e-4, bf16
학습 속도 맥북 57~77초/단계 → Kaggle T4 27초 → A100 2.41초
추론 8스텝·guidance 1, A100 칸당 4.1초(시험) / 4.9초(본 변환)
서울 전체 변환 A100 10대 동시, 51분, 오류 0
GPU 생성비 첫 완성까지 Modal 누적 약 $36(무료 크레딧 $30 포함), 10-23 기준 누적 $44.98
건물 약 58.5만 채, 공식 높이 20.4만 채
다시 그린 곳 패치 122장 (랜드마크·궁궐·성수 사옥·건축상 건물·주차장, 한강 다리 24곳 37장 포함)
야경 9,704칸, 로컬 6분, 0원
타일 768px WebP z0~8, 약 2.0GB + 불빛 439MB, 파일 22,186개
렌더 최적화 인스턴스 170만 → 약 5천, 삼각형 2,690만 → 420만, 46 → 73fps, JS 메모리 2.2GB → 0.75GB
로딩 워커 약 7초 → 1.25~1.5초, 첫 그림 2초 안
GPU 텍스처 예산 데스크톱 140장, 폰 72 → 56장(불빛 포함, 밉맵 없음)
캐릭터 한국 고전 캐릭터 36종

실패에서 배운 것

다음 계획

데이터 출처