"""DepthForge -- Gradio Space: photo -> CNC relief heightmap (16-bit PNG + STL). ZeroGPU orchestration (§6). ``import spaces`` before torch; a local shim keeps the app runnable off-Space. Only depth inference runs inside ``@spaces.GPU`` -- cleanup, detail-injection heightmap, and meshing are CPU work in the plain callback. """ from __future__ import annotations import logging import os import tempfile import time from typing import Optional, Tuple # --- ZeroGPU: import spaces BEFORE torch; shim so the app runs locally ------- # try: import spaces # type: ignore except ImportError: # local no-op shim class spaces: # type: ignore @staticmethod def GPU(*a, **k): def deco(f): return f return deco import gradio as gr import numpy as np from PIL import Image from core.backends import da3_available, get_backend, safe_infer from core.cleanup import clean_source from core.config import ( BACKEND_DA3_LARGE, CLEANUP, MESH, MODEL_CHOICES, RELIEF, ) from core.heightmap import ReliefParams, build_heightmap from core.io import write_png16 from core.mesh import heightmap_to_stl from core.presets import DEFAULT_PRESET, get_preset, preset_names logging.basicConfig(level=logging.INFO) logger = logging.getLogger("depthforge.app") # Map UI label -> internal backend key. _MODEL_LABEL_TO_KEY = {label: key for label, key in MODEL_CHOICES} _MODEL_KEY_TO_LABEL = {key: label for label, key in MODEL_CHOICES} _MODEL_LABELS = [label for label, _ in MODEL_CHOICES] _DEFAULT_MODEL_LABEL = _MODEL_LABELS[0] # --------------------------------------------------------------------------- # # GPU-decorated inference (ONLY model inference lives here) # --------------------------------------------------------------------------- # @spaces.GPU(duration=120) def _gpu_forward(backend, image: Image.Image) -> np.ndarray: """GPU-only forward pass (weights already downloaded/constructed on CPU).""" return backend.infer_loaded(image) # --------------------------------------------------------------------------- # # Main callback (CPU orchestration around the single GPU call) # --------------------------------------------------------------------------- # def generate( image: Optional[Image.Image], model_label: str, # cleanup trim_top: float, trim_bottom: float, trim_left: float, trim_right: float, denoise: bool, upscale: float, max_side: float, # relief invert: bool, detail_strength: float, detail_sigma: float, fine_strength: float, contrast_gamma: float, mask_thresh: float, bg_flatten: bool, smooth: float, fg_expand: bool, fg_bg_level: float, fg_local: float, # mesh width_mm: float, relief_mm: float, base_mm: float, mesh_step: float, progress: gr.Progress = gr.Progress(), ): """End-to-end: cleanup -> GPU depth -> heightmap -> PNG + STL.""" if image is None: raise gr.Error("Please upload a source image first.") t_all = time.time() backend_key = _MODEL_LABEL_TO_KEY.get(model_label, BACKEND_DA3_LARGE) # 1) Cleanup (CPU) progress(0.05, desc="Cleaning source") cleaned = clean_source( image, trim_top=trim_top, trim_bottom=trim_bottom, trim_left=trim_left, trim_right=trim_right, denoise=denoise, upscale=upscale, max_side=int(max_side), ) # 2) Depth inference (GPU only) progress(0.35, desc="Running depth model") t_gpu = time.time() # Weight download/construct happens on CPU inside safe_infer; only the # forward pass (_gpu_forward) runs under @spaces.GPU. depth_raw, meta = safe_infer(cleaned, backend_key, gpu_forward=_gpu_forward) gpu_secs = time.time() - t_gpu if meta.get("fell_back"): gr.Warning( f"{meta['requested']} unavailable — fell back to {meta['name']}." ) # 3) Heightmap (CPU, detail injection) progress(0.65, desc="Building relief heightmap") params = ReliefParams( invert=bool(invert), detail_strength=float(detail_strength), detail_sigma=float(detail_sigma), fine_strength=float(fine_strength), contrast_gamma=float(contrast_gamma), mask_thresh=float(mask_thresh), bg_flatten=bool(bg_flatten), smooth=float(smooth), fg_expand=bool(fg_expand), fg_bg_level=float(fg_bg_level), fg_local=float(fg_local), ) height_float, height16 = build_heightmap(depth_raw, cleaned, params) # 4) Exports (CPU) progress(0.85, desc="Writing PNG + STL") tmpdir = tempfile.mkdtemp(prefix="depthforge_") png_path = os.path.join(tmpdir, "depthforge_heightmap_16bit.png") write_png16(height16, png_path) stl_bytes = heightmap_to_stl( height_float, width_mm=float(width_mm), relief_mm=float(relief_mm), base_mm=float(base_mm), mesh_step=int(mesh_step), ) stl_path = os.path.join(tmpdir, "depthforge_relief.stl") with open(stl_path, "wb") as fh: fh.write(stl_bytes) # Preview image (8-bit view of the 16-bit map, for display only). preview = (height_float * 255.0).clip(0, 255).astype(np.uint8) preview_img = Image.fromarray(preview, mode="L") total_secs = time.time() - t_all status = ( f"Backend: {meta['name']} | License: {meta['license']}" f"{' (fallback)' if meta.get('fell_back') else ''}\n" f"Depth inference: {gpu_secs:.1f}s | Total: {total_secs:.1f}s\n" f"Heightmap: {height16.shape[1]}×{height16.shape[0]} px, 16-bit | " f"STL: {len(stl_bytes)/1024:.0f} KB (width {width_mm:.0f} mm, " f"relief {relief_mm:.0f} mm, base {base_mm:.0f} mm)" ) progress(1.0, desc="Done") gr.Info("Relief generated.") return preview_img, cleaned, png_path, stl_path, status # --------------------------------------------------------------------------- # # Preset wiring # --------------------------------------------------------------------------- # def apply_preset(name: str): """Return relief-slider updates for the selected preset (unset keys stay).""" p = get_preset(name) return ( gr.update(value=p["detail_strength"]) if "detail_strength" in p else gr.update(), gr.update(value=p["detail_sigma"]) if "detail_sigma" in p else gr.update(), gr.update(value=p["fine_strength"]) if "fine_strength" in p else gr.update(), gr.update(value=p["contrast_gamma"]) if "contrast_gamma" in p else gr.update(), gr.update(value=p["mask_thresh"]) if "mask_thresh" in p else gr.update(), gr.update(value=p["bg_flatten"]) if "bg_flatten" in p else gr.update(), gr.update(value=p["smooth"]) if "smooth" in p else gr.update(), ) def on_model_change(model_label: str): """Set the Invert toggle to the selected backend's default.""" key = _MODEL_LABEL_TO_KEY.get(model_label, BACKEND_DA3_LARGE) backend = get_backend(key) return gr.update(value=backend.invert_default) # --------------------------------------------------------------------------- # # UI # --------------------------------------------------------------------------- # HOW_IT_WORKS = """ **How it works.** A depth model estimates the 3D *form*; the **Sculpt (depth-led)** default keeps that smooth form dominant (strong foreground/background separation) and injects the photo's own fine texture (fabric, hair, folds) only subtly — so the result reads as a 3D sculpt, not a flat luminance emboss. Switch presets or raise *Detail strength* for a more engraved look. Exports a 16-bit heightmap and a watertight STL for CNC carving. **Models (quality tiers).** Default **DA3-Large 1.1 (Apache-2.0)** = best commercial-safe quality; **DA3-Base / DA3-Small (Apache)** are faster; **DA3 Nested-Giant** and **Depth-Anything-V2 Large** are **CC-BY-NC-4.0** — personal / non-commercial only. **V2-Small (Apache)** is the CPU-friendly guaranteed fallback. The active backend and its license are shown in Status. """ ASPIRE_STEPS = """ **Vectric Aspire 12.5 workflow** *Option A — heightmap (16-bit PNG):* 1. **Model → Import Bitmap / Import Component From File** and select the PNG. 2. Set the **model Z height** to your intended relief depth (e.g. 8 mm) and the **base/border** as needed. White = high, black = low. 3. Position/scale on the job; combine with **Merge High** if layering. *Option B — 3D model (STL):* 1. **Model → Import a Component / 3D Model** and select the STL. 2. Confirm units (mm) and orientation; the STL is a closed solid with a flat base. 3. Set material Z, then create **3D Roughing** (bull-nose/ballnose) and **3D Finishing** (ballnose, small stepover) toolpaths. Cut base-up with a flat bottom; the relief peaks at `base_mm + relief_mm`. """ def build_ui() -> gr.Blocks: with gr.Blocks(title="DepthForge", theme=gr.themes.Soft()) as demo: gr.Markdown( "# 🪵 DepthForge\n" "Turn a photo into a CNC-ready relief heightmap — **16-bit PNG + " "watertight STL** for Vectric Aspire 12.5." ) with gr.Accordion("How it works / License", open=False): gr.Markdown(HOW_IT_WORKS) with gr.Row(): # ---------------- Left: input & controls ---------------- # with gr.Column(scale=1): with gr.Accordion("Source", open=True): image_in = gr.Image(label="Photo", type="pil", sources=["upload", "clipboard"]) model_dd = gr.Dropdown( _MODEL_LABELS, value=_DEFAULT_MODEL_LABEL, label="Depth model", ) preset_dd = gr.Dropdown( preset_names(), value=DEFAULT_PRESET, label="Preset", ) with gr.Accordion("Cleanup", open=False): trim_top = gr.Slider(0, 25, value=CLEANUP.trim_top, step=0.5, label="Trim top %") trim_bottom = gr.Slider(0, 25, value=CLEANUP.trim_bottom, step=0.5, label="Trim bottom %") trim_left = gr.Slider(0, 25, value=CLEANUP.trim_left, step=0.5, label="Trim left %") trim_right = gr.Slider(0, 25, value=CLEANUP.trim_right, step=0.5, label="Trim right %") denoise = gr.Checkbox(value=CLEANUP.denoise, label="Denoise (median 3×3)") upscale = gr.Slider(1.0, 4.0, value=CLEANUP.upscale, step=0.5, label="Upscale ×") max_side = gr.Slider(512, 4096, value=CLEANUP.max_side, step=128, label="Max side (px)") with gr.Accordion("Subject / depth (Sculpt)", open=True): fg_expand = gr.Checkbox(value=RELIEF.fg_expand, label="Foreground pop (isolate subject + expand its depth)") fg_local = gr.Slider(0.0, 4.0, value=RELIEF.fg_local, step=0.1, label="Subject relief (local depth contrast)") fg_bg_level = gr.Slider(0.0, 0.5, value=RELIEF.fg_bg_level, step=0.01, label="Background level (0 = flat)") with gr.Accordion("Relief detail", open=False): invert = gr.Checkbox(value=RELIEF.invert, label="Invert (near → high)") detail_strength = gr.Slider(0.0, 1.5, value=RELIEF.detail_strength, step=0.01, label="Detail strength (coarse)") detail_sigma = gr.Slider(1.0, 20.0, value=RELIEF.detail_sigma, step=0.5, label="Detail sigma") fine_strength = gr.Slider(0.0, 1.0, value=RELIEF.fine_strength, step=0.01, label="Fine strength") contrast_gamma = gr.Slider(0.3, 2.0, value=RELIEF.contrast_gamma, step=0.01, label="Contrast gamma") mask_thresh = gr.Slider(0.0, 0.5, value=RELIEF.mask_thresh, step=0.01, label="Mask threshold") bg_flatten = gr.Checkbox(value=RELIEF.bg_flatten, label="Flatten background") smooth = gr.Slider(0.0, 5.0, value=RELIEF.smooth, step=0.1, label="Smooth") with gr.Accordion("3D / STL export", open=False): width_mm = gr.Slider(20, 400, value=MESH.width_mm, step=1, label="Width (mm, longer side)") relief_mm = gr.Slider(1, 50, value=MESH.relief_mm, step=0.5, label="Relief depth (mm)") base_mm = gr.Slider(0, 30, value=MESH.base_mm, step=0.5, label="Base thickness (mm)") mesh_step = gr.Slider(1, 8, value=MESH.mesh_step, step=1, label="Mesh step (1=full, 2=half, 4=quarter)") generate_btn = gr.Button("Generate relief", variant="primary") # ---------------- Right: outputs ---------------- # with gr.Column(scale=1): height_preview = gr.Image(label="Heightmap preview (grayscale)", type="pil") cleaned_preview = gr.Image(label="Cleaned source", type="pil", height=180) png_file = gr.File(label="Download 16-bit PNG") stl_file = gr.File(label="Download STL") status = gr.Textbox(label="Status", lines=4, interactive=False) with gr.Accordion("Aspire 12.5 instructions", open=False): gr.Markdown(ASPIRE_STEPS) # Examples row example_dir = os.path.join(os.path.dirname(__file__), "examples") example_imgs = [] if os.path.isdir(example_dir): for fn in sorted(os.listdir(example_dir)): if fn.lower().endswith((".png", ".jpg", ".jpeg", ".webp")): example_imgs.append([os.path.join(example_dir, fn)]) if example_imgs: gr.Examples(examples=example_imgs, inputs=[image_in], label="Examples") # ---- wiring ---- preset_dd.change( apply_preset, inputs=[preset_dd], outputs=[detail_strength, detail_sigma, fine_strength, contrast_gamma, mask_thresh, bg_flatten, smooth], ) model_dd.change(on_model_change, inputs=[model_dd], outputs=[invert]) generate_btn.click( generate, inputs=[ image_in, model_dd, trim_top, trim_bottom, trim_left, trim_right, denoise, upscale, max_side, invert, detail_strength, detail_sigma, fine_strength, contrast_gamma, mask_thresh, bg_flatten, smooth, fg_expand, fg_bg_level, fg_local, width_mm, relief_mm, base_mm, mesh_step, ], outputs=[height_preview, cleaned_preview, png_file, stl_file, status], ) return demo demo = build_ui() if __name__ == "__main__": if not da3_available(): logger.warning( "DA3 package not importable in this environment — DA3-Base will " "fall back to Depth-Anything-V2 Small at inference time." ) demo.queue().launch()