스테이션바/측점(체이니지) - 역 재진입 마커 누락 수정: 측점기준 탐지에 좌표근접(pxPassesTo) 합집합 보강(중복 24px 제거) - kmExists 판정: 좌표 반경만으로 잡힌 통과는 측점값 라벨/검색에서 제외(마커 동그라미는 유지) - 측점값 라벨 겹침 숨김(좌측 우선), 동일항목 브래킷 클러스터 - 측점 검색 순환(통과방향 다음→끝에서 처음), 마커 기준 후보 산출, 입력값 유지/blur 삭제 - 연속 체이니지 수직투영 유틸 분리(client/src/utils/chainage.ts), Minimap 컴포넌트 추가 영상 컨트롤 UI - 선형/드론궤적/좌측패널 토글을 재생바로 통합(settingsStore 공유·persist) - 드론 GPS·고도 HUD 상시 표시, 경로표고·투명도 항상 노출, 겹침제외 버튼 숨김 - 재생바 요소 재배치(좌측패널→선형→드론궤적→드론위치정보→시설등급), 활성색 amber 통일 문서/도구 - 종합 기술명세서(인계+특허 기초) md/html/pdf 3종, UI개선 보고서 3종 - md→html→pdf 변환 스크립트(scripts/md2docs.sh) + 스타일(docs/assets/report.css) - 작업 히스토리 다수 추가(docs/history/) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
835 lines
73 KiB
HTML
835 lines
73 KiB
HTML
<!DOCTYPE html>
|
||
<html xmlns="http://www.w3.org/1999/xhtml" lang xml:lang>
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<meta name="generator" content="pandoc" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes" />
|
||
<title>기술명세_GhiVideo_종합기술문서</title>
|
||
<style>
|
||
code{white-space: pre-wrap;}
|
||
span.smallcaps{font-variant: small-caps;}
|
||
span.underline{text-decoration: underline;}
|
||
div.column{display: inline-block; vertical-align: top; width: 50%;}
|
||
div.hanging-indent{margin-left: 1.5em; text-indent: -1.5em;}
|
||
ul.task-list{list-style: none;}
|
||
pre > code.sourceCode { white-space: pre; position: relative; }
|
||
pre > code.sourceCode > span { display: inline-block; line-height: 1.25; }
|
||
pre > code.sourceCode > span:empty { height: 1.2em; }
|
||
code.sourceCode > span { color: inherit; text-decoration: inherit; }
|
||
div.sourceCode { margin: 1em 0; }
|
||
pre.sourceCode { margin: 0; }
|
||
@media screen {
|
||
div.sourceCode { overflow: auto; }
|
||
}
|
||
@media print {
|
||
pre > code.sourceCode { white-space: pre-wrap; }
|
||
pre > code.sourceCode > span { text-indent: -5em; padding-left: 5em; }
|
||
}
|
||
pre.numberSource code
|
||
{ counter-reset: source-line 0; }
|
||
pre.numberSource code > span
|
||
{ position: relative; left: -4em; counter-increment: source-line; }
|
||
pre.numberSource code > span > a:first-child::before
|
||
{ content: counter(source-line);
|
||
position: relative; left: -1em; text-align: right; vertical-align: baseline;
|
||
border: none; display: inline-block;
|
||
-webkit-touch-callout: none; -webkit-user-select: none;
|
||
-khtml-user-select: none; -moz-user-select: none;
|
||
-ms-user-select: none; user-select: none;
|
||
padding: 0 4px; width: 4em;
|
||
color: #aaaaaa;
|
||
}
|
||
pre.numberSource { margin-left: 3em; border-left: 1px solid #aaaaaa; padding-left: 4px; }
|
||
div.sourceCode
|
||
{ }
|
||
@media screen {
|
||
pre > code.sourceCode > span > a:first-child::before { text-decoration: underline; }
|
||
}
|
||
code span.al { color: #ff0000; font-weight: bold; } /* Alert */
|
||
code span.an { color: #60a0b0; font-weight: bold; font-style: italic; } /* Annotation */
|
||
code span.at { color: #7d9029; } /* Attribute */
|
||
code span.bn { color: #40a070; } /* BaseN */
|
||
code span.bu { } /* BuiltIn */
|
||
code span.cf { color: #007020; font-weight: bold; } /* ControlFlow */
|
||
code span.ch { color: #4070a0; } /* Char */
|
||
code span.cn { color: #880000; } /* Constant */
|
||
code span.co { color: #60a0b0; font-style: italic; } /* Comment */
|
||
code span.cv { color: #60a0b0; font-weight: bold; font-style: italic; } /* CommentVar */
|
||
code span.do { color: #ba2121; font-style: italic; } /* Documentation */
|
||
code span.dt { color: #902000; } /* DataType */
|
||
code span.dv { color: #40a070; } /* DecVal */
|
||
code span.er { color: #ff0000; font-weight: bold; } /* Error */
|
||
code span.ex { } /* Extension */
|
||
code span.fl { color: #40a070; } /* Float */
|
||
code span.fu { color: #06287e; } /* Function */
|
||
code span.im { } /* Import */
|
||
code span.in { color: #60a0b0; font-weight: bold; font-style: italic; } /* Information */
|
||
code span.kw { color: #007020; font-weight: bold; } /* Keyword */
|
||
code span.op { color: #666666; } /* Operator */
|
||
code span.ot { color: #007020; } /* Other */
|
||
code span.pp { color: #bc7a00; } /* Preprocessor */
|
||
code span.sc { color: #4070a0; } /* SpecialChar */
|
||
code span.ss { color: #bb6688; } /* SpecialString */
|
||
code span.st { color: #4070a0; } /* String */
|
||
code span.va { color: #19177c; } /* Variable */
|
||
code span.vs { color: #4070a0; } /* VerbatimString */
|
||
code span.wa { color: #60a0b0; font-weight: bold; font-style: italic; } /* Warning */
|
||
.display.math{display: block; text-align: center; margin: 0.5rem auto;}
|
||
</style>
|
||
<style type="text/css">@page {
|
||
size: A4;
|
||
margin: 18mm 16mm 16mm 16mm;
|
||
@bottom-center {
|
||
content: counter(page) " / " counter(pages);
|
||
font-family: "Malgun Gothic", sans-serif;
|
||
font-size: 9pt;
|
||
color: #999;
|
||
}
|
||
}
|
||
html { font-size: 11pt; }
|
||
body {
|
||
font-family: "Malgun Gothic", "Hancom Gothic", sans-serif;
|
||
color: #23272e;
|
||
line-height: 1.65;
|
||
max-width: 920px;
|
||
margin: 0 auto;
|
||
padding: 24px;
|
||
}
|
||
h1 {
|
||
font-size: 1.7rem;
|
||
color: #b45309;
|
||
border-bottom: 3px solid #f59e0b;
|
||
padding-bottom: 8px;
|
||
margin: 0 0 4px;
|
||
}
|
||
h2 {
|
||
font-size: 1.25rem;
|
||
color: #b45309;
|
||
border-bottom: 1px solid #e5d3b3;
|
||
padding-bottom: 5px;
|
||
margin-top: 1.6em;
|
||
}
|
||
h3 { font-size: 1.05rem; color: #92400e; margin-top: 1.1em; }
|
||
a { color: #b45309; }
|
||
hr { border: none; border-top: 1px solid #e2e2e2; margin: 1.6em 0; }
|
||
ul { padding-left: 1.25em; }
|
||
li { margin: 0.18em 0; }
|
||
strong { color: #1f2937; }
|
||
code {
|
||
font-family: "D2Coding", Consolas, monospace;
|
||
background: #f4f1ea;
|
||
border: 1px solid #e7e0d2;
|
||
border-radius: 3px;
|
||
padding: 0.5px 5px;
|
||
font-size: 0.92em;
|
||
}
|
||
table {
|
||
border-collapse: collapse;
|
||
width: 100%;
|
||
margin: 0.8em 0;
|
||
font-size: 0.95em;
|
||
}
|
||
th, td { border: 1px solid #d8d2c4; padding: 6px 10px; text-align: left; vertical-align: top; }
|
||
th { background: #fdf3df; color: #7c2d12; }
|
||
blockquote {
|
||
border-left: 4px solid #f59e0b;
|
||
margin: 0.8em 0;
|
||
padding: 0.2em 0 0.2em 14px;
|
||
color: #555;
|
||
background: #fffbf2;
|
||
}
|
||
|
||
h1, h2, h3 { break-after: avoid; }
|
||
</style>
|
||
<!--[if lt IE 9]>
|
||
<script src="//cdnjs.cloudflare.com/ajax/libs/html5shiv/3.7.3/html5shiv-printshiv.min.js"></script>
|
||
<![endif]-->
|
||
</head>
|
||
<body>
|
||
<header id="title-block-header">
|
||
<h1 class="title">기술명세_GhiVideo_종합기술문서</h1>
|
||
</header>
|
||
<h1 id="ghivideo-종합-기술-명세서">GhiVideo 종합 기술 명세서</h1>
|
||
<blockquote>
|
||
<p><strong>목적</strong>: 본 문서는 (1) <strong>특허 출원/발명 신고</strong>의 기초 자료와 (2) <strong>후임 개발자 인계·업그레이드</strong> 자료를 겸한다. <strong>대상 시스템</strong>: GhiVideo — 시간축이 아니라 <strong>노선 위치(측점·체이니지)</strong> 를 1차 좌표로 삼아 드론 주행영상을 색인·탐색하고, 프레임별 카메라 자세로 측점·POI·선로중심선을 영상 위에 실시간 정합(geo-registration)하는 철도 주행영상 분석 플레이어. <strong>작성일</strong>: 2026-06-29 · 모든 소스 참조는 <code>[경로](경로#Lxx)</code> 형식(클릭 가능).</p>
|
||
</blockquote>
|
||
<hr />
|
||
<h2 id="0-문서-구성과-읽는-법">0. 문서 구성과 읽는 법</h2>
|
||
<table>
|
||
<thead>
|
||
<tr class="header">
|
||
<th>장</th>
|
||
<th>내용</th>
|
||
<th>주 독자</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr class="odd">
|
||
<td>1</td>
|
||
<td>시스템 개요·아키텍처·데이터 흐름</td>
|
||
<td>전체</td>
|
||
</tr>
|
||
<tr class="even">
|
||
<td>2</td>
|
||
<td><strong>좌표·투영 정본 파이프라인</strong> (모든 정합의 기반)</td>
|
||
<td>전체</td>
|
||
</tr>
|
||
<tr class="odd">
|
||
<td>3</td>
|
||
<td>카메라 투영 & 화각(FOV) 정합</td>
|
||
<td>개발/특허</td>
|
||
</tr>
|
||
<tr class="even">
|
||
<td>4</td>
|
||
<td>측점 표고 정합 (DEM-free) — 발명 B</td>
|
||
<td>특허</td>
|
||
</tr>
|
||
<tr class="odd">
|
||
<td>5</td>
|
||
<td>드론 자세 평활/보간 + 오버레이 렌더링</td>
|
||
<td>개발</td>
|
||
</tr>
|
||
<tr class="even">
|
||
<td>6</td>
|
||
<td>측점(체이니지) 시스템 & 스테이션바</td>
|
||
<td>개발/특허</td>
|
||
</tr>
|
||
<tr class="odd">
|
||
<td>7</td>
|
||
<td>위치보정 역투영 — 발명 C</td>
|
||
<td>특허</td>
|
||
</tr>
|
||
<tr class="even">
|
||
<td>8</td>
|
||
<td>백엔드 스트리밍/FFmpeg·데이터·상태관리</td>
|
||
<td>개발</td>
|
||
</tr>
|
||
<tr class="odd">
|
||
<td>9</td>
|
||
<td>개발 타임라인(주요 의사결정)</td>
|
||
<td>전체</td>
|
||
</tr>
|
||
<tr class="even">
|
||
<td>10</td>
|
||
<td><strong>특허 관점 종합</strong>(인벤토리·후보·청구포인트·선행기술)</td>
|
||
<td>특허</td>
|
||
</tr>
|
||
<tr class="odd">
|
||
<td>11</td>
|
||
<td>검증 결과표</td>
|
||
<td>전체</td>
|
||
</tr>
|
||
<tr class="even">
|
||
<td>12</td>
|
||
<td>후임자 인계(데이터흐름·튜닝상수·빌드·업그레이드)</td>
|
||
<td>개발</td>
|
||
</tr>
|
||
<tr class="odd">
|
||
<td>13</td>
|
||
<td>한계 / 출원 전 확인사항</td>
|
||
<td>전체</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
<hr />
|
||
<h2 id="1-시스템-개요--아키텍처">1. 시스템 개요 & 아키텍처</h2>
|
||
<h3 id="11-핵심-아이디어">1.1 핵심 아이디어</h3>
|
||
<p>드론으로 촬영한 철도 선로 주행영상의 각 프레임에는 SRT 메타데이터로 <strong>드론 위치(lat/lon/고도)·자세(yaw/pitch/roll)·초점거리</strong>가 기록된다. 한편 철도 GIS에는 측점·POI·선로 중심선의 <strong>WGS84 좌표(+표고)</strong> 가 있다. 이 둘을 <strong>핀홀 카메라 투영</strong>으로 결합하면:</p>
|
||
<ol type="1">
|
||
<li><strong>순방향 투영</strong> — "이 GIS 지점이 지금 화면 어느 픽셀에 보이는가" → 라벨/중심선/궤적 오버레이.</li>
|
||
<li><strong>역투영</strong> — "사용자가 화면에서 끈 픽셀은 어느 지리좌표인가" → POI 위치/표고 보정.</li>
|
||
<li><strong>측점 색인</strong> — 드론 GPS를 측점 폴리라인에 투영해 영상 매 시점을 <strong>측점값(체이니지)</strong> 으로 환산 → 측점 기반 탐색/스크러빙.</li>
|
||
</ol>
|
||
<h3 id="12-데이터-흐름">1.2 데이터 흐름</h3>
|
||
<pre><code>[폴더 선택: base.csv(드론프레임) + building/측점·교량·터널·역사 CSV + route.json + KMZ]
|
||
│ geoData.loadFolderGeoData (인코딩 자동감지·병렬 파싱·KMZ 우선)
|
||
▼
|
||
[geoStore] frames / stations / pois / structures / centerline / origin / routeMeta
|
||
│
|
||
├─▶ StationOverlay (영상 위 AR 오버레이)
|
||
│ precompute(가시성/겹침, requestIdleCallback) → RAF(연속포즈 재투영) → Canvas
|
||
│ geoProjection: WGS84→TM→ENU→카메라→핀홀
|
||
│
|
||
├─▶ StationBar (하단 측점 스크러버)
|
||
│ chainage: 드론GPS→측점폴리라인 수직투영→연속 체이니지
|
||
│ 이동거리/측점축, 방향색, 구조물 마커, 측점검색 순환
|
||
│
|
||
└─▶ VideoPlayer (Video.js + hls.js, 이중 재생경로, 프레임 정확도)</code></pre>
|
||
<h3 id="13-기술-스택">1.3 기술 스택</h3>
|
||
<ul>
|
||
<li><strong>프론트엔드</strong>: React 18 + TypeScript, Video.js 8.23 + @videojs/http-streaming, hls.js 1.6, Zustand(상태), Vite, Canvas 2D(오버레이), proj4(좌표변환), fflate(KMZ 압축해제).</li>
|
||
<li><strong>백엔드</strong>: Node.js 20 + Express, <code>child_process.spawn</code> FFmpeg 래퍼, Range Request 스트리밍, HLS 변환 + SSE 진행률.</li>
|
||
<li><strong>배포</strong>: PM2(<code>ghiVideo</code>, 포트 55000)가 <code>client/dist</code> 정적 서빙(빌드 결과물). 소스 변경 시 <code>npm run build -w client</code>(Node 20) 후 반영.</li>
|
||
</ul>
|
||
<hr />
|
||
<h2 id="2-좌표투영-정본-파이프라인--모든-정합의-기반">2. 좌표·투영 정본 파이프라인 (★ 모든 정합의 기반)</h2>
|
||
<blockquote>
|
||
<p>본 절을 <strong>정본</strong>으로 하고, 3~7장은 이를 참조한다. Python 레퍼런스 <code>advanced_tuner_v2.py</code>와 회전행렬·축정렬·정규화 공식이 1:1 동치로 이식되어 있다(<a href="client/src/utils/geoProjection.ts#L1">geoProjection.ts:1-12</a>).</p>
|
||
</blockquote>
|
||
<h3 id="21-5단계-변환">2.1 5단계 변환</h3>
|
||
<pre><code>[1] WGS84 (lat, lon, EL정표고)
|
||
│ proj4 EPSG:4326 → EPSG:5186 (한국 중부원점 TM)
|
||
▼
|
||
[2] 월드 ENU [East(m), North(m), Up(m)] (East=X, North=Y)
|
||
│ rel = target_ENU − (drone_ENU + offset)
|
||
▼
|
||
[3] 카메라좌표 [Xc, Yc, Zc] (R_w2c = R_align · R_b2w(yaw,pitch,roll)ᵀ)
|
||
│ 핀홀 + focal/sensor
|
||
▼
|
||
[4] 정규 이미지 좌표 [px, py] ∈ (0~1) (0.5 = 영상 중심)
|
||
│ object-fit:cover 변환
|
||
▼
|
||
[5] 화면 px</code></pre>
|
||
<h3 id="22-단계별-수식코드">2.2 단계별 수식·코드</h3>
|
||
<p><strong>단계 1 — WGS84 → TM</strong> (<a href="client/src/utils/geoProjection.ts#L75">geoProjection.ts:75-85</a>)</p>
|
||
<div class="sourceCode" id="cb3"><pre class="sourceCode ts"><code class="sourceCode typescript"><span id="cb3-1"><a href="#cb3-1" aria-hidden="true" tabindex="-1"></a>proj4<span class="op">.</span><span class="fu">defs</span>(<span class="st">'EPSG:5186'</span><span class="op">,</span> <span class="st">'+proj=tmerc +lat_0=38 +lon_0=127 +k=1 +x_0=200000 +y_0=600000 +ellps=GRS80 +units=m +no_defs'</span>)<span class="op">;</span></span>
|
||
<span id="cb3-2"><a href="#cb3-2" aria-hidden="true" tabindex="-1"></a><span class="kw">function</span> <span class="fu">latLonToTM</span>(lat<span class="op">,</span> lon) { const [e<span class="op">,</span> n] <span class="op">=</span> _toTM<span class="op">.</span><span class="fu">forward</span>([lon<span class="op">,</span> lat])<span class="op">;</span> return [e<span class="op">,</span> n]<span class="op">;</span> }</span></code></pre></div>
|
||
<p><strong>단계 2 — TM → ENU + 표고 datum 정합</strong> (<a href="client/src/utils/geoProjection.ts#L91">geoProjection.ts:91-97</a>, <a href="client/src/utils/geoProjection.ts#L263">263-271</a>). X/Y는 절대 TM, Z만 기준고도 대비 상대(상대벡터를 빼므로 무관). <strong>핵심: GIS 정표고(EL) + 지오이드고(N) → 타원체고</strong>로 변환해 드론 GPS 타원체고와 datum을 맞춘다.</p>
|
||
<div class="sourceCode" id="cb4"><pre class="sourceCode ts"><code class="sourceCode typescript"><span id="cb4-1"><a href="#cb4-1" aria-hidden="true" tabindex="-1"></a>const stEnu <span class="op">=</span> <span class="fu">geoToEnu</span>(targetLat<span class="op">,</span> targetLon<span class="op">,</span> targetAlt <span class="op">+</span> (params<span class="op">.</span><span class="at">geoidOffset</span> <span class="op">??</span> <span class="dv">0</span>)<span class="op">,</span> <span class="op">...</span>)<span class="op">;</span> <span class="co">// EL + N</span></span>
|
||
<span id="cb4-2"><a href="#cb4-2" aria-hidden="true" tabindex="-1"></a>const drEnuAdj <span class="op">=</span> [drEnu[<span class="dv">0</span>]<span class="op">+</span>offX<span class="op">,</span> drEnu[<span class="dv">1</span>]<span class="op">+</span>offY<span class="op">,</span> drEnu[<span class="dv">2</span>]<span class="op">+</span>offZ]<span class="op">;</span></span>
|
||
<span id="cb4-3"><a href="#cb4-3" aria-hidden="true" tabindex="-1"></a>const relEnu <span class="op">=</span> [stEnu[<span class="dv">0</span>]<span class="op">-</span>drEnuAdj[<span class="dv">0</span>]<span class="op">,</span> stEnu[<span class="dv">1</span>]<span class="op">-</span>drEnuAdj[<span class="dv">1</span>]<span class="op">,</span> stEnu[<span class="dv">2</span>]<span class="op">-</span>drEnuAdj[<span class="dv">2</span>]]<span class="op">;</span></span></code></pre></div>
|
||
<blockquote>
|
||
<p><strong>지오이드고 N</strong>: 대전지역 ≈ <strong>25.8m</strong>(= 84.244 − 16.853 − 41.59, KNGeoid 검증값). 초기 문서엔 25.449m로 기록된 바 있으나 본 명세는 25.8m로 통일(§11). 지역 상수이므로 타 지역 데이터셋은 재튜닝 필요.</p>
|
||
</blockquote>
|
||
<p><strong>단계 3 — ENU → 카메라좌표</strong> (<a href="client/src/utils/geoProjection.ts#L275">geoProjection.ts:275-295</a>). <code>R_b2w = Rz(−yaw)·Rx(pitch)·Ry(roll)</code>, <code>R_w2c = R_align·R_b2wᵀ</code>, <code>R_align=[[1,0,0],[0,0,−1],[0,1,0]]</code>. 행렬을 명시 생성하지 않고 전치(행=열)·부호치환으로 직접 계산:</p>
|
||
<div class="sourceCode" id="cb5"><pre class="sourceCode ts"><code class="sourceCode typescript"><span id="cb5-1"><a href="#cb5-1" aria-hidden="true" tabindex="-1"></a><span class="kw">function</span> <span class="fu">applyRw2c</span>(b2w<span class="op">,</span> rel) {</span>
|
||
<span id="cb5-2"><a href="#cb5-2" aria-hidden="true" tabindex="-1"></a> return {</span>
|
||
<span id="cb5-3"><a href="#cb5-3" aria-hidden="true" tabindex="-1"></a> Xc<span class="op">:</span> b2w[<span class="dv">0</span>][<span class="dv">0</span>]<span class="op">*</span>rel[<span class="dv">0</span>] <span class="op">+</span> b2w[<span class="dv">1</span>][<span class="dv">0</span>]<span class="op">*</span>rel[<span class="dv">1</span>] <span class="op">+</span> b2w[<span class="dv">2</span>][<span class="dv">0</span>]<span class="op">*</span>rel[<span class="dv">2</span>]<span class="op">,</span> <span class="co">// col0</span></span>
|
||
<span id="cb5-4"><a href="#cb5-4" aria-hidden="true" tabindex="-1"></a> Yc<span class="op">:</span> <span class="op">-</span>(b2w[<span class="dv">0</span>][<span class="dv">2</span>]<span class="op">*</span>rel[<span class="dv">0</span>] <span class="op">+</span> b2w[<span class="dv">1</span>][<span class="dv">2</span>]<span class="op">*</span>rel[<span class="dv">1</span>] <span class="op">+</span> b2w[<span class="dv">2</span>][<span class="dv">2</span>]<span class="op">*</span>rel[<span class="dv">2</span>])<span class="op">,</span> <span class="co">// -col2</span></span>
|
||
<span id="cb5-5"><a href="#cb5-5" aria-hidden="true" tabindex="-1"></a> Zc<span class="op">:</span> b2w[<span class="dv">0</span>][<span class="dv">1</span>]<span class="op">*</span>rel[<span class="dv">0</span>] <span class="op">+</span> b2w[<span class="dv">1</span>][<span class="dv">1</span>]<span class="op">*</span>rel[<span class="dv">1</span>] <span class="op">+</span> b2w[<span class="dv">2</span>][<span class="dv">1</span>]<span class="op">*</span>rel[<span class="dv">2</span>]<span class="op">,</span> <span class="co">// col1</span></span>
|
||
<span id="cb5-6"><a href="#cb5-6" aria-hidden="true" tabindex="-1"></a> }<span class="op">;</span></span>
|
||
<span id="cb5-7"><a href="#cb5-7" aria-hidden="true" tabindex="-1"></a>}</span></code></pre></div>
|
||
<p>규약: <strong>Zc>0 = 카메라 정면</strong>(영상 깊이축), Xc=우, Yc=하. Zc≤0은 카메라 뒤 → null.</p>
|
||
<p><strong>단계 4 — 카메라좌표 → 정규 픽셀(핀홀)</strong> (<a href="client/src/utils/geoProjection.ts#L126">geoProjection.ts:126-137</a>). 영상 중심 = 0.5, 해상도 독립:</p>
|
||
<div class="sourceCode" id="cb6"><pre class="sourceCode ts"><code class="sourceCode typescript"><span id="cb6-1"><a href="#cb6-1" aria-hidden="true" tabindex="-1"></a>pxRaw <span class="op">=</span> (<span class="fl">0.5</span> <span class="op">+</span> cx0) <span class="op">+</span> (Xc<span class="op">/</span>Zc) <span class="op">*</span> (f <span class="op">/</span> sensorW)<span class="op">;</span> <span class="co">// 가로</span></span>
|
||
<span id="cb6-2"><a href="#cb6-2" aria-hidden="true" tabindex="-1"></a>pyRaw <span class="op">=</span> (<span class="fl">0.5</span> <span class="op">+</span> cy0) <span class="op">+</span> (Yc<span class="op">/</span>Zc) <span class="op">*</span> (f <span class="op">/</span> sensorH)<span class="op">;</span> <span class="co">// 세로</span></span></code></pre></div>
|
||
<p><strong>단계 5 — 정규 → 화면 px (object-fit:cover)</strong> (<a href="client/src/components/overlay/StationOverlay.tsx#L923">StationOverlay.tsx:923-933</a>). 영상 크롭 사각형에 맞춰 정합:</p>
|
||
<div class="sourceCode" id="cb7"><pre class="sourceCode ts"><code class="sourceCode typescript"><span id="cb7-1"><a href="#cb7-1" aria-hidden="true" tabindex="-1"></a>const s <span class="op">=</span> <span class="bu">Math</span><span class="op">.</span><span class="fu">max</span>(W <span class="op">/</span> vW<span class="op">,</span> H <span class="op">/</span> vH)<span class="op">;</span> <span class="co">// cover: 더 큰 배율(넘침=크롭)</span></span>
|
||
<span id="cb7-2"><a href="#cb7-2" aria-hidden="true" tabindex="-1"></a>dispW <span class="op">=</span> vW <span class="op">*</span> s<span class="op">;</span> dispH <span class="op">=</span> vH <span class="op">*</span> s<span class="op">;</span> offX <span class="op">=</span> (W <span class="op">-</span> dispW)<span class="op">/</span><span class="dv">2</span><span class="op">;</span> offY <span class="op">=</span> (H <span class="op">-</span> dispH)<span class="op">/</span><span class="dv">2</span><span class="op">;</span></span>
|
||
<span id="cb7-3"><a href="#cb7-3" aria-hidden="true" tabindex="-1"></a>const vx <span class="op">=</span> (nx) <span class="kw">=></span> offX <span class="op">+</span> nx <span class="op">*</span> dispW<span class="op">;</span> const vy <span class="op">=</span> (ny) <span class="kw">=></span> offY <span class="op">+</span> ny <span class="op">*</span> dispH<span class="op">;</span></span></code></pre></div>
|
||
<h3 id="23-두-투영-경로의-의도적-분리">2.3 두 투영 경로의 의도적 분리</h3>
|
||
<ul>
|
||
<li><strong>렌더(정밀)</strong>: <a href="client/src/utils/geoProjection.ts">geoProjection.ts</a> — proj4 EPSG:5186 TM + 회전행렬 + geoid/포커스 보정. (3~5장)</li>
|
||
<li><strong>검색(경량)</strong>: <a href="client/src/utils/geoSearch.ts">geoSearch.ts</a> — cos-lat 근사 ENU. "현재 시야 내 POI" 또는 "특정 POI가 보이는 프레임 구간" 산출용.</li>
|
||
</ul>
|
||
<p>ENU 월드원점은 <strong>첫 측점</strong>(없으면 첫 드론프레임, 없으면 중심선 첫 점)(<a href="client/src/utils/geoSearch.ts#L117">geoSearch.ts:117-128</a>).</p>
|
||
<hr />
|
||
<h2 id="3-카메라-투영--화각fov-정합">3. 카메라 투영 & 화각(FOV) 정합</h2>
|
||
<blockquote>
|
||
<p>좌표 변환 파이프라인은 §2 참조. 본 절은 화각·내부표정 보정과 역투영을 다룬다.</p>
|
||
</blockquote>
|
||
<h3 id="31-focal--fov-관계">3.1 focal ↔︎ FOV 관계</h3>
|
||
<p>화각은 focal과 센서 치수로 결정된다(핀홀): <code>FOV = 2·atan(sensor / (2·focal))</code> (<a href="client/src/components/overlay/StationOverlay.tsx#L864">StationOverlay.tsx:864</a>). 기본값 <code>focal=24mm, sensorW=36mm(풀프레임 환산), sensorH=20.25mm(=36×9/16, 16:9)</code> (<a href="client/src/utils/geoProjection.ts#L48">geoProjection.ts:48-66</a>). 이 hFOV는 나침반/미니맵 시야부채꼴에도 사용.</p>
|
||
<h3 id="32-fovmode--세로-화각sensorh-역산-보정-">3.2 fovMode — 세로 화각(sensorH) 역산 보정 (★)</h3>
|
||
<p>라벨이 멀리/가까이에서 <strong>상하로 어긋날 때</strong>, 사용자가 POI 하나를 영상 속 실제 위치로 드래그하면 그 한 점의 대응으로 세로 화각을 역산한다. <strong>설계 핵심: 가로 focal(f/sensorW)은 건드리지 않고 sensorH만 역산</strong> → 빨간 중심선이 좌우로 틀어지지 않으면서 상하 배율만 교정.</p>
|
||
<p>투영식 <code>py = 0.5 + cy0 + (Yc/Zc)·(f/sH)</code> 를 sH에 대해 풀면 (<a href="client/src/components/overlay/StationOverlay.tsx#L1326">StationOverlay.tsx:1326-1344</a>):</p>
|
||
<div class="sourceCode" id="cb8"><pre class="sourceCode ts"><code class="sourceCode typescript"><span id="cb8-1"><a href="#cb8-1" aria-hidden="true" tabindex="-1"></a>const cc <span class="op">=</span> <span class="fu">toCameraCoords</span>(drone<span class="op">,</span> drag<span class="op">.</span><span class="at">lat</span><span class="op">,</span> drag<span class="op">.</span><span class="at">lon</span><span class="op">,</span> poiZ<span class="op">,</span> p<span class="op">,</span> wo)<span class="op">;</span></span>
|
||
<span id="cb8-2"><a href="#cb8-2" aria-hidden="true" tabindex="-1"></a>const b <span class="op">=</span> cc<span class="op">.</span><span class="at">Yc</span> <span class="op">/</span> cc<span class="op">.</span><span class="at">Zc</span><span class="op">;</span></span>
|
||
<span id="cb8-3"><a href="#cb8-3" aria-hidden="true" tabindex="-1"></a>const v <span class="op">=</span> pos<span class="op">.</span><span class="at">y</span> <span class="op">-</span> <span class="fl">0.5</span> <span class="op">-</span> (p<span class="op">.</span><span class="at">cy0</span> <span class="op">??</span> <span class="dv">0</span>)<span class="op">;</span></span>
|
||
<span id="cb8-4"><a href="#cb8-4" aria-hidden="true" tabindex="-1"></a>const sHNew <span class="op">=</span> (b <span class="op">*</span> p<span class="op">.</span><span class="at">focalLen</span>) <span class="op">/</span> v<span class="op">;</span> <span class="co">// sH = (Yc/Zc)·f / (py − 0.5 − cy0)</span></span>
|
||
<span id="cb8-5"><a href="#cb8-5" aria-hidden="true" tabindex="-1"></a><span class="fu">setParam</span>(<span class="st">'sensorH'</span><span class="op">,</span> <span class="bu">Math</span><span class="op">.</span><span class="fu">max</span>(<span class="dv">6</span><span class="op">,</span> <span class="bu">Math</span><span class="op">.</span><span class="fu">min</span>(<span class="dv">36</span><span class="op">,</span> sHNew)))<span class="op">;</span></span></code></pre></div>
|
||
<p>영상 세로 중심(v≈0)은 발산 → <code>|v|<0.03</code>이면 거부하고 안내.</p>
|
||
<h3 id="33-핵심-함수">3.3 핵심 함수</h3>
|
||
<ul>
|
||
<li><strong><code>toCameraCoords</code></strong> (<a href="client/src/utils/geoProjection.ts#L301">geoProjection.ts:301-317</a>) — GIS점→카메라좌표. 부산물 <code>distH</code>(수평 직선거리), <code>fwd/side</code>(진행방향 앞/옆 성분, 비등방 거리필터용). <code>pixelFromCamera</code>와 분리한 이유: 중심선처럼 선분 단위 근거리 Zc 클리핑·보간 후 투영해야 하기 때문.</li>
|
||
<li><strong><code>pixelFromCamera</code></strong> (<a href="client/src/utils/geoProjection.ts#L126">geoProjection.ts:126-137</a>) — 카메라좌표→정규픽셀(클램프 없음).</li>
|
||
<li><strong><code>groundPointFromPixel</code></strong> (<a href="client/src/utils/geoProjection.ts#L218">geoProjection.ts:218-251</a>) — 화면픽셀→지면 lat/lon. 광선-수평면 교차:
|
||
<div class="sourceCode" id="cb9"><pre class="sourceCode ts"><code class="sourceCode typescript"><span id="cb9-1"><a href="#cb9-1" aria-hidden="true" tabindex="-1"></a>const t <span class="op">=</span> relUpTarget <span class="op">/</span> dir[<span class="dv">2</span>]<span class="op">;</span> <span class="co">// dir = R_w2cᵀ·((px−0.5−cx0)·sW/f, (py−0.5−cy0)·sH/f, 1)</span></span>
|
||
<span id="cb9-2"><a href="#cb9-2" aria-hidden="true" tabindex="-1"></a><span class="fu">if</span> (t <span class="op"><=</span> <span class="dv">0</span>) return null<span class="op">;</span> <span class="co">// 카메라 뒤/위</span></span>
|
||
<span id="cb9-3"><a href="#cb9-3" aria-hidden="true" tabindex="-1"></a>const E <span class="op">=</span> drEnu[<span class="dv">0</span>]<span class="op">+</span>offX <span class="op">+</span> t<span class="op">*</span>dir[<span class="dv">0</span>]<span class="op">;</span> const N <span class="op">=</span> drEnu[<span class="dv">1</span>]<span class="op">+</span>offY <span class="op">+</span> t<span class="op">*</span>dir[<span class="dv">1</span>]<span class="op">;</span></span>
|
||
<span id="cb9-4"><a href="#cb9-4" aria-hidden="true" tabindex="-1"></a>const [lon<span class="op">,</span> lat] <span class="op">=</span> _toTM<span class="op">.</span><span class="fu">inverse</span>([E<span class="op">,</span> N])<span class="op">;</span></span></code></pre></div></li>
|
||
<li>관련 역투영 2종: <code>worldFromPixel</code>(슬랜트거리 유지 lat/lon/z 동시복원, <a href="client/src/utils/geoProjection.ts#L149">149-183</a>), <code>solveZForPixelY</code>(lat/lon 고정, py에 맞는 z만 1차식 역산, <a href="client/src/utils/geoProjection.ts#L194">194-211</a>). → 발명 C(§7).</li>
|
||
</ul>
|
||
<h3 id="34-보정값-영속화-데이터셋별">3.4 보정값 영속화 (데이터셋별)</h3>
|
||
<ul>
|
||
<li>카메라/내부표정·표시설정: <code>ghivideo:calib:{baseName}</code> (로드 1회, 저장 800ms debounce, <a href="client/src/components/overlay/StationOverlay.tsx#L473">StationOverlay.tsx:473-500</a>).</li>
|
||
<li>POI 위치/표고 override(DEM·드래그): <code>ghivideo:poiov:{baseName}</code> (폴더 파일 override 위에 localStorage 병합, <a href="client/src/components/overlay/StationOverlay.tsx#L502">502-524</a>).</li>
|
||
</ul>
|
||
<h3 id="35-설계-트레이드오프한계">3.5 설계 트레이드오프·한계</h3>
|
||
<ul>
|
||
<li><strong>렌즈 왜곡 미보정</strong>: 핀홀만 사용(방사/접선 왜곡 계수 없음). 분석 보조 라벨 정합엔 표준 정확도로 충분(가장자리 수 px 오차 가능).</li>
|
||
<li><strong>클라이언트 순수 계산</strong>: proj4만 의존, 서버 왕복 없이 60fps RAF에서 직접 투영.</li>
|
||
<li><strong>focal 분리 보정</strong>: 한 점 대응으로 sensorH 한 축만 역산(가로 정합 보존).</li>
|
||
<li><strong>POI 표고 가정</strong>: 실측 표고 없으면 최근접 선로 지면표고(또는 "드론고도−이격") → §4·드래그·DEM 보정.</li>
|
||
</ul>
|
||
<hr />
|
||
<h2 id="4-측점-표고-정합-dem-free-영상정합--발명-b">4. 측점 표고 정합 (DEM-free 영상정합) — 발명 B</h2>
|
||
<h3 id="41-무엇을--왜">4.1 무엇을 / 왜</h3>
|
||
<p>표시할 시설물(POI)은 주소 기반이라 <strong>표고값이 없는</strong> 경우가 대부분이다. 표고가 없으면 부각(수직각) 산출 불가 → 종래는 지면을 평면으로 가정. 그러나 노선은 실제 기복(정표고 41.6m→57.5m)이 있어 평면 가정 시 라벨이 화면 밖/지면 아래로 이탈한다. 실측에서 잘못된 고도(≈0) 사용 시 <strong>부각오차 42.7°</strong>.</p>
|
||
<p>→ 별도 DEM 취득 없이, <strong>노선을 따라 실측된 측점의 정표고</strong>를 지면고도원으로 삼아 정합한다.</p>
|
||
<h3 id="42-구성독립항-필수요소">4.2 구성(독립항 필수요소)</h3>
|
||
<ol type="1">
|
||
<li><strong>측점 표고 수열 보유</strong> — 노선 따라 실측된 복수 측점의 (lat/lon + 정표고 EL). (실데이터: 측점 47개, 41.6→57.5m)</li>
|
||
<li><strong>최근접 측점 지면고도 채택</strong> — 표고 미상 대상에 가장 가까운 측점의 정표고를 지면고도로. 프레임 진행에 따라 최근접 측점이 바뀌어 <strong>지면고도가 노선 기복을 따라 갱신</strong>.</li>
|
||
<li><strong>지오이드 datum 정합</strong> — 정표고(EL) + 지오이드고(N) → 타원체고 → 드론 GPS와 동일 기준. <code>rel_up = (targetZ + N) − droneAlt</code>.</li>
|
||
<li><strong>정합 투영</strong> — §2 파이프라인 수행.</li>
|
||
<li>(종속) <strong>대상별 표고 오프셋</strong>(poiZOffset) — 건물높이/지반차 보정, 측점·중심선엔 미적용.</li>
|
||
</ol>
|
||
<h3 id="43-검증값">4.3 검증값</h3>
|
||
<pre><code>타원체고 = 정표고(EL) + 지오이드고(N≈25.8m, 대전)
|
||
# 측점 157K900, frame0, 수평거리 91.3m
|
||
정표고≈0 → 부각 42.7° ❌
|
||
정표고 41.5 → 부각 25.1°
|
||
정표고+N≈67 → 부각 11.0° ✅ (드론 rel_alt 16.85m와 일치)</code></pre>
|
||
<ul>
|
||
<li><strong>데이터 함정(핵심)</strong>: <code>building/01)측점.csv</code>에 표고 컬럼이 둘 — <code>Z좌표</code>(로컬 ≈0) / <code>Z좌표_한국</code>(EPSG:5186 정표고 ≈41.5m). 기존 코드가 <code>Z좌표</code>(≈0)를 읽어 42.7° 오차 발생 → <code>Z좌표_한국</code> 우선으로 교정(<a href="client/src/utils/geoData.ts">geoData.ts 측점 파서</a>).</li>
|
||
<li><strong>효과</strong>: 부각오차 42.7°→<strong>11.0°</strong>, 구글어스 DEM 대비 <strong>0.2m 일치</strong>(2160p에서 ~6px). DEM 취득비용 불필요. 공개 DEM(SRTM 30m)보다 측량 측점이 더 정밀.</li>
|
||
</ul>
|
||
<hr />
|
||
<h2 id="5-드론-자세-평활보간--영상-오버레이-렌더링">5. 드론 자세 평활/보간 + 영상 오버레이 렌더링</h2>
|
||
<blockquote>
|
||
<p>두 본질 과제: <strong>(a) 자세/GPS 노이즈</strong>(단순평균은 회전 뭉갬→지연), <strong>(b) 렌더 부드러움 vs 계산비용</strong>(가시성 판정은 비싸고 60fps는 매 프레임 재투영 요구).</p>
|
||
</blockquote>
|
||
<h3 id="51-에지회전경계-보존-적응형-이동평균--smoothframe">5.1 에지(회전경계) 보존 적응형 이동평균 — <code>smoothFrame</code></h3>
|
||
<p><a href="client/src/components/overlay/StationOverlay.tsx#L577">StationOverlay.tsx:577-604</a>. 중심 <code>i</code>에서 좌우 최대 <code>halfWin</code>(기본 60fr≈2초)까지 평균하되, <strong>중심 yaw와 차이가 <code>YAW_EDGE=8°</code> 이내인 인접까지만 창 확장</strong>.</p>
|
||
<div class="sourceCode" id="cb11"><pre class="sourceCode ts"><code class="sourceCode typescript"><span id="cb11-1"><a href="#cb11-1" aria-hidden="true" tabindex="-1"></a>const YAW_EDGE <span class="op">=</span> <span class="dv">8</span><span class="op">;</span></span>
|
||
<span id="cb11-2"><a href="#cb11-2" aria-hidden="true" tabindex="-1"></a>const near <span class="op">=</span> (idx) <span class="kw">=></span> <span class="bu">Math</span><span class="op">.</span><span class="fu">abs</span>(((frames[idx]<span class="op">.</span><span class="at">yaw</span> <span class="op">-</span> yc <span class="op">+</span> <span class="dv">540</span>) <span class="op">%</span> <span class="dv">360</span>) <span class="op">-</span> <span class="dv">180</span>) <span class="op"><=</span> YAW_EDGE<span class="op">;</span></span>
|
||
<span id="cb11-3"><a href="#cb11-3" aria-hidden="true" tabindex="-1"></a><span class="fu">while</span> (lo <span class="op">></span> loMin <span class="op">&&</span> <span class="fu">near</span>(lo <span class="op">-</span> <span class="dv">1</span>)) lo<span class="op">--;</span> <span class="co">// 회전경계에서 멈춤</span></span>
|
||
<span id="cb11-4"><a href="#cb11-4" aria-hidden="true" tabindex="-1"></a><span class="fu">while</span> (hi <span class="op"><</span> hiMax <span class="op">&&</span> <span class="fu">near</span>(hi <span class="op">+</span> <span class="dv">1</span>)) hi<span class="op">++;</span></span>
|
||
<span id="cb11-5"><a href="#cb11-5" aria-hidden="true" tabindex="-1"></a><span class="co">// yaw는 원형평균: atan2(Σsin, Σcos)</span></span>
|
||
<span id="cb11-6"><a href="#cb11-6" aria-hidden="true" tabindex="-1"></a>yaw<span class="op">:</span> <span class="bu">Math</span><span class="op">.</span><span class="fu">atan2</span>(sinYaw <span class="op">/</span> n<span class="op">,</span> cosYaw <span class="op">/</span> n) <span class="op">*</span> <span class="dv">180</span> <span class="op">/</span> <span class="bu">Math</span><span class="op">.</span><span class="at">PI</span><span class="op">,</span></span></code></pre></div>
|
||
<ul>
|
||
<li>직선: 창 풀폭 → 노이즈 강하게 억제. 회전 중: 창 좁아져 <strong>즉시 추종</strong>(지연 없음). 회전 후: <strong>한 프레임씩</strong> 점진 재확장(튐 없음). 과거 '변화율 기반 축소'는 회전 종료 시 한꺼번에 재확장돼 회전 프레임이 다시 섞여 1회 튀던 부작용 → 경계보존으로 교체.</li>
|
||
</ul>
|
||
<h3 id="52-연속-프레임번호-보간-포즈--poseat">5.2 연속 프레임번호 보간 포즈 — <code>poseAt</code></h3>
|
||
<p><a href="client/src/components/overlay/StationOverlay.tsx#L670">StationOverlay.tsx:670-690</a>. 실수 프레임번호 <code>estFrame</code>에 대해 양 이웃의 평활포즈를 선형보간(이산 30fps 격자→60fps 연속). yaw는 <strong>최단각</strong> 보간:</p>
|
||
<div class="sourceCode" id="cb12"><pre class="sourceCode ts"><code class="sourceCode typescript"><span id="cb12-1"><a href="#cb12-1" aria-hidden="true" tabindex="-1"></a>let dy <span class="op">=</span> b<span class="op">.</span><span class="at">yaw</span> <span class="op">-</span> a<span class="op">.</span><span class="at">yaw</span><span class="op">;</span> dy <span class="op">=</span> ((dy <span class="op">+</span> <span class="dv">540</span>) <span class="op">%</span> <span class="dv">360</span>) <span class="op">-</span> <span class="dv">180</span><span class="op">;</span> <span class="co">// 최단각</span></span>
|
||
<span id="cb12-2"><a href="#cb12-2" aria-hidden="true" tabindex="-1"></a>return { <span class="op">...</span>a<span class="op">,</span> frame<span class="op">:</span> estFrame<span class="op">,</span> lat<span class="op">:</span><span class="fu">L</span>(a<span class="op">.</span><span class="at">lat</span><span class="op">,</span>b<span class="op">.</span><span class="at">lat</span>)<span class="op">,</span> <span class="op">...,</span> yaw<span class="op">:</span> a<span class="op">.</span><span class="at">yaw</span> <span class="op">+</span> dy<span class="op">*</span>frac }<span class="op">;</span></span></code></pre></div>
|
||
<p>O(log n) 이진탐색으로 매 RAF 호출 가능.</p>
|
||
<h3 id="53-속도적응-ema--이상치-거부--smoothstep-one-euro-사상">5.3 속도적응 EMA + 이상치 거부 — <code>smoothStep</code> (One-Euro 사상)</h3>
|
||
<p><a href="client/src/components/overlay/StationOverlay.tsx#L48">StationOverlay.tsx:48-61</a>. <strong>느릴 때(떨림)는 강하게 평활, 빠를 때(실제 팬)는 즉시 추종.</strong></p>
|
||
<div class="sourceCode" id="cb13"><pre class="sourceCode ts"><code class="sourceCode typescript"><span id="cb13-1"><a href="#cb13-1" aria-hidden="true" tabindex="-1"></a><span class="fu">if</span> (d <span class="op">></span> REJECT_DIST <span class="op">&&</span> prev<span class="op">.</span><span class="at">rej</span> <span class="op"><</span> MAX_REJECT_FRAMES) <span class="co">// 0.12 초과 점프 = 이상치 → 위치 유지</span></span>
|
||
<span id="cb13-2"><a href="#cb13-2" aria-hidden="true" tabindex="-1"></a> return { x<span class="op">:</span> prev<span class="op">.</span><span class="at">x</span><span class="op">,</span> y<span class="op">:</span> prev<span class="op">.</span><span class="at">y</span><span class="op">,</span> rej<span class="op">:</span> prev<span class="op">.</span><span class="at">rej</span> <span class="op">+</span> <span class="dv">1</span><span class="op">,</span> <span class="op">...</span> }<span class="op">;</span></span>
|
||
<span id="cb13-3"><a href="#cb13-3" aria-hidden="true" tabindex="-1"></a>const vx <span class="op">=</span> prev<span class="op">.</span><span class="at">vx</span> <span class="op">+</span> (dx <span class="op">-</span> prev<span class="op">.</span><span class="at">vx</span>) <span class="op">*</span> SMOOTH_VEL_BETA<span class="op">;</span> <span class="co">// β=0.25 속도평활(떨림 왕복은 상쇄)</span></span>
|
||
<span id="cb13-4"><a href="#cb13-4" aria-hidden="true" tabindex="-1"></a>const a <span class="op">=</span> <span class="bu">Math</span><span class="op">.</span><span class="fu">min</span>(maxAlpha<span class="op">,</span> minAlpha <span class="op">+</span> (maxAlpha <span class="op">-</span> minAlpha) <span class="op">*</span> <span class="bu">Math</span><span class="op">.</span><span class="fu">min</span>(<span class="dv">1</span><span class="op">,</span> speed<span class="op">/</span>speedRef))<span class="op">;</span></span>
|
||
<span id="cb13-5"><a href="#cb13-5" aria-hidden="true" tabindex="-1"></a>return { x<span class="op">:</span> prev<span class="op">.</span><span class="at">x</span> <span class="op">+</span> dx<span class="op">*</span>a<span class="op">,</span> y<span class="op">:</span> prev<span class="op">.</span><span class="at">y</span> <span class="op">+</span> dy<span class="op">*</span>a<span class="op">,</span> <span class="op">...</span> }<span class="op">;</span></span></code></pre></div>
|
||
<ul>
|
||
<li>이상치 거부에 <strong>연속거부 한계(MAX_REJECT_FRAMES=8)</strong> → 시크/재등장 같은 진짜 큰 이동에 영구 고착 방지.</li>
|
||
<li>추가로 <strong>픽셀 히스테리시스</strong>(0.75px 이상일 때만 정수좌표 갱신)로 저속 반올림 깜빡임 제거(<a href="client/src/components/overlay/StationOverlay.tsx#L1048">1048</a>).</li>
|
||
</ul>
|
||
<h3 id="54-라벨-사전계산--겹침-억제">5.4 라벨 사전계산 + 겹침 억제</h3>
|
||
<ul>
|
||
<li><strong><code>startLabelPrecompute</code></strong> (<a href="client/src/components/overlay/StationOverlay.tsx#L692">StationOverlay.tsx:692-746</a>): 전 프레임의 <strong>가시집합·겹침해소 결과</strong>를 <code>Map<frameNum, LabelCache></code>에 사전계산. <strong>화면좌표가 아니라 월드좌표(lat/lon/z)를 저장</strong> → RAF가 매 프레임 연속포즈로 재투영. <code>requestIdleCallback</code> 200프레임 청크, 취소토큰(<code>precomputeIdRef</code>), <strong>현재 프레임부터 순환 처리</strong>(재생 즉시 라벨), 첫 청크 즉시 노출, 재계산 시 옛 맵 복사.</li>
|
||
<li><strong>겹침 억제</strong> (<a href="client/src/components/overlay/StationOverlay.tsx#L755">755-784</a>): 임계 가로 <code>POI_MERGE_X=0.10</code>/세로 <code>0.035</code>(비등방, 텍스트가 가로로 긴 특성). 우선순위 ①철도역/역사 최우선 ②근접거리. <strong>진행방향 변형 우선</strong>: 상/하행 형제 겹침 시 노선 방향((상)/(하)) 변형을 남김.</li>
|
||
</ul>
|
||
<h3 id="55-raf-렌더-루프--성능-설계">5.5 RAF 렌더 루프 & 성능 설계</h3>
|
||
<p>매 프레임 <strong>계산 없이 조회+투영+드로잉만</strong>. 라이브 시간은 VideoPlayer <code>smoothTimeRef</code>(단조 벽시계 보간, 일시정지·시크·배속 보정)에서 받음.</p>
|
||
<table>
|
||
<thead>
|
||
<tr class="header">
|
||
<th>작업</th>
|
||
<th>비용</th>
|
||
<th>배치</th>
|
||
<th>빈도</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr class="odd">
|
||
<td>가시성/겹침 판정</td>
|
||
<td>높음</td>
|
||
<td>precompute</td>
|
||
<td>1회(+설정변경 debounce), idle 청크</td>
|
||
</tr>
|
||
<tr class="even">
|
||
<td>좌표 투영</td>
|
||
<td>중간</td>
|
||
<td>RAF</td>
|
||
<td>매 프레임</td>
|
||
</tr>
|
||
<tr class="odd">
|
||
<td>평활/EMA/히스테리시스</td>
|
||
<td>낮음</td>
|
||
<td>RAF</td>
|
||
<td>매 프레임</td>
|
||
</tr>
|
||
<tr class="even">
|
||
<td>팝업 가시집합 setState</td>
|
||
<td>리렌더</td>
|
||
<td>setInterval 150ms</td>
|
||
<td>변화 시만</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
<p><strong>핵심 = "무엇을 그릴지"(precompute) ↔︎ "어디에 그릴지"(RAF) 분리</strong>로 단일스레드 60fps 달성. 중심선/드론궤적은 <code>buildLines</code>로 매 프레임 직접 투영(근거리 Zc 클리핑·outcode 컬링). 나침반은 별도 ±3fr 평활 yaw + 360° 누적 언랩.</p>
|
||
<hr />
|
||
<h2 id="6-측점체이니지-시스템--스테이션바">6. 측점(체이니지) 시스템 & 스테이션바</h2>
|
||
<h3 id="61-연속-체이니지-수직투영-핵심-①">6.1 연속 체이니지 수직투영 (핵심 ①)</h3>
|
||
<p>드론 한 프레임의 위경도를 <strong>측점 폴리라인에 수직 투영</strong>해 100m 양자화가 아닌 <strong>10m 해상도 연속 측점값</strong>을 얻는다(부산물: 선로 수직 이격 offsetM).</p>
|
||
<ul>
|
||
<li><strong>폴리라인 구축</strong> <code>buildChainLine</code> (<a href="client/src/utils/chainage.ts#L22">chainage.ts:22-32</a>): 측점값 오름차순 정렬, 평면투영 <code>x=lon·k, y=lat·111000</code> (k=cos(lat0)·111000).</li>
|
||
<li><strong>점-선분 투영</strong> <code>projectToChain</code> (<a href="client/src/utils/chainage.ts#L35">chainage.ts:35-50</a>):
|
||
<div class="sourceCode" id="cb14"><pre class="sourceCode ts"><code class="sourceCode typescript"><span id="cb14-1"><a href="#cb14-1" aria-hidden="true" tabindex="-1"></a>const t <span class="op">=</span> L2<span class="op">===</span><span class="dv">0</span><span class="op">?</span><span class="dv">0</span><span class="op">:</span><span class="bu">Math</span><span class="op">.</span><span class="fu">max</span>(<span class="dv">0</span><span class="op">,</span><span class="bu">Math</span><span class="op">.</span><span class="fu">min</span>(<span class="dv">1</span><span class="op">,</span> ((px<span class="op">-</span>a<span class="op">.</span><span class="at">x</span>)<span class="op">*</span>dx <span class="op">+</span> (py<span class="op">-</span>a<span class="op">.</span><span class="at">y</span>)<span class="op">*</span>dy)<span class="op">/</span>L2))<span class="op">;</span></span>
|
||
<span id="cb14-2"><a href="#cb14-2" aria-hidden="true" tabindex="-1"></a>const d <span class="op">=</span> (px<span class="op">-</span>cx)<span class="op">**</span><span class="dv">2</span> <span class="op">+</span> (py<span class="op">-</span>cy)<span class="op">**</span><span class="dv">2</span><span class="op">;</span></span>
|
||
<span id="cb14-3"><a href="#cb14-3" aria-hidden="true" tabindex="-1"></a><span class="fu">if</span> (d <span class="op"><</span> bestD) { bestD <span class="op">=</span> d<span class="op">;</span> bestKm <span class="op">=</span> a<span class="op">.</span><span class="at">km</span> <span class="op">+</span> (b<span class="op">.</span><span class="at">km</span><span class="op">-</span>a<span class="op">.</span><span class="at">km</span>)<span class="op">*</span>t<span class="op">;</span> } <span class="co">// 최근접 선분 위 km 보간</span></span></code></pre></div></li>
|
||
<li><strong>왜 연속 투영인가</strong>: 최근접 측점점(100m 양자화)은 값이 계단처럼 튀고 두 측점 중간에서 전진/후진 전환 시점이 어긋난다. 수직투영은 선분 보간으로 매 프레임 부드럽게 변해 <strong>방향 전환점(극값) 타이밍이 실제 주행과 일치</strong>.</li>
|
||
</ul>
|
||
<h3 id="62-이중-측점값-배지용-km-vs-방향용-chain">6.2 이중 측점값: 배지용 <code>km</code> vs 방향용 <code>chain</code></h3>
|
||
<p>프레임별 precompute(<a href="client/src/stationbar/StationBar.tsx#L233">StationBar.tsx:233-246</a>): <code>km</code>=최근접 측점점(100m, 배지/폴백), <code>chain</code>=연속투영(10m, 방향/축/탐지). <code>videoFps</code>는 실데이터(마지막 프레임/재생시간)에서 유도, 미비 시 <code>30000/1001</code> 폴백.</p>
|
||
<h3 id="63-축-설계-시간축이-아닌-위치축-핵심-②">6.3 축 설계: 시간축이 아닌 위치축 (핵심 ②)</h3>
|
||
<p>선로 점검 영상은 <strong>호버(정지비행)</strong> 와 <strong>전·후진 반복</strong> 특성이 있어 시간축이 부적합하다(호버 시 커서가 위치와 어긋남).</p>
|
||
<ul>
|
||
<li><strong>이동거리축</strong> (<a href="client/src/stationbar/StationBar.tsx#L250">StationBar.tsx:250-271</a>): 연속 측점값을 ±8fr 평활 후 프레임간 <strong>절대 변화량 누적</strong>(<code>cum += |sm[i]−sm[i−1]|</code>)을 정규화(<code>frac</code>, 단조 비감소). 호버=frac 정체(커서 정지), 이동=frac 증가. 방향은 색 리본으로 직교 분리.</li>
|
||
<li><strong>측점축</strong>(후속 채택안, <a href="docs/history/2026-06-26_1520_스테이션바-측점축-전환.md">history 2026-06-26_1520</a>): x축을 <strong>측점값 비례</strong>로 두고, 축 범위를 역 POI 첫/끝으로 <strong>노선 고정</strong>(영상 무관 절대 측점) → 어떤 데이터로 재생하든 같은 구조물이 같은 위치/측점에 표시(회차 간 정렬).</li>
|
||
<li>시간↔︎축 양방향 변환: <code>cumFracAtTime</code>/<code>timeAtFrac</code>(또는 <code>chainAtTime</code>/<code>pxAtChain</code>)를 이진탐색+보간으로. 커서·구간색·구조물 마커가 <strong>동일 매핑 공유</strong>. 라이브 커서는 React 리렌더 없이 RAF에서 CSS 변수(<code>--pos-px</code>)만 갱신(<a href="client/src/stationbar/StationBar.tsx#L771">771-786</a>).</li>
|
||
</ul>
|
||
<h3 id="64-방향-판정--방향색-트랙">6.4 방향 판정 & 방향색 트랙</h3>
|
||
<p>평활 측점값 추세를 극값추적 + <strong>히스테리시스 HYST=10m</strong>로 방향 전환점 확정(<a href="client/src/stationbar/StationBar.tsx#L395">395-458</a>). <code>forwardDir = arrChain≥depChain?1:−1</code>. 정방향=주황 음영, 역방향=청록. 이동거리/측점축은 구간 px가 단조라 <code>linear-gradient</code> 1장으로 경량 처리(<a href="client/src/stationbar/StationBar.tsx#L686">686-726</a>).</p>
|
||
<h3 id="65-구조물-마커--좌표측점-이중-탐지--kmexists-핵심-③">6.5 구조물 마커 — 좌표/측점 이중 탐지 & <code>kmExists</code> (핵심 ③)</h3>
|
||
<p>두 통과 탐지기:</p>
|
||
<ul>
|
||
<li><strong><code>pxPassesTo(lat,lon,off)</code></strong> (<a href="client/src/stationbar/StationBar.tsx#L489">489-534</a>) — 좌표 근접. 임계 <code>TH=max(100, gmin·3)</code>.</li>
|
||
<li><strong><code>pxPassesAtMileage(targetM)</code></strong> (<a href="client/src/stationbar/StationBar.tsx#L537">537-559</a>) — 연속 체이니지가 <code>TH=stationTolerance(기본 20m)</code> 이내.</li>
|
||
</ul>
|
||
<p>탐지 우선순위(단락평가): ①명시 측점값 ②좌표를 측점선 투영→측점기준(통과순서 정확) ③이름매칭 POI 투영 ④좌표근접 폴백 … (<a href="client/src/stationbar/StationBar.tsx#L612">612-639</a>).</p>
|
||
<p><strong>역사 합집합 보강</strong>: 종점/조차장은 측점선 투영이 불안정해 재진입을 놓침(예 대전조차장 162k080). 단락평가 탓에 한 통과를 찾으면 좌표근접이 안 돌아 누락 → <strong>역사에 한해 좌표근접 통과를 합집합으로 합치되 24px 임계 중복제거</strong>(<a href="client/src/stationbar/StationBar.tsx#L629">629-634</a>).</p>
|
||
<p><strong><code>kmExists</code> (★)</strong>: 좌표 반경만으로 잡힌 통과는 실제 측점값이 그 위치에 없을 수 있다.</p>
|
||
<div class="sourceCode" id="cb15"><pre class="sourceCode ts"><code class="sourceCode typescript"><span id="cb15-1"><a href="#cb15-1" aria-hidden="true" tabindex="-1"></a>const kmExists <span class="op">=</span> stationKmVal <span class="op">==</span> null <span class="op">||</span> <span class="bu">Math</span><span class="op">.</span><span class="fu">abs</span>(p<span class="op">.</span><span class="at">km</span> <span class="op">-</span> stationKmVal) <span class="op"><=</span> existTol<span class="op">;</span> <span class="co">// existTol = stationTolerance</span></span></code></pre></div>
|
||
<p>(<a href="client/src/stationbar/StationBar.tsx#L648">StationBar.tsx:648</a>) <code>false</code>면 <strong>측점값 라벨·측점검색에서 제외하되 마커(동그라미)는 유지</strong> — "위치 표시"와 "측점 단정"의 분리.</p>
|
||
<p><strong>종점 미도착</strong>: <code>endGapPx=min(폭·0.15, (gapM/lenM)·폭)</code>, <code>placeUnreachedTerminal</code>이 최우측 역사를 트랙 끝에 미도착(속 빈 링)으로 고정(<a href="client/src/stationbar/StationBar.tsx#L303">303-308</a>, <a href="client/src/stationbar/StationBar.tsx#L574">574-586</a>).</p>
|
||
<h3 id="66-측점검색-순환--handlejumptomileage">6.6 측점검색 순환 — <code>handleJumpToMileage</code></h3>
|
||
<p><a href="client/src/stationbar/StationBar.tsx#L850">StationBar.tsx:850-916</a>. 측점값 입력 시 여러 통과를 <strong>통과방향 순환</strong>:</p>
|
||
<ol type="1">
|
||
<li>후보 <strong>1순위</strong>: 화면 마커(<code>structureMarks</code>) 중 <code>kmExists!==false</code> 이고 표시 10m값 일치 → px→<code>timeAtFrac</code> 역변환. (좌표근접 통과 포함 → 모든 동그라미 도달)</li>
|
||
<li><strong>2순위</strong>: 마커 없으면 chain 연속구간, 최종 전역 최근접 폴백.</li>
|
||
<li>정렬 + 0.3s 중복제거 → baseT 기준 다음 통과(끝→처음 순환). <code>jumpRef</code>로 연속검색 판정. Enter 후 입력값 유지, blur 시 삭제(<a href="client/src/stationbar/components/PlaybackControls/PlaybackControls.tsx">PlaybackControls.tsx</a>).</li>
|
||
</ol>
|
||
<h3 id="67-타임라인-보조-기술-timelinetsx">6.7 타임라인 보조 기술 (<a href="client/src/stationbar/components/Timeline/Timeline.tsx">Timeline.tsx</a>)</h3>
|
||
<ul>
|
||
<li><strong>동일항목 브래킷 클러스터</strong>: 동명 다통과를 거리 무관 한 묶음으로(드롭+수평 점선) 중앙 라벨 1개(<a href="client/src/stationbar/components/Timeline/Timeline.tsx#L143">143-172</a>).</li>
|
||
<li><strong>라벨 2줄 균등분할</strong> <code>splitStructLabel</code>: CJK 1.0/기타 0.55폭, 괄호그룹은 통째로 아랫줄(<a href="client/src/stationbar/components/Timeline/Timeline.tsx#L30">30-49</a>).</li>
|
||
<li><strong>측점값 라벨 겹침 숨김</strong> <code>showMileage</code>: 직전 표시와 <code>(lastW+w)/2</code> 미만이면 숨김, <code>kmExists===false</code>는 애초 생략(<a href="client/src/stationbar/components/Timeline/Timeline.tsx#L117">117-137</a>).</li>
|
||
<li><strong>아이콘 passed 전환</strong>: upcoming/passed 두 PNG를 opacity로 전환(무깜빡임), 전 상태 사전로드.</li>
|
||
</ul>
|
||
<hr />
|
||
<h2 id="7-위치보정-역투영-단일-제스처--발명-c">7. 위치보정 역투영 (단일 제스처) — 발명 C</h2>
|
||
<h3 id="71-무엇을--왜">7.1 무엇을 / 왜</h3>
|
||
<p>지오코딩 POI는 ±수십 m 오차. 2D 화면 이동만으로는 카메라로부터의 거리(깊이)를 결정 못함(깊이 모호성). 종래는 수평/높이를 따로 조정하거나 깊이맵(LiDAR) 요구. → <strong>단일 드래그로 수평·수직(3D) 동시 보정, 깊이맵 불요.</strong></p>
|
||
<h3 id="72-구성독립항-필수요소">7.2 구성(독립항 필수요소)</h3>
|
||
<ol type="1">
|
||
<li><strong>슬랜트거리 보존</strong> — 드래그 개시 시점의 카메라–객체 슬랜트거리(range)를 고정 → 깊이 모호성 해소 핵심 구속.</li>
|
||
<li><strong>역투영</strong> — 드롭 픽셀을 카메라 방향벡터로 환산 후 보존 거리만큼 투사.</li>
|
||
<li><strong>월드좌표 복원</strong> — 자세 회전·평행이동 역변환으로 lat/lon/z 복원(지오이드 역환원). 한 번의 드래그로 수평·수직 동시.</li>
|
||
<li><strong>영속·재적용</strong> — 복원 좌표를 객체 식별자(title)에 연계해 <code><base>_poi_overrides.json</code> + <code>ghivideo:poiov:<baseName></code> 저장.</li>
|
||
<li>(종속) <strong>가시영역 한정</strong> — 전방(Zc>0)만 보정.</li>
|
||
</ol>
|
||
<div class="sourceCode" id="cb16"><pre class="sourceCode ts"><code class="sourceCode typescript"><span id="cb16-1"><a href="#cb16-1" aria-hidden="true" tabindex="-1"></a>dir_cam <span class="fu">∝</span> ((px−0<span class="op">.</span><span class="dv">5</span>−cx0)·sW<span class="op">/</span>f<span class="op">,</span> (py−0<span class="op">.</span><span class="dv">5</span>−cy0)·sH<span class="op">/</span>f<span class="op">,</span> <span class="dv">1</span>)</span>
|
||
<span id="cb16-2"><a href="#cb16-2" aria-hidden="true" tabindex="-1"></a>cam <span class="op">=</span> dir_cam<span class="op">/|</span>dir_cam<span class="op">|</span> · range <span class="co">// ★거리 보존(드래그 시작값)</span></span>
|
||
<span id="cb16-3"><a href="#cb16-3" aria-hidden="true" tabindex="-1"></a>rel <span class="op">=</span> R_w2cᵀ · cam <span class="co">// 회전 역변환</span></span>
|
||
<span id="cb16-4"><a href="#cb16-4" aria-hidden="true" tabindex="-1"></a>z <span class="op">=</span> droneAlt <span class="op">+</span> offZ <span class="op">+</span> rel<span class="op">.</span><span class="at">up</span> − N_geoid <span class="co">// 정표고 복원</span></span>
|
||
<span id="cb16-5"><a href="#cb16-5" aria-hidden="true" tabindex="-1"></a>(lat<span class="op">,</span>lon) <span class="op">=</span> <span class="fu">TM⁻¹</span>(droneE<span class="op">+</span>offX<span class="op">+</span>rel<span class="op">.</span><span class="at">x</span><span class="op">,</span> droneN<span class="op">+</span>offY<span class="op">+</span>rel<span class="op">.</span><span class="at">y</span>)</span></code></pre></div>
|
||
<p><strong>변형 — z 전용 역산</strong> <code>solveZForPixelY</code>: lat/lon 고정, 드롭 세로위치로 z만 1차식 역산. 왕복검증 z=42→py→42.000.</p>
|
||
<h3 id="73-효과">7.3 효과</h3>
|
||
<p>2D 드래그 1회로 3D 보정(단계 절반), 역투영 왕복오차 ≈0, 깊이맵 불요, 파일+localStorage 재현성. <strong>"depth map 없이 거리보존"</strong> 을 명시해 US9188444B2와 구별(§10).</p>
|
||
<hr />
|
||
<h2 id="8-백엔드-스트리밍ffmpeg--데이터-파이프라인--상태관리--플레이어">8. 백엔드 스트리밍/FFmpeg · 데이터 파이프라인 · 상태관리 · 플레이어</h2>
|
||
<h3 id="81-대용량2gb-재생--이중-경로">8.1 대용량(2GB+) 재생 — 이중 경로</h3>
|
||
<ul>
|
||
<li><strong>로컬</strong>(<a href="client/src/hooks/useVideoPlayer.ts#L80">useVideoPlayer.ts:80-88</a>): <code>URL.createObjectURL</code> Blob URL 직접 재생(업로드/변환 없음), 교체 시 <code>revokeObjectURL</code>(누수 방지). <strong>현 운영 주 경로.</strong></li>
|
||
<li><strong>서버</strong>: Range Request 즉시 재생, 비표준 코덱(HEVC 등)이면 HLS 자동 전환(<a href="client/src/hooks/useVideoPlayer.ts#L15">15-17</a>). (서버/HLS 경로는 보존되나 UI는 <code>SHOW_TOOLBAR=false</code>로 숨김.)</li>
|
||
</ul>
|
||
<h3 id="82-range-request-streamingts52-77">8.2 Range Request (<a href="server/src/services/streaming.ts#L52">streaming.ts:52-77</a>)</h3>
|
||
<p>핵심: 한 응답에 전체를 싣지 않음. 끝값 없으면 <code>start+10MB−1</code>. <code>pipeline()</code>(백프레셔/에러 자동), <code>highWaterMark:1MB</code>, <code>Accept-Ranges:bytes</code>, path traversal 방어(<code>resolveVideoPath</code>). JS Number(2^53−1)로 4GB+ 안전(BigInt 불요). 멀티레인지 미지원.</p>
|
||
<h3 id="83-hls-변환--sse-hlsts">8.3 HLS 변환 + SSE (<a href="server/src/routes/hls.ts">hls.ts</a>)</h3>
|
||
<p>멱등(디스크에 <code>index.m3u8</code> 있으면 즉시 done), 코덱 분기(H.264 <code>-c copy</code> 수 초 / 그 외 libx264), <code>/progress</code> SSE로 stderr <code>time=</code> 파싱 푸시. hls.js 필수: <code>backBufferLength:30</code>(기본 Infinity면 장시간 재생 메모리 누수→크래시), maxBufferLength 30, enableWorker(<a href="client/src/hooks/useVideoPlayer.ts#L7">useVideoPlayer.ts:7-13</a>).</p>
|
||
<h3 id="84-ffmpeg-spawn-래퍼-ffmpegts">8.4 FFmpeg spawn 래퍼 (<a href="server/src/services/ffmpeg.ts">ffmpeg.ts</a>)</h3>
|
||
<p><code>-y</code> auto, stderr <code>time=HH:MM:SS.cc</code> 정규식 진행률, exit≠0 시 stderr 끝 500자 reject. 프레임 추출은 <code>-accurate_seek -ss {t} -i {f} -frames:v 1</code>(플래그 순서 = 입력단 정확 시킹, <a href="server/src/routes/frame.ts#L37">frame.ts:37-44</a>).</p>
|
||
<h3 id="85-데이터-파이프라인-geodatats">8.5 데이터 파이프라인 (<a href="client/src/utils/geoData.ts">geoData.ts</a>)</h3>
|
||
<p><code><input webkitdirectory></code>/드래그로 받은 <code>File[]</code>을 병렬 파싱(<code>Promise.all</code>). <strong>인코딩 자동감지</strong>(BOM→UTF-8/EUC-KR), <strong>헤더명 우선+위치 폴백</strong>(<code>makeFieldIndexer</code>), 좌표 유효성 게이트(lat 33~39/lon 124~132), KMZ 우선(있으면 CSV POI/구조물 미사용, fflate unzipSync).</p>
|
||
<table>
|
||
<thead>
|
||
<tr class="header">
|
||
<th>소스</th>
|
||
<th>파서</th>
|
||
<th>대상</th>
|
||
<th>매핑</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr class="odd">
|
||
<td><code><base>.csv</code></td>
|
||
<td>parseDroneFrames</td>
|
||
<td>DroneFrame[]</td>
|
||
<td>frame_cnt, lat/lon/alt, yaw/pitch/roll, focal</td>
|
||
</tr>
|
||
<tr class="even">
|
||
<td><code>building/01)측점.csv</code></td>
|
||
<td>parseStations</td>
|
||
<td>station + DirectionChange</td>
|
||
<td>측점→title, <strong>Z좌표_한국(정표고) 우선</strong>, 비고→방향전환</td>
|
||
</tr>
|
||
<tr class="odd">
|
||
<td><code><base>_POI.csv</code> / <code>02)지장물.csv</code></td>
|
||
<td>parsePois</td>
|
||
<td>poi</td>
|
||
<td>category_clean/명칭, lat/lon</td>
|
||
</tr>
|
||
<tr class="even">
|
||
<td><code>02)지장물_역사.csv</code></td>
|
||
<td>parseHistoricStations</td>
|
||
<td>역사 구조물</td>
|
||
<td>명칭→name(스테이션바 마커)</td>
|
||
</tr>
|
||
<tr class="odd">
|
||
<td><code>03)교량/04)터널/06)구교.csv</code></td>
|
||
<td>parseStructures</td>
|
||
<td>bridge/tunnel</td>
|
||
<td>구분/시설물명, 연장→lengthM, 시설종별→grade</td>
|
||
</tr>
|
||
<tr class="even">
|
||
<td><code>05)출입문번호.csv</code></td>
|
||
<td>parseAccessDoors</td>
|
||
<td>출입문 poi</td>
|
||
<td>출입문번호→title</td>
|
||
</tr>
|
||
<tr class="odd">
|
||
<td><code>*.kmz/kml</code></td>
|
||
<td>parseKmz</td>
|
||
<td>poi+구조물</td>
|
||
<td>Placemark + description표→props</td>
|
||
</tr>
|
||
<tr class="even">
|
||
<td><code>route.json</code></td>
|
||
<td>mergeStructures</td>
|
||
<td>보정</td>
|
||
<td>station/offset/이정 override</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
<p>중심선은 v2.0에 별도 파일이 없어 측점을 측점값 순 정렬한 폴리라인으로 합성.</p>
|
||
<h3 id="86-상태관리">8.6 상태관리</h3>
|
||
<ul>
|
||
<li><strong>geoStore</strong>(메모리, persist 없음): frames/stations/pois/structures/centerline/origin/routeMeta. <code>basePois</code>(원본) vs <code>pois</code>(override 적용) 분리로 원본 불변성(<a href="client/src/store/geoStore.ts#L96">geoStore.ts:96-110</a>).</li>
|
||
<li><strong>settingsStore</strong>(localStorage <code>ghivideo.settings</code>): 시설종별 필터, 표시 토글들. 커스텀 <code>merge</code>로 스키마 진화 대비. <code>isGradeVisible</code> 공유 규칙.</li>
|
||
</ul>
|
||
<h3 id="87-플레이어-통합--프레임-정확도">8.7 플레이어 통합 & 프레임 정확도</h3>
|
||
<ul>
|
||
<li>Video.js <code>data-vjs-player</code> + Strict Mode 가드, <code>controls:false</code>(측점바로 대체), <code>fill:true</code>.</li>
|
||
<li><strong>FPS 감지</strong>(<a href="client/src/hooks/useFrameStep.ts#L21">useFrameStep.ts:21-49</a>): <code>requestVideoFrameCallback</code>, 첫 콜백은 시작점만 기록(31fps 오감지 방지), 연속 2회 일치 확정. 스텝은 <code>1/fps</code>(누적 금지=드리프트 방지).</li>
|
||
<li><strong>이원 fps</strong>: 스텝 이동=실측 fps, 프레임번호 표시=29.97 고정(SRT 기준). 의도된 분리.</li>
|
||
<li><strong>매끄러운 커서</strong>: rAF 단조 벽시계 보간 <code>smoothTimeRef</code>(매 프레임, transform) + <code>smoothTime</code>(throttle, 배지) 분리.</li>
|
||
</ul>
|
||
<hr />
|
||
<h2 id="9-개발-타임라인-주요-기술-의사결정">9. 개발 타임라인 (주요 기술 의사결정)</h2>
|
||
<table>
|
||
<thead>
|
||
<tr class="header">
|
||
<th>시기</th>
|
||
<th>핵심 결정/구현</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr class="odd">
|
||
<td>2026-04-01</td>
|
||
<td>labelMap 사전계산 + requestIdleCallback + RAF, ±10fr 이동평균(yaw sin/cos), 30→60fps 보간</td>
|
||
</tr>
|
||
<tr class="even">
|
||
<td>2026-04-02</td>
|
||
<td><strong>투영 파이프라인 정립</strong>: EPSG:5186 TM, 핀홀(Rz·Rx·Ry), 타원체고=정표고+지오이드, Cohen-Sutherland 클리핑</td>
|
||
</tr>
|
||
<tr class="odd">
|
||
<td>2026-06-19</td>
|
||
<td><strong>v1→v2 데이터 형상 전환</strong>(실측 폴더), 인코딩 자동감지, 측점 비고→방향전환 추출, 시설종별 필터</td>
|
||
</tr>
|
||
<tr class="even">
|
||
<td>2026-06-22</td>
|
||
<td><strong>측점 표고 컬럼 교정(Z좌표_한국) → 부각 42.7°→11°</strong>(발명 B). 역투영 드래그 보정+override(발명 C 최초). title-키 보간+이상치 거부. 비등방 거리필터</td>
|
||
</tr>
|
||
<tr class="odd">
|
||
<td>2026-06-23</td>
|
||
<td>에지보존 평활(YAW_EDGE=8°). <strong>글로벌+KIPRIS 선행기술 조사</strong> → B·C 신규성 확인</td>
|
||
</tr>
|
||
<tr class="even">
|
||
<td>2026-06-24</td>
|
||
<td><strong>1클릭 FOV 보정(→sensorH 역산)</strong>, object-fit:cover 정렬, 라벨 월드좌표 저장+RAF 재투영, One-Euro EMA, KMZ 직접 로딩</td>
|
||
</tr>
|
||
<tr class="odd">
|
||
<td>2026-06-25~26</td>
|
||
<td>종점역 누락 수정, 양끝 역 고정, 라벨 2줄분할, <strong>동일항목 브래킷</strong>, <strong>거리축→측점축 전환</strong></td>
|
||
</tr>
|
||
<tr class="even">
|
||
<td>2026-06-29</td>
|
||
<td><strong>좌표/측점 이중탐지+합집합 보강</strong>, 측점값 라벨 겹침숨김, <strong>측점검색 순환+마커기준 전환+kmExists</strong>, UI 통합(토글 재생바 이동, 활성색 amber), 보고서 3종 생성, 55000 외부접속</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
<p>상세 근거: <a href="docs/history/2026-06-22_POI투영오차-개선.md">docs/history/2026-06-22_POI투영오차-개선.md</a>(발명 B·C 1차 출처), <a href="docs/history/2026-06-26_1520_스테이션바-측점축-전환.md">2026-06-26_1520_측점축-전환</a>, <a href="docs/history/2026-06-29_1029_역사-통과-좌표근접-보강.md">2026-06-29_1029_역사-통과-좌표근접-보강</a> 등.</p>
|
||
<hr />
|
||
<h2 id="10-특허-관점-종합">10. 특허 관점 종합</h2>
|
||
<blockquote>
|
||
<p>출처: <a href="docs/특허_스테이션기반_주행영상_플레이어.html">docs/특허_스테이션기반_주행영상_플레이어.html</a>(기술발굴), <a href="docs/발명신고서_측점표고정합_역투영보정.html">docs/발명신고서_측점표고정합_역투영보정.html</a>(변리사 제출용).</p>
|
||
</blockquote>
|
||
<h3 id="101-기술요소-인벤토리-e1e9">10.1 기술요소 인벤토리 (E1~E9)</h3>
|
||
<table>
|
||
<thead>
|
||
<tr class="header">
|
||
<th>코드</th>
|
||
<th>요소</th>
|
||
<th>특허 적합성</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr class="odd">
|
||
<td>E1</td>
|
||
<td>측점(체이니지) 기반 영상 색인·탐색</td>
|
||
<td>후보 A(좁게)</td>
|
||
</tr>
|
||
<tr class="even">
|
||
<td>E2</td>
|
||
<td>지리객체 실시간 영상 정합(labelMap 사전계산)</td>
|
||
<td>공지(큰 틀)</td>
|
||
</tr>
|
||
<tr class="odd">
|
||
<td>E3</td>
|
||
<td><strong>측점 표고 기반 DEM-free 지면고도 추정</strong></td>
|
||
<td><strong>후보 B(상)</strong></td>
|
||
</tr>
|
||
<tr class="even">
|
||
<td>E4</td>
|
||
<td><strong>역투영+슬랜트거리 보존 단일드래그 위치보정</strong></td>
|
||
<td><strong>후보 C(상)</strong></td>
|
||
</tr>
|
||
<tr class="odd">
|
||
<td>E5</td>
|
||
<td>비등방 거리필터+소실점 클러터 억제</td>
|
||
<td>후보 D(종속)</td>
|
||
</tr>
|
||
<tr class="even">
|
||
<td>E6</td>
|
||
<td>노이즈 강건 오버레이(이중평활·title짝·이상치거부)</td>
|
||
<td>후보 E(종속)</td>
|
||
</tr>
|
||
<tr class="odd">
|
||
<td>E7</td>
|
||
<td>측점 폴리라인→중심선</td>
|
||
<td>제외(통상 처리)</td>
|
||
</tr>
|
||
<tr class="even">
|
||
<td>E8</td>
|
||
<td>이종 CSV 융합(인코딩 자동감지)</td>
|
||
<td>제외</td>
|
||
</tr>
|
||
<tr class="odd">
|
||
<td>E9</td>
|
||
<td>비고 텍스트 방향전환 추출</td>
|
||
<td>제외</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
<h3 id="102-출원-권고-우선순위">10.2 출원 권고 우선순위</h3>
|
||
<ul>
|
||
<li><strong>1순위: 발명 B + C 결합</strong> — 신규성/진보성 우수, 글로벌+한국 신규성 확인.</li>
|
||
<li><strong>2순위: 후보 A(좁게)</strong> — LRS 비선형 재좌표화 + 회차간 위치앵커 + 다중회차 동기. 한국 선행기술(KR102268318B1·KR100411587B1) 위험 → 좁게 청구. <strong>본 명세의 측점축 노선좌표 고정(§6.3)·측점검색 순환(§6.6)·이중탐지 kmExists(§6.5)가 A의 구현 근거.</strong></li>
|
||
<li><strong>3순위: D·E 종속항 흡수</strong>. <strong>제외: E7·E8·E9</strong>.</li>
|
||
</ul>
|
||
<h3 id="103-청구-포인트-핵심-bc">10.3 청구 포인트 핵심 (B·C)</h3>
|
||
<ul>
|
||
<li><strong>발명 B 독립항</strong>: ①측점 실측 정표고 수열 → ②표고미상 대상에 최근접 측점 정표고를 지면고도 채택(노선기복 반영) → ③지오이드고 가산으로 타원체고 변환·datum 정합 → ④정합 투영. <strong>※ ②+③ 결합을 한 청구항에</strong>(개별요소는 공지).</li>
|
||
<li><strong>발명 C 독립항</strong>: ①드래그 개시 시 슬랜트거리 보존 → ②드롭픽셀 역투영 → ③단일 제스처로 수평·수직 동시 복원 → ④식별자 연계 영속. <strong>※ "depth map 없이 거리보존"</strong> 명시(US9188444B2 구별).</li>
|
||
</ul>
|
||
<h3 id="104-선행기술-조사-완료분">10.4 선행기술 (조사 완료분)</h3>
|
||
<ul>
|
||
<li>E1·E2 큰 틀은 공지: Esri ArcGIS Pro FMV, US9996976B2, Mandli Roadview, Remote GeoSystems LineVision, ENSCO Virtual Track Walk.</li>
|
||
<li>B·C는 글로벌+한국 신규성 확인. <strong>단, KIPRIS 전문(專門)검색 미수행 — 출원 전 필수.</strong></li>
|
||
</ul>
|
||
<h3 id="105-신규성-후보-종합-구현-기반">10.5 신규성 후보 종합 (구현 기반)</h3>
|
||
<ol type="1">
|
||
<li>드론 SRT pose 기반 <strong>실시간 GIS-영상 정합 파이프라인</strong>(타원체고/정표고 datum 자동 정합 포함).</li>
|
||
<li><strong>단일 대응점 sensorH 역산</strong> 인터랙티브 FOV 보정(가로 정합 보존, 중심부 발산 거부).</li>
|
||
<li><strong>깊이 모호성 분리형 역투영 3종</strong>(슬랜트유지/지면교차/표고 1차역산)을 보정 의도별 선택.</li>
|
||
<li><strong>에지(회전경계) 보존 적응형 평활</strong> + 속도적응 EMA(연속거부 한계).</li>
|
||
<li><strong>월드좌표 precompute + 연속포즈 재투영 2단 분리</strong>로 단일스레드 60fps AR.</li>
|
||
<li><strong>누적 이동거리/측점 비례 축 + 방향색 직교 분리</strong> 진행바, 노선좌표 고정(회차 정렬).</li>
|
||
<li><strong>좌표/측점 이중 탐지 + 합집합 보강 + kmExists 신뢰도 플래그</strong>("위치 표시"와 "측점 단정" 분리).</li>
|
||
</ol>
|
||
<hr />
|
||
<h2 id="11-검증-결과표">11. 검증 결과표</h2>
|
||
<table>
|
||
<thead>
|
||
<tr class="header">
|
||
<th>항목</th>
|
||
<th>개선 전</th>
|
||
<th>개선 후</th>
|
||
<th>근거</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr class="odd">
|
||
<td>부각 오차(측점 157K900, 91.3m)</td>
|
||
<td>42.7°</td>
|
||
<td><strong>11.0°</strong></td>
|
||
<td>§4.3</td>
|
||
</tr>
|
||
<tr class="even">
|
||
<td>지면고도 정확도(측점 vs 구글 DEM)</td>
|
||
<td>—</td>
|
||
<td><strong>0.2m(~6px@2160p)</strong></td>
|
||
<td>§4.3</td>
|
||
</tr>
|
||
<tr class="odd">
|
||
<td>역투영 왕복오차(z=42→py→z)</td>
|
||
<td>—</td>
|
||
<td><strong>≈0 (42.000)</strong></td>
|
||
<td>§7.2</td>
|
||
</tr>
|
||
<tr class="even">
|
||
<td>화면 EMA 지연</td>
|
||
<td>1.65s(α=0.01)</td>
|
||
<td>~25ms(α=0.4)</td>
|
||
<td>§5.3 / history 06-22</td>
|
||
</tr>
|
||
<tr class="odd">
|
||
<td>렌더</td>
|
||
<td>끊김</td>
|
||
<td><strong>60fps</strong>(precompute+RAF 분리)</td>
|
||
<td>§5.5</td>
|
||
</tr>
|
||
<tr class="even">
|
||
<td>역 재진입 마커</td>
|
||
<td>누락</td>
|
||
<td>좌표근접 합집합 보강으로 표출</td>
|
||
<td>§6.5</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
<p>지오이드고 N: 본 명세 <strong>25.8m</strong>(KNGeoid 검증) 통일. 초기 문서값 25.449m는 각주 처리.</p>
|
||
<hr />
|
||
<h2 id="12-후임자-인계">12. 후임자 인계</h2>
|
||
<h3 id="121-데이터-흐름-한눈에">12.1 데이터 흐름 한눈에</h3>
|
||
<p>geoStore(frames/stations/structures/routeMeta) → <code>useEffect</code> precompute(<code>viewedRef</code>=프레임별 km/chain/time, <code>chainRef</code>=time/frac/chain) → <code>pxAtTime</code>/<code>pxAtChain</code>·<code>barSegments</code>·<code>structureMarks</code> → Timeline 렌더. <strong>모든 px는 단일 매핑(<code>pxAtTime</code>/<code>pxAtChain</code>)을 통과</strong>, 역변환은 <code>timeAtFrac</code>/<code>chainAtTime</code>만 사용.</p>
|
||
<h3 id="122-튜닝-포인트">12.2 튜닝 포인트</h3>
|
||
<ul>
|
||
<li><strong>폴더 route.json</strong>: <code>stationTolerance</code>(통과/검색 허용오차, 20m), <code>direction</code>/<code>directionChanges</code>(방향 변형 필터), <code>endStationGapMeters</code>/<code>lengthKm</code>(미도착 폭), <code>startStationName</code>/<code>endStationName</code>.</li>
|
||
<li><strong>카메라(패널, 데이터셋별 저장)</strong>: <code>focalLen</code>, <code>sensorW</code>, <code>sensorH</code>(FOV), <code>yawOffset</code>/<code>pitch</code>/<code>roll</code>, <code>geoidOffset</code>(지역 N), <code>poiZOffset</code>.</li>
|
||
<li><strong>코드 상수</strong>: 평활 <code>W=8</code>, <code>YAW_EDGE=8°</code>, <code>HYST=10m</code>, 좌표통과 <code>TH=max(100,gmin·3)</code>, 역사 합집합 <code>24px</code>, 검색 중복 <code>0.3s</code>, EMA <code>β=0.25</code>/<code>REJECT_DIST=0.12</code>/<code>MAX_REJECT_FRAMES=8</code>.</li>
|
||
</ul>
|
||
<h3 id="123-빌드배포--필수-숙지">12.3 빌드/배포 (★ 필수 숙지)</h3>
|
||
<p>55000은 <strong>PM2 <code>ghiVideo</code>가 <code>client/dist</code>(빌드 결과)를 정적 서빙</strong>한다. 소스만 고치면 화면에 반영 안 됨.</p>
|
||
<div class="sourceCode" id="cb17"><pre class="sourceCode bash"><code class="sourceCode bash"><span id="cb17-1"><a href="#cb17-1" aria-hidden="true" tabindex="-1"></a><span class="bu">export</span> <span class="va">PATH=</span><span class="st">"</span><span class="va">$HOME</span><span class="st">/.nvm/versions/node/v20.20.2/bin:</span><span class="va">$PATH</span><span class="st">"</span> # 시스템 <span class="va">node</span> <span class="va">v12</span> → <span class="va">tsc</span>/vite 실패</span>
|
||
<span id="cb17-2"><a href="#cb17-2" aria-hidden="true" tabindex="-1"></a><span class="ex">npm</span> run build -w client # = tsc <span class="kw">&&</span> <span class="ex">vite</span> build → client/dist 갱신</span>
|
||
<span id="cb17-3"><a href="#cb17-3" aria-hidden="true" tabindex="-1"></a><span class="co"># 브라우저 Ctrl+Shift+R, 폴더 데이터는 런타임 로드라 폴더 재선택 필요</span></span></code></pre></div>
|
||
<p>외부(LAN) 접속: netsh portproxy(Windows :55000 → WSL IP:55000) + 방화벽. WSL IP는 재부팅 시 변동.</p>
|
||
<h3 id="124-업그레이드-포인트">12.4 업그레이드 포인트</h3>
|
||
<ol type="1">
|
||
<li>렌즈 왜곡(Brown-Conrady) 추가 → 가장자리 정합.</li>
|
||
<li>다점 대응 자동 캘리브레이션(focal·주점·왜곡 번들조정).</li>
|
||
<li>지오이드 격자(KNGeoid18) 보간으로 <code>geoidOffset</code> 자동화.</li>
|
||
<li>POI 표고 DEM 자동조회(현 부분지원).</li>
|
||
<li>중심선 per-frame 투영 공간색인, precompute Web Worker화.</li>
|
||
<li>frame.ts fps 고정(30)→<code>getVideoFps</code>, HLS jobs DB 큐, 멀티 Range 지원.</li>
|
||
<li><strong>코드 통합</strong>: 측점 파서/포매터(chainage.ts ↔︎ StationBar.tsx), 투영함수(<code>projectToChain</code> ↔︎ <code>projectChainage</code>) 단일화.</li>
|
||
</ol>
|
||
<hr />
|
||
<h2 id="13-한계--출원-전-확인사항">13. 한계 / 출원 전 확인사항</h2>
|
||
<ul>
|
||
<li><strong>KIPRIS 전문검색 미수행</strong> — 출원 전 필수.</li>
|
||
<li>지오코딩 수평오차는 데이터 한계(코드 보정은 드래그 수동).</li>
|
||
<li><code>geoidOffset</code> 지역 상수(대전 25.8m) — 타 지역 재튜닝.</li>
|
||
<li>VFR/abs_alt 노이즈, 먼 거리 역투영 민감.</li>
|
||
<li>미확정 항목(특허문서 <code>[확인 필요]</code> 유지): 다중회차 동기화 구현 범위, 위치앵커 주석 데이터모델, 권리귀속, 공개이력.</li>
|
||
<li>렌즈 왜곡 미보정(핀홀만) — 가장자리 정확도 트레이드오프.</li>
|
||
</ul>
|
||
<hr />
|
||
<p><em>본 문서는 5개 서브시스템(카메라 투영/자세 평활/측점·스테이션바/백엔드·데이터/기존 특허문서) 정밀 조사 결과를 종합한 것이다. 각 소스 참조는 클릭 가능한 파일:라인으로 추적 가능하며, 특허 출원 시 본 문서의 §3~§7·§10과 history 원문을 함께 변리사에게 제공할 것을 권장한다.</em></p>
|
||
</body>
|
||
</html>
|