제주 드론 도로영상 지원: 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
+108
View File
@@ -0,0 +1,108 @@
/**
* DJI 영상 카메라 메타데이터 자동 감지
*
* DJI 드론 MP4 에는 'djmd' 데이터 트랙(프레임별 protobuf 텔레메트리)이 mdat 에
* 비디오 프레임과 인터리브되어 있다. 파일 앞부분(8MB)에서 첫 djmd 샘플을 찾아 추출:
* - 카메라 모델 문자열 (필드 1.1.2, 예: "DJI ZenmuseP1")
* - 렌즈 초점거리 (필드 8.6, 0.1mm 단위 35mm 환산)
* - 해상도/fps (필드 1.7/1.6/1.8)
*
* 실측 검증(2026-07-09, 제주 데이터): DJI_20260629121835_0002.MP4 →
* model "DJI ZenmuseP1", 8.6=299(=29.9mm — DL 24mm 렌즈 × 4K 비디오 크롭 ≈1.25),
* 3840×2160, 59.94fps. (djmd 필드 6.4/6.5 는 위경도 라디안, 6.6 은 타원체고 mm)
*/
import type { DetectedCameraInfo } from '../types/geo';
const HEAD_BYTES = 8 * 1024 * 1024;
function readVarint(b: Uint8Array, i: number): [number, number] | null {
let v = 0, s = 0;
while (i < b.length && (b[i] & 0x80)) {
v += (b[i] & 0x7f) * 2 ** s;
s += 7; i++;
if (s > 42) return null;
}
if (i >= b.length) return null;
v += (b[i] & 0x7f) * 2 ** s;
return [v, i + 1];
}
/** [start,end) 구간을 protobuf 필드로 파싱해 경로("8.6")→값 수집. 깨진 구간은 조용히 중단. */
function parseFields(
b: Uint8Array, dv: DataView,
start: number, end: number,
prefix: string, out: Map<string, number>,
): void {
let i = start;
while (i < end) {
const kv = readVarint(b, i);
if (!kv) return;
const [key, ni] = kv;
i = ni;
const tag = key >> 3, wire = key & 7;
if (tag === 0 || tag > 60) return;
const path = prefix + tag;
if (wire === 0) {
const r = readVarint(b, i); if (!r) return;
out.set(path, r[0]); i = r[1];
} else if (wire === 5) {
if (i + 4 > end) return;
out.set(path, dv.getFloat32(i, true)); i += 4;
} else if (wire === 1) {
if (i + 8 > end) return;
out.set(path, dv.getFloat64(i, true)); i += 8;
} else if (wire === 2) {
const r = readVarint(b, i); if (!r) return;
const [len, p] = r;
if (p + len > end) return;
// 하위 메시지 시도 — 문자열/바이너리면 내부에서 알아서 중단됨(경로 prefix 로 충돌 없음)
parseFields(b, dv, p, p + len, path + '.', out);
i = p + len;
} else return;
}
}
/** 영상 File 앞부분에서 DJI djmd 카메라 정보를 감지한다. DJI 영상이 아니면 null. */
export async function detectCameraInfo(file: File): Promise<DetectedCameraInfo | null> {
try {
const buf = new Uint8Array(await file.slice(0, HEAD_BYTES).arrayBuffer());
const dv = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
// 모델 문자열: 0x12 <len> "DJI ..." (필드 1.1.2)
let m = -1;
for (let i = 2; i < buf.length - 4; i++) {
if (buf[i] === 0x44 && buf[i + 1] === 0x4a && buf[i + 2] === 0x49 && buf[i + 3] === 0x20 &&
buf[i - 2] === 0x12 && buf[i - 1] >= 4 && buf[i - 1] <= 40) { m = i; break; }
}
if (m < 0) return null;
const model = new TextDecoder().decode(buf.slice(m, m + buf[m - 1]));
// 샘플 시작(최상위 필드1, 0x0a <varint len> 0x0a ...)을 모델 문자열에서 역방향 탐색
let sampleStart = -1;
for (let back = 4; back <= 16; back++) {
const i = m - 2 - back;
if (i < 0) break;
if (buf[i] !== 0x0a) continue;
const r = readVarint(buf, i + 1);
if (!r) continue;
const [len, p] = r;
if (buf[p] === 0x0a && p < m && p + len > m && p + len <= buf.length) { sampleStart = i; break; }
}
if (sampleStart < 0) return { model };
const out = new Map<string, number>();
parseFields(buf, dv, sampleStart, Math.min(buf.length, sampleStart + 512), '', out);
const info: DetectedCameraInfo = { model };
const focal10 = out.get('8.6');
if (focal10 !== undefined && focal10 > 80 && focal10 < 3000) info.focalLen35 = focal10 / 10; // 8~300mm 만 신뢰
const w = out.get('1.7'), h = out.get('1.6'), fps = out.get('1.8');
if (w && w > 100) info.width = w;
if (h && h > 100) info.height = h;
if (typeof fps === 'number' && fps > 1 && fps < 1000) info.fps = Math.round(fps * 100) / 100;
return info;
} catch {
return null;
}
}