Spaces:
Running on Zero
Running on Zero
File size: 9,488 Bytes
28dbedc efa9c6f 28dbedc efa9c6f 28dbedc efa9c6f 28dbedc efa9c6f 28dbedc | 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 | """Checkpoint-native inference. No case catalog, references, or HTTP fallback here.
This module deliberately avoids importing torch at import time: spaces must install
its CUDA hooks first. The GPU boundary lives in zerogpu_backend, not in this module.
"""
from __future__ import annotations
import hashlib
import json
import os
import re
from pathlib import Path
class ConfigurationError(RuntimeError):
pass
class IncompleteGeneration(RuntimeError):
pass
def resolve_snapshot() -> Path:
local = os.getenv("GROUNDING_MODEL_PATH", "").strip()
repo = os.getenv("GROUNDING_MODEL_REPO", "").strip()
if local and repo:
raise ConfigurationError("Configure either GROUNDING_MODEL_PATH or GROUNDING_MODEL_REPO, not both.")
if local:
return Path(local).expanduser().resolve(strict=True)
if not repo:
raise ConfigurationError("Configure GROUNDING_MODEL_REPO and GROUNDING_MODEL_REVISION first.")
revision = os.getenv("GROUNDING_MODEL_REVISION", "").strip()
if not re.fullmatch(r"[0-9a-fA-F]{40}", revision):
raise ConfigurationError("GROUNDING_MODEL_REVISION must be the full 40-character commit SHA of reviewed model code.")
from huggingface_hub import snapshot_download
# The custom processor uses local open(). Download a pinned snapshot first;
# passing a Hub repo ID directly to this processor does not work.
return Path(snapshot_download(
repo_id=repo, revision=revision, token=os.getenv("HF_TOKEN") or None,
allow_patterns=["*.json", "*.py", "*.jinja", "*.txt", "*.safetensors"],
))
def inspect_contract(root: Path, *, require_weights: bool = True) -> dict:
"""Validate the public GroundingPI release without executing model code."""
required = [
"config.json", "preprocessor_config.json", "generation_config.json",
"tokenizer.json", "tokenizer_config.json", "chat_template.jinja",
"configuration_groundingpi.py", "configuration_groundingpi_vision.py",
"modeling_groundingpi.py", "modeling_groundingpi_vision.py",
"processing_groundingpi.py", "image_processing_groundingpi.py", "media_utils.py",
"streammind_gate.py",
]
missing = [name for name in required if not (root / name).is_file()]
if missing:
raise ConfigurationError(f"Model package is missing files: {', '.join(missing)}")
config = json.loads((root / "config.json").read_text())
if config.get("model_type") != "groundingpi":
raise ConfigurationError("This demo requires the GroundingPI/GroundingPI model package.")
if config.get("vision_config", {}).get("model_type") != "groundingpi_vision":
raise ConfigurationError("The model does not use the expected Kimi-K3 ViT.")
if not config.get("auto_map", {}).get("AutoModelForImageTextToText"):
raise ConfigurationError("The model is missing the AutoModelForImageTextToText mapping.")
if not config.get("auto_map", {}).get("AutoProcessor"):
raise ConfigurationError("The model is missing its custom AutoProcessor mapping.")
tokenizer = json.loads((root / "tokenizer.json").read_text())
vocab = dict(tokenizer.get("model", {}).get("vocab", {}))
vocab.update({item["content"]: item["id"] for item in tokenizer.get("added_tokens", [])})
gam_tokens = [f"<{n}>" for n in range(1000)] + [
"<|box_start|>", "<|box_end|>", "<|object_ref_start|>", "<|object_ref_end|>",
]
if any(token not in vocab for token in gam_tokens):
raise ConfigurationError("The tokenizer is missing native GAM coordinate or grounding tokens.")
if vocab.get("<|image_pad|>") != config.get("image_token_id"):
raise ConfigurationError("The image token ID does not match the model configuration.")
generation = json.loads((root / "generation_config.json").read_text())
eos = generation.get("eos_token_id")
eos = eos if isinstance(eos, list) else [eos]
if not eos or any(not isinstance(token, int) for token in eos):
raise ConfigurationError("generation_config has no valid end-of-sequence token.")
shards = []
if require_weights:
index = root / "model.safetensors.index.json"
if index.is_file():
shards = sorted(set(json.loads(index.read_text())["weight_map"].values()))
elif (root / "model.safetensors").is_file():
shards = ["model.safetensors"]
if not shards or any(
Path(name).name != name or not name.endswith(".safetensors")
or not (root / name).is_file() or (root / name).stat().st_size == 0
for name in shards
):
raise ConfigurationError("Model weight shards are incomplete. Upload the weights as well as the tokenizer and configuration.")
prep = json.loads((root / "preprocessor_config.json").read_text())
return {
"architecture": config["architectures"], "vision": "groundingpi_vision",
"config_sha256": hashlib.sha256((root / "config.json").read_bytes()).hexdigest(),
"processor_sha256": hashlib.sha256((root / "preprocessor_config.json").read_bytes()).hexdigest(),
"media_proc_cfg": prep.get("media_proc_cfg"), "gam_coordinate_tokens": 1000,
"eos_token_ids": eos, "weight_shards": shards,
"scope": "package_contract_only_not_gpu_inference",
}
def user_messages(prompt: str) -> list[dict]:
text = prompt.replace("<image>", "").strip()
# Exactly one image. Do not let text inject another image placeholder or role.
forbidden = ["<|image_pad|>", "<|video_pad|>", "<|image_placeholder|>",
"<|vision_start|>", "<|vision_end|>", "<|im_start|>", "<|im_end|>"]
if not text or any(token in text for token in forbidden):
raise ValueError("The prompt is empty or contains reserved image/conversation control tokens.")
return [{"role": "user", "content": [{"type": "image"}, {"type": "text", "text": text}]}]
def decode_answer(processor, generated_ids: list[int], eos_ids: list[int], limit: int) -> str:
# Keep GAM coordinate tokens. skip_special_tokens=True would erase evidence.
if not generated_ids:
raise IncompleteGeneration("The model returned no answer.")
if generated_ids[-1] not in eos_ids:
raise IncompleteGeneration(
f"Generation did not reach EOS ({len(generated_ids)}/{limit} tokens). Truncated output is not accepted as a valid result."
)
ids = list(generated_ids)
while ids and ids[-1] in eos_ids:
ids.pop()
text = processor.decode(ids, skip_special_tokens=False, spaces_between_special_tokens=False)
if not text.strip():
raise IncompleteGeneration("The model returned EOS without a grounding result.")
return text
class NativeRuntime:
def __init__(self, model, processor, eos_ids: list[int]):
self.model, self.processor, self.eos_ids = model, processor, eos_ids
@classmethod
def load(cls, root: Path) -> "NativeRuntime":
contract = inspect_contract(root)
import torch
from transformers import AutoModelForImageTextToText, AutoProcessor
processor = AutoProcessor.from_pretrained(str(root), trust_remote_code=True)
model = AutoModelForImageTextToText.from_pretrained(
str(root), trust_remote_code=True, dtype=torch.bfloat16,
attn_implementation="sdpa",
).eval().to("cuda")
return cls(model, processor, contract["eos_token_ids"])
def prepare(self, image_path: str | Path, prompt: str, limit: int):
from PIL import Image
path = Path(image_path)
if path.stat().st_size > 30 * 1024 * 1024:
raise ValueError("Image exceeds 30 MiB. Reduce the file size and retry.")
if not 1 <= limit <= 2048:
raise ConfigurationError("GROUNDING_MAX_TOKENS must be between 1 and 2048.")
with Image.open(path) as source:
if source.width * source.height > 50_000_000:
raise ValueError("Image exceeds 50 megapixels. Reduce its dimensions and retry.")
# Preserve RGBA: the checkpoint applies its own alpha background policy.
image = source.copy()
rendered = self.processor.apply_chat_template(
user_messages(prompt), tokenize=False, add_generation_prompt=True,
)
inputs = self.processor(text=[rendered], images=[image], return_tensors="pt")
prompt_tokens = int(inputs["input_ids"].shape[-1])
if prompt_tokens + limit > 8192:
raise ValueError("Image and prompt exceed the 8192-token context budget. Shorten the prompt; inputs will not be silently truncated.")
if "pixel_values" not in inputs or "image_grid_thw" not in inputs:
raise ConfigurationError("The processor returned no image pixels or grid. Inference has been stopped.")
return inputs, prompt_tokens
def generate_ids(self, inputs, limit: int) -> list[int]:
"""Must run inside @spaces.GPU on ZeroGPU. No loading or HTTP here."""
import torch
device_inputs = {key: value.to("cuda") for key, value in inputs.items()}
with torch.inference_mode():
output = self.model.generate(
**device_inputs, max_new_tokens=limit, do_sample=False,
use_cache=True, eos_token_id=self.eos_ids,
)
start = inputs["input_ids"].shape[-1]
# No GPU tensor escapes the temporary GPU lease.
return output[0, start:].detach().cpu().tolist()
|