# POI/측점 투영 오차 개선 — 개발자 기술 문서

**날짜**: 2026-06-22
**대상 데이터**: 하행)회덕-대전조차장 (v2.0)
**수정 파일**: `client/src/utils/geoProjection.ts`, `client/src/utils/geoData.ts`, `client/src/components/overlay/StationOverlay.tsx`

이 문서는 GPS POI/측점/중심선 오버레이의 위치 오차를 개선한 작업 기록이다.
**개발자가 코드를 직접 이해하고 추가 수정할 수 있도록** 배경·원인·코드·튜닝 방법을 모두 담는다.

---

## 0. 투영 파이프라인 개요 (먼저 읽기)

영상 위에 측점/POI/선로중심선을 그리는 전체 흐름:

```
CSV/SRT 드론 텔레메트리(프레임별 lat/lon/alt/yaw/pitch/roll)
   │  geoData.ts: parseDroneFrames / parseStations / parsePois / parseStructures
   ▼
geoStore (Zustand)  ── frames / stations / pois / centerline / origin
   │
   ▼
StationOverlay.tsx
   ├─ smoothFrame(): 드론 자세를 ±N프레임 이동평균으로 평활 (GPS/자세 노이즈 제거)
   ├─ startLabelPrecompute(): 전 프레임 × 전 측점/POI 의 화면좌표를 미리 계산 → labelMap
   ├─ RAF 루프: labelMap 조회 + 프레임 간 보간 + 화면 EMA → canvas 그리기
   │
   ▼ (좌표 변환 핵심)
geoProjection.ts: projectPoint() / toCameraCoords() + pixelFromCamera()
   1) WGS84(lat/lon) → EPSG:5186 TM(easting/northing, m)  [proj4]
   2) 대상·드론의 상대 ENU 벡터 rel = target - drone  (m)
   3) 회전행렬 R_b2w = Rz(-yaw)·Rx(pitch)·Ry(roll), R_w2c = R_align·R_b2wᵀ
   4) 카메라좌표 (Xc,Yc,Zc) = R_w2c·rel
   5) 핀홀 투영 px = 0.5 + (Xc/Zc)·(f/sensorW), py = 0.5 + (Yc/Zc)·(f/sensorH)
```

투영 공식 자체는 Python 원본 `pythonsource/advanced_tuner_v2.py` 와 1:1 일치한다 (검증 완료).
**오차는 공식이 아니라 "공식에 들어가는 입력값(표고·평활지연)" 에서 발생**했다.

데이터 사실(검증됨):
- 영상: 3840×2160, **29.97fps(=30000/1001)** → 코드의 `VIDEO_FPS` 와 일치 (프레임 동기화 OK)
- focal_len=24.0(고정), pitch=-29.8°(고정), roll=0(고정) → intrinsic/자세 입력 OK
- yaw: GPS 진행방향(course-over-ground)과 짐벌 yaw 차이 중앙값 **-0.74°** → yaw 바이어스 없음

---

## 1. [수정] 측점 표고를 잘못된 컬럼에서 읽던 문제 (수직 오차 ~31°)

### 증상
측점/중심선이 실제보다 훨씬 아래(또는 화면 밖)에 그려짐. 구버전에서 측점 158K500이
`py=1.225`(화면 아래로 이탈)로 계산됨.

### 원인
`building/01)측점.csv` 에는 표고 컬럼이 **두 개**다:
- `Z좌표` (col 3): **로컬 좌표계 높이 ≈ 0** (예: 0.0235)
- `Z좌표_한국` (col 9): **EPSG:5186 실제 정표고 ≈ 41.5m**

기존 `parseStations` 가 `Z좌표`(≈0)를 읽어, 측점/중심선이 표고 0에 놓였다.
드론 abs_alt(타원체고)=84m 와의 수직차가 -84m(부각 42.7°)로 과대 계산됨.

### 검증 (proj4 직접 계산, 157K900, frame 0, 수평거리 91.3m)
| 사용 표고 | 드론과 수직차 | 부각 |
|---|---|---|
| Z좌표≈0 (기존) | -84.2m | 42.7° ❌ |
| Z좌표_한국 41.5 (정표고) | -42.7m | 25.1° |
| 정표고+지오이드 ≈67 (타원체고) | **-17.7m** | **11.0°** ✅ |

드론 SRT `rel_alt`=16.85m 와 타원체고 사용 시 수직차(-17.7m)가 일치 → 정답 확인.

### 코드 (`client/src/utils/geoData.ts`, `parseStations`)
```diff
  const iTitle = fi('측점', 0);
- const iZ = fi('Z좌표', 3);
+ // Z좌표(col 3)는 로컬좌표(≈0). 실제 표고는 Z좌표_한국(EPSG:5186, 정표고)에 있다. 없으면 폴백.
+ const iZKorea = fi('Z좌표_한국');
+ const iZ = iZKorea >= 0 ? iZKorea : fi('Z좌표', 3);
```
> 중심선은 `buildCenterlineFromStations()` 가 측점 z를 그대로 쓰므로 이 한 줄로 함께 교정됨.
> **다른 데이터에서 컬럼명이 다르면** `fi('Z좌표_한국')` 의 문자열만 바꾸면 된다.

---

## 2. [수정] 정표고 → 타원체고 변환 (지오이드 보정) 추가

### 원인
측점의 `Z좌표_한국`(41.5m)은 **정표고(EL, 해수면 기준)** 이고, 드론 `abs_alt`(84m)는
**타원체고(GRS80 기준)**. 둘은 datum이 달라 **지오이드고(N)** 만큼 차이난다.
대전 지역 N ≈ 25.8m (= 84.244 − 16.853 − 41.59, KNGeoid 값과 일치).

정표고+25.8 = 타원체고 → 드론 alt와 같은 기준이 됨.

### 설계
지오이드 보정은 **모든 대상(측점/POI/중심선)** 에 공통 적용되므로 투영 레이어
(`geoProjection.ts`)에서 대상 고도에 더한다. 튜닝 가능하도록 `CameraParams` 필드로 노출.

