SatQuery Deployer commited on
Commit
62dd4e0
·
1 Parent(s): 6c93dc7

Deploy SatQuery TEOChat Bi-Temporal Service

Browse files
Files changed (2) hide show
  1. app.py +281 -269
  2. requirements.txt +16 -2
app.py CHANGED
@@ -1,284 +1,296 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
  """
2
- SatQuery — Real EarthMind-4B Hugging Face Space
3
-
4
- This app uses the official sy1998/EarthMind-4B checkpoint and its
5
- custom `predict_forward(...)` inference interface.
6
-
7
- Supported:
8
- - Single-image VQA
9
- - Scene captioning / description
10
- - Text-guided segmentation (pixel mask)
11
- - Bounding-box extraction derived from the predicted mask
12
-
13
- Important:
14
- - No fake/deterministic fallback.
15
- - The uploaded image is passed to EarthMind itself.
16
- - Store HF_TOKEN as a Hugging Face Space Secret if the Space needs it.
17
- """
18
-
19
- # IMPORTANT for Hugging Face ZeroGPU:
20
- # Import `spaces` before importing torch / transformers.
21
- import spaces
22
-
23
  import os
24
- from typing import Any, Optional
25
-
26
- import gradio as gr
27
- import numpy as np
 
 
28
  from PIL import Image
29
  import torch
