Mike0021's picture
Fix Server API progress signature
9049aa0 verified
Raw
History Blame Contribute Delete
18.2 kB
import os
# ZeroGPU and library caches must be configured before importing spaces/torch.
os.environ.setdefault("HF_HOME", os.path.expanduser("~/.cache/huggingface"))
os.environ.setdefault("HF_MODULES_CACHE", "/tmp/hf_modules")
os.environ.setdefault("MPLCONFIGDIR", "/tmp/matplotlib")
os.environ.setdefault("GRADIO_ANALYTICS_ENABLED", "False")
os.environ.setdefault("GRADIO_SSR_MODE", "false")
os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")
import spaces
import logging
import threading
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import gradio as gr
import torch
import torch.nn.functional as F
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from gradio.data_classes import FileData
from sentence_transformers import SentenceTransformer
MODEL_ID = "tencent/WeMM-Embedding-9B"
ROOT = Path(__file__).resolve().parent
ASSET_DIR = ROOT / "assets"
FRONTEND_DIR = ROOT / "frontend"
MATRYOSHKA_DIMS = (64, 128, 256, 512, 1024, 2048, 4096)
MAX_CUSTOM_TEXTS = 6
MAX_CUSTOM_MEDIA = 6
logging.basicConfig(level=logging.INFO, format="%(asctime)s | %(levelname)s | %(message)s")
LOGGER = logging.getLogger("wemm-space")
@dataclass(frozen=True)
class Candidate:
key: str
title: str
kind: str
description: str
payload: Any
media_path: str | None = None
SHOWCASE: tuple[Candidate, ...] = (
Candidate(
"llama4",
"Llama 4 model card",
"visual document",
"A dense model-card screenshot describing the Scout and Maverick variants.",
str(ASSET_DIR / "llama4_hgf.png"),
str(ASSET_DIR / "llama4_hgf.png"),
),
Candidate(
"qwen-omni",
"Qwen2.5-Omni overview",
"visual document",
"A model page covering omni-modal perception, speech, and video capabilities.",
str(ASSET_DIR / "qwen2.5omni_hgf.png"),
str(ASSET_DIR / "qwen2.5omni_hgf.png"),
),
Candidate(
"likelihood-contour",
"Scientific contour plot",
"figure",
"An orange likelihood contour plotted against gamma and log-scaled tau over mass.",
str(ASSET_DIR / "doc1.jpg"),
str(ASSET_DIR / "doc1.jpg"),
),
Candidate(
"budget-1971",
"1971 budget infographic",
"visual document",
"A historical chart comparing US outlays, including natural resources spending.",
str(ASSET_DIR / "doc2.jpg"),
str(ASSET_DIR / "doc2.jpg"),
),
Candidate(
"scoring-rules",
"Proper scoring rules paper",
"visual document",
"An academic page with lemmas, an algorithm, equations, and references.",
str(ASSET_DIR / "doc3.jpg"),
str(ASSET_DIR / "doc3.jpg"),
),
Candidate(
"road-safety",
"Road-safety assessment",
"visual document",
"An environmental assessment page about driver training, signs, and road closures.",
str(ASSET_DIR / "doc4.jpg"),
str(ASSET_DIR / "doc4.jpg"),
),
Candidate(
"mapo-tofu",
"Mapo tofu in motion",
"video",
"A short cooking clip showing the preparation of the Sichuan tofu dish.",
str(ASSET_DIR / "mapo_tofu.mp4"),
str(ASSET_DIR / "mapo_tofu.mp4"),
),
Candidate(
"zhajiang-noodles",
"Zhajiang noodles in motion",
"video",
"A short cooking clip showing noodles with a savory fermented-bean sauce.",
str(ASSET_DIR / "zhajiang_noodle.mp4"),
str(ASSET_DIR / "zhajiang_noodle.mp4"),
),
Candidate(
"vector-search",
"How vector search works",
"text",
"Dense retrieval maps queries and documents into one normalized vector space, then ranks candidates by cosine similarity.",
"Dense retrieval maps queries and documents into one normalized vector space, then ranks candidates by cosine similarity.",
),
Candidate(
"night-train",
"A quiet journey",
"text",
"夜行列车穿过雨中的城市,车窗映出霓虹灯和安静的乘客。",
"夜行列车穿过雨中的城市,车窗映出霓虹灯和安静的乘客。",
),
)
LOGGER.info("Loading %s on CPU", MODEL_ID)
MODEL = SentenceTransformer(
MODEL_ID,
trust_remote_code=True,
device="cpu",
model_kwargs={"dtype": torch.bfloat16, "low_cpu_mem_usage": True},
)
MODEL.eval()
torch.set_grad_enabled(False)
LOGGER.info("Model loaded; waiting for a ZeroGPU allocation")
_CACHE_LOCK = threading.Lock()
_INFERENCE_LOCK = threading.Lock()
_SHOWCASE_EMBEDDINGS: torch.Tensor | None = None
def _file_path(value: Any) -> str | None:
"""Normalize Gradio file values across browser and programmatic clients."""
if value is None:
return None
if isinstance(value, (str, Path)):
return str(value)
if isinstance(value, dict):
path = value.get("path") or value.get("name")
return str(path) if path else None
path = getattr(value, "path", None) or getattr(value, "name", None)
return str(path) if path else None
def _is_video(path: str) -> bool:
return Path(path.split("?", 1)[0]).suffix.lower() in {
".mp4", ".webm", ".mov", ".mkv", ".avi", ".mpeg", ".mpg"
}
def _multimodal_payload(text: str | None, image: Any, video: Any) -> tuple[Any, str]:
text = (text or "").strip()
image_path = _file_path(image)
video_path = _file_path(video)
if image_path and video_path:
raise gr.Error("Choose one visual query: an image or a video, not both.")
if image_path:
return ({"image": image_path, "text": text}, "image + text") if text else (image_path, "image")
if video_path:
return ({"video": video_path, "text": text}, "video + text") if text else (video_path, "video")
if text:
return text, "text"
raise gr.Error("Add a text, image, or video query to begin.")
def _parse_text_candidates(raw: str | None) -> list[Candidate]:
candidates: list[Candidate] = []
lines = [line.strip() for line in (raw or "").splitlines() if line.strip()]
if len(lines) > MAX_CUSTOM_TEXTS:
raise gr.Error(f"Use at most {MAX_CUSTOM_TEXTS} custom text candidates.")
for index, line in enumerate(lines, start=1):
if "::" in line:
title, body = (part.strip() for part in line.split("::", 1))
title = title or f"Custom text {index}"
body = body or title
else:
title, body = f"Custom text {index}", line
candidates.append(Candidate(f"custom-text-{index}", title[:80], "text", body[:240], body))
return candidates
def _parse_media_candidates(raw: Any) -> list[Candidate]:
candidates: list[Candidate] = []
items = raw or []
if len(items) > MAX_CUSTOM_MEDIA:
raise gr.Error(f"Upload at most {MAX_CUSTOM_MEDIA} candidate media files.")
for index, item in enumerate(items, start=1):
path = _file_path(item)
if not path:
continue
kind = "video" if _is_video(path) else "image"
candidates.append(
Candidate(
f"custom-media-{index}", f"Uploaded {kind} {index}", kind,
f"User-supplied {kind} candidate.", path, path,
)
)
return candidates
def _encode(items: list[Any], *, query: bool) -> torch.Tensor:
method = MODEL.encode_query if query else MODEL.encode_document
with torch.inference_mode():
embeddings = method(
items, batch_size=1, convert_to_tensor=True,
normalize_embeddings=True, show_progress_bar=False,
)
if embeddings.ndim == 1:
embeddings = embeddings.unsqueeze(0)
return embeddings.float().cpu()
def _truncate_normalize(embeddings: torch.Tensor, dimension: int) -> torch.Tensor:
return F.normalize(embeddings[..., :dimension], p=2, dim=-1)
def _dimension_scores(query: torch.Tensor, documents: torch.Tensor) -> dict[int, list[float]]:
scores: dict[int, list[float]] = {}
for dimension in MATRYOSHKA_DIMS:
query_d = _truncate_normalize(query, dimension)
docs_d = _truncate_normalize(documents, dimension)
scores[dimension] = (query_d @ docs_d.T).squeeze(0).tolist()
return scores
def _fingerprint(vector: torch.Tensor, bar_count: int = 96) -> list[float]:
values = vector.detach().float().flatten()
chunks = torch.tensor_split(values, min(bar_count, values.numel()))
return [round(float(chunk.mean()), 7) for chunk in chunks]
def _public_media(candidate: Candidate) -> str | None:
if not candidate.media_path:
return None
try:
relative = Path(candidate.media_path).resolve().relative_to(ASSET_DIR.resolve())
except ValueError:
return None
return f"/assets/{relative.as_posix()}"
def _search_duration(*args: Any, **kwargs: Any) -> int:
"""Budget more time for the cold corpus pass and video queries."""
query_payload = args[0] if args else None
custom_candidates = args[2] if len(args) > 2 else []
include_showcase = bool(args[3]) if len(args) > 3 else True
query_has_video = (
isinstance(query_payload, dict) and bool(query_payload.get("video"))
) or (isinstance(query_payload, str) and _is_video(query_payload))
has_uploaded_video = any(item.kind == "video" for item in custom_candidates or [])
if include_showcase and _SHOWCASE_EMBEDDINGS is None:
return 30
return 30 if query_has_video or has_uploaded_video else 15
@spaces.GPU(duration=_search_duration)
def _run_search(
query_payload: Any,
query_kind: str,
custom_candidates: list[Candidate],
include_showcase: bool,
dimension: int,
) -> tuple[torch.Tensor, torch.Tensor, list[Candidate], float, bool]:
global _SHOWCASE_EMBEDDINGS
started = time.perf_counter()
was_cold = include_showcase and _SHOWCASE_EMBEDDINGS is None
MODEL.to("cuda")
try:
query_embedding = _encode([query_payload], query=True)
document_blocks: list[torch.Tensor] = []
candidates: list[Candidate] = []
if include_showcase:
with _CACHE_LOCK:
cached = _SHOWCASE_EMBEDDINGS
if cached is None:
encoded = _encode([item.payload for item in SHOWCASE], query=False)
with _CACHE_LOCK:
if _SHOWCASE_EMBEDDINGS is None:
_SHOWCASE_EMBEDDINGS = encoded
cached = _SHOWCASE_EMBEDDINGS
document_blocks.append(cached)
candidates.extend(SHOWCASE)
if custom_candidates:
custom_embeddings = _encode([item.payload for item in custom_candidates], query=False)
document_blocks.append(custom_embeddings)
candidates.extend(custom_candidates)
if not document_blocks:
raise gr.Error("Include the showcase universe or add at least one candidate.")
documents = torch.cat(document_blocks, dim=0)
return query_embedding, documents, candidates, time.perf_counter() - started, was_cold
finally:
MODEL.to("cpu")
if torch.cuda.is_available():
torch.cuda.empty_cache()
def _pair_duration(*args: Any, **kwargs: Any) -> int:
has_video = any(
(isinstance(value, dict) and bool(value.get("video")))
or (isinstance(value, str) and _is_video(value))
for value in args[:2]
)
return 30 if has_video else 15
@spaces.GPU(duration=_pair_duration)
def _run_pair(
query_payload: Any,
candidate_payload: Any,
) -> tuple[torch.Tensor, torch.Tensor, float]:
started = time.perf_counter()
MODEL.to("cuda")
try:
query = _encode([query_payload], query=True)
candidate = _encode([candidate_payload], query=False)
return query, candidate, time.perf_counter() - started
finally:
MODEL.to("cpu")
if torch.cuda.is_available():
torch.cuda.empty_cache()
def _interpret_score(score: float) -> tuple[str, str]:
if score >= 0.75:
return "high alignment", "These inputs occupy a very similar region for this retrieval model."
if score >= 0.50:
return "meaningful alignment", "The model sees a substantial semantic relationship."
if score >= 0.25:
return "weak alignment", "There is some overlap, but stronger candidates may rank above it."
return "low alignment", "The model places these inputs relatively far apart."
app = gr.Server(
title="WeMM Semantic Universe API",
summary="Custom multimodal retrieval studio powered by tencent/WeMM-Embedding-9B",
version="2.0.0",
)
@app.api(
name="search",
description="Rank a mixed text/image/video collection using WeMM-Embedding-9B.",
concurrency_limit=1,
concurrency_id="wemm_gpu",
time_limit=300,
)
def search_api(
query_text: str = "",
query_image: FileData | None = None,
query_video: FileData | None = None,
custom_texts: str = "",
candidate_media: list[FileData] | None = None,
include_showcase: bool = True,
dimension: int = 1024,
) -> dict[str, Any]:
"""Search a mixed media collection and return structured visualization data."""
dimension = int(dimension)
if dimension not in MATRYOSHKA_DIMS:
raise gr.Error("Choose one of the model's native Matryoshka dimensions.")
query_payload, query_kind = _multimodal_payload(query_text, query_image, query_video)
custom_candidates = _parse_text_candidates(custom_texts) + _parse_media_candidates(candidate_media)
with _INFERENCE_LOCK:
query, documents, candidates, elapsed, was_cold = _run_search(
query_payload, query_kind, custom_candidates, bool(include_showcase), dimension,
)
dimension_map = _dimension_scores(query, documents)
chosen_scores = dimension_map[dimension]
order = sorted(range(len(candidates)), key=lambda index: chosen_scores[index], reverse=True)
ranked = [
{
"rank": rank,
"key": candidates[index].key,
"title": candidates[index].title,
"kind": candidates[index].kind,
"description": candidates[index].description,
"score": round(float(chosen_scores[index]), 7),
"media_url": _public_media(candidates[index]),
}
for rank, index in enumerate(order, start=1)
]
curve_series = [
{
"key": candidates[index].key,
"title": candidates[index].title,
"values": [round(float(dimension_map[dim][index]), 7) for dim in MATRYOSHKA_DIMS],
}
for index in order[:4]
]
return {
"mode": "search",
"model": MODEL_ID,
"query_kind": query_kind,
"dimension": dimension,
"dimensions": list(MATRYOSHKA_DIMS),
"candidate_count": len(candidates),
"elapsed_seconds": round(elapsed, 3),
"cache_state": "created" if was_cold else "reused" if include_showcase else "not requested",
"top_match": ranked[0],
"rankings": ranked,
"dimension_series": curve_series,
"query_fingerprint": _fingerprint(query[0, :dimension]),
"full_embedding_dimension": int(query.shape[-1]),
"query_vector_preview": [round(float(value), 7) for value in query[0, :12]],
"l2_norm_after_truncation": round(float(_truncate_normalize(query, dimension).norm()), 7),
"note": "Cosine similarity is a ranking signal, not a calibrated probability.",
}
@app.api(
name="compare",
description="Compare a multimodal query and candidate across every native Matryoshka dimension.",
concurrency_limit=1,
concurrency_id="wemm_gpu",
time_limit=240,
)
def compare_api(
query_text: str = "",
query_image: FileData | None = None,
query_video: FileData | None = None,
candidate_text: str = "",
candidate_image: FileData | None = None,
candidate_video: FileData | None = None,
dimension: int = 1024,
) -> dict[str, Any]:
"""Compare two supported inputs and return structured visualization data."""
dimension = int(dimension)
if dimension not in MATRYOSHKA_DIMS:
raise gr.Error("Choose one of the model's native Matryoshka dimensions.")
query_payload, query_kind = _multimodal_payload(query_text, query_image, query_video)
candidate_payload, candidate_kind = _multimodal_payload(
candidate_text, candidate_image, candidate_video
)
with _INFERENCE_LOCK:
query, candidate, elapsed = _run_pair(query_payload, candidate_payload)
scores = {
dim: float((_truncate_normalize(query, dim) @ _truncate_normalize(candidate, dim).T).item())
for dim in MATRYOSHKA_DIMS
}
selected_score = scores[dimension]
label, explanation = _interpret_score(selected_score)
return {
"mode": "compare",
"model": MODEL_ID,
"query_kind": query_kind,
"candidate_kind": candidate_kind,
"dimension": dimension,
"dimensions": list(MATRYOSHKA_DIMS),
"selected_score": round(selected_score, 7),
"label": label,
"explanation": explanation,
"scores": [round(scores[dim], 7) for dim in MATRYOSHKA_DIMS],
"query_fingerprint": _fingerprint(query[0, :dimension]),
"candidate_fingerprint": _fingerprint(candidate[0, :dimension]),
"elapsed_seconds": round(elapsed, 3),
"note": "Interpret thresholds relative to a task-specific candidate set.",
}
@app.get("/", response_class=HTMLResponse, include_in_schema=False)
async def homepage() -> HTMLResponse:
return HTMLResponse((FRONTEND_DIR / "index.html").read_text(encoding="utf-8"))
@app.get("/health", include_in_schema=False)
async def health() -> dict[str, str]:
return {"status": "ok", "model": MODEL_ID, "interface": "gradio-server"}
app.mount("/assets", StaticFiles(directory=str(ASSET_DIR)), name="assets")
app.mount("/ui", StaticFiles(directory=str(FRONTEND_DIR)), name="ui")
# Hugging Face Spaces expects the application object under this conventional name.
demo = app
if __name__ == "__main__":
app.launch(show_error=True)