### 코드 (`client/src/utils/geoProjection.ts`)
```diff
  // CameraParams 인터페이스
+ geoidOffset: number; // 지오이드고(m): 대상 정표고(EL)→타원체고. 드론 abs_alt와 datum 일치. 대전≈25.8

  // DEFAULT_CAMERA_PARAMS
+ geoidOffset: 25.8,

  // projectPoint() 와 buildRelEnu() 두 곳 모두
- const stEnu = geoToEnu(targetLat, targetLon, targetAlt, ...);
+ const stEnu = geoToEnu(targetLat, targetLon, targetAlt + (params.geoidOffset ?? 0), ...);
```
> 수학: `rel[2] = (targetZ + geoid) − droneAlt`. geoid를 키우면 대상이 위로(멀리) 이동.
> **다른 지역**은 패널 "지오이드" 슬라이더로 조정 (국내 22~30m). 코드 기본값은
> `DEFAULT_CAMERA_PARAMS.geoidOffset` 에서 변경.

---

## 3. [수정] 화면 EMA 과평활로 인한 라벨 지연 (~1.65초)

### 증상
카메라가 움직일 때 라벨이 뒤늦게(약 1.6초) 따라옴 → 이동 중 좌우/상하 어긋남.

### 원인
`StationOverlay.tsx` 의 화면좌표 EMA(지수이동평균) 계수 `emaAlpha` 기본값이 **0.01**.
RAF 60fps에서 시정수 ≈ 16.7ms/0.01 ≈ **1.65초**. 입력은 이미 `smoothFrame(±10fr)`로
평활되는데 화면 EMA까지 과하게 걸려 이중 평활 → 큰 지연.

### 코드 (`client/src/components/overlay/StationOverlay.tsx`)
```diff
- const [emaAlpha, setEmaAlpha] = useState(0.01);
- const emaAlphaRef = useRef(0.01);
+ // 0.01은 과평활(지연~1.65s). 입력이 smoothFrame으로 이미 평활되므로 화면 EMA는 가볍게(0.4≈지연25ms).
+ const [emaAlpha, setEmaAlpha] = useState(0.4);
+ const emaAlphaRef = useRef(0.4);
```
> EMA 동작: `disp += (target − disp) · α`. α↑ = 반응 빠름/덜 부드러움, α↓ = 느림/부드러움.
> 떨림이 심하면 패널 "EMA α" 를 0.2~0.3으로, 지연이 거슬리면 0.5~0.7로.

---

## 4. [수정] POI가 "실제보다 가까이(아래)" 뜨는 문제 — POI 전용 표고 보정

### 증상
드론 시점에서 라벨(POI)이 실제 위치보다 가까이/아래에 뜸. 특히 건물(회덕화물역 등).

### 원인 (이건 "버그"가 아니라 데이터 한계)
화면의 POI들(회덕화물역·회덕역주차장·국도17호선 등)은 웹소스 `_POI.csv` 출신으로
**표고 컬럼이 아예 없다**. 또 오버레이는 POI 고도를 `nearestCL().z`(가장 가까운 선로점 표고)로
덮어쓴다 → **모든 POI가 "선로 지면 높이"로 가정**됨.

계산(frame 0, 드론 지면 위 17m): 회덕화물역 거리 27m, 수직갭 17m → 부각 32°, py=0.55.
이 값은 **지면 점 기준으로는 기하학적으로 정확**하다. 그러나
1. 건물은 높이가 있어 지면 마커가 건물 몸체보다 아래에 보이고,
2. 주변 지반이 선로보다 높으면(예: +4~5m) 그만큼 더 아래/가깝게 보임.

per-POI 실제 표고(DEM/건물높이)가 없어 자동 보정은 불가. → **튜닝 핸들**을 제공.

### 설계
- 선로 중심선/측점은 표고가 정확하므로 **건드리면 안 됨**.
- 따라서 지오이드(전체 적용)와 별개로, **POI에만** 더하는 `poiZOffset` 을 신설.
- 적용 위치: 투영 레이어가 아니라 `StationOverlay` 의 **POI 루프에서만** poiZ에 가산.

### 코드
`client/src/utils/geoProjection.ts` (필드/기본값만 추가, 투영식엔 미사용):
```diff
+ poiZOffset: number;  // POI 전용 표고 보정(m): POI/구조물 마커만 들어올림. 측점·중심선 미적용.
  ...
+ poiZOffset: 0,       // 기본 0 (건물높이/지형차에 맞춰 패널에서 조정)
```
`client/src/components/overlay/StationOverlay.tsx` (POI 루프):
```diff
  for (const poi of allPoi) {
-   const poiZ = nearestCL(poi.lat, poi.lon)?.z ?? poi.z;
+   // 측점/중심선과 달리 POI만 poiZOffset 적용 (건물 높이/지형차 보정)
+   const poiZ = (nearestCL(poi.lat, poi.lon)?.z ?? poi.z) + (currentParams.poiZOffset ?? 0);
    const cc = toCameraCoords(drone, poi.lat, poi.lon, poiZ, currentParams, worldOrigin);
```
패널 슬라이더 추가(지오이드 아래):
```
<ParamRow label="POI 표고" value={params.poiZOffset} min={-10} max={40} step={0.5} unit="m" .../>
```
> **사용법**: POI가 건물보다 아래/가깝게 뜨면 "POI 표고" 를 +방향으로 올린다(회덕 부근 +4~5m 권장).
> 기본 0은 기존 동작 유지(선로지면). 도로처럼 실제 지면에 있는 POI는 0이 맞을 수 있어,
> 데이터 성격에 따라 절충해 맞춘다.
>
> **추가 개선 여지(미구현)**: 구조물/지장물 CSV가 실제 표고를 제공하면 per-POI z를 쓰도록
> `nearestCL().z` 대신 해당 z를 사용. 현재 building CSV의 `Z좌표` 는 로컬값(≈0~16)이라 부적합.

---

## 5. [수정] 먼 POI가 "선로 안"에 어긋나 보이는 문제 — POI 거리 제한

### 증상
88자원(고물상) 등 일부 POI가 선로 위/안에 표시됨. "안 맞는 POI가 좀 있다."

### 원인 (핵심)
**멀리 있는 POI는 부각이 0°에 수렴해 화면 "지평선(소실점)"에 몰린다.** 그 소실점은
선로가 사라지는 지점과 같다 → 먼 POI들이 전부 선로 위에 겹쳐 찍힌다.

