Files
GhiVideo_v4/docs/발표_GhiVideo_좌표정합과측점기반구현.md
b23042andClaude Fable 5 d38b842e8d GhiVideo 소스 복제 — v4 작업 시작 기준
기존 GhiVideo 저장소 HEAD의 트래킹 소스 362개 파일을 복제.
(node_modules·storage·빌드 산출물·대용량 미디어는 .gitignore 규칙대로 제외)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 14:32:51 +09:00

635 lines
40 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# GhiVideo 발표 자료
## 드론 GPS · 영상 · POI 좌표를 영상 위 정확한 위치에 — 그리고 프레임에서 측점(스테이션)으로
> 작성일: 2026-06-30 · 발표용 요약 문서
> 슬라이드 구분은 `---` 입니다 (Marp/reveal.js 변환 가능). 각 장은 "그림 → 핵심 → 코드 근거" 순.
<style>
figure.fig{margin:18px 0;text-align:center;page-break-inside:avoid;break-inside:avoid;}
figure.fig svg{width:100%;max-width:680px;height:auto;border:1px solid #e7e0d2;border-radius:10px;background:#fffdf8;}
figure.fig figcaption{font-size:0.88em;color:#777;margin-top:6px;}
text{font-family:'Malgun Gothic','Hancom Gothic',sans-serif;}
h2{border-bottom:2px solid #e7e0d2;padding-bottom:4px;}
.lead{background:#f8f6ef;border-left:4px solid #b45309;padding:10px 14px;border-radius:6px;}
</style>
---
## 목차
| 🟦 공간 정합 | 🟩 시간 동기화 | 🟧 표시 안정 | 🟦 측점 전환 |
|---|---|---|---|
| 지도좌표 → 화면픽셀 | 영상시각 ↔ 드론프레임 | 떨림·튐 제거 | 프레임 → 측점(공간) |
1. 우리가 풀어야 했던 문제
2. 입력 데이터 3종 — GPS · 영상 · POI
3. 전체 파이프라인 한 장
4. **핵심 ①** 좌표 정합 — 지도 좌표를 화면 픽셀로 (투영)
5. **핵심 ②** 시간 동기화 — 영상 시각과 드론 프레임 맞추기
6. **핵심 ③** 라벨 안정화 — 떨림·튐 제거
7. **핵심 ④** 프레임 기반 → 측점(스테이션) 기반 전환
8. 정확도 보정 도구 (DEM · 드래그 · 세로화각)
9. 정리 — 무엇을, 어떻게
---
## 1. 우리가 풀어야 했던 문제
<p class="lead">드론이 철도 노선을 따라 비행하며 찍은 영상 위에, <b>교량·터널·역사·지장물(POI)의 이름표를 실제 위치에 정확히</b> 띄우고 싶다.</p>
<figure class="fig">
<svg viewBox="0 0 680 300" role="img" aria-label="드론 영상 위 라벨 예시">
<!-- video frame -->
<rect x="20" y="20" width="640" height="230" rx="10" fill="#0b1020" stroke="#334155" stroke-width="2"/>
<!-- sky gradient -->
<defs>
<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#1e3a5f"/><stop offset="1" stop-color="#0b1020"/></linearGradient>
<marker id="m1" markerWidth="9" markerHeight="9" refX="6" refY="3" orient="auto"><path d="M0,0 L6,3 L0,6 Z" fill="#f59e0b"/></marker>
</defs>
<rect x="22" y="22" width="636" height="120" fill="url(#sky)"/>
<!-- rails -->
<polygon points="250,250 430,250 360,120 320,120" fill="#1c2533" stroke="#475569"/>
<line x1="285" y1="250" x2="338" y2="120" stroke="#94a3b8" stroke-width="2"/>
<line x1="395" y1="250" x2="342" y2="120" stroke="#94a3b8" stroke-width="2"/>
<g stroke="#64748b" stroke-width="1.5">
<line x1="300" y1="210" x2="380" y2="210"/><line x1="312" y1="180" x2="368" y2="180"/><line x1="322" y1="155" x2="358" y2="155"/></g>
<!-- bridge label -->
<circle cx="175" cy="120" r="5" fill="#f59e0b"/>
<rect x="120" y="78" width="110" height="26" rx="5" fill="#f59e0b"/>
<text x="175" y="96" text-anchor="middle" font-size="13" font-weight="bold" fill="#1a1303">🌉 회덕제1가도교</text>
<line x1="175" y1="104" x2="175" y2="116" stroke="#f59e0b" stroke-width="1.5" marker-end="url(#m1)"/>
<!-- tunnel label -->
<circle cx="500" cy="120" r="5" fill="#38bdf8"/>
<rect x="455" y="78" width="92" height="26" rx="5" fill="#38bdf8"/>
<text x="501" y="96" text-anchor="middle" font-size="13" font-weight="bold" fill="#06283d">🚇 법동터널</text>
<line x1="501" y1="104" x2="501" y2="116" stroke="#38bdf8" stroke-width="1.5"/>
<!-- HUD -->
<rect x="34" y="214" width="320" height="24" rx="5" fill="#000" opacity="0.55"/>
<text x="44" y="231" font-size="12" fill="#e2e8f0">GPS 36.334, 127.456 · 고도 123m · 측점 157K970</text>
<!-- compass -->
<circle cx="615" cy="60" r="24" fill="#0b1020" stroke="#64748b"/>
<polygon points="615,42 609,60 621,60" fill="#f87171"/><text x="615" y="38" text-anchor="middle" font-size="9" fill="#f87171">N</text>
<text x="340" y="278" text-anchor="middle" font-size="12" fill="#555">드론 영상 + 실제 위치에 붙는 이름표 + 위치 HUD</text>
</svg>
<figcaption>그림 1. 목표 — 흔들리는 드론 영상 위에, 지도 좌표만 가진 POI를 "진짜 그 자리"에 표시.</figcaption>
</figure>
**난이도 4가지** → 그래서 **세 가지 정합**이 필요합니다.
| 난이도 | 무엇이 어려운가 | 해결 정합 |
|---|---|---|
| 드론 흔들림 | yaw/pitch/roll·고도 변화·빠른 이동 | 🟦 공간 + 🟧 안정 |
| POI는 지도좌표만 | "화면 어디"인지 모름 | 🟦 공간(투영) |
| 시간 어긋남 | 영상 시각 ↔ 드론 데이터 시각 | 🟩 시간 |
| 매끄러움 | 60fps·떨림 없이 | 🟧 안정 |
---
## 2. 입력 데이터 3종
<figure class="fig">
<svg viewBox="0 0 680 270" role="img" aria-label="입력 3종 결합">
<defs><marker id="m2" markerWidth="10" markerHeight="10" refX="7" refY="3.5" orient="auto"><path d="M0,0 L7,3.5 L0,7 Z" fill="#b45309"/></marker></defs>
<!-- 1 drone log -->
<rect x="30" y="30" width="170" height="120" rx="10" fill="#f5f3ff" stroke="#7c3aed" stroke-width="1.5"/>
<text x="115" y="54" text-anchor="middle" font-size="14" font-weight="bold" fill="#5b21b6">① 드론 로그</text>
<text x="115" y="74" text-anchor="middle" font-size="11" fill="#6d28d9">프레임별 비행 CSV</text>
<g font-size="10" fill="#4c1d95" font-family="monospace">
<text x="46" y="98">frame lat yaw</text>
<text x="46" y="114"> 0 36.33 12°</text>
<text x="46" y="130"> 1 36.33 13°</text>
<text x="46" y="146"> … alt·pitch·roll</text></g>
<!-- 2 video -->
<rect x="255" y="30" width="170" height="120" rx="10" fill="#eff6ff" stroke="#2563eb" stroke-width="1.5"/>
<text x="340" y="54" text-anchor="middle" font-size="14" font-weight="bold" fill="#1e3a8a">② 영상</text>
<text x="340" y="74" text-anchor="middle" font-size="11" fill="#1d4ed8">mp4/webm 주행영상</text>
<rect x="295" y="86" width="90" height="50" rx="4" fill="#0b1020"/>
<polygon points="332,100 332,122 352,111" fill="#fff"/>
<!-- 3 poi -->
<rect x="480" y="30" width="170" height="120" rx="10" fill="#ecfdf5" stroke="#16a34a" stroke-width="1.5"/>
<text x="565" y="54" text-anchor="middle" font-size="14" font-weight="bold" fill="#14532d">③ POI / 측점</text>
<text x="565" y="74" text-anchor="middle" font-size="11" fill="#15803d">KMZ(1순위)+측점CSV</text>
<g font-size="10" fill="#166534" font-family="monospace">
<text x="496" y="98">🌉 교량 (lat,lon,z)</text>
<text x="496" y="114">🚇 터널 (lat,lon,z)</text>
<text x="496" y="130">📍 측점 157K970</text></g>
<!-- merge -->
<line x1="115" y1="150" x2="320" y2="195" stroke="#b45309" stroke-width="1.5" marker-end="url(#m2)"/>
<line x1="340" y1="150" x2="340" y2="195" stroke="#b45309" stroke-width="1.5" marker-end="url(#m2)"/>
<line x1="565" y1="150" x2="360" y2="195" stroke="#b45309" stroke-width="1.5" marker-end="url(#m2)"/>
<rect x="200" y="205" width="280" height="40" rx="20" fill="#fff7e6" stroke="#f59e0b" stroke-width="2"/>
<text x="340" y="230" text-anchor="middle" font-size="14" font-weight="bold" fill="#b45309">이 셋을 시간·공간으로 묶는다</text>
</svg>
<figcaption>그림 2. 드론 로그(언제·어디·어느 방향) + 영상 + 지도 POI를 결합.</figcaption>
</figure>
---
## 3. 전체 파이프라인 한 장
<figure class="fig">
<svg viewBox="0 0 680 420" role="img" aria-label="전체 파이프라인">
<defs><marker id="m3" markerWidth="10" markerHeight="10" refX="7" refY="3.5" orient="auto"><path d="M0,0 L7,3.5 L0,7 Z" fill="#64748b"/></marker></defs>
<!-- lane labels -->
<rect x="14" y="14" width="14" height="392" rx="4" fill="#16a34a" opacity="0.15"/>
<!-- step: time -->
<rect x="180" y="20" width="320" height="46" rx="8" fill="#ecfdf5" stroke="#16a34a" stroke-width="1.5"/>
<text x="340" y="40" text-anchor="middle" font-size="13" font-weight="bold" fill="#14532d">🟩 영상 재생 시각 t</text>
<text x="340" y="58" text-anchor="middle" font-size="11" fill="#15803d">smoothTimeRef: 60fps 단조보간</text>
<line x1="340" y1="66" x2="340" y2="84" stroke="#64748b" stroke-width="1.6" marker-end="url(#m3)"/>
<rect x="180" y="86" width="320" height="40" rx="8" fill="#ecfdf5" stroke="#16a34a" stroke-width="1.2"/>
<text x="340" y="111" text-anchor="middle" font-size="12" fill="#166534">t → 드론 프레임 보간 → 그 순간의 위치·자세(pose)</text>
<line x1="340" y1="126" x2="340" y2="142" stroke="#64748b" stroke-width="1.6" marker-end="url(#m3)"/>
<!-- projection box -->
<rect x="120" y="144" width="440" height="120" rx="10" fill="#eff6ff" stroke="#2563eb" stroke-width="1.8"/>
<text x="340" y="166" text-anchor="middle" font-size="13" font-weight="bold" fill="#1e3a8a">🟦 좌표 정합 (투영) — 4단계</text>
<g font-size="12" fill="#1e40af">
<text x="340" y="190" text-anchor="middle">POI(위경도·표고) → ENU(평면 미터)</text>
<text x="340" y="212" text-anchor="middle">→ 카메라 좌표 (yaw/pitch/roll 회전)</text>
<text x="340" y="234" text-anchor="middle">→ 화면 정규픽셀(0~1) [핀홀 카메라]</text>
<text x="340" y="256" text-anchor="middle">→ object-fit:cover 보정 → 실제 화면 px</text></g>
<line x1="340" y1="264" x2="340" y2="280" stroke="#64748b" stroke-width="1.6" marker-end="url(#m3)"/>
<!-- smoothing -->
<rect x="180" y="282" width="320" height="40" rx="8" fill="#fff7ed" stroke="#d97706" stroke-width="1.5"/>
<text x="340" y="307" text-anchor="middle" font-size="12" fill="#9a3412">🟧 라벨 평활(One Euro) + 이상치 거부 → 떨림 제거</text>
<line x1="340" y1="322" x2="340" y2="338" stroke="#64748b" stroke-width="1.6" marker-end="url(#m3)"/>
<!-- render -->
<rect x="180" y="340" width="320" height="40" rx="8" fill="#fff" stroke="#23272e" stroke-width="1.5"/>
<text x="340" y="365" text-anchor="middle" font-size="12" font-weight="bold" fill="#23272e">Canvas RAF 60fps 렌더 → 영상 위 이름표</text>
<!-- station branch -->
<line x1="500" y1="360" x2="600" y2="360" stroke="#0891b2" stroke-width="1.6"/>
<line x1="600" y1="360" x2="600" y2="395" stroke="#0891b2" stroke-width="1.6"/>
<rect x="430" y="392" width="240" height="22" rx="6" fill="#ecfeff" stroke="#0891b2"/>
<text x="550" y="407" text-anchor="middle" font-size="10.5" fill="#155e75">🟦 GPS→측점 투영 → 하단 측점바 커서</text>
</svg>
<figcaption>그림 3. 시각 t → pose 보간 → 4단계 투영 → 평활 → 렌더. 동시에 GPS는 측점값으로 투영되어 측점바로.</figcaption>
</figure>
---
## 4. 핵심 ① 좌표 정합 — 지도 좌표를 화면 픽셀로
<p class="lead">"POI는 위경도만 안다. 그게 <b>지금 화면 어디</b>에 보이는가?" — 이게 이 프로그램의 심장. <b>4단계 투영</b>으로 푼다.</p>
<figure class="fig">
<svg viewBox="0 0 680 150" role="img" aria-label="4단계 투영 흐름">
<defs><marker id="m4" markerWidth="10" markerHeight="10" refX="7" refY="3.5" orient="auto"><path d="M0,0 L7,3.5 L0,7 Z" fill="#2563eb"/></marker></defs>
<g text-anchor="middle">
<rect x="14" y="50" width="110" height="54" rx="8" fill="#ecfdf5" stroke="#16a34a"/><text x="69" y="74" font-size="12" font-weight="bold" fill="#14532d">위경도+표고</text><text x="69" y="92" font-size="10" fill="#166534">POI 🌉 (지도)</text>
<rect x="150" y="50" width="110" height="54" rx="8" fill="#eff6ff" stroke="#2563eb"/><text x="205" y="72" font-size="12" font-weight="bold" fill="#1e3a8a">평면 미터</text><text x="205" y="90" font-size="10" fill="#1d4ed8">ENU · 1단계</text>
<rect x="286" y="50" width="110" height="54" rx="8" fill="#eff6ff" stroke="#2563eb"/><text x="341" y="72" font-size="12" font-weight="bold" fill="#1e3a8a">카메라 좌표</text><text x="341" y="90" font-size="10" fill="#1d4ed8">회전 · 2단계</text>
<rect x="422" y="50" width="110" height="54" rx="8" fill="#eff6ff" stroke="#2563eb"/><text x="477" y="72" font-size="12" font-weight="bold" fill="#1e3a8a">화면 0~1</text><text x="477" y="90" font-size="10" fill="#1d4ed8">핀홀 · 3단계</text>
<rect x="558" y="50" width="108" height="54" rx="8" fill="#fff" stroke="#23272e" stroke-width="1.5"/><text x="612" y="72" font-size="12" font-weight="bold" fill="#23272e">화면 px</text><text x="612" y="90" font-size="10" fill="#555">cover · 4단계</text>
</g>
<line x1="124" y1="77" x2="148" y2="77" stroke="#2563eb" stroke-width="1.5" marker-end="url(#m4)"/>
<line x1="260" y1="77" x2="284" y2="77" stroke="#2563eb" stroke-width="1.5" marker-end="url(#m4)"/>
<line x1="396" y1="77" x2="420" y2="77" stroke="#2563eb" stroke-width="1.5" marker-end="url(#m4)"/>
<line x1="532" y1="77" x2="556" y2="77" stroke="#2563eb" stroke-width="1.5" marker-end="url(#m4)"/>
</svg>
<figcaption>그림 4. 4단계 투영 파이프라인. 파일: client/src/utils/geoProjection.ts</figcaption>
</figure>
---
### 4-1. 둥근 지구를 평평하게 (ENU 변환)
지구는 둥글어 거리 계산이 어렵습니다. 노선 한 구간만 잘라 **평평한 모눈종이(미터 단위)** 로 폅니다.
<figure class="fig">
<svg viewBox="0 0 680 200" role="img" aria-label="ENU 변환">
<defs><marker id="m41" markerWidth="9" markerHeight="9" refX="6" refY="3" orient="auto"><path d="M0,0 L6,3 L0,6 Z" fill="#6b7280"/></marker></defs>
<!-- globe -->
<circle cx="110" cy="100" r="55" fill="#dbeafe" stroke="#2563eb"/>
<ellipse cx="110" cy="100" rx="55" ry="20" fill="none" stroke="#93c5fd"/>
<ellipse cx="110" cy="100" rx="20" ry="55" fill="none" stroke="#93c5fd"/>
<circle cx="126" cy="80" r="5" fill="#dc2626"/>
<text x="110" y="175" text-anchor="middle" font-size="11" fill="#1e3a8a">둥근 지구 (위경도)</text>
<text x="235" y="95" font-size="13" fill="#166534" font-weight="bold">EPSG:5186</text>
<text x="235" y="112" font-size="11" fill="#166534">TM 투영</text>
<line x1="172" y1="100" x2="318" y2="100" stroke="#16a34a" stroke-width="2.5" marker-end="url(#m41)"/>
<!-- grid -->
<g stroke="#e5e7eb"><line x1="370" y1="40" x2="370" y2="170"/><line x1="430" y1="40" x2="430" y2="170"/><line x1="490" y1="40" x2="490" y2="170"/><line x1="550" y1="40" x2="550" y2="170"/><line x1="610" y1="40" x2="610" y2="170"/>
<line x1="350" y1="60" x2="650" y2="60"/><line x1="350" y1="100" x2="650" y2="100"/><line x1="350" y1="140" x2="650" y2="140"/></g>
<line x1="370" y1="160" x2="650" y2="160" stroke="#6b7280" stroke-width="2" marker-end="url(#m41)"/>
<line x1="370" y1="160" x2="370" y2="40" stroke="#6b7280" stroke-width="2" marker-end="url(#m41)"/>
<text x="635" y="178" font-size="11" fill="#6b7280">E 동(m)</text>
<text x="345" y="48" font-size="11" fill="#6b7280">N 북(m)</text>
<circle cx="370" cy="160" r="4" fill="#16a34a"/><text x="376" y="176" font-size="10" fill="#14532d">기준점(0,0,0)</text>
<circle cx="490" cy="76" r="6" fill="#b45309"/>
<text x="500" y="72" font-size="11" font-weight="bold" fill="#b45309">POI (E=120, N=300, U=-5)</text>
</svg>
<figcaption>그림 5. E/N = 동·북 몇 m, U = 기준점 대비 상대 높이(alt refAlt).</figcaption>
</figure>
```ts
// geoToEnu (9197)
const [e, n] = latLonToTM(lat, lon); // 위경도 → 평면 미터
return [e, n, alt - refAlt];
```
---
### 4-2. 드론이 보는 방향으로 회전 (카메라 좌표)
세상 좌표(E/N/U)를 드론의 **yaw·pitch·roll** 회전행렬에 곱해 "카메라가 보는 좌표(Xc, Yc, Zc)"로 바꿉니다.
<figure class="fig">
<svg viewBox="0 0 680 210" role="img" aria-label="세상→카메라 회전">
<defs><marker id="m42" markerWidth="10" markerHeight="10" refX="7" refY="3.5" orient="auto"><path d="M0,0 L7,3.5 L0,7 Z" fill="#7c3aed"/></marker></defs>
<!-- world frame -->
<text x="150" y="30" text-anchor="middle" font-size="12" font-weight="bold" fill="#334155">세상 기준 (북 고정)</text>
<line x1="80" y1="150" x2="240" y2="150" stroke="#6b7280" stroke-width="1.6"/><text x="248" y="154" font-size="11" fill="#6b7280">E</text>
<line x1="150" y1="170" x2="150" y2="60" stroke="#6b7280" stroke-width="1.6"/><text x="156" y="62" font-size="11" fill="#6b7280">N</text>
<circle cx="150" cy="150" r="8" fill="#7c3aed"/><text x="150" y="186" text-anchor="middle" font-size="10" fill="#5b21b6">🚁 북쪽 봄</text>
<circle cx="210" cy="95" r="5" fill="#b45309"/><text x="218" y="92" font-size="10" fill="#b45309">POI</text>
<!-- arrow -->
<line x1="280" y1="120" x2="370" y2="120" stroke="#7c3aed" stroke-width="2.5" marker-end="url(#m42)"/>
<text x="325" y="110" text-anchor="middle" font-size="11" fill="#5b21b6">회전</text>
<!-- camera frame -->
<text x="520" y="30" text-anchor="middle" font-size="12" font-weight="bold" fill="#334155">드론이 보는 기준 (정면이 기준)</text>
<circle cx="450" cy="150" r="8" fill="#7c3aed"/><text x="450" y="186" text-anchor="middle" font-size="10" fill="#5b21b6">🚁</text>
<line x1="450" y1="150" x2="610" y2="80" stroke="#16a34a" stroke-width="1.8" marker-end="url(#m42)"/><text x="612" y="78" font-size="11" fill="#15803d">앞(Zc)</text>
<line x1="450" y1="150" x2="600" y2="160" stroke="#0891b2" stroke-width="1.8" marker-end="url(#m42)"/><text x="604" y="166" font-size="11" fill="#155e75">옆(side)</text>
<circle cx="556" cy="108" r="5" fill="#b45309"/><text x="500" y="100" font-size="10" fill="#b45309">POI (앞 50m, 옆 8m)</text>
</svg>
<figcaption>그림 6. 북 기준 좌표를 "드론 정면 기준"으로 회전. 진행방향 거리 fwd(앞)·side(옆)도 함께 산출.</figcaption>
</figure>
```ts
// toCameraCoords (301317)
cc.fwd = relEnu[0]*sy + relEnu[1]*cy; // +면 앞쪽
cc.side = relEnu[0]*cy - relEnu[1]*sy; // +면 오른쪽 → "앞은 멀리, 옆은 가깝게" 비등방 필터 근거
```
---
### 4-3. 3D를 납작한 사진으로 (핀홀 카메라 투영)
핵심 한 줄: **깊이(Zc)로 나눈다 → 멀수록 화면 가운데로 작게.** (바늘구멍 사진기 원리)
<figure class="fig">
<svg viewBox="0 0 680 210" role="img" aria-label="핀홀 카메라">
<!-- pinhole -->
<rect x="330" y="55" width="16" height="100" fill="#475569"/>
<circle cx="338" cy="105" r="6" fill="#fffdf8" stroke="#475569"/>
<text x="338" y="172" text-anchor="middle" font-size="10" fill="#475569">구멍(렌즈)</text>
<!-- near big object -->
<line x1="120" y1="55" x2="120" y2="155" stroke="#16a34a" stroke-width="6"/>
<text x="120" y="44" text-anchor="middle" font-size="10" fill="#14532d">가까운 🌉</text>
<!-- far small object -->
<line x1="40" y1="85" x2="40" y2="125" stroke="#22c55e" stroke-width="6"/>
<text x="40" y="74" text-anchor="middle" font-size="10" fill="#15803d">먼 🌉</text>
<!-- rays through pinhole to screen -->
<line x1="120" y1="55" x2="338" y2="105" stroke="#16a34a" stroke-width="1" stroke-dasharray="3 3"/>
<line x1="120" y1="155" x2="338" y2="105" stroke="#16a34a" stroke-width="1" stroke-dasharray="3 3"/>
<line x1="338" y1="105" x2="470" y2="135" stroke="#16a34a" stroke-width="1" stroke-dasharray="3 3"/>
<line x1="338" y1="105" x2="470" y2="75" stroke="#16a34a" stroke-width="1" stroke-dasharray="3 3"/>
<!-- screen -->
<rect x="468" y="50" width="150" height="110" rx="6" fill="#0b1020" stroke="#334155"/>
<text x="543" y="44" text-anchor="middle" font-size="10" fill="#555">화면(0~1)</text>
<line x1="495" y1="75" x2="495" y2="135" stroke="#4ade80" stroke-width="5"/>
<text x="555" y="110" font-size="10" fill="#86efac">가까운 게 크게 맺힘</text>
<text x="338" y="200" text-anchor="middle" font-size="11" fill="#555">거리(Zc)가 클수록 → 화면에 작게</text>
</svg>
<figcaption>그림 7. pxRaw = 0.5 + (Xc/Zc)·(f/sensorW). 0.5=정중앙, f/sensor=화각.</figcaption>
</figure>
```ts
// pixelFromCamera (126137)
pxRaw = (0.5 + cx0) + (Xc / Zc) * (f / sW); // 가로 0~1
pyRaw = (0.5 + cy0) + (Yc / Zc) * (f / sH); // 세로 0~1
```
---
### 4-4. 높이 기준 통일 (지오이드 보정) + 화면 정렬
**두 개의 '0층' 문제**: 지도 높이(정표고·해발)와 GPS 높이(타원체고)는 기준이 달라 대전 기준 약 **25.8m** 차이. 안 맞추면 라벨이 위아래로 어긋납니다.
<figure class="fig">
<svg viewBox="0 0 680 180" role="img" aria-label="지오이드 보정">
<defs><marker id="m44a" markerWidth="9" markerHeight="9" refX="3" refY="3" orient="auto"><path d="M0,0 L6,3 L0,6 Z" fill="#dc2626"/></marker><marker id="m44b" markerWidth="9" markerHeight="9" refX="3" refY="3" orient="auto"><path d="M6,0 L0,3 L6,6 Z" fill="#dc2626"/></marker></defs>
<line x1="60" y1="50" x2="500" y2="50" stroke="#2563eb" stroke-width="2.5" stroke-dasharray="7 4"/>
<text x="510" y="54" font-size="12" fill="#1e3a8a">타원체고(GPS) 0</text>
<line x1="60" y1="110" x2="500" y2="110" stroke="#0ea5e9" stroke-width="2.5" stroke-dasharray="7 4"/>
<text x="510" y="114" font-size="12" fill="#0369a1">정표고(해발) 0</text>
<line x1="150" y1="51" x2="150" y2="109" stroke="#dc2626" stroke-width="1.6" marker-start="url(#m44a)" marker-end="url(#m44b)"/>
<text x="160" y="84" font-size="13" font-weight="bold" fill="#dc2626">≈ 25.8m (geoidOffset)</text>
<text x="160" y="100" font-size="10" fill="#b91c1c">지도 높이에 더해 GPS 기준으로 통일</text>
<text x="280" y="150" text-anchor="middle" font-size="11" fill="#555">기준을 맞춰야 라벨이 제 높이에 붙는다</text>
</svg>
<figcaption>그림 8. 지도 표고 + geoidOffset → GPS와 같은 기준. 마지막에 object-fit:cover 잘림을 반영해 0~1 → 실제 px.</figcaption>
</figure>
```ts
geoToEnu(lat, lon, targetAlt + params.geoidOffset, ...); // 높이 기준 통일
// 이후 coverRef(StationOverlay 921930)로 정규(0~1) → 실제 화면 px 정렬
```
➡ 4-1 ~ 4-4를 거치면 **POI가 영상 속 진짜 그 자리에** 찍힙니다.
---
## 5. 핵심 ② 시간 동기화 — 영상 시각 ↔ 드론 프레임
좌표가 맞아도 **"지금 영상 시각의 드론 위치"** 를 못 집으면 라벨이 엉뚱한 데 뜹니다.
**문제**: 브라우저 `video.currentTime`은 ~250ms 간격으로만 갱신 → 커서·라벨이 뚝뚝 끊김.
**해결**: `smoothTimeRef` — 벽시계로 **60fps 단조 보간**.
<figure class="fig">
<svg viewBox="0 0 680 190" role="img" aria-label="시간 보간">
<!-- raw -->
<text x="40" y="42" font-size="12" font-weight="bold" fill="#b91c1c">실제 currentTime (250ms 띄엄띄엄)</text>
<line x1="50" y1="60" x2="630" y2="60" stroke="#e5e7eb" stroke-width="2"/>
<g fill="#dc2626"><circle cx="80" cy="60" r="6"/><circle cx="220" cy="60" r="6"/><circle cx="360" cy="60" r="6"/><circle cx="500" cy="60" r="6"/><circle cx="620" cy="60" r="6"/></g>
<text x="340" y="84" text-anchor="middle" font-size="11" fill="#b91c1c">→ 라벨/커서가 뚝뚝 끊김</text>
<!-- smooth -->
<text x="40" y="124" font-size="12" font-weight="bold" fill="#166534">smoothTimeRef (매 프레임 채움 · 60fps)</text>
<line x1="50" y1="142" x2="630" y2="142" stroke="#e5e7eb" stroke-width="2"/>
<g fill="#16a34a"><circle cx="80" cy="142" r="4"/><circle cx="110" cy="142" r="4"/><circle cx="140" cy="142" r="4"/><circle cx="170" cy="142" r="4"/><circle cx="200" cy="142" r="4"/><circle cx="230" cy="142" r="4"/><circle cx="260" cy="142" r="4"/><circle cx="290" cy="142" r="4"/><circle cx="320" cy="142" r="4"/><circle cx="350" cy="142" r="4"/><circle cx="380" cy="142" r="4"/><circle cx="410" cy="142" r="4"/><circle cx="440" cy="142" r="4"/><circle cx="470" cy="142" r="4"/><circle cx="500" cy="142" r="4"/><circle cx="530" cy="142" r="4"/><circle cx="560" cy="142" r="4"/><circle cx="590" cy="142" r="4"/><circle cx="620" cy="142" r="4"/></g>
<text x="340" y="166" text-anchor="middle" font-size="11" fill="#166534">media + 경과시간×배속 · 0.3s 이상 벌어지면 재동기화</text>
</svg>
<figcaption>그림 9. 띄엄띄엄한 실제 시각을 60fps로 메워 부드럽게. 이 t로 드론 pose를 보간.</figcaption>
</figure>
```ts
// VideoPlayer.tsx 62117
let est = a.media + ((performance.now() - a.wall)/1000) * rate;
if (real - est > 0.3) est = real; // 재동기화
smoothTimeRef.current = t; // ref로 노출 → 리렌더 없이 매 프레임 읽음
```
---
## 6. 핵심 ③ 라벨 안정화 — 떨림·튐 제거
드론은 미세하게 흔들립니다. 그대로 투영하면 라벨이 부르르 떨립니다. → **One Euro 방식 속도적응 평활 + 이상치 거부.**
<figure class="fig">
<svg viewBox="0 0 680 220" role="img" aria-label="One Euro 평활">
<!-- case 1 -->
<rect x="20" y="20" width="210" height="180" rx="10" fill="#ecfdf5" stroke="#16a34a"/>
<text x="125" y="42" text-anchor="middle" font-size="12" font-weight="bold" fill="#14532d">떨림(왕복)</text>
<polyline points="40,110 60,95 80,118 100,92 120,116 140,96 160,114 180,98 200,110" fill="none" stroke="#dc2626" stroke-width="1.5"/>
<line x1="40" y1="150" x2="210" y2="150" stroke="#16a34a" stroke-width="3"/>
<text x="125" y="172" text-anchor="middle" font-size="10.5" fill="#166534">평활속도≈0 → 강하게 평활</text>
<text x="125" y="188" text-anchor="middle" font-size="10.5" fill="#166534">(안정)</text>
<!-- case 2 -->
<rect x="240" y="20" width="200" height="180" rx="10" fill="#eff6ff" stroke="#2563eb"/>
<text x="340" y="42" text-anchor="middle" font-size="12" font-weight="bold" fill="#1e3a8a">실제 이동</text>
<polyline points="260,150 290,130 320,110 350,90 380,70 410,55" fill="none" stroke="#2563eb" stroke-width="2.5"/>
<text x="340" y="172" text-anchor="middle" font-size="10.5" fill="#1e40af">방향 일관 → 즉시 추종</text>
<text x="340" y="188" text-anchor="middle" font-size="10.5" fill="#1e40af">(지연 없음)</text>
<!-- case 3 -->
<rect x="450" y="20" width="210" height="180" rx="10" fill="#fff7ed" stroke="#d97706"/>
<text x="555" y="42" text-anchor="middle" font-size="12" font-weight="bold" fill="#9a3412">갑작스런 점프</text>
<polyline points="470,150 500,148 530,150" fill="none" stroke="#d97706" stroke-width="2"/>
<circle cx="610" cy="80" r="6" fill="#dc2626"/>
<line x1="530" y1="150" x2="600" y2="86" stroke="#dc2626" stroke-width="1.5" stroke-dasharray="4 3"/>
<text x="612" y="76" font-size="14" fill="#dc2626">✗</text>
<text x="555" y="180" text-anchor="middle" font-size="10.5" fill="#9a3412">8프레임까지 무시 후 수용</text>
<text x="555" y="195" text-anchor="middle" font-size="10.5" fill="#9a3412">(시크는 진짜 → 결국 수용)</text>
</svg>
<figcaption>그림 10. 떨림은 죽이고, 진짜 이동은 즉시 따라가고, 튐(점프)은 무시. 세 동작을 한 필터로.</figcaption>
</figure>
```ts
// StationOverlay.tsx 4861
if (d > REJECT_DIST && prev.rej < MAX_REJECT_FRAMES) // 0.12 초과 점프 = 이상치
return { ...prev, rej: prev.rej + 1 }; // 위치 유지
const a = min(maxAlpha, minAlpha + (maxAlpha-minAlpha)*min(1, speed/speedRef));
return { x: prev.x + dx*a, y: prev.y + dy*a, ... }; // 속도 클수록 빠르게 추종
```
---
## 7. 핵심 ④ 프레임 기반 → 측점(스테이션) 기반
### 7-1. 왜 바꿨나? — 시간축의 한계
철도 현장은 **위치(측점)** 로 말합니다: "157K970 지점의 교량". 그런데 시간축은 **드론이 호버(정지)** 하면 무너집니다.
<figure class="fig">
<svg viewBox="0 0 680 180" role="img" aria-label="시간축 한계">
<text x="120" y="32" font-size="12" font-weight="bold" fill="#b91c1c">시간축 (균등)</text>
<line x1="40" y1="60" x2="500" y2="60" stroke="#cbd5e1" stroke-width="3"/>
<g fill="#64748b"><circle cx="40" cy="60" r="4"/><circle cx="132" cy="60" r="4"/><circle cx="224" cy="60" r="4"/><circle cx="316" cy="60" r="4"/><circle cx="408" cy="60" r="4"/><circle cx="500" cy="60" r="4"/></g>
<g font-size="10" fill="#64748b"><text x="40" y="78">0s</text><text x="128" y="78">1s</text><text x="220" y="78">2s</text><text x="312" y="78">3s</text><text x="404" y="78">4s</text><text x="496" y="78">5s</text></g>
<!-- hover -->
<rect x="224" y="100" width="120" height="50" rx="8" fill="#fee2e2" stroke="#dc2626"/>
<text x="284" y="120" text-anchor="middle" font-size="11" fill="#b91c1c">드론 호버(정지)</text>
<text x="284" y="138" text-anchor="middle" font-size="10" fill="#b91c1c">시간은 가는데 위치는 안 변함</text>
<text x="560" y="125" font-size="11" fill="#b91c1c">→ 시간축에선</text>
<text x="560" y="142" font-size="11" fill="#b91c1c">구분 불가 ✗</text>
</svg>
<figcaption>그림 11. 시간 균등축에선 "공중 대기(호버)" 구간을 표현할 수 없다.</figcaption>
</figure>
➡ 그래서 **측점(공간)축**으로 재해석: 드론이 호버하면 측점도 멈춰 있어야 자연스럽다.
---
### 7-2. 구현 ① — GPS를 측점값으로 투영
드론 GPS를 측점 폴리라인(선로)에 **수직으로 내려** "측점값(km)"과 "선로 이격(m)"을 구합니다.
<figure class="fig">
<svg viewBox="0 0 680 190" role="img" aria-label="GPS를 측점선에 투영">
<defs><marker id="m72" markerWidth="9" markerHeight="9" refX="6" refY="3" orient="auto"><path d="M0,0 L6,3 L0,6 Z" fill="#f59e0b"/></marker></defs>
<!-- chain line -->
<polyline points="60,130 200,122 360,118 520,124 630,130" fill="none" stroke="#0891b2" stroke-width="5"/>
<g fill="#155e75" font-size="10">
<circle cx="60" cy="130" r="5" fill="#0891b2"/><text x="40" y="152">157K900</text>
<circle cx="360" cy="118" r="5" fill="#0891b2"/><text x="335" y="108">158K000</text></g>
<text x="70" y="118" font-size="11" fill="#155e75">측점 폴리라인(선로)</text>
<!-- drone -->
<circle cx="280" cy="55" r="9" fill="#7c3aed"/><text x="295" y="52" font-size="12" font-weight="bold" fill="#5b21b6">🚁 드론 GPS</text>
<!-- perpendicular -->
<line x1="280" y1="64" x2="280" y2="118" stroke="#f59e0b" stroke-width="2" stroke-dasharray="5 4" marker-end="url(#m72)"/>
<circle cx="280" cy="120" r="6" fill="#b45309"/>
<text x="290" y="146" font-size="12" font-weight="bold" fill="#b45309">km=157K958, 이격 4.2m</text>
<text x="200" y="92" font-size="10" fill="#9a3412">projectToChain (수직 내림)</text>
</svg>
<figcaption>그림 12. 측점명 "157K970" → 157970m 환산으로 선로를 만들고, GPS를 수직 투영해 측점값·이격 산출.</figcaption>
</figure>
```ts
// chainage.ts projectToChain (3550)
const t = clamp(((px-a.x)*dx + (py-a.y)*dy) / L2, 0, 1); // 선분 위 투영 비율
bestKm = a.km + (b.km - a.km) * t; // 보간 측점값
```
---
### 7-3. 구현 ② — 이동거리축 (핵심 아이디어)
시간축 대신 **드론이 실제로 이동한 거리**를 축으로 씁니다. 측점값 변화량 |Δ|을 매 프레임 누적.
<figure class="fig">
<svg viewBox="0 0 680 200" role="img" aria-label="이동거리축">
<!-- timeline of values -->
<text x="40" y="32" font-size="11" fill="#334155">측점값(평활): 920 → 930 → 940 → <tspan fill="#b91c1c">940 940 940</tspan> → 950 → 960</text>
<!-- accumulation bar -->
<text x="40" y="72" font-size="11" font-weight="bold" fill="#155e75">누적 이동거리 = 축 위치</text>
<rect x="40" y="84" width="600" height="26" rx="13" fill="#e5e7eb"/>
<!-- run 1 -->
<rect x="40" y="84" width="180" height="26" rx="13" fill="#67e8f9"/>
<!-- hover (no growth) -->
<rect x="220" y="84" width="0" height="26" fill="#fca5a5"/>
<!-- run 2 -->
<rect x="220" y="84" width="180" height="26" fill="#22d3ee"/>
<circle cx="220" cy="97" r="11" fill="#0891b2"/>
<text x="220" y="101" text-anchor="middle" font-size="10" fill="#fff">⏸</text>
<text x="130" y="135" text-anchor="middle" font-size="10" fill="#155e75">정상 주행 →→→</text>
<text x="232" y="135" font-size="10" fill="#b91c1c">호버: 누적 멈춤 → 커서 정지!</text>
<text x="540" y="135" text-anchor="middle" font-size="10" fill="#155e75">→→→ 주행</text>
<text x="340" y="172" text-anchor="middle" font-size="11" fill="#555">호버 구간에서 커서가 멈춰 "공중 대기"가 한눈에 보인다</text>
</svg>
<figcaption>그림 13. |Δ측점값|을 누적해 0~1로 정규화 → 축 위치. 호버는 누적이 멈추므로 커서도 정지.</figcaption>
</figure>
```ts
// StationBar.tsx 233274
sm[i] = (pre[hi]-pre[lo])/(hi-lo); // 측점값 ±8프레임 평활
if (i>0) cum += Math.abs(sm[i]-sm[i-1]); // |Δ| 누적 = 실제 이동거리
frac[i] = cum / total; // 0~1 정규화 → 축 위치
```
---
### 7-4. 측점 기반의 결과 — 하단 측점바
<figure class="fig">
<svg viewBox="0 0 680 170" role="img" aria-label="하단 측점바">
<rect x="20" y="30" width="640" height="110" rx="10" fill="#0f172a" stroke="#334155"/>
<!-- track -->
<line x1="60" y1="95" x2="520" y2="95" stroke="#22d3ee" stroke-width="5"/>
<line x1="520" y1="95" x2="620" y2="95" stroke="#475569" stroke-width="5" stroke-dasharray="2 4"/>
<!-- markers -->
<g font-size="9" fill="#e2e8f0" text-anchor="middle">
<circle cx="60" cy="95" r="7" fill="#fbbf24"/><text x="60" y="72">🚉역</text>
<circle cx="170" cy="95" r="6" fill="#f59e0b"/><text x="170" y="72">🌉교량</text>
<circle cx="300" cy="95" r="6" fill="#38bdf8"/><text x="300" y="72">🚇터널</text>
<circle cx="460" cy="95" r="6" fill="#f59e0b"/><text x="460" y="72">🌉구교</text>
<circle cx="620" cy="95" r="7" fill="none" stroke="#94a3b8" stroke-width="2"/><text x="620" y="72">◌종점</text></g>
<!-- cursor -->
<polygon points="220,80 212,68 228,68" fill="#fff"/>
<line x1="220" y1="80" x2="220" y2="110" stroke="#fff" stroke-width="2"/>
<text x="220" y="128" text-anchor="middle" font-size="10" fill="#fff">현재 위치 커서 (60fps)</text>
<text x="572" y="118" font-size="9" fill="#94a3b8">회색=미도착</text>
</svg>
<figcaption>그림 14. 측점·구조물을 공간축에 배치. 영상별 FPS 자동산출로 배지 정밀화 · 종점 미도착은 회색 + 빈 링.</figcaption>
</figure>
---
### 7-5. 프레임 ↔ 측점 — 한 장 비교
| 구분 | 프레임 기반 | 측점 기반 (현재) |
|------|-------------|------------------|
| 축 | 시간(초) | 실제 이동거리/측점(km) |
| 호버 | 구분 불가 | 커서 정지로 가시화 |
| 현장 용어 | "3분 20초" | "157K970 교량" |
| 위치 질의 | 어려움 | GPS→측점 투영으로 즉답 |
| 동기화 | currentTime | smoothTimeRef + 측점투영 |
➡ **시간축 영상을 공간축(측점)으로 재해석**한 것이 이 프로그램의 정체성.
---
## 8. 정확도 보정 도구
투영은 카메라 파라미터·표고 가정에 민감 → **사용자가 미세보정**할 수 있게 함. 핵심은 투영의 **역방향**(화면→세계)을 푸는 것.
<figure class="fig">
<svg viewBox="0 0 680 210" role="img" aria-label="보정 도구">
<defs><marker id="m8" markerWidth="10" markerHeight="10" refX="7" refY="3.5" orient="auto"><path d="M0,0 L7,3.5 L0,7 Z" fill="#7c3aed"/></marker></defs>
<!-- screen -->
<rect x="40" y="40" width="220" height="130" rx="6" fill="#0b1020" stroke="#334155"/>
<text x="150" y="32" text-anchor="middle" font-size="11" fill="#555">영상 화면</text>
<circle cx="120" cy="80" r="6" fill="#f59e0b"/>
<text x="60" y="105" font-size="10" fill="#fde68a">라벨을 끌어 제자리로</text>
<!-- ground -->
<line x1="320" y1="175" x2="650" y2="175" stroke="#8b5e34" stroke-width="3"/>
<text x="320" y="193" font-size="10" fill="#7c5a3a">실제 땅</text>
<line x1="126" y1="82" x2="540" y2="170" stroke="#7c3aed" stroke-width="2" stroke-dasharray="5 4" marker-end="url(#m8)"/>
<circle cx="540" cy="170" r="6" fill="#7c3aed"/>
<text x="470" y="160" font-size="11" font-weight="bold" fill="#6d28d9">위경도 역산</text>
<text x="300" y="60" font-size="11" fill="#6d28d9">화면점 → 광선 → 지면 교차 → 실제 위치</text>
<text x="300" y="80" font-size="10" fill="#6d28d9">groundPointFromPixel</text>
</svg>
<figcaption>그림 15. "끌어다 맞추기" = 역투영. 끈 화면점을 광선으로 쏴 지면과 만나는 실제 좌표를 복원.</figcaption>
</figure>
| 도구 | 동작 |
|---|---|
| ① **DEM 자동표고** | 모든 POI를 실제 지형고도로 (서버 `/api/elevation`, SRTM 30m) |
| ② **드래그 편집** | 라벨을 끌면 `groundPointFromPixel`로 위경도 역산 |
| ③ **세로화각 보정** | POI를 실제 위치로 끌면 `sensorH`만 역산 자동보정 |
| ④ **보정값 저장** | `poi_overrides.json` 내보내기/가져오기 |
---
## 9. 정리 — 무엇을, 어떻게
<figure class="fig">
<svg viewBox="0 0 680 260" role="img" aria-label="전체 정리">
<defs><marker id="m9" markerWidth="10" markerHeight="10" refX="7" refY="3.5" orient="auto"><path d="M0,0 L7,3.5 L0,7 Z" fill="#64748b"/></marker></defs>
<!-- inputs -->
<g text-anchor="middle">
<rect x="20" y="40" width="120" height="34" rx="8" fill="#f5f3ff" stroke="#7c3aed"/><text x="80" y="62" font-size="11" fill="#5b21b6">드론 GPS</text>
<rect x="20" y="100" width="120" height="34" rx="8" fill="#eff6ff" stroke="#2563eb"/><text x="80" y="122" font-size="11" fill="#1e3a8a">영상</text>
<rect x="20" y="160" width="120" height="34" rx="8" fill="#ecfdf5" stroke="#16a34a"/><text x="80" y="182" font-size="11" fill="#14532d">POI 좌표</text>
</g>
<!-- stages -->
<g text-anchor="middle">
<rect x="200" y="40" width="130" height="60" rx="8" fill="#ecfdf5" stroke="#16a34a"/><text x="265" y="64" font-size="11" font-weight="bold" fill="#14532d">🟩 시간 동기화</text><text x="265" y="82" font-size="9" fill="#166534">smoothTimeRef</text><text x="265" y="95" font-size="9" fill="#166534">60fps 보간</text>
<rect x="200" y="110" width="130" height="74" rx="8" fill="#eff6ff" stroke="#2563eb"/><text x="265" y="132" font-size="11" font-weight="bold" fill="#1e3a8a">🟦 좌표 투영</text><text x="265" y="150" font-size="9" fill="#1e40af">ENU→카메라</text><text x="265" y="163" font-size="9" fill="#1e40af">→핀홀→cover</text><text x="265" y="176" font-size="9" fill="#1e40af">+geoid 보정</text>
<rect x="380" y="75" width="120" height="60" rx="8" fill="#fff7ed" stroke="#d97706"/><text x="440" y="99" font-size="11" font-weight="bold" fill="#9a3412">🟧 평활</text><text x="440" y="117" font-size="9" fill="#9a3412">One Euro +</text><text x="440" y="129" font-size="9" fill="#9a3412">이상치 거부</text>
<rect x="540" y="75" width="120" height="60" rx="8" fill="#fff" stroke="#23272e" stroke-width="1.5"/><text x="600" y="99" font-size="11" font-weight="bold" fill="#23272e">영상 위</text><text x="600" y="117" font-size="11" font-weight="bold" fill="#23272e">정확한 라벨✨</text>
<rect x="380" y="170" width="280" height="40" rx="8" fill="#ecfeff" stroke="#0891b2"/><text x="520" y="188" font-size="11" font-weight="bold" fill="#155e75">🟦 GPS→측점 투영 → 이동거리축</text><text x="520" y="202" font-size="9" fill="#155e75">→ 측점 기반 측점바 (호버까지 표현)</text>
</g>
<!-- arrows -->
<line x1="140" y1="70" x2="198" y2="70" stroke="#64748b" stroke-width="1.4" marker-end="url(#m9)"/>
<line x1="140" y1="117" x2="198" y2="135" stroke="#64748b" stroke-width="1.4" marker-end="url(#m9)"/>
<line x1="140" y1="177" x2="198" y2="155" stroke="#64748b" stroke-width="1.4" marker-end="url(#m9)"/>
<line x1="330" y1="120" x2="378" y2="105" stroke="#64748b" stroke-width="1.4" marker-end="url(#m9)"/>
<line x1="500" y1="105" x2="538" y2="105" stroke="#64748b" stroke-width="1.4" marker-end="url(#m9)"/>
<line x1="265" y1="184" x2="265" y2="195" stroke="#0891b2" stroke-width="1.4"/>
<line x1="265" y1="195" x2="378" y2="190" stroke="#0891b2" stroke-width="1.4" marker-end="url(#m9)"/>
</svg>
<figcaption>그림 16. 3대 정합(공간·시간·안정) + 패러다임 전환(프레임→측점).</figcaption>
</figure>
**3대 정합 기술**
1. **🟦 공간**: 4단계 투영(ENU→카메라회전→핀홀→cover) + 지오이드 보정 + 역투영 보정
2. **🟩 시간**: smoothTimeRef 60fps 단조보간으로 영상시각↔드론프레임 정합
3. **🟧 안정**: One Euro 속도적응 평활 + 이상치 거부
**패러다임 전환**
- 프레임(시간) 기반 → **측점(공간) 기반**: GPS를 선로에 투영해 측점값화, 이동거리축으로 호버까지 표현
---
## 부록 — 핵심 소스 색인
| 기술 | 파일 | 라인 |
|------|------|------|
| ENU 변환 | geoProjection.ts | 9197 |
| 카메라 좌표 + fwd/side | geoProjection.ts | 301317 |
| 핀홀 투영 | geoProjection.ts | 126137 |
| 지오이드 보정 | geoProjection.ts | 5367, projectPoint |
| 역투영(보정) | geoProjection.ts | 149251 |
| object-fit cover 정렬 | StationOverlay.tsx | 921930 |
| 라벨 평활/이상치 | StationOverlay.tsx | 4861 |
| RAF 60fps 렌더 | StationOverlay.tsx | 9051198 |
| smoothTimeRef | VideoPlayer.tsx | 62117 |
| 측점 투영(chainage) | chainage.ts | 1064 |
| 이동거리축 | StationBar.tsx | 233274 |
| 종점역 미도착 | StationBar.tsx | 300310, 574586 |
| DEM 표고 | StationOverlay.tsx / elevation.ts | 13841415 / 1467 |
> 상세 구현은 [구현상세_GhiVideo_기술-소스코드매칭.md](구현상세_GhiVideo_기술-소스코드매칭.md) 참조.