Spaces:
Sleeping
Sleeping
Initial: SigLIP2 text-only CPU Space for query encoding
Browse files- README.md +46 -7
- app.py +123 -0
- requirements.txt +7 -0
README.md
CHANGED
|
@@ -1,13 +1,52 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version:
|
| 8 |
-
python_version:
|
| 9 |
app_file: app.py
|
| 10 |
pinned: false
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: SigLIP2 Text Encoding (CPU)
|
| 3 |
+
emoji: "\U0001F4DD"
|
| 4 |
+
colorFrom: indigo
|
| 5 |
+
colorTo: blue
|
| 6 |
sdk: gradio
|
| 7 |
+
sdk_version: "5.16.1"
|
| 8 |
+
python_version: "3.12"
|
| 9 |
app_file: app.py
|
| 10 |
pinned: false
|
| 11 |
---
|
| 12 |
|
| 13 |
+
# SigLIP2 Text Encoding Service (CPU)
|
| 14 |
+
|
| 15 |
+
Text-only sibling of [`rmoxon/strands-embeddings-siglip2`](https://huggingface.co/spaces/rmoxon/strands-embeddings-siglip2).
|
| 16 |
+
|
| 17 |
+
Same model (`google/siglip2-so400m-patch16-384`), same 1152-dim output, same
|
| 18 |
+
`_unwrap` + L2-norm math — but runs the text tower on CPU so search queries
|
| 19 |
+
don't queue behind the GPU Space's image-batch backfill jobs.
|
| 20 |
+
|
| 21 |
+
## Why this exists
|
| 22 |
+
|
| 23 |
+
The GPU Space serves both:
|
| 24 |
+
1. The backfill worker (`process-siglip2-embeddings`) — batches 200 images at a time
|
| 25 |
+
2. User search queries (`embed-and-search-v2`) — single text per call
|
| 26 |
+
|
| 27 |
+
ZeroGPU PRO permits only one concurrent GPU job per account, so a query that
|
| 28 |
+
arrives mid-batch waits 5–9s for the GPU. Splitting query encoding onto a
|
| 29 |
+
dedicated CPU Space eliminates that queue contention; warm queries land in
|
| 30 |
+
~500–800ms regardless of worker activity.
|
| 31 |
+
|
| 32 |
+
## API endpoint
|
| 33 |
+
|
| 34 |
+
| Endpoint | Input | Output |
|
| 35 |
+
|---|---|---|
|
| 36 |
+
| `encode_text` | `text: str` | `{embedding: float[1152], dimensions: 1152}` |
|
| 37 |
+
| `healthz` | (any) | `{status, model, output_dim, device}` |
|
| 38 |
+
|
| 39 |
+
Use Gradio's `/call/<api_name>` SSE protocol (POST to submit, GET to stream
|
| 40 |
+
results). See `nextJS/supabase/functions/embed-and-search-v2/index.ts` in the
|
| 41 |
+
main Strands repo for the canonical client.
|
| 42 |
+
|
| 43 |
+
## Hardware
|
| 44 |
+
|
| 45 |
+
- **Runtime**: CPU Basic (2 vCPU, 16 GB RAM, free tier)
|
| 46 |
+
- **Model footprint**: ~3.6 GB float32 (full `AutoModel`)
|
| 47 |
+
- **Per-call latency (warm)**: ~500–800ms
|
| 48 |
+
- **Cold start**: ~10–20s after model load (Space sleeps after ~48h idle)
|
| 49 |
+
|
| 50 |
+
Embedding-space identity with the GPU Space is guaranteed by using identical
|
| 51 |
+
model weights, identical processor config (`padding="max_length"`,
|
| 52 |
+
`truncation=True`, `max_length=64`), and identical L2 normalization.
|
app.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import json
|
| 3 |
+
import logging
|
| 4 |
+
from transformers import AutoModel, AutoProcessor
|
| 5 |
+
import torch
|
| 6 |
+
import numpy as np
|
| 7 |
+
import gradio as gr
|
| 8 |
+
|
| 9 |
+
logging.basicConfig(level=logging.INFO)
|
| 10 |
+
logger = logging.getLogger(__name__)
|
| 11 |
+
|
| 12 |
+
# Text-only sibling of `rmoxon/strands-embeddings-siglip2`. Mirrors that Space's
|
| 13 |
+
# model config and `_unwrap` + `l2_normalize` math exactly so query embeddings
|
| 14 |
+
# produced here are interchangeable with embeddings produced by the GPU Space
|
| 15 |
+
# (same `search_embeddings.embedding_v2` cache, same `model_version` key).
|
| 16 |
+
MODEL_NAME = "google/siglip2-so400m-patch16-384"
|
| 17 |
+
OUTPUT_DIM = 1152
|
| 18 |
+
MODEL_VERSION = "siglip2-so400m-1152-v1"
|
| 19 |
+
|
| 20 |
+
logger.info(f"Loading SigLIP2 model on CPU: {MODEL_NAME}")
|
| 21 |
+
try:
|
| 22 |
+
# float32 on CPU — bf16 is slow without AVX-512 BF16 (most CPU Basic instances
|
| 23 |
+
# don't have it). Full model footprint at f32 is ~3.6GB; well under CPU Basic's
|
| 24 |
+
# 16GB. We use the full AutoModel rather than just .text_model so we can call
|
| 25 |
+
# `get_text_features` identically to the GPU Space.
|
| 26 |
+
siglip_model = AutoModel.from_pretrained(
|
| 27 |
+
MODEL_NAME,
|
| 28 |
+
torch_dtype=torch.float32,
|
| 29 |
+
)
|
| 30 |
+
siglip_model.eval()
|
| 31 |
+
|
| 32 |
+
siglip_processor = AutoProcessor.from_pretrained(
|
| 33 |
+
MODEL_NAME,
|
| 34 |
+
use_fast=True,
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
model_loaded = True
|
| 38 |
+
logger.info(f"SigLIP2 model loaded (output dim={OUTPUT_DIM}, model={MODEL_NAME})")
|
| 39 |
+
except Exception as e:
|
| 40 |
+
logger.error(f"Failed to load SigLIP2 model: {e}")
|
| 41 |
+
siglip_model = None
|
| 42 |
+
siglip_processor = None
|
| 43 |
+
model_loaded = False
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def l2_normalize(features: torch.Tensor) -> torch.Tensor:
|
| 47 |
+
"""L2-normalize so cosine similarity equals dot product."""
|
| 48 |
+
return features / features.norm(dim=-1, keepdim=True)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def _unwrap(output) -> torch.Tensor:
|
| 52 |
+
"""Coerce transformers output into a pooled tensor.
|
| 53 |
+
|
| 54 |
+
Some transformers versions return `BaseModelOutputWithPooling` from
|
| 55 |
+
`get_text_features` instead of a raw tensor. Identical to the GPU Space.
|
| 56 |
+
"""
|
| 57 |
+
if isinstance(output, torch.Tensor):
|
| 58 |
+
return output
|
| 59 |
+
if hasattr(output, "pooler_output") and output.pooler_output is not None:
|
| 60 |
+
return output.pooler_output
|
| 61 |
+
if hasattr(output, "last_hidden_state"):
|
| 62 |
+
return output.last_hidden_state.mean(dim=1)
|
| 63 |
+
raise TypeError(f"Cannot extract pooled tensor from {type(output).__name__}")
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def sanitize_vec(arr: np.ndarray) -> list:
|
| 67 |
+
arr = np.nan_to_num(arr, nan=0.0, posinf=0.0, neginf=0.0)
|
| 68 |
+
return arr.tolist()
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def encode_text(text: str) -> str:
|
| 72 |
+
"""Encode a single text string → L2-normalized SigLIP2 embedding."""
|
| 73 |
+
logger.info(f"Processing text: {text[:60]}...")
|
| 74 |
+
|
| 75 |
+
processed = siglip_processor(
|
| 76 |
+
text=[text], return_tensors="pt",
|
| 77 |
+
padding="max_length", truncation=True, max_length=64,
|
| 78 |
+
)
|
| 79 |
+
input_ids = processed['input_ids']
|
| 80 |
+
|
| 81 |
+
with torch.no_grad():
|
| 82 |
+
text_features = _unwrap(siglip_model.get_text_features(input_ids=input_ids))
|
| 83 |
+
text_features = l2_normalize(text_features)
|
| 84 |
+
|
| 85 |
+
emb = text_features.cpu().numpy().flatten()
|
| 86 |
+
return json.dumps({"embedding": sanitize_vec(emb), "dimensions": OUTPUT_DIM})
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def health_check_handler(_unused: str = "") -> str:
|
| 90 |
+
if not model_loaded:
|
| 91 |
+
return json.dumps({"status": "unhealthy", "model": MODEL_VERSION, "error": "Service failed to initialize"})
|
| 92 |
+
return json.dumps({
|
| 93 |
+
"status": "healthy",
|
| 94 |
+
"model": MODEL_VERSION,
|
| 95 |
+
"output_dim": OUTPUT_DIM,
|
| 96 |
+
"device": "cpu",
|
| 97 |
+
"service": "ready",
|
| 98 |
+
"runtime": "gradio-cpu",
|
| 99 |
+
})
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
with gr.Blocks(title="SigLIP2 Text Encoding (CPU)") as demo:
|
| 103 |
+
gr.Markdown(
|
| 104 |
+
f"# SigLIP2 Text Encoding Service (CPU)\n"
|
| 105 |
+
f"Text-only sibling of `rmoxon/strands-embeddings-siglip2`. "
|
| 106 |
+
f"Produces {OUTPUT_DIM}-dim SigLIP2-So400m embeddings for search queries.\n\n"
|
| 107 |
+
f"Use Gradio's `/call/encode_text` protocol for API access."
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
with gr.Tab("Text Encoding"):
|
| 111 |
+
text_input = gr.Textbox(label="Text", placeholder="a beautiful sunset over mountains")
|
| 112 |
+
text_output = gr.Textbox(label="Result", lines=4)
|
| 113 |
+
text_btn = gr.Button("Encode Text", variant="primary")
|
| 114 |
+
text_btn.click(fn=encode_text, inputs=text_input, outputs=text_output, api_name="encode_text")
|
| 115 |
+
|
| 116 |
+
health_input = gr.Textbox(visible=False)
|
| 117 |
+
health_output = gr.Textbox(visible=False)
|
| 118 |
+
health_btn = gr.Button(visible=False)
|
| 119 |
+
health_btn.click(fn=health_check_handler, inputs=health_input, outputs=health_output, api_name="healthz")
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
demo.queue(max_size=20, default_concurrency_limit=2)
|
| 123 |
+
demo.launch(server_name="0.0.0.0", server_port=7860)
|
requirements.txt
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
torch>=2.4.0
|
| 2 |
+
transformers>=4.49.0
|
| 3 |
+
sentencepiece>=0.2.0
|
| 4 |
+
Pillow>=9.5.0
|
| 5 |
+
requests>=2.31.0
|
| 6 |
+
numpy<2.0.0
|
| 7 |
+
gradio>=5.16.1
|