Reza2kn's picture
Make interactive WebGPU demo the main app
98a3578 verified
Raw
History Blame Contribute Delete
9.13 kB
from __future__ import annotations
import json
import os
from pathlib import Path
from typing import Any
import gradio as gr
import numpy as np
import onnxruntime as ort
from huggingface_hub import hf_hub_download, snapshot_download
from PIL import Image, ImageDraw
MODEL_REPO = os.getenv("PIXLRELIGHT_ONNX_REPO", "Reza2kn/PIXLRelight-ONNX")
MODEL_PATH = os.getenv("PIXLRELIGHT_ONNX_PATH")
MODEL_VARIANT = os.getenv("PIXLRELIGHT_ONNX_VARIANT", "int4").lower()
MODEL_FILES = {
"int4": "int4/pixlrelight_renderer_int4.onnx",
"fp16": "webgpu/pixlrelight_renderer_fp16.onnx",
"fp32": "pixlrelight_renderer.onnx",
}
ONNX_FILENAME = MODEL_FILES.get(MODEL_VARIANT, MODEL_FILES["int4"])
SIZE = 512
BASE_DIR = Path(__file__).resolve().parent
gr.set_static_paths([BASE_DIR])
SAMPLES = {
"Warm room": str(BASE_DIR / "samples" / "room00_source.png"),
"Studio chair": str(BASE_DIR / "samples" / "room01_source.png"),
}
DEFAULT_LIGHTS = [
{"x": 0.28, "y": 0.22, "radius": 0.42, "intensity": 1.25, "color": "#ffd08a"},
{"x": 0.78, "y": 0.62, "radius": 0.32, "intensity": 0.85, "color": "#86b7ff"},
]
CSS = """
.gradio-container {
max-width: none !important;
padding: 0 !important;
}
footer {display: none !important}
#webgpu-shell {
min-height: 100vh;
background: #f8fafc;
}
#webgpu-shell iframe {
display: block;
width: 100%;
height: 100vh;
min-height: 920px;
border: 0;
background: #f8fafc;
}
"""
def _hex_to_rgb01(value: str) -> np.ndarray:
value = (value or "#ffffff").strip().lstrip("#")
if len(value) != 6:
value = "ffffff"
return np.array([int(value[i : i + 2], 16) for i in (0, 2, 4)], dtype=np.float32) / 255.0
def _load_image(image: Any, sample_name: str | None = None) -> Image.Image:
if image is None:
image = SAMPLES.get(sample_name or "Warm room", next(iter(SAMPLES.values())))
if isinstance(image, Image.Image):
pil = image.convert("RGB")
else:
pil = Image.open(image).convert("RGB")
return pil
def _resize_square(pil: Image.Image) -> Image.Image:
pil = pil.convert("RGB")
w, h = pil.size
side = min(w, h)
left = (w - side) // 2
top = (h - side) // 2
pil = pil.crop((left, top, left + side, top + side))
return pil.resize((SIZE, SIZE), Image.Resampling.LANCZOS)
def _lights_from_state(state: str | list[dict[str, Any]] | None) -> list[dict[str, Any]]:
if state is None:
return [dict(x) for x in DEFAULT_LIGHTS]
if isinstance(state, str):
try:
state = json.loads(state)
except Exception:
return [dict(x) for x in DEFAULT_LIGHTS]
if not isinstance(state, list):
return [dict(x) for x in DEFAULT_LIGHTS]
clean = []
for light in state[:5]:
clean.append(
{
"x": float(np.clip(light.get("x", 0.5), 0.0, 1.0)),
"y": float(np.clip(light.get("y", 0.5), 0.0, 1.0)),
"radius": float(np.clip(light.get("radius", 0.35), 0.05, 1.0)),
"intensity": float(np.clip(light.get("intensity", 1.0), 0.0, 2.5)),
"color": str(light.get("color", "#ffffff")),
}
)
return clean or [dict(x) for x in DEFAULT_LIGHTS]
def render_light_map(state: str | list[dict[str, Any]] | None, selected: int = 0):
lights = _lights_from_state(state)
yy, xx = np.mgrid[0:SIZE, 0:SIZE].astype(np.float32)
light_map = np.zeros((SIZE, SIZE, 3), dtype=np.float32)
ambient = np.full_like(light_map, 0.055)
light_map += ambient
for light in lights:
cx = light["x"] * (SIZE - 1)
cy = light["y"] * (SIZE - 1)
sigma = max(1.0, light["radius"] * SIZE * 0.42)
falloff = np.exp(-((xx - cx) ** 2 + (yy - cy) ** 2) / (2.0 * sigma * sigma))
light_map += falloff[..., None] * _hex_to_rgb01(light["color"]) * light["intensity"]
light_map = light_map / max(1.0, float(light_map.max()))
preview = Image.fromarray((np.clip(light_map, 0, 1) * 255).astype(np.uint8), "RGB")
draw = ImageDraw.Draw(preview)
for idx, light in enumerate(lights):
cx = int(light["x"] * (SIZE - 1))
cy = int(light["y"] * (SIZE - 1))
r = 11 if idx == selected else 8
draw.ellipse((cx - r, cy - r, cx + r, cy + r), outline="white", width=3)
draw.ellipse((cx - r + 3, cy - r + 3, cx + r - 3, cy + r - 3), fill=tuple((_hex_to_rgb01(light["color"]) * 255).astype(int)))
return preview
def _make_intrinsics(source_arr: np.ndarray, state: str | list[dict[str, Any]] | None) -> np.ndarray:
light_img = np.asarray(render_light_map(state)).astype(np.float32) / 255.0
albedo = np.clip(source_arr * 0.85 + 0.15, 0, 1)
irradiance = light_img
residual = np.clip((light_img - 0.5) * 0.45 + source_arr * 0.12 + 0.5, 0, 1)
x = np.concatenate([albedo, irradiance, residual], axis=2)
return np.transpose(x, (2, 0, 1))[None].astype(np.float32)
_SESSION: ort.InferenceSession | None = None
def get_session() -> ort.InferenceSession:
global _SESSION
if _SESSION is None:
if MODEL_PATH:
model_path = MODEL_PATH
else:
repo_dir = snapshot_download(
MODEL_REPO,
allow_patterns=[ONNX_FILENAME, ONNX_FILENAME + ".data"],
)
model_path = str(Path(repo_dir) / ONNX_FILENAME)
providers = ["CPUExecutionProvider"]
_SESSION = ort.InferenceSession(model_path, providers=providers)
return _SESSION
def load_sample(sample_name: str):
pil = _load_image(None, sample_name)
state = json.dumps([dict(x) for x in DEFAULT_LIGHTS])
return pil, state, render_light_map(state, 0), 0, 0, 0.28, 0.22, 0.42, 1.25, "#ffd08a"
def select_light(selected: int, state: str):
lights = _lights_from_state(state)
selected = int(np.clip(selected, 0, len(lights) - 1))
light = lights[selected]
return selected, light["x"], light["y"], light["radius"], light["intensity"], light["color"], render_light_map(lights, selected)
def update_light(selected: int, x: float, y: float, radius: float, intensity: float, color: str, state: str):
lights = _lights_from_state(state)
selected = int(np.clip(selected, 0, len(lights) - 1))
lights[selected].update(x=float(x), y=float(y), radius=float(radius), intensity=float(intensity), color=color)
return json.dumps(lights), render_light_map(lights, selected)
def add_light(state: str):
lights = _lights_from_state(state)
if len(lights) < 5:
lights.append({"x": 0.5, "y": 0.35, "radius": 0.28, "intensity": 0.9, "color": "#ffffff"})
selected = len(lights) - 1
light = lights[selected]
return json.dumps(lights), selected, selected, light["x"], light["y"], light["radius"], light["intensity"], light["color"], render_light_map(lights, selected)
def remove_light(selected: int, state: str):
lights = _lights_from_state(state)
if len(lights) > 1:
lights.pop(int(np.clip(selected, 0, len(lights) - 1)))
selected = int(np.clip(selected, 0, len(lights) - 1))
light = lights[selected]
return json.dumps(lights), selected, selected, light["x"], light["y"], light["radius"], light["intensity"], light["color"], render_light_map(lights, selected)
def move_light(evt: gr.SelectData, selected: int, state: str):
lights = _lights_from_state(state)
selected = int(np.clip(selected, 0, len(lights) - 1))
x, y = evt.index
lights[selected]["x"] = float(np.clip(x / max(1, SIZE - 1), 0, 1))
lights[selected]["y"] = float(np.clip(y / max(1, SIZE - 1), 0, 1))
light = lights[selected]
return json.dumps(lights), light["x"], light["y"], render_light_map(lights, selected)
def relight(image, sample_name: str, state: str):
source = _resize_square(_load_image(image, sample_name))
source_arr = np.asarray(source).astype(np.float32) / 255.0
source_tensor = np.transpose(source_arr, (2, 0, 1))[None].astype(np.float32)
intrinsics = _make_intrinsics(source_arr, state)
outputs = get_session().run(
["rgb", "rgb_gain", "rgb_bias"],
{"source_images": source_tensor, "target_intrinsics": intrinsics},
)
pred = np.clip(outputs[0][0].transpose(1, 2, 0), 0, 1)
gain = outputs[1][0].transpose(1, 2, 0)
bias = outputs[2][0].transpose(1, 2, 0)
gain_vis = np.clip((gain - gain.min()) / max(1e-6, gain.max() - gain.min()), 0, 1)
bias_vis = np.clip((bias - bias.min()) / max(1e-6, bias.max() - bias.min()), 0, 1)
return (
Image.fromarray((pred * 255).astype(np.uint8), "RGB"),
Image.fromarray((gain_vis * 255).astype(np.uint8), "RGB"),
Image.fromarray((bias_vis * 255).astype(np.uint8), "RGB"),
)
with gr.Blocks(css=CSS, theme=gr.themes.Base()) as demo:
gr.HTML(
f"""
<div id="webgpu-shell">
<iframe
title="PIXLRelight WebGPU INT4 interactive light studio"
src="/gradio_api/file={BASE_DIR / 'webgpu.html'}"
allow="webgpu; fullscreen"
></iframe>
</div>
"""
)
if __name__ == "__main__":
demo.launch(show_error=True)