feat: 지도 나침반(OSM/위성) 추가 + 측점 검색·마커 정확도 개선 + POI/영상 수정

- 지도 기반 나침반(노스업, 현재위치, 시야 역삼각형): 호버 확대·휠 줌·클릭 위성전환, OSM/Esri 타일 서버 프록시(/api/tile)
- 스테이션 검색: 실제 측점(chain) 기준 이동, 없으면 '측점 없음' 안내
- 역 마커: 직교 투영 측점 일치 시에만 표시, 미도착 종점은 추가 방식
- POI 팝업 겹침/재등장·라벨 정합 수정
- 영상 fps 데이터 기반 자동 산출
- 기술/발표/쉬운설명 문서 추가

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-02 18:03:30 +09:00
co-authored by Claude Opus 4.8
parent e9052143d7
commit e0ff5dd6d0
58 changed files with 9635 additions and 378 deletions
@@ -0,0 +1,634 @@
# 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) 참조.