제주 드론 도로영상 지원: 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:
2026-07-09 20:58:49 +09:00
co-authored by Claude Fable 5
parent d38b842e8d
commit 84f4921826
32 changed files with 1833 additions and 110 deletions
+94 -17
View File
@@ -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 … ENDendGapPx] 에만 매핑되고, 종점역 마커는 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 => {