Reverb commited on
Commit
0062a30
·
1 Parent(s): 8138769

Implement Hunyuan3D-2.1 generation with background removal

Browse files

- generate_hunyuan() is now fully implemented using the vendored
hy3dshape package (tencent/Hunyuan3D-2.1, diffusers-based, no
custom CUDA wheels, works on torch 2.8.0 + ZeroGPU H200)
- @spaces.GPU(duration=240) wraps the local inference call
- Pipeline is loaded lazily on first GPU request and cached globally
- Background removal via rembg (IS-Net) with fallback to hy3dshape's
BackgroundRemover; toggled by do_rembg parameter
- Multi-view support: >1 image routed as {front/left/back/right} dict
- Working mesh is decimated via PyMeshLab to preset face target; full
mesh saved as high_poly.glb for Stage 2 baking
- Added gen_rembg checkbox to the Generate tab (default: on)
- Hunyuan3D-2.1 is now the default model (moved to top of radio)
- Preset save/load includes rembg flag (n=27 components)
- requirements.txt: added diffusers>=0.30.0, accelerate, einops, rembg

Files changed (3) hide show
  1. app.py +20 -12
  2. requirements.txt +12 -6
  3. src/stages/stage1_generate.py +164 -9
app.py CHANGED
@@ -81,11 +81,11 @@ def _stub(stage: str) -> str:
81
  )
82
 
83
 
84
- def handle_generate(images, model, quality, seed, _steps, _octree, tex_size, _symmetry):
85
  """Dispatch to the correct generation backend based on model selection."""
86
  if "TRELLIS" in model:
87
  return generate_trellis(images, quality, int(seed), int(tex_size))
88
- return generate_hunyuan(images, quality, int(seed), int(tex_size))
89
 
90
 
