MohdUmar0223 commited on
Commit
c817825
·
0 Parent(s):

Initial commit

Browse files
.env.example ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # =============================================================================
2
+ # AQI Intelligence Engine — Environment Variables
3
+ # Rename this file to .env and fill in your API keys.
4
+ # =============================================================================
5
+
6
+ # Flask/FastAPI Settings
7
+ ENV=development
8
+ PORT=7860
9
+
10
+ # --- API Keys for Data Services ---
11
+
12
+ # OpenWeather API (Required for real-time and historical AQI data)
13
+ # Get a key from: https://openweathermap.org/api
14
+ OPENWEATHER_API_KEY=your_openweather_api_key_here
15
+
16
+ # Mappls (MapmyIndia) API Key (Used for Indian traffic and geocoding)
17
+ # Get a key from: https://www.mappls.com/
18
+ MAPPLS_API_KEY=your_mappls_api_key_here
19
+
20
+ # NASA FIRMS API Key (Used for active fire and thermal anomaly detection)
21
+ # Get a key from: https://firms.modaps.eosdis.nasa.gov/api/map_key/
22
+ NASA_FIRMS_API_KEY=your_nasa_firms_api_key_here
23
+
24
+ # Sentinel Hub API Key (Used for satellite imagery fetching)
25
+ # Get a key from: https://www.sentinel-hub.com/
26
+ SENTINEL_HUB_API_KEY=your_sentinel_hub_api_key_here
27
+
28
+ # --- Hugging Face Authentication Token ---
29
+ # Optional: Paste your Hugging Face User Access Token (read) to download gated models
30
+ # Get a token from: https://huggingface.co/settings/tokens
31
+ HF_TOKEN=your_hf_token_here
.gitignore ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # =============================================================================
2
+ # Git Ignore Rules for AQI Intelligence Engine
3
+ # =============================================================================
4
+
5
+ # Virtual Environments
6
+ .venv/
7
+ venv/
8
+ env/
9
+ ENV/
10
+ env.bak/
11
+ venv.bak/
12
+
13
+ # Python Cache & Bytecode
14
+ __pycache__/
15
+ *.py[cod]
16
+ *$py.class
17
+ *.so
18
+ .Python
19
+
20
+ # Environment Variables & Secrets
21
+ .env
22
+ !.env.example
23
+
24
+ # Model Binaries & Caches (Hugging Face models & XGBoost pkl download automatically on startup)
25
+ models/hf_cache/
26
+ hf_cache/
27
+ *.bin
28
+ *.safetensors
29
+ *.pth
30
+ *.pkl
31
+ *.joblib
32
+
33
+ # Logs & Temporary Files
34
+ *.log
35
+ tmp/
36
+ .tmp/
37
+
38
+ # IDE & OS Files
39
+ .vscode/
40
+ .idea/
41
+ .DS_Store
42
+ Thumbs.db
Dockerfile ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ # System dependencies for geospatial libs
4
+ RUN apt-get update && apt-get install -y --no-install-recommends \
5
+ build-essential \
6
+ libgdal-dev \
7
+ libgeos-dev \
8
+ libproj-dev \
9
+ && rm -rf /var/lib/apt/lists/*
10
+
11
+ WORKDIR /code
12
+
13
+ # Install Python dependencies
14
+ COPY requirements.txt .
15
+ RUN pip install --no-cache-dir --upgrade -r requirements.txt
16
+
17
+ # Create non-root user (required by HF Spaces)
18
+ RUN useradd -m -u 1000 user
19
+ USER user
20
+ ENV HOME=/home/user \
21
+ PATH=/home/user/.local/bin:$PATH \
22
+ HF_HOME=/tmp/hf_cache \
23
+ TRANSFORMERS_CACHE=/tmp/hf_cache
24
+
25
+ WORKDIR $HOME/app
26
+
27
+ # Copy application code
28
+ COPY --chown=user . $HOME/app
29
+
30
+ # Expose port 7860 (HF Spaces default)
31
+ EXPOSE 7860
32
+
33
+ # Start FastAPI + Gradio server
34
+ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
README.md ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: AQI Intelligence Engine
3
+ emoji: 🌬️
4
+ colorFrom: blue
5
+ colorTo: green
6
+ sdk: gradio
7
+ sdk_version: 4.44.0
8
+ app_file: app.py
9
+ pinned: false
10
+ license: mit
11
+ short_description: Smart City AQI Forecast & Satellite Vision AI Engine
12
+ ---
13
+
14
+ # 🌬️ AQI Intelligence Engine (Hugging Face ZeroGPU & CPU Dual Mode)
15
+
16
+ An AI-powered Urban Air Quality Intelligence Platform built for Smart City Intervention, AQI forecasting (Google TimesFM 2.5 & Tuned XGBoost), satellite vision analysis (Florence-2 + Grounding DINO), and source attribution.
17
+
18
+ ## 🚀 Key Features
19
+
20
+ 1. **Dual Hardware Compatibility (CPU + ZeroGPU)**:
21
+ - Runs seamlessly on **ZeroGPU Space (A100 dynamic allocation)** when deployed to Hugging Face with GPU hardware.
22
+ - Automatically falls back to **CPU execution** on standard local machines or free CPU spaces without crashing or RAM OOM.
23
+ 2. **Interactive Gradio Control Center**:
24
+ - Web GUI for instant time-series AQI forecasting and satellite image pollution source analysis.
25
+ 3. **Full REST API Support**:
26
+ - Includes FastAPI swagger endpoints at `/docs`.
27
+ - Direct JSON endpoints `/forecast`, `/analyze-image`, `/health`.
28
+
29
+ ## 📦 Deploying to Hugging Face Spaces
30
+
31
+ 1. Create a new Space on Hugging Face:
32
+ - **Space SDK**: Gradio
33
+ - **Hardware**: ZeroGPU (or CPU basic)
34
+ 2. Push the contents of `backend-ai/` to your Hugging Face Space repository:
35
+ ```bash
36
+ git remote add hf https://huggingface.co/spaces/YOUR_USERNAME/YOUR_SPACE_NAME
37
+ git push hf main
38
+ ```
39
+ 3. Your Space will build automatically and serve both the Gradio Web App and REST APIs on port 7860.
app.py ADDED
@@ -0,0 +1,311 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ AQI Intelligence Engine — Hugging Face Gradio + ZeroGPU Main Application Entry Point
3
+
4
+ Supports both standalone Gradio Web Interface and REST API backend.
5
+ Runs seamlessly on Hugging Face ZeroGPU spaces as well as standard CPU fallback.
6
+ """
7
+
8
+ import os
9
+ import sys
10
+ import io
11
+ import time
12
+ import json
13
+ import logging
14
+ from PIL import Image
15
+ import pandas as pd
16
+ import gradio as gr
17
+ import gradio_client.utils as client_utils
18
+
19
+ # Monkey-patch Gradio Client bug where boolean OpenAPI schema fields cause TypeError
20
+ if hasattr(client_utils, "_json_schema_to_python_type"):
21
+ _orig_json_schema_to_python_type = client_utils._json_schema_to_python_type
22
+ def safe_json_schema_to_python_type(schema, defs=None):
23
+ if isinstance(schema, bool):
24
+ return "Any"
25
+ try:
26
+ return _orig_json_schema_to_python_type(schema, defs)
27
+ except Exception:
28
+ return "Any"
29
+ client_utils._json_schema_to_python_type = safe_json_schema_to_python_type
30
+
31
+ import asyncio
32
+ from datetime import datetime, timezone
33
+
34
+ try:
35
+ import spaces
36
+ except ImportError:
37
+ spaces = None
38
+
39
+ def gpu_decorator(func=None, duration=None):
40
+ """ZeroGPU decorator wrapper that applies spaces.GPU on HF or identity on CPU."""
41
+ if spaces is not None:
42
+ try:
43
+ if func is None:
44
+ return spaces.GPU(duration=duration) if duration else spaces.GPU
45
+ if callable(func):
46
+ return spaces.GPU(func)
47
+ return spaces.GPU
48
+ except Exception:
49
+ pass
50
+ if func is None:
51
+ return lambda f: f
52
+ if callable(func):
53
+ return func
54
+ return lambda f: f
55
+
56
+ from config import get_device, HAS_SPACES
57
+ from services.forecast.service import forecast_aqi
58
+ from services.vision.service import detect_pollution_sources
59
+
60
+ logging.basicConfig(level=logging.INFO)
61
+ logger = logging.getLogger("app_hf")
62
+
63
+ # Default sample AQI history for quick testing
64
+ DEFAULT_AQI_HIST = "145, 150, 162, 178, 185, 210, 240, 260, 255, 230, 215, 195, 180, 175, 182, 198, 220, 245, 270, 290, 310, 295, 280, 260"
65
+
66
+
67
+ async def _async_handle_forecast_ui(aqi_text_input: str, lat_val: str, lon_val: str):
68
+ """Async core logic for AQI Forecasting."""
69
+ try:
70
+ if not aqi_text_input or not aqi_text_input.strip():
71
+ return "❌ Error: Please provide at least 24 hourly AQI readings.", None, ""
72
+
73
+ # Parse CSV string into list of floats
74
+ raw_vals = [x.strip() for x in aqi_text_input.split(",") if x.strip()]
75
+ aqi_series = [float(x) for x in raw_vals]
76
+
77
+ lat = float(lat_val) if lat_val and lat_val.strip() else None
78
+ lon = float(lon_val) if lon_val and lon_val.strip() else None
79
+
80
+ start_time = time.time()
81
+ result = await forecast_aqi(aqi_series, lat=lat, lon=lon)
82
+ elapsed = round(time.time() - start_time, 2)
83
+
84
+ # Build summary text
85
+ summary = (
86
+ f"### 🔮 Forecast Completed in {elapsed}s\n"
87
+ f"- **Active Hardware**: `{result.get('device_used', get_device())}`\n"
88
+ f"- **Model Used**: `{result.get('model_type', 'Google TimesFM 2.5 / XGBoost')}`\n"
89
+ f"- **24-Hour Horizon Avg AQI**: `{result.get('forecast_24h', {}).get('mean', 'N/A')}`\n"
90
+ f"- **48-Hour Horizon Avg AQI**: `{result.get('forecast_48h', {}).get('mean', 'N/A')}`\n"
91
+ f"- **72-Hour Horizon Avg AQI**: `{result.get('forecast_72h', {}).get('mean', 'N/A')}`\n"
92
+ )
93
+
94
+ # Build DataFrame for plot table
95
+ hourly_preds = result.get("hourly_predictions", [])
96
+ if hourly_preds:
97
+ df = pd.DataFrame(hourly_preds)
98
+ else:
99
+ df = pd.DataFrame({"step": list(range(1, len(aqi_series)+1)), "aqi": aqi_series})
100
+
101
+ formatted_json = json.dumps(result, indent=2)
102
+ return summary, df, formatted_json
103
+
104
+ except Exception as e:
105
+ logger.error(f"Forecast UI Error: {e}")
106
+ return f"❌ Execution Failed: {str(e)}", None, ""
107
+
108
+
109
+ @gpu_decorator
110
+ def handle_forecast_ui(aqi_text_input: str, lat_val: str, lon_val: str):
111
+ """Gradio handler for AQI Forecasting wrapped with @gpu_decorator."""
112
+ return asyncio.run(_async_handle_forecast_ui(aqi_text_input, lat_val, lon_val))
113
+
114
+
115
+ async def _async_handle_vision_ui(input_image):
116
+ """Async core logic for Satellite Vision Pollution Detection."""
117
+ try:
118
+ if input_image is None:
119
+ return "❌ Error: Please upload a satellite or aerial image.", ""
120
+
121
+ start_time = time.time()
122
+ pil_image = Image.fromarray(input_image) if not isinstance(input_image, Image.Image) else input_image
123
+
124
+ result = await detect_pollution_sources(pil_image)
125
+ elapsed = round(time.time() - start_time, 2)
126
+
127
+ scene_desc = result.get("scene_description", "No detailed description.")
128
+ sources = result.get("pollution_sources_found", [])
129
+ counts = result.get("source_count", {})
130
+ severity = result.get("severity", "unknown").upper()
131
+
132
+ sources_str = ", ".join(sources) if sources else "None identified"
133
+
134
+ summary = (
135
+ f"### 🛰️ Vision Analysis Complete ({elapsed}s)\n"
136
+ f"- **Device Active**: `{get_device()}`\n"
137
+ f"- **Severity Level**: **`{severity}`**\n"
138
+ f"- **Identified Sources**: `{sources_str}`\n\n"
139
+ f"#### 📝 Scene Description:\n{scene_desc}\n\n"
140
+ f"#### 📊 Category Counts:\n```json\n{json.dumps(counts, indent=2)}\n```"
141
+ )
142
+
143
+ formatted_json = json.dumps(result, indent=2)
144
+ return summary, formatted_json
145
+
146
+ except Exception as e:
147
+ logger.error(f"Vision UI Error: {e}")
148
+ return f"❌ Analysis Failed: {str(e)}", ""
149
+
150
+
151
+ @gpu_decorator
152
+ def handle_vision_ui(input_image):
153
+ """Gradio handler for Satellite Vision wrapped with @gpu_decorator."""
154
+ return asyncio.run(_async_handle_vision_ui(input_image))
155
+
156
+
157
+ # Build Gradio Blocks Interface
158
+ with gr.Blocks(title="AQI Intelligence Engine — HF ZeroGPU Edition", theme=gr.themes.Soft()) as demo:
159
+ gr.Markdown(
160
+ """
161
+ # 🌬️ AQI Intelligence Engine (ZeroGPU & CPU Compatible)
162
+ ### AI-Powered Urban Air Quality Forecasting & High-Resolution Satellite Vision Intelligence
163
+ ---
164
+ """
165
+ )
166
+
167
+ with gr.Tabs():
168
+ # TAB 1: FORECASTING
169
+ with gr.TabItem("📈 AQI Forecasting (TimesFM 2.5 & XGBoost)"):
170
+ gr.Markdown("Input historical hourly AQI readings to generate 24h, 48h, and 72h predictive forecasts.")
171
+ with gr.Row():
172
+ with gr.Column(scale=1):
173
+ aqi_in = gr.Textbox(
174
+ label="Historical Hourly AQI Readings (CSV format, min 24 hours)",
175
+ value=DEFAULT_AQI_HIST,
176
+ lines=4,
177
+ )
178
+ lat_in = gr.Textbox(label="Latitude (Optional, e.g. 28.6139)", value="28.6139")
179
+ lon_in = gr.Textbox(label="Longitude (Optional, e.g. 77.2090)", value="77.2090")
180
+ run_forecast_btn = gr.Button("🚀 Run Forecast", variant="primary")
181
+
182
+ with gr.Column(scale=1):
183
+ forecast_summary_out = gr.Markdown(label="Forecast Overview")
184
+ forecast_table_out = gr.Dataframe(label="Forecast Step Matrix")
185
+
186
+ with gr.Accordion("🔍 Raw JSON Response Payload", open=False):
187
+ forecast_json_out = gr.Code(language="json")
188
+
189
+ run_forecast_btn.click(
190
+ fn=handle_forecast_ui,
191
+ inputs=[aqi_in, lat_in, lon_in],
192
+ outputs=[forecast_summary_out, forecast_table_out, forecast_json_out],
193
+ )
194
+
195
+ # TAB 2: SATELLITE VISION
196
+ with gr.TabItem("🛰️ Satellite Vision AI (Florence-2 + Grounding DINO)"):
197
+ gr.Markdown("Upload satellite/aerial imagery to detect air pollution sources, smoke plumes, kiln clusters, and industrial emissions.")
198
+ with gr.Row():
199
+ with gr.Column(scale=1):
200
+ image_in = gr.Image(label="Upload Satellite / Aerial Image", type="numpy")
201
+ analyze_img_btn = gr.Button("🔍 Analyze Image", variant="primary")
202
+
203
+ with gr.Column(scale=1):
204
+ vision_summary_out = gr.Markdown(label="Vision Intelligence Findings")
205
+
206
+ with gr.Accordion("🔍 Detailed Detections JSON", open=False):
207
+ vision_json_out = gr.Code(language="json")
208
+
209
+ analyze_img_btn.click(
210
+ fn=handle_vision_ui,
211
+ inputs=[image_in],
212
+ outputs=[vision_summary_out, vision_json_out],
213
+ )
214
+
215
+ # TAB 3: HARDWARE & SYSTEM STATUS
216
+ with gr.TabItem("⚡ Hardware & API Status"):
217
+ device_active = get_device()
218
+ gr.Markdown(
219
+ f"""
220
+ ### 🖥️ Active Deployment Info
221
+ - **Primary Compute Hardware**: `{device_active}`
222
+ - **HuggingFace `spaces` SDK Available**: `{spaces is not None}`
223
+ - **ZeroGPU Status**: `{"Active & Ready for dynamic GPU acceleration" if spaces is not None else "Running on standard CPU mode (Universal Compatibility)"}`
224
+
225
+ ### 🔌 REST API Endpoints
226
+ When deployed on Hugging Face Spaces, you can query REST endpoints directly:
227
+ - `POST /forecast`: Direct historical AQI prediction payload
228
+ - `POST /analyze-image`: Satellite base64 image breakdown
229
+ - `GET /health`: Core server state & device telemetry
230
+ """
231
+ )
232
+
233
+ from starlette.responses import JSONResponse
234
+ from starlette.requests import Request
235
+
236
+ # Initialize the Gradio queue — this creates the underlying FastAPI app object
237
+ # MUST be called before registering REST routes on demo.app
238
+ demo.queue()
239
+
240
+
241
+ def register_api_routes(gradio_app):
242
+ """Register custom REST API endpoints on the Gradio FastAPI app."""
243
+
244
+ @gradio_app.post("/api/v1/forecast")
245
+ async def api_forecast(request: Request):
246
+ """REST API endpoint for historical AQI prediction."""
247
+ try:
248
+ payload = await request.json()
249
+ from services.forecast.service import forecast_aqi
250
+ aqi_hist = payload.get("aqi_history", [])
251
+ lat = payload.get("lat")
252
+ lon = payload.get("lon")
253
+ res = await forecast_aqi(aqi_hist, lat=lat, lon=lon)
254
+ return JSONResponse(res)
255
+ except Exception as e:
256
+ logger.error(f"API Forecast error: {e}")
257
+ return JSONResponse({"error": str(e)}, status_code=400)
258
+
259
+ @gradio_app.post("/api/v1/analyze-image")
260
+ async def api_analyze_image(request: Request):
261
+ """REST API endpoint for satellite image pollution breakdown."""
262
+ try:
263
+ payload = await request.json()
264
+ import base64
265
+ from services.vision.service import detect_pollution_sources
266
+ base64_str = payload.get("image_base64", "")
267
+ if not base64_str:
268
+ return JSONResponse({"error": "No image_base64 provided"}, status_code=400)
269
+ if "," in base64_str:
270
+ base64_str = base64_str.split(",", 1)[1]
271
+ img_bytes = base64.b64decode(base64_str)
272
+ pil_img = Image.open(io.BytesIO(img_bytes)).convert("RGB")
273
+ res = await detect_pollution_sources(pil_img)
274
+ return JSONResponse(res)
275
+ except Exception as e:
276
+ logger.error(f"API Vision error: {e}")
277
+ return JSONResponse({"error": str(e)}, status_code=400)
278
+
279
+ @gradio_app.get("/api/v1/health")
280
+ async def api_health(request: Request):
281
+ """Health check endpoint."""
282
+ return JSONResponse({
283
+ "status": "ok",
284
+ "device": get_device(),
285
+ "timestamp": datetime.now(timezone.utc).isoformat(),
286
+ "version": "2.0.0",
287
+ })
288
+
289
+
290
+ # Register REST API routes on the Gradio underlying FastAPI app
291
+ try:
292
+ from fastapi.middleware.cors import CORSMiddleware
293
+ # Explicitly configure CORS on the underlying FastAPI app to allow direct requests from the browser
294
+ demo.app.add_middleware(
295
+ CORSMiddleware,
296
+ allow_origins=["*"],
297
+ allow_credentials=True,
298
+ allow_methods=["*"],
299
+ allow_headers=["*"],
300
+ )
301
+ register_api_routes(demo.app)
302
+ logger.info("CORS middleware and REST API routes registered successfully on Gradio FastAPI app.")
303
+ except Exception as e:
304
+ logger.warning(f"Could not register REST API routes or CORS (will be available via Gradio only): {e}")
305
+
306
+ # Export Gradio demo as root app object for Hugging Face ZeroGPU SDK
307
+ app = demo
308
+
309
+ if __name__ == "__main__":
310
+ # Local development only — on HF Spaces, uvicorn runs the app directly
311
+ demo.launch(server_name="0.0.0.0", server_port=7860, share=False)
config.py ADDED
@@ -0,0 +1,219 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ AQI Intelligence Engine — Central Configuration
3
+ Supports local development and HuggingFace Spaces deployment.
4
+ """
5
+
6
+ import os
7
+ import torch
8
+ from pathlib import Path
9
+ from dotenv import load_dotenv
10
+
11
+ # Load local environment variables from .env file if it exists
12
+ load_dotenv(dotenv_path=Path(__file__).parent / ".env")
13
+
14
+
15
+ # =============================================================================
16
+ # Environment Detection
17
+ # =============================================================================
18
+
19
+ IS_HF_SPACE = os.getenv("SPACE_ID") is not None
20
+ ENV = os.getenv("ENV", "development")
21
+
22
+ # =============================================================================
23
+ # ZeroGPU & Dynamic Device Configuration
24
+ # =============================================================================
25
+
26
+ try:
27
+ import spaces
28
+ HAS_SPACES = True
29
+ except ImportError:
30
+ HAS_SPACES = False
31
+
32
+ def gpu_decorator(func=None, duration=None):
33
+ """
34
+ Safely apply @spaces.GPU if in an active ZeroGPU environment,
35
+ otherwise return the original function as a no-op fallback.
36
+ """
37
+ if HAS_SPACES:
38
+ try:
39
+ if func is None:
40
+ return spaces.GPU(duration=duration) if duration else spaces.GPU
41
+ if callable(func):
42
+ return spaces.GPU(func)
43
+ return spaces.GPU
44
+ except (NotImplementedError, Exception):
45
+ pass
46
+
47
+ if func is None:
48
+ return lambda f: f
49
+ if callable(func):
50
+ return func
51
+ return lambda f: f
52
+
53
+ def get_device() -> str:
54
+ """Dynamically return 'cuda' if CUDA/ZeroGPU is active, otherwise 'cpu'."""
55
+ return "cuda" if torch.cuda.is_available() else "cpu"
56
+
57
+ def get_torch_dtype():
58
+ """Return torch.float16 for GPU or torch.float32 for CPU."""
59
+ return torch.float16 if get_device() == "cuda" else torch.float32
60
+
61
+ # Backward compatibility properties
62
+ DEVICE = get_device()
63
+ TORCH_DTYPE = get_torch_dtype()
64
+
65
+ # =============================================================================
66
+ # Paths
67
+ # =============================================================================
68
+
69
+ BASE_DIR = Path(__file__).parent
70
+ MODELS_DIR = Path(os.getenv("MODELS_DIR", str(BASE_DIR / "models")))
71
+ MODELS_DIR.mkdir(parents=True, exist_ok=True)
72
+
73
+ # HuggingFace cache — use /tmp on Spaces (writable), local dir otherwise
74
+ HF_CACHE_DIR = Path("/tmp/hf_cache") if IS_HF_SPACE else MODELS_DIR / "hf_cache"
75
+ HF_CACHE_DIR.mkdir(parents=True, exist_ok=True)
76
+ os.environ["HF_HOME"] = str(HF_CACHE_DIR)
77
+ os.environ["TRANSFORMERS_CACHE"] = str(HF_CACHE_DIR)
78
+
79
+ # =============================================================================
80
+ # Model Identifiers (HuggingFace Hub)
81
+ # =============================================================================
82
+
83
+ MODELS = {
84
+ "timesfm": "google/timesfm-2.5-200m-pytorch",
85
+ "florence2": "microsoft/Florence-2-base",
86
+ "grounding_dino": "IDEA-Research/grounding-dino-tiny",
87
+ "sam2": "facebook/sam2.1-hiera-small",
88
+ }
89
+
90
+ # =============================================================================
91
+ # Forecast Configuration
92
+ # =============================================================================
93
+
94
+ FORECAST_CONFIG = {
95
+ "max_context": 1024, # Max context length for TimesFM 2.5
96
+ "max_horizon": 128, # Max forecast horizon
97
+ "horizon_24h": 24, # Steps for 24-hour forecast
98
+ "horizon_48h": 48, # Steps for 48-hour forecast
99
+ "horizon_72h": 72, # Steps for 72-hour forecast
100
+ }
101
+
102
+ # =============================================================================
103
+ # Vision Configuration
104
+ # =============================================================================
105
+
106
+ VISION_CONFIG = {
107
+ "florence2_max_tokens": 1024,
108
+ "grounding_dino_box_threshold": 0.3,
109
+ "grounding_dino_text_threshold": 0.25,
110
+ "pollution_prompts": (
111
+ "smoke. fire. construction site. factory chimney. "
112
+ "dust cloud. burning waste. heavy vehicles. industrial plant. "
113
+ "brick kiln. open burning."
114
+ ),
115
+ }
116
+
117
+ # =============================================================================
118
+ # SAM2 Configuration
119
+ # =============================================================================
120
+
121
+ SAM2_CONFIG = {
122
+ "points_per_batch": 32,
123
+ "pred_iou_thresh": 0.7,
124
+ "stability_score_thresh": 0.85,
125
+ }
126
+
127
+ # =============================================================================
128
+ # Data API Keys & URLs
129
+ # =============================================================================
130
+
131
+ API_KEYS = {
132
+ "openweather": os.getenv("OPENWEATHER_API_KEY", ""),
133
+ "mappls": os.getenv("MAPPLS_API_KEY", ""),
134
+ "sentinel_hub": os.getenv("SENTINEL_HUB_API_KEY", ""),
135
+ "nasa_firms": os.getenv("NASA_FIRMS_API_KEY", ""),
136
+ # CPCB (Central Pollution Control Board) — data.gov.in
137
+ "cpcb_api_key": os.getenv("CPCB_API_KEY", "579b464db66ec23bdd000001cdd3946e44ce4aad7209ff7b23ac571b"),
138
+ # Mappls (MapMyIndia) OAuth2 credentials
139
+ "mappls_client_id": os.getenv("MAPPLS_CLIENT_ID", ""),
140
+ "mappls_client_secret": os.getenv("MAPPLS_CLIENT_SECRET", ""),
141
+ # Planet Insight Platform (replaces deprecated Sentinel Hub)
142
+ "planet_api_key": os.getenv("SENTINEL_HUB_API_KEY", ""), # PLAK key
143
+ "planet_client_id": os.getenv("PLANET_INSIGHT_CLIENT_ID", ""),
144
+ "planet_client_secret": os.getenv("PLANET_INSIGHT_CLIENT_SECRET", ""),
145
+ # TomTom
146
+ "tomtom": os.getenv("TOMTOM_API_KEY", ""),
147
+ # Provider selection
148
+ "traffic_provider": os.getenv("TRAFFIC_PROVIDER", "mappls").lower(),
149
+ }
150
+
151
+ API_URLS = {
152
+ "openweather_aqi": "http://api.openweathermap.org/data/2.5/air_pollution",
153
+ "openweather_aqi_history": "http://api.openweathermap.org/data/2.5/air_pollution/history",
154
+ "openweather_aqi_forecast": "http://api.openweathermap.org/data/2.5/air_pollution/forecast",
155
+ "open_meteo": "https://api.open-meteo.com/v1/forecast",
156
+ "open_meteo_historical": "https://archive-api.open-meteo.com/v1/archive",
157
+ "overpass": "https://overpass-api.de/api/interpreter",
158
+ "nasa_firms": "https://firms.modaps.eosdis.nasa.gov/api/area/csv",
159
+ "worldpop": "https://www.worldpop.org/rest/data",
160
+ # CPCB (data.gov.in)
161
+ "cpcb_stations": "https://api.data.gov.in/resource/3b01bcb8-0b14-4abf-b6f2-c1bfd384ba69",
162
+ # Planet Insight / Sentinel Hub APIs
163
+ "sentinel_hub_auth": "https://services.sentinel-hub.com/auth/realms/main/protocol/openid-connect/token",
164
+ "sentinel_hub_process": "https://services.sentinel-hub.com/api/v1/process",
165
+ "sentinel_hub_catalog": "https://services.sentinel-hub.com/api/v1/catalog/1.0.0/search",
166
+ "planet_data": "https://api.planet.com/data/v1",
167
+ "planet_basemaps": "https://api.planet.com/basemaps/v1/mosaics",
168
+ # TomTom
169
+ "tomtom_traffic": "https://api.tomtom.com/traffic/services/4/flowSegmentData/absolute/10/json",
170
+ }
171
+
172
+ # =============================================================================
173
+ # Cache TTL Settings (in seconds)
174
+ # =============================================================================
175
+
176
+ CACHE_TTL = {
177
+ "aqi": 300, # 5 minutes
178
+ "weather": 900, # 15 minutes
179
+ "traffic": 120, # 2 minutes
180
+ "satellite": 3600, # 1 hour
181
+ "fire": 600, # 10 minutes
182
+ "landuse": 86400, # 24 hours
183
+ "population": 86400, # 24 hours
184
+ "geospatial": 86400, # 24 hours
185
+ "cpcb": 1800, # 30 minutes (CPCB stations update hourly)
186
+ }
187
+
188
+ # =============================================================================
189
+ # India AQI Breakpoints (NAQI Standard)
190
+ # =============================================================================
191
+
192
+ AQI_BREAKPOINTS = {
193
+ "good": (0, 50),
194
+ "satisfactory": (51, 100),
195
+ "moderate": (101, 200),
196
+ "poor": (201, 300),
197
+ "very_poor": (301, 400),
198
+ "severe": (401, 500),
199
+ }
200
+
201
+ AQI_CATEGORIES = {
202
+ "good": {"color": "#00B050", "risk": "minimal", "advisory": "Air quality is good. No precautions needed."},
203
+ "satisfactory": {"color": "#92D050", "risk": "low", "advisory": "Acceptable for most. Unusually sensitive may notice symptoms."},
204
+ "moderate": {"color": "#FFC000", "risk": "moderate", "advisory": "May cause breathing discomfort to sensitive groups."},
205
+ "poor": {"color": "#FF6600", "risk": "high", "advisory": "May cause breathing discomfort to people on prolonged exposure."},
206
+ "very_poor": {"color": "#FF0000", "risk": "very_high", "advisory": "May cause respiratory illness on prolonged exposure."},
207
+ "severe": {"color": "#800000", "risk": "critical", "advisory": "Serious health effects. Everyone may experience problems."},
208
+ }
209
+
210
+ # =============================================================================
211
+ # Server Configuration
212
+ # =============================================================================
213
+
214
+ SERVER_CONFIG = {
215
+ "host": "0.0.0.0",
216
+ "port": int(os.getenv("PORT", 7860)),
217
+ "reload": ENV == "development",
218
+ "workers": 1, # Single worker for model memory efficiency
219
+ }
download_models.py ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Download all HuggingFace models required by the AQI Intelligence Engine.
3
+ Run this once before starting the server: python download_models.py
4
+ """
5
+
6
+ import os
7
+ import sys
8
+ import logging
9
+
10
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ def download_timesfm():
15
+ """Download Google TimesFM 2.5-200m model."""
16
+ logger.info("=" * 60)
17
+ logger.info("Downloading TimesFM 2.5-200m ...")
18
+ logger.info("=" * 60)
19
+ try:
20
+ from transformers import AutoModel
21
+ model = AutoModel.from_pretrained("google/timesfm-2.5-200m-pytorch", trust_remote_code=True)
22
+ logger.info(f"✅ TimesFM 2.5 downloaded successfully. Parameters: {sum(p.numel() for p in model.parameters()) / 1e6:.1f}M")
23
+ del model
24
+ except Exception as e:
25
+ logger.warning(f"⚠️ TimesFM transformer download failed: {e}")
26
+ logger.info("Trying alternative download via timesfm library...")
27
+ try:
28
+ import timesfm
29
+ model = timesfm.TimesFM_2p5_200M_torch.from_pretrained("google/timesfm-2.5-200m-pytorch")
30
+ logger.info("✅ TimesFM 2.5 downloaded via timesfm library.")
31
+ del model
32
+ except Exception as e2:
33
+ logger.error(f"❌ TimesFM download failed completely: {e2}")
34
+ logger.info("The forecast service will use fallback mode.")
35
+
36
+
37
+ def download_florence2():
38
+ """Download Microsoft Florence-2-base model."""
39
+ logger.info("=" * 60)
40
+ logger.info("Downloading Florence-2-base (230M params) ...")
41
+ logger.info("=" * 60)
42
+ try:
43
+ from transformers import AutoProcessor, AutoModelForCausalLM
44
+ processor = AutoProcessor.from_pretrained("microsoft/Florence-2-base", trust_remote_code=True)
45
+ model = AutoModelForCausalLM.from_pretrained("microsoft/Florence-2-base", trust_remote_code=True)
46
+ logger.info(f"✅ Florence-2-base downloaded. Parameters: {sum(p.numel() for p in model.parameters()) / 1e6:.1f}M")
47
+ del model, processor
48
+ except Exception as e:
49
+ logger.error(f"❌ Florence-2 download failed: {e}")
50
+
51
+
52
+ def download_grounding_dino():
53
+ """Download Grounding DINO tiny model."""
54
+ logger.info("=" * 60)
55
+ logger.info("Downloading Grounding DINO tiny ...")
56
+ logger.info("=" * 60)
57
+ try:
58
+ from transformers import AutoProcessor, AutoModelForZeroShotObjectDetection
59
+ processor = AutoProcessor.from_pretrained("IDEA-Research/grounding-dino-tiny")
60
+ model = AutoModelForZeroShotObjectDetection.from_pretrained("IDEA-Research/grounding-dino-tiny")
61
+ logger.info(f"✅ Grounding DINO downloaded. Parameters: {sum(p.numel() for p in model.parameters()) / 1e6:.1f}M")
62
+ del model, processor
63
+ except Exception as e:
64
+ logger.error(f"❌ Grounding DINO download failed: {e}")
65
+
66
+
67
+ def download_sam2():
68
+ """Download SAM2.1 hiera-small model."""
69
+ logger.info("=" * 60)
70
+ logger.info("Downloading SAM2.1 hiera-small ...")
71
+ logger.info("=" * 60)
72
+ try:
73
+ from transformers import AutoProcessor, AutoModel
74
+ processor = AutoProcessor.from_pretrained("facebook/sam2.1-hiera-small")
75
+ model = AutoModel.from_pretrained("facebook/sam2.1-hiera-small")
76
+ logger.info(f"✅ SAM2.1 downloaded. Parameters: {sum(p.numel() for p in model.parameters()) / 1e6:.1f}M")
77
+ del model, processor
78
+ except Exception as e:
79
+ logger.error(f"❌ SAM2.1 download failed: {e}")
80
+
81
+
82
+ def main():
83
+ """Download all models."""
84
+ logger.info("🚀 AQI Intelligence Engine — Model Downloader")
85
+ logger.info(f"HF Cache: {os.environ.get('HF_HOME', 'default')}")
86
+ logger.info("")
87
+
88
+ import torch
89
+ logger.info(f"PyTorch: {torch.__version__}")
90
+ logger.info(f"CUDA available: {torch.cuda.is_available()}")
91
+ logger.info(f"Device: {'cuda' if torch.cuda.is_available() else 'cpu'}")
92
+ logger.info("")
93
+
94
+ download_timesfm()
95
+ download_florence2()
96
+ download_grounding_dino()
97
+ download_sam2()
98
+
99
+ logger.info("")
100
+ logger.info("=" * 60)
101
+ logger.info("✅ All model downloads complete!")
102
+ logger.info("Run the server: uvicorn main:app --reload --port 7860")
103
+ logger.info("=" * 60)
104
+
105
+
106
+ if __name__ == "__main__":
107
+ # Set cache directory and Hugging Face token from config
108
+ try:
109
+ from config import HF_CACHE_DIR, API_KEYS
110
+ os.environ["HF_HOME"] = str(HF_CACHE_DIR)
111
+
112
+ # Load HuggingFace token if present in environment/config
113
+ hf_token = os.getenv("HF_TOKEN")
114
+ if hf_token:
115
+ os.environ["HF_TOKEN"] = hf_token
116
+ # Also set the standard HF_HUB_ENABLE_HF_TRANSFER / token for Hugging Face Hub library
117
+ os.environ["HUGGING_FACE_HUB_TOKEN"] = hf_token
118
+ logger.info("🔑 Hugging Face token detected and configured.")
119
+ except ImportError:
120
+ pass
121
+
122
+ main()
download_new_model.py ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Download Script for the New Indian AQI Prediction Model (XGBoost)
3
+ ==================================================================
4
+ Clones/downloads the tuned XGBoost model repository from Hugging Face:
5
+ https://huggingface.co/AdityaaXD/AQI-Prediction-Model-Of-India
6
+ """
7
+
8
+ import os
9
+ import sys
10
+ import subprocess
11
+ import shutil
12
+ import io
13
+ from pathlib import Path
14
+
15
+ # Fix Windows console encoding
16
+ sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
17
+ sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace')
18
+
19
+ # Base directories
20
+ BASE_DIR = Path(__file__).parent
21
+ MODELS_DIR = BASE_DIR / "models"
22
+ MODELS_DIR.mkdir(parents=True, exist_ok=True)
23
+
24
+ REPO_URL = "https://huggingface.co/AdityaaXD/AQI-Prediction-Model-Of-India"
25
+ TEMP_CLONE_DIR = MODELS_DIR / "temp_aqi_model_clone"
26
+
27
+ def download_model():
28
+ print("=" * 70)
29
+ print("🚀 DOWNLOADING ADITYAAXD AQI XGBOOST PREDICTION MODEL")
30
+ print(f" Source Repository: {REPO_URL}")
31
+ print("=" * 70)
32
+
33
+ # 1. Clean up any existing temp clone dir
34
+ if TEMP_CLONE_DIR.exists():
35
+ print("🧹 Cleaning up old temp clone directory...")
36
+ shutil.rmtree(TEMP_CLONE_DIR)
37
+
38
+ # 2. Run download using huggingface_hub or git clone
39
+ print("\n📦 Downloading repository from Hugging Face Hub...")
40
+ download_success = False
41
+ try:
42
+ from huggingface_hub import snapshot_download
43
+ snapshot_download(
44
+ repo_id="AdityaaXD/AQI-Prediction-Model-Of-India",
45
+ local_dir=str(TEMP_CLONE_DIR),
46
+ )
47
+ print("✅ Repository downloaded successfully via huggingface_hub API!")
48
+ download_success = True
49
+ except Exception as e_api:
50
+ print(f"⚠️ huggingface_hub snapshot_download failed ({e_api}), trying git clone...")
51
+ try:
52
+ subprocess.run(
53
+ ["git", "clone", REPO_URL, str(TEMP_CLONE_DIR)],
54
+ check=True,
55
+ stdout=subprocess.PIPE,
56
+ stderr=subprocess.PIPE,
57
+ text=True
58
+ )
59
+ print("✅ Repository cloned successfully via Git!")
60
+ download_success = True
61
+ except Exception as e_git:
62
+ print(f"❌ Git clone also failed: {e_git}")
63
+ return False
64
+
65
+ # 3. Locate the model files (.pkl, .json, etc.) and move them to models/
66
+ print("\n🔍 Searching for model files in cloned repository...")
67
+ model_files = list(TEMP_CLONE_DIR.glob("**/*.pkl")) + list(TEMP_CLONE_DIR.glob("**/*.json")) + list(TEMP_CLONE_DIR.glob("**/*.joblib"))
68
+
69
+ if not model_files:
70
+ print("⚠️ No direct pickle/json model files found. Listing all files in repository:")
71
+ for file in TEMP_CLONE_DIR.glob("*"):
72
+ if file.is_file():
73
+ print(f" - {file.name}")
74
+ # Copy everything to a dedicated directory in models
75
+ dest_dir = MODELS_DIR / "AQI-Prediction-Model-Of-India"
76
+ if dest_dir.exists():
77
+ shutil.rmtree(dest_dir)
78
+ shutil.copytree(TEMP_CLONE_DIR, dest_dir)
79
+ print(f"✅ Moved all files to {dest_dir}")
80
+ else:
81
+ # Move found model files directly into models/
82
+ for model_file in model_files:
83
+ dest_path = MODELS_DIR / model_file.name
84
+ shutil.copy2(model_file, dest_path)
85
+ print(f"✨ Copied {model_file.name} -> {dest_path}")
86
+
87
+ # 4. Cleanup temp folder
88
+ try:
89
+ print("🧹 Cleaning up temp clone directory...")
90
+ shutil.rmtree(TEMP_CLONE_DIR)
91
+ print("✅ Cleanup complete.")
92
+ except Exception as e:
93
+ print(f"⚠️ Warning: Could not clean up temp directory: {e}")
94
+
95
+ print("\n🎉 Model download and setup successfully completed!")
96
+ return True
97
+
98
+ if __name__ == "__main__":
99
+ download_model()
main.py ADDED
@@ -0,0 +1,194 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ AQI Intelligence Engine — FastAPI Main Application
3
+
4
+ The central entry point that:
5
+ 1. Loads all AI models on startup
6
+ 2. Registers all API routers
7
+ 3. Serves both locally and on HuggingFace Spaces (Docker)
8
+ """
9
+
10
+ import logging
11
+ import sys
12
+ import io
13
+
14
+ # Fix Windows console encoding for emoji rendering
15
+ if sys.platform.startswith("win"):
16
+ try:
17
+ sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
18
+ sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace')
19
+ except AttributeError:
20
+ pass
21
+
22
+ import time
23
+ from contextlib import asynccontextmanager
24
+ from datetime import datetime, timezone
25
+
26
+ from fastapi import FastAPI
27
+ from fastapi.middleware.cors import CORSMiddleware
28
+
29
+ from pydantic import BaseModel, Field
30
+ from config import SERVER_CONFIG, DEVICE
31
+
32
+ class HealthResponse(BaseModel):
33
+ status: str = "ok"
34
+ models_loaded: bool = False
35
+ device: str = "cpu"
36
+ timestamp: datetime = Field(default_factory=datetime.utcnow)
37
+ version: str = "2.0.0"
38
+
39
+ # Configure logging
40
+ logging.basicConfig(
41
+ level=logging.INFO,
42
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
43
+ handlers=[logging.StreamHandler(sys.stdout)],
44
+ )
45
+ logger = logging.getLogger("aqi_engine")
46
+
47
+ # Track model loading state
48
+ models_loaded = False
49
+ startup_time = None
50
+
51
+
52
+ @asynccontextmanager
53
+ async def lifespan(app: FastAPI):
54
+ """Load AI models on startup, clean up on shutdown."""
55
+ global models_loaded, startup_time
56
+
57
+ logger.info("=" * 60)
58
+ logger.info("🚀 AQI Intelligence Engine — Starting Up")
59
+ logger.info(f" Device: {DEVICE}")
60
+ logger.info("=" * 60)
61
+
62
+ import asyncio
63
+ import anyio
64
+
65
+ async def load_models_bg():
66
+ global models_loaded, startup_time
67
+ bg_start = time.time()
68
+
69
+ try:
70
+ from services.forecast.service import load_model as load_forecast
71
+ await anyio.to_thread.run_sync(load_forecast)
72
+ logger.info("✅ Forecast model loaded successfully")
73
+ except Exception as e:
74
+ logger.warning(f"Forecast model loading deferred: {e}")
75
+
76
+ # Heavy vision/segmentation models are loaded on-demand to prevent OOM crashes in container
77
+ logger.info("💤 Vision & Segmentation models deferred to on-demand loading")
78
+
79
+ elapsed = round(time.time() - bg_start, 2)
80
+ models_loaded = True
81
+ startup_time = elapsed
82
+ logger.info(f"✅ Startup models loaded in {elapsed}s")
83
+
84
+ # Start loading in background without blocking port binding
85
+ asyncio.create_task(load_models_bg())
86
+
87
+ logger.info(f"📡 API docs: http://localhost:{SERVER_CONFIG['port']}/docs")
88
+ logger.info("=" * 60)
89
+
90
+ yield # Application runs here
91
+
92
+ logger.info("Shutting down AQI Intelligence Engine...")
93
+
94
+
95
+ # =============================================================================
96
+ # FastAPI Application
97
+ # =============================================================================
98
+
99
+ app = FastAPI(
100
+ title="AQI Intelligence Engine",
101
+ description=(
102
+ "AI-powered Urban Air Quality Intelligence Platform for Smart City Intervention. "
103
+ "Provides AQI forecasting (TimesFM 2.5), satellite vision analysis (Florence-2 + "
104
+ "Grounding DINO), segmentation (SAM2), geospatial analysis, hotspot detection, "
105
+ "atmospheric dispersion modeling, source attribution, health risk assessment, "
106
+ "and inspector route optimization."
107
+ ),
108
+ version="2.0.0",
109
+ lifespan=lifespan,
110
+ )
111
+
112
+ # =============================================================================
113
+ # CORS Middleware (allow Next.js frontend to call this API)
114
+ # =============================================================================
115
+
116
+ app.add_middleware(
117
+ CORSMiddleware,
118
+ allow_origins=["*"], # In production: restrict to your Next.js domain
119
+ allow_credentials=True,
120
+ allow_methods=["*"],
121
+ allow_headers=["*"],
122
+ )
123
+
124
+ # =============================================================================
125
+ # Register Routers
126
+ # =============================================================================
127
+
128
+ from routers.services import services_router
129
+
130
+ app.include_router(services_router, prefix="/api/v1")
131
+ app.include_router(services_router)
132
+
133
+
134
+ # =============================================================================
135
+ # Mount Frontend Static Files & Serve index.html at Root
136
+ # =============================================================================
137
+
138
+ from fastapi.staticfiles import StaticFiles
139
+ from fastapi.responses import FileResponse
140
+ import os
141
+
142
+ # Create frontend directory if it doesn't exist yet
143
+ os.makedirs("frontend", exist_ok=True)
144
+
145
+ # Mount the static directory
146
+ app.mount("/frontend", StaticFiles(directory="frontend"), name="frontend")
147
+
148
+
149
+
150
+ # =============================================================================
151
+ # Health Check
152
+ # =============================================================================
153
+
154
+ @app.get("/health", response_model=HealthResponse, tags=["System"])
155
+ async def health_check():
156
+ """Health check endpoint — verifies the server and models are running."""
157
+ return HealthResponse(
158
+ status="ok",
159
+ models_loaded=models_loaded,
160
+ device=DEVICE,
161
+ timestamp=datetime.now(timezone.utc),
162
+ version="2.0.0",
163
+ )
164
+
165
+
166
+ @app.get("/", tags=["System"])
167
+ async def root():
168
+ """Root endpoint serving the interactive control center dashboard."""
169
+ index_path = os.path.join("frontend", "index.html")
170
+ if os.path.exists(index_path):
171
+ return FileResponse(index_path)
172
+ return {
173
+ "name": "AQI Intelligence Engine",
174
+ "version": "2.0.0",
175
+ "message": "Frontend dashboard not built yet. Visit /docs for APIs.",
176
+ "docs": "/docs",
177
+ "health": "/health"
178
+ }
179
+
180
+
181
+ # =============================================================================
182
+ # Run with: uvicorn main:app --reload --port 7860
183
+ # =============================================================================
184
+
185
+ if __name__ == "__main__":
186
+ import uvicorn
187
+ uvicorn.run(
188
+ "main:app",
189
+ host=SERVER_CONFIG["host"],
190
+ port=SERVER_CONFIG["port"],
191
+ reload=SERVER_CONFIG["reload"],
192
+ workers=SERVER_CONFIG["workers"],
193
+ reload_excludes=["models/*", "**/models/*", "*.json", "frontend/*"],
194
+ )
models/.gitkeep ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ # Keep this directory for downloaded model weights
2
+ # This directory is gitignored — models are downloaded at runtime
requirements.txt ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # =============================================================================
2
+ # AQI Intelligence Engine — Dependencies
3
+ # =============================================================================
4
+
5
+ # --- Core API Framework & Gradio Interface ---
6
+ fastapi>=0.115,<1.0.0
7
+ uvicorn[standard]>=0.32
8
+ pydantic>=2.10
9
+ python-multipart>=0.0.12
10
+ gradio>=4.44.0,<5.0.0
11
+ starlette>=0.36.0,<1.0.0
12
+ jinja2>=3.1.0,<3.1.5
13
+
14
+ # --- AI / ML Models ---
15
+ torch>=2.1
16
+ transformers>=4.48
17
+ huggingface_hub>=0.23.0,<0.25.0
18
+ timm>=1.0
19
+ timesfm[torch]>=2.0
20
+ spaces>=0.28.3 # Hugging Face ZeroGPU SDK (falls back to CPU outside HF)
21
+
22
+ # --- Data & Computation ---
23
+ numpy>=1.26
24
+ pandas>=2.2
25
+ scipy>=1.14
26
+ scikit-learn>=1.5
27
+ xgboost>=2.0.0
28
+ hdbscan>=0.8.40
29
+
30
+ # --- Geospatial ---
31
+ geopandas>=1.0
32
+ shapely>=2.0
33
+ h3>=4.1
34
+ # rasterio>=1.4 # Optional: uncomment if using local raster files
35
+
36
+ # --- Optimization ---
37
+ ortools>=9.11
38
+
39
+ # --- HTTP Client ---
40
+ httpx>=0.27
41
+
42
+ # --- Image Processing ---
43
+ Pillow>=10.4
44
+
45
+ # --- Caching ---
46
+ cachetools>=5.5
47
+
48
+ # --- Utilities ---
49
+ python-dotenv>=1.0
routers/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Routers package — FastAPI route definitions."""
routers/services.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Individual service routers — stand-alone TimesFM forecast and Vision endpoints.
3
+ """
4
+
5
+ import logging
6
+ import base64
7
+ import io
8
+ import time
9
+ from fastapi import APIRouter, HTTPException
10
+ from pydantic import BaseModel
11
+ from PIL import Image
12
+
13
+ from services.forecast.service import forecast_aqi
14
+ from services.vision.service import detect_pollution_sources
15
+
16
+ logger = logging.getLogger("aqi_engine.services_router")
17
+
18
+ services_router = APIRouter(tags=["Model Services"])
19
+
20
+ class DirectForecastRequest(BaseModel):
21
+ aqi_history: list[float]
22
+ forecast_steps: int = 24
23
+ lat: float = None
24
+ lon: float = None
25
+
26
+ @services_router.post("/forecast")
27
+ async def forecast_endpoint(request: DirectForecastRequest):
28
+ """
29
+ Run TimesFM forecasting directly using the provided historical AQI list from the client.
30
+ """
31
+ try:
32
+ result = await forecast_aqi(
33
+ request.aqi_history,
34
+ lat=request.lat,
35
+ lon=request.lon
36
+ )
37
+ return result
38
+ except Exception as e:
39
+ logger.error(f"Forecast endpoint error: {e}")
40
+ raise HTTPException(status_code=500, detail=str(e))
41
+
42
+
43
+ class AnalysisRequest(BaseModel):
44
+ image_base64: str
45
+
46
+ class AnalysisResponse(BaseModel):
47
+ scene_description: str = ""
48
+ detections: list = []
49
+ pollution_sources: list = []
50
+ source_count: dict = {}
51
+ severity: str = "unknown"
52
+ processing_time_seconds: float = 0
53
+ error: str = None
54
+
55
+ @services_router.post("/analyze-image", response_model=AnalysisResponse)
56
+ async def analyze_image_endpoint(request: AnalysisRequest):
57
+ """
58
+ Run Florence-2 and Grounding DINO on-demand for a single satellite image.
59
+ Uses the provided base64 image and returns detections/scene description.
60
+ """
61
+ start_time = time.time()
62
+ logger.info("🧠 Vision AI analysis requested for base64 image")
63
+ try:
64
+ if not request.image_base64:
65
+ return AnalysisResponse(error="No image data provided")
66
+
67
+ # Decode base64 to PIL Image
68
+ base64_str = request.image_base64
69
+ if "," in base64_str:
70
+ base64_str = base64_str.split(",", 1)[1]
71
+ img_data = base64.b64decode(base64_str)
72
+ pil_image = Image.open(io.BytesIO(img_data)).convert("RGB")
73
+
74
+ # Run vision pipeline
75
+ vision_result = await detect_pollution_sources(pil_image)
76
+
77
+ elapsed = round(time.time() - start_time, 2)
78
+ return AnalysisResponse(
79
+ scene_description=vision_result.get("scene_description", ""),
80
+ detections=vision_result.get("detections", []),
81
+ pollution_sources=vision_result.get("pollution_sources_found", []),
82
+ source_count=vision_result.get("source_count", {}),
83
+ severity=vision_result.get("severity", "unknown"),
84
+ processing_time_seconds=elapsed,
85
+ )
86
+
87
+ except Exception as e:
88
+ logger.error(f"Vision analysis endpoint failed: {e}")
89
+ return AnalysisResponse(error=str(e))
services/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Services package — all AI service modules."""
services/forecast/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """"""
services/forecast/service.py ADDED
@@ -0,0 +1,621 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ AQI Forecasting Service — Tuned XGBoost (India Flagship) & Google TimesFM 2.5.
3
+
4
+ Combines high-accuracy local tabular regression (XGBoost) with general-purpose
5
+ univariate time series foundation models (TimesFM).
6
+ """
7
+
8
+ import logging
9
+ import joblib
10
+ import pandas as pd
11
+ from datetime import datetime, timezone, timedelta
12
+ from typing import Optional
13
+
14
+ import numpy as np
15
+ import torch
16
+ import httpx
17
+
18
+ from config import get_device, get_torch_dtype, MODELS, FORECAST_CONFIG, AQI_BREAKPOINTS, API_KEYS
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ # Global model references — loaded once at startup
23
+ _xgboost_model = None
24
+ _scaler = None
25
+ _timesfm_model = None
26
+ _model_type = None # "xgboost", "timesfm_native", "transformers", or "fallback"
27
+
28
+
29
+ def load_model() -> bool:
30
+ """
31
+ Load the forecasting models into memory.
32
+ Prioritizes loading the tuned XGBoost flagship model, and falls back to
33
+ TimesFM or statistical baselines if it is not available.
34
+ """
35
+ global _timesfm_model, _xgboost_model, _scaler, _model_type
36
+
37
+ if _xgboost_model is not None or _timesfm_model is not None:
38
+ return True
39
+
40
+ # Attempt 1: Tuned XGBoost Model (Flagship model of Breathe Easy)
41
+ try:
42
+ import os
43
+ from pathlib import Path
44
+
45
+ # Paths for model checkpoints
46
+ base_dir = Path(__file__).parent.parent.parent
47
+ model_path = base_dir / "models" / "best_xgboost_tuned.pkl"
48
+ scaler_path = base_dir / "models" / "scaler.pkl"
49
+
50
+ if not (model_path.exists() and scaler_path.exists()):
51
+ logger.info("XGBoost model files not found locally. Attempting automatic download from Hugging Face Hub...")
52
+ try:
53
+ from download_new_model import download_model
54
+ download_model()
55
+ except Exception as e_dl:
56
+ logger.warning(f"Automatic XGBoost model download failed: {e_dl}")
57
+
58
+ if model_path.exists() and scaler_path.exists():
59
+ logger.info("Loading Tuned XGBoost model & StandardScaler...")
60
+ _xgboost_model = joblib.load(str(model_path))
61
+ _scaler = joblib.load(str(scaler_path))
62
+ _model_type = "xgboost"
63
+ logger.info("✅ Tuned XGBoost model and Scaler loaded successfully!")
64
+ return True
65
+ else:
66
+ logger.warning(f"XGBoost model files still not found after download attempt. Trying TimesFM...")
67
+ except Exception as e:
68
+ logger.warning(f"Failed to load tuned XGBoost model: {e}")
69
+
70
+ # Attempt 2: timesfm native library (Google Research standard)
71
+ try:
72
+ import timesfm
73
+ logger.info("Loading TimesFM 2.5 via native timesfm library...")
74
+ _timesfm_model = timesfm.TimesFM_2p5_200M_torch.from_pretrained(
75
+ MODELS["timesfm"]
76
+ )
77
+ _timesfm_model.compile(
78
+ timesfm.ForecastConfig(
79
+ max_context=FORECAST_CONFIG["max_context"],
80
+ max_horizon=FORECAST_CONFIG["max_horizon"],
81
+ use_continuous_quantile_head=True,
82
+ )
83
+ )
84
+ _model_type = "timesfm_native"
85
+ logger.info("✅ TimesFM loaded via native library")
86
+ return True
87
+ except Exception as e:
88
+ logger.warning(f"Native timesfm loading failed: {e}")
89
+
90
+ # Attempt 3: HuggingFace transformers
91
+ try:
92
+ from transformers import AutoModel
93
+ device = get_device()
94
+ logger.info(f"Loading TimesFM 2.5 via transformers on {device}...")
95
+ _timesfm_model = AutoModel.from_pretrained(
96
+ MODELS["timesfm"],
97
+ trust_remote_code=True,
98
+ dtype=get_torch_dtype(),
99
+ ).to(device).eval()
100
+ _model_type = "transformers"
101
+ logger.info(f"✅ TimesFM loaded via transformers on {device}")
102
+ return True
103
+ except Exception as e:
104
+ logger.warning(f"transformers loading failed: {e}")
105
+
106
+ logger.error("❌ No forecasting models could be loaded. Using statistical fallback.")
107
+ _model_type = "fallback"
108
+ return False
109
+
110
+
111
+ async def forecast_aqi(
112
+ historical_aqi: list[float],
113
+ weather_data: Optional[dict] = None,
114
+ traffic_data: Optional[dict] = None,
115
+ lat: Optional[float] = None,
116
+ lon: Optional[float] = None,
117
+ ) -> dict:
118
+ """
119
+ Forecast AQI for 24h, 48h, and 72h horizons.
120
+ Integrates univariate TimesFM foundation predictions with meteorological
121
+ forecasts and traffic congestion covariates. If the tuned XGBoost model
122
+ is loaded, it runs the flagship tabular prediction instead.
123
+
124
+ Args:
125
+ historical_aqi: List of hourly AQI values (oldest first, at least 24 values).
126
+ weather_data: Optional weather forecast dict for covariate context.
127
+ traffic_data: Optional traffic data dict.
128
+ lat: Optional latitude for XGBoost telemetry fetch.
129
+ lon: Optional longitude for XGBoost telemetry fetch.
130
+
131
+ Returns:
132
+ Forecast dict with point estimates, quantiles, and metadata.
133
+ """
134
+ # Ensure models are loaded
135
+ load_model()
136
+
137
+ if len(historical_aqi) < 24:
138
+ logger.warning(f"Only {len(historical_aqi)} historical points. Padding.")
139
+ # Pad with mean of available data
140
+ mean_val = np.mean(historical_aqi) if historical_aqi else 150
141
+ historical_aqi = [mean_val] * (24 - len(historical_aqi)) + historical_aqi
142
+
143
+ # Clean data: interpolate NaN/None values
144
+ series = np.array(historical_aqi, dtype=np.float32)
145
+ series = _interpolate_missing(series)
146
+
147
+ # Determine forecast horizon
148
+ horizon = FORECAST_CONFIG["horizon_72h"]
149
+
150
+ # Dispatch to appropriate model
151
+ quantile_forecast = None
152
+
153
+ if _model_type == "xgboost" and lat is not None and lon is not None:
154
+ logger.info("🔮 Running forecasting using tuned XGBoost model...")
155
+ point_forecast, quantile_forecast = await _forecast_xgboost(series, horizon, lat, lon)
156
+ if point_forecast is None:
157
+ # Telemetry fetch failed, fallback to statistical
158
+ logger.warning("XGBoost failed, falling back to statistical method.")
159
+ point_forecast, quantile_forecast = _forecast_statistical(series, horizon)
160
+ else:
161
+ if _model_type == "timesfm_native":
162
+ point_forecast, quantile_forecast = _forecast_native(series, horizon)
163
+ elif _model_type == "transformers":
164
+ point_forecast, quantile_forecast = _forecast_transformers(series, horizon)
165
+ else:
166
+ point_forecast, quantile_forecast = _forecast_statistical(series, horizon)
167
+
168
+ # --- Multivariate Meteorology & Traffic Adjustments for TimesFM/Statistical ---
169
+ base_forecast = np.copy(point_forecast)
170
+ point_forecast = _apply_multivariate_adjustments(
171
+ point_forecast, float(series[-1]), weather_data, traffic_data
172
+ )
173
+ if quantile_forecast is not None:
174
+ adjusted_quantiles = np.copy(quantile_forecast)
175
+ for t in range(len(point_forecast)):
176
+ ratio = point_forecast[t] / max(base_forecast[t], 1)
177
+ adjusted_quantiles[t] = quantile_forecast[t] * ratio
178
+ quantile_forecast = adjusted_quantiles
179
+
180
+ # Extract 24h/48h/72h forecasts
181
+ current_aqi = float(series[-1])
182
+ h24 = min(FORECAST_CONFIG["horizon_24h"], len(point_forecast)) - 1
183
+ h48 = min(FORECAST_CONFIG["horizon_48h"], len(point_forecast)) - 1
184
+ h72 = min(FORECAST_CONFIG["horizon_72h"], len(point_forecast)) - 1
185
+
186
+ forecasts = {}
187
+ for label, idx in [("24h", h24), ("48h", h48), ("72h", h72)]:
188
+ val = float(np.clip(point_forecast[idx], 0, 500))
189
+ if quantile_forecast is not None and len(quantile_forecast) > idx:
190
+ q = quantile_forecast[idx]
191
+ p10 = float(np.clip(q[0] if len(q) > 0 else val * 0.85, 0, 500))
192
+ p50 = float(np.clip(q[1] if len(q) > 1 else val, 0, 500))
193
+ p90 = float(np.clip(q[2] if len(q) > 2 else val * 1.15, 0, 500))
194
+ else:
195
+ p10 = float(np.clip(val * 0.85, 0, 500))
196
+ p50 = val
197
+ p90 = float(np.clip(val * 1.15, 0, 500))
198
+
199
+ forecasts[label] = {
200
+ "value": round(val, 1),
201
+ "p10": round(p10, 1),
202
+ "p50": round(p50, 1),
203
+ "p90": round(p90, 1),
204
+ }
205
+
206
+ # Determine trend
207
+ if forecasts["72h"]["value"] > current_aqi * 1.1:
208
+ trend = "increasing"
209
+ elif forecasts["72h"]["value"] < current_aqi * 0.9:
210
+ trend = "decreasing"
211
+ else:
212
+ trend = "stable"
213
+
214
+ # Calculate confidence based on forecast spread
215
+ spreads = [(f["p90"] - f["p10"]) / max(f["value"], 1) for f in forecasts.values()]
216
+ avg_spread = np.mean(spreads)
217
+ confidence = round(max(0.3, min(0.99, 1.0 - avg_spread)), 2)
218
+
219
+ # AQI category for 24h forecast
220
+ category = _get_aqi_category(forecasts["24h"]["value"])
221
+
222
+ hourly = [round(float(np.clip(v, 0, 500)), 1) for v in point_forecast[:horizon]]
223
+
224
+ model_label = "Tuned XGBoost Regressor (Breathe Easy)" if _model_type == "xgboost" else f"timesfm-2.5-200m ({_model_type})"
225
+
226
+ return {
227
+ "forecasts": forecasts,
228
+ "hourly": hourly,
229
+ "confidence": confidence,
230
+ "trend": trend,
231
+ "current_aqi": round(current_aqi, 1),
232
+ "category": category,
233
+ "model": model_label,
234
+ }
235
+
236
+
237
+ def _forecast_native(series: np.ndarray, horizon: int):
238
+ """Forecast using native timesfm library."""
239
+ try:
240
+ point_forecast, quantile_forecast = _timesfm_model.forecast(
241
+ horizon=horizon,
242
+ inputs=[series],
243
+ )
244
+ return point_forecast[0], quantile_forecast[0] if quantile_forecast is not None else None
245
+ except Exception as e:
246
+ logger.error(f"Native forecast failed: {e}")
247
+ return _forecast_statistical(series, horizon)
248
+
249
+
250
+ def _forecast_transformers(series: np.ndarray, horizon: int):
251
+ """Forecast using HuggingFace transformers."""
252
+ try:
253
+ device = get_device()
254
+ if _timesfm_model is not None and hasattr(_timesfm_model, "to"):
255
+ _timesfm_model.to(device)
256
+
257
+ # Convert inputs to expected shapes (batch_size=1, context_len)
258
+ past_values = torch.tensor(series, dtype=torch.float32).unsqueeze(0).to(device)
259
+
260
+ # Construct past_values_padding: False everywhere (valid data)
261
+ past_values_padding = torch.zeros_like(past_values, dtype=torch.bool).to(device)
262
+
263
+ # freq parameter (0 corresponds to standard frequency indicator for transformers implementation)
264
+ freq = torch.zeros((1,), dtype=torch.long).to(device)
265
+
266
+ with torch.no_grad():
267
+ outputs = _timesfm_model(
268
+ past_values=past_values,
269
+ past_values_padding=past_values_padding,
270
+ freq=freq,
271
+ )
272
+
273
+ mean_pred = outputs.mean_predictions.float().cpu().numpy()[0]
274
+
275
+ # Extract quantiles if available
276
+ quantile_pred = None
277
+ if hasattr(outputs, "quantile_predictions") and outputs.quantile_predictions is not None:
278
+ quantile_pred = outputs.quantile_predictions.float().cpu().numpy()[0]
279
+
280
+ return mean_pred[:horizon], quantile_pred[:horizon] if quantile_pred is not None else None
281
+ except Exception as e:
282
+ logger.error(f"Transformers forecast failed: {e}")
283
+ return _forecast_statistical(series, horizon)
284
+
285
+
286
+ def _forecast_statistical(series: np.ndarray, horizon: int):
287
+ """
288
+ Statistical fallback when TimesFM is unavailable.
289
+ Uses exponential smoothing + diurnal pattern extraction.
290
+ """
291
+ logger.info("Using statistical fallback forecast.")
292
+ n = len(series)
293
+
294
+ # Extract diurnal pattern (24-hour cycle)
295
+ if n >= 48:
296
+ daily_pattern = np.zeros(24)
297
+ counts = np.zeros(24)
298
+ for i in range(n):
299
+ hour = i % 24
300
+ daily_pattern[hour] += series[i]
301
+ counts[hour] += 1
302
+ daily_pattern = daily_pattern / np.maximum(counts, 1)
303
+ else:
304
+ daily_pattern = np.full(24, np.mean(series))
305
+
306
+ # Exponential smoothing for trend
307
+ alpha = 0.3
308
+ level = series[-1]
309
+ trend_val = np.mean(np.diff(series[-24:])) if n >= 25 else 0
310
+
311
+ forecast = np.zeros(horizon)
312
+ for i in range(horizon):
313
+ hour = (n + i) % 24
314
+ seasonal = daily_pattern[hour] - np.mean(daily_pattern)
315
+ forecast[i] = level + trend_val * (i + 1) + seasonal
316
+
317
+ # Dampen trend over time
318
+ trend_val *= 0.95
319
+
320
+ # Add noise for quantile estimation
321
+ noise_std = np.std(series[-24:]) if n >= 24 else 20
322
+ quantiles = np.zeros((horizon, 3))
323
+ for i in range(horizon):
324
+ spread = noise_std * np.sqrt(i + 1) * 0.3
325
+ quantiles[i, 0] = forecast[i] - spread # P10
326
+ quantiles[i, 1] = forecast[i] # P50
327
+ quantiles[i, 2] = forecast[i] + spread # P90
328
+
329
+ return forecast, quantiles
330
+
331
+
332
+ def _interpolate_missing(series: np.ndarray) -> np.ndarray:
333
+ """Replace NaN values with linear interpolation."""
334
+ nans = np.isnan(series)
335
+ if not np.any(nans):
336
+ return series
337
+
338
+ x = np.arange(len(series))
339
+ series[nans] = np.interp(x[nans], x[~nans], series[~nans])
340
+ return series
341
+
342
+
343
+ def _get_aqi_category(value: float) -> str:
344
+ """Map AQI value to NAQI category."""
345
+ for category, (low, high) in AQI_BREAKPOINTS.items():
346
+ if low <= value <= high:
347
+ return category.replace("_", " ").title()
348
+ return "Severe" if value > 400 else "Good"
349
+
350
+
351
+ def _apply_multivariate_adjustments(
352
+ base_forecast: np.ndarray,
353
+ current_aqi: float,
354
+ weather_forecast: Optional[dict],
355
+ traffic_data: Optional[dict],
356
+ ) -> np.ndarray:
357
+ """
358
+ Adjust the univariate TimesFM forecast by integrating meteorological covariates
359
+ (wind speed, precipitation, humidity, temperature) and traffic congestion patterns.
360
+ """
361
+ from datetime import datetime
362
+ n_steps = len(base_forecast)
363
+ adjusted = np.copy(base_forecast)
364
+
365
+ # 1. Extract weather forecast lists or fallback constants
366
+ wind_speeds = [10.0] * n_steps
367
+ humidities = [50.0] * n_steps
368
+ precip_probs = [0.0] * n_steps
369
+ temperatures = [25.0] * n_steps
370
+
371
+ if weather_forecast:
372
+ # Check if lists are available (typical forecast structure)
373
+ if isinstance(weather_forecast.get("wind_speed"), list) and len(weather_forecast["wind_speed"]) >= n_steps:
374
+ wind_speeds = weather_forecast["wind_speed"][:n_steps]
375
+ humidities = weather_forecast.get("humidity", [50.0] * n_steps)[:n_steps]
376
+ precip_probs = weather_forecast.get("precip_probability", [0.0] * n_steps)[:n_steps]
377
+ temperatures = weather_forecast.get("temperature", [25.0] * n_steps)[:n_steps]
378
+ else:
379
+ # Single value fallback (if current weather dict is passed)
380
+ wind_speeds = [float(weather_forecast.get("wind_speed", 10.0))] * n_steps
381
+ humidities = [float(weather_forecast.get("humidity", 50.0))] * n_steps
382
+ precip_probs = [float(weather_forecast.get("precipitation", 0.0)) * 10] * n_steps
383
+ temperatures = [float(weather_forecast.get("temperature", 25.0))] * n_steps
384
+
385
+ # 2. Extract traffic congestion index
386
+ congestion_index = 0.5
387
+ if traffic_data:
388
+ congestion_index = float(traffic_data.get("congestion_index", 0.5))
389
+
390
+ current_hour = datetime.now().hour
391
+
392
+ for t in range(n_steps):
393
+ hour = (current_hour + t) % 24
394
+ val = adjusted[t]
395
+
396
+ # --- A. METEOROLOGY ADJUSTMENTS ---
397
+
398
+ # Wind Dispersion: Soften the wind speed dispersion scaling bounds to prevent over-suppression.
399
+ w_speed = float(wind_speeds[t])
400
+ wind_mult = 10.0 / max(4.0, w_speed)
401
+ wind_mult = np.clip(wind_mult, 0.85, 1.20)
402
+
403
+ # Precipitation Washout: Rain washes out particulate matter.
404
+ rain_prob = float(precip_probs[t])
405
+ washout_effect = 0.0
406
+ if rain_prob > 20:
407
+ washout_effect = -0.05 - 0.15 * ((rain_prob - 20) / 80)
408
+
409
+ # Humidity Pollutant Trapping: High humidity traps smog.
410
+ humidity = float(humidities[t])
411
+ humidity_effect = 0.0
412
+ if humidity > 70:
413
+ humidity_effect = 0.10 * ((humidity - 70) / 30)
414
+
415
+ # Temperature Inversion (cold trapping)
416
+ temp = float(temperatures[t])
417
+ temp_effect = 0.0
418
+ if temp < 15 and humidity > 75:
419
+ temp_effect = 0.06
420
+
421
+ # --- B. TRAFFIC CONGESTION ADJUSTMENTS ---
422
+ # Traffic peaks in urban areas (morning 8-10 AM, evening 6-9 PM)
423
+ traffic_multiplier = 0.0
424
+ if hour in (8, 9, 10):
425
+ traffic_multiplier = 0.12 * congestion_index
426
+ elif hour in (17, 18, 19, 20):
427
+ traffic_multiplier = 0.18 * congestion_index
428
+ elif hour in (0, 1, 2, 3, 4):
429
+ traffic_multiplier = -0.08 # Night-time dip
430
+
431
+ # --- C. DIURNAL AND SEASONAL OFFSET ---
432
+ # Add a diurnal cyclic baseline correction (peaks around morning and night, dips in afternoon)
433
+ # Indian cities peak around 8 AM (rush hour + inversion) and 9 PM (heavy trucks + evening cooling)
434
+ diurnal_offset = 8.0 * np.sin(2.0 * np.pi * (hour - 6.0) / 24.0)
435
+
436
+ # --- D. COMBINE ADJUSTMENTS ---
437
+ # Apply multipliers
438
+ weather_scale = 1.0 + humidity_effect + temp_effect + washout_effect
439
+ val = val * weather_scale * wind_mult
440
+ val = val * (1.0 + traffic_multiplier)
441
+
442
+ # Apply additive diurnal shift
443
+ val += diurnal_offset
444
+
445
+ adjusted[t] = np.clip(val, 15, 500)
446
+
447
+ return adjusted
448
+
449
+
450
+ # =============================================================================
451
+ # XGBoost Flagship Model Helpers
452
+ # =============================================================================
453
+
454
+ async def _fetch_full_history_covariates(lat: float, lon: float, hours: int = 168) -> list[dict]:
455
+ """Fetch complete pollutant history from OpenWeather."""
456
+ api_key = API_KEYS.get("openweather", "")
457
+ if not api_key:
458
+ return []
459
+
460
+ url = "http://api.openweathermap.org/data/2.5/air_pollution/history"
461
+ now = int(datetime.now(timezone.utc).timestamp())
462
+ start = now - (hours * 3600)
463
+
464
+ try:
465
+ async with httpx.AsyncClient(timeout=30) as client:
466
+ resp = await client.get(
467
+ url,
468
+ params={"lat": lat, "lon": lon, "start": start, "end": now, "appid": api_key},
469
+ )
470
+ resp.raise_for_status()
471
+ data = resp.json()
472
+
473
+ records = []
474
+ for entry in data.get("list", []):
475
+ components = entry.get("components", {})
476
+ pm25 = components.get("pm2_5", 0)
477
+ aqi_val = _pm25_to_india_aqi(pm25) if pm25 > 0 else 150
478
+ dt = datetime.fromtimestamp(entry.get("dt", 0), tz=timezone.utc)
479
+
480
+ records.append({
481
+ "timestamp": dt,
482
+ "AQI": aqi_val,
483
+ "PM2.5": pm25,
484
+ "PM10": components.get("pm10", 0),
485
+ "NO": components.get("no", 0),
486
+ "NO2": components.get("no2", 0),
487
+ "SO2": components.get("so2", 0),
488
+ "CO": components.get("co", 0) / 1000.0, # mg/m3
489
+ "O3": components.get("o3", 0),
490
+ "NH3": components.get("nh3", 0),
491
+ })
492
+
493
+ return sorted(records, key=lambda x: x["timestamp"])
494
+ except Exception as e:
495
+ logger.error(f"Failed to fetch historical pollutants: {e}")
496
+ return []
497
+
498
+
499
+ def _engineer_xgboost_features(df: pd.DataFrame, target_idx: int) -> pd.DataFrame:
500
+ """Engineer all 38 features for the record at target_idx."""
501
+ slice_df = df.iloc[:target_idx+1].copy()
502
+ row = slice_df.iloc[-1].copy()
503
+ dt = row["timestamp"]
504
+
505
+ # 1. Base pollutants
506
+ feats = {
507
+ "PM2.5": row["PM2.5"],
508
+ "PM10": row["PM10"],
509
+ "NO": row["NO"],
510
+ "NO2": row["NO2"],
511
+ "NOx": row["NO"] + row["NO2"],
512
+ "NH3": row["NH3"],
513
+ "CO": row["CO"],
514
+ "SO2": row["SO2"],
515
+ "O3": row["O3"],
516
+ "Benzene": 1.5,
517
+ "Toluene": 3.0,
518
+ "Xylene": 1.0,
519
+ }
520
+
521
+ # 2. Temporal features
522
+ feats["Month"] = dt.month
523
+ feats["DayOfWeek"] = dt.weekday()
524
+ feats["Year"] = dt.year
525
+ feats["Day"] = dt.day
526
+ feats["DayOfYear"] = dt.timetuple().tm_yday
527
+ feats["Quarter"] = (dt.month - 1) // 3 + 1
528
+ feats["IsWeekend"] = 1 if dt.weekday() >= 5 else 0
529
+
530
+ # 3. Cyclical encodings
531
+ feats["Month_sin"] = np.sin(2 * np.pi * dt.month / 12.0)
532
+ feats["Month_cos"] = np.cos(2 * np.pi * dt.month / 12.0)
533
+ feats["DOW_sin"] = np.sin(2 * np.pi * dt.weekday() / 7.0)
534
+ feats["DOW_cos"] = np.cos(2 * np.pi * dt.weekday() / 7.0)
535
+
536
+ # 4. Lags
537
+ feats["AQI_lag1"] = slice_df.iloc[-2]["AQI"] if len(slice_df) >= 2 else row["AQI"]
538
+ feats["AQI_lag3"] = slice_df.iloc[-4]["AQI"] if len(slice_df) >= 4 else row["AQI"]
539
+ feats["AQI_lag7"] = slice_df.iloc[-8]["AQI"] if len(slice_df) >= 8 else row["AQI"]
540
+ feats["PM25_lag1"] = slice_df.iloc[-2]["PM2.5"] if len(slice_df) >= 2 else row["PM2.5"]
541
+ feats["PM10_lag1"] = slice_df.iloc[-2]["PM10"] if len(slice_df) >= 2 else row["PM10"]
542
+
543
+ # 5. Rolling statistics
544
+ feats["AQI_roll_mean_7"] = slice_df["AQI"].iloc[-7:].mean() if len(slice_df) >= 7 else slice_df["AQI"].mean()
545
+ feats["AQI_roll_std_7"] = slice_df["AQI"].iloc[-7:].std() if len(slice_df) >= 7 else 0.0
546
+ feats["AQI_roll_mean_14"] = slice_df["AQI"].iloc[-14:].mean() if len(slice_df) >= 14 else slice_df["AQI"].mean()
547
+ feats["AQI_roll_std_14"] = slice_df["AQI"].iloc[-14:].std() if len(slice_df) >= 14 else 0.0
548
+ feats["AQI_roll_mean_30"] = slice_df["AQI"].iloc[-30:].mean() if len(slice_df) >= 30 else slice_df["AQI"].mean()
549
+ feats["PM25_roll_mean_7"] = slice_df["PM2.5"].iloc[-7:].mean() if len(slice_df) >= 7 else slice_df["PM2.5"].mean()
550
+
551
+ # 6. Ratios & interactions
552
+ feats["PM25_PM10_product"] = row["PM2.5"] * row["PM10"]
553
+ feats["NO2_NO_ratio"] = row["NO2"] / max(0.1, row["NO"])
554
+ feats["PM25_PM10_ratio"] = row["PM2.5"] / max(1.0, row["PM10"])
555
+
556
+ # 7. City Encoded (Delhi average baseline)
557
+ feats["City_encoded"] = 250.0
558
+
559
+ ordered_cols = [
560
+ 'PM2.5', 'PM10', 'NO', 'NO2', 'NOx', 'NH3', 'CO', 'SO2', 'O3', 'Benzene', 'Toluene', 'Xylene',
561
+ 'Month', 'DayOfWeek', 'Year', 'Day', 'DayOfYear', 'Quarter', 'IsWeekend', 'Month_sin', 'Month_cos',
562
+ 'DOW_sin', 'DOW_cos', 'AQI_lag1', 'AQI_lag3', 'AQI_lag7', 'PM25_lag1', 'PM10_lag1',
563
+ 'AQI_roll_mean_7', 'AQI_roll_std_7', 'AQI_roll_mean_14', 'AQI_roll_std_14', 'AQI_roll_mean_30',
564
+ 'PM25_roll_mean_7', 'PM25_PM10_product', 'NO2_NO_ratio', 'PM25_PM10_ratio', 'City_encoded'
565
+ ]
566
+ return pd.DataFrame([feats])[ordered_cols]
567
+
568
+
569
+ async def _forecast_xgboost(
570
+ series: np.ndarray,
571
+ horizon: int,
572
+ lat: float,
573
+ lon: float,
574
+ ) -> tuple[Optional[np.ndarray], Optional[np.ndarray]]:
575
+ """Generate forecast using the tuned XGBoost model with recursive autoregressive scaling."""
576
+ import httpx
577
+ # Fetch 144h context + forecast horizon of full pollutants history
578
+ total_hours = 144 + horizon
579
+ records = await _fetch_full_history_covariates(lat, lon, hours=total_hours)
580
+
581
+ if not records or len(records) < 144:
582
+ logger.warning("Could not fetch historical telemetry for XGBoost.")
583
+ return None, None
584
+
585
+ df = pd.DataFrame(records)
586
+ predictions = []
587
+
588
+ # Context window initialization
589
+ rolling_df = df.iloc[:144].copy()
590
+
591
+ for step in range(horizon):
592
+ if 144 + step < len(df):
593
+ next_row = df.iloc[144 + step].copy()
594
+ else:
595
+ prev_row = rolling_df.iloc[-1].copy()
596
+ next_dt = prev_row["timestamp"] + timedelta(hours=1)
597
+ next_row = prev_row.copy()
598
+ next_row["timestamp"] = next_dt
599
+
600
+ rolling_df = pd.concat([rolling_df, pd.DataFrame([next_row])], ignore_index=True)
601
+
602
+ target_idx = len(rolling_df) - 1
603
+ X_df = _engineer_xgboost_features(rolling_df, target_idx)
604
+
605
+ X_scaled = _scaler.transform(X_df)
606
+ pred_val = _xgboost_model.predict(X_scaled)[0]
607
+ pred_val = float(np.clip(pred_val, 15, 500))
608
+
609
+ predictions.append(pred_val)
610
+ rolling_df.at[target_idx, "AQI"] = pred_val
611
+
612
+ # Construct confidence quantiles (P10, P50, P90)
613
+ noise_std = np.std(series[-24:]) if len(series) >= 24 else 20
614
+ quantiles = np.zeros((horizon, 3))
615
+ for i in range(horizon):
616
+ spread = noise_std * np.sqrt(i + 1) * 0.25
617
+ quantiles[i, 0] = predictions[i] - spread
618
+ quantiles[i, 1] = predictions[i]
619
+ quantiles[i, 2] = predictions[i] + spread
620
+
621
+ return np.array(predictions), quantiles
services/vision/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """"""
services/vision/service.py ADDED
@@ -0,0 +1,341 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Vision Service — Florence-2 (scene understanding) + Grounding DINO (zero-shot detection).
3
+
4
+ Two-model pipeline:
5
+ 1. Florence-2: Generates scene captions & open-vocabulary detection
6
+ 2. Grounding DINO: Zero-shot detection with pollution-specific text prompts
7
+ """
8
+
9
+ import logging
10
+ from typing import Optional
11
+
12
+ import torch
13
+ from PIL import Image
14
+
15
+ from config import get_device, get_torch_dtype, MODELS, VISION_CONFIG
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ # Global model references
20
+ _florence_model = None
21
+ _florence_processor = None
22
+ _dino_model = None
23
+ _dino_processor = None
24
+
25
+
26
+ def load_models() -> bool:
27
+ """Load Florence-2 and Grounding DINO models."""
28
+ success = True
29
+ success = _load_florence() and success
30
+ success = _load_grounding_dino() and success
31
+ return success
32
+
33
+
34
+ def _load_florence() -> bool:
35
+ """Load Florence-2-base model."""
36
+ global _florence_model, _florence_processor
37
+ if _florence_model is not None:
38
+ return True
39
+
40
+ try:
41
+ from transformers import AutoProcessor, AutoModelForCausalLM, PreTrainedModel
42
+ logger.info("Loading Florence-2-base...")
43
+
44
+ # Apply runtime compatibility patch to PreTrainedModel base class
45
+ # to prevent '_supports_sdpa' or '_supports_flash_attn_2' attribute errors on dynamic HuggingFace models
46
+ PreTrainedModel._supports_sdpa = False
47
+ PreTrainedModel._supports_flash_attn_2 = False
48
+
49
+ # Apply runtime compatibility patch for Florence-2 configs on newer transformers versions
50
+ try:
51
+ from transformers.models.florence2.configuration_florence2 import Florence2LanguageConfig
52
+ Florence2LanguageConfig.forced_bos_token_id = None
53
+ except Exception:
54
+ pass
55
+
56
+ device = get_device()
57
+ dtype = get_torch_dtype()
58
+
59
+ # Try loading from local cache first (no downloads/internet check)
60
+ try:
61
+ _florence_processor = AutoProcessor.from_pretrained(
62
+ MODELS["florence2"],
63
+ trust_remote_code=True,
64
+ local_files_only=True
65
+ )
66
+ _florence_model = AutoModelForCausalLM.from_pretrained(
67
+ MODELS["florence2"],
68
+ torch_dtype=dtype,
69
+ trust_remote_code=True,
70
+ attn_implementation="eager", # Avoid SDPA entirely
71
+ local_files_only=True
72
+ ).to(device).eval()
73
+ logger.info(f"✅ Florence-2-base loaded successfully on {device} from local cache")
74
+ return True
75
+ except Exception as e_local:
76
+ logger.info(f"Local cache load deferred/failed, attempting default online load: {e_local}")
77
+ # Fallback to standard check if local files only fails
78
+ _florence_processor = AutoProcessor.from_pretrained(
79
+ MODELS["florence2"],
80
+ trust_remote_code=True
81
+ )
82
+ _florence_model = AutoModelForCausalLM.from_pretrained(
83
+ MODELS["florence2"],
84
+ torch_dtype=dtype,
85
+ trust_remote_code=True,
86
+ attn_implementation="eager",
87
+ ).to(device).eval()
88
+
89
+ # Post-load safety check
90
+ if not hasattr(_florence_model, "_supports_sdpa"):
91
+ _florence_model._supports_sdpa = False
92
+
93
+ logger.info(f"✅ Florence-2-base loaded successfully on {device}")
94
+ return True
95
+ except Exception as e:
96
+ logger.error(f"❌ Florence-2 loading failed: {e}")
97
+ return False
98
+
99
+
100
+ def _load_grounding_dino() -> bool:
101
+ """Load Grounding DINO tiny model."""
102
+ global _dino_model, _dino_processor
103
+ if _dino_model is not None:
104
+ return True
105
+
106
+ try:
107
+ from transformers import AutoProcessor, AutoModelForZeroShotObjectDetection
108
+ device = get_device()
109
+ logger.info(f"Loading Grounding DINO tiny on {device}...")
110
+ _dino_processor = AutoProcessor.from_pretrained(MODELS["grounding_dino"])
111
+ _dino_model = AutoModelForZeroShotObjectDetection.from_pretrained(
112
+ MODELS["grounding_dino"]
113
+ ).to(device).eval()
114
+ logger.info(f"✅ Grounding DINO loaded on {device}")
115
+ return True
116
+ except Exception as e:
117
+ logger.error(f"❌ Grounding DINO loading failed: {e}")
118
+ return False
119
+
120
+
121
+ async def detect_pollution_sources(image: Image.Image) -> dict:
122
+ """
123
+ Run the full vision pipeline on a satellite/aerial image.
124
+ Supports ZeroGPU dynamic allocation and CPU fallback.
125
+ """
126
+ image = image.convert("RGB")
127
+
128
+ # Load models on-demand if they are not already loaded
129
+ if _florence_model is None or _dino_model is None:
130
+ logger.info("Vision models not loaded yet. Loading on-demand...")
131
+ load_models()
132
+
133
+ # Step 1: Florence-2 scene understanding
134
+ scene_description = await _florence_caption(image)
135
+ florence_detections = await _florence_detect(image)
136
+
137
+ # Step 2: Grounding DINO zero-shot detection for pollution sources
138
+ dino_detections = await _grounding_dino_detect(image)
139
+
140
+ # Combine and categorize detections
141
+ all_detections = florence_detections + dino_detections
142
+ pollution_sources, source_count, severity = _categorize_detections(all_detections)
143
+
144
+ return {
145
+ "scene_description": scene_description,
146
+ "detections": all_detections,
147
+ "pollution_sources_found": pollution_sources,
148
+ "source_count": source_count,
149
+ "severity": severity,
150
+ "model": "florence-2-base + grounding-dino-tiny",
151
+ "device_used": get_device(),
152
+ }
153
+
154
+
155
+ async def _florence_caption(image: Image.Image) -> str:
156
+ """Generate scene description using Florence-2."""
157
+ if _florence_model is None or _florence_processor is None:
158
+ return "Vision model not loaded. Unable to describe scene."
159
+
160
+ try:
161
+ device = get_device()
162
+ _florence_model.to(device)
163
+ prompt = "<MORE_DETAILED_CAPTION>"
164
+ inputs = _florence_processor(text=prompt, images=image, return_tensors="pt").to(device)
165
+
166
+ with torch.no_grad():
167
+ generated_ids = _florence_model.generate(
168
+ input_ids=inputs["input_ids"],
169
+ pixel_values=inputs["pixel_values"],
170
+ max_new_tokens=VISION_CONFIG["florence2_max_tokens"],
171
+ num_beams=3,
172
+ )
173
+
174
+ generated_text = _florence_processor.batch_decode(generated_ids, skip_special_tokens=False)[0]
175
+ parsed = _florence_processor.post_process_generation(
176
+ generated_text, task=prompt, image_size=(image.width, image.height)
177
+ )
178
+ return parsed.get(prompt, "No description available.")
179
+ except Exception as e:
180
+ logger.error(f"Florence-2 caption failed: {e}")
181
+ return f"Caption generation failed: {str(e)}"
182
+
183
+
184
+ async def _florence_detect(image: Image.Image) -> list[dict]:
185
+ """Run Florence-2 open detection."""
186
+ if _florence_model is None or _florence_processor is None:
187
+ return []
188
+
189
+ try:
190
+ device = get_device()
191
+ _florence_model.to(device)
192
+ prompt = "<OD>"
193
+ inputs = _florence_processor(text=prompt, images=image, return_tensors="pt").to(device)
194
+
195
+ with torch.no_grad():
196
+ generated_ids = _florence_model.generate(
197
+ input_ids=inputs["input_ids"],
198
+ pixel_values=inputs["pixel_values"],
199
+ max_new_tokens=VISION_CONFIG["florence2_max_tokens"],
200
+ num_beams=3,
201
+ )
202
+
203
+ generated_text = _florence_processor.batch_decode(generated_ids, skip_special_tokens=False)[0]
204
+ parsed = _florence_processor.post_process_generation(
205
+ generated_text, task=prompt, image_size=(image.width, image.height)
206
+ )
207
+
208
+ detections = []
209
+ od_result = parsed.get(prompt, {})
210
+ bboxes = od_result.get("bboxes", [])
211
+ labels = od_result.get("labels", [])
212
+
213
+ for bbox, label in zip(bboxes, labels):
214
+ detections.append({
215
+ "label": label.lower(),
216
+ "confidence": 0.7, # Florence-2 doesn't return confidence
217
+ "bbox": {
218
+ "x_min": float(bbox[0]),
219
+ "y_min": float(bbox[1]),
220
+ "x_max": float(bbox[2]),
221
+ "y_max": float(bbox[3]),
222
+ },
223
+ "source": "florence2",
224
+ })
225
+
226
+ return detections
227
+ except Exception as e:
228
+ logger.error(f"Florence-2 detection failed: {e}")
229
+ return []
230
+
231
+
232
+ async def _grounding_dino_detect(image: Image.Image) -> list[dict]:
233
+ """Run Grounding DINO zero-shot detection with pollution prompts."""
234
+ if _dino_model is None or _dino_processor is None:
235
+ return []
236
+
237
+ try:
238
+ device = get_device()
239
+ _dino_model.to(device)
240
+ text_prompt = VISION_CONFIG["pollution_prompts"]
241
+
242
+ inputs = _dino_processor(
243
+ images=image, text=text_prompt, return_tensors="pt"
244
+ ).to(device)
245
+
246
+ with torch.no_grad():
247
+ outputs = _dino_model(**inputs)
248
+
249
+ results = _dino_processor.post_process_grounded_object_detection(
250
+ outputs,
251
+ inputs.input_ids,
252
+ threshold=VISION_CONFIG["grounding_dino_box_threshold"],
253
+ text_threshold=VISION_CONFIG["grounding_dino_text_threshold"],
254
+ target_sizes=[image.size[::-1]],
255
+ )
256
+
257
+ detections = []
258
+ if results:
259
+ result = results[0]
260
+ boxes = result.get("boxes", [])
261
+ scores = result.get("scores", [])
262
+ labels = result.get("labels", []) # Changed from "text_labels" to "labels"
263
+
264
+ for box, score, label in zip(boxes, scores, labels):
265
+ box = box.cpu().tolist() if hasattr(box, "cpu") else box
266
+ score_val = score.item() if hasattr(score, "item") else float(score)
267
+
268
+ detections.append({
269
+ "label": label.strip().lower(),
270
+ "confidence": round(score_val, 3),
271
+ "bbox": {
272
+ "x_min": float(box[0]),
273
+ "y_min": float(box[1]),
274
+ "x_max": float(box[2]),
275
+ "y_max": float(box[3]),
276
+ },
277
+ "source": "grounding_dino",
278
+ })
279
+
280
+ return detections
281
+ except Exception as e:
282
+ logger.error(f"Grounding DINO detection failed: {e}")
283
+ return []
284
+
285
+
286
+ def _categorize_detections(detections: list[dict]) -> tuple[list[str], dict[str, int], str]:
287
+ """
288
+ Categorize raw detections into pollution source categories.
289
+ Returns: (pollution_sources, source_count, severity)
290
+ """
291
+ # Mapping from detection labels to pollution categories
292
+ label_to_category = {
293
+ "smoke": "industrial_emission",
294
+ "fire": "burning",
295
+ "burning waste": "burning",
296
+ "open burning": "burning",
297
+ "construction site": "construction_dust",
298
+ "construction": "construction_dust",
299
+ "factory chimney": "industrial_emission",
300
+ "industrial plant": "industrial_emission",
301
+ "factory": "industrial_emission",
302
+ "dust cloud": "dust",
303
+ "dust": "dust",
304
+ "heavy vehicles": "traffic",
305
+ "truck": "traffic",
306
+ "vehicle": "traffic",
307
+ "car": "traffic",
308
+ "bus": "traffic",
309
+ "brick kiln": "industrial_emission",
310
+ }
311
+
312
+ source_count: dict[str, int] = {}
313
+ pollution_sources: set[str] = set()
314
+
315
+ for det in detections:
316
+ label = det["label"].lower()
317
+ for keyword, category in label_to_category.items():
318
+ if keyword in label:
319
+ pollution_sources.add(category)
320
+ source_count[category] = source_count.get(category, 0) + 1
321
+ break
322
+
323
+ # Determine severity
324
+ total_sources = sum(source_count.values())
325
+ if total_sources == 0:
326
+ severity = "low"
327
+ elif total_sources <= 3:
328
+ severity = "medium"
329
+ elif total_sources <= 8:
330
+ severity = "high"
331
+ else:
332
+ severity = "critical"
333
+
334
+ # Boost severity if fire/burning detected
335
+ if "burning" in pollution_sources or "industrial_emission" in pollution_sources:
336
+ if severity == "low":
337
+ severity = "medium"
338
+ elif severity == "medium":
339
+ severity = "high"
340
+
341
+ return sorted(list(pollution_sources)), source_count, severity