File size: 7,083 Bytes
390a447
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7ba0f2e
 
 
 
 
 
 
 
390a447
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
441b11b
390a447
 
 
 
 
 
 
 
 
 
 
 
 
 
dd0c9d7
 
 
 
 
390a447
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
"""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))