rmoxon commited on
Commit
a70b50f
·
verified ·
1 Parent(s): 95d97ea

Initial: SigLIP2 text-only CPU Space for query encoding

Browse files
Files changed (3) hide show
  1. README.md +46 -7
  2. app.py +123 -0
  3. requirements.txt +7 -0
README.md CHANGED
@@ -1,13 +1,52 @@
1
  ---
2
- title: Strands Siglip2 Text Cpu
3
- emoji: 🏆
4
- colorFrom: pink
5
- colorTo: green
6
  sdk: gradio
7
- sdk_version: 6.14.0
8
- python_version: '3.13'
9
  app_file: app.py
10
  pinned: false
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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