기존 GhiVideo 저장소 HEAD의 트래킹 소스 362개 파일을 복제. (node_modules·storage·빌드 산출물·대용량 미디어는 .gitignore 규칙대로 제외) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
455 lines
30 KiB
HTML
455 lines
30 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>쉬운설명_KMZ에서_영상표출까지_전과정</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;}
|
||
.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">쉬운설명_KMZ에서_영상표출까지_전과정</h1>
|
||
</header>
|
||
<h1 id="kmz-파일에서-영상-위-이름표까지--전-과정-그림-설명서">KMZ 파일에서 영상 위 이름표까지 — 전 과정 그림 설명서</h1>
|
||
<blockquote>
|
||
<p>이 글은 <strong>초등학생도 이해할 수 있게</strong> 그림과 이야기로 쓴 설명서예요. "지도 파일(KMZ) 하나가 어떤 여행을 거쳐, 드론 영상 속 <strong>정확한 자리</strong>에 다리·터널 이름표로 나타날까?" 그 여정을 <strong>8단계</strong>로 하나씩 따라가 봅니다. (파일·줄 번호는 2026-07-03 기준)</p>
|
||
</blockquote>
|
||
<hr />
|
||
<h2 id="한눈에-보는-전체-여정">한눈에 보는 전체 여정</h2>
|
||
<p>KMZ는 "지도 위 핀들을 모아 둔 <strong>선물 상자</strong>"예요. 이 상자가 열려서, 안의 핀 하나하나가 영상 화면의 정확한 픽셀에 이름표로 붙기까지 — 전체 흐름은 이렇습니다.</p>
|
||
<figure>
|
||
<svg viewBox="0 0 760 210" role="img" aria-label="전체 파이프라인">
|
||
<defs><marker id="f1a" markerWidth="9" markerHeight="9" refX="6" refY="3" orient="auto"><path d="M0,0 L6,3 L0,6 Z" fill="#14305e"></path></marker></defs>
|
||
<!-- 1행: KMZ 처리 -->
|
||
<rect x="8" y="18" width="108" height="52" rx="6" fill="#fff7e6" stroke="#b45309"></rect>
|
||
<text x="62" y="40" text-anchor="middle" font-size="11" font-weight="bold" fill="#b45309">① KMZ 파일</text>
|
||
<text x="62" y="56" text-anchor="middle" font-size="9" fill="#7c2d12">(압축 선물상자)</text>
|
||
<rect x="146" y="18" width="108" height="52" rx="6" fill="#fff" stroke="#555"></rect>
|
||
<text x="200" y="40" text-anchor="middle" font-size="11" font-weight="bold">② 압축 풀기</text>
|
||
<text x="200" y="56" text-anchor="middle" font-size="9" fill="#555">doc.kml 꺼내기</text>
|
||
<rect x="284" y="18" width="108" height="52" rx="6" fill="#fff" stroke="#555"></rect>
|
||
<text x="338" y="40" text-anchor="middle" font-size="11" font-weight="bold">③ KML 읽기</text>
|
||
<text x="338" y="56" text-anchor="middle" font-size="9" fill="#555">폴더→핀(Placemark)</text>
|
||
<rect x="422" y="18" width="130" height="52" rx="6" fill="#eef2f8" stroke="#14305e"></rect>
|
||
<text x="487" y="40" text-anchor="middle" font-size="11" font-weight="bold" fill="#14305e">④ 목록 완성</text>
|
||
<text x="487" y="56" text-anchor="middle" font-size="9" fill="#555">POI·구조물 (이름+위경도)</text>
|
||
<line x1="116" y1="44" x2="144" y2="44" stroke="#14305e" stroke-width="1.5" marker-end="url(#f1a)"></line>
|
||
<line x1="254" y1="44" x2="282" y2="44" stroke="#14305e" stroke-width="1.5" marker-end="url(#f1a)"></line>
|
||
<line x1="392" y1="44" x2="420" y2="44" stroke="#14305e" stroke-width="1.5" marker-end="url(#f1a)"></line>
|
||
<!-- 보조입력 -->
|
||
<rect x="8" y="120" width="150" height="40" rx="6" fill="#f0fdf4" stroke="#16a34a"></rect>
|
||
<text x="83" y="137" text-anchor="middle" font-size="10" font-weight="bold" fill="#14532d">측점 CSV (실측 표고)</text>
|
||
<text x="83" y="152" text-anchor="middle" font-size="9" fill="#555">⑤ 높이 결정에 사용</text>
|
||
<rect x="178" y="120" width="160" height="40" rx="6" fill="#f0fdf4" stroke="#16a34a"></rect>
|
||
<text x="258" y="137" text-anchor="middle" font-size="10" font-weight="bold" fill="#14532d">드론 비행로그 (SRT/CSV)</text>
|
||
<text x="258" y="152" text-anchor="middle" font-size="9" fill="#555">위치·자세(yaw·pitch·roll)·초점</text>
|
||
<!-- 2행: 계산 -->
|
||
<rect x="368" y="114" width="170" height="52" rx="6" fill="#fff7e6" stroke="#b45309"></rect>
|
||
<text x="453" y="136" text-anchor="middle" font-size="11" font-weight="bold" fill="#b45309">⑥⑦ 좌표변환 + 투영</text>
|
||
<text x="453" y="152" text-anchor="middle" font-size="9" fill="#7c2d12">위경도→미터→카메라→화면</text>
|
||
<rect x="568" y="114" width="184" height="52" rx="6" fill="#fef2f2" stroke="#dc2626"></rect>
|
||
<text x="660" y="136" text-anchor="middle" font-size="11" font-weight="bold" fill="#991b1b">⑧ 영상 위 이름표</text>
|
||
<text x="660" y="152" text-anchor="middle" font-size="9" fill="#555">60fps 정합 + 떨림 평활</text>
|
||
<line x1="487" y1="70" x2="470" y2="112" stroke="#14305e" stroke-width="1.5" marker-end="url(#f1a)"></line>
|
||
<line x1="158" y1="140" x2="366" y2="140" stroke="#16a34a" stroke-width="1.2" marker-end="url(#f1a)"></line>
|
||
<line x1="338" y1="140" x2="366" y2="140" stroke="#16a34a" stroke-width="1.2"></line>
|
||
<line x1="538" y1="140" x2="566" y2="140" stroke="#14305e" stroke-width="1.5" marker-end="url(#f1a)"></line>
|
||
<text x="380" y="196" text-anchor="middle" font-size="9" fill="#888">— KMZ의 핀(④) + 측점 표고(⑤) + 드론 포즈를 합쳐 매 프레임 계산(⑥⑦) → 화면(⑧) —</text>
|
||
</svg>
|
||
<figcaption>그림 1. KMZ → 압축해제 → 파싱 → 목록 → (표고·드론 포즈 결합) → 좌표변환·투영 → 영상 표출</figcaption>
|
||
</figure>
|
||
|
||
<hr />
|
||
<h2 id="1단계--kmz는-압축된-선물-상자">1단계 — KMZ는 '압축된 선물 상자'</h2>
|
||
<ul>
|
||
<li><strong>KMZ = KML을 ZIP으로 압축한 것</strong>이에요. 상자(KMZ)를 열면 안에 설명서(doc.kml)가 들어 있어요.</li>
|
||
<li>사용자가 폴더를 선택하면, 프로그램은 폴더 안에서 <code>.kml</code>(맨 설명서)이 있으면 그걸 먼저 쓰고, 없으면 <code>.kmz</code>(상자)를 찾아 엽니다.</li>
|
||
<li>압축 풀기는 브라우저 안에서 <code>fflate</code> 라이브러리의 <code>unzipSync</code> 로 처리해요 — <strong>서버에 보내지 않고 내 컴퓨터에서 바로</strong> 풉니다.</li>
|
||
</ul>
|
||
<blockquote>
|
||
<p>코드: <a href="../client/src/utils/geoData.ts#L295">geoData.ts:295</a> <code>parseKmz()</code> — <code>.kml</code> 우선, 없으면 <code>.kmz</code>를 <code>unzipSync</code>로 해제해 <code>doc.kml</code>을 꺼냄. 실패하면 경고 후 건너뜀.</p>
|
||
</blockquote>
|
||
<h2 id="2단계--설명서kml-속은-폴더-나무--핀">2단계 — 설명서(KML) 속은 '폴더 나무 + 핀'</h2>
|
||
<p>KML은 XML이라는 형식의 글이에요. 그 안은 <strong>폴더 나무</strong> 모양이고, 잎사귀마다 <strong>핀(Placemark)</strong> 이 달려 있어요.</p>
|
||
<figure>
|
||
<svg viewBox="0 0 760 240" role="img" aria-label="KMZ 내부 구조와 핀 해부도">
|
||
<!-- 좌: 폴더 트리 -->
|
||
<rect x="8" y="10" width="300" height="220" rx="8" fill="#fdf8ef" stroke="#b45309"></rect>
|
||
<text x="158" y="32" text-anchor="middle" font-size="11.5" font-weight="bold" fill="#b45309">KMZ 상자 속 doc.kml (폴더 나무)</text>
|
||
<text x="30" y="58" font-size="10.5" font-weight="bold" fill="#333">📁 Document</text>
|
||
<text x="52" y="80" font-size="10.5" fill="#333">📁 02)지장물 — 📍📍📍 (POI)</text>
|
||
<text x="52" y="102" font-size="10.5" fill="#333">📁 03)교량 — 📍📍 (구조물)</text>
|
||
<text x="52" y="124" font-size="10.5" fill="#333">📁 04)터널 — 📍📍 (구조물)</text>
|
||
<text x="52" y="146" font-size="10.5" fill="#333">📁 05)출입문번호 — 📍 (POI)</text>
|
||
<text x="52" y="168" font-size="10.5" fill="#333">📁 06)구교 — 📍 (구조물)</text>
|
||
<text x="52" y="190" font-size="10.5" fill="#333">📍 철도역(KAKAO_RAIL) → POI+역사</text>
|
||
<text x="158" y="216" text-anchor="middle" font-size="9" fill="#888">폴더 이름을 보고 핀의 '종류'를 결정</text>
|
||
<!-- 우: Placemark 해부 -->
|
||
<rect x="340" y="10" width="412" height="220" rx="8" fill="#eef2f8" stroke="#14305e"></rect>
|
||
<text x="546" y="32" text-anchor="middle" font-size="11.5" font-weight="bold" fill="#14305e">핀(Placemark) 하나의 해부도</text>
|
||
<rect x="360" y="46" width="372" height="34" rx="4" fill="#fff" stroke="#94a3b8"></rect>
|
||
<text x="374" y="67" font-size="10.5" fill="#333"><tspan font-weight="bold"><name></tspan> 대전천교 — 핀의 이름</text>
|
||
<rect x="360" y="88" width="372" height="62" rx="4" fill="#fff" stroke="#94a3b8"></rect>
|
||
<text x="374" y="107" font-size="10.5" fill="#333"><tspan font-weight="bold"><description></tspan> 속성표(HTML 표)</text>
|
||
<text x="388" y="124" font-size="9.5" fill="#555">구분=대전천교 · 연장(m)=124.5 · 시설종별=2종 …</text>
|
||
<text x="388" y="140" font-size="9.5" fill="#555">→ 표의 (키, 값)을 한 줄씩 뽑아 저장</text>
|
||
<rect x="360" y="158" width="372" height="34" rx="4" fill="#fff" stroke="#94a3b8"></rect>
|
||
<text x="374" y="179" font-size="10.5" fill="#333"><tspan font-weight="bold"><coordinates></tspan> 127.41, 36.33, 12 ← 경도,위도[,고도]</text>
|
||
<text x="546" y="214" text-anchor="middle" font-size="9" fill="#888">좌표가 없으면 속성표의 lat/lon 사용 · 한국 범위(위도33~39, 경도124~132) 밖이면 버림</text>
|
||
</svg>
|
||
<figcaption>그림 2. (왼쪽) 폴더 나무를 따라 내려가며 핀 수집 · (오른쪽) 핀 1개에서 이름·속성표·좌표를 추출</figcaption>
|
||
</figure>
|
||
|
||
<p>프로그램은 나무를 <strong>재귀적으로</strong>(가지 끝까지) 걸어 내려가면서, 각 핀이 <strong>어느 폴더에 속했는지</strong>를 함께 기억해요.</p>
|
||
<blockquote>
|
||
<p>코드: <a href="../client/src/utils/geoData.ts#L376">geoData.ts:376</a> <code>walk()</code> — 폴더 트리 재귀. <a href="../client/src/utils/geoData.ts#L276">geoData.ts:276</a> <code>parseKmlDescProps()</code> — 속성표(HTML <code><tr><td></code>) 파싱. 구글어스 재저장본의 태그 변형(<code><tbody></code>, <code>&gt;</code> 엔티티 등)도 견고하게 처리.</p>
|
||
</blockquote>
|
||
<h2 id="3단계--핀을-종류별로-나누기">3단계 — 핀을 종류별로 나누기</h2>
|
||
<p>폴더 이름이 곧 분류 기준이에요. 학교에서 반 나누듯이:</p>
|
||
<table>
|
||
<thead>
|
||
<tr class="header">
|
||
<th>폴더</th>
|
||
<th>분류</th>
|
||
<th>어디에 표시되나</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr class="odd">
|
||
<td>03)교량 · 04)터널 · 06)구교</td>
|
||
<td><strong>구조물</strong> (bridge/tunnel)</td>
|
||
<td>영상 라벨 + 하단 스테이션바 마커</td>
|
||
</tr>
|
||
<tr class="even">
|
||
<td>02)지장물 · 05)출입문</td>
|
||
<td><strong>POI</strong> (지점)</td>
|
||
<td>영상 라벨</td>
|
||
</tr>
|
||
<tr class="odd">
|
||
<td>철도역 (KAKAO_RAIL)</td>
|
||
<td>POI <strong>+ 역사 구조물</strong></td>
|
||
<td>영상 라벨 + 스테이션바 <strong>양쪽</strong></td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
<ul>
|
||
<li>이때 속성표에서 <strong>연장(m)</strong>, <strong>시설종별(1종/2종/3종)</strong> 같은 값도 같이 담아요 → 나중에 시설등급 필터(1·2종만 보기)와 팝업 정보에 쓰입니다.</li>
|
||
<li>결과: <code>pois[]</code>(지점 목록) + <code>structures[]</code>(구조물 목록). <strong>KMZ가 이 데이터의 유일한 원본</strong>이라, 없으면 "데이터 누락" 경고를 띄워요.</li>
|
||
</ul>
|
||
<blockquote>
|
||
<p>코드: <a href="../client/src/utils/geoData.ts#L331">geoData.ts:331</a> <code>handlePlacemark()</code> — 폴더명 분기. <a href="../client/src/utils/geoData.ts#L539">geoData.ts:539</a> <code>loadGeoData</code> — KMZ 누락 시 <code>kmzMissing</code> 경고.</p>
|
||
</blockquote>
|
||
<h2 id="4단계--높이표고-정하기-핀은-몇-m-높이에-있나">4단계 — 높이(표고) 정하기: "핀은 몇 m 높이에 있나?"</h2>
|
||
<p>KMZ의 핀은 <strong>평면 위치(위도·경도)는 정확</strong>하지만, <strong>높이(z)는 없거나 부정확</strong>한 경우가 많아요. 높이가 틀리면 이름표가 하늘에 뜨거나 땅에 파묻혀 보여요. 그래서 이렇게 정합니다.</p>
|
||
<figure>
|
||
<svg viewBox="0 0 760 200" role="img" aria-label="표고 결정과 datum 정합">
|
||
<!-- 땅/지오이드/타원체 -->
|
||
<path d="M20 150 Q 200 120 380 140 T 740 130" fill="none" stroke="#8b5e34" stroke-width="3"></path>
|
||
<text x="60" y="140" font-size="10" fill="#8b5e34" font-weight="bold">땅(선로)</text>
|
||
<line x1="20" y1="178" x2="740" y2="178" stroke="#0ea5e9" stroke-width="1.5" stroke-dasharray="6 3"></line>
|
||
<text x="76" y="192" font-size="10" fill="#0369a1">바다 기준면(지오이드) — '정표고 0m'</text>
|
||
<!-- POI -->
|
||
<circle cx="420" cy="136" r="5" fill="#dc2626"></circle>
|
||
<text x="420" y="124" text-anchor="middle" font-size="10" font-weight="bold" fill="#991b1b">POI(교량)</text>
|
||
<line x1="420" y1="141" x2="420" y2="178" stroke="#dc2626" stroke-width="1.2" stroke-dasharray="3 2"></line>
|
||
<text x="452" y="166" font-size="9.5" fill="#991b1b">정표고 z = 측점 표고</text>
|
||
<!-- 드론 -->
|
||
<text x="560" y="48" text-anchor="middle" font-size="18">🛸</text>
|
||
<line x1="560" y1="58" x2="560" y2="130" stroke="#555" stroke-width="1" stroke-dasharray="3 2"></line>
|
||
<text x="640" y="70" font-size="9.5" fill="#555">드론 고도 = '타원체고' 기준</text>
|
||
<text x="640" y="84" font-size="9.5" fill="#b45309" font-weight="bold">→ 기준이 서로 다름!</text>
|
||
<rect x="60" y="20" width="360" height="52" rx="6" fill="#fff7e6" stroke="#b45309"></rect>
|
||
<text x="240" y="41" text-anchor="middle" font-size="10.5" font-weight="bold" fill="#b45309">높이 기준 맞추기(datum 정합)</text>
|
||
<text x="240" y="58" text-anchor="middle" font-size="10" fill="#7c2d12">타원체고 = 정표고 + 지오이드고(대전 ≈ 25.8m)</text>
|
||
</svg>
|
||
<figcaption>그림 3. 핀의 높이는 <b>가장 가까운 측점의 실측 표고</b>를 빌려 쓰고, 드론 고도와 기준을 맞추기 위해 지오이드고(+25.8m)를 더한다</figcaption>
|
||
</figure>
|
||
|
||
<p>높이 결정 우선순위 (셋 중 위에서부터):</p>
|
||
<ol type="1">
|
||
<li><strong>드래그 보정값</strong> — 사용자가 화면에서 직접 고친 값 (5단계 뒤 '보너스' 참고)</li>
|
||
<li><strong>DEM 자동 조회값</strong> — open-meteo API로 받은 그 지점 지면고도 (선택 기능)</li>
|
||
<li><strong>최근접 선로(측점) 표고</strong> — 기본값. 측점 CSV의 <strong>실측 정표고</strong>를 빌려 씀</li>
|
||
</ol>
|
||
<p>이 "측점 표고 빌려 쓰기" 덕분에 <strong>비싼 지형데이터(DEM) 없이도</strong> 하향각 오차가 42.7°→11.0°로 줄었어요.</p>
|
||
<blockquote>
|
||
<p>코드: <a href="../client/src/components/overlay/StationOverlay.tsx#L767">StationOverlay.tsx:767</a> — 지면고도 gz 결정. <a href="../client/src/utils/geoProjection.ts">geoProjection.ts</a> <code>geoidOffset</code> — datum 정합.</p>
|
||
</blockquote>
|
||
<h2 id="5단계--좌표-바꾸기-주소위경도를-미터-자로">5단계 — 좌표 바꾸기: 주소(위경도)를 '미터 자'로</h2>
|
||
<p>위도·경도는 <strong>둥근 지구 위의 주소</strong>라서, 그대로는 "몇 m 옆에 있나"를 계산하기 어려워요. 그래서 3번 갈아탑니다.</p>
|
||
<figure>
|
||
<svg viewBox="0 0 760 150" role="img" aria-label="좌표 3단 변환">
|
||
<defs><marker id="f4a" markerWidth="9" markerHeight="9" refX="6" refY="3" orient="auto"><path d="M0,0 L6,3 L0,6 Z" fill="#14305e"></path></marker></defs>
|
||
<rect x="8" y="35" width="160" height="72" rx="8" fill="#eef2f8" stroke="#14305e"></rect>
|
||
<text x="88" y="60" text-anchor="middle" font-size="11" font-weight="bold" fill="#14305e">🌍 위도·경도 (도)</text>
|
||
<text x="88" y="78" text-anchor="middle" font-size="9.5" fill="#555">36.33°N, 127.41°E</text>
|
||
<text x="88" y="94" text-anchor="middle" font-size="9" fill="#888">둥근 지구 주소</text>
|
||
<rect x="216" y="35" width="170" height="72" rx="8" fill="#fff" stroke="#555"></rect>
|
||
<text x="301" y="60" text-anchor="middle" font-size="11" font-weight="bold">🗺 평면 지도 좌표 (m)</text>
|
||
<text x="301" y="78" text-anchor="middle" font-size="9.5" fill="#555">EPSG:5186 한국 TM</text>
|
||
<text x="301" y="94" text-anchor="middle" font-size="9" fill="#888">동쪽 x m · 북쪽 y m</text>
|
||
<rect x="434" y="35" width="150" height="72" rx="8" fill="#fff" stroke="#555"></rect>
|
||
<text x="509" y="60" text-anchor="middle" font-size="11" font-weight="bold">🛸 드론 기준 (m)</text>
|
||
<text x="509" y="78" text-anchor="middle" font-size="9.5" fill="#555">동·북·위 상대거리</text>
|
||
<text x="509" y="94" text-anchor="middle" font-size="9" fill="#888">"드론에서 동쪽 40m…"</text>
|
||
<rect x="632" y="35" width="120" height="72" rx="8" fill="#fff7e6" stroke="#b45309"></rect>
|
||
<text x="692" y="60" text-anchor="middle" font-size="11" font-weight="bold" fill="#b45309">📷 카메라 눈</text>
|
||
<text x="692" y="78" text-anchor="middle" font-size="9.5" fill="#7c2d12">(Xc, Yc, Zc)</text>
|
||
<text x="692" y="94" text-anchor="middle" font-size="9" fill="#888">6단계에서</text>
|
||
<line x1="168" y1="71" x2="214" y2="71" stroke="#14305e" stroke-width="1.5" marker-end="url(#f4a)"></line>
|
||
<line x1="386" y1="71" x2="432" y2="71" stroke="#14305e" stroke-width="1.5" marker-end="url(#f4a)"></line>
|
||
<line x1="584" y1="71" x2="630" y2="71" stroke="#14305e" stroke-width="1.5" marker-end="url(#f4a)"></line>
|
||
<text x="191" y="60" text-anchor="middle" font-size="9" fill="#888">proj4</text>
|
||
<text x="409" y="60" text-anchor="middle" font-size="9" fill="#888">빼기(−드론)</text>
|
||
<text x="607" y="60" text-anchor="middle" font-size="9" fill="#888">회전행렬</text>
|
||
</svg>
|
||
<figcaption>그림 4. 위경도 → 한국 TM 평면(m) → 드론 기준 상대좌표 → 카메라 좌표, 3번의 갈아타기</figcaption>
|
||
</figure>
|
||
|
||
<ul>
|
||
<li><strong>위경도 → TM(m)</strong>: proj4 라이브러리로 한국 표준 평면좌표(EPSG:5186)로 변환. 이제 "미터"로 잴 수 있어요.</li>
|
||
<li><strong>TM → 드론 기준</strong>: 핀 위치에서 드론 위치를 <strong>빼면</strong>, "드론에서 동쪽 40m, 북쪽 12m, 아래 30m" 같은 상대 위치가 나와요.</li>
|
||
</ul>
|
||
<blockquote>
|
||
<p>코드: <a href="../client/src/utils/geoProjection.ts#L75">geoProjection.ts:75</a> EPSG:5186 정의, <a href="../client/src/utils/geoProjection.ts#L334">geoProjection.ts:334</a> <code>projectPoint()</code> 1부 — ENU 상대좌표 계산.</p>
|
||
</blockquote>
|
||
<h2 id="6단계--카메라의-눈으로-회전-드론이-보는-방향은">6단계 — 카메라의 눈으로 회전: "드론이 보는 방향은?"</h2>
|
||
<p>드론이 어느 쪽을 보고 있는지(비행로그의 <strong>yaw·pitch·roll</strong>)에 따라, 같은 핀도 화면 왼쪽에 보일 수도, 오른쪽에 보일 수도 있어요. 그래서 상대좌표를 <strong>회전행렬</strong>로 돌려서 "카메라가 보는 세계"의 좌표 (Xc, Yc, Zc)로 바꿉니다.</p>
|
||
<pre><code>R = Rz(−yaw) × Rx(pitch) × Ry(roll) ← 드론의 고개 돌림·끄덕임·갸웃을 그대로 재현
|
||
(Xc, Yc, Zc) = 카메라축변환 × Rᵀ × (동, 북, 위)</code></pre>
|
||
<ul>
|
||
<li><strong>Zc</strong> 는 "카메라 앞쪽으로 몇 m인가"예요. <strong>Zc ≤ 0이면 카메라 뒤</strong> → 그리지 않아요.</li>
|
||
<li>너무 멀거나(거리 필터) 화각(시야) 밖이어도 걸러냅니다.</li>
|
||
</ul>
|
||
<blockquote>
|
||
<p>코드: <a href="../client/src/utils/geoProjection.ts#L301">geoProjection.ts:301</a> <code>toCameraCoords()</code>, 회전 수식 주석은 <a href="../client/src/utils/geoProjection.ts#L319">geoProjection.ts:319</a>~.</p>
|
||
</blockquote>
|
||
<h2 id="7단계--핀홀-투영-3d-세상을-2d-사진으로">7단계 — 핀홀 투영: 3D 세상을 2D 사진으로</h2>
|
||
<p>카메라는 <strong>바늘구멍 사진기</strong>와 같아요. 3D 점이 구멍(렌즈)을 지나 필름(화면)에 맺힙니다.</p>
|
||
<figure>
|
||
<svg viewBox="0 0 760 190" role="img" aria-label="핀홀 투영">
|
||
<!-- 카메라 -->
|
||
<rect x="40" y="70" width="56" height="44" rx="6" fill="#333"></rect>
|
||
<circle cx="96" cy="92" r="7" fill="#111" stroke="#888"></circle>
|
||
<text x="68" y="132" text-anchor="middle" font-size="10" fill="#555">카메라(핀홀)</text>
|
||
<!-- 이미지 평면 -->
|
||
<rect x="150" y="30" width="10" height="124" fill="#e2e8f0" stroke="#94a3b8"></rect>
|
||
<text x="155" y="170" text-anchor="middle" font-size="10" fill="#555">화면(필름)</text>
|
||
<!-- 대상 -->
|
||
<circle cx="620" cy="52" r="7" fill="#dc2626"></circle>
|
||
<text x="620" y="38" text-anchor="middle" font-size="10.5" font-weight="bold" fill="#991b1b">교량 핀 (Xc, Yc, Zc)</text>
|
||
<!-- 광선 -->
|
||
<line x1="620" y1="52" x2="96" y2="92" stroke="#f59e0b" stroke-width="1.6" stroke-dasharray="6 3"></line>
|
||
<circle cx="155" cy="88" r="4.5" fill="#f59e0b"></circle>
|
||
<text x="182" y="80" font-size="10" fill="#b45309" font-weight="bold">맺힌 자리 = 화면 위치!</text>
|
||
<!-- 공식 -->
|
||
<rect x="240" y="120" width="480" height="54" rx="6" fill="#fffbeb" stroke="#f59e0b"></rect>
|
||
<text x="480" y="142" text-anchor="middle" font-size="11.5" font-weight="bold" fill="#92400e">가로 위치 = 0.5 + (Xc ÷ Zc) × (초점거리 ÷ 센서폭)</text>
|
||
<text x="480" y="162" text-anchor="middle" font-size="11.5" font-weight="bold" fill="#92400e">세로 위치 = 0.5 + (Yc ÷ Zc) × (초점거리 ÷ 센서높이)</text>
|
||
</svg>
|
||
<figcaption>그림 5. 핀홀 투영 — "멀수록(Zc↑) 가운데로 모이고, 옆에 있을수록(Xc↑) 가장자리로" 라는 원근법 그 자체</figcaption>
|
||
</figure>
|
||
|
||
<ul>
|
||
<li>결과는 <strong>0~1 사이의 정규 좌표</strong>예요. (0.5, 0.5)면 화면 정중앙.</li>
|
||
<li>÷Zc 가 바로 <strong>원근법</strong>이에요 — 멀리 있는 것일수록 화면 가운데 근처로 작게 모여요.</li>
|
||
<li>초점거리(focal)는 비행로그에서 프레임마다 읽고, 센서 크기는 16:9 영상 기준(36×20.25mm)을 써요. 영상마다 화각이 다르면 <strong>라벨 1개 드래그로 세로 화각을 역산</strong>해 맞출 수도 있어요(FOV 1점 보정).</li>
|
||
</ul>
|
||
<blockquote>
|
||
<p>코드: <a href="../client/src/utils/geoProjection.ts#L126">geoProjection.ts:126</a> <code>pixelFromCamera()</code> — 위 공식 그대로.</p>
|
||
</blockquote>
|
||
<h2 id="8단계--화면에-붙이기-액자-맞추기--떨림-잡기">8단계 — 화면에 붙이기: 액자 맞추기 + 떨림 잡기</h2>
|
||
<p>마지막으로 "0~1 정규 좌표"를 <strong>진짜 화면 픽셀</strong>로 바꿔 이름표를 놓아요. 여기엔 두 가지 마무리가 있어요.</p>
|
||
<figure>
|
||
<svg viewBox="0 0 760 180" role="img" aria-label="cover 정렬과 평활">
|
||
<!-- cover 정렬 -->
|
||
<rect x="20" y="24" width="330" height="130" rx="6" fill="#f8fafc" stroke="#94a3b8"></rect>
|
||
<text x="185" y="16" text-anchor="middle" font-size="10.5" font-weight="bold" fill="#333">① 액자 맞추기 (object-fit: cover)</text>
|
||
<rect x="45" y="44" width="280" height="90" fill="#e0f2fe" stroke="#0284c7" stroke-dasharray="5 3"></rect>
|
||
<rect x="20" y="44" width="330" height="90" fill="none" stroke="#dc2626" stroke-width="1.6"></rect>
|
||
<text x="185" y="76" text-anchor="middle" font-size="9.5" fill="#0369a1">플레이어 창(파랑 점선) 안에</text>
|
||
<text x="185" y="92" text-anchor="middle" font-size="9.5" fill="#991b1b">영상(빨강)이 꽉 차게 늘어나 양옆이 잘림</text>
|
||
<text x="185" y="112" text-anchor="middle" font-size="9.5" fill="#555">→ 오버레이도 <tspan font-weight="bold">같은 사각형</tspan>으로 계산해야 어긋나지 않음</text>
|
||
<text x="185" y="170" text-anchor="middle" font-size="9" fill="#888">정규좌표(0~1) × 영상표시영역 + 잘림offset = 화면 px</text>
|
||
<!-- 평활 -->
|
||
<rect x="400" y="24" width="340" height="130" rx="6" fill="#f8fafc" stroke="#94a3b8"></rect>
|
||
<text x="570" y="16" text-anchor="middle" font-size="10.5" font-weight="bold" fill="#333">② 떨림 잡기 (속도적응 평활)</text>
|
||
<polyline points="420,110 445,70 470,105 495,68 520,108 545,72" fill="none" stroke="#dc2626" stroke-width="1.4"></polyline>
|
||
<text x="482" y="130" text-anchor="middle" font-size="9.5" fill="#991b1b">GPS 떨림 그대로 → 라벨이 덜덜덜</text>
|
||
<polyline points="575,95 600,88 625,86 650,85 675,84 700,84" fill="none" stroke="#16a34a" stroke-width="2"></polyline>
|
||
<text x="640" y="130" text-anchor="middle" font-size="9.5" fill="#14532d">평활 후 → 착 붙음</text>
|
||
<text x="570" y="150" text-anchor="middle" font-size="9" fill="#888">떨림(방향 왔다갔다)=강하게 진정 · 실제 이동(방향 일정)=즉시 따라감</text>
|
||
</svg>
|
||
<figcaption>그림 6. (왼쪽) 영상이 창에 '꽉 차게' 잘려 보이므로 오버레이도 같은 규칙으로 정렬 · (오른쪽) 떨림만 골라 진정시키는 평활</figcaption>
|
||
</figure>
|
||
|
||
<ol type="1">
|
||
<li><strong>액자 맞추기(cover 정렬)</strong> — 영상은 플레이어 창을 비율 유지한 채 <strong>꽉 채우며 잘려</strong> 보여요. 그래서 이름표도 "창 기준"이 아니라 <strong>"잘린 영상 사각형 기준"</strong> 으로 놓아야 정확히 붙어요.</li>
|
||
<li><strong>떨림 잡기(평활)</strong> — GPS·자세값은 미세하게 떨려요. "방향이 왔다갔다(=떨림)"면 강하게 진정시키고, "방향이 일정(=실제 이동)"하면 즉시 따라가는 <strong>속도적응 평활</strong>로, 지연 없이 떨림만 없앱니다.</li>
|
||
<li>이 계산 전부를 매 화면 프레임(<strong>60fps, requestAnimationFrame</strong>)마다 다시 해요 — 그런데도 빠른 이유는, 무거운 준비(가시성 선별)는 미리 해 두고 프레임마다는 <strong>가벼운 재투영만</strong> 하기 때문이에요.</li>
|
||
</ol>
|
||
<blockquote>
|
||
<p>코드: <a href="../client/src/components/overlay/StationOverlay.tsx#L948">StationOverlay.tsx:948</a> cover 정렬, <a href="../client/src/components/overlay/StationOverlay.tsx#L49">StationOverlay.tsx:49</a> <code>smoothStep()</code> 평활.</p>
|
||
</blockquote>
|
||
<hr />
|
||
<h2 id="보너스--그래도-어긋나면-드래그-한-번">보너스 — 그래도 어긋나면? "드래그 한 번"</h2>
|
||
<p>지오코딩(주소→좌표 변환) 자체에 오차가 있으면 이름표가 조금 비껴 붙을 수 있어요. 그럴 땐 <strong>이름표를 마우스로 끌어 제자리에 놓기만 하면</strong> 돼요.</p>
|
||
<ul>
|
||
<li>화면에서 끈 위치(2D)를 7단계 공식의 <strong>역방향</strong>으로 계산해(역투영), 실제 좌표(위경도+높이)를 되찾아 저장해요.</li>
|
||
<li>2D→3D는 원래 답이 무한히 많지만(깊이를 모르므로), <strong>"카메라와의 거리는 그대로"</strong> 라는 약속을 둬서 드래그 한 번으로 가로·세로·높이가 <strong>동시에</strong> 맞춰집니다.</li>
|
||
<li>고친 값은 데이터셋별로 저장돼 다음에 열어도 유지돼요. (왕복 검증 오차 ≈ 0)</li>
|
||
</ul>
|
||
<blockquote>
|
||
<p>코드: <a href="../client/src/utils/geoProjection.ts#L149">geoProjection.ts:149</a> <code>worldFromPixel()</code> — 역투영.</p>
|
||
</blockquote>
|
||
<hr />
|
||
<h2 id="단계별-요약표">단계별 요약표</h2>
|
||
<table>
|
||
<thead>
|
||
<tr class="header">
|
||
<th style="text-align: center;">단계</th>
|
||
<th>하는 일</th>
|
||
<th>비유</th>
|
||
<th>코드 위치</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr class="odd">
|
||
<td style="text-align: center;">1</td>
|
||
<td>KMZ 압축 해제 → doc.kml</td>
|
||
<td>선물상자 열기</td>
|
||
<td>geoData.ts <code>parseKmz</code></td>
|
||
</tr>
|
||
<tr class="even">
|
||
<td style="text-align: center;">2</td>
|
||
<td>KML 폴더 나무 걷기, 핀 수집</td>
|
||
<td>설명서 읽기</td>
|
||
<td>geoData.ts <code>walk</code></td>
|
||
</tr>
|
||
<tr class="odd">
|
||
<td style="text-align: center;">3</td>
|
||
<td>폴더명으로 POI/구조물 분류</td>
|
||
<td>반 나누기</td>
|
||
<td>geoData.ts <code>handlePlacemark</code></td>
|
||
</tr>
|
||
<tr class="even">
|
||
<td style="text-align: center;">4</td>
|
||
<td>높이 결정(측점 표고) + 기준 맞춤(+25.8m)</td>
|
||
<td>키 재기, 자 통일</td>
|
||
<td>StationOverlay + geoidOffset</td>
|
||
</tr>
|
||
<tr class="odd">
|
||
<td style="text-align: center;">5</td>
|
||
<td>위경도 → 평면 m → 드론 기준 상대좌표</td>
|
||
<td>주소를 미터 자로</td>
|
||
<td>geoProjection.ts <code>projectPoint</code></td>
|
||
</tr>
|
||
<tr class="even">
|
||
<td style="text-align: center;">6</td>
|
||
<td>yaw·pitch·roll 회전 → 카메라 좌표</td>
|
||
<td>카메라 눈으로 보기</td>
|
||
<td>geoProjection.ts <code>toCameraCoords</code></td>
|
||
</tr>
|
||
<tr class="odd">
|
||
<td style="text-align: center;">7</td>
|
||
<td>핀홀 투영 → 화면 정규좌표(0~1)</td>
|
||
<td>바늘구멍 사진기</td>
|
||
<td>geoProjection.ts <code>pixelFromCamera</code></td>
|
||
</tr>
|
||
<tr class="even">
|
||
<td style="text-align: center;">8</td>
|
||
<td>cover 정렬 + 평활 + 60fps 렌더</td>
|
||
<td>액자 맞추고 떨림 잡기</td>
|
||
<td>StationOverlay.tsx</td>
|
||
</tr>
|
||
<tr class="odd">
|
||
<td style="text-align: center;">★</td>
|
||
<td>어긋나면 드래그 1회 역투영 보정</td>
|
||
<td>이름표 손으로 옮기기</td>
|
||
<td>geoProjection.ts <code>worldFromPixel</code></td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
<blockquote>
|
||
<p>더 깊은 수식·구현 근거: 「기술명세_GhiVideo_종합기술문서」 · 「구현상세_드론좌표_영상투영_소스코드매칭」 참조.</p>
|
||
</blockquote>
|
||
</body>
|
||
</html>
|