GhiVideo 소스 복제 — v4 작업 시작 기준

기존 GhiVideo 저장소 HEAD의 트래킹 소스 362개 파일을 복제.
(node_modules·storage·빌드 산출물·대용량 미디어는 .gitignore 규칙대로 제외)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-09 14:32:51 +09:00
co-authored by Claude Fable 5
commit d38b842e8d
362 changed files with 46358 additions and 0 deletions
+64
View File
@@ -0,0 +1,64 @@
/** 측점값(체이니지) 공유 유틸 — 드론 GPS 를 측점 폴리라인에 투영해 측점값/최근접측점 산출.
* StationBar(배지)·VideoPlayer(측점 진단 HUD) 가 동일 계산을 공유한다. */
export interface ChainLine {
pts: { x: number; y: number; km: number; lat: number; lon: number; title: string }[];
k: number; // 경도→m 환산 (cos(lat0)*111000)
}
/** "157K970" → 157970(m). 실패 시 -1. */
export function kmFromTitle(title: string): number {
const m = title.match(/(\d+)[Kk](\d+)/);
return m ? parseInt(m[1], 10) * 1000 + parseInt(m[2], 10) : -1;
}
/** 미터값 → "157k970" (10m 단위). */
export function fmtKm10(m: number): string {
const r = Math.round(m / 10) * 10;
return `${Math.floor(r / 1000)}k${String(r % 1000).padStart(3, '0')}`;
}
/** 측점 POI 목록 → 평면투영 폴리라인(km 오름차순). 측점이 없으면 null. */
export function buildChainLine(stations: { title: string; lat: number; lon: number }[]): ChainLine | null {
const sts = stations.filter((s) => kmFromTitle(s.title) >= 0);
if (!sts.length) return null;
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;
return {
pts: sorted.map((p) => ({ x: p.lon * k, y: p.lat * 111000, km: kmFromTitle(p.title), lat: p.lat, lon: p.lon, title: p.title })),
k,
};
}
/** 드론 lat/lon 을 폴리라인에 투영 → { km(측점값), offsetM(선로 수직이격) }. */
export function projectToChain(lat: number, lon: number, line: ChainLine): { km: number; offsetM: number } {
const px = lon * line.k;
const py = lat * 111000;
const pts = line.pts;
let bestD = Infinity;
let bestKm = pts.length ? pts[0].km : -1;
for (let i = 0; i < pts.length - 1; i++) {
const a = pts[i], b = pts[i + 1];
const dx = b.x - a.x, dy = b.y - a.y, L2 = dx * dx + dy * dy;
const t = L2 === 0 ? 0 : Math.max(0, Math.min(1, ((px - a.x) * dx + (py - a.y) * dy) / L2));
const cx = a.x + dx * t, cy = a.y + dy * t;
const d = (px - cx) ** 2 + (py - cy) ** 2;
if (d < bestD) { bestD = d; bestKm = a.km + (b.km - a.km) * t; }
}
return { km: bestKm, offsetM: Math.sqrt(bestD) };
}
/** 드론 lat/lon 의 최근접 측점(점) → { title, km, distM }. */
export function nearestChainPoint(lat: number, lon: number, line: ChainLine): { title: string; km: number; distM: number } | null {
const pts = line.pts;
if (!pts.length) return null;
let nIdx = 0, nD = Infinity;
for (let i = 0; i < pts.length; i++) {
const dx = (lat - pts[i].lat) * 111000;
const dy = (lon - pts[i].lon) * line.k;
const d = dx * dx + dy * dy;
if (d < nD) { nD = d; nIdx = i; }
}
return { title: pts[nIdx].title, km: pts[nIdx].km, distM: Math.sqrt(nD) };
}
+21
View File
@@ -0,0 +1,21 @@
// Reusable canvas for frame capture — single canvas instance per CLAUDE.md
let captureCanvas: HTMLCanvasElement | null = null;
export function captureFrame(video: HTMLVideoElement): string | null {
if (!captureCanvas) {
captureCanvas = document.createElement('canvas');
}
captureCanvas.width = video.videoWidth;
captureCanvas.height = video.videoHeight;
const ctx = captureCanvas.getContext('2d');
if (!ctx) return null;
ctx.drawImage(video, 0, 0);
return captureCanvas.toDataURL('image/jpeg', 0.95);
}
export function downloadDataUrl(dataUrl: string, filename: string): void {
const a = document.createElement('a');
a.href = dataUrl;
a.download = filename;
a.click();
}
+595
View File
@@ -0,0 +1,595 @@
/**
* 클라이언트 폴더 기반 지리정보 로딩 + CSV 파싱 (v2.0 데이터 형상 전용)
*
* `<input type="file" webkitdirectory>` 로 선택된 File[] 에서 v2.0 폴더 구조를
* 식별해 영상 / 드론 CSV / 측점·POI·구조물 CSV 를 파싱한다.
*
* v2.0 폴더 구조 (영상 base = 영상파일명에서 확장자 제거):
* <base>.MP4 영상
* <base>.csv 드론 프레임 (frame_cnt,latitude,...)
* <base>_POI.csv POI (source,query_label,title,category_clean,...,lat,lon,...)
* building/01)측점.csv 측점 (측점,X좌표,...,비고,lat,lon,...)
* building/02)지장물.csv POI (명칭,...,lat,lon)
* building/03)교량.csv 구조물 bridge (구분,...,연장(m),...,lat,lon,...)
* building/04)터널.csv 구조물 tunnel (구분,...,연장(m),...,lat,lon,...)
* building/05)출입문번호.csv 출입문 (기본 표시 제외 — 아래 참고)
* building/06)구교.csv 구조물 bridge (역구간,시설물명,...,연장(m),lat,lon,...)
*
* v2.0엔 center.csv 가 없다 → 측점(01)측점)을 측점값 순으로 이어 중심선을 생성한다.
*
* 인코딩(혼재 — 자동 감지): ArrayBuffer 앞 3바이트가 UTF-8 BOM(EF BB BF)이면 UTF-8,
* 아니면 EUC-KR. 디코딩 후 잔여 BOM(U+FEFF)을 제거한다. 파일별 하드코딩 없음.
*/
import type {
DroneFrame,
GeoPoint,
CenterlinePoint,
GeoOrigin,
FolderGeoData,
RouteMeta,
RouteStructure,
DirectionChange,
PoiOverrideMap,
} from '../types/geo';
import { stationOrder, getWorldOrigin } from './geoSearch';
import { unzipSync, strFromU8 } from 'fflate';
// ── CSV 파싱 헬퍼 ─────────────────────────────────────────────────────
export function parseCsvLine(line: string): string[] {
const result: string[] = [];
let current = '';
let inQuotes = false;
for (const ch of line) {
if (ch === '"') {
inQuotes = !inQuotes;
} else if (ch === ',' && !inQuotes) {
result.push(current.trim());
current = '';
} else {
current += ch;
}
}
result.push(current.trim());
return result;
}
/**
* ArrayBuffer → 문자열. 인코딩 자동 감지:
* 앞 3바이트가 UTF-8 BOM(EF BB BF)이면 UTF-8, 아니면 EUC-KR.
* 디코딩 후 남을 수 있는 BOM(U+FEFF)을 제거한다.
*/
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);
// 디코딩 후 잔여 BOM(U+FEFF) 제거
return text.replace(/^/, '');
}
/** File → 파싱된 행 배열. 인코딩은 BOM 기반 자동 감지. */
export async function readCsv(file: File): Promise<string[][]> {
const buf = await file.arrayBuffer();
const text = decodeBytes(buf);
return text
.split(/\r?\n/)
.filter(Boolean)
.map(parseCsvLine);
}
/** 헤더 행 → (헤더명|위치인덱스) → 값 추출기. 헤더가 깨지면 위치 인덱스로 폴백. */
function makeFieldIndexer(header: string[]): (name: string, fallback?: number) => number {
const cleaned = header.map((h) => h.trim().replace(/^/, ''));
return (name: string, fallback?: number): number => {
const i = cleaned.indexOf(name);
if (i >= 0) return i;
return fallback ?? -1;
};
}
/** 안전한 셀 접근 (인덱스 음수/범위 밖이면 ''). */
function cell(row: string[], i: number): string {
return i >= 0 && i < row.length ? row[i] : '';
}
// ── 파일 식별 헬퍼 ────────────────────────────────────────────────────
/** File 의 폴더 내 상대경로 (webkitRelativePath 우선, 없으면 name). */
function relPath(f: File): string {
return f.webkitRelativePath || f.name;
}
/** 경로의 마지막 세그먼트(파일명). */
function baseNameOf(p: string): string {
const parts = p.split('/');
return parts[parts.length - 1];
}
/** building/ 하위 파일 여부 (마지막 디렉토리 세그먼트가 building). */
function isInBuilding(f: File): boolean {
const parts = relPath(f).split('/');
return parts.length >= 2 && parts[parts.length - 2].toLowerCase() === 'building';
}
/** building/ 하위에서 파일명이 키워드를 포함하는 첫 파일. (예 '01)측점', '03)교량') */
function findBuildingFile(files: File[], keyword: string): File | null {
return files.find((f) => isInBuilding(f) && baseNameOf(relPath(f)).includes(keyword)) ?? null;
}
const VIDEO_EXT = /\.(mp4|webm)$/i;
/** 영상 파일 찾기 (mp4/webm, building 제외). */
export function findVideoFile(files: File[]): File | null {
return (
files.find((f) => !isInBuilding(f) && VIDEO_EXT.test(baseNameOf(relPath(f)))) ?? null
);
}
/** 영상 파일명에서 base(확장자 제거) 추출. */
export function deriveBaseName(videoFile: File | null): string | null {
if (!videoFile) return null;
return baseNameOf(relPath(videoFile)).replace(VIDEO_EXT, '');
}
// ── 파서: 드론 프레임 (변경 없음) ─────────────────────────────────────
/**
* 드론 프레임 CSV 파싱 (UTF-8 BOM, 헤더 이름 인덱스).
* 헤더: frame_cnt,latitude,longitude,altitude,yaw,pitch,roll,focal_len
* 루트(building 제외)의 <base>.csv 를 식별한다. base 가 없으면 루트 .csv 중
* POI 가 아닌 첫 파일을 사용한다(영상명 비의존 폴백).
*/
export async function parseDroneFrames(
files: File[],
baseName: string | null,
): Promise<DroneFrame[]> {
const rootCsv = files.filter((f) => {
if (isInBuilding(f)) return false;
const name = baseNameOf(relPath(f));
if (!/\.csv$/i.test(name)) return false;
if (/POI/i.test(name)) return false;
return true;
});
if (!rootCsv.length) return [];
// base 일치 우선, 없으면 첫 루트 csv
const droneFile =
(baseName && rootCsv.find((f) => baseNameOf(relPath(f)) === `${baseName}.csv`)) ||
rootCsv[0];
const rows = await readCsv(droneFile);
if (rows.length < 2) return [];
const fi = makeFieldIndexer(rows[0]);
return rows
.slice(1)
.map((r) => ({
frame: parseInt(cell(r, fi('frame_cnt', 0)), 10),
lat: parseFloat(cell(r, fi('latitude', 1))),
lon: parseFloat(cell(r, fi('longitude', 2))),
altitude: parseFloat(cell(r, fi('altitude', 3))),
yaw: parseFloat(cell(r, fi('yaw', 4))),
pitch: parseFloat(cell(r, fi('pitch', 5))),
roll: parseFloat(cell(r, fi('roll', 6))),
focalLen: parseFloat(cell(r, fi('focal_len', 7))),
}))
.filter((f) => !isNaN(f.lat));
}
// ── 파서: 측점 + 방향전환점 ───────────────────────────────────────────
/** "02:05" → 125 (초). 파싱 실패 시 NaN. */
function mmssToSeconds(s: string): number {
const m = s.match(/(\d+):(\d+)/);
if (!m) return NaN;
return parseInt(m[1], 10) * 60 + parseInt(m[2], 10);
}
/**
* 측점 CSV 파싱 (building/01)측점.csv, EUC-KR).
* 헤더: 측점,X좌표,Y좌표,Z좌표,비고,lat,lon,...
* title ← 측점, lat ← lat, lon ← lon, z ← Z좌표, category='측점', type='station'
* 비고(방향전환점) 도 함께 추출한다: `방향전환점(상행->하행, 02:05)`.
*/
export async function parseStations(files: File[]): Promise<{
stations: GeoPoint[];
directionChanges: DirectionChange[];
}> {
// 측점 파일 — building/ 또는 root(영상 옆) 어디든 허용(building 폴더 삭제 대비).
const file =
findBuildingFile(files, '01)측점') ??
files.find((f) => baseNameOf(relPath(f)).includes('01)측점')) ??
files.find((f) => baseNameOf(relPath(f)).includes('측점'));
if (!file) return { stations: [], directionChanges: [] };
const rows = await readCsv(file);
if (rows.length < 2) return { stations: [], directionChanges: [] };
const fi = makeFieldIndexer(rows[0]);
const iTitle = fi('측점', 0);
// Z좌표(col 3)는 로컬좌표(≈0). 실제 표고는 Z좌표_한국(EPSG:5186, 정표고)에 있다.
// 없으면 Z좌표로 폴백.
const iZKorea = fi('Z좌표_한국');
const iZ = iZKorea >= 0 ? iZKorea : fi('Z좌표', 3);
const iNote = fi('비고', 4);
const iLat = fi('lat', 5);
const iLon = fi('lon', 6);
const stations: GeoPoint[] = [];
const directionChanges: DirectionChange[] = [];
for (const r of rows.slice(1)) {
const lat = parseFloat(cell(r, iLat));
const lon = parseFloat(cell(r, iLon));
const title = cell(r, iTitle);
if (isNaN(lat) || isNaN(lon)) continue;
stations.push({
title,
category: '측점',
lat,
lon,
z: parseFloat(cell(r, iZ)) || 0,
type: 'station',
});
// 비고: 방향전환점(상행->하행, 02:05)
const note = cell(r, iNote);
const m = note.match(/방향전환점\s*\(\s*([^->]+?)\s*->\s*([^,)]+?)\s*,\s*([\d:]+)\s*\)/);
if (m) {
const atSeconds = mmssToSeconds(m[3]);
directionChanges.push({
station: title,
from: m[1].trim(),
to: m[2].trim(),
atSeconds: isNaN(atSeconds) ? -1 : atSeconds,
});
}
}
return { stations, directionChanges };
}
// ── 파서: KMZ (원본) ─────────────────────────────────────────────────
//
// KMZ(=doc.kml zip)는 building CSV의 원본이다. 폴더가 02)지장물·03)교량·04)터널·05)출입문번호·
// 06)구교 로 1:1 대응하고, 각 Placemark 가 좌표 + description HTML표(속성)를 담는다.
// → KMZ 에서 직접 POI/구조물을 추출하면 CSV 추출 중복이 사라진다. (측점(01)은 KMZ에 없음 → CSV 유지)
/** HTML 태그 제거 + 엔티티 디코드 + 트림. */
function stripHtml(s: string): string {
return s
.replace(/<[^>]*>/g, ' ')
.replace(/&gt;/g, '>').replace(/&lt;/g, '<').replace(/&amp;/g, '&')
.replace(/&nbsp;/g, ' ').replace(/&quot;/g, '"').replace(/&#39;/g, "'")
.replace(/\s+/g, ' ').trim();
}
/**
* description CDATA(HTML표) → 속성 배열. 행/셀 단위로 견고하게 파싱.
* 구글어스 재저장본은 `<tbody>`, `</b>` 뒤 `<br>`, `&gt;` 엔티티 등 변형이 있어
* `<tr>` 안의 두 `<td>` 셀에서 태그를 벗겨 (키, 값)으로 만든다(옛/새 형식 모두 처리).
*/
function parseKmlDescProps(descHtml: string): { k: string; v: string }[] {
const out: { k: string; v: string }[] = [];
const trRe = /<tr[^>]*>([\s\S]*?)<\/tr>/gi;
let tr: RegExpExecArray | null;
while ((tr = trRe.exec(descHtml))) {
const tds = [...tr[1].matchAll(/<td[^>]*>([\s\S]*?)<\/td>/gi)].map((m) => stripHtml(m[1]));
if (tds.length >= 2 && tds[0]) out.push({ k: tds[0], v: tds[1] });
}
return out;
}
/** 폴더 내 직계 자식 중 localName 일치 첫 요소. */
function childLocal(el: Element, local: string): Element | null {
for (const c of Array.from(el.children)) if (c.localName === local) return c;
return null;
}
/** KMZ/KML 파싱 — 있으면 { pois, structures }, 없거나 실패 시 null.
* bare .kml(구글 직접 다운로드) 우선, 없으면 .kmz(zip) 해제. */
export async function parseKmz(
files: File[],
): Promise<{ pois: GeoPoint[]; structures: RouteStructure[] } | null> {
const kmlFile = files.find(
(f) => !isInBuilding(f) && /\.kml$/i.test(baseNameOf(relPath(f))),
);
const kmzFile = files.find(
(f) => !isInBuilding(f) && /\.kmz$/i.test(baseNameOf(relPath(f))),
);
if (!kmlFile && !kmzFile) return null;
let kmlText: string;
try {
if (kmlFile) {
kmlText = await kmlFile.text(); // 구글 KML 은 UTF-8
} else {
const buf = new Uint8Array(await kmzFile!.arrayBuffer());
const entries = unzipSync(buf);
const kmlName =
Object.keys(entries).find((n) => /(^|\/)doc\.kml$/i.test(n)) ??
Object.keys(entries).find((n) => /\.kml$/i.test(n));
if (!kmlName || !entries[kmlName]) return null;
kmlText = strFromU8(entries[kmlName]);
}
} catch (e) {
console.warn('[KMZ/KML] 읽기 실패 → CSV 폴백', e);
return null;
}
const doc = new DOMParser().parseFromString(kmlText, 'application/xml');
if (doc.getElementsByTagName('parsererror').length) return null;
const pois: GeoPoint[] = [];
const structures: RouteStructure[] = [];
let nStruct = 0;
const handlePlacemark = (pm: Element, folder: string): void => {
const name = childLocal(pm, 'name')?.textContent?.trim() ?? '';
const desc = childLocal(pm, 'description')?.textContent ?? '';
const props = parseKmlDescProps(desc);
const pget = (k: string): string | undefined => props.find((p) => p.k === k)?.v;
// 좌표: <coordinates> lon,lat[,alt] 우선, 없으면 속성 lat/lon.
let lat = NaN, lon = NaN;
const coordEl = pm.getElementsByTagName('coordinates')[0];
if (coordEl?.textContent) {
const p = coordEl.textContent.trim().split(/[\s,]+/).map(Number);
if (p.length >= 2 && isFinite(p[0]) && isFinite(p[1])) { lon = p[0]; lat = p[1]; }
}
if (isNaN(lat) || isNaN(lon)) {
lat = parseFloat(pget('lat') ?? pget('latitude') ?? '');
lon = parseFloat(pget('lon') ?? pget('longitude') ?? '');
}
if (isNaN(lat) || isNaN(lon) || lat < 33 || lat > 39 || lon < 124 || lon > 132) return;
const lenNum = parseFloat(pget('연장(m)') ?? '');
if (folder.includes('교량')) {
structures.push({ id: `교량-${nStruct++}`, type: 'bridge', category: '교량', name: pget('구분') || name, lat, lon, ...(isNaN(lenNum) ? {} : { lengthM: lenNum }), ...(pget('시설종별') ? { grade: pget('시설종별') } : {}), props });
} else if (folder.includes('터널')) {
structures.push({ id: `터널-${nStruct++}`, type: 'tunnel', category: '터널', name: pget('구분') || name, lat, lon, ...(isNaN(lenNum) ? {} : { lengthM: lenNum }), ...(pget('시설종별') ? { grade: pget('시설종별') } : {}), props });
} else if (folder.includes('구교')) {
structures.push({ id: `구교-${nStruct++}`, type: 'bridge', category: '구교', name: pget('시설물명') || name, lat, lon, ...(isNaN(lenNum) ? {} : { lengthM: lenNum }), props });
} else if (folder.includes('출입문')) {
const title = pget('출입문번호') || name;
if (title) pois.push({ title, category: '출입문', lat, lon, z: parseFloat(pget('z') ?? pget('절대고도') ?? '0') || 0, type: 'poi', props });
} else {
// 02)지장물 등 = 지오코딩 POI. 모든 POI 는 영상에 표출(철도역 포함).
if (!name) return;
const src = pget('source');
const cat = pget('category_clean') || '지장물';
// 라벨: KML '구분' → 'title' → (없으면) placemark name 순.
const label = pget('구분') || pget('title') || name;
pois.push({ title: label, category: cat, lat, lon, z: parseFloat(pget('z') ?? pget('Z좌표') ?? pget('절대고도') ?? '0') || 0, type: 'poi', props });
// 철도역(KAKAO_RAIL)은 영상 POI 에 더해 '역사'로 하단 스테이션바에도 표출(lat/lon 으로 배치).
if (src === 'KAKAO_RAIL' || cat === '철도역' || cat === '역사') {
structures.push({ id: `역사-${nStruct++}`, type: 'station', category: '역사', name: label, lat, lon, props });
}
}
};
// 폴더 트리 재귀 — Placemark 가 직접 속한 (가장 안쪽) 폴더명을 folder 로 전달.
const walk = (el: Element, folder: string): void => {
for (const c of Array.from(el.children)) {
if (c.localName === 'Folder') {
walk(c, childLocal(c, 'name')?.textContent?.trim() ?? folder);
} else if (c.localName === 'Document') {
walk(c, folder);
} else if (c.localName === 'Placemark') {
handlePlacemark(c, folder);
}
}
};
if (doc.documentElement) walk(doc.documentElement, '');
return { pois, structures };
}
// ── route.json 보정 ───────────────────────────────────────────────────
/**
* 폴더 보조 파일 route.json 파싱 (UTF-8, JSON).
* `<base>.route.json` 우선(case-insensitive), 없으면 `route.json`.
* building/ 제외, 루트 파일만. 파싱 실패 시 null.
*/
export async function parseRouteMeta(
files: File[],
baseName: string | null,
): Promise<RouteMeta | null> {
const rootJson = files.filter(
(f) => !isInBuilding(f) && /\.json$/i.test(baseNameOf(relPath(f))),
);
if (!rootJson.length) return null;
const wantBase = baseName ? `${baseName}.route.json`.toLowerCase() : null;
const file =
(wantBase &&
rootJson.find((f) => baseNameOf(relPath(f)).toLowerCase() === wantBase)) ||
rootJson.find((f) => baseNameOf(relPath(f)).toLowerCase() === 'route.json');
if (!file) return null;
try {
const text = await file.text();
return JSON.parse(text) as RouteMeta;
} catch {
return null;
}
}
/** 이름 정규화(괄호 이하 제거) — route.json ↔ CSV 구조물 매칭용. */
function structBaseName(name: string): string {
return name.replace(/\s*[(].*$/, '').trim();
}
/**
* CSV 유래 구조물에 route.json structures 의 offset/station/이정 등을 보정(augment)한다.
* name 이 일치(괄호 이하 제거 후 부분일치)하는 항목의 값으로 override 하고,
* CSV 에 없는 구조물(route.json 전용)은 그대로 추가한다.
*/
export function mergeStructures(
csvStructures: RouteStructure[],
meta: RouteMeta | null,
): RouteStructure[] {
const metaList = meta?.structures;
if (!metaList || !metaList.length) return csvStructures;
const result = csvStructures.map((s) => ({ ...s }));
const used = new Set<number>();
for (const m of metaList) {
const mBase = structBaseName(m.name);
const idx = result.findIndex((s) => {
const sBase = structBaseName(s.name);
return sBase === mBase || sBase.includes(mBase) || mBase.includes(sBase);
});
if (idx >= 0) {
// 보정: route.json 에 명시된 필드만 override (CSV 좌표/연장은 유지).
const tgt = result[idx];
if (m.station != null) tgt.station = m.station;
if (m.offset != null) tgt.offset = m.offset;
if (m.startMileage != null) tgt.startMileage = m.startMileage;
if (m.endMileage != null) tgt.endMileage = m.endMileage;
if (m.lat != null) tgt.lat = m.lat;
if (m.lon != null) tgt.lon = m.lon;
used.add(idx);
} else {
// CSV 에 없는 route.json 전용 구조물 → 그대로 추가.
result.push({ ...m });
}
}
return result;
}
// ── POI 위치 보정 (마우스 드래그 저장값) ──────────────────────────────
/**
* 폴더에서 POI 보정 파일을 읽는다. `<base>_poi_overrides.json` 우선,
* 없으면 `poi_overrides.json`. building/ 제외, 루트만. 파싱 실패 시 {}.
* 형식: { baseName?, overrides: { "<title>": {lat,lon,z}, ... } } (또는 평면 맵).
*/
export async function parsePoiOverrides(
files: File[],
baseName: string | null,
): Promise<PoiOverrideMap> {
const rootJson = files.filter(
(f) => !isInBuilding(f) && /_poi_overrides\.json$/i.test(baseNameOf(relPath(f))),
);
if (!rootJson.length) return {};
const wantBase = baseName ? `${baseName}_poi_overrides.json`.toLowerCase() : null;
const file =
(wantBase && rootJson.find((f) => baseNameOf(relPath(f)).toLowerCase() === wantBase)) ||
rootJson[0];
try {
const obj = JSON.parse(await file.text());
const map = (obj && typeof obj === 'object' && obj.overrides) ? obj.overrides : obj;
const out: PoiOverrideMap = {};
for (const [title, v] of Object.entries(map as Record<string, unknown>)) {
const o = v as { lat?: number; lon?: number; z?: number };
if (o && isFinite(o.lat as number) && isFinite(o.lon as number)) {
out[title] = { lat: o.lat as number, lon: o.lon as number, z: Number(o.z) || 0 };
}
}
return out;
} catch {
return {};
}
}
/** 보정맵을 POI 배열에 적용 (title 일치 시 lat/lon/z 교체). 원본 불변. */
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;
});
}
// ── 중심선 생성 (v2.0엔 center.csv 없음) ──────────────────────────────
/**
* 측점을 측점값(mileage) 순으로 정렬한 폴리라인을 중심선으로 생성한다.
* v2.0엔 center.csv 가 없으므로 측점(01)측점)이 중심선의 소스다.
* 이 중심선이 체이니지 투영/RoutePanel 에 쓰인다.
*/
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 }));
}
// ── 통합 로더 ─────────────────────────────────────────────────────────
/**
* 폴더 내 파일에서 영상 + 지리정보(v2.0 형상)를 모두 파싱한다.
* 반환값을 geoStore.loadFromFolder 가 스토어에 적재한다.
*/
export async function loadFolderGeoData(
input: FileList | File[],
): Promise<FolderGeoData> {
const files = Array.from(input);
const videoFile = findVideoFile(files);
const baseName = deriveBaseName(videoFile);
const [frames, stationResult, routeMeta, poiOverrides, kmz] = await Promise.all([
parseDroneFrames(files, baseName),
parseStations(files),
parseRouteMeta(files, baseName),
parsePoiOverrides(files, baseName),
parseKmz(files),
]);
// 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),
);
const directionChanges = stationResult.directionChanges;
// route.json 은 선택적 보정 레이어 — 있으면 구조물에 override/augment.
// 철도역(역사)은 KMZ의 KAKAO_RAIL placemark에서 구조물로 생성됨(스테이션바 표출).
const structures = mergeStructures(baseStructures, routeMeta);
// v2.0엔 center.csv 가 없다 → 측점 폴리라인으로 중심선 생성.
const centerline = buildCenterlineFromStations(stations);
let origin: GeoOrigin | null = null;
if (stations.length || frames.length) {
origin = getWorldOrigin(frames, [...stations, ...pois]);
} else if (centerline.length) {
origin = { lat: centerline[0].lat, lon: centerline[0].lon, alt: centerline[0].z };
}
return {
videoFile,
baseName,
frames,
pois,
stations,
centerline,
origin,
routeMeta,
structures,
directionChanges,
poiOverrides,
kmzMissing,
};
}
+427
View File
@@ -0,0 +1,427 @@
/**
* 클라이언트 사이드 3D 좌표 변환 투영
*
* Python advanced_tuner_v2.py 와 동일한 알고리즘:
* R_b2w = Rz(-yaw) * Rx(pitch) * Ry(roll)
* R_align = [[1,0,0],[0,0,-1],[0,1,0]]
* R_w2c = R_align @ R_b2w.T
*
* 좌표계: EPSG:5186 TM [East(m), North(m), Up(m)]
* Python swap_xy=ON 과 동일: easting=X, northing=Y
* sensorH 기본값 20.25mm = 36 × (9/16), 16:9 동영상 기준
*/
import proj4 from 'proj4';
export interface DroneFrameBasic {
frame: number;
lat: number;
lon: number;
altitude: number;
yaw: number;
pitch: number;
roll: number;
focalLen: number;
}
/**
* 카메라 파라미터 — Python advanced_tuner_v2.py 기본값 기준
*
* yaw / pitch / roll 은 모두 SRT 프레임값에 더하는 오프셋 (기본 0).
* Python: pitch = radians(meta['pitch'] + spn_pitch.value()) ← spn_pitch 기본 0
* focalLen / sensorW / sensorH 는 Python spn_focal(24) / spn_sensor(36) 기본값.
* offX/offY/offZ: 드론 위치 보정 — Python off_x/y/z 동치.
*/
export interface CameraParams {
yawOffset: number; // yaw 오프셋 (degrees, per-frame SRT yaw에 더함)
pitch: number; // pitch 오프셋 (degrees, per-frame SRT pitch에 더함, 기본 0)
roll: number; // roll 오프셋 (degrees, per-frame SRT roll에 더함, 기본 0)
focalLen: number; // 초점거리 (35mm 환산 mm, 기본 24)
cx0: number; // 주점 X 오프셋 (정규화)
cy0: number; // 주점 Y 오프셋 (정규화)
offX: number; // 드론 위치 East 보정 (m, 기본 0)
offY: number; // 드론 위치 North 보정 (m, 기본 0)
offZ: number; // 드론 위치 Up 보정 (m, 기본 0)
geoidOffset: number; // 지오이드고 (m) — 대상 정표고(EL)→타원체고 변환. 드론 abs_alt(타원체고)와 datum 일치용. 대전≈25.8
poiZOffset: number; // POI 전용 표고 보정 (m) — POI/구조물 마커만 들어올림. 측점·중심선엔 미적용.
// POI는 실제 표고 데이터가 없어 선로 지면 높이로 가정됨 → 건물/지형이 선로보다 높을 때 보정.
sensorW: number; // 센서 폭 (mm, 기본 36)
sensorH: number; // 센서 높이 (mm, 기본 20.25 = 36×9/16, 16:9 영상)
}
/** Python advanced_tuner_v2.py 기본값 */
export const DEFAULT_CAMERA_PARAMS: CameraParams = {
yawOffset: 0,
pitch: 0, // offset, Python spn_pitch 기본값 0
roll: 0, // offset, Python spn_roll 기본값 0
focalLen: 24, // Python spn_focal 기본값 24
cx0: 0,
cy0: 0,
offX: 0,
offY: 0,
offZ: 0,
geoidOffset: 25.8, // 대전 KNGeoid18 ≈ 25.8m (정표고 → 타원체고)
poiZOffset: 0, // POI 표고 보정 기본 0 (건물 높이/지형차에 맞춰 패널에서 조정)
sensorW: 36, // Python spn_sensor 기본값 36
sensorH: 20.25,
};
/** 항상 DEFAULT_CAMERA_PARAMS 반환 (Python 방식: SRT 값은 per-frame으로 자동 적용됨) */
export function paramsFromFrame(_frame: DroneFrameBasic): CameraParams {
return { ...DEFAULT_CAMERA_PARAMS };
}
// EPSG:5186 Korean TM 정의 (Python pyproj와 동일)
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');
/** 위경도 → EPSG:5186 TM [easting(m), northing(m)] */
function latLonToTM(lat: number, lon: number): [number, number] {
// proj4: forward(lon, lat) → [easting, northing]
const [e, n] = _toTM.forward([lon, lat]);
return [e, n];
}
function toRad(d: number) { return d * Math.PI / 180; }
/** 위경도+표고 → 월드 [East, North, Up] (m).
* Python swap_xy=ON 방식: EPSG:5186 TM easting/northing + altitude */
function geoToEnu(
lat: number, lon: number, alt: number,
_refLat: number, _refLon: number, refAlt: number,
): [number, number, number] {
const [e, n] = latLonToTM(lat, lon);
return [e, n, alt - refAlt];
}
export interface ProjectResult {
px: number; // 0~1, 0=왼쪽 (클램프됨)
py: number; // 0~1, 0=위 (클램프됨)
pxRaw: number; // 클램프 없는 원본
pyRaw: number;
dist: number; // 수평 거리 (m)
h: number; // 수평각 (degrees)
v: number; // 수직각 (degrees)
inFov: boolean;
}
type Vec3 = [number, number, number];
/** 카메라 좌표 (Zc 부호 체크 없음 — 근거리 클리핑은 호출자가 처리) */
export interface CameraCoords {
Xc: number;
Yc: number;
Zc: number;
/** 수평(평면) 거리 (m) — 고도차 무시한 동·북 거리. POI 거리필터용. toCameraCoords가 채움. */
distH?: number;
/** 진행방향(yaw) 기준 앞쪽 수평거리 (m, +앞). 앞/옆 비등방 거리필터용. */
fwd?: number;
/** 진행방향 기준 옆쪽(좌우) 수평거리 (m, 부호=좌우). 필터는 |side| 사용. */
side?: number;
}
/** 카메라 좌표 → 정규화 픽셀 (Zc > 0 보장 후 호출) */
export function pixelFromCamera(
cc: CameraCoords,
params: CameraParams,
): { pxRaw: number; pyRaw: number } {
const f = params.focalLen;
const sW = params.sensorW ?? 36;
const sH = params.sensorH ?? 20.25;
return {
pxRaw: (0.5 + params.cx0) + (cc.Xc / cc.Zc) * (f / sW),
pyRaw: (0.5 + params.cy0) + (cc.Yc / cc.Zc) * (f / sH),
};
}
/**
* 역투영 — 화면 정규픽셀(px,py) + 현재 카메라에서 월드 좌표(lat/lon/z)를 복원.
* 2D→3D는 깊이가 모호하므로 **슬랜트 거리(range)를 유지**한다. 한 번의 드래그로
* 수평(lat/lon)과 수직(z)이 함께 바뀐다(POI 마우스 보정용).
*
* px = 0.5 + (Xc/Zc)·(f/sW), py = 0.5 + (Yc/Zc)·(f/sH) 의 역:
* 카메라방향 dir ∝ ((px-0.5)·sW/f, (py-0.5)·sH/f, 1) → range로 정규화 → (Xc,Yc,Zc)
* rel = R_w2cᵀ·(Xc,Yc,Zc), stEnu = drEnuAdj + rel
* z(정표고) = camera.alt + offZ + rel_up geoid (투영이 다시 geoid를 더함)
*/
export function worldFromPixel(
camera: DroneFrameBasic,
px: number, py: number, range: number,
params: CameraParams,
ref?: { lat: number; lon: number; alt: number },
): { lat: number; lon: number; z: number } {
const f = params.focalLen, sW = params.sensorW ?? 36, sH = params.sensorH ?? 20.25;
// 1) 카메라 프레임 방향 → range 로 스케일
const dx = ((px - 0.5) - (params.cx0 ?? 0)) * sW / f;
const dy = ((py - 0.5) - (params.cy0 ?? 0)) * sH / f;
const dz = 1;
const len = Math.sqrt(dx * dx + dy * dy + dz * dz);
const Xc = dx / len * range, Yc = dy / len * range, Zc = dz / len * range;
// 2) rel = R_w2cᵀ · cam. (R_w2c row0=col0(b2w), row1=-col2(b2w), row2=col1(b2w))
const b2w = buildRotation(camera, params);
const w2c: [Vec3, Vec3, Vec3] = [
[b2w[0][0], b2w[1][0], b2w[2][0]],
[-b2w[0][2], -b2w[1][2], -b2w[2][2]],
[b2w[0][1], b2w[1][1], b2w[2][1]],
];
const cam: Vec3 = [Xc, Yc, Zc];
const rel: Vec3 = [
w2c[0][0] * cam[0] + w2c[1][0] * cam[1] + w2c[2][0] * cam[2],
w2c[0][1] * cam[0] + w2c[1][1] * cam[1] + w2c[2][1] * cam[2],
w2c[0][2] * cam[0] + w2c[1][2] * cam[1] + w2c[2][2] * cam[2],
];
// 3) 월드 ENU. drEnu[0],[1] = 절대 TM easting/northing
const refPt = ref ?? { lat: camera.lat, lon: camera.lon, alt: camera.altitude };
const drEnu = geoToEnu(camera.lat, camera.lon, camera.altitude, refPt.lat, refPt.lon, refPt.alt);
const E = drEnu[0] + (params.offX ?? 0) + rel[0];
const N = drEnu[1] + (params.offY ?? 0) + rel[1];
const z = camera.altitude + (params.offZ ?? 0) + rel[2] - (params.geoidOffset ?? 0);
const [lon, lat] = _toTM.inverse([E, N]);
return { lat, lon, z };
}
/**
* 표고 역산 — POI의 lat/lon은 고정하고, 화면 세로위치(pyTarget)에 맞는 z(정표고)를 구한다.
* "POI가 진행방향으로 앞에 밀려 보이는(표고 과대) 슬라이드" 보정용: 드래그로 실제 지면에
* 내려놓으면 그 z가 저장되어 전 거리에서 정렬된다.
*
* py = 0.5 + cy0 + (Yc/Zc)·(f/sH). Yc,Zc 는 z에 1차(rel_up 가 z에 비례) →
* Yc=Yc0+dYc·u, Zc=Zc0+dZc·u (u=zz0), R=(py0.5cy0)·sH/f=Yc/Zc 풀이:
* u = (R·Zc0 Yc0)/(dYc R·dZc)
*/
export function solveZForPixelY(
camera: DroneFrameBasic,
lat: number, lon: number, z0: number, pyTarget: number,
params: CameraParams,
ref?: { lat: number; lon: number; alt: number },
): number {
const cc0 = toCameraCoords(camera, lat, lon, z0, params, ref);
const b2w = buildRotation(camera, params);
const dXc = b2w[2][0]; // col0 Up 성분
const dYc = -b2w[2][2]; // -col2 Up
const dZc = b2w[2][1]; // col1 Up
void dXc;
const f = params.focalLen, sH = params.sensorH ?? 20.25;
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;
}
/**
* 화면점 → 지면 좌표(lat/lon). 카메라 광선을 표고 zGround 의 수평면과 교차.
* POI 좌표(지오코딩) 오차 보정용: 드래그한 지면 지점의 lat/lon 을 돌려줌(z는 유지).
* 가까운/내려보는 프레임(가파른 광선)에서 정확. 먼 프레임(얕은 광선)은 민감 → 부정확.
*/
export function groundPointFromPixel(
camera: DroneFrameBasic,
px: number, py: number, zGround: number,
params: CameraParams,
ref?: { lat: number; lon: number; alt: number },
): { lat: number; lon: number } | null {
const f = params.focalLen, sW = params.sensorW ?? 36, sH = params.sensorH ?? 20.25;
const dxc = ((px - 0.5) - (params.cx0 ?? 0)) * sW / f;
const dyc = ((py - 0.5) - (params.cy0 ?? 0)) * sH / f;
const cam: Vec3 = [dxc, dyc, 1];
const b2w = buildRotation(camera, params);
const w2c: [Vec3, Vec3, Vec3] = [
[b2w[0][0], b2w[1][0], b2w[2][0]],
[-b2w[0][2], -b2w[1][2], -b2w[2][2]],
[b2w[0][1], b2w[1][1], b2w[2][1]],
];
// 월드 ENU 방향 dir = R_w2cᵀ · cam
const dir: Vec3 = [
w2c[0][0] * cam[0] + w2c[1][0] * cam[1] + w2c[2][0] * cam[2],
w2c[0][1] * cam[0] + w2c[1][1] * cam[1] + w2c[2][1] * cam[2],
w2c[0][2] * cam[0] + w2c[1][2] * cam[1] + w2c[2][2] * cam[2],
];
if (Math.abs(dir[2]) < 1e-9) return null;
// 광선 rel = t·dir, 목표면 rel_up = (zGround+geoid) droneAlt offZ
const refPt = ref ?? { lat: camera.lat, lon: camera.lon, alt: camera.altitude };
const drEnu = geoToEnu(camera.lat, camera.lon, camera.altitude, refPt.lat, refPt.lon, refPt.alt);
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 };
}
// ── 공통 내부 계산 ────────────────────────────────────────────────────────────
function buildRelEnu(
camera: DroneFrameBasic,
targetLat: number, targetLon: number, targetAlt: number,
params: CameraParams,
ref?: { lat: number; lon: number; alt: number },
): { relEnu: Vec3; dist: number } {
const refPt = ref ?? { lat: camera.lat, lon: camera.lon, alt: camera.altitude };
// 대상 정표고(EL) + 지오이드고 → 타원체고. 드론 abs_alt(타원체고)와 datum 일치.
const stEnu = geoToEnu(targetLat, targetLon, targetAlt + (params.geoidOffset ?? 0), refPt.lat, refPt.lon, refPt.alt);
const drEnu = geoToEnu(camera.lat, camera.lon, camera.altitude, refPt.lat, refPt.lon, refPt.alt);
const drEnuAdj: Vec3 = [
drEnu[0] + (params.offX ?? 0),
drEnu[1] + (params.offY ?? 0),
drEnu[2] + (params.offZ ?? 0),
];
const relEnu: Vec3 = [stEnu[0] - drEnuAdj[0], stEnu[1] - drEnuAdj[1], stEnu[2] - drEnuAdj[2]];
const dist = Math.sqrt(relEnu[0] ** 2 + relEnu[1] ** 2);
return { relEnu, dist };
}
function buildRotation(camera: DroneFrameBasic, params: CameraParams): [Vec3, Vec3, Vec3] {
const yaw = toRad(camera.yaw + params.yawOffset);
const pitch = toRad(camera.pitch + params.pitch);
const roll = toRad(camera.roll + params.roll);
const cy = Math.cos(yaw), sy = Math.sin(yaw);
const cp = Math.cos(pitch), sp = Math.sin(pitch);
const cr = Math.cos(roll), sr = Math.sin(roll);
return [
[ cy*cr + sy*sp*sr, sy*cp, cy*sr - sy*sp*cr],
[-sy*cr + cy*sp*sr, cy*cp, -sy*sr - cy*sp*cr],
[ -cp*sr, sp, cp*cr ],
];
}
function applyRw2c(b2w: [Vec3, Vec3, Vec3], rel: Vec3): CameraCoords {
return {
Xc: b2w[0][0]*rel[0] + b2w[1][0]*rel[1] + b2w[2][0]*rel[2],
Yc: -(b2w[0][2]*rel[0] + b2w[1][2]*rel[1] + b2w[2][2]*rel[2]),
Zc: b2w[0][1]*rel[0] + b2w[1][1]*rel[1] + b2w[2][1]*rel[2],
};
}
/**
* 카메라 좌표만 반환 (Zc 체크 없음).
* 선로 중심선 근거리 클리핑(Python 방식)에 사용.
*/
export function toCameraCoords(
camera: DroneFrameBasic,
targetLat: number, targetLon: number, targetAlt: number,
params: CameraParams,
ref?: { lat: number; lon: number; alt: number },
): CameraCoords {
const { relEnu, dist } = buildRelEnu(camera, targetLat, targetLon, targetAlt, params, ref);
const b2w = buildRotation(camera, params);
const cc = applyRw2c(b2w, relEnu);
cc.distH = dist; // 수평(평면) 거리 — 고도차 무시
// 진행방향(yaw, 북=0 시계방향) 기준 앞/옆 수평성분. heading=(sinθ[E],cosθ[N]).
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; // 우(+)/좌(-)
return cc;
}
/**
* Python advanced_tuner_v2.py 와 동일한 투영 공식
*
* 회전 행렬:
* R_b2w = Rz(-yaw) × Rx(pitch) × Ry(roll)
* R_align = [[1,0,0],[0,0,-1],[0,1,0]] (body→camera 축 변환)
* R_w2c = R_align × R_b2w.T
*
* 투영:
* pts_cam = R_w2c × rel_enu
* u_norm = 0.5 + (Xc/Zc) × (f/sensorW)
* v_norm = 0.5 + (Yc/Zc) × (f/sensorH)
*
* 드론 위치 오프셋 (off_x/y/z): 카메라 위치를 ENU 공간에서 보정
*/
export function projectPoint(
camera: DroneFrameBasic,
targetLat: number,
targetLon: number,
targetAlt: number,
params: CameraParams,
ref?: { lat: number; lon: number; alt: number },
): ProjectResult | null {
const refPt = ref ?? { lat: camera.lat, lon: camera.lon, alt: camera.altitude };
// 1. 월드 ENU (m) — 대상 정표고 + 지오이드고 → 타원체고 (드론 abs_alt와 datum 일치)
const stEnu = geoToEnu(targetLat, targetLon, targetAlt + (params.geoidOffset ?? 0), refPt.lat, refPt.lon, refPt.alt);
const drEnu = geoToEnu(camera.lat, camera.lon, camera.altitude, refPt.lat, refPt.lon, refPt.alt);
// 드론 위치 보정 적용 (Python: drone_pos = [dx+off_x, dy+off_y, alt+off_z])
const drEnuAdj: Vec3 = [
drEnu[0] + (params.offX ?? 0),
drEnu[1] + (params.offY ?? 0),
drEnu[2] + (params.offZ ?? 0),
];
const relEnu: Vec3 = [
stEnu[0] - drEnuAdj[0],
stEnu[1] - drEnuAdj[1],
stEnu[2] - drEnuAdj[2],
];
const dist = Math.sqrt(relEnu[0] ** 2 + relEnu[1] ** 2);
// 2. 회전 행렬 (Python 방식: Rz(-yaw)*Rx(pitch)*Ry(roll), 모두 라디안)
// Python: yaw=radians(meta['yaw']+off_yaw), pitch=radians(meta['pitch']+off_pitch), ...
const yaw = toRad(camera.yaw + params.yawOffset);
const pitch = toRad(camera.pitch + params.pitch); // SRT per-frame + offset
const roll = toRad(camera.roll + params.roll); // SRT per-frame + offset
const cy = Math.cos(yaw), sy = Math.sin(yaw);
const cp = Math.cos(pitch), sp = Math.sin(pitch);
const cr = Math.cos(roll), sr = Math.sin(roll);
// Rz(-yaw): rotation around Z by -yaw
// [[cy, sy, 0], [-sy, cy, 0], [0, 0, 1]]
// Rx(pitch): rotation around X by pitch
// [[1, 0, 0], [0, cp, -sp], [0, sp, cp]]
// Ry(roll): rotation around Y by roll
// [[cr, 0, sr], [0, 1, 0], [-sr, 0, cr]]
//
// R_b2w = Rz(-yaw) * Rx(pitch) * Ry(roll)
// Computed element by element:
const b2w: [Vec3, Vec3, Vec3] = [
[
cy*cr + sy*sp*sr, sy*cp, cy*sr - sy*sp*cr,
],
[
-sy*cr + cy*sp*sr, cy*cp, -sy*sr - cy*sp*cr,
],
[
-cp*sr, sp, cp*cr,
],
];
// R_w2c = R_align @ R_b2w.T (R_align = [[1,0,0],[0,0,-1],[0,1,0]])
//
// R_w2c rows are derived from columns of R_b2w:
// R_w2c row 0 = col 0 of R_b2w (R_align row 0 = [1,0,0])
// R_w2c row 1 = -(col 2 of R_b2w) (R_align row 1 = [0,0,-1])
// R_w2c row 2 = col 1 of R_b2w (R_align row 2 = [0,1,0])
//
// p_cam = R_w2c @ relEnu → access b2w columns (swap first index across rows)
const Xc = b2w[0][0]*relEnu[0] + b2w[1][0]*relEnu[1] + b2w[2][0]*relEnu[2]; // col 0
const Yc = -(b2w[0][2]*relEnu[0] + b2w[1][2]*relEnu[1] + b2w[2][2]*relEnu[2]); // -col 2
const Zc = b2w[0][1]*relEnu[0] + b2w[1][1]*relEnu[1] + b2w[2][1]*relEnu[2]; // col 1
if (Zc <= 0) return null;
// 3. 핀홀 투영 (Python: u=f_px*(Xc/Zc)+w/2, v=f_px*(Yc/Zc)+h/2)
const f = params.focalLen;
const sW = params.sensorW ?? 36;
const sH = params.sensorH ?? 20.25;
const pxRaw = (0.5 + params.cx0) + (Xc / Zc) * (f / sW);
const pyRaw = (0.5 + params.cy0) + (Yc / Zc) * (f / sH);
return {
px: Math.max(0, Math.min(1, pxRaw)),
py: Math.max(0, Math.min(1, pyRaw)),
pxRaw,
pyRaw,
dist,
h: Math.atan2(Xc, Zc) * (180 / Math.PI),
v: Math.atan2(-Yc, Zc) * (180 / Math.PI),
inFov: pxRaw >= 0 && pxRaw <= 1 && pyRaw >= 0 && pyRaw <= 1,
};
}
+255
View File
@@ -0,0 +1,255 @@
/**
* 클라이언트 지리정보 검색 알고리즘
*
* 서버 server/src/services/geoMatch.ts 의 검색/투영 로직을 그대로 포팅한다.
* (검색 UI 결과를 서버 응답과 동일하게 유지하기 위해, 오버레이용 proj4 기반
* geoProjection.ts 와는 별개의 단순 ENU(cos-lat 근사) 투영을 사용한다.)
*
* 싱글턴 캐시(loadFrames/loadPois) 대신 데이터를 인자로 받는 순수 함수로 변경했다.
* 입력 데이터는 geoStore 에서 읽어 넘긴다.
*/
import type {
DroneFrame,
GeoPoint,
FrameMatch,
PoiInFrame,
GeoOrigin,
} from '../types/geo';
// ── 카메라/지구 상수 (geoMatch.ts 동일) ──────────────────────────────
const SENSOR_W_MM = 36;
const SENSOR_H_MM = 24;
const R_EARTH = 6371000;
const DEFAULT_FPS = 30;
const GAP = 30;
export function toRad(deg: number): number {
return (deg * Math.PI) / 180;
}
/** 위경도+표고 → 월드 ENU (m). refLat/refLon/refAlt = 원점. geoMatch.ts:78-87 */
export function geoToEnu(
lat: number, lon: number, alt: number,
refLat: number, refLon: number, refAlt: number,
): [number, number, number] {
const cosRef = Math.cos(toRad(refLat));
const e = toRad(lon - refLon) * cosRef * R_EARTH; // East (m)
const n = toRad(lat - refLat) * R_EARTH; // North (m)
const u = alt - refAlt; // Up (m)
return [e, n, u];
}
/** 월드 ENU 벡터 + 카메라 자세 → 정규화 픽셀. geoMatch.ts:90-124 */
export function projectEnu(
relEnu: [number, number, number],
yawDeg: number,
pitchDeg: number,
focalMm: number,
yawOffset = 0,
): { px: number; py: number; cx: number; cy: number; cz: number } | null {
const yaw = toRad(yawDeg + yawOffset);
const pitch = toRad(pitchDeg);
const cosY = Math.cos(yaw), sinY = Math.sin(yaw);
const cosP = Math.cos(pitch), sinP = Math.sin(pitch);
const fwd: readonly [number, number, number] = [sinY * cosP, cosY * cosP, sinP];
const right: readonly [number, number, number] = [cosY, -sinY, 0];
const up: readonly [number, number, number] = [
right[1] * fwd[2] - right[2] * fwd[1],
right[2] * fwd[0] - right[0] * fwd[2],
right[0] * fwd[1] - right[1] * fwd[0],
];
const dot = (a: readonly [number, number, number], b: readonly [number, number, number]) =>
a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
const cx = dot(relEnu, right);
const cy = dot(relEnu, up);
const cz = dot(relEnu, fwd);
if (cz <= 0) return null;
const f = focalMm || 24;
const px = 0.5 + (cx / cz) * (f / SENSOR_W_MM);
const py = 0.5 - (cy / cz) * (f / SENSOR_H_MM);
return { px, py, cx, cy, cz };
}
/** DroneFrame + POI + 공통 기준점 → 픽셀. geoMatch.ts:127-156 */
export function project3D(
drone: DroneFrame,
poi: { lat: number; lon: number; z: number },
yawOffset = 0,
origin?: GeoOrigin,
): { px: number; py: number; dist: number; h: number; v: number; inFov: boolean } | null {
const ref = origin ?? { lat: drone.lat, lon: drone.lon, alt: drone.altitude };
const stEnu = geoToEnu(poi.lat, poi.lon, poi.z, ref.lat, ref.lon, ref.alt);
const drEnu = geoToEnu(drone.lat, drone.lon, drone.altitude, ref.lat, ref.lon, ref.alt);
const relEnu: [number, number, number] = [
stEnu[0] - drEnu[0],
stEnu[1] - drEnu[1],
stEnu[2] - drEnu[2],
];
const dist = Math.sqrt(relEnu[0] ** 2 + relEnu[1] ** 2);
const res = projectEnu(relEnu, drone.yaw, drone.pitch, drone.focalLen || 24, yawOffset);
if (!res) return null;
const h = Math.atan2(res.cx, res.cz) * (180 / Math.PI);
const v = Math.atan2(res.cy, res.cz) * (180 / Math.PI);
const inFov = res.px >= 0 && res.px <= 1 && res.py >= 0 && res.py <= 1;
return {
px: Math.max(0, Math.min(1, res.px)),
py: Math.max(0, Math.min(1, res.py)),
dist, h, v, inFov,
};
}
/** 측점 km 정렬 키. geoMatch.ts:371-375 */
export function stationOrder(title: string): number {
const m = title.match(/(\d+)[Kk](\d+)/);
if (!m) return 0;
return parseInt(m[1]) * 1000 + parseInt(m[2]);
}
/** 첫 번째 측점 위치를 ENU 원점으로 반환. geoMatch.ts:378-387 */
export function getWorldOrigin(frames: DroneFrame[], pois: GeoPoint[]): GeoOrigin {
const stations = pois
.filter((p) => p.type === 'station')
.sort((a, b) => stationOrder(a.title) - stationOrder(b.title));
if (stations.length) {
return { lat: stations[0].lat, lon: stations[0].lon, alt: stations[0].z };
}
return frames[0]
? { lat: frames[0].lat, lon: frames[0].lon, alt: frames[0].altitude }
: { lat: 0, lon: 0, alt: 0 };
}
/**
* 건물/측점명 검색 → 매칭 POI + 카메라 시야 프레임 목록. geoMatch.ts:399-468
* pois 는 POI+측점 통합 배열을 넘긴다(검색 대상 전체).
*/
export function findFramesForPoi(
frames: DroneFrame[],
pois: GeoPoint[],
query: string,
marginFactor = 1.0,
maxDist = 2000,
yawOffset = 0,
origin?: GeoOrigin | null,
): { poi: GeoPoint | null; frames: FrameMatch[] } {
const q = query.trim().toLowerCase();
const poi = pois.find((p) => p.title.toLowerCase().includes(q));
if (!poi) return { poi: null, frames: [] };
const matches: FrameMatch[] = [];
const ref = origin ?? getWorldOrigin(frames, pois);
for (const f of frames) {
const res = project3D(f, poi, yawOffset, ref);
if (!res) continue;
if (res.dist > maxDist) continue;
const halfW = 0.5 * marginFactor;
const halfH = 0.5 * marginFactor;
const rawPx = 0.5 + (res.px - 0.5);
const rawPy = 0.5 + (res.py - 0.5);
if (Math.abs(rawPx - 0.5) > halfW || Math.abs(rawPy - 0.5) > halfH) continue;
matches.push({
frame: f.frame,
time: f.frame / DEFAULT_FPS,
bearingDiff: res.h,
elevationDiff: res.v,
distance: res.dist,
pixelX: res.px,
pixelY: res.py,
});
}
matches.sort((a, b) => a.frame - b.frame);
const groups: FrameMatch[][] = [];
let group: FrameMatch[] = [];
for (const m of matches) {
if (group.length === 0 || m.frame - group[group.length - 1].frame <= GAP) {
group.push(m);
} else {
groups.push(group);
group = [m];
}
}
if (group.length > 0) groups.push(group);
const best = groups.map((g) => {
const groupStart = g[0].frame;
const groupEnd = g[g.length - 1].frame;
g.sort(
(a, b) =>
a.bearingDiff ** 2 + a.elevationDiff ** 2 - (b.bearingDiff ** 2 + b.elevationDiff ** 2),
);
return { ...g[0], groupSize: g.length, groupStart, groupEnd };
});
best.sort(
(a, b) =>
a.bearingDiff ** 2 + a.elevationDiff ** 2 - (b.bearingDiff ** 2 + b.elevationDiff ** 2),
);
return { poi, frames: best };
}
/**
* 프레임 번호 → 해당 프레임에서 카메라 시야에 들어오는 POI/측점 목록. geoMatch.ts:473-512
*/
export function findPoisForFrame(
frames: DroneFrame[],
pois: GeoPoint[],
frameNum: number,
marginFactor = 1.0,
yawOffset = 0,
origin?: GeoOrigin | null,
): { droneFrame: DroneFrame | null; pois: PoiInFrame[] } {
const drone =
frames.find((f) => f.frame === frameNum) ??
(() => {
let best = frames[0];
let bestD = Math.abs((best?.frame ?? 0) - frameNum);
for (const f of frames) {
const d = Math.abs(f.frame - frameNum);
if (d < bestD) {
bestD = d;
best = f;
}
if (d === 0) break;
}
return best;
})();
if (!drone) return { droneFrame: null, pois: [] };
const result: PoiInFrame[] = [];
const ref = origin ?? getWorldOrigin(frames, pois);
for (const poi of pois) {
const res = project3D(drone, poi, yawOffset, ref);
if (!res) continue;
const halfW = 0.5 * marginFactor;
const halfH = 0.5 * marginFactor;
if (Math.abs(res.px - 0.5) > halfW || Math.abs(res.py - 0.5) > halfH) continue;
result.push({
poi,
bearingDiff: res.h,
elevationDiff: res.v,
distance: res.dist,
pixelX: res.px,
pixelY: res.py,
});
}
result.sort((a, b) => a.distance - b.distance);
return { droneFrame: drone, pois: result };
}
+28
View File
@@ -0,0 +1,28 @@
export function secondsToTimecode(seconds: number, separator = '.'): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
const ms = Math.round((seconds % 1) * 1000);
return [
h.toString().padStart(2, '0'),
m.toString().padStart(2, '0'),
s.toString().padStart(2, '0'),
].join(':') + separator + ms.toString().padStart(3, '0');
}
export function timecodeToSeconds(tc: string): number {
const parts = tc.replace(',', '.').split(':');
if (parts.length === 3) {
const [h, m, s] = parts;
return parseInt(h) * 3600 + parseInt(m) * 60 + parseFloat(s);
}
return parseFloat(tc);
}
export function frameToSeconds(frame: number, fps: number): number {
return frame / fps;
}
export function secondsToFrame(seconds: number, fps: number): number {
return Math.floor(seconds * fps);
}