Compare commits

...

2 Commits

Author SHA1 Message Date
minsung
eaf7134309 fix: 히스토리 검증 훅 UTF-8 강제 + 측점 맵핑 문서 추가
- guard-history-fields.py: Windows cp949 stdin에서 한글 필드 정규식
  미스매치로 발생하던 false block 및 에러 mojibake 수정 (stdin/stderr UTF-8 강제)
- docs/geo-station-mapping.html: 지리정보·측점·프레임 맵핑 구조 +
  station 기준 재생 설계 문서
- docs/history: 2026-06-17 맵핑 구조 분석 기록

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 09:31:34 +09:00
minsung
c1b6f99032 fix: geoMatch loadFrames crash when GEO_DATA_DIR missing on new server
- Add fs.existsSync check before readdirSync in loadFrames()
- Add warning logs when GEO_DATA_DIR, drone CSV, or building/ dir not found
- Remove dead csvPath variable (unused)
- Add warning log in loadPois() when building/ dir missing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 17:17:59 +09:00
5 changed files with 415 additions and 5 deletions

View File

@@ -9,6 +9,15 @@ import json
import re
import os
# Windows 기본 stdin/stderr 인코딩(cp949)에서 UTF-8 한글이 깨져
# 정규식 매칭 실패(false block) + 에러 출력 mojibake가 발생.
# stdin/stderr를 UTF-8로 강제해 한글 필드를 정상 검사/출력한다.
for _stream in (sys.stdin, sys.stderr):
try:
_stream.reconfigure(encoding="utf-8")
except Exception:
pass
# ── path.json 로드 ────────────────────────────────────────────────────────────
HOOKS_DIR = os.path.dirname(os.path.abspath(__file__))
PATH_JSON = os.path.join(HOOKS_DIR, "path.json")

View File

