Files
GhiVideo/docs/쉬운설명_KMZ에서_영상표출까지_전과정.html
T
b23042andClaude Opus 4.8 fe25ce00f1 docs: 발표·특허·AI영상요약 자료 및 작업기록 추가
- 발표자료(좌표투영·측점기반재생) 갱신 + 그림/PDF
- 특허 출원 초안 및 도면
- AI 영상요약 서비스 작업기록(docs/history)
- 관련 scripts 추가

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 11:20:03 +09:00

455 lines
30 KiB
HTML
Raw 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 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> 그림과 이야기로 쓴 설명서예요. &quot;지도 파일(KMZ) 하나가 어떤 여행을 거쳐, 드론 영상 속 <strong>정확한 자리</strong>에 다리·터널 이름표로 나타날까?&quot; 그 여정을 <strong>8단계</strong>로 하나씩 따라가 봅니다. (파일·줄 번호는 2026-07-03 기준)</p>
</blockquote>
<hr />
<h2 id="한눈에-보는-전체-여정">한눈에 보는 전체 여정</h2>
<p>KMZ는 &quot;지도 위 핀들을 모아 둔 <strong>선물 상자</strong>&quot;예요. 이 상자가 열려서, 안의 핀 하나하나가 영상 화면의 정확한 픽셀에 이름표로 붙기까지 — 전체 흐름은 이렇습니다.</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는 &#39;압축된 선물 상자&#39;</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) 속은 &#39;폴더 나무 + 핀&#39;</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">폴더 이름을 보고 핀의 &#39;종류&#39;를 결정</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">&lt;name&gt;</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">&lt;description&gt;</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">&lt;coordinates&gt;</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>&lt;tr&gt;&lt;td&gt;</code>) 파싱. 구글어스 재저장본의 태그 변형(<code>&lt;tbody&gt;</code>, <code>&amp;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>이라, 없으면 &quot;데이터 누락&quot; 경고를 띄워요.</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단계 — 높이(표고) 정하기: &quot;핀은 몇 m 높이에 있나?&quot;</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">바다 기준면(지오이드) — &#39;정표고 0m&#39;</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">드론 고도 = &#39;타원체고&#39; 기준</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단계 뒤 &#39;보너스&#39; 참고)</li>
<li><strong>DEM 자동 조회값</strong> — open-meteo API로 받은 그 지점 지면고도 (선택 기능)</li>
<li><strong>최근접 선로(측점) 표고</strong> — 기본값. 측점 CSV의 <strong>실측 정표고</strong>를 빌려 씀</li>
</ol>
<p>&quot;측점 표고 빌려 쓰기&quot; 덕분에 <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단계 — 좌표 바꾸기: 주소(위경도)를 &#39;미터 자&#39;</h2>
<p>위도·경도는 <strong>둥근 지구 위의 주소</strong>라서, 그대로는 &quot;몇 m 옆에 있나&quot;를 계산하기 어려워요. 그래서 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">&quot;드론에서 동쪽 40m…&quot;</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)로 변환. 이제 &quot;미터&quot;로 잴 수 있어요.</li>
<li><strong>TM → 드론 기준</strong>: 핀 위치에서 드론 위치를 <strong>빼면</strong>, &quot;드론에서 동쪽 40m, 북쪽 12m, 아래 30m&quot; 같은 상대 위치가 나와요.</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단계 — 카메라의 눈으로 회전: &quot;드론이 보는 방향은?&quot;</h2>
<p>드론이 어느 쪽을 보고 있는지(비행로그의 <strong>yaw·pitch·roll</strong>)에 따라, 같은 핀도 화면 왼쪽에 보일 수도, 오른쪽에 보일 수도 있어요. 그래서 상대좌표를 <strong>회전행렬</strong>로 돌려서 &quot;카메라가 보는 세계&quot;의 좌표 (Xc, Yc, Zc)로 바꿉니다.</p>
<pre><code>R = Rz(yaw) × Rx(pitch) × Ry(roll) ← 드론의 고개 돌림·끄덕임·갸웃을 그대로 재현
(Xc, Yc, Zc) = 카메라축변환 × Rᵀ × (동, 북, 위)</code></pre>
<ul>
<li><strong>Zc</strong>&quot;카메라 앞쪽으로 몇 m인가&quot;예요. <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. 핀홀 투영 — &quot;멀수록(Zc↑) 가운데로 모이고, 옆에 있을수록(Xc↑) 가장자리로&quot; 라는 원근법 그 자체</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>마지막으로 &quot;0~1 정규 좌표&quot;<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. (왼쪽) 영상이 창에 &#39;꽉 차게&#39; 잘려 보이므로 오버레이도 같은 규칙으로 정렬 · (오른쪽) 떨림만 골라 진정시키는 평활</figcaption>
</figure>
<ol type="1">
<li><strong>액자 맞추기(cover 정렬)</strong> — 영상은 플레이어 창을 비율 유지한 채 <strong>꽉 채우며 잘려</strong> 보여요. 그래서 이름표도 &quot;창 기준&quot;이 아니라 <strong>&quot;잘린 영상 사각형 기준&quot;</strong> 으로 놓아야 정확히 붙어요.</li>
<li><strong>떨림 잡기(평활)</strong> — GPS·자세값은 미세하게 떨려요. &quot;방향이 왔다갔다(=떨림)&quot;면 강하게 진정시키고, &quot;방향이 일정(=실제 이동)&quot;하면 즉시 따라가는 <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="보너스--그래도-어긋나면-드래그-한-번">보너스 — 그래도 어긋나면? &quot;드래그 한 번&quot;</h2>
<p>지오코딩(주소→좌표 변환) 자체에 오차가 있으면 이름표가 조금 비껴 붙을 수 있어요. 그럴 땐 <strong>이름표를 마우스로 끌어 제자리에 놓기만 하면</strong> 돼요.</p>
<ul>
<li>화면에서 끈 위치(2D)를 7단계 공식의 <strong>역방향</strong>으로 계산해(역투영), 실제 좌표(위경도+높이)를 되찾아 저장해요.</li>
<li>2D→3D는 원래 답이 무한히 많지만(깊이를 모르므로), <strong>&quot;카메라와의 거리는 그대로&quot;</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>