Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
Download interface/backend/local_gen.py from SMSD75/SynthScope: direct link, hf CLI and curl.
- Browser
- Download file 12.4 kB
-
https://huggingface.co/spaces/SMSD75/SynthScope/resolve/main/interface/backend/local_gen.py
- Command line
-
hf download hf://spaces/SMSD75/SynthScope/interface/backend/local_gen.py
-
curl -L -o local_gen.py https://huggingface.co/spaces/SMSD75/SynthScope/resolve/main/interface/backend/local_gen.py
12.4 kB
| """Isolated runner for heavy OPEN-WEIGHT image generators (FLUX.2 [klein] 4B/9B, FLUX.1 Kontext [dev], Qwen-Image-2.1). | |
| ``generators.py`` launches this file as a SUBPROCESS, one image per call, so that | |
| * these models can use a newer ``diffusers`` than the shared SynthScope env (set ``$SYNTHSCOPE_GEN_PYTHONPATH`` to a | |
| directory holding diffusers main + huggingface_hub 1.x; see ``interface/README.md``), and | |
| * every byte of GPU/CPU memory is released after the image, so the web UI's detectors keep their 24 GB. | |
| python interface/backend/local_gen.py --model qwen_image_2_1 --image in.png --prompt "..." --out out.png | |
| Both models are image-CONDITIONED (they edit the source photo into a new image), run in bf16 on a 24 GB GPU | |
| (FLUX models: ``enable_model_cpu_offload``; Qwen-Image-2.1: staged load, see ``load_pipeline_staged``), and need | |
| no paid API key. Licenses: FLUX.2 [klein] 4B is | |
| Apache-2.0; Qwen-Image-2.1 is under the Qwen Research License (research use). | |
| """ | |
| import argparse | |
| import gc | |
| import json | |
| import os | |
| import sys | |
| import torch | |
| import diffusers | |
| import transformers | |
| from PIL import Image | |
| MAX_AREA = 1024 * 1024 | |
| MULTIPLE = 16 | |
| # key -> (HF repo, diffusers pipeline class, extra call kwargs) | |
| MODELS = { | |
| "flux2_klein_4b": ("black-forest-labs/FLUX.2-klein-4B", "Flux2KleinPipeline", | |
| {"num_inference_steps": 4, "guidance_scale": 1.0}), | |
| "qwen_image_2_1": ("Qwen/Qwen-Image-2.1", "QwenImage21Pipeline", | |
| {"num_inference_steps": 40}), | |
| # license-gated (accept the terms on the Hub with the account behind $HF_TOKEN); non-commercial licenses | |
| "flux2_klein_9b": ("black-forest-labs/FLUX.2-klein-9B", "Flux2KleinPipeline", | |
| {"num_inference_steps": 4, "guidance_scale": 1.0}), | |
| "flux1_kontext_dev": ("black-forest-labs/FLUX.1-Kontext-dev", "FluxKontextPipeline", | |
| {"num_inference_steps": 28, "guidance_scale": 2.5}), | |
| } | |
| DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu") | |
| DEBUG = bool(os.environ.get("LOCAL_GEN_DEBUG")) | |
| def mem(tag: str) -> None: | |
| """GPU memory trace (set LOCAL_GEN_DEBUG=1).""" | |
| if DEBUG: | |
| print(f"[mem] {tag}: allocated {torch.cuda.memory_allocated() / 2**30:.1f} GiB, " | |
| f"max {torch.cuda.max_memory_allocated() / 2**30:.1f} GiB", file=sys.stderr, flush=True) | |
| def fit(img: Image.Image) -> Image.Image: | |
| """Scale to <= MAX_AREA keeping aspect, with both sides a multiple of MULTIPLE.""" | |
| w, h = img.size | |
| s = min(1.0, (MAX_AREA / (w * h)) ** 0.5) | |
| nw, nh = max(MULTIPLE, int(w * s) // MULTIPLE * MULTIPLE), max(MULTIPLE, int(h * s) // MULTIPLE * MULTIPLE) | |
| return img.resize((nw, nh), Image.LANCZOS) | |
| def load_pipeline_cpu_offload(repo: str, cls_name: str, token): | |
| """Standard path: whole pipeline in host RAM, modules streamed to the GPU on demand (FLUX.2 [klein]). | |
| Without a GPU the pipeline simply runs on the CPU in bfloat16 (slow: minutes per image).""" | |
| pipe = getattr(diffusers, cls_name).from_pretrained(repo, torch_dtype=torch.bfloat16, token=token) | |
| if DEVICE.type == "cuda": | |
| pipe.enable_model_cpu_offload() | |
| return pipe | |
| def load_pipeline_staged(repo: str, cls_name: str, token): | |
| """Low-host-RAM path for Qwen-Image-2.1 (33 GB repo, OOMs a 28 GB host with ``enable_model_cpu_offload``). | |
| The Qwen3-VL text encoder is loaded straight onto the GPU, used once to encode prompt + source image, then | |
| dropped; only then is the 7B transformer loaded straight onto the GPU. Nothing large ever sits in host RAM. | |
| The pipeline is created with ``transformer=None`` and the transformer is attached inside ``encode_prompt`` | |
| (which ``__call__`` invokes before it first touches ``self.transformer``). | |
| """ | |
| pipeline_cls = getattr(diffusers, cls_name) | |
| text_encoder = transformers.Qwen3VLForConditionalGeneration.from_pretrained( | |
| repo, subfolder="text_encoder", torch_dtype=torch.bfloat16, device_map="cuda", token=token) | |
| pipe = pipeline_cls.from_pretrained(repo, text_encoder=text_encoder, transformer=None, | |
| torch_dtype=torch.bfloat16, token=token) | |
| pipe.vae.to(DEVICE) | |
| # The 1 MP VAE decode does not fit next to the 14 GB transformer on a 24 GB GPU. VAE tiling is NOT a fix: tiling the | |
| # ENCODE of the conditioning image corrupts it (output degenerates into texture), and tiled decode leaves visible | |
| # seams that a detector could key on. Instead the transformer (no longer needed once denoising is done) is moved | |
| # to host RAM right before the single-pass decode. | |
| vae_decode = pipe.vae.decode | |
| def decode_after_freeing_transformer(*args, **kwargs): | |
| pipe.transformer.to("cpu") | |
| gc.collect() | |
| torch.cuda.empty_cache() | |
| mem("transformer offloaded before decode") | |
| return vae_decode(*args, **kwargs) | |
| pipe.vae.decode = decode_after_freeing_transformer | |
| mem("text encoder + vae loaded") | |
| pipeline_cls._execution_device = property(lambda self: DEVICE) # no offload hooks in this mode | |
| held = {"text_encoder": text_encoder} # the only outside reference, so it can be dropped after encoding | |
| del text_encoder | |
| encode_prompt = pipe.encode_prompt | |
| def encode_then_load_transformer(*args, **kwargs): | |
| out = encode_prompt(*args, **kwargs) | |
| pipe.text_encoder = None | |
| held.clear() | |
| gc.collect() | |
| torch.cuda.empty_cache() | |
| mem("text encoder dropped") | |
| pipe.register_modules(transformer=diffusers.QwenImage21Transformer2DModel.from_pretrained( | |
| repo, subfolder="transformer", torch_dtype=torch.bfloat16, device_map="cuda", token=token)) | |
| mem("transformer loaded") | |
| return out | |
| pipe.encode_prompt = encode_then_load_transformer | |
| return pipe | |
| def load_pipeline_group_offload(repo: str, cls_name: str, token): | |
| """FLUX.1 Kontext [dev]: the 12B transformer (24 GB in bf16) cannot sit whole on a 24 GB GPU, which is what | |
| ``enable_model_cpu_offload`` needs. Keep the text encoders + VAE (~10 GB) on the GPU and stream the transformer | |
| through it two blocks at a time.""" | |
| pipe = getattr(diffusers, cls_name).from_pretrained(repo, torch_dtype=torch.bfloat16, token=token) | |
| for name in ("text_encoder", "text_encoder_2", "vae"): | |
| getattr(pipe, name).to(DEVICE) | |
| pipe.transformer.enable_group_offload(onload_device=DEVICE, offload_device=torch.device("cpu"), | |
| offload_type="block_level", num_blocks_per_group=2) | |
| type(pipe)._execution_device = property(lambda self: DEVICE) # the transformer reports "cpu" between blocks | |
| mem("kontext loaded") | |
| return pipe | |
| def run_batch_qwen(repo: str, cls_name: str, token, items: list, kw: dict, res: int) -> None: | |
| """Text-to-image for MANY prompts with one model load (Qwen-Image-2.1 on a 24 GB GPU): encode every prompt first | |
| with the text encoder on the GPU, drop it, load the transformer once, then denoise each prompt in turn | |
| (the transformer is parked on the CPU during each VAE decode, as in ``load_pipeline_staged``).""" | |
| pipeline_cls = getattr(diffusers, cls_name) | |
| text_encoder = transformers.Qwen3VLForConditionalGeneration.from_pretrained( | |
| repo, subfolder="text_encoder", torch_dtype=torch.bfloat16, device_map="cuda", token=token) | |
| pipe = pipeline_cls.from_pretrained(repo, text_encoder=text_encoder, transformer=None, | |
| torch_dtype=torch.bfloat16, token=token) | |
| pipe.vae.to(DEVICE) | |
| pipeline_cls._execution_device = property(lambda self: DEVICE) | |
| embeds = [] | |
| for it in items: | |
| with torch.no_grad(): # without this the saved embeddings keep the autograd graph, and with it the 16 GB encoder | |
| emb, mask, _ = pipe.encode_prompt(prompt=it["prompt"], device=DEVICE) | |
| embeds.append((emb.detach().cpu(), None if mask is None else mask.detach().cpu())) | |
| pipe.text_encoder = None | |
| del text_encoder | |
| gc.collect(); torch.cuda.empty_cache() | |
| pipe.register_modules(transformer=diffusers.QwenImage21Transformer2DModel.from_pretrained( | |
| repo, subfolder="transformer", torch_dtype=torch.bfloat16, device_map="cuda", token=token)) | |
| vae_decode = pipe.vae.decode | |
| def decode_after_freeing_transformer(*args, **kwargs): | |
| pipe.transformer.to("cpu"); gc.collect(); torch.cuda.empty_cache() | |
| return vae_decode(*args, **kwargs) | |
| pipe.vae.decode = decode_after_freeing_transformer | |
| for it, (emb, mask) in zip(items, embeds): | |
| pipe.transformer.to(DEVICE) | |
| out = pipe(prompt_embeds=emb.to(DEVICE), prompt_embeds_mask=None if mask is None else mask.to(DEVICE), | |
| generator=torch.Generator("cpu").manual_seed(it.get("seed", 0)), output_resolution=res, **kw).images[0] | |
| out.convert("RGB").save(it["out"], format="PNG") | |
| print(f"saved {it['out']} {out.size}", flush=True) | |
| def run_batch(model: str, items: list, res: int) -> None: | |
| """Text-only generation for a list of {prompt, out, seed, size?} items with one model load. Existing outputs are | |
| skipped, so an interrupted batch can simply be re-run.""" | |
| repo, cls_name, kw = MODELS[model] | |
| token = os.environ.get("HF_TOKEN") | |
| todo = [it for it in items if not os.path.exists(it["out"])] | |
| print(f"{len(items) - len(todo)} already done, {len(todo)} to generate with {model}", flush=True) | |
| if not todo: | |
| return | |
| if model == "qwen_image_2_1": | |
| run_batch_qwen(repo, cls_name, token, todo, kw, res) | |
| return | |
| pipe = load_pipeline_cpu_offload(repo, cls_name, token) | |
| for it in todo: | |
| w, h = (int(v) for v in (it.get("size") or "1024x1024").split("x")) | |
| out = pipe(prompt=it["prompt"], generator=torch.Generator("cpu").manual_seed(it.get("seed", 0)), | |
| height=h, width=w, **kw).images[0] | |
| out.convert("RGB").save(it["out"], format="PNG") | |
| print(f"saved {it['out']} {out.size}", flush=True) | |
| LOADERS = {"qwen_image_2_1": load_pipeline_staged, "flux1_kontext_dev": load_pipeline_group_offload} | |
| def main() -> int: | |
| ap = argparse.ArgumentParser() | |
| ap.add_argument("--model", required=True, choices=sorted(MODELS)) | |
| ap.add_argument("--image", help="source photo (not needed with --text-only)") | |
| ap.add_argument("--prompt") | |
| ap.add_argument("--out") | |
| ap.add_argument("--batch", help="JSON list of {prompt, out, seed, size}: text-to-image for all of them with one model load") | |
| ap.add_argument("--seed", type=int, default=0) | |
| ap.add_argument("--text-only", action="store_true", help="create the image from the prompt alone (no source photo)") | |
| ap.add_argument("--size", help="WxH of the output in --text-only mode (default 1024x1024)") | |
| ap.add_argument("--res", type=int, default=1024, help="output resolution (Qwen-Image-2.1 output_resolution)") | |
| args = ap.parse_args() | |
| if args.batch: | |
| run_batch(args.model, json.load(open(args.batch)), args.res) | |
| return 0 | |
| if not args.prompt or not args.out: | |
| ap.error("--prompt and --out are required (or use --batch)") | |
| if not args.text_only and not args.image: | |
| ap.error("--image is required unless --text-only is given") | |
| repo, cls_name, kw = MODELS[args.model] | |
| if not hasattr(diffusers, cls_name): | |
| print(f"diffusers {diffusers.__version__} has no {cls_name}; point $SYNTHSCOPE_GEN_PYTHONPATH at a newer diffusers", | |
| file=sys.stderr) | |
| return 3 | |
| pipe = LOADERS.get(args.model, load_pipeline_cpu_offload)(repo, cls_name, os.environ.get("HF_TOKEN")) | |
| src = None if args.text_only else fit(Image.open(args.image).convert("RGB")) | |
| call = dict(kw) | |
| if args.model.startswith("flux"): | |
| if src is not None: | |
| call.update(height=src.height, width=src.width) | |
| else: | |
| w, h = (int(v) for v in (args.size or "1024x1024").split("x")) | |
| call.update(height=h, width=w) | |
| if args.model.startswith("qwen"): | |
| call["output_resolution"] = args.res | |
| cond = {} if src is None else {"image": src} | |
| out = pipe(prompt=args.prompt, generator=torch.Generator("cpu").manual_seed(args.seed), **cond, **call).images[0] | |
| out.convert("RGB").save(args.out, format="PNG") | |
| print(f"saved {args.out} {out.size} via {cls_name} ({repo})", flush=True) | |
| return 0 | |
| if __name__ == "__main__": | |
| sys.exit(main()) | |