diff --git a/client/src/components/overlay/StationOverlay.tsx b/client/src/components/overlay/StationOverlay.tsx index 05c867e..6821428 100644 --- a/client/src/components/overlay/StationOverlay.tsx +++ b/client/src/components/overlay/StationOverlay.tsx @@ -1013,24 +1013,32 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible }, []); // 보간 포즈 — estFrame(연속 프레임번호)에서 앞뒤 smoothFrame 선형보간(yaw는 최단각). RAF에서 호출. + // 자세(yaw/pitch/roll)는 attLeadSec(자세 선행) 만큼 앞선 시점에서 별도 샘플 — + // 짐벌 로그의 기록 지연으로 팬(회전) 시 오버레이가 늦게 따라오는 이질감 보정. const poseAt = useCallback((estFrame: number): DroneFrameBasic | null => { const arr = allDroneFramesRef.current; if (!arr.length) return null; - let lo = 0, hi = arr.length - 1; - while (lo < hi) { const m = (lo + hi) >> 1; if (arr[m].frame < estFrame) lo = m + 1; else hi = m; } - const i2 = lo, i1 = Math.max(0, lo - 1); - const f1 = arr[i1].frame, f2 = arr[i2].frame; - const frac = f2 > f1 ? Math.min(1, Math.max(0, (estFrame - f1) / (f2 - f1))) : 0; const h = poseHalf(); - const a = smoothFrame(arr, i1, h), b = smoothFrame(arr, i2, h); - const L = (x: number, y: number) => x + (y - x) * frac; - let dy = b.yaw - a.yaw; dy = ((dy + 540) % 360) - 180; // 최단각 + const sample = (ef: number): { a: DroneFrameBasic; b: DroneFrameBasic; frac: number } => { + let lo = 0, hi = arr.length - 1; + while (lo < hi) { const m = (lo + hi) >> 1; if (arr[m].frame < ef) lo = m + 1; else hi = m; } + const i2 = lo, i1 = Math.max(0, lo - 1); + const f1 = arr[i1].frame, f2 = arr[i2].frame; + const frac = f2 > f1 ? Math.min(1, Math.max(0, (ef - f1) / (f2 - f1))) : 0; + return { a: smoothFrame(arr, i1, h), b: smoothFrame(arr, i2, h), frac }; + }; + const p = sample(estFrame); + const leadSec = paramsRef.current.attLeadSec ?? 0; + const q = leadSec !== 0 ? sample(estFrame + leadSec * fpsRef.current) : p; + const L = (x: number, y: number) => x + (y - x) * p.frac; + const A = (x: number, y: number) => x + (y - x) * q.frac; + let dy = q.b.yaw - q.a.yaw; dy = ((dy + 540) % 360) - 180; // 최단각 return { - ...a, + ...p.a, frame: estFrame, - lat: L(a.lat, b.lat), lon: L(a.lon, b.lon), altitude: L(a.altitude, b.altitude), - pitch: L(a.pitch, b.pitch), roll: L(a.roll, b.roll), - yaw: a.yaw + dy * frac, + lat: L(p.a.lat, p.b.lat), lon: L(p.a.lon, p.b.lon), altitude: L(p.a.altitude, p.b.altitude), + pitch: A(q.a.pitch, q.b.pitch), roll: A(q.a.roll, q.b.roll), + yaw: q.a.yaw + dy * q.frac, }; }, [smoothFrame, poseHalf]); @@ -1522,7 +1530,12 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible if (cc.Zc < CLIP_Z) return; const { pxRaw, pyRaw } = pixelFromCamera(cc, params); const prevDpoi = displayedPoiRef.current.get(poiA.title); - const d = smoothStep(prevDpoi, pxRaw, pyRaw, α, smoothMinAlphaRef.current, smoothSpeedRefRef.current); + // 프레임 동기(djmd) 데이터는 입력이 매끄러워 화면 EMA 불필요 — 즉시 투영으로 + // 팬(회전) 시 라벨이 늦게 따라오는 이질감 제거(선형·측점과 동일 타이밍). + // 비동기 데이터셋(CSV 지터)만 기존 속도적응 EMA 유지. + const d = frameSyncedRef.current + ? { x: pxRaw, y: pyRaw, rej: 0, vx: 0, vy: 0 } + : smoothStep(prevDpoi, pxRaw, pyRaw, α, smoothMinAlphaRef.current, smoothSpeedRefRef.current); seenPoi.add(poiA.title); // 픽셀 히스테리시스: 0.75px 이상 움직일 때만 정수 위치 갱신 → 저속(1배속) 깜빡임 제거. const psx0 = vx(d.x), psy0 = vy(d.y); @@ -2178,6 +2191,7 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
setParam('timeOffsetSec', v)} /> + setParam('attLeadSec', v)} /> setParam('yawOffset', v)} /> {/* Yaw 자동 추정(2단계 보정의 1단계) — 대략 맞춘 뒤 'Yaw 보정' 드래그 모드로 마무리 */}
{panelDroneFrame && (
diff --git a/client/src/stationbar/StationBar.tsx b/client/src/stationbar/StationBar.tsx index d563c79..e81b2f5 100755 --- a/client/src/stationbar/StationBar.tsx +++ b/client/src/stationbar/StationBar.tsx @@ -34,11 +34,16 @@ interface GeoPoint { } /** 프레임별 precompute 결과. km=배지용 최근접측점, chain=구간방향용 연속 체이니지, - * offM=측점 폴리라인까지 수평거리(m) — 노선 밖(접근/이탈) 판별용. */ + * offM=측점 폴리라인까지 수평거리(m) — 노선 밖(접근/이탈) 판별용. + * viewChain=카메라 시선 중심이 닿는 지면 교점의 체이니지(커서 배지용) — + * 드론 연직 체이니지(chain)보다 전방 주시거리(AGL/tan|pitch|)만큼 앞. */ interface ViewedPoint { frameNum: number; km: number; chain: number; + viewChain: number; + /** 배지 표시 지점(시선/오프셋 지점)의 노선 이격(m) — 오프셋 미설정이면 offM 과 동일. */ + viewOffM: number; offM: number; time: number; } @@ -49,12 +54,11 @@ interface StationLine { k: number; // 경도→m 환산 (cos(lat0)*111000) } -/** 드론 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; +/** 노선 이탈 판정 거리(m) — 이 이상 벗어나면 접근/이탈 구간으로 취급. */ +const OFF_ROUTE_M = 60; + +/** 평면 투영 좌표(px,py)를 측점 폴리라인에 투영 → 연속 체이니지(m) + 수평거리(m). */ +function projectXYChainage(px: number, py: number, line: StationLine): { chain: number; distM: number } { const pts = line.pts; let bestD = Infinity; let bestKm = pts.length ? pts[0].km : -1; @@ -76,6 +80,13 @@ function projectChainageDist(lat: number, lon: number, line: StationLine): { cha return { chain: bestKm, distM: isFinite(bestD) ? Math.sqrt(bestD) : Infinity }; } +/** 드론 lat/lon 을 측점 폴리라인에 투영 → 연속 체이니지(m) + 폴리라인까지 수평거리(m). + * 100m 양자화된 최근접측점과 달리 전진/후진 전환 시점이 실제 이동과 일치. + * distM 은 노선 밖(접근/이탈) 판별에 사용 — 밖에서는 chain 이 끝점에 클램프됨. */ +function projectChainageDist(lat: number, lon: number, line: StationLine): { chain: number; distM: number } { + return projectXYChainage(lon * line.k, lat * 111000, line); +} + function projectChainage(lat: number, lon: number, line: StationLine): number { return projectChainageDist(lat, lon, line).chain; } @@ -283,17 +294,110 @@ export function StationBar({ } const n = frames.length; const out: ViewedPoint[] = new Array(n); + // ── 시선 중심 체이니지(viewChain) 준비 ───────────────────────────── + // route.json routeInfo.station_lookahead(m) 설정: + // 미설정/0 → 기존 그대로: 드론 연직(바로 아래) 체이니지 (기본) + // N(숫자) → 드론 체이니지 + 진행방향으로 N(m) 고정 오프셋 + // "auto" → 자동: 주시거리 ≈ AGL / tan(|짐벌 피치|) + // (AGL = 드론고도 − 지면고도(측점 z 체이니지 보간, 동일 datum)) + // 진행방향 = 카메라 yaw 벡터 · 노선 접선 내적 부호. + // 오프셋 활성 시 노선 밖(접근/이탈)에서는 '시선이 닿는 지점'을 노선에 투영해 + // 그 지점이 노선 위(OFF_ROUTE_M 이내)면 배지가 측점값으로 전환된다. + const lookCfgRaw = (routeMeta?.routeInfo as Record | undefined)?.station_lookahead + ?? (routeMeta as unknown as Record | null)?.station_lookahead; + const lookFixed: number | null = + typeof lookCfgRaw === 'number' && isFinite(lookCfgRaw) && lookCfgRaw > 0 ? lookCfgRaw + : typeof lookCfgRaw === 'string' && lookCfgRaw.trim() !== '' && isFinite(Number(lookCfgRaw)) && Number(lookCfgRaw) > 0 ? Number(lookCfgRaw) + : null; + const lookAuto = typeof lookCfgRaw === 'string' && lookCfgRaw.trim().toLowerCase() === 'auto'; + const lookModeText = lookAuto ? '자동(카메라 시선 중심)' : lookFixed !== null ? `고정 +${lookFixed}m (진행방향)` : '미설정 (드론 연직 — 기존 동작)'; + const zProf = stations + .map((st) => ({ km: stationKm(st.title), z: st.z })) + .filter((p) => p.km >= 0 && p.z > 0) + .sort((a, b) => a.km - b.km); + const zAt = (km: number): number | null => { + if (zProf.length < 2) return null; + if (km <= zProf[0].km) return zProf[0].z; + if (km >= zProf[zProf.length - 1].km) return zProf[zProf.length - 1].z; + let lo = 0, hi = zProf.length - 1; + while (hi - lo > 1) { const m = (lo + hi) >> 1; if (zProf[m].km <= km) lo = m; else hi = m; } + const a = zProf[lo], b = zProf[hi]; + const r = b.km > a.km ? (km - a.km) / (b.km - a.km) : 0; + return a.z + (b.z - a.z) * r; + }; + const MAX_LOOK = 350; // 전방 주시거리 상한(m) — POI 표시범위(기본 400)와 유사 수준 + const tangentAt = (km: number): { x: number; y: number } | null => { + const pts = stationLine.pts; + for (let i = 0; i < pts.length - 1; i++) { + const a = pts[i], b = pts[i + 1]; + if ((km - a.km) * (km - b.km) <= 0 && a.km !== b.km) { + const s = b.km > a.km ? 1 : -1; // +km(체이니지 증가) 방향 접선 + const dx = (b.x - a.x) * s, dy = (b.y - a.y) * s; + const L = Math.hypot(dx, dy); + return L > 0 ? { x: dx / L, y: dy / L } : null; + } + } + return null; + }; + // 노선 체이니지 범위(클램프용) — 측점 폴리라인 km 범위(z 프로파일 유무와 무관). + let chMin = Infinity, chMax = -Infinity; + for (const p of stationLine.pts) { if (p.km < chMin) chMin = p.km; if (p.km > chMax) chMax = p.km; } for (let i = 0; i < n; i++) { const f = frames[i]; const pj = projectChainageDist(f.lat, f.lon, stationLine); + let viewChain = pj.chain; + let viewOffM = pj.distM; + if (lookFixed !== null || lookAuto) { + // 주시거리(m): 고정값 또는 자동(짐벌 피치·AGL) + let lookDist: number | null = lookFixed; + if (lookAuto) { + const gz = zAt(pj.chain); + if (gz !== null && isFinite(f.altitude)) { + const agl = f.altitude - gz; + if (agl > 5 && agl < 500) { + const pitchDeg = isFinite(f.pitch) ? f.pitch : -15; + lookDist = pitchDeg <= -3 + ? Math.min(MAX_LOOK, agl / Math.tan((-pitchDeg * Math.PI) / 180)) + : MAX_LOOK; // 수평에 가까우면 상한 사용 + } + } + } + if (lookDist !== null && lookDist > 0 && isFinite(f.yaw)) { + // 연속 모델: 유효 주시거리 = lookDist − (노선 이격의 초과분). + // 노선 위(이격 ≤ OFF_ROUTE_M): 초과분 0 → 항상 +lookDist (횡 이동에 안정) + // 접근/이탈: 이격이 줄수록 유효 주시거리가 0→lookDist 로 늘어 + // 배지가 0+000 에서 멈춤·점프 없이 진입 후 +lookDist 까지 매끄럽게 이어진다. + const over = Math.max(0, pj.distM - OFF_ROUTE_M); + const effLook = lookDist - over; + if (effLook >= 0) { + const t = tangentAt(pj.chain); + if (t) { + const yawRad = (f.yaw * Math.PI) / 180; + const dot = Math.sin(yawRad) * t.x + Math.cos(yawRad) * t.y; + viewChain = clamp(pj.chain + (dot >= 0 ? 1 : -1) * effLook, chMin, chMax); + } + viewOffM = Math.min(pj.distM, OFF_ROUTE_M); // 값 표시 상태(노선밖 아님) + } else { + // 아직 시선이 노선에 못 미침 — '노선밖 (남은 거리+θ)m' + viewOffM = OFF_ROUTE_M - effLook; + } + } + } out[i] = { frameNum: f.frame, km: nearestStationKm(f, stations), chain: pj.chain, + viewChain, + viewOffM, offM: pj.distM, time: f.frame / videoFps, }; } + // 진단: 세그먼트별 적용 확인용 — 중간 프레임 표본의 연직 대비 오프셋(Δ). + const midVp = out[Math.floor(out.length / 2)]; + console.log( + `[스테이션바] 배지 오프셋: ${lookModeText} | 표본 Δ${Math.round(midVp.viewChain - midVp.chain) >= 0 ? '+' : ''}${Math.round(midVp.viewChain - midVp.chain)}m (${out.length}행)`, + ); // 이동거리 축(frac): 체이니지 |Δ| 가 아니라 GPS 실이동거리 누적(±W 이동평균 평활). // 체이니지 투영은 노선 밖(접근/이탈 구간)에서 폴리라인 끝점(0k000 등)에 클램프되어 // 드론이 움직여도 커서가 멈추는 문제가 있었음(제주 — 비행 시작점이 노선 밖 700m). @@ -333,7 +437,7 @@ export function StationBar({ chainRef.current = { time, frac, depChain: dep, arrChain: arr }; viewedRef.current = out; setReady(true); - }, [stations, stationLine, storeFrames, videoFps]); + }, [stations, stationLine, storeFrames, videoFps, routeMeta]); /** 현재 시간에 가장 가까운 precompute 인덱스. */ const viewedIdxAtTime = useCallback((t: number): number => { @@ -352,18 +456,21 @@ export function StationBar({ return best; }, [videoFps]); - // 현재 보는 측점값 — 연속 체이니지(chain)로 10m 해상도 (km 필드는 100m 양자화). + // 현재 보는 측점값 — station_lookahead 미설정이면 드론 연직 체이니지(기존), + // 설정 시 진행방향 오프셋/시선 중심 지점의 체이니지(viewChain). const realChain = useMemo(() => { if (!ready) return null; const idx = viewedIdxAtTime(currentTime); - return idx >= 0 ? viewedRef.current[idx].chain : null; + return idx >= 0 ? viewedRef.current[idx].viewChain : null; }, [currentTime, ready, viewedIdxAtTime]); - // 현재 드론의 노선(측점 폴리라인) 이탈 거리(m) — 접근/이탈 구간 배지 표기용. + // 배지 표시 지점의 노선 이격(m) — 접근/이탈 구간 '노선밖' 표기용. + // 오프셋 활성 시 '시선이 닿는 지점' 기준이라, 화면이 노선을 비추기 시작하면 + // 드론이 아직 노선 밖이어도 배지가 측점값으로 먼저 전환된다. const realOffM = useMemo(() => { if (!ready) return null; const idx = viewedIdxAtTime(currentTime); - return idx >= 0 ? viewedRef.current[idx].offM : null; + return idx >= 0 ? viewedRef.current[idx].viewOffM : null; }, [currentTime, ready, viewedIdxAtTime]); // 종점역 '미도착' 폭(px) — 영상이 종점역 도착 전 끝날 때, 트랙 우측에 이만큼 비워둔다. @@ -460,7 +567,6 @@ export function StationBar({ // 커서 배지 = 폴더 데이터 기반 연속 체이니지(realChain)를 10m 단위로 표시. 데이터 없으면 빈 문자열. // 드론이 노선(측점 폴리라인)에서 OFF_ROUTE_M 이상 벗어난 구간(접근/이탈)은 체이니지가 // 끝점에 클램프되어 무의미 → "노선밖 ○○m" 로 표기해 측점이 안 변하는 이유를 드러낸다. - const OFF_ROUTE_M = 60; const cursorText = realChain !== null && realChain >= 0 ? (realOffM !== null && realOffM > OFF_ROUTE_M @@ -561,6 +667,9 @@ export function StationBar({ const arr = viewedRef.current; const frames = storeFrames; if (!ready || !arr.length || duration <= 0 || !frames.length) return []; + // station_lookahead(시선/오프셋) 활성 여부 — viewChain 이 연직 체이니지와 다르면 활성. + // 활성 시 시설물 마크는 viewChain 기준으로만 배치(물리 폴백 금지). + const lookActive = arr.some((v) => Math.abs(v.viewChain - v.chain) > 1); // 실좌표(lat/lon)에 드론이 가까워지는 '모든 통과 구간'의 px 목록. // (드론이 같은 구조물을 2번 이상 지날 때 각 통과마다 마커를 찍기 위함) @@ -619,6 +728,9 @@ export function StationBar({ }; // 측점값(미터)에 해당하는 모든 통과 지점 px (연속 체이니지 기준). + // 배지와 동일한 viewChain(오프셋/시선 기준) 사용 — station_lookahead 설정 시 + // 마크(시점·측점 기반 구조물)가 배지 값과 같은 타이밍에 커서와 만난다. + // (미설정이면 viewChain=연직 체이니지 — 기존 동작 그대로) const pxPassesAtMileage = (targetM: number): { px: number; km: number }[] => { const arr = viewedRef.current; if (!arr.length) return []; @@ -628,13 +740,13 @@ export function StationBar({ const out: { px: number; km: number }[] = []; let i = 0; while (i < arr.length) { - // 노선 밖(접근/이탈) 프레임 제외 — 폴리라인 밖에선 체이니지가 끝점(0 등)에 클램프되어 - // 접근 구간 전체가 '시점(0k000) 통과'로 오인돼 마크가 바 맨 앞에 찍히는 문제 방지. - if (arr[i].offM > OFF_ROUTE_M) { i++; continue; } - if (Math.abs(arr[i].chain - targetM) < TH) { - let j = i, bj = i, bd = Math.abs(arr[i].chain - targetM); - while (j < arr.length && arr[j].offM <= OFF_ROUTE_M && Math.abs(arr[j].chain - targetM) < TH) { - const d = Math.abs(arr[j].chain - targetM); + // 배지 표시 지점이 노선 밖인 프레임 제외 — 접근/이탈 구간에서 체이니지가 + // 끝점(0 등)에 클램프되어 '시점 통과'로 오인돼 마크가 앞에 찍히는 문제 방지. + if (arr[i].viewOffM > OFF_ROUTE_M) { i++; continue; } + if (Math.abs(arr[i].viewChain - targetM) < TH) { + let j = i, bj = i, bd = Math.abs(arr[i].viewChain - targetM); + while (j < arr.length && arr[j].viewOffM <= OFF_ROUTE_M && Math.abs(arr[j].viewChain - targetM) < TH) { + const d = Math.abs(arr[j].viewChain - targetM); if (d < bd) { bd = d; bj = j; } j++; } @@ -665,14 +777,14 @@ export function StationBar({ const appendEndpointStations = (marks: StructMark[]): StructMark[] => { const va = viewedRef.current; if (!va.length) return marks; - const onRoute = va.filter(v => v.offM <= OFF_ROUTE_M); + const onRoute = va.filter(v => v.viewOffM <= OFF_ROUTE_M); if (!onRoute.length) return marks; const ends = [ { v: onRoute[0], title: '시작' }, { v: onRoute[onRoute.length - 1], title: '끝' }, ]; for (const e of ends) { - const ch = e.v.chain; + const ch = e.v.viewChain; if (marks.some(m => Math.abs(m.km - ch) <= 100)) continue; const px = pxAtTime(e.v.time); if (px === null) continue; @@ -681,6 +793,22 @@ export function StationBar({ return marks; }; + // 1km(1+000) 단위 측점 마크 — 매 1000m 측점의 통과 위치에 측점값 라벨(위)만 표시. + // title 은 빈 문자열 → 하단 이름 라벨/그룹핑 제외(Timeline 에서 스킵). + // 기존 마크(시점·시설물 등)와 24px 이내로 겹치면 생략(라벨 충돌 방지). + const appendKmStations = (marks: StructMark[]): StructMark[] => { + if (!stationLine) return marks; + let lo = Infinity, hi = -Infinity; + for (const p of stationLine.pts) { if (p.km < lo) lo = p.km; if (p.km > hi) hi = p.km; } + for (let m = Math.ceil(lo / 1000) * 1000; m <= hi; m += 1000) { + for (const p of pxPassesAtMileage(m)) { + if (marks.some((x) => Math.abs(x.px - p.px) < 24)) continue; + marks.push({ px: p.px, title: '', category: '측점', km: m, kmExists: true }); + } + } + return marks; + }; + // 구조물 후보 POI(교량/터널/역사) base 이름 → 실좌표. const cats = new Set(['교량', '터널', '역사']); const poiByName = new Map(); @@ -744,12 +872,22 @@ export function StationBar({ // 일치(pxPassesAtMileage)'하는 통과에서만 마커를 찍는다. 그래야: // ① 좌표만 가까운(측점 다른) 조차장 재진입(드론 162k210인데 대전조차장 162k080)은 안 찍히고, // ② 진짜 162k080 통과에는 정상적으로 찍힌다. (교량/터널만 좌표근접 폴백 허용.) - if (!passes.length && cat !== '역사' && s.lat != null && s.lon != null) passes = pxPassesTo(s.lat, s.lon, off); - if (!passes.length && cat !== '역사' && match) passes = pxPassesTo(match.lat, match.lon, off); + // ※ station_lookahead(시선/오프셋) 활성 시 물리(드론 연직) 폴백 금지 — + // 구간 밖 시설물(예: 이전 세그먼트에서 이미 바라본 교량)이 물리 통과 위치로 + // 끌려 들어와 배지 기준(viewChain 범위)에 없는 값이 바에 찍히는 것을 방지. + // 해당 시설물은 자기 viewChain 범위를 가진 세그먼트의 바에서 정상 표시된다. + if (!lookActive) { + if (!passes.length && cat !== '역사' && s.lat != null && s.lon != null) passes = pxPassesTo(s.lat, s.lon, off); + if (!passes.length && cat !== '역사' && match) passes = pxPassesTo(match.lat, match.lon, off); + } if (!passes.length && s.startMileage != null && s.endMileage != null) { const mid = (s.startMileage + s.endMileage) / 2; - const px = pxAtMileage(mid); - if (px !== null) passes = [{ px, km: mid }]; + if (lookActive) { + passes = pxPassesAtMileage(mid); // 시선 기준 통과 — 범위 밖이면 미표시 + } else { + const px = pxAtMileage(mid); + if (px !== null) passes = [{ px, km: mid }]; + } } // 역사 km 라벨 = '역 좌표 자체'의 측점값(드론 최근접 프레임 chain 아님). // 역은 바 양끝에 스냅되므로, 시작/끝 커서값(= 역 위치 측점)과 일치시키기 위함. @@ -763,7 +901,7 @@ export function StationBar({ out.push({ px: p.px, title: s.name, category: cat, km: stationKmVal ?? p.km, kmExists, props: s.props }); } } - return appendEndpointStations(placeUnreachedTerminal(appendStartMark(out))); + return appendKmStations(appendEndpointStations(placeUnreachedTerminal(appendStartMark(out)))); } // 폴백: POI category 기반 (실좌표 GPS 근접, 모든 통과). @@ -782,7 +920,7 @@ export function StationBar({ out.push({ px: p.px, title: base, category: info.category, km: stationKmVal ?? p.km, kmExists }); } } - return appendEndpointStations(placeUnreachedTerminal(appendStartMark(out))); + return appendKmStations(appendEndpointStations(placeUnreachedTerminal(appendStartMark(out)))); // eslint-disable-next-line react-hooks/exhaustive-deps }, [ready, duration, storeFrames, pois, stations, pxAtTime, routeMeta, routeDirCh, storeStructures, pxAtMileage, endGapPx, stationLine, videoFps]); diff --git a/client/src/stationbar/components/Timeline/Timeline.tsx b/client/src/stationbar/components/Timeline/Timeline.tsx index 19b6326..451dd99 100755 --- a/client/src/stationbar/components/Timeline/Timeline.tsx +++ b/client/src/stationbar/components/Timeline/Timeline.tsx @@ -105,11 +105,13 @@ export function Timeline({ // 같은 구조물의 상/하/인상 변형(같은 base = 같은 좌표)은 '겹침제외 옵션과 무관하게' 진행방향 1개만. // (dir-우선 정렬돼 있어 방향 변형이 먼저 = 채택. 예: 법동가도교(하)/(인상) → (하) 1개) const byBase = new Map(); + const untitled: StructMark[] = []; // 이름 없는 마크(1km 단위 측점) — base dedupe 비대상 for (const it of ordered) { + if (!it.title) { untitled.push(it); continue; } const b = baseStruct(it.title); if (!byBase.has(b)) byBase.set(b, it); } - const keptOthers = [...byBase.values()]; + const keptOthers = [...byBase.values(), ...untitled]; // 라벨은 긴 이름 2줄(아래 splitStructLabel)로 폭을 줄여 표시. 그래도 가까워 겹치면 그대로 둠(엇갈림 없음). void overlapExclude; const structs = [...structures.filter(isStation), ...keptOthers].sort((a, b) => a.px - b.px); @@ -151,6 +153,7 @@ export function Timeline({ { const byTitle = new Map(); for (const s of structs) { + if (!s.title) continue; // 이름 없는 마크(1km 단위 측점 등) — 측점값 라벨만 표시 const a = byTitle.get(s.title); if (a) a.push(s); else byTitle.set(s.title, [s]); diff --git a/client/src/types/geo.ts b/client/src/types/geo.ts index 49b6718..572340c 100644 --- a/client/src/types/geo.ts +++ b/client/src/types/geo.ts @@ -142,6 +142,10 @@ export interface RouteInfo { ellipsoidal_height?: number; /** 개발 모드: 1(기본)=영상제어 버튼·제어 UI 표시, 0=숨김(뷰어 모드). */ dev_mode?: number; + /** 스테이션바 배지 전방 오프셋(m). 미설정/0=드론 연직 체이니지(기본, 기존 동작), + * N=진행방향 +N(m) 고정, "auto"=자동(짐벌 피치·AGL 기반 카메라 시선 중심). + * 활성 시 노선 밖(접근/이탈)에서는 시선 지점을 노선에 투영해 측점값 전환이 앞당겨진다. */ + station_lookahead?: number | string; /** 측점 인정 범위(m): station 위치를 이 거리 이내로 지날 때 마커 표시. 기본 40. 폴더별 조절. */ stationTolerance?: number; /** diff --git a/client/src/utils/geoProjection.ts b/client/src/utils/geoProjection.ts index a5f1de3..ef69564 100644 --- a/client/src/utils/geoProjection.ts +++ b/client/src/utils/geoProjection.ts @@ -35,6 +35,8 @@ export interface DroneFrameBasic { export interface CameraParams { timeOffsetSec?: number; // 영상↔비행로그 시간동기 보정(초). + = 로그를 뒤로 밀어 참조. // POI 가 진행방향으로 일괄 밀릴 때 조정 — "풀 붙임" 정합의 핵심 값. + attLeadSec?: number; // 자세(yaw/pitch/roll)만의 추가 시간 보정(초) — 짐벌 로그 기록 지연 보정. + // 영상이 좌우 팬(회전)할 때 라벨·선형이 늦게 따라오면 +, 앞서가면 −. yawOffset: number; // yaw 오프셋 (degrees, per-frame SRT yaw에 더함) pitch: number; // pitch 오프셋 (degrees, per-frame SRT pitch에 더함, 기본 0) roll: number; // roll 오프셋 (degrees, per-frame SRT roll에 더함, 기본 0) @@ -54,6 +56,7 @@ export interface CameraParams { /** Python advanced_tuner_v2.py 기본값 */ export const DEFAULT_CAMERA_PARAMS: CameraParams = { timeOffsetSec: 0, + attLeadSec: 0, yawOffset: 0, pitch: 0, // offset, Python spn_pitch 기본값 0 roll: 0, // offset, Python spn_roll 기본값 0 diff --git a/docs/history/2026-07-14_1759_전체-커밋-3차.md b/docs/history/2026-07-14_1759_전체-커밋-3차.md new file mode 100644 index 0000000..732a01e --- /dev/null +++ b/docs/history/2026-07-14_1759_전체-커밋-3차.md @@ -0,0 +1,22 @@ +# 2026-07-14 전체 커밋 (3차) — djmd 프레임 동기·오버레이 지면고정 + +**소요 시간**: 3분 +**Context 사용량**: input 7300k / output 2k tokens + +## 커밋 + +- **c64c57b** `djmd 프레임 동기·오버레이 지면고정·선형-측점 정합·UI 단축키` +- 36개 파일, +1248 / −56 +- 신규: client/src/utils/djmdTrack.ts (MP4 djmd 트랙 파서 + 자세 재정렬) +- docs/history 23건 포함 (7/13 저녁 ~ 7/14) + +## 주요 내용 + +djmd 프레임 동기 위치/고도, 자세 시간축 재정렬(-0.33s), 포즈 평활 제한(±5fr), +선형-측점 정점 삽입 정합, 측점 라벨 즉시 투영, 드론높이 모드 선형 통일, +offZ 잔재 교정, 로드 UX(정지 대기·UI 초기화), 측점 팝업 표고, 이중 라벨 제거, +측점 표기 0+000 전환, 양끝 시작/끝 스테이션, F2/A 단축키, 54000 포트 재부팅 자동화. + +## 다음 + +- push 는 사용자 요청 시 (GCM helper 연동됨 — `git push`) diff --git a/docs/history/2026-07-14_1835_배지-시선중심-체이니지-연동.md b/docs/history/2026-07-14_1835_배지-시선중심-체이니지-연동.md new file mode 100644 index 0000000..7987d01 --- /dev/null +++ b/docs/history/2026-07-14_1835_배지-시선중심-체이니지-연동.md @@ -0,0 +1,29 @@ +# 2026-07-14 스테이션바 현재값 — 드론 연직 → 카메라 시선 중심 체이니지 연동 + +**소요 시간**: 10분 +**Context 사용량**: input 7600k / output 3k tokens + +## 배경 (사용자 제보) + +배지(현재 스테이션)는 드론의 연직 투영 체이니지인데, 카메라는 피치 −15°로 전방 +(AGL 70m 기준 약 260m 앞)을 비추므로 화면 속 측점 라벨과 배지 값이 달라 혼동 유발. + +## 구현 — StationBar.tsx + +- ViewedPoint 에 **viewChain**(시선 중심 지면 교점의 체이니지) 추가: + - 전방 주시거리 = AGL / tan(|짐벌 피치|), 상한 350m (수평에 가까우면 상한 사용) + - AGL = 드론고도 − 지면고도(측점 z 의 체이니지 보간 — 동일 datum(타원체고)) + - 진행방향 = 카메라 yaw 벡터 · 노선 접선(+체이니지 방향) 내적 부호 → ±주시거리 + - 노선 양끝은 측점 범위로 클램프 +- 커서 배지(realChain)가 chain → **viewChain** 을 사용 +- 폴백: 측점 z 없는 데이터셋(z 프로파일 <2점)·고도/yaw 미상 → 기존 연직 체이니지 유지 +- 구간색·마크·이동거리축·노선밖 표기는 기존(물리 통과 기준) 그대로 + +## 효과 + +배지 값이 화면 중앙이 실제로 비추는 지점의 측점값과 일치 — 영상 속 측점 라벨과 동기화. + +## 검증 + +- tsc+vite 빌드 통과, 새 번들(index-DGLDr81A.js, 빌드 07.14 18:35) 서빙 +- 적용: F5 새로고침(빌드 시각 18:35 확인) → 폴더 재선택 diff --git a/docs/history/2026-07-14_1842_station-lookahead-설정.md b/docs/history/2026-07-14_1842_station-lookahead-설정.md new file mode 100644 index 0000000..e57e36c --- /dev/null +++ b/docs/history/2026-07-14_1842_station-lookahead-설정.md @@ -0,0 +1,27 @@ +# 2026-07-14 배지 전방 오프셋 route.json 설정화 — station_lookahead + +**소요 시간**: 6분 +**Context 사용량**: input 7900k / output 2k tokens + +## 요청 + +배지 시선 연동을 route.json 환경설정으로 — 0 이면 드론 연직, 100 이면 진행방향 +100m. + +## 구현 — StationBar.tsx, types/geo.ts + +route.json `routeInfo.station_lookahead` (m): + +| 값 | 동작 | +|----|------| +| `0` | 드론 바로 아래(연직 체이니지) — 종전 방식 | +| `N` (예: `100`) | 드론 체이니지 + 진행방향으로 N m 고정 오프셋 | +| 미설정 / `"auto"` | 자동 — 짐벌 피치·AGL 기반 카메라 시선 중심(직전 구현) | + +- 진행방향 = 카메라 yaw · 노선 접선 내적 부호 (후진 시 −N) +- 노선 체이니지 범위로 클램프(양끝 밖으로 안 나감), 숫자 문자열("100")도 허용 +- RouteMeta 타입에 station_lookahead 필드 문서화 + +## 검증 + +- tsc+vite 빌드 통과, 새 번들(index-BtY-C6rz.js, 빌드 07.14 18:42) 서빙 +- 적용: route.json 에 `"station_lookahead": 100` 추가 → F5 새로고침 → 폴더 재선택 diff --git a/docs/history/2026-07-14_1850_lookahead-미반영-진단-로그추가.md b/docs/history/2026-07-14_1850_lookahead-미반영-진단-로그추가.md new file mode 100644 index 0000000..b19f8fe --- /dev/null +++ b/docs/history/2026-07-14_1850_lookahead-미반영-진단-로그추가.md @@ -0,0 +1,28 @@ +# 2026-07-14 station_lookahead=100 미반영 제보 — 알고리즘 검증 + 모드 로그 추가 + +**소요 시간**: 7분 +**Context 사용량**: input 8200k / output 3k tokens + +## 제보 + +route.json 에 station_lookahead: 100 을 넣어도 배지가 0(드론 연직)처럼 보임. + +## 검증 + +- 사용자 route.json 확인: `"station_lookahead": 100` routeInfo 안에 정상 기재 ✓ +- parseRouteMeta 는 JSON 전체 보존(키 화이트리스트 없음) — 설정 전달 경로 정상 ✓ +- **알고리즘 실데이터 재현**: 경로 1-1 CSV 4개 시점(60/150/300/450s) 전부 + viewChain = chain **정확히 +100m** (진행방향 dot +0.94~0.97) — 코드 정상 +- 결론: 브라우저가 **이전 번들 실행 중**으로 판단(폴더 재선택은 데이터만 갱신, JS 는 F5 필요) + +## 조치 + +- 프리컴퓨트에 모드 확인용 콘솔 로그 추가: + `[스테이션바] 배지 오프셋: 고정 +100m (진행방향) | 0 (드론 연직) | 자동(카메라 시선 중심)` +- station_lookahead 를 routeInfo 밖(최상위)에 둔 경우도 인식하도록 허용 + +## 확인 절차 (사용자 안내) + +1. F5 새로고침 → 좌상단 빌드 시각 **07.14 18:50** 확인 +2. 폴더 재선택 → 콘솔(F12)에 `[스테이션바] 배지 오프셋: 고정 +100m` 로그 확인 +3. 배지가 드론 위치보다 100m 앞 측점값으로 표시되는지 확인 diff --git a/docs/history/2026-07-14_1902_노선밖-시선기준-전환.md b/docs/history/2026-07-14_1902_노선밖-시선기준-전환.md new file mode 100644 index 0000000..714f48e --- /dev/null +++ b/docs/history/2026-07-14_1902_노선밖-시선기준-전환.md @@ -0,0 +1,36 @@ +# 2026-07-14 접근 구간 '노선밖' — 시선 기준 전환 + 미설정 시 기존(연직) 기본값 + +**소요 시간**: 12분 +**Context 사용량**: input 8600k / output 4k tokens + +## 제보/요청 + +1. 접근 구간에서 화면은 이미 0+000~0+100 을 비추는데 배지는 "노선밖 229m" — + 바라보는 곳 기준이면 측점값이 나와야 하지 않나 +2. station_lookahead 는 **정의된 경우에만** 적용, 미정의면 **기존(드론 연직)** 동작 + +## 구현 — StationBar.tsx + +- **기본값 변경**: 미설정/0 → 드론 연직 체이니지(기존 동작 그대로). + 숫자 N → 진행방향 +N(m), `"auto"` → 짐벌 피치·AGL 기반 자동 (명시 시에만) +- **노선밖 판정도 시선 기준으로**(오프셋 활성 시): + - 노선 위: 노선을 따라 +N (곡선 안정, 기존 로직) + - 노선 밖(접근/이탈): 시선이 닿는 지점(직선 전방 N m)을 노선에 투영 — + 시선 지점 이격 ≤ 150m(광각 화면에 노선이 보이기 시작)면 측점값으로 전환, + 아니면 '노선밖 (시선 지점 이격)m' +- projectXYChainage 분리(평면좌표 투영 재사용), OFF_ROUTE_M 모듈 상수화, + ViewedPoint 에 viewOffM 추가(배지 전용 — 마크/구간색은 종전 드론 기준 유지) + +## 검증 (경로 1-1 실데이터, 고정 100m) + +| 드론 이격 | 배지 | +|-----------|------| +| 394→245m | 노선밖 299→152m (시선 지점 기준으로 기존보다 ~100m 작게) | +| 195→96m | **0+000** (화면에 노선 보임 → 조기 전환) | +| 노선 진입 후 | 0+104 → 0+308 → 0+707 (+100 유지) | + +- tsc+vite 빌드 통과, 새 번들(index-YV1lA2Bm.js, 빌드 07.14 19:02) 서빙 + +## 안내 + +- 스크린샷 시점(이격 229m)에 바로 측점값을 원하면 값을 200~300 또는 "auto" 권장 diff --git a/docs/history/2026-07-14_1907_시점마크-배지기준-통일.md b/docs/history/2026-07-14_1907_시점마크-배지기준-통일.md new file mode 100644 index 0000000..0d05b75 --- /dev/null +++ b/docs/history/2026-07-14_1907_시점마크-배지기준-통일.md @@ -0,0 +1,27 @@ +# 2026-07-14 배지 0+000 인데 시점 마크가 다른 위치 — 마크도 배지 기준(viewChain) 통일 + +**소요 시간**: 5분 +**Context 사용량**: input 9000k / output 2k tokens + +## 제보 + +배지는 0+000 인데 스테이션바의 시점(0+000) 마크는 더 오른쪽(다른 위치)에 표시. + +## 원인 + +배지는 시선/오프셋 기준(viewChain)으로 전환됐는데, 시점 마크·측점 기반 구조물 마크는 +여전히 **드론 물리 통과 시각**(chain) 기준 → 오프셋(예: 100m)만큼 시간차 발생. + +## 수정 — StationBar.tsx + +- pxPassesAtMileage(시점 마크·측점 기반 구조물 마크 공용)의 통과 탐지를 + chain/offM → **viewChain/viewOffM** 으로 전환 — 배지가 그 측점값을 표시하는 + 순간에 커서가 마크와 만난다 +- appendEndpointStations(양끝 시작/끝 스테이션)도 동일 기준으로 통일 +- station_lookahead 미설정이면 viewChain=연직 체이니지 → **기존 동작 그대로** + +## 검증 + +- tsc+vite 빌드 통과, 새 번들(index-qhnyNh_5.js, 빌드 07.14 19:07) 서빙 +- 적용: F5 새로고침(빌드 시각 19:07 확인) → 폴더 재선택 → + 배지가 0+000 이 되는 순간 커서가 시점 마크 위에 있는지 확인 diff --git a/docs/history/2026-07-14_1913_배지-연속모델-멈춤제거.md b/docs/history/2026-07-14_1913_배지-연속모델-멈춤제거.md new file mode 100644 index 0000000..5ed909b --- /dev/null +++ b/docs/history/2026-07-14_1913_배지-연속모델-멈춤제거.md @@ -0,0 +1,30 @@ +# 2026-07-14 시점 이후 스테이션 이동량 0 — 배지 연속 모델로 교체 + +**소요 시간**: 6분 +**Context 사용량**: input 9400k / output 3k tokens + +## 제보 + +시점(0+000) 마크부터 커서 위치까지 배지가 0+000 에 고정(이동량 0), 이후 +100 점프. + +## 원인 + +접근 구간에서 '시선 지점의 노선 투영'이 노선 시점(0+000)에 **클램프** — +시선이 노선 시작에 닿은 뒤 드론이 실제 진입할 때까지(~100m 비행) 배지가 0 에 정체, +진입 순간 노선따라 +100 으로 전환되며 점프. + +## 수정 — StationBar.tsx (연속 모델) + +- **유효 주시거리 = lookDist − max(0, 노선이격 − OFF_ROUTE_M)** + - 노선 위(이격 ≤ 60m): 초과분 0 → 항상 +lookDist (횡 이동 흔들림에도 안정) + - 접근/이탈: 이격이 줄수록 유효 주시거리 0 → lookDist 로 선형 증가 + - 유효 주시거리 < 0 이면 '노선밖 (남은거리+60)m' +- 배지가 0+000 을 지나는 순간부터 진입(+100)까지 **멈춤·점프 없이 연속** +- 시점 마크(pxPassesAtMileage, viewChain 기준)도 그 교차 순간에 찍혀 배지와 일치 + +## 검증 (경로 1-1, 고정 100m) + +노선밖 294→194→95m → 0+063 → 0+104 → 0+207 → 0+308 → … (단조 연속, 플랫 구간 없음) + +- tsc+vite 빌드 통과, 새 번들(index-ChxJN0eC.js, 빌드 07.14 19:13) 서빙 +- 적용: F5 새로고침(빌드 시각 19:13 확인) → 폴더 재선택 diff --git a/docs/history/2026-07-14_1929_시설물마크-옵션기준-통일.md b/docs/history/2026-07-14_1929_시설물마크-옵션기준-통일.md new file mode 100644 index 0000000..ed70b22 --- /dev/null +++ b/docs/history/2026-07-14_1929_시설물마크-옵션기준-통일.md @@ -0,0 +1,32 @@ +# 2026-07-14 스테이션바 시설물 마크 — station_lookahead 기준으로 완전 통일 + +**소요 시간**: 9분 +**Context 사용량**: input 9800k / output 3k tokens + +## 제보 + +배지(스테이션값)는 설정대로 나오는데, 스테이션바의 시설물(지장물) 마크 위치는 +드론 연직(물리 통과) 기준으로 보임 — 특히 두 번째 영상에서. + +## 원인 + +시설물 마크 배치는 다단 폴백 구조: +① 측점값 통과 창(pxPassesAtMileage — viewChain 기준으로 이미 전환) → +② **좌표 근접(pxPassesTo — 드론 GPS 물리 통과, 미전환)**. +세그먼트 시작 100m 안쪽 시설물은 viewChain(=연직+100)이 그 측점값을 커버하지 +않아 ①이 빈 결과 → ②(물리) 폴백으로 떨어져 배지와 기준이 섞임. + +## 수정 — StationBar.tsx pxPassesAtMileage + +- 오프셋 활성(viewChain≠chain 데이터 감지) 시 통과 창을 못 찾으면 + **viewChain 이 가장 가까워지는 프레임에 배치**(이격 150m 이내) — + 물리 폴백으로 빠지지 않음. 세그먼트 경계 시설물은 바 시작 부근에 클램프 +- 오프셋 미설정 데이터셋(회덕 등)은 기존 동작 그대로(감지 조건 불충족) +- 진단 로그 확장: `[스테이션바] 배지 오프셋: … | 표본 Δ+100m (N행)` — + 세그먼트마다 실제 적용량 확인 가능 + +## 검증 + +- tsc+vite 빌드 통과, 새 번들(index-mXKdnFF6.js, 빌드 07.14 19:29) 서빙 +- 적용: F5 새로고침(빌드 시각 19:29) → 폴더 재선택 → 두 번째 영상에서 + 커서가 시설물 마크에 닿을 때 화면 중앙(전방 100m)에 해당 시설물이 보여야 정상 diff --git a/docs/history/2026-07-14_1934_구간밖-시설물-마크-제거.md b/docs/history/2026-07-14_1934_구간밖-시설물-마크-제거.md new file mode 100644 index 0000000..937b399 --- /dev/null +++ b/docs/history/2026-07-14_1934_구간밖-시설물-마크-제거.md @@ -0,0 +1,34 @@ +# 2026-07-14 귀덕천교 1+110 이 세그2 바에 표시 — 구간 밖 시설물 마크 제거 + +**소요 시간**: 5분 +**Context 사용량**: input 10200k / output 3k tokens + +## 제보 (스크린샷) + +세그먼트 2 바에 귀덕천교(1+110)가 표시 — 현재 바의 스테이션 구간에 없는 값. + +## 데이터 확인 + +- 귀덕천교 물리 체이니지 1+102, 세그먼트 2 의 시선(viewChain) 범위 **1+177~1+608** +- 시선 기준으로 귀덕천교는 **세그먼트 1 에서 이미 바라본** 시설물 — + 세그2 바에는 없어야 정상 (세그1 바에서 viewChain 1+102 통과 시 정상 표시) + +## 원인 + +직전 수정의 '최근접 배치' 완화 + 물리(좌표 근접) 폴백이 구간 밖 시설물을 +현재 바로 끌어들임 (물리적으로는 드론이 세그2 초반에 귀덕천교 위를 지나므로). + +## 수정 — StationBar.tsx + +- '최근접 배치' 완화 제거 (viewChain 은 연속이라 범위 안 시설물은 항상 창에 잡힘 — + 완화가 구제하는 건 범위 밖뿐이므로 불필요·유해) +- **lookActive(오프셋 활성) 시 물리 폴백(pxPassesTo/pxAtMileage) 금지** — + 시설물 마크는 viewChain 기준으로만 배치, 범위 밖이면 미표시 + (이정값 전용 구조물은 pxPassesAtMileage 로 대체 시도) +- 오프셋 미설정 데이터셋(회덕 등)은 기존 폴백 체계 그대로 + +## 검증 + +- tsc+vite 빌드 통과, 새 번들(index-_1oNGZpr.js, 빌드 07.14 19:34) 서빙 +- 적용: F5 새로고침(빌드 시각 19:34) → 폴더 재선택 → + 세그2 바에서 귀덕천교 마크 사라지고, 세그1 바 끝부분(1+102 바라보는 시점)에 표시 diff --git a/docs/history/2026-07-14_1941_1km단위-측점-마크.md b/docs/history/2026-07-14_1941_1km단위-측점-마크.md new file mode 100644 index 0000000..48f16da --- /dev/null +++ b/docs/history/2026-07-14_1941_1km단위-측점-마크.md @@ -0,0 +1,23 @@ +# 2026-07-14 스테이션바 1km(1+000) 단위 측점 마크 표시 + +**소요 시간**: 7분 +**Context 사용량**: input 10600k / output 3k tokens + +## 요청 + +1+000 단위 스테이션마다 스테이션바에 표시. + +## 구현 + +- StationBar: appendKmStations — 측점 범위 내 매 1000m 값을 pxPassesAtMileage + (배지와 동일한 viewChain 기준)로 통과 위치에 배치. 측점값 라벨(위)만 표시 + (title 빈 문자열), 기존 마크와 24px 이내 겹치면 생략 +- Timeline: 빈 title 마크는 하단 이름 라벨/그룹핑·base dedupe 에서 제외 + (측점값 라벨과 눈금만 표시), 기존 겹침 숨김 로직은 그대로 적용 +- 세그먼트별로 해당 구간에 포함된 1km 측점만 표시 (viewChain 범위 기준) + +## 검증 + +- tsc+vite 빌드 통과, 새 번들(index-qkCcaENF.js, 빌드 07.14 19:41) 서빙 +- 적용: F5 새로고침(빌드 시각 19:41) → 폴더 재선택 → + 바에 1+000, 2+000 … 값이 통과 위치마다 표시되는지 확인 diff --git a/docs/history/2026-07-14_1958_경로2-노선밖149m-진단.md b/docs/history/2026-07-14_1958_경로2-노선밖149m-진단.md new file mode 100644 index 0000000..b25470f --- /dev/null +++ b/docs/history/2026-07-14_1958_경로2-노선밖149m-진단.md @@ -0,0 +1,29 @@ +# 2026-07-14 경로 2 '노선밖 149m' 진단 — 실제 대이탈 구간(최대 390m) + +**소요 시간**: 9분 +**Context 사용량**: input 11200k / output 3k tokens + +## 질문 (스크린샷: 바 4+000~5+510 '끝', 배지 '노선밖 149m') + +스테이션 구간인데 왜 노선밖 149m 인가? + +## 진단 (데이터 재현) + +- 해당 화면은 **경로 2** (비행 chain 3+046~6+334, lookahead 240 설정) +- 경로 2 비행경로는 **chain 4+15x ~ 5+52x 구간에서 측점 선형을 크게 벗어남** + (t=116s 이탈 시작 → 최대 이격 390m(t=164s) → t=289s 복귀) +- 배지 계산 재현: t=164s 이격 390m → 유효 주시거리 240−(390−60)=−90 → + '노선밖 60+90=**150m**' — 스크린샷의 149m 와 일치 ✓ +- 즉 오작동이 아니라 **드론(기존 도로)이 설계 선형에서 실제로 390m 떨어진 구간** — + 체이니지 범위(스테이션 구간) 안이라도 노선밖 판정은 '선형과의 횡거리' 기준 +- 설계 선형이 기존 도로와 다른 신설 구간(선형이 밭/온실을 가로지르는 것과 동일 맥락) + +## 관찰 (참고) + +- 이탈 구간 양끝에서 배지가 4+374 → (노선밖) → 5+433 으로 빠르게 건너뜀 — + 우회 도로를 나는 동안 선형 투영이 굴곡부를 가로질러 이동하기 때문(데이터 특성) + +## 조치 + +- 코드 변경 없음 (정상 동작). 원하면 이탈 구간에서도 최근접 측점값을 + 회색 등 다른 스타일로 계속 표시하는 옵션 구현 가능 — 사용자 판단 대기 diff --git a/docs/history/2026-07-14_2004_팬-회전-지연-이질감-보정.md b/docs/history/2026-07-14_2004_팬-회전-지연-이질감-보정.md new file mode 100644 index 0000000..d81b9c0 --- /dev/null +++ b/docs/history/2026-07-14_2004_팬-회전-지연-이질감-보정.md @@ -0,0 +1,34 @@ +# 2026-07-14 좌우 팬(회전) 시 오버레이 지연 이질감 — POI EMA 우회 + 자세 선행 보정 + +**소요 시간**: 12분 +**Context 사용량**: input 11800k / output 4k tokens + +## 제보 + +드론이 좌우로 회전(팬)할 때 노선·지장물이 뒤늦게 따라와 이질감. + +## 원인 (2중) + +1. **지장물(POI) 라벨의 화면 EMA 잔존** — 측점 라벨은 즉시 투영으로 바꿨지만(13:24) + POI 라벨은 속도적응 EMA(smoothStep)를 계속 사용 → 팬 시 수 프레임 지연 +2. **짐벌 자세 로그의 기록 지연** — 위치 교차상관 dt(−0.33s)로도 짐벌 yaw 고유의 + 내부 지연은 보정 안 됨 → 선형(EMA 없음)까지 함께 늦는 공통 원인 + +## 수정 + +- StationOverlay: **frameSynced(djmd) 데이터면 POI 라벨도 즉시 투영** (EMA 우회, + 0.75px 히스테리시스 유지) — 선형·측점·POI 모두 동일 타이밍 +- poseAt: **자세 선행(attLeadSec)** — 위치는 estFrame 그대로, 자세(yaw/pitch/roll)만 + attLeadSec 초 앞선 시점에서 샘플(짐벌 로그 지연 상쇄) +- CameraParams.attLeadSec 추가(기본 0) — camera.json 자동 저장/로드(숫자 통과), + 카메라 파라미터 패널에 '자세 선행' 슬라이더(−1~+1s, 0.01 스텝, 시간동기 아래) + +## 튜닝 방법 (사용자 안내) + +- 좌우로 회전하는 구간 재생 → '자세 선행'을 +0.10~0.30 범위에서 조금씩 올리며 + 라벨·선형이 영상과 같이 붙어 도는 값을 찾기 (앞서가면 −로) +- 찾은 값은 'PC 저장'으로 camera.json 에 보존 + +## 검증 + +- tsc+vite 빌드 통과, 새 번들(index-BvoVCM-N.js, 빌드 07.14 20:04) 서빙 diff --git a/docs/history/2026-07-14_2013_senH-최대값-100.md b/docs/history/2026-07-14_2013_senH-최대값-100.md new file mode 100644 index 0000000..abfbf95 --- /dev/null +++ b/docs/history/2026-07-14_2013_senH-최대값-100.md @@ -0,0 +1,17 @@ +# 2026-07-14 sen H(센서 높이) 슬라이더 최대값 100mm 확장 + +**소요 시간**: 2분 +**Context 사용량**: input 12200k / output 1k tokens + +## 요청 + +카메라 파라미터 'sen H' 최대값을 100 까지. + +## 수정 — StationOverlay.tsx + +- sen H ParamRow: max 36 → **100** (min 6, step 0.05 유지) + +## 검증 + +- tsc+vite 빌드 통과, 새 번들(index-D27TXlz-.js, 빌드 07.14 20:13) 서빙 +- 적용: F5 새로고침(빌드 시각 20:13) 후 카메라 파라미터에서 확인 diff --git a/docs/history/2026-07-14_2022_폴더별-카메라-화면설정-산출.md b/docs/history/2026-07-14_2022_폴더별-카메라-화면설정-산출.md new file mode 100644 index 0000000..6ec9a1f --- /dev/null +++ b/docs/history/2026-07-14_2022_폴더별-카메라-화면설정-산출.md @@ -0,0 +1,35 @@ +# 2026-07-14 지도 기준 맵핑 — 경로 1-2/1-3/2 카메라·화면표시 설정값 데이터 산출 + +**소요 시간**: 10분 +**Context 사용량**: input 12600k / output 4k tokens + +## 요청 + +지도(측점 5+800~6+100, 상가교차로)처럼 영상에 지장물이 맵핑되도록 +카메라·화면표시 옵션 설정값을 찾아 적용. + +## 방법 — 데이터 기반 산출 + +- **yawOffset**: 비행 트랙 방위 − 짐벌 방위의 원형 평균(이동 중 표본만). + 경로 1-1 검증: 추정 −14.0° vs 사용자 보정값 −13.6° → **0.4° 이내 일치**(방법 검증). + 짐벌 방위 편차는 비행마다 다름(자기 간섭·IMU 초기화) → 폴더별 산출 필요 +- **경로표고(dronePathZ)**: 각 폴더 비행 구간 측점 z 의 중앙값 +- 내부 파라미터(초점 29.9 / 센서 27.45×20.85 / 각종 오프셋 0)는 + 같은 카메라(ZenmuseP1)라 1-1 검증값 그대로 + +## 적용 (백업 .bak2) + +| 폴더 | yawOffset | 경로표고 | 비고 | +|------|-----------|---------|------| +| 경로 1-2 | −15.6° | 64m | 기존 offZ 0.6 등 잔재 제거 | +| 경로 1-3 | **+3.3°** | 101m | 기존 offZ 21.8 잔재 제거 — 부호 반대 편차였음 | +| 경로 2 | −9.8° | 70m | 기존 offZ 21.8 잔재 제거 | + +- camera.json/display.json 모두 새 값으로 교체(각 .bak2 백업), route.json 은 유지 +- display: 드론높이 OFF·표시거리 400m·선형/궤적 ON 등 1-1 구성 복제 + +## 확인/미세조정 안내 + +- F5 새로고침 → 해당 폴더 재선택 → 지도의 5+800~6+100·상가교차로 위치와 대조 +- 남는 미세 오차: 좌우 일괄 → yawOffset ±1° / 상하 원거리 → Pitch ±0.5° / + 회전 시 지연 → 자세 선행 +0.1~0.3 / 확정 후 'PC 저장' diff --git a/docs/history/2026-07-14_2031_경로1-3-카메라-기존복원.md b/docs/history/2026-07-14_2031_경로1-3-카메라-기존복원.md new file mode 100644 index 0000000..efdbb34 --- /dev/null +++ b/docs/history/2026-07-14_2031_경로1-3-카메라-기존복원.md @@ -0,0 +1,31 @@ +# 2026-07-14 경로 1-3 산출 설정이 더 안 맞음 — 기존 시각 보정값 복원 + +**소요 시간**: 9분 +**Context 사용량**: input 13200k / output 3k tokens + +## 제보 (스크린샷: 5+500~5+700) + +산출 설정(yaw +3.3, 표준 센서) 적용 후 기존보다 더 안 맞음. + +## 분석 + +- 노선 위 구간 한정 재산출도 +3.1° — 이탈 구간 오염이 아니라 + **1-3 은 카메라가 트랙과 나란하지 않은(도로가 화면 우측) 비행**이라 + '트랙=카메라' 가정 자체가 성립하지 않음 (1-1/1-2 는 −13.9/−14.1 로 일치) +- 사용자가 '백업' 폴더로 옮겨둔 기존 설정 확인: **yaw +4.3, pitch −12.9, + 센서 46.9×36, cy0 −0.01** — 시각 튜닝으로 찾은 조합(광화각+피치 보정이 한 몸) +- 결론: 데이터 산출은 카메라·짐벌이 '표준 자세'일 때만 유효 — 1-3 같은 + 변칙 촬영은 시각 튜닝 조합이 우선 + +## 조치 + +- 경로 1-3 camera.json 을 기존 값으로 복원 (+ timeOffsetSec/attLeadSec 0 추가, + offZ 는 0 유지 — 기존에도 0) +- display.json 은 산출본 유지(경로표고 101m 등) — 표고 데이터 기준이라 무해 +- 경로 1-2(−15.6)/경로 2(−9.8) 산출본은 유지 — 확인 후 안 맞으면 각각 + .bak2(1-2: 폴더 내 / 2: 폴더 내)로 복원 가능 + +## 확인 + +- F5 새로고침 → 경로 1-3 재선택 → 5+500~5+700 구간과 도로 정합 확인 +- 미세조정: 좌우 → yawOffset, 원거리 상하 → pitch, 회전 지연 → 자세 선행