import spaces import os import tempfile import time from contextlib import nullcontext from functools import lru_cache from typing import Any import gradio as gr import numpy as np import rembg import torch from gradio_litmodel3d import LitModel3D from PIL import Image UPSAMPLER_THEME = gr.themes.Soft( primary_hue=gr.themes.colors.indigo, secondary_hue=gr.themes.colors.purple, neutral_hue=gr.themes.colors.slate, ).set( button_primary_background_fill="linear-gradient(135deg, #6366f1, #a855f7)", button_primary_background_fill_hover="linear-gradient(135deg, #5457e5, #9333ea)", button_primary_text_color="#ffffff", button_primary_border_color="*primary_500", ) UPSAMPLER_CSS = """ footer{display:none !important} .gradio-container{max-width:1000px !important; margin:0 auto !important} h1,h2,h3{font-family:system-ui,-apple-system,'Segoe UI',sans-serif} """ os.system( 'CPPFLAGS="-include utility" TORCH_CUDA_ARCH_LIST="12.0+PTX" USE_CUDA=1 ' "pip install -vv --no-build-isolation ./texture_baker ./uv_unwrapper" ) import sf3d.utils as sf3d_utils from sf3d.system import SF3D os.environ["GRADIO_TEMP_DIR"] = os.path.join(os.environ.get("TMPDIR", "/tmp"), "gradio") rembg_session = rembg.new_session() COND_WIDTH = 512 COND_HEIGHT = 512 COND_DISTANCE = 1.6 COND_FOVY_DEG = 40 BACKGROUND_COLOR = [0.5, 0.5, 0.5] # Cached. Doesn't change c2w_cond = sf3d_utils.default_cond_c2w(COND_DISTANCE) intrinsic, intrinsic_normed_cond = sf3d_utils.create_intrinsic_from_fov_deg( COND_FOVY_DEG, COND_HEIGHT, COND_WIDTH ) generated_files = [] # Delete previous gradio temp dir folder if os.path.exists(os.environ["GRADIO_TEMP_DIR"]): print(f"Deleting {os.environ['GRADIO_TEMP_DIR']}") import shutil shutil.rmtree(os.environ["GRADIO_TEMP_DIR"]) device = sf3d_utils.get_device() model = SF3D.from_pretrained( "stabilityai/stable-fast-3d", config_name="config.yaml", weight_name="model.safetensors", ) model.eval() model = model.to(device) def run_model(input_image, remesh_option, vertex_count, texture_size): start = time.time() with torch.no_grad(): with torch.autocast( device_type=device, dtype=torch.bfloat16 ) if "cuda" in device else nullcontext(): model_batch = create_batch(input_image) model_batch = {k: v.to(device) for k, v in model_batch.items()} trimesh_mesh, _glob_dict = model.generate_mesh( model_batch, texture_size, remesh_option, vertex_count ) trimesh_mesh = trimesh_mesh[0] # Create new tmp file tmp_file = tempfile.NamedTemporaryFile(delete=False, suffix=".glb") trimesh_mesh.export(tmp_file.name, file_type="glb", include_normals=True) generated_files.append(tmp_file.name) print("Generation took:", time.time() - start, "s") return tmp_file.name def create_batch(input_image: Image) -> dict[str, Any]: img_cond = ( torch.from_numpy( np.asarray(input_image.resize((COND_WIDTH, COND_HEIGHT))).astype(np.float32) / 255.0 ) .float() .clip(0, 1) ) mask_cond = img_cond[:, :, -1:] rgb_cond = torch.lerp( torch.tensor(BACKGROUND_COLOR)[None, None, :], img_cond[:, :, :3], mask_cond ) batch_elem = { "rgb_cond": rgb_cond, "mask_cond": mask_cond, "c2w_cond": c2w_cond.unsqueeze(0), "intrinsic_cond": intrinsic.unsqueeze(0), "intrinsic_normed_cond": intrinsic_normed_cond.unsqueeze(0), } # Add batch dim batched = {k: v.unsqueeze(0) for k, v in batch_elem.items()} return batched @lru_cache def checkerboard(squares: int, size: int, min_value: float = 0.5): base = np.zeros((squares, squares)) + min_value base[1::2, ::2] = 1 base[::2, 1::2] = 1 repeat_mult = size // squares return ( base.repeat(repeat_mult, axis=0) .repeat(repeat_mult, axis=1)[:, :, None] .repeat(3, axis=-1) ) def remove_background(input_image: Image) -> Image: return rembg.remove(input_image, session=rembg_session) def show_mask_img(input_image: Image) -> Image: img_numpy = np.array(input_image) alpha = img_numpy[:, :, 3] / 255.0 chkb = checkerboard(32, 512) * 255 new_img = img_numpy[..., :3] * alpha[:, :, None] + chkb * (1 - alpha[:, :, None]) return Image.fromarray(new_img.astype(np.uint8), mode="RGB") # Mesh generation alone runs in ~5-10s; the 60s default request was rejecting # anonymous visitors whose remaining ZeroGPU quota was below the request. @spaces.GPU(duration=20) def run_button( run_btn, input_image, background_state, foreground_ratio, remesh_option, vertex_count, texture_size, ): if run_btn == "Run": if torch.cuda.is_available(): torch.cuda.reset_peak_memory_stats() glb_file: str = run_model( background_state, remesh_option.lower(), vertex_count, texture_size ) if torch.cuda.is_available(): print("Peak Memory:", torch.cuda.max_memory_allocated() / 1024 / 1024, "MB") elif torch.backends.mps.is_available(): print( "Peak Memory:", torch.mps.driver_allocated_memory() / 1024 / 1024, "MB" ) return ( gr.update(), gr.update(), gr.update(), gr.update(), gr.update(value=glb_file, visible=True), gr.update(visible=True), ) elif run_btn == "Remove Background": rem_removed = remove_background(input_image) fr_res = sf3d_utils.resize_foreground( rem_removed, foreground_ratio, out_size=(COND_WIDTH, COND_HEIGHT) ) return ( gr.update(value="Run", visible=True), rem_removed, fr_res, gr.update(value=show_mask_img(fr_res), visible=True), gr.update(value=None, visible=False), gr.update(visible=False), ) def requires_bg_remove(image, fr): if image is None: return ( gr.update(visible=False, value="Run"), None, None, gr.update(value=None, visible=False), gr.update(visible=False), gr.update(visible=False), ) alpha_channel = np.array(image.getchannel("A")) min_alpha = alpha_channel.min() if min_alpha == 0: print("Already has alpha") fr_res = sf3d_utils.resize_foreground( image, fr, out_size=(COND_WIDTH, COND_HEIGHT) ) return ( gr.update(value="Run", visible=True), image, fr_res, gr.update(value=show_mask_img(fr_res), visible=True), gr.update(visible=False), gr.update(visible=False), ) return ( gr.update(value="Remove Background", visible=True), None, None, gr.update(value=None, visible=False), gr.update(visible=False), gr.update(visible=False), ) def update_foreground_ratio(img_proc, fr): foreground_res = sf3d_utils.resize_foreground( img_proc, fr, out_size=(COND_WIDTH, COND_HEIGHT) ) return ( foreground_res, gr.update(value=show_mask_img(foreground_res)), ) # Full pipeline (bg removal + mesh + texture bake) measured ~12s end to end; # 30s keeps 2.5x headroom without tripping visitors' quota check. @spaces.GPU(duration=30) def image_to_glb(input_image, foreground_ratio, remesh_option, vertex_count, texture_size): # One-call API pipeline for headless callers (Upsampler free tool): background # removal + foreground resize + mesh generation, all in a single GPU allocation. rem = remove_background(input_image) fr_res = sf3d_utils.resize_foreground( rem, foreground_ratio, out_size=(COND_WIDTH, COND_HEIGHT) ) return run_model(fr_res, remesh_option.lower(), vertex_count, texture_size) with gr.Blocks(theme=UPSAMPLER_THEME, css=UPSAMPLER_CSS) as demo: gr.HTML("""

