Add SUM Parts reproduction and Seosan Myeongcheon application pipeline
Reproduces the SUM Parts (CVPR 2025) face-labeling benchmark on a single consumer GPU, then applies it to drone-photogrammetry road survey meshes. Verified on RTX 3060 12GB / WSL2 Ubuntu 22.04 / CUDA 11.8 / torch 2.0.1: - CUDA extensions build (pointnet2_batch, pointops, chamfer_dist, emd, subsampling) - PointNet 100 epochs reaches mIoU 17.19, matching the paper's reported 15.1 - OBJ -> PLY conversion round-trips through the model and yields per-point predictions Four upstream source patches, all idempotent, originals preserved: - numpy aliases removed in 1.24 (np.long etc.) and collections ABCs moved in python 3.10 - the blind test split ships label = -1, which crashed ConfusionMatrix - mode=val referenced `epoch` before assignment Documents the traps that cost the most time, including VRAM overflow silently falling back to host RAM on WSL2 (25-100x slowdown, no OOM) and the colour scale mismatch between r/g/b float32 and red/green/blue uint8. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+15
@@ -0,0 +1,15 @@
|
||||
# Point clouds and meshes — regenerate with scripts/mesh_to_ply.py instead
|
||||
output/
|
||||
*.ply
|
||||
*.obj
|
||||
*.pth
|
||||
*.zip
|
||||
|
||||
# Source-tree backups left by the patch scripts
|
||||
*.orig
|
||||
*.bak
|
||||
*.py.bak
|
||||
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
@@ -0,0 +1,48 @@
|
||||
# sum-parts-test
|
||||
|
||||
드론 사진측량 메시를 **건물 / 수목 / 차량 / 지면**으로 분할하기 위한
|
||||
[SUM Parts](https://github.com/tudelft3d/SUM-Parts-Benchmarks) (CVPR 2025) 재현 및 적용 작업.
|
||||
|
||||
대상 데이터: 서산 명천 도로 프로젝트 — ContextCapture OBJ 6블록, EPSG:5186.
|
||||
|
||||
## 이 레포에 있는 것
|
||||
|
||||
| 경로 | 내용 |
|
||||
|---|---|
|
||||
| [SETUP.md](SETUP.md) | **새 머신에서 시작하는 절차** — 여기부터 |
|
||||
| [docs/pipeline.html](docs/pipeline.html) | 6단계 공정 정의 (브라우저로 열 것) |
|
||||
| [docs/SUM-Parts-검토노트.md](docs/SUM-Parts-검토노트.md) | 트러블슈팅 16건, 데이터 스키마 실측, 라이선스 |
|
||||
| [scripts/](scripts/) | 환경 구축 · 학습 · 평가 · 변환 스크립트 47개 |
|
||||
|
||||
데이터와 체크포인트는 커밋하지 않는다(`.gitignore`). 스크립트로 재생성한다.
|
||||
|
||||
## 요약
|
||||
|
||||
- **모델**: PointVector (논문 mIoU 70.0%, 번들 최고이자 최속)
|
||||
- **학습 자산**: SUM Parts face 트랙 13클래스 → 우리 4클래스로 통합
|
||||
- **제약**: 저자가 학습 가중치를 공개하지 않아 직접 학습이 유일한 경로
|
||||
- **VRAM**: 논문 설정 `voxel_max 64000`은 16.5GB 필요 → 12GB 카드 불가, 24GB 필요
|
||||
|
||||
## 빠른 시작
|
||||
|
||||
```bash
|
||||
bash scripts/setup_env.sh
|
||||
bash scripts/setup_pointnext.sh
|
||||
bash scripts/patch_numpy_aliases.sh
|
||||
bash scripts/patch_unlabeled_test.sh
|
||||
bash scripts/patch_val_mode.sh
|
||||
python scripts/verify_env.py # ALL OK 확인
|
||||
|
||||
bash scripts/download_data.sh all # HF 게이트 수동 수락 선행
|
||||
bash scripts/prepare_full_split.sh
|
||||
bash scripts/link_data.sh
|
||||
|
||||
bash scripts/launch_overnight.sh pointvector-xl
|
||||
```
|
||||
|
||||
자세한 것은 [SETUP.md](SETUP.md).
|
||||
|
||||
## 라이선스
|
||||
|
||||
스크립트와 문서는 자유롭게 쓰되, 참조하는 SUM Parts는
|
||||
**데이터 CC BY-NC 4.0 / 코드 GPL-3.0**이다. 상업 이용은 원저자 허락이 필요하다.
|
||||
@@ -0,0 +1,196 @@
|
||||
# 새 머신에서 시작하기
|
||||
|
||||
RTX 3090(24GB) 머신에서 SUM Parts + PointVector 학습을 재현하는 절차.
|
||||
|
||||
3060(12GB)에서는 VRAM이 모자라 `voxel_max`를 24000으로 낮춰야 했다.
|
||||
**24GB에서는 논문 설정 64000을 그대로 쓴다** — 이게 이 머신으로 옮기는 유일한 이유다.
|
||||
|
||||
---
|
||||
|
||||
## 0. 전제
|
||||
|
||||
| 항목 | 필요 |
|
||||
|---|---|
|
||||
| OS | Windows + WSL2 (Ubuntu 22.04) 또는 네이티브 Linux |
|
||||
| GPU | RTX 3090 24GB, 드라이버가 WSL에서 인식될 것 |
|
||||
| 디스크 | 40GB 이상 여유 (데이터 13GB + 환경 + 체크포인트) |
|
||||
| 계정 | HuggingFace 계정 (데이터 게이트 수락에 필요) |
|
||||
|
||||
WSL이면 `nvidia-smi`가 WSL 안에서 GPU를 보여야 한다. 안 보이면 여기서 멈추고 드라이버부터.
|
||||
|
||||
```bash
|
||||
nvidia-smi # RTX 3090 24576MiB 가 보여야 함
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. 클론
|
||||
|
||||
```bash
|
||||
git clone <gitea-url>/sum-parts-test.git
|
||||
cd sum-parts-test
|
||||
```
|
||||
|
||||
스크립트는 경로를 `/mnt/d/MYCLAUDE_PROJECT/sum-parts-test/scripts`로 하드코딩한 곳이 있다.
|
||||
다른 경로에 두면 아래 한 줄로 일괄 치환한다.
|
||||
|
||||
```bash
|
||||
NEW=$(pwd)/scripts
|
||||
grep -rl '/mnt/d/MYCLAUDE_PROJECT/sum-parts-test/scripts' scripts/ \
|
||||
| xargs sed -i "s|/mnt/d/MYCLAUDE_PROJECT/sum-parts-test/scripts|$NEW|g"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 환경 구축 (약 40분)
|
||||
|
||||
```bash
|
||||
bash scripts/setup_env.sh # conda + CUDA 11.8 + torch 2.0.1
|
||||
bash scripts/setup_pointnext.sh # 의존성 + CUDA 확장 5종 빌드
|
||||
```
|
||||
|
||||
`setup_env.sh` 안의 `TORCH_CUDA_ARCH_LIST`를 3090에 맞춰야 한다.
|
||||
|
||||
```bash
|
||||
# RTX 3060 = 8.6, RTX 3090 = 8.6 (둘 다 Ampere라 동일, 수정 불필요)
|
||||
# RTX 4090이면 8.9로 변경
|
||||
```
|
||||
|
||||
이어서 업스트림 소스 패치 4건. **전부 멱등이라 여러 번 실행해도 안전하다.**
|
||||
|
||||
```bash
|
||||
bash scripts/patch_numpy_aliases.sh # np.long 등 제거된 별칭 + collections ABC
|
||||
bash scripts/patch_unlabeled_test.sh # 블라인드 test셋의 label=-1 처리
|
||||
bash scripts/patch_val_mode.sh # mode=val 의 UnboundLocalError
|
||||
```
|
||||
|
||||
**게이트 — 통과 못 하면 다음으로 가지 말 것:**
|
||||
|
||||
```bash
|
||||
python scripts/verify_env.py
|
||||
# 기대: ALL OK (확장 6종 import + openpoints 체인 전부)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 데이터 (약 30분)
|
||||
|
||||
HuggingFace 게이트를 **브라우저에서 1회 수동 수락**해야 한다. 자동화 불가.
|
||||
|
||||
1. https://huggingface.co/datasets/gwxgrxhyz/SUM-Parts
|
||||
2. 로그인 → CC BY-NC 4.0 수락
|
||||
3. 토큰을 `~/.cache/huggingface/token`에 두거나 `huggingface-cli login`
|
||||
|
||||
```bash
|
||||
bash scripts/download_data.sh all # 5.3GB 받아 13GB로 전개
|
||||
bash scripts/prepare_full_split.sh # validate/ -> val/ 심볼릭 링크
|
||||
bash scripts/link_data.sh # PointNeXt_bundle/data 연결
|
||||
```
|
||||
|
||||
**게이트:** 세 split이 `24 / 8 / 8`로 집계될 것.
|
||||
|
||||
---
|
||||
|
||||
## 4. VRAM 확인 (5분) — 24GB에서 반드시 먼저
|
||||
|
||||
논문 설정이 실제로 들어가는지 확인한다. 3060에서는 여기서 실패했다.
|
||||
|
||||
```bash
|
||||
CFG=pointvector-xl ITERS=5 bash scripts/sweep_voxel_max.sh 40000 48000 64000
|
||||
```
|
||||
|
||||
기대 결과 (3090 24GB):
|
||||
|
||||
| voxel_max | peak VRAM | 판정 |
|
||||
|---|---|---|
|
||||
| 64000 | 약 16.5G | ✅ 들어감 |
|
||||
|
||||
**`fits? = yes`가 나와야 한다.** `NO (spilling)`이면 그 값은 쓰면 안 된다.
|
||||
|
||||
> ⚠️ **WSL2에서 VRAM 초과는 OOM을 내지 않는다.** 드라이버가 호스트 RAM으로 흘려서
|
||||
> 학습이 **조용히 완주한다 — 25~100배 느리게.** 반드시 peak VRAM 수치로 판정할 것.
|
||||
> 판별 보조 지표는 **전력**: 사용률 100%인데 전력이 낮으면 연산이 아니라 PCIe 전송 대기다.
|
||||
|
||||
---
|
||||
|
||||
## 5. 학습 (논문 설정, 약 3~5시간)
|
||||
|
||||
```bash
|
||||
CFG_VOXEL_MAX=64000 VAL_VOXEL_MAX=64000 \
|
||||
bash scripts/launch_overnight.sh pointvector-xl
|
||||
```
|
||||
|
||||
`setsid nohup`으로 분리 실행되므로 터미널·세션을 닫아도 살아남는다.
|
||||
크래시하면 최신 체크포인트에서 자동 재개한다(최대 8회).
|
||||
|
||||
진행 확인:
|
||||
|
||||
```bash
|
||||
bash scripts/check_training.sh # epoch, GPU, best miou
|
||||
bash scripts/verify_speed.sh # HEALTHY / DEGRADED 판정
|
||||
bash scripts/epoch_timing.sh # epoch별 소요시간, 감속 지점 특정
|
||||
```
|
||||
|
||||
중단:
|
||||
|
||||
```bash
|
||||
bash scripts/stop_training.sh # 워치독 먼저 죽여서 자동 재시작 방지
|
||||
```
|
||||
|
||||
**게이트:** `verify_speed.sh`가 `HEALTHY`, peak VRAM이 카드 용량의 95% 미만.
|
||||
|
||||
---
|
||||
|
||||
## 6. 평가
|
||||
|
||||
```bash
|
||||
bash scripts/final_eval.sh # val(라벨 있음) + test(블라인드, 예측만)
|
||||
bash scripts/eval_coarse.sh # 4클래스 통합 성적 ← 우리 과제 기준
|
||||
```
|
||||
|
||||
`eval_coarse.sh`가 실제로 중요한 수치를 낸다. SUM 13클래스를
|
||||
건물 / 수목 / 차량 / 지면으로 합쳐서 채점한다.
|
||||
|
||||
**게이트:** 통합 mIoU가 **"전부 건물" 무지성 분류기(IoU 약 67%)를 이길 것.**
|
||||
절대값이 아니라 baseline 대비로 판정한다.
|
||||
|
||||
---
|
||||
|
||||
## 참고 — 3060 실측값 (비교 기준)
|
||||
|
||||
| 모델 | voxel_max | peak VRAM | s/iter | 100 epoch |
|
||||
|---|---|---|---|---|
|
||||
| pointnet | 64000 | 6.01G | 0.291 | 2.9h |
|
||||
| pointnet++msg | 64000 | 4.16G | 0.675 | 6.8h |
|
||||
| pointvector-xl | 24000 | 6.46G | 0.402 | 2.9h |
|
||||
| pointvector-xl | 64000 | **16.49G** | 13.113 | ❌ 12GB 불가 |
|
||||
| pointnext-xl | 32000 | 8.03G | 0.635 | 6.4h |
|
||||
| pointnext-xl | 64000 | **15.47G** | 46.980 | ❌ 12GB 불가 |
|
||||
|
||||
논문 보고치 (face 트랙, 12클래스):
|
||||
|
||||
| 모델 | mIoU |
|
||||
|---|---|
|
||||
| PointNet | 15.1% |
|
||||
| PointNet++ | 33.1% |
|
||||
| PointNeXt | 65.3% |
|
||||
| **PointVector** | **70.0%** |
|
||||
|
||||
3060에서 pointnet 100 epoch 실측 = **17.19%** (논문 15.1%와 근사, 재현 확인됨).
|
||||
|
||||
---
|
||||
|
||||
## 알려진 제약
|
||||
|
||||
- **test 세트는 블라인드다.** 라벨이 전부 `-1`이라 로컬 채점이 불가능하다.
|
||||
논문 수치와 직접 대조하려면 예측을 저자(gaoweixiaocuhk@gmail.com)에게 보내야 한다.
|
||||
- **`voxel_max`는 중립적 손잡이가 아니다.** 모델의 동작점 일부다.
|
||||
같은 체크포인트가 검증 프로토콜에 따라 mIoU 17.19 / 4.20으로 갈렸다.
|
||||
- **라이선스**: 데이터 CC BY-NC 4.0, 코드 GPL-3.0. 상업 이용은 저자 허락 필요.
|
||||
|
||||
---
|
||||
|
||||
## 상세 기록
|
||||
|
||||
- [docs/SUM-Parts-검토노트.md](docs/SUM-Parts-검토노트.md) — 트러블슈팅 16건, 실측 데이터 스키마, 라이선스 검토
|
||||
- [docs/pipeline.html](docs/pipeline.html) — 전체 공정 정의 (브라우저로 열 것)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,646 @@
|
||||
<title>서산 명천 메시 분할 파이프라인</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans+KR:wght@300;400;500;600;700&display=swap">
|
||||
|
||||
<style>
|
||||
/* Palette derives from the dataset's own semantic class colours
|
||||
(terrain / high_vegetation / water), pulled down in saturation so they
|
||||
read as document colour rather than legend swatches. Neutrals carry a
|
||||
slight green bias to sit with them. */
|
||||
:root {
|
||||
--ground: #F6F7F4;
|
||||
--surface: #FFFFFF;
|
||||
--surface-sunk: #EDEFEA;
|
||||
--line: #D8DCD3;
|
||||
--line-soft: #E6E9E1;
|
||||
--ink: #1A1F1B;
|
||||
--ink-soft: #4B534C;
|
||||
--ink-faint: #7C857D;
|
||||
|
||||
--veg: #2F6F4E; /* high_vegetation */
|
||||
--terr: #8A5A2B; /* terrain */
|
||||
--water: #2A6E7A; /* water */
|
||||
--signal: #B4482D; /* traps, hard stops */
|
||||
--veg-wash: #E7F0EA;
|
||||
--signal-wash: #F8E9E4;
|
||||
--water-wash: #E4EEF0;
|
||||
|
||||
--shadow: 0 1px 2px rgba(26,31,27,.05), 0 8px 24px -12px rgba(26,31,27,.18);
|
||||
|
||||
--step-0: .75rem;
|
||||
--step-1: .8125rem;
|
||||
--step-2: .9375rem;
|
||||
--step-3: 1.125rem;
|
||||
--step-4: 1.5rem;
|
||||
--step-5: 2.25rem;
|
||||
--step-6: 3rem;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) {
|
||||
--ground: #12150F;
|
||||
--surface: #191D16;
|
||||
--surface-sunk: #10130E;
|
||||
--line: #2C3229;
|
||||
--line-soft: #232821;
|
||||
--ink: #E9ECE6;
|
||||
--ink-soft: #AEB6AC;
|
||||
--ink-faint: #7B8479;
|
||||
|
||||
--veg: #6FBF8E;
|
||||
--terr: #C79A63;
|
||||
--water: #6BB6C2;
|
||||
--signal: #E08A6B;
|
||||
--veg-wash: #1B2A20;
|
||||
--signal-wash: #2E1D17;
|
||||
--water-wash: #172629;
|
||||
|
||||
--shadow: 0 1px 2px rgba(0,0,0,.4), 0 8px 24px -12px rgba(0,0,0,.7);
|
||||
}
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
--ground: #12150F;
|
||||
--surface: #191D16;
|
||||
--surface-sunk: #10130E;
|
||||
--line: #2C3229;
|
||||
--line-soft: #232821;
|
||||
--ink: #E9ECE6;
|
||||
--ink-soft: #AEB6AC;
|
||||
--ink-faint: #7B8479;
|
||||
|
||||
--veg: #6FBF8E;
|
||||
--terr: #C79A63;
|
||||
--water: #6BB6C2;
|
||||
--signal: #E08A6B;
|
||||
--veg-wash: #1B2A20;
|
||||
--signal-wash: #2E1D17;
|
||||
--water-wash: #172629;
|
||||
|
||||
--shadow: 0 1px 2px rgba(0,0,0,.4), 0 8px 24px -12px rgba(0,0,0,.7);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--ground);
|
||||
color: var(--ink);
|
||||
font-family: "IBM Plex Sans KR", system-ui, -apple-system, sans-serif;
|
||||
font-size: var(--step-2);
|
||||
line-height: 1.65;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.wrap {
|
||||
max-width: 60rem;
|
||||
margin: 0 auto;
|
||||
padding: 3.5rem 1.5rem 6rem;
|
||||
}
|
||||
|
||||
code, .mono { font-family: "IBM Plex Mono", ui-monospace, monospace; }
|
||||
|
||||
/* ---------- masthead ---------- */
|
||||
|
||||
.masthead { display: flex; flex-direction: column; gap: 1.25rem; margin-bottom: 3rem; }
|
||||
|
||||
.eyebrow {
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
font-size: var(--step-0);
|
||||
letter-spacing: .14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(2rem, 5.5vw, var(--step-6));
|
||||
font-weight: 600;
|
||||
letter-spacing: -.025em;
|
||||
line-height: 1.1;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.standfirst {
|
||||
margin: 0;
|
||||
max-width: 46ch;
|
||||
font-size: var(--step-3);
|
||||
font-weight: 300;
|
||||
color: var(--ink-soft);
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.facts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(9.5rem, 1fr));
|
||||
gap: 1px;
|
||||
background: var(--line);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
margin-top: .75rem;
|
||||
}
|
||||
.fact { background: var(--surface); padding: .875rem 1rem; }
|
||||
.fact dt {
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
font-size: var(--step-0);
|
||||
letter-spacing: .08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-faint);
|
||||
margin: 0 0 .25rem;
|
||||
}
|
||||
.fact dd {
|
||||
margin: 0;
|
||||
font-size: var(--step-2);
|
||||
font-weight: 500;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ---------- section headings ---------- */
|
||||
|
||||
h2 {
|
||||
margin: 3.5rem 0 1.25rem;
|
||||
font-size: var(--step-4);
|
||||
font-weight: 600;
|
||||
letter-spacing: -.015em;
|
||||
padding-bottom: .5rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
h3 { margin: 2rem 0 .625rem; font-size: var(--step-3); font-weight: 600; }
|
||||
|
||||
p { margin: 0 0 1rem; max-width: 68ch; }
|
||||
|
||||
/* ---------- the spine ---------- */
|
||||
|
||||
.spine { display: flex; flex-direction: column; gap: 1rem; }
|
||||
|
||||
.phase {
|
||||
position: relative;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-left: 3px solid var(--accent, var(--veg));
|
||||
border-radius: 3px;
|
||||
padding: 1.375rem 1.5rem 1.5rem;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.phase[data-tone="setup"] { --accent: var(--water); }
|
||||
.phase[data-tone="train"] { --accent: var(--veg); }
|
||||
.phase[data-tone="apply"] { --accent: var(--terr); }
|
||||
|
||||
.phase-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: .75rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: .25rem;
|
||||
}
|
||||
.idx {
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
font-size: var(--step-0);
|
||||
font-weight: 600;
|
||||
letter-spacing: .1em;
|
||||
color: var(--accent, var(--veg));
|
||||
}
|
||||
.phase-title { font-size: var(--step-3); font-weight: 600; letter-spacing: -.01em; }
|
||||
.cadence {
|
||||
margin-left: auto;
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
font-size: var(--step-0);
|
||||
color: var(--ink-faint);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.phase-why { margin: 0 0 1rem; color: var(--ink-soft); font-size: var(--step-1); max-width: 62ch; }
|
||||
|
||||
.flow {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
|
||||
gap: .75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.cell {
|
||||
background: var(--surface-sunk);
|
||||
border-radius: 3px;
|
||||
padding: .625rem .75rem;
|
||||
}
|
||||
.cell h4 {
|
||||
margin: 0 0 .25rem;
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
font-size: var(--step-0);
|
||||
font-weight: 500;
|
||||
letter-spacing: .08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
.cell p { margin: 0; font-size: var(--step-1); line-height: 1.5; }
|
||||
.cell code { font-size: .8em; }
|
||||
|
||||
.gate {
|
||||
display: flex;
|
||||
gap: .625rem;
|
||||
align-items: flex-start;
|
||||
background: var(--veg-wash);
|
||||
border-radius: 3px;
|
||||
padding: .625rem .75rem;
|
||||
font-size: var(--step-1);
|
||||
}
|
||||
.gate strong {
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
font-size: var(--step-0);
|
||||
letter-spacing: .08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--veg);
|
||||
white-space: nowrap;
|
||||
padding-top: .1rem;
|
||||
}
|
||||
.gate span { color: var(--ink); }
|
||||
|
||||
.cmds {
|
||||
margin: 0 0 1rem;
|
||||
padding: .75rem .875rem;
|
||||
background: var(--surface-sunk);
|
||||
border-radius: 3px;
|
||||
overflow-x: auto;
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
font-size: var(--step-1);
|
||||
line-height: 1.7;
|
||||
white-space: pre;
|
||||
}
|
||||
.cmds .c { color: var(--ink-faint); }
|
||||
|
||||
/* ---------- traps ---------- */
|
||||
|
||||
.trap {
|
||||
background: var(--signal-wash);
|
||||
border: 1px solid color-mix(in srgb, var(--signal) 22%, transparent);
|
||||
border-radius: 3px;
|
||||
padding: .875rem 1rem;
|
||||
margin: 1rem 0;
|
||||
font-size: var(--step-1);
|
||||
}
|
||||
.trap h4 {
|
||||
margin: 0 0 .375rem;
|
||||
font-size: var(--step-1);
|
||||
font-weight: 600;
|
||||
color: var(--signal);
|
||||
}
|
||||
.trap p { margin: 0 0 .5rem; max-width: 64ch; }
|
||||
.trap p:last-child { margin-bottom: 0; }
|
||||
|
||||
/* ---------- tables ---------- */
|
||||
|
||||
.scroll { overflow-x: auto; margin: 0 0 1.25rem; border: 1px solid var(--line); border-radius: 3px; }
|
||||
table { border-collapse: collapse; width: 100%; font-size: var(--step-1); }
|
||||
th, td { padding: .5rem .75rem; text-align: left; border-bottom: 1px solid var(--line-soft); }
|
||||
thead th {
|
||||
background: var(--surface-sunk);
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
font-size: var(--step-0);
|
||||
font-weight: 500;
|
||||
letter-spacing: .06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-faint);
|
||||
white-space: nowrap;
|
||||
}
|
||||
tbody tr:last-child td { border-bottom: none; }
|
||||
td.num { font-variant-numeric: tabular-nums; text-align: right; white-space: nowrap; }
|
||||
.win { color: var(--veg); font-weight: 600; }
|
||||
.bad { color: var(--signal); font-weight: 600; }
|
||||
|
||||
.pill {
|
||||
display: inline-block;
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
font-size: var(--step-0);
|
||||
padding: .1rem .45rem;
|
||||
border-radius: 2px;
|
||||
background: var(--surface-sunk);
|
||||
border: 1px solid var(--line);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.pill.done { background: var(--veg-wash); border-color: color-mix(in srgb, var(--veg) 25%, transparent); color: var(--veg); }
|
||||
.pill.now { background: var(--water-wash); border-color: color-mix(in srgb, var(--water) 30%, transparent); color: var(--water); }
|
||||
.pill.todo { color: var(--ink-faint); }
|
||||
|
||||
ul { margin: 0 0 1rem; padding-left: 1.15rem; max-width: 66ch; }
|
||||
li { margin-bottom: .3rem; }
|
||||
|
||||
footer {
|
||||
margin-top: 4rem;
|
||||
padding-top: 1.25rem;
|
||||
border-top: 1px solid var(--line);
|
||||
font-size: var(--step-1);
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
@media (max-width: 34rem) {
|
||||
.cadence { margin-left: 0; width: 100%; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="wrap">
|
||||
|
||||
<header class="masthead">
|
||||
<div class="eyebrow">공정 정의 · v1 · 2026-08-21</div>
|
||||
<h1>서산 명천 메시 분할 파이프라인</h1>
|
||||
<p class="standfirst">
|
||||
드론 사진측량 메시를 건물·수목·차량·지면으로 분할하고,
|
||||
원본 면(face)을 클래스별로 쪼개 내보내기까지의 전 공정.
|
||||
</p>
|
||||
|
||||
<dl class="facts">
|
||||
<div class="fact"><dt>대상</dt><dd>서산 명천 도로</dd></div>
|
||||
<div class="fact"><dt>원본</dt><dd>OBJ 6블록 · 3.0 GB</dd></div>
|
||||
<div class="fact"><dt>좌표계</dt><dd>EPSG:5186</dd></div>
|
||||
<div class="fact"><dt>학습 자산</dt><dd>SUM Parts</dd></div>
|
||||
<div class="fact"><dt>모델</dt><dd>PointVector</dd></div>
|
||||
<div class="fact"><dt>연산</dt><dd>RTX 3060 12 GB</dd></div>
|
||||
</dl>
|
||||
</header>
|
||||
|
||||
<p>
|
||||
공정은 6단계다. 앞 3단계는 <strong>1회성 자산 구축</strong>이고,
|
||||
뒤 3단계는 <strong>타일마다 반복</strong>된다.
|
||||
각 단계에는 통과 조건(게이트)이 있다. 게이트를 건너뛰면
|
||||
다음 단계에서 원인을 알 수 없는 형태로 실패한다 — 실제로 그렇게 여러 번 잃었다.
|
||||
</p>
|
||||
|
||||
<h2>공정</h2>
|
||||
|
||||
<div class="spine">
|
||||
|
||||
<!-- 0 -->
|
||||
<section class="phase" data-tone="setup">
|
||||
<div class="phase-head">
|
||||
<span class="idx">단계 0</span>
|
||||
<span class="phase-title">실행 환경</span>
|
||||
<span class="cadence">1회 · 반나절</span>
|
||||
</div>
|
||||
<p class="phase-why">
|
||||
커스텀 CUDA 커널을 직접 빌드해야 하므로 Windows 네이티브로는 안 된다. WSL2에서만 성립한다.
|
||||
</p>
|
||||
<div class="flow">
|
||||
<div class="cell"><h4>입력</h4><p>없음</p></div>
|
||||
<div class="cell"><h4>처리</h4><p>conda 환경 · CUDA 11.8 · torch 2.0.1 · CUDA 확장 5종 빌드</p></div>
|
||||
<div class="cell"><h4>출력</h4><p>동작하는 <code>sumparts</code> 환경</p></div>
|
||||
</div>
|
||||
<div class="cmds"><span class="c"># 환경 + 확장 빌드 + 소스 현대화 패치</span>
|
||||
bash scripts/setup_env.sh
|
||||
bash scripts/setup_pointnext.sh
|
||||
bash scripts/patch_numpy_aliases.sh
|
||||
bash scripts/patch_unlabeled_test.sh
|
||||
bash scripts/patch_val_mode.sh</div>
|
||||
<div class="gate">
|
||||
<strong>게이트</strong>
|
||||
<span><code>python scripts/verify_env.py</code> 가 <code>ALL OK</code> 를 낼 것.
|
||||
확장 6종 import + <code>openpoints</code> 체인 전부 통과해야 한다.</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 1 -->
|
||||
<section class="phase" data-tone="setup">
|
||||
<div class="phase-head">
|
||||
<span class="idx">단계 1</span>
|
||||
<span class="phase-title">학습 데이터 확보</span>
|
||||
<span class="cadence">1회 · 30분</span>
|
||||
</div>
|
||||
<p class="phase-why">
|
||||
한국 데이터에 라벨이 없으므로 SUM Parts로 대신 학습한다.
|
||||
저자가 학습 가중치를 공개하지 않아 직접 학습이 유일한 경로다.
|
||||
</p>
|
||||
<div class="flow">
|
||||
<div class="cell"><h4>입력</h4><p>HuggingFace 게이트 수락 (브라우저 수동)</p></div>
|
||||
<div class="cell"><h4>처리</h4><p>다운로드 5.3 GB → 전개 13 GB → split 정리</p></div>
|
||||
<div class="cell"><h4>출력</h4><p>train 24 / val 8 / test 8 타일</p></div>
|
||||
</div>
|
||||
<div class="cmds">bash scripts/download_data.sh all
|
||||
bash scripts/prepare_full_split.sh
|
||||
bash scripts/link_data.sh</div>
|
||||
<div class="trap">
|
||||
<h4>배포본 split 이름이 로더와 다르다</h4>
|
||||
<p>디렉토리는 <code>validate/</code>인데 로더는 <code>val/</code>을 찾는다.
|
||||
그대로 두면 <strong>예외 없이</strong> 검증 0개로 학습이 돌아간다.
|
||||
<code>prepare_full_split.sh</code>가 심볼릭 링크로 해결한다.</p>
|
||||
</div>
|
||||
<div class="gate">
|
||||
<strong>게이트</strong>
|
||||
<span>세 split 모두 <code>24 / 8 / 8</code>로 집계될 것.</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 2 -->
|
||||
<section class="phase" data-tone="train">
|
||||
<div class="phase-head">
|
||||
<span class="idx">단계 2</span>
|
||||
<span class="phase-title">모델 학습</span>
|
||||
<span class="cadence">1회 · 4시간</span>
|
||||
</div>
|
||||
<p class="phase-why">
|
||||
PointVector를 쓴다. 논문 실측 mIoU 70.0 %로 번들 최고이고, PointNeXt보다 빠르다.
|
||||
PointNet은 15.1 %로 실무용이 아니다.
|
||||
</p>
|
||||
<div class="flow">
|
||||
<div class="cell"><h4>입력</h4><p>SUM Parts face 트랙 · 13클래스</p></div>
|
||||
<div class="cell"><h4>처리</h4><p>100 epoch · <code>voxel_max 24000</code> · 워치독 자동 재개</p></div>
|
||||
<div class="cell"><h4>출력</h4><p><code>ckpt_best.pth</code> 41 MB</p></div>
|
||||
</div>
|
||||
<div class="cmds">bash scripts/launch_overnight.sh pointvector-xl
|
||||
bash scripts/check_training.sh <span class="c"># 진행 확인</span></div>
|
||||
<div class="trap">
|
||||
<h4>VRAM 초과가 OOM을 내지 않는다</h4>
|
||||
<p>WSL2 드라이버는 VRAM을 넘으면 호스트 RAM으로 흘린다.
|
||||
학습은 <strong>조용히 완주한다 — 25~100배 느리게.</strong>
|
||||
12 GB 카드에서 <code>peak 15.47 GB</code>가 찍힌다.</p>
|
||||
<p><strong>판별법: 전력.</strong> 사용률 100 %인데 전력이 낮으면(3060 기준 60 W대)
|
||||
연산이 아니라 PCIe 전송 대기다. 정상이면 140 W대.</p>
|
||||
</div>
|
||||
<div class="gate">
|
||||
<strong>게이트</strong>
|
||||
<span><code>verify_speed.sh</code> 가 <code>HEALTHY</code> 를 낼 것.
|
||||
<code>peak VRAM < 11 GB</code> 이고 전력이 120 W를 넘어야 한다.</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 3 -->
|
||||
<section class="phase" data-tone="apply">
|
||||
<div class="phase-head">
|
||||
<span class="idx">단계 3</span>
|
||||
<span class="phase-title">한국 데이터 변환</span>
|
||||
<span class="cadence">타일마다 · 5분</span>
|
||||
</div>
|
||||
<p class="phase-why">
|
||||
원본 OBJ는 읽기만 한다. 모델 입력은 포인트 클라우드 PLY여야 한다 —
|
||||
데이터로더가 <code>*.ply</code>만 스캔하고, OBJ에는 포인트별 라벨 필드가 없다.
|
||||
</p>
|
||||
<div class="flow">
|
||||
<div class="cell"><h4>입력</h4><p>ContextCapture OBJ + 텍스처 아틀라스 17~21장</p></div>
|
||||
<div class="cell"><h4>처리</h4><p>노선 축 타일링 → 면적가중 샘플링 → 텍스처 UV 조회</p></div>
|
||||
<div class="cell"><h4>출력</h4><p>타일당 47만 포인트 PLY</p></div>
|
||||
</div>
|
||||
<div class="cmds">python scripts/inspect_obj.py Block.obj <span class="c"># 범위·머티리얼 파악</span>
|
||||
python scripts/mesh_to_ply.py Block.obj tile.ply \
|
||||
--bbox X0 Y0 X1 Y1 --points 470000
|
||||
python scripts/check_ply.py tile.ply <span class="c"># 스키마 대조</span></div>
|
||||
<div class="trap">
|
||||
<h4>색상 스케일이 조용히 망가진다</h4>
|
||||
<p>SUM 배포본은 <code>r,g,b</code> <strong>float32 [0,1]</strong>이다.
|
||||
<code>red,green,blue</code> uint8로 쓰면 로더가 정규화를 하지 않아
|
||||
<strong>255배 큰 특징값</strong>이 네트워크에 들어간다. 에러 없이 결과만 무의미해진다.</p>
|
||||
<p>멀티머티리얼도 함정이다. <code>trimesh.load(force='mesh')</code>로 합치면
|
||||
텍스처가 전부 유실되어 결과가 회색이 된다. Scene으로 받아 머티리얼별로 샘플링해야 한다.</p>
|
||||
</div>
|
||||
<div class="gate">
|
||||
<strong>게이트</strong>
|
||||
<span><code>check_ply.py</code>가 SUM 배포본과 나란히 <code>OK</code>를 낼 것.
|
||||
회색 폴백 경고가 없어야 한다.</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 4 -->
|
||||
<section class="phase" data-tone="apply">
|
||||
<div class="phase-head">
|
||||
<span class="idx">단계 4</span>
|
||||
<span class="phase-title">추론 및 클래스 통합</span>
|
||||
<span class="cadence">타일마다 · 1분</span>
|
||||
</div>
|
||||
<p class="phase-why">
|
||||
SUM 13클래스를 우리가 필요한 4클래스로 합친다.
|
||||
창·문 수준 세부는 필요 없으므로 세부 클래스의 개별 성적은 비용이 아니다.
|
||||
</p>
|
||||
<div class="flow">
|
||||
<div class="cell"><h4>입력</h4><p>변환된 PLY + 학습 체크포인트</p></div>
|
||||
<div class="cell"><h4>처리</h4><p>슬라이딩 윈도우 추론 → 13→4 매핑</p></div>
|
||||
<div class="cell"><h4>출력</h4><p>포인트별 클래스 + 예측 PLY</p></div>
|
||||
</div>
|
||||
<div class="scroll">
|
||||
<table>
|
||||
<thead><tr><th>우리 클래스</th><th>SUM 원본 클래스</th><th class="num">논문 IoU</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>건물</td><td>facade · roof · chimney · dormer · balcony · roof_installation · wall</td><td class="num">85.9–91.7 %</td></tr>
|
||||
<tr><td>수목</td><td>high_vegetation</td><td class="num win">96.8 %</td></tr>
|
||||
<tr><td>차량</td><td>car · boat</td><td class="num win">95.2 %</td></tr>
|
||||
<tr><td>지면 (도로 포함)</td><td>terrain</td><td class="num win">92.3 %</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p style="font-size:var(--step-1); color:var(--ink-soft);">
|
||||
도로면은 face 트랙에서 <code>terrain</code>에 포함된다. 별도 <code>road</code> 클래스는
|
||||
texture 트랙(19클래스)에만 있고 우리에겐 필요 없다.
|
||||
</p>
|
||||
<div class="gate">
|
||||
<strong>게이트</strong>
|
||||
<span>Mapple로 육안 확인 + 통합 4클래스 mIoU 측정.
|
||||
<strong>"전부 건물" 무지성 분류기(IoU 67 %)를 반드시 이길 것.</strong>
|
||||
못 이기면 모델이 퇴화한 상태다.</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 5 -->
|
||||
<section class="phase" data-tone="apply">
|
||||
<div class="phase-head">
|
||||
<span class="idx">단계 5</span>
|
||||
<span class="phase-title">메시 분할</span>
|
||||
<span class="cadence">타일마다 · 미구현</span>
|
||||
</div>
|
||||
<p class="phase-why">
|
||||
최종 산출물. 포인트 예측을 원본 면으로 되돌려 클래스별 OBJ로 쪼갠다.
|
||||
</p>
|
||||
<div class="flow">
|
||||
<div class="cell"><h4>입력</h4><p>포인트별 예측 + 원본 OBJ</p></div>
|
||||
<div class="cell"><h4>처리</h4><p>포인트→면 역매핑 → 면 다수결 → 클래스별 분리</p></div>
|
||||
<div class="cell"><h4>출력</h4><p><code>building.obj</code> · <code>vegetation.obj</code> · <code>vehicle.obj</code> · <code>ground.obj</code></p></div>
|
||||
</div>
|
||||
<div class="trap">
|
||||
<h4>원본 vertex는 불가침</h4>
|
||||
<p>분할은 재생성이 아니라 <strong>면(face) 분할</strong>이다.
|
||||
구멍 메우기는 새 vertex 덧대기만 허용된다. 원본 지면 Z를 DTM으로 덮어쓰면 실패다.</p>
|
||||
<p>현재 변환기는 샘플링 시 face index를 남기지 않는다.
|
||||
<strong>역매핑을 위해 face index 보존 기능 추가가 필요하다.</strong></p>
|
||||
</div>
|
||||
<div class="gate">
|
||||
<strong>게이트</strong>
|
||||
<span>분할된 면 수의 합 = 원본 면 수. 누락도 중복도 없을 것.</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
<h2>정확도가 부족할 때</h2>
|
||||
|
||||
<p>
|
||||
단계 4의 게이트를 통과 못 하면 도메인 갭 때문이다.
|
||||
헬싱키 도시로 학습한 모델을 한국 도로 현장에 적용하는 구조적 한계다.
|
||||
그때 붙이는 분기가 아래다.
|
||||
</p>
|
||||
|
||||
<div class="phase" data-tone="train" style="margin-bottom:1rem;">
|
||||
<div class="phase-head">
|
||||
<span class="idx">분기</span>
|
||||
<span class="phase-title">자동 라벨링 + 파인튜닝</span>
|
||||
<span class="cadence">조건부</span>
|
||||
</div>
|
||||
<div class="flow">
|
||||
<div class="cell"><h4>입력</h4><p>드론 원본 8,120장 + 카메라 포즈</p></div>
|
||||
<div class="cell"><h4>처리</h4><p>SAM 3.1 텍스트 프롬프트 → 2D 마스크 → 포즈로 3D 투영 → 면 투표</p></div>
|
||||
<div class="cell"><h4>출력</h4><p>한국 데이터 자동 라벨 → 파인튜닝</p></div>
|
||||
</div>
|
||||
<ul>
|
||||
<li><strong>SAM 3의 텍스트 프롬프트가 전제다.</strong> SAM 2는 덩어리만 나누고 이름을 못 붙인다.</li>
|
||||
<li><strong>카메라 포즈 없으면 2D→3D 전이 자체가 불가능하다.</strong> 서산 명천은 PPK와 ContextCapture 메타가 있어 성립한다.</li>
|
||||
<li>가림 처리 필요 — 메시로 depth test 해서 안 보이는 면에 투표하면 안 된다.</li>
|
||||
<li><strong>사람 몫은 전수 라벨링이 아니라 소량 검증이다.</strong> 자동 라벨로 학습하고 자동 라벨로 평가하면 같은 오류를 서로 확인해주는 꼴이라 정확도를 알 수 없다. 최소 1~2타일은 사람이 확인한 정답이 있어야 한다.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<h2>모델 선택 근거</h2>
|
||||
|
||||
<div class="scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>모델</th><th>발표</th><th>핵심</th><th class="num">논문 mIoU</th><th class="num">우리 실측 s/iter</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>PointNet</td><td>2017</td><td>전체 한 번에 max-pool. 이웃 개념 없음</td><td class="num bad">15.1 %</td><td class="num">0.291</td></tr>
|
||||
<tr><td>PointNet++</td><td>2017</td><td>이웃끼리 묶어 계층 처리</td><td class="num">33.1 %</td><td class="num">0.675</td></tr>
|
||||
<tr><td>PointNeXt</td><td>2022</td><td>++ 구조 유지, 학습법·크기 개선</td><td class="num">65.3 %</td><td class="num">0.635</td></tr>
|
||||
<tr><td><strong>PointVector</strong></td><td>2023</td><td>이웃 특징을 고차원 벡터로 합침</td><td class="num win">70.0 %</td><td class="num win">0.402</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p style="font-size:var(--step-1); color:var(--ink-soft);">
|
||||
s/iter는 RTX 3060 12 GB 실측, XL 모델은 VRAM에 맞춰 <code>voxel_max</code>를 낮춘 값이다.
|
||||
PointVector가 가장 정확하고 동시에 가장 빠르다.
|
||||
</p>
|
||||
|
||||
<h2>현재 상태</h2>
|
||||
|
||||
<div class="scroll">
|
||||
<table>
|
||||
<thead><tr><th>단계</th><th>상태</th><th>비고</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>0 · 실행 환경</td><td><span class="pill done">완료</span></td><td>확장 5종 빌드, 패치 4건</td></tr>
|
||||
<tr><td>1 · 학습 데이터</td><td><span class="pill done">완료</span></td><td>13 GB 전개, 24/8/8</td></tr>
|
||||
<tr><td>2 · 모델 학습</td><td><span class="pill now">진행 중</span></td><td>PointVector 100 epoch</td></tr>
|
||||
<tr><td>3 · 데이터 변환</td><td><span class="pill done">검증됨</span></td><td>BlockYBA 1타일 POC 통과</td></tr>
|
||||
<tr><td>4 · 추론·통합</td><td><span class="pill todo">대기</span></td><td>학습 완료 후</td></tr>
|
||||
<tr><td>5 · 메시 분할</td><td><span class="pill todo">미구현</span></td><td>face index 보존 필요</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h2>제약</h2>
|
||||
|
||||
<ul>
|
||||
<li><strong>test 세트는 블라인드다.</strong> 라벨이 전부 <code>-1</code>이라 로컬 채점이 원천 불가하다. 논문 수치와 직접 대조하려면 예측을 저자에게 보내야 한다.</li>
|
||||
<li><strong>어노테이션 도구는 비공개다.</strong> 저자가 유료 서비스로 판매 중이라 한국 데이터 라벨링에 쓸 수 없다.</li>
|
||||
<li><strong>XL 모델은 논문 설정 재현이 불가하다.</strong> <code>voxel_max</code>를 64000에서 낮춰야 12 GB에 들어간다. 이 값은 중립적 손잡이가 아니라 모델의 동작점 일부다 — 같은 체크포인트가 프로토콜에 따라 mIoU 17.19 / 4.20으로 갈렸다.</li>
|
||||
<li><strong>데이터는 CC BY-NC 4.0, 코드는 GPL-3.0이다.</strong> 상업 이용은 저자 허락이 필요하고, NC 데이터로 뽑은 가중치도 NC로 취급하는 게 안전하다.</li>
|
||||
</ul>
|
||||
|
||||
<footer>
|
||||
실측 환경 — RTX 3060 12 GB · WSL2 Ubuntu 22.04 · CUDA 11.8 · torch 2.0.1 · Python 3.10<br>
|
||||
상세 기록은 <code>docs/SUM-Parts-검토노트.md</code>, 스크립트는 <code>scripts/</code>.
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,157 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Measure peak VRAM and per-iteration time for each sumv2_triangle model.
|
||||
|
||||
Answers "will full training fit on this GPU, and how long would it take" with
|
||||
numbers off the actual card rather than a guess. Builds the model and the real
|
||||
train loader from each cfg, runs a handful of train steps at the cfg's own
|
||||
batch_size, and reports peak allocated memory plus iterations/second.
|
||||
|
||||
Run from PointNeXt_bundle/examples/segmentation.
|
||||
|
||||
Usage:
|
||||
python bench_models.py [--iters 8] [--cfgs pointnet pointnext-xl ...]
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import torch
|
||||
|
||||
sys.path.append(str(Path(__file__).resolve().parent))
|
||||
sys.path.append("../../")
|
||||
|
||||
from openpoints.utils import EasyConfig # noqa: E402
|
||||
from openpoints.dataset import build_dataloader_from_cfg, get_features_by_keys # noqa: E402
|
||||
from openpoints.models import build_model_from_cfg # noqa: E402
|
||||
from openpoints.loss import build_criterion_from_cfg # noqa: E402
|
||||
from openpoints.optim import build_optimizer_from_cfg # noqa: E402
|
||||
|
||||
ALL_CFGS = ["pointnet", "pointnet++msg", "pointnext-xl", "pointvector-xl"]
|
||||
|
||||
|
||||
def bench(cfg_name: str, iters: int, voxel_max: int | None = None,
|
||||
batch_size: int | None = None) -> dict:
|
||||
cfg = EasyConfig()
|
||||
cfg.load(f"../../cfgs/sumv2_triangle/{cfg_name}.yaml", recursive=True)
|
||||
cfg.rank, cfg.distributed, cfg.mp = 0, False, False
|
||||
if voxel_max is not None:
|
||||
cfg.dataset.train.voxel_max = voxel_max
|
||||
if batch_size is not None:
|
||||
cfg.batch_size = batch_size
|
||||
|
||||
model = build_model_from_cfg(cfg.model).cuda()
|
||||
n_params = sum(p.numel() for p in model.parameters())
|
||||
|
||||
cfg.criterion_args.weight = None
|
||||
criterion = build_criterion_from_cfg(cfg.criterion_args).cuda()
|
||||
optimizer = build_optimizer_from_cfg(model, lr=cfg.lr, **cfg.optimizer)
|
||||
|
||||
train_loader = build_dataloader_from_cfg(
|
||||
cfg.batch_size, cfg.dataset, cfg.dataloader,
|
||||
datatransforms_cfg=cfg.datatransforms, split="train", distributed=False,
|
||||
)
|
||||
|
||||
torch.cuda.empty_cache()
|
||||
torch.cuda.reset_peak_memory_stats()
|
||||
|
||||
model.train()
|
||||
times: list[float] = []
|
||||
it = iter(train_loader)
|
||||
n_points = None
|
||||
|
||||
for i in range(iters):
|
||||
try:
|
||||
data = next(it)
|
||||
except StopIteration:
|
||||
it = iter(train_loader)
|
||||
data = next(it)
|
||||
|
||||
for k in data:
|
||||
data[k] = data[k].cuda(non_blocking=True)
|
||||
target = data["y"].squeeze(-1)
|
||||
data["x"] = get_features_by_keys(data, cfg.feature_keys)
|
||||
if n_points is None:
|
||||
n_points = int(data["pos"].shape[0] * data["pos"].shape[1]) \
|
||||
if data["pos"].dim() == 3 else int(data["pos"].shape[0])
|
||||
|
||||
torch.cuda.synchronize()
|
||||
t0 = time.perf_counter()
|
||||
|
||||
logits = model(data)
|
||||
loss = criterion(logits, target)
|
||||
loss.backward()
|
||||
optimizer.step()
|
||||
optimizer.zero_grad()
|
||||
|
||||
torch.cuda.synchronize()
|
||||
dt = time.perf_counter() - t0
|
||||
if i >= 2: # skip warm-up iterations
|
||||
times.append(dt)
|
||||
|
||||
peak = torch.cuda.max_memory_allocated() / 1024**3
|
||||
reserved = torch.cuda.max_memory_reserved() / 1024**3
|
||||
avg = sum(times) / len(times) if times else float("nan")
|
||||
|
||||
del model, optimizer, criterion, train_loader
|
||||
torch.cuda.empty_cache()
|
||||
|
||||
return {
|
||||
"cfg": cfg_name, "params_m": n_params / 1e6, "batch_size": cfg.batch_size,
|
||||
"voxel_max": cfg.dataset.train.voxel_max, "points_per_batch": n_points,
|
||||
"peak_gb": peak, "reserved_gb": reserved, "sec_per_iter": avg,
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--iters", type=int, default=8)
|
||||
ap.add_argument("--cfgs", nargs="*", default=ALL_CFGS)
|
||||
ap.add_argument("--voxel-max", type=int, default=None,
|
||||
help="override dataset.train.voxel_max (cfg default: 64000)")
|
||||
ap.add_argument("--batch-size", type=int, default=None)
|
||||
args = ap.parse_args()
|
||||
|
||||
total_gb = torch.cuda.get_device_properties(0).total_memory / 1024**3
|
||||
print(f"GPU: {torch.cuda.get_device_name(0)} {total_gb:.1f} GB")
|
||||
if args.voxel_max or args.batch_size:
|
||||
print(f"overrides: voxel_max={args.voxel_max} batch_size={args.batch_size}")
|
||||
print("NOTE: on WSL2 the NVIDIA driver spills past VRAM into host RAM instead")
|
||||
print(" of raising OOM. A peak above the card's capacity means the run")
|
||||
print(" was paging over PCIe -- it completes, but uselessly slowly.\n")
|
||||
|
||||
rows = []
|
||||
for name in args.cfgs:
|
||||
print(f"--- benchmarking {name} ---", flush=True)
|
||||
try:
|
||||
rows.append(bench(name, args.iters, args.voxel_max, args.batch_size))
|
||||
print(f" ok\n", flush=True)
|
||||
except torch.cuda.OutOfMemoryError as e:
|
||||
print(f" OOM: {str(e)[:120]}\n", flush=True)
|
||||
rows.append({"cfg": name, "oom": True})
|
||||
torch.cuda.empty_cache()
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f" FAILED {type(e).__name__}: {str(e)[:200]}\n", flush=True)
|
||||
rows.append({"cfg": name, "error": f"{type(e).__name__}: {e}"})
|
||||
torch.cuda.empty_cache()
|
||||
|
||||
print()
|
||||
print(f"{'cfg':<16}{'params':>9}{'bs':>4}{'pts/batch':>12}"
|
||||
f"{'peak VRAM':>11}{'s/iter':>9} fits?")
|
||||
print("-" * 70)
|
||||
for r in rows:
|
||||
if r.get("oom"):
|
||||
print(f"{r['cfg']:<16}{'':>9}{'':>4}{'':>12}{'OOM':>11}{'':>9}")
|
||||
elif r.get("error"):
|
||||
print(f"{r['cfg']:<16} {r['error'][:44]}")
|
||||
else:
|
||||
fits = "yes" if r["peak_gb"] < total_gb * 0.95 else "NO (spilling)"
|
||||
print(f"{r['cfg']:<16}{r['params_m']:>8.1f}M{r['batch_size']:>4}"
|
||||
f"{r['points_per_batch']:>12,}{r['peak_gb']:>10.2f}G"
|
||||
f"{r['sec_per_iter']:>9.3f} {fits}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env bash
|
||||
# SUM Parts - build only the CUDA extensions (rerunnable)
|
||||
#
|
||||
# Split out of setup_pointnext.sh so a failed compile can be retried without
|
||||
# reinstalling every python dependency.
|
||||
#
|
||||
# ninja note: torch 2.0's cpp_extension shells out to `ninja -v` and reads its
|
||||
# output through a pipe. ninja >= 1.12 dies with SIGPIPE there, which torch
|
||||
# surfaces only as the useless "Error compiling objects for extension".
|
||||
# Pinning ninja to 1.11.x avoids it.
|
||||
set -euo pipefail
|
||||
|
||||
CONDA_ROOT="$HOME/miniconda3"
|
||||
ENV_NAME="sumparts"
|
||||
REPO="$HOME/sum-parts/semantic_segmentation/PointNeXt_bundle"
|
||||
|
||||
source "$CONDA_ROOT/etc/profile.d/conda.sh"
|
||||
conda activate "$ENV_NAME"
|
||||
|
||||
export TORCH_CUDA_ARCH_LIST="8.6" # RTX 3060
|
||||
export CUDA_HOME="$CONDA_PREFIX"
|
||||
export PATH="$CUDA_HOME/bin:$PATH"
|
||||
export MAX_JOBS="${MAX_JOBS:-4}"
|
||||
|
||||
echo "=== pin ninja to 1.11.x ==="
|
||||
pip install --no-cache-dir "ninja==1.11.1.1"
|
||||
ninja --version
|
||||
|
||||
# subsampling/setup.py imports numpy.distutils, which imports
|
||||
# distutils.msvccompiler. setuptools removed that module in 74.0, so anything
|
||||
# newer dies with "No module named 'distutils.msvccompiler'".
|
||||
echo "=== pin setuptools to 69.5.1 (numpy.distutils needs it) ==="
|
||||
pip install --no-cache-dir "setuptools==69.5.1"
|
||||
python -c "import setuptools; print('setuptools', setuptools.__version__)"
|
||||
|
||||
# Skip extensions that already import cleanly, so a retry does not redo a
|
||||
# 5-minute nvcc pass that already succeeded. FORCE=1 rebuilds everything.
|
||||
have() { python -c "import $1" 2>/dev/null; }
|
||||
|
||||
# NOTE: pointnet2_batch's setup.py names the extension pointnet2_batch_cuda,
|
||||
# not pointnet2_cuda as in upstream PointNet++ forks.
|
||||
if [ "${FORCE:-0}" = "1" ] || ! have pointnet2_batch_cuda; then
|
||||
echo "=== build pointnet2_batch ==="
|
||||
cd "$REPO/openpoints/cpp/pointnet2_batch"
|
||||
rm -rf build ./*.egg-info dist
|
||||
python setup.py install
|
||||
else
|
||||
echo "=== skip pointnet2_batch (already importable) ==="
|
||||
fi
|
||||
|
||||
echo "=== build subsampling ==="
|
||||
cd "$REPO/openpoints/cpp/subsampling"
|
||||
rm -rf build ./*.so
|
||||
python setup.py build_ext --inplace
|
||||
|
||||
if [ "${FORCE:-0}" = "1" ] || ! have pointops_cuda; then
|
||||
echo "=== build pointops ==="
|
||||
cd "$REPO/openpoints/cpp/pointops"
|
||||
rm -rf build ./*.egg-info dist
|
||||
python setup.py install
|
||||
else
|
||||
echo "=== skip pointops (already importable) ==="
|
||||
fi
|
||||
|
||||
# chamfer_dist and emd only matter for reconstruction tasks, but
|
||||
# openpoints/models/__init__.py imports .reconstruction unconditionally, which
|
||||
# imports chamfer_dist -- so even a pure segmentation run fails at import time
|
||||
# without them. They are not optional in practice.
|
||||
if [ "${FORCE:-0}" = "1" ] || ! have chamfer; then
|
||||
echo "=== build chamfer_dist ==="
|
||||
cd "$REPO/openpoints/cpp/chamfer_dist"
|
||||
rm -rf build ./*.egg-info dist
|
||||
python setup.py install
|
||||
else
|
||||
echo "=== skip chamfer_dist (already importable) ==="
|
||||
fi
|
||||
|
||||
# emd/setup.py names the package emd_ext and the extension emd_cuda; plain
|
||||
# `emd` is only the python-level alias in openpoints/cpp/emd/__init__.py.
|
||||
if [ "${FORCE:-0}" = "1" ] || ! have emd_cuda; then
|
||||
echo "=== build emd ==="
|
||||
cd "$REPO/openpoints/cpp/emd"
|
||||
rm -rf build ./*.egg-info dist
|
||||
python setup.py install
|
||||
else
|
||||
echo "=== skip emd (already importable) ==="
|
||||
fi
|
||||
|
||||
echo "=== verify ==="
|
||||
cd "$REPO"
|
||||
python - <<'PY'
|
||||
import sys
|
||||
print("torch :", __import__("torch").__version__,
|
||||
"| cuda", __import__("torch").cuda.is_available())
|
||||
ok = True
|
||||
for m in ["pointnet2_batch_cuda", "pointops_cuda", "chamfer", "emd_cuda",
|
||||
"torch_scatter", "plyfile"]:
|
||||
try:
|
||||
__import__(m)
|
||||
print(f"{m:22s}: OK")
|
||||
except Exception as e:
|
||||
ok = False
|
||||
print(f"{m:22s}: FAIL {type(e).__name__}: {e}")
|
||||
try:
|
||||
from openpoints.cpp.subsampling import grid_subsampling # noqa: F401
|
||||
print(f"{'grid_subsampling':22s}: OK")
|
||||
except Exception as e:
|
||||
ok = False
|
||||
print(f"{'grid_subsampling':22s}: FAIL {type(e).__name__}: {e}")
|
||||
sys.exit(0 if ok else 1)
|
||||
PY
|
||||
|
||||
echo "BUILD DONE"
|
||||
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
# SUM Parts - verify PYTORCH_CUDA_ALLOC_CONF is accepted before committing a run
|
||||
#
|
||||
# torch 2.0.1 aborts at CUDA init on an unknown key, and the traceback points at
|
||||
# model.to(device) rather than at the env var, so an invalid setting looks like
|
||||
# a model problem. Check it in two seconds instead.
|
||||
set -uo pipefail
|
||||
|
||||
source "$HOME/miniconda3/etc/profile.d/conda.sh"
|
||||
conda activate sumparts
|
||||
|
||||
CONF="${1:-garbage_collection_threshold:0.7,max_split_size_mb:128}"
|
||||
|
||||
echo "torch : $(python -c 'import torch; print(torch.__version__)')"
|
||||
echo "testing: PYTORCH_CUDA_ALLOC_CONF=$CONF"
|
||||
|
||||
PYTORCH_CUDA_ALLOC_CONF="$CONF" python - <<'PY'
|
||||
import os
|
||||
import torch
|
||||
|
||||
print("env :", os.environ.get("PYTORCH_CUDA_ALLOC_CONF"))
|
||||
x = torch.zeros(1024, 1024, device="cuda")
|
||||
y = (x + 1).sum().item()
|
||||
print("cuda : OK", torch.cuda.get_device_name(0), "| smoke sum =", y)
|
||||
print("alloc : %.1f MB" % (torch.cuda.memory_allocated() / 1024**2))
|
||||
PY
|
||||
rc=$?
|
||||
|
||||
if [ $rc -eq 0 ]; then
|
||||
echo "ACCEPTED"
|
||||
else
|
||||
echo "REJECTED (rc=$rc) -- do not launch with this setting"
|
||||
fi
|
||||
exit $rc
|
||||
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Report the download sizes of the SUM Parts archives on Hugging Face."""
|
||||
from huggingface_hub import HfApi
|
||||
|
||||
REPO = "gwxgrxhyz/SUM-Parts"
|
||||
|
||||
api = HfApi()
|
||||
info = api.repo_info(REPO, repo_type="dataset", files_metadata=True)
|
||||
|
||||
rows = []
|
||||
for s in info.siblings:
|
||||
size = s.size or (s.lfs.size if getattr(s, "lfs", None) else None)
|
||||
if size and size > 1_000_000:
|
||||
rows.append((s.rfilename, size))
|
||||
|
||||
rows.sort(key=lambda r: -r[1])
|
||||
total = 0
|
||||
for name, size in rows:
|
||||
print(f"{size / 1e9:>8.2f} GB {name}")
|
||||
total += size
|
||||
print(f"{'-' * 30}")
|
||||
print(f"{total / 1e9:>8.2f} GB total (files > 1 MB)")
|
||||
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env bash
|
||||
# SUM Parts - did the dataset.test.voxel_max override actually land?
|
||||
set -uo pipefail
|
||||
|
||||
LOG="${1:-$HOME/sum-parts/runs/coarse_eval/infer.log}"
|
||||
|
||||
echo "log: $LOG"
|
||||
[ -f "$LOG" ] || { echo " missing"; exit 1; }
|
||||
|
||||
echo
|
||||
echo "=== opts line (what was overridden) ==="
|
||||
grep -a '^opts:' "$LOG" || echo " (none)"
|
||||
|
||||
echo
|
||||
echo "=== dataset block from the cfg dump ==="
|
||||
sed -n '/^dataset:/,/^datatransforms:/p' "$LOG" | head -25
|
||||
|
||||
echo
|
||||
echo "=== how many sub-clouds per tile ==="
|
||||
grep -ao 'Test on [0-9]*-th cloud \[[0-9]*\]/\[[0-9]*\]' "$LOG" \
|
||||
| sed 's/.*\///' | sort -u | head
|
||||
|
||||
echo
|
||||
echo "=== reported metrics ==="
|
||||
grep -aE 'test_oa|iou per cls' "$LOG" | tail -4
|
||||
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compare PLY point clouds against what the SUM Parts loader needs.
|
||||
|
||||
Pass the converted file first and a reference SUM Parts tile second to see the
|
||||
two side by side.
|
||||
|
||||
Usage:
|
||||
python check_ply.py mine.ply [reference.ply ...]
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from plyfile import PlyData
|
||||
|
||||
# read_ply_with_plyfilelib tries red/green/blue first, then r/g/b
|
||||
RGB_SETS = (("red", "green", "blue"), ("r", "g", "b"))
|
||||
|
||||
|
||||
def report(path: Path) -> bool:
|
||||
ply = PlyData.read(str(path))
|
||||
v = ply["vertex"]
|
||||
props = [p.name for p in v.properties]
|
||||
|
||||
print(f"=== {path.name} ===")
|
||||
print(f" format : {'binary' if not ply.text else 'ascii'}")
|
||||
print(f" points : {len(v):,}")
|
||||
print(f" properties: {props}")
|
||||
|
||||
ok = True
|
||||
|
||||
missing_xyz = [c for c in ("x", "y", "z") if c not in props]
|
||||
if missing_xyz:
|
||||
print(f" MISSING : {missing_xyz}")
|
||||
ok = False
|
||||
else:
|
||||
xyz = np.stack([v["x"], v["y"], v["z"]], axis=1)
|
||||
lo, hi = xyz.min(axis=0), xyz.max(axis=0)
|
||||
ext = hi - lo
|
||||
print(f" extent : {np.round(ext, 2).tolist()} m")
|
||||
print(f" origin : {np.round(lo, 2).tolist()}")
|
||||
if ext.max() < 0.5:
|
||||
print(" WARNING : extent < 0.5 -- degrees, not metres?")
|
||||
ok = False
|
||||
area = ext[0] * ext[1]
|
||||
if area > 0:
|
||||
print(f" density : {len(v) / area:,.1f} pts/m^2")
|
||||
|
||||
rgb_set = next((s for s in RGB_SETS if all(c in props for c in s)), None)
|
||||
if rgb_set is None:
|
||||
print(f" MISSING : colour -- need {RGB_SETS[0]} or {RGB_SETS[1]}")
|
||||
ok = False
|
||||
else:
|
||||
rgb = np.stack([v[c] for c in rgb_set], axis=1)
|
||||
print(f" colour : {rgb_set} dtype={rgb.dtype} "
|
||||
f"range=[{rgb.min()}, {rgb.max()}] mean={rgb.mean(axis=0).round(1).tolist()}")
|
||||
grey = int((rgb == 128).all(axis=1).sum())
|
||||
if grey:
|
||||
print(f" WARNING : {grey:,} points are exactly grey (texture miss?)")
|
||||
|
||||
if "label" not in props:
|
||||
print(" MISSING : label")
|
||||
ok = False
|
||||
else:
|
||||
lab = np.asarray(v["label"])
|
||||
u, c = np.unique(lab, return_counts=True)
|
||||
shown = list(zip(u.tolist(), c.tolist()))[:14]
|
||||
print(f" label : dtype={lab.dtype} uniq={u.tolist()[:20]}")
|
||||
print(f" label hist: {shown}")
|
||||
if u.tolist() == [0]:
|
||||
print(" note : all unclassified -- inference only, cannot train/score")
|
||||
|
||||
print(f" verdict : {'OK' if ok else 'INCOMPATIBLE'}")
|
||||
print()
|
||||
return ok
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if len(sys.argv) < 2:
|
||||
print(__doc__)
|
||||
raise SystemExit(2)
|
||||
|
||||
all_ok = True
|
||||
for a in sys.argv[1:]:
|
||||
p = Path(a)
|
||||
if not p.exists():
|
||||
print(f"{a}: not found\n")
|
||||
all_ok = False
|
||||
continue
|
||||
all_ok &= report(p)
|
||||
|
||||
raise SystemExit(0 if all_ok else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Summarise a prediction PLY written by main.py's visualization step.
|
||||
|
||||
main.py colours predictions through SUMV2_Triangle_COLOR_MAP rather than
|
||||
writing a label field, so recover the class by matching each point's RGB back
|
||||
to that palette.
|
||||
|
||||
Usage:
|
||||
python check_pred.py .../seosan_BlockYBA_tile0_pred.ply
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from plyfile import PlyData
|
||||
|
||||
# openpoints/dataset/sumv2_triangle/sumv2_triangle.py
|
||||
CLASSES = ['unclassified', 'terrain', 'high_vegetation', 'facade_surface',
|
||||
'water', 'car', 'boat', 'roof_surface', 'chimney', 'dormer',
|
||||
'balcony', 'roof_installation', 'wall']
|
||||
|
||||
COLOR_MAP = np.array([
|
||||
(0., 0., 0.), (170., 85., 0.), (0., 255., 0.), (255., 255., 0.),
|
||||
(0., 255., 255.), (255., 0., 255.), (0., 0., 153.), (85., 85., 127.),
|
||||
(255., 50., 50.), (85., 0., 127.), (50., 125., 150.), (50., 0., 50.),
|
||||
(215., 160., 140.),
|
||||
])
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if len(sys.argv) != 2:
|
||||
print(__doc__)
|
||||
raise SystemExit(2)
|
||||
|
||||
path = Path(sys.argv[1])
|
||||
if not path.exists():
|
||||
print(f"{path}: not found")
|
||||
raise SystemExit(1)
|
||||
|
||||
v = PlyData.read(str(path))["vertex"]
|
||||
props = [p.name for p in v.properties]
|
||||
print(f"=== {path.name} ===")
|
||||
print(f" points : {len(v):,}")
|
||||
print(f" properties : {props}")
|
||||
|
||||
rgb_set = next((s for s in (("red", "green", "blue"), ("r", "g", "b"))
|
||||
if all(c in props for c in s)), None)
|
||||
if rgb_set is None:
|
||||
print(" no colour channels -- cannot recover predicted class")
|
||||
raise SystemExit(1)
|
||||
|
||||
rgb = np.stack([np.asarray(v[c], dtype=np.float64) for c in rgb_set], axis=1)
|
||||
if rgb.max() <= 1.0:
|
||||
rgb = rgb * 255.0
|
||||
|
||||
# nearest palette entry per point
|
||||
d = ((rgb[:, None, :] - COLOR_MAP[None, :, :]) ** 2).sum(axis=2)
|
||||
cls = d.argmin(axis=1)
|
||||
resid = np.sqrt(d.min(axis=1))
|
||||
|
||||
print(f" palette fit: max residual {resid.max():.1f} "
|
||||
f"({'exact' if resid.max() < 1 else 'approximate'})")
|
||||
print()
|
||||
print(f" {'class':<20} {'points':>10} {'share':>7}")
|
||||
u, c = np.unique(cls, return_counts=True)
|
||||
order = np.argsort(-c)
|
||||
for i in order:
|
||||
k, n = int(u[i]), int(c[i])
|
||||
name = CLASSES[k] if k < len(CLASSES) else f"?{k}"
|
||||
print(f" {name:<20} {n:>10,} {100 * n / len(cls):>6.2f}%")
|
||||
|
||||
print()
|
||||
print(f" distinct classes predicted: {len(u)}")
|
||||
if len(u) == 1:
|
||||
print(" note: single class everywhere -- expected from a 1-epoch model")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env bash
|
||||
# SUM Parts - inspect label values in the test split
|
||||
#
|
||||
# test() crashed in ConfusionMatrix.update with
|
||||
# RuntimeError: bincount only supports 1-d non-negative integral inputs
|
||||
# which means true*num_classes + pred went negative, non-integral, or 2-D.
|
||||
# Check what the files actually contain.
|
||||
set -uo pipefail
|
||||
|
||||
DATA="${1:-$HOME/sum-parts/data/face_labeling/texsp_pcl}"
|
||||
|
||||
source "$HOME/miniconda3/etc/profile.d/conda.sh"
|
||||
conda activate sumparts
|
||||
|
||||
python - "$DATA" <<'PY'
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from plyfile import PlyData
|
||||
|
||||
root = Path(sys.argv[1])
|
||||
print(f"root: {root}\n")
|
||||
|
||||
for split in ("test", "train"):
|
||||
files = sorted((root / split).glob("*.ply"))
|
||||
print(f"=== {split}: {len(files)} files ===")
|
||||
for f in files:
|
||||
v = PlyData.read(str(f))["vertex"]
|
||||
props = [p.name for p in v.properties]
|
||||
if "label" not in props:
|
||||
print(f" {f.name:<34} NO LABEL FIELD props={props}")
|
||||
continue
|
||||
lab = np.asarray(v["label"])
|
||||
u = np.unique(lab)
|
||||
neg = int((lab < 0).sum())
|
||||
flag = ""
|
||||
if neg:
|
||||
flag += f" NEGATIVE x{neg}"
|
||||
if u.max() > 12:
|
||||
flag += f" OUT-OF-RANGE max={u.max()}"
|
||||
if not np.issubdtype(lab.dtype, np.integer):
|
||||
flag += f" NON-INTEGER dtype={lab.dtype}"
|
||||
print(f" {f.name:<34} n={len(lab):>8,} dtype={str(lab.dtype):<8} "
|
||||
f"range=[{u.min()},{u.max()}] uniq={len(u)}{flag}")
|
||||
print()
|
||||
PY
|
||||
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env bash
|
||||
# SUM Parts - status of the unattended training run
|
||||
set -uo pipefail
|
||||
|
||||
RUNROOT="$HOME/sum-parts/runs"
|
||||
|
||||
WORKDIR="${1:-}"
|
||||
if [ -z "$WORKDIR" ]; then
|
||||
if [ -f "$RUNROOT/.latest" ]; then
|
||||
WORKDIR=$(cat "$RUNROOT/.latest")
|
||||
else
|
||||
WORKDIR=$(find "$RUNROOT" -maxdepth 1 -mindepth 1 -type d -printf '%T@ %p\n' 2>/dev/null \
|
||||
| sort -rn | head -1 | cut -d' ' -f2-)
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "$WORKDIR" ] || [ ! -d "$WORKDIR" ]; then
|
||||
echo "no run directory found under $RUNROOT"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== run: $(basename "$WORKDIR") ==="
|
||||
[ -f "$WORKDIR/status.txt" ] && cat "$WORKDIR/status.txt"
|
||||
|
||||
echo
|
||||
echo "=== process ==="
|
||||
if pgrep -f train_watchdog.sh > /dev/null; then
|
||||
pgrep -af "train_watchdog.sh"
|
||||
pgrep -af "main.py" | head -3
|
||||
else
|
||||
echo "watchdog NOT running"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "=== GPU ==="
|
||||
nvidia-smi --query-gpu=memory.used,memory.total,utilization.gpu,temperature.gpu \
|
||||
--format=csv,noheader 2>/dev/null || echo "nvidia-smi unavailable"
|
||||
|
||||
echo
|
||||
echo "=== epochs completed ==="
|
||||
if [ -f "$WORKDIR/train.log" ]; then
|
||||
grep -oE 'Epoch [0-9]+ LR [0-9.]+ train_miou [0-9.]+, val_miou [0-9.]+, best val miou [0-9.]+' \
|
||||
"$WORKDIR/train.log" 2>/dev/null | tail -8 || echo " (no epoch lines yet)"
|
||||
echo
|
||||
echo " total epoch lines: $(grep -cE 'Epoch [0-9]+ LR' "$WORKDIR/train.log" 2>/dev/null || echo 0)"
|
||||
echo " log size : $(du -h "$WORKDIR/train.log" | cut -f1)"
|
||||
echo " last write : $(date -r "$WORKDIR/train.log" '+%F %T')"
|
||||
else
|
||||
echo " no train.log yet"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "=== watchdog events ==="
|
||||
[ -f "$WORKDIR/watchdog.log" ] && tail -12 "$WORKDIR/watchdog.log"
|
||||
|
||||
echo
|
||||
echo "=== best so far ==="
|
||||
grep -E 'Find a better ckpt' "$WORKDIR/train.log" 2>/dev/null | tail -3 || echo " none yet"
|
||||
@@ -0,0 +1,179 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Score SUM Parts predictions at the granularity this project actually needs.
|
||||
|
||||
The benchmark reports 13 fine classes. The task here is coarser: separate a
|
||||
drone-photogrammetry mesh into building / vegetation / vehicle / ground. Window
|
||||
and single-wall detail is explicitly out of scope.
|
||||
|
||||
That difference matters for how the numbers read. A fine-grained mIoU averages
|
||||
in classes like dormer, balcony and roof_installation, which a weak baseline
|
||||
scores 0 on -- but every one of those collapses into "building" here, so their
|
||||
individual failure costs nothing. Merging first, then scoring, measures the
|
||||
thing that is actually wanted.
|
||||
|
||||
Mapping (SUM index -> coarse):
|
||||
1 terrain -> ground
|
||||
2 high_vegetation -> vegetation
|
||||
3 facade_surface -> building
|
||||
5 car -> vehicle
|
||||
6 boat -> vehicle
|
||||
7 roof_surface -> building
|
||||
8 chimney -> building
|
||||
9 dormer -> building
|
||||
10 balcony -> building
|
||||
11 roof_installation -> building
|
||||
12 wall -> building
|
||||
0 unclassified -> ignored
|
||||
4 water -> ignored (not a target class here)
|
||||
|
||||
Usage:
|
||||
python coarse_eval.py pred.ply gt.ply [more pairs ...]
|
||||
python coarse_eval.py --pred-dir DIR --gt-dir DIR
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from plyfile import PlyData
|
||||
|
||||
FINE = ['unclassified', 'terrain', 'high_vegetation', 'facade_surface',
|
||||
'water', 'car', 'boat', 'roof_surface', 'chimney', 'dormer',
|
||||
'balcony', 'roof_installation', 'wall']
|
||||
|
||||
COARSE = ['ignored', 'ground', 'vegetation', 'building', 'vehicle']
|
||||
|
||||
# fine index -> coarse index (0 = ignored)
|
||||
FINE_TO_COARSE = np.array([
|
||||
0, # unclassified
|
||||
1, # terrain -> ground
|
||||
2, # high_vegetation -> vegetation
|
||||
3, # facade_surface -> building
|
||||
0, # water -> ignored
|
||||
4, # car -> vehicle
|
||||
4, # boat -> vehicle
|
||||
3, # roof_surface -> building
|
||||
3, # chimney -> building
|
||||
3, # dormer -> building
|
||||
3, # balcony -> building
|
||||
3, # roof_installation -> building
|
||||
3, # wall -> building
|
||||
], dtype=np.int64)
|
||||
|
||||
# main.py's visualization writes colours, not labels; recover the class by
|
||||
# matching against the palette it used
|
||||
COLOR_MAP = np.array([
|
||||
(0., 0., 0.), (170., 85., 0.), (0., 255., 0.), (255., 255., 0.),
|
||||
(0., 255., 255.), (255., 0., 255.), (0., 0., 153.), (85., 85., 127.),
|
||||
(255., 50., 50.), (85., 0., 127.), (50., 125., 150.), (50., 0., 50.),
|
||||
(215., 160., 140.),
|
||||
])
|
||||
|
||||
|
||||
def load_labels(path: Path) -> np.ndarray:
|
||||
"""Fine class per point: from a `label` field, else decoded from colour."""
|
||||
v = PlyData.read(str(path))["vertex"]
|
||||
props = [p.name for p in v.properties]
|
||||
|
||||
if "label" in props:
|
||||
lab = np.asarray(v["label"]).astype(np.int64).ravel()
|
||||
# a prediction ply may carry a placeholder label; fall through if so
|
||||
if lab.max() >= 0 and not (lab == lab[0]).all():
|
||||
return lab
|
||||
if lab.max() >= 0 and lab[0] >= 0:
|
||||
return lab
|
||||
|
||||
rgb_set = next((s for s in (("red", "green", "blue"), ("r", "g", "b"))
|
||||
if all(c in props for c in s)), None)
|
||||
if rgb_set is None:
|
||||
raise SystemExit(f"{path}: no label field and no colour to decode")
|
||||
|
||||
rgb = np.stack([np.asarray(v[c], dtype=np.float64) for c in rgb_set], axis=1)
|
||||
if rgb.max() <= 1.0:
|
||||
rgb *= 255.0
|
||||
d = ((rgb[:, None, :] - COLOR_MAP[None, :, :]) ** 2).sum(axis=2)
|
||||
return d.argmin(axis=1).astype(np.int64)
|
||||
|
||||
|
||||
def confusion(pred: np.ndarray, true: np.ndarray, n: int) -> np.ndarray:
|
||||
k = (true >= 0) & (true < n) & (pred >= 0) & (pred < n)
|
||||
return np.bincount(true[k] * n + pred[k], minlength=n * n).reshape(n, n)
|
||||
|
||||
|
||||
def report(cm: np.ndarray, names: list[str], skip: set[int]) -> None:
|
||||
tp = np.diag(cm).astype(np.float64)
|
||||
actual = cm.sum(axis=1).astype(np.float64)
|
||||
predicted = cm.sum(axis=0).astype(np.float64)
|
||||
union = actual + predicted - tp
|
||||
|
||||
print(f" {'class':<12} {'IoU':>7} {'recall':>8} {'points':>12}")
|
||||
ious = []
|
||||
for i, name in enumerate(names):
|
||||
if i in skip:
|
||||
continue
|
||||
iou = 100.0 * tp[i] / union[i] if union[i] > 0 else 0.0
|
||||
rec = 100.0 * tp[i] / actual[i] if actual[i] > 0 else 0.0
|
||||
ious.append(iou)
|
||||
print(f" {name:<12} {iou:>6.2f}% {rec:>7.2f}% {int(actual[i]):>12,}")
|
||||
|
||||
scored = [i for i in range(len(names)) if i not in skip]
|
||||
oa_tp = tp[scored].sum()
|
||||
oa_n = actual[scored].sum()
|
||||
print()
|
||||
print(f" mIoU : {np.mean(ious):.2f}% (over {len(ious)} classes)")
|
||||
print(f" OA : {100.0 * oa_tp / oa_n if oa_n else 0:.2f}%")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("pairs", nargs="*", help="pred.ply gt.ply [pred gt ...]")
|
||||
ap.add_argument("--pred-dir", type=Path)
|
||||
ap.add_argument("--gt-dir", type=Path)
|
||||
args = ap.parse_args()
|
||||
|
||||
pairs: list[tuple[Path, Path]] = []
|
||||
if args.pred_dir and args.gt_dir:
|
||||
for p in sorted(args.pred_dir.glob("*_pred.ply")):
|
||||
stem = p.name.replace("_pred.ply", ".ply")
|
||||
g = args.gt_dir / stem
|
||||
if g.exists():
|
||||
pairs.append((p, g))
|
||||
else:
|
||||
print(f"warn: no ground truth for {p.name}", file=sys.stderr)
|
||||
else:
|
||||
if len(args.pairs) % 2:
|
||||
raise SystemExit("pairs must come as pred gt pred gt ...")
|
||||
pairs = [(Path(args.pairs[i]), Path(args.pairs[i + 1]))
|
||||
for i in range(0, len(args.pairs), 2)]
|
||||
|
||||
if not pairs:
|
||||
raise SystemExit("nothing to evaluate")
|
||||
|
||||
cm_fine = np.zeros((13, 13), dtype=np.int64)
|
||||
cm_coarse = np.zeros((5, 5), dtype=np.int64)
|
||||
|
||||
for pred_p, gt_p in pairs:
|
||||
pred = load_labels(pred_p)
|
||||
true = load_labels(gt_p)
|
||||
if len(pred) != len(true):
|
||||
print(f"warn: {pred_p.name} has {len(pred):,} points but "
|
||||
f"{gt_p.name} has {len(true):,} -- skipped", file=sys.stderr)
|
||||
continue
|
||||
print(f" + {gt_p.name} ({len(true):,} pts)")
|
||||
cm_fine += confusion(pred, true, 13)
|
||||
cm_coarse += confusion(FINE_TO_COARSE[pred], FINE_TO_COARSE[true], 5)
|
||||
|
||||
print()
|
||||
print("=== fine (13 SUM classes, benchmark granularity) ===")
|
||||
report(cm_fine, FINE, skip={0})
|
||||
|
||||
print()
|
||||
print("=== coarse (what this project needs) ===")
|
||||
report(cm_coarse, COARSE, skip={0})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env bash
|
||||
# SUM Parts - dump everything about the most recent run attempt
|
||||
set -uo pipefail
|
||||
|
||||
RUNROOT="$HOME/sum-parts/runs"
|
||||
D="${1:-$(cat "$RUNROOT/.latest" 2>/dev/null)}"
|
||||
|
||||
echo "run dir: $D"
|
||||
[ -d "$D" ] || { echo " (missing)"; exit 1; }
|
||||
ls -la "$D"
|
||||
|
||||
for f in watchdog.log nohup.out status.txt train.log; do
|
||||
echo
|
||||
echo "=== $f ==="
|
||||
if [ -f "$D/$f" ]; then
|
||||
tail -30 "$D/$f"
|
||||
else
|
||||
echo " (absent)"
|
||||
fi
|
||||
done
|
||||
|
||||
echo
|
||||
echo "=== processes ==="
|
||||
pgrep -af 'train_watchdog|main.py' || echo " none running"
|
||||
|
||||
echo
|
||||
echo "=== syntax check of watchdog ==="
|
||||
bash -n /mnt/d/MYCLAUDE_PROJECT/sum-parts-test/scripts/train_watchdog.sh && echo " OK"
|
||||
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env bash
|
||||
# SUM Parts - dataset download from Hugging Face
|
||||
#
|
||||
# PREREQUISITE: the dataset is GATED (gated: auto). Before this works you must,
|
||||
# once, in a browser:
|
||||
# 1. open https://huggingface.co/datasets/gwxgrxhyz/SUM-Parts
|
||||
# 2. log in and accept the CC BY-NC 4.0 terms on the gate form
|
||||
# Without that step every download returns HTTP 403.
|
||||
#
|
||||
# Usage:
|
||||
# bash download_data.sh # demo only (smoke test, small)
|
||||
# bash download_data.sh all # demo + mesh + pcl (large)
|
||||
set -euo pipefail
|
||||
|
||||
CONDA_ROOT="$HOME/miniconda3"
|
||||
ENV_NAME="sumparts"
|
||||
REPO_ID="gwxgrxhyz/SUM-Parts"
|
||||
# cfgs/sumv2_triangle/default.yaml uses data_root: ../../data/... relative to
|
||||
# PointNeXt_bundle, which resolves to <repo>/data/
|
||||
DATA_DIR="$HOME/sum-parts/data"
|
||||
DL_DIR="$DATA_DIR/_archives"
|
||||
|
||||
source "$CONDA_ROOT/etc/profile.d/conda.sh"
|
||||
conda activate "$ENV_NAME"
|
||||
|
||||
python -c "import huggingface_hub" 2>/dev/null || pip install --no-cache-dir "huggingface_hub[cli]"
|
||||
|
||||
# Reuse the token already cached on the Windows side if WSL has none.
|
||||
if [ ! -f "$HOME/.cache/huggingface/token" ] && [ -f /mnt/c/Users/"$USER"/.cache/huggingface/token ]; then
|
||||
mkdir -p "$HOME/.cache/huggingface"
|
||||
cp /mnt/c/Users/"$USER"/.cache/huggingface/token "$HOME/.cache/huggingface/token"
|
||||
chmod 600 "$HOME/.cache/huggingface/token"
|
||||
echo "copied HF token from Windows profile"
|
||||
fi
|
||||
|
||||
if [ "${1:-demo}" = "all" ]; then
|
||||
FILES=(demo.zip mesh.zip pcl.zip)
|
||||
else
|
||||
FILES=(demo.zip)
|
||||
fi
|
||||
|
||||
mkdir -p "$DL_DIR"
|
||||
|
||||
for f in "${FILES[@]}"; do
|
||||
echo "=== downloading $f ==="
|
||||
python - "$REPO_ID" "$f" "$DL_DIR" <<'PY'
|
||||
import sys
|
||||
from huggingface_hub import hf_hub_download
|
||||
repo_id, filename, out_dir = sys.argv[1:4]
|
||||
p = hf_hub_download(
|
||||
repo_id=repo_id,
|
||||
filename=filename,
|
||||
repo_type="dataset",
|
||||
local_dir=out_dir,
|
||||
)
|
||||
print("saved:", p)
|
||||
PY
|
||||
done
|
||||
|
||||
# Extract with python's zipfile rather than `unzip`: the distro has no unzip
|
||||
# installed and sudo needs a password here, so apt is not an option.
|
||||
for f in "${FILES[@]}"; do
|
||||
echo "=== extracting $f ==="
|
||||
python - "$DL_DIR/$f" "$DATA_DIR" <<'PY'
|
||||
import sys, zipfile
|
||||
src, dest = sys.argv[1:3]
|
||||
with zipfile.ZipFile(src) as z:
|
||||
names = z.namelist()
|
||||
print(f" {len(names)} entries")
|
||||
z.extractall(dest)
|
||||
print(" ->", dest)
|
||||
PY
|
||||
done
|
||||
|
||||
# NOTE: no `find ... | head` here. Under `set -euo pipefail`, head closing the
|
||||
# pipe sends SIGPIPE to find and the script exits 141 -- reported as a failed
|
||||
# download even though everything extracted fine.
|
||||
echo "=== resulting layout (depth 2) ==="
|
||||
find "$DATA_DIR" -maxdepth 2 -not -path '*/_archives/*' -type d | sort
|
||||
|
||||
echo
|
||||
echo "=== ply counts ==="
|
||||
for d in "$DATA_DIR"/*/; do
|
||||
[ "$(basename "$d")" = "_archives" ] && continue
|
||||
n=$(find "$d" -name '*.ply' 2>/dev/null | wc -l)
|
||||
[ "$n" -gt 0 ] && printf '%-28s %5s ply\n' "$(basename "$d")/" "$n"
|
||||
done
|
||||
|
||||
echo "DATA DONE"
|
||||
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env bash
|
||||
# SUM Parts - per-epoch wall time, to locate where a run slowed down
|
||||
set -uo pipefail
|
||||
|
||||
RUNROOT="$HOME/sum-parts/runs"
|
||||
WORKDIR="${1:-$(cat "$RUNROOT/.latest" 2>/dev/null)}"
|
||||
LOG="$WORKDIR/train.log"
|
||||
|
||||
[ -f "$LOG" ] || { echo "no train.log at $WORKDIR"; exit 1; }
|
||||
|
||||
grep -E 'Epoch [0-9]+ LR' "$LOG" | awk '
|
||||
{
|
||||
# [08/20 20:28:44 SUMV2_Triangle]: Epoch 10 LR ...
|
||||
# $1 $2 $3 $4 $5
|
||||
split($2, t, ":")
|
||||
sec = t[1]*3600 + t[2]*60 + t[3]
|
||||
for (i = 1; i <= NF; i++) if ($i == "Epoch") { ep = $(i+1); break }
|
||||
if (prev_sec != "") {
|
||||
d = sec - prev_sec
|
||||
if (d < 0) d += 86400 # crossed midnight
|
||||
printf "epoch %3d %s +%6.1f s", ep, t[1]":"t[2]":"t[3], d
|
||||
if (d > slow_thresh) printf " <-- SLOW"
|
||||
printf "\n"
|
||||
} else {
|
||||
printf "epoch %3d %s\n", ep, t[1]":"t[2]":"t[3]
|
||||
}
|
||||
prev_sec = sec
|
||||
}
|
||||
BEGIN { slow_thresh = 300 }
|
||||
'
|
||||
|
||||
echo
|
||||
echo "--- summary ---"
|
||||
grep -E 'Epoch [0-9]+ LR' "$LOG" | tail -1
|
||||
echo "epochs done: $(grep -cE 'Epoch [0-9]+ LR' "$LOG")"
|
||||
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env bash
|
||||
# SUM Parts - score the trained model on the classes this project actually needs
|
||||
#
|
||||
# Runs the sliding-window test() path over the VAL split (which has real labels,
|
||||
# unlike the blind test split), then re-scores the predictions after merging
|
||||
# SUM's 13 fine classes down to building / vegetation / vehicle / ground.
|
||||
set -uo pipefail
|
||||
|
||||
CONDA_ROOT="$HOME/miniconda3"
|
||||
SEG="$HOME/sum-parts/semantic_segmentation/PointNeXt_bundle/examples/segmentation"
|
||||
DATA="$HOME/sum-parts/data/face_labeling/texsp_pcl"
|
||||
SCRIPTS="/mnt/d/MYCLAUDE_PROJECT/sum-parts-test/scripts"
|
||||
OUT="$HOME/sum-parts/runs/coarse_eval"
|
||||
|
||||
source "$CONDA_ROOT/etc/profile.d/conda.sh"
|
||||
conda activate sumparts
|
||||
export WANDB_MODE=disabled WANDB_SILENT=true CUDA_HOME="$CONDA_PREFIX"
|
||||
export PYTORCH_CUDA_ALLOC_CONF="garbage_collection_threshold:0.7,max_split_size_mb:128"
|
||||
|
||||
mkdir -p "$OUT"
|
||||
cd "$SEG"
|
||||
|
||||
CKPT=$(find "$SEG/log/sumv2_triangle" -name '*_ckpt_best.pth' -printf '%T@ %p\n' \
|
||||
| sort -rn | head -1 | cut -d' ' -f2-)
|
||||
echo "checkpoint: $(basename "$CKPT")"
|
||||
|
||||
# test() writes prediction plys and slides over whole tiles; point it at val so
|
||||
# there is ground truth to score against.
|
||||
#
|
||||
# TEST_VOXEL_MAX matters more than it looks. PointNet max-pools ONE global
|
||||
# feature over whatever it is handed, so the number of points per forward pass
|
||||
# is part of the model's operating point, not a neutral batching knob. Training
|
||||
# used 64000-point crops; the cfg's test default (null) feeds ~350k-point chunks
|
||||
# and the predictions collapse toward the majority class. Set it to 64000 to
|
||||
# make inference match training.
|
||||
TEST_VOXEL_MAX="${TEST_VOXEL_MAX:-}"
|
||||
VM_ARG=()
|
||||
[ -n "$TEST_VOXEL_MAX" ] && VM_ARG=(dataset.test.voxel_max="$TEST_VOXEL_MAX")
|
||||
|
||||
echo "=== inference over val split (sliding window) ==="
|
||||
echo " test voxel_max: ${TEST_VOXEL_MAX:-null (cfg default)}"
|
||||
python -u main.py \
|
||||
--cfg ../../cfgs/sumv2_triangle/pointnet.yaml \
|
||||
mode=test \
|
||||
--pretrained_path "$CKPT" \
|
||||
dataset.common.data_root="$DATA" \
|
||||
dataset.test.split=val \
|
||||
"${VM_ARG[@]}" \
|
||||
wandb.use_wandb=False \
|
||||
> "$OUT/infer.log" 2>&1
|
||||
rc=$?
|
||||
if [ $rc -ne 0 ]; then
|
||||
echo " FAILED rc=$rc"
|
||||
tail -15 "$OUT/infer.log"
|
||||
exit $rc
|
||||
fi
|
||||
grep -aE 'test_oa|iou per cls' "$OUT/infer.log" | tail -4
|
||||
|
||||
VIS=$(dirname "$(dirname "$CKPT")")/visualization
|
||||
echo
|
||||
echo "=== predictions in $VIS ==="
|
||||
ls -1 "$VIS" | grep -c '_pred.ply' || true
|
||||
|
||||
echo
|
||||
echo "=== re-scoring at project granularity ==="
|
||||
python "$SCRIPTS/coarse_eval.py" --pred-dir "$VIS" --gt-dir "$DATA/val" \
|
||||
| tee "$OUT/coarse.txt"
|
||||
|
||||
echo
|
||||
echo "logs in $OUT"
|
||||
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env bash
|
||||
# SUM Parts - final evaluation of the trained checkpoint
|
||||
#
|
||||
# Two passes, because the splits differ in what they can tell you:
|
||||
#
|
||||
# val : labeled (0..12) -> produces real numbers you can quote locally
|
||||
# test : label = -1 everywhere (blind set) -> produces predictions only.
|
||||
# The authors score it; see the README's "send predictions to our
|
||||
# email for local assessment".
|
||||
#
|
||||
# Requires patch_unlabeled_test.sh and patch_val_mode.sh to have run, otherwise
|
||||
# the test pass dies in ConfusionMatrix on the -1 placeholders and mode=val dies
|
||||
# with UnboundLocalError on `epoch`.
|
||||
#
|
||||
# No voxel_max override here on purpose. The cfg already validates with
|
||||
# voxel_max: null (whole tiles), which is what we want for the final number --
|
||||
# training capped it only to keep the allocator inside VRAM. Passing
|
||||
# `dataset.val.voxel_max=null` on the command line does NOT work: it arrives as
|
||||
# the string "null" and crop_pc then compares int >= str.
|
||||
set -uo pipefail
|
||||
|
||||
CONDA_ROOT="$HOME/miniconda3"
|
||||
SEG="$HOME/sum-parts/semantic_segmentation/PointNeXt_bundle/examples/segmentation"
|
||||
DATA="$HOME/sum-parts/data/face_labeling/texsp_pcl"
|
||||
OUT="$HOME/sum-parts/runs/final_eval"
|
||||
|
||||
source "$CONDA_ROOT/etc/profile.d/conda.sh"
|
||||
conda activate sumparts
|
||||
export WANDB_MODE=disabled WANDB_SILENT=true CUDA_HOME="$CONDA_PREFIX"
|
||||
export PYTORCH_CUDA_ALLOC_CONF="garbage_collection_threshold:0.7,max_split_size_mb:128"
|
||||
|
||||
mkdir -p "$OUT"
|
||||
|
||||
CKPT=$(find "$SEG/log/sumv2_triangle" -name '*_ckpt_best.pth' -printf '%T@ %p\n' \
|
||||
| sort -rn | head -1 | cut -d' ' -f2-)
|
||||
[ -n "$CKPT" ] || { echo "error: no checkpoint found" >&2; exit 1; }
|
||||
|
||||
echo "checkpoint: $CKPT"
|
||||
echo "data : $DATA"
|
||||
echo
|
||||
|
||||
cd "$SEG"
|
||||
|
||||
run_mode() {
|
||||
local mode="$1" log="$OUT/${1}.log"
|
||||
echo "=== mode=$mode ==="
|
||||
set +e
|
||||
python -u main.py \
|
||||
--cfg ../../cfgs/sumv2_triangle/pointnet.yaml \
|
||||
mode="$mode" \
|
||||
--pretrained_path "$CKPT" \
|
||||
dataset.common.data_root="$DATA" \
|
||||
wandb.use_wandb=False \
|
||||
val_batch_size=1 \
|
||||
> "$log" 2>&1
|
||||
local rc=$?
|
||||
set -e
|
||||
if [ $rc -eq 0 ]; then
|
||||
echo " ok"
|
||||
else
|
||||
echo " FAILED rc=$rc"
|
||||
tail -12 "$log"
|
||||
fi
|
||||
grep -aE 'val_oa|test_oa|iou per cls|Best ckpt' "$log" | tail -6
|
||||
echo
|
||||
return $rc
|
||||
}
|
||||
|
||||
# val first: this is the number we can actually stand behind locally
|
||||
run_mode val
|
||||
val_rc=$?
|
||||
|
||||
# test: predictions only, no score possible
|
||||
run_mode test
|
||||
test_rc=$?
|
||||
|
||||
echo "=== prediction files ==="
|
||||
find "$SEG/log/sumv2_triangle" -name '*_pred.ply' -newermt '-30 minutes' \
|
||||
-printf '%p (%s bytes)\n' 2>/dev/null | tail -12
|
||||
|
||||
echo
|
||||
echo "logs in $OUT"
|
||||
[ $val_rc -eq 0 ] && echo "FINAL EVAL: val OK" || echo "FINAL EVAL: val FAILED"
|
||||
[ $test_rc -eq 0 ] && echo "FINAL EVAL: test OK" || echo "FINAL EVAL: test FAILED"
|
||||
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Stream an OBJ and report the numbers that decide how to tile it for SUM Parts.
|
||||
|
||||
Reads line by line so a multi-GB mesh does not have to fit in RAM. Reports
|
||||
vertex/face counts, the bounding box, whether UVs and vertex colours are
|
||||
present, and the material/texture references.
|
||||
|
||||
Usage:
|
||||
python inspect_obj.py path/to/Block.obj [more.obj ...]
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def inspect(path: Path) -> dict:
|
||||
n_v = n_vt = n_vn = n_f = 0
|
||||
has_vertex_color = False
|
||||
mtllib: list[str] = []
|
||||
usemtl: set[str] = set()
|
||||
lo = [float("inf")] * 3
|
||||
hi = [float("-inf")] * 3
|
||||
|
||||
with path.open("r", encoding="utf-8", errors="replace") as f:
|
||||
for line in f:
|
||||
if line.startswith("v "):
|
||||
n_v += 1
|
||||
parts = line.split()
|
||||
x, y, z = float(parts[1]), float(parts[2]), float(parts[3])
|
||||
if x < lo[0]: lo[0] = x
|
||||
if y < lo[1]: lo[1] = y
|
||||
if z < lo[2]: lo[2] = z
|
||||
if x > hi[0]: hi[0] = x
|
||||
if y > hi[1]: hi[1] = y
|
||||
if z > hi[2]: hi[2] = z
|
||||
# "v x y z r g b" is how some exporters carry per-vertex colour
|
||||
if len(parts) >= 7:
|
||||
has_vertex_color = True
|
||||
elif line.startswith("vt "):
|
||||
n_vt += 1
|
||||
elif line.startswith("vn "):
|
||||
n_vn += 1
|
||||
elif line.startswith("f "):
|
||||
n_f += 1
|
||||
elif line.startswith("mtllib"):
|
||||
mtllib.append(line.split(maxsplit=1)[1].strip())
|
||||
elif line.startswith("usemtl"):
|
||||
usemtl.add(line.split(maxsplit=1)[1].strip())
|
||||
|
||||
return {
|
||||
"path": path,
|
||||
"size_mb": path.stat().st_size / 1024 / 1024,
|
||||
"n_v": n_v, "n_vt": n_vt, "n_vn": n_vn, "n_f": n_f,
|
||||
"vertex_color": has_vertex_color,
|
||||
"mtllib": mtllib, "usemtl": sorted(usemtl),
|
||||
"lo": lo, "hi": hi,
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if len(sys.argv) < 2:
|
||||
print(__doc__)
|
||||
raise SystemExit(2)
|
||||
|
||||
for arg in sys.argv[1:]:
|
||||
p = Path(arg)
|
||||
if not p.exists():
|
||||
print(f"{arg}: not found")
|
||||
continue
|
||||
|
||||
r = inspect(p)
|
||||
ext = [r["hi"][i] - r["lo"][i] for i in range(3)]
|
||||
area = ext[0] * ext[1]
|
||||
|
||||
print(f"=== {p.name} ({r['size_mb']:,.0f} MB) ===")
|
||||
print(f" vertices : {r['n_v']:>12,}")
|
||||
print(f" faces : {r['n_f']:>12,}")
|
||||
print(f" texcoords : {r['n_vt']:>12,} {'(UV present)' if r['n_vt'] else '(NO UV)'}")
|
||||
print(f" normals : {r['n_vn']:>12,}")
|
||||
print(f" vtx color : {r['vertex_color']}")
|
||||
print(f" mtllib : {r['mtllib']}")
|
||||
print(f" materials : {len(r['usemtl'])} -> {r['usemtl'][:5]}"
|
||||
f"{' ...' if len(r['usemtl']) > 5 else ''}")
|
||||
print(f" bbox min : {[round(v, 2) for v in r['lo']]}")
|
||||
print(f" bbox max : {[round(v, 2) for v in r['hi']]}")
|
||||
print(f" extent : {[round(v, 2) for v in ext]} (metres, local)")
|
||||
print(f" footprint : {area / 1e6:.3f} km^2")
|
||||
if area:
|
||||
print(f" density : {r['n_f'] / area:,.1f} faces/m^2")
|
||||
# SUM Parts tiles are ~252 m square; this is how many that footprint is
|
||||
print(f" ~252m tiles: {max(1, round(ext[0] / 252)) * max(1, round(ext[1] / 252))}")
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
# SUM Parts - look at the actual epoch lines, to check for interleaving or
|
||||
# a non-monotonic "best val miou"
|
||||
set -uo pipefail
|
||||
|
||||
RUNROOT="$HOME/sum-parts/runs"
|
||||
D="${1:-$(cat "$RUNROOT/.latest" 2>/dev/null)}"
|
||||
LOG="$D/train.log"
|
||||
|
||||
echo "run: $D"
|
||||
[ -f "$LOG" ] || { echo " no train.log"; exit 1; }
|
||||
|
||||
echo
|
||||
echo "=== last 14 epoch lines ==="
|
||||
grep -aE 'Epoch [0-9]+ LR' "$LOG" | tail -14
|
||||
|
||||
echo
|
||||
echo "=== 'Find a better ckpt' events (last 8) ==="
|
||||
grep -aE 'Find a better ckpt' "$LOG" | tail -8
|
||||
|
||||
echo
|
||||
echo "=== how many distinct run_names wrote to this log ==="
|
||||
grep -aoE 'sumv2_triangle-train-pointnet-ngpus1-[0-9]{8}-[0-9]{6}-[A-Za-z0-9]+' "$LOG" \
|
||||
| sort -u | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "=== effective val_freq (Val: bars per epoch) ==="
|
||||
echo " epoch lines : $(grep -acE 'Epoch [0-9]+ LR' "$LOG")"
|
||||
echo " val passes : $(grep -aoc 'Val: 100%' "$LOG" 2>/dev/null || echo '?')"
|
||||
|
||||
echo
|
||||
echo "=== processes ==="
|
||||
pgrep -af 'main.py --cfg' | awk '{print " pid " $1}' | head
|
||||
echo " count: $(pgrep -cf 'main.py --cfg')"
|
||||
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env bash
|
||||
# SUM Parts - compare early vs recent per-iteration speed in a running train log
|
||||
#
|
||||
# Separates "each iteration got slower" from "something between epochs got
|
||||
# slower" (validation, checkpointing, dataloader restart).
|
||||
set -uo pipefail
|
||||
|
||||
RUNROOT="$HOME/sum-parts/runs"
|
||||
WORKDIR="${1:-$(cat "$RUNROOT/.latest" 2>/dev/null)}"
|
||||
LOG="$WORKDIR/train.log"
|
||||
|
||||
[ -f "$LOG" ] || { echo "no train.log at ${WORKDIR:-<unset>}"; exit 1; }
|
||||
|
||||
echo "log: $LOG"
|
||||
echo
|
||||
|
||||
echo "=== earliest 15 rate readings ==="
|
||||
grep -oE '[0-9.]+(s/it|it/s)' "$LOG" | head -15 | tr '\n' ' '
|
||||
echo
|
||||
echo
|
||||
|
||||
echo "=== most recent 15 rate readings ==="
|
||||
grep -oE '[0-9.]+(s/it|it/s)' "$LOG" | tail -15 | tr '\n' ' '
|
||||
echo
|
||||
echo
|
||||
|
||||
echo "=== Train Epoch progress lines (first / last) ==="
|
||||
grep -oE 'Train Epoch \[[0-9]+/[0-9]+\] Loss [0-9.]+ Acc [0-9.]+' "$LOG" | head -3
|
||||
echo " ..."
|
||||
grep -oE 'Train Epoch \[[0-9]+/[0-9]+\] Loss [0-9.]+ Acc [0-9.]+' "$LOG" | tail -3
|
||||
echo
|
||||
|
||||
echo "=== log growth ==="
|
||||
echo " size : $(du -h "$LOG" | cut -f1)"
|
||||
echo " last write: $(date -r "$LOG" '+%F %T') (now $(date '+%F %T'))"
|
||||
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env bash
|
||||
# SUM Parts - start an unattended training run and return immediately
|
||||
#
|
||||
# setsid detaches the watchdog into its own session, so it keeps running after
|
||||
# the launching shell, the Claude Code session, or the Windows terminal goes
|
||||
# away. It does NOT survive `wsl --shutdown` or a Windows reboot -- nothing run
|
||||
# inside WSL does.
|
||||
#
|
||||
# Usage:
|
||||
# bash launch_overnight.sh # pointnet, 100 epochs
|
||||
# EPOCHS=100 bash launch_overnight.sh pointnet
|
||||
#
|
||||
# Then check on it any time with:
|
||||
# bash check_training.sh
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPTS="/mnt/d/MYCLAUDE_PROJECT/sum-parts-test/scripts"
|
||||
CFG="${1:-pointnet}"
|
||||
STAMP=$(date +%Y%m%d-%H%M%S)
|
||||
WORKDIR="$HOME/sum-parts/runs/${CFG}_${STAMP}"
|
||||
|
||||
mkdir -p "$WORKDIR"
|
||||
echo "$WORKDIR" > "$HOME/sum-parts/runs/.latest"
|
||||
|
||||
# already running?
|
||||
if pgrep -f "train_watchdog.sh" > /dev/null; then
|
||||
echo "a watchdog is already running:"
|
||||
pgrep -af "train_watchdog.sh"
|
||||
echo
|
||||
echo "stop it first with: bash $SCRIPTS/stop_training.sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
STAMP="$STAMP" EPOCHS="${EPOCHS:-100}" VAL_FREQ="${VAL_FREQ:-5}" \
|
||||
MAX_RETRIES="${MAX_RETRIES:-8}" CFG_VOXEL_MAX="${CFG_VOXEL_MAX:-}" \
|
||||
RESUME_CKPT="${RESUME_CKPT:-}" \
|
||||
setsid nohup bash "$SCRIPTS/train_watchdog.sh" "$CFG" \
|
||||
> "$WORKDIR/nohup.out" 2>&1 < /dev/null &
|
||||
|
||||
sleep 3
|
||||
|
||||
echo "launched: $CFG"
|
||||
echo "workdir : $WORKDIR"
|
||||
echo
|
||||
pgrep -af "train_watchdog.sh" || echo "WARNING: watchdog not visible in process list"
|
||||
echo
|
||||
echo "check progress: bash $SCRIPTS/check_training.sh"
|
||||
echo "stop : bash $SCRIPTS/stop_training.sh"
|
||||
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env bash
|
||||
# SUM Parts - point the cfg's data_root at the downloaded dataset
|
||||
#
|
||||
# cfgs/sumv2_triangle/default.yaml sets:
|
||||
# data_root: ../../data/sumv2_tri_texpcl/
|
||||
# That path is resolved from the *working directory* of main.py, which is
|
||||
# examples/segmentation. So ../../ is PointNeXt_bundle, NOT the repo root --
|
||||
# the dataset has to be reachable at PointNeXt_bundle/data/.
|
||||
#
|
||||
# download_data.sh puts the data in <repo>/data, so link the two.
|
||||
set -euo pipefail
|
||||
|
||||
REPO_DATA="$HOME/sum-parts/data"
|
||||
BUNDLE="$HOME/sum-parts/semantic_segmentation/PointNeXt_bundle"
|
||||
|
||||
if [ ! -d "$REPO_DATA" ]; then
|
||||
echo "error: $REPO_DATA missing -- run download_data.sh first" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -e "$BUNDLE/data" ] && [ ! -L "$BUNDLE/data" ]; then
|
||||
echo "error: $BUNDLE/data exists and is not a symlink; refusing to replace" >&2
|
||||
ls -la "$BUNDLE/data" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ln -sfn "$REPO_DATA" "$BUNDLE/data"
|
||||
echo "linked: $BUNDLE/data -> $REPO_DATA"
|
||||
|
||||
echo "=== resolution check (as main.py sees it) ==="
|
||||
cd "$BUNDLE/examples/segmentation"
|
||||
for track in sumv2_tri_texpcl sumv2_tex_texpcl; do
|
||||
for split in train val test; do
|
||||
d="../../data/$track/$split"
|
||||
n=$(find "$d" -name '*.ply' 2>/dev/null | wc -l)
|
||||
printf '%-18s %-6s %s ply\n' "$track" "$split" "$n"
|
||||
done
|
||||
done
|
||||
|
||||
echo "LINK DONE"
|
||||
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env bash
|
||||
# SUM Parts - list an archive's contents without extracting it
|
||||
#
|
||||
# Used to count tiles per split, which is what turns a per-iteration benchmark
|
||||
# into a wall-clock training estimate.
|
||||
set -euo pipefail
|
||||
|
||||
ARCHIVE="${1:-$HOME/sum-parts/data/_archives/mesh.zip}"
|
||||
|
||||
source "$HOME/miniconda3/etc/profile.d/conda.sh"
|
||||
conda activate sumparts
|
||||
|
||||
python - "$ARCHIVE" <<'PY'
|
||||
import re
|
||||
import sys
|
||||
import zipfile
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
path = Path(sys.argv[1])
|
||||
z = zipfile.ZipFile(path)
|
||||
names = z.namelist()
|
||||
|
||||
print(f"=== {path.name} : {len(names)} entries ===\n")
|
||||
|
||||
dirs = Counter()
|
||||
for n in names:
|
||||
p = "/".join(n.split("/")[:-1]) or "."
|
||||
dirs[p] += 1
|
||||
for d, c in sorted(dirs.items()):
|
||||
print(f" {c:>5} {d}/")
|
||||
|
||||
print()
|
||||
splits = Counter()
|
||||
for n in names:
|
||||
m = re.search(r"(?:^|/)(train|val|test|training|validation)(?:/|_)", n, re.I)
|
||||
if m:
|
||||
splits[m.group(1).lower()] += 1
|
||||
if splits:
|
||||
print("split hints:", dict(splits))
|
||||
|
||||
ply = [n for n in names if n.lower().endswith(".ply")]
|
||||
print(f"\n.ply entries: {len(ply)}")
|
||||
|
||||
per_split = Counter(n.split("/")[0] for n in ply)
|
||||
for s, c in sorted(per_split.items()):
|
||||
print(f" {c:>4} ply in {s}/")
|
||||
|
||||
for n in ply[:5]:
|
||||
print(f" e.g. {n}")
|
||||
|
||||
total = sum(i.file_size for i in z.infolist())
|
||||
print(f"\nuncompressed total: {total / 1e9:.2f} GB")
|
||||
PY
|
||||
@@ -0,0 +1,279 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Convert a textured mesh into the PLY point cloud SUM Parts expects.
|
||||
|
||||
Target schema, from openpoints/dataset/sumv2_triangle/sumv2_triangle.py
|
||||
(read_ply_with_plyfilelib) and confirmed against the shipped demo tile:
|
||||
|
||||
element vertex N
|
||||
property float x / y / z
|
||||
property uchar red / green / blue (the demo uses r/g/b; the loader
|
||||
accepts either spelling)
|
||||
property uchar label 0 == unclassified, which cfg ignores
|
||||
property int object_index optional
|
||||
|
||||
Written for ContextCapture-style aerial OBJ exports, which are the shape the
|
||||
Seosan Myeongcheon blocks come in:
|
||||
|
||||
* multi-material -- one texture atlas per material (17-21 per block), so the
|
||||
mesh has to be sampled per material, not as one merged blob. Merging with
|
||||
force='mesh' silently drops every texture and the output comes out grey.
|
||||
* no vertex colours -- the texture is the ONLY colour source. There is no
|
||||
fallback to degrade to.
|
||||
* coordinates already local and metric, with the true origin recorded in
|
||||
metadata.xml as SRSOrigin (EPSG:5186+9999). Same convention SUM Parts
|
||||
itself ships, so no reprojection is needed -- but never feed raw easting/
|
||||
northing or degrees, since every cfg radius and voxel size is in metres.
|
||||
|
||||
--bbox crops before sampling, which is how you get one ~252 m SUM-sized tile
|
||||
out of a 465 x 487 m block.
|
||||
|
||||
Usage:
|
||||
python mesh_to_ply.py Block.obj out.ply --points 500000
|
||||
python mesh_to_ply.py Block.obj tile.ply --bbox 300 700 552 952
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
def die(msg: str) -> None:
|
||||
print(f"error: {msg}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
def log(msg: str) -> None:
|
||||
print(f"[{time.strftime('%H:%M:%S')}] {msg}", flush=True)
|
||||
|
||||
|
||||
def load_scene(path: Path):
|
||||
"""Load as a Scene so each material keeps its own texture."""
|
||||
try:
|
||||
import trimesh
|
||||
except ImportError:
|
||||
die("trimesh not installed. run: pip install trimesh pillow")
|
||||
|
||||
log(f"loading {path.name} ({path.stat().st_size / 1e6:,.0f} MB) ...")
|
||||
scene = trimesh.load(path, process=False)
|
||||
|
||||
if isinstance(scene, trimesh.Trimesh):
|
||||
geoms = [scene]
|
||||
else:
|
||||
geoms = list(scene.geometry.values())
|
||||
|
||||
geoms = [g for g in geoms if getattr(g, "faces", None) is not None and len(g.faces)]
|
||||
if not geoms:
|
||||
die(f"{path} has no faces")
|
||||
|
||||
log(f"loaded {len(geoms)} geometry group(s), "
|
||||
f"{sum(len(g.faces) for g in geoms):,} faces total")
|
||||
return geoms
|
||||
|
||||
|
||||
def crop(geom, bbox):
|
||||
"""Keep faces whose centroid falls inside the XY bbox. Returns a mask."""
|
||||
if bbox is None:
|
||||
return np.ones(len(geom.faces), dtype=bool)
|
||||
x0, y0, x1, y1 = bbox
|
||||
c = geom.vertices[geom.faces].mean(axis=1)
|
||||
return (c[:, 0] >= x0) & (c[:, 0] < x1) & (c[:, 1] >= y0) & (c[:, 1] < y1)
|
||||
|
||||
|
||||
def sample_geom(geom, face_mask, n_points, seed):
|
||||
"""Area-weighted sampling restricted to the masked faces, with colour."""
|
||||
import trimesh
|
||||
|
||||
idx = np.flatnonzero(face_mask)
|
||||
if idx.size == 0 or n_points <= 0:
|
||||
return np.empty((0, 3)), np.empty((0, 3), dtype=np.uint8)
|
||||
|
||||
tri = geom.vertices[geom.faces[idx]] # (m, 3, 3)
|
||||
area = trimesh.triangles.area(tri)
|
||||
total = area.sum()
|
||||
if total <= 0:
|
||||
return np.empty((0, 3)), np.empty((0, 3), dtype=np.uint8)
|
||||
|
||||
rng = np.random.default_rng(seed)
|
||||
pick = rng.choice(idx.size, size=n_points, p=area / total)
|
||||
|
||||
# uniform barycentric coordinates over each chosen triangle
|
||||
r1, r2 = rng.random(n_points), rng.random(n_points)
|
||||
s = np.sqrt(r1)
|
||||
bary = np.stack([1 - s, s * (1 - r2), s * r2], axis=1) # (n, 3)
|
||||
|
||||
xyz = (tri[pick] * bary[:, :, None]).sum(axis=1)
|
||||
rgb = colour(geom, idx[pick], bary)
|
||||
return xyz, rgb
|
||||
|
||||
|
||||
def colour(geom, face_idx, bary) -> np.ndarray:
|
||||
"""Per-point RGB from the geometry's texture; grey if it has none."""
|
||||
n = len(face_idx)
|
||||
visual = getattr(geom, "visual", None)
|
||||
|
||||
uv = getattr(visual, "uv", None)
|
||||
material = getattr(visual, "material", None)
|
||||
image = getattr(material, "image", None) if material is not None else None
|
||||
|
||||
if uv is not None and image is not None:
|
||||
try:
|
||||
tri_uv = np.asarray(uv)[geom.faces[face_idx]] # (n, 3, 2)
|
||||
point_uv = (tri_uv * bary[:, :, None]).sum(axis=1)
|
||||
img = np.asarray(image.convert("RGB"))
|
||||
h, w = img.shape[:2]
|
||||
# OBJ UV origin is bottom-left; image rows run top-down
|
||||
px = np.clip((point_uv[:, 0] % 1.0) * (w - 1), 0, w - 1).astype(np.int32)
|
||||
py = np.clip((1.0 - point_uv[:, 1] % 1.0) * (h - 1), 0, h - 1).astype(np.int32)
|
||||
return img[py, px].astype(np.uint8)
|
||||
except Exception as e: # noqa: BLE001 - exporters vary wildly
|
||||
print(f"warn: texture sampling failed ({e})", file=sys.stderr)
|
||||
|
||||
vc = getattr(visual, "vertex_colors", None)
|
||||
if vc is not None and len(vc) == len(geom.vertices):
|
||||
vc = np.asarray(vc)[:, :3].astype(np.float64)
|
||||
return (vc[geom.faces[face_idx]] * bary[:, :, None]).sum(axis=1).astype(np.uint8)
|
||||
|
||||
return np.full((n, 3), 128, dtype=np.uint8)
|
||||
|
||||
|
||||
def write_ply(path: Path, xyz, rgb, label: int, object_index: int | None,
|
||||
colour_style: str = "sum") -> None:
|
||||
"""Write the point cloud.
|
||||
|
||||
colour_style='sum' reproduces exactly what SUM Parts ships: fields named
|
||||
r/g/b holding float32 in [0, 1]. That match is not cosmetic. The loader
|
||||
normalises nothing --
|
||||
|
||||
rgb = np.stack([...'red','green','blue'...]).astype(np.uint8)
|
||||
except ValueError:
|
||||
rgb = np.stack([...'r','g','b'...]).astype(np.float32)
|
||||
if np.max(rgb) > 1:
|
||||
rgb = rgb # <- no-op, despite how it reads
|
||||
|
||||
-- and the active datatransforms in cfgs/sumv2_triangle/default.yaml are
|
||||
[PointsToTensor, PointCloudScaling, PointCloudRotation, PointCloudJitter];
|
||||
NumpyChromaticNormalize is commented out. So whatever is in the file reaches
|
||||
the network unscaled. Writing 0-255 where the training data was 0-1 hands
|
||||
the model colour features 255x too large.
|
||||
|
||||
colour_style='uint8' writes red/green/blue as uint8 instead, for viewers
|
||||
that expect the conventional PLY spelling.
|
||||
"""
|
||||
try:
|
||||
from plyfile import PlyData, PlyElement
|
||||
except ImportError:
|
||||
die("plyfile not installed. run: pip install plyfile")
|
||||
|
||||
if colour_style == "sum":
|
||||
cols = [("r", "f4"), ("g", "f4"), ("b", "f4")]
|
||||
vals = (rgb.astype(np.float32) / 255.0)
|
||||
else:
|
||||
cols = [("red", "u1"), ("green", "u1"), ("blue", "u1")]
|
||||
vals = rgb
|
||||
|
||||
# demo tiles store label as int32
|
||||
dtype = [("x", "f4"), ("y", "f4"), ("z", "f4"), *cols, ("label", "i4")]
|
||||
if object_index is not None:
|
||||
dtype.append(("object_index", "i4"))
|
||||
|
||||
arr = np.empty(len(xyz), dtype=dtype)
|
||||
arr["x"], arr["y"], arr["z"] = xyz[:, 0], xyz[:, 1], xyz[:, 2]
|
||||
for i, (name, _) in enumerate(cols):
|
||||
arr[name] = vals[:, i]
|
||||
arr["label"] = label
|
||||
if object_index is not None:
|
||||
arr["object_index"] = object_index
|
||||
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
PlyData([PlyElement.describe(arr, "vertex")], text=False).write(str(path))
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(
|
||||
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("input", type=Path)
|
||||
ap.add_argument("output", type=Path)
|
||||
ap.add_argument("--points", type=int, default=500_000,
|
||||
help="total points to sample (default: 500000)")
|
||||
ap.add_argument("--bbox", type=float, nargs=4, metavar=("X0", "Y0", "X1", "Y1"),
|
||||
help="crop to this XY box in the mesh's own local coords")
|
||||
ap.add_argument("--label", type=int, default=0,
|
||||
help="constant label to stamp; 0 = unclassified (default: 0)")
|
||||
ap.add_argument("--object-index", type=int, default=None)
|
||||
ap.add_argument("--colour-style", choices=("sum", "uint8"), default="sum",
|
||||
help="'sum' (default): r/g/b float32 0-1, byte-for-byte what "
|
||||
"SUM Parts ships. 'uint8': red/green/blue 0-255 for viewers")
|
||||
ap.add_argument("--keep-origin", action="store_true",
|
||||
help="do NOT translate the result to a local origin")
|
||||
ap.add_argument("--seed", type=int, default=0)
|
||||
args = ap.parse_args()
|
||||
|
||||
if not args.input.exists():
|
||||
die(f"{args.input} not found")
|
||||
if not 0 <= args.label <= 255:
|
||||
die("--label must fit in a uint8 (0-255)")
|
||||
|
||||
geoms = load_scene(args.input)
|
||||
|
||||
# Budget points per geometry by cropped surface area, so a material that
|
||||
# covers half the tile gets half the points.
|
||||
import trimesh
|
||||
masks, areas = [], []
|
||||
for g in geoms:
|
||||
m = crop(g, args.bbox)
|
||||
masks.append(m)
|
||||
a = trimesh.triangles.area(g.vertices[g.faces[m]]).sum() if m.any() else 0.0
|
||||
areas.append(a)
|
||||
|
||||
total_area = float(sum(areas))
|
||||
if total_area <= 0:
|
||||
die("nothing left after cropping -- check --bbox against the mesh bbox")
|
||||
log(f"cropped surface area: {total_area:,.0f} m^2 across "
|
||||
f"{sum(int(m.sum()) for m in masks):,} faces")
|
||||
|
||||
parts_xyz, parts_rgb = [], []
|
||||
for i, (g, m, a) in enumerate(zip(geoms, masks, areas)):
|
||||
if a <= 0:
|
||||
continue
|
||||
n = int(round(args.points * a / total_area))
|
||||
if n <= 0:
|
||||
continue
|
||||
xyz, rgb = sample_geom(g, m, n, args.seed + i)
|
||||
if len(xyz):
|
||||
parts_xyz.append(xyz)
|
||||
parts_rgb.append(rgb)
|
||||
log(f" geom {i:>3}: {int(m.sum()):>8,} faces {a:>12,.0f} m^2 -> {len(xyz):>8,} pts")
|
||||
|
||||
if not parts_xyz:
|
||||
die("no points sampled")
|
||||
|
||||
xyz = np.vstack(parts_xyz)
|
||||
rgb = np.vstack(parts_rgb)
|
||||
|
||||
lo, hi = xyz.min(axis=0), xyz.max(axis=0)
|
||||
log(f"extent: {np.round(hi - lo, 2)} m origin: {np.round(lo, 2)}")
|
||||
|
||||
if not args.keep_origin:
|
||||
xyz = xyz - lo
|
||||
log("translated to local origin (min corner -> 0,0,0)")
|
||||
|
||||
if (hi - lo).max() < 0.5:
|
||||
print("warn: extent under 0.5 units -- looks like degrees, not metres. "
|
||||
"Reproject to a metric CRS (e.g. EPSG:5186) first.", file=sys.stderr)
|
||||
|
||||
grey = int((rgb == 128).all(axis=1).sum())
|
||||
if grey:
|
||||
print(f"warn: {grey:,}/{len(rgb):,} points fell back to grey "
|
||||
"(missing texture)", file=sys.stderr)
|
||||
|
||||
write_ply(args.output, xyz, rgb, args.label, args.object_index, args.colour_style)
|
||||
log(f"wrote {args.output} ({len(xyz):,} points, label={args.label}, "
|
||||
f"colour={args.colour_style})")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env bash
|
||||
# SUM Parts - emit training progress as discrete events, exit when the run ends
|
||||
#
|
||||
# Designed to be selective: a 100-epoch run writes ~100 epoch lines, which would
|
||||
# be 100 notifications. So this emits every Nth epoch, plus anything that would
|
||||
# change what to do next -- watchdog retries, tracebacks, OOM, GPU trouble, and
|
||||
# the final outcome.
|
||||
#
|
||||
# Silence must not be mistakable for success, so the terminal check keys off the
|
||||
# watchdog process disappearing, not off a success marker appearing.
|
||||
set -uo pipefail
|
||||
|
||||
RUNROOT="$HOME/sum-parts/runs"
|
||||
EVERY="${EVERY:-10}" # emit one progress line per this many epochs
|
||||
POLL="${POLL:-120}"
|
||||
TOTAL="${TOTAL:-100}" # target epoch count, for the "n/TOTAL" display
|
||||
|
||||
WORKDIR="${1:-}"
|
||||
if [ -z "$WORKDIR" ]; then
|
||||
WORKDIR=$(cat "$RUNROOT/.latest" 2>/dev/null || true)
|
||||
fi
|
||||
if [ -z "$WORKDIR" ] || [ ! -d "$WORKDIR" ]; then
|
||||
echo "MONITOR-ERROR: no run directory (looked at $RUNROOT/.latest)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TRAINLOG="$WORKDIR/train.log"
|
||||
WDLOG="$WORKDIR/watchdog.log"
|
||||
|
||||
echo "MONITOR-START $(basename "$WORKDIR") every=${EVERY}ep poll=${POLL}s"
|
||||
|
||||
last_epoch_reported=0
|
||||
wd_lines=0
|
||||
err_lines=0
|
||||
|
||||
while true; do
|
||||
# --- progress ------------------------------------------------------
|
||||
if [ -f "$TRAINLOG" ]; then
|
||||
line=$(grep -aE 'Epoch [0-9]+ LR' "$TRAINLOG" 2>/dev/null | tail -1)
|
||||
if [ -n "$line" ]; then
|
||||
# Report the epoch number the trainer itself prints. Counting log
|
||||
# lines is wrong after a resume: the new log starts mid-run, so a
|
||||
# run continuing at epoch 31 would be announced as epoch 11.
|
||||
ep=$(echo "$line" | grep -oE 'Epoch [0-9]+' | grep -oE '[0-9]+')
|
||||
if [ -n "$ep" ] && [ $((ep / EVERY)) -gt $((last_epoch_reported / EVERY)) ]; then
|
||||
best=$(grep -aE 'Find a better ckpt' "$TRAINLOG" | tail -1 \
|
||||
| grep -oE 'val_miou [0-9.]+' | tail -1)
|
||||
echo "EPOCH $ep/$TOTAL | ${line#*] } | best: ${best:-none}"
|
||||
last_epoch_reported=$ep
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- watchdog events (retries, resumes, give-ups) ------------------
|
||||
if [ -f "$WDLOG" ]; then
|
||||
c=$(wc -l < "$WDLOG")
|
||||
if [ "$c" -gt "$wd_lines" ]; then
|
||||
tail -n +$((wd_lines + 1)) "$WDLOG" \
|
||||
| grep -E 'attempt|exited rc=|giving up|exhausted|finished cleanly|FATAL' || true
|
||||
wd_lines=$c
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- speed regression -----------------------------------------------
|
||||
# The first run silently fell from 0.29 s/it to 3.1 s/it when the caching
|
||||
# allocator pool overflowed VRAM into host RAM. No error, no OOM -- just a
|
||||
# 24-hour ETA. Watch the rate, not only the exit code.
|
||||
if [ -f "$TRAINLOG" ]; then
|
||||
r=$(grep -aoE '[0-9.]+(s/it|it/s)' "$TRAINLOG" | tail -1)
|
||||
if [ -n "$r" ]; then
|
||||
sec=$(awk -v x="$r" 'BEGIN {
|
||||
if (x ~ /it\/s$/) { sub(/it\/s$/,"",x); printf "%.3f", (x>0 ? 1.0/x : 0) }
|
||||
else { sub(/s\/it$/,"",x); printf "%.3f", x }
|
||||
}')
|
||||
slow=$(awk -v v="$sec" 'BEGIN { print (v > 1.5) ? 1 : 0 }')
|
||||
if [ "$slow" = "1" ] && [ "${slow_reported:-0}" = "0" ]; then
|
||||
mem=$(nvidia-smi --query-gpu=memory.used,power.draw --format=csv,noheader 2>/dev/null)
|
||||
echo "SLOWDOWN: ${sec} s/it (healthy is ~0.29) | GPU $mem"
|
||||
slow_reported=1
|
||||
elif [ "$slow" = "0" ]; then
|
||||
slow_reported=0
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- failure signatures in the trainer itself ----------------------
|
||||
if [ -f "$TRAINLOG" ]; then
|
||||
e=$(grep -cE 'Traceback|CUDA error|out of memory|OutOfMemory|Killed|AssertionError' \
|
||||
"$TRAINLOG" 2>/dev/null || echo 0)
|
||||
if [ "$e" -gt "$err_lines" ]; then
|
||||
echo "ERROR-SIGNAL x$((e - err_lines)):"
|
||||
grep -E 'Traceback|CUDA error|out of memory|OutOfMemory|Killed|AssertionError' \
|
||||
"$TRAINLOG" | tail -3
|
||||
err_lines=$e
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- terminal check: watchdog gone means the run is over, either way
|
||||
if ! pgrep -f train_watchdog.sh > /dev/null 2>&1; then
|
||||
sleep 5
|
||||
state=$(grep -E '^state' "$WORKDIR/status.txt" 2>/dev/null | cut -d: -f2- | xargs)
|
||||
final=$(grep -aE 'Epoch [0-9]+ LR' "$TRAINLOG" 2>/dev/null | tail -1 \
|
||||
| grep -oE 'Epoch [0-9]+' | grep -oE '[0-9]+')
|
||||
echo "MONITOR-END state='${state:-unknown}' last_epoch=${final:-0}/$TOTAL"
|
||||
if [ -f "$TRAINLOG" ]; then
|
||||
echo "--- tail ---"
|
||||
tail -8 "$TRAINLOG"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
sleep "$POLL"
|
||||
done
|
||||
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env bash
|
||||
# SUM Parts - map the per-class IoU array onto class names
|
||||
#
|
||||
# The log prints a bare numpy array. Guessing whether it starts at class 0 or
|
||||
# class 1 by eye is how you end up reporting "car IoU 95.6". Count it.
|
||||
set -uo pipefail
|
||||
|
||||
LOG="${1:-$HOME/sum-parts/runs/val_ab/val_capped.log}"
|
||||
|
||||
source "$HOME/miniconda3/etc/profile.d/conda.sh"
|
||||
conda activate sumparts
|
||||
|
||||
python - "$LOG" <<'PY'
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
CLASSES = ['unclassified', 'terrain', 'high_vegetation', 'facade_surface',
|
||||
'water', 'car', 'boat', 'roof_surface', 'chimney', 'dormer',
|
||||
'balcony', 'roof_installation', 'wall']
|
||||
|
||||
text = Path(sys.argv[1]).read_text(encoding="utf-8", errors="replace")
|
||||
|
||||
m = re.findall(r"iou per cls is:\s*\[([^\]]*)\]", text)
|
||||
if not m:
|
||||
print("no 'iou per cls' line found in", sys.argv[1])
|
||||
raise SystemExit(1)
|
||||
|
||||
vals = [float(x) for x in m[-1].split()]
|
||||
print(f"file : {Path(sys.argv[1]).name}")
|
||||
print(f"entries: {len(vals)} (num_classes = {len(CLASSES)})")
|
||||
print()
|
||||
|
||||
if len(vals) == len(CLASSES):
|
||||
names = CLASSES
|
||||
note = "array covers classes 0..12"
|
||||
elif len(vals) == len(CLASSES) - 1:
|
||||
names = CLASSES[:-1]
|
||||
note = ("array is one short of num_classes. ConfusionMatrix remaps the "
|
||||
"ignore_index into slot num_classes-1, so the last class shares a "
|
||||
"bucket with ignored points and is dropped from the report.")
|
||||
else:
|
||||
names = [f"class_{i}" for i in range(len(vals))]
|
||||
note = "unexpected length -- names are positional only"
|
||||
|
||||
print(f"note : {note}\n")
|
||||
print(f" {'#':>2} {'class':<20} {'IoU':>7}")
|
||||
for i, (n, v) in enumerate(zip(names, vals)):
|
||||
mark = " <-- 0" if v == 0 else ""
|
||||
print(f" {i:>2} {n:<20} {v:>7.2f}{mark}")
|
||||
|
||||
nz = [v for v in vals if v > 0]
|
||||
print()
|
||||
print(f" non-zero classes : {len(nz)}/{len(vals)}")
|
||||
print(f" mean over all : {sum(vals)/len(vals):.2f}")
|
||||
PY
|
||||
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env bash
|
||||
# SUM Parts - modernise APIs the upstream code uses that newer runtimes removed
|
||||
#
|
||||
# Two families, same root cause (2022 code on a 2025 toolchain):
|
||||
# 1. numpy aliases removed in numpy 1.24
|
||||
# 2. collections ABCs moved to collections.abc in python 3.10
|
||||
#
|
||||
# The upstream env pins numpy 1.20, where np.long / np.int / np.float / np.bool
|
||||
# / np.object / np.str still existed as aliases. numpy 1.24 removed them, so on
|
||||
# any modern numpy the dataset loaders die inside a DataLoader worker with
|
||||
#
|
||||
# AttributeError: module 'numpy' has no attribute 'long'
|
||||
#
|
||||
# and the traceback points at the worker, not at the version mismatch.
|
||||
#
|
||||
# Downgrading numpy is not an option here: torch 2.0.1 needs numpy<2 but
|
||||
# numpy 1.20 has no python 3.10 wheels. So patch the source instead.
|
||||
#
|
||||
# Mapping follows what the aliases actually were:
|
||||
# np.long -> np.int64 (alias of python int, i.e. C long)
|
||||
# np.int -> int
|
||||
# np.float -> float
|
||||
# np.bool -> bool
|
||||
# np.object -> object
|
||||
# np.str -> str
|
||||
#
|
||||
# Idempotent: re-running finds nothing to change. Writes .bak files on first
|
||||
# touch only.
|
||||
set -euo pipefail
|
||||
|
||||
REPO="${1:-$HOME/sum-parts/semantic_segmentation/PointNeXt_bundle}"
|
||||
|
||||
if [ ! -d "$REPO/openpoints" ]; then
|
||||
echo "error: $REPO does not look like PointNeXt_bundle" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$REPO"
|
||||
|
||||
ABCS='Iterable\|Mapping\|MutableMapping\|Sequence\|Callable\|Hashable\|Iterator\|Container\|Sized'
|
||||
PATTERN="np\.long\b\|np\.int\b\|np\.float\b\|np\.bool\b\|np\.object\b\|np\.str\b\|collections\.\($ABCS\)\b"
|
||||
|
||||
echo "=== before ==="
|
||||
grep -rn "$PATTERN" --include='*.py' . || echo " (none)"
|
||||
|
||||
# \b keeps np.int from matching np.int32/np.int64, np.float from np.float32, etc.
|
||||
# The collections rule skips anything already written as collections.abc.X.
|
||||
find . -name '*.py' -print0 | xargs -0 sed -i.bak \
|
||||
-e 's/\bnp\.long\b/np.int64/g' \
|
||||
-e 's/\bnp\.int\b/int/g' \
|
||||
-e 's/\bnp\.float\b/float/g' \
|
||||
-e 's/\bnp\.bool\b/bool/g' \
|
||||
-e 's/\bnp\.object\b/object/g' \
|
||||
-e 's/\bnp\.str\b/str/g' \
|
||||
-e "s/\bcollections\.\($ABCS\)\b/collections.abc.\1/g"
|
||||
|
||||
# sed -i.bak writes a .bak for every file it opens, not just changed ones
|
||||
find . -name '*.py.bak' -print0 | while IFS= read -r -d '' b; do
|
||||
if cmp -s "$b" "${b%.bak}"; then rm -f "$b"; fi
|
||||
done
|
||||
|
||||
# `import collections` alone does not pull in collections.abc on every python,
|
||||
# so make sure any file that now references collections.abc imports it.
|
||||
grep -rl 'collections\.abc\.' --include='*.py' . | while IFS= read -r f; do
|
||||
if ! grep -q '^import collections.abc' "$f"; then
|
||||
sed -i 's/^import collections$/import collections\nimport collections.abc/' "$f"
|
||||
fi
|
||||
done
|
||||
|
||||
echo
|
||||
echo "=== after ==="
|
||||
if grep -rn "$PATTERN" --include='*.py' .; then
|
||||
echo "WARNING: some occurrences remain" >&2
|
||||
else
|
||||
echo " clean"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "=== files changed (.bak kept) ==="
|
||||
find . -name '*.py.bak' | sed 's/\.bak$//' || true
|
||||
|
||||
echo "PATCH DONE"
|
||||
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env bash
|
||||
# SUM Parts - let test() run on the withheld (unlabeled) test split
|
||||
#
|
||||
# The 8 test tiles ship with label = -1 for every point. That is deliberate:
|
||||
# the test set is blind, and the README says to email predictions to the authors
|
||||
# for scoring. Training and validation labels are normal (0..12).
|
||||
#
|
||||
# main.py only checks `if label is not None`, so the -1 placeholder flows into
|
||||
# ConfusionMatrix.update, where
|
||||
# unique_mapping = true * num_classes + pred
|
||||
# goes negative and torch.bincount rejects it:
|
||||
# RuntimeError: bincount only supports 1-d non-negative integral inputs
|
||||
#
|
||||
# The fix treats an all-negative label array as "no ground truth": predictions
|
||||
# are still produced and written, metrics are simply skipped for that tile.
|
||||
# This changes no scoring behaviour on labeled data.
|
||||
#
|
||||
# Idempotent -- re-running detects the patch is already applied.
|
||||
set -euo pipefail
|
||||
|
||||
source "$HOME/miniconda3/etc/profile.d/conda.sh"
|
||||
conda activate sumparts
|
||||
|
||||
REPO="${1:-$HOME/sum-parts/semantic_segmentation/PointNeXt_bundle}"
|
||||
MAIN="$REPO/examples/segmentation/main.py"
|
||||
|
||||
[ -f "$MAIN" ] || { echo "error: $MAIN not found" >&2; exit 1; }
|
||||
|
||||
if grep -q 'SUMPARTS-UNLABELED-TEST' "$MAIN"; then
|
||||
echo "already patched"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
cp -n "$MAIN" "$MAIN.orig" 2>/dev/null || true
|
||||
|
||||
python - "$MAIN" <<'PY'
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
p = Path(sys.argv[1])
|
||||
src = p.read_text(encoding="utf-8")
|
||||
|
||||
old = """ coord, feat, label, idx_points, voxel_idx, reverse_idx_part, reverse_idx = load_data(data_path, cfg)
|
||||
if label is not None:
|
||||
label = torch.from_numpy(label.astype(int).squeeze()).cuda(non_blocking=True)
|
||||
"""
|
||||
|
||||
new = """ coord, feat, label, idx_points, voxel_idx, reverse_idx_part, reverse_idx = load_data(data_path, cfg)
|
||||
# SUMPARTS-UNLABELED-TEST: the shipped test split carries label = -1 for
|
||||
# every point (blind test set; predictions are emailed to the authors for
|
||||
# scoring). Passing that to ConfusionMatrix makes true*num_classes+pred
|
||||
# negative and torch.bincount raises. Treat it as "no ground truth" so
|
||||
# predictions are still produced. Labeled splits are unaffected.
|
||||
if label is not None and (label < 0).all():
|
||||
logging.info(f' no ground truth in {os.path.basename(data_path)} '
|
||||
f'(all labels are -1) -- predicting without scoring')
|
||||
label = None
|
||||
if label is not None:
|
||||
label = torch.from_numpy(label.astype(int).squeeze()).cuda(non_blocking=True)
|
||||
"""
|
||||
|
||||
if old not in src:
|
||||
print("PATTERN NOT FOUND -- main.py differs from what this patch expects", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
|
||||
p.write_text(src.replace(old, new), encoding="utf-8")
|
||||
print("patched:", p)
|
||||
PY
|
||||
|
||||
python -c "import ast,sys; ast.parse(open(sys.argv[1], encoding='utf-8').read())" "$MAIN" \
|
||||
&& echo "syntax OK"
|
||||
|
||||
echo "PATCH DONE (original kept at $MAIN.orig)"
|
||||
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env bash
|
||||
# SUM Parts - fix mode=val crashing before it starts
|
||||
#
|
||||
# main.py:227 validate_fn(model, val_loader, cfg, num_votes=1, epoch=epoch)
|
||||
# UnboundLocalError: local variable 'epoch' referenced before assignment
|
||||
#
|
||||
# `epoch` is only bound inside the training loop, so the standalone validation
|
||||
# path references it before it exists. Bind it to -1 (the same sentinel
|
||||
# validate() already documents in its signature) right before the call.
|
||||
#
|
||||
# Idempotent.
|
||||
set -euo pipefail
|
||||
|
||||
source "$HOME/miniconda3/etc/profile.d/conda.sh"
|
||||
conda activate sumparts
|
||||
|
||||
REPO="${1:-$HOME/sum-parts/semantic_segmentation/PointNeXt_bundle}"
|
||||
MAIN="$REPO/examples/segmentation/main.py"
|
||||
|
||||
[ -f "$MAIN" ] || { echo "error: $MAIN not found" >&2; exit 1; }
|
||||
|
||||
if grep -q 'SUMPARTS-VAL-EPOCH' "$MAIN"; then
|
||||
echo "already patched"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
python - "$MAIN" <<'PY'
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
p = Path(sys.argv[1])
|
||||
src = p.read_text(encoding="utf-8")
|
||||
|
||||
old = """ if cfg.mode == 'val':
|
||||
best_epoch, best_val = load_checkpoint(model, pretrained_path=cfg.pretrained_path)
|
||||
val_miou, val_macc, val_oa, val_ious, val_accs = validate_fn(model, val_loader, cfg, num_votes=1, epoch=epoch)"""
|
||||
|
||||
new = """ if cfg.mode == 'val':
|
||||
best_epoch, best_val = load_checkpoint(model, pretrained_path=cfg.pretrained_path)
|
||||
# SUMPARTS-VAL-EPOCH: `epoch` is only bound inside the training
|
||||
# loop below, so mode=val referenced it before assignment and
|
||||
# died with UnboundLocalError. -1 is the sentinel validate()
|
||||
# already defaults to.
|
||||
epoch = best_epoch if best_epoch is not None else -1
|
||||
val_miou, val_macc, val_oa, val_ious, val_accs = validate_fn(model, val_loader, cfg, num_votes=1, epoch=epoch)"""
|
||||
|
||||
if old not in src:
|
||||
print("PATTERN NOT FOUND -- main.py differs from what this patch expects", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
|
||||
p.write_text(src.replace(old, new), encoding="utf-8")
|
||||
print("patched:", p)
|
||||
PY
|
||||
|
||||
python -c "import ast,sys; ast.parse(open(sys.argv[1], encoding='utf-8').read())" "$MAIN" \
|
||||
&& echo "syntax OK"
|
||||
|
||||
echo "PATCH DONE"
|
||||
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Rewrite a SUM-schema PLY into the spelling desktop viewers expect.
|
||||
|
||||
The training files carry colour as r/g/b float32 in [0,1] — that is what the
|
||||
SUM Parts loader reads and what the network was trained on. Most mesh/point
|
||||
viewers (CloudCompare, MeshLab, Mapple) look for red/green/blue uint8 instead,
|
||||
and when they don't find it they either render the cloud flat grey or list the
|
||||
floats as scalar fields.
|
||||
|
||||
This converts colour spelling only. Coordinates and labels pass through
|
||||
untouched, so the geometry you inspect is exactly the geometry the model saw.
|
||||
|
||||
Usage:
|
||||
python ply_for_viewer.py in.ply out.ply
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from plyfile import PlyData, PlyElement
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if len(sys.argv) != 3:
|
||||
print(__doc__)
|
||||
raise SystemExit(2)
|
||||
|
||||
src, dst = Path(sys.argv[1]), Path(sys.argv[2])
|
||||
if not src.exists():
|
||||
raise SystemExit(f"{src}: not found")
|
||||
|
||||
v = PlyData.read(str(src))["vertex"]
|
||||
props = [p.name for p in v.properties]
|
||||
print(f"in : {src.name} {len(v):,} points props={props}")
|
||||
|
||||
rgb_set = next((s for s in (("r", "g", "b"), ("red", "green", "blue"))
|
||||
if all(c in props for c in s)), None)
|
||||
if rgb_set is None:
|
||||
raise SystemExit("no colour channels found")
|
||||
|
||||
rgb = np.stack([np.asarray(v[c], dtype=np.float64) for c in rgb_set], axis=1)
|
||||
if rgb.max() <= 1.0:
|
||||
rgb = rgb * 255.0
|
||||
print(" colour was float 0-1 -> scaling to 0-255")
|
||||
rgb = np.clip(rgb, 0, 255).astype(np.uint8)
|
||||
|
||||
dtype = [("x", "f4"), ("y", "f4"), ("z", "f4"),
|
||||
("red", "u1"), ("green", "u1"), ("blue", "u1")]
|
||||
if "label" in props:
|
||||
dtype.append(("label", "i4"))
|
||||
|
||||
arr = np.empty(len(v), dtype=dtype)
|
||||
arr["x"], arr["y"], arr["z"] = v["x"], v["y"], v["z"]
|
||||
arr["red"], arr["green"], arr["blue"] = rgb[:, 0], rgb[:, 1], rgb[:, 2]
|
||||
if "label" in props:
|
||||
arr["label"] = np.asarray(v["label"]).astype(np.int32)
|
||||
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
PlyData([PlyElement.describe(arr, "vertex")], text=False).write(str(dst))
|
||||
|
||||
print(f"out : {dst} ({dst.stat().st_size / 1e6:.1f} MB)")
|
||||
print(f" mean RGB {rgb.mean(axis=0).round(1).tolist()}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env bash
|
||||
# SUM Parts - inspect the prediction PLY the POC inference run produced
|
||||
set -euo pipefail
|
||||
|
||||
CONDA_ROOT="$HOME/miniconda3"
|
||||
ENV_NAME="sumparts"
|
||||
LOGROOT="$HOME/sum-parts/semantic_segmentation/PointNeXt_bundle/examples/segmentation/log"
|
||||
SCRIPTS="/mnt/d/MYCLAUDE_PROJECT/sum-parts-test/scripts"
|
||||
|
||||
source "$CONDA_ROOT/etc/profile.d/conda.sh"
|
||||
conda activate "$ENV_NAME"
|
||||
|
||||
PRED=$(find "$LOGROOT" -name '*_pred.ply' -printf '%T@ %p\n' | sort -rn | head -1 | cut -d' ' -f2-)
|
||||
if [ -z "$PRED" ]; then
|
||||
echo "error: no *_pred.ply found under $LOGROOT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "prediction: $PRED"
|
||||
ls -lh "$PRED"
|
||||
echo
|
||||
python "$SCRIPTS/check_pred.py" "$PRED"
|
||||
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env bash
|
||||
# SUM Parts - run inference on the converted Korean tile
|
||||
#
|
||||
# This answers one question: does a SUM Parts model accept our data and emit
|
||||
# per-point predictions? It does NOT measure anything. The checkpoint is the
|
||||
# 1-epoch smoke-test model and the tile carries label=0 everywhere, so the
|
||||
# reported mIoU/OA are meaningless by construction. Look at whether it runs and
|
||||
# what the predicted class histogram looks like, nothing else.
|
||||
set -euo pipefail
|
||||
|
||||
CONDA_ROOT="$HOME/miniconda3"
|
||||
ENV_NAME="sumparts"
|
||||
REPO="$HOME/sum-parts/semantic_segmentation/PointNeXt_bundle"
|
||||
SEG="$REPO/examples/segmentation"
|
||||
|
||||
TILE="$HOME/sum-parts/data/korea_poc/seosan_BlockYBA_tile0.ply"
|
||||
TRACK="$HOME/sum-parts/data/korea_poc_track"
|
||||
LOG="${LOG:-/tmp/sumparts_poc_infer.log}"
|
||||
|
||||
source "$CONDA_ROOT/etc/profile.d/conda.sh"
|
||||
conda activate "$ENV_NAME"
|
||||
export WANDB_MODE=disabled WANDB_SILENT=true
|
||||
export CUDA_HOME="$CONDA_PREFIX"
|
||||
|
||||
[ -f "$TILE" ] || { echo "error: $TILE missing -- run poc_korea.sh first" >&2; exit 1; }
|
||||
|
||||
# The dataset class globs {train,val,test}/*.ply and errors on a missing split,
|
||||
# so all three have to exist even for a test-only run.
|
||||
for split in train val test; do
|
||||
mkdir -p "$TRACK/$split"
|
||||
ln -f "$TILE" "$TRACK/$split/$(basename "$TILE")"
|
||||
done
|
||||
rm -rf "$TRACK/processed"
|
||||
|
||||
CKPT=$(find "$SEG/log/sumv2_triangle" -name '*_ckpt_best.pth' -printf '%T@ %p\n' \
|
||||
| sort -rn | head -1 | cut -d' ' -f2-)
|
||||
[ -n "$CKPT" ] || { echo "error: no checkpoint found -- run smoke_train.sh first" >&2; exit 1; }
|
||||
echo "checkpoint: $CKPT"
|
||||
|
||||
cd "$SEG"
|
||||
|
||||
set +e
|
||||
python -u main.py \
|
||||
--cfg ../../cfgs/sumv2_triangle/pointnet.yaml \
|
||||
mode=test \
|
||||
--pretrained_path "$CKPT" \
|
||||
dataset.common.data_root="$TRACK" \
|
||||
wandb.use_wandb=False \
|
||||
batch_size=2 \
|
||||
val_batch_size=1 \
|
||||
> "$LOG" 2>&1
|
||||
rc=$?
|
||||
set -e
|
||||
|
||||
echo "=== exit=$rc | last 25 lines ==="
|
||||
tail -25 "$LOG"
|
||||
[ "$rc" -eq 0 ] && echo "POC INFER DONE" || echo "POC INFER FAILED (rc=$rc)"
|
||||
exit "$rc"
|
||||
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env bash
|
||||
# SUM Parts - Korean data proof of concept
|
||||
#
|
||||
# Scope is deliberately one tile. The point is to answer "does our data go
|
||||
# through this pipeline at all", not to measure anything. Nothing here produces
|
||||
# a number worth quoting.
|
||||
#
|
||||
# Source: Seosan Myeongcheon, 구역2(미션7)/BlockYBA
|
||||
# ContextCapture OBJ, 4,553,583 faces, 17 texture atlases
|
||||
# local metric coords, EPSG:5186+9999, SRSOrigin 155184.79/469705.03/149.57
|
||||
# block extent 465.36 x 487.46 x 27.55 m
|
||||
#
|
||||
# We cut one 252 m tile out of the middle -- 252 m is the size of the tile
|
||||
# SUM Parts ships, so the point density and neighbourhood radii line up with
|
||||
# what the cfgs assume.
|
||||
set -euo pipefail
|
||||
|
||||
CONDA_ROOT="$HOME/miniconda3"
|
||||
ENV_NAME="sumparts"
|
||||
SCRIPTS="/mnt/d/MYCLAUDE_PROJECT/sum-parts-test/scripts"
|
||||
|
||||
SRC="/mnt/d/MyProject_대용량샘플/02. 데이터/01. 서산 명천_08월/02. 본태모델/구역2(미션7)/BlockYBA/BlockYBA.obj"
|
||||
OUT_DIR="$HOME/sum-parts/data/korea_poc"
|
||||
OUT="$OUT_DIR/seosan_BlockYBA_tile0.ply"
|
||||
|
||||
# centre 252 m tile of the block's 465 x 487 m footprint
|
||||
X0=300; Y0=720; X1=552; Y1=972
|
||||
|
||||
# demo_texsp_pcl.ply carries 471,726 points over a 252 m tile; match it so the
|
||||
# voxel_size 0.02 / voxel_max 64000 settings behave the same way
|
||||
POINTS=470000
|
||||
|
||||
source "$CONDA_ROOT/etc/profile.d/conda.sh"
|
||||
conda activate "$ENV_NAME"
|
||||
|
||||
if [ ! -f "$SRC" ]; then
|
||||
echo "error: source mesh not found:" >&2
|
||||
echo " $SRC" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$OUT_DIR"
|
||||
|
||||
echo "=== converting one 252m tile ==="
|
||||
python "$SCRIPTS/mesh_to_ply.py" "$SRC" "$OUT" \
|
||||
--points "$POINTS" \
|
||||
--bbox "$X0" "$Y0" "$X1" "$Y1" \
|
||||
--label 0
|
||||
|
||||
echo
|
||||
echo "=== verifying against the SUM Parts schema ==="
|
||||
python "$SCRIPTS/check_ply.py" "$OUT" \
|
||||
"$HOME/sum-parts/data/pcl/face_labeling_pcl/demo_texsp_pcl.ply"
|
||||
|
||||
echo "POC CONVERT DONE"
|
||||
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env bash
|
||||
# SUM Parts - lay the demo tile out as a train/val/test split
|
||||
#
|
||||
# demo.zip ships a single showcase tile, not a split. The dataset class globs
|
||||
# <data_root>/{train,val,test}/*.ply, so nothing runs until that structure
|
||||
# exists.
|
||||
#
|
||||
# WARNING: this puts the SAME tile in all three splits. That is fine for a
|
||||
# pipeline smoke test (does the chain survive?) and meaningless as a
|
||||
# measurement -- every val/test number it produces is train-set memorisation.
|
||||
# Do not quote any mIoU from this layout.
|
||||
#
|
||||
# Track selection follows the cfg directory name: cfgs/sumv2_triangle uses
|
||||
# data_root ../../data/sumv2_tri_texpcl, i.e. the triangle (face-label) track
|
||||
# sampled with the texture-superpixel sampler -> face_labeling_pcl/*_texsp_pcl.ply
|
||||
set -euo pipefail
|
||||
|
||||
DATA="$HOME/sum-parts/data"
|
||||
|
||||
# track name -> source ply
|
||||
declare -A SRC=(
|
||||
[sumv2_tri_texpcl]="$DATA/pcl/face_labeling_pcl/demo_texsp_pcl.ply"
|
||||
[sumv2_tex_texpcl]="$DATA/pcl/texture_labeling_pcl/demo_texsp_pcl.ply"
|
||||
)
|
||||
|
||||
for track in "${!SRC[@]}"; do
|
||||
src="${SRC[$track]}"
|
||||
if [ ! -f "$src" ]; then
|
||||
echo "skip $track: $src not found"
|
||||
continue
|
||||
fi
|
||||
echo "=== $track <- $(basename "$src") ==="
|
||||
for split in train val test; do
|
||||
mkdir -p "$DATA/$track/$split"
|
||||
# hardlink: same tile in three splits, one copy on disk
|
||||
ln -f "$src" "$DATA/$track/$split/$(basename "$src")"
|
||||
done
|
||||
# a stale presample cache silently overrides the ply files
|
||||
rm -rf "$DATA/$track/processed"
|
||||
done
|
||||
|
||||
echo
|
||||
echo "=== layout ==="
|
||||
for track in "${!SRC[@]}"; do
|
||||
[ -d "$DATA/$track" ] || continue
|
||||
for split in train val test; do
|
||||
n=$(find "$DATA/$track/$split" -name '*.ply' 2>/dev/null | wc -l)
|
||||
printf '%-20s %-6s %s ply\n' "$track" "$split" "$n"
|
||||
done
|
||||
done
|
||||
|
||||
echo "SPLIT DONE"
|
||||
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env bash
|
||||
# SUM Parts - make the extracted dataset match what the loader globs for
|
||||
#
|
||||
# pcl.zip ships splits named train / validate / test. The dataset class asks for
|
||||
# split='val':
|
||||
#
|
||||
# if split == "train" or split == 'val':
|
||||
# self.data_list = glob.glob(os.path.join(data_root, split, "*.ply"))
|
||||
#
|
||||
# so `validate/` is never found and validation silently runs on 0 samples --
|
||||
# the same quiet failure mode as a wrong data_root. Symlink val -> validate.
|
||||
#
|
||||
# Track layout after extraction:
|
||||
# face_labeling/{face_cen,possion,random,texsp}_pcl/ -> 13-class triangle
|
||||
# pixel_labeling/{possion,random,texsp}_pcl/ -> 20-class texture
|
||||
#
|
||||
# cfgs/sumv2_triangle uses data_root ../../data/sumv2_tri_texpcl, i.e. the
|
||||
# triangle track sampled with the texture-superpixel sampler:
|
||||
# face_labeling/texsp_pcl
|
||||
set -euo pipefail
|
||||
|
||||
DATA="$HOME/sum-parts/data"
|
||||
|
||||
echo "=== linking val -> validate in every track ==="
|
||||
for track in "$DATA"/face_labeling/*_pcl "$DATA"/pixel_labeling/*_pcl; do
|
||||
[ -d "$track" ] || continue
|
||||
if [ -d "$track/validate" ] && [ ! -e "$track/val" ]; then
|
||||
ln -sfn validate "$track/val"
|
||||
echo " linked $(basename "$(dirname "$track")")/$(basename "$track")/val -> validate"
|
||||
fi
|
||||
done
|
||||
|
||||
echo
|
||||
echo "=== split sizes ==="
|
||||
printf '%-40s %6s %6s %6s\n' "track" "train" "val" "test"
|
||||
for track in "$DATA"/face_labeling/*_pcl "$DATA"/pixel_labeling/*_pcl; do
|
||||
[ -d "$track" ] || continue
|
||||
rel="$(basename "$(dirname "$track")")/$(basename "$track")"
|
||||
# -L so the val symlink is followed; plain find would report 0 and look
|
||||
# like the link did not work
|
||||
tr=$(find -L "$track/train" -name '*.ply' 2>/dev/null | wc -l)
|
||||
va=$(find -L "$track/val" -name '*.ply' 2>/dev/null | wc -l)
|
||||
te=$(find -L "$track/test" -name '*.ply' 2>/dev/null | wc -l)
|
||||
printf '%-40s %6s %6s %6s\n' "$rel" "$tr" "$va" "$te"
|
||||
done
|
||||
|
||||
echo
|
||||
echo "=== recommended data_root ==="
|
||||
echo " triangle (13 classes) : $DATA/face_labeling/texsp_pcl"
|
||||
echo " texture (20 classes) : $DATA/pixel_labeling/texsp_pcl"
|
||||
|
||||
echo "SPLIT DONE"
|
||||
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env bash
|
||||
# SUM Parts - repair packages truncated by the WSL VM crash
|
||||
#
|
||||
# The WSL instance died (Wsl/Service/CreateInstance/E_FAIL) partway through the
|
||||
# dependency install. Everything pip had open at that moment landed on disk as
|
||||
# 0-byte files. The failure mode is nasty because import still succeeds -- the
|
||||
# module is simply empty, so you get things like
|
||||
#
|
||||
# from torch_scatter import scatter
|
||||
# TypeError: 'module' object is not callable
|
||||
#
|
||||
# pointing at the call site rather than at the broken install.
|
||||
#
|
||||
# This reinstalls the batch that was in flight. Zero-byte __init__.py files are
|
||||
# normal (namespace markers), so the scan below ignores those.
|
||||
set -euo pipefail
|
||||
|
||||
CONDA_ROOT="$HOME/miniconda3"
|
||||
ENV_NAME="sumparts"
|
||||
|
||||
source "$CONDA_ROOT/etc/profile.d/conda.sh"
|
||||
conda activate "$ENV_NAME"
|
||||
|
||||
SITE=$(python -c "import site; print(site.getsitepackages()[0])")
|
||||
|
||||
scan() {
|
||||
find "$SITE" -type f \( -name '*.py' -o -name '*.so' \) -size 0 \
|
||||
! -name '__init__.py' | wc -l
|
||||
}
|
||||
|
||||
echo "=== suspicious 0-byte files before: $(scan) ==="
|
||||
|
||||
# The dependency batch that was installing when the VM went down.
|
||||
PKGS=(
|
||||
plyfile scikit-learn easydict PyYAML tensorboard termcolor tqdm
|
||||
multimethod h5py matplotlib pandas shortuuid gdown Cython pyvista
|
||||
wandb trimesh pillow
|
||||
)
|
||||
|
||||
echo "=== force-reinstalling ${#PKGS[@]} packages ==="
|
||||
pip install --no-cache-dir --force-reinstall "${PKGS[@]}"
|
||||
|
||||
# Pins that other steps depend on; --force-reinstall above can pull them up.
|
||||
echo "=== restoring pins ==="
|
||||
pip install --no-cache-dir "numpy<2" "setuptools==69.5.1" "ninja==1.11.1.1"
|
||||
|
||||
# torch-scatter must match the exact torch build, so it needs its own index.
|
||||
python -c "import torch_scatter, inspect; assert callable(torch_scatter.scatter)" 2>/dev/null || {
|
||||
echo "=== reinstalling torch-scatter ==="
|
||||
pip install --no-cache-dir --force-reinstall torch-scatter \
|
||||
-f https://data.pyg.org/whl/torch-2.0.1+cu118.html
|
||||
}
|
||||
|
||||
echo
|
||||
echo "=== suspicious 0-byte files after: $(scan) ==="
|
||||
find "$SITE" -type f \( -name '*.py' -o -name '*.so' \) -size 0 \
|
||||
! -name '__init__.py' | head -20
|
||||
|
||||
echo
|
||||
echo "=== import check ==="
|
||||
python - <<'PY'
|
||||
import importlib
|
||||
mods = ["numpy", "torch", "torch_scatter", "plyfile", "sklearn", "matplotlib",
|
||||
"pandas", "h5py", "yaml", "easydict", "tqdm", "termcolor", "multimethod",
|
||||
"wandb", "trimesh", "PIL", "gdown"]
|
||||
bad = []
|
||||
for m in mods:
|
||||
try:
|
||||
importlib.import_module(m)
|
||||
print(f"{m:14s} OK")
|
||||
except Exception as e:
|
||||
bad.append(m)
|
||||
print(f"{m:14s} FAIL {type(e).__name__}: {e}")
|
||||
import torch_scatter
|
||||
print("torch_scatter.scatter callable:", callable(torch_scatter.scatter))
|
||||
raise SystemExit(1 if bad or not callable(torch_scatter.scatter) else 0)
|
||||
PY
|
||||
|
||||
echo "REPAIR DONE"
|
||||
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env bash
|
||||
# SUM Parts - benchmark VRAM and speed of each sumv2_triangle model
|
||||
#
|
||||
# Uses whatever tiles are in data/sumv2_tri_texpcl. With only the demo tile
|
||||
# present the per-iteration cost is still representative: voxel_max caps how
|
||||
# many points reach the network per sample, so timing does not depend on how
|
||||
# many tiles exist.
|
||||
set -euo pipefail
|
||||
|
||||
CONDA_ROOT="$HOME/miniconda3"
|
||||
ENV_NAME="sumparts"
|
||||
SEG="$HOME/sum-parts/semantic_segmentation/PointNeXt_bundle/examples/segmentation"
|
||||
SCRIPTS="/mnt/d/MYCLAUDE_PROJECT/sum-parts-test/scripts"
|
||||
LOG="${LOG:-/tmp/sumparts_bench.log}"
|
||||
|
||||
source "$CONDA_ROOT/etc/profile.d/conda.sh"
|
||||
conda activate "$ENV_NAME"
|
||||
export WANDB_MODE=disabled WANDB_SILENT=true CUDA_HOME="$CONDA_PREFIX"
|
||||
|
||||
cd "$SEG"
|
||||
|
||||
set +e
|
||||
python -u "$SCRIPTS/bench_models.py" "$@" > "$LOG" 2>&1
|
||||
rc=$?
|
||||
set -e
|
||||
|
||||
grep -v "it/s\]" "$LOG" | tail -40
|
||||
echo "(full log: $LOG)"
|
||||
exit "$rc"
|
||||
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env bash
|
||||
# SUM Parts - WSL2 conda env setup (no sudo required)
|
||||
#
|
||||
# NOTE: Anaconda's "defaults" channels (repo.anaconda.com/pkgs/*) require accepting
|
||||
# a Terms of Service that carries commercial-license obligations for larger orgs.
|
||||
# This script deliberately avoids them entirely: conda-forge for packages,
|
||||
# nvidia channel for the CUDA toolkit, both with --override-channels.
|
||||
set -euo pipefail
|
||||
|
||||
CONDA_ROOT="$HOME/miniconda3"
|
||||
ENV_NAME="sumparts"
|
||||
|
||||
export PATH="$CONDA_ROOT/bin:$PATH"
|
||||
source "$CONDA_ROOT/etc/profile.d/conda.sh"
|
||||
|
||||
echo "=== [0/5] pin channels to conda-forge (drop anaconda defaults) ==="
|
||||
conda config --remove channels defaults 2>/dev/null || true
|
||||
conda config --add channels conda-forge
|
||||
conda config --set channel_priority strict
|
||||
|
||||
echo "=== [1/5] create env: $ENV_NAME (python 3.10) ==="
|
||||
conda create -n "$ENV_NAME" -y --override-channels -c conda-forge python=3.10
|
||||
|
||||
conda activate "$ENV_NAME"
|
||||
|
||||
echo "=== [2/5] CUDA Toolkit 11.8 (nvidia channel, no sudo) ==="
|
||||
conda install -y --override-channels -c "nvidia/label/cuda-11.8.0" cuda-toolkit
|
||||
|
||||
echo "=== [3/5] PyTorch 2.0.1 + cu118 (pip wheels) ==="
|
||||
pip install --no-cache-dir \
|
||||
torch==2.0.1+cu118 torchvision==0.15.2+cu118 \
|
||||
--index-url https://download.pytorch.org/whl/cu118
|
||||
|
||||
echo "=== [4/5] pin numpy<2 (torch 2.0.x is ABI-incompatible with numpy 2) ==="
|
||||
pip install --no-cache-dir "numpy<2"
|
||||
|
||||
echo "=== [5/5] verify ==="
|
||||
which nvcc
|
||||
nvcc --version | tail -2
|
||||
python - <<'PY'
|
||||
import torch, numpy
|
||||
print("torch :", torch.__version__)
|
||||
print("cuda :", torch.version.cuda)
|
||||
print("avail :", torch.cuda.is_available())
|
||||
print("device :", torch.cuda.get_device_name(0) if torch.cuda.is_available() else None)
|
||||
print("numpy :", numpy.__version__)
|
||||
PY
|
||||
|
||||
echo "ENV DONE"
|
||||
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env bash
|
||||
# SUM Parts - PointNeXt_bundle dependencies + CUDA extension build
|
||||
#
|
||||
# The upstream install.sh assumes python 3.7 / torch 1.12.1 / cu113 and pins
|
||||
# requirements.txt to versions that no longer resolve on python 3.10.
|
||||
# This script keeps the same package set but relaxes the pins, and drops
|
||||
# packages that are not needed for the sumv2 segmentation task:
|
||||
# - deepspeed (not used by the sumv2 training path, heavy build)
|
||||
# - mkdocs-* (docs only)
|
||||
# It also adds two packages the code needs but requirements.txt omits:
|
||||
# - plyfile : imported by both sumv2 dataset loaders
|
||||
# - wandb : main.py imports it at module scope, so it must exist even when
|
||||
# wandb.use_wandb=False. Run with WANDB_MODE=disabled.
|
||||
# NOTE: the chamfer_dist / emd extensions look reconstruction-only, but
|
||||
# openpoints/models/__init__.py imports .reconstruction unconditionally, so
|
||||
# segmentation runs need them too. build_ext.sh builds them.
|
||||
set -euo pipefail
|
||||
|
||||
CONDA_ROOT="$HOME/miniconda3"
|
||||
ENV_NAME="sumparts"
|
||||
REPO="$HOME/sum-parts/semantic_segmentation/PointNeXt_bundle"
|
||||
|
||||
source "$CONDA_ROOT/etc/profile.d/conda.sh"
|
||||
conda activate "$ENV_NAME"
|
||||
|
||||
# RTX 3060 == compute capability 8.6. Pin it so nvcc does not build every arch.
|
||||
export TORCH_CUDA_ARCH_LIST="8.6"
|
||||
export CUDA_HOME="$CONDA_PREFIX"
|
||||
export PATH="$CUDA_HOME/bin:$PATH"
|
||||
|
||||
echo "=== [1/4] python deps ==="
|
||||
# setuptools 69.5.1 : subsampling/setup.py imports numpy.distutils, which needs
|
||||
# distutils.msvccompiler -- removed in setuptools 74.0. The extensions also
|
||||
# still use `python setup.py install`, dropped in setuptools 80.
|
||||
pip install --no-cache-dir "setuptools==69.5.1" wheel
|
||||
|
||||
pip install --no-cache-dir \
|
||||
plyfile \
|
||||
scikit-learn \
|
||||
ninja \
|
||||
easydict \
|
||||
PyYAML \
|
||||
tensorboard \
|
||||
termcolor \
|
||||
tqdm \
|
||||
multimethod \
|
||||
h5py \
|
||||
matplotlib \
|
||||
pandas \
|
||||
shortuuid \
|
||||
gdown \
|
||||
Cython \
|
||||
pyvista \
|
||||
wandb \
|
||||
trimesh \
|
||||
pillow \
|
||||
"numpy<2"
|
||||
|
||||
echo "=== [2/4] torch-scatter (matched to torch 2.0.1+cu118) ==="
|
||||
pip install --no-cache-dir torch-scatter \
|
||||
-f https://data.pyg.org/whl/torch-2.0.1+cu118.html
|
||||
|
||||
echo "=== [3/4] build CUDA extensions ==="
|
||||
# Delegated to build_ext.sh so a failed compile can be retried on its own.
|
||||
# That script also pins ninja==1.11.1.1 (torch 2.0 + ninja>=1.12 dies on SIGPIPE).
|
||||
bash "$(dirname "$0")/build_ext.sh"
|
||||
|
||||
echo "POINTNEXT DONE"
|
||||
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env bash
|
||||
# SUM Parts - what actually landed on disk after extraction
|
||||
set -euo pipefail
|
||||
|
||||
DATA="$HOME/sum-parts/data"
|
||||
cd "$DATA"
|
||||
|
||||
printf '%-26s %8s %10s\n' "directory" "ply" "size"
|
||||
printf -- '--------------------------------------------------\n'
|
||||
for d in */; do
|
||||
n=$(find "$d" -name '*.ply' 2>/dev/null | wc -l)
|
||||
s=$(du -sh "$d" 2>/dev/null | cut -f1)
|
||||
printf '%-26s %8s %10s\n' "$d" "$n" "$s"
|
||||
done
|
||||
|
||||
echo
|
||||
echo "=== ply per split, two levels down ==="
|
||||
find . -mindepth 2 -maxdepth 3 -type d 2>/dev/null | sort | while IFS= read -r d; do
|
||||
n=$(find "$d" -maxdepth 1 -name '*.ply' 2>/dev/null | wc -l)
|
||||
[ "$n" -gt 0 ] && printf '%-52s %4s ply\n' "$d" "$n"
|
||||
done
|
||||
|
||||
echo
|
||||
echo "total on disk: $(du -sh "$DATA" | cut -f1)"
|
||||
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env bash
|
||||
# SUM Parts - pipeline smoke test
|
||||
#
|
||||
# Goal is NOT accuracy. Goal is proving the whole chain survives:
|
||||
# ply load -> grid subsample -> cuda ops -> forward -> backward -> val
|
||||
# Uses the lightest model (pointnet) and 1 epoch. Expect garbage mIoU.
|
||||
#
|
||||
# Usage:
|
||||
# bash smoke_train.sh # pointnet, 1 epoch
|
||||
# bash smoke_train.sh pointnext-xl 2 # other cfg / epoch count
|
||||
set -euo pipefail
|
||||
|
||||
CONDA_ROOT="$HOME/miniconda3"
|
||||
ENV_NAME="sumparts"
|
||||
REPO="$HOME/sum-parts/semantic_segmentation/PointNeXt_bundle"
|
||||
|
||||
CFG="${1:-pointnet}"
|
||||
EPOCHS="${2:-1}"
|
||||
|
||||
source "$CONDA_ROOT/etc/profile.d/conda.sh"
|
||||
conda activate "$ENV_NAME"
|
||||
|
||||
export CUDA_HOME="$CONDA_PREFIX"
|
||||
export PATH="$CUDA_HOME/bin:$PATH"
|
||||
|
||||
# main.py imports wandb at module scope, so the package must exist even with
|
||||
# wandb.use_wandb=False. Disabled mode keeps it from asking for a login.
|
||||
export WANDB_MODE=disabled
|
||||
export WANDB_SILENT=true
|
||||
|
||||
cd "$REPO/examples/segmentation"
|
||||
|
||||
# Two constraints squeeze BS from both sides, and both fail confusingly:
|
||||
#
|
||||
# too large -> the loader drops the last partial batch, so a single tile with
|
||||
# the cfg's batch_size (6 for pointnet) yields ZERO batches. The confusion
|
||||
# matrix is then never populated and cm.all_metrics() dies on
|
||||
# AttributeError: 'int' object has no attribute 'diag'
|
||||
#
|
||||
# too small -> BatchNorm1d refuses a batch of 1 while training:
|
||||
# ValueError: Expected more than 1 value per channel when training,
|
||||
# got input size torch.Size([1, 512])
|
||||
#
|
||||
# So: BS=2 (the BatchNorm floor), and LOOP repeats the tile enough times to fill
|
||||
# whole batches.
|
||||
LOOP="${3:-4}"
|
||||
BS="${BS:-2}"
|
||||
|
||||
LOG="${LOG:-/tmp/sumparts_smoke.log}"
|
||||
|
||||
echo "=== smoke train: cfg=$CFG epochs=$EPOCHS loop=$LOOP bs=$BS ==="
|
||||
echo "log: $LOG"
|
||||
|
||||
# Capture both streams to a file. Tracebacks were getting lost when this ran
|
||||
# under a wrapper that only kept stdout, which made crashes look silent.
|
||||
set +e
|
||||
python -u main.py \
|
||||
--cfg "../../cfgs/sumv2_triangle/${CFG}.yaml" \
|
||||
mode=train \
|
||||
epochs="$EPOCHS" \
|
||||
wandb.use_wandb=False \
|
||||
dataset.train.loop="$LOOP" \
|
||||
batch_size="$BS" \
|
||||
val_batch_size=1 \
|
||||
val_freq=1 \
|
||||
> "$LOG" 2>&1
|
||||
rc=$?
|
||||
set -e
|
||||
|
||||
echo "=== exit=$rc | last 30 lines ==="
|
||||
tail -30 "$LOG"
|
||||
|
||||
[ "$rc" -eq 0 ] && echo "SMOKE DONE" || echo "SMOKE FAILED (rc=$rc)"
|
||||
exit "$rc"
|
||||
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env bash
|
||||
# SUM Parts - stop the unattended training run
|
||||
#
|
||||
# Kills the watchdog first so it does not treat the dying trainer as a crash
|
||||
# and immediately resume it.
|
||||
set -uo pipefail
|
||||
|
||||
if ! pgrep -f train_watchdog.sh > /dev/null && ! pgrep -f "main.py" > /dev/null; then
|
||||
echo "nothing running"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "stopping watchdog:"
|
||||
pgrep -af train_watchdog.sh || true
|
||||
pkill -f train_watchdog.sh || true
|
||||
sleep 2
|
||||
|
||||
echo "stopping trainer:"
|
||||
pgrep -af "main.py" || true
|
||||
pkill -f "examples/segmentation/main.py" || true
|
||||
sleep 3
|
||||
|
||||
if pgrep -f "main.py" > /dev/null; then
|
||||
echo "still alive, sending SIGKILL"
|
||||
pkill -9 -f "examples/segmentation/main.py" || true
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "remaining:"
|
||||
pgrep -af "train_watchdog.sh|main.py" || echo " clean"
|
||||
echo
|
||||
echo "checkpoints are kept -- relaunch resumes from the latest one."
|
||||
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env bash
|
||||
# SUM Parts - find the largest voxel_max that still fits in VRAM
|
||||
#
|
||||
# Why this matters: on WSL2 the NVIDIA driver spills past VRAM into host RAM
|
||||
# instead of raising OOM, so an oversized config still "works" -- at 47 s/iter
|
||||
# instead of 0.43. Dropping voxel_max until the peak fits gave a 108x speedup
|
||||
# on pointnext-xl. But voxel_max is also the cfg value the paper trained with
|
||||
# (64000), so anything lower is a deviation worth naming. This sweep finds how
|
||||
# close to 64000 the card can actually get.
|
||||
set -uo pipefail
|
||||
|
||||
CONDA_ROOT="$HOME/miniconda3"
|
||||
SEG="$HOME/sum-parts/semantic_segmentation/PointNeXt_bundle/examples/segmentation"
|
||||
SCRIPTS="/mnt/d/MYCLAUDE_PROJECT/sum-parts-test/scripts"
|
||||
|
||||
CFG="${CFG:-pointnext-xl}"
|
||||
ITERS="${ITERS:-5}"
|
||||
VALUES="${*:-24000 32000 40000 48000 64000}"
|
||||
|
||||
source "$CONDA_ROOT/etc/profile.d/conda.sh"
|
||||
conda activate sumparts
|
||||
export WANDB_MODE=disabled WANDB_SILENT=true CUDA_HOME="$CONDA_PREFIX"
|
||||
|
||||
cd "$SEG"
|
||||
|
||||
echo "sweeping $CFG over voxel_max: $VALUES"
|
||||
echo
|
||||
printf '%-10s %-12s %-11s %-10s %s\n' "voxel_max" "pts/batch" "peak VRAM" "s/iter" "fits?"
|
||||
printf -- '---------------------------------------------------------\n'
|
||||
|
||||
for vm in $VALUES; do
|
||||
log="/tmp/sweep_${CFG}_${vm}.log"
|
||||
python -u "$SCRIPTS/bench_models.py" \
|
||||
--iters "$ITERS" --voxel-max "$vm" --cfgs "$CFG" > "$log" 2>&1
|
||||
rc=$?
|
||||
if [ $rc -ne 0 ]; then
|
||||
printf '%-10s %s\n' "$vm" "FAILED (rc=$rc, see $log)"
|
||||
continue
|
||||
fi
|
||||
line=$(grep -E "^${CFG} +[0-9]" "$log" | tail -1)
|
||||
if [ -z "$line" ]; then
|
||||
printf '%-10s %s\n' "$vm" "no result (see $log)"
|
||||
continue
|
||||
fi
|
||||
# cfg params bs pts peak s/iter fits...
|
||||
pts=$(echo "$line" | awk '{print $4}')
|
||||
peak=$(echo "$line" | awk '{print $5}')
|
||||
sec=$(echo "$line" | awk '{print $6}')
|
||||
fits=$(echo "$line" | cut -d' ' -f7- | sed 's/^ *//')
|
||||
printf '%-10s %-12s %-11s %-10s %s\n' "$vm" "$pts" "$peak" "$sec" "$fits"
|
||||
done
|
||||
|
||||
echo
|
||||
echo "SWEEP DONE"
|
||||
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env bash
|
||||
# SUM Parts - full training run on the real dataset
|
||||
#
|
||||
# Usage:
|
||||
# bash train_full.sh # pointnet, paper settings
|
||||
# bash train_full.sh pointnext-xl # auto-picks a voxel_max that fits
|
||||
# CFG_VOXEL_MAX=40000 bash train_full.sh pointnext-xl
|
||||
# EPOCHS=20 bash train_full.sh pointnet
|
||||
#
|
||||
# Measured on this box (RTX 3060 12GB), 24 train tiles, loop 30, batch_size 2
|
||||
# => 360 iter/epoch:
|
||||
#
|
||||
# pointnet voxel_max 64000 (paper) 6.01G 0.291 s/iter ~2.9 h/100ep
|
||||
# pointnet++msg voxel_max 64000 (paper) 4.16G 0.675 s/iter ~6.8 h/100ep
|
||||
# pointvector-xl voxel_max 24000 6.46G 0.402 s/iter ~4.0 h/100ep
|
||||
# pointnext-xl voxel_max 32000 8.03G 0.635 s/iter ~6.4 h/100ep
|
||||
#
|
||||
# The XL rows are NOT the paper configuration. At voxel_max 64000 they need
|
||||
# ~15-16 GB, and on WSL2 the driver spills past VRAM into host RAM instead of
|
||||
# raising OOM -- the run completes at 47 s/iter, i.e. ~20 days for 100 epochs.
|
||||
# Numbers from a reduced voxel_max are not comparable to the published ones.
|
||||
set -euo pipefail
|
||||
|
||||
CONDA_ROOT="$HOME/miniconda3"
|
||||
ENV_NAME="sumparts"
|
||||
SEG="$HOME/sum-parts/semantic_segmentation/PointNeXt_bundle/examples/segmentation"
|
||||
DATA="$HOME/sum-parts/data/face_labeling/texsp_pcl" # triangle track, 13 classes
|
||||
|
||||
CFG="${1:-pointnet}"
|
||||
EPOCHS="${EPOCHS:-100}"
|
||||
VAL_FREQ="${VAL_FREQ:-5}" # cfg default is 1; every epoch costs 8 val tiles
|
||||
LOG="${LOG:-/tmp/sumparts_train_${CFG}.log}"
|
||||
|
||||
# largest voxel_max measured to stay inside 12 GB for each model
|
||||
if [ -n "${CFG_VOXEL_MAX:-}" ]; then
|
||||
VOXEL_MAX="$CFG_VOXEL_MAX"
|
||||
else
|
||||
case "$CFG" in
|
||||
pointnext-xl) VOXEL_MAX=32000 ;;
|
||||
pointvector-xl) VOXEL_MAX=24000 ;;
|
||||
*) VOXEL_MAX=64000 ;; # paper setting; small models fit
|
||||
esac
|
||||
fi
|
||||
|
||||
source "$CONDA_ROOT/etc/profile.d/conda.sh"
|
||||
conda activate "$ENV_NAME"
|
||||
export WANDB_MODE=disabled WANDB_SILENT=true CUDA_HOME="$CONDA_PREFIX"
|
||||
|
||||
[ -d "$DATA/train" ] || { echo "error: $DATA/train missing -- run download_data.sh and prepare_full_split.sh" >&2; exit 1; }
|
||||
|
||||
echo "=== full training ==="
|
||||
echo " cfg : $CFG"
|
||||
echo " data_root : $DATA"
|
||||
echo " train/val/test : $(find -L "$DATA/train" -name '*.ply' | wc -l)/$(find -L "$DATA/val" -name '*.ply' | wc -l)/$(find -L "$DATA/test" -name '*.ply' | wc -l)"
|
||||
echo " epochs : $EPOCHS"
|
||||
echo " val_freq : $VAL_FREQ"
|
||||
echo " voxel_max : $VOXEL_MAX$([ "$VOXEL_MAX" -lt 64000 ] && echo ' (reduced from paper 64000 to fit VRAM)')"
|
||||
echo " log : $LOG"
|
||||
echo
|
||||
|
||||
cd "$SEG"
|
||||
|
||||
set +e
|
||||
python -u main.py \
|
||||
--cfg "../../cfgs/sumv2_triangle/${CFG}.yaml" \
|
||||
mode=train \
|
||||
dataset.common.data_root="$DATA" \
|
||||
dataset.train.voxel_max="$VOXEL_MAX" \
|
||||
epochs="$EPOCHS" \
|
||||
val_freq="$VAL_FREQ" \
|
||||
wandb.use_wandb=False \
|
||||
> "$LOG" 2>&1
|
||||
rc=$?
|
||||
set -e
|
||||
|
||||
echo "=== exit=$rc | last 25 lines ==="
|
||||
tail -25 "$LOG"
|
||||
[ "$rc" -eq 0 ] && echo "TRAIN DONE" || echo "TRAIN FAILED (rc=$rc)"
|
||||
exit "$rc"
|
||||
@@ -0,0 +1,215 @@
|
||||
#!/usr/bin/env bash
|
||||
# SUM Parts - unattended training with automatic resume
|
||||
#
|
||||
# The WSL VM has died twice under load on this box, so an overnight run needs to
|
||||
# survive the process disappearing. main.py checkpoints every epoch and supports
|
||||
#
|
||||
# mode=resume --pretrained_path <run>/checkpoint/<run>_ckpt_latest.pth
|
||||
#
|
||||
# When pretrained_path sits inside a checkpoint/ directory, resume_exp_directory
|
||||
# reuses the SAME run folder and resume_checkpoint restores epoch, optimizer and
|
||||
# scheduler -- so a restart continues rather than starting over.
|
||||
#
|
||||
# This wrapper runs the training, and if it exits non-zero it waits, finds the
|
||||
# newest checkpoint, and resumes. Up to MAX_RETRIES times. It stops retrying if
|
||||
# the run reached the final epoch, or if two consecutive attempts fail without
|
||||
# a new checkpoint being written (that means it is failing before it can train,
|
||||
# so retrying is pointless).
|
||||
#
|
||||
# Usage:
|
||||
# bash train_watchdog.sh [cfg]
|
||||
# MAX_RETRIES=10 EPOCHS=100 bash train_watchdog.sh pointnet
|
||||
#
|
||||
# For an unattended launch use launch_overnight.sh, which detaches this from
|
||||
# the terminal.
|
||||
set -uo pipefail
|
||||
|
||||
CONDA_ROOT="$HOME/miniconda3"
|
||||
ENV_NAME="sumparts"
|
||||
SEG="$HOME/sum-parts/semantic_segmentation/PointNeXt_bundle/examples/segmentation"
|
||||
DATA="$HOME/sum-parts/data/face_labeling/texsp_pcl"
|
||||
|
||||
CFG="${1:-pointnet}"
|
||||
EPOCHS="${EPOCHS:-100}"
|
||||
VAL_FREQ="${VAL_FREQ:-5}"
|
||||
MAX_RETRIES="${MAX_RETRIES:-8}"
|
||||
RETRY_WAIT="${RETRY_WAIT:-60}"
|
||||
|
||||
RUNROOT="$HOME/sum-parts/runs"
|
||||
STAMP="${STAMP:-$(date +%Y%m%d-%H%M%S)}"
|
||||
WORKDIR="$RUNROOT/${CFG}_${STAMP}"
|
||||
LOG="$WORKDIR/watchdog.log"
|
||||
TRAINLOG="$WORKDIR/train.log"
|
||||
STATUS="$WORKDIR/status.txt"
|
||||
|
||||
if [ -n "${CFG_VOXEL_MAX:-}" ]; then
|
||||
VOXEL_MAX="$CFG_VOXEL_MAX"
|
||||
else
|
||||
case "$CFG" in
|
||||
pointnext-xl) VOXEL_MAX=32000 ;;
|
||||
pointvector-xl) VOXEL_MAX=24000 ;;
|
||||
*) VOXEL_MAX=64000 ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
mkdir -p "$WORKDIR"
|
||||
|
||||
say() { echo "[$(date '+%F %T')] $*" | tee -a "$LOG"; }
|
||||
|
||||
write_status() {
|
||||
{
|
||||
echo "cfg : $CFG"
|
||||
echo "workdir : $WORKDIR"
|
||||
echo "state : $1"
|
||||
echo "attempt : ${attempt:-0}/$MAX_RETRIES"
|
||||
echo "epochs : $EPOCHS (val_freq $VAL_FREQ)"
|
||||
echo "voxel_max : $VOXEL_MAX"
|
||||
echo "updated : $(date '+%F %T')"
|
||||
echo "last epoch : $(last_epoch)"
|
||||
echo "train log : $TRAINLOG"
|
||||
} > "$STATUS"
|
||||
}
|
||||
|
||||
# newest *_ckpt_latest.pth under the segmentation log tree
|
||||
find_ckpt() {
|
||||
find "$SEG/log/sumv2_triangle" -name '*_ckpt_latest.pth' -newermt "@$START_EPOCH_TS" \
|
||||
-printf '%T@ %p\n' 2>/dev/null | sort -rn | head -1 | cut -d' ' -f2-
|
||||
}
|
||||
|
||||
last_epoch() {
|
||||
grep -oE 'Epoch [0-9]+ LR' "$TRAINLOG" 2>/dev/null | tail -1 | grep -oE '[0-9]+' || echo "-"
|
||||
}
|
||||
|
||||
source "$CONDA_ROOT/etc/profile.d/conda.sh"
|
||||
conda activate "$ENV_NAME"
|
||||
export WANDB_MODE=disabled WANDB_SILENT=true CUDA_HOME="$CONDA_PREFIX"
|
||||
|
||||
# Why expandable_segments: cfgs/sumv2_triangle/default.yaml validates with
|
||||
# val: { voxel_max: null }
|
||||
# i.e. the whole tile (~470k points) in one go, while training is capped at
|
||||
# voxel_max. Those oversized transient allocations fragment the caching
|
||||
# allocator, its pool grows past 12 GB, and on WSL2 the driver quietly spills
|
||||
# the excess into host RAM instead of raising OOM.
|
||||
#
|
||||
# Observed on the first run: epochs 1-10 took ~103 s each, then from epoch 11
|
||||
# (right after the epoch-10 validation) every epoch took ~1100 s -- a 10x
|
||||
# slowdown with the GPU at 100% util, full clocks, no thermal throttle, and
|
||||
# 11.7 GB dedicated + 19.0 GB shared. Killing the process dropped the card back
|
||||
# to 1.2 GB, so it was the trainer's own pool, not other apps.
|
||||
#
|
||||
# NOTE: expandable_segments is NOT available here -- it landed in torch 2.1 and
|
||||
# this env is on 2.0.1, where it aborts at startup with
|
||||
# RuntimeError: Unrecognized CachingAllocator option: expandable_segments
|
||||
# garbage_collection_threshold and max_split_size_mb do exist in 2.0.
|
||||
export PYTORCH_CUDA_ALLOC_CONF="${PYTORCH_CUDA_ALLOC_CONF:-garbage_collection_threshold:0.7,max_split_size_mb:128}"
|
||||
|
||||
# Validation runs with voxel_max: null, i.e. a whole ~470k-point tile in one
|
||||
# forward pass, which peaks far above the capped training step. Bounding it to
|
||||
# the training value keeps the allocator pool inside VRAM.
|
||||
#
|
||||
# This is a real deviation, stated plainly: val_miou is then computed on
|
||||
# subsampled tiles, so it is only a model-selection signal. It does not affect
|
||||
# the reported test numbers -- test() slides over the full tile regardless.
|
||||
VAL_VOXEL_MAX="${VAL_VOXEL_MAX:-64000}"
|
||||
|
||||
if [ ! -d "$DATA/train" ]; then
|
||||
say "FATAL: $DATA/train missing. run download_data.sh + prepare_full_split.sh"
|
||||
write_status "failed-no-data"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# RESUME_CKPT lets a relaunch continue an earlier run instead of starting over.
|
||||
# Needed because find_ckpt only considers checkpoints written after this
|
||||
# watchdog started, so a fresh watchdog would otherwise ignore existing ones.
|
||||
RESUME_CKPT="${RESUME_CKPT:-}"
|
||||
if [ -n "$RESUME_CKPT" ] && [ ! -f "$RESUME_CKPT" ]; then
|
||||
say "FATAL: RESUME_CKPT does not exist: $RESUME_CKPT"
|
||||
write_status "failed-bad-resume-ckpt"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
START_EPOCH_TS=$(date +%s)
|
||||
cd "$SEG"
|
||||
|
||||
say "cfg=$CFG epochs=$EPOCHS val_freq=$VAL_FREQ voxel_max=$VOXEL_MAX"
|
||||
say "data=$DATA train/val/test = $(find -L "$DATA/train" -name '*.ply' | wc -l)/$(find -L "$DATA/val" -name '*.ply' | wc -l)/$(find -L "$DATA/test" -name '*.ply' | wc -l)"
|
||||
say "workdir=$WORKDIR"
|
||||
|
||||
attempt=0
|
||||
prev_ckpt=""
|
||||
|
||||
while [ "$attempt" -le "$MAX_RETRIES" ]; do
|
||||
ckpt=$(find_ckpt)
|
||||
# on the very first attempt, an explicitly supplied checkpoint wins
|
||||
if [ "$attempt" -eq 0 ] && [ -n "$RESUME_CKPT" ]; then
|
||||
ckpt="$RESUME_CKPT"
|
||||
fi
|
||||
|
||||
if [ -z "$ckpt" ]; then
|
||||
say "attempt $attempt: fresh start"
|
||||
write_status "running (fresh)"
|
||||
python -u main.py \
|
||||
--cfg "../../cfgs/sumv2_triangle/${CFG}.yaml" \
|
||||
mode=train \
|
||||
dataset.common.data_root="$DATA" \
|
||||
dataset.train.voxel_max="$VOXEL_MAX" \
|
||||
dataset.val.voxel_max="$VAL_VOXEL_MAX" \
|
||||
epochs="$EPOCHS" \
|
||||
val_freq="$VAL_FREQ" \
|
||||
wandb.use_wandb=False \
|
||||
>> "$TRAINLOG" 2>&1
|
||||
rc=$?
|
||||
else
|
||||
say "attempt $attempt: resuming from $(basename "$ckpt")"
|
||||
write_status "running (resumed)"
|
||||
python -u main.py \
|
||||
--cfg "../../cfgs/sumv2_triangle/${CFG}.yaml" \
|
||||
mode=resume \
|
||||
--pretrained_path "$ckpt" \
|
||||
dataset.common.data_root="$DATA" \
|
||||
dataset.train.voxel_max="$VOXEL_MAX" \
|
||||
dataset.val.voxel_max="$VAL_VOXEL_MAX" \
|
||||
epochs="$EPOCHS" \
|
||||
val_freq="$VAL_FREQ" \
|
||||
wandb.use_wandb=False \
|
||||
>> "$TRAINLOG" 2>&1
|
||||
rc=$?
|
||||
fi
|
||||
|
||||
if [ "$rc" -eq 0 ]; then
|
||||
say "training finished cleanly (rc=0) at epoch $(last_epoch)"
|
||||
write_status "done"
|
||||
say "last 20 lines:"
|
||||
tail -20 "$TRAINLOG" | tee -a "$LOG"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
say "attempt $attempt exited rc=$rc at epoch $(last_epoch)"
|
||||
tail -15 "$TRAINLOG" | tee -a "$LOG"
|
||||
|
||||
new_ckpt=$(find_ckpt)
|
||||
if [ "$attempt" -gt 0 ] && [ "$new_ckpt" = "$prev_ckpt" ]; then
|
||||
say "no new checkpoint since the last attempt -- failing before training, giving up"
|
||||
write_status "failed-no-progress"
|
||||
exit "$rc"
|
||||
fi
|
||||
prev_ckpt="$new_ckpt"
|
||||
|
||||
attempt=$((attempt + 1))
|
||||
if [ "$attempt" -gt "$MAX_RETRIES" ]; then
|
||||
say "exhausted $MAX_RETRIES retries"
|
||||
write_status "failed-retries-exhausted"
|
||||
exit "$rc"
|
||||
fi
|
||||
|
||||
write_status "waiting ${RETRY_WAIT}s before retry"
|
||||
say "waiting ${RETRY_WAIT}s, then retrying"
|
||||
sleep "$RETRY_WAIT"
|
||||
|
||||
# if the VM itself restarted, the GPU may take a moment to be usable again
|
||||
for i in $(seq 1 10); do
|
||||
if nvidia-smi -L >/dev/null 2>&1; then break; fi
|
||||
say " GPU not ready yet ($i/10)"
|
||||
sleep 15
|
||||
done
|
||||
done
|
||||
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env bash
|
||||
# SUM Parts - A/B the validation protocol on the same checkpoint
|
||||
#
|
||||
# Training reported val_miou 17.19 @E90, but a standalone mode=val gave 4.20.
|
||||
# The only difference is dataset.val.voxel_max:
|
||||
#
|
||||
# A) 64000 what training used after the memory mitigation (§3-2-1),
|
||||
# i.e. validation on a 64k-point crop, matching the training
|
||||
# input size
|
||||
# B) null the cfg's own default: the whole ~700k-point tile in one
|
||||
# forward pass
|
||||
#
|
||||
# PointNet pools a single global feature over whatever it is given, so input
|
||||
# size is not a neutral knob -- this measures how much it moved the number.
|
||||
set -uo pipefail
|
||||
|
||||
CONDA_ROOT="$HOME/miniconda3"
|
||||
SEG="$HOME/sum-parts/semantic_segmentation/PointNeXt_bundle/examples/segmentation"
|
||||
DATA="$HOME/sum-parts/data/face_labeling/texsp_pcl"
|
||||
OUT="$HOME/sum-parts/runs/val_ab"
|
||||
|
||||
source "$CONDA_ROOT/etc/profile.d/conda.sh"
|
||||
conda activate sumparts
|
||||
export WANDB_MODE=disabled WANDB_SILENT=true CUDA_HOME="$CONDA_PREFIX"
|
||||
export PYTORCH_CUDA_ALLOC_CONF="garbage_collection_threshold:0.7,max_split_size_mb:128"
|
||||
|
||||
mkdir -p "$OUT"
|
||||
cd "$SEG"
|
||||
|
||||
CKPT="${CKPT:-$(find "$SEG/log/sumv2_triangle" -name '*_ckpt_best.pth' -printf '%T@ %p\n' \
|
||||
| sort -rn | head -1 | cut -d' ' -f2-)}"
|
||||
echo "checkpoint: $(basename "$CKPT")"
|
||||
echo
|
||||
|
||||
run_val() {
|
||||
local tag="$1"; shift
|
||||
local log="$OUT/val_${tag}.log"
|
||||
python -u main.py \
|
||||
--cfg ../../cfgs/sumv2_triangle/pointnet.yaml \
|
||||
mode=val \
|
||||
--pretrained_path "$CKPT" \
|
||||
dataset.common.data_root="$DATA" \
|
||||
wandb.use_wandb=False \
|
||||
val_batch_size=1 \
|
||||
"$@" \
|
||||
> "$log" 2>&1
|
||||
local rc=$?
|
||||
if [ $rc -ne 0 ]; then
|
||||
echo " [$tag] FAILED rc=$rc"
|
||||
tail -6 "$log"
|
||||
return 1
|
||||
fi
|
||||
grep -aA1 'Best ckpt' "$log" | tail -2 | sed "s/^/ [$tag] /"
|
||||
}
|
||||
|
||||
echo "=== A: voxel_max 64000 (what training measured) ==="
|
||||
run_val capped dataset.val.voxel_max=64000
|
||||
echo
|
||||
|
||||
echo "=== B: voxel_max null (cfg default, whole tile) ==="
|
||||
run_val full
|
||||
echo
|
||||
|
||||
echo "logs in $OUT"
|
||||
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env bash
|
||||
# SUM Parts - check the downloaded archives survived
|
||||
#
|
||||
# The WSL VM has now died twice under load, and the first time it left pip's
|
||||
# in-flight files as 0-byte stubs. A download interrupted the same way leaves a
|
||||
# truncated zip, so verify before trusting anything that was in flight.
|
||||
set -euo pipefail
|
||||
|
||||
source "$HOME/miniconda3/etc/profile.d/conda.sh"
|
||||
conda activate sumparts
|
||||
|
||||
python - <<'PY'
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
d = Path.home() / "sum-parts/data/_archives"
|
||||
expected = {"demo.zip": 0.12, "mesh.zip": 0.52, "pcl.zip": 4.66} # GB, from the HF listing
|
||||
|
||||
if not d.exists():
|
||||
print(f"{d} missing")
|
||||
raise SystemExit(1)
|
||||
|
||||
bad = []
|
||||
for f in sorted(d.glob("*.zip")):
|
||||
gb = f.stat().st_size / 1e9
|
||||
want = expected.get(f.name)
|
||||
line = f"{f.name:<12} {gb:>6.2f} GB"
|
||||
if want:
|
||||
line += f" (expected ~{want:.2f} GB)"
|
||||
if gb < want * 0.97:
|
||||
line += " TRUNCATED"
|
||||
bad.append(f.name)
|
||||
print(line)
|
||||
continue
|
||||
try:
|
||||
z = zipfile.ZipFile(f)
|
||||
broken = z.testzip()
|
||||
if broken:
|
||||
line += f" CORRUPT at {broken}"
|
||||
bad.append(f.name)
|
||||
else:
|
||||
line += f" OK, {len(z.namelist())} entries"
|
||||
except Exception as e:
|
||||
line += f" UNREADABLE {type(e).__name__}: {e}"
|
||||
bad.append(f.name)
|
||||
print(line)
|
||||
|
||||
for name in expected:
|
||||
if not (d / name).exists():
|
||||
print(f"{name:<12} MISSING")
|
||||
|
||||
print()
|
||||
print("all good" if not bad else f"re-download: {', '.join(bad)}")
|
||||
raise SystemExit(1 if bad else 0)
|
||||
PY
|
||||
@@ -0,0 +1,100 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check that every compiled extension and import the sumv2 training path needs
|
||||
is actually present.
|
||||
|
||||
Extension import names do not match their directory names, which is an easy way
|
||||
to waste an hour chasing a build that already succeeded:
|
||||
|
||||
openpoints/cpp/pointnet2_batch -> pointnet2_batch_cuda
|
||||
openpoints/cpp/pointops -> pointops_cuda
|
||||
openpoints/cpp/chamfer_dist -> chamfer
|
||||
openpoints/cpp/emd -> emd_cuda (package name: emd_ext)
|
||||
openpoints/cpp/subsampling -> openpoints.cpp.subsampling.grid_subsampling
|
||||
|
||||
Run from PointNeXt_bundle/ (or anywhere, if openpoints is importable).
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def add_openpoints_to_path() -> Path | None:
|
||||
"""Put PointNeXt_bundle on sys.path.
|
||||
|
||||
main.py does this itself with a hardcoded '../../', but this script lives
|
||||
outside the repo, so walk up from cwd (then from the default clone path)
|
||||
until a directory containing openpoints/ turns up.
|
||||
"""
|
||||
candidates = [Path.cwd(), *Path.cwd().parents,
|
||||
Path.home() / "sum-parts/semantic_segmentation/PointNeXt_bundle"]
|
||||
for c in candidates:
|
||||
if (c / "openpoints" / "__init__.py").exists():
|
||||
sys.path.insert(0, str(c))
|
||||
return c
|
||||
return None
|
||||
|
||||
|
||||
MODULES = [
|
||||
"torch",
|
||||
"numpy",
|
||||
"pointnet2_batch_cuda",
|
||||
"pointops_cuda",
|
||||
"chamfer",
|
||||
"emd_cuda",
|
||||
"torch_scatter",
|
||||
"plyfile",
|
||||
"wandb",
|
||||
"trimesh",
|
||||
]
|
||||
|
||||
FROM_IMPORTS = [
|
||||
("openpoints.cpp.subsampling", "grid_subsampling"),
|
||||
("openpoints.models", "build_model_from_cfg"),
|
||||
("openpoints.dataset", "build_dataloader_from_cfg"),
|
||||
]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ok = True
|
||||
|
||||
root = add_openpoints_to_path()
|
||||
print(f"openpoints root: {root or 'NOT FOUND'}")
|
||||
if root is None:
|
||||
ok = False
|
||||
|
||||
try:
|
||||
import torch
|
||||
print(f"torch {torch.__version__} | cuda {torch.version.cuda} | "
|
||||
f"available {torch.cuda.is_available()}")
|
||||
if torch.cuda.is_available():
|
||||
print(f"device: {torch.cuda.get_device_name(0)}")
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"torch import failed: {e}")
|
||||
return 1
|
||||
|
||||
print()
|
||||
for name in MODULES:
|
||||
try:
|
||||
__import__(name)
|
||||
print(f"{name:32s} OK")
|
||||
except Exception as e: # noqa: BLE001
|
||||
ok = False
|
||||
print(f"{name:32s} FAIL {type(e).__name__}: {e}")
|
||||
|
||||
print()
|
||||
for mod, attr in FROM_IMPORTS:
|
||||
try:
|
||||
m = __import__(mod, fromlist=[attr])
|
||||
getattr(m, attr)
|
||||
print(f"{mod + '.' + attr:32s} OK")
|
||||
except Exception as e: # noqa: BLE001
|
||||
ok = False
|
||||
print(f"{mod + '.' + attr:32s} FAIL {type(e).__name__}: {e}")
|
||||
|
||||
print()
|
||||
print("ALL OK" if ok else "SOME CHECKS FAILED")
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env bash
|
||||
# SUM Parts - did the run come back to healthy speed?
|
||||
#
|
||||
# Healthy for pointnet at voxel_max 64000 on this box is ~0.29 s/it (benchmark)
|
||||
# and ~103 s/epoch. The degraded state was ~3.1 s/it and ~1100 s/epoch, caused
|
||||
# by the caching allocator pool overflowing VRAM into host RAM.
|
||||
set -uo pipefail
|
||||
|
||||
RUNROOT="$HOME/sum-parts/runs"
|
||||
D="${1:-$(cat "$RUNROOT/.latest" 2>/dev/null)}"
|
||||
LOG="$D/train.log"
|
||||
|
||||
echo "run: $D"
|
||||
[ -f "$LOG" ] || { echo " no train.log yet"; exit 1; }
|
||||
|
||||
echo
|
||||
echo "=== resume confirmation ==="
|
||||
grep -aE 'Resume|Successful Loading|start_epoch' "$LOG" | tail -3 || echo " (none found)"
|
||||
|
||||
echo
|
||||
echo "=== current epoch ==="
|
||||
grep -aoE 'Train Epoch \[[0-9]+/[0-9]+\]' "$LOG" | tail -1 || echo " not started"
|
||||
|
||||
echo
|
||||
echo "=== recent iteration rates ==="
|
||||
grep -aoE '[0-9.]+(s/it|it/s)' "$LOG" | tail -12 | tr '\n' ' '
|
||||
echo
|
||||
|
||||
echo
|
||||
echo "=== GPU ==="
|
||||
nvidia-smi --query-gpu=memory.used,memory.total,utilization.gpu,power.draw --format=csv,noheader
|
||||
|
||||
echo
|
||||
echo "=== verdict ==="
|
||||
# tqdm prints either "N.NNs/it" or "N.NNit/s" depending on which side of 1 the
|
||||
# rate falls, so take whichever form appeared LAST and normalise to s/it.
|
||||
# (Grepping only for 's/it' picks up a stale warm-up reading and misjudges a
|
||||
# run that has since recovered.)
|
||||
last=$(grep -aoE '[0-9.]+(s/it|it/s)' "$LOG" | tail -1)
|
||||
if [ -z "$last" ]; then
|
||||
echo " no rate readings yet"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
sec=$(awk -v r="$last" 'BEGIN {
|
||||
if (r ~ /it\/s$/) { sub(/it\/s$/, "", r); printf "%.4f", (r > 0 ? 1.0 / r : 0) }
|
||||
else { sub(/s\/it$/, "", r); printf "%.4f", r }
|
||||
}')
|
||||
|
||||
echo " last reading: $last -> ${sec} s/it"
|
||||
awk -v v="$sec" 'BEGIN {
|
||||
if (v < 0.8) print " HEALTHY (benchmark is 0.291 s/it)";
|
||||
else if (v < 1.5) print " MARGINAL";
|
||||
else print " DEGRADED -- allocator likely spilling to host RAM";
|
||||
}'
|
||||
Reference in New Issue
Block a user