diff --git a/.gitignore b/.gitignore index 35e3147..82f7847 100644 --- a/.gitignore +++ b/.gitignore @@ -191,4 +191,6 @@ cache/* # Example photos (they are big and should not be cloned by everyone using the repo) img/outdoor_reconstruction/* -saved_maps/* \ No newline at end of file +saved_maps/* + +result/* \ No newline at end of file diff --git a/docs/RELLIS_REPLICATION.md b/docs/RELLIS_REPLICATION.md new file mode 100644 index 0000000..81b68d3 --- /dev/null +++ b/docs/RELLIS_REPLICATION.md @@ -0,0 +1,213 @@ +# RELLIS-3D Table V replication attempt + +## TL;DR + +Across **four independent configurations** — including the literal protocol Simon Schwaiger described in [PR #2 review](https://github.com/SimonSchwaiger/otas/pull/2) (6-class terrain subset, `neg=["thing"]`, `threshold=0.8`) run against the **first commit** of this repository (`6aec2d4`, also as Simon recommended) — our best mIoU on the 1672-frame RELLIS-3D test split is **6.70%**, vs the paper's Table V claim of **48.48% (DINOv2 ViT-S/14)**. Residual gap: **~42 mIoU**, and it is not sensitive to commit version, `shared_feat_resolution`, `n_components`, `dinov2_input_size`, or input image resize. + +The structural reason is visible in the per-class breakdown: `semantic_mask.similarity` min-max-normalises *per image*, so the threshold@0.8 binary decision fires on the noisiest 20% of pixels in every frame where a class is absent. Across the ~1500 absent-class frames per sparse class (`dirt` appears in 13 frames, `water` in 19, `rubble` in 145), this accumulates into hundreds of millions of FPs that drown every sparse class's IoU. `bush` (present in 1658 of 1672 frames) is the only class with a meaningful score (~35), and it carries the entire 6-class mean. + +We suspect the remaining gap is an **mIoU averaging convention** that filters out frames where the class is absent in GT (per-frame mIoU averaged only over frames-with-class-present, then meaned across classes). This is a one-knob change — happy to run it and post the result — but we want to verify the convention rather than guess. + +## Simon's clarification (PR #2 review, 2026-05) + +Quoted verbatim from the PR conversation: + +> we run Rellis-3D only on a subset of classes relevant to terrain segmentation +> +> ```python +> class_prompts = {1: "dirt", 6: "water", 10: "asphalt", +> 19: "bush", 33: "mud", 34: "rubble"} +> neg_prompts = ["thing"] +> threshold_value = 0.8 +> ``` +> +> I'd also recommend switching to the first commit of this repository (that's the exact code we ran the evaluation on). + +This clarification superseded our initial assumption (20-class argmax of bare class names with no negative prompt). We rebuilt the eval against the literal protocol — see [`own_eval/own_RELLIS_paper.py`](../own_eval/own_RELLIS_paper.py) — and re-ran on both current `main` and on a `6aec2d4` worktree. + +## Full investigation table + +All runs: zero-shot, no mask refinement, no spatial, DINOv2 ViT-S/14 + MaskCLIP ViT-B/16, native 1200×1920 input. 1672-frame test split. Hardware: NVIDIA RTX PRO 6000 Blackwell, torch 2.12.0+cu132. + +| # | Code | Config | Protocol | mIoU | +|--:|---|---|---|--:| +| 1 | OTAS-repo `OTAS_small.json`-shaped default (when investigation started) | d=32, Cr=12, dinov2 input 518, 480×640 | 20 bare class names, argmax | 16.70 | +| 2 | Current `main` | §VII.A: d=64, Cr=24, dinov2 input 224, 1024×1024 | 20 bare class names, argmax | 15.43 | +| 3 | Current `main`, [`own_RELLIS.py`](../own_eval/own_RELLIS.py) | §VII.A: d=64, Cr=24, dinov2 input 224, native 1200×1920 | 20 bare class names, argmax | 15.66 | +| 4 | Current `main`, [`own_RELLIS_paper.py`](../own_eval/own_RELLIS_paper.py) | §VII.A: d=64, Cr=24, dinov2 input 224, native | **Simon's protocol**: 6 classes, `neg=["thing"]`, `t=0.8` | **6.70** | +| 5 | **First commit (`6aec2d4`)** + worktree | **First-commit defaults**: d=32, Cr=48, dinov2 input 518 | Simon's protocol: 6 classes, `neg=["thing"]`, `t=0.8` | **6.64** | +| 6 | **First commit (`6aec2d4`)** + worktree | §VII.A: d=64, Cr=24, dinov2 input 224 | Simon's protocol: 6 classes, `neg=["thing"]`, `t=0.8` | **6.63** | +| — | — | — | **Paper Table V claim (DINOv2 ViT-S/14)** | **48.48** | + +Rows 4–6 are the three independent attempts at Simon's literal Table V protocol. They land within **0.07 mIoU** of each other across two different code versions and two different config presets — `semantic_mask.similarity` math is identical between first-commit and current `main` (we diffed `src/model.py` to confirm: same cosine sim, same `clamp(sim_max - sim_min, min=0.05)` normalisation, same `(lr_sims_norm > threshold)` binarisation). Config defaults differ between the two commits but the effect on the 6-class mIoU is below kmeans-clustering noise. + +## Per-class numbers under Simon's protocol — current main run (row 4) + +| class | raw id | n frames present (of 1672) | total GT px | OTAS IoU | TP | FP | FN | +|---|--:|--:|--:|--:|--:|--:|--:| +| dirt | 1 | 13 | 9,690 | **0.00** | 0 | 765,332,340 | 9,690 | +| water | 6 | 19 | 959,662 | **0.35** | 500,432 | 140,051,968 | 459,230 | +| asphalt | 10 | 503 | 3,850,438 | **0.66** | 2,398,866 | 362,257,374 | 1,451,572 | +| bush | 19 | 1658 | 662,926,185 | **35.14** | 395,710,902 | 463,160,538 | 267,215,283 | +| mud | 33 | 574 | 29,818,401 | **3.59** | 19,584,570 | 516,240,750 | 10,233,831 | +| rubble | 34 | 145 | 1,907,260 | **0.47** | 1,738,363 | 364,129,997 | 168,897 | +| **mIoU(6cls)** | | | | **6.70** | | | | + +First-commit + first-commit-defaults (row 5) per-class IoU: 0.00 / 0.38 / 0.62 / 34.87 / 3.50 / 0.47 → **6.64**. +First-commit + §VII.A overrides (row 6) per-class IoU: 0.00 / 0.44 / 0.66 / 34.67 / 3.55 / 0.47 → **6.63**. + +The pattern is identical across all three: `bush` carries the headline (~35), every other class is ≤ 4, sparse classes hit ~0 because TPs (thousands) are dwarfed by FPs (hundreds of millions). + +## Root cause of the gap, as best we can pin it down + +`semantic_mask.similarity` in [`src/model.py`](../src/model.py) does: + +```python +lr_sims = sum(pos_sims) / len(pos_sims) - sum(neg_sims) / (len(neg_sims) + 1e-8) +sim_min, sim_max = lr_sims.min(), lr_sims.max() +sim_range = torch.clamp(sim_max - sim_min, min=0.05) +lr_sims_norm = (lr_sims - sim_min) / (sim_range + 1e-8) +``` + +The min-max normalisation is **per image**. On a frame where the class of interest is absent (e.g. `dirt` on the ~1659 dirt-free frames out of 1672), the raw cosine similarity range is small but non-zero — pure noise. The `clamp(min=0.05)` lower-bounds the range, but 0.05 is still tight enough that the noise distribution gets stretched to fill [0, 1]. The threshold@0.8 then fires on roughly the noisiest 20% of pixels of that frame. + +Across ~1500 absent-class frames × 1920×1200 pixels × ~20% above-threshold, that's hundreds of millions of false positives per sparse class. The TPs on the rare frames where the class IS present (a few thousand pixels in the dirt case) are completely overwhelmed. + +`bush` doesn't suffer this because it's present in 99.2% of frames — the per-image normalisation is normalising real signal, not noise. + +## Reproduction + +### Prerequisites + +- Python 3.12 venv with `requirements.txt`. +- DINOv2 + CLIP checkpoints via `bash download_checkpoints.sh`. SAM2 is not required (we run with `enable_mask_refinement: false`). +- RELLIS-3D dataset extracted under a single root, e.g. `/path/to/Rellis-3D`: + + ``` + Rellis-3D/ + train.lst val.lst test.lst # 44 KB Image Split File archive + 00000/ 00001/ 00002/ 00003/ 00004/ + pylon_camera_node/ # RGB .jpg, 1920×1200 + pylon_camera_node_label_id/ # uint8 label-id .png, 1920×1200 + ``` + + The 4 Google Drive archives needed (per upstream `unmannedlab/RELLIS-3D` README) are: Full Images (11 GB), Full Image Annotations ID Format (94 MB), Image Split File (44 KB), Ontology Definition (18 KB). + +### Run on current main + §VII.A overrides (row 4 above) + +```bash +cd /path/to/OTAS +env -u LD_LIBRARY_PATH .venv/bin/python own_eval/own_RELLIS_paper.py \ + --data_dir /path/to/Rellis-3D \ + --config_preset paper_vii_a +cat result/Pred/RELLIS_rgb_paper/results.txt | grep mIoU_6cls +# → mIoU_6cls: 6.7020 +``` + +### Run on first commit (`6aec2d4`) with first-commit defaults (row 5) + +```bash +git worktree add /tmp/otas-first-commit 6aec2d4 +ln -sf $(pwd)/src/foundation_models/dinov2_checkpoints/dinov2_vits14_reg4_pretrain.pth \ + /tmp/otas-first-commit/src/foundation_models/dinov2_checkpoints/ +ln -sf $(pwd)/src/foundation_models/clip_checkpoints/ViT-B-16.pt \ + /tmp/otas-first-commit/src/foundation_models/clip_checkpoints/ +# One-line py3.12 compat fix the first commit predates: +sed -i 's|from pkg_resources import packaging|import packaging.version|' \ + /tmp/otas-first-commit/src/foundation_models/maskclip_onnx/clip.py +mkdir -p /tmp/otas-first-commit/own_eval +cp own_eval/{own_RELLIS_paper.py,rellis_dataset.py} /tmp/otas-first-commit/own_eval/ +cd /tmp/otas-first-commit +env -u LD_LIBRARY_PATH /path/to/OTAS/.venv/bin/python own_eval/own_RELLIS_paper.py \ + --data_dir /path/to/Rellis-3D \ + --config_preset first_commit_defaults \ + --out_root /tmp/otas-first-commit/result/Pred \ + --out_suffix _firstcommit_defaults +cat result/Pred/RELLIS_rgb_firstcommit_defaults/results.txt | grep mIoU_6cls +# → mIoU_6cls: 6.6397 +``` + +### Run on first commit + §VII.A overrides (row 6) + +Same as row 5 but `--config_preset paper_vii_a --out_suffix _firstcommit_viia`. Lands at `mIoU_6cls: 6.6317`. + +The `env -u LD_LIBRARY_PATH` prefix forces the cu132 torch wheel's bundled cuBLAS to win over the system `/usr/local/cuda-*` library on Blackwell GPUs; without it MaskCLIP's forward passes fail with `cublasLtGetVersion` symbol errors. Drop it on other GPU/CUDA combinations. + +## What's in this PR + +| File | Purpose | +|---|---| +| [`own_eval/own_RELLIS.py`](../own_eval/own_RELLIS.py) | 20-class argmax driver (rows 1–3 above). CLI: `--enable_mask_refinement`, `--input_h`/`--input_w`. §VII.A hyperparameters baked into `otas_segmentor.py:_DEFAULT_CONFIG`. | +| [`own_eval/own_RELLIS_paper.py`](../own_eval/own_RELLIS_paper.py) | Simon's Table V protocol driver (rows 4–6 above). CLI: `--config_preset {paper_vii_a, first_commit_defaults}`, `--threshold` (default 0.8), `--max_frames` for smoke runs, `--save_preds` to cache the (6, H, W) per-frame binary stack. Calls `model.semantic_mask.similarity()` + threshold directly (no N-way argmax adapter). | +| [`own_eval/rellis_dataset.py`](../own_eval/rellis_dataset.py) | RELLIS-3D PyTorch `Dataset`. Parses `train.lst`/`val.lst`/`test.lst` (2-col `