Lotus_Depth / app.py
sciencellama's picture
Add /normal16 endpoint: raw float16 normals (.npy) to avoid 8-bit quantisation grain
c6a2f5f verified
Raw
History Blame Contribute Delete
7.74 kB
#!/usr/bin/env python3
"""Lotus 1 Space β€” depth + normal endpoints."""
# ── Compatibility shims for huggingface_hub >= 0.30 ──────────────────────────
# HfFolder and cached_download were removed in hub 0.30, but diffusers==0.28.0
# and gradio 5.x oauth module still import them. Restore stubs/aliases BEFORE
# any of those packages are imported.
import huggingface_hub as _hfhub
try:
_hfhub.HfFolder
except AttributeError:
class _HfFolderStub:
@staticmethod
def get_token(): return None
@staticmethod
def save_token(token): pass
@staticmethod
def delete_token(): pass
_hfhub.HfFolder = _HfFolderStub
try:
_hfhub.cached_download
except AttributeError:
from huggingface_hub import hf_hub_download
def _cached_download_compat(url_or_filename=None, *args, **kwargs):
# diffusers uses cached_download(url, ...) or cached_download(repo_id, ...)
# Best-effort redirect to hf_hub_download for repo-based calls
if url_or_filename and not url_or_filename.startswith("http"):
return hf_hub_download(url_or_filename, *args, **kwargs)
# For URL-based calls, just return the path as-is (shouldn't reach here at inference)
raise NotImplementedError(f"cached_download URL mode not supported: {url_or_filename}")
_hfhub.cached_download = _cached_download_compat
# Also patch the diffusers import path
import sys
if "huggingface_hub" in sys.modules:
sys.modules["huggingface_hub"].cached_download = _cached_download_compat
# ─────────────────────────────────────────────────────────────────────────────
import os
# ── Patch gradio_client bool-schema bug ──────────────────────────────────────
# gradio_client <= ~1.7.x has a bug where `"const" in schema` raises
# TypeError when schema is a bool (e.g. additionalProperties: true).
# This crashes GET /gradio_api/info, making the space unreachable via API.
# Monkey-patch _json_schema_to_python_type to guard against non-dict schemas.
try:
import gradio_client.utils as _gcu
_orig_j2p = _gcu._json_schema_to_python_type
def _safe_j2p(schema, defs=None):
if not isinstance(schema, dict):
return "Any"
return _orig_j2p(schema, defs)
_gcu._json_schema_to_python_type = _safe_j2p
except Exception as _e:
print(f"[warn] gradio_client patch failed: {_e}")
# ─────────────────────────────────────────────────────────────────────────────
import tempfile
import numpy as np
import spaces
import torch
import gradio as gr
from PIL import Image
from infer import load_pipe, infer_pipe
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
SEED = 3
_pipes = {}
def _get_pipe(task: str):
if task not in _pipes:
pipe_g, _pipe_d = load_pipe(task, device)
_pipes[task] = pipe_g
return _pipes[task]
def _save_16bit_png(img: Image.Image) -> str:
"""Save the depth map as a true 16-bit PNG and return the path.
colorize_depth_map (modified previously) already returns a uint16 PIL
Image. The default gr.Image output would re-encode it as 8-bit webp,
which destroys the upper byte and yields visible stepping in the
fabricated relief (~20 Β΅m per step over a 5 mm relief). Returning
a file path via gr.File bypasses Gradio's image processing entirely.
"""
arr = np.array(img)
fd, tmp_path = tempfile.mkstemp(suffix='_depth16.png')
os.close(fd)
if arr.dtype == np.uint16:
# Native 16-bit path β€” preserve all 65,536 levels
Image.fromarray(arr, mode="I;16").save(tmp_path, format="PNG")
elif arr.dtype == np.uint8:
# Up-promote to 16-bit (no extra precision, but consistent output)
arr16 = arr.astype(np.uint16) * 257 # 0->0, 255->65535
Image.fromarray(arr16, mode="I;16").save(tmp_path, format="PNG")
else:
# Float / other β€” normalise to 16-bit
a = arr.astype(np.float32)
rng = max(a.max() - a.min(), 1e-9)
a = (a - a.min()) / rng
Image.fromarray((a * 65535).astype(np.uint16),
mode="I;16").save(tmp_path, format="PNG")
return tmp_path
@spaces.GPU
def infer_depth(image_path):
pipe = _get_pipe("depth")
img = infer_pipe(pipe, image_path, "depth", SEED, device)
return _save_16bit_png(img)
def _save_png(img: Image.Image) -> str:
"""Save an RGB / L image as lossless PNG and return the path.
Used for the normal map: Gradio's gr.Image output would re-encode via
webp (lossy), and even tiny per-pixel noise on flat regions becomes
visible orange-peel artifacts after Poisson integration of the
normal-derived gradient field.
"""
fd, tmp_path = tempfile.mkstemp(suffix='_normal.png')
os.close(fd)
img.save(tmp_path, format="PNG")
return tmp_path
@spaces.GPU
def infer_normal(image_path):
pipe = _get_pipe("normal")
img = infer_pipe(pipe, image_path, "normal", SEED, device)
return _save_png(img)
def _save_normal_npy(arr) -> str:
"""Save the raw float normal prediction as a .npy (float16, exact).
The model computes in float16, so float16 storage is lossless and only
~6 MB for 1024x1024x3. Values are in [0,1] β€” the same convention as the
8-bit PNG, where the surface normal = value * 2 - 1. Returning the float
array bypasses the 8-bit quantisation that stair-steps the integrated
bas-relief into visible 'orange-peel' grain.
"""
fd, tmp_path = tempfile.mkstemp(suffix='_normal_f16.npy')
os.close(fd)
np.save(tmp_path, np.asarray(arr, dtype=np.float16))
return tmp_path
@spaces.GPU
def infer_normal16(image_path):
pipe = _get_pipe("normal")
_img, npy = infer_pipe(pipe, image_path, "normal", SEED, device,
return_float=True)
return _save_normal_npy(npy)
with gr.Blocks(title="Lotus 1 - Depth + Normal") as demo:
gr.Markdown("# Lotus 1 - Depth & Normal")
gr.Markdown("API: `/depth` returns a **16-bit grayscale PNG** depth map, "
"`/normal` returns a **lossless RGB PNG** normal map, and "
"`/normal16` returns the **raw float16 normal** as a `.npy` "
"(values in [0,1]; normal = value*2-1). All via gr.File so "
"Gradio doesn't re-encode them.")
with gr.Tab("Depth"):
d_in = gr.Image(label="Input", type="filepath")
d_out = gr.File(label="Depth (16-bit PNG)")
d_btn = gr.Button("Run depth")
d_btn.click(infer_depth, inputs=d_in, outputs=d_out, api_name="depth")
with gr.Tab("Normal"):
n_in = gr.Image(label="Input", type="filepath")
n_out = gr.File(label="Normal (lossless PNG)")
n_btn = gr.Button("Run normal")
n_btn.click(infer_normal, inputs=n_in, outputs=n_out, api_name="normal")
with gr.Tab("Normal (float16)"):
n16_in = gr.Image(label="Input", type="filepath")
n16_out = gr.File(label="Normal (float16 .npy)")
n16_btn = gr.Button("Run normal (float16)")
n16_btn.click(infer_normal16, inputs=n16_in, outputs=n16_out,
api_name="normal16")
if __name__ == "__main__":
demo.queue(max_size=10).launch(server_name="0.0.0.0", server_port=7860,
show_error=True)