djmd 프레임 동기·오버레이 지면고정·선형-측점 정합·UI 단축키
- djmd(영상 내장 텔레메트리) 프레임 동기 위치/고도: MP4 stbl 선별 파싱(djmdTrack.ts), CSV 시각정렬 오차(평균 3.2m) 제거 — 세그먼트별 백그라운드 정밀화(frameSynced 플래그) - 자세 시간축 재정렬: djmd 위치 교차상관으로 CSV↔영상 오프셋 자동 추정(실측 -0.33s, 잔차 0.24m) 후 yaw/pitch/roll 재샘플(yaw 최단각) — 옆 지물 미끄러짐 해소 - 포즈 평활 자동 제한: 프레임 동기 데이터는 ±5프레임 상한(기존 ±60=±1s) — 회전 시 오버레이 전체가 드론에 딸려오는 지연 제거 - 선형-측점 정합: 측점 위치를 선형 정점으로 삽입(3D 이격≤0.012m) + 정점 z 를 측점 체이니지 보간으로 재정렬, 측점 라벨 화면 EMA 제거(선과 동일한 즉시 투영) — 선형이 측점 POI 를 픽셀 단위로 통과 - 드론높이 모드 높이 규칙 통일: 선형도 '드론고도-이격거리' 평면 적용(라벨과 동일) - 카메라 offZ 잔재 교정: 표고 체계 정비 이전 드래그 보정값(-18.3m)이 이중 보정이 되어 원거리 POI 가 접근할수록 밀리던 원인 — 경로 1-1 camera.json 0 으로 교정(.bak 백업) - 로드 UX: 폴더 로드 시 정지 상태로 대기(처음부터), 데이터 교체 시 UI 초기 상태 복원 - 라벨: 측점 팝업 표고(타원체고) 행 추가, 구조물-POI 이중 라벨 제거(어음천교), 시간동기 보정 슬라이더(timeOffsetSec) - 측점 표기 0+000 형식 전환(생성 6곳·파서 4곳 신구 호환) + 스테이션바 양끝 100m 내 시설물 없으면 시작/끝 스테이션 마크 - 단축키: F2 개발자 모드(dev_mode 1↔0, 세션 한정), A 선형 표시 토글 - 인프라: 54000 포트 재부팅 자동화(portproxy 직접 IP + 로그온 갱신, pm2 resurrect) - docs/history: 작업 이력 23건 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -38,6 +38,10 @@ const POI_MERGE_Y = 0.035;
|
||||
|
||||
type DispPos = { x: number; y: number; rej: number; vx: number; vy: number; rx?: number; ry?: number };
|
||||
|
||||
// djmd 프레임 동기 데이터일 때 포즈 평활 상한(±프레임). 위치·자세가 이미 매끄러워
|
||||
// 긴 평활(기본 60 ≈ ±1s)은 회전 시 포즈 지연 → 오버레이 전체가 드론에 '딸려오는' 원인.
|
||||
const POSE_SMOOTH_SYNCED_MAX = 5;
|
||||
|
||||
// 속도 적응형 평활(One Euro 방식) 상수.
|
||||
// 떨림(노이즈)은 매 프레임 방향이 번갈아 → 평활속도≈0 → 강하게 평활(1배속에서 안정).
|
||||
// 실제 이동은 방향이 일관 → 평활속도 큼 → alpha↑로 즉시 추종(빠른 배속에서 지연 없음).
|
||||
@@ -194,7 +198,7 @@ const orderedFullFields = (
|
||||
): { k: string; v: string }[] => [...compact, ...props.filter((p) => !isCompactKey(p.k))];
|
||||
|
||||
function stationOrder(title: string): number {
|
||||
const m = title.match(/(\d+)[Kk](\d+)/);
|
||||
const m = title.match(/(\d+)\s*[Kk+]\s*(\d+)/);
|
||||
if (!m) return 0;
|
||||
return parseInt(m[1]) * 1000 + parseInt(m[2]);
|
||||
}
|
||||
@@ -534,6 +538,13 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
const storePois = useGeoStore(s => s.pois);
|
||||
const storeCenterline = useGeoStore(s => s.centerline);
|
||||
const storeFrames = useGeoStore(s => s.frames);
|
||||
const storeFrameSynced = useGeoStore(s => s.frameSynced);
|
||||
const frameSyncedRef = useRef(false);
|
||||
useEffect(() => { frameSyncedRef.current = storeFrameSynced; }, [storeFrameSynced]);
|
||||
// 포즈 평활 유효 반폭 — 프레임 동기(djmd) 데이터는 상한 제한(회전 시 포즈 지연 제거).
|
||||
const poseHalf = useCallback((): number =>
|
||||
frameSyncedRef.current ? Math.min(smoothHalfRef.current, POSE_SMOOTH_SYNCED_MAX) : smoothHalfRef.current,
|
||||
[]);
|
||||
const storeOrigin = useGeoStore(s => s.origin);
|
||||
const storeStructures = useGeoStore(s => s.structures);
|
||||
const routeMeta = useGeoStore(s => s.routeMeta);
|
||||
@@ -799,18 +810,23 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
// 구조물(교량/터널/구교)을 영상 오버레이에 POI처럼 표시(거리필터 적용).
|
||||
// - 영상은 시설등급 무관 모두 표출(신대천교(복) 등 포함). 스테이션바엔 시설등급 필터로 별도 제어.
|
||||
// lat/lon 없는 항목(측점기반 배치 등)도 제외.
|
||||
// 신포맷(지장물.kml '타입') 교량/터널은 POI 로도 등록됨 — 같은 이름이 POI 에 있으면
|
||||
// 영상 라벨이 이중 표출(파란 POI + 빨간 구조물, z 도 달라 위치 분리)되므로 구조물 쪽 제외.
|
||||
// (스테이션바 구조물 마크는 storeStructures 직접 사용이라 영향 없음)
|
||||
useEffect(() => {
|
||||
const poiTitles = new Set(storePois.map(p => p.title));
|
||||
allStructuresRef.current = storeStructures
|
||||
.filter((s): s is RouteStructure & { lat: number; lon: number } =>
|
||||
s.category != null && OVERLAY_STRUCT_CATEGORIES.has(s.category) &&
|
||||
typeof s.lat === 'number' && typeof s.lon === 'number')
|
||||
typeof s.lat === 'number' && typeof s.lon === 'number' &&
|
||||
!poiTitles.has(s.name))
|
||||
.map(s => ({
|
||||
title: s.name,
|
||||
category: s.category ?? '구교',
|
||||
lat: s.lat, lon: s.lon, z: 0, type: 'poi' as const,
|
||||
props: s.props,
|
||||
}));
|
||||
}, [storeStructures]);
|
||||
}, [storeStructures, storePois]);
|
||||
|
||||
// 중심선
|
||||
useEffect(() => {
|
||||
@@ -935,8 +951,14 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
const sq = Math.sqrt(disc);
|
||||
return [(-b - sq) / (2 * a), (-b + sq) / (2 * a)].filter((t) => t >= 0 && t <= 1);
|
||||
};
|
||||
// 드론높이 모드: 측점/POI 라벨과 동일한 '드론고도 − 이격거리' 평면에 투영 —
|
||||
// 옵션 적용 시 라벨만 이동하고 선형이 지형 z 에 남아 서로 분리되는 것을 방지
|
||||
// (측점 POI 와 빨간 선형이 항상 같은 선상에 놓이도록 높이 규칙 통일).
|
||||
const planeZ = poiDroneHeightRef.current
|
||||
? drone.altitude - (params.geoidOffset ?? 0) - droneHeightDropRef.current
|
||||
: null;
|
||||
const projCL = (lat: number, lon: number, z: number): CameraCoords =>
|
||||
toCameraCoords(drone, lat, lon, z > 0 ? z : CL_Z, params, worldOrigin);
|
||||
toCameraCoords(drone, lat, lon, planeZ ?? (z > 0 ? z : CL_Z), params, worldOrigin);
|
||||
for (let i = 1; i < allCL.length; i++) {
|
||||
const p0 = allCL[i - 1], p1 = allCL[i];
|
||||
const e0 = enOf(p0), e1 = enOf(p1);
|
||||
@@ -999,7 +1021,7 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
const i2 = lo, i1 = Math.max(0, lo - 1);
|
||||
const f1 = arr[i1].frame, f2 = arr[i2].frame;
|
||||
const frac = f2 > f1 ? Math.min(1, Math.max(0, (estFrame - f1) / (f2 - f1))) : 0;
|
||||
const h = smoothHalfRef.current;
|
||||
const h = poseHalf();
|
||||
const a = smoothFrame(arr, i1, h), b = smoothFrame(arr, i2, h);
|
||||
const L = (x: number, y: number) => x + (y - x) * frac;
|
||||
let dy = b.yaw - a.yaw; dy = ((dy + 540) % 360) - 180; // 최단각
|
||||
@@ -1010,7 +1032,7 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
pitch: L(a.pitch, b.pitch), roll: L(a.roll, b.roll),
|
||||
yaw: a.yaw + dy * frac,
|
||||
};
|
||||
}, [smoothFrame]);
|
||||
}, [smoothFrame, poseHalf]);
|
||||
|
||||
// 텍스트 사전 계산 — requestIdleCallback으로 백그라운드 실행
|
||||
const startLabelPrecompute = useCallback((currentParams: CameraParams, currentSmoothHalf: number, currentMaxRange: number, currentDroneHeight: boolean, currentDroneDrop: number) => {
|
||||
@@ -1056,11 +1078,14 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
});
|
||||
const gzOf = new Map<string, number>();
|
||||
for (const poi of allPoi) {
|
||||
// 지면고도 gz: 보정값(DEM/드래그) → 최근접 선로표고(유효값) → POI 자체 z → 경로표고 앵커.
|
||||
// 지면고도 gz 우선순위: 보정값(드래그) → **POI 자체 z(실측)** → 중심선 z → 경로표고 앵커.
|
||||
// 종전엔 중심선 스냅이 POI z 보다 우선 — 도로에서 떨어진 지물이 도로 높이에 붙어
|
||||
// 원거리에서 계속 밀리다 근거리에서만 맞는 증상의 원인. 실측 z 보유 데이터셋 기준으로 교정.
|
||||
const clz = nearestCL(poi.lat, poi.lon)?.z;
|
||||
gzOf.set(poi.title, overridesRef.current[poi.title]?.z
|
||||
?? (poi.z > 0 ? poi.z : undefined)
|
||||
?? (clz !== undefined && clz > 0 ? clz : undefined)
|
||||
?? (poi.z > 0 ? poi.z : PATH_Z));
|
||||
?? PATH_Z);
|
||||
}
|
||||
|
||||
const t0 = performance.now();
|
||||
@@ -1079,7 +1104,8 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
while (processed < end) {
|
||||
const idx = (startIdx + processed) % total; // startIdx→끝→0→… 순환
|
||||
processed++;
|
||||
const drone = smoothFrame(frames, idx, SMOOTH_HALF);
|
||||
const drone = smoothFrame(frames, idx,
|
||||
frameSyncedRef.current ? Math.min(SMOOTH_HALF, POSE_SMOOTH_SYNCED_MAX) : SMOOTH_HALF);
|
||||
|
||||
const stationLabels: LabelCache['stationLabels'] = [];
|
||||
for (const sp of snappedSt) {
|
||||
@@ -1232,7 +1258,7 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
const p = paramsRef.current;
|
||||
const fr = allDroneFramesRef.current;
|
||||
const d = fr.length
|
||||
? smoothFrame(fr, currentFrameIdxRef.current, smoothHalfRef.current)
|
||||
? smoothFrame(fr, currentFrameIdxRef.current, poseHalf())
|
||||
: cur;
|
||||
compassRef.current = {
|
||||
effectiveYaw: d.yaw + p.yawOffset,
|
||||
@@ -1246,7 +1272,7 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
if (!currentDroneFrameRef.current) { renderCacheRef.current = null; return; }
|
||||
const frames = allDroneFramesRef.current;
|
||||
const drone = frames.length
|
||||
? smoothFrame(frames, currentFrameIdxRef.current, smoothHalfRef.current)
|
||||
? smoothFrame(frames, currentFrameIdxRef.current, poseHalf())
|
||||
: currentDroneFrameRef.current;
|
||||
const params = paramsRef.current;
|
||||
renderCacheRef.current = {
|
||||
@@ -1323,14 +1349,17 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
if (cache) {
|
||||
|
||||
// 부드러운 시간(timeRef, 60fps 보간) → 연속 프레임번호. 선/궤적·라벨 공통 사용.
|
||||
const estTime = timeRef
|
||||
// 시간동기(timeOffsetSec): 영상↔비행로그의 시각 오프셋 보정 — POI 가 진행방향으로
|
||||
// 일괄 밀릴 때 조정(+ = 로그를 뒤로, − = 앞으로). camera.json 에 저장되는 보정값.
|
||||
const estTime = (timeRef
|
||||
? timeRef.current
|
||||
: currentTimeSecRef.current + (performance.now() - timeUpdateWallRef.current) / 1000;
|
||||
: currentTimeSecRef.current + (performance.now() - timeUpdateWallRef.current) / 1000)
|
||||
+ (paramsRef.current.timeOffsetSec ?? 0);
|
||||
const estFrame = estTime * fpsRef.current;
|
||||
|
||||
// 연속 보간 포즈(라인·라벨 공통) — 매 RAF 프레임 직접 투영해 부드럽게.
|
||||
const dronePose = poseAt(estFrame) ?? (allDroneFramesRef.current.length
|
||||
? smoothFrame(allDroneFramesRef.current, currentFrameIdxRef.current, smoothHalfRef.current)
|
||||
? smoothFrame(allDroneFramesRef.current, currentFrameIdxRef.current, poseHalf())
|
||||
: currentDroneFrameRef.current!);
|
||||
const lines = buildLines(dronePose);
|
||||
|
||||
@@ -1453,7 +1482,10 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
if (cc.Zc < CLIP_Z) return;
|
||||
const { pxRaw, pyRaw } = pixelFromCamera(cc, params);
|
||||
const prevDst = displayedStRef.current.get(stA.title);
|
||||
const d = smoothStep(prevDst, pxRaw, pyRaw, α, smoothMinAlphaRef.current, smoothSpeedRefRef.current);
|
||||
// 측점 라벨은 선형 실선과 픽셀 단위로 붙어 다녀야 하므로 화면 EMA 없이 즉시 투영
|
||||
// (실선도 매 프레임 즉시 투영 — djmd 프레임 동기 이후 입력이 매끄러워 평활 불필요.
|
||||
// 반올림 떨림은 아래 0.75px 히스테리시스가 계속 흡수).
|
||||
const d: DispPos = { x: pxRaw, y: pyRaw, rej: 0, vx: 0, vy: 0 };
|
||||
seenSt.add(stA.title);
|
||||
// 픽셀 히스테리시스: 0.75px 이상 움직일 때만 정수 위치 갱신 → 저속(1배속) 반올림 깜빡임 제거.
|
||||
const sx0 = vx(d.x), sy0 = vy(d.y);
|
||||
@@ -1636,9 +1668,9 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
// 현재 프레임의 평활 드론 자세 (역투영 기준)
|
||||
const getSmoothedDrone = useCallback((): DroneFrameBasic | null => {
|
||||
const frames = allDroneFramesRef.current;
|
||||
if (frames.length) return smoothFrame(frames, currentFrameIdxRef.current, smoothHalfRef.current);
|
||||
if (frames.length) return smoothFrame(frames, currentFrameIdxRef.current, poseHalf());
|
||||
return currentDroneFrameRef.current;
|
||||
}, [smoothFrame]);
|
||||
}, [smoothFrame, poseHalf]);
|
||||
|
||||
// 화면 클릭좌표(client px) → 영상프레임 정규좌표(0~1). object-fit:cover 역변환(coverRef).
|
||||
const clientToVideoNorm = useCallback((clientX: number, clientY: number): { x: number; y: number } => {
|
||||
@@ -1676,7 +1708,7 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
if (!hit) return;
|
||||
const poi = allPoisRef.current.concat(allStructuresRef.current).find(p => p.title === hit.title);
|
||||
if (!poi) return;
|
||||
const baseZ = nearestCL(poi.lat, poi.lon)?.z ?? poi.z;
|
||||
const baseZ = (poi.z > 0 ? poi.z : nearestCL(poi.lat, poi.lon)?.z) ?? poi.z; // 실측 z 우선(가시 gz 와 동일 규칙)
|
||||
const poiZ = overridesRef.current[hit.title] ? poi.z : baseZ;
|
||||
setSelPoi(hit.title); setSelZ(poiZ); setSelZBase(baseZ);
|
||||
dragRef.current = { title: hit.title, lat: poi.lat, lon: poi.lon, z0: poiZ, sx: nx, sy: ny };
|
||||
@@ -1709,7 +1741,7 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
? allPoisRef.current.concat(allStructuresRef.current).find(p => p.title === hit.title)
|
||||
: allGeoStationsRef.current.find(s => s.title === hit.title);
|
||||
if (!obj) return;
|
||||
const groundZ = overridesRef.current[hit.title]?.z ?? nearestCL(obj.lat, obj.lon)?.z ?? obj.z;
|
||||
const groundZ = overridesRef.current[hit.title]?.z ?? (obj.z > 0 ? obj.z : nearestCL(obj.lat, obj.lon)?.z) ?? obj.z; // 실측 z 우선
|
||||
const drone = getSmoothedDrone();
|
||||
const dist = drone
|
||||
? toCameraCoords(drone, obj.lat, obj.lon, groundZ, paramsRef.current, worldOriginRef.current).distH ?? null
|
||||
@@ -1967,6 +1999,14 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
<span className="text-gray-500">경도</span><span>{pp.lon.toFixed(6)}</span>
|
||||
</>
|
||||
)}
|
||||
{/* 표고(z) — KML 좌표 3번째 성분(타원체고). 속성표(SimpleData)에 없어서 별도 표기.
|
||||
(지장물의 '높이' 속성은 구조물 높이라 표고와 구분. 컴팩트 팝업은 간결 유지) */}
|
||||
{showFull && pp.z > 0 && (
|
||||
<>
|
||||
<span className="text-gray-500 whitespace-nowrap">표고</span>
|
||||
<span className="break-all">{pp.z.toFixed(1)} m (타원체고)</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -2137,6 +2177,7 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
>{camSaveState === 'ok' ? '저장됨 ✓' : 'PC에 저장'}</button>
|
||||
</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="Yaw ±" value={params.yawOffset} min={-180} max={180} step={0.1} unit="°" decimals={1} onChange={v => setParam('yawOffset', v)} />
|
||||
{/* Yaw 자동 추정(2단계 보정의 1단계) — 대략 맞춘 뒤 'Yaw 보정' 드래그 모드로 마무리 */}
|
||||
<button
|
||||
|
||||
Reference in New Issue
Block a user