제주 드론 도로영상 지원: DJI 로그 어댑터·분할영상 연속재생·카메라 자동감지·오버레이 개선
- 제주 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<number | null>(() => {
|
||||
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 => {
|
||||
|
||||
Reference in New Issue
Block a user