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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 18:03:30 +09:00

529 lines
33 KiB
Markdown
Raw 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.
# 드론 좌표 → 영상 픽셀 투영: 기술 ↔ 소스코드 매칭 (구현 상세)
> 발표 자료 [발표_핵심기술_드론좌표를_영상에_맞추기_쉬운설명](발표_핵심기술_드론좌표를_영상에_맞추기_쉬운설명.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번/초에도 안 버벅여요.