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 ` ` per line), reads RGB jpg + uint8 label-id PNG, applies the unknown-incl 20-class LUT from `ontology.yaml` (raw 0/1/3/…/34 → contig 0..19). The paper-protocol driver reuses this dataset and reads raw label PNGs directly so it can compare against Simon's raw-ID mapping without going through the contig LUT. |
+| [`own_eval/eval_common.py`](../own_eval/eval_common.py) | Per-frame inference loop + confusion-matrix-pooled IoU for the 20-class argmax path. Forwards arbitrary `config_overrides` dict so the SAM toggle and the §VII.A hyperparameters can be set per-run. |
+| [`own_eval/otas_segmentor.py`](../own_eval/otas_segmentor.py) | N-way `OTASEncoder` adapter for the 20-class argmax path: `language_map.embed_image → per-class clip_similarity → bilinear up → argmax`. SAM-on path delegates per-class refinement to `semantic_mask.binary_mask_refined(..., ret_dict=True)` then argmaxes across the per-class `pred_logits`. |
+| [`docs/RELLIS_REPLICATION.md`](RELLIS_REPLICATION.md) | This file. |
+| [`src/foundation_models/maskclip_onnx/clip.py`](../src/foundation_models/maskclip_onnx/clip.py) | One-line py3.12 compat: `import packaging.version` (replaces `from pkg_resources import packaging`, which doesn't expose `.version.parse` on modern setuptools). |
+| `.gitignore` | Adds `result/*` so cached per-frame predictions don't get committed. |
+
+## What we have tested and ruled out
+
+| Hypothesis | Tested? | Result |
+|---|---|---|
+| `shared_feat_resolution = 64` (§VII.A) | Yes | Moves 20-class mIoU ~−1 (16.70 → 15.43 combined with the other §VII.A knobs). Moves 6-class mIoU ~+0.01 (6.64 → 6.63 first-commit + this knob only). |
+| `n_components = 24` (PCA `Cr`, §VII.A) | Yes | Combined with d=64: no big jump in either protocol. |
+| `dinov2_input_size = 224` (DINOv2 native 16×16 grid, §VII.A) | Yes | Combined with d=64: no big jump. |
+| `dinov2_input_size = 518` (first-commit default) | Yes | 6-class run on first commit: 6.64, vs 6.63 with size=224. No meaningful difference. |
+| Input image resize to 1024×1024 (§VII.A literal) | Yes (20-class only) | No big jump (15.43 vs 15.66 native). |
+| Mask refinement off | Confirmed | Both our runs and the paper Table V have it off. |
+| **First commit code (`6aec2d4`)** | **Yes** — both with first-commit-default config and with §VII.A overrides | **6.64 / 6.63** — `semantic_mask.similarity` math is byte-identical to current `main`; the diff between first-commit and current is config defaults + the `featurizer.` module reorganisation, none of which affects inference outputs. |
+| Negative prompt of `"thing"` | Yes (Simon's protocol) | Applied. |
+| Threshold-at-0.8 binary scoring | Yes (Simon's protocol) | Applied. |
+| 6-class terrain subset | Yes (Simon's protocol) | Applied. |
+| 20-class argmax (our original assumption) | Yes | Lands at 15.66; superseded by Simon's clarification. |
+
+## Open hypotheses we have NOT tested
+
+After rows 4–6 establish that the gap is insensitive to commit version, config, and resize, the remaining structural unknowns are:
+
+1. **mIoU averaging convention.** We accumulate one (TP, FP, FN) triple per class across all 1672 frames and compute IoU once over the totals. The per-class FP counts above (765M for dirt across 1659 absent-class frames) directly tank pooled IoU on sparse classes. If Table V's headline is computed *per frame*, with each class's contribution averaged **only over frames where the class is present in GT**, the absent-class FPs drop out and the headline rises dramatically. We have not yet run this metric — it's a one-knob change to `own_RELLIS_paper.py` (accumulate per-frame TP/FP/FN per class, then average IoU per class only across frames-with-GT-present, then mean across classes). Happy to run it and report back.
+
+2. **Test-split version / curation.** We use `test.lst` from the 44 KB Image Split File archive on the upstream `unmannedlab/RELLIS-3D` Google Drive (1672 lines, 2-column ` `). All 6 paper classes appear in this split, but with very skewed frequency: dirt in 13 of 1672 frames, water in 19, rubble in 145. The paper may use a different release of this split, a curated subset (e.g. frames where ≥1 of the 6 classes is present), or evaluate only on frames where the class of interest is in GT.
+
+3. **Per-class threshold.** Simon's PR comment specifies `threshold_value = 0.8` as a single scalar. Worth confirming that's one threshold applied uniformly across all 6 classes — not e.g. per-class thresholds tuned on val.
+
+## Ask for the upstream maintainers
+
+Simon's PR #2 review already covered the 6-class subset, negative prompt, and threshold — thank you for that, it materially clarified things and ruled out the 20-class argmax interpretation. To close the remaining ~42-point gap unambiguously, we'd value:
+
+1. **The exact eval script that produced Table V** — even a minimal `eval_rellis.py` analogous to `demo.ipynb`. The single most important question we cannot answer from the released code is the **mIoU averaging convention**: pooled-across-frames per class (what we do) vs per-frame averaged over frames-with-class-present per class? Given the sparse-class FP problem visible in the per-class table above, this is the most likely structural source of our gap.
+
+2. **The `OTAS_*.json` config file used for Table V.** The shipped `OTAS_small.json` has `enable_mask_refinement: true` and `shared_feat_resolution: 16`, which contradicts §VII.A's `d=64`. Knowing which config file produced the 48.48 number would resolve any remaining ambiguity about d / Cr / dinov2 input size — though our test of 6 different configs landing within 0.07 mIoU of each other suggests config is not the load-bearing variable here.
+
+3. **The split file used.** If the paper evaluates on a different split than upstream `test.lst`, or restricts to frames-with-class-present, that's a simple fix on our end.
+
+We're happy to fold our `own_RELLIS_paper.py` into the official scaffolding if that's the right approach, or rewrite it on top of an upstream `eval_rellis.py` once released. Either way, having a runnable script that produces the paper's headline number would let downstream users verify and build on Table V with confidence.
+
+## Verification
+
+After applying this PR to a fresh OTAS checkout:
+
+```bash
+# 1. Setup
+git clone ; cd otas
+python3.12 -m venv .venv
+.venv/bin/pip install -r requirements.txt
+bash download_checkpoints.sh
+
+# 2. Stage RELLIS-3D under /path/to/Rellis-3D (see "Prerequisites" above)
+
+# 3. Run Simon's Table V protocol on current main
+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
+grep mIoU_6cls result/Pred/RELLIS_rgb_paper/results.txt
+# → mIoU_6cls: 6.70xx (kmeans noise: ±0.05)
+
+# 4. Run the original 20-class argmax protocol for comparison
+env -u LD_LIBRARY_PATH .venv/bin/python own_eval/own_RELLIS.py \
+ --data_dir /path/to/Rellis-3D
+grep -E "full_mIoU|fg_only_mIoU" result/Pred/RELLIS_rgb/results.txt
+# → full_mIoU: 15.66xx, fg_only_mIoU: 16.48xx
+```
+
+Both should land well below 48.48. The first-commit reproduction (rows 5–6) requires the worktree dance documented in `Reproduction` above; it lands at 6.63–6.64 across two config presets.
diff --git a/own_eval/eval_common.py b/own_eval/eval_common.py
new file mode 100644
index 0000000..8153706
--- /dev/null
+++ b/own_eval/eval_common.py
@@ -0,0 +1,283 @@
+# Shared evaluation loop for OTAS on the unified 6-dataset scoreboard.
+#
+# Provides one entry point — `run_eval(dataset, class_names, out_dir, modality)` — that
+# every per-dataset script (own_GOD.py, own_BASEPROD.py, …) wraps with the dataset-
+# specific class list and DataLoader. The point of pulling this out is so the modality
+# handling, OTAS inference, prediction caching, and IoU metric computation are written
+# once and identical across all six datasets — matching the convention RADSeg's
+# eval.py and OpenRSS's own_*.py use, except deduplicated.
+#
+# Modality contract:
+# The OpenRSS datasets we re-use yield `(image_f, label, name)` where
+# `image_f` is a (4, H, W) float tensor in [0, 1] with channels [R, G, B, T].
+# For OTAS (which is RGB-trained DINOv2 + MaskCLIP) we expose two modalities:
+# - "rgb": pass channels [R, G, B] through OTAS unchanged.
+# - "thermal": replicate channel T to 3 channels (R = G = B = T) — mirrors how
+# RADSeg's "thermal" column is generated. DINOv2 is out-of-distribution
+# on thermal, which is the whole point of the comparison.
+# An earlier revision also emitted a 4th `th_vis` tensor (jet-colormapped thermal)
+# for qualitative panels; it was never consumed and has been removed from every
+# adapter to save the per-frame colormap+resize cost.
+#
+# Output layout (per dataset × modality):
+# /preds/.png uint8 grayscale, per-pixel class IDs.
+# /results.txt full + fg-only mIoU and per-class IoU.
+# /overlays/.png 3-up [input | gt | pred] panels for a
+# sparse sample (visual sanity only).
+
+import os
+import sys
+import time
+from pathlib import Path
+from typing import List, Optional
+
+import numpy as np
+from PIL import Image
+import torch
+from torch.utils.data import DataLoader
+from tqdm import tqdm
+
+# Make sibling otas_segmentor importable when invoked as `python -m own_eval.own_GOD`
+# from the OTAS root, or as `python own_eval/own_GOD.py` directly.
+_OWN_EVAL_DIR = str(Path(__file__).resolve().parent)
+if _OWN_EVAL_DIR not in sys.path:
+ sys.path.insert(0, _OWN_EVAL_DIR)
+
+
+def _tensor_to_pil_rgb(image_f: torch.Tensor) -> Image.Image:
+ # image_f: (4, H, W) float in [0, 1] from the OpenRSS DataLoader.
+ # Returns a PIL RGB image of the first 3 (RGB) channels.
+ rgb = image_f[:3].numpy()
+ rgb_u8 = (np.clip(rgb, 0.0, 1.0) * 255.0).astype(np.uint8).transpose(1, 2, 0)
+ return Image.fromarray(rgb_u8, mode="RGB")
+
+
+def _tensor_to_pil_thermal_as_rgb(image_f: torch.Tensor) -> Image.Image:
+ # Replicate the thermal channel (image_f[3]) to a 3-channel PIL RGB so DINOv2's
+ # ImageNet-mean normalization sees plausible-ish R = G = B input. This matches
+ # RADSeg's "thermal" treatment exactly — same image stream, different model
+ # encoder.
+ th = image_f[3].numpy()
+ th_u8 = (np.clip(th, 0.0, 1.0) * 255.0).astype(np.uint8)
+ th_rgb = np.stack([th_u8, th_u8, th_u8], axis=-1)
+ return Image.fromarray(th_rgb, mode="RGB")
+
+
+def _iou_per_class(conf: np.ndarray) -> np.ndarray:
+ # Symmetric IoU = TP / (TP + FP + FN) per class, derived from a (N, N) confusion
+ # matrix where rows are ground-truth and columns are predictions. Returns NaN for
+ # any class that has zero presence in both gt and prediction (so it doesn't drag
+ # the mean down to zero artificially).
+ tp = np.diag(conf).astype(np.float64)
+ fp = conf.sum(axis=0) - tp
+ fn = conf.sum(axis=1) - tp
+ denom = tp + fp + fn
+ iou = np.where(denom > 0, tp / np.maximum(denom, 1e-12), np.nan)
+ return iou
+
+
+def _resolve_palette(n_classes: int, palette: Optional[List[List[int]]]) -> np.ndarray:
+ # Returns an (>=n_classes, 3) uint8 LUT. If `palette` is given, it must have
+ # at least n_classes rows in the dataset's class order (row i = color for
+ # class i); otherwise we fall back to matplotlib tab20. Hand-curated palettes
+ # let GOD/BASEPROD overlays color-match the published ontology keys
+ # (and the RADSeg viz grid), instead of getting an arbitrary tab20 mapping.
+ if palette is not None:
+ arr = np.asarray(palette, dtype=np.uint8)
+ assert arr.ndim == 2 and arr.shape[1] == 3, f"palette must be (N, 3) RGB, got {arr.shape}"
+ assert arr.shape[0] >= n_classes, (
+ f"palette has {arr.shape[0]} colors but dataset has {n_classes} classes"
+ )
+ return arr
+ from matplotlib import cm
+ cmap = cm.get_cmap("tab20", max(n_classes, 20))
+ return (np.array([cmap(i)[:3] for i in range(max(n_classes, 20))]) * 255).astype(np.uint8)
+
+
+def _save_palette_overlay(pred: np.ndarray, n_classes: int, out_path: Path,
+ palette: Optional[List[List[int]]] = None):
+ lut = _resolve_palette(n_classes, palette)
+ overlay = lut[pred] # (H, W, 3)
+ Image.fromarray(overlay).save(out_path)
+
+
+def _save_3up(rgb_pil: Image.Image, gt: np.ndarray, pred: np.ndarray,
+ n_classes: int, out_path: Path,
+ palette: Optional[List[List[int]]] = None):
+ # Side-by-side: input RGB | GT palette | Pred palette. Used for a sparse sample
+ # of frames so we can eyeball alignment / failure modes without needing all
+ # overlays on disk.
+ lut = _resolve_palette(n_classes, palette)
+ h, w = pred.shape
+ rgb_np = np.asarray(rgb_pil.resize((w, h)))
+ # Treat any GT id >= n_classes (e.g. 255 ignore) as a black pixel rather than
+ # crashing the palette lookup.
+ gt_safe = np.where(gt < n_classes, gt, 0).astype(np.uint8)
+ gt_paint = lut[gt_safe]
+ pred_paint = lut[pred]
+ panel = np.concatenate([rgb_np, gt_paint, pred_paint], axis=1)
+ Image.fromarray(panel).save(out_path)
+
+
+def run_eval(
+ dataset,
+ class_names: List[str],
+ out_dir: str,
+ modality: str,
+ *,
+ num_overlay_samples: int = 12,
+ ignore_label: int = 255,
+ config_overrides: Optional[dict] = None,
+ palette: Optional[List[List[int]]] = None,
+ redraw_overlays_only: bool = False,
+):
+ # Args:
+ # dataset: a torch Dataset that yields (image_f (4,H,W), label (H,W),
+ # name (str)). The OpenRSS dataset adapters satisfy this
+ # contract.
+ # class_names: list of N strings. class_names[0] MUST be "unknown".
+ # out_dir: where to write preds/, overlays/, results.txt.
+ # modality: "rgb" or "thermal" (= thermal-as-RGB replica).
+ # num_overlay_samples: how many frames to emit 3-up overlays for. The full
+ # preds/ dir always has every frame, but overlays are
+ # sparse so we don't drown the disk.
+ # ignore_label: GT pixel value to treat as ignore (not counted in IoU). 255
+ # by default — matches the OpenRSS dataset convention.
+ # config_overrides: optional dict of OTAS config keys to override (e.g.
+ # {"enable_mask_refinement": True} to turn SAM2 on). Forwarded
+ # verbatim to OTASEncoder; see otas_segmentor._DEFAULT_CONFIG.
+ # palette: optional (N, 3) per-class RGB LUT in dataset class order.
+ # If omitted, overlays use matplotlib tab20. Pass GOD's
+ # GREAT_OUTDOORS_UNKNOWN_PALETTE to color-match the RADSeg
+ # viz grid and the published GOD ontology key.
+ # redraw_overlays_only: skip OTAS inference and metric computation; only
+ # re-render the sampled 3-up overlays from cached preds in
+ # /preds/. Used to refresh visualizations after a
+ # palette change without redoing the (slow) forward pass.
+ # Requires preds/ to already exist on disk.
+ assert modality in {"rgb", "thermal"}, f"modality must be rgb|thermal, got {modality!r}"
+
+ # Import the encoder here so OTAS only loads once per process (and not at module
+ # import time, which would prevent us from setting env vars first).
+ from otas_segmentor import OTASEncoder
+
+ out_dir = Path(out_dir)
+ preds_dir = out_dir / "preds"
+ overlays_dir = out_dir / "overlays"
+ preds_dir.mkdir(parents=True, exist_ok=True)
+ overlays_dir.mkdir(parents=True, exist_ok=True)
+
+ n_classes = len(class_names)
+
+ # We deliberately keep batch_size=1 because OTAS's language_map operates on PIL
+ # images one at a time (DINOv2 forward is autograd-disabled but not batched in
+ # OTAS's reference path).
+ loader = DataLoader(dataset, batch_size=1, num_workers=0, shuffle=False)
+ n_frames = len(dataset)
+
+ # Pick `num_overlay_samples` evenly-spaced indices for the qualitative panels.
+ overlay_indices = set(np.linspace(0, n_frames - 1, num=num_overlay_samples, dtype=int).tolist())
+
+ chosen = (
+ _tensor_to_pil_rgb if modality == "rgb" else _tensor_to_pil_thermal_as_rgb
+ )
+
+ # Redraw-only fast path: skip OTAS, skip metrics, just re-render overlays
+ # from cached preds. Saves the ~5-min forward pass when we only want to
+ # refresh visualizations after a palette change.
+ if redraw_overlays_only:
+ redrawn = 0
+ for idx, batch in enumerate(loader):
+ if idx not in overlay_indices:
+ continue
+ image_f, label, name = batch
+ image_f = image_f.squeeze(0)
+ label_np = label.squeeze(0).numpy()
+ if isinstance(name, (list, tuple)):
+ name = name[0]
+ name = str(name)
+ pred_path = preds_dir / f"{name}.png"
+ if not pred_path.exists():
+ print(f"[redraw] missing cached pred {pred_path}, skipping")
+ continue
+ pred_pil = Image.open(pred_path)
+ # Cached preds may live at a different resolution than the current
+ # dataset grid (e.g. an earlier run cached at pylon-native 1080×1440;
+ # the current default is 480×640). Resize with NEAREST so the panel
+ # composes — preds remain the canonical authoritative cache on disk.
+ target_h, target_w = label_np.shape
+ if pred_pil.size != (target_w, target_h):
+ pred_pil = pred_pil.resize((target_w, target_h), Image.NEAREST)
+ preds = np.asarray(pred_pil, dtype=np.uint8)
+ pil = chosen(image_f)
+ _save_3up(pil, label_np, preds, n_classes, overlays_dir / f"{name}.png",
+ palette=palette)
+ redrawn += 1
+ print(f"[{out_dir.name}] redrew {redrawn} overlays (modality={modality})")
+ return
+
+ encoder = OTASEncoder(class_names=class_names, config_overrides=config_overrides)
+
+ conf = np.zeros((n_classes, n_classes), dtype=np.int64)
+ t0 = time.time()
+
+ pbar = tqdm(loader, desc=f"OTAS[{modality}] {out_dir.name}", total=n_frames)
+ for idx, batch in enumerate(pbar):
+ image_f, label, name = batch
+ # DataLoader collates to a leading batch dim of 1 — strip it.
+ image_f = image_f.squeeze(0) # (4, H, W) float
+ label_np = label.squeeze(0).numpy() # (H, W) int64
+ # `name` may be a list (DataLoader collates strings) — pull out the scalar.
+ if isinstance(name, (list, tuple)):
+ name = name[0]
+ name = str(name)
+
+ pil = chosen(image_f)
+ preds, _probs = encoder.predict(pil) # uint8 (H, W)
+
+ # Bucket into the conf matrix, excluding ignore-label pixels.
+ valid = label_np != ignore_label
+ if valid.any():
+ gt_valid = label_np[valid]
+ pred_valid = preds[valid]
+ # Clip just in case — preds are already in [0..n_classes-1] but defensive.
+ gt_valid = np.clip(gt_valid, 0, n_classes - 1)
+ pred_valid = np.clip(pred_valid, 0, n_classes - 1)
+ bin_idx = gt_valid * n_classes + pred_valid
+ counts = np.bincount(bin_idx, minlength=n_classes * n_classes)
+ conf += counts.reshape(n_classes, n_classes)
+
+ # Cache pred PNG (uint8 grayscale) — argmax IDs, no palette, so any
+ # downstream tool can re-paint with its own palette.
+ Image.fromarray(preds).save(preds_dir / f"{name}.png")
+
+ if idx in overlay_indices:
+ _save_3up(pil, label_np, preds, n_classes, overlays_dir / f"{name}.png",
+ palette=palette)
+
+ elapsed = time.time() - t0
+ iou = _iou_per_class(conf)
+ full_miou = np.nanmean(iou)
+ fg_miou = np.nanmean(iou[1:]) # excludes the 'unknown' class at contig 0
+
+ # Write a single results.txt that's grep-friendly for the scoreboard updater.
+ with open(out_dir / "results.txt", "w") as f:
+ f.write(f"# OTAS eval — {out_dir.name}\n")
+ f.write(f"modality: {modality}\n")
+ f.write(f"n_frames: {n_frames}\n")
+ f.write(f"n_classes: {n_classes}\n")
+ f.write(f"elapsed_seconds: {elapsed:.1f}\n")
+ f.write(f"full_mIoU: {full_miou * 100:.4f}\n")
+ f.write(f"fg_only_mIoU: {fg_miou * 100:.4f}\n")
+ f.write("\nper_class_IoU:\n")
+ for name, val in zip(class_names, iou):
+ val_str = "nan" if np.isnan(val) else f"{val * 100:.4f}"
+ f.write(f" {name}: {val_str}\n")
+ f.write("\nconfusion_matrix_rows_gt_cols_pred:\n")
+ for row in conf:
+ f.write(" " + " ".join(str(int(c)) for c in row) + "\n")
+
+ print(f"[{out_dir.name}] full mIoU = {full_miou * 100:.2f}%, "
+ f"fg-only mIoU = {fg_miou * 100:.2f}% "
+ f"({n_frames} frames, {elapsed:.0f}s)")
+ return {"full_mIoU": full_miou, "fg_only_mIoU": fg_miou, "iou_per_class": iou}
diff --git a/own_eval/otas_segmentor.py b/own_eval/otas_segmentor.py
new file mode 100644
index 0000000..11407c2
--- /dev/null
+++ b/own_eval/otas_segmentor.py
@@ -0,0 +1,245 @@
+# Multi-class semantic-segmentation adapter for OTAS.
+#
+# Purpose:
+# OTAS's public API (`single_inference.similarity_single`) takes a list of `pos_prompts` and
+# `neg_prompts` and collapses them to a single per-pixel similarity map via
+# `mean(pos_sims) - mean(neg_sims)` (see src/model.py:semantic_mask.similarity). That shape
+# is binary-flavoured: one score map per (pos, neg) prompt set, then thresholded.
+#
+# To compare OTAS against RADSeg / OpenRSS on the unified 6-dataset scoreboard we need
+# per-pixel N-way argmax across 5–22 classes (one of which is the unified `unknown` at
+# contig 0). This adapter bypasses the aggregation step and instead:
+#
+# 1. Runs OTAS's language_map once per image, producing a pooled
+# (shared_feat_resolution, shared_feat_resolution, 512) embedding map.
+# 2. Encodes each of the N class names through MaskCLIP — using the BARE class string
+# only, no prompt template (no "a photo of …"), no negative prompts.
+# 3. Calls OTAS's lower-level `clip_similarity` einsum per class, producing N low-res
+# (H_lr, W_lr) similarity maps.
+# 4. Stacks them to (N, H_lr, W_lr), bilinear-upsamples to (N, H, W) (the same step
+# OTAS's `similarity_single` already uses for its single-map case), then argmaxes
+# across the class axis to get a (H, W) uint8 prediction tensor.
+#
+# Prompt-format rationale (user-specified for this evaluation):
+# - Bare class names, no template. RADSeg uses an imagenet-style 20-variant template,
+# OpenRSS uses `"a pohot of "` (sic). Each system gets the prompt format the user
+# specified for it; we do not apply templates that weren't part of OTAS's design.
+# - No negative prompts. Argmax across all N positive prompts (one of which is
+# `"unknown"` at contig 0) is the entire scoring rule.
+# - SAM2 mask refinement is off (matches RADSeg / OpenRSS which have no post-processing).
+#
+# Output contract:
+# `OTASEncoder.predict(pil_img)` returns `(preds, probs)` where
+# preds: np.uint8 (H, W) per-pixel argmax class IDs in [0..N-1]
+# probs: np.float32 (H, W, N) raw cosine similarities (NOT softmaxed; used only for
+# downstream introspection / saved overlays).
+# The output shape matches RADSeg's `(seg_probs, seg_preds)` ordering so per-dataset
+# eval scripts can mirror the RADSeg / OpenRSS scaffolding one-for-one.
+
+import os
+import sys
+import json
+import copy
+from pathlib import Path
+from typing import List, Tuple, Optional
+
+import numpy as np
+import torch
+import torch.nn.functional as F
+from PIL import Image
+
+# OTAS's `src/` is not a package; add it to sys.path so `import model` resolves to OTAS's
+# model.py, not torchvision.models or any other shadowing module.
+_OTAS_SRC = str(Path(__file__).resolve().parent.parent / "src")
+if _OTAS_SRC not in sys.path:
+ sys.path.insert(0, _OTAS_SRC)
+
+
+_DEFAULT_CONFIG = {
+ # SAM2 + Open3D / spatial reconstruction off — we only want the 2D semantic head.
+ "enable_mask_refinement": False,
+ "enable_spatial": False,
+ # Model compilation is broken on some torch.compile + Blackwell combinations and
+ # only matters for repeated calls inside a single process. Disable for stability.
+ "enable_model_compilation": False,
+ # OTAS paper Table V / supplementary §VII.A configuration. These four knobs match
+ # the published RELLIS-3D backbone-ablation protocol verbatim:
+ # - dinov2_input_size=224 yields DINOv2's native 16×16 patch grid (14-px patches),
+ # which is then bilinear-interpolated up to the d=64 shared resolution.
+ # - shared_feat_resolution=64 = the d=64 shared feature grid used by the paper.
+ # - n_clusters=24, n_components=24 = k=24, Cr=24 from the paper.
+ # These apply to every dataset on the scoreboard, not just RELLIS, so the OTAS
+ # column reports numbers under a single internally consistent config.
+ "dinov2_input_size": 224,
+ "dino_scale_factor": 2,
+ "shared_feat_resolution": 64,
+ "n_clusters": 24,
+ "n_components": 24,
+ "enable_amp_autocast": True,
+}
+
+
+def _write_temp_config(custom_overrides: Optional[dict] = None) -> str:
+ # OTAS picks up config overrides via the OTAS_CONFIG_PATH env var, which it parses as a
+ # JSON file. Write our overrides to a tempfile and return the path; OTAS keys not present
+ # here fall back to src/config.py defaults.
+ cfg = copy.deepcopy(_DEFAULT_CONFIG)
+ if custom_overrides:
+ cfg.update(custom_overrides)
+ cfg_path = Path("/tmp") / f"otas_adapter_cfg_{os.getpid()}.json"
+ cfg_path.write_text(json.dumps(cfg))
+ return str(cfg_path)
+
+
+class OTASEncoder:
+ # Instantiate once with the dataset's class list, then call .predict(pil_img) per frame.
+ #
+ # The class list must include the unified `"unknown"` token at index 0 (matching the
+ # RADSeg / OpenRSS scoreboard convention). The rest are bare foreground class names from
+ # the dataset's ontology (e.g. `"grass"`, `"trees"`, `"sky"`, …) — they are encoded with
+ # CLIP's text encoder as-is, with no prompt template applied.
+ def __init__(self, class_names: List[str], config_overrides: Optional[dict] = None):
+ assert len(class_names) >= 2, "Need at least 2 classes for argmax to be meaningful."
+ assert class_names[0].lower() == "unknown", (
+ f"Class 0 must be 'unknown' to match the unified ignore-class convention; "
+ f"got {class_names[0]!r}."
+ )
+ self.class_names = list(class_names)
+ self.num_classes = len(self.class_names)
+
+ cfg_path = _write_temp_config(config_overrides)
+ # Lazy import: importing OTAS's model.py triggers heavy backbone loads (DINOv2,
+ # MaskCLIP). We want that to happen exactly once, inside __init__, after the
+ # OTAS_CONFIG_PATH env var is set.
+ os.environ["OTAS_CONFIG_PATH"] = cfg_path
+ import model # noqa: E402
+ import vision_utils # noqa: E402
+
+ self._config = copy.deepcopy(model.config)
+ self._language_map = model.language_map(config=self._config)
+ # Hold a reference to the global featurizer so we don't reload checkpoints.
+ self._featurizer = vision_utils.featurizer
+ self._clip_similarity = vision_utils.clip_similarity
+
+ # SAM2 multi-class refinement reuses OTAS's reference path verbatim. OTAS's
+ # `semantic_mask.binary_mask_refined` already encapsulates the full single-class
+ # SAM2 pipeline (clip_similarity → normalize → threshold → upsample to 256 →
+ # logits → sam2.predict → best-scored logits). To get an N-way argmax we call
+ # `binary_mask_refined(..., ret_dict=True)` once per class with that class as the
+ # sole positive prompt + `""` as the (no-op) negative prompt, pull the returned
+ # `pred_logits`, then argmax across the per-class SAM-refined logits. This way
+ # the SAM-on path leans on OTAS's tested code rather than re-implementing
+ # threshold/upsample/clamp/predict logic in the adapter.
+ self._enable_mask_refinement = bool(self._config.get("enable_mask_refinement", False))
+ self._mask_instance = (
+ model.semantic_mask(config=self._config)
+ if self._enable_mask_refinement else None
+ )
+
+ # Pre-encode all class-name text embeddings once. Each is a (D,) float32 tensor on
+ # the configured CLIP device. Bare class string, no prompt template.
+ self._text_feats = []
+ for name in self.class_names:
+ feat = self._featurizer.clip_encode_text(name)["features"].detach().clone()
+ self._text_feats.append(feat)
+ # Stack to (N, D) for convenience; per-class einsum is still done in a loop because
+ # `clip_similarity` is shaped for a single text vector at a time.
+ self._text_feats_stack = torch.stack(self._text_feats, dim=0) # (N, D)
+
+ self._device = self._config["clip_device"]
+
+ @torch.no_grad()
+ def predict(self, img: Image.Image) -> Tuple[np.ndarray, np.ndarray]:
+ # Returns (preds, probs):
+ # preds: np.uint8 (H, W) argmax class IDs over the bare-name prompt set.
+ # probs: np.float32 (H, W, N) raw cosine similarities at full resolution.
+ # The probs tensor is mostly for debugging / saving raw heatmaps; downstream eval
+ # only consumes preds.
+ if img.mode != "RGB":
+ img = img.convert("RGB")
+ orig_h, orig_w = img.height, img.width
+
+ # 1. Pooled DINOv2 + MaskCLIP embedding map: (H_lr, W_lr, D)
+ pooled = self._language_map.embed_image(img)
+ # 2. Reshape to (1, D, H_lr, W_lr) for `clip_similarity` which expects "chw" layout.
+ img_feats = pooled.permute(2, 0, 1).unsqueeze(0)
+
+ # 3. Per-class cosine similarity. Loop over N classes; each einsum is cheap relative
+ # to the DINOv2 forward pass that already ran in step 1.
+ per_class_sims = []
+ for text_feat in self._text_feats:
+ sim_lr = self._clip_similarity(img_feats, text_feat) # (H_lr, W_lr)
+ per_class_sims.append(sim_lr)
+ sims_lr = torch.stack(per_class_sims, dim=0) # (N, H_lr, W_lr)
+
+ # 4. Upsample each class's similarity map to (H, W) via bilinear interp (same op
+ # OTAS's `similarity_single` uses for its single-map case). Add a batch+channel
+ # axis so F.interpolate's "bilinear" mode is well-defined.
+ sims_up = F.interpolate(
+ sims_lr.unsqueeze(0),
+ size=(orig_h, orig_w),
+ mode="bilinear",
+ align_corners=False,
+ ).squeeze(0) # (N, H, W)
+
+ # 5a. SAM2 multi-class refinement (only if enable_mask_refinement=True).
+ # Delegate per-class refinement to OTAS's own `binary_mask_refined` so we reuse
+ # the exact threshold / upsample / clamp / SAM2.predict / best-score pipeline
+ # the OTAS authors maintain; we only add the N-class argmax on top.
+ if self._enable_mask_refinement:
+ sims_refined = self._refine_with_sam(img, pooled, orig_h, orig_w)
+ preds = sims_refined.argmax(axis=0).astype(np.uint8)
+ probs = sims_refined.transpose(1, 2, 0).astype(np.float32) # (H, W, N)
+ return preds, probs
+
+ # 5b. No-SAM path — argmax across the class axis. Cast to uint8 — class IDs fit
+ # (N <= 22 in our scoreboard) and uint8 PNGs are the canonical cached-pred
+ # format on disk.
+ preds = sims_up.argmax(dim=0).to(torch.uint8).cpu().numpy()
+ probs = sims_up.permute(1, 2, 0).to(torch.float32).cpu().numpy() # (H, W, N)
+ return preds, probs
+
+ def _refine_with_sam(self, img: Image.Image, pooled_features: torch.Tensor,
+ orig_h: int, orig_w: int) -> np.ndarray:
+ # Apply OTAS's own `semantic_mask.binary_mask_refined` per class and stack the
+ # SAM-returned logits into a (N, orig_h, orig_w) numpy float32 array suitable
+ # for argmaxing.
+ #
+ # For each class c we call OTAS verbatim:
+ # self._mask_instance.binary_mask_refined(
+ # shared_feature_map=pooled_features,
+ # pos_prompts=[class_name],
+ # neg_prompts=[""],
+ # original_img=img,
+ # ret_dict=True,
+ # )
+ # which internally runs clip_similarity → min-max normalise → threshold@0.5 →
+ # bilinear upsample to 256x256 → scale to logits in [-9.9999, 9.9999] →
+ # sam2_model.set_image → sam2_model.predict → pick best-scored output.
+ # We pull `pred_logits` from the returned dict and upsample to full image
+ # resolution so all classes' refined logits share a grid before argmax.
+ #
+ # The bare-class prompt protocol is preserved: pos = [class_name],
+ # neg = [""] — OTAS's similarity() treats the [""] neg list as a zero map, so
+ # this matches the no-SAM adapter's "score raw per-class similarity, argmax
+ # across N classes" rule exactly.
+ assert self._mask_instance is not None, (
+ "SAM2 not loaded — instantiate OTASEncoder with "
+ "config_overrides={'enable_mask_refinement': True}.")
+
+ n_cls = len(self.class_names)
+ refined = np.zeros((n_cls, orig_h, orig_w), dtype=np.float32)
+ for c, name in enumerate(self.class_names):
+ out = self._mask_instance.binary_mask_refined(
+ shared_feature_map=pooled_features,
+ pos_prompts=[name],
+ neg_prompts=[""],
+ original_img=img,
+ ret_dict=True,
+ )
+ sam_logits = out["pred_logits"] # SAM2's native low-res logits, (h, w)
+ sam_logits_t = torch.from_numpy(sam_logits).unsqueeze(0).unsqueeze(0).float()
+ sam_logits_up = F.interpolate(sam_logits_t, size=(orig_h, orig_w),
+ mode="bilinear", align_corners=False)
+ refined[c] = sam_logits_up.squeeze().numpy()
+ return refined
diff --git a/own_eval/own_RELLIS.py b/own_eval/own_RELLIS.py
new file mode 100644
index 0000000..42182b8
--- /dev/null
+++ b/own_eval/own_RELLIS.py
@@ -0,0 +1,102 @@
+# OTAS open-vocabulary evaluation on RELLIS-3D (Texas A&M off-road autonomous-driving dataset).
+#
+# Mirrors own_GOD.py one-for-one, with two differences:
+# 1. RGB-only — RELLIS-3D ships no thermal modality (sensors: LiDAR + Basler RGB + Nerian
+# stereo + VN-300 INS). The 4-channel image tensor still gets emitted by RELLIS_dataset
+# for API parity, but channel 3 is all-zero and we never run modality="thermal" here.
+# 2. SAM ablation — accepts --enable_mask_refinement to toggle OTAS's SAM2 mask refinement
+# head on top of the bare DINOv2 + MaskCLIP token alignment. Default off (matches the
+# paper's Table V row "OTAS w. DINOv2 ViT-S/14, raw class labels, no mask refinement").
+#
+# Output dir convention:
+# result/Pred/RELLIS_rgb/ <- SAM off (paper Table V replication target = 48.48 mIoU)
+# result/Pred/RELLIS_rgb_sam/ <- SAM on (new ablation, not in the paper)
+#
+# Two invocations are needed to produce both numbers (a single process can't easily flip
+# the SAM toggle since OTAS's config is locked at OTASEncoder construction time).
+
+import argparse
+import sys
+from pathlib import Path
+
+# The RELLIS_dataset.py is vendored next to this script (own_eval/rellis_dataset.py) so
+# the RELLIS-3D replication is fully self-contained inside an OTAS checkout.
+sys.path.insert(0, str(Path(__file__).resolve().parent)) # eval_common, otas_segmentor, rellis_dataset
+
+from rellis_dataset import RELLIS_dataset, RELLIS_CLASS_NAMES
+from eval_common import run_eval
+
+
+def main():
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--data_dir", default="/home/ubuntu/mnt/rellis_3d/Rellis-3D",
+ help="Rellis-3D root dir (contains train.lst/val.lst/test.lst and 00000..00004/).")
+ parser.add_argument("--split", default="test",
+ help="One of train|val|test (each .lst file has 2 cols: ).")
+ parser.add_argument("--out_root", default="/home/ubuntu/code/OTAS/result/Pred")
+ # input_h/input_w default to None → RELLIS_dataset returns frames at native
+ # 1920×1200 with no resize. DINOv2 always resizes internally to
+ # dinov2_input_size (224), so the dataset H/W only controls the GT scoring
+ # grid and the bilinear pred-upsample target — native is the right answer
+ # for DINOv2. The paper §VII.A wording of "1024×1024" was specifically
+ # about getting AM-RADIO/DINOv3's 16-pixel patches to land exactly on the
+ # d=64 shared grid, not about DINOv2 (which the OTAS repo ships as
+ # default). Pass explicit --input_h --input_w to override.
+ parser.add_argument("--input_h", type=int, default=None)
+ parser.add_argument("--input_w", type=int, default=None)
+ parser.add_argument("--enable_mask_refinement", action="store_true",
+ help="Turn OTAS SAM2 mask refinement on. Paper Table V uses "
+ "no mask refinement; this flag is for ablation only.")
+ parser.add_argument("--out_suffix", default=None,
+ help="Override the output dir suffix. Default: '_sam' if SAM on, "
+ "empty otherwise. Set explicitly to override.")
+ args = parser.parse_args()
+
+ if args.out_suffix is None:
+ suffix = "_sam" if args.enable_mask_refinement else ""
+ else:
+ suffix = args.out_suffix
+ out_dir = Path(args.out_root) / f"RELLIS_rgb{suffix}"
+
+ input_h, input_w = args.input_h, args.input_w
+
+ dataset = RELLIS_dataset(
+ data_dir=args.data_dir,
+ split=args.split,
+ input_h=input_h,
+ input_w=input_w,
+ )
+ class_names = RELLIS_CLASS_NAMES
+
+ # Smoke-check: make sure the dataset loaded sane label values before paying the
+ # 10-minute encoder warmup + ~1672-frame eval cost. Same invariant as RADSeg's
+ # verify_rellis.py "(3) GT remap on real frames" check.
+ print(f"[own_RELLIS] {len(class_names)} classes; "
+ f"dataset size: {len(dataset)} frames; sample IDs in 3 frames:")
+ import numpy as np
+ valid = set(range(len(class_names)))
+ seen = set()
+ for i in (0, len(dataset) // 2, len(dataset) - 1):
+ _, lbl, name = dataset[i]
+ ids = set(np.unique(lbl.numpy()).tolist())
+ leaks = ids - valid
+ assert not leaks, f"frame {i} ({name}) leaked GT values: {sorted(leaks)}"
+ seen |= ids
+ print(f" frame {i:>4} ({name}): GT ids = {sorted(ids)}")
+ print(f"[own_RELLIS] union of GT ids over 3 frames: {sorted(seen)} (all in 0..{len(class_names)-1})\n")
+
+ # OTAS paper Table V / §VII.A defaults — shared_feat_resolution=64, n_components=24,
+ # dinov2_input_size=224, k=24, no mask refinement — are now baked into
+ # otas_segmentor.py:_DEFAULT_CONFIG, so this driver doesn't need to override them.
+ config_overrides = {"enable_mask_refinement": bool(args.enable_mask_refinement)}
+ run_eval(
+ dataset=dataset,
+ class_names=class_names,
+ out_dir=str(out_dir),
+ modality="rgb",
+ config_overrides=config_overrides,
+ )
+
+
+if __name__ == "__main__":
+ main()
diff --git a/own_eval/own_RELLIS_paper.py b/own_eval/own_RELLIS_paper.py
new file mode 100644
index 0000000..3a711bb
--- /dev/null
+++ b/own_eval/own_RELLIS_paper.py
@@ -0,0 +1,358 @@
+# OTAS Table V replication on RELLIS-3D under the protocol Simon Schwaiger confirmed
+# in https://github.com/SimonSchwaiger/otas/pull/2 (review comment, 2026-05).
+#
+# Protocol — verbatim from Simon's PR comment:
+# class_prompts = {1: "dirt", 6: "water", 10: "asphalt",
+# 19: "bush", 33: "mud", 34: "rubble"} # raw RELLIS IDs
+# neg_prompts = ["thing"]
+# threshold_value = 0.8
+#
+# This is a 6-class binary-per-class evaluation, NOT the 20-class argmax our existing
+# own_RELLIS.py runs. Each of the 6 terrain classes is scored independently as a binary
+# segmentation task. For class c at every pixel:
+# 1. lr_sims_norm = min-max-normalised( sim(img, "c") - sim(img, "thing") )
+# 2. pred_c = (lr_sims_norm > 0.8) # threshold-binarised, no argmax
+# 3. gt_c = (raw_label == raw_id_of_c)
+# 4. TP/FP/FN accumulated independently per class across all 1672 test-split frames
+# 5. mIoU = mean over 6 classes of TP / (TP + FP + FN)
+#
+# This differs structurally from own_RELLIS.py:
+# - own_RELLIS.py: 20 class names as positive prompts, no negative prompt, per-pixel
+# argmax across the 20 score maps, mIoU computed via 20×20 confusion matrix. Got
+# 15.66 full / 16.48 fg-only mIoU on the 1672-frame test split.
+# - own_RELLIS_paper.py (this file): 6 class names looped one-by-one as pos prompt,
+# "thing" as neg prompt, threshold@0.8 per class, mIoU computed from per-class
+# binary TP/FP/FN. Target: paper's Table V claim of 48.48 mIoU.
+#
+# This driver calls OTAS's `semantic_mask.similarity(...)` + threshold directly rather
+# than reusing the multi-class OTASEncoder adapter in otas_segmentor.py, because:
+# - The N-way argmax adapter was designed for the apples-to-apples cross-system
+# scoreboard (RADSeg / OpenRSS / OTAS all do N-way argmax). The paper's per-class
+# threshold protocol doesn't fit that mould.
+# - OTAS's `semantic_mask.similarity` already returns the min-max-normalised score
+# map in [0,1] that the threshold acts on. Reusing it verbatim guarantees we're
+# scoring against the exact normalisation OTAS itself uses internally.
+
+import argparse
+import os
+import sys
+import time
+from pathlib import Path
+
+import numpy as np
+import torch
+import torch.nn.functional as F
+from PIL import Image
+from tqdm import tqdm
+
+# Make own_eval/ importable for rellis_dataset.
+_OWN_EVAL_DIR = str(Path(__file__).resolve().parent)
+if _OWN_EVAL_DIR not in sys.path:
+ sys.path.insert(0, _OWN_EVAL_DIR)
+
+from rellis_dataset import RELLIS_dataset # reuses the existing PNG/LUT pipeline
+
+
+# Simon's mapping: raw RELLIS ontology IDs -> bare class-name prompts. Order is fixed so
+# the per-class IoU table is deterministic; per-class IoU mean is order-invariant.
+PAPER_CLASS_PROMPTS = [
+ (1, "dirt"),
+ (6, "water"),
+ (10, "asphalt"),
+ (19, "bush"),
+ (33, "mud"),
+ (34, "rubble"),
+]
+NEG_PROMPTS = ["thing"]
+THRESHOLD_VALUE = 0.8
+
+
+def _resolve_otas():
+ # Add OTAS src/ to sys.path and import model/vision_utils. Mirrors the lazy import
+ # dance otas_segmentor.OTASEncoder does so the heavy DINOv2/MaskCLIP loads only fire
+ # once and only after OTAS_CONFIG_PATH is set.
+ src = str(Path(__file__).resolve().parent.parent / "src")
+ if src not in sys.path:
+ sys.path.insert(0, src)
+ import model # noqa: E402
+ import vision_utils # noqa: E402
+ return model, vision_utils
+
+
+def _write_config(preset: str = "paper_vii_a", custom_overrides=None):
+ # Same config-tempfile trick otas_segmentor uses: write a JSON file holding hyperparameters
+ # + SAM/spatial toggles, point OTAS_CONFIG_PATH at it.
+ #
+ # Two presets:
+ # "paper_vii_a" - §VII.A overrides: d=64, Cr=24, k=24, dinov2_input_size=224.
+ # Mask refinement off, spatial off. Same overrides our
+ # 20-class own_RELLIS.py uses.
+ # "first_commit_defaults" - Only override the SAM/spatial/compilation toggles. Lets the
+ # first-commit `src/config.py` defaults drive shared_feat_resolution
+ # (32), n_components (48), n_clusters (24) and dinov2_input_size
+ # (effectively 518 via dinov2_params img_size). Simon recommended
+ # "switching to the first commit of this repository" in PR #2; this
+ # preset is the literal default config from that commit minus the
+ # SAM toggle (which would also load Open3D + sam2 weights).
+ import json
+ if preset == "paper_vii_a":
+ cfg = {
+ "enable_mask_refinement": False, # Table V is SAM-off (confirmed in paper §VII.A)
+ "enable_spatial": False,
+ "enable_model_compilation": False,
+ "dinov2_input_size": 224, # §VII.A
+ "dino_scale_factor": 2,
+ "shared_feat_resolution": 64, # §VII.A (d=64)
+ "n_clusters": 24, # §VII.A (k=24)
+ "n_components": 24, # §VII.A (Cr=24)
+ "enable_amp_autocast": True,
+ }
+ elif preset == "first_commit_defaults":
+ cfg = {
+ "enable_mask_refinement": False, # off so we don't need SAM2 weights
+ "enable_spatial": False, # off so Open3D doesn't load
+ "enable_model_compilation": False, # off; current main turns this on but it adds
+ # 30 s of cold-start with no inference effect
+ "enable_amp_autocast": True,
+ # NO override of d / n_components / n_clusters / dinov2_input_size: lets the
+ # first-commit src/config.py defaults win (d=32, Cr=48, k=24, dinov2_input_size
+ # not in config dict so the dinov2_params img_size=518 effectively applies).
+ }
+ else:
+ raise ValueError(f"Unknown preset: {preset!r}")
+ if custom_overrides:
+ cfg.update(custom_overrides)
+ cfg_path = Path("/tmp") / f"otas_paper_cfg_{os.getpid()}.json"
+ cfg_path.write_text(json.dumps(cfg))
+ return str(cfg_path)
+
+
+@torch.no_grad()
+def _per_class_pred(language_map, semantic_mask_inst, pil_img, class_name, neg_prompts,
+ threshold, target_h, target_w):
+ # Returns a uint8 (target_h, target_w) binary mask of where the per-class threshold
+ # fires under OTAS's own normalisation. Mirrors what `binary_mask_interpolated` does
+ # internally but lets us share the language_map.embed_image call across all 6
+ # classes for one image, instead of paying that cost 6× per frame.
+ pooled = language_map.embed_image(pil_img) # (H_lr, W_lr, D)
+ lr_sims_norm = semantic_mask_inst.similarity(
+ shared_feature_map=pooled,
+ pos_prompts=[class_name],
+ neg_prompts=neg_prompts,
+ ) # (H_lr, W_lr) in [0,1]
+ binary = (lr_sims_norm > threshold).to(pooled.dtype)
+ binary_up = F.interpolate(
+ binary.unsqueeze(0).unsqueeze(0),
+ size=(target_h, target_w),
+ mode="nearest", # matches binary_mask_interpolated
+ ).squeeze().to(torch.uint8).cpu().numpy()
+ return binary_up
+
+
+@torch.no_grad()
+def _per_frame_six_classes(language_map, semantic_mask_inst, pil_img,
+ class_names, neg_prompts, threshold, target_h, target_w):
+ # Computes all 6 per-class binary masks for one frame in a single
+ # `language_map.embed_image` call (the expensive part) + 6 cheap
+ # `semantic_mask.similarity` einsums + 6 NEAREST upsamples.
+ # Returns (6, target_h, target_w) uint8 stack in the order of `class_names`.
+ pooled = language_map.embed_image(pil_img) # (H_lr, W_lr, D) — ONCE per frame
+ out = np.zeros((len(class_names), target_h, target_w), dtype=np.uint8)
+ for i, name in enumerate(class_names):
+ lr_sims_norm = semantic_mask_inst.similarity(
+ shared_feature_map=pooled,
+ pos_prompts=[name],
+ neg_prompts=neg_prompts,
+ ) # (H_lr, W_lr) in [0,1]
+ binary = (lr_sims_norm > threshold).to(pooled.dtype)
+ binary_up = F.interpolate(
+ binary.unsqueeze(0).unsqueeze(0),
+ size=(target_h, target_w),
+ mode="nearest",
+ ).squeeze().to(torch.uint8).cpu().numpy()
+ out[i] = binary_up
+ return out
+
+
+def main():
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--data_dir", default="/home/ubuntu/mnt/rellis_3d/Rellis-3D")
+ parser.add_argument("--split", default="test")
+ parser.add_argument("--out_root", default="/home/ubuntu/code/OTAS/result/Pred")
+ parser.add_argument("--input_h", type=int, default=None,
+ help="Optional resize height. Default: native 1200.")
+ parser.add_argument("--input_w", type=int, default=None,
+ help="Optional resize width. Default: native 1920.")
+ parser.add_argument("--threshold", type=float, default=THRESHOLD_VALUE,
+ help=f"Per-class binary threshold. Default {THRESHOLD_VALUE} "
+ "(Simon's PR comment).")
+ parser.add_argument("--out_suffix", default="_paper",
+ help="Output dir suffix. Default '_paper' so this lands at "
+ "result/Pred/RELLIS_rgb_paper/ alongside the 20-class "
+ "argmax run at result/Pred/RELLIS_rgb/.")
+ parser.add_argument("--save_preds", action="store_true",
+ help="Cache the (6, H, W) binary stack per frame. Off by "
+ "default — disk cost is ~6× the 20-class run.")
+ parser.add_argument("--num_overlay_samples", type=int, default=12)
+ parser.add_argument("--max_frames", type=int, default=None,
+ help="Cap frames evaluated. Default None (full split). "
+ "Set e.g. 5 for a smoke test.")
+ parser.add_argument("--config_preset", default="paper_vii_a",
+ choices=["paper_vii_a", "first_commit_defaults"],
+ help="paper_vii_a: §VII.A overrides (d=64, Cr=24, k=24, "
+ "dinov2_input_size=224). first_commit_defaults: only "
+ "override SAM/spatial/compilation toggles; leaves "
+ "src/config.py defaults (d=32, Cr=48, dinov2 input 518) "
+ "intact. Use the latter against a 6aec2d4 worktree to "
+ "follow Simon's 'switch to the first commit' suggestion.")
+ args = parser.parse_args()
+
+ # Eager-import OTAS first so config knobs land in os.environ before model.py runs.
+ cfg_path = _write_config(preset=args.config_preset)
+ os.environ["OTAS_CONFIG_PATH"] = cfg_path
+ model, vision_utils = _resolve_otas()
+ config = model.config
+
+ print(f"[own_RELLIS_paper] OTAS config: shared_feat_resolution="
+ f"{config.get('shared_feat_resolution')}, n_clusters={config.get('n_clusters')}, "
+ f"n_components={config.get('n_components')}, dinov2_input_size="
+ f"{config.get('dinov2_input_size')}, enable_mask_refinement="
+ f"{config.get('enable_mask_refinement')}")
+
+ language_map = model.language_map(config=config)
+ semantic_mask_inst = model.semantic_mask(config=config)
+
+ # Dataset — reuse the 20-class RELLIS_dataset, but we only consume raw_label values
+ # via the existing LUT. We bypass the contig 0..19 mapping by recomputing GT from
+ # the raw label PNG inside the loop (the LUT collapses gaps to 0, so we can read
+ # raw IDs directly from the PNG).
+ dataset = RELLIS_dataset(
+ data_dir=args.data_dir,
+ split=args.split,
+ input_h=args.input_h,
+ input_w=args.input_w,
+ )
+ print(f"[own_RELLIS_paper] dataset: {len(dataset)} frames, native resize: "
+ f"{args.input_h}×{args.input_w} (None means dataset-native)")
+
+ # Per-class accumulators. Each is a length-6 array indexed by paper-class-order.
+ raw_ids = np.array([rid for (rid, _) in PAPER_CLASS_PROMPTS], dtype=np.int64)
+ class_names = [n for (_, n) in PAPER_CLASS_PROMPTS]
+ n_cls = len(class_names)
+ tp = np.zeros(n_cls, dtype=np.int64)
+ fp = np.zeros(n_cls, dtype=np.int64)
+ fn = np.zeros(n_cls, dtype=np.int64)
+
+ out_dir = Path(args.out_root) / f"RELLIS_rgb{args.out_suffix}"
+ overlays_dir = out_dir / "overlays"
+ preds_dir = out_dir / "preds"
+ overlays_dir.mkdir(parents=True, exist_ok=True)
+ if args.save_preds:
+ preds_dir.mkdir(parents=True, exist_ok=True)
+
+ n_frames = len(dataset)
+ if args.max_frames is not None:
+ n_frames = min(n_frames, args.max_frames)
+ print(f"[own_RELLIS_paper] capping eval to first {n_frames} frames (--max_frames)")
+ overlay_indices = set(np.linspace(0, max(n_frames - 1, 1),
+ num=min(args.num_overlay_samples, n_frames),
+ dtype=int).tolist())
+
+ t0 = time.time()
+ pbar = tqdm(range(n_frames), desc=f"OTAS-paper[{args.split}]")
+ for idx in pbar:
+ image_f, _label_contig, name = dataset[idx]
+ # We need the RAW RELLIS label (not the contig 0..19 remapped one) so we can
+ # compare against Simon's raw-ID mapping directly. Pull it via the dataset's
+ # path bookkeeping — cheaper than re-mapping from contig back to raw.
+ img_rel, label_rel = dataset.pairs[idx]
+ raw_label_path = os.path.join(dataset.data_dir, label_rel)
+ raw_label = np.asarray(Image.open(raw_label_path)) # uint8 (1200, 1920)
+ # Honour --input_h/--input_w resize. If the dataset is in native mode (None),
+ # raw_label is already the right shape.
+ if args.input_h is not None and args.input_w is not None:
+ raw_label = np.asarray(
+ Image.fromarray(raw_label).resize((args.input_w, args.input_h),
+ resample=Image.NEAREST))
+ target_h, target_w = raw_label.shape
+
+ rgb = (image_f[:3].numpy().clip(0, 1) * 255).astype(np.uint8).transpose(1, 2, 0)
+ pil = Image.fromarray(rgb, mode="RGB")
+
+ binary_stack = _per_frame_six_classes(
+ language_map=language_map,
+ semantic_mask_inst=semantic_mask_inst,
+ pil_img=pil,
+ class_names=class_names,
+ neg_prompts=NEG_PROMPTS,
+ threshold=args.threshold,
+ target_h=target_h,
+ target_w=target_w,
+ ) # (6, H, W) uint8
+
+ # Per-class binary IoU accumulator.
+ for c, raw_id in enumerate(raw_ids):
+ gt_c = (raw_label == raw_id)
+ pred_c = binary_stack[c].astype(bool)
+ tp[c] += np.logical_and(gt_c, pred_c).sum()
+ fp[c] += np.logical_and(~gt_c, pred_c).sum()
+ fn[c] += np.logical_and(gt_c, ~pred_c).sum()
+
+ if args.save_preds:
+ # Save as a single (6, H, W) uint8 npz — one file per frame.
+ np.savez_compressed(preds_dir / f"{name}.npz", binary_stack=binary_stack)
+
+ if idx in overlay_indices:
+ # 3-up per class would be busy; instead make a tiled (input + 6 binary)
+ # overlay so the 12 sampled frames are skimmable.
+ tile_h = target_h // 2
+ tile_w = target_w // 2
+ input_small = np.asarray(pil.resize((tile_w, tile_h)))
+ tiles = [input_small]
+ for c, name_c in enumerate(class_names):
+ bin_small = np.asarray(
+ Image.fromarray((binary_stack[c] * 255).astype(np.uint8))
+ .resize((tile_w, tile_h), Image.NEAREST))
+ tile_rgb = np.stack([bin_small] * 3, axis=-1)
+ tiles.append(tile_rgb)
+ row0 = np.concatenate(tiles[:4], axis=1)
+ row1 = np.concatenate(tiles[4:] + [np.zeros_like(tiles[0])], axis=1)
+ panel = np.concatenate([row0, row1], axis=0)
+ Image.fromarray(panel).save(overlays_dir / f"{name}.png")
+
+ elapsed = time.time() - t0
+ denom = tp + fp + fn
+ iou = np.where(denom > 0, tp / np.maximum(denom, 1e-12), np.nan)
+ miou = float(np.nanmean(iou))
+
+ with open(out_dir / "results.txt", "w") as f:
+ f.write(f"# OTAS eval — Table V paper protocol (Simon's PR #2 review comment)\n")
+ f.write(f"protocol: 6-class binary per-class, neg=['thing'], threshold={args.threshold}\n")
+ f.write(f"split: {args.split}\n")
+ f.write(f"n_frames: {n_frames}\n")
+ f.write(f"n_classes: {n_cls}\n")
+ f.write(f"input_h: {args.input_h}\n")
+ f.write(f"input_w: {args.input_w}\n")
+ f.write(f"elapsed_seconds: {elapsed:.1f}\n")
+ f.write(f"mIoU_6cls: {miou * 100:.4f}\n")
+ f.write("\nper_class_IoU:\n")
+ for c, name in enumerate(class_names):
+ val = iou[c]
+ val_str = "nan" if np.isnan(val) else f"{val * 100:.4f}"
+ f.write(f" {name} (raw id {raw_ids[c]}): {val_str} "
+ f"tp={int(tp[c])} fp={int(fp[c])} fn={int(fn[c])}\n")
+ f.write(f"\nthreshold: {args.threshold}\n")
+ f.write(f"neg_prompts: {NEG_PROMPTS}\n")
+ f.write(f"pos_prompts: {[n for (_, n) in PAPER_CLASS_PROMPTS]}\n")
+ f.write(f"raw_id_to_prompt: {dict(PAPER_CLASS_PROMPTS)}\n")
+
+ print(f"\n[own_RELLIS_paper] mIoU(6cls) = {miou * 100:.2f}% "
+ f"({n_frames} frames, {elapsed:.0f}s)")
+ print("per-class IoU:")
+ for c, name in enumerate(class_names):
+ val_str = "nan" if np.isnan(iou[c]) else f"{iou[c] * 100:6.2f}%"
+ print(f" {name:<10s} (raw id {raw_ids[c]:>2d}): {val_str}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/own_eval/rellis_dataset.py b/own_eval/rellis_dataset.py
new file mode 100644
index 0000000..1343e4a
--- /dev/null
+++ b/own_eval/rellis_dataset.py
@@ -0,0 +1,185 @@
+# RELLIS-3D Dataset (Texas A&M off-road autonomous-driving benchmark) loader for OTAS.
+#
+# Yields `(image_f (4,H,W), label (H,W), name_safe (str))` tuples — the same shape the
+# OTAS `own_eval/eval_common.run_eval` loop expects. Self-contained so the RELLIS-3D
+# replication can be reproduced from a single OTAS checkout, no sibling repos required.
+# Mirrors `/home/ubuntu/code/OpenRSS/util/RELLIS_dataset.py` one-for-one — keep them in
+# sync if you change one.
+#
+# RELLIS-3D ships:
+# - RGB: pylon_camera_node//.jpg (1920x1200)
+# - Label-id PNG: pylon_camera_node_label_id//.png (1920x1200, uint8)
+# - NO thermal channel (sensors are LiDAR + Basler RGB + Nerian stereo + VN-300 INS).
+#
+# The dataset root also contains `train.lst` / `val.lst` / `test.lst`. Each line is two
+# whitespace-separated paths relative to the dataset root: ` `.
+# (The legacy HRNet loader has a 1-column branch for test mode, but the released test.lst
+# is actually 2-col like train/val — confirmed by inspection.)
+#
+# Class IDs in the RELLIS ontology are sparse: {0, 1, 3, 4, 5, 6, 7, 8, 9, 10, 12, 15, 17,
+# 18, 19, 23, 27, 31, 33, 34}. We remap to contiguous [0..19] under the unified
+# unknown-incl convention: raw 0 (void) -> contig 0 (unknown); raw {1, 3, 4, ..., 34} ->
+# contig {1, 2, 3, ..., 19} in the order documented by Rellis-3D/ontology.yaml; any other
+# raw ID (gap values 2/11/13/14/16/20-22/24-26/28-30/32/35/36) folds to contig 0
+# (unknown) via the 256-entry LUT default. This is byte-identical to what
+# `RemapRellisUnknownLabel` does in RADSeg/evaluation/2d/custom_datasets.py — same
+# mapping, same class order.
+#
+# Modality contract: RELLIS has only one valid eval mode (RGB). There is no `mask_modality`
+# argument and no thermal channel — earlier revisions accepted a `mask_modality` flag for
+# "API parity with GOD_dataset", but the four-way enum (`none`/`rgb_only`/`thermal_only`)
+# was a semantic lie on RELLIS: `none` = "RGB + zero-padded T" (not RGB+T fusion); `rgb_only`
+# was a no-op (the T was already zero); `thermal_only` silently produced an all-zero input
+# and ran garbage IoU. No caller exercised any mode other than `none`, so the parameter has
+# been removed.
+#
+# Returned tuple: `(image_f (4,H,W) float[0,1] R G B 0, label (H,W) int64 in 0..19,
+# name_safe (str))`. The 4th channel is constant zero (no thermal sensor) — kept on the
+# tensor only so the same SAM 4-channel input convention works.
+
+import os
+import numpy as np
+import torch
+from torch.utils.data.dataset import Dataset
+import PIL
+from PIL import Image
+
+
+# Order matches Rellis-3D/ontology.yaml + RADSeg's RELLIS_UNKNOWN_CLASSES. Singular `tree`
+# is intentional — kept verbatim from the source ontology (vs GOD's plural `trees`).
+RELLIS_CLASS_NAMES = [
+ "unknown", "dirt", "grass", "tree", "pole", "water", "sky", "vehicle",
+ "object", "asphalt", "building", "log", "person", "fence", "bush",
+ "concrete", "barrier", "puddle", "mud", "rubble",
+]
+RELLIS_NUM_CLASSES = len(RELLIS_CLASS_NAMES) # 20 incl unknown
+
+# Raw RELLIS GT id -> contig id under the unknown-incl convention.
+# Raw 0 (void) -> 0 (unknown). 19 fg raw ids -> contig 1..19 in ontology-yaml order.
+RELLIS_RAW_TO_CONTIGUOUS = {
+ 0: 0, 1: 1, 3: 2, 4: 3, 5: 4, 6: 5, 7: 6, 8: 7, 9: 8, 10: 9,
+ 12: 10, 15: 11, 17: 12, 18: 13, 19: 14, 23: 15, 27: 16, 31: 17,
+ 33: 18, 34: 19,
+}
+
+
+def _build_remap_lut() -> np.ndarray:
+ # 256-entry LUT, default 0 (unknown). Gap raw IDs (2, 11, 13, 14, 16, 20-22, 24-26,
+ # 28-30, 32, 35, 36) silently fold to unknown — matches RADSeg's LUT semantics.
+ lut = np.zeros(256, dtype=np.uint8)
+ for raw, mapped in RELLIS_RAW_TO_CONTIGUOUS.items():
+ lut[raw] = mapped
+ return lut
+
+
+_REMAP_LUT = _build_remap_lut()
+
+
+class RELLIS_dataset(Dataset):
+ # Yields (image_tensor (4,H,W) float[0,1] with channel 3 = constant zero, label_tensor
+ # (H,W) int64, name_safe (str)) so OTAS's eval_common.run_eval can consume it unchanged.
+ #
+ # `data_dir` should point at the extracted Rellis-3D root (the directory containing the
+ # split lists and the 5 sequence subdirs 00000..00004). The split list is read directly
+ # from `/.lst` — no separate `split_dir` is needed; RELLIS ships them
+ # at the dataset root.
+ def __init__(self, data_dir, split="test", split_dir=None, input_h=None, input_w=None,
+ transform=None):
+ # input_h/input_w default to None — when unset the Basler pylon JPGs
+ # and matching label PNGs (both 1920×1200 natively) are returned at
+ # their native shape with no resize. Pass explicit (input_h, input_w)
+ # to score on a custom grid.
+ super().__init__()
+ self.data_dir = data_dir
+ # split_dir is accepted for API parity with GOD_dataset but ignored — RELLIS ships
+ # the split lists at the dataset root, not a sibling directory.
+ self.split_dir = split_dir or data_dir
+ self.split = split
+ self.input_h = input_h
+ self.input_w = input_w
+ self.transform = transform or []
+ self._lut = _REMAP_LUT
+ self.class_names = RELLIS_CLASS_NAMES
+
+ # Parse the split file. Each line has two whitespace-separated relative paths.
+ split_path = os.path.join(self.split_dir, split + ".lst")
+ self.pairs = [] # list of (img_rel, label_rel)
+ with open(split_path, "r") as f:
+ for line in f:
+ line = line.strip()
+ if not line:
+ continue
+ parts = line.split()
+ # The released test.lst has 2 cols like train/val (the upstream HRNet
+ # loader's 1-col test branch is legacy / unused) — fail loudly if that
+ # assumption is ever violated by a future RELLIS release.
+ assert len(parts) == 2, (
+ f"RELLIS split line must have 2 paths, got {len(parts)}: {line!r}")
+ self.pairs.append((parts[0], parts[1]))
+ self.n_data = len(self.pairs)
+
+ def __getitem__(self, index):
+ img_rel, label_rel = self.pairs[index]
+ # name_safe: replace path separators with '_' so eval_common can use it as a single
+ # filename component. Strip the .jpg extension. e.g. "00000_pylon_camera_node_frame000000-1581624652_750"
+ stem, _ext = os.path.splitext(img_rel)
+ name_safe = stem.replace("/", "_")
+
+ # RGB at 1920x1200; resize to (input_h, input_w) if those are set, else
+ # keep native.
+ rgb_path = os.path.join(self.data_dir, img_rel)
+ rgb = np.asarray(PIL.Image.open(rgb_path).convert("RGB")) # (1200, 1920, 3) uint8
+
+ # Label from pylon_camera_node_label_id (same resolution as RGB).
+ # Confirmed across the released test split: every label PNG is PIL mode "L",
+ # (1200, 1920) uint8, holding raw class IDs in the documented sparse set
+ # {0, 1, 3-10, 12, 15, 17-19, 23, 27, 31, 33-34}. Colored annotations live
+ # in the sibling pylon_camera_node_label_color/ directory and are never
+ # loaded here. Remap via LUT to contig [0..19].
+ label_path = os.path.join(self.data_dir, label_rel)
+ raw_label = np.asarray(PIL.Image.open(label_path)) # (1200, 1920) uint8
+ remapped = self._lut[raw_label]
+
+ # input_h/input_w None ⇒ keep native (RGB and label are both 1920×1200
+ # so this is a true no-op); else bilinear-resize RGB, NEAREST-resize
+ # label.
+ if self.input_h is None and self.input_w is None:
+ rgb_resized = rgb
+ label = remapped.astype(np.int64)
+ th_h, th_w = rgb.shape[:2]
+ else:
+ rgb_resized = np.asarray(
+ PIL.Image.fromarray(rgb).resize((self.input_w, self.input_h),
+ resample=PIL.Image.BILINEAR)
+ )
+ label = np.asarray(
+ PIL.Image.fromarray(remapped).resize((self.input_w, self.input_h),
+ resample=PIL.Image.NEAREST),
+ dtype=np.int64,
+ )
+ th_h, th_w = self.input_h, self.input_w
+
+ # 4th channel is constant zero — RELLIS-3D has no thermal sensor. Kept on the tensor
+ # so the same SAM 4-channel input convention works; downstream modality switches in
+ # GOD-style eval loops are not relevant here.
+ th_resized = np.zeros((th_h, th_w), dtype=np.uint8)
+
+ # (H, W, 4) uint8 R G B 0
+ image = np.dstack([rgb_resized, th_resized])
+
+ for func in self.transform:
+ image, label = func(image, label)
+
+ # `image` is already (input_h, input_w, 4) uint8 — RGB was resized at
+ # load time and dstack'd with the zero thermal channel. So we only need
+ # the uint8 -> float32/255 cast and the HWC -> CHW transpose that
+ # PyTorch expects. The earlier PIL round-trip-with-resize here was a
+ # copy-paste from MF_dataset (where it IS load-bearing because that
+ # adapter resizes at this final step) and ran an identity resize on
+ # every frame.
+ image_f = image.astype(np.float32).transpose(2, 0, 1) / 255
+
+ return torch.tensor(image_f), torch.tensor(label), name_safe
+
+ def __len__(self):
+ return self.n_data
diff --git a/src/foundation_models/maskclip_onnx/clip.py b/src/foundation_models/maskclip_onnx/clip.py
index b2159d4..c1b2b28 100644
--- a/src/foundation_models/maskclip_onnx/clip.py
+++ b/src/foundation_models/maskclip_onnx/clip.py
@@ -4,6 +4,7 @@
import warnings
from typing import Any, Union, List
import packaging
+import packaging.version
import torch
from PIL import Image