측점 160K900 프레임에서 화면에 잡히는 POI 9개 중 8개가 **240~312m 거리**이고 전부
`py≈0.0`(지평선), px 0.2~0.6에 몰려 있었다. 88자원은 노선기준 ~2.3km로 더 멀다.
가까운 POI(태경자동차 121m)는 정상 위치. 즉 **"안 맞는 POI = 먼 POI"** 다.

추가로 이 POI들은 표고가 없어 선로 지면 높이에 놓여 정확히 소실선 위에 앉는다(§4 참조).

### 설계
거리 컷이 가장 단순·확실. 카메라좌표의 슬랜트 거리 `√(Xc²+Yc²+Zc²)` 가 임계값을
넘으면 그 POI를 사전계산 단계에서 제외(라벨 자체를 안 만듦). 측점/중심선은 미적용
(측점은 선로 위라 소실점으로 가도 자연스러움).

### 코드 (`client/src/components/overlay/StationOverlay.tsx`)
상태 추가:
```diff
+ const [maxPoiDist, setMaxPoiDist] = useState(200);     // POI 표시 거리 상한(m)
+ const maxPoiDistRef = useRef(200);
+ useEffect(() => { maxPoiDistRef.current = maxPoiDist; }, [maxPoiDist]);
```
precompute 시그니처에 인자 추가 + POI 루프에서 컷:
```diff
- const startLabelPrecompute = useCallback((currentParams, currentSmoothHalf) => {
+ const startLabelPrecompute = useCallback((currentParams, currentSmoothHalf, currentMaxPoiDist) => {
    ...
+   const MAX_POI_DIST = currentMaxPoiDist;
    for (const poi of allPoi) {
      const cc = toCameraCoords(...);
      if (cc.Zc < CLIP_Z) continue;
+     const slant = Math.sqrt(cc.Xc*cc.Xc + cc.Yc*cc.Yc + cc.Zc*cc.Zc);
+     if (slant > MAX_POI_DIST) continue;   // 먼 POI 제외
```
두 호출부와 effect 의존성에 `maxPoiDist` 추가, 패널에 슬라이더 추가:
```
<ParamRow label="POI 거리" value={maxPoiDist} min={30} max={1000} step={10} unit="m" .../>
```
> **튜닝**: 기본 200m. 너무 휑하면 ↑(300~500), 먼 라벨이 거슬리면 ↓(100~150).
> 슬랜트 거리(3D)라 드론 높이(~35m) 포함이지만 100m+에선 수평거리와 거의 같다.
> **측점에도 같은 컷을 주고 싶으면** 측점 루프(`for (const st of allSt)`)에 동일 패턴 추가.

---

## 6. [결정] 선로변 POI 좌표 오차는 코드로 보정 불가 (데이터 한계)

### 분석
거리 필터 적용 후에도 88자원 라벨이 선로 위에 보인 케이스 분석:
- 스크린샷 프레임은 측점 160K700 부근, 이때 88자원은 **90m 거리**(먼 POI 아님 → 거리필터 통과 정상).
- 88자원 좌표(36.381059,127.420448)의 **선로 중심선까지 수직거리 = 13.5m** (nearest 측점 160K800, 31m).
- 즉 좌표 자체가 선로 바로 옆 → 90m 거리에서 13.5m 측면오프셋은 화면상 선로와 겹쳐 보임.

### 결론
- POI **수평 위치(px)** 는 좌표의 bearing으로 정확히 결정됨(투영 정확). 투영 버그 아님.
- KAKAO/VWORLD POI는 주소 기반 지오코딩 → ±수십m 오차. 철도 인접 업체(고물상 등)는
  좌표가 선로 옆에 찍힘. **올바른 좌표 없이는 코드로 못 고침.**

### 방침 (사용자 결정)
- "선로 옆 실제 POI일 수 있으니 숨기지 말고, 줄일 수 있는 오차만 최소화."
- → 근접 POI 숨김/거리 하향 **미적용**. 거리 필터 200m 유지.
- → 줄일 수 있는 체계오차(수직 datum/지오이드/지연/소실점몰림/FOV)는 이미 적용·튜닝 가능.
- → 개별 POI 좌표를 바로잡으려면 별도 보정좌표 파일이 필요(향후 과제).

---

## 7. [분석] KMZ `절대고도`는 상수 — POI 표고 개선에 사용 불가

### 배경
`2구간_14.회덕-대전조차장_하행.kmz`(=doc.kml)에 각 POI의 description 표가 있고
`절대고도` 필드가 있음. POI별 실측 지반고도면 §4의 poiZOffset 추정을 대체할 수 있어 검토.

### 분석 (python으로 doc.kml 전수 파싱)
- Placemark 100개, 절대고도 보유 83개. **전부 67.391m (고유값 1개), Z좌표=0.**
- 67.391 = 노선 출발점 지면 타원체고 (= 드론 frame0 abs_alt 84.244 − rel_alt 16.853).
- 즉 **출발점 고도 하나를 모든 POI에 일괄 기입**한 값. per-POI 실측 아님.

### 왜 쓰면 안 되나
현재 방식(POI = 가장 가까운 선로점 표고 + 지오이드)은 선로 상승(정표고 41.6→57.5m)을
따라 POI 고도가 67→83m로 변함 → 지형 상승 반영. KMZ 상수(67.391)는 후반 POI를 ~16m
낮게 만들어 **현재 방식보다 부정확**. (드론 abs_alt가 84→120으로 상승하는 것과도 부합)

### 결론
- KMZ `절대고도`는 datum 검증용으로만 유효(지오이드 25.8 재확인). 표고 개선엔 미사용.
- 진짜 개선엔 **POI별 실측 Z** 또는 **DEM** 필요. KMZ 좌표는 _POI.csv와 동일이라
  88자원 등 수평 오차도 변화 없음.
- 코드 변경 없음.

---

## 8. [전수조사] 데이터 폴더 전체 탐색 — 실측 고도원(DEM) 부재 확인

### 조사 대상 (데이터 폴더 전 파일)
`*.MP4 / *.srt / *.csv(드론) / *_POI.csv / building/01~06 / *.kmz / *.qgz / *_구조물추출_Z.xlsx`

