Spaces:
Running on Zero
Running on Zero
File size: 19,459 Bytes
2407511 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 | """Provider-neutral, server-side vision analysis for rendered SAR products.
This module deliberately operates on exported display images only. It never
touches model tensors, checkpoints, preprocessing, or metric calculations.
"""
from __future__ import annotations
import base64
import hashlib
import json
import os
import logging
import time
from dataclasses import dataclass
from typing import Any, Dict, Optional
import requests
from google import genai
from google.genai import errors as genai_errors
from google.genai import types as genai_types
LOGGER = logging.getLogger("sar-colorization.vision")
DEBUG_ENABLED = os.getenv("GEOVISION_DEBUG", "").strip().lower() in {"1", "true", "yes"}
ANALYSIS_TYPES = {
"pix2pix",
"sarfusionformer_raw",
"sarfusionformer_enhanced",
"sarfusionformer_corrected",
"ground_truth",
"difference",
"comparison",
}
SYSTEM_PROMPT = """You are an assistant supporting qualitative remote-sensing image review.
Analyze only visible patterns in the supplied rendered image. Do not claim that generated
SAR-to-optical imagery is real ground truth, do not infer precise geography, dates, land
ownership, activity, or identities, and do not make safety-critical decisions. Clearly
distinguish observations from plausible interpretations. Be especially cautious around
model hallucinations, synthetic colour, blur, tiling, and contrast enhancement. Return
only valid JSON matching the requested schema."""
SCHEMA: Dict[str, Any] = {
"type": "object",
"properties": {
"executive_summary": {"type": "string"},
"terrain": {"type": "array", "items": {"type": "string"}},
"structural_and_human_features": {"type": "array", "items": {"type": "string"}},
"vegetation_and_water": {"type": "array", "items": {"type": "string"}},
"image_quality": {"type": "array", "items": {"type": "string"}},
"possible_artifacts": {"type": "array", "items": {"type": "string"}},
"notes": {"type": "array", "items": {"type": "string"}},
"limitations": {"type": "array", "items": {"type": "string"}},
"recommended_actions": {"type": "array", "items": {"type": "string"}},
"confidence": {"type": "string", "enum": ["low", "medium", "high"]},
"disclaimer": {"type": "string"},
},
"required": [
"executive_summary", "terrain", "structural_and_human_features",
"vegetation_and_water", "image_quality", "possible_artifacts", "notes",
"limitations", "recommended_actions", "confidence", "disclaimer",
],
}
@dataclass(frozen=True)
class VisionSettings:
provider: str
api_key: Optional[str]
model: str
@classmethod
def from_environment(cls) -> "VisionSettings":
env_file = os.path.join(os.path.dirname(__file__), ".env")
if os.path.isfile(env_file):
with open(env_file, "r", encoding="utf-8") as environment:
for line in environment:
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
os.environ.setdefault(key.strip(), value.strip())
return cls(
provider=(os.getenv("VISION_PROVIDER", "").strip().lower() or ("gemini" if os.getenv("GEMINI_API_KEY") else "")),
api_key=os.getenv("VISION_API_KEY") or os.getenv("OPENAI_API_KEY") or os.getenv("GEMINI_API_KEY"),
model=(os.getenv("VISION_MODEL") if os.getenv("VISION_PROVIDER", "").strip().lower() != "gemini" else os.getenv("GEMINI_MODEL")) or os.getenv("GEMINI_DEFAULT_MODEL", "gemini-3.5-flash"),
)
@property
def configured(self) -> bool:
return self.provider in {"openai", "gemini"} and bool(self.api_key) and bool(self.model)
class VisionAnalysisError(RuntimeError):
"""Expected configuration, provider, or schema error for this optional feature."""
def __init__(self, message: str, code: str = "PROVIDER_ERROR", transient: bool = False) -> None:
super().__init__(message)
self.code = code
self.transient = transient
class ImageAnalysisService:
"""Small provider interface; add providers here without exposing keys to the UI."""
def __init__(self, settings: Optional[VisionSettings] = None) -> None:
self.settings = settings or VisionSettings.from_environment()
self._cache: Dict[str, Dict[str, Any]] = {}
def status(self) -> Dict[str, Any]:
return {
"available": self.settings.configured,
"provider": self.settings.provider or None,
"model": self.settings.model if self.settings.configured else None,
}
def _gemini_client(self):
return genai.Client(api_key=str(self.settings.api_key))
def _debug(self, **values: Any) -> Dict[str, Any]:
safe = {key: value for key, value in values.items() if key not in {"api_key", "image_bytes", "response_text"}}
return safe if DEBUG_ENABLED else {}
def _gemini_exception(self, error: Exception) -> VisionAnalysisError:
"""Turn SDK failures into safe, actionable UI error codes without secrets."""
http_code = getattr(error, "code", None)
details = (str(getattr(error, "details", "")) + " " + str(error)).lower()
if http_code == 401 or "api_key_invalid" in details or "api key not valid" in details:
return VisionAnalysisError("The saved Gemini API key is invalid or no longer authorized.", "INVALID_API_KEY")
if http_code == 403 or "permission denied" in details or "permission" in details:
return VisionAnalysisError("This API key does not have access to the selected Gemini model.", "MODEL_PERMISSION_DENIED")
if http_code == 404 or "not found" in details or ("model" in details and "supported" in details):
return VisionAnalysisError("The selected Gemini model is unavailable. Choose a supported model in Settings.", "UNSUPPORTED_MODEL")
if http_code == 429:
if "quota" in details or "resource_exhausted" in details:
return VisionAnalysisError("Your Gemini quota has been reached. Try again later or use another key.", "QUOTA_EXCEEDED", True)
return VisionAnalysisError("Gemini is temporarily rate-limited. Wait briefly and retry.", "RATE_LIMITED", True)
if http_code in {408, 504} or "timeout" in details or "timed out" in details:
return VisionAnalysisError("Gemini did not respond in time. Your reconstruction result is unaffected.", "TIMEOUT", True)
if "connect" in details or "network" in details or "dns" in details or "name resolution" in details:
return VisionAnalysisError("GeoVision could not reach Google Gemini. Check the network and retry.", "NETWORK_ERROR", True)
if isinstance(error, genai_errors.ServerError) or (isinstance(http_code, int) and http_code >= 500):
return VisionAnalysisError("Google Gemini is temporarily unavailable. Try again shortly.", "GOOGLE_API_UNAVAILABLE", True)
return VisionAnalysisError("Gemini returned an unexpected provider error. The reconstruction result is unaffected.", "UNKNOWN_PROVIDER_ERROR")
def test_connection(self) -> Dict[str, Any]:
if not self.settings.configured:
return {"success": False, "status": "not_configured", "message": "AI analysis requires an API key.", "error_code": "NOT_CONFIGURED"}
started = __import__("time").perf_counter()
try:
if self.settings.provider == "gemini":
LOGGER.info("Gemini connection test: model=%s key_length=%d endpoint=sdk.generate_content", self.settings.model, len(str(self.settings.api_key)))
client = self._gemini_client()
response = client.models.generate_content(
model=self.settings.model, contents="Reply with OK.",
config=genai_types.GenerateContentConfig(temperature=0, max_output_tokens=8),
)
LOGGER.info("Gemini connection test completed: model=%s response=%s", self.settings.model, bool(getattr(response, "text", None)))
elif self.settings.provider == "openai":
response = requests.get(
"https://api.openai.com/v1/models/{}".format(self.settings.model),
headers={"Authorization": "Bearer {}".format(self.settings.api_key)}, timeout=15
)
if not response.ok:
raise VisionAnalysisError("The saved API key could not access the selected model.", "INVALID_API_KEY")
else:
raise VisionAnalysisError("Unsupported AI provider.", "UNSUPPORTED_PROVIDER")
return {"success": True, "provider": "Google Gemini" if self.settings.provider == "gemini" else "OpenAI", "model": self.settings.model, "status": "connected", "latency_ms": round((__import__("time").perf_counter() - started) * 1000), "message": "Connection successful.", "debug": self._debug(model=self.settings.model, endpoint="sdk.generate_content", status="received", latency_ms=round((time.perf_counter() - started) * 1000))}
except (genai_errors.APIError, requests.RequestException, TimeoutError) as error:
mapped = self._gemini_exception(error) if self.settings.provider == "gemini" else VisionAnalysisError("The provider could not be reached.", "NETWORK_FAILURE", True)
LOGGER.exception("Gemini connection test failed: model=%s key_length=%d code=%s", self.settings.model, len(str(self.settings.api_key)), mapped.code)
return {"success": False, "provider": self.settings.provider, "status": "temporarily_unavailable" if mapped.transient else "invalid", "error_code": mapped.code, "message": str(mapped), "debug": self._debug(model=self.settings.model, endpoint="sdk.generate_content", error_code=mapped.code)}
except VisionAnalysisError as error:
LOGGER.exception("Provider connection test failed: code=%s", error.code)
return {"success": False, "provider": self.settings.provider, "status": "temporarily_unavailable" if error.transient else "invalid", "error_code": error.code, "message": str(error)}
except Exception as error:
mapped = self._gemini_exception(error) if self.settings.provider == "gemini" else VisionAnalysisError("The provider could not be reached.", "NETWORK_FAILURE", True)
LOGGER.exception("Unexpected Gemini connection-test failure: model=%s key_length=%d code=%s", self.settings.model, len(str(self.settings.api_key)), mapped.code)
return {"success": False, "provider": self.settings.provider, "status": "temporarily_unavailable" if mapped.transient else "invalid", "error_code": mapped.code, "message": str(mapped), "debug": self._debug(model=self.settings.model, endpoint="sdk.generate_content", error_code=mapped.code)}
def analyze_image(
self, image_bytes: bytes, mime_type: str, analysis_type: str, metadata: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
if analysis_type not in ANALYSIS_TYPES:
raise VisionAnalysisError("Unsupported analysis type: {}".format(analysis_type))
if not self.settings.configured:
raise VisionAnalysisError("Connect Google Gemini before using AI Analysis.", "PROVIDER_NOT_CONFIGURED")
if self.settings.provider not in {"openai", "gemini"}:
raise VisionAnalysisError("Unsupported AI provider.", "UNSUPPORTED_PROVIDER")
cache_key = hashlib.sha256(
image_bytes + self.settings.model.encode("utf-8") + analysis_type.encode("utf-8") + json.dumps(metadata or {}, sort_keys=True).encode("utf-8")
).hexdigest()
if cache_key in self._cache:
return {**self._cache[cache_key], "cached": True}
if self.settings.provider == "gemini":
result = self._analyze_gemini(image_bytes, mime_type, analysis_type, metadata or {})
self._cache[cache_key] = result
return result
encoded = base64.b64encode(image_bytes).decode("ascii")
context = {
"analysis_type": analysis_type,
"metadata": metadata or {},
"instruction": (
"Provide a qualitative review of this rendered image. This may be a generated "
"SAR-to-optical prediction, an enhanced display, a colour-corrected rendering, "
"or reference imagery. Describe only visual evidence and preserve scientific caution."
),
}
payload = {
"model": self.settings.model,
"input": [
{"role": "system", "content": [{"type": "input_text", "text": SYSTEM_PROMPT}]},
{
"role": "user",
"content": [
{"type": "input_text", "text": json.dumps(context)},
{"type": "input_image", "image_url": "data:{};base64,{}".format(mime_type, encoded), "detail": "high"},
],
},
],
"text": {
"format": {
"type": "json_schema",
"name": "remote_sensing_image_review",
"strict": True,
"schema": SCHEMA,
}
},
}
try:
response = requests.post(
"https://api.openai.com/v1/responses",
headers={"Authorization": "Bearer {}".format(self.settings.api_key), "Content-Type": "application/json"},
json=payload,
timeout=75,
)
except requests.RequestException as error:
raise VisionAnalysisError("The vision provider could not be reached.") from error
if not response.ok:
if response.status_code == 401:
raise VisionAnalysisError("OpenAI rejected the saved API key. Replace it in Settings → AI Providers.")
if response.status_code == 429:
raise VisionAnalysisError(
"OpenAI has no available API quota for this project. Add billing or use a funded API key, then test the connection in Settings → AI Providers."
)
if response.status_code == 403:
raise VisionAnalysisError("The saved API key does not have access to the configured model. Review the project permissions in Settings → AI Providers.")
raise VisionAnalysisError("The vision provider could not complete this request (HTTP {}). Try again or test the connection in Settings → AI Providers.".format(response.status_code))
try:
response_data = response.json()
output_text = response_data.get("output_text")
if not output_text:
output_text = next(
content["text"]
for item in response_data.get("output", [])
for content in item.get("content", [])
if content.get("type") == "output_text" and content.get("text")
)
report = json.loads(output_text)
except (KeyError, StopIteration, TypeError, ValueError) as error:
raise VisionAnalysisError("Vision provider returned an invalid structured analysis.") from error
result = {"report": report, "provider": self.settings.provider, "model": self.settings.model, "cached": False}
self._cache[cache_key] = result
return result
def _analyze_gemini(self, image_bytes: bytes, mime_type: str, analysis_type: str, metadata: Dict[str, Any]) -> Dict[str, Any]:
if mime_type not in {"image/png", "image/jpeg", "image/webp"}:
raise VisionAnalysisError("Unsupported image format. Use PNG, JPEG, or WEBP.", "INVALID_IMAGE")
if len(image_bytes) > 20 * 1024 * 1024:
raise VisionAnalysisError("Image too large for AI Analysis. Use an image smaller than 20 MB.", "IMAGE_TOO_LARGE")
prompt = """You are a professional remote sensing scientist. Analyze this reconstructed optical satellite image. Assess overall scene, land cover, vegetation, water, roads, buildings, urban density, agriculture, possible flood evidence, terrain, confidence score, model artifacts, and scientific observations. Do not present generated imagery as ground truth. Return a JSON object matching the requested schema; make every field concise and Markdown-friendly for display."""
image_part = genai_types.Part.from_bytes(data=image_bytes, mime_type=mime_type)
started = time.perf_counter()
LOGGER.info("Gemini image analysis: model=%s image_bytes=%d mime_type=%s analysis_type=%s", self.settings.model, len(image_bytes), mime_type, analysis_type)
try:
client = self._gemini_client()
response = client.models.generate_content(
model=self.settings.model,
contents=[image_part, prompt, json.dumps({"analysis_type": analysis_type, "metadata": metadata})],
config=genai_types.GenerateContentConfig(
system_instruction=SYSTEM_PROMPT, response_mime_type="application/json",
response_schema=SCHEMA, temperature=0.2, max_output_tokens=1800,
),
)
if DEBUG_ENABLED:
LOGGER.debug("Gemini SDK response metadata: %s", {"has_text": bool(response.text), "model": self.settings.model, "usage": getattr(getattr(response, "usage_metadata", None), "total_token_count", None)})
response_text = response.text
report = json.loads(response_text)
missing = set(SCHEMA["required"]) - set(report)
if missing:
raise ValueError("missing required report fields")
except (genai_errors.APIError, requests.RequestException, TimeoutError) as error:
mapped = self._gemini_exception(error)
LOGGER.exception("Gemini image analysis failed: model=%s image_bytes=%d mime_type=%s code=%s", self.settings.model, len(image_bytes), mime_type, mapped.code)
raise mapped from error
except (TypeError, ValueError, json.JSONDecodeError) as error:
LOGGER.exception("Gemini returned invalid structured output: model=%s", self.settings.model)
raise VisionAnalysisError("Gemini returned an invalid structured analysis.", "MALFORMED_RESPONSE") from error
except Exception as error:
mapped = self._gemini_exception(error)
LOGGER.exception("Unexpected Gemini image-analysis failure: model=%s image_bytes=%d mime_type=%s code=%s", self.settings.model, len(image_bytes), mime_type, mapped.code)
raise mapped from error
latency_ms = round((time.perf_counter() - started) * 1000)
usage = getattr(response, "usage_metadata", None)
LOGGER.info("Gemini image analysis complete: model=%s latency_ms=%d tokens=%s", self.settings.model, latency_ms, getattr(usage, "total_token_count", None))
return {"report": report, "provider": "gemini", "model": self.settings.model, "cached": False, "debug": self._debug(model=self.settings.model, endpoint="sdk.generate_content", latency_ms=latency_ms, image_size=len(image_bytes), mime_type=mime_type, status="success", token_usage=getattr(usage, "total_token_count", None))}
|