flexpart / app.py
multimodalart's picture
multimodalart HF Staff
Upload app.py with huggingface_hub
4bb5817 verified
Raw History Blame Contribute Delete
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))
@spaces.GPU(duration=_estimate_duration)
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)