depthforge / app.py
ajju1020's picture
Subject relief (local depth contrast) + Sculpt UI controls
44b2388 verified
Raw History Blame Contribute Delete
15 kB
"""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()