import os # ZeroGPU packs the whole module-scope resident set to disk at startup, in a # single pre-allocated file at ZEROGPU_OFFLOAD_DIR (default ~/.zerogpu/tensors). # The default overlay layer and the RAM-backed tmpfs (/dev/shm) both run out of # room at pack time (OSError(28)); the dedicated NVMe volume /data-nvme is a # real block device with predictable free space, so point the offload there. os.environ.setdefault("ZEROGPU_OFFLOAD_DIR", "/data-nvme/zerogpu_tensors") # Allocator config to survive transient memory spikes from the large DiT. os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True") # DiffSynth defaults to ModelScope; force Hugging Face so all weights come from the Hub. os.environ.setdefault("DIFFSYNTH_DOWNLOAD_SOURCE", "huggingface") import gc import glob import math import sys import time import spaces # must come before torch / any CUDA-touching import import numpy as np import torch import torch.nn.functional as F import gradio as gr from PIL import Image from huggingface_hub import snapshot_download # Local vendored packages (MetaView `src/` + `diffsynth/` and Depth-Anything-3). sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from depth_anything_3.api import DepthAnything3 from diffsynth.core import ModelConfig from diffsynth import load_state_dict from src.MetaView_pipeline import MetaViewPipeline # --------------------------------------------------------------------------- # Model ids # --------------------------------------------------------------------------- METAVIEW_REPO = "Kwai-Kolors/MetaView" DA3_GIANT_REPO = "depth-anything/DA3-GIANT-1.1" DA3_NESTED_REPO = "depth-anything/DA3NESTED-GIANT-LARGE-1.1" QWEN_EDIT_REPO = "Qwen/Qwen-Image-Edit" # MetaView global config (matches the reference inference script). EXPORT_3D_FEAT_LAYERS = [19, 27, 33, 39] PROPE_DIM_ARRANGE = [64, 20, 20, 24] ADD_DEPTH = len(PROPE_DIM_ARRANGE) == 4 MERGE_3D = True PROMPT = ["镜头视角转到指定位置"] # "move the camera view to the target position" GEN_W, GEN_H = 960, 528 NUM_STEPS = 40 device = "cuda" dtype = torch.bfloat16 # --------------------------------------------------------------------------- # Load everything on CPU at module scope. DiffSynth's loader reads safetensors # directly onto the requested device (bypassing the `spaces` CUDA hijack), so # loading with device="cuda" here fails on ZeroGPU with "No CUDA GPUs are # available" — no GPU is attached to the main process. We therefore load on # CPU and then call `.to("cuda")` at module scope (below). That explicit # module-scope `.to("cuda")` IS intercepted by the `spaces` hijack and is what # ZeroGPU packs for the GPU worker — the standard ZeroGPU recipe. # --------------------------------------------------------------------------- print("[*] Downloading MetaView checkpoint...") metaview_dir = snapshot_download(METAVIEW_REPO) metaview_ckpt = glob.glob(os.path.join(metaview_dir, "*.safetensors"))[0] print("[*] Downloading Depth-Anything-3 models...") da3_giant_dir = snapshot_download(DA3_GIANT_REPO) da3_nested_dir = snapshot_download(DA3_NESTED_REPO) # Download the Qwen-Image-Edit backbone components from the Hub explicitly so we # can hand DiffSynth concrete local file paths (avoids the ModelScope default # download path and the processor-dir globbing quirk). print("[*] Downloading Qwen-Image-Edit backbone...") qwen_edit_dir = snapshot_download( QWEN_EDIT_REPO, allow_patterns=["transformer/diffusion_pytorch_model*.safetensors", "transformer/*.json", "processor/*"], ) qwen_image_dir = snapshot_download( "Qwen/Qwen-Image", allow_patterns=["text_encoder/model*.safetensors", "text_encoder/*.json", "vae/diffusion_pytorch_model.safetensors", "vae/*.json", "tokenizer/*"], ) transformer_files = sorted(glob.glob( os.path.join(qwen_edit_dir, "transformer", "diffusion_pytorch_model*.safetensors"))) text_encoder_files = sorted(glob.glob( os.path.join(qwen_image_dir, "text_encoder", "model*.safetensors"))) vae_file = os.path.join(qwen_image_dir, "vae", "diffusion_pytorch_model.safetensors") processor_path = os.path.join(qwen_edit_dir, "processor") tokenizer_path = os.path.join(qwen_image_dir, "tokenizer") # The MetaView / Qwen-Image-Edit pipeline is the dominant memory consumer # (~58 GB of weights). Load it at module scope so `import spaces` can pack it. # The two Depth-Anything-3 priors (~12 GB) are loaded lazily on the first GPU # request instead — keeping them out of the module-scope resident set gives the # large transformer enough headroom to load within the 104 GB host RAM limit. print("[*] Loading MetaView / Qwen-Image-Edit pipeline on CPU...") pipe = MetaViewPipeline.from_pretrained( torch_dtype=dtype, device="cpu", model_configs=[ ModelConfig(path=transformer_files), ModelConfig(path=text_encoder_files), ModelConfig(path=vae_file), ], tokenizer_config=ModelConfig(path=tokenizer_path), processor_config=ModelConfig(path=processor_path), ) print(f"[*] Applying MetaView weights from {metaview_ckpt}...") state_dict = load_state_dict(metaview_ckpt) pipe.dit.load_state_dict(state_dict, strict=False) del state_dict gc.collect() # Load the two Depth-Anything-3 priors on CPU too. The full module-scope # resident set (pipe ~66 GB + priors ~12 GB in fp32) is packed to disk by # ZeroGPU at startup and must fit the ~76 GB NVMe offload volume (below); fp32 # priors overflow it by ~2 GB. DA3's Transformer *backbone* is the multi-GB # bulk and already runs under bf16 autocast, so we store its weights in bf16 # (lossless in practice). Its downstream heads (depth DPT, camera decoder, # gaussian) run ops that require weights and activations to share a dtype, so # they stay fp32; a forward hook re-casts the bf16 backbone features to fp32 at # the boundary so the fp32 heads receive fp32 inputs. This trims each prior # enough to fit while keeping the geometry outputs numerically fp32. def _cast_tree_to_float(x): if isinstance(x, torch.Tensor): return x.float() if x.is_floating_point() else x if isinstance(x, (list, tuple)): return type(x)(_cast_tree_to_float(v) for v in x) if isinstance(x, dict): return {k: _cast_tree_to_float(v) for k, v in x.items()} return x def _shrink_backbone_to_bf16(api): model = getattr(api, "model", api) backbone = getattr(model, "backbone", None) if isinstance(backbone, torch.nn.Module): backbone.to(dtype=dtype) # Re-cast backbone outputs to fp32 so the fp32 heads stay dtype-consistent. backbone.register_forward_hook(lambda _m, _in, out: _cast_tree_to_float(out)) return api print("[*] Loading Depth-Anything-3 GIANT (3D feature extractor)...") da3_giant = _shrink_backbone_to_bf16(DepthAnything3.from_pretrained(da3_giant_dir).eval()) print("[*] Loading Depth-Anything-3 NESTED (dense depth)...") da3_nested = _shrink_backbone_to_bf16(DepthAnything3.from_pretrained(da3_nested_dir).eval()) print("[*] All models loaded on CPU.") # --------------------------------------------------------------------------- # Standard ZeroGPU recipe: move everything to CUDA at module/global scope. # `import spaces` monkey-patches `torch.cuda.*`, so these `.to("cuda")` calls # are intercepted in the main (GPU-less) process — the backend packs the # weights to disk and streams them into VRAM inside the GPU worker on the first # @spaces.GPU call. This must NOT be deferred into the decorated function. # --------------------------------------------------------------------------- print("[*] Moving models to CUDA (module scope)...") pipe.to(device=device) da3_giant.to(device) da3_nested.to(device) print("[*] Models placed on CUDA.") # --------------------------------------------------------------------------- # AoTI (ahead-of-time inductor): load the precompiled repeated-block graph. # The artifact is produced offline by the companion compile Space # (hugging-apps/metaview-aoti-compile) and published to an HF Dataset repo. We # load it once at module scope via the `spaces` package's AoTI helpers, # following the block-loading pattern (module exposes `_repeated_blocks`). If # the artifact is missing or incompatible we fall back to eager execution. # --------------------------------------------------------------------------- AOTI_REPO = "hugging-apps/metaview-aoti" # HF Dataset repo (artifact store) AOTI_BLOCK = "MetaViewTransformerBlock" # Expose the repeated block so the standard `spaces` AoTI blocks pattern applies. pipe.dit._repeated_blocks = [AOTI_BLOCK] def _load_aoti(): """Download the precompiled block from the dataset and patch it onto the DiT. Uses the `spaces` package AoTI machinery. `aoti_blocks_load` defaults to a model repo, so we resolve the dataset artifact manually and reuse the same LazyAOTIModel + aoti_patch primitives it uses internally. """ from huggingface_hub import hf_hub_download from spaces.zero.torch.aoti import LazyAOTIModel, aoti_patch pt2 = hf_hub_download( repo_id=AOTI_REPO, filename="package.pt2", subfolder=AOTI_BLOCK, repo_type="dataset", token=os.environ.get("HF_TOKEN"), ) lazy = LazyAOTIModel(pt2) patched = 0 for module in pipe.dit.modules(): if type(module).__name__ == AOTI_BLOCK: aoti_patch(module, lazy) patched += 1 print(f"[*] AoTI: patched {patched} '{AOTI_BLOCK}' blocks from {AOTI_REPO}.") try: _load_aoti() except Exception as e: # noqa: BLE001 - never break serving on AoTI issues print(f"[*] AoTI load failed ({e!r}); running eager.") def compute_target_extrinsic(yaw_deg, pitch_deg, radius): """Camera World-to-Camera extrinsic for a rotation around a sphere center in front of the camera (yaw = left/right, pitch = up/down).""" yaw = math.radians(yaw_deg) pitch = math.radians(pitch_deg) R_y = np.array([[np.cos(yaw), 0, np.sin(yaw)], [0, 1, 0], [-np.sin(yaw), 0, np.cos(yaw)]]) R_x = np.array([[1, 0, 0], [0, np.cos(pitch), -np.sin(pitch)], [0, np.sin(pitch), np.cos(pitch)]]) R = R_y @ R_x C = np.array([0.0, 0.0, radius]) t = C - R @ C T = np.eye(4) T[:3, :3] = R T[:3, 3] = t return T @spaces.GPU(duration=240, size="xlarge") def synthesize(image, yaw, pitch, radius=0.0, *args, progress=gr.Progress(track_tqdm=True)): """Synthesize a novel view of a single input image at a target camera pose. Args: image: Source image (a single monocular view). yaw: Horizontal camera rotation in degrees (positive = right, negative = left). pitch: Vertical camera rotation in degrees (positive = up, negative = down). radius: Rotation radius. If 0, it is auto-derived from the scene center depth. Returns: The synthesized novel-view image at the requested camera pose. """ # `radius` is optional so callers that omit it (e.g. gr.Examples, which only # binds [image, yaw, pitch]) don't shift a positional argument into it. The # trailing `*args` swallows any extra positional injected by Gradio so the # Progress object is always delivered via the `progress` keyword default. if image is None: raise gr.Error("Please provide an input image.") original_image = image.convert("RGB") edit_image = original_image.resize((GEN_W, GEN_H)) t0 = time.perf_counter() with torch.inference_mode(): # --- 1. 3D feature extraction (DA3 GIANT) + intrinsics --- feat_out = da3_giant.inference( [edit_image], export_feat_layers=EXPORT_3D_FEAT_LAYERS, process_res=840 ) intri = feat_out.intrinsics[0] width = intri[0, 2] * 2 height = intri[1, 2] * 2 Ks_matrix = [ [intri[0, 0] / width, 0.0, 0.0], [0.0, intri[1, 1] / height, 0.0], [0.0, 0.0, 1.0], ] Ks = torch.Tensor(Ks_matrix) Ks = torch.stack([Ks, Ks], dim=0).unsqueeze(0) # (1, 2, 3, 3) feats = [torch.from_numpy(feat_out.aux[f"feat_layer_{layer}"]) for layer in EXPORT_3D_FEAT_LAYERS] feat_3D = torch.cat(feats, dim=-1).to(dtype=dtype, device=device) # --- 2. Dense depth estimation (DA3 NESTED) --- prediction = da3_nested.inference([edit_image], process_res=840) depth_edit = torch.Tensor(prediction.depth).unsqueeze(0) depth_edit = F.interpolate(depth_edit, size=(GEN_H, GEN_W), mode="bilinear", align_corners=False)[0] depth_latent = torch.zeros_like(depth_edit) depth = torch.cat([depth_latent, depth_edit], dim=0).unsqueeze(0) # (1, 2, H, W) # --- 3. Target pose --- r = float(radius) if r <= 0: depth_squeeze = depth[0, 1] r = depth_squeeze[depth_squeeze.shape[0] // 2, depth_squeeze.shape[1] // 2].item() extrinsic_target = compute_target_extrinsic(float(yaw), float(pitch), r) extrinsic_source = np.eye(4) viewmats = torch.Tensor( np.stack((extrinsic_target, extrinsic_source), axis=0) ).unsqueeze(0) # (1, 2, 4, 4) -> [target, source] # --- 4. Novel view generation (MetaView DiT) --- generated_image = pipe( PROMPT, edit_image=edit_image, edit_image_auto_resize=False, seed=0, viewmats=viewmats.to(device=device, dtype=dtype), Ks=Ks.to(device=device, dtype=dtype), prope_dim_arrange=PROPE_DIM_ARRANGE, add_attn=True, add_3D=True, feat_3D=feat_3D, depth=depth.to(device=device, dtype=dtype) if ADD_DEPTH else None, merge_3D=MERGE_3D, val=True, num_inference_steps=NUM_STEPS, height=GEN_H, width=GEN_W, ) print(f"[*] Inference took {time.perf_counter() - t0:.1f}s") return generated_image CSS = """ #col-container { max-width: 1100px; margin: 0 auto; } .dark .gradio-container { color: var(--body-text-color); } #camera-control-wrapper { min-height: 380px; } """ # yaw ∈ [YAW_MIN, YAW_MAX] (left/right) and pitch ∈ [PITCH_MIN, PITCH_MAX] # (down/up) are the only two dimensions MetaView's synthesize() consumes. YAW_MIN, YAW_MAX = -60, 60 PITCH_MIN, PITCH_MAX = -45, 45 # --------------------------------------------------------------------------- # 3D camera-control widget (Three.js), adapted from # multimodalart/qwen-image-multiple-angles-3d-camera. Reduced from that demo's # three dimensions (azimuth / elevation / distance) to only the two MetaView # uses: yaw (green ring, horizontal rotation) and pitch (pink arc, vertical). # The distance dimension and its orange handle are dropped entirely. # # Implemented as a plain gr.HTML block + inline Three.js script (the Space runs # gradio 5.49.1, which predates the html_template/js_on_load custom-component # API). Bidirectional coupling to the yaw & pitch sliders is done the same way # the previous 2D pad did it: dragging a handle writes the target value into the # slider's native and dispatches an `input` event so Gradio updates its # state; a polling loop reads the sliders back so external slider changes move # the 3D handles. Three.js itself is loaded via demo.launch(head=...). # --------------------------------------------------------------------------- CAM_3D_JS = f"""