스테이션바 시선 연동(station_lookahead)·팬 지연 보정·1km 측점 마크

- station_lookahead(route.json): 배지 전방 오프셋 — 미설정/0=드론 연직(기존),
  N=진행방향 +N(m), "auto"=짐벌 피치·AGL 기반 자동. 연속 모델(유효 주시거리 =
  N − 노선이격 초과분)로 접근 구간에서 멈춤·점프 없이 측점값 연속 진행
- 노선밖 판정도 시선 기준: 화면이 노선을 비추기 시작하면 배지가 측점값으로
  조기 전환, '노선밖 ○m' 도 시선 지점 기준
- 시점·시설물 마크와 배지 기준 통일(viewChain): 오프셋 활성 시 물리(드론 연직)
  폴백 금지 — 구간 밖 시설물은 해당 세그먼트 바에 미표시(귀덕천교 사례),
  세그먼트별 진단 로그(표본 Δ) 추가
- 스테이션바 1km(1+000) 단위 측점 마크: 측점값 라벨만 표시(이름 라벨·그룹핑 제외),
  기존 마크와 겹침 시 생략 + 양끝 100m 내 시설물 없으면 시작/끝 스테이션 표시
- 팬(회전) 지연 이질감 보정: 프레임 동기 데이터의 POI 라벨 화면 EMA 우회(즉시 투영)
  + 자세 선행(attLeadSec) 파라미터 — 짐벌 로그 기록 지연 상쇄(위치는 유지,
  자세만 앞선 시점 참조), 카메라 패널 슬라이더(-1~+1s)
- 배지 시선 중심 자동 모드: AGL/tan|짐벌피치| 전방 주시거리(상한 350m),
  진행방향 = 카메라 yaw·노선 접선 내적
- 기타: sen H 최대 100mm, F2 개발자 모드 토글(직전 커밋 누락분 아님 — 이번 범위)
- 데이터 작업(코드 외): 경로 1-2/1-3/2 카메라·화면설정 산출 적용(yawOffset 실측,
  경로표고 중앙값) 및 1-3 기존 시각보정 복원 — docs/history 참조
- docs/history: 작업 이력 15건

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 20:43:00 +09:00
co-authored by Claude Fable 5
parent c64c57b58e
commit ca591ce58c
20 changed files with 639 additions and 43 deletions
@@ -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
</div>
<div className="space-y-2 mb-3">
<ParamRow label="시간동기" tip="영상↔비행로그 시각 오프셋(초). POI 전체가 진행방향으로 밀리면 조정 (+: 라벨을 진행방향 앞으로 / −: 뒤로). '풀 붙임' 정합의 핵심 — camera.json 에 저장됨" value={params.timeOffsetSec ?? 0} min={-2} max={2} step={0.05} unit="s" decimals={2} onChange={v => setParam('timeOffsetSec', v)} />
<ParamRow label="자세 선행" tip="짐벌 자세 로그의 기록 지연 보정(초). 영상이 좌우로 팬(회전)할 때 라벨·선형이 늦게 따라오면 +, 앞서가면 −. 위치는 그대로 두고 자세만 앞선 시점을 참조 — camera.json 에 저장됨" value={params.attLeadSec ?? 0} min={-1} max={1} step={0.01} unit="s" decimals={2} onChange={v => setParam('attLeadSec', v)} />
<ParamRow label="Yaw ±" value={params.yawOffset} min={-180} max={180} step={0.1} unit="°" decimals={1} onChange={v => setParam('yawOffset', v)} />
{/* Yaw 자동 추정(2단계 보정의 1단계) — 대략 맞춘 뒤 'Yaw 보정' 드래그 모드로 마무리 */}
<button
@@ -2219,7 +2233,7 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
<ParamRow label="cx₀" value={params.cx0} min={-0.5} max={0.5} step={0.005} unit="" decimals={3} onChange={v => setParam('cx0', v)} />
<ParamRow label="cy₀" value={params.cy0} min={-0.5} max={0.5} step={0.005} unit="" decimals={3} onChange={v => setParam('cy0', v)} />
<ParamRow label="sen W" value={params.sensorW} min={10} max={50} step={0.05} unit="mm" decimals={2} onChange={v => setParam('sensorW', v)} />
<ParamRow label="sen H" value={params.sensorH} min={6} max={36} step={0.05} unit="mm" decimals={2} onChange={v => setParam('sensorH', v)} />
<ParamRow label="sen H" value={params.sensorH} min={6} max={100} step={0.05} unit="mm" decimals={2} onChange={v => setParam('sensorH', v)} />
</div>
{panelDroneFrame && (
<div className="border-t border-gray-700 pt-2 mb-2 text-[10px] text-gray-400 font-mono space-y-0.5">