Spaces:
Running on Zero
Running on Zero
| from __future__ import annotations | |
| import logging | |
| import os | |
| import random | |
| import threading | |
| import traceback | |
| from dataclasses import dataclass | |
| from pathlib import Path | |
| from typing import Any | |
| # Keep DiffSynth on the Hugging Face download path and make cache locations writable | |
| # on both Spaces and a local checkout. | |
| os.environ.setdefault("DIFFSYNTH_DOWNLOAD_SOURCE", "huggingface") | |
| os.environ.setdefault("DIFFSYNTH_SKIP_DOWNLOAD", "True") | |
| os.environ.setdefault("HF_HUB_ENABLE_HF_TRANSFER", "1") | |
| os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True") | |
| import gradio as gr | |
| import torch | |
| from huggingface_hub import hf_hub_download, snapshot_download | |
| from safetensors.torch import load_file | |
| try: | |
| import spaces | |
| except ImportError: # Keeps the app importable when developing outside Spaces. | |
| class _LocalSpaces: | |
| def GPU(*_args: Any, **_kwargs: Any): | |
| def decorator(function): | |
| return function | |
| return decorator | |
| spaces = _LocalSpaces() | |
| from diffsynth.pipelines.anima_image import AnimaImagePipeline, ModelConfig | |
| from lycoris import create_lycoris_from_weights | |
| logging.basicConfig(level=logging.INFO) | |
| LOGGER = logging.getLogger("anima-telescopa") | |
| SPACE_MODEL_ID = "RicemanT/Anima-Telescopa" | |
| BASE_MODEL_ID = "circlestone-labs/Anima" | |
| BASE_FILES = { | |
| "diffusion": "split_files/diffusion_models/anima-base-v1.0.safetensors", | |
| "text_encoder": "split_files/text_encoders/qwen_3_06b_base.safetensors", | |
| "vae": "split_files/vae/qwen_image_vae.safetensors", | |
| } | |
| # The finetune file in the source repository is intentionally not exposed here: | |
| # this app loads LoKr adapters onto the published Anima base, while that file is a | |
| # separate full-finetune artifact with different loading requirements. | |
| ADAPTERS = { | |
| "Recommended · LoKr v0.5 · Epoch 10": "Anima-TelescopaLOKRV0.5-Epoch10.safetensors", | |
| "Earlier · LoKr v0.1 · Epoch 3": "Anima-TelescopaLOKRV0.1-Epoch3.safetensors", | |
| } | |
| DEFAULT_PROMPT = ( | |
| "1girl, solo, long silver hair, blue eyes, blue dress, underwater, " | |
| "floating hair, refraction, detailed anime background, cinematic composition" | |
| ) | |
| DEFAULT_NEGATIVE = ( | |
| "low quality, worst quality, blurry, jpeg artifacts, watermark, signature, " | |
| "text, logo, distorted anatomy, extra fingers" | |
| ) | |
| RECOMMENDED_PREFIX = "(masterpiece, best quality, highres, detailed background:1.2), " | |
| def _writable_directory(preferred: str, fallback: str) -> Path: | |
| for candidate in (Path(preferred), Path(fallback)): | |
| try: | |
| candidate.mkdir(parents=True, exist_ok=True) | |
| probe = candidate / ".write-test" | |
| probe.touch() | |
| probe.unlink() | |
| return candidate | |
| except OSError: | |
| continue | |
| raise RuntimeError("No writable model/cache directory is available.") | |
| HF_HOME = _writable_directory( | |
| os.environ.get("HF_HOME", "/data/.cache/huggingface"), | |
| "/tmp/.cache/huggingface", | |
| ) | |
| MODEL_DIR = _writable_directory( | |
| os.environ.get("ANIMA_LOCAL_MODEL_DIR", "/data/models/anima-telescopa"), | |
| "/tmp/models/anima-telescopa", | |
| ) | |
| os.environ["HF_HOME"] = str(HF_HOME) | |
| os.environ.setdefault("DIFFSYNTH_MODEL_BASE_PATH", str(MODEL_DIR / "diffsynth")) | |
| class Assets: | |
| diffusion: str | |
| text_encoder: str | |
| vae: str | |
| qwen_tokenizer_dir: str | |
| t5_tokenizer_dir: str | |
| _PIPE: AnimaImagePipeline | None = None | |
| _ADAPTER_NETWORK: Any | None = None | |
| _ACTIVE_ADAPTER: str | None = None | |
| _RUNTIME_LOCK = threading.RLock() | |
| def _download_assets(progress: gr.Progress | None = None) -> Assets: | |
| def download(repo_id: str, filename: str) -> str: | |
| if progress: | |
| progress(0, desc=f"Preparing {Path(filename).name}") | |
| return hf_hub_download(repo_id=repo_id, filename=filename, cache_dir=str(HF_HOME)) | |
| qwen_tokenizer_dir = snapshot_download( | |
| repo_id="Qwen/Qwen3-0.6B", | |
| cache_dir=str(HF_HOME), | |
| allow_patterns=["tokenizer*", "*.json", "*.model"], | |
| ) | |
| t5_tokenizer_dir = snapshot_download( | |
| repo_id="google/t5-v1_1-xxl", | |
| cache_dir=str(HF_HOME), | |
| allow_patterns=["tokenizer*", "*.json", "*.model"], | |
| ) | |
| return Assets( | |
| diffusion=download(BASE_MODEL_ID, BASE_FILES["diffusion"]), | |
| text_encoder=download(BASE_MODEL_ID, BASE_FILES["text_encoder"]), | |
| vae=download(BASE_MODEL_ID, BASE_FILES["vae"]), | |
| qwen_tokenizer_dir=qwen_tokenizer_dir, | |
| t5_tokenizer_dir=t5_tokenizer_dir, | |
| ) | |
| def _load_pipeline(progress: gr.Progress | None = None) -> AnimaImagePipeline: | |
| global _PIPE | |
| with _RUNTIME_LOCK: | |
| if _PIPE is not None: | |
| return _PIPE | |
| if not torch.cuda.is_available(): | |
| raise RuntimeError( | |
| "Anima requires a CUDA GPU for practical inference. " | |
| "Run this Space on a GPU-enabled hardware tier (ZeroGPU, T4, A10G, or better)." | |
| ) | |
| assets = _download_assets(progress) | |
| if progress: | |
| progress(0.45, desc="Loading Anima base model") | |
| pipeline_kwargs = { | |
| "torch_dtype": torch.bfloat16, | |
| "device": "cuda", | |
| "model_configs": [ | |
| ModelConfig(path=assets.diffusion), | |
| ModelConfig(path=assets.text_encoder), | |
| ModelConfig(path=assets.vae), | |
| ], | |
| "tokenizer_config": ModelConfig(path=assets.qwen_tokenizer_dir), | |
| "tokenizer_t5xxl_config": ModelConfig(path=assets.t5_tokenizer_dir), | |
| } | |
| vram_limit = os.environ.get("ANIMA_VRAM_LIMIT") | |
| if vram_limit: | |
| pipeline_kwargs["vram_limit"] = int(vram_limit) | |
| _PIPE = AnimaImagePipeline.from_pretrained(**pipeline_kwargs) | |
| if progress: | |
| progress(0.7, desc="Anima base model ready") | |
| return _PIPE | |
| def _translate_lokr_state_dict(weights: dict[str, torch.Tensor]) -> dict[str, torch.Tensor]: | |
| """Translate diffusion-pipe/ComfyUI LoKr names to LyCORIS names. | |
| Telescopa stores keys like ``diffusion_model.blocks.0.self_attn.q_proj.lokr_w1``. | |
| LyCORIS resolves target modules from ``pipe.dit.named_modules()`` and expects | |
| the same module path flattened under its ``lycoris_`` prefix. | |
| """ | |
| translated: dict[str, torch.Tensor] = {} | |
| module_prefixes: set[str] = set() | |
| prefix = "diffusion_model." | |
| for key, value in weights.items(): | |
| if not key.startswith(prefix): | |
| translated[key] = value | |
| continue | |
| target = key[len(prefix) :] | |
| if "." not in target: | |
| continue | |
| module_name, suffix = target.rsplit(".", 1) | |
| lycoris_name = "lycoris_" + module_name.replace(".", "_") | |
| translated[f"{lycoris_name}.{suffix}"] = value | |
| if suffix in {"lokr_w1", "lokr_w2", "lokr_w1_a", "lokr_w2_a"}: | |
| module_prefixes.add(lycoris_name) | |
| # Telescopa was trained with LoKr alpha 16, but its exported safetensors | |
| # contain no alpha tensors. LyCORIS requires `<prefix>.alpha` and otherwise | |
| # attempts float(None) while constructing a full-matrix LoKr module. | |
| for module_prefix in module_prefixes: | |
| translated.setdefault(f"{module_prefix}.alpha", torch.tensor(16.0)) | |
| if not translated or not module_prefixes: | |
| raise RuntimeError("The Telescopa LoKr file did not contain translatable adapter weights.") | |
| return translated | |
| def _activate_adapter(adapter_label: str, scale: float, progress: gr.Progress | None = None) -> None: | |
| global _ADAPTER_NETWORK, _ACTIVE_ADAPTER | |
| if adapter_label not in ADAPTERS: | |
| raise ValueError("Unknown Telescopa adapter variant.") | |
| pipe = _load_pipeline(progress) | |
| adapter_filename = ADAPTERS[adapter_label] | |
| if _ACTIVE_ADAPTER == adapter_filename and _ADAPTER_NETWORK is not None: | |
| _ADAPTER_NETWORK.multiplier = float(scale) | |
| return | |
| with _RUNTIME_LOCK: | |
| if _ADAPTER_NETWORK is not None: | |
| _ADAPTER_NETWORK.restore() | |
| _ADAPTER_NETWORK = None | |
| _ACTIVE_ADAPTER = None | |
| if progress: | |
| progress(0.78, desc=f"Loading {adapter_label}") | |
| adapter_path = hf_hub_download( | |
| repo_id=SPACE_MODEL_ID, | |
| filename=adapter_filename, | |
| cache_dir=str(HF_HOME), | |
| ) | |
| # The repository contains diffusion-pipe/Kohya-compatible full-matrix | |
| # LoKr weights. LyCORIS maps those keys onto DiffSynth's Anima DiT. | |
| weights = _translate_lokr_state_dict(load_file(adapter_path, device="cpu")) | |
| # LyCORIS 3.4 returns (network, state_dict), not the network alone. | |
| _ADAPTER_NETWORK, _ = create_lycoris_from_weights( | |
| multiplier=float(scale), | |
| file=adapter_path, | |
| module=pipe.dit, | |
| weights_sd=weights, | |
| ) | |
| _ADAPTER_NETWORK.apply_to() | |
| matched_loras = getattr(_ADAPTER_NETWORK, "loras", None) | |
| if not matched_loras: | |
| _ADAPTER_NETWORK.restore() | |
| _ADAPTER_NETWORK = None | |
| raise RuntimeError( | |
| "The selected LoKr file did not match any Anima DiT layers. " | |
| "The adapter/runtime versions may be incompatible." | |
| ) | |
| _ACTIVE_ADAPTER = adapter_filename | |
| def _normalize_dimension(value: int) -> int: | |
| return max(512, min(1280, int(round(int(value) / 16) * 16))) | |
| def _normalize_seed(seed: int | None) -> int: | |
| try: | |
| value = int(seed) if seed is not None else -1 | |
| except (TypeError, ValueError): | |
| value = -1 | |
| return random.randint(0, 2**31 - 1) if value < 0 else value | |
| def _prepare_prompt(prompt: str, use_prefix: bool) -> str: | |
| prompt = (prompt or "").strip() or DEFAULT_PROMPT | |
| if use_prefix and not prompt.lower().startswith(RECOMMENDED_PREFIX.lower()): | |
| prompt = RECOMMENDED_PREFIX + prompt | |
| return prompt | |
| def generate( | |
| prompt: str, | |
| negative_prompt: str, | |
| adapter_label: str, | |
| adapter_scale: float, | |
| width: int, | |
| height: int, | |
| steps: int, | |
| cfg_scale: float, | |
| sigma_shift: float, | |
| seed: int, | |
| use_prefix: bool, | |
| progress: gr.Progress = gr.Progress(track_tqdm=False), | |
| ): | |
| try: | |
| prompt = _prepare_prompt(prompt, use_prefix) | |
| negative_prompt = (negative_prompt or DEFAULT_NEGATIVE).strip() | |
| width = _normalize_dimension(width) | |
| height = _normalize_dimension(height) | |
| steps = max(10, min(45, int(steps))) | |
| cfg_scale = max(1.0, min(8.0, float(cfg_scale))) | |
| sigma_shift = float(sigma_shift) | |
| seed = _normalize_seed(seed) | |
| _activate_adapter(adapter_label, adapter_scale, progress) | |
| pipe = _load_pipeline(progress) | |
| if progress: | |
| progress(0.82, desc="Generating Telescopa image") | |
| with torch.inference_mode(): | |
| image = pipe( | |
| prompt=prompt, | |
| negative_prompt=negative_prompt, | |
| cfg_scale=cfg_scale, | |
| height=height, | |
| width=width, | |
| seed=seed, | |
| num_inference_steps=steps, | |
| sigma_shift=None if sigma_shift <= 0 else sigma_shift, | |
| progress_bar_cmd=lambda value: progress(0.82 + 0.17 * float(value), desc="Sampling"), | |
| ) | |
| info = ( | |
| f"**Seed:** `{seed}` · **Variant:** {adapter_label} · " | |
| f"**Size:** {width}×{height} · **Steps:** {steps}" | |
| ) | |
| return image, info | |
| except Exception as exc: | |
| LOGGER.error("Telescopa generation failed: %s", exc) | |
| LOGGER.debug(traceback.format_exc()) | |
| return None, ( | |
| "**Generation failed.** The base Anima runtime or LoKr adapter could not be loaded. " | |
| f"`{type(exc).__name__}: {exc}`" | |
| ) | |
| with gr.Blocks(title="Anima · Telescopa", theme=gr.themes.Soft()) as demo: | |
| gr.Markdown( | |
| """ | |
| # Anima · Telescopa | |
| Generate anime-style illustrations with **[RicemanT/Anima-Telescopa](https://huggingface.co/RicemanT/Anima-Telescopa)**, a full-matrix **LoKr** fine-tune on [Anima](https://huggingface.co/circlestone-labs/Anima). | |
| The recommended settings from the model card are pre-filled: **28 steps · CFG 4 · shift 5**. The first generation downloads the Anima base components, tokenizers, and selected adapter into the Space cache. | |
| """ | |
| ) | |
| with gr.Row(): | |
| with gr.Column(scale=1): | |
| prompt = gr.Textbox(label="Prompt", value=DEFAULT_PROMPT, lines=5) | |
| negative_prompt = gr.Textbox(label="Negative prompt", value=DEFAULT_NEGATIVE, lines=3) | |
| use_prefix = gr.Checkbox( | |
| label="Add quality/background prefix", | |
| value=True, | |
| info="Adds a compact quality prompt recommended for this fine-tune.", | |
| ) | |
| adapter_label = gr.Dropdown( | |
| choices=list(ADAPTERS), | |
| value=list(ADAPTERS)[0], | |
| label="Telescopa variant", | |
| ) | |
| adapter_scale = gr.Slider(0.0, 1.5, value=1.0, step=0.05, label="LoKr strength") | |
| with gr.Row(): | |
| width = gr.Slider(512, 1280, value=1024, step=16, label="Width") | |
| height = gr.Slider(512, 1280, value=1024, step=16, label="Height") | |
| with gr.Row(): | |
| steps = gr.Slider(10, 45, value=28, step=1, label="Steps") | |
| cfg_scale = gr.Slider(1, 8, value=4, step=0.1, label="CFG") | |
| with gr.Row(): | |
| sigma_shift = gr.Slider(0, 8, value=5, step=0.1, label="AuraFlow shift") | |
| seed = gr.Number(value=-1, precision=0, label="Seed (-1 = random)") | |
| generate_button = gr.Button("Generate image", variant="primary") | |
| with gr.Column(scale=1): | |
| output = gr.Image(label="Generated image", type="pil") | |
| info = gr.Markdown("Choose a prompt and generate an image.\n\n*GPU inference is required.*") | |
| gr.Examples( | |
| examples=[ | |
| ["1girl, solo, red hair, school uniform, sunset rooftop, city skyline, wind, dramatic clouds"], | |
| ["ancient library inside a giant tree, warm sunlight, floating books, intricate anime background"], | |
| ["small coastal train station at night, glowing vending machines, rain, cinematic anime background"], | |
| ], | |
| inputs=[prompt], | |
| label="Prompt ideas", | |
| ) | |
| gr.Markdown( | |
| "**License note:** the model weights are distributed under the CircleStone Labs Non-Commercial License v1.1. " | |
| "Review the [model card](https://huggingface.co/RicemanT/Anima-Telescopa) before deploying or using this Space." | |
| ) | |
| generate_button.click( | |
| fn=generate, | |
| inputs=[ | |
| prompt, | |
| negative_prompt, | |
| adapter_label, | |
| adapter_scale, | |
| width, | |
| height, | |
| steps, | |
| cfg_scale, | |
| sigma_shift, | |
| seed, | |
| use_prefix, | |
| ], | |
| outputs=[output, info], | |
| ) | |
| if __name__ == "__main__": | |
| demo.queue(max_size=12).launch() | |