djmd 프레임 동기·오버레이 지면고정·선형-측점 정합·UI 단축키
- djmd(영상 내장 텔레메트리) 프레임 동기 위치/고도: MP4 stbl 선별 파싱(djmdTrack.ts), CSV 시각정렬 오차(평균 3.2m) 제거 — 세그먼트별 백그라운드 정밀화(frameSynced 플래그) - 자세 시간축 재정렬: djmd 위치 교차상관으로 CSV↔영상 오프셋 자동 추정(실측 -0.33s, 잔차 0.24m) 후 yaw/pitch/roll 재샘플(yaw 최단각) — 옆 지물 미끄러짐 해소 - 포즈 평활 자동 제한: 프레임 동기 데이터는 ±5프레임 상한(기존 ±60=±1s) — 회전 시 오버레이 전체가 드론에 딸려오는 지연 제거 - 선형-측점 정합: 측점 위치를 선형 정점으로 삽입(3D 이격≤0.012m) + 정점 z 를 측점 체이니지 보간으로 재정렬, 측점 라벨 화면 EMA 제거(선과 동일한 즉시 투영) — 선형이 측점 POI 를 픽셀 단위로 통과 - 드론높이 모드 높이 규칙 통일: 선형도 '드론고도-이격거리' 평면 적용(라벨과 동일) - 카메라 offZ 잔재 교정: 표고 체계 정비 이전 드래그 보정값(-18.3m)이 이중 보정이 되어 원거리 POI 가 접근할수록 밀리던 원인 — 경로 1-1 camera.json 0 으로 교정(.bak 백업) - 로드 UX: 폴더 로드 시 정지 상태로 대기(처음부터), 데이터 교체 시 UI 초기 상태 복원 - 라벨: 측점 팝업 표고(타원체고) 행 추가, 구조물-POI 이중 라벨 제거(어음천교), 시간동기 보정 슬라이더(timeOffsetSec) - 측점 표기 0+000 형식 전환(생성 6곳·파서 4곳 신구 호환) + 스테이션바 양끝 100m 내 시설물 없으면 시작/끝 스테이션 마크 - 단축키: F2 개발자 모드(dev_mode 1↔0, 세션 한정), A 선형 표시 토글 - 인프라: 54000 포트 재부팅 자동화(portproxy 직접 IP + 로그온 갱신, pm2 resurrect) - docs/history: 작업 이력 23건 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,10 +12,10 @@ export function kmFromTitle(title: string): number {
|
||||
return m ? parseInt(m[1], 10) * 1000 + parseInt(m[2], 10) : -1;
|
||||
}
|
||||
|
||||
/** 미터값 → "157k970" (10m 단위). */
|
||||
/** 미터값 → "157+970" (10m 단위). */
|
||||
export function fmtKm10(m: number): string {
|
||||
const r = Math.round(m / 10) * 10;
|
||||
return `${Math.floor(r / 1000)}k${String(r % 1000).padStart(3, '0')}`;
|
||||
return `${Math.floor(r / 1000)}+${String(r % 1000).padStart(3, '0')}`;
|
||||
}
|
||||
|
||||
/** 측점 POI 목록 → 평면투영 폴리라인(km 오름차순). 측점이 없으면 null. */
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* DJI 영상 내장 텔레메트리(djmd 트랙) — 프레임 단위 정밀 위치/고도 추출
|
||||
*
|
||||
* DJI MP4 에는 비디오 프레임마다 djmd 데이터 샘플(protobuf)이 기록된다:
|
||||
* 6.3 = 프레임 카운터, 6.4/6.5 = 위도/경도(라디안, double), 6.6 = 타원체고(mm)
|
||||
* 이 위치는 해당 '영상 프레임'에 하드웨어 수준으로 동기되어 있어, 외부 비행로그(CSV,
|
||||
* 10Hz + 시각 정렬 가정)의 시간 동기 오차가 원천적으로 없다 — 오르막/내리막/가감속에서
|
||||
* POI 정합("풀 붙임")의 근본 해법. 자세(yaw/pitch/roll)는 djmd 에 없어 CSV 를 유지한다.
|
||||
*
|
||||
* 구현: MP4 박스(moov→trak→stbl)를 파싱해 djmd 트랙의 샘플 오프셋 테이블을 얻고,
|
||||
* 전체 파일 스캔 없이 해당 바이트 구간만 골라 읽는다(기본 6프레임 간격 샘플링 ≈ 10Hz —
|
||||
* 동기 정확도는 프레임 단위, 밀도는 CSV 동급).
|
||||
*/
|
||||
|
||||
import type { DroneFrame } from '../types/geo';
|
||||
|
||||
interface DjmdSample {
|
||||
frame: number;
|
||||
lat: number; // deg
|
||||
lon: number; // deg
|
||||
altM: number; // 타원체고 (m)
|
||||
}
|
||||
|
||||
// ── 저수준 리더 ───────────────────────────────────────────────────────
|
||||
|
||||
async function readAt(file: File, off: number, len: number): Promise<DataView> {
|
||||
const buf = await file.slice(off, Math.min(off + len, file.size)).arrayBuffer();
|
||||
return new DataView(buf);
|
||||
}
|
||||
|
||||
function str4(dv: DataView, off: number): string {
|
||||
return String.fromCharCode(dv.getUint8(off), dv.getUint8(off + 1), dv.getUint8(off + 2), dv.getUint8(off + 3));
|
||||
}
|
||||
|
||||
/** 파일 최상위에서 type 박스 탐색 (mdat 등 대형 박스는 크기로 건너뜀). */
|
||||
async function findTopBox(file: File, type: string): Promise<{ off: number; size: number; hdr: number } | null> {
|
||||
let off = 0;
|
||||
while (off + 8 <= file.size) {
|
||||
const h = await readAt(file, off, 16);
|
||||
if (h.byteLength < 8) return null;
|
||||
let size = h.getUint32(0);
|
||||
const t = str4(h, 4);
|
||||
let hdr = 8;
|
||||
if (size === 1) {
|
||||
if (h.byteLength < 16) return null;
|
||||
size = Number(h.getBigUint64(8));
|
||||
hdr = 16;
|
||||
} else if (size === 0) size = file.size - off;
|
||||
if (t === type) return { off, size, hdr };
|
||||
if (size < 8) return null;
|
||||
off += size;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** DataView 내 자식 박스 순회. */
|
||||
function* boxes(dv: DataView, start: number, end: number): Generator<{ type: string; start: number; end: number }> {
|
||||
let off = start;
|
||||
while (off + 8 <= end) {
|
||||
let size = dv.getUint32(off);
|
||||
const type = str4(dv, off + 4);
|
||||
let hdr = 8;
|
||||
if (size === 1) {
|
||||
size = Number(dv.getBigUint64(off + 8));
|
||||
hdr = 16;
|
||||
} else if (size === 0) size = end - off;
|
||||
if (size < hdr) return;
|
||||
yield { type, start: off + hdr, end: off + Math.min(size, end - off) };
|
||||
off += size;
|
||||
}
|
||||
}
|
||||
|
||||
function findChild(dv: DataView, start: number, end: number, type: string): { start: number; end: number } | null {
|
||||
for (const b of boxes(dv, start, end)) if (b.type === type) return { start: b.start, end: b.end };
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── protobuf 미니 파서 (cameraMeta 와 동일 방식) ──────────────────────
|
||||
|
||||
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];
|
||||
}
|
||||
|
||||
/** djmd 샘플에서 6.3(frame)/6.4(lat rad)/6.5(lon rad)/6.6(alt mm) 추출. */
|
||||
function parseSample(b: Uint8Array): DjmdSample | null {
|
||||
const dv = new DataView(b.buffer, b.byteOffset, b.byteLength);
|
||||
let frame: number | undefined, lat: number | undefined, lon: number | undefined, alt: number | undefined;
|
||||
const walk = (start: number, end: number, depth: number, parentTag: 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;
|
||||
if (wire === 0) {
|
||||
const r = readVarint(b, i); if (!r) return;
|
||||
if (depth === 1 && parentTag === 6) {
|
||||
if (tag === 3) frame = r[0];
|
||||
else if (tag === 6) alt = r[0] / 1000;
|
||||
}
|
||||
i = r[1];
|
||||
} else if (wire === 5) { i += 4; }
|
||||
else if (wire === 1) {
|
||||
if (i + 8 > end) return;
|
||||
if (depth === 1 && parentTag === 6) {
|
||||
const val = dv.getFloat64(i, true) * 180 / Math.PI;
|
||||
if (tag === 4) lat = val;
|
||||
else if (tag === 5) lon = val;
|
||||
}
|
||||
i += 8;
|
||||
} else if (wire === 2) {
|
||||
const r = readVarint(b, i); if (!r) return;
|
||||
const [len, p] = r;
|
||||
if (p + len > end) return;
|
||||
if (depth === 0 && tag === 6) walk(p, p + len, 1, 6);
|
||||
i = p + len;
|
||||
} else return;
|
||||
}
|
||||
};
|
||||
walk(0, b.length, 0, 0);
|
||||
if (frame === undefined || lat === undefined || lon === undefined) return null;
|
||||
if (!(lat > -90 && lat < 90 && lon > -180 && lon < 180)) return null;
|
||||
return { frame, lat, lon, altM: alt ?? 0 };
|
||||
}
|
||||
|
||||
// ── djmd 샘플 테이블 파싱 + 선별 읽기 ─────────────────────────────────
|
||||
|
||||
/**
|
||||
* 영상 파일의 djmd 트랙에서 프레임별 위치 샘플을 읽는다 (step 프레임 간격).
|
||||
* DJI 영상이 아니거나 트랙이 없으면 null.
|
||||
*/
|
||||
export async function parseDjmdPositions(file: File, step = 6): Promise<DjmdSample[] | null> {
|
||||
const moov = await findTopBox(file, 'moov');
|
||||
if (!moov || moov.size > 64 * 1024 * 1024) return null;
|
||||
const mv = await readAt(file, moov.off + moov.hdr, moov.size - moov.hdr);
|
||||
const end = mv.byteLength;
|
||||
|
||||
// djmd stsd 를 가진 trak 의 stbl 탐색
|
||||
let stbl: { start: number; end: number } | null = null;
|
||||
for (const trak of boxes(mv, 0, end)) {
|
||||
if (trak.type !== 'trak') continue;
|
||||
const mdia = findChild(mv, trak.start, trak.end, 'mdia');
|
||||
if (!mdia) continue;
|
||||
const minf = findChild(mv, mdia.start, mdia.end, 'minf');
|
||||
if (!minf) continue;
|
||||
const st = findChild(mv, minf.start, minf.end, 'stbl');
|
||||
if (!st) continue;
|
||||
const stsd = findChild(mv, st.start, st.end, 'stsd');
|
||||
if (!stsd) continue;
|
||||
// stsd: ver/flags(4) count(4) → entry: size(4) fourcc(4)
|
||||
if (stsd.end - stsd.start >= 16 && str4(mv, stsd.start + 12) === 'djmd') { stbl = st; break; }
|
||||
}
|
||||
if (!stbl) return null;
|
||||
|
||||
const stsz = findChild(mv, stbl.start, stbl.end, 'stsz');
|
||||
const stsc = findChild(mv, stbl.start, stbl.end, 'stsc');
|
||||
const stco = findChild(mv, stbl.start, stbl.end, 'stco');
|
||||
const co64 = findChild(mv, stbl.start, stbl.end, 'co64');
|
||||
if (!stsz || !stsc || (!stco && !co64)) return null;
|
||||
|
||||
// stsz — 샘플 크기
|
||||
const fixedSize = mv.getUint32(stsz.start + 4);
|
||||
const sampleCount = mv.getUint32(stsz.start + 8);
|
||||
if (!sampleCount) return null;
|
||||
const sizeOf = (i: number): number => (fixedSize ? fixedSize : mv.getUint32(stsz.start + 12 + i * 4));
|
||||
|
||||
// stco/co64 — 청크 오프셋
|
||||
const chunkCount = mv.getUint32((co64 ?? stco!).start + 4);
|
||||
const chunkOff = (c: number): number =>
|
||||
co64 ? Number(mv.getBigUint64(co64.start + 8 + c * 8)) : mv.getUint32(stco!.start + 8 + c * 4);
|
||||
|
||||
// stsc — 청크당 샘플 수 (구간 압축) 전개
|
||||
const stscCount = mv.getUint32(stsc.start + 4);
|
||||
const stscEntry = (i: number): [number, number] => [
|
||||
mv.getUint32(stsc.start + 8 + i * 12), // first_chunk (1-base)
|
||||
mv.getUint32(stsc.start + 8 + i * 12 + 4), // samples_per_chunk
|
||||
];
|
||||
|
||||
// 샘플 인덱스 → 파일 오프셋 테이블 (step 간격 대상만)
|
||||
const targets: { idx: number; off: number; size: number }[] = [];
|
||||
let sampleIdx = 0, entry = 0;
|
||||
for (let c = 0; c < chunkCount && sampleIdx < sampleCount; c++) {
|
||||
while (entry + 1 < stscCount && stscEntry(entry + 1)[0] <= c + 1) entry++;
|
||||
const spc = stscEntry(entry)[1];
|
||||
let off = chunkOff(c);
|
||||
for (let s = 0; s < spc && sampleIdx < sampleCount; s++, sampleIdx++) {
|
||||
const sz = sizeOf(sampleIdx);
|
||||
if (sampleIdx % step === 0) targets.push({ idx: sampleIdx, off, size: sz });
|
||||
off += sz;
|
||||
}
|
||||
}
|
||||
if (targets.length < 5) return null;
|
||||
|
||||
// 배치 읽기(동시 64) + 파싱
|
||||
const out: DjmdSample[] = [];
|
||||
const BATCH = 64;
|
||||
for (let i = 0; i < targets.length; i += BATCH) {
|
||||
const batch = targets.slice(i, i + BATCH);
|
||||
const parsed = await Promise.all(batch.map(async (t) => {
|
||||
const dv = await readAt(file, t.off, t.size);
|
||||
return parseSample(new Uint8Array(dv.buffer));
|
||||
}));
|
||||
for (const p of parsed) if (p) out.push(p);
|
||||
}
|
||||
out.sort((a, b) => a.frame - b.frame);
|
||||
return out.length >= 5 ? out : null;
|
||||
}
|
||||
|
||||
// ── DroneFrame 정밀 위치 병합 ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* CSV 기반 프레임의 위치/고도를 djmd(영상 내장, 프레임 동기) 값으로 교체하고,
|
||||
* **자세(yaw/pitch/roll)도 CSV↔영상 시간 오프셋을 자동 추정해 재정렬**한다.
|
||||
*
|
||||
* 시간 오프셋 추정: CSV 위치(시각 정렬 가정)와 djmd 위치(프레임 동기 실측)를
|
||||
* 교차상관해 평균 거리가 최소가 되는 dt 를 찾는다. 이 dt 로 자세를 재샘플하면
|
||||
* 옆으로 지나치는 지물(상대 방위가 빠르게 변함)의 라벨 미끄러짐이 제거된다.
|
||||
*
|
||||
* 고도 datum: djmd 는 타원체고 —
|
||||
* - useEllipsoidal(타원체고 데이터셋): 그대로 사용
|
||||
* - 정표고 데이터셋: 첫 샘플과 CSV 첫 프레임의 차(지오이드고)를 빼서 정표고로 변환
|
||||
*/
|
||||
export async function refineFramesWithDjmd(
|
||||
videoFile: File,
|
||||
frames: DroneFrame[],
|
||||
useEllipsoidal: boolean,
|
||||
): Promise<DroneFrame[] | null> {
|
||||
if (!frames.length) return null;
|
||||
const samples = await parseDjmdPositions(videoFile);
|
||||
if (!samples) return null;
|
||||
const FPS = 59.94; // 프레임 번호 ↔ 시간 환산 (DJI_VIRTUAL_FPS 와 동일 기준)
|
||||
|
||||
// 정표고 데이터셋: 지오이드고 = djmd 타원체고 − CSV 고도 (같은 프레임 부근)
|
||||
let altShift = 0;
|
||||
if (!useEllipsoidal) {
|
||||
const s0 = samples[0];
|
||||
let nearest = frames[0];
|
||||
for (const f of frames) if (Math.abs(f.frame - s0.frame) < Math.abs(nearest.frame - s0.frame)) nearest = f;
|
||||
altShift = s0.altM - nearest.altitude;
|
||||
if (!isFinite(altShift) || Math.abs(altShift) > 80) return null; // 비정상 → 사용 안 함
|
||||
}
|
||||
|
||||
// 프레임 번호 기준 선형 보간 (djmd 위치)
|
||||
const at = (frame: number): { lat: number; lon: number; altM: number } => {
|
||||
let lo = 0, hi = samples.length - 1;
|
||||
if (frame <= samples[0].frame) return samples[0];
|
||||
if (frame >= samples[hi].frame) return samples[hi];
|
||||
while (hi - lo > 1) { const m = (lo + hi) >> 1; if (samples[m].frame <= frame) lo = m; else hi = m; }
|
||||
const a = samples[lo], b = samples[hi];
|
||||
const t = b.frame > a.frame ? (frame - a.frame) / (b.frame - a.frame) : 0;
|
||||
return {
|
||||
lat: a.lat + (b.lat - a.lat) * t,
|
||||
lon: a.lon + (b.lon - a.lon) * t,
|
||||
altM: a.altM + (b.altM - a.altM) * t,
|
||||
};
|
||||
};
|
||||
|
||||
// ── CSV 시각(t=frame/FPS) 기준 보간 (위치+자세) — 시간 오프셋 추정·자세 재샘플용 ──
|
||||
const ct = frames.map((f) => f.frame / FPS);
|
||||
const cosLat = Math.cos((frames[0].lat * Math.PI) / 180);
|
||||
const csvAt = (t: number): DroneFrame => {
|
||||
const n = ct.length;
|
||||
if (t <= ct[0]) return frames[0];
|
||||
if (t >= ct[n - 1]) return frames[n - 1];
|
||||
let lo = 0, hi = n - 1;
|
||||
while (hi - lo > 1) { const m = (lo + hi) >> 1; if (ct[m] <= t) lo = m; else hi = m; }
|
||||
const a = frames[lo], b = frames[hi];
|
||||
const r = ct[hi] > ct[lo] ? (t - ct[lo]) / (ct[hi] - ct[lo]) : 0;
|
||||
let dy = b.yaw - a.yaw; dy = ((dy + 540) % 360) - 180; // 최단각
|
||||
return {
|
||||
...a,
|
||||
lat: a.lat + (b.lat - a.lat) * r,
|
||||
lon: a.lon + (b.lon - a.lon) * r,
|
||||
yaw: a.yaw + dy * r,
|
||||
pitch: a.pitch + (b.pitch - a.pitch) * r,
|
||||
roll: a.roll + (b.roll - a.roll) * r,
|
||||
};
|
||||
};
|
||||
|
||||
// CSV↔영상 시간 오프셋(dt) 추정 — 위치 교차상관 (조밀 50포인트, 조사 ±1.5s → 0.01s 정밀)
|
||||
const stride = Math.max(1, Math.floor(samples.length / 50));
|
||||
const probes = samples.filter((_, i) => i % stride === 0);
|
||||
const cost = (dt: number): number => {
|
||||
let s = 0, n = 0;
|
||||
for (const p of probes) {
|
||||
const c = csvAt(p.frame / FPS + dt);
|
||||
s += Math.hypot((p.lat - c.lat) * 111000, (p.lon - c.lon) * 111000 * cosLat);
|
||||
n++;
|
||||
}
|
||||
return n ? s / n : Infinity;
|
||||
};
|
||||
let bestDt = 0, bestC = cost(0);
|
||||
for (let dt = -1.5; dt <= 1.5001; dt += 0.05) { const c = cost(dt); if (c < bestC) { bestC = c; bestDt = dt; } }
|
||||
for (let dt = bestDt - 0.05; dt <= bestDt + 0.0501; dt += 0.01) { const c = cost(dt); if (c < bestC) { bestC = c; bestDt = dt; } }
|
||||
if (Math.abs(bestDt) > 0.005) {
|
||||
console.log(`[djmd] CSV↔영상 시간 오프셋 ${bestDt.toFixed(2)}s 추정 (잔차 ${bestC.toFixed(1)}m) — 자세 재정렬 적용`);
|
||||
}
|
||||
|
||||
return frames.map((f) => {
|
||||
const p = at(f.frame); // 위치/고도: djmd (프레임 동기)
|
||||
const a = csvAt(f.frame / FPS + bestDt); // 자세: CSV 를 추정 오프셋으로 재시각화
|
||||
return {
|
||||
...f,
|
||||
lat: p.lat, lon: p.lon, altitude: p.altM - altShift,
|
||||
yaw: a.yaw, pitch: a.pitch, roll: a.roll,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -504,7 +504,21 @@ export async function parseKmz(
|
||||
prevKey = key;
|
||||
pts.push({ lat: p[1], lon: p[0], z: p.length >= 3 && isFinite(p[2]) ? p[2] : 0 });
|
||||
}
|
||||
if (pts.length >= 2 && pts.length > centerline.length) centerline = pts;
|
||||
if (pts.length >= 2 && pts.length > centerline.length) {
|
||||
// z 톱니 노이즈 평활 — 원본(지형고도 샘플링)의 ±1m 양자화 잔차가 인접 정점에서
|
||||
// 부호 반전 톱니를 만들고, 영상의 낮은 시야각 투영에서 선이 지그재그로 꺾여 보임.
|
||||
// 도로 종단 경사는 완만하므로 ±3점 이동평균으로 톱니만 제거(경사 보존).
|
||||
const zs = pts.map((p) => p.z);
|
||||
for (let i = 0; i < pts.length; i++) {
|
||||
if (zs[i] <= 0) continue;
|
||||
let sum = 0, cnt = 0;
|
||||
for (let j = Math.max(0, i - 3); j <= Math.min(pts.length - 1, i + 3); j++) {
|
||||
if (zs[j] > 0) { sum += zs[j]; cnt++; }
|
||||
}
|
||||
if (cnt) pts[i].z = sum / cnt;
|
||||
}
|
||||
centerline = pts;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -575,7 +589,7 @@ export async function parseKmz(
|
||||
? Math.round(kmProp * 1000)
|
||||
: staMatch ? parseInt(staMatch[1], 10) * 1000 + parseInt(staMatch[2], 10) : NaN;
|
||||
if (!isFinite(meters)) return;
|
||||
const title = `${Math.floor(meters / 1000)}k${String(meters % 1000).padStart(3, '0')}`;
|
||||
const title = `${Math.floor(meters / 1000)}+${String(meters % 1000).padStart(3, '0')}`;
|
||||
// z: 좌표 고도(DEM 기록값) 있으면 사용 — 없으면 0(미상, 오버레이가 경로표고 앵커).
|
||||
stations.push({ title, category: '측점', lat, lon, z: coordAlt ?? 0, type: 'station', props });
|
||||
return;
|
||||
@@ -615,6 +629,67 @@ export async function parseKmz(
|
||||
if (doc.documentElement) walk(doc.documentElement, '');
|
||||
}
|
||||
|
||||
// 선형이 측점 POI 를 정확히 통과하도록 정비 — 측점 기준.
|
||||
// ① 측점 위치를 선형 폴리라인에 '정점으로 삽입' (긴 직선 현 중간의 측점도 통과 보장 —
|
||||
// 라벨과 선이 같은 점(lat/lon/z)을 투영하므로 어떤 카메라 자세에서도 화면에서 일치)
|
||||
// ② 나머지 원본 정점 z 는 측점 z 의 체이니지 보간으로 교체 (톱니 평활 대체, 종단 = 측점)
|
||||
const staZ = stations.filter((s) => isFinite(s.z) && s.z > 0);
|
||||
if (centerline.length >= 2 && staZ.length >= 2) {
|
||||
const cosLat = Math.cos((centerline[0].lat * Math.PI) / 180);
|
||||
const ex = (p: { lat: number; lon: number }): number => (p.lon - centerline[0].lon) * 111320 * cosLat;
|
||||
const ey = (p: { lat: number; lon: number }): number => (p.lat - centerline[0].lat) * 111320;
|
||||
const cum: number[] = [0];
|
||||
for (let i = 1; i < centerline.length; i++) {
|
||||
cum.push(cum[i - 1] + Math.hypot(ex(centerline[i]) - ex(centerline[i - 1]), ey(centerline[i]) - ey(centerline[i - 1])));
|
||||
}
|
||||
// 측점 → 선형 세그먼트 투영 (선형에서 50m 이상 떨어진 측점은 제외)
|
||||
const anchors: { s: number; z: number; seg: number; t: number; lat: number; lon: number }[] = [];
|
||||
for (const st of staZ) {
|
||||
const px = ex(st), py = ey(st);
|
||||
let bestS = 0, bestD = Infinity, bestSeg = 0, bestT = 0;
|
||||
for (let i = 0; i < centerline.length - 1; i++) {
|
||||
const ax = ex(centerline[i]), ay = ey(centerline[i]);
|
||||
const dx = ex(centerline[i + 1]) - ax, dy = ey(centerline[i + 1]) - ay;
|
||||
const len2 = dx * dx + dy * dy;
|
||||
const t = len2 > 0 ? Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / len2)) : 0;
|
||||
const d = Math.hypot(px - (ax + dx * t), py - (ay + dy * t));
|
||||
if (d < bestD) { bestD = d; bestS = cum[i] + Math.sqrt(len2) * t; bestSeg = i; bestT = t; }
|
||||
}
|
||||
if (bestD < 50) anchors.push({ s: bestS, z: st.z, seg: bestSeg, t: bestT, lat: st.lat, lon: st.lon });
|
||||
}
|
||||
anchors.sort((a, b) => a.s - b.s);
|
||||
if (anchors.length >= 2) {
|
||||
// ② 원본 정점 z ← 측점 체이니지 보간
|
||||
for (let i = 0; i < centerline.length; i++) {
|
||||
const s = cum[i];
|
||||
if (s <= anchors[0].s) { centerline[i].z = anchors[0].z; continue; }
|
||||
if (s >= anchors[anchors.length - 1].s) { centerline[i].z = anchors[anchors.length - 1].z; continue; }
|
||||
let lo = 0, hi = anchors.length - 1;
|
||||
while (hi - lo > 1) { const m = (lo + hi) >> 1; if (anchors[m].s <= s) lo = m; else hi = m; }
|
||||
const a = anchors[lo], b = anchors[hi];
|
||||
const r = b.s > a.s ? (s - a.s) / (b.s - a.s) : 0;
|
||||
centerline[i].z = a.z + (b.z - a.z) * r;
|
||||
}
|
||||
// ① 측점 정점 삽입 — 세그먼트별로 t 순 삽입. 기존 정점과 1m 이내면 생략(중복 방지).
|
||||
const rebuilt: CenterlinePoint[] = [];
|
||||
let inserted = 0;
|
||||
for (let i = 0; i < centerline.length; i++) {
|
||||
rebuilt.push(centerline[i]);
|
||||
if (i === centerline.length - 1) break;
|
||||
const onSeg = anchors.filter((a) => a.seg === i).sort((a, b) => a.t - b.t);
|
||||
for (const a of onSeg) {
|
||||
const near = (p: CenterlinePoint): boolean =>
|
||||
Math.hypot(ex(a) - ex(p), ey(a) - ey(p)) < 1;
|
||||
if (near(centerline[i]) || near(centerline[i + 1])) continue;
|
||||
rebuilt.push({ lat: a.lat, lon: a.lon, z: a.z });
|
||||
inserted++;
|
||||
}
|
||||
}
|
||||
centerline = rebuilt;
|
||||
console.log(`[KML] 선형-측점 정합: 측점 정점 ${inserted}개 삽입 + 정점 z 를 측점 ${anchors.length}점 기준 재정렬`);
|
||||
}
|
||||
}
|
||||
|
||||
return { pois, structures, stations, centerline };
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,8 @@ export interface DroneFrameBasic {
|
||||
* offX/offY/offZ: 드론 위치 보정 — Python off_x/y/z 동치.
|
||||
*/
|
||||
export interface CameraParams {
|
||||
timeOffsetSec?: number; // 영상↔비행로그 시간동기 보정(초). + = 로그를 뒤로 밀어 참조.
|
||||
// POI 가 진행방향으로 일괄 밀릴 때 조정 — "풀 붙임" 정합의 핵심 값.
|
||||
yawOffset: number; // yaw 오프셋 (degrees, per-frame SRT yaw에 더함)
|
||||
pitch: number; // pitch 오프셋 (degrees, per-frame SRT pitch에 더함, 기본 0)
|
||||
roll: number; // roll 오프셋 (degrees, per-frame SRT roll에 더함, 기본 0)
|
||||
@@ -51,6 +53,7 @@ export interface CameraParams {
|
||||
|
||||
/** Python advanced_tuner_v2.py 기본값 */
|
||||
export const DEFAULT_CAMERA_PARAMS: CameraParams = {
|
||||
timeOffsetSec: 0,
|
||||
yawOffset: 0,
|
||||
pitch: 0, // offset, Python spn_pitch 기본값 0
|
||||
roll: 0, // offset, Python spn_roll 기본값 0
|
||||
|
||||
Reference in New Issue
Block a user