multimodalart's picture
multimodalart HF Staff
Size ZeroGPU durations from measured runs; align resolution schema with Radio choices
f9cd3f3 verified
Raw
History Blame Contribute Delete
22.7 kB
"""UniSpace — unified text-to-image generation and instruction-based image editing.
Paper: UniSpace: Unified Visual Representation and Scalable Multimodal Modeling
Model: https://huggingface.co/yjb6/UniSpace
Code: https://github.com/yjb6/UniSpace
"""
import os
# Must be set before torch / the UniSpace modeling code is imported.
os.environ.setdefault("ATTENTION_BACKEND", "torch_sdpa")
os.environ.setdefault("USE_FLEX_ATTENTION", "0")
os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")
os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
import spaces # noqa: E402 (must precede torch / CUDA-touching imports)
import gc # noqa: E402
import json # noqa: E402
import random # noqa: E402
import sys # noqa: E402
import time # noqa: E402
import gradio as gr # noqa: E402
import torch # noqa: E402
from huggingface_hub import hf_hub_download # noqa: E402
from omegaconf import OmegaConf # noqa: E402
from safetensors import safe_open # noqa: E402
ROOT = os.path.dirname(os.path.abspath(__file__))
PR_ROOT = os.path.join(ROOT, "patch-reparameterization")
sys.path.insert(0, os.path.join(PR_ROOT, "src"))
sys.path.insert(0, os.path.join(ROOT, "unispace"))
MODEL_REPO = "yjb6/UniSpace"
CKPT_DIR = "unispace-sft-0012000"
QWEN_LLM = "Qwen/Qwen3-8B"
QWEN_VL = "Qwen/Qwen3-VL-8B-Instruct"
# Official inference settings (unispace/scripts/eval/run_release_generation.sh +
# unispace/eval/gen/eval_sft_0012000.example.json).
MAX_LATENT_SIZE = 96
TIMESTEP_SHIFT = 0.112
DEFAULT_STEPS = 50
DEFAULT_CFG = 10.0
GEN_CFG_RENORM_MIN = 0.9 # gen_images_qwen3_unified_mot.py main()
EDIT_CFG_RENORM_MIN = 0.0 # gen_images_imgedit_qwen3_unified_mot.py argparse default
EDIT_RESOLUTION = 1024
DEVICE = "cuda"
DEVICE_TYPE = "cuda"
def _log(msg):
print(f"[unispace] {msg}", flush=True)
def _free_cached_file(path):
"""Delete a hub-cached file (symlink + blob) to keep the Space disk small."""
try:
real = os.path.realpath(path)
if os.path.islink(path):
os.unlink(path)
if os.path.exists(real):
os.remove(real)
except OSError as exc: # pragma: no cover
_log(f"could not free {path}: {exc}")
# ---------------------------------------------------------------------------
# 1. Vision-tower-only loader for the PatchReparam encoder.
#
# stage1/encoders/qwen3_unified.py loads the *full* Qwen3-VL-8B-Instruct
# (17.5 GB) only to keep `.visual`, and does so with device_map="cuda:N",
# which is incompatible with the ZeroGPU hijack. We swap in a loader that
# builds the vision tower on CPU and pulls its weights from the single shard
# that holds them (2.7 GB).
# ---------------------------------------------------------------------------
import transformers.models.qwen3_vl.modeling_qwen3_vl as _q3vl_mod # noqa: E402
from transformers import AutoConfig, AutoTokenizer # noqa: E402
from transformers.models.qwen3_vl.modeling_qwen3_vl import Qwen3VLVisionModel # noqa: E402
try:
from transformers.modeling_utils import no_init_weights
except ImportError: # pragma: no cover
from contextlib import nullcontext as no_init_weights
class _VisionOnlyQwen3VL:
"""Minimal stand-in exposing the two attributes Qwen3Unified touches."""
def __init__(self, visual):
self.visual = visual
self.model = None
@classmethod
def from_pretrained(cls, model_name, torch_dtype=None, device_map=None, **kwargs):
cfg = AutoConfig.from_pretrained(model_name)
vis_cfg = cfg.vision_config
vis_cfg._attn_implementation = "sdpa"
t0 = time.time()
with no_init_weights():
visual = Qwen3VLVisionModel(vis_cfg)
visual = visual.to(torch.float32)
shard = hf_hub_download(model_name, "model-00004-of-00004.safetensors")
prefix = "model.visual."
state = {}
with safe_open(shard, framework="pt", device="cpu") as f:
for key in f.keys():
if key.startswith(prefix):
state[key[len(prefix):]] = f.get_tensor(key).to(torch.float32)
missing, unexpected = visual.load_state_dict(state, strict=False)
_log(
f"vision tower: {len(state)} tensors, missing={len(missing)}, "
f"unexpected={len(unexpected)} ({time.time() - t0:.1f}s)"
)
if missing:
_log(f"vision tower missing keys (first 10): {missing[:10]}")
del state
_free_cached_file(shard)
gc.collect()
return cls(visual)
_q3vl_mod.Qwen3VLForConditionalGeneration = _VisionOnlyQwen3VL
# ---------------------------------------------------------------------------
# 2. PatchReparam ("RAE") tokenizer / decoder
# ---------------------------------------------------------------------------
from modeling.autoencoder_unified import load_unified_vae # noqa: E402
from modeling.qwen3.configuration_qwen3 import Qwen3Config # noqa: E402
from modeling.unimm.qwen3_mot import Qwen3MoTForConditionalGeneration # noqa: E402
from modeling.unimm.unimm_mot import UnimmConfig, UnimmMoT # noqa: E402
import unispace_infer as ui # noqa: E402
def _build_vae_config_yaml():
src = os.path.join(PR_ROOT, "configs", "vae", "qwen3-temp-run-mar1024_eval.yaml")
cfg = OmegaConf.load(src)
cfg.stage_1.params.encoder_config_path = QWEN_VL
cfg.stage_1.params.encoder_params.model_name = QWEN_VL
cfg.stage_1.params.normalization_stat_path = hf_hub_download(
MODEL_REPO, "stats/pr-qwen-vit-normalization-stats.pt"
)
cfg.stage_1.checkpoint.path = hf_hub_download(
MODEL_REPO, "encoders/pr-qwen-vit-tokenizer.pt"
)
out = os.path.join(ROOT, "_vae_eval_resolved.yaml")
OmegaConf.save(cfg, out, resolve=False)
return out, str(cfg.stage_1.checkpoint.path)
_log("loading PatchReparam tokenizer/decoder ...")
_t0 = time.time()
_vae_yaml, _vae_ckpt = _build_vae_config_yaml()
vae_model, vae_config = load_unified_vae(local_path=_vae_yaml, patch_reparam_root=PR_ROOT)
vae_model = vae_model.eval()
_free_cached_file(_vae_ckpt)
gc.collect()
_log(
f"PatchReparam ready in {time.time() - _t0:.1f}s "
f"(downsample={vae_config.downsample}, z_channels={vae_config.z_channels})"
)
# ---------------------------------------------------------------------------
# 3. Qwen3-8B Mixture-of-Transformers backbone
#
# Every `language_model.*` tensor lives in the UniSpace checkpoint, so the base
# Qwen3-8B weights are never needed — only its config + tokenizer. The 17-shard
# fp32 checkpoint (61 GB) is streamed one shard at a time, cast to bf16 and
# released, so peak disk stays around 4 GB.
# ---------------------------------------------------------------------------
_log("building Qwen3-8B MoT backbone from config ...")
_t0 = time.time()
llm_config = Qwen3Config.from_pretrained(QWEN_LLM)
llm_config.qk_norm = True
llm_config.tie_word_embeddings = False
llm_config._attn_implementation = "sdpa"
_orig_linear_reset = torch.nn.Linear.reset_parameters
_orig_embedding_reset = torch.nn.Embedding.reset_parameters
torch.nn.Linear.reset_parameters = lambda self: None
torch.nn.Embedding.reset_parameters = lambda self: None
torch.set_default_dtype(torch.bfloat16)
try:
with no_init_weights():
language_model = Qwen3MoTForConditionalGeneration(llm_config)
finally:
torch.set_default_dtype(torch.float32)
torch.nn.Linear.reset_parameters = _orig_linear_reset
torch.nn.Embedding.reset_parameters = _orig_embedding_reset
_log(f"backbone skeleton built in {time.time() - _t0:.1f}s")
unimm_config = UnimmConfig(
visual_gen=True,
visual_und=True,
llm_config=llm_config,
vit_config=None,
vae_config=vae_config,
vit_max_num_patch_per_side=MAX_LATENT_SIZE,
connector_act="gelu_pytorch_tanh",
latent_patch_size=1,
max_latent_size=MAX_LATENT_SIZE,
use_qwen_vit=False,
use_moe=True,
use_qwen3_unified=True,
share_unified2llm=False,
use_spatial_merge=False,
use_spatial_merge_gen=False,
use_spatial_merge_und=True,
use_mrope=False,
)
tokenizer = AutoTokenizer.from_pretrained(QWEN_LLM)
model = UnimmMoT(language_model, None, tokenizer, unimm_config)
del language_model
gc.collect()
new_token_ids = {
"bos_token_id": tokenizer.encode("<|im_start|>")[0],
"eos_token_id": tokenizer.encode("<|im_end|>")[0],
"pad_token_id": tokenizer.pad_token_id or tokenizer.eos_token_id,
"start_of_image": tokenizer.encode("<|vision_start|>")[0],
"end_of_image": tokenizer.encode("<|vision_end|>")[0],
}
# The two positional embeddings are frozen 2-D sincos tables rebuilt at
# construction time — the official edit script drops them from the checkpoint.
SKIP_KEYS = {"latent_pos_embed.pos_embed", "vit_pos_embed.pos_embed"}
_log("streaming UniSpace SFT checkpoint ...")
_t0 = time.time()
_index = json.load(
open(hf_hub_download(MODEL_REPO, f"{CKPT_DIR}/model.safetensors.index.json"))
)
_shards = sorted(set(_index["weight_map"].values()))
_loaded = set()
for _i, _shard in enumerate(_shards, 1):
_path = hf_hub_download(MODEL_REPO, f"{CKPT_DIR}/{_shard}")
_sd = {}
with safe_open(_path, framework="pt", device="cpu") as f:
for _k in f.keys():
if _k in SKIP_KEYS:
continue
_sd[_k] = f.get_tensor(_k).to(torch.bfloat16)
_msg = model.load_state_dict(_sd, strict=False)
if _msg.unexpected_keys:
_log(f" shard {_i}: unexpected {_msg.unexpected_keys[:5]}")
_loaded.update(_sd.keys())
del _sd, _msg
gc.collect()
_free_cached_file(_path)
_log(f" shard {_i}/{len(_shards)} loaded ({time.time() - _t0:.0f}s elapsed)")
_expected = set(model.state_dict().keys())
_missing = sorted(_expected - _loaded - SKIP_KEYS)
_log(f"checkpoint loaded: {len(_loaded)} tensors, missing={len(_missing)}")
if _missing:
_log(f"MISSING KEYS (first 20): {_missing[:20]}")
model = model.to(torch.bfloat16).eval()
for _p in model.parameters():
_p.requires_grad_(False)
for _p in vae_model.parameters():
_p.requires_grad_(False)
gc.collect()
_log("moving weights to GPU ...")
model = model.to(DEVICE)
vae_model = vae_model.to(DEVICE)
_log("model ready")
# ---------------------------------------------------------------------------
# 4. Gradio handlers
# ---------------------------------------------------------------------------
MAX_SEED = 2**31 - 1
# Durations are sized from measured runs on this Space (ZeroGPU large), not
# guessed. Sampling dominates and is linear in `steps` at ~1.13 s/step at
# 1024x1024, with essentially no fixed cost:
# text-to-image 16 steps -> 17.4s | 50 steps -> 56.2s | 60 steps -> 67.0s
# Editing adds ~2s for the reference-image encode (Qwen3-VL tower + VAE) and
# runs at the same per-step cost (mar_1024 buckets keep the area ~1024^2).
# ~10s covers GPU allocation + weight streaming, then a 15% safety margin.
# Kept deliberately tight: `duration` is what a visitor's quota is charged
# against, and lower values also rank higher in the ZeroGPU queue.
_STEP_SECONDS = 1.13
_ALLOC_SECONDS = 10.0
_MARGIN = 1.15
def _duration_generate(prompt="", steps=DEFAULT_STEPS, *args, **kwargs):
try:
steps = int(steps)
except (TypeError, ValueError):
steps = DEFAULT_STEPS
return min(100, int((_ALLOC_SECONDS + steps * _STEP_SECONDS) * _MARGIN))
def _duration_edit(image=None, instruction="", steps=DEFAULT_STEPS, *args, **kwargs):
try:
steps = int(steps)
except (TypeError, ValueError):
steps = DEFAULT_STEPS
return min(105, int((_ALLOC_SECONDS + 2.0 + steps * _STEP_SECONDS) * _MARGIN))
@spaces.GPU(duration=_duration_generate)
def generate(
prompt: str,
steps: int = DEFAULT_STEPS,
cfg_scale: float = DEFAULT_CFG,
resolution: str = "1024",
seed: int = 42,
randomize_seed: bool = False,
progress=gr.Progress(track_tqdm=True),
):
"""Generate a square image from a text prompt with UniSpace.
Args:
prompt: Text description of the image to generate.
steps: Number of flow-matching sampling steps.
cfg_scale: Classifier-free guidance strength.
resolution: Output width/height in pixels, one of "512", "768", "1024".
seed: Random seed.
randomize_seed: Draw a fresh random seed instead of using `seed`.
Returns:
The generated PIL image, the seed that was used, and a timing note.
"""
if not prompt or not prompt.strip():
raise gr.Error("Please enter a prompt.")
if randomize_seed:
seed = random.randint(0, MAX_SEED)
seed = int(seed)
resolution = int(resolution)
t0 = time.perf_counter()
images = ui.generate_image(
prompt=prompt.strip(),
num_timesteps=int(steps),
cfg_scale=float(cfg_scale),
cfg_interval=[0, 1.0],
cfg_renorm_min=GEN_CFG_RENORM_MIN,
timestep_shift=TIMESTEP_SHIFT,
max_t=1.0,
num_images=1,
resolution=resolution,
inference_mode="flash",
device=DEVICE,
device_type=DEVICE_TYPE,
gen_model=model,
tokenizer=tokenizer,
new_token_ids=new_token_ids,
vae_model=vae_model,
seed=seed,
)
elapsed = time.perf_counter() - t0
return images[0], seed, f"{resolution}×{resolution} · {steps} steps · {elapsed:.1f}s"
@spaces.GPU(duration=_duration_edit)
def edit(
image,
instruction: str,
steps: int = DEFAULT_STEPS,
cfg_scale: float = DEFAULT_CFG,
seed: int = 42,
randomize_seed: bool = False,
progress=gr.Progress(track_tqdm=True),
):
"""Edit an image by following a natural-language instruction.
Args:
image: The source image to edit.
instruction: What to change, e.g. "Change the background to a forest.".
steps: Number of flow-matching sampling steps.
cfg_scale: Classifier-free guidance strength on the instruction.
seed: Random seed.
randomize_seed: Draw a fresh random seed instead of using `seed`.
Returns:
The edited PIL image, the seed that was used, and a timing note.
"""
if image is None:
raise gr.Error("Please upload an image to edit.")
if not instruction or not instruction.strip():
raise gr.Error("Please enter an editing instruction.")
if randomize_seed:
seed = random.randint(0, MAX_SEED)
seed = int(seed)
ref = image.convert("RGB")
target_size = ui.compute_target_size_from_ref(ref, EDIT_RESOLUTION)
t0 = time.perf_counter()
out = ui.editing_image(
gen_model=model,
tokenizer=tokenizer,
new_token_ids=new_token_ids,
vae_model=vae_model,
ref_image=ref,
prompt=instruction.strip(),
ref_image_size=448,
ref_max_size=None,
ref_min_size=256,
ref_use_mar=True,
ref_mar_resolution=EDIT_RESOLUTION,
target_size=target_size,
num_timesteps=int(steps),
cfg_text_scale=float(cfg_scale),
cfg_interval=[0, 1.0],
cfg_renorm_min=EDIT_CFG_RENORM_MIN,
timestep_shift=TIMESTEP_SHIFT,
device=DEVICE,
device_type=DEVICE_TYPE,
seed=seed,
)
elapsed = time.perf_counter() - t0
return out, seed, f"{target_size[1]}×{target_size[0]} · {steps} steps · {elapsed:.1f}s"
# Thin wrappers for gr.Examples. They deliberately take no `gr.Progress`
# parameter: Gradio injects the Progress object at the index of that parameter
# and would otherwise land it in `steps` when an example row supplies fewer
# values than the full signature.
def generate_example(prompt: str):
"""Run the text-to-image example row with default settings."""
return generate(prompt)
def edit_example(image, instruction: str):
"""Run the image-editing example row with default settings."""
return edit(image, instruction)
# ---------------------------------------------------------------------------
# 5. UI
# ---------------------------------------------------------------------------
GEN_EXAMPLES = [
["A photo of a press conference with microphones and a blurred crowd in the background, realistic news style"],
["A watercolor painting of a lighthouse during a winter sunrise."],
["An ink wash painting style image illustrating a Chinese poem. A solitary boat floats on a misty river near mountains. Vertical traditional Chinese text on the right reads: '孤舟蓑笠翁,独钓寒江雪'. The mood is serene and melancholic. A red seal stamp (chop) is visible in the bottom left corner."],
["A realistic photo of a modern airport terminal sign hanging from the ceiling. The sign has a yellow background with black text. It shows directions with arrows. The text is in three languages: English 'Departures', Chinese '出发', and Japanese '出発'. An icon of an airplane taking off is next to the text. The background shows a blurred terminal hall with travelers."],
["A professional product photo of a sneaker on a white background, studio lighting, advertising style"],
]
EDIT_EXAMPLES = [
["examples/background_change.png", "Change the background to a forest."],
["examples/color_alter.png", "Alter the color of the mirror frame to orange."],
["examples/material_alter.png", "Change the cup in hand to ceramic."],
["examples/subject-add.png", "Add a balloon decoration strip below the airplane."],
]
CSS = """
#col-container { max-width: 1100px; margin: 0 auto; }
"""
with gr.Blocks(theme=gr.themes.Citrus(), css=CSS, title="UniSpace") as demo:
with gr.Column(elem_id="col-container"):
gr.Markdown(
"""
# 🛰️ UniSpace
**Unified Visual Representation and Scalable Multimodal Modeling** — an 8B
Mixture-of-Transformers that generates and edits images on top of a single
patch-reparameterized visual tokenizer.
[Paper](https://huggingface.co/papers/2608.08676) ·
[Model](https://huggingface.co/yjb6/UniSpace) ·
[Code](https://github.com/yjb6/UniSpace)
"""
)
with gr.Tabs():
with gr.Tab("Text to image"):
with gr.Row():
with gr.Column():
g_prompt = gr.Textbox(
label="Prompt",
lines=3,
placeholder="A watercolor painting of a lighthouse during a winter sunrise.",
)
g_run = gr.Button("Generate", variant="primary")
with gr.Accordion("Advanced settings", open=False):
# String choices so the generated API/MCP schema
# (Literal['512','768','1024']) matches what the
# component actually accepts; handler casts to int.
g_resolution = gr.Radio(
choices=["512", "768", "1024"],
value="1024",
label="Resolution",
)
g_steps = gr.Slider(
8, 60, value=DEFAULT_STEPS, step=1, label="Sampling steps"
)
g_cfg = gr.Slider(
1.0, 15.0, value=DEFAULT_CFG, step=0.5,
label="Guidance scale (CFG)",
)
g_seed = gr.Slider(
0, MAX_SEED, value=42, step=1, label="Seed"
)
g_rand = gr.Checkbox(value=False, label="Randomize seed")
with gr.Column():
g_out = gr.Image(label="Result", type="pil", height=520)
g_used_seed = gr.Number(label="Seed used", interactive=False)
g_info = gr.Textbox(label="Run info", interactive=False)
gr.Examples(
examples=GEN_EXAMPLES,
inputs=[g_prompt],
outputs=[g_out, g_used_seed, g_info],
fn=generate_example,
cache_examples=True,
cache_mode="lazy",
label="Prompts from the UniSpace release",
)
with gr.Tab("Instruction editing"):
with gr.Row():
with gr.Column():
e_image = gr.Image(label="Input image", type="pil", height=340)
e_prompt = gr.Textbox(
label="Editing instruction",
lines=2,
placeholder="Change the background to a forest.",
)
e_run = gr.Button("Edit", variant="primary")
with gr.Accordion("Advanced settings", open=False):
e_steps = gr.Slider(
8, 60, value=DEFAULT_STEPS, step=1, label="Sampling steps"
)
e_cfg = gr.Slider(
1.0, 15.0, value=DEFAULT_CFG, step=0.5,
label="Guidance scale (CFG)",
)
e_seed = gr.Slider(
0, MAX_SEED, value=42, step=1, label="Seed"
)
e_rand = gr.Checkbox(value=False, label="Randomize seed")
with gr.Column():
e_out = gr.Image(label="Result", type="pil", height=520)
e_used_seed = gr.Number(label="Seed used", interactive=False)
e_info = gr.Textbox(label="Run info", interactive=False)
gr.Examples(
examples=EDIT_EXAMPLES,
inputs=[e_image, e_prompt],
outputs=[e_out, e_used_seed, e_info],
fn=edit_example,
cache_examples=True,
cache_mode="lazy",
label="Examples from the UniSpace project page (GEdit-Bench, MIT)",
)
g_run.click(
fn=generate,
inputs=[g_prompt, g_steps, g_cfg, g_resolution, g_seed, g_rand],
outputs=[g_out, g_used_seed, g_info],
api_name="generate",
)
e_run.click(
fn=edit,
inputs=[e_image, e_prompt, e_steps, e_cfg, e_seed, e_rand],
outputs=[e_out, e_used_seed, e_info],
api_name="edit",
)
if __name__ == "__main__":
demo.queue().launch(mcp_server=True)