### 고도(Z) 데이터 전수 비교
| 소스 | 고도 필드 | 값 | 판정 |
|---|---|---|---|
| building/01)측점.csv | `Z좌표_한국` | 정표고 41.6→57.5m (**가변, 실측**) | ✅ 유일한 실제 가변 고도. **이미 사용** |
| building/01)측점.csv | `Z좌표` | 로컬 ≈0 | 무효 |
| *_POI.csv / KMZ / xlsx(교량·터널·지장물) | `절대고도` | **상수 67.391** | 출발점 고도 일괄, 무효 |
| xlsx 구교 | `z` | 67.391 + 로컬오프셋(비고 '기존값') | 무효 |
| xlsx(교량·터널) | `Z좌표` | 로컬 2~22 | 구조물 상대높이, 지형 아님 |
| *.srt | `rel_alt` | 이륙점 기준 | 지형프로파일 아님(상수지형) |
| *.qgz(QGIS) | 래스터/DEM | **없음** (벡터 레이어만 참조) | 없음 |

### .qgz 분석
참조 GeoPackage `14회덕-대전조차장_layers.gpkg`(폴더에 없음)의 레이어:
`드론경로_버퍼_150m, 드론경로_라인/포인트, 출입문, 구조물_교량/터널, 지장물`.
→ **DEM 없음.** 또한 **POI 선정 기준이 '드론경로 150m 버퍼'** 임을 확인.

### 결론
- POI별 실측 지형고도(DEM)는 데이터셋에 **존재하지 않음**.
- 실제 가변 고도는 측점 `Z좌표_한국` 뿐이며 **현재 POI 고도 추정(최근접 측점 표고+지오이드)에 이미 사용 중** → 수직은 데이터 한계상 최선.
- 좌표는 모든 파일이 동일(지오코딩) → 88자원류 수평오차 개선 불가.

### 적용 변경
데이터 설계(150m 버퍼)에 맞춰 POI 거리 필터 기본값 **200 → 150m**.
```diff
- const [maxPoiDist, setMaxPoiDist] = useState(200);
- const maxPoiDistRef = useRef(200);
+ const [maxPoiDist, setMaxPoiDist] = useState(150);   // '드론경로_버퍼_150m'와 정렬
+ const maxPoiDistRef = useRef(150);
```

---

## 9. [검증] 구글어스 '지면고도' vs 현재 방식 — 0.2m 일치 (변경 불필요)

### 배경
사용자가 KMZ를 구글어스에 import → POI 클릭 시 `지면 고도` 값이 표시됨을 확인
(예: 신대천교(복) = 41.4566m). "파일에 per-POI 고도가 있다"는 취지.

### 사실관계
- **KMZ 파일**: `절대고도=67.391`(상수)만 저장. (§7과 동일, 변함없음)
- **구글어스**: 클릭 시 자체 DEM에서 `지면고도`(정표고)를 **실시간 계산**해 표시.
  파일 값이 아니라 구글 지형 데이터.
- **현재 앱**: 측량 측점 `Z좌표_한국`(정표고)로 POI 지면고도 추정.

### 수치 검증
| POI | 구글어스 지면고도 | 현재(최근접 측점 정표고) | 차이 |
|---|---|---|---|
| 신대천교(복) | 41.4566m | 41.26m (158K400) | **0.2m** |

→ 90m 거리에서 0.2m = 0.13°, ~6px(2160p), 무시 가능. 현재 방식이 POI별로 변하는
지면고도(40.8~80.5m, 지형상승 반영)를 이미 정확히 산출 → **구글어스 DEM과 동급**.

### 결론
- 수직(고도)은 이미 측량 측점 기반으로 구글어스와 0.2m 일치 → **개선 여지 없음, 코드 변경 없음**.
- 공개 DEM(SRTM 30m, ±수m)을 끌어와도 측량 측점보다 정밀하지 않아 regression 위험.
- 잔여 88자원류는 **수평 좌표(지오코딩) 오차** → 고도 데이터로 해결 불가(올바른 좌표 필요).

---

## 10. [기능] POI 마우스 드래그 위치 보정 + 내보내기/가져오기

### 목적
지오코딩 오차로 어긋난 POI(88자원 등)를 **마우스로 끌어** 올바른 위치에 놓고, 그
보정을 파일로 저장 → 다음 로드 시 자동 반영. (수평+수직 동시 보정)

### 동작 원리
1. **편집 모드** ON → 오버레이 canvas가 포인터 입력을 받음(`pointer-events`).
2. POI 마커 **드래그** → 놓은 화면 픽셀을 **역투영**해 월드 좌표(lat/lon/z) 복원.
   - 2D→3D 깊이 모호성은 **슬랜트 거리 유지**로 해소(드래그 시작 시 거리 고정).
   - 따라서 한 번의 드래그로 **수평·수직 동시** 보정.
3. 결과를 `title` 키로 store에 저장(`setPoiOverride`) → `pois` 재적용 → labelMap 재계산.
4. **내보내기**: `<base>_poi_overrides.json` 다운로드. **가져오기**: 파일 선택 적용.
5. 그 파일을 **데이터 폴더에 두면** 다음 폴더 로드 시 `parsePoiOverrides`가 자동 적용.

### 역투영 수학 (검증됨: Zc>0 왕복오차 ≈0)
`geoProjection.ts worldFromPixel()`:
```
dir_cam ∝ ((px-0.5-cx0)·sW/f, (py-0.5-cy0)·sH/f, 1)   // 픽셀→카메라 방향
cam = dir_cam/|dir_cam| · range                          // 거리 유지
rel = R_w2cᵀ · cam                                        // 회전 역
E = droneE + offX + rel.x,  N = droneN + offY + rel.y
z = droneAlt + offZ + rel.up − geoid                     // 정표고로 복원(투영이 geoid 재가산)
(lat,lon) = TM⁻¹(E,N)
```
> 화면에 보이는 POI(Zc>0)만 드래그 가능 → 역투영 정확. 카메라 뒤(Zc<0)는 해당 없음.

### 보정 POI의 고도 처리
보정된 POI는 precompute에서 **자신의 z(정표고)를 그대로 사용**(`overridesRef`로 판별).
미보정 POI는 기존대로 `nearestCL.z + poiZOffset`. (둘 다 투영에서 geoid 가산)