91
  def run_post_process(
@@ -284,7 +284,7 @@ def ui_refresh_presets() -> gr.Dropdown:
284
 
285
  def ui_save_preset(
286
  name: str,
287
- gen_model, gen_quality, gen_seed, gen_tex_size,
288
  pp_repair, pp_cleanup, pp_decimate, pp_target_faces,
289
  pp_symmetry, pp_unwrap, pp_normal_bake, pp_normal_format,
290
  pp_albedo, pp_material, pp_ao, pp_ao_quality,
@@ -296,7 +296,7 @@ def ui_save_preset(
296
  return "❌ Preset name cannot be empty.", ui_refresh_presets()
297
  config = {
298
  "name": name, "version": 1, "created_at": time.time(),
299
- "stage1": {"model": gen_model, "quality": gen_quality, "seed": int(gen_seed), "tex_size": int(gen_tex_size)},
300
  "stage2": {
301
  "repair": pp_repair, "cleanup": pp_cleanup, "decimate": pp_decimate,
302
  "target_faces": int(pp_target_faces), "symmetry": pp_symmetry,
@@ -315,7 +315,7 @@ def ui_save_preset(
315
  def ui_load_preset(name: str):
316
  """Apply a saved preset to all UI components."""
317
  _no_op = gr.update()
318
- n = 26 # number of component outputs after pr_status
319
  if not name:
320
  return ("❌ Select a preset first.",) + (_no_op,) * n
321
  try:
@@ -331,10 +331,11 @@ def ui_load_preset(name: str):
331
  return (
332
  f"✅ Loaded preset: `{name}`",
333
  # Stage 1
334
- gr.update(value=s1.get("model", "TRELLIS.2 (Hard Surface)")),
335
  gr.update(value=s1.get("quality", "Balanced (~60s)")),
336
  gr.update(value=s1.get("seed", 42)),
337
  gr.update(value=s1.get("tex_size", 2048)),
 
338
  # Stage 2
339
  gr.update(value=s2.get("repair", True)),
340
  gr.update(value=s2.get("cleanup", True)),
@@ -424,10 +425,10 @@ def build_ui() -> gr.Blocks:
424
  )
425
  gen_model = gr.Radio(
426
  choices=[
 
427
  "TRELLIS.2 (Hard Surface)",
428
- "Hunyuan3D-2 (Organic / Characters)",
429
  ],
430
- value="TRELLIS.2 (Hard Surface)",
431
  label="Generation model",
432
  )
433
  gen_quality = gr.Radio(
@@ -435,6 +436,12 @@ def build_ui() -> gr.Blocks:
435
  value="Balanced (~60s)",
436
  label="Quality preset",
437
  )
 
 
 
 
 
 
438
  with gr.Accordion("Advanced", open=False):
439
  gen_seed = gr.Number(value=42, label="Seed", precision=0)
440
  gen_steps = gr.Slider(
@@ -442,7 +449,7 @@ def build_ui() -> gr.Blocks:
442
  label="Inference steps",
443
  )
444
  gen_octree = gr.Dropdown(
445
- choices=[128, 192, 256], value=192,
446
  label="Octree resolution",
447
  )
448
  gen_tex_size = gr.Dropdown(
@@ -460,7 +467,8 @@ def build_ui() -> gr.Blocks:
460
  gen_btn.click(
461
  fn=handle_generate,
462
  inputs=[gen_images, gen_model, gen_quality, gen_seed,
463
- gen_steps, gen_octree, gen_tex_size, gen_symmetry],
 
464
  outputs=gen_status,
465
  )
466
 
@@ -627,7 +635,7 @@ def build_ui() -> gr.Blocks:
627
  inputs=pr_list,
628
  outputs=[
629
  pr_status,
630
- gen_model, gen_quality, gen_seed, gen_tex_size,
631
  pp_repair, pp_cleanup, pp_decimate, pp_target_faces,
632
  pp_symmetry, pp_unwrap, pp_normal_bake, pp_normal_format,
633
  pp_albedo_bake, pp_material_bake, pp_ao, pp_ao_quality,
@@ -640,7 +648,7 @@ def build_ui() -> gr.Blocks:
640
  fn=ui_save_preset,
641
  inputs=[
642
  pr_name,
643
- gen_model, gen_quality, gen_seed, gen_tex_size,
644
  pp_repair, pp_cleanup, pp_decimate, pp_target_faces,
645
  pp_symmetry, pp_unwrap, pp_normal_bake, pp_normal_format,
646
  pp_albedo_bake, pp_material_bake, pp_ao, pp_ao_quality,
 
81
  )
82
 
83
 
84
+ def handle_generate(images, model, quality, seed, _steps, _octree, tex_size, _symmetry, do_rembg):
85
  """Dispatch to the correct generation backend based on model selection."""
86
  if "TRELLIS" in model:
87
  return generate_trellis(images, quality, int(seed), int(tex_size))
88
+ return generate_hunyuan(images, quality, int(seed), int(tex_size), do_rembg=bool(do_rembg))
89
 
90
 
91
  def run_post_process(
 
284
 
285
  def ui_save_preset(
286
  name: str,
287
+ gen_model, gen_quality, gen_seed, gen_tex_size, gen_rembg,
288
  pp_repair, pp_cleanup, pp_decimate, pp_target_faces,
289
  pp_symmetry, pp_unwrap, pp_normal_bake, pp_normal_format,
290
  pp_albedo, pp_material, pp_ao, pp_ao_quality,
 
296
  return "❌ Preset name cannot be empty.", ui_refresh_presets()
297
  config = {
298
  "name": name, "version": 1, "created_at": time.time(),
299
+ "stage1": {"model": gen_model, "quality": gen_quality, "seed": int(gen_seed), "tex_size": int(gen_tex_size), "rembg": bool(gen_rembg)},
300
  "stage2": {
301
  "repair": pp_repair, "cleanup": pp_cleanup, "decimate": pp_decimate,
302
  "target_faces": int(pp_target_faces), "symmetry": pp_symmetry,
 
315
  def ui_load_preset(name: str):
316
  """Apply a saved preset to all UI components."""
317
  _no_op = gr.update()
318
+ n = 27 # number of component outputs after pr_status
319
  if not name:
320
  return ("❌ Select a preset first.",) + (_no_op,) * n
321
  try:
 
331
  return (
332
  f"✅ Loaded preset: `{name}`",
333
  # Stage 1
334
+ gr.update(value=s1.get("model", "Hunyuan3D-2.1 (Organic / Characters)")),
335
  gr.update(value=s1.get("quality", "Balanced (~60s)")),
336
  gr.update(value=s1.get("seed", 42)),
337
  gr.update(value=s1.get("tex_size", 2048)),
338
+ gr.update(value=s1.get("rembg", True)),
339
  # Stage 2
340
  gr.update(value=s2.get("repair", True)),
341
  gr.update(value=s2.get("cleanup", True)),
 
425
  )
426
  gen_model = gr.Radio(
427
  choices=[
428
+ "Hunyuan3D-2.1 (Organic / Characters)",
429
  "TRELLIS.2 (Hard Surface)",
 
430
  ],
431
+ value="Hunyuan3D-2.1 (Organic / Characters)",
432
  label="Generation model",
433
  )
434
  gen_quality = gr.Radio(
 
436
  value="Balanced (~60s)",
437
  label="Quality preset",
438
  )
439
+ gen_rembg = gr.Checkbox(
440
+ value=True,
441
+ label="Remove background automatically",
442
+ info="Uses rembg IS-Net to strip background before generation. "
443
+ "Disable if your image already has a transparent background.",
444
+ )
445
  with gr.Accordion("Advanced", open=False):
446
  gen_seed = gr.Number(value=42, label="Seed", precision=0)
447
  gen_steps = gr.Slider(
 
449
  label="Inference steps",
450
  )
451
  gen_octree = gr.Dropdown(
452
+ choices=[256, 384, 512], value=384,
453
  label="Octree resolution",
454
  )
455
  gen_tex_size = gr.Dropdown(
 
467
  gen_btn.click(
468
  fn=handle_generate,
469
  inputs=[gen_images, gen_model, gen_quality, gen_seed,
470
+ gen_steps, gen_octree, gen_tex_size, gen_symmetry,
471
+ gen_rembg],
472
  outputs=gen_status,
473
  )
474
 
 
635
  inputs=pr_list,
636
  outputs=[
637
  pr_status,
638
+ gen_model, gen_quality, gen_seed, gen_tex_size, gen_rembg,
639
  pp_repair, pp_cleanup, pp_decimate, pp_target_faces,
640
  pp_symmetry, pp_unwrap, pp_normal_bake, pp_normal_format,
641
  pp_albedo_bake, pp_material_bake, pp_ao, pp_ao_quality,
 
648
  fn=ui_save_preset,
649
  inputs=[
650
  pr_name,
651
+ gen_model, gen_quality, gen_seed, gen_tex_size, gen_rembg,
652
  pp_repair, pp_cleanup, pp_decimate, pp_target_faces,
653
  pp_symmetry, pp_unwrap, pp_normal_bake, pp_normal_format,
654
  pp_albedo_bake, pp_material_bake, pp_ao, pp_ao_quality,
requirements.txt CHANGED
@@ -7,12 +7,12 @@ spaces # HF ZeroGPU decorator
7
  numpy
8
  pillow
9
 
10
- # ===== Milestone 2: Stage 1 Generation (via gradio_client) =====
11
- # NOTE: TRELLIS.2 generation runs on microsoft/TRELLIS.2 via gradio_client.
12
- # The custom wheels (o_voxel, cumesh, flex_gemm, nvdiffrast) are ABI-incompatible
13
- # with torch 2.8.0+ they were compiled for torch 2.6.0 which ZeroGPU no longer
14
- # supports. Remove them until Jeff Xiang releases torch 2.8+ compatible wheels.
15
- gradio_client # call TRELLIS.2 / Hunyuan3D-2.1 Spaces
16
  torch==2.8.0
17
  torchvision==0.23.0
18
  imageio==2.37.2
@@ -26,6 +26,12 @@ zstandard==0.25.0
26
  kornia==0.8.2
27
  timm==1.0.22
28
  git+https://github.com/EasternJournalist/utils3d.git@9a4eb15e4021b67b12c460c7057d642626897ec8
 
 
 
 
 
 
29
 
30
  # ===== Milestone 3: Stage 2A-2C Mesh Cleanup =====
31
  trimesh[easy]
 
7
  numpy
8
  pillow
9
 
10
+ # ===== Milestone 2: Stage 1 Generation =====
11
+ # TRELLIS.2 generation: via gradio_client (remote Space) — ABI-incompatible custom
12
+ # wheels (o_voxel, cumesh, flex_gemm) cannot run locally on torch 2.8.0+.
13
+ # Hunyuan3D-2.1 generation: local inference via vendored hy3dshape package
14
+ # diffusers-based, no custom CUDA extensions, works on torch 2.8.0 + ZeroGPU.
15
+ gradio_client # call TRELLIS.2 Space
16
  torch==2.8.0
17
  torchvision==0.23.0
18
  imageio==2.37.2
 
26
  kornia==0.8.2
27
  timm==1.0.22
28
  git+https://github.com/EasternJournalist/utils3d.git@9a4eb15e4021b67b12c460c7057d642626897ec8
29
+ # Hunyuan3D-2.1 dependencies
30
+ diffusers>=0.30.0
31
+ accelerate
32
+ einops
33
+ # Background removal (used before shape generation)
34
+ rembg
35
 
36
  # ===== Milestone 3: Stage 2A-2C Mesh Cleanup =====
37
  trimesh[easy]
src/stages/stage1_generate.py CHANGED
@@ -1,25 +1,34 @@
1
  """
2
  Stage 1 — Image to 3D generation.
3
 
4
- Generation is offloaded via gradio_client to the official TRELLIS.2 / Hunyuan3D
5
- HuggingFace Spaces. All the TRELLIS.2 custom wheels (o_voxel, cumesh, flex_gemm)
6
- are compiled against torch 2.6.0 and cannot be loaded under torch 2.8.0+ due to
7
- C++ ABI changes. This client approach requires no local GPU for Stage 1; the
8
- remote Space does the heavy lifting, returns two GLB files, and we copy them into
9
- our workspace for Stage 2 processing.
 
 
10
  """
11
  from __future__ import annotations
12
 
13
  import shutil
 
14
  import tempfile
15
  import time
16
  from pathlib import Path
17
 
 
18
  from PIL import Image
19
 
20
  from src import workspace
21
  from src.workspace import CURRENT
22
 
 
 
 
 
 
23
  # ---------------------------------------------------------------------------
24
  # Quality presets → TRELLIS.2 sampler parameters
25
  # ---------------------------------------------------------------------------
@@ -180,16 +189,162 @@ def generate_trellis(
180
 
181
 
182
  # ---------------------------------------------------------------------------
183
- # Hunyuan3D-2.1 (Milestone 2b stub)
184
  # ---------------------------------------------------------------------------
185
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
186
  def generate_hunyuan(
187
  images: list | None,
188
  quality: str,
189
  seed: int,
190
  tex_size: int = 2048,
 
191
  ) -> str:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
192
  return (
193
- "🚧 **Hunyuan3D-2.1** not wired yet.\n\n"
194
- "Will be enabled in Milestone 2b. Use TRELLIS.2 for now."
 
 
 
195
  )
 
1
  """
2
  Stage 1 — Image to 3D generation.
3
 
4
+ TRELLIS.2 generation is offloaded via gradio_client to the official HuggingFace
5
+ Space (microsoft/TRELLIS.2) because the TRELLIS.2 custom wheels (o_voxel, cumesh,
6
+ flex_gemm) are compiled for torch 2.6.0 and are ABI-broken on torch 2.8.0+.
7
+
8
+ Hunyuan3D-2.1 runs locally using the vendored hy3dshape package. The shape
9
+ pipeline is diffusers-based with no custom CUDA extensions — it works on torch
10
+ 2.8.0 + ZeroGPU H200. The model is streamed from tencent/Hunyuan3D-2.1 on first
11
+ run and cached by HuggingFace Hub.
12
  """
13
  from __future__ import annotations
14
 
15
  import shutil
16
+ import sys
17
  import tempfile
18
  import time
19
  from pathlib import Path
20
 
21
+ import spaces
22
  from PIL import Image
23
 
24
  from src import workspace
25
  from src.workspace import CURRENT
26
 
27
+ # Make the vendored hy3dshape package importable.
28
+ _HY3D_PATH = Path(__file__).resolve().parents[2] / "Hunyuan3D-2.1" / "hy3dshape"
29
+ if str(_HY3D_PATH) not in sys.path:
30
+ sys.path.insert(0, str(_HY3D_PATH))
31
+
32
  # ---------------------------------------------------------------------------
33
  # Quality presets → TRELLIS.2 sampler parameters
34
  # ---------------------------------------------------------------------------
 
189
 
190
 
191
  # ---------------------------------------------------------------------------
192
+ # Hunyuan3D-2.1 local inference using vendored hy3dshape
193
  # ---------------------------------------------------------------------------
194
 
195
+ # Quality presets for Hunyuan3D-2.1
196
+ _HY_PRESETS = {
197
+ "Fast (~30s)": {"steps": 20, "octree_resolution": 256, "decimation_target": 50_000},
198
+ "Balanced (~60s)": {"steps": 35, "octree_resolution": 384, "decimation_target": 100_000},
199
+ "Hero (~90s)": {"steps": 50, "octree_resolution": 512, "decimation_target": 200_000},
200
+ }
201
+
202
+ # Pipeline loaded lazily on first GPU call to avoid startup cost.
203
+ _hy_pipeline = None
204
+
205
+
206
+ def _get_hy_pipeline():
207
+ global _hy_pipeline
208
+ if _hy_pipeline is None:
209
+ from hy3dshape.pipelines import Hunyuan3DDiTFlowMatchingPipeline
210
+ _hy_pipeline = Hunyuan3DDiTFlowMatchingPipeline.from_pretrained(
211
+ "tencent/Hunyuan3D-2.1",
212
+ subfolder="hunyuan3d-dit-v2-1",
213
+ use_safetensors=False,
214
+ variant="fp16",
215
+ )
216
+ return _hy_pipeline
217
+
218
+
219
+ def _remove_background(image: Image.Image) -> Image.Image:
220
+ """Strip background using rembg (IS-Net model)."""
221
+ try:
222
+ from rembg import remove, new_session
223
+ session = new_session()
224
+ return remove(image, session=session, bgcolor=[255, 255, 255, 0])
225
+ except Exception as e:
226
+ # Fall back to the hy3dshape wrapper if rembg is unavailable.
227
+ try:
228
+ from hy3dshape.rembg import BackgroundRemover
229
+ remover = BackgroundRemover()
230
+ return remover(image)
231
+ except Exception:
232
+ raise RuntimeError(f"Background removal failed: {e}")
233
+
234
+
235
+ @spaces.GPU(duration=240)
236
  def generate_hunyuan(
237
  images: list | None,
238
  quality: str,
239
  seed: int,
240
  tex_size: int = 2048,
241
+ do_rembg: bool = True,
242
  ) -> str:
243
+ """Generate a 3D mesh with Hunyuan3D-2.1 and save it to the workspace."""
244
+ import torch
245
+
246
+ t0 = time.time()
247
+
248
+ if not images:
249
+ return "❌ Please upload at least one reference image."
250
+
251
+ # ------------------------------------------------------------------
252
+ # Load and optionally remove background from input image(s)
253
+ # ------------------------------------------------------------------
254
+ def _open(f) -> Image.Image:
255
+ path = f.name if hasattr(f, "name") else str(f)
256
+ return Image.open(path).convert("RGBA")
257
+
258
+ pil_images = [_open(f) for f in images]
259
+
260
+ if do_rembg:
261
+ try:
262
+ pil_images = [_remove_background(img) for img in pil_images]
263
+ except Exception as e:
264
+ return f"❌ Background removal failed:\n```\n{e}\n```"
265
+
266
+ preset = _HY_PRESETS.get(quality, _HY_PRESETS["Balanced (~60s)"])
267
+
268
+ # Multi-view: pass as dict if more than one image is provided.
269
+ if len(pil_images) == 1:
270
+ image_input = pil_images[0]
271
+ else:
272
+ view_keys = ["front", "left", "back", "right"]
273
+ image_input = {view_keys[i]: img for i, img in enumerate(pil_images[:4])}
274
+
275
+ # ------------------------------------------------------------------
276
+ # Shape generation
277
+ # ------------------------------------------------------------------
278
+ try:
279
+ pipeline = _get_hy_pipeline()
280
+ generator = torch.Generator().manual_seed(int(seed))
281
+ meshes = pipeline(
282
+ image=image_input,
283
+ num_inference_steps=preset["steps"],
284
+ octree_resolution=preset["octree_resolution"],
285
+ mc_level=0.0,
286
+ output_type="trimesh",
287
+ generator=generator,
288
+ )
289
+ mesh = meshes[0] if isinstance(meshes, list) else meshes
290
+ except Exception as e:
291
+ return (
292
+ f"❌ Hunyuan3D-2.1 generation failed:\n```\n{type(e).__name__}: {e}\n```\n\n"
293
+ f"Tip: make sure the image has a clean subject on a white/transparent background."
294
+ )
295
+
296
+ # ------------------------------------------------------------------
297
+ # Save to workspace
298
+ # ------------------------------------------------------------------
299
+ workspace.reset_current()
300
+ ws = workspace.reset_state()
301
+
302
+ high_poly_path = CURRENT / "high_poly.glb"
303
+ raw_gen_path = CURRENT / "raw_gen.glb"
304
+ mesh.export(str(high_poly_path))
305
+ # Decimate for the working copy
306
+ try:
307
+ import pymeshlab
308
+ import trimesh as _tm
309
+ with tempfile.NamedTemporaryFile(suffix=".ply", delete=False) as tmp:
310
+ ply_in = tmp.name
311
+ mesh.export(ply_in)
312
+ ms = pymeshlab.MeshSet()
313
+ ms.load_new_mesh(ply_in)
314
+ target = max(100, preset["decimation_target"])
315
+ ms.apply_filter(
316
+ "meshing_decimation_quadric_edge_collapse",
317
+ targetfacenum=target,
318
+ preservenormal=True,
319
+ autoclean=True,
320
+ )
321
+ with tempfile.NamedTemporaryFile(suffix=".ply", delete=False) as tmp2:
322
+ ply_out = tmp2.name
323
+ ms.save_current_mesh(ply_out)
324
+ working = _tm.load(ply_out, force="mesh")
325
+ working.export(str(raw_gen_path))
326
+ except Exception:
327
+ # If decimation fails, just copy the full mesh.
328
+ shutil.copy(str(high_poly_path), str(raw_gen_path))
329
+
330
+ face_count = len(mesh.faces)
331
+ vertex_count = len(mesh.vertices)
332
+
333
+ ws.high_poly_glb = high_poly_path
334
+ ws.raw_gen_glb = raw_gen_path
335
+ ws.model_used = "Hunyuan3D-2.1"
336
+ ws.face_count = face_count
337
+ ws.vertex_count = vertex_count
338
+
339
+ elapsed = time.time() - t0
340
+
341
+ from src import quota as _quota
342
+ _quota.record_usage(f"generate_hunyuan_{quality.split()[0].lower()}", elapsed)
343
+
344
  return (
345
+ f" **Generated with Hunyuan3D-2.1** · {quality}\n\n"
346
+ f"- Faces: {face_count:,} · Vertices: {vertex_count:,}\n"
347
+ f"- Time: {elapsed:.1f}s\n\n"
348
+ f"Scroll down to see the result in the 3D viewer. "
349
+ f"High-poly saved for Stage 2 baking."
350
  )