Spaces:
Running on Zero
Running on Zero
PatchAlign3D zero-shot 3D part segmentation demo
Browse files- README.md +43 -6
- app.py +431 -0
- examples/airplane.ply +0 -0
- examples/bunny.obj +0 -0
- examples/chair.ply +0 -0
- examples/guitar.ply +0 -0
- examples/lamp.ply +0 -0
- examples/mug.ply +0 -0
- examples/table.ply +0 -0
- patchalign3d.py +465 -0
- requirements.txt +8 -0
README.md
CHANGED
|
@@ -1,13 +1,50 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
sdk_version: 6.22.0
|
| 8 |
-
python_version: '3.12'
|
| 9 |
app_file: app.py
|
| 10 |
pinned: false
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: PatchAlign3D
|
| 3 |
+
emoji: 🧩
|
| 4 |
+
colorFrom: gray
|
| 5 |
+
colorTo: pink
|
| 6 |
sdk: gradio
|
| 7 |
sdk_version: 6.22.0
|
|
|
|
| 8 |
app_file: app.py
|
| 9 |
pinned: false
|
| 10 |
+
license: mit
|
| 11 |
+
short_description: Zero-shot 3D part segmentation from text queries
|
| 12 |
+
python_version: "3.12"
|
| 13 |
+
startup_duration_timeout: 1h
|
| 14 |
+
models:
|
| 15 |
+
- patchalign3d/patchalign3d-encoder
|
| 16 |
---
|
| 17 |
|
| 18 |
+
# PatchAlign3D · zero-shot 3D part segmentation
|
| 19 |
+
|
| 20 |
+
Name the parts you want **in words** and see them highlighted on a 3D shape. A single forward pass of a
|
| 21 |
+
point-cloud encoder whose *patch-level* features are aligned to CLIP text space — no test-time multi-view
|
| 22 |
+
rendering, no per-category training.
|
| 23 |
+
|
| 24 |
+
- Paper: https://huggingface.co/papers/2601.02457
|
| 25 |
+
- Code: https://github.com/souhail-hadgi/PatchAlign3D
|
| 26 |
+
- Weights: https://huggingface.co/patchalign3d/patchalign3d-encoder
|
| 27 |
+
|
| 28 |
+
## How it works
|
| 29 |
+
|
| 30 |
+
Points are centred and scaled to the unit sphere and the Y/Z axes are swapped to match the training
|
| 31 |
+
convention (exactly as in the official `infer.py`). Furthest-point sampling picks patch centres, a k-NN
|
| 32 |
+
neighbourhood around each becomes a patch token, and a 12-layer point transformer produces one feature per
|
| 33 |
+
patch. A learned linear head projects those into the CLIP `ViT-bigG-14 (laion2b_s39b_b160k)` text space,
|
| 34 |
+
where they are matched against a prompt ensemble. Each point takes the label of its nearest patch centre.
|
| 35 |
+
|
| 36 |
+
## Implementation notes
|
| 37 |
+
|
| 38 |
+
`patchalign3d.py` is a faithful port of the official inference path with three behaviour-preserving
|
| 39 |
+
deviations, so the Space runs without custom CUDA extensions:
|
| 40 |
+
|
| 41 |
+
1. `pointnet2_ops.furthest_point_sample` → pure-torch iterative FPS (same start index, squared distances,
|
| 42 |
+
argmax selection).
|
| 43 |
+
2. `knn_cuda.KNN` → `torch.cdist` + `topk`.
|
| 44 |
+
3. `open_clip ViT-bigG-14 / laion2b_s39b_b160k` text tower → the byte-identical (up to fp16 rounding)
|
| 45 |
+
`text_encoder_2` + `tokenizer_2` of `stabilityai/stable-diffusion-xl-base-1.0`, which avoids a 10 GB
|
| 46 |
+
download of the full image+text CLIP checkpoint.
|
| 47 |
+
|
| 48 |
+
Verified against ShapeNetPart ground truth on CPU before deployment: the checkpoint loads with 0 missing
|
| 49 |
+
and 0 unexpected keys, and per-shape zero-shot mIoU averages **0.59** with the default `part_only` prompts
|
| 50 |
+
(**0.64** with `ensemble`) across 8 held-out test shapes.
|
app.py
ADDED
|
@@ -0,0 +1,431 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""PatchAlign3D — open-vocabulary (zero-shot) 3D part segmentation from point clouds.
|
| 2 |
+
|
| 3 |
+
Paper: https://huggingface.co/papers/2601.02457
|
| 4 |
+
Code: https://github.com/souhail-hadgi/PatchAlign3D
|
| 5 |
+
Weights: https://huggingface.co/patchalign3d/patchalign3d-encoder
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import os
|
| 9 |
+
|
| 10 |
+
os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
|
| 11 |
+
|
| 12 |
+
import spaces # noqa: E402 (must precede torch)
|
| 13 |
+
|
| 14 |
+
import tempfile # noqa: E402
|
| 15 |
+
import time # noqa: E402
|
| 16 |
+
from pathlib import Path # noqa: E402
|
| 17 |
+
|
| 18 |
+
import gradio as gr # noqa: E402
|
| 19 |
+
import numpy as np # noqa: E402
|
| 20 |
+
import plotly.graph_objects as go # noqa: E402
|
| 21 |
+
import torch # noqa: E402
|
| 22 |
+
import trimesh # noqa: E402
|
| 23 |
+
from huggingface_hub import hf_hub_download # noqa: E402
|
| 24 |
+
from transformers import CLIPTextModelWithProjection, CLIPTokenizer # noqa: E402
|
| 25 |
+
|
| 26 |
+
import patchalign3d as pa # noqa: E402
|
| 27 |
+
|
| 28 |
+
# --------------------------------------------------------------------------------------
|
| 29 |
+
# Models — module scope, eager .to("cuda"); ZeroGPU streams them in on the first call
|
| 30 |
+
# --------------------------------------------------------------------------------------
|
| 31 |
+
|
| 32 |
+
CKPT = hf_hub_download("patchalign3d/patchalign3d-encoder", "patchalign3d.pt")
|
| 33 |
+
model, proj = pa.load_patchalign3d(CKPT)
|
| 34 |
+
model = model.to("cuda")
|
| 35 |
+
proj = proj.to("cuda")
|
| 36 |
+
|
| 37 |
+
tokenizer = CLIPTokenizer.from_pretrained(pa.CLIP_TEXT_REPO, subfolder=pa.CLIP_TOKENIZER_SUBFOLDER)
|
| 38 |
+
text_model = (
|
| 39 |
+
CLIPTextModelWithProjection.from_pretrained(
|
| 40 |
+
pa.CLIP_TEXT_REPO, subfolder=pa.CLIP_TEXT_SUBFOLDER, variant="fp16", dtype=torch.float32
|
| 41 |
+
)
|
| 42 |
+
.eval()
|
| 43 |
+
.to("cuda")
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
print(
|
| 47 |
+
f"[init] PatchAlign3D encoder {sum(p.numel() for p in model.parameters()) / 1e6:.1f}M params | "
|
| 48 |
+
f"CLIP ViT-bigG-14 text tower {sum(p.numel() for p in text_model.parameters()) / 1e6:.1f}M params | "
|
| 49 |
+
f"tokenizer pad={tokenizer.pad_token_id} ctx={tokenizer.model_max_length}"
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
MAX_LABELS = 12
|
| 53 |
+
MESH_EXTS = {".obj", ".glb", ".gltf", ".stl", ".off", ".ply", ".dae", ".3mf"}
|
| 54 |
+
|
| 55 |
+
# Distinguishable qualitative palette
|
| 56 |
+
PALETTE = [
|
| 57 |
+
"#e6194b", "#3cb44b", "#4363d8", "#f58231", "#911eb4", "#00b8d4",
|
| 58 |
+
"#f032e6", "#a1c800", "#fabed4", "#469990", "#9a6324", "#7f0000",
|
| 59 |
+
]
|
| 60 |
+
|
| 61 |
+
# ShapeNetPart part vocabularies, verbatim from the official eval.py
|
| 62 |
+
PRESETS = {
|
| 63 |
+
"— custom —": ("", ""),
|
| 64 |
+
"Airplane": ("body, wing, tail, engine or frame", "airplane"),
|
| 65 |
+
"Bag": ("handle, body", "bag"),
|
| 66 |
+
"Cap": ("crown, brim", "cap"),
|
| 67 |
+
"Car": ("roof, hood, wheel, body", "car"),
|
| 68 |
+
"Chair": ("back, seat, leg, arm", "chair"),
|
| 69 |
+
"Earphone": ("earcup, headband, data wire", "earphone"),
|
| 70 |
+
"Guitar": ("headstock, neck, body", "guitar"),
|
| 71 |
+
"Knife": ("blade, handle", "knife"),
|
| 72 |
+
"Lamp": ("base, lampshade, fixing bracket, pole", "lamp"),
|
| 73 |
+
"Laptop": ("keyboard, screen", "laptop"),
|
| 74 |
+
"Motorbike": ("gas tank, seat, wheel, handles or handlebars, headlight, engine or frame", "motorbike"),
|
| 75 |
+
"Mug": ("handle, cup", "mug"),
|
| 76 |
+
"Pistol": ("barrel, handle or grip, trigger and guard", "pistol"),
|
| 77 |
+
"Rocket": ("body, fin, nose", "rocket"),
|
| 78 |
+
"Skateboard": ("wheel, deck, belt for foot", "skateboard"),
|
| 79 |
+
"Table": ("desktop, leg or support, drawer", "table"),
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
# --------------------------------------------------------------------------------------
|
| 84 |
+
# Shape loading
|
| 85 |
+
# --------------------------------------------------------------------------------------
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def _resample(pts: np.ndarray, npoints: int, seed: int) -> np.ndarray:
|
| 89 |
+
n = len(pts)
|
| 90 |
+
if n == npoints:
|
| 91 |
+
return pts
|
| 92 |
+
rng = np.random.default_rng(seed)
|
| 93 |
+
return pts[rng.choice(n, size=npoints, replace=n < npoints)]
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def load_shape(path: str, npoints: int = pa.DEFAULT_NPOINTS, seed: int = 0):
|
| 97 |
+
"""Read a mesh or point cloud; return `npoints` unit-sphere-normalised points + a description."""
|
| 98 |
+
p = Path(path)
|
| 99 |
+
ext = p.suffix.lower()
|
| 100 |
+
src = "point cloud"
|
| 101 |
+
|
| 102 |
+
if ext in (".npz", ".npy"):
|
| 103 |
+
if ext == ".npy":
|
| 104 |
+
arr = np.load(p)
|
| 105 |
+
else:
|
| 106 |
+
d = np.load(p, allow_pickle=True)
|
| 107 |
+
key = next((k for k in ("points", "xyz", "pos", "vertices") if k in d), None)
|
| 108 |
+
if key is None:
|
| 109 |
+
raise gr.Error(f"NPZ must contain points/xyz/pos/vertices — found {list(d.keys())}")
|
| 110 |
+
arr = d[key]
|
| 111 |
+
arr = np.asarray(arr, dtype=np.float32)
|
| 112 |
+
pts = arr.reshape(-1, arr.shape[-1])[:, :3]
|
| 113 |
+
elif ext in (".txt", ".pts", ".xyz", ".csv", ".asc"):
|
| 114 |
+
raw = np.loadtxt(p, delimiter="," if ext == ".csv" else None, dtype=np.float32)
|
| 115 |
+
pts = np.atleast_2d(raw)[:, :3]
|
| 116 |
+
elif ext in MESH_EXTS:
|
| 117 |
+
obj = trimesh.load(str(p), process=False)
|
| 118 |
+
if isinstance(obj, trimesh.Scene):
|
| 119 |
+
faced = [g for g in obj.geometry.values() if getattr(g, "faces", None) is not None and len(g.faces)]
|
| 120 |
+
if faced:
|
| 121 |
+
try:
|
| 122 |
+
obj = obj.to_mesh()
|
| 123 |
+
except Exception:
|
| 124 |
+
obj = trimesh.util.concatenate(faced)
|
| 125 |
+
else:
|
| 126 |
+
verts = [np.asarray(g.vertices) for g in obj.geometry.values() if hasattr(g, "vertices")]
|
| 127 |
+
if not verts:
|
| 128 |
+
raise gr.Error("No geometry found in this file.")
|
| 129 |
+
obj = trimesh.PointCloud(np.concatenate(verts, axis=0))
|
| 130 |
+
if getattr(obj, "faces", None) is not None and len(obj.faces) > 0:
|
| 131 |
+
np.random.seed(int(seed) % (2**31))
|
| 132 |
+
pts = np.asarray(trimesh.sample.sample_surface(obj, int(npoints))[0], dtype=np.float32)
|
| 133 |
+
src = f"mesh, {len(obj.faces):,} faces, surface-sampled"
|
| 134 |
+
else:
|
| 135 |
+
pts = np.asarray(obj.vertices, dtype=np.float32)[:, :3]
|
| 136 |
+
else:
|
| 137 |
+
raise gr.Error(
|
| 138 |
+
f"Unsupported file type '{ext}'. Use a mesh (.obj/.glb/.gltf/.stl/.off/.ply) "
|
| 139 |
+
"or a point cloud (.ply/.npz/.txt/.xyz)."
|
| 140 |
+
)
|
| 141 |
+
|
| 142 |
+
pts = np.ascontiguousarray(pts[np.isfinite(pts).all(axis=1)], dtype=np.float32)
|
| 143 |
+
if len(pts) < 32:
|
| 144 |
+
raise gr.Error(f"Only {len(pts)} usable points found — need at least 32.")
|
| 145 |
+
raw_n = len(pts)
|
| 146 |
+
pts = _resample(pts, int(npoints), int(seed))
|
| 147 |
+
return pa.pc_normalize(pts.astype(np.float32)), f"{src}, {raw_n:,} pts → {len(pts):,} used"
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
# --------------------------------------------------------------------------------------
|
| 151 |
+
# Plotting
|
| 152 |
+
# --------------------------------------------------------------------------------------
|
| 153 |
+
|
| 154 |
+
_AXIS = dict(showbackground=False, showgrid=False, zeroline=False, showticklabels=False, title="")
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def _style(fig: go.Figure, title: str, height: int) -> go.Figure:
|
| 158 |
+
fig.update_layout(
|
| 159 |
+
title=dict(text=title, x=0.02, font=dict(size=12, color="#8a8a8a")),
|
| 160 |
+
scene=dict(xaxis=_AXIS, yaxis=_AXIS, zaxis=_AXIS, aspectmode="data",
|
| 161 |
+
camera=dict(eye=dict(x=1.6, y=1.2, z=1.0))),
|
| 162 |
+
margin=dict(l=0, r=0, t=28, b=0),
|
| 163 |
+
height=height,
|
| 164 |
+
showlegend=len(fig.data) > 1,
|
| 165 |
+
legend=dict(orientation="h", yanchor="bottom", y=0.0, xanchor="left", x=0.0,
|
| 166 |
+
font=dict(color="#8a8a8a", size=11), bgcolor="rgba(0,0,0,0)"),
|
| 167 |
+
paper_bgcolor="rgba(0,0,0,0)",
|
| 168 |
+
plot_bgcolor="rgba(0,0,0,0)",
|
| 169 |
+
font=dict(color="#8a8a8a"),
|
| 170 |
+
)
|
| 171 |
+
return fig
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def plot_raw(points: np.ndarray, title: str, height: int = 300) -> go.Figure:
|
| 175 |
+
fig = go.Figure(
|
| 176 |
+
go.Scatter3d(
|
| 177 |
+
x=points[:, 0], y=points[:, 1], z=points[:, 2], mode="markers",
|
| 178 |
+
marker=dict(size=1.8, color="#9aa0a6"), name="input", hoverinfo="skip",
|
| 179 |
+
)
|
| 180 |
+
)
|
| 181 |
+
return _style(fig, title, height)
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def plot_segments(points, pred, names, conf, title: str, height: int = 560) -> go.Figure:
|
| 185 |
+
fig = go.Figure()
|
| 186 |
+
for k, name in enumerate(names):
|
| 187 |
+
m = pred == k
|
| 188 |
+
if not m.any():
|
| 189 |
+
continue
|
| 190 |
+
fig.add_trace(
|
| 191 |
+
go.Scatter3d(
|
| 192 |
+
x=points[m, 0], y=points[m, 1], z=points[m, 2], mode="markers",
|
| 193 |
+
marker=dict(size=2.6, color=PALETTE[k % len(PALETTE)]),
|
| 194 |
+
name=f"{name} · {int(m.sum())}",
|
| 195 |
+
customdata=conf[m],
|
| 196 |
+
hovertemplate=f"<b>{name}</b><br>p=%{{customdata:.2f}}<extra></extra>",
|
| 197 |
+
)
|
| 198 |
+
)
|
| 199 |
+
return _style(fig, title, height)
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
def export_colored_ply(points: np.ndarray, pred: np.ndarray) -> str:
|
| 203 |
+
rgba = np.zeros((len(points), 4), dtype=np.uint8)
|
| 204 |
+
rgba[:, 3] = 255
|
| 205 |
+
for k in range(int(pred.max()) + 1):
|
| 206 |
+
h = PALETTE[k % len(PALETTE)].lstrip("#")
|
| 207 |
+
rgba[pred == k, :3] = [int(h[i:i + 2], 16) for i in (0, 2, 4)]
|
| 208 |
+
f = tempfile.NamedTemporaryFile(suffix="_patchalign3d.ply", delete=False)
|
| 209 |
+
f.close()
|
| 210 |
+
trimesh.PointCloud(points, colors=rgba).export(f.name)
|
| 211 |
+
return f.name
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
# --------------------------------------------------------------------------------------
|
| 215 |
+
# Handlers
|
| 216 |
+
# --------------------------------------------------------------------------------------
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
def preview_shape(shape_file: str, num_points: int = pa.DEFAULT_NPOINTS, seed: int = 0):
|
| 220 |
+
"""Show the uploaded shape as a plain point cloud. CPU only — no GPU needed.
|
| 221 |
+
|
| 222 |
+
Args:
|
| 223 |
+
shape_file: Path to a mesh or point-cloud file.
|
| 224 |
+
num_points: Number of points to sample for the preview.
|
| 225 |
+
seed: Sampling seed.
|
| 226 |
+
|
| 227 |
+
Returns:
|
| 228 |
+
An interactive 3D scatter plot of the sampled input points.
|
| 229 |
+
"""
|
| 230 |
+
if not shape_file:
|
| 231 |
+
return None
|
| 232 |
+
points, info = load_shape(shape_file, int(num_points), int(seed))
|
| 233 |
+
return plot_raw(points, f"Input — {info}")
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
def _parse_labels(labels_text: str):
|
| 237 |
+
names = [x.strip() for x in (labels_text or "").split(",") if x.strip()]
|
| 238 |
+
if not names:
|
| 239 |
+
raise gr.Error("Enter at least one part name, e.g. `back, seat, leg, arm`.")
|
| 240 |
+
if len(names) > MAX_LABELS:
|
| 241 |
+
raise gr.Error(f"At most {MAX_LABELS} part queries at a time (got {len(names)}).")
|
| 242 |
+
return names
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
@spaces.GPU(duration=60)
|
| 246 |
+
def segment(
|
| 247 |
+
shape_file: str,
|
| 248 |
+
labels_text: str = "back, seat, leg, arm",
|
| 249 |
+
num_points: int = pa.DEFAULT_NPOINTS,
|
| 250 |
+
num_group: int = pa.DEFAULT_NUM_GROUP,
|
| 251 |
+
group_size: int = pa.DEFAULT_GROUP_SIZE,
|
| 252 |
+
text_setting: str = "part_only",
|
| 253 |
+
category: str = "",
|
| 254 |
+
assign: str = "nearest",
|
| 255 |
+
tau: float = pa.DEFAULT_TAU,
|
| 256 |
+
seed: int = 0,
|
| 257 |
+
):
|
| 258 |
+
"""Zero-shot 3D part segmentation of a shape, driven by free-form text part names.
|
| 259 |
+
|
| 260 |
+
Args:
|
| 261 |
+
shape_file: Path to a mesh (.obj/.glb/.gltf/.stl/.off/.ply) or point cloud (.ply/.npz/.txt/.xyz).
|
| 262 |
+
labels_text: Comma-separated part names to look for, e.g. "back, seat, leg, arm".
|
| 263 |
+
num_points: Points sampled from the shape (2048 matches the training setting).
|
| 264 |
+
num_group: Number of patches (furthest-point-sampled centres) the encoder uses.
|
| 265 |
+
group_size: Points per patch (k-NN neighbourhood size).
|
| 266 |
+
text_setting: Prompt ensemble — "part_only", "part_plus_cat" or "ensemble".
|
| 267 |
+
category: Object category used by the "part_plus_cat" / "ensemble" prompts, e.g. "chair".
|
| 268 |
+
assign: Patch-to-point assignment — "nearest" patch centre, or patch "membership" voting.
|
| 269 |
+
tau: CLIP temperature used to turn cosine similarities into probabilities.
|
| 270 |
+
seed: Seed for point / surface sampling.
|
| 271 |
+
|
| 272 |
+
Returns:
|
| 273 |
+
An interactive 3D plot of the segmented shape, the share of points per part,
|
| 274 |
+
a colour-coded .ply download, and a short run summary.
|
| 275 |
+
"""
|
| 276 |
+
if not shape_file:
|
| 277 |
+
raise gr.Error("Upload a 3D shape first, or pick one of the examples below.")
|
| 278 |
+
names = _parse_labels(labels_text)
|
| 279 |
+
|
| 280 |
+
num_points = int(num_points)
|
| 281 |
+
num_group = max(1, min(int(num_group), num_points))
|
| 282 |
+
group_size = max(1, min(int(group_size), num_points))
|
| 283 |
+
|
| 284 |
+
t0 = time.perf_counter()
|
| 285 |
+
points, info = load_shape(shape_file, num_points, int(seed))
|
| 286 |
+
t_load = time.perf_counter() - t0
|
| 287 |
+
|
| 288 |
+
t1 = time.perf_counter()
|
| 289 |
+
pred, probs = pa.segment_point_cloud(
|
| 290 |
+
points, names, model, proj, text_model, tokenizer, "cuda",
|
| 291 |
+
category=category or "", text_setting=text_setting, assign=assign,
|
| 292 |
+
tau=float(tau), num_group=num_group, group_size=group_size,
|
| 293 |
+
)
|
| 294 |
+
t_gpu = time.perf_counter() - t1
|
| 295 |
+
|
| 296 |
+
conf = probs[np.arange(len(pred)), pred]
|
| 297 |
+
shares = {name: float((pred == k).mean()) for k, name in enumerate(names)}
|
| 298 |
+
fig = plot_segments(points, pred, names, conf, "Predicted parts — drag to rotate, scroll to zoom")
|
| 299 |
+
ply = export_colored_ply(points, pred)
|
| 300 |
+
summary = (
|
| 301 |
+
f"**{len(points):,} points → {num_group} patches → {len(names)} text queries** \n"
|
| 302 |
+
f"{info} · prompts `{text_setting}`"
|
| 303 |
+
+ (f" · category `{category}`" if category and text_setting != "part_only" else "")
|
| 304 |
+
+ f" \nload {t_load:.2f}s · inference **{t_gpu:.2f}s** · mean confidence {conf.mean():.2f}"
|
| 305 |
+
)
|
| 306 |
+
return fig, shares, ply, summary
|
| 307 |
+
|
| 308 |
+
|
| 309 |
+
def apply_preset(preset: str, labels_text: str, category: str):
|
| 310 |
+
if preset in PRESETS and preset != "— custom —":
|
| 311 |
+
return PRESETS[preset]
|
| 312 |
+
return labels_text, category
|
| 313 |
+
|
| 314 |
+
|
| 315 |
+
# --------------------------------------------------------------------------------------
|
| 316 |
+
# UI
|
| 317 |
+
# --------------------------------------------------------------------------------------
|
| 318 |
+
|
| 319 |
+
CSS = """
|
| 320 |
+
#col-container { max-width: 1240px; margin: 0 auto; }
|
| 321 |
+
.dark .gradio-container { color: var(--body-text-color); }
|
| 322 |
+
"""
|
| 323 |
+
|
| 324 |
+
EXAMPLES = [
|
| 325 |
+
["examples/chair.ply", "back, seat, leg"],
|
| 326 |
+
["examples/airplane.ply", "body, wing, tail"],
|
| 327 |
+
["examples/guitar.ply", "headstock, neck, body"],
|
| 328 |
+
["examples/table.ply", "desktop, leg or support, drawer"],
|
| 329 |
+
["examples/lamp.ply", "base, lampshade, pole"],
|
| 330 |
+
["examples/mug.ply", "handle, cup"],
|
| 331 |
+
["examples/bunny.obj", "ear, head, torso, foot"],
|
| 332 |
+
]
|
| 333 |
+
|
| 334 |
+
with gr.Blocks(theme=gr.themes.Citrus(), css=CSS, title="PatchAlign3D") as demo:
|
| 335 |
+
with gr.Column(elem_id="col-container"):
|
| 336 |
+
gr.Markdown(
|
| 337 |
+
"""
|
| 338 |
+
# PatchAlign3D · zero-shot 3D part segmentation
|
| 339 |
+
|
| 340 |
+
Name the parts you want **in words** and see them highlighted on the 3D shape. One forward pass of a
|
| 341 |
+
point-cloud encoder whose *patch* features are aligned to CLIP text space — no test-time multi-view rendering.
|
| 342 |
+
|
| 343 |
+
[Paper](https://huggingface.co/papers/2601.02457) · [Code](https://github.com/souhail-hadgi/PatchAlign3D)
|
| 344 |
+
· [Weights](https://huggingface.co/patchalign3d/patchalign3d-encoder)
|
| 345 |
+
· [Project page](https://souhail-hadgi.github.io/patchalign3dsite)
|
| 346 |
+
"""
|
| 347 |
+
)
|
| 348 |
+
|
| 349 |
+
with gr.Row():
|
| 350 |
+
with gr.Column(scale=2):
|
| 351 |
+
shape_file = gr.File(
|
| 352 |
+
label="3D shape — mesh or point cloud",
|
| 353 |
+
file_types=[".obj", ".glb", ".gltf", ".stl", ".off", ".ply",
|
| 354 |
+
".npz", ".npy", ".txt", ".xyz", ".pts"],
|
| 355 |
+
type="filepath",
|
| 356 |
+
)
|
| 357 |
+
preview = gr.Plot(label="Input")
|
| 358 |
+
preset = gr.Dropdown(
|
| 359 |
+
label="Part-vocabulary preset (fills the box below)",
|
| 360 |
+
choices=list(PRESETS.keys()), value="— custom —",
|
| 361 |
+
)
|
| 362 |
+
labels_text = gr.Textbox(
|
| 363 |
+
label="Part queries (comma-separated)",
|
| 364 |
+
value="back, seat, leg, arm",
|
| 365 |
+
placeholder="back, seat, leg, arm",
|
| 366 |
+
lines=2,
|
| 367 |
+
)
|
| 368 |
+
run = gr.Button("Segment", variant="primary")
|
| 369 |
+
|
| 370 |
+
with gr.Column(scale=3):
|
| 371 |
+
plot = gr.Plot(label="Segmentation")
|
| 372 |
+
summary = gr.Markdown()
|
| 373 |
+
with gr.Row():
|
| 374 |
+
shares = gr.Label(label="Share of points per part", num_top_classes=MAX_LABELS)
|
| 375 |
+
ply_out = gr.File(label="Colour-coded point cloud (.ply)")
|
| 376 |
+
|
| 377 |
+
with gr.Accordion("Advanced settings", open=False):
|
| 378 |
+
with gr.Row():
|
| 379 |
+
num_points = gr.Slider(512, 8192, value=pa.DEFAULT_NPOINTS, step=512, label="Points sampled")
|
| 380 |
+
num_group = gr.Slider(32, 512, value=pa.DEFAULT_NUM_GROUP, step=32, label="Patches (FPS centres)")
|
| 381 |
+
group_size = gr.Slider(8, 64, value=pa.DEFAULT_GROUP_SIZE, step=8, label="Points per patch")
|
| 382 |
+
with gr.Row():
|
| 383 |
+
text_setting = gr.Radio(
|
| 384 |
+
["part_only", "part_plus_cat", "ensemble"], value="part_only",
|
| 385 |
+
label="Prompt ensemble",
|
| 386 |
+
info="`part_plus_cat` / `ensemble` also use the object category",
|
| 387 |
+
)
|
| 388 |
+
category = gr.Textbox(label="Object category", value="", placeholder="chair")
|
| 389 |
+
with gr.Row():
|
| 390 |
+
assign = gr.Radio(["nearest", "membership"], value="nearest", label="Patch → point assignment")
|
| 391 |
+
tau = gr.Slider(0.01, 1.0, value=pa.DEFAULT_TAU, step=0.01, label="CLIP temperature τ")
|
| 392 |
+
seed = gr.Number(label="Sampling seed", value=0, precision=0)
|
| 393 |
+
|
| 394 |
+
inputs = [shape_file, labels_text, num_points, num_group, group_size,
|
| 395 |
+
text_setting, category, assign, tau, seed]
|
| 396 |
+
outputs = [plot, shares, ply_out, summary]
|
| 397 |
+
|
| 398 |
+
gr.Examples(
|
| 399 |
+
examples=EXAMPLES,
|
| 400 |
+
inputs=[shape_file, labels_text],
|
| 401 |
+
outputs=outputs,
|
| 402 |
+
fn=segment,
|
| 403 |
+
cache_examples=True,
|
| 404 |
+
cache_mode="lazy",
|
| 405 |
+
label="Examples · ShapeNetPart test shapes and the Stanford Bunny mesh",
|
| 406 |
+
)
|
| 407 |
+
|
| 408 |
+
gr.Markdown(
|
| 409 |
+
"""
|
| 410 |
+
### How it works
|
| 411 |
+
Points are centred and scaled to the unit sphere and the Y/Z axes are swapped to match the training
|
| 412 |
+
convention (exactly as in the official `infer.py`). Furthest-point sampling picks patch centres, a k-NN
|
| 413 |
+
neighbourhood around each becomes a patch token, and a 12-layer point transformer produces one feature
|
| 414 |
+
per patch. A learned linear head projects those into the CLIP `ViT-bigG-14 (laion2b_s39b_b160k)` text
|
| 415 |
+
space, where they are matched against the prompt ensemble `{"<part>", "a <part>", "<part> part"}`.
|
| 416 |
+
Each point takes the label of its nearest patch centre.
|
| 417 |
+
|
| 418 |
+
Every query is *forced* to win somewhere, so asking for a part the shape does not have will still colour
|
| 419 |
+
something — that is expected for open-vocabulary matching. Shapes close to the ShapeNetPart categories
|
| 420 |
+
work best; the Bunny is there to show that arbitrary meshes go through the same path.
|
| 421 |
+
"""
|
| 422 |
+
)
|
| 423 |
+
|
| 424 |
+
shape_file.change(preview_shape, inputs=[shape_file, num_points, seed], outputs=preview,
|
| 425 |
+
api_name="preview")
|
| 426 |
+
preset.change(apply_preset, inputs=[preset, labels_text, category], outputs=[labels_text, category])
|
| 427 |
+
run.click(segment, inputs=inputs, outputs=outputs, api_name="segment")
|
| 428 |
+
labels_text.submit(segment, inputs=inputs, outputs=outputs)
|
| 429 |
+
|
| 430 |
+
if __name__ == "__main__":
|
| 431 |
+
demo.launch(mcp_server=True)
|
examples/airplane.ply
ADDED
|
Binary file (24.7 kB). View file
|
|
|
examples/bunny.obj
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
examples/chair.ply
ADDED
|
Binary file (24.7 kB). View file
|
|
|
examples/guitar.ply
ADDED
|
Binary file (24.7 kB). View file
|
|
|
examples/lamp.ply
ADDED
|
Binary file (24.7 kB). View file
|
|
|
examples/mug.ply
ADDED
|
Binary file (24.7 kB). View file
|
|
|
examples/table.ply
ADDED
|
Binary file (24.7 kB). View file
|
|
|
patchalign3d.py
ADDED
|
@@ -0,0 +1,465 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Faithful, dependency-light port of the official PatchAlign3D stage-2 inference path.
|
| 2 |
+
|
| 3 |
+
Source of truth:
|
| 4 |
+
https://github.com/souhail-hadgi/PatchAlign3D
|
| 5 |
+
src/models/point_transformer.py (encoder + patch grouping)
|
| 6 |
+
src/inference/infer.py (single-shape inference)
|
| 7 |
+
src/inference/eval.py (ShapeNetPart / FAUST evaluation)
|
| 8 |
+
src/datasets/shapenet.py (pc_normalize, 2048-point sampling)
|
| 9 |
+
|
| 10 |
+
Deviations from upstream, all behaviour-preserving:
|
| 11 |
+
* `pointnet2_ops.furthest_point_sample` -> pure-torch FPS with the same
|
| 12 |
+
deterministic seeding (start from index 0, squared distances, argmax).
|
| 13 |
+
* `knn_cuda.KNN(..., transpose_mode=True)` -> pure-torch cdist + topk
|
| 14 |
+
(ascending distance order, identical semantics).
|
| 15 |
+
* open_clip `ViT-bigG-14 / laion2b_s39b_b160k` text tower -> the *same*
|
| 16 |
+
weights served as a HF `CLIPTextModelWithProjection` (verified numerically
|
| 17 |
+
identical up to fp16 storage rounding), so only the ~1.4 GB text tower is
|
| 18 |
+
downloaded instead of the full 10 GB two-tower checkpoint.
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
from __future__ import annotations
|
| 22 |
+
|
| 23 |
+
import numpy as np
|
| 24 |
+
import torch
|
| 25 |
+
import torch.nn as nn
|
| 26 |
+
import torch.nn.functional as F
|
| 27 |
+
|
| 28 |
+
# --------------------------------------------------------------------------------------
|
| 29 |
+
# Config constants taken verbatim from the reference scripts
|
| 30 |
+
# --------------------------------------------------------------------------------------
|
| 31 |
+
|
| 32 |
+
TRANS_DIM = 384
|
| 33 |
+
DEPTH = 12
|
| 34 |
+
NUM_HEADS = 6
|
| 35 |
+
ENCODER_DIMS = 256
|
| 36 |
+
DROP_PATH_RATE = 0.1
|
| 37 |
+
|
| 38 |
+
DEFAULT_NUM_GROUP = 128
|
| 39 |
+
DEFAULT_GROUP_SIZE = 32
|
| 40 |
+
DEFAULT_NPOINTS = 2048
|
| 41 |
+
DEFAULT_TAU = 0.07
|
| 42 |
+
|
| 43 |
+
CLIP_TEXT_REPO = "stabilityai/stable-diffusion-xl-base-1.0"
|
| 44 |
+
CLIP_TEXT_SUBFOLDER = "text_encoder_2"
|
| 45 |
+
CLIP_TOKENIZER_SUBFOLDER = "tokenizer_2"
|
| 46 |
+
CLIP_TEXT_DIM = 1280 # ViT-bigG-14 joint embedding dim
|
| 47 |
+
|
| 48 |
+
PART_ONLY_TEMPLATES = ["{}", "a {}", "{} part"]
|
| 49 |
+
PART_PLUS_CAT_TEMPLATES = [
|
| 50 |
+
"a {} of a {}",
|
| 51 |
+
"the {} of a {}",
|
| 52 |
+
"{} of {}",
|
| 53 |
+
"a {} part of a {}",
|
| 54 |
+
]
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def clean_text(s: str) -> str:
|
| 58 |
+
"""Upstream `_clean_text`: lowercase, underscores -> spaces, strip punctuation."""
|
| 59 |
+
s = s.strip().lower().replace("_", " ")
|
| 60 |
+
out = []
|
| 61 |
+
for ch in s:
|
| 62 |
+
out.append(ch if (ch.isalnum() or ch.isspace()) else " ")
|
| 63 |
+
return " ".join("".join(out).split())
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
# --------------------------------------------------------------------------------------
|
| 67 |
+
# Pure-torch replacements for pointnet2_ops / knn_cuda
|
| 68 |
+
# --------------------------------------------------------------------------------------
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def furthest_point_sample(xyz: torch.Tensor, npoint: int) -> torch.Tensor:
|
| 72 |
+
"""Iterative FPS matching `pointnet2_ops.furthest_point_sample`.
|
| 73 |
+
|
| 74 |
+
Starts from point index 0 and greedily picks the point with the largest
|
| 75 |
+
squared distance to the already-selected set (exactly what the CUDA kernel
|
| 76 |
+
does). Returns (B, npoint) long indices.
|
| 77 |
+
"""
|
| 78 |
+
B, N, _ = xyz.shape
|
| 79 |
+
device = xyz.device
|
| 80 |
+
idx = torch.zeros(B, npoint, dtype=torch.long, device=device)
|
| 81 |
+
dist = torch.full((B, N), 1e10, device=device, dtype=xyz.dtype)
|
| 82 |
+
farthest = torch.zeros(B, dtype=torch.long, device=device)
|
| 83 |
+
ar = torch.arange(B, device=device)
|
| 84 |
+
for i in range(npoint):
|
| 85 |
+
idx[:, i] = farthest
|
| 86 |
+
centroid = xyz[ar, farthest, :].view(B, 1, 3)
|
| 87 |
+
d = ((xyz - centroid) ** 2).sum(-1)
|
| 88 |
+
dist = torch.minimum(dist, d)
|
| 89 |
+
farthest = dist.argmax(-1)
|
| 90 |
+
return idx
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def fps(data: torch.Tensor, number: int) -> torch.Tensor:
|
| 94 |
+
"""(B, N, 3) -> (B, number, 3) furthest-point-sampled coordinates."""
|
| 95 |
+
idx = furthest_point_sample(data, number)
|
| 96 |
+
return torch.gather(data, 1, idx.unsqueeze(-1).expand(-1, -1, data.shape[-1]))
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def knn_indices(ref: torch.Tensor, query: torch.Tensor, k: int) -> torch.Tensor:
|
| 100 |
+
"""`knn_cuda.KNN(k, transpose_mode=True)(ref, query)[1]`.
|
| 101 |
+
|
| 102 |
+
ref: (B, Nr, 3), query: (B, Nq, 3) -> (B, Nq, k) indices into Nr,
|
| 103 |
+
ordered by ascending distance.
|
| 104 |
+
"""
|
| 105 |
+
d = torch.cdist(query, ref) # (B, Nq, Nr)
|
| 106 |
+
return d.topk(k, dim=-1, largest=False).indices
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
# --------------------------------------------------------------------------------------
|
| 110 |
+
# Point-Transformer encoder (verbatim port of src/models/point_transformer.py)
|
| 111 |
+
# --------------------------------------------------------------------------------------
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
class DropPath(nn.Module):
|
| 115 |
+
"""Stochastic depth. Identity at inference time (which is all we do here)."""
|
| 116 |
+
|
| 117 |
+
def __init__(self, drop_prob: float = 0.0):
|
| 118 |
+
super().__init__()
|
| 119 |
+
self.drop_prob = drop_prob
|
| 120 |
+
|
| 121 |
+
def forward(self, x):
|
| 122 |
+
if self.drop_prob == 0.0 or not self.training:
|
| 123 |
+
return x
|
| 124 |
+
keep = 1.0 - self.drop_prob
|
| 125 |
+
shape = (x.shape[0],) + (1,) * (x.ndim - 1)
|
| 126 |
+
mask = x.new_empty(shape).bernoulli_(keep).div_(keep)
|
| 127 |
+
return x * mask
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
class PatchedGroup(nn.Module):
|
| 131 |
+
"""Same as upstream `PatchedGroup`, with FPS/KNN swapped for the torch versions."""
|
| 132 |
+
|
| 133 |
+
def __init__(self, num_group: int, group_size: int):
|
| 134 |
+
super().__init__()
|
| 135 |
+
self.num_group = num_group
|
| 136 |
+
self.group_size = group_size
|
| 137 |
+
|
| 138 |
+
def forward(self, xyz: torch.Tensor):
|
| 139 |
+
batch_size, num_points, C = xyz.shape
|
| 140 |
+
if C > 3:
|
| 141 |
+
xyz_only = xyz[:, :, :3].contiguous()
|
| 142 |
+
extra = xyz[:, :, 3:].contiguous()
|
| 143 |
+
else:
|
| 144 |
+
xyz_only = xyz.contiguous()
|
| 145 |
+
extra = None
|
| 146 |
+
|
| 147 |
+
center = fps(xyz_only, self.num_group) # (B, G, 3)
|
| 148 |
+
idx = knn_indices(xyz_only, center, self.group_size) # (B, G, M)
|
| 149 |
+
idx_rel = idx.clone()
|
| 150 |
+
idx_base = torch.arange(0, batch_size, device=xyz.device).view(-1, 1, 1) * num_points
|
| 151 |
+
idx_flat = (idx + idx_base).view(-1)
|
| 152 |
+
neigh_xyz = xyz_only.reshape(batch_size * num_points, -1)[idx_flat, :].view(
|
| 153 |
+
batch_size, self.num_group, self.group_size, 3
|
| 154 |
+
)
|
| 155 |
+
if extra is not None:
|
| 156 |
+
neigh_extra = extra.reshape(batch_size * num_points, -1)[idx_flat, :].view(
|
| 157 |
+
batch_size, self.num_group, self.group_size, -1
|
| 158 |
+
)
|
| 159 |
+
neighborhood = torch.cat((neigh_xyz - center.unsqueeze(2), neigh_extra), dim=-1)
|
| 160 |
+
else:
|
| 161 |
+
neighborhood = neigh_xyz - center.unsqueeze(2)
|
| 162 |
+
return neighborhood.contiguous(), center.contiguous(), idx_rel
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
class Encoder(nn.Module):
|
| 166 |
+
def __init__(self, encoder_channel: int, color: bool = False):
|
| 167 |
+
super().__init__()
|
| 168 |
+
self.encoder_channel = encoder_channel
|
| 169 |
+
self.first_conv = nn.Sequential(
|
| 170 |
+
nn.Conv1d(6 if color else 3, 128, 1),
|
| 171 |
+
nn.BatchNorm1d(128),
|
| 172 |
+
nn.ReLU(inplace=True),
|
| 173 |
+
nn.Conv1d(128, 256, 1),
|
| 174 |
+
)
|
| 175 |
+
self.second_conv = nn.Sequential(
|
| 176 |
+
nn.Conv1d(512, 512, 1),
|
| 177 |
+
nn.BatchNorm1d(512),
|
| 178 |
+
nn.ReLU(inplace=True),
|
| 179 |
+
nn.Conv1d(512, self.encoder_channel, 1),
|
| 180 |
+
)
|
| 181 |
+
|
| 182 |
+
def forward(self, point_groups):
|
| 183 |
+
bs, g, n, c = point_groups.shape
|
| 184 |
+
point_groups = point_groups.reshape(bs * g, n, c).permute(0, 2, 1)
|
| 185 |
+
feature = self.first_conv(point_groups)
|
| 186 |
+
feature_global = torch.max(feature, 2, keepdim=True)[0]
|
| 187 |
+
feature_global = feature_global.repeat(1, 1, n)
|
| 188 |
+
feature = torch.cat([feature_global, feature], 1)
|
| 189 |
+
feature = self.second_conv(feature)
|
| 190 |
+
feature = feature.max(dim=2)[0]
|
| 191 |
+
return feature.reshape(bs, g, self.encoder_channel).contiguous()
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
class MLP(nn.Module):
|
| 195 |
+
def __init__(self, in_features, hidden_features=None, out_features=None, act_layer=nn.GELU, drop=0.0):
|
| 196 |
+
super().__init__()
|
| 197 |
+
out_features = out_features or in_features
|
| 198 |
+
hidden_features = hidden_features or in_features
|
| 199 |
+
self.fc1 = nn.Linear(in_features, hidden_features)
|
| 200 |
+
self.act = act_layer()
|
| 201 |
+
self.fc2 = nn.Linear(hidden_features, out_features)
|
| 202 |
+
self.drop = nn.Dropout(drop)
|
| 203 |
+
|
| 204 |
+
def forward(self, x):
|
| 205 |
+
x = self.fc1(x)
|
| 206 |
+
x = self.act(x)
|
| 207 |
+
x = self.drop(x)
|
| 208 |
+
x = self.fc2(x)
|
| 209 |
+
x = self.drop(x)
|
| 210 |
+
return x
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
class Attention(nn.Module):
|
| 214 |
+
def __init__(self, dim, num_heads=8, qkv_bias=False, qk_scale=None, attn_drop=0.0, proj_drop=0.0):
|
| 215 |
+
super().__init__()
|
| 216 |
+
self.num_heads = num_heads
|
| 217 |
+
head_dim = dim // num_heads
|
| 218 |
+
self.scale = qk_scale or head_dim ** -0.5
|
| 219 |
+
self.qkv = nn.Linear(dim, dim * 3, bias=qkv_bias)
|
| 220 |
+
self.attn_drop = nn.Dropout(attn_drop)
|
| 221 |
+
self.proj = nn.Linear(dim, dim)
|
| 222 |
+
self.proj_drop = nn.Dropout(proj_drop)
|
| 223 |
+
|
| 224 |
+
def forward(self, x):
|
| 225 |
+
B, N, C = x.shape
|
| 226 |
+
qkv = self.qkv(x).reshape(B, N, 3, self.num_heads, C // self.num_heads).permute(2, 0, 3, 1, 4)
|
| 227 |
+
q, k, v = qkv[0], qkv[1], qkv[2]
|
| 228 |
+
attn = (q @ k.transpose(-2, -1)) * self.scale
|
| 229 |
+
attn = attn.softmax(dim=-1)
|
| 230 |
+
attn = self.attn_drop(attn)
|
| 231 |
+
x = (attn @ v).transpose(1, 2).reshape(B, N, C)
|
| 232 |
+
x = self.proj(x)
|
| 233 |
+
return self.proj_drop(x)
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
class Block(nn.Module):
|
| 237 |
+
def __init__(self, dim, num_heads, mlp_ratio=4.0, qkv_bias=False, qk_scale=None,
|
| 238 |
+
drop=0.0, attn_drop=0.0, drop_path=0.0, act_layer=nn.GELU):
|
| 239 |
+
super().__init__()
|
| 240 |
+
self.norm1 = nn.LayerNorm(dim)
|
| 241 |
+
self.attn = Attention(dim, num_heads=num_heads, qkv_bias=qkv_bias, qk_scale=qk_scale,
|
| 242 |
+
attn_drop=attn_drop, proj_drop=drop)
|
| 243 |
+
self.drop_path = DropPath(drop_path) if drop_path > 0.0 else nn.Identity()
|
| 244 |
+
self.norm2 = nn.LayerNorm(dim)
|
| 245 |
+
self.mlp = MLP(in_features=dim, hidden_features=int(dim * mlp_ratio), act_layer=act_layer, drop=drop)
|
| 246 |
+
|
| 247 |
+
def forward(self, x):
|
| 248 |
+
x = x + self.drop_path(self.attn(self.norm1(x)))
|
| 249 |
+
x = x + self.drop_path(self.mlp(self.norm2(x)))
|
| 250 |
+
return x
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
class TransformerEncoder(nn.Module):
|
| 254 |
+
def __init__(self, embed_dim=768, depth=4, num_heads=12, mlp_ratio=4.0, qkv_bias=False,
|
| 255 |
+
qk_scale=None, drop_rate=0.0, attn_drop_rate=0.0, drop_path_rate=0.0):
|
| 256 |
+
super().__init__()
|
| 257 |
+
|
| 258 |
+
def _drop_for_block(i):
|
| 259 |
+
if isinstance(drop_path_rate, (list, tuple)):
|
| 260 |
+
return drop_path_rate[i]
|
| 261 |
+
return drop_path_rate
|
| 262 |
+
|
| 263 |
+
self.blocks = nn.ModuleList([
|
| 264 |
+
Block(dim=embed_dim, num_heads=num_heads, mlp_ratio=mlp_ratio, qkv_bias=qkv_bias,
|
| 265 |
+
qk_scale=qk_scale, drop=drop_rate, attn_drop=attn_drop_rate,
|
| 266 |
+
drop_path=_drop_for_block(i))
|
| 267 |
+
for i in range(depth)
|
| 268 |
+
])
|
| 269 |
+
|
| 270 |
+
def forward(self, x, pos):
|
| 271 |
+
for blk in self.blocks:
|
| 272 |
+
x = blk(x + pos)
|
| 273 |
+
return x
|
| 274 |
+
|
| 275 |
+
|
| 276 |
+
class PointTransformer(nn.Module):
|
| 277 |
+
"""Upstream `point_transformer.get_model`."""
|
| 278 |
+
|
| 279 |
+
def __init__(self, num_group=DEFAULT_NUM_GROUP, group_size=DEFAULT_GROUP_SIZE, color=False):
|
| 280 |
+
super().__init__()
|
| 281 |
+
self.trans_dim = TRANS_DIM
|
| 282 |
+
self.depth = DEPTH
|
| 283 |
+
self.num_heads = NUM_HEADS
|
| 284 |
+
self.encoder_dims = ENCODER_DIMS
|
| 285 |
+
self.color = color
|
| 286 |
+
self.group_size = group_size
|
| 287 |
+
self.num_group = num_group
|
| 288 |
+
|
| 289 |
+
self.group_divider = PatchedGroup(num_group=num_group, group_size=group_size)
|
| 290 |
+
self.encoder = Encoder(encoder_channel=self.encoder_dims, color=color)
|
| 291 |
+
self.reduce_dim = nn.Linear(self.encoder_dims, self.trans_dim)
|
| 292 |
+
|
| 293 |
+
self.cls_token = nn.Parameter(torch.zeros(1, 1, self.trans_dim))
|
| 294 |
+
self.cls_pos = nn.Parameter(torch.randn(1, 1, self.trans_dim))
|
| 295 |
+
self.pos_embed = nn.Sequential(nn.Linear(3, 128), nn.GELU(), nn.Linear(128, self.trans_dim))
|
| 296 |
+
|
| 297 |
+
dpr = [x.item() for x in torch.linspace(0, DROP_PATH_RATE, self.depth)]
|
| 298 |
+
self.blocks = TransformerEncoder(embed_dim=self.trans_dim, depth=self.depth,
|
| 299 |
+
drop_path_rate=dpr, num_heads=self.num_heads)
|
| 300 |
+
self.norm = nn.LayerNorm(self.trans_dim)
|
| 301 |
+
|
| 302 |
+
def set_grouping(self, num_group: int, group_size: int) -> None:
|
| 303 |
+
self.group_divider.num_group = int(num_group)
|
| 304 |
+
self.group_divider.group_size = int(group_size)
|
| 305 |
+
|
| 306 |
+
def forward_patches(self, pts: torch.Tensor):
|
| 307 |
+
"""pts: (B, C, N) with C >= 3. Returns patch_emb (B, D, G), centers (B, 3, G), idx (B, G, M)."""
|
| 308 |
+
pts_bn = pts.transpose(-1, -2).contiguous()
|
| 309 |
+
neighborhood, center, patch_idx = self.group_divider(pts_bn)
|
| 310 |
+
group_tokens = self.encoder(neighborhood)
|
| 311 |
+
group_tokens = self.reduce_dim(group_tokens)
|
| 312 |
+
|
| 313 |
+
cls_tokens = self.cls_token.expand(group_tokens.size(0), -1, -1)
|
| 314 |
+
cls_pos = self.cls_pos.expand(group_tokens.size(0), -1, -1)
|
| 315 |
+
pos = self.pos_embed(center)
|
| 316 |
+
|
| 317 |
+
x = torch.cat((cls_tokens, group_tokens), dim=1)
|
| 318 |
+
pos = torch.cat((cls_pos, pos), dim=1)
|
| 319 |
+
feature = self.blocks(x, pos)
|
| 320 |
+
patch_emb = self.norm(feature)[:, 1:, :].transpose(-1, -2).contiguous()
|
| 321 |
+
patch_centers = center.transpose(-1, -2).contiguous()
|
| 322 |
+
return patch_emb, patch_centers, patch_idx
|
| 323 |
+
|
| 324 |
+
|
| 325 |
+
class PatchToTextProj(nn.Module):
|
| 326 |
+
def __init__(self, in_dim: int, out_dim: int):
|
| 327 |
+
super().__init__()
|
| 328 |
+
self.proj = nn.Linear(in_dim, out_dim)
|
| 329 |
+
|
| 330 |
+
def forward(self, patch_emb):
|
| 331 |
+
x = patch_emb.transpose(1, 2)
|
| 332 |
+
x = self.proj(x)
|
| 333 |
+
return F.normalize(x, dim=-1)
|
| 334 |
+
|
| 335 |
+
|
| 336 |
+
# --------------------------------------------------------------------------------------
|
| 337 |
+
# Geometry helpers
|
| 338 |
+
# --------------------------------------------------------------------------------------
|
| 339 |
+
|
| 340 |
+
|
| 341 |
+
def pc_normalize(pc: np.ndarray) -> np.ndarray:
|
| 342 |
+
"""Upstream `pc_normalize`: centre, then scale to the unit sphere."""
|
| 343 |
+
centroid = pc.mean(axis=0)
|
| 344 |
+
pc = pc - centroid
|
| 345 |
+
m = np.max(np.sqrt((pc ** 2).sum(axis=1)))
|
| 346 |
+
if m <= 0:
|
| 347 |
+
m = 1.0
|
| 348 |
+
return pc / m
|
| 349 |
+
|
| 350 |
+
|
| 351 |
+
def prepare_points(points: torch.Tensor) -> torch.Tensor:
|
| 352 |
+
"""Upstream `prepare_points`: (B,N,C) -> (B,C,N) with the Y/Z axes swapped."""
|
| 353 |
+
if points.ndim != 3:
|
| 354 |
+
raise ValueError(f"Expected (B,N,C), got {tuple(points.shape)}")
|
| 355 |
+
pts = points.transpose(2, 1).contiguous()
|
| 356 |
+
pts[:, [1, 2], :] = pts[:, [2, 1], :]
|
| 357 |
+
return pts
|
| 358 |
+
|
| 359 |
+
|
| 360 |
+
def assign_points_from_patches(points_xyz, patch_centers, patch_logits, patch_idx, mode="nearest"):
|
| 361 |
+
"""Upstream `assign_points_from_patches` (knn_cuda replaced by cdist/argmin)."""
|
| 362 |
+
B, _, N = points_xyz.shape
|
| 363 |
+
K = patch_logits.shape[-1]
|
| 364 |
+
if mode == "membership":
|
| 365 |
+
point_logits = torch.zeros(B, N, K, device=points_xyz.device, dtype=patch_logits.dtype)
|
| 366 |
+
counts = torch.zeros(B, N, 1, device=points_xyz.device, dtype=patch_logits.dtype)
|
| 367 |
+
for b in range(B):
|
| 368 |
+
idx = patch_idx[b].reshape(-1)
|
| 369 |
+
src = patch_logits[b].unsqueeze(1).expand_as(patch_idx[b].unsqueeze(-1).expand(-1, -1, K)).reshape(-1, K)
|
| 370 |
+
point_logits[b].index_add_(0, idx, src)
|
| 371 |
+
ones = torch.ones(idx.shape[0], 1, device=points_xyz.device, dtype=patch_logits.dtype)
|
| 372 |
+
counts[b].index_add_(0, idx, ones)
|
| 373 |
+
return point_logits / counts.clamp_min(1.0)
|
| 374 |
+
nearest = knn_indices(patch_centers.transpose(1, 2).contiguous(),
|
| 375 |
+
points_xyz.transpose(1, 2).contiguous(), 1).squeeze(-1)
|
| 376 |
+
return patch_logits.gather(1, nearest.unsqueeze(-1).expand(-1, -1, K))
|
| 377 |
+
|
| 378 |
+
|
| 379 |
+
# --------------------------------------------------------------------------------------
|
| 380 |
+
# Text side
|
| 381 |
+
# --------------------------------------------------------------------------------------
|
| 382 |
+
|
| 383 |
+
|
| 384 |
+
def build_prompts(name: str, category: str, setting: str) -> list[str]:
|
| 385 |
+
"""Prompt ensemble for one part label, mirroring `eval.py:encode_texts`."""
|
| 386 |
+
nm = clean_text(name)
|
| 387 |
+
cname = clean_text(category or "")
|
| 388 |
+
texts: list[str] = []
|
| 389 |
+
if setting in ("part_plus_cat", "ensemble") and cname:
|
| 390 |
+
for tpl in PART_PLUS_CAT_TEMPLATES:
|
| 391 |
+
slots = tpl.count("{}")
|
| 392 |
+
if slots == 2:
|
| 393 |
+
texts.append(tpl.format(nm, cname))
|
| 394 |
+
elif slots == 1:
|
| 395 |
+
texts.append(tpl.format(f"{cname} {nm}"))
|
| 396 |
+
else:
|
| 397 |
+
texts.append(f"{cname} {nm}")
|
| 398 |
+
if (setting in ("part_only", "ensemble")) or not cname:
|
| 399 |
+
for tpl in PART_ONLY_TEMPLATES:
|
| 400 |
+
texts.append(tpl.format(nm) if tpl.count("{}") == 1 else nm)
|
| 401 |
+
return texts or [nm]
|
| 402 |
+
|
| 403 |
+
|
| 404 |
+
@torch.no_grad()
|
| 405 |
+
def encode_labels(names, category, setting, text_model, tokenizer, device) -> torch.Tensor:
|
| 406 |
+
"""One L2-normalised CLIP text embedding per label -> (K, 1280)."""
|
| 407 |
+
per_label = []
|
| 408 |
+
for nm in names:
|
| 409 |
+
prompts = build_prompts(nm, category, setting)
|
| 410 |
+
toks = tokenizer(prompts, padding="max_length", max_length=tokenizer.model_max_length,
|
| 411 |
+
truncation=True, return_tensors="pt").to(device)
|
| 412 |
+
feat = text_model(**toks).text_embeds.float()
|
| 413 |
+
feat = F.normalize(feat, dim=-1)
|
| 414 |
+
per_label.append(F.normalize(feat.mean(dim=0, keepdim=True), dim=-1))
|
| 415 |
+
return torch.cat(per_label, dim=0)
|
| 416 |
+
|
| 417 |
+
|
| 418 |
+
# --------------------------------------------------------------------------------------
|
| 419 |
+
# Checkpoint
|
| 420 |
+
# --------------------------------------------------------------------------------------
|
| 421 |
+
|
| 422 |
+
|
| 423 |
+
def load_patchalign3d(ckpt_path: str, num_group=DEFAULT_NUM_GROUP, group_size=DEFAULT_GROUP_SIZE):
|
| 424 |
+
model = PointTransformer(num_group=num_group, group_size=group_size, color=False)
|
| 425 |
+
proj = PatchToTextProj(in_dim=TRANS_DIM, out_dim=CLIP_TEXT_DIM)
|
| 426 |
+
ckpt = torch.load(ckpt_path, map_location="cpu", weights_only=False)
|
| 427 |
+
if "model" in ckpt:
|
| 428 |
+
res = model.load_state_dict(ckpt["model"], strict=False)
|
| 429 |
+
print(f"[ckpt] encoder: missing={len(res.missing_keys)} unexpected={len(res.unexpected_keys)}")
|
| 430 |
+
if res.missing_keys:
|
| 431 |
+
print(" missing:", res.missing_keys)
|
| 432 |
+
if res.unexpected_keys:
|
| 433 |
+
print(" unexpected:", res.unexpected_keys)
|
| 434 |
+
else:
|
| 435 |
+
raise RuntimeError("checkpoint has no 'model' entry")
|
| 436 |
+
if "proj" in ckpt:
|
| 437 |
+
res = proj.load_state_dict(ckpt["proj"], strict=False)
|
| 438 |
+
print(f"[ckpt] proj: missing={len(res.missing_keys)} unexpected={len(res.unexpected_keys)}")
|
| 439 |
+
else:
|
| 440 |
+
raise RuntimeError("checkpoint has no 'proj' entry")
|
| 441 |
+
return model.eval(), proj.eval()
|
| 442 |
+
|
| 443 |
+
|
| 444 |
+
# --------------------------------------------------------------------------------------
|
| 445 |
+
# End-to-end segmentation
|
| 446 |
+
# --------------------------------------------------------------------------------------
|
| 447 |
+
|
| 448 |
+
|
| 449 |
+
@torch.no_grad()
|
| 450 |
+
def segment_point_cloud(points_np, label_names, model, proj, text_model, tokenizer, device,
|
| 451 |
+
category="", text_setting="part_only", assign="nearest",
|
| 452 |
+
tau=DEFAULT_TAU, num_group=DEFAULT_NUM_GROUP, group_size=DEFAULT_GROUP_SIZE):
|
| 453 |
+
"""points_np: (N,3) float array in original coordinates. Returns (pred, probs)."""
|
| 454 |
+
model.set_grouping(num_group, group_size)
|
| 455 |
+
pts = torch.as_tensor(np.ascontiguousarray(points_np[:, :3]), dtype=torch.float32).unsqueeze(0)
|
| 456 |
+
pts = prepare_points(pts).to(device)
|
| 457 |
+
|
| 458 |
+
patch_emb, patch_centers, patch_idx = model.forward_patches(pts)
|
| 459 |
+
patch_feat = proj(patch_emb)
|
| 460 |
+
text_feats = encode_labels(label_names, category, text_setting, text_model, tokenizer, device)
|
| 461 |
+
logits = (patch_feat @ text_feats.t()) / max(float(tau), 1e-6)
|
| 462 |
+
point_logits = assign_points_from_patches(pts[:, :3, :], patch_centers, logits, patch_idx, mode=assign)
|
| 463 |
+
probs = point_logits.softmax(dim=-1).squeeze(0)
|
| 464 |
+
pred = point_logits.argmax(dim=-1).squeeze(0)
|
| 465 |
+
return pred.cpu().numpy().astype(np.int64), probs.cpu().numpy().astype(np.float32)
|
requirements.txt
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
torch
|
| 2 |
+
transformers
|
| 3 |
+
numpy
|
| 4 |
+
trimesh
|
| 5 |
+
networkx
|
| 6 |
+
plotly
|
| 7 |
+
safetensors
|
| 8 |
+
pillow
|