### 수정 파일
- `types/geo.ts`: `PoiOverride`, `PoiOverrideMap`, `FolderGeoData.poiOverrides`.
- `utils/geoProjection.ts`: `worldFromPixel()` (역투영).
- `utils/geoData.ts`: `parsePoiOverrides()`(폴더 JSON 로드), `applyPoiOverrides()`(title 매칭 교체), `loadFolderGeoData`에 연결.
- `store/geoStore.ts`: `basePois`/`poiOverrides` 상태, `setPoiOverride`/`clearPoiOverride`/`setPoiOverrides`, 로드시 적용.
- `components/overlay/StationOverlay.tsx`: 편집모드 토글, canvas 포인터 핸들러(hit-test→드래그→drop 역투영), 드래그 피드백 렌더, 패널 UI(편집/내보내기/가져오기), 보정 POI z 처리, override 변경 시 재계산.

### 사용법 (사용자)
1. 카메라 파라미터 패널 → **"✎ 편집 모드"** 클릭.
2. POI를 끌어 올바른 위치에 놓기(영상 일시정지 권장 — 자세가 안정적).
3. **"내보내기"** → `<base>_poi_overrides.json` 저장.
4. 그 파일을 **데이터 폴더**(MP4 옆)에 복사 → 다음 폴더 로드 시 자동 적용.
   (또는 **"가져오기"** 로 즉시 적용)

### 한계/참고
- 식별자가 `title` 뿐 → 같은 title POI가 여럿이면 함께 보정됨(현 데이터엔 거의 없음).
- drop 후 라벨 이동은 재계산(≈500ms debounce) 뒤 반영.
- 측점/중심선/구조물은 편집 대상 아님(POI만).

---

## 11. [개선] 라벨 이동 부드럽게 — 부드러운 시간원 + 연속 프레임 보간

### 증상
재생 중 라벨이 매끄럽지 않고 "멈췄다 점프" 하듯 보임.

### 원인 (2가지)
1. StationOverlay에 **거친 `currentTime`** 전달: playerStore.currentTime은 Video.js
   `timeupdate` 이벤트(약 4~15Hz)로 갱신됨([useVideoPlayer.ts:43]). 반면 VideoPlayer엔
   이미 60fps 단조보간 시간 `smoothTimeRef`가 있는데 라벨엔 안 쓰고 있었음.
2. 보간 `frac`이 **1프레임으로 클램프**(`Math.min(0.999, …)`): currentTime 갱신 간격이
   1프레임을 넘으면 라벨이 frame+1에서 멈췄다가 다음 갱신 때 여러 프레임 점프.

### 수정
- VideoPlayer: `<StationOverlay timeRef={smoothTimeRef} …>` 추가 (StationBar와 동일 패턴).
- StationOverlay RAF: 시간원을 timeRef로 바꾸고 **연속 프레임 인덱스**로 보간:
```diff
- const frameNum = currentFrameNumRef.current;
- const estTime = currentTimeSecRef.current + (now - timeUpdateWallRef.current)/1000;
- const frac = Math.min(0.999, estTime*VIDEO_FPS - frameNum);   // 1프레임 클램프(멈춤)
- const labelsA = get(frameNum), labelsB = get(frameNum+1);
+ const estTime = timeRef ? timeRef.current : (prop 기반 폴백);
+ const estFrame = estTime * VIDEO_FPS;
+ let baseFrame = Math.floor(estFrame), frac = estFrame - baseFrame;  // 클램프 없음
+ let labelsA = get(baseFrame), labelsB = get(baseFrame+1);
+ if (!labelsA) { baseFrame = currentFrameNumRef.current; frac = 0; … } // 갭 폴백
```
- Props에 `timeRef?: React.MutableRefObject<number>` 추가.

### 효과
- timeRef는 일시정지·시크·배속까지 보정된 60fps 단조시간 → 라벨이 매 프레임 연속 이동.
- 1프레임 클램프 제거로 갱신 간격이 길어도 라벨이 끊기지 않고 진행.
- 기존 화면 EMA(α=0.4)는 유지 → 프레임 경계 속도변화까지 완만.
- 일시정지 시 timeRef 고정 → 라벨 표류 없음(이전 폴백식은 벽시계로 표류 위험).

### 남은 것 (선택)
- 빨간 **중심선**은 아직 `currentTime`(coarse) 기반 + 비보간이라 라벨만큼 매끄럽지 않음.
  같은 방식(보간 드론 자세로 RAF에서 투영)으로 부드럽게 가능 — 필요 시 후속 작업.

---

## 12. [버그픽스] 라벨이 옆으로 튀었다 복귀 — 인덱스 짝짓기 + 이상치 거부

### 증상
재생 중 일부 라벨이 한 프레임 옆으로 확 튀었다가 다음 프레임에 제자리로 돌아옴.

### 근본 원인 (인덱스 짝짓기 오류)
precompute는 프레임별로 `stationLabels`/`poiMarkers`에 **클리핑/화면밖 필터를 통과한 것만
push** 한다. RAF 보간은 `labelsA[i]` ↔ `labelsB[i]` 를 **배열 인덱스로 짝지어** 보간했는데,
A↔B 사이에 라벨 하나가 필터에 들고나면 인덱스가 밀려 **서로 다른 라벨끼리 보간** →
좌표가 확 튀고, 다음 프레임에 정렬되며 복귀.

### 수정 (StationOverlay.tsx)
1. **title로 짝짓기** (근본 해결): 프레임 B 라벨을 `Map<title, entry>` 로 인덱싱 후
   `stA.title` 로 조회. 항상 같은 라벨끼리 보간.
```diff
- const stB = labelsB?.stationLabels[i];       // 인덱스 짝짓기(필터 변하면 어긋남)
+ const stB_byTitle = new Map((labelsB?.stationLabels ?? []).map(s => [s.title, s]));
+ const stB = stB_byTitle.get(stA.title);      // title 짝짓기
```
2. **이상치 거부 필터** (요청 + 잔여 GPS/투영 스파이크 대비): 표시좌표 갱신 시 이전 위치
   대비 점프가 임계(`REJECT_DIST=0.12`, 정규화) 초과면 **갱신 무시(이전 위치 유지)**.
   시크/재등장에 갇히지 않도록 연속 `MAX_REJECT_FRAMES=8` 후 수용.
