Spaces:
Sleeping
Sleeping
Add /normal16 endpoint: raw float16 normals (.npy) to avoid 8-bit quantisation grain
c6a2f5f verified | #!/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: | |
| def get_token(): return None | |
| def save_token(token): pass | |
| 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 | |
| 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 | |
| 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 | |
| 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) | |