Spaces:
Running on Zero
Running on Zero
Download space_runtime.py from NeilLeeNTU/PhotoGuider: direct link, hf CLI and curl.
- Browser
- Download file 7.08 kB
-
https://huggingface.co/spaces/NeilLeeNTU/PhotoGuider/resolve/main/space_runtime.py
- Command line
-
hf download hf://spaces/NeilLeeNTU/PhotoGuider/space_runtime.py
-
curl -L -o space_runtime.py https://huggingface.co/spaces/NeilLeeNTU/PhotoGuider/resolve/main/space_runtime.py
7.08 kB
| """Single-process PhotoGuider runtime for the Hugging Face Space. | |
| The release runs each extractor family in its own environment and process (envs/, workers.py). | |
| Here every model lives in one environment and one process, loaded once and kept resident: the | |
| extractor ops run in-process through the same load()/run(args) interface worker_serve.py uses, and | |
| the explainer VLM is loaded once instead of once per call. On a Space (SPACE_ID set) the explainer | |
| runs in bf16 instead of NF4, which the 48 GB ZeroGPU slice holds without bitsandbytes. | |
| """ | |
| import functools | |
| import importlib | |
| import os | |
| import sys | |
| import types | |
| from pathlib import Path | |
| ROOT = Path(os.environ.get("PHOTOGUIDER_ROOT", Path(__file__).resolve().parent)).resolve() | |
| INF = ROOT / "inference" | |
| LIB = INF / "_lib" | |
| # one environment serves every role; require_env() only checks that the interpreter exists | |
| for _role in ("MAIN", "LAION", "FGAESQ", "SAM", "EXPLAINER"): | |
| os.environ.setdefault(f"CROPPER_ENV_{_role}", sys.executable) | |
| sys.path[:0] = [str(INF), str(LIB), str(LIB / "extractors")] | |
| # FG-IAA ships `utils/` as a namespace package; transparent-background appends a directory holding | |
| # a regular `utils.py` to sys.path, which would shadow it. Bind the name to FG-IAA's package first. | |
| from env_config import asset # noqa: E402 | |
| if "utils" not in sys.modules: | |
| _u = types.ModuleType("utils") | |
| _u.__path__ = [str(Path(asset("fgaesq_source", "envs/fgaesq/FGAesQ_Inference")) / "utils")] | |
| sys.modules["utils"] = _u | |
| import pandas as pd # noqa: E402 | |
| if os.environ.get("SPACE_ID"): | |
| # ZeroGPU: the Gradio process has no GPU and forks a worker per GPU call. transformers 5 asks | |
| # torch.cuda.is_current_stream_capturing() during a CPU forward, which initialises the CUDA | |
| # runtime in that GPU-less parent, and every worker forked afterwards then sees no GPU | |
| # ("No CUDA GPUs are available"). We never capture CUDA graphs, so answer without asking. | |
| import transformers.utils.import_utils as _tiu | |
| _tiu.is_cuda_stream_capturing = lambda: False | |
| import _shared # noqa: E402 | |
| import feature_extractors # noqa: E402 | |
| import feat_saliency as FS # noqa: E402 | |
| import explain_vlm # noqa: E402 | |
| import explain as EX # noqa: E402 | |
| import photoguider # noqa: E402 | |
| class InProcessPool: | |
| """ExtractorPool interface; ops run in this process with their models resident.""" | |
| def __init__(self, verbose=False): | |
| self.mods = {} | |
| def module(self, op): | |
| if op not in self.mods: | |
| if op not in feature_extractors.OP_ENV: | |
| raise ValueError(f"unknown extractor op '{op}'") | |
| m = importlib.import_module(op) | |
| m.load() | |
| self.mods[op] = m | |
| return self.mods[op] | |
| def run_op(self, op, args=None): | |
| self.module(op).run(args) | |
| return True | |
| def close(self): # models stay resident across requests | |
| pass | |
| def load_vlm_bf16(adapter_dir): | |
| """explain_vlm.load_vlm without the NF4 quantization.""" | |
| import torch | |
| from transformers import AutoProcessor, Qwen2_5_VLForConditionalGeneration | |
| from peft import PeftModel | |
| adapter_dir = Path(adapter_dir) | |
| torch.manual_seed(1234) | |
| proc = AutoProcessor.from_pretrained(explain_vlm.BASE_VLM, revision=explain_vlm.revision("qwen")) | |
| model = Qwen2_5_VLForConditionalGeneration.from_pretrained( | |
| explain_vlm.BASE_VLM, revision=explain_vlm.revision("qwen"), torch_dtype=torch.bfloat16).to("cuda") | |
| model = PeftModel.from_pretrained(model, str(adapter_dir), is_trainable=False).eval() | |
| return {"proc": proc, "model": model, | |
| "bad": explain_vlm.cjk_bad_words_ids(proc.tokenizer, adapter_dir / "cjk_ids_cache.json")} | |
| # ── two speed-ups that leave every number unchanged ── | |
| # 1. The ~1,500 candidate boxes get their hand-crafted features in a per-row Python loop | |
| # (consultant_features.stage_c_features); the rows are independent, so large pools are split | |
| # across forked CPU workers and concatenated back in order. | |
| import multiprocessing # noqa: E402 | |
| import infer # noqa: E402 | |
| _WORKERS = int(os.environ.get("PHOTOGUIDER_WORKERS") or max(1, min(8, (os.cpu_count() or 2) - 1))) # 1 = serial | |
| _serial_features = infer.af_chain_features | |
| def _chunk_features(args): | |
| dfp, maps = args | |
| return infer.M02.stage_c_features(dfp.reset_index(drop=True), maps) | |
| def parallel_features(dfp, vmix_2d, v1_2d, mask_np, min_rows=200): | |
| if len(dfp) < min_rows or _WORKERS == 1: | |
| return _serial_features(dfp, vmix_2d, v1_2d, mask_np) | |
| maps = infer.maps_for(dfp.photo_id.iloc[0], vmix_2d, v1_2d, mask_np) | |
| dfp = dfp.reset_index(drop=True) | |
| bounds = [round(i * len(dfp) / _WORKERS) for i in range(_WORKERS + 1)] | |
| # The ZeroGPU worker is a daemonic process, which may not have children; it is forked fresh for | |
| # every call, so lift that flag here and fork a pool for this call only (numpy only, never CUDA). | |
| multiprocessing.current_process()._config["daemon"] = False | |
| with multiprocessing.get_context("fork").Pool(_WORKERS) as pool: | |
| parts = pool.map(_chunk_features, [(dfp.iloc[a:b], maps) for a, b in zip(bounds, bounds[1:]) if b > a]) | |
| out = pd.concat(parts, ignore_index=True) | |
| assert len(out) == len(dfp), "parallel candidate features lost rows" | |
| return out | |
| infer.af_chain_features = parallel_features | |
| # 2. The SAM image encoder sees the same full image several times per request (mean embedding, | |
| # patch norms, the decoder's set_image, both extractor passes) and the same candidate batches | |
| # in both passes. Memoise its forward on the exact input tensor; a hit returns the stored output. | |
| def memoise_sam_encoder(sam, max_entries=48): | |
| import hashlib | |
| enc, cache = sam.image_encoder, {} | |
| forward = enc.forward | |
| def cached_forward(x): | |
| key = (tuple(x.shape), str(x.dtype), hashlib.sha1(x.detach().cpu().numpy().tobytes()).hexdigest()) | |
| if key not in cache: | |
| if len(cache) >= max_entries: | |
| cache.pop(next(iter(cache))) | |
| cache[key] = forward(x) | |
| return cache[key] | |
| enc.forward = cached_forward | |
| return sam | |
| _get_sam = _shared.get_sam | |
| def get_sam_cached(): | |
| if "sam" not in _shared._CACHE: | |
| memoise_sam_encoder(_get_sam()) | |
| return _shared._CACHE["sam"] | |
| _shared.get_sam = get_sam_cached | |
| explain_vlm.load_vlm = functools.cache(load_vlm_bf16 if os.environ.get("SPACE_ID") else explain_vlm.load_vlm) | |
| EX.write_explanation = lambda crop_path, prompt: explain_vlm.generate_explanation(crop_path, prompt, str(EX.ADAPTER_DIR)) | |
| photoguider.ExtractorPool = InProcessPool | |
| def preload(session, explain=True): | |
| """Load every model once (extractors, saliency, explainer) so requests only run inference.""" | |
| for op in feature_extractors.OP_ENV: | |
| session.pool.module(op) | |
| FS._model() | |
| import feat_clip_refine | |
| feat_clip_refine._load() | |
| if "rem" not in FS._STATE: | |
| from transparent_background import Remover | |
| FS._STATE["rem"] = Remover() | |
| if explain: | |
| explain_vlm.load_vlm(str(EX.ADAPTER_DIR)) | |