제주 드론 도로영상 지원: 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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user