From 87b66d9ed49dde1528b1f59a8f9a68f4d2215fa9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=95=9C=EC=84=B1=EC=9D=BC?= Date: Wed, 15 Jul 2026 16:27:03 +0900 Subject: [PATCH] =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=B4=ED=94=84=EB=9D=BC=EC=9D=B8=20=E2=80=94=20=EC=A0=84=20?= =?UTF-8?q?=EA=B5=AC=EA=B0=84=20=ED=94=84=EB=A0=88=EC=9E=84=C2=B7djmd=20dt?= =?UTF-8?q?=20=EC=A0=84=ED=8C=8C=C2=B7=EC=84=B8=EA=B7=B8=EB=A8=BC=ED=8A=B8?= =?UTF-8?q?=20=EC=8B=9C=ED=81=AC=C2=B7=EC=84=A0=ED=98=95=20=EA=B3=A1?= =?UTF-8?q?=EC=84=A0=20=ED=8F=89=ED=99=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - geoData: fullFrames(전체 비행 프레임 — 전 구간 측점 검색용, datum·초점 보정 동일 적용), DJI_VIRTUAL_FPS export, 선형 곡선 평활(Centripetal Catmull-Rom — 정점 ~6m 세분, 원 정점 전부 통과로 측점 정합 유지, 60m+ 직선·양끝 제외) - djmdTrack: refineFramesWithDjmd 가 교차상관 dt(초)도 반환 - geoStore: fullFrames·djmdDtSec 상태 — 검색의 CSV↔영상 시간 변환에 사용 - useVideoPlayer.loadLocalFile(startAtSec): 로드 후 지정 시각부터 시작 - VideoPlayer.handleSeekSegment: 측점 검색의 타 세그먼트 점프(전환+시크, 재생 상태 유지), station_lookahead 등 RouteInfo 타입 문서화 Co-Authored-By: Claude Fable 5 --- client/src/components/player/VideoPlayer.tsx | 18 +++++- client/src/hooks/useVideoPlayer.ts | 6 +- client/src/store/geoStore.ts | 12 +++- client/src/types/geo.ts | 6 ++ client/src/utils/djmdTrack.ts | 7 ++- client/src/utils/geoData.ts | 63 +++++++++++++++++++- 6 files changed, 102 insertions(+), 10 deletions(-) diff --git a/client/src/components/player/VideoPlayer.tsx b/client/src/components/player/VideoPlayer.tsx index 4c00f5e..29a6593 100644 --- a/client/src/components/player/VideoPlayer.tsx +++ b/client/src/components/player/VideoPlayer.tsx @@ -38,7 +38,7 @@ function collectDropEntry(entry: FileSystemEntry, out: File[]): Promise { } export interface VideoPlayerHandle { - loadLocalFile: (file: File) => void; + loadLocalFile: (file: File, autoPlay?: boolean, startAtSec?: number) => void; loadServerStream: (videoId: string, filename: string) => void | Promise; seekTo: (time: number) => void; getVideoElement: () => HTMLVideoElement | null; @@ -232,6 +232,21 @@ const VideoPlayer = forwardRef( } }; + // 측점 검색 전 구간 점프 — 다른 세그먼트의 통과 지점으로 전환+시크. + // 같은 세그먼트면 시크만, 다르면 loadSegment 후 해당 시각부터(재생 상태 유지). + const handleSeekSegment = async (i: number, timeSec: number): Promise => { + const geo = useGeoStore.getState(); + if (usePlayerStore.getState().source?.kind !== 'local') return; + if (i === geo.videoIndex) { + playerRef.current?.currentTime(timeSec); + return; + } + const wasPlaying = usePlayerStore.getState().playing; + const file = await geo.loadSegment(i); + if (!file) return; + loadLocalFile(file, wasPlaying, timeSec); + }; + // 분할 영상 연속재생 — 영상이 끝나면 같은 폴더의 다음 영상(이름 오름차순)을 자동 재생. // 드론 로그(시간 기반)는 loadSegment 가 누적 오프셋으로 재정렬해 궤적 정합을 유지한다. useEffect(() => { @@ -625,6 +640,7 @@ const VideoPlayer = forwardRef( onStop={handleStop} onCapture={handleCaptureFrame} onSeek={handleSeek} + onSeekSegment={(i, t) => { void handleSeekSegment(i, t); }} showStations={showStations} onToggleStations={() => setShowStations((v) => !v)} /> diff --git a/client/src/hooks/useVideoPlayer.ts b/client/src/hooks/useVideoPlayer.ts index 1b1b773..5d66842 100644 --- a/client/src/hooks/useVideoPlayer.ts +++ b/client/src/hooks/useVideoPlayer.ts @@ -76,7 +76,7 @@ export function useVideoPlayer(containerRef: React.RefObject { + const loadLocalFile = useCallback((file: File, autoPlay = false, startAtSec = 0) => { const player = playerRef.current; if (!player) return; @@ -96,7 +96,7 @@ export function useVideoPlayer(containerRef: React.RefObject { if (player.isDisposed() || objectUrlRef.current !== objectUrl) return; - player.currentTime(0); + player.currentTime(startAtSec > 0 ? startAtSec : 0); if (autoPlay) { const p = player.play(); if (p && typeof p.catch === 'function') p.catch(() => {}); @@ -110,7 +110,7 @@ export function useVideoPlayer(containerRef: React.RefObject 0 ? startAtSec : 0); store.setDuration(0); if (prevUrl) URL.revokeObjectURL(prevUrl); // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/client/src/store/geoStore.ts b/client/src/store/geoStore.ts index ab7fe9a..9164638 100644 --- a/client/src/store/geoStore.ts +++ b/client/src/store/geoStore.ts @@ -64,6 +64,10 @@ interface GeoStore { /** frames 가 djmd(영상 내장 텔레메트리) 프레임 동기 위치로 정밀화됐는지. * true 면 입력이 매끄러워 오버레이 포즈 평활(smoothHalf)을 최소로 제한한다. */ frameSynced: boolean; + /** 전체 비행(모든 세그먼트) 프레임 — 측점 '전 구간' 검색용. */ + fullFrames: DroneFrame[]; + /** djmd 교차상관 시간 오프셋(초) — 영상시간 = CSV시간 − 이 값. 검색 시각 변환용. */ + djmdDtSec: number; /** * 폴더 선택 파일에서 지리정보를 파싱해 스토어에 적재한다. @@ -88,6 +92,9 @@ interface GeoStore { const EMPTY = { loaded: false, frames: [] as DroneFrame[], + /** 전체 비행(모든 세그먼트) 프레임 — 측점 '전 구간' 검색용. 표시용 frames 와 별개. */ + fullFrames: [] as DroneFrame[], + djmdDtSec: 0, frameSynced: false, pois: [] as GeoPoint[], basePois: [] as GeoPoint[], @@ -128,8 +135,8 @@ async function refineWithDjmd( const s = get(); // 파싱 중 세그먼트/폴더가 바뀌었으면 폐기 if (s.videoIndex !== forIndex || s.videoFiles[forIndex] !== videoFile || s.frames !== s0.frames) return; - set({ frames: refined, frameSynced: true }); - console.log(`[djmd] 영상 내장 텔레메트리로 위치/고도 정밀화 적용 (${refined.length}행, 프레임 동기)`); + set({ frames: refined.frames, frameSynced: true, djmdDtSec: refined.dtSec }); + console.log(`[djmd] 영상 내장 텔레메트리로 위치/고도 정밀화 적용 (${refined.frames.length}행, 프레임 동기)`); } catch (e) { console.warn('[djmd] 정밀 위치 적용 실패(CSV 기반 유지):', e); } @@ -143,6 +150,7 @@ export const useGeoStore = create((set, get) => ({ set({ loaded: true, frames: data.frames, + fullFrames: data.fullFrames, frameSynced: false, basePois: data.pois, poiOverrides: data.poiOverrides, diff --git a/client/src/types/geo.ts b/client/src/types/geo.ts index 572340c..777ef29 100644 --- a/client/src/types/geo.ts +++ b/client/src/types/geo.ts @@ -146,6 +146,10 @@ export interface RouteInfo { * N=진행방향 +N(m) 고정, "auto"=자동(짐벌 피치·AGL 기반 카메라 시선 중심). * 활성 시 노선 밖(접근/이탈)에서는 시선 지점을 노선에 투영해 측점값 전환이 앞당겨진다. */ station_lookahead?: number | string; + /** 오버레이 자세 필터 저속 컷오프(Hz, 기본 0.8) — 작을수록 직진 시 떨림 억제가 강함. */ + att_min_cut?: number; + /** 오버레이 자세 필터 속도 계수(기본 0.5) — 클수록 팬(회전) 추종이 민감. */ + att_beta?: number; /** 측점 인정 범위(m): station 위치를 이 거리 이내로 지날 때 마커 표시. 기본 40. 폴더별 조절. */ stationTolerance?: number; /** @@ -180,6 +184,8 @@ export interface FolderGeoData { videoFile: File | null; baseName: string | null; frames: DroneFrame[]; + /** 전체 비행(모든 세그먼트) 프레임 — 측점 '전 구간' 검색용. 표시용 frames 와 별개. */ + fullFrames: DroneFrame[]; pois: GeoPoint[]; // type==='poi' (KMZ 원본: 지장물·출입문 등) stations: GeoPoint[]; // type==='station' (측점.csv, stationOrder 정렬) centerline: CenterlinePoint[]; // 측점을 mileage 순으로 이은 폴리라인 (v2.0엔 center.csv 없음) diff --git a/client/src/utils/djmdTrack.ts b/client/src/utils/djmdTrack.ts index 2b2303b..3609922 100644 --- a/client/src/utils/djmdTrack.ts +++ b/client/src/utils/djmdTrack.ts @@ -234,7 +234,7 @@ export async function refineFramesWithDjmd( videoFile: File, frames: DroneFrame[], useEllipsoidal: boolean, -): Promise { +): Promise<{ frames: DroneFrame[]; dtSec: number } | null> { if (!frames.length) return null; const samples = await parseDjmdPositions(videoFile); if (!samples) return null; @@ -306,7 +306,7 @@ export async function refineFramesWithDjmd( console.log(`[djmd] CSV↔영상 시간 오프셋 ${bestDt.toFixed(2)}s 추정 (잔차 ${bestC.toFixed(1)}m) — 자세 재정렬 적용`); } - return frames.map((f) => { + const refined = frames.map((f) => { const p = at(f.frame); // 위치/고도: djmd (프레임 동기) const a = csvAt(f.frame / FPS + bestDt); // 자세: CSV 를 추정 오프셋으로 재시각화 return { @@ -315,4 +315,7 @@ export async function refineFramesWithDjmd( yaw: a.yaw, pitch: a.pitch, roll: a.roll, }; }); + // dtSec: CSV↔영상 시간 오프셋 — 영상시간 = CSV시간 − dtSec. + // 측점 검색(CSV 축 인덱스)이 영상 시각으로 변환할 때 사용(0.33s ≈ 2m 오차 제거). + return { frames: refined, dtSec: bestDt }; } diff --git a/client/src/utils/geoData.ts b/client/src/utils/geoData.ts index 3be2006..44392a8 100644 --- a/client/src/utils/geoData.ts +++ b/client/src/utils/geoData.ts @@ -164,7 +164,7 @@ export function deriveBaseName(videoFile: File | null): string | null { * 같은 값을 역산해 시간으로 되돌리므로 상수 자체는 임의여도 정합이 유지된다. * 59.94 는 이 데이터셋(DJI 4K60) 실제 fps 와 일치시켜 HUD 표기도 자연스럽게 한 것. */ -const DJI_VIRTUAL_FPS = 59.94; +export const DJI_VIRTUAL_FPS = 59.94; /** 영상 세그먼트 창 — DJI 시간 기반 로그를 특정 영상 구간에 정렬할 때 사용. */ export interface SegmentWindow { @@ -690,9 +690,63 @@ export async function parseKmz( } } + // 곡선 평활 — 측점 정점 삽입/정비가 끝난 최종 선형에 적용(원 정점 전부 보존). + if (centerline.length >= 4) { + const before = centerline.length; + centerline = smoothCenterlineCR(centerline); + if (centerline.length !== before) { + console.log(`[KML] 선형 곡선 평활(Catmull-Rom): ${before} → ${centerline.length}점`); + } + } + return { pois, structures, stations, centerline }; } +/** + * 선형 곡선 평활 — Centripetal Catmull-Rom 으로 정점 사이를 세분(모든 원 정점 통과). + * 곡선부(정점 ~20m 간격)의 직선 연결이 화면에서 각져 보이는 것을 해소한다. + * 측점 삽입 정점도 그대로 통과하므로 선형-측점 정합 유지. 60m 이상 장대 세그먼트 + * (직선 구간)와 양끝 세그먼트는 세분하지 않는다(불필요·경계 불안정 방지). + */ +function smoothCenterlineCR(pts: CenterlinePoint[]): CenterlinePoint[] { + if (pts.length < 4) return pts; + const k = Math.cos((pts[0].lat * Math.PI) / 180) * 111320; + const dist = (a: CenterlinePoint, b: CenterlinePoint): number => + Math.hypot((b.lon - a.lon) * k, (b.lat - a.lat) * 111320); + const out: CenterlinePoint[] = []; + for (let i = 0; i < pts.length - 1; i++) { + const p1 = pts[i], p2 = pts[i + 1]; + out.push(p1); + if (i === 0 || i === pts.length - 2) continue; // 양끝 세그먼트는 직선 유지 + const len = dist(p1, p2); + if (len < 2 || len >= 60) continue; // 중복/장대 세그먼트 제외 + const p0 = pts[i - 1], p3 = pts[i + 2]; + const sub = Math.min(6, Math.max(2, Math.ceil(len / 6))); + const t0 = 0; + const t1 = t0 + Math.max(0.7, Math.sqrt(dist(p0, p1))); + const t2 = t1 + Math.max(0.7, Math.sqrt(len)); + const t3 = t2 + Math.max(0.7, Math.sqrt(dist(p2, p3))); + const bg = (c0: number, c1: number, c2: number, c3: number, t: number): number => { + const A1 = ((t1 - t) * c0 + (t - t0) * c1) / (t1 - t0); + const A2 = ((t2 - t) * c1 + (t - t1) * c2) / (t2 - t1); + const A3 = ((t3 - t) * c2 + (t - t2) * c3) / (t3 - t2); + const B1 = ((t2 - t) * A1 + (t - t0) * A2) / (t2 - t0); + const B2 = ((t3 - t) * A2 + (t - t1) * A3) / (t3 - t1); + return ((t2 - t) * B1 + (t - t1) * B2) / (t2 - t1); + }; + for (let j = 1; j < sub; j++) { + const t = t1 + ((t2 - t1) * j) / sub; + out.push({ + lat: bg(p0.lat, p1.lat, p2.lat, p3.lat, t), + lon: bg(p0.lon, p1.lon, p2.lon, p3.lon, t), + z: bg(p0.z, p1.z, p2.z, p3.z, t), + }); + } + } + out.push(pts[pts.length - 1]); + return out; +} + // ── 카메라 파라미터 파일 (.camera.json) ───────────────────────── /** @@ -936,8 +990,10 @@ export async function loadFolderGeoData( ); } - const [frames, stationResult, routeMeta, poiOverrides, kmz, cameraInfo, cameraJson, displayJson] = await Promise.all([ + const [frames, fullFrames, stationResult, routeMeta, poiOverrides, kmz, cameraInfo, cameraJson, displayJson] = await Promise.all([ parseDroneFrames(files, baseName, segmentWindow), + // 전체 비행(모든 세그먼트) 프레임 — 측점 '전 구간' 검색용(표시용 frames 와 별개) + parseDroneFrames(files, baseName, null), parseStations(files), parseRouteMeta(files, baseName), parsePoiOverrides(files, baseName), @@ -968,6 +1024,7 @@ export async function loadFolderGeoData( if (isFinite(geoidN) && Math.abs(geoidN) < 60) { altitudeOffsetM = Math.round(geoidN * 100) / 100; for (const f of frames) f.altitude += altitudeOffsetM; + for (const f of fullFrames) f.altitude += altitudeOffsetM; console.log( `[geo] 타원체고 기준 데이터셋 — 드론 고도를 타원체고로 변환 (+${altitudeOffsetM.toFixed(2)}m = ` + `djmd ${cameraInfo.ellipsoidalAltM.toFixed(1)} − CSV ${(frames[0].altitude - altitudeOffsetM).toFixed(1)})`, @@ -978,6 +1035,7 @@ export async function loadFolderGeoData( // 감지된 초점거리를 드론 프레임 focalLen 에도 반영 (검색/투영 FOV 판정 일관성). if (cameraInfo?.focalLen35) { for (const f of frames) f.focalLen = cameraInfo.focalLen35; + for (const f of fullFrames) f.focalLen = cameraInfo.focalLen35; console.log( `[camera] ${cameraInfo.model} 감지 — focal ${cameraInfo.focalLen35}mm` + (cameraInfo.width ? `, ${cameraInfo.width}x${cameraInfo.height}@${cameraInfo.fps ?? '?'}fps` : ''), @@ -1047,6 +1105,7 @@ export async function loadFolderGeoData( videoFile, baseName, frames, + fullFrames, pois, stations, centerline,