akhaliq HF Staff commited on
Commit
0d71da7
·
1 Parent(s): 25145b8

feat: add frontend UI for Pixal3D image-to-3D generation interface

Browse files
Files changed (2) hide show
  1. app.py +154 -410
  2. index.html +855 -0
app.py CHANGED
@@ -1,15 +1,20 @@
1
- """
2
- Pixal3D (TRELLIS.2 Backbone) - Gradio App
3
-
4
- Image-to-3D generation using Proj-mode Cascade inference (512->1024/1536).
5
-
6
- """
7
-
8
- import spaces
9
- import gradio as gr
10
-
11
  import os
12
  import subprocess
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  subprocess.run([
14
  "pip", "install", "--force-reinstall", "--no-deps",
15
  "https://github.com/LDYang694/Storages/releases/download/20260430/utils3d-0.0.2-py3-none-any.whl"
@@ -21,31 +26,26 @@ os.environ["ATTN_BACKEND"] = "flash_attn_3"
21
  os.environ["FLEX_GEMM_AUTOTUNE_CACHE_PATH"] = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'autotune_cache.json')
22
  os.environ["FLEX_GEMM_AUTOTUNER_VERBOSE"] = '1'
23
 
24
- import argparse
25
- import math
26
- import time
27
- from datetime import datetime
28
- import shutil
29
- import cv2
30
- from typing import *
31
- import torch
32
- import numpy as np
33
- from PIL import Image
34
- import base64
35
- import io
36
  from trellis2.modules.sparse import SparseTensor
37
  from trellis2.pipelines import Pixal3DImageTo3DPipeline
38
  from trellis2.renderers import EnvMap
39
  from trellis2.utils import render_utils
40
  import o_voxel
41
 
42
-
43
  # ============================================================================
44
  # Constants & Defaults
45
  # ============================================================================
46
 
47
  MAX_SEED = np.iinfo(np.int32).max
48
  TMP_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'tmp')
 
 
