데이터 파이프라인 — 전 구간 프레임·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:
@@ -38,7 +38,7 @@ function collectDropEntry(entry: FileSystemEntry, out: File[]): Promise<void> {
|
||||
}
|
||||
|
||||
export interface VideoPlayerHandle {
|
||||
loadLocalFile: (file: File) => void;
|
||||
loadLocalFile: (file: File, autoPlay?: boolean, startAtSec?: number) => void;
|
||||
loadServerStream: (videoId: string, filename: string) => void | Promise<void>;
|
||||
seekTo: (time: number) => void;
|
||||
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 가 누적 오프셋으로 재정렬해 궤적 정합을 유지한다.
|
||||
useEffect(() => {
|
||||
@@ -625,6 +640,7 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
|
||||
onStop={handleStop}
|
||||
onCapture={handleCaptureFrame}
|
||||
onSeek={handleSeek}
|
||||
onSeekSegment={(i, t) => { void handleSeekSegment(i, t); }}
|
||||
showStations={showStations}
|
||||
onToggleStations={() => setShowStations((v) => !v)}
|
||||
/>
|
||||
|
||||
@@ -76,7 +76,7 @@ export function useVideoPlayer(containerRef: React.RefObject<HTMLDivElement | nu
|
||||
// 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;
|
||||
if (!player) return;
|
||||
|
||||
@@ -96,7 +96,7 @@ export function useVideoPlayer(containerRef: React.RefObject<HTMLDivElement | nu
|
||||
// - autoPlay=true(분할영상 연속재생·재생목록 선택): 0초부터 즉시 재생
|
||||
player.one('loadedmetadata', () => {
|
||||
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<HTMLDivElement | nu
|
||||
// 새 소스 메타데이터 로드 전까지 남아 보이는 문제 방지. (src 교체는 pause 이벤트를
|
||||
// 보장하지 않아 store.playing 이 true 로 남을 수 있음)
|
||||
store.setPlaying(false);
|
||||
store.setCurrentTime(0);
|
||||
store.setCurrentTime(startAtSec > 0 ? startAtSec : 0);
|
||||
store.setDuration(0);
|
||||
if (prevUrl) URL.revokeObjectURL(prevUrl);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
||||
@@ -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<GeoStore>((set, get) => ({
|
||||
set({
|
||||
loaded: true,
|
||||
frames: data.frames,
|
||||
fullFrames: data.fullFrames,
|
||||
frameSynced: false,
|
||||
basePois: data.pois,
|
||||
poiOverrides: data.poiOverrides,
|
||||
|
||||
@@ -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 없음)
|
||||
|
||||
@@ -234,7 +234,7 @@ export async function refineFramesWithDjmd(
|
||||
videoFile: File,
|
||||
frames: DroneFrame[],
|
||||
useEllipsoidal: boolean,
|
||||
): Promise<DroneFrame[] | null> {
|
||||
): 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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
// ── 카메라 파라미터 파일 (<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),
|
||||
// 전체 비행(모든 세그먼트) 프레임 — 측점 '전 구간' 검색용(표시용 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,
|
||||
|
||||
Reference in New Issue
Block a user