File size: 12,184 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
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
"""Deployment-only adapter between Gradio file inputs and SatQuery's agent API.

This module intentionally delegates every scientific decision to the existing
``satquery_agent.api.agent_image_query`` implementation.  It only translates
transport types and exposes generated preview files to Gradio.
"""

from __future__ import annotations

import asyncio
import mimetypes
import os
from pathlib import Path
from typing import Any, Iterable

from fastapi import HTTPException
from starlette.datastructures import Headers, UploadFile


APP_ROOT = Path(__file__).resolve().parent
CACHE_ROOT = APP_ROOT / ".cache"


def configure_environment() -> None:
    """Set safe Space defaults before importing SatQuery model singletons."""

    cache_root = CACHE_ROOT.resolve()
    os.environ.setdefault("HF_HOME", str(cache_root / "huggingface"))
    os.environ.setdefault("HUGGINGFACE_HUB_CACHE", str(cache_root / "huggingface" / "hub"))
    os.environ.setdefault("TRANSFORMERS_CACHE", str(cache_root / "huggingface" / "hub"))
    os.environ.setdefault("SATQUERY_MODEL_CACHE", str(cache_root / "public-models"))
    os.environ.setdefault("SATQUERY_WORKSPACE_ROOT", str(APP_ROOT))
    os.environ.setdefault("SATQUERY_PREVIEW_DIR", str(cache_root / "previews"))
    os.environ.setdefault("SATQUERY_ARTIFACT_DIR", str(cache_root / "artifacts"))
    os.environ.setdefault("SATQUERY_GROUNDER_LOCAL_MODEL", str(APP_ROOT / "models" / "grounding_dino_tiny"))
    os.environ.setdefault("SATQUERY_GROUNDER_LOCAL_FILES_ONLY", "true")
    os.environ.setdefault("SATQUERY_GROUNDER_ALLOW_DOWNLOAD", "false")
    os.environ.setdefault("SATQUERY_GROUNDER_LOAD_ON_STARTUP", "false")
    os.environ.setdefault("SVE_LOAD_ON_STARTUP", "false")
    os.environ.setdefault("RSVQA_LOAD_ON_STARTUP", "false")
    os.environ.setdefault("TTP_ENABLED", "false")
    os.environ.setdefault("SATQUERY_CHANGEREX_ENABLED", "true")
    os.environ.setdefault("SATQUERY_CHANGE_ENGINE", "changerex")
    os.environ.setdefault("SATQUERY_CHANGEREX_DEVICE", "cpu")


configure_environment()

from satquery_agent.api import agent_health, agent_image_query  # noqa: E402
from satquery_agent.image_ingestion import preview_file_path  # noqa: E402
from satquery_agent.models import (  # noqa: E402
    ImageModality,
    InputMode,
    Modality,
    ObservationRole,
)


_BACKEND: Any | None = None


def _model_dump(value: Any) -> dict[str, Any]:
    if hasattr(value, "model_dump"):
        return value.model_dump(mode="json")
    return value.dict()


def preload_models() -> None:
    """Load the six private artifacts and public GPU models once per replica.

    ZeroGPU emulates CUDA during module initialization, which is the supported
    place to construct model singletons and move their weights to CUDA.  No
    inference or startup smoke request is performed here.
    """

    global _BACKEND
    if _BACKEND is not None:
        return

    import backend
    from satquery_agent.services.sve_service import get_sve_service
    from satquery_agent.specialists.captioner import get_captioner
    from satquery_agent.specialists.grounder import get_grounder
    from satquery_agent.specialists.rsvqa_specialist import get_rsvqa_specialist

    # Importing backend constructs and registers the three SAR translation
    # models. The remaining managers are process-wide singletons.
    _BACKEND = backend
    if any(
        model is None
        for model in (backend.PIX2PIX_MODEL, backend.SARFUSIONFORMER_MODEL, backend.COLOR_CORRECTOR)
    ):
        raise RuntimeError("One or more verified SAR translation artifacts could not be loaded.")
    get_sve_service().load()
    get_rsvqa_specialist().load()
    grounder = get_grounder()
    grounder.load()
    if grounder.grounding_specialist_health().status != "ready":
        raise RuntimeError("The verified Grounding Specialist artifact could not be loaded.")
    get_captioner().load()


