djmd 프레임 동기·오버레이 지면고정·선형-측점 정합·UI 단축키
- djmd(영상 내장 텔레메트리) 프레임 동기 위치/고도: MP4 stbl 선별 파싱(djmdTrack.ts), CSV 시각정렬 오차(평균 3.2m) 제거 — 세그먼트별 백그라운드 정밀화(frameSynced 플래그) - 자세 시간축 재정렬: djmd 위치 교차상관으로 CSV↔영상 오프셋 자동 추정(실측 -0.33s, 잔차 0.24m) 후 yaw/pitch/roll 재샘플(yaw 최단각) — 옆 지물 미끄러짐 해소 - 포즈 평활 자동 제한: 프레임 동기 데이터는 ±5프레임 상한(기존 ±60=±1s) — 회전 시 오버레이 전체가 드론에 딸려오는 지연 제거 - 선형-측점 정합: 측점 위치를 선형 정점으로 삽입(3D 이격≤0.012m) + 정점 z 를 측점 체이니지 보간으로 재정렬, 측점 라벨 화면 EMA 제거(선과 동일한 즉시 투영) — 선형이 측점 POI 를 픽셀 단위로 통과 - 드론높이 모드 높이 규칙 통일: 선형도 '드론고도-이격거리' 평면 적용(라벨과 동일) - 카메라 offZ 잔재 교정: 표고 체계 정비 이전 드래그 보정값(-18.3m)이 이중 보정이 되어 원거리 POI 가 접근할수록 밀리던 원인 — 경로 1-1 camera.json 0 으로 교정(.bak 백업) - 로드 UX: 폴더 로드 시 정지 상태로 대기(처음부터), 데이터 교체 시 UI 초기 상태 복원 - 라벨: 측점 팝업 표고(타원체고) 행 추가, 구조물-POI 이중 라벨 제거(어음천교), 시간동기 보정 슬라이더(timeOffsetSec) - 측점 표기 0+000 형식 전환(생성 6곳·파서 4곳 신구 호환) + 스테이션바 양끝 100m 내 시설물 없으면 시작/끝 스테이션 마크 - 단축키: F2 개발자 모드(dev_mode 1↔0, 세션 한정), A 선형 표시 토글 - 인프라: 54000 포트 재부팅 자동화(portproxy 직접 IP + 로그온 갱신, pm2 resurrect) - docs/history: 작업 이력 23건 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -23,15 +23,15 @@ const VIDEO_FPS = 30000 / 1001;
|
|||||||
const cleanTitle = (t: string) => t.replace(/\s*\([상하]\)\s*$/, '').trim();
|
const cleanTitle = (t: string) => t.replace(/\s*\([상하]\)\s*$/, '').trim();
|
||||||
|
|
||||||
function stationKm(title: string): number {
|
function stationKm(title: string): number {
|
||||||
const m = title.match(/(\d+)[Kk](\d+)/);
|
const m = title.match(/(\d+)\s*[Kk+]\s*(\d+)/);
|
||||||
if (!m) return -1;
|
if (!m) return -1;
|
||||||
return parseInt(m[1]) * 1000 + parseInt(m[2]);
|
return parseInt(m[1]) * 1000 + parseInt(m[2]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 미터값 → "158k160" (10m 단위, 재생바 배지와 동일 형식). */
|
/** 미터값 → "158+160" (10m 단위, 재생바 배지와 동일 형식). */
|
||||||
function formatKm10(m: number): string {
|
function formatKm10(m: number): string {
|
||||||
const r = Math.round(m / 10) * 10;
|
const r = Math.round(m / 10) * 10;
|
||||||
return `${Math.floor(r / 1000)}k${String(r % 1000).padStart(3, '0')}`;
|
return `${Math.floor(r / 1000)}+${String(r % 1000).padStart(3, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CATEGORY_EMOJI: Record<string, string> = {
|
const CATEGORY_EMOJI: Record<string, string> = {
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ const POI_MERGE_Y = 0.035;
|
|||||||
|
|
||||||
type DispPos = { x: number; y: number; rej: number; vx: number; vy: number; rx?: number; ry?: number };
|
type DispPos = { x: number; y: number; rej: number; vx: number; vy: number; rx?: number; ry?: number };
|
||||||
|
|
||||||
|
// djmd 프레임 동기 데이터일 때 포즈 평활 상한(±프레임). 위치·자세가 이미 매끄러워
|
||||||
|
// 긴 평활(기본 60 ≈ ±1s)은 회전 시 포즈 지연 → 오버레이 전체가 드론에 '딸려오는' 원인.
|
||||||
|
const POSE_SMOOTH_SYNCED_MAX = 5;
|
||||||
|
|
||||||
// 속도 적응형 평활(One Euro 방식) 상수.
|
// 속도 적응형 평활(One Euro 방식) 상수.
|
||||||
// 떨림(노이즈)은 매 프레임 방향이 번갈아 → 평활속도≈0 → 강하게 평활(1배속에서 안정).
|
// 떨림(노이즈)은 매 프레임 방향이 번갈아 → 평활속도≈0 → 강하게 평활(1배속에서 안정).
|
||||||
// 실제 이동은 방향이 일관 → 평활속도 큼 → alpha↑로 즉시 추종(빠른 배속에서 지연 없음).
|
// 실제 이동은 방향이 일관 → 평활속도 큼 → alpha↑로 즉시 추종(빠른 배속에서 지연 없음).
|
||||||
@@ -194,7 +198,7 @@ const orderedFullFields = (
|
|||||||
): { k: string; v: string }[] => [...compact, ...props.filter((p) => !isCompactKey(p.k))];
|
): { k: string; v: string }[] => [...compact, ...props.filter((p) => !isCompactKey(p.k))];
|
||||||
|
|
||||||
function stationOrder(title: string): number {
|
function stationOrder(title: string): number {
|
||||||
const m = title.match(/(\d+)[Kk](\d+)/);
|
const m = title.match(/(\d+)\s*[Kk+]\s*(\d+)/);
|
||||||
if (!m) return 0;
|
if (!m) return 0;
|
||||||
return parseInt(m[1]) * 1000 + parseInt(m[2]);
|
return parseInt(m[1]) * 1000 + parseInt(m[2]);
|
||||||
}
|
}
|
||||||
@@ -534,6 +538,13 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
|||||||
const storePois = useGeoStore(s => s.pois);
|
const storePois = useGeoStore(s => s.pois);
|
||||||
const storeCenterline = useGeoStore(s => s.centerline);
|
const storeCenterline = useGeoStore(s => s.centerline);
|
||||||
const storeFrames = useGeoStore(s => s.frames);
|
const storeFrames = useGeoStore(s => s.frames);
|
||||||
|
const storeFrameSynced = useGeoStore(s => s.frameSynced);
|
||||||
|
const frameSyncedRef = useRef(false);
|
||||||
|
useEffect(() => { frameSyncedRef.current = storeFrameSynced; }, [storeFrameSynced]);
|
||||||
|
// 포즈 평활 유효 반폭 — 프레임 동기(djmd) 데이터는 상한 제한(회전 시 포즈 지연 제거).
|
||||||
|
const poseHalf = useCallback((): number =>
|
||||||
|
frameSyncedRef.current ? Math.min(smoothHalfRef.current, POSE_SMOOTH_SYNCED_MAX) : smoothHalfRef.current,
|
||||||
|
[]);
|
||||||
const storeOrigin = useGeoStore(s => s.origin);
|
const storeOrigin = useGeoStore(s => s.origin);
|
||||||
const storeStructures = useGeoStore(s => s.structures);
|
const storeStructures = useGeoStore(s => s.structures);
|
||||||
const routeMeta = useGeoStore(s => s.routeMeta);
|
const routeMeta = useGeoStore(s => s.routeMeta);
|
||||||
@@ -799,18 +810,23 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
|||||||
// 구조물(교량/터널/구교)을 영상 오버레이에 POI처럼 표시(거리필터 적용).
|
// 구조물(교량/터널/구교)을 영상 오버레이에 POI처럼 표시(거리필터 적용).
|
||||||
// - 영상은 시설등급 무관 모두 표출(신대천교(복) 등 포함). 스테이션바엔 시설등급 필터로 별도 제어.
|
// - 영상은 시설등급 무관 모두 표출(신대천교(복) 등 포함). 스테이션바엔 시설등급 필터로 별도 제어.
|
||||||
// lat/lon 없는 항목(측점기반 배치 등)도 제외.
|
// lat/lon 없는 항목(측점기반 배치 등)도 제외.
|
||||||
|
// 신포맷(지장물.kml '타입') 교량/터널은 POI 로도 등록됨 — 같은 이름이 POI 에 있으면
|
||||||
|
// 영상 라벨이 이중 표출(파란 POI + 빨간 구조물, z 도 달라 위치 분리)되므로 구조물 쪽 제외.
|
||||||
|
// (스테이션바 구조물 마크는 storeStructures 직접 사용이라 영향 없음)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const poiTitles = new Set(storePois.map(p => p.title));
|
||||||
allStructuresRef.current = storeStructures
|
allStructuresRef.current = storeStructures
|
||||||
.filter((s): s is RouteStructure & { lat: number; lon: number } =>
|
.filter((s): s is RouteStructure & { lat: number; lon: number } =>
|
||||||
s.category != null && OVERLAY_STRUCT_CATEGORIES.has(s.category) &&
|
s.category != null && OVERLAY_STRUCT_CATEGORIES.has(s.category) &&
|
||||||
typeof s.lat === 'number' && typeof s.lon === 'number')
|
typeof s.lat === 'number' && typeof s.lon === 'number' &&
|
||||||
|
!poiTitles.has(s.name))
|
||||||
.map(s => ({
|
.map(s => ({
|
||||||
title: s.name,
|
title: s.name,
|
||||||
category: s.category ?? '구교',
|
category: s.category ?? '구교',
|
||||||
lat: s.lat, lon: s.lon, z: 0, type: 'poi' as const,
|
lat: s.lat, lon: s.lon, z: 0, type: 'poi' as const,
|
||||||
props: s.props,
|
props: s.props,
|
||||||
}));
|
}));
|
||||||
}, [storeStructures]);
|
}, [storeStructures, storePois]);
|
||||||
|
|
||||||
// 중심선
|
// 중심선
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -935,8 +951,14 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
|||||||
const sq = Math.sqrt(disc);
|
const sq = Math.sqrt(disc);
|
||||||
return [(-b - sq) / (2 * a), (-b + sq) / (2 * a)].filter((t) => t >= 0 && t <= 1);
|
return [(-b - sq) / (2 * a), (-b + sq) / (2 * a)].filter((t) => t >= 0 && t <= 1);
|
||||||
};
|
};
|
||||||
|
// 드론높이 모드: 측점/POI 라벨과 동일한 '드론고도 − 이격거리' 평면에 투영 —
|
||||||
|
// 옵션 적용 시 라벨만 이동하고 선형이 지형 z 에 남아 서로 분리되는 것을 방지
|
||||||
|
// (측점 POI 와 빨간 선형이 항상 같은 선상에 놓이도록 높이 규칙 통일).
|
||||||
|
const planeZ = poiDroneHeightRef.current
|
||||||
|
? drone.altitude - (params.geoidOffset ?? 0) - droneHeightDropRef.current
|
||||||
|
: null;
|
||||||
const projCL = (lat: number, lon: number, z: number): CameraCoords =>
|
const projCL = (lat: number, lon: number, z: number): CameraCoords =>
|
||||||
toCameraCoords(drone, lat, lon, z > 0 ? z : CL_Z, params, worldOrigin);
|
toCameraCoords(drone, lat, lon, planeZ ?? (z > 0 ? z : CL_Z), params, worldOrigin);
|
||||||
for (let i = 1; i < allCL.length; i++) {
|
for (let i = 1; i < allCL.length; i++) {
|
||||||
const p0 = allCL[i - 1], p1 = allCL[i];
|
const p0 = allCL[i - 1], p1 = allCL[i];
|
||||||
const e0 = enOf(p0), e1 = enOf(p1);
|
const e0 = enOf(p0), e1 = enOf(p1);
|
||||||
@@ -999,7 +1021,7 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
|||||||
const i2 = lo, i1 = Math.max(0, lo - 1);
|
const i2 = lo, i1 = Math.max(0, lo - 1);
|
||||||
const f1 = arr[i1].frame, f2 = arr[i2].frame;
|
const f1 = arr[i1].frame, f2 = arr[i2].frame;
|
||||||
const frac = f2 > f1 ? Math.min(1, Math.max(0, (estFrame - f1) / (f2 - f1))) : 0;
|
const frac = f2 > f1 ? Math.min(1, Math.max(0, (estFrame - f1) / (f2 - f1))) : 0;
|
||||||
const h = smoothHalfRef.current;
|
const h = poseHalf();
|
||||||
const a = smoothFrame(arr, i1, h), b = smoothFrame(arr, i2, h);
|
const a = smoothFrame(arr, i1, h), b = smoothFrame(arr, i2, h);
|
||||||
const L = (x: number, y: number) => x + (y - x) * frac;
|
const L = (x: number, y: number) => x + (y - x) * frac;
|
||||||
let dy = b.yaw - a.yaw; dy = ((dy + 540) % 360) - 180; // 최단각
|
let dy = b.yaw - a.yaw; dy = ((dy + 540) % 360) - 180; // 최단각
|
||||||
@@ -1010,7 +1032,7 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
|||||||
pitch: L(a.pitch, b.pitch), roll: L(a.roll, b.roll),
|
pitch: L(a.pitch, b.pitch), roll: L(a.roll, b.roll),
|
||||||
yaw: a.yaw + dy * frac,
|
yaw: a.yaw + dy * frac,
|
||||||
};
|
};
|
||||||
}, [smoothFrame]);
|
}, [smoothFrame, poseHalf]);
|
||||||
|
|
||||||
// 텍스트 사전 계산 — requestIdleCallback으로 백그라운드 실행
|
// 텍스트 사전 계산 — requestIdleCallback으로 백그라운드 실행
|
||||||
const startLabelPrecompute = useCallback((currentParams: CameraParams, currentSmoothHalf: number, currentMaxRange: number, currentDroneHeight: boolean, currentDroneDrop: number) => {
|
const startLabelPrecompute = useCallback((currentParams: CameraParams, currentSmoothHalf: number, currentMaxRange: number, currentDroneHeight: boolean, currentDroneDrop: number) => {
|
||||||
@@ -1056,11 +1078,14 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
|||||||
});
|
});
|
||||||
const gzOf = new Map<string, number>();
|
const gzOf = new Map<string, number>();
|
||||||
for (const poi of allPoi) {
|
for (const poi of allPoi) {
|
||||||
// 지면고도 gz: 보정값(DEM/드래그) → 최근접 선로표고(유효값) → POI 자체 z → 경로표고 앵커.
|
// 지면고도 gz 우선순위: 보정값(드래그) → **POI 자체 z(실측)** → 중심선 z → 경로표고 앵커.
|
||||||
|
// 종전엔 중심선 스냅이 POI z 보다 우선 — 도로에서 떨어진 지물이 도로 높이에 붙어
|
||||||
|
// 원거리에서 계속 밀리다 근거리에서만 맞는 증상의 원인. 실측 z 보유 데이터셋 기준으로 교정.
|
||||||
const clz = nearestCL(poi.lat, poi.lon)?.z;
|
const clz = nearestCL(poi.lat, poi.lon)?.z;
|
||||||
gzOf.set(poi.title, overridesRef.current[poi.title]?.z
|
gzOf.set(poi.title, overridesRef.current[poi.title]?.z
|
||||||
|
?? (poi.z > 0 ? poi.z : undefined)
|
||||||
?? (clz !== undefined && clz > 0 ? clz : undefined)
|
?? (clz !== undefined && clz > 0 ? clz : undefined)
|
||||||
?? (poi.z > 0 ? poi.z : PATH_Z));
|
?? PATH_Z);
|
||||||
}
|
}
|
||||||
|
|
||||||
const t0 = performance.now();
|
const t0 = performance.now();
|
||||||
@@ -1079,7 +1104,8 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
|||||||
while (processed < end) {
|
while (processed < end) {
|
||||||
const idx = (startIdx + processed) % total; // startIdx→끝→0→… 순환
|
const idx = (startIdx + processed) % total; // startIdx→끝→0→… 순환
|
||||||
processed++;
|
processed++;
|
||||||
const drone = smoothFrame(frames, idx, SMOOTH_HALF);
|
const drone = smoothFrame(frames, idx,
|
||||||
|
frameSyncedRef.current ? Math.min(SMOOTH_HALF, POSE_SMOOTH_SYNCED_MAX) : SMOOTH_HALF);
|
||||||
|
|
||||||
const stationLabels: LabelCache['stationLabels'] = [];
|
const stationLabels: LabelCache['stationLabels'] = [];
|
||||||
for (const sp of snappedSt) {
|
for (const sp of snappedSt) {
|
||||||
@@ -1232,7 +1258,7 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
|||||||
const p = paramsRef.current;
|
const p = paramsRef.current;
|
||||||
const fr = allDroneFramesRef.current;
|
const fr = allDroneFramesRef.current;
|
||||||
const d = fr.length
|
const d = fr.length
|
||||||
? smoothFrame(fr, currentFrameIdxRef.current, smoothHalfRef.current)
|
? smoothFrame(fr, currentFrameIdxRef.current, poseHalf())
|
||||||
: cur;
|
: cur;
|
||||||
compassRef.current = {
|
compassRef.current = {
|
||||||
effectiveYaw: d.yaw + p.yawOffset,
|
effectiveYaw: d.yaw + p.yawOffset,
|
||||||
@@ -1246,7 +1272,7 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
|||||||
if (!currentDroneFrameRef.current) { renderCacheRef.current = null; return; }
|
if (!currentDroneFrameRef.current) { renderCacheRef.current = null; return; }
|
||||||
const frames = allDroneFramesRef.current;
|
const frames = allDroneFramesRef.current;
|
||||||
const drone = frames.length
|
const drone = frames.length
|
||||||
? smoothFrame(frames, currentFrameIdxRef.current, smoothHalfRef.current)
|
? smoothFrame(frames, currentFrameIdxRef.current, poseHalf())
|
||||||
: currentDroneFrameRef.current;
|
: currentDroneFrameRef.current;
|
||||||
const params = paramsRef.current;
|
const params = paramsRef.current;
|
||||||
renderCacheRef.current = {
|
renderCacheRef.current = {
|
||||||
@@ -1323,14 +1349,17 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
|||||||
if (cache) {
|
if (cache) {
|
||||||
|
|
||||||
// 부드러운 시간(timeRef, 60fps 보간) → 연속 프레임번호. 선/궤적·라벨 공통 사용.
|
// 부드러운 시간(timeRef, 60fps 보간) → 연속 프레임번호. 선/궤적·라벨 공통 사용.
|
||||||
const estTime = timeRef
|
// 시간동기(timeOffsetSec): 영상↔비행로그의 시각 오프셋 보정 — POI 가 진행방향으로
|
||||||
|
// 일괄 밀릴 때 조정(+ = 로그를 뒤로, − = 앞으로). camera.json 에 저장되는 보정값.
|
||||||
|
const estTime = (timeRef
|
||||||
? timeRef.current
|
? timeRef.current
|
||||||
: currentTimeSecRef.current + (performance.now() - timeUpdateWallRef.current) / 1000;
|
: currentTimeSecRef.current + (performance.now() - timeUpdateWallRef.current) / 1000)
|
||||||
|
+ (paramsRef.current.timeOffsetSec ?? 0);
|
||||||
const estFrame = estTime * fpsRef.current;
|
const estFrame = estTime * fpsRef.current;
|
||||||
|
|
||||||
// 연속 보간 포즈(라인·라벨 공통) — 매 RAF 프레임 직접 투영해 부드럽게.
|
// 연속 보간 포즈(라인·라벨 공통) — 매 RAF 프레임 직접 투영해 부드럽게.
|
||||||
const dronePose = poseAt(estFrame) ?? (allDroneFramesRef.current.length
|
const dronePose = poseAt(estFrame) ?? (allDroneFramesRef.current.length
|
||||||
? smoothFrame(allDroneFramesRef.current, currentFrameIdxRef.current, smoothHalfRef.current)
|
? smoothFrame(allDroneFramesRef.current, currentFrameIdxRef.current, poseHalf())
|
||||||
: currentDroneFrameRef.current!);
|
: currentDroneFrameRef.current!);
|
||||||
const lines = buildLines(dronePose);
|
const lines = buildLines(dronePose);
|
||||||
|
|
||||||
@@ -1453,7 +1482,10 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
|||||||
if (cc.Zc < CLIP_Z) return;
|
if (cc.Zc < CLIP_Z) return;
|
||||||
const { pxRaw, pyRaw } = pixelFromCamera(cc, params);
|
const { pxRaw, pyRaw } = pixelFromCamera(cc, params);
|
||||||
const prevDst = displayedStRef.current.get(stA.title);
|
const prevDst = displayedStRef.current.get(stA.title);
|
||||||
const d = smoothStep(prevDst, pxRaw, pyRaw, α, smoothMinAlphaRef.current, smoothSpeedRefRef.current);
|
// 측점 라벨은 선형 실선과 픽셀 단위로 붙어 다녀야 하므로 화면 EMA 없이 즉시 투영
|
||||||
|
// (실선도 매 프레임 즉시 투영 — djmd 프레임 동기 이후 입력이 매끄러워 평활 불필요.
|
||||||
|
// 반올림 떨림은 아래 0.75px 히스테리시스가 계속 흡수).
|
||||||
|
const d: DispPos = { x: pxRaw, y: pyRaw, rej: 0, vx: 0, vy: 0 };
|
||||||
seenSt.add(stA.title);
|
seenSt.add(stA.title);
|
||||||
// 픽셀 히스테리시스: 0.75px 이상 움직일 때만 정수 위치 갱신 → 저속(1배속) 반올림 깜빡임 제거.
|
// 픽셀 히스테리시스: 0.75px 이상 움직일 때만 정수 위치 갱신 → 저속(1배속) 반올림 깜빡임 제거.
|
||||||
const sx0 = vx(d.x), sy0 = vy(d.y);
|
const sx0 = vx(d.x), sy0 = vy(d.y);
|
||||||
@@ -1636,9 +1668,9 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
|||||||
// 현재 프레임의 평활 드론 자세 (역투영 기준)
|
// 현재 프레임의 평활 드론 자세 (역투영 기준)
|
||||||
const getSmoothedDrone = useCallback((): DroneFrameBasic | null => {
|
const getSmoothedDrone = useCallback((): DroneFrameBasic | null => {
|
||||||
const frames = allDroneFramesRef.current;
|
const frames = allDroneFramesRef.current;
|
||||||
if (frames.length) return smoothFrame(frames, currentFrameIdxRef.current, smoothHalfRef.current);
|
if (frames.length) return smoothFrame(frames, currentFrameIdxRef.current, poseHalf());
|
||||||
return currentDroneFrameRef.current;
|
return currentDroneFrameRef.current;
|
||||||
}, [smoothFrame]);
|
}, [smoothFrame, poseHalf]);
|
||||||
|
|
||||||
// 화면 클릭좌표(client px) → 영상프레임 정규좌표(0~1). object-fit:cover 역변환(coverRef).
|
// 화면 클릭좌표(client px) → 영상프레임 정규좌표(0~1). object-fit:cover 역변환(coverRef).
|
||||||
const clientToVideoNorm = useCallback((clientX: number, clientY: number): { x: number; y: number } => {
|
const clientToVideoNorm = useCallback((clientX: number, clientY: number): { x: number; y: number } => {
|
||||||
@@ -1676,7 +1708,7 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
|||||||
if (!hit) return;
|
if (!hit) return;
|
||||||
const poi = allPoisRef.current.concat(allStructuresRef.current).find(p => p.title === hit.title);
|
const poi = allPoisRef.current.concat(allStructuresRef.current).find(p => p.title === hit.title);
|
||||||
if (!poi) return;
|
if (!poi) return;
|
||||||
const baseZ = nearestCL(poi.lat, poi.lon)?.z ?? poi.z;
|
const baseZ = (poi.z > 0 ? poi.z : nearestCL(poi.lat, poi.lon)?.z) ?? poi.z; // 실측 z 우선(가시 gz 와 동일 규칙)
|
||||||
const poiZ = overridesRef.current[hit.title] ? poi.z : baseZ;
|
const poiZ = overridesRef.current[hit.title] ? poi.z : baseZ;
|
||||||
setSelPoi(hit.title); setSelZ(poiZ); setSelZBase(baseZ);
|
setSelPoi(hit.title); setSelZ(poiZ); setSelZBase(baseZ);
|
||||||
dragRef.current = { title: hit.title, lat: poi.lat, lon: poi.lon, z0: poiZ, sx: nx, sy: ny };
|
dragRef.current = { title: hit.title, lat: poi.lat, lon: poi.lon, z0: poiZ, sx: nx, sy: ny };
|
||||||
@@ -1709,7 +1741,7 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
|||||||
? allPoisRef.current.concat(allStructuresRef.current).find(p => p.title === hit.title)
|
? allPoisRef.current.concat(allStructuresRef.current).find(p => p.title === hit.title)
|
||||||
: allGeoStationsRef.current.find(s => s.title === hit.title);
|
: allGeoStationsRef.current.find(s => s.title === hit.title);
|
||||||
if (!obj) return;
|
if (!obj) return;
|
||||||
const groundZ = overridesRef.current[hit.title]?.z ?? nearestCL(obj.lat, obj.lon)?.z ?? obj.z;
|
const groundZ = overridesRef.current[hit.title]?.z ?? (obj.z > 0 ? obj.z : nearestCL(obj.lat, obj.lon)?.z) ?? obj.z; // 실측 z 우선
|
||||||
const drone = getSmoothedDrone();
|
const drone = getSmoothedDrone();
|
||||||
const dist = drone
|
const dist = drone
|
||||||
? toCameraCoords(drone, obj.lat, obj.lon, groundZ, paramsRef.current, worldOriginRef.current).distH ?? null
|
? toCameraCoords(drone, obj.lat, obj.lon, groundZ, paramsRef.current, worldOriginRef.current).distH ?? null
|
||||||
@@ -1967,6 +1999,14 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
|||||||
<span className="text-gray-500">경도</span><span>{pp.lon.toFixed(6)}</span>
|
<span className="text-gray-500">경도</span><span>{pp.lon.toFixed(6)}</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{/* 표고(z) — KML 좌표 3번째 성분(타원체고). 속성표(SimpleData)에 없어서 별도 표기.
|
||||||
|
(지장물의 '높이' 속성은 구조물 높이라 표고와 구분. 컴팩트 팝업은 간결 유지) */}
|
||||||
|
{showFull && pp.z > 0 && (
|
||||||
|
<>
|
||||||
|
<span className="text-gray-500 whitespace-nowrap">표고</span>
|
||||||
|
<span className="break-all">{pp.z.toFixed(1)} m (타원체고)</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -2137,6 +2177,7 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
|||||||
>{camSaveState === 'ok' ? '저장됨 ✓' : 'PC에 저장'}</button>
|
>{camSaveState === 'ok' ? '저장됨 ✓' : 'PC에 저장'}</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2 mb-3">
|
<div className="space-y-2 mb-3">
|
||||||
|
<ParamRow label="시간동기" tip="영상↔비행로그 시각 오프셋(초). POI 전체가 진행방향으로 밀리면 조정 (+: 라벨을 진행방향 앞으로 / −: 뒤로). '풀 붙임' 정합의 핵심 — camera.json 에 저장됨" value={params.timeOffsetSec ?? 0} min={-2} max={2} step={0.05} unit="s" decimals={2} onChange={v => setParam('timeOffsetSec', v)} />
|
||||||
<ParamRow label="Yaw ±" value={params.yawOffset} min={-180} max={180} step={0.1} unit="°" decimals={1} onChange={v => setParam('yawOffset', v)} />
|
<ParamRow label="Yaw ±" value={params.yawOffset} min={-180} max={180} step={0.1} unit="°" decimals={1} onChange={v => setParam('yawOffset', v)} />
|
||||||
{/* Yaw 자동 추정(2단계 보정의 1단계) — 대략 맞춘 뒤 'Yaw 보정' 드래그 모드로 마무리 */}
|
{/* Yaw 자동 추정(2단계 보정의 1단계) — 대략 맞춘 뒤 'Yaw 보정' 드래그 모드로 마무리 */}
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -129,11 +129,27 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
|
|||||||
// dev_mode (route.json) — 0 이면 '영상제어' 버튼과 제어 UI 전체를 숨김(뷰어 모드).
|
// dev_mode (route.json) — 0 이면 '영상제어' 버튼과 제어 UI 전체를 숨김(뷰어 모드).
|
||||||
// routeInfo.dev_mode 우선, 최상위 dev_mode 도 허용. 없으면 1(표시) — 기존 동작 유지.
|
// routeInfo.dev_mode 우선, 최상위 dev_mode 도 허용. 없으면 1(표시) — 기존 동작 유지.
|
||||||
const storeRouteMeta = useGeoStore((s) => s.routeMeta);
|
const storeRouteMeta = useGeoStore((s) => s.routeMeta);
|
||||||
const devMode = (() => {
|
const fileDevMode = (() => {
|
||||||
const ri = storeRouteMeta?.routeInfo as Record<string, unknown> | undefined;
|
const ri = storeRouteMeta?.routeInfo as Record<string, unknown> | undefined;
|
||||||
const v = ri?.dev_mode ?? (storeRouteMeta as unknown as Record<string, unknown> | null)?.dev_mode;
|
const v = ri?.dev_mode ?? (storeRouteMeta as unknown as Record<string, unknown> | null)?.dev_mode;
|
||||||
return v === 0 || v === '0' ? 0 : 1;
|
return v === 0 || v === '0' ? 0 : 1;
|
||||||
})();
|
})();
|
||||||
|
// F2 → 개발자 모드(1↔0) 전환 — route.json 값을 세션 한정으로 덮어씀(파일 미변경).
|
||||||
|
// 새 폴더 로드 시 초기화되어 다시 파일 값을 따른다. 텍스트 입력 중에는 무시.
|
||||||
|
const [devModeOverride, setDevModeOverride] = useState<number | null>(null);
|
||||||
|
useEffect(() => { setDevModeOverride(null); }, [storeRouteMeta]);
|
||||||
|
const devMode = devModeOverride ?? fileDevMode;
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent): void => {
|
||||||
|
if (e.code !== 'F2') return;
|
||||||
|
const t = e.target as HTMLElement;
|
||||||
|
if (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable) return;
|
||||||
|
e.preventDefault();
|
||||||
|
setDevModeOverride((prev) => ((prev ?? fileDevMode) === 0 ? 1 : 0));
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, [fileDevMode]);
|
||||||
// 분할 영상 재생목록 — 폴더 로드 시 이름 오름차순. 목록 클릭으로 세그먼트 직접 선택 재생.
|
// 분할 영상 재생목록 — 폴더 로드 시 이름 오름차순. 목록 클릭으로 세그먼트 직접 선택 재생.
|
||||||
const videoFiles = useGeoStore((s) => s.videoFiles);
|
const videoFiles = useGeoStore((s) => s.videoFiles);
|
||||||
const videoDurations = useGeoStore((s) => s.videoDurations);
|
const videoDurations = useGeoStore((s) => s.videoDurations);
|
||||||
@@ -148,6 +164,18 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
|
|||||||
// 선형(중심선)·드론 궤적 영상 오버레이 토글 — StationOverlay 와 공유(설정 스토어).
|
// 선형(중심선)·드론 궤적 영상 오버레이 토글 — StationOverlay 와 공유(설정 스토어).
|
||||||
const showCenterline = useSettingsStore((s) => s.showCenterline);
|
const showCenterline = useSettingsStore((s) => s.showCenterline);
|
||||||
const setShowCenterline = useSettingsStore((s) => s.setShowCenterline);
|
const setShowCenterline = useSettingsStore((s) => s.setShowCenterline);
|
||||||
|
// A → 선형(중심선+측점 라벨) 표시/숨김 토글. 텍스트 입력·수정키 조합(Ctrl+A 등)은 무시.
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent): void => {
|
||||||
|
if (e.code !== 'KeyA' || e.ctrlKey || e.metaKey || e.altKey) return;
|
||||||
|
const t = e.target as HTMLElement;
|
||||||
|
if (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable) return;
|
||||||
|
e.preventDefault();
|
||||||
|
setShowCenterline(!showCenterline);
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, [showCenterline, setShowCenterline]);
|
||||||
const showDronePath = useSettingsStore((s) => s.showDronePath);
|
const showDronePath = useSettingsStore((s) => s.showDronePath);
|
||||||
const setShowDronePath = useSettingsStore((s) => s.setShowDronePath);
|
const setShowDronePath = useSettingsStore((s) => s.setShowDronePath);
|
||||||
const showRoutePanel = useSettingsStore((s) => s.showRoutePanel);
|
const showRoutePanel = useSettingsStore((s) => s.showRoutePanel);
|
||||||
@@ -195,13 +223,13 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
|
|||||||
if (usePlayerStore.getState().source?.kind !== 'local') return;
|
if (usePlayerStore.getState().source?.kind !== 'local') return;
|
||||||
if (i === geo.videoIndex) {
|
if (i === geo.videoIndex) {
|
||||||
playerRef.current?.currentTime(0);
|
playerRef.current?.currentTime(0);
|
||||||
|
const p = playerRef.current?.play();
|
||||||
|
if (p && typeof p.catch === 'function') p.catch(() => {});
|
||||||
} else {
|
} else {
|
||||||
const file = await geo.loadSegment(i);
|
const file = await geo.loadSegment(i);
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
loadLocalFile(file);
|
loadLocalFile(file, true); // 목록 선택 — 로딩 완료 시 0초부터 자동 재생
|
||||||
}
|
}
|
||||||
const p = playerRef.current?.play();
|
|
||||||
if (p && typeof p.catch === 'function') p.catch(() => {});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 분할 영상 연속재생 — 영상이 끝나면 같은 폴더의 다음 영상(이름 오름차순)을 자동 재생.
|
// 분할 영상 연속재생 — 영상이 끝나면 같은 폴더의 다음 영상(이름 오름차순)을 자동 재생.
|
||||||
@@ -219,10 +247,7 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
|
|||||||
if (next >= geo.videoFiles.length) return;
|
if (next >= geo.videoFiles.length) return;
|
||||||
const file = await geo.loadSegment(next);
|
const file = await geo.loadSegment(next);
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
loadLocalFile(file);
|
loadLocalFile(file, true); // 연속재생 — 로딩 완료 시 0초부터 자동 재생
|
||||||
// ended 직후는 paused 상태라 loadLocalFile 의 이어재생 로직이 안 걸림 → 직접 재생.
|
|
||||||
const p = player.play();
|
|
||||||
if (p && typeof p.catch === 'function') p.catch(() => {});
|
|
||||||
})();
|
})();
|
||||||
};
|
};
|
||||||
player.on('ended', onEnded);
|
player.on('ended', onEnded);
|
||||||
|
|||||||
@@ -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) => {
|
const loadLocalFile = useCallback((file: File, autoPlay = false) => {
|
||||||
const player = playerRef.current;
|
const player = playerRef.current;
|
||||||
if (!player) return;
|
if (!player) return;
|
||||||
|
|
||||||
@@ -87,19 +87,32 @@ export function useVideoPlayer(containerRef: React.RefObject<HTMLDivElement | nu
|
|||||||
// 재생 중 폴더 교체 지원: 새 src 설정 후 이전 blob URL 을 해제한다.
|
// 재생 중 폴더 교체 지원: 새 src 설정 후 이전 blob URL 을 해제한다.
|
||||||
// (player.src() 가 새 소스를 채택한 뒤이므로 prevUrl 은 더 이상 참조되지 않아 안전)
|
// (player.src() 가 새 소스를 채택한 뒤이므로 prevUrl 은 더 이상 참조되지 않아 안전)
|
||||||
const prevUrl = objectUrlRef.current;
|
const prevUrl = objectUrlRef.current;
|
||||||
// 교체 직전 재생 중이었으면(=드롭 교체) 새 영상도 이어서 재생. 최초 로드는 prevUrl 없음 → 자동재생 안 함.
|
|
||||||
const wasPlaying = prevUrl != null && !player.paused();
|
|
||||||
const objectUrl = URL.createObjectURL(file);
|
const objectUrl = URL.createObjectURL(file);
|
||||||
objectUrlRef.current = objectUrl;
|
objectUrlRef.current = objectUrl;
|
||||||
player.src({ src: objectUrl, type: file.type || 'video/mp4' });
|
player.src({ src: objectUrl, type: file.type || 'video/mp4' });
|
||||||
|
// 로드 정책: 로딩 완료(loadedmetadata) 시점에
|
||||||
|
// - autoPlay=false(폴더 로드): '정지' 명령 실행 — pause + 0초. 애매한 일시정지 상태 방지,
|
||||||
|
// 재생 버튼/스페이스 등 사용자 조작으로만 시작
|
||||||
|
// - autoPlay=true(분할영상 연속재생·재생목록 선택): 0초부터 즉시 재생
|
||||||
|
player.one('loadedmetadata', () => {
|
||||||
|
if (player.isDisposed() || objectUrlRef.current !== objectUrl) return;
|
||||||
|
player.currentTime(0);
|
||||||
|
if (autoPlay) {
|
||||||
|
const p = player.play();
|
||||||
|
if (p && typeof p.catch === 'function') p.catch(() => {});
|
||||||
|
} else {
|
||||||
|
player.pause();
|
||||||
|
}
|
||||||
|
});
|
||||||
store.setSource({ kind: 'local', file, objectUrl });
|
store.setSource({ kind: 'local', file, objectUrl });
|
||||||
store.setHlsReady(false);
|
store.setHlsReady(false);
|
||||||
|
// 데이터 교체 시 UI 상태 즉시 초기화 — 이전 소스의 재생중 표시(일시정지 토글)·시간이
|
||||||
|
// 새 소스 메타데이터 로드 전까지 남아 보이는 문제 방지. (src 교체는 pause 이벤트를
|
||||||
|
// 보장하지 않아 store.playing 이 true 로 남을 수 있음)
|
||||||
|
store.setPlaying(false);
|
||||||
|
store.setCurrentTime(0);
|
||||||
|
store.setDuration(0);
|
||||||
if (prevUrl) URL.revokeObjectURL(prevUrl);
|
if (prevUrl) URL.revokeObjectURL(prevUrl);
|
||||||
if (wasPlaying) {
|
|
||||||
// 드롭은 사용자 제스처라 자동재생 정책에 막히지 않음. 준비되면 재생(실패는 무시).
|
|
||||||
const p = player.play();
|
|
||||||
if (p && typeof p.catch === 'function') p.catch(() => {});
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -117,6 +130,10 @@ export function useVideoPlayer(containerRef: React.RefObject<HTMLDivElement | nu
|
|||||||
|
|
||||||
store.setSource({ kind: 'server', videoId, filename });
|
store.setSource({ kind: 'server', videoId, filename });
|
||||||
store.setHlsReady(false);
|
store.setHlsReady(false);
|
||||||
|
// 데이터 교체 시 UI 상태 즉시 초기화 (loadLocalFile 과 동일 정책)
|
||||||
|
store.setPlaying(false);
|
||||||
|
store.setCurrentTime(0);
|
||||||
|
store.setDuration(0);
|
||||||
const playRaw = () => player.src({ src: `/api/stream/${videoId}`, type: 'video/mp4' });
|
const playRaw = () => player.src({ src: `/api/stream/${videoId}`, type: 'video/mp4' });
|
||||||
|
|
||||||
// 코덱 확인 — 브라우저가 직접 못 푸는 코덱(HEVC 등)이면 HLS로 자동 재생
|
// 코덱 확인 — 브라우저가 직접 못 푸는 코덱(HEVC 등)이면 HLS로 자동 재생
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ const clamp = (v: number, lo: number, hi: number): number =>
|
|||||||
Math.min(hi, Math.max(lo, v));
|
Math.min(hi, Math.max(lo, v));
|
||||||
|
|
||||||
function stationKm(title: string): number {
|
function stationKm(title: string): number {
|
||||||
const m = title.match(/(\d+)[Kk](\d+)/);
|
const m = title.match(/(\d+)\s*[Kk+]\s*(\d+)/);
|
||||||
return m ? parseInt(m[1], 10) * 1000 + parseInt(m[2], 10) : -1;
|
return m ? parseInt(m[1], 10) * 1000 + parseInt(m[2], 10) : -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,26 +144,26 @@ function nearestStationKm(f: DroneFrameBasic, stations: GeoPoint[]): number {
|
|||||||
/** station 값(숫자=미터, "158k400" 문자열) → 미터. 실패 시 -1. */
|
/** station 값(숫자=미터, "158k400" 문자열) → 미터. 실패 시 -1. */
|
||||||
function mileageToMeters(v: number | string): number {
|
function mileageToMeters(v: number | string): number {
|
||||||
if (typeof v === 'number') return v;
|
if (typeof v === 'number') return v;
|
||||||
const m = String(v).match(/(\d+)\s*[kK]\s*(\d+)/);
|
const m = String(v).match(/(\d+)\s*[kK+]\s*(\d+)/);
|
||||||
if (m) return parseInt(m[1], 10) * 1000 + parseInt(m[2], 10);
|
if (m) return parseInt(m[1], 10) * 1000 + parseInt(m[2], 10);
|
||||||
const n = parseInt(String(v).replace(/[^0-9]/g, ''), 10);
|
const n = parseInt(String(v).replace(/[^0-9]/g, ''), 10);
|
||||||
return Number.isFinite(n) ? n : -1;
|
return Number.isFinite(n) ? n : -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 미터값 → "158k710" (10m 단위 반올림). 커서 배지용 십단위 표시. */
|
/** 미터값 → "158+710" (10m 단위 반올림). 커서 배지용 십단위 표시. */
|
||||||
function formatMileage10(m: number): string {
|
function formatMileage10(m: number): string {
|
||||||
const r = Math.round(m / 10) * 10;
|
const r = Math.round(m / 10) * 10;
|
||||||
const km = Math.floor(r / 1000);
|
const km = Math.floor(r / 1000);
|
||||||
const mm = r % 1000;
|
const mm = r % 1000;
|
||||||
return `${km}k${String(mm).padStart(3, '0')}`;
|
return `${km}+${String(mm).padStart(3, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 미터값 → "158k163" (1m 단위) — 커서 배지용. 노선밖 표기(1m)와 갱신 밀도를 맞춘다. */
|
/** 미터값 → "158+163" (1m 단위) — 커서 배지용. 노선밖 표기(1m)와 갱신 밀도를 맞춘다. */
|
||||||
function formatMileage1(m: number): string {
|
function formatMileage1(m: number): string {
|
||||||
const r = Math.round(m);
|
const r = Math.round(m);
|
||||||
const km = Math.floor(r / 1000);
|
const km = Math.floor(r / 1000);
|
||||||
const mm = r % 1000;
|
const mm = r % 1000;
|
||||||
return `${km}k${String(mm).padStart(3, '0')}`;
|
return `${km}+${String(mm).padStart(3, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -659,6 +659,28 @@ export function StationBar({
|
|||||||
return marks;
|
return marks;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 바 양끝 시작/끝 스테이션 — 각 끝 100m(체이니지) 이내에 다른 마크가 없으면
|
||||||
|
// 영상 시작·끝 지점의 측점값 마크를 추가(양끝이 비어 시작/끝 위치가 모호한 것 방지).
|
||||||
|
// 노선 밖(접근/이탈) 프레임은 체이니지가 클램프되므로 첫/마지막 '노선 위' 프레임 기준.
|
||||||
|
const appendEndpointStations = (marks: StructMark[]): StructMark[] => {
|
||||||
|
const va = viewedRef.current;
|
||||||
|
if (!va.length) return marks;
|
||||||
|
const onRoute = va.filter(v => v.offM <= OFF_ROUTE_M);
|
||||||
|
if (!onRoute.length) return marks;
|
||||||
|
const ends = [
|
||||||
|
{ v: onRoute[0], title: '시작' },
|
||||||
|
{ v: onRoute[onRoute.length - 1], title: '끝' },
|
||||||
|
];
|
||||||
|
for (const e of ends) {
|
||||||
|
const ch = e.v.chain;
|
||||||
|
if (marks.some(m => Math.abs(m.km - ch) <= 100)) continue;
|
||||||
|
const px = pxAtTime(e.v.time);
|
||||||
|
if (px === null) continue;
|
||||||
|
marks.push({ px, title: e.title, category: '측점', km: Math.round(ch), kmExists: true });
|
||||||
|
}
|
||||||
|
return marks;
|
||||||
|
};
|
||||||
|
|
||||||
// 구조물 후보 POI(교량/터널/역사) base 이름 → 실좌표.
|
// 구조물 후보 POI(교량/터널/역사) base 이름 → 실좌표.
|
||||||
const cats = new Set(['교량', '터널', '역사']);
|
const cats = new Set(['교량', '터널', '역사']);
|
||||||
const poiByName = new Map<string, { lat: number; lon: number; category: string }>();
|
const poiByName = new Map<string, { lat: number; lon: number; category: string }>();
|
||||||
@@ -741,7 +763,7 @@ export function StationBar({
|
|||||||
out.push({ px: p.px, title: s.name, category: cat, km: stationKmVal ?? p.km, kmExists, props: s.props });
|
out.push({ px: p.px, title: s.name, category: cat, km: stationKmVal ?? p.km, kmExists, props: s.props });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return placeUnreachedTerminal(appendStartMark(out));
|
return appendEndpointStations(placeUnreachedTerminal(appendStartMark(out)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 폴백: POI category 기반 (실좌표 GPS 근접, 모든 통과).
|
// 폴백: POI category 기반 (실좌표 GPS 근접, 모든 통과).
|
||||||
@@ -760,7 +782,7 @@ export function StationBar({
|
|||||||
out.push({ px: p.px, title: base, category: info.category, km: stationKmVal ?? p.km, kmExists });
|
out.push({ px: p.px, title: base, category: info.category, km: stationKmVal ?? p.km, kmExists });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return placeUnreachedTerminal(appendStartMark(out));
|
return appendEndpointStations(placeUnreachedTerminal(appendStartMark(out)));
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [ready, duration, storeFrames, pois, stations, pxAtTime, routeMeta, routeDirCh, storeStructures, pxAtMileage, endGapPx, stationLine, videoFps]);
|
}, [ready, duration, storeFrames, pois, stations, pxAtTime, routeMeta, routeDirCh, storeStructures, pxAtMileage, endGapPx, stationLine, videoFps]);
|
||||||
|
|
||||||
|
|||||||
@@ -14,11 +14,11 @@ import { cssVars, px } from '../../utils/cssVars';
|
|||||||
import { MileageMarker } from '../MileageMarker/MileageMarker';
|
import { MileageMarker } from '../MileageMarker/MileageMarker';
|
||||||
import styles from './Timeline.module.scss';
|
import styles from './Timeline.module.scss';
|
||||||
|
|
||||||
/** 미터값 → "158k160" (10m 단위). 음수면 빈 문자열. */
|
/** 미터값 → "158+160" (10m 단위). 음수면 빈 문자열. */
|
||||||
function fmtKm(m: number): string {
|
function fmtKm(m: number): string {
|
||||||
if (m < 0) return '';
|
if (m < 0) return '';
|
||||||
const r = Math.round(m / 10) * 10;
|
const r = Math.round(m / 10) * 10;
|
||||||
return `${Math.floor(r / 1000)}k${String(r % 1000).padStart(3, '0')}`;
|
return `${Math.floor(r / 1000)}+${String(r % 1000).padStart(3, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -46,12 +46,12 @@ export function pxForMileage(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 158204 → "158k200". */
|
/** 158204 → "158+200". */
|
||||||
export function formatMileage(mileage: number): string {
|
export function formatMileage(mileage: number): string {
|
||||||
const rounded = Math.round(mileage / MILEAGE_ROUND_M) * MILEAGE_ROUND_M;
|
const rounded = Math.round(mileage / MILEAGE_ROUND_M) * MILEAGE_ROUND_M;
|
||||||
const km = Math.floor(rounded / METERS_PER_KM);
|
const km = Math.floor(rounded / METERS_PER_KM);
|
||||||
const m = rounded % METERS_PER_KM;
|
const m = rounded % METERS_PER_KM;
|
||||||
return `${km}k${String(m).padStart(3, '0')}`;
|
return `${km}+${String(m).padStart(3, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Accepts "158k200", "158200", "161800"… Returns meters or null. */
|
/** Accepts "158k200", "158200", "161800"… Returns meters or null. */
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import type {
|
|||||||
DetectedCameraInfo,
|
DetectedCameraInfo,
|
||||||
} from '../types/geo';
|
} from '../types/geo';
|
||||||
import { loadFolderGeoData, applyPoiOverrides, parseDroneFrames } from '../utils/geoData';
|
import { loadFolderGeoData, applyPoiOverrides, parseDroneFrames } from '../utils/geoData';
|
||||||
|
import { refineFramesWithDjmd } from '../utils/djmdTrack';
|
||||||
|
|
||||||
interface GeoStore {
|
interface GeoStore {
|
||||||
loaded: boolean;
|
loaded: boolean;
|
||||||
@@ -60,6 +61,9 @@ interface GeoStore {
|
|||||||
displayJson: Record<string, unknown> | null;
|
displayJson: Record<string, unknown> | null;
|
||||||
/** 드론 고도 datum 보정(m) — 타원체고 데이터셋의 정표고→타원체고 변환량. 세그먼트 전환에도 적용. */
|
/** 드론 고도 datum 보정(m) — 타원체고 데이터셋의 정표고→타원체고 변환량. 세그먼트 전환에도 적용. */
|
||||||
altitudeOffsetM: number;
|
altitudeOffsetM: number;
|
||||||
|
/** frames 가 djmd(영상 내장 텔레메트리) 프레임 동기 위치로 정밀화됐는지.
|
||||||
|
* true 면 입력이 매끄러워 오버레이 포즈 평활(smoothHalf)을 최소로 제한한다. */
|
||||||
|
frameSynced: boolean;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 폴더 선택 파일에서 지리정보를 파싱해 스토어에 적재한다.
|
* 폴더 선택 파일에서 지리정보를 파싱해 스토어에 적재한다.
|
||||||
@@ -84,6 +88,7 @@ interface GeoStore {
|
|||||||
const EMPTY = {
|
const EMPTY = {
|
||||||
loaded: false,
|
loaded: false,
|
||||||
frames: [] as DroneFrame[],
|
frames: [] as DroneFrame[],
|
||||||
|
frameSynced: false,
|
||||||
pois: [] as GeoPoint[],
|
pois: [] as GeoPoint[],
|
||||||
basePois: [] as GeoPoint[],
|
basePois: [] as GeoPoint[],
|
||||||
poiOverrides: {} as PoiOverrideMap,
|
poiOverrides: {} as PoiOverrideMap,
|
||||||
@@ -105,6 +110,31 @@ const EMPTY = {
|
|||||||
altitudeOffsetM: 0,
|
altitudeOffsetM: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* djmd(영상 내장 텔레메트리) 정밀 위치 보정 — 백그라운드 적용.
|
||||||
|
* CSV 기반 프레임으로 즉시 표시한 뒤, 영상 파일에서 프레임 동기 위치/고도를 읽어 교체한다.
|
||||||
|
* (오르막/내리막·가감속에서 시간동기 오차를 원천 제거 — 자세는 CSV 유지)
|
||||||
|
*/
|
||||||
|
async function refineWithDjmd(
|
||||||
|
videoFile: File,
|
||||||
|
forIndex: number,
|
||||||
|
get: () => GeoStore,
|
||||||
|
set: (partial: Partial<GeoStore>) => void,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const s0 = get();
|
||||||
|
const refined = await refineFramesWithDjmd(videoFile, s0.frames, s0.altitudeOffsetM > 0);
|
||||||
|
if (!refined) return;
|
||||||
|
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}행, 프레임 동기)`);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[djmd] 정밀 위치 적용 실패(CSV 기반 유지):', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const useGeoStore = create<GeoStore>((set, get) => ({
|
export const useGeoStore = create<GeoStore>((set, get) => ({
|
||||||
...EMPTY,
|
...EMPTY,
|
||||||
|
|
||||||
@@ -113,6 +143,7 @@ export const useGeoStore = create<GeoStore>((set, get) => ({
|
|||||||
set({
|
set({
|
||||||
loaded: true,
|
loaded: true,
|
||||||
frames: data.frames,
|
frames: data.frames,
|
||||||
|
frameSynced: false,
|
||||||
basePois: data.pois,
|
basePois: data.pois,
|
||||||
poiOverrides: data.poiOverrides,
|
poiOverrides: data.poiOverrides,
|
||||||
pois: applyPoiOverrides(data.pois, data.poiOverrides),
|
pois: applyPoiOverrides(data.pois, data.poiOverrides),
|
||||||
@@ -133,6 +164,8 @@ export const useGeoStore = create<GeoStore>((set, get) => ({
|
|||||||
displayJson: data.displayJson,
|
displayJson: data.displayJson,
|
||||||
altitudeOffsetM: data.altitudeOffsetM,
|
altitudeOffsetM: data.altitudeOffsetM,
|
||||||
});
|
});
|
||||||
|
// 백그라운드 djmd 정밀 위치 보정 (완료 시 frames 교체 → 라벨 캐시 자동 재계산)
|
||||||
|
if (data.videoFile) void refineWithDjmd(data.videoFile, 0, get, set);
|
||||||
return data.videoFile;
|
return data.videoFile;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -152,7 +185,7 @@ export const useGeoStore = create<GeoStore>((set, get) => ({
|
|||||||
|
|
||||||
if (!offsetKnown) {
|
if (!offsetKnown) {
|
||||||
console.warn(`[geo] 세그먼트 ${index + 1} 오프셋 계산 불가(영상 길이 측정 실패) — 드론 궤적 미표시`);
|
console.warn(`[geo] 세그먼트 ${index + 1} 오프셋 계산 불가(영상 길이 측정 실패) — 드론 궤적 미표시`);
|
||||||
set({ frames: [], videoIndex: index });
|
set({ frames: [], videoIndex: index, frameSynced: false });
|
||||||
return file;
|
return file;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,7 +200,9 @@ export const useGeoStore = create<GeoStore>((set, get) => ({
|
|||||||
console.log(
|
console.log(
|
||||||
`[geo] 세그먼트 ${index + 1}/${s.videoFiles.length} 전환 — 로그 ${offsetSec.toFixed(1)}s~ 정렬, ${frames.length}행`,
|
`[geo] 세그먼트 ${index + 1}/${s.videoFiles.length} 전환 — 로그 ${offsetSec.toFixed(1)}s~ 정렬, ${frames.length}행`,
|
||||||
);
|
);
|
||||||
set({ frames, videoIndex: index });
|
set({ frames, videoIndex: index, frameSynced: false });
|
||||||
|
// 백그라운드 djmd 정밀 위치 보정 — 세그먼트 영상의 자체 텔레메트리 사용(프레임 동기)
|
||||||
|
void refineWithDjmd(file, index, get, set);
|
||||||
return file;
|
return file;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -12,10 +12,10 @@ export function kmFromTitle(title: string): number {
|
|||||||
return m ? parseInt(m[1], 10) * 1000 + parseInt(m[2], 10) : -1;
|
return m ? parseInt(m[1], 10) * 1000 + parseInt(m[2], 10) : -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 미터값 → "157k970" (10m 단위). */
|
/** 미터값 → "157+970" (10m 단위). */
|
||||||
export function fmtKm10(m: number): string {
|
export function fmtKm10(m: number): string {
|
||||||
const r = Math.round(m / 10) * 10;
|
const r = Math.round(m / 10) * 10;
|
||||||
return `${Math.floor(r / 1000)}k${String(r % 1000).padStart(3, '0')}`;
|
return `${Math.floor(r / 1000)}+${String(r % 1000).padStart(3, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 측점 POI 목록 → 평면투영 폴리라인(km 오름차순). 측점이 없으면 null. */
|
/** 측점 POI 목록 → 평면투영 폴리라인(km 오름차순). 측점이 없으면 null. */
|
||||||
|
|||||||
@@ -0,0 +1,318 @@
|
|||||||
|
/**
|
||||||
|
* DJI 영상 내장 텔레메트리(djmd 트랙) — 프레임 단위 정밀 위치/고도 추출
|
||||||
|
*
|
||||||
|
* DJI MP4 에는 비디오 프레임마다 djmd 데이터 샘플(protobuf)이 기록된다:
|
||||||
|
* 6.3 = 프레임 카운터, 6.4/6.5 = 위도/경도(라디안, double), 6.6 = 타원체고(mm)
|
||||||
|
* 이 위치는 해당 '영상 프레임'에 하드웨어 수준으로 동기되어 있어, 외부 비행로그(CSV,
|
||||||
|
* 10Hz + 시각 정렬 가정)의 시간 동기 오차가 원천적으로 없다 — 오르막/내리막/가감속에서
|
||||||
|
* POI 정합("풀 붙임")의 근본 해법. 자세(yaw/pitch/roll)는 djmd 에 없어 CSV 를 유지한다.
|
||||||
|
*
|
||||||
|
* 구현: MP4 박스(moov→trak→stbl)를 파싱해 djmd 트랙의 샘플 오프셋 테이블을 얻고,
|
||||||
|
* 전체 파일 스캔 없이 해당 바이트 구간만 골라 읽는다(기본 6프레임 간격 샘플링 ≈ 10Hz —
|
||||||
|
* 동기 정확도는 프레임 단위, 밀도는 CSV 동급).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { DroneFrame } from '../types/geo';
|
||||||
|
|
||||||
|
interface DjmdSample {
|
||||||
|
frame: number;
|
||||||
|
lat: number; // deg
|
||||||
|
lon: number; // deg
|
||||||
|
altM: number; // 타원체고 (m)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 저수준 리더 ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function readAt(file: File, off: number, len: number): Promise<DataView> {
|
||||||
|
const buf = await file.slice(off, Math.min(off + len, file.size)).arrayBuffer();
|
||||||
|
return new DataView(buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
function str4(dv: DataView, off: number): string {
|
||||||
|
return String.fromCharCode(dv.getUint8(off), dv.getUint8(off + 1), dv.getUint8(off + 2), dv.getUint8(off + 3));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 파일 최상위에서 type 박스 탐색 (mdat 등 대형 박스는 크기로 건너뜀). */
|
||||||
|
async function findTopBox(file: File, type: string): Promise<{ off: number; size: number; hdr: number } | null> {
|
||||||
|
let off = 0;
|
||||||
|
while (off + 8 <= file.size) {
|
||||||
|
const h = await readAt(file, off, 16);
|
||||||
|
if (h.byteLength < 8) return null;
|
||||||
|
let size = h.getUint32(0);
|
||||||
|
const t = str4(h, 4);
|
||||||
|
let hdr = 8;
|
||||||
|
if (size === 1) {
|
||||||
|
if (h.byteLength < 16) return null;
|
||||||
|
size = Number(h.getBigUint64(8));
|
||||||
|
hdr = 16;
|
||||||
|
} else if (size === 0) size = file.size - off;
|
||||||
|
if (t === type) return { off, size, hdr };
|
||||||
|
if (size < 8) return null;
|
||||||
|
off += size;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** DataView 내 자식 박스 순회. */
|
||||||
|
function* boxes(dv: DataView, start: number, end: number): Generator<{ type: string; start: number; end: number }> {
|
||||||
|
let off = start;
|
||||||
|
while (off + 8 <= end) {
|
||||||
|
let size = dv.getUint32(off);
|
||||||
|
const type = str4(dv, off + 4);
|
||||||
|
let hdr = 8;
|
||||||
|
if (size === 1) {
|
||||||
|
size = Number(dv.getBigUint64(off + 8));
|
||||||
|
hdr = 16;
|
||||||
|
} else if (size === 0) size = end - off;
|
||||||
|
if (size < hdr) return;
|
||||||
|
yield { type, start: off + hdr, end: off + Math.min(size, end - off) };
|
||||||
|
off += size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function findChild(dv: DataView, start: number, end: number, type: string): { start: number; end: number } | null {
|
||||||
|
for (const b of boxes(dv, start, end)) if (b.type === type) return { start: b.start, end: b.end };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── protobuf 미니 파서 (cameraMeta 와 동일 방식) ──────────────────────
|
||||||
|
|
||||||
|
function readVarint(b: Uint8Array, i: number): [number, number] | null {
|
||||||
|
let v = 0, s = 0;
|
||||||
|
while (i < b.length && (b[i] & 0x80)) {
|
||||||
|
v += (b[i] & 0x7f) * 2 ** s;
|
||||||
|
s += 7; i++;
|
||||||
|
if (s > 42) return null;
|
||||||
|
}
|
||||||
|
if (i >= b.length) return null;
|
||||||
|
v += (b[i] & 0x7f) * 2 ** s;
|
||||||
|
return [v, i + 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** djmd 샘플에서 6.3(frame)/6.4(lat rad)/6.5(lon rad)/6.6(alt mm) 추출. */
|
||||||
|
function parseSample(b: Uint8Array): DjmdSample | null {
|
||||||
|
const dv = new DataView(b.buffer, b.byteOffset, b.byteLength);
|
||||||
|
let frame: number | undefined, lat: number | undefined, lon: number | undefined, alt: number | undefined;
|
||||||
|
const walk = (start: number, end: number, depth: number, parentTag: number): void => {
|
||||||
|
let i = start;
|
||||||
|
while (i < end) {
|
||||||
|
const kv = readVarint(b, i);
|
||||||
|
if (!kv) return;
|
||||||
|
const [key, ni] = kv;
|
||||||
|
i = ni;
|
||||||
|
const tag = key >> 3, wire = key & 7;
|
||||||
|
if (tag === 0 || tag > 60) return;
|
||||||
|
if (wire === 0) {
|
||||||
|
const r = readVarint(b, i); if (!r) return;
|
||||||
|
if (depth === 1 && parentTag === 6) {
|
||||||
|
if (tag === 3) frame = r[0];
|
||||||
|
else if (tag === 6) alt = r[0] / 1000;
|
||||||
|
}
|
||||||
|
i = r[1];
|
||||||
|
} else if (wire === 5) { i += 4; }
|
||||||
|
else if (wire === 1) {
|
||||||
|
if (i + 8 > end) return;
|
||||||
|
if (depth === 1 && parentTag === 6) {
|
||||||
|
const val = dv.getFloat64(i, true) * 180 / Math.PI;
|
||||||
|
if (tag === 4) lat = val;
|
||||||
|
else if (tag === 5) lon = val;
|
||||||
|
}
|
||||||
|
i += 8;
|
||||||
|
} else if (wire === 2) {
|
||||||
|
const r = readVarint(b, i); if (!r) return;
|
||||||
|
const [len, p] = r;
|
||||||
|
if (p + len > end) return;
|
||||||
|
if (depth === 0 && tag === 6) walk(p, p + len, 1, 6);
|
||||||
|
i = p + len;
|
||||||
|
} else return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
walk(0, b.length, 0, 0);
|
||||||
|
if (frame === undefined || lat === undefined || lon === undefined) return null;
|
||||||
|
if (!(lat > -90 && lat < 90 && lon > -180 && lon < 180)) return null;
|
||||||
|
return { frame, lat, lon, altM: alt ?? 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── djmd 샘플 테이블 파싱 + 선별 읽기 ─────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 영상 파일의 djmd 트랙에서 프레임별 위치 샘플을 읽는다 (step 프레임 간격).
|
||||||
|
* DJI 영상이 아니거나 트랙이 없으면 null.
|
||||||
|
*/
|
||||||
|
export async function parseDjmdPositions(file: File, step = 6): Promise<DjmdSample[] | null> {
|
||||||
|
const moov = await findTopBox(file, 'moov');
|
||||||
|
if (!moov || moov.size > 64 * 1024 * 1024) return null;
|
||||||
|
const mv = await readAt(file, moov.off + moov.hdr, moov.size - moov.hdr);
|
||||||
|
const end = mv.byteLength;
|
||||||
|
|
||||||
|
// djmd stsd 를 가진 trak 의 stbl 탐색
|
||||||
|
let stbl: { start: number; end: number } | null = null;
|
||||||
|
for (const trak of boxes(mv, 0, end)) {
|
||||||
|
if (trak.type !== 'trak') continue;
|
||||||
|
const mdia = findChild(mv, trak.start, trak.end, 'mdia');
|
||||||
|
if (!mdia) continue;
|
||||||
|
const minf = findChild(mv, mdia.start, mdia.end, 'minf');
|
||||||
|
if (!minf) continue;
|
||||||
|
const st = findChild(mv, minf.start, minf.end, 'stbl');
|
||||||
|
if (!st) continue;
|
||||||
|
const stsd = findChild(mv, st.start, st.end, 'stsd');
|
||||||
|
if (!stsd) continue;
|
||||||
|
// stsd: ver/flags(4) count(4) → entry: size(4) fourcc(4)
|
||||||
|
if (stsd.end - stsd.start >= 16 && str4(mv, stsd.start + 12) === 'djmd') { stbl = st; break; }
|
||||||
|
}
|
||||||
|
if (!stbl) return null;
|
||||||
|
|
||||||
|
const stsz = findChild(mv, stbl.start, stbl.end, 'stsz');
|
||||||
|
const stsc = findChild(mv, stbl.start, stbl.end, 'stsc');
|
||||||
|
const stco = findChild(mv, stbl.start, stbl.end, 'stco');
|
||||||
|
const co64 = findChild(mv, stbl.start, stbl.end, 'co64');
|
||||||
|
if (!stsz || !stsc || (!stco && !co64)) return null;
|
||||||
|
|
||||||
|
// stsz — 샘플 크기
|
||||||
|
const fixedSize = mv.getUint32(stsz.start + 4);
|
||||||
|
const sampleCount = mv.getUint32(stsz.start + 8);
|
||||||
|
if (!sampleCount) return null;
|
||||||
|
const sizeOf = (i: number): number => (fixedSize ? fixedSize : mv.getUint32(stsz.start + 12 + i * 4));
|
||||||
|
|
||||||
|
// stco/co64 — 청크 오프셋
|
||||||
|
const chunkCount = mv.getUint32((co64 ?? stco!).start + 4);
|
||||||
|
const chunkOff = (c: number): number =>
|
||||||
|
co64 ? Number(mv.getBigUint64(co64.start + 8 + c * 8)) : mv.getUint32(stco!.start + 8 + c * 4);
|
||||||
|
|
||||||
|
// stsc — 청크당 샘플 수 (구간 압축) 전개
|
||||||
|
const stscCount = mv.getUint32(stsc.start + 4);
|
||||||
|
const stscEntry = (i: number): [number, number] => [
|
||||||
|
mv.getUint32(stsc.start + 8 + i * 12), // first_chunk (1-base)
|
||||||
|
mv.getUint32(stsc.start + 8 + i * 12 + 4), // samples_per_chunk
|
||||||
|
];
|
||||||
|
|
||||||
|
// 샘플 인덱스 → 파일 오프셋 테이블 (step 간격 대상만)
|
||||||
|
const targets: { idx: number; off: number; size: number }[] = [];
|
||||||
|
let sampleIdx = 0, entry = 0;
|
||||||
|
for (let c = 0; c < chunkCount && sampleIdx < sampleCount; c++) {
|
||||||
|
while (entry + 1 < stscCount && stscEntry(entry + 1)[0] <= c + 1) entry++;
|
||||||
|
const spc = stscEntry(entry)[1];
|
||||||
|
let off = chunkOff(c);
|
||||||
|
for (let s = 0; s < spc && sampleIdx < sampleCount; s++, sampleIdx++) {
|
||||||
|
const sz = sizeOf(sampleIdx);
|
||||||
|
if (sampleIdx % step === 0) targets.push({ idx: sampleIdx, off, size: sz });
|
||||||
|
off += sz;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (targets.length < 5) return null;
|
||||||
|
|
||||||
|
// 배치 읽기(동시 64) + 파싱
|
||||||
|
const out: DjmdSample[] = [];
|
||||||
|
const BATCH = 64;
|
||||||
|
for (let i = 0; i < targets.length; i += BATCH) {
|
||||||
|
const batch = targets.slice(i, i + BATCH);
|
||||||
|
const parsed = await Promise.all(batch.map(async (t) => {
|
||||||
|
const dv = await readAt(file, t.off, t.size);
|
||||||
|
return parseSample(new Uint8Array(dv.buffer));
|
||||||
|
}));
|
||||||
|
for (const p of parsed) if (p) out.push(p);
|
||||||
|
}
|
||||||
|
out.sort((a, b) => a.frame - b.frame);
|
||||||
|
return out.length >= 5 ? out : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DroneFrame 정밀 위치 병합 ─────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CSV 기반 프레임의 위치/고도를 djmd(영상 내장, 프레임 동기) 값으로 교체하고,
|
||||||
|
* **자세(yaw/pitch/roll)도 CSV↔영상 시간 오프셋을 자동 추정해 재정렬**한다.
|
||||||
|
*
|
||||||
|
* 시간 오프셋 추정: CSV 위치(시각 정렬 가정)와 djmd 위치(프레임 동기 실측)를
|
||||||
|
* 교차상관해 평균 거리가 최소가 되는 dt 를 찾는다. 이 dt 로 자세를 재샘플하면
|
||||||
|
* 옆으로 지나치는 지물(상대 방위가 빠르게 변함)의 라벨 미끄러짐이 제거된다.
|
||||||
|
*
|
||||||
|
* 고도 datum: djmd 는 타원체고 —
|
||||||
|
* - useEllipsoidal(타원체고 데이터셋): 그대로 사용
|
||||||
|
* - 정표고 데이터셋: 첫 샘플과 CSV 첫 프레임의 차(지오이드고)를 빼서 정표고로 변환
|
||||||
|
*/
|
||||||
|
export async function refineFramesWithDjmd(
|
||||||
|
videoFile: File,
|
||||||
|
frames: DroneFrame[],
|
||||||
|
useEllipsoidal: boolean,
|
||||||
|
): Promise<DroneFrame[] | null> {
|
||||||
|
if (!frames.length) return null;
|
||||||
|
const samples = await parseDjmdPositions(videoFile);
|
||||||
|
if (!samples) return null;
|
||||||
|
const FPS = 59.94; // 프레임 번호 ↔ 시간 환산 (DJI_VIRTUAL_FPS 와 동일 기준)
|
||||||
|
|
||||||
|
// 정표고 데이터셋: 지오이드고 = djmd 타원체고 − CSV 고도 (같은 프레임 부근)
|
||||||
|
let altShift = 0;
|
||||||
|
if (!useEllipsoidal) {
|
||||||
|
const s0 = samples[0];
|
||||||
|
let nearest = frames[0];
|
||||||
|
for (const f of frames) if (Math.abs(f.frame - s0.frame) < Math.abs(nearest.frame - s0.frame)) nearest = f;
|
||||||
|
altShift = s0.altM - nearest.altitude;
|
||||||
|
if (!isFinite(altShift) || Math.abs(altShift) > 80) return null; // 비정상 → 사용 안 함
|
||||||
|
}
|
||||||
|
|
||||||
|
// 프레임 번호 기준 선형 보간 (djmd 위치)
|
||||||
|
const at = (frame: number): { lat: number; lon: number; altM: number } => {
|
||||||
|
let lo = 0, hi = samples.length - 1;
|
||||||
|
if (frame <= samples[0].frame) return samples[0];
|
||||||
|
if (frame >= samples[hi].frame) return samples[hi];
|
||||||
|
while (hi - lo > 1) { const m = (lo + hi) >> 1; if (samples[m].frame <= frame) lo = m; else hi = m; }
|
||||||
|
const a = samples[lo], b = samples[hi];
|
||||||
|
const t = b.frame > a.frame ? (frame - a.frame) / (b.frame - a.frame) : 0;
|
||||||
|
return {
|
||||||
|
lat: a.lat + (b.lat - a.lat) * t,
|
||||||
|
lon: a.lon + (b.lon - a.lon) * t,
|
||||||
|
altM: a.altM + (b.altM - a.altM) * t,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── CSV 시각(t=frame/FPS) 기준 보간 (위치+자세) — 시간 오프셋 추정·자세 재샘플용 ──
|
||||||
|
const ct = frames.map((f) => f.frame / FPS);
|
||||||
|
const cosLat = Math.cos((frames[0].lat * Math.PI) / 180);
|
||||||
|
const csvAt = (t: number): DroneFrame => {
|
||||||
|
const n = ct.length;
|
||||||
|
if (t <= ct[0]) return frames[0];
|
||||||
|
if (t >= ct[n - 1]) return frames[n - 1];
|
||||||
|
let lo = 0, hi = n - 1;
|
||||||
|
while (hi - lo > 1) { const m = (lo + hi) >> 1; if (ct[m] <= t) lo = m; else hi = m; }
|
||||||
|
const a = frames[lo], b = frames[hi];
|
||||||
|
const r = ct[hi] > ct[lo] ? (t - ct[lo]) / (ct[hi] - ct[lo]) : 0;
|
||||||
|
let dy = b.yaw - a.yaw; dy = ((dy + 540) % 360) - 180; // 최단각
|
||||||
|
return {
|
||||||
|
...a,
|
||||||
|
lat: a.lat + (b.lat - a.lat) * r,
|
||||||
|
lon: a.lon + (b.lon - a.lon) * r,
|
||||||
|
yaw: a.yaw + dy * r,
|
||||||
|
pitch: a.pitch + (b.pitch - a.pitch) * r,
|
||||||
|
roll: a.roll + (b.roll - a.roll) * r,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// CSV↔영상 시간 오프셋(dt) 추정 — 위치 교차상관 (조밀 50포인트, 조사 ±1.5s → 0.01s 정밀)
|
||||||
|
const stride = Math.max(1, Math.floor(samples.length / 50));
|
||||||
|
const probes = samples.filter((_, i) => i % stride === 0);
|
||||||
|
const cost = (dt: number): number => {
|
||||||
|
let s = 0, n = 0;
|
||||||
|
for (const p of probes) {
|
||||||
|
const c = csvAt(p.frame / FPS + dt);
|
||||||
|
s += Math.hypot((p.lat - c.lat) * 111000, (p.lon - c.lon) * 111000 * cosLat);
|
||||||
|
n++;
|
||||||
|
}
|
||||||
|
return n ? s / n : Infinity;
|
||||||
|
};
|
||||||
|
let bestDt = 0, bestC = cost(0);
|
||||||
|
for (let dt = -1.5; dt <= 1.5001; dt += 0.05) { const c = cost(dt); if (c < bestC) { bestC = c; bestDt = dt; } }
|
||||||
|
for (let dt = bestDt - 0.05; dt <= bestDt + 0.0501; dt += 0.01) { const c = cost(dt); if (c < bestC) { bestC = c; bestDt = dt; } }
|
||||||
|
if (Math.abs(bestDt) > 0.005) {
|
||||||
|
console.log(`[djmd] CSV↔영상 시간 오프셋 ${bestDt.toFixed(2)}s 추정 (잔차 ${bestC.toFixed(1)}m) — 자세 재정렬 적용`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return frames.map((f) => {
|
||||||
|
const p = at(f.frame); // 위치/고도: djmd (프레임 동기)
|
||||||
|
const a = csvAt(f.frame / FPS + bestDt); // 자세: CSV 를 추정 오프셋으로 재시각화
|
||||||
|
return {
|
||||||
|
...f,
|
||||||
|
lat: p.lat, lon: p.lon, altitude: p.altM - altShift,
|
||||||
|
yaw: a.yaw, pitch: a.pitch, roll: a.roll,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -504,7 +504,21 @@ export async function parseKmz(
|
|||||||
prevKey = key;
|
prevKey = key;
|
||||||
pts.push({ lat: p[1], lon: p[0], z: p.length >= 3 && isFinite(p[2]) ? p[2] : 0 });
|
pts.push({ lat: p[1], lon: p[0], z: p.length >= 3 && isFinite(p[2]) ? p[2] : 0 });
|
||||||
}
|
}
|
||||||
if (pts.length >= 2 && pts.length > centerline.length) centerline = pts;
|
if (pts.length >= 2 && pts.length > centerline.length) {
|
||||||
|
// z 톱니 노이즈 평활 — 원본(지형고도 샘플링)의 ±1m 양자화 잔차가 인접 정점에서
|
||||||
|
// 부호 반전 톱니를 만들고, 영상의 낮은 시야각 투영에서 선이 지그재그로 꺾여 보임.
|
||||||
|
// 도로 종단 경사는 완만하므로 ±3점 이동평균으로 톱니만 제거(경사 보존).
|
||||||
|
const zs = pts.map((p) => p.z);
|
||||||
|
for (let i = 0; i < pts.length; i++) {
|
||||||
|
if (zs[i] <= 0) continue;
|
||||||
|
let sum = 0, cnt = 0;
|
||||||
|
for (let j = Math.max(0, i - 3); j <= Math.min(pts.length - 1, i + 3); j++) {
|
||||||
|
if (zs[j] > 0) { sum += zs[j]; cnt++; }
|
||||||
|
}
|
||||||
|
if (cnt) pts[i].z = sum / cnt;
|
||||||
|
}
|
||||||
|
centerline = pts;
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -575,7 +589,7 @@ export async function parseKmz(
|
|||||||
? Math.round(kmProp * 1000)
|
? Math.round(kmProp * 1000)
|
||||||
: staMatch ? parseInt(staMatch[1], 10) * 1000 + parseInt(staMatch[2], 10) : NaN;
|
: staMatch ? parseInt(staMatch[1], 10) * 1000 + parseInt(staMatch[2], 10) : NaN;
|
||||||
if (!isFinite(meters)) return;
|
if (!isFinite(meters)) return;
|
||||||
const title = `${Math.floor(meters / 1000)}k${String(meters % 1000).padStart(3, '0')}`;
|
const title = `${Math.floor(meters / 1000)}+${String(meters % 1000).padStart(3, '0')}`;
|
||||||
// z: 좌표 고도(DEM 기록값) 있으면 사용 — 없으면 0(미상, 오버레이가 경로표고 앵커).
|
// z: 좌표 고도(DEM 기록값) 있으면 사용 — 없으면 0(미상, 오버레이가 경로표고 앵커).
|
||||||
stations.push({ title, category: '측점', lat, lon, z: coordAlt ?? 0, type: 'station', props });
|
stations.push({ title, category: '측점', lat, lon, z: coordAlt ?? 0, type: 'station', props });
|
||||||
return;
|
return;
|
||||||
@@ -615,6 +629,67 @@ export async function parseKmz(
|
|||||||
if (doc.documentElement) walk(doc.documentElement, '');
|
if (doc.documentElement) walk(doc.documentElement, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 선형이 측점 POI 를 정확히 통과하도록 정비 — 측점 기준.
|
||||||
|
// ① 측점 위치를 선형 폴리라인에 '정점으로 삽입' (긴 직선 현 중간의 측점도 통과 보장 —
|
||||||
|
// 라벨과 선이 같은 점(lat/lon/z)을 투영하므로 어떤 카메라 자세에서도 화면에서 일치)
|
||||||
|
// ② 나머지 원본 정점 z 는 측점 z 의 체이니지 보간으로 교체 (톱니 평활 대체, 종단 = 측점)
|
||||||
|
const staZ = stations.filter((s) => isFinite(s.z) && s.z > 0);
|
||||||
|
if (centerline.length >= 2 && staZ.length >= 2) {
|
||||||
|
const cosLat = Math.cos((centerline[0].lat * Math.PI) / 180);
|
||||||
|
const ex = (p: { lat: number; lon: number }): number => (p.lon - centerline[0].lon) * 111320 * cosLat;
|
||||||
|
const ey = (p: { lat: number; lon: number }): number => (p.lat - centerline[0].lat) * 111320;
|
||||||
|
const cum: number[] = [0];
|
||||||
|
for (let i = 1; i < centerline.length; i++) {
|
||||||
|
cum.push(cum[i - 1] + Math.hypot(ex(centerline[i]) - ex(centerline[i - 1]), ey(centerline[i]) - ey(centerline[i - 1])));
|
||||||
|
}
|
||||||
|
// 측점 → 선형 세그먼트 투영 (선형에서 50m 이상 떨어진 측점은 제외)
|
||||||
|
const anchors: { s: number; z: number; seg: number; t: number; lat: number; lon: number }[] = [];
|
||||||
|
for (const st of staZ) {
|
||||||
|
const px = ex(st), py = ey(st);
|
||||||
|
let bestS = 0, bestD = Infinity, bestSeg = 0, bestT = 0;
|
||||||
|
for (let i = 0; i < centerline.length - 1; i++) {
|
||||||
|
const ax = ex(centerline[i]), ay = ey(centerline[i]);
|
||||||
|
const dx = ex(centerline[i + 1]) - ax, dy = ey(centerline[i + 1]) - ay;
|
||||||
|
const len2 = dx * dx + dy * dy;
|
||||||
|
const t = len2 > 0 ? Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / len2)) : 0;
|
||||||
|
const d = Math.hypot(px - (ax + dx * t), py - (ay + dy * t));
|
||||||
|
if (d < bestD) { bestD = d; bestS = cum[i] + Math.sqrt(len2) * t; bestSeg = i; bestT = t; }
|
||||||
|
}
|
||||||
|
if (bestD < 50) anchors.push({ s: bestS, z: st.z, seg: bestSeg, t: bestT, lat: st.lat, lon: st.lon });
|
||||||
|
}
|
||||||
|
anchors.sort((a, b) => a.s - b.s);
|
||||||
|
if (anchors.length >= 2) {
|
||||||
|
// ② 원본 정점 z ← 측점 체이니지 보간
|
||||||
|
for (let i = 0; i < centerline.length; i++) {
|
||||||
|
const s = cum[i];
|
||||||
|
if (s <= anchors[0].s) { centerline[i].z = anchors[0].z; continue; }
|
||||||
|
if (s >= anchors[anchors.length - 1].s) { centerline[i].z = anchors[anchors.length - 1].z; continue; }
|
||||||
|
let lo = 0, hi = anchors.length - 1;
|
||||||
|
while (hi - lo > 1) { const m = (lo + hi) >> 1; if (anchors[m].s <= s) lo = m; else hi = m; }
|
||||||
|
const a = anchors[lo], b = anchors[hi];
|
||||||
|
const r = b.s > a.s ? (s - a.s) / (b.s - a.s) : 0;
|
||||||
|
centerline[i].z = a.z + (b.z - a.z) * r;
|
||||||
|
}
|
||||||
|
// ① 측점 정점 삽입 — 세그먼트별로 t 순 삽입. 기존 정점과 1m 이내면 생략(중복 방지).
|
||||||
|
const rebuilt: CenterlinePoint[] = [];
|
||||||
|
let inserted = 0;
|
||||||
|
for (let i = 0; i < centerline.length; i++) {
|
||||||
|
rebuilt.push(centerline[i]);
|
||||||
|
if (i === centerline.length - 1) break;
|
||||||
|
const onSeg = anchors.filter((a) => a.seg === i).sort((a, b) => a.t - b.t);
|
||||||
|
for (const a of onSeg) {
|
||||||
|
const near = (p: CenterlinePoint): boolean =>
|
||||||
|
Math.hypot(ex(a) - ex(p), ey(a) - ey(p)) < 1;
|
||||||
|
if (near(centerline[i]) || near(centerline[i + 1])) continue;
|
||||||
|
rebuilt.push({ lat: a.lat, lon: a.lon, z: a.z });
|
||||||
|
inserted++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
centerline = rebuilt;
|
||||||
|
console.log(`[KML] 선형-측점 정합: 측점 정점 ${inserted}개 삽입 + 정점 z 를 측점 ${anchors.length}점 기준 재정렬`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return { pois, structures, stations, centerline };
|
return { pois, structures, stations, centerline };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ export interface DroneFrameBasic {
|
|||||||
* offX/offY/offZ: 드론 위치 보정 — Python off_x/y/z 동치.
|
* offX/offY/offZ: 드론 위치 보정 — Python off_x/y/z 동치.
|
||||||
*/
|
*/
|
||||||
export interface CameraParams {
|
export interface CameraParams {
|
||||||
|
timeOffsetSec?: number; // 영상↔비행로그 시간동기 보정(초). + = 로그를 뒤로 밀어 참조.
|
||||||
|
// POI 가 진행방향으로 일괄 밀릴 때 조정 — "풀 붙임" 정합의 핵심 값.
|
||||||
yawOffset: number; // yaw 오프셋 (degrees, per-frame SRT yaw에 더함)
|
yawOffset: number; // yaw 오프셋 (degrees, per-frame SRT yaw에 더함)
|
||||||
pitch: number; // pitch 오프셋 (degrees, per-frame SRT pitch에 더함, 기본 0)
|
pitch: number; // pitch 오프셋 (degrees, per-frame SRT pitch에 더함, 기본 0)
|
||||||
roll: number; // roll 오프셋 (degrees, per-frame SRT roll에 더함, 기본 0)
|
roll: number; // roll 오프셋 (degrees, per-frame SRT roll에 더함, 기본 0)
|
||||||
@@ -51,6 +53,7 @@ export interface CameraParams {
|
|||||||
|
|
||||||
/** Python advanced_tuner_v2.py 기본값 */
|
/** Python advanced_tuner_v2.py 기본값 */
|
||||||
export const DEFAULT_CAMERA_PARAMS: CameraParams = {
|
export const DEFAULT_CAMERA_PARAMS: CameraParams = {
|
||||||
|
timeOffsetSec: 0,
|
||||||
yawOffset: 0,
|
yawOffset: 0,
|
||||||
pitch: 0, // offset, Python spn_pitch 기본값 0
|
pitch: 0, // offset, Python spn_pitch 기본값 0
|
||||||
roll: 0, // offset, Python spn_roll 기본값 0
|
roll: 0, // offset, Python spn_roll 기본값 0
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# 2026-07-13 개발분 전체 커밋 (2차)
|
||||||
|
|
||||||
|
**소요 시간**: 2분
|
||||||
|
**Context 사용량**: input 2930k / output 1k tokens
|
||||||
|
|
||||||
|
## 작업 내용
|
||||||
|
|
||||||
|
7/9 커밋(84f4921) 이후 개발분 전체를 main 에 커밋:
|
||||||
|
|
||||||
|
- **7d51ba9** "표고 체계 정비·설정 파일 단순화·선형 클리핑·시연 준비" (53파일 +1708/−104)
|
||||||
|
- 주요: 타원체고 자동 변환, byDem 자동 생성, 설정 단순 이름, 선형 클립(관통 수정),
|
||||||
|
깜빡임 구조 수정, dev_mode, 시연 스크립트, 히스토리 30건
|
||||||
|
|
||||||
|
## 참고
|
||||||
|
|
||||||
|
- push 는 사용자 직접 수행 (환경에 gitea 인증 없음)
|
||||||
|
- 이 히스토리 파일은 커밋 후 생성분 — 다음 커밋에 포함
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# 2026-07-13 push 완료 + GCM 자격 증명 연동
|
||||||
|
|
||||||
|
**소요 시간**: 4분
|
||||||
|
**Context 사용량**: input 2980k / output 2k tokens
|
||||||
|
|
||||||
|
## 작업 내용
|
||||||
|
|
||||||
|
- origin/main = 7d51ba9 동기화 확인 (Everything up-to-date)
|
||||||
|
- **WSL git → Windows Git Credential Manager 연동** (저장소 로컬 credential.helper):
|
||||||
|
`git config credential.helper '!"/mnt/c/Program Files/Git/mingw64/bin/git-credential-manager.exe"'`
|
||||||
|
- fetch/push 인증 무프롬프트 동작 확인 — 이후 세션에서 push 직접 수행 가능
|
||||||
|
- 비밀번호는 GCM 내부 처리(세션에 비노출). 직접 조회 방식은 권한 정책상 차단되어
|
||||||
|
helper 위임 방식으로 대체
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# 2026-07-14 재부팅 후 54000 포트 자동 개방 (시작프로그램 등록)
|
||||||
|
|
||||||
|
**소요 시간**: 8분
|
||||||
|
**Context 사용량**: input 3040k / output 3k tokens
|
||||||
|
|
||||||
|
## 문제
|
||||||
|
|
||||||
|
재부팅 후 54000 포트 닫힘 — Windows 포워딩·방화벽 규칙은 유지되지만
|
||||||
|
**WSL 내 pm2 서비스가 자동 시작되지 않아** 리슨 프로세스가 없었음.
|
||||||
|
|
||||||
|
## 조치 (사용자 명시 요청으로 자동 시작 등록)
|
||||||
|
|
||||||
|
1. pm2 save — ghiVideo(55000)·ghiVideo-v4(54000) 목록 저장 확인
|
||||||
|
(videoplayer(5173)는 이번 목록에 없음 — 필요 시 별도 기동 후 재저장)
|
||||||
|
2. **Windows 시작프로그램에 GhiVideo-pm2.vbs 등록**
|
||||||
|
(`C:\Users\User\...\Startup\`, 기존 AI-Video-Brief.vbs 와 동일 패턴):
|
||||||
|
`wsl.exe -d Ubuntu-22.04 -u hanmac bash -c "setsid bash -c 'source ~/.nvm/nvm.sh && pm2 resurrect' ..."`
|
||||||
|
3. 동일 명령 실행 검증: 서비스 무손상, 54000 응답 200 ✓
|
||||||
|
|
||||||
|
## 재부팅 후 동작 체계 (전부 자동)
|
||||||
|
|
||||||
|
- Windows **로그인** 시 → vbs → WSL 부팅 + pm2 resurrect → 54000/55000 기동
|
||||||
|
- portproxy 0.0.0.0:54000→127.0.0.1(재부팅·WSL IP 변경 무관) + 방화벽 규칙 유지
|
||||||
|
- ※ 부팅만으로는 안 되고 **로그인까지** 되어야 함(시작프로그램 특성, 기존 스크립트와 동일)
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# 2026-07-14 54000 접속 불가 — 포워딩 직접 IP + 로그온 자동 갱신
|
||||||
|
|
||||||
|
**소요 시간**: 7분
|
||||||
|
**Context 사용량**: input 3090k / output 3k tokens
|
||||||
|
|
||||||
|
## 문제
|
||||||
|
|
||||||
|
재부팅 후 54000 외부 접속 불가. 진단: WSL 리슨 ✓, WSL 로컬 200 ✓, portproxy 규칙 ✓,
|
||||||
|
**Windows 127.0.0.1:54000 타임아웃** — 127.0.0.1 중계 방식이 재부팅 후 wslrelay 바인딩
|
||||||
|
순서 문제로 portproxy 가 자기 자신으로 되돌아가는 루프 발생.
|
||||||
|
|
||||||
|
## 조치 (사용자 UAC 승인 1회)
|
||||||
|
|
||||||
|
1. portproxy 54000 을 **직접 WSL IP(172.21.50.250) 방식**으로 변경 (55000 과 동일)
|
||||||
|
2. **WSL IP 변경 대응 자동화**: 로그온 시 관리자 권한 예약 작업 `WSL-PortProxy-Update` 등록
|
||||||
|
- `C:\Users\Public\wsl-portproxy-update.ps1`: WSL 기동 대기(최대 60s) → `hostname -I` 로
|
||||||
|
현재 IP 조회 → 54000/55000 규칙 재설정
|
||||||
|
- 설정 스크립트: `C:\Users\Public\wsl-portproxy-setup.ps1` (등록 + 즉시 1회 실행)
|
||||||
|
|
||||||
|
## 검증
|
||||||
|
|
||||||
|
- Windows 루프백 200 ✓, LAN IP(172.16.40.179:54000) 200 ✓
|
||||||
|
|
||||||
|
## 재부팅 후 자동 체계 (최종)
|
||||||
|
|
||||||
|
1. 로그온 → 시작프로그램 vbs → WSL + pm2 resurrect (54000/55000 기동)
|
||||||
|
2. 로그온 → 예약 작업(관리자) → 현재 WSL IP 로 portproxy 갱신
|
||||||
|
3. 방화벽 규칙(영구) → 외부 접속 OK — 전 과정 무개입
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# 2026-07-14 데이터 로드 = 0초 + 일시정지 (수동 재생)
|
||||||
|
|
||||||
|
**소요 시간**: 5분
|
||||||
|
**Context 사용량**: input 3140k / output 2k tokens
|
||||||
|
|
||||||
|
## 요청 2건
|
||||||
|
|
||||||
|
1. 폴더 로드 시 이전 재생 위치에서 이어지는 경우가 있음 → 항상 처음부터
|
||||||
|
2. 로드 후 자동재생 없이 대기 — 재생 버튼/스페이스로만 시작
|
||||||
|
|
||||||
|
## 수정 — client/src/hooks/useVideoPlayer.ts (loadLocalFile)
|
||||||
|
|
||||||
|
- **0초 강제 리셋**: loadedmetadata 시점에 currentTime(0) (소스 교체 경합 가드 포함)
|
||||||
|
- **자동재생 제거**: 기존 wasPlaying(교체 직전 재생 중이면 이어재생) 로직 삭제 —
|
||||||
|
로드는 항상 일시정지 상태로만 준비
|
||||||
|
- 분할영상 연속재생(ended)·재생목록 선택은 호출부에서 명시적 play() → 기존 동작 유지
|
||||||
|
|
||||||
|
## 검증
|
||||||
|
|
||||||
|
- tsc+vite 빌드 통과, 새 번들(index-CGW0uGP6.js) 서빙
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# 2026-07-14 데이터 로딩 완료 시 '정지' 명령 실행
|
||||||
|
|
||||||
|
**소요 시간**: 4분
|
||||||
|
**Context 사용량**: input 3190k / output 2k tokens
|
||||||
|
|
||||||
|
## 요청
|
||||||
|
|
||||||
|
폴더 로드 후 애매한 '일시정지' 상태에 빠지는 경우가 있음 → 로딩 완료 시 '정지'(pause+0초)
|
||||||
|
명령이 실행되게.
|
||||||
|
|
||||||
|
## 수정
|
||||||
|
|
||||||
|
### client/src/hooks/useVideoPlayer.ts — loadLocalFile(file, autoPlay=false)
|
||||||
|
- loadedmetadata(로딩 완료) 시점에:
|
||||||
|
- autoPlay=false(폴더 로드): **currentTime(0) + pause() = 정지 명령** — 스톱 버튼과 동일 상태
|
||||||
|
- autoPlay=true: 0초부터 즉시 재생
|
||||||
|
- 소스 교체 경합 가드(objectUrl 일치 확인) 유지
|
||||||
|
|
||||||
|
### client/src/components/player/VideoPlayer.tsx
|
||||||
|
- 연속재생(ended)·재생목록 선택: `loadLocalFile(file, true)` 로 전환 —
|
||||||
|
기존의 "src 설정 직후 play() 큐잉"보다 순서가 결정적(메타데이터 후 0초→재생)
|
||||||
|
- 재생목록 같은 항목 재선택: currentTime(0)+play 유지
|
||||||
|
|
||||||
|
## 검증
|
||||||
|
|
||||||
|
- tsc+vite 빌드 통과, 새 번들(index-ChR6Vi6j.js) 서빙
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# 2026-07-14 데이터 교체 시 UI 상태 초기화
|
||||||
|
|
||||||
|
**소요 시간**: 3분
|
||||||
|
**Context 사용량**: input 3240k / output 1k tokens
|
||||||
|
|
||||||
|
## 문제
|
||||||
|
|
||||||
|
폴더(데이터) 교체 후 UI 에 이전 소스의 '일시정지(재생중 토글)' 표시가 남음 —
|
||||||
|
src 교체는 pause 이벤트를 보장하지 않아 playerStore.playing 이 true 로 잔존.
|
||||||
|
|
||||||
|
## 수정 — useVideoPlayer.ts
|
||||||
|
|
||||||
|
- loadLocalFile / loadServerStream 에서 소스 설정 직후 스토어 즉시 초기화:
|
||||||
|
`setPlaying(false)` + `setCurrentTime(0)` + `setDuration(0)`
|
||||||
|
- 자동재생 경로(autoPlay=true)는 이후 play 이벤트로 playing=true 재설정 — 순서 정상
|
||||||
|
|
||||||
|
## 검증
|
||||||
|
|
||||||
|
- tsc+vite 빌드 통과, 새 번들(index-ZFLATFML.js) 서빙
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# 2026-07-14 시간동기(영상↔로그) 보정 슬라이더
|
||||||
|
|
||||||
|
**소요 시간**: 8분
|
||||||
|
**Context 사용량**: input 3290k / output 4k tokens
|
||||||
|
|
||||||
|
## 배경 (사용자 문제 제기)
|
||||||
|
|
||||||
|
오르막 촬영(드론 고도 점진 상승)에서 POI 가 영상에 "풀 붙인 듯" 고정되지 않고 밀림.
|
||||||
|
남은 오차원 분석: ① 영상-로그 시간 동기(이동 중 최대 ~5m 밀림의 주범),
|
||||||
|
② 포즈 스무딩 지연(smoothHalf 60 = ±6초 평균 — 고도/자세가 서서히 변하는 오르막에서 랙).
|
||||||
|
|
||||||
|
## 구현 — 시간동기 보정 (①)
|
||||||
|
|
||||||
|
- CameraParams 에 `timeOffsetSec`(기본 0) 추가 — **camera.json/보정 저장에 자동 포함**
|
||||||
|
- RAF 연속 프레임 산출에 오프셋 적용: estTime = 부드러운 시간 + timeOffsetSec
|
||||||
|
→ 포즈 보간·라벨 캐시 조회·선형/궤적 투영 전부 일괄 반영
|
||||||
|
- UI: 자세 보정 섹션 최상단 "시간동기" 슬라이더 (−2~+2s, 0.05s 스텝, 툴팁)
|
||||||
|
|
||||||
|
## 안내 (②)
|
||||||
|
|
||||||
|
- 오르막 데이터셋은 smooth(포즈 이동평균 반폭) 60 → **10 내외 권장** — ±1초 평균으로
|
||||||
|
고도 상승 추종. 조정 후 display.json 저장
|
||||||
|
|
||||||
|
## 남은 개선 여지 (미구현 제안)
|
||||||
|
|
||||||
|
- djmd(영상 내장 텔레메트리)의 프레임별 위치/고도 사용 — 프레임 단위 완전 동기.
|
||||||
|
자세(yaw/pitch)는 djmd 에 없어 CSV 병용 필요. 필요 시 다음 단계로
|
||||||
|
|
||||||
|
## 검증
|
||||||
|
|
||||||
|
- tsc+vite 빌드 통과, 새 번들(index-ee25eCoO.js) 서빙
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# 2026-07-14 djmd 프레임 동기 위치 업그레이드 (근본 해결)
|
||||||
|
|
||||||
|
**소요 시간**: 25분
|
||||||
|
**Context 사용량**: input 3360k / output 12k tokens
|
||||||
|
|
||||||
|
## 배경 (사용자 승인)
|
||||||
|
|
||||||
|
오르막/내리막에서 POI 가 "풀 붙임"처럼 고정되지 않음 — 근본 원인은 CSV 비행로그의
|
||||||
|
시각 정렬 가정(로그 t0 = 영상 시작, 10Hz) 오차. **실측: CSV 가정 vs 영상 내장 텔레메트리
|
||||||
|
실위치 오차 평균 3.2m / 최대 4.1m** (≈0.33s 동기 오차) — 이것이 밀림의 정체.
|
||||||
|
|
||||||
|
## 구현 — client/src/utils/djmdTrack.ts (신규)
|
||||||
|
|
||||||
|
- **MP4 박스 파서**: 최상위 박스 워크(mdat 스킵) → moov(파일 끝, 0.2MB) →
|
||||||
|
trak/mdia/minf/stbl 에서 stsd=djmd 트랙 탐색 → stsz/stsc/stco(co64) 샘플 테이블 전개
|
||||||
|
- **선별 읽기**: 전체 3.7GB 스캔 없이 djmd 샘플 바이트 구간만 6프레임 간격(≈10Hz) 배치 읽기
|
||||||
|
(1,970회 slice, 동시 64) — 동기 정확도는 프레임 단위, 밀도는 CSV 동급
|
||||||
|
- **protobuf 추출**: 6.3 프레임 카운터 / 6.4·6.5 위경도(라디안) / 6.6 타원체고(mm)
|
||||||
|
- **refineFramesWithDjmd**: CSV 프레임의 위치/고도를 djmd 보간값으로 교체
|
||||||
|
(자세 yaw/pitch/roll·focalLen 은 CSV 유지). datum: 타원체고 데이터셋은 그대로,
|
||||||
|
정표고 데이터셋은 (첫 샘플 − CSV 첫 고도) 지오이드고 자동 차감. 비정상(|편차|>80m) 시 미적용
|
||||||
|
|
||||||
|
## geoStore 통합
|
||||||
|
|
||||||
|
- loadFromFolder / loadSegment 직후 **백그라운드 refine** — CSV 기반으로 즉시 표시 후
|
||||||
|
파싱 완료 시 frames 교체(라벨 캐시는 frames 변경 감지로 자동 재계산)
|
||||||
|
- 경합 가드: 파싱 중 세그먼트/폴더 전환 시 결과 폐기
|
||||||
|
- DJI 영상이 아니면 조용히 CSV 유지(무해)
|
||||||
|
|
||||||
|
## 검증 (실파일, node 포팅 동일 로직)
|
||||||
|
|
||||||
|
- moov/stbl 파싱 ✓, 샘플 11,817개 중 1,969개 추출(step 6) ✓
|
||||||
|
- 첫 샘플 frame 6: 33.40398°, 126.28924°, 174.43m — CSV·기존 분석과 정합 ✓
|
||||||
|
- 오차 측정: 평균 3.2m/최대 4.1m 제거 효과 확인
|
||||||
|
- tsc+vite 빌드 통과, 새 번들(index-CW0-dRMN.js) 서빙
|
||||||
|
|
||||||
|
## 사용 안내
|
||||||
|
|
||||||
|
- 폴더 로드 후 1~3초 내 콘솔 `[djmd] 영상 내장 텔레메트리로 위치/고도 정밀화 적용` 확인
|
||||||
|
- 시간동기 슬라이더는 0 권장(위치는 이제 프레임 동기 — 잔여 자세 오차만 Yaw±로)
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# 2026-07-14 선형 찌글찌글 — z 톱니 노이즈 평활
|
||||||
|
|
||||||
|
**소요 시간**: 8분
|
||||||
|
**Context 사용량**: input 3430k / output 4k tokens
|
||||||
|
|
||||||
|
## 문제 (스크린샷 제보)
|
||||||
|
|
||||||
|
같은 측점.kml 이 구글어스(평면)에선 매끈한데 영상 오버레이에선 선형이 지그재그로 꺾임.
|
||||||
|
|
||||||
|
## 원인 (데이터 검증)
|
||||||
|
|
||||||
|
선형 정점 z 가 104.9 ↔ 105.9 처럼 **±1m 양자화 톱니** (원본 제작 시 지형고도 샘플링 잔차,
|
||||||
|
부호 반전 톱니 7개, 최대 인접 z 차 12m). 평면 지도에선 z 가 안 보여 매끈하지만,
|
||||||
|
영상의 낮은 시야각 투영에선 1m 차이가 선 꺾임으로 증폭되어 보임.
|
||||||
|
|
||||||
|
## 수정 — geoData.ts parseKmz (선형 로드 시)
|
||||||
|
|
||||||
|
- 정점 z 에 **±3점 이동평균** 적용 — 톱니 7→1개, 최대 인접 z 차 12→2.7m,
|
||||||
|
종단 경사는 보존(시작/끝 값 유지)
|
||||||
|
|
||||||
|
## 남은 개선 여지 (안내)
|
||||||
|
|
||||||
|
- 곡선 구간의 미세한 좌우 흔들림은 자세(yaw)가 아직 CSV 시각축인 잔차 —
|
||||||
|
djmd 위치와의 교차상관으로 자세 시간축도 자동 정렬 가능(요청 시 구현)
|
||||||
|
|
||||||
|
## 검증
|
||||||
|
|
||||||
|
- tsc+vite 빌드 통과, 새 번들(index-Bs4i3DNJ.js) 서빙
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# 2026-07-14 POI 원거리 밀림 — 지면고도 z 우선순위 교정
|
||||||
|
|
||||||
|
**소요 시간**: 7분
|
||||||
|
**Context 사용량**: input 3490k / output 3k tokens
|
||||||
|
|
||||||
|
## 문제 (제보)
|
||||||
|
|
||||||
|
POI 가 멀리 있을 때는 계속 밀리다가 화면 하단(근거리)에서는 잘 맞음.
|
||||||
|
|
||||||
|
## 원인 — 높이(표고) 오차의 전형적 시그니처
|
||||||
|
|
||||||
|
근거리는 위치(djmd 프레임 동기, 정확)가 지배해 맞고, 원거리는 각도·높이 오차가 증폭됨.
|
||||||
|
코드상 지면고도(gz)가 **POI 자체 실측 z 보다 '최근접 중심선(도로) 높이 스냅'을 우선** —
|
||||||
|
회덕(철도, POI z 미보유) 시절의 규칙이 남아, 도로에서 떨어진 지물(마을회관·산 등)이
|
||||||
|
도로 높이로 붙어 원거리에서 수 m 높이 오차 → 접근하며 서서히 제자리로 미끄러지는 증상.
|
||||||
|
|
||||||
|
## 수정 — StationOverlay.tsx
|
||||||
|
|
||||||
|
- gz 우선순위 교정: 보정값(드래그) → **POI 자체 z(실측)** → 중심선 z → 경로표고 앵커
|
||||||
|
- 편집 모드 초기 표고(baseZ/groundZ)도 동일 규칙으로 통일
|
||||||
|
- z 없는 데이터셋은 기존 폴백 그대로(무영향)
|
||||||
|
|
||||||
|
## 잔여 안내
|
||||||
|
|
||||||
|
- 수정 후에도 '모든' POI 가 원거리에서 같은 방향으로 균일하게 밀리면 = 짐벌 피치 바이어스
|
||||||
|
(로그가 정수 -15 로 고정 기록) → 카메라 파라미터 Pitch± 를 ±0.5° 내에서 미세 조정
|
||||||
|
- 드론높이 모드 사용 중이면 지면고도 모드(OFF) 권장 — 평면 근사가 원거리 오차의 또 다른 원인
|
||||||
|
|
||||||
|
## 검증
|
||||||
|
|
||||||
|
- tsc+vite 빌드 통과, 새 번들(index-BiwO87gk.js) 서빙
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# 2026-07-14 옆 지물(상대리 마을회관) 밀림 — 자세 시간축 자동 재정렬
|
||||||
|
|
||||||
|
**소요 시간**: 25분
|
||||||
|
**Context 사용량**: input 3800k / output 6k tokens
|
||||||
|
|
||||||
|
## 문제 (스크린샷 제보)
|
||||||
|
|
||||||
|
전방 지물(중간산서로 등)은 잘 붙는데 '상대리 마을회관'처럼 옆으로 지나치는 POI 가
|
||||||
|
계속 뒤로 밀리면서 사라짐.
|
||||||
|
|
||||||
|
## 진단
|
||||||
|
|
||||||
|
- 마을회관 z 는 정상(파일 96.83 vs DEM 환산 97.9) → 표고 원인 배제
|
||||||
|
- 위치는 djmd(영상 내장 텔레메트리) 프레임 동기로 정확하지만,
|
||||||
|
**자세(yaw/pitch/roll)는 여전히 CSV 시각정렬** — 두 시간축 사이 잔차가 존재.
|
||||||
|
- 옆을 지나치는 지물은 상대 방위가 초당 수십 도씩 급변 → yaw 가 0.3초만 늦어도
|
||||||
|
화면에서 수백 px 뒤로 밀림. 전방 지물은 방위 변화가 느려 티가 안 났던 것.
|
||||||
|
|
||||||
|
## 수정 — djmdTrack.ts refineFramesWithDjmd
|
||||||
|
|
||||||
|
- djmd 위치 ↔ CSV 위치의 **교차상관으로 시간 오프셋(dt) 자동 추정**
|
||||||
|
(조사 −1.5~+1.5s 0.05s 스텝 → 최적 근방 ±0.05s 를 0.01s 정밀 스윕, 프로브 ~50점)
|
||||||
|
- 최종 프레임: 위치 = djmd(프레임 동기), 자세 = CSV 를 `t + dt` 로 재샘플
|
||||||
|
(yaw 는 최단각 보간) → 위치·자세가 모두 영상 시간축으로 통일
|
||||||
|
|
||||||
|
## 검증 (실데이터 경로 1-1, DJI_...0002.MP4)
|
||||||
|
|
||||||
|
- 추정 dt = **−0.33s** (기존 진단치 ≈0.33s 와 정확히 일치)
|
||||||
|
- 위치 잔차: dt=0 일 때 3.24m → dt 적용 시 **0.24m**
|
||||||
|
- tsc+vite 빌드 통과, 새 번들(index-DpAUafsi.js) 서빙 확인
|
||||||
|
|
||||||
|
## 사용법 / 안내
|
||||||
|
|
||||||
|
- 새로고침 후 폴더 재선택 → 콘솔에 `[djmd] CSV↔영상 시간 오프셋 ...s 추정` 로그 출력
|
||||||
|
- 세그먼트마다 각자 dt 를 추정하므로 분할영상 전체에 자동 적용
|
||||||
|
- 수정 후에도 모든 POI 가 원거리에서 균일하게 밀리면 짐벌 피치 바이어스 →
|
||||||
|
카메라 파라미터 Pitch± ±0.5° 미세 조정
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# 2026-07-14 상대리교차로 밀림 — camera.json offZ 잔재값 교정
|
||||||
|
|
||||||
|
**소요 시간**: 13분
|
||||||
|
**Context 사용량**: input 4200k / output 5k tokens
|
||||||
|
|
||||||
|
## 문제 (스크린샷 3장 제보)
|
||||||
|
|
||||||
|
자세 시간축 재정렬 후에도 상대리교차로가 접근할수록 위로(뒤로) 계속 밀림.
|
||||||
|
|
||||||
|
## 진단 (수치 확정)
|
||||||
|
|
||||||
|
- 교차로 좌표/표고 정상(z=94.834 타원체고, 선형 최근접 29.5m)
|
||||||
|
- 재정렬 코드 정상(외부 검증 dt=-0.33s, 잔차 0.24m) — yaw 는 횡방향이라 이 증상과 무관
|
||||||
|
- **원인: camera.json 의 offZ = −18.3m** (7/13 20:07 저장, 표고 체계 근본 교정 *이전*의
|
||||||
|
드래그 보정값). 오늘 djmd 타원체고(프레임 동기)로 고도가 정확해지자 이중 보정이 되어
|
||||||
|
카메라를 18.3m 낮게 모델링 → 겉보기각 오차 = 18.3/거리:
|
||||||
|
416m에서 +2.45° → 86m에서 +7.34° (접근할수록 위로 밀림 — 제보와 정확히 일치)
|
||||||
|
- 드론 djmd 고도 174.0m(타원체고) 일정, 교차로 상대높이 79.2m — offZ=0 이면 각도 오차 0
|
||||||
|
|
||||||
|
## 조치
|
||||||
|
|
||||||
|
- 경로 1-1 camera.json: **offZ −18.3 → 0** (`.bak` 백업 생성, 사용자 선택: 1-1만)
|
||||||
|
- 경로 1-2(0.6)/1-3(21.8)/2(21.8) 도 같은 잔재값 보유 — 해당 경로 재생 시 같은 증상
|
||||||
|
예상되므로 필요 시 같은 방식으로 교정 권장 (사용자 판단 대기)
|
||||||
|
|
||||||
|
## 확인 방법
|
||||||
|
|
||||||
|
- 새로고침 후 폴더 재선택 → camera.json 자동 적용(offZ 0)
|
||||||
|
- 잔여 미세 오차는 offZ ±2m 또는 Pitch ±0.5° 미세 조정
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# 2026-07-14 측점 POI-선형 분리 — 선형 z 를 측점 기준으로 재정렬
|
||||||
|
|
||||||
|
**소요 시간**: 12분
|
||||||
|
**Context 사용량**: input 4400k / output 5k tokens
|
||||||
|
|
||||||
|
## 문제 (스크린샷 제보, 1k700~1k800 구간)
|
||||||
|
|
||||||
|
측점 POI(눈금)와 선형(빨간선)이 화면에서 서로 어긋나 보임.
|
||||||
|
|
||||||
|
## 원인
|
||||||
|
|
||||||
|
- 선형 정점 z: 로드 시 **±3점 이동평균 평활** 값
|
||||||
|
- 측점 POI z: **자체 실측 z** (7/13 교정값)
|
||||||
|
- 두 z 가 지점에 따라 ±2.5m 어긋나(1k700 부근 +2.54m / 1k850 −2.46m)
|
||||||
|
낮은 시야각 투영에서 선과 눈금이 분리되어 보임
|
||||||
|
|
||||||
|
## 수정 — geoData.ts parseKmz
|
||||||
|
|
||||||
|
- **선형 정점 z 를 측점 POI z 의 체이니지 선형 보간으로 교체** (측점이 기준)
|
||||||
|
- 측점 71점을 선형 세그먼트에 투영해 체이니지 앵커 생성(횡 이격 최대 0.029m)
|
||||||
|
- 선형에서 50m 이상 떨어진 측점은 앵커에서 제외, 양끝 밖은 최근접 측점 z 클램프
|
||||||
|
- 측점 z 자체가 종단(부드러움)이므로 기존 톱니 평활도 함께 대체
|
||||||
|
- 측점 z 없는 데이터셋(앵커 <2)은 기존 평활 z 유지 (무영향)
|
||||||
|
|
||||||
|
## 검증 (경로 1-1 실데이터)
|
||||||
|
|
||||||
|
- 재정렬 후 측점 체이니지에서 선형 z − 측점 z = **0.0000m** (완전 정합)
|
||||||
|
- 기존 평활 z 대비 변경 최대 5.79m(체이니지 6832m — 노선 연장 밖 구간)
|
||||||
|
- tsc+vite 빌드 통과, 새 번들(index-tLZxZy_r.js) 서빙
|
||||||
|
|
||||||
|
## 확인 방법
|
||||||
|
|
||||||
|
- 새로고침 후 폴더 재선택 → 콘솔에 `[KML] 선형 z 를 측점 71점 기준으로 재정렬` 로그
|
||||||
|
- 선형이 모든 측점 눈금을 정확히 통과해야 정상
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# 2026-07-14 측점 POI-선형 분리(교차로 구간) — 측점 정점 삽입으로 통과 보장
|
||||||
|
|
||||||
|
**소요 시간**: 11분
|
||||||
|
**Context 사용량**: input 4700k / output 6k tokens
|
||||||
|
|
||||||
|
## 문제 (스크린샷 제보, 2k200~2k300 교차로)
|
||||||
|
|
||||||
|
직전 "선형 z 재정렬" 후에도 교차로 구간에서 측점 눈금과 선형이 분리되어 보임.
|
||||||
|
|
||||||
|
## 원인 (실데이터 검증)
|
||||||
|
|
||||||
|
- 이 구간 선형은 **433m 직선 현 1개 세그먼트**(정점123→124)이고, 측점 2+200~2+500은
|
||||||
|
그 현 위(횡 0.00m)에 정확히 존재 — 원본 데이터 자체는 정합
|
||||||
|
- 직전 수정(정점 z만 재정렬)은 현의 **양 끝 z만** 교체 → 현 중간에서 측점 z 프로파일
|
||||||
|
(곡선 종단: 101.9→98.6→97.0→95.4→93.8)과 직선 보간이 어긋나 2+200에서 +1.2m 오차를
|
||||||
|
오히려 유발 (수정 전 원본은 이 구간이 자체 정합이었음)
|
||||||
|
|
||||||
|
## 수정 — geoData.ts parseKmz (직전 로직 대체·확장)
|
||||||
|
|
||||||
|
- **측점 위치를 선형 폴리라인에 정점으로 삽입** (세그먼트별 t 순, 기존 정점 1m 이내면 생략)
|
||||||
|
→ 선형이 모든 측점의 lat/lon/z 를 기하학적으로 정확히 통과.
|
||||||
|
라벨과 선이 같은 3D 점을 투영하므로 **어떤 카메라 자세/오차에서도 화면에서 일치**
|
||||||
|
- 나머지 원본 정점 z 는 측점 체이니지 보간(직전 로직) 유지
|
||||||
|
|
||||||
|
## 검증 (경로 1-1 실데이터)
|
||||||
|
|
||||||
|
- 정점 146 → 213 (측점 정점 67개 삽입, 4개는 기존 정점과 1m 이내라 생략)
|
||||||
|
- 모든 측점의 선형과의 **3D 최대 이격 0.012m** (화면 픽셀 이하)
|
||||||
|
- 433m 현이 2+200/2+300/2+400 측점을 정확히 통과함 확인
|
||||||
|
- tsc+vite 빌드 통과, 새 번들(index-DD92sAy6.js) 서빙
|
||||||
|
|
||||||
|
## 확인 방법
|
||||||
|
|
||||||
|
- 새로고침 후 폴더 재선택 → 콘솔 `[KML] 선형-측점 정합: 측점 정점 67개 삽입 ...` 로그
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# 2026-07-14 선형 실선-측점 라벨 따로 움직임 — 측점 라벨 즉시 투영 전환
|
||||||
|
|
||||||
|
**소요 시간**: 4분
|
||||||
|
**Context 사용량**: input 4900k / output 2k tokens
|
||||||
|
|
||||||
|
## 문제 (제보)
|
||||||
|
|
||||||
|
측점 정점 삽입(3D 데이터 일치) 후에도 선형 실선과 측점 POI 라벨이 화면에서 따로 움직임.
|
||||||
|
|
||||||
|
## 원인
|
||||||
|
|
||||||
|
같은 3D 점을 투영해도 **그리는 경로가 달랐음**:
|
||||||
|
- 선형 실선: 매 프레임 카메라 포즈로 즉시 투영 (지연 0)
|
||||||
|
- 측점 라벨: 즉시 투영 → **화면 EMA(속도적응 평활, smoothStep) + 이상치 거부** 를 거침
|
||||||
|
→ CSV 위치가 떨리던 시절의 떨림 억제 장치가 라벨을 수 프레임 지연시켜
|
||||||
|
움직이는 동안 실선과 분리되어 보임 (display.json 의 강한 평활값 0.02/0.002 로 더 두드러짐)
|
||||||
|
|
||||||
|
## 수정 — StationOverlay.tsx
|
||||||
|
|
||||||
|
- 측점 라벨의 smoothStep(EMA) 제거 → **실선과 동일한 즉시 투영** 사용
|
||||||
|
- 반올림 떨림은 기존 0.75px 픽셀 히스테리시스가 계속 흡수
|
||||||
|
- POI(지장물) 라벨의 EMA 는 유지 (실선과 붙어 다닐 대상이 아님)
|
||||||
|
|
||||||
|
## 효과
|
||||||
|
|
||||||
|
측점 라벨과 선형 실선이 같은 포즈·같은 3D 점·같은 투영·같은 타이밍 → **픽셀 단위 고정**.
|
||||||
|
포즈 오차가 있어도 둘이 함께 움직여 분리되지 않음.
|
||||||
|
|
||||||
|
## 검증
|
||||||
|
|
||||||
|
- tsc+vite 빌드 통과, 새 번들(index-DCx_qhIq.js) 서빙
|
||||||
|
- 새로고침 후 폴더 재선택으로 적용
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# 2026-07-14 드론 회전 시 오버레이 전체가 딸려오는 증상 — 포즈 평활 자동 제한
|
||||||
|
|
||||||
|
**소요 시간**: 10분
|
||||||
|
**Context 사용량**: input 5200k / output 4k tokens
|
||||||
|
|
||||||
|
## 문제 (제보)
|
||||||
|
|
||||||
|
드론이 방향을 바꿀 때 POI·선형·궤적이 지면에 붙어 있지 않고 드론에 부착된 것처럼
|
||||||
|
함께 회전하며 딸려옴. (직진 구간에서는 정상)
|
||||||
|
|
||||||
|
## 원인
|
||||||
|
|
||||||
|
오버레이 카메라 포즈가 **±smoothHalf 프레임 이동평균**으로 평활되는데, 기본/저장값이
|
||||||
|
60(±1초). 회전 중에는 평활 yaw 가 실제 영상 yaw 를 못 따라가(수 도 지연) 투영된 세계
|
||||||
|
전체가 카메라와 같이 회전 → "부착된 듯 딸려오는" 증상. CSV 위치가 프레임마다 떨리던
|
||||||
|
시절의 떨림 억제 장치로, djmd 프레임 동기 이후에는 불필요한 지연만 유발.
|
||||||
|
|
||||||
|
## 수정
|
||||||
|
|
||||||
|
- geoStore: `frameSynced` 플래그 추가 — djmd 정밀화 적용 시 true (폴더/세그먼트 로드 시 false)
|
||||||
|
- StationOverlay: 포즈 평활 유효 반폭 `poseHalf()` 도입 —
|
||||||
|
**프레임 동기 데이터면 min(smoothHalf, 5)** (±5프레임 ≈ 0.08초, 사실상 즉시 추종)
|
||||||
|
- 적용 지점: poseAt(라인·라벨 공통 포즈), 나침반/렌더캐시, RAF 폴백, 드래그 역투영,
|
||||||
|
라벨 프리컴퓨트(가시성 판단)
|
||||||
|
- CSV 기반(비동기화) 데이터셋은 기존 smoothHalf 그대로 (무영향)
|
||||||
|
|
||||||
|
## 효과
|
||||||
|
|
||||||
|
회전 시 오버레이가 영상과 같은 속도로 즉시 회전 — 지면 고정("풀 붙인 듯") 유지.
|
||||||
|
위치·자세가 프레임 동기이므로 평활 제거에 따른 떨림 없음(0.75px 히스테리시스 유지).
|
||||||
|
|
||||||
|
## 검증
|
||||||
|
|
||||||
|
- tsc+vite 빌드 통과, 새 번들(index-CXHCdlnf.js) 서빙
|
||||||
|
- 새로고침 후 폴더 재선택으로 적용 (교차로 회전 구간에서 확인 권장)
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# 2026-07-14 선형선-선형점 불일치 재제보 — 구버전 번들 실행 진단
|
||||||
|
|
||||||
|
**소요 시간**: 5분
|
||||||
|
**Context 사용량**: input 5500k / output 3k tokens
|
||||||
|
|
||||||
|
## 제보 (스크린샷, 1k900~2k100)
|
||||||
|
|
||||||
|
선형선(빨간 선)이 측점 눈금에서 우하단으로 벗어남 — 가까울수록 간격 증가.
|
||||||
|
|
||||||
|
## 진단
|
||||||
|
|
||||||
|
- 현행 배포 코드(14:18 번들)에서는 불일치가 **구조적으로 불가능**:
|
||||||
|
① 측점 위치가 선형 정점으로 삽입되어 있고(13:21), ② 측점 눈금 앵커는 projectCL 로
|
||||||
|
선형 위에 스냅된 같은 3D 점이며, ③ 같은 포즈·같은 투영으로 그려짐(13:24)
|
||||||
|
- 스크린샷의 간격 패턴(가까울수록 벌어짐 = z 오차 시그니처, 약 4~6m)은
|
||||||
|
**13:08 이전 번들**(선형 z 원본, 측점 z 와 4.6m 차이 구간)과 정확히 일치
|
||||||
|
- 서버 index.html 은 `Cache-Control: max-age=0` — 일반 새로고침이면 새 코드 수신.
|
||||||
|
즉 **페이지 새로고침 없이 폴더만 재선택**해 온 것으로 판단
|
||||||
|
(폴더 재선택은 데이터만 다시 읽고 JS 번들은 그대로)
|
||||||
|
|
||||||
|
## 확인 수단 (기존 기능)
|
||||||
|
|
||||||
|
- 좌상단 타이틀 아래 **빌드 시각 표시** (`v1.0.0 · 07.14 14:18`) — 14:18 이후여야 최신
|
||||||
|
- 콘솔(F12): `[KML] 선형-측점 정합: 측점 정점 67개 삽입 ...` 로그 확인
|
||||||
|
|
||||||
|
## 조치
|
||||||
|
|
||||||
|
- 코드 변경 없음. 사용자에게 **F5 새로고침(필요시 Ctrl+F5) → 폴더 재선택** 안내
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# 2026-07-14 드론높이·이격거리 옵션 시 선형 분리 — 선형에도 동일 높이 규칙 적용
|
||||||
|
|
||||||
|
**소요 시간**: 5분
|
||||||
|
**Context 사용량**: input 5700k / output 2k tokens
|
||||||
|
|
||||||
|
## 요청
|
||||||
|
|
||||||
|
드론높이 + POI 이격거리 옵션을 켜면 측점 POI 라벨 높이는 바뀌는데 빨간 선형은
|
||||||
|
지형 z 에 남아 분리됨 → 선형도 동일하게 적용해 항상 같은 선상에 놓이게.
|
||||||
|
|
||||||
|
## 수정 — StationOverlay.tsx buildLines
|
||||||
|
|
||||||
|
- 드론높이 모드(poiDroneHeight)일 때 선형 정점을 측점/POI 라벨과 **동일한
|
||||||
|
'드론고도 − geoidOffset − 이격거리' 평면**에 투영 (planeZ)
|
||||||
|
- 지면고도 모드(기본)는 기존대로 정점 z(측점 기준) 사용 — 무영향
|
||||||
|
- 라벨과 선이 같은 RAF 포즈(dronePose)를 공유하므로 평면 기준도 완전 동일
|
||||||
|
|
||||||
|
## 효과
|
||||||
|
|
||||||
|
드론높이/이격거리 슬라이더 조절 시 측점 라벨·POI·빨간 선형이 한 몸으로 이동 —
|
||||||
|
어떤 모드에서도 측점 POI 와 선형이 같은 선상 유지.
|
||||||
|
|
||||||
|
## 검증
|
||||||
|
|
||||||
|
- tsc+vite 빌드 통과, 새 번들(index-BDuURKji.js, 빌드 07.14 14:41) 서빙
|
||||||
|
- 적용: F5 새로고침(좌상단 빌드 시각 14:41 확인) → 폴더 재선택
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# 2026-07-14 측점 팝업에 높이(표고)가 없음 — 표고 행 추가
|
||||||
|
|
||||||
|
**소요 시간**: 6분
|
||||||
|
**Context 사용량**: input 6000k / output 2k tokens
|
||||||
|
|
||||||
|
## 질문 (스크린샷: 3k100 팝업)
|
||||||
|
|
||||||
|
측점 POI 팝업에 왜 높이가 없나?
|
||||||
|
|
||||||
|
## 원인
|
||||||
|
|
||||||
|
- 높이 데이터는 **있음** — 측점.kml 좌표 3번째 성분(z, 타원체고). 예: 3+100 = 108.2m.
|
||||||
|
렌더링(선형·라벨 높이)에도 이미 사용 중.
|
||||||
|
- 팝업은 KML 속성표(SimpleData: STA/lat/lon/5186_x/5186_y/km)만 나열하는데,
|
||||||
|
측점 속성표에는 높이 키가 없어 화면에 안 보였던 것.
|
||||||
|
|
||||||
|
## 수정 — StationOverlay.tsx 팝업
|
||||||
|
|
||||||
|
- 전체 보기 팝업 하단에 **`표고 NNN.N m (타원체고)`** 행 추가 (z>0 일 때)
|
||||||
|
- 측점·지장물 공통 적용. 지장물의 '높이' 속성(구조물 높이)과 구분되는 표기.
|
||||||
|
- 컴팩트(자동) 팝업은 간결 유지 — 전체 보기에서만 표시
|
||||||
|
|
||||||
|
## 검증
|
||||||
|
|
||||||
|
- tsc+vite 빌드 통과, 새 번들(index-vYVLTTyB.js, 빌드 07.14 15:35) 서빙
|
||||||
|
- 적용: F5 새로고침(좌상단 빌드 시각 15:35 확인) → 폴더 재선택 → 측점 라벨 클릭
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# 2026-07-14 어음천교 라벨 2개(파랑+빨강) — 구조물 이중 표출 제거
|
||||||
|
|
||||||
|
**소요 시간**: 5분
|
||||||
|
**Context 사용량**: input 6200k / output 2k tokens
|
||||||
|
|
||||||
|
## 문제 (스크린샷 제보)
|
||||||
|
|
||||||
|
어음천교 라벨이 파란색(위)·빨간색(아래) 두 개로 표출.
|
||||||
|
|
||||||
|
## 원인
|
||||||
|
|
||||||
|
신포맷 지장물.kml 의 교량/터널은 파싱 시 **POI(파란 텍스트박스)와 구조물(스테이션바
|
||||||
|
마크용) 양쪽에 등록**되는데, 오버레이가 영상 라벨을 만들 때 POI + 구조물을 합쳐
|
||||||
|
같은 시설물이 두 번 그려짐. 구조물 쪽은 z=0(표고 미상)으로 변환되어 파란 POI(실측 z)와
|
||||||
|
높이 폴백이 달라 위치까지 분리 — 빨간 라벨이 아래쪽에 따로 표시.
|
||||||
|
|
||||||
|
## 수정 — StationOverlay.tsx
|
||||||
|
|
||||||
|
- 구조물 → 영상 라벨 변환 시 **같은 이름이 POI 에 이미 있으면 제외** (POI 우선 —
|
||||||
|
실측 z + KML 지정 색상 보유)
|
||||||
|
- 스테이션바 구조물 마크는 storeStructures 직접 사용이라 영향 없음
|
||||||
|
- 회덕 포맷(교량이 구조물에만 존재)은 기존대로 영상 표출 유지
|
||||||
|
|
||||||
|
## 검증
|
||||||
|
|
||||||
|
- tsc+vite 빌드 통과, 새 번들(index-CAEWZ-A_.js, 빌드 07.14 15:43) 서빙
|
||||||
|
- 적용: F5 새로고침(빌드 시각 15:43 확인) → 폴더 재선택 → 어음천교 라벨 1개(파랑)만 확인
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# 2026-07-14 F2 키로 개발자 모드(dev_mode 1↔0) 전환
|
||||||
|
|
||||||
|
**소요 시간**: 5분
|
||||||
|
**Context 사용량**: input 6400k / output 2k tokens
|
||||||
|
|
||||||
|
## 요청
|
||||||
|
|
||||||
|
F2 를 누르면 dev_mode 1(제어 UI 표시) ↔ 0(뷰어 모드) 상태 전환.
|
||||||
|
|
||||||
|
## 구현 — VideoPlayer.tsx
|
||||||
|
|
||||||
|
- `devModeOverride` 상태 추가 — F2 keydown 시 현재 유효값 기준 1↔0 토글
|
||||||
|
- 유효 devMode = override ?? route.json 파일 값 (**파일은 변경하지 않음**, 세션 한정)
|
||||||
|
- 새 폴더 로드(routeMeta 변경) 시 override 초기화 → 다시 파일 값 적용
|
||||||
|
- 텍스트 입력(INPUT/TEXTAREA/contentEditable) 중에는 F2 무시 — 기존 단축키 규칙과 동일
|
||||||
|
- 기존 게이팅 그대로: devMode 0 이면 '영상제어' 버튼 그룹·카메라/화면표시 패널 숨김
|
||||||
|
|
||||||
|
## 검증
|
||||||
|
|
||||||
|
- tsc+vite 빌드 통과, 새 번들(index-D1wtVulq.js, 빌드 07.14 16:15) 서빙
|
||||||
|
- 적용: F5 새로고침(빌드 시각 16:15 확인) → F2 로 제어 UI 표시/숨김 전환 확인
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# 2026-07-14 단축키 A — 선형 표시/숨김 토글
|
||||||
|
|
||||||
|
**소요 시간**: 4분
|
||||||
|
**Context 사용량**: input 6600k / output 1k tokens
|
||||||
|
|
||||||
|
## 요청
|
||||||
|
|
||||||
|
A 키로 선형(빨간선) show/hide 전환.
|
||||||
|
|
||||||
|
## 구현 — VideoPlayer.tsx
|
||||||
|
|
||||||
|
- `KeyA` keydown → 설정 스토어 `showCenterline` 토글 (측점 라벨도 선형의 일부라 함께 전환)
|
||||||
|
- Ctrl/Meta/Alt 조합(Ctrl+A 전체선택 등)과 텍스트 입력 중에는 무시
|
||||||
|
- 화면표시 옵션 패널의 "선형 표시" 체크박스와 같은 상태를 공유 — 어느 쪽으로 바꿔도 동기화
|
||||||
|
|
||||||
|
## 검증
|
||||||
|
|
||||||
|
- tsc+vite 빌드 통과, 새 번들(index-CkjmYxHT.js, 빌드 07.14 17:00) 서빙
|
||||||
|
- 적용: F5 새로고침(빌드 시각 17:00 확인) → A 키로 선형 표시/숨김 확인
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# 2026-07-14 측점 표기 0+000 형식 전환 + 스테이션바 양끝 시작/끝 스테이션 표시
|
||||||
|
|
||||||
|
**소요 시간**: 12분
|
||||||
|
**Context 사용량**: input 7000k / output 4k tokens
|
||||||
|
|
||||||
|
## 요청
|
||||||
|
|
||||||
|
1. 스테이션 표시를 0k000 → **0+000** 형식으로
|
||||||
|
2. 스테이션바 맨 왼쪽/오른쪽 **100m 이내에 시설물이 없으면** 시작/끝 스테이션 표시
|
||||||
|
|
||||||
|
## 구현
|
||||||
|
|
||||||
|
### ① 표기 형식 통일 (NkNNN → N+NNN)
|
||||||
|
|
||||||
|
- 생성 6곳: StationBar(커서 배지 10m/1m), mileage.ts(100m 라벨), Timeline(눈금),
|
||||||
|
chainage.ts, RoutePanel, geoData.ts(측점 라벨 제목 — 오버레이·팝업 포함)
|
||||||
|
- 제목 파서 4곳(`(\d+)[Kk](\d+)`)은 `[Kk+]` 로 확장 — 신·구 형식 모두 인식
|
||||||
|
(StationBar stationKm/mileageToMeters, StationOverlay stationOrder, RoutePanel stationKm)
|
||||||
|
- 원본 KML STA 형식("0+000")과 동일해져 데이터-화면 표기 일치
|
||||||
|
|
||||||
|
### ② 스테이션바 양끝 시작/끝 스테이션 마크
|
||||||
|
|
||||||
|
- appendEndpointStations: 첫/마지막 '노선 위'(offM≤60m) 프레임의 체이니지 기준,
|
||||||
|
기존 마크(시설물·시점 포함)가 **100m 이내에 없을 때만** '시작'/'끝' 마크 추가
|
||||||
|
- 위 줄 = 측점값(0+000 형식), 아래 줄 = '시작'/'끝'. 접근/이탈(노선밖) 구간은 제외
|
||||||
|
- 기존 시점(0+000 통과) 마크가 가까이 있으면 중복 방지로 생략됨
|
||||||
|
|
||||||
|
## 검증
|
||||||
|
|
||||||
|
- tsc+vite 빌드 통과, 새 번들(index-DMtfrnP9.js, 빌드 07.14 17:43) 서빙
|
||||||
|
- 적용: F5 새로고침(빌드 시각 17:43 확인) → 폴더 재선택
|
||||||
Reference in New Issue
Block a user