Spaces:
Runtime error
Runtime error
| """HuggingFace Spaces (Gradio) demo for Foveated Diffusion. | |
| Mirrors the behavior of `webgui/server.py` from the project repo, adapted to | |
| Gradio + an HF Spaces deployment: | |
| - LoRA checkpoints (no_fov, fov_random, fov_saliency, fov_bbox) are fetched | |
| from `bchao1/foveated_diffusion` via `hf_hub_download` on startup. Same | |
| fuse/unfuse machinery as the Flask server β switching is fast and bounded | |
| in VRAM. | |
| - The painted mask + "preset circle" mask spec are quantized to LR blocks | |
| under the same any-touch-upgrades-the-block rule used in `webgui/`. | |
| - Generation runs under the same fixed settings (1024x1024, 50 steps, soft | |
| foveation blend on, decode_mode=merge, prediction_type=clean). | |
| Layout (mirrors webgui/static/index.html): | |
| Left column = prompt, seed, LoRA, mask mode, draw/preset controls, Generate. | |
| Right column = side-by-side tokenization mask preview + generated image. | |
| """ | |
| from __future__ import annotations | |
| import io | |
| import os | |
| import sys | |
| import threading | |
| import time | |
| from types import SimpleNamespace | |
| # Match webgui/server.py: disable sageattention before importing torch-deps. | |
| sys.modules["sageattention"] = None | |
| # ZeroGPU runtime. MUST be imported before torch so that `spaces` can intercept | |
| # CUDA initialization during module load. On non-Spaces envs `spaces` isn't | |
| # installed, so we install a no-op shim that makes `@spaces.GPU(...)` a | |
| # transparent decorator. This keeps `python app.py` working locally. | |
| try: | |
| import spaces # type: ignore | |
| except ImportError: | |
| class _SpacesShim: | |
| def GPU(*args, **kwargs): | |
| def decorator(fn): | |
| return fn | |
| # @spaces.GPU (no parens): the first arg is the function itself. | |
| if args and callable(args[0]) and not kwargs: | |
| return args[0] | |
| return decorator | |
| spaces = _SpacesShim() # type: ignore[assignment] | |
| import numpy as np | |
| import torch | |
| import torch.nn.functional as F | |
| import gradio as gr | |
| from huggingface_hub import hf_hub_download | |
| from PIL import Image | |
| # Make the bundled `src/` package importable. | |
| # | |
| # On Spaces, `src/` is vendored into the Space root by `upload_space.py`, so | |
| # it lives next to app.py at `_HERE/src`. For local dev (running app.py from | |
| # the project repo before upload), `src/` only exists at the repo root, so we | |
| # also add `_HERE/../..` to sys.path. The second insert is a no-op on Spaces | |
| # (the path doesn't exist there). | |
| _HERE = os.path.dirname(os.path.abspath(__file__)) | |
| sys.path.insert(0, _HERE) | |
| _REPO_ROOT = os.path.normpath(os.path.join(_HERE, "..", "..")) | |
| if os.path.isdir(os.path.join(_REPO_ROOT, "src")): | |
| sys.path.insert(0, _REPO_ROOT) | |
| from diffsynth.core import load_state_dict # noqa: E402 | |
| from src.inference import load_pipeline # noqa: E402 | |
| from src.inference.visualize import create_tokenization_mask_vis # noqa: E402 | |
| from src.masks import create_foveation_mask_full_res # noqa: E402 | |
| os.environ.setdefault("TOKENIZERS_PARALLELISM", "false") | |
| # Fixed generation settings (per webgui spec). | |
| MODEL_ID = "black-forest-labs/FLUX.2-klein-base-4B" | |
| HEIGHT = 1024 | |
| WIDTH = 1024 | |
| NUM_INFERENCE_STEPS = 50 | |
| GUIDANCE_SCALE = 4.0 | |
| DECODE_MODE = "merge" | |
| PREDICTION_TYPE = "clean" | |
| LR_DOWNSAMPLE_FACTOR = 2 | |
| SOFT_FOVEATION_BLEND = True | |
| # Masks are tiny (1024x1024 fp32 ~ 4 MB) and the only thing they need to do | |
| # outside a `@spaces.GPU` call is feed the tokenization preview. Building them | |
| # on CPU keeps `refresh_tokenization` (fires on every slider move) off the GPU | |
| # entirely; we move them to `_pipe.device` once, inside `generate`, just before | |
| # the actual forward. | |
| MASK_DEVICE = "cpu" | |
| # LoRA registry. Files come from the bchao1/foveated_diffusion model repo. | |
| LORA_REPO = "bchao1/foveated_diffusion" | |
| LORA_FILES = { | |
| "no_fov": "image/no_fov.safetensors", | |
| "random": "image/fov_random.safetensors", | |
| "saliency": "image/fov_saliency.safetensors", | |
| # "bbox": "image/fov_bbox.safetensors", # disabled in this UI build | |
| } | |
| DEFAULT_LORA = "random" | |
| _pipe = None | |
| _lora_registry: dict = {} # name -> {"path": str, "state_dict": dict or None} | |
| _lora_order: list = [] | |
| # LoRA state. The dropdown (`lora_dd`) is the actual source of truth β its | |
| # value is plumbed into every handler (and `generate`) as an explicit Gradio | |
| # input, so it crosses the @spaces.GPU process boundary safely under either | |
| # fork or spawn. The two globals below are kept only for: | |
| # _selected_lora β parent-only book-keeping for `switch_lora`'s rollback on | |
| # an invalid dropdown value (defensive; not load-bearing). | |
| # _current_lora β worker-local. Tracks what's fused in *this* worker's | |
| # model copy so `_apply_selected_lora` can skip work when | |
| # the dropdown choice already matches. | |
| _selected_lora: str = DEFAULT_LORA | |
| _current_lora: str | None = None | |
| _lora_lock = threading.Lock() | |
| _job_lock = threading.Lock() # serializes generation + LoRA switching | |
| # --------------------------------------------------------------------------- | |
| # LoRA fuse / unfuse (ported verbatim from webgui/server.py) | |
| # --------------------------------------------------------------------------- | |
| def _convert_lora_sd(state_dict): | |
| loader = _pipe.lora_loader(torch_dtype=_pipe.torch_dtype, device="cpu") | |
| return loader.convert_state_dict(state_dict) | |
| def _apply_lora_delta(state_dict, sign): | |
| lora_layer_names = { | |
| k[: -len(".lora_B.weight")] | |
| for k in state_dict if k.endswith(".lora_B.weight") | |
| } | |
| updated = 0 | |
| for name, module in _pipe.dit.named_modules(): | |
| if name not in lora_layer_names: | |
| continue | |
| wb = state_dict[name + ".lora_B.weight"].to(device=_pipe.device, dtype=_pipe.torch_dtype) | |
| wa = state_dict[name + ".lora_A.weight"].to(device=_pipe.device, dtype=_pipe.torch_dtype) | |
| if wb.dim() == 4: | |
| wb = wb.squeeze(3).squeeze(2) | |
| wa = wa.squeeze(3).squeeze(2) | |
| delta = torch.mm(wb, wa).unsqueeze(2).unsqueeze(3) | |
| else: | |
| delta = torch.mm(wb, wa) | |
| base = module.weight.data | |
| base.add_(delta.to(device=base.device, dtype=base.dtype), alpha=float(sign)) | |
| updated += 1 | |
| return updated | |
| def _switch_lora_unlocked(target_name: str): | |
| global _current_lora | |
| if target_name == _current_lora: | |
| return | |
| if target_name not in _lora_registry: | |
| raise ValueError(f"unknown lora: {target_name}") | |
| if _current_lora is not None: | |
| cur = _lora_registry[_current_lora] | |
| n = _apply_lora_delta(cur["state_dict"], sign=-1) | |
| print(f"[lora] unfused {_current_lora} ({n} tensors)") | |
| nxt = _lora_registry[target_name] | |
| if nxt["state_dict"] is None: | |
| raw = load_state_dict(nxt["path"], torch_dtype=_pipe.torch_dtype) | |
| nxt["state_dict"] = _convert_lora_sd(raw) | |
| n = _apply_lora_delta(nxt["state_dict"], sign=+1) | |
| print(f"[lora] fused {target_name} ({n} tensors)") | |
| _current_lora = target_name | |
| def _fetch_lora_files() -> dict[str, str]: | |
| """hf_hub_download each LoRA from LORA_REPO; return {name: local_path}.""" | |
| out = {} | |
| for name, fn in LORA_FILES.items(): | |
| print(f"[lora] fetching {LORA_REPO}/{fn} β¦") | |
| out[name] = hf_hub_download(repo_id=LORA_REPO, filename=fn) | |
| return out | |
| def _load_pipe(): | |
| global _pipe | |
| # On HF Spaces we MUST pull FLUX.2 from HuggingFace Hub (not ModelScope, the | |
| # diffsynth default). The `preload_from_hub` block in README.md caches these | |
| # files during the Space build, so this call is just a fast cache lookup. | |
| # Locally, $DIFFSYNTH_MODEL_BASE_PATH points at a ModelScope checkout, so we | |
| # keep the diffsynth default there. | |
| if "SPACE_ID" in os.environ or os.environ.get("DIFFSYNTH_USE_HF") == "1": | |
| from diffsynth.pipelines.flux2_image import Flux2ImagePipeline, ModelConfig | |
| from src.diffsynth_fov import Flux2FoveatedImagePipeline | |
| print("[pipe] loading FLUX2 from HuggingFace Hub β¦") | |
| _pipe = Flux2FoveatedImagePipeline.from_pretrained( | |
| torch_dtype=torch.bfloat16, | |
| device="cuda" if torch.cuda.is_available() else "cpu", | |
| model_configs=[ | |
| ModelConfig(model_id=MODEL_ID, origin_file_pattern="transformer/*.safetensors", download_source="huggingface"), | |
| ModelConfig(model_id=MODEL_ID, origin_file_pattern="text_encoder/*.safetensors", download_source="huggingface"), | |
| ModelConfig(model_id=MODEL_ID, origin_file_pattern="vae/diffusion_pytorch_model.safetensors", download_source="huggingface"), | |
| ], | |
| tokenizer_config=ModelConfig(model_id=MODEL_ID, origin_file_pattern="tokenizer/", download_source="huggingface"), | |
| ) | |
| else: | |
| loader_args = SimpleNamespace( | |
| model_id=MODEL_ID, | |
| lora_checkpoint=None, | |
| dit_checkpoint=None, | |
| experiment="ours", | |
| ) | |
| _pipe = load_pipeline(loader_args) | |
| print(f"[pipe] loaded on {_pipe.device}") | |
| paths = _fetch_lora_files() | |
| for name in LORA_FILES: # preserve declared order | |
| _lora_registry[name] = {"path": paths[name], "state_dict": None} | |
| _lora_order.append(name) | |
| # Preload + convert all LoRA state dicts on CPU at startup. They stay in | |
| # parent memory and are visible (copy-on-write) to every `@spaces.GPU` | |
| # worker, so a cold worker doesn't pay disk-read + convert cost inside its | |
| # GPU window. ~hundreds of MB per LoRA in CPU RAM β fine. | |
| print("[lora] preloading state dicts on CPU β¦") | |
| for name in LORA_FILES: | |
| raw = load_state_dict(_lora_registry[name]["path"], torch_dtype=_pipe.torch_dtype) | |
| _lora_registry[name]["state_dict"] = _convert_lora_sd(raw) | |
| print("[lora] preload done") | |
| # NOTE: the actual fuse (`_apply_lora_delta`, which does `torch.mm` on | |
| # CUDA) is deferred to the first `@spaces.GPU` `generate` call. On | |
| # ZeroGPU the GPU is only attached inside @spaces.GPU-decorated | |
| # functions, AND those run in forked workers β so any fuse done outside | |
| # them either fails (no GPU) or doesn't propagate back to the parent. | |
| def _apply_selected_lora(lora_name: str): | |
| """Reconcile the worker's fused LoRA with `lora_name`. | |
| Must be called from inside an `@spaces.GPU` function. `lora_name` is | |
| passed in (from the dropdown via Gradio inputs) rather than read from a | |
| global β globals don't reliably cross the @spaces.GPU process boundary | |
| on ZeroGPU.""" | |
| if _current_lora == lora_name: | |
| return | |
| with _lora_lock: | |
| if _current_lora == lora_name: # double-checked | |
| return | |
| _switch_lora_unlocked(lora_name) | |
| # --------------------------------------------------------------------------- | |
| # Mask construction (ported from webgui/server.py) | |
| # --------------------------------------------------------------------------- | |
| def _quantize_to_lr_blocks(raw_mask: torch.Tensor): | |
| if raw_mask.shape != (HEIGHT, WIDTH): | |
| raw_mask = F.interpolate( | |
| raw_mask.unsqueeze(0).unsqueeze(0), size=(HEIGHT, WIDTH), mode="nearest", | |
| ).squeeze(0).squeeze(0) | |
| lr_h = HEIGHT // 16 // LR_DOWNSAMPLE_FACTOR | |
| lr_w = WIDTH // 16 // LR_DOWNSAMPLE_FACTOR | |
| lr_mask = F.adaptive_max_pool2d( | |
| (raw_mask > 0).float().unsqueeze(0).unsqueeze(0), (lr_h, lr_w), | |
| ) | |
| token_grid = F.interpolate( | |
| lr_mask, size=(HEIGHT // 16, WIDTH // 16), mode="nearest", | |
| ).squeeze(0).squeeze(0) | |
| full_res = F.interpolate( | |
| lr_mask, size=(HEIGHT, WIDTH), mode="nearest", | |
| ).squeeze(0).squeeze(0) | |
| return token_grid, full_res | |
| def _drawn_mask_to_tensors(rgba: np.ndarray): | |
| """ImageEditor output -> (token_grid_mask, full_res_mask). `rgba` is HxWx4 uint8.""" | |
| if rgba.ndim == 3 and rgba.shape[2] == 4: | |
| alpha = rgba[..., 3] | |
| elif rgba.ndim == 3: | |
| alpha = rgba.max(axis=2) | |
| else: | |
| alpha = rgba | |
| raw = torch.from_numpy((alpha > 0).astype(np.float32)).to(MASK_DEVICE) | |
| return _quantize_to_lr_blocks(raw) | |
| def _alpha_union_from_layers(layers) -> np.ndarray | None: | |
| """OR together alpha channels of all layers β gives painted region in RGBA layers | |
| even when the composite is opaque.""" | |
| if not layers: | |
| return None | |
| out = None | |
| for layer in layers: | |
| arr = np.asarray(layer) | |
| if arr.ndim != 3 or arr.shape[2] != 4 or arr.size == 0: | |
| continue | |
| a = arr[..., 3] | |
| out = a if out is None else np.maximum(out, a) | |
| return out | |
| def _preset_mask_to_tensors(cx: float, cy: float, r: float): | |
| full_res = create_foveation_mask_full_res( | |
| HEIGHT, WIDTH, (cx, cy), r, "circular", MASK_DEVICE, | |
| ) | |
| return _quantize_to_lr_blocks(full_res) | |
| def _empty_mask_tensors(): | |
| token = torch.zeros(HEIGHT // 16, WIDTH // 16, device=MASK_DEVICE, dtype=torch.float32) | |
| full = torch.zeros(HEIGHT, WIDTH, device=MASK_DEVICE, dtype=torch.float32) | |
| return token, full | |
| def _full_hr_mask_tensors(): | |
| """All-HR mask β every token is foveal. Used with the no-fov LoRA.""" | |
| token = torch.ones(HEIGHT // 16, WIDTH // 16, device=MASK_DEVICE, dtype=torch.float32) | |
| full = torch.ones(HEIGHT, WIDTH, device=MASK_DEVICE, dtype=torch.float32) | |
| return token, full | |
| def _build_mask(lora_name: str, mode: str, editor_value, cx: float, cy: float, r: float): | |
| """Return (token_grid_mask, full_res_mask, is_empty). | |
| `lora_name` is passed explicitly (from the Gradio dropdown component, not | |
| a global) so the call works across the @spaces.GPU process boundary even | |
| if ZeroGPU uses spawn semantics.""" | |
| # The no-fov LoRA was trained without foveation conditioning β it expects | |
| # a full-HR token mask and ignores the circle controls entirely. | |
| if lora_name == "no_fov": | |
| t, f = _full_hr_mask_tensors() | |
| return t, f, False | |
| if mode == "Preset (circle)": | |
| if r <= 0: | |
| return (*_empty_mask_tensors(), True) | |
| t, f = _preset_mask_to_tensors(float(cx), float(cy), float(r)) | |
| return t, f, bool(f.sum().item() == 0) | |
| # Drawn. ImageEditor returns {"background": ..., "layers": [...], "composite": ...} | |
| # We MUST use the layers (not the composite): Gradio's composite has alpha=255 | |
| # everywhere (opaque background), so `alpha > 0` matches every pixel and the | |
| # whole image registers as HR. The layer RGBA arrays preserve true paint alpha. | |
| if editor_value is None: | |
| return (*_empty_mask_tensors(), True) | |
| if isinstance(editor_value, dict): | |
| layers = editor_value.get("layers") or [] | |
| alpha = _alpha_union_from_layers(layers) | |
| if alpha is None: | |
| # No usable layers; fall back to composite alpha (rare). | |
| composite = editor_value.get("composite") | |
| if composite is None: | |
| return (*_empty_mask_tensors(), True) | |
| arr = np.asarray(composite) | |
| if arr.size == 0: | |
| return (*_empty_mask_tensors(), True) | |
| t, f = _drawn_mask_to_tensors(arr) | |
| return t, f, bool(f.sum().item() == 0) | |
| raw = torch.from_numpy((alpha > 0).astype(np.float32)).to(MASK_DEVICE) | |
| t, f = _quantize_to_lr_blocks(raw) | |
| return t, f, bool(f.sum().item() == 0) | |
| # Bare numpy array β treat as composite-like. | |
| arr = np.asarray(editor_value) | |
| if arr.size == 0: | |
| return (*_empty_mask_tensors(), True) | |
| t, f = _drawn_mask_to_tensors(arr) | |
| return t, f, bool(f.sum().item() == 0) | |
| def _tokenization_vis_image(token_mask) -> np.ndarray: | |
| return create_tokenization_mask_vis(token_mask, HEIGHT, WIDTH, lr_factor=LR_DOWNSAMPLE_FACTOR) | |
| # --------------------------------------------------------------------------- | |
| # Gradio handlers | |
| # --------------------------------------------------------------------------- | |
| def refresh_tokenization(lora_name, mode, editor_value, cx, cy, r): | |
| print(f"[tok] enter lora={lora_name!r} mode={mode!r} cx={cx} cy={cy} r={r} editor={type(editor_value).__name__}", flush=True) | |
| if _pipe is None: | |
| print("[tok] pipe is None, returning", flush=True) | |
| return None | |
| try: | |
| t0 = time.time() | |
| token_mask, _, _ = _build_mask(lora_name, mode, editor_value, cx, cy, r) | |
| print(f"[tok] mask built ({time.time() - t0:.3f}s)", flush=True) | |
| t1 = time.time() | |
| vis = _tokenization_vis_image(token_mask) | |
| print(f"[tok] vis built ({time.time() - t1:.3f}s) shape={getattr(vis, 'shape', None)}", flush=True) | |
| return vis | |
| except Exception as e: | |
| import traceback | |
| print(f"[tok] EXCEPTION: {e!r}", flush=True) | |
| traceback.print_exc() | |
| return None | |
| def on_mode_change(lora_name, mode, editor_value, cx, cy, r): | |
| """Switch active mode β refresh tokenization + swap CSS active/inactive | |
| classes on the two group wrappers. | |
| We deliberately do NOT toggle visibility of the draw/preset groups: hiding | |
| a Group via `gr.update(visible=False)` in Gradio 6.14 unmounts its children, | |
| and re-showing it later does not re-bind their event listeners β slider | |
| .change/.release would silently stop firing. Both groups stay mounted; CSS | |
| dims the inactive one via the `mode-active` / `mode-inactive` classes. | |
| """ | |
| is_draw = (mode == "Draw") | |
| draw_cls = ["mode-group", "mode-active" if is_draw else "mode-inactive"] | |
| preset_cls = ["mode-group", "mode-inactive" if is_draw else "mode-active"] | |
| tok = refresh_tokenization(lora_name, mode, editor_value, cx, cy, r) | |
| return gr.update(elem_classes=draw_cls), gr.update(elem_classes=preset_cls), tok | |
| def switch_lora(name: str, cx: float, cy: float, r: float): | |
| """Record the user's LoRA selection and refresh dependent UI. | |
| NOT decorated with `@spaces.GPU`: the actual fuse can't happen here | |
| because @spaces.GPU runs in a forked worker and its mutations to the | |
| model + globals don't propagate back to the parent. Instead, we just | |
| update `_selected_lora` here and let the next `generate` worker fuse. | |
| UI updates: dropdown value, description, status, slider interactivity | |
| (no_fov disables circle controls), tokenization preview. | |
| """ | |
| global _selected_lora | |
| if name not in LORA_FILES: | |
| return ( | |
| gr.update(value=_selected_lora), | |
| LORA_DESCRIPTIONS.get(_selected_lora, ""), | |
| f"unknown LoRA: {name}", | |
| gr.update(), | |
| gr.update(), | |
| gr.update(), | |
| None, | |
| ) | |
| _selected_lora = name | |
| is_no_fov = (name == "no_fov") | |
| # Pass `name` explicitly so the no_fov full-HR short-circuit triggers | |
| # for the preview the user sees right after switching. | |
| token_mask, _, _ = _build_mask(name, "Preset (circle)", None, cx, cy, r) | |
| tok_vis = _tokenization_vis_image(token_mask) | |
| # interactive=False + CSS `pointer-events: none` so the handles can't fire | |
| # `.change`/`.release` even if the user clicks them. (Belt-and-suspenders: | |
| # the `_build_mask` short-circuit above already forces full-HR for no_fov, | |
| # but this prevents the sliders from looking active.) | |
| slider_classes = ["slider-locked"] if is_no_fov else [] | |
| def _slider_update(): | |
| return gr.update(interactive=not is_no_fov, elem_classes=slider_classes) | |
| return ( | |
| gr.update(value=name), | |
| LORA_DESCRIPTIONS.get(name, ""), | |
| f"Selected: {name} (fuses on next generate)", | |
| _slider_update(), | |
| _slider_update(), | |
| _slider_update(), | |
| tok_vis, | |
| ) | |
| def toggle_mode(mode): | |
| is_draw = (mode == "Draw") | |
| return gr.update(visible=is_draw), gr.update(visible=not is_draw) | |
| def fill_all_editor(): | |
| """Paint the entire canvas with opaque black so the whole image becomes HR.""" | |
| layer = np.zeros((HEIGHT, WIDTH, 4), dtype=np.uint8) | |
| layer[..., 0:3] = 17 | |
| layer[..., 3] = 255 | |
| return {"background": None, "layers": [layer], "composite": layer} | |
| def clear_editor(): | |
| return {"background": None, "layers": [], "composite": None} | |
| def clear_preset(): | |
| return 0.0, 0.0, 0.0 | |
| def generate(prompt, seed, lora_name, mode, editor_value, cx, cy, r, | |
| progress=gr.Progress(track_tqdm=False)): | |
| print(f"[gen] CLICK REACHED lora={lora_name!r} mode={mode!r} seed={seed}", flush=True) | |
| if _pipe is None: | |
| raise gr.Error("Pipeline not loaded yet β please wait for cold start.") | |
| if lora_name not in LORA_FILES: | |
| raise gr.Error(f"unknown LoRA: {lora_name}") | |
| prompt = (prompt or "").strip() | |
| if not prompt: | |
| raise gr.Error("Prompt is required.") | |
| seed = int(seed or 0) | |
| # `lora_name` came in as a Gradio input (the live dropdown value), so it | |
| # crosses the @spaces.GPU process boundary safely β no global-state | |
| # propagation required. | |
| _apply_selected_lora(lora_name) | |
| progress(0.0, desc="Building mask") | |
| t_mask = time.time() | |
| token_mask, full_res_mask, is_empty = _build_mask(lora_name, mode, editor_value, cx, cy, r) | |
| print(f"[gen] mask built ({time.time() - t_mask:.2f}s) is_empty={is_empty}", flush=True) | |
| if is_empty: | |
| raise gr.Error("Mask is empty β paint a region or set a circle radius > 0.") | |
| tok_vis = _tokenization_vis_image(token_mask) | |
| print("[gen] tokenization vis ready -> yielding to UI", flush=True) | |
| # Yield tok_vis right away so the tokenization panel updates *before* denoise. | |
| # gr.update() keeps out_img unchanged at this stage. | |
| yield tok_vis, gr.update() | |
| print("[gen] waiting for _job_lock", flush=True) | |
| with _job_lock: | |
| print("[gen] _job_lock acquired; calling _pipe", flush=True) | |
| torch.cuda.empty_cache() | |
| # Move CPU-built masks onto the pipeline's device for the forward. | |
| token_mask = token_mask.to(_pipe.device) | |
| full_res_mask = full_res_mask.to(_pipe.device) | |
| def progress_wrap(iterable): | |
| iterable = list(iterable) | |
| total = len(iterable) or NUM_INFERENCE_STEPS | |
| t_prev = time.time() | |
| for i, item in enumerate(iterable): | |
| yield item | |
| t_now = time.time() | |
| print(f"[gen] step {i + 1}/{total} {t_now - t_prev:.3f}s", flush=True) | |
| t_prev = t_now | |
| progress((i + 1) / total, desc=f"Denoising {i + 1}/{total}") | |
| t0 = time.time() | |
| image = _pipe( | |
| prompt=prompt, | |
| height=HEIGHT, | |
| width=WIDTH, | |
| seed=seed, | |
| rand_device="cuda" if torch.cuda.is_available() else "cpu", | |
| num_inference_steps=NUM_INFERENCE_STEPS, | |
| cfg_scale=GUIDANCE_SCALE, | |
| foveation_mask=token_mask, | |
| full_res_foveation_mask=full_res_mask, | |
| decode_mode=DECODE_MODE, | |
| prediction_type=PREDICTION_TYPE, | |
| soft_foveation_blend=SOFT_FOVEATION_BLEND, | |
| lr_downsample_factor=LR_DOWNSAMPLE_FACTOR, | |
| progress_bar_cmd=progress_wrap, | |
| ) | |
| print(f"[gen] total pipeline call: {time.time() - t0:.2f}s", flush=True) | |
| yield tok_vis, image | |
| # --------------------------------------------------------------------------- | |
| # UI | |
| # --------------------------------------------------------------------------- | |
| # Short description shown under the LoRA dropdown. | |
| LORA_DESCRIPTIONS = { | |
| "no_fov": "Finetuned baseline β **no foveation conditioning**. Always generates a uniform full-resolution image (mask controls disabled).", | |
| "random": "Finetuned with **random gaze locations** during training. Most general-purpose foveation LoRA.", | |
| "saliency": "Finetuned with **saliency-driven** foveation masks (model attends to visually salient regions).", | |
| } | |
| DEFAULT_PROMPT = ( | |
| "Plitvice Lakes cascade through a series of sixteen terraced turquoise pools " | |
| "connected by hundreds of waterfalls in a forested Croatian valley. The " | |
| "travertine barriers between each pool, the moss and the mineral deposits, " | |
| "and the submerged fallen logs in the crystal-clear water are all equally " | |
| "sharp. The surrounding beech and fir forest is in full autumn colourβgold, " | |
| "russet, and dark greenβand the waterfalls range from wide curtains to thin " | |
| "threads, all frozen sharp in the even overcast light. The water's colour " | |
| "shifts from pale aquamarine in the shallows to deep teal in the pools." | |
| ) | |
| CSS = """ | |
| .tok-img img, .out-img img { background: #f4f4f4 !important; } | |
| /* Belt-and-suspenders lock for cx/cy/r when no_fov is active: gr.update | |
| (interactive=False) sets the disabled attribute, this kills pointer events | |
| so a stray click on the handle can't fire .change/.release. */ | |
| .slider-locked, .slider-locked * { pointer-events: none !important; } | |
| .slider-locked { opacity: 0.35 !important; } | |
| """ | |
| def build_ui() -> gr.Blocks: | |
| with gr.Blocks(title="Foveated Diffusion") as demo: | |
| gr.Markdown( | |
| "## Foveated Diffusion: Efficient Spatially Aware Image and Video Generation \n" | |
| "Image generation demo for Foveated Diffusion. The base model is <a href=\"https://huggingface.co/black-forest-labs/FLUX.2-klein-base-4B\" target=\"_blank\" rel=\"noopener noreferrer\">FLUX.2-klein-base-4B</a>. \n" | |
| " \n" | |
| '<a href="https://bchao1.github.io/foveated-diffusion/" target="_blank" rel="noopener noreferrer">Project website</a> Β· ' | |
| '<a href="https://huggingface.co/bchao1/foveated_diffusion" target="_blank" rel="noopener noreferrer">Model weights</a> Β· ' | |
| '<a href="https://arxiv.org/abs/2603.23491" target="_blank" rel="noopener noreferrer">Paper</a> Β· ' | |
| '<a href="https://github.com/bchao1/foveated_diffusion" target="_blank" rel="noopener noreferrer">Code</a>' | |
| ) | |
| with gr.Row(): | |
| # ---------------- Left column: controls ---------------- | |
| with gr.Column(scale=1, min_width=340): | |
| prompt = gr.Textbox(label="Prompt", lines=6, value=DEFAULT_PROMPT) | |
| seed = gr.Number(label="Seed", value=0, precision=0) | |
| lora_dd = gr.Dropdown( | |
| label="LoRA model", | |
| choices=list(LORA_FILES.keys()), | |
| value=DEFAULT_LORA, | |
| interactive=True, | |
| ) | |
| lora_desc = gr.Markdown( | |
| LORA_DESCRIPTIONS[DEFAULT_LORA], | |
| elem_classes=["hint"], | |
| ) | |
| lora_status = gr.Markdown(f"Active: {DEFAULT_LORA}") | |
| # --- Mask mode --- | |
| # The "Draw" path (paint a freeform HR region with gr.ImageEditor) | |
| # is implemented end-to-end in this file but intentionally NOT | |
| # surfaced in the UI for this Spaces demo. The radio and the | |
| # ImageEditor are kept alive as hidden components (visible=False) | |
| # so the wiring + handler logic compiles unchanged β re-enable | |
| # them by flipping visible=True if a future demo wants both modes. | |
| mode = gr.Radio( | |
| choices=["Draw", "Preset (circle)"], | |
| value="Preset (circle)", | |
| visible=False, | |
| ) | |
| with gr.Group(visible=False) as draw_group: | |
| editor = gr.ImageEditor( | |
| image_mode="RGBA", | |
| type="numpy", | |
| sources=(), | |
| canvas_size=(1024, 1024), | |
| fixed_canvas=True, | |
| layers=False, | |
| transforms=(), | |
| brush=gr.Brush( | |
| default_size=80, | |
| colors=["#111111"], | |
| color_mode="fixed", | |
| default_color="#111111", | |
| ), | |
| eraser=gr.Eraser(default_size=80), | |
| height=420, | |
| ) | |
| with gr.Row(): | |
| clear_btn = gr.Button("Clear", variant="secondary") | |
| fill_btn = gr.Button("Fill all", variant="secondary") | |
| # Preset (circle) is the only mode exposed in the UI. | |
| with gr.Group() as preset_group: | |
| gr.Markdown( | |
| "**Foveal region (circular)** β place a circular HR region " | |
| "on the image. `cx, cy β [-0.5, 0.5]` (0 = image center); " | |
| "`r` is relative to half the image diagonal.", | |
| elem_classes=["hint"], | |
| ) | |
| cx = gr.Slider(-0.5, 0.5, value=0.0, step=0.01, label="Center X") | |
| cy = gr.Slider(-0.5, 0.5, value=0.0, step=0.01, label="Center Y") | |
| r = gr.Slider(0.0, 1.0, value=0.3, step=0.01, label="Radius") | |
| preset_clear_btn = gr.Button("Clear", variant="secondary") | |
| generate_btn = gr.Button("Generate", variant="primary") | |
| # ---------------- Right column: stages ---------------- | |
| with gr.Column(scale=2): | |
| with gr.Row(): | |
| tok_img = gr.Image( | |
| label="Tokenization mask", | |
| height=520, | |
| interactive=False, | |
| elem_classes=["tok-img"], | |
| ) | |
| out_img = gr.Image( | |
| label="Generated image", | |
| height=520, | |
| interactive=False, | |
| elem_classes=["out-img"], | |
| ) | |
| gr.Markdown( | |
| "**Tokenization mask** β white = HR (foveal) tokens, gray = LR " | |
| "(peripheral). Refreshes as you move the circle sliders. " | |
| "**Generated image** β appears once denoising finishes." | |
| ) | |
| # ---------------- Wiring ---------------- | |
| # lora_dd is FIRST so every handler that reads `refresh_in` (and | |
| # `generate`) gets the live dropdown value as an explicit argument β | |
| # the only safe way to ferry it into @spaces.GPU workers. | |
| refresh_in = [lora_dd, mode, editor, cx, cy, r] | |
| # Mode change: visibility toggle + tokenization refresh in ONE handler. | |
| # Registering two separate listeners on the same event in Gradio 6.x | |
| # was unreliable; a single combined handler is the safe pattern. | |
| mode.change( | |
| on_mode_change, refresh_in, [draw_group, preset_group, tok_img], queue=False, | |
| ) | |
| # LoRA switch: also disables circle sliders and refreshes tok preview | |
| # so no_fov immediately reflects "full-HR, controls disabled". | |
| lora_dd.change( | |
| switch_lora, | |
| [lora_dd, cx, cy, r], | |
| [lora_dd, lora_desc, lora_status, cx, cy, r, tok_img], | |
| ) | |
| # Live tokenization preview on edits / slider moves. | |
| # queue=False so brush/slider activity isn't serialized behind a running | |
| # generation in the queue worker. We wire BOTH .release and .change on | |
| # sliders β .release alone can miss events on some Gradio 6.x builds | |
| # (especially for sliders inside a Group that was initially hidden). | |
| editor.apply(refresh_tokenization, refresh_in, tok_img, queue=False) | |
| for trig in (cx.release, cy.release, r.release, | |
| cx.change, cy.change, r.change): | |
| trig(refresh_tokenization, refresh_in, tok_img, queue=False) | |
| # Draw helpers. queue=False β pure value-replacement, no GPU. | |
| clear_btn.click(clear_editor, None, editor, queue=False) | |
| fill_btn.click(fill_all_editor, None, editor, queue=False) | |
| preset_clear_btn.click(clear_preset, None, [cx, cy, r], queue=False) | |
| # Generate. | |
| # show_progress_on=[out_img] keeps the denoise progress bar from | |
| # overlaying the tokenization panel. | |
| generate_btn.click( | |
| generate, | |
| inputs=[prompt, seed, lora_dd, mode, editor, cx, cy, r], | |
| outputs=[tok_img, out_img], | |
| show_progress_on=[out_img], | |
| ) | |
| # On page load: render the initial tokenization grid for the default | |
| # circle (cx=0, cy=0, r=0.3). Mode is fixed to "Preset (circle)" β the | |
| # Draw mode is kept in code (visible=False) but not wired into load. | |
| demo.load(refresh_tokenization, refresh_in, tok_img, queue=False) | |
| return demo | |
| if __name__ == "__main__": | |
| print("Loading pipeline and LoRAs (cold start can take several minutes)β¦") | |
| _load_pipe() | |
| demo = build_ui() | |
| # Spaces sets PORT=7860 by convention. | |
| demo.queue(max_size=8).launch( | |
| server_name=os.environ.get("HOST", "0.0.0.0"), | |
| server_port=int(os.environ.get("PORT", 7860)), | |
| theme=gr.themes.Default(primary_hue="slate"), | |
| css=CSS, | |
| # SHARE=1 -> create a *.gradio.live tunnel. Use this to bypass flaky | |
| # local port-forwarders (e.g. VS Code) that mangle Gradio's SSE/WS. | |
| share=os.environ.get("SHARE", "").lower() in ("1", "true", "yes"), | |
| ) | |