def _upload(path_value: str | Path | None) -> UploadFile:
    if not path_value:
        raise ValueError("An image file is required.")
    path = Path(path_value).resolve()
    if not path.is_file():
        raise ValueError("The uploaded image is unavailable.")
    mime_type = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
    return UploadFile(
        path.open("rb"),
        size=path.stat().st_size,
        filename=path.name,
        headers=Headers({"content-type": mime_type}),
    )


def _preview_references(value: Any) -> Iterable[str]:
    if isinstance(value, dict):
        for nested in value.values():
            yield from _preview_references(nested)
    elif isinstance(value, list):
        for nested in value:
            yield from _preview_references(nested)
    elif isinstance(value, str) and value.startswith("/api/agent/previews/"):
        yield value


def _published_artifacts(response: dict[str, Any]) -> tuple[list[dict[str, Any]], list[str]]:
    manifest: list[dict[str, Any]] = []
    paths: list[str] = []
    seen: set[str] = set()
    for reference in _preview_references(response):
        if reference in seen:
            continue
        seen.add(reference)
        path = preview_file_path(reference.rsplit("/", 1)[-1])
        if path is None:
            continue
        index = len(paths)
        paths.append(str(path))
        manifest.append(
            {
                "index": index,
                "kind": "preview",
                "reference": reference,
                "filename": path.name,
            }
        )
    return manifest, paths


async def _execute_agent_query(
    *,
    query: str,
    input_mode: InputMode,
    primary_modality: Modality,
    primary_image_modality: ImageModality | None,
    primary_image_path: str | Path | None,
    secondary_modality: Modality | None = None,
    secondary_image_path: str | Path | None = None,
    primary_observation_role: ObservationRole | None = None,
    secondary_observation_role: ObservationRole | None = None,
    primary_date: str | None = None,
    secondary_date: str | None = None,
    use_cache: bool = True,
    force_rerun: bool = False,
) -> tuple[dict[str, Any], list[dict[str, Any]], list[str]]:
    uploads: list[UploadFile] = []
    try:
        primary_upload = _upload(primary_image_path)
        uploads.append(primary_upload)
        secondary_upload = None
        if secondary_image_path:
            secondary_upload = _upload(secondary_image_path)
            uploads.append(secondary_upload)
        response = await agent_image_query(
            query=query,
            input_mode=input_mode,
            primary_modality=primary_modality,
            primary_image_modality=primary_image_modality,
            primary_image=primary_upload,
            secondary_modality=secondary_modality,
            secondary_image=secondary_upload,
            primary_observation_role=primary_observation_role,
            secondary_observation_role=secondary_observation_role,
            primary_date=primary_date,
            secondary_date=secondary_date,
            use_cache=use_cache,
            force_rerun=force_rerun,
        )
        serialized = _model_dump(response)
        manifest, paths = _published_artifacts(serialized)
        return {"ok": True, "status_code": 200, "response": serialized}, manifest, paths
    except HTTPException as error:
        return {
            "ok": False,
            "status_code": error.status_code,
            "error": error.detail,
        }, [], []
    except (OSError, ValueError) as error:
        return {
            "ok": False,
            "status_code": 400,
            "error": {"code": "INVALID_INPUT", "message": str(error)},
        }, [], []
    except Exception:
        return {
            "ok": False,
            "status_code": 500,
            "error": {
                "code": "INFERENCE_FAILED",
                "message": "SatQuery could not complete this inference request.",
            },
        }, [], []
    finally:
        for upload in uploads:
            await upload.close()


def _run(**kwargs: Any) -> tuple[dict[str, Any], list[dict[str, Any]], list[str]]:
    return asyncio.run(_execute_agent_query(**kwargs))


