Safetensors
mhassanch commited on
Commit
9adcd23
·
1 Parent(s): 9da5d19

Add dedicated text and image embedding endpoints

Browse files
Files changed (4) hide show
  1. EXAMPLES.md +211 -0
  2. INFERENCE.md +47 -2
  3. infer.py +86 -10
  4. server.py +161 -1
EXAMPLES.md ADDED
@@ -0,0 +1,211 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # GeoText-1652 request examples
2
+
3
+ All image routes require a public RGB GeoTIFF/COG URL. Use this image in the
4
+ examples below:
5
+
6
+ ```text
7
+ https://huggingface.co/datasets/geobase/geoai-cogs/resolve/main/geoembeddings-demo/building-detection_sm.tif
8
+ ```
9
+
10
+ ## Inputs and outcomes at a glance
11
+
12
+ | Workflow | Inputs | Primary returned fields | What to do next |
13
+ | --- | --- | --- | --- |
14
+ | Embed text | `queries` | `text_embeddings` | Search global or patch vector collections. |
15
+ | Embed one image | `image_url` | `image_embedding` | Store in an image index for image-to-image retrieval. |
16
+ | Embed image patches | `image_url`, `include_patch_embeddings` | `patch_embeddings.patches` | Search each footprint for text-guided localization. |
17
+ | Rank text descriptions | `image_url`, `queries` | `ranked_queries` | Determine which description best matches the image. |
18
+ | Rank a large raster | image, queries, `tile_size` | `ranked_queries` with `tile_index` | Find the best tile for each query. |
19
+ | Propose matching regions | image, queries, `include_bboxes` | `text_conditioned_boxes` / `stitched_text_conditioned_boxes` | Inspect semantic candidate regions. |
20
+ | Persist patch index data | image, `store_patch_embeddings` | `patch_embeddings_storage` | Download Parquet and run vector similarity search. |
21
+
22
+ ## 1. Health check
23
+
24
+ ```bash
25
+ curl https://YOUR-ENDPOINT.endpoints.huggingface.cloud/health
26
+ ```
27
+
28
+ ## 2. Text-only embeddings
29
+
30
+ `POST /embed/text`
31
+
32
+ ```json
33
+ {
34
+ "queries": ["a parking lot", "a large building", "a sports field"]
35
+ }
36
+ ```
37
+
38
+ Returns one normalized 256-dimensional embedding per query. This is the route
39
+ to use when searching a previously stored image or patch-vector collection.
40
+
41
+ ## 3. Global image embedding
42
+
43
+ `POST /embed/image`
44
+
45
+ ```json
46
+ {
47
+ "image_url": "https://huggingface.co/datasets/geobase/geoai-cogs/resolve/main/geoembeddings-demo/building-detection_sm.tif",
48
+ "include_global_embedding": true
49
+ }
50
+ ```
51
+
52
+ ## 4. Image patch embeddings
53
+
54
+ `POST /embed/image`
55
+
56
+ ```json
57
+ {
58
+ "image_url": "https://huggingface.co/datasets/geobase/geoai-cogs/resolve/main/geoembeddings-demo/building-detection_sm.tif",
59
+ "include_global_embedding": false,
60
+ "include_patch_embeddings": true
61
+ }
62
+ ```
63
+
64
+ Each patch includes `source_pixel_xyxy` and a normalized 256-dimensional
65
+ embedding. The model returns a 12 by 12 patch grid for one 384 px model input.
66
+
67
+ ## 5. Tiled global and patch image embeddings
68
+
69
+ `POST /embed/image`
70
+
71
+ ```json
72
+ {
73
+ "image_url": "https://huggingface.co/datasets/geobase/geoai-cogs/resolve/main/geoembeddings-demo/building-detection_sm.tif",
74
+ "tile_size": 384,
75
+ "tile_overlap": 64,
76
+ "include_global_embedding": true,
77
+ "include_patch_embeddings": true,
78
+ "return_tile_results": false
79
+ }
80
+ ```
81
+
82
+ The global vector is an area-weighted, L2-normalized mean of tile vectors.
83
+ Patch vectors keep their `tile_index` and original-image pixel footprints.
84
+
85
+ ## 6. Store tiled patch embeddings in the Hub
86
+
87
+ `POST /embed/image`
88
+
89
+ ```json
90
+ {
91
+ "image_url": "https://huggingface.co/datasets/geobase/geoai-cogs/resolve/main/geoembeddings-demo/building-detection_sm.tif",
92
+ "tile_size": 384,
93
+ "tile_overlap": 64,
94
+ "include_global_embedding": true,
95
+ "store_patch_embeddings": true,
96
+ "output_prefix": "geotext/demo-image"
97
+ }
98
+ ```
99
+
100
+ Endpoint secrets/configuration required:
101
+
102
+ ```text
103
+ HF_TOKEN=<write-capable token>
104
+ HF_BUCKET=your-namespace/your-embedding-dataset
105
+ ```
106
+
107
+ This stores a compressed Parquet file but leaves the large patch collection out
108
+ of the response. Add `"include_patch_embeddings": true` to return it too.
109
+
110
+ ## 7. Image-level text ranking
111
+
112
+ `POST /infer` (or `POST /` in the Inference Endpoints Playground)
113
+
114
+ ```json
115
+ {
116
+ "image_url": "https://huggingface.co/datasets/geobase/geoai-cogs/resolve/main/geoembeddings-demo/building-detection_sm.tif",
117
+ "queries": ["a parking lot", "a building", "a sports field"]
118
+ }
119
+ ```
120
+
121
+ Returns `ranked_queries`, sorted by descending cosine similarity. This answers
122
+ questions such as: *which of these descriptions best matches this image?*
123
+
124
+ ## 8. Search with global image and text vectors
125
+
126
+ `POST /infer`
127
+
128
+ ```json
129
+ {
130
+ "image_url": "https://huggingface.co/datasets/geobase/geoai-cogs/resolve/main/geoembeddings-demo/building-detection_sm.tif",
131
+ "queries": ["a parking lot", "a building"],
132
+ "include_embeddings": true
133
+ }
134
+ ```
135
+
136
+ ## 9. Large-raster ranking, localization, and stitched region proposals
137
+
138
+ `POST /infer`
139
+
140
+ ```json
141
+ {
142
+ "image_url": "https://huggingface.co/datasets/geobase/geoai-cogs/resolve/main/geoembeddings-demo/building-detection_sm.tif",
143
+ "queries": ["a parking lot", "a building"],
144
+ "tile_size": 384,
145
+ "tile_overlap": 64,
146
+ "include_embeddings": true,
147
+ "include_bboxes": true,
148
+ "return_tile_results": false
149
+ }
150
+ ```
151
+
152
+ Per-query similarity is the maximum matching tile score. Region proposals are
153
+ converted to original-image pixels and deduplicated with non-maximum
154
+ suppression.
155
+
156
+ The `tile_index` in `ranked_queries` identifies the strongest tile for each
157
+ query. Add `return_tile_results: true` to receive all tile scores and inspect
158
+ the full ranking surface.
159
+
160
+ ## 10. Inspect every tile
161
+
162
+ `POST /infer`
163
+
164
+ ```json
165
+ {
166
+ "image_url": "https://huggingface.co/datasets/geobase/geoai-cogs/resolve/main/geoembeddings-demo/building-detection_sm.tif",
167
+ "queries": ["a parking lot"],
168
+ "tile_size": 384,
169
+ "tile_overlap": 64,
170
+ "include_embeddings": true,
171
+ "include_patch_embeddings": true,
172
+ "include_bboxes": true,
173
+ "return_tile_results": true
174
+ }
175
+ ```
176
+
177
+ This is for debugging only: it returns all tile results and can be very large.
178
+
179
+ ## 11. Text-to-patch localization from stored embeddings
180
+
181
+ This is a two-step workflow. First create or retrieve a text vector:
182
+
183
+ `POST /embed/text`
184
+
185
+ ```json
186
+ {
187
+ "queries": ["a parking lot"]
188
+ }
189
+ ```
190
+
191
+ Then compute dot-product similarity between that 256-D vector and the
192
+ `embedding` column in `patch_embeddings.parquet`. Sort descending and use each
193
+ result's `source_pixel_xyxy` field to draw the matching locations on the
194
+ original image. This is the scalable text-guided localization workflow.
195
+
196
+ ## 12. Image-to-image retrieval
197
+
198
+ Create a global vector for a query image:
199
+
200
+ `POST /embed/image`
201
+
202
+ ```json
203
+ {
204
+ "image_url": "https://huggingface.co/datasets/geobase/geoai-cogs/resolve/main/geoembeddings-demo/building-detection_sm.tif",
205
+ "include_global_embedding": true
206
+ }
207
+ ```
208
+
209
+ Store `image_embedding` in a vector database or local matrix with global
210
+ vectors from other images. Rank candidates by dot product to find visually and
211
+ semantically similar images.
INFERENCE.md CHANGED
@@ -5,8 +5,53 @@ the official [GeoText-1652 source](https://github.com/MultimodalGeo/GeoText-1652
5
  and checkpoint.
6
 
7
  The image input must be an RGB GeoTIFF/COG with at least three bands. GeoText
8
- ranks text descriptions against the image; it is not an object-detection model
9
- and does not return building polygons or bounding boxes.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
 
11
  ## Build a checkpoint-free image
12
 
 
5
  and checkpoint.
6
 
7
  The image input must be an RGB GeoTIFF/COG with at least three bands. GeoText
8
+ is a text-image retrieval model. It returns similarity scores and embeddings;
9
+ its optional text-conditioned boxes are semantic region proposals, not
10
+ pixel-accurate object-detection polygons.
11
+
12
+ ## Use cases, inputs, and outcomes
13
+
14
+ | Use case | Route | Required inputs | Optional inputs | Outcome |
15
+ | --- | --- | --- | --- | --- |
16
+ | Text embedding creation | `/embed/text` | `queries` | — | One normalized 256-D vector per text query. |
17
+ | Global image embedding creation | `/embed/image` | `image_url` | `tile_size`, `tile_overlap` | One normalized 256-D vector representing the full image. |
18
+ | Patch embedding creation | `/embed/image` | `image_url` | `include_patch_embeddings`, tiling | One 256-D vector per spatial patch plus its source-pixel footprint. |
19
+ | Image-level text ranking | `/infer` | `image_url`, `queries` | `include_embeddings` | Every submitted query ranked by image-text similarity. |
20
+ | Tile-level text ranking | `/infer` | `image_url`, `queries`, `tile_size` | `return_tile_results` | Best score and winning tile for each query; optionally every tile score. |
21
+ | Text-guided localization | `/embed/text` + stored patch vectors | query text and a Parquet/vector index | top-k | Highest-similarity patch footprints showing where the text concept occurs. |
22
+ | Online text-guided region proposals | `/infer` | `image_url`, `queries` | `include_bboxes`, tiling | Text-conditioned candidate boxes in source-image pixels; not segmentation polygons. |
23
+ | Image-to-image retrieval | `/embed/image` + global-vector index | query `image_url` | top-k in downstream index | A global image vector for nearest-neighbor retrieval outside the endpoint. |
24
+ | Patch-to-patch / change exploration | `/embed/image` + patch-vector index | query image or patch | tiling, top-k | Patch vectors for downstream spatial nearest-neighbor comparison. |
25
+ | Persistent embedding dataset | `/embed/image` | `image_url`, `store_patch_embeddings` | `output_prefix`, tiling | Compressed Parquet stored in the configured Hugging Face dataset repository. |
26
+
27
+ ### Outcome terminology
28
+
29
+ - **Embedding:** a normalized 256-dimensional vector. Compare vectors with dot
30
+ product, which equals cosine similarity because GeoText normalizes them.
31
+ - **Ranking:** a descending list of supplied text queries scored against one
32
+ image or its tiles. Scores are relative similarity, not probabilities.
33
+ - **Localization:** downstream similarity search between a text embedding and
34
+ stored patch embeddings. The returned `source_pixel_xyxy` footprint locates
35
+ the match in the original raster.
36
+ - **Region proposal:** the optional `include_bboxes` output. It is a
37
+ text-conditioned semantic box, not a detected object instance or a polygon.
38
+ - **Stitching:** combines tiled outputs. Search uses each query's maximum tile
39
+ score; global image embeddings use an area-weighted mean and L2
40
+ normalization; overlapping proposal boxes are deduplicated by NMS.
41
+
42
+ ## API routes
43
+
44
+ | Route | Purpose | Required input |
45
+ | --- | --- | --- |
46
+ | `POST /embed/text` | Text-only 256-D embeddings | `queries` |
47
+ | `POST /embed/image` | Image-only global and/or patch embeddings | `image_url` |
48
+ | `POST /infer` or `POST /` | Combined text-guided search | `image_url`, `queries` |
49
+ | `GET /health` | Endpoint readiness probe | none |
50
+
51
+ `/embed/text` does not fetch an image or run the vision encoder. `/embed/image`
52
+ does not tokenize text or run the text encoder. The combined search route is
53
+ kept for Playground compatibility. See [EXAMPLES.md](EXAMPLES.md) for every
54
+ request payload.
55
 
56
  ## Build a checkpoint-free image
57
 
infer.py CHANGED
@@ -166,6 +166,91 @@ def load_model(source_dir: Path, model_dir: Path, device: torch.device):
166
  return model.to(device).eval(), tokenizer, config
167
 
168
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
169
  @torch.inference_mode()
170
  def model_outputs(
171
  model,
@@ -178,16 +263,7 @@ def model_outputs(
178
  include_bboxes: bool = False,
179
  include_patch_embeddings: bool = False,
180
  ) -> Dict[str, Any]:
181
- image_transform = transforms.Compose(
182
- [
183
- transforms.Resize(
184
- (config["image_res"], config["image_res"]), interpolation=Image.BICUBIC
185
- ),
186
- transforms.ToTensor(),
187
- transforms.Normalize(mean=CLIP_MEAN, std=CLIP_STD),
188
- ]
189
- )
190
- image_tensor = image_transform(image).unsqueeze(0).to(device)
191
  text_input = tokenizer(
192
  queries,
193
  padding="max_length",
 
166
  return model.to(device).eval(), tokenizer, config
167
 
168
 
169
+ def _image_tensor(image: Image.Image, config: Dict[str, Any], device: torch.device):
170
+ transform = transforms.Compose(
171
+ [
172
+ transforms.Resize(
173
+ (config["image_res"], config["image_res"]), interpolation=Image.BICUBIC
174
+ ),
175
+ transforms.ToTensor(),
176
+ transforms.Normalize(mean=CLIP_MEAN, std=CLIP_STD),
177
+ ]
178
+ )
179
+ return transform(image).unsqueeze(0).to(device)
180
+
181
+
182
+ @torch.inference_mode()
183
+ def text_embedding_outputs(
184
+ model, tokenizer, config: Dict[str, Any], queries: List[str], device: torch.device
185
+ ) -> Dict[str, Any]:
186
+ """Encode text without reading an image or running the vision encoder."""
187
+ text_input = tokenizer(
188
+ queries,
189
+ padding="max_length",
190
+ truncation=True,
191
+ max_length=config["max_tokens"],
192
+ return_tensors="pt",
193
+ ).to(device)
194
+ text_embeds = model.get_text_embeds(text_input.input_ids, text_input.attention_mask)
195
+ text_features = model.get_features(text_embeds=text_embeds)
196
+ return {
197
+ "embedding_dimension": text_features.shape[-1],
198
+ "text_embeddings": [
199
+ {"query": query, "embedding": embedding.float().cpu().tolist()}
200
+ for query, embedding in zip(queries, text_features)
201
+ ],
202
+ }
203
+
204
+
205
+ @torch.inference_mode()
206
+ def image_embedding_outputs(
207
+ model,
208
+ config: Dict[str, Any],
209
+ image: Image.Image,
210
+ device: torch.device,
211
+ include_global_embedding: bool = True,
212
+ include_patch_embeddings: bool = False,
213
+ ) -> Dict[str, Any]:
214
+ """Encode an image without tokenizing text or running the text encoder."""
215
+ image_embeds, _ = model.get_vision_embeds(_image_tensor(image, config, device))
216
+ result = {"embedding_dimension": model.vision_proj.out_features}
217
+ if include_global_embedding:
218
+ image_feature = model.get_features(image_embeds=image_embeds)[0]
219
+ result["image_embedding"] = image_feature.float().cpu().tolist()
220
+ if include_patch_embeddings:
221
+ patch_features = torch.nn.functional.normalize(
222
+ model.vision_proj(image_embeds[:, 1:, :]), dim=-1
223
+ )[0]
224
+ patch_count = patch_features.shape[0]
225
+ columns = int(round(patch_count**0.5))
226
+ if columns <= 0 or patch_count % columns:
227
+ columns = patch_count
228
+ rows = patch_count // columns
229
+ patches = []
230
+ for index, embedding in enumerate(patch_features.float().cpu().tolist()):
231
+ row, column = divmod(index, columns)
232
+ patches.append(
233
+ {
234
+ "patch_index": index,
235
+ "row": row,
236
+ "column": column,
237
+ "source_pixel_xyxy": [
238
+ image.width * column / columns,
239
+ image.height * row / rows,
240
+ image.width * (column + 1) / columns,
241
+ image.height * (row + 1) / rows,
242
+ ],
243
+ "embedding": embedding,
244
+ }
245
+ )
246
+ result["patch_embeddings"] = {
247
+ "embedding_dimension": patch_features.shape[-1],
248
+ "grid": {"rows": rows, "columns": columns},
249
+ "patches": patches,
250
+ }
251
+ return result
252
+
253
+
254
  @torch.inference_mode()
255
  def model_outputs(
256
  model,
 
263
  include_bboxes: bool = False,
264
  include_patch_embeddings: bool = False,
265
  ) -> Dict[str, Any]:
266
+ image_tensor = _image_tensor(image, config, device)
 
 
 
 
 
 
 
 
 
267
  text_input = tokenizer(
268
  queries,
269
  padding="max_length",
server.py CHANGED
@@ -11,7 +11,14 @@ import numpy as np
11
  import torch
12
  from fastapi import FastAPI, HTTPException
13
  from hub_storage import persist_patch_embeddings
14
- from infer import fetch_rgb_geotiff, load_model, model_outputs, tile_image
 
 
 
 
 
 
 
15
  from pydantic import BaseModel, Field
16
 
17
 
@@ -41,6 +48,33 @@ class InferenceRequest(BaseModel):
41
  )
42
 
43
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
  @asynccontextmanager
45
  async def lifespan(app: FastAPI):
46
  device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
@@ -218,6 +252,89 @@ def _tiled_outputs(
218
  return result
219
 
220
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
221
  @app.get("/health")
222
  def health() -> Dict[str, str]:
223
  return {"status": "ok"}
@@ -226,6 +343,7 @@ def health() -> Dict[str, str]:
226
  @app.post("/")
227
  @app.post("/infer")
228
  def infer(request: InferenceRequest) -> Dict[str, Any]:
 
229
  try:
230
  image, image_metadata = fetch_rgb_geotiff(request.image_url)
231
  runtime = app.state.runtime
@@ -262,3 +380,45 @@ def infer(request: InferenceRequest) -> Dict[str, Any]:
262
  return result
263
  except Exception as error:
264
  raise HTTPException(status_code=422, detail=str(error)) from error
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  import torch
12
  from fastapi import FastAPI, HTTPException
13
  from hub_storage import persist_patch_embeddings
14
+ from infer import (
15
+ fetch_rgb_geotiff,
16
+ image_embedding_outputs,
17
+ load_model,
18
+ model_outputs,
19
+ text_embedding_outputs,
20
+ tile_image,
21
+ )
22
  from pydantic import BaseModel, Field
23
 
24
 
 
48
  )
49
 
50
 
51
+ class TextEmbeddingRequest(BaseModel):
52
+ queries: List[str] = Field(..., min_items=1, description="Text strings to embed")
53
+
54
+
55
+ class ImageEmbeddingRequest(BaseModel):
56
+ image_url: str = Field(..., description="Public RGB GeoTIFF/COG URL")
57
+ include_global_embedding: bool = Field(
58
+ True, description="Return the normalized 256-dimensional image vector"
59
+ )
60
+ include_patch_embeddings: bool = Field(
61
+ False, description="Return normalized 256-dimensional spatial patch vectors"
62
+ )
63
+ store_patch_embeddings: bool = Field(
64
+ False, description="Persist patch embeddings as Parquet in HF_BUCKET"
65
+ )
66
+ output_prefix: Optional[str] = Field(
67
+ None, description="Optional Hugging Face dataset path prefix"
68
+ )
69
+ tile_overlap: int = Field(64, ge=0, description="Source-pixel overlap between tiles")
70
+ tile_size: Optional[int] = Field(
71
+ None, ge=64, description="Optional source-pixel tile size"
72
+ )
73
+ return_tile_results: bool = Field(
74
+ False, description="Include per-tile embeddings in addition to stitched output"
75
+ )
76
+
77
+
78
  @asynccontextmanager
79
  async def lifespan(app: FastAPI):
80
  device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
 
252
  return result
253
 
254
 
255
+ def _image_outputs(
256
+ runtime: Dict[str, Any], image, request: ImageEmbeddingRequest
257
+ ) -> Dict[str, Any]:
258
+ """Run vision-only embedding inference, optionally over overlapping tiles."""
259
+ include_patches = request.include_patch_embeddings or request.store_patch_embeddings
260
+ if request.tile_size is None:
261
+ return image_embedding_outputs(
262
+ runtime["model"],
263
+ runtime["config"],
264
+ image,
265
+ runtime["device"],
266
+ include_global_embedding=request.include_global_embedding,
267
+ include_patch_embeddings=include_patches,
268
+ )
269
+
270
+ tiles = tile_image(image, request.tile_size, request.tile_overlap)
271
+ tile_outputs = []
272
+ for tile in tiles:
273
+ output = image_embedding_outputs(
274
+ runtime["model"],
275
+ runtime["config"],
276
+ tile["image"],
277
+ runtime["device"],
278
+ include_global_embedding=request.include_global_embedding,
279
+ include_patch_embeddings=include_patches,
280
+ )
281
+ tile_outputs.append((tile, output))
282
+
283
+ result = {
284
+ "embedding_dimension": tile_outputs[0][1]["embedding_dimension"],
285
+ "tiling": {
286
+ "tile_size": request.tile_size,
287
+ "tile_overlap": request.tile_overlap,
288
+ "tile_count": len(tiles),
289
+ "global_embedding_stitching": "area-weighted mean, then L2 normalization",
290
+ },
291
+ }
292
+ if request.include_global_embedding:
293
+ weights = np.asarray(
294
+ [tile["width"] * tile["height"] for tile, _ in tile_outputs], dtype=np.float32
295
+ )
296
+ vectors = np.asarray(
297
+ [output["image_embedding"] for _, output in tile_outputs], dtype=np.float32
298
+ )
299
+ embedding = np.average(vectors, axis=0, weights=weights)
300
+ embedding /= max(float(np.linalg.norm(embedding)), 1e-8)
301
+ result["image_embedding"] = embedding.tolist()
302
+ if include_patches:
303
+ patches = []
304
+ for tile, output in tile_outputs:
305
+ for patch in output["patch_embeddings"]["patches"]:
306
+ x1, y1, x2, y2 = patch["source_pixel_xyxy"]
307
+ patches.append(
308
+ {
309
+ **patch,
310
+ "tile_index": tile["index"],
311
+ "source_pixel_xyxy": [
312
+ x1 + tile["column"],
313
+ y1 + tile["row"],
314
+ x2 + tile["column"],
315
+ y2 + tile["row"],
316
+ ],
317
+ }
318
+ )
319
+ result["patch_embeddings"] = {
320
+ "embedding_dimension": result["embedding_dimension"],
321
+ "grid": "per_tile",
322
+ "patches": patches,
323
+ }
324
+ if request.return_tile_results:
325
+ result["tile_results"] = [
326
+ {
327
+ "tile_index": tile["index"],
328
+ "source_pixel_window": [
329
+ tile["column"], tile["row"], tile["width"], tile["height"]
330
+ ],
331
+ **output,
332
+ }
333
+ for tile, output in tile_outputs
334
+ ]
335
+ return result
336
+
337
+
338
  @app.get("/health")
339
  def health() -> Dict[str, str]:
340
  return {"status": "ok"}
 
343
  @app.post("/")
344
  @app.post("/infer")
345
  def infer(request: InferenceRequest) -> Dict[str, Any]:
346
+ """Backward-compatible combined image/text search endpoint."""
347
  try:
348
  image, image_metadata = fetch_rgb_geotiff(request.image_url)
349
  runtime = app.state.runtime
 
380
  return result
381
  except Exception as error:
382
  raise HTTPException(status_code=422, detail=str(error)) from error
383
+
384
+
385
+ @app.post("/embed/text")
386
+ def embed_text(request: TextEmbeddingRequest) -> Dict[str, Any]:
387
+ """Return query vectors without fetching imagery or running the vision encoder."""
388
+ try:
389
+ runtime = app.state.runtime
390
+ result = text_embedding_outputs(
391
+ runtime["model"],
392
+ runtime["tokenizer"],
393
+ runtime["config"],
394
+ request.queries,
395
+ runtime["device"],
396
+ )
397
+ return {"device": str(runtime["device"]), **result}
398
+ except Exception as error:
399
+ raise HTTPException(status_code=422, detail=str(error)) from error
400
+
401
+
402
+ @app.post("/embed/image")
403
+ def embed_image(request: ImageEmbeddingRequest) -> Dict[str, Any]:
404
+ """Return image or patch vectors without tokenizing text or running search."""
405
+ try:
406
+ image, image_metadata = fetch_rgb_geotiff(request.image_url)
407
+ runtime = app.state.runtime
408
+ result = {
409
+ "image_url": request.image_url,
410
+ "image_metadata": image_metadata,
411
+ "device": str(runtime["device"]),
412
+ }
413
+ result.update(_image_outputs(runtime, image, request))
414
+ if request.store_patch_embeddings:
415
+ result["patch_embeddings_storage"] = persist_patch_embeddings(
416
+ image_url=request.image_url,
417
+ patches=result["patch_embeddings"]["patches"],
418
+ output_prefix=request.output_prefix,
419
+ )
420
+ if not request.include_patch_embeddings:
421
+ result.pop("patch_embeddings")
422
+ return result
423
+ except Exception as error:
424
+ raise HTTPException(status_code=422, detail=str(error)) from error