데이터 파이프라인 — 전 구간 프레임·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
+5 -2
View File
@@ -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 };
}
+61 -2
View File
@@ -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,