def run_single_image(
    primary_image: str | None,
    query: str,
    primary_modality: str = "optical",
    primary_image_modality: str = "auto",
    use_cache: bool = True,
    force_rerun: bool = False,
) -> tuple[dict[str, Any], list[dict[str, Any]], list[str]]:
    """Execute the existing single-image agent workflow."""

    try:
        return _run(
            query=query,
            input_mode=InputMode.SINGLE,
            primary_modality=Modality(primary_modality),
            primary_image_modality=ImageModality(primary_image_modality),
            primary_image_path=primary_image,
            use_cache=use_cache,
            force_rerun=force_rerun,
        )
    except ValueError as error:
        return _invalid_parameter(error)


def run_optical_sar(
    optical_image: str | None,
    sar_image: str | None,
    query: str,
    use_cache: bool = True,
    force_rerun: bool = False,
) -> tuple[dict[str, Any], list[dict[str, Any]], list[str]]:
    """Execute the existing optical-plus-SAR cross-modal workflow."""

    return _run(
        query=query,
        input_mode=InputMode.CROSS_MODAL,
        primary_modality=Modality.OPTICAL,
        primary_image_modality=ImageModality.AUTO,
        primary_image_path=optical_image,
        secondary_modality=Modality.SAR,
        secondary_image_path=sar_image,
        primary_observation_role=ObservationRole.OPTICAL,
        secondary_observation_role=ObservationRole.SAR,
        use_cache=use_cache,
        force_rerun=force_rerun,
    )


def run_bitemporal(
    earlier_image: str | None,
    later_image: str | None,
    query: str,
    primary_date: str,
    secondary_date: str,
    primary_modality: str = "optical",
    use_cache: bool = True,
    force_rerun: bool = False,
) -> tuple[dict[str, Any], list[dict[str, Any]], list[str]]:
    """Execute the existing bi-temporal workflow."""

    try:
        modality = Modality(primary_modality)
        return _run(
            query=query,
            input_mode=InputMode.BI_TEMPORAL,
            primary_modality=modality,
            primary_image_modality=ImageModality.AUTO,
            primary_image_path=earlier_image,
            secondary_modality=modality,
            secondary_image_path=later_image,
            primary_date=primary_date,
            secondary_date=secondary_date,
            use_cache=use_cache,
            force_rerun=force_rerun,
        )
    except ValueError as error:
        return _invalid_parameter(error)


def _invalid_parameter(error: ValueError) -> tuple[dict[str, Any], list[dict[str, Any]], list[str]]:
    return {
        "ok": False,
        "status_code": 422,
        "error": {"code": "INVALID_PARAMETER", "message": str(error)},
    }, [], []


def space_health() -> dict[str, Any]:
    """Return model lifecycle state without performing inference or allocating a GPU."""

    backend_health: dict[str, Any] | None = None
    if _BACKEND is not None:
        backend_health = _BACKEND.health()
    return {
        "status": "ready" if _BACKEND is not None else "initializing",
        "runtime": "hugging-face-zerogpu",
        "backend": backend_health,
        "agent": _model_dump(agent_health()),
    }


def api_contract() -> dict[str, Any]:
    """Describe the stable bridge contract without touching model execution."""

    return {
        "schema_version": "hf-space-bridge-v1",
        "endpoints": {
            "single_image": {"gpu_duration_seconds": 90},
            "optical_sar": {"gpu_duration_seconds": 75},
            "bitemporal": {"gpu_duration_seconds": 90},
            "health": {"gpu": False},
            "contract": {"gpu": False},
        },
        "result_outputs": ["result_envelope", "artifact_manifest", "artifact_files"],
        "private_artifacts": {
            "repository": "AnirudhShashikumar/SatQuery_AI_Models",
            "count": 6,
            "authentication": "HF_TOKEN Space secret",
            "integrity": "pinned SHA-256 allowlist",
        },
    }