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,528 @@
# 드론 좌표 → 영상 픽셀 투영: 기술 ↔ 소스코드 매칭 (구현 상세)
> 발표 자료 [발표_핵심기술_드론좌표를_영상에_맞추기_쉬운설명](발표_핵심기술_드론좌표를_영상에_맞추기_쉬운설명.md) 의
> "쉬운 비유"가 **실제 어떤 코드로 구현**되었는지, 파일·줄 위치와 함께 설명하는 개발자용 문서입니다.
> 핵심 계산은 대부분 [client/src/utils/geoProjection.ts](../client/src/utils/geoProjection.ts) 한 곳에 모여 있고,
> 매 프레임 호출·평활·렌더링은 [client/src/components/overlay/StationOverlay.tsx](../client/src/components/overlay/StationOverlay.tsx) 에 있습니다.
<style>
figure.fig{margin:20px 0;text-align:center;page-break-inside:avoid;break-inside:avoid;}
figure.fig svg{width:100%;max-width:700px;height:auto;border:1px solid #e7e0d2;border-radius:10px;background:#fffdf8;}
figure.fig figcaption{font-size:0.9em;color:#777;margin-top:6px;}
text{font-family:'Malgun Gothic','Hancom Gothic',sans-serif;}
code{background:#f5f3ee;padding:1px 4px;border-radius:4px;}
</style>
---
## 0. 전체 파이프라인 (파일 기준)
<figure class="fig">
<svg viewBox="0 0 700 340" role="img" aria-label="전체 파이프라인">
<defs><marker id="a" markerWidth="11" markerHeight="11" refX="7" refY="3.5" orient="auto"><path d="M0,0 L7,3.5 L0,7 Z" fill="#7c3aed"/></marker></defs>
<rect x="200" y="12" width="300" height="40" rx="8" fill="#f1f5f9" stroke="#475569"/>
<text x="350" y="30" text-anchor="middle" font-size="12" font-weight="bold" fill="#334155">데이터 로드 (폴더 선택)</text>
<text x="350" y="45" text-anchor="middle" font-size="10" fill="#475569">geoData.ts → geoStore (드론 CSV·POI·측점·중심선)</text>
<line x1="350" y1="52" x2="350" y2="70" stroke="#7c3aed" stroke-width="2" marker-end="url(#a)"/>
<rect x="150" y="72" width="400" height="34" rx="8" fill="#eef2ff" stroke="#4f46e5"/>
<text x="350" y="94" text-anchor="middle" font-size="11" font-weight="bold" fill="#3730a3">매 프레임 루프: StationOverlay.tsx draw() @ requestAnimationFrame</text>
<line x1="350" y1="106" x2="350" y2="124" stroke="#7c3aed" stroke-width="2" marker-end="url(#a)"/>
<g font-size="11">
<rect x="120" y="126" width="460" height="30" rx="6" fill="#ecfdf5" stroke="#16a34a"/>
<text x="350" y="146" text-anchor="middle" fill="#166534">① 각도→미터 latLonToTM / geoToEnu (proj4 · EPSG:5186)</text>
<line x1="350" y1="156" x2="350" y2="170" stroke="#7c3aed" stroke-width="1.5" marker-end="url(#a)"/>
<rect x="120" y="172" width="460" height="30" rx="6" fill="#fefce8" stroke="#ca8a04"/>
<text x="350" y="192" text-anchor="middle" fill="#854d0e">② 드론기준 상대위치 buildRelEnu (빼기)</text>
<line x1="350" y1="202" x2="350" y2="216" stroke="#7c3aed" stroke-width="1.5" marker-end="url(#a)"/>
<rect x="120" y="218" width="460" height="30" rx="6" fill="#eff6ff" stroke="#2563eb"/>
<text x="350" y="238" text-anchor="middle" fill="#1e3a8a">③ 방향 회전 buildRotation + applyRw2c (toCameraCoords)</text>
<line x1="350" y1="248" x2="350" y2="262" stroke="#7c3aed" stroke-width="1.5" marker-end="url(#a)"/>
<rect x="120" y="264" width="460" height="30" rx="6" fill="#fdf2f8" stroke="#db2777"/>
<text x="350" y="284" text-anchor="middle" fill="#9d174d">④ 핀홀 투영 pixelFromCamera → (px,py)</text>
<line x1="350" y1="294" x2="350" y2="308" stroke="#7c3aed" stroke-width="1.5" marker-end="url(#a)"/>
<rect x="120" y="310" width="460" height="26" rx="6" fill="#f5f3ff" stroke="#7c3aed"/>
<text x="350" y="327" text-anchor="middle" fill="#5b21b6">⑤⑥ 보간·평활(poseAt/smoothFrame/smoothStep) → ⑦ Canvas 렌더</text>
</g>
</svg>
</figure>
> 아래 표의 **기술**과 본문의 **굵은 용어**들은 문서 맨 아래 **[📖 용어 사전(도움말)](#-용어-사전-도움말)** 에서
> 그림과 함께 자세히 풀어 놓았어요. 모르는 말이 나오면 바로 그 항목을 보면 됩니다. (다른 검색 필요 없음!)
| 단계 | 기술 | 파일 | 핵심 함수 | **하는 일 (쉽게)** |
|---|---|---|---|---|
| ① 위경도→미터 | proj4 · EPSG:5186 | geoProjection.ts | `latLonToTM`, `geoToEnu` | GPS **각도**(위도·경도)를 계산하기 쉬운 **미터 지도**로 바꾼다 |
| ② 드론기준 위치 | ENU 상대좌표 | geoProjection.ts | `buildRelEnu` | 터널−드론 좌표를 **빼서** "드론에서 동/북/위로 몇 m"인지 구한다 |
| ③ 방향 회전 | 회전행렬 | geoProjection.ts | `buildRotation`, `applyRw2c` | 카메라 기울기(**yaw·pitch·roll**)만큼 방향을 **한 번에 돌린다** |
| ④ 화면 투영 | 핀홀 카메라 모델 | geoProjection.ts | `pixelFromCamera` | 3D 방향을 **납작한 화면의 점(가로·세로 %)** 으로 눌러 담는다 |
| ⑤ 사이 채우기 | 보간 | StationOverlay.tsx | `poseAt` | 사진 30장 **사이의 중간 위치**를 상상해 채워 부드럽게 |
| ⑥ 흔들림 제거 | 이동평균 + EMA | StationOverlay.tsx | `smoothFrame`, `smoothStep` | 여러 값을 **평균 내** 드론 떨림·튐을 없앤다 |
| ⑦ 렌더 | RAF + Canvas 2D | StationOverlay.tsx | `draw` | 화면 새로 그릴 때마다(≈60/s) **이름표를 캔버스에 그린다** |
| ⑧ fps 자동 | 프레임수÷길이 | VideoPlayer.tsx | `effectiveFps` | 영상이 **1초에 몇 장**인지 스스로 알아내 ①~⑦의 시간 기준을 맞춘다 |
---
## 1. 위경도(각도) → 미터 지도 (proj4 · EPSG:5186)
**위치:** [geoProjection.ts:74-97](../client/src/utils/geoProjection.ts#L74-L97)
```ts
proj4.defs('EPSG:5186',
'+proj=tmerc +lat_0=38 +lon_0=127 +k=1 +x_0=200000 +y_0=600000 +ellps=GRS80 +units=m +no_defs');
const _toTM = proj4('EPSG:4326', 'EPSG:5186');
function latLonToTM(lat, lon) { // 위경도(각도) → TM(미터)
const [e, n] = _toTM.forward([lon, lat]); // 주의: proj4 는 [lon, lat] 순서
return [e, n];
}
```
- **무엇:** WGS84 위경도(`EPSG:4326`) → 한국 TM(`EPSG:5186`, 중부원점) 미터 좌표로 변환.
- **왜:** 각도로는 거리를 못 재므로, 이후 모든 계산을 미터로 하기 위한 출발점.
- **역방향:** `_toTM.inverse([E,N])` → 다시 위경도 (드래그 보정 `groundPointFromPixel` 등에서 사용).
---
## 2. 드론 기준 상대 위치 (ENU 좌표)
**위치:** [geoProjection.ts:255-273 `buildRelEnu`](../client/src/utils/geoProjection.ts#L255-L273)
```ts
const stEnu = geoToEnu(targetLat, targetLon, targetAlt + geoidOffset, ...); // 터널(대상)
const drEnu = geoToEnu(camera.lat, camera.lon, camera.altitude, ...); // 드론(카메라)
const drEnuAdj = [drEnu[0]+offX, drEnu[1]+offY, drEnu[2]+offZ]; // 위치 미세보정
const relEnu = [stEnu[0]-drEnuAdj[0], stEnu[1]-drEnuAdj[1], stEnu[2]-drEnuAdj[2]]; // 빼기
const dist = Math.hypot(relEnu[0], relEnu[1]); // 수평거리(m)
```
- **무엇:** 대상과 드론을 미터 좌표로 놓고 빼서 "드론 기준 동/북/상 몇 m"(상대 벡터) 산출.
- **왜:** 카메라에서 본 방향 계산의 입력. (`geoToEnu`는 [geoProjection.ts:91-97](../client/src/utils/geoProjection.ts#L91-L97))
- **`geoidOffset`:** 대상 정표고(EL)+지오이드고 → 타원체고. 드론 GPS 고도(타원체고)와 기준을 맞춤(대전≈25.8m).
---
## 3. 카메라 기울기 = 회전행렬 (Rotation Matrix)
**위치:** [geoProjection.ts:275-295](../client/src/utils/geoProjection.ts#L275-L295) (`buildRotation` + `applyRw2c`)
```ts
function buildRotation(camera, params) { // yaw·pitch·roll → 3×3 행렬
const yaw = toRad(camera.yaw + params.yawOffset);
const pitch = toRad(camera.pitch + params.pitch);
const roll = toRad(camera.roll + params.roll);
const cy=cos(yaw), sy=sin(yaw), cp=cos(pitch), sp=sin(pitch), cr=cos(roll), sr=sin(roll);
return [[cy*cr+sy*sp*sr, sy*cp, cy*sr-sy*sp*cr], ...]; // R_b2w
}
function applyRw2c(b2w, rel) { // R_w2c · rel → 카메라 좌표
return { Xc: b2w[0][0]*rel[0]+b2w[1][0]*rel[1]+b2w[2][0]*rel[2],
Yc: -(b2w[0][2]*rel[0]+...), Zc: b2w[0][1]*rel[0]+... };
}
```
- **무엇:** yaw(좌우)·pitch(상하)·roll(갸웃)을 하나의 3×3 행렬로 만들어, 상대벡터를 **카메라 시점 좌표 `(Xc,Yc,Zc)`** 로 한 번에 회전.
- **왜:** 세 회전을 개별 적용하지 않고 행렬 한 번 곱으로 정확·간결하게. (문서의 "만능 양념장")
- **원본과 동일:** `R_w2c = R_align · R_b2wᵀ` (파이썬 `advanced_tuner_v2.py` 이식 — 파일 상단 주석 [geoProjection.ts:1-12](../client/src/utils/geoProjection.ts#L1-L12)).
- **묶음 함수:** ②+③을 한 번에 = [`toCameraCoords`(301-317)](../client/src/utils/geoProjection.ts#L301-L317). `distH/fwd/side`(거리필터용)도 여기서 채움.
---
## 4. 바늘구멍 사진기 = 핀홀 투영 (초점거리)
**위치:** [geoProjection.ts:126-137 `pixelFromCamera`](../client/src/utils/geoProjection.ts#L126-L137)
```ts
export function pixelFromCamera(cc, params) {
const f = params.focalLen, sW = params.sensorW ?? 36, sH = params.sensorH ?? 20.25;
return {
pxRaw: (0.5 + params.cx0) + (cc.Xc / cc.Zc) * (f / sW), // 가로 0~1
pyRaw: (0.5 + params.cy0) + (cc.Yc / cc.Zc) * (f / sH), // 세로 0~1
};
}
```
- **무엇:** 카메라 좌표 → 화면 정규좌표(0~1). 문서의 "창문 스티커" 계산이 이 두 줄.
- **원근:** `Xc/Zc`, `Yc/Zc` — 앞쪽 거리 `Zc`로 나누므로 **멀수록 가운데로 작게**.
- **초점거리:** `f/sW`, `f/sH``f`(focalLen)가 클수록(망원) 크게. `sensorH=20.25`는 16:9 기준(=36×9/16).
- **참고:** 원스톱 함수 [`projectPoint`(334-427)](../client/src/utils/geoProjection.ts#L334-L427)는 ①~④+FOV판정까지 한 번에 수행(디버그/단건용). 실사용 렌더는 `toCameraCoords`+`pixelFromCamera`를 프레임마다 호출.
---
## 5. 30장 사이 부드럽게 = 보간 (Interpolation)
**위치:** [StationOverlay.tsx:689-712 `poseAt`](../client/src/components/overlay/StationOverlay.tsx#L689)
```ts
const poseAt = (estFrame) => { // 연속 프레임번호 → 드론 포즈
const frac = (estFrame - f1) / (f2 - f1); // 두 실제 프레임 사이 위치(0~1)
const L = (x, y) => x + (y - x) * frac; // 선형보간
let dy = ((b.yaw - a.yaw + 540) % 360) - 180; // 방향은 최단각으로
return { ...a, lat: L(a.lat,b.lat), lon: L(a.lon,b.lon), yaw: a.yaw + dy*frac, ... };
};
```
- **무엇:** 정수 프레임(사진 30장) 사이의 **중간 드론 위치·자세**를 계산.
- **왜:** 이름표가 30번이 아니라 화면 갱신(≈60번)마다 매끈하게 이동.
- **입력:** 현재 시각 → 연속 프레임번호 `estFrame = estTime * fpsRef.current` ([StationOverlay.tsx:974](../client/src/components/overlay/StationOverlay.tsx#L974)). `fpsRef`는 §8에서 주입.
---
## 6. 흔들림 제거 = 이동평균 + EMA 평활
**위치 A — 원본 데이터 평균:** [StationOverlay.tsx:596-620 `smoothFrame`](../client/src/components/overlay/StationOverlay.tsx#L596)
```ts
// 중심 프레임 기준 ±halfWin 프레임의 GPS·자세를 평균 (회전 경계는 보존)
lat = Σlat/n; yaw = atan2(Σsin, Σcos); // 각도는 sin/cos 평균 후 atan2
```
**위치 B — 화면 위치 평활:** [StationOverlay.tsx:48-61 `smoothStep`](../client/src/components/overlay/StationOverlay.tsx#L48)
```ts
if (d > REJECT_DIST && prev.rej < MAX) return {}; // 튀는 값(이상치) 무시
const speed = hypot(vx, vy); // 평활된 이동속도
const a = min(maxAlpha, minAlpha + (maxAlpha-minAlpha)*min(1, speed/speedRef));
return { x: prev.x + dx*a, y: prev.y + dy*a, ... }; // 속도적응 EMA
```
- **무엇:** (A) GPS·자세 노이즈를 프레임 평균으로 줄이고, (B) 화면 좌표를 속도적응 EMA로 부드럽게 + 이상치 거부.
- **왜:** 드론 떨림에도 이름표가 안정적으로 붙어 있게. (느릴 땐 강하게 평활, 빠를 땐 즉시 추종)
---
## 7. 매 프레임 렌더 = requestAnimationFrame + Canvas 2D
**위치:** [StationOverlay.tsx:931-1243 `draw` 루프](../client/src/components/overlay/StationOverlay.tsx#L931)
```ts
const draw = () => {
rafId = requestAnimationFrame(draw); // 화면 갱신마다 반복(≈60/s)
const dronePose = poseAt(estFrame); // §5 보간 포즈
// POI 마다:
const cc = toCameraCoords(dronePose, poiA.lat, poiA.lon, pz, params, worldOrigin); // §2+§3
const { pxRaw, pyRaw } = pixelFromCamera(cc, params); // §4
const d = smoothStep(prevDpoi, pxRaw, pyRaw, ...); // §6 화면 평활
ctx.strokeText(label, lx, labelY); ctx.fillText(...); // Canvas 에 이름표 그림
};
```
- **무엇:** 화면이 새로 그려질 때마다 §2~§6을 다시 계산해 **Canvas 2D**에 이름표·중심선을 그림.
- **호출 지점:** 측점 라벨 [1070-1072](../client/src/components/overlay/StationOverlay.tsx#L1070-L1072), POI/구조물 라벨 [1107-1109](../client/src/components/overlay/StationOverlay.tsx#L1107-L1109).
- **성능:** 무거운 "가시집합/겹침 판정"은 별도 사전계산(`requestIdleCallback`, 파일 상단 주석), RAF는 투영·그리기 위주.
---
## 8. 영상별 fps 자동 산출
**위치:** [VideoPlayer.tsx:272-286 `effectiveFps`](../client/src/components/player/VideoPlayer.tsx#L272)
```ts
const effectiveFps = useMemo(() => {
if (!storeFrames.length || !duration) return 30000/1001; // 폴백 29.97
let maxF = 0; for (const f of storeFrames) if (f.frame > maxF) maxF = f.frame;
const raw = maxF / duration; // 마지막 프레임번호 ÷ 영상길이
const STD = [23.976,24,25,29.97,30,50,59.94,60];
let best = STD[0], bd = Math.abs(raw-STD[0]);
for (const s of STD) { const d = Math.abs(raw-s); if (d<bd){bd=d;best=s;} }
return bd <= best*0.1 ? best : raw; // 표준값 ±10%면 스냅
}, [storeFrames, duration]);
```
- **무엇:** 드론 CSV에는 시간이 없고 프레임 번호(`frame_cnt`)만 있으므로, **영상 길이**와 결합해 fps 산출 후 표준값에 스냅.
- **연결:** `<StationOverlay fps={effectiveFps} />` → [StationOverlay.tsx:260 `fpsRef`](../client/src/components/overlay/StationOverlay.tsx#L260) → §5의 `estFrame`, 최근접 프레임 탐색의 기준.
- **전제:** 드론 CSV가 영상 전 구간을 덮는다고 가정(마지막 frame_cnt ≈ 영상 끝). 부분만 덮으면 스냅 실패 시 원시값 사용.
---
## 부록. 데이터 로딩 (폴더 → 스토어)
**위치:** [geoData.ts](../client/src/utils/geoData.ts) → [geoStore.ts](../client/src/store/geoStore.ts)
- `<input webkitdirectory>` 로 고른 폴더의 `<base>.csv`(드론), `_POI.csv`, `building/*.csv`(측점·교량·터널·구교)를 파싱.
- 드론 CSV 헤더: `frame_cnt,latitude,longitude,altitude,yaw,pitch,roll,focal_len` ([geoData.ts:139-172](../client/src/utils/geoData.ts#L139)).
- 인코딩 자동감지(UTF-8 BOM / EUC-KR), KMZ(zip)는 `fflate` 로 해제.
- ENU 기준원점: `getWorldOrigin` ([geoData.ts:576](../client/src/utils/geoData.ts#L576)) → 스토어 `origin` → 투영에 `ref` 로 전달.
---
## 관련 함수 빠른 색인
| 함수 | 파일:줄 | 역할 |
|---|---|---|
| `latLonToTM` / `geoToEnu` | geoProjection.ts:81 / 91 | 각도→미터, ENU |
| `buildRelEnu` | geoProjection.ts:255 | 드론기준 상대벡터 |
| `buildRotation` / `applyRw2c` | geoProjection.ts:275 / 289 | 회전행렬·적용 |
| `toCameraCoords` | geoProjection.ts:301 | ②+③ 묶음 |
| `pixelFromCamera` | geoProjection.ts:126 | 핀홀 투영 |
| `projectPoint` | geoProjection.ts:334 | ①~④ 원스톱(디버그) |
| `worldFromPixel` / `groundPointFromPixel` / `solveZForPixelY` | geoProjection.ts:149 / 218 / 194 | 역투영(드래그 보정) |
| `poseAt` / `smoothFrame` / `smoothStep` | StationOverlay.tsx:689 / 596 / 48 | 보간·평활 |
| `draw` | StationOverlay.tsx:931 | RAF 렌더 루프 |
| `effectiveFps` | VideoPlayer.tsx:272 | fps 자동 산출 |
---
# 📖 용어 사전 (도움말)
> 본문에 나온 전문용어를 **초등학생도 이해할 수 있게** 그림과 함께 풀었어요.
> 모르는 말이 나오면 여기만 보면 돼요. (다른 검색 필요 없음!)
## ▸ 위도·경도 (latitude / longitude)
지구 위의 위치를 나타내는 **두 개의 각도**예요. **지구 중심에서 각도기로 잰 각(도, °)** 이에요.
- **위도** = 적도(0도)에서 **위/아래로** 몇 도 (북극 90도, 남극 −90도)
- **경도** = 영국(0도)에서 **옆으로 빙 둘러** 몇 도 (동쪽으로 갈수록 커짐)
<figure class="fig">
<svg viewBox="0 0 700 220" role="img" aria-label="위도와 경도는 지구 중심에서 잰 각도">
<!-- 위도 -->
<g>
<circle cx="160" cy="110" r="80" fill="#eef6ff" stroke="#2563eb"/>
<line x1="80" y1="110" x2="240" y2="110" stroke="#93c5fd"/>
<text x="250" y="113" font-size="10" fill="#1e3a8a">적도 0°</text>
<line x1="160" y1="110" x2="215" y2="52" stroke="#ef4444" stroke-width="2"/>
<path d="M200,110 A55,55 0 0 0 190,75" fill="none" stroke="#ef4444"/>
<text x="205" y="95" font-size="11" fill="#b91c1c" font-weight="bold">위도</text>
<circle cx="160" cy="110" r="3" fill="#111"/>
<text x="160" y="205" text-anchor="middle" font-size="11" fill="#1e3a8a">위도 = 위아래 각도 (중심에서 잼)</text>
</g>
<!-- 경도 -->
<g>
<circle cx="500" cy="110" r="80" fill="#f0fdf4" stroke="#16a34a"/>
<ellipse cx="500" cy="110" rx="30" ry="80" fill="none" stroke="#86efac"/>
<ellipse cx="500" cy="110" rx="60" ry="80" fill="none" stroke="#86efac"/>
<line x1="500" y1="110" x2="500" y2="30" stroke="#16a34a"/>
<text x="500" y="26" text-anchor="middle" font-size="10" fill="#166534">0° (영국)</text>
<line x1="500" y1="110" x2="565" y2="65" stroke="#ea580c" stroke-width="2"/>
<path d="M500,50 A60,60 0 0 1 548,72" fill="none" stroke="#ea580c"/>
<text x="530" y="52" font-size="11" fill="#9a3412" font-weight="bold">경도</text>
<circle cx="500" cy="110" r="3" fill="#111"/>
<text x="500" y="205" text-anchor="middle" font-size="11" fill="#166534">경도 = 옆으로 도는 각도</text>
</g>
</svg>
<figcaption>둘 다 지구 '중심'에서 잰 각도(도). 그래서 단위가 미터가 아니라 '도(°)'다.</figcaption>
</figure>
## ▸ WGS84 · EPSG:4326
- **WGS84**: 전 세계 GPS가 쓰는 **위도·경도 표준**. "지구를 이런 모양·기준으로 본다"는 세계 공통 약속.
- **EPSG**: 세상의 여러 좌표계에 **번호표를 붙여 정리한 목록** (도서관 책번호 같은 것).
- **EPSG:4326** = 그 목록에서 **WGS84(위도·경도)** 에 붙은 번호. 코드에서 `'EPSG:4326'` 이라 쓰면 "위경도 방식"이란 뜻.
## ▸ EPSG:5186 · 한국 TM (횡축 메르카토르)
- 우리나라 전용 **평평한 미터 지도** 좌표계. 위경도(각도)를 **미터(거리)** 로 바꾼 결과가 이 좌표.
- **TM = Transverse Mercator(횡축 메르카토르)**: 둥근 지구에 **원통을 옆으로 눕혀 씌워** 펴는 방법.
원통이 닿는 **세로선(경도) 근처가 가장 정확****남북으로 길쭉한 한국에 딱**.
<figure class="fig">
<svg viewBox="0 0 700 190" role="img" aria-label="TM 도법 - 원통을 눕혀 씌우기">
<text x="130" y="24" text-anchor="middle" font-size="11" fill="#475569" font-weight="bold">보통 메르카토르</text>
<ellipse cx="130" cy="105" rx="45" ry="70" fill="none" stroke="#94a3b8" stroke-width="2"/>
<circle cx="130" cy="105" r="42" fill="#dbeafe" stroke="#2563eb"/>
<line x1="88" y1="105" x2="172" y2="105" stroke="#ef4444" stroke-width="2.5"/>
<text x="130" y="182" text-anchor="middle" font-size="10" fill="#b91c1c">가로(적도) 근처 정확</text>
<text x="350" y="105" text-anchor="middle" font-size="24" fill="#64748b">➡ 90° 눕힘 ➡</text>
<text x="560" y="24" text-anchor="middle" font-size="11" fill="#475569" font-weight="bold">TM (횡축) — 한국용</text>
<ellipse cx="560" cy="105" rx="70" ry="45" fill="none" stroke="#94a3b8" stroke-width="2"/>
<circle cx="560" cy="105" r="42" fill="#dcfce7" stroke="#16a34a"/>
<line x1="560" y1="63" x2="560" y2="147" stroke="#ef4444" stroke-width="2.5"/>
<text x="560" y="182" text-anchor="middle" font-size="10" fill="#b91c1c">세로(경도127°) 근처 정확</text>
</svg>
<figcaption>원통이 '닿는 빨간 선' 근처가 가장 정확하다. 한국은 세로로 길어서 세로선에 맞추는 TM을 쓴다.</figcaption>
</figure>
**코드의 설정 쪽지 뜻** (`+proj=tmerc +lat_0=38 +lon_0=127 +k=1 +x_0=200000 +y_0=600000 +ellps=GRS80`):
| 설정 | 뜻 |
|---|---|
| `proj=tmerc` | TM(횡축 메르카토르) 방식 |
| `lat_0=38` `lon_0=127` | 지도 **기준점** = 위도38·경도127 (한국 한가운데) |
| `k=1` | 크기 **1배**(줄임/늘림 없음) |
| `x_0=200000` `y_0=600000` | **false easting/northing** (아래 항목 참고) |
| `ellps=GRS80` | 지구를 **GRS80**(살짝 찌그러진 귤 모양)으로 봄 |
| `units=m` | 단위 = 미터 |
## ▸ false easting / false northing (x_0, y_0)
기준점에서 서쪽·남쪽으로 가면 좌표가 **마이너스()** 가 돼요. 계산이 헷갈리니까, **처음부터 큰 수를 더해**
어디서나 **플러스(+)** 가 되게 해요. (한국은 가로 +20만, 세로 +60만 m)
<figure class="fig">
<svg viewBox="0 0 700 110" role="img" aria-label="false easting - 마이너스를 없애려 큰 수 더하기">
<line x1="40" y1="45" x2="660" y2="45" stroke="#94a3b8" stroke-width="2"/>
<line x1="200" y1="35" x2="200" y2="55" stroke="#111"/>
<text x="200" y="28" text-anchor="middle" font-size="10" fill="#111">기준점 0</text>
<text x="110" y="72" text-anchor="middle" font-size="11" fill="#b91c1c">서쪽 = 50000 😵</text>
<text x="320" y="72" text-anchor="middle" font-size="11" fill="#166534">동쪽 = +30000</text>
<text x="350" y="95" text-anchor="middle" font-size="11" fill="#3730a3">+20만을 더하면 → 서쪽도 150000 (전부 +) 😎</text>
</svg>
<figcaption>온도에 273을 더해 '절대온도(K)'로 음수를 없애는 것과 같은 아이디어.</figcaption>
</figure>
## ▸ 높이 3형제 — 정표고 · 지오이드고 · 타원체고 (geoidOffset)
높이를 재는 **기준면이 3가지**라서 서로 변환이 필요해요. 드론 GPS 높이와 지도 높이의 **기준을 맞추려고**
`geoidOffset`(대전≈25.8m)을 더해요.
<figure class="fig">
<svg viewBox="0 0 700 200" role="img" aria-label="정표고 지오이드고 타원체고">
<line x1="60" y1="60" x2="640" y2="60" stroke="#ca8a04" stroke-width="2" stroke-dasharray="6 4"/>
<text x="648" y="63" font-size="10" fill="#854d0e">타원체(GPS 기준, 매끈한 계산용 면)</text>
<path d="M60,110 q145,-22 290,0 t290,0" fill="none" stroke="#2563eb" stroke-width="2"/>
<text x="648" y="113" font-size="10" fill="#1e3a8a">지오이드(평균 해수면)</text>
<path d="M60,150 q80,-30 160,-8 q120,26 220,-14 q140,-40 340,10" fill="none" stroke="#16a34a" stroke-width="2.5"/>
<text x="120" y="175" font-size="10" fill="#166534">실제 땅(산·건물)</text>
<line x1="330" y1="60" x2="330" y2="102" stroke="#a16207" stroke-width="1.5"/>
<text x="336" y="88" font-size="10" fill="#a16207">지오이드고(≈25.8m)</text>
<line x1="330" y1="110" x2="330" y2="140" stroke="#15803d" stroke-width="1.5"/>
<text x="336" y="130" font-size="10" fill="#15803d">정표고(지도 높이)</text>
</svg>
<figcaption>정표고(우리가 아는 '해발 높이') + 지오이드고 = 타원체고(GPS 높이). 코드는 이 변환으로 기준을 맞춘다.</figcaption>
</figure>
## ▸ ENU 좌표 (East-North-Up, 동·북·상)
한 점(기준원점)을 중심으로 **동쪽(E)·북쪽(N)·위(U)** 세 방향으로 "몇 m"인지 나타내는 방식.
드론을 원점에 놓으면 터널이 "동 +50, 북 +200, 위 80" 처럼 표현돼요(본문 ②).
<figure class="fig">
<svg viewBox="0 0 700 180" role="img" aria-label="ENU 동북상 좌표축">
<defs><marker id="ax" markerWidth="10" markerHeight="10" refX="7" refY="3.5" orient="auto"><path d="M0,0 L7,3.5 L0,7 Z" fill="#334155"/></marker></defs>
<circle cx="300" cy="120" r="4" fill="#111"/>
<text x="300" y="150" text-anchor="middle" font-size="10" fill="#475569">드론(원점 0,0,0)</text>
<line x1="300" y1="120" x2="470" y2="120" stroke="#334155" stroke-width="2" marker-end="url(#ax)"/>
<text x="480" y="124" font-size="11" fill="#b45309" font-weight="bold">E 동쪽</text>
<line x1="300" y1="120" x2="300" y2="30" stroke="#334155" stroke-width="2" marker-end="url(#ax)"/>
<text x="285" y="26" font-size="11" fill="#15803d" font-weight="bold">U 위</text>
<line x1="300" y1="120" x2="210" y2="70" stroke="#334155" stroke-width="2" marker-end="url(#ax)"/>
<text x="150" y="66" font-size="11" fill="#1e3a8a" font-weight="bold">N 북쪽</text>
<text x="430" y="70" font-size="16">🚇</text>
<text x="430" y="90" font-size="10" fill="#166534">터널: 동+50, 북+200, 위−80</text>
</svg>
<figcaption>기준원점(getWorldOrigin)에서 동·북·위로 잰 미터 좌표. 빼기로 '드론 기준 상대위치'를 만든다.</figcaption>
</figure>
## ▸ yaw · pitch · roll (카메라가 기운 3방향)
카메라(또는 드론)가 향한 방향을 나타내는 **3개의 회전**. 사람 고개 움직임과 같아요.
<figure class="fig">
<svg viewBox="0 0 700 150" role="img" aria-label="yaw pitch roll">
<g transform="translate(120,75)"><text x="0" y="-38" text-anchor="middle" font-size="26">🙂</text>
<path d="M-42,8 a42,16 0 0 0 84,0" fill="none" stroke="#2563eb" stroke-width="3"/><polygon points="42,8 35,2 35,15" fill="#2563eb"/>
<text x="0" y="40" text-anchor="middle" font-size="12" font-weight="bold" fill="#2563eb">yaw</text><text x="0" y="57" text-anchor="middle" font-size="10" fill="#64748b">좌우(도리도리)</text></g>
<g transform="translate(350,75)"><text x="0" y="-38" text-anchor="middle" font-size="26">🙂</text>
<path d="M0,-28 a16,42 0 0 0 0,56" fill="none" stroke="#16a34a" stroke-width="3"/><polygon points="0,28 -6,21 6,21" fill="#16a34a"/>
<text x="0" y="40" text-anchor="middle" font-size="12" font-weight="bold" fill="#16a34a">pitch</text><text x="0" y="57" text-anchor="middle" font-size="10" fill="#64748b">위아래(끄덕끄덕)</text></g>
<g transform="translate(580,75)"><text x="0" y="-38" text-anchor="middle" font-size="26">🙂</text>
<path d="M-28,0 a28,28 0 1 1 7,18" fill="none" stroke="#db2777" stroke-width="3"/><polygon points="-21,18 -28,12 -13,11" fill="#db2777"/>
<text x="0" y="40" text-anchor="middle" font-size="12" font-weight="bold" fill="#db2777">roll</text><text x="0" y="57" text-anchor="middle" font-size="10" fill="#64748b">갸웃(갸우뚱)</text></g>
</svg>
</figure>
## ▸ 회전행렬 (Rotation Matrix)
위 3개 회전(yaw·pitch·roll)을 **하나의 작은 숫자표(3×3)** 로 미리 합쳐 둔 것. 이 표를 위치에 **한 번 곱하면**
세 방향 회전이 **동시에** 적용돼요. (문서의 "방향 돌리기 만능 양념장")
<figure class="fig">
<svg viewBox="0 0 700 130" role="img" aria-label="세 회전을 한 숫자표로 합치기">
<rect x="30" y="45" width="70" height="34" rx="6" fill="#eff6ff" stroke="#2563eb"/><text x="65" y="67" text-anchor="middle" font-size="12" fill="#1e3a8a">yaw</text>
<text x="110" y="67" font-size="16">+</text>
<rect x="130" y="45" width="70" height="34" rx="6" fill="#f0fdf4" stroke="#16a34a"/><text x="165" y="67" text-anchor="middle" font-size="12" fill="#166534">pitch</text>
<text x="210" y="67" font-size="16">+</text>
<rect x="230" y="45" width="70" height="34" rx="6" fill="#fdf2f8" stroke="#db2777"/><text x="265" y="67" text-anchor="middle" font-size="12" fill="#9d174d">roll</text>
<text x="335" y="67" font-size="22" fill="#64748b">➡</text>
<rect x="380" y="30" width="120" height="66" rx="6" fill="#fffbe6" stroke="#ca8a04"/>
<text x="440" y="52" text-anchor="middle" font-size="10" fill="#854d0e">3×3 숫자표</text>
<text x="440" y="70" text-anchor="middle" font-size="10" fill="#854d0e">(회전행렬)</text>
<text x="440" y="86" text-anchor="middle" font-size="9" fill="#a16207">R_w2c</text>
<text x="520" y="67" font-size="16">×</text>
<rect x="540" y="45" width="120" height="34" rx="6" fill="#f1f5f9" stroke="#475569"/><text x="600" y="67" text-anchor="middle" font-size="10" fill="#334155">위치 → 돌린 위치</text>
</svg>
<figcaption>3번 돌릴 걸 표 1개로 합쳐 한 번에 곱한다 → 빠르고 실수 없음. 코드: buildRotation + applyRw2c.</figcaption>
</figure>
## ▸ 카메라 좌표 (Xc, Yc, Zc)
회전까지 마친 뒤 얻는, **카메라 눈 기준** 좌표. `Zc`=앞쪽 거리, `Xc`=좌우, `Yc`=상하.
`Zc`가 0보다 커야(앞에 있어야) 화면에 보여요. 다음 단계(핀홀)에서 이 값을 `Xc/Zc`, `Yc/Zc`로 나눠 써요.
## ▸ 핀홀 카메라 모델 · 초점거리 · 센서
**바늘구멍 사진기** 원리. 작은 구멍(렌즈)을 지나며 3D가 화면에 맺혀요. **멀면 작게, 가까우면 크게**(원근).
<figure class="fig">
<svg viewBox="0 0 700 190" role="img" aria-label="핀홀 카메라 - 빛이 구멍 지나 거꾸로 맺힘">
<line x1="90" y1="30" x2="90" y2="150" stroke="#16a34a" stroke-width="6"/><polygon points="90,30 84,44 96,44" fill="#16a34a"/>
<text x="90" y="170" text-anchor="middle" font-size="10" fill="#166534">터널(큼)</text>
<line x1="360" y1="18" x2="360" y2="162" stroke="#94a3b8" stroke-width="6"/><circle cx="360" cy="90" r="5" fill="#111"/>
<text x="360" y="180" text-anchor="middle" font-size="10" fill="#475569">바늘구멍(렌즈)</text>
<line x1="90" y1="30" x2="560" y2="138.6" stroke="#f59e0b" stroke-width="1.3"/>
<line x1="90" y1="150" x2="560" y2="41.4" stroke="#f59e0b" stroke-width="1.3"/>
<line x1="560" y1="41.4" x2="560" y2="138.6" stroke="#ef4444" stroke-width="5"/><polygon points="560,138.6 554,124.6 566,124.6" fill="#ef4444"/>
<text x="560" y="170" text-anchor="middle" font-size="10" fill="#b91c1c">맺힌 상(작고 거꾸로)</text>
</svg>
<figcaption>모든 빛이 구멍 한 점을 지나 X자로 교차 → 화면엔 작고 거꾸로 맺힘. '초점거리(focal length)'가 클수록(망원) 크게 보인다.</figcaption>
</figure>
- **초점거리(focalLen)**: 렌즈가 얼마나 '당겨 찍나'(망원↔광각). 클수록 화면에서 크게.
- **센서(sensorW/H)**: 카메라 필름 크기. 초점거리와 센서 비율이 화각을 정함(코드 `f/sW`, `f/sH`).
## ▸ 정규좌표 (0~1)
화면 위치를 픽셀(예: 1920) 대신 **비율(0~1)** 로 나타낸 것. **0=왼쪽/위, 1=오른쪽/아래.**
화면 크기가 달라져도 그대로 쓸 수 있어 편해요. (예: 가로 0.6 = 화면의 60% 지점)
## ▸ 보간 (Interpolation)
두 값 **사이의 중간값**을 계산해 채우는 것. 사진이 30장뿐이어도 사이사이를 상상해 채워 **부드럽게** 움직여요.
<figure class="fig">
<svg viewBox="0 0 700 120" role="img" aria-label="보간 - 두 점 사이 중간 채우기">
<circle cx="120" cy="80" r="7" fill="#2563eb"/><text x="120" y="105" text-anchor="middle" font-size="10" fill="#1e3a8a">사진1</text>
<circle cx="560" cy="40" r="7" fill="#2563eb"/><text x="560" y="30" text-anchor="middle" font-size="10" fill="#1e3a8a">사진2</text>
<line x1="120" y1="80" x2="560" y2="40" stroke="#cbd5e1" stroke-dasharray="4 4"/>
<circle cx="230" cy="70" r="4" fill="#ef4444"/><circle cx="340" cy="60" r="4" fill="#ef4444"/><circle cx="450" cy="50" r="4" fill="#ef4444"/>
<text x="340" y="95" text-anchor="middle" font-size="10" fill="#b91c1c">← 중간을 채운 위치(보간) →</text>
</svg>
<figcaption>사진1·2 사이 몇 % 지점인지(frac) 계산해 중간 위치를 만든다. 코드: poseAt.</figcaption>
</figure>
## ▸ 이동평균 · EMA (지수이동평균)
여러 값을 **평균 내** 들쭉날쭉(떨림)을 매끈하게 만드는 방법.
- **이동평균**: 앞뒤 몇 개를 평균 (코드 `smoothFrame`)
- **EMA**: 최근 값에 더 무게를 둔 평균, 반응이 빠름 (코드 `smoothStep`)
<figure class="fig">
<svg viewBox="0 0 700 130" role="img" aria-label="떨리는 값 vs 평활한 값">
<polyline points="40,70 80,40 120,95 160,45 200,90 240,50 280,85 320,55 360,80" fill="none" stroke="#f87171" stroke-width="2"/>
<text x="200" y="120" text-anchor="middle" font-size="10" fill="#b91c1c">평활 전: 부들부들 떨림</text>
<path d="M400,70 q80,-8 160,-6 t120,-4" fill="none" stroke="#16a34a" stroke-width="3"/>
<text x="560" y="120" text-anchor="middle" font-size="10" fill="#166534">평활 후: 매끈</text>
<text x="378" y="66" font-size="18" fill="#64748b">➡</text>
</svg>
<figcaption>드론 흔들림으로 튀는 위치를 평균으로 눌러 이름표가 안 떨리게 한다.</figcaption>
</figure>
## ▸ requestAnimationFrame (RAF)
브라우저에게 **"화면 새로 그릴 때마다 이 일을 해줘"** 라고 부탁하는 명령. 보통 1초에 약 60번 실행돼요.
그래서 이름표 위치를 매번 다시 계산·그리기 해 부드럽게 따라다녀요.
## ▸ Canvas 2D
웹페이지 안의 **그림판(도화지)**. 여기에 선·글자를 직접 그려요. 우리는 중심선·측점·POI 이름표를
매 프레임 이 캔버스에 그려요. (선명하고 빠름)
## ▸ fps · 프레임
- **프레임** = 영상의 **낱장 사진** 한 장.
- **fps(frames per second)** = **1초에 몇 장** 넘기나. 예: 30fps = 1초에 30장.
- 프레임 번호를 시간으로 바꾸려면 fps가 필요해요: `시간 = 프레임번호 ÷ fps` (본문 ⑧).
## ▸ requestIdleCallback
브라우저가 **한가한 틈**에 무거운 일을 시키는 명령. 무거운 "어떤 라벨을 보여줄지" 준비 작업을 여기서 미리 해
두고, 매 프레임(RAF)에는 가벼운 그리기만 → 60번/초에도 안 버벅여요.