```ts
function smoothStep(prev, tx, ty, alpha) {
  if (!prev) return { x:tx, y:ty, rej:0 };
  const d = Math.hypot(tx-prev.x, ty-prev.y);
  if (d > REJECT_DIST && prev.rej < MAX_REJECT_FRAMES)
    return { x:prev.x, y:prev.y, rej:prev.rej+1 };   // 이상치 무시
  return { x:prev.x+(tx-prev.x)*alpha, y:prev.y+(ty-prev.y)*alpha, rej:0 };
}
```
3. **사라진 라벨 상태 정리**: 이번 프레임에 안 그린 라벨은 displayed 맵에서 삭제 →
   재등장 시 옛 좌표가 남아 거부로 늦게 뜨는 것 방지(새 위치에서 시작).

### 튜닝
- 정상 팬 이동은 60fps에서 프레임당 ≪0.12 라 영향 없음. 스파이크(0.2~0.5)만 걸러짐.
- 너무 빠른 팬에서 잠깐 머뭇하면 `REJECT_DIST` ↑ 또는 `MAX_REJECT_FRAMES` ↓.

---

## 13. [기능] 구조물(교량/터널/구교)도 영상 오버레이에 표출

### 배경
"신대천교(복)이 왜 안 보이나?" → 신대천교(복)은 `03)교량.csv`(구조물)에만 있고
`_POI.csv`/지장물엔 없음. 오버레이는 측점+POI만 그려 구조물은 미표시였음
(구조물은 RoutePanel 미니맵 전용이었음).

### 수정 (StationOverlay.tsx)
- `useGeoStore(s => s.structures)` 구독, 구조물을 GeoPoint 형태로 변환해
  `allStructuresRef` 에 보관(이모지: category 교량🌉/터널🚇/구교🌉).
  ```ts
  allStructuresRef.current = storeStructures
    .filter(s => typeof s.lat==='number' && typeof s.lon==='number')
    .map(s => ({ title:s.name, category:s.category ?? (s.type==='tunnel'?'터널':'교량'),
                 lat:s.lat, lon:s.lon, z:0, type:'poi' }));
  ```
- precompute에서 `allPoi = allPoisRef.current.concat(allStructuresRef.current)` →
  구조물도 POI와 동일 파이프라인(평면거리필터·title보간·EMA·드래그보정)으로 표시.
- 재계산 트리거에 `storeStructures` 의존 추가.

### 효과/참고
- 드론 평면거리 60m 이내의 교량/터널/구교가 라벨로 표출됨.
- 측점선 토글(`visible`)·POI 거리필터·POI 드래그 보정이 동일 적용.
- lat/lon 없는(측점기반) 구조물은 제외.

---

## 14. [기능] POI 유효거리 앞/옆 분리(비등방 거리필터)

### 요구
"앞쪽 유효거리와 옆쪽 유효거리를 달리해서 POI 표출."

### 구현
평면거리를 진행방향(yaw) 기준 **앞쪽(fwd)·옆쪽(side)** 으로 분해해 각각 임계 적용.
- `geoProjection.ts toCameraCoords`: relEnu와 yaw로 성분 계산
  ```ts
  const yaw = toRad(camera.yaw + params.yawOffset);
  cc.fwd  = relEnu[0]*sin(yaw) + relEnu[1]*cos(yaw);  // +앞
  cc.side = relEnu[0]*cos(yaw) - relEnu[1]*sin(yaw);  // 좌우
  ```
- `StationOverlay`: `maxPoiDist` → `maxPoiFront`/`maxPoiSide`(상태·ref·슬라이더 2개).
  필터: `if (cc.fwd > MAX_FRONT || |cc.side| > MAX_SIDE) continue;`
- 기본값 앞 60m / 옆 40m. 패널 "POI 앞쪽"(10~1000) · "POI 옆쪽"(5~500) 슬라이더.

### 효과
- 진행방향으로는 멀리(앞 큰 값), 좌우 클러터는 좁게(옆 작은 값) 식으로 독립 조절.
- 측점/구조물도 동일 POI 파이프라인 → 함께 적용.

---

## 15. [변경] 구조물 표출 위치 분리 — 교량·터널=스테이션바 / 구교=영상

### 요구
`building/`의 **교량(03)·터널(04)** 은 하단 **스테이션바**에, **나머지(구교 06 + 지장물 02 +
_POI)** 는 **영상 화면**(POI 거리설정 적용)에 표출.

### 사실관계
- 스테이션바(StationBar)는 이미 `geoStore.structures`를 `s.type`으로 판정해 교량/터널을
  표출 중. 단 **구교(06)는 type='bridge'라 교량에 섞여 바에 같이 표출**되고 있었음.
- §13에서 임시로 교량/터널/구교를 모두 영상에 넣었음 → 본 변경으로 정리.

### 수정
- `StationOverlay.tsx`: 영상 구조물은 **category==='구교'만** 포함(교량/터널 제외).
  ```diff
  - .filter(s => typeof s.lat==='number' && typeof s.lon==='number')   // 모든 구조물
  + .filter(s => s.category==='구교' && typeof s.lat==='number' && typeof s.lon==='number')
  ```
- `stationbar/StationBar.tsx`: 구조물 루프에서 **구교 제외**(교량/터널만 바에 유지).
  ```diff
    for (const s of storeStructures) {
  +   if (s.category === '구교') continue;   // 구교는 영상으로
      const cat = s.type === 'tunnel' ? '터널' : s.type === 'bridge' ? '교량' : '역사';
  ```

### 결과 라우팅
| 데이터 | 표출 위치 |
|---|---|
| 03)교량, 04)터널 | 하단 스테이션바 |
| 06)구교 | 영상 오버레이(거리필터) |
| 02)지장물, _POI.csv | 영상 오버레이(거리필터) |
| 01)측점 | 영상(측점 라벨) + 바(측점값) |

> 구분 키는 `RouteStructure.category`('교량'/'터널'/'구교'). type만으론 구교/교량이
> 둘 다 'bridge'라 분리 불가 → category로 라우팅.

---

## 16. [기능] 역사(02)지장물_역사.csv) → 스테이션바 표출

### 요구
새 파일 `building/02)지장물_역사.csv`(역=철도역)는 하단 스테이션바에만 표출,
그 외(지장물·구교·일반 POI)는 영상에만(거리필터).

