- 타원체고 기준 자동 변환: route.json ellipsoidal_height 선언 시 드론 고도(CSV 정표고)를 영상 메타(djmd 6.6 타원체고)와의 차(지오이드고 자동 산출)만큼 보정, 세그먼트 전환에도 유지 - KML 표고 파이프라인: 좌표 고도(z) 사용(POI/측점), *_byDem.kml 자동 생성(DEM 조회·높이 속성 포함) + 로더의 _byDem 우선 규칙, 선형중심선(LineString) 로딩·경로표고 앵커 - 설정 파일: camera.json/display.json/route.json 단순 이름 인식, 화면표시 옵션 PC 저장/자동 적용, 초기화 버튼을 환경설정 파일 기준 복원으로 변경, 저장 파일명 단순화 - 선형 표시: 표출된 마지막 측점까지 클립(경계 세그먼트 절단 + 관통 현 처리 — 구간 실종 수정) - 스테이션바: 시점(0k000) 마크(실통과 위치), 노선밖 프레임 통과 오인 방지, 커서 배지 1m 단위, 배너 연장/소요 실측값(측점 체이니지·영상 길이 합) 우선 - 세그먼트 전환 라벨 깜빡임 구조 수정(캐시 초기화·최근접 키 조회·이중 재계산 제거) + 사전계산 프레임 불변값 호이스팅(재생 지연 해소) - UI: 슬라이더/값 입력칸 휠 조절, 패널 버튼 위치 정렬, 저장 버튼 중복 제거, dev_mode(route.json)로 영상제어 UI 표시 제어, 지오이드 최대 200, 이격거리 측점 동반 적용 - 시연: start-demo.bat + docs/DEMO.md - docs/history: 작업 이력 30건 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
114 lines
4.5 KiB
TypeScript
114 lines
4.5 KiB
TypeScript
/**
|
||
* 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;
|
||
// 6.6 = 드론 타원체고(mm) — 영상 시작 시점. CSV 정표고와의 차 = 지역 지오이드고(datum 변환용).
|
||
const altMm = out.get('6.6');
|
||
if (typeof altMm === 'number' && altMm > 0 && altMm < 10_000_000) {
|
||
info.ellipsoidalAltM = Math.round(altMm) / 1000;
|
||
}
|
||
return info;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|