File size: 20,843 Bytes
6068a05
de2a6e9
 
6068a05
de2a6e9
 
 
 
 
 
 
 
 
6068a05
 
 
de2a6e9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3751e88
 
de2a6e9
 
 
 
 
 
 
6068a05
de2a6e9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6068a05
de2a6e9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6068a05
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
de2a6e9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6068a05
 
 
 
 
 
de2a6e9
 
 
 
 
 
 
 
 
 
 
6068a05
 
 
 
 
 
 
de2a6e9
 
 
 
3751e88
de2a6e9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6068a05
 
de2a6e9
 
 
 
6068a05
 
 
 
 
de2a6e9
 
 
 
 
 
 
6068a05
de2a6e9
 
 
 
6068a05
 
de2a6e9
 
 
 
6068a05
de2a6e9
 
 
6068a05
 
 
 
de2a6e9
 
 
6068a05
 
 
 
 
 
 
 
 
 
 
 
 
 
de2a6e9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6068a05
de2a6e9
 
 
 
 
 
 
 
 
6068a05
de2a6e9
 
 
6068a05
 
de2a6e9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
"""Real pinned regional priors: CPU default plus optional StreetCLIP ZeroGPU.

Model acquisition runs ONLY in the declared HF Space, never on a developer host.
No map-service credentials or paid hardware changes are used. Optional GPU calls use ordinary ZeroGPU quotas.
"""
import os
os.environ.setdefault("HF_HUB_DISABLE_TELEMETRY", "1")
os.environ.setdefault("GRADIO_ANALYTICS_ENABLED", "False")
os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")
# Import spaces before Gradio/Torch, as required by HF Spaces runtime integration.
import spaces  # noqa: F401,E402

import importlib.metadata
import copy
from contextlib import nullcontext
from spaces.config import Config as SpacesConfig
import json
from pathlib import Path
import threading
import time
import traceback
import warnings

import gradio as gr
from PIL import Image, ImageOps
import torch
import torch.nn.functional as F

from contracts import (MAX_IMAGE_PIXELS, caption_rows, coordinate_rows, top_k_value)

TARGET_SPACE = "SammySnake/CLIP"
STREET_REPO = "geolocal/StreetCLIP"
STREET_REVISION = "e3561ba2ad9bf14c9efd6b0092607b8497efbfea"
GEO_REPO = "VicenteVivanco/geoclip-geolocation"
GEO_REVISION = "40a931123d2c6451ef7da1d37cf68cded0ec4d35"
BACKBONE_REPO = "openai/clip-vit-large-patch14"
BACKBONE_REVISION = "32bd64288804d66eefd0ccbe215aa642df71cc41"

Image.MAX_IMAGE_PIXELS = MAX_IMAGE_PIXELS
warnings.filterwarnings("error", category=Image.DecompressionBombWarning)


def read_limit(path):
    try:
        return Path(path).read_text().strip()
    except OSError:
        return None


