Spaces:
Running on Zero
Running on Zero
Download app.py from hugging-apps/flexpart: direct link, hf CLI and curl.
- Browser
- Download file 19.5 kB
-
https://huggingface.co/spaces/hugging-apps/flexpart/resolve/main/app.py
- Command line
-
hf download hf://spaces/hugging-apps/flexpart/app.py
-
curl -L -o app.py https://huggingface.co/spaces/hugging-apps/flexpart/resolve/main/app.py
19.5 kB
| """ | |
| FlexPart β A Unified Framework for Image-to-3D Part Generation via Variable Granularity. | |
| Interactive demo: upload an object image (the background is removed automatically), | |
| click on the object to mark each part you want, and the model generates a 3D mesh | |
| for every marked part, composed into a single colored GLB. | |
| Paper: https://huggingface.co/papers/2610.31671 | |
| Code: https://github.com/Gucci233/FlexPart | |
| Weights: https://huggingface.co/gucci233/FlexPart | |
| """ | |
| import spaces # MUST be the first import β before torch / diffusers / transformers | |
| import json | |
| import os | |
| import sys | |
| import tempfile | |
| import time | |
| from typing import List, Tuple, Union | |
| import gradio as gr | |
| import numpy as np | |
| import torch | |
| import torch.nn.functional as F | |
| import trimesh | |
| from accelerate.utils import set_seed | |
| from huggingface_hub import hf_hub_download | |
| from PIL import Image, ImageDraw | |
| from torchvision import transforms | |
| from transformers import BitImageProcessor, Dinov2Model | |
| # This Space runs from its own repo root, so `src.*` imports resolve directly. | |
| sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) | |
| from src.models.autoencoders import TripoSGVAEModel | |
| from src.models.briarmbg import BriaRMBG | |
| from src.models.transformers.flexpart_transformer import FlexPartDiTModel | |
| from src.pipelines.pipeline_flexpart import FlexPartPipeline | |
| from src.schedulers import RectifiedFlowScheduler | |
| DEVICE = "cuda" | |
| DTYPE = torch.float16 | |
| FLEXPART_REPO = "gucci233/FlexPart" | |
| TRIPOSG_REPO = "VAST-AI/TripoSG" | |
| RMBG_REPO = "briaai/RMBG-1.4" | |
| # --------------------------------------------------------------------------- # | |
| # Model loading (module scope, eager β ZeroGPU packs weights to disk and # | |
| # streams them into VRAM on the first @spaces.GPU call). # | |
| # --------------------------------------------------------------------------- # | |
| def _load_pipeline() -> FlexPartPipeline: | |
| """Assemble the FlexPart pipeline from its Hub components.""" | |
| # 1. Transformer: FlexPart repo ships the bare safetensors; its config comes | |
| # from the repo's configs/flexpart_inference.json, so build a local | |
| # from_pretrained directory that pairs the two. | |
| ckpt_dir = hf_hub_download(FLEXPART_REPO, "diffusion_pytorch_model.safetensors") | |
| transformer_dir = os.path.join(os.path.dirname(ckpt_dir), "flexpart_transformer") | |
| os.makedirs(transformer_dir, exist_ok=True) | |
| config_src = os.path.join(os.path.dirname(os.path.abspath(__file__)), "configs", "flexpart_inference.json") | |
| with open(config_src) as f: | |
| import json as _json | |
| cfg = _json.load(f) | |
| with open(os.path.join(transformer_dir, "config.json"), "w") as f: | |
| _json.dump(cfg, f) | |
| ckpt_dst = os.path.join(transformer_dir, "diffusion_pytorch_model.safetensors") | |
| if not os.path.exists(ckpt_dst): | |
| try: | |
| os.symlink(ckpt_dir, ckpt_dst) | |
| except OSError: | |
| import shutil | |
| shutil.copy2(ckpt_dir, ckpt_dst) | |
| transformer = FlexPartDiTModel.from_pretrained(transformer_dir, torch_dtype=DTYPE) | |
| # 2. VAE / image encoder / feature extractor / scheduler come from TripoSG. | |
| vae = TripoSGVAEModel.from_pretrained(TRIPOSG_REPO, subfolder="vae", torch_dtype=DTYPE) | |
| image_encoder = Dinov2Model.from_pretrained(TRIPOSG_REPO, subfolder="image_encoder_dinov2", torch_dtype=DTYPE) | |
| feature_extractor = BitImageProcessor.from_pretrained(TRIPOSG_REPO, subfolder="feature_extractor_dinov2") | |
| scheduler = RectifiedFlowScheduler.from_pretrained(TRIPOSG_REPO, subfolder="scheduler") | |
| # 3. Background removal. | |
| rmbg = BriaRMBG.from_pretrained(RMBG_REPO) | |
| rmbg.to(DEVICE).eval() | |
| global _RMBG_NET | |
| _RMBG_NET = rmbg | |
| pipe = FlexPartPipeline( | |
| vae=vae, | |
| transformer=transformer, | |
| scheduler=scheduler, | |
| image_encoder_dinov2=image_encoder, | |
| feature_extractor_dinov2=feature_extractor, | |
| ) | |
| return pipe.to(DEVICE, DTYPE) | |
| pipe = _load_pipeline() | |
| # Pre-annotated example point sets (pixel coordinates in each 2048x2048 source | |
| # image). 1: robot dog (8 parts), 2: creature (6 parts), 3: authors' own | |
| # example_points.npy (7 parts, gray electronic device). | |
| EXAMPLE_POINTS = { | |
| "1": [[819, 472], [571, 748], [737, 404], [901, 404], [942, 1065], [707, 1588], [1259, 1536], [1414, 870]], | |
| "2": [[666, 512], [594, 389], [973, 942], [799, 1536], [1280, 1485], [1476, 727]], | |
| "3": [[1024.0, 1000.0], [430.0, 1260.0], [1610.0, 1250.0], [825.0, 1550.0], [1220.0, 1550.0], [815.0, 530.0], [1285.0, 530.0]], | |
| } | |
| RGB_PALETTE = [ | |
| (82, 170, 220), (215, 91, 78), (45, 136, 117), (247, 172, 83), (124, 121, 121), | |
| (127, 171, 209), (243, 152, 101), (145, 204, 192), (150, 59, 121), (181, 206, 78), | |
| (189, 119, 149), (199, 193, 222), (200, 151, 54), (236, 110, 102), (238, 182, 212), | |
| ] | |
| # --------------------------------------------------------------------------- # | |
| # Mesh helpers (ported from the reference app / data_utils). # | |
| # --------------------------------------------------------------------------- # | |
| def filter_mesh(mesh: trimesh.Trimesh) -> trimesh.Trimesh: | |
| """Keep the largest connected submeshes (>20% of the max face count).""" | |
| submeshes = mesh.split(only_watertight=False) | |
| if len(submeshes) == 1: | |
| return submeshes[0] | |
| face_counts = [len(m.faces) for m in submeshes] | |
| max_faces = max(face_counts) | |
| filtered = [m for m in submeshes if len(m.faces) > max_faces * 0.2] | |
| if len(filtered) == 1: | |
| return filtered[0] | |
| merged = trimesh.util.concatenate(filtered) | |
| merged.merge_vertices() | |
| _ = merged.vertex_normals | |
| return merged | |
| def normalize_mesh(mesh: Union[trimesh.Trimesh, trimesh.Scene], scale: float = 2.0): | |
| """Center the composition on its bounding-box centroid and fit it to `scale`.""" | |
| bbox = mesh.bounding_box | |
| translation = -bbox.centroid | |
| scale = scale / bbox.primitive.extents.max() | |
| mesh.apply_translation(translation) | |
| mesh.apply_scale(scale) | |
| return mesh | |
| def get_colored_mesh_composition(meshes: List[trimesh.Trimesh]) -> trimesh.Scene: | |
| """Compose per-part meshes into one Scene with a fixed part-color palette.""" | |
| colored_scene = trimesh.Scene() | |
| for idx, mesh in enumerate(meshes): | |
| color = np.array(RGB_PALETTE[idx % len(RGB_PALETTE)]) | |
| mesh.visual = trimesh.visual.ColorVisuals(mesh=mesh, vertex_colors=color) | |
| colored_scene.add_geometry(mesh) | |
| return colored_scene | |
| # --------------------------------------------------------------------------- # | |
| # Background removal (reference pipeline, 1:1). # | |
| # --------------------------------------------------------------------------- # | |
| def remove_background(pil_image: Image.Image) -> Image.Image: | |
| """Run BriaRMBG-1.4 and return the image with a soft alpha channel.""" | |
| pil_image = pil_image.convert("RGB") | |
| w, h = pil_image.size | |
| im_np = np.array(pil_image) | |
| im_tensor = torch.tensor(im_np, dtype=torch.float32).permute(2, 0, 1).unsqueeze(0).to(DEVICE) | |
| im_tensor = torch.div(im_tensor, 255.0) | |
| im_tensor = transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5))(im_tensor) | |
| im_tensor = F.interpolate(im_tensor, size=(1024, 1024), mode="bilinear", align_corners=False) | |
| with torch.no_grad(): | |
| result = _RMBG_NET(im_tensor) | |
| mask_tensor = result | |
| while isinstance(mask_tensor, (list, tuple)): | |
| mask_tensor = mask_tensor[0] | |
| if mask_tensor.dim() == 2: | |
| mask_tensor = mask_tensor.unsqueeze(0).unsqueeze(0) | |
| elif mask_tensor.dim() == 3: | |
| mask_tensor = mask_tensor.unsqueeze(0) | |
| mask_tensor = F.interpolate(mask_tensor, size=(h, w), mode="bilinear", align_corners=False) | |
| ma = mask_tensor.squeeze().clamp(0, 1).cpu().numpy() | |
| pil_image.putalpha(Image.fromarray((ma * 255).astype(np.uint8))) | |
| return pil_image | |
| # --------------------------------------------------------------------------- # | |
| # Annotation helpers. # | |
| # --------------------------------------------------------------------------- # | |
| def draw_annotations(image: np.ndarray, points: List[List[float]]) -> np.ndarray: | |
| """Draw numbered markers on a copy of the image so users see their clicks.""" | |
| pil = Image.fromarray(image).convert("RGB") | |
| draw = ImageDraw.Draw(pil) | |
| scale = max(pil.size) / 1024.0 | |
| radius = max(int(9 * scale), 6) | |
| for i, (x, y) in enumerate(points): | |
| x, y = int(round(x)), int(round(y)) | |
| draw.ellipse([x - radius, y - radius, x + radius, y + radius], fill=(255, 60, 60), outline=(255, 255, 255), width=max(radius // 3, 2)) | |
| draw.text((x + radius + 2, y - radius), str(i + 1), fill=(255, 255, 255)) | |
| return np.array(pil) | |
| def parse_points_json(points_json: str, width: int, height: int) -> List[Tuple[float, float]]: | |
| """Parse the JSON list of [x, y] pixels into normalized (x/w, y/h) points.""" | |
| if not points_json: | |
| return [] | |
| try: | |
| raw = json.loads(points_json) | |
| except json.JSONDecodeError: | |
| return [] | |
| out = [] | |
| for p in raw: | |
| if len(p) < 2: | |
| continue | |
| x, y = float(p[0]), float(p[1]) | |
| if 0 <= x < width and 0 <= y < height: | |
| out.append((x / width, y / height)) | |
| return out | |
| # --------------------------------------------------------------------------- # | |
| # Event handlers. # | |
| # --------------------------------------------------------------------------- # | |
| def on_image_change(image: np.ndarray): | |
| """Reset annotations whenever a new image is loaded.""" | |
| if image is None: | |
| return "[]", None | |
| return "[]", draw_annotations(image, []) | |
| def on_image_select(image: np.ndarray, points_json: str, evt: gr.SelectData): | |
| """Append the clicked point (original-image pixel coords) to the annotation list.""" | |
| if image is None: | |
| raise gr.Error("Upload an image first.") | |
| x, y = evt.index | |
| try: | |
| points = json.loads(points_json) if points_json else [] | |
| except json.JSONDecodeError: | |
| points = [] | |
| points.append([float(x), float(y)]) | |
| return json.dumps(points), draw_annotations(image, points) | |
| def on_clear(points_json: str, image: np.ndarray): | |
| """Clear all annotations.""" | |
| if image is None: | |
| return "[]", None | |
| return "[]", draw_annotations(image, []) | |
| def on_undo(points_json: str, image: np.ndarray): | |
| """Remove the most recent annotation.""" | |
| if image is None: | |
| raise gr.Error("Upload an image first.") | |
| try: | |
| points = json.loads(points_json) if points_json else [] | |
| except json.JSONDecodeError: | |
| points = [] | |
| points = points[:-1] if points else [] | |
| return json.dumps(points), draw_annotations(image, points) | |
| def _estimate_duration(image, points_json, seed=1000, guidance_scale=7.0, steps=50, *args, **kwargs): | |
| """Scale reserved GPU time with the number of parts and diffusion steps. | |
| Measured baseline: 7 parts x 50 steps took ~84 s end-to-end on ZeroGPU, | |
| so reserve ~1.8x that and scale linearly with parts/steps, capped to stay | |
| within visitor tier limits. | |
| """ | |
| try: | |
| num_parts = len(json.loads(points_json)) if points_json else 0 | |
| except (json.JSONDecodeError, TypeError): | |
| num_parts = 0 | |
| try: | |
| steps = int(steps) | |
| except (TypeError, ValueError): | |
| steps = 50 | |
| return min(590, 30 + int(num_parts * 14 + steps * 0.5)) | |
| def generate(image: np.ndarray, points_json: str, seed: float = 1000, guidance_scale: float = 7.0, steps: int = 50, progress=gr.Progress(track_tqdm=True)) -> Tuple[str, List[str], int]: | |
| """Generate a 3D mesh per annotated part. | |
| Args: | |
| image: the input object image (background already removed). | |
| points_json: JSON list of [x, y] click positions, one per part. | |
| seed: random seed for reproducibility. | |
| guidance_scale: classifier-free guidance strength. | |
| steps: number of diffusion denoising steps. | |
| Returns: | |
| (composed_glb_path, per_part_glb_paths, num_parts) | |
| """ | |
| t0 = time.perf_counter() | |
| if image is None: | |
| raise gr.Error("Please upload an image first!") | |
| image_input_np = image | |
| if image_input_np.ndim == 3 and image_input_np.shape[2] == 3: | |
| # Background removal was skipped β run it now so the DINOv2 encoder | |
| # sees a white-backed, alpha-trimmed subject, as the reference does. | |
| image_input_np = np.array(remove_background(Image.fromarray(image_input_np))) | |
| h, w = image_input_np.shape[:2] | |
| points_list = parse_points_json(points_json, w, h) | |
| num_parts = len(points_list) | |
| if num_parts == 0: | |
| raise gr.Error("No annotations detected! Please click on the image to add points (one per part).") | |
| if num_parts > pipe.transformer.config.max_num_parts: | |
| raise gr.Error(f"The number of annotated parts ({num_parts}) exceeds the model capacity ({pipe.transformer.config.max_num_parts}).") | |
| # Point-only prompts (the reference path for this demo): each point gets a | |
| # full-frame placeholder box flagged invalid, so only the point is active. | |
| processed_boxes = [(0.0, 0.0, 1.0, 1.0)] * num_parts | |
| valid_boxes = [False] * num_parts | |
| prompts = { | |
| "masks_prompt": None, | |
| "boxes_prompt": processed_boxes, | |
| "points_prompt": points_list, | |
| "valid_masks": [False] * num_parts, | |
| "valid_boxes": valid_boxes, | |
| "valid_points": [True] * num_parts, | |
| } | |
| pil_image = Image.fromarray(image_input_np) | |
| if pil_image.mode == "RGBA": | |
| white_bg = Image.new("RGB", pil_image.size, (255, 255, 255)) | |
| white_bg.paste(pil_image, mask=pil_image.split()[3]) | |
| final_image = white_bg | |
| else: | |
| final_image = pil_image.convert("RGB") | |
| set_seed(int(seed)) | |
| outputs = pipe( | |
| image=[final_image] * num_parts, | |
| attention_kwargs={"num_parts": num_parts}, | |
| num_tokens=1024, | |
| generator=torch.Generator(device=DEVICE).manual_seed(int(seed)), | |
| num_inference_steps=int(steps), | |
| guidance_scale=guidance_scale, | |
| use_flash_decoder=False, | |
| **prompts, | |
| ).meshes | |
| if any(m is None or len(m.faces) == 0 for m in outputs): | |
| raise gr.Error("Mesh extraction returned an empty part. Try another seed or prompt.") | |
| clean_outputs = [filter_mesh(m) for m in outputs] | |
| merged_mesh = get_colored_mesh_composition(clean_outputs) | |
| merged_mesh = normalize_mesh(merged_mesh) | |
| composed_path = os.path.join(tempfile.mkdtemp(), "flexpart_composed.glb") | |
| merged_mesh.export(composed_path) | |
| part_paths = [] | |
| for i, mesh in enumerate(clean_outputs): | |
| m = normalize_mesh(mesh) | |
| p = os.path.join(os.path.dirname(composed_path), f"flexpart_part_{i}.glb") | |
| m.export(p) | |
| part_paths.append(p) | |
| print(f"[FlexPart] generated {num_parts} parts in {time.perf_counter() - t0:.1f}s") | |
| return composed_path, part_paths, num_parts | |
| # --------------------------------------------------------------------------- # | |
| # UI # | |
| # --------------------------------------------------------------------------- # | |
| css = """ | |
| .gradio-container {max-width: 1400px !important;} | |
| .annot-preview img {image-rendering: auto;} | |
| """ | |
| with gr.Blocks(theme=gr.themes.Citrus(), css=css, title="FlexPart β Image-to-3D Part Generation") as demo: | |
| gr.Markdown( | |
| """ | |
| # π§© FlexPart β Image-to-3D Part Generation | |
| **A Unified Framework for Image-to-3D Part Generation via Variable Granularity** | |
| Upload an object image, click once on each part you want (each click = one part), and hit **Generate**. | |
| The model produces one 3D mesh per clicked part, composed into a single colored GLB. | |
| """ | |
| ) | |
| with gr.Row(): | |
| with gr.Column(scale=1): | |
| input_image = gr.Image( | |
| label="Input image (click a part to mark it)", | |
| type="numpy", | |
| interactive=True, | |
| height=420, | |
| sources=["upload", "clipboard"], | |
| ) | |
| annotated_preview = gr.Image( | |
| label="Your annotations (preview)", | |
| type="numpy", | |
| interactive=False, | |
| height=420, | |
| elem_classes=["annot-preview"], | |
| ) | |
| points_state = gr.Textbox( | |
| value="[]", | |
| label="Part points (JSON, one [x, y] per click β editable)", | |
| lines=2, | |
| interactive=True, | |
| ) | |
| with gr.Row(): | |
| undo_btn = gr.Button("β© Undo last") | |
| clear_btn = gr.Button("π Clear all") | |
| with gr.Accordion("Advanced settings", open=False): | |
| seed = gr.Number(value=1000, label="Seed", precision=0) | |
| guidance = gr.Slider(1.0, 20.0, value=7.0, step=0.1, label="Guidance scale") | |
| steps = gr.Slider(10, 100, value=50, step=1, label="Diffusion steps") | |
| gen_btn = gr.Button("π Generate 3D Mesh", variant="primary") | |
| with gr.Column(scale=1): | |
| part_count = gr.Number(value=0, label="Parts generated", precision=0, interactive=False) | |
| composed_3d = gr.Model3D( | |
| label="Composed 3D parts (colored per part)", | |
| clear_color=[1.0, 1.0, 1.0, 1.0], | |
| camera_position=(180, 90, 4), | |
| ) | |
| part_files = gr.Files(label="Individual part GLBs (download)") | |
| gr.Markdown( | |
| """ | |
| ### How it works | |
| 1. **Upload** an object image (or pick an example below). The background is removed with BriaRMBG-1.4. | |
| 2. **Click** on the image β every click marks one part you want the model to generate (up to 32). | |
| 3. **Generate** β FlexPart runs part-level 3D diffusion (TripoSG VAE + DINOv2 image conditioning) and | |
| extracts one mesh per part via hierarchical octree marching cubes, then composes them with per-part colors. | |
| Model: [gucci233/FlexPart](https://huggingface.co/gucci233/FlexPart) Β· | |
| Code: [Gucci233/FlexPart](https://github.com/Gucci233/FlexPart) Β· | |
| Paper: [2610.31671](https://huggingface.co/papers/2610.31671) | |
| """ | |
| ) | |
| examples = gr.Examples( | |
| examples=[ | |
| ["assets/1.png", json.dumps(EXAMPLE_POINTS["1"])], | |
| ["assets/2.png", json.dumps(EXAMPLE_POINTS["2"])], | |
| ["assets/3.png", json.dumps(EXAMPLE_POINTS["3"])], | |
| ], | |
| inputs=[input_image, points_state], | |
| fn=generate, | |
| outputs=[composed_3d, part_files, part_count], | |
| cache_examples=True, | |
| cache_mode="lazy", | |
| label="Examples (pre-annotated β click to run)", | |
| ) | |
| # Wiring | |
| input_image.select(on_image_select, inputs=[input_image, points_state], outputs=[points_state, annotated_preview]) | |
| input_image.change(on_image_change, inputs=[input_image], outputs=[points_state, annotated_preview]) | |
| undo_btn.click(on_undo, inputs=[points_state, input_image], outputs=[points_state, annotated_preview]) | |
| clear_btn.click(on_clear, inputs=[points_state, input_image], outputs=[points_state, annotated_preview]) | |
| gen_btn.click( | |
| generate, | |
| inputs=[input_image, points_state, seed, guidance, steps], | |
| outputs=[composed_3d, part_files, part_count], | |
| ) | |
| demo.queue().launch(mcp_server=True) | |