### 분석
- 파일 컬럼은 지장물과 동일(명칭,…,lat,lon), EUC-KR. 내용=역(대전조차장역·회덕화물역).
- 스테이션바 Timeline은 이미 `category==='역사'`를 **원형 아이콘**으로 렌더(Timeline.tsx:181).
- 함정1: 파일명 `02)지장물_역사.csv`가 `02)지장물`을 포함 → 기존 지장물 파서가 오집 가능.
- 함정2: 회덕화물역은 `_POI.csv`에도 `철도역`으로 존재 → 바·영상 중복 노출.

### 수정 (geoData.ts / StationOverlay.tsx)
- `parseHistoricStations()`: `지장물_역사` 파일 → `RouteStructure{type:'station', category:'역사', lat, lon}`.
  `loadFolderGeoData`에서 `structures`에 concat → 바가 역사로 표출(영상은 '구교'만 표시해 제외됨).
- `parsePois` 지장물 매칭에서 **'역사' 포함 파일 제외**(오집 방지).
- 영상 POI 목록에서 **category '철도역'/'역사' 제외** → 역사 중복(영상) 제거, 바에만 표출.

### 결과 라우팅(최종)
| 데이터 | 표출 |
|---|---|
| 03)교량, 04)터널 | 스테이션바 |
| **02)지장물_역사 (역사)** | **스테이션바(원형 아이콘)** |
| 06)구교 | 영상(거리필터) |
| 02)지장물, _POI(철도역 제외) | 영상(거리필터) |
| _POI 철도역 | 제외(역사로 바에 표출) |

---

## 17. [진단+영속화] 멀리 있는 POI가 어긋나 보이고 가까이 가면 맞는 현상

### 증상
멀리서 본 POI가 실제 지도 위치와 차이가 크고, 드론이 접근(천정점)하면 정확해짐.
재생 중 POI가 "뒤로 밀려나며" 수렴.

### 진단 (기하학)
- **천정점에서 정확 + 멀수록 오차 증가**는 **표고(z) 오차가 아님**.
  표고 오차의 화면 영향 Δθ ≈ Δz·D/(D²+H²) 는 D≈H(드론높이~35m)에서 최대이고
  멀수록(D≫H) 감소. 가까이(~30m)서 맞으면 표고는 정상 → DEM(50m)은 오히려 틀림(z≈42 정상).
- **천정점=0, 지평선으로 증가**는 **카메라 각도(yaw) 미세편차**의 특징(POI가 yaw축인
  천정 방향에 있을 때 0, 지평선에서 최대). 가까이선 고부각이라 작고, 멀리선 크게 벌어짐.
- 즉 **짐벌 각도 보정 오프셋(Yaw±/Pitch±)이 0** 이라 잔여 편차 미보정 상태(Python 튜너가
  찾던 값). 1회 보정하면 전 거리에서 정렬됨.

### 수정 — 보정값 영속화 (StationOverlay.tsx)
오프셋이 새로고침마다 0으로 초기화되던 문제 → **데이터셋(baseName)별 localStorage 저장·복원**:
```ts
// 로드: ghivideo:calib:<baseName> → params + 표시설정 복원 (baseName당 1회)
// 저장: params/maxPoiFront/maxPoiSide/smoothHalf/emaAlpha 변경 시 디바운스 저장
```
→ Yaw±/Pitch± 등 한 번 맞추면 유지되어 "처음부터 맞게" 표시됨.

### 사용자 보정 절차
1. 멀리 어긋난 POI가 보이는 프레임에서 일시정지.
2. **Yaw±**(좌우 어긋남) / **Pitch±**(상하 어긋남)를 조금씩 움직여 POI를 실제 위치에 맞춤.
3. 자동 저장됨(다음 로드 시 적용). (브라우저별. 휴대는 POI 내보내기와 별개 — 필요 시 확장.)

### 한계
- 자동 보정은 정답 화면좌표(대응점)가 없어 불가 → 1회 수동 보정 필요.
- 향후: 먼 POI를 드래그하면 그 편차로 yaw/pitch 오프셋을 역산하는 캘리브레이션 가능.

---

## 18. [수정] 멀리서 앞으로 밀려보이는 슬라이드 = 표고 오차 → 드래그 표고 역산

### 재진단 (앞 §17 정정)
증상: POI가 진행방향으로 **앞에(멀리) 표시**되고, 드론이 다가갈수록 **뒤로 밀려** 실제
위치에 수렴. → **표고(z) 오차가 맞음.** 지면 어긋남은 **Δ앞 = D·Δz/(H−Δz)** 로 거리 D에
**비례**(천정점 0). §17에서 "각도 문제"로 본 건 화면 각도만 봐서 틀렸고, 사용자 관점의
지면 거리로 보면 정확히 표고. 방향상 **z가 과대**(POI를 둑·다리로 솟은 선로 표고에 배치 →
실지면보다 높음)라 앞으로 밀림.

### KML 절대고도 재확인 → 사용 불가
KMZ/doc.kml 96개 placemark 전부 `절대고도=67.391/392`(상수), coords z=0.
**per-POI 실지면고도 아님**(출발점 고도 일괄). 주차장은 그 상당값에서 ~6~9m 더 낮아져야
하므로 상수로는 못 고침.

### 수정 — 드래그로 표고 z 역산 (lat/lon 고정)
데이터에 per-POI 지면고도가 없으므로, **드래그로 POI를 실제 지면에 내려놓으면 그 세로
위치에 맞는 z를 역산**해 저장. lat/lon은 고정(수평은 보통 정확) → 한 번 맞추면 전 거리에서
정렬되어 슬라이드가 사라짐. 보정은 override 파일/영속화로 유지.
- `geoProjection.ts solveZForPixelY()`: py=0.5+cy0+(Yc/Zc)(f/sH), Yc·Zc 가 z에 1차 →
  `z = z0 + (R·Zc0 − Yc0)/(dYc − R·dZc)`, `R=(py−0.5−cy0)·sH/f`, dYc=−b2w[2][2], dZc=b2w[2][1].
  (왕복검증: z=42→py→solveZ=42.000. 화면 아래로 끌면 z↓=지면 낮춤.)
- `StationOverlay.tsx`: 드래그 종료 시 `worldFromPixel`(거리유지) → `solveZForPixelY`(lat/lon
  고정·z풀이)로 교체. 드롭 세로위치로 z 산출 후 override 저장.

### 사용
편집 모드 → 어긋난 POI를 **실제 지면 위치(보통 아래로)로 드래그** → 표고가 맞춰져 슬라이드
제거. 한 번 하면 영속화(§10 override + §17 calib 저장).