30
- from transformers import AutoModelForCausalLM, AutoTokenizer
31
-
32
-
33
- # ---------------------------------------------------------------------
34
- # Configuration
35
- # ---------------------------------------------------------------------
36
-
37
- MODEL_ID = os.getenv("EARTHMIND_MODEL_ID", "sy1998/EarthMind-4B")
38
- HF_TOKEN = os.getenv("HF_TOKEN") or os.getenv("HUGGINGFACE_HUB_TOKEN")
39
-
40
- print(f"[SatQuery] Loading official EarthMind checkpoint: {MODEL_ID}")
41
-
42
- # EarthMind's official Hugging Face quick-start uses:
43
- # AutoModelForCausalLM.from_pretrained(..., trust_remote_code=True,
44
- # device_map="cuda:0")
45
- MODEL = AutoModelForCausalLM.from_pretrained(
46
- MODEL_ID,
47
- torch_dtype="auto",
48
- device_map="cuda:0",
49
- trust_remote_code=True,
50
- token=HF_TOKEN if HF_TOKEN else None,
51
- )
52
-
53
- TOKENIZER = AutoTokenizer.from_pretrained(
54
- MODEL_ID,
55
- trust_remote_code=True,
56
- token=HF_TOKEN if HF_TOKEN else None,
57
- )
58
-
59
- MODEL.eval()
60
-
61
- print("[SatQuery] EarthMind loaded successfully.")
62
-
63
-
64
- # ---------------------------------------------------------------------
65
- # Helpers
66
- # ---------------------------------------------------------------------
67
-
68
- def _build_prompt(query: str, task: str) -> str:
69
- query = (query or "").strip()
70
-
71
- if task == "vqa":
72
- if not query:
73
- query = "What is visible in this remote-sensing image?"
74
- return f"<image>{query}"
75
-
76
- if task == "captioning":
77
- if query:
78
- return f"<image>Please provide a detailed remote-sensing description of the image, with emphasis on: {query}"
79
- return (
80
- "<image>Please provide a detailed description of the remote-sensing "
81
- "image, including major land-cover types, objects, infrastructure, "
82
- "and spatial relationships."
83
- )
84
-
85
- if task == "grounding":
86
- if not query:
87
- query = "the most prominent water body"
88
- return (
89
- "<image>Please segment the region/object described by the following "
90
- f"instruction: {query}"
91
- )
92
-
93
- raise ValueError(f"Unsupported task: {task}")
94
-
95
-
96
- def _to_numpy_mask(mask: Any) -> Optional[np.ndarray]:
97
- """Normalize EarthMind's returned mask to H x W boolean NumPy array."""
98
- if mask is None:
99
- return None
100
-
101
- if torch.is_tensor(mask):
102
- mask = mask.detach().cpu().numpy()
103
-
104
- arr = np.asarray(mask)
105
-
106
- # Official EarthMind returns masks shaped like (1, H, W)
107
- # for image inference. Be defensive about extra singleton dims.
108
- while arr.ndim > 2:
109
- arr = arr[0]
110
-
111
- if arr.ndim != 2:
112
- return None
113
-
114
- return arr.astype(bool)
115
-
116
-
117
- def _extract_first_mask(result: dict) -> Optional[np.ndarray]:
118
- masks = result.get("prediction_masks", [])
119
- if not masks:
120
- return None
121
-
122
- # Usually masks = [np.ndarray(1, H, W)] for one [SEG] prediction.
123
- first = masks[0]
124
-
125
- # If several masks are returned, the first corresponds to the first
126
- # [SEG] token/output region.
127
- return _to_numpy_mask(first)
128
-
129
-
130
- def _mask_to_bbox(mask: Optional[np.ndarray]) -> Optional[list]:
131
- """
132
- Return normalized [ymin, xmin, ymax, xmax] in [0, 1].
133
- """
134
- if mask is None or not mask.any():
135
- return None
136
-
137
- ys, xs = np.where(mask)
138
- h, w = mask.shape
139
-
140
- ymin = float(ys.min() / h)
141
- xmin = float(xs.min() / w)
142
- ymax = float((ys.max() + 1) / h)
143
- xmax = float((xs.max() + 1) / w)
144
-
145
- return [
146
- round(ymin, 6),
147
- round(xmin, 6),
148
- round(ymax, 6),
149
- round(xmax, 6),
150
- ]
151
-
152
-
153
- def _overlay_mask(image: Image.Image, mask: Optional[np.ndarray]) -> Optional[Image.Image]:
154
- if mask is None:
155
- return None
156
-
157
- base = image.convert("RGBA")
158
- mask_img = Image.fromarray((mask.astype(np.uint8) * 140), mode="L")
159
-
160
- # Red overlay only for visualization.
161
- overlay = Image.new("RGBA", base.size, (255, 0, 0, 0))
162
- overlay.putalpha(mask_img)
163
-
164
- return Image.alpha_composite(base, overlay).convert("RGB")
165
-
166
-
167
- # ---------------------------------------------------------------------
168
- # Real EarthMind inference
169
- # ---------------------------------------------------------------------
170
-
171
- @spaces.GPU(duration=120)
172
- def run_earthmind(
173
- image: Image.Image,
174
- query: str,
175
- task: str,
176
- ):
177
- if image is None:
178
- raise gr.Error("Please upload an image.")
179
-
180
- if not isinstance(image, Image.Image):
181
- image = Image.fromarray(np.asarray(image))
182
-
183
- image = image.convert("RGB")
184
-
185
- prompt = _build_prompt(query, task)
186
-
187
- print(f"[SatQuery] Task: {task}")
188
- print(f"[SatQuery] Prompt: {prompt}")
189
-
190
- # THIS IS THE OFFICIAL EARTHMIND INFERENCE INTERFACE.
191
- result = MODEL.predict_forward(
192
- image=image,
193
- text=prompt,
194
- tokenizer=TOKENIZER,
195
- )
196
-
197
- prediction = str(result.get("prediction", "")).strip()
198
- mask = _extract_first_mask(result)
199
- bbox = _mask_to_bbox(mask)
200
- overlay = _overlay_mask(image, mask)
201
-
202
- trace = {
203
- "model": MODEL_ID,
204
- "task": task,
205
- "inference_backend": "EarthMind.predict_forward",
206
- "image_used": True,
207
- "segmentation_generated": bool(mask is not None),
208
- "normalized_bbox_ymin_xmin_ymax_xmax": bbox,
209
- }
210
-
211
- return prediction, overlay, trace
212
-
213
-
214
- # ---------------------------------------------------------------------
215
- # Gradio UI
216
- # ---------------------------------------------------------------------
217
-
218
- with gr.Blocks(title="SatQuery — EarthMind-4B") as demo:
219
- gr.Markdown(
220
- """
221
- # 🛰️ SatQuery — EarthMind-4B
222
- **Real Hugging Face EarthMind-4B inference**
223
-
224
- Use one remote-sensing image with a natural-language instruction.
225
-
226
- **VQA:** Ask a question about the image.
227
- **Captioning:** Generate a remote-sensing scene description.
228
- **Grounding:** Ask EarthMind to segment a specific object/region.
229
- """
230
- )
231
-
232
- with gr.Row():
233
- with gr.Column():
234
- image = gr.Image(
235
- type="pil",
236
- label="Remote-Sensing Image",
237
- )
238
-
239
- task = gr.Dropdown(
240
- choices=["vqa", "captioning", "grounding"],
241
- value="vqa",
242
- label="Task",
243
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
244
 
245
- query = gr.Textbox(
246
- lines=3,
247
- label="Natural-Language Query / Instruction",
248
- placeholder=(
249
- "Examples:\n"
250
- "• What land-cover types are visible?\n"
251
- "• Describe the major objects and infrastructure.\n"
252
- "• Segment the water body."
253
- ),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
254
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
255
 
256
- run_btn = gr.Button(
257
- "Run EarthMind",
258
- variant="primary",
259
- )
260
 
261
- with gr.Column():
262
- answer = gr.Textbox(
263
- label="EarthMind Answer",
264
- lines=10,
265
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
266
 
267
- result_image = gr.Image(
268
- label="Predicted Spatial Evidence / Mask",
269
- type="pil",
270
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
271
 
272
- trace = gr.JSON(
273
- label="Execution Trace",
274
- )
275
 
276
- run_btn.click(
277
- fn=run_earthmind,
278
- inputs=[image, query, task],
279
- outputs=[answer, result_image, trace],
280
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
281
 
282
 
283
  if __name__ == "__main__":
284
- demo.launch()
 
1
+ """Hugging Face ZeroGPU Space: SatQuery TEOChat Official Bi-Temporal Reasoning Service.
2
+
3
+ Official Model: jirvin16/TEOChat
4
+ Official Repository: https://github.com/ermongroup/TEOChat
5
+ Responsibilities:
6
+ 1. Bi-temporal satellite image reasoning (T1 -> T2)
7
+ 2. Temporal change description
8
+ 3. Change-based Visual Question Answering (VQA)
9
+ 4. Temporal Earth Observation question answering
10
+
11
+ NO mock fallbacks. NO NumPy image differencing. NO hardcoded percentages or categories.
12
+ Uses real Video-LLaVA / TEOChat official inference code only.
13
  """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  import os
15
+ import io
16
+ import time
17
+ import base64
18
+ import tempfile
19
+ import logging
20
+ from typing import Optional, Dict, Any, List
21
  from PIL import Image
22
  import torch
23
+ import gradio as gr
24
+ from fastapi import FastAPI, Request, Response, status
25
+ from pydantic import BaseModel, Field
26
+
27
+ # Setup logging
28
+ logging.basicConfig(level=logging.INFO)
29
+ logger = logging.getLogger("satquery.teochat")
30
+
31
+ try:
32
+ import spaces
33
+ has_spaces = True
34
+ except ImportError:
35
+ has_spaces = False
36
+
37
+ MODEL_ID = os.getenv("MODEL_ID", "jirvin16/TEOChat")
38
+ DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
39
+
40
+ _model = None
41
+ _tokenizer = None
42
+ _processor = None
43
+ _load_error = None
44
+
45
+
46
+ def ensure_videollava_installed():
47
+ """Ensure videollava package from official TEOChat repo is importable."""
48
+ try:
49
+ import videollava
50
+ return
51
+ except ImportError:
52
+ logger.info("[TEOChat] videollava not found. Attempting to install from official repo...")
53
+ import subprocess
54
+ import sys
55
+ try:
56
+ subprocess.run(
57
+ [sys.executable, "-m", "pip", "install", "--no-deps", "git+https://github.com/ermongroup/TEOChat.git"],
58
+ check=True,
59
+ capture_output=True
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  )
61
+ logger.info("[TEOChat] Successfully installed TEOChat videollava package!")
62
+ except Exception as err:
63
+ logger.error(f"[TEOChat] Failed to pip install git+https://github.com/ermongroup/TEOChat.git: {err}")
64
+ raise ImportError(
65
+ "Official TEOChat 'videollava' library is required. "
66
+ "Please run: pip install git+https://github.com/ermongroup/TEOChat.git"
67
+ ) from err
68
+
69
+
70
+ def load_teochat_model():
71
+ """Load the official TEOChat model using official videollava.eval.eval.load_model."""
72
+ global _model, _tokenizer, _processor, _load_error
73
+ if _model is not None and _tokenizer is not None and _processor is not None:
74
+ return _tokenizer, _model, _processor
75
+
76
+ logger.info(f"[TEOChat] Loading official model from {MODEL_ID} on {DEVICE}...")
77
+ try:
78
+ ensure_videollava_installed()
79
+ from videollava.eval.eval import load_model
80
+
81
+ # Load 8-bit when CUDA is available to fit ZeroGPU / T4 / A10G memory requirements
82
+ use_8bit = torch.cuda.is_available()
83
+ tokenizer, model, processor = load_model(
84
+ model_path=MODEL_ID,
85
+ model_base=None,
86
+ load_8bit=use_8bit,
87
+ device=DEVICE
88
+ )
89
 
90
+ _tokenizer = tokenizer
91
+ _model = model
92
+ _processor = processor
93
+ _load_error = None
94
+ logger.info("[TEOChat] Successfully loaded official checkpoint!")
95
+ return _tokenizer, _model, _processor
96
+ except Exception as exc:
97
+ _load_error = str(exc)
98
+ logger.error(f"[TEOChat] Failed to load official model: {exc}", exc_info=True)
99
+ raise exc
100
+
101
+
102
+ def decode_and_save_temp(img_input: Any, suffix: str = ".png") -> str:
103
+ """Decode image payload and persist to a temporary file path for the official processor."""
104
+ if img_input is None:
105
+ raise ValueError("Missing required temporal image input.")
106
+
107
+ # If it's already a valid existing file path on disk, return it directly
108
+ if isinstance(img_input, str) and os.path.exists(img_input):
109
+ return os.path.abspath(img_input)
110
+
111
+ pil_img = None
112
+ if isinstance(img_input, Image.Image):
113
+ pil_img = img_input.convert("RGB")
114
+ elif isinstance(img_input, str):
115
+ if img_input.startswith("data:image"):
116
+ _, b64 = img_input.split(",", 1)
117
+ raw = base64.b64decode(b64)
118
+ pil_img = Image.open(io.BytesIO(raw)).convert("RGB")
119
+ else:
120
+ try:
121
+ raw = base64.b64decode(img_input)
122
+ pil_img = Image.open(io.BytesIO(raw)).convert("RGB")
123
+ except Exception as e:
124
+ raise ValueError(f"Failed to decode base64 image: {e}")
125
+
126
+ if pil_img is None:
127
+ raise ValueError(f"Unrecognized image input format: {type(img_input)}")
128
+
129
+ tmp_file = tempfile.NamedTemporaryFile(suffix=suffix, delete=False)
130
+ pil_img.save(tmp_file.name, format="PNG")
131
+ tmp_file.close()
132
+ return tmp_file.name
133
+
134
+
135
+ def _run_teochat_inference(
136
+ image_t1_input: Any,
137
+ image_t2_input: Any,
138
+ query: str
139
+ ) -> Dict[str, Any]:
140
+ """Execute official TEOChat forward pass on bi-temporal sequence [T1, T2]."""
141
+ t0 = time.time()
142
+ tokenizer, model, processor = load_teochat_model()
143
+ from videollava.eval.inference import run_inference_single
144
+
145
+ if not query or not query.strip():
146
+ query = "What changed between T1 and T2?"
147
+
148
+ # 1. Save or expose valid local file paths
149
+ t1_path = decode_and_save_temp(image_t1_input, suffix="_t1.png")
150
+ t2_path = decode_and_save_temp(image_t2_input, suffix="_t2.png")
151
+
152
+ try:
153
+ # 2. Preserve strict chronological order [T1, T2]
154
+ image_paths = [t1_path, t2_path]
155
+
156
+ # 3. Build official prompt containing <video>
157
+ inp = f"These are two satellite images in chronological order: <video> {query.strip()}"
158
+
159
+ logger.info(f"[TEOChat] Executing official run_inference_single with {len(image_paths)} temporal images...")
160
+ with torch.inference_mode():
161
+ response = run_inference_single(
162
+ model=model,
163
+ processor=processor,
164
+ tokenizer=tokenizer,
165
+ inp=inp,
166
+ image_paths=image_paths,
167
+ conv_mode="v1",
168
+ timestamps=[],
169
+ prompt_strategy="interleave",
170
+ chronological_prefix=True,
171
+ temperature=0.2,
172
+ max_new_tokens=256
173
  )
174
+ finally:
175
+ # Clean up temporary files
176
+ for p in [t1_path, t2_path]:
177
+ try:
178
+ if os.path.exists(p):
179
+ os.remove(p)
180
+ except Exception:
181
+ pass
182
+
183
+ elapsed_sec = round(time.time() - t0, 3)
184
+
185
+ # Return standard normalized payload
186
+ return {
187
+ "status": "success",
188
+ "model": "TEOChat",
189
+ "task": "temporal_change_qa",
190
+ "answer": response,
191
+ "confidence": None, # No fabricated confidence score
192
+ "metadata": {
193
+ "model_id": MODEL_ID,
194
+ "checkpoint": "jirvin16/TEOChat",
195
+ "num_temporal_images": 2,
196
+ "inference_time_sec": elapsed_sec,
197
+ "device": str(DEVICE)
198
+ }
199
+ }
200
 
 
 
 
 
201
 
202
+ # Wrap in ZeroGPU decorator if running on Hugging Face Spaces with ZeroGPU
203
+ if has_spaces:
204
+ execute_temporal_inference = spaces.GPU(duration=60)(_run_teochat_inference)
205
+ else:
206
+ execute_temporal_inference = _run_teochat_inference
207
+
208
+
209
+ # ==============================================================================
210
+ # Gradio Web Interface
211
+ # ==============================================================================
212
+ def gradio_fn(t1_img, t2_img, query):
213
+ try:
214
+ res = execute_temporal_inference(t1_img, t2_img, query)
215
+ return res["answer"]
216
+ except Exception as err:
217
+ return f"Error: {str(err)}"
218
+
219
+
220
+ demo = gr.Interface(
221
+ fn=gradio_fn,
222
+ inputs=[
223
+ gr.Image(type="pil", label="Baseline Observation (T1 / Pre-Event)"),
224
+ gr.Image(type="pil", label="Target Observation (T2 / Post-Event)"),
225
+ gr.Textbox(lines=2, placeholder="e.g. What changed between T1 and T2?", label="Temporal Reasoning Query", value="What changed between T1 and T2?")
226
+ ],
227
+ outputs=[
228
+ gr.Textbox(label="TEOChat Official Delta Narrative")
229
+ ],
230
+ title="🛰️ SatQuery: Official TEOChat Bi-Temporal Specialist Service",
231
+ description="Official jirvin16/TEOChat Bi-Temporal Remote Sensing Specialist. Temporal change description and change-based VQA."
232
+ )
233
 
234
+ app = demo.app
235
+
236
+
237
+ # ==============================================================================
238
+ # Direct REST API for SatQuery Backend Adapter
239
+ # ==============================================================================
240
+ class ChangeVqaRequest(BaseModel):
241
+ image_t1: Optional[str] = Field(default=None, description="T1 image (base64 or path)")
242
+ image_t2: Optional[str] = Field(default=None, description="T2 image (base64 or path)")
243
+ query: str = Field(default="", description="Temporal change query")
244
+ metadata: Dict[str, Any] = Field(default_factory=dict)
245
+ parameters: Dict[str, Any] = Field(default_factory=dict)
246
+
247
+
248
+ @app.get("/health")
249
+ def api_health(response: Response):
250
+ """Healthcheck endpoint. Returns healthy only if model is successfully loaded or can be loaded."""
251
+ global _model, _load_error
252
+ if _load_error:
253
+ response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE
254
+ return {
255
+ "status": "unhealthy",
256
+ "model": "jirvin16/TEOChat",
257
+ "error": _load_error
258
+ }
259
+ return {
260
+ "status": "healthy",
261
+ "model": "jirvin16/TEOChat",
262
+ "device": str(DEVICE),
263
+ "is_loaded": _model is not None
264
+ }
265
 
 
 
 
266
 
267
+ @app.post("/change_vqa")
268
+ async def api_change_vqa(req: ChangeVqaRequest, response: Response):
269
+ """Direct REST endpoint called by SatQuery ChangeAnalysisAdapter."""
270
+ try:
271
+ if not req.image_t1 or not req.image_t2:
272
+ response.status_code = status.HTTP_400_BAD_REQUEST
273
+ return {
274
+ "status": "error",
275
+ "model": "jirvin16/TEOChat",
276
+ "error": "Both T1 and T2 images are required for bi-temporal reasoning."
277
+ }
278
+
279
+ res = execute_temporal_inference(
280
+ image_t1_input=req.image_t1,
281
+ image_t2_input=req.image_t2,
282
+ query=req.query
283
+ )
284
+ return res
285
+ except Exception as exc:
286
+ logger.error(f"[TEOChat] API Execution error: {exc}", exc_info=True)
287
+ response.status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
288
+ return {
289
+ "status": "error",
290
+ "model": "jirvin16/TEOChat",
291
+ "error": str(exc)
292
+ }
293
 
294
 
295
  if __name__ == "__main__":
296
+ demo.launch(server_name="0.0.0.0", server_port=7860, ssr_mode=False)
requirements.txt CHANGED
@@ -1,7 +1,21 @@
1
  gradio>=4.20.0
2
  fastapi>=0.110.0
3
  uvicorn>=0.28.0
4
- torch>=2.0.0
5
- torchvision>=0.15.0
 
 
 
 
 
 
 
6
  pillow>=10.0.0
7
  numpy>=1.24.0
 
 
 
 
 
 
 
 
1
  gradio>=4.20.0
2
  fastapi>=0.110.0
3
  uvicorn>=0.28.0
4
+ pydantic>=2.0.0
5
+ torch==2.2.1
6
+ torchvision==0.17.1
7
+ transformers==4.31.0
8
+ tokenizers>=0.12.1,<0.14
9
+ sentencepiece==0.1.99
10
+ accelerate==0.26.1
11
+ peft==0.7.1
12
+ bitsandbytes==0.41.0
13
  pillow>=10.0.0
14
  numpy>=1.24.0
15
+ shortuuid
16
+ einops==0.6.1
17
+ einops-exts==0.0.4
18
+ timm==0.6.12
19
+ opencv-python-headless>=4.7.0.72
20
+ decord==0.6.0
21
+ git+https://github.com/ermongroup/TEOChat.git