multimodalart HF Staff commited on
Commit
83c5d6a
·
verified ·
1 Parent(s): d968973

PatchAlign3D zero-shot 3D part segmentation demo

Browse files
README.md CHANGED
@@ -1,13 +1,50 @@
1
  ---
2
- title: Patch Align 3d
3
- emoji: 🦀
4
- colorFrom: indigo
5
- colorTo: purple
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
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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