"""PatchAlign3D — open-vocabulary (zero-shot) 3D part segmentation from point clouds. Paper: https://huggingface.co/papers/2601.02457 Code: https://github.com/souhail-hadgi/PatchAlign3D Weights: https://huggingface.co/patchalign3d/patchalign3d-encoder """ import os os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True") import spaces # noqa: E402 (must precede torch) import tempfile # noqa: E402 import time # noqa: E402 from pathlib import Path # noqa: E402 import gradio as gr # noqa: E402 import numpy as np # noqa: E402 import plotly.graph_objects as go # noqa: E402 import torch # noqa: E402 import trimesh # noqa: E402 from huggingface_hub import hf_hub_download # noqa: E402 from transformers import CLIPTextModelWithProjection, CLIPTokenizer # noqa: E402 import patchalign3d as pa # noqa: E402 # -------------------------------------------------------------------------------------- # Models — module scope, eager .to("cuda"); ZeroGPU streams them in on the first call # -------------------------------------------------------------------------------------- CKPT = hf_hub_download("patchalign3d/patchalign3d-encoder", "patchalign3d.pt") model, proj = pa.load_patchalign3d(CKPT) model = model.to("cuda") proj = proj.to("cuda") tokenizer = CLIPTokenizer.from_pretrained(pa.CLIP_TEXT_REPO, subfolder=pa.CLIP_TOKENIZER_SUBFOLDER) text_model = ( CLIPTextModelWithProjection.from_pretrained( pa.CLIP_TEXT_REPO, subfolder=pa.CLIP_TEXT_SUBFOLDER, variant="fp16", dtype=torch.float32 ) .eval() .to("cuda") ) print( f"[init] PatchAlign3D encoder {sum(p.numel() for p in model.parameters()) / 1e6:.1f}M params | " f"CLIP ViT-bigG-14 text tower {sum(p.numel() for p in text_model.parameters()) / 1e6:.1f}M params | " f"tokenizer pad={tokenizer.pad_token_id} ctx={tokenizer.model_max_length}" ) MAX_LABELS = 12 MESH_EXTS = {".obj", ".glb", ".gltf", ".stl", ".off", ".ply", ".dae", ".3mf"} # Distinguishable qualitative palette PALETTE = [ "#e6194b", "#3cb44b", "#4363d8", "#f58231", "#911eb4", "#00b8d4", "#f032e6", "#a1c800", "#fabed4", "#469990", "#9a6324", "#7f0000", ] # ShapeNetPart part vocabularies, verbatim from the official eval.py PRESETS = { "— custom —": ("", ""), "Airplane": ("body, wing, tail, engine or frame", "airplane"), "Bag": ("handle, body", "bag"), "Cap": ("crown, brim", "cap"), "Car": ("roof, hood, wheel, body", "car"), "Chair": ("back, seat, leg, arm", "chair"), "Earphone": ("earcup, headband, data wire", "earphone"), "Guitar": ("headstock, neck, body", "guitar"), "Knife": ("blade, handle", "knife"), "Lamp": ("base, lampshade, fixing bracket, pole", "lamp"), "Laptop": ("keyboard, screen", "laptop"), "Motorbike": ("gas tank, seat, wheel, handles or handlebars, headlight, engine or frame", "motorbike"), "Mug": ("handle, cup", "mug"), "Pistol": ("barrel, handle or grip, trigger and guard", "pistol"), "Rocket": ("body, fin, nose", "rocket"), "Skateboard": ("wheel, deck, belt for foot", "skateboard"), "Table": ("desktop, leg or support, drawer", "table"), } # -------------------------------------------------------------------------------------- # Shape loading # -------------------------------------------------------------------------------------- def _resample(pts: np.ndarray, npoints: int, seed: int) -> np.ndarray: n = len(pts) if n == npoints: return pts rng = np.random.default_rng(seed) return pts[rng.choice(n, size=npoints, replace=n < npoints)] def load_shape(path: str, npoints: int = pa.DEFAULT_NPOINTS, seed: int = 0): """Read a mesh or point cloud; return `npoints` unit-sphere-normalised points + a description.""" p = Path(path) ext = p.suffix.lower() src = "point cloud" if ext in (".npz", ".npy"): if ext == ".npy": arr = np.load(p) else: d = np.load(p, allow_pickle=True) key = next((k for k in ("points", "xyz", "pos", "vertices") if k in d), None) if key is None: raise gr.Error(f"NPZ must contain points/xyz/pos/vertices — found {list(d.keys())}") arr = d[key] arr = np.asarray(arr, dtype=np.float32) pts = arr.reshape(-1, arr.shape[-1])[:, :3] elif ext in (".txt", ".pts", ".xyz", ".csv", ".asc"): raw = np.loadtxt(p, delimiter="," if ext == ".csv" else None, dtype=np.float32) pts = np.atleast_2d(raw)[:, :3] elif ext in MESH_EXTS: obj = trimesh.load(str(p), process=False) if isinstance(obj, trimesh.Scene): faced = [g for g in obj.geometry.values() if getattr(g, "faces", None) is not None and len(g.faces)] if faced: try: obj = obj.to_mesh() except Exception: obj = trimesh.util.concatenate(faced) else: verts = [np.asarray(g.vertices) for g in obj.geometry.values() if hasattr(g, "vertices")] if not verts: raise gr.Error("No geometry found in this file.") obj = trimesh.PointCloud(np.concatenate(verts, axis=0)) if getattr(obj, "faces", None) is not None and len(obj.faces) > 0: np.random.seed(int(seed) % (2**31)) pts = np.asarray(trimesh.sample.sample_surface(obj, int(npoints))[0], dtype=np.float32) src = f"mesh, {len(obj.faces):,} faces, surface-sampled" else: pts = np.asarray(obj.vertices, dtype=np.float32)[:, :3] else: raise gr.Error( f"Unsupported file type '{ext}'. Use a mesh (.obj/.glb/.gltf/.stl/.off/.ply) " "or a point cloud (.ply/.npz/.txt/.xyz)." ) pts = np.ascontiguousarray(pts[np.isfinite(pts).all(axis=1)], dtype=np.float32) if len(pts) < 32: raise gr.Error(f"Only {len(pts)} usable points found — need at least 32.") raw_n = len(pts) pts = _resample(pts, int(npoints), int(seed)) return pa.pc_normalize(pts.astype(np.float32)), f"{src}, {raw_n:,} pts → {len(pts):,} used" # -------------------------------------------------------------------------------------- # Plotting # -------------------------------------------------------------------------------------- _AXIS = dict(showbackground=False, showgrid=False, zeroline=False, showticklabels=False, title="") def _style(fig: go.Figure, title: str, height: int) -> go.Figure: fig.update_layout( title=dict(text=title, x=0.02, font=dict(size=12, color="#8a8a8a")), scene=dict(xaxis=_AXIS, yaxis=_AXIS, zaxis=_AXIS, aspectmode="data", camera=dict(eye=dict(x=1.6, y=1.2, z=1.0))), margin=dict(l=0, r=0, t=28, b=0), height=height, showlegend=len(fig.data) > 1, legend=dict(orientation="h", yanchor="bottom", y=0.0, xanchor="left", x=0.0, font=dict(color="#8a8a8a", size=11), bgcolor="rgba(0,0,0,0)"), paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(0,0,0,0)", font=dict(color="#8a8a8a"), ) return fig def plot_raw(points: np.ndarray, title: str, height: int = 300) -> go.Figure: fig = go.Figure( go.Scatter3d( x=points[:, 0], y=points[:, 1], z=points[:, 2], mode="markers", marker=dict(size=1.8, color="#9aa0a6"), name="input", hoverinfo="skip", ) ) return _style(fig, title, height) def plot_segments(points, pred, names, conf, title: str, height: int = 560) -> go.Figure: fig = go.Figure() for k, name in enumerate(names): m = pred == k if not m.any(): continue fig.add_trace( go.Scatter3d( x=points[m, 0], y=points[m, 1], z=points[m, 2], mode="markers", marker=dict(size=2.6, color=PALETTE[k % len(PALETTE)]), name=f"{name} · {int(m.sum())}", customdata=conf[m], hovertemplate=f"{name}
p=%{{customdata:.2f}}", ) ) return _style(fig, title, height) def export_colored_ply(points: np.ndarray, pred: np.ndarray) -> str: rgba = np.zeros((len(points), 4), dtype=np.uint8) rgba[:, 3] = 255 for k in range(int(pred.max()) + 1): h = PALETTE[k % len(PALETTE)].lstrip("#") rgba[pred == k, :3] = [int(h[i:i + 2], 16) for i in (0, 2, 4)] f = tempfile.NamedTemporaryFile(suffix="_patchalign3d.ply", delete=False) f.close() trimesh.PointCloud(points, colors=rgba).export(f.name) return f.name # -------------------------------------------------------------------------------------- # Handlers # -------------------------------------------------------------------------------------- def preview_shape(shape_file: str, num_points: int = pa.DEFAULT_NPOINTS, seed: int = 0): """Show the uploaded shape as a plain point cloud. CPU only — no GPU needed. Args: shape_file: Path to a mesh or point-cloud file. num_points: Number of points to sample for the preview. seed: Sampling seed. Returns: An interactive 3D scatter plot of the sampled input points. """ if not shape_file: return None points, info = load_shape(shape_file, int(num_points), int(seed)) return plot_raw(points, f"Input — {info}") def _parse_labels(labels_text: str): names = [x.strip() for x in (labels_text or "").split(",") if x.strip()] if not names: raise gr.Error("Enter at least one part name, e.g. `back, seat, leg, arm`.") if len(names) > MAX_LABELS: raise gr.Error(f"At most {MAX_LABELS} part queries at a time (got {len(names)}).") return names def _estimate_duration(shape_file=None, labels_text="", num_points=pa.DEFAULT_NPOINTS, *args, **kwargs): """GPU reservation. Measured worst case is ~1.2 s of compute at the heaviest settings; the rest is headroom for loading / surface-sampling a large user-supplied mesh inside the same call.""" try: mb = os.path.getsize(shape_file) / 1e6 except Exception: mb = 0.0 try: n = int(num_points) except Exception: n = pa.DEFAULT_NPOINTS return int(min(90, 5 + 0.6 * mb + 2.0 * n / pa.DEFAULT_NPOINTS)) @spaces.GPU(duration=_estimate_duration) def segment( shape_file: str, labels_text: str = "back, seat, leg, arm", num_points: int = pa.DEFAULT_NPOINTS, num_group: int = pa.DEFAULT_NUM_GROUP, group_size: int = pa.DEFAULT_GROUP_SIZE, text_setting: str = "part_only", category: str = "", assign: str = "nearest", tau: float = pa.DEFAULT_TAU, seed: int = 0, ): """Zero-shot 3D part segmentation of a shape, driven by free-form text part names. Args: shape_file: Path to a mesh (.obj/.glb/.gltf/.stl/.off/.ply) or point cloud (.ply/.npz/.txt/.xyz). labels_text: Comma-separated part names to look for, e.g. "back, seat, leg, arm". num_points: Points sampled from the shape (2048 matches the training setting). num_group: Number of patches (furthest-point-sampled centres) the encoder uses. group_size: Points per patch (k-NN neighbourhood size). text_setting: Prompt ensemble — "part_only", "part_plus_cat" or "ensemble". category: Object category used by the "part_plus_cat" / "ensemble" prompts, e.g. "chair". assign: Patch-to-point assignment — "nearest" patch centre, or patch "membership" voting. tau: CLIP temperature used to turn cosine similarities into probabilities. seed: Seed for point / surface sampling. Returns: An interactive 3D plot of the segmented shape, the share of points per part, a colour-coded .ply download, and a short run summary. """ if not shape_file: raise gr.Error("Upload a 3D shape first, or pick one of the examples below.") names = _parse_labels(labels_text) num_points = int(num_points) num_group = max(1, min(int(num_group), num_points)) group_size = max(1, min(int(group_size), num_points)) t0 = time.perf_counter() points, info = load_shape(shape_file, num_points, int(seed)) t_load = time.perf_counter() - t0 t1 = time.perf_counter() pred, probs = pa.segment_point_cloud( points, names, model, proj, text_model, tokenizer, "cuda", category=category or "", text_setting=text_setting, assign=assign, tau=float(tau), num_group=num_group, group_size=group_size, ) t_gpu = time.perf_counter() - t1 conf = probs[np.arange(len(pred)), pred] shares = {name: float((pred == k).mean()) for k, name in enumerate(names)} fig = plot_segments(points, pred, names, conf, "Predicted parts — drag to rotate, scroll to zoom") ply = export_colored_ply(points, pred) summary = ( f"**{len(points):,} points → {num_group} patches → {len(names)} text queries** \n" f"{info} · prompts `{text_setting}`" + (f" · category `{category}`" if category and text_setting != "part_only" else "") + f" \nload {t_load:.2f}s · inference **{t_gpu:.2f}s** · mean confidence {conf.mean():.2f}" ) return fig, shares, ply, summary def apply_preset(preset: str, labels_text: str, category: str): if preset in PRESETS and preset != "— custom —": return PRESETS[preset] return labels_text, category # -------------------------------------------------------------------------------------- # UI # -------------------------------------------------------------------------------------- CSS = """ #col-container { max-width: 1240px; margin: 0 auto; } .dark .gradio-container { color: var(--body-text-color); } """ EXAMPLES = [ ["examples/chair.ply", "back, seat, leg"], ["examples/airplane.ply", "body, wing, tail"], ["examples/guitar.ply", "headstock, neck, body"], ["examples/table.ply", "desktop, leg or support, drawer"], ["examples/lamp.ply", "base, lampshade, pole"], ["examples/mug.ply", "handle, cup"], ["examples/bunny.obj", "ear, head, torso, foot"], ] with gr.Blocks(title="PatchAlign3D") as demo: with gr.Column(elem_id="col-container"): gr.Markdown( """ # PatchAlign3D · zero-shot 3D part segmentation Name the parts you want **in words** and see them highlighted on the 3D shape. One forward pass of a point-cloud encoder whose *patch* features are aligned to CLIP text space — no test-time multi-view rendering. [Paper](https://huggingface.co/papers/2601.02457) · [Code](https://github.com/souhail-hadgi/PatchAlign3D) · [Weights](https://huggingface.co/patchalign3d/patchalign3d-encoder) · [Project page](https://souhail-hadgi.github.io/patchalign3dsite) """ ) with gr.Row(): with gr.Column(scale=2): shape_file = gr.File( label="3D shape — mesh or point cloud", file_types=[".obj", ".glb", ".gltf", ".stl", ".off", ".ply", ".npz", ".npy", ".txt", ".xyz", ".pts"], type="filepath", ) preview = gr.Plot(label="Input") preset = gr.Dropdown( label="Part-vocabulary preset (fills the box below)", choices=list(PRESETS.keys()), value="— custom —", ) labels_text = gr.Textbox( label="Part queries (comma-separated)", value="back, seat, leg, arm", placeholder="back, seat, leg, arm", lines=2, ) run = gr.Button("Segment", variant="primary") with gr.Column(scale=3): plot = gr.Plot(label="Segmentation") summary = gr.Markdown() with gr.Row(): shares = gr.Label(label="Share of points per part", num_top_classes=MAX_LABELS) ply_out = gr.File(label="Colour-coded point cloud (.ply)") with gr.Accordion("Advanced settings", open=False): with gr.Row(): num_points = gr.Slider(512, 8192, value=pa.DEFAULT_NPOINTS, step=512, label="Points sampled") num_group = gr.Slider(32, 512, value=pa.DEFAULT_NUM_GROUP, step=32, label="Patches (FPS centres)") group_size = gr.Slider(8, 64, value=pa.DEFAULT_GROUP_SIZE, step=8, label="Points per patch") with gr.Row(): text_setting = gr.Radio( ["part_only", "part_plus_cat", "ensemble"], value="part_only", label="Prompt ensemble", info="`part_plus_cat` / `ensemble` also use the object category", ) category = gr.Textbox(label="Object category", value="", placeholder="chair") with gr.Row(): assign = gr.Radio(["nearest", "membership"], value="nearest", label="Patch → point assignment") tau = gr.Slider(0.01, 1.0, value=pa.DEFAULT_TAU, step=0.01, label="CLIP temperature τ") seed = gr.Number(label="Sampling seed", value=0, precision=0) inputs = [shape_file, labels_text, num_points, num_group, group_size, text_setting, category, assign, tau, seed] outputs = [plot, shares, ply_out, summary] gr.Examples( examples=EXAMPLES, inputs=[shape_file, labels_text], outputs=outputs, fn=segment, cache_examples=True, cache_mode="lazy", label="Examples · ShapeNetPart test shapes and the Stanford Bunny mesh", ) gr.Markdown( """ ### How it works Points are centred and scaled to the unit sphere and the Y/Z axes are swapped to match the training convention (exactly as in the official `infer.py`). Furthest-point sampling picks patch centres, a k-NN neighbourhood around each becomes a patch token, and a 12-layer point transformer produces one feature per patch. A learned linear head projects those into the CLIP `ViT-bigG-14 (laion2b_s39b_b160k)` text space, where they are matched against the prompt ensemble `{"", "a ", " part"}`. Each point takes the label of its nearest patch centre. Every query is *forced* to win somewhere, so asking for a part the shape does not have will still colour something — that is expected for open-vocabulary matching. Shapes close to the ShapeNetPart categories work best; the Bunny is there to show that arbitrary meshes go through the same path. """ ) shape_file.change(preview_shape, inputs=[shape_file, num_points, seed], outputs=preview, api_name="preview") preset.change(apply_preset, inputs=[preset, labels_text, category], outputs=[labels_text, category], api_name=False) run.click(segment, inputs=inputs, outputs=outputs, api_name="segment") labels_text.submit(segment, inputs=inputs, outputs=outputs, api_name=False) if __name__ == "__main__": demo.launch(theme=gr.themes.Citrus(), css=CSS, mcp_server=True)