데이터 파이프라인 — 전 구간 프레임·djmd dt 전파·세그먼트 시크·선형 곡선 평활

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 16:27:03 +09:00
co-authored by Claude Fable 5
parent 76a2ab4984
commit 87b66d9ed4
6 changed files with 102 additions and 10 deletions
+17 -1
View File
@@ -38,7 +38,7 @@ function collectDropEntry(entry: FileSystemEntry, out: File[]): Promise<void> {
} }
export interface VideoPlayerHandle { export interface VideoPlayerHandle {
loadLocalFile: (file: File) => void; loadLocalFile: (file: File, autoPlay?: boolean, startAtSec?: number) => void;
loadServerStream: (videoId: string, filename: string) => void | Promise<void>; loadServerStream: (videoId: string, filename: string) => void | Promise<void>;
seekTo: (time: number) => void; seekTo: (time: number) => void;
getVideoElement: () => HTMLVideoElement | null; getVideoElement: () => HTMLVideoElement | null;
@@ -232,6 +232,21 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
} }
}; };
// 측점 검색 전 구간 점프 — 다른 세그먼트의 통과 지점으로 전환+시크.
// 같은 세그먼트면 시크만, 다르면 loadSegment 후 해당 시각부터(재생 상태 유지).
const handleSeekSegment = async (i: number, timeSec: number): Promise<void> => {
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 가 누적 오프셋으로 재정렬해 궤적 정합을 유지한다. // 드론 로그(시간 기반)는 loadSegment 가 누적 오프셋으로 재정렬해 궤적 정합을 유지한다.
useEffect(() => { useEffect(() => {
@@ -625,6 +640,7 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
onStop={handleStop} onStop={handleStop}
onCapture={handleCaptureFrame} onCapture={handleCaptureFrame}
onSeek={handleSeek} onSeek={handleSeek}
onSeekSegment={(i, t) => { void handleSeekSegment(i, t); }}
showStations={showStations} showStations={showStations}
onToggleStations={() => setShowStations((v) => !v)} onToggleStations={() => setShowStations((v) => !v)}
/> />
+3 -3
View File
@@ -76,7 +76,7 @@ export function useVideoPlayer(containerRef: React.RefObject<HTMLDivElement | nu
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
const loadLocalFile = useCallback((file: File, autoPlay = false) => { const loadLocalFile = useCallback((file: File, autoPlay = false, startAtSec = 0) => {
const player = playerRef.current; const player = playerRef.current;
if (!player) return; if (!player) return;
@@ -96,7 +96,7 @@ export function useVideoPlayer(containerRef: React.RefObject<HTMLDivElement | nu
// - autoPlay=true(분할영상 연속재생·재생목록 선택): 0초부터 즉시 재생 // - autoPlay=true(분할영상 연속재생·재생목록 선택): 0초부터 즉시 재생
player.one('loadedmetadata', () => { player.one('loadedmetadata', () => {
if (player.isDisposed() || objectUrlRef.current !== objectUrl) return; if (player.isDisposed() || objectUrlRef.current !== objectUrl) return;
player.currentTime(0); player.currentTime(startAtSec > 0 ? startAtSec : 0);
if (autoPlay) { if (autoPlay) {
const p = player.play(); const p = player.play();
if (p && typeof p.catch === 'function') p.catch(() => {}); if (p && typeof p.catch === 'function') p.catch(() => {});
@@ -110,7 +110,7 @@ export function useVideoPlayer(containerRef: React.RefObject<HTMLDivElement | nu
// 새 소스 메타데이터 로드 전까지 남아 보이는 문제 방지. (src 교체는 pause 이벤트를 // 새 소스 메타데이터 로드 전까지 남아 보이는 문제 방지. (src 교체는 pause 이벤트를
// 보장하지 않아 store.playing 이 true 로 남을 수 있음) // 보장하지 않아 store.playing 이 true 로 남을 수 있음)
store.setPlaying(false); store.setPlaying(false);
store.setCurrentTime(0); store.setCurrentTime(startAtSec > 0 ? startAtSec : 0);
store.setDuration(0); store.setDuration(0);
if (prevUrl) URL.revokeObjectURL(prevUrl); if (prevUrl) URL.revokeObjectURL(prevUrl);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
+10 -2
View File
@@ -64,6 +64,10 @@ interface GeoStore {
/** frames 가 djmd(영상 내장 텔레메트리) 프레임 동기 위치로 정밀화됐는지. /** frames 가 djmd(영상 내장 텔레메트리) 프레임 동기 위치로 정밀화됐는지.
* true 면 입력이 매끄러워 오버레이 포즈 평활(smoothHalf)을 최소로 제한한다. */ * true 면 입력이 매끄러워 오버레이 포즈 평활(smoothHalf)을 최소로 제한한다. */
frameSynced: boolean; frameSynced: boolean;
/** 전체 비행(모든 세그먼트) 프레임 — 측점 '전 구간' 검색용. */
fullFrames: DroneFrame[];
/** djmd 교차상관 시간 오프셋(초) — 영상시간 = CSV시간 − 이 값. 검색 시각 변환용. */
djmdDtSec: number;
/** /**
* 폴더 선택 파일에서 지리정보를 파싱해 스토어에 적재한다. * 폴더 선택 파일에서 지리정보를 파싱해 스토어에 적재한다.
@@ -88,6 +92,9 @@ interface GeoStore {
const EMPTY = { const EMPTY = {
loaded: false, loaded: false,
frames: [] as DroneFrame[], frames: [] as DroneFrame[],
/** 전체 비행(모든 세그먼트) 프레임 — 측점 '전 구간' 검색용. 표시용 frames 와 별개. */
fullFrames: [] as DroneFrame[],
djmdDtSec: 0,
frameSynced: false, frameSynced: false,
pois: [] as GeoPoint[], pois: [] as GeoPoint[],
basePois: [] as GeoPoint[], basePois: [] as GeoPoint[],
@@ -128,8 +135,8 @@ async function refineWithDjmd(
const s = get(); const s = get();
// 파싱 중 세그먼트/폴더가 바뀌었으면 폐기 // 파싱 중 세그먼트/폴더가 바뀌었으면 폐기
if (s.videoIndex !== forIndex || s.videoFiles[forIndex] !== videoFile || s.frames !== s0.frames) return; if (s.videoIndex !== forIndex || s.videoFiles[forIndex] !== videoFile || s.frames !== s0.frames) return;
set({ frames: refined, frameSynced: true }); set({ frames: refined.frames, frameSynced: true, djmdDtSec: refined.dtSec });
console.log(`[djmd] 영상 내장 텔레메트리로 위치/고도 정밀화 적용 (${refined.length}행, 프레임 동기)`); console.log(`[djmd] 영상 내장 텔레메트리로 위치/고도 정밀화 적용 (${refined.frames.length}행, 프레임 동기)`);
} catch (e) { } catch (e) {
console.warn('[djmd] 정밀 위치 적용 실패(CSV 기반 유지):', e); console.warn('[djmd] 정밀 위치 적용 실패(CSV 기반 유지):', e);
} }
@@ -143,6 +150,7 @@ export const useGeoStore = create<GeoStore>((set, get) => ({
set({ set({
loaded: true, loaded: true,
frames: data.frames, frames: data.frames,
fullFrames: data.fullFrames,
frameSynced: false, frameSynced: false,
basePois: data.pois, basePois: data.pois,
poiOverrides: data.poiOverrides, poiOverrides: data.poiOverrides,
+6
View File
@@ -146,6 +146,10 @@ export interface RouteInfo {
* N=진행방향 +N(m) 고정, "auto"=자동(짐벌 피치·AGL 기반 카메라 시선 중심). * N=진행방향 +N(m) 고정, "auto"=자동(짐벌 피치·AGL 기반 카메라 시선 중심).
* 활성 시 노선 밖(접근/이탈)에서는 시선 지점을 노선에 투영해 측점값 전환이 앞당겨진다. */ * 활성 시 노선 밖(접근/이탈)에서는 시선 지점을 노선에 투영해 측점값 전환이 앞당겨진다. */
station_lookahead?: number | string; station_lookahead?: number | string;
/** 오버레이 자세 필터 저속 컷오프(Hz, 기본 0.8) — 작을수록 직진 시 떨림 억제가 강함. */
att_min_cut?: number;
/** 오버레이 자세 필터 속도 계수(기본 0.5) — 클수록 팬(회전) 추종이 민감. */
att_beta?: number;
/** 측점 인정 범위(m): station 위치를 이 거리 이내로 지날 때 마커 표시. 기본 40. 폴더별 조절. */ /** 측점 인정 범위(m): station 위치를 이 거리 이내로 지날 때 마커 표시. 기본 40. 폴더별 조절. */
stationTolerance?: number; stationTolerance?: number;
/** /**
@@ -180,6 +184,8 @@ export interface FolderGeoData {
videoFile: File | null; videoFile: File | null;
baseName: string | null; baseName: string | null;
frames: DroneFrame[]; frames: DroneFrame[];
/** 전체 비행(모든 세그먼트) 프레임 — 측점 '전 구간' 검색용. 표시용 frames 와 별개. */
fullFrames: DroneFrame[];
pois: GeoPoint[]; // type==='poi' (KMZ 원본: 지장물·출입문 등) pois: GeoPoint[]; // type==='poi' (KMZ 원본: 지장물·출입문 등)
stations: GeoPoint[]; // type==='station' (측점.csv, stationOrder 정렬) stations: GeoPoint[]; // type==='station' (측점.csv, stationOrder 정렬)
centerline: CenterlinePoint[]; // 측점을 mileage 순으로 이은 폴리라인 (v2.0엔 center.csv 없음) centerline: CenterlinePoint[]; // 측점을 mileage 순으로 이은 폴리라인 (v2.0엔 center.csv 없음)
+5 -2
View File
@@ -234,7 +234,7 @@ export async function refineFramesWithDjmd(
videoFile: File, videoFile: File,
frames: DroneFrame[], frames: DroneFrame[],
useEllipsoidal: boolean, useEllipsoidal: boolean,
): Promise<DroneFrame[] | null> { ): Promise<{ frames: DroneFrame[]; dtSec: number } | null> {
if (!frames.length) return null; if (!frames.length) return null;
const samples = await parseDjmdPositions(videoFile); const samples = await parseDjmdPositions(videoFile);
if (!samples) return null; if (!samples) return null;
@@ -306,7 +306,7 @@ export async function refineFramesWithDjmd(
console.log(`[djmd] CSV↔영상 시간 오프셋 ${bestDt.toFixed(2)}s 추정 (잔차 ${bestC.toFixed(1)}m) — 자세 재정렬 적용`); 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 p = at(f.frame); // 위치/고도: djmd (프레임 동기)
const a = csvAt(f.frame / FPS + bestDt); // 자세: CSV 를 추정 오프셋으로 재시각화 const a = csvAt(f.frame / FPS + bestDt); // 자세: CSV 를 추정 오프셋으로 재시각화
return { return {
@@ -315,4 +315,7 @@ export async function refineFramesWithDjmd(
yaw: a.yaw, pitch: a.pitch, roll: a.roll, yaw: a.yaw, pitch: a.pitch, roll: a.roll,
}; };
}); });
// dtSec: CSV↔영상 시간 오프셋 — 영상시간 = CSV시간 − dtSec.
// 측점 검색(CSV 축 인덱스)이 영상 시각으로 변환할 때 사용(0.33s ≈ 2m 오차 제거).
return { frames: refined, dtSec: bestDt };
} }
+61 -2
View File
@@ -164,7 +164,7 @@ export function deriveBaseName(videoFile: File | null): string | null {
* 같은 값을 역산해 시간으로 되돌리므로 상수 자체는 임의여도 정합이 유지된다. * 같은 값을 역산해 시간으로 되돌리므로 상수 자체는 임의여도 정합이 유지된다.
* 59.94 는 이 데이터셋(DJI 4K60) 실제 fps 와 일치시켜 HUD 표기도 자연스럽게 한 것. * 59.94 는 이 데이터셋(DJI 4K60) 실제 fps 와 일치시켜 HUD 표기도 자연스럽게 한 것.
*/ */
const DJI_VIRTUAL_FPS = 59.94; export const DJI_VIRTUAL_FPS = 59.94;
/** 영상 세그먼트 창 — DJI 시간 기반 로그를 특정 영상 구간에 정렬할 때 사용. */ /** 영상 세그먼트 창 — DJI 시간 기반 로그를 특정 영상 구간에 정렬할 때 사용. */
export interface SegmentWindow { 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 }; 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;
}
// ── 카메라 파라미터 파일 (<base>.camera.json) ───────────────────────── // ── 카메라 파라미터 파일 (<base>.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), parseDroneFrames(files, baseName, segmentWindow),
// 전체 비행(모든 세그먼트) 프레임 — 측점 '전 구간' 검색용(표시용 frames 와 별개)
parseDroneFrames(files, baseName, null),
parseStations(files), parseStations(files),
parseRouteMeta(files, baseName), parseRouteMeta(files, baseName),
parsePoiOverrides(files, baseName), parsePoiOverrides(files, baseName),
@@ -968,6 +1024,7 @@ export async function loadFolderGeoData(
if (isFinite(geoidN) && Math.abs(geoidN) < 60) { if (isFinite(geoidN) && Math.abs(geoidN) < 60) {
altitudeOffsetM = Math.round(geoidN * 100) / 100; altitudeOffsetM = Math.round(geoidN * 100) / 100;
for (const f of frames) f.altitude += altitudeOffsetM; for (const f of frames) f.altitude += altitudeOffsetM;
for (const f of fullFrames) f.altitude += altitudeOffsetM;
console.log( console.log(
`[geo] 타원체고 기준 데이터셋 — 드론 고도를 타원체고로 변환 (+${altitudeOffsetM.toFixed(2)}m = ` + `[geo] 타원체고 기준 데이터셋 — 드론 고도를 타원체고로 변환 (+${altitudeOffsetM.toFixed(2)}m = ` +
`djmd ${cameraInfo.ellipsoidalAltM.toFixed(1)} CSV ${(frames[0].altitude - altitudeOffsetM).toFixed(1)})`, `djmd ${cameraInfo.ellipsoidalAltM.toFixed(1)} CSV ${(frames[0].altitude - altitudeOffsetM).toFixed(1)})`,
@@ -978,6 +1035,7 @@ export async function loadFolderGeoData(
// 감지된 초점거리를 드론 프레임 focalLen 에도 반영 (검색/투영 FOV 판정 일관성). // 감지된 초점거리를 드론 프레임 focalLen 에도 반영 (검색/투영 FOV 판정 일관성).
if (cameraInfo?.focalLen35) { if (cameraInfo?.focalLen35) {
for (const f of frames) f.focalLen = cameraInfo.focalLen35; for (const f of frames) f.focalLen = cameraInfo.focalLen35;
for (const f of fullFrames) f.focalLen = cameraInfo.focalLen35;
console.log( console.log(
`[camera] ${cameraInfo.model} 감지 — focal ${cameraInfo.focalLen35}mm` + `[camera] ${cameraInfo.model} 감지 — focal ${cameraInfo.focalLen35}mm` +
(cameraInfo.width ? `, ${cameraInfo.width}x${cameraInfo.height}@${cameraInfo.fps ?? '?'}fps` : ''), (cameraInfo.width ? `, ${cameraInfo.width}x${cameraInfo.height}@${cameraInfo.fps ?? '?'}fps` : ''),
@@ -1047,6 +1105,7 @@ export async function loadFolderGeoData(
videoFile, videoFile,
baseName, baseName,
frames, frames,
fullFrames,
pois, pois,
stations, stations,
centerline, centerline,