Stable Fast 3D

Turn a photo into a textured 3D model (GLB) in seconds.

""") with gr.Row(): with gr.Column(): input_img = gr.Image( type="pil", label="Input Image", sources="upload", image_mode="RGBA" ) with gr.Accordion("Advanced Settings", open=False): foreground_ratio = gr.Slider( label="Foreground Ratio", minimum=0.5, maximum=1.0, value=0.85, step=0.05, ) remesh_option = gr.Radio( choices=["None", "Triangle", "Quad"], label="Remeshing", value="None", visible=True, ) vertex_count_slider = gr.Slider( label="Target Vertex Count", minimum=-1, maximum=20000, value=-1, visible=True, ) texture_size = gr.Slider( label="Texture Size", minimum=512, maximum=2048, value=1024, step=256, visible=True, ) run_btn = gr.Button("Convert to 3D", variant="primary") with gr.Column(): output_3d = LitModel3D( label="3D Model", visible=True, clear_color=[0.0, 0.0, 0.0, 0.0], tonemapping="aces", contrast=1.0, scale=1.0, ) # Hidden HDR env-map input kept to preserve the /lambda API endpoint. with gr.Column(visible=False) as hdr_row: hdr_illumination_file = gr.File( label="HDR Env Map", file_types=[".hdr"], file_count="single" ) hdr_illumination_file.change( lambda x: gr.update(env_map=x.name if x is not None else None), inputs=hdr_illumination_file, outputs=[output_3d], ) run_btn.click( image_to_glb, inputs=[ input_img, foreground_ratio, remesh_option, vertex_count_slider, texture_size, ], outputs=[output_3d], ) # Hidden API-only endpoint: raw image -> GLB in one call (bg removal included), # so headless callers don't need the stateful Remove-Background/Run UI flow. _api_in = gr.Image(type="pil", visible=False) _api_out = gr.File(visible=False) _api_btn = gr.Button(visible=False) _api_btn.click( image_to_glb, inputs=[_api_in, foreground_ratio, remesh_option, vertex_count_slider, texture_size], outputs=[_api_out], api_name="image_to_glb", ) gr.HTML("""

Stable Fast 3D converts a single image into a textured 3D model in seconds. It reconstructs the mesh, UV-unwraps it, and bakes albedo and material properties, then exports a game-ready GLB you can drop into Blender, Unity, Unreal, or a web viewer. A fast way to turn product shots, concept art, or AI-generated images into 3D assets.

Maintained by Upsampler. Check out the free image to 3D converter, no sign-up required.

""") demo.queue().launch(share=True)