Show real GLB extraction stages and elapsed time; add seven TRELLIS.2 HDR preview modes
Browse files- .gitattributes +3 -0
- app.py +2 -2
- assets/preview-lighting/README.md +6 -0
- assets/preview-lighting/basecolor.png +0 -0
- assets/preview-lighting/clay.png +0 -0
- assets/preview-lighting/courtyard.exr +3 -0
- assets/preview-lighting/forest.exr +3 -0
- assets/preview-lighting/hdri_courtyard.png +0 -0
- assets/preview-lighting/hdri_forest.png +0 -0
- assets/preview-lighting/hdri_studio.png +0 -0
- assets/preview-lighting/hdri_sunset.png +0 -0
- assets/preview-lighting/license.txt +15 -0
- assets/preview-lighting/normal.png +0 -0
- assets/preview-lighting/studio.exr +0 -0
- assets/preview-lighting/sunset.exr +3 -0
- hf_space/extraction_progress.py +52 -0
- hf_space/native_build.py +6 -2
- hf_space/ovoxel_preview.js +3 -1
- hf_space/ovoxel_preview.py +24 -23
- hf_space/ovoxel_results.py +49 -6
- hf_space/timing.py +14 -0
- hf_space/ui.css +16 -3
- hf_space/ui.py +40 -18
.gitattributes
CHANGED
|
@@ -50,3 +50,6 @@ assets/supratex_fighter_typography.mp4 filter=lfs diff=lfs merge=lfs -text
|
|
| 50 |
assets/fighter.png filter=lfs diff=lfs merge=lfs -text
|
| 51 |
|
| 52 |
assets/urban_gardener.png filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
assets/fighter.png filter=lfs diff=lfs merge=lfs -text
|
| 51 |
|
| 52 |
assets/urban_gardener.png filter=lfs diff=lfs merge=lfs -text
|
| 53 |
+
assets/preview-lighting/courtyard.exr filter=lfs diff=lfs merge=lfs -text
|
| 54 |
+
assets/preview-lighting/forest.exr filter=lfs diff=lfs merge=lfs -text
|
| 55 |
+
assets/preview-lighting/sunset.exr filter=lfs diff=lfs merge=lfs -text
|
app.py
CHANGED
|
@@ -48,8 +48,8 @@ def process_image_generator(input_image, seed=42, generation_token=None, progres
|
|
| 48 |
|
| 49 |
|
| 50 |
@spaces.GPU(duration=600, size='large')
|
| 51 |
-
def extract_glb(identifier):
|
| 52 |
-
return ovoxel_results.extract(identifier)
|
| 53 |
|
| 54 |
|
| 55 |
from hf_space.ui import build_ui, CSS, THEME
|
|
|
|
| 48 |
|
| 49 |
|
| 50 |
@spaces.GPU(duration=600, size='large')
|
| 51 |
+
def extract_glb(identifier, extraction_token=None):
|
| 52 |
+
return ovoxel_results.extract(identifier, extraction_token)
|
| 53 |
|
| 54 |
|
| 55 |
from hf_space.ui import build_ui, CSS, THEME
|
assets/preview-lighting/README.md
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
HDR environment maps and render-mode swatches from Microsoft's TRELLIS.2 Space,
|
| 2 |
+
revision ebf60b20fc5a4607f90a1c11c0aab0ceeda5429d:
|
| 3 |
+
https://huggingface.co/spaces/microsoft/TRELLIS.2/tree/ebf60b20fc5a4607f90a1c11c0aab0ceeda5429d/assets
|
| 4 |
+
|
| 5 |
+
Environment-map attribution is retained in license.txt. The four HDR files are
|
| 6 |
+
used by the original TRELLIS.2 PbrMeshRenderer / nvdiffrec environment-light backend.
|
assets/preview-lighting/basecolor.png
ADDED
|
assets/preview-lighting/clay.png
ADDED
|
assets/preview-lighting/courtyard.exr
ADDED
|
|
Git LFS Details
|
assets/preview-lighting/forest.exr
ADDED
|
|
Git LFS Details
|
assets/preview-lighting/hdri_courtyard.png
ADDED
|
assets/preview-lighting/hdri_forest.png
ADDED
|
assets/preview-lighting/hdri_studio.png
ADDED
|
assets/preview-lighting/hdri_sunset.png
ADDED
|
assets/preview-lighting/license.txt
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
All HDRIs are licensed as CC0.
|
| 2 |
+
|
| 3 |
+
These were created by Greg Zaal (Poly Haven https://polyhaven.com).
|
| 4 |
+
Originals used for each HDRI:
|
| 5 |
+
- City: https://polyhaven.com/a/portland_landing_pad
|
| 6 |
+
- Courtyard: https://polyhaven.com/a/courtyard
|
| 7 |
+
- Forest: https://polyhaven.com/a/ninomaru_teien
|
| 8 |
+
- Interior: https://polyhaven.com/a/hotel_room
|
| 9 |
+
- Night: Probably https://polyhaven.com/a/moonless_golf
|
| 10 |
+
- Studio: Probably https://polyhaven.com/a/studio_small_01
|
| 11 |
+
- Sunrise: https://polyhaven.com/a/spruit_sunrise
|
| 12 |
+
- Sunset: https://polyhaven.com/a/venice_sunset
|
| 13 |
+
|
| 14 |
+
1K resolution of each was taken, and compressed with oiiotool:
|
| 15 |
+
oiiotool input.exr --ch R,G,B -d float --compression dwab:300 --clamp:min=0.0:max=32000.0 -o output.exr
|
assets/preview-lighting/normal.png
ADDED
|
assets/preview-lighting/studio.exr
ADDED
|
|
assets/preview-lighting/sunset.exr
ADDED
|
|
Git LFS Details
|
hf_space/extraction_progress.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Cross-process GLB extraction stages, polled independently of the GPU queue."""
|
| 2 |
+
import html
|
| 3 |
+
import time
|
| 4 |
+
from hf_space import generation_progress as records
|
| 5 |
+
|
| 6 |
+
# These are completed workflow stages, not a prediction of remaining time.
|
| 7 |
+
STAGES = {
|
| 8 |
+
'load': (0, 'Loading o-voxel data'),
|
| 9 |
+
'export_prepare': (1, 'Preparing GLB export'),
|
| 10 |
+
'mesh_prepare': (1, 'Preparing the mesh'),
|
| 11 |
+
'initial_cleanup': (1, 'Cleaning the mesh'),
|
| 12 |
+
'bvh': (2, 'Building surface acceleration'),
|
| 13 |
+
'cleanup': (2, 'Remeshing and simplifying'),
|
| 14 |
+
'uv_unwrap': (3, 'Unwrapping UVs'),
|
| 15 |
+
'texture_sample': (4, 'Baking 4096 px PBR textures'),
|
| 16 |
+
'finalize': (5, 'Preparing PBR materials'),
|
| 17 |
+
'file_write': (6, 'Writing GLB'),
|
| 18 |
+
'validate': (7, 'Validating GLB'),
|
| 19 |
+
}
|
| 20 |
+
TOTAL = 8
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def start():
|
| 24 |
+
token = records.start()
|
| 25 |
+
records.update(token, label='Waiting for GPU', completed=0)
|
| 26 |
+
return token
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def stage(token, name):
|
| 30 |
+
if name in STAGES:
|
| 31 |
+
completed, label = STAGES[name]
|
| 32 |
+
records.update(token, status='running', label=label, completed=completed)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def finish(token, failed=False):
|
| 36 |
+
records.update(token, status='failed' if failed else 'complete',
|
| 37 |
+
label='Extraction failed — please retry' if failed else 'GLB ready to download',
|
| 38 |
+
**({} if failed else {'completed': TOTAL}), finished_unix=time.time())
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def status_html(token):
|
| 42 |
+
record = records._read(token)
|
| 43 |
+
if not record:
|
| 44 |
+
return ''
|
| 45 |
+
elapsed = max(0, record.get('finished_unix', time.time()) - record['started_unix'])
|
| 46 |
+
completed = record.get('completed', 0)
|
| 47 |
+
label = html.escape(record.get('label', 'Waiting for GPU'))
|
| 48 |
+
status = html.escape(record.get('status', 'queued'), quote=True)
|
| 49 |
+
return (f'<div class="st-extraction-progress {status}" role="status" aria-live="polite">'
|
| 50 |
+
f'<div><strong>{label}</strong><span>Elapsed: {int(elapsed)} s</span></div>'
|
| 51 |
+
f'<progress max="{TOTAL}" value="{completed}" aria-label="GLB extraction stages"></progress>'
|
| 52 |
+
f'<small>{completed} / {TOTAL} stages complete</small></div>')
|
hf_space/native_build.py
CHANGED
|
@@ -113,10 +113,14 @@ def build():
|
|
| 113 |
wheels.mkdir(exist_ok=True)
|
| 114 |
pip = [sys.executable, '-m', 'pip']
|
| 115 |
completed = []
|
| 116 |
-
|
|
|
|
|
|
|
|
|
|
| 117 |
update(stage='compiling', current=name, completed=completed)
|
| 118 |
# Wheel distribution names differ from the public repository names.
|
| 119 |
-
distribution = {'CuMesh': 'cumesh', 'FlexGEMM': 'flex_gemm', 'nvdiffrast': 'nvdiffrast',
|
|
|
|
| 120 |
cached = list(persistent.glob(distribution + '-*.whl'))
|
| 121 |
if cached:
|
| 122 |
local = wheels / cached[0].name
|
|
|
|
| 113 |
wheels.mkdir(exist_ok=True)
|
| 114 |
pip = [sys.executable, '-m', 'pip']
|
| 115 |
completed = []
|
| 116 |
+
preview_sources = ((
|
| 117 |
+
'nvdiffrec', 'https://github.com/JeffreyXiang/nvdiffrec.git',
|
| 118 |
+
'b296927cc7fd01c2ac1087c8065c4d7248f72da4', ''),)
|
| 119 |
+
for name, url, revision, subdir in (*SOURCES, *preview_sources):
|
| 120 |
update(stage='compiling', current=name, completed=completed)
|
| 121 |
# Wheel distribution names differ from the public repository names.
|
| 122 |
+
distribution = {'CuMesh': 'cumesh', 'FlexGEMM': 'flex_gemm', 'nvdiffrast': 'nvdiffrast',
|
| 123 |
+
'TRELLIS.2': 'o_voxel', 'nvdiffrec': 'nvdiffrec_render'}[name]
|
| 124 |
cached = list(persistent.glob(distribution + '-*.whl'))
|
| 125 |
if cached:
|
| 126 |
local = wheels / cached[0].name
|
hf_space/ovoxel_preview.js
CHANGED
|
@@ -6,7 +6,9 @@
|
|
| 6 |
image.hidden = image.dataset.mode !== root.dataset.mode || image.dataset.view !== root.dataset.view;
|
| 7 |
});
|
| 8 |
root.querySelectorAll('.st-ovoxel-modes button').forEach(button => {
|
| 9 |
-
|
|
|
|
|
|
|
| 10 |
});
|
| 11 |
};
|
| 12 |
document.addEventListener('input', event => {
|
|
|
|
| 6 |
image.hidden = image.dataset.mode !== root.dataset.mode || image.dataset.view !== root.dataset.view;
|
| 7 |
});
|
| 8 |
root.querySelectorAll('.st-ovoxel-modes button').forEach(button => {
|
| 9 |
+
const active = button.dataset.mode === root.dataset.mode;
|
| 10 |
+
button.classList.toggle('active', active);
|
| 11 |
+
button.setAttribute('aria-pressed', String(active));
|
| 12 |
});
|
| 13 |
};
|
| 14 |
document.addEventListener('input', event => {
|
hf_space/ovoxel_preview.py
CHANGED
|
@@ -1,36 +1,36 @@
|
|
| 1 |
"""Render the final decoded o-voxel directly, before UV or GLB extraction.
|
| 2 |
|
| 3 |
-
Uses TRELLIS.2's
|
| 4 |
-
lighting so preview does not require an extra HDR environment dependency.
|
| 5 |
"""
|
| 6 |
import json
|
| 7 |
from pathlib import Path
|
| 8 |
from hf_space.timing import span
|
| 9 |
|
| 10 |
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
|
|
|
|
|
|
| 27 |
|
| 28 |
|
| 29 |
def save_and_render(output, representation):
|
| 30 |
import numpy as np
|
| 31 |
import torch
|
| 32 |
from PIL import Image
|
| 33 |
-
from supratex.utils.render_utils import render_snapshot
|
| 34 |
if representation.vertices.device.type != 'cuda':
|
| 35 |
raise RuntimeError('Multiview rendering requires CUDA')
|
| 36 |
output = Path(output)
|
|
@@ -54,20 +54,21 @@ def save_and_render(output, representation):
|
|
| 54 |
extrinsics = [matrix@rotation for matrix in extrinsics]
|
| 55 |
with torch.no_grad():
|
| 56 |
frames = render_frames(representation, extrinsics, intrinsics,
|
| 57 |
-
options={'resolution':768,'ssaa':1,'peel_layers':2}, envmap=
|
| 58 |
folder = output.parent/'views'
|
| 59 |
folder.mkdir(exist_ok=True)
|
| 60 |
-
modes = ('
|
| 61 |
for mode in modes:
|
| 62 |
for index, pixels in enumerate(frames[mode]):
|
| 63 |
mask = frames['mask'][index][..., :1].astype(np.float32)/255
|
| 64 |
# Composite the same neutral background in every view.
|
| 65 |
-
if mode
|
| 66 |
pixels = np.clip(pixels.astype(np.float32)+(1-mask)*25,0,255).astype(np.uint8)
|
| 67 |
else:
|
| 68 |
pixels = np.clip(pixels*mask+(1-mask)*25,0,255).astype(np.uint8)
|
| 69 |
Image.fromarray(pixels).save(folder/f'{mode}-{index:02d}.webp',quality=90)
|
| 70 |
(output.parent/'preview.json').write_text(json.dumps({
|
| 71 |
'views':count,'modes':list(modes),'resolution':768,'device':'cuda',
|
| 72 |
-
'renderer':'TRELLIS.2 PbrMeshRenderer','lighting':'
|
|
|
|
| 73 |
'automatic_glb_export':False}))
|
|
|
|
| 1 |
"""Render the final decoded o-voxel directly, before UV or GLB extraction.
|
| 2 |
|
| 3 |
+
Uses TRELLIS.2's PbrMeshRenderer and HDR environments for material inspection.
|
|
|
|
| 4 |
"""
|
| 5 |
import json
|
| 6 |
from pathlib import Path
|
| 7 |
from hf_space.timing import span
|
| 8 |
|
| 9 |
|
| 10 |
+
ENVIRONMENTS = ('studio', 'forest', 'sunset', 'courtyard')
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def load_environments():
|
| 14 |
+
import os
|
| 15 |
+
os.environ['OPENCV_IO_ENABLE_OPENEXR'] = '1'
|
| 16 |
+
import cv2
|
| 17 |
+
import torch
|
| 18 |
+
from supratex.renderers import EnvMap
|
| 19 |
+
folder = Path(__file__).resolve().parents[1]/'assets/preview-lighting'
|
| 20 |
+
environments = {}
|
| 21 |
+
for name in ENVIRONMENTS:
|
| 22 |
+
image = cv2.imread(str(folder/f'{name}.exr'), cv2.IMREAD_UNCHANGED)
|
| 23 |
+
if image is None:
|
| 24 |
+
raise RuntimeError(f'Could not load HDR environment: {name}')
|
| 25 |
+
environments[name] = EnvMap(torch.tensor(cv2.cvtColor(image, cv2.COLOR_BGR2RGB),
|
| 26 |
+
dtype=torch.float32, device='cuda'))
|
| 27 |
+
return environments
|
| 28 |
|
| 29 |
|
| 30 |
def save_and_render(output, representation):
|
| 31 |
import numpy as np
|
| 32 |
import torch
|
| 33 |
from PIL import Image
|
|
|
|
| 34 |
if representation.vertices.device.type != 'cuda':
|
| 35 |
raise RuntimeError('Multiview rendering requires CUDA')
|
| 36 |
output = Path(output)
|
|
|
|
| 54 |
extrinsics = [matrix@rotation for matrix in extrinsics]
|
| 55 |
with torch.no_grad():
|
| 56 |
frames = render_frames(representation, extrinsics, intrinsics,
|
| 57 |
+
options={'resolution':768,'ssaa':1,'peel_layers':2}, envmap=load_environments())
|
| 58 |
folder = output.parent/'views'
|
| 59 |
folder.mkdir(exist_ok=True)
|
| 60 |
+
modes = ('normal','clay','base_color',*(f'shaded_{name}' for name in ENVIRONMENTS))
|
| 61 |
for mode in modes:
|
| 62 |
for index, pixels in enumerate(frames[mode]):
|
| 63 |
mask = frames['mask'][index][..., :1].astype(np.float32)/255
|
| 64 |
# Composite the same neutral background in every view.
|
| 65 |
+
if mode.startswith('shaded_'):
|
| 66 |
pixels = np.clip(pixels.astype(np.float32)+(1-mask)*25,0,255).astype(np.uint8)
|
| 67 |
else:
|
| 68 |
pixels = np.clip(pixels*mask+(1-mask)*25,0,255).astype(np.uint8)
|
| 69 |
Image.fromarray(pixels).save(folder/f'{mode}-{index:02d}.webp',quality=90)
|
| 70 |
(output.parent/'preview.json').write_text(json.dumps({
|
| 71 |
'views':count,'modes':list(modes),'resolution':768,'device':'cuda',
|
| 72 |
+
'renderer':'TRELLIS.2 PbrMeshRenderer','lighting':'HDR image-based lighting',
|
| 73 |
+
'environments':list(ENVIRONMENTS),'default_mode':'shaded_forest',
|
| 74 |
'automatic_glb_export':False}))
|
hf_space/ovoxel_results.py
CHANGED
|
@@ -3,8 +3,20 @@ import json
|
|
| 3 |
from pathlib import Path
|
| 4 |
import re
|
| 5 |
import shutil
|
|
|
|
| 6 |
from urllib.parse import quote
|
| 7 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
ROOT = Path('/data/generated/ovoxel-results-v1')
|
| 9 |
|
| 10 |
|
|
@@ -30,21 +42,50 @@ def persist(source, report):
|
|
| 30 |
def preview_html(identifier):
|
| 31 |
root = directory(identifier)
|
| 32 |
metadata = json.loads((root/'preview.json').read_text())
|
|
|
|
|
|
|
|
|
|
| 33 |
images = []
|
| 34 |
for mode in metadata['modes']:
|
| 35 |
for view in range(metadata['views']):
|
| 36 |
url = '/gradio_api/file='+quote(str(root/'views'/f'{mode}-{view:02d}.webp'),safe='/')
|
| 37 |
-
hidden = '' if mode ==
|
| 38 |
images.append(f'<img src="{url}" data-mode="{mode}" data-view="{view}" alt="{mode.replace("_"," ")} view {view+1}"{hidden}>')
|
| 39 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
'<div class="st-ovoxel-frames">'+''.join(images)+'</div>'
|
| 41 |
-
'<div class="st-ovoxel-modes"
|
| 42 |
-
'<button type="button" data-mode="base_color">Base color</button>'
|
| 43 |
-
'<button type="button" data-mode="normal">Normal</button></div>'
|
| 44 |
f'<label class="st-ovoxel-angle">View angle <input type="range" min="0" max="{metadata["views"]-1}" value="0" step="1" aria-label="View angle"></label></div>')
|
| 45 |
|
| 46 |
|
| 47 |
-
def extract(identifier):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
"""Runs only inside the explicitly requested GPU extraction callback."""
|
| 49 |
root = directory(identifier)
|
| 50 |
if not (root/'ready.json').is_file():
|
|
@@ -67,6 +108,8 @@ def extract(identifier):
|
|
| 67 |
import time
|
| 68 |
started = time.monotonic()
|
| 69 |
_export(output, representation,4096,500000,'png',_runtime())
|
|
|
|
|
|
|
| 70 |
from hf_space.backend import validate_glb
|
| 71 |
metadata = validate_glb(output)
|
| 72 |
(root/'extraction.json').write_text(json.dumps({'status':'complete','device':'cuda',
|
|
|
|
| 3 |
from pathlib import Path
|
| 4 |
import re
|
| 5 |
import shutil
|
| 6 |
+
from html import escape
|
| 7 |
from urllib.parse import quote
|
| 8 |
|
| 9 |
+
PREVIEW_MODES = (
|
| 10 |
+
('normal', 'Normal', 'normal.png'),
|
| 11 |
+
('clay', 'Clay', 'clay.png'),
|
| 12 |
+
('base_color', 'Base color', 'basecolor.png'),
|
| 13 |
+
('shaded_studio', 'HDRI Studio', 'hdri_studio.png'),
|
| 14 |
+
('shaded_forest', 'HDRI Forest', 'hdri_forest.png'),
|
| 15 |
+
('shaded_sunset', 'HDRI Sunset', 'hdri_sunset.png'),
|
| 16 |
+
('shaded_courtyard', 'HDRI Courtyard', 'hdri_courtyard.png'),
|
| 17 |
+
('shaded', 'Studio', 'hdri_studio.png'), # Previously saved previews.
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
ROOT = Path('/data/generated/ovoxel-results-v1')
|
| 21 |
|
| 22 |
|
|
|
|
| 42 |
def preview_html(identifier):
|
| 43 |
root = directory(identifier)
|
| 44 |
metadata = json.loads((root/'preview.json').read_text())
|
| 45 |
+
default = metadata.get('default_mode', 'shaded' if 'shaded' in metadata['modes'] else 'shaded_forest')
|
| 46 |
+
if default not in metadata['modes']:
|
| 47 |
+
default = metadata['modes'][0]
|
| 48 |
images = []
|
| 49 |
for mode in metadata['modes']:
|
| 50 |
for view in range(metadata['views']):
|
| 51 |
url = '/gradio_api/file='+quote(str(root/'views'/f'{mode}-{view:02d}.webp'),safe='/')
|
| 52 |
+
hidden = '' if mode == default and view == 0 else ' hidden'
|
| 53 |
images.append(f'<img src="{url}" data-mode="{mode}" data-view="{view}" alt="{mode.replace("_"," ")} view {view+1}"{hidden}>')
|
| 54 |
+
buttons = []
|
| 55 |
+
for mode, label, icon in PREVIEW_MODES:
|
| 56 |
+
if mode not in metadata['modes']:
|
| 57 |
+
continue
|
| 58 |
+
# Inline the tiny swatches; preview frames remain individually cached files.
|
| 59 |
+
import base64
|
| 60 |
+
icon_path = Path(__file__).resolve().parents[1]/'assets/preview-lighting'/icon
|
| 61 |
+
swatch = base64.b64encode(icon_path.read_bytes()).decode('ascii')
|
| 62 |
+
active = mode == default
|
| 63 |
+
buttons.append(f'<button type="button" data-mode="{mode}" class="{"active" if active else ""}" '
|
| 64 |
+
f'title="{label}" aria-label="{label}" aria-pressed="{str(active).lower()}">'
|
| 65 |
+
f'<img src="data:image/png;base64,{swatch}" alt=""><span class="st-mode-name">{label}</span></button>')
|
| 66 |
+
return (f'<div class="st-ovoxel-preview" data-mode="{escape(default, quote=True)}" data-view="0">'
|
| 67 |
'<div class="st-ovoxel-frames">'+''.join(images)+'</div>'
|
| 68 |
+
'<div class="st-ovoxel-modes" role="group" aria-label="Render mode">'+''.join(buttons)+'</div>'
|
|
|
|
|
|
|
| 69 |
f'<label class="st-ovoxel-angle">View angle <input type="range" min="0" max="{metadata["views"]-1}" value="0" step="1" aria-label="View angle"></label></div>')
|
| 70 |
|
| 71 |
|
| 72 |
+
def extract(identifier, extraction_token=None):
|
| 73 |
+
from hf_space import extraction_progress as progress
|
| 74 |
+
from hf_space import generation_progress
|
| 75 |
+
from hf_space.timing import observe_stages
|
| 76 |
+
token = extraction_token if generation_progress.valid(extraction_token) else progress.start()
|
| 77 |
+
try:
|
| 78 |
+
progress.stage(token, 'load')
|
| 79 |
+
with observe_stages(lambda name: progress.stage(token, name)):
|
| 80 |
+
output = _extract(identifier, token)
|
| 81 |
+
progress.finish(token)
|
| 82 |
+
return output
|
| 83 |
+
except Exception:
|
| 84 |
+
progress.finish(token, failed=True)
|
| 85 |
+
raise
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def _extract(identifier, token):
|
| 89 |
"""Runs only inside the explicitly requested GPU extraction callback."""
|
| 90 |
root = directory(identifier)
|
| 91 |
if not (root/'ready.json').is_file():
|
|
|
|
| 108 |
import time
|
| 109 |
started = time.monotonic()
|
| 110 |
_export(output, representation,4096,500000,'png',_runtime())
|
| 111 |
+
from hf_space import extraction_progress
|
| 112 |
+
extraction_progress.stage(token, 'validate')
|
| 113 |
from hf_space.backend import validate_glb
|
| 114 |
metadata = validate_glb(output)
|
| 115 |
(root/'extraction.json').write_text(json.dumps({'status':'complete','device':'cuda',
|
hf_space/timing.py
CHANGED
|
@@ -9,6 +9,17 @@ import time
|
|
| 9 |
import uuid
|
| 10 |
|
| 11 |
_current = ContextVar('supratex_timing_span', default=None)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
|
| 14 |
def _sync_cuda():
|
|
@@ -33,6 +44,9 @@ def _record(path, data):
|
|
| 33 |
|
| 34 |
@contextmanager
|
| 35 |
def span(name):
|
|
|
|
|
|
|
|
|
|
| 36 |
path = os.environ.get('SUPRATEX_TIMING_PATH')
|
| 37 |
if not path:
|
| 38 |
yield
|
|
|
|
| 9 |
import uuid
|
| 10 |
|
| 11 |
_current = ContextVar('supratex_timing_span', default=None)
|
| 12 |
+
_observer = ContextVar('supratex_stage_observer', default=None)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
@contextmanager
|
| 16 |
+
def observe_stages(callback):
|
| 17 |
+
"""Report actual stage starts within this request, even without a timing log."""
|
| 18 |
+
token = _observer.set(callback)
|
| 19 |
+
try:
|
| 20 |
+
yield
|
| 21 |
+
finally:
|
| 22 |
+
_observer.reset(token)
|
| 23 |
|
| 24 |
|
| 25 |
def _sync_cuda():
|
|
|
|
| 44 |
|
| 45 |
@contextmanager
|
| 46 |
def span(name):
|
| 47 |
+
callback = _observer.get()
|
| 48 |
+
if callback is not None:
|
| 49 |
+
callback(name)
|
| 50 |
path = os.environ.get('SUPRATEX_TIMING_PATH')
|
| 51 |
if not path:
|
| 52 |
yield
|
hf_space/ui.css
CHANGED
|
@@ -180,12 +180,25 @@
|
|
| 180 |
#st-app .st-ovoxel-frames{position:relative;flex:1;min-height:230px;overflow:hidden}
|
| 181 |
#st-app .st-ovoxel-frames img{position:absolute;inset:0;width:100%;height:100%;object-fit:contain}
|
| 182 |
#st-app .st-ovoxel-frames img[hidden]{display:none!important}
|
| 183 |
-
#st-app .st-ovoxel-modes{display:flex;justify-content:center;gap:
|
| 184 |
-
#st-app .st-ovoxel-modes button{padding:
|
| 185 |
-
#st-app .st-ovoxel-modes button
|
|
|
|
|
|
|
|
|
|
| 186 |
#st-app .st-ovoxel-angle{display:flex;align-items:center;gap:14px;color:#aaa;font-size:12px}
|
| 187 |
#st-app .st-ovoxel-angle input{flex:1;accent-color:#f2cd70}
|
| 188 |
#st-extract{flex:0 0 auto!important}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 189 |
#st-app .st-extraction-note{font-size:14px;line-height:1.6;color:#999;text-align:center}
|
| 190 |
@media(max-width:700px){#st-ovoxel{height:370px;flex:auto!important}}
|
| 191 |
#st-output-panel{position:relative}
|
|
|
|
| 180 |
#st-app .st-ovoxel-frames{position:relative;flex:1;min-height:230px;overflow:hidden}
|
| 181 |
#st-app .st-ovoxel-frames img{position:absolute;inset:0;width:100%;height:100%;object-fit:contain}
|
| 182 |
#st-app .st-ovoxel-frames img[hidden]{display:none!important}
|
| 183 |
+
#st-app .st-ovoxel-modes{display:flex;justify-content:center;gap:12px;flex-wrap:wrap;padding:4px}
|
| 184 |
+
#st-app .st-ovoxel-modes button{position:relative;padding:2px;width:34px;height:34px;border:2px solid transparent;border-radius:50%;background:#252523;color:#ccc;cursor:pointer}
|
| 185 |
+
#st-app .st-ovoxel-modes button img{width:100%;height:100%;border-radius:50%;object-fit:cover}
|
| 186 |
+
#st-app .st-ovoxel-modes button.active{border-color:#f2cd70;box-shadow:0 0 0 2px #f2cd7024}
|
| 187 |
+
#st-app .st-ovoxel-modes button:focus-visible{outline:2px solid #f2cd70;outline-offset:4px}
|
| 188 |
+
#st-app .st-mode-name{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%)}
|
| 189 |
#st-app .st-ovoxel-angle{display:flex;align-items:center;gap:14px;color:#aaa;font-size:12px}
|
| 190 |
#st-app .st-ovoxel-angle input{flex:1;accent-color:#f2cd70}
|
| 191 |
#st-extract{flex:0 0 auto!important}
|
| 192 |
+
#st-download,#st-extraction-progress{flex:0 0 auto!important}
|
| 193 |
+
#st-app .st-extraction-progress{display:flex;flex-direction:column;gap:8px;padding:12px 16px;border:1px solid #484438;border-radius:10px;background:#22211e;color:#ddd8cd;font-size:12px}
|
| 194 |
+
#st-app .st-extraction-progress>div{display:flex;justify-content:space-between;gap:12px;flex-wrap:wrap}
|
| 195 |
+
#st-app .st-extraction-progress strong{font-weight:500}
|
| 196 |
+
#st-app .st-extraction-progress span{color:#aaa;font-variant-numeric:tabular-nums}
|
| 197 |
+
#st-app .st-extraction-progress progress{width:100%;height:5px;accent-color:#f2cd70;border:0;border-radius:5px;overflow:hidden;background:#35322b}
|
| 198 |
+
#st-app .st-extraction-progress progress::-webkit-progress-bar{background:#35322b}
|
| 199 |
+
#st-app .st-extraction-progress progress::-webkit-progress-value{background:#f2cd70}
|
| 200 |
+
#st-app .st-extraction-progress small{color:#aaa;font-size:11px}
|
| 201 |
+
#st-app .st-extraction-progress.failed{border-color:#c36f67}
|
| 202 |
#st-app .st-extraction-note{font-size:14px;line-height:1.6;color:#999;text-align:center}
|
| 203 |
@media(max-width:700px){#st-ovoxel{height:370px;flex:auto!important}}
|
| 204 |
#st-output-panel{position:relative}
|
hf_space/ui.py
CHANGED
|
@@ -3,7 +3,7 @@ from pathlib import Path
|
|
| 3 |
import os
|
| 4 |
import gradio as gr
|
| 5 |
from PIL import Image
|
| 6 |
-
from hf_space import generation_progress, startup_progress
|
| 7 |
|
| 8 |
ROOT = Path(__file__).resolve().parents[1]
|
| 9 |
CSS = Path(__file__).with_name('ui.css').read_text(encoding='utf-8')
|
|
@@ -102,6 +102,8 @@ def build_ui(generate, deployment_status, *, cached_result, default_image=None,
|
|
| 102 |
input_present = gr.State(default_image is not None)
|
| 103 |
generated_asset = gr.Textbox(value='',visible=False)
|
| 104 |
progress_timer = gr.Timer(1, active=False)
|
|
|
|
|
|
|
| 105 |
with gr.Column(elem_id='st-app'):
|
| 106 |
html(HEADER, elem_id='st-header')
|
| 107 |
with gr.Row(elem_id='st-workspace', equal_height=False):
|
|
@@ -138,22 +140,25 @@ def build_ui(generate, deployment_status, *, cached_result, default_image=None,
|
|
| 138 |
clear_color=(0.098, 0.098, 0.098, 1), elem_id='st-model')
|
| 139 |
render_output = html('',visible='hidden',elem_id='st-ovoxel')
|
| 140 |
extract_btn = gr.Button('Extract GLB',interactive=False,elem_id='st-extract')
|
|
|
|
|
|
|
| 141 |
html('<div class="st-extraction-note">Initial geometry generation can take longer. GLB extraction can also be slow.</div>',elem_id='st-extraction-note')
|
| 142 |
status_output = gr.JSON(visible=False)
|
| 143 |
refresh = gr.Button(visible=False)
|
| 144 |
|
| 145 |
def select_example(current_phase, evt: gr.SelectData):
|
| 146 |
-
if current_phase
|
| 147 |
-
raise gr.Error('Please wait for the current
|
| 148 |
path = examples[int(evt.index)][0]
|
| 149 |
with Image.open(path) as image:
|
| 150 |
model = cached_result(image, 42)
|
| 151 |
return (path, 42, gr.update(value=model, visible=True if model is not None else 'hidden'),
|
| 152 |
gr.update(visible=True if model is None else 'hidden'), 'idle', gr.update(value='', visible='hidden'),
|
| 153 |
-
gr.update(value='',visible='hidden'),'',gr.update(interactive=False)
|
|
|
|
| 154 |
|
| 155 |
if examples:
|
| 156 |
-
gallery.select(select_example, [phase], [input_image, seed, mesh_output, empty, phase, estimate,render_output,generated_asset,extract_btn],
|
| 157 |
api_visibility='private', queue=False)
|
| 158 |
if examples_status:
|
| 159 |
example_trigger = gr.Button(visible=False)
|
|
@@ -163,12 +168,12 @@ def build_ui(generate, deployment_status, *, cached_result, default_image=None,
|
|
| 163 |
state = deployment_status()
|
| 164 |
usable = available(state, image, current_seed)
|
| 165 |
status = gr.update(value='', visible='hidden') if usable and current_phase != 'failed' else status_label(state, current_phase)
|
| 166 |
-
return state, gr.update(interactive=usable and current_phase
|
| 167 |
|
| 168 |
def sync_startup(current_phase, has_image):
|
| 169 |
state = deployment_status()
|
| 170 |
usable = ready(state) and has_image
|
| 171 |
-
return state, gr.update(interactive=usable and current_phase
|
| 172 |
|
| 173 |
def begin(image, current_seed):
|
| 174 |
if image is None:
|
|
@@ -178,7 +183,8 @@ def build_ui(generate, deployment_status, *, cached_result, default_image=None,
|
|
| 178 |
gr.update(value=None, visible='hidden'), gr.update(visible=True),
|
| 179 |
gr.update(value=generation_progress.overlay_html(token, estimated_seconds), visible=True),
|
| 180 |
token, gr.update(active=True), generation_progress.status_html(token),
|
| 181 |
-
gr.update(value='',visible='hidden'),'',gr.update(interactive=False)
|
|
|
|
| 182 |
|
| 183 |
def finish(image, current_seed):
|
| 184 |
usable = available(deployment_status(), image, current_seed)
|
|
@@ -202,7 +208,7 @@ def build_ui(generate, deployment_status, *, cached_result, default_image=None,
|
|
| 202 |
return gr.update(value=rendered, visible=True), identifier
|
| 203 |
|
| 204 |
start = generate_btn.click(begin, [input_image, seed],
|
| 205 |
-
[phase, generate_btn, live_status, mesh_output, empty, estimate, generation_token, progress_timer, progress_state,render_output,generated_asset,extract_btn],
|
| 206 |
queue=False, api_visibility='private')
|
| 207 |
run = start.then(generate_preview, [input_image, seed, generation_token], [render_output,generated_asset], api_name='generate',
|
| 208 |
concurrency_limit=1, concurrency_id='supra-tex-gpu', show_progress='hidden')
|
|
@@ -214,15 +220,31 @@ def build_ui(generate, deployment_status, *, cached_result, default_image=None,
|
|
| 214 |
def begin_extract(identifier):
|
| 215 |
if not identifier:
|
| 216 |
raise gr.Error('Generate a new asset before extracting GLB.')
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 226 |
progress_timer.tick(generation_progress.status_html, [generation_token], progress_state,
|
| 227 |
queue=False, api_visibility='private', show_progress='hidden')
|
| 228 |
refresh.click(deployment_status, [], status_output, api_name='deployment_status', queue=False)
|
|
|
|
| 3 |
import os
|
| 4 |
import gradio as gr
|
| 5 |
from PIL import Image
|
| 6 |
+
from hf_space import generation_progress, startup_progress, extraction_progress
|
| 7 |
|
| 8 |
ROOT = Path(__file__).resolve().parents[1]
|
| 9 |
CSS = Path(__file__).with_name('ui.css').read_text(encoding='utf-8')
|
|
|
|
| 102 |
input_present = gr.State(default_image is not None)
|
| 103 |
generated_asset = gr.Textbox(value='',visible=False)
|
| 104 |
progress_timer = gr.Timer(1, active=False)
|
| 105 |
+
extraction_token = gr.State('')
|
| 106 |
+
extraction_timer = gr.Timer(1, active=False)
|
| 107 |
with gr.Column(elem_id='st-app'):
|
| 108 |
html(HEADER, elem_id='st-header')
|
| 109 |
with gr.Row(elem_id='st-workspace', equal_height=False):
|
|
|
|
| 140 |
clear_color=(0.098, 0.098, 0.098, 1), elem_id='st-model')
|
| 141 |
render_output = html('',visible='hidden',elem_id='st-ovoxel')
|
| 142 |
extract_btn = gr.Button('Extract GLB',interactive=False,elem_id='st-extract')
|
| 143 |
+
extraction_status = html('', visible='hidden', elem_id='st-extraction-progress')
|
| 144 |
+
download_btn = gr.DownloadButton('Download GLB', visible='hidden', elem_id='st-download')
|
| 145 |
html('<div class="st-extraction-note">Initial geometry generation can take longer. GLB extraction can also be slow.</div>',elem_id='st-extraction-note')
|
| 146 |
status_output = gr.JSON(visible=False)
|
| 147 |
refresh = gr.Button(visible=False)
|
| 148 |
|
| 149 |
def select_example(current_phase, evt: gr.SelectData):
|
| 150 |
+
if current_phase in ('running', 'extracting'):
|
| 151 |
+
raise gr.Error('Please wait for the current operation to finish.')
|
| 152 |
path = examples[int(evt.index)][0]
|
| 153 |
with Image.open(path) as image:
|
| 154 |
model = cached_result(image, 42)
|
| 155 |
return (path, 42, gr.update(value=model, visible=True if model is not None else 'hidden'),
|
| 156 |
gr.update(visible=True if model is None else 'hidden'), 'idle', gr.update(value='', visible='hidden'),
|
| 157 |
+
gr.update(value='',visible='hidden'),'',gr.update(interactive=False),
|
| 158 |
+
gr.update(value=None,visible='hidden'),gr.update(value='',visible='hidden'))
|
| 159 |
|
| 160 |
if examples:
|
| 161 |
+
gallery.select(select_example, [phase], [input_image, seed, mesh_output, empty, phase, estimate,render_output,generated_asset,extract_btn,download_btn,extraction_status],
|
| 162 |
api_visibility='private', queue=False)
|
| 163 |
if examples_status:
|
| 164 |
example_trigger = gr.Button(visible=False)
|
|
|
|
| 168 |
state = deployment_status()
|
| 169 |
usable = available(state, image, current_seed)
|
| 170 |
status = gr.update(value='', visible='hidden') if usable and current_phase != 'failed' else status_label(state, current_phase)
|
| 171 |
+
return state, gr.update(interactive=usable and current_phase not in ('running', 'extracting')), status, image is not None
|
| 172 |
|
| 173 |
def sync_startup(current_phase, has_image):
|
| 174 |
state = deployment_status()
|
| 175 |
usable = ready(state) and has_image
|
| 176 |
+
return state, gr.update(interactive=usable and current_phase not in ('running', 'extracting')), status_label(state, current_phase)
|
| 177 |
|
| 178 |
def begin(image, current_seed):
|
| 179 |
if image is None:
|
|
|
|
| 183 |
gr.update(value=None, visible='hidden'), gr.update(visible=True),
|
| 184 |
gr.update(value=generation_progress.overlay_html(token, estimated_seconds), visible=True),
|
| 185 |
token, gr.update(active=True), generation_progress.status_html(token),
|
| 186 |
+
gr.update(value='',visible='hidden'),'',gr.update(interactive=False),
|
| 187 |
+
gr.update(value=None,visible='hidden'),gr.update(value='',visible='hidden'))
|
| 188 |
|
| 189 |
def finish(image, current_seed):
|
| 190 |
usable = available(deployment_status(), image, current_seed)
|
|
|
|
| 208 |
return gr.update(value=rendered, visible=True), identifier
|
| 209 |
|
| 210 |
start = generate_btn.click(begin, [input_image, seed],
|
| 211 |
+
[phase, generate_btn, live_status, mesh_output, empty, estimate, generation_token, progress_timer, progress_state,render_output,generated_asset,extract_btn,download_btn,extraction_status],
|
| 212 |
queue=False, api_visibility='private')
|
| 213 |
run = start.then(generate_preview, [input_image, seed, generation_token], [render_output,generated_asset], api_name='generate',
|
| 214 |
concurrency_limit=1, concurrency_id='supra-tex-gpu', show_progress='hidden')
|
|
|
|
| 220 |
def begin_extract(identifier):
|
| 221 |
if not identifier:
|
| 222 |
raise gr.Error('Generate a new asset before extracting GLB.')
|
| 223 |
+
token = extraction_progress.start()
|
| 224 |
+
return ('extracting', gr.update(interactive=False), gr.update(interactive=False),
|
| 225 |
+
token, gr.update(active=True),
|
| 226 |
+
gr.update(value=extraction_progress.status_html(token),visible=True),
|
| 227 |
+
gr.update(value=None,visible='hidden'))
|
| 228 |
+
def display_extraction(identifier, token=None):
|
| 229 |
+
return gr.update(value=extract_glb(identifier, token),visible=True)
|
| 230 |
+
def finish_extract(token, image, current_seed, failed=False):
|
| 231 |
+
extraction_progress.finish(token, failed=failed)
|
| 232 |
+
return ('complete', gr.update(interactive=True),
|
| 233 |
+
gr.update(interactive=available(deployment_status(),image,current_seed)),
|
| 234 |
+
gr.update(active=False),
|
| 235 |
+
gr.update(value=extraction_progress.status_html(token),visible=True))
|
| 236 |
+
extraction = extract_btn.click(begin_extract,[generated_asset],
|
| 237 |
+
[phase,extract_btn,generate_btn,extraction_token,extraction_timer,extraction_status,download_btn],
|
| 238 |
+
queue=False,api_visibility='private')
|
| 239 |
+
extraction = extraction.then(display_extraction,[generated_asset,extraction_token],download_btn,api_name='extract_glb',
|
| 240 |
+
concurrency_limit=1,concurrency_id='supra-tex-gpu',show_progress='hidden')
|
| 241 |
+
extraction.success(finish_extract,[extraction_token,input_image,seed],
|
| 242 |
+
[phase,extract_btn,generate_btn,extraction_timer,extraction_status],queue=False,api_visibility='private')
|
| 243 |
+
extraction.failure(lambda token,image,current_seed:finish_extract(token,image,current_seed,failed=True),
|
| 244 |
+
[extraction_token,input_image,seed], [phase,extract_btn,generate_btn,extraction_timer,extraction_status],
|
| 245 |
+
queue=False,api_visibility='private')
|
| 246 |
+
extraction_timer.tick(extraction_progress.status_html,[extraction_token],extraction_status,
|
| 247 |
+
queue=False,api_visibility='private',show_progress='hidden')
|
| 248 |
progress_timer.tick(generation_progress.status_html, [generation_token], progress_state,
|
| 249 |
queue=False, api_visibility='private', show_progress='hidden')
|
| 250 |
refresh.click(deployment_status, [], status_output, api_name='deployment_status', queue=False)
|