Spaces:
Sleeping
Sleeping
Commit ·
c817825
0
Parent(s):
Initial commit
Browse files- .env.example +31 -0
- .gitignore +42 -0
- Dockerfile +34 -0
- README.md +39 -0
- app.py +311 -0
- config.py +219 -0
- download_models.py +122 -0
- download_new_model.py +99 -0
- main.py +194 -0
- models/.gitkeep +2 -0
- requirements.txt +49 -0
- routers/__init__.py +1 -0
- routers/services.py +89 -0
- services/__init__.py +1 -0
- services/forecast/__init__.py +1 -0
- services/forecast/service.py +621 -0
- services/vision/__init__.py +1 -0
- services/vision/service.py +341 -0
.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
|