From 84f4921826301e557aa4acb6e8021e139a48767e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=95=9C=EC=84=B1=EC=9D=BC?= Date: Thu, 9 Jul 2026 20:58:49 +0900 Subject: [PATCH] =?UTF-8?q?=EC=A0=9C=EC=A3=BC=20=EB=93=9C=EB=A1=A0=20?= =?UTF-8?q?=EB=8F=84=EB=A1=9C=EC=98=81=EC=83=81=20=EC=A7=80=EC=9B=90:=20DJ?= =?UTF-8?q?I=20=EB=A1=9C=EA=B7=B8=20=EC=96=B4=EB=8C=91=ED=84=B0=C2=B7?= =?UTF-8?q?=EB=B6=84=ED=95=A0=EC=98=81=EC=83=81=20=EC=97=B0=EC=86=8D?= =?UTF-8?q?=EC=9E=AC=EC=83=9D=C2=B7=EC=B9=B4=EB=A9=94=EB=9D=BC=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=EA=B0=90=EC=A7=80=C2=B7=EC=98=A4=EB=B2=84=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 제주 DJI 시간기반 비행로그(CSV) 파싱 어댑터: 세그먼트 창 정렬, 가상 fps 환산 - 분할 영상(이름 오름차순) 연속재생 + 좌상단 영상목록 콤보(선택 재생), 세그먼트별 드론 로그 재정렬 - KML 다중 파일 병합 파싱: STA 체이니지 측점(71개) + 지장물 신포맷(타입/이름/텍스트박스_색상) - 시설물 라벨: 지정색 배경 박스 + 흰 글자, 팝업 중앙 정렬, 호버 구간 깜빡임 수정 - 카메라 자동감지(djmd: ZenmuseP1, focal 29.9mm) + <영상명>.camera.json PC 저장/폴더 자동 적용 - Yaw 자동추정(GPS 진행방위 기반) + 라벨 드래그 Yaw 역산 모드 - 스테이션바: GPS 이동거리 진행도(호버 시 정지), 노선밖 거리 표기, 원거리 구조물 마크 제외 - 서버: /api/camera 라우트, ecosystem 포트 54000·제주 데이터 경로 - docs/history: 작업 이력 21건 Co-Authored-By: Claude Fable 5 --- .../src/components/overlay/StationOverlay.tsx | 301 ++++++++++++--- client/src/components/player/VideoPlayer.tsx | 102 +++++ client/src/stationbar/StationBar.tsx | 111 +++++- .../TimelineCursor/TimelineCursor.module.scss | 4 +- client/src/store/geoStore.ts | 66 +++- client/src/types/geo.ts | 23 ++ client/src/utils/cameraMeta.ts | 108 ++++++ client/src/utils/geoData.ts | 354 ++++++++++++++++-- ...2026-07-09_1510_54000포트-외부접근-셋팅.md | 36 ++ ...026-07-09_1525_제주데이터-플레이어-연결.md | 37 ++ .../2026-07-09_1558_제주-드론궤적-어댑터.md | 49 +++ .../2026-07-09_1637_분할영상-연속재생.md | 48 +++ ...6-07-09_1648_스테이션바-이동거리-진행도.md | 44 +++ .../2026-07-09_1702_KML-시설물-측점-임포트.md | 53 +++ .../2026-07-09_1715_지장물KML-신포맷-파싱.md | 51 +++ ...6-07-09_1725_라벨-표고앵커-측점거리제한.md | 52 +++ .../2026-07-09_1738_영상-재생목록-패널.md | 31 ++ ...07-09_1754_카메라감지-라벨박스-영상콤보.md | 50 +++ ...26-07-09_1758_카메라focal-자동적용-개선.md | 30 ++ ...09_1802_스테이션바-노선밖-진행정지-수정.md | 33 ++ ...9_1842_스테이션바-원거리구조물-겹침수정.md | 40 ++ ...026-07-09_1850_스테이션배지-노선밖-표기.md | 38 ++ ...9_2021_카메라json-세그먼트라벨-팝업정렬.md | 47 +++ .../2026-07-09_2024_커서배지-줄바꿈-수정.md | 17 + ...26-07-09_2030_호버구간-라벨-깜빡임-수정.md | 35 ++ ...2026-07-09_2035_카메라패널-값-출처-확인.md | 23 ++ ...026-07-09_2042_djmd-자세-주점-부재-확인.md | 28 ++ .../2026-07-09_2046_yaw보정-방안-설명.md | 15 + ...7-09_2050_yaw자동추정-드래그역산-PC저장.md | 39 ++ ecosystem.config.js | 12 +- server/src/app.ts | 2 + server/src/routes/camera.ts | 64 ++++ 32 files changed, 1833 insertions(+), 110 deletions(-) create mode 100644 client/src/utils/cameraMeta.ts create mode 100644 docs/history/2026-07-09_1510_54000포트-외부접근-셋팅.md create mode 100644 docs/history/2026-07-09_1525_제주데이터-플레이어-연결.md create mode 100644 docs/history/2026-07-09_1558_제주-드론궤적-어댑터.md create mode 100644 docs/history/2026-07-09_1637_분할영상-연속재생.md create mode 100644 docs/history/2026-07-09_1648_스테이션바-이동거리-진행도.md create mode 100644 docs/history/2026-07-09_1702_KML-시설물-측점-임포트.md create mode 100644 docs/history/2026-07-09_1715_지장물KML-신포맷-파싱.md create mode 100644 docs/history/2026-07-09_1725_라벨-표고앵커-측점거리제한.md create mode 100644 docs/history/2026-07-09_1738_영상-재생목록-패널.md create mode 100644 docs/history/2026-07-09_1754_카메라감지-라벨박스-영상콤보.md create mode 100644 docs/history/2026-07-09_1758_카메라focal-자동적용-개선.md create mode 100644 docs/history/2026-07-09_1802_스테이션바-노선밖-진행정지-수정.md create mode 100644 docs/history/2026-07-09_1842_스테이션바-원거리구조물-겹침수정.md create mode 100644 docs/history/2026-07-09_1850_스테이션배지-노선밖-표기.md create mode 100644 docs/history/2026-07-09_2021_카메라json-세그먼트라벨-팝업정렬.md create mode 100644 docs/history/2026-07-09_2024_커서배지-줄바꿈-수정.md create mode 100644 docs/history/2026-07-09_2030_호버구간-라벨-깜빡임-수정.md create mode 100644 docs/history/2026-07-09_2035_카메라패널-값-출처-확인.md create mode 100644 docs/history/2026-07-09_2042_djmd-자세-주점-부재-확인.md create mode 100644 docs/history/2026-07-09_2046_yaw보정-방안-설명.md create mode 100644 docs/history/2026-07-09_2050_yaw자동추정-드래그역산-PC저장.md create mode 100644 server/src/routes/camera.ts diff --git a/client/src/components/overlay/StationOverlay.tsx b/client/src/components/overlay/StationOverlay.tsx index 2686e75..a8b1399 100644 --- a/client/src/components/overlay/StationOverlay.tsx +++ b/client/src/components/overlay/StationOverlay.tsx @@ -91,11 +91,26 @@ const CATEGORY_EMOJI: Record = { '역사': '🚉', '철도역': '🚉', '지장물': '🏢', + '지장물L': '🏢', '측점': '📍', '구교': '🌉', '출입문': '🚪', + '교차로': '🚦', + '도로': '🛣️', + '강.하천': '🌊', + '마을': '🏘️', + '산': '⛰️', }; +/** 라벨 색 밝기 판정 — 어두운 색이면 외곽선을 밝게 반전(가독성). */ +function isDarkLabelColor(hex: string): boolean { + const m = hex.match(/^#([0-9a-f]{6})$/i); + if (!m) return false; + const n = parseInt(m[1], 16); + const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255; + return 0.299 * r + 0.587 * g + 0.114 * b < 96; +} + // 영상 오버레이엔 모든 선로 구조물(교량/터널/구교)을 라벨로 표출한다. // 시설등급(시설종별) 표시 필터는 하단 스테이션바·RoutePanel 전용 → 영상은 항상 전부 표출. // (역사=철도역은 type='station'이라 여기 미포함 → 스테이션바 전용 유지.) @@ -113,8 +128,10 @@ const DISPLAY_DEFAULTS = { smoothSpeedRef: 0.010, // 즉시추종 기준 속도(클수록 더 강하게 평활) maxPoiRange: 1000, // false = 지면고도 기준(POI를 지면고도 z에 고정 → gap=드론고도−지면고도 자동변동, 드론 오르내려도 지물에 붙음). + // 표고 미상(z=0) 라벨은 '경로 표고' 평면에 앵커 → 궤적과 같은 슬라이더로 함께 보정됨. // true = 드론−이격거리 고정(gap 상수). 드론 고도 변동 시 부정확. - poiDroneHeight: true, + // 기본 false: 실측 z(회덕)는 지물에 정확히 붙고, 미상(제주)은 경로표고로 일괄 보정 가능. + poiDroneHeight: false, droneHeightDrop: 24, }; @@ -123,7 +140,7 @@ const DISPLAY_DEFAULTS = { // 포즈(poseAt)로 직접 투영 → 라인처럼 부드럽게 이동. (precompute = 가시성/겹침 결정 전용) interface LabelCache { stationLabels: { title: string; lat: number; lon: number; z: number }[]; - poiMarkers: { title: string; label: string; category: string; lat: number; lon: number; gz: number; compact: { k: string; v: string }[]; labelRow: number; labelRowCount: number }[]; + poiMarkers: { title: string; label: string; category: string; lat: number; lon: number; gz: number; compact: { k: string; v: string }[]; labelRow: number; labelRowCount: number; color?: string }[]; } // 중심선 + 나침반 렌더 캐시 (per-frame, renderCacheRef) @@ -322,6 +339,9 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible // FOV(초점) 보정 모드: POI 라벨을 실제 위치로 끌면 그 대응점으로 focal 을 역산. const [fovMode, setFovMode] = useState(false); const fovModeRef = useRef(false); + // Yaw 보정 모드: POI 라벨을 실제 위치(좌우)로 끌면 필요한 Yaw± 를 역산. + const [yawMode, setYawMode] = useState(false); + const yawModeRef = useRef(false); // 라벨 속성 팝업(다중) — 항상 활성(모드 버튼 없음). 라벨 위에서만 캔버스가 입력을 받아 클릭→팝업. // id=`${kind}:${title}`. 위치는 RAF 가 라벨 displayed 좌표로 매 프레임 갱신(재생 중 따라 이동). type InfoPopup = { id: string; kind: 'poi' | 'station'; title: string; category: string; lat: number; lon: number; z: number; dist: number | null; sx: number; sy: number; props?: { k: string; v: string }[]; @@ -392,6 +412,7 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible useEffect(() => { maxPoiRangeRef.current = maxPoiRange; }, [maxPoiRange]); useEffect(() => { editModeRef.current = editMode; }, [editMode]); useEffect(() => { fovModeRef.current = fovMode; }, [fovMode]); + useEffect(() => { yawModeRef.current = yawMode; }, [yawMode]); // 컴팩트 자동 팝업 동기화 — visStructRef(연속 온스크린 라벨) 마다 라벨 옆 DOM 팝업을 자동 '생성'만. // 제거는 RAF 가 단일 권한(onScreenLabels + 4프레임 유예)으로 담당 → add/remove 충돌(번쩍임) 제거. @@ -480,27 +501,35 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible useEffect(() => { overlapExcludeRef.current = poiOverlapExclude; }, [poiOverlapExclude]); // 좌측 노선 패널(RoutePanel) 표시 토글은 하단 재생바(VideoPlayer)로 이동. const storeBaseName = useGeoStore(s => s.baseName); + const storeCameraInfo = useGeoStore(s => s.cameraInfo); + const storeCameraJson = useGeoStore(s => s.cameraJson); const storePoiOverrides = useGeoStore(s => s.poiOverrides); const setPoiOverride = useGeoStore(s => s.setPoiOverride); const setPoiOverrides = useGeoStore(s => s.setPoiOverrides); const clearPoiOverride = useGeoStore(s => s.clearPoiOverride); - // 경로 표고 슬라이더 상한 — 데이터(중심선) 최대 지면표고 + 여유 (없으면 60 폴백) + // 경로 표고 슬라이더 상한 — 데이터 최대 표고 + 여유 (없으면 60 폴백). + // 중심선이 없거나 z=0 인 데이터셋(예: 제주 DJI 로그)은 드론 고도(ASL)가 유일한 상한 근거 — + // 지면은 드론보다 낮으므로 max(드론고도)까지 열어두면 어떤 지형이든 슬라이더로 도달 가능. const pathZMax = useMemo(() => { let m = 0; for (const c of storeCenterline) if (typeof c.z === 'number' && c.z > m) m = c.z; + for (const f of storeFrames) if (typeof f.altitude === 'number' && f.altitude > m) m = f.altitude; return m > 0 ? Math.ceil(m + 10) : 60; - }, [storeCenterline]); + }, [storeCenterline, storeFrames]); useEffect(() => { overridesRef.current = storePoiOverrides; }, [storePoiOverrides]); // ── 보정값 영속화 (baseName별 localStorage) ────────────────────────────── // 카메라 각도/내부표정(Yaw±/Pitch±/focal 등) + 표시설정을 데이터셋별로 저장·복원. // 한 번 튜닝하면 새로고침/재로드해도 유지 → "처음부터 맞게" 표시. const calibLoadedFor = useRef(null); + // 이 데이터셋에 저장된 보정값이 있었는지 — 카메라 감지값 자동 적용 여부 판단(사용자 튜닝 우선). + const calibHadSavedRef = useRef(false); useEffect(() => { if (!storeBaseName || calibLoadedFor.current === storeBaseName) return; calibLoadedFor.current = storeBaseName; try { const raw = localStorage.getItem(`ghivideo:calib:${storeBaseName}`); + calibHadSavedRef.current = !!raw; if (!raw) return; const c = JSON.parse(raw); if (c.params && typeof c.params === 'object') setParams(p => ({ ...p, ...c.params })); @@ -525,6 +554,100 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible return () => clearTimeout(t); }, [storeBaseName, params, maxPoiRange, smoothHalf, emaAlpha, smoothMinAlpha, smoothSpeedRef]); + // 영상(djmd)에서 감지한 카메라 초점거리 자동 적용. + // 사용자가 f 를 직접 튜닝한 값(기본값 24 가 아닌 저장값)만 우선하고, 그 외(첫 로드, + // 또는 저장은 됐지만 f 가 기본값 그대로인 경우)는 감지값을 적용한다. + // (보정 자동저장은 폴더를 열기만 해도 기본값으로 저장되므로 '저장 존재' 만으론 튜닝 여부 판단 불가) + const camAppliedFor = useRef(null); + useEffect(() => { + if (!storeBaseName || !storeCameraInfo?.focalLen35) return; + if (camAppliedFor.current === storeBaseName) return; + camAppliedFor.current = storeBaseName; + const f = storeCameraInfo.focalLen35; + const model = storeCameraInfo.model; + setParams(p => { + const focalTuned = calibHadSavedRef.current && + Math.abs(p.focalLen - DEFAULT_CAMERA_PARAMS.focalLen) > 0.01; + if (focalTuned) { + console.log(`[camera] ${model} 감지 (${f}mm) — 사용자 튜닝 f=${p.focalLen}mm 유지 (패널 '적용'으로 교체 가능)`); + return p; + } + console.log(`[camera] ${model} 감지 → focal ${f}mm 자동 적용`); + return { ...p, focalLen: f }; + }); + }, [storeBaseName, storeCameraInfo]); + + // 폴더의 <영상 base>.camera.json 카메라 파라미터 — 최우선 적용(localStorage/감지값 위에). + // 이 effect 는 calib 복원·감지값 적용 effect 뒤에 선언되어 함수형 업데이트가 마지막에 반영된다. + const camJsonAppliedFor = useRef(null); + useEffect(() => { + if (!storeBaseName || !storeCameraJson) return; + if (camJsonAppliedFor.current === storeBaseName) return; + camJsonAppliedFor.current = storeBaseName; + const cam = storeCameraJson; + setParams(p => { + const next = { ...p }; + for (const [k, v] of Object.entries(cam)) { + if (k in next && typeof v === 'number' && isFinite(v)) (next as unknown as Record)[k] = v; + } + return next; + }); + console.log('[camera] .camera.json 파라미터 적용(최우선):', cam); + }, [storeBaseName, storeCameraJson]); + + // 현재 카메라 파라미터를 사용자 PC 에 <영상 base>.camera.json 으로 다운로드 저장. + // Yaw±(자동추정/드래그 역산 결과) 포함 전체 파라미터가 담긴다. + // 받은 파일을 영상 폴더에 넣어두면 다음 폴더 선택 때 최우선으로 자동 적용된다. + const [camSaveState, setCamSaveState] = useState<'idle' | 'ok'>('idle'); + const saveCameraToPc = useCallback(() => { + const geo = useGeoStore.getState(); + const videoName = geo.videoFiles[geo.videoIndex]?.name ?? (geo.baseName ? `${geo.baseName}.mp4` : 'camera.mp4'); + const base = videoName.replace(/\.[^.]+$/, ''); + const payload = { + camera: paramsRef.current, + model: geo.cameraInfo?.model, + savedAt: new Date().toISOString(), + }; + const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${base}.camera.json`; + a.click(); + setTimeout(() => URL.revokeObjectURL(url), 5000); + setCamSaveState('ok'); + setTimeout(() => setCamSaveState('idle'), 2500); + }, []); + + // Yaw 자동 추정 — 전진 비행 구간에서 GPS 진행 방위(정확) vs 짐벌 헤딩(나침반 오차 포함)의 + // 차이 중앙값을 Yaw± 로 설정. 카메라가 진행방향을 보며 촬영하는 도로 추종 비행 가정(근사치). + const autoEstimateYaw = useCallback(() => { + const frames = allDroneFramesRef.current; + if (!frames || frames.length < 30) { alert('드론 로그가 부족해 자동 추정할 수 없습니다.'); return; } + const fps = fpsRef.current; + const cosLat = Math.cos((frames[0].lat * Math.PI) / 180); + const STEP = 10; // 진행방위 계산 창(≈1초) — GPS 지터 평활 + const diffs: number[] = []; + for (let i = STEP; i < frames.length; i++) { + const a = frames[i - STEP], b = frames[i]; + const dt = (b.frame - a.frame) / fps; + if (dt <= 0) continue; + const dx = (b.lon - a.lon) * 111000 * cosLat; + const dy = (b.lat - a.lat) * 111000; + if (Math.hypot(dx, dy) / dt < 2) continue; // 정지/호버 구간 제외 + const course = (Math.atan2(dx, dy) * 180) / Math.PI; // 진행 방위 (North=0, CW) + let d = course - frames[i - (STEP >> 1)].yaw; // 진행방위 − 짐벌 헤딩 + d = ((d + 540) % 360) - 180; + if (Math.abs(d) < 60) diffs.push(d); // 옆을 보며 찍은 구간 제외 + } + if (diffs.length < 20) { alert('전진 촬영 구간이 부족해 자동 추정할 수 없습니다.'); return; } + diffs.sort((x, y) => x - y); + const med = diffs[diffs.length >> 1]; + const next = Math.round(med * 10) / 10; + setParam('yawOffset', next); + console.log(`[yaw] 자동 추정: 유효 샘플 ${diffs.length}개, 중앙값 ${med.toFixed(2)}° → Yaw± ${next}° 적용`); + }, [setParam]); + // POI 보정(override: DEM 표고/드래그)도 baseName별 자동 저장·복원 → 새로고침해도 유지. const ovLoadedFor = useRef(null); useEffect(() => { @@ -728,6 +851,9 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible const CLIP_Z = 0.1; const SMOOTH_HALF = currentSmoothHalf; const MAX_RANGE = currentMaxRange; // POI 표시 범위 — 드론 수평 직선거리(m) + // 표고 미상(z=0) 라벨의 앵커 평면 — 드론 궤적과 동일한 '경로 표고' 값. + // 사용자가 경로표고 슬라이더로 궤적을 도로에 맞추면 측점/POI 라벨도 함께 맞는다. + const PATH_Z = dronePathZRef.current; if (!frames.length) return; @@ -752,20 +878,31 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible const stationLabels: LabelCache['stationLabels'] = []; for (const st of allSt) { const snap = nearestCL(st.lat, st.lon); - const slat = snap?.lat ?? st.lat, slon = snap?.lon ?? st.lon, sz = snap?.z ?? st.z; + const slat = snap?.lat ?? st.lat, slon = snap?.lon ?? st.lon; + const szRaw = snap?.z ?? st.z; + // 표고 미상(0 이하)이면 경로표고 평면에 앵커 — 공중부양/시차 밀림 방지. + const sz = szRaw > 0 ? szRaw : PATH_Z; const cc = toCameraCoords(drone, slat, slon, sz, currentParams, worldOrigin); if (cc.Zc < CLIP_Z) continue; + // 측점도 POI 와 동일하게 표시 범위 제한 — 원거리(수 km) 측점이 수평선에 뭉치는 것 방지. + if ((cc.distH ?? 0) > MAX_RANGE) continue; const { pxRaw, pyRaw } = pixelFromCamera(cc, currentParams); - if (pxRaw < -0.05 || pxRaw > 1.05 || pyRaw < -0.05 || pyRaw > 1.05) continue; + // 포함 경계 ±0.15 로 넉넉히 — 경계에 걸친 라벨이 호버(정지) 중 GPS 지터로 + // 캐시 프레임마다 들락거리며 깜빡이는 것 방지. 실제 화면 밖 컬링은 드로우가 담당. + if (pxRaw < -0.15 || pxRaw > 1.15 || pyRaw < -0.15 || pyRaw > 1.15) continue; // 화면좌표 대신 (스냅된)월드좌표 저장 → RAF 가 연속 포즈로 재투영. stationLabels.push({ title: st.title, lat: slat, lon: slon, z: sz }); } // 후보 수집 (거리 dist + 월드좌표 + 지면고도 gz) → 겹침 억제용 - const poiCand: { x: number; y: number; title: string; label: string; category: string; dist: number; lat: number; lon: number; gz: number; compact: { k: string; v: string }[] }[] = []; + const poiCand: { x: number; y: number; title: string; label: string; category: string; dist: number; lat: number; lon: number; gz: number; compact: { k: string; v: string }[]; color?: string }[] = []; for (const poi of allPoi) { - // 지면고도 gz: 보정값(DEM/드래그) 또는 최근접 선로표고. RAF 투영 기준값. - const gz = overridesRef.current[poi.title]?.z ?? nearestCL(poi.lat, poi.lon)?.z ?? poi.z; + // 지면고도 gz: 보정값(DEM/드래그) → 최근접 선로표고(유효값) → POI 자체 z → 경로표고 앵커. + // 표고 미상(0)인 데이터셋(제주)은 경로표고 평면에 앵커되어 궤적과 함께 보정된다. + const clz = nearestCL(poi.lat, poi.lon)?.z; + const gz = overridesRef.current[poi.title]?.z + ?? (clz !== undefined && clz > 0 ? clz : undefined) + ?? (poi.z > 0 ? poi.z : PATH_Z); // 가시성 판정용 poiZ: 드론높이 모드면 드론−이격거리, 아니면 gz. const poiZ = currentDroneHeight ? drone.altitude - (currentParams.geoidOffset ?? 0) - currentDroneDrop @@ -775,13 +912,18 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible // 표시 범위: 드론과의 수평 직선거리(distH)가 범위 안이면 표시, 밖이면 제외. if ((cc.distH ?? 0) > MAX_RANGE) continue; const { pxRaw, pyRaw } = pixelFromCamera(cc, currentParams); - if (pxRaw < -0.02 || pxRaw > 1.02 || pyRaw < -0.02 || pyRaw > 1.02) continue; - poiCand.push({ x: pxRaw, y: pyRaw, title: poi.title, label: guboonOf(poi.props) ?? cleanTitle(poi.title), category: poi.category, dist: cc.distH ?? 0, lat: poi.lat, lon: poi.lon, gz, compact: compactFieldsOf(poi.props) }); + // 포함 경계 ±0.15 — 경계 걸침 라벨의 호버 중 10Hz 깜빡임 방지(화면 밖 컬링은 드로우 담당). + if (pxRaw < -0.15 || pxRaw > 1.15 || pyRaw < -0.15 || pyRaw > 1.15) continue; + poiCand.push({ x: pxRaw, y: pyRaw, title: poi.title, label: guboonOf(poi.props) ?? cleanTitle(poi.title), category: poi.category, dist: cc.distH ?? 0, lat: poi.lat, lon: poi.lon, gz, compact: compactFieldsOf(poi.props), ...(poi.labelColor ? { color: poi.labelColor } : {}) }); } // 겹침 억제: KAKAO_RAIL(철도역/역사) 라벨을 최우선 유지 → 겹치면 일반 POI 가 숨겨진다. // 같은 우선순위 내에서는 가까운(dist 작은) 것 우선. 임계 이내면 뒤(낮은우선/먼) 것 숨김. + // 거리차 1m 미만은 제목순 고정 — 호버(정지) 중 GPS 지터로 승자가 프레임마다 뒤바뀌며 + // 라벨 쌍이 교대로 깜빡이는 것 방지. const railRank = (cat: string): number => (cat === '철도역' || cat === '역사' ? 0 : 1); - poiCand.sort((a, b) => railRank(a.category) - railRank(b.category) || a.dist - b.dist); + poiCand.sort((a, b) => + railRank(a.category) - railRank(b.category) || + (Math.abs(a.dist - b.dist) > 1 ? a.dist - b.dist : (a.title < b.title ? -1 : a.title > b.title ? 1 : 0))); // 겹침 시 진행방향 변형 우선: 같은 base 형제(예: 회덕제1가도교 상/하)가 겹치면 // 하행='(하)' / 상행='(상)' 쪽을 남긴다(드론이 실제 지나는 선로). 무관한 POI엔 영향 없음. // 진행방향 변형 태그(닫는 괄호까지) — 하행='(하)', 상행='(상)'. '(하인상)' 등은 제외. @@ -816,7 +958,7 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible const ck = coordKey(c); const row = groupSeen.get(ck) ?? 0; groupSeen.set(ck, row + 1); - return { title: c.title, label: c.label, category: c.category, lat: c.lat, lon: c.lon, gz: c.gz, compact: c.compact, labelRow: row, labelRowCount: groupCount.get(ck) ?? 1 }; + return { title: c.title, label: c.label, category: c.category, lat: c.lat, lon: c.lon, gz: c.gz, compact: c.compact, labelRow: row, labelRowCount: groupCount.get(ck) ?? 1, ...(c.color ? { color: c.color } : {}) }; }); newMap.set(drone.frame, { stationLabels, poiMarkers }); @@ -841,17 +983,20 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible }, [nearestCL]); // 모든 데이터 로드 완료 시 사전 계산 시작 + // storeFrames 의존 필수: 분할 영상 세그먼트 전환(loadSegment)으로 frames 가 교체되면 + // 라벨 캐시(프레임별 가시 POI)를 새 구간 기준으로 재계산해야 함 — 없으면 이전 세그먼트 + // 캐시가 남아 두 번째 영상부터 시설물이 표출되지 않는다. useEffect(() => { if (!droneFramesLoaded || !geoDataLoaded || !clDataLoaded) return; startLabelPrecompute(paramsRef.current, smoothHalf, maxPoiRange, poiDroneHeight, droneHeightDrop); - }, [droneFramesLoaded, geoDataLoaded, clDataLoaded, startLabelPrecompute, smoothHalf, maxPoiRange, poiDroneHeight, droneHeightDrop, poiOverlapExclude]); + }, [droneFramesLoaded, geoDataLoaded, clDataLoaded, startLabelPrecompute, smoothHalf, maxPoiRange, poiDroneHeight, droneHeightDrop, poiOverlapExclude, dronePathZ, storeFrames]); - // params / smoothHalf / maxPoiDist 변경 시 사전 계산 재시작 (500ms debounce) + // params / smoothHalf / maxPoiDist / 경로표고(표고 미상 라벨 앵커) 변경 시 사전 계산 재시작 (500ms debounce) useEffect(() => { if (!droneFramesLoaded || !geoDataLoaded || !clDataLoaded) return; const timer = setTimeout(() => startLabelPrecompute(params, smoothHalf, maxPoiRange, poiDroneHeight, droneHeightDrop), 500); return () => clearTimeout(timer); - }, [params, smoothHalf, maxPoiRange, poiDroneHeight, droneHeightDrop, storePois, storeStructures, droneFramesLoaded, geoDataLoaded, clDataLoaded, startLabelPrecompute, poiOverlapExclude]); + }, [params, smoothHalf, maxPoiRange, poiDroneHeight, droneHeightDrop, storePois, storeStructures, droneFramesLoaded, geoDataLoaded, clDataLoaded, startLabelPrecompute, poiOverlapExclude, dronePathZ, storeFrames]); // 현재 재생 시간 → 드론 프레임 ref 갱신 useEffect(() => { @@ -1131,31 +1276,32 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible const py = (prevDpoi?.ry !== undefined && Math.abs(psy0 - prevDpoi.ry) <= 0.75) ? prevDpoi.ry : Math.round(psy0); d.rx = px; d.ry = py; displayedPoiRef.current.set(poiA.title, d); - const r = 10; - // 십자 마커 - ctx.strokeStyle = '#64c8ff'; ctx.lineWidth = 2.5; - ctx.beginPath(); - ctx.moveTo(px-r, py); ctx.lineTo(px+r, py); - ctx.moveTo(px, py-r); ctx.lineTo(px, py+r); - ctx.stroke(); - // 이모지 + 텍스트 - const emoji = CATEGORY_EMOJI[poiA.category] ?? '📌'; + // 텍스트 박스만 표출(십자 마커/이모지 아이콘 없음): + // 배경 = KML 텍스트박스_색상(poiA.color, 없으면 기본 하늘색), 글자 = 흰색. + // 박스는 POI 지점(px)에 가로 중앙 정렬. + const boxCol = poiA.color ?? '#64c8ff'; // 라벨: 속성에 '구분'이 있으면 그 값(원문 그대로), 없으면 cleanTitle(title). poiCand에서 확정. - const label = `${emoji} ${poiA.label}`; - const lx = Math.max(2, px + 14); - // 동일좌표 라벨 세로 배치: 행 중앙을 십자에 맞춰 위→아래로 안 겹치게 오프셋. - const ROW_H = 24; + const label = poiA.label; + // 동일좌표 라벨 세로 배치: 행 중앙을 지점에 맞춰 위→아래로 안 겹치게 오프셋. + const ROW_H = 28; const labelY = py + (poiA.labelRow - (poiA.labelRowCount - 1) / 2) * ROW_H; - ctx.strokeStyle = 'rgba(0,0,0,0.85)'; ctx.lineWidth = 4; - ctx.lineJoin = 'round'; - ctx.textBaseline = 'middle'; - ctx.strokeText(label, lx, labelY); - ctx.fillStyle = '#64c8ff'; - ctx.fillText(label, lx, labelY); - // 히트박스(십자 마커 ~ 글자 끝) — 아이콘·글자 어디를 눌러도 선택되게. (라벨 행 위치 기준) const tw = ctx.measureText(label).width; - hitBoxes.push({ kind: 'poi', title: poiA.title, x0: px - r, y0: labelY - 13, x1: lx + tw + 2, y1: labelY + 13 }); - const poiVisible = labelOnScreen(px - r, labelY - 13, lx + tw + 2, labelY + 13); + // 박스 높이 = 글자 크기(20px) × 1.2 — 라벨 상하 여유. 행 간격(ROW_H)도 같이 벌림. + const PAD_X = 7, BOX_H = 24; + const bx0 = Math.max(2, px - tw / 2 - PAD_X); + const bx1 = bx0 + tw + PAD_X * 2; + const by0 = labelY - BOX_H / 2, by1 = labelY + BOX_H / 2; + ctx.fillStyle = boxCol; + ctx.beginPath(); + if (typeof ctx.roundRect === 'function') ctx.roundRect(bx0, by0, bx1 - bx0, BOX_H, 5); + else ctx.rect(bx0, by0, bx1 - bx0, BOX_H); + ctx.fill(); + ctx.fillStyle = '#ffffff'; + ctx.textBaseline = 'middle'; + ctx.fillText(label, bx0 + PAD_X, labelY); + // 히트박스 = 텍스트 박스 영역 (어디를 눌러도 선택) + hitBoxes.push({ kind: 'poi', title: poiA.title, x0: bx0, y0: by0 - 2, x1: bx1 + 2, y1: by1 + 2 }); + const poiVisible = labelOnScreen(bx0, by0, bx1, by1); if (poiVisible) onScreenLabels.add(`poi:${poiA.title}`); // 연속 온스크린 프레임 수 갱신(화면 밖이면 0으로 리셋). const streak = poiVisible ? (poiOnScreenStreakRef.current.get(poiA.title) ?? 0) + 1 : 0; @@ -1179,7 +1325,7 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible } // end if(cache) — 중심선/드론경로/측점/POI // 편집/FOV 보정 모드: 드래그 중 마커 피드백 (마우스 따라다님) - if ((editModeRef.current || fovModeRef.current) && dragRef.current && dragPosRef.current) { + if ((editModeRef.current || fovModeRef.current || yawModeRef.current) && dragRef.current && dragPosRef.current) { const dx = vx(dragPosRef.current.x), dy = vy(dragPosRef.current.y); ctx.strokeStyle = '#ffe14d'; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(dx, dy, 14, 0, Math.PI * 2); ctx.stroke(); @@ -1224,7 +1370,10 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible // 잡으면 라벨 높이만큼 먹혀 팝업이 글자에 붙는다 → 라벨 '바깥 가장자리' 기준으로 띄운다. const LABEL_HALF = 15; // 라벨 반높이(px) — sy 중심 ± 이 값이 글자/아이콘 영역 const GAP = 12; // 라벨 가장자리 ~ 팝업 사이 실제 여백 - const tx = Math.min(Math.max(8, sx - 10), Math.max(8, W - pw - 4)); + // POI 라벨은 지점(sx) 기준 가로 중앙 정렬 박스 → 팝업도 박스 중앙 아래로 정렬. + // 측점 라벨은 마커 오른쪽으로 그려지므로 기존(좌측 여유 10px) 유지. + const txRaw = kind === 'poi' ? sx - pw / 2 : sx - 10; + const tx = Math.min(Math.max(8, txRaw), Math.max(8, W - pw - 4)); // 기본은 라벨 '아래'. 아래 공간이 부족하면(화면 하단 근접) 라벨 '위'로 플립. // ※ 히스테리시스(sticky): 경계에서 sy 가 미세하게 오르내려도 위↔아래로 왕복하지 않도록, // '위'로 간 뒤엔 아래에 HYST 이상 여유가 생겨야만 '아래'로 복귀. (왕복=팝업 순간이동=번쩍임 방지) @@ -1299,7 +1448,7 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible const { x: nx, y: ny } = clientToVideoNorm(e.clientX, e.clientY); // 편집/세로화각 보정: POI 선택 + 드래그 - if (editModeRef.current || fovModeRef.current) { + if (editModeRef.current || fovModeRef.current || yawModeRef.current) { const canvas = canvasRef.current; if (!canvas) return; const hit = hitTestLabel(nx, ny); if (!hit) return; @@ -1366,15 +1515,15 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible // 영상이 준비되기 전(폴더 선택 화면 등)엔 입력을 받지 않음 → 하위 UI(폴더 선택/드롭) 클릭 통과. if (!videoReady) { canvas.style.pointerEvents = 'none'; return; } canvas.style.pointerEvents = 'auto'; - canvas.style.cursor = (editMode || fovMode) ? 'move' : 'default'; - if (editMode || fovMode) { setInfoPopups([]); return; } + canvas.style.cursor = (editMode || fovMode || yawMode) ? 'move' : 'default'; + if (editMode || fovMode || yawMode) { setInfoPopups([]); return; } const onMove = (e: MouseEvent) => { const { x: nx, y: ny } = clientToVideoNorm(e.clientX, e.clientY); canvas.style.cursor = hitTestLabel(nx, ny) ? 'pointer' : 'default'; }; parent.addEventListener('mousemove', onMove); return () => { parent.removeEventListener('mousemove', onMove); }; - }, [videoReady, editMode, fovMode, clientToVideoNorm, hitTestLabel]); + }, [videoReady, editMode, fovMode, yawMode, clientToVideoNorm, hitTestLabel]); const onPoiPointerUp = useCallback(() => { const drag = dragRef.current, pos = dragPosRef.current; @@ -1385,6 +1534,27 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible const drone = getSmoothedDrone(); if (!drone) return; const p = paramsRef.current, wo = worldOriginRef.current; + // ── Yaw(방위 오프셋) 보정 ── + // 드롭한 화면점의 '가로'(px)만 맞춘다. 라벨의 절대 방위는 고정이므로, + // 현재 카메라축 기준 수평각 θ0 = atan2(Xc, Zc) 과 드롭 지점이 요구하는 + // 수평각 θ1 = atan((px* − 0.5 − cx0)·sW / f) 의 차이만큼 카메라 방위를 돌리면 됨: + // yawOffset_new = yawOffset_old + (θ0 − θ1) + if (yawModeRef.current) { + const poiZ = poiDroneHeightRef.current + ? drone.altitude - (p.geoidOffset ?? 0) - droneHeightDropRef.current + : drag.z0; + const cc = toCameraCoords(drone, drag.lat, drag.lon, poiZ, p, wo); + if (cc.Zc < 0.1) return; + const th0 = Math.atan2(cc.Xc, cc.Zc); + const th1 = Math.atan(((pos.x - 0.5 - (p.cx0 ?? 0)) * (p.sensorW ?? 36)) / p.focalLen); + const dYaw = (th0 - th1) * 180 / Math.PI; + if (!isFinite(dYaw) || Math.abs(dYaw) > 45) { alert('보정량이 비정상적으로 큽니다 — 올바른 지물 위치로 끌었는지 확인하세요.'); return; } + const next = Math.max(-180, Math.min(180, (p.yawOffset ?? 0) + dYaw)); + setParam('yawOffset', Math.round(next * 10) / 10); + console.log(`[yaw] 드래그 역산: Δ${dYaw.toFixed(2)}° → Yaw± ${next.toFixed(1)}°`); + return; + } + // ── 세로 화각(sensorH) 보정 ── // 드롭한 화면점의 '세로'(py)만 맞춘다. 가로(f/sW)는 이미 정확하므로 건드리지 않음 // (f 를 바꾸면 가로까지 틀어져 스테이션선이 좌우로 벗어남). @@ -1632,26 +1802,32 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible {poiDroneHeight ? `■ 드론 높이와 POI 이격거리 ${droneHeightDrop}m` : '▶ 드론 높이와 POI 이격거리'} {poiDroneHeight && ( - setDroneHeightDrop(Math.round(v))} /> + setDroneHeightDrop(Math.round(v))} /> )}
POI 위치 편집 (마우스)
- - +
ℹ️ 라벨 클릭 → 속성 팝업(여러 개 누적, 재생 따라 이동). 빈 곳 클릭 → 팝업 닫기, 팝업 없으면 재생/정지.
{dragTitle ? `드래그 중: ${dragTitle}` : fovMode ? `🎯 POI를 실제 위치로 끌면 세로화각 자동보정 (현재 senH=${(params.sensorH ?? 20.25).toFixed(2)}mm)` + : yawMode ? `🧭 POI를 실제 위치(좌우)로 끌면 Yaw 자동보정 (현재 Yaw±=${params.yawOffset.toFixed(1)}°)` : `보정된 POI ${Object.keys(storePoiOverrides).length}개`}
{editMode && selPoi && ( @@ -1700,6 +1876,13 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
자세 보정 오프셋(SRT + offset)
setParam('yawOffset', v)} /> + {/* Yaw 자동 추정(2단계 보정의 1단계) — 대략 맞춘 뒤 'Yaw 보정' 드래그 모드로 마무리 */} + setParam('pitch', v)} /> setParam('roll', v)} />
@@ -1712,6 +1895,30 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
정표고→타원체고 (대전≈25.8)
내부표정 (초점·주점·센서)
+ {/* 영상(djmd) 자동 감지 정보 + 카메라값 서버 저장('<영상 base>.camera.json' — 재로드 시 자동 적용) */} +
+ + 📷 {storeCameraInfo + ? `${storeCameraInfo.model}${storeCameraInfo.focalLen35 ? ` · ${storeCameraInfo.focalLen35}mm` : ''}${storeCameraInfo.fps ? ` · ${storeCameraInfo.fps}fps` : ''}` + : '카메라 미감지'} + +
+ {storeCameraInfo?.focalLen35 !== undefined && ( + + )} + +
+
setParam('focalLen', v)} /> setParam('cx0', v)} /> diff --git a/client/src/components/player/VideoPlayer.tsx b/client/src/components/player/VideoPlayer.tsx index 4950cc1..6e2328a 100644 --- a/client/src/components/player/VideoPlayer.tsx +++ b/client/src/components/player/VideoPlayer.tsx @@ -43,6 +43,12 @@ export interface VideoPlayerHandle { getVideoElement: () => HTMLVideoElement | null; } +/** 초 → "m:ss" (재생목록 길이 표시용). 미상이면 "--:--". */ +function fmtDur(d: number | null | undefined): string { + if (d == null || !isFinite(d)) return '--:--'; + return `${Math.floor(d / 60)}:${String(Math.floor(d % 60)).padStart(2, '0')}`; +} + interface VideoPlayerProps { onAddMemo: (time: number) => void; onToggleHelp?: () => void; @@ -119,6 +125,12 @@ const VideoPlayer = forwardRef( const geoLoaded = useGeoStore((s) => s.loaded); // 드론 정보 — 하단바에 GPS/고도 항상 표시. const storeFrames = useGeoStore((s) => s.frames); + // 분할 영상 재생목록 — 폴더 로드 시 이름 오름차순. 목록 클릭으로 세그먼트 직접 선택 재생. + const videoFiles = useGeoStore((s) => s.videoFiles); + const videoDurations = useGeoStore((s) => s.videoDurations); + const videoIndex = useGeoStore((s) => s.videoIndex); + // 영상목록 콤보(좌상단 타이틀 아래) 펼침 상태 — 기본 접힘. + const [showPlaylist, setShowPlaylist] = useState(false); // 시설등급(시설종별) 표시 필터 — 재생바/패널이 구독하는 설정 스토어. const gradeFilter = useSettingsStore((s) => s.gradeFilter); const setGradeFilter = useSettingsStore((s) => s.setGradeFilter); @@ -155,6 +167,48 @@ const VideoPlayer = forwardRef( } }; + // 재생목록에서 세그먼트 선택 — 같은 항목이면 처음부터 재생, 다른 항목이면 + // 드론 로그를 해당 구간으로 재정렬(loadSegment)한 뒤 전환 재생. + const handleSelectSegment = async (i: number): Promise => { + const geo = useGeoStore.getState(); + if (usePlayerStore.getState().source?.kind !== 'local') return; + if (i === geo.videoIndex) { + playerRef.current?.currentTime(0); + } else { + const file = await geo.loadSegment(i); + if (!file) return; + loadLocalFile(file); + } + const p = playerRef.current?.play(); + if (p && typeof p.catch === 'function') p.catch(() => {}); + }; + + // 분할 영상 연속재생 — 영상이 끝나면 같은 폴더의 다음 영상(이름 오름차순)을 자동 재생. + // 드론 로그(시간 기반)는 loadSegment 가 누적 오프셋으로 재정렬해 궤적 정합을 유지한다. + useEffect(() => { + const player = playerRef.current; + if (!player) return; + const onEnded = (): void => { + void (async () => { + const geo = useGeoStore.getState(); + if (!geo.loaded || geo.videoFiles.length < 2) return; + // 폴더 기반 로컬 재생에서만 동작 (서버 스트림/단일 파일은 대상 아님) + if (usePlayerStore.getState().source?.kind !== 'local') return; + const next = geo.videoIndex + 1; + if (next >= geo.videoFiles.length) return; + const file = await geo.loadSegment(next); + if (!file) return; + loadLocalFile(file); + // ended 직후는 paused 상태라 loadLocalFile 의 이어재생 로직이 안 걸림 → 직접 재생. + const p = player.play(); + if (p && typeof p.catch === 'function') p.catch(() => {}); + })(); + }; + player.on('ended', onEnded); + return () => { player.off('ended', onEnded); }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [playerRef, loadLocalFile]); + // Expose methods to parent via ref useImperativeHandle(ref, () => ({ loadLocalFile, @@ -425,6 +479,54 @@ const VideoPlayer = forwardRef( />
+ {/* 영상 목록 콤보 — 좌상단 타이틀(노선 배너) 아래. 버튼 클릭 시 목록이 아래로 펼쳐지고 + 항목 선택 시 해당 영상으로 즉시 전환(드론 로그도 세그먼트에 맞춰 자동 재정렬). + 현재 재생 항목 재선택 시 처음부터 재생. */} + {source?.kind === 'local' && videoFiles.length > 0 && ( +
+ + {showPlaylist && ( +
    + {videoFiles.map((f, i) => ( +
  • + +
  • + ))} +
+ )} +
+ )} + {/* Empty state placeholder — 가운데 반투명 폴더 선택 */} {!source && (
diff --git a/client/src/stationbar/StationBar.tsx b/client/src/stationbar/StationBar.tsx index 365ee76..ce6fc52 100755 --- a/client/src/stationbar/StationBar.tsx +++ b/client/src/stationbar/StationBar.tsx @@ -33,11 +33,13 @@ interface GeoPoint { type: string; } -/** 프레임별 precompute 결과. km=배지용 최근접측점, chain=구간방향용 연속 체이니지. */ +/** 프레임별 precompute 결과. km=배지용 최근접측점, chain=구간방향용 연속 체이니지, + * offM=측점 폴리라인까지 수평거리(m) — 노선 밖(접근/이탈) 판별용. */ interface ViewedPoint { frameNum: number; km: number; chain: number; + offM: number; time: number; } @@ -47,9 +49,10 @@ interface StationLine { k: number; // 경도→m 환산 (cos(lat0)*111000) } -/** 드론 lat/lon 을 측점 폴리라인에 투영 → 연속 체이니지(m). - * 100m 양자화된 최근접측점과 달리 전진/후진 전환 시점이 실제 이동과 일치. */ -function projectChainage(lat: number, lon: number, line: StationLine): number { +/** 드론 lat/lon 을 측점 폴리라인에 투영 → 연속 체이니지(m) + 폴리라인까지 수평거리(m). + * 100m 양자화된 최근접측점과 달리 전진/후진 전환 시점이 실제 이동과 일치. + * distM 은 노선 밖(접근/이탈) 판별에 사용 — 밖에서는 chain 이 끝점에 클램프됨. */ +function projectChainageDist(lat: number, lon: number, line: StationLine): { chain: number; distM: number } { const px = lon * line.k; const py = lat * 111000; const pts = line.pts; @@ -70,7 +73,11 @@ function projectChainage(lat: number, lon: number, line: StationLine): number { bestKm = a.km + (b.km - a.km) * t; } } - return bestKm; + return { chain: bestKm, distM: isFinite(bestD) ? Math.sqrt(bestD) : Infinity }; +} + +function projectChainage(lat: number, lon: number, line: StationLine): number { + return projectChainageDist(lat, lon, line).chain; } /** 데이터 기반 트랙 구간: km 증가(dir 1, 주황) / 감소(dir -1, 하늘색). px는 시간축. */ @@ -232,32 +239,79 @@ export function StationBar({ useEffect(() => { const frames = storeFrames; - if (!frames.length || !stations.length || !stationLine) { setReady(false); return; } + if (!frames.length || !stations.length || !stationLine) { + // 측점 없는 데이터셋(예: 제주 DJI 로그) 폴백 — 측점 기반 기능(배지/구간색/마크)은 + // ready=false 로 비활성이지만, 이동거리축(chainRef)은 드론 GPS 누적 이동거리로 구성한다. + // → 커서 진행도가 시간 선형이 아닌 실제 이동량을 따라가 호버(정지) 시 커서도 멈춘다. + setReady(false); + viewedRef.current = []; + if (!frames.length) { chainRef.current = null; return; } + const n = frames.length; + const W = 8; // 평활 반폭(프레임) — GPS 지터가 이동거리로 누적되는 것 방지(측점 경로와 동일 정책) + const preLat = new Float64Array(n + 1); + const preLon = new Float64Array(n + 1); + for (let i = 0; i < n; i++) { + preLat[i + 1] = preLat[i] + frames[i].lat; + preLon[i + 1] = preLon[i] + frames[i].lon; + } + const cosLat = Math.cos((frames[0].lat * Math.PI) / 180); + const time = new Float64Array(n); + const frac = new Float64Array(n); + let cum = 0, pLat = 0, pLon = 0; + for (let i = 0; i < n; i++) { + const lo = Math.max(0, i - W), hi = Math.min(n, i + W + 1); + const la = (preLat[hi] - preLat[lo]) / (hi - lo); + const ln = (preLon[hi] - preLon[lo]) / (hi - lo); + if (i > 0) cum += Math.hypot((la - pLat) * 111000, (ln - pLon) * 111000 * cosLat); + pLat = la; pLon = ln; + time[i] = frames[i].frame / videoFps; + frac[i] = cum; + } + const total = cum > 0 ? cum : 1; + for (let i = 0; i < n; i++) frac[i] /= total; + // depChain/arrChain 은 방향색(측점 기반) 전용 — 폴백에선 의미 없음(단조 증가 가정값). + chainRef.current = { time, frac, depChain: 0, arrChain: 1 }; + return; + } const n = frames.length; const out: ViewedPoint[] = new Array(n); for (let i = 0; i < n; i++) { const f = frames[i]; + const pj = projectChainageDist(f.lat, f.lon, stationLine); out[i] = { frameNum: f.frame, km: nearestStationKm(f, stations), - chain: projectChainage(f.lat, f.lon, stationLine), + chain: pj.chain, + offM: pj.distM, time: f.frame / videoFps, }; } - // 이동거리 축: 평활 측점값(±W 이동평균)의 프레임간 |Δ| 누적 = 실제 이동량 → 전체로 정규화(frac). - // 호버(이동 無)면 frac 정체 → 커서 정지. 이동하면(전진/후진 무관) frac 증가 → 커서 항상 우측. - // depChain/arrChain(앞·뒤 5% 평균)은 방향색 기준. + // 이동거리 축(frac): 체이니지 |Δ| 가 아니라 GPS 실이동거리 누적(±W 이동평균 평활). + // 체이니지 투영은 노선 밖(접근/이탈 구간)에서 폴리라인 끝점(0k000 등)에 클램프되어 + // 드론이 움직여도 커서가 멈추는 문제가 있었음(제주 — 비행 시작점이 노선 밖 700m). + // GPS 누적거리는 호버 시 정체(커서 정지) + 노선 안팎 무관하게 실제 이동을 따라간다. + // depChain/arrChain(앞·뒤 5% 평균, 체이니지 기준)은 방향색 판정에만 사용. const W = 8; // 평활 반폭(프레임) const pre = new Float64Array(n + 1); - for (let i = 0; i < n; i++) pre[i + 1] = pre[i] + out[i].chain; + const preLat = new Float64Array(n + 1); + const preLon = new Float64Array(n + 1); + for (let i = 0; i < n; i++) { + pre[i + 1] = pre[i] + out[i].chain; + preLat[i + 1] = preLat[i] + frames[i].lat; + preLon[i + 1] = preLon[i] + frames[i].lon; + } + const cosLat = Math.cos((frames[0].lat * Math.PI) / 180); const time = new Float64Array(n); const sm = new Float64Array(n); const frac = new Float64Array(n); - let cum = 0; + let cum = 0, pLat = 0, pLon = 0; for (let i = 0; i < n; i++) { const lo = Math.max(0, i - W), hi = Math.min(n, i + W + 1); sm[i] = (pre[hi] - pre[lo]) / (hi - lo); - if (i > 0) cum += Math.abs(sm[i] - sm[i - 1]); + const la = (preLat[hi] - preLat[lo]) / (hi - lo); + const ln = (preLon[hi] - preLon[lo]) / (hi - lo); + if (i > 0) cum += Math.hypot((la - pLat) * 111000, (ln - pLon) * 111000 * cosLat); + pLat = la; pLon = ln; time[i] = out[i].time; frac[i] = cum; } @@ -297,6 +351,13 @@ export function StationBar({ return idx >= 0 ? viewedRef.current[idx].chain : null; }, [currentTime, ready, viewedIdxAtTime]); + // 현재 드론의 노선(측점 폴리라인) 이탈 거리(m) — 접근/이탈 구간 배지 표기용. + const realOffM = useMemo(() => { + if (!ready) return null; + const idx = viewedIdxAtTime(currentTime); + return idx >= 0 ? viewedRef.current[idx].offM : null; + }, [currentTime, ready, viewedIdxAtTime]); + // 종점역 '미도착' 폭(px) — 영상이 종점역 도착 전 끝날 때, 트랙 우측에 이만큼 비워둔다. // 영상 타임라인은 [START … END−endGapPx] 에만 매핑되고, 종점역 마커는 END(미도착)에 둔다. // 폭 = 미도착거리(m) / 노선길이(m) × 트랙폭 (옵션 2: 실제 비례). 안전상 15%로 상한. @@ -335,11 +396,12 @@ export function StationBar({ return null; }, [routeMeta, directionChanges, duration]); - // 진행도(이동거리축): 시간 t → 누적 이동거리 비율(frac, 0~1). chainRef 미준비 시 시간선형 폴백. + // 진행도(이동거리축): 시간 t → 누적 이동거리 비율(frac, 0~1). chainRef 미구성 시 시간선형 폴백. + // chainRef 는 측점 유무와 무관하게 드론 프레임만 있으면 구성된다(위 폴백) → ready 비의존. const cumFracAtTime = useCallback( (t: number): number => { const c = chainRef.current; - if (!c || !ready || duration <= 0) + if (!c || duration <= 0) return duration > 0 ? clamp(t / duration, 0, 1) : 0; const { time, frac } = c; const n = time.length; @@ -355,10 +417,11 @@ export function StationBar({ [ready, duration], ); // 역변환(이동거리 비율 → 시간): 바 클릭 seek 용. frac 단조 비감소 → 이진 탐색. + // cumFracAtTime 과 동일하게 ready 비의존(측점 없는 폴백 chainRef 도 사용). const timeAtFrac = useCallback( (f: number): number => { const c = chainRef.current; - if (!c || !ready || duration <= 0) return clamp(f, 0, 1) * duration; + if (!c || duration <= 0) return clamp(f, 0, 1) * duration; const { time, frac } = c; const n = frac.length; if (n === 0) return 0; @@ -387,8 +450,15 @@ export function StationBar({ const cursorPx = progressPx; // 커서 배지 = 폴더 데이터 기반 연속 체이니지(realChain)를 10m 단위로 표시. 데이터 없으면 빈 문자열. + // 드론이 노선(측점 폴리라인)에서 OFF_ROUTE_M 이상 벗어난 구간(접근/이탈)은 체이니지가 + // 끝점에 클램프되어 무의미 → "노선밖 ○○m" 로 표기해 측점이 안 변하는 이유를 드러낸다. + const OFF_ROUTE_M = 60; const cursorText = - realChain !== null && realChain >= 0 ? formatMileage10(realChain) : ''; + realChain !== null && realChain >= 0 + ? (realOffM !== null && realOffM > OFF_ROUTE_M + ? `노선밖 ${Math.round(realOffM)}m` + : formatMileage10(realChain)) + : ''; // viewedRef(프레임→측점 km) 추이로 구간 색·전환점 산출. // km 증가 구간 = dir 1(주황), 감소 구간 = dir -1(하늘색). HYST로 최근접 지터 무시. @@ -498,6 +568,13 @@ export function StationBar({ if (d < gmin) gmin = d; } if (!isFinite(gmin)) return []; + // 이 영상(세그먼트)에서 실제로 근접 통과하지 않은 구조물은 마크 생략. + // 종전엔 TH 가 최근접거리에 비례해 늘어나(gmin×3) 수 km 밖 구조물도 최근접 + // 프레임(세그먼트 끝)에 마크가 찍혀, 실제 통과 구조물과 겹쳐 보였음 + // (제주: 세그1 끝 귀덕천교 위에 금성천교·어음천교가 겹침 — 이들은 세그3 통과분). + // 해당 구조물은 통과하는 세그먼트의 바에서 정상 표시된다. + const PASS_MAX_M = 300; + if (gmin > PASS_MAX_M) return []; const TH = Math.max(100, gmin * 3); // 통과 인정 거리(m) // 통과 프레임에서 진행방향으로 offsetM 만큼 이동(GPS 누적거리 기준). const shift = (idx: number): number => { diff --git a/client/src/stationbar/components/TimelineCursor/TimelineCursor.module.scss b/client/src/stationbar/components/TimelineCursor/TimelineCursor.module.scss index d797f12..ceae280 100755 --- a/client/src/stationbar/components/TimelineCursor/TimelineCursor.module.scss +++ b/client/src/stationbar/components/TimelineCursor/TimelineCursor.module.scss @@ -41,9 +41,11 @@ align-items: center; justify-content: center; color: #fff; - font-size: 20px; + /* "노선밖 737m" 같은 긴 표기도 한 줄 유지 — 글자 축소 + 줄바꿈 금지(폭은 내용에 맞게 늘어남) */ + font-size: 17px; font-weight: 700; letter-spacing: 0.4px; + white-space: nowrap; box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.35), 0 2px 6px rgba(0, 0, 0, 0.45); diff --git a/client/src/store/geoStore.ts b/client/src/store/geoStore.ts index cbd47b5..26027d5 100644 --- a/client/src/store/geoStore.ts +++ b/client/src/store/geoStore.ts @@ -23,8 +23,9 @@ import type { DirectionChange, PoiOverride, PoiOverrideMap, + DetectedCameraInfo, } from '../types/geo'; -import { loadFolderGeoData, applyPoiOverrides } from '../utils/geoData'; +import { loadFolderGeoData, applyPoiOverrides, parseDroneFrames } from '../utils/geoData'; interface GeoStore { loaded: boolean; @@ -43,12 +44,30 @@ interface GeoStore { directionChanges: DirectionChange[]; /** 마지막 폴더 로드에서 KMZ(POI·구조물 원본)가 누락됐는지. true면 재구축 필요. */ kmzMissing: boolean; + /** 폴더 내 영상 파일 전체(이름 오름차순) — 분할본 연속재생용. */ + videoFiles: File[]; + /** videoFiles 각 영상 길이(초). 측정 실패 시 null. */ + videoDurations: (number | null)[]; + /** 현재 재생 중인 세그먼트 인덱스 (videoFiles 기준). */ + videoIndex: number; + /** 폴더 원본 파일 전체 — 세그먼트 전환 시 드론 CSV 재파싱용. */ + folderFiles: File[]; + /** 영상 djmd 트랙에서 감지한 카메라 정보 (예: ZenmuseP1, 초점거리). */ + cameraInfo: DetectedCameraInfo | null; + /** 폴더의 `<영상 base>.camera.json` 카메라 파라미터 — 감지값/저장값보다 최우선 적용. */ + cameraJson: Record | null; /** * 폴더 선택 파일에서 지리정보를 파싱해 스토어에 적재한다. * 발견한 영상 File 을 반환(없으면 null) — 호출자가 loadLocalFile 로 재생. */ loadFromFolder: (files: FileList | File[]) => Promise; + /** + * 분할 영상 세그먼트 전환 — 드론 로그(시간 기반)를 해당 영상 구간(누적 오프셋)으로 + * 재정렬해 frames 를 교체한다. 재생할 영상 File 을 반환(범위 밖이면 null). + * baseName 은 폴더(첫 영상) 기준을 유지한다 — 카메라 보정 localStorage 연속성. + */ + loadSegment: (index: number) => Promise; /** 단일 POI 보정 설정/갱신 (드래그 종료 시). title 키. */ setPoiOverride: (title: string, ov: PoiOverride) => void; /** 단일 POI 보정 해제. */ @@ -72,9 +91,15 @@ const EMPTY = { structures: [] as RouteStructure[], directionChanges: [] as DirectionChange[], kmzMissing: false, + videoFiles: [] as File[], + videoDurations: [] as (number | null)[], + videoIndex: 0, + folderFiles: [] as File[], + cameraInfo: null as DetectedCameraInfo | null, + cameraJson: null as Record | null, }; -export const useGeoStore = create((set) => ({ +export const useGeoStore = create((set, get) => ({ ...EMPTY, loadFromFolder: async (files) => { @@ -93,10 +118,47 @@ export const useGeoStore = create((set) => ({ structures: data.structures, directionChanges: data.directionChanges, kmzMissing: data.kmzMissing, + videoFiles: data.videoFiles, + videoDurations: data.videoDurations, + videoIndex: 0, + folderFiles: data.folderFiles, + cameraInfo: data.cameraInfo, + cameraJson: data.cameraJson, }); return data.videoFile; }, + loadSegment: async (index) => { + const s = get(); + const file = s.videoFiles[index]; + if (!file) return null; + + // 누적 오프셋 = 앞 세그먼트 길이 합. 하나라도 측정 실패면 정렬 불가 → 궤적만 비운다(재생은 계속). + let offsetSec = 0; + let offsetKnown = true; + for (let i = 0; i < index; i++) { + const d = s.videoDurations[i]; + if (d == null) { offsetKnown = false; break; } + offsetSec += d; + } + + if (!offsetKnown) { + console.warn(`[geo] 세그먼트 ${index + 1} 오프셋 계산 불가(영상 길이 측정 실패) — 드론 궤적 미표시`); + set({ frames: [], videoIndex: index }); + return file; + } + + const frames = await parseDroneFrames(s.folderFiles, s.baseName, { + offsetSec, + durationSec: s.videoDurations[index], + }); + console.log( + `[geo] 세그먼트 ${index + 1}/${s.videoFiles.length} 전환 — 로그 ${offsetSec.toFixed(1)}s~ 정렬, ${frames.length}행`, + ); + set({ frames, videoIndex: index }); + return file; + }, + setPoiOverride: (title, ov) => set((s) => { const poiOverrides = { ...s.poiOverrides, [title]: ov }; diff --git a/client/src/types/geo.ts b/client/src/types/geo.ts index faa3a88..9014fad 100644 --- a/client/src/types/geo.ts +++ b/client/src/types/geo.ts @@ -32,6 +32,8 @@ export interface GeoPoint { type: 'poi' | 'station'; /** 원본 CSV 속성(라벨 클릭 팝업에 표시). 순서 보존 위해 배열. */ props?: { k: string; v: string }[]; + /** 라벨 표시 색(CSS). KML 텍스트박스_색상/IconStyle 유래. 없으면 기본색. */ + labelColor?: string; } /** 의미상 별칭 (POI = GeoPoint, Station = GeoPoint with type==='station') */ @@ -152,6 +154,17 @@ export interface RouteMeta { structures?: RouteStructure[]; } +/** 영상 파일(djmd 트랙)에서 자동 감지한 드론 카메라 정보 */ +export interface DetectedCameraInfo { + /** 카메라 모델 (예: "DJI ZenmuseP1") */ + model: string; + /** 35mm 환산 초점거리(mm) — djmd 필드 8.6(0.1mm 단위) 유래 */ + focalLen35?: number; + width?: number; + height?: number; + fps?: number; +} + /** loadFolderGeoData 파싱 결과 (v2.0 데이터 형상) */ export interface FolderGeoData { videoFile: File | null; @@ -170,4 +183,14 @@ export interface FolderGeoData { poiOverrides: PoiOverrideMap; /** KMZ(POI·구조물 원본)가 없거나 비어 있음 → 데이터 누락(재구축 필요). 측점·드론은 별개. */ kmzMissing: boolean; + /** 폴더 내 영상 파일 전체(이름 오름차순) — 분할본 연속재생용. videoFile === videoFiles[0]. */ + videoFiles: File[]; + /** videoFiles 와 짝을 이루는 각 영상 길이(초). 메타데이터 측정 실패 시 null. */ + videoDurations: (number | null)[]; + /** 폴더 원본 파일 전체 — 세그먼트 전환 시 드론 CSV 재파싱용. */ + folderFiles: File[]; + /** 영상 djmd 트랙에서 감지한 카메라 정보 (DJI 아닌 영상이면 null). */ + cameraInfo: DetectedCameraInfo | null; + /** 폴더의 `<영상 base>.camera.json` 카메라 파라미터 (없으면 null). 최우선 적용. */ + cameraJson: Record | null; } diff --git a/client/src/utils/cameraMeta.ts b/client/src/utils/cameraMeta.ts new file mode 100644 index 0000000..dccfc5b --- /dev/null +++ b/client/src/utils/cameraMeta.ts @@ -0,0 +1,108 @@ +/** + * DJI 영상 카메라 메타데이터 자동 감지 + * + * DJI 드론 MP4 에는 'djmd' 데이터 트랙(프레임별 protobuf 텔레메트리)이 mdat 에 + * 비디오 프레임과 인터리브되어 있다. 파일 앞부분(8MB)에서 첫 djmd 샘플을 찾아 추출: + * - 카메라 모델 문자열 (필드 1.1.2, 예: "DJI ZenmuseP1") + * - 렌즈 초점거리 (필드 8.6, 0.1mm 단위 35mm 환산) + * - 해상도/fps (필드 1.7/1.6/1.8) + * + * 실측 검증(2026-07-09, 제주 데이터): DJI_20260629121835_0002.MP4 → + * model "DJI ZenmuseP1", 8.6=299(=29.9mm — DL 24mm 렌즈 × 4K 비디오 크롭 ≈1.25), + * 3840×2160, 59.94fps. (djmd 필드 6.4/6.5 는 위경도 라디안, 6.6 은 타원체고 mm) + */ + +import type { DetectedCameraInfo } from '../types/geo'; + +const HEAD_BYTES = 8 * 1024 * 1024; + +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]; +} + +/** [start,end) 구간을 protobuf 필드로 파싱해 경로("8.6")→값 수집. 깨진 구간은 조용히 중단. */ +function parseFields( + b: Uint8Array, dv: DataView, + start: number, end: number, + prefix: string, out: Map, +): 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; + const path = prefix + tag; + if (wire === 0) { + const r = readVarint(b, i); if (!r) return; + out.set(path, r[0]); i = r[1]; + } else if (wire === 5) { + if (i + 4 > end) return; + out.set(path, dv.getFloat32(i, true)); i += 4; + } else if (wire === 1) { + if (i + 8 > end) return; + out.set(path, dv.getFloat64(i, true)); i += 8; + } else if (wire === 2) { + const r = readVarint(b, i); if (!r) return; + const [len, p] = r; + if (p + len > end) return; + // 하위 메시지 시도 — 문자열/바이너리면 내부에서 알아서 중단됨(경로 prefix 로 충돌 없음) + parseFields(b, dv, p, p + len, path + '.', out); + i = p + len; + } else return; + } +} + +/** 영상 File 앞부분에서 DJI djmd 카메라 정보를 감지한다. DJI 영상이 아니면 null. */ +export async function detectCameraInfo(file: File): Promise { + try { + const buf = new Uint8Array(await file.slice(0, HEAD_BYTES).arrayBuffer()); + const dv = new DataView(buf.buffer, buf.byteOffset, buf.byteLength); + + // 모델 문자열: 0x12 "DJI ..." (필드 1.1.2) + let m = -1; + for (let i = 2; i < buf.length - 4; i++) { + if (buf[i] === 0x44 && buf[i + 1] === 0x4a && buf[i + 2] === 0x49 && buf[i + 3] === 0x20 && + buf[i - 2] === 0x12 && buf[i - 1] >= 4 && buf[i - 1] <= 40) { m = i; break; } + } + if (m < 0) return null; + const model = new TextDecoder().decode(buf.slice(m, m + buf[m - 1])); + + // 샘플 시작(최상위 필드1, 0x0a 0x0a ...)을 모델 문자열에서 역방향 탐색 + let sampleStart = -1; + for (let back = 4; back <= 16; back++) { + const i = m - 2 - back; + if (i < 0) break; + if (buf[i] !== 0x0a) continue; + const r = readVarint(buf, i + 1); + if (!r) continue; + const [len, p] = r; + if (buf[p] === 0x0a && p < m && p + len > m && p + len <= buf.length) { sampleStart = i; break; } + } + if (sampleStart < 0) return { model }; + + const out = new Map(); + parseFields(buf, dv, sampleStart, Math.min(buf.length, sampleStart + 512), '', out); + + const info: DetectedCameraInfo = { model }; + const focal10 = out.get('8.6'); + if (focal10 !== undefined && focal10 > 80 && focal10 < 3000) info.focalLen35 = focal10 / 10; // 8~300mm 만 신뢰 + const w = out.get('1.7'), h = out.get('1.6'), fps = out.get('1.8'); + if (w && w > 100) info.width = w; + if (h && h > 100) info.height = h; + if (typeof fps === 'number' && fps > 1 && fps < 1000) info.fps = Math.round(fps * 100) / 100; + return info; + } catch { + return null; + } +} diff --git a/client/src/utils/geoData.ts b/client/src/utils/geoData.ts index ba652e9..9404ea0 100644 --- a/client/src/utils/geoData.ts +++ b/client/src/utils/geoData.ts @@ -33,6 +33,7 @@ import type { PoiOverrideMap, } from '../types/geo'; import { stationOrder, getWorldOrigin } from './geoSearch'; +import { detectCameraInfo } from './cameraMeta'; import { unzipSync, strFromU8 } from 'fflate'; // ── CSV 파싱 헬퍼 ───────────────────────────────────────────────────── @@ -120,11 +121,33 @@ function findBuildingFile(files: File[], keyword: string): File | null { const VIDEO_EXT = /\.(mp4|webm)$/i; -/** 영상 파일 찾기 (mp4/webm, building 제외). */ +/** 폴더 내 모든 영상 파일 (이름순 정렬 — DJI 분할본 _0002, _0003… 순서 보장). */ +export function findVideoFiles(files: File[]): File[] { + return files + .filter((f) => !isInBuilding(f) && VIDEO_EXT.test(baseNameOf(relPath(f)))) + .sort((a, b) => baseNameOf(relPath(a)).localeCompare(baseNameOf(relPath(b)))); +} + +/** 영상 파일 찾기 (mp4/webm, building 제외). 여러 개면 이름순 첫 번째. */ export function findVideoFile(files: File[]): File | null { - return ( - files.find((f) => !isInBuilding(f) && VIDEO_EXT.test(baseNameOf(relPath(f)))) ?? null - ); + return findVideoFiles(files)[0] ?? null; +} + +/** 영상 File 의 재생 길이(초) — 메타데이터만 읽는다(전체 로드 없음). 실패 시 null. */ +export function probeVideoDuration(file: File): Promise { + return new Promise((resolve) => { + const url = URL.createObjectURL(file); + const v = document.createElement('video'); + v.preload = 'metadata'; + const done = (d: number | null): void => { + URL.revokeObjectURL(url); + v.removeAttribute('src'); + resolve(d); + }; + v.onloadedmetadata = () => done(isFinite(v.duration) ? v.duration : null); + v.onerror = () => done(null); + v.src = url; + }); } /** 영상 파일명에서 base(확장자 제거) 추출. */ @@ -133,17 +156,44 @@ export function deriveBaseName(videoFile: File | null): string | null { return baseNameOf(relPath(videoFile)).replace(VIDEO_EXT, ''); } -// ── 파서: 드론 프레임 (변경 없음) ───────────────────────────────────── +// ── 파서: 드론 프레임 ───────────────────────────────────────────────── /** - * 드론 프레임 CSV 파싱 (UTF-8 BOM, 헤더 이름 인덱스). - * 헤더: frame_cnt,latitude,longitude,altitude,yaw,pitch,roll,focal_len + * DJI 비행로그(시간 기반) → 프레임 환산용 가상 fps. + * frame = 영상 상대시간(초) × 이 값. 소비측(effectiveFps = maxFrame ÷ 영상길이)이 + * 같은 값을 역산해 시간으로 되돌리므로 상수 자체는 임의여도 정합이 유지된다. + * 59.94 는 이 데이터셋(DJI 4K60) 실제 fps 와 일치시켜 HUD 표기도 자연스럽게 한 것. + */ +const DJI_VIRTUAL_FPS = 59.94; + +/** 영상 세그먼트 창 — DJI 시간 기반 로그를 특정 영상 구간에 정렬할 때 사용. */ +export interface SegmentWindow { + /** 전체 녹화 기준 이 영상의 시작 오프셋(초). 첫 세그먼트는 0. */ + offsetSec: number; + /** 이 영상의 길이(초). 미상(null)이면 클리핑 없이 전체 로그를 쓴다. */ + durationSec: number | null; +} + +/** + * 드론 프레임 CSV 파싱 (UTF-8 BOM, 헤더 이름 인덱스). 두 형식 지원: + * + * ① 프레임 기반(회덕 v2.0): frame_cnt,latitude,longitude,altitude,yaw,pitch,roll,focal_len + * ② 시간 기반(DJI 비행로그, 제주): time(millisecond),latitude,longitude,…, + * altitude_above_seaLevel/compass_heading/gimbal_heading(degrees) 등. + * - 첫 행 time = 녹화 시작으로 보고(로그 폭 ≈ 분할영상 총길이 검증됨), + * frame = ((time−t0)/1000 − offsetSec) × DJI_VIRTUAL_FPS 로 환산. + * - 자세는 카메라 방향인 gimbal_* 우선, 없으면 기체 compass/pitch/roll 폴백. + * - focal_len 이 없어 35mm 환산 24mm(DJI 광각 기본)로 둔다 → 카메라 파라미터 UI로 보정. + * - segmentWindow.durationSec 가 있으면 해당 영상 구간 밖 행을 버린다 + * (분할 영상에서 effectiveFps 자기보정이 성립하려면 필수). + * * 루트(building 제외)의 .csv 를 식별한다. base 가 없으면 루트 .csv 중 * POI 가 아닌 첫 파일을 사용한다(영상명 비의존 폴백). */ export async function parseDroneFrames( files: File[], baseName: string | null, + segmentWindow: SegmentWindow | null = null, ): Promise { const rootCsv = files.filter((f) => { if (isInBuilding(f)) return false; @@ -164,6 +214,51 @@ export async function parseDroneFrames( const fi = makeFieldIndexer(rows[0]); + // ② 시간 기반(DJI 비행로그) — time(millisecond) 헤더로 감지. + const iTime = fi('time(millisecond)'); + if (iTime >= 0) { + const iLat = fi('latitude'); + const iLon = fi('longitude'); + const iAlt = fi('altitude_above_seaLevel(meter)'); + const iYaw = fi('gimbal_heading(degrees)') >= 0 ? fi('gimbal_heading(degrees)') : fi('compass_heading(degrees)'); + const iPitch = fi('gimbal_pitch(degrees)') >= 0 ? fi('gimbal_pitch(degrees)') : fi('pitch(degrees)'); + const iRoll = fi('gimbal_roll(degrees)') >= 0 ? fi('gimbal_roll(degrees)') : fi('roll(degrees)'); + + const raw = rows + .slice(1) + .map((r) => ({ + timeMs: parseFloat(cell(r, iTime)), + lat: parseFloat(cell(r, iLat)), + lon: parseFloat(cell(r, iLon)), + altitude: parseFloat(cell(r, iAlt)), + yaw: parseFloat(cell(r, iYaw)), + pitch: parseFloat(cell(r, iPitch)), + roll: parseFloat(cell(r, iRoll)) || 0, + focalLen: 24, + })) + .filter((f) => !isNaN(f.lat) && !isNaN(f.timeMs)); + if (!raw.length) return []; + + const t0 = raw[0].timeMs; + const offsetSec = segmentWindow?.offsetSec ?? 0; + const durationSec = segmentWindow?.durationSec ?? null; + const maxFrame = durationSec != null ? durationSec * DJI_VIRTUAL_FPS : Infinity; + + const frames = raw + .map(({ timeMs, ...rest }) => ({ + ...rest, + frame: Math.round(((timeMs - t0) / 1000 - offsetSec) * DJI_VIRTUAL_FPS), + })) + .filter((f) => f.frame >= 0 && f.frame <= maxFrame); + + console.log( + `[geo] DJI 시간기반 로그: ${raw.length}행 중 ${frames.length}행 사용 ` + + `(구간 ${offsetSec.toFixed(1)}s~${durationSec != null ? (offsetSec + durationSec).toFixed(1) : '끝'}s, 가상fps ${DJI_VIRTUAL_FPS})`, + ); + return frames; + } + + // ① 프레임 기반(회덕 v2.0) return rows .slice(1) .map((r) => ({ @@ -290,48 +385,91 @@ function childLocal(el: Element, local: string): Element | null { return null; } -/** KMZ/KML 파싱 — 있으면 { pois, structures }, 없거나 실패 시 null. - * bare .kml(구글 직접 다운로드) 우선, 없으면 .kmz(zip) 해제. */ +/** 한국어 색상명 → CSS 색 (KML 텍스트박스_색상 속성). 어두운 색은 렌더 시 밝은 외곽선 처리. */ +const KOR_LABEL_COLORS: Record = { + 검정: '#1f2937', 흰색: '#ffffff', 회색: '#9ca3af', + 빨강: '#ef4444', 주황: '#fb923c', 노랑: '#facc15', + 연두: '#a3e635', 초록: '#22c55e', 하늘: '#38bdf8', + 파랑: '#3b82f6', 남색: '#3730a3', 보라: '#a855f7', 분홍: '#f472b6', +}; + +function koreanColorToCss(v?: string): string | undefined { + if (!v) return undefined; + const key = v.trim(); + if (KOR_LABEL_COLORS[key]) return KOR_LABEL_COLORS[key]; + for (const k in KOR_LABEL_COLORS) if (key.includes(k)) return KOR_LABEL_COLORS[k]; + return undefined; +} + +/** Placemark 인라인 IconStyle 색 — KML aabbggrr → CSS #rrggbb. */ +function inlineIconColor(pm: Element): string | undefined { + const icon = pm.getElementsByTagName('IconStyle')[0]; + const c = icon?.getElementsByTagName('color')[0]?.textContent?.trim(); + if (!c || !/^[0-9a-fA-F]{8}$/.test(c)) return undefined; + const bb = c.slice(2, 4), gg = c.slice(4, 6), rr = c.slice(6, 8); + return `#${rr}${gg}${bb}`.toLowerCase(); +} + +/** KMZ/KML 파싱 — 있으면 { pois, structures, stations }, 없거나 실패 시 null. + * bare .kml(구글 직접 다운로드) 우선, 없으면 .kmz(zip) 해제. + * + * 속성 소스 2종을 모두 props 로 수집한다: + * - description CDATA HTML 표 (구글어스/지오코딩 산출물 — 회덕 v2.0) + * - ExtendedData/SchemaData/SimpleData (CAD·GIS 내보내기 — 제주 도로 KML) + * + * 측점(STA): 이름이 도로 체이니지(`6+998`) 형식이거나 STA 속성이 있는 Point 는 + * POI 가 아니라 측점(type='station')으로 수집한다. km 속성(단위 km) 우선, 없으면 + * 이름에서 미터값을 파싱해 기존 철도식 제목(`6k998`)으로 변환 — stationOrder/stationKm + * 등 측점 파이프라인(스테이션바·노선패널·검색)과 그대로 호환된다. */ export async function parseKmz( files: File[], -): Promise<{ pois: GeoPoint[]; structures: RouteStructure[] } | null> { - const kmlFile = files.find( +): Promise<{ pois: GeoPoint[]; structures: RouteStructure[]; stations: GeoPoint[] } | null> { + // 폴더 내 모든 KML/KMZ 를 파싱해 병합한다 (예: 제주 — 측점 KML + 지장물 KML 별도 파일). + const kmlFiles = files.filter( (f) => !isInBuilding(f) && /\.kml$/i.test(baseNameOf(relPath(f))), ); - const kmzFile = files.find( + const kmzFiles = files.filter( (f) => !isInBuilding(f) && /\.kmz$/i.test(baseNameOf(relPath(f))), ); - if (!kmlFile && !kmzFile) return null; + if (!kmlFiles.length && !kmzFiles.length) return null; - let kmlText: string; - try { - if (kmlFile) { - kmlText = await kmlFile.text(); // 구글 KML 은 UTF-8 - } else { - const buf = new Uint8Array(await kmzFile!.arrayBuffer()); + const kmlTexts: string[] = []; + for (const kf of kmlFiles) { + try { + kmlTexts.push(await kf.text()); // 구글 KML 은 UTF-8 + } catch (e) { + console.warn(`[KML] 읽기 실패: ${baseNameOf(relPath(kf))}`, e); + } + } + for (const kf of kmzFiles) { + try { + const buf = new Uint8Array(await kf.arrayBuffer()); const entries = unzipSync(buf); const kmlName = Object.keys(entries).find((n) => /(^|\/)doc\.kml$/i.test(n)) ?? Object.keys(entries).find((n) => /\.kml$/i.test(n)); - if (!kmlName || !entries[kmlName]) return null; - kmlText = strFromU8(entries[kmlName]); + if (kmlName && entries[kmlName]) kmlTexts.push(strFromU8(entries[kmlName])); + } catch (e) { + console.warn(`[KMZ] 읽기 실패: ${baseNameOf(relPath(kf))}`, e); } - } catch (e) { - console.warn('[KMZ/KML] 읽기 실패 → CSV 폴백', e); - return null; } - - const doc = new DOMParser().parseFromString(kmlText, 'application/xml'); - if (doc.getElementsByTagName('parsererror').length) return null; + if (!kmlTexts.length) return null; const pois: GeoPoint[] = []; const structures: RouteStructure[] = []; + const stations: GeoPoint[] = []; let nStruct = 0; const handlePlacemark = (pm: Element, folder: string): void => { const name = childLocal(pm, 'name')?.textContent?.trim() ?? ''; const desc = childLocal(pm, 'description')?.textContent ?? ''; const props = parseKmlDescProps(desc); + // ExtendedData/SchemaData/SimpleData 속성 (CAD·GIS 내보내기) — HTML 표 props 와 통합. + for (const sd of Array.from(pm.getElementsByTagName('SimpleData'))) { + const k = sd.getAttribute('name') ?? ''; + const v = sd.textContent?.trim() ?? ''; + if (k) props.push({ k, v }); + } const pget = (k: string): string | undefined => props.find((p) => p.k === k)?.v; // 좌표: lon,lat[,alt] 우선, 없으면 속성 lat/lon. @@ -347,6 +485,32 @@ export async function parseKmz( } if (isNaN(lat) || isNaN(lon) || lat < 33 || lat > 39 || lon < 124 || lon > 132) return; + // 새 포맷: 구글어스 CSV 변환 지장물 KML — Schema(타입/타입상세/이름/연장/텍스트박스_색상/높이), + // 폴더 구분 없이 Placemark 직속. '타입' 키 존재로 감지한다(회덕 desc표/STA 스키마엔 없음). + // 좌표는 SimpleData X/Y(저정밀)가 아니라 Point coordinates(고정밀)를 쓴다(위에서 이미 추출). + if (pget('타입') !== undefined) { + const title = pget('이름') || name; + if (!title) return; + const category = pget('타입') || '지장물'; + const lenM = parseFloat(pget('연장') ?? ''); + // 라벨 색: 텍스트박스_색상(한국어명) 우선, 없으면 인라인 IconStyle 색. + const labelColor = koreanColorToCss(pget('텍스트박스_색상')) ?? inlineIconColor(pm); + // z: '높이'는 표고가 아니라 구조물 높이라 사용하지 않음(팝업 props 로만 노출). + // 지면표고 미상(0) → 오버레이 기본(드론고도−이격) 모드 및 중심선 z 폴백으로 표시. + pois.push({ + title, category, lat, lon, z: 0, type: 'poi', + ...(labelColor ? { labelColor } : {}), + props, + }); + // 교량/터널은 스테이션바·노선패널 마크용 구조물로도 등록. + if (category.includes('교량')) { + structures.push({ id: `교량-${nStruct++}`, type: 'bridge', category: '교량', name: title, lat, lon, ...(isFinite(lenM) ? { lengthM: lenM } : {}), props }); + } else if (category.includes('터널')) { + structures.push({ id: `터널-${nStruct++}`, type: 'tunnel', category: '터널', name: title, lat, lon, ...(isFinite(lenM) ? { lengthM: lenM } : {}), props }); + } + return; + } + const lenNum = parseFloat(pget('연장(m)') ?? ''); if (folder.includes('교량')) { structures.push({ id: `교량-${nStruct++}`, type: 'bridge', category: '교량', name: pget('구분') || name, lat, lon, ...(isNaN(lenNum) ? {} : { lengthM: lenNum }), ...(pget('시설종별') ? { grade: pget('시설종별') } : {}), props }); @@ -358,6 +522,21 @@ export async function parseKmz( const title = pget('출입문번호') || name; if (title) pois.push({ title, category: '출입문', lat, lon, z: parseFloat(pget('z') ?? pget('절대고도') ?? '0') || 0, type: 'poi', props }); } else { + // 도로 체이니지 측점 (예: "0+100" 또는 STA 속성) → type='station' 으로 수집. + // km 속성(단위 km) 우선, 없으면 이름의 "k+m" 파싱. 제목은 철도식 NkNNN 으로 변환. + const staName = pget('STA') || name; + const staMatch = staName.match(/^(\d+)\+(\d+)$/); + if (staMatch || pget('STA') !== undefined) { + const kmProp = parseFloat(pget('km') ?? ''); + const meters = isFinite(kmProp) + ? Math.round(kmProp * 1000) + : staMatch ? parseInt(staMatch[1], 10) * 1000 + parseInt(staMatch[2], 10) : NaN; + if (!isFinite(meters)) return; + const title = `${Math.floor(meters / 1000)}k${String(meters % 1000).padStart(3, '0')}`; + // z 미상(KML 고도 0) — 소비측(loadFolderGeoData)에서 드론 고도 기반으로 보정. + stations.push({ title, category: '측점', lat, lon, z: 0, type: 'station', props }); + return; + } // 02)지장물 등 = 지오코딩 POI. 모든 POI 는 영상에 표출(철도역 포함). if (!name) return; const src = pget('source'); @@ -384,9 +563,52 @@ export async function parseKmz( } } }; - if (doc.documentElement) walk(doc.documentElement, ''); + for (const text of kmlTexts) { + const doc = new DOMParser().parseFromString(text, 'application/xml'); + if (doc.getElementsByTagName('parsererror').length) { + console.warn('[KML] XML 파싱 실패 — 해당 파일 건너뜀'); + continue; + } + if (doc.documentElement) walk(doc.documentElement, ''); + } - return { pois, structures }; + return { pois, structures, stations }; +} + +// ── 카메라 파라미터 파일 (.camera.json) ───────────────────────── + +/** + * 폴더에서 카메라 파라미터 파일을 읽는다. 서버 '카메라값 저장'(PUT /api/camera/:videoId)이 + * 영상 옆에 만든 `<영상 base>.camera.json`(또는 `<영상 base>.json`) 형식: + * { "camera": { focalLen, sensorW, sensorH, yawOffset, pitch, roll, offX, offY, offZ, geoidOffset, cx0, cy0 }, ... } + * 폴더 내 어떤 영상의 base 와도 매칭 허용(분할본 공용). 숫자 필드만 통과시킨다. + */ +export async function parseCameraJson( + files: File[], + videoFiles: File[], +): Promise | null> { + const bases = videoFiles.map((v) => baseNameOf(relPath(v)).replace(VIDEO_EXT, '').toLowerCase()); + const file = files.find((f) => { + if (isInBuilding(f)) return false; + const name = baseNameOf(relPath(f)).toLowerCase(); + const m = name.match(/^(.+?)(\.camera)?\.json$/); + if (!m) return false; + if (/\.route\.json$|_poi_overrides\.json$/.test(name)) return false; + return bases.includes(m[1]); + }); + if (!file) return null; + try { + const obj = JSON.parse(await file.text()); + const cam = obj && typeof obj === 'object' ? (obj.camera ?? obj) : null; + if (!cam || typeof cam !== 'object') return null; + const out: Record = {}; + for (const [k, v] of Object.entries(cam)) { + if (typeof v === 'number' && isFinite(v)) out[k] = v; + } + return Object.keys(out).length ? out : null; + } catch { + return null; + } } // ── route.json 보정 ─────────────────────────────────────────────────── @@ -409,7 +631,9 @@ export async function parseRouteMeta( const file = (wantBase && rootJson.find((f) => baseNameOf(relPath(f)).toLowerCase() === wantBase)) || - rootJson.find((f) => baseNameOf(relPath(f)).toLowerCase() === 'route.json'); + rootJson.find((f) => baseNameOf(relPath(f)).toLowerCase() === 'route.json') || + // 임의 접두 `<이름>.route.json` 허용 (예: 제주 중산간도로 경로 1-1.route.json) + rootJson.find((f) => /\.route\.json$/i.test(baseNameOf(relPath(f)))); if (!file) return null; try { @@ -533,33 +757,80 @@ export async function loadFolderGeoData( ): Promise { const files = Array.from(input); - const videoFile = findVideoFile(files); + const videoFiles = findVideoFiles(files); + const videoFile = videoFiles[0] ?? null; const baseName = deriveBaseName(videoFile); - const [frames, stationResult, routeMeta, poiOverrides, kmz] = await Promise.all([ - parseDroneFrames(files, baseName), + // 각 영상 길이를 메타데이터로 측정 — DJI 시간 기반 로그의 세그먼트 정렬(누적 오프셋)과 + // ended 연속재생(geoStore.loadSegment)에 쓴다. 분할본이 아니면 1개만 측정. + const videoDurations: (number | null)[] = []; + for (const vf of videoFiles) videoDurations.push(await probeVideoDuration(vf)); + + // 첫 세그먼트 창 — 로그는 전체 녹화(분할본 합)를 커버하므로 첫 영상 구간만 잘라 쓴다. + const segmentWindow: SegmentWindow | null = videoFile + ? { offsetSec: 0, durationSec: videoDurations[0] } + : null; + if (videoFiles.length > 1) { + console.log( + `[geo] 분할 영상 ${videoFiles.length}개 감지 (이름 오름차순 연속재생): ` + + videoFiles.map((vf, i) => `${baseNameOf(relPath(vf))}(${videoDurations[i]?.toFixed(1) ?? '?'}s)`).join(' → '), + ); + } + + const [frames, stationResult, routeMeta, poiOverrides, kmz, cameraInfo, cameraJson] = await Promise.all([ + parseDroneFrames(files, baseName, segmentWindow), parseStations(files), parseRouteMeta(files, baseName), parsePoiOverrides(files, baseName), parseKmz(files), + videoFile ? detectCameraInfo(videoFile) : Promise.resolve(null), + parseCameraJson(files, videoFiles), ]); + if (cameraJson) { + console.log('[camera] .camera.json 발견 — 저장된 카메라 파라미터 적용:', cameraJson); + } + + // 감지된 초점거리를 드론 프레임 focalLen 에도 반영 (검색/투영 FOV 판정 일관성). + if (cameraInfo?.focalLen35) { + for (const f of frames) f.focalLen = cameraInfo.focalLen35; + console.log( + `[camera] ${cameraInfo.model} 감지 — focal ${cameraInfo.focalLen35}mm` + + (cameraInfo.width ? `, ${cameraInfo.width}x${cameraInfo.height}@${cameraInfo.fps ?? '?'}fps` : ''), + ); + } else if (cameraInfo) { + console.log(`[camera] ${cameraInfo.model} 감지 (초점거리 정보 없음)`); + } + // POI/구조물 출처: KMZ(원본)가 유일한 소스다(지장물·출입문·교량/터널/구교·철도역). - // KMZ 정책상 필수 — 없거나 비어 있으면 "데이터 누락"으로 보고 경고만 남기고 빈 상태로 둔다 - // (CSV 폴백 없음. building/ POI·구조물 CSV는 사용하지 않음). 측점·중심선은 항상 CSV(측점)에서 온다. - const kmzMissing = !kmz || (kmz.pois.length === 0 && kmz.structures.length === 0); + // KMZ 파일 자체가 없을 때만 "데이터 누락"(kmzMissing=alert 대상)으로 본다. + // 파일은 있는데 POI/구조물이 0건인 경우(예: 제주 — 노선 KML만 있고 POI 없음)는 + // 정상 데이터셋으로 취급하고 콘솔 경고만 남긴다. + const kmzMissing = !kmz; if (kmzMissing) { console.warn( - '[KMZ] 누락/비어있음 — POI·구조물이 표시되지 않습니다. ' + + '[KMZ] 누락 — POI·구조물이 표시되지 않습니다. ' + 'KMZ(원본)를 포함해 데이터를 재구축·전달하세요. (측점·드론 정보는 정상 로드)', ); + } else if (kmz!.pois.length === 0 && kmz!.structures.length === 0 && kmz!.stations.length === 0) { + console.warn('[KMZ] POI·구조물·측점 0건 — 이 데이터셋엔 표출할 지물이 없습니다(드론 궤적은 무관).'); } else { - console.log(`[KMZ] POI ${kmz!.pois.length} · 구조물 ${kmz!.structures.length} 로드(원본)`); + console.log(`[KMZ] POI ${kmz!.pois.length} · 구조물 ${kmz!.structures.length} · 측점 ${kmz!.stations.length} 로드(원본)`); } const pois = kmz?.pois ?? []; const baseStructures = kmz?.structures ?? []; - const stations = stationResult.stations.sort( + // 측점 소스: CSV(01)측점 — 실측 표고 보유) 우선, 없으면 KML 체이니지 측점(STA) 사용. + // KML 측점은 표고 미상 → z=0 그대로 둔다(미상 표식). 드론고도 기반 근사(−24m)는 + // 실제 지면과 수십 m 어긋나 라벨이 공중에 떠서 시차 불일치(카메라 전진 시 라벨 밀림)를 + // 유발했음 → 오버레이가 z=0 라벨을 '경로 표고' 평면에 앵커하는 방식으로 대체. + let rawStations = stationResult.stations; + if (!rawStations.length && kmz?.stations.length) { + rawStations = kmz.stations; + console.log(`[KMZ] 체이니지 측점 ${rawStations.length}개 로드 (표고 미상 → 오버레이 '경로 표고' 평면 앵커)`); + } + + const stations = rawStations.sort( (a, b) => stationOrder(a.title) - stationOrder(b.title), ); const directionChanges = stationResult.directionChanges; @@ -591,5 +862,10 @@ export async function loadFolderGeoData( directionChanges, poiOverrides, kmzMissing, + videoFiles, + videoDurations, + folderFiles: files, + cameraInfo, + cameraJson, }; } diff --git a/docs/history/2026-07-09_1510_54000포트-외부접근-셋팅.md b/docs/history/2026-07-09_1510_54000포트-외부접근-셋팅.md new file mode 100644 index 0000000..405fecc --- /dev/null +++ b/docs/history/2026-07-09_1510_54000포트-외부접근-셋팅.md @@ -0,0 +1,36 @@ +# 2026-07-09 54000 포트 외부 접근 셋팅 + +**소요 시간**: 15분 +**Context 사용량**: input 95k / output 10k tokens + +## 작업 내용 + +GhiVideo_v4를 54000 포트로 서비스하고 다른 컴퓨터에서 접근 가능하도록 셋팅. + +### 완료 항목 + +1. **의존성 설치 + 빌드** + - 시스템 기본 node(v12)로 `npm install` 실패 (better-sqlite3 gyp 오류) → nvm Node 20 (v20.20.2)으로 재설치 성공 + - `npm run build` (shared → server → client) 완료 + +2. **PM2 앱 이름 충돌 해결** + - 원본 GhiVideo와 v4의 ecosystem.config.js가 둘 다 `name: "ghiVideo"` → `pm2 start` 시 원본 앱이 v4 env(PORT=54000)로 재시작되는 사고 발생 + - v4의 ecosystem.config.js 이름을 `ghiVideo-v4`로 변경 + - 꼬인 엔트리 삭제 후 양쪽 재기동: 원본 `ghiVideo`(55000) 복구 + `ghiVideo-v4`(54000) 신규 기동 + - 둘 다 `/api/videos` 200 응답 확인, `pm2 save` 완료 + +3. **Windows 포트포워딩 (미완 — 사용자 실행 필요)** + - WSL2 NAT 모드 (WSL IP: 172.21.50.250). 기존 포트들(55000, 5173, 8080 등)은 netsh portproxy + 방화벽 규칙으로 개방된 패턴 확인 + - 54000용 스크립트를 `C:\Users\Public\ghivideo-v4-54000-setup.ps1`에 작성 + - 관리자 권한 자동 실행은 권한 정책상 차단 → **사용자가 관리자 PowerShell에서 직접 실행 필요** + +### 재부팅 내구성 검토 (같은 세션 후속 작업) + +- Windows 루프백 → WSL 포워딩 확인: `127.0.0.1:54000` 200 응답 → portproxy `connectaddress=127.0.0.1` 방식이면 **WSL IP가 바뀌어도 규칙이 안 깨짐**. 설정 스크립트를 이 방식으로 갱신함 +- 기존 규칙들(55000, 5173 등)은 WSL IP(172.21.50.250) 하드코딩 — 재부팅으로 IP 바뀌면 깨질 수 있음. 동일하게 127.0.0.1로 바꾸는 것 권장 +- **pm2 자동 시작 없음**: pm2 systemd 유닛 없음, 시작프로그램의 AI-Video-Brief.vbs는 ai_video_brief 전용. 재부팅 후 `pm2 resurrect` 수동 실행 필요. 시작프로그램 vbs 자동 생성은 보안 정책 차단 → 사용자 직접 생성 필요 + +### 다음 단계 참고 + +- 관리자 PowerShell에서 1회 실행: `powershell -ExecutionPolicy Bypass -File C:\Users\Public\ghivideo-v4-54000-setup.ps1` (portproxy 127.0.0.1 방식 + 방화벽 규칙) +- v4 서버 코드 기본 포트는 3030(`server/src/config.ts`)이나, PM2 env로 54000 주입됨 diff --git a/docs/history/2026-07-09_1525_제주데이터-플레이어-연결.md b/docs/history/2026-07-09_1525_제주데이터-플레이어-연결.md new file mode 100644 index 0000000..e628353 --- /dev/null +++ b/docs/history/2026-07-09_1525_제주데이터-플레이어-연결.md @@ -0,0 +1,37 @@ +# 2026-07-09 제주 중산간 도로 데이터 플레이어 연결 + +**소요 시간**: 10분 +**Context 사용량**: input 130k / output 6k tokens + +## 작업 내용 + +`D:\_project\...\제주 중산간 도로 노선 동영상 비행 로그\경로 1-1` 데이터를 GhiVideo_v4 플레이어에 연결. + +### 데이터 구성 + +- DJI 드론 영상 4개 (DJI_20260629121835_0002~0005.MP4, 총 12GB) + - H.264, 4K(3840x2160), 59.94fps, 개당 약 197초(0005는 더 짧음) +- 비행로그 CSV: `제주 중산간도로 동영상 경로 1-1.csv` — **시간 기반**(100ms 간격), 컬럼: `time(millisecond), latitude, longitude, X(E), Y(N), altitude_above_seaLevel, compass_heading, pitch, roll, gimbal_heading, gimbal_pitch, gimbal_roll` +- `제주_중산간도로.kml` — 도로 노선 + +### 완료 항목 + +- ecosystem.config.js의 `VIDEOS_DIR`/`GEO_DATA_DIR`를 `/mnt/d/...` 제주 경로로 변경, pm2 재기동 + save +- `/api/videos` 4개 영상 인식, `/api/meta` 정상 (H.264 확인 → HLS 변환 시 `-c copy` 가능) +- Range 스트리밍 검증: 앞부분 55MB/s, 2GB 지점 seek 34MB/s (4K60 비트레이트 약 19MB/s 대비 충분) + +### 미완료 / 알려진 제약 (geo 비행로그 연동) + +현재 geoMatch 서비스는 회덕 데이터셋 전용이라 제주 CSV를 읽지 못함: + +1. 드론 CSV 파일명 `'회덕'` 하드코딩 필터 (`geoMatch.ts` loadFrames) +2. 컬럼 불일치: 기대 `frame_cnt/latitude/longitude/altitude/yaw/pitch/roll/focal_len` vs 제주 `time(millisecond)/compass_heading/gimbal_heading/...` → 시간→프레임 변환 어댑터 필요 +3. POI/측점(`building/` 폴더) 데이터가 제주 데이터셋에 없음 → POI 검색 기능은 데이터 부재로 불가 +4. 영상이 4개로 분할 → 로그 시간을 (영상 파일, 영상 내 시점)으로 매핑하는 오프셋 계산 필요 +5. KML은 중심선(center.csv 대체) 소스로 활용 가능 + +### 다음 단계 참고 + +- 업로드 기능(tus) 사용 시 D: 폴더(VIDEOS_DIR)에 저장됨 — 의도 확인 필요 +- HLS/프레임/썸네일 산출물은 기존대로 프로젝트 `storage/`에 생성됨 +- 제주 geo 어댑터 작업 시 요구 기능(경로 시각화, 프레임별 위치 표시 등) 범위 확정 필요 diff --git a/docs/history/2026-07-09_1558_제주-드론궤적-어댑터.md b/docs/history/2026-07-09_1558_제주-드론궤적-어댑터.md new file mode 100644 index 0000000..8bbaa0a --- /dev/null +++ b/docs/history/2026-07-09_1558_제주-드론궤적-어댑터.md @@ -0,0 +1,49 @@ +# 2026-07-09 제주 DJI 비행로그 드론궤적 어댑터 + +**소요 시간**: 30분 +**Context 사용량**: input 175k / output 12k tokens + +## 문제 + +제주 데이터 폴더 선택 시 드론 궤적 오버레이가 표시되지 않음. + +## 원인 + +클라이언트 지리정보 파이프라인(폴더 선택 → `geoData.ts` 파싱 → geoStore → StationOverlay 투영)이 회덕 v2.0 프레임 기반 CSV(`frame_cnt,latitude,…,yaw,pitch,roll,focal_len`) 전용이었음. 제주 CSV는 DJI 비행로그 시간 기반(`time(millisecond),latitude,…,compass_heading,gimbal_heading,…`)이라: + +1. 위치 폴백 인덱스로 컬럼이 밀림 — frame←ms값(89500~), altitude←X(E) 평면좌표, yaw←Y(N), focalLen←pitch 등 전부 오염 +2. `effectiveFps`(=마지막 frame ÷ 영상길이) 자기보정이 깨져 시간↔프레임 변환 실패 +3. 분할 영상 4개(_0002~_0005) 중 첫 영상 길이(197.1s)에 로그 전체(672.6s)가 압축 매핑됨 +4. 경로표고 슬라이더 상한(중심선 z 기반, 폴백 60m)이 제주 지형(드론 148m ASL)에 못 미침 + +## 수정 내용 + +### client/src/utils/geoData.ts + +- `parseDroneFrames`: `time(millisecond)` 헤더 감지 시 DJI 시간 기반 브랜치로 분기 + - 컬럼: lat/lon/`altitude_above_seaLevel(meter)`, 자세는 gimbal_* 우선(기체 compass/pitch/roll 폴백), focalLen=24mm 기본 + - `frame = ((time−t0)/1000 − offsetSec) × 59.94(가상fps)` — effectiveFps 자기보정과 정합 + - `SegmentWindow`(offsetSec/durationSec)로 재생 영상 구간 밖 행 클리핑 +- `findVideoFiles`: 영상 이름순 정렬(DJI 분할본 _0002 먼저 확정) +- `probeVideoDuration`: objectURL + `preload=metadata`로 영상 길이만 측정 +- `loadFolderGeoData`: 첫 영상 길이 측정 → 세그먼트 창 전달, 분할본 감지 시 콘솔 경고 +- `parseRouteMeta`: 임의 접두 `*.route.json` 인식 (제주 route.json 파일명 대응) +- `parseKmz`: 체이니지 표점(`0+100` 형식) Point는 POI 제외 (z=0이라 투영 깨짐 방지) +- `kmzMissing`: KML/KMZ 파일 자체가 없을 때만 alert (파일은 있으나 POI 0건은 콘솔 경고만) + +### client/src/components/overlay/StationOverlay.tsx + +- `pathZMax`: 중심선 z에 더해 드론 고도(ASL) 최대값도 상한 근거에 포함 → 제주에서 슬라이더 159m까지 + +## 검증 + +- `npm run build -w client` (tsc + vite) 통과, 54000 포트가 새 번들(index-Cp4zEgX_.js) 서빙 확인 +- 실제 CSV로 파싱 수학 시뮬레이션: 6649행 → 세그먼트 내 1947행, frame 0~11814, effectiveFps 59.9248→59.94 스냅, 영상 t=100s ↔ 로그 t=100.00s 왕복 정합, 자세값 정상(yaw 65.1°, pitch −15°, alt 147.9m) + +## 남은 제약 / 다음 단계 참고 + +- **분할 영상 2~4번째(_0003~_0005) 미지원** — 세그먼트 선택 UI + offsetSec(누적 길이) 연결 필요. 로그 폭(672.6s) ≈ 영상 총길이(671.7s) 검증됨, 오프셋: 0 / 197.147 / 394.211 / 591.475s +- 드론 고도 거의 일정(147.8~148.6m ASL) — 도로 표고는 그 아래. 경로표고 슬라이더로 90~140m 부근 튜닝 필요 +- KML LineString(도로 중심선)은 고도가 전부 0이라 중심선(선형 오버레이) 소스로 미사용 +- 측점/POI 데이터가 제주 데이터셋에 없음 — KML 체이니지 표점(0+000~) 활용하려면 표고 부여 필요 +- route.json의 durationSec(568)이 실제 영상 총길이(671.7s)와 불일치 — 사용자 확인 필요 diff --git a/docs/history/2026-07-09_1637_분할영상-연속재생.md b/docs/history/2026-07-09_1637_분할영상-연속재생.md new file mode 100644 index 0000000..ef53dc5 --- /dev/null +++ b/docs/history/2026-07-09_1637_분할영상-연속재생.md @@ -0,0 +1,48 @@ +# 2026-07-09 분할 영상 연속재생 (이름 오름차순 자동 전환) + +**소요 시간**: 25분 +**Context 사용량**: input 210k / output 8k tokens + +## 작업 내용 + +폴더 내 분할 영상(DJI _0002~_0005)이 끝나면 이름 오름차순으로 다음 영상을 자동 재생. +전환 시 드론 로그(시간 기반)를 해당 세그먼트의 누적 오프셋으로 재정렬해 궤적 정합 유지. + +## 수정 파일 + +### client/src/types/geo.ts +- `FolderGeoData`에 `videoFiles`(이름 오름차순), `videoDurations`, `folderFiles` 추가 + +### client/src/utils/geoData.ts +- `loadFolderGeoData`: 폴더 내 **모든** 영상 길이를 메타데이터로 측정(기존엔 첫 영상만), + 분할본 감지 시 재생 순서 콘솔 로그. 측정값·원본 파일 목록을 반환값에 포함 + +### client/src/store/geoStore.ts +- 상태 추가: `videoFiles` / `videoDurations` / `videoIndex` / `folderFiles` +- **`loadSegment(index)` 액션 신설**: 누적 오프셋(앞 세그먼트 길이 합) 계산 → + `parseDroneFrames`를 세그먼트 창으로 재실행해 frames 교체, videoIndex 갱신, 재생할 File 반환 + - 길이 측정 실패 세그먼트가 있으면 궤적만 비우고(frames=[]) 재생은 계속 + - `baseName`은 폴더(첫 영상) 기준 유지 — 카메라 보정 localStorage 연속성 + - `origin`도 폴더 로드 시점 값 유지 — ENU 기준점 일관성(전환 시 투영 점프 방지) + +### client/src/components/player/VideoPlayer.tsx +- Video.js `ended` 이벤트 핸들러 추가: 폴더 기반 로컬 재생 + 분할본 2개 이상일 때만 + `loadSegment(next)` → `loadLocalFile(file)` → `player.play()` (ended 직후는 paused라 직접 재생) +- 서버 스트림/단일 파일 재생은 대상 아님 (source.kind !== 'local' 가드) + +## 검증 + +- `npm run build -w client` (tsc + vite) 통과, 54000 포트 새 번들(index-BA4g81-1.js) 서빙 확인 +- 실제 CSV로 4개 세그먼트 오프셋 시뮬레이션: + - 세그먼트 1: offset 0s, 1947행, effFps 59.925 → 59.94 스냅 ✓ + - 세그먼트 2: offset 197.1s, 1963행, effFps 59.935 ✓ + - 세그먼트 3: offset 394.2s, 1936행, effFps 59.920 ✓ + - 세그먼트 4: offset 591.5s, 793행, effFps 59.877 ✓ (±10% 스냅 범위 내) + - 행 합계 6639/6649 — 경계 인접 소수 행만 제외, 정상 + +## 다음 단계 참고 + +- 세그먼트 수동 선택 UI(목록에서 특정 세그먼트로 점프)는 미구현 — `loadSegment`가 이미 있어 + UI만 붙이면 됨 (`useGeoStore.getState().loadSegment(i)` + `loadLocalFile`) +- 마지막 세그먼트(_0005) 종료 시 정지 (반복 재생 없음 — 요구사항 밖) +- 전환 시 1~2초 로딩 공백 가능(objectURL 교체) — 프리로드 최적화는 필요 시 diff --git a/docs/history/2026-07-09_1648_스테이션바-이동거리-진행도.md b/docs/history/2026-07-09_1648_스테이션바-이동거리-진행도.md new file mode 100644 index 0000000..66de9ce --- /dev/null +++ b/docs/history/2026-07-09_1648_스테이션바-이동거리-진행도.md @@ -0,0 +1,44 @@ +# 2026-07-09 스테이션바 진행도 — 드론 정지 시 커서 정지 (이동거리축 폴백) + +**소요 시간**: 10분 +**Context 사용량**: input 240k / output 5k tokens + +## 문제 + +드론이 이동을 멈췄는데(호버링) 하단 스테이션바 커서는 계속 증가. + +## 원인 + +StationBar의 진행도 축(chainRef: 시간→누적 이동거리 비율)은 **측점 폴리라인 체이니지 기반**으로만 +구성됨 (`frames + stations + stationLine` 필요). 제주 데이터셋은 측점이 없어 `ready=false` +→ `cumFracAtTime`이 **시간 선형 폴백**(`t / duration`) 사용 → 드론이 멈춰도 커서는 시간 비례로 전진. + +실데이터 확인: 세그먼트 2에서 드론이 t=8~128s(120초), t=178~198s 두 번 호버링 — +사용자가 관측한 구간과 일치. + +## 수정 내용 — client/src/stationbar/StationBar.tsx + +- precompute useEffect에 **측점 없음 폴백** 추가: 드론 프레임만 있으면 GPS(위경도)를 + ±8프레임 이동평균으로 평활 후 프레임 간 수평거리 누적 → 정규화(frac)해 chainRef 구성. + 측점 기반 기능(배지/구간색/구조물 마크)은 기존대로 ready=false 비활성 유지 +- `cumFracAtTime` / `timeAtFrac` 가드에서 `!ready` 제거 — chainRef 존재 여부만 확인 + (폴백 chainRef도 사용). 바 클릭 seek도 이동거리축 역변환으로 동작 +- depChain/arrChain은 방향색 전용이라 폴백에선 더미(0/1) + +## 검증 (실데이터 시뮬레이션, 세그먼트 2) + +| 시점 | 기존(시간선형) | 수정(이동거리) | +|------|--------------|--------------| +| t=8s (호버 시작) | 4.1% | 8.0% | +| t=68s (호버 중) | 34.5% | 8.4% | +| t=128s (호버 끝) | 65.0% | 9.0% | + +호버 120초 동안 커서가 사실상 정지(+1.0%p). 세그먼트 2 총 이동거리 460.2m. +tsc+vite 빌드 통과, 54000 포트 새 번들(index-quVWnwfd.js) 서빙 확인. + +## 다음 단계 참고 + +- 정지 구간에서 커서가 멈추므로, 그 동안 시간은 흐르는데 커서가 안 움직이는 게 정상 동작 + (좌측 시간 배지는 계속 증가 — 서로 다른 축) +- 드론이 전 구간 정지(총 이동거리 0)인 극단 케이스는 frac 전부 0 → 커서 시작점 고정 +- 세그먼트 전환(loadSegment) 시 frames 교체 → chainRef 자동 재구성됨 diff --git a/docs/history/2026-07-09_1702_KML-시설물-측점-임포트.md b/docs/history/2026-07-09_1702_KML-시설물-측점-임포트.md new file mode 100644 index 0000000..22d5d53 --- /dev/null +++ b/docs/history/2026-07-09_1702_KML-시설물-측점-임포트.md @@ -0,0 +1,53 @@ +# 2026-07-09 KML 시설물/측점 정보 임포트 (ExtendedData 지원) + +**소요 시간**: 12분 +**Context 사용량**: input 265k / output 6k tokens + +## 작업 내용 + +KML 파일에서 시설물 정보를 가져올 수 있도록 parseKmz 확장. 제주 KML의 STA(체이니지 측점) +71개가 측점으로 로드되어 측점 파이프라인 전체(스테이션바·노선패널·검색·오버레이) 활성화. + +## 배경 — 제주 KML 구조 + +``` +Folder 선형중심선: LineString (도로 중심선, CAD 내보내기) +Folder STA: Point 71개 (0+000 ~ 6+998, 100m 간격) + └ ExtendedData/SchemaData/SimpleData: STA(이름), lat, lon, 5186_x/y, km(float) +``` + +기존 parseKmz 는 description CDATA HTML 표(회덕 지오코딩 산출물)만 속성으로 읽었고, +체이니지 Point 는 직전 작업에서 POI 오염 방지를 위해 스킵 처리했었음. + +## 수정 내용 — client/src/utils/geoData.ts + +1. **SimpleData 속성 수집**: `ExtendedData/SchemaData/SimpleData` 를 description HTML 표와 + 동일하게 props 로 통합 — CAD·GIS 내보내기 KML의 속성이 기존 폴더 분기(교량/터널/구교/출입문)에서도 + 그대로 동작 (향후 시설물 폴더가 있는 KML 대응) +2. **체이니지 측점 → station**: 이름 `N+NNN` 형식 또는 STA 속성 보유 Point 를 + `type='station'` 으로 수집. km 속성(단위 km) 우선, 없으면 이름 파싱. + 제목을 철도식 `NkNNN`("0k100")으로 변환 → stationOrder/stationKm 파이프라인 그대로 호환 +3. **parseKmz 반환에 `stations` 추가**, 로드 로그에 측점 수 표기 +4. **loadFolderGeoData**: CSV 측점(01)측점 — 실측 표고) 우선, 없으면 KML 측점 사용. + KML 측점은 표고 미상(z=0) → **최근접 드론 프레임 고도 − 24m 근사** + (도로 추종 저고도 비행 가정, 오버레이 기본 모드가 드론고도 기준이라 오차 영향 작음) + +## 활성화되는 기능 (제주 데이터) + +- 하단 스테이션바: 측점 체이니지 기반 이동거리축(ready=true), 최근접 측점 배지, 바 클릭 seek +- 좌측 노선 패널(RoutePanel): 측점 71개 (0k000~6k998), 시점/종점명은 route.json(A/B) +- 영상 오버레이: 측점 라벨 투영 (드론고도 − 이격 모드 기본) +- GeoSearch: 측점명 검색/자동완성 + +## 검증 + +- tsc + vite 빌드 통과, 54000 포트 새 번들(index-BvbrGp3O.js) 서빙 +- KML STA 데이터 레벨 검증: 71개 전부 변환, 제목 0k000~6k998, stationKm 파싱 유효, 단조증가 + +## 다음 단계 참고 + +- KML 노선은 0~7km인데 경로 1-1 비행은 그중 약 1.9km 구간만 커버 — RoutePanel 마커가 + 일부 구간에서만 움직이는 것이 정상 +- 교량/터널 등 실제 시설물 Placemark 가 있는 KML 이 오면 기존 폴더명 분기(교량/터널/구교/출입문)로 + 자동 임포트됨 — SimpleData 속성(연장(m), 구분 등)도 이제 인식 +- 선형중심선 LineString 은 미사용 (측점 폴리라인으로 중심선 생성 — 기존 로직) diff --git a/docs/history/2026-07-09_1715_지장물KML-신포맷-파싱.md b/docs/history/2026-07-09_1715_지장물KML-신포맷-파싱.md new file mode 100644 index 0000000..7a32835 --- /dev/null +++ b/docs/history/2026-07-09_1715_지장물KML-신포맷-파싱.md @@ -0,0 +1,51 @@ +# 2026-07-09 지장물 KML 신포맷 파싱 (다중 KML + 라벨 색상) + +**소요 시간**: 13분 +**Context 사용량**: input 300k / output 8k tokens + +## 문제 + +새로 받은 `제주_중산간도로_지장물.kml`(구글어스 Pro에서 CSV 변환, 지장물 33개)의 +좌표/라벨 색상이 화면에 표시되지 않음. + +## 원인 + +1. **parseKmz 가 KML 을 1개만 읽음** — `files.find(...)` 라 폴더에 KML 이 2개(측점 KML + 지장물 KML)면 + 첫 파일만 파싱되고 지장물 KML 은 통째로 무시됨 (핵심 원인) +2. 신포맷은 폴더(교량/터널 등) 구분이 없고 Placemark 직속 + Schema + (`타입/타입상세/텍스트박스_색상/이름/연장/X/Y/높이`) — 기존 desc HTML 표 기반 분기와 불일치 +3. 라벨 색상 개념이 렌더러에 없음 — 캔버스 라벨이 고정색(#64c8ff) 단일 + +## 수정 내용 + +### client/src/utils/geoData.ts +- **parseKmz 다중 파일 병합**: 폴더 내 모든 .kml/.kmz 를 순회 파싱해 pois/structures/stations 병합 + (파일 단위 실패는 경고 후 건너뜀) +- **신포맷(지장물 스키마) 브랜치**: `타입` SimpleData 존재로 감지 — + - title=`이름`(폴백 name), category=`타입`(교차로/도로/교량/강.하천/마을/산/지장물/지장물L) + - 좌표는 SimpleData X/Y(저정밀)가 아닌 **Point coordinates(고정밀)** 사용 + - `높이`는 표고가 아닌 구조물 높이 → z=0 유지(팝업 props 로만 노출) + - 타입 교량/터널은 구조물(스테이션바·노선패널 마크)로도 중복 등록 (연장→lengthM) +- **라벨 색**: `텍스트박스_색상` 한국어명(검정/남색/빨강/연두/주황/초록/파랑 등 13색) → CSS 매핑, + 없으면 인라인 IconStyle 색(KML aabbggrr → #rrggbb) 폴백 → `GeoPoint.labelColor` + +### client/src/types/geo.ts +- `GeoPoint.labelColor?: string` 추가 + +### client/src/components/overlay/StationOverlay.tsx +- labelColor 를 poiCand → poiMarkers(라벨 캐시) → 캔버스 렌더로 전달 +- 십자 마커·라벨 텍스트를 지정 색으로 렌더, 어두운 색(검정·남색)은 외곽선을 밝게 반전(가독성) +- CATEGORY_EMOJI 확장: 교차로🚦 도로🛣️ 강.하천🌊 마을🏘️ 산⛰️ 지장물L🏢 + +## 검증 + +- tsc+vite 빌드 통과, 54000 포트 새 번들(index-CLmojEn3.js) 서빙 +- 지장물 KML 데이터 검증: 33개 전부 파싱, 색상 매핑 33/33, 교량 3건 구조물 중복 등록, + 좌표 불량 0 (Point coordinates 고정밀 사용) +- 기존 측점 KML(STA 71개)과 병합 로드 확인 (다중 KML) + +## 다음 단계 참고 + +- 신포맷 감지 키는 `타입` SimpleData — 회덕 desc표/STA 스키마와 충돌 없음 +- styleUrl(#pointStyleMap1) → StyleMap 참조 해석은 미구현 (인라인 Style 이 항상 존재해 불필요) +- 색상명 미정의 시(예: 새로운 색 이름) 인라인 IconStyle 색 폴백 → 그것도 없으면 기본 하늘색 diff --git a/docs/history/2026-07-09_1725_라벨-표고앵커-측점거리제한.md b/docs/history/2026-07-09_1725_라벨-표고앵커-측점거리제한.md new file mode 100644 index 0000000..0335b12 --- /dev/null +++ b/docs/history/2026-07-09_1725_라벨-표고앵커-측점거리제한.md @@ -0,0 +1,52 @@ +# 2026-07-09 오버레이 라벨 표고 앵커 + 측점 거리 제한 + +**소요 시간**: 10분 +**Context 사용량**: input 340k / output 6k tokens + +## 문제 (스크린샷 제보) + +1. 빨간 측점 라벨(0k000~6k998)이 수평선 부근에 전부 겹쳐서 깨진 글자처럼 보임 +2. POI 라벨이 실제 지물보다 높게 떠서, 카메라(드론)가 전진하면 라벨이 계속 뒤로 밀림(시차 불일치) + +## 원인 + +1. **측점 라벨엔 거리 제한이 없음** — POI는 maxPoiRange(기본 1000m) 필터가 있지만 측점은 없어서 + 7km 앞 측점까지 전부 FOV 안 수평선 근처에 투영·중첩됨 +2. **라벨 표고 근사가 틀림** — 직전 작업에서 측점 z를 "드론고도−24m ≈ 124m"로 근사했으나 + 실제 지면은 ≈60m(경로표고 기본값 58이 도로에 붙는 것으로 확인) → 라벨이 지면보다 + 약 60m 공중에 떠서 원근 시차가 안 맞아 전진 시 라벨이 뒤로 흐름. + 드론고도 모드(pz=드론고도−이격24)도 동일하게 124m 평면이라 같은 증상 + +## 수정 내용 + +### client/src/utils/geoData.ts +- KML 측점의 z 근사(드론고도−24) 제거 → z=0(표고 미상 표식) 유지 + +### client/src/components/overlay/StationOverlay.tsx +- **표고 미상(z≤0) 라벨을 '경로 표고'(dronePathZ) 평면에 앵커**: + - 측점 라벨 sz: 유효 z 없으면 PATH_Z(경로표고) 사용 + - POI gz 폴백 체인: 보정값 → 중심선 z(>0만) → POI z(>0만) → PATH_Z + - 경로표고 슬라이더 변경 시 라벨 캐시 재계산(500ms 디바운스, 기존 트리거에 dronePathZ 추가) + - → **궤적을 도로에 맞추는 슬라이더 하나로 측점/POI 라벨 높이가 함께 보정됨** +- **측점 라벨에도 maxPoiRange 거리 제한 적용** — 원거리 측점 수평선 뭉침 해소 +- **poiDroneHeight 기본값 true→false(지면고도 기준)**: + - 실측 z 데이터셋(회덕)은 지물에 정확히 붙음(원래 이 모드가 정확) + - 표고 미상 데이터셋(제주)은 경로표고 앵커로 일괄 보정 가능 + - 드론고도 모드는 UI 토글로 여전히 선택 가능 + +## 검증 + +- tsc+vite 빌드 통과, 54000 포트 새 번들(index-Nv1HLuS3.js) 서빙 +- 기하 검증(계산): 지면 60m/드론 148m 기준, 124m 라벨은 300m 전방 지물 대비 화면상 약 + 12% 위쪽에 투영(수평선 방향) → 전진 시 벌어짐. PATH_Z(58) 앵커 시 지물과 동일 평면 → 시차 일치 + +## 사용법 (사용자 안내) + +- 새로고침 후 폴더 재선택 → '경로 표고' 슬라이더로 파란 궤적을 도로 노면에 맞추면 + 측점·POI 라벨도 같은 평면으로 따라옴 (기본 58m가 대략 맞음) +- 측점 라벨 표시 거리는 'POI 표시범위'(maxPoiRange) 슬라이더로 조절 + +## 다음 단계 참고 + +- 지형 기복이 큰 구간은 단일 평면 앵커의 한계 있음 — 필요 시 구간별 z 또는 DEM 도입 검토 +- RoutePanel 가시범위(초록 박스) 계산은 여전히 pt.z(0) 사용 — 부정확할 수 있으나 부차적 diff --git a/docs/history/2026-07-09_1738_영상-재생목록-패널.md b/docs/history/2026-07-09_1738_영상-재생목록-패널.md new file mode 100644 index 0000000..9d771c3 --- /dev/null +++ b/docs/history/2026-07-09_1738_영상-재생목록-패널.md @@ -0,0 +1,31 @@ +# 2026-07-09 영상 재생목록 패널 (분할 영상 선택 재생) + +**소요 시간**: 10분 +**Context 사용량**: input 380k / output 5k tokens + +## 작업 내용 + +폴더 로드 시 폴더 내 영상 파일 목록(이름 오름차순)을 패널로 표시하고, +목록에서 원하는 영상을 클릭해 바로 선택 재생할 수 있게 함. + +## 수정 내용 — client/src/components/player/VideoPlayer.tsx + +- **재생목록 패널** (우하단, 재생바 위): + - 폴더 내 영상 파일명 + 길이(m:ss), 현재 재생 항목 ▶ + 주황 하이라이트 + - 항목 클릭 → 해당 세그먼트로 즉시 전환 재생 (geoStore.loadSegment 로 드론 로그도 + 해당 구간 누적 오프셋으로 재정렬 — ended 자동 전환과 동일 경로) + - 현재 재생 중인 항목 재클릭 → 처음(0초)부터 재생 + - 목록이 길면 스크롤 (max-h-48) + - 헤더에 "N/M" 진행 표시. ended 자동 전환 시에도 videoIndex 구독으로 하이라이트 자동 갱신 +- **'영상목록 N/M' 토글 버튼**: 하단 컨트롤 행(드론궤적 버튼 옆). 기본 표시(ON) +- `fmtDur` 헬퍼(초→"m:ss"), geoStore videoFiles/videoDurations/videoIndex 구독 추가 +- 로컬(폴더) 재생일 때만 표시 (source.kind === 'local') + +## 검증 + +- tsc+vite 빌드 통과, 54000 포트 새 번들(index-DLUIvK3Q.js) 서빙 + +## 다음 단계 참고 + +- 세그먼트 전환 시 약 1초 로딩 공백(objectURL 교체) — 필요 시 다음 세그먼트 프리로드 검토 +- 패널 위치는 우하단 고정 — 캡처 목록 등 다른 우측 UI 와 겹치면 위치 조정 필요 diff --git a/docs/history/2026-07-09_1754_카메라감지-라벨박스-영상콤보.md b/docs/history/2026-07-09_1754_카메라감지-라벨박스-영상콤보.md new file mode 100644 index 0000000..19b8891 --- /dev/null +++ b/docs/history/2026-07-09_1754_카메라감지-라벨박스-영상콤보.md @@ -0,0 +1,50 @@ +# 2026-07-09 드론 카메라 자동 감지 + 라벨 박스 스타일 + 영상목록 콤보 + +**소요 시간**: 16분 +**Context 사용량**: input 430k / output 12k tokens + +## 작업 내용 (요청 4건) + +### 1. 드론 카메라 정보 자동 감지 → 카메라 파라미터 적용 (POI 매핑 어긋남의 핵심 원인) + +DJI MP4의 `djmd` 데이터 트랙(프레임별 protobuf 텔레메트리)을 분석해 필드 구조 해독: + +| 필드 | 내용 | 제주 값 | +|------|------|--------| +| 1.1.2 | 카메라 모델 | **DJI ZenmuseP1** (풀프레임 측량 카메라) | +| 1.7 / 1.6 / 1.8 | 해상도 / fps | 3840×2160 / 59.94 | +| 6.4 / 6.5 / 6.6 | 위경도(라디안) / 타원체고(mm) | 33.40°/126.29°/174.4m | +| **8.6** | **초점거리 (0.1mm, 35mm 환산)** | **299 → 29.9mm** | + +- 기존 기본값 24mm vs 실제 29.9mm(DL 24mm 렌즈 × 4K 비디오 크롭 ≈1.25) → FOV 과대 → POI 어긋남 +- `client/src/utils/cameraMeta.ts` 신규: 브라우저에서 영상 File 앞 8MB를 읽어 djmd 첫 샘플 파싱 + (모델 문자열 탐색 → 샘플 경계 역추적 → protobuf 필드 수집). 실파일 검증 완료 +- `loadFolderGeoData`: 감지 정보를 FolderGeoData/geoStore(cameraInfo)에 적재, 드론 프레임 focalLen에도 반영 +- StationOverlay: **저장된 보정(localStorage calib)이 없는 데이터셋은 감지 focal 자동 적용**, + 카메라 파라미터 패널에 "📷 모델 · 초점 · fps" 표시 + **[적용] 버튼**(수동 적용 — 이미 튜닝한 + 데이터셋은 자동 적용을 건너뛰므로 이 버튼 사용) + +### 2. '드론 높이와 POI 이격거리' 슬라이더 최대값 120 → 300 + +### 3. 영상목록 → 좌상단 타이틀 아래 콤보 드롭다운 + +- 우하단 패널/하단 버튼 제거 → 노선 배너(타이틀) 아래 `left:8, top:paramTop` 콤보 버튼 + (현재 파일명 + n/m + ▼). 클릭 시 목록이 아래로 펼쳐지고 선택 시 즉시 전환·접힘 +- RoutePanel이 paramTop+40부터 시작해 겹침 없음 + +### 4. POI 라벨: 십자/아이콘 제거, 지정색 배경 + 흰 글자 + +- 캔버스 렌더를 "텍스트 박스"로 변경: 배경 = KML 텍스트박스_색상(라운드 사각형), 글자 = 흰색 +- POI 지점에 가로 중앙 정렬, 히트박스도 박스 영역으로 갱신 + +## 검증 + +- tsc+vite 빌드 통과, 54000 포트 새 번들(index-Bn6Gs0g2.js) 서빙 +- 카메라 감지 알고리즘을 실파일 앞 8MB로 재현 검증: ZenmuseP1 / 29.9mm / 3840×2160@59.94 정확 추출 + +## 다음 단계 참고 + +- **이미 캘리브레이션이 저장된 데이터셋(이번 세션에서 슬라이더 만진 경우)은 focal 자동 적용이 + 건너뛰어짐** → 카메라 파라미터 패널의 [적용] 버튼으로 29.9mm 반영 필요 +- djmd 6.x(위경도 라디안/타원체고)는 CSV보다 정밀한 포즈 소스 — 향후 CSV 대체 후보 +- 8.8(변화 필드)은 포커스 거리로 추정 — 미사용 diff --git a/docs/history/2026-07-09_1758_카메라focal-자동적용-개선.md b/docs/history/2026-07-09_1758_카메라focal-자동적용-개선.md new file mode 100644 index 0000000..0c193b5 --- /dev/null +++ b/docs/history/2026-07-09_1758_카메라focal-자동적용-개선.md @@ -0,0 +1,30 @@ +# 2026-07-09 카메라 focal 자동 적용 조건 개선 + +**소요 시간**: 4분 +**Context 사용량**: input 460k / output 3k tokens + +## 문제 + +"드론 카메라 속성이 카메라 파라미터에 적용된 건가?" 확인 요청. +직전 구현은 "저장된 보정(localStorage calib)이 있으면 자동 적용 건너뜀"이었는데, +**보정 자동저장은 폴더를 열기만 해도 기본 params 로 저장**되므로(800ms 디바운스 저장 effect가 +첫 로드에도 실행) 제주 데이터셋은 이미 calib 이 존재 → 감지 focal(29.9mm)이 적용되지 않았을 +가능성이 높았음. + +## 수정 — StationOverlay.tsx 자동 적용 조건 + +- 기존: 저장된 calib 존재 → 무조건 건너뜀 +- 변경: **사용자가 f 를 실제로 튜닝한 경우(저장된 f ≠ 기본값 24)만 유지**, 그 외 + (첫 로드 / 저장은 됐지만 f 기본값 그대로)는 감지값 자동 적용 +- setParams 함수형 업데이트 안에서 판단 → calib 복원값 기준으로 정확히 비교 + +## 확인 방법 (사용자 안내) + +- 새로고침 → 폴더 재선택 → 카메라 파라미터 패널: **f = 29.9mm** 이고 + 📷 줄에 "DJI ZenmuseP1 · 29.9mm" 표시되면 적용된 것 +- 콘솔 로그: `[camera] DJI ZenmuseP1 감지 → focal 29.9mm 자동 적용` +- f 를 이미 직접 튜닝했던 경우엔 유지되며, [적용] 버튼으로 감지값 교체 가능 + +## 검증 + +- tsc+vite 빌드 통과, 54000 포트 새 번들(index-Ba4WSfaE.js) 서빙 diff --git a/docs/history/2026-07-09_1802_스테이션바-노선밖-진행정지-수정.md b/docs/history/2026-07-09_1802_스테이션바-노선밖-진행정지-수정.md new file mode 100644 index 0000000..352b821 --- /dev/null +++ b/docs/history/2026-07-09_1802_스테이션바-노선밖-진행정지-수정.md @@ -0,0 +1,33 @@ +# 2026-07-09 스테이션바 노선 밖 구간 진행 정지 수정 + +**소요 시간**: 4분 +**Context 사용량**: input 500k / output 3k tokens + +## 문제 (스크린샷 제보) + +드론이 전진 중인데(영상 1:17, GPS 33.4084/126.2945) 하단 스테이션바 커서·배지(0k000)가 고정. + +## 원인 + +측점이 로드되면서 스테이션바 이동거리축이 **측점 폴리라인 체이니지 투영 |Δ| 누적** 방식으로 +동작하는데, 제주 비행은 **노선 시작점(0k000)에서 남서쪽 약 700m 밖에서 이륙해 접근**한다 +(0k000 도달은 87.3초 시점). 노선 밖에서는 투영이 폴리라인 끝점(0k000)에 클램프 → 체이니지 +변화량 0 → 접근 구간 내내(첫 87초) 커서·배지 정지. + +## 수정 — client/src/stationbar/StationBar.tsx + +- 이동거리축(frac)을 체이니지 |Δ| 대신 **GPS 실이동거리 누적**(±8프레임 이동평균 평활)으로 변경 + - 호버 시 정체(커서 정지) 특성 유지 + 노선 안팎 무관하게 실제 이동 반영 + - 측점 배지(최근접 측점)·구간 방향색(depChain/arrChain)은 기존 체이니지 기준 유지 + - 측점 없는 데이터셋용 GPS 폴백(직전 작업)과 계산 방식 일원화 + +## 검증 (실데이터 시뮬레이션, 세그먼트 1) + +- 스크린샷 시점 t=77.8s: 이동 719m → 커서 37.7% (기존 방식은 0% 고정) +- 총 이동거리 1909m, 0k000 도달 87.3s — 도달 전 배지가 0k000인 것은 정상(최근접 측점) +- tsc+vite 빌드 통과, 54000 포트 새 번들(index-C1t_EPA4.js) 서빙 + +## 다음 단계 참고 + +- 배지는 "가장 가까운 측점"이라 노선 접근 전에는 0k000 고정이 맞는 동작 — 접근 구간을 + 별도 표시(예: "노선 진입 전")하고 싶으면 chain 클램프 여부로 판별 가능 diff --git a/docs/history/2026-07-09_1842_스테이션바-원거리구조물-겹침수정.md b/docs/history/2026-07-09_1842_스테이션바-원거리구조물-겹침수정.md new file mode 100644 index 0000000..57e6026 --- /dev/null +++ b/docs/history/2026-07-09_1842_스테이션바-원거리구조물-겹침수정.md @@ -0,0 +1,40 @@ +# 2026-07-09 스테이션바 원거리 구조물 마크 겹침 수정 + +**소요 시간**: 6분 +**Context 사용량**: input 540k / output 3k tokens + +## 문제 (스크린샷 제보) + +스테이션바에 교량 마크 2개가 붙어 있고 라벨이 "귀어음천교"처럼 보임 — 실제로는 +**"귀덕천교"와 "어음천교" 라벨이 같은 위치에 겹쳐** 찍힌 것. (귀어음천교라는 시설물은 없음) + +## 원인 + +지장물 KML의 교량 3개 통과 시점(실데이터 계산): + +| 교량 | 세그1 최근접 | 실제 통과 | +|------|------------|----------| +| 귀덕천교 | t=194.6s, 24m | 세그먼트 1 끝 (정상) | +| 금성천교 | t=197.1s(끝 클램프), **1358m** | 세그먼트 3 (t=510.8s, 3m) | +| 어음천교 | t=197.1s(끝 클램프), **1880m** | 세그먼트 3 (t=565.2s, 59m) | + +`pxPassesTo`의 통과 인정 거리(TH)가 `max(100, 최근접거리×3)` — 근접 통과가 없는 구조물은 +TH 가 수 km 로 늘어나 전체 비행이 "통과"로 인정 → 최근접 프레임(세그먼트 끝)에 마크 생성 → +세그1 끝에서 실제 통과하는 귀덕천교와 같은 px 에 3개 마크·라벨이 겹침. + +## 수정 — client/src/stationbar/StationBar.tsx + +- `pxPassesTo`에 근접 통과 상한 `PASS_MAX_M = 300` 추가: 이 영상(세그먼트)에서 300m 이내로 + 접근하지 않은 구조물은 마크 생략. 해당 구조물은 실제 통과하는 세그먼트의 바에 정상 표시됨 + (세그먼트 전환 시 frames 교체 → 마크 재계산) + +## 검증 + +- 실데이터 기준: 세그1 → 귀덕천교만(24m ✓), 금성·어음 제외(1358/1880m) ✓ + 세그3 → 금성천교(3m)·어음천교(59m) 표시 ✓ +- tsc+vite 빌드 통과, 54000 포트 새 번들(index-CFrrDGW7.js) 서빙 + +## 다음 단계 참고 + +- PASS_MAX_M(300m)은 역사처럼 노선 옆 구조물도 포함하도록 여유 있게 설정 — 도로/철도 + 공통으로 문제없을 값. 특수 데이터셋에서 조정 필요 시 route.json 파라미터화 검토 diff --git a/docs/history/2026-07-09_1850_스테이션배지-노선밖-표기.md b/docs/history/2026-07-09_1850_스테이션배지-노선밖-표기.md new file mode 100644 index 0000000..a2d780e --- /dev/null +++ b/docs/history/2026-07-09_1850_스테이션배지-노선밖-표기.md @@ -0,0 +1,38 @@ +# 2026-07-09 스테이션 배지 노선 밖(접근 구간) 표기 + +**소요 시간**: 6분 +**Context 사용량**: input 580k / output 3k tokens + +## 문제 (스크린샷 제보) + +영상 54.8초(커서는 정상 진행 중)인데 스테이션 배지가 "0k000"에서 안 변함. + +## 원인 (버그 아님 — 표기 문제) + +이 비행은 노선 시작점(0k000)에서 남서쪽 ~700m 밖에서 이륙해 **87초에 노선 진입**. +진입 전에는 "가장 가까운 측점 = 0k000"이라 배지가 0k000으로 고정되는 게 계산상 맞지만, +사용자는 고장으로 인식 → 접근 구간임을 배지에 드러내기로 함. + +## 수정 — client/src/stationbar/StationBar.tsx + +- `projectChainageDist` 신설: 체이니지 + **폴리라인까지 수평거리(distM)** 반환 + (기존 `projectChainage`는 래퍼로 유지 — 구조물 마크 등 기존 호출부 무변경) +- `ViewedPoint.offM` 추가(프레임별 노선 이탈 거리), `realOffM` memo 추가 +- 배지 표기: 노선에서 **60m 이상 벗어난 구간은 "노선밖 ○○m"** 로 표시(거리 실시간 갱신), + 60m 이내면 기존 체이니지(0k000, 0k110, …) 표시 + +## 검증 (실데이터 시뮬레이션, 세그먼트 1) + +| 시점 | 배지 | +|------|------| +| 10s | 노선밖 708m | +| 54.8s (스크린샷) | 노선밖 346m | +| 87s (진입) | 0k000 | +| 100s / 130s / 190s | 0k110 / 0k410 / 1k010 | + +→ 진입 후 배지 정상 진행 확인. tsc+vite 빌드 통과, 새 번들(index-DyILp3c8.js) 서빙. + +## 다음 단계 참고 + +- OFF_ROUTE_M=60m 고정 — 드론이 노선 옆으로 크게 이격 비행하는 데이터셋이면 route.json + 파라미터화 검토 diff --git a/docs/history/2026-07-09_2021_카메라json-세그먼트라벨-팝업정렬.md b/docs/history/2026-07-09_2021_카메라json-세그먼트라벨-팝업정렬.md new file mode 100644 index 0000000..badddc7 --- /dev/null +++ b/docs/history/2026-07-09_2021_카메라json-세그먼트라벨-팝업정렬.md @@ -0,0 +1,47 @@ +# 2026-07-09 카메라 JSON 저장/적용 + 세그먼트 라벨 캐시 + 팝업 정렬 + 박스 높이 + +**소요 시간**: 20분 +**Context 사용량**: input 640k / output 10k tokens + +## 요청 4건 처리 + +### 1. 카메라 화각 정보를 영상 이름의 JSON 으로 서버 저장 → 폴더에 함께 있으면 자동 적용 + +- **서버**: `routes/camera.ts` 신규 — `PUT /api/camera/:videoId` 가 영상 폴더(VIDEOS_DIR)에 + `<영상 base>.camera.json` 저장(`{ camera: {...}, model, savedAt }`), `GET` 조회. Path traversal 방어 +- **클라이언트**: + - `parseCameraJson`(geoData): 폴더에서 `<영상 base>.camera.json`(또는 `.json`) 인식 — + 폴더 내 어떤 분할본 base 와도 매칭, 숫자 필드만 통과 + - geoStore `cameraJson` 적재 → StationOverlay 에서 **최우선 적용**(localStorage 보정·djmd 감지값보다 위, + effect 선언 순서로 보장) + - 카메라 파라미터 패널 📷 줄에 **[서버에 저장]** 버튼 — 현재 파라미터 전체(focalLen/yaw/pitch/…)를 + 현재 재생 중인 영상 이름으로 저장. 저장중…/저장됨 ✓/실패 상태 표시 +- E2E 검증: PUT → D: 폴더에 파일 생성 → GET 일치 → 정리 완료 + +### 2. 두 번째 영상에서 시설물 미표출 + +- 원인: 라벨 사전계산(startLabelPrecompute) effect deps 에 `storeFrames` 누락 — + 세그먼트 전환(loadSegment)으로 frames 가 교체돼도 이전 세그먼트의 라벨 캐시를 계속 사용 → + 새 위치에서 전부 화면 밖 투영 +- 수정: 두 precompute effect deps 에 `storeFrames` 추가 → 세그먼트 전환 시 재계산 + +### 3. 시설물 라벨 아래 검은 팝업 정렬 + +- 원인: 팝업 x 앵커가 구 라벨 레이아웃(지점 오른쪽 텍스트) 기준 `sx − 10` 고정 — + 라벨이 중앙정렬 박스로 바뀌면서 어긋남 +- 수정: POI 팝업은 `sx − 팝업폭/2`(박스 중앙 아래 정렬), 측점 팝업은 기존 유지 + +### 4. 라벨 배경 박스 여유 + +- BOX_H 22 → **24**(글자 20px × 1.2), PAD_X 6→7, 동일좌표 다중 라벨 행 간격 ROW_H 24→28 + +## 검증 + +- 클라이언트(tsc+vite)·서버(tsc) 빌드 통과, pm2 재시작, 새 번들(index-BxAoyQvu.js) 서빙 +- 카메라 API 왕복 테스트 통과 + +## 다음 단계 참고 + +- camera.json 저장은 서버 VIDEOS_DIR 기준 — 폴더 선택으로 여는 로컬 폴더가 VIDEOS_DIR 과 + 다른 경로면 저장 파일이 그 폴더에 없음(현 구성은 동일 폴더라 문제없음) +- camera.json 은 폴더 공용(분할본 전체 적용). 세그먼트별 별도 카메라가 필요하면 확장 필요 diff --git a/docs/history/2026-07-09_2024_커서배지-줄바꿈-수정.md b/docs/history/2026-07-09_2024_커서배지-줄바꿈-수정.md new file mode 100644 index 0000000..e1b4c5c --- /dev/null +++ b/docs/history/2026-07-09_2024_커서배지-줄바꿈-수정.md @@ -0,0 +1,17 @@ +# 2026-07-09 스테이션바 커서 배지 줄바꿈 수정 + +**소요 시간**: 3분 +**Context 사용량**: input 680k / output 1k tokens + +## 문제 + +커서 배지의 "노선밖 737m" 표기가 두 줄로 꺾여 표시됨 (배지가 min-width 112px + 20px 글자 ++ 줄바꿈 허용이라 공백에서 개행). + +## 수정 — TimelineCursor.module.scss + +- 배지 글자 20px → **17px**, `white-space: nowrap` 추가 (폭은 내용에 맞게 자동 확장) + +## 검증 + +- 빌드 통과, 새 번들(index-Cf5yoZh9.js) 서빙 diff --git a/docs/history/2026-07-09_2030_호버구간-라벨-깜빡임-수정.md b/docs/history/2026-07-09_2030_호버구간-라벨-깜빡임-수정.md new file mode 100644 index 0000000..716ef57 --- /dev/null +++ b/docs/history/2026-07-09_2030_호버구간-라벨-깜빡임-수정.md @@ -0,0 +1,35 @@ +# 2026-07-09 호버 구간 라벨 깜빡임 수정 (세그먼트 2) + +**소요 시간**: 6분 +**Context 사용량**: input 720k / output 3k tokens + +## 문제 + +두 번째 영상 재생 시 라벨이 심하게 깜빡임. + +## 원인 + +세그먼트 2는 **120초 호버 구간**(짐벌 회전 0.0°/s — 실데이터 확인, 완전 정지 프레이밍): + +1. **화면 경계 걸침 라벨의 포함 경계 플래핑**: 라벨 사전계산의 화면 포함 판정이 + POI ±0.02 / 측점 ±0.05 로 타이트 — 호버 중 GPS 미세 지터로 경계에 걸친 라벨이 + 캐시 프레임(10Hz)마다 포함↔제외를 반복 → 표시/소멸 깜빡임. + (이동 중에는 경계 통과가 순간이라 눈에 안 띄었음 — 호버에선 120초 내내 반복) +2. **겹침 억제 승자 플래핑**: 겹친 라벨 쌍의 우선순위가 dist 정렬 — 거리가 거의 같은 쌍은 + 지터로 순위가 프레임마다 뒤바뀌어 두 라벨이 교대로 깜빡임. + +## 수정 — client/src/components/overlay/StationOverlay.tsx + +- 사전계산 포함 경계를 POI/측점 모두 **±0.15** 로 확대 — 경계 판정을 안정화하고 + 실제 화면 밖 컬링은 드로우 단계(labelOnScreen)가 담당 (마커 수 증가는 미미) +- 겹침 억제 정렬에 **거리차 1m 미만 = 제목순 고정** 타이브레이크 추가 — 승자 고정 + +## 검증 + +- 세그먼트별 짐벌 데이터: 세그1 평균 0.4°/s(이동 촬영) vs 세그2 평균 0.0°/s(호버) — 원인 부합 +- tsc+vite 빌드 통과, 새 번들(index-pDBh9-ym.js) 서빙 + +## 다음 단계 참고 + +- 남을 수 있는 잔여 깜빡임: POI 표시범위(maxPoiRange) 경계(기본 1000m)에 정확히 걸친 POI 가 + 호버 중 지터로 in/out 반복하는 경우 — 발생 시 범위 판정에 히스테리시스(±2%) 추가 검토 diff --git a/docs/history/2026-07-09_2035_카메라패널-값-출처-확인.md b/docs/history/2026-07-09_2035_카메라패널-값-출처-확인.md new file mode 100644 index 0000000..1bfe5b3 --- /dev/null +++ b/docs/history/2026-07-09_2035_카메라패널-값-출처-확인.md @@ -0,0 +1,23 @@ +# 2026-07-09 카메라 패널 값 출처 확인 (Q&A) + +**소요 시간**: 3분 +**Context 사용량**: input 750k / output 1k tokens + +## 질문 + +폴더 로드 후 카메라 파라미터 패널 값들이 드론에 저장된 카메라 값인지 확인 요청. + +## 답변 (코드 변경 없음) + +| 항목 | 값 | 출처 | +|------|-----|------| +| f | 29.9mm | **드론(djmd) 감지값 자동 적용** ✓ | +| 상태줄 yaw/pitch | 13.2°/−15° | **드론 비행로그**(현재 프레임 짐벌 자세) ✓ | +| Yaw ± | −16.5° | localStorage 복원된 사용자 보정(기본 0) — f=24 시절 튜닝 잔재, 재조정 권장 | +| cy₀ | −0.105 | localStorage 복원된 사용자 보정(기본 0) | +| 지오이드 | 25.8 | 앱 기본값(대전 기준) — 지면고도 모드에선 미사용 | +| sen W/H | 36/20.25 | 앱 기본값 — P1이 실제 풀프레임이라 사실과 일치 | + +## 사용자 안내 + +초기화 → f 29.9 적용 → Yaw 재조정 → [서버에 저장]으로 camera.json 고정 권장. diff --git a/docs/history/2026-07-09_2042_djmd-자세-주점-부재-확인.md b/docs/history/2026-07-09_2042_djmd-자세-주점-부재-확인.md new file mode 100644 index 0000000..0230d49 --- /dev/null +++ b/docs/history/2026-07-09_2042_djmd-자세-주점-부재-확인.md @@ -0,0 +1,28 @@ +# 2026-07-09 djmd에 yaw/cy₀ 존재 여부 확인 (Q&A) + +**소요 시간**: 5분 +**Context 사용량**: input 780k / output 2k tokens + +## 질문 + +드론 카메라에서 yaw, cy₀ 값도 가져올 수 있지 않은지. + +## 분석 (코드 변경 없음) + +세그먼트1 중반(프레임 5990, t≈100s, CSV yaw=65.1°) djmd 레코드 전수 해독: + +- 존재: 위치(6.4/6.5 위경도 라디안, 6.6 타원체고 mm), 노출(7.4 셔터분모 60~65, 7.5 ISO계열, + 7.7~7.10 측광), 렌즈(8.6 초점 299=29.9mm, 8.8 포커스거리), 해상도/fps +- **부재: 짐벌 자세(yaw/pitch/roll), 주점(cx/cy) 캘리브레이션** +- 7.4=65 는 yaw(65.1°)와 우연히 유사하나 프레임10에서 61(당시 yaw 29.7°)로 불일치 → 셔터 분모로 판정 + +## 답변 요지 + +- Yaw±는 로그 계통 오차(자기편각 등)의 보정 델타 — 드론 데이터에 존재 불가 +- cy₀는 DJI **사진 JPG의 XMP DewarpData**에만 존재 (fx/fy/cx/cy/왜곡계수) + +## 제안 (미구현) + +1. P1 사진 1장 폴더 동봉 시 DewarpData 자동 적용 기능 +2. GPS 진행방위 vs 짐벌 헤딩으로 yaw 바이어스 자동 추정 +3. 라벨 드래그로 yaw 역산 모드 (FOV 보정과 동일 패턴) — 실무적 최선 diff --git a/docs/history/2026-07-09_2046_yaw보정-방안-설명.md b/docs/history/2026-07-09_2046_yaw보정-방안-설명.md new file mode 100644 index 0000000..0184892 --- /dev/null +++ b/docs/history/2026-07-09_2046_yaw보정-방안-설명.md @@ -0,0 +1,15 @@ +# 2026-07-09 Yaw 보정 방안 2·3번 상세 설명 (Q&A) + +**소요 시간**: 3분 +**Context 사용량**: input 800k / output 1k tokens + +## 내용 (코드 변경 없음) + +Yaw 보정 자동화 두 방안을 쉬운 언어로 재설명: + +- **2번(자동 추정)**: GPS 이동 방위(정확) vs 짐벌 헤딩(나침반 오차 포함)의 일정한 차이의 + 중앙값을 Yaw± 에 자동 입력. 버튼 한 번, 근사치. 카메라가 진행방향을 안 본 구간이 섞이면 오차. +- **3번(드래그 역산)**: 일시정지 → 어긋난 라벨을 영상 속 실제 지물 위치로 드래그 → + 필요한 Yaw± 를 역산해 자동 설정. 기존 FOV 보정 모드(f 역산)와 동일 패턴. 정확·직관적. + +추천: 3번(정확) 또는 2번+3번 병행. 사용자 선택 대기. diff --git a/docs/history/2026-07-09_2050_yaw자동추정-드래그역산-PC저장.md b/docs/history/2026-07-09_2050_yaw자동추정-드래그역산-PC저장.md new file mode 100644 index 0000000..c86cd4e --- /dev/null +++ b/docs/history/2026-07-09_2050_yaw자동추정-드래그역산-PC저장.md @@ -0,0 +1,39 @@ +# 2026-07-09 Yaw 자동추정 + 드래그 역산 + 카메라값 PC 저장 + +**소요 시간**: 15분 +**Context 사용량**: input 850k / output 8k tokens + +## 작업 내용 (요청 3건) + +### 1. Yaw 자동 추정 버튼 (2번 방안) + +- 카메라 파라미터 패널 'Yaw ±' 아래 **[Yaw 자동추정 (비행경로 기준)]** 버튼 +- 알고리즘: 전진 구간(속도≥2m/s)에서 GPS 진행 방위(1초 창 평활) − 짐벌 헤딩 차이 수집 + (|차|≥60° 는 옆보기 촬영으로 간주 제외) → **중앙값을 Yaw± 로 설정** +- 실데이터 검증: 세그먼트1 유효 샘플 1889개, 중앙값 **−16.3°** + — 사용자가 이전에 수동 튜닝했던 −16.5° 와 거의 일치 (알고리즘·기존 튜닝 상호 검증) + +### 2. Yaw 드래그 역산 모드 (3번 방안) + +- POI 편집 섹션에 **[🧭 Yaw 보정 — POI를 실제 위치(좌우)로 드래그]** 모드 추가 + (편집/세로화각 모드와 상호 배제, 기존 fovMode 패턴 재사용) +- 역산: θ0=atan2(Xc,Zc)(현재 투영각) vs θ1=atan((px*−0.5−cx0)·sW/f)(드롭 지점 요구각) + → `yawOffset += (θ0−θ1)`. |Δ|>45° 는 오조작으로 거부 +- 게이팅(커서/히트/팝업/드래그 피드백) 4곳에 yawMode 연결 + +### 3. 카메라값 저장: 서버 → **사용자 PC 다운로드** + +- 📷 줄 버튼 '서버에 저장' → **[PC에 저장]**: 현재 파라미터 전체(Yaw± 보정 결과 포함)를 + `<영상이름>.camera.json` 으로 브라우저 다운로드 +- 받은 파일을 영상 폴더에 넣으면 폴더 선택 시 최우선 자동 적용(기존 로더 그대로) +- 서버 라우트(/api/camera)는 유지하되 UI 에서는 미사용 + +## 검증 + +- tsc+vite 빌드 통과, 새 번들(index-D5ltlp17.js) 서빙 +- Yaw 자동 추정 실데이터 시뮬레이션 −16.3° (수동값 −16.5° 부합) + +## 사용 흐름 (사용자 안내) + +1. [Yaw 자동추정] → 대략 정렬 2. [🧭 Yaw 보정] 모드에서 라벨 하나를 실제 지물로 드래그 → 미세 조정 +3. [PC에 저장] → 받은 `<영상이름>.camera.json` 을 영상 폴더로 이동 → 이후 폴더 선택만으로 적용 diff --git a/ecosystem.config.js b/ecosystem.config.js index 1fef460..d128c09 100755 --- a/ecosystem.config.js +++ b/ecosystem.config.js @@ -1,7 +1,7 @@ module.exports = { apps: [ { - name: "ghiVideo", + name: "ghiVideo-v4", // cwd = 이 설정 파일이 있는 위치(프로젝트 루트)를 작업 디렉토리로 고정. // 이 한 곳만 앵커로 두고, 아래 경로는 전부 cwd 기준 상대경로로 해석된다. cwd: __dirname, @@ -10,9 +10,11 @@ module.exports = { // PATH 의 node 사용 — pm2 start 전에 nvm 으로 Node 20 이 활성화돼 있어야 함 interpreter: "node", env: { - PORT: 55000, + PORT: 54000, // 서버 config.ts 가 path.resolve(cwd 기준)로 해석 → 상대경로 OK - VIDEOS_DIR: "samplevideo", + // 제주 중산간 도로 데이터 (D: 드라이브, WSL drvfs 마운트 경유) + VIDEOS_DIR: + "/mnt/d/_project/20260612_abcvideo/import data/v4.0 (제주도)/v4.0 (제주도_필요한 데이터만)/제주 중산간 도로 노선 동영상 비행 로그/경로 1-1", HLS_DIR: "storage/hls", FRAMES_DIR: "storage/frames", THUMBNAILS_DIR: "storage/thumbnails", @@ -20,7 +22,9 @@ module.exports = { // 절대경로 대신 PATH 에서 탐색 (apt 설치 시 /usr/bin 에 있고 PATH 포함됨) FFMPEG_PATH: "ffmpeg", FFPROBE_PATH: "ffprobe", - GEO_DATA_DIR: "samplevideo", + // 비행로그 CSV/KML 위치 (드론 CSV 로더는 제주 형식 미지원 — 어댑터 작업 필요) + GEO_DATA_DIR: + "/mnt/d/_project/20260612_abcvideo/import data/v4.0 (제주도)/v4.0 (제주도_필요한 데이터만)/제주 중산간 도로 노선 동영상 비행 로그/경로 1-1", CENTER_CSV_PATH: "pythonsource/input/center.csv", }, }, diff --git a/server/src/app.ts b/server/src/app.ts index bec89d2..6ad644d 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -15,6 +15,7 @@ import annotationsRouter from './routes/annotations'; import geoRouter from './routes/geo'; import elevationRouter from './routes/elevation'; import tileRouter from './routes/tile'; +import cameraRouter from './routes/camera'; const app = express(); @@ -37,6 +38,7 @@ app.use('/api/annotations/:videoId', annotationsRouter); app.use('/api/geo', geoRouter); app.use('/api/elevation', elevationRouter); app.use('/api/tile', tileRouter); +app.use('/api/camera', cameraRouter); app.get('/api/health', (_req, res) => { res.json({ status: 'ok', timestamp: new Date().toISOString() }); diff --git a/server/src/routes/camera.ts b/server/src/routes/camera.ts new file mode 100644 index 0000000..7759856 --- /dev/null +++ b/server/src/routes/camera.ts @@ -0,0 +1,64 @@ +/** + * 카메라 파라미터 파일 저장/조회 + * + * 클라이언트(카메라 파라미터 패널)의 '서버에 저장' 버튼이 현재 파라미터를 + * 영상과 같은 폴더(VIDEOS_DIR)에 `<영상 base>.camera.json` 으로 저장한다. + * 이후 사용자가 폴더를 다시 선택하면(영상과 함께 이 json 이 딸려 오면) + * 클라이언트 로더가 이 파일의 카메라 값을 최우선으로 적용한다. + */ + +import { Router, Request, Response } from 'express'; +import path from 'path'; +import fs from 'fs'; +import { config } from '../config'; + +const router = Router(); + +function cameraFilePath(videoId: string): string | null { + const base = videoId.replace(/\.[^.]+$/, ''); + if (!base) return null; + const filePath = path.resolve(config.videosDir, `${base}.camera.json`); + // Path traversal 방어 — VIDEOS_DIR 내부만 허용 + if (!filePath.startsWith(path.resolve(config.videosDir))) return null; + return filePath; +} + +// PUT /api/camera/:videoId — 카메라 파라미터 저장 (body = { camera: {...}, model?, savedAt? }) +router.put('/:videoId', (req: Request, res: Response) => { + const filePath = cameraFilePath(req.params.videoId); + if (!filePath) { + res.status(400).json({ error: 'Invalid video ID' }); + return; + } + const body = req.body; + if (!body || typeof body !== 'object' || typeof body.camera !== 'object') { + res.status(400).json({ error: 'body.camera 객체가 필요합니다' }); + return; + } + try { + fs.writeFileSync(filePath, JSON.stringify(body, null, 2), 'utf-8'); + res.json({ ok: true, file: path.basename(filePath) }); + } catch (e) { + res.status(500).json({ error: String(e) }); + } +}); + +// GET /api/camera/:videoId — 저장된 카메라 파라미터 조회 (없으면 404) +router.get('/:videoId', (req: Request, res: Response) => { + const filePath = cameraFilePath(req.params.videoId); + if (!filePath) { + res.status(400).json({ error: 'Invalid video ID' }); + return; + } + if (!fs.existsSync(filePath)) { + res.status(404).json({ error: 'camera.json 없음' }); + return; + } + try { + res.json(JSON.parse(fs.readFileSync(filePath, 'utf-8'))); + } catch (e) { + res.status(500).json({ error: String(e) }); + } +}); + +export default router;