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"""