docs: 발표·특허·AI영상요약 자료 및 작업기록 추가

- 발표자료(좌표투영·측점기반재생) 갱신 + 그림/PDF
- 특허 출원 초안 및 도면
- AI 영상요약 서비스 작업기록(docs/history)
- 관련 scripts 추가

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-09 11:20:03 +09:00
co-authored by Claude Opus 4.8
parent 5c8b499979
commit fe25ce00f1
78 changed files with 6186 additions and 15 deletions
@@ -0,0 +1,309 @@
# KMZ 파일에서 영상 위 이름표까지 — 전 과정 그림 설명서
> 이 글은 **초등학생도 이해할 수 있게** 그림과 이야기로 쓴 설명서예요.
> "지도 파일(KMZ) 하나가 어떤 여행을 거쳐, 드론 영상 속 **정확한 자리**에 다리·터널 이름표로 나타날까?"
> 그 여정을 **8단계**로 하나씩 따라가 봅니다. (파일·줄 번호는 2026-07-03 기준)
---
## 한눈에 보는 전체 여정
KMZ는 "지도 위 핀들을 모아 둔 **선물 상자**"예요. 이 상자가 열려서, 안의 핀 하나하나가
영상 화면의 정확한 픽셀에 이름표로 붙기까지 — 전체 흐름은 이렇습니다.
<figure>
<svg viewBox="0 0 760 210" role="img" aria-label="전체 파이프라인">
<defs><marker id="f1a" markerWidth="9" markerHeight="9" refX="6" refY="3" orient="auto"><path d="M0,0 L6,3 L0,6 Z" fill="#14305e"/></marker></defs>
<!-- 1행: KMZ 처리 -->
<rect x="8" y="18" width="108" height="52" rx="6" fill="#fff7e6" stroke="#b45309"/>
<text x="62" y="40" text-anchor="middle" font-size="11" font-weight="bold" fill="#b45309">① KMZ 파일</text>
<text x="62" y="56" text-anchor="middle" font-size="9" fill="#7c2d12">(압축 선물상자)</text>
<rect x="146" y="18" width="108" height="52" rx="6" fill="#fff" stroke="#555"/>
<text x="200" y="40" text-anchor="middle" font-size="11" font-weight="bold">② 압축 풀기</text>
<text x="200" y="56" text-anchor="middle" font-size="9" fill="#555">doc.kml 꺼내기</text>
<rect x="284" y="18" width="108" height="52" rx="6" fill="#fff" stroke="#555"/>
<text x="338" y="40" text-anchor="middle" font-size="11" font-weight="bold">③ KML 읽기</text>
<text x="338" y="56" text-anchor="middle" font-size="9" fill="#555">폴더→핀(Placemark)</text>
<rect x="422" y="18" width="130" height="52" rx="6" fill="#eef2f8" stroke="#14305e"/>
<text x="487" y="40" text-anchor="middle" font-size="11" font-weight="bold" fill="#14305e">④ 목록 완성</text>
<text x="487" y="56" text-anchor="middle" font-size="9" fill="#555">POI·구조물 (이름+위경도)</text>
<line x1="116" y1="44" x2="144" y2="44" stroke="#14305e" stroke-width="1.5" marker-end="url(#f1a)"/>
<line x1="254" y1="44" x2="282" y2="44" stroke="#14305e" stroke-width="1.5" marker-end="url(#f1a)"/>
<line x1="392" y1="44" x2="420" y2="44" stroke="#14305e" stroke-width="1.5" marker-end="url(#f1a)"/>
<!-- 보조입력 -->
<rect x="8" y="120" width="150" height="40" rx="6" fill="#f0fdf4" stroke="#16a34a"/>
<text x="83" y="137" text-anchor="middle" font-size="10" font-weight="bold" fill="#14532d">측점 CSV (실측 표고)</text>
<text x="83" y="152" text-anchor="middle" font-size="9" fill="#555">⑤ 높이 결정에 사용</text>
<rect x="178" y="120" width="160" height="40" rx="6" fill="#f0fdf4" stroke="#16a34a"/>
<text x="258" y="137" text-anchor="middle" font-size="10" font-weight="bold" fill="#14532d">드론 비행로그 (SRT/CSV)</text>
<text x="258" y="152" text-anchor="middle" font-size="9" fill="#555">위치·자세(yaw·pitch·roll)·초점</text>
<!-- 2행: 계산 -->
<rect x="368" y="114" width="170" height="52" rx="6" fill="#fff7e6" stroke="#b45309"/>
<text x="453" y="136" text-anchor="middle" font-size="11" font-weight="bold" fill="#b45309">⑥⑦ 좌표변환 + 투영</text>
<text x="453" y="152" text-anchor="middle" font-size="9" fill="#7c2d12">위경도→미터→카메라→화면</text>
<rect x="568" y="114" width="184" height="52" rx="6" fill="#fef2f2" stroke="#dc2626"/>
<text x="660" y="136" text-anchor="middle" font-size="11" font-weight="bold" fill="#991b1b">⑧ 영상 위 이름표</text>
<text x="660" y="152" text-anchor="middle" font-size="9" fill="#555">60fps 정합 + 떨림 평활</text>
<line x1="487" y1="70" x2="470" y2="112" stroke="#14305e" stroke-width="1.5" marker-end="url(#f1a)"/>
<line x1="158" y1="140" x2="366" y2="140" stroke="#16a34a" stroke-width="1.2" marker-end="url(#f1a)"/>
<line x1="338" y1="140" x2="366" y2="140" stroke="#16a34a" stroke-width="1.2"/>
<line x1="538" y1="140" x2="566" y2="140" stroke="#14305e" stroke-width="1.5" marker-end="url(#f1a)"/>
<text x="380" y="196" text-anchor="middle" font-size="9" fill="#888">— KMZ의 핀(④) + 측점 표고(⑤) + 드론 포즈를 합쳐 매 프레임 계산(⑥⑦) → 화면(⑧) —</text>
</svg>
<figcaption>그림 1. KMZ → 압축해제 → 파싱 → 목록 → (표고·드론 포즈 결합) → 좌표변환·투영 → 영상 표출</figcaption>
</figure>
---
## 1단계 — KMZ는 '압축된 선물 상자'
- **KMZ = KML을 ZIP으로 압축한 것**이에요. 상자(KMZ)를 열면 안에 설명서(doc.kml)가 들어 있어요.
- 사용자가 폴더를 선택하면, 프로그램은 폴더 안에서 `.kml`(맨 설명서)이 있으면 그걸 먼저 쓰고,
없으면 `.kmz`(상자)를 찾아 엽니다.
- 압축 풀기는 브라우저 안에서 `fflate` 라이브러리의 `unzipSync` 로 처리해요 — **서버에 보내지 않고 내 컴퓨터에서 바로** 풉니다.
> 코드: [geoData.ts:295](../client/src/utils/geoData.ts#L295) `parseKmz()` — `.kml` 우선, 없으면 `.kmz`를 `unzipSync`로 해제해 `doc.kml`을 꺼냄. 실패하면 경고 후 건너뜀.
## 2단계 — 설명서(KML) 속은 '폴더 나무 + 핀'
KML은 XML이라는 형식의 글이에요. 그 안은 **폴더 나무** 모양이고, 잎사귀마다 **핀(Placemark)** 이 달려 있어요.
<figure>
<svg viewBox="0 0 760 240" role="img" aria-label="KMZ 내부 구조와 핀 해부도">
<!-- 좌: 폴더 트리 -->
<rect x="8" y="10" width="300" height="220" rx="8" fill="#fdf8ef" stroke="#b45309"/>
<text x="158" y="32" text-anchor="middle" font-size="11.5" font-weight="bold" fill="#b45309">KMZ 상자 속 doc.kml (폴더 나무)</text>
<text x="30" y="58" font-size="10.5" font-weight="bold" fill="#333">📁 Document</text>
<text x="52" y="80" font-size="10.5" fill="#333">📁 02)지장물 — 📍📍📍 (POI)</text>
<text x="52" y="102" font-size="10.5" fill="#333">📁 03)교량 — 📍📍 (구조물)</text>
<text x="52" y="124" font-size="10.5" fill="#333">📁 04)터널 — 📍📍 (구조물)</text>
<text x="52" y="146" font-size="10.5" fill="#333">📁 05)출입문번호 — 📍 (POI)</text>
<text x="52" y="168" font-size="10.5" fill="#333">📁 06)구교 — 📍 (구조물)</text>
<text x="52" y="190" font-size="10.5" fill="#333">📍 철도역(KAKAO_RAIL) → POI+역사</text>
<text x="158" y="216" text-anchor="middle" font-size="9" fill="#888">폴더 이름을 보고 핀의 '종류'를 결정</text>
<!-- 우: Placemark 해부 -->
<rect x="340" y="10" width="412" height="220" rx="8" fill="#eef2f8" stroke="#14305e"/>
<text x="546" y="32" text-anchor="middle" font-size="11.5" font-weight="bold" fill="#14305e">핀(Placemark) 하나의 해부도</text>
<rect x="360" y="46" width="372" height="34" rx="4" fill="#fff" stroke="#94a3b8"/>
<text x="374" y="67" font-size="10.5" fill="#333"><tspan font-weight="bold">&lt;name&gt;</tspan> 대전천교 — 핀의 이름</text>
<rect x="360" y="88" width="372" height="62" rx="4" fill="#fff" stroke="#94a3b8"/>
<text x="374" y="107" font-size="10.5" fill="#333"><tspan font-weight="bold">&lt;description&gt;</tspan> 속성표(HTML 표)</text>
<text x="388" y="124" font-size="9.5" fill="#555">구분=대전천교 · 연장(m)=124.5 · 시설종별=2종 …</text>
<text x="388" y="140" font-size="9.5" fill="#555">→ 표의 (키, 값)을 한 줄씩 뽑아 저장</text>
<rect x="360" y="158" width="372" height="34" rx="4" fill="#fff" stroke="#94a3b8"/>
<text x="374" y="179" font-size="10.5" fill="#333"><tspan font-weight="bold">&lt;coordinates&gt;</tspan> 127.41, 36.33, 12 ← 경도,위도[,고도]</text>
<text x="546" y="214" text-anchor="middle" font-size="9" fill="#888">좌표가 없으면 속성표의 lat/lon 사용 · 한국 범위(위도33~39, 경도124~132) 밖이면 버림</text>
</svg>
<figcaption>그림 2. (왼쪽) 폴더 나무를 따라 내려가며 핀 수집 · (오른쪽) 핀 1개에서 이름·속성표·좌표를 추출</figcaption>
</figure>
프로그램은 나무를 **재귀적으로**(가지 끝까지) 걸어 내려가면서, 각 핀이 **어느 폴더에 속했는지**를 함께 기억해요.
> 코드: [geoData.ts:376](../client/src/utils/geoData.ts#L376) `walk()` — 폴더 트리 재귀. [geoData.ts:276](../client/src/utils/geoData.ts#L276) `parseKmlDescProps()` — 속성표(HTML `<tr><td>`) 파싱. 구글어스 재저장본의 태그 변형(`<tbody>`, `&gt;` 엔티티 등)도 견고하게 처리.
## 3단계 — 핀을 종류별로 나누기
폴더 이름이 곧 분류 기준이에요. 학교에서 반 나누듯이:
| 폴더 | 분류 | 어디에 표시되나 |
|------|------|----------------|
| 03)교량 · 04)터널 · 06)구교 | **구조물** (bridge/tunnel) | 영상 라벨 + 하단 스테이션바 마커 |
| 02)지장물 · 05)출입문 | **POI** (지점) | 영상 라벨 |
| 철도역 (KAKAO_RAIL) | POI **+ 역사 구조물** | 영상 라벨 + 스테이션바 **양쪽** |
- 이때 속성표에서 **연장(m)**, **시설종별(1종/2종/3종)** 같은 값도 같이 담아요 →
나중에 시설등급 필터(1·2종만 보기)와 팝업 정보에 쓰입니다.
- 결과: `pois[]`(지점 목록) + `structures[]`(구조물 목록). **KMZ가 이 데이터의 유일한 원본**이라, 없으면 "데이터 누락" 경고를 띄워요.
> 코드: [geoData.ts:331](../client/src/utils/geoData.ts#L331) `handlePlacemark()` — 폴더명 분기. [geoData.ts:539](../client/src/utils/geoData.ts#L539) `loadGeoData` — KMZ 누락 시 `kmzMissing` 경고.
## 4단계 — 높이(표고) 정하기: "핀은 몇 m 높이에 있나?"
KMZ의 핀은 **평면 위치(위도·경도)는 정확**하지만, **높이(z)는 없거나 부정확**한 경우가 많아요.
높이가 틀리면 이름표가 하늘에 뜨거나 땅에 파묻혀 보여요. 그래서 이렇게 정합니다.
<figure>
<svg viewBox="0 0 760 200" role="img" aria-label="표고 결정과 datum 정합">
<!-- 땅/지오이드/타원체 -->
<path d="M20 150 Q 200 120 380 140 T 740 130" fill="none" stroke="#8b5e34" stroke-width="3"/>
<text x="60" y="140" font-size="10" fill="#8b5e34" font-weight="bold">땅(선로)</text>
<line x1="20" y1="178" x2="740" y2="178" stroke="#0ea5e9" stroke-width="1.5" stroke-dasharray="6 3"/>
<text x="76" y="192" font-size="10" fill="#0369a1">바다 기준면(지오이드) — '정표고 0m'</text>
<!-- POI -->
<circle cx="420" cy="136" r="5" fill="#dc2626"/>
<text x="420" y="124" text-anchor="middle" font-size="10" font-weight="bold" fill="#991b1b">POI(교량)</text>
<line x1="420" y1="141" x2="420" y2="178" stroke="#dc2626" stroke-width="1.2" stroke-dasharray="3 2"/>
<text x="452" y="166" font-size="9.5" fill="#991b1b">정표고 z = 측점 표고</text>
<!-- 드론 -->
<text x="560" y="48" text-anchor="middle" font-size="18">🛸</text>
<line x1="560" y1="58" x2="560" y2="130" stroke="#555" stroke-width="1" stroke-dasharray="3 2"/>
<text x="640" y="70" font-size="9.5" fill="#555">드론 고도 = '타원체고' 기준</text>
<text x="640" y="84" font-size="9.5" fill="#b45309" font-weight="bold">→ 기준이 서로 다름!</text>
<rect x="60" y="20" width="360" height="52" rx="6" fill="#fff7e6" stroke="#b45309"/>
<text x="240" y="41" text-anchor="middle" font-size="10.5" font-weight="bold" fill="#b45309">높이 기준 맞추기(datum 정합)</text>
<text x="240" y="58" text-anchor="middle" font-size="10" fill="#7c2d12">타원체고 = 정표고 + 지오이드고(대전 ≈ 25.8m)</text>
</svg>
<figcaption>그림 3. 핀의 높이는 <b>가장 가까운 측점의 실측 표고</b>를 빌려 쓰고, 드론 고도와 기준을 맞추기 위해 지오이드고(+25.8m)를 더한다</figcaption>
</figure>
높이 결정 우선순위 (셋 중 위에서부터):
1. **드래그 보정값** — 사용자가 화면에서 직접 고친 값 (5단계 뒤 '보너스' 참고)
2. **DEM 자동 조회값** — open-meteo API로 받은 그 지점 지면고도 (선택 기능)
3. **최근접 선로(측점) 표고** — 기본값. 측점 CSV의 **실측 정표고**를 빌려 씀
이 "측점 표고 빌려 쓰기" 덕분에 **비싼 지형데이터(DEM) 없이도** 하향각 오차가 42.7°→11.0°로 줄었어요.
> 코드: [StationOverlay.tsx:767](../client/src/components/overlay/StationOverlay.tsx#L767) — 지면고도 gz 결정. [geoProjection.ts](../client/src/utils/geoProjection.ts) `geoidOffset` — datum 정합.
## 5단계 — 좌표 바꾸기: 주소(위경도)를 '미터 자'로
위도·경도는 **둥근 지구 위의 주소**라서, 그대로는 "몇 m 옆에 있나"를 계산하기 어려워요.
그래서 3번 갈아탑니다.
<figure>
<svg viewBox="0 0 760 150" role="img" aria-label="좌표 3단 변환">
<defs><marker id="f4a" markerWidth="9" markerHeight="9" refX="6" refY="3" orient="auto"><path d="M0,0 L6,3 L0,6 Z" fill="#14305e"/></marker></defs>
<rect x="8" y="35" width="160" height="72" rx="8" fill="#eef2f8" stroke="#14305e"/>
<text x="88" y="60" text-anchor="middle" font-size="11" font-weight="bold" fill="#14305e">🌍 위도·경도 (도)</text>
<text x="88" y="78" text-anchor="middle" font-size="9.5" fill="#555">36.33°N, 127.41°E</text>
<text x="88" y="94" text-anchor="middle" font-size="9" fill="#888">둥근 지구 주소</text>
<rect x="216" y="35" width="170" height="72" rx="8" fill="#fff" stroke="#555"/>
<text x="301" y="60" text-anchor="middle" font-size="11" font-weight="bold">🗺 평면 지도 좌표 (m)</text>
<text x="301" y="78" text-anchor="middle" font-size="9.5" fill="#555">EPSG:5186 한국 TM</text>
<text x="301" y="94" text-anchor="middle" font-size="9" fill="#888">동쪽 x m · 북쪽 y m</text>
<rect x="434" y="35" width="150" height="72" rx="8" fill="#fff" stroke="#555"/>
<text x="509" y="60" text-anchor="middle" font-size="11" font-weight="bold">🛸 드론 기준 (m)</text>
<text x="509" y="78" text-anchor="middle" font-size="9.5" fill="#555">동·북·위 상대거리</text>
<text x="509" y="94" text-anchor="middle" font-size="9" fill="#888">"드론에서 동쪽 40m…"</text>
<rect x="632" y="35" width="120" height="72" rx="8" fill="#fff7e6" stroke="#b45309"/>
<text x="692" y="60" text-anchor="middle" font-size="11" font-weight="bold" fill="#b45309">📷 카메라 눈</text>
<text x="692" y="78" text-anchor="middle" font-size="9.5" fill="#7c2d12">(Xc, Yc, Zc)</text>
<text x="692" y="94" text-anchor="middle" font-size="9" fill="#888">6단계에서</text>
<line x1="168" y1="71" x2="214" y2="71" stroke="#14305e" stroke-width="1.5" marker-end="url(#f4a)"/>
<line x1="386" y1="71" x2="432" y2="71" stroke="#14305e" stroke-width="1.5" marker-end="url(#f4a)"/>
<line x1="584" y1="71" x2="630" y2="71" stroke="#14305e" stroke-width="1.5" marker-end="url(#f4a)"/>
<text x="191" y="60" text-anchor="middle" font-size="9" fill="#888">proj4</text>
<text x="409" y="60" text-anchor="middle" font-size="9" fill="#888">빼기(−드론)</text>
<text x="607" y="60" text-anchor="middle" font-size="9" fill="#888">회전행렬</text>
</svg>
<figcaption>그림 4. 위경도 → 한국 TM 평면(m) → 드론 기준 상대좌표 → 카메라 좌표, 3번의 갈아타기</figcaption>
</figure>
- **위경도 → TM(m)**: proj4 라이브러리로 한국 표준 평면좌표(EPSG:5186)로 변환. 이제 "미터"로 잴 수 있어요.
- **TM → 드론 기준**: 핀 위치에서 드론 위치를 **빼면**, "드론에서 동쪽 40m, 북쪽 12m, 아래 30m" 같은 상대 위치가 나와요.
> 코드: [geoProjection.ts:75](../client/src/utils/geoProjection.ts#L75) EPSG:5186 정의, [geoProjection.ts:334](../client/src/utils/geoProjection.ts#L334) `projectPoint()` 1부 — ENU 상대좌표 계산.
## 6단계 — 카메라의 눈으로 회전: "드론이 보는 방향은?"
드론이 어느 쪽을 보고 있는지(비행로그의 **yaw·pitch·roll**)에 따라, 같은 핀도 화면 왼쪽에 보일 수도, 오른쪽에 보일 수도 있어요.
그래서 상대좌표를 **회전행렬**로 돌려서 "카메라가 보는 세계"의 좌표 (Xc, Yc, Zc)로 바꿉니다.
```
R = Rz(yaw) × Rx(pitch) × Ry(roll) ← 드론의 고개 돌림·끄덕임·갸웃을 그대로 재현
(Xc, Yc, Zc) = 카메라축변환 × Rᵀ × (동, 북, 위)
```
- **Zc** 는 "카메라 앞쪽으로 몇 m인가"예요. **Zc ≤ 0이면 카메라 뒤** → 그리지 않아요.
- 너무 멀거나(거리 필터) 화각(시야) 밖이어도 걸러냅니다.
> 코드: [geoProjection.ts:301](../client/src/utils/geoProjection.ts#L301) `toCameraCoords()`, 회전 수식 주석은 [geoProjection.ts:319](../client/src/utils/geoProjection.ts#L319)~.
## 7단계 — 핀홀 투영: 3D 세상을 2D 사진으로
카메라는 **바늘구멍 사진기**와 같아요. 3D 점이 구멍(렌즈)을 지나 필름(화면)에 맺힙니다.
<figure>
<svg viewBox="0 0 760 190" role="img" aria-label="핀홀 투영">
<!-- 카메라 -->
<rect x="40" y="70" width="56" height="44" rx="6" fill="#333"/>
<circle cx="96" cy="92" r="7" fill="#111" stroke="#888"/>
<text x="68" y="132" text-anchor="middle" font-size="10" fill="#555">카메라(핀홀)</text>
<!-- 이미지 평면 -->
<rect x="150" y="30" width="10" height="124" fill="#e2e8f0" stroke="#94a3b8"/>
<text x="155" y="170" text-anchor="middle" font-size="10" fill="#555">화면(필름)</text>
<!-- 대상 -->
<circle cx="620" cy="52" r="7" fill="#dc2626"/>
<text x="620" y="38" text-anchor="middle" font-size="10.5" font-weight="bold" fill="#991b1b">교량 핀 (Xc, Yc, Zc)</text>
<!-- 광선 -->
<line x1="620" y1="52" x2="96" y2="92" stroke="#f59e0b" stroke-width="1.6" stroke-dasharray="6 3"/>
<circle cx="155" cy="88" r="4.5" fill="#f59e0b"/>
<text x="182" y="80" font-size="10" fill="#b45309" font-weight="bold">맺힌 자리 = 화면 위치!</text>
<!-- 공식 -->
<rect x="240" y="120" width="480" height="54" rx="6" fill="#fffbeb" stroke="#f59e0b"/>
<text x="480" y="142" text-anchor="middle" font-size="11.5" font-weight="bold" fill="#92400e">가로 위치 = 0.5 + (Xc ÷ Zc) × (초점거리 ÷ 센서폭)</text>
<text x="480" y="162" text-anchor="middle" font-size="11.5" font-weight="bold" fill="#92400e">세로 위치 = 0.5 + (Yc ÷ Zc) × (초점거리 ÷ 센서높이)</text>
</svg>
<figcaption>그림 5. 핀홀 투영 — "멀수록(Zc↑) 가운데로 모이고, 옆에 있을수록(Xc↑) 가장자리로" 라는 원근법 그 자체</figcaption>
</figure>
- 결과는 **0~1 사이의 정규 좌표**예요. (0.5, 0.5)면 화면 정중앙.
- ÷Zc 가 바로 **원근법**이에요 — 멀리 있는 것일수록 화면 가운데 근처로 작게 모여요.
- 초점거리(focal)는 비행로그에서 프레임마다 읽고, 센서 크기는 16:9 영상 기준(36×20.25mm)을 써요. 영상마다 화각이 다르면 **라벨 1개 드래그로 세로 화각을 역산**해 맞출 수도 있어요(FOV 1점 보정).
> 코드: [geoProjection.ts:126](../client/src/utils/geoProjection.ts#L126) `pixelFromCamera()` — 위 공식 그대로.
## 8단계 — 화면에 붙이기: 액자 맞추기 + 떨림 잡기
마지막으로 "0~1 정규 좌표"를 **진짜 화면 픽셀**로 바꿔 이름표를 놓아요. 여기엔 두 가지 마무리가 있어요.
<figure>
<svg viewBox="0 0 760 180" role="img" aria-label="cover 정렬과 평활">
<!-- cover 정렬 -->
<rect x="20" y="24" width="330" height="130" rx="6" fill="#f8fafc" stroke="#94a3b8"/>
<text x="185" y="16" text-anchor="middle" font-size="10.5" font-weight="bold" fill="#333">① 액자 맞추기 (object-fit: cover)</text>
<rect x="45" y="44" width="280" height="90" fill="#e0f2fe" stroke="#0284c7" stroke-dasharray="5 3"/>
<rect x="20" y="44" width="330" height="90" fill="none" stroke="#dc2626" stroke-width="1.6"/>
<text x="185" y="76" text-anchor="middle" font-size="9.5" fill="#0369a1">플레이어 창(파랑 점선) 안에</text>
<text x="185" y="92" text-anchor="middle" font-size="9.5" fill="#991b1b">영상(빨강)이 꽉 차게 늘어나 양옆이 잘림</text>
<text x="185" y="112" text-anchor="middle" font-size="9.5" fill="#555">→ 오버레이도 <tspan font-weight="bold">같은 사각형</tspan>으로 계산해야 어긋나지 않음</text>
<text x="185" y="170" text-anchor="middle" font-size="9" fill="#888">정규좌표(0~1) × 영상표시영역 + 잘림offset = 화면 px</text>
<!-- 평활 -->
<rect x="400" y="24" width="340" height="130" rx="6" fill="#f8fafc" stroke="#94a3b8"/>
<text x="570" y="16" text-anchor="middle" font-size="10.5" font-weight="bold" fill="#333">② 떨림 잡기 (속도적응 평활)</text>
<polyline points="420,110 445,70 470,105 495,68 520,108 545,72" fill="none" stroke="#dc2626" stroke-width="1.4"/>
<text x="482" y="130" text-anchor="middle" font-size="9.5" fill="#991b1b">GPS 떨림 그대로 → 라벨이 덜덜덜</text>
<polyline points="575,95 600,88 625,86 650,85 675,84 700,84" fill="none" stroke="#16a34a" stroke-width="2"/>
<text x="640" y="130" text-anchor="middle" font-size="9.5" fill="#14532d">평활 후 → 착 붙음</text>
<text x="570" y="150" text-anchor="middle" font-size="9" fill="#888">떨림(방향 왔다갔다)=강하게 진정 · 실제 이동(방향 일정)=즉시 따라감</text>
</svg>
<figcaption>그림 6. (왼쪽) 영상이 창에 '꽉 차게' 잘려 보이므로 오버레이도 같은 규칙으로 정렬 · (오른쪽) 떨림만 골라 진정시키는 평활</figcaption>
</figure>
1. **액자 맞추기(cover 정렬)** — 영상은 플레이어 창을 비율 유지한 채 **꽉 채우며 잘려** 보여요. 그래서 이름표도 "창 기준"이 아니라 **"잘린 영상 사각형 기준"** 으로 놓아야 정확히 붙어요.
2. **떨림 잡기(평활)** — GPS·자세값은 미세하게 떨려요. "방향이 왔다갔다(=떨림)"면 강하게 진정시키고, "방향이 일정(=실제 이동)"하면 즉시 따라가는 **속도적응 평활**로, 지연 없이 떨림만 없앱니다.
3. 이 계산 전부를 매 화면 프레임(**60fps, requestAnimationFrame**)마다 다시 해요 — 그런데도 빠른 이유는, 무거운 준비(가시성 선별)는 미리 해 두고 프레임마다는 **가벼운 재투영만** 하기 때문이에요.
> 코드: [StationOverlay.tsx:948](../client/src/components/overlay/StationOverlay.tsx#L948) cover 정렬, [StationOverlay.tsx:49](../client/src/components/overlay/StationOverlay.tsx#L49) `smoothStep()` 평활.
---
## 보너스 — 그래도 어긋나면? "드래그 한 번"
지오코딩(주소→좌표 변환) 자체에 오차가 있으면 이름표가 조금 비껴 붙을 수 있어요.
그럴 땐 **이름표를 마우스로 끌어 제자리에 놓기만 하면** 돼요.
- 화면에서 끈 위치(2D)를 7단계 공식의 **역방향**으로 계산해(역투영), 실제 좌표(위경도+높이)를 되찾아 저장해요.
- 2D→3D는 원래 답이 무한히 많지만(깊이를 모르므로), **"카메라와의 거리는 그대로"** 라는 약속을 둬서 드래그 한 번으로 가로·세로·높이가 **동시에** 맞춰집니다.
- 고친 값은 데이터셋별로 저장돼 다음에 열어도 유지돼요. (왕복 검증 오차 ≈ 0)
> 코드: [geoProjection.ts:149](../client/src/utils/geoProjection.ts#L149) `worldFromPixel()` — 역투영.
---
## 단계별 요약표
| 단계 | 하는 일 | 비유 | 코드 위치 |
|:-:|------|------|----------|
| 1 | KMZ 압축 해제 → doc.kml | 선물상자 열기 | geoData.ts `parseKmz` |
| 2 | KML 폴더 나무 걷기, 핀 수집 | 설명서 읽기 | geoData.ts `walk` |
| 3 | 폴더명으로 POI/구조물 분류 | 반 나누기 | geoData.ts `handlePlacemark` |
| 4 | 높이 결정(측점 표고) + 기준 맞춤(+25.8m) | 키 재기, 자 통일 | StationOverlay + geoidOffset |
| 5 | 위경도 → 평면 m → 드론 기준 상대좌표 | 주소를 미터 자로 | geoProjection.ts `projectPoint` |
| 6 | yaw·pitch·roll 회전 → 카메라 좌표 | 카메라 눈으로 보기 | geoProjection.ts `toCameraCoords` |
| 7 | 핀홀 투영 → 화면 정규좌표(0~1) | 바늘구멍 사진기 | geoProjection.ts `pixelFromCamera` |
| 8 | cover 정렬 + 평활 + 60fps 렌더 | 액자 맞추고 떨림 잡기 | StationOverlay.tsx |
| ★ | 어긋나면 드래그 1회 역투영 보정 | 이름표 손으로 옮기기 | geoProjection.ts `worldFromPixel` |
> 더 깊은 수식·구현 근거: 「기술명세_GhiVideo_종합기술문서」 · 「구현상세_드론좌표_영상투영_소스코드매칭」 참조.