def cpu_threads():
    available = len(os.sched_getaffinity(0)) if hasattr(os, "sched_getaffinity") else (os.cpu_count() or 1)
    quota = read_limit("/sys/fs/cgroup/cpu.max")
    if quota:
        parts = quota.split()
        if len(parts) == 2 and parts[0] != "max":
            available = min(available, max(1, int(parts[0]) // int(parts[1])))
    return max(1, min(4, available))


torch.set_num_threads(cpu_threads())
# The HF/ZeroGPU runtime initializes inter-op scheduling before this app loads.
# Do not reset that process-global one-shot setting after initialization.

_MODELS = {}
_GALLERY = None
_STATE_LOCK = threading.Lock()
_INFER_LOCK = threading.Lock()
_STATE = {
    "streetclip": {"phase": "not_started", "repo": STREET_REPO, "revision": STREET_REVISION},
    "streetclip_gpu": {"phase": "not_started", "repo": STREET_REPO, "revision": STREET_REVISION},
    "geoclip": {"phase": "not_started", "repo": GEO_REPO, "revision": GEO_REVISION},
    "geoclip_gallery": {"phase": "not_started", "rows_encoded": 0},
}


def phase(name, state, **metadata):
    with _STATE_LOCK:
        _STATE[name].update(phase=state, **metadata)
    print(json.dumps({"event": "model_state", "model": name, "phase": state, **metadata}), flush=True)


def features(value):
    """Transformers 4.x tensor / 5.x projected pooler_output compatibility."""
    if isinstance(value, torch.Tensor):
        return value
    if isinstance(getattr(value, "pooler_output", None), torch.Tensor):
        return value.pooler_output
    raise TypeError("Unexpected CLIP feature output; model inference not accepted.")


def load_models():
    """Cloud-only downloader. Failure is exposed, never replaced with dummy weights."""
    global _GALLERY
    if os.environ.get("SPACE_ID") != TARGET_SPACE:
        for name in ("streetclip", "streetclip_gpu", "geoclip", "geoclip_gallery"):
            phase(name, "disabled_outside_target_space")
        return
    start = time.perf_counter()
    try:
        phase("streetclip", "loading")
        from huggingface_hub import snapshot_download
        from transformers import CLIPModel, CLIPProcessor
        folder = snapshot_download(
            STREET_REPO, revision=STREET_REVISION,
            allow_patterns=["*.json", "merges.txt", "pytorch_model.bin"],
        )
        processor = CLIPProcessor.from_pretrained(folder, local_files_only=True)
        model = CLIPModel.from_pretrained(
            folder, local_files_only=True, use_safetensors=False,
        ).eval().to("cpu")
        model.requires_grad_(False)
        _MODELS["streetclip"] = (model, processor)
        phase("streetclip", "ready", load_seconds=round(time.perf_counter() - start, 3), device="cpu")
    except Exception as exc:
        phase("streetclip", "error", error_type=type(exc).__name__)
        traceback.print_exc()

    # Stage an independent copy before decorator registration / Gradio startup.
    # CPU parameters are never moved to CUDA or mutated by a GPU request.
    if SpacesConfig.zero_gpu and "streetclip" in _MODELS:
        try:
            phase("streetclip_gpu", "loading")
            cpu_model, processor = _MODELS["streetclip"]
            gpu_model = copy.deepcopy(cpu_model).to("cuda").eval()
            if next(gpu_model.parameters()).device.type != "cuda":
                raise RuntimeError("StreetCLIP GPU copy was not placed on CUDA.")
            _MODELS["streetclip_gpu"] = (gpu_model, processor)
            phase("streetclip_gpu", "ready", device="cuda", allocation="zerogpu_staged",
                  note="Real GPU execution occurs only during the explicit GPU endpoint call.")
        except Exception as exc:
            phase("streetclip_gpu", "error", error_type=type(exc).__name__)
            traceback.print_exc()
    else:
        phase("streetclip_gpu", "unavailable", reason="ZeroGPU inactive or CPU model failed to load")

    start = time.perf_counter()
    try:
        phase("geoclip", "loading")
        from geoclip import GeoCLIP
        model = GeoCLIP.from_pretrained(GEO_REPO, revision=GEO_REVISION, device="cpu")
        model.requires_grad_(False)
        _MODELS["geoclip"] = model
        phase("geoclip", "ready", load_seconds=round(time.perf_counter() - start, 3), device="cpu",
              gallery_size=len(model.gps_gallery))
        phase("geoclip_gallery", "encoding")
        gallery_features = []
        with torch.inference_mode():
            for first in range(0, len(model.gps_gallery), 1024):
                batch = model.gps_gallery[first:first + 1024]
                gallery_features.append(F.normalize(model.location_encoder(batch), dim=-1))
                with _STATE_LOCK:
                    _STATE["geoclip_gallery"]["rows_encoded"] = min(first + 1024, len(model.gps_gallery))
        _GALLERY = torch.cat(gallery_features, dim=0)
        phase("geoclip_gallery", "ready", rows_encoded=len(_GALLERY), dimension=_GALLERY.shape[1])
    except Exception as exc:
        name = "geoclip_gallery" if "geoclip" in _MODELS else "geoclip"
        phase(name, "error", error_type=type(exc).__name__)
        traceback.print_exc()


# Synchronous root-module setup is intentional for ZeroGPU model staging.
# Local imports / schema tests never enter this branch and never fetch weights.
if os.environ.get("SPACE_ID") == TARGET_SPACE:
    load_models()


def installed_version(name):
    try:
        return importlib.metadata.version(name)
    except importlib.metadata.PackageNotFoundError:
        return "not_installed"


def status():
    with _STATE_LOCK:
        state = json.loads(json.dumps(_STATE))
    return {
        "app": "GeoCLIP + StreetCLIP regional priors", "app_version": 3,
        "space": TARGET_SPACE, "execution": "cpu_default_optional_zerogpu", "models": state,
        "all_models_ready": all(state[name]["phase"] == "ready" for name in
                                ("streetclip", "geoclip", "geoclip_gallery")),
        "optional_gpu_ready": state["streetclip_gpu"]["phase"] == "ready",
        "gpu_runtime_policy": {"endpoint": "/streetclip_score_gpu", "duration_seconds": 30,
                               "uses_normal_zerogpu_quota": True, "automatic_fallback": False},
        "limits": {"upload_bytes": 10_000_000, "image_pixels": MAX_IMAGE_PIXELS,
                   "captions": 100, "coordinate_candidates": 100, "top_k": 20,
                   "queue": 8, "inference_concurrency": 1},
        "runtime": {"torch_threads": torch.get_num_threads(),
                    "torch_interop_threads": torch.get_num_interop_threads(),
                    "cpu_affinity_count": len(os.sched_getaffinity(0)) if hasattr(os, "sched_getaffinity") else None,
                    "cpu_max": read_limit("/sys/fs/cgroup/cpu.max"),
                    "memory_max": read_limit("/sys/fs/cgroup/memory.max"),
                    "memory_current": read_limit("/sys/fs/cgroup/memory.current")},
        "versions": {name: installed_version(name) for name in
                     ("torch", "transformers", "geoclip", "gradio", "huggingface-hub")},
        "backbone": {"repo": BACKBONE_REPO, "revision": BACKBONE_REVISION},
        "warning": "Scores rank hypotheses, not calibrated location confidence. No precise-location verification.",
    }


def image_rgb(image):
    if not isinstance(image, Image.Image):
        raise gr.Error("Upload a real ground-level image.")
    if image.width < 16 or image.height < 16 or image.width * image.height > MAX_IMAGE_PIXELS:
        raise gr.Error("Image must be at least 16×16 and no larger than 16 megapixels.")
    return ImageOps.exif_transpose(image).convert("RGB")


def validated(parser, value):
    try:
        return parser(value)
    except ValueError as exc:
        raise gr.Error(str(exc)) from exc


def require(name):
    if name not in _MODELS:
        with _STATE_LOCK:
            current = _STATE[name]["phase"]
        raise gr.Error(f"{name} is {current}. Check /status and retry after loading completes.")
    return _MODELS[name]


def common_result(model, image, started):
    return {
        "model": model, "device": "cpu", "elapsed_seconds": round(time.perf_counter() - started, 4),
        "input_width": image.width, "input_height": image.height,
        "coordinate_verified": False, "calibrated_confidence": False,
        "warning": "Regional hypothesis ranking only. Verify with independent geometry/imagery; scores are not probabilities of correctness.",
    }


def _streetclip_score_impl(image, captions_json, top_k, *, model_key, device):
    """Shared real model math; device is controlled by endpoint, not user input."""
    started = time.perf_counter()
    image = image_rgb(image)
    rows = validated(caption_rows, captions_json)
    top_k = validated(top_k_value, top_k)
    model, processor = require(model_key)
    # Do not carry a parent thread lock into ZeroGPU's isolated worker process.
    guard = _INFER_LOCK if device == "cpu" else nullcontext()
    with guard, torch.inference_mode():
        pixels = processor(images=image, return_tensors="pt")["pixel_values"].to(device)
        image_features = F.normalize(features(model.get_image_features(pixel_values=pixels)), dim=-1)
        all_scores = []
        for first in range(0, len(rows), 16):
            texts = [row["text"] for row in rows[first:first + 16]]
            inputs = processor.tokenizer(texts, padding=True, truncation=False, return_tensors="pt")
            if inputs["input_ids"].shape[1] > model.config.text_config.max_position_embeddings:
                raise gr.Error("A caption exceeds the CLIP 77-token limit. Shorten it; no silent truncation is performed.")
            inputs = {key: value.to(device) for key, value in inputs.items()}
            text_features = F.normalize(features(model.get_text_features(**inputs)), dim=-1)
            all_scores.append((image_features @ text_features.T).squeeze(0))
        cosine = torch.cat(all_scores)
        logits = cosine * model.logit_scale.exp()
        # Only CPU scalars cross the ZeroGPU process boundary.
        cosine, logits = cosine.float().cpu(), logits.float().cpu()
        order = torch.argsort(cosine, descending=True)[:min(top_k, len(rows))]
        ranking = [{"rank": rank + 1, **rows[index], "cosine_similarity": float(cosine[index]),
                    "logit": float(logits[index])} for rank, index in enumerate(order.tolist())]
    result = common_result("StreetCLIP", image, started)
    result.update(device=device, revision=STREET_REVISION, candidate_count=len(rows), ranking=ranking,
                  preprocessing="Official 336px CLIP image processor; English text, maximum 77 tokens.",
                  limitations=["StreetCLIP's training dataset excludes China and India.",
                               "Candidate phrasing and candidate coverage affect rankings; cosine is not calibrated confidence."])
    if device == "cuda":
        result["gpu"] = {"name": torch.cuda.get_device_name(),
                         "allocated_bytes": torch.cuda.memory_allocated(),
                         "duration_seconds": 30, "uses_normal_zerogpu_quota": True}
    return result


def streetclip_score(image, captions_json, top_k=10):
    """Default CPU scoring: no GPU allocation is requested by this endpoint."""
    return _streetclip_score_impl(image, captions_json, top_k, model_key="streetclip", device="cpu")


@spaces.GPU(duration=30)
def streetclip_score_gpu(image, captions_json, top_k=10):
    """Optional genuine CUDA inference; ordinary quota/capacity errors propagate."""
    # Match CPU FP32 scoring: no autocast or TF32 approximation.
    torch.backends.cuda.matmul.allow_tf32 = False
    torch.backends.cudnn.allow_tf32 = False
    return _streetclip_score_impl(image, captions_json, top_k, model_key="streetclip_gpu", device="cuda")


def geoclip_candidates(image, coordinates_json, top_k=10):
    """Score caller-supplied GPS lat/lon hypotheses with the real GeoCLIP encoder."""
    started = time.perf_counter()
    image = image_rgb(image)
    rows = validated(coordinate_rows, coordinates_json)
    top_k = validated(top_k_value, top_k)
    model = require("geoclip")
    with _INFER_LOCK, torch.inference_mode():
        pixels = model.image_encoder.preprocess_image(image)
        coordinates = torch.tensor([[row["lat"], row["lon"]] for row in rows], dtype=torch.float32)
        logits = model(pixels, coordinates).squeeze(0)
        cosine = logits / model.logit_scale.exp()
        order = torch.argsort(logits, descending=True)[:min(top_k, len(rows))]
        ranking = [{"rank": rank + 1, **rows[index], "cosine_similarity": float(cosine[index]),
                    "logit": float(logits[index])} for rank, index in enumerate(order.tolist())]
    result = common_result("GeoCLIP", image, started)
    result.update(revision=GEO_REVISION, candidate_count=len(rows), ranking=ranking,
                  coordinate_order="latitude, longitude in degrees; GPS, not GCJ-02/BD-09",
                  preprocessing="Official pinned OpenAI CLIP ViT-L/14 image processor (224px).",
                  warning="Scores are regional priors, not exact GPS verification; candidate coordinates are input hypotheses.")
    return result


def geoclip_predict(image, top_k=10):
    """Retrieve hypotheses from the author's frozen 100,000-coordinate gallery."""
    started = time.perf_counter()
    image = image_rgb(image)
    top_k = validated(top_k_value, top_k)
    model = require("geoclip")
    if _GALLERY is None:
        raise gr.Error("GeoCLIP coordinate gallery is still encoding or failed; inspect /status.")
    with _INFER_LOCK, torch.inference_mode():
        pixels = model.image_encoder.preprocess_image(image)
        image_features = F.normalize(model.image_encoder(pixels), dim=-1)
        cosine = (image_features @ _GALLERY.T).squeeze(0)
        logits = cosine * model.logit_scale.exp()
        relative_softmax = logits.softmax(dim=-1)
        order = torch.argsort(logits, descending=True)[:top_k]
        ranking = [{"rank": rank + 1, "gallery_index": index,
                    "lat": float(model.gps_gallery[index, 0]), "lon": float(model.gps_gallery[index, 1]),
                    "cosine_similarity": float(cosine[index]), "logit": float(logits[index]),
                    "gallery_relative_softmax": float(relative_softmax[index])}
                   for rank, index in enumerate(order.tolist())]
    result = common_result("GeoCLIP", image, started)
    result.update(revision=GEO_REVISION, gallery_count=len(_GALLERY), ranking=ranking,
                  coordinate_order="latitude, longitude in degrees; GPS, not GCJ-02/BD-09",
                  warning="These are coordinates already present in the author's gallery, not measured camera positions. gallery_relative_softmax is relative to this gallery, not confidence of correctness.")
    return result


def make_demo():
    with gr.Blocks(analytics_enabled=False, delete_cache=(600, 3600)) as demo:
        gr.Markdown("""# GeoCLIP + StreetCLIP · 区域先验检索
这两个模型负责**缩小和排序候选**,不会把照片重建成卫星图,也不证明精确坐标。
默认 CPU 实际推理;模型只下载到此 HF 容器。另提供可选 StreetCLIP GPU 推理,使用正常 ZeroGPU 配额;不承诺无限算力或永久在线。
StreetCLIP 为 **CC-BY-NC-4.0 非商业用途**;其训练数据不包含中国与印度,国内结果必须另行核验。
只上传你有权处理的景物照片。请勿上传用于追踪他人的私人照片。上传文件为临时文件;超过一小时的文件每十分钟清理一次。
""")
        with gr.Tab("StreetCLIP · 图文候选"):
            street_image = gr.Image(type="pil", sources=["upload"], label="原始地面照片(不是 BEV 可视化)")
            captions = gr.Textbox(label='JSON 字符串数组,或 [{"id":"a","text":"..."}];建议英语', lines=5,
                                  value='["A street in Jiangsu, China.", "A street in Anhui, China.", "A street in Zhejiang, China."]')
            street_k = gr.Number(value=10, precision=0, minimum=1, maximum=20, label="Top K")
            street_button = gr.Button("对候选文字打分")
            street_gpu_button = gr.Button("可选:GPU 打分(消耗正常 ZeroGPU 配额)")
            street_output = gr.JSON(label="相对分数,不是定位置信度")
            street_button.click(streetclip_score, [street_image, captions, street_k], street_output,
                                api_name="streetclip_score", concurrency_id="inference", concurrency_limit=1)
            street_gpu_button.click(streetclip_score_gpu, [street_image, captions, street_k], street_output,
                                    api_name="streetclip_score_gpu", concurrency_id="inference", concurrency_limit=1)
        with gr.Tab("GeoCLIP · 全球粗排"):
            geo_image = gr.Image(type="pil", sources=["upload"], label="原始地面照片")
            geo_k = gr.Number(value=10, precision=0, minimum=1, maximum=20, label="Top K")
            geo_button = gr.Button("检索作者的 100K 坐标候选库")
            geo_output = gr.JSON(label="候选坐标,未经几何核验")
            geo_button.click(geoclip_predict, [geo_image, geo_k], geo_output,
                             api_name="geoclip_predict", concurrency_id="inference", concurrency_limit=1)
        with gr.Tab("GeoCLIP · 自定义区域候选"):
            candidate_image = gr.Image(type="pil", sources=["upload"], label="原始地面照片")
            candidates = gr.Textbox(label='[{"id":"candidate-a","lat":31.0,"lon":120.0}],最多100项', lines=5,
                                    value='[{"id":"candidate-a","lat":31.0,"lon":120.0}, {"id":"candidate-b","lat":33.0,"lon":118.0}]')
            candidate_k = gr.Number(value=10, precision=0, minimum=1, maximum=20, label="Top K")
            candidate_button = gr.Button("评分给定 GPS 坐标")
            candidate_output = gr.JSON(label="区域先验分数")
            candidate_button.click(geoclip_candidates, [candidate_image, candidates, candidate_k], candidate_output,
                                   api_name="geoclip_candidates", concurrency_id="inference", concurrency_limit=1)
        with gr.Tab("状态 / 来源"):
            status_button = gr.Button("检查真实模型加载状态")
            status_output = gr.JSON(label="状态与固定模型版本")
            status_button.click(status, [], status_output, api_name="status", queue=False)
            gr.Markdown("""[GeoCLIP · NeurIPS 2023](https://github.com/VicenteVivan/geo-clip) ·
[StreetCLIP · model card](https://huggingface.co/geolocal/StreetCLIP) ·
[来源与许可](https://huggingface.co/spaces/SammySnake/CLIP/blob/main/ATTRIBUTION.md)

建议链路:区域先验 → Sample4Geo/其他跨视角模型的候选排序 → OrienterNet BEV 与语义地图匹配 → 独立实景/几何核验。
不要平均这几个模型未经校准的原始分数,也不要把 BEV 特征着色图当作真实卫星图输入它们。
""")
    return demo


if __name__ == "__main__":
    make_demo().queue(max_size=8, default_concurrency_limit=1).launch(
        server_name="0.0.0.0", server_port=7860, max_file_size="10mb", show_error=True,
    )