49
  MODES = [
50
  {"name": "Normal", "icon": "assets/app/normal.png", "render_key": "normal"},
51
  {"name": "Clay render", "icon": "assets/app/clay.png", "render_key": "clay"},
@@ -55,8 +55,6 @@ MODES = [
55
  {"name": "HDRI courtyard", "icon": "assets/app/hdri_courtyard.png", "render_key": "shaded_courtyard"},
56
  ]
57
  STEPS = 8
58
- DEFAULT_MODE = 3
59
- DEFAULT_STEP = 0
60
 
61
  # Cascade parameters
62
  CASCADE_LR_RESOLUTION = 512
@@ -68,7 +66,7 @@ WILD_MESH_SCALE = 1.0
68
  WILD_EXTEND_PIXEL = 0
69
  WILD_IMAGE_RESOLUTION = 512
70
 
71
- # Image Cond Model configs (extracted from training configs, hardcoded)
72
  IMAGE_COND_CONFIGS = {
73
  "ss": {
74
  "model_name": "camenduru/dinov3-vitl16-pretrain-lvd1689m",
@@ -98,126 +96,62 @@ IMAGE_COND_CONFIGS = {
98
  },
99
  }
100
 
101
-
102
- # ============================================================================
103
- # CSS & JS
104
- # ============================================================================
105
-
106
- css = """
107
- .stepper-wrapper { padding: 0; }
108
- .stepper-container { padding: 0; align-items: center; }
109
- .step-button { flex-direction: row; }
110
- .step-connector { transform: none; }
111
- .step-number { width: 16px; height: 16px; }
112
- .step-label { position: relative; bottom: 0; }
113
- .wrap.center.full { inset: 0; height: 100%; }
114
- .wrap.center.full.translucent { background: var(--block-background-fill); }
115
- .meta-text-center {
116
- display: block !important; position: absolute !important;
117
- top: unset !important; bottom: 0 !important; right: 0 !important; transform: unset !important;
118
- }
119
- .previewer-container {
120
- position: relative;
121
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
122
- width: 100%; height: 722px; margin: 0 auto; padding: 20px;
123
- display: flex; flex-direction: column; align-items: center; justify-content: center;
124
- }
125
- .previewer-container .tips-icon {
126
- position: absolute; right: 10px; top: 10px; z-index: 10;
127
- border-radius: 10px; color: #fff; background-color: var(--color-accent); padding: 3px 6px; user-select: none;
128
- }
129
- .previewer-container .tips-text {
130
- position: absolute; right: 10px; top: 50px; color: #fff; background-color: var(--color-accent);
131
- border-radius: 10px; padding: 6px; text-align: left; max-width: 300px; z-index: 10;
132
- transition: all 0.3s; opacity: 0%; user-select: none;
133
- }
134
- .previewer-container .tips-text p { font-size: 14px; line-height: 1.2; }
135
- .tips-icon:hover + .tips-text { display: block; opacity: 100%; }
136
- .previewer-container .mode-row {
137
- width: 100%; display: flex; gap: 8px; justify-content: center; margin-bottom: 20px; flex-wrap: wrap;
138
- }
139
- .previewer-container .mode-btn {
140
- width: 24px; height: 24px; border-radius: 50%; cursor: pointer; opacity: 0.5;
141
- transition: all 0.2s; border: 2px solid #ddd; object-fit: cover;
142
- }
143
- .previewer-container .mode-btn:hover { opacity: 0.9; transform: scale(1.1); }
144
- .previewer-container .mode-btn.active { opacity: 1; border-color: var(--color-accent); transform: scale(1.1); }
145
- .previewer-container .display-row {
146
- margin-bottom: 20px; min-height: 400px; width: 100%; flex-grow: 1;
147
- display: flex; justify-content: center; align-items: center;
148
- }
149
- .previewer-container .previewer-main-image {
150
- max-width: 100%; max-height: 100%; flex-grow: 1; object-fit: contain; display: none;
151
- }
152
- .previewer-container .previewer-main-image.visible { display: block; }
153
- .previewer-container .slider-row {
154
- width: 100%; display: flex; flex-direction: column; align-items: center; gap: 10px; padding: 0 10px;
155
- }
156
- .previewer-container input[type=range] { -webkit-appearance: none; width: 100%; max-width: 400px; background: transparent; }
157
- .previewer-container input[type=range]::-webkit-slider-runnable-track {
158
- width: 100%; height: 8px; cursor: pointer; background: #ddd; border-radius: 5px;
159
- }
160
- .previewer-container input[type=range]::-webkit-slider-thumb {
161
- height: 20px; width: 20px; border-radius: 50%; background: var(--color-accent);
162
- cursor: pointer; -webkit-appearance: none; margin-top: -6px;
163
- box-shadow: 0 2px 5px rgba(0,0,0,0.2); transition: transform 0.1s;
164
- }
165
- .previewer-container input[type=range]::-webkit-slider-thumb:hover { transform: scale(1.2); }
166
- .gradio-container .padded:has(.previewer-container) { padding: 0 !important; }
167
- .gradio-container:has(.previewer-container) [data-testid="block-label"] { position: absolute; top: 0; left: 0; }
168
- """
169
-
170
- head = """
171
- <script>
172
- function refreshView(mode, step) {
173
- const allImgs = document.querySelectorAll('.previewer-main-image');
174
- for (let i = 0; i < allImgs.length; i++) {
175
- const img = allImgs[i];
176
- if (img.classList.contains('visible')) {
177
- const id = img.id;
178
- const [_, m, s] = id.split('-');
179
- if (mode === -1) mode = parseInt(m.slice(1));
180
- if (step === -1) step = parseInt(s.slice(1));
181
- break;
182
- }
183
- }
184
- allImgs.forEach(img => img.classList.remove('visible'));
185
- const targetId = 'view-m' + mode + '-s' + step;
186
- const targetImg = document.getElementById(targetId);
187
- if (targetImg) targetImg.classList.add('visible');
188
- const allBtns = document.querySelectorAll('.mode-btn');
189
- allBtns.forEach((btn, idx) => {
190
- if (idx === mode) btn.classList.add('active');
191
- else btn.classList.remove('active');
192
- });
193
- }
194
- function selectMode(mode) { refreshView(mode, -1); }
195
- function onSliderChange(val) { refreshView(-1, parseInt(val)); }
196
- </script>
197
- """
198
-
199
- empty_html = f"""
200
- <div class="previewer-container">
201
- <svg style=" opacity: .5; height: var(--size-5); color: var(--body-text-color);"
202
- xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="feather feather-image"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect><circle cx="8.5" cy="8.5" r="1.5"></circle><polyline points="21 15 16 10 5 21"></polyline></svg>
203
- </div>
204
- """
205
-
206
-
207
  # ============================================================================
208
- # Model Loading Utilities
209
  # ============================================================================
210
 
211
  def build_image_cond_model(config: dict):
212
- """Build DinoV3ProjFeatureExtractor."""
213
  from trellis2.trainers.flow_matching.mixins.image_conditioned_proj import DinoV3ProjFeatureExtractor
214
  model = DinoV3ProjFeatureExtractor(**config)
215
  model.eval()
216
  return model
217
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
218
 
219
  # ============================================================================
220
- # Camera Parameter Utilities
221
  # ============================================================================
222
 
223
  def compute_f_pixels(camera_angle_x: float, resolution: int) -> float:
@@ -225,7 +159,6 @@ def compute_f_pixels(camera_angle_x: float, resolution: int) -> float:
225
  f_pixels = focal_length * resolution / 32.0
226
  return float(f_pixels.item())
227
 
228
-
229
  def distance_from_fov(camera_angle_x, grid_point, target_point, mesh_scale, image_resolution):
230
  rotation_matrix = torch.tensor([[1.0, 0.0, 0.0], [0.0, 0.0, -1.0], [0.0, 1.0, 0.0]])
231
  gp = grid_point.to(torch.float32) @ rotation_matrix.T
@@ -238,25 +171,8 @@ def distance_from_fov(camera_angle_x, grid_point, target_point, mesh_scale, imag
238
  distance_x = f_pixels * xw / x_ndc - yw
239
  return {"distance_from_x": float(distance_x), "f_pixels": float(f_pixels)}
240
 
241
-
242
- def load_moge_model(device="cuda", model_name=MOGE_MODEL_NAME):
243
- print(f"[MoGe-2] Loading model {model_name}...")
244
- from moge.model.v2 import MoGeModel
245
- moge_model = MoGeModel.from_pretrained(model_name).to(device)
246
- moge_model.eval()
247
- print("[MoGe-2] Model loaded!")
248
- return moge_model
249
-
250
-
251
- def get_camera_params_wild_moge(image, moge_model, device="cuda",
252
- mesh_scale=1.0, extend_pixel=0, image_resolution=512):
253
- """Estimate camera parameters via MoGe-2."""
254
- if isinstance(image, str):
255
- pil_image = Image.open(image).convert("RGB")
256
- elif isinstance(image, Image.Image):
257
- pil_image = image.convert("RGB")
258
- else:
259
- raise ValueError(f"Unsupported image type: {type(image)}")
260
  width, height = pil_image.size
261
  image_np = np.array(pil_image).astype(np.float32) / 255.0
262
  image_tensor = torch.from_numpy(image_np).permute(2, 0, 1).to(device)
@@ -275,88 +191,81 @@ def get_camera_params_wild_moge(image, moge_model, device="cuda",
275
  )["distance_from_x"]
276
  return {'camera_angle_x': camera_angle_x, 'distance': distance, 'mesh_scale': mesh_scale}
277
 
278
-
279
- # ============================================================================
280
- # UI Utilities
281
- # ============================================================================
282
-
283
- def image_to_base64(image):
284
- buffered = io.BytesIO()
285
- image = image.convert("RGB")
286
- image.save(buffered, format="jpeg", quality=85)
287
- img_str = base64.b64encode(buffered.getvalue()).decode()
288
- return f"data:image/jpeg;base64,{img_str}"
289
-
290
-
291
- def start_session(req: gr.Request):
292
- user_dir = os.path.join(TMP_DIR, str(req.session_hash))
293
- os.makedirs(user_dir, exist_ok=True)
294
-
295
-
296
- def end_session(req: gr.Request):
297
- user_dir = os.path.join(TMP_DIR, str(req.session_hash))
298
- if os.path.exists(user_dir):
299
- shutil.rmtree(user_dir)
300
-
301
-
302
- def preprocess_image(image: Image.Image) -> Image.Image:
303
- return pipeline.preprocess_image(image)
304
-
305
-
306
  def pack_state(shape_slat, tex_slat, res):
307
- return {
308
  'shape_slat_feats': shape_slat.feats.cpu().numpy(),
309
  'tex_slat_feats': tex_slat.feats.cpu().numpy(),
310
  'coords': shape_slat.coords.cpu().numpy(),
311
  'res': res,
312
  }
 
 
 
313
 
314
-
315
- def unpack_state(state):
316
  shape_slat = SparseTensor(
317
- feats=torch.from_numpy(state['shape_slat_feats']).cuda(),
318
- coords=torch.from_numpy(state['coords']).cuda(),
319
  )
320
- tex_slat = shape_slat.replace(torch.from_numpy(state['tex_slat_feats']).cuda())
321
- return shape_slat, tex_slat, state['res']
322
 
 
 
 
323
 
324
- def get_seed(randomize_seed, seed):
325
- return np.random.randint(0, MAX_SEED) if randomize_seed else seed
326
 
 
 
 
 
 
327
 
328
- # ============================================================================
329
- # Core Inference
330
- # ============================================================================
 
 
 
 
 
331
 
 
332
  @spaces.GPU(duration=120)
333
- def image_to_3d(
334
- image, seed, resolution,
335
- ss_guidance_strength, ss_guidance_rescale, ss_sampling_steps, ss_rescale_t,
336
- shape_slat_guidance_strength, shape_slat_guidance_rescale, shape_slat_sampling_steps, shape_slat_rescale_t,
337
- tex_slat_guidance_strength, tex_slat_guidance_rescale, tex_slat_sampling_steps, tex_slat_rescale_t,
338
- req: gr.Request,
339
- progress=gr.Progress(track_tqdm=True),
340
- ):
341
- device = pipeline.device
 
 
 
 
 
 
 
 
 
342
  torch.manual_seed(seed)
343
  hr_resolution = int(resolution)
344
-
345
- total_t0 = time.time()
346
- print(f"\n{'='*60}")
347
- print(f" [Generate] Start | seed={seed}, resolution={hr_resolution}")
348
- print(f"{'='*60}")
349
-
350
- # Preprocessing
351
- image_preprocessed = pipeline.preprocess_image(image)
352
-
353
- # Camera estimation via MoGe-2
354
  camera_params = get_camera_params_wild_moge(
355
- image_preprocessed, moge_model, device=str(device),
356
  mesh_scale=WILD_MESH_SCALE, extend_pixel=WILD_EXTEND_PIXEL,
357
  image_resolution=WILD_IMAGE_RESOLUTION,
358
  )
359
-
360
  ss_sampler_override = {"steps": ss_sampling_steps, "guidance_strength": ss_guidance_strength,
361
  "guidance_rescale": ss_guidance_rescale, "rescale_t": ss_rescale_t}
362
  shape_sampler_override = {"steps": shape_slat_sampling_steps, "guidance_strength": shape_slat_guidance_strength,
@@ -364,7 +273,6 @@ def image_to_3d(
364
  tex_sampler_override = {"steps": tex_slat_sampling_steps, "guidance_strength": tex_slat_guidance_strength,
365
  "guidance_rescale": tex_slat_guidance_rescale, "rescale_t": tex_slat_rescale_t}
366
 
367
- # Run pipeline
368
  pipeline_type = f"{hr_resolution}_cascade"
369
  mesh_list, (shape_slat, tex_slat, res) = pipeline.run(
370
  image_preprocessed,
@@ -378,60 +286,37 @@ def image_to_3d(
378
  pipeline_type=pipeline_type,
379
  max_num_tokens=CASCADE_MAX_NUM_TOKENS,
380
  )
 
381
  mesh = mesh_list[0]
382
- state = pack_state(shape_slat, tex_slat, res)
383
- del shape_slat, tex_slat, mesh_list
384
- torch.cuda.empty_cache()
385
-
386
- # Render
387
  mesh.simplify(16777216)
388
- images = render_utils.render_proj_aligned_video(
389
  mesh, camera_angle_x=camera_params['camera_angle_x'],
390
  distance=camera_params['distance'], resolution=1024,
391
  num_frames=STEPS, envmap=envmap,
392
  )
393
- del mesh
394
- torch.cuda.empty_cache()
395
- print(f"\n [Generate] Total time: {time.time()-total_t0:.2f}s")
396
-
397
- # Build HTML
398
- images_html = ""
399
- for m_idx, mode in enumerate(MODES):
400
- for s_idx in range(STEPS):
401
- unique_id = f"view-m{m_idx}-s{s_idx}"
402
- is_visible = (m_idx == DEFAULT_MODE and s_idx == DEFAULT_STEP)
403
- vis_class = "visible" if is_visible else ""
404
- img_base64 = image_to_base64(Image.fromarray(images[mode['render_key']][s_idx]))
405
- images_html += f'<img id="{unique_id}" class="previewer-main-image {vis_class}" src="{img_base64}" loading="eager">'
406
-
407
- btns_html = ""
408
- for idx, mode in enumerate(MODES):
409
- active_class = "active" if idx == DEFAULT_MODE else ""
410
- btns_html += f'<img src="{mode["icon_base64"]}" class="mode-btn {active_class}" onclick="selectMode({idx})" title="{mode["name"]}">'
411
-
412
- full_html = f"""
413
- <div class="previewer-container">
414
- <div class="tips-wrapper">
415
- <div class="tips-icon">Tips</div>
416
- <div class="tips-text">
417
- <p>Render Mode - Click circular buttons to switch render modes.</p>
418
- <p>View Angle - Drag the slider to change the view angle.</p>
419
- </div>
420
- </div>
421
- <div class="display-row">{images_html}</div>
422
- <div class="mode-row" id="btn-group">{btns_html}</div>
423
- <div class="slider-row">
424
- <input type="range" id="custom-slider" min="0" max="{STEPS - 1}" value="{DEFAULT_STEP}" step="1" oninput="onSliderChange(this.value)">
425
- </div>
426
- </div>
427
- """
428
- return state, full_html
429
 
 
 
 
 
430
 
 
431
  @spaces.GPU(duration=120)
432
- def extract_glb(state, decimation_target, texture_size, req: gr.Request, progress=gr.Progress(track_tqdm=True)):
433
- user_dir = os.path.join(TMP_DIR, str(req.session_hash))
434
- shape_slat, tex_slat, res = unpack_state(state)
435
  mesh = pipeline.decode_latent(shape_slat, tex_slat, res)[0]
436
  glb = o_voxel.postprocess.to_glb(
437
  vertices=mesh.vertices, faces=mesh.faces, attr_volume=mesh.attrs,
@@ -440,7 +325,6 @@ def extract_glb(state, decimation_target, texture_size, req: gr.Request, progres
440
  decimation_target=decimation_target, texture_size=texture_size,
441
  remesh=True, remesh_band=1, remesh_project=0, use_tqdm=True,
442
  )
443
- # Ry(180°) @ Rx(90°): (x,y,z) → (-x, -z, -y)
444
  rot = np.array([
445
  [-1, 0, 0, 0],
446
  [ 0, 0, -1, 0],
@@ -448,153 +332,13 @@ def extract_glb(state, decimation_target, texture_size, req: gr.Request, progres
448
  [ 0, 0, 0, 1],
449
  ], dtype=np.float64)
450
  glb.apply_transform(rot)
451
- now = datetime.now()
452
- timestamp = now.strftime("%Y-%m-%dT%H%M%S") + f".{now.microsecond // 1000:03d}"
453
- os.makedirs(user_dir, exist_ok=True)
454
- glb_path = os.path.join(user_dir, f'sample_{timestamp}.glb')
455
- glb.export(glb_path, extension_webp=True)
456
- torch.cuda.empty_cache()
457
- return glb_path, glb_path
458
-
459
-
460
- # ============================================================================
461
- # Gradio UI
462
- # ============================================================================
463
-
464
- with gr.Blocks(delete_cache=(600, 600)) as demo:
465
- gr.Markdown("""
466
- ## Pixal3D: Pixel-Aligned 3D Generation from Images
467
- [[Project Page](https://ldyang694.github.io/projects/pixal3d/)]
468
- * Upload an image and click **Generate** to create a 3D asset using Pixal3D with TRELLIS.2 backbone.
469
- * Click **Extract GLB** to export and download the generated GLB file.
470
- * Camera parameters are estimated automatically via MoGe-2.
471
- """)
472
-
473
- with gr.Row():
474
- with gr.Column(scale=1, min_width=360):
475
- image_prompt = gr.Image(label="Image Prompt", format="png", image_mode="RGBA", type="pil", height=400)
476
- resolution = gr.Radio(["1024", "1536"], label="Resolution", value="1536")
477
- seed = gr.Slider(0, MAX_SEED, label="Seed", value=42, step=1)
478
- randomize_seed = gr.Checkbox(label="Randomize Seed", value=True)
479
- decimation_target = gr.Slider(100000, 1000000, label="Decimation Target", value=1000000, step=10000)
480
- texture_size = gr.Slider(1024, 4096, label="Texture Size", value=4096, step=1024)
481
- generate_btn = gr.Button("Generate")
482
-
483
- with gr.Accordion(label="Advanced Settings", open=False):
484
- gr.Markdown("Stage 1: Sparse Structure Generation")
485
- with gr.Row():
486
- ss_guidance_strength = gr.Slider(1.0, 10.0, label="Guidance Strength", value=7.5, step=0.1)
487
- ss_guidance_rescale = gr.Slider(0.0, 1.0, label="Guidance Rescale", value=0.7, step=0.01)
488
- ss_sampling_steps = gr.Slider(1, 50, label="Sampling Steps", value=12, step=1)
489
- ss_rescale_t = gr.Slider(1.0, 6.0, label="Rescale T", value=5.0, step=0.1)
490
- gr.Markdown("Stage 2: Shape Generation")
491
- with gr.Row():
492
- shape_slat_guidance_strength = gr.Slider(1.0, 10.0, label="Guidance Strength", value=7.5, step=0.1)
493
- shape_slat_guidance_rescale = gr.Slider(0.0, 1.0, label="Guidance Rescale", value=0.5, step=0.01)
494
- shape_slat_sampling_steps = gr.Slider(1, 50, label="Sampling Steps", value=12, step=1)
495
- shape_slat_rescale_t = gr.Slider(1.0, 6.0, label="Rescale T", value=3.0, step=0.1)
496
- gr.Markdown("Stage 3: Material Generation")
497
- with gr.Row():
498
- tex_slat_guidance_strength = gr.Slider(1.0, 10.0, label="Guidance Strength", value=1.0, step=0.1)
499
- tex_slat_guidance_rescale = gr.Slider(0.0, 1.0, label="Guidance Rescale", value=0.0, step=0.01)
500
- tex_slat_sampling_steps = gr.Slider(1, 50, label="Sampling Steps", value=12, step=1)
501
- tex_slat_rescale_t = gr.Slider(1.0, 6.0, label="Rescale T", value=3.0, step=0.1)
502
-
503
- with gr.Column(scale=10):
504
- with gr.Walkthrough(selected=0) as walkthrough:
505
- with gr.Step("Preview", id=0):
506
- preview_output = gr.HTML(empty_html, label="3D Asset Preview", show_label=True, container=True)
507
- extract_btn = gr.Button("Extract GLB")
508
- with gr.Step("Extract", id=1):
509
- glb_output = gr.Model3D(label="Extracted GLB", height=724, show_label=True, display_mode="solid", clear_color=(0.25, 0.25, 0.25, 1.0), camera_position=(-90, 90, None))
510
- download_btn = gr.DownloadButton(label="Download GLB")
511
-
512
- with gr.Column(scale=1, min_width=172):
513
- examples = gr.Examples(
514
- examples=[f'assets/example_image/{image}' for image in os.listdir("assets/example_image")],
515
- inputs=[image_prompt], fn=preprocess_image, outputs=[image_prompt],
516
- run_on_click=True, examples_per_page=18,
517
- )
518
-
519
- output_buf = gr.State()
520
-
521
- demo.load(start_session)
522
- demo.unload(end_session)
523
- image_prompt.upload(preprocess_image, inputs=[image_prompt], outputs=[image_prompt])
524
-
525
- generate_btn.click(get_seed, inputs=[randomize_seed, seed], outputs=[seed]).then(
526
- lambda: gr.Walkthrough(selected=0), outputs=walkthrough
527
- ).then(
528
- image_to_3d,
529
- inputs=[image_prompt, seed, resolution,
530
- ss_guidance_strength, ss_guidance_rescale, ss_sampling_steps, ss_rescale_t,
531
- shape_slat_guidance_strength, shape_slat_guidance_rescale, shape_slat_sampling_steps, shape_slat_rescale_t,
532
- tex_slat_guidance_strength, tex_slat_guidance_rescale, tex_slat_sampling_steps, tex_slat_rescale_t],
533
- outputs=[output_buf, preview_output],
534
- )
535
-
536
- extract_btn.click(lambda: gr.Walkthrough(selected=1), outputs=walkthrough).then(
537
- extract_glb, inputs=[output_buf, decimation_target, texture_size], outputs=[glb_output, download_btn],
538
- )
539
-
540
-
541
- # ============================================================================
542
- # Launch
543
- # ============================================================================
544
-
545
- def parse_args():
546
- parser = argparse.ArgumentParser(description="Pixal3D Gradio App")
547
- parser.add_argument("--model_path", type=str, default="TencentARC/Pixal3D-T",
548
- help="HuggingFace repo ID or local path (default: TencentARC/Pixal3D-T)")
549
- parser.add_argument("--port", type=int, default=7860)
550
- parser.add_argument("--share", action="store_true", default=True)
551
- return parser.parse_args()
552
 
 
 
553
 
554
  if __name__ == "__main__":
555
- args = parse_args()
556
- os.makedirs(TMP_DIR, exist_ok=True)
557
-
558
- # Construct UI icon base64
559
- for i in range(len(MODES)):
560
- icon = Image.open(MODES[i]['icon'])
561
- MODES[i]['icon_base64'] = image_to_base64(icon)
562
-
563
- # Load pipeline from HuggingFace or local path
564
- print(f"[Pipeline] Loading from {args.model_path}...")
565
- pipeline = Pixal3DImageTo3DPipeline.from_pretrained(args.model_path)
566
-
567
- # Load environment maps
568
- envmap = {
569
- 'forest': EnvMap(torch.tensor(cv2.cvtColor(cv2.imread('assets/hdri/forest.exr', cv2.IMREAD_UNCHANGED), cv2.COLOR_BGR2RGB), dtype=torch.float32, device='cuda')),
570
- 'sunset': EnvMap(torch.tensor(cv2.cvtColor(cv2.imread('assets/hdri/sunset.exr', cv2.IMREAD_UNCHANGED), cv2.COLOR_BGR2RGB), dtype=torch.float32, device='cuda')),
571
- 'courtyard': EnvMap(torch.tensor(cv2.cvtColor(cv2.imread('assets/hdri/courtyard.exr', cv2.IMREAD_UNCHANGED), cv2.COLOR_BGR2RGB), dtype=torch.float32, device='cuda')),
572
- }
573
-
574
- # Build image cond models and set on pipeline
575
- print("[ImageCond] Building DinoV3ProjFeatureExtractor models...")
576
- pipeline.image_cond_model_ss = build_image_cond_model(IMAGE_COND_CONFIGS["ss"])
577
- pipeline.image_cond_model_shape_512 = build_image_cond_model(IMAGE_COND_CONFIGS["shape_512"])
578
- pipeline.image_cond_model_shape_1024 = build_image_cond_model(IMAGE_COND_CONFIGS["shape_1024"])
579
- pipeline.image_cond_model_tex_1024 = build_image_cond_model(IMAGE_COND_CONFIGS["tex_1024"])
580
-
581
- pipeline.cuda()
582
-
583
- # Pre-download NAF model (avoid lazy-loading during inference)
584
- print("[NAF] Pre-loading NAF upsampler model...")
585
- for attr in ['image_cond_model_ss', 'image_cond_model_shape_512', 'image_cond_model_shape_1024', 'image_cond_model_tex_1024']:
586
- model = getattr(pipeline, attr, None)
587
- if model is not None and getattr(model, 'use_naf_upsample', False):
588
- model._load_naf()
589
- print("[NAF] NAF model loaded.")
590
-
591
- # Load MoGe-2
592
- print("\n[MoGe-2] Loading model for camera estimation...")
593
- moge_model = load_moge_model(device="cuda")
594
-
595
- print(f"\n{'=' * 60}")
596
- print(f" Pixal3D ready! Model loaded from: {args.model_path}")
597
- print(f" Cascade: {CASCADE_LR_RESOLUTION} -> 1024/1536")
598
- print(f"{'=' * 60}\n")
599
-
600
- demo.launch(css=css, head=head, server_port=args.port, share=args.share)
 
 
 
 
 
 
 
 
 
 
 
1
  import os
2
  import subprocess
3
+ import argparse
4
+ import math
5
+ import time
6
+ import shutil
7
+ import cv2
8
+ import torch
9
+ import numpy as np
10
+ import base64
11
+ import io
12
+ import json
13
+ from datetime import datetime
14
+ from typing import *
15
+ from PIL import Image
16
+
17
+ # Re-install utils3d as in original app.py
18
  subprocess.run([
19
  "pip", "install", "--force-reinstall", "--no-deps",
20
  "https://github.com/LDYang694/Storages/releases/download/20260430/utils3d-0.0.2-py3-none-any.whl"
 
26
  os.environ["FLEX_GEMM_AUTOTUNE_CACHE_PATH"] = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'autotune_cache.json')
27
  os.environ["FLEX_GEMM_AUTOTUNER_VERBOSE"] = '1'
28
 
29
+ import spaces
30
+ from gradio import Server
31
+ from gradio.data_classes import FileData
32
+ from fastapi.responses import HTMLResponse
33
+ from fastapi.staticfiles import StaticFiles
34
+
 
 
 
 
 
 
35
  from trellis2.modules.sparse import SparseTensor
36
  from trellis2.pipelines import Pixal3DImageTo3DPipeline
37
  from trellis2.renderers import EnvMap
38
  from trellis2.utils import render_utils
39
  import o_voxel
40
 
 
41
  # ============================================================================
42
  # Constants & Defaults
43
  # ============================================================================
44
 
45
  MAX_SEED = np.iinfo(np.int32).max
46
  TMP_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'tmp')
47
+ os.makedirs(TMP_DIR, exist_ok=True)
48
+
49
  MODES = [
50
  {"name": "Normal", "icon": "assets/app/normal.png", "render_key": "normal"},
51
  {"name": "Clay render", "icon": "assets/app/clay.png", "render_key": "clay"},
 
55
  {"name": "HDRI courtyard", "icon": "assets/app/hdri_courtyard.png", "render_key": "shaded_courtyard"},
56
  ]
57
  STEPS = 8
 
 
58
 
59
  # Cascade parameters
60
  CASCADE_LR_RESOLUTION = 512
 
66
  WILD_EXTEND_PIXEL = 0
67
  WILD_IMAGE_RESOLUTION = 512
68
 
69
+ # Image Cond Model configs
70
  IMAGE_COND_CONFIGS = {
71
  "ss": {
72
  "model_name": "camenduru/dinov3-vitl16-pretrain-lvd1689m",
 
96
  },
97
  }
98
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99
  # ============================================================================
100
+ # Model Loading
101
  # ============================================================================
102
 
103
  def build_image_cond_model(config: dict):
 
104
  from trellis2.trainers.flow_matching.mixins.image_conditioned_proj import DinoV3ProjFeatureExtractor
105
  model = DinoV3ProjFeatureExtractor(**config)
106
  model.eval()
107
  return model
108
 
109
+ def load_moge_model(device="cuda", model_name=MOGE_MODEL_NAME):
110
+ from moge.model.v2 import MoGeModel
111
+ moge_model = MoGeModel.from_pretrained(model_name).to(device)
112
+ moge_model.eval()
113
+ return moge_model
114
+
115
+ # Global instances (lazy loaded or loaded at start)
116
+ pipeline = None
117
+ moge_model = None
118
+ envmap = None
119
+
120
+ def init_models():
121
+ global pipeline, moge_model, envmap
122
+ if pipeline is not None:
123
+ return
124
+
125
+ model_path = "TencentARC/Pixal3D-T"
126
+ print(f"[Pipeline] Loading from {model_path}...")
127
+ pipeline = Pixal3DImageTo3DPipeline.from_pretrained(model_path)
128
+
129
+ print("[ImageCond] Building DinoV3ProjFeatureExtractor models...")
130
+ pipeline.image_cond_model_ss = build_image_cond_model(IMAGE_COND_CONFIGS["ss"])
131
+ pipeline.image_cond_model_shape_512 = build_image_cond_model(IMAGE_COND_CONFIGS["shape_512"])
132
+ pipeline.image_cond_model_shape_1024 = build_image_cond_model(IMAGE_COND_CONFIGS["shape_1024"])
133
+ pipeline.image_cond_model_tex_1024 = build_image_cond_model(IMAGE_COND_CONFIGS["tex_1024"])
134
+
135
+ pipeline.cuda()
136
+
137
+ print("[NAF] Pre-loading NAF upsampler model...")
138
+ for attr in ['image_cond_model_ss', 'image_cond_model_shape_512', 'image_cond_model_shape_1024', 'image_cond_model_tex_1024']:
139
+ model = getattr(pipeline, attr, None)
140
+ if model is not None and getattr(model, 'use_naf_upsample', False):
141
+ model._load_naf()
142
+
143
+ print("[MoGe-2] Loading model for camera estimation...")
144
+ moge_model = load_moge_model(device="cuda")
145
+
146
+ print("[EnvMap] Loading environment maps...")
147
+ envmap = {
148
+ 'forest': EnvMap(torch.tensor(cv2.cvtColor(cv2.imread('assets/hdri/forest.exr', cv2.IMREAD_UNCHANGED), cv2.COLOR_BGR2RGB), dtype=torch.float32, device='cuda')),
149
+ 'sunset': EnvMap(torch.tensor(cv2.cvtColor(cv2.imread('assets/hdri/sunset.exr', cv2.IMREAD_UNCHANGED), cv2.COLOR_BGR2RGB), dtype=torch.float32, device='cuda')),
150
+ 'courtyard': EnvMap(torch.tensor(cv2.cvtColor(cv2.imread('assets/hdri/courtyard.exr', cv2.IMREAD_UNCHANGED), cv2.COLOR_BGR2RGB), dtype=torch.float32, device='cuda')),
151
+ }
152
 
153
  # ============================================================================
154
+ # Utilities
155
  # ============================================================================
156
 
157
  def compute_f_pixels(camera_angle_x: float, resolution: int) -> float:
 
159
  f_pixels = focal_length * resolution / 32.0
160
  return float(f_pixels.item())
161
 
 
162
  def distance_from_fov(camera_angle_x, grid_point, target_point, mesh_scale, image_resolution):
163
  rotation_matrix = torch.tensor([[1.0, 0.0, 0.0], [0.0, 0.0, -1.0], [0.0, 1.0, 0.0]])
164
  gp = grid_point.to(torch.float32) @ rotation_matrix.T
 
171
  distance_x = f_pixels * xw / x_ndc - yw
172
  return {"distance_from_x": float(distance_x), "f_pixels": float(f_pixels)}
173
 
174
+ def get_camera_params_wild_moge(image_path, device="cuda", mesh_scale=1.0, extend_pixel=0, image_resolution=512):
175
+ pil_image = Image.open(image_path).convert("RGB")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
176
  width, height = pil_image.size
177
  image_np = np.array(pil_image).astype(np.float32) / 255.0
178
  image_tensor = torch.from_numpy(image_np).permute(2, 0, 1).to(device)
 
191
  )["distance_from_x"]
192
  return {'camera_angle_x': camera_angle_x, 'distance': distance, 'mesh_scale': mesh_scale}
193
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
194
  def pack_state(shape_slat, tex_slat, res):
195
+ state_data = {
196
  'shape_slat_feats': shape_slat.feats.cpu().numpy(),
197
  'tex_slat_feats': tex_slat.feats.cpu().numpy(),
198
  'coords': shape_slat.coords.cpu().numpy(),
199
  'res': res,
200
  }
201
+ state_path = os.path.join(TMP_DIR, f"state_{int(time.time()*1000)}.npz")
202
+ np.savez_compressed(state_path, **state_data)
203
+ return state_path
204
 
205
+ def unpack_state(state_path):
206
+ data = np.load(state_path)
207
  shape_slat = SparseTensor(
208
+ feats=torch.from_numpy(data['shape_slat_feats']).cuda(),
209
+ coords=torch.from_numpy(data['coords']).cuda(),
210
  )
211
+ tex_slat = shape_slat.replace(torch.from_numpy(data['tex_slat_feats']).cuda())
212
+ return shape_slat, tex_slat, int(data['res'])
213
 
214
+ # ============================================================================
215
+ # API Implementation
216
+ # ============================================================================
217
 
218
+ app = Server()
 
219
 
220
+ @app.get("/")
221
+ async def homepage():
222
+ html_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "index.html")
223
+ with open(html_path, "r", encoding="utf-8") as f:
224
+ return HTMLResponse(content=f.read())
225
 
226
+ @app.api()
227
+ def preprocess(image: FileData) -> FileData:
228
+ init_models()
229
+ img = Image.open(image.path)
230
+ processed = pipeline.preprocess_image(img)
231
+ out_path = os.path.join(TMP_DIR, f"preprocessed_{int(time.time()*1000)}.png")
232
+ processed.save(out_path)
233
+ return FileData(path=out_path)
234
 
235
+ @app.api()
236
  @spaces.GPU(duration=120)
237
+ def generate_3d(
238
+ image: FileData,
239
+ seed: int,
240
+ resolution: int,
241
+ ss_guidance_strength: float = 7.5,
242
+ ss_guidance_rescale: float = 0.7,
243
+ ss_sampling_steps: int = 12,
244
+ ss_rescale_t: float = 5.0,
245
+ shape_slat_guidance_strength: float = 7.5,
246
+ shape_slat_guidance_rescale: float = 0.5,
247
+ shape_slat_sampling_steps: int = 12,
248
+ shape_slat_rescale_t: float = 3.0,
249
+ tex_slat_guidance_strength: float = 1.0,
250
+ tex_slat_guidance_rescale: float = 0.0,
251
+ tex_slat_sampling_steps: int = 12,
252
+ tex_slat_rescale_t: float = 3.0,
253
+ ) -> Dict:
254
+ init_models()
255
  torch.manual_seed(seed)
256
  hr_resolution = int(resolution)
257
+
258
+ img = Image.open(image.path)
259
+ image_preprocessed = pipeline.preprocess_image(img)
260
+ temp_processed_path = os.path.join(TMP_DIR, "temp_proc.png")
261
+ image_preprocessed.save(temp_processed_path)
262
+
 
 
 
 
263
  camera_params = get_camera_params_wild_moge(
264
+ temp_processed_path, device="cuda",
265
  mesh_scale=WILD_MESH_SCALE, extend_pixel=WILD_EXTEND_PIXEL,
266
  image_resolution=WILD_IMAGE_RESOLUTION,
267
  )
268
+
269
  ss_sampler_override = {"steps": ss_sampling_steps, "guidance_strength": ss_guidance_strength,
270
  "guidance_rescale": ss_guidance_rescale, "rescale_t": ss_rescale_t}
271
  shape_sampler_override = {"steps": shape_slat_sampling_steps, "guidance_strength": shape_slat_guidance_strength,
 
273
  tex_sampler_override = {"steps": tex_slat_sampling_steps, "guidance_strength": tex_slat_guidance_strength,
274
  "guidance_rescale": tex_slat_guidance_rescale, "rescale_t": tex_slat_rescale_t}
275
 
 
276
  pipeline_type = f"{hr_resolution}_cascade"
277
  mesh_list, (shape_slat, tex_slat, res) = pipeline.run(
278
  image_preprocessed,
 
286
  pipeline_type=pipeline_type,
287
  max_num_tokens=CASCADE_MAX_NUM_TOKENS,
288
  )
289
+
290
  mesh = mesh_list[0]
291
+ state_path = pack_state(shape_slat, tex_slat, res)
292
+
 
 
 
293
  mesh.simplify(16777216)
294
+ renders = render_utils.render_proj_aligned_video(
295
  mesh, camera_angle_x=camera_params['camera_angle_x'],
296
  distance=camera_params['distance'], resolution=1024,
297
  num_frames=STEPS, envmap=envmap,
298
  )
299
+
300
+ # Save renders and return paths
301
+ render_files = {}
302
+ for mode_key, frames in renders.items():
303
+ mode_files = []
304
+ for i, frame in enumerate(frames):
305
+ p = os.path.abspath(os.path.join(TMP_DIR, f"render_{mode_key}_{i}_{int(time.time()*1000)}.jpg"))
306
+ Image.fromarray(frame).save(p, quality=85)
307
+ mode_files.append(FileData(path=p))
308
+ render_files[mode_key] = mode_files
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
309
 
310
+ return {
311
+ "render_paths": render_files,
312
+ "state_path": os.path.abspath(state_path)
313
+ }
314
 
315
+ @app.api()
316
  @spaces.GPU(duration=120)
317
+ def extract_glb_api(state_path: str, decimation_target: int, texture_size: int) -> FileData:
318
+ init_models()
319
+ shape_slat, tex_slat, res = unpack_state(state_path)
320
  mesh = pipeline.decode_latent(shape_slat, tex_slat, res)[0]
321
  glb = o_voxel.postprocess.to_glb(
322
  vertices=mesh.vertices, faces=mesh.faces, attr_volume=mesh.attrs,
 
325
  decimation_target=decimation_target, texture_size=texture_size,
326
  remesh=True, remesh_band=1, remesh_project=0, use_tqdm=True,
327
  )
 
328
  rot = np.array([
329
  [-1, 0, 0, 0],
330
  [ 0, 0, -1, 0],
 
332
  [ 0, 0, 0, 1],
333
  ], dtype=np.float64)
334
  glb.apply_transform(rot)
335
+
336
+ out_glb = os.path.join(TMP_DIR, f"result_{int(time.time()*1000)}.glb")
337
+ glb.export(out_glb, extension_webp=True)
338
+ return FileData(path=out_glb)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
339
 
340
+ # Mount assets and tmp for direct access if needed (though FileData handles it)
341
+ app.mount("/assets", StaticFiles(directory="assets"), name="assets")
342
 
343
  if __name__ == "__main__":
344
+ app.launch(show_error=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
index.html ADDED
@@ -0,0 +1,855 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Pixal3D | Premium 3D Generation</title>
7
+ <meta name="description" content="State-of-the-art pixel-aligned 3D generation from a single image.">
8
+
9
+ <!-- Fonts -->
10
+ <link rel="preconnect" href="https://fonts.googleapis.com">
11
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
12
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Outfit:wght@400;500;600;700;800&display=swap" rel="stylesheet">
13
+
14
+ <!-- Icons & Components -->
15
+ <script src="https://unpkg.com/lucide@latest"></script>
16
+ <script type="module" src="https://ajax.googleapis.com/ajax/libs/model-viewer/4.0.0/model-viewer.min.js"></script>
17
+
18
+ <style>
19
+ :root {
20
+ --primary: #6366f1;
21
+ --primary-hover: #4f46e5;
22
+ --bg: #0f172a;
23
+ --card-bg: rgba(30, 41, 59, 0.7);
24
+ --border: rgba(255, 255, 255, 0.1);
25
+ --text: #f8fafc;
26
+ --text-muted: #94a3b8;
27
+ --accent: #10b981;
28
+ --glass: rgba(255, 255, 255, 0.03);
29
+ --glass-border: rgba(255, 255, 255, 0.08);
30
+ }
31
+
32
+ * {
33
+ margin: 0;
34
+ padding: 0;
35
+ box-sizing: border-box;
36
+ }
37
+
38
+ body {
39
+ font-family: 'Inter', sans-serif;
40
+ background: var(--bg);
41
+ color: var(--text);
42
+ line-height: 1.6;
43
+ overflow-x: hidden;
44
+ background: radial-gradient(circle at top right, #1e1b4b, transparent),
45
+ radial-gradient(circle at bottom left, #0f172a, transparent);
46
+ min-height: 100vh;
47
+ }
48
+
49
+ h1, h2, h3 {
50
+ font-family: 'Outfit', sans-serif;
51
+ font-weight: 700;
52
+ }
53
+
54
+ .container {
55
+ max-width: 1200px;
56
+ margin: 0 auto;
57
+ padding: 2rem;
58
+ }
59
+
60
+ /* Header */
61
+ header {
62
+ text-align: center;
63
+ margin-bottom: 3rem;
64
+ animation: fadeInDown 0.8s ease-out;
65
+ }
66
+
67
+ .logo-container {
68
+ display: inline-flex;
69
+ align-items: center;
70
+ gap: 1rem;
71
+ margin-bottom: 1rem;
72
+ }
73
+
74
+ .logo-icon {
75
+ color: var(--primary);
76
+ width: 40px;
77
+ height: 40px;
78
+ }
79
+
80
+ h1 {
81
+ font-size: 3rem;
82
+ background: linear-gradient(135deg, #fff 0%, #94a3b8 100%);
83
+ -webkit-background-clip: text;
84
+ -webkit-text-fill-color: transparent;
85
+ letter-spacing: -0.02em;
86
+ }
87
+
88
+ header p {
89
+ color: var(--text-muted);
90
+ font-size: 1.1rem;
91
+ max-width: 600px;
92
+ margin: 0 auto;
93
+ }
94
+
95
+ /* Main Grid */
96
+ .main-grid {
97
+ display: grid;
98
+ grid-template-columns: 1fr 1fr;
99
+ gap: 2rem;
100
+ align-items: start;
101
+ }
102
+
103
+ @media (max-width: 968px) {
104
+ .main-grid {
105
+ grid-template-columns: 1fr;
106
+ }
107
+ }
108
+
109
+ /* Card Style */
110
+ .card {
111
+ background: var(--card-bg);
112
+ backdrop-filter: blur(12px);
113
+ border: 1px solid var(--border);
114
+ border-radius: 24px;
115
+ padding: 2rem;
116
+ box-shadow: 0 20px 50px rgba(0, 0, 0, 0.3);
117
+ transition: transform 0.3s ease, box-shadow 0.3s ease;
118
+ }
119
+
120
+ .card:hover {
121
+ box-shadow: 0 30px 60px rgba(0, 0, 0, 0.4);
122
+ }
123
+
124
+ /* Upload Area */
125
+ .upload-area {
126
+ border: 2px dashed var(--border);
127
+ border-radius: 16px;
128
+ padding: 3rem;
129
+ text-align: center;
130
+ cursor: pointer;
131
+ transition: all 0.3s ease;
132
+ position: relative;
133
+ overflow: hidden;
134
+ background: var(--glass);
135
+ }
136
+
137
+ .upload-area:hover {
138
+ border-color: var(--primary);
139
+ background: rgba(99, 102, 241, 0.05);
140
+ }
141
+
142
+ .upload-area.dragging {
143
+ border-color: var(--accent);
144
+ background: rgba(16, 185, 129, 0.05);
145
+ transform: scale(1.02);
146
+ }
147
+
148
+ .upload-placeholder {
149
+ display: flex;
150
+ flex-direction: column;
151
+ align-items: center;
152
+ gap: 1rem;
153
+ }
154
+
155
+ .upload-icon {
156
+ width: 64px;
157
+ height: 64px;
158
+ color: var(--primary);
159
+ opacity: 0.8;
160
+ }
161
+
162
+ #preview-img {
163
+ max-width: 100%;
164
+ max-height: 400px;
165
+ border-radius: 12px;
166
+ display: none;
167
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
168
+ }
169
+
170
+ /* Controls */
171
+ .controls {
172
+ margin-top: 2rem;
173
+ display: flex;
174
+ flex-direction: column;
175
+ gap: 1.5rem;
176
+ }
177
+
178
+ .input-group {
179
+ display: flex;
180
+ flex-direction: column;
181
+ gap: 0.5rem;
182
+ }
183
+
184
+ .input-group label {
185
+ font-weight: 500;
186
+ color: var(--text-muted);
187
+ font-size: 0.9rem;
188
+ }
189
+
190
+ select, input[type="range"] {
191
+ width: 100%;
192
+ }
193
+
194
+ select {
195
+ background: #1e293b;
196
+ border: 1px solid var(--border);
197
+ color: white;
198
+ padding: 0.75rem;
199
+ border-radius: 8px;
200
+ outline: none;
201
+ cursor: pointer;
202
+ }
203
+
204
+ .btn {
205
+ background: var(--primary);
206
+ color: white;
207
+ border: none;
208
+ padding: 1rem 2rem;
209
+ border-radius: 12px;
210
+ font-weight: 600;
211
+ font-size: 1rem;
212
+ cursor: pointer;
213
+ transition: all 0.2s ease;
214
+ display: flex;
215
+ align-items: center;
216
+ justify-content: center;
217
+ gap: 0.5rem;
218
+ width: 100%;
219
+ }
220
+
221
+ .btn:hover {
222
+ background: var(--primary-hover);
223
+ transform: translateY(-2px);
224
+ }
225
+
226
+ .btn:active {
227
+ transform: translateY(0);
228
+ }
229
+
230
+ .btn:disabled {
231
+ background: #475569;
232
+ cursor: not-allowed;
233
+ transform: none;
234
+ }
235
+
236
+ .btn-secondary {
237
+ background: var(--glass);
238
+ border: 1px solid var(--border);
239
+ }
240
+
241
+ .btn-secondary:hover {
242
+ background: var(--border);
243
+ }
244
+
245
+ /* Result Area */
246
+ .result-container {
247
+ display: flex;
248
+ flex-direction: column;
249
+ gap: 2rem;
250
+ min-height: 500px;
251
+ justify-content: center;
252
+ }
253
+
254
+ .empty-state {
255
+ text-align: center;
256
+ color: var(--text-muted);
257
+ opacity: 0.5;
258
+ }
259
+
260
+ /* 3D Viewer / Frame Slider */
261
+ .viewer-container {
262
+ position: relative;
263
+ width: 100%;
264
+ aspect-ratio: 1/1;
265
+ background: #000;
266
+ border-radius: 16px;
267
+ overflow: hidden;
268
+ display: none;
269
+ box-shadow: inset 0 0 50px rgba(0,0,0,1);
270
+ }
271
+
272
+ .viewer-frame {
273
+ position: absolute;
274
+ inset: 0;
275
+ width: 100%;
276
+ height: 100%;
277
+ object-fit: contain;
278
+ display: none;
279
+ }
280
+
281
+ .viewer-frame.active {
282
+ display: block;
283
+ }
284
+
285
+ .viewer-controls {
286
+ position: absolute;
287
+ bottom: 20px;
288
+ left: 20px;
289
+ right: 20px;
290
+ display: flex;
291
+ flex-direction: column;
292
+ gap: 10px;
293
+ background: rgba(0,0,0,0.5);
294
+ backdrop-filter: blur(8px);
295
+ padding: 15px;
296
+ border-radius: 12px;
297
+ border: 1px solid rgba(255,255,255,0.1);
298
+ }
299
+
300
+ .mode-selector {
301
+ display: flex;
302
+ justify-content: center;
303
+ gap: 8px;
304
+ flex-wrap: wrap;
305
+ }
306
+
307
+ .mode-btn {
308
+ width: 32px;
309
+ height: 32px;
310
+ border-radius: 50%;
311
+ border: 2px solid transparent;
312
+ cursor: pointer;
313
+ transition: all 0.2s;
314
+ background-size: cover;
315
+ opacity: 0.6;
316
+ }
317
+
318
+ .mode-btn.active {
319
+ border-color: var(--primary);
320
+ transform: scale(1.1);
321
+ opacity: 1;
322
+ }
323
+
324
+ /* Slider Styling */
325
+ input[type="range"] {
326
+ -webkit-appearance: none;
327
+ height: 6px;
328
+ background: rgba(255,255,255,0.1);
329
+ border-radius: 5px;
330
+ }
331
+
332
+ input[type="range"]::-webkit-slider-thumb {
333
+ -webkit-appearance: none;
334
+ width: 18px;
335
+ height: 18px;
336
+ background: var(--primary);
337
+ border-radius: 50%;
338
+ cursor: pointer;
339
+ box-shadow: 0 0 10px rgba(99, 102, 241, 0.5);
340
+ }
341
+
342
+ /* Advanced Settings */
343
+ .advanced-toggle {
344
+ display: flex;
345
+ align-items: center;
346
+ justify-content: center;
347
+ gap: 0.5rem;
348
+ font-size: 0.85rem;
349
+ color: var(--text-muted);
350
+ cursor: pointer;
351
+ margin-top: 1rem;
352
+ transition: color 0.2s;
353
+ }
354
+
355
+ .advanced-toggle:hover {
356
+ color: var(--text);
357
+ }
358
+
359
+ .advanced-panel {
360
+ display: none;
361
+ flex-direction: column;
362
+ gap: 1.5rem;
363
+ margin-top: 1.5rem;
364
+ padding-top: 1.5rem;
365
+ border-top: 1px solid var(--border);
366
+ }
367
+
368
+ .advanced-panel.visible {
369
+ display: flex;
370
+ }
371
+
372
+ .advanced-section h4 {
373
+ font-size: 0.8rem;
374
+ text-transform: uppercase;
375
+ letter-spacing: 0.05em;
376
+ margin-bottom: 1rem;
377
+ color: var(--primary);
378
+ }
379
+
380
+ /* Examples */
381
+ .examples-section {
382
+ margin-top: 4rem;
383
+ }
384
+
385
+ .examples-grid {
386
+ display: grid;
387
+ grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
388
+ gap: 1rem;
389
+ margin-top: 1.5rem;
390
+ }
391
+
392
+ .example-item {
393
+ aspect-ratio: 1/1;
394
+ border-radius: 12px;
395
+ overflow: hidden;
396
+ cursor: pointer;
397
+ border: 2px solid transparent;
398
+ transition: all 0.2s;
399
+ }
400
+
401
+ .example-item:hover {
402
+ transform: scale(1.05);
403
+ border-color: var(--primary);
404
+ }
405
+
406
+ .example-item img {
407
+ width: 100%;
408
+ height: 100%;
409
+ object-fit: cover;
410
+ }
411
+
412
+ /* Loading Overlay */
413
+ #loading-overlay {
414
+ position: fixed;
415
+ inset: 0;
416
+ background: rgba(15, 23, 42, 0.8);
417
+ backdrop-filter: blur(8px);
418
+ z-index: 1000;
419
+ display: none;
420
+ flex-direction: column;
421
+ align-items: center;
422
+ justify-content: center;
423
+ gap: 1.5rem;
424
+ }
425
+
426
+ .loader {
427
+ width: 48px;
428
+ height: 48px;
429
+ border: 4px solid var(--primary);
430
+ border-bottom-color: transparent;
431
+ border-radius: 50%;
432
+ animation: rotation 1s linear infinite;
433
+ }
434
+
435
+ @keyframes rotation {
436
+ 0% { transform: rotate(0deg); }
437
+ 100% { transform: rotate(360deg); }
438
+ }
439
+
440
+ @keyframes fadeInDown {
441
+ from { opacity: 0; transform: translateY(-20px); }
442
+ to { opacity: 1; transform: translateY(0); }
443
+ }
444
+
445
+ /* Model Viewer */
446
+ #final-model-viewer {
447
+ width: 100%;
448
+ height: 500px;
449
+ background: #111;
450
+ border-radius: 16px;
451
+ display: none;
452
+ }
453
+
454
+ /* Toast */
455
+ #toast {
456
+ position: fixed;
457
+ bottom: 2rem;
458
+ right: 2rem;
459
+ background: #1e293b;
460
+ color: white;
461
+ padding: 1rem 1.5rem;
462
+ border-radius: 12px;
463
+ border-left: 4px solid var(--primary);
464
+ box-shadow: 0 10px 30px rgba(0,0,0,0.5);
465
+ display: none;
466
+ z-index: 2000;
467
+ animation: slideIn 0.3s ease-out;
468
+ }
469
+
470
+ @keyframes slideIn {
471
+ from { transform: translateX(100%); opacity: 0; }
472
+ to { transform: translateX(0); opacity: 1; }
473
+ }
474
+ </style>
475
+ </head>
476
+ <body>
477
+
478
+ <div class="container">
479
+ <header>
480
+ <div class="logo-container">
481
+ <i data-lucide="box" class="logo-icon"></i>
482
+ <h1>Pixal3D</h1>
483
+ </div>
484
+ <p>High-fidelity, pixel-aligned 3D asset generation from a single reference image. Powered by TRELLIS.2</p>
485
+ </header>
486
+
487
+ <div class="main-grid">
488
+ <!-- Left: Input -->
489
+ <div class="card">
490
+ <div class="upload-area" id="drop-zone" onclick="document.getElementById('file-input').click()">
491
+ <input type="file" id="file-input" hidden accept="image/*">
492
+ <div class="upload-placeholder" id="upload-placeholder">
493
+ <i data-lucide="image-up" class="upload-icon"></i>
494
+ <h3>Drop your image here</h3>
495
+ <p>or click to browse from files</p>
496
+ </div>
497
+ <img id="preview-img" src="" alt="Preview">
498
+ </div>
499
+
500
+ <div class="controls">
501
+ <div class="input-group">
502
+ <label>Resolution</label>
503
+ <select id="resolution">
504
+ <option value="1024">1024 (Balanced)</option>
505
+ <option value="1536" selected>1536 (High Quality)</option>
506
+ </select>
507
+ </div>
508
+
509
+ <div class="input-group">
510
+ <label>Seed</label>
511
+ <div style="display: flex; gap: 1rem;">
512
+ <input type="number" id="seed" value="42" style="flex: 1; background: #1e293b; border: 1px solid var(--border); color: white; padding: 0.5rem; border-radius: 8px;">
513
+ <button class="btn-secondary" style="padding: 0.5rem 1rem; border-radius: 8px;" onclick="randomizeSeed()">
514
+ <i data-lucide="shuffle" style="width: 16px;"></i>
515
+ </button>
516
+ </div>
517
+ </div>
518
+
519
+ <button class="btn" id="generate-btn" disabled>
520
+ <i data-lucide="zap"></i>
521
+ Generate 3D Asset
522
+ </button>
523
+
524
+ <div class="advanced-toggle" onclick="toggleAdvanced()">
525
+ <i data-lucide="settings-2" style="width: 14px;"></i>
526
+ Advanced Generation Parameters
527
+ </div>
528
+
529
+ <div class="advanced-panel" id="advanced-panel">
530
+ <div class="advanced-section">
531
+ <h4>Stage 1: Sparse Structure</h4>
532
+ <div class="input-group">
533
+ <label>Guidance Strength: <span id="ss_gs_val">7.5</span></label>
534
+ <input type="range" id="ss_gs" min="1" max="10" step="0.1" value="7.5" oninput="updateVal('ss_gs')">
535
+ </div>
536
+ <div class="input-group">
537
+ <label>Sampling Steps: <span id="ss_steps_val">12</span></label>
538
+ <input type="range" id="ss_steps" min="1" max="50" step="1" value="12" oninput="updateVal('ss_steps')">
539
+ </div>
540
+ </div>
541
+ <div class="advanced-section">
542
+ <h4>Stage 2: Shape</h4>
543
+ <div class="input-group">
544
+ <label>Guidance Strength: <span id="shape_gs_val">7.5</span></label>
545
+ <input type="range" id="shape_gs" min="1" max="10" step="0.1" value="7.5" oninput="updateVal('shape_gs')">
546
+ </div>
547
+ </div>
548
+ <div class="advanced-section">
549
+ <h4>Export Settings</h4>
550
+ <div class="input-group">
551
+ <label>Decimation Target: <span id="decim_val">1,000,000</span></label>
552
+ <input type="range" id="decimation" min="100000" max="1000000" step="10000" value="1000000" oninput="updateVal('decimation')">
553
+ </div>
554
+ <div class="input-group">
555
+ <label>Texture Size: <span id="tex_val">4096</span></label>
556
+ <input type="range" id="tex_size" min="1024" max="4096" step="1024" value="4096" oninput="updateVal('tex_size')">
557
+ </div>
558
+ </div>
559
+ </div>
560
+ </div>
561
+ </div>
562
+
563
+ <!-- Right: Results -->
564
+ <div class="card">
565
+ <div class="result-container" id="result-container">
566
+ <div class="empty-state" id="empty-state">
567
+ <i data-lucide="rocket" style="width: 48px; height: 48px; margin-bottom: 1rem; opacity: 0.3;"></i>
568
+ <p>Upload an image to start generating</p>
569
+ </div>
570
+
571
+ <div class="viewer-container" id="preview-viewer">
572
+ <div id="frame-stack"></div>
573
+ <div class="viewer-controls">
574
+ <div class="mode-selector" id="mode-selector"></div>
575
+ <input type="range" id="angle-slider" min="0" max="7" value="0" step="1">
576
+ <div style="display: flex; justify-content: space-between; font-size: 0.7rem; color: var(--text-muted); padding: 0 5px;">
577
+ <span>-90°</span>
578
+ <span>Rotation Scrub</span>
579
+ <span>+90°</span>
580
+ </div>
581
+ </div>
582
+ </div>
583
+
584
+ <model-viewer id="final-model-viewer"
585
+ camera-controls
586
+ auto-rotate
587
+ shadow-intensity="1"
588
+ environment-image="neutral"
589
+ exposure="1">
590
+ </model-viewer>
591
+
592
+ <div id="result-actions" style="display: none; flex-direction: column; gap: 1rem;">
593
+ <button class="btn" id="extract-btn">
594
+ <i data-lucide="box"></i>
595
+ Extract & Optimize GLB
596
+ </button>
597
+ <a id="download-link" style="text-decoration: none;">
598
+ <button class="btn btn-secondary" id="download-btn" style="display: none;">
599
+ <i data-lucide="download"></i>
600
+ Download GLB
601
+ </button>
602
+ </a>
603
+ </div>
604
+ </div>
605
+ </div>
606
+ </div>
607
+
608
+ <!-- Examples -->
609
+ <div class="examples-section">
610
+ <h3>Try an Example</h3>
611
+ <div class="examples-grid" id="examples-grid">
612
+ <!-- Will be populated by JS -->
613
+ </div>
614
+ </div>
615
+ </div>
616
+
617
+ <div id="loading-overlay">
618
+ <div class="loader"></div>
619
+ <h3 id="loading-text">Generating Magic...</h3>
620
+ <p style="color: var(--text-muted); font-size: 0.9rem;">This usually takes about 60-90 seconds</p>
621
+ </div>
622
+
623
+ <div id="toast"></div>
624
+
625
+ <script type="module">
626
+ import { Client, handle_file } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js";
627
+
628
+ let client;
629
+ let currentFile = null;
630
+ let generationResult = null;
631
+ let currentMode = "shaded_forest";
632
+ let currentFrame = 0;
633
+
634
+ const MODES = [
635
+ { name: "Normal", key: "normal", color: "#888" },
636
+ { name: "Clay", key: "clay", color: "#d2b48c" },
637
+ { name: "Base Color", key: "base_color", color: "#fff" },
638
+ { name: "Forest", key: "shaded_forest", color: "#228b22" },
639
+ { name: "Sunset", key: "shaded_sunset", color: "#ff4500" },
640
+ { name: "Courtyard", key: "shaded_courtyard", color: "#4682b4" }
641
+ ];
642
+
643
+ async function init() {
644
+ lucide.createIcons();
645
+ try {
646
+ client = await Client.connect(window.location.origin);
647
+ setupEventListeners();
648
+ loadExamples();
649
+ } catch (err) {
650
+ console.error("Failed to connect to Gradio Server:", err);
651
+ showToast("Connection failed. Please refresh.");
652
+ }
653
+ }
654
+
655
+ function setupEventListeners() {
656
+ const dropZone = document.getElementById('drop-zone');
657
+ const fileInput = document.getElementById('file-input');
658
+
659
+ dropZone.ondragover = (e) => { e.preventDefault(); dropZone.classList.add('dragging'); };
660
+ dropZone.ondragleave = () => dropZone.classList.remove('dragging');
661
+ dropZone.ondrop = (e) => {
662
+ e.preventDefault();
663
+ dropZone.classList.remove('dragging');
664
+ if (e.dataTransfer.files.length) handleFile(e.dataTransfer.files[0]);
665
+ };
666
+
667
+ fileInput.onchange = (e) => {
668
+ if (e.target.files.length) handleFile(e.target.files[0]);
669
+ };
670
+
671
+ document.getElementById('generate-btn').onclick = generate;
672
+ document.getElementById('extract-btn').onclick = extract;
673
+ document.getElementById('angle-slider').oninput = (e) => {
674
+ currentFrame = parseInt(e.target.value);
675
+ updateFrameVisibility();
676
+ };
677
+
678
+ // Mode selector
679
+ const selector = document.getElementById('mode-selector');
680
+ MODES.forEach(m => {
681
+ const btn = document.createElement('div');
682
+ btn.className = 'mode-btn' + (m.key === currentMode ? ' active' : '');
683
+ btn.style.backgroundColor = m.color;
684
+ btn.title = m.name;
685
+ btn.onclick = () => {
686
+ currentMode = m.key;
687
+ document.querySelectorAll('.mode-btn').forEach(b => b.classList.remove('active'));
688
+ btn.classList.add('active');
689
+ updateFrameVisibility();
690
+ };
691
+ selector.appendChild(btn);
692
+ });
693
+ }
694
+
695
+ async function handleFile(file) {
696
+ currentFile = file;
697
+ const reader = new FileReader();
698
+ reader.onload = (e) => {
699
+ const preview = document.getElementById('preview-img');
700
+ const placeholder = document.getElementById('upload-placeholder');
701
+ preview.src = e.target.result;
702
+ preview.style.display = 'block';
703
+ placeholder.style.display = 'none';
704
+ document.getElementById('generate-btn').disabled = false;
705
+ };
706
+ reader.readAsDataURL(file);
707
+
708
+ // Auto-preprocess
709
+ try {
710
+ const result = await client.predict("/preprocess", { image: handle_file(file) });
711
+ // We don't necessarily need to do anything with the result yet,
712
+ // but it warms up the server.
713
+ } catch (err) {
714
+ console.error("Preprocess error:", err);
715
+ }
716
+ }
717
+
718
+ async function generate() {
719
+ if (!currentFile) return;
720
+
721
+ showLoading("Generating 3D Asset...");
722
+ try {
723
+ const params = {
724
+ image: handle_file(currentFile),
725
+ seed: parseInt(document.getElementById('seed').value),
726
+ resolution: parseInt(document.getElementById('resolution').value),
727
+ ss_guidance_strength: parseFloat(document.getElementById('ss_gs').value),
728
+ ss_sampling_steps: parseInt(document.getElementById('ss_steps').value),
729
+ shape_slat_guidance_strength: parseFloat(document.getElementById('shape_gs').value)
730
+ // Others use defaults
731
+ };
732
+
733
+ const result = await client.predict("/generate_3d", params);
734
+ generationResult = result.data[0];
735
+ renderPreview(generationResult.render_paths);
736
+
737
+ document.getElementById('empty-state').style.display = 'none';
738
+ document.getElementById('preview-viewer').style.display = 'block';
739
+ document.getElementById('result-actions').style.display = 'flex';
740
+ document.getElementById('final-model-viewer').style.display = 'none';
741
+ document.getElementById('download-btn').style.display = 'none';
742
+
743
+ hideLoading();
744
+ showToast("Generation complete!");
745
+ } catch (err) {
746
+ console.error("Generation error:", err);
747
+ hideLoading();
748
+ showToast("Generation failed. Check console.");
749
+ }
750
+ }
751
+
752
+ function renderPreview(renderPaths) {
753
+ const stack = document.getElementById('frame-stack');
754
+ stack.innerHTML = '';
755
+
756
+ Object.entries(renderPaths).forEach(([mode, files]) => {
757
+ files.forEach((file, i) => {
758
+ const img = document.getElementById(`f-${mode}-${i}`) || document.createElement('img');
759
+ img.src = file.url;
760
+ img.className = 'viewer-frame';
761
+ img.dataset.mode = mode;
762
+ img.dataset.frame = i;
763
+ img.id = `f-${mode}-${i}`;
764
+ if (!img.parentElement) stack.appendChild(img);
765
+ });
766
+ });
767
+ updateFrameVisibility();
768
+ }
769
+
770
+ function updateFrameVisibility() {
771
+ document.querySelectorAll('.viewer-frame').forEach(f => f.classList.remove('active'));
772
+ const active = document.getElementById(`f-${currentMode}-${currentFrame}`);
773
+ if (active) active.classList.add('active');
774
+ }
775
+
776
+ async function extract() {
777
+ if (!generationResult) return;
778
+
779
+ showLoading("Extracting & Optimizing GLB...");
780
+ try {
781
+ const params = {
782
+ state_path: generationResult.state_path,
783
+ decimation_target: parseInt(document.getElementById('decimation').value),
784
+ texture_size: parseInt(document.getElementById('tex_size').value)
785
+ };
786
+
787
+ const result = await client.predict("/extract_glb_api", params);
788
+ const glbUrl = result.data[0].url;
789
+
790
+ const viewer = document.getElementById('final-model-viewer');
791
+ viewer.src = glbUrl;
792
+ viewer.style.display = 'block';
793
+ document.getElementById('preview-viewer').style.display = 'none';
794
+
795
+ const dlBtn = document.getElementById('download-btn');
796
+ dlBtn.style.display = 'flex';
797
+ document.getElementById('download-link').href = glbUrl;
798
+ document.getElementById('download-link').download = "pixal3d_asset.glb";
799
+
800
+ hideLoading();
801
+ showToast("GLB Extracted successfully!");
802
+ } catch (err) {
803
+ console.error("Extraction error:", err);
804
+ hideLoading();
805
+ showToast("Extraction failed.");
806
+ }
807
+ }
808
+
809
+ function loadExamples() {
810
+ const grid = document.getElementById('examples-grid');
811
+ const examples = [
812
+ 'assets/example_image/0a34fae7ba57cb8870df5325b9c30ea474def1b0913c19c596655b85a79fdee4.webp',
813
+ 'assets/example_image/0e4984a9b3765ce80e9853443f9319ecedf90885c74b56cccfebc09402740f8a.webp',
814
+ 'assets/example_image/130c2b18f1651a70f8aa15b2c99f8dba29bb943044d92871f9223bd3e989e8b1.webp',
815
+ 'assets/example_image/22a868bac8e62511fccd2bc82ed31ae77ed31ae2a8a149be7150957f11b30c9b.webp',
816
+ 'assets/example_image/3903b87907a6b4947006e6fc7c0c64f40cd98932a02bf0ecf7d6dfae776f3a38.webp',
817
+ 'assets/example_image/4bc7abe209c8673dd3766ee4fad14d40acbed02d118e7629f645c60fd77313f1.webp'
818
+ ];
819
+
820
+ // Global UI helpers
821
+ window.toggleAdvanced = () => {
822
+ document.getElementById('advanced-panel').classList.toggle('visible');
823
+ };
824
+
825
+ window.updateVal = (id) => {
826
+ const val = document.getElementById(id).value;
827
+ let displayVal = val;
828
+ if (id === 'decimation') displayVal = parseInt(val).toLocaleString();
829
+ document.getElementById(id + '_val').textContent = displayVal;
830
+ };
831
+
832
+ window.randomizeSeed = () => {
833
+ document.getElementById('seed').value = Math.floor(Math.random() * 1000000);
834
+ };
835
+
836
+ function showLoading(text) {
837
+ document.getElementById('loading-text').textContent = text;
838
+ document.getElementById('loading-overlay').style.display = 'flex';
839
+ }
840
+
841
+ function hideLoading() {
842
+ document.getElementById('loading-overlay').style.display = 'none';
843
+ }
844
+
845
+ function showToast(msg) {
846
+ const toast = document.getElementById('toast');
847
+ toast.textContent = msg;
848
+ toast.style.display = 'block';
849
+ setTimeout(() => { toast.style.display = 'none'; }, 3000);
850
+ }
851
+
852
+ init();
853
+ </script>
854
+ </body>
855
+ </html>