Files
GhiVideo/docs/history/2026-06-22_POI투영오차-개선.md
b23042andClaude Opus 4.8 78fc090360 초기 커밋: 스테이션(측점) 기반 주행영상 플레이어
- 클라이언트(React/Vite): Video.js 플레이어, 하단 스테이션바, POI/구조물 영상 오버레이, 카메라 파라미터 보정, 미니맵/RoutePanel
- 서버(Express): Range 스트리밍, HLS 변환, 프레임 추출, tus 업로드, 주석 API
- 최근 작업: 스테이션바 종점역 표출/양끝 정렬/미도착(재생불가) 표시, POI 라벨 '구분' 우선·컴팩트 팝업·겹침제외 토글·동일좌표 다중행, 진행방향(상/하) 우선 표출, 커서 배지 크기·픽셀 떨림 개선

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 16:37:57 +09:00

743 lines
39 KiB
Markdown
Raw Permalink 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.
# 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=(py0.5cy0)·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