### 한계/대안
- per-POI라 다수 POI는 수동. 공통적으로 높으면 "POI 표고"(poiZOffset) 음수로 일괄 하향 가능.
- 90m DEM은 절벽/구조물 인접에서 부정확(주차장 50m 오판) → 자동 적용 안 함.

---

## 19. [기능] DEM 표고 자동적용 (좌표→지면고도) + 보정 자동저장

### 배경
"POI 좌표를 아니 절대고도(지면고도)를 구할 수 있지 않나?" → 맞음. 데이터엔 per-POI 지면이
없지만(KML/SRT는 상수 67.391로 환원) **좌표로 DEM을 조회**하면 됨.

### 검증 (open-meteo, Copernicus 90m)
| POI | DEM | 측점(현재) | 차이 | 해석 |
|---|---|---|---|---|
| 회덕화물역/주차장 등 선로변 | ~42 | ~41.4 | +0.6 | 거의 동일(정상) |
| 경부고속도로 | 35 | 40.8 | −5.8 | 도로는 더 낮음(정상) |
| 갑천(하천) | 29 | 50.5 | −21.5 | 하천 훨씬 낮음(정상) |
중앙값 차이≈0(선로변), 골짜기 지형은 적절히 하향. DEM 정표고 ≈ 측점 정표고(신대천교 42.0 vs 41.3) → datum 일치, +geoid(25.8)로 타원체고 변환.

### 구현 (StationOverlay.tsx)
- **"🌐 DEM 표고 자동적용"** 버튼: 현재 POI+구조물 좌표를 100개씩 묶어 open-meteo
  `/v1/elevation` 배치 조회(CORS 허용 확인) → 각 POI override `{lat,lon,z:DEM}` 설정.
  override z는 정표고로 저장되어 투영 시 geoid 가산(선로 POI와 동일 경로).
- **보정 자동저장**: poiOverrides 를 `ghivideo:poiov:<baseName>` localStorage 에 저장·복원
  (드래그/DEM 결과가 새로고침해도 유지). 파일 내보내기/가져오기는 그대로(휴대용).

### 한계
- 공개 90m DEM이라 **절벽·구조물 인접(예: 신대지구 주차장)은 부정확**(주차장을 50m로 과대) →
  그런 소수는 **드래그 표고 보정**(§18)으로 미세조정.
- 고정밀이 필요하면 국토지리정보원 5m DEM/VWorld 고도API(키 필요)로 교체 가능.

### 사용
편집 모드 → **"DEM 표고 자동적용"** 1클릭 → 전 POI 지면고도 적용(자동저장). 멀리서 밀리는
현상이 대부분 완화됨. 남는 소수만 드래그.

---

## 카메라 파라미터 패널 정리 (현재 노출되는 튜닝값)

| 슬라이더 | 코드 키 | 의미 | 기본값 |
|---|---|---|---|
| smooth | `smoothHalf` | 드론 자세 이동평균 반폭(프레임) | 10 |
| EMA α | `emaAlpha` | 화면좌표 평활(↑반응/↓부드러움) | 0.4 |
| **POI 앞쪽** | `maxPoiFront` | **진행방향 앞쪽 유효거리(평면)** | **60m** |
| **POI 옆쪽** | `maxPoiSide` | **진행방향 옆쪽(좌우) 유효거리(평면)** | **40m** |

> POI 거리필터는 **평면(수평) 거리**(고도차 0 간주)를 **진행방향 앞/옆으로 분해**해 비등방
> 적용. `toCameraCoords`가 yaw 기준 `fwd`(+앞), `side`(좌우)를 채우고, precompute가
> `fwd > maxPoiFront || |side| > maxPoiSide` 면 제외. (이전: 단일 평면반경 → 앞/옆 분리.)
| Yaw/Pitch/Roll ± | `yawOffset/pitch/roll` | 자세 오프셋(SRT값에 가산) | 0 |
| off X/Y/Z | `offX/offY/offZ` | 드론 위치 보정(ENU, m) | 0 |
| 지오이드 | `geoidOffset` | 정표고→타원체고(전체) | 25.8 |
| **POI 표고** | `poiZOffset` | **POI만 표고 보정** | **0** |
| f / cx₀ / cy₀ / senW / senH | `focalLen/cx0/cy0/sensorW/sensorH` | 내부표정(FOV/주점) | 24/0/0/36/20.25 |

---

## 빌드 & 테스트 절차 (중요)

이 프로젝트는 **HMR 개발서버가 아니라** pm2가 빌드 산출물(`client/dist`)을 정적 서빙한다.
→ 소스 수정 후 **반드시 재빌드해야** 브라우저에 반영된다(새로고침만으론 안 됨).

```bash
# 시스템 node는 v12라 빌드 불가 → nvm node20 사용
export PATH=~/.nvm/versions/node/v20.20.2/bin:$PATH
cd ~/projects/gitea/b23042/GhiVideo
npm run build -w client          # tsc + vite build → client/dist 갱신
# 서버(pm2 ghiVideo)는 요청마다 dist를 읽으므로 재시작 불필요
# 브라우저에서 Ctrl+Shift+R (하드 새로고침)
```
> 자주 반복하면 `npm run dev -w client`(vite HMR, 포트 5173)가 편함.

---

## 검증
- `tsc --noEmit` / `vite build` 통과 (node20).
- proj4로 수직 기하 재계산 → 부각 11° 및 POI 부각 32°(지면기준 정확) 확인.
- yaw 바이어스 -0.74° (1194 이동샘플) → yaw 오프셋 불필요.

## 남은 이슈 / 다음 단계
- 드론 `abs_alt`가 구간별로 노이즈 있음(지면 위 높이 17m→35m→스파이크 68m). 정밀 수직은
  per-프레임 abs_alt 신뢰도에 의존. 필요 시 SRT `rel_alt` 병합 고려.
- POI 실제 표고(DEM/건물높이) 확보 시 `poiZOffset` 수동튜닝 대신 자동화 가능.
- FOV(focal/sensorW)는 16:9 영상 화각이 24mm/36mm 가정과 다를 수 있어 미세 좌우오차 시 튜닝 대상.

**소요 시간**: 90분
**Context 사용량**: input ~180k / output ~25k tokens
