feat: 스테이션바 측점 정확도 개선 + 영상 컨트롤 UI 통합 + 종합 기술문서
스테이션바/측점(체이니지) - 역 재진입 마커 누락 수정: 측점기준 탐지에 좌표근접(pxPassesTo) 합집합 보강(중복 24px 제거) - kmExists 판정: 좌표 반경만으로 잡힌 통과는 측점값 라벨/검색에서 제외(마커 동그라미는 유지) - 측점값 라벨 겹침 숨김(좌측 우선), 동일항목 브래킷 클러스터 - 측점 검색 순환(통과방향 다음→끝에서 처음), 마커 기준 후보 산출, 입력값 유지/blur 삭제 - 연속 체이니지 수직투영 유틸 분리(client/src/utils/chainage.ts), Minimap 컴포넌트 추가 영상 컨트롤 UI - 선형/드론궤적/좌측패널 토글을 재생바로 통합(settingsStore 공유·persist) - 드론 GPS·고도 HUD 상시 표시, 경로표고·투명도 항상 노출, 겹침제외 버튼 숨김 - 재생바 요소 재배치(좌측패널→선형→드론궤적→드론위치정보→시설등급), 활성색 amber 통일 문서/도구 - 종합 기술명세서(인계+특허 기초) md/html/pdf 3종, UI개선 보고서 3종 - md→html→pdf 변환 스크립트(scripts/md2docs.sh) + 스타일(docs/assets/report.css) - 작업 히스토리 다수 추가(docs/history/) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 18mm 16mm 16mm 16mm;
|
||||
@bottom-center {
|
||||
content: counter(page) " / " counter(pages);
|
||||
font-family: "Malgun Gothic", sans-serif;
|
||||
font-size: 9pt;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
html { font-size: 11pt; }
|
||||
|
||||
body {
|
||||
font-family: "Malgun Gothic", "Hancom Gothic", sans-serif;
|
||||
color: #23272e;
|
||||
line-height: 1.65;
|
||||
max-width: 920px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.7rem;
|
||||
color: #b45309; /* amber-700 — 앱 활성색 계열 */
|
||||
border-bottom: 3px solid #f59e0b; /* amber-500 */
|
||||
padding-bottom: 8px;
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.25rem;
|
||||
color: #b45309;
|
||||
border-bottom: 1px solid #e5d3b3;
|
||||
padding-bottom: 5px;
|
||||
margin-top: 1.6em;
|
||||
}
|
||||
|
||||
h3 { font-size: 1.05rem; color: #92400e; margin-top: 1.1em; }
|
||||
|
||||
a { color: #b45309; }
|
||||
|
||||
hr { border: none; border-top: 1px solid #e2e2e2; margin: 1.6em 0; }
|
||||
|
||||
ul { padding-left: 1.25em; }
|
||||
li { margin: 0.18em 0; }
|
||||
|
||||
strong { color: #1f2937; }
|
||||
|
||||
code {
|
||||
font-family: "D2Coding", Consolas, monospace;
|
||||
background: #f4f1ea;
|
||||
border: 1px solid #e7e0d2;
|
||||
border-radius: 3px;
|
||||
padding: 0.5px 5px;
|
||||
font-size: 0.92em;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 0.8em 0;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
th, td { border: 1px solid #d8d2c4; padding: 6px 10px; text-align: left; vertical-align: top; }
|
||||
th { background: #fdf3df; color: #7c2d12; }
|
||||
|
||||
blockquote {
|
||||
border-left: 4px solid #f59e0b;
|
||||
margin: 0.8em 0;
|
||||
padding: 0.2em 0 0.2em 14px;
|
||||
color: #555;
|
||||
background: #fffbf2;
|
||||
}
|
||||
|
||||
/* ★ 강조 항목이 페이지 경계에서 잘리지 않게 */
|
||||
h1, h2, h3 { break-after: avoid; }
|
||||
@@ -0,0 +1,36 @@
|
||||
# 동일 항목 묶음 브래킷 표시
|
||||
|
||||
**소요 시간**: 약 15분
|
||||
**Context 사용량**: input ~55k / output ~6k tokens
|
||||
|
||||
## 결론
|
||||
스테이션바 타임라인에서 같은 항목(동일 역명)을 드론이 여러 번 통과할 때, 두 번째 참고 이미지처럼
|
||||
**점선 브래킷(각 통과 수직 드롭 + 하단 수평 연결선)** 으로 묶고 **클러스터 중앙에 라벨 1개** 만
|
||||
표시하도록 변경.
|
||||
|
||||
## 근거 / 변경 내용
|
||||
- 파일: `client/src/stationbar/components/Timeline/Timeline.tsx`
|
||||
- 기존: 다중통과 시 `labelEntries`(마지막 통과 라벨 1개 + 나머지 중앙 라벨 1개) + 통과별 점선 틱(`leaderPxs`)
|
||||
→ 같은 항목인데 라벨이 2개로 쪼개져 표시됨.
|
||||
- 변경: `labelGroups` 로 재구성.
|
||||
- 같은 title 통과들을 px 간격(`GROUP_GAP_PX = 350`)으로 **클러스터 분할** — 가까운 통과는 한 묶음,
|
||||
멀리 떨어진 통과(예: 대전조차장 두 차례 진입)는 별도 묶음 → 각자 브래킷/라벨 1개.
|
||||
- 클러스터당: 각 통과 px에 수직 드롭(top 46, h4) + 양끝을 잇는 수평 점선(top 50) + 중앙(centerPx) 라벨 1개.
|
||||
- 단일 통과는 브래킷 없이 자기 위치 라벨 1개(기존 동작 유지).
|
||||
- 색(passed/neutral) 판정 기준을 통과 px → 클러스터 `centerPx` 로 변경. 미도착(unreached)은 묶음 내
|
||||
하나라도 있으면 neutral 유지.
|
||||
|
||||
## 검증
|
||||
- `tsc --noEmit` 통과 (node 20 환경).
|
||||
|
||||
## 후속 수정 (가로 점선 미표시 → 거리 클러스터링 제거)
|
||||
- 1차 구현은 `GROUP_GAP_PX = 350` 으로 동명 통과를 거리별 클러스터로 분할 → 대전조차장역 두 통과가
|
||||
~1050px 떨어져 각각 단일 묶음이 되어 브래킷(가로 점선)이 안 나옴.
|
||||
- 수정: 거리 클러스터링 제거. **동명(title) 통과는 거리와 무관하게 하나로 묶어** lo~hi 전체에
|
||||
수평 점선 + 각 통과 드롭 + 중앙 라벨 1개를 그림. (사용자 요청: "가로 점선이 보여야해")
|
||||
|
||||
## 다음 단계 참고
|
||||
- 브래킷 수직 위치(드롭 46~50, 수평 50, 라벨행 51)는 트랙 하단(46.5)/labelRow(51) 기준. 라벨 폰트/행간
|
||||
변경 시 함께 점검.
|
||||
- 동명 통과가 매우 멀리 떨어진 경우 중앙 라벨이 빈 트랙 중앙에 놓일 수 있음(가로 점선으로 연결됨). 추후
|
||||
묶음 분할이 다시 필요하면 거리 임계값 방식을 재도입할 것.
|
||||
@@ -0,0 +1,35 @@
|
||||
# 스테이션바 거리축(이동량 기반) 전환
|
||||
|
||||
**소요 시간**: 약 20분
|
||||
**Context 사용량**: input ~90k / output ~9k tokens
|
||||
|
||||
## 결론
|
||||
스테이션바 커서/구간/마커의 가로축을 **시간(프레임) 선형 → 누적 이동거리(거리축)** 로 전환.
|
||||
드론이 호버링(이동 없음)하면 커서가 멈추고, 실제로 이동할 때만 커서가 진행한다. (사용자 요청 A안)
|
||||
|
||||
## 근거 / 변경 내용
|
||||
- 파일: `client/src/stationbar/StationBar.tsx`
|
||||
- 기존: `px = 시작 + (currentTime/duration) × 트랙폭` — 순수 시간 정비례. 호버 중에도 커서 전진(배지 측점값은 정지 → 불일치).
|
||||
- 변경: 시간↔진행도 매핑을 거리축으로 교체. 핵심은 매핑 함수 한 곳에 집중되어 커서·구간색·구조물 마커가 일관되게 따라옴.
|
||||
1. `progRef`: 프레임별 `{time, frac}` 배열. `frac` = 누적 이동거리 비율(0~1, 단조 비감소).
|
||||
2. 진행도 산출(viewedRef 빌드 useEffect): 연속 측점값(chain)을 ±W(8프레임) 이동평균으로 평활(GPS 지터 제거) 후
|
||||
프레임간 `|Δ|` 누적 = 실제 이동량. 전체로 정규화.
|
||||
3. `cumFracAtTime(t)`: 시간→진행비율(이진탐색+선형보간). progRef 미준비 시 시간선형 폴백.
|
||||
4. `timeAtFrac(f)`: 역변환(진행비율→시간) — 바 클릭/측점 seek 용.
|
||||
5. `pxAtTime(t) = 시작 + cumFracAtTime(t) × 트랙폭` — 모든 배치가 공유.
|
||||
6. 라이브 커서 RAF, `seekToTrackX` 도 거리축 함수 사용.
|
||||
|
||||
## 동작
|
||||
- 호버(이동 無): frac 정체 → 커서 정지. 이동: frac 증가 → 좌→우 진행.
|
||||
- 전진/후진 모두 이동량으로 더해져 커서는 단조 진행(방향은 기존 주황/청록 구간색으로 구분).
|
||||
- 배지 측점값과 커서가 동일 데이터(연속 측점값)에서 파생 → 표시·이동 일관.
|
||||
|
||||
## 검증
|
||||
- `tsc --noEmit` 통과 + `client/dist` 빌드 완료. (pm2 ghiVideo 는 정적 서빙 → 재시작 불필요, 브라우저 새로고침)
|
||||
|
||||
## 다음 단계 참고 / 되돌리기
|
||||
- 사용자가 "이상하면 원복" 예정. 되돌리려면 `pxAtTime`/`cumFracAtTime`/`timeAtFrac` 를 기존 시간선형
|
||||
(`(t/duration)`)으로 복귀시키고 `progRef`/평활 블록 제거하면 됨.
|
||||
- 평활 반폭 `W=8`, 호버 판정은 평활로 처리(별도 데드밴드 없음). 호버 중 커서가 미세하게 기어가면 W를 키우거나
|
||||
소량 데드밴드 추가.
|
||||
- 부작용: 호버가 길어도 바에서 폭 0으로 수렴 → 바 클릭으로 호버 내부 시간 미세 seek 불가(영상 타임라인 사용).
|
||||
@@ -0,0 +1,43 @@
|
||||
# 스테이션바 측점거리(체이니지) 비례 축 전환 (B안)
|
||||
|
||||
**소요 시간**: 약 30분
|
||||
**Context 사용량**: input ~150k / output ~14k tokens
|
||||
|
||||
## 결론
|
||||
스테이션바 x축을 직전 A안(누적 이동거리) → **측점값(체이니지) 비례 축**으로 전환.
|
||||
바 가로위치가 실제 측점거리에 비례하고, 커서는 드론의 현재 측점 위치를 표시한다(멈추면 정지, 후진하면 좌로 이동).
|
||||
측점 10m 차이 구조물이 바에서도 가깝게 표시된다. 색: '진행색 + 커서색' 안 선택.
|
||||
|
||||
## 배경
|
||||
- A안(누적 이동거리)은 두 구조물이 측점상 10m여도 그 사이 드론이 앞뒤로 많이 이동하면 누적거리가 커져 바에서 멀게 표시됨 → 사용자 지적.
|
||||
- 요구: 바는 측점거리에 비례, 재생 중 실제 드론 위치를 바에 표시.
|
||||
|
||||
## 변경 내용 (client/src/stationbar/StationBar.tsx)
|
||||
- `chainRef = {time, chain(평활), minC, maxC}` 생성(ready useEffect). chain 은 ±8프레임 이동평균(지터 제거).
|
||||
- 매핑: `pxAtChain(측점) = START + clamp((c-minC)/(maxC-minC)) * timeTrackWidth`,
|
||||
`chainAtTime(t)`(이진탐색+보간), `pxAtTime(t) = pxAtChain(chainAtTime(t))`. 커서·구조물 공유.
|
||||
- 커서: `progressPx = pxAtTime(currentTime)`, RAF 도 `pxAtTime(liveTime)`.
|
||||
- 색(측점축은 방향 리본 표현 불가): `trackGradient`=주황 음영 단일, `trackGradientIdle`=회색 단일,
|
||||
`dividers=[]`. 방향은 `currentReverse`(측점값 0.4s 전 대비 감소=후진)로 커서 색만 구분.
|
||||
- 구조물 중복 제거: `dedupeMarksByTitle`(title 당 1개) — 측점축에선 동명 다중통과가 한 자리에 겹침.
|
||||
→ Timeline 다중통과 묶음 점선(브래킷)은 자연 소멸.
|
||||
- seek: 클릭 px → 목표 측점 → 허용오차 내 프레임 중 현재 시간에 가장 가까운 통과로 onSeek.
|
||||
- `barSegments` 는 렌더 미사용(쿼리 useMemo 는 kmLabels 위해 유지), `cumFracAtTime/timeAtFrac/progRef` 제거.
|
||||
|
||||
## 후속 수정 — 축 범위 '노선 고정' (데이터 무관 절대 측점)
|
||||
- 문제: 축 범위를 드론 커버 구간 [minC,maxC] 로 잡아 영상마다 양끝 측점이 바뀜 → 같은 구조물이 다른
|
||||
위치/측점으로 표시됨(사용자 지적: "어떤 데이터로 재생하느냐에 따라 측점이 바뀌면 안돼").
|
||||
- 수정:
|
||||
1. 축 범위 minC/maxC = stationLine(역 POI, km 오름차순) 첫/끝 측점값 → 노선 고정. 드론 chain 은
|
||||
projectChainage 로 이 범위 안에 들어옴.
|
||||
2. 구조물 px = pxAtChain(구조물 측점값) → 통과 시각이 아닌 '절대 측점'으로 배치(마일리지 라벨과 일치,
|
||||
영상 달라도 동일 위치). dedupeMarksByTitle 로 동명 1개.
|
||||
- 효과: 좌(낮은 측점)→우(높은 측점) 고정 자. 구조물 위치 불변, 커서만 드론 현재 측점을 가리킴.
|
||||
|
||||
## 검증
|
||||
- `tsc --noEmit` 통과 + client 빌드 완료.
|
||||
|
||||
## 알려진 한계 / 다음 단계
|
||||
- 진짜 비례라 측점이 매우 가까운 라벨끼리 겹쳐 보일 수 있음(법동가도교/대전조차장역). 필요시 라벨 충돌회피 후처리.
|
||||
- 방향 전진/후진 색 리본 제거됨(커서 색으로만 방향). endGapPx(미도착 종점역) 우측 밴드/표식은 유지.
|
||||
- 이상 시 A안(누적거리 축) 또는 그 이전 시간선형으로 되돌릴 수 있음. 핵심 매핑은 pxAtChain/chainAtTime/pxAtTime 3개에 집중.
|
||||
@@ -0,0 +1,40 @@
|
||||
# 역사(역) 통과 마커 누락 수정 — 좌표근접 합집합 보강
|
||||
|
||||
**소요 시간**: 약 30분
|
||||
**Context 사용량**: input ~150k / output ~9k tokens
|
||||
|
||||
## 결론
|
||||
대전조차장역처럼 같은 역을 여러 번 지날 때, 일부 통과(예: 162k080 재진입)에 스테이션바 동그라미(역사 원형 마커)가
|
||||
빠지던 문제를 수정. 측점-기준 탐지에 더해 **좌표근접(`pxPassesTo`) 통과를 합집합으로 보강**해 누락된 재진입까지 마커를 찍는다.
|
||||
|
||||
## 원인
|
||||
- 파일: `client/src/stationbar/StationBar.tsx` (`structureMarks`)
|
||||
- 역사(대전조차장역)는 `geoData`에서 좌표(lat/lon)만 있고 측점값(`station`)이 없는 RouteStructure 로 들어온다.
|
||||
- 통과 탐지가 `projectChainage(좌표)` 한 값으로 `pxPassesAtMileage` 만 수행.
|
||||
- 탐지 체인이 `if (!passes.length)` **단락 평가**라, 측점-기준이 통과를 하나라도 찾으면 좌표근접 탐지가 안 돌아감.
|
||||
- 종점/조차장 구역은 측점선(폴리라인) 투영이 불안정 → 한 통과는 투영 측점이 ±`stationTolerance`(기본 20m)
|
||||
안에 들어와 잡히지만, 다른 통과는 20m 밖으로 벗어나 누락. 첫 통과를 이미 찾았으니 좌표근접 보강도 안 돌아 영영 빠짐.
|
||||
|
||||
## 변경 내용
|
||||
- `cat === '역사'` 이고 lat/lon 이 있으면, 측점-기준 탐지 결과에 `pxPassesTo(lat, lon, off)` 통과를 합집합으로 추가.
|
||||
- 같은 통과(거의 동일 px)는 24px 임계로 중복 제거 → 멀리 떨어진 실제 재진입만 추가됨.
|
||||
(TRACK_WIDTH_PX = 1509.5, 24px ≈ 1.6% → 동일 best 프레임 중복만 합쳐지고 떨어진 재진입은 보존)
|
||||
- km 라벨 동작(stationKmVal 우선)은 기존 유지 — 이번 수정은 '마커 누락'만 대상.
|
||||
|
||||
## 검증
|
||||
- `tsc --noEmit` + `vite build` 통과 (node v20.20.2, `~/.nvm/versions/node/v20.20.2/bin`).
|
||||
- 사용자 화면(포트 55000)에서 폴더 재선택 후 162k080 재진입 동그라미 표출 **확인 완료**.
|
||||
|
||||
## ★ 진짜 블로커 — 빌드/서빙 구조 (이번 세션 최대 교훈)
|
||||
- 증상: 소스를 여러 번 고쳐도 화면이 전혀 안 바뀜("여전히 빠져있어" 반복).
|
||||
- 원인: 사용자가 보는 화면은 dev 서버가 아니라 **PM2 앱 `ghiVideo`(포트 55000)** 이고,
|
||||
`server/dist/.../app.js` 가 `express.static(client/dist)` 로 **빌드 결과를 서빙**한다(ecosystem.config.js).
|
||||
→ `client/src` 만 고치면 절대 반영 안 됨. `client/dist` 가 6/26 빌드로 고정돼 있었음.
|
||||
- 형제 디렉토리 혼선도 있었음: `videoplayer`(PM2, 5173), `defVideo`(PM2) 가 함께 떠 있음. 작업 경로는 GhiVideo.
|
||||
- 해결: `npm run build -w client` (node 20) 로 `client/dist` 갱신 → 강력 새로고침(Ctrl+Shift+R) → 폴더 재선택.
|
||||
- 메모리에 구조 기록: `ghivideo-serve-build`.
|
||||
|
||||
## 참고 / 후속
|
||||
- `stationTolerance` 타입 주석(geo.ts:139)은 "기본 40"으로 적혀 있으나 실제 코드 기본값은 `?? 20`. 불일치 정리 권장.
|
||||
- 좌표근접 탐지 임계는 `pxPassesTo` 내부 `Math.max(100, gmin*3)`(StationBar.tsx:499) — 측점 tolerance(20m)와 별개.
|
||||
- 출발점 부근 '조기 통과' 오탐 우려는 역사 마커에 한해 합집합으로 한정 → 일반 측점-기준 동작에는 영향 없음.
|
||||
@@ -0,0 +1,23 @@
|
||||
# 측점값 라벨 겹침 숨김 (먼저 나온 것만 표시)
|
||||
|
||||
**소요 시간**: 약 10분
|
||||
**Context 사용량**: input ~190k / output ~11k tokens
|
||||
|
||||
## 결론
|
||||
스테이션바에서 측점값(예: 157k990) 라벨이 근접 통과로 겹쳐 보일 때, px 오름차순으로 **먼저 나온(좌측) 라벨만
|
||||
표시하고 뒤엣것은 숨김** 처리. 아이콘(동그라미)과 점선 브래킷은 그대로 둔다(측점값 텍스트만 숨김).
|
||||
|
||||
## 변경 내용
|
||||
- 파일: `client/src/stationbar/components/Timeline/Timeline.tsx`
|
||||
- `structs`(px 오름차순) 직후 `showMileage: boolean[]` 산출:
|
||||
- `labelW(v) = v.length * 7.5 + 4` (13px bold + 2px stroke 대략 폭)
|
||||
- 직전 '표시한' 라벨과 중앙 간격이 `(lastW + w)/2` 미만이면 겹침으로 보고 숨김(`showMileage[i]=false`).
|
||||
- 표시한 경우에만 `lastPx/lastW` 갱신 → 연쇄 겹침도 누적 기준으로 정확히 판정.
|
||||
- `mileageRow` 렌더 조건을 `s.km >= 0` → `s.km >= 0 && showMileage[i]` 로 변경.
|
||||
|
||||
## 검증
|
||||
- `tsc + vite build` 통과(node 20). client/dist 재빌드 → 55000 강력 새로고침으로 확인.
|
||||
|
||||
## 참고
|
||||
- 라벨은 trackWrapper transform 하위라 px 공간 비교가 시각 겹침과 동치(균일 스케일).
|
||||
- 폭 추정 상수(7.5/+4)는 폰트/스트로크 변경 시 재조정.
|
||||
@@ -0,0 +1,26 @@
|
||||
# 선형·드론궤적 토글을 재생바 버튼으로 이동
|
||||
|
||||
**소요 시간**: 약 12분
|
||||
**Context 사용량**: input ~210k / output ~13k tokens
|
||||
|
||||
## 결론
|
||||
영상 오버레이의 "선형(중심선)"·"드론 궤적" 표시 토글을 **하단 재생바(VideoPlayer)의 겹침제외 버튼 왼쪽**에
|
||||
버튼 2개로 추가하고, 화면표시 옵션 창의 기존 동일 기능 버튼 2개는 제거. 버튼 폭은 겹침제외와 동일(min-w-[96px]).
|
||||
|
||||
## 변경 내용
|
||||
- `client/src/store/settingsStore.ts`
|
||||
- `showCenterline`(기본 true), `showDronePath`(기본 true) + 각 setter 추가. persist 로 지속.
|
||||
- `client/src/components/overlay/StationOverlay.tsx`
|
||||
- 로컬 `useState` 였던 `showCenterline`/`showDronePath` → 설정 스토어 구독으로 전환(refs/리셋은 그대로).
|
||||
- 화면표시 옵션 창의 선형/드론궤적 토글 버튼 2개 제거. (드론궤적 경로표고·투명도 슬라이더는 유지)
|
||||
- `client/src/components/player/VideoPlayer.tsx`
|
||||
- 스토어 셀렉터 추가, 겹침제외 버튼 바로 앞(좌측)에 `선형 ON/OFF`·`드론궤적 ON/OFF` 버튼 삽입.
|
||||
- 스타일: 겹침제외와 동일 폭(min-w-[96px]), 색은 기능별(선형=red, 드론궤적=sky).
|
||||
- 노출 조건은 겹침제외와 동일: 영상제어 ON + geoLoaded.
|
||||
|
||||
## 검증
|
||||
- `tsc + vite build` 통과(node 20). client/dist 재빌드 → 55000 강력 새로고침 확인.
|
||||
|
||||
## 참고
|
||||
- 상태를 스토어로 올려 바 버튼과 오버레이 렌더가 단일 소스 공유.
|
||||
- '기본값' 리셋(StationOverlay)도 스토어 setter 로 동작 유지.
|
||||
@@ -0,0 +1,22 @@
|
||||
# 경로표고·투명도 항상 표시 + 겹침제외 버튼 숨김
|
||||
|
||||
**소요 시간**: 약 5분
|
||||
**Context 사용량**: input ~225k / output ~14k tokens
|
||||
|
||||
## 결론
|
||||
화면표시 옵션 창의 "경로 표고"·"투명도" 슬라이더를 드론궤적 ON/OFF와 무관하게 항상 표시하도록 변경.
|
||||
재생바의 "겹침제외 ON/OFF" 버튼은 UI에서 숨김(기능/상태는 보존).
|
||||
|
||||
## 변경 내용
|
||||
- `client/src/components/overlay/StationOverlay.tsx`
|
||||
- 경로표고·투명도 ParamRow 를 감싸던 `{showDronePath && (...)}` 게이트 제거 → 항상 렌더.
|
||||
- `client/src/components/player/VideoPlayer.tsx`
|
||||
- 겹침제외 토글 버튼 JSX 주석 처리(숨김). `poiOverlapExclude` 상태는 스토어에 그대로 존재(기본 ON),
|
||||
StationOverlay/StationBar/RoutePanel 이 계속 구독. 버튼만 비표시. 다시 보이려면 주석 해제.
|
||||
|
||||
## 검증
|
||||
- `tsc --noEmit` + `vite build` 통과(node 20). client/dist 재빌드(11:11).
|
||||
|
||||
## 참고
|
||||
- 겹침제외 selector(poiOverlapExclude/setPoiOverlapExclude)는 VideoPlayer 에 선언만 남아 미사용 상태지만
|
||||
tsc 통과(noUnusedLocals 미적용). 버튼 재노출 대비 보존.
|
||||
@@ -0,0 +1,28 @@
|
||||
# 좌측 패널 버튼 바 이동 + 측점진단 버튼 삭제(GPS·고도 항상 표시)
|
||||
|
||||
**소요 시간**: 약 8분
|
||||
**Context 사용량**: input ~245k / output ~16k tokens
|
||||
|
||||
## 결론
|
||||
- "좌측 노선 패널 표시" 버튼을 화면표시 옵션 창에서 제거하고, 하단 재생바의 **선형 버튼 왼편**에
|
||||
"좌측 패널 ON/OFF" 버튼으로 이동(동일 폭 min-w-[96px], emerald).
|
||||
- "측점 진단" 토글 버튼 삭제. 드론 GPS·절대고도 HUD 는 **항상 표시**(토글 없음)로 변경.
|
||||
|
||||
## 변경 내용
|
||||
- `client/src/components/overlay/StationOverlay.tsx`
|
||||
- 표시 토글 영역에서 "좌측 노선 패널", "측점 진단" 버튼 2개 제거.
|
||||
- 미사용된 `showRoutePanel`/`setShowRoutePanel`, `showStationDiag`/`setShowStationDiag` 선언 제거.
|
||||
- `client/src/components/player/VideoPlayer.tsx`
|
||||
- `setShowRoutePanel` 셀렉터 추가, 선형 버튼 앞(좌측)에 "좌측 패널 ON/OFF" 버튼 삽입.
|
||||
- `stationDiag` useMemo 의 `!showStationDiag` 게이트 제거 → 프레임만 있으면 항상 계산.
|
||||
- GPS·고도 HUD 렌더의 `showStationDiag &&` 게이트 제거 → 항상 표시.
|
||||
- 미사용된 `showStationDiag` 셀렉터 제거.
|
||||
|
||||
## 바 버튼 순서 (영상제어 ON + geoLoaded)
|
||||
좌측 패널 → 선형 → 드론궤적 (겹침제외는 이전 요청대로 숨김)
|
||||
|
||||
## 검증
|
||||
- `tsc + vite build` 통과(node 20). client/dist 재빌드(11:15).
|
||||
|
||||
## 참고
|
||||
- settingsStore 의 showStationDiag 필드는 남겨둠(다른 잠재 구독 대비). 현재 토글 UI 는 없음.
|
||||
@@ -0,0 +1,19 @@
|
||||
# 재생바 요소 재배치 — 드론궤적+위치정보 인접, 시설등급 우측
|
||||
|
||||
**소요 시간**: 약 3분
|
||||
**Context 사용량**: input ~260k / output ~17k tokens
|
||||
|
||||
## 결론
|
||||
하단 재생바에서 드론 위치정보(GPS·고도) HUD 를 드론궤적 버튼 바로 우측에 붙이고, 시설등급 옵션 패널을
|
||||
그 오른편(맨 뒤)으로 옮겨 순서를 정리.
|
||||
|
||||
## 변경 내용
|
||||
- `client/src/components/player/VideoPlayer.tsx`
|
||||
- 드론 위치정보 HUD(`{stationDiag && ...}`)를 시설등급 패널 뒤 → **드론궤적 버튼 직후**로 이동.
|
||||
- 시설등급 패널이 HUD 뒤(우측)에 오도록 순서 변경.
|
||||
|
||||
## 바 버튼/요소 순서 (영상제어 ON + geoLoaded)
|
||||
좌측 패널 → 선형 → 드론궤적 → 드론 위치정보(GPS·고도) → 시설등급
|
||||
|
||||
## 검증
|
||||
- `tsc + vite build` 통과(node 20). client/dist 재빌드(11:19).
|
||||
@@ -0,0 +1,25 @@
|
||||
# 활성화 상태 색상 amber(겨자/오렌지)로 통일
|
||||
|
||||
**소요 시간**: 약 4분
|
||||
**Context 사용량**: input ~280k / output ~18k tokens
|
||||
|
||||
## 결론
|
||||
재생바·우측 토글의 활성화(ON/선택) 표시 색을 제각각(파랑/초록/빨강/하늘/회색)에서 **amber-400 + 검정 글씨**로
|
||||
통일. 사용자 요청(노란/겨자/오렌지 계열).
|
||||
|
||||
## 변경 내용
|
||||
- `client/src/components/player/VideoPlayer.tsx`
|
||||
- 영상제어 ON: blue → `bg-amber-400 border-amber-300 text-black`
|
||||
- 배속 선택: `bg-blue-600 text-white` → `bg-amber-400 text-black`
|
||||
- 좌측 패널 / 선형 / 드론궤적 ON: emerald/red/sky → amber-400
|
||||
- 시설등급 체크박스: `accent-blue-500` → `accent-amber-400`
|
||||
- `client/src/components/overlay/StationOverlay.tsx`
|
||||
- "화면표시 옵션"(showDisplay) 활성: sky-700 → amber-400
|
||||
- "카메라 파라미터"(showControls) 활성: gray-700 → amber-400
|
||||
|
||||
## 검증
|
||||
- `tsc + vite build` 통과(node 20). client/dist 재빌드(11:23).
|
||||
|
||||
## 참고
|
||||
- 비활성(OFF)은 기존 `bg-black/70 border-gray-600 text-gray-300` 유지.
|
||||
- 더 진하게 원하면 amber-500 으로 교체.
|
||||
@@ -0,0 +1,27 @@
|
||||
# 작업 보고서 md/html/pdf 3종 생성 + 변환 스크립트
|
||||
|
||||
**소요 시간**: 약 12분
|
||||
**Context 사용량**: input ~310k / output ~21k tokens
|
||||
|
||||
## 결론
|
||||
오늘 작업 내용을 팀장 보고용 문서로 작성하고, **md(베이스) → html + pdf** 3종 산출물을 생성.
|
||||
재생성용 변환 스크립트와 스타일을 저장소에 추가.
|
||||
|
||||
## 산출물
|
||||
- `docs/보고서_2026-06-29_스테이션바_UI개선.md` (베이스)
|
||||
- `docs/보고서_2026-06-29_스테이션바_UI개선.html` (자체완결, CSS 인라인)
|
||||
- `docs/보고서_2026-06-29_스테이션바_UI개선.pdf` (A4 3p, 한글 임베드, 페이지번호)
|
||||
|
||||
## 추가 파일
|
||||
- `scripts/md2docs.sh` — `md2docs.sh <파일.md>` 로 같은 경로에 html+pdf 생성.
|
||||
- `docs/assets/report.css` — 보고서 스타일(앰버 헤더/표/@page).
|
||||
|
||||
## 변환 환경 (메모: report-doc-toolchain)
|
||||
- pandoc 2.11 `--self-contained` 로 md→html (CSS 인라인 임베드).
|
||||
- weasyprint 61.2 로 html→pdf. **pydyf 는 0.10 으로 핀**(0.11 은 API 변경으로 TypeError).
|
||||
- 한글 폰트: 환경에 CJK 폰트 부재 → Windows 맑은고딕(`/mnt/c/Windows/Fonts/malgun*.ttf`)을
|
||||
`~/.local/share/fonts` 로 복사 후 `fc-cache`. WSL 이라 가능.
|
||||
|
||||
## 검증
|
||||
- PDF 헤더 %PDF-1.7, 3페이지, `/FontFile`(Malgun 글리프 임베드) 확인 → 모든 뷰어에서 한글 정상.
|
||||
- scripts/md2docs.sh 재실행으로 동일 산출물 재현 확인.
|
||||
@@ -0,0 +1,27 @@
|
||||
# 측점 검색 순환 점프 + 입력값 유지/블러삭제
|
||||
|
||||
**소요 시간**: 약 12분
|
||||
**Context 사용량**: input ~360k / output ~24k tokens
|
||||
|
||||
## 결론
|
||||
측점값이 여러 곳(여러 통과)에 존재할 때, 측점 검색을 **통과방향 순환(find-next)** 으로 개선.
|
||||
Enter 반복으로 다음 통과로 이동, 끝에서 처음으로 순환. 입력값은 Enter 후 유지하고 blur 시 삭제.
|
||||
|
||||
## 변경 내용
|
||||
- `client/src/stationbar/StationBar.tsx` — `handleJumpToMileage` 재작성
|
||||
- 입력 측점의 '모든 통과'를 허용오차(stationTolerance, 기본 20m) 내 연속구간=1통과로 묶어 시간 오름차순 수집.
|
||||
- 기준 시각(baseT)에서 통과방향(시간 증가) 다음 통과로 이동, 없으면 passes[0] 으로 순환.
|
||||
- `jumpRef`(km,time)로 연속 검색 판정: 같은 측점이고 커서가 직전 점프 위치 그대로(|Δ|<0.1s)일 때만
|
||||
연속(다음 통과). 재생/클릭으로 커서 이동했거나 다른 측점이면 현재 커서(라이브)에서 새로 시작.
|
||||
- 라이브 시각은 `timeRef?.current ?? currentTime`. 커버 범위 밖(±20m) 입력은 무시(기존 유지).
|
||||
- 허용오차 내 통과가 없으면 전역 최근접 1개로 폴백.
|
||||
- `client/src/stationbar/components/PlaybackControls/PlaybackControls.tsx`
|
||||
- Enter 시 `setQuery('')` 제거 → 입력값 유지(같은 값 재Enter 로 순환).
|
||||
- input `onBlur` → 입력값 삭제(다른 곳 클릭 시 언셀렉트).
|
||||
|
||||
## 검증
|
||||
- `tsc + vite build` 통과(node 20). client/dist 재빌드(12:12).
|
||||
|
||||
## 참고
|
||||
- 순환 순서는 '시간(이동거리축 진행) 오름차순' = 화면상 좌→우.
|
||||
- 향후 입력창 옆 'n/m' 위치 표시·매칭 하이라이트를 추가하면 인지성 더 향상.
|
||||
@@ -0,0 +1,27 @@
|
||||
# 측점 검색을 '화면 마커' 기준으로 전환 (누락 통과까지 검색)
|
||||
|
||||
**소요 시간**: 약 7분
|
||||
**Context 사용량**: input ~390k / output ~26k tokens
|
||||
|
||||
## 결론
|
||||
측점 검색 점프가 일부 통과로 가지 못하던 문제 해결. 검색 후보를 chain 기준 → **화면에 보이는
|
||||
마커(structureMarks) 기준**으로 바꿔, 바에 표시된 모든 동그라미(좌표근접 탐지 통과 포함)를 순환하도록 함.
|
||||
|
||||
## 원인
|
||||
- 마커(structureMarks)는 측점기준 + 좌표근접(pxPassesTo) 합집합으로 통과를 찍음(조차장 등 chain 어긋난 통과 포함).
|
||||
- 그러나 검색(handleJumpToMileage)은 `|arr[i].chain - km| < TOL` 측점기준으로만 통과를 찾음
|
||||
→ chain 이 162080 에서 벗어난 통과(마커는 있음)는 검색이 못 감.
|
||||
|
||||
## 변경 내용 (`client/src/stationbar/StationBar.tsx`)
|
||||
- handleJumpToMileage 후보 산출 1순위:
|
||||
`structureMarks.filter(표시 10m 반올림값 == 입력값).map(px → timeAtFrac 로 시각 역변환)`.
|
||||
- 2순위(마커 없는 임의 측점): 기존 chain 연속구간 탐지 → 없으면 전역 최근접 폴백.
|
||||
- 시간 정렬 + 0.3s 근접 중복 제거 후, 통과방향 순환(기존 jumpRef 로직 유지).
|
||||
- deps 에 structureMarks, timeAtFrac, timeTrackWidth 추가.
|
||||
|
||||
## 검증
|
||||
- `tsc + vite build` 통과(node 20). client/dist 재빌드(12:19).
|
||||
|
||||
## 참고
|
||||
- 매칭은 표시 라벨과 동일하게 10m 반올림 일치 → 사용자가 보는 값과 검색 일치.
|
||||
- 종점 미도착으로 px=TRACK_END 로 이동된 마커는 역변환 시 끝 시각이 됨(허용).
|
||||
@@ -0,0 +1,30 @@
|
||||
# 좌표 반경으로만 잡힌 마커의 측점값 라벨/검색 제외
|
||||
|
||||
**소요 시간**: 약 8분
|
||||
**Context 사용량**: input ~420k / output ~28k tokens
|
||||
|
||||
## 결론
|
||||
역 마커가 좌표 반경(pxPassesTo)으로만 잡혀 그 위치에 실제 측점값이 존재하지 않는 경우,
|
||||
**측점값 라벨을 숨기고 검색 대상에서도 제외**. 마커(동그라미)는 유지.
|
||||
|
||||
## 판정 (kmExists)
|
||||
- 각 통과의 실제 측점 = 그 통과 프레임의 연속 chain(p.km, 좌표탐지) / 측점기준 탐지는 targetM(=stationKmVal).
|
||||
- 표시 측점값 = stationKmVal(역 좌표 투영).
|
||||
- `kmExists = stationKmVal==null || |p.km - stationKmVal| <= stationTolerance(기본 20m)`
|
||||
- 측점기준 통과 → 일치 → true
|
||||
- 좌표 반경만으로 잡힌 통과(측점 동떨어짐) → false
|
||||
|
||||
## 변경 내용
|
||||
- `client/src/stationbar/StationBar.tsx`
|
||||
- `StructMark`에 `kmExists?: boolean` 추가.
|
||||
- structureMarks 두 경로(storeStructures / POI 폴백) push 시 kmExists 계산.
|
||||
- handleJumpToMileage 마커 후보 필터에 `m.kmExists !== false` 추가(값 없는 마커로는 검색 점프 안 함).
|
||||
- `client/src/stationbar/components/Timeline/Timeline.tsx`
|
||||
- 측점값 라벨 표시 루프에서 `s.kmExists === false`면 라벨 생략(동그라미·이름 라벨은 유지).
|
||||
|
||||
## 검증
|
||||
- `tsc + vite build` 통과(node 20). client/dist 재빌드(12:27).
|
||||
|
||||
## 참고
|
||||
- 측점기준 통과는 km=targetM=stationKmVal 로 동일 → 항상 kmExists=true(정상 측점은 영향 없음).
|
||||
- 마커 동그라미/브래킷/역명 라벨은 그대로. 숨기는 것은 '측점값(10m)' 라벨과 그 값으로의 검색뿐.
|
||||
@@ -0,0 +1,28 @@
|
||||
# 55000 포트 외부(LAN) 접속 활성화
|
||||
|
||||
**소요 시간**: 약 10분
|
||||
**Context 사용량**: input ~450k / output ~30k tokens
|
||||
|
||||
## 결론
|
||||
PM2 ghiVideo(55000) 를 같은 네트워크의 다른 기기에서 접속 가능하게 설정.
|
||||
접속 주소: **http://172.16.40.179:55000/** (Windows 이더넷 LAN IP).
|
||||
|
||||
## 점검 결과
|
||||
- 서버: `0.0.0.0:55000` 바인딩 ✓, CORS `origin:true`(모든 origin 허용) ✓ → 서버측 준비 완료.
|
||||
- 차단 원인: WSL2 기본 NAT 모드 → WSL IP(172.21.50.250)는 LAN 직접 도달 불가.
|
||||
|
||||
## 조치 (Windows 관리자/UAC)
|
||||
- netsh portproxy 추가: `0.0.0.0:55000 → 172.21.50.250:55000`
|
||||
(기존에 8080/3000/5173 등 동일 패턴 사용 중이라 55000만 추가)
|
||||
- 방화벽 인바운드 허용 규칙 "WSL ghiVideo 55000" (TCP 55000) 추가.
|
||||
- 스크립트: `C:\Windows\Temp\wsl_port_55000.cmd` (UAC 로 실행).
|
||||
|
||||
## 검증
|
||||
- portproxy 목록에 55000 등록 확인.
|
||||
- 방화벽 규칙 Enabled/Inbound/Allow 확인.
|
||||
- Windows→`http://localhost:55000/` Invoke-WebRequest → **200**.
|
||||
|
||||
## 주의 / 후속
|
||||
- WSL IP 는 재부팅/`wsl --shutdown` 시 변경 → portproxy(고정 172.21.50.250) 끊김. 재적용 필요.
|
||||
(요청 시 WSL IP 자동감지 재설정 스크립트 작성 가능)
|
||||
- 172.16.x 사내망 한정. 인터넷 접속은 공유기/회사 방화벽 포트포워딩 추가 필요.
|
||||
@@ -0,0 +1,302 @@
|
||||
# 드론 자세 평활/보간 + 영상 오버레이 렌더링 파이프라인
|
||||
|
||||
> 후임자 인계 + 특허 문서용 상세 기술 문서
|
||||
> 대상 소스: [client/src/components/overlay/StationOverlay.tsx](../../client/src/components/overlay/StationOverlay.tsx) (1639줄), 투영 유틸 [client/src/utils/geoProjection.ts](../../client/src/utils/geoProjection.ts)
|
||||
|
||||
---
|
||||
|
||||
## 1. 기술 개요 / 목적
|
||||
|
||||
드론으로 촬영한 철도 선로 주행영상 위에, 측점(스테이션)·선로구조물(교량/터널/구교)·POI·선로 중심선·드론 GPS 궤적·나침반을 **영상 픽셀에 정확히 정합(AR 오버레이)** 시켜 표출하는 렌더링 파이프라인이다.
|
||||
|
||||
입력 데이터는 두 종류로, (a) 프레임별 드론 자세/위치 로그(SRT 추출: `frame, lat, lon, altitude, yaw, pitch, roll`)와 (b) 지리정보(측점·중심선·POI·구조물의 위경도+표고)이다. 핵심 변환은 핀홀 카메라 모델 기반의 월드(EPSG:5186 TM ENU) → 카메라 → 정규화 픽셀 투영([geoProjection.ts#L301](../../client/src/utils/geoProjection.ts#L301), [#L126](../../client/src/utils/geoProjection.ts#L126))이다.
|
||||
|
||||
이 문서가 다루는 본질적 과제는 **두 가지 노이즈/지연 문제**이다.
|
||||
|
||||
1. **자세/GPS 노이즈** — 원시 SRT 로그는 떨림이 있어 그대로 투영하면 라벨이 매 프레임 진동한다. 그러나 단순 평균은 회전 구간을 뭉개 라벨이 늦게 따라온다(지연). → **에지 보존 적응형 평활(smoothFrame)** 로 해결.
|
||||
2. **렌더 부드러움 vs 계산 비용** — 라벨 가시성/겹침 판정은 비싸고(전 프레임 × 전 항목), 60fps 매끄러운 이동은 매 프레임 재투영을 요구한다. → **가시성은 사전계산(precompute), 좌표는 RAF에서 연속포즈로 재투영**하는 2단 분리로 해결.
|
||||
|
||||
---
|
||||
|
||||
## 2. 핵심 알고리즘
|
||||
|
||||
### 2.1 에지(회전경계) 보존 적응형 이동평균 — `smoothFrame`
|
||||
|
||||
[StationOverlay.tsx#L577](../../client/src/components/overlay/StationOverlay.tsx#L577)
|
||||
|
||||
원시 드론 프레임 배열에서 인덱스 `i`를 중심으로 좌우 최대 `halfWin`(기본 60프레임 ≈ 2초)까지 이동평균을 취하되, **중심 프레임의 yaw와 차이가 임계 `YAW_EDGE = 8°` 이내인 인접 프레임까지만 창을 확장**한다.
|
||||
|
||||
```typescript
|
||||
// StationOverlay.tsx:577
|
||||
const smoothFrame = useCallback((frames: DroneFrameBasic[], i: number, halfWin: number): DroneFrameBasic => {
|
||||
let lo = i, hi = i;
|
||||
if (frames.length > 1 && halfWin > 0) {
|
||||
const YAW_EDGE = 8; // deg — 중심 대비 이 이상 벌어지면 다른(회전) 구간으로 보고 평균 제외
|
||||
const yc = frames[i].yaw;
|
||||
const near = (idx: number) => Math.abs(((frames[idx].yaw - yc + 540) % 360) - 180) <= YAW_EDGE;
|
||||
const loMin = Math.max(0, i - halfWin);
|
||||
const hiMax = Math.min(frames.length - 1, i + halfWin);
|
||||
while (lo > loMin && near(lo - 1)) lo--; // 회전경계에서 멈춤
|
||||
while (hi < hiMax && near(hi + 1)) hi++;
|
||||
}
|
||||
```
|
||||
|
||||
**수식.** 창 `[lo, hi]`(요소 수 `n = hi − lo + 1`)에 대해 lat/lon/altitude/pitch/roll은 산술평균. yaw는 각도이므로 단순 산술평균 시 0°/360° 경계에서 깨진다 → **원형평균(circular mean)**:
|
||||
|
||||
```
|
||||
yaw̄ = atan2( (1/n)·Σ sin(yawₖ), (1/n)·Σ cos(yawₖ) )
|
||||
```
|
||||
|
||||
[StationOverlay.tsx#L590](../../client/src/components/overlay/StationOverlay.tsx#L590):
|
||||
|
||||
```typescript
|
||||
const yr = f.yaw * Math.PI / 180;
|
||||
sinYaw += Math.sin(yr); cosYaw += Math.cos(yr);
|
||||
// ...
|
||||
yaw: Math.atan2(sinYaw / n, cosYaw / n) * 180 / Math.PI,
|
||||
```
|
||||
|
||||
`near()`의 `((Δ + 540) % 360) − 180`은 yaw 차를 항상 `[−180°, 180°]`로 정규화하는 최단각 차이 패턴이다.
|
||||
|
||||
**의도 / 트레이드오프.**
|
||||
- 직선 주행: yaw가 거의 일정 → 창이 풀폭(±60fr)으로 펴져 GPS/자세 노이즈를 강하게 억제.
|
||||
- 회전 중: 회전경계에서 창이 좁아져(한쪽 또는 양쪽이 중심 근처에서 멈춤) **실제 자세를 즉시 추종(지연 없음)**.
|
||||
- 회전 종료 후: 회전 프레임을 배제한 채 창이 **한 프레임씩** 점진적으로 재확장 → 튐 없음.
|
||||
- 트레이드오프: `YAW_EDGE`가 너무 작으면 완만한 곡선도 회전으로 보아 노이즈 억제가 약해지고, 너무 크면 회전이 평균에 섞여 지연. 8°는 떨림(보통 <2°)과 실제 회전을 가르는 경험값.
|
||||
- (주석에 기록된) 과거 '변화율 기반 창 축소' 방식은 회전 종료 시 창이 한꺼번에 재확장되며 회전 프레임이 다시 섞여 한 번 튀는 부작용이 있어 경계보존 방식으로 교체됨 ([StationOverlay.tsx#L569](../../client/src/components/overlay/StationOverlay.tsx#L569) 주석).
|
||||
|
||||
### 2.2 연속 프레임번호 보간 포즈 — `poseAt`
|
||||
|
||||
[StationOverlay.tsx#L670](../../client/src/components/overlay/StationOverlay.tsx#L670)
|
||||
|
||||
영상의 부드러운 재생시간(60fps 벽시계 보간, §4.1)에서 산출된 **실수(實數) 프레임번호 `estFrame`** 에 대응하는 자세를 만든다. 이산 SRT 프레임만으로는 30fps 격자에 갇혀 라벨이 계단식으로 움직이므로, 양 이웃 프레임의 **평활 포즈(smoothFrame 결과)** 를 선형보간한다.
|
||||
|
||||
```typescript
|
||||
// StationOverlay.tsx:670 — 이진탐색으로 estFrame을 끼우는 두 프레임 찾기
|
||||
let lo = 0, hi = arr.length - 1;
|
||||
while (lo < hi) { const m = (lo + hi) >> 1; if (arr[m].frame < estFrame) lo = m + 1; else hi = m; }
|
||||
const i2 = lo, i1 = Math.max(0, lo - 1);
|
||||
const f1 = arr[i1].frame, f2 = arr[i2].frame;
|
||||
const frac = f2 > f1 ? Math.min(1, Math.max(0, (estFrame - f1) / (f2 - f1))) : 0;
|
||||
const a = smoothFrame(arr, i1, h), b = smoothFrame(arr, i2, h);
|
||||
const L = (x: number, y: number) => x + (y - x) * frac;
|
||||
let dy = b.yaw - a.yaw; dy = ((dy + 540) % 360) - 180; // 최단각
|
||||
return { ...a, frame: estFrame,
|
||||
lat: L(a.lat, b.lat), lon: L(a.lon, b.lon), altitude: L(a.altitude, b.altitude),
|
||||
pitch: L(a.pitch, b.pitch), roll: L(a.roll, b.roll), yaw: a.yaw + dy * frac };
|
||||
```
|
||||
|
||||
**수식.** 보간계수 `frac = (estFrame − f1)/(f2 − f1)`. 위치/피치/롤은 `L(x,y) = x + (y−x)·frac`. yaw는 359°→1° 같은 경계에서 긴 쪽으로 도는 것을 막기 위해 **최단각 보간**: `Δ = ((b.yaw − a.yaw + 540) % 360) − 180`, `yaw = a.yaw + Δ·frac`.
|
||||
|
||||
**트레이드오프.** O(log n) 이진탐색으로 호출당 비용이 낮아 매 RAF 프레임 호출 가능. smoothFrame을 매 프레임 두 번 호출하므로(`a`, `b`) 평활창이 매우 클 때는 비용이 늘 수 있으나, 실데이터 규모에서는 무시 가능.
|
||||
|
||||
### 2.3 속도적응 EMA + 이상치 거부 — `smoothStep` (One-Euro 방식)
|
||||
|
||||
[StationOverlay.tsx#L48](../../client/src/components/overlay/StationOverlay.tsx#L48)
|
||||
|
||||
`poseAt`로 재투영한 화면 정규좌표를 매 프레임 그대로 쓰면 잔여 떨림이 보인다. 화면단에서 One-Euro 필터 사상(idea)을 적용: **느릴 때(떨림)는 강하게 평활, 빠를 때(실제 팬)는 즉시 추종**.
|
||||
|
||||
```typescript
|
||||
// StationOverlay.tsx:48
|
||||
function smoothStep(prev, tx, ty, maxAlpha, minAlpha, speedRef): DispPos {
|
||||
if (!prev) return { x: tx, y: ty, rej: 0, vx: 0, vy: 0 };
|
||||
const dx = tx - prev.x, dy = ty - prev.y;
|
||||
const d = Math.hypot(dx, dy);
|
||||
if (d > REJECT_DIST && prev.rej < MAX_REJECT_FRAMES) {
|
||||
return { x: prev.x, y: prev.y, rej: prev.rej + 1, vx: prev.vx, vy: prev.vy }; // 이상치: 위치 유지
|
||||
}
|
||||
// 속도 벡터 평활 → 떨림(왕복)은 상쇄돼 작게, 실제 이동은 유지.
|
||||
const vx = prev.vx + (dx - prev.vx) * SMOOTH_VEL_BETA;
|
||||
const vy = prev.vy + (dy - prev.vy) * SMOOTH_VEL_BETA;
|
||||
const speed = Math.hypot(vx, vy);
|
||||
const a = Math.min(maxAlpha, minAlpha + (maxAlpha - minAlpha) * Math.min(1, speed / Math.max(1e-4, speedRef)));
|
||||
return { x: prev.x + dx * a, y: prev.y + dy * a, rej: 0, vx, vy };
|
||||
}
|
||||
```
|
||||
|
||||
**수식.**
|
||||
- 이상치 거부: 프레임당 점프 `d = ‖(dx,dy)‖`가 `REJECT_DIST = 0.12`(정규화 화면거리)를 넘으면 갱신을 버리고 이전 위치 유지. 단 연속 거부가 `MAX_REJECT_FRAMES = 8`을 넘으면(=시크/재등장 같은 진짜 큰 이동) 수용 → 영구 고착 방지.
|
||||
- 속도 평활: `v ← v + (Δ − v)·β`, `β = SMOOTH_VEL_BETA = 0.25`. 떨림은 매 프레임 방향이 번갈아(왕복) `v`가 상쇄돼 ≈0, 실제 이동은 방향이 일관돼 `v` 유지(저크 제거).
|
||||
- 적응 alpha: `α = min(maxAlpha, minAlpha + (maxAlpha − minAlpha)·min(1, speed/speedRef))`.
|
||||
- 정지/떨림(`speed≈0`) → `α≈minAlpha`(기본 0.12, 강한 평활).
|
||||
- 빠른 팬(`speed ≥ speedRef`, 기본 `speedRef=0.010`) → `α=maxAlpha`(기본 `emaAlpha=1.0`, 즉시 추종).
|
||||
- 위치 갱신: `pos ← pos + Δ·α`.
|
||||
|
||||
**의도 / 트레이드오프.** 입력이 이미 smoothFrame(±60fr) + poseAt 보간으로 한 차례 평활됐으므로 화면 EMA의 `maxAlpha`는 가볍게 둔다(주석: 과거 고정 α=0.01은 지연 ~1.65s로 라벨이 늦게 따라옴). `minAlpha`↓ / `speedRef`↑ 일수록 1배속 떨림은 줄지만 미세 추종 지연이 늘어난다. 이 세 값은 패널에서 데이터셋별로 실시간 튜닝·저장된다([StationOverlay.tsx#L494](../../client/src/components/overlay/StationOverlay.tsx#L494)).
|
||||
|
||||
추가로 **픽셀 히스테리시스**가 EMA 위에 한 겹 더 적용된다: 화면 px 위치가 0.75px 이상 움직일 때만 정수 좌표를 갱신해 저속에서 반올림 깜빡임을 제거한다([StationOverlay.tsx#L1048](../../client/src/components/overlay/StationOverlay.tsx#L1048), [#L1084](../../client/src/components/overlay/StationOverlay.tsx#L1084)).
|
||||
|
||||
---
|
||||
|
||||
## 3. 라벨 사전계산 + 겹침 억제
|
||||
|
||||
### 3.1 `startLabelPrecompute` — requestIdleCallback 청크 처리, 월드좌표 저장
|
||||
|
||||
[StationOverlay.tsx#L692](../../client/src/components/overlay/StationOverlay.tsx#L692)
|
||||
|
||||
데이터 로드 완료 시(또는 params/평활/범위 변경 시 500ms debounce, [#L824](../../client/src/components/overlay/StationOverlay.tsx#L824)) 전 프레임에 대해 **어떤 라벨이 보이는가(가시집합)와 겹침 해소 결과**를 미리 계산해 `Map<frameNum, LabelCache>`에 적재한다. 핵심 설계는 **화면좌표가 아니라 월드좌표(lat/lon/z)를 저장**한다는 점이다([LabelCache 정의 #L123](../../client/src/components/overlay/StationOverlay.tsx#L123)). 가시성/겹침은 한 번만 결정하고, 실제 화면 위치는 RAF가 매 프레임 연속포즈로 재투영한다.
|
||||
|
||||
**청크 처리.** 메인스레드 블로킹을 피하려 `requestIdleCallback`으로 한 번에 `CHUNK = 200`프레임씩 처리한다.
|
||||
|
||||
```typescript
|
||||
// StationOverlay.tsx:717
|
||||
const step = () => {
|
||||
if (precomputeIdRef.current !== id) return; // 취소됨 (새 계산이 시작되면 id 불일치 → 중단)
|
||||
const end = Math.min(processed + CHUNK, total);
|
||||
while (processed < end) {
|
||||
const idx = (startIdx + processed) % total; // startIdx→끝→0→… 순환
|
||||
processed++;
|
||||
const drone = smoothFrame(frames, idx, SMOOTH_HALF);
|
||||
// ... 측점/POI 가시판정 + 겹침억제 → newMap.set(drone.frame, {...})
|
||||
}
|
||||
if (!published) { labelMapRef.current = newMap; published = true; } // 첫 청크 후 즉시 노출
|
||||
if (processed < total) requestIdleCallback(step, { timeout: 200 });
|
||||
};
|
||||
step(); // 첫 청크는 동기 실행 → 정지 중에도 토글/재계산 즉시 반영
|
||||
```
|
||||
|
||||
세 가지 영리한 설계가 있다.
|
||||
- **현재 프레임부터 순환 처리** (`startIdx = currentFrameIdxRef.current`, `idx = (startIdx + processed) % total`): 지금 화면에 보이는 구간을 먼저 계산해 재생 즉시 라벨이 뜬다.
|
||||
- **재계산 시 옛 맵 복사로 시작** ([#L696](../../client/src/components/overlay/StationOverlay.tsx#L696)): 빈 구간 없이 새 청크가 덮어쓰며 갱신.
|
||||
- **취소 토큰** `precomputeIdRef`: 새 계산이 시작되면 `id`가 증가해 진행 중이던 step이 자동 중단(중복 작업 방지).
|
||||
|
||||
가시판정은 카메라 클리핑(`Zc < CLIP_Z = 0.1` 제외 = 카메라 뒤/너무 가까움), 화면 밖(`pxRaw/pyRaw`가 [−0.02, 1.02] 밖) 제외, POI는 수평직선거리 `distH > MAX_RANGE` 제외로 이뤄진다([#L731](../../client/src/components/overlay/StationOverlay.tsx#L731), [#L748](../../client/src/components/overlay/StationOverlay.tsx#L748)).
|
||||
|
||||
### 3.2 겹침 억제 / 우선순위 / 진행방향 변형
|
||||
|
||||
[StationOverlay.tsx#L755](../../client/src/components/overlay/StationOverlay.tsx#L755)
|
||||
|
||||
화면상 가까이 겹치는 라벨을 솎아낸다. 임계: 가로 `POI_MERGE_X = 0.10`, 세로 `POI_MERGE_Y = 0.035`(정규화). 비등방(가로 관대, 세로 엄격)은 텍스트 라벨이 가로로 길기 때문.
|
||||
|
||||
**우선순위 정렬**(겹침 시 누가 남는가): ① 철도역/역사(`railRank=0`)를 최우선, ② 같은 등급 내에서는 드론에 가까운 것(`dist` 작은 것)을 우선.
|
||||
|
||||
```typescript
|
||||
// StationOverlay.tsx:757
|
||||
const railRank = (cat: string): number => (cat === '철도역' || cat === '역사' ? 0 : 1);
|
||||
poiCand.sort((a, b) => railRank(a.category) - railRank(b.category) || a.dist - b.dist);
|
||||
const dirTag = routeDirRef.current === '상' ? '(상)' : routeDirRef.current === '하' ? '(하)' : '';
|
||||
```
|
||||
|
||||
**진행방향 변형 우선**: 같은 구조물이 상/하행 형제로 두 개 있을 때(예: `회덕제1가도교(상)` / `회덕제1가도교(하)`), 드론이 실제 지나는 선로 방향(routeMeta의 direction, [#L447](../../client/src/components/overlay/StationOverlay.tsx#L447))의 변형을 남긴다. `baseStruct()`로 괄호 변형을 떼 형제임을 판정하고, 이미 수용된 라벨이 비-방향태그인데 새 후보가 방향태그면 교체한다([#L777](../../client/src/components/overlay/StationOverlay.tsx#L777)):
|
||||
|
||||
```typescript
|
||||
const k = accepted[overlapIdx];
|
||||
if (dirTag && baseStruct(c.title) === baseStruct(k.title) && c.title.includes(dirTag) && !k.title.includes(dirTag)) {
|
||||
accepted[overlapIdx] = c;
|
||||
}
|
||||
```
|
||||
|
||||
겹침제외 토글 OFF면 억제 없이 전부 표시하며, 동일좌표 라벨은 `labelRow / labelRowCount`로 세로 행 배치된다([#L784](../../client/src/components/overlay/StationOverlay.tsx#L784))(렌더 시 `ROW_H = 24px`로 중앙정렬, [#L1101](../../client/src/components/overlay/StationOverlay.tsx#L1101)).
|
||||
|
||||
---
|
||||
|
||||
## 4. RAF 렌더 루프 (계산 없이 조회 + 투영만)
|
||||
|
||||
[StationOverlay.tsx#L906](../../client/src/components/overlay/StationOverlay.tsx#L906)
|
||||
|
||||
매 프레임 `requestAnimationFrame`에서 캔버스를 다시 그린다. **무거운 계산(가시성/겹침)은 하지 않고**, precompute가 채운 가시집합을 조회 + 연속포즈로 재투영 + 그리기만 한다.
|
||||
|
||||
### 4.1 연속 시간 → 보간 포즈
|
||||
|
||||
```typescript
|
||||
// StationOverlay.tsx:941
|
||||
const estTime = timeRef
|
||||
? timeRef.current
|
||||
: currentTimeSecRef.current + (performance.now() - timeUpdateWallRef.current) / 1000;
|
||||
const estFrame = estTime * VIDEO_FPS; // VIDEO_FPS = 30000/1001 ≈ 29.97
|
||||
const dronePose = poseAt(estFrame) ?? (...폴백 smoothFrame...);
|
||||
const lines = buildLines(dronePose);
|
||||
```
|
||||
|
||||
`timeRef`는 VideoPlayer의 `smoothTimeRef`로, 매 RAF마다 단조(monotonic) 벽시계 보간으로 갱신되는 재생시간이다([VideoPlayer.tsx#L84](../../client/src/components/player/VideoPlayer.tsx#L84)). 일시정지·시크·배속을 모두 보정하므로 라벨/선/궤적이 영상과 동기된 채 60fps로 매끄럽게 흐른다.
|
||||
|
||||
### 4.2 object-fit:cover 정규좌표 → 화면 변환 (`coverRef`)
|
||||
|
||||
영상은 컨테이너를 비율유지·크롭하며 채우므로(CSS `object-fit:cover`), 오버레이도 같은 cover 사각형에 매핑해야 정합된다.
|
||||
|
||||
```typescript
|
||||
// StationOverlay.tsx:923
|
||||
const { w: vW, h: vH } = videoSizeRef.current;
|
||||
let dispW = W, dispH = H, offX = 0, offY = 0;
|
||||
if (vW > 0 && vH > 0) {
|
||||
const s = Math.max(W / vW, H / vH); // cover: 더 큰 배율(넘침=크롭)
|
||||
dispW = vW * s; dispH = vH * s;
|
||||
offX = (W - dispW) / 2; offY = (H - dispH) / 2; // 음수 = 화면 밖(크롭)
|
||||
}
|
||||
coverRef.current = { offX, offY, dispW, dispH, W, H };
|
||||
const vx = (nx: number) => offX + nx * dispW; // 정규(0~1, 영상프레임) → 화면 px
|
||||
const vy = (ny: number) => offY + ny * dispH;
|
||||
```
|
||||
|
||||
`coverRef`는 RAF가 매 프레임 갱신하고, 포인터 핸들러(클릭→정규좌표 역변환 `clientToVideoNorm`, [#L1211](../../client/src/components/overlay/StationOverlay.tsx#L1211))와 자동팝업 동기화도 공유한다.
|
||||
|
||||
### 4.3 중심선 / 드론궤적 투영 (`buildLines`)
|
||||
|
||||
[StationOverlay.tsx#L606](../../client/src/components/overlay/StationOverlay.tsx#L606). 보간포즈를 받아 매 프레임 직접 투영한다(캐시 미사용 → 라인이 라벨처럼 부드럽게 이동).
|
||||
- **근거리 클리핑**: 세그먼트 양 끝의 `Zc`가 `CLIP_Z=0.1` 경계를 가로지르면 교점 `t = (CLIP_Z − z1)/(z2 − z1)`로 잘라 투영([#L622](../../client/src/components/overlay/StationOverlay.tsx#L622)) → 카메라 뒤 점이 화면 반대편으로 튀는 것 방지.
|
||||
- **화면밖 컬링**: 비트마스크 `oc()` outcode로 양 끝이 같은 변 밖이면 세그먼트 폐기([#L612](../../client/src/components/overlay/StationOverlay.tsx#L612)).
|
||||
- 드론궤적: GPS 경로(lat/lon)를 표고 `Z`에 투영, `PW=15` 폭 이동평균으로 평활, `STEP=3` 샘플, `MAX_FWD=200m` 앞까지만([#L646](../../client/src/components/overlay/StationOverlay.tsx#L646)).
|
||||
|
||||
라벨 자체도 캐시의 월드좌표를 `dronePose`로 재투영 → `smoothStep` → 히스테리시스 후 그린다([#L1039](../../client/src/components/overlay/StationOverlay.tsx#L1039) 측점, [#L1071](../../client/src/components/overlay/StationOverlay.tsx#L1071) POI).
|
||||
|
||||
### 4.4 나침반 미니맵 (heading-up)
|
||||
|
||||
[StationOverlay.tsx#L954](../../client/src/components/overlay/StationOverlay.tsx#L954). 본 포즈는 ±60fr 평활이라 회전 시 나침반이 지연된다 → **별도로 ±3fr 가벼운 평활 yaw**를 보간해 회전에 즉시 반응. `--rot` CSS 변수로 DOM 회전을 갱신하며, **360° 누적 언랩**으로 359°→0° 점프를 방지한다:
|
||||
|
||||
```typescript
|
||||
const target = -(rawYaw + paramsRef.current.yawOffset);
|
||||
const delta = (((target - minimapRotRef.current) % 360) + 540) % 360 - 180;
|
||||
minimapRotRef.current += delta;
|
||||
minimapRef.current.style.setProperty('--rot', `${minimapRotRef.current}deg`);
|
||||
```
|
||||
|
||||
### 4.5 자동 컴팩트 팝업 동기화 (setInterval 150ms)
|
||||
|
||||
[StationOverlay.tsx#L376](../../client/src/components/overlay/StationOverlay.tsx#L376). 가시 구조물(`visStructRef`, RAF가 매 프레임 채움)마다 라벨 옆에 선명한 DOM 팝업(시설종별/구조형식/연장 등 컴팩트 3필드)을 자동 생성/제거한다. **60fps RAF에서 setState를 남발하면 리렌더 폭주**하므로, 가시집합 변화 감지를 **150ms 인터벌**로 분리해 추가/제거가 있을 때만 `setInfoPopups`를 호출한다. 팝업의 실제 화면 위치는 다시 RAF가 라벨 표시좌표를 따라 매 프레임 갱신(EMA `PA=0.18`, 화면하단 근접 시 위로 플립, [#L1146](../../client/src/components/overlay/StationOverlay.tsx#L1146)).
|
||||
|
||||
---
|
||||
|
||||
## 5. 성능 설계 — 60fps 유지 전략
|
||||
|
||||
| 작업 | 비용 | 배치 | 빈도 |
|
||||
|------|------|------|------|
|
||||
| 가시성/겹침 판정 (전 프레임 × 전 항목) | 높음 | precompute | 1회(+설정변경 시 debounce 재계산), idle 청크 200fr |
|
||||
| 좌표 투영 (보간포즈 → 화면) | 중간 | RAF | 매 프레임 |
|
||||
| 평활/EMA/히스테리시스 | 낮음 | RAF | 매 프레임 |
|
||||
| 팝업 가시집합 setState | 리렌더 유발 | setInterval 150ms | 변화 시에만 |
|
||||
|
||||
**핵심 = precompute(가시성)와 RAF(좌표) 분리.** "어떤 라벨을 그릴지"(비싼 결정)는 영상 변화와 무관하게 한 번 계산해 두고, "어디에 그릴지"(가벼운 투영)만 매 프레임 한다. 이로써 RAF 루프는 Map 조회 + 행렬 투영 + 캔버스 드로잉만 남아 60fps를 유지한다([파일 상단 주석 #L1](../../client/src/components/overlay/StationOverlay.tsx#L1)).
|
||||
|
||||
부가 전략: 사전계산의 idle 청크 처리(메인스레드 비블로킹) + 취소토큰, 현재 프레임 우선 순환계산, 첫 청크 즉시노출, 픽셀 히스테리시스(불필요한 재드로 억제), 팝업 위치 EMA(물결/잔상 방지), 데이터셋별 보정값 localStorage 영속화([#L490](../../client/src/components/overlay/StationOverlay.tsx#L490)).
|
||||
|
||||
---
|
||||
|
||||
## 6. 한계 / 업그레이드 포인트
|
||||
|
||||
- **단일 캐시 프레임키 의존 잔존**: 라벨 가시집합은 `Math.floor(estFrame)`로 정수 프레임의 캐시를 조회([#L1022](../../client/src/components/overlay/StationOverlay.tsx#L1022)). 좌표는 연속이지만 가시집합 자체는 프레임 경계에서 바뀐다 → 빠른 배속에서 라벨 등장/퇴장이 미세하게 튈 수 있음.
|
||||
- **중심선 per-frame 투영 비용**: `buildLines`가 전 중심선 점을 매 프레임 투영. 점 수가 많아지면 부담 → 화면 근방 구간만 투영하는 공간색인(앞서 컬링 전 prefilter)으로 개선 여지.
|
||||
- **POI 표고 부정확**: POI는 실측 표고가 없어 최근접 선로표고 또는 '드론고도 − N'으로 가정([#L744](../../client/src/components/overlay/StationOverlay.tsx#L744)) → 드래그/DEM override로 수동보정. DEM 자동조회 통합이 업그레이드 포인트.
|
||||
- **VFR/프레임 누락**: `VIDEO_FPS` 상수(29.97) 가정. 가변프레임율 영상은 frame↔time 매핑 오차 발생.
|
||||
- **평활 파라미터 수동 튜닝**: YAW_EDGE/REJECT_DIST/speedRef 등이 상수 또는 수동슬라이더 → 데이터셋 통계 기반 자동 캘리브레이션 가능.
|
||||
- **단일 스레드**: precompute가 메인스레드 idle 의존. Web Worker로 옮기면 대용량에서 더 안정.
|
||||
|
||||
---
|
||||
|
||||
## 7. 특허 관점 신규성
|
||||
|
||||
1. **에지(회전경계) 보존 적응형 이동평균** — yaw 차 임계로 평균창을 회전경계에서 멈추고, 회전 종료 후 한 프레임씩 점진 재확장하여 *직선구간 노이즈 억제*와 *회전구간 무지연 추종*을 동시에 달성. 회전 프레임 재혼입에 의한 1회 튐을 구조적으로 제거(변화율 기반 방식 대비).
|
||||
|
||||
2. **월드좌표 사전계산 + 연속포즈 재투영의 2단 분리** — 라벨 가시성/겹침/우선순위(비싼 결정)는 월드좌표로 1회 사전계산하고, 화면 위치(가벼운 투영)는 매 RAF 연속 보간포즈로 재계산. "무엇을 그릴지"와 "어디에 그릴지"의 분리로 60fps AR 오버레이를 단일스레드에서 달성.
|
||||
|
||||
3. **연속 프레임번호 보간 포즈 + 최단각 yaw** — 단조 벽시계 보간 재생시간을 실수 프레임번호로 변환, 이산 SRT 자세를 평활-후-보간하여 이산격자(30fps)를 넘는 60fps 연속 자세를 생성. yaw는 원형평균(평활)·최단각(보간)으로 360° 경계 안정.
|
||||
|
||||
4. **속도적응 화면 평활(One-Euro 사상) + 연속거부 한계** — 속도벡터 평활로 떨림(왕복상쇄)과 실이동(방향일관)을 분리해 alpha를 적응시키고, 이상치 거부에 *연속거부 한계*를 두어 시크/재등장 시 영구고착을 방지.
|
||||
|
||||
5. **진행방향 인지 형제 라벨 해소** — 상/하행 형제 구조물이 화면상 겹칠 때 노선 진행방향((상)/(하)) 변형을 우선 표시. 철도역 최우선 + 근접거리 차선의 다단 우선순위와 결합.
|
||||
|
||||
6. **object-fit:cover 정합 + 양방향 좌표 변환** — 영상 크롭 사각형에 오버레이를 일치시키는 정규좌표 변환과 그 역변환(클릭→월드 역투영)으로, 영상 위 라벨 클릭/드래그 보정(focal 역산·표고 역산·지면교차)을 동일 좌표계에서 처리.
|
||||
|
||||
---
|
||||
|
||||
**소요 시간**: 18분
|
||||
**Context 사용량**: input 88k / output 12k tokens
|
||||
@@ -0,0 +1,32 @@
|
||||
# GhiVideo 종합 기술 명세서 작성 (인계+특허 기초)
|
||||
|
||||
**소요 시간**: 약 35분
|
||||
**Context 사용량**: input ~600k / output ~36k tokens
|
||||
|
||||
## 결론
|
||||
사용된 전 기술을 소스 인용과 함께 1개 종합 문서로 정리. 후임 인계 + 특허 출원 기초자료 겸용.
|
||||
md/html/pdf 3종 생성(PDF 18p, 한글 임베드).
|
||||
|
||||
## 산출물
|
||||
- `docs/기술명세_GhiVideo_종합기술문서.md` (베이스)
|
||||
- `docs/기술명세_GhiVideo_종합기술문서.html` (자체완결)
|
||||
- `docs/기술명세_GhiVideo_종합기술문서.pdf` (A4 18p, Malgun 임베드, 페이지번호)
|
||||
|
||||
## 방법
|
||||
- 5개 서브시스템을 병렬 서브에이전트로 정밀 조사 후 종합:
|
||||
1) 카메라 투영·FOV 정합(geoProjection.ts) 2) 드론 자세 평활·오버레이 렌더(StationOverlay.tsx)
|
||||
3) 측점·스테이션바(chainage.ts/StationBar/Timeline) 4) 백엔드·데이터·상태(server/geoData/store)
|
||||
5) 기존 특허/발명 문서 + history 타임라인
|
||||
- 투영 파이프라인은 정본 1개(§2)로 통합, 지오이드 N=25.8m 통일, 특허/인계 섹션 통합.
|
||||
|
||||
## 문서 구성 (13장)
|
||||
개요·아키텍처 / 좌표·투영 정본 / 카메라·FOV / 측점표고정합(발명B) / 자세평활·렌더 /
|
||||
측점·스테이션바 / 위치보정 역투영(발명C) / 백엔드·데이터 / 개발타임라인 / 특허종합 /
|
||||
검증결과표 / 후임자 인계 / 한계·출원전 확인.
|
||||
|
||||
## 검증
|
||||
- scripts/md2docs.sh 로 html+pdf 생성, %PDF-1.7 18페이지 /FontFile(Malgun) 임베드 확인.
|
||||
|
||||
## 참고
|
||||
- 특허 출원 시 §3~§7·§10 + history 원문(특히 2026-06-22 POI투영오차)을 변리사에 함께 제공 권장.
|
||||
- KIPRIS 전문검색은 미수행 — 출원 전 필수.
|
||||
@@ -0,0 +1,834 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" lang xml:lang>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="generator" content="pandoc" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes" />
|
||||
<title>기술명세_GhiVideo_종합기술문서</title>
|
||||
<style>
|
||||
code{white-space: pre-wrap;}
|
||||
span.smallcaps{font-variant: small-caps;}
|
||||
span.underline{text-decoration: underline;}
|
||||
div.column{display: inline-block; vertical-align: top; width: 50%;}
|
||||
div.hanging-indent{margin-left: 1.5em; text-indent: -1.5em;}
|
||||
ul.task-list{list-style: none;}
|
||||
pre > code.sourceCode { white-space: pre; position: relative; }
|
||||
pre > code.sourceCode > span { display: inline-block; line-height: 1.25; }
|
||||
pre > code.sourceCode > span:empty { height: 1.2em; }
|
||||
code.sourceCode > span { color: inherit; text-decoration: inherit; }
|
||||
div.sourceCode { margin: 1em 0; }
|
||||
pre.sourceCode { margin: 0; }
|
||||
@media screen {
|
||||
div.sourceCode { overflow: auto; }
|
||||
}
|
||||
@media print {
|
||||
pre > code.sourceCode { white-space: pre-wrap; }
|
||||
pre > code.sourceCode > span { text-indent: -5em; padding-left: 5em; }
|
||||
}
|
||||
pre.numberSource code
|
||||
{ counter-reset: source-line 0; }
|
||||
pre.numberSource code > span
|
||||
{ position: relative; left: -4em; counter-increment: source-line; }
|
||||
pre.numberSource code > span > a:first-child::before
|
||||
{ content: counter(source-line);
|
||||
position: relative; left: -1em; text-align: right; vertical-align: baseline;
|
||||
border: none; display: inline-block;
|
||||
-webkit-touch-callout: none; -webkit-user-select: none;
|
||||
-khtml-user-select: none; -moz-user-select: none;
|
||||
-ms-user-select: none; user-select: none;
|
||||
padding: 0 4px; width: 4em;
|
||||
color: #aaaaaa;
|
||||
}
|
||||
pre.numberSource { margin-left: 3em; border-left: 1px solid #aaaaaa; padding-left: 4px; }
|
||||
div.sourceCode
|
||||
{ }
|
||||
@media screen {
|
||||
pre > code.sourceCode > span > a:first-child::before { text-decoration: underline; }
|
||||
}
|
||||
code span.al { color: #ff0000; font-weight: bold; } /* Alert */
|
||||
code span.an { color: #60a0b0; font-weight: bold; font-style: italic; } /* Annotation */
|
||||
code span.at { color: #7d9029; } /* Attribute */
|
||||
code span.bn { color: #40a070; } /* BaseN */
|
||||
code span.bu { } /* BuiltIn */
|
||||
code span.cf { color: #007020; font-weight: bold; } /* ControlFlow */
|
||||
code span.ch { color: #4070a0; } /* Char */
|
||||
code span.cn { color: #880000; } /* Constant */
|
||||
code span.co { color: #60a0b0; font-style: italic; } /* Comment */
|
||||
code span.cv { color: #60a0b0; font-weight: bold; font-style: italic; } /* CommentVar */
|
||||
code span.do { color: #ba2121; font-style: italic; } /* Documentation */
|
||||
code span.dt { color: #902000; } /* DataType */
|
||||
code span.dv { color: #40a070; } /* DecVal */
|
||||
code span.er { color: #ff0000; font-weight: bold; } /* Error */
|
||||
code span.ex { } /* Extension */
|
||||
code span.fl { color: #40a070; } /* Float */
|
||||
code span.fu { color: #06287e; } /* Function */
|
||||
code span.im { } /* Import */
|
||||
code span.in { color: #60a0b0; font-weight: bold; font-style: italic; } /* Information */
|
||||
code span.kw { color: #007020; font-weight: bold; } /* Keyword */
|
||||
code span.op { color: #666666; } /* Operator */
|
||||
code span.ot { color: #007020; } /* Other */
|
||||
code span.pp { color: #bc7a00; } /* Preprocessor */
|
||||
code span.sc { color: #4070a0; } /* SpecialChar */
|
||||
code span.ss { color: #bb6688; } /* SpecialString */
|
||||
code span.st { color: #4070a0; } /* String */
|
||||
code span.va { color: #19177c; } /* Variable */
|
||||
code span.vs { color: #4070a0; } /* VerbatimString */
|
||||
code span.wa { color: #60a0b0; font-weight: bold; font-style: italic; } /* Warning */
|
||||
.display.math{display: block; text-align: center; margin: 0.5rem auto;}
|
||||
</style>
|
||||
<style type="text/css">@page {
|
||||
size: A4;
|
||||
margin: 18mm 16mm 16mm 16mm;
|
||||
@bottom-center {
|
||||
content: counter(page) " / " counter(pages);
|
||||
font-family: "Malgun Gothic", sans-serif;
|
||||
font-size: 9pt;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
html { font-size: 11pt; }
|
||||
body {
|
||||
font-family: "Malgun Gothic", "Hancom Gothic", sans-serif;
|
||||
color: #23272e;
|
||||
line-height: 1.65;
|
||||
max-width: 920px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.7rem;
|
||||
color: #b45309;
|
||||
border-bottom: 3px solid #f59e0b;
|
||||
padding-bottom: 8px;
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
h2 {
|
||||
font-size: 1.25rem;
|
||||
color: #b45309;
|
||||
border-bottom: 1px solid #e5d3b3;
|
||||
padding-bottom: 5px;
|
||||
margin-top: 1.6em;
|
||||
}
|
||||
h3 { font-size: 1.05rem; color: #92400e; margin-top: 1.1em; }
|
||||
a { color: #b45309; }
|
||||
hr { border: none; border-top: 1px solid #e2e2e2; margin: 1.6em 0; }
|
||||
ul { padding-left: 1.25em; }
|
||||
li { margin: 0.18em 0; }
|
||||
strong { color: #1f2937; }
|
||||
code {
|
||||
font-family: "D2Coding", Consolas, monospace;
|
||||
background: #f4f1ea;
|
||||
border: 1px solid #e7e0d2;
|
||||
border-radius: 3px;
|
||||
padding: 0.5px 5px;
|
||||
font-size: 0.92em;
|
||||
}
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 0.8em 0;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
th, td { border: 1px solid #d8d2c4; padding: 6px 10px; text-align: left; vertical-align: top; }
|
||||
th { background: #fdf3df; color: #7c2d12; }
|
||||
blockquote {
|
||||
border-left: 4px solid #f59e0b;
|
||||
margin: 0.8em 0;
|
||||
padding: 0.2em 0 0.2em 14px;
|
||||
color: #555;
|
||||
background: #fffbf2;
|
||||
}
|
||||
|
||||
h1, h2, h3 { break-after: avoid; }
|
||||
</style>
|
||||
<!--[if lt IE 9]>
|
||||
<script src="//cdnjs.cloudflare.com/ajax/libs/html5shiv/3.7.3/html5shiv-printshiv.min.js"></script>
|
||||
<![endif]-->
|
||||
</head>
|
||||
<body>
|
||||
<header id="title-block-header">
|
||||
<h1 class="title">기술명세_GhiVideo_종합기술문서</h1>
|
||||
</header>
|
||||
<h1 id="ghivideo-종합-기술-명세서">GhiVideo 종합 기술 명세서</h1>
|
||||
<blockquote>
|
||||
<p><strong>목적</strong>: 본 문서는 (1) <strong>특허 출원/발명 신고</strong>의 기초 자료와 (2) <strong>후임 개발자 인계·업그레이드</strong> 자료를 겸한다. <strong>대상 시스템</strong>: GhiVideo — 시간축이 아니라 <strong>노선 위치(측점·체이니지)</strong> 를 1차 좌표로 삼아 드론 주행영상을 색인·탐색하고, 프레임별 카메라 자세로 측점·POI·선로중심선을 영상 위에 실시간 정합(geo-registration)하는 철도 주행영상 분석 플레이어. <strong>작성일</strong>: 2026-06-29 · 모든 소스 참조는 <code>[경로](경로#Lxx)</code> 형식(클릭 가능).</p>
|
||||
</blockquote>
|
||||
<hr />
|
||||
<h2 id="0-문서-구성과-읽는-법">0. 문서 구성과 읽는 법</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr class="header">
|
||||
<th>장</th>
|
||||
<th>내용</th>
|
||||
<th>주 독자</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr class="odd">
|
||||
<td>1</td>
|
||||
<td>시스템 개요·아키텍처·데이터 흐름</td>
|
||||
<td>전체</td>
|
||||
</tr>
|
||||
<tr class="even">
|
||||
<td>2</td>
|
||||
<td><strong>좌표·투영 정본 파이프라인</strong> (모든 정합의 기반)</td>
|
||||
<td>전체</td>
|
||||
</tr>
|
||||
<tr class="odd">
|
||||
<td>3</td>
|
||||
<td>카메라 투영 & 화각(FOV) 정합</td>
|
||||
<td>개발/특허</td>
|
||||
</tr>
|
||||
<tr class="even">
|
||||
<td>4</td>
|
||||
<td>측점 표고 정합 (DEM-free) — 발명 B</td>
|
||||
<td>특허</td>
|
||||
</tr>
|
||||
<tr class="odd">
|
||||
<td>5</td>
|
||||
<td>드론 자세 평활/보간 + 오버레이 렌더링</td>
|
||||
<td>개발</td>
|
||||
</tr>
|
||||
<tr class="even">
|
||||
<td>6</td>
|
||||
<td>측점(체이니지) 시스템 & 스테이션바</td>
|
||||
<td>개발/특허</td>
|
||||
</tr>
|
||||
<tr class="odd">
|
||||
<td>7</td>
|
||||
<td>위치보정 역투영 — 발명 C</td>
|
||||
<td>특허</td>
|
||||
</tr>
|
||||
<tr class="even">
|
||||
<td>8</td>
|
||||
<td>백엔드 스트리밍/FFmpeg·데이터·상태관리</td>
|
||||
<td>개발</td>
|
||||
</tr>
|
||||
<tr class="odd">
|
||||
<td>9</td>
|
||||
<td>개발 타임라인(주요 의사결정)</td>
|
||||
<td>전체</td>
|
||||
</tr>
|
||||
<tr class="even">
|
||||
<td>10</td>
|
||||
<td><strong>특허 관점 종합</strong>(인벤토리·후보·청구포인트·선행기술)</td>
|
||||
<td>특허</td>
|
||||
</tr>
|
||||
<tr class="odd">
|
||||
<td>11</td>
|
||||
<td>검증 결과표</td>
|
||||
<td>전체</td>
|
||||
</tr>
|
||||
<tr class="even">
|
||||
<td>12</td>
|
||||
<td>후임자 인계(데이터흐름·튜닝상수·빌드·업그레이드)</td>
|
||||
<td>개발</td>
|
||||
</tr>
|
||||
<tr class="odd">
|
||||
<td>13</td>
|
||||
<td>한계 / 출원 전 확인사항</td>
|
||||
<td>전체</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<hr />
|
||||
<h2 id="1-시스템-개요--아키텍처">1. 시스템 개요 & 아키텍처</h2>
|
||||
<h3 id="11-핵심-아이디어">1.1 핵심 아이디어</h3>
|
||||
<p>드론으로 촬영한 철도 선로 주행영상의 각 프레임에는 SRT 메타데이터로 <strong>드론 위치(lat/lon/고도)·자세(yaw/pitch/roll)·초점거리</strong>가 기록된다. 한편 철도 GIS에는 측점·POI·선로 중심선의 <strong>WGS84 좌표(+표고)</strong> 가 있다. 이 둘을 <strong>핀홀 카메라 투영</strong>으로 결합하면:</p>
|
||||
<ol type="1">
|
||||
<li><strong>순방향 투영</strong> — "이 GIS 지점이 지금 화면 어느 픽셀에 보이는가" → 라벨/중심선/궤적 오버레이.</li>
|
||||
<li><strong>역투영</strong> — "사용자가 화면에서 끈 픽셀은 어느 지리좌표인가" → POI 위치/표고 보정.</li>
|
||||
<li><strong>측점 색인</strong> — 드론 GPS를 측점 폴리라인에 투영해 영상 매 시점을 <strong>측점값(체이니지)</strong> 으로 환산 → 측점 기반 탐색/스크러빙.</li>
|
||||
</ol>
|
||||
<h3 id="12-데이터-흐름">1.2 데이터 흐름</h3>
|
||||
<pre><code>[폴더 선택: base.csv(드론프레임) + building/측점·교량·터널·역사 CSV + route.json + KMZ]
|
||||
│ geoData.loadFolderGeoData (인코딩 자동감지·병렬 파싱·KMZ 우선)
|
||||
▼
|
||||
[geoStore] frames / stations / pois / structures / centerline / origin / routeMeta
|
||||
│
|
||||
├─▶ StationOverlay (영상 위 AR 오버레이)
|
||||
│ precompute(가시성/겹침, requestIdleCallback) → RAF(연속포즈 재투영) → Canvas
|
||||
│ geoProjection: WGS84→TM→ENU→카메라→핀홀
|
||||
│
|
||||
├─▶ StationBar (하단 측점 스크러버)
|
||||
│ chainage: 드론GPS→측점폴리라인 수직투영→연속 체이니지
|
||||
│ 이동거리/측점축, 방향색, 구조물 마커, 측점검색 순환
|
||||
│
|
||||
└─▶ VideoPlayer (Video.js + hls.js, 이중 재생경로, 프레임 정확도)</code></pre>
|
||||
<h3 id="13-기술-스택">1.3 기술 스택</h3>
|
||||
<ul>
|
||||
<li><strong>프론트엔드</strong>: React 18 + TypeScript, Video.js 8.23 + @videojs/http-streaming, hls.js 1.6, Zustand(상태), Vite, Canvas 2D(오버레이), proj4(좌표변환), fflate(KMZ 압축해제).</li>
|
||||
<li><strong>백엔드</strong>: Node.js 20 + Express, <code>child_process.spawn</code> FFmpeg 래퍼, Range Request 스트리밍, HLS 변환 + SSE 진행률.</li>
|
||||
<li><strong>배포</strong>: PM2(<code>ghiVideo</code>, 포트 55000)가 <code>client/dist</code> 정적 서빙(빌드 결과물). 소스 변경 시 <code>npm run build -w client</code>(Node 20) 후 반영.</li>
|
||||
</ul>
|
||||
<hr />
|
||||
<h2 id="2-좌표투영-정본-파이프라인--모든-정합의-기반">2. 좌표·투영 정본 파이프라인 (★ 모든 정합의 기반)</h2>
|
||||
<blockquote>
|
||||
<p>본 절을 <strong>정본</strong>으로 하고, 3~7장은 이를 참조한다. Python 레퍼런스 <code>advanced_tuner_v2.py</code>와 회전행렬·축정렬·정규화 공식이 1:1 동치로 이식되어 있다(<a href="client/src/utils/geoProjection.ts#L1">geoProjection.ts:1-12</a>).</p>
|
||||
</blockquote>
|
||||
<h3 id="21-5단계-변환">2.1 5단계 변환</h3>
|
||||
<pre><code>[1] WGS84 (lat, lon, EL정표고)
|
||||
│ proj4 EPSG:4326 → EPSG:5186 (한국 중부원점 TM)
|
||||
▼
|
||||
[2] 월드 ENU [East(m), North(m), Up(m)] (East=X, North=Y)
|
||||
│ rel = target_ENU − (drone_ENU + offset)
|
||||
▼
|
||||
[3] 카메라좌표 [Xc, Yc, Zc] (R_w2c = R_align · R_b2w(yaw,pitch,roll)ᵀ)
|
||||
│ 핀홀 + focal/sensor
|
||||
▼
|
||||
[4] 정규 이미지 좌표 [px, py] ∈ (0~1) (0.5 = 영상 중심)
|
||||
│ object-fit:cover 변환
|
||||
▼
|
||||
[5] 화면 px</code></pre>
|
||||
<h3 id="22-단계별-수식코드">2.2 단계별 수식·코드</h3>
|
||||
<p><strong>단계 1 — WGS84 → TM</strong> (<a href="client/src/utils/geoProjection.ts#L75">geoProjection.ts:75-85</a>)</p>
|
||||
<div class="sourceCode" id="cb3"><pre class="sourceCode ts"><code class="sourceCode typescript"><span id="cb3-1"><a href="#cb3-1" aria-hidden="true" tabindex="-1"></a>proj4<span class="op">.</span><span class="fu">defs</span>(<span class="st">'EPSG:5186'</span><span class="op">,</span> <span class="st">'+proj=tmerc +lat_0=38 +lon_0=127 +k=1 +x_0=200000 +y_0=600000 +ellps=GRS80 +units=m +no_defs'</span>)<span class="op">;</span></span>
|
||||
<span id="cb3-2"><a href="#cb3-2" aria-hidden="true" tabindex="-1"></a><span class="kw">function</span> <span class="fu">latLonToTM</span>(lat<span class="op">,</span> lon) { const [e<span class="op">,</span> n] <span class="op">=</span> _toTM<span class="op">.</span><span class="fu">forward</span>([lon<span class="op">,</span> lat])<span class="op">;</span> return [e<span class="op">,</span> n]<span class="op">;</span> }</span></code></pre></div>
|
||||
<p><strong>단계 2 — TM → ENU + 표고 datum 정합</strong> (<a href="client/src/utils/geoProjection.ts#L91">geoProjection.ts:91-97</a>, <a href="client/src/utils/geoProjection.ts#L263">263-271</a>). X/Y는 절대 TM, Z만 기준고도 대비 상대(상대벡터를 빼므로 무관). <strong>핵심: GIS 정표고(EL) + 지오이드고(N) → 타원체고</strong>로 변환해 드론 GPS 타원체고와 datum을 맞춘다.</p>
|
||||
<div class="sourceCode" id="cb4"><pre class="sourceCode ts"><code class="sourceCode typescript"><span id="cb4-1"><a href="#cb4-1" aria-hidden="true" tabindex="-1"></a>const stEnu <span class="op">=</span> <span class="fu">geoToEnu</span>(targetLat<span class="op">,</span> targetLon<span class="op">,</span> targetAlt <span class="op">+</span> (params<span class="op">.</span><span class="at">geoidOffset</span> <span class="op">??</span> <span class="dv">0</span>)<span class="op">,</span> <span class="op">...</span>)<span class="op">;</span> <span class="co">// EL + N</span></span>
|
||||
<span id="cb4-2"><a href="#cb4-2" aria-hidden="true" tabindex="-1"></a>const drEnuAdj <span class="op">=</span> [drEnu[<span class="dv">0</span>]<span class="op">+</span>offX<span class="op">,</span> drEnu[<span class="dv">1</span>]<span class="op">+</span>offY<span class="op">,</span> drEnu[<span class="dv">2</span>]<span class="op">+</span>offZ]<span class="op">;</span></span>
|
||||
<span id="cb4-3"><a href="#cb4-3" aria-hidden="true" tabindex="-1"></a>const relEnu <span class="op">=</span> [stEnu[<span class="dv">0</span>]<span class="op">-</span>drEnuAdj[<span class="dv">0</span>]<span class="op">,</span> stEnu[<span class="dv">1</span>]<span class="op">-</span>drEnuAdj[<span class="dv">1</span>]<span class="op">,</span> stEnu[<span class="dv">2</span>]<span class="op">-</span>drEnuAdj[<span class="dv">2</span>]]<span class="op">;</span></span></code></pre></div>
|
||||
<blockquote>
|
||||
<p><strong>지오이드고 N</strong>: 대전지역 ≈ <strong>25.8m</strong>(= 84.244 − 16.853 − 41.59, KNGeoid 검증값). 초기 문서엔 25.449m로 기록된 바 있으나 본 명세는 25.8m로 통일(§11). 지역 상수이므로 타 지역 데이터셋은 재튜닝 필요.</p>
|
||||
</blockquote>
|
||||
<p><strong>단계 3 — ENU → 카메라좌표</strong> (<a href="client/src/utils/geoProjection.ts#L275">geoProjection.ts:275-295</a>). <code>R_b2w = Rz(−yaw)·Rx(pitch)·Ry(roll)</code>, <code>R_w2c = R_align·R_b2wᵀ</code>, <code>R_align=[[1,0,0],[0,0,−1],[0,1,0]]</code>. 행렬을 명시 생성하지 않고 전치(행=열)·부호치환으로 직접 계산:</p>
|
||||
<div class="sourceCode" id="cb5"><pre class="sourceCode ts"><code class="sourceCode typescript"><span id="cb5-1"><a href="#cb5-1" aria-hidden="true" tabindex="-1"></a><span class="kw">function</span> <span class="fu">applyRw2c</span>(b2w<span class="op">,</span> rel) {</span>
|
||||
<span id="cb5-2"><a href="#cb5-2" aria-hidden="true" tabindex="-1"></a> return {</span>
|
||||
<span id="cb5-3"><a href="#cb5-3" aria-hidden="true" tabindex="-1"></a> Xc<span class="op">:</span> b2w[<span class="dv">0</span>][<span class="dv">0</span>]<span class="op">*</span>rel[<span class="dv">0</span>] <span class="op">+</span> b2w[<span class="dv">1</span>][<span class="dv">0</span>]<span class="op">*</span>rel[<span class="dv">1</span>] <span class="op">+</span> b2w[<span class="dv">2</span>][<span class="dv">0</span>]<span class="op">*</span>rel[<span class="dv">2</span>]<span class="op">,</span> <span class="co">// col0</span></span>
|
||||
<span id="cb5-4"><a href="#cb5-4" aria-hidden="true" tabindex="-1"></a> Yc<span class="op">:</span> <span class="op">-</span>(b2w[<span class="dv">0</span>][<span class="dv">2</span>]<span class="op">*</span>rel[<span class="dv">0</span>] <span class="op">+</span> b2w[<span class="dv">1</span>][<span class="dv">2</span>]<span class="op">*</span>rel[<span class="dv">1</span>] <span class="op">+</span> b2w[<span class="dv">2</span>][<span class="dv">2</span>]<span class="op">*</span>rel[<span class="dv">2</span>])<span class="op">,</span> <span class="co">// -col2</span></span>
|
||||
<span id="cb5-5"><a href="#cb5-5" aria-hidden="true" tabindex="-1"></a> Zc<span class="op">:</span> b2w[<span class="dv">0</span>][<span class="dv">1</span>]<span class="op">*</span>rel[<span class="dv">0</span>] <span class="op">+</span> b2w[<span class="dv">1</span>][<span class="dv">1</span>]<span class="op">*</span>rel[<span class="dv">1</span>] <span class="op">+</span> b2w[<span class="dv">2</span>][<span class="dv">1</span>]<span class="op">*</span>rel[<span class="dv">2</span>]<span class="op">,</span> <span class="co">// col1</span></span>
|
||||
<span id="cb5-6"><a href="#cb5-6" aria-hidden="true" tabindex="-1"></a> }<span class="op">;</span></span>
|
||||
<span id="cb5-7"><a href="#cb5-7" aria-hidden="true" tabindex="-1"></a>}</span></code></pre></div>
|
||||
<p>규약: <strong>Zc>0 = 카메라 정면</strong>(영상 깊이축), Xc=우, Yc=하. Zc≤0은 카메라 뒤 → null.</p>
|
||||
<p><strong>단계 4 — 카메라좌표 → 정규 픽셀(핀홀)</strong> (<a href="client/src/utils/geoProjection.ts#L126">geoProjection.ts:126-137</a>). 영상 중심 = 0.5, 해상도 독립:</p>
|
||||
<div class="sourceCode" id="cb6"><pre class="sourceCode ts"><code class="sourceCode typescript"><span id="cb6-1"><a href="#cb6-1" aria-hidden="true" tabindex="-1"></a>pxRaw <span class="op">=</span> (<span class="fl">0.5</span> <span class="op">+</span> cx0) <span class="op">+</span> (Xc<span class="op">/</span>Zc) <span class="op">*</span> (f <span class="op">/</span> sensorW)<span class="op">;</span> <span class="co">// 가로</span></span>
|
||||
<span id="cb6-2"><a href="#cb6-2" aria-hidden="true" tabindex="-1"></a>pyRaw <span class="op">=</span> (<span class="fl">0.5</span> <span class="op">+</span> cy0) <span class="op">+</span> (Yc<span class="op">/</span>Zc) <span class="op">*</span> (f <span class="op">/</span> sensorH)<span class="op">;</span> <span class="co">// 세로</span></span></code></pre></div>
|
||||
<p><strong>단계 5 — 정규 → 화면 px (object-fit:cover)</strong> (<a href="client/src/components/overlay/StationOverlay.tsx#L923">StationOverlay.tsx:923-933</a>). 영상 크롭 사각형에 맞춰 정합:</p>
|
||||
<div class="sourceCode" id="cb7"><pre class="sourceCode ts"><code class="sourceCode typescript"><span id="cb7-1"><a href="#cb7-1" aria-hidden="true" tabindex="-1"></a>const s <span class="op">=</span> <span class="bu">Math</span><span class="op">.</span><span class="fu">max</span>(W <span class="op">/</span> vW<span class="op">,</span> H <span class="op">/</span> vH)<span class="op">;</span> <span class="co">// cover: 더 큰 배율(넘침=크롭)</span></span>
|
||||
<span id="cb7-2"><a href="#cb7-2" aria-hidden="true" tabindex="-1"></a>dispW <span class="op">=</span> vW <span class="op">*</span> s<span class="op">;</span> dispH <span class="op">=</span> vH <span class="op">*</span> s<span class="op">;</span> offX <span class="op">=</span> (W <span class="op">-</span> dispW)<span class="op">/</span><span class="dv">2</span><span class="op">;</span> offY <span class="op">=</span> (H <span class="op">-</span> dispH)<span class="op">/</span><span class="dv">2</span><span class="op">;</span></span>
|
||||
<span id="cb7-3"><a href="#cb7-3" aria-hidden="true" tabindex="-1"></a>const vx <span class="op">=</span> (nx) <span class="kw">=></span> offX <span class="op">+</span> nx <span class="op">*</span> dispW<span class="op">;</span> const vy <span class="op">=</span> (ny) <span class="kw">=></span> offY <span class="op">+</span> ny <span class="op">*</span> dispH<span class="op">;</span></span></code></pre></div>
|
||||
<h3 id="23-두-투영-경로의-의도적-분리">2.3 두 투영 경로의 의도적 분리</h3>
|
||||
<ul>
|
||||
<li><strong>렌더(정밀)</strong>: <a href="client/src/utils/geoProjection.ts">geoProjection.ts</a> — proj4 EPSG:5186 TM + 회전행렬 + geoid/포커스 보정. (3~5장)</li>
|
||||
<li><strong>검색(경량)</strong>: <a href="client/src/utils/geoSearch.ts">geoSearch.ts</a> — cos-lat 근사 ENU. "현재 시야 내 POI" 또는 "특정 POI가 보이는 프레임 구간" 산출용.</li>
|
||||
</ul>
|
||||
<p>ENU 월드원점은 <strong>첫 측점</strong>(없으면 첫 드론프레임, 없으면 중심선 첫 점)(<a href="client/src/utils/geoSearch.ts#L117">geoSearch.ts:117-128</a>).</p>
|
||||
<hr />
|
||||
<h2 id="3-카메라-투영--화각fov-정합">3. 카메라 투영 & 화각(FOV) 정합</h2>
|
||||
<blockquote>
|
||||
<p>좌표 변환 파이프라인은 §2 참조. 본 절은 화각·내부표정 보정과 역투영을 다룬다.</p>
|
||||
</blockquote>
|
||||
<h3 id="31-focal--fov-관계">3.1 focal ↔︎ FOV 관계</h3>
|
||||
<p>화각은 focal과 센서 치수로 결정된다(핀홀): <code>FOV = 2·atan(sensor / (2·focal))</code> (<a href="client/src/components/overlay/StationOverlay.tsx#L864">StationOverlay.tsx:864</a>). 기본값 <code>focal=24mm, sensorW=36mm(풀프레임 환산), sensorH=20.25mm(=36×9/16, 16:9)</code> (<a href="client/src/utils/geoProjection.ts#L48">geoProjection.ts:48-66</a>). 이 hFOV는 나침반/미니맵 시야부채꼴에도 사용.</p>
|
||||
<h3 id="32-fovmode--세로-화각sensorh-역산-보정-">3.2 fovMode — 세로 화각(sensorH) 역산 보정 (★)</h3>
|
||||
<p>라벨이 멀리/가까이에서 <strong>상하로 어긋날 때</strong>, 사용자가 POI 하나를 영상 속 실제 위치로 드래그하면 그 한 점의 대응으로 세로 화각을 역산한다. <strong>설계 핵심: 가로 focal(f/sensorW)은 건드리지 않고 sensorH만 역산</strong> → 빨간 중심선이 좌우로 틀어지지 않으면서 상하 배율만 교정.</p>
|
||||
<p>투영식 <code>py = 0.5 + cy0 + (Yc/Zc)·(f/sH)</code> 를 sH에 대해 풀면 (<a href="client/src/components/overlay/StationOverlay.tsx#L1326">StationOverlay.tsx:1326-1344</a>):</p>
|
||||
<div class="sourceCode" id="cb8"><pre class="sourceCode ts"><code class="sourceCode typescript"><span id="cb8-1"><a href="#cb8-1" aria-hidden="true" tabindex="-1"></a>const cc <span class="op">=</span> <span class="fu">toCameraCoords</span>(drone<span class="op">,</span> drag<span class="op">.</span><span class="at">lat</span><span class="op">,</span> drag<span class="op">.</span><span class="at">lon</span><span class="op">,</span> poiZ<span class="op">,</span> p<span class="op">,</span> wo)<span class="op">;</span></span>
|
||||
<span id="cb8-2"><a href="#cb8-2" aria-hidden="true" tabindex="-1"></a>const b <span class="op">=</span> cc<span class="op">.</span><span class="at">Yc</span> <span class="op">/</span> cc<span class="op">.</span><span class="at">Zc</span><span class="op">;</span></span>
|
||||
<span id="cb8-3"><a href="#cb8-3" aria-hidden="true" tabindex="-1"></a>const v <span class="op">=</span> pos<span class="op">.</span><span class="at">y</span> <span class="op">-</span> <span class="fl">0.5</span> <span class="op">-</span> (p<span class="op">.</span><span class="at">cy0</span> <span class="op">??</span> <span class="dv">0</span>)<span class="op">;</span></span>
|
||||
<span id="cb8-4"><a href="#cb8-4" aria-hidden="true" tabindex="-1"></a>const sHNew <span class="op">=</span> (b <span class="op">*</span> p<span class="op">.</span><span class="at">focalLen</span>) <span class="op">/</span> v<span class="op">;</span> <span class="co">// sH = (Yc/Zc)·f / (py − 0.5 − cy0)</span></span>
|
||||
<span id="cb8-5"><a href="#cb8-5" aria-hidden="true" tabindex="-1"></a><span class="fu">setParam</span>(<span class="st">'sensorH'</span><span class="op">,</span> <span class="bu">Math</span><span class="op">.</span><span class="fu">max</span>(<span class="dv">6</span><span class="op">,</span> <span class="bu">Math</span><span class="op">.</span><span class="fu">min</span>(<span class="dv">36</span><span class="op">,</span> sHNew)))<span class="op">;</span></span></code></pre></div>
|
||||
<p>영상 세로 중심(v≈0)은 발산 → <code>|v|<0.03</code>이면 거부하고 안내.</p>
|
||||
<h3 id="33-핵심-함수">3.3 핵심 함수</h3>
|
||||
<ul>
|
||||
<li><strong><code>toCameraCoords</code></strong> (<a href="client/src/utils/geoProjection.ts#L301">geoProjection.ts:301-317</a>) — GIS점→카메라좌표. 부산물 <code>distH</code>(수평 직선거리), <code>fwd/side</code>(진행방향 앞/옆 성분, 비등방 거리필터용). <code>pixelFromCamera</code>와 분리한 이유: 중심선처럼 선분 단위 근거리 Zc 클리핑·보간 후 투영해야 하기 때문.</li>
|
||||
<li><strong><code>pixelFromCamera</code></strong> (<a href="client/src/utils/geoProjection.ts#L126">geoProjection.ts:126-137</a>) — 카메라좌표→정규픽셀(클램프 없음).</li>
|
||||
<li><strong><code>groundPointFromPixel</code></strong> (<a href="client/src/utils/geoProjection.ts#L218">geoProjection.ts:218-251</a>) — 화면픽셀→지면 lat/lon. 광선-수평면 교차:
|
||||
<div class="sourceCode" id="cb9"><pre class="sourceCode ts"><code class="sourceCode typescript"><span id="cb9-1"><a href="#cb9-1" aria-hidden="true" tabindex="-1"></a>const t <span class="op">=</span> relUpTarget <span class="op">/</span> dir[<span class="dv">2</span>]<span class="op">;</span> <span class="co">// dir = R_w2cᵀ·((px−0.5−cx0)·sW/f, (py−0.5−cy0)·sH/f, 1)</span></span>
|
||||
<span id="cb9-2"><a href="#cb9-2" aria-hidden="true" tabindex="-1"></a><span class="fu">if</span> (t <span class="op"><=</span> <span class="dv">0</span>) return null<span class="op">;</span> <span class="co">// 카메라 뒤/위</span></span>
|
||||
<span id="cb9-3"><a href="#cb9-3" aria-hidden="true" tabindex="-1"></a>const E <span class="op">=</span> drEnu[<span class="dv">0</span>]<span class="op">+</span>offX <span class="op">+</span> t<span class="op">*</span>dir[<span class="dv">0</span>]<span class="op">;</span> const N <span class="op">=</span> drEnu[<span class="dv">1</span>]<span class="op">+</span>offY <span class="op">+</span> t<span class="op">*</span>dir[<span class="dv">1</span>]<span class="op">;</span></span>
|
||||
<span id="cb9-4"><a href="#cb9-4" aria-hidden="true" tabindex="-1"></a>const [lon<span class="op">,</span> lat] <span class="op">=</span> _toTM<span class="op">.</span><span class="fu">inverse</span>([E<span class="op">,</span> N])<span class="op">;</span></span></code></pre></div></li>
|
||||
<li>관련 역투영 2종: <code>worldFromPixel</code>(슬랜트거리 유지 lat/lon/z 동시복원, <a href="client/src/utils/geoProjection.ts#L149">149-183</a>), <code>solveZForPixelY</code>(lat/lon 고정, py에 맞는 z만 1차식 역산, <a href="client/src/utils/geoProjection.ts#L194">194-211</a>). → 발명 C(§7).</li>
|
||||
</ul>
|
||||
<h3 id="34-보정값-영속화-데이터셋별">3.4 보정값 영속화 (데이터셋별)</h3>
|
||||
<ul>
|
||||
<li>카메라/내부표정·표시설정: <code>ghivideo:calib:{baseName}</code> (로드 1회, 저장 800ms debounce, <a href="client/src/components/overlay/StationOverlay.tsx#L473">StationOverlay.tsx:473-500</a>).</li>
|
||||
<li>POI 위치/표고 override(DEM·드래그): <code>ghivideo:poiov:{baseName}</code> (폴더 파일 override 위에 localStorage 병합, <a href="client/src/components/overlay/StationOverlay.tsx#L502">502-524</a>).</li>
|
||||
</ul>
|
||||
<h3 id="35-설계-트레이드오프한계">3.5 설계 트레이드오프·한계</h3>
|
||||
<ul>
|
||||
<li><strong>렌즈 왜곡 미보정</strong>: 핀홀만 사용(방사/접선 왜곡 계수 없음). 분석 보조 라벨 정합엔 표준 정확도로 충분(가장자리 수 px 오차 가능).</li>
|
||||
<li><strong>클라이언트 순수 계산</strong>: proj4만 의존, 서버 왕복 없이 60fps RAF에서 직접 투영.</li>
|
||||
<li><strong>focal 분리 보정</strong>: 한 점 대응으로 sensorH 한 축만 역산(가로 정합 보존).</li>
|
||||
<li><strong>POI 표고 가정</strong>: 실측 표고 없으면 최근접 선로 지면표고(또는 "드론고도−이격") → §4·드래그·DEM 보정.</li>
|
||||
</ul>
|
||||
<hr />
|
||||
<h2 id="4-측점-표고-정합-dem-free-영상정합--발명-b">4. 측점 표고 정합 (DEM-free 영상정합) — 발명 B</h2>
|
||||
<h3 id="41-무엇을--왜">4.1 무엇을 / 왜</h3>
|
||||
<p>표시할 시설물(POI)은 주소 기반이라 <strong>표고값이 없는</strong> 경우가 대부분이다. 표고가 없으면 부각(수직각) 산출 불가 → 종래는 지면을 평면으로 가정. 그러나 노선은 실제 기복(정표고 41.6m→57.5m)이 있어 평면 가정 시 라벨이 화면 밖/지면 아래로 이탈한다. 실측에서 잘못된 고도(≈0) 사용 시 <strong>부각오차 42.7°</strong>.</p>
|
||||
<p>→ 별도 DEM 취득 없이, <strong>노선을 따라 실측된 측점의 정표고</strong>를 지면고도원으로 삼아 정합한다.</p>
|
||||
<h3 id="42-구성독립항-필수요소">4.2 구성(독립항 필수요소)</h3>
|
||||
<ol type="1">
|
||||
<li><strong>측점 표고 수열 보유</strong> — 노선 따라 실측된 복수 측점의 (lat/lon + 정표고 EL). (실데이터: 측점 47개, 41.6→57.5m)</li>
|
||||
<li><strong>최근접 측점 지면고도 채택</strong> — 표고 미상 대상에 가장 가까운 측점의 정표고를 지면고도로. 프레임 진행에 따라 최근접 측점이 바뀌어 <strong>지면고도가 노선 기복을 따라 갱신</strong>.</li>
|
||||
<li><strong>지오이드 datum 정합</strong> — 정표고(EL) + 지오이드고(N) → 타원체고 → 드론 GPS와 동일 기준. <code>rel_up = (targetZ + N) − droneAlt</code>.</li>
|
||||
<li><strong>정합 투영</strong> — §2 파이프라인 수행.</li>
|
||||
<li>(종속) <strong>대상별 표고 오프셋</strong>(poiZOffset) — 건물높이/지반차 보정, 측점·중심선엔 미적용.</li>
|
||||
</ol>
|
||||
<h3 id="43-검증값">4.3 검증값</h3>
|
||||
<pre><code>타원체고 = 정표고(EL) + 지오이드고(N≈25.8m, 대전)
|
||||
# 측점 157K900, frame0, 수평거리 91.3m
|
||||
정표고≈0 → 부각 42.7° ❌
|
||||
정표고 41.5 → 부각 25.1°
|
||||
정표고+N≈67 → 부각 11.0° ✅ (드론 rel_alt 16.85m와 일치)</code></pre>
|
||||
<ul>
|
||||
<li><strong>데이터 함정(핵심)</strong>: <code>building/01)측점.csv</code>에 표고 컬럼이 둘 — <code>Z좌표</code>(로컬 ≈0) / <code>Z좌표_한국</code>(EPSG:5186 정표고 ≈41.5m). 기존 코드가 <code>Z좌표</code>(≈0)를 읽어 42.7° 오차 발생 → <code>Z좌표_한국</code> 우선으로 교정(<a href="client/src/utils/geoData.ts">geoData.ts 측점 파서</a>).</li>
|
||||
<li><strong>효과</strong>: 부각오차 42.7°→<strong>11.0°</strong>, 구글어스 DEM 대비 <strong>0.2m 일치</strong>(2160p에서 ~6px). DEM 취득비용 불필요. 공개 DEM(SRTM 30m)보다 측량 측점이 더 정밀.</li>
|
||||
</ul>
|
||||
<hr />
|
||||
<h2 id="5-드론-자세-평활보간--영상-오버레이-렌더링">5. 드론 자세 평활/보간 + 영상 오버레이 렌더링</h2>
|
||||
<blockquote>
|
||||
<p>두 본질 과제: <strong>(a) 자세/GPS 노이즈</strong>(단순평균은 회전 뭉갬→지연), <strong>(b) 렌더 부드러움 vs 계산비용</strong>(가시성 판정은 비싸고 60fps는 매 프레임 재투영 요구).</p>
|
||||
</blockquote>
|
||||
<h3 id="51-에지회전경계-보존-적응형-이동평균--smoothframe">5.1 에지(회전경계) 보존 적응형 이동평균 — <code>smoothFrame</code></h3>
|
||||
<p><a href="client/src/components/overlay/StationOverlay.tsx#L577">StationOverlay.tsx:577-604</a>. 중심 <code>i</code>에서 좌우 최대 <code>halfWin</code>(기본 60fr≈2초)까지 평균하되, <strong>중심 yaw와 차이가 <code>YAW_EDGE=8°</code> 이내인 인접까지만 창 확장</strong>.</p>
|
||||
<div class="sourceCode" id="cb11"><pre class="sourceCode ts"><code class="sourceCode typescript"><span id="cb11-1"><a href="#cb11-1" aria-hidden="true" tabindex="-1"></a>const YAW_EDGE <span class="op">=</span> <span class="dv">8</span><span class="op">;</span></span>
|
||||
<span id="cb11-2"><a href="#cb11-2" aria-hidden="true" tabindex="-1"></a>const near <span class="op">=</span> (idx) <span class="kw">=></span> <span class="bu">Math</span><span class="op">.</span><span class="fu">abs</span>(((frames[idx]<span class="op">.</span><span class="at">yaw</span> <span class="op">-</span> yc <span class="op">+</span> <span class="dv">540</span>) <span class="op">%</span> <span class="dv">360</span>) <span class="op">-</span> <span class="dv">180</span>) <span class="op"><=</span> YAW_EDGE<span class="op">;</span></span>
|
||||
<span id="cb11-3"><a href="#cb11-3" aria-hidden="true" tabindex="-1"></a><span class="fu">while</span> (lo <span class="op">></span> loMin <span class="op">&&</span> <span class="fu">near</span>(lo <span class="op">-</span> <span class="dv">1</span>)) lo<span class="op">--;</span> <span class="co">// 회전경계에서 멈춤</span></span>
|
||||
<span id="cb11-4"><a href="#cb11-4" aria-hidden="true" tabindex="-1"></a><span class="fu">while</span> (hi <span class="op"><</span> hiMax <span class="op">&&</span> <span class="fu">near</span>(hi <span class="op">+</span> <span class="dv">1</span>)) hi<span class="op">++;</span></span>
|
||||
<span id="cb11-5"><a href="#cb11-5" aria-hidden="true" tabindex="-1"></a><span class="co">// yaw는 원형평균: atan2(Σsin, Σcos)</span></span>
|
||||
<span id="cb11-6"><a href="#cb11-6" aria-hidden="true" tabindex="-1"></a>yaw<span class="op">:</span> <span class="bu">Math</span><span class="op">.</span><span class="fu">atan2</span>(sinYaw <span class="op">/</span> n<span class="op">,</span> cosYaw <span class="op">/</span> n) <span class="op">*</span> <span class="dv">180</span> <span class="op">/</span> <span class="bu">Math</span><span class="op">.</span><span class="at">PI</span><span class="op">,</span></span></code></pre></div>
|
||||
<ul>
|
||||
<li>직선: 창 풀폭 → 노이즈 강하게 억제. 회전 중: 창 좁아져 <strong>즉시 추종</strong>(지연 없음). 회전 후: <strong>한 프레임씩</strong> 점진 재확장(튐 없음). 과거 '변화율 기반 축소'는 회전 종료 시 한꺼번에 재확장돼 회전 프레임이 다시 섞여 1회 튀던 부작용 → 경계보존으로 교체.</li>
|
||||
</ul>
|
||||
<h3 id="52-연속-프레임번호-보간-포즈--poseat">5.2 연속 프레임번호 보간 포즈 — <code>poseAt</code></h3>
|
||||
<p><a href="client/src/components/overlay/StationOverlay.tsx#L670">StationOverlay.tsx:670-690</a>. 실수 프레임번호 <code>estFrame</code>에 대해 양 이웃의 평활포즈를 선형보간(이산 30fps 격자→60fps 연속). yaw는 <strong>최단각</strong> 보간:</p>
|
||||
<div class="sourceCode" id="cb12"><pre class="sourceCode ts"><code class="sourceCode typescript"><span id="cb12-1"><a href="#cb12-1" aria-hidden="true" tabindex="-1"></a>let dy <span class="op">=</span> b<span class="op">.</span><span class="at">yaw</span> <span class="op">-</span> a<span class="op">.</span><span class="at">yaw</span><span class="op">;</span> dy <span class="op">=</span> ((dy <span class="op">+</span> <span class="dv">540</span>) <span class="op">%</span> <span class="dv">360</span>) <span class="op">-</span> <span class="dv">180</span><span class="op">;</span> <span class="co">// 최단각</span></span>
|
||||
<span id="cb12-2"><a href="#cb12-2" aria-hidden="true" tabindex="-1"></a>return { <span class="op">...</span>a<span class="op">,</span> frame<span class="op">:</span> estFrame<span class="op">,</span> lat<span class="op">:</span><span class="fu">L</span>(a<span class="op">.</span><span class="at">lat</span><span class="op">,</span>b<span class="op">.</span><span class="at">lat</span>)<span class="op">,</span> <span class="op">...,</span> yaw<span class="op">:</span> a<span class="op">.</span><span class="at">yaw</span> <span class="op">+</span> dy<span class="op">*</span>frac }<span class="op">;</span></span></code></pre></div>
|
||||
<p>O(log n) 이진탐색으로 매 RAF 호출 가능.</p>
|
||||
<h3 id="53-속도적응-ema--이상치-거부--smoothstep-one-euro-사상">5.3 속도적응 EMA + 이상치 거부 — <code>smoothStep</code> (One-Euro 사상)</h3>
|
||||
<p><a href="client/src/components/overlay/StationOverlay.tsx#L48">StationOverlay.tsx:48-61</a>. <strong>느릴 때(떨림)는 강하게 평활, 빠를 때(실제 팬)는 즉시 추종.</strong></p>
|
||||
<div class="sourceCode" id="cb13"><pre class="sourceCode ts"><code class="sourceCode typescript"><span id="cb13-1"><a href="#cb13-1" aria-hidden="true" tabindex="-1"></a><span class="fu">if</span> (d <span class="op">></span> REJECT_DIST <span class="op">&&</span> prev<span class="op">.</span><span class="at">rej</span> <span class="op"><</span> MAX_REJECT_FRAMES) <span class="co">// 0.12 초과 점프 = 이상치 → 위치 유지</span></span>
|
||||
<span id="cb13-2"><a href="#cb13-2" aria-hidden="true" tabindex="-1"></a> return { x<span class="op">:</span> prev<span class="op">.</span><span class="at">x</span><span class="op">,</span> y<span class="op">:</span> prev<span class="op">.</span><span class="at">y</span><span class="op">,</span> rej<span class="op">:</span> prev<span class="op">.</span><span class="at">rej</span> <span class="op">+</span> <span class="dv">1</span><span class="op">,</span> <span class="op">...</span> }<span class="op">;</span></span>
|
||||
<span id="cb13-3"><a href="#cb13-3" aria-hidden="true" tabindex="-1"></a>const vx <span class="op">=</span> prev<span class="op">.</span><span class="at">vx</span> <span class="op">+</span> (dx <span class="op">-</span> prev<span class="op">.</span><span class="at">vx</span>) <span class="op">*</span> SMOOTH_VEL_BETA<span class="op">;</span> <span class="co">// β=0.25 속도평활(떨림 왕복은 상쇄)</span></span>
|
||||
<span id="cb13-4"><a href="#cb13-4" aria-hidden="true" tabindex="-1"></a>const a <span class="op">=</span> <span class="bu">Math</span><span class="op">.</span><span class="fu">min</span>(maxAlpha<span class="op">,</span> minAlpha <span class="op">+</span> (maxAlpha <span class="op">-</span> minAlpha) <span class="op">*</span> <span class="bu">Math</span><span class="op">.</span><span class="fu">min</span>(<span class="dv">1</span><span class="op">,</span> speed<span class="op">/</span>speedRef))<span class="op">;</span></span>
|
||||
<span id="cb13-5"><a href="#cb13-5" aria-hidden="true" tabindex="-1"></a>return { x<span class="op">:</span> prev<span class="op">.</span><span class="at">x</span> <span class="op">+</span> dx<span class="op">*</span>a<span class="op">,</span> y<span class="op">:</span> prev<span class="op">.</span><span class="at">y</span> <span class="op">+</span> dy<span class="op">*</span>a<span class="op">,</span> <span class="op">...</span> }<span class="op">;</span></span></code></pre></div>
|
||||
<ul>
|
||||
<li>이상치 거부에 <strong>연속거부 한계(MAX_REJECT_FRAMES=8)</strong> → 시크/재등장 같은 진짜 큰 이동에 영구 고착 방지.</li>
|
||||
<li>추가로 <strong>픽셀 히스테리시스</strong>(0.75px 이상일 때만 정수좌표 갱신)로 저속 반올림 깜빡임 제거(<a href="client/src/components/overlay/StationOverlay.tsx#L1048">1048</a>).</li>
|
||||
</ul>
|
||||
<h3 id="54-라벨-사전계산--겹침-억제">5.4 라벨 사전계산 + 겹침 억제</h3>
|
||||
<ul>
|
||||
<li><strong><code>startLabelPrecompute</code></strong> (<a href="client/src/components/overlay/StationOverlay.tsx#L692">StationOverlay.tsx:692-746</a>): 전 프레임의 <strong>가시집합·겹침해소 결과</strong>를 <code>Map<frameNum, LabelCache></code>에 사전계산. <strong>화면좌표가 아니라 월드좌표(lat/lon/z)를 저장</strong> → RAF가 매 프레임 연속포즈로 재투영. <code>requestIdleCallback</code> 200프레임 청크, 취소토큰(<code>precomputeIdRef</code>), <strong>현재 프레임부터 순환 처리</strong>(재생 즉시 라벨), 첫 청크 즉시 노출, 재계산 시 옛 맵 복사.</li>
|
||||
<li><strong>겹침 억제</strong> (<a href="client/src/components/overlay/StationOverlay.tsx#L755">755-784</a>): 임계 가로 <code>POI_MERGE_X=0.10</code>/세로 <code>0.035</code>(비등방, 텍스트가 가로로 긴 특성). 우선순위 ①철도역/역사 최우선 ②근접거리. <strong>진행방향 변형 우선</strong>: 상/하행 형제 겹침 시 노선 방향((상)/(하)) 변형을 남김.</li>
|
||||
</ul>
|
||||
<h3 id="55-raf-렌더-루프--성능-설계">5.5 RAF 렌더 루프 & 성능 설계</h3>
|
||||
<p>매 프레임 <strong>계산 없이 조회+투영+드로잉만</strong>. 라이브 시간은 VideoPlayer <code>smoothTimeRef</code>(단조 벽시계 보간, 일시정지·시크·배속 보정)에서 받음.</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr class="header">
|
||||
<th>작업</th>
|
||||
<th>비용</th>
|
||||
<th>배치</th>
|
||||
<th>빈도</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr class="odd">
|
||||
<td>가시성/겹침 판정</td>
|
||||
<td>높음</td>
|
||||
<td>precompute</td>
|
||||
<td>1회(+설정변경 debounce), idle 청크</td>
|
||||
</tr>
|
||||
<tr class="even">
|
||||
<td>좌표 투영</td>
|
||||
<td>중간</td>
|
||||
<td>RAF</td>
|
||||
<td>매 프레임</td>
|
||||
</tr>
|
||||
<tr class="odd">
|
||||
<td>평활/EMA/히스테리시스</td>
|
||||
<td>낮음</td>
|
||||
<td>RAF</td>
|
||||
<td>매 프레임</td>
|
||||
</tr>
|
||||
<tr class="even">
|
||||
<td>팝업 가시집합 setState</td>
|
||||
<td>리렌더</td>
|
||||
<td>setInterval 150ms</td>
|
||||
<td>변화 시만</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p><strong>핵심 = "무엇을 그릴지"(precompute) ↔︎ "어디에 그릴지"(RAF) 분리</strong>로 단일스레드 60fps 달성. 중심선/드론궤적은 <code>buildLines</code>로 매 프레임 직접 투영(근거리 Zc 클리핑·outcode 컬링). 나침반은 별도 ±3fr 평활 yaw + 360° 누적 언랩.</p>
|
||||
<hr />
|
||||
<h2 id="6-측점체이니지-시스템--스테이션바">6. 측점(체이니지) 시스템 & 스테이션바</h2>
|
||||
<h3 id="61-연속-체이니지-수직투영-핵심-①">6.1 연속 체이니지 수직투영 (핵심 ①)</h3>
|
||||
<p>드론 한 프레임의 위경도를 <strong>측점 폴리라인에 수직 투영</strong>해 100m 양자화가 아닌 <strong>10m 해상도 연속 측점값</strong>을 얻는다(부산물: 선로 수직 이격 offsetM).</p>
|
||||
<ul>
|
||||
<li><strong>폴리라인 구축</strong> <code>buildChainLine</code> (<a href="client/src/utils/chainage.ts#L22">chainage.ts:22-32</a>): 측점값 오름차순 정렬, 평면투영 <code>x=lon·k, y=lat·111000</code> (k=cos(lat0)·111000).</li>
|
||||
<li><strong>점-선분 투영</strong> <code>projectToChain</code> (<a href="client/src/utils/chainage.ts#L35">chainage.ts:35-50</a>):
|
||||
<div class="sourceCode" id="cb14"><pre class="sourceCode ts"><code class="sourceCode typescript"><span id="cb14-1"><a href="#cb14-1" aria-hidden="true" tabindex="-1"></a>const t <span class="op">=</span> L2<span class="op">===</span><span class="dv">0</span><span class="op">?</span><span class="dv">0</span><span class="op">:</span><span class="bu">Math</span><span class="op">.</span><span class="fu">max</span>(<span class="dv">0</span><span class="op">,</span><span class="bu">Math</span><span class="op">.</span><span class="fu">min</span>(<span class="dv">1</span><span class="op">,</span> ((px<span class="op">-</span>a<span class="op">.</span><span class="at">x</span>)<span class="op">*</span>dx <span class="op">+</span> (py<span class="op">-</span>a<span class="op">.</span><span class="at">y</span>)<span class="op">*</span>dy)<span class="op">/</span>L2))<span class="op">;</span></span>
|
||||
<span id="cb14-2"><a href="#cb14-2" aria-hidden="true" tabindex="-1"></a>const d <span class="op">=</span> (px<span class="op">-</span>cx)<span class="op">**</span><span class="dv">2</span> <span class="op">+</span> (py<span class="op">-</span>cy)<span class="op">**</span><span class="dv">2</span><span class="op">;</span></span>
|
||||
<span id="cb14-3"><a href="#cb14-3" aria-hidden="true" tabindex="-1"></a><span class="fu">if</span> (d <span class="op"><</span> bestD) { bestD <span class="op">=</span> d<span class="op">;</span> bestKm <span class="op">=</span> a<span class="op">.</span><span class="at">km</span> <span class="op">+</span> (b<span class="op">.</span><span class="at">km</span><span class="op">-</span>a<span class="op">.</span><span class="at">km</span>)<span class="op">*</span>t<span class="op">;</span> } <span class="co">// 최근접 선분 위 km 보간</span></span></code></pre></div></li>
|
||||
<li><strong>왜 연속 투영인가</strong>: 최근접 측점점(100m 양자화)은 값이 계단처럼 튀고 두 측점 중간에서 전진/후진 전환 시점이 어긋난다. 수직투영은 선분 보간으로 매 프레임 부드럽게 변해 <strong>방향 전환점(극값) 타이밍이 실제 주행과 일치</strong>.</li>
|
||||
</ul>
|
||||
<h3 id="62-이중-측점값-배지용-km-vs-방향용-chain">6.2 이중 측점값: 배지용 <code>km</code> vs 방향용 <code>chain</code></h3>
|
||||
<p>프레임별 precompute(<a href="client/src/stationbar/StationBar.tsx#L233">StationBar.tsx:233-246</a>): <code>km</code>=최근접 측점점(100m, 배지/폴백), <code>chain</code>=연속투영(10m, 방향/축/탐지). <code>videoFps</code>는 실데이터(마지막 프레임/재생시간)에서 유도, 미비 시 <code>30000/1001</code> 폴백.</p>
|
||||
<h3 id="63-축-설계-시간축이-아닌-위치축-핵심-②">6.3 축 설계: 시간축이 아닌 위치축 (핵심 ②)</h3>
|
||||
<p>선로 점검 영상은 <strong>호버(정지비행)</strong> 와 <strong>전·후진 반복</strong> 특성이 있어 시간축이 부적합하다(호버 시 커서가 위치와 어긋남).</p>
|
||||
<ul>
|
||||
<li><strong>이동거리축</strong> (<a href="client/src/stationbar/StationBar.tsx#L250">StationBar.tsx:250-271</a>): 연속 측점값을 ±8fr 평활 후 프레임간 <strong>절대 변화량 누적</strong>(<code>cum += |sm[i]−sm[i−1]|</code>)을 정규화(<code>frac</code>, 단조 비감소). 호버=frac 정체(커서 정지), 이동=frac 증가. 방향은 색 리본으로 직교 분리.</li>
|
||||
<li><strong>측점축</strong>(후속 채택안, <a href="docs/history/2026-06-26_1520_스테이션바-측점축-전환.md">history 2026-06-26_1520</a>): x축을 <strong>측점값 비례</strong>로 두고, 축 범위를 역 POI 첫/끝으로 <strong>노선 고정</strong>(영상 무관 절대 측점) → 어떤 데이터로 재생하든 같은 구조물이 같은 위치/측점에 표시(회차 간 정렬).</li>
|
||||
<li>시간↔︎축 양방향 변환: <code>cumFracAtTime</code>/<code>timeAtFrac</code>(또는 <code>chainAtTime</code>/<code>pxAtChain</code>)를 이진탐색+보간으로. 커서·구간색·구조물 마커가 <strong>동일 매핑 공유</strong>. 라이브 커서는 React 리렌더 없이 RAF에서 CSS 변수(<code>--pos-px</code>)만 갱신(<a href="client/src/stationbar/StationBar.tsx#L771">771-786</a>).</li>
|
||||
</ul>
|
||||
<h3 id="64-방향-판정--방향색-트랙">6.4 방향 판정 & 방향색 트랙</h3>
|
||||
<p>평활 측점값 추세를 극값추적 + <strong>히스테리시스 HYST=10m</strong>로 방향 전환점 확정(<a href="client/src/stationbar/StationBar.tsx#L395">395-458</a>). <code>forwardDir = arrChain≥depChain?1:−1</code>. 정방향=주황 음영, 역방향=청록. 이동거리/측점축은 구간 px가 단조라 <code>linear-gradient</code> 1장으로 경량 처리(<a href="client/src/stationbar/StationBar.tsx#L686">686-726</a>).</p>
|
||||
<h3 id="65-구조물-마커--좌표측점-이중-탐지--kmexists-핵심-③">6.5 구조물 마커 — 좌표/측점 이중 탐지 & <code>kmExists</code> (핵심 ③)</h3>
|
||||
<p>두 통과 탐지기:</p>
|
||||
<ul>
|
||||
<li><strong><code>pxPassesTo(lat,lon,off)</code></strong> (<a href="client/src/stationbar/StationBar.tsx#L489">489-534</a>) — 좌표 근접. 임계 <code>TH=max(100, gmin·3)</code>.</li>
|
||||
<li><strong><code>pxPassesAtMileage(targetM)</code></strong> (<a href="client/src/stationbar/StationBar.tsx#L537">537-559</a>) — 연속 체이니지가 <code>TH=stationTolerance(기본 20m)</code> 이내.</li>
|
||||
</ul>
|
||||
<p>탐지 우선순위(단락평가): ①명시 측점값 ②좌표를 측점선 투영→측점기준(통과순서 정확) ③이름매칭 POI 투영 ④좌표근접 폴백 … (<a href="client/src/stationbar/StationBar.tsx#L612">612-639</a>).</p>
|
||||
<p><strong>역사 합집합 보강</strong>: 종점/조차장은 측점선 투영이 불안정해 재진입을 놓침(예 대전조차장 162k080). 단락평가 탓에 한 통과를 찾으면 좌표근접이 안 돌아 누락 → <strong>역사에 한해 좌표근접 통과를 합집합으로 합치되 24px 임계 중복제거</strong>(<a href="client/src/stationbar/StationBar.tsx#L629">629-634</a>).</p>
|
||||
<p><strong><code>kmExists</code> (★)</strong>: 좌표 반경만으로 잡힌 통과는 실제 측점값이 그 위치에 없을 수 있다.</p>
|
||||
<div class="sourceCode" id="cb15"><pre class="sourceCode ts"><code class="sourceCode typescript"><span id="cb15-1"><a href="#cb15-1" aria-hidden="true" tabindex="-1"></a>const kmExists <span class="op">=</span> stationKmVal <span class="op">==</span> null <span class="op">||</span> <span class="bu">Math</span><span class="op">.</span><span class="fu">abs</span>(p<span class="op">.</span><span class="at">km</span> <span class="op">-</span> stationKmVal) <span class="op"><=</span> existTol<span class="op">;</span> <span class="co">// existTol = stationTolerance</span></span></code></pre></div>
|
||||
<p>(<a href="client/src/stationbar/StationBar.tsx#L648">StationBar.tsx:648</a>) <code>false</code>면 <strong>측점값 라벨·측점검색에서 제외하되 마커(동그라미)는 유지</strong> — "위치 표시"와 "측점 단정"의 분리.</p>
|
||||
<p><strong>종점 미도착</strong>: <code>endGapPx=min(폭·0.15, (gapM/lenM)·폭)</code>, <code>placeUnreachedTerminal</code>이 최우측 역사를 트랙 끝에 미도착(속 빈 링)으로 고정(<a href="client/src/stationbar/StationBar.tsx#L303">303-308</a>, <a href="client/src/stationbar/StationBar.tsx#L574">574-586</a>).</p>
|
||||
<h3 id="66-측점검색-순환--handlejumptomileage">6.6 측점검색 순환 — <code>handleJumpToMileage</code></h3>
|
||||
<p><a href="client/src/stationbar/StationBar.tsx#L850">StationBar.tsx:850-916</a>. 측점값 입력 시 여러 통과를 <strong>통과방향 순환</strong>:</p>
|
||||
<ol type="1">
|
||||
<li>후보 <strong>1순위</strong>: 화면 마커(<code>structureMarks</code>) 중 <code>kmExists!==false</code> 이고 표시 10m값 일치 → px→<code>timeAtFrac</code> 역변환. (좌표근접 통과 포함 → 모든 동그라미 도달)</li>
|
||||
<li><strong>2순위</strong>: 마커 없으면 chain 연속구간, 최종 전역 최근접 폴백.</li>
|
||||
<li>정렬 + 0.3s 중복제거 → baseT 기준 다음 통과(끝→처음 순환). <code>jumpRef</code>로 연속검색 판정. Enter 후 입력값 유지, blur 시 삭제(<a href="client/src/stationbar/components/PlaybackControls/PlaybackControls.tsx">PlaybackControls.tsx</a>).</li>
|
||||
</ol>
|
||||
<h3 id="67-타임라인-보조-기술-timelinetsx">6.7 타임라인 보조 기술 (<a href="client/src/stationbar/components/Timeline/Timeline.tsx">Timeline.tsx</a>)</h3>
|
||||
<ul>
|
||||
<li><strong>동일항목 브래킷 클러스터</strong>: 동명 다통과를 거리 무관 한 묶음으로(드롭+수평 점선) 중앙 라벨 1개(<a href="client/src/stationbar/components/Timeline/Timeline.tsx#L143">143-172</a>).</li>
|
||||
<li><strong>라벨 2줄 균등분할</strong> <code>splitStructLabel</code>: CJK 1.0/기타 0.55폭, 괄호그룹은 통째로 아랫줄(<a href="client/src/stationbar/components/Timeline/Timeline.tsx#L30">30-49</a>).</li>
|
||||
<li><strong>측점값 라벨 겹침 숨김</strong> <code>showMileage</code>: 직전 표시와 <code>(lastW+w)/2</code> 미만이면 숨김, <code>kmExists===false</code>는 애초 생략(<a href="client/src/stationbar/components/Timeline/Timeline.tsx#L117">117-137</a>).</li>
|
||||
<li><strong>아이콘 passed 전환</strong>: upcoming/passed 두 PNG를 opacity로 전환(무깜빡임), 전 상태 사전로드.</li>
|
||||
</ul>
|
||||
<hr />
|
||||
<h2 id="7-위치보정-역투영-단일-제스처--발명-c">7. 위치보정 역투영 (단일 제스처) — 발명 C</h2>
|
||||
<h3 id="71-무엇을--왜">7.1 무엇을 / 왜</h3>
|
||||
<p>지오코딩 POI는 ±수십 m 오차. 2D 화면 이동만으로는 카메라로부터의 거리(깊이)를 결정 못함(깊이 모호성). 종래는 수평/높이를 따로 조정하거나 깊이맵(LiDAR) 요구. → <strong>단일 드래그로 수평·수직(3D) 동시 보정, 깊이맵 불요.</strong></p>
|
||||
<h3 id="72-구성독립항-필수요소">7.2 구성(독립항 필수요소)</h3>
|
||||
<ol type="1">
|
||||
<li><strong>슬랜트거리 보존</strong> — 드래그 개시 시점의 카메라–객체 슬랜트거리(range)를 고정 → 깊이 모호성 해소 핵심 구속.</li>
|
||||
<li><strong>역투영</strong> — 드롭 픽셀을 카메라 방향벡터로 환산 후 보존 거리만큼 투사.</li>
|
||||
<li><strong>월드좌표 복원</strong> — 자세 회전·평행이동 역변환으로 lat/lon/z 복원(지오이드 역환원). 한 번의 드래그로 수평·수직 동시.</li>
|
||||
<li><strong>영속·재적용</strong> — 복원 좌표를 객체 식별자(title)에 연계해 <code><base>_poi_overrides.json</code> + <code>ghivideo:poiov:<baseName></code> 저장.</li>
|
||||
<li>(종속) <strong>가시영역 한정</strong> — 전방(Zc>0)만 보정.</li>
|
||||
</ol>
|
||||
<div class="sourceCode" id="cb16"><pre class="sourceCode ts"><code class="sourceCode typescript"><span id="cb16-1"><a href="#cb16-1" aria-hidden="true" tabindex="-1"></a>dir_cam <span class="fu">∝</span> ((px−0<span class="op">.</span><span class="dv">5</span>−cx0)·sW<span class="op">/</span>f<span class="op">,</span> (py−0<span class="op">.</span><span class="dv">5</span>−cy0)·sH<span class="op">/</span>f<span class="op">,</span> <span class="dv">1</span>)</span>
|
||||
<span id="cb16-2"><a href="#cb16-2" aria-hidden="true" tabindex="-1"></a>cam <span class="op">=</span> dir_cam<span class="op">/|</span>dir_cam<span class="op">|</span> · range <span class="co">// ★거리 보존(드래그 시작값)</span></span>
|
||||
<span id="cb16-3"><a href="#cb16-3" aria-hidden="true" tabindex="-1"></a>rel <span class="op">=</span> R_w2cᵀ · cam <span class="co">// 회전 역변환</span></span>
|
||||
<span id="cb16-4"><a href="#cb16-4" aria-hidden="true" tabindex="-1"></a>z <span class="op">=</span> droneAlt <span class="op">+</span> offZ <span class="op">+</span> rel<span class="op">.</span><span class="at">up</span> − N_geoid <span class="co">// 정표고 복원</span></span>
|
||||
<span id="cb16-5"><a href="#cb16-5" aria-hidden="true" tabindex="-1"></a>(lat<span class="op">,</span>lon) <span class="op">=</span> <span class="fu">TM⁻¹</span>(droneE<span class="op">+</span>offX<span class="op">+</span>rel<span class="op">.</span><span class="at">x</span><span class="op">,</span> droneN<span class="op">+</span>offY<span class="op">+</span>rel<span class="op">.</span><span class="at">y</span>)</span></code></pre></div>
|
||||
<p><strong>변형 — z 전용 역산</strong> <code>solveZForPixelY</code>: lat/lon 고정, 드롭 세로위치로 z만 1차식 역산. 왕복검증 z=42→py→42.000.</p>
|
||||
<h3 id="73-효과">7.3 효과</h3>
|
||||
<p>2D 드래그 1회로 3D 보정(단계 절반), 역투영 왕복오차 ≈0, 깊이맵 불요, 파일+localStorage 재현성. <strong>"depth map 없이 거리보존"</strong> 을 명시해 US9188444B2와 구별(§10).</p>
|
||||
<hr />
|
||||
<h2 id="8-백엔드-스트리밍ffmpeg--데이터-파이프라인--상태관리--플레이어">8. 백엔드 스트리밍/FFmpeg · 데이터 파이프라인 · 상태관리 · 플레이어</h2>
|
||||
<h3 id="81-대용량2gb-재생--이중-경로">8.1 대용량(2GB+) 재생 — 이중 경로</h3>
|
||||
<ul>
|
||||
<li><strong>로컬</strong>(<a href="client/src/hooks/useVideoPlayer.ts#L80">useVideoPlayer.ts:80-88</a>): <code>URL.createObjectURL</code> Blob URL 직접 재생(업로드/변환 없음), 교체 시 <code>revokeObjectURL</code>(누수 방지). <strong>현 운영 주 경로.</strong></li>
|
||||
<li><strong>서버</strong>: Range Request 즉시 재생, 비표준 코덱(HEVC 등)이면 HLS 자동 전환(<a href="client/src/hooks/useVideoPlayer.ts#L15">15-17</a>). (서버/HLS 경로는 보존되나 UI는 <code>SHOW_TOOLBAR=false</code>로 숨김.)</li>
|
||||
</ul>
|
||||
<h3 id="82-range-request-streamingts52-77">8.2 Range Request (<a href="server/src/services/streaming.ts#L52">streaming.ts:52-77</a>)</h3>
|
||||
<p>핵심: 한 응답에 전체를 싣지 않음. 끝값 없으면 <code>start+10MB−1</code>. <code>pipeline()</code>(백프레셔/에러 자동), <code>highWaterMark:1MB</code>, <code>Accept-Ranges:bytes</code>, path traversal 방어(<code>resolveVideoPath</code>). JS Number(2^53−1)로 4GB+ 안전(BigInt 불요). 멀티레인지 미지원.</p>
|
||||
<h3 id="83-hls-변환--sse-hlsts">8.3 HLS 변환 + SSE (<a href="server/src/routes/hls.ts">hls.ts</a>)</h3>
|
||||
<p>멱등(디스크에 <code>index.m3u8</code> 있으면 즉시 done), 코덱 분기(H.264 <code>-c copy</code> 수 초 / 그 외 libx264), <code>/progress</code> SSE로 stderr <code>time=</code> 파싱 푸시. hls.js 필수: <code>backBufferLength:30</code>(기본 Infinity면 장시간 재생 메모리 누수→크래시), maxBufferLength 30, enableWorker(<a href="client/src/hooks/useVideoPlayer.ts#L7">useVideoPlayer.ts:7-13</a>).</p>
|
||||
<h3 id="84-ffmpeg-spawn-래퍼-ffmpegts">8.4 FFmpeg spawn 래퍼 (<a href="server/src/services/ffmpeg.ts">ffmpeg.ts</a>)</h3>
|
||||
<p><code>-y</code> auto, stderr <code>time=HH:MM:SS.cc</code> 정규식 진행률, exit≠0 시 stderr 끝 500자 reject. 프레임 추출은 <code>-accurate_seek -ss {t} -i {f} -frames:v 1</code>(플래그 순서 = 입력단 정확 시킹, <a href="server/src/routes/frame.ts#L37">frame.ts:37-44</a>).</p>
|
||||
<h3 id="85-데이터-파이프라인-geodatats">8.5 데이터 파이프라인 (<a href="client/src/utils/geoData.ts">geoData.ts</a>)</h3>
|
||||
<p><code><input webkitdirectory></code>/드래그로 받은 <code>File[]</code>을 병렬 파싱(<code>Promise.all</code>). <strong>인코딩 자동감지</strong>(BOM→UTF-8/EUC-KR), <strong>헤더명 우선+위치 폴백</strong>(<code>makeFieldIndexer</code>), 좌표 유효성 게이트(lat 33~39/lon 124~132), KMZ 우선(있으면 CSV POI/구조물 미사용, fflate unzipSync).</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr class="header">
|
||||
<th>소스</th>
|
||||
<th>파서</th>
|
||||
<th>대상</th>
|
||||
<th>매핑</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr class="odd">
|
||||
<td><code><base>.csv</code></td>
|
||||
<td>parseDroneFrames</td>
|
||||
<td>DroneFrame[]</td>
|
||||
<td>frame_cnt, lat/lon/alt, yaw/pitch/roll, focal</td>
|
||||
</tr>
|
||||
<tr class="even">
|
||||
<td><code>building/01)측점.csv</code></td>
|
||||
<td>parseStations</td>
|
||||
<td>station + DirectionChange</td>
|
||||
<td>측점→title, <strong>Z좌표_한국(정표고) 우선</strong>, 비고→방향전환</td>
|
||||
</tr>
|
||||
<tr class="odd">
|
||||
<td><code><base>_POI.csv</code> / <code>02)지장물.csv</code></td>
|
||||
<td>parsePois</td>
|
||||
<td>poi</td>
|
||||
<td>category_clean/명칭, lat/lon</td>
|
||||
</tr>
|
||||
<tr class="even">
|
||||
<td><code>02)지장물_역사.csv</code></td>
|
||||
<td>parseHistoricStations</td>
|
||||
<td>역사 구조물</td>
|
||||
<td>명칭→name(스테이션바 마커)</td>
|
||||
</tr>
|
||||
<tr class="odd">
|
||||
<td><code>03)교량/04)터널/06)구교.csv</code></td>
|
||||
<td>parseStructures</td>
|
||||
<td>bridge/tunnel</td>
|
||||
<td>구분/시설물명, 연장→lengthM, 시설종별→grade</td>
|
||||
</tr>
|
||||
<tr class="even">
|
||||
<td><code>05)출입문번호.csv</code></td>
|
||||
<td>parseAccessDoors</td>
|
||||
<td>출입문 poi</td>
|
||||
<td>출입문번호→title</td>
|
||||
</tr>
|
||||
<tr class="odd">
|
||||
<td><code>*.kmz/kml</code></td>
|
||||
<td>parseKmz</td>
|
||||
<td>poi+구조물</td>
|
||||
<td>Placemark + description표→props</td>
|
||||
</tr>
|
||||
<tr class="even">
|
||||
<td><code>route.json</code></td>
|
||||
<td>mergeStructures</td>
|
||||
<td>보정</td>
|
||||
<td>station/offset/이정 override</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p>중심선은 v2.0에 별도 파일이 없어 측점을 측점값 순 정렬한 폴리라인으로 합성.</p>
|
||||
<h3 id="86-상태관리">8.6 상태관리</h3>
|
||||
<ul>
|
||||
<li><strong>geoStore</strong>(메모리, persist 없음): frames/stations/pois/structures/centerline/origin/routeMeta. <code>basePois</code>(원본) vs <code>pois</code>(override 적용) 분리로 원본 불변성(<a href="client/src/store/geoStore.ts#L96">geoStore.ts:96-110</a>).</li>
|
||||
<li><strong>settingsStore</strong>(localStorage <code>ghivideo.settings</code>): 시설종별 필터, 표시 토글들. 커스텀 <code>merge</code>로 스키마 진화 대비. <code>isGradeVisible</code> 공유 규칙.</li>
|
||||
</ul>
|
||||
<h3 id="87-플레이어-통합--프레임-정확도">8.7 플레이어 통합 & 프레임 정확도</h3>
|
||||
<ul>
|
||||
<li>Video.js <code>data-vjs-player</code> + Strict Mode 가드, <code>controls:false</code>(측점바로 대체), <code>fill:true</code>.</li>
|
||||
<li><strong>FPS 감지</strong>(<a href="client/src/hooks/useFrameStep.ts#L21">useFrameStep.ts:21-49</a>): <code>requestVideoFrameCallback</code>, 첫 콜백은 시작점만 기록(31fps 오감지 방지), 연속 2회 일치 확정. 스텝은 <code>1/fps</code>(누적 금지=드리프트 방지).</li>
|
||||
<li><strong>이원 fps</strong>: 스텝 이동=실측 fps, 프레임번호 표시=29.97 고정(SRT 기준). 의도된 분리.</li>
|
||||
<li><strong>매끄러운 커서</strong>: rAF 단조 벽시계 보간 <code>smoothTimeRef</code>(매 프레임, transform) + <code>smoothTime</code>(throttle, 배지) 분리.</li>
|
||||
</ul>
|
||||
<hr />
|
||||
<h2 id="9-개발-타임라인-주요-기술-의사결정">9. 개발 타임라인 (주요 기술 의사결정)</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr class="header">
|
||||
<th>시기</th>
|
||||
<th>핵심 결정/구현</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr class="odd">
|
||||
<td>2026-04-01</td>
|
||||
<td>labelMap 사전계산 + requestIdleCallback + RAF, ±10fr 이동평균(yaw sin/cos), 30→60fps 보간</td>
|
||||
</tr>
|
||||
<tr class="even">
|
||||
<td>2026-04-02</td>
|
||||
<td><strong>투영 파이프라인 정립</strong>: EPSG:5186 TM, 핀홀(Rz·Rx·Ry), 타원체고=정표고+지오이드, Cohen-Sutherland 클리핑</td>
|
||||
</tr>
|
||||
<tr class="odd">
|
||||
<td>2026-06-19</td>
|
||||
<td><strong>v1→v2 데이터 형상 전환</strong>(실측 폴더), 인코딩 자동감지, 측점 비고→방향전환 추출, 시설종별 필터</td>
|
||||
</tr>
|
||||
<tr class="even">
|
||||
<td>2026-06-22</td>
|
||||
<td><strong>측점 표고 컬럼 교정(Z좌표_한국) → 부각 42.7°→11°</strong>(발명 B). 역투영 드래그 보정+override(발명 C 최초). title-키 보간+이상치 거부. 비등방 거리필터</td>
|
||||
</tr>
|
||||
<tr class="odd">
|
||||
<td>2026-06-23</td>
|
||||
<td>에지보존 평활(YAW_EDGE=8°). <strong>글로벌+KIPRIS 선행기술 조사</strong> → B·C 신규성 확인</td>
|
||||
</tr>
|
||||
<tr class="even">
|
||||
<td>2026-06-24</td>
|
||||
<td><strong>1클릭 FOV 보정(→sensorH 역산)</strong>, object-fit:cover 정렬, 라벨 월드좌표 저장+RAF 재투영, One-Euro EMA, KMZ 직접 로딩</td>
|
||||
</tr>
|
||||
<tr class="odd">
|
||||
<td>2026-06-25~26</td>
|
||||
<td>종점역 누락 수정, 양끝 역 고정, 라벨 2줄분할, <strong>동일항목 브래킷</strong>, <strong>거리축→측점축 전환</strong></td>
|
||||
</tr>
|
||||
<tr class="even">
|
||||
<td>2026-06-29</td>
|
||||
<td><strong>좌표/측점 이중탐지+합집합 보강</strong>, 측점값 라벨 겹침숨김, <strong>측점검색 순환+마커기준 전환+kmExists</strong>, UI 통합(토글 재생바 이동, 활성색 amber), 보고서 3종 생성, 55000 외부접속</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p>상세 근거: <a href="docs/history/2026-06-22_POI투영오차-개선.md">docs/history/2026-06-22_POI투영오차-개선.md</a>(발명 B·C 1차 출처), <a href="docs/history/2026-06-26_1520_스테이션바-측점축-전환.md">2026-06-26_1520_측점축-전환</a>, <a href="docs/history/2026-06-29_1029_역사-통과-좌표근접-보강.md">2026-06-29_1029_역사-통과-좌표근접-보강</a> 등.</p>
|
||||
<hr />
|
||||
<h2 id="10-특허-관점-종합">10. 특허 관점 종합</h2>
|
||||
<blockquote>
|
||||
<p>출처: <a href="docs/특허_스테이션기반_주행영상_플레이어.html">docs/특허_스테이션기반_주행영상_플레이어.html</a>(기술발굴), <a href="docs/발명신고서_측점표고정합_역투영보정.html">docs/발명신고서_측점표고정합_역투영보정.html</a>(변리사 제출용).</p>
|
||||
</blockquote>
|
||||
<h3 id="101-기술요소-인벤토리-e1e9">10.1 기술요소 인벤토리 (E1~E9)</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr class="header">
|
||||
<th>코드</th>
|
||||
<th>요소</th>
|
||||
<th>특허 적합성</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr class="odd">
|
||||
<td>E1</td>
|
||||
<td>측점(체이니지) 기반 영상 색인·탐색</td>
|
||||
<td>후보 A(좁게)</td>
|
||||
</tr>
|
||||
<tr class="even">
|
||||
<td>E2</td>
|
||||
<td>지리객체 실시간 영상 정합(labelMap 사전계산)</td>
|
||||
<td>공지(큰 틀)</td>
|
||||
</tr>
|
||||
<tr class="odd">
|
||||
<td>E3</td>
|
||||
<td><strong>측점 표고 기반 DEM-free 지면고도 추정</strong></td>
|
||||
<td><strong>후보 B(상)</strong></td>
|
||||
</tr>
|
||||
<tr class="even">
|
||||
<td>E4</td>
|
||||
<td><strong>역투영+슬랜트거리 보존 단일드래그 위치보정</strong></td>
|
||||
<td><strong>후보 C(상)</strong></td>
|
||||
</tr>
|
||||
<tr class="odd">
|
||||
<td>E5</td>
|
||||
<td>비등방 거리필터+소실점 클러터 억제</td>
|
||||
<td>후보 D(종속)</td>
|
||||
</tr>
|
||||
<tr class="even">
|
||||
<td>E6</td>
|
||||
<td>노이즈 강건 오버레이(이중평활·title짝·이상치거부)</td>
|
||||
<td>후보 E(종속)</td>
|
||||
</tr>
|
||||
<tr class="odd">
|
||||
<td>E7</td>
|
||||
<td>측점 폴리라인→중심선</td>
|
||||
<td>제외(통상 처리)</td>
|
||||
</tr>
|
||||
<tr class="even">
|
||||
<td>E8</td>
|
||||
<td>이종 CSV 융합(인코딩 자동감지)</td>
|
||||
<td>제외</td>
|
||||
</tr>
|
||||
<tr class="odd">
|
||||
<td>E9</td>
|
||||
<td>비고 텍스트 방향전환 추출</td>
|
||||
<td>제외</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h3 id="102-출원-권고-우선순위">10.2 출원 권고 우선순위</h3>
|
||||
<ul>
|
||||
<li><strong>1순위: 발명 B + C 결합</strong> — 신규성/진보성 우수, 글로벌+한국 신규성 확인.</li>
|
||||
<li><strong>2순위: 후보 A(좁게)</strong> — LRS 비선형 재좌표화 + 회차간 위치앵커 + 다중회차 동기. 한국 선행기술(KR102268318B1·KR100411587B1) 위험 → 좁게 청구. <strong>본 명세의 측점축 노선좌표 고정(§6.3)·측점검색 순환(§6.6)·이중탐지 kmExists(§6.5)가 A의 구현 근거.</strong></li>
|
||||
<li><strong>3순위: D·E 종속항 흡수</strong>. <strong>제외: E7·E8·E9</strong>.</li>
|
||||
</ul>
|
||||
<h3 id="103-청구-포인트-핵심-bc">10.3 청구 포인트 핵심 (B·C)</h3>
|
||||
<ul>
|
||||
<li><strong>발명 B 독립항</strong>: ①측점 실측 정표고 수열 → ②표고미상 대상에 최근접 측점 정표고를 지면고도 채택(노선기복 반영) → ③지오이드고 가산으로 타원체고 변환·datum 정합 → ④정합 투영. <strong>※ ②+③ 결합을 한 청구항에</strong>(개별요소는 공지).</li>
|
||||
<li><strong>발명 C 독립항</strong>: ①드래그 개시 시 슬랜트거리 보존 → ②드롭픽셀 역투영 → ③단일 제스처로 수평·수직 동시 복원 → ④식별자 연계 영속. <strong>※ "depth map 없이 거리보존"</strong> 명시(US9188444B2 구별).</li>
|
||||
</ul>
|
||||
<h3 id="104-선행기술-조사-완료분">10.4 선행기술 (조사 완료분)</h3>
|
||||
<ul>
|
||||
<li>E1·E2 큰 틀은 공지: Esri ArcGIS Pro FMV, US9996976B2, Mandli Roadview, Remote GeoSystems LineVision, ENSCO Virtual Track Walk.</li>
|
||||
<li>B·C는 글로벌+한국 신규성 확인. <strong>단, KIPRIS 전문(專門)검색 미수행 — 출원 전 필수.</strong></li>
|
||||
</ul>
|
||||
<h3 id="105-신규성-후보-종합-구현-기반">10.5 신규성 후보 종합 (구현 기반)</h3>
|
||||
<ol type="1">
|
||||
<li>드론 SRT pose 기반 <strong>실시간 GIS-영상 정합 파이프라인</strong>(타원체고/정표고 datum 자동 정합 포함).</li>
|
||||
<li><strong>단일 대응점 sensorH 역산</strong> 인터랙티브 FOV 보정(가로 정합 보존, 중심부 발산 거부).</li>
|
||||
<li><strong>깊이 모호성 분리형 역투영 3종</strong>(슬랜트유지/지면교차/표고 1차역산)을 보정 의도별 선택.</li>
|
||||
<li><strong>에지(회전경계) 보존 적응형 평활</strong> + 속도적응 EMA(연속거부 한계).</li>
|
||||
<li><strong>월드좌표 precompute + 연속포즈 재투영 2단 분리</strong>로 단일스레드 60fps AR.</li>
|
||||
<li><strong>누적 이동거리/측점 비례 축 + 방향색 직교 분리</strong> 진행바, 노선좌표 고정(회차 정렬).</li>
|
||||
<li><strong>좌표/측점 이중 탐지 + 합집합 보강 + kmExists 신뢰도 플래그</strong>("위치 표시"와 "측점 단정" 분리).</li>
|
||||
</ol>
|
||||
<hr />
|
||||
<h2 id="11-검증-결과표">11. 검증 결과표</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr class="header">
|
||||
<th>항목</th>
|
||||
<th>개선 전</th>
|
||||
<th>개선 후</th>
|
||||
<th>근거</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr class="odd">
|
||||
<td>부각 오차(측점 157K900, 91.3m)</td>
|
||||
<td>42.7°</td>
|
||||
<td><strong>11.0°</strong></td>
|
||||
<td>§4.3</td>
|
||||
</tr>
|
||||
<tr class="even">
|
||||
<td>지면고도 정확도(측점 vs 구글 DEM)</td>
|
||||
<td>—</td>
|
||||
<td><strong>0.2m(~6px@2160p)</strong></td>
|
||||
<td>§4.3</td>
|
||||
</tr>
|
||||
<tr class="odd">
|
||||
<td>역투영 왕복오차(z=42→py→z)</td>
|
||||
<td>—</td>
|
||||
<td><strong>≈0 (42.000)</strong></td>
|
||||
<td>§7.2</td>
|
||||
</tr>
|
||||
<tr class="even">
|
||||
<td>화면 EMA 지연</td>
|
||||
<td>1.65s(α=0.01)</td>
|
||||
<td>~25ms(α=0.4)</td>
|
||||
<td>§5.3 / history 06-22</td>
|
||||
</tr>
|
||||
<tr class="odd">
|
||||
<td>렌더</td>
|
||||
<td>끊김</td>
|
||||
<td><strong>60fps</strong>(precompute+RAF 분리)</td>
|
||||
<td>§5.5</td>
|
||||
</tr>
|
||||
<tr class="even">
|
||||
<td>역 재진입 마커</td>
|
||||
<td>누락</td>
|
||||
<td>좌표근접 합집합 보강으로 표출</td>
|
||||
<td>§6.5</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p>지오이드고 N: 본 명세 <strong>25.8m</strong>(KNGeoid 검증) 통일. 초기 문서값 25.449m는 각주 처리.</p>
|
||||
<hr />
|
||||
<h2 id="12-후임자-인계">12. 후임자 인계</h2>
|
||||
<h3 id="121-데이터-흐름-한눈에">12.1 데이터 흐름 한눈에</h3>
|
||||
<p>geoStore(frames/stations/structures/routeMeta) → <code>useEffect</code> precompute(<code>viewedRef</code>=프레임별 km/chain/time, <code>chainRef</code>=time/frac/chain) → <code>pxAtTime</code>/<code>pxAtChain</code>·<code>barSegments</code>·<code>structureMarks</code> → Timeline 렌더. <strong>모든 px는 단일 매핑(<code>pxAtTime</code>/<code>pxAtChain</code>)을 통과</strong>, 역변환은 <code>timeAtFrac</code>/<code>chainAtTime</code>만 사용.</p>
|
||||
<h3 id="122-튜닝-포인트">12.2 튜닝 포인트</h3>
|
||||
<ul>
|
||||
<li><strong>폴더 route.json</strong>: <code>stationTolerance</code>(통과/검색 허용오차, 20m), <code>direction</code>/<code>directionChanges</code>(방향 변형 필터), <code>endStationGapMeters</code>/<code>lengthKm</code>(미도착 폭), <code>startStationName</code>/<code>endStationName</code>.</li>
|
||||
<li><strong>카메라(패널, 데이터셋별 저장)</strong>: <code>focalLen</code>, <code>sensorW</code>, <code>sensorH</code>(FOV), <code>yawOffset</code>/<code>pitch</code>/<code>roll</code>, <code>geoidOffset</code>(지역 N), <code>poiZOffset</code>.</li>
|
||||
<li><strong>코드 상수</strong>: 평활 <code>W=8</code>, <code>YAW_EDGE=8°</code>, <code>HYST=10m</code>, 좌표통과 <code>TH=max(100,gmin·3)</code>, 역사 합집합 <code>24px</code>, 검색 중복 <code>0.3s</code>, EMA <code>β=0.25</code>/<code>REJECT_DIST=0.12</code>/<code>MAX_REJECT_FRAMES=8</code>.</li>
|
||||
</ul>
|
||||
<h3 id="123-빌드배포--필수-숙지">12.3 빌드/배포 (★ 필수 숙지)</h3>
|
||||
<p>55000은 <strong>PM2 <code>ghiVideo</code>가 <code>client/dist</code>(빌드 결과)를 정적 서빙</strong>한다. 소스만 고치면 화면에 반영 안 됨.</p>
|
||||
<div class="sourceCode" id="cb17"><pre class="sourceCode bash"><code class="sourceCode bash"><span id="cb17-1"><a href="#cb17-1" aria-hidden="true" tabindex="-1"></a><span class="bu">export</span> <span class="va">PATH=</span><span class="st">"</span><span class="va">$HOME</span><span class="st">/.nvm/versions/node/v20.20.2/bin:</span><span class="va">$PATH</span><span class="st">"</span> # 시스템 <span class="va">node</span> <span class="va">v12</span> → <span class="va">tsc</span>/vite 실패</span>
|
||||
<span id="cb17-2"><a href="#cb17-2" aria-hidden="true" tabindex="-1"></a><span class="ex">npm</span> run build -w client # = tsc <span class="kw">&&</span> <span class="ex">vite</span> build → client/dist 갱신</span>
|
||||
<span id="cb17-3"><a href="#cb17-3" aria-hidden="true" tabindex="-1"></a><span class="co"># 브라우저 Ctrl+Shift+R, 폴더 데이터는 런타임 로드라 폴더 재선택 필요</span></span></code></pre></div>
|
||||
<p>외부(LAN) 접속: netsh portproxy(Windows :55000 → WSL IP:55000) + 방화벽. WSL IP는 재부팅 시 변동.</p>
|
||||
<h3 id="124-업그레이드-포인트">12.4 업그레이드 포인트</h3>
|
||||
<ol type="1">
|
||||
<li>렌즈 왜곡(Brown-Conrady) 추가 → 가장자리 정합.</li>
|
||||
<li>다점 대응 자동 캘리브레이션(focal·주점·왜곡 번들조정).</li>
|
||||
<li>지오이드 격자(KNGeoid18) 보간으로 <code>geoidOffset</code> 자동화.</li>
|
||||
<li>POI 표고 DEM 자동조회(현 부분지원).</li>
|
||||
<li>중심선 per-frame 투영 공간색인, precompute Web Worker화.</li>
|
||||
<li>frame.ts fps 고정(30)→<code>getVideoFps</code>, HLS jobs DB 큐, 멀티 Range 지원.</li>
|
||||
<li><strong>코드 통합</strong>: 측점 파서/포매터(chainage.ts ↔︎ StationBar.tsx), 투영함수(<code>projectToChain</code> ↔︎ <code>projectChainage</code>) 단일화.</li>
|
||||
</ol>
|
||||
<hr />
|
||||
<h2 id="13-한계--출원-전-확인사항">13. 한계 / 출원 전 확인사항</h2>
|
||||
<ul>
|
||||
<li><strong>KIPRIS 전문검색 미수행</strong> — 출원 전 필수.</li>
|
||||
<li>지오코딩 수평오차는 데이터 한계(코드 보정은 드래그 수동).</li>
|
||||
<li><code>geoidOffset</code> 지역 상수(대전 25.8m) — 타 지역 재튜닝.</li>
|
||||
<li>VFR/abs_alt 노이즈, 먼 거리 역투영 민감.</li>
|
||||
<li>미확정 항목(특허문서 <code>[확인 필요]</code> 유지): 다중회차 동기화 구현 범위, 위치앵커 주석 데이터모델, 권리귀속, 공개이력.</li>
|
||||
<li>렌즈 왜곡 미보정(핀홀만) — 가장자리 정확도 트레이드오프.</li>
|
||||
</ul>
|
||||
<hr />
|
||||
<p><em>본 문서는 5개 서브시스템(카메라 투영/자세 평활/측점·스테이션바/백엔드·데이터/기존 특허문서) 정밀 조사 결과를 종합한 것이다. 각 소스 참조는 클릭 가능한 파일:라인으로 추적 가능하며, 특허 출원 시 본 문서의 §3~§7·§10과 history 원문을 함께 변리사에게 제공할 것을 권장한다.</em></p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,541 @@
|
||||
# GhiVideo 종합 기술 명세서
|
||||
|
||||
> **목적**: 본 문서는 (1) **특허 출원/발명 신고**의 기초 자료와 (2) **후임 개발자 인계·업그레이드** 자료를 겸한다.
|
||||
> **대상 시스템**: GhiVideo — 시간축이 아니라 **노선 위치(측점·체이니지)** 를 1차 좌표로 삼아 드론 주행영상을 색인·탐색하고, 프레임별 카메라 자세로 측점·POI·선로중심선을 영상 위에 실시간 정합(geo-registration)하는 철도 주행영상 분석 플레이어.
|
||||
> **작성일**: 2026-06-29 · 모든 소스 참조는 `[경로](경로#Lxx)` 형식(클릭 가능).
|
||||
|
||||
---
|
||||
|
||||
## 0. 문서 구성과 읽는 법
|
||||
|
||||
| 장 | 내용 | 주 독자 |
|
||||
|----|------|---------|
|
||||
| 1 | 시스템 개요·아키텍처·데이터 흐름 | 전체 |
|
||||
| 2 | **좌표·투영 정본 파이프라인** (모든 정합의 기반) | 전체 |
|
||||
| 3 | 카메라 투영 & 화각(FOV) 정합 | 개발/특허 |
|
||||
| 4 | 측점 표고 정합 (DEM-free) — 발명 B | 특허 |
|
||||
| 5 | 드론 자세 평활/보간 + 오버레이 렌더링 | 개발 |
|
||||
| 6 | 측점(체이니지) 시스템 & 스테이션바 | 개발/특허 |
|
||||
| 7 | 위치보정 역투영 — 발명 C | 특허 |
|
||||
| 8 | 백엔드 스트리밍/FFmpeg·데이터·상태관리 | 개발 |
|
||||
| 9 | 개발 타임라인(주요 의사결정) | 전체 |
|
||||
| 10 | **특허 관점 종합**(인벤토리·후보·청구포인트·선행기술) | 특허 |
|
||||
| 11 | 검증 결과표 | 전체 |
|
||||
| 12 | 후임자 인계(데이터흐름·튜닝상수·빌드·업그레이드) | 개발 |
|
||||
| 13 | 한계 / 출원 전 확인사항 | 전체 |
|
||||
|
||||
---
|
||||
|
||||
## 1. 시스템 개요 & 아키텍처
|
||||
|
||||
### 1.1 핵심 아이디어
|
||||
|
||||
드론으로 촬영한 철도 선로 주행영상의 각 프레임에는 SRT 메타데이터로 **드론 위치(lat/lon/고도)·자세(yaw/pitch/roll)·초점거리**가 기록된다. 한편 철도 GIS에는 측점·POI·선로 중심선의 **WGS84 좌표(+표고)** 가 있다. 이 둘을 **핀홀 카메라 투영**으로 결합하면:
|
||||
|
||||
1. **순방향 투영** — "이 GIS 지점이 지금 화면 어느 픽셀에 보이는가" → 라벨/중심선/궤적 오버레이.
|
||||
2. **역투영** — "사용자가 화면에서 끈 픽셀은 어느 지리좌표인가" → POI 위치/표고 보정.
|
||||
3. **측점 색인** — 드론 GPS를 측점 폴리라인에 투영해 영상 매 시점을 **측점값(체이니지)** 으로 환산 → 측점 기반 탐색/스크러빙.
|
||||
|
||||
### 1.2 데이터 흐름
|
||||
|
||||
```
|
||||
[폴더 선택: base.csv(드론프레임) + building/측점·교량·터널·역사 CSV + route.json + KMZ]
|
||||
│ geoData.loadFolderGeoData (인코딩 자동감지·병렬 파싱·KMZ 우선)
|
||||
▼
|
||||
[geoStore] frames / stations / pois / structures / centerline / origin / routeMeta
|
||||
│
|
||||
├─▶ StationOverlay (영상 위 AR 오버레이)
|
||||
│ precompute(가시성/겹침, requestIdleCallback) → RAF(연속포즈 재투영) → Canvas
|
||||
│ geoProjection: WGS84→TM→ENU→카메라→핀홀
|
||||
│
|
||||
├─▶ StationBar (하단 측점 스크러버)
|
||||
│ chainage: 드론GPS→측점폴리라인 수직투영→연속 체이니지
|
||||
│ 이동거리/측점축, 방향색, 구조물 마커, 측점검색 순환
|
||||
│
|
||||
└─▶ VideoPlayer (Video.js + hls.js, 이중 재생경로, 프레임 정확도)
|
||||
```
|
||||
|
||||
### 1.3 기술 스택
|
||||
|
||||
- **프론트엔드**: React 18 + TypeScript, Video.js 8.23 + @videojs/http-streaming, hls.js 1.6, Zustand(상태), Vite, Canvas 2D(오버레이), proj4(좌표변환), fflate(KMZ 압축해제).
|
||||
- **백엔드**: Node.js 20 + Express, `child_process.spawn` FFmpeg 래퍼, Range Request 스트리밍, HLS 변환 + SSE 진행률.
|
||||
- **배포**: PM2(`ghiVideo`, 포트 55000)가 `client/dist` 정적 서빙(빌드 결과물). 소스 변경 시 `npm run build -w client`(Node 20) 후 반영.
|
||||
|
||||
---
|
||||
|
||||
## 2. 좌표·투영 정본 파이프라인 (★ 모든 정합의 기반)
|
||||
|
||||
> 본 절을 **정본**으로 하고, 3~7장은 이를 참조한다. Python 레퍼런스 `advanced_tuner_v2.py`와 회전행렬·축정렬·정규화 공식이 1:1 동치로 이식되어 있다([geoProjection.ts:1-12](client/src/utils/geoProjection.ts#L1)).
|
||||
|
||||
### 2.1 5단계 변환
|
||||
|
||||
```
|
||||
[1] WGS84 (lat, lon, EL정표고)
|
||||
│ proj4 EPSG:4326 → EPSG:5186 (한국 중부원점 TM)
|
||||
▼
|
||||
[2] 월드 ENU [East(m), North(m), Up(m)] (East=X, North=Y)
|
||||
│ rel = target_ENU − (drone_ENU + offset)
|
||||
▼
|
||||
[3] 카메라좌표 [Xc, Yc, Zc] (R_w2c = R_align · R_b2w(yaw,pitch,roll)ᵀ)
|
||||
│ 핀홀 + focal/sensor
|
||||
▼
|
||||
[4] 정규 이미지 좌표 [px, py] ∈ (0~1) (0.5 = 영상 중심)
|
||||
│ object-fit:cover 변환
|
||||
▼
|
||||
[5] 화면 px
|
||||
```
|
||||
|
||||
### 2.2 단계별 수식·코드
|
||||
|
||||
**단계 1 — WGS84 → TM** ([geoProjection.ts:75-85](client/src/utils/geoProjection.ts#L75))
|
||||
```ts
|
||||
proj4.defs('EPSG:5186', '+proj=tmerc +lat_0=38 +lon_0=127 +k=1 +x_0=200000 +y_0=600000 +ellps=GRS80 +units=m +no_defs');
|
||||
function latLonToTM(lat, lon) { const [e, n] = _toTM.forward([lon, lat]); return [e, n]; }
|
||||
```
|
||||
|
||||
**단계 2 — TM → ENU + 표고 datum 정합** ([geoProjection.ts:91-97](client/src/utils/geoProjection.ts#L91), [263-271](client/src/utils/geoProjection.ts#L263)). X/Y는 절대 TM, Z만 기준고도 대비 상대(상대벡터를 빼므로 무관). **핵심: GIS 정표고(EL) + 지오이드고(N) → 타원체고**로 변환해 드론 GPS 타원체고와 datum을 맞춘다.
|
||||
```ts
|
||||
const stEnu = geoToEnu(targetLat, targetLon, targetAlt + (params.geoidOffset ?? 0), ...); // EL + N
|
||||
const drEnuAdj = [drEnu[0]+offX, drEnu[1]+offY, drEnu[2]+offZ];
|
||||
const relEnu = [stEnu[0]-drEnuAdj[0], stEnu[1]-drEnuAdj[1], stEnu[2]-drEnuAdj[2]];
|
||||
```
|
||||
> **지오이드고 N**: 대전지역 ≈ **25.8m**(= 84.244 − 16.853 − 41.59, KNGeoid 검증값). 초기 문서엔 25.449m로 기록된 바 있으나 본 명세는 25.8m로 통일(§11). 지역 상수이므로 타 지역 데이터셋은 재튜닝 필요.
|
||||
|
||||
**단계 3 — ENU → 카메라좌표** ([geoProjection.ts:275-295](client/src/utils/geoProjection.ts#L275)). `R_b2w = Rz(−yaw)·Rx(pitch)·Ry(roll)`, `R_w2c = R_align·R_b2wᵀ`, `R_align=[[1,0,0],[0,0,−1],[0,1,0]]`. 행렬을 명시 생성하지 않고 전치(행=열)·부호치환으로 직접 계산:
|
||||
```ts
|
||||
function applyRw2c(b2w, rel) {
|
||||
return {
|
||||
Xc: b2w[0][0]*rel[0] + b2w[1][0]*rel[1] + b2w[2][0]*rel[2], // col0
|
||||
Yc: -(b2w[0][2]*rel[0] + b2w[1][2]*rel[1] + b2w[2][2]*rel[2]), // -col2
|
||||
Zc: b2w[0][1]*rel[0] + b2w[1][1]*rel[1] + b2w[2][1]*rel[2], // col1
|
||||
};
|
||||
}
|
||||
```
|
||||
규약: **Zc>0 = 카메라 정면**(영상 깊이축), Xc=우, Yc=하. Zc≤0은 카메라 뒤 → null.
|
||||
|
||||
**단계 4 — 카메라좌표 → 정규 픽셀(핀홀)** ([geoProjection.ts:126-137](client/src/utils/geoProjection.ts#L126)). 영상 중심 = 0.5, 해상도 독립:
|
||||
```ts
|
||||
pxRaw = (0.5 + cx0) + (Xc/Zc) * (f / sensorW); // 가로
|
||||
pyRaw = (0.5 + cy0) + (Yc/Zc) * (f / sensorH); // 세로
|
||||
```
|
||||
|
||||
**단계 5 — 정규 → 화면 px (object-fit:cover)** ([StationOverlay.tsx:923-933](client/src/components/overlay/StationOverlay.tsx#L923)). 영상 크롭 사각형에 맞춰 정합:
|
||||
```ts
|
||||
const s = Math.max(W / vW, H / vH); // cover: 더 큰 배율(넘침=크롭)
|
||||
dispW = vW * s; dispH = vH * s; offX = (W - dispW)/2; offY = (H - dispH)/2;
|
||||
const vx = (nx) => offX + nx * dispW; const vy = (ny) => offY + ny * dispH;
|
||||
```
|
||||
|
||||
### 2.3 두 투영 경로의 의도적 분리
|
||||
|
||||
- **렌더(정밀)**: [geoProjection.ts](client/src/utils/geoProjection.ts) — proj4 EPSG:5186 TM + 회전행렬 + geoid/포커스 보정. (3~5장)
|
||||
- **검색(경량)**: [geoSearch.ts](client/src/utils/geoSearch.ts) — cos-lat 근사 ENU. "현재 시야 내 POI" 또는 "특정 POI가 보이는 프레임 구간" 산출용.
|
||||
|
||||
ENU 월드원점은 **첫 측점**(없으면 첫 드론프레임, 없으면 중심선 첫 점)([geoSearch.ts:117-128](client/src/utils/geoSearch.ts#L117)).
|
||||
|
||||
---
|
||||
|
||||
## 3. 카메라 투영 & 화각(FOV) 정합
|
||||
|
||||
> 좌표 변환 파이프라인은 §2 참조. 본 절은 화각·내부표정 보정과 역투영을 다룬다.
|
||||
|
||||
### 3.1 focal ↔ FOV 관계
|
||||
|
||||
화각은 focal과 센서 치수로 결정된다(핀홀): `FOV = 2·atan(sensor / (2·focal))` ([StationOverlay.tsx:864](client/src/components/overlay/StationOverlay.tsx#L864)). 기본값 `focal=24mm, sensorW=36mm(풀프레임 환산), sensorH=20.25mm(=36×9/16, 16:9)` ([geoProjection.ts:48-66](client/src/utils/geoProjection.ts#L48)). 이 hFOV는 나침반/미니맵 시야부채꼴에도 사용.
|
||||
|
||||
### 3.2 fovMode — 세로 화각(sensorH) 역산 보정 (★)
|
||||
|
||||
라벨이 멀리/가까이에서 **상하로 어긋날 때**, 사용자가 POI 하나를 영상 속 실제 위치로 드래그하면 그 한 점의 대응으로 세로 화각을 역산한다. **설계 핵심: 가로 focal(f/sensorW)은 건드리지 않고 sensorH만 역산** → 빨간 중심선이 좌우로 틀어지지 않으면서 상하 배율만 교정.
|
||||
|
||||
투영식 `py = 0.5 + cy0 + (Yc/Zc)·(f/sH)` 를 sH에 대해 풀면 ([StationOverlay.tsx:1326-1344](client/src/components/overlay/StationOverlay.tsx#L1326)):
|
||||
```ts
|
||||
const cc = toCameraCoords(drone, drag.lat, drag.lon, poiZ, p, wo);
|
||||
const b = cc.Yc / cc.Zc;
|
||||
const v = pos.y - 0.5 - (p.cy0 ?? 0);
|
||||
const sHNew = (b * p.focalLen) / v; // sH = (Yc/Zc)·f / (py − 0.5 − cy0)
|
||||
setParam('sensorH', Math.max(6, Math.min(36, sHNew)));
|
||||
```
|
||||
영상 세로 중심(v≈0)은 발산 → `|v|<0.03`이면 거부하고 안내.
|
||||
|
||||
### 3.3 핵심 함수
|
||||
|
||||
- **`toCameraCoords`** ([geoProjection.ts:301-317](client/src/utils/geoProjection.ts#L301)) — GIS점→카메라좌표. 부산물 `distH`(수평 직선거리), `fwd/side`(진행방향 앞/옆 성분, 비등방 거리필터용). `pixelFromCamera`와 분리한 이유: 중심선처럼 선분 단위 근거리 Zc 클리핑·보간 후 투영해야 하기 때문.
|
||||
- **`pixelFromCamera`** ([geoProjection.ts:126-137](client/src/utils/geoProjection.ts#L126)) — 카메라좌표→정규픽셀(클램프 없음).
|
||||
- **`groundPointFromPixel`** ([geoProjection.ts:218-251](client/src/utils/geoProjection.ts#L218)) — 화면픽셀→지면 lat/lon. 광선-수평면 교차:
|
||||
```ts
|
||||
const t = relUpTarget / dir[2]; // dir = R_w2cᵀ·((px−0.5−cx0)·sW/f, (py−0.5−cy0)·sH/f, 1)
|
||||
if (t <= 0) return null; // 카메라 뒤/위
|
||||
const E = drEnu[0]+offX + t*dir[0]; const N = drEnu[1]+offY + t*dir[1];
|
||||
const [lon, lat] = _toTM.inverse([E, N]);
|
||||
```
|
||||
- 관련 역투영 2종: `worldFromPixel`(슬랜트거리 유지 lat/lon/z 동시복원, [149-183](client/src/utils/geoProjection.ts#L149)), `solveZForPixelY`(lat/lon 고정, py에 맞는 z만 1차식 역산, [194-211](client/src/utils/geoProjection.ts#L194)). → 발명 C(§7).
|
||||
|
||||
### 3.4 보정값 영속화 (데이터셋별)
|
||||
|
||||
- 카메라/내부표정·표시설정: `ghivideo:calib:{baseName}` (로드 1회, 저장 800ms debounce, [StationOverlay.tsx:473-500](client/src/components/overlay/StationOverlay.tsx#L473)).
|
||||
- POI 위치/표고 override(DEM·드래그): `ghivideo:poiov:{baseName}` (폴더 파일 override 위에 localStorage 병합, [502-524](client/src/components/overlay/StationOverlay.tsx#L502)).
|
||||
|
||||
### 3.5 설계 트레이드오프·한계
|
||||
|
||||
- **렌즈 왜곡 미보정**: 핀홀만 사용(방사/접선 왜곡 계수 없음). 분석 보조 라벨 정합엔 표준 정확도로 충분(가장자리 수 px 오차 가능).
|
||||
- **클라이언트 순수 계산**: proj4만 의존, 서버 왕복 없이 60fps RAF에서 직접 투영.
|
||||
- **focal 분리 보정**: 한 점 대응으로 sensorH 한 축만 역산(가로 정합 보존).
|
||||
- **POI 표고 가정**: 실측 표고 없으면 최근접 선로 지면표고(또는 "드론고도−이격") → §4·드래그·DEM 보정.
|
||||
|
||||
---
|
||||
|
||||
## 4. 측점 표고 정합 (DEM-free 영상정합) — 발명 B
|
||||
|
||||
### 4.1 무엇을 / 왜
|
||||
|
||||
표시할 시설물(POI)은 주소 기반이라 **표고값이 없는** 경우가 대부분이다. 표고가 없으면 부각(수직각) 산출 불가 → 종래는 지면을 평면으로 가정. 그러나 노선은 실제 기복(정표고 41.6m→57.5m)이 있어 평면 가정 시 라벨이 화면 밖/지면 아래로 이탈한다. 실측에서 잘못된 고도(≈0) 사용 시 **부각오차 42.7°**.
|
||||
|
||||
→ 별도 DEM 취득 없이, **노선을 따라 실측된 측점의 정표고**를 지면고도원으로 삼아 정합한다.
|
||||
|
||||
### 4.2 구성(독립항 필수요소)
|
||||
|
||||
1. **측점 표고 수열 보유** — 노선 따라 실측된 복수 측점의 (lat/lon + 정표고 EL). (실데이터: 측점 47개, 41.6→57.5m)
|
||||
2. **최근접 측점 지면고도 채택** — 표고 미상 대상에 가장 가까운 측점의 정표고를 지면고도로. 프레임 진행에 따라 최근접 측점이 바뀌어 **지면고도가 노선 기복을 따라 갱신**.
|
||||
3. **지오이드 datum 정합** — 정표고(EL) + 지오이드고(N) → 타원체고 → 드론 GPS와 동일 기준. `rel_up = (targetZ + N) − droneAlt`.
|
||||
4. **정합 투영** — §2 파이프라인 수행.
|
||||
5. (종속) **대상별 표고 오프셋**(poiZOffset) — 건물높이/지반차 보정, 측점·중심선엔 미적용.
|
||||
|
||||
### 4.3 검증값
|
||||
|
||||
```
|
||||
타원체고 = 정표고(EL) + 지오이드고(N≈25.8m, 대전)
|
||||
# 측점 157K900, frame0, 수평거리 91.3m
|
||||
정표고≈0 → 부각 42.7° ❌
|
||||
정표고 41.5 → 부각 25.1°
|
||||
정표고+N≈67 → 부각 11.0° ✅ (드론 rel_alt 16.85m와 일치)
|
||||
```
|
||||
- **데이터 함정(핵심)**: `building/01)측점.csv`에 표고 컬럼이 둘 — `Z좌표`(로컬 ≈0) / `Z좌표_한국`(EPSG:5186 정표고 ≈41.5m). 기존 코드가 `Z좌표`(≈0)를 읽어 42.7° 오차 발생 → `Z좌표_한국` 우선으로 교정([geoData.ts 측점 파서](client/src/utils/geoData.ts)).
|
||||
- **효과**: 부각오차 42.7°→**11.0°**, 구글어스 DEM 대비 **0.2m 일치**(2160p에서 ~6px). DEM 취득비용 불필요. 공개 DEM(SRTM 30m)보다 측량 측점이 더 정밀.
|
||||
|
||||
---
|
||||
|
||||
## 5. 드론 자세 평활/보간 + 영상 오버레이 렌더링
|
||||
|
||||
> 두 본질 과제: **(a) 자세/GPS 노이즈**(단순평균은 회전 뭉갬→지연), **(b) 렌더 부드러움 vs 계산비용**(가시성 판정은 비싸고 60fps는 매 프레임 재투영 요구).
|
||||
|
||||
### 5.1 에지(회전경계) 보존 적응형 이동평균 — `smoothFrame`
|
||||
|
||||
[StationOverlay.tsx:577-604](client/src/components/overlay/StationOverlay.tsx#L577). 중심 `i`에서 좌우 최대 `halfWin`(기본 60fr≈2초)까지 평균하되, **중심 yaw와 차이가 `YAW_EDGE=8°` 이내인 인접까지만 창 확장**.
|
||||
```ts
|
||||
const YAW_EDGE = 8;
|
||||
const near = (idx) => Math.abs(((frames[idx].yaw - yc + 540) % 360) - 180) <= YAW_EDGE;
|
||||
while (lo > loMin && near(lo - 1)) lo--; // 회전경계에서 멈춤
|
||||
while (hi < hiMax && near(hi + 1)) hi++;
|
||||
// yaw는 원형평균: atan2(Σsin, Σcos)
|
||||
yaw: Math.atan2(sinYaw / n, cosYaw / n) * 180 / Math.PI,
|
||||
```
|
||||
- 직선: 창 풀폭 → 노이즈 강하게 억제. 회전 중: 창 좁아져 **즉시 추종**(지연 없음). 회전 후: **한 프레임씩** 점진 재확장(튐 없음). 과거 '변화율 기반 축소'는 회전 종료 시 한꺼번에 재확장돼 회전 프레임이 다시 섞여 1회 튀던 부작용 → 경계보존으로 교체.
|
||||
|
||||
### 5.2 연속 프레임번호 보간 포즈 — `poseAt`
|
||||
|
||||
[StationOverlay.tsx:670-690](client/src/components/overlay/StationOverlay.tsx#L670). 실수 프레임번호 `estFrame`에 대해 양 이웃의 평활포즈를 선형보간(이산 30fps 격자→60fps 연속). yaw는 **최단각** 보간:
|
||||
```ts
|
||||
let dy = b.yaw - a.yaw; dy = ((dy + 540) % 360) - 180; // 최단각
|
||||
return { ...a, frame: estFrame, lat:L(a.lat,b.lat), ..., yaw: a.yaw + dy*frac };
|
||||
```
|
||||
O(log n) 이진탐색으로 매 RAF 호출 가능.
|
||||
|
||||
### 5.3 속도적응 EMA + 이상치 거부 — `smoothStep` (One-Euro 사상)
|
||||
|
||||
[StationOverlay.tsx:48-61](client/src/components/overlay/StationOverlay.tsx#L48). **느릴 때(떨림)는 강하게 평활, 빠를 때(실제 팬)는 즉시 추종.**
|
||||
```ts
|
||||
if (d > REJECT_DIST && prev.rej < MAX_REJECT_FRAMES) // 0.12 초과 점프 = 이상치 → 위치 유지
|
||||
return { x: prev.x, y: prev.y, rej: prev.rej + 1, ... };
|
||||
const vx = prev.vx + (dx - prev.vx) * SMOOTH_VEL_BETA; // β=0.25 속도평활(떨림 왕복은 상쇄)
|
||||
const a = Math.min(maxAlpha, minAlpha + (maxAlpha - minAlpha) * Math.min(1, speed/speedRef));
|
||||
return { x: prev.x + dx*a, y: prev.y + dy*a, ... };
|
||||
```
|
||||
- 이상치 거부에 **연속거부 한계(MAX_REJECT_FRAMES=8)** → 시크/재등장 같은 진짜 큰 이동에 영구 고착 방지.
|
||||
- 추가로 **픽셀 히스테리시스**(0.75px 이상일 때만 정수좌표 갱신)로 저속 반올림 깜빡임 제거([1048](client/src/components/overlay/StationOverlay.tsx#L1048)).
|
||||
|
||||
### 5.4 라벨 사전계산 + 겹침 억제
|
||||
|
||||
- **`startLabelPrecompute`** ([StationOverlay.tsx:692-746](client/src/components/overlay/StationOverlay.tsx#L692)): 전 프레임의 **가시집합·겹침해소 결과**를 `Map<frameNum, LabelCache>`에 사전계산. **화면좌표가 아니라 월드좌표(lat/lon/z)를 저장** → RAF가 매 프레임 연속포즈로 재투영. `requestIdleCallback` 200프레임 청크, 취소토큰(`precomputeIdRef`), **현재 프레임부터 순환 처리**(재생 즉시 라벨), 첫 청크 즉시 노출, 재계산 시 옛 맵 복사.
|
||||
- **겹침 억제** ([755-784](client/src/components/overlay/StationOverlay.tsx#L755)): 임계 가로 `POI_MERGE_X=0.10`/세로 `0.035`(비등방, 텍스트가 가로로 긴 특성). 우선순위 ①철도역/역사 최우선 ②근접거리. **진행방향 변형 우선**: 상/하행 형제 겹침 시 노선 방향((상)/(하)) 변형을 남김.
|
||||
|
||||
### 5.5 RAF 렌더 루프 & 성능 설계
|
||||
|
||||
매 프레임 **계산 없이 조회+투영+드로잉만**. 라이브 시간은 VideoPlayer `smoothTimeRef`(단조 벽시계 보간, 일시정지·시크·배속 보정)에서 받음.
|
||||
|
||||
| 작업 | 비용 | 배치 | 빈도 |
|
||||
|------|------|------|------|
|
||||
| 가시성/겹침 판정 | 높음 | precompute | 1회(+설정변경 debounce), idle 청크 |
|
||||
| 좌표 투영 | 중간 | RAF | 매 프레임 |
|
||||
| 평활/EMA/히스테리시스 | 낮음 | RAF | 매 프레임 |
|
||||
| 팝업 가시집합 setState | 리렌더 | setInterval 150ms | 변화 시만 |
|
||||
|
||||
**핵심 = "무엇을 그릴지"(precompute) ↔ "어디에 그릴지"(RAF) 분리**로 단일스레드 60fps 달성. 중심선/드론궤적은 `buildLines`로 매 프레임 직접 투영(근거리 Zc 클리핑·outcode 컬링). 나침반은 별도 ±3fr 평활 yaw + 360° 누적 언랩.
|
||||
|
||||
---
|
||||
|
||||
## 6. 측점(체이니지) 시스템 & 스테이션바
|
||||
|
||||
### 6.1 연속 체이니지 수직투영 (핵심 ①)
|
||||
|
||||
드론 한 프레임의 위경도를 **측점 폴리라인에 수직 투영**해 100m 양자화가 아닌 **10m 해상도 연속 측점값**을 얻는다(부산물: 선로 수직 이격 offsetM).
|
||||
- **폴리라인 구축** `buildChainLine` ([chainage.ts:22-32](client/src/utils/chainage.ts#L22)): 측점값 오름차순 정렬, 평면투영 `x=lon·k, y=lat·111000` (k=cos(lat0)·111000).
|
||||
- **점-선분 투영** `projectToChain` ([chainage.ts:35-50](client/src/utils/chainage.ts#L35)):
|
||||
```ts
|
||||
const t = L2===0?0:Math.max(0,Math.min(1, ((px-a.x)*dx + (py-a.y)*dy)/L2));
|
||||
const d = (px-cx)**2 + (py-cy)**2;
|
||||
if (d < bestD) { bestD = d; bestKm = a.km + (b.km-a.km)*t; } // 최근접 선분 위 km 보간
|
||||
```
|
||||
- **왜 연속 투영인가**: 최근접 측점점(100m 양자화)은 값이 계단처럼 튀고 두 측점 중간에서 전진/후진 전환 시점이 어긋난다. 수직투영은 선분 보간으로 매 프레임 부드럽게 변해 **방향 전환점(극값) 타이밍이 실제 주행과 일치**.
|
||||
|
||||
### 6.2 이중 측점값: 배지용 `km` vs 방향용 `chain`
|
||||
|
||||
프레임별 precompute([StationBar.tsx:233-246](client/src/stationbar/StationBar.tsx#L233)): `km`=최근접 측점점(100m, 배지/폴백), `chain`=연속투영(10m, 방향/축/탐지). `videoFps`는 실데이터(마지막 프레임/재생시간)에서 유도, 미비 시 `30000/1001` 폴백.
|
||||
|
||||
### 6.3 축 설계: 시간축이 아닌 위치축 (핵심 ②)
|
||||
|
||||
선로 점검 영상은 **호버(정지비행)** 와 **전·후진 반복** 특성이 있어 시간축이 부적합하다(호버 시 커서가 위치와 어긋남).
|
||||
- **이동거리축** ([StationBar.tsx:250-271](client/src/stationbar/StationBar.tsx#L250)): 연속 측점값을 ±8fr 평활 후 프레임간 **절대 변화량 누적**(`cum += |sm[i]−sm[i−1]|`)을 정규화(`frac`, 단조 비감소). 호버=frac 정체(커서 정지), 이동=frac 증가. 방향은 색 리본으로 직교 분리.
|
||||
- **측점축**(후속 채택안, [history 2026-06-26_1520](docs/history/2026-06-26_1520_스테이션바-측점축-전환.md)): x축을 **측점값 비례**로 두고, 축 범위를 역 POI 첫/끝으로 **노선 고정**(영상 무관 절대 측점) → 어떤 데이터로 재생하든 같은 구조물이 같은 위치/측점에 표시(회차 간 정렬).
|
||||
- 시간↔축 양방향 변환: `cumFracAtTime`/`timeAtFrac`(또는 `chainAtTime`/`pxAtChain`)를 이진탐색+보간으로. 커서·구간색·구조물 마커가 **동일 매핑 공유**. 라이브 커서는 React 리렌더 없이 RAF에서 CSS 변수(`--pos-px`)만 갱신([771-786](client/src/stationbar/StationBar.tsx#L771)).
|
||||
|
||||
### 6.4 방향 판정 & 방향색 트랙
|
||||
|
||||
평활 측점값 추세를 극값추적 + **히스테리시스 HYST=10m**로 방향 전환점 확정([395-458](client/src/stationbar/StationBar.tsx#L395)). `forwardDir = arrChain≥depChain?1:−1`. 정방향=주황 음영, 역방향=청록. 이동거리/측점축은 구간 px가 단조라 `linear-gradient` 1장으로 경량 처리([686-726](client/src/stationbar/StationBar.tsx#L686)).
|
||||
|
||||
### 6.5 구조물 마커 — 좌표/측점 이중 탐지 & `kmExists` (핵심 ③)
|
||||
|
||||
두 통과 탐지기:
|
||||
- **`pxPassesTo(lat,lon,off)`** ([489-534](client/src/stationbar/StationBar.tsx#L489)) — 좌표 근접. 임계 `TH=max(100, gmin·3)`.
|
||||
- **`pxPassesAtMileage(targetM)`** ([537-559](client/src/stationbar/StationBar.tsx#L537)) — 연속 체이니지가 `TH=stationTolerance(기본 20m)` 이내.
|
||||
|
||||
탐지 우선순위(단락평가): ①명시 측점값 ②좌표를 측점선 투영→측점기준(통과순서 정확) ③이름매칭 POI 투영 ④좌표근접 폴백 … ([612-639](client/src/stationbar/StationBar.tsx#L612)).
|
||||
|
||||
**역사 합집합 보강**: 종점/조차장은 측점선 투영이 불안정해 재진입을 놓침(예 대전조차장 162k080). 단락평가 탓에 한 통과를 찾으면 좌표근접이 안 돌아 누락 → **역사에 한해 좌표근접 통과를 합집합으로 합치되 24px 임계 중복제거**([629-634](client/src/stationbar/StationBar.tsx#L629)).
|
||||
|
||||
**`kmExists` (★)**: 좌표 반경만으로 잡힌 통과는 실제 측점값이 그 위치에 없을 수 있다.
|
||||
```ts
|
||||
const kmExists = stationKmVal == null || Math.abs(p.km - stationKmVal) <= existTol; // existTol = stationTolerance
|
||||
```
|
||||
([StationBar.tsx:648](client/src/stationbar/StationBar.tsx#L648)) `false`면 **측점값 라벨·측점검색에서 제외하되 마커(동그라미)는 유지** — "위치 표시"와 "측점 단정"의 분리.
|
||||
|
||||
**종점 미도착**: `endGapPx=min(폭·0.15, (gapM/lenM)·폭)`, `placeUnreachedTerminal`이 최우측 역사를 트랙 끝에 미도착(속 빈 링)으로 고정([303-308](client/src/stationbar/StationBar.tsx#L303), [574-586](client/src/stationbar/StationBar.tsx#L574)).
|
||||
|
||||
### 6.6 측점검색 순환 — `handleJumpToMileage`
|
||||
|
||||
[StationBar.tsx:850-916](client/src/stationbar/StationBar.tsx#L850). 측점값 입력 시 여러 통과를 **통과방향 순환**:
|
||||
1. 후보 **1순위**: 화면 마커(`structureMarks`) 중 `kmExists!==false` 이고 표시 10m값 일치 → px→`timeAtFrac` 역변환. (좌표근접 통과 포함 → 모든 동그라미 도달)
|
||||
2. **2순위**: 마커 없으면 chain 연속구간, 최종 전역 최근접 폴백.
|
||||
3. 정렬 + 0.3s 중복제거 → baseT 기준 다음 통과(끝→처음 순환). `jumpRef`로 연속검색 판정. Enter 후 입력값 유지, blur 시 삭제([PlaybackControls.tsx](client/src/stationbar/components/PlaybackControls/PlaybackControls.tsx)).
|
||||
|
||||
### 6.7 타임라인 보조 기술 ([Timeline.tsx](client/src/stationbar/components/Timeline/Timeline.tsx))
|
||||
|
||||
- **동일항목 브래킷 클러스터**: 동명 다통과를 거리 무관 한 묶음으로(드롭+수평 점선) 중앙 라벨 1개([143-172](client/src/stationbar/components/Timeline/Timeline.tsx#L143)).
|
||||
- **라벨 2줄 균등분할** `splitStructLabel`: CJK 1.0/기타 0.55폭, 괄호그룹은 통째로 아랫줄([30-49](client/src/stationbar/components/Timeline/Timeline.tsx#L30)).
|
||||
- **측점값 라벨 겹침 숨김** `showMileage`: 직전 표시와 `(lastW+w)/2` 미만이면 숨김, `kmExists===false`는 애초 생략([117-137](client/src/stationbar/components/Timeline/Timeline.tsx#L117)).
|
||||
- **아이콘 passed 전환**: upcoming/passed 두 PNG를 opacity로 전환(무깜빡임), 전 상태 사전로드.
|
||||
|
||||
---
|
||||
|
||||
## 7. 위치보정 역투영 (단일 제스처) — 발명 C
|
||||
|
||||
### 7.1 무엇을 / 왜
|
||||
|
||||
지오코딩 POI는 ±수십 m 오차. 2D 화면 이동만으로는 카메라로부터의 거리(깊이)를 결정 못함(깊이 모호성). 종래는 수평/높이를 따로 조정하거나 깊이맵(LiDAR) 요구. → **단일 드래그로 수평·수직(3D) 동시 보정, 깊이맵 불요.**
|
||||
|
||||
### 7.2 구성(독립항 필수요소)
|
||||
|
||||
1. **슬랜트거리 보존** — 드래그 개시 시점의 카메라–객체 슬랜트거리(range)를 고정 → 깊이 모호성 해소 핵심 구속.
|
||||
2. **역투영** — 드롭 픽셀을 카메라 방향벡터로 환산 후 보존 거리만큼 투사.
|
||||
3. **월드좌표 복원** — 자세 회전·평행이동 역변환으로 lat/lon/z 복원(지오이드 역환원). 한 번의 드래그로 수평·수직 동시.
|
||||
4. **영속·재적용** — 복원 좌표를 객체 식별자(title)에 연계해 `<base>_poi_overrides.json` + `ghivideo:poiov:<baseName>` 저장.
|
||||
5. (종속) **가시영역 한정** — 전방(Zc>0)만 보정.
|
||||
|
||||
```ts
|
||||
dir_cam ∝ ((px−0.5−cx0)·sW/f, (py−0.5−cy0)·sH/f, 1)
|
||||
cam = dir_cam/|dir_cam| · range // ★거리 보존(드래그 시작값)
|
||||
rel = R_w2cᵀ · cam // 회전 역변환
|
||||
z = droneAlt + offZ + rel.up − N_geoid // 정표고 복원
|
||||
(lat,lon) = TM⁻¹(droneE+offX+rel.x, droneN+offY+rel.y)
|
||||
```
|
||||
**변형 — z 전용 역산** `solveZForPixelY`: lat/lon 고정, 드롭 세로위치로 z만 1차식 역산. 왕복검증 z=42→py→42.000.
|
||||
|
||||
### 7.3 효과
|
||||
|
||||
2D 드래그 1회로 3D 보정(단계 절반), 역투영 왕복오차 ≈0, 깊이맵 불요, 파일+localStorage 재현성. **"depth map 없이 거리보존"** 을 명시해 US9188444B2와 구별(§10).
|
||||
|
||||
---
|
||||
|
||||
## 8. 백엔드 스트리밍/FFmpeg · 데이터 파이프라인 · 상태관리 · 플레이어
|
||||
|
||||
### 8.1 대용량(2GB+) 재생 — 이중 경로
|
||||
|
||||
- **로컬**([useVideoPlayer.ts:80-88](client/src/hooks/useVideoPlayer.ts#L80)): `URL.createObjectURL` Blob URL 직접 재생(업로드/변환 없음), 교체 시 `revokeObjectURL`(누수 방지). **현 운영 주 경로.**
|
||||
- **서버**: Range Request 즉시 재생, 비표준 코덱(HEVC 등)이면 HLS 자동 전환([15-17](client/src/hooks/useVideoPlayer.ts#L15)). (서버/HLS 경로는 보존되나 UI는 `SHOW_TOOLBAR=false`로 숨김.)
|
||||
|
||||
### 8.2 Range Request ([streaming.ts:52-77](server/src/services/streaming.ts#L52))
|
||||
|
||||
핵심: 한 응답에 전체를 싣지 않음. 끝값 없으면 `start+10MB−1`. `pipeline()`(백프레셔/에러 자동), `highWaterMark:1MB`, `Accept-Ranges:bytes`, path traversal 방어(`resolveVideoPath`). JS Number(2^53−1)로 4GB+ 안전(BigInt 불요). 멀티레인지 미지원.
|
||||
|
||||
### 8.3 HLS 변환 + SSE ([hls.ts](server/src/routes/hls.ts))
|
||||
|
||||
멱등(디스크에 `index.m3u8` 있으면 즉시 done), 코덱 분기(H.264 `-c copy` 수 초 / 그 외 libx264), `/progress` SSE로 stderr `time=` 파싱 푸시. hls.js 필수: `backBufferLength:30`(기본 Infinity면 장시간 재생 메모리 누수→크래시), maxBufferLength 30, enableWorker([useVideoPlayer.ts:7-13](client/src/hooks/useVideoPlayer.ts#L7)).
|
||||
|
||||
### 8.4 FFmpeg spawn 래퍼 ([ffmpeg.ts](server/src/services/ffmpeg.ts))
|
||||
|
||||
`-y` auto, stderr `time=HH:MM:SS.cc` 정규식 진행률, exit≠0 시 stderr 끝 500자 reject. 프레임 추출은 `-accurate_seek -ss {t} -i {f} -frames:v 1`(플래그 순서 = 입력단 정확 시킹, [frame.ts:37-44](server/src/routes/frame.ts#L37)).
|
||||
|
||||
### 8.5 데이터 파이프라인 ([geoData.ts](client/src/utils/geoData.ts))
|
||||
|
||||
`<input webkitdirectory>`/드래그로 받은 `File[]`을 병렬 파싱(`Promise.all`). **인코딩 자동감지**(BOM→UTF-8/EUC-KR), **헤더명 우선+위치 폴백**(`makeFieldIndexer`), 좌표 유효성 게이트(lat 33~39/lon 124~132), KMZ 우선(있으면 CSV POI/구조물 미사용, fflate unzipSync).
|
||||
|
||||
| 소스 | 파서 | 대상 | 매핑 |
|
||||
|------|------|------|------|
|
||||
| `<base>.csv` | parseDroneFrames | DroneFrame[] | frame_cnt, lat/lon/alt, yaw/pitch/roll, focal |
|
||||
| `building/01)측점.csv` | parseStations | station + DirectionChange | 측점→title, **Z좌표_한국(정표고) 우선**, 비고→방향전환 |
|
||||
| `<base>_POI.csv` / `02)지장물.csv` | parsePois | poi | category_clean/명칭, lat/lon |
|
||||
| `02)지장물_역사.csv` | parseHistoricStations | 역사 구조물 | 명칭→name(스테이션바 마커) |
|
||||
| `03)교량/04)터널/06)구교.csv` | parseStructures | bridge/tunnel | 구분/시설물명, 연장→lengthM, 시설종별→grade |
|
||||
| `05)출입문번호.csv` | parseAccessDoors | 출입문 poi | 출입문번호→title |
|
||||
| `*.kmz/kml` | parseKmz | poi+구조물 | Placemark + description표→props |
|
||||
| `route.json` | mergeStructures | 보정 | station/offset/이정 override |
|
||||
|
||||
중심선은 v2.0에 별도 파일이 없어 측점을 측점값 순 정렬한 폴리라인으로 합성.
|
||||
|
||||
### 8.6 상태관리
|
||||
|
||||
- **geoStore**(메모리, persist 없음): frames/stations/pois/structures/centerline/origin/routeMeta. `basePois`(원본) vs `pois`(override 적용) 분리로 원본 불변성([geoStore.ts:96-110](client/src/store/geoStore.ts#L96)).
|
||||
- **settingsStore**(localStorage `ghivideo.settings`): 시설종별 필터, 표시 토글들. 커스텀 `merge`로 스키마 진화 대비. `isGradeVisible` 공유 규칙.
|
||||
|
||||
### 8.7 플레이어 통합 & 프레임 정확도
|
||||
|
||||
- Video.js `data-vjs-player` + Strict Mode 가드, `controls:false`(측점바로 대체), `fill:true`.
|
||||
- **FPS 감지**([useFrameStep.ts:21-49](client/src/hooks/useFrameStep.ts#L21)): `requestVideoFrameCallback`, 첫 콜백은 시작점만 기록(31fps 오감지 방지), 연속 2회 일치 확정. 스텝은 `1/fps`(누적 금지=드리프트 방지).
|
||||
- **이원 fps**: 스텝 이동=실측 fps, 프레임번호 표시=29.97 고정(SRT 기준). 의도된 분리.
|
||||
- **매끄러운 커서**: rAF 단조 벽시계 보간 `smoothTimeRef`(매 프레임, transform) + `smoothTime`(throttle, 배지) 분리.
|
||||
|
||||
---
|
||||
|
||||
## 9. 개발 타임라인 (주요 기술 의사결정)
|
||||
|
||||
| 시기 | 핵심 결정/구현 |
|
||||
|------|----------------|
|
||||
| 2026-04-01 | labelMap 사전계산 + requestIdleCallback + RAF, ±10fr 이동평균(yaw sin/cos), 30→60fps 보간 |
|
||||
| 2026-04-02 | **투영 파이프라인 정립**: EPSG:5186 TM, 핀홀(Rz·Rx·Ry), 타원체고=정표고+지오이드, Cohen-Sutherland 클리핑 |
|
||||
| 2026-06-19 | **v1→v2 데이터 형상 전환**(실측 폴더), 인코딩 자동감지, 측점 비고→방향전환 추출, 시설종별 필터 |
|
||||
| 2026-06-22 | **측점 표고 컬럼 교정(Z좌표_한국) → 부각 42.7°→11°**(발명 B). 역투영 드래그 보정+override(발명 C 최초). title-키 보간+이상치 거부. 비등방 거리필터 |
|
||||
| 2026-06-23 | 에지보존 평활(YAW_EDGE=8°). **글로벌+KIPRIS 선행기술 조사** → B·C 신규성 확인 |
|
||||
| 2026-06-24 | **1클릭 FOV 보정(→sensorH 역산)**, object-fit:cover 정렬, 라벨 월드좌표 저장+RAF 재투영, One-Euro EMA, KMZ 직접 로딩 |
|
||||
| 2026-06-25~26 | 종점역 누락 수정, 양끝 역 고정, 라벨 2줄분할, **동일항목 브래킷**, **거리축→측점축 전환** |
|
||||
| 2026-06-29 | **좌표/측점 이중탐지+합집합 보강**, 측점값 라벨 겹침숨김, **측점검색 순환+마커기준 전환+kmExists**, UI 통합(토글 재생바 이동, 활성색 amber), 보고서 3종 생성, 55000 외부접속 |
|
||||
|
||||
상세 근거: [docs/history/2026-06-22_POI투영오차-개선.md](docs/history/2026-06-22_POI투영오차-개선.md)(발명 B·C 1차 출처), [2026-06-26_1520_측점축-전환](docs/history/2026-06-26_1520_스테이션바-측점축-전환.md), [2026-06-29_1029_역사-통과-좌표근접-보강](docs/history/2026-06-29_1029_역사-통과-좌표근접-보강.md) 등.
|
||||
|
||||
---
|
||||
|
||||
## 10. 특허 관점 종합
|
||||
|
||||
> 출처: [docs/특허_스테이션기반_주행영상_플레이어.html](docs/특허_스테이션기반_주행영상_플레이어.html)(기술발굴), [docs/발명신고서_측점표고정합_역투영보정.html](docs/발명신고서_측점표고정합_역투영보정.html)(변리사 제출용).
|
||||
|
||||
### 10.1 기술요소 인벤토리 (E1~E9)
|
||||
|
||||
| 코드 | 요소 | 특허 적합성 |
|
||||
|------|------|------------|
|
||||
| E1 | 측점(체이니지) 기반 영상 색인·탐색 | 후보 A(좁게) |
|
||||
| E2 | 지리객체 실시간 영상 정합(labelMap 사전계산) | 공지(큰 틀) |
|
||||
| E3 | **측점 표고 기반 DEM-free 지면고도 추정** | **후보 B(상)** |
|
||||
| E4 | **역투영+슬랜트거리 보존 단일드래그 위치보정** | **후보 C(상)** |
|
||||
| E5 | 비등방 거리필터+소실점 클러터 억제 | 후보 D(종속) |
|
||||
| E6 | 노이즈 강건 오버레이(이중평활·title짝·이상치거부) | 후보 E(종속) |
|
||||
| E7 | 측점 폴리라인→중심선 | 제외(통상 처리) |
|
||||
| E8 | 이종 CSV 융합(인코딩 자동감지) | 제외 |
|
||||
| E9 | 비고 텍스트 방향전환 추출 | 제외 |
|
||||
|
||||
### 10.2 출원 권고 우선순위
|
||||
|
||||
- **1순위: 발명 B + C 결합** — 신규성/진보성 우수, 글로벌+한국 신규성 확인.
|
||||
- **2순위: 후보 A(좁게)** — LRS 비선형 재좌표화 + 회차간 위치앵커 + 다중회차 동기. 한국 선행기술(KR102268318B1·KR100411587B1) 위험 → 좁게 청구. **본 명세의 측점축 노선좌표 고정(§6.3)·측점검색 순환(§6.6)·이중탐지 kmExists(§6.5)가 A의 구현 근거.**
|
||||
- **3순위: D·E 종속항 흡수**. **제외: E7·E8·E9**.
|
||||
|
||||
### 10.3 청구 포인트 핵심 (B·C)
|
||||
|
||||
- **발명 B 독립항**: ①측점 실측 정표고 수열 → ②표고미상 대상에 최근접 측점 정표고를 지면고도 채택(노선기복 반영) → ③지오이드고 가산으로 타원체고 변환·datum 정합 → ④정합 투영. **※ ②+③ 결합을 한 청구항에**(개별요소는 공지).
|
||||
- **발명 C 독립항**: ①드래그 개시 시 슬랜트거리 보존 → ②드롭픽셀 역투영 → ③단일 제스처로 수평·수직 동시 복원 → ④식별자 연계 영속. **※ "depth map 없이 거리보존"** 명시(US9188444B2 구별).
|
||||
|
||||
### 10.4 선행기술 (조사 완료분)
|
||||
|
||||
- E1·E2 큰 틀은 공지: Esri ArcGIS Pro FMV, US9996976B2, Mandli Roadview, Remote GeoSystems LineVision, ENSCO Virtual Track Walk.
|
||||
- B·C는 글로벌+한국 신규성 확인. **단, KIPRIS 전문(專門)검색 미수행 — 출원 전 필수.**
|
||||
|
||||
### 10.5 신규성 후보 종합 (구현 기반)
|
||||
|
||||
1. 드론 SRT pose 기반 **실시간 GIS-영상 정합 파이프라인**(타원체고/정표고 datum 자동 정합 포함).
|
||||
2. **단일 대응점 sensorH 역산** 인터랙티브 FOV 보정(가로 정합 보존, 중심부 발산 거부).
|
||||
3. **깊이 모호성 분리형 역투영 3종**(슬랜트유지/지면교차/표고 1차역산)을 보정 의도별 선택.
|
||||
4. **에지(회전경계) 보존 적응형 평활** + 속도적응 EMA(연속거부 한계).
|
||||
5. **월드좌표 precompute + 연속포즈 재투영 2단 분리**로 단일스레드 60fps AR.
|
||||
6. **누적 이동거리/측점 비례 축 + 방향색 직교 분리** 진행바, 노선좌표 고정(회차 정렬).
|
||||
7. **좌표/측점 이중 탐지 + 합집합 보강 + kmExists 신뢰도 플래그**("위치 표시"와 "측점 단정" 분리).
|
||||
|
||||
---
|
||||
|
||||
## 11. 검증 결과표
|
||||
|
||||
| 항목 | 개선 전 | 개선 후 | 근거 |
|
||||
|------|---------|---------|------|
|
||||
| 부각 오차(측점 157K900, 91.3m) | 42.7° | **11.0°** | §4.3 |
|
||||
| 지면고도 정확도(측점 vs 구글 DEM) | — | **0.2m(~6px@2160p)** | §4.3 |
|
||||
| 역투영 왕복오차(z=42→py→z) | — | **≈0 (42.000)** | §7.2 |
|
||||
| 화면 EMA 지연 | 1.65s(α=0.01) | ~25ms(α=0.4) | §5.3 / history 06-22 |
|
||||
| 렌더 | 끊김 | **60fps**(precompute+RAF 분리) | §5.5 |
|
||||
| 역 재진입 마커 | 누락 | 좌표근접 합집합 보강으로 표출 | §6.5 |
|
||||
|
||||
지오이드고 N: 본 명세 **25.8m**(KNGeoid 검증) 통일. 초기 문서값 25.449m는 각주 처리.
|
||||
|
||||
---
|
||||
|
||||
## 12. 후임자 인계
|
||||
|
||||
### 12.1 데이터 흐름 한눈에
|
||||
|
||||
geoStore(frames/stations/structures/routeMeta) → `useEffect` precompute(`viewedRef`=프레임별 km/chain/time, `chainRef`=time/frac/chain) → `pxAtTime`/`pxAtChain`·`barSegments`·`structureMarks` → Timeline 렌더. **모든 px는 단일 매핑(`pxAtTime`/`pxAtChain`)을 통과**, 역변환은 `timeAtFrac`/`chainAtTime`만 사용.
|
||||
|
||||
### 12.2 튜닝 포인트
|
||||
|
||||
- **폴더 route.json**: `stationTolerance`(통과/검색 허용오차, 20m), `direction`/`directionChanges`(방향 변형 필터), `endStationGapMeters`/`lengthKm`(미도착 폭), `startStationName`/`endStationName`.
|
||||
- **카메라(패널, 데이터셋별 저장)**: `focalLen`, `sensorW`, `sensorH`(FOV), `yawOffset`/`pitch`/`roll`, `geoidOffset`(지역 N), `poiZOffset`.
|
||||
- **코드 상수**: 평활 `W=8`, `YAW_EDGE=8°`, `HYST=10m`, 좌표통과 `TH=max(100,gmin·3)`, 역사 합집합 `24px`, 검색 중복 `0.3s`, EMA `β=0.25`/`REJECT_DIST=0.12`/`MAX_REJECT_FRAMES=8`.
|
||||
|
||||
### 12.3 빌드/배포 (★ 필수 숙지)
|
||||
|
||||
55000은 **PM2 `ghiVideo`가 `client/dist`(빌드 결과)를 정적 서빙**한다. 소스만 고치면 화면에 반영 안 됨.
|
||||
```bash
|
||||
export PATH="$HOME/.nvm/versions/node/v20.20.2/bin:$PATH" # 시스템 node v12 → tsc/vite 실패
|
||||
npm run build -w client # = tsc && vite build → client/dist 갱신
|
||||
# 브라우저 Ctrl+Shift+R, 폴더 데이터는 런타임 로드라 폴더 재선택 필요
|
||||
```
|
||||
외부(LAN) 접속: netsh portproxy(Windows :55000 → WSL IP:55000) + 방화벽. WSL IP는 재부팅 시 변동.
|
||||
|
||||
### 12.4 업그레이드 포인트
|
||||
|
||||
1. 렌즈 왜곡(Brown-Conrady) 추가 → 가장자리 정합.
|
||||
2. 다점 대응 자동 캘리브레이션(focal·주점·왜곡 번들조정).
|
||||
3. 지오이드 격자(KNGeoid18) 보간으로 `geoidOffset` 자동화.
|
||||
4. POI 표고 DEM 자동조회(현 부분지원).
|
||||
5. 중심선 per-frame 투영 공간색인, precompute Web Worker화.
|
||||
6. frame.ts fps 고정(30)→`getVideoFps`, HLS jobs DB 큐, 멀티 Range 지원.
|
||||
7. **코드 통합**: 측점 파서/포매터(chainage.ts ↔ StationBar.tsx), 투영함수(`projectToChain` ↔ `projectChainage`) 단일화.
|
||||
|
||||
---
|
||||
|
||||
## 13. 한계 / 출원 전 확인사항
|
||||
|
||||
- **KIPRIS 전문검색 미수행** — 출원 전 필수.
|
||||
- 지오코딩 수평오차는 데이터 한계(코드 보정은 드래그 수동).
|
||||
- `geoidOffset` 지역 상수(대전 25.8m) — 타 지역 재튜닝.
|
||||
- VFR/abs_alt 노이즈, 먼 거리 역투영 민감.
|
||||
- 미확정 항목(특허문서 `[확인 필요]` 유지): 다중회차 동기화 구현 범위, 위치앵커 주석 데이터모델, 권리귀속, 공개이력.
|
||||
- 렌즈 왜곡 미보정(핀홀만) — 가장자리 정확도 트레이드오프.
|
||||
|
||||
---
|
||||
|
||||
*본 문서는 5개 서브시스템(카메라 투영/자세 평활/측점·스테이션바/백엔드·데이터/기존 특허문서) 정밀 조사 결과를 종합한 것이다. 각 소스 참조는 클릭 가능한 파일:라인으로 추적 가능하며, 특허 출원 시 본 문서의 §3~§7·§10과 history 원문을 함께 변리사에게 제공할 것을 권장한다.*
|
||||
Binary file not shown.
@@ -0,0 +1,158 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" lang xml:lang>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="generator" content="pandoc" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes" />
|
||||
<title>보고서_2026-06-29_스테이션바_UI개선</title>
|
||||
<style>
|
||||
code{white-space: pre-wrap;}
|
||||
span.smallcaps{font-variant: small-caps;}
|
||||
span.underline{text-decoration: underline;}
|
||||
div.column{display: inline-block; vertical-align: top; width: 50%;}
|
||||
div.hanging-indent{margin-left: 1.5em; text-indent: -1.5em;}
|
||||
ul.task-list{list-style: none;}
|
||||
.display.math{display: block; text-align: center; margin: 0.5rem auto;}
|
||||
</style>
|
||||
<style type="text/css">@page {
|
||||
size: A4;
|
||||
margin: 18mm 16mm 16mm 16mm;
|
||||
@bottom-center {
|
||||
content: counter(page) " / " counter(pages);
|
||||
font-family: "Malgun Gothic", sans-serif;
|
||||
font-size: 9pt;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
html { font-size: 11pt; }
|
||||
body {
|
||||
font-family: "Malgun Gothic", "Hancom Gothic", sans-serif;
|
||||
color: #23272e;
|
||||
line-height: 1.65;
|
||||
max-width: 920px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.7rem;
|
||||
color: #b45309;
|
||||
border-bottom: 3px solid #f59e0b;
|
||||
padding-bottom: 8px;
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
h2 {
|
||||
font-size: 1.25rem;
|
||||
color: #b45309;
|
||||
border-bottom: 1px solid #e5d3b3;
|
||||
padding-bottom: 5px;
|
||||
margin-top: 1.6em;
|
||||
}
|
||||
h3 { font-size: 1.05rem; color: #92400e; margin-top: 1.1em; }
|
||||
a { color: #b45309; }
|
||||
hr { border: none; border-top: 1px solid #e2e2e2; margin: 1.6em 0; }
|
||||
ul { padding-left: 1.25em; }
|
||||
li { margin: 0.18em 0; }
|
||||
strong { color: #1f2937; }
|
||||
code {
|
||||
font-family: "D2Coding", Consolas, monospace;
|
||||
background: #f4f1ea;
|
||||
border: 1px solid #e7e0d2;
|
||||
border-radius: 3px;
|
||||
padding: 0.5px 5px;
|
||||
font-size: 0.92em;
|
||||
}
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 0.8em 0;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
th, td { border: 1px solid #d8d2c4; padding: 6px 10px; text-align: left; vertical-align: top; }
|
||||
th { background: #fdf3df; color: #7c2d12; }
|
||||
blockquote {
|
||||
border-left: 4px solid #f59e0b;
|
||||
margin: 0.8em 0;
|
||||
padding: 0.2em 0 0.2em 14px;
|
||||
color: #555;
|
||||
background: #fffbf2;
|
||||
}
|
||||
|
||||
h1, h2, h3 { break-after: avoid; }
|
||||
</style>
|
||||
<!--[if lt IE 9]>
|
||||
<script src="//cdnjs.cloudflare.com/ajax/libs/html5shiv/3.7.3/html5shiv-printshiv.min.js"></script>
|
||||
<![endif]-->
|
||||
</head>
|
||||
<body>
|
||||
<header id="title-block-header">
|
||||
<h1 class="title">보고서_2026-06-29_스테이션바_UI개선</h1>
|
||||
</header>
|
||||
<h1 id="작업-보고서--주행영상-플레이어-스테이션바ui-개선">작업 보고서 — 주행영상 플레이어 스테이션바/UI 개선</h1>
|
||||
<ul>
|
||||
<li><strong>작성일</strong>: 2026-06-29</li>
|
||||
<li><strong>대상</strong>: 스테이션(측점) 기반 주행영상 플레이어 (GhiVideo)</li>
|
||||
<li><strong>범위</strong>: 하단 스테이션바 마커 정확도 개선, 영상 컨트롤 UI 재구성, 표시 일관성 정리</li>
|
||||
</ul>
|
||||
<hr />
|
||||
<h2 id="1-개요">1. 개요</h2>
|
||||
<p>하단 스테이션바의 <strong>측점 마커 표시 정확도 문제 2건을 수정</strong>하고, 영상 화면의 <strong>표시 옵션 컨트롤을 재생바로 통합·정리</strong>하여 조작 동선을 단순화했습니다. 또한 활성 상태 색상을 통일해 UI 일관성을 높였습니다.</p>
|
||||
<hr />
|
||||
<h2 id="2-버그-수정-정확도">2. 버그 수정 (정확도)</h2>
|
||||
<h3 id="2-1-역驛-재진입-시-마커-누락-수정-">2-1. 역(驛) 재진입 시 마커 누락 수정 ★</h3>
|
||||
<ul>
|
||||
<li><strong>현상</strong>: 대전조차장처럼 드론이 같은 역을 두 번 지날 때, 한쪽 통과(예: 측점 162k080)에 역 마커(동그라미)가 표시되지 않음.</li>
|
||||
<li><strong>원인</strong>: 역 마커를 '측점선 투영값' 한 가지 방식으로만 탐지 → 종점/조차장처럼 선형이 복잡한 구역에서는 투영 오차로 일부 통과가 인정 범위(±20m) 밖으로 벗어나 누락.</li>
|
||||
<li><strong>조치</strong>: 측점 기준 탐지에 <strong>좌표 근접 탐지를 합집합으로 보강</strong>(중복은 자동 제거). 누락되던 재진입 통과까지 마커가 정상 표출.</li>
|
||||
<li><strong>효과</strong>: 동일 역 다중 통과 구간의 마커 신뢰도 향상.</li>
|
||||
</ul>
|
||||
<h3 id="2-2-측점값-라벨-겹침-정리">2-2. 측점값 라벨 겹침 정리</h3>
|
||||
<ul>
|
||||
<li><strong>현상</strong>: 근접 통과 시 측점값(예: 157k990) 텍스트가 서로 겹쳐 판독 불가.</li>
|
||||
<li><strong>조치</strong>: 라벨이 시각적으로 겹치면 <strong>먼저 나온(좌측) 값만 표시하고 뒤엣값은 숨김</strong>(아이콘·연결 점선은 유지).</li>
|
||||
<li><strong>효과</strong>: 측점값 가독성 확보.</li>
|
||||
</ul>
|
||||
<hr />
|
||||
<h2 id="3-uiux-개선-영상-컨트롤-재구성">3. UI/UX 개선 (영상 컨트롤 재구성)</h2>
|
||||
<p>기존에 '화면표시 옵션' 창에 흩어져 있던 표시 토글을 <strong>하단 재생바로 통합</strong>하여, 영상을 보면서 바로 켜고 끌 수 있도록 동선을 개선했습니다.</p>
|
||||
<h3 id="3-1-표시-토글을-재생바로-이동">3-1. 표시 토글을 재생바로 이동</h3>
|
||||
<ul>
|
||||
<li><strong>선형(중심선)</strong>, <strong>드론 궤적</strong>, <strong>좌측 노선 패널</strong> 토글을 재생바 버튼으로 이동.</li>
|
||||
<li>기존 '화면표시 옵션' 창의 중복 버튼은 제거(기능은 동일, 단일 위치로 일원화).</li>
|
||||
<li>상태를 설정 저장소로 통합 → 어디서 켜도 즉시 반영되고 <strong>새로고침해도 유지</strong>.</li>
|
||||
</ul>
|
||||
<h3 id="3-2-항상-표시-항목-정리">3-2. 항상 표시 항목 정리</h3>
|
||||
<ul>
|
||||
<li><strong>드론 GPS·절대고도 정보</strong>: ON/OFF 버튼 제거하고 <strong>상시 표시</strong>로 변경(요청사항).</li>
|
||||
<li><strong>경로 표고 / 투명도</strong> 슬라이더: 드론 궤적 ON/OFF와 무관하게 <strong>항상 노출</strong>.</li>
|
||||
</ul>
|
||||
<h3 id="3-3-재생바-요소-배치-정리">3-3. 재생바 요소 배치 정리</h3>
|
||||
<ul>
|
||||
<li>최종 순서: <strong>좌측 패널 → 선형 → 드론궤적 → 드론 위치정보(GPS·고도) → 시설등급</strong></li>
|
||||
<li>드론 궤적 버튼과 드론 위치정보를 인접 배치, 시설등급 옵션을 그 우측에 배치.</li>
|
||||
<li>잘 쓰지 않는 'POI 겹침제외' 버튼은 숨김(기능/상태는 보존, 기본 ON).</li>
|
||||
</ul>
|
||||
<h3 id="3-4-활성-상태-색상-통일">3-4. 활성 상태 색상 통일</h3>
|
||||
<ul>
|
||||
<li>제각각이던 활성(ON) 색(파랑/초록/빨강/하늘색)을 <strong>앰버(겨자·오렌지 계열)로 통일</strong> → UI 일관성 향상.</li>
|
||||
</ul>
|
||||
<hr />
|
||||
<h2 id="4-운영-메모-배포-방식">4. 운영 메모 (배포 방식)</h2>
|
||||
<ul>
|
||||
<li>사용자 화면(포트 55000)은 <strong>빌드 결과물을 서빙</strong>하는 구조로, 소스 수정 후 <strong>클라이언트 빌드 → 새로고침</strong>해야 반영됩니다. (이번 작업 중 빌드 누락으로 변경이 보이지 않던 이슈를 확인·정리)</li>
|
||||
<li>데이터(드론 프레임/POI)는 폴더 선택으로 로드되므로, 새로고침 시 <strong>폴더 재선택</strong> 필요.</li>
|
||||
</ul>
|
||||
<hr />
|
||||
<h2 id="5-검증">5. 검증</h2>
|
||||
<ul>
|
||||
<li>모든 변경 TypeScript 타입검사 + 프로덕션 빌드 통과.</li>
|
||||
<li>역 재진입 마커 표출은 실제 화면(55000)에서 표시 확인 완료.</li>
|
||||
</ul>
|
||||
<hr />
|
||||
<h2 id="6-향후-참고선택-개선안">6. 향후 참고(선택 개선안)</h2>
|
||||
<ul>
|
||||
<li>측점값 라벨 폭 추정 상수는 폰트 변경 시 재조정 필요.</li>
|
||||
<li>활성 색조(앰버 명도)는 가독성에 따라 미세 조정 가능.</li>
|
||||
<li>'POI 겹침제외'는 현재 숨김 상태이므로, 재노출이 필요하면 토글 복원 가능.</li>
|
||||
</ul>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,71 @@
|
||||
# 작업 보고서 — 주행영상 플레이어 스테이션바/UI 개선
|
||||
|
||||
- **작성일**: 2026-06-29
|
||||
- **대상**: 스테이션(측점) 기반 주행영상 플레이어 (GhiVideo)
|
||||
- **범위**: 하단 스테이션바 마커 정확도 개선, 영상 컨트롤 UI 재구성, 표시 일관성 정리
|
||||
|
||||
---
|
||||
|
||||
## 1. 개요
|
||||
|
||||
하단 스테이션바의 **측점 마커 표시 정확도 문제 2건을 수정**하고, 영상 화면의 **표시 옵션 컨트롤을 재생바로 통합·정리**하여 조작 동선을 단순화했습니다. 또한 활성 상태 색상을 통일해 UI 일관성을 높였습니다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 버그 수정 (정확도)
|
||||
|
||||
### 2-1. 역(驛) 재진입 시 마커 누락 수정 ★
|
||||
- **현상**: 대전조차장처럼 드론이 같은 역을 두 번 지날 때, 한쪽 통과(예: 측점 162k080)에 역 마커(동그라미)가 표시되지 않음.
|
||||
- **원인**: 역 마커를 '측점선 투영값' 한 가지 방식으로만 탐지 → 종점/조차장처럼 선형이 복잡한 구역에서는 투영 오차로 일부 통과가 인정 범위(±20m) 밖으로 벗어나 누락.
|
||||
- **조치**: 측점 기준 탐지에 **좌표 근접 탐지를 합집합으로 보강**(중복은 자동 제거). 누락되던 재진입 통과까지 마커가 정상 표출.
|
||||
- **효과**: 동일 역 다중 통과 구간의 마커 신뢰도 향상.
|
||||
|
||||
### 2-2. 측점값 라벨 겹침 정리
|
||||
- **현상**: 근접 통과 시 측점값(예: 157k990) 텍스트가 서로 겹쳐 판독 불가.
|
||||
- **조치**: 라벨이 시각적으로 겹치면 **먼저 나온(좌측) 값만 표시하고 뒤엣값은 숨김**(아이콘·연결 점선은 유지).
|
||||
- **효과**: 측점값 가독성 확보.
|
||||
|
||||
---
|
||||
|
||||
## 3. UI/UX 개선 (영상 컨트롤 재구성)
|
||||
|
||||
기존에 '화면표시 옵션' 창에 흩어져 있던 표시 토글을 **하단 재생바로 통합**하여, 영상을 보면서 바로 켜고 끌 수 있도록 동선을 개선했습니다.
|
||||
|
||||
### 3-1. 표시 토글을 재생바로 이동
|
||||
- **선형(중심선)**, **드론 궤적**, **좌측 노선 패널** 토글을 재생바 버튼으로 이동.
|
||||
- 기존 '화면표시 옵션' 창의 중복 버튼은 제거(기능은 동일, 단일 위치로 일원화).
|
||||
- 상태를 설정 저장소로 통합 → 어디서 켜도 즉시 반영되고 **새로고침해도 유지**.
|
||||
|
||||
### 3-2. 항상 표시 항목 정리
|
||||
- **드론 GPS·절대고도 정보**: ON/OFF 버튼 제거하고 **상시 표시**로 변경(요청사항).
|
||||
- **경로 표고 / 투명도** 슬라이더: 드론 궤적 ON/OFF와 무관하게 **항상 노출**.
|
||||
|
||||
### 3-3. 재생바 요소 배치 정리
|
||||
- 최종 순서: **좌측 패널 → 선형 → 드론궤적 → 드론 위치정보(GPS·고도) → 시설등급**
|
||||
- 드론 궤적 버튼과 드론 위치정보를 인접 배치, 시설등급 옵션을 그 우측에 배치.
|
||||
- 잘 쓰지 않는 'POI 겹침제외' 버튼은 숨김(기능/상태는 보존, 기본 ON).
|
||||
|
||||
### 3-4. 활성 상태 색상 통일
|
||||
- 제각각이던 활성(ON) 색(파랑/초록/빨강/하늘색)을 **앰버(겨자·오렌지 계열)로 통일** → UI 일관성 향상.
|
||||
|
||||
---
|
||||
|
||||
## 4. 운영 메모 (배포 방식)
|
||||
|
||||
- 사용자 화면(포트 55000)은 **빌드 결과물을 서빙**하는 구조로, 소스 수정 후 **클라이언트 빌드 → 새로고침**해야 반영됩니다. (이번 작업 중 빌드 누락으로 변경이 보이지 않던 이슈를 확인·정리)
|
||||
- 데이터(드론 프레임/POI)는 폴더 선택으로 로드되므로, 새로고침 시 **폴더 재선택** 필요.
|
||||
|
||||
---
|
||||
|
||||
## 5. 검증
|
||||
|
||||
- 모든 변경 TypeScript 타입검사 + 프로덕션 빌드 통과.
|
||||
- 역 재진입 마커 표출은 실제 화면(55000)에서 표시 확인 완료.
|
||||
|
||||
---
|
||||
|
||||
## 6. 향후 참고(선택 개선안)
|
||||
|
||||
- 측점값 라벨 폭 추정 상수는 폰트 변경 시 재조정 필요.
|
||||
- 활성 색조(앰버 명도)는 가독성에 따라 미세 조정 가능.
|
||||
- 'POI 겹침제외'는 현재 숨김 상태이므로, 재노출이 필요하면 토글 복원 가능.
|
||||
Binary file not shown.
@@ -58,7 +58,7 @@
|
||||
td code,p code,li code{background:#0c0f14;border:1px solid var(--line);border-radius:5px;padding:1px 6px;font-size:12.5px;color:#9ad0ff;font-family:"SFMono-Regular",Consolas,"Liberation Mono",Menlo,monospace}
|
||||
|
||||
.badge{display:inline-block;font-size:11.5px;font-weight:700;padding:2px 9px;border-radius:999px;border:1px solid transparent}
|
||||
.b-high{background:rgba(126,231,135,.13);color:var(--good);border-color:rgba(126,231,135,.35)}
|
||||
.b-high{background:rgba(36, 70, 39, 0.13);color:var(--good);border-color:rgba(126,231,135,.35)}
|
||||
.b-mid{background:rgba(255,204,102,.13);color:var(--mid);border-color:rgba(255,204,102,.35)}
|
||||
.b-low{background:rgba(255,123,114,.13);color:var(--bad);border-color:rgba(255,123,114,.35)}
|
||||
.chk{color:var(--warn);font-weight:600}
|
||||
|
||||
Reference in New Issue
Block a user