From c64c57b58e4f3ea1160678de396e974305369d57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=95=9C=EC=84=B1=EC=9D=BC?= Date: Tue, 14 Jul 2026 17:59:43 +0900 Subject: [PATCH] =?UTF-8?q?djmd=20=ED=94=84=EB=A0=88=EC=9E=84=20=EB=8F=99?= =?UTF-8?q?=EA=B8=B0=C2=B7=EC=98=A4=EB=B2=84=EB=A0=88=EC=9D=B4=20=EC=A7=80?= =?UTF-8?q?=EB=A9=B4=EA=B3=A0=EC=A0=95=C2=B7=EC=84=A0=ED=98=95-=EC=B8=A1?= =?UTF-8?q?=EC=A0=90=20=EC=A0=95=ED=95=A9=C2=B7UI=20=EB=8B=A8=EC=B6=95?= =?UTF-8?q?=ED=82=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- client/src/components/overlay/RoutePanel.tsx | 6 +- .../src/components/overlay/StationOverlay.tsx | 79 +++-- client/src/components/player/VideoPlayer.tsx | 41 ++- client/src/hooks/useVideoPlayer.ts | 33 +- client/src/stationbar/StationBar.tsx | 38 ++- .../components/Timeline/Timeline.tsx | 4 +- client/src/stationbar/utils/mileage.ts | 4 +- client/src/store/geoStore.ts | 39 ++- client/src/utils/chainage.ts | 4 +- client/src/utils/djmdTrack.ts | 318 ++++++++++++++++++ client/src/utils/geoData.ts | 79 ++++- client/src/utils/geoProjection.ts | 3 + docs/history/2026-07-13_2037_전체-커밋-2차.md | 17 + docs/history/2026-07-13_2042_push-GCM-연동.md | 13 + .../2026-07-14_0913_재부팅-자동시작-등록.md | 24 ++ .../2026-07-14_0920_포워딩-직접IP-자동갱신.md | 28 ++ ...026-07-14_1007_로드시-처음부터-일시정지.md | 20 ++ .../2026-07-14_1011_로딩완료-정지명령.md | 26 ++ .../2026-07-14_1014_데이터교체-UI초기화.md | 19 ++ .../2026-07-14_1055_시간동기-보정-슬라이더.md | 31 ++ ...14_1104_djmd-프레임동기-위치-업그레이드.md | 40 +++ .../2026-07-14_1117_선형-z톱니-평활.md | 28 ++ ...26-07-14_1131_POI-원거리-밀림-z우선순위.md | 31 ++ .../2026-07-14_1236_자세-시간축-재정렬.md | 37 ++ ...6-07-14_1254_교차로-밀림-offZ-잔재-교정.md | 29 ++ .../2026-07-14_1308_선형-측점기준-z정렬.md | 34 ++ .../2026-07-14_1321_선형-측점정점-삽입.md | 34 ++ .../2026-07-14_1324_측점라벨-즉시투영.md | 32 ++ ...1418_회전시-오버레이-딸림-포즈평활-제한.md | 35 ++ ...4_1431_선형선-선형점-불일치-구버전-진단.md | 28 ++ .../2026-07-14_1441_선형-드론높이모드-통일.md | 26 ++ .../2026-07-14_1535_측점팝업-표고-표시.md | 26 ++ .../2026-07-14_1543_어음천교-이중라벨-제거.md | 27 ++ .../2026-07-14_1615_F2-개발자모드-토글.md | 21 ++ .../2026-07-14_1700_단축키-A-선형토글.md | 19 ++ ...4_1743_측점표기-플러스형식-양끝스테이션.md | 31 ++ 36 files changed, 1248 insertions(+), 56 deletions(-) create mode 100644 client/src/utils/djmdTrack.ts create mode 100644 docs/history/2026-07-13_2037_전체-커밋-2차.md create mode 100644 docs/history/2026-07-13_2042_push-GCM-연동.md create mode 100644 docs/history/2026-07-14_0913_재부팅-자동시작-등록.md create mode 100644 docs/history/2026-07-14_0920_포워딩-직접IP-자동갱신.md create mode 100644 docs/history/2026-07-14_1007_로드시-처음부터-일시정지.md create mode 100644 docs/history/2026-07-14_1011_로딩완료-정지명령.md create mode 100644 docs/history/2026-07-14_1014_데이터교체-UI초기화.md create mode 100644 docs/history/2026-07-14_1055_시간동기-보정-슬라이더.md create mode 100644 docs/history/2026-07-14_1104_djmd-프레임동기-위치-업그레이드.md create mode 100644 docs/history/2026-07-14_1117_선형-z톱니-평활.md create mode 100644 docs/history/2026-07-14_1131_POI-원거리-밀림-z우선순위.md create mode 100644 docs/history/2026-07-14_1236_자세-시간축-재정렬.md create mode 100644 docs/history/2026-07-14_1254_교차로-밀림-offZ-잔재-교정.md create mode 100644 docs/history/2026-07-14_1308_선형-측점기준-z정렬.md create mode 100644 docs/history/2026-07-14_1321_선형-측점정점-삽입.md create mode 100644 docs/history/2026-07-14_1324_측점라벨-즉시투영.md create mode 100644 docs/history/2026-07-14_1418_회전시-오버레이-딸림-포즈평활-제한.md create mode 100644 docs/history/2026-07-14_1431_선형선-선형점-불일치-구버전-진단.md create mode 100644 docs/history/2026-07-14_1441_선형-드론높이모드-통일.md create mode 100644 docs/history/2026-07-14_1535_측점팝업-표고-표시.md create mode 100644 docs/history/2026-07-14_1543_어음천교-이중라벨-제거.md create mode 100644 docs/history/2026-07-14_1615_F2-개발자모드-토글.md create mode 100644 docs/history/2026-07-14_1700_단축키-A-선형토글.md create mode 100644 docs/history/2026-07-14_1743_측점표기-플러스형식-양끝스테이션.md diff --git a/client/src/components/overlay/RoutePanel.tsx b/client/src/components/overlay/RoutePanel.tsx index 74a8df6..92e3cc7 100644 --- a/client/src/components/overlay/RoutePanel.tsx +++ b/client/src/components/overlay/RoutePanel.tsx @@ -23,15 +23,15 @@ const VIDEO_FPS = 30000 / 1001; const cleanTitle = (t: string) => t.replace(/\s*\([상하]\)\s*$/, '').trim(); 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; return parseInt(m[1]) * 1000 + parseInt(m[2]); } -/** 미터값 → "158k160" (10m 단위, 재생바 배지와 동일 형식). */ +/** 미터값 → "158+160" (10m 단위, 재생바 배지와 동일 형식). */ function formatKm10(m: number): string { 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 = { diff --git a/client/src/components/overlay/StationOverlay.tsx b/client/src/components/overlay/StationOverlay.tsx index f28992d..05c867e 100644 --- a/client/src/components/overlay/StationOverlay.tsx +++ b/client/src/components/overlay/StationOverlay.tsx @@ -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 }; +// djmd 프레임 동기 데이터일 때 포즈 평활 상한(±프레임). 위치·자세가 이미 매끄러워 +// 긴 평활(기본 60 ≈ ±1s)은 회전 시 포즈 지연 → 오버레이 전체가 드론에 '딸려오는' 원인. +const POSE_SMOOTH_SYNCED_MAX = 5; + // 속도 적응형 평활(One Euro 방식) 상수. // 떨림(노이즈)은 매 프레임 방향이 번갈아 → 평활속도≈0 → 강하게 평활(1배속에서 안정). // 실제 이동은 방향이 일관 → 평활속도 큼 → alpha↑로 즉시 추종(빠른 배속에서 지연 없음). @@ -194,7 +198,7 @@ const orderedFullFields = ( ): { k: string; v: string }[] => [...compact, ...props.filter((p) => !isCompactKey(p.k))]; 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; 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 storeCenterline = useGeoStore(s => s.centerline); 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 storeStructures = useGeoStore(s => s.structures); const routeMeta = useGeoStore(s => s.routeMeta); @@ -799,18 +810,23 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible // 구조물(교량/터널/구교)을 영상 오버레이에 POI처럼 표시(거리필터 적용). // - 영상은 시설등급 무관 모두 표출(신대천교(복) 등 포함). 스테이션바엔 시설등급 필터로 별도 제어. // lat/lon 없는 항목(측점기반 배치 등)도 제외. + // 신포맷(지장물.kml '타입') 교량/터널은 POI 로도 등록됨 — 같은 이름이 POI 에 있으면 + // 영상 라벨이 이중 표출(파란 POI + 빨간 구조물, z 도 달라 위치 분리)되므로 구조물 쪽 제외. + // (스테이션바 구조물 마크는 storeStructures 직접 사용이라 영향 없음) useEffect(() => { + const poiTitles = new Set(storePois.map(p => p.title)); allStructuresRef.current = storeStructures .filter((s): s is RouteStructure & { lat: number; lon: number } => 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 => ({ title: s.name, category: s.category ?? '구교', lat: s.lat, lon: s.lon, z: 0, type: 'poi' as const, props: s.props, })); - }, [storeStructures]); + }, [storeStructures, storePois]); // 중심선 useEffect(() => { @@ -935,8 +951,14 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible const sq = Math.sqrt(disc); 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 => - 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++) { const p0 = allCL[i - 1], p1 = allCL[i]; 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 f1 = arr[i1].frame, f2 = arr[i2].frame; 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 L = (x: number, y: number) => x + (y - x) * frac; 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), yaw: a.yaw + dy * frac, }; - }, [smoothFrame]); + }, [smoothFrame, poseHalf]); // 텍스트 사전 계산 — requestIdleCallback으로 백그라운드 실행 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(); for (const poi of allPoi) { - // 지면고도 gz: 보정값(DEM/드래그) → 최근접 선로표고(유효값) → POI 자체 z → 경로표고 앵커. + // 지면고도 gz 우선순위: 보정값(드래그) → **POI 자체 z(실측)** → 중심선 z → 경로표고 앵커. + // 종전엔 중심선 스냅이 POI z 보다 우선 — 도로에서 떨어진 지물이 도로 높이에 붙어 + // 원거리에서 계속 밀리다 근거리에서만 맞는 증상의 원인. 실측 z 보유 데이터셋 기준으로 교정. const clz = nearestCL(poi.lat, poi.lon)?.z; gzOf.set(poi.title, overridesRef.current[poi.title]?.z + ?? (poi.z > 0 ? poi.z : undefined) ?? (clz !== undefined && clz > 0 ? clz : undefined) - ?? (poi.z > 0 ? poi.z : PATH_Z)); + ?? PATH_Z); } const t0 = performance.now(); @@ -1079,7 +1104,8 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible while (processed < end) { const idx = (startIdx + processed) % total; // startIdx→끝→0→… 순환 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'] = []; for (const sp of snappedSt) { @@ -1232,7 +1258,7 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible const p = paramsRef.current; const fr = allDroneFramesRef.current; const d = fr.length - ? smoothFrame(fr, currentFrameIdxRef.current, smoothHalfRef.current) + ? smoothFrame(fr, currentFrameIdxRef.current, poseHalf()) : cur; compassRef.current = { effectiveYaw: d.yaw + p.yawOffset, @@ -1246,7 +1272,7 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible if (!currentDroneFrameRef.current) { renderCacheRef.current = null; return; } const frames = allDroneFramesRef.current; const drone = frames.length - ? smoothFrame(frames, currentFrameIdxRef.current, smoothHalfRef.current) + ? smoothFrame(frames, currentFrameIdxRef.current, poseHalf()) : currentDroneFrameRef.current; const params = paramsRef.current; renderCacheRef.current = { @@ -1323,14 +1349,17 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible if (cache) { // 부드러운 시간(timeRef, 60fps 보간) → 연속 프레임번호. 선/궤적·라벨 공통 사용. - const estTime = timeRef + // 시간동기(timeOffsetSec): 영상↔비행로그의 시각 오프셋 보정 — POI 가 진행방향으로 + // 일괄 밀릴 때 조정(+ = 로그를 뒤로, − = 앞으로). camera.json 에 저장되는 보정값. + const estTime = (timeRef ? 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; // 연속 보간 포즈(라인·라벨 공통) — 매 RAF 프레임 직접 투영해 부드럽게. const dronePose = poseAt(estFrame) ?? (allDroneFramesRef.current.length - ? smoothFrame(allDroneFramesRef.current, currentFrameIdxRef.current, smoothHalfRef.current) + ? smoothFrame(allDroneFramesRef.current, currentFrameIdxRef.current, poseHalf()) : currentDroneFrameRef.current!); const lines = buildLines(dronePose); @@ -1453,7 +1482,10 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible if (cc.Zc < CLIP_Z) return; const { pxRaw, pyRaw } = pixelFromCamera(cc, params); 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); // 픽셀 히스테리시스: 0.75px 이상 움직일 때만 정수 위치 갱신 → 저속(1배속) 반올림 깜빡임 제거. 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 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; - }, [smoothFrame]); + }, [smoothFrame, poseHalf]); // 화면 클릭좌표(client px) → 영상프레임 정규좌표(0~1). object-fit:cover 역변환(coverRef). 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; const poi = allPoisRef.current.concat(allStructuresRef.current).find(p => p.title === hit.title); 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; setSelPoi(hit.title); setSelZ(poiZ); setSelZBase(baseZ); 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) : allGeoStationsRef.current.find(s => s.title === hit.title); 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 dist = drone ? 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 경도{pp.lon.toFixed(6)} )} + {/* 표고(z) — KML 좌표 3번째 성분(타원체고). 속성표(SimpleData)에 없어서 별도 표기. + (지장물의 '높이' 속성은 구조물 높이라 표고와 구분. 컴팩트 팝업은 간결 유지) */} + {showFull && pp.z > 0 && ( + <> + 표고 + {pp.z.toFixed(1)} m (타원체고) + + )} ); @@ -2137,6 +2177,7 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible >{camSaveState === 'ok' ? '저장됨 ✓' : 'PC에 저장'}
+ setParam('timeOffsetSec', v)} /> setParam('yawOffset', v)} /> {/* Yaw 자동 추정(2단계 보정의 1단계) — 대략 맞춘 뒤 'Yaw 보정' 드래그 모드로 마무리 */}