Files
dronevideoplayer/docs/geo-station-mapping.html
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

273 lines
16 KiB
HTML
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.
<!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>