feat: 지도 나침반(OSM/위성) 추가 + 측점 검색·마커 정확도 개선 + POI/영상 수정

- 지도 기반 나침반(노스업, 현재위치, 시야 역삼각형): 호버 확대·휠 줌·클릭 위성전환, OSM/Esri 타일 서버 프록시(/api/tile)
- 스테이션 검색: 실제 측점(chain) 기준 이동, 없으면 '측점 없음' 안내
- 역 마커: 직교 투영 측점 일치 시에만 표시, 미도착 종점은 추가 방식
- POI 팝업 겹침/재등장·라벨 정합 수정
- 영상 fps 데이터 기반 자동 산출
- 기술/발표/쉬운설명 문서 추가

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-02 18:03:30 +09:00
co-authored by Claude Opus 4.8
parent e9052143d7
commit e0ff5dd6d0
58 changed files with 9635 additions and 378 deletions
@@ -0,0 +1,179 @@
import { useEffect, useRef, useState } from 'react';
import type { MutableRefObject } from 'react';
import { useSettingsStore } from '../../store/settingsStore';
/**
* 지도 기반 나침반 (노스업) — OSM 타일 위에 '현재 위치(중심 고정 점)' + '드론 시야(반투명 역삼각형)'.
* - 노스업: 지도는 북쪽 위로 고정. 드론이 움직이면 지도가 팬(현재 위치가 항상 중앙).
* - 시야 역삼각형(▽): 중심(드론)에서 진행/촬영 방향으로 벌어지는 반투명 부채꼴. yaw 로 회전.
* - 마우스 오버: 위젯 자체가 커져(스케일 아님, 타일 재배치) 더 넓은 영역을 선명하게 표시.
* 휠로 줌 인/아웃. 벗어나면 기본 크기·기본 줌으로 복귀.
* - poseRef: 부모 RAF 가 매 프레임 { lat, lon, yaw(도, 0=N 시계+) } 갱신 → 내부 RAF 가 읽어
* 역삼각형 회전(즉시) + 지도 팬(transform) + 임계 이동/줌·크기 변경 시 타일 재배치.
* - 타일은 서버 프록시(/api/tile) 경유(외부 직접 로드는 CSP img-src/COEP 로 차단).
*/
export interface CompassPose { lat: number; lon: number; yaw: number }
const BASE_D = 200; // 기본 지름(px)
const HOVER_D = 400; // 오버 시 지름(px, 2배) — 위젯 확대(스케일 아님 → 더 넓은 영역, 선명)
const TILE = 256; // OSM 타일 크기
const MARGIN = TILE; // 팬 중 빈틈 방지용 여유 타일 폭
const RELAYOUT_PX = 96; // 중심이 이만큼 벗어나면 타일 재배치(팬 리셋)
const DEFAULT_ZOOM = 16;
const MIN_ZOOM = 12;
const MAX_ZOOM = 19;
/** 위경도 → 전역 픽셀(Web Mercator, zoom z). */
function project(lat: number, lon: number, z: number): { x: number; y: number } {
const n = 2 ** z;
const x = ((lon + 180) / 360) * n * TILE;
const s = Math.min(0.9999, Math.max(-0.9999, Math.sin((lat * Math.PI) / 180)));
const y = (0.5 - Math.log((1 + s) / (1 - s)) / (4 * Math.PI)) * n * TILE;
return { x, y };
}
export function MapCompass({ poseRef }: { poseRef: MutableRefObject<CompassPose> }) {
const mapStyle = useSettingsStore((s) => s.compassMapStyle); // 'street' | 'sat'
const setMapStyle = useSettingsStore((s) => s.setCompassMapStyle);
const src = mapStyle === 'sat' ? 'sat' : 'osm';
const [hovered, setHovered] = useState(false);
const [zoom, setZoomState] = useState(DEFAULT_ZOOM);
const zoomRef = useRef(zoom);
useEffect(() => { zoomRef.current = zoom; }, [zoom]);
const setZoom = (z: number): void => setZoomState(Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, Math.round(z))));
const D = hovered ? HOVER_D : BASE_D;
const HALF = D / 2;
// 타일 배치 기준 중심(이 좌표에 맞춰 타일을 깔고, 현재위치와의 차이는 transform 으로 팬).
const [tileCenter, setTileCenter] = useState<{ lat: number; lon: number } | null>(null);
const arrowRef = useRef<HTMLDivElement>(null);
const layerRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const tileCenterRef = useRef<{ lat: number; lon: number } | null>(null);
useEffect(() => { tileCenterRef.current = tileCenter; }, [tileCenter]);
// 줌 변경 시 현재 위치 기준으로 타일 재배치(줌 전환 튐 방지).
useEffect(() => {
const p = poseRef.current;
if (p && isFinite(p.lat) && isFinite(p.lon)) setTileCenter({ lat: p.lat, lon: p.lon });
}, [zoom, poseRef]);
// 휠 줌(페이지 스크롤 대신). native 리스너(passive:false)로 preventDefault.
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const onWheel = (e: WheelEvent): void => {
e.preventDefault();
setZoom(zoomRef.current + (e.deltaY < 0 ? 1 : -1)); // 위로 스크롤 = 확대
};
el.addEventListener('wheel', onWheel, { passive: false });
return () => el.removeEventListener('wheel', onWheel);
}, []);
useEffect(() => {
let raf = 0;
const tick = (): void => {
raf = requestAnimationFrame(tick);
const p = poseRef.current;
if (!p || !isFinite(p.lat) || !isFinite(p.lon)) return;
const z = zoomRef.current;
if (arrowRef.current) arrowRef.current.style.transform = `rotate(${p.yaw}deg)`;
const tc = tileCenterRef.current;
if (!tc) { setTileCenter({ lat: p.lat, lon: p.lon }); return; }
const cur = project(p.lat, p.lon, z);
const base = project(tc.lat, tc.lon, z);
const dx = cur.x - base.x, dy = cur.y - base.y;
if (layerRef.current) layerRef.current.style.transform = `translate(${-dx}px, ${-dy}px)`;
if (Math.hypot(dx, dy) > RELAYOUT_PX) setTileCenter({ lat: p.lat, lon: p.lon });
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [poseRef]);
// 벗어나면 기본값 복귀(기본 크기 + 기본 줌).
const onLeave = (): void => { setHovered(false); setZoomState(DEFAULT_ZOOM); };
// 타일 배치 — 기준 중심(tileCenter)을 뷰포트 중앙에 두고, 뷰포트+여유를 덮는 타일 나열.
const tiles: { key: string; url: string; left: number; top: number }[] = [];
if (tileCenter) {
const c = project(tileCenter.lat, tileCenter.lon, zoom);
const n = 2 ** zoom;
const minTx = Math.floor((c.x - HALF - MARGIN) / TILE);
const maxTx = Math.floor((c.x + HALF + MARGIN) / TILE);
const minTy = Math.floor((c.y - HALF - MARGIN) / TILE);
const maxTy = Math.floor((c.y + HALF + MARGIN) / TILE);
for (let tx = minTx; tx <= maxTx; tx++) {
for (let ty = minTy; ty <= maxTy; ty++) {
if (ty < 0 || ty >= n) continue;
const wx = ((tx % n) + n) % n; // 경도 래핑
tiles.push({
key: `${src}_${zoom}_${tx}_${ty}`,
url: `/api/tile/${src}/${zoom}/${wx}/${ty}`,
left: tx * TILE - c.x + HALF,
top: ty * TILE - c.y + HALF,
});
}
}
}
return (
<div
ref={containerRef}
onMouseEnter={() => setHovered(true)}
onMouseLeave={onLeave}
onClick={() => setMapStyle(mapStyle === 'sat' ? 'street' : 'sat')}
title="클릭: 위성 ↔ 일반 전환"
style={{ position: 'absolute', top: 14, right: 14, width: D, height: D, zIndex: hovered ? 40 : 30, pointerEvents: 'auto', cursor: 'pointer' }}
>
{/* 원형 클립 지도 */}
<div
style={{
position: 'absolute', inset: 0, borderRadius: '50%', overflow: 'hidden',
background: 'rgba(16,22,32,0.55)', border: '3px solid rgba(255,255,255,0.92)', boxSizing: 'border-box',
}}
>
{/* 팬 레이어 — RAF 가 transform 으로 이동. 타일은 tileCenter 기준 배치. */}
<div ref={layerRef} style={{ position: 'absolute', inset: 0, willChange: 'transform' }}>
{tiles.map((t) => (
<img
key={t.key} src={t.url} width={TILE} height={TILE} alt="" draggable={false}
style={{ position: 'absolute', left: t.left, top: t.top, maxWidth: 'none' }}
/>
))}
</div>
{!tileCenter && (
<div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'rgba(255,255,255,0.7)', fontSize: 11 }}>
</div>
)}
</div>
{/* 드론 시야(역삼각형 ▽) — 중심(드론)에서 촬영방향으로 벌어짐. yaw 로 회전.
주황 그라데이션: 근거리(드론) 진하게 → 멀수록 투명. 보더 없음. */}
<div ref={arrowRef} style={{ position: 'absolute', inset: 0, transformOrigin: '50% 50%' }}>
<svg viewBox="0 0 100 100" width={D} height={D} style={{ position: 'absolute', inset: 0 }}>
<defs>
<linearGradient id="fovGrad" gradientUnits="userSpaceOnUse" x1="50" y1="50" x2="50" y2="13">
<stop offset="0%" stopColor="#ff8800" stopOpacity="0.85" />
<stop offset="100%" stopColor="#ff8800" stopOpacity="0" />
</linearGradient>
</defs>
{/* 꼭짓점이 현재위치 점에 붙음(50). */}
<polygon points="50,50 27,13 73,13" fill="url(#fovGrad)" />
</svg>
</div>
{/* 현재 위치 점(중심) + 상단 N(노스업 고정, 크게·붉은·반투명) — SVG 라 위젯 크기에 맞춰 스케일. */}
<svg viewBox="0 0 100 100" width={D} height={D} style={{ position: 'absolute', inset: 0 }}>
{/* 현재 위치 점 — 빨강 단색, 보더 없음. */}
<circle cx="50" cy="50" r="3.6" fill="rgba(226,35,26,0.8)" />
<text
x="50" y="13.5" textAnchor="middle" fontSize="15" fontWeight="800"
fill="#e22319" textLength="9" lengthAdjust="spacingAndGlyphs"
>N</text>
</svg>
</div>
);
}
+1 -1
View File
@@ -6,7 +6,7 @@ import { forwardRef } from 'react';
* - 상단 흰 삼각형 인덱스 + 외곽 링은 고정(위 = 드론 진행방향 = 헤딩).
* ref 는 root div — `style.setProperty('--rot', `${-yaw}deg`)`.
*/
const D = 150; // 나침반 한 변(px)
const D = 200; // 나침반 한 변(px) — 지도 나침반(BASE_D)과 동일
// 눈금: 6° 간격(60개). 0/90/180/270=장, 30°배수=중, 그 외=단.
const TICKS = Array.from({ length: 60 }, (_, i) => {
+144 -35
View File
@@ -20,6 +20,7 @@ import {
import { useGeoStore } from '../../store/geoStore';
import { useSettingsStore } from '../../store/settingsStore';
import { Minimap } from './Minimap';
import { MapCompass, type CompassPose } from './MapCompass';
import type { GeoPoint, CenterlinePoint, PoiOverrideMap, RouteStructure } from '../../types/geo';
const VIDEO_FPS = 30000 / 1001;
@@ -253,6 +254,12 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
const visibleRef = useRef(visible);
const videoReadyRef = useRef(videoReady);
const videoSizeRef = useRef({ w: videoWidth, h: videoHeight });
const barHeightRef = useRef(barHeight); // 하단 스테이션바 높이(px) — 팝업/라벨 유효 하단 = H barHeight
useEffect(() => { barHeightRef.current = barHeight; }, [barHeight]);
// 영상 실제 fps — 부모(VideoPlayer)가 데이터(마지막 프레임 번호)+영상길이로 계산해 prop 으로 전달.
// 프레임번호↔시간 변환의 기준(고정 29.97 대신 영상별 자동). 0/미정이면 VIDEO_FPS 폴백.
const fpsRef = useRef(fps > 0 ? fps : VIDEO_FPS);
useEffect(() => { if (fps > 0) fpsRef.current = fps; }, [fps]);
// object-fit:cover 변환 (RAF에서 매 프레임 갱신, 포인터 핸들러도 참조). 정규(0~1, 영상프레임) → 화면 px.
const coverRef = useRef({ offX: 0, offY: 0, dispW: 0, dispH: 0, W: 0, H: 0 });
const worldOriginRef = useRef<{ lat: number; lon: number; alt: number } | undefined>(undefined);
@@ -268,6 +275,8 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
// 나침반 미니맵(DOM) root — RAF 에서 --rot(=-yaw) 갱신. minimapRotRef=누적 회전(360° 언랩).
const minimapRef = useRef<HTMLDivElement>(null);
const minimapRotRef = useRef(0);
// 지도 나침반용 현재 위치/방위 — RAF 가 매 프레임 갱신, MapCompass 가 읽음.
const mapPoseRef = useRef<CompassPose>({ lat: 0, lon: 0, yaw: 0 });
// UI state
const [params, setParams] = useState<CameraParams>(DEFAULT_CAMERA_PARAMS);
@@ -324,7 +333,17 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
auto?: boolean };
const [infoPopups, setInfoPopups] = useState<InfoPopup[]>([]);
const infoPopupsRef = useRef<InfoPopup[]>([]);
useEffect(() => { infoPopupsRef.current = infoPopups; }, [infoPopups]);
useEffect(() => {
infoPopupsRef.current = infoPopups;
// 팝업 제거 경로가 여럿(RAF off-4프레임 / sync toRemoveIds / 수동 ✕·ESC·빈곳클릭)인데
// 보조 맵 정리는 RAF 경로만 했었다 → sync/수동 제거 시 popupPosRef 잔존. 그러면 재추가 때
// hidden-mount 가드(!popupPosRef.has)가 무력화돼 라벨 없이 팝업만 stale 위치에 번쩍인다.
// → infoPopups 변경 시 사라진 id 의 보조 맵을 일괄 정리(단일 출처). (popupElsRef 는 ref 콜백이 정리.)
const ids = new Set(infoPopups.map(p => p.id));
for (const id of popupPosRef.current.keys()) if (!ids.has(id)) popupPosRef.current.delete(id);
for (const id of popupMissRef.current.keys()) if (!ids.has(id)) popupMissRef.current.delete(id);
for (const id of popupFlipRef.current.keys()) if (!ids.has(id)) popupFlipRef.current.delete(id);
}, [infoPopups]);
// 영상 진행 방향('상'|'하') — 겹치는 상/하 형제 구조물 중 이 방향을 우선 표시. (값 주입은 아래 routeMeta 구독 후)
const routeDirRef = useRef<'상' | '하' | null>(null);
// 이번 프레임 가시 컴팩트-구조물 (title → {category, compact}). RAF 가 채우고 sync 인터벌이 읽음.
@@ -332,6 +351,7 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
const popupElsRef = useRef<Map<string, HTMLDivElement>>(new Map());
const popupMissRef = useRef<Map<string, number>>(new Map()); // 라벨이 화면 밖인 연속 프레임 수(제거 유예)
const popupPosRef = useRef<Map<string, { x: number; y: number }>>(new Map()); // 팝업 위치 EMA(물결 방지)
const popupFlipRef = useRef<Map<string, 'above' | 'below'>>(new Map()); // 팝업 배치면(위/아래) — 히스테리시스로 경계 왕복(번쩍임) 방지
// 라벨 히트박스(아이콘+글자 영역, 화면 px) — RAF 가 매 프레임 갱신, 클릭/호버 히트테스트에 사용.
const labelHitRef = useRef<{ kind: 'poi' | 'station'; title: string; x0: number; y0: number; x1: number; y1: number }[]>([]);
const hasPopupRef = useRef(false);
@@ -339,6 +359,9 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
const poiDroneHeightRef = useRef(DISPLAY_DEFAULTS.poiDroneHeight);
const droneHeightDropRef = useRef(DISPLAY_DEFAULTS.droneHeightDrop);
const overridesRef = useRef<PoiOverrideMap>({});
// POI 라벨이 연속으로 화면 안에 그려진 프레임 수(title→streak). 팝업 '추가'에 히스테리시스를 줘
// 경계에서 라벨이 미세하게 깜빡일 때(1~2프레임) 팝업이 재생성돼 번쩍이는 것을 막는다.
const poiOnScreenStreakRef = useRef<Map<string, number>>(new Map());
// 드래그 상태: 선택 POI + lat/lon(고정) + 시작 z + 시작 화면좌표. 드롭 시 세로위치로 z 역산.
const dragRef = useRef<{ title: string; lat: number; lon: number; z0: number; sx: number; sy: number } | null>(null);
const dragPosRef = useRef<{ x: number; y: number } | null>(null);
@@ -370,7 +393,8 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
useEffect(() => { editModeRef.current = editMode; }, [editMode]);
useEffect(() => { fovModeRef.current = fovMode; }, [fovMode]);
// 컴팩트 자동 팝업 동기화 — 가시 구조물(visStructRef)마다 라벨 옆 DOM 팝업을 자동 생성/제거.
// 컴팩트 자동 팝업 동기화 — visStructRef(연속 온스크린 라벨) 마다 라벨 옆 DOM 팝업을 자동 '생성'만.
// 제거는 RAF 가 단일 권한(onScreenLabels + 4프레임 유예)으로 담당 → add/remove 충돌(번쩍임) 제거.
// (캔버스 텍스트는 흐려서 DOM 팝업으로 선명하게. 위치는 RAF 가 displayed 좌표로 매 프레임 갱신.)
// 60fps 루프에서 setState 남발 방지 → 150ms 인터벌로 가시집합 변화시에만 갱신.
useEffect(() => {
@@ -392,12 +416,10 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
props: obj?.props, compact: info.compact, auto: true, expanded: false,
});
});
const toRemoveIds = cur.filter(p => p.auto && !vis.has(p.title)).map(p => p.id);
if (toAdd.length || toRemoveIds.length) {
if (toAdd.length) {
setInfoPopups(prev => {
const kept = prev.filter(p => !toRemoveIds.includes(p.id));
const ids = new Set(kept.map(p => p.id));
return [...kept, ...toAdd.filter(p => !ids.has(p.id))];
const ids = new Set(prev.map(p => p.id));
return [...prev, ...toAdd.filter(p => !ids.has(p.id))];
});
}
};
@@ -449,6 +471,10 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
routeDirRef.current = d.includes('하') ? '하' : d.includes('상') ? '상' : null;
}, [routeMeta]);
// POI 겹침제외 토글(설정 스토어). false면 겹침 억제 없이 모든 POI 표시.
const compassType = useSettingsStore(s => s.compassType);
const setCompassType = useSettingsStore(s => s.setCompassType);
const compassMapStyle = useSettingsStore(s => s.compassMapStyle);
const setCompassMapStyle = useSettingsStore(s => s.setCompassMapStyle);
const poiOverlapExclude = useSettingsStore(s => s.poiOverlapExclude);
const overlapExcludeRef = useRef(true);
useEffect(() => { overlapExcludeRef.current = poiOverlapExclude; }, [poiOverlapExclude]);
@@ -833,11 +859,12 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
if (!droneFramesLoaded) return;
const frames = allDroneFramesRef.current;
if (!frames.length) return;
let best = frames[0], bestIdx = 0, bestD = Math.abs((best.frame ?? 0) / VIDEO_FPS - currentTime);
const vfps = fpsRef.current;
let best = frames[0], bestIdx = 0, bestD = Math.abs((best.frame ?? 0) / vfps - currentTime);
for (let i = 0; i < frames.length; i++) {
const d = Math.abs(frames[i].frame / VIDEO_FPS - currentTime);
const d = Math.abs(frames[i].frame / vfps - currentTime);
if (d < bestD) { bestD = d; best = frames[i]; bestIdx = i; }
if (bestD < 1 / VIDEO_FPS / 2) break;
if (bestD < 1 / vfps / 2) break;
}
currentFrameNumRef.current = best.frame;
currentFrameIdxRef.current = bestIdx;
@@ -933,6 +960,16 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
// 이번 프레임 라벨 히트박스(아이콘+글자, 화면 px) 수집 → 끝에서 labelHitRef 갱신.
const hitBoxes: { kind: 'poi' | 'station'; title: string; x0: number; y0: number; x1: number; y1: number }[] = [];
// 이번 프레임에 '실제로 화면 안에 그려진' 라벨 id(`kind:title`) 집합. 팝업 표시의 단일 기준 —
// 십자마커/텍스트가 뷰포트 안에 있을 때만 채운다. 팝업은 이 집합에 있을 때만 보이게 해
// "라벨은 화면 밖(안 보임)인데 팝업만 clamp 되어 뜨는" 문제를 원천 차단한다.
const onScreenLabels = new Set<string>();
// 유효 하단 = 화면 높이 − 스테이션바 높이. 팝업/라벨 가시 기준을 '모니터'가 아니라
// '영상 재생 영역(스테이션바 위쪽)' 으로 잡는다. (라벨이 바 밑으로 내려가면 사라지게)
const HB = H - barHeightRef.current;
// 히트박스가 '영상 영역(0..W, 0..HB)' 과 겹치는지(라벨이 눈에 보이는지). -2px 안쪽 요구로 경계 애매함 제거.
const labelOnScreen = (x0: number, y0: number, x1: number, y1: number): boolean =>
x1 >= 2 && x0 <= W - 2 && y1 >= 2 && y0 <= HB - 2;
const cache = renderCacheRef.current;
if (cache) {
@@ -941,7 +978,7 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
const estTime = timeRef
? timeRef.current
: currentTimeSecRef.current + (performance.now() - timeUpdateWallRef.current) / 1000;
const estFrame = estTime * VIDEO_FPS;
const estFrame = estTime * fpsRef.current;
// 연속 보간 포즈(라인·라벨 공통) — 매 RAF 프레임 직접 투영해 부드럽게.
const dronePose = poseAt(estFrame) ?? (allDroneFramesRef.current.length
@@ -951,7 +988,7 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
// 나침반 미니맵(heading-up) 회전 — 영상과 즉시 동기. dronePose.yaw 는 ±smoothHalf(기본 60fr ≈2s)
// 평활이라 회전 시 지연 → 가벼운 평활(±3fr)의 yaw 를 따로 보간해 사용. 360° 누적 언랩(점프 방지).
if (minimapRef.current) {
{
const arr = allDroneFramesRef.current;
let rawYaw = dronePose.yaw;
if (arr.length) {
@@ -964,11 +1001,19 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
let dy = b.yaw - a.yaw; dy = ((dy + 540) % 360) - 180;
rawYaw = a.yaw + dy * frac;
}
const target = -(rawYaw + paramsRef.current.yawOffset);
const headingDeg = rawYaw + paramsRef.current.yawOffset; // 실제 방위(0=N, 시계+)
// 지도 나침반(노스업)용 현재 위치/방위 — MapCompass 가 읽음.
mapPoseRef.current.lat = dronePose.lat;
mapPoseRef.current.lon = dronePose.lon;
mapPoseRef.current.yaw = headingDeg;
// 아날로그 나침반(heading-up) 회전 — 렌더 중일 때만. 360° 누적 언랩(점프 방지).
if (minimapRef.current) {
const target = -headingDeg;
const delta = (((target - minimapRotRef.current) % 360) + 540) % 360 - 180;
minimapRotRef.current += delta;
minimapRef.current.style.setProperty('--rot', `${minimapRotRef.current}deg`);
}
}
// 선로 중심선 (선형) — 독립 토글
if (showCenterlineRef.current && lines.centerlineSegs.length > 0) {
@@ -1063,6 +1108,7 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
// 히트박스(마커 선 ~ 글자 끝)
const tw = ctx.measureText(cleanTitle(stA.title)).width;
hitBoxes.push({ kind: 'station', title: stA.title, x0: x - 8, y0: y - 12, x1: lx + tw + 2, y1: y + 12 });
if (labelOnScreen(x - 8, y - 12, lx + tw + 2, y + 12)) onScreenLabels.add(`station:${stA.title}`);
});
}
@@ -1109,15 +1155,25 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
// 히트박스(십자 마커 ~ 글자 끝) — 아이콘·글자 어디를 눌러도 선택되게. (라벨 행 위치 기준)
const tw = ctx.measureText(label).width;
hitBoxes.push({ kind: 'poi', title: poiA.title, x0: px - r, y0: labelY - 13, x1: lx + tw + 2, y1: labelY + 13 });
const poiVisible = labelOnScreen(px - r, labelY - 13, lx + tw + 2, labelY + 13);
if (poiVisible) onScreenLabels.add(`poi:${poiA.title}`);
// 연속 온스크린 프레임 수 갱신(화면 밖이면 0으로 리셋).
const streak = poiVisible ? (poiOnScreenStreakRef.current.get(poiA.title) ?? 0) + 1 : 0;
poiOnScreenStreakRef.current.set(poiA.title, streak);
ctx.textBaseline = 'alphabetic';
// 컴팩트 팝업 자동표시: 단일 라벨만(동일좌표 다중행은 팝업 겹침 방지 위해 제외 → 클릭 시 개별 표시).
if (poiA.compact.length && poiA.labelRowCount <= 1) visStructRef.current.set(poiA.title, { category: poiA.category, compact: poiA.compact });
// 컴팩트 팝업 자동생성 후보 등록(sync 인터벌이 읽음). 조건:
// ① 라벨이 '연속' ADD_STREAK 프레임 이상 화면 안(경계 깜빡임에 팝업 재생성 방지),
// ② 컴팩트 필드 있음, ③ 단일 라벨(동일좌표 다중행 제외 → 클릭 시 개별 표시).
const ADD_STREAK = 6; // ~100ms 안정적으로 보여야 팝업 생성
if (streak >= ADD_STREAK && poiA.compact.length && poiA.labelRowCount <= 1) visStructRef.current.set(poiA.title, { category: poiA.category, compact: poiA.compact });
});
// 이번 프레임에 안 보인 라벨의 표시상태 제거 → 재등장 시 새 위치에서 시작
// (오래된 좌표가 남아 이상치 거부로 늦게 뜨는 것 방지)
for (const k of displayedStRef.current.keys()) if (!seenSt.has(k)) displayedStRef.current.delete(k);
for (const k of displayedPoiRef.current.keys()) if (!seenPoi.has(k)) displayedPoiRef.current.delete(k);
// poiMarkers 에서 빠진 POI 는 streak 도 정리(재등장 시 0부터 다시 쌓임).
for (const k of poiOnScreenStreakRef.current.keys()) if (!seenPoi.has(k)) poiOnScreenStreakRef.current.delete(k);
}
} // end if(visible) — 측점/POI 라벨
} // end if(cache) — 중심선/드론경로/측점/POI
@@ -1149,28 +1205,34 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
const sep = id.indexOf(':');
const kind = id.slice(0, sep), title = id.slice(sep + 1);
const disp = (kind === 'poi' ? displayedPoiRef.current : displayedStRef.current).get(title);
// 라벨이 없거나(필터됨) 보이는 컨테이너 밖(cover 크롭 포함)이면 '사라짐'으로 간주.
// 팝업 표시 = '이 프레임에 라벨이 실제로 화면 안에 그려졌는가'(onScreenLabels)로만 판정.
// 좌표 ±16 슬랙 + clamp 조합은 라벨(특히 텍스트)이 화면 밖인데 팝업만 clamp 되어 뜨는
// 문제를 만들었음 → 라벨 가시성과 완전히 일치시킨다.
const sx = disp ? offX + disp.x * dispW : 0, sy = disp ? offY + disp.y * dispH : 0;
const off = !disp || sx < -16 || sx > W + 16 || sy < -16 || sy > H + 16;
const off = !disp || !onScreenLabels.has(id);
if (off) {
const m = (popupMissRef.current.get(id) ?? 0) + 1;
popupMissRef.current.set(id, m);
if (m >= 4) toRemove.push(id); // 4프레임(~0.07s) 연속 밖이면 제거(라벨과 함께 사라짐)
else el.style.visibility = 'hidden';
// 라벨이 화면에서 벗어나면 '즉시' 제거(유예 없음) → 하단 이탈 즉시 사라짐.
// 유예를 두면 그 사이 흔들림으로 라벨이 경계로 잠깐 되돌아올 때 팝업이 다시 보였다 사라짐.
// 재등장은 sync 의 ADD_STREAK(연속 안정 프레임) 게이트가 막는다(짧은 복귀로는 재생성 안 됨).
el.style.visibility = 'hidden';
toRemove.push(id);
return;
}
popupMissRef.current.set(id, 0);
const ph = el.offsetHeight || 120;
const pw = el.offsetWidth || 240;
// 기본은 라벨 '아래'. 아래 공간이 부족하면(화면 하단 근접) 라벨 '위'로 플립 →
// 라벨과 겹치거나 화면 밖으로 잘리는 것을 방지. 위로도 부족하면 화면 안으로 클램프.
const GAP = 16;
// 라벨(십자+글자)은 sy 를 중심으로 상하 ~LABEL_HALF 만큼 차지한다. GAP 을 sy(중심) 기준으로
// 잡으면 라벨 높이만큼 먹혀 팝업이 글자에 붙는다 → 라벨 '바깥 가장자리' 기준으로 띄운다.
const LABEL_HALF = 15; // 라벨 반높이(px) — sy 중심 ± 이 값이 글자/아이콘 영역
const GAP = 12; // 라벨 가장자리 ~ 팝업 사이 실제 여백
const tx = Math.min(Math.max(8, sx - 10), Math.max(8, W - pw - 4));
let ty = sy + GAP; // 아래
if (ty + ph > H - 2) {
const aboveTy = sy - GAP - ph; // 위로 플립 (팝업 하단이 라벨 위 ~GAP 지점)
ty = aboveTy >= 2 ? aboveTy : Math.max(2, H - 2 - ph);
}
// 기본은 라벨 '아래'. 아래 공간이 부족하면(화면 하단 근접) 라벨 '위'로 플립.
// ※ 히스테리시스(sticky): 경계에서 sy 가 미세하게 오르내려도 위↔아래로 왕복하지 않도록,
// '위'로 간 뒤엔 아래에 HYST 이상 여유가 생겨야만 '아래'로 복귀. (왕복=팝업 순간이동=번쩍임 방지)
// 팝업은 '항상 라벨 아래'(flip 없음, 하단 클램프 없음). 라벨을 따라 자유롭게 내려가다
// 스테이션바 영역으로 진입하면 팝업 컨테이너의 overflow-hidden(HB 아래 클립)이 시각적으로
// 가린다 → 바 뒤로 슬라이드되는 것처럼 보임. 라벨이 바 밑(HB)으로 내려가면 위 off 판정으로
// 팝업 제거(라벨과 함께 사라짐). (이전 flip=라벨 위로 튐 / clamp=바에 걸침 문제 모두 제거)
let ty = Math.max(2, sy + LABEL_HALF + GAP);
el.style.visibility = 'visible';
const pp = popupPosRef.current.get(id);
const PA = 0.18;
@@ -1183,7 +1245,7 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
el.style.top = `${Math.round(ny)}px`;
});
if (toRemove.length) {
toRemove.forEach(id => { popupMissRef.current.delete(id); popupPosRef.current.delete(id); });
toRemove.forEach(id => { popupMissRef.current.delete(id); popupPosRef.current.delete(id); popupFlipRef.current.delete(id); });
setInfoPopups(prev => prev.filter(p => !toRemove.includes(p.id)));
}
}
@@ -1442,10 +1504,15 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
/>
{/* 나침반 미니맵 — 우측 상단(기존 캔버스 나침반 대체). 핀은 RAF 가 드론 방위로 회전. */}
{geoDataLoaded && <Minimap ref={minimapRef} />}
{geoDataLoaded && (compassType === 'map'
? <MapCompass poseRef={mapPoseRef} />
: <Minimap ref={minimapRef} />)}
{/* 라벨 속성 팝업(다중) — 위치는 RAF 가 라벨을 따라 갱신. DOM 이라 텍스트 선명.
컴팩트(기본 5필드) ↔ 전체 토글은 팝업 클릭. 자동(auto) 팝업은 ✕ 없음(가시 구조물 따라 표시). */}
컴팩트(기본 5필드) ↔ 전체 토글은 팝업 클릭. 자동(auto) 팝업은 ✕ 없음(가시 구조물 따라 표시).
▶ 클립 컨테이너: 높이 = 영상영역(H barHeight). overflow-hidden 으로 스테이션바 영역을 가려
팝업이 라벨 따라 내려가면 바 뒤로 슬라이드되어 사라짐(flip/걸침 없이). */}
<div className="absolute left-0 right-0 top-0 overflow-hidden pointer-events-none z-40" style={{ bottom: barHeight }}>
{infoPopups.map(pp => {
const showFull = pp.expanded || !pp.compact || pp.compact.length === 0;
// 전체 보기: 컴팩트 항목을 같은 순서로 맨 위에 + 나머지 속성 → 클릭 전/후 순서 일치.
@@ -1455,11 +1522,20 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
return (
<div
key={pp.id}
ref={el => { if (el) popupElsRef.current.set(pp.id, el); else popupElsRef.current.delete(pp.id); }}
ref={el => {
if (el) {
// RAF 가 위치를 확정(popupPosRef 등록)하기 전인 '신규' 팝업은 숨겨서 mount →
// 화면 밖 라벨의 팝업이 직전(stale) 위치에 한 프레임 번쩍이는 것 방지.
// RAF 가 온스크린 확인 후 visibility:visible + 위치 지정(off 면 계속 숨김→제거).
// visibility 는 RAF 가 명령형으로만 관리(style prop 미포함) → 재렌더에도 유지.
if (!popupPosRef.current.has(pp.id)) el.style.visibility = 'hidden';
popupElsRef.current.set(pp.id, el);
} else popupElsRef.current.delete(pp.id);
}}
onClick={() => setInfoPopups(prev => prev.map(p => p.id === pp.id ? { ...p, expanded: !p.expanded } : p))}
title={showFull ? '클릭 → 접기' : '클릭 → 전체 항목'}
className="absolute z-40 max-w-[260px] bg-black/90 border border-emerald-500/70 rounded-md shadow-xl text-white text-[11px] px-2 py-1.5 pointer-events-auto cursor-pointer"
style={{ left: Math.min(Math.max(8, pp.sx - 10), (canvasSizeRef.current.w || 9999) - 240), top: Math.min(Math.max(8, pp.sy + 16), (canvasSizeRef.current.h || 9999) - 140) }}
style={{ left: Math.min(Math.max(8, pp.sx - 10), (canvasSizeRef.current.w || 9999) - 240), top: Math.min(Math.max(8, pp.sy + 27), (canvasSizeRef.current.h || 9999) - 140) }}
>
{!pp.auto && (
<button onClick={e => { e.stopPropagation(); setInfoPopups(prev => prev.filter(p => p.id !== pp.id)); }} className="absolute top-0.5 right-1 text-gray-400 hover:text-white leading-none text-[12px]"></button>
@@ -1482,6 +1558,7 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
</div>
);
})}
</div>
{showPanel && (
<div className="absolute right-2 z-30 flex flex-col-reverse items-end gap-2" style={{ bottom: (barHeight || 130) + 8 }}>
@@ -1502,6 +1579,38 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
{showDisplay && (
<div className="bg-black/90 border border-sky-700 rounded p-3 text-white w-72 select-none max-h-[80vh] overflow-y-auto shadow-xl">
<div className="text-[10px] text-gray-500 uppercase tracking-wider mb-1.5 border-b border-gray-700 pb-2 mb-2"> </div>
{/* 나침반 타입 — 눈금(analog) / 지도(OSM, 노스업) 선택 */}
<div className="flex items-center gap-1.5 text-[11px] mb-3">
<span className="text-gray-400 w-12 shrink-0 text-right"></span>
<div className="flex-1 flex gap-1">
<button
onClick={() => setCompassType('analog')}
className={`flex-1 px-2 py-1 rounded border text-[11px] ${compassType === 'analog' ? 'bg-amber-400 border-amber-300 text-black' : 'bg-black/60 border-gray-700 text-gray-300 hover:bg-black/80'}`}
></button>
<button
onClick={() => setCompassType('map')}
className={`flex-1 px-2 py-1 rounded border text-[11px] ${compassType === 'map' ? 'bg-amber-400 border-amber-300 text-black' : 'bg-black/60 border-gray-700 text-gray-300 hover:bg-black/80'}`}
title="지도 위 현재 위치(노스업). 인터넷 필요."
></button>
</div>
</div>
{/* 지도 나침반 배경 — 일반(OSM) / 위성(Esri). 지도 타입일 때만 노출. */}
{compassType === 'map' && (
<div className="flex items-center gap-1.5 text-[11px] mb-3">
<span className="text-gray-400 w-12 shrink-0 text-right"></span>
<div className="flex-1 flex gap-1">
<button
onClick={() => setCompassMapStyle('street')}
className={`flex-1 px-2 py-1 rounded border text-[11px] ${compassMapStyle === 'street' ? 'bg-amber-400 border-amber-300 text-black' : 'bg-black/60 border-gray-700 text-gray-300 hover:bg-black/80'}`}
></button>
<button
onClick={() => setCompassMapStyle('sat')}
className={`flex-1 px-2 py-1 rounded border text-[11px] ${compassMapStyle === 'sat' ? 'bg-amber-400 border-amber-300 text-black' : 'bg-black/60 border-gray-700 text-gray-300 hover:bg-black/80'}`}
title="Esri World Imagery 위성 영상"
></button>
</div>
</div>
)}
<div className="space-y-2 mb-3">
{/* 선형/드론궤적/좌측패널 토글은 하단 재생바(VideoPlayer)로 이동. 측점 진단 버튼은 삭제. */}
{/* 경로표고·투명도는 드론궤적 ON/OFF 무관하게 항상 표시. */}
+30 -8
View File
@@ -56,7 +56,7 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
const { playerRef, loadLocalFile, loadServerStream, switchToHls, getVideoElement } =
useVideoPlayer(containerRef);
const { stepForward, stepBackward, fps } = useFrameStep(playerRef);
const { stepForward, stepBackward } = useFrameStep(playerRef);
const { currentTime, duration, playing, source, playbackRate, videoReady, videoWidth, videoHeight } = usePlayerStore();
// 커서 매끄러운 이동:
@@ -141,6 +141,15 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
const videoFile = await loadFromFolder(files);
if (videoFile) loadLocalFile(videoFile);
else console.warn('[geo] 폴더에 영상 파일(mp4/webm)이 없습니다 — 지리정보만 로드');
// KMZ(POI·구조물 원본) 필수 — 측점/드론은 있는데 KMZ만 빠진 경우 = 데이터 누락.
const { kmzMissing, stations, frames } = useGeoStore.getState();
if (kmzMissing && (stations.length > 0 || frames.length > 0)) {
alert(
'KMZ(POI·구조물 원본)가 폴더에 없습니다.\n' +
'POI·구조물이 표시되지 않습니다. KMZ를 포함해 데이터를 재구축·전달하세요.\n' +
'(측점·드론 정보는 정상 로드되었습니다.)',
);
}
} catch (err) {
console.error('[geo] 폴더 로드 실패', err);
}
@@ -256,10 +265,23 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
})();
};
// VIDEO_FPS: 영상 실제 fps (29.97 = 30000/1001). Python SRT FrameCnt 기준과 일치.
// stableFps(VFC 감지)는 31fps 오감지가 있어 프레임 번호 표시에는 사용하지 않음.
const VIDEO_FPS = 30000 / 1001;
const frame = secondsToFrame(currentTime, VIDEO_FPS);
// 영상 실제 fps — 데이터(드론 CSV 마지막 프레임 번호) ÷ 영상 길이(초) 로 자동 산출 후
// 표준 fps(24/25/29.97/30/50/60 등) 중 가까운 값에 스냅. 영상마다 fps 가 달라도 자동 대응.
// (드론 CSV 엔 시간이 없고 frame_cnt 만 있어, 영상 length 와 결합해야 fps 를 알 수 있다.)
// 데이터/영상길이 미확보 시 29.97 폴백. VFC 자동감지는 31fps 오감지가 있어 사용 안 함.
const effectiveFps = useMemo(() => {
const FALLBACK = 30000 / 1001;
if (!storeFrames.length || !duration || duration <= 0) return FALLBACK;
let maxF = 0;
for (const f of storeFrames) if (f.frame > maxF) maxF = f.frame;
if (maxF <= 0) return FALLBACK;
const raw = maxF / duration; // 마지막 프레임 번호 ÷ 영상 길이(초)
const STD = [23.976, 24, 25, 29.97, 30, 50, 59.94, 60];
let best = STD[0], bd = Math.abs(raw - STD[0]);
for (const s of STD) { const d = Math.abs(raw - s); if (d < bd) { bd = d; best = s; } }
return bd <= best * 0.1 ? best : raw; // 표준값 ±10% 이내면 스냅, 아니면 원시값
}, [storeFrames, duration]);
const frame = secondsToFrame(currentTime, effectiveFps);
const videoId = source?.kind === 'server' ? source.videoId : null;
// 드론 정보(측점진단) — 현재 프레임에 가장 가까운 드론 프레임의 GPS/고도만 표시.
@@ -330,7 +352,7 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
</div>
{/* 영상 프레임 표시 패널 — 프레임 번호를 고정폭(6ch)으로 묶어 자릿수가 늘어도 폭 불변 */}
<span className="bg-black/70 text-gray-200 text-xs px-2 py-1 rounded font-mono whitespace-nowrap">
{secondsToTimecode(currentTime)} | F<span className="inline-block text-left" style={{ minWidth: '6ch' }}>{frame}</span> | {fps}fps
{secondsToTimecode(currentTime)} | F<span className="inline-block text-left" style={{ minWidth: '6ch' }}>{frame}</span> | {effectiveFps.toFixed(2)}fps
</span>
{geoLoaded && (
<>
@@ -431,7 +453,7 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
currentFrame={frame}
currentTime={currentTime}
timeRef={smoothTimeRef}
fps={fps}
fps={effectiveFps}
visible={showStations}
videoReady={videoReady}
videoWidth={videoWidth}
@@ -495,7 +517,7 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
const input = (e.currentTarget.elements.namedItem('frameInput') as HTMLInputElement);
const frameNum = parseInt(input.value, 10);
if (!isNaN(frameNum)) {
playerRef.current?.currentTime(frameNum / VIDEO_FPS);
playerRef.current?.currentTime(frameNum / effectiveFps);
}
input.blur();
}}
+24 -5
View File
@@ -19,6 +19,9 @@ const UNSUPPORTED_CODECS = new Set(['hevc', 'h265', 'hvc1', 'hev1']);
export function useVideoPlayer(containerRef: React.RefObject<HTMLDivElement | null>) {
const playerRef = useRef<Player | null>(null);
const hlsRef = useRef<Hls | null>(null);
// 현재 로컬 재생 중인 blob URL. 소스 교체/해제 시점을 결정적으로 관리(이벤트 기반 해제는 교체 시
// 새 URL까지 조기 revoke 되는 race 가 있어 ref 로 직접 추적한다).
const objectUrlRef = useRef<string | null>(null);
const store = usePlayerStore();
useEffect(() => {
@@ -61,6 +64,10 @@ export function useVideoPlayer(containerRef: React.RefObject<HTMLDivElement | nu
return () => {
hlsRef.current?.destroy();
hlsRef.current = null;
if (objectUrlRef.current) {
URL.revokeObjectURL(objectUrlRef.current);
objectUrlRef.current = null;
}
if (playerRef.current && !playerRef.current.isDisposed()) {
playerRef.current.dispose();
playerRef.current = null;
@@ -77,15 +84,22 @@ export function useVideoPlayer(containerRef: React.RefObject<HTMLDivElement | nu
hlsRef.current?.destroy();
hlsRef.current = null;
// 재생 중 폴더 교체 지원: 새 src 설정 후 이전 blob URL 을 해제한다.
// (player.src() 가 새 소스를 채택한 뒤이므로 prevUrl 은 더 이상 참조되지 않아 안전)
const prevUrl = objectUrlRef.current;
// 교체 직전 재생 중이었으면(=드롭 교체) 새 영상도 이어서 재생. 최초 로드는 prevUrl 없음 → 자동재생 안 함.
const wasPlaying = prevUrl != null && !player.paused();
const objectUrl = URL.createObjectURL(file);
objectUrlRef.current = objectUrl;
player.src({ src: objectUrl, type: file.type || 'video/mp4' });
store.setSource({ kind: 'local', file, objectUrl });
store.setHlsReady(false);
// Revoke objectUrl when video element is reset
player.one('emptied', () => {
URL.revokeObjectURL(objectUrl);
});
if (prevUrl) URL.revokeObjectURL(prevUrl);
if (wasPlaying) {
// 드롭은 사용자 제스처라 자동재생 정책에 막히지 않음. 준비되면 재생(실패는 무시).
const p = player.play();
if (p && typeof p.catch === 'function') p.catch(() => {});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -95,6 +109,11 @@ export function useVideoPlayer(containerRef: React.RefObject<HTMLDivElement | nu
hlsRef.current?.destroy();
hlsRef.current = null;
// 로컬 → 서버 전환 시 이전 blob URL 해제.
if (objectUrlRef.current) {
URL.revokeObjectURL(objectUrlRef.current);
objectUrlRef.current = null;
}
store.setSource({ kind: 'server', videoId, filename });
store.setHlsReady(false);
+29 -46
View File
@@ -567,21 +567,19 @@ export function StationBar({
if (!poiByName.has(base)) poiByName.set(base, { lat: p.lat, lon: p.lon, category: p.category });
}
// 양끝 스냅 제거: 각 역사/구조물은 '실제 통과 위치(시간축 px)' 그대로 둔다
// → 마커 위치가 그 지점 커서 측점값과 일치(스냅으로 인한 측점 어긋남 해소).
// 종점역 '미도착'만 별도 처리: 영상이 종점 측점에 못 미치면(endGapPx>0) 종점역(최우측 역사)을
// 트랙 끝(TRACK_END = 미도착 gap 안, 실제 종점 측점 위치)에 두고 미도착 스타일로 표시한다.
// 실제 통과 마커는 '이동/삭제 없이' 직교 통과 위치에 그대로 둔다(드론이 종점 야드에서 162080을
// 여러 번 지나면 각 통과 자리에 마커 유지 → 검색과 일치).
// 종점(최우측 역사)이 '미도착'(endGapPx>0)이면, 기존 마커를 옮기지 않고 트랙 끝(TRACK_END,
// 실제 종점 측점 위치)에 '미도착' 종점 마커를 하나 '추가'한다. → 드론이 못 간 종점 162080도
// 끝에 표시되고, 지나간 162080 통과들도 실제 위치에 그대로 남는다.
const placeUnreachedTerminal = (marks: StructMark[]): StructMark[] => {
if (endGapPx <= 0) return marks;
let hiIdx = -1;
let hiPx = -Infinity;
for (let i = 0; i < marks.length; i++) {
if ((marks[i].category === '역사' || marks[i].category === '역') && marks[i].px > hiPx) {
hiPx = marks[i].px;
hiIdx = i;
let term: StructMark | null = null;
for (const m of marks) {
if ((m.category === '역사' || m.category === '역') && m.px > hiPx) { hiPx = m.px; term = m; }
}
}
if (hiIdx >= 0) marks[hiIdx] = { ...marks[hiIdx], px: TRACK_END_PX, unreached: true };
if (term) marks.push({ ...term, px: TRACK_END_PX, unreached: true });
return marks;
};
@@ -619,19 +617,12 @@ export function StationBar({
passes = pxPassesAtMileage(projectChainage(s.lat, s.lon, stationLine));
if (!passes.length && match && stationLine)
passes = pxPassesAtMileage(projectChainage(match.lat, match.lon, stationLine));
if (!passes.length && s.lat != null && s.lon != null) passes = pxPassesTo(s.lat, s.lon, off);
if (!passes.length && match) passes = pxPassesTo(match.lat, match.lon, off);
// 역사(역) 통과 보강: 측점선 투영이 불안정한 종점/조차장 구역에서는 측점-기준 탐지가
// 일부 통과를 놓친다(예: 대전조차장 162k080 재진입은 투영 측점이 ±20m 밖이라 누락).
// 이미 한 통과를 찾으면 위 단락평가로 좌표근접 탐지가 안 돌아 영영 빠지므로, 여기서
// 좌표근접(pxPassesTo) 통과를 '합집합'으로 합쳐 빠진 재진입까지 마커를 찍는다.
// 같은 통과(거의 같은 px)는 24px 임계로 중복 제거 → 멀리 떨어진 실제 재진입만 추가됨.
if (cat === '역사' && s.lat != null && s.lon != null) {
for (const cp of pxPassesTo(s.lat, s.lon, off)) {
if (!passes.some((p) => Math.abs(p.px - cp.px) < 24)) passes.push(cp);
}
passes.sort((a, b) => a.px - b.px);
}
// 역사(역)은 좌표근접(pxPassesTo) 폴백을 쓰지 않는다 → '드론 실제 측점(chain)이 역 측점과
// 일치(pxPassesAtMileage)'하는 통과에서만 마커를 찍는다. 그래야:
// ① 좌표만 가까운(측점 다른) 조차장 재진입(드론 162k210인데 대전조차장 162k080)은 안 찍히고,
// ② 진짜 162k080 통과에는 정상적으로 찍힌다. (교량/터널만 좌표근접 폴백 허용.)
if (!passes.length && cat !== '역사' && s.lat != null && s.lon != null) passes = pxPassesTo(s.lat, s.lon, off);
if (!passes.length && cat !== '역사' && match) passes = pxPassesTo(match.lat, match.lon, off);
if (!passes.length && s.startMileage != null && s.endMileage != null) {
const mid = (s.startMileage + s.endMileage) / 2;
const px = pxAtMileage(mid);
@@ -643,8 +634,8 @@ export function StationBar({
? projectChainage(s.lat, s.lon, stationLine) : null;
const existTol = routeMeta?.routeInfo?.stationTolerance ?? 20;
for (const p of passes) {
// 표시 측점값(stationKmVal)과 그 통과의 실제 측점(p.km)이 허용오차 밖이면(=좌표 반경만으로
// 잡힌 통과, 그 위치엔 실제로 그 측점값이 없음) 측점값 라벨/검색에서 제외. 마커(동그라미)는 유지.
// 이 통과들은 pxPassesAtMileage(측점 일치) 또는 이정값 기반이라 이미 '실제 측점 일치' 통과.
// (역사는 좌표근접 폴백을 위에서 배제.) kmExists 는 라벨/검색 참고용으로만 유지.
const kmExists = stationKmVal == null || Math.abs(p.km - stationKmVal) <= existTol;
out.push({ px: p.px, title: s.name, category: cat, km: stationKmVal ?? p.km, kmExists, props: s.props });
}
@@ -850,7 +841,7 @@ export function StationBar({
const handleJumpToMileage = useCallback(
(km: number) => {
const arr = viewedRef.current;
if (!arr.length || duration <= 0) return;
if (!arr.length || duration <= 0) return false;
// 커버 측점 범위 산출 (구간 밖 입력은 무시).
let lo = Infinity, hi = -Infinity;
for (let i = 0; i < arr.length; i++) {
@@ -859,18 +850,16 @@ export function StationBar({
if (c > hi) hi = c;
}
const MARGIN = 20;
if (km < lo - MARGIN || km > hi + MARGIN) return;
if (km < lo - MARGIN || km > hi + MARGIN) return false;
// 1순위: '화면에 보이는 마커(structureMarks)' 중 표시 측점값이 일치하는 것들의 시각.
// → 마커는 좌표근접 탐지까지 포함하므로(조차장 등 chain 이 어긋난 통과 포함) 검색이 모든
// 동그라미 위치로 이동 가능. px 는 timeAtFrac 로 시각 역변환. 매칭은 표시(10m 반올림) 일치.
const tgt10 = Math.round(km / 10);
let times: number[] = structureMarks
.filter((m) => m.km >= 0 && m.kmExists !== false && Math.round(m.km / 10) === tgt10)
.map((m) => timeAtFrac((m.px - TRACK_START_PX) / timeTrackWidth));
// 2순위(마커 없음=임의 측점): 연속 체이니지 기준 통과(허용오차 내 연속구간=1통과).
if (!times.length) {
// 측점 검색은 '드론의 실제 투영 측점(chain)' 기준으로만 이동한다(마커에 '표시된' 라벨 기준 아님).
// → 입력값과 허용오차(stationTolerance 기본 20m) 안인 프레임들을 '연속구간=1통과'로 묶어,
// 그 통과 지점들로만 순환 이동. 표시 라벨만 162080이고 실제 위치는 다른(예: 162210 조차장
// 좌표근접) 마커로는 절대 튀지 않는다.
// 통과가 0개면(그 측점을 실제로 안 지남) 이동하지 않고 false → 입력창에 '측점 없음' 안내.
const TOL = routeMeta?.routeInfo?.stationTolerance ?? 20;
const times: number[] = [];
{
let i = 0;
while (i < arr.length) {
if (Math.abs(arr[i].chain - km) < TOL) {
@@ -884,15 +873,8 @@ export function StationBar({
i = j;
} else i++;
}
if (!times.length) {
let best = 0, bd = Infinity;
for (let k = 0; k < arr.length; k++) {
const d = Math.abs(arr[k].chain - km);
if (d < bd) { bd = d; best = k; }
}
times.push(arr[best].time);
}
}
if (!times.length) return false;
// 시간 오름차순 + 근접 중복 제거(같은 통과가 두 경로/마커로 잡힌 경우).
times.sort((a, b) => a - b);
const passes: number[] = [];
@@ -911,8 +893,9 @@ export function StationBar({
jumpRef.current = { km, time: target };
onSeek(clamp(target, 0, duration));
return true;
},
[duration, onSeek, routeMeta, currentTime, timeRef, structureMarks, timeAtFrac, timeTrackWidth],
[duration, onSeek, routeMeta, currentTime, timeRef],
);
return (
@@ -203,6 +203,30 @@ $tool-btn-gap: 1px; // 화면캡처 ↔ 측점선 간격 (47px 버튼이 바 안
}
}
/* '측점 없음' 안내 — 입력창 위에 겹쳐 표시(패널 자식이 absolute 라 일반 흐름은 가려짐). */
.notFound {
position: absolute;
left: $transport-btn-left;
top: 62px;
height: 32.67px;
width: $transport-group-w - $transport-btn-left - $panel-inset;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
z-index: 6; // mileageInput(z:4) 위
border-radius: 4px;
background: rgba(20, 0, 0, 0.88);
box-shadow: 0 0 0 2px #000;
color: #ff6b6b;
font-family: 'Noto Sans KR', var(--font-ui);
font-size: 14px;
font-weight: 700;
letter-spacing: 0.02em;
white-space: nowrap;
pointer-events: none;
}
/* 화면캡처·측점선 보기 패널 */
.toolGroup {
position: relative;
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import type { ChangeEvent, KeyboardEvent } from 'react';
import { parseMileageQuery } from '../../utils/mileage';
import styles from './PlaybackControls.module.scss';
@@ -8,7 +8,8 @@ interface PlaybackControlsProps {
onTogglePlay: () => void;
onStop: () => void;
onCapture: () => void;
onJumpToMileage: (mileage: number) => void;
/** 측점 검색: 이동했으면 true, 이 영상에 없는 측점이라 이동 못 했으면 false. */
onJumpToMileage: (mileage: number) => boolean;
/** 측점선 토글을 외부 상태로 제어할 때 사용(미지정 시 내부 상태). */
lineOn?: boolean;
onToggleLine?: () => void;
@@ -24,23 +25,34 @@ export function PlaybackControls({
onToggleLine,
}: PlaybackControlsProps) {
const [query, setQuery] = useState('');
const [notFound, setNotFound] = useState(false);
const [lineOnInternal, setLineOnInternal] = useState(false);
const lineOn = lineOnProp ?? lineOnInternal;
const toggleLine = onToggleLine ?? (() => setLineOnInternal((v) => !v));
// '측점 없음' 안내는 잠깐만 표시(1.8초 후 자동 사라짐).
useEffect(() => {
if (!notFound) return;
const t = setTimeout(() => setNotFound(false), 1800);
return () => clearTimeout(t);
}, [notFound]);
const handleQueryChange = (e: ChangeEvent<HTMLInputElement>): void => {
setQuery(e.target.value);
setNotFound(false); // 다시 입력하면 안내 숨김
};
const handleQueryKeyDown = (e: KeyboardEvent<HTMLInputElement>): void => {
if (e.key !== 'Enter') return;
const mileage = parseMileageQuery(query);
// Enter 후에도 입력값 유지 → 같은 측점 재Enter 시 통과방향 다음 위치로 순환 검색.
if (mileage !== null) onJumpToMileage(mileage);
// 이 영상에 없는 측점(이동 실패) 또는 형식 오류면 '측점 없음' 안내를 잠깐 띄운다.
const moved = mileage !== null ? onJumpToMileage(mileage) : false;
setNotFound(!moved);
};
// 포커스를 잃으면(다른 곳 클릭 등) 입력값 삭제.
const handleQueryBlur = (): void => setQuery('');
const handleQueryBlur = (): void => { setQuery(''); setNotFound(false); };
return (
<div className={styles.controlsRow}>
@@ -71,6 +83,9 @@ export function PlaybackControls({
onBlur={handleQueryBlur}
placeholder="측점입력"
/>
{notFound && (
<span role="alert" className={styles.notFound}> </span>
)}
</div>
<div className={styles.toolGroup}>
<button
+5 -1
View File
@@ -37,10 +37,12 @@ interface GeoStore {
origin: GeoOrigin | null;
baseName: string | null;
routeMeta: RouteMeta | null;
/** v2.0 CSV(03)교량/04)터널/06)구교) 유래 구조물 + route.json 보정. */
/** KMZ 원본 구조물(교량/터널/구교/역사) + route.json 보정. */
structures: RouteStructure[];
/** 측점 비고에서 추출한 방향전환점 목록. */
directionChanges: DirectionChange[];
/** 마지막 폴더 로드에서 KMZ(POI·구조물 원본)가 누락됐는지. true면 재구축 필요. */
kmzMissing: boolean;
/**
* 폴더 선택 파일에서 지리정보를 파싱해 스토어에 적재한다.
@@ -69,6 +71,7 @@ const EMPTY = {
routeMeta: null as RouteMeta | null,
structures: [] as RouteStructure[],
directionChanges: [] as DirectionChange[],
kmzMissing: false,
};
export const useGeoStore = create<GeoStore>((set) => ({
@@ -89,6 +92,7 @@ export const useGeoStore = create<GeoStore>((set) => ({
routeMeta: data.routeMeta,
structures: data.structures,
directionChanges: data.directionChanges,
kmzMissing: data.kmzMissing,
});
return data.videoFile;
},
+10
View File
@@ -34,6 +34,12 @@ interface SettingsStore {
/** 드론 궤적 영상 오버레이 표시 여부. 기본 표시. (VideoPlayer 바 토글로 제어) */
showDronePath: boolean;
setShowDronePath: (v: boolean) => void;
/** 우측 상단 나침반 타입. 'analog'=눈금 나침반, 'map'=지도(노스업, 현재위치). 기본 map. */
compassType: 'analog' | 'map';
setCompassType: (v: 'analog' | 'map') => void;
/** 지도 나침반 배경. 'street'=OSM 일반, 'sat'=위성(Esri). 기본 street. */
compassMapStyle: 'street' | 'sat';
setCompassMapStyle: (v: 'street' | 'sat') => void;
}
const DEFAULT_GRADE_FILTER: Record<string, boolean> = {
@@ -59,6 +65,10 @@ export const useSettingsStore = create<SettingsStore>()(
setShowCenterline: (v) => set({ showCenterline: v }),
showDronePath: true,
setShowDronePath: (v) => set({ showDronePath: v }),
compassType: 'map',
setCompassType: (v) => set({ compassType: v }),
compassMapStyle: 'street',
setCompassMapStyle: (v) => set({ compassMapStyle: v }),
}),
{
name: 'ghivideo.settings',
+5 -3
View File
@@ -157,15 +157,17 @@ export interface FolderGeoData {
videoFile: File | null;
baseName: string | null;
frames: DroneFrame[];
pois: GeoPoint[]; // type==='poi' (<base>_POI.csv + building/02)지장물.csv)
stations: GeoPoint[]; // type==='station' (building/01)측점.csv, stationOrder 정렬)
pois: GeoPoint[]; // type==='poi' (KMZ 원본: 지장물·출입문 등)
stations: GeoPoint[]; // type==='station' (측점.csv, stationOrder 정렬)
centerline: CenterlinePoint[]; // 측점을 mileage 순으로 이은 폴리라인 (v2.0엔 center.csv 없음)
origin: GeoOrigin | null;
routeMeta: RouteMeta | null;
/** v2.0 CSV(03)교량/04)터널/06)구교) 유래 구조물 + route.json 보정. */
/** KMZ 원본 구조물(교량/터널/구교/역사) + route.json 보정. */
structures: RouteStructure[];
/** 측점 비고에서 추출한 방향전환점 목록. */
directionChanges: DirectionChange[];
/** 폴더의 `<base>_poi_overrides.json` 에서 읽은 POI 위치 보정값 (없으면 {}). */
poiOverrides: PoiOverrideMap;
/** KMZ(POI·구조물 원본)가 없거나 비어 있음 → 데이터 누락(재구축 필요). 측점·드론은 별개. */
kmzMissing: boolean;
}
+18 -271
View File
@@ -94,16 +94,6 @@ function cell(row: string[], i: number): string {
return i >= 0 && i < row.length ? row[i] : '';
}
/** 헤더행 정규화(트림+BOM 제거). props/팝업 표시용. */
function cleanHeader(header: string[]): string[] {
return header.map((h) => h.trim().replace(/^/, ''));
}
/** (정규화된)헤더 + 데이터행 → 비어있지 않은 속성 배열(순서 보존). 라벨 클릭 팝업 표시용. */
function rowProps(header: string[], row: string[]): { k: string; v: string }[] {
return header.map((k, i) => ({ k, v: cell(row, i) })).filter((p) => p.k && p.v !== '');
}
// ── 파일 식별 헬퍼 ────────────────────────────────────────────────────
/** File 의 폴더 내 상대경로 (webkitRelativePath 우선, 없으면 name). */
@@ -263,254 +253,6 @@ export async function parseStations(files: File[]): Promise<{
return { stations, directionChanges };
}
// ── 파서: POI (2개 소스 병합) ─────────────────────────────────────────
/**
* POI 파싱 — 2개 소스를 병합한다(둘 다 type='poi').
* a. 루트 <base>_POI.csv (UTF-8): title←title, category←category_clean, lat/lon, z=0
* b. building/02)지장물.csv (EUC-KR): title←명칭, category='지장물', lat/lon, z←Z좌표
*/
export async function parsePois(files: File[], baseName: string | null): Promise<GeoPoint[]> {
const result: GeoPoint[] = [];
// a. <base>_POI.csv (없으면 루트의 *_POI.csv 폴백)
const poiFile =
(baseName &&
files.find(
(f) => !isInBuilding(f) && baseNameOf(relPath(f)) === `${baseName}_POI.csv`,
)) ||
files.find((f) => !isInBuilding(f) && /_POI\.csv$/i.test(baseNameOf(relPath(f)))) ||
null;
if (poiFile) {
const rows = await readCsv(poiFile);
if (rows.length >= 2) {
const header = cleanHeader(rows[0]);
const fi = makeFieldIndexer(rows[0]);
const iTitle = fi('title', 2);
const iCat = fi('category_clean', 3);
const iLat = fi('lat', 7);
const iLon = fi('lon', 8);
for (const r of rows.slice(1)) {
const lat = parseFloat(cell(r, iLat));
const lon = parseFloat(cell(r, iLon));
if (isNaN(lat) || isNaN(lon)) continue;
result.push({
title: cell(r, iTitle),
category: cell(r, iCat) || '건물',
lat,
lon,
z: 0,
type: 'poi',
props: rowProps(header, r),
});
}
}
}
// b. building/02)지장물.csv (단, '02)지장물_역사.csv'는 역사 전용 → 제외)
const isObstacle = (f: File) => {
const n = baseNameOf(relPath(f));
return isInBuilding(f) && n.includes('지장물') && !n.includes('역사');
};
const obFile = files.find((f) => isObstacle(f) && baseNameOf(relPath(f)).includes('02)')) ??
files.find(isObstacle) ?? null;
if (obFile) {
const rows = await readCsv(obFile);
if (rows.length >= 2) {
const header = cleanHeader(rows[0]);
const fi = makeFieldIndexer(rows[0]);
const iTitle = fi('명칭', 0);
const iZ = fi('Z좌표', 5);
const iLat = fi('lat', 6);
const iLon = fi('lon', 7);
for (const r of rows.slice(1)) {
const lat = parseFloat(cell(r, iLat));
const lon = parseFloat(cell(r, iLon));
if (isNaN(lat) || isNaN(lon)) continue;
result.push({
title: cell(r, iTitle),
category: '지장물',
lat,
lon,
z: parseFloat(cell(r, iZ)) || 0,
type: 'poi',
props: rowProps(header, r),
});
}
}
}
return result;
}
// ── 파서: 구조물 (교량/터널/구교) ─────────────────────────────────────
/**
* 구조물 파싱 — 3개 CSV 를 RouteStructure[] 로 병합한다.
* a. building/03)교량.csv: name←구분, type='bridge', lengthM←연장(m), grade←시설종별, category='교량'
* b. building/04)터널.csv: name←구분, type='tunnel', lengthM←연장(m), grade←시설종별, category='터널'
* c. building/06)구교.csv: name←시설물명, type='bridge'(구교 아이콘=교량), lengthM←연장(m), category='구교'(시설종별 컬럼 없음→grade undefined)
* lat/lon 이 유효하지 않은 행은 건너뛴다(깨진 좌표 방어).
*/
export async function parseStructures(files: File[]): Promise<RouteStructure[]> {
const out: RouteStructure[] = [];
const pushFrom = async (
file: File | null,
spec: {
type: 'bridge' | 'tunnel';
category: string;
nameKey: string;
nameFallback: number;
lengthKey: string;
lengthFallback: number;
latFallback: number;
lonFallback: number;
/** 시설종별 컬럼 인덱스 폴백. 컬럼이 없는 파일은 생략(undefined). */
gradeFallback?: number;
},
): Promise<void> => {
if (!file) return;
const rows = await readCsv(file);
if (rows.length < 2) return;
const header = cleanHeader(rows[0]);
const fi = makeFieldIndexer(rows[0]);
const iName = fi(spec.nameKey, spec.nameFallback);
const iLen = fi(spec.lengthKey, spec.lengthFallback);
const iLat = fi('lat', spec.latFallback);
const iLon = fi('lon', spec.lonFallback);
// 시설종별: 헤더에 있으면 그 인덱스, 없으면 폴백(지정 시) 사용. 둘 다 없으면 -1.
const iGrade = spec.gradeFallback != null ? fi('시설종별', spec.gradeFallback) : fi('시설종별');
let n = 0;
for (const r of rows.slice(1)) {
const lat = parseFloat(cell(r, iLat));
const lon = parseFloat(cell(r, iLon));
// 위경도 유효성: 대한민국 범위(대략) 밖이면 깨진 행으로 간주.
if (isNaN(lat) || isNaN(lon) || lat < 33 || lat > 39 || lon < 124 || lon > 132) continue;
const name = cell(r, iName);
if (!name) continue;
const lengthM = parseFloat(cell(r, iLen));
// 시설종별 원문(trim). 빈 문자열이면 undefined.
const grade = cell(r, iGrade).trim();
out.push({
id: `${spec.category}-${n++}`,
type: spec.type,
name,
category: spec.category,
lat,
lon,
...(isNaN(lengthM) ? {} : { lengthM }),
...(grade ? { grade } : {}),
props: rowProps(header, r),
});
}
};
// a. 03)교량: 구분,시설종별,...,연장(m),...,X좌표,Y좌표,Z좌표,lat,lon,category_clean
await pushFrom(findBuildingFile(files, '03)교량') ?? findBuildingFile(files, '교량'), {
type: 'bridge',
category: '교량',
nameKey: '구분',
nameFallback: 0,
lengthKey: '연장(m)',
lengthFallback: 5,
latFallback: 10,
lonFallback: 11,
gradeFallback: 1,
});
// b. 04)터널: 구분,...,연장(m),시설종별,X좌표,Y좌표,Z좌표,lat,lon,category_clean
await pushFrom(findBuildingFile(files, '04)터널') ?? findBuildingFile(files, '터널'), {
type: 'tunnel',
category: '터널',
nameKey: '구분',
nameFallback: 0,
lengthKey: '연장(m)',
lengthFallback: 4,
latFallback: 9,
lonFallback: 10,
gradeFallback: 5,
});
// c. 06)구교: 역구간,시설물명,...,연장(m),lat,lon,z,...
await pushFrom(findBuildingFile(files, '06)구교') ?? findBuildingFile(files, '구교'), {
type: 'bridge',
category: '구교',
nameKey: '시설물명',
nameFallback: 1,
lengthKey: '연장(m)',
lengthFallback: 5,
latFallback: 6,
lonFallback: 7,
});
return out;
}
/**
* 역사(철도역) 파싱 — building/02)지장물_역사.csv (EUC-KR).
* 헤더: 명칭,주소(도로명),주소(지번),X좌표,Y좌표,Z좌표,lat,lon
* → RouteStructure(type='station', category='역사')로 변환. 하단 스테이션바에 원형 마커로 표출.
* (영상 오버레이는 category '구교'만 표시하므로 역사는 영상에 안 뜸.)
*/
export async function parseHistoricStations(files: File[]): Promise<RouteStructure[]> {
// 역사 파일 — building/ 또는 root 어디든(building 폴더 삭제 대비).
const file = files.find((f) => baseNameOf(relPath(f)).includes('지장물_역사'));
if (!file) return [];
const rows = await readCsv(file);
if (rows.length < 2) return [];
const fi = makeFieldIndexer(rows[0]);
const iName = fi('명칭', 0);
const iLat = fi('lat', 6);
const iLon = fi('lon', 7);
const out: RouteStructure[] = [];
let n = 0;
for (const r of rows.slice(1)) {
const lat = parseFloat(cell(r, iLat));
const lon = parseFloat(cell(r, iLon));
if (isNaN(lat) || isNaN(lon) || lat < 33 || lat > 39 || lon < 124 || lon > 132) continue;
const name = cell(r, iName);
if (!name) continue;
out.push({ id: `역사-${n++}`, type: 'station', name, category: '역사', lat, lon });
}
return out;
}
/**
* 출입문 파싱 — building/05)출입문번호.csv (UTF-8 BOM).
* 헤더: 본부,선별,역구간,latitude,longitude,시설팀,출입문번호,...,z,측점
* → GeoPoint{ title=출입문번호, category='출입문', lat/lon, z, type='poi' } 로 영상에 POI 처럼 표출.
* 위치 인덱스 폴백: latitude=3, longitude=4, 출입문번호=6, z=12.
*/
export async function parseAccessDoors(files: File[]): Promise<GeoPoint[]> {
const file = findBuildingFile(files, '05)출입문') ?? findBuildingFile(files, '출입문');
if (!file) return [];
const rows = await readCsv(file);
if (rows.length < 2) return [];
const header = cleanHeader(rows[0]);
const fi = makeFieldIndexer(rows[0]);
const iTitle = fi('출입문번호', 6);
const iLat = fi('latitude', 3);
const iLon = fi('longitude', 4);
const iZ = fi('z', 12);
const out: GeoPoint[] = [];
for (const r of rows.slice(1)) {
const lat = parseFloat(cell(r, iLat));
const lon = parseFloat(cell(r, iLon));
if (isNaN(lat) || isNaN(lon) || lat < 33 || lat > 39 || lon < 124 || lon > 132) continue;
const title = cell(r, iTitle);
if (!title) continue;
// 원본 CSV 속성 전부(비어있지 않은 것) → 팝업에 표시(KMZ 정보표와 동일 항목).
out.push({ title, category: '출입문', lat, lon, z: parseFloat(cell(r, iZ)) || 0, type: 'poi', props: rowProps(header, r) });
}
return out;
}
// ── 파서: KMZ (원본) ─────────────────────────────────────────────────
//
// KMZ(=doc.kml zip)는 building CSV의 원본이다. 폴더가 02)지장물·03)교량·04)터널·05)출입문번호·
@@ -794,24 +536,28 @@ export async function loadFolderGeoData(
const videoFile = findVideoFile(files);
const baseName = deriveBaseName(videoFile);
const [frames, stationResult, poisRaw, csvStructures, routeMeta, poiOverrides, historicStations, accessDoors, kmz] = await Promise.all([
const [frames, stationResult, routeMeta, poiOverrides, kmz] = await Promise.all([
parseDroneFrames(files, baseName),
parseStations(files),
parsePois(files, baseName),
parseStructures(files),
parseRouteMeta(files, baseName),
parsePoiOverrides(files, baseName),
parseHistoricStations(files),
parseAccessDoors(files),
parseKmz(files),
]);
// POI/구조물 출처: KMZ(원본)가 있으면 그걸 사용(지장물·출입문·교량/터널/구교) → CSV 추출 중복 제거.
// KMZ 없거나 파싱 실패 시 CSV 폴백(지오코딩 _POI + 지장물 + 출입문 / 구조물). 측점은 항상 CSV.
const useKmz = !!kmz && (kmz.pois.length > 0 || kmz.structures.length > 0);
const pois = useKmz ? kmz!.pois : poisRaw.concat(accessDoors);
const baseStructures = useKmz ? kmz!.structures : csvStructures;
if (useKmz) console.log(`[KMZ] POI ${kmz!.pois.length} · 구조물 ${kmz!.structures.length} 로드(원본) → CSV 미사용`);
// POI/구조물 출처: KMZ(원본)가 유일한 소스다(지장물·출입문·교량/터널/구교·철도역).
// KMZ 정책상 필수 — 없거나 비어 있으면 "데이터 누락"으로 보고 경고만 남기고 빈 상태로 둔다
// (CSV 폴백 없음. building/ POI·구조물 CSV는 사용하지 않음). 측점·중심선은 항상 CSV(측점)에서 온다.
const kmzMissing = !kmz || (kmz.pois.length === 0 && kmz.structures.length === 0);
if (kmzMissing) {
console.warn(
'[KMZ] 누락/비어있음 — POI·구조물이 표시되지 않습니다. ' +
'KMZ(원본)를 포함해 데이터를 재구축·전달하세요. (측점·드론 정보는 정상 로드)',
);
} else {
console.log(`[KMZ] POI ${kmz!.pois.length} · 구조물 ${kmz!.structures.length} 로드(원본)`);
}
const pois = kmz?.pois ?? [];
const baseStructures = kmz?.structures ?? [];
const stations = stationResult.stations.sort(
(a, b) => stationOrder(a.title) - stationOrder(b.title),
@@ -819,8 +565,8 @@ export async function loadFolderGeoData(
const directionChanges = stationResult.directionChanges;
// route.json 은 선택적 보정 레이어 — 있으면 구조물에 override/augment.
// 역사(02)지장물_역사)는 구조물로 합류 → 스테이션바 표출(영상엔 미표시).
const structures = mergeStructures(baseStructures, routeMeta).concat(historicStations);
// 철도역(역사)은 KMZ의 KAKAO_RAIL placemark에서 구조물로 생성됨(스테이션바 표출).
const structures = mergeStructures(baseStructures, routeMeta);
// v2.0엔 center.csv 가 없다 → 측점 폴리라인으로 중심선 생성.
const centerline = buildCenterlineFromStations(stations);
@@ -844,5 +590,6 @@ export async function loadFolderGeoData(
structures,
directionChanges,
poiOverrides,
kmzMissing,
};
}
@@ -0,0 +1,253 @@
# DefVideo → GhiVideo 업그레이드 상세 문서
> 작성일: 2026-06-30
> 비교 기준: `b23042/DefVideo`(구버전) → `b23042/GhiVideo`(신버전)
> 두 저장소는 git 히스토리가 분리된 별도 저장소이며, 동일 코드베이스에서 파생되었다. 본 문서는 실제 소스 트리(`diff -r`)를 직접 비교하여 작성되었다.
---
## 0. 한눈에 보기
| 영역 | 변경 규모 | 평가 |
|------|----------|------|
| **서버 (server)** | `app.ts` +2줄, 신규 `elevation.ts`(69줄) | 거의 동일 — 골격 유지 |
| **공유 타입 (shared)** | 변경 없음 | 동일 |
| **클라이언트 (client)** | 약 **+3,000줄** 순증 | **대폭 발전** |
**핵심 결론**: 기반 골격(서버 스트리밍/HLS/업로드, 공유 타입)은 그대로 유지하면서, **측점(체이니지) 기반 주행영상 분석 도메인 기능**이 본격적으로 구현된 한 단계 발전한 버전이다. 세 갈래로 요약하면:
1. **V2.0 데이터 형식 대응** — 영상 옆 KMZ/KML을 1순위로 POI·구조물 추출(없으면 `building/` 5종 CSV 폴백), 측점 CSV 기반 중심선 생성, 자동 인코딩 감지, 방향전환점 추출
2. **투영 정확도 개선** — 지오이드 보정, 지면고도(DEM) 적용, 역투영 기반 위치 보정
3. **영상 오버레이/편집 UI 고도화** — 라벨 평활, 겹침 억제, 컴팩트 팝업, 드래그 편집, 나침반 미니맵
---
## 1. 파일 단위 변경 요약
### 변경된 기존 파일
| 파일 | 변경량 | 내용 |
|------|--------|------|
| `client/src/components/overlay/StationOverlay.tsx` | **+1,188 / 167** | 라벨 평활·겹침억제·구조물표출·컴팩트팝업·편집모드 (최대 변경) |
| `client/src/utils/geoData.ts` | **+660 / 111** | V2.0 폴더 파싱·구조물·방향전환점·POI보정 |
| `client/src/stationbar/StationBar.tsx` | +340 / −87 | 이동거리축·시설등급필터·종점역 미도착 |
| `client/src/stationbar/components/Timeline/Timeline.tsx` | +248 / −27 | 구조물 겹침필터·라벨 그룹핑 |
| `client/src/components/player/VideoPlayer.tsx` | +168 / 40 | 영상컨트롤 UI 통합·smoothTimeRef·폴더 드롭 |
| `client/src/utils/geoProjection.ts` | +138 / −5 | 지오이드 보정·역투영 함수 3종 |
| `client/src/types/geo.ts` | +54 / −7 | 구조물/방향전환점/보정 타입 |
| `client/src/store/geoStore.ts` | +47 / −3 | POI 보정 분리관리·구조물 상태 |
| `client/src/components/overlay/RoutePanel.tsx` | +27 / −5 | 구조물 필터·방향 우선순위 |
| `client/src/store/playerStore.ts` | +13 / 1 | videoReady·영상 해상도 상태 |
| `client/src/hooks/useVideoPlayer.ts` | +8 / −0 | 영상 로드 이벤트 추적 |
| `server/src/app.ts` | +2 / 0 | elevation 라우트 등록 |
### 신규 추가 파일 (DefVideo엔 없음)
| 파일 | 줄수 | 역할 |
|------|------|------|
| `client/src/components/overlay/Minimap.tsx` | 69 | 드론 방위각(heading-up) 나침반 미니맵 |
| `client/src/store/settingsStore.ts` | 90 | 사용자 표시 설정 중앙 관리(localStorage 영속) |
| `client/src/utils/chainage.ts` | 64 | 측점값(체이니지) 계산·중심선 투영 |
| `server/src/routes/elevation.ts` | 69 | DEM(표고) 데이터 프록시 API |
---
## 2. 데이터 처리 — V2.0 형식 대응 (`geoData.ts`)
가장 많이 변경된 유틸. 구버전은 `center.csv` + 단일 POI CSV 중심이었으나, 신버전은 **영상 옆(root)의 KMZ/KML을 1순위 소스**로 POI·구조물을 직접 추출하고, `building/` 하위 CSV는 **KMZ가 없을 때의 폴백**으로 둔다(`loadFolderGeoData``useKmz` 분기). 측점만 KMZ에 없어 항상 CSV에서 읽되, 측점·역사 CSV는 `building/` 또는 root 어디서든 찾는다(폴더 삭제 대비). 즉 현재 데이터는 KMZ(root) + 측점 CSV(root)만으로 동작하며 building 폴더가 필수는 아니다.
### 2.1 자동 인코딩 감지
- **구버전**: 파일별로 `utf-8` / `euc-kr`를 코드에 하드코딩
- **신버전**: ArrayBuffer 앞 3바이트(BOM `EF BB BF`)를 검사해 UTF-8/EUC-KR 자동 선택 → 혼재된 인코딩 자동 처리
### 2.2 데이터 소스 우선순위 (KMZ 우선 · CSV 폴백)
```
1순위: 영상 옆(root) *.kml / *.kmz → parseKmz → POI·구조물 직접 추출
(KMZ에 POI/구조물 있으면 useKmz=true → 아래 building CSV 미사용)
측점: 01)측점.csv (building/ 또는 root) → 측점 + 방향전환점 (KMZ에 없어 항상 CSV)
역사: 02)지장물_역사.csv (building/ 또는 root) → 스테이션바 전용
폴백(KMZ 없을 때만):
building/02)지장물.csv → POI(지장물)
building/03)교량.csv → 구조물(bridge)
building/04)터널.csv → 구조물(tunnel)
building/05)출입문번호.csv → POI(출입문)
building/06)구교.csv → 구조물(bridge, 구교 아이콘)
```
- `center.csv`를 더 이상 찾지 않고, **측점(01)을 측점값 순으로 이어 중심선을 생성**(`buildCenterlineFromStations()`)
- 지장물·교량·터널·구교·출입문 CSV는 `building/` 안에서만 찾지만, KMZ가 있으면 통째로 무시되므로 KMZ 기반 데이터에서는 building 폴더가 없어도 정상 동작한다.
### 2.3 CSV 파싱 견고성 강화
- `makeFieldIndexer()`: 헤더명 기반 인덱싱 실패 시 위치 인덱스로 폴백 → 깨진 EUC-KR 헤더도 안전
- `cell()`: 음수/범위 초과 인덱스에서 빈 문자열 반환 → 크래시 방지
- `rowProps()`: CSV 원본 속성을 `{k, v}[]` 쌍으로 추출 → UI 라벨 클릭 팝업에 그대로 표출
### 2.4 신규 파싱 항목
- **측점 + 방향전환점**(`parseStations()`): `Z좌표_한국`(정표고, EPSG:5186) 우선 사용, 비고 컬럼에서 `방향전환점(상행->하행, 02:05)` 정규식 추출 → `DirectionChange` 생성
- **구조물**(`parseStructures()`): 교량/터널/구교 3종 통합, 위경도 대한민국 범위(33–39°N, 124132°E) 검증, `연장(m)·시설종별·분류` 채움
- **역사**: `type='station'`으로 변환 → 스테이션바에만 표시(영상 오버레이 제외)
- **KMZ 원본**(`parseKmz()`): 구글 어스 KMZ가 있으면 직접 추출(CSV 중복 회피), 측점만 항상 CSV 사용
- **POI 위치 보정**(`parsePoiOverrides()`/`applyPoiOverrides()`): 드래그 보정값을 `<base>_poi_overrides.json`(`{title: {lat,lon,z}}`)으로 저장/복원
---
## 3. 투영 정확도 개선 (`geoProjection.ts`, `chainage.ts`)
### 3.1 카메라 파라미터 신규 필드
- `geoidOffset`(m): 정표고(EL) → 타원체고 변환. 드론 `abs_alt`(타원체고)와 datum 일치 (대전 기본 25.8m, KNGeoid18)
- `poiZOffset`(m): POI 전용 표고 보정. 선로 지면 가정 오차를 패널에서 조절
### 3.2 카메라 좌표 신규 필드
- `distH`: 수평 거리(m) — POI 거리필터용
- `fwd`/`side`: 진행방향(yaw) 기준 앞쪽/옆쪽 거리(m) → **비등방 거리필터**(앞은 멀리, 옆은 가깝게) 구현 가능
### 3.3 역투영 함수 3종 신규 (편집 기능의 수학적 기반)
- `worldFromPixel(px, py, range)`: 화면 픽셀 + 슬랜트거리 → 월드좌표(lat/lon/z). POI 드래그 보정(수평+수직 동시)
- `solveZForPixelY(...)`: 특정 화면 세로위치에 맞는 표고(z) 역산. "앞으로 밀려 보이는 슬라이드" 보정
- `groundPointFromPixel(px, py, zGround)`: 화면점과 특정 고도평면의 교차점(lat/lon). 지면 좌표 보정
### 3.4 측점/체이니지 계산 (`chainage.ts`, 신규)
- `kmFromTitle("157K970")``157970`(m), `fmtKm10()``"157k970"`
- `buildChainLine()`: 측점 → 평면투영 폴리라인
- `projectToChain()`: 드론 GPS → 폴리라인 투영 → `{km(측점값), offsetM(선로 수직이격)}`
- `nearestChainPoint()`: 최근접 측점 검색 → StationBar·VideoPlayer HUD 공유
---
## 4. 영상 오버레이 고도화 (`StationOverlay.tsx`)
### 4.1 라벨 평활 & 이상치 거부
- **이상치 거부**: 한 프레임에 화면폭 12%(`REJECT_DIST`) 이상 점프하면 노이즈로 보고 이전 위치 유지. 단 연속 8프레임(`MAX_REJECT_FRAMES`) 초과 시 수용 → 시크/재등장에서 멈추지 않음
- **속도 적응형 평활(One Euro 방식)**: 떨림(방향 왕복)은 강하게 평활, 실제 이동(방향 일관)은 즉시 추종 → 1배속 떨림 제거 + 빠른 배속 지연 해소
- 조절 파라미터: `smoothMinAlpha`(정지 시 최소 추종), `smoothSpeedRef`(즉시추종 기준속도)
### 4.2 POI 겹침 억제
- 화면상 가로 10%·세로 3.5% 이내 마커는 겹침으로 판정 → 드론에 더 가까운 것만 표시
- 같은 건물 여러 업체/시설 중 주요한 것만 노출 → 화면 정리
### 4.3 구조물 표출 & 컴팩트 팝업
- 교량/터널/구교를 POI처럼 라벨로 표출(이모지 🌉 구교, 🚪 출입문 추가)
- **컴팩트 필드**: 라벨 옆 항상 표시(시설종별 → 구조형식 → 연장 → 폭 → 용도 → 준공연도 순)
- 클릭 시 전체 팝업 확장, 다시 클릭 시 컴팩트로 토글. **다중 팝업** 누적 가능, ESC로 정리
### 4.4 POI 높이 모드 & DEM 자동 적용
- **드론 높이 기준**: POI를 드론보다 N미터 아래 고정(`droneHeightDrop`)
- **지면고도 기준**: POI를 실제 지형고도에 고정 → 드론 오르내려도 지물에 붙음
- 서버 elevation API(SRTM 30m DEM)로 모든 좌표의 실제 표고 일괄 적용 → 먼 곳에서 밀리는 현상 완화
### 4.5 드래그 편집 & 세로화각 보정
- POI를 마우스로 드래그 → `groundPointFromPixel`로 위경도 역산
- 표고 슬라이더로 높이 미세조정, 개별 리셋 가능
- **세로화각 보정 모드**: 라벨이 상하로 어긋날 때 POI를 실제 위치로 드래그 → 세로 화각(sensorH)만 역산 자동보정
- 보정값 JSON 내보내기/가져오기, 일괄 초기화
### 4.6 좌표 정렬 (object-fit:cover 보정)
- `coverRef`: 영상이 `object-fit:cover`로 크롭된 영역을 매 프레임 계산 → 정규좌표(0–1) ↔ 화면 px 정확 변환 → 포인터 히트테스트 정확
---
## 5. 나침반 미니맵 (`Minimap.tsx`, 신규)
- 우측 상단 고정 150×150px 아날로그 나침반 위젯(heading-up)
- 고정 프레임(배경원/외곽링) + 회전 카드(6° 간격 60눈금 + N 문자 + 빨강/흰 삼각형)
- 부모의 `--rot` CSS 변수로 부드럽게 회전, StationOverlay RAF에서 매 프레임 갱신(360° 언랩)
- 효과: 영상만으로 드론이 향하는 방향을 한눈에 파악
---
## 6. 하단 측점바 & 타임라인 (`StationBar.tsx`, `Timeline.tsx`)
### 6.1 측점 정확도 개선
- **이동거리축 추가**: 드론 실제 GPS 이동량을 시간축에 반영 → 호버(공중 대기) 구간 가시화, 전진/후진 색 리본(주황/하늘색) 구분
- **영상 FPS 자동 적응**: 고정 29.97 대신 `마지막 프레임 / 재생시간`으로 영상별 계산 → 측점 배지 정확도 향상
- **kmExists 플래그**: 실제 측점값 존재 여부 구분 → 좌표 반경만으로 잡힌 통과는 라벨 제외
### 6.2 시설등급 필터 & 구조물 표시
- 1종/2종/3종/기타 선택적 표시(settingsStore 연동, 체크박스 즉시 반영)
- 긴 이름 2줄 분할(CJK 1.0/영문 0.55 가중치)
- 상/하행 평행 변형은 영상 진행방향에 따라 하나만 표시
- 동명 구조물 반복 통과 → 중앙 라벨 1개 + 통과점 점선 드롭 + 수평 브래킷
### 6.3 종점역 미도착 시각화
- 영상이 종점역에 미도달 시 트랙 우측을 회색(재생불가)으로 표시(상한 15%)
- 종점역 마커를 '속 빈 링' 스타일(미도착)로
---
## 7. 영상 플레이어 (`VideoPlayer.tsx`, `useVideoPlayer.ts`, `playerStore.ts`)
- **영상제어 UI 수평 통합**: 좌하단 컨트롤을 한 줄 가로 배열(`영상제어 | 배속 | 프레임 | 좌측패널 | 선형 | 드론궤적 | GPS/고도 | 시설등급`), flex-wrap 자동 줄바꿈
- **smoothTimeRef**: 벽시계 기준 단조 보간 시간 → StationBar가 ref로 직접 읽어 React 리렌더 없이 transform 갱신 → 60fps 부드러운 커서, 시크 시 즉시 재동기화
- **videoReady 게이트**: `loadeddata` 이후에만 오버레이 렌더 → 영상보다 먼저 그려지는 깜빡임 제거
- **영상 해상도 추적**: `videoWidth/videoHeight`로 object-fit:cover 정렬 정확화
- **폴더 드롭 지원**: `webkitGetAsEntry()` 디렉토리 재귀 순회 → 영상+측점/POI 폴더 통째 로드
- **드론 정보 HUD**: 현재 프레임 최근접 드론 GPS/고도 항상 표시
- **버튼 정렬**: `min-w-[96px]` 고정폭 + amber 활성색, 프레임번호 6자리 고정폭
---
## 8. 설정 중앙 관리 (`settingsStore.ts`, 신규)
Zustand + persist(`localStorage: 'ghivideo.settings'`)로 다음 표시 설정을 영속화한다.
| 키 | 기본값 | 용도 |
|----|--------|------|
| `gradeFilter` | `{1종:T, 2종:T, 3종:F, 기타:T}` | 시설종별 표시 필터 |
| `poiOverlapExclude` | `true` | 겹친 POI 숨김 |
| `showRoutePanel` | `true` | 좌측 노선 패널 |
| `showStationDiag` | `false` | 측점 진단 HUD |
| `showCenterline` | `true` | 선형(중심선) 오버레이 |
| `showDronePath` | `true` | 드론 궤적 오버레이 |
- 누락된 키는 기본값으로 자동 보충(스키마 진화 대비)
- `isGradeVisible()` 공유 함수로 StationBar·RoutePanel·StationOverlay가 동일 규칙 적용
---
## 9. 서버: 고도 API 프록시 (`elevation.ts`, 신규)
- 브라우저 COEP/CSP 제약으로 클라이언트가 외부 DEM API를 직접 호출 불가 → **서버 중계**
- `GET /api/elevation?lat=36.4,36.5&lon=127.4,127.5` (배치, 콤마 구분)
- 응답: `{ elevation: number[], source }`
- 폴백 전략: ① opentopodata SRTM 30m(정밀) → ② open-meteo 90m(폴백)
- `app.ts``app.use('/api/elevation', elevationRouter)` 등록 (서버 변경은 이것 + 라우트 파일이 전부)
---
## 10. 타입/스토어 변경 요약
### `geo.ts` 신규/확장
- `GeoPoint.props`: CSV 원본 속성 `{k,v}[]` (팝업 표시)
- `RouteStructure`: `lengthM`(연장) · `category`(교량/터널/구교/역사) · `grade`(시설종별) · `props` 추가
- `DirectionChange`(신규): `station` · `from`/`to` · `atSeconds`
- `PoiOverride` / `PoiOverrideMap`(신규): 드래그 보정값
- `RouteMeta.endStationGapMeters`: 종점역 미도착 거리
- `FolderGeoData`: `structures` · `directionChanges` · `poiOverrides` 추가
### `geoStore.ts`
- POI 보정 분리관리: `basePois`(원본) + `poiOverrides`(보정맵) → `applyPoiOverrides()` 결과가 `pois`
- 신규 상태: `structures`, `directionChanges`
- 신규 메서드: `setPoiOverride()`, `clearPoiOverride()`, `setPoiOverrides()`
- `loadFromFolder()`: Promise.all 병렬 로딩으로 8개 파서 동시 실행
---
## 11. 사용자 체감 개선 총괄
| 항목 | 개선 효과 |
|------|----------|
| **라벨 안정성** | 떨림/튐 제거 — 이상치 거부 + 속도적응 평활 |
| **화면 가독성** | 겹친 POI/구조물 자동 숨김 |
| **정보 탐색** | 다중 팝업 + 컴팩트/전체 토글 + ESC 정리 |
| **위치 정확도** | 지오이드 보정 + DEM 실제 표고 + 역투영 편집 |
| **편집 편의** | 드래그 + 표고 슬라이더 + 세로화각 자동보정 + JSON 저장 |
| **측점 정확도** | 이동거리축(호버 가시화) + 영상별 FPS 자동 |
| **커서 부드러움** | smoothTimeRef 단조보간 → 60fps |
| **작업 효율** | 폴더 통째 드롭 로드, 설정 localStorage 영속 |
| **방향 인지** | 나침반 미니맵으로 드론 방위각 실시간 표시 |
| **오버레이 안정** | videoReady 게이트로 깜빡임 제거 |
---
*본 문서는 `DefVideo`와 `GhiVideo`의 소스 트리 전체 비교(`diff -r`) 및 주요 변경 파일의 라인 단위 분석을 통해 작성되었다.*
+55
View File
@@ -0,0 +1,55 @@
/* 한맥 기술개발센터 회의자료(HWP) 양식 모사 — md→html→pdf 공용 */
@page {
size: A4;
margin: 22mm 18mm 18mm 18mm;
@top-left { content: "한맥 기술개발센터"; font-family: "Malgun Gothic"; font-size: 8.5pt; color: #555; }
@top-right { content: "대외비 / 회의자료"; font-family: "Malgun Gothic"; font-size: 8.5pt; color: #a33; }
@bottom-center { content: "기술로 사람과 자연이 함께하는 세상을 만들어 갑니다."; font-family: "Malgun Gothic"; font-size: 8.5pt; color: #999; }
@bottom-right { content: counter(page); font-family: "Malgun Gothic"; font-size: 8.5pt; color: #999; }
}
html { font-size: 10.5pt; }
body {
font-family: "Malgun Gothic", "Hancom Gothic", sans-serif;
color: #1a1a1a; line-height: 1.55; margin: 0 auto; max-width: 900px; padding: 8px 10px;
}
/* 제목 */
h1 {
text-align: center; font-size: 17pt; font-weight: 800; color: #111;
margin: 2px 0 4px; padding-bottom: 8px; border-bottom: 2.5px solid #333;
}
.subhead { text-align: center; color: #666; font-size: 9.5pt; margin: 0 0 18px; }
/* □ 절 제목 (## 으로 작성, 텍스트에 □ 포함) */
h2 {
font-size: 13pt; font-weight: 800; color: #14305e;
margin: 18px 0 8px; padding: 0; border: none;
}
/* ▷ 소제목 (### 으로 작성) */
h3 { font-size: 11pt; font-weight: 700; color: #333; margin: 12px 0 5px; }
ul { margin: 4px 0 8px; padding-left: 18px; }
li { margin: 2px 0; }
strong { color: #111; }
a { color: #14305e; text-decoration: none; }
/* 표 — HWP 느낌(전 테두리, 헤더 회색) */
table { border-collapse: collapse; width: 100%; margin: 8px 0 12px; font-size: 9.5pt; }
th, td { border: 1px solid #555; padding: 5px 8px; vertical-align: top; text-align: left; }
th { background: #e9edf3; color: #14305e; font-weight: 700; text-align: center; }
td.c, th.c { text-align: center; }
/* ☞ 제언 박스 */
blockquote {
border: 1px solid #c9b27a; background: #fffbf0; border-radius: 4px;
margin: 10px 0; padding: 8px 12px; color: #5a4626; font-size: 9.8pt;
}
figure { margin: 10px 0; text-align: center; page-break-inside: avoid; break-inside: avoid; }
figure svg { width: 100%; max-width: 720px; height: auto; }
figure figcaption { font-size: 8.8pt; color: #777; margin-top: 4px; }
text { font-family: "Malgun Gothic", sans-serif; }
h1, h2, h3 { break-after: avoid; }
table, figure { break-inside: avoid; }
@@ -0,0 +1,35 @@
# 드론 흔들림 잡기 + 글자 붙이기 — 초등학생용 쉬운 설명 작성
## 작업 내용
기존 기술문서 [2026-06-29_1436_드론자세평활-오버레이렌더링-기술문서화.md](2026-06-29_1436_드론자세평활-오버레이렌더링-기술문서화.md)의 내용이 실제 코드에 적용되어 있는지 검증한 뒤, 그 기술을 **초등학생 눈높이 + SVG 그림**으로 쉽게 풀어쓴 문서를 신규 작성.
### 1. 기술문서 ↔ 실제 코드 적용 여부 검증
- [StationOverlay.tsx](../../client/src/components/overlay/StationOverlay.tsx)(1639줄)에서 문서가 인용한 함수/상수 위치를 grep으로 대조
- `smoothFrame`(L577), `poseAt`(L670), `smoothStep`(L48), `startLabelPrecompute`(L692), `buildLines`(L606), `coverRef`(L257), `VIDEO_FPS=30000/1001`(L25), `YAW_EDGE=8`(L580), `REJECT_DIST=0.12`, `MAX_REJECT_FRAMES=8`, `SMOOTH_VEL_BETA=0.25` 모두 실제 존재·일치 확인
- 결론: 기술문서는 가상이 아니라 실제 구현 코드를 정확히 기술한 것
### 2. 초등학생용 쉬운설명 문서 작성
- 신규 파일: [docs/쉬운설명_드론흔들림잡기와_글자붙이기.md](../쉬운설명_드론흔들림잡기와_글자붙이기.md)
- 기존 `쉬운설명_GhiVideo_기술이야기.md`의 SVG figure 스타일을 따름
- 8개 SVG 삽화 포함, 비유 중심 서술 (도시락/만화 사이그림/거북이토끼 등)
- 다룬 핵심 기술 4+1가지:
1. 에지보존 적응형 평활(smoothFrame) → "곧을 땐 많이, 돌 땐 멈춰서"
2. 연속프레임 보간(poseAt) → "30장을 60장처럼 사이 채우기"
3. 속도적응 EMA + 이상치거부(smoothStep) → "느리면 살살, 빠르면 빨리"
4. precompute + RAF 2단 분리 → "미리 정하기 vs 그때그때 그리기 (도시락 비유)"
5. 겹침 우선순위 + 상/하행 형제 라벨 + 나침반(보너스)
### 3. HTML/PDF 변환
- `scripts/md2docs.sh`로 변환 완료
- 산출물: `쉬운설명_드론흔들림잡기와_글자붙이기.{html,pdf}` (PDF 118KB)
## 산출물
- `docs/쉬운설명_드론흔들림잡기와_글자붙이기.md` (22KB)
- `docs/쉬운설명_드론흔들림잡기와_글자붙이기.html` (28KB)
- `docs/쉬운설명_드론흔들림잡기와_글자붙이기.pdf` (118KB)
---
**소요 시간**: 12분
**Context 사용량**: input 95k / output 11k tokens
@@ -0,0 +1,39 @@
# 지도 POI → 영상 매핑 기술 — 초등학생용 쉬운 설명 작성
## 작업 내용
"지도상의 POI 좌표를 영상에 매핑한 기술들"을 실제 코드에서 모두 조사·나열하고, 초등학생 눈높이 + SVG 그림으로 설명하는 문서를 신규 작성.
### 1. 실제 구현 기술 조사
- [geoProjection.ts](../../client/src/utils/geoProjection.ts) 전체 정독 + [StationOverlay.tsx](../../client/src/components/overlay/StationOverlay.tsx)의 POI 처리부 분석
- 매핑 파이프라인을 구성하는 12개 기술 식별:
1. 좌표계 변환 (proj4, EPSG:4326→5186 한국 TM)
2. ENU 월드 3D 좌표 (East-North-Up)
3. 지오이드 높이 보정 (정표고↔타원체고, 대전≈25.8m)
4. 자세 회전 행렬 (yaw/pitch/roll → R_b2w/R_w2c)
5. 상대 위치 벡터 (대상−드론, offX/Y/Z)
6. 핀홀 카메라 원근투영 (Xc/Zc·f/sensor)
7. 화각(초점거리 24mm·센서 36×20.25)
8. 카메라 뒤 클리핑 (Zc<CLIP_Z)
9. POI 표고 추정 (최근접 중심선 nearestCL / 드론−24m)
10. 거리 필터 (수평거리 distH<MAX_RANGE 1000m, 앞/옆 fwd/side)
11. 역투영 보정 (worldFromPixel/solveZForPixelY/groundPointFromPixel + focal 역산)
12. object-fit:cover 정규좌표→화면 변환
### 2. 쉬운설명 문서 작성
- 신규 파일: [docs/쉬운설명_지도POI를_영상에_붙이는기술.md](../쉬운설명_지도POI를_영상에_붙이는기술.md)
- 기존 쉬운설명 문서 스타일 계승, SVG 삽화 14개 + 비유 중심 서술
- 기술 한눈에 보기 표 → 각 기술별 그림 상세 → 전체 요약 → 특허포인트 → 용어사전
### 3. HTML/PDF 변환
- `scripts/md2docs.sh`로 변환 완료 (PDF 137KB)
## 산출물
- `docs/쉬운설명_지도POI를_영상에_붙이는기술.md` (33KB)
- `docs/쉬운설명_지도POI를_영상에_붙이는기술.html` (41KB)
- `docs/쉬운설명_지도POI를_영상에_붙이는기술.pdf` (137KB)
---
**소요 시간**: 15분
**Context 사용량**: input 130k / output 18k tokens
@@ -0,0 +1,20 @@
# DefVideo → GhiVideo 업그레이드 문서화
**소요 시간**: 약 20분
**Context 사용량**: input ~70k / output ~9k tokens
## 작업 내용
- `b23042/DefVideo`(구버전)와 `b23042/GhiVideo`(신버전) 소스 트리를 `diff -r`로 전체 비교
- 두 저장소는 git 히스토리 분리(공통 커밋 없음) → 워킹 트리 직접 비교로 차이 분석
- 변경 규모 정량화(파일별 +/− 라인 수, 신규 파일 줄수)
- 주요 변경 파일 3그룹을 병렬 Explore 에이전트로 상세 분석
- StationOverlay/Minimap/RoutePanel
- geoData/geoProjection/chainage/geo타입/geoStore
- StationBar/Timeline/VideoPlayer/settingsStore/elevation
## 결론
- 서버·공유타입은 거의 동일(elevation API 라우트만 신규), 클라이언트는 약 +3,000줄 순증으로 대폭 발전
- 핵심 3축: ① V2.0 building/ 폴더 CSV 형식 대응 ② 투영 정확도 개선(지오이드/DEM/역투영) ③ 영상 오버레이·편집 UI 고도화
## 산출물
- `docs/DefVideo→GhiVideo_업그레이드_상세.md` (상세 업그레이드 문서)
@@ -0,0 +1,42 @@
# KMZ 필수화 — building POI/구조물 CSV 폴백 제거 + 누락 경고
## 배경 / 결정
- 정책: **KMZ는 항상 필수**. 없으면 "데이터 누락"으로 보고 재구축해서 KMZ를 전달받음.
- 기존 코드는 KMZ 없으면 **조용히 building CSV로 폴백** → 불완전 데이터가 에러 없이 통과(정책과 상충).
- 사용자 선택: **"경고만 표시"** (앱은 계속 동작, CSV POI/구조물 폴백 제거, 측점은 유지).
## 변경 내용
### 1. geoData.ts — 죽은 파서 제거 + KMZ 단일 소스화
- 삭제: `parsePois`, `parseStructures`, `parseHistoricStations`, `parseAccessDoors` + 전용 헬퍼 `rowProps`, `cleanHeader`
- `loadFolderGeoData`:
- `Promise.all`에서 위 4개 파서 호출 제거(헛수고 파싱 제거)
- `useKmz` 분기 → `kmzMissing` 판정으로 교체: `!kmz || (pois==0 && structures==0)`
- KMZ 누락 시 `console.warn`, 정상 시 `console.log`
- `pois = kmz?.pois ?? []`, `baseStructures = kmz?.structures ?? []`
- `structures`에서 `historicStations` concat 제거 → **역사 중복 위험 제거**(역사는 KMZ KAKAO_RAIL에서만 생성)
- 반환값에 `kmzMissing` 추가
- 유지: `parseStations`(측점), `parseDroneFrames`, `parseKmz`, `mergeStructures`, `parseRouteMeta`, `parsePoiOverrides`, `buildCenterlineFromStations` 및 공용 헬퍼(`makeFieldIndexer`/`cell`/`readCsv`/`findBuildingFile`/`mmssToSeconds` 등)
### 2. 타입/스토어 (kmzMissing 전파)
- [types/geo.ts](../../client/src/types/geo.ts): `FolderGeoData.kmzMissing: boolean` 추가
- [store/geoStore.ts](../../client/src/store/geoStore.ts): `GeoStore.kmzMissing` + `EMPTY` 기본값 + `loadFromFolder`에서 set
### 3. UI 경고
- [VideoPlayer.tsx](../../client/src/components/player/VideoPlayer.tsx) `handleSelectFolder`: 로드 후 `kmzMissing && (stations||frames 존재)``alert`로 "KMZ 누락 → 재구축 필요(측점·드론은 정상)" 안내
### 4. 문서 갱신
- [docs/구현상세_GhiVideo_기술-소스코드매칭.md](../구현상세_GhiVideo_기술-소스코드매칭.md): 데이터 흐름도 + 소스 우선순위 콜아웃 + 파서 표를 KMZ 필수 정책으로 갱신(폴백 제거 명시)
## 검증
- `tsc --noEmit` (node v20.20.2): **통과** (시스템 기본 node v12라 nvm node20 사용)
- `npm run build` (tsc && vite build): **성공** (219 modules, 빌드 1.49s)
## 비고
- 측점.csv(루트)는 KMZ에 없어 그대로 필수 — 빠지면 스테이션바/체이니지/중심선 동작 불가.
- 빌드 산출물 반영됨 → 55000 PM2 서빙 시 재시작/재배포 필요할 수 있음.
---
**소요 시간**: 25분
**Context 사용량**: input 175k / output 22k tokens
@@ -0,0 +1,22 @@
# 구현 상세 — 기술·소스코드 매칭 문서화
**소요 시간**: 약 15분
**Context 사용량**: input ~120k / output ~16k tokens
## 작업 내용
- 앞서 설명한 기술들을 실제 소스코드(파일·라인·핵심 코드)와 1:1 매칭한 구현 상세 문서 작성
- 영역별 3개 Explore 에이전트로 정확한 함수 위치/라인번호/코드 스니펫 추출
- 오버레이/미니맵/RoutePanel
- geoData/geoProjection/chainage/타입/geoStore
- StationBar/Timeline/VideoPlayer/settingsStore/elevation
- 추출 결과를 14개 섹션 + 빠른 색인표로 종합
## 산출물
- `docs/구현상세_GhiVideo_기술-소스코드매칭.md`
- 전체 데이터 흐름도 → 파싱 → 투영/역투영 → 체이니지 → 오버레이 렌더 → 라벨평활 → 겹침/팝업 → 위치보정(드래그/DEM/세로화각) → 미니맵 → 측점바/이동거리축 → smoothTimeRef → 상태관리 → 서버 고도 API
- 기능↔소스 빠른 색인표(파일:라인) 포함
- 각 기능마다 핵심 코드 스니펫 + 동작 원리 설명
## 참고
- 라인번호는 2026-06-30 기준이며 코드 수정 시 달라질 수 있음
- 기존 문서 `DefVideo→GhiVideo_업그레이드_상세.md`, `쉬운설명_GhiVideo_기술이야기.md`와 상호 참조
@@ -0,0 +1,20 @@
# 발표 문서 작성 — 좌표 정합과 측점 기반 구현
**소요 시간**: 약 10분
**Context 사용량**: input ~150k / output ~20k tokens
## 작업 내용
- 개발 내용을 발표용 슬라이드형 마크다운으로 작성
- 주제: 드론 GPS·영상·POI 좌표를 영상 위 정확한 위치에 배치하는 기술 + 프레임→측점 기반 전환
- 기존 소스 분석(geoProjection/chainage/StationBar/StationOverlay/VideoPlayer) 결과를 재활용해 그림·도식 중심으로 구성
## 산출물
- `docs/발표_GhiVideo_좌표정합과측점기반구현.md`
- 9개 슬라이드 섹션 + 부록 소스 색인
- 핵심 ①공간(4단계 투영+지오이드) ②시간(smoothTimeRef) ③안정(One Euro) ④프레임→측점 전환
- ASCII 도식: 투영 파이프라인, 핀홀 카메라, ENU, 측점 투영, 이동거리축(호버), 측점바
- 프레임 vs 측점 비교표, 핵심 소스 색인표
## 참고
- `---` 슬라이드 구분으로 Marp/reveal.js 변환 가능
- 상세 구현 문서(`구현상세_...md`)와 상호 참조
@@ -0,0 +1,37 @@
# 재생 중 데이터 폴더 교체 시 "media could not be loaded" 수정
## 증상
재생 중에 다른 데이터 폴더를 드래그&드롭하면 지오 데이터(측점/POI/스테이션바)는 정상 로드되는데
**영상만** 상단에 "The media could not be loaded …" 에러가 뜨고 검은 화면.
## 원인 — objectURL 조기 해제 race
[useVideoPlayer.loadLocalFile](../../client/src/hooks/useVideoPlayer.ts#L72)이 blob URL 해제를 `player.one('emptied', …)` 이벤트에 의존.
소스 교체 시:
1. 첫 로드 → `.one('emptied', revoke url1)` 등록
2. 드롭 → `createObjectURL`**url2** 생성 → `player.src(url2)` (이때 `'emptied'`**비동기 예약**) → 직후 `.one('emptied', revoke url2)` 등록
3. 예약된 `'emptied'` 발화 → url1·url2 핸들러가 **동시 실행** → 방금 만든 **url2까지 revoke** → 소스 무효 → 로드 실패
첫 로드는 이전 'emptied'가 없어 정상, **교체 시에만** 재현.
## 수정 — ref 기반 결정적 해제
- `objectUrlRef`로 현재 blob URL 추적, `'emptied'` 핸들러 제거
- `loadLocalFile`: 새 `src` 설정 후 **이전 URL만** revoke (새 URL은 보존)
- `loadServerStream`: 로컬→서버 전환 시 이전 blob URL revoke
- 언마운트(dispose) cleanup: 남은 blob URL revoke
- 부가: 교체 직전 재생 중이었으면(=드롭 교체) 새 영상도 `player.play()`로 이어서 재생
(drop은 사용자 제스처라 자동재생 정책 통과. 최초 로드는 `prevUrl` 없음 → 기존처럼 자동재생 안 함)
## 파일
- [client/src/hooks/useVideoPlayer.ts](../../client/src/hooks/useVideoPlayer.ts)
## 검증
- `tsc --noEmit`: 통과 / `npm run build`: 성공 (node v20.20.2)
- 재현/회귀: 재생 중 폴더 드롭 → 새 영상 로드+재생, 콘솔 에러 없음(코드 경로상). 실브라우저 확인은 하드 새로고침 후 폴더 재드롭 필요.
## 반영 방법
- client만 변경 → **하드 새로고침(Ctrl+Shift+R)** 후 폴더 드롭으로 확인. PM2 재시작 불필요.
---
**소요 시간**: 12분
**Context 사용량**: input 198k / output 26k tokens
@@ -0,0 +1,24 @@
# 발표자료 SVG 도식화 전면 개편
## 작업 내용
기존 [발표_GhiVideo_좌표정합과측점기반구현.md](../발표_GhiVideo_좌표정합과측점기반구현.md)가 ASCII 아트 도식이라 가독성이 낮아, **인라인 SVG 그림 16개로 전면 교체**하고 색상 체계·표로 도식화해 이해도를 높임.
## 변경점
- ASCII 다이어그램 → **SVG figure 16개** (기존 쉬운설명 문서들의 `figure.fig` 스타일 계승)
- 색상 의미 통일: 🟦공간(투영, 파랑) · 🟩시간(초록) · 🟧안정(주황) · 🟦측점(청록) · 드론(보라)
- 주요 그림: 목표 화면 목업, 입력3종 결합, 전체 파이프라인(레인), 4단계 투영, ENU·회전·핀홀·지오이드, 시간보간(sparse↔dense), One Euro 3케이스, 시간축 한계, GPS→측점 투영, 이동거리축(호버), 측점바 목업, 역투영 보정, 최종 정리도
- 내용/코드근거/소스색인 표는 유지(정확도 보존), 슬라이드 `---` 구분 유지(Marp 호환)
- `<style>` 블록 추가(figure 스타일 + lead 콜아웃)
## 산출물
- `docs/발표_GhiVideo_좌표정합과측점기반구현.md` (41KB)
- `.html` (59KB) / `.pdf` (181KB) — md2docs.sh 변환
## 비고
- weasyprint 경고 `user-select: none`(31행)은 무시됨(렌더 영향 없음).
- 원본 ASCII 버전은 덮어씀(내용은 보존+개선).
---
**소요 시간**: 18분
**Context 사용량**: input 215k / output 32k tokens
@@ -0,0 +1,103 @@
# POI 라벨 사라진 뒤 팝업 재등장 수정
**소요 시간**: 약 10분
**Context 사용량**: input ~80k / output ~3k tokens
## 문제
POI 라벨이 화면 밖으로 사라졌는데, 방금 전 위치에 POI 팝업이 다시 뜨는 문제.
## 원인
[client/src/components/overlay/StationOverlay.tsx](../../client/src/components/overlay/StationOverlay.tsx) 의
마커 그리기 루프(1114행)가 라벨이 화면(비디오 크롭) 밖으로 나가도 `cc.Zc >= CLIP_Z` 이기만 하면
매 프레임 `visStructRef` 에 계속 등록.
- 팝업 제거 로직(RAF): 라벨이 off-container 상태 4프레임이면 팝업 제거.
- 자동 sync 인터벌(150ms): `vis.has(title)` 이 아직 true → 팝업을 즉시 다시 추가.
→ 둘이 싸워 팝업이 마지막 위치에 되살아남.
## 수정
`visStructRef.set` 을 팝업 off 판정과 **동일한 화면 범위**(`px/py ∈ [-16, W+16]/[-16, H+16]`)로 제한.
`vx(nx)=offX+nx*dispW` 는 팝업 off-check 의 `sx=offX+disp.x*dispW` 와 같은 좌표계라 일치.
→ 라벨이 화면 밖이면 vis 에서도 빠져 sync 가 되살리지 않음(제거 로직과 일관).
## 추가 수정 (잔여 1프레임 번쩍임)
`visStructRef` 가드만으론 sync 인터벌이 팝업 DOM 을 **새로 mount** 하는 순간의 번쩍임이 남음:
새 팝업 요소는 React 초기 스타일(`top: sy+27`)로 **보이는 상태**로 붙고, 다음 RAF 가
'화면 밖→숨김' 판정을 내리기 전 한 프레임 stale 위치에 번쩍임.
→ 팝업 ref 콜백에서 `popupPosRef`(RAF 위치 확정 시 기록)에 아직 없는 '신규' 팝업은
`visibility:hidden` 으로 mount. RAF 가 온스크린 확인 후에만 visible 로 전환(off 면 계속 숨김→제거).
visibility 는 RAF 명령형으로만 관리(style prop 미포함)라 재렌더에도 유지됨.
## 추가 수정 2 (hidden-mount 가드 무력화 — 진짜 원인)
hidden-mount 가드는 `!popupPosRef.has(id)` 일 때만 숨긴다. 그런데 팝업 제거 경로가 여럿인데
보조 맵(popupPosRef/popupMissRef) 정리는 **RAF off-4프레임 경로만** 했음.
→ sync `toRemoveIds` / 수동(✕·ESC·빈곳클릭) 제거 시 `popupPosRef` 잔존 → 같은 title 재추가 시
`popupPosRef.has=true` → 숨김 안 됨 → React 초기 스타일(`top: sy+27`)로 **보이는 채 mount**
라벨 없이 팝업만 stale 위치에 나타남(+ RAF 가 stale 위치에서 EMA 로 미끄러짐).
수정:
1. `infoPopups` 변경 effect 에서 사라진 id 의 보조 맵 일괄 정리(단일 출처).
2. sync `toRemoveIds` 처리 시 즉시 `popupPosRef`/`popupMissRef` 정리(재추가 전에 확실히 비움).
## 추가 수정 3 (definitive — 라벨 가시성과 완전 결합)
증상이 계속 동일해 근본 재분석: 팝업 off 판정이 라벨 **십자마커 중심** ±16px 슬랙 + 화면 안 **clamp**
조합이라, 마커가 경계 근처(예: 오른쪽 끝 밖 ~16px)면 라벨 텍스트는 화면 밖(안 보임)인데
팝업만 clamp 되어 화면 안에 뜸 → "라벨 없이 팝업만".
수정: 이번 프레임에 라벨이 **실제로 화면 안에 그려졌는지**(히트박스∩뷰포트)를 `onScreenLabels`
집합으로 만들고, 팝업 표시/visStruct 등록을 이 집합에만 의존하도록 통일.
- `off = !disp || !onScreenLabels.has(id)` (좌표 슬랙·clamp 기반 판정 제거)
- visStruct 등록도 `poiVisible`(히트박스 온스크린) 기준
## 추가 수정 4 (사라졌다 잠깐 재등장 → 완전 제거)
증상: 라벨 사라짐 → 팝업 사라짐 → 잠깐 재등장 → 사라짐. 원인은 sync(150ms 추가) ↔ RAF(제거) 충돌.
- 경계에서 라벨이 1~2프레임 깜빡이면 visStruct 재등록 → sync 가 팝업 재생성 → RAF 가 다시 제거.
수정:
1. 추가 히스테리시스: 라벨이 **연속 ADD_STREAK(6프레임 ~100ms)** 이상 화면 안일 때만 visStruct 등록
(poiOnScreenStreakRef). 짧은 깜빡임으론 팝업 생성 안 됨.
2. 제거 단일화: sync 의 팝업 제거 로직 삭제 → **RAF(onScreenLabels + 4프레임 유예)가 유일한 제거 권한**.
add/remove 충돌 제거 → 라벨 사라지면 팝업도 사라진 뒤 재생성 안 됨.
## 추가 수정 5 (진짜 리그레션 원인 — flip 왕복)
사용자 지적: 이 증상은 '하단 겹침' 수정(추가수정 없음, 첫 위치 수정) 이후 생긴 리그레션.
→ 가시성 문제가 아니라 flip(아래↔위 전환) 로직에 **히스테리시스가 없어서** 발생.
라벨이 하단 flip 임계값 부근이면 드론 흔들림/평활로 `sy` 가 미세하게 오르내릴 때마다
`ty+ph > H-2` 가 참↔거짓 왕복 → 팝업이 아래↔위로 **순간이동(bigJump 스냅)** → "사라졌다 나타났다".
수정: `popupFlipRef`(위/아래 상태) + 히스테리시스(HYST=48px). '위'로 간 뒤엔 아래에 48px 이상
여유가 생겨야만 '아래'로 복귀 → 경계 왕복 제거. flip 상태도 제거 경로에서 정리.
## 추가 수정 6 (하단 이탈 즉시 제거 — 유예 삭제)
요구: 라벨이 하단으로 벗어나면 팝업 즉시 사라지고 재등장 금지.
잔여 원인: 제거에 4프레임 '유예'가 있어, 그 사이 흔들림으로 라벨이 경계로 잠깐 되들어오면
RAF 가 아직 안 지워진 팝업을 다시 visible 로 바꿈 → "사라졌다 잠깐 나타났다".
수정: off(라벨 화면 밖) 판정 시 유예 없이 **즉시 toRemove**(+숨김). 재등장은 sync 의
ADD_STREAK(6프레임 연속 안정) 게이트가 막음 → 짧은 복귀로는 재생성 안 됨.
mid-screen 은 라벨 히트박스가 가장자리에서 멀어 off 가 안 나므로 영향 없음.
## 추가 수정 7 (하단 기준 = 영상 영역(스테이션바 위), 모니터 아님)
요구: 팝업이 스테이션바에 걸치지 않게, 라벨이 바 밑으로 내려가면 팝업 제거. 기준은 영상 재생 영역.
- 스테이션바는 영상 위 z-20 오버레이(absolute bottom-0), 팝업은 z-40 → 바 위에 걸쳐 보였음.
- 유효 하단 `HB = H barHeight` 도입(barHeightRef, ResizeObserver 실측 ~130px).
- `labelOnScreen` 하단 기준 `H-2``HB-2`: 라벨 히트박스가 바 밑이면 off → 즉시 제거.
- 팝업 flip/clamp 하단 기준도 `H``HB`: 팝업이 바 위로만 배치(걸침 방지).
## 추가 수정 8 (flip 제거 + 바 뒤로 슬라이드)
요구: 팝업이 라벨 위로 flip하면 안 됨. 항상 라벨 아래에 있다가 라벨이 바 밑으로 사라질 때 같이 사라짐.
- flip/clamp 둘 다 문제였음(flip=위로 튐, clamp=바에 걸쳐 멈춤).
수정:
1. flip·하단 clamp 제거 → 팝업 `ty = sy + LABEL_HALF + GAP` (항상 라벨 아래, 자유 하강).
2. 팝업들을 클립 컨테이너로 감쌈: `absolute left-0 right-0 top-0 overflow-hidden` + `bottom: barHeight`
(높이 = 영상영역 HB). 팝업이 라벨 따라 내려가면 바 영역에서 클립돼 '바 뒤로 슬라이드'되어 사라짐.
3. 제거는 label off(HB 기준) 그대로 → 라벨이 바 밑이면 팝업 제거.
## 배포 확인
- :55000 서빙 index.html → `assets/index-yfJyCKp1.js` (이번 빌드 해시와 일치)
- ecosystem: ghiVideo, cwd=프로젝트루트, clientDist=client/dist (정적 즉시 반영)
## 검증
- `tsc --noEmit` 통과
- `npm run build` 성공 → :55000 프로덕션 반영 (브라우저 강력 새로고침 필요)
@@ -0,0 +1,23 @@
# POI 팝업 ↔ 라벨 겹침 수정
**소요 시간**: 약 15분
**Context 사용량**: input ~60k / output ~4k tokens
## 문제
POI 팝업이 화면 아래쪽에 다가올수록 라벨(십자마커+글자)과 팝업창이 겹치는 문제.
## 원인
[client/src/components/overlay/StationOverlay.tsx](../../client/src/components/overlay/StationOverlay.tsx) 의 RAF 팝업 위치 계산에서
여백 `GAP=16` 을 라벨 **중심**(`sy`, 십자마커 위치) 기준으로 잡음.
라벨 글자·아이콘이 `sy` 위아래로 ~13px 차지 → 실제 여백은 3px뿐. 하단 위로 플립해도 동일 3px라 계속 겹침.
## 수정
- `LABEL_HALF=15`(라벨 반높이) 도입 → 라벨 **바깥 가장자리** 기준으로 띄움.
- 아래: `ty = sy + LABEL_HALF + GAP`
- 위 플립: `aboveTy = sy - LABEL_HALF - GAP - ph`
- `GAP` 16→12 (실제 여백 3px → 12px)
- 초기 렌더 top `sy+16 → sy+27` 로 맞춰 1프레임 튐 방지.
## 검증
- `tsc --noEmit` 통과
- `npm run build` 성공 → :55000 프로덕션 반영
@@ -0,0 +1,27 @@
# 역 마커 — 실제 측점 일치 기준으로 정정 (162080 누락 수정)
**소요 시간**: 약 15분
**Context 사용량**: input ~230k / output ~4k tokens
## 문제
직전 수정(kmExists 게이트)이 너무 세서, **진짜 162080 위치의 대전조차장 마커까지 사라짐**.
## 원인
kmExists 게이트가 `|p.km stationKmVal|` 비교인데:
- 탐지는 `s.station`(등록 측점 162080) → p.km = 162080
- stationKmVal = `projectChainage(역 좌표)` (좌표 투영) — 등록값과 어긋날 수 있음
→ 둘이 다르면 게이트가 실제 162080 통과까지 제거.
## 수정 ([StationBar.tsx](../../client/src/stationbar/StationBar.tsx))
게이트 방식 폐기, 탐지 소스로 구분:
1. 역사(역)는 **좌표근접(pxPassesTo) 폴백을 배제**`pxPassesAtMileage`(실제 측점 일치)로만 탐지.
- `if (!passes.length && cat !== '역사' && ...) pxPassesTo(...)` (교량/터널만 허용)
2. `if (!kmExists) continue` 게이트 제거(주 경로 + 폴백 경로). kmExists 는 라벨/검색 참고용으로만 유지.
## 효과
- ① 좌표만 가까운(측점 다른) 조차장 재진입(드론 162210인데 162080)은 안 찍힘.
- ② 진짜 162080 통과에는 정상 표시.
- 검색은 이미 chain 기준(별도 수정)이라 마커 변경과 무관하게 정확.
## 검증
- `tsc --noEmit` 통과, `npm run build` 성공 → 해시 `index-Cdsf6M1x.js`(HUD `· 검색v3`) :55000 서빙.
@@ -0,0 +1,26 @@
# 역(스테이션) 마커 — 직교 투영 측점 일치 시에만 표시
**소요 시간**: 약 15분
**Context 사용량**: input ~180k / output ~4k tokens
## 문제
스테이션바에서 드론이 대전조차장(역 측점 162k080) '좌표에만' 가까우면, 실제 드론 측점이
162k290(210m 밖)이어도 대전조차장역 마커가 찍혔다. → 직교 위치가 안 맞는데 역 표시됨.
## 원인
[StationBar.tsx](../../client/src/stationbar/StationBar.tsx) 구조물 마크 생성:
- 역사 '보강 탐지'가 `pxPassesTo`(좌표 근접) 통과를 합집합으로 추가 → 측점 불일치 통과까지 마커.
- `kmExists`(=드론 측점 p.km 와 역 측점 stationKmVal 차이 ≤ tol)가 false여도 마커(동그라미)는 유지했음.
## 수정
1. 역사 좌표근접 '보강 탐지' 블록 제거 (측점 불일치 재진입을 억지로 추가하던 부분).
2. 마커 push 시 `if (!kmExists) continue;` 게이트 추가(주 경로 + 폴백 경로 both).
→ 역은 **드론의 직교 투영 측점이 역 측점과 stationTolerance(기본 20m) 이내일 때만** 표시.
→ 교량/터널은 stationKmVal=null → kmExists 항상 true → 영향 없음.
## 효과
- 대전조차장에서 드론이 162k290일 때 162k080 역 마커가 더 이상 안 뜸.
- 측점 일치 통과(pxPassesAtMileage)는 그대로 유지 → 정상 통과·재진입은 계속 표시.
## 검증
- `tsc --noEmit` 통과, `npm run build` 성공 → 새 해시 `index-CJflcoyT.js` :55000 서빙 확인.
@@ -0,0 +1,34 @@
# 영상 fps 데이터 기반 자동 산출 (고정 29.97 → 가변)
**소요 시간**: 약 20분
**Context 사용량**: input ~150k / output ~6k tokens
## 배경
프레임번호↔시간 변환이 `VIDEO_FPS = 30000/1001`(29.97)로 하드코딩 → 30fps 영상 전용.
24/60fps 등 다른 영상은 이름표가 어긋남. 어떤 fps 영상이 로드될지 미리 알 수 없음.
## 문제 데이터
드론 CSV(`frame_cnt,latitude,...`)엔 **시간이 없고 frame_cnt(프레임 번호)만** 있음.
→ fps 를 CSV 단독으로 못 구함. **영상 길이(duration)** 와 결합 필요.
## 해결
`effectiveFps = maxFrameCnt / duration` → 표준 fps(23.976/24/25/29.97/30/50/59.94/60) 중
±10% 이내면 스냅, 아니면 원시값. 데이터/길이 미확보 시 29.97 폴백.
### 변경 파일 (2개, 소규모)
- [VideoPlayer.tsx](../../client/src/components/player/VideoPlayer.tsx)
- `effectiveFps` useMemo 신설(storeFrames+duration). `VIDEO_FPS` 하드코딩 4곳 제거
(frame 계산 / HUD 표시 / 프레임입력 이동 / StationOverlay prop).
- useFrameStep 의 fps(VFC 감지, 31 오감지)는 미사용 → destructure 제거.
- [StationOverlay.tsx](../../client/src/components/overlay/StationOverlay.tsx)
- `fpsRef`(prop fps 기반) 추가. 프레임↔시간 변환 VIDEO_FPS 사용처(최근접 프레임 탐색, estFrame)를
fpsRef.current 로 교체. VIDEO_FPS 상수는 폴백 기본값으로만 유지.
## 주의/전제
- 드론 CSV 가 영상 전체 구간을 덮는다는 전제(마지막 frame_cnt ≈ 영상 끝). 부분만 덮으면 fps 과소 →
스냅 실패 시 원시값 사용. 필요 시 메타데이터/FFprobe 로 보강 가능.
- 표준 목록 밖 fps 는 스냅 없이 원시값 사용.
## 검증
- `tsc --noEmit` 통과
- `npm run build` 성공 → 새 해시 `index-BbUe4Gr4.js` :55000 서빙 확인
@@ -0,0 +1,33 @@
# 종점(대전조차장) 마커 끝-이동 제거 → 실제 직교 위치에 그대로 표시
**소요 시간**: 약 15분
**Context 사용량**: input ~260k / output ~5k tokens
## 사용자 원칙
- POI(영상 오버레이) 정보: 현재 위치 반경 안만 표시.
- **스테이션바 항목: 반경 없이, 측점선과 직교로 만나는 시설물이면 무조건 실제 위치에 표시.**
## 문제
검색이 찾은 162080 자리에 대전조차장 마커가 없음.
원인: `placeUnreachedTerminal` 이 endGapPx>0 일 때 **최우측 역사 마커를 트랙 끝(TRACK_END)** 으로
이동('미도착' 표시) → 드론이 종점 야드에서 162080을 여러 번 지나는데 마지막 통과 마커가 끝으로
옮겨져 검색 위치(실제 162080)와 어긋남.
## 수정 ([StationBar.tsx](../../client/src/stationbar/StationBar.tsx))
`placeUnreachedTerminal`**no-op**(marks 그대로 반환)으로 변경 → 마커 끝-이동 제거.
- 모든 162080 통과 마커가 실제 직교 위치에 유지(사용자 원칙과 일치).
- 종점 이름(endStationName=대전조차장역)은 오른쪽 끝 고정 표시 유지.
- 회색 미도착 gap(endGapPx) 자체는 트랙 색으로만 유지(마커는 안 옮김).
## 관련 상태
- 역사: pxPassesAtMileage(측점 일치)로만 탐지(좌표근접·게이트 없음) → 직교 일치 통과에만, 반경 없음.
- 교량/터널: pxPassesAtMileage 우선 + (측점/좌표 투영 없을 때만) pxPassesTo 폴백. 필요 시 폴백 제거 검토.
## 보정: 미도착 종점 마커는 '추가'로 복원
no-op 로 두니 드론이 못 간 '종점 162080'의 대전조차장 마커까지 사라짐(사용자 지적).
→ placeUnreachedTerminal 을 '이동'이 아니라 '추가'로 변경:
- endGapPx>0 이면 최우측 역사 마커를 **복사**해 TRACK_END 에 unreached 로 push(기존 마커 유지).
- 결과: 지나간 162080 통과는 실제 위치에 그대로 + 못 간 종점 162080은 끝에 미도착 표시.
## 검증
- `tsc --noEmit` 통과, `npm run build` 성공 → 해시 `index-D66Yy1Dn.js`(HUD `· 검색v5`) :55000 서빙.
@@ -0,0 +1,57 @@
# 지도 기반 나침반(OSM, 노스업) 추가 + 타입 전환
**소요 시간**: 약 30분
**Context 사용량**: input ~340k / output ~9k tokens
## 요구
우측 상단 나침반을 2종 중 선택. 새 '지도 기반 나침반' = 나침반 안에 지도 + 실시간 위치.
사용자 선택: **OSM 온라인 타일 · 노스업(북쪽 고정) · 화면표시 옵션에 전환 버튼.**
## 구현
### 클라이언트
- [store/settingsStore.ts](../../client/src/store/settingsStore.ts): `compassType: 'analog'|'map'` + setter (persist).
- [components/overlay/MapCompass.tsx](../../client/src/components/overlay/MapCompass.tsx) (신규):
- OSM 타일을 Web Mercator 로 배치, 원형 클립. 노스업(지도 고정) + 현재위치 중심 점 + 진행방향 화살표(yaw 회전) + 상단 N.
- `poseRef`(부모 RAF 갱신 {lat,lon,yaw}) 를 내부 RAF 로 읽어: 화살표 즉시 회전 + 지도 팬(transform) + 임계 이동 시 타일 재배치(setState).
- 타일 URL = **서버 프록시** `/api/tile/{z}/{x}/{y}.png` (외부 직접 로드는 CSP/COEP 로 차단됨).
- [components/overlay/StationOverlay.tsx](../../client/src/components/overlay/StationOverlay.tsx):
- `mapPoseRef` 추가, RAF 나침반 블록에서 headingDeg 항상 계산해 기록(아날로그 회전은 렌더 중일 때만).
- 렌더: compassType 에 따라 `<MapCompass>` / `<Minimap>` 선택.
- 화면표시 옵션 '표시 토글'에 나침반 [눈금|지도] 버튼 추가.
### 서버 (프록시 필수)
- CSP `img-src 'self'` + COEP `require-corp` 라 외부 타일 `<img>` 직접 로드 불가(elevation.ts 와 동일 제약).
- [server/src/routes/tile.ts](../../server/src/routes/tile.ts) (신규): `GET /api/tile/:z/:x/:y`
OSM 타일 fetch(식별 User-Agent) 후 같은 출처로 중계. `Cross-Origin-Resource-Policy: same-origin`, 7일 캐시, 좌표 검증(범위 밖 400).
- [server/src/app.ts](../../server/src/app.ts): `app.use('/api/tile', tileRouter)`.
## 배포
- server/client 빌드 + **PM2 restart ghiVideo**(서버 라우트 반영 필수) 완료.
- 검증: `/api/tile/16/x/y.png` → 200 image/png, CORP same-origin, 7일 캐시. 범위 밖 → 400. 클라 `index-BS7bYMJ3.js`.
## 추가: 줌 + 호버 확대 + 휠 줌
- settingsStore `compassZoom`(12~19, 기본16) + setter(persist). MapCompass 에서 project(z 동적) 사용.
- 나침반 하단 /z/+ 버튼(pointer-events auto). 줌 변경 시 현재위치 기준 타일 재배치.
- **마우스 오버 → 2배 확대**(transform scale, 우상단 고정 origin, 140ms), **벗어나면 원복**.
- **휠 줌**: 컨테이너 native wheel 리스너(passive:false) → deltaY 로 compassZoom ±1(위=확대).
- 호버/휠 위해 컨테이너 pointer-events auto(해당 영역만 이벤트 캡처).
## 개선(사용자 피드백)
- 확대: CSS scale(흐릿) → **위젯 지름 자체 확대**(BASE_D 150 → HOVER_D 300, 타일 재배치) → 더 넓은
영역을 선명하게. 마우스 오버 시 확대, **벗어나면 기본 크기+기본 줌(16) 복귀**.
- 방향 표시: 빨간 화살표 → **반투명 역삼각형(▽)** = 드론 시야(중심에서 촬영방향으로 벌어짐, yaw 회전).
- **줌 +/− 버튼·z값 표시 삭제.** 줌은 휠만(오버 중), 벗어나면 기본값.
- 상단 **N**: SVG text 로(위젯 크기에 맞춰 스케일) **크게·붉은색·반투명**.
- settingsStore 의 compassZoom 제거(줌은 MapCompass 로컬 상태로 이동, 세션 임시).
## 추가: 위성지도 옵션
- 타일 프록시를 소스별로 확장: [server/routes/tile.ts](../../server/src/routes/tile.ts) `GET /api/tile/:source/:z/:x/:y`
- source=osm → OpenStreetMap 일반, source=sat → **Esri World Imagery 위성**(z/row/col 순서, jpeg).
- settingsStore `compassMapStyle: 'street'|'sat'`(persist). MapCompass 타일 URL `/api/tile/${src}/...`.
- 화면표시 옵션에 지도 [일반|위성] 토글(지도 타입일 때만 노출).
- server 재빌드 + PM2 restart. 검증: osm→200 png, sat→200 jpeg.
- 주의: Esri World Imagery 는 출처표기 필요(내부 도구, 저부하). 대량이면 라이선스 검토.
## 주의/추후
- OSM 타일 사용 정책: 저부하 유지(줌16 고정 + 클라 96px 이동 시에만 재배치 + 서버 7일 캐시). 대량이면 자체 타일/유료 서비스 검토.
- 인터넷 없으면 타일 미표시(테두리/점/화살표만). 오프라인 필요 시 '노선 개요도(데이터 기반)' 타입 추가 검토.
@@ -0,0 +1,45 @@
# 측점 검색 — 없는 측점 검색 시 엉뚱한 곳으로 튀는 문제 수정
**소요 시간**: 약 10분
**Context 사용량**: input ~200k / output ~3k tokens
## 문제
스테이션바에서 `162080`(162k080) 검색 → 130m 떨어진 `162k210`으로 이동.
## 원인
[StationBar.tsx `handleJumpToMileage`](../../client/src/stationbar/StationBar.tsx#L845) 검색 3단계:
1. 마커(structureMarks) 표시측점 일치
2. 연속 측점(chain)이 허용오차(stationTolerance 20m) 안
3. **폴백: 둘 다 없으면 '무조건 가장 가까운 측점'으로 이동** ← 문제
162080이 이 영상에 (직교 투영으로) 없으니(조차장에서 드론 투영측점=162210) 3번 폴백이
130m 밖 162210으로 점프. '직교 일치 시에만' 원칙과도 불일치.
## 수정
폴백(882~889) 제거 → `if (!times.length) return;`.
TOL(기본 20m) 안에 없으면 그 측점은 (직교로) 존재하지 않으므로 **점프하지 않음**.
프레임이 조밀(30fps)해 실제 지나는 측점은 항상 TOL 안에서 잡히므로 폴백 불필요.
## 효과
- 없는 측점 검색 시 엉뚱한 곳으로 안 튐(아무 동작 안 함).
- 존재하는 측점(마커/연속측점)은 그대로 검색·순환 이동.
## 추가: '측점 없음' 안내 (사용자 선택 = 안내+이동안함)
'이동 안 함'만으로는 커서가 직전 위치(162210)에 남아 "검색이 거기로 간 것처럼" 보이는 혼동 →
검색 실패 시 **안내 메시지** 표시.
- `handleJumpToMileage` 가 boolean 반환(이동 true / 못 찾음 false).
- [PlaybackControls.tsx](../../client/src/stationbar/components/PlaybackControls/PlaybackControls.tsx):
Enter 시 못 찾으면 입력창 옆에 **"측점 없음"**(빨강) 1.8초 표시, 재입력/블러 시 숨김.
## 추가 2: 검색을 '실제 측점(chain)' 기준으로만 (마커 라벨 기준 제거)
증상: 162080 검색 시 여러 162080 위치를 순환하다 '맨 끝에' 162210으로 이동.
원인: 검색 1순위가 structureMarks 를 '표시 측점 라벨(round km)'로 매칭 → 라벨은 162080이지만
실제 위치가 다른(조차장 좌표근접) 마커가 순환에 끼어듦.
수정: 검색 1순위(마커 라벨 매칭) 제거. **드론 실제 투영 측점(arr[].chain)이 입력값과 TOL 이내인
통과로만** 이동(연속구간=1통과, 순환). 없으면 false → '측점 없음'.
- deps 에서 structureMarks/timeAtFrac/timeTrackWidth 제거.
- 진단용 HUD 표시 `· 검색v2`(빌드 확인용, 확인 후 제거 예정).
## 검증
- `tsc --noEmit` 통과, `npm run build` 성공 → 해시 `index-BrZ52ILs.js` :55000 서빙 확인.
@@ -0,0 +1,25 @@
# '측점 없음' 안내가 안 뜨던 문제 수정 (CSS 레이아웃)
**소요 시간**: 약 10분
**Context 사용량**: input ~300k / output ~2k tokens
## 문제
없는 측점 검색 시 "측점 없음" 안내가 화면에 안 떴다(로직은 정상).
## 원인
재생/입력 패널 `.transportGroup` 은 고정 크기 + 자식 요소가 모두 `position: absolute`.
안내 `<span>` 이 일반 흐름이라 패널 좌상단에 깔려 버튼/패널 뒤에 가려짐.
## 수정
- [PlaybackControls.module.scss](../../client/src/stationbar/components/PlaybackControls/PlaybackControls.module.scss)
`.notFound` 클래스 추가: 입력창과 같은 위치·크기로 **절대배치**, `z-index:6`, 어두운 배경 + 빨간 글씨.
- [PlaybackControls.tsx](../../client/src/stationbar/components/PlaybackControls/PlaybackControls.tsx)
안내 span 을 인라인 스타일 → `className={styles.notFound}` (문구 `⚠ 측점 없음`).
- 1.8초 후 자동 숨김 / 재입력 시 숨김(기존 로직 유지).
## 부수 정리
- 진단용 HUD 표시 `· 검색v5` 제거(빌드 확인용이었음).
## 검증
- `tsc --noEmit` 통과, `npm run build` 성공 → :55000 서빙.
- 168000(범위 밖) 검색 시 '⚠ 측점 없음' 정상 표시 확인(사용자).
@@ -0,0 +1,745 @@
# GhiVideo 구현 상세 — 기술과 소스코드 매칭
> 작성일: 2026-06-30
> 본 문서는 [쉬운설명_GhiVideo_기술이야기](쉬운설명_GhiVideo_기술이야기.md) 및 [DefVideo→GhiVideo_업그레이드_상세](DefVideo→GhiVideo_업그레이드_상세.md)에서 설명한 기술들을 **실제 소스코드(파일·라인·핵심 코드)**와 1:1로 매칭하여, "어떻게 구현했는가"를 코드 근거와 함께 상세히 기술한 문서다.
> 라인 번호는 작성 시점(2026-06-30) 기준이며 코드 수정 시 다소 달라질 수 있다.
---
## 목차
1. [전체 데이터 흐름](#1-전체-데이터-흐름-한눈에)
2. [데이터 로딩 — V2.0 폴더 파싱](#2-데이터-로딩--v20-폴더-파싱)
3. [좌표 투영 엔진](#3-좌표-투영-엔진)
4. [역투영 — 화면을 다시 세계로](#4-역투영--화면을-다시-세계로)
5. [측점·체이니지 계산](#5-측점체이니지-계산)
6. [영상 오버레이 렌더링](#6-영상-오버레이-렌더링)
7. [라벨 안정화 — 평활과 이상치 거부](#7-라벨-안정화--평활과-이상치-거부)
8. [POI 겹침 억제·컴팩트 팝업](#8-poi-겹침-억제컴팩트-팝업)
9. [위치 보정 — 드래그·DEM·세로화각](#9-위치-보정--드래그demem세로화각)
10. [나침반 미니맵](#10-나침반-미니맵)
11. [하단 측점바 — 이동거리축](#11-하단-측점바--이동거리축)
12. [60fps 부드러운 커서 — smoothTimeRef](#12-60fps-부드러운-커서--smoothtimeref)
13. [상태 관리 — geoStore·settingsStore·playerStore](#13-상태-관리)
14. [서버 — 고도(DEM) 프록시 API](#14-서버--고도dem-프록시-api)
---
## 1. 전체 데이터 흐름 (한눈에)
```
[폴더 드롭]
VideoPlayer.handleDrop (webkitGetAsEntry + collectDropEntry 재귀 수집)
[파싱] geoData.loadFolderGeoData
├ decodeBytes (BOM 자동 인코딩)
├ parseKmz (영상 옆 root .kml/.kmz) POI·구조물의 *유일* 소스(필수)
│ └ 없거나 비면 kmzMissing=true → 경고(alert+console), POI·구조물 빈 상태
│ (building/ POI·구조물 CSV 폴백 제거됨. 측점은 별개로 항상 로드)
├ parseStations → 측점 + 방향전환점 (측점 CSV: root 또는 building/)
├ parsePoiOverrides (드래그 보정 JSON)
└ buildCenterlineFromStations (center.csv 대체)
[상태] geoStore (basePois + poiOverrides → applyPoiOverrides → pois)
settingsStore (gradeFilter 등 localStorage 영속)
playerStore (videoReady, videoWidth/Height)
[투영] geoProjection: ENU → 카메라좌표(회전행렬) → 정규픽셀
chainage: 드론 GPS → 측점값(km) / 선로 이격(offsetM)
[렌더] StationOverlay (RAF 60fps): 선형·궤적·라벨·팝업·미니맵
StationBar (RAF): 이동거리축 커서/측점배지
VideoPlayer (RAF): smoothTimeRef 단조보간 시간
```
---
## 2. 데이터 로딩 — KMZ 우선, CSV 폴백
**파일**: [client/src/utils/geoData.ts](client/src/utils/geoData.ts)
> **데이터 소스 우선순위 (중요 — KMZ 필수 정책)**
> POI·구조물의 **유일한 소스는 영상 옆(root)의 `.kml`/`.kmz`** 다. KMZ는 **필수**이며, 없거나 비어 있으면 `kmzMissing=true`로 두고 **경고(alert + console)** 만 띄운 채 POI·구조물을 빈 상태로 둔다(데이터 누락 → 재구축 필요). **`building/` POI·구조물 CSV 폴백은 제거**되었다(`parsePois`/`parseStructures`/`parseAccessDoors`/`parseHistoricStations` 삭제). 단 **측점**은 KMZ에 없어 항상 CSV에서 읽으며, 이 측점 CSV는 root 또는 `building/` 어디서든 찾는다. 따라서 지금은 **KMZ(root) + 측점 CSV(root)** 만으로 동작한다. (철도역=역사는 KMZ의 `KAKAO_RAIL` placemark에서 구조물로 생성된다.)
>
> ```ts
> // loadFolderGeoData — KMZ가 유일 소스(필수). 없으면 경고만, 폴백 없음.
> const kmzMissing = !kmz || (kmz.pois.length === 0 && kmz.structures.length === 0);
> if (kmzMissing) console.warn('[KMZ] 누락/비어있음 — POI·구조물 미표시. 재구축 필요');
> const pois = kmz?.pois ?? [];
> const baseStructures = kmz?.structures ?? [];
> ```
### 2.1 자동 인코딩 감지 (`decodeBytes`, 6370행)
ArrayBuffer 앞 3바이트(BOM)로 UTF-8 / EUC-KR을 자동 판별한다. 구버전은 파일별로 인코딩을 하드코딩했으나, 신버전은 혼재 환경을 자동 처리한다.
```ts
export function decodeBytes(buf: ArrayBuffer): string {
const head = new Uint8Array(buf, 0, Math.min(3, buf.byteLength));
const isUtf8Bom = head.length >= 3 && head[0] === 0xef && head[1] === 0xbb && head[2] === 0xbf;
const encoding = isUtf8Bom ? 'utf-8' : 'euc-kr';
const text = new TextDecoder(encoding).decode(buf);
return text.replace(/^/, ''); // 디코딩 후 잔여 BOM 제거
}
```
### 2.2 파싱 견고성 헬퍼 (83129행)
깨진 EUC-KR 헤더에도 안전하도록, 헤더명 검색 실패 시 위치 인덱스로 폴백한다.
```ts
function makeFieldIndexer(header: string[]): (name: string, fallback?: number) => number {
const cleaned = header.map((h) => h.trim().replace(/^/, ''));
return (name, fallback) => {
const i = cleaned.indexOf(name);
return i >= 0 ? i : (fallback ?? -1); // 헤더명 실패 → 위치 폴백
};
}
function cell(row: string[], i: number): string { // 음수/범위 밖 → '' (크래시 방지)
return i >= 0 && i < row.length ? row[i] : '';
}
```
측점 CSV는 `findBuildingFile(files, '01)측점')` 키워드 매칭으로 `building/` → root 순으로 찾는다(둘 다 지원). KMZ 도입 후 POI·구조물 CSV 파서는 제거되어, 현재 CSV 파서는 측점·드론 둘뿐이다.
| 소스 | 파서 | 산출물 | 위치 |
|------|------|--------|------|
| 루트 `*.kml` / `*.kmz` | `parseKmz` | POI·구조물·역사 | **유일 소스(root, 필수)** |
| `측점.csv` | `parseStations` | 측점 + 방향전환점 | root **또는** building/ |
| `<base>.csv` | `parseDroneFrames` | 드론 프레임(자세/위치) | root |
> ~~`parsePois`·`parseStructures`·`parseAccessDoors`·`parseHistoricStations`~~ — KMZ 필수 정책으로 **삭제됨**(building/ POI·구조물 CSV 폴백 제거).
> 정리: **측점·역사**는 root 폴백이 있어 building 없이도 읽힌다. **지장물·교량·터널·구교·출입문 CSV**는 여전히 building/ 안에서만 찾지만, 이들은 KMZ가 있을 때 통째로 무시되므로 현재 KMZ 기반 데이터에서는 building 폴더가 없어도 정상 동작한다.
### 2.3 측점 표고 우선순위 & 방향전환점 (207260행)
측점 CSV에서 `Z좌표_한국`(정표고, EPSG:5186)을 우선 사용하고, 없으면 `Z좌표`로 폴백한다. 비고 컬럼은 정규식으로 방향전환점을 추출한다.
```ts
const iZKorea = fi('Z좌표_한국');
const iZ = iZKorea >= 0 ? iZKorea : fi('Z좌표', 3); // 정표고 우선
// ...
// 비고: "방향전환점(상행->하행, 02:05)"
const m = note.match(/방향전환점\s*\(\s*([^->]+?)\s*->\s*([^,)]+?)\s*,\s*([\d:]+)\s*\)/);
if (m) directionChanges.push({
station: title, from: m[1].trim(), to: m[2].trim(),
atSeconds: mmssToSeconds(m[3]),
});
```
### 2.4 POI 위치 보정 입출력 (732767행)
드래그로 만든 보정값을 `<base>_poi_overrides.json`에서 읽고(`parsePoiOverrides`), 원본에 덧씌운다(`applyPoiOverrides`). 원본과 보정을 분리해 **재적용 가능**하게 둔 것이 핵심 설계다.
```ts
export function applyPoiOverrides(pois: GeoPoint[], overrides: PoiOverrideMap): GeoPoint[] {
if (!overrides || !Object.keys(overrides).length) return pois;
return pois.map((p) => {
const o = overrides[p.title];
return o ? { ...p, lat: o.lat, lon: o.lon, z: o.z } : p;
});
}
```
### 2.5 중심선 생성 (`buildCenterlineFromStations`, 776781행)
V2.0엔 `center.csv`가 없으므로, 측점을 측점값 순으로 정렬해 중심선 폴리라인을 만든다.
```ts
export function buildCenterlineFromStations(stations: GeoPoint[]): CenterlinePoint[] {
return [...stations]
.filter((s) => !isNaN(s.lat) && !isNaN(s.lon))
.sort((a, b) => stationOrder(a.title) - stationOrder(b.title))
.map((s) => ({ lat: s.lat, lon: s.lon, z: s.z }));
}
```
---
## 3. 좌표 투영 엔진
**파일**: [client/src/utils/geoProjection.ts](client/src/utils/geoProjection.ts)
월드좌표(위경도+표고)를 영상 화면의 정규픽셀(0~1)로 변환하는 핵심이다. 흐름은 **ENU → 카메라좌표(회전) → 정규픽셀** 3단계.
### 🧒 먼저, 아주 쉽게 — "이거 무슨 일을 하는 거야?"
우리는 지도에서 **다리(교량)가 어디 있는지** 알아요. 위도·경도라는 숫자로요. (예: "북위 36.33도, 동경 127.45도")
그런데 드론이 하늘에서 영상을 찍고 있어요. 우리가 하고 싶은 건:
> **"그 다리가 지금 드론 영상 화면의 어디쯤에 보일까?"** 를 계산해서, 그 자리에 이름표(라벨)를 딱 붙이는 거예요.
이걸 **투영(projection)** 이라고 해요. 쉽게 말하면 **"지도 위의 한 점 → 영상 화면 위의 한 점"으로 바꾸는 마법**이에요.
```
지도(현실 세계) 드론 영상 화면
┌───────────────┐ ┌───────────────┐
│ 🌉 다리 │ ──투영 마법──► │ 🌉 │ ← 여기 이름표!
│ (위도, 경도) │ │ (화면 x, y) │
└───────────────┘ └───────────────┘
```
이 마법은 한 번에 안 되고 **3단계**를 거쳐요. 마치 요리처럼 순서대로 재료를 다듬어요.
```
1단계 2단계 3단계
[ENU 변환] → [카메라 눈으로 보기] → [사진 찍기]
둥근 지구를 드론이 고개 돌린 3D 세상을
평평한 모눈 방향에 맞춰 납작한 사진
종이로 폄 좌표를 돌림 한 장으로
```
---
### 3.1 1단계 — 둥근 지구를 평평한 모눈종이로 (`geoToEnu`, 9197행)
**문제**: 지구는 둥글어요. 그런데 둥근 표면 위에서 "몇 미터 떨어졌나"를 계산하긴 어려워요.
**해결**: 우리가 보는 작은 지역(노선 한 구간)만 **싹둑 잘라서 평평한 모눈종이(graph paper)** 위에 펼쳐요. 그러면 위치를 그냥 "오른쪽으로 몇 미터(E, East), 위로 몇 미터(N, North)"로 말할 수 있어요. 높이(U, Up)는 "기준점보다 몇 미터 높은가"로 둬요.
```
둥근 지구 표면 평평한 모눈종이 (ENU)
🌐 N(북) ↑
╱ ╲ ──► │ • 다리 (E=120m, N=300m, U=-5m)
(위도,경도) │
────────┼────────► E(동)
│ 기준점(0,0)
```
- `E`, `N` = 옆으로·앞으로 몇 미터 (지도를 평평하게 편 좌표, EPSG:5186 TM)
- `U` = 높이. `alt - refAlt`**기준 높이를 빼서** "상대 높이"로 만들어요.
```ts
function geoToEnu(lat, lon, alt, _refLat, _refLon, refAlt): [number, number, number] {
const [e, n] = latLonToTM(lat, lon); // 위도·경도 → 평평한 미터 좌표(E, N)
return [e, n, alt - refAlt]; // 높이는 기준점 대비 상대값(U)
}
```
> 💡 비유: 지구본에 붙은 스티커를 떼어 책상 위에 평평하게 붙이는 것. 작은 조각이라 찌그러짐이 거의 없어요.
---
### 3.2 2단계 — 드론의 눈높이로 고개 돌리기 (`toCameraCoords`, 301317행)
모눈종이 좌표(E, N, U)는 **'세상' 기준**이에요. 하지만 카메라(드론)는 **자기가 바라보는 방향**이 따로 있어요. 드론이 동쪽을 보든 북쪽을 보든, "내 앞·내 오른쪽·내 위" 기준으로 바꿔줘야 해요.
이때 드론의 자세 3가지를 써요:
- **yaw(요)** = 어느 쪽을 향하는지 (나침반 방향, 좌우 회전)
- **pitch(피치)** = 위/아래로 얼마나 기울었는지
- **roll(롤)** = 좌우로 얼마나 갸우뚱했는지
이 3개로 **회전 행렬**을 만들어 세상 좌표를 "카메라가 보는 좌표(Xc, Yc, Zc)"로 빙글 돌려요.
```
세상 기준(N=북 고정) 카메라 기준(드론이 보는 방향)
N↑ "앞"(Zc) ↗
│ • 다리 / • 다리
│ ╱ / (앞으로 50m,
────────┼────────► E ──회전──► / 오른쪽 8m)
│drone🚁 🚁─────► "오른쪽"(side)
(북을 보는 중) 드론이 보는 정면이 기준!
```
추가로 **진행방향(가는 길) 기준 거리**도 계산해요:
- `fwd` = 드론이 **가는 방향으로 앞쪽 몇 미터** (양수면 앞)
- `side` = **옆으로 몇 미터** (오른쪽 +, 왼쪽 )
이게 왜 필요할까요? 👉 "앞에 있는 먼 다리는 보여주되, 옆으로 멀리 떨어진 건 가리고 싶다" 같은 **똑똑한 필터**(앞은 멀리, 옆은 가깝게 = 비등방 거리필터)를 만들 수 있어요.
```ts
const cc = applyRw2c(b2w, relEnu); // 세상좌표 → 카메라좌표 (회전 적용)
cc.distH = dist; // 평면(수평) 거리
const yaw = toRad(camera.yaw + params.yawOffset);
const sy = Math.sin(yaw), cy = Math.cos(yaw);
cc.fwd = relEnu[0] * sy + relEnu[1] * cy; // +면 내 앞쪽
cc.side = relEnu[0] * cy - relEnu[1] * sy; // +면 오른쪽 / −면 왼쪽
```
> 💡 비유: 친구가 "북쪽으로 3걸음"이라고 말해도, 내가 서쪽을 보고 있으면 그건 "내 오른쪽 3걸음"이에요. **내가 보는 방향 기준으로 바꿔 말하는 것**이 2단계예요.
---
### 3.3 3단계 — 3D 세상을 납작한 사진 한 장으로 (`pixelFromCamera`, 126137행)
이제 진짜 **사진 찍기**예요. 카메라는 입체(3D) 세상을 납작한(2D) 사진으로 눌러 담아요. 핵심 원리는 딱 하나:
> **멀리 있는 건 화면 가운데로 작게, 가까이 있는 건 크게.**
옛날 **바늘구멍 사진기(핀홀 카메라)** 와 똑같아요. 작은 구멍으로 빛이 들어와 뒤쪽 종이에 상이 맺혀요.
```
바늘구멍 사진기 원리
다리 🌉 화면(필름)
\ │
\ ┌──┐ │ • ← 다리가 맺힌 점
\ 빛 │ │ 바늘구멍 │
\─────► │ •│ ───────────────► │
가까울수록 (구멍) │
화면에서 큼 │
◄────── 거리(Zc) ──────►
```
수학으로는 "카메라 좌표를 **깊이(Zc)로 나누는 것**"이 전부예요. Zc(거리)가 크면(멀면) 나눈 값이 작아져서 → 화면 가운데(0.5)에 가깝게 찍혀요.
```ts
return {
pxRaw: (0.5 + params.cx0) + (cc.Xc / cc.Zc) * (f / sW), // 가로 위치 (0~1)
pyRaw: (0.5 + params.cy0) + (cc.Yc / cc.Zc) * (f / sH), // 세로 위치 (0~1)
};
```
- `0.5` = 화면 정중앙. 결과가 0이면 화면 왼쪽 끝, 1이면 오른쪽 끝.
- `cc.Xc / cc.Zc` = **옆으로 벌어진 정도 ÷ 거리** → 멀수록 가운데로.
- `f / sW` = 렌즈가 얼마나 "확대/광각"인지 (초점거리 ÷ 센서폭 = 화각). 망원렌즈면 크게, 광각이면 넓게 보여요.
> 💡 비유: 손가락을 눈앞에 두면 크게 보이고, 멀리 뻗으면 작아 보이죠? 똑같이 "거리로 나누기" 한 거예요.
---
### 3.4 보너스 — 높이의 함정, '두 개의 바다 높이' (`geoidOffset`)
마지막으로 까다로운 문제 하나. **높이(고도)를 재는 자(기준)가 두 종류**예요.
1. **정표고(EL)** — 우리가 흔히 쓰는 "바다 높이(해발)". 측점 데이터(지도)가 쓰는 자.
2. **타원체고** — GPS·드론이 쓰는 "수학적으로 매끈한 지구 모양" 기준의 자.
이 둘은 같은 장소라도 **숫자가 달라요**. 대전 근처에선 약 **25.8m** 차이가 나요. 안 맞추면 다리가 화면에서 위아래로 어긋나 보여요.
```
타원체고 자 ──────────────── ← GPS/드론이 재는 0
│ 약 25.8m (geoidOffset)
정표고(해발) 자 ────────────── ← 지도(측점)가 재는 0
│ 다리의 실제 높이
▓▓▓▓▓ 땅 ▓▓▓▓▓
```
그래서 지도 높이에 **25.8m를 더해** GPS 기준으로 맞춰줘요. 이 한 줄 덕분에 라벨이 다리에 정확히 붙어요.
```ts
// 지도 높이(정표고) + geoidOffset = GPS 기준 높이(타원체고)
const stEnu = geoToEnu(targetLat, targetLon, targetAlt + (params.geoidOffset ?? 0), ...);
// DEFAULT_CAMERA_PARAMS: geoidOffset: 25.8 (대전 KNGeoid18), sensorW: 36, sensorH: 20.25 ...
```
> 💡 비유: 친구는 1층을 "0층"이라 부르고 나는 "1층"이라 불러요. 같은 곳을 말해도 숫자가 달라요. 그래서 "네 숫자에 1을 더하면 내 숫자야"라고 약속을 맞춰주는 것 = geoidOffset.
---
### 📋 3단계 한 줄 요약
| 단계 | 하는 일 | 쉬운 말 | 함수 |
|------|---------|---------|------|
| 1 | 둥근 지구 → 평평한 미터좌표(E,N,U) | 지구본 스티커를 책상에 펴기 | `geoToEnu` |
| 2 | 세상좌표 → 카메라가 보는 좌표 | 내가 보는 방향 기준으로 고개 돌리기 | `toCameraCoords` |
| 3 | 3D → 2D 화면 점(0~1) | 거리로 나눠 사진 찍기 | `pixelFromCamera` |
| + | 높이 기준 맞추기 | 두 개의 '0층'을 약속으로 통일 | `geoidOffset` |
---
## 4. 역투영 — 화면을 다시 세계로
**파일**: [client/src/utils/geoProjection.ts](client/src/utils/geoProjection.ts) (149251행)
드래그 편집·세로화각 보정 기능의 수학적 기반. 화면 픽셀을 다시 월드좌표로 되돌리는 3개 함수.
| 함수 | 위치 | 입력→출력 | 용도 |
|------|------|-----------|------|
| `worldFromPixel` | 149–183 | 픽셀 + 슬랜트거리 → lat/lon/z | POI 드래그(수평+수직 동시) |
| `solveZForPixelY` | 194–211 | 화면 세로위치 → 표고 z | "앞으로 밀려 보임" 보정 |
| `groundPointFromPixel` | 218251 | 픽셀 + 지면고도 → lat/lon | 지면 좌표 보정(z 유지) |
핵심 아이디어: 투영의 회전행렬 `b2w`를 전치(`w2c`)해 카메라 방향벡터를 월드 ENU 방향으로 되돌린 뒤, 거리(또는 평면 교차)로 스케일한다.
```ts
// groundPointFromPixel: 광선과 지면(zGround)의 교차 (218251행)
const relUpTarget = (zGround + (params.geoidOffset ?? 0)) - camera.altitude - (params.offZ ?? 0);
const t = relUpTarget / dir[2]; // 광선 파라미터
if (t <= 0) return null;
const E = drEnu[0] + (params.offX ?? 0) + t * dir[0];
const N = drEnu[1] + (params.offY ?? 0) + t * dir[1];
const [lon, lat] = _toTM.inverse([E, N]);
return { lat, lon };
```
```ts
// solveZForPixelY: 표고만 역산 — u = (R·Zc0 Yc0)/(dYc R·dZc) (194211행)
const R = (pyTarget - 0.5 - (params.cy0 ?? 0)) * sH / f;
const denom = dYc - R * dZc;
if (Math.abs(denom) < 1e-9) return z0;
return z0 + (R * cc0.Zc - cc0.Yc) / denom;
```
---
## 5. 측점·체이니지 계산
**파일**: [client/src/utils/chainage.ts](client/src/utils/chainage.ts) (신규)
드론 GPS를 "선로 위 측점값(km)"과 "선로 수직이격(m)"으로 환산한다. StationBar와 오버레이 HUD가 공유.
```ts
export function kmFromTitle(title: string): number { // "157K970" → 157970
const m = title.match(/(\d+)[Kk](\d+)/);
return m ? parseInt(m[1], 10) * 1000 + parseInt(m[2], 10) : -1;
}
export function buildChainLine(stations): ChainLine | null { // 측점 → 평면 폴리라인 (22–32)
const sorted = [...sts].sort((a, b) => kmFromTitle(a.title) - kmFromTitle(b.title));
const lat0 = sorted.reduce((s, p) => s + p.lat, 0) / sorted.length;
const k = Math.cos((lat0 * Math.PI) / 180) * 111000; // 경도→m 환산
return { pts: sorted.map((p) => ({ x: p.lon * k, y: p.lat * 111000, km: kmFromTitle(p.title), ... })), k };
}
export function projectToChain(lat, lon, line): { km; offsetM } { // GPS → 측점값+이격 (3550)
// 각 선분에 점-투영 → 최근접 선분의 보간 km, 수직거리(offsetM) 반환
}
```
---
## 6. 영상 오버레이 렌더링
**파일**: [client/src/components/overlay/StationOverlay.tsx](client/src/components/overlay/StationOverlay.tsx)
### 6.1 RAF 60fps 루프 (9051198행)
`timeupdate`(~250ms, 부정확) 대신 `requestAnimationFrame` 루프에서 매 프레임 Canvas를 다시 그린다. CLAUDE.md의 "오버레이 성능" 규칙을 정확히 따른다.
```tsx
const draw = () => {
rafId = requestAnimationFrame(draw);
ctx.clearRect(0, 0, W, H);
// 1. object-fit:cover 변환 계산
// 2. 연속 보간 포즈(estFrame) → dronePose = poseAt(estFrame)
// 3. 중심선·드론궤적 투영/렌더
// 4. 측점 라벨 / 5. POI 마커(재투영 + smoothStep 평활)
// 6. 히트박스 수집 / 7. 팝업 위치 매 프레임 추종 / 8. 편집 피드백
};
```
매 프레임 **사전계산 캐시(가시성/겹침)**는 그대로 두고, 위치만 현재 보간 포즈로 재투영한다 → 선처럼 부드럽게 이동.
### 6.2 object-fit:cover 정렬 (921930행)
영상이 `object-fit:cover`로 크롭된 실제 표시영역을 매 프레임 계산해, 정규좌표(0~1, 영상 프레임 기준)를 화면 px로 정확히 변환한다. 이게 있어야 라벨이 영상 위 실제 지점에 정렬된다.
```tsx
const s = Math.max(W / vW, H / vH); // cover: 더 큰 배율
dispW = vW * s; dispH = vH * s;
offX = (W - dispW) / 2; offY = (H - dispH) / 2; // 음수 = 화면 밖(크롭)
coverRef.current = { offX, offY, dispW, dispH, W, H };
const vx = (nx) => offX + nx * dispW; // 정규 → 화면 px
```
---
## 7. 라벨 안정화 — 평활과 이상치 거부
**파일**: [client/src/components/overlay/StationOverlay.tsx](client/src/components/overlay/StationOverlay.tsx) (4861행)
떨림/튐을 잡는 핵심 알고리즘. One Euro 필터 방식의 **속도 적응형 EMA + 이상치 거부**.
```tsx
const REJECT_DIST = 0.12; // 1프레임 최대 점프(정규)
const MAX_REJECT_FRAMES = 8; // 연속 거부 한계 → 초과 시 수용
const SMOOTH_VEL_BETA = 0.25; // 속도 평활 계수
function smoothStep(prev, tx, ty, maxAlpha, minAlpha, speedRef): DispPos {
if (!prev) return { x: tx, y: ty, rej: 0, vx: 0, vy: 0 };
const dx = tx - prev.x, dy = ty - prev.y, d = Math.hypot(dx, dy);
if (d > REJECT_DIST && prev.rej < MAX_REJECT_FRAMES) // 이상치 → 위치 유지
return { x: prev.x, y: prev.y, rej: prev.rej + 1, vx: prev.vx, vy: prev.vy };
const vx = prev.vx + (dx - prev.vx) * SMOOTH_VEL_BETA; // 속도 평활
const vy = prev.vy + (dy - prev.vy) * SMOOTH_VEL_BETA;
const speed = Math.hypot(vx, vy);
const a = Math.min(maxAlpha, minAlpha + (maxAlpha - minAlpha) * Math.min(1, speed / Math.max(1e-4, speedRef)));
return { x: prev.x + dx * a, y: prev.y + dy * a, rej: 0, vx, vy };
}
```
**원리**: 떨림은 방향이 매 프레임 왕복 → 평활속도≈0 → `a``minAlpha`로 작아져 강하게 평활. 실제 이동은 방향이 일관 → 평활속도 큼 → `a``maxAlpha`로 커져 지연 없이 추종. 비정상 점프(시크 등)는 8프레임까지 무시하다 수용.
---
## 8. POI 겹침 억제·컴팩트 팝업
**파일**: [client/src/components/overlay/StationOverlay.tsx](client/src/components/overlay/StationOverlay.tsx)
### 8.1 겹침 억제 (3536, 770780행)
화면상 가로 10%·세로 3.5% 이내 마커는 겹침으로 보고, 같은 좌표의 형제 구조물(상/하)이면 진행방향 변형을 우선 남긴다.
```tsx
const POI_MERGE_X = 0.10, POI_MERGE_Y = 0.035;
// ...
if (Math.abs(k.x - c.x) < POI_MERGE_X && Math.abs(k.y - c.y) < POI_MERGE_Y) { overlapIdx = i; break; }
// 형제 + c가 진행방향 변형이면 교체
if (dirTag && baseStruct(c.title) === baseStruct(k.title) && c.title.includes(dirTag) && !k.title.includes(dirTag))
accepted[overlapIdx] = c;
```
### 8.2 컴팩트 팝업 (152176, 12691292행)
라벨 옆에 항상 핵심 3~6필드(시설종별→구조형식→연장→폭→용도→준공연도)를 컴팩트로 보이고, 클릭하면 전체로 확장한다. **다중 팝업** 누적 가능.
```tsx
const compactFieldsOf = (props) => { // 정해진 순서로 존재하는 것만 (152–166)
pick('시설종별', k => k === '시설종별'); pick('구조형식', k => /구조형식/.test(k));
pick('연장(m)', k => /연장/.test(k)); /* 폭/용도/준공 ... */
};
// 클릭: 이미 있으면 전체↔컴팩트 토글, 없으면 추가 (컴팩트필드 없으면 바로 전체 펼침) (12691292)
expanded: compact.length === 0
```
---
## 9. 위치 보정 — 드래그·DEM·세로화각
**파일**: [client/src/components/overlay/StationOverlay.tsx](client/src/components/overlay/StationOverlay.tsx)
### 9.1 드래그 편집 (12361254, 1348행)
편집 모드에서 POI를 끌면 `groundPointFromPixel`로 화면점을 지면과 교차시켜 lat/lon을 역산, geoStore에 보정으로 저장.
```tsx
const ll = groundPointFromPixel(drone, pos.x, pos.y, drag.z0, p, wo);
if (ll) setPoiOverride(drag.title, { lat: ll.lat, lon: ll.lon, z: drag.z0 });
```
### 9.2 DEM 자동 표고 (13841415행)
모든 POI/구조물 좌표를 100개씩 묶어 서버 `/api/elevation`(SRTM 30m)에 질의, 실제 지형고도를 보정값으로 일괄 적용. COEP/CSP로 외부 직접호출이 막혀 서버가 중계.
```tsx
const r = await fetch(`/api/elevation?lat=${lat}&lon=${lon}`); // 같은 출처 프록시
const elev = (await r.json())?.elevation;
chunk.forEach((p, k) => { if (typeof elev[k] === 'number') next[p.title] = { lat: p.lat, lon: p.lon, z: elev[k] }; });
setPoiOverrides(next);
```
### 9.3 세로화각 보정 (13311345행)
라벨이 상하로 어긋날 때, POI를 영상 속 실제 위치로 끌면 **세로 화각(sensorH)만** 역산해 자동 보정한다(가로 초점 f는 불변).
```tsx
const b = cc.Yc / cc.Zc;
const v = pos.y - 0.5 - (p.cy0 ?? 0);
const sHNew = Math.max(6, Math.min(36, (b * p.focalLen) / v));
setParam('sensorH', sHNew);
```
---
## 10. 나침반 미니맵
**파일**: [client/src/components/overlay/Minimap.tsx](client/src/components/overlay/Minimap.tsx) + StationOverlay RAF(954971행)
SVG 카드 전체를 `transform: rotate(var(--rot))`로 회전시키고, RAF에서 매 프레임 `--rot``yaw`로 갱신한다. 360° 누적 언랩으로 회전 점프를 막고, 회전엔 메인 포즈(±60fr 강한 평활)가 아닌 **가벼운 ±3fr 평활 yaw**를 별도 계산해 지연을 줄였다.
```tsx
// Minimap.tsx: transform: 'rotate(var(--rot, 0deg))'
// StationOverlay.tsx (954971): 매 프레임 갱신
const target = -(rawYaw + paramsRef.current.yawOffset);
const delta = (((target - minimapRotRef.current) % 360) + 540) % 360 - 180; // 최단경로 언랩
minimapRotRef.current += delta;
minimapRef.current.style.setProperty('--rot', `${minimapRotRef.current}deg`);
```
---
## 11. 하단 측점바 — 이동거리축
**파일**: [client/src/stationbar/StationBar.tsx](client/src/stationbar/StationBar.tsx)
### 11.1 영상별 FPS 자동 (228231행)
고정 29.97 대신 `마지막 프레임 / 재생시간`으로 영상마다 산출 → 측점 배지 정확도 향상.
```tsx
const videoFps = useMemo(() => {
const last = storeFrames.length ? storeFrames[storeFrames.length - 1].frame : 0;
return last > 0 && duration > 0 ? last / duration : VIDEO_FPS;
}, [storeFrames, duration]);
```
### 11.2 이동거리축 누적 (233274행)
드론의 실제 측점값(체이니지)을 ±8프레임 평활한 뒤, 프레임 간 변화량 `|Δ|`을 누적해 "실제 이동거리" 축을 만든다. 호버(공중 정지) 구간에선 누적이 멈춰 커서가 정지 → 대기 시간이 가시화된다. 앞뒤 5% 평균(`depChain`/`arrChain`)으로 출발·도착 방향을 잡는다.
```tsx
const W = 8;
for (let i = 0; i < n; i++) { // 측점값 ±W 이동평균
sm[i] = (pre[hi] - pre[lo]) / (hi - lo);
if (i > 0) cum += Math.abs(sm[i] - sm[i - 1]); // |Δ| 누적 = 이동거리
frac[i] = cum;
}
for (let i = 0; i < n; i++) frac[i] /= total; // 0~1 정규화
chainRef.current = { time, frac, depChain: dep, arrChain: arr };
```
### 11.3 종점역 미도착 (300310, 574586행)
영상이 종점역에 미도달하면 트랙 우측을 회색(상한 15%)으로 비우고, 종점 마커를 '속 빈 링(unreached)'으로 표시.
```tsx
const endGapPx = Math.min(TRACK_WIDTH_PX * 0.15, (gapM / lenM) * TRACK_WIDTH_PX);
const timeTrackWidth = TRACK_WIDTH_PX - endGapPx;
// 마지막 역사 마커를 트랙 끝(미도착)으로
if (hiIdx >= 0) marks[hiIdx] = { ...marks[hiIdx], px: TRACK_END_PX, unreached: true };
```
### 11.4 Timeline 라벨 그룹핑 (Timeline.tsx 139172행)
동명 구조물의 여러 통과점을 `Map<title, marks[]>`으로 묶어, 중앙에 라벨 1개 + 각 통과에 점선 드롭 + 양끝 브래킷으로 표시. 상/하 변형은 진행방향 1개만 남긴다.
---
## 12. 60fps 부드러운 커서 — smoothTimeRef
**파일**: [client/src/components/player/VideoPlayer.tsx](client/src/components/player/VideoPlayer.tsx) (62117행)
`currentTime`은 ~250ms 간격으로 갱신돼 커서가 끊긴다. 이를 **벽시계 기준 단조 보간**으로 메워 60fps로 만든다. 정지 시엔 실제 시간에 앵커, 재생 중엔 `media + 경과×배속`으로 추정하되 0.3s 이상 벌어지면 재동기화.
```tsx
let est = a.media + ((performance.now() - a.wall) / 1000) * rate;
const real = p.currentTime() ?? 0;
if (real - est > 0.3 || real < a.media - 0.3) { // 드리프트 보정
est = real; anchorRef.current = { media: real, wall: performance.now() };
}
smoothTimeRef.current = t; // ref로 노출
```
StationBar는 이 `timeRef`**React 리렌더 없이** 직접 읽어 CSS 변수만 갱신한다(StationBar.tsx 769786행) → 커서/진행바가 부드럽게 흐른다.
```tsx
const t = timeRef.current ?? 0;
el.style.setProperty('--pos-px', `${pxAtTime(t)}px`);
el.style.setProperty('--cursor-x', `${renderX(pos)}px`);
```
---
## 13. 상태 관리
### 13.1 geoStore — 보정 분리관리
**파일**: [client/src/store/geoStore.ts](client/src/store/geoStore.ts) (29110행)
`basePois`(파싱 원본, 불변) + `poiOverrides`(보정 맵) → `applyPoiOverrides` 결과가 `pois`. 보정 추가/삭제 시 항상 원본에서 재계산하므로 누적 오염이 없다.
```ts
setPoiOverride: (title, ov) => set((s) => {
const poiOverrides = { ...s.poiOverrides, [title]: ov };
return { poiOverrides, pois: applyPoiOverrides(s.basePois, poiOverrides) };
}),
```
### 13.2 settingsStore — localStorage 영속
**파일**: [client/src/store/settingsStore.ts](client/src/store/settingsStore.ts) (4690행)
Zustand `persist``ghivideo.settings` 키에 저장. `merge`로 과거 저장본의 누락 키를 기본값 보충(스키마 진화 대비). `isGradeVisible`을 공유 함수로 두어 StationBar·RoutePanel·오버레이가 동일 규칙을 쓴다.
```ts
{ name: 'ghivideo.settings',
merge: (persisted, current) => ({ ...current, ...p,
gradeFilter: { ...DEFAULT_GRADE_FILTER, ...(p.gradeFilter ?? {}) } }) }
// isGradeVisible: 미지정/미등록 등급은 표시, 등록 등급은 체크 상태 따름
```
### 13.3 playerStore — videoReady 게이트
**파일**: [client/src/store/playerStore.ts](client/src/store/playerStore.ts) (1418행) + [useVideoPlayer.ts](client/src/hooks/useVideoPlayer.ts) (4450행)
`loadeddata` 전엔 오버레이를 그리지 않아(깜빡임 방지), `videoWidth/Height`로 cover 정렬을 정확화.
```ts
player.on('loadstart', () => store.setVideoReady(false));
player.on('loadeddata', () => store.setVideoReady(true));
const reportSize = () => store.setVideoSize(player.videoWidth() ?? 0, player.videoHeight() ?? 0);
player.on('loadedmetadata', reportSize);
player.on('loadeddata', reportSize);
```
### 13.4 폴더 드롭 (VideoPlayer.tsx 1837, 233257행)
`webkitGetAsEntry()`로 디렉터리 엔트리를 얻어 `collectDropEntry`로 재귀 수집 → 영상+측점/POI 폴더를 통째로 로드.
```tsx
function collectDropEntry(entry, out): Promise<void> { // 디렉터리 재귀
if (entry.isDirectory) { /* readEntries 배치 반복 */ }
else (entry).file((f) => { out.push(f); });
}
```
---
## 14. 서버 — 고도(DEM) 프록시 API
**파일**: [server/src/routes/elevation.ts](server/src/routes/elevation.ts) (1467행)
브라우저 COEP/CSP 제약으로 클라이언트가 외부 DEM API를 직접 못 부르므로 서버가 중계한다. 배치(콤마 구분) 질의, **opentopodata SRTM 30m → open-meteo 90m** 2단 폴백.
```ts
router.get('/', async (req, res) => {
// 1) open-topodata SRTM 30m
const locs = lats.map((la, i) => `${la.trim()},${lons[i].trim()}`).join('|');
const r = await fetch('https://api.opentopodata.org/v1/srtm30m?locations=' + encodeURIComponent(locs));
if (r.ok && elevation.some(v => v != null)) {
res.json({ elevation, source: 'opentopodata-srtm30m' }); return;
}
// 2) open-meteo 90m 폴백
const r2 = await fetch('https://api.open-meteo.com/v1/elevation?latitude=' + latStr + '&longitude=' + lonStr);
res.json({ elevation: (await r2.json())?.elevation ?? [], source: 'open-meteo-90m' });
});
```
`app.ts``app.use('/api/elevation', elevationRouter)`로 등록(서버 변경의 전부).
---
## 부록 — 기능 ↔ 소스 빠른 색인
| 기능 | 파일 | 라인 |
|------|------|------|
| 자동 인코딩 감지 | geoData.ts | 6370 |
| 파싱 헬퍼(폴백 인덱서) | geoData.ts | 83129 |
| 측점/방향전환점 | geoData.ts | 207264 |
| 구조물 파싱 | geoData.ts | 356449 |
| KMZ 파싱 | geoData.ts | 553648 |
| POI 보정 입출력 | geoData.ts | 732767 |
| 중심선 생성 | geoData.ts | 776781 |
| 카메라좌표 변환 | geoProjection.ts | 301317 |
| 정규픽셀 투영 | geoProjection.ts | 126137 |
| 역투영 3종 | geoProjection.ts | 149251 |
| 체이니지 계산 | chainage.ts | 1064 |
| RAF 렌더 루프 | StationOverlay.tsx | 9051198 |
| cover 정렬 | StationOverlay.tsx | 921930 |
| 라벨 평활/이상치거부 | StationOverlay.tsx | 4861 |
| POI 겹침 억제 | StationOverlay.tsx | 3536, 770780 |
| 컴팩트 팝업 | StationOverlay.tsx | 152176, 12691292 |
| 드래그/DEM/세로화각 | StationOverlay.tsx | 12361254, 13311415 |
| 나침반 미니맵 | Minimap.tsx / StationOverlay.tsx | 전체 / 954971 |
| 이동거리축 | StationBar.tsx | 228310 |
| 라벨 그룹핑 | Timeline.tsx | 101172 |
| smoothTimeRef | VideoPlayer.tsx / StationBar.tsx | 62117 / 769786 |
| geoStore 보정 | geoStore.ts | 29110 |
| settingsStore | settingsStore.ts | 4690 |
| videoReady 게이트 | playerStore.ts / useVideoPlayer.ts | 1418 / 4450 |
| 폴더 드롭 | VideoPlayer.tsx | 1837, 233257 |
| 고도 API | elevation.ts | 1467 |
---
*본 문서는 GhiVideo 소스코드를 직접 읽어 함수·라인·핵심 코드를 추출해 작성되었다. 코드 인용은 가독성을 위해 일부 축약·생략(`...`)했으며, 정확한 전체 구현은 해당 파일을 참조하라.*
@@ -0,0 +1,724 @@
<!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>구현상세_드론좌표_영상투영_소스코드매칭</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">구현상세_드론좌표_영상투영_소스코드매칭</h1>
</header>
<h1 id="드론-좌표--영상-픽셀-투영-기술--소스코드-매칭-구현-상세">드론 좌표 → 영상 픽셀 투영: 기술 ↔︎ 소스코드 매칭 (구현 상세)</h1>
<blockquote>
<p>발표 자료 <a href="발표_핵심기술_드론좌표를_영상에_맞추기_쉬운설명.md">발표_핵심기술_드론좌표를_영상에_맞추기_쉬운설명</a>&quot;쉬운 비유&quot;<strong>실제 어떤 코드로 구현</strong>되었는지, 파일·줄 위치와 함께 설명하는 개발자용 문서입니다. 핵심 계산은 대부분 <a href="../client/src/utils/geoProjection.ts">client/src/utils/geoProjection.ts</a> 한 곳에 모여 있고, 매 프레임 호출·평활·렌더링은 <a href="../client/src/components/overlay/StationOverlay.tsx">client/src/components/overlay/StationOverlay.tsx</a> 에 있습니다.</p>
</blockquote>
<style>
figure.fig{margin:20px 0;text-align:center;page-break-inside:avoid;break-inside:avoid;}
figure.fig svg{width:100%;max-width:700px;height:auto;border:1px solid #e7e0d2;border-radius:10px;background:#fffdf8;}
figure.fig figcaption{font-size:0.9em;color:#777;margin-top:6px;}
text{font-family:'Malgun Gothic','Hancom Gothic',sans-serif;}
code{background:#f5f3ee;padding:1px 4px;border-radius:4px;}
</style>
<hr />
<h2 id="0-전체-파이프라인-파일-기준">0. 전체 파이프라인 (파일 기준)</h2>
<figure class="fig">
<svg viewBox="0 0 700 340" role="img" aria-label="전체 파이프라인">
<defs><marker id="a" markerWidth="11" markerHeight="11" refX="7" refY="3.5" orient="auto"><path d="M0,0 L7,3.5 L0,7 Z" fill="#7c3aed"></path></marker></defs>
<rect x="200" y="12" width="300" height="40" rx="8" fill="#f1f5f9" stroke="#475569"></rect>
<text x="350" y="30" text-anchor="middle" font-size="12" font-weight="bold" fill="#334155">데이터 로드 (폴더 선택)</text>
<text x="350" y="45" text-anchor="middle" font-size="10" fill="#475569">geoData.ts → geoStore (드론 CSV·POI·측점·중심선)</text>
<line x1="350" y1="52" x2="350" y2="70" stroke="#7c3aed" stroke-width="2" marker-end="url(#a)"></line>
<rect x="150" y="72" width="400" height="34" rx="8" fill="#eef2ff" stroke="#4f46e5"></rect>
<text x="350" y="94" text-anchor="middle" font-size="11" font-weight="bold" fill="#3730a3">매 프레임 루프: StationOverlay.tsx draw() @ requestAnimationFrame</text>
<line x1="350" y1="106" x2="350" y2="124" stroke="#7c3aed" stroke-width="2" marker-end="url(#a)"></line>
<g font-size="11">
<rect x="120" y="126" width="460" height="30" rx="6" fill="#ecfdf5" stroke="#16a34a"></rect>
<text x="350" y="146" text-anchor="middle" fill="#166534">① 각도→미터 latLonToTM / geoToEnu (proj4 · EPSG:5186)</text>
<line x1="350" y1="156" x2="350" y2="170" stroke="#7c3aed" stroke-width="1.5" marker-end="url(#a)"></line>
<rect x="120" y="172" width="460" height="30" rx="6" fill="#fefce8" stroke="#ca8a04"></rect>
<text x="350" y="192" text-anchor="middle" fill="#854d0e">② 드론기준 상대위치 buildRelEnu (빼기)</text>
<line x1="350" y1="202" x2="350" y2="216" stroke="#7c3aed" stroke-width="1.5" marker-end="url(#a)"></line>
<rect x="120" y="218" width="460" height="30" rx="6" fill="#eff6ff" stroke="#2563eb"></rect>
<text x="350" y="238" text-anchor="middle" fill="#1e3a8a">③ 방향 회전 buildRotation + applyRw2c (toCameraCoords)</text>
<line x1="350" y1="248" x2="350" y2="262" stroke="#7c3aed" stroke-width="1.5" marker-end="url(#a)"></line>
<rect x="120" y="264" width="460" height="30" rx="6" fill="#fdf2f8" stroke="#db2777"></rect>
<text x="350" y="284" text-anchor="middle" fill="#9d174d">④ 핀홀 투영 pixelFromCamera → (px,py)</text>
<line x1="350" y1="294" x2="350" y2="308" stroke="#7c3aed" stroke-width="1.5" marker-end="url(#a)"></line>
<rect x="120" y="310" width="460" height="26" rx="6" fill="#f5f3ff" stroke="#7c3aed"></rect>
<text x="350" y="327" text-anchor="middle" fill="#5b21b6">⑤⑥ 보간·평활(poseAt/smoothFrame/smoothStep) → ⑦ Canvas 렌더</text>
</g>
</svg>
</figure>
<blockquote>
<p>아래 표의 <strong>기술</strong>과 본문의 <strong>굵은 용어</strong>들은 문서 맨 아래 <strong><a href="#-용어-사전-도움말">📖 용어 사전(도움말)</a></strong> 에서 그림과 함께 자세히 풀어 놓았어요. 모르는 말이 나오면 바로 그 항목을 보면 됩니다. (다른 검색 필요 없음!)</p>
</blockquote>
<table>
<thead>
<tr class="header">
<th>단계</th>
<th>기술</th>
<th>파일</th>
<th>핵심 함수</th>
<th><strong>하는 일 (쉽게)</strong></th>
</tr>
</thead>
<tbody>
<tr class="odd">
<td>① 위경도→미터</td>
<td>proj4 · EPSG:5186</td>
<td>geoProjection.ts</td>
<td><code>latLonToTM</code>, <code>geoToEnu</code></td>
<td>GPS <strong>각도</strong>(위도·경도)를 계산하기 쉬운 <strong>미터 지도</strong>로 바꾼다</td>
</tr>
<tr class="even">
<td>② 드론기준 위치</td>
<td>ENU 상대좌표</td>
<td>geoProjection.ts</td>
<td><code>buildRelEnu</code></td>
<td>터널−드론 좌표를 <strong>빼서</strong> &quot;드론에서 동/북/위로 몇 m&quot;인지 구한다</td>
</tr>
<tr class="odd">
<td>③ 방향 회전</td>
<td>회전행렬</td>
<td>geoProjection.ts</td>
<td><code>buildRotation</code>, <code>applyRw2c</code></td>
<td>카메라 기울기(<strong>yaw·pitch·roll</strong>)만큼 방향을 <strong>한 번에 돌린다</strong></td>
</tr>
<tr class="even">
<td>④ 화면 투영</td>
<td>핀홀 카메라 모델</td>
<td>geoProjection.ts</td>
<td><code>pixelFromCamera</code></td>
<td>3D 방향을 <strong>납작한 화면의 점(가로·세로 %)</strong> 으로 눌러 담는다</td>
</tr>
<tr class="odd">
<td>⑤ 사이 채우기</td>
<td>보간</td>
<td>StationOverlay.tsx</td>
<td><code>poseAt</code></td>
<td>사진 30장 <strong>사이의 중간 위치</strong>를 상상해 채워 부드럽게</td>
</tr>
<tr class="even">
<td>⑥ 흔들림 제거</td>
<td>이동평균 + EMA</td>
<td>StationOverlay.tsx</td>
<td><code>smoothFrame</code>, <code>smoothStep</code></td>
<td>여러 값을 <strong>평균 내</strong> 드론 떨림·튐을 없앤다</td>
</tr>
<tr class="odd">
<td>⑦ 렌더</td>
<td>RAF + Canvas 2D</td>
<td>StationOverlay.tsx</td>
<td><code>draw</code></td>
<td>화면 새로 그릴 때마다(≈60/s) <strong>이름표를 캔버스에 그린다</strong></td>
</tr>
<tr class="even">
<td>⑧ fps 자동</td>
<td>프레임수÷길이</td>
<td>VideoPlayer.tsx</td>
<td><code>effectiveFps</code></td>
<td>영상이 <strong>1초에 몇 장</strong>인지 스스로 알아내 ①~⑦의 시간 기준을 맞춘다</td>
</tr>
</tbody>
</table>
<hr />
<h2 id="1-위경도각도--미터-지도-proj4--epsg5186">1. 위경도(각도) → 미터 지도 (proj4 · EPSG:5186)</h2>
<p><strong>위치:</strong> <a href="../client/src/utils/geoProjection.ts#L74-L97">geoProjection.ts:74-97</a></p>
<div class="sourceCode" id="cb1"><pre class="sourceCode ts"><code class="sourceCode typescript"><span id="cb1-1"><a href="#cb1-1" aria-hidden="true" tabindex="-1"></a>proj4<span class="op">.</span><span class="fu">defs</span>(<span class="st">&#39;EPSG:5186&#39;</span><span class="op">,</span></span>
<span id="cb1-2"><a href="#cb1-2" aria-hidden="true" tabindex="-1"></a> <span class="st">&#39;+proj=tmerc +lat_0=38 +lon_0=127 +k=1 +x_0=200000 +y_0=600000 +ellps=GRS80 +units=m +no_defs&#39;</span>)<span class="op">;</span></span>
<span id="cb1-3"><a href="#cb1-3" aria-hidden="true" tabindex="-1"></a>const _toTM <span class="op">=</span> <span class="fu">proj4</span>(<span class="st">&#39;EPSG:4326&#39;</span><span class="op">,</span> <span class="st">&#39;EPSG:5186&#39;</span>)<span class="op">;</span></span>
<span id="cb1-4"><a href="#cb1-4" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb1-5"><a href="#cb1-5" aria-hidden="true" tabindex="-1"></a><span class="kw">function</span> <span class="fu">latLonToTM</span>(lat<span class="op">,</span> lon) { <span class="co">// 위경도(각도) → TM(미터)</span></span>
<span id="cb1-6"><a href="#cb1-6" aria-hidden="true" tabindex="-1"></a> 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> <span class="co">// 주의: proj4 는 [lon, lat] 순서</span></span>
<span id="cb1-7"><a href="#cb1-7" aria-hidden="true" tabindex="-1"></a> return [e<span class="op">,</span> n]<span class="op">;</span></span>
<span id="cb1-8"><a href="#cb1-8" aria-hidden="true" tabindex="-1"></a>}</span></code></pre></div>
<ul>
<li><strong>무엇:</strong> WGS84 위경도(<code>EPSG:4326</code>) → 한국 TM(<code>EPSG:5186</code>, 중부원점) 미터 좌표로 변환.</li>
<li><strong>왜:</strong> 각도로는 거리를 못 재므로, 이후 모든 계산을 미터로 하기 위한 출발점.</li>
<li><strong>역방향:</strong> <code>_toTM.inverse([E,N])</code> → 다시 위경도 (드래그 보정 <code>groundPointFromPixel</code> 등에서 사용).</li>
</ul>
<hr />
<h2 id="2-드론-기준-상대-위치-enu-좌표">2. 드론 기준 상대 위치 (ENU 좌표)</h2>
<p><strong>위치:</strong> <a href="../client/src/utils/geoProjection.ts#L255-L273">geoProjection.ts:255-273 <code>buildRelEnu</code></a></p>
<div class="sourceCode" id="cb2"><pre class="sourceCode ts"><code class="sourceCode typescript"><span id="cb2-1"><a href="#cb2-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> geoidOffset<span class="op">,</span> <span class="op">...</span>)<span class="op">;</span> <span class="co">// 터널(대상)</span></span>
<span id="cb2-2"><a href="#cb2-2" aria-hidden="true" tabindex="-1"></a>const drEnu <span class="op">=</span> <span class="fu">geoToEnu</span>(camera<span class="op">.</span><span class="at">lat</span><span class="op">,</span> camera<span class="op">.</span><span class="at">lon</span><span class="op">,</span> camera<span class="op">.</span><span class="at">altitude</span><span class="op">,</span> <span class="op">...</span>)<span class="op">;</span> <span class="co">// 드론(카메라)</span></span>
<span id="cb2-3"><a href="#cb2-3" 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 class="co">// 위치 미세보정</span></span>
<span id="cb2-4"><a href="#cb2-4" 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 class="co">// 빼기</span></span>
<span id="cb2-5"><a href="#cb2-5" aria-hidden="true" tabindex="-1"></a>const dist <span class="op">=</span> <span class="bu">Math</span><span class="op">.</span><span class="fu">hypot</span>(relEnu[<span class="dv">0</span>]<span class="op">,</span> relEnu[<span class="dv">1</span>])<span class="op">;</span> <span class="co">// 수평거리(m)</span></span></code></pre></div>
<ul>
<li><strong>무엇:</strong> 대상과 드론을 미터 좌표로 놓고 빼서 &quot;드론 기준 동/북/상 몇 m&quot;(상대 벡터) 산출.</li>
<li><strong>왜:</strong> 카메라에서 본 방향 계산의 입력. (<code>geoToEnu</code><a href="../client/src/utils/geoProjection.ts#L91-L97">geoProjection.ts:91-97</a>)</li>
<li><strong><code>geoidOffset</code>:</strong> 대상 정표고(EL)+지오이드고 → 타원체고. 드론 GPS 고도(타원체고)와 기준을 맞춤(대전≈25.8m).</li>
</ul>
<hr />
<h2 id="3-카메라-기울기--회전행렬-rotation-matrix">3. 카메라 기울기 = 회전행렬 (Rotation Matrix)</h2>
<p><strong>위치:</strong> <a href="../client/src/utils/geoProjection.ts#L275-L295">geoProjection.ts:275-295</a> (<code>buildRotation</code> + <code>applyRw2c</code>)</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><span class="kw">function</span> <span class="fu">buildRotation</span>(camera<span class="op">,</span> params) { <span class="co">// yaw·pitch·roll → 3×3 행렬</span></span>
<span id="cb3-2"><a href="#cb3-2" aria-hidden="true" tabindex="-1"></a> const yaw <span class="op">=</span> <span class="fu">toRad</span>(camera<span class="op">.</span><span class="at">yaw</span> <span class="op">+</span> params<span class="op">.</span><span class="at">yawOffset</span>)<span class="op">;</span></span>
<span id="cb3-3"><a href="#cb3-3" aria-hidden="true" tabindex="-1"></a> const pitch <span class="op">=</span> <span class="fu">toRad</span>(camera<span class="op">.</span><span class="at">pitch</span> <span class="op">+</span> params<span class="op">.</span><span class="at">pitch</span>)<span class="op">;</span></span>
<span id="cb3-4"><a href="#cb3-4" aria-hidden="true" tabindex="-1"></a> const roll <span class="op">=</span> <span class="fu">toRad</span>(camera<span class="op">.</span><span class="at">roll</span> <span class="op">+</span> params<span class="op">.</span><span class="at">roll</span>)<span class="op">;</span></span>
<span id="cb3-5"><a href="#cb3-5" aria-hidden="true" tabindex="-1"></a> const cy<span class="op">=</span><span class="fu">cos</span>(yaw)<span class="op">,</span> sy<span class="op">=</span><span class="fu">sin</span>(yaw)<span class="op">,</span> cp<span class="op">=</span><span class="fu">cos</span>(pitch)<span class="op">,</span> sp<span class="op">=</span><span class="fu">sin</span>(pitch)<span class="op">,</span> cr<span class="op">=</span><span class="fu">cos</span>(roll)<span class="op">,</span> sr<span class="op">=</span><span class="fu">sin</span>(roll)<span class="op">;</span></span>
<span id="cb3-6"><a href="#cb3-6" aria-hidden="true" tabindex="-1"></a> return [[cy<span class="op">*</span>cr<span class="op">+</span>sy<span class="op">*</span>sp<span class="op">*</span>sr<span class="op">,</span> sy<span class="op">*</span>cp<span class="op">,</span> cy<span class="op">*</span>sr<span class="op">-</span>sy<span class="op">*</span>sp<span class="op">*</span>cr]<span class="op">,</span> <span class="op">...</span>]<span class="op">;</span> <span class="co">// R_b2w</span></span>
<span id="cb3-7"><a href="#cb3-7" aria-hidden="true" tabindex="-1"></a>}</span>
<span id="cb3-8"><a href="#cb3-8" aria-hidden="true" tabindex="-1"></a><span class="kw">function</span> <span class="fu">applyRw2c</span>(b2w<span class="op">,</span> rel) { <span class="co">// R_w2c · rel → 카메라 좌표</span></span>
<span id="cb3-9"><a href="#cb3-9" aria-hidden="true" tabindex="-1"></a> return { 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>
<span id="cb3-10"><a href="#cb3-10" 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>)<span class="op">,</span> 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> }<span class="op">;</span></span>
<span id="cb3-11"><a href="#cb3-11" aria-hidden="true" tabindex="-1"></a>}</span></code></pre></div>
<ul>
<li><strong>무엇:</strong> yaw(좌우)·pitch(상하)·roll(갸웃)을 하나의 3×3 행렬로 만들어, 상대벡터를 <strong>카메라 시점 좌표 <code>(Xc,Yc,Zc)</code></strong> 로 한 번에 회전.</li>
<li><strong>왜:</strong> 세 회전을 개별 적용하지 않고 행렬 한 번 곱으로 정확·간결하게. (문서의 &quot;만능 양념장&quot;)</li>
<li><strong>원본과 동일:</strong> <code>R_w2c = R_align · R_b2wᵀ</code> (파이썬 <code>advanced_tuner_v2.py</code> 이식 — 파일 상단 주석 <a href="../client/src/utils/geoProjection.ts#L1-L12">geoProjection.ts:1-12</a>).</li>
<li><strong>묶음 함수:</strong> ②+③을 한 번에 = <a href="../client/src/utils/geoProjection.ts#L301-L317"><code>toCameraCoords</code>(301-317)</a>. <code>distH/fwd/side</code>(거리필터용)도 여기서 채움.</li>
</ul>
<hr />
<h2 id="4-바늘구멍-사진기--핀홀-투영-초점거리">4. 바늘구멍 사진기 = 핀홀 투영 (초점거리)</h2>
<p><strong>위치:</strong> <a href="../client/src/utils/geoProjection.ts#L126-L137">geoProjection.ts:126-137 <code>pixelFromCamera</code></a></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>export <span class="kw">function</span> <span class="fu">pixelFromCamera</span>(cc<span class="op">,</span> params) {</span>
<span id="cb4-2"><a href="#cb4-2" aria-hidden="true" tabindex="-1"></a> const f <span class="op">=</span> params<span class="op">.</span><span class="at">focalLen</span><span class="op">,</span> sW <span class="op">=</span> params<span class="op">.</span><span class="at">sensorW</span> <span class="op">??</span> <span class="dv">36</span><span class="op">,</span> sH <span class="op">=</span> params<span class="op">.</span><span class="at">sensorH</span> <span class="op">??</span> <span class="fl">20.25</span><span class="op">;</span></span>
<span id="cb4-3"><a href="#cb4-3" aria-hidden="true" tabindex="-1"></a> return {</span>
<span id="cb4-4"><a href="#cb4-4" aria-hidden="true" tabindex="-1"></a> pxRaw<span class="op">:</span> (<span class="fl">0.5</span> <span class="op">+</span> params<span class="op">.</span><span class="at">cx0</span>) <span class="op">+</span> (cc<span class="op">.</span><span class="at">Xc</span> <span class="op">/</span> cc<span class="op">.</span><span class="at">Zc</span>) <span class="op">*</span> (f <span class="op">/</span> sW)<span class="op">,</span> <span class="co">// 가로 0~1</span></span>
<span id="cb4-5"><a href="#cb4-5" aria-hidden="true" tabindex="-1"></a> pyRaw<span class="op">:</span> (<span class="fl">0.5</span> <span class="op">+</span> params<span class="op">.</span><span class="at">cy0</span>) <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> (f <span class="op">/</span> sH)<span class="op">,</span> <span class="co">// 세로 0~1</span></span>
<span id="cb4-6"><a href="#cb4-6" aria-hidden="true" tabindex="-1"></a> }<span class="op">;</span></span>
<span id="cb4-7"><a href="#cb4-7" aria-hidden="true" tabindex="-1"></a>}</span></code></pre></div>
<ul>
<li><strong>무엇:</strong> 카메라 좌표 → 화면 정규좌표(0~1). 문서의 &quot;창문 스티커&quot; 계산이 이 두 줄.</li>
<li><strong>원근:</strong> <code>Xc/Zc</code>, <code>Yc/Zc</code> — 앞쪽 거리 <code>Zc</code>로 나누므로 <strong>멀수록 가운데로 작게</strong>.</li>
<li><strong>초점거리:</strong> <code>f/sW</code>, <code>f/sH</code><code>f</code>(focalLen)가 클수록(망원) 크게. <code>sensorH=20.25</code>는 16:9 기준(=36×9/16).</li>
<li><strong>참고:</strong> 원스톱 함수 <a href="../client/src/utils/geoProjection.ts#L334-L427"><code>projectPoint</code>(334-427)</a>는 ①~④+FOV판정까지 한 번에 수행(디버그/단건용). 실사용 렌더는 <code>toCameraCoords</code>+<code>pixelFromCamera</code>를 프레임마다 호출.</li>
</ul>
<hr />
<h2 id="5-30장-사이-부드럽게--보간-interpolation">5. 30장 사이 부드럽게 = 보간 (Interpolation)</h2>
<p><strong>위치:</strong> <a href="../client/src/components/overlay/StationOverlay.tsx#L689">StationOverlay.tsx:689-712 <code>poseAt</code></a></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>const poseAt <span class="op">=</span> (estFrame) <span class="kw">=&gt;</span> { <span class="co">// 연속 프레임번호 → 드론 포즈</span></span>
<span id="cb5-2"><a href="#cb5-2" aria-hidden="true" tabindex="-1"></a> const frac <span class="op">=</span> (estFrame <span class="op">-</span> f1) <span class="op">/</span> (f2 <span class="op">-</span> f1)<span class="op">;</span> <span class="co">// 두 실제 프레임 사이 위치(0~1)</span></span>
<span id="cb5-3"><a href="#cb5-3" aria-hidden="true" tabindex="-1"></a> const L <span class="op">=</span> (x<span class="op">,</span> y) <span class="kw">=&gt;</span> x <span class="op">+</span> (y <span class="op">-</span> x) <span class="op">*</span> frac<span class="op">;</span> <span class="co">// 선형보간</span></span>
<span id="cb5-4"><a href="#cb5-4" 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> <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="cb5-5"><a href="#cb5-5" aria-hidden="true" tabindex="-1"></a> return { <span class="op">...</span>a<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> lon<span class="op">:</span> <span class="fu">L</span>(a<span class="op">.</span><span class="at">lon</span><span class="op">,</span>b<span class="op">.</span><span class="at">lon</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 class="op">...</span> }<span class="op">;</span></span>
<span id="cb5-6"><a href="#cb5-6" aria-hidden="true" tabindex="-1"></a>}<span class="op">;</span></span></code></pre></div>
<ul>
<li><strong>무엇:</strong> 정수 프레임(사진 30장) 사이의 <strong>중간 드론 위치·자세</strong>를 계산.</li>
<li><strong>왜:</strong> 이름표가 30번이 아니라 화면 갱신(≈60번)마다 매끈하게 이동.</li>
<li><strong>입력:</strong> 현재 시각 → 연속 프레임번호 <code>estFrame = estTime * fpsRef.current</code> (<a href="../client/src/components/overlay/StationOverlay.tsx#L974">StationOverlay.tsx:974</a>). <code>fpsRef</code>는 §8에서 주입.</li>
</ul>
<hr />
<h2 id="6-흔들림-제거--이동평균--ema-평활">6. 흔들림 제거 = 이동평균 + EMA 평활</h2>
<p><strong>위치 A — 원본 데이터 평균:</strong> <a href="../client/src/components/overlay/StationOverlay.tsx#L596">StationOverlay.tsx:596-620 <code>smoothFrame</code></a></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><span class="co">// 중심 프레임 기준 ±halfWin 프레임의 GPS·자세를 평균 (회전 경계는 보존)</span></span>
<span id="cb6-2"><a href="#cb6-2" aria-hidden="true" tabindex="-1"></a>lat <span class="op">=</span> Σlat<span class="op">/</span>n<span class="op">;</span> yaw <span class="op">=</span> <span class="fu">atan2</span>(Σsin<span class="op">,</span> Σcos)<span class="op">;</span> <span class="co">// 각도는 sin/cos 평균 후 atan2</span></span></code></pre></div>
<p><strong>위치 B — 화면 위치 평활:</strong> <a href="../client/src/components/overlay/StationOverlay.tsx#L48">StationOverlay.tsx:48-61 <code>smoothStep</code></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><span class="fu">if</span> (d <span class="op">&gt;</span> REJECT_DIST <span class="op">&amp;&amp;</span> prev<span class="op">.</span><span class="at">rej</span> <span class="op">&lt;</span> MAX) return {유지}<span class="op">;</span> <span class="co">// 튀는 값(이상치) 무시</span></span>
<span id="cb7-2"><a href="#cb7-2" aria-hidden="true" tabindex="-1"></a>const speed <span class="op">=</span> <span class="fu">hypot</span>(vx<span class="op">,</span> vy)<span class="op">;</span> <span class="co">// 평활된 이동속도</span></span>
<span id="cb7-3"><a href="#cb7-3" aria-hidden="true" tabindex="-1"></a>const a <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="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="cb7-4"><a href="#cb7-4" 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 class="co">// 속도적응 EMA</span></span></code></pre></div>
<ul>
<li><strong>무엇:</strong> (A) GPS·자세 노이즈를 프레임 평균으로 줄이고, (B) 화면 좌표를 속도적응 EMA로 부드럽게 + 이상치 거부.</li>
<li><strong>왜:</strong> 드론 떨림에도 이름표가 안정적으로 붙어 있게. (느릴 땐 강하게 평활, 빠를 땐 즉시 추종)</li>
</ul>
<hr />
<h2 id="7-매-프레임-렌더--requestanimationframe--canvas-2d">7. 매 프레임 렌더 = requestAnimationFrame + Canvas 2D</h2>
<p><strong>위치:</strong> <a href="../client/src/components/overlay/StationOverlay.tsx#L931">StationOverlay.tsx:931-1243 <code>draw</code> 루프</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 draw <span class="op">=</span> () <span class="kw">=&gt;</span> {</span>
<span id="cb8-2"><a href="#cb8-2" aria-hidden="true" tabindex="-1"></a> rafId <span class="op">=</span> <span class="fu">requestAnimationFrame</span>(draw)<span class="op">;</span> <span class="co">// 화면 갱신마다 반복(≈60/s)</span></span>
<span id="cb8-3"><a href="#cb8-3" aria-hidden="true" tabindex="-1"></a> const dronePose <span class="op">=</span> <span class="fu">poseAt</span>(estFrame)<span class="op">;</span> <span class="co">// §5 보간 포즈</span></span>
<span id="cb8-4"><a href="#cb8-4" aria-hidden="true" tabindex="-1"></a> <span class="co">// POI 마다:</span></span>
<span id="cb8-5"><a href="#cb8-5" aria-hidden="true" tabindex="-1"></a> const cc <span class="op">=</span> <span class="fu">toCameraCoords</span>(dronePose<span class="op">,</span> poiA<span class="op">.</span><span class="at">lat</span><span class="op">,</span> poiA<span class="op">.</span><span class="at">lon</span><span class="op">,</span> pz<span class="op">,</span> params<span class="op">,</span> worldOrigin)<span class="op">;</span> <span class="co">// §2+§3</span></span>
<span id="cb8-6"><a href="#cb8-6" aria-hidden="true" tabindex="-1"></a> const { pxRaw<span class="op">,</span> pyRaw } <span class="op">=</span> <span class="fu">pixelFromCamera</span>(cc<span class="op">,</span> params)<span class="op">;</span> <span class="co">// §4</span></span>
<span id="cb8-7"><a href="#cb8-7" aria-hidden="true" tabindex="-1"></a> const d <span class="op">=</span> <span class="fu">smoothStep</span>(prevDpoi<span class="op">,</span> pxRaw<span class="op">,</span> pyRaw<span class="op">,</span> <span class="op">...</span>)<span class="op">;</span> <span class="co">// §6 화면 평활</span></span>
<span id="cb8-8"><a href="#cb8-8" aria-hidden="true" tabindex="-1"></a> ctx<span class="op">.</span><span class="fu">strokeText</span>(label<span class="op">,</span> lx<span class="op">,</span> labelY)<span class="op">;</span> ctx<span class="op">.</span><span class="fu">fillText</span>(<span class="op">...</span>)<span class="op">;</span> <span class="co">// Canvas 에 이름표 그림</span></span>
<span id="cb8-9"><a href="#cb8-9" aria-hidden="true" tabindex="-1"></a>}<span class="op">;</span></span></code></pre></div>
<ul>
<li><strong>무엇:</strong> 화면이 새로 그려질 때마다 §2~§6을 다시 계산해 <strong>Canvas 2D</strong>에 이름표·중심선을 그림.</li>
<li><strong>호출 지점:</strong> 측점 라벨 <a href="../client/src/components/overlay/StationOverlay.tsx#L1070-L1072">1070-1072</a>, POI/구조물 라벨 <a href="../client/src/components/overlay/StationOverlay.tsx#L1107-L1109">1107-1109</a>.</li>
<li><strong>성능:</strong> 무거운 &quot;가시집합/겹침 판정&quot;은 별도 사전계산(<code>requestIdleCallback</code>, 파일 상단 주석), RAF는 투영·그리기 위주.</li>
</ul>
<hr />
<h2 id="8-영상별-fps-자동-산출">8. 영상별 fps 자동 산출</h2>
<p><strong>위치:</strong> <a href="../client/src/components/player/VideoPlayer.tsx#L272">VideoPlayer.tsx:272-286 <code>effectiveFps</code></a></p>
<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 effectiveFps <span class="op">=</span> <span class="fu">useMemo</span>(() <span class="kw">=&gt;</span> {</span>
<span id="cb9-2"><a href="#cb9-2" aria-hidden="true" tabindex="-1"></a> <span class="fu">if</span> (<span class="op">!</span>storeFrames<span class="op">.</span><span class="at">length</span> <span class="op">||</span> <span class="op">!</span>duration) return <span class="dv">30000</span><span class="op">/</span><span class="dv">1001</span><span class="op">;</span> <span class="co">// 폴백 29.97</span></span>
<span id="cb9-3"><a href="#cb9-3" aria-hidden="true" tabindex="-1"></a> let maxF <span class="op">=</span> <span class="dv">0</span><span class="op">;</span> <span class="fu">for</span> (const f of storeFrames) <span class="fu">if</span> (f<span class="op">.</span><span class="at">frame</span> <span class="op">&gt;</span> maxF) maxF <span class="op">=</span> f<span class="op">.</span><span class="at">frame</span><span class="op">;</span></span>
<span id="cb9-4"><a href="#cb9-4" aria-hidden="true" tabindex="-1"></a> const raw <span class="op">=</span> maxF <span class="op">/</span> duration<span class="op">;</span> <span class="co">// 마지막 프레임번호 ÷ 영상길이</span></span>
<span id="cb9-5"><a href="#cb9-5" aria-hidden="true" tabindex="-1"></a> const STD <span class="op">=</span> [<span class="fl">23.976</span><span class="op">,</span><span class="dv">24</span><span class="op">,</span><span class="dv">25</span><span class="op">,</span><span class="fl">29.97</span><span class="op">,</span><span class="dv">30</span><span class="op">,</span><span class="dv">50</span><span class="op">,</span><span class="fl">59.94</span><span class="op">,</span><span class="dv">60</span>]<span class="op">;</span></span>
<span id="cb9-6"><a href="#cb9-6" aria-hidden="true" tabindex="-1"></a> let best <span class="op">=</span> STD[<span class="dv">0</span>]<span class="op">,</span> bd <span class="op">=</span> <span class="bu">Math</span><span class="op">.</span><span class="fu">abs</span>(raw<span class="op">-</span>STD[<span class="dv">0</span>])<span class="op">;</span></span>
<span id="cb9-7"><a href="#cb9-7" aria-hidden="true" tabindex="-1"></a> <span class="fu">for</span> (const s of STD) { const d <span class="op">=</span> <span class="bu">Math</span><span class="op">.</span><span class="fu">abs</span>(raw<span class="op">-</span>s)<span class="op">;</span> <span class="fu">if</span> (d<span class="op">&lt;</span>bd){bd<span class="op">=</span>d<span class="op">;</span>best<span class="op">=</span>s<span class="op">;</span>} }</span>
<span id="cb9-8"><a href="#cb9-8" aria-hidden="true" tabindex="-1"></a> return bd <span class="op">&lt;=</span> best<span class="op">*</span><span class="fl">0.1</span> <span class="op">?</span> best <span class="op">:</span> raw<span class="op">;</span> <span class="co">// 표준값 ±10%면 스냅</span></span>
<span id="cb9-9"><a href="#cb9-9" aria-hidden="true" tabindex="-1"></a>}<span class="op">,</span> [storeFrames<span class="op">,</span> duration])<span class="op">;</span></span></code></pre></div>
<ul>
<li><strong>무엇:</strong> 드론 CSV에는 시간이 없고 프레임 번호(<code>frame_cnt</code>)만 있으므로, <strong>영상 길이</strong>와 결합해 fps 산출 후 표준값에 스냅.</li>
<li><strong>연결:</strong> <code>&lt;StationOverlay fps={effectiveFps} /&gt;</code><a href="../client/src/components/overlay/StationOverlay.tsx#L260">StationOverlay.tsx:260 <code>fpsRef</code></a> → §5의 <code>estFrame</code>, 최근접 프레임 탐색의 기준.</li>
<li><strong>전제:</strong> 드론 CSV가 영상 전 구간을 덮는다고 가정(마지막 frame_cnt ≈ 영상 끝). 부분만 덮으면 스냅 실패 시 원시값 사용.</li>
</ul>
<hr />
<h2 id="부록-데이터-로딩-폴더--스토어">부록. 데이터 로딩 (폴더 → 스토어)</h2>
<p><strong>위치:</strong> <a href="../client/src/utils/geoData.ts">geoData.ts</a><a href="../client/src/store/geoStore.ts">geoStore.ts</a></p>
<ul>
<li><code>&lt;input webkitdirectory&gt;</code> 로 고른 폴더의 <code>&lt;base&gt;.csv</code>(드론), <code>_POI.csv</code>, <code>building/*.csv</code>(측점·교량·터널·구교)를 파싱.</li>
<li>드론 CSV 헤더: <code>frame_cnt,latitude,longitude,altitude,yaw,pitch,roll,focal_len</code> (<a href="../client/src/utils/geoData.ts#L139">geoData.ts:139-172</a>).</li>
<li>인코딩 자동감지(UTF-8 BOM / EUC-KR), KMZ(zip)는 <code>fflate</code> 로 해제.</li>
<li>ENU 기준원점: <code>getWorldOrigin</code> (<a href="../client/src/utils/geoData.ts#L576">geoData.ts:576</a>) → 스토어 <code>origin</code> → 투영에 <code>ref</code> 로 전달.</li>
</ul>
<hr />
<h2 id="관련-함수-빠른-색인">관련 함수 빠른 색인</h2>
<table>
<thead>
<tr class="header">
<th>함수</th>
<th>파일:줄</th>
<th>역할</th>
</tr>
</thead>
<tbody>
<tr class="odd">
<td><code>latLonToTM</code> / <code>geoToEnu</code></td>
<td>geoProjection.ts:81 / 91</td>
<td>각도→미터, ENU</td>
</tr>
<tr class="even">
<td><code>buildRelEnu</code></td>
<td>geoProjection.ts:255</td>
<td>드론기준 상대벡터</td>
</tr>
<tr class="odd">
<td><code>buildRotation</code> / <code>applyRw2c</code></td>
<td>geoProjection.ts:275 / 289</td>
<td>회전행렬·적용</td>
</tr>
<tr class="even">
<td><code>toCameraCoords</code></td>
<td>geoProjection.ts:301</td>
<td>②+③ 묶음</td>
</tr>
<tr class="odd">
<td><code>pixelFromCamera</code></td>
<td>geoProjection.ts:126</td>
<td>핀홀 투영</td>
</tr>
<tr class="even">
<td><code>projectPoint</code></td>
<td>geoProjection.ts:334</td>
<td>①~④ 원스톱(디버그)</td>
</tr>
<tr class="odd">
<td><code>worldFromPixel</code> / <code>groundPointFromPixel</code> / <code>solveZForPixelY</code></td>
<td>geoProjection.ts:149 / 218 / 194</td>
<td>역투영(드래그 보정)</td>
</tr>
<tr class="even">
<td><code>poseAt</code> / <code>smoothFrame</code> / <code>smoothStep</code></td>
<td>StationOverlay.tsx:689 / 596 / 48</td>
<td>보간·평활</td>
</tr>
<tr class="odd">
<td><code>draw</code></td>
<td>StationOverlay.tsx:931</td>
<td>RAF 렌더 루프</td>
</tr>
<tr class="even">
<td><code>effectiveFps</code></td>
<td>VideoPlayer.tsx:272</td>
<td>fps 자동 산출</td>
</tr>
</tbody>
</table>
<hr />
<h1 id="-용어-사전-도움말">📖 용어 사전 (도움말)</h1>
<blockquote>
<p>본문에 나온 전문용어를 <strong>초등학생도 이해할 수 있게</strong> 그림과 함께 풀었어요. 모르는 말이 나오면 여기만 보면 돼요. (다른 검색 필요 없음!)</p>
</blockquote>
<h2 id="-위도경도-latitude--longitude">▸ 위도·경도 (latitude / longitude)</h2>
<p>지구 위의 위치를 나타내는 <strong>두 개의 각도</strong>예요. <strong>지구 중심에서 각도기로 잰 각(도, °)</strong> 이에요.</p>
<ul>
<li><strong>위도</strong> = 적도(0도)에서 <strong>위/아래로</strong> 몇 도 (북극 90도, 남극 −90도)</li>
<li><strong>경도</strong> = 영국(0도)에서 <strong>옆으로 빙 둘러</strong> 몇 도 (동쪽으로 갈수록 커짐)</li>
</ul>
<figure class="fig">
<svg viewBox="0 0 700 220" role="img" aria-label="위도와 경도는 지구 중심에서 잰 각도">
<!-- 위도 -->
<g>
<circle cx="160" cy="110" r="80" fill="#eef6ff" stroke="#2563eb"></circle>
<line x1="80" y1="110" x2="240" y2="110" stroke="#93c5fd"></line>
<text x="250" y="113" font-size="10" fill="#1e3a8a">적도 0°</text>
<line x1="160" y1="110" x2="215" y2="52" stroke="#ef4444" stroke-width="2"></line>
<path d="M200,110 A55,55 0 0 0 190,75" fill="none" stroke="#ef4444"></path>
<text x="205" y="95" font-size="11" fill="#b91c1c" font-weight="bold">위도</text>
<circle cx="160" cy="110" r="3" fill="#111"></circle>
<text x="160" y="205" text-anchor="middle" font-size="11" fill="#1e3a8a">위도 = 위아래 각도 (중심에서 잼)</text>
</g>
<!-- 경도 -->
<g>
<circle cx="500" cy="110" r="80" fill="#f0fdf4" stroke="#16a34a"></circle>
<ellipse cx="500" cy="110" rx="30" ry="80" fill="none" stroke="#86efac"></ellipse>
<ellipse cx="500" cy="110" rx="60" ry="80" fill="none" stroke="#86efac"></ellipse>
<line x1="500" y1="110" x2="500" y2="30" stroke="#16a34a"></line>
<text x="500" y="26" text-anchor="middle" font-size="10" fill="#166534">0° (영국)</text>
<line x1="500" y1="110" x2="565" y2="65" stroke="#ea580c" stroke-width="2"></line>
<path d="M500,50 A60,60 0 0 1 548,72" fill="none" stroke="#ea580c"></path>
<text x="530" y="52" font-size="11" fill="#9a3412" font-weight="bold">경도</text>
<circle cx="500" cy="110" r="3" fill="#111"></circle>
<text x="500" y="205" text-anchor="middle" font-size="11" fill="#166534">경도 = 옆으로 도는 각도</text>
</g>
</svg>
<figcaption>둘 다 지구 &#39;중심&#39;에서 잰 각도(도). 그래서 단위가 미터가 아니라 &#39;도(°)&#39;다.</figcaption>
</figure>
<h2 id="-wgs84--epsg4326">▸ WGS84 · EPSG:4326</h2>
<ul>
<li><strong>WGS84</strong>: 전 세계 GPS가 쓰는 <strong>위도·경도 표준</strong>. &quot;지구를 이런 모양·기준으로 본다&quot;는 세계 공통 약속.</li>
<li><strong>EPSG</strong>: 세상의 여러 좌표계에 <strong>번호표를 붙여 정리한 목록</strong> (도서관 책번호 같은 것).</li>
<li><strong>EPSG:4326</strong> = 그 목록에서 <strong>WGS84(위도·경도)</strong> 에 붙은 번호. 코드에서 <code>&#39;EPSG:4326&#39;</code> 이라 쓰면 &quot;위경도 방식&quot;이란 뜻.</li>
</ul>
<h2 id="-epsg5186--한국-tm-횡축-메르카토르">▸ EPSG:5186 · 한국 TM (횡축 메르카토르)</h2>
<ul>
<li>우리나라 전용 <strong>평평한 미터 지도</strong> 좌표계. 위경도(각도)를 <strong>미터(거리)</strong> 로 바꾼 결과가 이 좌표.</li>
<li><strong>TM = Transverse Mercator(횡축 메르카토르)</strong>: 둥근 지구에 <strong>원통을 옆으로 눕혀 씌워</strong> 펴는 방법. 원통이 닿는 <strong>세로선(경도) 근처가 가장 정확</strong><strong>남북으로 길쭉한 한국에 딱</strong>.</li>
</ul>
<figure class="fig">
<svg viewBox="0 0 700 190" role="img" aria-label="TM 도법 - 원통을 눕혀 씌우기">
<text x="130" y="24" text-anchor="middle" font-size="11" fill="#475569" font-weight="bold">보통 메르카토르</text>
<ellipse cx="130" cy="105" rx="45" ry="70" fill="none" stroke="#94a3b8" stroke-width="2"></ellipse>
<circle cx="130" cy="105" r="42" fill="#dbeafe" stroke="#2563eb"></circle>
<line x1="88" y1="105" x2="172" y2="105" stroke="#ef4444" stroke-width="2.5"></line>
<text x="130" y="182" text-anchor="middle" font-size="10" fill="#b91c1c">가로(적도) 근처 정확</text>
<text x="350" y="105" text-anchor="middle" font-size="24" fill="#64748b">➡ 90° 눕힘 ➡</text>
<text x="560" y="24" text-anchor="middle" font-size="11" fill="#475569" font-weight="bold">TM (횡축) — 한국용</text>
<ellipse cx="560" cy="105" rx="70" ry="45" fill="none" stroke="#94a3b8" stroke-width="2"></ellipse>
<circle cx="560" cy="105" r="42" fill="#dcfce7" stroke="#16a34a"></circle>
<line x1="560" y1="63" x2="560" y2="147" stroke="#ef4444" stroke-width="2.5"></line>
<text x="560" y="182" text-anchor="middle" font-size="10" fill="#b91c1c">세로(경도127°) 근처 정확</text>
</svg>
<figcaption>원통이 &#39;닿는 빨간 선&#39; 근처가 가장 정확하다. 한국은 세로로 길어서 세로선에 맞추는 TM을 쓴다.</figcaption>
</figure>
<p><strong>코드의 설정 쪽지 뜻</strong> (<code>+proj=tmerc +lat_0=38 +lon_0=127 +k=1 +x_0=200000 +y_0=600000 +ellps=GRS80</code>):</p>
<table>
<thead>
<tr class="header">
<th>설정</th>
<th></th>
</tr>
</thead>
<tbody>
<tr class="odd">
<td><code>proj=tmerc</code></td>
<td>TM(횡축 메르카토르) 방식</td>
</tr>
<tr class="even">
<td><code>lat_0=38</code> <code>lon_0=127</code></td>
<td>지도 <strong>기준점</strong> = 위도38·경도127 (한국 한가운데)</td>
</tr>
<tr class="odd">
<td><code>k=1</code></td>
<td>크기 <strong>1배</strong>(줄임/늘림 없음)</td>
</tr>
<tr class="even">
<td><code>x_0=200000</code> <code>y_0=600000</code></td>
<td><strong>false easting/northing</strong> (아래 항목 참고)</td>
</tr>
<tr class="odd">
<td><code>ellps=GRS80</code></td>
<td>지구를 <strong>GRS80</strong>(살짝 찌그러진 귤 모양)으로 봄</td>
</tr>
<tr class="even">
<td><code>units=m</code></td>
<td>단위 = 미터</td>
</tr>
</tbody>
</table>
<h2 id="-false-easting--false-northing-x_0-y_0">▸ false easting / false northing (x_0, y_0)</h2>
<p>기준점에서 서쪽·남쪽으로 가면 좌표가 <strong>마이너스()</strong> 가 돼요. 계산이 헷갈리니까, <strong>처음부터 큰 수를 더해</strong> 어디서나 <strong>플러스(+)</strong> 가 되게 해요. (한국은 가로 +20만, 세로 +60만 m)</p>
<figure class="fig">
<svg viewBox="0 0 700 110" role="img" aria-label="false easting - 마이너스를 없애려 큰 수 더하기">
<line x1="40" y1="45" x2="660" y2="45" stroke="#94a3b8" stroke-width="2"></line>
<line x1="200" y1="35" x2="200" y2="55" stroke="#111"></line>
<text x="200" y="28" text-anchor="middle" font-size="10" fill="#111">기준점 0</text>
<text x="110" y="72" text-anchor="middle" font-size="11" fill="#b91c1c">서쪽 = 50000 😵</text>
<text x="320" y="72" text-anchor="middle" font-size="11" fill="#166534">동쪽 = +30000</text>
<text x="350" y="95" text-anchor="middle" font-size="11" fill="#3730a3">+20만을 더하면 → 서쪽도 150000 (전부 +) 😎</text>
</svg>
<figcaption>온도에 273을 더해 &#39;절대온도(K)&#39;로 음수를 없애는 것과 같은 아이디어.</figcaption>
</figure>
<h2 id="-높이-3형제--정표고--지오이드고--타원체고-geoidoffset">▸ 높이 3형제 — 정표고 · 지오이드고 · 타원체고 (geoidOffset)</h2>
<p>높이를 재는 <strong>기준면이 3가지</strong>라서 서로 변환이 필요해요. 드론 GPS 높이와 지도 높이의 <strong>기준을 맞추려고</strong> <code>geoidOffset</code>(대전≈25.8m)을 더해요.</p>
<figure class="fig">
<svg viewBox="0 0 700 200" role="img" aria-label="정표고 지오이드고 타원체고">
<line x1="60" y1="60" x2="640" y2="60" stroke="#ca8a04" stroke-width="2" stroke-dasharray="6 4"></line>
<text x="648" y="63" font-size="10" fill="#854d0e">타원체(GPS 기준, 매끈한 계산용 면)</text>
<path d="M60,110 q145,-22 290,0 t290,0" fill="none" stroke="#2563eb" stroke-width="2"></path>
<text x="648" y="113" font-size="10" fill="#1e3a8a">지오이드(평균 해수면)</text>
<path d="M60,150 q80,-30 160,-8 q120,26 220,-14 q140,-40 340,10" fill="none" stroke="#16a34a" stroke-width="2.5"></path>
<text x="120" y="175" font-size="10" fill="#166534">실제 땅(산·건물)</text>
<line x1="330" y1="60" x2="330" y2="102" stroke="#a16207" stroke-width="1.5"></line>
<text x="336" y="88" font-size="10" fill="#a16207">지오이드고(≈25.8m)</text>
<line x1="330" y1="110" x2="330" y2="140" stroke="#15803d" stroke-width="1.5"></line>
<text x="336" y="130" font-size="10" fill="#15803d">정표고(지도 높이)</text>
</svg>
<figcaption>정표고(우리가 아는 &#39;해발 높이&#39;) + 지오이드고 = 타원체고(GPS 높이). 코드는 이 변환으로 기준을 맞춘다.</figcaption>
</figure>
<h2 id="-enu-좌표-east-north-up-동북상">▸ ENU 좌표 (East-North-Up, 동·북·상)</h2>
<p>한 점(기준원점)을 중심으로 <strong>동쪽(E)·북쪽(N)·위(U)</strong> 세 방향으로 &quot;몇 m&quot;인지 나타내는 방식. 드론을 원점에 놓으면 터널이 &quot;동 +50, 북 +200, 위 80&quot; 처럼 표현돼요(본문 ②).</p>
<figure class="fig">
<svg viewBox="0 0 700 180" role="img" aria-label="ENU 동북상 좌표축">
<defs><marker id="ax" markerWidth="10" markerHeight="10" refX="7" refY="3.5" orient="auto"><path d="M0,0 L7,3.5 L0,7 Z" fill="#334155"></path></marker></defs>
<circle cx="300" cy="120" r="4" fill="#111"></circle>
<text x="300" y="150" text-anchor="middle" font-size="10" fill="#475569">드론(원점 0,0,0)</text>
<line x1="300" y1="120" x2="470" y2="120" stroke="#334155" stroke-width="2" marker-end="url(#ax)"></line>
<text x="480" y="124" font-size="11" fill="#b45309" font-weight="bold">E 동쪽</text>
<line x1="300" y1="120" x2="300" y2="30" stroke="#334155" stroke-width="2" marker-end="url(#ax)"></line>
<text x="285" y="26" font-size="11" fill="#15803d" font-weight="bold">U 위</text>
<line x1="300" y1="120" x2="210" y2="70" stroke="#334155" stroke-width="2" marker-end="url(#ax)"></line>
<text x="150" y="66" font-size="11" fill="#1e3a8a" font-weight="bold">N 북쪽</text>
<text x="430" y="70" font-size="16">🚇</text>
<text x="430" y="90" font-size="10" fill="#166534">터널: 동+50, 북+200, 위−80</text>
</svg>
<figcaption>기준원점(getWorldOrigin)에서 동·북·위로 잰 미터 좌표. 빼기로 &#39;드론 기준 상대위치&#39;를 만든다.</figcaption>
</figure>
<h2 id="-yaw--pitch--roll-카메라가-기운-3방향">▸ yaw · pitch · roll (카메라가 기운 3방향)</h2>
<p>카메라(또는 드론)가 향한 방향을 나타내는 <strong>3개의 회전</strong>. 사람 고개 움직임과 같아요.</p>
<figure class="fig">
<svg viewBox="0 0 700 150" role="img" aria-label="yaw pitch roll">
<g transform="translate(120,75)"><text x="0" y="-38" text-anchor="middle" font-size="26">🙂</text>
<path d="M-42,8 a42,16 0 0 0 84,0" fill="none" stroke="#2563eb" stroke-width="3"></path><polygon points="42,8 35,2 35,15" fill="#2563eb"></polygon>
<text x="0" y="40" text-anchor="middle" font-size="12" font-weight="bold" fill="#2563eb">yaw</text><text x="0" y="57" text-anchor="middle" font-size="10" fill="#64748b">좌우(도리도리)</text></g>
<g transform="translate(350,75)"><text x="0" y="-38" text-anchor="middle" font-size="26">🙂</text>
<path d="M0,-28 a16,42 0 0 0 0,56" fill="none" stroke="#16a34a" stroke-width="3"></path><polygon points="0,28 -6,21 6,21" fill="#16a34a"></polygon>
<text x="0" y="40" text-anchor="middle" font-size="12" font-weight="bold" fill="#16a34a">pitch</text><text x="0" y="57" text-anchor="middle" font-size="10" fill="#64748b">위아래(끄덕끄덕)</text></g>
<g transform="translate(580,75)"><text x="0" y="-38" text-anchor="middle" font-size="26">🙂</text>
<path d="M-28,0 a28,28 0 1 1 7,18" fill="none" stroke="#db2777" stroke-width="3"></path><polygon points="-21,18 -28,12 -13,11" fill="#db2777"></polygon>
<text x="0" y="40" text-anchor="middle" font-size="12" font-weight="bold" fill="#db2777">roll</text><text x="0" y="57" text-anchor="middle" font-size="10" fill="#64748b">갸웃(갸우뚱)</text></g>
</svg>
</figure>
<h2 id="-회전행렬-rotation-matrix">▸ 회전행렬 (Rotation Matrix)</h2>
<p>위 3개 회전(yaw·pitch·roll)을 <strong>하나의 작은 숫자표(3×3)</strong> 로 미리 합쳐 둔 것. 이 표를 위치에 <strong>한 번 곱하면</strong> 세 방향 회전이 <strong>동시에</strong> 적용돼요. (문서의 &quot;방향 돌리기 만능 양념장&quot;)</p>
<figure class="fig">
<svg viewBox="0 0 700 130" role="img" aria-label="세 회전을 한 숫자표로 합치기">
<rect x="30" y="45" width="70" height="34" rx="6" fill="#eff6ff" stroke="#2563eb"></rect><text x="65" y="67" text-anchor="middle" font-size="12" fill="#1e3a8a">yaw</text>
<text x="110" y="67" font-size="16">+</text>
<rect x="130" y="45" width="70" height="34" rx="6" fill="#f0fdf4" stroke="#16a34a"></rect><text x="165" y="67" text-anchor="middle" font-size="12" fill="#166534">pitch</text>
<text x="210" y="67" font-size="16">+</text>
<rect x="230" y="45" width="70" height="34" rx="6" fill="#fdf2f8" stroke="#db2777"></rect><text x="265" y="67" text-anchor="middle" font-size="12" fill="#9d174d">roll</text>
<text x="335" y="67" font-size="22" fill="#64748b"></text>
<rect x="380" y="30" width="120" height="66" rx="6" fill="#fffbe6" stroke="#ca8a04"></rect>
<text x="440" y="52" text-anchor="middle" font-size="10" fill="#854d0e">3×3 숫자표</text>
<text x="440" y="70" text-anchor="middle" font-size="10" fill="#854d0e">(회전행렬)</text>
<text x="440" y="86" text-anchor="middle" font-size="9" fill="#a16207">R_w2c</text>
<text x="520" y="67" font-size="16">×</text>
<rect x="540" y="45" width="120" height="34" rx="6" fill="#f1f5f9" stroke="#475569"></rect><text x="600" y="67" text-anchor="middle" font-size="10" fill="#334155">위치 → 돌린 위치</text>
</svg>
<figcaption>3번 돌릴 걸 표 1개로 합쳐 한 번에 곱한다 → 빠르고 실수 없음. 코드: buildRotation + applyRw2c.</figcaption>
</figure>
<h2 id="-카메라-좌표-xc-yc-zc">▸ 카메라 좌표 (Xc, Yc, Zc)</h2>
<p>회전까지 마친 뒤 얻는, <strong>카메라 눈 기준</strong> 좌표. <code>Zc</code>=앞쪽 거리, <code>Xc</code>=좌우, <code>Yc</code>=상하. <code>Zc</code>가 0보다 커야(앞에 있어야) 화면에 보여요. 다음 단계(핀홀)에서 이 값을 <code>Xc/Zc</code>, <code>Yc/Zc</code>로 나눠 써요.</p>
<h2 id="-핀홀-카메라-모델--초점거리--센서">▸ 핀홀 카메라 모델 · 초점거리 · 센서</h2>
<p><strong>바늘구멍 사진기</strong> 원리. 작은 구멍(렌즈)을 지나며 3D가 화면에 맺혀요. <strong>멀면 작게, 가까우면 크게</strong>(원근).</p>
<figure class="fig">
<svg viewBox="0 0 700 190" role="img" aria-label="핀홀 카메라 - 빛이 구멍 지나 거꾸로 맺힘">
<line x1="90" y1="30" x2="90" y2="150" stroke="#16a34a" stroke-width="6"></line><polygon points="90,30 84,44 96,44" fill="#16a34a"></polygon>
<text x="90" y="170" text-anchor="middle" font-size="10" fill="#166534">터널(큼)</text>
<line x1="360" y1="18" x2="360" y2="162" stroke="#94a3b8" stroke-width="6"></line><circle cx="360" cy="90" r="5" fill="#111"></circle>
<text x="360" y="180" text-anchor="middle" font-size="10" fill="#475569">바늘구멍(렌즈)</text>
<line x1="90" y1="30" x2="560" y2="138.6" stroke="#f59e0b" stroke-width="1.3"></line>
<line x1="90" y1="150" x2="560" y2="41.4" stroke="#f59e0b" stroke-width="1.3"></line>
<line x1="560" y1="41.4" x2="560" y2="138.6" stroke="#ef4444" stroke-width="5"></line><polygon points="560,138.6 554,124.6 566,124.6" fill="#ef4444"></polygon>
<text x="560" y="170" text-anchor="middle" font-size="10" fill="#b91c1c">맺힌 상(작고 거꾸로)</text>
</svg>
<figcaption>모든 빛이 구멍 한 점을 지나 X자로 교차 → 화면엔 작고 거꾸로 맺힘. &#39;초점거리(focal length)&#39;가 클수록(망원) 크게 보인다.</figcaption>
</figure>
<ul>
<li><strong>초점거리(focalLen)</strong>: 렌즈가 얼마나 &#39;당겨 찍나&#39;(망원↔︎광각). 클수록 화면에서 크게.</li>
<li><strong>센서(sensorW/H)</strong>: 카메라 필름 크기. 초점거리와 센서 비율이 화각을 정함(코드 <code>f/sW</code>, <code>f/sH</code>).</li>
</ul>
<h2 id="-정규좌표-01">▸ 정규좌표 (0~1)</h2>
<p>화면 위치를 픽셀(예: 1920) 대신 <strong>비율(0~1)</strong> 로 나타낸 것. <strong>0=왼쪽/위, 1=오른쪽/아래.</strong> 화면 크기가 달라져도 그대로 쓸 수 있어 편해요. (예: 가로 0.6 = 화면의 60% 지점)</p>
<h2 id="-보간-interpolation">▸ 보간 (Interpolation)</h2>
<p>두 값 <strong>사이의 중간값</strong>을 계산해 채우는 것. 사진이 30장뿐이어도 사이사이를 상상해 채워 <strong>부드럽게</strong> 움직여요.</p>
<figure class="fig">
<svg viewBox="0 0 700 120" role="img" aria-label="보간 - 두 점 사이 중간 채우기">
<circle cx="120" cy="80" r="7" fill="#2563eb"></circle><text x="120" y="105" text-anchor="middle" font-size="10" fill="#1e3a8a">사진1</text>
<circle cx="560" cy="40" r="7" fill="#2563eb"></circle><text x="560" y="30" text-anchor="middle" font-size="10" fill="#1e3a8a">사진2</text>
<line x1="120" y1="80" x2="560" y2="40" stroke="#cbd5e1" stroke-dasharray="4 4"></line>
<circle cx="230" cy="70" r="4" fill="#ef4444"></circle><circle cx="340" cy="60" r="4" fill="#ef4444"></circle><circle cx="450" cy="50" r="4" fill="#ef4444"></circle>
<text x="340" y="95" text-anchor="middle" font-size="10" fill="#b91c1c">← 중간을 채운 위치(보간) →</text>
</svg>
<figcaption>사진1·2 사이 몇 % 지점인지(frac) 계산해 중간 위치를 만든다. 코드: poseAt.</figcaption>
</figure>
<h2 id="-이동평균--ema-지수이동평균">▸ 이동평균 · EMA (지수이동평균)</h2>
<p>여러 값을 <strong>평균 내</strong> 들쭉날쭉(떨림)을 매끈하게 만드는 방법.</p>
<ul>
<li><strong>이동평균</strong>: 앞뒤 몇 개를 평균 (코드 <code>smoothFrame</code>)</li>
<li><strong>EMA</strong>: 최근 값에 더 무게를 둔 평균, 반응이 빠름 (코드 <code>smoothStep</code>)</li>
</ul>
<figure class="fig">
<svg viewBox="0 0 700 130" role="img" aria-label="떨리는 값 vs 평활한 값">
<polyline points="40,70 80,40 120,95 160,45 200,90 240,50 280,85 320,55 360,80" fill="none" stroke="#f87171" stroke-width="2"></polyline>
<text x="200" y="120" text-anchor="middle" font-size="10" fill="#b91c1c">평활 전: 부들부들 떨림</text>
<path d="M400,70 q80,-8 160,-6 t120,-4" fill="none" stroke="#16a34a" stroke-width="3"></path>
<text x="560" y="120" text-anchor="middle" font-size="10" fill="#166534">평활 후: 매끈</text>
<text x="378" y="66" font-size="18" fill="#64748b"></text>
</svg>
<figcaption>드론 흔들림으로 튀는 위치를 평균으로 눌러 이름표가 안 떨리게 한다.</figcaption>
</figure>
<h2 id="-requestanimationframe-raf">▸ requestAnimationFrame (RAF)</h2>
<p>브라우저에게 <strong>&quot;화면 새로 그릴 때마다 이 일을 해줘&quot;</strong> 라고 부탁하는 명령. 보통 1초에 약 60번 실행돼요. 그래서 이름표 위치를 매번 다시 계산·그리기 해 부드럽게 따라다녀요.</p>
<h2 id="-canvas-2d">▸ Canvas 2D</h2>
<p>웹페이지 안의 <strong>그림판(도화지)</strong>. 여기에 선·글자를 직접 그려요. 우리는 중심선·측점·POI 이름표를 매 프레임 이 캔버스에 그려요. (선명하고 빠름)</p>
<h2 id="-fps--프레임">▸ fps · 프레임</h2>
<ul>
<li><strong>프레임</strong> = 영상의 <strong>낱장 사진</strong> 한 장.</li>
<li><strong>fps(frames per second)</strong> = <strong>1초에 몇 장</strong> 넘기나. 예: 30fps = 1초에 30장.</li>
<li>프레임 번호를 시간으로 바꾸려면 fps가 필요해요: <code>시간 = 프레임번호 ÷ fps</code> (본문 ⑧).</li>
</ul>
<h2 id="-requestidlecallback">▸ requestIdleCallback</h2>
<p>브라우저가 <strong>한가한 틈</strong>에 무거운 일을 시키는 명령. 무거운 &quot;어떤 라벨을 보여줄지&quot; 준비 작업을 여기서 미리 해 두고, 매 프레임(RAF)에는 가벼운 그리기만 → 60번/초에도 안 버벅여요.</p>
</body>
</html>
@@ -0,0 +1,528 @@
# 드론 좌표 → 영상 픽셀 투영: 기술 ↔ 소스코드 매칭 (구현 상세)
> 발표 자료 [발표_핵심기술_드론좌표를_영상에_맞추기_쉬운설명](발표_핵심기술_드론좌표를_영상에_맞추기_쉬운설명.md) 의
> "쉬운 비유"가 **실제 어떤 코드로 구현**되었는지, 파일·줄 위치와 함께 설명하는 개발자용 문서입니다.
> 핵심 계산은 대부분 [client/src/utils/geoProjection.ts](../client/src/utils/geoProjection.ts) 한 곳에 모여 있고,
> 매 프레임 호출·평활·렌더링은 [client/src/components/overlay/StationOverlay.tsx](../client/src/components/overlay/StationOverlay.tsx) 에 있습니다.
<style>
figure.fig{margin:20px 0;text-align:center;page-break-inside:avoid;break-inside:avoid;}
figure.fig svg{width:100%;max-width:700px;height:auto;border:1px solid #e7e0d2;border-radius:10px;background:#fffdf8;}
figure.fig figcaption{font-size:0.9em;color:#777;margin-top:6px;}
text{font-family:'Malgun Gothic','Hancom Gothic',sans-serif;}
code{background:#f5f3ee;padding:1px 4px;border-radius:4px;}
</style>
---
## 0. 전체 파이프라인 (파일 기준)
<figure class="fig">
<svg viewBox="0 0 700 340" role="img" aria-label="전체 파이프라인">
<defs><marker id="a" markerWidth="11" markerHeight="11" refX="7" refY="3.5" orient="auto"><path d="M0,0 L7,3.5 L0,7 Z" fill="#7c3aed"/></marker></defs>
<rect x="200" y="12" width="300" height="40" rx="8" fill="#f1f5f9" stroke="#475569"/>
<text x="350" y="30" text-anchor="middle" font-size="12" font-weight="bold" fill="#334155">데이터 로드 (폴더 선택)</text>
<text x="350" y="45" text-anchor="middle" font-size="10" fill="#475569">geoData.ts → geoStore (드론 CSV·POI·측점·중심선)</text>
<line x1="350" y1="52" x2="350" y2="70" stroke="#7c3aed" stroke-width="2" marker-end="url(#a)"/>
<rect x="150" y="72" width="400" height="34" rx="8" fill="#eef2ff" stroke="#4f46e5"/>
<text x="350" y="94" text-anchor="middle" font-size="11" font-weight="bold" fill="#3730a3">매 프레임 루프: StationOverlay.tsx draw() @ requestAnimationFrame</text>
<line x1="350" y1="106" x2="350" y2="124" stroke="#7c3aed" stroke-width="2" marker-end="url(#a)"/>
<g font-size="11">
<rect x="120" y="126" width="460" height="30" rx="6" fill="#ecfdf5" stroke="#16a34a"/>
<text x="350" y="146" text-anchor="middle" fill="#166534">① 각도→미터 latLonToTM / geoToEnu (proj4 · EPSG:5186)</text>
<line x1="350" y1="156" x2="350" y2="170" stroke="#7c3aed" stroke-width="1.5" marker-end="url(#a)"/>
<rect x="120" y="172" width="460" height="30" rx="6" fill="#fefce8" stroke="#ca8a04"/>
<text x="350" y="192" text-anchor="middle" fill="#854d0e">② 드론기준 상대위치 buildRelEnu (빼기)</text>
<line x1="350" y1="202" x2="350" y2="216" stroke="#7c3aed" stroke-width="1.5" marker-end="url(#a)"/>
<rect x="120" y="218" width="460" height="30" rx="6" fill="#eff6ff" stroke="#2563eb"/>
<text x="350" y="238" text-anchor="middle" fill="#1e3a8a">③ 방향 회전 buildRotation + applyRw2c (toCameraCoords)</text>
<line x1="350" y1="248" x2="350" y2="262" stroke="#7c3aed" stroke-width="1.5" marker-end="url(#a)"/>
<rect x="120" y="264" width="460" height="30" rx="6" fill="#fdf2f8" stroke="#db2777"/>
<text x="350" y="284" text-anchor="middle" fill="#9d174d">④ 핀홀 투영 pixelFromCamera → (px,py)</text>
<line x1="350" y1="294" x2="350" y2="308" stroke="#7c3aed" stroke-width="1.5" marker-end="url(#a)"/>
<rect x="120" y="310" width="460" height="26" rx="6" fill="#f5f3ff" stroke="#7c3aed"/>
<text x="350" y="327" text-anchor="middle" fill="#5b21b6">⑤⑥ 보간·평활(poseAt/smoothFrame/smoothStep) → ⑦ Canvas 렌더</text>
</g>
</svg>
</figure>
> 아래 표의 **기술**과 본문의 **굵은 용어**들은 문서 맨 아래 **[📖 용어 사전(도움말)](#-용어-사전-도움말)** 에서
> 그림과 함께 자세히 풀어 놓았어요. 모르는 말이 나오면 바로 그 항목을 보면 됩니다. (다른 검색 필요 없음!)
| 단계 | 기술 | 파일 | 핵심 함수 | **하는 일 (쉽게)** |
|---|---|---|---|---|
| ① 위경도→미터 | proj4 · EPSG:5186 | geoProjection.ts | `latLonToTM`, `geoToEnu` | GPS **각도**(위도·경도)를 계산하기 쉬운 **미터 지도**로 바꾼다 |
| ② 드론기준 위치 | ENU 상대좌표 | geoProjection.ts | `buildRelEnu` | 터널−드론 좌표를 **빼서** "드론에서 동/북/위로 몇 m"인지 구한다 |
| ③ 방향 회전 | 회전행렬 | geoProjection.ts | `buildRotation`, `applyRw2c` | 카메라 기울기(**yaw·pitch·roll**)만큼 방향을 **한 번에 돌린다** |
| ④ 화면 투영 | 핀홀 카메라 모델 | geoProjection.ts | `pixelFromCamera` | 3D 방향을 **납작한 화면의 점(가로·세로 %)** 으로 눌러 담는다 |
| ⑤ 사이 채우기 | 보간 | StationOverlay.tsx | `poseAt` | 사진 30장 **사이의 중간 위치**를 상상해 채워 부드럽게 |
| ⑥ 흔들림 제거 | 이동평균 + EMA | StationOverlay.tsx | `smoothFrame`, `smoothStep` | 여러 값을 **평균 내** 드론 떨림·튐을 없앤다 |
| ⑦ 렌더 | RAF + Canvas 2D | StationOverlay.tsx | `draw` | 화면 새로 그릴 때마다(≈60/s) **이름표를 캔버스에 그린다** |
| ⑧ fps 자동 | 프레임수÷길이 | VideoPlayer.tsx | `effectiveFps` | 영상이 **1초에 몇 장**인지 스스로 알아내 ①~⑦의 시간 기준을 맞춘다 |
---
## 1. 위경도(각도) → 미터 지도 (proj4 · EPSG:5186)
**위치:** [geoProjection.ts:74-97](../client/src/utils/geoProjection.ts#L74-L97)
```ts
proj4.defs('EPSG:5186',
'+proj=tmerc +lat_0=38 +lon_0=127 +k=1 +x_0=200000 +y_0=600000 +ellps=GRS80 +units=m +no_defs');
const _toTM = proj4('EPSG:4326', 'EPSG:5186');
function latLonToTM(lat, lon) { // 위경도(각도) → TM(미터)
const [e, n] = _toTM.forward([lon, lat]); // 주의: proj4 는 [lon, lat] 순서
return [e, n];
}
```
- **무엇:** WGS84 위경도(`EPSG:4326`) → 한국 TM(`EPSG:5186`, 중부원점) 미터 좌표로 변환.
- **왜:** 각도로는 거리를 못 재므로, 이후 모든 계산을 미터로 하기 위한 출발점.
- **역방향:** `_toTM.inverse([E,N])` → 다시 위경도 (드래그 보정 `groundPointFromPixel` 등에서 사용).
---
## 2. 드론 기준 상대 위치 (ENU 좌표)
**위치:** [geoProjection.ts:255-273 `buildRelEnu`](../client/src/utils/geoProjection.ts#L255-L273)
```ts
const stEnu = geoToEnu(targetLat, targetLon, targetAlt + geoidOffset, ...); // 터널(대상)
const drEnu = geoToEnu(camera.lat, camera.lon, camera.altitude, ...); // 드론(카메라)
const drEnuAdj = [drEnu[0]+offX, drEnu[1]+offY, drEnu[2]+offZ]; // 위치 미세보정
const relEnu = [stEnu[0]-drEnuAdj[0], stEnu[1]-drEnuAdj[1], stEnu[2]-drEnuAdj[2]]; // 빼기
const dist = Math.hypot(relEnu[0], relEnu[1]); // 수평거리(m)
```
- **무엇:** 대상과 드론을 미터 좌표로 놓고 빼서 "드론 기준 동/북/상 몇 m"(상대 벡터) 산출.
- **왜:** 카메라에서 본 방향 계산의 입력. (`geoToEnu`는 [geoProjection.ts:91-97](../client/src/utils/geoProjection.ts#L91-L97))
- **`geoidOffset`:** 대상 정표고(EL)+지오이드고 → 타원체고. 드론 GPS 고도(타원체고)와 기준을 맞춤(대전≈25.8m).
---
## 3. 카메라 기울기 = 회전행렬 (Rotation Matrix)
**위치:** [geoProjection.ts:275-295](../client/src/utils/geoProjection.ts#L275-L295) (`buildRotation` + `applyRw2c`)
```ts
function buildRotation(camera, params) { // yaw·pitch·roll → 3×3 행렬
const yaw = toRad(camera.yaw + params.yawOffset);
const pitch = toRad(camera.pitch + params.pitch);
const roll = toRad(camera.roll + params.roll);
const cy=cos(yaw), sy=sin(yaw), cp=cos(pitch), sp=sin(pitch), cr=cos(roll), sr=sin(roll);
return [[cy*cr+sy*sp*sr, sy*cp, cy*sr-sy*sp*cr], ...]; // R_b2w
}
function applyRw2c(b2w, rel) { // R_w2c · rel → 카메라 좌표
return { Xc: b2w[0][0]*rel[0]+b2w[1][0]*rel[1]+b2w[2][0]*rel[2],
Yc: -(b2w[0][2]*rel[0]+...), Zc: b2w[0][1]*rel[0]+... };
}
```
- **무엇:** yaw(좌우)·pitch(상하)·roll(갸웃)을 하나의 3×3 행렬로 만들어, 상대벡터를 **카메라 시점 좌표 `(Xc,Yc,Zc)`** 로 한 번에 회전.
- **왜:** 세 회전을 개별 적용하지 않고 행렬 한 번 곱으로 정확·간결하게. (문서의 "만능 양념장")
- **원본과 동일:** `R_w2c = R_align · R_b2wᵀ` (파이썬 `advanced_tuner_v2.py` 이식 — 파일 상단 주석 [geoProjection.ts:1-12](../client/src/utils/geoProjection.ts#L1-L12)).
- **묶음 함수:** ②+③을 한 번에 = [`toCameraCoords`(301-317)](../client/src/utils/geoProjection.ts#L301-L317). `distH/fwd/side`(거리필터용)도 여기서 채움.
---
## 4. 바늘구멍 사진기 = 핀홀 투영 (초점거리)
**위치:** [geoProjection.ts:126-137 `pixelFromCamera`](../client/src/utils/geoProjection.ts#L126-L137)
```ts
export function pixelFromCamera(cc, params) {
const f = params.focalLen, sW = params.sensorW ?? 36, sH = params.sensorH ?? 20.25;
return {
pxRaw: (0.5 + params.cx0) + (cc.Xc / cc.Zc) * (f / sW), // 가로 0~1
pyRaw: (0.5 + params.cy0) + (cc.Yc / cc.Zc) * (f / sH), // 세로 0~1
};
}
```
- **무엇:** 카메라 좌표 → 화면 정규좌표(0~1). 문서의 "창문 스티커" 계산이 이 두 줄.
- **원근:** `Xc/Zc`, `Yc/Zc` — 앞쪽 거리 `Zc`로 나누므로 **멀수록 가운데로 작게**.
- **초점거리:** `f/sW`, `f/sH``f`(focalLen)가 클수록(망원) 크게. `sensorH=20.25`는 16:9 기준(=36×9/16).
- **참고:** 원스톱 함수 [`projectPoint`(334-427)](../client/src/utils/geoProjection.ts#L334-L427)는 ①~④+FOV판정까지 한 번에 수행(디버그/단건용). 실사용 렌더는 `toCameraCoords`+`pixelFromCamera`를 프레임마다 호출.
---
## 5. 30장 사이 부드럽게 = 보간 (Interpolation)
**위치:** [StationOverlay.tsx:689-712 `poseAt`](../client/src/components/overlay/StationOverlay.tsx#L689)
```ts
const poseAt = (estFrame) => { // 연속 프레임번호 → 드론 포즈
const frac = (estFrame - f1) / (f2 - f1); // 두 실제 프레임 사이 위치(0~1)
const L = (x, y) => x + (y - x) * frac; // 선형보간
let dy = ((b.yaw - a.yaw + 540) % 360) - 180; // 방향은 최단각으로
return { ...a, lat: L(a.lat,b.lat), lon: L(a.lon,b.lon), yaw: a.yaw + dy*frac, ... };
};
```
- **무엇:** 정수 프레임(사진 30장) 사이의 **중간 드론 위치·자세**를 계산.
- **왜:** 이름표가 30번이 아니라 화면 갱신(≈60번)마다 매끈하게 이동.
- **입력:** 현재 시각 → 연속 프레임번호 `estFrame = estTime * fpsRef.current` ([StationOverlay.tsx:974](../client/src/components/overlay/StationOverlay.tsx#L974)). `fpsRef`는 §8에서 주입.
---
## 6. 흔들림 제거 = 이동평균 + EMA 평활
**위치 A — 원본 데이터 평균:** [StationOverlay.tsx:596-620 `smoothFrame`](../client/src/components/overlay/StationOverlay.tsx#L596)
```ts
// 중심 프레임 기준 ±halfWin 프레임의 GPS·자세를 평균 (회전 경계는 보존)
lat = Σlat/n; yaw = atan2(Σsin, Σcos); // 각도는 sin/cos 평균 후 atan2
```
**위치 B — 화면 위치 평활:** [StationOverlay.tsx:48-61 `smoothStep`](../client/src/components/overlay/StationOverlay.tsx#L48)
```ts
if (d > REJECT_DIST && prev.rej < MAX) return {}; // 튀는 값(이상치) 무시
const speed = hypot(vx, vy); // 평활된 이동속도
const a = min(maxAlpha, minAlpha + (maxAlpha-minAlpha)*min(1, speed/speedRef));
return { x: prev.x + dx*a, y: prev.y + dy*a, ... }; // 속도적응 EMA
```
- **무엇:** (A) GPS·자세 노이즈를 프레임 평균으로 줄이고, (B) 화면 좌표를 속도적응 EMA로 부드럽게 + 이상치 거부.
- **왜:** 드론 떨림에도 이름표가 안정적으로 붙어 있게. (느릴 땐 강하게 평활, 빠를 땐 즉시 추종)
---
## 7. 매 프레임 렌더 = requestAnimationFrame + Canvas 2D
**위치:** [StationOverlay.tsx:931-1243 `draw` 루프](../client/src/components/overlay/StationOverlay.tsx#L931)
```ts
const draw = () => {
rafId = requestAnimationFrame(draw); // 화면 갱신마다 반복(≈60/s)
const dronePose = poseAt(estFrame); // §5 보간 포즈
// POI 마다:
const cc = toCameraCoords(dronePose, poiA.lat, poiA.lon, pz, params, worldOrigin); // §2+§3
const { pxRaw, pyRaw } = pixelFromCamera(cc, params); // §4
const d = smoothStep(prevDpoi, pxRaw, pyRaw, ...); // §6 화면 평활
ctx.strokeText(label, lx, labelY); ctx.fillText(...); // Canvas 에 이름표 그림
};
```
- **무엇:** 화면이 새로 그려질 때마다 §2~§6을 다시 계산해 **Canvas 2D**에 이름표·중심선을 그림.
- **호출 지점:** 측점 라벨 [1070-1072](../client/src/components/overlay/StationOverlay.tsx#L1070-L1072), POI/구조물 라벨 [1107-1109](../client/src/components/overlay/StationOverlay.tsx#L1107-L1109).
- **성능:** 무거운 "가시집합/겹침 판정"은 별도 사전계산(`requestIdleCallback`, 파일 상단 주석), RAF는 투영·그리기 위주.
---
## 8. 영상별 fps 자동 산출
**위치:** [VideoPlayer.tsx:272-286 `effectiveFps`](../client/src/components/player/VideoPlayer.tsx#L272)
```ts
const effectiveFps = useMemo(() => {
if (!storeFrames.length || !duration) return 30000/1001; // 폴백 29.97
let maxF = 0; for (const f of storeFrames) if (f.frame > maxF) maxF = f.frame;
const raw = maxF / duration; // 마지막 프레임번호 ÷ 영상길이
const STD = [23.976,24,25,29.97,30,50,59.94,60];
let best = STD[0], bd = Math.abs(raw-STD[0]);
for (const s of STD) { const d = Math.abs(raw-s); if (d<bd){bd=d;best=s;} }
return bd <= best*0.1 ? best : raw; // 표준값 ±10%면 스냅
}, [storeFrames, duration]);
```
- **무엇:** 드론 CSV에는 시간이 없고 프레임 번호(`frame_cnt`)만 있으므로, **영상 길이**와 결합해 fps 산출 후 표준값에 스냅.
- **연결:** `<StationOverlay fps={effectiveFps} />` → [StationOverlay.tsx:260 `fpsRef`](../client/src/components/overlay/StationOverlay.tsx#L260) → §5의 `estFrame`, 최근접 프레임 탐색의 기준.
- **전제:** 드론 CSV가 영상 전 구간을 덮는다고 가정(마지막 frame_cnt ≈ 영상 끝). 부분만 덮으면 스냅 실패 시 원시값 사용.
---
## 부록. 데이터 로딩 (폴더 → 스토어)
**위치:** [geoData.ts](../client/src/utils/geoData.ts) → [geoStore.ts](../client/src/store/geoStore.ts)
- `<input webkitdirectory>` 로 고른 폴더의 `<base>.csv`(드론), `_POI.csv`, `building/*.csv`(측점·교량·터널·구교)를 파싱.
- 드론 CSV 헤더: `frame_cnt,latitude,longitude,altitude,yaw,pitch,roll,focal_len` ([geoData.ts:139-172](../client/src/utils/geoData.ts#L139)).
- 인코딩 자동감지(UTF-8 BOM / EUC-KR), KMZ(zip)는 `fflate` 로 해제.
- ENU 기준원점: `getWorldOrigin` ([geoData.ts:576](../client/src/utils/geoData.ts#L576)) → 스토어 `origin` → 투영에 `ref` 로 전달.
---
## 관련 함수 빠른 색인
| 함수 | 파일:줄 | 역할 |
|---|---|---|
| `latLonToTM` / `geoToEnu` | geoProjection.ts:81 / 91 | 각도→미터, ENU |
| `buildRelEnu` | geoProjection.ts:255 | 드론기준 상대벡터 |
| `buildRotation` / `applyRw2c` | geoProjection.ts:275 / 289 | 회전행렬·적용 |
| `toCameraCoords` | geoProjection.ts:301 | ②+③ 묶음 |
| `pixelFromCamera` | geoProjection.ts:126 | 핀홀 투영 |
| `projectPoint` | geoProjection.ts:334 | ①~④ 원스톱(디버그) |
| `worldFromPixel` / `groundPointFromPixel` / `solveZForPixelY` | geoProjection.ts:149 / 218 / 194 | 역투영(드래그 보정) |
| `poseAt` / `smoothFrame` / `smoothStep` | StationOverlay.tsx:689 / 596 / 48 | 보간·평활 |
| `draw` | StationOverlay.tsx:931 | RAF 렌더 루프 |
| `effectiveFps` | VideoPlayer.tsx:272 | fps 자동 산출 |
---
# 📖 용어 사전 (도움말)
> 본문에 나온 전문용어를 **초등학생도 이해할 수 있게** 그림과 함께 풀었어요.
> 모르는 말이 나오면 여기만 보면 돼요. (다른 검색 필요 없음!)
## ▸ 위도·경도 (latitude / longitude)
지구 위의 위치를 나타내는 **두 개의 각도**예요. **지구 중심에서 각도기로 잰 각(도, °)** 이에요.
- **위도** = 적도(0도)에서 **위/아래로** 몇 도 (북극 90도, 남극 −90도)
- **경도** = 영국(0도)에서 **옆으로 빙 둘러** 몇 도 (동쪽으로 갈수록 커짐)
<figure class="fig">
<svg viewBox="0 0 700 220" role="img" aria-label="위도와 경도는 지구 중심에서 잰 각도">
<!-- 위도 -->
<g>
<circle cx="160" cy="110" r="80" fill="#eef6ff" stroke="#2563eb"/>
<line x1="80" y1="110" x2="240" y2="110" stroke="#93c5fd"/>
<text x="250" y="113" font-size="10" fill="#1e3a8a">적도 0°</text>
<line x1="160" y1="110" x2="215" y2="52" stroke="#ef4444" stroke-width="2"/>
<path d="M200,110 A55,55 0 0 0 190,75" fill="none" stroke="#ef4444"/>
<text x="205" y="95" font-size="11" fill="#b91c1c" font-weight="bold">위도</text>
<circle cx="160" cy="110" r="3" fill="#111"/>
<text x="160" y="205" text-anchor="middle" font-size="11" fill="#1e3a8a">위도 = 위아래 각도 (중심에서 잼)</text>
</g>
<!-- 경도 -->
<g>
<circle cx="500" cy="110" r="80" fill="#f0fdf4" stroke="#16a34a"/>
<ellipse cx="500" cy="110" rx="30" ry="80" fill="none" stroke="#86efac"/>
<ellipse cx="500" cy="110" rx="60" ry="80" fill="none" stroke="#86efac"/>
<line x1="500" y1="110" x2="500" y2="30" stroke="#16a34a"/>
<text x="500" y="26" text-anchor="middle" font-size="10" fill="#166534">0° (영국)</text>
<line x1="500" y1="110" x2="565" y2="65" stroke="#ea580c" stroke-width="2"/>
<path d="M500,50 A60,60 0 0 1 548,72" fill="none" stroke="#ea580c"/>
<text x="530" y="52" font-size="11" fill="#9a3412" font-weight="bold">경도</text>
<circle cx="500" cy="110" r="3" fill="#111"/>
<text x="500" y="205" text-anchor="middle" font-size="11" fill="#166534">경도 = 옆으로 도는 각도</text>
</g>
</svg>
<figcaption>둘 다 지구 '중심'에서 잰 각도(도). 그래서 단위가 미터가 아니라 '도(°)'다.</figcaption>
</figure>
## ▸ WGS84 · EPSG:4326
- **WGS84**: 전 세계 GPS가 쓰는 **위도·경도 표준**. "지구를 이런 모양·기준으로 본다"는 세계 공통 약속.
- **EPSG**: 세상의 여러 좌표계에 **번호표를 붙여 정리한 목록** (도서관 책번호 같은 것).
- **EPSG:4326** = 그 목록에서 **WGS84(위도·경도)** 에 붙은 번호. 코드에서 `'EPSG:4326'` 이라 쓰면 "위경도 방식"이란 뜻.
## ▸ EPSG:5186 · 한국 TM (횡축 메르카토르)
- 우리나라 전용 **평평한 미터 지도** 좌표계. 위경도(각도)를 **미터(거리)** 로 바꾼 결과가 이 좌표.
- **TM = Transverse Mercator(횡축 메르카토르)**: 둥근 지구에 **원통을 옆으로 눕혀 씌워** 펴는 방법.
원통이 닿는 **세로선(경도) 근처가 가장 정확****남북으로 길쭉한 한국에 딱**.
<figure class="fig">
<svg viewBox="0 0 700 190" role="img" aria-label="TM 도법 - 원통을 눕혀 씌우기">
<text x="130" y="24" text-anchor="middle" font-size="11" fill="#475569" font-weight="bold">보통 메르카토르</text>
<ellipse cx="130" cy="105" rx="45" ry="70" fill="none" stroke="#94a3b8" stroke-width="2"/>
<circle cx="130" cy="105" r="42" fill="#dbeafe" stroke="#2563eb"/>
<line x1="88" y1="105" x2="172" y2="105" stroke="#ef4444" stroke-width="2.5"/>
<text x="130" y="182" text-anchor="middle" font-size="10" fill="#b91c1c">가로(적도) 근처 정확</text>
<text x="350" y="105" text-anchor="middle" font-size="24" fill="#64748b">➡ 90° 눕힘 ➡</text>
<text x="560" y="24" text-anchor="middle" font-size="11" fill="#475569" font-weight="bold">TM (횡축) — 한국용</text>
<ellipse cx="560" cy="105" rx="70" ry="45" fill="none" stroke="#94a3b8" stroke-width="2"/>
<circle cx="560" cy="105" r="42" fill="#dcfce7" stroke="#16a34a"/>
<line x1="560" y1="63" x2="560" y2="147" stroke="#ef4444" stroke-width="2.5"/>
<text x="560" y="182" text-anchor="middle" font-size="10" fill="#b91c1c">세로(경도127°) 근처 정확</text>
</svg>
<figcaption>원통이 '닿는 빨간 선' 근처가 가장 정확하다. 한국은 세로로 길어서 세로선에 맞추는 TM을 쓴다.</figcaption>
</figure>
**코드의 설정 쪽지 뜻** (`+proj=tmerc +lat_0=38 +lon_0=127 +k=1 +x_0=200000 +y_0=600000 +ellps=GRS80`):
| 설정 | 뜻 |
|---|---|
| `proj=tmerc` | TM(횡축 메르카토르) 방식 |
| `lat_0=38` `lon_0=127` | 지도 **기준점** = 위도38·경도127 (한국 한가운데) |
| `k=1` | 크기 **1배**(줄임/늘림 없음) |
| `x_0=200000` `y_0=600000` | **false easting/northing** (아래 항목 참고) |
| `ellps=GRS80` | 지구를 **GRS80**(살짝 찌그러진 귤 모양)으로 봄 |
| `units=m` | 단위 = 미터 |
## ▸ false easting / false northing (x_0, y_0)
기준점에서 서쪽·남쪽으로 가면 좌표가 **마이너스()** 가 돼요. 계산이 헷갈리니까, **처음부터 큰 수를 더해**
어디서나 **플러스(+)** 가 되게 해요. (한국은 가로 +20만, 세로 +60만 m)
<figure class="fig">
<svg viewBox="0 0 700 110" role="img" aria-label="false easting - 마이너스를 없애려 큰 수 더하기">
<line x1="40" y1="45" x2="660" y2="45" stroke="#94a3b8" stroke-width="2"/>
<line x1="200" y1="35" x2="200" y2="55" stroke="#111"/>
<text x="200" y="28" text-anchor="middle" font-size="10" fill="#111">기준점 0</text>
<text x="110" y="72" text-anchor="middle" font-size="11" fill="#b91c1c">서쪽 = 50000 😵</text>
<text x="320" y="72" text-anchor="middle" font-size="11" fill="#166534">동쪽 = +30000</text>
<text x="350" y="95" text-anchor="middle" font-size="11" fill="#3730a3">+20만을 더하면 → 서쪽도 150000 (전부 +) 😎</text>
</svg>
<figcaption>온도에 273을 더해 '절대온도(K)'로 음수를 없애는 것과 같은 아이디어.</figcaption>
</figure>
## ▸ 높이 3형제 — 정표고 · 지오이드고 · 타원체고 (geoidOffset)
높이를 재는 **기준면이 3가지**라서 서로 변환이 필요해요. 드론 GPS 높이와 지도 높이의 **기준을 맞추려고**
`geoidOffset`(대전≈25.8m)을 더해요.
<figure class="fig">
<svg viewBox="0 0 700 200" role="img" aria-label="정표고 지오이드고 타원체고">
<line x1="60" y1="60" x2="640" y2="60" stroke="#ca8a04" stroke-width="2" stroke-dasharray="6 4"/>
<text x="648" y="63" font-size="10" fill="#854d0e">타원체(GPS 기준, 매끈한 계산용 면)</text>
<path d="M60,110 q145,-22 290,0 t290,0" fill="none" stroke="#2563eb" stroke-width="2"/>
<text x="648" y="113" font-size="10" fill="#1e3a8a">지오이드(평균 해수면)</text>
<path d="M60,150 q80,-30 160,-8 q120,26 220,-14 q140,-40 340,10" fill="none" stroke="#16a34a" stroke-width="2.5"/>
<text x="120" y="175" font-size="10" fill="#166534">실제 땅(산·건물)</text>
<line x1="330" y1="60" x2="330" y2="102" stroke="#a16207" stroke-width="1.5"/>
<text x="336" y="88" font-size="10" fill="#a16207">지오이드고(≈25.8m)</text>
<line x1="330" y1="110" x2="330" y2="140" stroke="#15803d" stroke-width="1.5"/>
<text x="336" y="130" font-size="10" fill="#15803d">정표고(지도 높이)</text>
</svg>
<figcaption>정표고(우리가 아는 '해발 높이') + 지오이드고 = 타원체고(GPS 높이). 코드는 이 변환으로 기준을 맞춘다.</figcaption>
</figure>
## ▸ ENU 좌표 (East-North-Up, 동·북·상)
한 점(기준원점)을 중심으로 **동쪽(E)·북쪽(N)·위(U)** 세 방향으로 "몇 m"인지 나타내는 방식.
드론을 원점에 놓으면 터널이 "동 +50, 북 +200, 위 80" 처럼 표현돼요(본문 ②).
<figure class="fig">
<svg viewBox="0 0 700 180" role="img" aria-label="ENU 동북상 좌표축">
<defs><marker id="ax" markerWidth="10" markerHeight="10" refX="7" refY="3.5" orient="auto"><path d="M0,0 L7,3.5 L0,7 Z" fill="#334155"/></marker></defs>
<circle cx="300" cy="120" r="4" fill="#111"/>
<text x="300" y="150" text-anchor="middle" font-size="10" fill="#475569">드론(원점 0,0,0)</text>
<line x1="300" y1="120" x2="470" y2="120" stroke="#334155" stroke-width="2" marker-end="url(#ax)"/>
<text x="480" y="124" font-size="11" fill="#b45309" font-weight="bold">E 동쪽</text>
<line x1="300" y1="120" x2="300" y2="30" stroke="#334155" stroke-width="2" marker-end="url(#ax)"/>
<text x="285" y="26" font-size="11" fill="#15803d" font-weight="bold">U 위</text>
<line x1="300" y1="120" x2="210" y2="70" stroke="#334155" stroke-width="2" marker-end="url(#ax)"/>
<text x="150" y="66" font-size="11" fill="#1e3a8a" font-weight="bold">N 북쪽</text>
<text x="430" y="70" font-size="16">🚇</text>
<text x="430" y="90" font-size="10" fill="#166534">터널: 동+50, 북+200, 위−80</text>
</svg>
<figcaption>기준원점(getWorldOrigin)에서 동·북·위로 잰 미터 좌표. 빼기로 '드론 기준 상대위치'를 만든다.</figcaption>
</figure>
## ▸ yaw · pitch · roll (카메라가 기운 3방향)
카메라(또는 드론)가 향한 방향을 나타내는 **3개의 회전**. 사람 고개 움직임과 같아요.
<figure class="fig">
<svg viewBox="0 0 700 150" role="img" aria-label="yaw pitch roll">
<g transform="translate(120,75)"><text x="0" y="-38" text-anchor="middle" font-size="26">🙂</text>
<path d="M-42,8 a42,16 0 0 0 84,0" fill="none" stroke="#2563eb" stroke-width="3"/><polygon points="42,8 35,2 35,15" fill="#2563eb"/>
<text x="0" y="40" text-anchor="middle" font-size="12" font-weight="bold" fill="#2563eb">yaw</text><text x="0" y="57" text-anchor="middle" font-size="10" fill="#64748b">좌우(도리도리)</text></g>
<g transform="translate(350,75)"><text x="0" y="-38" text-anchor="middle" font-size="26">🙂</text>
<path d="M0,-28 a16,42 0 0 0 0,56" fill="none" stroke="#16a34a" stroke-width="3"/><polygon points="0,28 -6,21 6,21" fill="#16a34a"/>
<text x="0" y="40" text-anchor="middle" font-size="12" font-weight="bold" fill="#16a34a">pitch</text><text x="0" y="57" text-anchor="middle" font-size="10" fill="#64748b">위아래(끄덕끄덕)</text></g>
<g transform="translate(580,75)"><text x="0" y="-38" text-anchor="middle" font-size="26">🙂</text>
<path d="M-28,0 a28,28 0 1 1 7,18" fill="none" stroke="#db2777" stroke-width="3"/><polygon points="-21,18 -28,12 -13,11" fill="#db2777"/>
<text x="0" y="40" text-anchor="middle" font-size="12" font-weight="bold" fill="#db2777">roll</text><text x="0" y="57" text-anchor="middle" font-size="10" fill="#64748b">갸웃(갸우뚱)</text></g>
</svg>
</figure>
## ▸ 회전행렬 (Rotation Matrix)
위 3개 회전(yaw·pitch·roll)을 **하나의 작은 숫자표(3×3)** 로 미리 합쳐 둔 것. 이 표를 위치에 **한 번 곱하면**
세 방향 회전이 **동시에** 적용돼요. (문서의 "방향 돌리기 만능 양념장")
<figure class="fig">
<svg viewBox="0 0 700 130" role="img" aria-label="세 회전을 한 숫자표로 합치기">
<rect x="30" y="45" width="70" height="34" rx="6" fill="#eff6ff" stroke="#2563eb"/><text x="65" y="67" text-anchor="middle" font-size="12" fill="#1e3a8a">yaw</text>
<text x="110" y="67" font-size="16">+</text>
<rect x="130" y="45" width="70" height="34" rx="6" fill="#f0fdf4" stroke="#16a34a"/><text x="165" y="67" text-anchor="middle" font-size="12" fill="#166534">pitch</text>
<text x="210" y="67" font-size="16">+</text>
<rect x="230" y="45" width="70" height="34" rx="6" fill="#fdf2f8" stroke="#db2777"/><text x="265" y="67" text-anchor="middle" font-size="12" fill="#9d174d">roll</text>
<text x="335" y="67" font-size="22" fill="#64748b">➡</text>
<rect x="380" y="30" width="120" height="66" rx="6" fill="#fffbe6" stroke="#ca8a04"/>
<text x="440" y="52" text-anchor="middle" font-size="10" fill="#854d0e">3×3 숫자표</text>
<text x="440" y="70" text-anchor="middle" font-size="10" fill="#854d0e">(회전행렬)</text>
<text x="440" y="86" text-anchor="middle" font-size="9" fill="#a16207">R_w2c</text>
<text x="520" y="67" font-size="16">×</text>
<rect x="540" y="45" width="120" height="34" rx="6" fill="#f1f5f9" stroke="#475569"/><text x="600" y="67" text-anchor="middle" font-size="10" fill="#334155">위치 → 돌린 위치</text>
</svg>
<figcaption>3번 돌릴 걸 표 1개로 합쳐 한 번에 곱한다 → 빠르고 실수 없음. 코드: buildRotation + applyRw2c.</figcaption>
</figure>
## ▸ 카메라 좌표 (Xc, Yc, Zc)
회전까지 마친 뒤 얻는, **카메라 눈 기준** 좌표. `Zc`=앞쪽 거리, `Xc`=좌우, `Yc`=상하.
`Zc`가 0보다 커야(앞에 있어야) 화면에 보여요. 다음 단계(핀홀)에서 이 값을 `Xc/Zc`, `Yc/Zc`로 나눠 써요.
## ▸ 핀홀 카메라 모델 · 초점거리 · 센서
**바늘구멍 사진기** 원리. 작은 구멍(렌즈)을 지나며 3D가 화면에 맺혀요. **멀면 작게, 가까우면 크게**(원근).
<figure class="fig">
<svg viewBox="0 0 700 190" role="img" aria-label="핀홀 카메라 - 빛이 구멍 지나 거꾸로 맺힘">
<line x1="90" y1="30" x2="90" y2="150" stroke="#16a34a" stroke-width="6"/><polygon points="90,30 84,44 96,44" fill="#16a34a"/>
<text x="90" y="170" text-anchor="middle" font-size="10" fill="#166534">터널(큼)</text>
<line x1="360" y1="18" x2="360" y2="162" stroke="#94a3b8" stroke-width="6"/><circle cx="360" cy="90" r="5" fill="#111"/>
<text x="360" y="180" text-anchor="middle" font-size="10" fill="#475569">바늘구멍(렌즈)</text>
<line x1="90" y1="30" x2="560" y2="138.6" stroke="#f59e0b" stroke-width="1.3"/>
<line x1="90" y1="150" x2="560" y2="41.4" stroke="#f59e0b" stroke-width="1.3"/>
<line x1="560" y1="41.4" x2="560" y2="138.6" stroke="#ef4444" stroke-width="5"/><polygon points="560,138.6 554,124.6 566,124.6" fill="#ef4444"/>
<text x="560" y="170" text-anchor="middle" font-size="10" fill="#b91c1c">맺힌 상(작고 거꾸로)</text>
</svg>
<figcaption>모든 빛이 구멍 한 점을 지나 X자로 교차 → 화면엔 작고 거꾸로 맺힘. '초점거리(focal length)'가 클수록(망원) 크게 보인다.</figcaption>
</figure>
- **초점거리(focalLen)**: 렌즈가 얼마나 '당겨 찍나'(망원↔광각). 클수록 화면에서 크게.
- **센서(sensorW/H)**: 카메라 필름 크기. 초점거리와 센서 비율이 화각을 정함(코드 `f/sW`, `f/sH`).
## ▸ 정규좌표 (0~1)
화면 위치를 픽셀(예: 1920) 대신 **비율(0~1)** 로 나타낸 것. **0=왼쪽/위, 1=오른쪽/아래.**
화면 크기가 달라져도 그대로 쓸 수 있어 편해요. (예: 가로 0.6 = 화면의 60% 지점)
## ▸ 보간 (Interpolation)
두 값 **사이의 중간값**을 계산해 채우는 것. 사진이 30장뿐이어도 사이사이를 상상해 채워 **부드럽게** 움직여요.
<figure class="fig">
<svg viewBox="0 0 700 120" role="img" aria-label="보간 - 두 점 사이 중간 채우기">
<circle cx="120" cy="80" r="7" fill="#2563eb"/><text x="120" y="105" text-anchor="middle" font-size="10" fill="#1e3a8a">사진1</text>
<circle cx="560" cy="40" r="7" fill="#2563eb"/><text x="560" y="30" text-anchor="middle" font-size="10" fill="#1e3a8a">사진2</text>
<line x1="120" y1="80" x2="560" y2="40" stroke="#cbd5e1" stroke-dasharray="4 4"/>
<circle cx="230" cy="70" r="4" fill="#ef4444"/><circle cx="340" cy="60" r="4" fill="#ef4444"/><circle cx="450" cy="50" r="4" fill="#ef4444"/>
<text x="340" y="95" text-anchor="middle" font-size="10" fill="#b91c1c">← 중간을 채운 위치(보간) →</text>
</svg>
<figcaption>사진1·2 사이 몇 % 지점인지(frac) 계산해 중간 위치를 만든다. 코드: poseAt.</figcaption>
</figure>
## ▸ 이동평균 · EMA (지수이동평균)
여러 값을 **평균 내** 들쭉날쭉(떨림)을 매끈하게 만드는 방법.
- **이동평균**: 앞뒤 몇 개를 평균 (코드 `smoothFrame`)
- **EMA**: 최근 값에 더 무게를 둔 평균, 반응이 빠름 (코드 `smoothStep`)
<figure class="fig">
<svg viewBox="0 0 700 130" role="img" aria-label="떨리는 값 vs 평활한 값">
<polyline points="40,70 80,40 120,95 160,45 200,90 240,50 280,85 320,55 360,80" fill="none" stroke="#f87171" stroke-width="2"/>
<text x="200" y="120" text-anchor="middle" font-size="10" fill="#b91c1c">평활 전: 부들부들 떨림</text>
<path d="M400,70 q80,-8 160,-6 t120,-4" fill="none" stroke="#16a34a" stroke-width="3"/>
<text x="560" y="120" text-anchor="middle" font-size="10" fill="#166534">평활 후: 매끈</text>
<text x="378" y="66" font-size="18" fill="#64748b">➡</text>
</svg>
<figcaption>드론 흔들림으로 튀는 위치를 평균으로 눌러 이름표가 안 떨리게 한다.</figcaption>
</figure>
## ▸ requestAnimationFrame (RAF)
브라우저에게 **"화면 새로 그릴 때마다 이 일을 해줘"** 라고 부탁하는 명령. 보통 1초에 약 60번 실행돼요.
그래서 이름표 위치를 매번 다시 계산·그리기 해 부드럽게 따라다녀요.
## ▸ Canvas 2D
웹페이지 안의 **그림판(도화지)**. 여기에 선·글자를 직접 그려요. 우리는 중심선·측점·POI 이름표를
매 프레임 이 캔버스에 그려요. (선명하고 빠름)
## ▸ fps · 프레임
- **프레임** = 영상의 **낱장 사진** 한 장.
- **fps(frames per second)** = **1초에 몇 장** 넘기나. 예: 30fps = 1초에 30장.
- 프레임 번호를 시간으로 바꾸려면 fps가 필요해요: `시간 = 프레임번호 ÷ fps` (본문 ⑧).
## ▸ requestIdleCallback
브라우저가 **한가한 틈**에 무거운 일을 시키는 명령. 무거운 "어떤 라벨을 보여줄지" 준비 작업을 여기서 미리 해
두고, 매 프레임(RAF)에는 가벼운 그리기만 → 60번/초에도 안 버벅여요.
@@ -0,0 +1,856 @@
<!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>
<h2 id="드론-gps--영상--poi-좌표를-영상-위-정확한-위치에--그리고-프레임에서-측점스테이션으로">드론 GPS · 영상 · POI 좌표를 영상 위 정확한 위치에 — 그리고 프레임에서 측점(스테이션)으로</h2>
<blockquote>
<p>작성일: 2026-06-30 · 발표용 요약 문서 슬라이드 구분은 <code>---</code> 입니다 (Marp/reveal.js 변환 가능). 각 장은 &quot;그림 → 핵심 → 코드 근거&quot; 순.</p>
</blockquote>
<style>
figure.fig{margin:18px 0;text-align:center;page-break-inside:avoid;break-inside:avoid;}
figure.fig svg{width:100%;max-width:680px;height:auto;border:1px solid #e7e0d2;border-radius:10px;background:#fffdf8;}
figure.fig figcaption{font-size:0.88em;color:#777;margin-top:6px;}
text{font-family:'Malgun Gothic','Hancom Gothic',sans-serif;}
h2{border-bottom:2px solid #e7e0d2;padding-bottom:4px;}
.lead{background:#f8f6ef;border-left:4px solid #b45309;padding:10px 14px;border-radius:6px;}
</style>
<hr />
<h2 id="목차">목차</h2>
<table>
<thead>
<tr class="header">
<th>🟦 공간 정합</th>
<th>🟩 시간 동기화</th>
<th>🟧 표시 안정</th>
<th>🟦 측점 전환</th>
</tr>
</thead>
<tbody>
<tr class="odd">
<td>지도좌표 → 화면픽셀</td>
<td>영상시각 ↔︎ 드론프레임</td>
<td>떨림·튐 제거</td>
<td>프레임 → 측점(공간)</td>
</tr>
</tbody>
</table>
<ol type="1">
<li>우리가 풀어야 했던 문제</li>
<li>입력 데이터 3종 — GPS · 영상 · POI</li>
<li>전체 파이프라인 한 장</li>
<li><strong>핵심 ①</strong> 좌표 정합 — 지도 좌표를 화면 픽셀로 (투영)</li>
<li><strong>핵심 ②</strong> 시간 동기화 — 영상 시각과 드론 프레임 맞추기</li>
<li><strong>핵심 ③</strong> 라벨 안정화 — 떨림·튐 제거</li>
<li><strong>핵심 ④</strong> 프레임 기반 → 측점(스테이션) 기반 전환</li>
<li>정확도 보정 도구 (DEM · 드래그 · 세로화각)</li>
<li>정리 — 무엇을, 어떻게</li>
</ol>
<hr />
<h2 id="1-우리가-풀어야-했던-문제">1. 우리가 풀어야 했던 문제</h2>
<p class="lead">드론이 철도 노선을 따라 비행하며 찍은 영상 위에, <b>교량·터널·역사·지장물(POI)의 이름표를 실제 위치에 정확히</b> 띄우고 싶다.</p>
<figure class="fig">
<svg viewBox="0 0 680 300" role="img" aria-label="드론 영상 위 라벨 예시">
<!-- video frame -->
<rect x="20" y="20" width="640" height="230" rx="10" fill="#0b1020" stroke="#334155" stroke-width="2"></rect>
<!-- sky gradient -->
<defs>
<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#1e3a5f"></stop><stop offset="1" stop-color="#0b1020"></stop></linearGradient>
<marker id="m1" markerWidth="9" markerHeight="9" refX="6" refY="3" orient="auto"><path d="M0,0 L6,3 L0,6 Z" fill="#f59e0b"></path></marker>
</defs>
<rect x="22" y="22" width="636" height="120" fill="url(#sky)"></rect>
<!-- rails -->
<polygon points="250,250 430,250 360,120 320,120" fill="#1c2533" stroke="#475569"></polygon>
<line x1="285" y1="250" x2="338" y2="120" stroke="#94a3b8" stroke-width="2"></line>
<line x1="395" y1="250" x2="342" y2="120" stroke="#94a3b8" stroke-width="2"></line>
<g stroke="#64748b" stroke-width="1.5">
<line x1="300" y1="210" x2="380" y2="210"></line><line x1="312" y1="180" x2="368" y2="180"></line><line x1="322" y1="155" x2="358" y2="155"></line></g>
<!-- bridge label -->
<circle cx="175" cy="120" r="5" fill="#f59e0b"></circle>
<rect x="120" y="78" width="110" height="26" rx="5" fill="#f59e0b"></rect>
<text x="175" y="96" text-anchor="middle" font-size="13" font-weight="bold" fill="#1a1303">🌉 회덕제1가도교</text>
<line x1="175" y1="104" x2="175" y2="116" stroke="#f59e0b" stroke-width="1.5" marker-end="url(#m1)"></line>
<!-- tunnel label -->
<circle cx="500" cy="120" r="5" fill="#38bdf8"></circle>
<rect x="455" y="78" width="92" height="26" rx="5" fill="#38bdf8"></rect>
<text x="501" y="96" text-anchor="middle" font-size="13" font-weight="bold" fill="#06283d">🚇 법동터널</text>
<line x1="501" y1="104" x2="501" y2="116" stroke="#38bdf8" stroke-width="1.5"></line>
<!-- HUD -->
<rect x="34" y="214" width="320" height="24" rx="5" fill="#000" opacity="0.55"></rect>
<text x="44" y="231" font-size="12" fill="#e2e8f0">GPS 36.334, 127.456 · 고도 123m · 측점 157K970</text>
<!-- compass -->
<circle cx="615" cy="60" r="24" fill="#0b1020" stroke="#64748b"></circle>
<polygon points="615,42 609,60 621,60" fill="#f87171"></polygon><text x="615" y="38" text-anchor="middle" font-size="9" fill="#f87171">N</text>
<text x="340" y="278" text-anchor="middle" font-size="12" fill="#555">드론 영상 + 실제 위치에 붙는 이름표 + 위치 HUD</text>
</svg>
<figcaption>그림 1. 목표 — 흔들리는 드론 영상 위에, 지도 좌표만 가진 POI를 &quot;진짜 그 자리&quot;에 표시.</figcaption>
</figure>
<p><strong>난이도 4가지</strong> → 그래서 <strong>세 가지 정합</strong>이 필요합니다.</p>
<table>
<thead>
<tr class="header">
<th>난이도</th>
<th>무엇이 어려운가</th>
<th>해결 정합</th>
</tr>
</thead>
<tbody>
<tr class="odd">
<td>드론 흔들림</td>
<td>yaw/pitch/roll·고도 변화·빠른 이동</td>
<td>🟦 공간 + 🟧 안정</td>
</tr>
<tr class="even">
<td>POI는 지도좌표만</td>
<td>&quot;화면 어디&quot;인지 모름</td>
<td>🟦 공간(투영)</td>
</tr>
<tr class="odd">
<td>시간 어긋남</td>
<td>영상 시각 ↔︎ 드론 데이터 시각</td>
<td>🟩 시간</td>
</tr>
<tr class="even">
<td>매끄러움</td>
<td>60fps·떨림 없이</td>
<td>🟧 안정</td>
</tr>
</tbody>
</table>
<hr />
<h2 id="2-입력-데이터-3종">2. 입력 데이터 3종</h2>
<figure class="fig">
<svg viewBox="0 0 680 270" role="img" aria-label="입력 3종 결합">
<defs><marker id="m2" markerWidth="10" markerHeight="10" refX="7" refY="3.5" orient="auto"><path d="M0,0 L7,3.5 L0,7 Z" fill="#b45309"></path></marker></defs>
<!-- 1 drone log -->
<rect x="30" y="30" width="170" height="120" rx="10" fill="#f5f3ff" stroke="#7c3aed" stroke-width="1.5"></rect>
<text x="115" y="54" text-anchor="middle" font-size="14" font-weight="bold" fill="#5b21b6">① 드론 로그</text>
<text x="115" y="74" text-anchor="middle" font-size="11" fill="#6d28d9">프레임별 비행 CSV</text>
<g font-size="10" fill="#4c1d95" font-family="monospace">
<text x="46" y="98">frame lat yaw</text>
<text x="46" y="114"> 0 36.33 12°</text>
<text x="46" y="130"> 1 36.33 13°</text>
<text x="46" y="146"> … alt·pitch·roll</text></g>
<!-- 2 video -->
<rect x="255" y="30" width="170" height="120" rx="10" fill="#eff6ff" stroke="#2563eb" stroke-width="1.5"></rect>
<text x="340" y="54" text-anchor="middle" font-size="14" font-weight="bold" fill="#1e3a8a">② 영상</text>
<text x="340" y="74" text-anchor="middle" font-size="11" fill="#1d4ed8">mp4/webm 주행영상</text>
<rect x="295" y="86" width="90" height="50" rx="4" fill="#0b1020"></rect>
<polygon points="332,100 332,122 352,111" fill="#fff"></polygon>
<!-- 3 poi -->
<rect x="480" y="30" width="170" height="120" rx="10" fill="#ecfdf5" stroke="#16a34a" stroke-width="1.5"></rect>
<text x="565" y="54" text-anchor="middle" font-size="14" font-weight="bold" fill="#14532d">③ POI / 측점</text>
<text x="565" y="74" text-anchor="middle" font-size="11" fill="#15803d">KMZ(1순위)+측점CSV</text>
<g font-size="10" fill="#166534" font-family="monospace">
<text x="496" y="98">🌉 교량 (lat,lon,z)</text>
<text x="496" y="114">🚇 터널 (lat,lon,z)</text>
<text x="496" y="130">📍 측점 157K970</text></g>
<!-- merge -->
<line x1="115" y1="150" x2="320" y2="195" stroke="#b45309" stroke-width="1.5" marker-end="url(#m2)"></line>
<line x1="340" y1="150" x2="340" y2="195" stroke="#b45309" stroke-width="1.5" marker-end="url(#m2)"></line>
<line x1="565" y1="150" x2="360" y2="195" stroke="#b45309" stroke-width="1.5" marker-end="url(#m2)"></line>
<rect x="200" y="205" width="280" height="40" rx="20" fill="#fff7e6" stroke="#f59e0b" stroke-width="2"></rect>
<text x="340" y="230" text-anchor="middle" font-size="14" font-weight="bold" fill="#b45309">이 셋을 시간·공간으로 묶는다</text>
</svg>
<figcaption>그림 2. 드론 로그(언제·어디·어느 방향) + 영상 + 지도 POI를 결합.</figcaption>
</figure>
<hr />
<h2 id="3-전체-파이프라인-한-장">3. 전체 파이프라인 한 장</h2>
<figure class="fig">
<svg viewBox="0 0 680 420" role="img" aria-label="전체 파이프라인">
<defs><marker id="m3" markerWidth="10" markerHeight="10" refX="7" refY="3.5" orient="auto"><path d="M0,0 L7,3.5 L0,7 Z" fill="#64748b"></path></marker></defs>
<!-- lane labels -->
<rect x="14" y="14" width="14" height="392" rx="4" fill="#16a34a" opacity="0.15"></rect>
<!-- step: time -->
<rect x="180" y="20" width="320" height="46" rx="8" fill="#ecfdf5" stroke="#16a34a" stroke-width="1.5"></rect>
<text x="340" y="40" text-anchor="middle" font-size="13" font-weight="bold" fill="#14532d">🟩 영상 재생 시각 t</text>
<text x="340" y="58" text-anchor="middle" font-size="11" fill="#15803d">smoothTimeRef: 60fps 단조보간</text>
<line x1="340" y1="66" x2="340" y2="84" stroke="#64748b" stroke-width="1.6" marker-end="url(#m3)"></line>
<rect x="180" y="86" width="320" height="40" rx="8" fill="#ecfdf5" stroke="#16a34a" stroke-width="1.2"></rect>
<text x="340" y="111" text-anchor="middle" font-size="12" fill="#166534">t → 드론 프레임 보간 → 그 순간의 위치·자세(pose)</text>
<line x1="340" y1="126" x2="340" y2="142" stroke="#64748b" stroke-width="1.6" marker-end="url(#m3)"></line>
<!-- projection box -->
<rect x="120" y="144" width="440" height="120" rx="10" fill="#eff6ff" stroke="#2563eb" stroke-width="1.8"></rect>
<text x="340" y="166" text-anchor="middle" font-size="13" font-weight="bold" fill="#1e3a8a">🟦 좌표 정합 (투영) — 4단계</text>
<g font-size="12" fill="#1e40af">
<text x="340" y="190" text-anchor="middle">POI(위경도·표고) → ENU(평면 미터)</text>
<text x="340" y="212" text-anchor="middle">→ 카메라 좌표 (yaw/pitch/roll 회전)</text>
<text x="340" y="234" text-anchor="middle">→ 화면 정규픽셀(0~1) [핀홀 카메라]</text>
<text x="340" y="256" text-anchor="middle">→ object-fit:cover 보정 → 실제 화면 px</text></g>
<line x1="340" y1="264" x2="340" y2="280" stroke="#64748b" stroke-width="1.6" marker-end="url(#m3)"></line>
<!-- smoothing -->
<rect x="180" y="282" width="320" height="40" rx="8" fill="#fff7ed" stroke="#d97706" stroke-width="1.5"></rect>
<text x="340" y="307" text-anchor="middle" font-size="12" fill="#9a3412">🟧 라벨 평활(One Euro) + 이상치 거부 → 떨림 제거</text>
<line x1="340" y1="322" x2="340" y2="338" stroke="#64748b" stroke-width="1.6" marker-end="url(#m3)"></line>
<!-- render -->
<rect x="180" y="340" width="320" height="40" rx="8" fill="#fff" stroke="#23272e" stroke-width="1.5"></rect>
<text x="340" y="365" text-anchor="middle" font-size="12" font-weight="bold" fill="#23272e">Canvas RAF 60fps 렌더 → 영상 위 이름표</text>
<!-- station branch -->
<line x1="500" y1="360" x2="600" y2="360" stroke="#0891b2" stroke-width="1.6"></line>
<line x1="600" y1="360" x2="600" y2="395" stroke="#0891b2" stroke-width="1.6"></line>
<rect x="430" y="392" width="240" height="22" rx="6" fill="#ecfeff" stroke="#0891b2"></rect>
<text x="550" y="407" text-anchor="middle" font-size="10.5" fill="#155e75">🟦 GPS→측점 투영 → 하단 측점바 커서</text>
</svg>
<figcaption>그림 3. 시각 t → pose 보간 → 4단계 투영 → 평활 → 렌더. 동시에 GPS는 측점값으로 투영되어 측점바로.</figcaption>
</figure>
<hr />
<h2 id="4-핵심-①-좌표-정합--지도-좌표를-화면-픽셀로">4. 핵심 ① 좌표 정합 — 지도 좌표를 화면 픽셀로</h2>
<p class="lead">&quot;POI는 위경도만 안다. 그게 <b>지금 화면 어디</b>에 보이는가?&quot; — 이게 이 프로그램의 심장. <b>4단계 투영</b>으로 푼다.</p>
<figure class="fig">
<svg viewBox="0 0 680 150" role="img" aria-label="4단계 투영 흐름">
<defs><marker id="m4" markerWidth="10" markerHeight="10" refX="7" refY="3.5" orient="auto"><path d="M0,0 L7,3.5 L0,7 Z" fill="#2563eb"></path></marker></defs>
<g text-anchor="middle">
<rect x="14" y="50" width="110" height="54" rx="8" fill="#ecfdf5" stroke="#16a34a"></rect><text x="69" y="74" font-size="12" font-weight="bold" fill="#14532d">위경도+표고</text><text x="69" y="92" font-size="10" fill="#166534">POI 🌉 (지도)</text>
<rect x="150" y="50" width="110" height="54" rx="8" fill="#eff6ff" stroke="#2563eb"></rect><text x="205" y="72" font-size="12" font-weight="bold" fill="#1e3a8a">평면 미터</text><text x="205" y="90" font-size="10" fill="#1d4ed8">ENU · 1단계</text>
<rect x="286" y="50" width="110" height="54" rx="8" fill="#eff6ff" stroke="#2563eb"></rect><text x="341" y="72" font-size="12" font-weight="bold" fill="#1e3a8a">카메라 좌표</text><text x="341" y="90" font-size="10" fill="#1d4ed8">회전 · 2단계</text>
<rect x="422" y="50" width="110" height="54" rx="8" fill="#eff6ff" stroke="#2563eb"></rect><text x="477" y="72" font-size="12" font-weight="bold" fill="#1e3a8a">화면 0~1</text><text x="477" y="90" font-size="10" fill="#1d4ed8">핀홀 · 3단계</text>
<rect x="558" y="50" width="108" height="54" rx="8" fill="#fff" stroke="#23272e" stroke-width="1.5"></rect><text x="612" y="72" font-size="12" font-weight="bold" fill="#23272e">화면 px</text><text x="612" y="90" font-size="10" fill="#555">cover · 4단계</text>
</g>
<line x1="124" y1="77" x2="148" y2="77" stroke="#2563eb" stroke-width="1.5" marker-end="url(#m4)"></line>
<line x1="260" y1="77" x2="284" y2="77" stroke="#2563eb" stroke-width="1.5" marker-end="url(#m4)"></line>
<line x1="396" y1="77" x2="420" y2="77" stroke="#2563eb" stroke-width="1.5" marker-end="url(#m4)"></line>
<line x1="532" y1="77" x2="556" y2="77" stroke="#2563eb" stroke-width="1.5" marker-end="url(#m4)"></line>
</svg>
<figcaption>그림 4. 4단계 투영 파이프라인. 파일: client/src/utils/geoProjection.ts</figcaption>
</figure>
<hr />
<h3 id="4-1-둥근-지구를-평평하게-enu-변환">4-1. 둥근 지구를 평평하게 (ENU 변환)</h3>
<p>지구는 둥글어 거리 계산이 어렵습니다. 노선 한 구간만 잘라 <strong>평평한 모눈종이(미터 단위)</strong> 로 폅니다.</p>
<figure class="fig">
<svg viewBox="0 0 680 200" role="img" aria-label="ENU 변환">
<defs><marker id="m41" markerWidth="9" markerHeight="9" refX="6" refY="3" orient="auto"><path d="M0,0 L6,3 L0,6 Z" fill="#6b7280"></path></marker></defs>
<!-- globe -->
<circle cx="110" cy="100" r="55" fill="#dbeafe" stroke="#2563eb"></circle>
<ellipse cx="110" cy="100" rx="55" ry="20" fill="none" stroke="#93c5fd"></ellipse>
<ellipse cx="110" cy="100" rx="20" ry="55" fill="none" stroke="#93c5fd"></ellipse>
<circle cx="126" cy="80" r="5" fill="#dc2626"></circle>
<text x="110" y="175" text-anchor="middle" font-size="11" fill="#1e3a8a">둥근 지구 (위경도)</text>
<text x="235" y="95" font-size="13" fill="#166534" font-weight="bold">EPSG:5186</text>
<text x="235" y="112" font-size="11" fill="#166534">TM 투영</text>
<line x1="172" y1="100" x2="318" y2="100" stroke="#16a34a" stroke-width="2.5" marker-end="url(#m41)"></line>
<!-- grid -->
<g stroke="#e5e7eb"><line x1="370" y1="40" x2="370" y2="170"></line><line x1="430" y1="40" x2="430" y2="170"></line><line x1="490" y1="40" x2="490" y2="170"></line><line x1="550" y1="40" x2="550" y2="170"></line><line x1="610" y1="40" x2="610" y2="170"></line>
<line x1="350" y1="60" x2="650" y2="60"></line><line x1="350" y1="100" x2="650" y2="100"></line><line x1="350" y1="140" x2="650" y2="140"></line></g>
<line x1="370" y1="160" x2="650" y2="160" stroke="#6b7280" stroke-width="2" marker-end="url(#m41)"></line>
<line x1="370" y1="160" x2="370" y2="40" stroke="#6b7280" stroke-width="2" marker-end="url(#m41)"></line>
<text x="635" y="178" font-size="11" fill="#6b7280">E 동(m)</text>
<text x="345" y="48" font-size="11" fill="#6b7280">N 북(m)</text>
<circle cx="370" cy="160" r="4" fill="#16a34a"></circle><text x="376" y="176" font-size="10" fill="#14532d">기준점(0,0,0)</text>
<circle cx="490" cy="76" r="6" fill="#b45309"></circle>
<text x="500" y="72" font-size="11" font-weight="bold" fill="#b45309">POI (E=120, N=300, U=-5)</text>
</svg>
<figcaption>그림 5. E/N = 동·북 몇 m, U = 기준점 대비 상대 높이(alt refAlt).</figcaption>
</figure>
<div class="sourceCode" id="cb1"><pre class="sourceCode ts"><code class="sourceCode typescript"><span id="cb1-1"><a href="#cb1-1" aria-hidden="true" tabindex="-1"></a><span class="co">// geoToEnu (9197)</span></span>
<span id="cb1-2"><a href="#cb1-2" aria-hidden="true" tabindex="-1"></a>const [e<span class="op">,</span> n] <span class="op">=</span> <span class="fu">latLonToTM</span>(lat<span class="op">,</span> lon)<span class="op">;</span> <span class="co">// 위경도 → 평면 미터</span></span>
<span id="cb1-3"><a href="#cb1-3" aria-hidden="true" tabindex="-1"></a>return [e<span class="op">,</span> n<span class="op">,</span> alt <span class="op">-</span> refAlt]<span class="op">;</span></span></code></pre></div>
<hr />
<h3 id="4-2-드론이-보는-방향으로-회전-카메라-좌표">4-2. 드론이 보는 방향으로 회전 (카메라 좌표)</h3>
<p>세상 좌표(E/N/U)를 드론의 <strong>yaw·pitch·roll</strong> 회전행렬에 곱해 &quot;카메라가 보는 좌표(Xc, Yc, Zc)&quot;로 바꿉니다.</p>
<figure class="fig">
<svg viewBox="0 0 680 210" role="img" aria-label="세상→카메라 회전">
<defs><marker id="m42" markerWidth="10" markerHeight="10" refX="7" refY="3.5" orient="auto"><path d="M0,0 L7,3.5 L0,7 Z" fill="#7c3aed"></path></marker></defs>
<!-- world frame -->
<text x="150" y="30" text-anchor="middle" font-size="12" font-weight="bold" fill="#334155">세상 기준 (북 고정)</text>
<line x1="80" y1="150" x2="240" y2="150" stroke="#6b7280" stroke-width="1.6"></line><text x="248" y="154" font-size="11" fill="#6b7280">E</text>
<line x1="150" y1="170" x2="150" y2="60" stroke="#6b7280" stroke-width="1.6"></line><text x="156" y="62" font-size="11" fill="#6b7280">N</text>
<circle cx="150" cy="150" r="8" fill="#7c3aed"></circle><text x="150" y="186" text-anchor="middle" font-size="10" fill="#5b21b6">🚁 북쪽 봄</text>
<circle cx="210" cy="95" r="5" fill="#b45309"></circle><text x="218" y="92" font-size="10" fill="#b45309">POI</text>
<!-- arrow -->
<line x1="280" y1="120" x2="370" y2="120" stroke="#7c3aed" stroke-width="2.5" marker-end="url(#m42)"></line>
<text x="325" y="110" text-anchor="middle" font-size="11" fill="#5b21b6">회전</text>
<!-- camera frame -->
<text x="520" y="30" text-anchor="middle" font-size="12" font-weight="bold" fill="#334155">드론이 보는 기준 (정면이 기준)</text>
<circle cx="450" cy="150" r="8" fill="#7c3aed"></circle><text x="450" y="186" text-anchor="middle" font-size="10" fill="#5b21b6">🚁</text>
<line x1="450" y1="150" x2="610" y2="80" stroke="#16a34a" stroke-width="1.8" marker-end="url(#m42)"></line><text x="612" y="78" font-size="11" fill="#15803d">앞(Zc)</text>
<line x1="450" y1="150" x2="600" y2="160" stroke="#0891b2" stroke-width="1.8" marker-end="url(#m42)"></line><text x="604" y="166" font-size="11" fill="#155e75">옆(side)</text>
<circle cx="556" cy="108" r="5" fill="#b45309"></circle><text x="500" y="100" font-size="10" fill="#b45309">POI (앞 50m, 옆 8m)</text>
</svg>
<figcaption>그림 6. 북 기준 좌표를 &quot;드론 정면 기준&quot;으로 회전. 진행방향 거리 fwd(앞)·side(옆)도 함께 산출.</figcaption>
</figure>
<div class="sourceCode" id="cb2"><pre class="sourceCode ts"><code class="sourceCode typescript"><span id="cb2-1"><a href="#cb2-1" aria-hidden="true" tabindex="-1"></a><span class="co">// toCameraCoords (301317)</span></span>
<span id="cb2-2"><a href="#cb2-2" aria-hidden="true" tabindex="-1"></a>cc<span class="op">.</span><span class="at">fwd</span> <span class="op">=</span> relEnu[<span class="dv">0</span>]<span class="op">*</span>sy <span class="op">+</span> relEnu[<span class="dv">1</span>]<span class="op">*</span>cy<span class="op">;</span> <span class="co">// +면 앞쪽</span></span>
<span id="cb2-3"><a href="#cb2-3" aria-hidden="true" tabindex="-1"></a>cc<span class="op">.</span><span class="at">side</span> <span class="op">=</span> relEnu[<span class="dv">0</span>]<span class="op">*</span>cy <span class="op">-</span> relEnu[<span class="dv">1</span>]<span class="op">*</span>sy<span class="op">;</span> <span class="co">// +면 오른쪽 → &quot;앞은 멀리, 옆은 가깝게&quot; 비등방 필터 근거</span></span></code></pre></div>
<hr />
<h3 id="4-3-3d를-납작한-사진으로-핀홀-카메라-투영">4-3. 3D를 납작한 사진으로 (핀홀 카메라 투영)</h3>
<p>핵심 한 줄: <strong>깊이(Zc)로 나눈다 → 멀수록 화면 가운데로 작게.</strong> (바늘구멍 사진기 원리)</p>
<figure class="fig">
<svg viewBox="0 0 680 210" role="img" aria-label="핀홀 카메라">
<!-- pinhole -->
<rect x="330" y="55" width="16" height="100" fill="#475569"></rect>
<circle cx="338" cy="105" r="6" fill="#fffdf8" stroke="#475569"></circle>
<text x="338" y="172" text-anchor="middle" font-size="10" fill="#475569">구멍(렌즈)</text>
<!-- near big object -->
<line x1="120" y1="55" x2="120" y2="155" stroke="#16a34a" stroke-width="6"></line>
<text x="120" y="44" text-anchor="middle" font-size="10" fill="#14532d">가까운 🌉</text>
<!-- far small object -->
<line x1="40" y1="85" x2="40" y2="125" stroke="#22c55e" stroke-width="6"></line>
<text x="40" y="74" text-anchor="middle" font-size="10" fill="#15803d">먼 🌉</text>
<!-- rays through pinhole to screen -->
<line x1="120" y1="55" x2="338" y2="105" stroke="#16a34a" stroke-width="1" stroke-dasharray="3 3"></line>
<line x1="120" y1="155" x2="338" y2="105" stroke="#16a34a" stroke-width="1" stroke-dasharray="3 3"></line>
<line x1="338" y1="105" x2="470" y2="135" stroke="#16a34a" stroke-width="1" stroke-dasharray="3 3"></line>
<line x1="338" y1="105" x2="470" y2="75" stroke="#16a34a" stroke-width="1" stroke-dasharray="3 3"></line>
<!-- screen -->
<rect x="468" y="50" width="150" height="110" rx="6" fill="#0b1020" stroke="#334155"></rect>
<text x="543" y="44" text-anchor="middle" font-size="10" fill="#555">화면(0~1)</text>
<line x1="495" y1="75" x2="495" y2="135" stroke="#4ade80" stroke-width="5"></line>
<text x="555" y="110" font-size="10" fill="#86efac">가까운 게 크게 맺힘</text>
<text x="338" y="200" text-anchor="middle" font-size="11" fill="#555">거리(Zc)가 클수록 → 화면에 작게</text>
</svg>
<figcaption>그림 7. pxRaw = 0.5 + (Xc/Zc)·(f/sensorW). 0.5=정중앙, f/sensor=화각.</figcaption>
</figure>
<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><span class="co">// pixelFromCamera (126137)</span></span>
<span id="cb3-2"><a href="#cb3-2" 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> sW)<span class="op">;</span> <span class="co">// 가로 0~1</span></span>
<span id="cb3-3"><a href="#cb3-3" 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> sH)<span class="op">;</span> <span class="co">// 세로 0~1</span></span></code></pre></div>
<hr />
<h3 id="4-4-높이-기준-통일-지오이드-보정--화면-정렬">4-4. 높이 기준 통일 (지오이드 보정) + 화면 정렬</h3>
<p><strong>두 개의 &#39;0층&#39; 문제</strong>: 지도 높이(정표고·해발)와 GPS 높이(타원체고)는 기준이 달라 대전 기준 약 <strong>25.8m</strong> 차이. 안 맞추면 라벨이 위아래로 어긋납니다.</p>
<figure class="fig">
<svg viewBox="0 0 680 180" role="img" aria-label="지오이드 보정">
<defs><marker id="m44a" markerWidth="9" markerHeight="9" refX="3" refY="3" orient="auto"><path d="M0,0 L6,3 L0,6 Z" fill="#dc2626"></path></marker><marker id="m44b" markerWidth="9" markerHeight="9" refX="3" refY="3" orient="auto"><path d="M6,0 L0,3 L6,6 Z" fill="#dc2626"></path></marker></defs>
<line x1="60" y1="50" x2="500" y2="50" stroke="#2563eb" stroke-width="2.5" stroke-dasharray="7 4"></line>
<text x="510" y="54" font-size="12" fill="#1e3a8a">타원체고(GPS) 0</text>
<line x1="60" y1="110" x2="500" y2="110" stroke="#0ea5e9" stroke-width="2.5" stroke-dasharray="7 4"></line>
<text x="510" y="114" font-size="12" fill="#0369a1">정표고(해발) 0</text>
<line x1="150" y1="51" x2="150" y2="109" stroke="#dc2626" stroke-width="1.6" marker-start="url(#m44a)" marker-end="url(#m44b)"></line>
<text x="160" y="84" font-size="13" font-weight="bold" fill="#dc2626">≈ 25.8m (geoidOffset)</text>
<text x="160" y="100" font-size="10" fill="#b91c1c">지도 높이에 더해 GPS 기준으로 통일</text>
<text x="280" y="150" text-anchor="middle" font-size="11" fill="#555">기준을 맞춰야 라벨이 제 높이에 붙는다</text>
</svg>
<figcaption>그림 8. 지도 표고 + geoidOffset → GPS와 같은 기준. 마지막에 object-fit:cover 잘림을 반영해 0~1 → 실제 px.</figcaption>
</figure>
<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><span class="fu">geoToEnu</span>(lat<span class="op">,</span> lon<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="op">...</span>)<span class="op">;</span> <span class="co">// 높이 기준 통일</span></span>
<span id="cb4-2"><a href="#cb4-2" aria-hidden="true" tabindex="-1"></a><span class="co">// 이후 coverRef(StationOverlay 921930)로 정규(0~1) → 실제 화면 px 정렬</span></span></code></pre></div>
<p>➡ 4-1 ~ 4-4를 거치면 <strong>POI가 영상 속 진짜 그 자리에</strong> 찍힙니다.</p>
<hr />
<h2 id="5-핵심-②-시간-동기화--영상-시각--드론-프레임">5. 핵심 ② 시간 동기화 — 영상 시각 ↔︎ 드론 프레임</h2>
<p>좌표가 맞아도 <strong>&quot;지금 영상 시각의 드론 위치&quot;</strong> 를 못 집으면 라벨이 엉뚱한 데 뜹니다.</p>
<p><strong>문제</strong>: 브라우저 <code>video.currentTime</code>은 ~250ms 간격으로만 갱신 → 커서·라벨이 뚝뚝 끊김. <strong>해결</strong>: <code>smoothTimeRef</code> — 벽시계로 <strong>60fps 단조 보간</strong>.</p>
<figure class="fig">
<svg viewBox="0 0 680 190" role="img" aria-label="시간 보간">
<!-- raw -->
<text x="40" y="42" font-size="12" font-weight="bold" fill="#b91c1c">실제 currentTime (250ms 띄엄띄엄)</text>
<line x1="50" y1="60" x2="630" y2="60" stroke="#e5e7eb" stroke-width="2"></line>
<g fill="#dc2626"><circle cx="80" cy="60" r="6"></circle><circle cx="220" cy="60" r="6"></circle><circle cx="360" cy="60" r="6"></circle><circle cx="500" cy="60" r="6"></circle><circle cx="620" cy="60" r="6"></circle></g>
<text x="340" y="84" text-anchor="middle" font-size="11" fill="#b91c1c">→ 라벨/커서가 뚝뚝 끊김</text>
<!-- smooth -->
<text x="40" y="124" font-size="12" font-weight="bold" fill="#166534">smoothTimeRef (매 프레임 채움 · 60fps)</text>
<line x1="50" y1="142" x2="630" y2="142" stroke="#e5e7eb" stroke-width="2"></line>
<g fill="#16a34a"><circle cx="80" cy="142" r="4"></circle><circle cx="110" cy="142" r="4"></circle><circle cx="140" cy="142" r="4"></circle><circle cx="170" cy="142" r="4"></circle><circle cx="200" cy="142" r="4"></circle><circle cx="230" cy="142" r="4"></circle><circle cx="260" cy="142" r="4"></circle><circle cx="290" cy="142" r="4"></circle><circle cx="320" cy="142" r="4"></circle><circle cx="350" cy="142" r="4"></circle><circle cx="380" cy="142" r="4"></circle><circle cx="410" cy="142" r="4"></circle><circle cx="440" cy="142" r="4"></circle><circle cx="470" cy="142" r="4"></circle><circle cx="500" cy="142" r="4"></circle><circle cx="530" cy="142" r="4"></circle><circle cx="560" cy="142" r="4"></circle><circle cx="590" cy="142" r="4"></circle><circle cx="620" cy="142" r="4"></circle></g>
<text x="340" y="166" text-anchor="middle" font-size="11" fill="#166534">media + 경과시간×배속 · 0.3s 이상 벌어지면 재동기화</text>
</svg>
<figcaption>그림 9. 띄엄띄엄한 실제 시각을 60fps로 메워 부드럽게. 이 t로 드론 pose를 보간.</figcaption>
</figure>
<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="co">// VideoPlayer.tsx 62117</span></span>
<span id="cb5-2"><a href="#cb5-2" aria-hidden="true" tabindex="-1"></a>let est <span class="op">=</span> a<span class="op">.</span><span class="at">media</span> <span class="op">+</span> ((performance<span class="op">.</span><span class="fu">now</span>() <span class="op">-</span> a<span class="op">.</span><span class="at">wall</span>)<span class="op">/</span><span class="dv">1000</span>) <span class="op">*</span> rate<span class="op">;</span></span>
<span id="cb5-3"><a href="#cb5-3" aria-hidden="true" tabindex="-1"></a><span class="fu">if</span> (real <span class="op">-</span> est <span class="op">&gt;</span> <span class="fl">0.3</span>) est <span class="op">=</span> real<span class="op">;</span> <span class="co">// 재동기화</span></span>
<span id="cb5-4"><a href="#cb5-4" aria-hidden="true" tabindex="-1"></a>smoothTimeRef<span class="op">.</span><span class="at">current</span> <span class="op">=</span> t<span class="op">;</span> <span class="co">// ref로 노출 → 리렌더 없이 매 프레임 읽음</span></span></code></pre></div>
<hr />
<h2 id="6-핵심-③-라벨-안정화--떨림튐-제거">6. 핵심 ③ 라벨 안정화 — 떨림·튐 제거</h2>
<p>드론은 미세하게 흔들립니다. 그대로 투영하면 라벨이 부르르 떨립니다. → <strong>One Euro 방식 속도적응 평활 + 이상치 거부.</strong></p>
<figure class="fig">
<svg viewBox="0 0 680 220" role="img" aria-label="One Euro 평활">
<!-- case 1 -->
<rect x="20" y="20" width="210" height="180" rx="10" fill="#ecfdf5" stroke="#16a34a"></rect>
<text x="125" y="42" text-anchor="middle" font-size="12" font-weight="bold" fill="#14532d">떨림(왕복)</text>
<polyline points="40,110 60,95 80,118 100,92 120,116 140,96 160,114 180,98 200,110" fill="none" stroke="#dc2626" stroke-width="1.5"></polyline>
<line x1="40" y1="150" x2="210" y2="150" stroke="#16a34a" stroke-width="3"></line>
<text x="125" y="172" text-anchor="middle" font-size="10.5" fill="#166534">평활속도≈0 → 강하게 평활</text>
<text x="125" y="188" text-anchor="middle" font-size="10.5" fill="#166534">(안정)</text>
<!-- case 2 -->
<rect x="240" y="20" width="200" height="180" rx="10" fill="#eff6ff" stroke="#2563eb"></rect>
<text x="340" y="42" text-anchor="middle" font-size="12" font-weight="bold" fill="#1e3a8a">실제 이동</text>
<polyline points="260,150 290,130 320,110 350,90 380,70 410,55" fill="none" stroke="#2563eb" stroke-width="2.5"></polyline>
<text x="340" y="172" text-anchor="middle" font-size="10.5" fill="#1e40af">방향 일관 → 즉시 추종</text>
<text x="340" y="188" text-anchor="middle" font-size="10.5" fill="#1e40af">(지연 없음)</text>
<!-- case 3 -->
<rect x="450" y="20" width="210" height="180" rx="10" fill="#fff7ed" stroke="#d97706"></rect>
<text x="555" y="42" text-anchor="middle" font-size="12" font-weight="bold" fill="#9a3412">갑작스런 점프</text>
<polyline points="470,150 500,148 530,150" fill="none" stroke="#d97706" stroke-width="2"></polyline>
<circle cx="610" cy="80" r="6" fill="#dc2626"></circle>
<line x1="530" y1="150" x2="600" y2="86" stroke="#dc2626" stroke-width="1.5" stroke-dasharray="4 3"></line>
<text x="612" y="76" font-size="14" fill="#dc2626"></text>
<text x="555" y="180" text-anchor="middle" font-size="10.5" fill="#9a3412">8프레임까지 무시 후 수용</text>
<text x="555" y="195" text-anchor="middle" font-size="10.5" fill="#9a3412">(시크는 진짜 → 결국 수용)</text>
</svg>
<figcaption>그림 10. 떨림은 죽이고, 진짜 이동은 즉시 따라가고, 튐(점프)은 무시. 세 동작을 한 필터로.</figcaption>
</figure>
<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><span class="co">// StationOverlay.tsx 4861</span></span>
<span id="cb6-2"><a href="#cb6-2" aria-hidden="true" tabindex="-1"></a><span class="fu">if</span> (d <span class="op">&gt;</span> REJECT_DIST <span class="op">&amp;&amp;</span> prev<span class="op">.</span><span class="at">rej</span> <span class="op">&lt;</span> MAX_REJECT_FRAMES) <span class="co">// 0.12 초과 점프 = 이상치</span></span>
<span id="cb6-3"><a href="#cb6-3" aria-hidden="true" tabindex="-1"></a> return { <span class="op">...</span>prev<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="co">// 위치 유지</span></span>
<span id="cb6-4"><a href="#cb6-4" aria-hidden="true" tabindex="-1"></a>const a <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="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="cb6-5"><a href="#cb6-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 class="co">// 속도 클수록 빠르게 추종</span></span></code></pre></div>
<hr />
<h2 id="7-핵심-④-프레임-기반--측점스테이션-기반">7. 핵심 ④ 프레임 기반 → 측점(스테이션) 기반</h2>
<h3 id="7-1-왜-바꿨나--시간축의-한계">7-1. 왜 바꿨나? — 시간축의 한계</h3>
<p>철도 현장은 <strong>위치(측점)</strong> 로 말합니다: &quot;157K970 지점의 교량&quot;. 그런데 시간축은 <strong>드론이 호버(정지)</strong> 하면 무너집니다.</p>
<figure class="fig">
<svg viewBox="0 0 680 180" role="img" aria-label="시간축 한계">
<text x="120" y="32" font-size="12" font-weight="bold" fill="#b91c1c">시간축 (균등)</text>
<line x1="40" y1="60" x2="500" y2="60" stroke="#cbd5e1" stroke-width="3"></line>
<g fill="#64748b"><circle cx="40" cy="60" r="4"></circle><circle cx="132" cy="60" r="4"></circle><circle cx="224" cy="60" r="4"></circle><circle cx="316" cy="60" r="4"></circle><circle cx="408" cy="60" r="4"></circle><circle cx="500" cy="60" r="4"></circle></g>
<g font-size="10" fill="#64748b"><text x="40" y="78">0s</text><text x="128" y="78">1s</text><text x="220" y="78">2s</text><text x="312" y="78">3s</text><text x="404" y="78">4s</text><text x="496" y="78">5s</text></g>
<!-- hover -->
<rect x="224" y="100" width="120" height="50" rx="8" fill="#fee2e2" stroke="#dc2626"></rect>
<text x="284" y="120" text-anchor="middle" font-size="11" fill="#b91c1c">드론 호버(정지)</text>
<text x="284" y="138" text-anchor="middle" font-size="10" fill="#b91c1c">시간은 가는데 위치는 안 변함</text>
<text x="560" y="125" font-size="11" fill="#b91c1c">→ 시간축에선</text>
<text x="560" y="142" font-size="11" fill="#b91c1c">구분 불가 ✗</text>
</svg>
<figcaption>그림 11. 시간 균등축에선 &quot;공중 대기(호버)&quot; 구간을 표현할 수 없다.</figcaption>
</figure>
<p>➡ 그래서 <strong>측점(공간)축</strong>으로 재해석: 드론이 호버하면 측점도 멈춰 있어야 자연스럽다.</p>
<hr />
<h3 id="7-2-구현-①--gps를-측점값으로-투영">7-2. 구현 ① — GPS를 측점값으로 투영</h3>
<p>드론 GPS를 측점 폴리라인(선로)에 <strong>수직으로 내려</strong> &quot;측점값(km)&quot;&quot;선로 이격(m)&quot;을 구합니다.</p>
<figure class="fig">
<svg viewBox="0 0 680 190" role="img" aria-label="GPS를 측점선에 투영">
<defs><marker id="m72" markerWidth="9" markerHeight="9" refX="6" refY="3" orient="auto"><path d="M0,0 L6,3 L0,6 Z" fill="#f59e0b"></path></marker></defs>
<!-- chain line -->
<polyline points="60,130 200,122 360,118 520,124 630,130" fill="none" stroke="#0891b2" stroke-width="5"></polyline>
<g fill="#155e75" font-size="10">
<circle cx="60" cy="130" r="5" fill="#0891b2"></circle><text x="40" y="152">157K900</text>
<circle cx="360" cy="118" r="5" fill="#0891b2"></circle><text x="335" y="108">158K000</text></g>
<text x="70" y="118" font-size="11" fill="#155e75">측점 폴리라인(선로)</text>
<!-- drone -->
<circle cx="280" cy="55" r="9" fill="#7c3aed"></circle><text x="295" y="52" font-size="12" font-weight="bold" fill="#5b21b6">🚁 드론 GPS</text>
<!-- perpendicular -->
<line x1="280" y1="64" x2="280" y2="118" stroke="#f59e0b" stroke-width="2" stroke-dasharray="5 4" marker-end="url(#m72)"></line>
<circle cx="280" cy="120" r="6" fill="#b45309"></circle>
<text x="290" y="146" font-size="12" font-weight="bold" fill="#b45309">km=157K958, 이격 4.2m</text>
<text x="200" y="92" font-size="10" fill="#9a3412">projectToChain (수직 내림)</text>
</svg>
<figcaption>그림 12. 측점명 &quot;157K970&quot; → 157970m 환산으로 선로를 만들고, GPS를 수직 투영해 측점값·이격 산출.</figcaption>
</figure>
<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><span class="co">// chainage.ts projectToChain (3550)</span></span>
<span id="cb7-2"><a href="#cb7-2" aria-hidden="true" tabindex="-1"></a>const t <span class="op">=</span> <span class="fu">clamp</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 class="dv">0</span><span class="op">,</span> <span class="dv">1</span>)<span class="op">;</span> <span class="co">// 선분 위 투영 비율</span></span>
<span id="cb7-3"><a href="#cb7-3" aria-hidden="true" tabindex="-1"></a>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">// 보간 측점값</span></span></code></pre></div>
<hr />
<h3 id="7-3-구현-②--이동거리축-핵심-아이디어">7-3. 구현 ② — 이동거리축 (핵심 아이디어)</h3>
<p>시간축 대신 <strong>드론이 실제로 이동한 거리</strong>를 축으로 씁니다. 측점값 변화량 |Δ|을 매 프레임 누적.</p>
<figure class="fig">
<svg viewBox="0 0 680 200" role="img" aria-label="이동거리축">
<!-- timeline of values -->
<text x="40" y="32" font-size="11" fill="#334155">측점값(평활): 920 → 930 → 940 → <tspan fill="#b91c1c">940 940 940</tspan> → 950 → 960</text>
<!-- accumulation bar -->
<text x="40" y="72" font-size="11" font-weight="bold" fill="#155e75">누적 이동거리 = 축 위치</text>
<rect x="40" y="84" width="600" height="26" rx="13" fill="#e5e7eb"></rect>
<!-- run 1 -->
<rect x="40" y="84" width="180" height="26" rx="13" fill="#67e8f9"></rect>
<!-- hover (no growth) -->
<rect x="220" y="84" width="0" height="26" fill="#fca5a5"></rect>
<!-- run 2 -->
<rect x="220" y="84" width="180" height="26" fill="#22d3ee"></rect>
<circle cx="220" cy="97" r="11" fill="#0891b2"></circle>
<text x="220" y="101" text-anchor="middle" font-size="10" fill="#fff"></text>
<text x="130" y="135" text-anchor="middle" font-size="10" fill="#155e75">정상 주행 →→→</text>
<text x="232" y="135" font-size="10" fill="#b91c1c">호버: 누적 멈춤 → 커서 정지!</text>
<text x="540" y="135" text-anchor="middle" font-size="10" fill="#155e75">→→→ 주행</text>
<text x="340" y="172" text-anchor="middle" font-size="11" fill="#555">호버 구간에서 커서가 멈춰 &quot;공중 대기&quot;가 한눈에 보인다</text>
</svg>
<figcaption>그림 13. |Δ측점값|을 누적해 0~1로 정규화 → 축 위치. 호버는 누적이 멈추므로 커서도 정지.</figcaption>
</figure>
<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><span class="co">// StationBar.tsx 233274</span></span>
<span id="cb8-2"><a href="#cb8-2" aria-hidden="true" tabindex="-1"></a>sm[i] <span class="op">=</span> (pre[hi]<span class="op">-</span>pre[lo])<span class="op">/</span>(hi<span class="op">-</span>lo)<span class="op">;</span> <span class="co">// 측점값 ±8프레임 평활</span></span>
<span id="cb8-3"><a href="#cb8-3" aria-hidden="true" tabindex="-1"></a><span class="fu">if</span> (i<span class="op">&gt;</span><span class="dv">0</span>) cum <span class="op">+=</span> <span class="bu">Math</span><span class="op">.</span><span class="fu">abs</span>(sm[i]<span class="op">-</span>sm[i<span class="op">-</span><span class="dv">1</span>])<span class="op">;</span> <span class="co">// |Δ| 누적 = 실제 이동거리</span></span>
<span id="cb8-4"><a href="#cb8-4" aria-hidden="true" tabindex="-1"></a>frac[i] <span class="op">=</span> cum <span class="op">/</span> total<span class="op">;</span> <span class="co">// 0~1 정규화 → 축 위치</span></span></code></pre></div>
<hr />
<h3 id="7-4-측점-기반의-결과--하단-측점바">7-4. 측점 기반의 결과 — 하단 측점바</h3>
<figure class="fig">
<svg viewBox="0 0 680 170" role="img" aria-label="하단 측점바">
<rect x="20" y="30" width="640" height="110" rx="10" fill="#0f172a" stroke="#334155"></rect>
<!-- track -->
<line x1="60" y1="95" x2="520" y2="95" stroke="#22d3ee" stroke-width="5"></line>
<line x1="520" y1="95" x2="620" y2="95" stroke="#475569" stroke-width="5" stroke-dasharray="2 4"></line>
<!-- markers -->
<g font-size="9" fill="#e2e8f0" text-anchor="middle">
<circle cx="60" cy="95" r="7" fill="#fbbf24"></circle><text x="60" y="72">🚉역</text>
<circle cx="170" cy="95" r="6" fill="#f59e0b"></circle><text x="170" y="72">🌉교량</text>
<circle cx="300" cy="95" r="6" fill="#38bdf8"></circle><text x="300" y="72">🚇터널</text>
<circle cx="460" cy="95" r="6" fill="#f59e0b"></circle><text x="460" y="72">🌉구교</text>
<circle cx="620" cy="95" r="7" fill="none" stroke="#94a3b8" stroke-width="2"></circle><text x="620" y="72">◌종점</text></g>
<!-- cursor -->
<polygon points="220,80 212,68 228,68" fill="#fff"></polygon>
<line x1="220" y1="80" x2="220" y2="110" stroke="#fff" stroke-width="2"></line>
<text x="220" y="128" text-anchor="middle" font-size="10" fill="#fff">현재 위치 커서 (60fps)</text>
<text x="572" y="118" font-size="9" fill="#94a3b8">회색=미도착</text>
</svg>
<figcaption>그림 14. 측점·구조물을 공간축에 배치. 영상별 FPS 자동산출로 배지 정밀화 · 종점 미도착은 회색 + 빈 링.</figcaption>
</figure>
<hr />
<h3 id="7-5-프레임--측점--한-장-비교">7-5. 프레임 ↔︎ 측점 — 한 장 비교</h3>
<table>
<thead>
<tr class="header">
<th>구분</th>
<th>프레임 기반</th>
<th>측점 기반 (현재)</th>
</tr>
</thead>
<tbody>
<tr class="odd">
<td></td>
<td>시간(초)</td>
<td>실제 이동거리/측점(km)</td>
</tr>
<tr class="even">
<td>호버</td>
<td>구분 불가</td>
<td>커서 정지로 가시화</td>
</tr>
<tr class="odd">
<td>현장 용어</td>
<td>&quot;3분 20초&quot;</td>
<td>&quot;157K970 교량&quot;</td>
</tr>
<tr class="even">
<td>위치 질의</td>
<td>어려움</td>
<td>GPS→측점 투영으로 즉답</td>
</tr>
<tr class="odd">
<td>동기화</td>
<td>currentTime</td>
<td>smoothTimeRef + 측점투영</td>
</tr>
</tbody>
</table>
<p><strong>시간축 영상을 공간축(측점)으로 재해석</strong>한 것이 이 프로그램의 정체성.</p>
<hr />
<h2 id="8-정확도-보정-도구">8. 정확도 보정 도구</h2>
<p>투영은 카메라 파라미터·표고 가정에 민감 → <strong>사용자가 미세보정</strong>할 수 있게 함. 핵심은 투영의 <strong>역방향</strong>(화면→세계)을 푸는 것.</p>
<figure class="fig">
<svg viewBox="0 0 680 210" role="img" aria-label="보정 도구">
<defs><marker id="m8" markerWidth="10" markerHeight="10" refX="7" refY="3.5" orient="auto"><path d="M0,0 L7,3.5 L0,7 Z" fill="#7c3aed"></path></marker></defs>
<!-- screen -->
<rect x="40" y="40" width="220" height="130" rx="6" fill="#0b1020" stroke="#334155"></rect>
<text x="150" y="32" text-anchor="middle" font-size="11" fill="#555">영상 화면</text>
<circle cx="120" cy="80" r="6" fill="#f59e0b"></circle>
<text x="60" y="105" font-size="10" fill="#fde68a">라벨을 끌어 제자리로</text>
<!-- ground -->
<line x1="320" y1="175" x2="650" y2="175" stroke="#8b5e34" stroke-width="3"></line>
<text x="320" y="193" font-size="10" fill="#7c5a3a">실제 땅</text>
<line x1="126" y1="82" x2="540" y2="170" stroke="#7c3aed" stroke-width="2" stroke-dasharray="5 4" marker-end="url(#m8)"></line>
<circle cx="540" cy="170" r="6" fill="#7c3aed"></circle>
<text x="470" y="160" font-size="11" font-weight="bold" fill="#6d28d9">위경도 역산</text>
<text x="300" y="60" font-size="11" fill="#6d28d9">화면점 → 광선 → 지면 교차 → 실제 위치</text>
<text x="300" y="80" font-size="10" fill="#6d28d9">groundPointFromPixel</text>
</svg>
<figcaption>그림 15. &quot;끌어다 맞추기&quot; = 역투영. 끈 화면점을 광선으로 쏴 지면과 만나는 실제 좌표를 복원.</figcaption>
</figure>
<table>
<thead>
<tr class="header">
<th>도구</th>
<th>동작</th>
</tr>
</thead>
<tbody>
<tr class="odd">
<td><strong>DEM 자동표고</strong></td>
<td>모든 POI를 실제 지형고도로 (서버 <code>/api/elevation</code>, SRTM 30m)</td>
</tr>
<tr class="even">
<td><strong>드래그 편집</strong></td>
<td>라벨을 끌면 <code>groundPointFromPixel</code>로 위경도 역산</td>
</tr>
<tr class="odd">
<td><strong>세로화각 보정</strong></td>
<td>POI를 실제 위치로 끌면 <code>sensorH</code>만 역산 자동보정</td>
</tr>
<tr class="even">
<td><strong>보정값 저장</strong></td>
<td><code>poi_overrides.json</code> 내보내기/가져오기</td>
</tr>
</tbody>
</table>
<hr />
<h2 id="9-정리--무엇을-어떻게">9. 정리 — 무엇을, 어떻게</h2>
<figure class="fig">
<svg viewBox="0 0 680 260" role="img" aria-label="전체 정리">
<defs><marker id="m9" markerWidth="10" markerHeight="10" refX="7" refY="3.5" orient="auto"><path d="M0,0 L7,3.5 L0,7 Z" fill="#64748b"></path></marker></defs>
<!-- inputs -->
<g text-anchor="middle">
<rect x="20" y="40" width="120" height="34" rx="8" fill="#f5f3ff" stroke="#7c3aed"></rect><text x="80" y="62" font-size="11" fill="#5b21b6">드론 GPS</text>
<rect x="20" y="100" width="120" height="34" rx="8" fill="#eff6ff" stroke="#2563eb"></rect><text x="80" y="122" font-size="11" fill="#1e3a8a">영상</text>
<rect x="20" y="160" width="120" height="34" rx="8" fill="#ecfdf5" stroke="#16a34a"></rect><text x="80" y="182" font-size="11" fill="#14532d">POI 좌표</text>
</g>
<!-- stages -->
<g text-anchor="middle">
<rect x="200" y="40" width="130" height="60" rx="8" fill="#ecfdf5" stroke="#16a34a"></rect><text x="265" y="64" font-size="11" font-weight="bold" fill="#14532d">🟩 시간 동기화</text><text x="265" y="82" font-size="9" fill="#166534">smoothTimeRef</text><text x="265" y="95" font-size="9" fill="#166534">60fps 보간</text>
<rect x="200" y="110" width="130" height="74" rx="8" fill="#eff6ff" stroke="#2563eb"></rect><text x="265" y="132" font-size="11" font-weight="bold" fill="#1e3a8a">🟦 좌표 투영</text><text x="265" y="150" font-size="9" fill="#1e40af">ENU→카메라</text><text x="265" y="163" font-size="9" fill="#1e40af">→핀홀→cover</text><text x="265" y="176" font-size="9" fill="#1e40af">+geoid 보정</text>
<rect x="380" y="75" width="120" height="60" rx="8" fill="#fff7ed" stroke="#d97706"></rect><text x="440" y="99" font-size="11" font-weight="bold" fill="#9a3412">🟧 평활</text><text x="440" y="117" font-size="9" fill="#9a3412">One Euro +</text><text x="440" y="129" font-size="9" fill="#9a3412">이상치 거부</text>
<rect x="540" y="75" width="120" height="60" rx="8" fill="#fff" stroke="#23272e" stroke-width="1.5"></rect><text x="600" y="99" font-size="11" font-weight="bold" fill="#23272e">영상 위</text><text x="600" y="117" font-size="11" font-weight="bold" fill="#23272e">정확한 라벨✨</text>
<rect x="380" y="170" width="280" height="40" rx="8" fill="#ecfeff" stroke="#0891b2"></rect><text x="520" y="188" font-size="11" font-weight="bold" fill="#155e75">🟦 GPS→측점 투영 → 이동거리축</text><text x="520" y="202" font-size="9" fill="#155e75">→ 측점 기반 측점바 (호버까지 표현)</text>
</g>
<!-- arrows -->
<line x1="140" y1="70" x2="198" y2="70" stroke="#64748b" stroke-width="1.4" marker-end="url(#m9)"></line>
<line x1="140" y1="117" x2="198" y2="135" stroke="#64748b" stroke-width="1.4" marker-end="url(#m9)"></line>
<line x1="140" y1="177" x2="198" y2="155" stroke="#64748b" stroke-width="1.4" marker-end="url(#m9)"></line>
<line x1="330" y1="120" x2="378" y2="105" stroke="#64748b" stroke-width="1.4" marker-end="url(#m9)"></line>
<line x1="500" y1="105" x2="538" y2="105" stroke="#64748b" stroke-width="1.4" marker-end="url(#m9)"></line>
<line x1="265" y1="184" x2="265" y2="195" stroke="#0891b2" stroke-width="1.4"></line>
<line x1="265" y1="195" x2="378" y2="190" stroke="#0891b2" stroke-width="1.4" marker-end="url(#m9)"></line>
</svg>
<figcaption>그림 16. 3대 정합(공간·시간·안정) + 패러다임 전환(프레임→측점).</figcaption>
</figure>
<p><strong>3대 정합 기술</strong></p>
<ol type="1">
<li><strong>🟦 공간</strong>: 4단계 투영(ENU→카메라회전→핀홀→cover) + 지오이드 보정 + 역투영 보정</li>
<li><strong>🟩 시간</strong>: smoothTimeRef 60fps 단조보간으로 영상시각↔︎드론프레임 정합</li>
<li><strong>🟧 안정</strong>: One Euro 속도적응 평활 + 이상치 거부</li>
</ol>
<p><strong>패러다임 전환</strong></p>
<ul>
<li>프레임(시간) 기반 → <strong>측점(공간) 기반</strong>: GPS를 선로에 투영해 측점값화, 이동거리축으로 호버까지 표현</li>
</ul>
<hr />
<h2 id="부록--핵심-소스-색인">부록 — 핵심 소스 색인</h2>
<table>
<thead>
<tr class="header">
<th>기술</th>
<th>파일</th>
<th>라인</th>
</tr>
</thead>
<tbody>
<tr class="odd">
<td>ENU 변환</td>
<td>geoProjection.ts</td>
<td>9197</td>
</tr>
<tr class="even">
<td>카메라 좌표 + fwd/side</td>
<td>geoProjection.ts</td>
<td>301317</td>
</tr>
<tr class="odd">
<td>핀홀 투영</td>
<td>geoProjection.ts</td>
<td>126137</td>
</tr>
<tr class="even">
<td>지오이드 보정</td>
<td>geoProjection.ts</td>
<td>5367, projectPoint</td>
</tr>
<tr class="odd">
<td>역투영(보정)</td>
<td>geoProjection.ts</td>
<td>149251</td>
</tr>
<tr class="even">
<td>object-fit cover 정렬</td>
<td>StationOverlay.tsx</td>
<td>921930</td>
</tr>
<tr class="odd">
<td>라벨 평활/이상치</td>
<td>StationOverlay.tsx</td>
<td>4861</td>
</tr>
<tr class="even">
<td>RAF 60fps 렌더</td>
<td>StationOverlay.tsx</td>
<td>9051198</td>
</tr>
<tr class="odd">
<td>smoothTimeRef</td>
<td>VideoPlayer.tsx</td>
<td>62117</td>
</tr>
<tr class="even">
<td>측점 투영(chainage)</td>
<td>chainage.ts</td>
<td>1064</td>
</tr>
<tr class="odd">
<td>이동거리축</td>
<td>StationBar.tsx</td>
<td>233274</td>
</tr>
<tr class="even">
<td>종점역 미도착</td>
<td>StationBar.tsx</td>
<td>300310, 574586</td>
</tr>
<tr class="odd">
<td>DEM 표고</td>
<td>StationOverlay.tsx / elevation.ts</td>
<td>13841415 / 1467</td>
</tr>
</tbody>
</table>
<blockquote>
<p>상세 구현은 <a href="구현상세_GhiVideo_기술-소스코드매칭.md">구현상세_GhiVideo_기술-소스코드매칭.md</a> 참조.</p>
</blockquote>
</body>
</html>
@@ -0,0 +1,634 @@
# GhiVideo 발표 자료
## 드론 GPS · 영상 · POI 좌표를 영상 위 정확한 위치에 — 그리고 프레임에서 측점(스테이션)으로
> 작성일: 2026-06-30 · 발표용 요약 문서
> 슬라이드 구분은 `---` 입니다 (Marp/reveal.js 변환 가능). 각 장은 "그림 → 핵심 → 코드 근거" 순.
<style>
figure.fig{margin:18px 0;text-align:center;page-break-inside:avoid;break-inside:avoid;}
figure.fig svg{width:100%;max-width:680px;height:auto;border:1px solid #e7e0d2;border-radius:10px;background:#fffdf8;}
figure.fig figcaption{font-size:0.88em;color:#777;margin-top:6px;}
text{font-family:'Malgun Gothic','Hancom Gothic',sans-serif;}
h2{border-bottom:2px solid #e7e0d2;padding-bottom:4px;}
.lead{background:#f8f6ef;border-left:4px solid #b45309;padding:10px 14px;border-radius:6px;}
</style>
---
## 목차
| 🟦 공간 정합 | 🟩 시간 동기화 | 🟧 표시 안정 | 🟦 측점 전환 |
|---|---|---|---|
| 지도좌표 → 화면픽셀 | 영상시각 ↔ 드론프레임 | 떨림·튐 제거 | 프레임 → 측점(공간) |
1. 우리가 풀어야 했던 문제
2. 입력 데이터 3종 — GPS · 영상 · POI
3. 전체 파이프라인 한 장
4. **핵심 ①** 좌표 정합 — 지도 좌표를 화면 픽셀로 (투영)
5. **핵심 ②** 시간 동기화 — 영상 시각과 드론 프레임 맞추기
6. **핵심 ③** 라벨 안정화 — 떨림·튐 제거
7. **핵심 ④** 프레임 기반 → 측점(스테이션) 기반 전환
8. 정확도 보정 도구 (DEM · 드래그 · 세로화각)
9. 정리 — 무엇을, 어떻게
---
## 1. 우리가 풀어야 했던 문제
<p class="lead">드론이 철도 노선을 따라 비행하며 찍은 영상 위에, <b>교량·터널·역사·지장물(POI)의 이름표를 실제 위치에 정확히</b> 띄우고 싶다.</p>
<figure class="fig">
<svg viewBox="0 0 680 300" role="img" aria-label="드론 영상 위 라벨 예시">
<!-- video frame -->
<rect x="20" y="20" width="640" height="230" rx="10" fill="#0b1020" stroke="#334155" stroke-width="2"/>
<!-- sky gradient -->
<defs>
<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#1e3a5f"/><stop offset="1" stop-color="#0b1020"/></linearGradient>
<marker id="m1" markerWidth="9" markerHeight="9" refX="6" refY="3" orient="auto"><path d="M0,0 L6,3 L0,6 Z" fill="#f59e0b"/></marker>
</defs>
<rect x="22" y="22" width="636" height="120" fill="url(#sky)"/>
<!-- rails -->
<polygon points="250,250 430,250 360,120 320,120" fill="#1c2533" stroke="#475569"/>
<line x1="285" y1="250" x2="338" y2="120" stroke="#94a3b8" stroke-width="2"/>
<line x1="395" y1="250" x2="342" y2="120" stroke="#94a3b8" stroke-width="2"/>
<g stroke="#64748b" stroke-width="1.5">
<line x1="300" y1="210" x2="380" y2="210"/><line x1="312" y1="180" x2="368" y2="180"/><line x1="322" y1="155" x2="358" y2="155"/></g>
<!-- bridge label -->
<circle cx="175" cy="120" r="5" fill="#f59e0b"/>
<rect x="120" y="78" width="110" height="26" rx="5" fill="#f59e0b"/>
<text x="175" y="96" text-anchor="middle" font-size="13" font-weight="bold" fill="#1a1303">🌉 회덕제1가도교</text>
<line x1="175" y1="104" x2="175" y2="116" stroke="#f59e0b" stroke-width="1.5" marker-end="url(#m1)"/>
<!-- tunnel label -->
<circle cx="500" cy="120" r="5" fill="#38bdf8"/>
<rect x="455" y="78" width="92" height="26" rx="5" fill="#38bdf8"/>
<text x="501" y="96" text-anchor="middle" font-size="13" font-weight="bold" fill="#06283d">🚇 법동터널</text>
<line x1="501" y1="104" x2="501" y2="116" stroke="#38bdf8" stroke-width="1.5"/>
<!-- HUD -->
<rect x="34" y="214" width="320" height="24" rx="5" fill="#000" opacity="0.55"/>
<text x="44" y="231" font-size="12" fill="#e2e8f0">GPS 36.334, 127.456 · 고도 123m · 측점 157K970</text>
<!-- compass -->
<circle cx="615" cy="60" r="24" fill="#0b1020" stroke="#64748b"/>
<polygon points="615,42 609,60 621,60" fill="#f87171"/><text x="615" y="38" text-anchor="middle" font-size="9" fill="#f87171">N</text>
<text x="340" y="278" text-anchor="middle" font-size="12" fill="#555">드론 영상 + 실제 위치에 붙는 이름표 + 위치 HUD</text>
</svg>
<figcaption>그림 1. 목표 — 흔들리는 드론 영상 위에, 지도 좌표만 가진 POI를 "진짜 그 자리"에 표시.</figcaption>
</figure>
**난이도 4가지** → 그래서 **세 가지 정합**이 필요합니다.
| 난이도 | 무엇이 어려운가 | 해결 정합 |
|---|---|---|
| 드론 흔들림 | yaw/pitch/roll·고도 변화·빠른 이동 | 🟦 공간 + 🟧 안정 |
| POI는 지도좌표만 | "화면 어디"인지 모름 | 🟦 공간(투영) |
| 시간 어긋남 | 영상 시각 ↔ 드론 데이터 시각 | 🟩 시간 |
| 매끄러움 | 60fps·떨림 없이 | 🟧 안정 |
---
## 2. 입력 데이터 3종
<figure class="fig">
<svg viewBox="0 0 680 270" role="img" aria-label="입력 3종 결합">
<defs><marker id="m2" markerWidth="10" markerHeight="10" refX="7" refY="3.5" orient="auto"><path d="M0,0 L7,3.5 L0,7 Z" fill="#b45309"/></marker></defs>
<!-- 1 drone log -->
<rect x="30" y="30" width="170" height="120" rx="10" fill="#f5f3ff" stroke="#7c3aed" stroke-width="1.5"/>
<text x="115" y="54" text-anchor="middle" font-size="14" font-weight="bold" fill="#5b21b6">① 드론 로그</text>
<text x="115" y="74" text-anchor="middle" font-size="11" fill="#6d28d9">프레임별 비행 CSV</text>
<g font-size="10" fill="#4c1d95" font-family="monospace">
<text x="46" y="98">frame lat yaw</text>
<text x="46" y="114"> 0 36.33 12°</text>
<text x="46" y="130"> 1 36.33 13°</text>
<text x="46" y="146"> … alt·pitch·roll</text></g>
<!-- 2 video -->
<rect x="255" y="30" width="170" height="120" rx="10" fill="#eff6ff" stroke="#2563eb" stroke-width="1.5"/>
<text x="340" y="54" text-anchor="middle" font-size="14" font-weight="bold" fill="#1e3a8a">② 영상</text>
<text x="340" y="74" text-anchor="middle" font-size="11" fill="#1d4ed8">mp4/webm 주행영상</text>
<rect x="295" y="86" width="90" height="50" rx="4" fill="#0b1020"/>
<polygon points="332,100 332,122 352,111" fill="#fff"/>
<!-- 3 poi -->
<rect x="480" y="30" width="170" height="120" rx="10" fill="#ecfdf5" stroke="#16a34a" stroke-width="1.5"/>
<text x="565" y="54" text-anchor="middle" font-size="14" font-weight="bold" fill="#14532d">③ POI / 측점</text>
<text x="565" y="74" text-anchor="middle" font-size="11" fill="#15803d">KMZ(1순위)+측점CSV</text>
<g font-size="10" fill="#166534" font-family="monospace">
<text x="496" y="98">🌉 교량 (lat,lon,z)</text>
<text x="496" y="114">🚇 터널 (lat,lon,z)</text>
<text x="496" y="130">📍 측점 157K970</text></g>
<!-- merge -->
<line x1="115" y1="150" x2="320" y2="195" stroke="#b45309" stroke-width="1.5" marker-end="url(#m2)"/>
<line x1="340" y1="150" x2="340" y2="195" stroke="#b45309" stroke-width="1.5" marker-end="url(#m2)"/>
<line x1="565" y1="150" x2="360" y2="195" stroke="#b45309" stroke-width="1.5" marker-end="url(#m2)"/>
<rect x="200" y="205" width="280" height="40" rx="20" fill="#fff7e6" stroke="#f59e0b" stroke-width="2"/>
<text x="340" y="230" text-anchor="middle" font-size="14" font-weight="bold" fill="#b45309">이 셋을 시간·공간으로 묶는다</text>
</svg>
<figcaption>그림 2. 드론 로그(언제·어디·어느 방향) + 영상 + 지도 POI를 결합.</figcaption>
</figure>
---
## 3. 전체 파이프라인 한 장
<figure class="fig">
<svg viewBox="0 0 680 420" role="img" aria-label="전체 파이프라인">
<defs><marker id="m3" markerWidth="10" markerHeight="10" refX="7" refY="3.5" orient="auto"><path d="M0,0 L7,3.5 L0,7 Z" fill="#64748b"/></marker></defs>
<!-- lane labels -->
<rect x="14" y="14" width="14" height="392" rx="4" fill="#16a34a" opacity="0.15"/>
<!-- step: time -->
<rect x="180" y="20" width="320" height="46" rx="8" fill="#ecfdf5" stroke="#16a34a" stroke-width="1.5"/>
<text x="340" y="40" text-anchor="middle" font-size="13" font-weight="bold" fill="#14532d">🟩 영상 재생 시각 t</text>
<text x="340" y="58" text-anchor="middle" font-size="11" fill="#15803d">smoothTimeRef: 60fps 단조보간</text>
<line x1="340" y1="66" x2="340" y2="84" stroke="#64748b" stroke-width="1.6" marker-end="url(#m3)"/>
<rect x="180" y="86" width="320" height="40" rx="8" fill="#ecfdf5" stroke="#16a34a" stroke-width="1.2"/>
<text x="340" y="111" text-anchor="middle" font-size="12" fill="#166534">t → 드론 프레임 보간 → 그 순간의 위치·자세(pose)</text>
<line x1="340" y1="126" x2="340" y2="142" stroke="#64748b" stroke-width="1.6" marker-end="url(#m3)"/>
<!-- projection box -->
<rect x="120" y="144" width="440" height="120" rx="10" fill="#eff6ff" stroke="#2563eb" stroke-width="1.8"/>
<text x="340" y="166" text-anchor="middle" font-size="13" font-weight="bold" fill="#1e3a8a">🟦 좌표 정합 (투영) — 4단계</text>
<g font-size="12" fill="#1e40af">
<text x="340" y="190" text-anchor="middle">POI(위경도·표고) → ENU(평면 미터)</text>
<text x="340" y="212" text-anchor="middle">→ 카메라 좌표 (yaw/pitch/roll 회전)</text>
<text x="340" y="234" text-anchor="middle">→ 화면 정규픽셀(0~1) [핀홀 카메라]</text>
<text x="340" y="256" text-anchor="middle">→ object-fit:cover 보정 → 실제 화면 px</text></g>
<line x1="340" y1="264" x2="340" y2="280" stroke="#64748b" stroke-width="1.6" marker-end="url(#m3)"/>
<!-- smoothing -->
<rect x="180" y="282" width="320" height="40" rx="8" fill="#fff7ed" stroke="#d97706" stroke-width="1.5"/>
<text x="340" y="307" text-anchor="middle" font-size="12" fill="#9a3412">🟧 라벨 평활(One Euro) + 이상치 거부 → 떨림 제거</text>
<line x1="340" y1="322" x2="340" y2="338" stroke="#64748b" stroke-width="1.6" marker-end="url(#m3)"/>
<!-- render -->
<rect x="180" y="340" width="320" height="40" rx="8" fill="#fff" stroke="#23272e" stroke-width="1.5"/>
<text x="340" y="365" text-anchor="middle" font-size="12" font-weight="bold" fill="#23272e">Canvas RAF 60fps 렌더 → 영상 위 이름표</text>
<!-- station branch -->
<line x1="500" y1="360" x2="600" y2="360" stroke="#0891b2" stroke-width="1.6"/>
<line x1="600" y1="360" x2="600" y2="395" stroke="#0891b2" stroke-width="1.6"/>
<rect x="430" y="392" width="240" height="22" rx="6" fill="#ecfeff" stroke="#0891b2"/>
<text x="550" y="407" text-anchor="middle" font-size="10.5" fill="#155e75">🟦 GPS→측점 투영 → 하단 측점바 커서</text>
</svg>
<figcaption>그림 3. 시각 t → pose 보간 → 4단계 투영 → 평활 → 렌더. 동시에 GPS는 측점값으로 투영되어 측점바로.</figcaption>
</figure>
---
## 4. 핵심 ① 좌표 정합 — 지도 좌표를 화면 픽셀로
<p class="lead">"POI는 위경도만 안다. 그게 <b>지금 화면 어디</b>에 보이는가?" — 이게 이 프로그램의 심장. <b>4단계 투영</b>으로 푼다.</p>
<figure class="fig">
<svg viewBox="0 0 680 150" role="img" aria-label="4단계 투영 흐름">
<defs><marker id="m4" markerWidth="10" markerHeight="10" refX="7" refY="3.5" orient="auto"><path d="M0,0 L7,3.5 L0,7 Z" fill="#2563eb"/></marker></defs>
<g text-anchor="middle">
<rect x="14" y="50" width="110" height="54" rx="8" fill="#ecfdf5" stroke="#16a34a"/><text x="69" y="74" font-size="12" font-weight="bold" fill="#14532d">위경도+표고</text><text x="69" y="92" font-size="10" fill="#166534">POI 🌉 (지도)</text>
<rect x="150" y="50" width="110" height="54" rx="8" fill="#eff6ff" stroke="#2563eb"/><text x="205" y="72" font-size="12" font-weight="bold" fill="#1e3a8a">평면 미터</text><text x="205" y="90" font-size="10" fill="#1d4ed8">ENU · 1단계</text>
<rect x="286" y="50" width="110" height="54" rx="8" fill="#eff6ff" stroke="#2563eb"/><text x="341" y="72" font-size="12" font-weight="bold" fill="#1e3a8a">카메라 좌표</text><text x="341" y="90" font-size="10" fill="#1d4ed8">회전 · 2단계</text>
<rect x="422" y="50" width="110" height="54" rx="8" fill="#eff6ff" stroke="#2563eb"/><text x="477" y="72" font-size="12" font-weight="bold" fill="#1e3a8a">화면 0~1</text><text x="477" y="90" font-size="10" fill="#1d4ed8">핀홀 · 3단계</text>
<rect x="558" y="50" width="108" height="54" rx="8" fill="#fff" stroke="#23272e" stroke-width="1.5"/><text x="612" y="72" font-size="12" font-weight="bold" fill="#23272e">화면 px</text><text x="612" y="90" font-size="10" fill="#555">cover · 4단계</text>
</g>
<line x1="124" y1="77" x2="148" y2="77" stroke="#2563eb" stroke-width="1.5" marker-end="url(#m4)"/>
<line x1="260" y1="77" x2="284" y2="77" stroke="#2563eb" stroke-width="1.5" marker-end="url(#m4)"/>
<line x1="396" y1="77" x2="420" y2="77" stroke="#2563eb" stroke-width="1.5" marker-end="url(#m4)"/>
<line x1="532" y1="77" x2="556" y2="77" stroke="#2563eb" stroke-width="1.5" marker-end="url(#m4)"/>
</svg>
<figcaption>그림 4. 4단계 투영 파이프라인. 파일: client/src/utils/geoProjection.ts</figcaption>
</figure>
---
### 4-1. 둥근 지구를 평평하게 (ENU 변환)
지구는 둥글어 거리 계산이 어렵습니다. 노선 한 구간만 잘라 **평평한 모눈종이(미터 단위)** 로 폅니다.
<figure class="fig">
<svg viewBox="0 0 680 200" role="img" aria-label="ENU 변환">
<defs><marker id="m41" markerWidth="9" markerHeight="9" refX="6" refY="3" orient="auto"><path d="M0,0 L6,3 L0,6 Z" fill="#6b7280"/></marker></defs>
<!-- globe -->
<circle cx="110" cy="100" r="55" fill="#dbeafe" stroke="#2563eb"/>
<ellipse cx="110" cy="100" rx="55" ry="20" fill="none" stroke="#93c5fd"/>
<ellipse cx="110" cy="100" rx="20" ry="55" fill="none" stroke="#93c5fd"/>
<circle cx="126" cy="80" r="5" fill="#dc2626"/>
<text x="110" y="175" text-anchor="middle" font-size="11" fill="#1e3a8a">둥근 지구 (위경도)</text>
<text x="235" y="95" font-size="13" fill="#166534" font-weight="bold">EPSG:5186</text>
<text x="235" y="112" font-size="11" fill="#166534">TM 투영</text>
<line x1="172" y1="100" x2="318" y2="100" stroke="#16a34a" stroke-width="2.5" marker-end="url(#m41)"/>
<!-- grid -->
<g stroke="#e5e7eb"><line x1="370" y1="40" x2="370" y2="170"/><line x1="430" y1="40" x2="430" y2="170"/><line x1="490" y1="40" x2="490" y2="170"/><line x1="550" y1="40" x2="550" y2="170"/><line x1="610" y1="40" x2="610" y2="170"/>
<line x1="350" y1="60" x2="650" y2="60"/><line x1="350" y1="100" x2="650" y2="100"/><line x1="350" y1="140" x2="650" y2="140"/></g>
<line x1="370" y1="160" x2="650" y2="160" stroke="#6b7280" stroke-width="2" marker-end="url(#m41)"/>
<line x1="370" y1="160" x2="370" y2="40" stroke="#6b7280" stroke-width="2" marker-end="url(#m41)"/>
<text x="635" y="178" font-size="11" fill="#6b7280">E 동(m)</text>
<text x="345" y="48" font-size="11" fill="#6b7280">N 북(m)</text>
<circle cx="370" cy="160" r="4" fill="#16a34a"/><text x="376" y="176" font-size="10" fill="#14532d">기준점(0,0,0)</text>
<circle cx="490" cy="76" r="6" fill="#b45309"/>
<text x="500" y="72" font-size="11" font-weight="bold" fill="#b45309">POI (E=120, N=300, U=-5)</text>
</svg>
<figcaption>그림 5. E/N = 동·북 몇 m, U = 기준점 대비 상대 높이(alt refAlt).</figcaption>
</figure>
```ts
// geoToEnu (9197)
const [e, n] = latLonToTM(lat, lon); // 위경도 → 평면 미터
return [e, n, alt - refAlt];
```
---
### 4-2. 드론이 보는 방향으로 회전 (카메라 좌표)
세상 좌표(E/N/U)를 드론의 **yaw·pitch·roll** 회전행렬에 곱해 "카메라가 보는 좌표(Xc, Yc, Zc)"로 바꿉니다.
<figure class="fig">
<svg viewBox="0 0 680 210" role="img" aria-label="세상→카메라 회전">
<defs><marker id="m42" markerWidth="10" markerHeight="10" refX="7" refY="3.5" orient="auto"><path d="M0,0 L7,3.5 L0,7 Z" fill="#7c3aed"/></marker></defs>
<!-- world frame -->
<text x="150" y="30" text-anchor="middle" font-size="12" font-weight="bold" fill="#334155">세상 기준 (북 고정)</text>
<line x1="80" y1="150" x2="240" y2="150" stroke="#6b7280" stroke-width="1.6"/><text x="248" y="154" font-size="11" fill="#6b7280">E</text>
<line x1="150" y1="170" x2="150" y2="60" stroke="#6b7280" stroke-width="1.6"/><text x="156" y="62" font-size="11" fill="#6b7280">N</text>
<circle cx="150" cy="150" r="8" fill="#7c3aed"/><text x="150" y="186" text-anchor="middle" font-size="10" fill="#5b21b6">🚁 북쪽 봄</text>
<circle cx="210" cy="95" r="5" fill="#b45309"/><text x="218" y="92" font-size="10" fill="#b45309">POI</text>
<!-- arrow -->
<line x1="280" y1="120" x2="370" y2="120" stroke="#7c3aed" stroke-width="2.5" marker-end="url(#m42)"/>
<text x="325" y="110" text-anchor="middle" font-size="11" fill="#5b21b6">회전</text>
<!-- camera frame -->
<text x="520" y="30" text-anchor="middle" font-size="12" font-weight="bold" fill="#334155">드론이 보는 기준 (정면이 기준)</text>
<circle cx="450" cy="150" r="8" fill="#7c3aed"/><text x="450" y="186" text-anchor="middle" font-size="10" fill="#5b21b6">🚁</text>
<line x1="450" y1="150" x2="610" y2="80" stroke="#16a34a" stroke-width="1.8" marker-end="url(#m42)"/><text x="612" y="78" font-size="11" fill="#15803d">앞(Zc)</text>
<line x1="450" y1="150" x2="600" y2="160" stroke="#0891b2" stroke-width="1.8" marker-end="url(#m42)"/><text x="604" y="166" font-size="11" fill="#155e75">옆(side)</text>
<circle cx="556" cy="108" r="5" fill="#b45309"/><text x="500" y="100" font-size="10" fill="#b45309">POI (앞 50m, 옆 8m)</text>
</svg>
<figcaption>그림 6. 북 기준 좌표를 "드론 정면 기준"으로 회전. 진행방향 거리 fwd(앞)·side(옆)도 함께 산출.</figcaption>
</figure>
```ts
// toCameraCoords (301317)
cc.fwd = relEnu[0]*sy + relEnu[1]*cy; // +면 앞쪽
cc.side = relEnu[0]*cy - relEnu[1]*sy; // +면 오른쪽 → "앞은 멀리, 옆은 가깝게" 비등방 필터 근거
```
---
### 4-3. 3D를 납작한 사진으로 (핀홀 카메라 투영)
핵심 한 줄: **깊이(Zc)로 나눈다 → 멀수록 화면 가운데로 작게.** (바늘구멍 사진기 원리)
<figure class="fig">
<svg viewBox="0 0 680 210" role="img" aria-label="핀홀 카메라">
<!-- pinhole -->
<rect x="330" y="55" width="16" height="100" fill="#475569"/>
<circle cx="338" cy="105" r="6" fill="#fffdf8" stroke="#475569"/>
<text x="338" y="172" text-anchor="middle" font-size="10" fill="#475569">구멍(렌즈)</text>
<!-- near big object -->
<line x1="120" y1="55" x2="120" y2="155" stroke="#16a34a" stroke-width="6"/>
<text x="120" y="44" text-anchor="middle" font-size="10" fill="#14532d">가까운 🌉</text>
<!-- far small object -->
<line x1="40" y1="85" x2="40" y2="125" stroke="#22c55e" stroke-width="6"/>
<text x="40" y="74" text-anchor="middle" font-size="10" fill="#15803d">먼 🌉</text>
<!-- rays through pinhole to screen -->
<line x1="120" y1="55" x2="338" y2="105" stroke="#16a34a" stroke-width="1" stroke-dasharray="3 3"/>
<line x1="120" y1="155" x2="338" y2="105" stroke="#16a34a" stroke-width="1" stroke-dasharray="3 3"/>
<line x1="338" y1="105" x2="470" y2="135" stroke="#16a34a" stroke-width="1" stroke-dasharray="3 3"/>
<line x1="338" y1="105" x2="470" y2="75" stroke="#16a34a" stroke-width="1" stroke-dasharray="3 3"/>
<!-- screen -->
<rect x="468" y="50" width="150" height="110" rx="6" fill="#0b1020" stroke="#334155"/>
<text x="543" y="44" text-anchor="middle" font-size="10" fill="#555">화면(0~1)</text>
<line x1="495" y1="75" x2="495" y2="135" stroke="#4ade80" stroke-width="5"/>
<text x="555" y="110" font-size="10" fill="#86efac">가까운 게 크게 맺힘</text>
<text x="338" y="200" text-anchor="middle" font-size="11" fill="#555">거리(Zc)가 클수록 → 화면에 작게</text>
</svg>
<figcaption>그림 7. pxRaw = 0.5 + (Xc/Zc)·(f/sensorW). 0.5=정중앙, f/sensor=화각.</figcaption>
</figure>
```ts
// pixelFromCamera (126137)
pxRaw = (0.5 + cx0) + (Xc / Zc) * (f / sW); // 가로 0~1
pyRaw = (0.5 + cy0) + (Yc / Zc) * (f / sH); // 세로 0~1
```
---
### 4-4. 높이 기준 통일 (지오이드 보정) + 화면 정렬
**두 개의 '0층' 문제**: 지도 높이(정표고·해발)와 GPS 높이(타원체고)는 기준이 달라 대전 기준 약 **25.8m** 차이. 안 맞추면 라벨이 위아래로 어긋납니다.
<figure class="fig">
<svg viewBox="0 0 680 180" role="img" aria-label="지오이드 보정">
<defs><marker id="m44a" markerWidth="9" markerHeight="9" refX="3" refY="3" orient="auto"><path d="M0,0 L6,3 L0,6 Z" fill="#dc2626"/></marker><marker id="m44b" markerWidth="9" markerHeight="9" refX="3" refY="3" orient="auto"><path d="M6,0 L0,3 L6,6 Z" fill="#dc2626"/></marker></defs>
<line x1="60" y1="50" x2="500" y2="50" stroke="#2563eb" stroke-width="2.5" stroke-dasharray="7 4"/>
<text x="510" y="54" font-size="12" fill="#1e3a8a">타원체고(GPS) 0</text>
<line x1="60" y1="110" x2="500" y2="110" stroke="#0ea5e9" stroke-width="2.5" stroke-dasharray="7 4"/>
<text x="510" y="114" font-size="12" fill="#0369a1">정표고(해발) 0</text>
<line x1="150" y1="51" x2="150" y2="109" stroke="#dc2626" stroke-width="1.6" marker-start="url(#m44a)" marker-end="url(#m44b)"/>
<text x="160" y="84" font-size="13" font-weight="bold" fill="#dc2626">≈ 25.8m (geoidOffset)</text>
<text x="160" y="100" font-size="10" fill="#b91c1c">지도 높이에 더해 GPS 기준으로 통일</text>
<text x="280" y="150" text-anchor="middle" font-size="11" fill="#555">기준을 맞춰야 라벨이 제 높이에 붙는다</text>
</svg>
<figcaption>그림 8. 지도 표고 + geoidOffset → GPS와 같은 기준. 마지막에 object-fit:cover 잘림을 반영해 0~1 → 실제 px.</figcaption>
</figure>
```ts
geoToEnu(lat, lon, targetAlt + params.geoidOffset, ...); // 높이 기준 통일
// 이후 coverRef(StationOverlay 921930)로 정규(0~1) → 실제 화면 px 정렬
```
➡ 4-1 ~ 4-4를 거치면 **POI가 영상 속 진짜 그 자리에** 찍힙니다.
---
## 5. 핵심 ② 시간 동기화 — 영상 시각 ↔ 드론 프레임
좌표가 맞아도 **"지금 영상 시각의 드론 위치"** 를 못 집으면 라벨이 엉뚱한 데 뜹니다.
**문제**: 브라우저 `video.currentTime`은 ~250ms 간격으로만 갱신 → 커서·라벨이 뚝뚝 끊김.
**해결**: `smoothTimeRef` — 벽시계로 **60fps 단조 보간**.
<figure class="fig">
<svg viewBox="0 0 680 190" role="img" aria-label="시간 보간">
<!-- raw -->
<text x="40" y="42" font-size="12" font-weight="bold" fill="#b91c1c">실제 currentTime (250ms 띄엄띄엄)</text>
<line x1="50" y1="60" x2="630" y2="60" stroke="#e5e7eb" stroke-width="2"/>
<g fill="#dc2626"><circle cx="80" cy="60" r="6"/><circle cx="220" cy="60" r="6"/><circle cx="360" cy="60" r="6"/><circle cx="500" cy="60" r="6"/><circle cx="620" cy="60" r="6"/></g>
<text x="340" y="84" text-anchor="middle" font-size="11" fill="#b91c1c">→ 라벨/커서가 뚝뚝 끊김</text>
<!-- smooth -->
<text x="40" y="124" font-size="12" font-weight="bold" fill="#166534">smoothTimeRef (매 프레임 채움 · 60fps)</text>
<line x1="50" y1="142" x2="630" y2="142" stroke="#e5e7eb" stroke-width="2"/>
<g fill="#16a34a"><circle cx="80" cy="142" r="4"/><circle cx="110" cy="142" r="4"/><circle cx="140" cy="142" r="4"/><circle cx="170" cy="142" r="4"/><circle cx="200" cy="142" r="4"/><circle cx="230" cy="142" r="4"/><circle cx="260" cy="142" r="4"/><circle cx="290" cy="142" r="4"/><circle cx="320" cy="142" r="4"/><circle cx="350" cy="142" r="4"/><circle cx="380" cy="142" r="4"/><circle cx="410" cy="142" r="4"/><circle cx="440" cy="142" r="4"/><circle cx="470" cy="142" r="4"/><circle cx="500" cy="142" r="4"/><circle cx="530" cy="142" r="4"/><circle cx="560" cy="142" r="4"/><circle cx="590" cy="142" r="4"/><circle cx="620" cy="142" r="4"/></g>
<text x="340" y="166" text-anchor="middle" font-size="11" fill="#166534">media + 경과시간×배속 · 0.3s 이상 벌어지면 재동기화</text>
</svg>
<figcaption>그림 9. 띄엄띄엄한 실제 시각을 60fps로 메워 부드럽게. 이 t로 드론 pose를 보간.</figcaption>
</figure>
```ts
// VideoPlayer.tsx 62117
let est = a.media + ((performance.now() - a.wall)/1000) * rate;
if (real - est > 0.3) est = real; // 재동기화
smoothTimeRef.current = t; // ref로 노출 → 리렌더 없이 매 프레임 읽음
```
---
## 6. 핵심 ③ 라벨 안정화 — 떨림·튐 제거
드론은 미세하게 흔들립니다. 그대로 투영하면 라벨이 부르르 떨립니다. → **One Euro 방식 속도적응 평활 + 이상치 거부.**
<figure class="fig">
<svg viewBox="0 0 680 220" role="img" aria-label="One Euro 평활">
<!-- case 1 -->
<rect x="20" y="20" width="210" height="180" rx="10" fill="#ecfdf5" stroke="#16a34a"/>
<text x="125" y="42" text-anchor="middle" font-size="12" font-weight="bold" fill="#14532d">떨림(왕복)</text>
<polyline points="40,110 60,95 80,118 100,92 120,116 140,96 160,114 180,98 200,110" fill="none" stroke="#dc2626" stroke-width="1.5"/>
<line x1="40" y1="150" x2="210" y2="150" stroke="#16a34a" stroke-width="3"/>
<text x="125" y="172" text-anchor="middle" font-size="10.5" fill="#166534">평활속도≈0 → 강하게 평활</text>
<text x="125" y="188" text-anchor="middle" font-size="10.5" fill="#166534">(안정)</text>
<!-- case 2 -->
<rect x="240" y="20" width="200" height="180" rx="10" fill="#eff6ff" stroke="#2563eb"/>
<text x="340" y="42" text-anchor="middle" font-size="12" font-weight="bold" fill="#1e3a8a">실제 이동</text>
<polyline points="260,150 290,130 320,110 350,90 380,70 410,55" fill="none" stroke="#2563eb" stroke-width="2.5"/>
<text x="340" y="172" text-anchor="middle" font-size="10.5" fill="#1e40af">방향 일관 → 즉시 추종</text>
<text x="340" y="188" text-anchor="middle" font-size="10.5" fill="#1e40af">(지연 없음)</text>
<!-- case 3 -->
<rect x="450" y="20" width="210" height="180" rx="10" fill="#fff7ed" stroke="#d97706"/>
<text x="555" y="42" text-anchor="middle" font-size="12" font-weight="bold" fill="#9a3412">갑작스런 점프</text>
<polyline points="470,150 500,148 530,150" fill="none" stroke="#d97706" stroke-width="2"/>
<circle cx="610" cy="80" r="6" fill="#dc2626"/>
<line x1="530" y1="150" x2="600" y2="86" stroke="#dc2626" stroke-width="1.5" stroke-dasharray="4 3"/>
<text x="612" y="76" font-size="14" fill="#dc2626">✗</text>
<text x="555" y="180" text-anchor="middle" font-size="10.5" fill="#9a3412">8프레임까지 무시 후 수용</text>
<text x="555" y="195" text-anchor="middle" font-size="10.5" fill="#9a3412">(시크는 진짜 → 결국 수용)</text>
</svg>
<figcaption>그림 10. 떨림은 죽이고, 진짜 이동은 즉시 따라가고, 튐(점프)은 무시. 세 동작을 한 필터로.</figcaption>
</figure>
```ts
// StationOverlay.tsx 4861
if (d > REJECT_DIST && prev.rej < MAX_REJECT_FRAMES) // 0.12 초과 점프 = 이상치
return { ...prev, rej: prev.rej + 1 }; // 위치 유지
const a = min(maxAlpha, minAlpha + (maxAlpha-minAlpha)*min(1, speed/speedRef));
return { x: prev.x + dx*a, y: prev.y + dy*a, ... }; // 속도 클수록 빠르게 추종
```
---
## 7. 핵심 ④ 프레임 기반 → 측점(스테이션) 기반
### 7-1. 왜 바꿨나? — 시간축의 한계
철도 현장은 **위치(측점)** 로 말합니다: "157K970 지점의 교량". 그런데 시간축은 **드론이 호버(정지)** 하면 무너집니다.
<figure class="fig">
<svg viewBox="0 0 680 180" role="img" aria-label="시간축 한계">
<text x="120" y="32" font-size="12" font-weight="bold" fill="#b91c1c">시간축 (균등)</text>
<line x1="40" y1="60" x2="500" y2="60" stroke="#cbd5e1" stroke-width="3"/>
<g fill="#64748b"><circle cx="40" cy="60" r="4"/><circle cx="132" cy="60" r="4"/><circle cx="224" cy="60" r="4"/><circle cx="316" cy="60" r="4"/><circle cx="408" cy="60" r="4"/><circle cx="500" cy="60" r="4"/></g>
<g font-size="10" fill="#64748b"><text x="40" y="78">0s</text><text x="128" y="78">1s</text><text x="220" y="78">2s</text><text x="312" y="78">3s</text><text x="404" y="78">4s</text><text x="496" y="78">5s</text></g>
<!-- hover -->
<rect x="224" y="100" width="120" height="50" rx="8" fill="#fee2e2" stroke="#dc2626"/>
<text x="284" y="120" text-anchor="middle" font-size="11" fill="#b91c1c">드론 호버(정지)</text>
<text x="284" y="138" text-anchor="middle" font-size="10" fill="#b91c1c">시간은 가는데 위치는 안 변함</text>
<text x="560" y="125" font-size="11" fill="#b91c1c">→ 시간축에선</text>
<text x="560" y="142" font-size="11" fill="#b91c1c">구분 불가 ✗</text>
</svg>
<figcaption>그림 11. 시간 균등축에선 "공중 대기(호버)" 구간을 표현할 수 없다.</figcaption>
</figure>
➡ 그래서 **측점(공간)축**으로 재해석: 드론이 호버하면 측점도 멈춰 있어야 자연스럽다.
---
### 7-2. 구현 ① — GPS를 측점값으로 투영
드론 GPS를 측점 폴리라인(선로)에 **수직으로 내려** "측점값(km)"과 "선로 이격(m)"을 구합니다.
<figure class="fig">
<svg viewBox="0 0 680 190" role="img" aria-label="GPS를 측점선에 투영">
<defs><marker id="m72" markerWidth="9" markerHeight="9" refX="6" refY="3" orient="auto"><path d="M0,0 L6,3 L0,6 Z" fill="#f59e0b"/></marker></defs>
<!-- chain line -->
<polyline points="60,130 200,122 360,118 520,124 630,130" fill="none" stroke="#0891b2" stroke-width="5"/>
<g fill="#155e75" font-size="10">
<circle cx="60" cy="130" r="5" fill="#0891b2"/><text x="40" y="152">157K900</text>
<circle cx="360" cy="118" r="5" fill="#0891b2"/><text x="335" y="108">158K000</text></g>
<text x="70" y="118" font-size="11" fill="#155e75">측점 폴리라인(선로)</text>
<!-- drone -->
<circle cx="280" cy="55" r="9" fill="#7c3aed"/><text x="295" y="52" font-size="12" font-weight="bold" fill="#5b21b6">🚁 드론 GPS</text>
<!-- perpendicular -->
<line x1="280" y1="64" x2="280" y2="118" stroke="#f59e0b" stroke-width="2" stroke-dasharray="5 4" marker-end="url(#m72)"/>
<circle cx="280" cy="120" r="6" fill="#b45309"/>
<text x="290" y="146" font-size="12" font-weight="bold" fill="#b45309">km=157K958, 이격 4.2m</text>
<text x="200" y="92" font-size="10" fill="#9a3412">projectToChain (수직 내림)</text>
</svg>
<figcaption>그림 12. 측점명 "157K970" → 157970m 환산으로 선로를 만들고, GPS를 수직 투영해 측점값·이격 산출.</figcaption>
</figure>
```ts
// chainage.ts projectToChain (3550)
const t = clamp(((px-a.x)*dx + (py-a.y)*dy) / L2, 0, 1); // 선분 위 투영 비율
bestKm = a.km + (b.km - a.km) * t; // 보간 측점값
```
---
### 7-3. 구현 ② — 이동거리축 (핵심 아이디어)
시간축 대신 **드론이 실제로 이동한 거리**를 축으로 씁니다. 측점값 변화량 |Δ|을 매 프레임 누적.
<figure class="fig">
<svg viewBox="0 0 680 200" role="img" aria-label="이동거리축">
<!-- timeline of values -->
<text x="40" y="32" font-size="11" fill="#334155">측점값(평활): 920 → 930 → 940 → <tspan fill="#b91c1c">940 940 940</tspan> → 950 → 960</text>
<!-- accumulation bar -->
<text x="40" y="72" font-size="11" font-weight="bold" fill="#155e75">누적 이동거리 = 축 위치</text>
<rect x="40" y="84" width="600" height="26" rx="13" fill="#e5e7eb"/>
<!-- run 1 -->
<rect x="40" y="84" width="180" height="26" rx="13" fill="#67e8f9"/>
<!-- hover (no growth) -->
<rect x="220" y="84" width="0" height="26" fill="#fca5a5"/>
<!-- run 2 -->
<rect x="220" y="84" width="180" height="26" fill="#22d3ee"/>
<circle cx="220" cy="97" r="11" fill="#0891b2"/>
<text x="220" y="101" text-anchor="middle" font-size="10" fill="#fff">⏸</text>
<text x="130" y="135" text-anchor="middle" font-size="10" fill="#155e75">정상 주행 →→→</text>
<text x="232" y="135" font-size="10" fill="#b91c1c">호버: 누적 멈춤 → 커서 정지!</text>
<text x="540" y="135" text-anchor="middle" font-size="10" fill="#155e75">→→→ 주행</text>
<text x="340" y="172" text-anchor="middle" font-size="11" fill="#555">호버 구간에서 커서가 멈춰 "공중 대기"가 한눈에 보인다</text>
</svg>
<figcaption>그림 13. |Δ측점값|을 누적해 0~1로 정규화 → 축 위치. 호버는 누적이 멈추므로 커서도 정지.</figcaption>
</figure>
```ts
// StationBar.tsx 233274
sm[i] = (pre[hi]-pre[lo])/(hi-lo); // 측점값 ±8프레임 평활
if (i>0) cum += Math.abs(sm[i]-sm[i-1]); // |Δ| 누적 = 실제 이동거리
frac[i] = cum / total; // 0~1 정규화 → 축 위치
```
---
### 7-4. 측점 기반의 결과 — 하단 측점바
<figure class="fig">
<svg viewBox="0 0 680 170" role="img" aria-label="하단 측점바">
<rect x="20" y="30" width="640" height="110" rx="10" fill="#0f172a" stroke="#334155"/>
<!-- track -->
<line x1="60" y1="95" x2="520" y2="95" stroke="#22d3ee" stroke-width="5"/>
<line x1="520" y1="95" x2="620" y2="95" stroke="#475569" stroke-width="5" stroke-dasharray="2 4"/>
<!-- markers -->
<g font-size="9" fill="#e2e8f0" text-anchor="middle">
<circle cx="60" cy="95" r="7" fill="#fbbf24"/><text x="60" y="72">🚉역</text>
<circle cx="170" cy="95" r="6" fill="#f59e0b"/><text x="170" y="72">🌉교량</text>
<circle cx="300" cy="95" r="6" fill="#38bdf8"/><text x="300" y="72">🚇터널</text>
<circle cx="460" cy="95" r="6" fill="#f59e0b"/><text x="460" y="72">🌉구교</text>
<circle cx="620" cy="95" r="7" fill="none" stroke="#94a3b8" stroke-width="2"/><text x="620" y="72">◌종점</text></g>
<!-- cursor -->
<polygon points="220,80 212,68 228,68" fill="#fff"/>
<line x1="220" y1="80" x2="220" y2="110" stroke="#fff" stroke-width="2"/>
<text x="220" y="128" text-anchor="middle" font-size="10" fill="#fff">현재 위치 커서 (60fps)</text>
<text x="572" y="118" font-size="9" fill="#94a3b8">회색=미도착</text>
</svg>
<figcaption>그림 14. 측점·구조물을 공간축에 배치. 영상별 FPS 자동산출로 배지 정밀화 · 종점 미도착은 회색 + 빈 링.</figcaption>
</figure>
---
### 7-5. 프레임 ↔ 측점 — 한 장 비교
| 구분 | 프레임 기반 | 측점 기반 (현재) |
|------|-------------|------------------|
| 축 | 시간(초) | 실제 이동거리/측점(km) |
| 호버 | 구분 불가 | 커서 정지로 가시화 |
| 현장 용어 | "3분 20초" | "157K970 교량" |
| 위치 질의 | 어려움 | GPS→측점 투영으로 즉답 |
| 동기화 | currentTime | smoothTimeRef + 측점투영 |
➡ **시간축 영상을 공간축(측점)으로 재해석**한 것이 이 프로그램의 정체성.
---
## 8. 정확도 보정 도구
투영은 카메라 파라미터·표고 가정에 민감 → **사용자가 미세보정**할 수 있게 함. 핵심은 투영의 **역방향**(화면→세계)을 푸는 것.
<figure class="fig">
<svg viewBox="0 0 680 210" role="img" aria-label="보정 도구">
<defs><marker id="m8" markerWidth="10" markerHeight="10" refX="7" refY="3.5" orient="auto"><path d="M0,0 L7,3.5 L0,7 Z" fill="#7c3aed"/></marker></defs>
<!-- screen -->
<rect x="40" y="40" width="220" height="130" rx="6" fill="#0b1020" stroke="#334155"/>
<text x="150" y="32" text-anchor="middle" font-size="11" fill="#555">영상 화면</text>
<circle cx="120" cy="80" r="6" fill="#f59e0b"/>
<text x="60" y="105" font-size="10" fill="#fde68a">라벨을 끌어 제자리로</text>
<!-- ground -->
<line x1="320" y1="175" x2="650" y2="175" stroke="#8b5e34" stroke-width="3"/>
<text x="320" y="193" font-size="10" fill="#7c5a3a">실제 땅</text>
<line x1="126" y1="82" x2="540" y2="170" stroke="#7c3aed" stroke-width="2" stroke-dasharray="5 4" marker-end="url(#m8)"/>
<circle cx="540" cy="170" r="6" fill="#7c3aed"/>
<text x="470" y="160" font-size="11" font-weight="bold" fill="#6d28d9">위경도 역산</text>
<text x="300" y="60" font-size="11" fill="#6d28d9">화면점 → 광선 → 지면 교차 → 실제 위치</text>
<text x="300" y="80" font-size="10" fill="#6d28d9">groundPointFromPixel</text>
</svg>
<figcaption>그림 15. "끌어다 맞추기" = 역투영. 끈 화면점을 광선으로 쏴 지면과 만나는 실제 좌표를 복원.</figcaption>
</figure>
| 도구 | 동작 |
|---|---|
| ① **DEM 자동표고** | 모든 POI를 실제 지형고도로 (서버 `/api/elevation`, SRTM 30m) |
| ② **드래그 편집** | 라벨을 끌면 `groundPointFromPixel`로 위경도 역산 |
| ③ **세로화각 보정** | POI를 실제 위치로 끌면 `sensorH`만 역산 자동보정 |
| ④ **보정값 저장** | `poi_overrides.json` 내보내기/가져오기 |
---
## 9. 정리 — 무엇을, 어떻게
<figure class="fig">
<svg viewBox="0 0 680 260" role="img" aria-label="전체 정리">
<defs><marker id="m9" markerWidth="10" markerHeight="10" refX="7" refY="3.5" orient="auto"><path d="M0,0 L7,3.5 L0,7 Z" fill="#64748b"/></marker></defs>
<!-- inputs -->
<g text-anchor="middle">
<rect x="20" y="40" width="120" height="34" rx="8" fill="#f5f3ff" stroke="#7c3aed"/><text x="80" y="62" font-size="11" fill="#5b21b6">드론 GPS</text>
<rect x="20" y="100" width="120" height="34" rx="8" fill="#eff6ff" stroke="#2563eb"/><text x="80" y="122" font-size="11" fill="#1e3a8a">영상</text>
<rect x="20" y="160" width="120" height="34" rx="8" fill="#ecfdf5" stroke="#16a34a"/><text x="80" y="182" font-size="11" fill="#14532d">POI 좌표</text>
</g>
<!-- stages -->
<g text-anchor="middle">
<rect x="200" y="40" width="130" height="60" rx="8" fill="#ecfdf5" stroke="#16a34a"/><text x="265" y="64" font-size="11" font-weight="bold" fill="#14532d">🟩 시간 동기화</text><text x="265" y="82" font-size="9" fill="#166534">smoothTimeRef</text><text x="265" y="95" font-size="9" fill="#166534">60fps 보간</text>
<rect x="200" y="110" width="130" height="74" rx="8" fill="#eff6ff" stroke="#2563eb"/><text x="265" y="132" font-size="11" font-weight="bold" fill="#1e3a8a">🟦 좌표 투영</text><text x="265" y="150" font-size="9" fill="#1e40af">ENU→카메라</text><text x="265" y="163" font-size="9" fill="#1e40af">→핀홀→cover</text><text x="265" y="176" font-size="9" fill="#1e40af">+geoid 보정</text>
<rect x="380" y="75" width="120" height="60" rx="8" fill="#fff7ed" stroke="#d97706"/><text x="440" y="99" font-size="11" font-weight="bold" fill="#9a3412">🟧 평활</text><text x="440" y="117" font-size="9" fill="#9a3412">One Euro +</text><text x="440" y="129" font-size="9" fill="#9a3412">이상치 거부</text>
<rect x="540" y="75" width="120" height="60" rx="8" fill="#fff" stroke="#23272e" stroke-width="1.5"/><text x="600" y="99" font-size="11" font-weight="bold" fill="#23272e">영상 위</text><text x="600" y="117" font-size="11" font-weight="bold" fill="#23272e">정확한 라벨✨</text>
<rect x="380" y="170" width="280" height="40" rx="8" fill="#ecfeff" stroke="#0891b2"/><text x="520" y="188" font-size="11" font-weight="bold" fill="#155e75">🟦 GPS→측점 투영 → 이동거리축</text><text x="520" y="202" font-size="9" fill="#155e75">→ 측점 기반 측점바 (호버까지 표현)</text>
</g>
<!-- arrows -->
<line x1="140" y1="70" x2="198" y2="70" stroke="#64748b" stroke-width="1.4" marker-end="url(#m9)"/>
<line x1="140" y1="117" x2="198" y2="135" stroke="#64748b" stroke-width="1.4" marker-end="url(#m9)"/>
<line x1="140" y1="177" x2="198" y2="155" stroke="#64748b" stroke-width="1.4" marker-end="url(#m9)"/>
<line x1="330" y1="120" x2="378" y2="105" stroke="#64748b" stroke-width="1.4" marker-end="url(#m9)"/>
<line x1="500" y1="105" x2="538" y2="105" stroke="#64748b" stroke-width="1.4" marker-end="url(#m9)"/>
<line x1="265" y1="184" x2="265" y2="195" stroke="#0891b2" stroke-width="1.4"/>
<line x1="265" y1="195" x2="378" y2="190" stroke="#0891b2" stroke-width="1.4" marker-end="url(#m9)"/>
</svg>
<figcaption>그림 16. 3대 정합(공간·시간·안정) + 패러다임 전환(프레임→측점).</figcaption>
</figure>
**3대 정합 기술**
1. **🟦 공간**: 4단계 투영(ENU→카메라회전→핀홀→cover) + 지오이드 보정 + 역투영 보정
2. **🟩 시간**: smoothTimeRef 60fps 단조보간으로 영상시각↔드론프레임 정합
3. **🟧 안정**: One Euro 속도적응 평활 + 이상치 거부
**패러다임 전환**
- 프레임(시간) 기반 → **측점(공간) 기반**: GPS를 선로에 투영해 측점값화, 이동거리축으로 호버까지 표현
---
## 부록 — 핵심 소스 색인
| 기술 | 파일 | 라인 |
|------|------|------|
| ENU 변환 | geoProjection.ts | 9197 |
| 카메라 좌표 + fwd/side | geoProjection.ts | 301317 |
| 핀홀 투영 | geoProjection.ts | 126137 |
| 지오이드 보정 | geoProjection.ts | 5367, projectPoint |
| 역투영(보정) | geoProjection.ts | 149251 |
| object-fit cover 정렬 | StationOverlay.tsx | 921930 |
| 라벨 평활/이상치 | StationOverlay.tsx | 4861 |
| RAF 60fps 렌더 | StationOverlay.tsx | 9051198 |
| smoothTimeRef | VideoPlayer.tsx | 62117 |
| 측점 투영(chainage) | chainage.ts | 1064 |
| 이동거리축 | StationBar.tsx | 233274 |
| 종점역 미도착 | StationBar.tsx | 300310, 574586 |
| DEM 표고 | StationOverlay.tsx / elevation.ts | 13841415 / 1467 |
> 상세 구현은 [구현상세_GhiVideo_기술-소스코드매칭.md](구현상세_GhiVideo_기술-소스코드매칭.md) 참조.
@@ -0,0 +1,337 @@
<!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;}
.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>GhiVideo</strong> 의 두 가지 핵심 기술을 <strong>초등학생도 이해할 수 있게</strong> 그림으로 설명합니다. <strong>① 드론 좌표를 영상에 딱 맞추기(좌표 투영)</strong> · <strong>② 시간이 아니라 &#39;측점&#39;으로 움직이는 재생 바</strong></p>
</blockquote>
<style>
figure.fig{margin:20px 0;text-align:center;page-break-inside:avoid;break-inside:avoid;}
figure.fig svg{width:100%;max-width:700px;height:auto;border:1px solid #e7e0d2;border-radius:10px;background:#fffdf8;}
figure.fig figcaption{font-size:0.9em;color:#777;margin-top:6px;}
text{font-family:'Malgun Gothic','Hancom Gothic',sans-serif;}
h2{border-top:3px solid #e7e0d2;padding-top:14px;margin-top:34px;}
</style>
<hr />
<h2 id="0-한눈에--ghivideo는-뭐가-특별한가요">0. 한눈에 — GhiVideo는 뭐가 특별한가요?</h2>
<p>보통 영상 플레이어는 &quot;그냥 영상 재생&quot;이 끝이에요. GhiVideo는 여기에 <strong>두 가지 마법</strong>을 더했어요.</p>
<figure class="fig">
<svg viewBox="0 0 700 210" role="img" aria-label="GhiVideo 두 가지 핵심">
<rect x="30" y="30" width="300" height="150" rx="12" fill="#eff6ff" stroke="#2563eb"></rect>
<text x="180" y="58" text-anchor="middle" font-size="14" font-weight="bold" fill="#1e3a8a">① 좌표 투영</text>
<text x="180" y="86" text-anchor="middle" font-size="11" fill="#1e40af">지도 속 터널·다리 위치를</text>
<text x="180" y="106" text-anchor="middle" font-size="11" fill="#1e40af">영상 화면의 정확한 자리에</text>
<text x="180" y="126" text-anchor="middle" font-size="11" fill="#1e40af">이름표로 딱 붙이기</text>
<text x="180" y="158" text-anchor="middle" font-size="22">🚇🏷️</text>
<rect x="370" y="30" width="300" height="150" rx="12" fill="#f0fdf4" stroke="#16a34a"></rect>
<text x="520" y="58" text-anchor="middle" font-size="14" font-weight="bold" fill="#166534">② 측점 기반 재생 바</text>
<text x="520" y="86" text-anchor="middle" font-size="11" fill="#15803d">시간(0:00) 대신</text>
<text x="520" y="106" text-anchor="middle" font-size="11" fill="#15803d">철도 &#39;측점(160k130)&#39;으로</text>
<text x="520" y="126" text-anchor="middle" font-size="11" fill="#15803d">찾아가는 재생 바</text>
<text x="520" y="158" text-anchor="middle" font-size="22">📏▶️</text>
</svg>
</figure>
<hr />
<h1 id="part-1-드론-좌표를-영상에-딱-맞추기-">Part 1. 드론 좌표를 영상에 &#39;&#39; 맞추기 🎯</h1>
<h2 id="1-1-풀고-싶은-문제">1-1. 풀고 싶은 문제</h2>
<p>드론이 <strong>앞으로 날며</strong> 앞쪽 땅을 비스듬히 찍어요. 이 영상 위에 <strong>&quot;여기가 회덕터널!&quot;</strong> 이름표를 붙이고 싶어요. 드론이 계속 움직이니까 이름표도 <strong>졸졸 따라다녀야</strong> 해요.</p>
<figure class="fig">
<svg viewBox="0 0 700 220" role="img" aria-label="드론이 앞을 보며 촬영">
<defs>
<marker id="mv" markerWidth="12" markerHeight="12" refX="8" refY="4" orient="auto"><path d="M0,0 L8,4 L0,8 Z" fill="#2563eb"></path></marker>
<marker id="mc" markerWidth="12" markerHeight="12" refX="8" refY="4" orient="auto"><path d="M0,0 L8,4 L0,8 Z" fill="#ea580c"></path></marker>
</defs>
<rect x="0" y="0" width="700" height="150" fill="#eef6ff"></rect><rect x="0" y="150" width="700" height="70" fill="#eaf7ea"></rect>
<line x1="0" y1="150" x2="700" y2="150" stroke="#bcd8bc" stroke-width="2"></line>
<g transform="translate(110,50)"><rect x="-24" y="-7" width="48" height="14" rx="4" fill="#334155"></rect><circle cx="-24" cy="0" r="8" fill="none" stroke="#334155" stroke-width="3"></circle><circle cx="24" cy="0" r="8" fill="none" stroke="#334155" stroke-width="3"></circle><text x="0" y="-14" text-anchor="middle" font-size="12" font-weight="bold" fill="#334155">드론</text></g>
<line x1="140" y1="50" x2="280" y2="50" stroke="#2563eb" stroke-width="3" stroke-dasharray="7 5" marker-end="url(#mv)"></line><text x="220" y="40" text-anchor="middle" font-size="12" fill="#2563eb" font-weight="bold">이동 방향 ▶</text>
<line x1="118" y1="58" x2="470" y2="148" stroke="#ea580c" stroke-width="3" marker-end="url(#mc)"></line><text x="300" y="105" text-anchor="middle" font-size="12" fill="#ea580c" font-weight="bold">카메라 시선(앞·아래로 비스듬히)</text>
<text x="485" y="145" font-size="20">🚇</text><text x="520" y="175" text-anchor="middle" font-size="12" font-weight="bold" fill="#166534">회덕터널 (앞쪽!)</text>
<line x1="60" y1="200" x2="660" y2="200" stroke="#8a7a5c" stroke-width="5"></line>
</svg>
<figcaption>드론은 앞으로 날며 앞쪽 땅을 비스듬히 본다. 터널은 드론 바로 밑이 아니라 &#39;앞쪽&#39;에 찍힌다.</figcaption>
</figure>
<h2 id="1-2-핵심-아이디어--창문에-스티커-붙이기-">1-2. 핵심 아이디어 — &#39;창문에 스티커 붙이기&#39; 🪟</h2>
<p>카메라(눈)에서 터널로 <strong>선을 긋고</strong>, 그 선이 <strong>화면(창문)을 뚫는 점</strong>에 이름표를 붙여요.</p>
<figure class="fig">
<svg viewBox="0 0 700 210" role="img" aria-label="창문 스티커 원리">
<defs><marker id="me" markerWidth="12" markerHeight="12" refX="8" refY="4" orient="auto"><path d="M0,0 L8,4 L0,8 Z" fill="#0f766e"></path></marker></defs>
<text x="70" y="115" text-anchor="middle" font-size="30">👁️</text><text x="70" y="150" text-anchor="middle" font-size="12" fill="#0f766e" font-weight="bold">카메라(눈)</text>
<text x="620" y="100" text-anchor="middle" font-size="36">🌳</text><text x="620" y="140" text-anchor="middle" font-size="12" fill="#166534" font-weight="bold">터널(나무)</text>
<line x1="95" y1="108" x2="600" y2="88" stroke="#0f766e" stroke-width="2.5" marker-end="url(#me)"></line>
<rect x="330" y="35" width="80" height="150" rx="6" fill="#e0f2fe" stroke="#0284c7" stroke-width="2" opacity="0.8"></rect><text x="370" y="200" text-anchor="middle" font-size="12" fill="#0369a1" font-weight="bold">창문 = 영상 화면</text>
<circle cx="370" cy="97" r="8" fill="#ef4444"></circle><text x="370" y="78" text-anchor="middle" font-size="12" fill="#b91c1c" font-weight="bold">스티커!(이름표 자리)</text>
</svg>
</figure>
<h2 id="1-3-실제로는-3단계로-계산해요">1-3. 실제로는 3단계로 계산해요</h2>
<figure class="fig">
<svg viewBox="0 0 700 140" role="img" aria-label="투영 3단계">
<defs><marker id="a3" markerWidth="11" markerHeight="11" refX="7" refY="3.5" orient="auto"><path d="M0,0 L7,3.5 L0,7 Z" fill="#7c3aed"></path></marker></defs>
<rect x="20" y="45" width="190" height="50" rx="8" fill="#ecfdf5" stroke="#16a34a"></rect><text x="115" y="68" text-anchor="middle" font-size="12" font-weight="bold" fill="#166534">① 미터로 바꿔</text><text x="115" y="85" text-anchor="middle" font-size="10" fill="#166534">드론 기준 위치 재기</text>
<line x1="210" y1="70" x2="245" y2="70" stroke="#7c3aed" stroke-width="2" marker-end="url(#a3)"></line>
<rect x="250" y="45" width="190" height="50" rx="8" fill="#eff6ff" stroke="#2563eb"></rect><text x="345" y="68" text-anchor="middle" font-size="12" font-weight="bold" fill="#1e3a8a">② 카메라 기울기만큼</text><text x="345" y="85" text-anchor="middle" font-size="10" fill="#1e3a8a">방향 돌리기(회전)</text>
<line x1="440" y1="70" x2="475" y2="70" stroke="#7c3aed" stroke-width="2" marker-end="url(#a3)"></line>
<rect x="480" y="45" width="200" height="50" rx="8" fill="#fdf2f8" stroke="#db2777"></rect><text x="580" y="68" text-anchor="middle" font-size="12" font-weight="bold" fill="#9d174d">③ 화면 점 찾기</text><text x="580" y="85" text-anchor="middle" font-size="10" fill="#9d174d">(창문 스티커 · 핀홀)</text>
</svg>
<figcaption>결과: &quot;터널은 화면 가로 60%, 세로 70% 지점&quot; → 거기에 이름표를 딱!</figcaption>
</figure>
<table>
<thead>
<tr class="header">
<th>단계</th>
<th>진짜 기술</th>
<th>쉽게 말하면</th>
</tr>
</thead>
<tbody>
<tr class="odd">
<td>① 미터로 바꾸기</td>
<td>proj4 · EPSG:5186(한국 TM)</td>
<td>둥근 지구를 평평한 미터 지도로 펴기</td>
</tr>
<tr class="even">
<td>② 방향 돌리기</td>
<td>회전행렬(yaw·pitch·roll)</td>
<td>카메라 기울기를 숫자표로 한 번에 적용</td>
</tr>
<tr class="odd">
<td>③ 화면 점 찾기</td>
<td>핀홀 카메라 모델·초점거리</td>
<td>바늘구멍 사진기 원리로 점 찍기</td>
</tr>
<tr class="even">
<td>(+) 부드럽게</td>
<td>보간 · EMA 평활</td>
<td>30장 사이 채우고 드론 떨림 없애기</td>
</tr>
</tbody>
</table>
<blockquote>
<p>💡 원래 파이썬 실험 프로그램(<code>advanced_tuner_v2.py</code>)의 계산을 <strong>웹 브라우저(TypeScript)로 이식</strong> → 설치 없이 실행.</p>
</blockquote>
<hr />
<h1 id="part-2-시간이-아니라-측점으로-움직이는-재생-바-">Part 2. 시간이 아니라 &#39;측점&#39;으로 움직이는 재생 바 📏</h1>
<h2 id="2-1-무엇이-다른가요">2-1. 무엇이 다른가요?</h2>
<p>보통 플레이어의 아래 막대는 <strong>시간(0:00 ~ 끝)</strong> 기준이에요. 그런데 철도 점검은 <strong>&quot;몇 분&quot;이 아니라 &quot;몇 측점&quot;</strong> 으로 말해요. 그래서 GhiVideo는 막대를 <strong>측점(측점값, 예: 160k130)</strong> 기준으로 바꿨어요.</p>
<figure class="fig">
<svg viewBox="0 0 700 230" role="img" aria-label="시간 축 재생바 vs 측점 축 재생바">
<text x="350" y="24" text-anchor="middle" font-size="13" font-weight="bold" fill="#475569">보통 플레이어 (시간 축)</text>
<rect x="40" y="38" width="620" height="26" rx="6" fill="#e5e7eb" stroke="#9ca3af"></rect>
<rect x="40" y="38" width="260" height="26" rx="6" fill="#9ca3af"></rect>
<circle cx="300" cy="51" r="10" fill="#374151"></circle>
<text x="45" y="82" font-size="11" fill="#374151">0:00</text><text x="330" y="82" font-size="11" fill="#374151">03:12</text><text x="632" y="82" font-size="11" fill="#374151">09:28</text>
<text x="350" y="104" text-anchor="middle" font-size="11" fill="#6b7280">&quot;3분 12초 지점&quot; — 여기가 어디 선로인지 모름 😵</text>
<p><text x="350" y="150" text-anchor="middle" font-size="13" font-weight="bold" fill="#166534">GhiVideo (측점 축)</text> <rect x="40" y="164" width="620" height="26" rx="6" fill="#fde9c8" stroke="#e0a94f"></rect> <rect x="40" y="164" width="300" height="26" rx="6" fill="#f59e0b"></rect> <circle cx="340" cy="177" r="11" fill="#ea580c"></circle><text x="340" y="181" text-anchor="middle" font-size="9" fill="#fff" font-weight="bold">160k</text> <line x1="200" y1="160" x2="200" y2="194" stroke="#0284c7" stroke-width="2"></line><text x="200" y="210" text-anchor="middle" font-size="9" fill="#0369a1">회덕터널</text> <line x1="470" y1="160" x2="470" y2="194" stroke="#0284c7" stroke-width="2"></line><text x="470" y="210" text-anchor="middle" font-size="9" fill="#0369a1">회덕천교</text> <text x="45" y="228" font-size="11" fill="#166534">대전조차장</text><text x="600" y="228" font-size="11" fill="#166534">신탄진</text> </svg></p>
<figcaption>시간 축은 &quot;몇 분&quot;만 안다. 측점 축은 &quot;지금 어느 선로 위치(측점)&quot;&quot;무슨 구조물 근처&quot;인지 바로 보인다.</figcaption>
</figure>
<h2 id="2-2-측점이-뭐예요-">2-2. 측점이 뭐예요? 📍</h2>
<p>철도에서 <strong>출발점부터 몇 미터 왔는지</strong>를 나타내는 &#39;거리 이정표&#39;예요. <code>160k130</code> = 출발점에서 <strong>160km + 130m</strong> 지점. (도로의 &#39;km 표지판&#39;과 똑같아요!)</p>
<h2 id="2-3-어떻게-만들었나--시간측점-지도-️">2-3. 어떻게 만들었나 — &quot;시간↔︎측점 지도&quot; 🗺️</h2>
<p>비밀은 <strong>드론 GPS</strong>예요. 영상의 <strong>매 프레임(사진 한 장)</strong> 마다 두 가지를 계산해 <strong>짝지어</strong> 둬요:</p>
<figure class="fig">
<svg viewBox="0 0 700 250" role="img" aria-label="프레임마다 시간과 측점을 짝짓기">
<defs><marker id="ad" markerWidth="10" markerHeight="10" refX="7" refY="3.5" orient="auto"><path d="M0,0 L7,3.5 L0,7 Z" fill="#7c3aed"></path></marker></defs>
<rect x="250" y="15" width="200" height="40" rx="8" fill="#f1f5f9" stroke="#475569"></rect>
<text x="350" y="33" text-anchor="middle" font-size="12" font-weight="bold" fill="#334155">영상 프레임 (사진 1장)</text>
<text x="350" y="49" text-anchor="middle" font-size="10" fill="#475569">번호 + 드론 GPS 기록</text>
<line x1="300" y1="55" x2="200" y2="80" stroke="#7c3aed" stroke-width="2" marker-end="url(#ad)"></line>
<line x1="400" y1="55" x2="500" y2="80" stroke="#7c3aed" stroke-width="2" marker-end="url(#ad)"></line>
<rect x="70" y="82" width="260" height="52" rx="8" fill="#eff6ff" stroke="#2563eb"></rect>
<text x="200" y="103" text-anchor="middle" font-size="12" font-weight="bold" fill="#1e3a8a">프레임번호 ÷ fps = 시간</text>
<text x="200" y="122" text-anchor="middle" font-size="10" fill="#1e40af">예: 5760번 ÷ 30 = 192초</text>
<rect x="370" y="82" width="270" height="52" rx="8" fill="#f0fdf4" stroke="#16a34a"></rect>
<text x="505" y="103" text-anchor="middle" font-size="12" font-weight="bold" fill="#166534">GPS → 측점 (선로에 투영)</text>
<text x="505" y="122" text-anchor="middle" font-size="10" fill="#15803d">예: 위/경도 → 160k130</text>
<line x1="200" y1="134" x2="330" y2="170" stroke="#7c3aed" stroke-width="2" marker-end="url(#ad)"></line>
<line x1="505" y1="134" x2="370" y2="170" stroke="#7c3aed" stroke-width="2" marker-end="url(#ad)"></line>
<rect x="210" y="172" width="280" height="46" rx="8" fill="#fef3c7" stroke="#ca8a04"></rect>
<text x="350" y="193" text-anchor="middle" font-size="12" font-weight="bold" fill="#854d0e">시간 ↔ 측점 지도 완성!</text>
<text x="350" y="210" text-anchor="middle" font-size="10" fill="#a16207">&quot;192초 = 160k130&quot; 처럼 서로 변환 가능</text>
</svg>
<figcaption>프레임마다 (시간)과 (측점)을 계산해 짝지으면, 시간↔측점을 자유롭게 오갈 수 있다.</figcaption>
</figure>
<p><strong>GPS를 측점으로 바꾸는 법(②):</strong> 드론 GPS 점을 <strong>선로(측점들을 이은 선)에 수직으로 툭 떨어뜨려</strong>, 그 발자국이 몇 측점인지 읽어요. (코드: <code>projectToChain</code> / <code>projectChainage</code>)</p>
<figure class="fig">
<svg viewBox="0 0 700 160" role="img" aria-label="드론 GPS를 선로에 수직 투영">
<line x1="60" y1="110" x2="640" y2="70" stroke="#8a7a5c" stroke-width="6"></line>
<text x="70" y="135" font-size="11" fill="#6b4f2a">선로(측점들을 이은 선)</text>
<circle cx="360" cy="30" r="7" fill="#2563eb"></circle><text x="360" y="22" text-anchor="middle" font-size="10" fill="#1e3a8a">드론 GPS</text>
<line x1="360" y1="30" x2="373" y2="93" stroke="#ef4444" stroke-width="2" stroke-dasharray="4 3"></line>
<circle cx="373" cy="93" r="5" fill="#ef4444"></circle><text x="410" y="96" font-size="11" fill="#b91c1c" font-weight="bold">발자국 = 160k130</text>
</svg>
<figcaption>드론이 선로 바로 위가 아니어도, 수직으로 내린 발자국 위치로 &#39;측점값&#39;을 정확히 읽는다.</figcaption>
</figure>
<h2 id="2-4-똑똑한-점-3가지-">2-4. 똑똑한 점 3가지 ✨</h2>
<h3 id="①-막대의-가로축--시간이-아니라-실제-이동-거리">① 막대의 가로축 = &#39;시간&#39;이 아니라 &#39;실제 이동 거리&#39;</h3>
<p>드론이 <strong>멈춰서 맴돌면 시간은 흐르지만 위치는 그대로</strong>예요. 이때 막대 커서가 계속 가면 이상하죠. 그래서 가로축을 <strong>실제 이동한 거리(측점 변화량 누적)</strong> 로 만들었어요. → <strong>멈추면 커서도 멈추고, 움직이면 전진.</strong></p>
<figure class="fig">
<svg viewBox="0 0 700 150" role="img" aria-label="이동거리 축 - 멈추면 커서 정지">
<text x="175" y="22" text-anchor="middle" font-size="12" font-weight="bold" fill="#475569">시간 축(옛방식)</text>
<rect x="40" y="34" width="270" height="20" rx="5" fill="#e5e7eb" stroke="#9ca3af"></rect><circle cx="230" cy="44" r="8" fill="#374151"></circle>
<text x="175" y="78" text-anchor="middle" font-size="10" fill="#b91c1c">드론이 멈춰도 커서가 계속 감 😵</text>
<text x="525" y="22" text-anchor="middle" font-size="12" font-weight="bold" fill="#166534">이동거리 축(새방식)</text>
<rect x="390" y="34" width="270" height="20" rx="5" fill="#fde9c8" stroke="#e0a94f"></rect><circle cx="560" cy="44" r="8" fill="#ea580c"></circle>
<text x="525" y="78" text-anchor="middle" font-size="10" fill="#166534">멈추면 커서도 멈춤, 움직일 때만 전진 😎</text>
</svg>
</figure>
<h3 id="②-방향을-색으로--측점이-늘면-주황-줄면-하늘색">② 방향을 색으로 — 측점이 늘면 주황, 줄면 하늘색</h3>
<p>드론이 앞으로 가면(측점 증가) <strong>주황</strong>, 되돌아오면(측점 감소) <strong>하늘색</strong> 으로 칠해 <strong>진행 방향</strong>을 한눈에 보여줘요.</p>
<h3 id="③-구조물을-제자리에--교량터널역은-측점값에-배치">③ 구조물을 제자리에 — 교량·터널·역은 &#39;측점값&#39;에 배치</h3>
<p>교량·터널·역사를 각자의 <strong>측점값 위치</strong>에 표시해서, &quot;이 구조물이 어디쯤인지&quot; 막대만 봐도 알 수 있어요.</p>
<h2 id="2-5-측점으로-찾아가기seek">2-5. 측점으로 &#39;찾아가기&#39;(Seek)</h2>
<p>측점값을 입력하거나 막대를 누르면 → <strong>그 측점이 나오는 시간을 지도에서 찾아 → 그 시간으로 이동</strong>해요. (시간↔︎측점 지도가 있으니 가능한 일!)</p>
<figure class="fig">
<svg viewBox="0 0 700 90" role="img" aria-label="측점 입력으로 이동">
<defs><marker id="as" markerWidth="10" markerHeight="10" refX="7" refY="3.5" orient="auto"><path d="M0,0 L7,3.5 L0,7 Z" fill="#7c3aed"></path></marker></defs>
<rect x="30" y="30" width="180" height="34" rx="6" fill="#f0fdf4" stroke="#16a34a"></rect><text x="120" y="52" text-anchor="middle" font-size="12" fill="#166534">&quot;160k130&quot; 입력</text>
<line x1="210" y1="47" x2="255" y2="47" stroke="#7c3aed" stroke-width="2" marker-end="url(#as)"></line>
<rect x="260" y="30" width="200" height="34" rx="6" fill="#fef3c7" stroke="#ca8a04"></rect><text x="360" y="52" text-anchor="middle" font-size="11" fill="#854d0e">지도에서 시간 찾기(192초)</text>
<line x1="460" y1="47" x2="505" y2="47" stroke="#7c3aed" stroke-width="2" marker-end="url(#as)"></line>
<rect x="510" y="30" width="160" height="34" rx="6" fill="#eff6ff" stroke="#2563eb"></rect><text x="590" y="52" text-anchor="middle" font-size="12" fill="#1e3a8a">그 시간으로 재생</text>
</svg>
</figure>
<h2 id="2-6-핵심-기술--파일">2-6. 핵심 기술 / 파일</h2>
<table>
<thead>
<tr class="header">
<th>하는 일</th>
<th>기술</th>
<th>파일 · 함수</th>
</tr>
</thead>
<tbody>
<tr class="odd">
<td>GPS → 측점값</td>
<td>선분에 수직 투영</td>
<td><a href="../client/src/utils/chainage.ts">chainage.ts</a> <code>projectToChain</code></td>
</tr>
<tr class="even">
<td>프레임 → 시간</td>
<td>fps 자동 산출</td>
<td>VideoPlayer.tsx <code>effectiveFps</code></td>
</tr>
<tr class="odd">
<td>시간↔︎측점 지도</td>
<td>프레임별 precompute</td>
<td><a href="../client/src/stationbar/StationBar.tsx">StationBar.tsx</a></td>
</tr>
<tr class="even">
<td>이동거리 축</td>
<td>측점 변화량 누적(정규화)</td>
<td>StationBar.tsx <code>frac</code></td>
</tr>
<tr class="odd">
<td>방향 색·구조물 배치</td>
<td>측점값 기준 렌더</td>
<td>stationbar/components/*</td>
</tr>
</tbody>
</table>
<hr />
<h2 id="마무리--한-문장씩-">마무리 — 한 문장씩 🎁</h2>
<ul>
<li><strong>Part 1 (좌표 투영):</strong> &quot;드론이 <strong>어디서·어느 쪽</strong>을 보는지 알기 때문에, 지도 속 터널이 <strong>영상 화면 어디</strong>에 보일지 계산해 이름표를 딱 붙인다.&quot;</li>
<li><strong>Part 2 (측점 기반):</strong> &quot;드론 <strong>GPS로 매 프레임의 측점</strong>을 계산해 <strong>시간↔︎측점 지도</strong>를 만들어, <strong>시간이 아니라 측점으로</strong> 영상을 탐색한다.&quot;</li>
</ul>
<blockquote>
<p>두 기술 모두 <strong>드론의 GPS·자세 데이터</strong>를 똑똑하게 활용한 결과예요. 그래서 GhiVideo는 단순 영상이 아니라 <strong>&quot;선로 위 어디를 보고 있는지 아는&quot; 주행영상 플레이어</strong>가 됩니다. 🚁📏</p>
</blockquote>
</body>
</html>
@@ -0,0 +1,234 @@
# GhiVideo 핵심 기술 두 가지 (발표 자료)
> 드론 주행영상 플레이어 **GhiVideo** 의 두 가지 핵심 기술을 **초등학생도 이해할 수 있게** 그림으로 설명합니다.
> **① 드론 좌표를 영상에 딱 맞추기(좌표 투영)** · **② 시간이 아니라 '측점'으로 움직이는 재생 바**
<style>
figure.fig{margin:20px 0;text-align:center;page-break-inside:avoid;break-inside:avoid;}
figure.fig svg{width:100%;max-width:700px;height:auto;border:1px solid #e7e0d2;border-radius:10px;background:#fffdf8;}
figure.fig figcaption{font-size:0.9em;color:#777;margin-top:6px;}
text{font-family:'Malgun Gothic','Hancom Gothic',sans-serif;}
h2{border-top:3px solid #e7e0d2;padding-top:14px;margin-top:34px;}
</style>
---
## 0. 한눈에 — GhiVideo는 뭐가 특별한가요?
보통 영상 플레이어는 "그냥 영상 재생"이 끝이에요. GhiVideo는 여기에 **두 가지 마법**을 더했어요.
<figure class="fig">
<svg viewBox="0 0 700 210" role="img" aria-label="GhiVideo 두 가지 핵심">
<rect x="30" y="30" width="300" height="150" rx="12" fill="#eff6ff" stroke="#2563eb"/>
<text x="180" y="58" text-anchor="middle" font-size="14" font-weight="bold" fill="#1e3a8a">① 좌표 투영</text>
<text x="180" y="86" text-anchor="middle" font-size="11" fill="#1e40af">지도 속 터널·다리 위치를</text>
<text x="180" y="106" text-anchor="middle" font-size="11" fill="#1e40af">영상 화면의 정확한 자리에</text>
<text x="180" y="126" text-anchor="middle" font-size="11" fill="#1e40af">이름표로 딱 붙이기</text>
<text x="180" y="158" text-anchor="middle" font-size="22">🚇🏷️</text>
<rect x="370" y="30" width="300" height="150" rx="12" fill="#f0fdf4" stroke="#16a34a"/>
<text x="520" y="58" text-anchor="middle" font-size="14" font-weight="bold" fill="#166534">② 측점 기반 재생 바</text>
<text x="520" y="86" text-anchor="middle" font-size="11" fill="#15803d">시간(0:00) 대신</text>
<text x="520" y="106" text-anchor="middle" font-size="11" fill="#15803d">철도 '측점(160k130)'으로</text>
<text x="520" y="126" text-anchor="middle" font-size="11" fill="#15803d">찾아가는 재생 바</text>
<text x="520" y="158" text-anchor="middle" font-size="22">📏▶️</text>
</svg>
</figure>
---
# Part 1. 드론 좌표를 영상에 '딱' 맞추기 🎯
## 1-1. 풀고 싶은 문제
드론이 **앞으로 날며** 앞쪽 땅을 비스듬히 찍어요. 이 영상 위에 **"여기가 회덕터널!"** 이름표를 붙이고 싶어요.
드론이 계속 움직이니까 이름표도 **졸졸 따라다녀야** 해요.
<figure class="fig">
<svg viewBox="0 0 700 220" role="img" aria-label="드론이 앞을 보며 촬영">
<defs>
<marker id="mv" markerWidth="12" markerHeight="12" refX="8" refY="4" orient="auto"><path d="M0,0 L8,4 L0,8 Z" fill="#2563eb"/></marker>
<marker id="mc" markerWidth="12" markerHeight="12" refX="8" refY="4" orient="auto"><path d="M0,0 L8,4 L0,8 Z" fill="#ea580c"/></marker>
</defs>
<rect x="0" y="0" width="700" height="150" fill="#eef6ff"/><rect x="0" y="150" width="700" height="70" fill="#eaf7ea"/>
<line x1="0" y1="150" x2="700" y2="150" stroke="#bcd8bc" stroke-width="2"/>
<g transform="translate(110,50)"><rect x="-24" y="-7" width="48" height="14" rx="4" fill="#334155"/><circle cx="-24" cy="0" r="8" fill="none" stroke="#334155" stroke-width="3"/><circle cx="24" cy="0" r="8" fill="none" stroke="#334155" stroke-width="3"/><text x="0" y="-14" text-anchor="middle" font-size="12" font-weight="bold" fill="#334155">드론</text></g>
<line x1="140" y1="50" x2="280" y2="50" stroke="#2563eb" stroke-width="3" stroke-dasharray="7 5" marker-end="url(#mv)"/><text x="220" y="40" text-anchor="middle" font-size="12" fill="#2563eb" font-weight="bold">이동 방향 ▶</text>
<line x1="118" y1="58" x2="470" y2="148" stroke="#ea580c" stroke-width="3" marker-end="url(#mc)"/><text x="300" y="105" text-anchor="middle" font-size="12" fill="#ea580c" font-weight="bold">카메라 시선(앞·아래로 비스듬히)</text>
<text x="485" y="145" font-size="20">🚇</text><text x="520" y="175" text-anchor="middle" font-size="12" font-weight="bold" fill="#166534">회덕터널 (앞쪽!)</text>
<line x1="60" y1="200" x2="660" y2="200" stroke="#8a7a5c" stroke-width="5"/>
</svg>
<figcaption>드론은 앞으로 날며 앞쪽 땅을 비스듬히 본다. 터널은 드론 바로 밑이 아니라 '앞쪽'에 찍힌다.</figcaption>
</figure>
## 1-2. 핵심 아이디어 — '창문에 스티커 붙이기' 🪟
카메라(눈)에서 터널로 **선을 긋고**, 그 선이 **화면(창문)을 뚫는 점**에 이름표를 붙여요.
<figure class="fig">
<svg viewBox="0 0 700 210" role="img" aria-label="창문 스티커 원리">
<defs><marker id="me" markerWidth="12" markerHeight="12" refX="8" refY="4" orient="auto"><path d="M0,0 L8,4 L0,8 Z" fill="#0f766e"/></marker></defs>
<text x="70" y="115" text-anchor="middle" font-size="30">👁️</text><text x="70" y="150" text-anchor="middle" font-size="12" fill="#0f766e" font-weight="bold">카메라(눈)</text>
<text x="620" y="100" text-anchor="middle" font-size="36">🌳</text><text x="620" y="140" text-anchor="middle" font-size="12" fill="#166534" font-weight="bold">터널(나무)</text>
<line x1="95" y1="108" x2="600" y2="88" stroke="#0f766e" stroke-width="2.5" marker-end="url(#me)"/>
<rect x="330" y="35" width="80" height="150" rx="6" fill="#e0f2fe" stroke="#0284c7" stroke-width="2" opacity="0.8"/><text x="370" y="200" text-anchor="middle" font-size="12" fill="#0369a1" font-weight="bold">창문 = 영상 화면</text>
<circle cx="370" cy="97" r="8" fill="#ef4444"/><text x="370" y="78" text-anchor="middle" font-size="12" fill="#b91c1c" font-weight="bold">스티커!(이름표 자리)</text>
</svg>
</figure>
## 1-3. 실제로는 3단계로 계산해요
<figure class="fig">
<svg viewBox="0 0 700 140" role="img" aria-label="투영 3단계">
<defs><marker id="a3" markerWidth="11" markerHeight="11" refX="7" refY="3.5" orient="auto"><path d="M0,0 L7,3.5 L0,7 Z" fill="#7c3aed"/></marker></defs>
<rect x="20" y="45" width="190" height="50" rx="8" fill="#ecfdf5" stroke="#16a34a"/><text x="115" y="68" text-anchor="middle" font-size="12" font-weight="bold" fill="#166534">① 미터로 바꿔</text><text x="115" y="85" text-anchor="middle" font-size="10" fill="#166534">드론 기준 위치 재기</text>
<line x1="210" y1="70" x2="245" y2="70" stroke="#7c3aed" stroke-width="2" marker-end="url(#a3)"/>
<rect x="250" y="45" width="190" height="50" rx="8" fill="#eff6ff" stroke="#2563eb"/><text x="345" y="68" text-anchor="middle" font-size="12" font-weight="bold" fill="#1e3a8a">② 카메라 기울기만큼</text><text x="345" y="85" text-anchor="middle" font-size="10" fill="#1e3a8a">방향 돌리기(회전)</text>
<line x1="440" y1="70" x2="475" y2="70" stroke="#7c3aed" stroke-width="2" marker-end="url(#a3)"/>
<rect x="480" y="45" width="200" height="50" rx="8" fill="#fdf2f8" stroke="#db2777"/><text x="580" y="68" text-anchor="middle" font-size="12" font-weight="bold" fill="#9d174d">③ 화면 점 찾기</text><text x="580" y="85" text-anchor="middle" font-size="10" fill="#9d174d">(창문 스티커 · 핀홀)</text>
</svg>
<figcaption>결과: "터널은 화면 가로 60%, 세로 70% 지점" → 거기에 이름표를 딱!</figcaption>
</figure>
| 단계 | 진짜 기술 | 쉽게 말하면 |
|---|---|---|
| ① 미터로 바꾸기 | proj4 · EPSG:5186(한국 TM) | 둥근 지구를 평평한 미터 지도로 펴기 |
| ② 방향 돌리기 | 회전행렬(yaw·pitch·roll) | 카메라 기울기를 숫자표로 한 번에 적용 |
| ③ 화면 점 찾기 | 핀홀 카메라 모델·초점거리 | 바늘구멍 사진기 원리로 점 찍기 |
| (+) 부드럽게 | 보간 · EMA 평활 | 30장 사이 채우고 드론 떨림 없애기 |
> 💡 원래 파이썬 실험 프로그램(`advanced_tuner_v2.py`)의 계산을 **웹 브라우저(TypeScript)로 이식** → 설치 없이 실행.
---
# Part 2. 시간이 아니라 '측점'으로 움직이는 재생 바 📏
## 2-1. 무엇이 다른가요?
보통 플레이어의 아래 막대는 **시간(0:00 ~ 끝)** 기준이에요. 그런데 철도 점검은 **"몇 분"이 아니라 "몇 측점"** 으로 말해요. 그래서 GhiVideo는 막대를 **측점(측점값, 예: 160k130)** 기준으로 바꿨어요.
<figure class="fig">
<svg viewBox="0 0 700 230" role="img" aria-label="시간 축 재생바 vs 측점 축 재생바">
<text x="350" y="24" text-anchor="middle" font-size="13" font-weight="bold" fill="#475569">보통 플레이어 (시간 축)</text>
<rect x="40" y="38" width="620" height="26" rx="6" fill="#e5e7eb" stroke="#9ca3af"/>
<rect x="40" y="38" width="260" height="26" rx="6" fill="#9ca3af"/>
<circle cx="300" cy="51" r="10" fill="#374151"/>
<text x="45" y="82" font-size="11" fill="#374151">0:00</text><text x="330" y="82" font-size="11" fill="#374151">03:12</text><text x="632" y="82" font-size="11" fill="#374151">09:28</text>
<text x="350" y="104" text-anchor="middle" font-size="11" fill="#6b7280">"3분 12초 지점" — 여기가 어디 선로인지 모름 😵</text>
<text x="350" y="150" text-anchor="middle" font-size="13" font-weight="bold" fill="#166534">GhiVideo (측점 축)</text>
<rect x="40" y="164" width="620" height="26" rx="6" fill="#fde9c8" stroke="#e0a94f"/>
<rect x="40" y="164" width="300" height="26" rx="6" fill="#f59e0b"/>
<circle cx="340" cy="177" r="11" fill="#ea580c"/><text x="340" y="181" text-anchor="middle" font-size="9" fill="#fff" font-weight="bold">160k</text>
<line x1="200" y1="160" x2="200" y2="194" stroke="#0284c7" stroke-width="2"/><text x="200" y="210" text-anchor="middle" font-size="9" fill="#0369a1">회덕터널</text>
<line x1="470" y1="160" x2="470" y2="194" stroke="#0284c7" stroke-width="2"/><text x="470" y="210" text-anchor="middle" font-size="9" fill="#0369a1">회덕천교</text>
<text x="45" y="228" font-size="11" fill="#166534">대전조차장</text><text x="600" y="228" font-size="11" fill="#166534">신탄진</text>
</svg>
<figcaption>시간 축은 "몇 분"만 안다. 측점 축은 "지금 어느 선로 위치(측점)"와 "무슨 구조물 근처"인지 바로 보인다.</figcaption>
</figure>
## 2-2. 측점이 뭐예요? 📍
철도에서 **출발점부터 몇 미터 왔는지**를 나타내는 '거리 이정표'예요.
`160k130` = 출발점에서 **160km + 130m** 지점. (도로의 'km 표지판'과 똑같아요!)
## 2-3. 어떻게 만들었나 — "시간↔측점 지도" 🗺️
비밀은 **드론 GPS**예요. 영상의 **매 프레임(사진 한 장)** 마다 두 가지를 계산해 **짝지어** 둬요:
<figure class="fig">
<svg viewBox="0 0 700 250" role="img" aria-label="프레임마다 시간과 측점을 짝짓기">
<defs><marker id="ad" markerWidth="10" markerHeight="10" refX="7" refY="3.5" orient="auto"><path d="M0,0 L7,3.5 L0,7 Z" fill="#7c3aed"/></marker></defs>
<rect x="250" y="15" width="200" height="40" rx="8" fill="#f1f5f9" stroke="#475569"/>
<text x="350" y="33" text-anchor="middle" font-size="12" font-weight="bold" fill="#334155">영상 프레임 (사진 1장)</text>
<text x="350" y="49" text-anchor="middle" font-size="10" fill="#475569">번호 + 드론 GPS 기록</text>
<line x1="300" y1="55" x2="200" y2="80" stroke="#7c3aed" stroke-width="2" marker-end="url(#ad)"/>
<line x1="400" y1="55" x2="500" y2="80" stroke="#7c3aed" stroke-width="2" marker-end="url(#ad)"/>
<rect x="70" y="82" width="260" height="52" rx="8" fill="#eff6ff" stroke="#2563eb"/>
<text x="200" y="103" text-anchor="middle" font-size="12" font-weight="bold" fill="#1e3a8a">프레임번호 ÷ fps = 시간</text>
<text x="200" y="122" text-anchor="middle" font-size="10" fill="#1e40af">예: 5760번 ÷ 30 = 192초</text>
<rect x="370" y="82" width="270" height="52" rx="8" fill="#f0fdf4" stroke="#16a34a"/>
<text x="505" y="103" text-anchor="middle" font-size="12" font-weight="bold" fill="#166534">GPS → 측점 (선로에 투영)</text>
<text x="505" y="122" text-anchor="middle" font-size="10" fill="#15803d">예: 위/경도 → 160k130</text>
<line x1="200" y1="134" x2="330" y2="170" stroke="#7c3aed" stroke-width="2" marker-end="url(#ad)"/>
<line x1="505" y1="134" x2="370" y2="170" stroke="#7c3aed" stroke-width="2" marker-end="url(#ad)"/>
<rect x="210" y="172" width="280" height="46" rx="8" fill="#fef3c7" stroke="#ca8a04"/>
<text x="350" y="193" text-anchor="middle" font-size="12" font-weight="bold" fill="#854d0e">시간 ↔ 측점 지도 완성!</text>
<text x="350" y="210" text-anchor="middle" font-size="10" fill="#a16207">"192초 = 160k130" 처럼 서로 변환 가능</text>
</svg>
<figcaption>프레임마다 (시간)과 (측점)을 계산해 짝지으면, 시간↔측점을 자유롭게 오갈 수 있다.</figcaption>
</figure>
**GPS를 측점으로 바꾸는 법(②):** 드론 GPS 점을 **선로(측점들을 이은 선)에 수직으로 툭 떨어뜨려**, 그 발자국이 몇 측점인지 읽어요. (코드: `projectToChain` / `projectChainage`)
<figure class="fig">
<svg viewBox="0 0 700 160" role="img" aria-label="드론 GPS를 선로에 수직 투영">
<line x1="60" y1="110" x2="640" y2="70" stroke="#8a7a5c" stroke-width="6"/>
<text x="70" y="135" font-size="11" fill="#6b4f2a">선로(측점들을 이은 선)</text>
<circle cx="360" cy="30" r="7" fill="#2563eb"/><text x="360" y="22" text-anchor="middle" font-size="10" fill="#1e3a8a">드론 GPS</text>
<line x1="360" y1="30" x2="373" y2="93" stroke="#ef4444" stroke-width="2" stroke-dasharray="4 3"/>
<circle cx="373" cy="93" r="5" fill="#ef4444"/><text x="410" y="96" font-size="11" fill="#b91c1c" font-weight="bold">발자국 = 160k130</text>
</svg>
<figcaption>드론이 선로 바로 위가 아니어도, 수직으로 내린 발자국 위치로 '측점값'을 정확히 읽는다.</figcaption>
</figure>
## 2-4. 똑똑한 점 3가지 ✨
### ① 막대의 가로축 = '시간'이 아니라 '실제 이동 거리'
드론이 **멈춰서 맴돌면 시간은 흐르지만 위치는 그대로**예요. 이때 막대 커서가 계속 가면 이상하죠.
그래서 가로축을 **실제 이동한 거리(측점 변화량 누적)** 로 만들었어요. → **멈추면 커서도 멈추고, 움직이면 전진.**
<figure class="fig">
<svg viewBox="0 0 700 150" role="img" aria-label="이동거리 축 - 멈추면 커서 정지">
<text x="175" y="22" text-anchor="middle" font-size="12" font-weight="bold" fill="#475569">시간 축(옛방식)</text>
<rect x="40" y="34" width="270" height="20" rx="5" fill="#e5e7eb" stroke="#9ca3af"/><circle cx="230" cy="44" r="8" fill="#374151"/>
<text x="175" y="78" text-anchor="middle" font-size="10" fill="#b91c1c">드론이 멈춰도 커서가 계속 감 😵</text>
<text x="525" y="22" text-anchor="middle" font-size="12" font-weight="bold" fill="#166534">이동거리 축(새방식)</text>
<rect x="390" y="34" width="270" height="20" rx="5" fill="#fde9c8" stroke="#e0a94f"/><circle cx="560" cy="44" r="8" fill="#ea580c"/>
<text x="525" y="78" text-anchor="middle" font-size="10" fill="#166534">멈추면 커서도 멈춤, 움직일 때만 전진 😎</text>
</svg>
</figure>
### ② 방향을 색으로 — 측점이 늘면 주황, 줄면 하늘색
드론이 앞으로 가면(측점 증가) **주황**, 되돌아오면(측점 감소) **하늘색** 으로 칠해 **진행 방향**을 한눈에 보여줘요.
### ③ 구조물을 제자리에 — 교량·터널·역은 '측점값'에 배치
교량·터널·역사를 각자의 **측점값 위치**에 표시해서, "이 구조물이 어디쯤인지" 막대만 봐도 알 수 있어요.
## 2-5. 측점으로 '찾아가기'(Seek)
측점값을 입력하거나 막대를 누르면 → **그 측점이 나오는 시간을 지도에서 찾아 → 그 시간으로 이동**해요.
(시간↔측점 지도가 있으니 가능한 일!)
<figure class="fig">
<svg viewBox="0 0 700 90" role="img" aria-label="측점 입력으로 이동">
<defs><marker id="as" markerWidth="10" markerHeight="10" refX="7" refY="3.5" orient="auto"><path d="M0,0 L7,3.5 L0,7 Z" fill="#7c3aed"/></marker></defs>
<rect x="30" y="30" width="180" height="34" rx="6" fill="#f0fdf4" stroke="#16a34a"/><text x="120" y="52" text-anchor="middle" font-size="12" fill="#166534">"160k130" 입력</text>
<line x1="210" y1="47" x2="255" y2="47" stroke="#7c3aed" stroke-width="2" marker-end="url(#as)"/>
<rect x="260" y="30" width="200" height="34" rx="6" fill="#fef3c7" stroke="#ca8a04"/><text x="360" y="52" text-anchor="middle" font-size="11" fill="#854d0e">지도에서 시간 찾기(192초)</text>
<line x1="460" y1="47" x2="505" y2="47" stroke="#7c3aed" stroke-width="2" marker-end="url(#as)"/>
<rect x="510" y="30" width="160" height="34" rx="6" fill="#eff6ff" stroke="#2563eb"/><text x="590" y="52" text-anchor="middle" font-size="12" fill="#1e3a8a">그 시간으로 재생</text>
</svg>
</figure>
## 2-6. 핵심 기술 / 파일
| 하는 일 | 기술 | 파일 · 함수 |
|---|---|---|
| GPS → 측점값 | 선분에 수직 투영 | [chainage.ts](../client/src/utils/chainage.ts) `projectToChain` |
| 프레임 → 시간 | fps 자동 산출 | VideoPlayer.tsx `effectiveFps` |
| 시간↔측점 지도 | 프레임별 precompute | [StationBar.tsx](../client/src/stationbar/StationBar.tsx) |
| 이동거리 축 | 측점 변화량 누적(정규화) | StationBar.tsx `frac` |
| 방향 색·구조물 배치 | 측점값 기준 렌더 | stationbar/components/* |
---
## 마무리 — 한 문장씩 🎁
- **Part 1 (좌표 투영):** "드론이 **어디서·어느 쪽**을 보는지 알기 때문에, 지도 속 터널이 **영상 화면 어디**에 보일지 계산해 이름표를 딱 붙인다."
- **Part 2 (측점 기반):** "드론 **GPS로 매 프레임의 측점**을 계산해 **시간↔측점 지도**를 만들어, **시간이 아니라 측점으로** 영상을 탐색한다."
> 두 기술 모두 **드론의 GPS·자세 데이터**를 똑똑하게 활용한 결과예요.
> 그래서 GhiVideo는 단순 영상이 아니라 **"선로 위 어디를 보고 있는지 아는" 주행영상 플레이어**가 됩니다. 🚁📏
@@ -0,0 +1,434 @@
<!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>발표_핵심기술_드론좌표를_영상에_맞추기_쉬운설명</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">발표_핵심기술_드론좌표를_영상에_맞추기_쉬운설명</h1>
</header>
<h1 id="드론이-본-것을-지도랑-딱-맞추는-마법-발표용-쉬운-설명서">드론이 본 것을 지도랑 &#39;&#39; 맞추는 마법 (발표용 쉬운 설명서)</h1>
<blockquote>
<p>이 글은 <strong>초등학생도 이해할 수 있게</strong> 그림과 이야기로 쓴 발표 자료예요. 주제: <strong>&quot;드론이 찍은 영상 위에, 지도 속 터널·다리 이름표를 어떻게 정확한 자리에 붙일까?&quot;</strong> (진짜 수학 공식은 <a href="../client/src/utils/geoProjection.ts">geoProjection.ts</a> 참고)</p>
</blockquote>
<style>
figure.fig{margin:22px 0;text-align:center;page-break-inside:avoid;break-inside:avoid;}
figure.fig svg{width:100%;max-width:700px;height:auto;border:1px solid #e7e0d2;border-radius:10px;background:#fffdf8;}
figure.fig figcaption{font-size:0.9em;color:#777;margin-top:6px;}
text{font-family:'Malgun Gothic','Hancom Gothic',sans-serif;}
.big{font-size:1.05em;line-height:1.7;}
</style>
<hr />
<h2 id="0-한-문장으로-말하면">0. 한 문장으로 말하면</h2>
<p><strong>&quot;드론이 지금 어디서, 어느 쪽을 보고 있는지&quot;</strong> 를 알기 때문에, <strong>&quot;지도 속 터널이 영상 화면의 어느 점에 보일지&quot;</strong> 를 계산해서 이름표를 딱 붙이는 기술이에요.</p>
<blockquote>
<p>🎯 <strong>비유</strong>: 내가 서 있는 자리와 바라보는 방향을 알면, &quot;저 멀리 나무가 내 눈앞 어디쯤 보일지&quot; 알 수 있죠? 그걸 컴퓨터가 대신 해 주는 거예요.</p>
</blockquote>
<hr />
<h2 id="1-우리가-풀고-싶은-문제--드론은-앞을-보며-날아가요">1. 우리가 풀고 싶은 문제 — 드론은 &#39;앞을 보며&#39; 날아가요</h2>
<p>드론은 가만히 아래를 내려다보는 게 아니라, <strong>앞으로 날아가면서 자기 앞쪽 땅을 비스듬히</strong> 봐요. (자동차 운전할 때 바로 앞 범퍼가 아니라, 저~ 앞 도로를 보는 것과 똑같아요! 🚗)</p>
<figure class="fig">
<svg viewBox="0 0 700 340" role="img" aria-label="드론이 앞을 보며 날아가며 터널을 촬영">
<defs>
<marker id="mv" markerWidth="12" markerHeight="12" refX="8" refY="4" orient="auto"><path d="M0,0 L8,4 L0,8 Z" fill="#2563eb"></path></marker>
<marker id="mc" markerWidth="12" markerHeight="12" refX="8" refY="4" orient="auto"><path d="M0,0 L8,4 L0,8 Z" fill="#ea580c"></path></marker>
</defs>
<!-- 하늘/땅 -->
<rect x="0" y="0" width="700" height="230" fill="#eef6ff"></rect>
<rect x="0" y="230" width="700" height="110" fill="#eaf7ea"></rect>
<line x1="0" y1="230" x2="700" y2="230" stroke="#bcd8bc" stroke-width="2"></line>
<!-- 드론 -->
<g transform="translate(120,70)">
<rect x="-26" y="-8" width="52" height="16" rx="4" fill="#334155"></rect>
<circle cx="-26" cy="0" r="9" fill="none" stroke="#334155" stroke-width="3"></circle>
<circle cx="26" cy="0" r="9" fill="none" stroke="#334155" stroke-width="3"></circle>
<text x="0" y="-16" text-anchor="middle" font-size="13" font-weight="bold" fill="#334155">드론</text>
</g>
<!-- 이동 방향 -->
<line x1="150" y1="70" x2="300" y2="70" stroke="#2563eb" stroke-width="3" stroke-dasharray="7 5" marker-end="url(#mv)"></line>
<text x="235" y="60" text-anchor="middle" font-size="13" fill="#2563eb" font-weight="bold">이동 방향 ▶</text>
<!-- 카메라 시선 (앞·아래로 비스듬히) -->
<line x1="128" y1="80" x2="470" y2="228" stroke="#ea580c" stroke-width="3" marker-end="url(#mc)"></line>
<text x="300" y="150" text-anchor="middle" font-size="13" fill="#ea580c" font-weight="bold">카메라 시선 (앞·아래로 비스듬히)</text>
<!-- 드론 바로 아래 -->
<line x1="120" y1="80" x2="120" y2="230" stroke="#94a3b8" stroke-width="2" stroke-dasharray="4 4"></line>
<text x="120" y="250" text-anchor="middle" font-size="11" fill="#64748b">드론 바로 밑</text>
<!-- 터널 -->
<text x="480" y="222" text-anchor="middle" font-size="22">🚇</text>
<text x="520" y="255" text-anchor="middle" font-size="13" font-weight="bold" fill="#166534">회덕터널 (드론보다 앞!)</text>
<!-- 기찻길 -->
<line x1="60" y1="300" x2="660" y2="300" stroke="#8a7a5c" stroke-width="6"></line>
<line x1="60" y1="290" x2="660" y2="290" stroke="#8a7a5c" stroke-width="2"></line>
<line x1="60" y1="310" x2="660" y2="310" stroke="#8a7a5c" stroke-width="2"></line>
</svg>
<figcaption>드론은 앞으로 날며(파란 화살표), 자기보다 <b>앞쪽</b> 땅을 비스듬히 본다(주황 시선). 그래서 터널은 드론 바로 밑이 아니라 <b>앞쪽</b>에 찍힌다.</figcaption>
</figure>
<p><strong>어려운 점:</strong> 드론은 계속 날아가고 바람에 흔들려요. 그래서 이름표도 터널을 <strong>졸졸 따라다녀야</strong> 해요. → &quot;이 터널이 지금 화면의 <strong>어느 점</strong>에 보이는지&quot;를 매 순간(1초에 60번!) 계산해야 해요.</p>
<hr />
<h2 id="2-우리가-가진-단서-4가지-">2. 우리가 가진 &#39;단서&#39; 4가지 🔍</h2>
<p>탐정처럼, 우리는 이미 몇 가지를 알고 있어요. 이 4개만 있으면 계산이 돼요!</p>
<figure class="fig">
<svg viewBox="0 0 700 200" role="img" aria-label="계산에 필요한 단서 4가지">
<rect x="20" y="30" width="150" height="140" rx="10" fill="#ecfdf5" stroke="#16a34a"></rect>
<text x="95" y="60" text-anchor="middle" font-size="26">📍</text>
<text x="95" y="95" text-anchor="middle" font-size="13" font-weight="bold" fill="#166534">① 드론 위치</text>
<text x="95" y="118" text-anchor="middle" font-size="11" fill="#166534">하늘의 &#39;주소&#39;</text>
<text x="95" y="136" text-anchor="middle" font-size="11" fill="#166534">(GPS 위도·경도·높이)</text>
<rect x="190" y="30" width="150" height="140" rx="10" fill="#eff6ff" stroke="#2563eb"></rect>
<text x="265" y="60" text-anchor="middle" font-size="26">🧭</text>
<text x="265" y="95" text-anchor="middle" font-size="13" font-weight="bold" fill="#1e3a8a">② 보는 방향</text>
<text x="265" y="118" text-anchor="middle" font-size="11" fill="#1e3a8a">어디를·얼마나</text>
<text x="265" y="136" text-anchor="middle" font-size="11" fill="#1e3a8a">기울여 보나</text>
<rect x="360" y="30" width="150" height="140" rx="10" fill="#fefce8" stroke="#ca8a04"></rect>
<text x="435" y="60" text-anchor="middle" font-size="26">🚇</text>
<text x="435" y="95" text-anchor="middle" font-size="13" font-weight="bold" fill="#854d0e">③ 터널 위치</text>
<text x="435" y="118" text-anchor="middle" font-size="11" fill="#854d0e">땅의 &#39;주소&#39;</text>
<text x="435" y="136" text-anchor="middle" font-size="11" fill="#854d0e">(지도 GPS)</text>
<rect x="530" y="30" width="150" height="140" rx="10" fill="#fdf2f8" stroke="#db2777"></rect>
<text x="605" y="60" text-anchor="middle" font-size="26">🔎</text>
<text x="605" y="95" text-anchor="middle" font-size="13" font-weight="bold" fill="#9d174d">④ 렌즈 정보</text>
<text x="605" y="118" text-anchor="middle" font-size="11" fill="#9d174d">얼마나 당겨</text>
<text x="605" y="136" text-anchor="middle" font-size="11" fill="#9d174d">찍나 (줌)</text>
</svg>
<figcaption>이 4가지 단서로 &quot;터널이 화면 어디에 보일지&quot;를 계산한다.</figcaption>
</figure>
<hr />
<h2 id="3-핵심-아이디어--창문에-스티커-붙이기-">3. 핵심 아이디어 — &#39;창문에 스티커 붙이기&#39; 🪟</h2>
<p><strong>이 그림 하나만 이해하면 끝!</strong> 가장 중요한 부분이에요.</p>
<p>내가 창문 안에서 밖의 나무를 봐요. <strong>내 눈에서 나무로 직선</strong>을 쭉 그으면, 그 선이 <strong>창문 유리를 통과하는 점</strong>이 있죠? 거기에 스티커를 붙이면 나무 위에 딱 겹쳐요!</p>
<figure class="fig">
<svg viewBox="0 0 700 260" role="img" aria-label="창문에 스티커 붙이기 원리">
<defs><marker id="me" markerWidth="12" markerHeight="12" refX="8" refY="4" orient="auto"><path d="M0,0 L8,4 L0,8 Z" fill="#0f766e"></path></marker></defs>
<!---->
<text x="70" y="135" text-anchor="middle" font-size="34">👁️</text>
<text x="70" y="170" text-anchor="middle" font-size="12" fill="#0f766e" font-weight="bold">카메라(눈)</text>
<!-- 나무 -->
<text x="620" y="120" text-anchor="middle" font-size="40">🌳</text>
<text x="620" y="160" text-anchor="middle" font-size="12" fill="#166534" font-weight="bold">터널(나무)</text>
<!-- 시선 -->
<line x1="95" y1="125" x2="600" y2="105" stroke="#0f766e" stroke-width="2.5" marker-end="url(#me)"></line>
<!-- 창문 -->
<rect x="330" y="40" width="90" height="180" rx="6" fill="#e0f2fe" stroke="#0284c7" stroke-width="2" opacity="0.8"></rect>
<text x="375" y="238" text-anchor="middle" font-size="12" fill="#0369a1" font-weight="bold">창문 = 영상 화면</text>
<!-- 스티커 점 (시선이 창을 뚫는 곳) -->
<circle cx="375" cy="115" r="8" fill="#ef4444"></circle>
<text x="375" y="90" text-anchor="middle" font-size="12" fill="#b91c1c" font-weight="bold">스티커!</text>
<text x="375" y="105" text-anchor="middle" font-size="10" fill="#b91c1c">(이름표 붙는 자리)</text>
</svg>
<figcaption>카메라(눈)에서 터널(나무)로 선을 긋고, 그 선이 <b>화면(창문)을 뚫는 점</b>에 이름표를 붙인다.</figcaption>
</figure>
<blockquote>
<p>👉 그래서 하는 일은 딱 하나예요: <strong>&quot;카메라에서 터널로 선을 긋고, 그 선이 화면을 뚫는 점을 찾는다.&quot;</strong></p>
</blockquote>
<hr />
<h2 id="4-실제-계산은-3번에-나눠서-해요-">4. 실제 계산은 3번에 나눠서 해요 ✏️</h2>
<h3 id="계산-①-터널은-나드론-기준으로-어디-있지">계산 ① 터널은 &#39;나(드론) 기준으로&#39; 어디 있지?</h3>
<p>GPS 주소(위도·경도)는 <strong>지구본 위 각도</strong>라 계산이 불편해요. 그래서 먼저 <strong>모눈종이(미터 단위)</strong> 로 바꿔요. 그다음 드론을 중심(0,0)에 놓고 거리를 재요.</p>
<figure class="fig">
<svg viewBox="0 0 700 230" role="img" aria-label="미터 좌표로 바꾸고 드론 기준 거리 재기">
<!-- 지구본 -->
<circle cx="90" cy="115" r="45" fill="#dbeafe" stroke="#2563eb"></circle>
<path d="M50,115 h80 M90,72 v86 M60,90 q30,25 60,0 M60,140 q30,-25 60,0" stroke="#2563eb" fill="none" stroke-width="1"></path>
<text x="90" y="185" text-anchor="middle" font-size="11" fill="#1e3a8a">지구본 각도</text>
<text x="90" y="200" text-anchor="middle" font-size="10" fill="#1e3a8a">(위도·경도)</text>
<!-- 화살표 -->
<text x="185" y="118" text-anchor="middle" font-size="24" fill="#64748b"></text>
<text x="185" y="140" text-anchor="middle" font-size="10" fill="#64748b">바꾸기</text>
<!-- 모눈종이 -->
<g transform="translate(250,45)">
<rect x="0" y="0" width="160" height="140" fill="#fffdf8" stroke="#ca8a04"></rect>
<g stroke="#eadfae" stroke-width="1">
<line x1="32" y1="0" x2="32" y2="140"></line><line x1="64" y1="0" x2="64" y2="140"></line><line x1="96" y1="0" x2="96" y2="140"></line><line x1="128" y1="0" x2="128" y2="140"></line>
<line x1="0" y1="35" x2="160" y2="35"></line><line x1="0" y1="70" x2="160" y2="70"></line><line x1="0" y1="105" x2="160" y2="105"></line>
</g>
<!-- 드론(중심) -->
<circle cx="48" cy="105" r="6" fill="#334155"></circle>
<text x="48" y="128" text-anchor="middle" font-size="10" fill="#334155">드론(0,0)</text>
<!-- 터널 -->
<text x="120" y="45" text-anchor="middle" font-size="16">🚇</text>
<!-- 거리 화살표 -->
<line x1="48" y1="105" x2="120" y2="105" stroke="#16a34a" stroke-width="1.5" stroke-dasharray="3 3"></line>
<line x1="120" y1="105" x2="120" y2="45" stroke="#16a34a" stroke-width="1.5" stroke-dasharray="3 3"></line>
</g>
<text x="470" y="80" font-size="13" fill="#166534" font-weight="bold">드론 기준으로 재보니…</text>
<text x="470" y="108" font-size="12" fill="#166534">• 앞으로 200 m</text>
<text x="470" y="130" font-size="12" fill="#166534">• 오른쪽으로 50 m</text>
<text x="470" y="152" font-size="12" fill="#166534">• 아래로 80 m</text>
</svg>
<figcaption>각도를 미터로 바꾼 뒤, 드론을 중심에 놓고 &quot;앞/옆/아래로 몇 m&quot;인지 잰다.</figcaption>
</figure>
<h3 id="계산-②-그런데-내-카메라가-기울어져-있잖아">계산 ② 그런데 내 카메라가 기울어져 있잖아</h3>
<p>드론 카메라는 3가지로 방향이 틀어질 수 있어요. 사람이 고개를 움직이는 것과 똑같아요.</p>
<figure class="fig">
<svg viewBox="0 0 700 180" role="img" aria-label="카메라 방향 3가지 yaw pitch roll">
<g transform="translate(120,90)">
<text x="0" y="-45" text-anchor="middle" font-size="30">🙂</text>
<path d="M-45,10 a45,18 0 0 0 90,0" fill="none" stroke="#2563eb" stroke-width="3"></path>
<polygon points="45,10 38,3 38,17" fill="#2563eb"></polygon>
<text x="0" y="45" text-anchor="middle" font-size="13" font-weight="bold" fill="#2563eb">yaw (좌우로 돌기)</text>
<text x="0" y="63" text-anchor="middle" font-size="10" fill="#64748b">고개 도리도리</text>
</g>
<g transform="translate(350,90)">
<text x="0" y="-45" text-anchor="middle" font-size="30">🙂</text>
<path d="M0,-30 a18,45 0 0 0 0,60" fill="none" stroke="#16a34a" stroke-width="3"></path>
<polygon points="0,30 -7,23 7,23" fill="#16a34a"></polygon>
<text x="0" y="45" text-anchor="middle" font-size="13" font-weight="bold" fill="#16a34a">pitch (위아래 숙이기)</text>
<text x="0" y="63" text-anchor="middle" font-size="10" fill="#64748b">고개 끄덕끄덕</text>
</g>
<g transform="translate(580,90)">
<text x="0" y="-45" text-anchor="middle" font-size="30">🙂</text>
<path d="M-30,0 a30,30 0 1 1 8,20" fill="none" stroke="#db2777" stroke-width="3"></path>
<polygon points="-22,20 -30,14 -14,12" fill="#db2777"></polygon>
<text x="0" y="45" text-anchor="middle" font-size="13" font-weight="bold" fill="#db2777">roll (옆으로 갸웃)</text>
<text x="0" y="63" text-anchor="middle" font-size="10" fill="#64748b">고개 갸우뚱</text>
</g>
</svg>
<figcaption>①에서 잰 위치를, 카메라가 실제로 보는 방향(yaw·pitch·roll)에 맞게 살짝 돌려준다.</figcaption>
</figure>
<p>특히 우리 드론은 <strong>앞을 비스듬히</strong> 봐요(pitch). 그래서 터널이 화면 위쪽(멀리)에 작게 보일지, 아래쪽(가까이)에 크게 보일지가 딱 맞아떨어져요.</p>
<h3 id="계산-③-3d를-납작한-화면에-눌러-담기">계산 ③ 3D를 납작한 화면에 눌러 담기</h3>
<p>이제 3번의 <strong>창문 스티커</strong>를 실제로 계산해요. 멀수록 가운데로 작게, 가까울수록 크게·바깥으로!</p>
<figure class="fig">
<svg viewBox="0 0 700 220" role="img" aria-label="원근 투영 - 멀면 작게 가까우면 크게">
<!-- 카메라 -->
<text x="55" y="120" text-anchor="middle" font-size="28">📷</text>
<!-- 시야 원뿔 -->
<polygon points="80,110 660,20 660,200" fill="#f1f5f9" stroke="#94a3b8" stroke-dasharray="4 4"></polygon>
<!-- 화면 평면 -->
<rect x="250" y="55" width="14" height="110" fill="#e0f2fe" stroke="#0284c7"></rect>
<text x="257" y="185" text-anchor="middle" font-size="11" fill="#0369a1">화면</text>
<!-- 가까운 나무 (크게, 바깥) -->
<text x="330" y="80" text-anchor="middle" font-size="26">🌳</text>
<text x="330" y="100" text-anchor="middle" font-size="10" fill="#166534">가까움→크게</text>
<!-- 먼 나무 (작게, 가운데) -->
<text x="610" y="112" text-anchor="middle" font-size="15">🌲</text>
<text x="610" y="130" text-anchor="middle" font-size="10" fill="#166534">멀면→작게</text>
<!-- 화면 위 점 -->
<circle cx="257" cy="88" r="5" fill="#ef4444"></circle>
<circle cx="257" cy="112" r="4" fill="#ef4444"></circle>
</svg>
<figcaption>3D 방향을 납작한 화면 좌표로 바꾸면 → &quot;화면 가로 60%, 세로 70%&quot; 같은 <b>정확한 점</b>이 나온다.</figcaption>
</figure>
<blockquote>
<p>드디어 답: <strong>&quot;터널은 화면의 가로 60%, 세로 70% 지점에 보인다!&quot;</strong> → 거기에 이름표 딱! 🎯</p>
</blockquote>
<hr />
<h2 id="5-마지막-문제--드론이-흔들려요-">5. 마지막 문제 — 드론이 흔들려요 🌬️</h2>
<p>드론은 바람에 흔들려서, 그냥 두면 이름표가 <strong>부들부들</strong> 떨려요. 그래서 <strong>여러 순간(프레임)의 위치를 평균 내서 부드럽게</strong> 만들어요. (친구가 손을 떨며 가리켜도, 우리가 &quot;대충 저기구나&quot; 하고 부드럽게 알아보는 것과 같아요.)</p>
<hr />
<h2 id="6-그래서-진짜-기술은-뭘-썼을까-">6. 그래서 &#39;진짜 기술&#39;은 뭘 썼을까? 🛠️</h2>
<p>지금까지는 <strong>비유</strong>로 설명했어요. 이제 컴퓨터가 실제로 쓴 <strong>진짜 도구(기술)</strong> 이름을 알려줄게요. 어려운 이름이 나오지만, 오른쪽 칸을 보면 <strong>한 줄로 쉽게</strong> 이해돼요. 😊</p>
<figure class="fig">
<svg viewBox="0 0 700 340" role="img" aria-label="비유와 진짜 기술 연결표">
<!-- 헤더 -->
<rect x="20" y="15" width="200" height="34" fill="#fef3c7" stroke="#ca8a04"></rect>
<rect x="220" y="15" width="230" height="34" fill="#e0e7ff" stroke="#4f46e5"></rect>
<rect x="450" y="15" width="230" height="34" fill="#dcfce7" stroke="#16a34a"></rect>
<text x="120" y="37" text-anchor="middle" font-size="12" font-weight="bold" fill="#854d0e">우리가 한 일(비유)</text>
<text x="335" y="37" text-anchor="middle" font-size="12" font-weight="bold" fill="#3730a3">진짜 기술 이름</text>
<text x="565" y="37" text-anchor="middle" font-size="12" font-weight="bold" fill="#166534">쉽게 말하면</text>
<!---->
<g font-size="11">
<text x="30" y="70" fill="#444">지구본 각도 → 모눈종이</text>
<text x="230" y="70" fill="#3730a3" font-weight="bold">proj4 · EPSG:5186 좌표계</text>
<text x="460" y="70" fill="#166534">둥근 지구를 평평한 지도로 펴기</text>
<line x1="20" y1="80" x2="680" y2="80" stroke="#eee"></line>
<text x="30" y="103" fill="#444">카메라 기울기 반영</text>
<text x="230" y="103" fill="#3730a3" font-weight="bold">회전행렬 (Rotation Matrix)</text>
<text x="460" y="103" fill="#166534">방향을 숫자표로 만들어 한 번에 돌리기</text>
<line x1="20" y1="113" x2="680" y2="113" stroke="#eee"></line>
<text x="30" y="136" fill="#444">3D → 화면 점 (창문 스티커)</text>
<text x="230" y="136" fill="#3730a3" font-weight="bold">핀홀 카메라 모델 · 초점거리</text>
<text x="460" y="136" fill="#166534">바늘구멍 사진기 원리로 점 찍기</text>
<line x1="20" y1="146" x2="680" y2="146" stroke="#eee"></line>
<text x="30" y="169" fill="#444">30장 사이 부드럽게</text>
<text x="230" y="169" fill="#3730a3" font-weight="bold">보간 (Interpolation)</text>
<text x="460" y="169" fill="#166534">사진 사이 중간 그림 상상해 채우기</text>
<line x1="20" y1="179" x2="680" y2="179" stroke="#eee"></line>
<text x="30" y="202" fill="#444">흔들림 잡기</text>
<text x="230" y="202" fill="#3730a3" font-weight="bold">이동평균 · EMA 평활</text>
<text x="460" y="202" fill="#166534">여러 값 평균 내 부들거림 없애기</text>
<line x1="20" y1="212" x2="680" y2="212" stroke="#eee"></line>
<text x="30" y="235" fill="#444">1초에 60번 다시 그리기</text>
<text x="230" y="235" fill="#3730a3" font-weight="bold">requestAnimationFrame · Canvas</text>
<text x="460" y="235" fill="#166534">화면 새로 칠할 때마다 그림 그리기</text>
<line x1="20" y1="245" x2="680" y2="245" stroke="#eee"></line>
<text x="30" y="268" fill="#444">영상마다 프레임 수 맞추기</text>
<text x="230" y="268" fill="#3730a3" font-weight="bold">fps 자동 산출 (프레임수÷길이)</text>
<text x="460" y="268" fill="#166534">1초에 몇 장인지 스스로 알아내기</text>
<line x1="20" y1="278" x2="680" y2="278" stroke="#eee"></line>
<text x="30" y="301" fill="#444">전체 화면·버튼·상태</text>
<text x="230" y="301" fill="#3730a3" font-weight="bold">React · TypeScript</text>
<text x="460" y="301" fill="#166534">화면을 블록처럼 조립하는 도구</text>
</g>
<rect x="20" y="15" width="660" height="300" fill="none" stroke="#ddd"></rect>
</svg>
<figcaption>왼쪽(비유) → 가운데(진짜 기술) → 오른쪽(쉬운 뜻). 이 표 한 장이 발표의 &#39;기술 요약 슬라이드&#39;예요.</figcaption>
</figure>
<blockquote>
<p>💡 <strong>재미있는 사실:</strong> 이 계산의 원래 버전은 <strong>파이썬(Python)</strong> 으로 만든 실험 프로그램(<code>advanced_tuner_v2.py</code>)이었어요. 그걸 <strong>웹 브라우저에서 바로 돌아가게 TypeScript로 옮겨서</strong>, 설치 없이 인터넷 창에서 실행돼요. 🌐</p>
</blockquote>
<hr />
<h2 id="7-중요한-기술-3개만-더-깊이-그래도-쉽게-">7. 중요한 기술 3개만 더 깊이 (그래도 쉽게!) 🔎</h2>
<h3 id="7-1-둥근-지구를-평평하게-펴기--좌표계-변환-proj4--epsg5186">7-1. 둥근 지구를 평평하게 펴기 — 좌표계 변환 (proj4 · EPSG:5186)</h3>
<p>지구는 <strong>공(球)</strong> 이라서, 위치를 &quot;각도(위도·경도)&quot;로 말해요. 그런데 각도로는 <strong>&quot;몇 미터 떨어졌지?&quot;</strong> 계산이 어려워요. 그래서 지구 표면을 <strong>평평한 종이에 펴서(투영)</strong>, 미터 단위로 바꿔요. 이때 쓰는 게 <strong>proj4</strong> 라는 도구고, 우리나라에 딱 맞게 만든 지도 규격이 <strong>EPSG:5186 (한국 TM 좌표계)</strong> 예요.</p>
<figure class="fig">
<svg viewBox="0 0 700 180" role="img" aria-label="지구를 평평한 지도로 펴기">
<circle cx="120" cy="90" r="55" fill="#dbeafe" stroke="#2563eb" stroke-width="2"></circle>
<path d="M65,90 h110 M120,35 v110 M80,60 q40,30 80,0 M80,120 q40,-30 80,0" stroke="#2563eb" fill="none"></path>
<text x="120" y="168" text-anchor="middle" font-size="11" fill="#1e3a8a">둥근 지구 (각도)</text>
<text x="300" y="85" text-anchor="middle" font-size="26" fill="#64748b"></text>
<text x="300" y="108" text-anchor="middle" font-size="11" fill="#64748b">proj4 로 펴기</text>
<g transform="translate(430,35)">
<rect x="0" y="0" width="220" height="110" fill="#fffdf8" stroke="#ca8a04"></rect>
<g stroke="#eadfae"><line x1="44" y1="0" x2="44" y2="110"></line><line x1="88" y1="0" x2="88" y2="110"></line><line x1="132" y1="0" x2="132" y2="110"></line><line x1="176" y1="0" x2="176" y2="110"></line><line x1="0" y1="37" x2="220" y2="37"></line><line x1="0" y1="74" x2="220" y2="74"></line></g>
</g>
<text x="540" y="168" text-anchor="middle" font-size="11" fill="#854d0e">평평한 지도 (미터) · EPSG:5186</text>
</svg>
<figcaption>각도로 된 위치를 &#39;자로 잴 수 있는 미터&#39;로 바꾸면, 드론과 터널 사이 거리를 정확히 계산할 수 있다.</figcaption>
</figure>
<h3 id="7-2-방향을-숫자표로-만들어-한-번에-돌리기--회전행렬">7-2. 방향을 &#39;숫자표&#39;로 만들어 한 번에 돌리기 — 회전행렬</h3>
<p>카메라가 좌우(yaw)·위아래(pitch)·갸웃(roll)로 기울어져 있죠? 이 세 방향을 <strong>하나씩 따로</strong> 돌리면 복잡하고 실수해요. 그래서 수학에서는 방향 정보를 <strong>작은 숫자표(행렬)</strong> 로 만들어요. 이 숫자표를 위치에 <strong>한 번 곱하면</strong>, 세 방향 회전이 <strong>동시에 딱</strong> 적용돼요.</p>
<blockquote>
<p>🎯 <strong>비유:</strong> 요리할 때 양념을 하나씩 넣는 대신, <strong>미리 섞어둔 &#39;만능 양념장&#39; 한 숟갈</strong>을 넣으면 끝나는 것과 같아요. 회전행렬 = &quot;방향 돌리기 만능 양념장&quot; 이에요. (우리 코드에선 <code>R_align · R_b2w^T</code> 라는 양념장을 써요.)</p>
</blockquote>
<h3 id="7-3-바늘구멍-사진기-원리--핀홀-카메라-모델">7-3. 바늘구멍 사진기 원리 — 핀홀 카메라 모델</h3>
<p>3D 세상을 <strong>납작한 사진 한 장</strong>으로 만드는 원리예요. 옛날 <strong>바늘구멍 사진기</strong>를 떠올려요: 작은 구멍으로 빛이 들어와 뒤쪽 벽에 그림이 맺혀요. 이때 <strong>멀리 있는 건 작게, 가까운 건 크게</strong> 맺혀요(원근).</p>
<figure class="fig">
<svg viewBox="0 0 700 210" role="img" aria-label="핀홀(바늘구멍) 카메라 원리">
<!-- 물체(터널): x=90, y=30~160 (가운데 y=95) -->
<line x1="90" y1="30" x2="90" y2="160" stroke="#16a34a" stroke-width="6"></line>
<polygon points="90,30 84,44 96,44" fill="#16a34a"></polygon>
<text x="90" y="180" text-anchor="middle" font-size="11" fill="#166534">터널(실제, 큼)</text>
<!-- 구멍 벽 + 바늘구멍(광선 교차점) at (360,95) -->
<line x1="360" y1="18" x2="360" y2="172" stroke="#94a3b8" stroke-width="6"></line>
<circle cx="360" cy="95" r="5.5" fill="#111"></circle>
<text x="360" y="192" text-anchor="middle" font-size="11" fill="#475569">바늘구멍(=렌즈)</text>
<!-- 광선: 물체 양끝 → 바늘구멍(360,95) 통과 → 화면. 반드시 점을 지나 X자로 교차. -->
<line x1="90" y1="30" x2="560" y2="143.1" stroke="#f59e0b" stroke-width="1.5"></line>
<line x1="90" y1="160" x2="560" y2="46.9" stroke="#f59e0b" stroke-width="1.5"></line>
<!-- 화면에 맺힌 상: x=560, 거꾸로 + 작게 (y=46.9~143.1, 가운데 95) -->
<line x1="560" y1="46.9" x2="560" y2="143.1" stroke="#ef4444" stroke-width="5"></line>
<polygon points="560,143.1 554,129.1 566,129.1" fill="#ef4444"></polygon>
<text x="560" y="180" text-anchor="middle" font-size="11" fill="#b91c1c">맺힌 상(작고 거꾸로 ↕)</text>
</svg>
<figcaption>구멍(렌즈)을 지나며 3D가 화면에 맺힌다. &#39;얼마나 당겨 찍나(초점거리)&#39;로 크기가 정해진다.</figcaption>
</figure>
<p>컴퓨터는 이 원리를 <strong>곱셈·나눗셈 몇 번</strong>으로 계산해서, &quot;터널이 화면의 정확히 어느 점&quot;인지 구해요. 여기서 <strong>초점거리(focal length)</strong> 가 클수록(망원처럼 당길수록) 물체가 화면에서 크게 보여요.</p>
<hr />
<h2 id="8-한-장-요약-마지막-슬라이드-">8. 한 장 요약 (마지막 슬라이드) 🎁</h2>
<figure class="fig">
<svg viewBox="0 0 700 300" role="img" aria-label="전체 요약 파이프라인">
<defs><marker id="ms" markerWidth="12" markerHeight="12" refX="8" refY="4" orient="auto"><path d="M0,0 L8,4 L0,8 Z" fill="#7c3aed"></path></marker></defs>
<rect x="150" y="15" width="400" height="42" rx="8" fill="#f5f3ff" stroke="#7c3aed"></rect>
<text x="350" y="41" text-anchor="middle" font-size="13" font-weight="bold" fill="#5b21b6">드론 위치 + 보는 방향 + 터널 위치 + 렌즈 정보</text>
<line x1="350" y1="57" x2="350" y2="80" stroke="#7c3aed" stroke-width="2" marker-end="url(#ms)"></line>
<rect x="150" y="82" width="400" height="42" rx="8" fill="#ecfdf5" stroke="#16a34a"></rect>
<text x="350" y="108" text-anchor="middle" font-size="13" font-weight="bold" fill="#166534">① 미터로 바꿔 &quot;드론 기준 위치&quot; 재기</text>
<line x1="350" y1="124" x2="350" y2="147" stroke="#7c3aed" stroke-width="2" marker-end="url(#ms)"></line>
<rect x="150" y="149" width="400" height="42" rx="8" fill="#eff6ff" stroke="#2563eb"></rect>
<text x="350" y="175" text-anchor="middle" font-size="13" font-weight="bold" fill="#1e3a8a">② 카메라 기울기만큼 방향 돌리기</text>
<line x1="350" y1="191" x2="350" y2="214" stroke="#7c3aed" stroke-width="2" marker-end="url(#ms)"></line>
<rect x="150" y="216" width="400" height="42" rx="8" fill="#fefce8" stroke="#ca8a04"></rect>
<text x="350" y="242" text-anchor="middle" font-size="13" font-weight="bold" fill="#854d0e">③ 창문에 스티커 붙이듯 화면 점 찾기</text>
<line x1="350" y1="258" x2="350" y2="278" stroke="#7c3aed" stroke-width="2" marker-end="url(#ms)"></line>
<text x="350" y="294" text-anchor="middle" font-size="14" font-weight="bold" fill="#b91c1c">🎯 화면의 정확한 자리에 이름표 붙이기!</text>
</svg>
</figure>
<blockquote>
<p><strong>한 문장 결론:</strong> <strong>&quot;드론이 어디서·어느 쪽을 보는지 알기 때문에, 지도 속 터널이 영상 화면 어디에 보일지를 계산해서 이름표를 딱 붙이는 기술&quot;</strong> 이에요.</p>
</blockquote>
<p>&quot;드론 좌표 → 영상 픽셀 3D 투영&quot;이 GhiVideo를 <strong>보통 영상 플레이어와 다르게 만드는 가장 특별한 기술</strong>이랍니다. ✨</p>
</body>
</html>
@@ -0,0 +1,396 @@
# 드론이 본 것을 지도랑 '딱' 맞추는 마법 (발표용 쉬운 설명서)
> 이 글은 **초등학생도 이해할 수 있게** 그림과 이야기로 쓴 발표 자료예요.
> 주제: **"드론이 찍은 영상 위에, 지도 속 터널·다리 이름표를 어떻게 정확한 자리에 붙일까?"**
> (진짜 수학 공식은 [geoProjection.ts](../client/src/utils/geoProjection.ts) 참고)
<style>
figure.fig{margin:22px 0;text-align:center;page-break-inside:avoid;break-inside:avoid;}
figure.fig svg{width:100%;max-width:700px;height:auto;border:1px solid #e7e0d2;border-radius:10px;background:#fffdf8;}
figure.fig figcaption{font-size:0.9em;color:#777;margin-top:6px;}
text{font-family:'Malgun Gothic','Hancom Gothic',sans-serif;}
.big{font-size:1.05em;line-height:1.7;}
</style>
---
## 0. 한 문장으로 말하면
**"드론이 지금 어디서, 어느 쪽을 보고 있는지"** 를 알기 때문에,
**"지도 속 터널이 영상 화면의 어느 점에 보일지"** 를 계산해서 이름표를 딱 붙이는 기술이에요.
> 🎯 **비유**: 내가 서 있는 자리와 바라보는 방향을 알면,
> "저 멀리 나무가 내 눈앞 어디쯤 보일지" 알 수 있죠? 그걸 컴퓨터가 대신 해 주는 거예요.
---
## 1. 우리가 풀고 싶은 문제 — 드론은 '앞을 보며' 날아가요
드론은 가만히 아래를 내려다보는 게 아니라, **앞으로 날아가면서 자기 앞쪽 땅을 비스듬히** 봐요.
(자동차 운전할 때 바로 앞 범퍼가 아니라, 저~ 앞 도로를 보는 것과 똑같아요! 🚗)
<figure class="fig">
<svg viewBox="0 0 700 340" role="img" aria-label="드론이 앞을 보며 날아가며 터널을 촬영">
<defs>
<marker id="mv" markerWidth="12" markerHeight="12" refX="8" refY="4" orient="auto"><path d="M0,0 L8,4 L0,8 Z" fill="#2563eb"/></marker>
<marker id="mc" markerWidth="12" markerHeight="12" refX="8" refY="4" orient="auto"><path d="M0,0 L8,4 L0,8 Z" fill="#ea580c"/></marker>
</defs>
<!-- 하늘/땅 -->
<rect x="0" y="0" width="700" height="230" fill="#eef6ff"/>
<rect x="0" y="230" width="700" height="110" fill="#eaf7ea"/>
<line x1="0" y1="230" x2="700" y2="230" stroke="#bcd8bc" stroke-width="2"/>
<!-- 드론 -->
<g transform="translate(120,70)">
<rect x="-26" y="-8" width="52" height="16" rx="4" fill="#334155"/>
<circle cx="-26" cy="0" r="9" fill="none" stroke="#334155" stroke-width="3"/>
<circle cx="26" cy="0" r="9" fill="none" stroke="#334155" stroke-width="3"/>
<text x="0" y="-16" text-anchor="middle" font-size="13" font-weight="bold" fill="#334155">드론</text>
</g>
<!-- 이동 방향 -->
<line x1="150" y1="70" x2="300" y2="70" stroke="#2563eb" stroke-width="3" stroke-dasharray="7 5" marker-end="url(#mv)"/>
<text x="235" y="60" text-anchor="middle" font-size="13" fill="#2563eb" font-weight="bold">이동 방향 ▶</text>
<!-- 카메라 시선 (앞·아래로 비스듬히) -->
<line x1="128" y1="80" x2="470" y2="228" stroke="#ea580c" stroke-width="3" marker-end="url(#mc)"/>
<text x="300" y="150" text-anchor="middle" font-size="13" fill="#ea580c" font-weight="bold">카메라 시선 (앞·아래로 비스듬히)</text>
<!-- 드론 바로 아래 -->
<line x1="120" y1="80" x2="120" y2="230" stroke="#94a3b8" stroke-width="2" stroke-dasharray="4 4"/>
<text x="120" y="250" text-anchor="middle" font-size="11" fill="#64748b">드론 바로 밑</text>
<!-- 터널 -->
<text x="480" y="222" text-anchor="middle" font-size="22">🚇</text>
<text x="520" y="255" text-anchor="middle" font-size="13" font-weight="bold" fill="#166534">회덕터널 (드론보다 앞!)</text>
<!-- 기찻길 -->
<line x1="60" y1="300" x2="660" y2="300" stroke="#8a7a5c" stroke-width="6"/>
<line x1="60" y1="290" x2="660" y2="290" stroke="#8a7a5c" stroke-width="2"/>
<line x1="60" y1="310" x2="660" y2="310" stroke="#8a7a5c" stroke-width="2"/>
</svg>
<figcaption>드론은 앞으로 날며(파란 화살표), 자기보다 <b>앞쪽</b> 땅을 비스듬히 본다(주황 시선). 그래서 터널은 드론 바로 밑이 아니라 <b>앞쪽</b>에 찍힌다.</figcaption>
</figure>
**어려운 점:** 드론은 계속 날아가고 바람에 흔들려요. 그래서 이름표도 터널을 **졸졸 따라다녀야** 해요.
→ "이 터널이 지금 화면의 **어느 점**에 보이는지"를 매 순간(1초에 60번!) 계산해야 해요.
---
## 2. 우리가 가진 '단서' 4가지 🔍
탐정처럼, 우리는 이미 몇 가지를 알고 있어요. 이 4개만 있으면 계산이 돼요!
<figure class="fig">
<svg viewBox="0 0 700 200" role="img" aria-label="계산에 필요한 단서 4가지">
<rect x="20" y="30" width="150" height="140" rx="10" fill="#ecfdf5" stroke="#16a34a"/>
<text x="95" y="60" text-anchor="middle" font-size="26">📍</text>
<text x="95" y="95" text-anchor="middle" font-size="13" font-weight="bold" fill="#166534">① 드론 위치</text>
<text x="95" y="118" text-anchor="middle" font-size="11" fill="#166534">하늘의 '주소'</text>
<text x="95" y="136" text-anchor="middle" font-size="11" fill="#166534">(GPS 위도·경도·높이)</text>
<rect x="190" y="30" width="150" height="140" rx="10" fill="#eff6ff" stroke="#2563eb"/>
<text x="265" y="60" text-anchor="middle" font-size="26">🧭</text>
<text x="265" y="95" text-anchor="middle" font-size="13" font-weight="bold" fill="#1e3a8a">② 보는 방향</text>
<text x="265" y="118" text-anchor="middle" font-size="11" fill="#1e3a8a">어디를·얼마나</text>
<text x="265" y="136" text-anchor="middle" font-size="11" fill="#1e3a8a">기울여 보나</text>
<rect x="360" y="30" width="150" height="140" rx="10" fill="#fefce8" stroke="#ca8a04"/>
<text x="435" y="60" text-anchor="middle" font-size="26">🚇</text>
<text x="435" y="95" text-anchor="middle" font-size="13" font-weight="bold" fill="#854d0e">③ 터널 위치</text>
<text x="435" y="118" text-anchor="middle" font-size="11" fill="#854d0e">땅의 '주소'</text>
<text x="435" y="136" text-anchor="middle" font-size="11" fill="#854d0e">(지도 GPS)</text>
<rect x="530" y="30" width="150" height="140" rx="10" fill="#fdf2f8" stroke="#db2777"/>
<text x="605" y="60" text-anchor="middle" font-size="26">🔎</text>
<text x="605" y="95" text-anchor="middle" font-size="13" font-weight="bold" fill="#9d174d">④ 렌즈 정보</text>
<text x="605" y="118" text-anchor="middle" font-size="11" fill="#9d174d">얼마나 당겨</text>
<text x="605" y="136" text-anchor="middle" font-size="11" fill="#9d174d">찍나 (줌)</text>
</svg>
<figcaption>이 4가지 단서로 "터널이 화면 어디에 보일지"를 계산한다.</figcaption>
</figure>
---
## 3. 핵심 아이디어 — '창문에 스티커 붙이기' 🪟
**이 그림 하나만 이해하면 끝!** 가장 중요한 부분이에요.
내가 창문 안에서 밖의 나무를 봐요. **내 눈에서 나무로 직선**을 쭉 그으면,
그 선이 **창문 유리를 통과하는 점**이 있죠? 거기에 스티커를 붙이면 나무 위에 딱 겹쳐요!
<figure class="fig">
<svg viewBox="0 0 700 260" role="img" aria-label="창문에 스티커 붙이기 원리">
<defs><marker id="me" markerWidth="12" markerHeight="12" refX="8" refY="4" orient="auto"><path d="M0,0 L8,4 L0,8 Z" fill="#0f766e"/></marker></defs>
<!-- 눈 -->
<text x="70" y="135" text-anchor="middle" font-size="34">👁️</text>
<text x="70" y="170" text-anchor="middle" font-size="12" fill="#0f766e" font-weight="bold">카메라(눈)</text>
<!-- 나무 -->
<text x="620" y="120" text-anchor="middle" font-size="40">🌳</text>
<text x="620" y="160" text-anchor="middle" font-size="12" fill="#166534" font-weight="bold">터널(나무)</text>
<!-- 시선 -->
<line x1="95" y1="125" x2="600" y2="105" stroke="#0f766e" stroke-width="2.5" marker-end="url(#me)"/>
<!-- 창문 -->
<rect x="330" y="40" width="90" height="180" rx="6" fill="#e0f2fe" stroke="#0284c7" stroke-width="2" opacity="0.8"/>
<text x="375" y="238" text-anchor="middle" font-size="12" fill="#0369a1" font-weight="bold">창문 = 영상 화면</text>
<!-- 스티커 점 (시선이 창을 뚫는 곳) -->
<circle cx="375" cy="115" r="8" fill="#ef4444"/>
<text x="375" y="90" text-anchor="middle" font-size="12" fill="#b91c1c" font-weight="bold">스티커!</text>
<text x="375" y="105" text-anchor="middle" font-size="10" fill="#b91c1c">(이름표 붙는 자리)</text>
</svg>
<figcaption>카메라(눈)에서 터널(나무)로 선을 긋고, 그 선이 <b>화면(창문)을 뚫는 점</b>에 이름표를 붙인다.</figcaption>
</figure>
> 👉 그래서 하는 일은 딱 하나예요:
> **"카메라에서 터널로 선을 긋고, 그 선이 화면을 뚫는 점을 찾는다."**
---
## 4. 실제 계산은 3번에 나눠서 해요 ✏️
### 계산 ① 터널은 '나(드론) 기준으로' 어디 있지?
GPS 주소(위도·경도)는 **지구본 위 각도**라 계산이 불편해요.
그래서 먼저 **모눈종이(미터 단위)** 로 바꿔요. 그다음 드론을 중심(0,0)에 놓고 거리를 재요.
<figure class="fig">
<svg viewBox="0 0 700 230" role="img" aria-label="미터 좌표로 바꾸고 드론 기준 거리 재기">
<!-- 지구본 -->
<circle cx="90" cy="115" r="45" fill="#dbeafe" stroke="#2563eb"/>
<path d="M50,115 h80 M90,72 v86 M60,90 q30,25 60,0 M60,140 q30,-25 60,0" stroke="#2563eb" fill="none" stroke-width="1"/>
<text x="90" y="185" text-anchor="middle" font-size="11" fill="#1e3a8a">지구본 각도</text>
<text x="90" y="200" text-anchor="middle" font-size="10" fill="#1e3a8a">(위도·경도)</text>
<!-- 화살표 -->
<text x="185" y="118" text-anchor="middle" font-size="24" fill="#64748b">➡</text>
<text x="185" y="140" text-anchor="middle" font-size="10" fill="#64748b">바꾸기</text>
<!-- 모눈종이 -->
<g transform="translate(250,45)">
<rect x="0" y="0" width="160" height="140" fill="#fffdf8" stroke="#ca8a04"/>
<g stroke="#eadfae" stroke-width="1">
<line x1="32" y1="0" x2="32" y2="140"/><line x1="64" y1="0" x2="64" y2="140"/><line x1="96" y1="0" x2="96" y2="140"/><line x1="128" y1="0" x2="128" y2="140"/>
<line x1="0" y1="35" x2="160" y2="35"/><line x1="0" y1="70" x2="160" y2="70"/><line x1="0" y1="105" x2="160" y2="105"/>
</g>
<!-- 드론(중심) -->
<circle cx="48" cy="105" r="6" fill="#334155"/>
<text x="48" y="128" text-anchor="middle" font-size="10" fill="#334155">드론(0,0)</text>
<!-- 터널 -->
<text x="120" y="45" text-anchor="middle" font-size="16">🚇</text>
<!-- 거리 화살표 -->
<line x1="48" y1="105" x2="120" y2="105" stroke="#16a34a" stroke-width="1.5" stroke-dasharray="3 3"/>
<line x1="120" y1="105" x2="120" y2="45" stroke="#16a34a" stroke-width="1.5" stroke-dasharray="3 3"/>
</g>
<text x="470" y="80" font-size="13" fill="#166534" font-weight="bold">드론 기준으로 재보니…</text>
<text x="470" y="108" font-size="12" fill="#166534">• 앞으로 200 m</text>
<text x="470" y="130" font-size="12" fill="#166534">• 오른쪽으로 50 m</text>
<text x="470" y="152" font-size="12" fill="#166534">• 아래로 80 m</text>
</svg>
<figcaption>각도를 미터로 바꾼 뒤, 드론을 중심에 놓고 "앞/옆/아래로 몇 m"인지 잰다.</figcaption>
</figure>
### 계산 ② 그런데 내 카메라가 기울어져 있잖아
드론 카메라는 3가지로 방향이 틀어질 수 있어요. 사람이 고개를 움직이는 것과 똑같아요.
<figure class="fig">
<svg viewBox="0 0 700 180" role="img" aria-label="카메라 방향 3가지 yaw pitch roll">
<g transform="translate(120,90)">
<text x="0" y="-45" text-anchor="middle" font-size="30">🙂</text>
<path d="M-45,10 a45,18 0 0 0 90,0" fill="none" stroke="#2563eb" stroke-width="3"/>
<polygon points="45,10 38,3 38,17" fill="#2563eb"/>
<text x="0" y="45" text-anchor="middle" font-size="13" font-weight="bold" fill="#2563eb">yaw (좌우로 돌기)</text>
<text x="0" y="63" text-anchor="middle" font-size="10" fill="#64748b">고개 도리도리</text>
</g>
<g transform="translate(350,90)">
<text x="0" y="-45" text-anchor="middle" font-size="30">🙂</text>
<path d="M0,-30 a18,45 0 0 0 0,60" fill="none" stroke="#16a34a" stroke-width="3"/>
<polygon points="0,30 -7,23 7,23" fill="#16a34a"/>
<text x="0" y="45" text-anchor="middle" font-size="13" font-weight="bold" fill="#16a34a">pitch (위아래 숙이기)</text>
<text x="0" y="63" text-anchor="middle" font-size="10" fill="#64748b">고개 끄덕끄덕</text>
</g>
<g transform="translate(580,90)">
<text x="0" y="-45" text-anchor="middle" font-size="30">🙂</text>
<path d="M-30,0 a30,30 0 1 1 8,20" fill="none" stroke="#db2777" stroke-width="3"/>
<polygon points="-22,20 -30,14 -14,12" fill="#db2777"/>
<text x="0" y="45" text-anchor="middle" font-size="13" font-weight="bold" fill="#db2777">roll (옆으로 갸웃)</text>
<text x="0" y="63" text-anchor="middle" font-size="10" fill="#64748b">고개 갸우뚱</text>
</g>
</svg>
<figcaption>①에서 잰 위치를, 카메라가 실제로 보는 방향(yaw·pitch·roll)에 맞게 살짝 돌려준다.</figcaption>
</figure>
특히 우리 드론은 **앞을 비스듬히** 봐요(pitch). 그래서 터널이 화면 위쪽(멀리)에 작게 보일지, 아래쪽(가까이)에 크게 보일지가 딱 맞아떨어져요.
### 계산 ③ 3D를 납작한 화면에 눌러 담기
이제 3번의 **창문 스티커**를 실제로 계산해요. 멀수록 가운데로 작게, 가까울수록 크게·바깥으로!
<figure class="fig">
<svg viewBox="0 0 700 220" role="img" aria-label="원근 투영 - 멀면 작게 가까우면 크게">
<!-- 카메라 -->
<text x="55" y="120" text-anchor="middle" font-size="28">📷</text>
<!-- 시야 원뿔 -->
<polygon points="80,110 660,20 660,200" fill="#f1f5f9" stroke="#94a3b8" stroke-dasharray="4 4"/>
<!-- 화면 평면 -->
<rect x="250" y="55" width="14" height="110" fill="#e0f2fe" stroke="#0284c7"/>
<text x="257" y="185" text-anchor="middle" font-size="11" fill="#0369a1">화면</text>
<!-- 가까운 나무 (크게, 바깥) -->
<text x="330" y="80" text-anchor="middle" font-size="26">🌳</text>
<text x="330" y="100" text-anchor="middle" font-size="10" fill="#166534">가까움→크게</text>
<!-- 먼 나무 (작게, 가운데) -->
<text x="610" y="112" text-anchor="middle" font-size="15">🌲</text>
<text x="610" y="130" text-anchor="middle" font-size="10" fill="#166534">멀면→작게</text>
<!-- 화면 위 점 -->
<circle cx="257" cy="88" r="5" fill="#ef4444"/>
<circle cx="257" cy="112" r="4" fill="#ef4444"/>
</svg>
<figcaption>3D 방향을 납작한 화면 좌표로 바꾸면 → "화면 가로 60%, 세로 70%" 같은 <b>정확한 점</b>이 나온다.</figcaption>
</figure>
> 드디어 답: **"터널은 화면의 가로 60%, 세로 70% 지점에 보인다!"** → 거기에 이름표 딱! 🎯
---
## 5. 마지막 문제 — 드론이 흔들려요 🌬️
드론은 바람에 흔들려서, 그냥 두면 이름표가 **부들부들** 떨려요.
그래서 **여러 순간(프레임)의 위치를 평균 내서 부드럽게** 만들어요.
(친구가 손을 떨며 가리켜도, 우리가 "대충 저기구나" 하고 부드럽게 알아보는 것과 같아요.)
---
## 6. 그래서 '진짜 기술'은 뭘 썼을까? 🛠️
지금까지는 **비유**로 설명했어요. 이제 컴퓨터가 실제로 쓴 **진짜 도구(기술)** 이름을 알려줄게요.
어려운 이름이 나오지만, 오른쪽 칸을 보면 **한 줄로 쉽게** 이해돼요. 😊
<figure class="fig">
<svg viewBox="0 0 700 340" role="img" aria-label="비유와 진짜 기술 연결표">
<!-- 헤더 -->
<rect x="20" y="15" width="200" height="34" fill="#fef3c7" stroke="#ca8a04"/>
<rect x="220" y="15" width="230" height="34" fill="#e0e7ff" stroke="#4f46e5"/>
<rect x="450" y="15" width="230" height="34" fill="#dcfce7" stroke="#16a34a"/>
<text x="120" y="37" text-anchor="middle" font-size="12" font-weight="bold" fill="#854d0e">우리가 한 일(비유)</text>
<text x="335" y="37" text-anchor="middle" font-size="12" font-weight="bold" fill="#3730a3">진짜 기술 이름</text>
<text x="565" y="37" text-anchor="middle" font-size="12" font-weight="bold" fill="#166534">쉽게 말하면</text>
<!-- 행 -->
<g font-size="11">
<text x="30" y="70" fill="#444">지구본 각도 → 모눈종이</text>
<text x="230" y="70" fill="#3730a3" font-weight="bold">proj4 · EPSG:5186 좌표계</text>
<text x="460" y="70" fill="#166534">둥근 지구를 평평한 지도로 펴기</text>
<line x1="20" y1="80" x2="680" y2="80" stroke="#eee"/>
<text x="30" y="103" fill="#444">카메라 기울기 반영</text>
<text x="230" y="103" fill="#3730a3" font-weight="bold">회전행렬 (Rotation Matrix)</text>
<text x="460" y="103" fill="#166534">방향을 숫자표로 만들어 한 번에 돌리기</text>
<line x1="20" y1="113" x2="680" y2="113" stroke="#eee"/>
<text x="30" y="136" fill="#444">3D → 화면 점 (창문 스티커)</text>
<text x="230" y="136" fill="#3730a3" font-weight="bold">핀홀 카메라 모델 · 초점거리</text>
<text x="460" y="136" fill="#166534">바늘구멍 사진기 원리로 점 찍기</text>
<line x1="20" y1="146" x2="680" y2="146" stroke="#eee"/>
<text x="30" y="169" fill="#444">30장 사이 부드럽게</text>
<text x="230" y="169" fill="#3730a3" font-weight="bold">보간 (Interpolation)</text>
<text x="460" y="169" fill="#166534">사진 사이 중간 그림 상상해 채우기</text>
<line x1="20" y1="179" x2="680" y2="179" stroke="#eee"/>
<text x="30" y="202" fill="#444">흔들림 잡기</text>
<text x="230" y="202" fill="#3730a3" font-weight="bold">이동평균 · EMA 평활</text>
<text x="460" y="202" fill="#166534">여러 값 평균 내 부들거림 없애기</text>
<line x1="20" y1="212" x2="680" y2="212" stroke="#eee"/>
<text x="30" y="235" fill="#444">1초에 60번 다시 그리기</text>
<text x="230" y="235" fill="#3730a3" font-weight="bold">requestAnimationFrame · Canvas</text>
<text x="460" y="235" fill="#166534">화면 새로 칠할 때마다 그림 그리기</text>
<line x1="20" y1="245" x2="680" y2="245" stroke="#eee"/>
<text x="30" y="268" fill="#444">영상마다 프레임 수 맞추기</text>
<text x="230" y="268" fill="#3730a3" font-weight="bold">fps 자동 산출 (프레임수÷길이)</text>
<text x="460" y="268" fill="#166534">1초에 몇 장인지 스스로 알아내기</text>
<line x1="20" y1="278" x2="680" y2="278" stroke="#eee"/>
<text x="30" y="301" fill="#444">전체 화면·버튼·상태</text>
<text x="230" y="301" fill="#3730a3" font-weight="bold">React · TypeScript</text>
<text x="460" y="301" fill="#166534">화면을 블록처럼 조립하는 도구</text>
</g>
<rect x="20" y="15" width="660" height="300" fill="none" stroke="#ddd"/>
</svg>
<figcaption>왼쪽(비유) → 가운데(진짜 기술) → 오른쪽(쉬운 뜻). 이 표 한 장이 발표의 '기술 요약 슬라이드'예요.</figcaption>
</figure>
> 💡 **재미있는 사실:** 이 계산의 원래 버전은 **파이썬(Python)** 으로 만든 실험 프로그램(`advanced_tuner_v2.py`)이었어요.
> 그걸 **웹 브라우저에서 바로 돌아가게 TypeScript로 옮겨서**, 설치 없이 인터넷 창에서 실행돼요. 🌐
---
## 7. 중요한 기술 3개만 더 깊이 (그래도 쉽게!) 🔎
### 7-1. 둥근 지구를 평평하게 펴기 — 좌표계 변환 (proj4 · EPSG:5186)
지구는 **공(球)** 이라서, 위치를 "각도(위도·경도)"로 말해요. 그런데 각도로는 **"몇 미터 떨어졌지?"** 계산이 어려워요.
그래서 지구 표면을 **평평한 종이에 펴서(투영)**, 미터 단위로 바꿔요. 이때 쓰는 게 **proj4** 라는 도구고, 우리나라에 딱 맞게 만든 지도 규격이 **EPSG:5186 (한국 TM 좌표계)** 예요.
<figure class="fig">
<svg viewBox="0 0 700 180" role="img" aria-label="지구를 평평한 지도로 펴기">
<circle cx="120" cy="90" r="55" fill="#dbeafe" stroke="#2563eb" stroke-width="2"/>
<path d="M65,90 h110 M120,35 v110 M80,60 q40,30 80,0 M80,120 q40,-30 80,0" stroke="#2563eb" fill="none"/>
<text x="120" y="168" text-anchor="middle" font-size="11" fill="#1e3a8a">둥근 지구 (각도)</text>
<text x="300" y="85" text-anchor="middle" font-size="26" fill="#64748b">➡</text>
<text x="300" y="108" text-anchor="middle" font-size="11" fill="#64748b">proj4 로 펴기</text>
<g transform="translate(430,35)">
<rect x="0" y="0" width="220" height="110" fill="#fffdf8" stroke="#ca8a04"/>
<g stroke="#eadfae"><line x1="44" y1="0" x2="44" y2="110"/><line x1="88" y1="0" x2="88" y2="110"/><line x1="132" y1="0" x2="132" y2="110"/><line x1="176" y1="0" x2="176" y2="110"/><line x1="0" y1="37" x2="220" y2="37"/><line x1="0" y1="74" x2="220" y2="74"/></g>
</g>
<text x="540" y="168" text-anchor="middle" font-size="11" fill="#854d0e">평평한 지도 (미터) · EPSG:5186</text>
</svg>
<figcaption>각도로 된 위치를 '자로 잴 수 있는 미터'로 바꾸면, 드론과 터널 사이 거리를 정확히 계산할 수 있다.</figcaption>
</figure>
### 7-2. 방향을 '숫자표'로 만들어 한 번에 돌리기 — 회전행렬
카메라가 좌우(yaw)·위아래(pitch)·갸웃(roll)로 기울어져 있죠? 이 세 방향을 **하나씩 따로** 돌리면 복잡하고 실수해요.
그래서 수학에서는 방향 정보를 **작은 숫자표(행렬)** 로 만들어요. 이 숫자표를 위치에 **한 번 곱하면**, 세 방향 회전이 **동시에 딱** 적용돼요.
> 🎯 **비유:** 요리할 때 양념을 하나씩 넣는 대신, **미리 섞어둔 '만능 양념장' 한 숟갈**을 넣으면 끝나는 것과 같아요.
> 회전행렬 = "방향 돌리기 만능 양념장" 이에요. (우리 코드에선 `R_align · R_b2w^T` 라는 양념장을 써요.)
### 7-3. 바늘구멍 사진기 원리 — 핀홀 카메라 모델
3D 세상을 **납작한 사진 한 장**으로 만드는 원리예요. 옛날 **바늘구멍 사진기**를 떠올려요:
작은 구멍으로 빛이 들어와 뒤쪽 벽에 그림이 맺혀요. 이때 **멀리 있는 건 작게, 가까운 건 크게** 맺혀요(원근).
<figure class="fig">
<svg viewBox="0 0 700 210" role="img" aria-label="핀홀(바늘구멍) 카메라 원리">
<!-- 물체(터널): x=90, y=30~160 (가운데 y=95) -->
<line x1="90" y1="30" x2="90" y2="160" stroke="#16a34a" stroke-width="6"/>
<polygon points="90,30 84,44 96,44" fill="#16a34a"/>
<text x="90" y="180" text-anchor="middle" font-size="11" fill="#166534">터널(실제, 큼)</text>
<!-- 구멍 벽 + 바늘구멍(광선 교차점) at (360,95) -->
<line x1="360" y1="18" x2="360" y2="172" stroke="#94a3b8" stroke-width="6"/>
<circle cx="360" cy="95" r="5.5" fill="#111"/>
<text x="360" y="192" text-anchor="middle" font-size="11" fill="#475569">바늘구멍(=렌즈)</text>
<!-- 광선: 물체 양끝 → 바늘구멍(360,95) 통과 → 화면. 반드시 점을 지나 X자로 교차. -->
<line x1="90" y1="30" x2="560" y2="143.1" stroke="#f59e0b" stroke-width="1.5"/>
<line x1="90" y1="160" x2="560" y2="46.9" stroke="#f59e0b" stroke-width="1.5"/>
<!-- 화면에 맺힌 상: x=560, 거꾸로 + 작게 (y=46.9~143.1, 가운데 95) -->
<line x1="560" y1="46.9" x2="560" y2="143.1" stroke="#ef4444" stroke-width="5"/>
<polygon points="560,143.1 554,129.1 566,129.1" fill="#ef4444"/>
<text x="560" y="180" text-anchor="middle" font-size="11" fill="#b91c1c">맺힌 상(작고 거꾸로 ↕)</text>
</svg>
<figcaption>구멍(렌즈)을 지나며 3D가 화면에 맺힌다. '얼마나 당겨 찍나(초점거리)'로 크기가 정해진다.</figcaption>
</figure>
컴퓨터는 이 원리를 **곱셈·나눗셈 몇 번**으로 계산해서, "터널이 화면의 정확히 어느 점"인지 구해요.
여기서 **초점거리(focal length)** 가 클수록(망원처럼 당길수록) 물체가 화면에서 크게 보여요.
---
## 8. 한 장 요약 (마지막 슬라이드) 🎁
<figure class="fig">
<svg viewBox="0 0 700 300" role="img" aria-label="전체 요약 파이프라인">
<defs><marker id="ms" markerWidth="12" markerHeight="12" refX="8" refY="4" orient="auto"><path d="M0,0 L8,4 L0,8 Z" fill="#7c3aed"/></marker></defs>
<rect x="150" y="15" width="400" height="42" rx="8" fill="#f5f3ff" stroke="#7c3aed"/>
<text x="350" y="41" text-anchor="middle" font-size="13" font-weight="bold" fill="#5b21b6">드론 위치 + 보는 방향 + 터널 위치 + 렌즈 정보</text>
<line x1="350" y1="57" x2="350" y2="80" stroke="#7c3aed" stroke-width="2" marker-end="url(#ms)"/>
<rect x="150" y="82" width="400" height="42" rx="8" fill="#ecfdf5" stroke="#16a34a"/>
<text x="350" y="108" text-anchor="middle" font-size="13" font-weight="bold" fill="#166534">① 미터로 바꿔 "드론 기준 위치" 재기</text>
<line x1="350" y1="124" x2="350" y2="147" stroke="#7c3aed" stroke-width="2" marker-end="url(#ms)"/>
<rect x="150" y="149" width="400" height="42" rx="8" fill="#eff6ff" stroke="#2563eb"/>
<text x="350" y="175" text-anchor="middle" font-size="13" font-weight="bold" fill="#1e3a8a">② 카메라 기울기만큼 방향 돌리기</text>
<line x1="350" y1="191" x2="350" y2="214" stroke="#7c3aed" stroke-width="2" marker-end="url(#ms)"/>
<rect x="150" y="216" width="400" height="42" rx="8" fill="#fefce8" stroke="#ca8a04"/>
<text x="350" y="242" text-anchor="middle" font-size="13" font-weight="bold" fill="#854d0e">③ 창문에 스티커 붙이듯 화면 점 찾기</text>
<line x1="350" y1="258" x2="350" y2="278" stroke="#7c3aed" stroke-width="2" marker-end="url(#ms)"/>
<text x="350" y="294" text-anchor="middle" font-size="14" font-weight="bold" fill="#b91c1c">🎯 화면의 정확한 자리에 이름표 붙이기!</text>
</svg>
</figure>
> **한 문장 결론:**
> **"드론이 어디서·어느 쪽을 보는지 알기 때문에, 지도 속 터널이 영상 화면 어디에 보일지를 계산해서 이름표를 딱 붙이는 기술"** 이에요.
이 "드론 좌표 → 영상 픽셀 3D 투영"이 GhiVideo를 **보통 영상 플레이어와 다르게 만드는 가장 특별한 기술**이랍니다. ✨
@@ -0,0 +1,301 @@
<!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>철도 스테이션 기반 주행영상 플레이어 — 개발 내용 및 핵심기술</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: 22mm 18mm 18mm 18mm;
@top-left { content: "한맥 기술개발센터"; font-family: "Malgun Gothic"; font-size: 8.5pt; color: #555; }
@top-right { content: "대외비 / 회의자료"; font-family: "Malgun Gothic"; font-size: 8.5pt; color: #a33; }
@bottom-center { content: "기술로 사람과 자연이 함께하는 세상을 만들어 갑니다."; font-family: "Malgun Gothic"; font-size: 8.5pt; color: #999; }
@bottom-right { content: counter(page); font-family: "Malgun Gothic"; font-size: 8.5pt; color: #999; }
}
html { font-size: 10.5pt; }
body {
font-family: "Malgun Gothic", "Hancom Gothic", sans-serif;
color: #1a1a1a; line-height: 1.55; margin: 0 auto; max-width: 900px; padding: 8px 10px;
}
h1 {
text-align: center; font-size: 17pt; font-weight: 800; color: #111;
margin: 2px 0 4px; padding-bottom: 8px; border-bottom: 2.5px solid #333;
}
.subhead { text-align: center; color: #666; font-size: 9.5pt; margin: 0 0 18px; }
h2 {
font-size: 13pt; font-weight: 800; color: #14305e;
margin: 18px 0 8px; padding: 0; border: none;
}
h3 { font-size: 11pt; font-weight: 700; color: #333; margin: 12px 0 5px; }
ul { margin: 4px 0 8px; padding-left: 18px; }
li { margin: 2px 0; }
strong { color: #111; }
a { color: #14305e; text-decoration: none; }
table { border-collapse: collapse; width: 100%; margin: 8px 0 12px; font-size: 9.5pt; }
th, td { border: 1px solid #555; padding: 5px 8px; vertical-align: top; text-align: left; }
th { background: #e9edf3; color: #14305e; font-weight: 700; text-align: center; }
td.c, th.c { text-align: center; }
blockquote {
border: 1px solid #c9b27a; background: #fffbf0; border-radius: 4px;
margin: 10px 0; padding: 8px 12px; color: #5a4626; font-size: 9.8pt;
}
figure { margin: 10px 0; text-align: center; page-break-inside: avoid; break-inside: avoid; }
figure svg { width: 100%; max-width: 720px; height: auto; }
figure figcaption { font-size: 8.8pt; color: #777; margin-top: 4px; }
text { font-family: "Malgun Gothic", sans-serif; }
h1, h2, h3 { break-after: avoid; }
table, figure { break-inside: 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">철도 스테이션 기반 주행영상 플레이어 — 개발 내용 및 핵심기술</h1>
</header>
<h1 id="철도-스테이션측점-기반-주행영상-웹플레이어--개발-내용-및-핵심기술">철도 스테이션(측점) 기반 주행영상 웹플레이어 — 개발 내용 및 핵심기술</h1>
<p class="subhead">기술개발센터 · 작성일 2026-06-29 · (작성자/팀 기입)</p>
<h2 id="-개요">□ 개요</h2>
<ul>
<li><p>드론으로 촬영한 철도 주행영상을 <strong>시간이 아닌 측점(체이니지) 위치 기준</strong>으로 색인·탐색하고, 프레임별 카메라 자세를 이용해 <strong>측점·POI·선로중심선을 영상 위에 실시간 정합(AR 오버레이)</strong> 하여 표출하는 <strong>웹 기반 주행영상 분석 플레이어</strong> 개발</p></li>
<li><p>입력 데이터 : 드론 주행영상 + 프레임별 비행로그(SRT) + 노선 측점/POI/구조물 데이터(폴더 일괄 적재)</p></li>
</ul>
<table>
<thead>
<tr class="header">
<th>항목</th>
<th>내용</th>
<th style="text-align: center;">포맷</th>
<th>비고</th>
</tr>
</thead>
<tbody>
<tr class="odd">
<td>드론 주행영상</td>
<td>점검 구간 촬영 영상</td>
<td style="text-align: center;">MP4</td>
<td>2GB+ 대용량</td>
</tr>
<tr class="even">
<td>비행로그</td>
<td>프레임별 위치·자세·초점</td>
<td style="text-align: center;">SRT/CSV</td>
<td>lat/lon/고도, yaw/pitch/roll, focal</td>
</tr>
<tr class="odd">
<td>측점</td>
<td>노선 측점 좌표·표고</td>
<td style="text-align: center;">CSV(01측점)</td>
<td><strong>정표고(Z좌표_한국)</strong> 사용</td>
</tr>
<tr class="even">
<td>POI/구조물</td>
<td>교량·터널·역사·지장물·출입문</td>
<td style="text-align: center;">CSV/KMZ</td>
<td>03교량·04터널·06구교·02지장물</td>
</tr>
<tr class="odd">
<td>노선 보정</td>
<td>진행방향·종점·이정 보정</td>
<td style="text-align: center;">route.json</td>
<td>폴더별(선택)</td>
</tr>
</tbody>
</table>
<ul>
<li>처리 알고리즘 흐름도</li>
</ul>
<figure>
<svg viewBox="0 0 760 150" role="img" aria-label="처리 흐름도">
<defs><marker id="ha" 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="55" width="120" height="46" rx="5" fill="#eef2f8" stroke="#14305e"></rect>
<text x="68" y="74" text-anchor="middle" font-size="11" font-weight="bold" fill="#14305e">폴더 입력</text>
<text x="68" y="90" text-anchor="middle" font-size="9" fill="#555">영상·로그·CSV·KMZ</text>
<rect x="160" y="55" width="120" height="46" rx="5" fill="#fff" stroke="#555"></rect>
<text x="220" y="74" text-anchor="middle" font-size="11" font-weight="bold">파싱</text>
<text x="220" y="90" text-anchor="middle" font-size="9" fill="#555">인코딩 자동감지</text>
<rect x="312" y="55" width="138" height="46" rx="5" fill="#fff7e6" stroke="#b45309"></rect>
<text x="381" y="74" text-anchor="middle" font-size="11" font-weight="bold" fill="#b45309">좌표·측점 계산</text>
<text x="381" y="90" text-anchor="middle" font-size="9" fill="#7c2d12">투영 · 체이니지</text>
<rect x="496" y="10" width="256" height="36" rx="5" fill="#fff" stroke="#16a34a"></rect>
<text x="624" y="33" text-anchor="middle" font-size="10.5" font-weight="bold" fill="#14532d">① 영상 AR 오버레이</text>
<rect x="496" y="60" width="256" height="36" rx="5" fill="#fff" stroke="#16a34a"></rect>
<text x="624" y="83" text-anchor="middle" font-size="10.5" font-weight="bold" fill="#14532d">② 측점 스테이션바(탐색)</text>
<rect x="496" y="110" width="256" height="36" rx="5" fill="#fff" stroke="#16a34a"></rect>
<text x="624" y="133" text-anchor="middle" font-size="10.5" font-weight="bold" fill="#14532d">③ 대용량 영상 재생</text>
<line x1="128" y1="78" x2="158" y2="78" stroke="#14305e" stroke-width="1.5" marker-end="url(#ha)"></line>
<line x1="280" y1="78" x2="310" y2="78" stroke="#14305e" stroke-width="1.5" marker-end="url(#ha)"></line>
<line x1="450" y1="78" x2="494" y2="28" stroke="#14305e" stroke-width="1.5" marker-end="url(#ha)"></line>
<line x1="450" y1="78" x2="494" y2="78" stroke="#14305e" stroke-width="1.5" marker-end="url(#ha)"></line>
<line x1="450" y1="78" x2="494" y2="128" stroke="#14305e" stroke-width="1.5" marker-end="url(#ha)"></line>
</svg>
<figcaption>그림 1. 데이터 입력 → 파싱 → 좌표·측점 계산 → (오버레이/스테이션바/재생) 처리 흐름</figcaption>
</figure>
<ul>
<li>출력 / 주요 기능</li>
</ul>
<table>
<thead>
<tr class="header">
<th>항목</th>
<th>내용</th>
<th>비고</th>
</tr>
</thead>
<tbody>
<tr class="odd">
<td>영상 오버레이</td>
<td>측점·교량·터널·역사·POI·중심선·드론궤적을 영상에 정합 표시</td>
<td>60fps</td>
</tr>
<tr class="even">
<td>측점 스테이션바</td>
<td>측점 위치 기반 탐색바(거리/측점축), 구조물 마커, 방향색</td>
<td>시간축 대체</td>
</tr>
<tr class="odd">
<td>측점 검색</td>
<td>측점값 입력 → 해당 위치로 이동(여러 곳이면 순환)</td>
<td></td>
</tr>
<tr class="even">
<td>위치 보정</td>
<td>화면 드래그로 POI 위치/표고 보정·저장</td>
<td>데이터셋별 영속</td>
</tr>
<tr class="odd">
<td>대용량 재생</td>
<td>2GB+ 영상 Range/HLS 스트리밍</td>
<td>로컬·서버</td>
</tr>
</tbody>
</table>
<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>측점(체이니지) 색인·탐색</td>
<td>영상 매 시점을 노선 측점값으로 환산, 위치 기준 탐색</td>
<td>시간축 대비 점검 적합</td>
</tr>
<tr class="even">
<td style="text-align: center;">2</td>
<td>실시간 영상 정합(투영)</td>
<td>드론 위치·자세·초점 → 핀홀 투영으로 GIS점을 화면 픽셀에 정렬</td>
<td>서버 없이 클라이언트 60fps</td>
</tr>
<tr class="odd">
<td style="text-align: center;">3</td>
<td><strong>측점 표고 기반 DEM-free 정합</strong></td>
<td>측점 실측 정표고를 지면고도로 사용 + 지오이드 datum 정합</td>
<td>DEM 불필요, 부각오차 42.7°→11°</td>
</tr>
<tr class="even">
<td style="text-align: center;">4</td>
<td><strong>단일 드래그 역투영 보정</strong></td>
<td>화면 드래그 1회로 가로·세로·거리 동시 복원(깊이맵 불요)</td>
<td>지오코딩 오차 손쉬운 교정</td>
</tr>
<tr class="odd">
<td style="text-align: center;">5</td>
<td>화각(FOV) 1점 보정</td>
<td>라벨 1개를 끌면 세로 화각(sensorH) 역산(가로 정합 보존)</td>
<td>영상별 화각 자동 정합</td>
</tr>
<tr class="even">
<td style="text-align: center;">6</td>
<td>에지보존 적응형 평활</td>
<td>직선=강하게/회전=즉시추종, 속도적응 EMA</td>
<td>떨림 억제 + 무지연</td>
</tr>
<tr class="odd">
<td style="text-align: center;">7</td>
<td>위치(거리/측점)축 진행바</td>
<td>누적 이동거리·측점 비례 축, 방향은 색으로 분리</td>
<td>호버·왕복 정확 표현</td>
</tr>
<tr class="even">
<td style="text-align: center;">8</td>
<td>좌표/측점 이중탐지 + kmExists</td>
<td>측점·좌표 두 기준 합집합 탐지, 실제 측점 유무 판정</td>
<td>재진입 마커 누락 해소</td>
</tr>
<tr class="odd">
<td style="text-align: center;">9</td>
<td>대용량 스트리밍(Range/HLS)</td>
<td>10MB 청크 Range + HLS 분할(backBufferLength 30)</td>
<td>2GB+ 안정 재생</td>
</tr>
</tbody>
</table>
<blockquote>
<p>세부 알고리즘·수식·소스 위치는 별첨 「기술명세_GhiVideo_종합기술문서」 참조.</p>
</blockquote>
<h2 id="-개발-결과-및-이슈사항">□ 개발 결과 및 이슈사항</h2>
<ul>
<li>영상 정합 정확도 : 표고 datum 정합으로 <strong>부각오차 42.7°→11.0°</strong>, 측점 기반 지면고도가 구글 DEM 대비 <strong>약 0.2m(2160p에서 ~6px) 일치</strong></li>
<li>위치 보정 : 역투영 <strong>왕복오차 ≈ 0</strong>(z=42→화면→42.000), 보정값은 데이터셋별 자동 저장·복원</li>
<li>렌더 성능 : 사전계산(가시성) + RAF(좌표 재투영) 분리로 4K·대용량에서도 <strong>60fps 유지</strong></li>
<li>측점 탐색 : 좌표/측점 이중탐지 + 합집합 보강으로 <strong>조차장·종점 재진입 마커 누락 해소</strong>, 실제 측점 없는 위치는 측점값 라벨 숨김</li>
<li>운영 : 사내망 외부접속 지원(포트 55000), 보고서 자동생성(md→HTML/PDF) 도구 구축</li>
<li>이슈 : 지오코딩 <strong>수평오차는 데이터 한계</strong>(드래그 수동보정) · 지오이드고는 <strong>지역상수</strong>(대전 25.8m, 타지역 재튜닝) · VFR/GPS 노이즈 잔존 · <strong>특허 KIPRIS 전문검색 미수행</strong></li>
</ul>
<p>▷ 측점 표고 정합 vs 외부 DEM 활용 (비교)</p>
<table>
<thead>
<tr class="header">
<th>항목</th>
<th>측점 표고 정합(본 방식)</th>
<th>외부 DEM 활용</th>
<th>비고</th>
</tr>
</thead>
<tbody>
<tr class="odd">
<td>장점</td>
<td>1. 별도 DEM 취득 불필요<br>2. 노선 기복 자동 반영<br>3. 측량값이라 정밀(~0.2m)</td>
<td>1. 측점 데이터 없는 구간도 커버</td>
<td>본 방식은 보유 측점 활용</td>
</tr>
<tr class="even">
<td>단점</td>
<td>측점 사이 구간은 보간 의존</td>
<td>1. 취득비용/해상도 한계(30~90m)<br>2. 다운로드·전처리 시간</td>
<td>DEM은 선로 인접·절벽서 부정확</td>
</tr>
</tbody>
</table>
<blockquote>
<p>☞ 정합 정확도가 핵심인 점검·분석 용도에서는 <strong>측점 표고 정합 방식이 비용·정밀도 모두 유리</strong>. 측점 미보유 구간 보강이 필요할 때만 외부 DEM 보조 활용 권장.</p>
<p>☞ 향후 : ① 발명 B(측점 표고 정합) + C(단일 드래그 역투영) 중심 <strong>특허 출원</strong>(KIPRIS 전문검색 선행) ② 렌즈왜곡·자동 캘리브레이션 ③ 측점 미보유 구간 DEM 자동조회.</p>
</blockquote>
</body>
</html>
@@ -0,0 +1,92 @@
# 철도 스테이션(측점) 기반 주행영상 웹플레이어 — 개발 내용 및 핵심기술
<p class="subhead">기술개발센터 · 작성일 2026-06-29 · (작성자/팀 기입)</p>
## □ 개요
- 드론으로 촬영한 철도 주행영상을 **시간이 아닌 측점(체이니지) 위치 기준**으로 색인·탐색하고, 프레임별 카메라 자세를 이용해 **측점·POI·선로중심선을 영상 위에 실시간 정합(AR 오버레이)** 하여 표출하는 **웹 기반 주행영상 분석 플레이어** 개발
- 입력 데이터 : 드론 주행영상 + 프레임별 비행로그(SRT) + 노선 측점/POI/구조물 데이터(폴더 일괄 적재)
| 항목 | 내용 | 포맷 | 비고 |
|------|------|:----:|------|
| 드론 주행영상 | 점검 구간 촬영 영상 | MP4 | 2GB+ 대용량 |
| 비행로그 | 프레임별 위치·자세·초점 | SRT/CSV | lat/lon/고도, yaw/pitch/roll, focal |
| 측점 | 노선 측점 좌표·표고 | CSV(01측점) | **정표고(Z좌표_한국)** 사용 |
| POI/구조물 | 교량·터널·역사·지장물·출입문 | CSV/KMZ | 03교량·04터널·06구교·02지장물 |
| 노선 보정 | 진행방향·종점·이정 보정 | route.json | 폴더별(선택) |
- 처리 알고리즘 흐름도
<figure>
<svg viewBox="0 0 760 150" role="img" aria-label="처리 흐름도">
<defs><marker id="ha" markerWidth="9" markerHeight="9" refX="6" refY="3" orient="auto"><path d="M0,0 L6,3 L0,6 Z" fill="#14305e"/></marker></defs>
<rect x="8" y="55" width="120" height="46" rx="5" fill="#eef2f8" stroke="#14305e"/>
<text x="68" y="74" text-anchor="middle" font-size="11" font-weight="bold" fill="#14305e">폴더 입력</text>
<text x="68" y="90" text-anchor="middle" font-size="9" fill="#555">영상·로그·CSV·KMZ</text>
<rect x="160" y="55" width="120" height="46" rx="5" fill="#fff" stroke="#555"/>
<text x="220" y="74" text-anchor="middle" font-size="11" font-weight="bold">파싱</text>
<text x="220" y="90" text-anchor="middle" font-size="9" fill="#555">인코딩 자동감지</text>
<rect x="312" y="55" width="138" height="46" rx="5" fill="#fff7e6" stroke="#b45309"/>
<text x="381" y="74" text-anchor="middle" font-size="11" font-weight="bold" fill="#b45309">좌표·측점 계산</text>
<text x="381" y="90" text-anchor="middle" font-size="9" fill="#7c2d12">투영 · 체이니지</text>
<rect x="496" y="10" width="256" height="36" rx="5" fill="#fff" stroke="#16a34a"/>
<text x="624" y="33" text-anchor="middle" font-size="10.5" font-weight="bold" fill="#14532d">① 영상 AR 오버레이</text>
<rect x="496" y="60" width="256" height="36" rx="5" fill="#fff" stroke="#16a34a"/>
<text x="624" y="83" text-anchor="middle" font-size="10.5" font-weight="bold" fill="#14532d">② 측점 스테이션바(탐색)</text>
<rect x="496" y="110" width="256" height="36" rx="5" fill="#fff" stroke="#16a34a"/>
<text x="624" y="133" text-anchor="middle" font-size="10.5" font-weight="bold" fill="#14532d">③ 대용량 영상 재생</text>
<line x1="128" y1="78" x2="158" y2="78" stroke="#14305e" stroke-width="1.5" marker-end="url(#ha)"/>
<line x1="280" y1="78" x2="310" y2="78" stroke="#14305e" stroke-width="1.5" marker-end="url(#ha)"/>
<line x1="450" y1="78" x2="494" y2="28" stroke="#14305e" stroke-width="1.5" marker-end="url(#ha)"/>
<line x1="450" y1="78" x2="494" y2="78" stroke="#14305e" stroke-width="1.5" marker-end="url(#ha)"/>
<line x1="450" y1="78" x2="494" y2="128" stroke="#14305e" stroke-width="1.5" marker-end="url(#ha)"/>
</svg>
<figcaption>그림 1. 데이터 입력 → 파싱 → 좌표·측점 계산 → (오버레이/스테이션바/재생) 처리 흐름</figcaption>
</figure>
- 출력 / 주요 기능
| 항목 | 내용 | 비고 |
|------|------|------|
| 영상 오버레이 | 측점·교량·터널·역사·POI·중심선·드론궤적을 영상에 정합 표시 | 60fps |
| 측점 스테이션바 | 측점 위치 기반 탐색바(거리/측점축), 구조물 마커, 방향색 | 시간축 대체 |
| 측점 검색 | 측점값 입력 → 해당 위치로 이동(여러 곳이면 순환) | — |
| 위치 보정 | 화면 드래그로 POI 위치/표고 보정·저장 | 데이터셋별 영속 |
| 대용량 재생 | 2GB+ 영상 Range/HLS 스트리밍 | 로컬·서버 |
## □ 핵심 기술
| # | 기술 | 핵심 내용 | 효과 |
|:-:|------|----------|------|
| 1 | 측점(체이니지) 색인·탐색 | 영상 매 시점을 노선 측점값으로 환산, 위치 기준 탐색 | 시간축 대비 점검 적합 |
| 2 | 실시간 영상 정합(투영) | 드론 위치·자세·초점 → 핀홀 투영으로 GIS점을 화면 픽셀에 정렬 | 서버 없이 클라이언트 60fps |
| 3 | **측점 표고 기반 DEM-free 정합** | 측점 실측 정표고를 지면고도로 사용 + 지오이드 datum 정합 | DEM 불필요, 부각오차 42.7°→11° |
| 4 | **단일 드래그 역투영 보정** | 화면 드래그 1회로 가로·세로·거리 동시 복원(깊이맵 불요) | 지오코딩 오차 손쉬운 교정 |
| 5 | 화각(FOV) 1점 보정 | 라벨 1개를 끌면 세로 화각(sensorH) 역산(가로 정합 보존) | 영상별 화각 자동 정합 |
| 6 | 에지보존 적응형 평활 | 직선=강하게/회전=즉시추종, 속도적응 EMA | 떨림 억제 + 무지연 |
| 7 | 위치(거리/측점)축 진행바 | 누적 이동거리·측점 비례 축, 방향은 색으로 분리 | 호버·왕복 정확 표현 |
| 8 | 좌표/측점 이중탐지 + kmExists | 측점·좌표 두 기준 합집합 탐지, 실제 측점 유무 판정 | 재진입 마커 누락 해소 |
| 9 | 대용량 스트리밍(Range/HLS) | 10MB 청크 Range + HLS 분할(backBufferLength 30) | 2GB+ 안정 재생 |
> 세부 알고리즘·수식·소스 위치는 별첨 「기술명세_GhiVideo_종합기술문서」 참조.
## □ 개발 결과 및 이슈사항
- 영상 정합 정확도 : 표고 datum 정합으로 **부각오차 42.7°→11.0°**, 측점 기반 지면고도가 구글 DEM 대비 **약 0.2m(2160p에서 ~6px) 일치**
- 위치 보정 : 역투영 **왕복오차 ≈ 0**(z=42→화면→42.000), 보정값은 데이터셋별 자동 저장·복원
- 렌더 성능 : 사전계산(가시성) + RAF(좌표 재투영) 분리로 4K·대용량에서도 **60fps 유지**
- 측점 탐색 : 좌표/측점 이중탐지 + 합집합 보강으로 **조차장·종점 재진입 마커 누락 해소**, 실제 측점 없는 위치는 측점값 라벨 숨김
- 운영 : 사내망 외부접속 지원(포트 55000), 보고서 자동생성(md→HTML/PDF) 도구 구축
- 이슈 : 지오코딩 **수평오차는 데이터 한계**(드래그 수동보정) · 지오이드고는 **지역상수**(대전 25.8m, 타지역 재튜닝) · VFR/GPS 노이즈 잔존 · **특허 KIPRIS 전문검색 미수행**
▷ 측점 표고 정합 vs 외부 DEM 활용 (비교)
| 항목 | 측점 표고 정합(본 방식) | 외부 DEM 활용 | 비고 |
|------|------------------------|---------------|------|
| 장점 | 1. 별도 DEM 취득 불필요<br>2. 노선 기복 자동 반영<br>3. 측량값이라 정밀(~0.2m) | 1. 측점 데이터 없는 구간도 커버 | 본 방식은 보유 측점 활용 |
| 단점 | 측점 사이 구간은 보간 의존 | 1. 취득비용/해상도 한계(30~90m)<br>2. 다운로드·전처리 시간 | DEM은 선로 인접·절벽서 부정확 |
> ☞ 정합 정확도가 핵심인 점검·분석 용도에서는 **측점 표고 정합 방식이 비용·정밀도 모두 유리**. 측점 미보유 구간 보강이 필요할 때만 외부 DEM 보조 활용 권장.
>
> ☞ 향후 : ① 발명 B(측점 표고 정합) + C(단일 드래그 역투영) 중심 **특허 출원**(KIPRIS 전문검색 선행) ② 렌즈왜곡·자동 캘리브레이션 ③ 측점 미보유 구간 DEM 자동조회.
@@ -0,0 +1,507 @@
<!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;}
.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> 이해할 수 있게 쓴 설명서입니다. 어려운 공식 대신 <strong>비유와 그림</strong>으로 &quot;이 프로그램이 무슨 일을, 왜, 어떻게 하는지&quot;를 풀어 씁니다. (더 깊은 기술/수식은 같은 폴더의 <code>기술명세_GhiVideo_종합기술문서.pdf</code> 참고.)</p>
</blockquote>
<style>
figure.fig{margin:20px 0;text-align:center;page-break-inside:avoid;break-inside:avoid;}
figure.fig svg{width:100%;max-width:640px;height:auto;border:1px solid #e7e0d2;border-radius:8px;background:#fffdf8;}
figure.fig figcaption{font-size:0.9em;color:#777;margin-top:6px;}
text{font-family:'Malgun Gothic','Hancom Gothic',sans-serif;}
</style>
<hr />
<h2 id="0-한-문장으로-말하면">0. 한 문장으로 말하면</h2>
<p><strong>드론이 철길 위를 날며 찍은 영상</strong>을 보면서, 화면 속 다리·터널·역·선로 위에 <strong>&quot;여기는 어디, 저건 무슨 다리&quot;</strong> 같은 정보를 <strong>자동으로 딱 붙여서</strong> 보여주는 프로그램입니다.</p>
<p>마치 <strong>포켓몬GO</strong><strong>내비게이션의 증강현실(AR) 길안내</strong>처럼, 실제 영상 위에 정보가 떠 있는 거예요.</p>
<hr />
<h2 id="1-가장-큰-그림-먼저">1. 가장 큰 그림 먼저</h2>
<p>우리가 가진 재료는 딱 두 가지입니다.</p>
<ol type="1">
<li><strong>드론이 찍은 영상</strong> — 매 순간 &quot;드론이 <strong>어디에</strong> 있었고 <strong>어느 방향을</strong> 보는지&quot;가 같이 기록돼 있어요.</li>
<li><strong>지도 정보</strong> — 다리·터널·역·측점이 지구상 <strong>어디에 있는지</strong>(위도·경도) 적힌 데이터.</li>
</ol>
<p>이 둘을 합치면 <strong>&quot;저 다리는 화면의 어느 위치에 보일까?&quot;</strong> 에 답할 수 있어요. 그게 핵심 기술입니다.</p>
<figure class="fig">
<svg viewBox="0 0 640 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="#b45309"></path></marker></defs>
<rect x="20" y="25" width="180" height="55" rx="8" fill="#fff" stroke="#2563eb" stroke-width="1.5"></rect>
<text x="110" y="48" text-anchor="middle" font-size="15" font-weight="bold" fill="#1e3a8a">드론 영상</text>
<text x="110" y="68" text-anchor="middle" font-size="12" fill="#555">(위치 · 보는 방향 기록)</text>
<rect x="20" y="125" width="180" height="55" rx="8" fill="#fff" stroke="#16a34a" stroke-width="1.5"></rect>
<text x="110" y="148" text-anchor="middle" font-size="15" font-weight="bold" fill="#14532d">지도 정보</text>
<text x="110" y="168" text-anchor="middle" font-size="12" fill="#555">(다리·터널·측점 위치)</text>
<rect x="270" y="75" width="150" height="55" rx="8" fill="#fff7e6" stroke="#f59e0b" stroke-width="2"></rect>
<text x="345" y="98" text-anchor="middle" font-size="15" font-weight="bold" fill="#b45309">합치기</text>
<text x="345" y="118" text-anchor="middle" font-size="12" fill="#7c2d12">(투영 계산)</text>
<rect x="470" y="75" width="150" height="55" rx="8" fill="#fff" stroke="#b45309" stroke-width="1.5"></rect>
<text x="545" y="100" text-anchor="middle" font-size="14" font-weight="bold" fill="#23272e">화면에</text>
<text x="545" y="119" text-anchor="middle" font-size="14" font-weight="bold" fill="#23272e">정보 표시</text>
<line x1="200" y1="52" x2="266" y2="92" stroke="#b45309" stroke-width="1.5" marker-end="url(#f1a)"></line>
<line x1="200" y1="152" x2="266" y2="114" stroke="#b45309" stroke-width="1.5" marker-end="url(#f1a)"></line>
<line x1="420" y1="102" x2="466" y2="102" stroke="#b45309" stroke-width="1.5" marker-end="url(#f1a)"></line>
</svg>
<figcaption>그림 1. 드론 영상 + 지도 정보 → &quot;투영 계산&quot;으로 합쳐 → 화면에 정보 표시</figcaption>
</figure>
<hr />
<h2 id="2-영상-위에-글자를-정확히-붙이는-마법">2. 영상 위에 글자를 정확히 붙이는 마법</h2>
<h3 id="2-1-먼저-위치를-숫자로-바꿔요">2-1. 먼저, 위치를 &#39;숫자&#39;로 바꿔요</h3>
<p>지구 위 위치는 보통 위도·경도(각도)로 말하는데, 거리 계산이 불편해요. 그래서 <strong>&quot;기준점에서 동쪽 몇 m, 북쪽 몇 m&quot;</strong> 처럼 <strong>미터(m)</strong> 로 바꿉니다.</p>
<blockquote>
<p>🧭 <strong>비유</strong>: 학교 정문을 (0,0)으로 정하고 &quot;동쪽 50m, 북쪽 30m 지점&quot;이라 말하는 것과 같아요.</p>
</blockquote>
<figure class="fig">
<svg viewBox="0 0 640 240" role="img" aria-label="좌표 예시">
<defs><marker id="f2a" markerWidth="9" markerHeight="9" refX="6" refY="3" orient="auto"><path d="M0,0 L6,3 L0,6 Z" fill="#6b7280"></path></marker></defs>
<!-- grid -->
<g stroke="#eee" stroke-width="1">
<line x1="90" y1="40" x2="90" y2="200"></line><line x1="170" y1="40" x2="170" y2="200"></line><line x1="250" y1="40" x2="250" y2="200"></line><line x1="330" y1="40" x2="330" y2="200"></line>
<line x1="90" y1="200" x2="610" y2="200"></line><line x1="90" y1="160" x2="610" y2="160"></line><line x1="90" y1="120" x2="610" y2="120"></line><line x1="90" y1="80" x2="610" y2="80"></line>
</g>
<!-- axes -->
<line x1="90" y1="200" x2="610" y2="200" stroke="#6b7280" stroke-width="2" marker-end="url(#f2a)"></line>
<line x1="90" y1="200" x2="90" y2="35" stroke="#6b7280" stroke-width="2" marker-end="url(#f2a)"></line>
<text x="600" y="222" font-size="13" fill="#6b7280">동쪽(m) →</text>
<text x="60" y="45" font-size="13" fill="#6b7280">북쪽(m) ↑</text>
<!-- origin -->
<circle cx="90" cy="200" r="5" fill="#16a34a"></circle>
<text x="98" y="218" font-size="12" fill="#14532d">학교 정문 (0,0)</text>
<!-- point: east 50m (->x 290), north 30m (->y 80) -->
<line x1="290" y1="200" x2="290" y2="80" stroke="#f59e0b" stroke-width="1.5" stroke-dasharray="4 3"></line>
<line x1="90" y1="80" x2="290" y2="80" stroke="#f59e0b" stroke-width="1.5" stroke-dasharray="4 3"></line>
<circle cx="290" cy="80" r="6" fill="#b45309"></circle>
<text x="300" y="76" font-size="13" font-weight="bold" fill="#b45309">건물</text>
<text x="300" y="95" font-size="12" fill="#7c2d12">(동 50m, 북 30m)</text>
</svg>
<figcaption>그림 2. 위치를 &quot;기준점에서 동/북 몇 m&quot;로 바꾸면 거리·계산이 쉬워집니다.</figcaption>
</figure>
<h3 id="2-2-드론이-어디서-어느-쪽을-보는지-알아요">2-2. 드론이 &#39;어디서, 어느 쪽을&#39; 보는지 알아요</h3>
<p>영상의 각 순간마다 드론은 <strong>위치</strong>(어디 떠 있나)와 <strong>자세</strong>(어느 쪽을 보나)를 기록합니다. 이 둘을 알면, 카메라가 <strong>무엇을</strong> 보는지 계산할 수 있어요.</p>
<blockquote>
<p>🎥 <strong>비유</strong>: 친구가 어디 서서 어느 쪽을 가리키는지 알면, 손가락이 무엇을 가리키는지 알 수 있죠.</p>
</blockquote>
<figure class="fig">
<svg viewBox="0 0 640 210" role="img" aria-label="드론 위치와 방향">
<defs><marker id="f3a" markerWidth="9" markerHeight="9" refX="6" refY="3" orient="auto"><path d="M0,0 L6,3 L0,6 Z" fill="#2563eb"></path></marker></defs>
<!-- view cone -->
<path d="M120,120 L470,55 L470,165 Z" fill="#dbeafe" opacity="0.6"></path>
<!-- drone -->
<circle cx="120" cy="120" r="9" fill="#2563eb"></circle>
<text x="105" y="150" font-size="13" font-weight="bold" fill="#1e3a8a">드론</text>
<text x="80" y="168" font-size="11" fill="#555">(여기 있고, 이쪽을 봄)</text>
<!-- building target -->
<rect x="500" y="70" width="60" height="70" fill="#fde68a" stroke="#b45309"></rect>
<rect x="512" y="84" width="12" height="12" fill="#fff"></rect><rect x="536" y="84" width="12" height="12" fill="#fff"></rect><rect x="512" y="110" width="12" height="12" fill="#fff"></rect><rect x="536" y="110" width="12" height="12" fill="#fff"></rect>
<text x="530" y="158" text-anchor="middle" font-size="12" fill="#7c2d12">건물</text>
<!-- ray -->
<line x1="130" y1="118" x2="496" y2="105" stroke="#2563eb" stroke-width="1.5" stroke-dasharray="5 4" marker-end="url(#f3a)"></line>
<text x="250" y="100" font-size="12" fill="#1e3a8a">&quot;저건 화면 어디에 보일까?&quot; 계산</text>
</svg>
<figcaption>그림 3. 드론의 위치 + 보는 방향을 알면, 대상이 화면 어디에 보일지 계산됩니다.</figcaption>
</figure>
<h3 id="2-3-3d-세상을-납작한-화면2d에-그려요--원근법">2-3. 3D 세상을 납작한 화면(2D)에 그려요 — &#39;원근법&#39;</h3>
<p>입체(3D)를 화면(2D)에 그릴 때 쓰는 게 <strong>원근법</strong>입니다. 규칙은 누구나 알아요: <strong>가까운 건 크게, 먼 건 작게.</strong></p>
<blockquote>
<p>📷 <strong>비유</strong>: 바늘구멍 사진기처럼, 카메라는 3D를 2D로 눌러 담습니다.</p>
</blockquote>
<figure class="fig">
<svg viewBox="0 0 640 230" role="img" aria-label="원근법">
<!-- ground -->
<line x1="40" y1="195" x2="620" y2="195" stroke="#9ca3af" stroke-width="1.5"></line>
<!-- eye -->
<circle cx="70" cy="150" r="7" fill="#111"></circle>
<text x="42" y="172" font-size="12" fill="#333">눈/카메라</text>
<!-- screen -->
<line x1="210" y1="60" x2="210" y2="195" stroke="#2563eb" stroke-width="2"></line>
<text x="170" y="52" font-size="12" fill="#1e3a8a">화면</text>
<!-- near pole (same real height as far) at x=360, top y=70 bottom y=195 -->
<line x1="360" y1="70" x2="360" y2="195" stroke="#16a34a" stroke-width="4"></line>
<text x="330" y="215" font-size="12" fill="#14532d">가까운 기둥</text>
<!-- far pole at x=560 same height -->
<line x1="560" y1="70" x2="560" y2="195" stroke="#16a34a" stroke-width="4"></line>
<text x="530" y="215" font-size="12" fill="#14532d">먼 기둥</text>
<text x="350" y="58" font-size="11" fill="#777">(실제로는 둘 다 같은 크기)</text>
<!-- rays from eye to near top/bottom -->
<line x1="70" y1="150" x2="360" y2="70" stroke="#f59e0b" stroke-width="1" stroke-dasharray="3 3"></line>
<line x1="70" y1="150" x2="360" y2="195" stroke="#f59e0b" stroke-width="1" stroke-dasharray="3 3"></line>
<!-- rays to far -->
<line x1="70" y1="150" x2="560" y2="70" stroke="#b45309" stroke-width="1" stroke-dasharray="3 3"></line>
<line x1="70" y1="150" x2="560" y2="195" stroke="#b45309" stroke-width="1" stroke-dasharray="3 3"></line>
<!-- projected segments on screen: near ~111..169, far ~127..161 -->
<line x1="210" y1="111" x2="210" y2="169" stroke="#16a34a" stroke-width="6"></line>
<line x1="218" y1="127" x2="218" y2="161" stroke="#15803d" stroke-width="6" opacity="0.8"></line>
<text x="226" y="120" font-size="11" fill="#166534">화면엔 가까운 게 더 크게</text>
</svg>
<figcaption>그림 4. 실제 크기가 같아도, 화면에는 가까운 것이 크게·먼 것이 작게 찍힙니다(원근법).</figcaption>
</figure>
<h3 id="2-4-높이에는-함정이-있어요-지오이드-이야기">2-4. &#39;높이&#39;에는 함정이 있어요 (지오이드 이야기)</h3>
<p>높이를 재는 기준이 <strong>두 가지</strong>라서 헷갈려요.</p>
<ul>
<li><strong>GPS 높이</strong>: 지구를 매끈한 타원으로 본 기준</li>
<li><strong>지도 해발고도</strong>: 평균 바닷물 높이 기준(지오이드)</li>
</ul>
<p>둘은 지역마다 <strong>수십 m</strong> 차이 나요(대전 약 25.8m). 안 맞추면 글자가 엉뚱한 데로 갑니다.</p>
<blockquote>
<p>🌊 <strong>비유</strong>: &quot;1층 바닥 기준&quot;&quot;지하 바닥 기준&quot;으로 잰 높이는 같은 건물인데 숫자가 다르죠.</p>
</blockquote>
<figure class="fig">
<svg viewBox="0 0 640 200" role="img" aria-label="높이 기준 차이">
<defs><marker id="f5a" markerWidth="9" markerHeight="9" refX="3" refY="3" orient="auto"><path d="M0,0 L6,3 L0,6 Z" fill="#dc2626"></path></marker><marker id="f5b" markerWidth="9" markerHeight="9" refX="3" refY="3" orient="auto"><path d="M6,0 L0,3 L6,6 Z" fill="#dc2626"></path></marker></defs>
<!-- ground hill -->
<path d="M40,170 Q200,120 360,150 T620,135" fill="none" stroke="#8b5e34" stroke-width="3"></path>
<text x="46" y="188" font-size="11" fill="#7c5a3a">실제 땅(굴곡)</text>
<!-- GPS reference line -->
<line x1="40" y1="55" x2="620" y2="55" stroke="#2563eb" stroke-width="2" stroke-dasharray="6 4"></line>
<text x="430" y="48" font-size="12" fill="#1e3a8a">GPS 기준 (타원체)</text>
<!-- geoid reference line -->
<line x1="40" y1="105" x2="620" y2="105" stroke="#0ea5e9" stroke-width="2" stroke-dasharray="6 4"></line>
<text x="400" y="122" font-size="12" fill="#0369a1">지도 해발 기준 (지오이드)</text>
<!-- gap arrow -->
<line x1="160" y1="56" x2="160" y2="104" stroke="#dc2626" stroke-width="1.5" marker-start="url(#f5a)" marker-end="url(#f5b)"></line>
<text x="170" y="84" font-size="12" font-weight="bold" fill="#dc2626">약 25.8m 차이</text>
<text x="170" y="100" font-size="11" fill="#b91c1c">→ 맞춰줘야 함</text>
</svg>
<figcaption>그림 5. 높이 기준이 둘이라 수십 m 차이. 이걸 맞춰줘야 글자가 제자리에 붙습니다.</figcaption>
</figure>
<p>또 영리한 점: 우리 데이터엔 <strong>측점마다 실제 측량한 높이</strong>가 있어서, 비싼 3D 지형 데이터를 안 사고 <strong>가장 가까운 측점의 높이</strong>를 가져다 쓰면 선로 굴곡까지 자연스럽게 반영됩니다. (특허 후보 — 6장)</p>
<h3 id="2-5-화각-맞추기--줌렌즈-vs-광각렌즈">2-5. &#39;화각&#39; 맞추기 — 줌렌즈 vs 광각렌즈</h3>
<p>같은 자리에서도 <strong>광각</strong>은 넓게, <strong></strong>은 좁게 보이죠. 이 &quot;얼마나 넓게 보나&quot;<strong>화각(FOV)</strong> 입니다. 화각이 안 맞으면 글자가 위아래로 어긋나는데, 사용자가 글자 하나를 제자리로 끌어주면 프로그램이 화각을 <strong>스스로 맞춥니다.</strong></p>
<figure class="fig">
<svg viewBox="0 0 640 200" role="img" aria-label="화각 비교">
<!-- apex -->
<circle cx="80" cy="100" r="7" fill="#111"></circle>
<text x="55" y="122" font-size="12" fill="#333">카메라</text>
<!-- wide cone -->
<path d="M80,100 L600,25 L600,175 Z" fill="#fef3c7" opacity="0.7" stroke="#f59e0b"></path>
<text x="470" y="40" font-size="13" font-weight="bold" fill="#b45309">광각: 넓게 봄</text>
<!-- narrow cone -->
<path d="M80,100 L600,80 L600,120 Z" fill="#bfdbfe" opacity="0.85" stroke="#2563eb"></path>
<text x="470" y="150" font-size="13" font-weight="bold" fill="#1e3a8a">줌: 좁게 봄</text>
</svg>
<figcaption>그림 6. 화각(FOV) = 얼마나 넓게 보는지. 광각은 넓게, 줌은 좁게.</figcaption>
</figure>
<h3 id="2-6-흔들림-잡기--평균-내기">2-6. 흔들림 잡기 — &#39;평균 내기&#39;</h3>
<p>GPS·자세 기록엔 미세한 <strong>떨림</strong>이 있어 글자가 부르르 떨려요. 여러 순간을 <strong>평균</strong> 내면 부드러워집니다. 단, 방향을 휙 트는 순간까지 섞으면 늦게 따라오니까 — <strong>곧게 갈 땐 많이, 틀 땐 적게</strong> 평균 내요.</p>
<blockquote>
<p>✏️ <strong>비유</strong>: 떨면서 그은 선을 옆 점들과 평균 내어 매끈하게 다듬는 것.</p>
</blockquote>
<figure class="fig">
<svg viewBox="0 0 640 200" role="img" aria-label="평활 전후">
<text x="40" y="40" font-size="12" fill="#b91c1c">원래 (GPS 떨림)</text>
<polyline points="40,60 80,52 110,70 140,50 175,72 205,54 240,68 275,50 310,70 345,55 380,66 420,52 455,70 490,54 525,66 560,52 600,62" fill="none" stroke="#dc2626" stroke-width="2"></polyline>
<text x="40" y="135" font-size="12" fill="#166534">평균 낸 후 (부드러움)</text>
<polyline points="40,155 110,153 175,156 240,154 310,156 380,154 455,156 525,154 600,155" fill="none" stroke="#16a34a" stroke-width="3"></polyline>
</svg>
<figcaption>그림 7. 떨리는 값(빨강)을 평균 내면 부드러운 선(초록)이 됩니다.</figcaption>
</figure>
<h3 id="2-7-손으로-끌어서-위치-고치기--거꾸로-계산">2-7. 손으로 끌어서 위치 고치기 — &#39;거꾸로 계산&#39;</h3>
<p>지도 위치가 가끔 틀려요. 화면에서 글자를 <strong>제자리로 끌면</strong>, 프로그램이 그 화면 위치를 <strong>거꾸로 따라가</strong> 실제 위치를 찾아 고칩니다. (2-3을 반대로 돌리는 것!) 고친 위치는 저장돼 다음에도 적용돼요.</p>
<figure class="fig">
<svg viewBox="0 0 640 210" role="img" aria-label="역투영 보정">
<defs><marker id="f8a" markerWidth="10" markerHeight="10" refX="7" refY="3.5" orient="auto"><path d="M0,0 L7,3.5 L0,7 Z" fill="#7c3aed"></path></marker></defs>
<!-- screen -->
<rect x="40" y="35" width="200" height="130" rx="6" fill="#0b1020" stroke="#444"></rect>
<text x="140" y="28" text-anchor="middle" font-size="12" fill="#333">영상 화면</text>
<circle cx="160" cy="80" r="6" fill="#f59e0b"></circle>
<text x="100" y="105" font-size="11" fill="#fde68a">여기로 끌었다</text>
<!-- ground -->
<line x1="300" y1="175" x2="620" y2="175" stroke="#8b5e34" stroke-width="3"></line>
<text x="300" y="195" font-size="11" fill="#7c5a3a">실제 땅</text>
<!-- ray from screen point to ground point -->
<line x1="166" y1="82" x2="540" y2="170" stroke="#7c3aed" stroke-width="2" stroke-dasharray="5 4" marker-end="url(#f8a)"></line>
<circle cx="540" cy="170" r="6" fill="#7c3aed"></circle>
<text x="470" y="160" font-size="12" font-weight="bold" fill="#6d28d9">진짜 위치!</text>
<text x="250" y="60" font-size="12" fill="#6d28d9">화면의 점 → 거꾸로 따라가 → 실제 위치 계산</text>
</svg>
<figcaption>그림 8. 화면에서 끈 점을 거꾸로 따라가 실제 위치를 찾아 자동 보정합니다.</figcaption>
</figure>
<hr />
<h2 id="3-측점測點--우리-영상의-km-표지판">3. 측점(測點) — 우리 영상의 &#39;km 표지판&#39;</h2>
<h3 id="3-1-측점이-뭐예요">3-1. 측점이 뭐예요?</h3>
<p>고속도로의 <strong>&quot;서울 기점 158km&quot;</strong> 표지판처럼, 철도도 <strong>시작점에서 몇 m 떨어졌는지</strong>를 나타내는 표지가 있어요. 이게 <strong>측점(체이니지)</strong> 입니다. 예: <code>158k200</code> = 시작점에서 158,200m 지점.</p>
<p>이 프로그램은 영상의 매 순간을 <strong>&quot;지금 측점 몇을 보고 있다&quot;</strong> 로 바꿔, <strong>시간이 아니라 위치로</strong> 영상을 탐색합니다.</p>
<h3 id="3-2-드론-위치를-측점으로-바꾸기">3-2. 드론 위치를 측점으로 바꾸기</h3>
<p>드론은 철길 바로 위가 아니라 <strong>비스듬히 옆/위</strong>에서 날아요. 그래서 드론 위치를 <strong>철길 선 위로 수직으로 내려찍어</strong> 측점을 읽습니다.</p>
<figure class="fig">
<svg viewBox="0 0 640 210" role="img" aria-label="측점 수직투영">
<!-- route line with ticks -->
<polyline points="40,150 180,140 330,135 480,140 600,150" fill="none" stroke="#06a4c8" stroke-width="4"></polyline>
<g font-size="11" fill="#0369a1">
<line x1="110" y1="138" x2="110" y2="160" stroke="#0369a1"></line><text x="92" y="176">157k</text>
<line x1="255" y1="132" x2="255" y2="155" stroke="#0369a1"></line><text x="237" y="171">158k</text>
<line x1="405" y1="134" x2="405" y2="157" stroke="#0369a1"></line><text x="387" y="173">159k</text>
</g>
<text x="46" y="135" font-size="12" fill="#0369a1">선로(측점 표지)</text>
<!-- drone -->
<circle cx="330" cy="60" r="9" fill="#2563eb"></circle>
<text x="345" y="58" font-size="13" font-weight="bold" fill="#1e3a8a">드론</text>
<text x="345" y="74" font-size="11" fill="#555">(옆/위에서 비스듬히)</text>
<!-- perpendicular drop -->
<line x1="330" y1="68" x2="330" y2="133" stroke="#f59e0b" stroke-width="2" stroke-dasharray="5 4"></line>
<circle cx="330" cy="135" r="6" fill="#b45309"></circle>
<text x="338" y="128" font-size="12" font-weight="bold" fill="#b45309">158k170</text>
</svg>
<figcaption>그림 9. 드론 위치를 선로로 수직으로 내려 &quot;지금 보는 측점&quot;을 10m 단위로 읽습니다.</figcaption>
</figure>
<h3 id="3-3-왜-같은-측점이-여러-군데-보일까">3-3. 왜 같은 측점이 여러 군데 보일까?</h3>
<p>조차장·종점에선 드론이 <strong>같은 곳을 앞뒤로 여러 번</strong> 지나가요. 그러면 같은 측점이 여러 번 찍힙니다.</p>
<blockquote>
<p>🚂 <strong>비유</strong>: 운동장 트랙을 두 바퀴 돌면 &quot;출발선&quot;을 두 번 지나죠. 잘못된 게 아니라 정말 두 번 지난 거예요.</p>
</blockquote>
<h3 id="3-4-왜-시간-막대가-아니라-거리-막대일까">3-4. 왜 &#39;시간 막대&#39;가 아니라 &#39;거리 막대&#39;일까?</h3>
<p>드론은 <strong>가만히 떠 있기(호버)</strong> 도 하고 <strong>앞뒤로 왔다 갔다</strong> 해요. 시간 막대면 드론이 멈춰도 커서가 계속 흘러가 어긋납니다. 그래서 <strong>실제 움직인 거리</strong> 기준으로 막대를 만들었어요 — <strong>멈추면 커서도 멈추고, 움직인 만큼만 이동.</strong></p>
<blockquote>
<p>🥾 <strong>비유</strong>: 등산 앱이 &quot;몇 시간째&quot;가 아니라 &quot;<strong>몇 km 걸었는지</strong>&quot;를 보여주는 것. 쉬면 점이 안 움직이죠.</p>
</blockquote>
<figure class="fig">
<svg viewBox="0 0 640 220" role="img" aria-label="시간 막대 vs 거리 막대">
<!-- time bar -->
<text x="40" y="42" font-size="13" font-weight="bold" fill="#b91c1c">시간 막대 ✗</text>
<rect x="40" y="55" width="500" height="16" rx="8" fill="#e5e7eb"></rect>
<rect x="40" y="55" width="380" height="16" rx="8" fill="#fca5a5"></rect>
<circle cx="420" cy="63" r="10" fill="#dc2626"></circle>
<text x="200" y="95" font-size="12" fill="#b91c1c">드론이 멈춰(호버) 있어도 커서가 계속 흘러감 → 위치와 어긋남</text>
<!-- distance bar -->
<text x="40" y="150" font-size="13" font-weight="bold" fill="#166534">거리 막대 ✓</text>
<rect x="40" y="163" width="500" height="16" rx="8" fill="#e5e7eb"></rect>
<rect x="40" y="163" width="250" height="16" rx="8" fill="#86efac"></rect>
<circle cx="290" cy="171" r="10" fill="#16a34a"></circle>
<text x="200" y="203" font-size="12" fill="#166534">호버(정지) 중엔 커서도 멈춤 → &quot;지금 보는 위치&quot;와 일치</text>
</svg>
<figcaption>그림 10. 시간 막대는 멈춰도 커서가 흐르지만, 거리 막대는 움직인 만큼만 이동합니다.</figcaption>
</figure>
<p>진행 방향(앞으로/뒤로)은 막대의 <strong></strong>(주황=정방향, 청록=역방향)으로 구분합니다.</p>
<h3 id="3-5-측점을-검색하면-빙글빙글-순환-이동">3-5. 측점을 검색하면 빙글빙글 (순환 이동)</h3>
<p>측점값을 입력하고 <strong>Enter</strong>를 치면 그 지점으로 이동, 또 치면 <strong>다음 지점</strong>, 마지막이면 <strong>처음으로</strong> 돌아와요.</p>
<blockquote>
<p>🔁 <strong>비유</strong>: 워드의 &quot;다음 찾기&quot;를 반복하면 같은 단어를 차례로 돌다가 처음으로 돌아오죠.</p>
</blockquote>
<figure class="fig">
<svg viewBox="0 0 640 170" role="img" aria-label="측점 검색 순환">
<defs><marker id="f11a" markerWidth="9" markerHeight="9" refX="6" refY="3" orient="auto"><path d="M0,0 L6,3 L0,6 Z" fill="#b45309"></path></marker></defs>
<rect x="40" y="95" width="560" height="14" rx="7" fill="#e5e7eb"></rect>
<g>
<circle cx="150" cy="102" r="11" fill="#f59e0b"></circle><text x="150" y="106" text-anchor="middle" font-size="11" font-weight="bold" fill="#fff">1</text><text x="120" y="135" font-size="11" fill="#7c2d12">162k080</text>
<circle cx="330" cy="102" r="11" fill="#f59e0b"></circle><text x="330" y="106" text-anchor="middle" font-size="11" font-weight="bold" fill="#fff">2</text><text x="300" y="135" font-size="11" fill="#7c2d12">162k080</text>
<circle cx="510" cy="102" r="11" fill="#f59e0b"></circle><text x="510" y="106" text-anchor="middle" font-size="11" font-weight="bold" fill="#fff">3</text><text x="480" y="135" font-size="11" fill="#7c2d12">162k080</text>
</g>
<path d="M162,90 Q240,60 318,90" fill="none" stroke="#b45309" stroke-width="1.5" marker-end="url(#f11a)"></path>
<path d="M342,90 Q420,60 498,90" fill="none" stroke="#b45309" stroke-width="1.5" marker-end="url(#f11a)"></path>
<path d="M510,118 Q330,165 150,118" fill="none" stroke="#b45309" stroke-width="1.5" stroke-dasharray="5 4" marker-end="url(#f11a)"></path>
<text x="270" y="52" font-size="12" fill="#b45309">Enter → 다음</text>
<text x="290" y="160" font-size="12" fill="#b45309">끝나면 처음으로</text>
</svg>
<figcaption>그림 11. 같은 측점이 여러 곳이면, Enter를 누를 때마다 다음으로·끝나면 처음으로 순환합니다.</figcaption>
</figure>
<h3 id="3-6-진짜-측점과-근처라서-찍힌-것-구분-kmexists">3-6. &#39;진짜 측점&#39;&#39;근처라서 찍힌 것&#39; 구분 (kmExists)</h3>
<p>어떤 역 <strong>근처를</strong> 지나가면 동그라미가 찍히는데, 그 자리 실제 측점값이 역의 값과 <strong>다를 수도</strong> 있어요. 그러면 <strong>동그라미는 남기고, 측점값 숫자만 숨깁니다.</strong></p>
<blockquote>
<p>🏷️ <strong>비유</strong>: &quot;이 근처에 우체국 있음&quot;은 표시하되, 정확한 주소가 아니면 주소 숫자는 안 붙이는 것.</p>
</blockquote>
<hr />
<h2 id="4-아주-큰-영상을-끊김-없이-트는-법">4. 아주 큰 영상을 끊김 없이 트는 법</h2>
<p><strong>2GB 넘는 영상</strong>을 통째로 읽으면 컴퓨터가 멈춰요. 그래서 <strong>보는 부분만 조금씩</strong>(약 10MB) 잘라 받고, 또는 <strong>몇 초짜리 조각들</strong>로 나눠 이어 붙여 재생합니다(유튜브 방식).</p>
<figure class="fig">
<svg viewBox="0 0 640 150" role="img" aria-label="스트리밍 조각">
<rect x="40" y="45" width="560" height="55" rx="6" fill="#eef2ff" stroke="#6366f1"></rect>
<g stroke="#a5b4fc" stroke-dasharray="4 3">
<line x1="120" y1="45" x2="120" y2="100"></line><line x1="200" y1="45" x2="200" y2="100"></line><line x1="280" y1="45" x2="280" y2="100"></line><line x1="360" y1="45" x2="360" y2="100"></line><line x1="440" y1="45" x2="440" y2="100"></line><line x1="520" y1="45" x2="520" y2="100"></line>
</g>
<rect x="280" y="45" width="80" height="55" fill="#f59e0b" opacity="0.85"></rect>
<text x="320" y="78" text-anchor="middle" font-size="11" font-weight="bold" fill="#fff">지금 보는</text>
<text x="320" y="118" text-anchor="middle" font-size="11" fill="#b45309">필요한 조각만 조금씩</text>
<text x="46" y="36" font-size="12" fill="#4338ca">큰 영상 (2GB+)</text>
</svg>
<figcaption>그림 12. 큰 영상을 통째로가 아니라, 보는 만큼 조각으로 잘라 받아 끊김을 막습니다.</figcaption>
</figure>
<p>오래 보면 데이터가 쌓여 메모리가 터질 수 있어, <strong>&quot;지나간 30초치만 남기고 버리도록&quot;</strong> 해 뒀습니다. 프레임(사진 한 장) 단위로 정확히 뽑을 땐 <strong>FFmpeg</strong>라는 전문 도구가 그 장면을 잘라 줍니다.</p>
<hr />
<h2 id="5-분명히-고쳤는데-화면이-그대로예요-꼭-알아둘-점">5. &quot;분명히 고쳤는데 화면이 그대로예요?&quot; (꼭 알아둘 점)</h2>
<p>우리가 보는 화면은 <strong>&quot;차린 음식(빌드 결과물)&quot;</strong> 을 보여줍니다. <strong>&quot;날재료(코드)&quot;</strong> 를 고쳐도 <strong>다시 요리(빌드)</strong> 하지 않으면 식탁은 그대로예요.</p>
<blockquote>
<p>🍳 <strong>비유</strong>: 레시피를 고쳐도 다시 요리해 차리기 전엔 식탁 음식은 안 바뀝니다.</p>
</blockquote>
<p>그래서 코드를 고치면 ① <strong>다시 빌드</strong> → ② 브라우저 <strong>새로고침</strong>(Ctrl+Shift+R) → ③ <strong>폴더 다시 선택</strong> 해야 바뀐 내용이 보입니다.</p>
<hr />
<h2 id="6-이게-왜-특별한특허감-기술일까">6. 이게 왜 &#39;특별한(특허감)&#39; 기술일까?</h2>
<ol type="1">
<li><strong>측량 측점 높이로, 비싼 3D 지형 없이 정합</strong> (2-4) + 두 높이 기준 자동 맞춤.</li>
<li><strong>한 번 끌어서 가로·세로·거리 동시 보정</strong> (2-7) — 보통은 깊이 측정 장비가 필요한 일을 장비 없이.</li>
<li><strong>상황 봐가며 조절하는 흔들림 잡기</strong> (2-6) — 부드러움과 빠른 반응을 동시에.</li>
<li><strong>시간이 아니라 &#39;위치(측점)&#39;로 영상을 다루기</strong> (3장) — 거리 막대·검색 순환·진짜/근처 구분 등.</li>
</ol>
<blockquote>
<p>⚠️ 단, 특허는 <strong>이미 비슷한 게 있는지(선행기술) 전문 검색</strong>을 꼭 해봐야 확정됩니다. (아직 미완)</p>
</blockquote>
<hr />
<h2 id="7-쉬운-용어-사전">7. 쉬운 용어 사전</h2>
<table>
<thead>
<tr class="header">
<th>용어</th>
<th>쉬운 뜻</th>
</tr>
</thead>
<tbody>
<tr class="odd">
<td><strong>측점(체이니지)</strong></td>
<td>노선 시작점에서 몇 m 떨어졌는지 나타내는 &#39;철길 km 표지판&#39;</td>
</tr>
<tr class="even">
<td><strong>위도·경도</strong></td>
<td>지구 위 위치를 나타내는 두 숫자</td>
</tr>
<tr class="odd">
<td><strong>yaw / pitch / roll</strong></td>
<td>카메라가 좌우로 돈 / 아래로 숙인 / 옆으로 기운 정도</td>
</tr>
<tr class="even">
<td><strong>투영(projection)</strong></td>
<td>입체(3D)를 납작한 화면(2D)에 그리는 것 (원근법)</td>
</tr>
<tr class="odd">
<td><strong>역투영</strong></td>
<td>그 반대 — 화면의 한 점이 실제 어디인지 거꾸로 찾는 것</td>
</tr>
<tr class="even">
<td><strong>화각(FOV)</strong></td>
<td>카메라가 얼마나 넓게 보는지 (광각=넓게, 줌=좁게)</td>
</tr>
<tr class="odd">
<td><strong>지오이드</strong></td>
<td>&#39;해발고도&#39;의 기준(평균 바닷물 높이). GPS 높이와 기준이 달라 변환 필요</td>
</tr>
<tr class="even">
<td><strong>DEM</strong></td>
<td>땅의 높낮이를 담은 3D 지형 데이터(보통 비쌈)</td>
</tr>
<tr class="odd">
<td><strong>평활(스무딩)</strong></td>
<td>떨리는 값을 주변과 평균 내어 부드럽게 만드는 것</td>
</tr>
<tr class="even">
<td><strong>호버</strong></td>
<td>드론이 한자리에 떠서 멈춰 있는 상태</td>
</tr>
<tr class="odd">
<td><strong>스테이션바</strong></td>
<td>화면 아래의 측점(위치) 기반 진행 막대</td>
</tr>
<tr class="even">
<td><strong>빌드</strong></td>
<td>코드를 컴퓨터가 실행할 형태로 &#39;요리&#39;하는 과정</td>
</tr>
<tr class="odd">
<td><strong>스트리밍</strong></td>
<td>영상을 통째로 안 받고 보는 만큼 조금씩 받아 트는 것</td>
</tr>
</tbody>
</table>
<hr />
<h2 id="8-마치며">8. 마치며</h2>
<blockquote>
<p><strong>&quot;드론이 어디서 어느 쪽을 보는지&quot;</strong><strong>&quot;지도에 무엇이 어디 있는지&quot;</strong> 를 합쳐서, 영상 위에 정보를 정확히 붙이고, <strong>시간이 아니라 &#39;측점(위치)&#39;으로</strong> 영상을 탐색하게 해주는 도구.</p>
</blockquote>
<p>복잡해 보여도, 사실은 일상의 생각들 — <em>친구가 가리키는 곳 알아채기, 원근법, km 표지판, 등산 앱의 걸은 거리, 떨리는 선 다듬기</em> — 을 컴퓨터로 정밀하게 구현한 것뿐입니다. 🙂</p>
</body>
</html>
@@ -0,0 +1,432 @@
# GhiVideo, 쉽게 풀어쓴 기술 이야기 (그림판)
> 이 문서는 **수학을 잘 몰라도, 중학생이 읽어도** 이해할 수 있게 쓴 설명서입니다.
> 어려운 공식 대신 **비유와 그림**으로 "이 프로그램이 무슨 일을, 왜, 어떻게 하는지"를 풀어 씁니다.
> (더 깊은 기술/수식은 같은 폴더의 `기술명세_GhiVideo_종합기술문서.pdf` 참고.)
<style>
figure.fig{margin:20px 0;text-align:center;page-break-inside:avoid;break-inside:avoid;}
figure.fig svg{width:100%;max-width:640px;height:auto;border:1px solid #e7e0d2;border-radius:8px;background:#fffdf8;}
figure.fig figcaption{font-size:0.9em;color:#777;margin-top:6px;}
text{font-family:'Malgun Gothic','Hancom Gothic',sans-serif;}
</style>
---
## 0. 한 문장으로 말하면
**드론이 철길 위를 날며 찍은 영상**을 보면서, 화면 속 다리·터널·역·선로 위에
**"여기는 어디, 저건 무슨 다리"** 같은 정보를 **자동으로 딱 붙여서** 보여주는 프로그램입니다.
마치 **포켓몬GO**나 **내비게이션의 증강현실(AR) 길안내**처럼, 실제 영상 위에 정보가 떠 있는 거예요.
---
## 1. 가장 큰 그림 먼저
우리가 가진 재료는 딱 두 가지입니다.
1. **드론이 찍은 영상** — 매 순간 "드론이 **어디에** 있었고 **어느 방향을** 보는지"가 같이 기록돼 있어요.
2. **지도 정보** — 다리·터널·역·측점이 지구상 **어디에 있는지**(위도·경도) 적힌 데이터.
이 둘을 합치면 **"저 다리는 화면의 어느 위치에 보일까?"** 에 답할 수 있어요. 그게 핵심 기술입니다.
<figure class="fig">
<svg viewBox="0 0 640 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="#b45309"/></marker></defs>
<rect x="20" y="25" width="180" height="55" rx="8" fill="#fff" stroke="#2563eb" stroke-width="1.5"/>
<text x="110" y="48" text-anchor="middle" font-size="15" font-weight="bold" fill="#1e3a8a">드론 영상</text>
<text x="110" y="68" text-anchor="middle" font-size="12" fill="#555">(위치 · 보는 방향 기록)</text>
<rect x="20" y="125" width="180" height="55" rx="8" fill="#fff" stroke="#16a34a" stroke-width="1.5"/>
<text x="110" y="148" text-anchor="middle" font-size="15" font-weight="bold" fill="#14532d">지도 정보</text>
<text x="110" y="168" text-anchor="middle" font-size="12" fill="#555">(다리·터널·측점 위치)</text>
<rect x="270" y="75" width="150" height="55" rx="8" fill="#fff7e6" stroke="#f59e0b" stroke-width="2"/>
<text x="345" y="98" text-anchor="middle" font-size="15" font-weight="bold" fill="#b45309">합치기</text>
<text x="345" y="118" text-anchor="middle" font-size="12" fill="#7c2d12">(투영 계산)</text>
<rect x="470" y="75" width="150" height="55" rx="8" fill="#fff" stroke="#b45309" stroke-width="1.5"/>
<text x="545" y="100" text-anchor="middle" font-size="14" font-weight="bold" fill="#23272e">화면에</text>
<text x="545" y="119" text-anchor="middle" font-size="14" font-weight="bold" fill="#23272e">정보 표시</text>
<line x1="200" y1="52" x2="266" y2="92" stroke="#b45309" stroke-width="1.5" marker-end="url(#f1a)"/>
<line x1="200" y1="152" x2="266" y2="114" stroke="#b45309" stroke-width="1.5" marker-end="url(#f1a)"/>
<line x1="420" y1="102" x2="466" y2="102" stroke="#b45309" stroke-width="1.5" marker-end="url(#f1a)"/>
</svg>
<figcaption>그림 1. 드론 영상 + 지도 정보 → "투영 계산"으로 합쳐 → 화면에 정보 표시</figcaption>
</figure>
---
## 2. 영상 위에 글자를 정확히 붙이는 마법
### 2-1. 먼저, 위치를 '숫자'로 바꿔요
지구 위 위치는 보통 위도·경도(각도)로 말하는데, 거리 계산이 불편해요.
그래서 **"기준점에서 동쪽 몇 m, 북쪽 몇 m"** 처럼 **미터(m)** 로 바꿉니다.
> 🧭 **비유**: 학교 정문을 (0,0)으로 정하고 "동쪽 50m, 북쪽 30m 지점"이라 말하는 것과 같아요.
<figure class="fig">
<svg viewBox="0 0 640 240" role="img" aria-label="좌표 예시">
<defs><marker id="f2a" markerWidth="9" markerHeight="9" refX="6" refY="3" orient="auto"><path d="M0,0 L6,3 L0,6 Z" fill="#6b7280"/></marker></defs>
<!-- grid -->
<g stroke="#eee" stroke-width="1">
<line x1="90" y1="40" x2="90" y2="200"/><line x1="170" y1="40" x2="170" y2="200"/><line x1="250" y1="40" x2="250" y2="200"/><line x1="330" y1="40" x2="330" y2="200"/>
<line x1="90" y1="200" x2="610" y2="200"/><line x1="90" y1="160" x2="610" y2="160"/><line x1="90" y1="120" x2="610" y2="120"/><line x1="90" y1="80" x2="610" y2="80"/>
</g>
<!-- axes -->
<line x1="90" y1="200" x2="610" y2="200" stroke="#6b7280" stroke-width="2" marker-end="url(#f2a)"/>
<line x1="90" y1="200" x2="90" y2="35" stroke="#6b7280" stroke-width="2" marker-end="url(#f2a)"/>
<text x="600" y="222" font-size="13" fill="#6b7280">동쪽(m) →</text>
<text x="60" y="45" font-size="13" fill="#6b7280">북쪽(m) ↑</text>
<!-- origin -->
<circle cx="90" cy="200" r="5" fill="#16a34a"/>
<text x="98" y="218" font-size="12" fill="#14532d">학교 정문 (0,0)</text>
<!-- point: east 50m (->x 290), north 30m (->y 80) -->
<line x1="290" y1="200" x2="290" y2="80" stroke="#f59e0b" stroke-width="1.5" stroke-dasharray="4 3"/>
<line x1="90" y1="80" x2="290" y2="80" stroke="#f59e0b" stroke-width="1.5" stroke-dasharray="4 3"/>
<circle cx="290" cy="80" r="6" fill="#b45309"/>
<text x="300" y="76" font-size="13" font-weight="bold" fill="#b45309">건물</text>
<text x="300" y="95" font-size="12" fill="#7c2d12">(동 50m, 북 30m)</text>
</svg>
<figcaption>그림 2. 위치를 "기준점에서 동/북 몇 m"로 바꾸면 거리·계산이 쉬워집니다.</figcaption>
</figure>
### 2-2. 드론이 '어디서, 어느 쪽을' 보는지 알아요
영상의 각 순간마다 드론은 **위치**(어디 떠 있나)와 **자세**(어느 쪽을 보나)를 기록합니다.
이 둘을 알면, 카메라가 **무엇을** 보는지 계산할 수 있어요.
> 🎥 **비유**: 친구가 어디 서서 어느 쪽을 가리키는지 알면, 손가락이 무엇을 가리키는지 알 수 있죠.
<figure class="fig">
<svg viewBox="0 0 640 210" role="img" aria-label="드론 위치와 방향">
<defs><marker id="f3a" markerWidth="9" markerHeight="9" refX="6" refY="3" orient="auto"><path d="M0,0 L6,3 L0,6 Z" fill="#2563eb"/></marker></defs>
<!-- view cone -->
<path d="M120,120 L470,55 L470,165 Z" fill="#dbeafe" opacity="0.6"/>
<!-- drone -->
<circle cx="120" cy="120" r="9" fill="#2563eb"/>
<text x="105" y="150" font-size="13" font-weight="bold" fill="#1e3a8a">드론</text>
<text x="80" y="168" font-size="11" fill="#555">(여기 있고, 이쪽을 봄)</text>
<!-- building target -->
<rect x="500" y="70" width="60" height="70" fill="#fde68a" stroke="#b45309"/>
<rect x="512" y="84" width="12" height="12" fill="#fff"/><rect x="536" y="84" width="12" height="12" fill="#fff"/><rect x="512" y="110" width="12" height="12" fill="#fff"/><rect x="536" y="110" width="12" height="12" fill="#fff"/>
<text x="530" y="158" text-anchor="middle" font-size="12" fill="#7c2d12">건물</text>
<!-- ray -->
<line x1="130" y1="118" x2="496" y2="105" stroke="#2563eb" stroke-width="1.5" stroke-dasharray="5 4" marker-end="url(#f3a)"/>
<text x="250" y="100" font-size="12" fill="#1e3a8a">"저건 화면 어디에 보일까?" 계산</text>
</svg>
<figcaption>그림 3. 드론의 위치 + 보는 방향을 알면, 대상이 화면 어디에 보일지 계산됩니다.</figcaption>
</figure>
### 2-3. 3D 세상을 납작한 화면(2D)에 그려요 — '원근법'
입체(3D)를 화면(2D)에 그릴 때 쓰는 게 **원근법**입니다. 규칙은 누구나 알아요:
**가까운 건 크게, 먼 건 작게.**
> 📷 **비유**: 바늘구멍 사진기처럼, 카메라는 3D를 2D로 눌러 담습니다.
<figure class="fig">
<svg viewBox="0 0 640 230" role="img" aria-label="원근법">
<!-- ground -->
<line x1="40" y1="195" x2="620" y2="195" stroke="#9ca3af" stroke-width="1.5"/>
<!-- eye -->
<circle cx="70" cy="150" r="7" fill="#111"/>
<text x="42" y="172" font-size="12" fill="#333">눈/카메라</text>
<!-- screen -->
<line x1="210" y1="60" x2="210" y2="195" stroke="#2563eb" stroke-width="2"/>
<text x="170" y="52" font-size="12" fill="#1e3a8a">화면</text>
<!-- near pole (same real height as far) at x=360, top y=70 bottom y=195 -->
<line x1="360" y1="70" x2="360" y2="195" stroke="#16a34a" stroke-width="4"/>
<text x="330" y="215" font-size="12" fill="#14532d">가까운 기둥</text>
<!-- far pole at x=560 same height -->
<line x1="560" y1="70" x2="560" y2="195" stroke="#16a34a" stroke-width="4"/>
<text x="530" y="215" font-size="12" fill="#14532d">먼 기둥</text>
<text x="350" y="58" font-size="11" fill="#777">(실제로는 둘 다 같은 크기)</text>
<!-- rays from eye to near top/bottom -->
<line x1="70" y1="150" x2="360" y2="70" stroke="#f59e0b" stroke-width="1" stroke-dasharray="3 3"/>
<line x1="70" y1="150" x2="360" y2="195" stroke="#f59e0b" stroke-width="1" stroke-dasharray="3 3"/>
<!-- rays to far -->
<line x1="70" y1="150" x2="560" y2="70" stroke="#b45309" stroke-width="1" stroke-dasharray="3 3"/>
<line x1="70" y1="150" x2="560" y2="195" stroke="#b45309" stroke-width="1" stroke-dasharray="3 3"/>
<!-- projected segments on screen: near ~111..169, far ~127..161 -->
<line x1="210" y1="111" x2="210" y2="169" stroke="#16a34a" stroke-width="6"/>
<line x1="218" y1="127" x2="218" y2="161" stroke="#15803d" stroke-width="6" opacity="0.8"/>
<text x="226" y="120" font-size="11" fill="#166534">화면엔 가까운 게 더 크게</text>
</svg>
<figcaption>그림 4. 실제 크기가 같아도, 화면에는 가까운 것이 크게·먼 것이 작게 찍힙니다(원근법).</figcaption>
</figure>
### 2-4. '높이'에는 함정이 있어요 (지오이드 이야기)
높이를 재는 기준이 **두 가지**라서 헷갈려요.
- **GPS 높이**: 지구를 매끈한 타원으로 본 기준
- **지도 해발고도**: 평균 바닷물 높이 기준(지오이드)
둘은 지역마다 **수십 m** 차이 나요(대전 약 25.8m). 안 맞추면 글자가 엉뚱한 데로 갑니다.
> 🌊 **비유**: "1층 바닥 기준"과 "지하 바닥 기준"으로 잰 높이는 같은 건물인데 숫자가 다르죠.
<figure class="fig">
<svg viewBox="0 0 640 200" role="img" aria-label="높이 기준 차이">
<defs><marker id="f5a" markerWidth="9" markerHeight="9" refX="3" refY="3" orient="auto"><path d="M0,0 L6,3 L0,6 Z" fill="#dc2626"/></marker><marker id="f5b" markerWidth="9" markerHeight="9" refX="3" refY="3" orient="auto"><path d="M6,0 L0,3 L6,6 Z" fill="#dc2626"/></marker></defs>
<!-- ground hill -->
<path d="M40,170 Q200,120 360,150 T620,135" fill="none" stroke="#8b5e34" stroke-width="3"/>
<text x="46" y="188" font-size="11" fill="#7c5a3a">실제 땅(굴곡)</text>
<!-- GPS reference line -->
<line x1="40" y1="55" x2="620" y2="55" stroke="#2563eb" stroke-width="2" stroke-dasharray="6 4"/>
<text x="430" y="48" font-size="12" fill="#1e3a8a">GPS 기준 (타원체)</text>
<!-- geoid reference line -->
<line x1="40" y1="105" x2="620" y2="105" stroke="#0ea5e9" stroke-width="2" stroke-dasharray="6 4"/>
<text x="400" y="122" font-size="12" fill="#0369a1">지도 해발 기준 (지오이드)</text>
<!-- gap arrow -->
<line x1="160" y1="56" x2="160" y2="104" stroke="#dc2626" stroke-width="1.5" marker-start="url(#f5a)" marker-end="url(#f5b)"/>
<text x="170" y="84" font-size="12" font-weight="bold" fill="#dc2626">약 25.8m 차이</text>
<text x="170" y="100" font-size="11" fill="#b91c1c">→ 맞춰줘야 함</text>
</svg>
<figcaption>그림 5. 높이 기준이 둘이라 수십 m 차이. 이걸 맞춰줘야 글자가 제자리에 붙습니다.</figcaption>
</figure>
또 영리한 점: 우리 데이터엔 **측점마다 실제 측량한 높이**가 있어서, 비싼 3D 지형 데이터를 안 사고
**가장 가까운 측점의 높이**를 가져다 쓰면 선로 굴곡까지 자연스럽게 반영됩니다. (특허 후보 — 6장)
### 2-5. '화각' 맞추기 — 줌렌즈 vs 광각렌즈
같은 자리에서도 **광각**은 넓게, **줌**은 좁게 보이죠. 이 "얼마나 넓게 보나"가 **화각(FOV)** 입니다.
화각이 안 맞으면 글자가 위아래로 어긋나는데, 사용자가 글자 하나를 제자리로 끌어주면
프로그램이 화각을 **스스로 맞춥니다.**
<figure class="fig">
<svg viewBox="0 0 640 200" role="img" aria-label="화각 비교">
<!-- apex -->
<circle cx="80" cy="100" r="7" fill="#111"/>
<text x="55" y="122" font-size="12" fill="#333">카메라</text>
<!-- wide cone -->
<path d="M80,100 L600,25 L600,175 Z" fill="#fef3c7" opacity="0.7" stroke="#f59e0b"/>
<text x="470" y="40" font-size="13" font-weight="bold" fill="#b45309">광각: 넓게 봄</text>
<!-- narrow cone -->
<path d="M80,100 L600,80 L600,120 Z" fill="#bfdbfe" opacity="0.85" stroke="#2563eb"/>
<text x="470" y="150" font-size="13" font-weight="bold" fill="#1e3a8a">줌: 좁게 봄</text>
</svg>
<figcaption>그림 6. 화각(FOV) = 얼마나 넓게 보는지. 광각은 넓게, 줌은 좁게.</figcaption>
</figure>
### 2-6. 흔들림 잡기 — '평균 내기'
GPS·자세 기록엔 미세한 **떨림**이 있어 글자가 부르르 떨려요. 여러 순간을 **평균** 내면 부드러워집니다.
단, 방향을 휙 트는 순간까지 섞으면 늦게 따라오니까 — **곧게 갈 땐 많이, 틀 땐 적게** 평균 내요.
> ✏️ **비유**: 떨면서 그은 선을 옆 점들과 평균 내어 매끈하게 다듬는 것.
<figure class="fig">
<svg viewBox="0 0 640 200" role="img" aria-label="평활 전후">
<text x="40" y="40" font-size="12" fill="#b91c1c">원래 (GPS 떨림)</text>
<polyline points="40,60 80,52 110,70 140,50 175,72 205,54 240,68 275,50 310,70 345,55 380,66 420,52 455,70 490,54 525,66 560,52 600,62" fill="none" stroke="#dc2626" stroke-width="2"/>
<text x="40" y="135" font-size="12" fill="#166534">평균 낸 후 (부드러움)</text>
<polyline points="40,155 110,153 175,156 240,154 310,156 380,154 455,156 525,154 600,155" fill="none" stroke="#16a34a" stroke-width="3"/>
</svg>
<figcaption>그림 7. 떨리는 값(빨강)을 평균 내면 부드러운 선(초록)이 됩니다.</figcaption>
</figure>
### 2-7. 손으로 끌어서 위치 고치기 — '거꾸로 계산'
지도 위치가 가끔 틀려요. 화면에서 글자를 **제자리로 끌면**, 프로그램이 그 화면 위치를
**거꾸로 따라가** 실제 위치를 찾아 고칩니다. (2-3을 반대로 돌리는 것!) 고친 위치는 저장돼 다음에도 적용돼요.
<figure class="fig">
<svg viewBox="0 0 640 210" role="img" aria-label="역투영 보정">
<defs><marker id="f8a" markerWidth="10" markerHeight="10" refX="7" refY="3.5" orient="auto"><path d="M0,0 L7,3.5 L0,7 Z" fill="#7c3aed"/></marker></defs>
<!-- screen -->
<rect x="40" y="35" width="200" height="130" rx="6" fill="#0b1020" stroke="#444"/>
<text x="140" y="28" text-anchor="middle" font-size="12" fill="#333">영상 화면</text>
<circle cx="160" cy="80" r="6" fill="#f59e0b"/>
<text x="100" y="105" font-size="11" fill="#fde68a">여기로 끌었다</text>
<!-- ground -->
<line x1="300" y1="175" x2="620" y2="175" stroke="#8b5e34" stroke-width="3"/>
<text x="300" y="195" font-size="11" fill="#7c5a3a">실제 땅</text>
<!-- ray from screen point to ground point -->
<line x1="166" y1="82" x2="540" y2="170" stroke="#7c3aed" stroke-width="2" stroke-dasharray="5 4" marker-end="url(#f8a)"/>
<circle cx="540" cy="170" r="6" fill="#7c3aed"/>
<text x="470" y="160" font-size="12" font-weight="bold" fill="#6d28d9">진짜 위치!</text>
<text x="250" y="60" font-size="12" fill="#6d28d9">화면의 점 → 거꾸로 따라가 → 실제 위치 계산</text>
</svg>
<figcaption>그림 8. 화면에서 끈 점을 거꾸로 따라가 실제 위치를 찾아 자동 보정합니다.</figcaption>
</figure>
---
## 3. 측점(測點) — 우리 영상의 'km 표지판'
### 3-1. 측점이 뭐예요?
고속도로의 **"서울 기점 158km"** 표지판처럼, 철도도 **시작점에서 몇 m 떨어졌는지**를 나타내는
표지가 있어요. 이게 **측점(체이니지)** 입니다. 예: `158k200` = 시작점에서 158,200m 지점.
이 프로그램은 영상의 매 순간을 **"지금 측점 몇을 보고 있다"** 로 바꿔, **시간이 아니라 위치로** 영상을 탐색합니다.
### 3-2. 드론 위치를 측점으로 바꾸기
드론은 철길 바로 위가 아니라 **비스듬히 옆/위**에서 날아요. 그래서 드론 위치를
**철길 선 위로 수직으로 내려찍어** 측점을 읽습니다.
<figure class="fig">
<svg viewBox="0 0 640 210" role="img" aria-label="측점 수직투영">
<!-- route line with ticks -->
<polyline points="40,150 180,140 330,135 480,140 600,150" fill="none" stroke="#06a4c8" stroke-width="4"/>
<g font-size="11" fill="#0369a1">
<line x1="110" y1="138" x2="110" y2="160" stroke="#0369a1"/><text x="92" y="176">157k</text>
<line x1="255" y1="132" x2="255" y2="155" stroke="#0369a1"/><text x="237" y="171">158k</text>
<line x1="405" y1="134" x2="405" y2="157" stroke="#0369a1"/><text x="387" y="173">159k</text>
</g>
<text x="46" y="135" font-size="12" fill="#0369a1">선로(측점 표지)</text>
<!-- drone -->
<circle cx="330" cy="60" r="9" fill="#2563eb"/>
<text x="345" y="58" font-size="13" font-weight="bold" fill="#1e3a8a">드론</text>
<text x="345" y="74" font-size="11" fill="#555">(옆/위에서 비스듬히)</text>
<!-- perpendicular drop -->
<line x1="330" y1="68" x2="330" y2="133" stroke="#f59e0b" stroke-width="2" stroke-dasharray="5 4"/>
<circle cx="330" cy="135" r="6" fill="#b45309"/>
<text x="338" y="128" font-size="12" font-weight="bold" fill="#b45309">158k170</text>
</svg>
<figcaption>그림 9. 드론 위치를 선로로 수직으로 내려 "지금 보는 측점"을 10m 단위로 읽습니다.</figcaption>
</figure>
### 3-3. 왜 같은 측점이 여러 군데 보일까?
조차장·종점에선 드론이 **같은 곳을 앞뒤로 여러 번** 지나가요. 그러면 같은 측점이 여러 번 찍힙니다.
> 🚂 **비유**: 운동장 트랙을 두 바퀴 돌면 "출발선"을 두 번 지나죠. 잘못된 게 아니라 정말 두 번 지난 거예요.
### 3-4. 왜 '시간 막대'가 아니라 '거리 막대'일까?
드론은 **가만히 떠 있기(호버)** 도 하고 **앞뒤로 왔다 갔다** 해요. 시간 막대면 드론이 멈춰도
커서가 계속 흘러가 어긋납니다. 그래서 **실제 움직인 거리** 기준으로 막대를 만들었어요 —
**멈추면 커서도 멈추고, 움직인 만큼만 이동.**
> 🥾 **비유**: 등산 앱이 "몇 시간째"가 아니라 "**몇 km 걸었는지**"를 보여주는 것. 쉬면 점이 안 움직이죠.
<figure class="fig">
<svg viewBox="0 0 640 220" role="img" aria-label="시간 막대 vs 거리 막대">
<!-- time bar -->
<text x="40" y="42" font-size="13" font-weight="bold" fill="#b91c1c">시간 막대 ✗</text>
<rect x="40" y="55" width="500" height="16" rx="8" fill="#e5e7eb"/>
<rect x="40" y="55" width="380" height="16" rx="8" fill="#fca5a5"/>
<circle cx="420" cy="63" r="10" fill="#dc2626"/>
<text x="200" y="95" font-size="12" fill="#b91c1c">드론이 멈춰(호버) 있어도 커서가 계속 흘러감 → 위치와 어긋남</text>
<!-- distance bar -->
<text x="40" y="150" font-size="13" font-weight="bold" fill="#166534">거리 막대 ✓</text>
<rect x="40" y="163" width="500" height="16" rx="8" fill="#e5e7eb"/>
<rect x="40" y="163" width="250" height="16" rx="8" fill="#86efac"/>
<circle cx="290" cy="171" r="10" fill="#16a34a"/>
<text x="200" y="203" font-size="12" fill="#166534">호버(정지) 중엔 커서도 멈춤 → "지금 보는 위치"와 일치</text>
</svg>
<figcaption>그림 10. 시간 막대는 멈춰도 커서가 흐르지만, 거리 막대는 움직인 만큼만 이동합니다.</figcaption>
</figure>
진행 방향(앞으로/뒤로)은 막대의 **색**(주황=정방향, 청록=역방향)으로 구분합니다.
### 3-5. 측점을 검색하면 빙글빙글 (순환 이동)
측점값을 입력하고 **Enter**를 치면 그 지점으로 이동, 또 치면 **다음 지점**, 마지막이면 **처음으로** 돌아와요.
> 🔁 **비유**: 워드의 "다음 찾기"를 반복하면 같은 단어를 차례로 돌다가 처음으로 돌아오죠.
<figure class="fig">
<svg viewBox="0 0 640 170" role="img" aria-label="측점 검색 순환">
<defs><marker id="f11a" markerWidth="9" markerHeight="9" refX="6" refY="3" orient="auto"><path d="M0,0 L6,3 L0,6 Z" fill="#b45309"/></marker></defs>
<rect x="40" y="95" width="560" height="14" rx="7" fill="#e5e7eb"/>
<g>
<circle cx="150" cy="102" r="11" fill="#f59e0b"/><text x="150" y="106" text-anchor="middle" font-size="11" font-weight="bold" fill="#fff">1</text><text x="120" y="135" font-size="11" fill="#7c2d12">162k080</text>
<circle cx="330" cy="102" r="11" fill="#f59e0b"/><text x="330" y="106" text-anchor="middle" font-size="11" font-weight="bold" fill="#fff">2</text><text x="300" y="135" font-size="11" fill="#7c2d12">162k080</text>
<circle cx="510" cy="102" r="11" fill="#f59e0b"/><text x="510" y="106" text-anchor="middle" font-size="11" font-weight="bold" fill="#fff">3</text><text x="480" y="135" font-size="11" fill="#7c2d12">162k080</text>
</g>
<path d="M162,90 Q240,60 318,90" fill="none" stroke="#b45309" stroke-width="1.5" marker-end="url(#f11a)"/>
<path d="M342,90 Q420,60 498,90" fill="none" stroke="#b45309" stroke-width="1.5" marker-end="url(#f11a)"/>
<path d="M510,118 Q330,165 150,118" fill="none" stroke="#b45309" stroke-width="1.5" stroke-dasharray="5 4" marker-end="url(#f11a)"/>
<text x="270" y="52" font-size="12" fill="#b45309">Enter → 다음</text>
<text x="290" y="160" font-size="12" fill="#b45309">끝나면 처음으로</text>
</svg>
<figcaption>그림 11. 같은 측점이 여러 곳이면, Enter를 누를 때마다 다음으로·끝나면 처음으로 순환합니다.</figcaption>
</figure>
### 3-6. '진짜 측점'과 '근처라서 찍힌 것' 구분 (kmExists)
어떤 역 **근처를** 지나가면 동그라미가 찍히는데, 그 자리 실제 측점값이 역의 값과 **다를 수도** 있어요.
그러면 **동그라미는 남기고, 측점값 숫자만 숨깁니다.**
> 🏷️ **비유**: "이 근처에 우체국 있음"은 표시하되, 정확한 주소가 아니면 주소 숫자는 안 붙이는 것.
---
## 4. 아주 큰 영상을 끊김 없이 트는 법
**2GB 넘는 영상**을 통째로 읽으면 컴퓨터가 멈춰요. 그래서 **보는 부분만 조금씩**(약 10MB) 잘라
받고, 또는 **몇 초짜리 조각들**로 나눠 이어 붙여 재생합니다(유튜브 방식).
<figure class="fig">
<svg viewBox="0 0 640 150" role="img" aria-label="스트리밍 조각">
<rect x="40" y="45" width="560" height="55" rx="6" fill="#eef2ff" stroke="#6366f1"/>
<g stroke="#a5b4fc" stroke-dasharray="4 3">
<line x1="120" y1="45" x2="120" y2="100"/><line x1="200" y1="45" x2="200" y2="100"/><line x1="280" y1="45" x2="280" y2="100"/><line x1="360" y1="45" x2="360" y2="100"/><line x1="440" y1="45" x2="440" y2="100"/><line x1="520" y1="45" x2="520" y2="100"/>
</g>
<rect x="280" y="45" width="80" height="55" fill="#f59e0b" opacity="0.85"/>
<text x="320" y="78" text-anchor="middle" font-size="11" font-weight="bold" fill="#fff">지금 보는</text>
<text x="320" y="118" text-anchor="middle" font-size="11" fill="#b45309">필요한 조각만 조금씩</text>
<text x="46" y="36" font-size="12" fill="#4338ca">큰 영상 (2GB+)</text>
</svg>
<figcaption>그림 12. 큰 영상을 통째로가 아니라, 보는 만큼 조각으로 잘라 받아 끊김을 막습니다.</figcaption>
</figure>
오래 보면 데이터가 쌓여 메모리가 터질 수 있어, **"지나간 30초치만 남기고 버리도록"** 해 뒀습니다.
프레임(사진 한 장) 단위로 정확히 뽑을 땐 **FFmpeg**라는 전문 도구가 그 장면을 잘라 줍니다.
---
## 5. "분명히 고쳤는데 화면이 그대로예요?" (꼭 알아둘 점)
우리가 보는 화면은 **"차린 음식(빌드 결과물)"** 을 보여줍니다. **"날재료(코드)"** 를 고쳐도
**다시 요리(빌드)** 하지 않으면 식탁은 그대로예요.
> 🍳 **비유**: 레시피를 고쳐도 다시 요리해 차리기 전엔 식탁 음식은 안 바뀝니다.
그래서 코드를 고치면 ① **다시 빌드** → ② 브라우저 **새로고침**(Ctrl+Shift+R) → ③ **폴더 다시 선택**
해야 바뀐 내용이 보입니다.
---
## 6. 이게 왜 '특별한(특허감)' 기술일까?
1. **측량 측점 높이로, 비싼 3D 지형 없이 정합** (2-4) + 두 높이 기준 자동 맞춤.
2. **한 번 끌어서 가로·세로·거리 동시 보정** (2-7) — 보통은 깊이 측정 장비가 필요한 일을 장비 없이.
3. **상황 봐가며 조절하는 흔들림 잡기** (2-6) — 부드러움과 빠른 반응을 동시에.
4. **시간이 아니라 '위치(측점)'로 영상을 다루기** (3장) — 거리 막대·검색 순환·진짜/근처 구분 등.
> ⚠️ 단, 특허는 **이미 비슷한 게 있는지(선행기술) 전문 검색**을 꼭 해봐야 확정됩니다. (아직 미완)
---
## 7. 쉬운 용어 사전
| 용어 | 쉬운 뜻 |
|------|---------|
| **측점(체이니지)** | 노선 시작점에서 몇 m 떨어졌는지 나타내는 '철길 km 표지판' |
| **위도·경도** | 지구 위 위치를 나타내는 두 숫자 |
| **yaw / pitch / roll** | 카메라가 좌우로 돈 / 아래로 숙인 / 옆으로 기운 정도 |
| **투영(projection)** | 입체(3D)를 납작한 화면(2D)에 그리는 것 (원근법) |
| **역투영** | 그 반대 — 화면의 한 점이 실제 어디인지 거꾸로 찾는 것 |
| **화각(FOV)** | 카메라가 얼마나 넓게 보는지 (광각=넓게, 줌=좁게) |
| **지오이드** | '해발고도'의 기준(평균 바닷물 높이). GPS 높이와 기준이 달라 변환 필요 |
| **DEM** | 땅의 높낮이를 담은 3D 지형 데이터(보통 비쌈) |
| **평활(스무딩)** | 떨리는 값을 주변과 평균 내어 부드럽게 만드는 것 |
| **호버** | 드론이 한자리에 떠서 멈춰 있는 상태 |
| **스테이션바** | 화면 아래의 측점(위치) 기반 진행 막대 |
| **빌드** | 코드를 컴퓨터가 실행할 형태로 '요리'하는 과정 |
| **스트리밍** | 영상을 통째로 안 받고 보는 만큼 조금씩 받아 트는 것 |
---
## 8. 마치며
> **"드론이 어디서 어느 쪽을 보는지"** 와 **"지도에 무엇이 어디 있는지"** 를 합쳐서,
> 영상 위에 정보를 정확히 붙이고, **시간이 아니라 '측점(위치)'으로** 영상을 탐색하게 해주는 도구.
복잡해 보여도, 사실은 일상의 생각들 — *친구가 가리키는 곳 알아채기, 원근법, km 표지판,
등산 앱의 걸은 거리, 떨리는 선 다듬기* — 을 컴퓨터로 정밀하게 구현한 것뿐입니다. 🙂
Binary file not shown.
@@ -0,0 +1,427 @@
<!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>쉬운설명_드론흔들림잡기와_글자붙이기</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">쉬운설명_드론흔들림잡기와_글자붙이기</h1>
</header>
<h1 id="드론-영상에-글자를-딱-붙이는-비밀-그림으로-보는-이야기">드론 영상에 글자를 &#39;&#39; 붙이는 비밀 (그림으로 보는 이야기)</h1>
<blockquote>
<p>이 글은 <strong>초등학생도 이해할 수 있게</strong> 쓴 설명서예요. 어려운 수학 대신 <strong>그림과 이야기</strong>로, 드론 영상 위에 글자를 어떻게 흔들림 없이 딱 붙이는지 알려줄게요. (더 어려운 진짜 기술 설명은 같은 폴더의 기술문서를 보세요.)</p>
</blockquote>
<style>
figure.fig{margin:20px 0;text-align:center;page-break-inside:avoid;break-inside:avoid;}
figure.fig svg{width:100%;max-width:660px;height:auto;border:1px solid #e7e0d2;border-radius:8px;background:#fffdf8;}
figure.fig figcaption{font-size:0.9em;color:#777;margin-top:6px;}
text{font-family:'Malgun Gothic','Hancom Gothic',sans-serif;}
</style>
<hr />
<h2 id="0-한-문장으로-말하면">0. 한 문장으로 말하면</h2>
<p><strong>날아다니는 드론이 찍은 영상</strong> 위에 &quot;여기는 ○○다리, 저기는 △△터널&quot; 같은 글자를 <strong>떨지 않고, 부드럽게, 딱 맞는 자리에</strong> 붙여 주는 기술 이야기예요.</p>
<blockquote>
<p>🎮 <strong>비유</strong>: 게임에서 캐릭터 머리 위에 이름표가 떠다니죠? 그 이름표가 흔들리지 않고 캐릭터를 졸졸 따라다니게 만드는 비법이라고 생각하면 돼요.</p>
</blockquote>
<hr />
<h2 id="1-무슨-문제가-있었을까">1. 무슨 문제가 있었을까?</h2>
<p>드론은 하늘에서 바람을 맞으며 날아요. 그래서 영상이 <strong>조금씩 부르르 떨려요.</strong> 그냥 글자를 붙이면, 글자도 같이 <strong>부르르 떨려서</strong> 보기 싫어요.</p>
<p>게다가 영상은 1초에 <strong>사진 30장</strong>으로 만들어지는데, 글자가 30번만 움직이면 <strong>뚝뚝 끊겨</strong> 보여요. 우리는 <strong>물 흐르듯 부드럽게</strong> 움직이길 바라죠.</p>
<figure class="fig">
<svg viewBox="0 0 660 210" role="img" aria-label="문제 두 가지">
<!-- problem 1: shaking -->
<rect x="30" y="40" width="280" height="140" rx="10" fill="#fff" stroke="#dc2626" stroke-width="1.5"></rect>
<text x="170" y="32" text-anchor="middle" font-size="14" font-weight="bold" fill="#b91c1c">문제 1. 글자가 떨려요</text>
<rect x="120" y="80" width="90" height="34" rx="6" fill="#fecaca" stroke="#dc2626"></rect>
<text x="165" y="102" text-anchor="middle" font-size="13" fill="#7f1d1d">○○다리</text>
<path d="M70,150 Q90,135 110,150 T150,150 T190,150 T230,150 T270,150" fill="none" stroke="#dc2626" stroke-width="2"></path>
<text x="170" y="172" text-anchor="middle" font-size="11" fill="#b91c1c">부르르~ 떨림</text>
<!-- problem 2: jerky -->
<rect x="350" y="40" width="280" height="140" rx="10" fill="#fff" stroke="#d97706" stroke-width="1.5"></rect>
<text x="490" y="32" text-anchor="middle" font-size="14" font-weight="bold" fill="#b45309">문제 2. 뚝뚝 끊겨요</text>
<g fill="#fde68a" stroke="#d97706">
<rect x="380" y="120" width="22" height="22"></rect><rect x="430" y="110" width="22" height="22"></rect><rect x="480" y="118" width="22" height="22"></rect><rect x="530" y="105" width="22" height="22"></rect><rect x="580" y="115" width="22" height="22"></rect>
</g>
<text x="490" y="170" text-anchor="middle" font-size="11" fill="#b45309">한 칸씩 점프 (부드럽지 않음)</text>
</svg>
<figcaption>그림 1. 떨림(왼쪽)과 끊김(오른쪽) — 이 두 가지를 동시에 해결해야 해요.</figcaption>
</figure>
<p>이 두 가지를 <strong>한꺼번에</strong> 해결하는 게 이 기술의 핵심이에요. 하나씩 볼까요?</p>
<hr />
<h2 id="2-비법-①-똑똑한-평균-내기--곧을-땐-많이-돌-땐-적게">2. 비법 ① 똑똑한 평균 내기 — &quot;곧을 땐 많이, 돌 땐 적게&quot;</h2>
<h3 id="떨림을-없애는-가장-쉬운-방법은-평균">떨림을 없애는 가장 쉬운 방법은 &#39;평균&#39;</h3>
<p>떨리는 값들을 <strong>여러 개 모아 평균</strong>을 내면 부드러워져요. 예를 들어 드론이 보는 방향이 <code>10°, 12°, 9°, 11°</code> 처럼 떨릴 때, 평균을 내면 <code>≈10.5°</code>로 매끈해지죠.</p>
<blockquote>
<p>✏️ <strong>비유</strong>: 손을 떨면서 그은 선도, 옆 점들과 평균을 내면 자를 댄 듯 매끈해져요.</p>
</blockquote>
<h3 id="그런데-함정이-있어요">그런데 함정이 있어요!</h3>
<p>드론이 <strong>방향을 휙 트는 순간</strong>까지 평균에 섞으면, 글자가 <strong>늦게 따라와요.</strong> 회전했는데 글자는 아직 직진 방향을 보고 있는 거죠. (답답!)</p>
<h3 id="그래서-똑똑한-평균을-써요">그래서 &#39;똑똑한 평균&#39;을 써요</h3>
<ul>
<li><strong>곧게 갈 때</strong> → 옆 사진을 <strong>많이</strong> (앞뒤 약 60장, 2초어치) 모아 평균 → 떨림 꽉 잡기 💪</li>
<li><strong>방향 틀 때</strong>&quot;어, 방향이 확 달라졌네?&quot; 하고 거기서 <strong>딱 멈춰서</strong> 그 부분은 평균에 안 섞어요 → 즉시 따라가기 ⚡</li>
</ul>
<p>방향 차이가 <strong>8도</strong>보다 크게 벌어지면 &quot;여기는 다른(도는) 구간이다!&quot; 하고 평균 범위를 멈춰요.</p>
<figure class="fig">
<svg viewBox="0 0 660 250" role="img" aria-label="똑똑한 평균">
<!-- straight section -->
<text x="40" y="40" font-size="13" font-weight="bold" fill="#166534">① 곧게 갈 때 = 넓게 평균 (떨림 꽉 잡기)</text>
<line x1="40" y1="75" x2="620" y2="75" stroke="#d1d5db" stroke-width="2"></line>
<g fill="#86efac" stroke="#16a34a">
<circle cx="120" cy="75" r="7"></circle><circle cx="170" cy="75" r="7"></circle><circle cx="220" cy="75" r="7"></circle><circle cx="320" cy="75" r="10"></circle><circle cx="420" cy="75" r="7"></circle><circle cx="470" cy="75" r="7"></circle><circle cx="520" cy="75" r="7"></circle>
</g>
<rect x="110" y="58" width="420" height="34" rx="17" fill="#16a34a" opacity="0.12"></rect>
<text x="320" y="108" text-anchor="middle" font-size="11" fill="#166534">가운데(큰 점) 둘레로 앞뒤 많이 모아 평균</text>
<!-- turning section -->
<text x="40" y="155" font-size="13" font-weight="bold" fill="#b45309">② 방향 틀 때 = 경계에서 멈춤 (즉시 따라가기)</text>
<polyline points="40,200 260,200 360,200 440,165 520,135 600,120" fill="none" stroke="#d1d5db" stroke-width="2"></polyline>
<g fill="#fde68a" stroke="#d97706">
<circle cx="180" cy="200" r="7"></circle><circle cx="260" cy="200" r="7"></circle><circle cx="320" cy="200" r="10"></circle><circle cx="400" cy="183" r="7"></circle><circle cx="470" cy="150" r="7"></circle>
</g>
<rect x="245" y="184" width="95" height="34" rx="17" fill="#16a34a" opacity="0.12"></rect>
<line x1="360" y1="160" x2="360" y2="215" stroke="#dc2626" stroke-width="2" stroke-dasharray="4 3"></line>
<text x="368" y="150" font-size="11" font-weight="bold" fill="#dc2626">8°↑ 꺾임! 여기서 멈춤</text>
<text x="200" y="238" text-anchor="middle" font-size="11" fill="#b45309">도는 부분은 평균에 안 섞음 → 안 늦음</text>
</svg>
<figcaption>그림 2. 곧을 땐 넓게 평균(떨림 제거), 돌 땐 꺾이는 지점에서 멈춤(지연 없음).</figcaption>
</figure>
<p>이렇게 하면 <strong>직선에서는 떨림이 사라지고, 회전에서는 늦지 않게</strong> 따라가요. 일석이조죠! 🐦🐦</p>
<hr />
<h2 id="3-비법-②-사진-사이를-채우기--30장을-60장처럼">3. 비법 ② 사진 사이를 채우기 — &quot;30장을 60장처럼&quot;</h2>
<p>영상은 1초에 사진 30장이에요. 글자를 그 30장에만 맞춰 움직이면 <strong>뚝뚝</strong> 끊겨 보여요.</p>
<p>그래서 <strong>사진과 사진 사이</strong>의 값을 <strong>계산으로 만들어</strong> 채워요. 예를 들어 10번 사진은 &quot;방향 20°&quot;, 11번 사진은 &quot;방향 24°&quot;라면, 그 <strong>딱 중간</strong>&quot;방향 22°&quot;라고 만들어 내는 거예요.</p>
<blockquote>
<p>🎨 <strong>비유</strong>: 만화 영화에서 두 그림 사이에 그림을 더 그려 넣으면 더 부드럽게 움직이죠. 그것과 똑같아요. (&quot;사이 그림 채우기&quot;)</p>
</blockquote>
<figure class="fig">
<svg viewBox="0 0 660 200" role="img" aria-label="사이 채우기 보간">
<line x1="60" y1="120" x2="600" y2="120" stroke="#d1d5db" stroke-width="2"></line>
<!-- real frames -->
<circle cx="140" cy="120" r="10" fill="#2563eb"></circle>
<text x="140" y="150" text-anchor="middle" font-size="11" fill="#1e3a8a">10번 사진</text>
<text x="140" y="100" text-anchor="middle" font-size="11" fill="#1e3a8a">20°</text>
<circle cx="520" cy="120" r="10" fill="#2563eb"></circle>
<text x="520" y="150" text-anchor="middle" font-size="11" fill="#1e3a8a">11번 사진</text>
<text x="520" y="100" text-anchor="middle" font-size="11" fill="#1e3a8a">24°</text>
<!-- interpolated -->
<g fill="#f59e0b">
<circle cx="235" cy="120" r="6"></circle><circle cx="330" cy="120" r="6"></circle><circle cx="425" cy="120" r="6"></circle>
</g>
<text x="330" y="100" text-anchor="middle" font-size="11" font-weight="bold" fill="#b45309">22° (계산으로 만든 사이 값)</text>
<text x="330" y="175" text-anchor="middle" font-size="11" fill="#b45309">↑ 빈틈을 채워 부드럽게</text>
</svg>
<figcaption>그림 3. 진짜 사진(파랑) 사이에 계산으로 만든 값(주황)을 채워 끊김을 없앱니다.</figcaption>
</figure>
<p>이렇게 사이를 채우면 글자가 <strong>물 흐르듯</strong> 부드럽게 움직여요. 화면이 60번씩 새로 그려져도 글자가 항상 <strong>딱 맞는 중간 자리</strong>에 있게 되는 거죠.</p>
<blockquote>
<p>💡 작은 비밀: 방향(각도)은 <strong>359° 다음이 0°</strong> 라서, 그냥 계산하면 글자가 한 바퀴 빙 돌아버려요. 그래서 &quot;<strong>가까운 쪽으로 돌기</strong>&quot; 규칙을 따로 넣어 뒀어요. (359°→0°은 한 칸만 돌게)</p>
</blockquote>
<hr />
<h2 id="4-비법-③-화면에서-한-번-더-다듬기--살살-vs-빨리">4. 비법 ③ 화면에서 한 번 더 다듬기 — &quot;살살 vs 빨리&quot;</h2>
<p>비법 ①, ②로 이미 많이 부드러워졌지만, 화면에서 <strong>마지막으로 한 번 더</strong> 다듬어요. 규칙은 아주 똑똑해요:</p>
<ul>
<li><strong>천천히 움직일 때(=떨림일 가능성 큼)</strong> → 글자를 <strong>살살</strong> 움직여 떨림을 죽여요. 🐢</li>
<li><strong>빨리 움직일 때(=진짜로 휙 도는 중)</strong> → 글자를 <strong>빨리</strong> 따라가게 해요. 🐇</li>
</ul>
<figure class="fig">
<svg viewBox="0 0 660 180" role="img" aria-label="속도에 따라 다르게">
<!-- slow -->
<rect x="30" y="40" width="280" height="110" rx="10" fill="#ecfdf5" stroke="#16a34a"></rect>
<text x="170" y="68" text-anchor="middle" font-size="14" font-weight="bold" fill="#166534">🐢 느리면 = 살살</text>
<text x="170" y="95" text-anchor="middle" font-size="12" fill="#166534">&quot;이건 떨림일 거야&quot;</text>
<text x="170" y="120" text-anchor="middle" font-size="12" fill="#166534">→ 조금만 움직여 떨림 제거</text>
<!-- fast -->
<rect x="350" y="40" width="280" height="110" rx="10" fill="#eff6ff" stroke="#2563eb"></rect>
<text x="490" y="68" text-anchor="middle" font-size="14" font-weight="bold" fill="#1e3a8a">🐇 빠르면 = 빨리</text>
<text x="490" y="95" text-anchor="middle" font-size="12" fill="#1e3a8a">&quot;진짜로 도는 중이야&quot;</text>
<text x="490" y="120" text-anchor="middle" font-size="12" fill="#1e3a8a">→ 곧바로 쫓아감 (안 늦음)</text>
</svg>
<figcaption>그림 4. 천천히면 떨림으로 보고 살살, 빠르면 진짜 움직임으로 보고 빨리 따라갑니다.</figcaption>
</figure>
<h3 id="깜짝-점프는-무시해요-단-너무-오래는-말고">깜짝 점프는 무시해요 (단, 너무 오래는 말고)</h3>
<p>가끔 데이터가 <strong>확 튀어서</strong> 글자가 엉뚱한 데로 점프하려 해요. 이런 건 <strong>&quot;어, 이상한데?&quot; 하고 무시</strong>해요. 하지만 영상을 <strong>확 건너뛰기(시크)</strong> 했을 땐 진짜로 멀리 가야 하니까, <strong>계속 8번 넘게</strong> 같은 점프가 들어오면 &quot;이건 진짜구나&quot; 하고 받아들여요.</p>
<blockquote>
<p>🚦 <strong>비유</strong>: 친구가 갑자기 펄쩍 뛰면 &quot;장난이지?&quot; 하고 무시하지만, 계속 그쪽으로 가면 &quot;아, 진짜 가는구나&quot; 하고 따라가는 것과 같아요.</p>
</blockquote>
<hr />
<h2 id="5-비법-④-미리-정해두기-vs-그때그때-그리기-제일-중요">5. 비법 ④ 미리 정해두기 vs 그때그때 그리기 (제일 중요!)</h2>
<p>화면은 1초에 <strong>60번</strong>이나 새로 그려져요. 매번 모든 걸 다시 계산하면 컴퓨터가 <strong>헉헉</strong>대요. 그래서 일을 <strong>두 종류로 나눴어요.</strong></p>
<table>
<thead>
<tr class="header">
<th>종류</th>
<th>무슨 일?</th>
<th>언제 하나?</th>
</tr>
</thead>
<tbody>
<tr class="odd">
<td><strong>무거운 일</strong></td>
<td>&quot;어떤 글자를 <strong>보여줄지</strong>&quot; 정하기 (가려진 것 빼기, 겹친 것 정리)</td>
<td>미리 한 번만</td>
</tr>
<tr class="even">
<td><strong>가벼운 일</strong></td>
<td>&quot;그 글자를 화면 <strong>어디에 그릴지</strong>&quot; 계산</td>
<td>매 순간(60번)</td>
</tr>
</tbody>
</table>
<blockquote>
<p>🍱 <strong>비유</strong>: 도시락을 미리 싸 두면(무거운 일), 점심시간엔 뚜껑만 열면 돼요(가벼운 일). 매번 처음부터 요리하면 점심시간이 다 가버리죠!</p>
</blockquote>
<figure class="fig">
<svg viewBox="0 0 660 220" role="img" aria-label="두 단계 분리">
<!-- heavy precompute -->
<rect x="30" y="50" width="250" height="120" rx="10" fill="#fff7ed" stroke="#ea580c" stroke-width="1.5"></rect>
<text x="155" y="42" text-anchor="middle" font-size="13" font-weight="bold" fill="#c2410c">무거운 일 (미리 한 번)</text>
<text x="155" y="85" text-anchor="middle" font-size="12" fill="#7c2d12">&quot;어떤 글자를 보여줄까?&quot;</text>
<text x="155" y="110" text-anchor="middle" font-size="11" fill="#9a3412">· 가려진 글자 빼기</text>
<text x="155" y="132" text-anchor="middle" font-size="11" fill="#9a3412">· 겹친 글자 정리</text>
<text x="155" y="154" text-anchor="middle" font-size="11" fill="#9a3412">→ 결과를 저장해 둠 🍱</text>
<!-- arrow -->
<defs><marker id="g5a" markerWidth="10" markerHeight="10" refX="7" refY="3.5" orient="auto"><path d="M0,0 L7,3.5 L0,7 Z" fill="#6b7280"></path></marker></defs>
<line x1="285" y1="110" x2="375" y2="110" stroke="#6b7280" stroke-width="2" marker-end="url(#g5a)"></line>
<!-- light raf -->
<rect x="380" y="50" width="250" height="120" rx="10" fill="#eff6ff" stroke="#2563eb" stroke-width="1.5"></rect>
<text x="505" y="42" text-anchor="middle" font-size="13" font-weight="bold" fill="#1d4ed8">가벼운 일 (매 순간 60번)</text>
<text x="505" y="85" text-anchor="middle" font-size="12" fill="#1e3a8a">&quot;저장된 글자를</text>
<text x="505" y="107" text-anchor="middle" font-size="12" fill="#1e3a8a">어디에 그릴까?&quot;</text>
<text x="505" y="135" text-anchor="middle" font-size="11" fill="#1e40af">→ 위치만 쓱 계산해서 그리기</text>
<text x="505" y="157" text-anchor="middle" font-size="11" fill="#1e40af">→ 그래서 안 끊겨요! ✨</text>
</svg>
<figcaption>그림 5. 무거운 결정은 미리, 가벼운 위치 계산만 매 순간 — 그래서 부드러운 60프레임 유지.</figcaption>
</figure>
<h3 id="더-똑똑한-점들">더 똑똑한 점들</h3>
<ul>
<li><strong>지금 보는 곳부터 미리 계산</strong> → 영상을 틀면 바로 글자가 떠요. (멀리 있는 건 천천히 준비)</li>
<li><strong>컴퓨터가 한가할 때 조금씩</strong> 준비해서, 영상이 안 끊겨요.</li>
<li>글자 위치는 <strong>진짜 땅 위 좌표</strong>로 저장해 둬요. 그래야 드론이 움직여도 그 자리에 정확히 다시 그릴 수 있죠.</li>
</ul>
<hr />
<h2 id="6-비법-⑤-글자가-겹치면-누가-이길까">6. 비법 ⑤ 글자가 겹치면 누가 이길까?</h2>
<p>화면에 글자가 많으면 서로 <strong>겹쳐서</strong> 못 읽어요. 그래서 겹치면 <strong>누구를 남길지</strong> 규칙으로 정해요.</p>
<ol type="1">
<li><strong>기차역</strong>이 가장 힘이 세요 → 역은 무조건 남겨요. 🚉</li>
<li>같은 급이면 <strong>드론에 더 가까운 것</strong>이 이겨요. (가까운 게 더 중요하니까)</li>
</ol>
<figure class="fig">
<svg viewBox="0 0 660 180" role="img" aria-label="겹침 우선순위">
<!-- before -->
<text x="160" y="35" text-anchor="middle" font-size="13" font-weight="bold" fill="#b91c1c">겹쳐서 안 보여요</text>
<rect x="80" y="60" width="100" height="30" rx="6" fill="#fde68a" stroke="#b45309" opacity="0.9"></rect>
<rect x="120" y="75" width="100" height="30" rx="6" fill="#bfdbfe" stroke="#2563eb" opacity="0.9"></rect>
<rect x="100" y="90" width="100" height="30" rx="6" fill="#fecaca" stroke="#dc2626" opacity="0.9"></rect>
<text x="150" y="150" text-anchor="middle" font-size="11" fill="#777">셋이 겹침 😵</text>
<!-- arrow -->
<defs><marker id="g6a" markerWidth="10" markerHeight="10" refX="7" refY="3.5" orient="auto"><path d="M0,0 L7,3.5 L0,7 Z" fill="#16a34a"></path></marker></defs>
<line x1="300" y1="90" x2="380" y2="90" stroke="#16a34a" stroke-width="2.5" marker-end="url(#g6a)"></line>
<!-- after -->
<text x="510" y="35" text-anchor="middle" font-size="13" font-weight="bold" fill="#166534">중요한 것만 남겨요</text>
<rect x="450" y="78" width="120" height="34" rx="6" fill="#fde68a" stroke="#b45309"></rect>
<text x="510" y="100" text-anchor="middle" font-size="13" fill="#7c2d12">🚉 ○○역</text>
<text x="510" y="150" text-anchor="middle" font-size="11" fill="#166534">역이 최우선! 깔끔 😊</text>
</svg>
<figcaption>그림 6. 겹치면 기차역 먼저, 그다음 가까운 것 순으로 남겨 화면을 깔끔하게.</figcaption>
</figure>
<h3 id="위아래-선로-형제-구분-똑똑">위·아래 선로 형제 구분 (똑똑!)</h3>
<p>같은 다리가 <strong>위로 가는 선로용(상)</strong><strong>아래로 가는 선로용(하)</strong> 두 개로 있을 때가 있어요. 드론이 지금 <strong>위로 가는 선로</strong>를 따라가면 <code>○○다리(상)</code> 을, 아래면 <code>○○다리(하)</code> 를 보여줘요. <strong>드론이 진짜 지나는 쪽</strong>의 이름표를 골라주는 거죠.</p>
<blockquote>
<p>🚂 <strong>비유</strong>: 상행선·하행선 플랫폼이 따로 있을 때, 내가 탄 기차 쪽 안내판을 보여주는 것과 같아요.</p>
</blockquote>
<hr />
<h2 id="7-보너스--빙글빙글-나침반">7. 보너스 — 빙글빙글 나침반</h2>
<p>화면 구석엔 <strong>나침반</strong>이 있어요. 드론이 도는 대로 같이 돌아서 &quot;지금 어느 쪽을 보는지&quot; 알려줘요.</p>
<p>큰 글자들은 <strong>떨림 제거를 많이</strong> 해서 살짝 느긋한데, 나침반은 <strong>방향을 바로바로</strong> 보여줘야 하니까 <strong>떨림 제거를 살짝만</strong> 해서 회전에 <strong>재빠르게</strong> 반응하게 만들었어요.</p>
<p>그리고 나침반이 359°에서 0°로 갈 때 <strong>휙 한 바퀴 안 돌고</strong> 가까운 쪽으로만 살짝 돌게 해 뒀어요.</p>
<figure class="fig">
<svg viewBox="0 0 660 170" role="img" aria-label="나침반">
<circle cx="120" cy="85" r="55" fill="#0b1020" stroke="#475569" stroke-width="2"></circle>
<text x="120" y="42" text-anchor="middle" font-size="12" fill="#f87171" font-weight="bold">N</text>
<text x="120" y="138" text-anchor="middle" font-size="11" fill="#cbd5e1">S</text>
<text x="68" y="90" text-anchor="middle" font-size="11" fill="#cbd5e1">W</text>
<text x="172" y="90" text-anchor="middle" font-size="11" fill="#cbd5e1">E</text>
<polygon points="120,55 112,90 128,90" fill="#f87171"></polygon>
<polygon points="120,115 112,90 128,90" fill="#cbd5e1"></polygon>
<text x="320" y="70" font-size="13" fill="#334155">드론이 도는 대로 나침반도 빙글~ 돌아요.</text>
<text x="320" y="98" font-size="13" fill="#334155">큰 글자보다 <tspan font-weight="bold" fill="#1d4ed8">더 빠릿하게</tspan> 반응하도록</text>
<text x="320" y="124" font-size="13" fill="#334155">떨림 제거를 살짝만 해 뒀어요.</text>
</svg>
<figcaption>그림 7. 나침반은 회전에 빠르게 반응하도록 일부러 가볍게 다듬습니다.</figcaption>
</figure>
<hr />
<h2 id="8-전체를-한-그림으로-정리">8. 전체를 한 그림으로 정리</h2>
<figure class="fig">
<svg viewBox="0 0 660 300" role="img" aria-label="전체 흐름">
<defs><marker id="g8a" markerWidth="10" markerHeight="10" refX="7" refY="3.5" orient="auto"><path d="M0,0 L7,3.5 L0,7 Z" fill="#b45309"></path></marker></defs>
<!-- step 1 -->
<rect x="30" y="30" width="600" height="44" rx="8" fill="#ecfdf5" stroke="#16a34a"></rect>
<text x="330" y="58" text-anchor="middle" font-size="13" fill="#166534"><tspan font-weight="bold">① 똑똑한 평균</tspan> — 곧을 땐 많이, 돌 땐 멈춰서 (떨림 제거 + 안 늦음)</text>
<line x1="330" y1="74" x2="330" y2="92" stroke="#b45309" stroke-width="2" marker-end="url(#g8a)"></line>
<!-- step 2 -->
<rect x="30" y="95" width="600" height="44" rx="8" fill="#eff6ff" stroke="#2563eb"></rect>
<text x="330" y="123" text-anchor="middle" font-size="13" fill="#1e3a8a"><tspan font-weight="bold">② 사이 채우기</tspan> — 30장을 60장처럼 빈틈 메우기 (부드럽게)</text>
<line x1="330" y1="139" x2="330" y2="157" stroke="#b45309" stroke-width="2" marker-end="url(#g8a)"></line>
<!-- step 3 -->
<rect x="30" y="160" width="600" height="44" rx="8" fill="#fefce8" stroke="#ca8a04"></rect>
<text x="330" y="188" text-anchor="middle" font-size="13" fill="#854d0e"><tspan font-weight="bold">③ 화면에서 다듬기</tspan> — 느리면 살살, 빠르면 빨리 + 튀는 값 무시</text>
<line x1="330" y1="204" x2="330" y2="222" stroke="#b45309" stroke-width="2" marker-end="url(#g8a)"></line>
<!-- step 4 -->
<rect x="30" y="225" width="600" height="44" rx="8" fill="#fff7ed" stroke="#ea580c"></rect>
<text x="330" y="253" text-anchor="middle" font-size="13" fill="#9a3412"><tspan font-weight="bold">④ 미리 정하고 + 매 순간 그리기</tspan> — 무거운 건 미리, 위치만 60번 (안 끊김)</text>
</svg>
<figcaption>그림 8. 네 가지 비법이 차례로 작동해, 떨림 없이 부드러운 글자 붙이기가 완성됩니다.</figcaption>
</figure>
<hr />
<h2 id="9-왜-이게-대단할까-특허감인-이유">9. 왜 이게 대단할까? (특허감인 이유)</h2>
<ol type="1">
<li><strong>곧을 땐 많이, 돌 땐 멈추는 똑똑한 평균</strong> — 보통은 &quot;부드러움&quot;&quot;빠른 반응&quot; 중 하나만 얻는데, 이건 <strong>둘 다</strong> 얻어요. (회전 끝나고 한 번 튀는 문제도 없앴어요.)</li>
<li><strong>미리 정하기 + 매 순간 위치만 그리기</strong> — 무거운 일과 가벼운 일을 나눠서, 느린 컴퓨터에서도 <strong>부드러운 60프레임</strong>을 만들어요.</li>
<li><strong>30장을 60장처럼 사이 채우기 + 가까운 쪽으로만 돌기</strong> — 끊김 없이, 방향도 안 헷갈리게.</li>
<li><strong>느리면 살살·빠르면 빨리 + 튀는 값 무시</strong> — 떨림과 진짜 움직임을 구별해요.</li>
<li><strong>위·아래 선로 형제 이름표 골라주기</strong> — 드론이 진짜 지나는 쪽 이름을 보여줘요.</li>
</ol>
<blockquote>
<p>⚠️ 단, 특허는 <strong>비슷한 게 이미 있는지</strong> 전문가가 꼭 찾아봐야 확정돼요.</p>
</blockquote>
<hr />
<h2 id="10-쉬운-용어-사전">10. 쉬운 용어 사전</h2>
<table>
<thead>
<tr class="header">
<th>어려운 말</th>
<th>쉬운 뜻</th>
</tr>
</thead>
<tbody>
<tr class="odd">
<td><strong>평활(스무딩)</strong></td>
<td>떨리는 값을 주변과 평균 내어 부드럽게 만드는 것</td>
</tr>
<tr class="even">
<td><strong>보간</strong></td>
<td>사진과 사진 <strong>사이 값을 계산으로 채우는</strong> 것 (사이 그림 그리기)</td>
</tr>
<tr class="odd">
<td><strong>yaw(요)</strong></td>
<td>드론이 <strong>좌우로 돈 방향</strong> (어느 쪽을 보나)</td>
</tr>
<tr class="even">
<td><strong>프레임</strong></td>
<td>영상을 이루는 <strong>사진 한 장</strong> (1초에 보통 30장)</td>
</tr>
<tr class="odd">
<td><strong>RAF (매 순간 그리기)</strong></td>
<td>화면을 1초에 약 60번 다시 그리는 것</td>
</tr>
<tr class="even">
<td><strong>사전계산(미리 정하기)</strong></td>
<td>무거운 결정을 <strong>미리 한 번</strong> 해서 저장해 두는 것</td>
</tr>
<tr class="odd">
<td><strong>이상치 무시</strong></td>
<td>갑자기 확 튀는 <strong>이상한 값을 버리는</strong></td>
</tr>
<tr class="even">
<td><strong>나침반(heading-up)</strong></td>
<td>드론이 보는 방향에 맞춰 도는 방향 표시</td>
</tr>
</tbody>
</table>
<hr />
<h2 id="11-마치며">11. 마치며</h2>
<blockquote>
<p>드론은 바람에 흔들리고, 영상은 사진 30장으로 뚝뚝 끊겨요. 그런데도 글자가 <strong>떨지 않고, 부드럽게, 딱 맞는 자리</strong>에 붙는 건 <strong>① 똑똑한 평균 ② 사이 채우기 ③ 화면 다듬기 ④ 미리 정하고 그리기</strong> — 이 네 가지 비법이 함께 일하기 때문이에요.</p>
</blockquote>
<p>어려워 보여도, 사실은 <strong>&quot;떨리는 선 매끈하게 다듬기, 만화 사이 그림 그리기, 도시락 미리 싸기&quot;</strong> 같은 <strong>일상의 아이디어</strong>를 컴퓨터로 정밀하게 만든 것뿐이랍니다. 🙂</p>
</body>
</html>
@@ -0,0 +1,352 @@
# 드론 영상에 글자를 '딱' 붙이는 비밀 (그림으로 보는 이야기)
> 이 글은 **초등학생도 이해할 수 있게** 쓴 설명서예요.
> 어려운 수학 대신 **그림과 이야기**로, 드론 영상 위에 글자를 어떻게 흔들림 없이 딱 붙이는지 알려줄게요.
> (더 어려운 진짜 기술 설명은 같은 폴더의 기술문서를 보세요.)
<style>
figure.fig{margin:20px 0;text-align:center;page-break-inside:avoid;break-inside:avoid;}
figure.fig svg{width:100%;max-width:660px;height:auto;border:1px solid #e7e0d2;border-radius:8px;background:#fffdf8;}
figure.fig figcaption{font-size:0.9em;color:#777;margin-top:6px;}
text{font-family:'Malgun Gothic','Hancom Gothic',sans-serif;}
</style>
---
## 0. 한 문장으로 말하면
**날아다니는 드론이 찍은 영상** 위에 "여기는 ○○다리, 저기는 △△터널" 같은 글자를
**떨지 않고, 부드럽게, 딱 맞는 자리에** 붙여 주는 기술 이야기예요.
> 🎮 **비유**: 게임에서 캐릭터 머리 위에 이름표가 떠다니죠? 그 이름표가 흔들리지 않고
> 캐릭터를 졸졸 따라다니게 만드는 비법이라고 생각하면 돼요.
---
## 1. 무슨 문제가 있었을까?
드론은 하늘에서 바람을 맞으며 날아요. 그래서 영상이 **조금씩 부르르 떨려요.**
그냥 글자를 붙이면, 글자도 같이 **부르르 떨려서** 보기 싫어요.
게다가 영상은 1초에 **사진 30장**으로 만들어지는데, 글자가 30번만 움직이면
**뚝뚝 끊겨** 보여요. 우리는 **물 흐르듯 부드럽게** 움직이길 바라죠.
<figure class="fig">
<svg viewBox="0 0 660 210" role="img" aria-label="문제 두 가지">
<!-- problem 1: shaking -->
<rect x="30" y="40" width="280" height="140" rx="10" fill="#fff" stroke="#dc2626" stroke-width="1.5"/>
<text x="170" y="32" text-anchor="middle" font-size="14" font-weight="bold" fill="#b91c1c">문제 1. 글자가 떨려요</text>
<rect x="120" y="80" width="90" height="34" rx="6" fill="#fecaca" stroke="#dc2626"/>
<text x="165" y="102" text-anchor="middle" font-size="13" fill="#7f1d1d">○○다리</text>
<path d="M70,150 Q90,135 110,150 T150,150 T190,150 T230,150 T270,150" fill="none" stroke="#dc2626" stroke-width="2"/>
<text x="170" y="172" text-anchor="middle" font-size="11" fill="#b91c1c">부르르~ 떨림</text>
<!-- problem 2: jerky -->
<rect x="350" y="40" width="280" height="140" rx="10" fill="#fff" stroke="#d97706" stroke-width="1.5"/>
<text x="490" y="32" text-anchor="middle" font-size="14" font-weight="bold" fill="#b45309">문제 2. 뚝뚝 끊겨요</text>
<g fill="#fde68a" stroke="#d97706">
<rect x="380" y="120" width="22" height="22"/><rect x="430" y="110" width="22" height="22"/><rect x="480" y="118" width="22" height="22"/><rect x="530" y="105" width="22" height="22"/><rect x="580" y="115" width="22" height="22"/>
</g>
<text x="490" y="170" text-anchor="middle" font-size="11" fill="#b45309">한 칸씩 점프 (부드럽지 않음)</text>
</svg>
<figcaption>그림 1. 떨림(왼쪽)과 끊김(오른쪽) — 이 두 가지를 동시에 해결해야 해요.</figcaption>
</figure>
이 두 가지를 **한꺼번에** 해결하는 게 이 기술의 핵심이에요. 하나씩 볼까요?
---
## 2. 비법 ① 똑똑한 평균 내기 — "곧을 땐 많이, 돌 땐 적게"
### 떨림을 없애는 가장 쉬운 방법은 '평균'
떨리는 값들을 **여러 개 모아 평균**을 내면 부드러워져요.
예를 들어 드론이 보는 방향이 `10°, 12°, 9°, 11°` 처럼 떨릴 때, 평균을 내면 `≈10.5°`로 매끈해지죠.
> ✏️ **비유**: 손을 떨면서 그은 선도, 옆 점들과 평균을 내면 자를 댄 듯 매끈해져요.
### 그런데 함정이 있어요!
드론이 **방향을 휙 트는 순간**까지 평균에 섞으면, 글자가 **늦게 따라와요.**
회전했는데 글자는 아직 직진 방향을 보고 있는 거죠. (답답!)
### 그래서 '똑똑한 평균'을 써요
- **곧게 갈 때** → 옆 사진을 **많이** (앞뒤 약 60장, 2초어치) 모아 평균 → 떨림 꽉 잡기 💪
- **방향 틀 때** → "어, 방향이 확 달라졌네?" 하고 거기서 **딱 멈춰서** 그 부분은 평균에 안 섞어요 → 즉시 따라가기 ⚡
방향 차이가 **8도**보다 크게 벌어지면 "여기는 다른(도는) 구간이다!" 하고 평균 범위를 멈춰요.
<figure class="fig">
<svg viewBox="0 0 660 250" role="img" aria-label="똑똑한 평균">
<!-- straight section -->
<text x="40" y="40" font-size="13" font-weight="bold" fill="#166534">① 곧게 갈 때 = 넓게 평균 (떨림 꽉 잡기)</text>
<line x1="40" y1="75" x2="620" y2="75" stroke="#d1d5db" stroke-width="2"/>
<g fill="#86efac" stroke="#16a34a">
<circle cx="120" cy="75" r="7"/><circle cx="170" cy="75" r="7"/><circle cx="220" cy="75" r="7"/><circle cx="320" cy="75" r="10"/><circle cx="420" cy="75" r="7"/><circle cx="470" cy="75" r="7"/><circle cx="520" cy="75" r="7"/>
</g>
<rect x="110" y="58" width="420" height="34" rx="17" fill="#16a34a" opacity="0.12"/>
<text x="320" y="108" text-anchor="middle" font-size="11" fill="#166534">가운데(큰 점) 둘레로 앞뒤 많이 모아 평균</text>
<!-- turning section -->
<text x="40" y="155" font-size="13" font-weight="bold" fill="#b45309">② 방향 틀 때 = 경계에서 멈춤 (즉시 따라가기)</text>
<polyline points="40,200 260,200 360,200 440,165 520,135 600,120" fill="none" stroke="#d1d5db" stroke-width="2"/>
<g fill="#fde68a" stroke="#d97706">
<circle cx="180" cy="200" r="7"/><circle cx="260" cy="200" r="7"/><circle cx="320" cy="200" r="10"/><circle cx="400" cy="183" r="7"/><circle cx="470" cy="150" r="7"/>
</g>
<rect x="245" y="184" width="95" height="34" rx="17" fill="#16a34a" opacity="0.12"/>
<line x1="360" y1="160" x2="360" y2="215" stroke="#dc2626" stroke-width="2" stroke-dasharray="4 3"/>
<text x="368" y="150" font-size="11" font-weight="bold" fill="#dc2626">8°↑ 꺾임! 여기서 멈춤</text>
<text x="200" y="238" text-anchor="middle" font-size="11" fill="#b45309">도는 부분은 평균에 안 섞음 → 안 늦음</text>
</svg>
<figcaption>그림 2. 곧을 땐 넓게 평균(떨림 제거), 돌 땐 꺾이는 지점에서 멈춤(지연 없음).</figcaption>
</figure>
이렇게 하면 **직선에서는 떨림이 사라지고, 회전에서는 늦지 않게** 따라가요. 일석이조죠! 🐦🐦
---
## 3. 비법 ② 사진 사이를 채우기 — "30장을 60장처럼"
영상은 1초에 사진 30장이에요. 글자를 그 30장에만 맞춰 움직이면 **뚝뚝** 끊겨 보여요.
그래서 **사진과 사진 사이**의 값을 **계산으로 만들어** 채워요.
예를 들어 10번 사진은 "방향 20°", 11번 사진은 "방향 24°"라면,
그 **딱 중간**은 "방향 22°"라고 만들어 내는 거예요.
> 🎨 **비유**: 만화 영화에서 두 그림 사이에 그림을 더 그려 넣으면 더 부드럽게 움직이죠.
> 그것과 똑같아요. ("사이 그림 채우기")
<figure class="fig">
<svg viewBox="0 0 660 200" role="img" aria-label="사이 채우기 보간">
<line x1="60" y1="120" x2="600" y2="120" stroke="#d1d5db" stroke-width="2"/>
<!-- real frames -->
<circle cx="140" cy="120" r="10" fill="#2563eb"/>
<text x="140" y="150" text-anchor="middle" font-size="11" fill="#1e3a8a">10번 사진</text>
<text x="140" y="100" text-anchor="middle" font-size="11" fill="#1e3a8a">20°</text>
<circle cx="520" cy="120" r="10" fill="#2563eb"/>
<text x="520" y="150" text-anchor="middle" font-size="11" fill="#1e3a8a">11번 사진</text>
<text x="520" y="100" text-anchor="middle" font-size="11" fill="#1e3a8a">24°</text>
<!-- interpolated -->
<g fill="#f59e0b">
<circle cx="235" cy="120" r="6"/><circle cx="330" cy="120" r="6"/><circle cx="425" cy="120" r="6"/>
</g>
<text x="330" y="100" text-anchor="middle" font-size="11" font-weight="bold" fill="#b45309">22° (계산으로 만든 사이 값)</text>
<text x="330" y="175" text-anchor="middle" font-size="11" fill="#b45309">↑ 빈틈을 채워 부드럽게</text>
</svg>
<figcaption>그림 3. 진짜 사진(파랑) 사이에 계산으로 만든 값(주황)을 채워 끊김을 없앱니다.</figcaption>
</figure>
이렇게 사이를 채우면 글자가 **물 흐르듯** 부드럽게 움직여요. 화면이 60번씩 새로 그려져도
글자가 항상 **딱 맞는 중간 자리**에 있게 되는 거죠.
> 💡 작은 비밀: 방향(각도)은 **359° 다음이 0°** 라서, 그냥 계산하면 글자가 한 바퀴 빙 돌아버려요.
> 그래서 "**가까운 쪽으로 돌기**" 규칙을 따로 넣어 뒀어요. (359°→0°은 한 칸만 돌게)
---
## 4. 비법 ③ 화면에서 한 번 더 다듬기 — "살살 vs 빨리"
비법 ①, ②로 이미 많이 부드러워졌지만, 화면에서 **마지막으로 한 번 더** 다듬어요.
규칙은 아주 똑똑해요:
- **천천히 움직일 때(=떨림일 가능성 큼)** → 글자를 **살살** 움직여 떨림을 죽여요. 🐢
- **빨리 움직일 때(=진짜로 휙 도는 중)** → 글자를 **빨리** 따라가게 해요. 🐇
<figure class="fig">
<svg viewBox="0 0 660 180" role="img" aria-label="속도에 따라 다르게">
<!-- slow -->
<rect x="30" y="40" width="280" height="110" rx="10" fill="#ecfdf5" stroke="#16a34a"/>
<text x="170" y="68" text-anchor="middle" font-size="14" font-weight="bold" fill="#166534">🐢 느리면 = 살살</text>
<text x="170" y="95" text-anchor="middle" font-size="12" fill="#166534">"이건 떨림일 거야"</text>
<text x="170" y="120" text-anchor="middle" font-size="12" fill="#166534">→ 조금만 움직여 떨림 제거</text>
<!-- fast -->
<rect x="350" y="40" width="280" height="110" rx="10" fill="#eff6ff" stroke="#2563eb"/>
<text x="490" y="68" text-anchor="middle" font-size="14" font-weight="bold" fill="#1e3a8a">🐇 빠르면 = 빨리</text>
<text x="490" y="95" text-anchor="middle" font-size="12" fill="#1e3a8a">"진짜로 도는 중이야"</text>
<text x="490" y="120" text-anchor="middle" font-size="12" fill="#1e3a8a">→ 곧바로 쫓아감 (안 늦음)</text>
</svg>
<figcaption>그림 4. 천천히면 떨림으로 보고 살살, 빠르면 진짜 움직임으로 보고 빨리 따라갑니다.</figcaption>
</figure>
### 깜짝 점프는 무시해요 (단, 너무 오래는 말고)
가끔 데이터가 **확 튀어서** 글자가 엉뚱한 데로 점프하려 해요. 이런 건 **"어, 이상한데?" 하고 무시**해요.
하지만 영상을 **확 건너뛰기(시크)** 했을 땐 진짜로 멀리 가야 하니까,
**계속 8번 넘게** 같은 점프가 들어오면 "이건 진짜구나" 하고 받아들여요.
> 🚦 **비유**: 친구가 갑자기 펄쩍 뛰면 "장난이지?" 하고 무시하지만,
> 계속 그쪽으로 가면 "아, 진짜 가는구나" 하고 따라가는 것과 같아요.
---
## 5. 비법 ④ 미리 정해두기 vs 그때그때 그리기 (제일 중요!)
화면은 1초에 **60번**이나 새로 그려져요. 매번 모든 걸 다시 계산하면 컴퓨터가 **헉헉**대요.
그래서 일을 **두 종류로 나눴어요.**
| 종류 | 무슨 일? | 언제 하나? |
|------|---------|-----------|
| **무거운 일** | "어떤 글자를 **보여줄지**" 정하기 (가려진 것 빼기, 겹친 것 정리) | 미리 한 번만 |
| **가벼운 일** | "그 글자를 화면 **어디에 그릴지**" 계산 | 매 순간(60번) |
> 🍱 **비유**: 도시락을 미리 싸 두면(무거운 일), 점심시간엔 뚜껑만 열면 돼요(가벼운 일).
> 매번 처음부터 요리하면 점심시간이 다 가버리죠!
<figure class="fig">
<svg viewBox="0 0 660 220" role="img" aria-label="두 단계 분리">
<!-- heavy precompute -->
<rect x="30" y="50" width="250" height="120" rx="10" fill="#fff7ed" stroke="#ea580c" stroke-width="1.5"/>
<text x="155" y="42" text-anchor="middle" font-size="13" font-weight="bold" fill="#c2410c">무거운 일 (미리 한 번)</text>
<text x="155" y="85" text-anchor="middle" font-size="12" fill="#7c2d12">"어떤 글자를 보여줄까?"</text>
<text x="155" y="110" text-anchor="middle" font-size="11" fill="#9a3412">· 가려진 글자 빼기</text>
<text x="155" y="132" text-anchor="middle" font-size="11" fill="#9a3412">· 겹친 글자 정리</text>
<text x="155" y="154" text-anchor="middle" font-size="11" fill="#9a3412">→ 결과를 저장해 둠 🍱</text>
<!-- arrow -->
<defs><marker id="g5a" markerWidth="10" markerHeight="10" refX="7" refY="3.5" orient="auto"><path d="M0,0 L7,3.5 L0,7 Z" fill="#6b7280"/></marker></defs>
<line x1="285" y1="110" x2="375" y2="110" stroke="#6b7280" stroke-width="2" marker-end="url(#g5a)"/>
<!-- light raf -->
<rect x="380" y="50" width="250" height="120" rx="10" fill="#eff6ff" stroke="#2563eb" stroke-width="1.5"/>
<text x="505" y="42" text-anchor="middle" font-size="13" font-weight="bold" fill="#1d4ed8">가벼운 일 (매 순간 60번)</text>
<text x="505" y="85" text-anchor="middle" font-size="12" fill="#1e3a8a">"저장된 글자를</text>
<text x="505" y="107" text-anchor="middle" font-size="12" fill="#1e3a8a">어디에 그릴까?"</text>
<text x="505" y="135" text-anchor="middle" font-size="11" fill="#1e40af">→ 위치만 쓱 계산해서 그리기</text>
<text x="505" y="157" text-anchor="middle" font-size="11" fill="#1e40af">→ 그래서 안 끊겨요! ✨</text>
</svg>
<figcaption>그림 5. 무거운 결정은 미리, 가벼운 위치 계산만 매 순간 — 그래서 부드러운 60프레임 유지.</figcaption>
</figure>
### 더 똑똑한 점들
- **지금 보는 곳부터 미리 계산** → 영상을 틀면 바로 글자가 떠요. (멀리 있는 건 천천히 준비)
- **컴퓨터가 한가할 때 조금씩** 준비해서, 영상이 안 끊겨요.
- 글자 위치는 **진짜 땅 위 좌표**로 저장해 둬요. 그래야 드론이 움직여도 그 자리에 정확히 다시 그릴 수 있죠.
---
## 6. 비법 ⑤ 글자가 겹치면 누가 이길까?
화면에 글자가 많으면 서로 **겹쳐서** 못 읽어요. 그래서 겹치면 **누구를 남길지** 규칙으로 정해요.
1. **기차역**이 가장 힘이 세요 → 역은 무조건 남겨요. 🚉
2. 같은 급이면 **드론에 더 가까운 것**이 이겨요. (가까운 게 더 중요하니까)
<figure class="fig">
<svg viewBox="0 0 660 180" role="img" aria-label="겹침 우선순위">
<!-- before -->
<text x="160" y="35" text-anchor="middle" font-size="13" font-weight="bold" fill="#b91c1c">겹쳐서 안 보여요</text>
<rect x="80" y="60" width="100" height="30" rx="6" fill="#fde68a" stroke="#b45309" opacity="0.9"/>
<rect x="120" y="75" width="100" height="30" rx="6" fill="#bfdbfe" stroke="#2563eb" opacity="0.9"/>
<rect x="100" y="90" width="100" height="30" rx="6" fill="#fecaca" stroke="#dc2626" opacity="0.9"/>
<text x="150" y="150" text-anchor="middle" font-size="11" fill="#777">셋이 겹침 😵</text>
<!-- arrow -->
<defs><marker id="g6a" markerWidth="10" markerHeight="10" refX="7" refY="3.5" orient="auto"><path d="M0,0 L7,3.5 L0,7 Z" fill="#16a34a"/></marker></defs>
<line x1="300" y1="90" x2="380" y2="90" stroke="#16a34a" stroke-width="2.5" marker-end="url(#g6a)"/>
<!-- after -->
<text x="510" y="35" text-anchor="middle" font-size="13" font-weight="bold" fill="#166534">중요한 것만 남겨요</text>
<rect x="450" y="78" width="120" height="34" rx="6" fill="#fde68a" stroke="#b45309"/>
<text x="510" y="100" text-anchor="middle" font-size="13" fill="#7c2d12">🚉 ○○역</text>
<text x="510" y="150" text-anchor="middle" font-size="11" fill="#166534">역이 최우선! 깔끔 😊</text>
</svg>
<figcaption>그림 6. 겹치면 기차역 먼저, 그다음 가까운 것 순으로 남겨 화면을 깔끔하게.</figcaption>
</figure>
### 위·아래 선로 형제 구분 (똑똑!)
같은 다리가 **위로 가는 선로용(상)****아래로 가는 선로용(하)** 두 개로 있을 때가 있어요.
드론이 지금 **위로 가는 선로**를 따라가면 `○○다리(상)` 을, 아래면 `○○다리(하)` 를 보여줘요.
**드론이 진짜 지나는 쪽**의 이름표를 골라주는 거죠.
> 🚂 **비유**: 상행선·하행선 플랫폼이 따로 있을 때, 내가 탄 기차 쪽 안내판을 보여주는 것과 같아요.
---
## 7. 보너스 — 빙글빙글 나침반
화면 구석엔 **나침반**이 있어요. 드론이 도는 대로 같이 돌아서 "지금 어느 쪽을 보는지" 알려줘요.
큰 글자들은 **떨림 제거를 많이** 해서 살짝 느긋한데, 나침반은 **방향을 바로바로** 보여줘야 하니까
**떨림 제거를 살짝만** 해서 회전에 **재빠르게** 반응하게 만들었어요.
그리고 나침반이 359°에서 0°로 갈 때 **휙 한 바퀴 안 돌고** 가까운 쪽으로만 살짝 돌게 해 뒀어요.
<figure class="fig">
<svg viewBox="0 0 660 170" role="img" aria-label="나침반">
<circle cx="120" cy="85" r="55" fill="#0b1020" stroke="#475569" stroke-width="2"/>
<text x="120" y="42" text-anchor="middle" font-size="12" fill="#f87171" font-weight="bold">N</text>
<text x="120" y="138" text-anchor="middle" font-size="11" fill="#cbd5e1">S</text>
<text x="68" y="90" text-anchor="middle" font-size="11" fill="#cbd5e1">W</text>
<text x="172" y="90" text-anchor="middle" font-size="11" fill="#cbd5e1">E</text>
<polygon points="120,55 112,90 128,90" fill="#f87171"/>
<polygon points="120,115 112,90 128,90" fill="#cbd5e1"/>
<text x="320" y="70" font-size="13" fill="#334155">드론이 도는 대로 나침반도 빙글~ 돌아요.</text>
<text x="320" y="98" font-size="13" fill="#334155">큰 글자보다 <tspan font-weight="bold" fill="#1d4ed8">더 빠릿하게</tspan> 반응하도록</text>
<text x="320" y="124" font-size="13" fill="#334155">떨림 제거를 살짝만 해 뒀어요.</text>
</svg>
<figcaption>그림 7. 나침반은 회전에 빠르게 반응하도록 일부러 가볍게 다듬습니다.</figcaption>
</figure>
---
## 8. 전체를 한 그림으로 정리
<figure class="fig">
<svg viewBox="0 0 660 300" role="img" aria-label="전체 흐름">
<defs><marker id="g8a" markerWidth="10" markerHeight="10" refX="7" refY="3.5" orient="auto"><path d="M0,0 L7,3.5 L0,7 Z" fill="#b45309"/></marker></defs>
<!-- step 1 -->
<rect x="30" y="30" width="600" height="44" rx="8" fill="#ecfdf5" stroke="#16a34a"/>
<text x="330" y="58" text-anchor="middle" font-size="13" fill="#166534"><tspan font-weight="bold">① 똑똑한 평균</tspan> — 곧을 땐 많이, 돌 땐 멈춰서 (떨림 제거 + 안 늦음)</text>
<line x1="330" y1="74" x2="330" y2="92" stroke="#b45309" stroke-width="2" marker-end="url(#g8a)"/>
<!-- step 2 -->
<rect x="30" y="95" width="600" height="44" rx="8" fill="#eff6ff" stroke="#2563eb"/>
<text x="330" y="123" text-anchor="middle" font-size="13" fill="#1e3a8a"><tspan font-weight="bold">② 사이 채우기</tspan> — 30장을 60장처럼 빈틈 메우기 (부드럽게)</text>
<line x1="330" y1="139" x2="330" y2="157" stroke="#b45309" stroke-width="2" marker-end="url(#g8a)"/>
<!-- step 3 -->
<rect x="30" y="160" width="600" height="44" rx="8" fill="#fefce8" stroke="#ca8a04"/>
<text x="330" y="188" text-anchor="middle" font-size="13" fill="#854d0e"><tspan font-weight="bold">③ 화면에서 다듬기</tspan> — 느리면 살살, 빠르면 빨리 + 튀는 값 무시</text>
<line x1="330" y1="204" x2="330" y2="222" stroke="#b45309" stroke-width="2" marker-end="url(#g8a)"/>
<!-- step 4 -->
<rect x="30" y="225" width="600" height="44" rx="8" fill="#fff7ed" stroke="#ea580c"/>
<text x="330" y="253" text-anchor="middle" font-size="13" fill="#9a3412"><tspan font-weight="bold">④ 미리 정하고 + 매 순간 그리기</tspan> — 무거운 건 미리, 위치만 60번 (안 끊김)</text>
</svg>
<figcaption>그림 8. 네 가지 비법이 차례로 작동해, 떨림 없이 부드러운 글자 붙이기가 완성됩니다.</figcaption>
</figure>
---
## 9. 왜 이게 대단할까? (특허감인 이유)
1. **곧을 땐 많이, 돌 땐 멈추는 똑똑한 평균** — 보통은 "부드러움"과 "빠른 반응" 중 하나만 얻는데,
이건 **둘 다** 얻어요. (회전 끝나고 한 번 튀는 문제도 없앴어요.)
2. **미리 정하기 + 매 순간 위치만 그리기** — 무거운 일과 가벼운 일을 나눠서,
느린 컴퓨터에서도 **부드러운 60프레임**을 만들어요.
3. **30장을 60장처럼 사이 채우기 + 가까운 쪽으로만 돌기** — 끊김 없이, 방향도 안 헷갈리게.
4. **느리면 살살·빠르면 빨리 + 튀는 값 무시** — 떨림과 진짜 움직임을 구별해요.
5. **위·아래 선로 형제 이름표 골라주기** — 드론이 진짜 지나는 쪽 이름을 보여줘요.
> ⚠️ 단, 특허는 **비슷한 게 이미 있는지** 전문가가 꼭 찾아봐야 확정돼요.
---
## 10. 쉬운 용어 사전
| 어려운 말 | 쉬운 뜻 |
|----------|---------|
| **평활(스무딩)** | 떨리는 값을 주변과 평균 내어 부드럽게 만드는 것 |
| **보간** | 사진과 사진 **사이 값을 계산으로 채우는** 것 (사이 그림 그리기) |
| **yaw(요)** | 드론이 **좌우로 돈 방향** (어느 쪽을 보나) |
| **프레임** | 영상을 이루는 **사진 한 장** (1초에 보통 30장) |
| **RAF (매 순간 그리기)** | 화면을 1초에 약 60번 다시 그리는 것 |
| **사전계산(미리 정하기)** | 무거운 결정을 **미리 한 번** 해서 저장해 두는 것 |
| **이상치 무시** | 갑자기 확 튀는 **이상한 값을 버리는** 것 |
| **나침반(heading-up)** | 드론이 보는 방향에 맞춰 도는 방향 표시 |
---
## 11. 마치며
> 드론은 바람에 흔들리고, 영상은 사진 30장으로 뚝뚝 끊겨요.
> 그런데도 글자가 **떨지 않고, 부드럽게, 딱 맞는 자리**에 붙는 건
> **① 똑똑한 평균 ② 사이 채우기 ③ 화면 다듬기 ④ 미리 정하고 그리기** —
> 이 네 가지 비법이 함께 일하기 때문이에요.
어려워 보여도, 사실은 **"떨리는 선 매끈하게 다듬기, 만화 사이 그림 그리기, 도시락 미리 싸기"** 같은
**일상의 아이디어**를 컴퓨터로 정밀하게 만든 것뿐이랍니다. 🙂
@@ -0,0 +1,656 @@
<!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>쉬운설명_지도POI를_영상에_붙이는기술</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">쉬운설명_지도POI를_영상에_붙이는기술</h1>
</header>
<h1 id="지도-위의-poi를-영상에-딱-붙이는-기술들-그림-설명서">지도 위의 POI를 영상에 &#39;&#39; 붙이는 기술들 (그림 설명서)</h1>
<blockquote>
<p>이 글은 <strong>초등학생도 이해할 수 있게</strong> 쓴 설명서예요. &quot;지도에 적힌 다리·터널·역의 위치(POI)를, 드론 영상의 <strong>정확한 화면 자리</strong>에 어떻게 붙이는가?&quot; 그 비밀을 <strong>그림과 이야기</strong>로 하나씩 풀어 줄게요. (진짜 수학 공식은 같은 폴더의 기술문서 <a href="../client/src/utils/geoProjection.ts">geoProjection.ts</a>를 보세요.)</p>
</blockquote>
<style>
figure.fig{margin:20px 0;text-align:center;page-break-inside:avoid;break-inside:avoid;}
figure.fig svg{width:100%;max-width:680px;height:auto;border:1px solid #e7e0d2;border-radius:8px;background:#fffdf8;}
figure.fig figcaption{font-size:0.9em;color:#777;margin-top:6px;}
text{font-family:'Malgun Gothic','Hancom Gothic',sans-serif;}
</style>
<hr />
<h2 id="0-한-문장으로-말하면">0. 한 문장으로 말하면</h2>
<p><strong>&quot;지도에서는 ○○다리가 여기 있어요&quot;</strong> 라는 위치 정보(POI)를 받아서, <strong>&quot;그럼 드론 영상 화면에서는 바로 이 자리에 보이겠네!&quot;</strong> 하고 글자를 딱 붙여 주는 기술이에요.</p>
<blockquote>
<p>🎯 <strong>비유</strong>: 보물지도에 &quot;보물은 큰 나무 옆&quot;이라고 적혀 있을 때, 내가 지금 서 있는 자리와 보는 방향을 알면 &quot;저 나무가 내 눈앞 어디쯤 보일지&quot; 알 수 있죠. 그걸 컴퓨터가 하는 거예요.</p>
</blockquote>
<hr />
<h2 id="1-큰-그림-먼저--poi가-화면에-붙기까지-여행">1. 큰 그림 먼저 — POI가 화면에 붙기까지 여행</h2>
<p>POI 하나가 화면에 붙으려면 <strong>여러 단계의 변신</strong>을 거쳐요. 마치 공장의 컨베이어 벨트처럼요.</p>
<figure class="fig">
<svg viewBox="0 0 680 280" role="img" aria-label="POI 매핑 전체 흐름">
<defs><marker id="p1a" markerWidth="10" markerHeight="10" refX="7" refY="3.5" orient="auto"><path d="M0,0 L7,3.5 L0,7 Z" fill="#b45309"></path></marker></defs>
<rect x="30" y="30" width="180" height="50" rx="8" fill="#ecfdf5" stroke="#16a34a"></rect>
<text x="120" y="52" text-anchor="middle" font-size="13" font-weight="bold" fill="#166534">① 지도 위치</text>
<text x="120" y="70" text-anchor="middle" font-size="11" fill="#166534">위도·경도 (각도)</text>
<rect x="250" y="30" width="180" height="50" rx="8" fill="#eff6ff" stroke="#2563eb"></rect>
<text x="340" y="52" text-anchor="middle" font-size="13" font-weight="bold" fill="#1e3a8a">② 미터 좌표</text>
<text x="340" y="70" text-anchor="middle" font-size="11" fill="#1e3a8a">동 몇 m, 북 몇 m</text>
<rect x="470" y="30" width="180" height="50" rx="8" fill="#fefce8" stroke="#ca8a04"></rect>
<text x="560" y="52" text-anchor="middle" font-size="13" font-weight="bold" fill="#854d0e">③ 높이 맞추기</text>
<text x="560" y="70" text-anchor="middle" font-size="11" fill="#854d0e">지오이드 보정</text>
<rect x="470" y="115" width="180" height="50" rx="8" fill="#fff7ed" stroke="#ea580c"></rect>
<text x="560" y="137" text-anchor="middle" font-size="13" font-weight="bold" fill="#9a3412">④ 드론 기준 위치</text>
<text x="560" y="155" text-anchor="middle" font-size="11" fill="#9a3412">드론에서 본 방향</text>
<rect x="250" y="115" width="180" height="50" rx="8" fill="#fdf2f8" stroke="#db2777"></rect>
<text x="340" y="137" text-anchor="middle" font-size="13" font-weight="bold" fill="#9d174d">⑤ 카메라 눈 기준</text>
<text x="340" y="155" text-anchor="middle" font-size="11" fill="#9d174d">자세(회전) 적용</text>
<rect x="30" y="115" width="180" height="50" rx="8" fill="#f5f3ff" stroke="#7c3aed"></rect>
<text x="120" y="137" text-anchor="middle" font-size="13" font-weight="bold" fill="#5b21b6">⑥ 화면 평면에 투영</text>
<text x="120" y="155" text-anchor="middle" font-size="11" fill="#5b21b6">바늘구멍 사진기</text>
<rect x="250" y="205" width="180" height="50" rx="8" fill="#fff" stroke="#b45309" stroke-width="2"></rect>
<text x="340" y="227" text-anchor="middle" font-size="13" font-weight="bold" fill="#23272e">⑦ 화면에 글자!</text>
<text x="340" y="245" text-anchor="middle" font-size="11" fill="#555">○○다리 ✨</text>
<!-- arrows -->
<line x1="210" y1="55" x2="246" y2="55" stroke="#b45309" stroke-width="1.5" marker-end="url(#p1a)"></line>
<line x1="430" y1="55" x2="466" y2="55" stroke="#b45309" stroke-width="1.5" marker-end="url(#p1a)"></line>
<line x1="560" y1="80" x2="560" y2="111" stroke="#b45309" stroke-width="1.5" marker-end="url(#p1a)"></line>
<line x1="470" y1="140" x2="434" y2="140" stroke="#b45309" stroke-width="1.5" marker-end="url(#p1a)"></line>
<line x1="250" y1="140" x2="214" y2="140" stroke="#b45309" stroke-width="1.5" marker-end="url(#p1a)"></line>
<line x1="120" y1="165" x2="120" y2="195" stroke="#b45309" stroke-width="1.5"></line>
<line x1="120" y1="195" x2="250" y2="225" stroke="#b45309" stroke-width="1.5" marker-end="url(#p1a)"></line>
</svg>
<figcaption>그림 1. POI는 ①지도좌표 → ②미터 → ③높이맞춤 → ④드론기준 → ⑤카메라기준 → ⑥화면투영 → ⑦글자 순으로 변신합니다.</figcaption>
</figure>
<hr />
<h2 id="2-사용한-기술-한눈에-보기">2. 사용한 기술 한눈에 보기</h2>
<table>
<thead>
<tr class="header">
<th>번호</th>
<th>기술 이름</th>
<th>쉽게 말하면</th>
<th>자세히</th>
</tr>
</thead>
<tbody>
<tr class="odd">
<td></td>
<td><strong>좌표계 변환 (proj4)</strong></td>
<td>위도·경도(각도)를 &#39;미터&#39;로 바꾸기</td>
<td>3장</td>
</tr>
<tr class="even">
<td></td>
<td><strong>ENU 월드 좌표</strong></td>
<td>&quot;기준점에서 동·북·위로 몇 m&quot;</td>
<td>4장</td>
</tr>
<tr class="odd">
<td></td>
<td><strong>지오이드 높이 보정</strong></td>
<td>두 가지 높이 기준을 맞추기</td>
<td>5장</td>
</tr>
<tr class="even">
<td></td>
<td><strong>자세 회전 행렬 (yaw/pitch/roll)</strong></td>
<td>드론이 어느 쪽을 보는지 계산</td>
<td>6장</td>
</tr>
<tr class="odd">
<td></td>
<td><strong>상대 위치 벡터</strong></td>
<td>드론에서 POI까지의 방향·거리</td>
<td>7장</td>
</tr>
<tr class="even">
<td></td>
<td><strong>핀홀 카메라 원근투영</strong></td>
<td>3D 세상을 납작한 화면에 그리기</td>
<td>8장</td>
</tr>
<tr class="odd">
<td></td>
<td><strong>화각(초점거리·센서)</strong></td>
<td>&quot;얼마나 넓게 보나&quot; 맞추기</td>
<td>9장</td>
</tr>
<tr class="even">
<td></td>
<td><strong>카메라 뒤 지우기(클리핑)</strong></td>
<td>뒤에 있는 건 안 그리기</td>
<td>10장</td>
</tr>
<tr class="odd">
<td></td>
<td><strong>POI 높이 추측</strong></td>
<td>실제 높이 없을 때 똑똑하게 짐작</td>
<td>11장</td>
</tr>
<tr class="even">
<td></td>
<td><strong>거리 필터</strong></td>
<td>너무 먼 건 숨기기</td>
<td>12장</td>
</tr>
<tr class="odd">
<td></td>
<td><strong>역투영 보정</strong></td>
<td>손으로 끌어 거꾸로 고치기</td>
<td>13장</td>
</tr>
<tr class="even">
<td></td>
<td><strong>화면 크롭 맞춤(cover)</strong></td>
<td>영상 잘림에 글자도 맞추기</td>
<td>14장</td>
</tr>
</tbody>
</table>
<p>이제 하나씩 그림으로 볼게요!</p>
<hr />
<h2 id="3-기술-①--위도경도를-미터로-바꾸기-proj4">3. 기술 ① — 위도·경도를 &#39;미터&#39;로 바꾸기 (proj4)</h2>
<p>지도 위치는 보통 <strong>위도·경도</strong> (예: 북위 36.3°, 동경 127.4°)로 말해요. 그런데 이건 <strong>각도</strong>라서 &quot;두 곳이 몇 m 떨어졌나?&quot; 같은 계산이 어려워요.</p>
<p>그래서 <strong>&quot;기준점에서 동쪽 몇 m, 북쪽 몇 m&quot;</strong> 같은 <strong>미터(m)</strong> 좌표로 바꿔요. 우리나라 전용 지도 격자인 <strong>EPSG:5186 (한국 TM)</strong> 을 써서 바꿔요. 이 변신은 <code>proj4</code>라는 도구가 해 줍니다.</p>
<blockquote>
<p>📏 <strong>비유</strong>: &quot;동경 127.4도&quot;라고 하면 와닿지 않지만, &quot;학교에서 동쪽으로 500m&quot;라고 하면 바로 이해되죠.</p>
</blockquote>
<figure class="fig">
<svg viewBox="0 0 680 200" role="img" aria-label="위경도를 미터로">
<defs><marker id="p3a" markerWidth="10" markerHeight="10" refX="7" refY="3.5" orient="auto"><path d="M0,0 L7,3.5 L0,7 Z" fill="#16a34a"></path></marker></defs>
<!-- globe -->
<circle cx="120" cy="100" r="60" fill="#dbeafe" stroke="#2563eb"></circle>
<ellipse cx="120" cy="100" rx="60" ry="22" fill="none" stroke="#93c5fd"></ellipse>
<ellipse cx="120" cy="100" rx="22" ry="60" fill="none" stroke="#93c5fd"></ellipse>
<circle cx="138" cy="78" r="5" fill="#dc2626"></circle>
<text x="120" y="180" text-anchor="middle" font-size="12" fill="#1e3a8a">위도·경도 (각도)</text>
<text x="120" y="40" text-anchor="middle" font-size="11" fill="#b91c1c">36.3°, 127.4°</text>
<!-- arrow -->
<line x1="195" y1="100" x2="270" y2="100" stroke="#16a34a" stroke-width="2.5" marker-end="url(#p3a)"></line>
<text x="232" y="90" text-anchor="middle" font-size="11" fill="#166534">proj4</text>
<text x="232" y="118" text-anchor="middle" font-size="10" fill="#166534">한국 TM</text>
<!-- grid -->
<g stroke="#e5e7eb"><line x1="320" y1="40" x2="320" y2="170"></line><line x1="380" y1="40" x2="380" y2="170"></line><line x1="440" y1="40" x2="440" y2="170"></line><line x1="500" y1="40" x2="500" y2="170"></line><line x1="560" y1="40" x2="560" y2="170"></line>
<line x1="300" y1="60" x2="620" y2="60"></line><line x1="300" y1="100" x2="620" y2="100"></line><line x1="300" y1="140" x2="620" y2="140"></line></g>
<line x1="320" y1="170" x2="620" y2="170" stroke="#6b7280" stroke-width="2"></line>
<line x1="320" y1="170" x2="320" y2="40" stroke="#6b7280" stroke-width="2"></line>
<text x="610" y="188" font-size="10" fill="#6b7280">동(m)→</text>
<text x="300" y="50" font-size="10" fill="#6b7280">북(m)↑</text>
<circle cx="500" cy="80" r="6" fill="#16a34a"></circle>
<text x="508" y="76" font-size="11" font-weight="bold" fill="#166534">동 540m, 북 320m</text>
</svg>
<figcaption>그림 2. 각도(위경도)를 우리나라 전용 미터 격자(한국 TM)로 바꿔 계산하기 쉽게 만듭니다.</figcaption>
</figure>
<hr />
<h2 id="4-기술-②--동북위로-몇-m-3d-좌표-enu">4. 기술 ② — &quot;동·북·위로 몇 m&quot; 3D 좌표 (ENU)</h2>
<p>이제 위치를 <strong>세 개의 숫자</strong>로 나타내요. 기준점에서:</p>
<ul>
<li><strong>E</strong>ast (동쪽으로 몇 m)</li>
<li><strong>N</strong>orth (북쪽으로 몇 m)</li>
<li><strong>U</strong>p (위로 몇 m = 높이)</li>
</ul>
<p>이 세 글자를 따서 <strong>ENU</strong>라고 불러요. 이렇게 하면 지구 위 모든 위치를 <strong>3D 공간의 한 점</strong>으로 다룰 수 있어요.</p>
<blockquote>
<p>🧊 <strong>비유</strong>: 교실에서 물건 위치를 &quot;칠판에서 오른쪽 2m, 앞으로 3m, 바닥에서 1m 높이&quot;라고 말하는 것과 같아요.</p>
</blockquote>
<figure class="fig">
<svg viewBox="0 0 680 220" role="img" aria-label="ENU 3D 좌표">
<defs><marker id="p4a" markerWidth="9" markerHeight="9" refX="6" refY="3" orient="auto"><path d="M0,0 L6,3 L0,6 Z" fill="#6b7280"></path></marker></defs>
<!-- origin -->
<circle cx="180" cy="160" r="6" fill="#16a34a"></circle>
<text x="150" y="180" font-size="11" fill="#14532d">기준점 (0,0,0)</text>
<!-- E axis -->
<line x1="180" y1="160" x2="560" y2="160" stroke="#6b7280" stroke-width="2" marker-end="url(#p4a)"></line>
<text x="555" y="180" font-size="12" fill="#6b7280">E 동쪽</text>
<!-- N axis (diagonal back) -->
<line x1="180" y1="160" x2="380" y2="60" stroke="#6b7280" stroke-width="2" marker-end="url(#p4a)"></line>
<text x="385" y="55" font-size="12" fill="#6b7280">N 북쪽</text>
<!-- U axis -->
<line x1="180" y1="160" x2="180" y2="40" stroke="#6b7280" stroke-width="2" marker-end="url(#p4a)"></line>
<text x="150" y="40" font-size="12" fill="#6b7280">U 위(높이)</text>
<!-- point -->
<line x1="440" y1="120" x2="440" y2="80" stroke="#f59e0b" stroke-width="1.5" stroke-dasharray="4 3"></line>
<circle cx="440" cy="80" r="7" fill="#b45309"></circle>
<text x="450" y="76" font-size="12" font-weight="bold" fill="#b45309">○○다리</text>
<text x="450" y="94" font-size="11" fill="#7c2d12">(E, N, U)</text>
</svg>
<figcaption>그림 3. 모든 위치를 &quot;동·북·위로 몇 m&quot;인 3D 점으로 나타냅니다(ENU).</figcaption>
</figure>
<hr />
<h2 id="5-기술-③--두-가지-높이-기준-맞추기-지오이드-보정">5. 기술 ③ — 두 가지 &#39;높이 기준&#39; 맞추기 (지오이드 보정)</h2>
<p>높이를 재는 기준이 <strong>두 가지</strong>라서 헷갈려요.</p>
<ul>
<li><strong>GPS 높이</strong>: 지구를 매끈한 타원이라 보고 잰 높이 (드론이 기록하는 높이)</li>
<li><strong>지도 해발고도</strong>: 평균 바닷물 높이 기준 (지도에 적힌 높이) = <strong>지오이드</strong></li>
</ul>
<p>이 둘은 지역마다 <strong>수십 m</strong> 차이 나요. (대전은 약 <strong>25.8m</strong>!) 안 맞추면 글자가 위아래로 엉뚱하게 떠요. 그래서 지도 높이에 <strong>25.8m를 더해</strong> 드론의 높이 기준과 똑같이 맞춰 줘요.</p>
<blockquote>
<p>🌊 <strong>비유</strong>: &quot;1층 바닥 기준 높이&quot;&quot;지하주차장 바닥 기준 높이&quot;는 같은 창문인데 숫자가 다르죠. 비교하려면 기준을 하나로 맞춰야 해요.</p>
</blockquote>
<figure class="fig">
<svg viewBox="0 0 680 200" role="img" aria-label="높이 기준 차이">
<defs><marker id="p5a" markerWidth="9" markerHeight="9" refX="3" refY="3" orient="auto"><path d="M0,0 L6,3 L0,6 Z" fill="#dc2626"></path></marker><marker id="p5b" markerWidth="9" markerHeight="9" refX="3" refY="3" orient="auto"><path d="M6,0 L0,3 L6,6 Z" fill="#dc2626"></path></marker></defs>
<line x1="40" y1="55" x2="640" y2="55" stroke="#2563eb" stroke-width="2" stroke-dasharray="6 4"></line>
<text x="450" y="48" font-size="12" fill="#1e3a8a">GPS 높이 기준 (드론이 기록)</text>
<line x1="40" y1="115" x2="640" y2="115" stroke="#0ea5e9" stroke-width="2" stroke-dasharray="6 4"></line>
<text x="430" y="135" font-size="12" fill="#0369a1">지도 해발 기준 (지오이드)</text>
<line x1="160" y1="56" x2="160" y2="114" stroke="#dc2626" stroke-width="1.5" marker-start="url(#p5a)" marker-end="url(#p5b)"></line>
<text x="170" y="92" font-size="12" font-weight="bold" fill="#dc2626">약 25.8m 차이</text>
<path d="M40,170 Q200,150 360,158 T640,150" fill="none" stroke="#8b5e34" stroke-width="3"></path>
<text x="46" y="188" font-size="11" fill="#7c5a3a">실제 땅</text>
<text x="400" y="175" font-size="11" fill="#b91c1c">→ 지도 높이에 25.8m 더해 기준 통일</text>
</svg>
<figcaption>그림 4. 높이 기준이 둘이라 약 25.8m 차이. 더해서 맞춰야 글자가 제 높이에 붙습니다.</figcaption>
</figure>
<hr />
<h2 id="6-기술-④--드론이-어느-쪽을-보는지-자세-회전">6. 기술 ④ — 드론이 &#39;어느 쪽을 보는지&#39; (자세 회전)</h2>
<p>드론은 가만히 있지 않아요. <strong>좌우로 돌고(yaw), 아래로 숙이고(pitch), 옆으로 기울어요(roll).</strong> 이 세 가지를 알아야 &quot;카메라가 정확히 어느 방향을 보는지&quot; 계산할 수 있어요.</p>
<p>이걸 <strong>회전 행렬</strong>이라는 수학 도구로 한 번에 계산해요. (세 방향 회전을 합치는 마법 표라고 생각하면 돼요.)</p>
<figure class="fig">
<svg viewBox="0 0 680 200" role="img" aria-label="yaw pitch roll">
<!-- yaw -->
<g><circle cx="120" cy="100" r="45" fill="none" stroke="#2563eb" stroke-width="2"></circle>
<path d="M120,55 A45,45 0 0 1 158,80" fill="none" stroke="#2563eb" stroke-width="3" marker-end="url(#p6a)"></path>
<defs><marker id="p6a" markerWidth="8" markerHeight="8" refX="4" refY="4" orient="auto"><path d="M0,0 L8,4 L0,8 Z" fill="#2563eb"></path></marker></defs>
<text x="120" y="105" text-anchor="middle" font-size="13" font-weight="bold" fill="#1e3a8a">yaw</text>
<text x="120" y="165" text-anchor="middle" font-size="11" fill="#1e3a8a">좌우로 돌기</text></g>
<!-- pitch -->
<g><ellipse cx="340" cy="100" rx="45" ry="20" fill="none" stroke="#16a34a" stroke-width="2"></ellipse>
<path d="M340,80 A20,20 0 0 1 360,100" fill="none" stroke="#16a34a" stroke-width="3"></path>
<text x="340" y="105" text-anchor="middle" font-size="13" font-weight="bold" fill="#166534">pitch</text>
<text x="340" y="165" text-anchor="middle" font-size="11" fill="#166534">위·아래 숙이기</text></g>
<!-- roll -->
<g><line x1="510" y1="100" x2="610" y2="100" stroke="#d1d5db" stroke-width="2"></line>
<line x1="520" y1="115" x2="600" y2="85" stroke="#ea580c" stroke-width="3"></line>
<text x="560" y="135" text-anchor="middle" font-size="13" font-weight="bold" fill="#9a3412">roll</text>
<text x="560" y="165" text-anchor="middle" font-size="11" fill="#9a3412">옆으로 기울기</text></g>
</svg>
<figcaption>그림 5. 드론의 세 가지 자세(yaw·pitch·roll)를 합쳐, 카메라가 보는 방향을 정확히 계산합니다.</figcaption>
</figure>
<hr />
<h2 id="7-기술-⑤--드론에서-poi까지-상대-위치">7. 기술 ⑤ — 드론에서 POI까지 &#39;상대 위치&#39;</h2>
<p>이제 <strong>POI의 위치 드론의 위치</strong>를 빼서, <strong>&quot;드론에서 봤을 때 POI가 어느 쪽에, 얼마나 멀리&quot;</strong> 있는지 구해요. 그리고 ④에서 구한 드론의 보는 방향을 적용하면, <strong>&quot;카메라 눈 기준으로&quot; POI가 어디 있는지</strong> 나와요.</p>
<blockquote>
<p>👉 <strong>비유</strong>: 친구가 어디 서서 어느 쪽을 보는지 알면, &quot;친구 눈에는 저 건물이 왼쪽 앞에 보이겠네&quot; 하고 알 수 있죠.</p>
</blockquote>
<figure class="fig">
<svg viewBox="0 0 680 200" role="img" aria-label="상대 위치">
<defs><marker id="p7a" markerWidth="10" markerHeight="10" refX="7" refY="3.5" orient="auto"><path d="M0,0 L7,3.5 L0,7 Z" fill="#7c3aed"></path></marker></defs>
<!-- view cone -->
<path d="M130,120 L470,55 L470,165 Z" fill="#ede9fe" opacity="0.6"></path>
<circle cx="130" cy="120" r="9" fill="#7c3aed"></circle>
<text x="100" y="148" font-size="12" font-weight="bold" fill="#5b21b6">드론</text>
<rect x="500" y="75" width="55" height="60" fill="#fde68a" stroke="#b45309"></rect>
<text x="527" y="152" text-anchor="middle" font-size="11" fill="#7c2d12">○○다리</text>
<line x1="140" y1="118" x2="496" y2="100" stroke="#7c3aed" stroke-width="2" stroke-dasharray="5 4" marker-end="url(#p7a)"></line>
<text x="250" y="95" font-size="12" fill="#5b21b6">&quot;드론 눈 기준으로 오른쪽 앞, 340m&quot;</text>
</svg>
<figcaption>그림 6. POI 위치에서 드론 위치를 빼고 드론의 보는 방향을 적용해, 카메라 눈 기준 위치를 구합니다.</figcaption>
</figure>
<hr />
<h2 id="8-기술-⑥--3d를-납작한-화면에-그리기-핀홀-카메라-원근투영">8. 기술 ⑥ — 3D를 납작한 화면에 그리기 (핀홀 카메라 원근투영)</h2>
<p>이제 핵심! <strong>입체(3D)를 납작한 화면(2D)에</strong> 그려요. 규칙은 누구나 알아요: <strong>가까운 건 크게, 먼 건 작게.</strong> 이게 바로 <strong>원근법</strong>이고, 카메라는 <strong>바늘구멍 사진기</strong>처럼 작동해요.</p>
<p>계산은 의외로 간단해요. &quot;옆으로 간 거리 ÷ 앞으로 간 거리&quot; 를 하면 화면의 좌우 위치가 나와요. (위아래도 똑같이!) 멀수록(앞으로 간 거리가 클수록) 나누는 값이 커져서 → 화면 가운데로 작게 모여요.</p>
<blockquote>
<p>📷 <strong>비유</strong>: 기찻길이 멀어질수록 한 점으로 모이는 것 — 그게 바로 이 나눗셈의 결과예요.</p>
</blockquote>
<figure class="fig">
<svg viewBox="0 0 680 230" role="img" aria-label="핀홀 투영">
<line x1="40" y1="195" x2="650" y2="195" stroke="#9ca3af" stroke-width="1.5"></line>
<circle cx="70" cy="150" r="7" fill="#111"></circle>
<text x="42" y="172" font-size="11" fill="#333">카메라(눈)</text>
<line x1="210" y1="55" x2="210" y2="195" stroke="#2563eb" stroke-width="2"></line>
<text x="175" y="48" font-size="11" fill="#1e3a8a">화면</text>
<!-- near pole -->
<line x1="360" y1="65" x2="360" y2="195" stroke="#16a34a" stroke-width="4"></line>
<text x="330" y="213" font-size="11" fill="#14532d">가까운 다리</text>
<line x1="560" y1="65" x2="560" y2="195" stroke="#16a34a" stroke-width="4"></line>
<text x="530" y="213" font-size="11" fill="#14532d">먼 다리</text>
<text x="350" y="53" font-size="10" fill="#777">(실제 크기는 같음)</text>
<line x1="70" y1="150" x2="360" y2="65" stroke="#f59e0b" stroke-width="1" stroke-dasharray="3 3"></line>
<line x1="70" y1="150" x2="360" y2="195" stroke="#f59e0b" stroke-width="1" stroke-dasharray="3 3"></line>
<line x1="70" y1="150" x2="560" y2="65" stroke="#b45309" stroke-width="1" stroke-dasharray="3 3"></line>
<line x1="70" y1="150" x2="560" y2="195" stroke="#b45309" stroke-width="1" stroke-dasharray="3 3"></line>
<line x1="210" y1="108" x2="210" y2="173" stroke="#16a34a" stroke-width="6"></line>
<line x1="218" y1="125" x2="218" y2="167" stroke="#15803d" stroke-width="6" opacity="0.8"></line>
<text x="228" y="120" font-size="10" fill="#166534">화면엔 가까운 게 크게</text>
</svg>
<figcaption>그림 7. 바늘구멍 사진기처럼, &quot;옆거리 ÷ 앞거리&quot;로 화면 위치를 구하면 자동으로 원근법이 됩니다.</figcaption>
</figure>
<hr />
<h2 id="9-기술-⑦--얼마나-넓게-보나-화각-맞추기-초점거리센서">9. 기술 ⑦ — &#39;얼마나 넓게 보나&#39; 화각 맞추기 (초점거리·센서)</h2>
<p>같은 자리에서도 <strong>광각 렌즈</strong>는 넓게, <strong>줌 렌즈</strong>는 좁게 보여요. 이 &quot;얼마나 넓게 보나&quot;<strong>화각</strong>이에요. 화각은 <strong>초점거리(focal, 기본 24mm)</strong><strong>센서 크기(36mm, 16:9)</strong> 로 정해져요.</p>
<p>화각이 안 맞으면 글자가 위아래·좌우로 어긋나요. 그래서 8장의 나눗셈에 이 값을 곱해서 정확히 맞춰 줘요.</p>
<blockquote>
<p>🔍 <strong>비유</strong>: 같은 창밖 풍경도 망원경(줌)으로 보면 좁고 크게, 그냥 보면 넓고 작게 보이죠.</p>
</blockquote>
<figure class="fig">
<svg viewBox="0 0 680 190" role="img" aria-label="화각">
<circle cx="80" cy="95" r="7" fill="#111"></circle>
<text x="55" y="117" font-size="11" fill="#333">카메라</text>
<path d="M80,95 L620,20 L620,170 Z" fill="#fef3c7" opacity="0.7" stroke="#f59e0b"></path>
<text x="480" y="38" font-size="13" font-weight="bold" fill="#b45309">광각: 넓게 봄</text>
<path d="M80,95 L620,75 L620,115 Z" fill="#bfdbfe" opacity="0.85" stroke="#2563eb"></path>
<text x="480" y="145" font-size="13" font-weight="bold" fill="#1e3a8a">줌: 좁게 봄</text>
</svg>
<figcaption>그림 8. 초점거리·센서로 정해지는 화각(FOV)을 맞춰야 글자가 정확한 자리에 붙습니다.</figcaption>
</figure>
<hr />
<h2 id="10-기술-⑧--카메라-뒤에-있는-건-안-그리기-클리핑">10. 기술 ⑧ — 카메라 &#39;&#39;에 있는 건 안 그리기 (클리핑)</h2>
<p>POI가 드론 <strong>뒤쪽</strong>이나 <strong>너무 가까이</strong>에 있으면, 계산이 이상해져서 글자가 화면 반대편으로 <strong>휙 튀어요.</strong> 그래서 &quot;앞으로 간 거리(Zc)가 너무 작거나 마이너스면 = 카메라 뒤/너무 가까움&quot;<strong>그냥 안 그려요.</strong></p>
<blockquote>
<p>🙈 <strong>비유</strong>: 내 뒤통수 쪽 물건은 내 눈에 안 보이죠. 안 보이는 건 화면에도 안 그리는 게 맞아요.</p>
</blockquote>
<figure class="fig">
<svg viewBox="0 0 680 180" role="img" aria-label="클리핑">
<circle cx="340" cy="90" r="9" fill="#111"></circle>
<text x="320" y="115" font-size="11" fill="#333">카메라</text>
<path d="M340,90 L640,30 L640,150 Z" fill="#dcfce7" opacity="0.6" stroke="#16a34a"></path>
<text x="560" y="90" font-size="12" fill="#166534">앞 = 그림 ✓</text>
<circle cx="560" cy="80" r="6" fill="#16a34a"></circle>
<!-- behind -->
<path d="M340,90 L40,30 L40,150 Z" fill="#fee2e2" opacity="0.6" stroke="#dc2626"></path>
<text x="120" y="90" font-size="12" fill="#b91c1c">뒤 = 안 그림 ✗</text>
<circle cx="130" cy="80" r="6" fill="#dc2626"></circle>
<line x1="120" y1="70" x2="140" y2="90" stroke="#b91c1c" stroke-width="2"></line><line x1="140" y1="70" x2="120" y2="90" stroke="#b91c1c" stroke-width="2"></line>
</svg>
<figcaption>그림 9. 카메라 뒤·너무 가까운 POI는 글자가 튀므로 아예 그리지 않습니다(클리핑).</figcaption>
</figure>
<hr />
<h2 id="11-기술-⑨--poi-높이를-똑똑하게-추측하기">11. 기술 ⑨ — POI 높이를 똑똑하게 &#39;추측&#39;하기</h2>
<p>문제가 하나 있어요. 다리·역의 <strong>위도·경도는 정확히 아는데, 정확한 높이는 모를 때</strong>가 많아요. 비싼 3D 지형 데이터(DEM)를 사면 알 수 있지만, 안 사고도 똑똑하게 짐작해요. 두 가지 방법:</p>
<ol type="1">
<li><strong>가장 가까운 선로(중심선)의 높이</strong>를 빌려 써요. → 선로 굴곡(오르막/내리막)까지 자연스럽게 반영! 👍</li>
<li>또는 <strong>&quot;드론 높이 일정값(약 24m)&quot;</strong> 으로 가정해요.</li>
</ol>
<p>그리고 사람이 직접 고친 높이가 있으면(드래그/DEM) 그걸 <strong>가장 먼저</strong> 써요.</p>
<blockquote>
<p>🏔️ <strong>비유</strong>: 친구 키를 모를 때, 바로 옆에 선 비슷한 친구 키를 참고해 짐작하는 것과 같아요.</p>
</blockquote>
<figure class="fig">
<svg viewBox="0 0 680 190" role="img" aria-label="POI 높이 추측">
<!-- rail line with slope -->
<polyline points="40,150 200,140 360,120 520,135 640,125" fill="none" stroke="#06a4c8" stroke-width="4"></polyline>
<text x="46" y="172" font-size="11" fill="#0369a1">가까운 선로(높이 알고 있음)</text>
<!-- POI -->
<circle cx="360" cy="120" r="7" fill="#b45309"></circle>
<line x1="360" y1="120" x2="360" y2="70" stroke="#f59e0b" stroke-width="2" stroke-dasharray="4 3"></line>
<rect x="335" y="45" width="50" height="25" rx="4" fill="#fde68a" stroke="#b45309"></rect>
<text x="360" y="62" text-anchor="middle" font-size="11" fill="#7c2d12">○○다리</text>
<text x="375" y="115" font-size="11" font-weight="bold" fill="#b45309">↑ 옆 선로 높이를 빌려 씀</text>
</svg>
<figcaption>그림 10. POI 높이를 모를 때, 가장 가까운 선로 높이를 빌려 자연스럽게 맞춥니다.</figcaption>
</figure>
<hr />
<h2 id="12-기술-⑩--너무-먼-건-숨기기-거리-필터">12. 기술 ⑩ — 너무 먼 건 숨기기 (거리 필터)</h2>
<p>화면에 모든 POI를 다 그리면 너무 복잡해요. 그래서 드론과의 <strong>수평 직선거리</strong>가 정해진 범위(기본 <strong>1000m</strong>)보다 멀면 <strong>숨겨요.</strong> 가까운 것만 보여 주는 거죠.</p>
<p>&quot;앞에 있나 / 옆에 있나&quot;도 따로 구분해서 더 똑똑하게 걸러낼 수 있어요.</p>
<blockquote>
<p>🔭 <strong>비유</strong>: 지도 앱에서 너무 멀리 있는 가게 이름은 안 보이다가, 가까이 가면 나타나는 것과 같아요.</p>
</blockquote>
<figure class="fig">
<svg viewBox="0 0 680 190" role="img" aria-label="거리 필터">
<circle cx="120" cy="95" r="9" fill="#7c3aed"></circle>
<text x="95" y="120" font-size="11" font-weight="bold" fill="#5b21b6">드론</text>
<circle cx="120" cy="95" r="130" fill="#7c3aed" opacity="0.07" stroke="#7c3aed" stroke-dasharray="6 4"></circle>
<text x="120" y="220" text-anchor="middle" font-size="11" fill="#5b21b6"></text>
<text x="200" y="40" font-size="11" fill="#5b21b6">범위(1000m) 안</text>
<!-- inside -->
<circle cx="180" cy="120" r="6" fill="#16a34a"></circle><text x="190" y="124" font-size="11" fill="#166534">○○다리 ✓</text>
<circle cx="210" cy="60" r="6" fill="#16a34a"></circle><text x="220" y="64" font-size="11" fill="#166534">△△역 ✓</text>
<!-- outside -->
<circle cx="560" cy="120" r="6" fill="#9ca3af"></circle><text x="500" y="140" font-size="11" fill="#9ca3af">먼 터널 ✗ (숨김)</text>
</svg>
<figcaption>그림 11. 드론에서 너무 먼 POI는 숨기고, 범위 안의 것만 화면에 보여 깔끔하게 합니다.</figcaption>
</figure>
<hr />
<h2 id="13-기술-⑪--손으로-끌어-거꾸로-고치기-역투영">13. 기술 ⑪ — 손으로 끌어 거꾸로 고치기 (역투영)</h2>
<p>지도 위치가 가끔 살짝 틀려요. 그러면 화면에서 글자를 <strong>제자리로 쓱 끌면</strong>, 컴퓨터가 그 화면 위치를 <strong>거꾸로 따라가</strong> 실제 위치(또는 높이)를 다시 계산해 고쳐요. (8장을 반대로!) 고친 값은 <strong>저장</strong>돼서 다음에도, 다른 장면에서도 계속 맞아요.</p>
<p>이렇게 거꾸로 푸는 방법이 여러 개 있어요:</p>
<ul>
<li><strong>화면점 → 실제 위치</strong> 복원 (위·아래 동시 보정)</li>
<li><strong>높이만</strong> 다시 풀기 (앞으로 밀려 보일 때)</li>
<li><strong>광선을 땅과 만나게</strong> 해서 좌우 위치 고치기</li>
<li>글자를 끌면 <strong>화각(초점거리)을 스스로 다시 맞추기</strong></li>
</ul>
<blockquote>
<p>🎯 <strong>비유</strong>: 다트가 빗나가면, 맞은 자리를 보고 &quot;팔을 이만큼 틀어야겠다&quot; 하고 거꾸로 교정하는 것과 같아요.</p>
</blockquote>
<figure class="fig">
<svg viewBox="0 0 680 200" role="img" aria-label="역투영 보정">
<defs><marker id="p13a" markerWidth="10" markerHeight="10" refX="7" refY="3.5" orient="auto"><path d="M0,0 L7,3.5 L0,7 Z" fill="#7c3aed"></path></marker></defs>
<rect x="40" y="35" width="220" height="130" rx="6" fill="#0b1020" stroke="#444"></rect>
<text x="150" y="28" text-anchor="middle" font-size="11" fill="#333">영상 화면</text>
<circle cx="170" cy="80" r="6" fill="#f59e0b"></circle>
<text x="95" y="105" font-size="11" fill="#fde68a">여기로 끌었다</text>
<line x1="300" y1="175" x2="650" y2="175" stroke="#8b5e34" stroke-width="3"></line>
<text x="300" y="195" font-size="11" fill="#7c5a3a">실제 땅</text>
<line x1="176" y1="82" x2="560" y2="170" stroke="#7c3aed" stroke-width="2" stroke-dasharray="5 4" marker-end="url(#p13a)"></line>
<circle cx="560" cy="170" r="6" fill="#7c3aed"></circle>
<text x="485" y="160" font-size="12" font-weight="bold" fill="#6d28d9">진짜 위치!</text>
<text x="270" y="60" font-size="11" fill="#6d28d9">화면의 점 → 거꾸로 따라가 → 실제 위치 계산·저장</text>
</svg>
<figcaption>그림 12. 글자를 끌면 그 화면점을 거꾸로 따라가 실제 위치·높이·화각을 자동 보정하고 저장합니다.</figcaption>
</figure>
<hr />
<h2 id="14-기술-⑫--영상-잘림에-글자도-맞추기-cover-변환">14. 기술 ⑫ — 영상 잘림에 글자도 맞추기 (cover 변환)</h2>
<p>영상은 화면을 <strong>비율 유지하며 꽉 채우다 보니 가장자리가 살짝 잘려요</strong>(CSS <code>object-fit: cover</code>). 그래서 글자도 <strong>똑같이 잘린 영상에 맞춰</strong> 위치를 옮겨야 정확히 붙어요. 8장에서 구한 &quot;0~1 사이의 화면 비율 위치&quot;를, 실제 화면 픽셀로 바꿀 때 이 잘림을 똑같이 반영해 줘요.</p>
<blockquote>
<p>🖼️ <strong>비유</strong>: 액자(화면)에 사진(영상)을 꽉 채우면 사진 가장자리가 조금 잘리죠. 사진 위에 붙일 스티커(글자)도 그 잘린 만큼 같이 옮겨 줘야 제자리예요.</p>
</blockquote>
<figure class="fig">
<svg viewBox="0 0 680 190" role="img" aria-label="cover 변환">
<!-- container -->
<rect x="220" y="30" width="240" height="130" fill="#0b1020" stroke="#23272e" stroke-width="2"></rect>
<text x="340" y="22" text-anchor="middle" font-size="11" fill="#333">화면(액자)</text>
<!-- video larger, cropped -->
<rect x="180" y="40" width="320" height="110" fill="#1e293b" opacity="0.5" stroke="#64748b" stroke-dasharray="5 4"></rect>
<text x="340" y="100" text-anchor="middle" font-size="11" fill="#94a3b8">영상(좌우 살짝 잘림)</text>
<!-- label -->
<rect x="300" y="70" width="80" height="24" rx="4" fill="#fde68a" stroke="#b45309"></rect>
<text x="340" y="87" text-anchor="middle" font-size="11" fill="#7c2d12">○○다리</text>
<text x="510" y="95" font-size="11" fill="#555">글자도 잘린 만큼</text>
<text x="510" y="113" font-size="11" fill="#555">같이 맞춰 이동</text>
</svg>
<figcaption>그림 13. 영상이 꽉 차며 잘리는 만큼 글자 위치도 똑같이 보정해 정확히 정합시킵니다.</figcaption>
</figure>
<hr />
<h2 id="15-전체를-한-줄로-다시-정리">15. 전체를 한 줄로 다시 정리</h2>
<figure class="fig">
<svg viewBox="0 0 680 120" role="img" aria-label="전체 요약">
<defs><marker id="p15a" markerWidth="9" markerHeight="9" refX="6" refY="3" orient="auto"><path d="M0,0 L6,3 L0,6 Z" fill="#b45309"></path></marker></defs>
<g font-size="11" text-anchor="middle">
<rect x="10" y="45" width="80" height="34" rx="6" fill="#ecfdf5" stroke="#16a34a"></rect><text x="50" y="66" fill="#166534">위경도</text>
<rect x="110" y="45" width="80" height="34" rx="6" fill="#eff6ff" stroke="#2563eb"></rect><text x="150" y="66" fill="#1e3a8a">미터·ENU</text>
<rect x="210" y="45" width="80" height="34" rx="6" fill="#fefce8" stroke="#ca8a04"></rect><text x="250" y="66" fill="#854d0e">높이맞춤</text>
<rect x="310" y="45" width="80" height="34" rx="6" fill="#fff7ed" stroke="#ea580c"></rect><text x="350" y="60" fill="#9a3412">드론자세</text><text x="350" y="73" fill="#9a3412">+상대위치</text>
<rect x="410" y="45" width="80" height="34" rx="6" fill="#f5f3ff" stroke="#7c3aed"></rect><text x="450" y="60" fill="#5b21b6">원근투영</text><text x="450" y="73" fill="#5b21b6">+화각</text>
<rect x="510" y="45" width="70" height="34" rx="6" fill="#fdf2f8" stroke="#db2777"></rect><text x="545" y="60" fill="#9d174d">거리·겹침</text><text x="545" y="73" fill="#9d174d">필터</text>
<rect x="600" y="45" width="70" height="34" rx="6" fill="#fff" stroke="#b45309" stroke-width="2"></rect><text x="635" y="66" fill="#23272e">화면 글자✨</text>
</g>
<line x1="90" y1="62" x2="108" y2="62" stroke="#b45309" stroke-width="1.3" marker-end="url(#p15a)"></line>
<line x1="190" y1="62" x2="208" y2="62" stroke="#b45309" stroke-width="1.3" marker-end="url(#p15a)"></line>
<line x1="290" y1="62" x2="308" y2="62" stroke="#b45309" stroke-width="1.3" marker-end="url(#p15a)"></line>
<line x1="390" y1="62" x2="408" y2="62" stroke="#b45309" stroke-width="1.3" marker-end="url(#p15a)"></line>
<line x1="490" y1="62" x2="508" y2="62" stroke="#b45309" stroke-width="1.3" marker-end="url(#p15a)"></line>
<line x1="580" y1="62" x2="598" y2="62" stroke="#b45309" stroke-width="1.3" marker-end="url(#p15a)"></line>
</svg>
<figcaption>그림 14. 위경도 → 미터/ENU → 높이맞춤 → 드론자세·상대위치 → 원근투영·화각 → 거리/겹침 필터 → 화면 글자!</figcaption>
</figure>
<hr />
<h2 id="16-왜-이게-대단할까-특허감-포인트">16. 왜 이게 대단할까? (특허감 포인트)</h2>
<ul>
<li><strong>측량 선로 높이로, 비싼 3D 지형 없이도 정합</strong> (11장) + 두 높이 기준 자동 맞춤(5장).</li>
<li><strong>한 번 끌어서 위치·높이·화각을 동시에 거꾸로 보정</strong> (13장) — 보통은 깊이 측정 장비가 필요한 일을 장비 없이.</li>
<li><strong>드론 자세·화각·지오이드·원근법을 모두 합친 정확한 투영</strong> (6~9장) — 영상 위에 글자를 픽셀 단위로 정합.</li>
</ul>
<blockquote>
<p>⚠️ 단, 특허는 <strong>비슷한 게 이미 있는지(선행기술)</strong> 전문가가 꼭 찾아봐야 확정돼요.</p>
</blockquote>
<hr />
<h2 id="17-쉬운-용어-사전">17. 쉬운 용어 사전</h2>
<table>
<thead>
<tr class="header">
<th>어려운 말</th>
<th>쉬운 뜻</th>
</tr>
</thead>
<tbody>
<tr class="odd">
<td><strong>POI</strong></td>
<td>다리·터널·역처럼 지도에 표시된 &#39;관심 지점&#39;(Point Of Interest)</td>
</tr>
<tr class="even">
<td><strong>좌표계 변환 / proj4</strong></td>
<td>위도·경도(각도)를 미터 격자로 바꾸는 것 (한국 TM = EPSG:5186)</td>
</tr>
<tr class="odd">
<td><strong>ENU</strong></td>
<td>기준점에서 동(E)·북(N)·위(U)로 몇 m인지 나타내는 3D 좌표</td>
</tr>
<tr class="even">
<td><strong>지오이드</strong></td>
<td>&#39;해발고도&#39;의 기준(평균 바닷물 높이). GPS 높이와 달라 보정 필요</td>
</tr>
<tr class="odd">
<td><strong>yaw / pitch / roll</strong></td>
<td>좌우로 돈 / 위아래 숙인 / 옆으로 기운 정도 (드론 자세)</td>
</tr>
<tr class="even">
<td><strong>회전 행렬</strong></td>
<td>세 가지 회전을 한 번에 계산하는 수학 도구</td>
</tr>
<tr class="odd">
<td><strong>투영(projection)</strong></td>
<td>입체(3D)를 납작한 화면(2D)에 그리는 것 (원근법)</td>
</tr>
<tr class="even">
<td><strong>핀홀 카메라</strong></td>
<td>바늘구멍 사진기 — 가까운 건 크게, 먼 건 작게</td>
</tr>
<tr class="odd">
<td><strong>화각(FOV) / 초점거리</strong></td>
<td>카메라가 얼마나 넓게 보는지 (광각=넓게, 줌=좁게)</td>
</tr>
<tr class="even">
<td><strong>클리핑</strong></td>
<td>카메라 뒤/너무 가까운 것을 안 그리고 잘라내는 것</td>
</tr>
<tr class="odd">
<td><strong>DEM</strong></td>
<td>땅의 높낮이를 담은 3D 지형 데이터 (보통 비쌈)</td>
</tr>
<tr class="even">
<td><strong>역투영</strong></td>
<td>투영의 반대 — 화면의 한 점이 실제 어디인지 거꾸로 찾는 것</td>
</tr>
<tr class="odd">
<td><strong>object-fit: cover</strong></td>
<td>영상을 비율 유지하며 화면을 꽉 채우는 방식(가장자리 잘림)</td>
</tr>
</tbody>
</table>
<hr />
<h2 id="18-마치며">18. 마치며</h2>
<blockquote>
<p>지도에 적힌 <strong>&quot;○○다리는 여기&quot;</strong> 라는 위치 하나가 화면에 붙기까지, <strong>위경도→미터 변환 → 높이 맞추기 → 드론 자세·상대위치 → 원근법 투영 → 거리·겹침 정리</strong> 라는 여러 기술이 차례로 일을 해요.</p>
</blockquote>
<p>복잡해 보여도, 사실은 <strong>&quot;학교에서 동쪽 몇 m, 친구가 보는 방향, 기찻길이 멀어지면 작아지는 원근법&quot;</strong> 같은 <strong>일상의 생각들</strong>을 컴퓨터로 정밀하게 이어 붙인 것뿐이랍니다. 🙂</p>
</body>
</html>
@@ -0,0 +1,515 @@
# 지도 위의 POI를 영상에 '딱' 붙이는 기술들 (그림 설명서)
> 이 글은 **초등학생도 이해할 수 있게** 쓴 설명서예요.
> "지도에 적힌 다리·터널·역의 위치(POI)를, 드론 영상의 **정확한 화면 자리**에 어떻게 붙이는가?"
> 그 비밀을 **그림과 이야기**로 하나씩 풀어 줄게요.
> (진짜 수학 공식은 같은 폴더의 기술문서 [geoProjection.ts](../client/src/utils/geoProjection.ts)를 보세요.)
<style>
figure.fig{margin:20px 0;text-align:center;page-break-inside:avoid;break-inside:avoid;}
figure.fig svg{width:100%;max-width:680px;height:auto;border:1px solid #e7e0d2;border-radius:8px;background:#fffdf8;}
figure.fig figcaption{font-size:0.9em;color:#777;margin-top:6px;}
text{font-family:'Malgun Gothic','Hancom Gothic',sans-serif;}
</style>
---
## 0. 한 문장으로 말하면
**"지도에서는 ○○다리가 여기 있어요"** 라는 위치 정보(POI)를 받아서,
**"그럼 드론 영상 화면에서는 바로 이 자리에 보이겠네!"** 하고 글자를 딱 붙여 주는 기술이에요.
> 🎯 **비유**: 보물지도에 "보물은 큰 나무 옆"이라고 적혀 있을 때,
> 내가 지금 서 있는 자리와 보는 방향을 알면 "저 나무가 내 눈앞 어디쯤 보일지" 알 수 있죠. 그걸 컴퓨터가 하는 거예요.
---
## 1. 큰 그림 먼저 — POI가 화면에 붙기까지 여행
POI 하나가 화면에 붙으려면 **여러 단계의 변신**을 거쳐요. 마치 공장의 컨베이어 벨트처럼요.
<figure class="fig">
<svg viewBox="0 0 680 280" role="img" aria-label="POI 매핑 전체 흐름">
<defs><marker id="p1a" markerWidth="10" markerHeight="10" refX="7" refY="3.5" orient="auto"><path d="M0,0 L7,3.5 L0,7 Z" fill="#b45309"/></marker></defs>
<rect x="30" y="30" width="180" height="50" rx="8" fill="#ecfdf5" stroke="#16a34a"/>
<text x="120" y="52" text-anchor="middle" font-size="13" font-weight="bold" fill="#166534">① 지도 위치</text>
<text x="120" y="70" text-anchor="middle" font-size="11" fill="#166534">위도·경도 (각도)</text>
<rect x="250" y="30" width="180" height="50" rx="8" fill="#eff6ff" stroke="#2563eb"/>
<text x="340" y="52" text-anchor="middle" font-size="13" font-weight="bold" fill="#1e3a8a">② 미터 좌표</text>
<text x="340" y="70" text-anchor="middle" font-size="11" fill="#1e3a8a">동 몇 m, 북 몇 m</text>
<rect x="470" y="30" width="180" height="50" rx="8" fill="#fefce8" stroke="#ca8a04"/>
<text x="560" y="52" text-anchor="middle" font-size="13" font-weight="bold" fill="#854d0e">③ 높이 맞추기</text>
<text x="560" y="70" text-anchor="middle" font-size="11" fill="#854d0e">지오이드 보정</text>
<rect x="470" y="115" width="180" height="50" rx="8" fill="#fff7ed" stroke="#ea580c"/>
<text x="560" y="137" text-anchor="middle" font-size="13" font-weight="bold" fill="#9a3412">④ 드론 기준 위치</text>
<text x="560" y="155" text-anchor="middle" font-size="11" fill="#9a3412">드론에서 본 방향</text>
<rect x="250" y="115" width="180" height="50" rx="8" fill="#fdf2f8" stroke="#db2777"/>
<text x="340" y="137" text-anchor="middle" font-size="13" font-weight="bold" fill="#9d174d">⑤ 카메라 눈 기준</text>
<text x="340" y="155" text-anchor="middle" font-size="11" fill="#9d174d">자세(회전) 적용</text>
<rect x="30" y="115" width="180" height="50" rx="8" fill="#f5f3ff" stroke="#7c3aed"/>
<text x="120" y="137" text-anchor="middle" font-size="13" font-weight="bold" fill="#5b21b6">⑥ 화면 평면에 투영</text>
<text x="120" y="155" text-anchor="middle" font-size="11" fill="#5b21b6">바늘구멍 사진기</text>
<rect x="250" y="205" width="180" height="50" rx="8" fill="#fff" stroke="#b45309" stroke-width="2"/>
<text x="340" y="227" text-anchor="middle" font-size="13" font-weight="bold" fill="#23272e">⑦ 화면에 글자!</text>
<text x="340" y="245" text-anchor="middle" font-size="11" fill="#555">○○다리 ✨</text>
<!-- arrows -->
<line x1="210" y1="55" x2="246" y2="55" stroke="#b45309" stroke-width="1.5" marker-end="url(#p1a)"/>
<line x1="430" y1="55" x2="466" y2="55" stroke="#b45309" stroke-width="1.5" marker-end="url(#p1a)"/>
<line x1="560" y1="80" x2="560" y2="111" stroke="#b45309" stroke-width="1.5" marker-end="url(#p1a)"/>
<line x1="470" y1="140" x2="434" y2="140" stroke="#b45309" stroke-width="1.5" marker-end="url(#p1a)"/>
<line x1="250" y1="140" x2="214" y2="140" stroke="#b45309" stroke-width="1.5" marker-end="url(#p1a)"/>
<line x1="120" y1="165" x2="120" y2="195" stroke="#b45309" stroke-width="1.5"/>
<line x1="120" y1="195" x2="250" y2="225" stroke="#b45309" stroke-width="1.5" marker-end="url(#p1a)"/>
</svg>
<figcaption>그림 1. POI는 ①지도좌표 → ②미터 → ③높이맞춤 → ④드론기준 → ⑤카메라기준 → ⑥화면투영 → ⑦글자 순으로 변신합니다.</figcaption>
</figure>
---
## 2. 사용한 기술 한눈에 보기
| 번호 | 기술 이름 | 쉽게 말하면 | 자세히 |
|------|----------|-------------|--------|
| ① | **좌표계 변환 (proj4)** | 위도·경도(각도)를 '미터'로 바꾸기 | 3장 |
| ② | **ENU 월드 좌표** | "기준점에서 동·북·위로 몇 m" | 4장 |
| ③ | **지오이드 높이 보정** | 두 가지 높이 기준을 맞추기 | 5장 |
| ④ | **자세 회전 행렬 (yaw/pitch/roll)** | 드론이 어느 쪽을 보는지 계산 | 6장 |
| ⑤ | **상대 위치 벡터** | 드론에서 POI까지의 방향·거리 | 7장 |
| ⑥ | **핀홀 카메라 원근투영** | 3D 세상을 납작한 화면에 그리기 | 8장 |
| ⑦ | **화각(초점거리·센서)** | "얼마나 넓게 보나" 맞추기 | 9장 |
| ⑧ | **카메라 뒤 지우기(클리핑)** | 뒤에 있는 건 안 그리기 | 10장 |
| ⑨ | **POI 높이 추측** | 실제 높이 없을 때 똑똑하게 짐작 | 11장 |
| ⑩ | **거리 필터** | 너무 먼 건 숨기기 | 12장 |
| ⑪ | **역투영 보정** | 손으로 끌어 거꾸로 고치기 | 13장 |
| ⑫ | **화면 크롭 맞춤(cover)** | 영상 잘림에 글자도 맞추기 | 14장 |
이제 하나씩 그림으로 볼게요!
---
## 3. 기술 ① — 위도·경도를 '미터'로 바꾸기 (proj4)
지도 위치는 보통 **위도·경도** (예: 북위 36.3°, 동경 127.4°)로 말해요. 그런데 이건 **각도**라서
"두 곳이 몇 m 떨어졌나?" 같은 계산이 어려워요.
그래서 **"기준점에서 동쪽 몇 m, 북쪽 몇 m"** 같은 **미터(m)** 좌표로 바꿔요.
우리나라 전용 지도 격자인 **EPSG:5186 (한국 TM)** 을 써서 바꿔요. 이 변신은 `proj4`라는 도구가 해 줍니다.
> 📏 **비유**: "동경 127.4도"라고 하면 와닿지 않지만, "학교에서 동쪽으로 500m"라고 하면 바로 이해되죠.
<figure class="fig">
<svg viewBox="0 0 680 200" role="img" aria-label="위경도를 미터로">
<defs><marker id="p3a" markerWidth="10" markerHeight="10" refX="7" refY="3.5" orient="auto"><path d="M0,0 L7,3.5 L0,7 Z" fill="#16a34a"/></marker></defs>
<!-- globe -->
<circle cx="120" cy="100" r="60" fill="#dbeafe" stroke="#2563eb"/>
<ellipse cx="120" cy="100" rx="60" ry="22" fill="none" stroke="#93c5fd"/>
<ellipse cx="120" cy="100" rx="22" ry="60" fill="none" stroke="#93c5fd"/>
<circle cx="138" cy="78" r="5" fill="#dc2626"/>
<text x="120" y="180" text-anchor="middle" font-size="12" fill="#1e3a8a">위도·경도 (각도)</text>
<text x="120" y="40" text-anchor="middle" font-size="11" fill="#b91c1c">36.3°, 127.4°</text>
<!-- arrow -->
<line x1="195" y1="100" x2="270" y2="100" stroke="#16a34a" stroke-width="2.5" marker-end="url(#p3a)"/>
<text x="232" y="90" text-anchor="middle" font-size="11" fill="#166534">proj4</text>
<text x="232" y="118" text-anchor="middle" font-size="10" fill="#166534">한국 TM</text>
<!-- grid -->
<g stroke="#e5e7eb"><line x1="320" y1="40" x2="320" y2="170"/><line x1="380" y1="40" x2="380" y2="170"/><line x1="440" y1="40" x2="440" y2="170"/><line x1="500" y1="40" x2="500" y2="170"/><line x1="560" y1="40" x2="560" y2="170"/>
<line x1="300" y1="60" x2="620" y2="60"/><line x1="300" y1="100" x2="620" y2="100"/><line x1="300" y1="140" x2="620" y2="140"/></g>
<line x1="320" y1="170" x2="620" y2="170" stroke="#6b7280" stroke-width="2"/>
<line x1="320" y1="170" x2="320" y2="40" stroke="#6b7280" stroke-width="2"/>
<text x="610" y="188" font-size="10" fill="#6b7280">동(m)→</text>
<text x="300" y="50" font-size="10" fill="#6b7280">북(m)↑</text>
<circle cx="500" cy="80" r="6" fill="#16a34a"/>
<text x="508" y="76" font-size="11" font-weight="bold" fill="#166534">동 540m, 북 320m</text>
</svg>
<figcaption>그림 2. 각도(위경도)를 우리나라 전용 미터 격자(한국 TM)로 바꿔 계산하기 쉽게 만듭니다.</figcaption>
</figure>
---
## 4. 기술 ② — "동·북·위로 몇 m" 3D 좌표 (ENU)
이제 위치를 **세 개의 숫자**로 나타내요. 기준점에서:
- **E**ast (동쪽으로 몇 m)
- **N**orth (북쪽으로 몇 m)
- **U**p (위로 몇 m = 높이)
이 세 글자를 따서 **ENU**라고 불러요. 이렇게 하면 지구 위 모든 위치를 **3D 공간의 한 점**으로 다룰 수 있어요.
> 🧊 **비유**: 교실에서 물건 위치를 "칠판에서 오른쪽 2m, 앞으로 3m, 바닥에서 1m 높이"라고 말하는 것과 같아요.
<figure class="fig">
<svg viewBox="0 0 680 220" role="img" aria-label="ENU 3D 좌표">
<defs><marker id="p4a" markerWidth="9" markerHeight="9" refX="6" refY="3" orient="auto"><path d="M0,0 L6,3 L0,6 Z" fill="#6b7280"/></marker></defs>
<!-- origin -->
<circle cx="180" cy="160" r="6" fill="#16a34a"/>
<text x="150" y="180" font-size="11" fill="#14532d">기준점 (0,0,0)</text>
<!-- E axis -->
<line x1="180" y1="160" x2="560" y2="160" stroke="#6b7280" stroke-width="2" marker-end="url(#p4a)"/>
<text x="555" y="180" font-size="12" fill="#6b7280">E 동쪽</text>
<!-- N axis (diagonal back) -->
<line x1="180" y1="160" x2="380" y2="60" stroke="#6b7280" stroke-width="2" marker-end="url(#p4a)"/>
<text x="385" y="55" font-size="12" fill="#6b7280">N 북쪽</text>
<!-- U axis -->
<line x1="180" y1="160" x2="180" y2="40" stroke="#6b7280" stroke-width="2" marker-end="url(#p4a)"/>
<text x="150" y="40" font-size="12" fill="#6b7280">U 위(높이)</text>
<!-- point -->
<line x1="440" y1="120" x2="440" y2="80" stroke="#f59e0b" stroke-width="1.5" stroke-dasharray="4 3"/>
<circle cx="440" cy="80" r="7" fill="#b45309"/>
<text x="450" y="76" font-size="12" font-weight="bold" fill="#b45309">○○다리</text>
<text x="450" y="94" font-size="11" fill="#7c2d12">(E, N, U)</text>
</svg>
<figcaption>그림 3. 모든 위치를 "동·북·위로 몇 m"인 3D 점으로 나타냅니다(ENU).</figcaption>
</figure>
---
## 5. 기술 ③ — 두 가지 '높이 기준' 맞추기 (지오이드 보정)
높이를 재는 기준이 **두 가지**라서 헷갈려요.
- **GPS 높이**: 지구를 매끈한 타원이라 보고 잰 높이 (드론이 기록하는 높이)
- **지도 해발고도**: 평균 바닷물 높이 기준 (지도에 적힌 높이) = **지오이드**
이 둘은 지역마다 **수십 m** 차이 나요. (대전은 약 **25.8m**!) 안 맞추면 글자가 위아래로 엉뚱하게 떠요.
그래서 지도 높이에 **25.8m를 더해** 드론의 높이 기준과 똑같이 맞춰 줘요.
> 🌊 **비유**: "1층 바닥 기준 높이"와 "지하주차장 바닥 기준 높이"는 같은 창문인데 숫자가 다르죠.
> 비교하려면 기준을 하나로 맞춰야 해요.
<figure class="fig">
<svg viewBox="0 0 680 200" role="img" aria-label="높이 기준 차이">
<defs><marker id="p5a" markerWidth="9" markerHeight="9" refX="3" refY="3" orient="auto"><path d="M0,0 L6,3 L0,6 Z" fill="#dc2626"/></marker><marker id="p5b" markerWidth="9" markerHeight="9" refX="3" refY="3" orient="auto"><path d="M6,0 L0,3 L6,6 Z" fill="#dc2626"/></marker></defs>
<line x1="40" y1="55" x2="640" y2="55" stroke="#2563eb" stroke-width="2" stroke-dasharray="6 4"/>
<text x="450" y="48" font-size="12" fill="#1e3a8a">GPS 높이 기준 (드론이 기록)</text>
<line x1="40" y1="115" x2="640" y2="115" stroke="#0ea5e9" stroke-width="2" stroke-dasharray="6 4"/>
<text x="430" y="135" font-size="12" fill="#0369a1">지도 해발 기준 (지오이드)</text>
<line x1="160" y1="56" x2="160" y2="114" stroke="#dc2626" stroke-width="1.5" marker-start="url(#p5a)" marker-end="url(#p5b)"/>
<text x="170" y="92" font-size="12" font-weight="bold" fill="#dc2626">약 25.8m 차이</text>
<path d="M40,170 Q200,150 360,158 T640,150" fill="none" stroke="#8b5e34" stroke-width="3"/>
<text x="46" y="188" font-size="11" fill="#7c5a3a">실제 땅</text>
<text x="400" y="175" font-size="11" fill="#b91c1c">→ 지도 높이에 25.8m 더해 기준 통일</text>
</svg>
<figcaption>그림 4. 높이 기준이 둘이라 약 25.8m 차이. 더해서 맞춰야 글자가 제 높이에 붙습니다.</figcaption>
</figure>
---
## 6. 기술 ④ — 드론이 '어느 쪽을 보는지' (자세 회전)
드론은 가만히 있지 않아요. **좌우로 돌고(yaw), 아래로 숙이고(pitch), 옆으로 기울어요(roll).**
이 세 가지를 알아야 "카메라가 정확히 어느 방향을 보는지" 계산할 수 있어요.
이걸 **회전 행렬**이라는 수학 도구로 한 번에 계산해요. (세 방향 회전을 합치는 마법 표라고 생각하면 돼요.)
<figure class="fig">
<svg viewBox="0 0 680 200" role="img" aria-label="yaw pitch roll">
<!-- yaw -->
<g><circle cx="120" cy="100" r="45" fill="none" stroke="#2563eb" stroke-width="2"/>
<path d="M120,55 A45,45 0 0 1 158,80" fill="none" stroke="#2563eb" stroke-width="3" marker-end="url(#p6a)"/>
<defs><marker id="p6a" markerWidth="8" markerHeight="8" refX="4" refY="4" orient="auto"><path d="M0,0 L8,4 L0,8 Z" fill="#2563eb"/></marker></defs>
<text x="120" y="105" text-anchor="middle" font-size="13" font-weight="bold" fill="#1e3a8a">yaw</text>
<text x="120" y="165" text-anchor="middle" font-size="11" fill="#1e3a8a">좌우로 돌기</text></g>
<!-- pitch -->
<g><ellipse cx="340" cy="100" rx="45" ry="20" fill="none" stroke="#16a34a" stroke-width="2"/>
<path d="M340,80 A20,20 0 0 1 360,100" fill="none" stroke="#16a34a" stroke-width="3"/>
<text x="340" y="105" text-anchor="middle" font-size="13" font-weight="bold" fill="#166534">pitch</text>
<text x="340" y="165" text-anchor="middle" font-size="11" fill="#166534">위·아래 숙이기</text></g>
<!-- roll -->
<g><line x1="510" y1="100" x2="610" y2="100" stroke="#d1d5db" stroke-width="2"/>
<line x1="520" y1="115" x2="600" y2="85" stroke="#ea580c" stroke-width="3"/>
<text x="560" y="135" text-anchor="middle" font-size="13" font-weight="bold" fill="#9a3412">roll</text>
<text x="560" y="165" text-anchor="middle" font-size="11" fill="#9a3412">옆으로 기울기</text></g>
</svg>
<figcaption>그림 5. 드론의 세 가지 자세(yaw·pitch·roll)를 합쳐, 카메라가 보는 방향을 정확히 계산합니다.</figcaption>
</figure>
---
## 7. 기술 ⑤ — 드론에서 POI까지 '상대 위치'
이제 **POI의 위치 − 드론의 위치**를 빼서, **"드론에서 봤을 때 POI가 어느 쪽에, 얼마나 멀리"** 있는지 구해요.
그리고 ④에서 구한 드론의 보는 방향을 적용하면, **"카메라 눈 기준으로" POI가 어디 있는지** 나와요.
> 👉 **비유**: 친구가 어디 서서 어느 쪽을 보는지 알면, "친구 눈에는 저 건물이 왼쪽 앞에 보이겠네" 하고 알 수 있죠.
<figure class="fig">
<svg viewBox="0 0 680 200" role="img" aria-label="상대 위치">
<defs><marker id="p7a" markerWidth="10" markerHeight="10" refX="7" refY="3.5" orient="auto"><path d="M0,0 L7,3.5 L0,7 Z" fill="#7c3aed"/></marker></defs>
<!-- view cone -->
<path d="M130,120 L470,55 L470,165 Z" fill="#ede9fe" opacity="0.6"/>
<circle cx="130" cy="120" r="9" fill="#7c3aed"/>
<text x="100" y="148" font-size="12" font-weight="bold" fill="#5b21b6">드론</text>
<rect x="500" y="75" width="55" height="60" fill="#fde68a" stroke="#b45309"/>
<text x="527" y="152" text-anchor="middle" font-size="11" fill="#7c2d12">○○다리</text>
<line x1="140" y1="118" x2="496" y2="100" stroke="#7c3aed" stroke-width="2" stroke-dasharray="5 4" marker-end="url(#p7a)"/>
<text x="250" y="95" font-size="12" fill="#5b21b6">"드론 눈 기준으로 오른쪽 앞, 340m"</text>
</svg>
<figcaption>그림 6. POI 위치에서 드론 위치를 빼고 드론의 보는 방향을 적용해, 카메라 눈 기준 위치를 구합니다.</figcaption>
</figure>
---
## 8. 기술 ⑥ — 3D를 납작한 화면에 그리기 (핀홀 카메라 원근투영)
이제 핵심! **입체(3D)를 납작한 화면(2D)에** 그려요. 규칙은 누구나 알아요: **가까운 건 크게, 먼 건 작게.**
이게 바로 **원근법**이고, 카메라는 **바늘구멍 사진기**처럼 작동해요.
계산은 의외로 간단해요. "옆으로 간 거리 ÷ 앞으로 간 거리" 를 하면 화면의 좌우 위치가 나와요. (위아래도 똑같이!)
멀수록(앞으로 간 거리가 클수록) 나누는 값이 커져서 → 화면 가운데로 작게 모여요.
> 📷 **비유**: 기찻길이 멀어질수록 한 점으로 모이는 것 — 그게 바로 이 나눗셈의 결과예요.
<figure class="fig">
<svg viewBox="0 0 680 230" role="img" aria-label="핀홀 투영">
<line x1="40" y1="195" x2="650" y2="195" stroke="#9ca3af" stroke-width="1.5"/>
<circle cx="70" cy="150" r="7" fill="#111"/>
<text x="42" y="172" font-size="11" fill="#333">카메라(눈)</text>
<line x1="210" y1="55" x2="210" y2="195" stroke="#2563eb" stroke-width="2"/>
<text x="175" y="48" font-size="11" fill="#1e3a8a">화면</text>
<!-- near pole -->
<line x1="360" y1="65" x2="360" y2="195" stroke="#16a34a" stroke-width="4"/>
<text x="330" y="213" font-size="11" fill="#14532d">가까운 다리</text>
<line x1="560" y1="65" x2="560" y2="195" stroke="#16a34a" stroke-width="4"/>
<text x="530" y="213" font-size="11" fill="#14532d">먼 다리</text>
<text x="350" y="53" font-size="10" fill="#777">(실제 크기는 같음)</text>
<line x1="70" y1="150" x2="360" y2="65" stroke="#f59e0b" stroke-width="1" stroke-dasharray="3 3"/>
<line x1="70" y1="150" x2="360" y2="195" stroke="#f59e0b" stroke-width="1" stroke-dasharray="3 3"/>
<line x1="70" y1="150" x2="560" y2="65" stroke="#b45309" stroke-width="1" stroke-dasharray="3 3"/>
<line x1="70" y1="150" x2="560" y2="195" stroke="#b45309" stroke-width="1" stroke-dasharray="3 3"/>
<line x1="210" y1="108" x2="210" y2="173" stroke="#16a34a" stroke-width="6"/>
<line x1="218" y1="125" x2="218" y2="167" stroke="#15803d" stroke-width="6" opacity="0.8"/>
<text x="228" y="120" font-size="10" fill="#166534">화면엔 가까운 게 크게</text>
</svg>
<figcaption>그림 7. 바늘구멍 사진기처럼, "옆거리 ÷ 앞거리"로 화면 위치를 구하면 자동으로 원근법이 됩니다.</figcaption>
</figure>
---
## 9. 기술 ⑦ — '얼마나 넓게 보나' 화각 맞추기 (초점거리·센서)
같은 자리에서도 **광각 렌즈**는 넓게, **줌 렌즈**는 좁게 보여요. 이 "얼마나 넓게 보나"가 **화각**이에요.
화각은 **초점거리(focal, 기본 24mm)****센서 크기(36mm, 16:9)** 로 정해져요.
화각이 안 맞으면 글자가 위아래·좌우로 어긋나요. 그래서 8장의 나눗셈에 이 값을 곱해서 정확히 맞춰 줘요.
> 🔍 **비유**: 같은 창밖 풍경도 망원경(줌)으로 보면 좁고 크게, 그냥 보면 넓고 작게 보이죠.
<figure class="fig">
<svg viewBox="0 0 680 190" role="img" aria-label="화각">
<circle cx="80" cy="95" r="7" fill="#111"/>
<text x="55" y="117" font-size="11" fill="#333">카메라</text>
<path d="M80,95 L620,20 L620,170 Z" fill="#fef3c7" opacity="0.7" stroke="#f59e0b"/>
<text x="480" y="38" font-size="13" font-weight="bold" fill="#b45309">광각: 넓게 봄</text>
<path d="M80,95 L620,75 L620,115 Z" fill="#bfdbfe" opacity="0.85" stroke="#2563eb"/>
<text x="480" y="145" font-size="13" font-weight="bold" fill="#1e3a8a">줌: 좁게 봄</text>
</svg>
<figcaption>그림 8. 초점거리·센서로 정해지는 화각(FOV)을 맞춰야 글자가 정확한 자리에 붙습니다.</figcaption>
</figure>
---
## 10. 기술 ⑧ — 카메라 '뒤'에 있는 건 안 그리기 (클리핑)
POI가 드론 **뒤쪽**이나 **너무 가까이**에 있으면, 계산이 이상해져서 글자가 화면 반대편으로 **휙 튀어요.**
그래서 "앞으로 간 거리(Zc)가 너무 작거나 마이너스면 = 카메라 뒤/너무 가까움" → **그냥 안 그려요.**
> 🙈 **비유**: 내 뒤통수 쪽 물건은 내 눈에 안 보이죠. 안 보이는 건 화면에도 안 그리는 게 맞아요.
<figure class="fig">
<svg viewBox="0 0 680 180" role="img" aria-label="클리핑">
<circle cx="340" cy="90" r="9" fill="#111"/>
<text x="320" y="115" font-size="11" fill="#333">카메라</text>
<path d="M340,90 L640,30 L640,150 Z" fill="#dcfce7" opacity="0.6" stroke="#16a34a"/>
<text x="560" y="90" font-size="12" fill="#166534">앞 = 그림 ✓</text>
<circle cx="560" cy="80" r="6" fill="#16a34a"/>
<!-- behind -->
<path d="M340,90 L40,30 L40,150 Z" fill="#fee2e2" opacity="0.6" stroke="#dc2626"/>
<text x="120" y="90" font-size="12" fill="#b91c1c">뒤 = 안 그림 ✗</text>
<circle cx="130" cy="80" r="6" fill="#dc2626"/>
<line x1="120" y1="70" x2="140" y2="90" stroke="#b91c1c" stroke-width="2"/><line x1="140" y1="70" x2="120" y2="90" stroke="#b91c1c" stroke-width="2"/>
</svg>
<figcaption>그림 9. 카메라 뒤·너무 가까운 POI는 글자가 튀므로 아예 그리지 않습니다(클리핑).</figcaption>
</figure>
---
## 11. 기술 ⑨ — POI 높이를 똑똑하게 '추측'하기
문제가 하나 있어요. 다리·역의 **위도·경도는 정확히 아는데, 정확한 높이는 모를 때**가 많아요.
비싼 3D 지형 데이터(DEM)를 사면 알 수 있지만, 안 사고도 똑똑하게 짐작해요. 두 가지 방법:
1. **가장 가까운 선로(중심선)의 높이**를 빌려 써요. → 선로 굴곡(오르막/내리막)까지 자연스럽게 반영! 👍
2. 또는 **"드론 높이 일정값(약 24m)"** 으로 가정해요.
그리고 사람이 직접 고친 높이가 있으면(드래그/DEM) 그걸 **가장 먼저** 써요.
> 🏔️ **비유**: 친구 키를 모를 때, 바로 옆에 선 비슷한 친구 키를 참고해 짐작하는 것과 같아요.
<figure class="fig">
<svg viewBox="0 0 680 190" role="img" aria-label="POI 높이 추측">
<!-- rail line with slope -->
<polyline points="40,150 200,140 360,120 520,135 640,125" fill="none" stroke="#06a4c8" stroke-width="4"/>
<text x="46" y="172" font-size="11" fill="#0369a1">가까운 선로(높이 알고 있음)</text>
<!-- POI -->
<circle cx="360" cy="120" r="7" fill="#b45309"/>
<line x1="360" y1="120" x2="360" y2="70" stroke="#f59e0b" stroke-width="2" stroke-dasharray="4 3"/>
<rect x="335" y="45" width="50" height="25" rx="4" fill="#fde68a" stroke="#b45309"/>
<text x="360" y="62" text-anchor="middle" font-size="11" fill="#7c2d12">○○다리</text>
<text x="375" y="115" font-size="11" font-weight="bold" fill="#b45309">↑ 옆 선로 높이를 빌려 씀</text>
</svg>
<figcaption>그림 10. POI 높이를 모를 때, 가장 가까운 선로 높이를 빌려 자연스럽게 맞춥니다.</figcaption>
</figure>
---
## 12. 기술 ⑩ — 너무 먼 건 숨기기 (거리 필터)
화면에 모든 POI를 다 그리면 너무 복잡해요. 그래서 드론과의 **수평 직선거리**가
정해진 범위(기본 **1000m**)보다 멀면 **숨겨요.** 가까운 것만 보여 주는 거죠.
또 "앞에 있나 / 옆에 있나"도 따로 구분해서 더 똑똑하게 걸러낼 수 있어요.
> 🔭 **비유**: 지도 앱에서 너무 멀리 있는 가게 이름은 안 보이다가, 가까이 가면 나타나는 것과 같아요.
<figure class="fig">
<svg viewBox="0 0 680 190" role="img" aria-label="거리 필터">
<circle cx="120" cy="95" r="9" fill="#7c3aed"/>
<text x="95" y="120" font-size="11" font-weight="bold" fill="#5b21b6">드론</text>
<circle cx="120" cy="95" r="130" fill="#7c3aed" opacity="0.07" stroke="#7c3aed" stroke-dasharray="6 4"/>
<text x="120" y="220" text-anchor="middle" font-size="11" fill="#5b21b6"></text>
<text x="200" y="40" font-size="11" fill="#5b21b6">범위(1000m) 안</text>
<!-- inside -->
<circle cx="180" cy="120" r="6" fill="#16a34a"/><text x="190" y="124" font-size="11" fill="#166534">○○다리 ✓</text>
<circle cx="210" cy="60" r="6" fill="#16a34a"/><text x="220" y="64" font-size="11" fill="#166534">△△역 ✓</text>
<!-- outside -->
<circle cx="560" cy="120" r="6" fill="#9ca3af"/><text x="500" y="140" font-size="11" fill="#9ca3af">먼 터널 ✗ (숨김)</text>
</svg>
<figcaption>그림 11. 드론에서 너무 먼 POI는 숨기고, 범위 안의 것만 화면에 보여 깔끔하게 합니다.</figcaption>
</figure>
---
## 13. 기술 ⑪ — 손으로 끌어 거꾸로 고치기 (역투영)
지도 위치가 가끔 살짝 틀려요. 그러면 화면에서 글자를 **제자리로 쓱 끌면**,
컴퓨터가 그 화면 위치를 **거꾸로 따라가** 실제 위치(또는 높이)를 다시 계산해 고쳐요. (8장을 반대로!)
고친 값은 **저장**돼서 다음에도, 다른 장면에서도 계속 맞아요.
이렇게 거꾸로 푸는 방법이 여러 개 있어요:
- **화면점 → 실제 위치** 복원 (위·아래 동시 보정)
- **높이만** 다시 풀기 (앞으로 밀려 보일 때)
- **광선을 땅과 만나게** 해서 좌우 위치 고치기
- 글자를 끌면 **화각(초점거리)을 스스로 다시 맞추기**
> 🎯 **비유**: 다트가 빗나가면, 맞은 자리를 보고 "팔을 이만큼 틀어야겠다" 하고 거꾸로 교정하는 것과 같아요.
<figure class="fig">
<svg viewBox="0 0 680 200" role="img" aria-label="역투영 보정">
<defs><marker id="p13a" markerWidth="10" markerHeight="10" refX="7" refY="3.5" orient="auto"><path d="M0,0 L7,3.5 L0,7 Z" fill="#7c3aed"/></marker></defs>
<rect x="40" y="35" width="220" height="130" rx="6" fill="#0b1020" stroke="#444"/>
<text x="150" y="28" text-anchor="middle" font-size="11" fill="#333">영상 화면</text>
<circle cx="170" cy="80" r="6" fill="#f59e0b"/>
<text x="95" y="105" font-size="11" fill="#fde68a">여기로 끌었다</text>
<line x1="300" y1="175" x2="650" y2="175" stroke="#8b5e34" stroke-width="3"/>
<text x="300" y="195" font-size="11" fill="#7c5a3a">실제 땅</text>
<line x1="176" y1="82" x2="560" y2="170" stroke="#7c3aed" stroke-width="2" stroke-dasharray="5 4" marker-end="url(#p13a)"/>
<circle cx="560" cy="170" r="6" fill="#7c3aed"/>
<text x="485" y="160" font-size="12" font-weight="bold" fill="#6d28d9">진짜 위치!</text>
<text x="270" y="60" font-size="11" fill="#6d28d9">화면의 점 → 거꾸로 따라가 → 실제 위치 계산·저장</text>
</svg>
<figcaption>그림 12. 글자를 끌면 그 화면점을 거꾸로 따라가 실제 위치·높이·화각을 자동 보정하고 저장합니다.</figcaption>
</figure>
---
## 14. 기술 ⑫ — 영상 잘림에 글자도 맞추기 (cover 변환)
영상은 화면을 **비율 유지하며 꽉 채우다 보니 가장자리가 살짝 잘려요**(CSS `object-fit: cover`).
그래서 글자도 **똑같이 잘린 영상에 맞춰** 위치를 옮겨야 정확히 붙어요.
8장에서 구한 "0~1 사이의 화면 비율 위치"를, 실제 화면 픽셀로 바꿀 때 이 잘림을 똑같이 반영해 줘요.
> 🖼️ **비유**: 액자(화면)에 사진(영상)을 꽉 채우면 사진 가장자리가 조금 잘리죠.
> 사진 위에 붙일 스티커(글자)도 그 잘린 만큼 같이 옮겨 줘야 제자리예요.
<figure class="fig">
<svg viewBox="0 0 680 190" role="img" aria-label="cover 변환">
<!-- container -->
<rect x="220" y="30" width="240" height="130" fill="#0b1020" stroke="#23272e" stroke-width="2"/>
<text x="340" y="22" text-anchor="middle" font-size="11" fill="#333">화면(액자)</text>
<!-- video larger, cropped -->
<rect x="180" y="40" width="320" height="110" fill="#1e293b" opacity="0.5" stroke="#64748b" stroke-dasharray="5 4"/>
<text x="340" y="100" text-anchor="middle" font-size="11" fill="#94a3b8">영상(좌우 살짝 잘림)</text>
<!-- label -->
<rect x="300" y="70" width="80" height="24" rx="4" fill="#fde68a" stroke="#b45309"/>
<text x="340" y="87" text-anchor="middle" font-size="11" fill="#7c2d12">○○다리</text>
<text x="510" y="95" font-size="11" fill="#555">글자도 잘린 만큼</text>
<text x="510" y="113" font-size="11" fill="#555">같이 맞춰 이동</text>
</svg>
<figcaption>그림 13. 영상이 꽉 차며 잘리는 만큼 글자 위치도 똑같이 보정해 정확히 정합시킵니다.</figcaption>
</figure>
---
## 15. 전체를 한 줄로 다시 정리
<figure class="fig">
<svg viewBox="0 0 680 120" role="img" aria-label="전체 요약">
<defs><marker id="p15a" markerWidth="9" markerHeight="9" refX="6" refY="3" orient="auto"><path d="M0,0 L6,3 L0,6 Z" fill="#b45309"/></marker></defs>
<g font-size="11" text-anchor="middle">
<rect x="10" y="45" width="80" height="34" rx="6" fill="#ecfdf5" stroke="#16a34a"/><text x="50" y="66" fill="#166534">위경도</text>
<rect x="110" y="45" width="80" height="34" rx="6" fill="#eff6ff" stroke="#2563eb"/><text x="150" y="66" fill="#1e3a8a">미터·ENU</text>
<rect x="210" y="45" width="80" height="34" rx="6" fill="#fefce8" stroke="#ca8a04"/><text x="250" y="66" fill="#854d0e">높이맞춤</text>
<rect x="310" y="45" width="80" height="34" rx="6" fill="#fff7ed" stroke="#ea580c"/><text x="350" y="60" fill="#9a3412">드론자세</text><text x="350" y="73" fill="#9a3412">+상대위치</text>
<rect x="410" y="45" width="80" height="34" rx="6" fill="#f5f3ff" stroke="#7c3aed"/><text x="450" y="60" fill="#5b21b6">원근투영</text><text x="450" y="73" fill="#5b21b6">+화각</text>
<rect x="510" y="45" width="70" height="34" rx="6" fill="#fdf2f8" stroke="#db2777"/><text x="545" y="60" fill="#9d174d">거리·겹침</text><text x="545" y="73" fill="#9d174d">필터</text>
<rect x="600" y="45" width="70" height="34" rx="6" fill="#fff" stroke="#b45309" stroke-width="2"/><text x="635" y="66" fill="#23272e">화면 글자✨</text>
</g>
<line x1="90" y1="62" x2="108" y2="62" stroke="#b45309" stroke-width="1.3" marker-end="url(#p15a)"/>
<line x1="190" y1="62" x2="208" y2="62" stroke="#b45309" stroke-width="1.3" marker-end="url(#p15a)"/>
<line x1="290" y1="62" x2="308" y2="62" stroke="#b45309" stroke-width="1.3" marker-end="url(#p15a)"/>
<line x1="390" y1="62" x2="408" y2="62" stroke="#b45309" stroke-width="1.3" marker-end="url(#p15a)"/>
<line x1="490" y1="62" x2="508" y2="62" stroke="#b45309" stroke-width="1.3" marker-end="url(#p15a)"/>
<line x1="580" y1="62" x2="598" y2="62" stroke="#b45309" stroke-width="1.3" marker-end="url(#p15a)"/>
</svg>
<figcaption>그림 14. 위경도 → 미터/ENU → 높이맞춤 → 드론자세·상대위치 → 원근투영·화각 → 거리/겹침 필터 → 화면 글자!</figcaption>
</figure>
---
## 16. 왜 이게 대단할까? (특허감 포인트)
- **측량 선로 높이로, 비싼 3D 지형 없이도 정합** (11장) + 두 높이 기준 자동 맞춤(5장).
- **한 번 끌어서 위치·높이·화각을 동시에 거꾸로 보정** (13장) — 보통은 깊이 측정 장비가 필요한 일을 장비 없이.
- **드론 자세·화각·지오이드·원근법을 모두 합친 정확한 투영** (6~9장) — 영상 위에 글자를 픽셀 단위로 정합.
> ⚠️ 단, 특허는 **비슷한 게 이미 있는지(선행기술)** 전문가가 꼭 찾아봐야 확정돼요.
---
## 17. 쉬운 용어 사전
| 어려운 말 | 쉬운 뜻 |
|----------|---------|
| **POI** | 다리·터널·역처럼 지도에 표시된 '관심 지점'(Point Of Interest) |
| **좌표계 변환 / proj4** | 위도·경도(각도)를 미터 격자로 바꾸는 것 (한국 TM = EPSG:5186) |
| **ENU** | 기준점에서 동(E)·북(N)·위(U)로 몇 m인지 나타내는 3D 좌표 |
| **지오이드** | '해발고도'의 기준(평균 바닷물 높이). GPS 높이와 달라 보정 필요 |
| **yaw / pitch / roll** | 좌우로 돈 / 위아래 숙인 / 옆으로 기운 정도 (드론 자세) |
| **회전 행렬** | 세 가지 회전을 한 번에 계산하는 수학 도구 |
| **투영(projection)** | 입체(3D)를 납작한 화면(2D)에 그리는 것 (원근법) |
| **핀홀 카메라** | 바늘구멍 사진기 — 가까운 건 크게, 먼 건 작게 |
| **화각(FOV) / 초점거리** | 카메라가 얼마나 넓게 보는지 (광각=넓게, 줌=좁게) |
| **클리핑** | 카메라 뒤/너무 가까운 것을 안 그리고 잘라내는 것 |
| **DEM** | 땅의 높낮이를 담은 3D 지형 데이터 (보통 비쌈) |
| **역투영** | 투영의 반대 — 화면의 한 점이 실제 어디인지 거꾸로 찾는 것 |
| **object-fit: cover** | 영상을 비율 유지하며 화면을 꽉 채우는 방식(가장자리 잘림) |
---
## 18. 마치며
> 지도에 적힌 **"○○다리는 여기"** 라는 위치 하나가 화면에 붙기까지,
> **위경도→미터 변환 → 높이 맞추기 → 드론 자세·상대위치 → 원근법 투영 → 거리·겹침 정리** 라는
> 여러 기술이 차례로 일을 해요.
복잡해 보여도, 사실은 **"학교에서 동쪽 몇 m, 친구가 보는 방향, 기찻길이 멀어지면 작아지는 원근법"** 같은
**일상의 생각들**을 컴퓨터로 정밀하게 이어 붙인 것뿐이랍니다. 🙂
+2
View File
@@ -14,6 +14,7 @@ import metaRouter from './routes/meta';
import annotationsRouter from './routes/annotations';
import geoRouter from './routes/geo';
import elevationRouter from './routes/elevation';
import tileRouter from './routes/tile';
const app = express();
@@ -35,6 +36,7 @@ app.use('/api/meta', metaRouter);
app.use('/api/annotations/:videoId', annotationsRouter);
app.use('/api/geo', geoRouter);
app.use('/api/elevation', elevationRouter);
app.use('/api/tile', tileRouter);
app.get('/api/health', (_req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
+51
View File
@@ -0,0 +1,51 @@
import { Router } from 'express';
const router = Router();
/**
* CSP(img-src 'self') / COEP(require-corp)
* <img> . .
*
* GET /api/tile/:source/:z/:x/:y (y "숫자.png")
* - source=osm : OpenStreetMap () https://tile.openstreetmap.org/{z}/{x}/{y}.png
* - source=sat : Esri World Imagery .../tile/{z}/{y}/{x} (row=y, col=x )
*
* 주의: ( User-Agent, ). Esri World Imagery .
*/
router.get('/:source/:z/:x/:y', async (req, res) => {
const source = String(req.params.source);
const z = Number(req.params.z);
const x = Number(req.params.x); // col (tx)
const y = Number(String(req.params.y).replace(/\.png$/i, '')); // row (ty)
if (![z, x, y].every(Number.isInteger) || z < 0 || z > 19) {
res.status(400).json({ error: 'invalid tile coords' });
return;
}
const n = 2 ** z;
if (x < 0 || x >= n || y < 0 || y >= n) {
res.status(400).json({ error: 'tile out of range' });
return;
}
const upstreamUrl =
source === 'sat'
? `https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/${z}/${y}/${x}` // z/row/col
: `https://tile.openstreetmap.org/${z}/${x}/${y}.png`; // z/col/row
try {
const upstream = await fetch(upstreamUrl, {
headers: { 'User-Agent': 'GhiVideo/1.0 (drone route inspection player)' },
});
if (!upstream.ok) {
res.status(502).json({ error: 'upstream ' + upstream.status });
return;
}
const buf = Buffer.from(await upstream.arrayBuffer());
res.setHeader('Content-Type', upstream.headers.get('content-type') ?? 'image/png');
res.setHeader('Cache-Control', 'public, max-age=604800, immutable'); // 7일 캐시
res.setHeader('Cross-Origin-Resource-Policy', 'same-origin'); // COEP 만족
res.send(buf);
} catch (e) {
res.status(502).json({ error: String(e) });
}
});
export default router;