GhiVideo 소스 복제 — v4 작업 시작 기준
기존 GhiVideo 저장소 HEAD의 트래킹 소스 362개 파일을 복제. (node_modules·storage·빌드 산출물·대용량 미디어는 .gitignore 규칙대로 제외) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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) };
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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(/>/g, '>').replace(/</g, '<').replace(/&/g, '&')
|
||||
.replace(/ /g, ' ').replace(/"/g, '"').replace(/'/g, "'")
|
||||
.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* description CDATA(HTML표) → 속성 배열. 행/셀 단위로 견고하게 파싱.
|
||||
* 구글어스 재저장본은 `<tbody>`, `</b>` 뒤 `<br>`, `>` 엔티티 등 변형이 있어
|
||||
* `<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,
|
||||
};
|
||||
}
|
||||
@@ -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=z−z0), R=(py−0.5−cy0)·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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user