@@ -0,0 +1,272 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>지리정보 · 측점 · 프레임 맵핑 / Station 기준 재생 설계</title>
<style>
:root{
--bg:#0d1117; --panel:#161b22; --panel2:#1c2330; --border:#30363d;
--text:#e6edf3; --muted:#9da7b3; --accent:#ffd700; --blue:#64c8ff;
--green:#6ee787; --red:#ff6b6b; --code:#0b0f14;
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--text);
font-family:-apple-system,"Segoe UI","Malgun Gothic",sans-serif;line-height:1.65;font-size:15px}
.wrap{max-width:1080px;margin:0 auto;padding:40px 24px 80px}
h1{font-size:28px;margin:0 0 6px;border-bottom:2px solid var(--accent);padding-bottom:14px}
h2{font-size:21px;margin:48px 0 14px;color:var(--accent);border-left:4px solid var(--accent);padding-left:12px}
h3{font-size:16px;margin:26px 0 10px;color:var(--blue)}
.sub{color:var(--muted);margin:0 0 8px;font-size:13px}
p{margin:10px 0}
code{background:var(--code);padding:2px 6px;border-radius:4px;font-family:"Cascadia Code",Consolas,monospace;
font-size:13px;color:#ffd9a0;border:1px solid #222}
pre{background:var(--code);border:1px solid var(--border);border-radius:8px;padding:16px;overflow-x:auto;
font-family:"Cascadia Code",Consolas,monospace;font-size:13px;line-height:1.55;color:#cdd9e5}
pre code{background:none;border:none;padding:0;color:inherit}
table{width:100%;border-collapse:collapse;margin:14px 0;font-size:13.5px}
th,td{border:1px solid var(--border);padding:9px 11px;text-align:left;vertical-align:top}
th{background:var(--panel2);color:var(--accent);font-weight:600}
tr:nth-child(even) td{background:var(--panel)}
.file{color:var(--green);font-family:Consolas,monospace;font-size:12.5px;white-space:nowrap}
.panel{background:var(--panel);border:1px solid var(--border);border-radius:10px;padding:18px 22px;margin:16px 0}
.note{border-left:4px solid var(--blue);background:#10202b;padding:12px 16px;border-radius:0 8px 8px 0;margin:16px 0}
.warn{border-left:4px solid var(--red);background:#2a1416;padding:12px 16px;border-radius:0 8px 8px 0;margin:16px 0}
.ok{border-left:4px solid var(--green);background:#10241a;padding:12px 16px;border-radius:0 8px 8px 0;margin:16px 0}
.flow{display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin:18px 0;font-size:13px}
.box{background:var(--panel2);border:1px solid var(--border);border-radius:8px;padding:10px 14px;text-align:center;min-width:90px}
.box b{display:block;color:var(--accent);font-size:13px}
.box small{color:var(--muted);font-size:11px}
.arrow{color:var(--accent);font-size:20px;font-weight:bold}
.tag{display:inline-block;font-size:11px;padding:1px 7px;border-radius:10px;margin-left:6px;vertical-align:middle}
.tag.exist{background:#10241a;color:var(--green);border:1px solid #2ea043}
.tag.new{background:#2a2410;color:var(--accent);border:1px solid #9e7e1e}
.legend{font-size:12px;color:var(--muted);margin-top:8px}
ul{margin:8px 0;padding-left:22px}
li{margin:5px 0}
.k{color:var(--accent)}
hr{border:none;border-top:1px solid var(--border);margin:40px 0}
.small{font-size:12px;color:var(--muted)}
</style>
</head>
<body>
<div class="wrap">
<h1>지리정보 · 측점 · 프레임 맵핑 구조</h1>
<p class="sub">abcvideo — 시간 기준 → <b>Station(측점) 기준 재생</b> 전환을 위한 데이터 맵핑 정리 · 2026-06-17</p>
<div class="note">
<b>한 줄 요약</b> — 영상과 지리정보를 잇는 단 하나의 키는 <b>프레임 번호(<code>frame_cnt</code>)</b>다.
측점 기준 재생이란 결국 <b>측점 → 대표 프레임 → <code>currentTime = frame / fps</code></b> 로 seek 하는 역방향 흐름이다.
</div>
<!-- ───────────────────────────── 1. 전체 흐름 ───────────────────────────── -->
<h2>1. 전체 맵핑 흐름</h2>
<div class="flow">
<div class="box"><b>영상 프레임</b><small>currentTime</small></div>
<span class="arrow"></span>
<div class="box"><b>frame_cnt</b><small>드론 CSV 행</small></div>
<span class="arrow"></span>
<div class="box"><b>드론 자세</b><small>lat·lon·alt·yaw·pitch·roll</small></div>
<span class="arrow"></span>
<div class="box"><b>투영</b><small>핀홀 카메라</small></div>
<span class="arrow"></span>
<div class="box"><b>측점·POI·중심선</b><small>화면 픽셀(0~1)</small></div>
</div>
<p class="legend">화살표는 현재 구현된 <b>시간 → 지리정보(표시)</b> 단방향. Station 기준 재생은 이 화살표를 <span class="k">반대로</span> 타는 것 (§5).</p>
<pre><code>geo(lat, lon, z)
└─ 위경도+표고를 월드 ENU(m) 좌표로 변환 (원점 = 첫 측점)
└─ 드론(카메라) 위치를 빼서 상대 벡터
└─ 카메라 회전 적용 R_w2c = R_align · R_b2w(yaw,pitch,roll)ᵀ
└─ 핀홀 투영 px = 0.5 + (Xc/Zc)·(f/sensorW)
py = 0.5 + (Yc/Zc)·(f/sensorH)
└─ 화면 정규화 좌표 (0~1) → 캔버스 오버레이</code></pre>
<!-- ───────────────────────────── 2. 파일 맵핑 ───────────────────────────── -->
<h2>2. 어떤 파일이 무엇을 맵핑하나</h2>
<h3>데이터 소스 (입력)</h3>
<p class="small">위치: <code>GEO_DATA_DIR</code> (기본 <code>samplevideo/</code>). 인코딩은 EUC-KR/UTF-8 자동 감지.</p>
<table>
<tr><th>파일</th><th>역할</th><th>핵심 컬럼 / 키</th><th>로더</th></tr>
<tr>
<td class="file">*회덕*.csv<br>(POI·측점 제외)</td>
<td>드론 비행 로그 — <b>영상↔지리 연결의 본체</b>. SRT 자막에서 추출된 프레임별 GPS·자세.</td>
<td><code>frame_cnt</code>, latitude, longitude, altitude, yaw, pitch, roll, focal_len</td>
<td class="file">geoMatch.ts<br>loadFrames()</td>
</tr>
<tr>
<td class="file">building/<br>*POI*위경도*.csv</td>
<td>지장물·건물·터널·교량·역사. (<code>_타원체고</code> 버전 우선 사용)</td>
<td>title, category_clean, lat, lon, z</td>
<td class="file">geoMatch.ts<br>loadPois()</td>
</tr>
<tr>
<td class="file">building/<br>*측점*위경도*.csv</td>
<td><b>측점</b> — km측점(예 <code>12K345</code>). type=<code>station</code>.</td>
<td>title, lat, lon, z</td>
<td class="file">geoMatch.ts<br>loadPois()</td>
</tr>
<tr>
<td class="file">pythonsource/<br>input/center.csv</td>
<td>선로 중심선 224점 — 측점/POI의 표고(z) 스냅 기준선.</td>
<td>lat(1), lon(2), 타원체고 h(5)</td>
<td class="file">geoMatch.ts<br>loadCenterline()</td>
</tr>
<tr>
<td class="file">*.srt</td>
<td>terrain offset(abs_alt rel_alt) 산출용. 현재 로드만 하고 거의 미사용.</td>
<td>rel_alt, abs_alt</td>
<td class="file">geoMatch.ts<br>loadTerrainOffset()</td>
</tr>
</table>
<h3>처리 / 변환 (로직)</h3>
<table>
<tr><th>파일</th><th>책임</th></tr>
<tr><td class="file">server/src/services/geoMatch.ts</td>
<td>CSV 로드(싱글턴 캐시) · 평면근사 ENU 투영 · <code>findFramesForPoi</code>(이름→프레임) · <code>findPoisForFrame</code>(프레임→POI) · <code>getWorldOrigin</code>(원점=첫 측점) · <code>stationOrder</code>(km 정렬)</td></tr>
<tr><td class="file">server/src/routes/geo.ts</td>
<td>REST: <code>/api/geo/pois</code> · <code>/search</code> · <code>/frame/:n</code> · <code>/frames</code> · <code>/centerline</code></td></tr>
<tr><td class="file">client/src/utils/geoProjection.ts</td>
<td><b>정밀 투영</b> — EPSG:5186 TM(proj4), Python <code>advanced_tuner_v2.py</code>와 동일 회전식. 오버레이가 사용.</td></tr>
</table>
<h3>표시 / 조작 (UI)</h3>
<table>
<tr><th>파일</th><th>역할</th></tr>
<tr><td class="file">client/.../overlay/StationOverlay.tsx</td>
<td>실시간 캔버스 오버레이. 전 프레임 픽셀 위치를 <code>Map&lt;frameNum,…&gt;</code>로 사전계산 → RAF 루프에서 조회·보간·EMA 스무딩 후 그림.</td></tr>
<tr><td class="file">client/.../geo/StationVerify.tsx</td>
<td><b>측점 탭</b>. 측점 목록 클릭 → <code>/api/geo/search</code> → 최적 프레임으로 seek. <span class="tag exist">측점 재생의 토대</span></td></tr>
<tr><td class="file">client/.../geo/GeoSearch.tsx</td>
<td>지리정보 탭. 이름→프레임 검색 + 현재 프레임→보이는 건물 역조회.</td></tr>
<tr><td class="file">client/.../player/VideoPlayer.tsx</td>
<td>frame↔time 변환의 단일 출처. <code>frame = round(currentTime·VIDEO_FPS)</code>, seek는 <code>currentTime(frame/VIDEO_FPS)</code>.</td></tr>
<tr><td class="file">client/src/App.tsx</td>
<td>우측 사이드바 탭(주석/지리정보/측점) 배선. <code>onSeekToFrame={f =&gt; handleSeek(f/fps)}</code>.</td></tr>
</table>
<!-- ───────────────────────────── 3. 프레임 = 조인 키 ───────────────────────────── -->
<h2>3. 프레임 번호 = 모든 맵핑의 조인 키</h2>
<div class="panel">
<pre><code>// 영상 시간 ↔ 프레임 (CFR 가정, 누적연산 금지)
time = frame / fps
frame = round(currentTime * fps)
// fps 값이 코드에 두 개 존재
client VIDEO_FPS = 30000/1001 = 29.97 // VideoPlayer.tsx, StationOverlay.tsx
server DEFAULT_FPS = 30 // geoMatch.ts (FrameMatch.time 계산용)</code></pre>
</div>
<div class="warn">
<b>주의 — fps 불일치(29.97 vs 30).</b> seek는 프레임 번호로 하므로 실질 오차는 작지만,
서버 <code>FrameMatch.time</code> 값은 30 기준이라 부정확. 측점 기준 타임라인에서 <b>시간(초)</b>을 직접 쓰려면 반드시 <code>29.97</code>로 통일할 것.
</div>
<!-- ───────────────────────────── 4. 두 방향 질의 ───────────────────────────── -->
<h2>4. 현재 제공되는 두 방향 질의</h2>
<table>
<tr><th>방향</th><th>함수 / API</th><th>동작</th><th>UI</th></tr>
<tr>
<td><b>이름 → 프레임</b></td>
<td class="file">findFramesForPoi()<br>GET /api/geo/search?q=</td>
<td>측점/건물명 검색 → 전 프레임 투영 → FOV 안 프레임 수집 → 연속구간 그룹화(GAP=30프레임) → 구간별 가장 중심에 가까운 프레임 1개 선택</td>
<td>StationVerify / GeoSearch</td>
</tr>
<tr>
<td><b>프레임 → 이름</b></td>
<td class="file">findPoisForFrame()<br>GET /api/geo/frame/:n</td>
<td>해당 프레임 드론 자세로 전 POI 투영 → FOV 안 목록 반환 (거리순)</td>
<td>GeoSearch 역조회</td>
</tr>
</table>
<div class="ok">
<b>측점 기준 재생의 핵심 빌딩블록은 이미 있다.</b>
<code>findFramesForPoi(측점명)</code><span class="k">측점 → 대표 프레임</span> 을, <code>onSeekToFrame</code><span class="k">프레임 → seek</span> 를 담당.
남은 일은 이를 <b>전 측점에 대해 한 번에</b> 묶어 km순 타임라인으로 만드는 것뿐.
</div>
<!-- ───────────────────────────── 5. Station 기준 재생 ───────────────────────────── -->
<h2>5. Station(측점) 기준 재생 설계</h2>
<h3>현재(시간축) vs 목표(측점축)</h3>
<table>
<tr><th></th><th>현재 — 시간 기준</th><th>목표 — 측점 기준</th></tr>
<tr><td>타임라인 단위</td><td>초 / 프레임</td><td>측점 (km측점, <code>stationOrder</code> 순)</td></tr>
<tr><td>탐색 동작</td><td>seek bar 드래그</td><td>측점 선택 → 그 측점이 가장 잘 보이는 프레임으로 점프</td></tr>
<tr><td>"다음" 동작</td><td>+N초</td><td>다음 km측점의 대표 프레임</td></tr>
<tr><td>핵심 데이터</td><td>frame ↔ time</td><td><b>측점 → 대표 프레임</b> 인덱스 테이블</td></tr>
</table>
<h3>제안 흐름</h3>
<div class="flow">
<div class="box"><b>측점 N개</b><small>/api/geo/pois (station)</small></div>
<span class="arrow"></span>
<div class="box"><b>측점→프레임</b><small>findFramesForPoi ×N</small></div>
<span class="arrow"></span>
<div class="box"><b>km순 정렬</b><small>stationOrder</small></div>
<span class="arrow"></span>
<div class="box"><b>측점 타임라인</b><small>[{측점, frame, time}]</small></div>
<span class="arrow"></span>
<div class="box"><b>seek</b><small>currentTime=frame/29.97</small></div>
</div>
<h3>측점 인덱스 데이터 모델</h3>
<pre><code>interface StationCue {
title: string; // "12K345"
km: number; // stationOrder()/1000 = 12.345
frame: number; // findFramesForPoi 대표 프레임 (구간 중심)
time: number; // frame / 29.97 ← fps 통일 필수
distance: number; // 카메라~측점 수평거리 (정확도 참고)
pixelX: number; pixelY: number; // 화면 내 위치 (품질 판단)
}
type StationTimeline = StationCue[]; // km 오름차순</code></pre>
<h3>구현 옵션</h3>
<div class="panel">
<p><b>A. 클라이언트 일괄 호출</b> <span class="tag exist">서버 변경 없음</span><br>
측점 목록을 받아 측점마다 <code>/api/geo/search</code> 를 호출(또는 1회 Promise.all)해 <code>StationCue[]</code> 구성.
StationVerify 가 이미 측점별 단건으로 하는 일을 전체로 확장하는 수준. 측점 수가 수십~수백이면 호출 비용 고려.</p>
</div>
<div class="panel">
<p><b>B. 서버 일괄 인덱스 API 신설</b> <span class="tag new">권장</span><br>
<code>GET /api/geo/station-index</code> — 서버가 전 측점에 대해 <code>findFramesForPoi</code> 를 한 번에 돌려
<code>StationCue[]</code>(km순)를 반환. 프레임/POI 캐시가 이미 싱글턴이라 추가 I/O 없음.
클라는 1회 호출로 측점 타임라인 확보 → 측점 슬라이더/리스트로 바로 재생.</p>
</div>
<h3>재생 UI 결선 (기존 자산 재사용)</h3>
<pre><code>// 이미 존재: 측점 클릭 → seek (StationVerify.tsx → App.tsx)
onSeekToFrame = (frame) =&gt; handleSeek(frame / (fps || 30)); // fps=29.97 통일
// 추가: 측점 슬라이더 / 이전·다음 측점 버튼
nextStation() → timeline[idx+1].frame → onSeekToFrame
prevStation() → timeline[idx-1].frame → onSeekToFrame
// 현재 재생 위치 → 현재 측점 역표시 (선택)
currentFrame → timeline 에서 가장 가까운 cue → "현재 12K345 부근"</code></pre>
<div class="note">
<b>설계 포인트</b>
<ul>
<li>측점의 표고(z)는 투영 전 <b>가장 가까운 중심선 점</b>에 스냅됨(<code>nearestCL</code>). 대표 프레임 산출도 동일 기준이라 표시와 재생이 일치.</li>
<li>대표 프레임은 "구간 중 가장 중심에 가까운" 프레임 — 측점이 화면 정중앙에 오는 시점. 측점 기준 재생의 자연스러운 정지점.</li>
<li>드론이 한 측점을 여러 번 지나가면 <code>findFramesForPoi</code> 가 여러 구간을 반환 → 측점 1개에 cue 여러 개 가능. km 타임라인은 대표 1개, 나머지는 보조 점프로.</li>
</ul>
</div>
<hr>
<h2>후속 / 결정 필요</h2>
<ul>
<li><b>fps 통일</b> — 서버 <code>DEFAULT_FPS=30</code><code>30000/1001</code>. 측점 시간축 도입 전 선행.</li>
<li><b>인덱스 위치</b> — 옵션 A(클라 일괄) vs B(서버 <code>/station-index</code>). 측점 수·반응성으로 결정.</li>
<li><b>투영 일원화</b> — 서버(평면근사)와 클라(EPSG:5186 정밀)가 다름. 대표 프레임 정확도를 위해 서버도 클라 방식으로 맞출지 검토.</li>
</ul>
<p class="small">생성: Claude Code · 소스 기준 docs/history/2026-06-17_지리정보-측점-프레임-맵핑구조-분석.md</p>
</div>
</body>
</html>

View File

@@ -0,0 +1,53 @@
# 2026-04-02 프레임 동기화, 렌더링 최적화, 터널링
## 작업 개요
- 프레임 번호 불일치(Python vs abcvideo) 원인 분석 및 수정
- StationOverlay 렌더링 끊김 개선
- 외부 접속 터널링(cloudflared) 설정
- 빨간선 하단 클리핑 수정
- 좌표계/카메라 파라미터 시스템 설명
## 완료 항목
### 프레임 동기화 수정
- **원인**: VFC 측정에서 첫 프레임 카운트 포함 → 29.97fps 영상이 31fps로 오감지
- 29.97fps → 1초에 30프레임, elapsed≈1.001s, frameCount=31 → round(31/1.001)=**31fps**
- `useFrameStep.ts`: VFC 첫 호출은 startTime만 기록, 카운트 제외
- `VideoPlayer.tsx`: 프레임 표시를 `VIDEO_FPS=30000/1001(29.97)`로 계산 → Python SRT FrameCnt와 일치
- `StationOverlay.tsx`: 드론 프레임 매칭을 `currentTime`(초) 기반으로 변경 (fps 오감지 무관)
### 빨간선 하단 클리핑 수정
- 기존: 한쪽 끝점만 화면 밖이어도 세그먼트 전체 스킵
- 수정: Cohen-Sutherland trivial reject (양쪽 다 같은 방향 밖일 때만 스킵)
- SCREEN_M: 30 → 200으로 증가
### StationOverlay 렌더링 최적화
- 드론 경로(흰색 선) 완전 제거
- 좌표 변환(224점 proj4)을 useEffect로 이동 → 드론 프레임 변경 시만 실행
- RAF 루프: 캐시된 픽셀 좌표만 읽어 draw (계산 없음 → 60fps 유지)
- ResizeObserver로 캔버스 크기 별도 관리
### 기본값 ON
- `showStations` 초기값 `false``true`
### 터널링
- `vite.config.ts`: `allowedHosts: true` 추가
- cloudflared 명령: `cloudflared tunnel --url http://localhost:5173`
- PowerShell 환경변수 설정: `$env:VIDEOS_DIR="..."; npm run dev:server`
### 좌표 시스템 설명
- 타원체고(h) = 표고(H) + 지오이드고(N=25.449m) 설명
- EPSG:5186 TM 투영 원리
- center.csv vs 측점_XY값.csv 역할 차이
- 드론 카메라 회전 행렬(Rz×Rx×Ry) → 핀홀 투영 전체 흐름 설명
## 기술 부채 / 남은 이슈
- 재생 중 끊김이 완전히 제거되지 않음 (데이터 자체가 29.97fps 이산 → 근본적 한계)
- server-side geoMatch.ts는 여전히 구형 ENU 근사 사용 (클라이언트 렌더링에는 미사용)
## Git
- 커밋: `30bec66` (frontend 브랜치)
- push 완료
**소요 시간**: 180분
**Context 사용량**: input 180k / output 18k tokens

View File

@@ -0,0 +1,69 @@
# 지리정보 + 측점 + 프레임 맵핑 구조 분석
**작업일**: 2026-06-17
**작업명**: 지리정보·측점·프레임 맵핑 구조 소스 분석 + station 기준 재생 HTML 문서화
**소요 시간**: 18분
**Context 사용량**: input 60k / output 8k tokens
**이슈**: #0
---
## 목적
영상 프레임 ↔ 측점 ↔ 지리정보(POI/중심선)가 현재 소스에서 어떻게 맵핑되는지 코드 기반으로 파악. 추가로 "시간 기준이 아닌 station(측점) 기준 재생"을 위한 맵핑 구조를 HTML 문서로 출력.
## 분석 대상 파일
- [server/src/services/geoMatch.ts](../../server/src/services/geoMatch.ts) — 서버 투영/매칭 (평면근사 ENU)
- [server/src/routes/geo.ts](../../server/src/routes/geo.ts) — geo API 엔드포인트
- [client/src/utils/geoProjection.ts](../../client/src/utils/geoProjection.ts) — 클라 투영 (EPSG:5186 TM, Python advanced_tuner_v2.py 동일)
- [client/src/components/overlay/StationOverlay.tsx](../../client/src/components/overlay/StationOverlay.tsx) — 실시간 캔버스 오버레이
- [client/src/components/geo/StationVerify.tsx](../../client/src/components/geo/StationVerify.tsx) — 측점→프레임 점프 검증 패널
- [client/src/components/player/VideoPlayer.tsx](../../client/src/components/player/VideoPlayer.tsx) — frame↔time 변환
## 핵심 결론
### 맵핑 키 = 프레임 번호
- 드론 CSV의 `frame_cnt` = 영상 프레임 ↔ GPS+자세 연결고리
- `time = frame / fps`
- **fps 불일치 발견**: 클라 `VIDEO_FPS = 30000/1001 = 29.97`, 서버 `DEFAULT_FPS = 30`. seek는 frame번호 기반이라 실害 적으나 서버 `FrameMatch.time` 부정확.
### 데이터 소스 (GEO_DATA_DIR 기본 samplevideo/)
1. `*회덕*.csv` — 드론 비행로그 (frame_cnt, lat, lon, alt, yaw, pitch, roll, focal_len)
2. `building/*POI*위경도*.csv` — 지장물 (타원체고 버전 우선)
3. `building/*측점*위경도*.csv` — 측점
4. `pythonsource/input/center.csv` — 선로 중심선 224점 (타원체고 col5)
5. `*.srt` — terrain offset (로드만, 거의 미사용)
### 투영 파이프라인
```
geo(lat,lon,z) → ENU world(m) → 카메라 상대벡터 → 회전(yaw/pitch/roll) → 핀홀 투영 px,py(0~1)
```
- 월드 원점 = **첫 측점** (측량 기준점), 드론 frame[0] 아님 → 드론 GPS 오차 회피
- 서버/클라 투영 **구현 2벌** (서버=cos(lat) 평면근사, 클라=EPSG:5186 TM proj4) — 정밀도 차이
### 두 방향 질의
- `findFramesForPoi(name)`: 측점명 → 전 프레임 투영 → FOV 안 → 연속구간 그룹화(GAP=30) → 구간별 중심 프레임. (StationVerify 점프)
- `findPoisForFrame(n)`: 프레임 → 보이는 POI 목록.
### 클라 실시간 오버레이
- idle time에 `Map<frameNum, {stationLabels, poiMarkers}>` 사전계산
- RAF 루프: Map 조회 + frameNum→+1 보간 + EMA 스무딩 → canvas
- 측점은 투영 전 가장 가까운 중심선 점에 스냅 (nearestCL, z도 중심선값)
- smoothFrame: 드론 자세 ±N프레임 이동평균으로 GPS/IMU 노이즈 제거
### 측점 순서
- `stationOrder`: title `\d+K\d+` 파싱 = km측점 ("12K345" = 12.345km). 서버/클라 동일 함수.
## Station 기준 재생 관점
현재 구조는 "시간(프레임) → 지리정보" 단방향 표시 중심. station 기준 재생 = **측점 → 대표 프레임 → seek** 역방향이 핵심.
- 이미 존재하는 빌딩블록: `findFramesForPoi` (측점→최적 프레임), `onSeekToFrame` (프레임→`currentTime(frame/fps)`).
- station 기준 타임라인을 만들려면: 전 측점에 대해 `findFramesForPoi` 1회 일괄 호출 → 측점별 대표 프레임 테이블 구성 → km 순 정렬 → 측점 간 이동 = seek.
- 산출물: 맵핑 구조 + station 기준 재생 설계를 정리한 HTML (`docs/geo-station-mapping.html`).
## 후속 참고
- fps 불일치(30 vs 29.97) 정리 검토 가치 있음
- 서버/클라 투영 알고리즘 통일 여부 결정 필요 (현재 클라가 정밀)
- station 기준 재생용 측점→프레임 테이블 API(`/api/geo/station-index` 등) 신설 검토

View File

@@ -276,15 +276,19 @@ function loadFrames(): DroneFrame[] {
if (_frames) return _frames;
if (!_dataDir) return [];
const csvPath = fs.readdirSync(_dataDir)
.find(f => f.endsWith('.csv') && !fs.statSync(path.join(_dataDir!, f)).isDirectory()
&& !path.join(_dataDir!, f).includes('building'));
if (!fs.existsSync(_dataDir)) {
console.warn(`[geo] GEO_DATA_DIR not found: ${_dataDir}. Set GEO_DATA_DIR env var.`);
return [];
}
// 드론 CSV는 data dir 바로 아래 (building 폴더 제외)
const files = fs.readdirSync(_dataDir).filter(f =>
f.endsWith('.csv') && f.includes('회덕') && !f.includes('POI') && !f.includes('측점')
);
if (!files.length) return [];
if (!files.length) {
console.warn(`[geo] No drone CSV (containing '회덕') found in: ${_dataDir}`);
return [];
}
const rows = readCsvUtf8(path.join(_dataDir, files[0]));
const header = rows[0].map(h => h.trim().replace(/^\uFEFF/, ''));
@@ -309,7 +313,10 @@ function loadPois(): GeoPoint[] {
if (!_dataDir) return [];
const buildingDir = path.join(_dataDir, 'building');
if (!fs.existsSync(buildingDir)) return [];
if (!fs.existsSync(buildingDir)) {
console.warn(`[geo] building/ dir not found: ${buildingDir}`);
return [];
}
const result: GeoPoint[] = [];