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:
nbright
2026-08-21 10:29:25 +09:00
co-authored by Claude Opus 5
commit 609d9a6972
52 changed files with 5463 additions and 0 deletions
+15
View File
@@ -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/
+48
View File
@@ -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**이다. 상업 이용은 원저자 허락이 필요하다.
+196
View File
@@ -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
+646
View File
@@ -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 &lt; 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.991.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>
+157
View File
@@ -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()
+113
View File
@@ -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"
+34
View File
@@ -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
+22
View File
@@ -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)")
+25
View File
@@ -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
+98
View File
@@ -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()
+81
View File
@@ -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()
+47
View File
@@ -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
+58
View File
@@ -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"
+179
View File
@@ -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()
+28
View File
@@ -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"
+89
View File
@@ -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"
+35
View File
@@ -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")"
+70
View File
@@ -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"
+84
View File
@@ -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"
+97
View File
@@ -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()
+34
View File
@@ -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')"
+35
View File
@@ -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'))"
+48
View File
@@ -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"
+40
View File
@@ -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"
+54
View File
@@ -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
+279
View File
@@ -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()
+113
View File
@@ -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
+56
View File
@@ -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
+82
View File
@@ -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"
+73
View File
@@ -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)"
+58
View File
@@ -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"
+68
View File
@@ -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()
+22
View File
@@ -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"
+58
View File
@@ -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"
+55
View File
@@ -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"
+52
View File
@@ -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"
+52
View File
@@ -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"
+79
View File
@@ -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"
+29
View File
@@ -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"
+49
View File
@@ -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"
+68
View File
@@ -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"
+24
View File
@@ -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)"
+74
View File
@@ -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"
+32
View File
@@ -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."
+54
View File
@@ -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"
+79
View File
@@ -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"
+215
View File
@@ -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
+64
View File
@@ -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"
+55
View File
@@ -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
+100
View File
@@ -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())
+55
View File
@@ -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";
}'