Kinzaaa commited on
Commit
3fd6082
Β·
0 Parent(s):

Initial deployment: Attention UNet + ZoeDepth + Grad-CAM + Risk Assessment

Browse files
.gitattributes ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ *.h5 filter=lfs diff=lfs merge=lfs -text
2
+ *.keras filter=lfs diff=lfs merge=lfs -text
3
+ *.pkl filter=lfs diff=lfs merge=lfs -text
README.md ADDED
@@ -0,0 +1,315 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Flood Risk Detection
3
+ emoji: 🌊
4
+ colorFrom: blue
5
+ colorTo: red
6
+ sdk: gradio
7
+ sdk_version: "6.14.0"
8
+ app_file: app.py
9
+ pinned: true
10
+ license: mit
11
+ short_description: Attention UNet flood segmentation + ZoeDepth + risk assessment
12
+ ---
13
+
14
+ # 🌊 Flood Detection & Depth-Aware Risk Assessment
15
+
16
+ An end-to-end deep learning pipeline for flood detection, explainability, depth estimation, and risk scoring from satellite/aerial imagery.
17
+
18
+ ---
19
+
20
+ ## πŸ—οΈ Project Structure
21
+
22
+ ```
23
+ β”œβ”€β”€ app.py ← Gradio UI (HF Spaces entry point)
24
+ β”œβ”€β”€ app/
25
+ β”‚ β”œβ”€β”€ __init__.py
26
+ β”‚ β”œβ”€β”€ main.py ← FastAPI REST backend
27
+ β”‚ └── model_utils.py ← Core engine (model, Grad-CAM, ZoeDepth, risk)
28
+ β”œβ”€β”€ models/
29
+ β”‚ β”œβ”€β”€ flood_best_model_export.keras ← Attention UNet weights
30
+ β”‚ └── flood_model_production.pkl ← Metadata (thresholds, metrics, layer names)
31
+ β”œβ”€β”€ requirements.txt
32
+ └── run_all.py ← Local launcher (FastAPI + Gradio together)
33
+ ```
34
+
35
+ ---
36
+
37
+ ## βš™οΈ Full Pipeline
38
+
39
+ ```
40
+ Input Image
41
+ β”‚
42
+ β–Ό
43
+ 1. Preprocessing
44
+ └─ Convert to RGB β†’ resize 512Γ—512 β†’ normalise [0,1]
45
+ β”‚
46
+ β–Ό
47
+ 2. Attention UNet (flood_best_model_export.keras)
48
+ └─ Predicts per-pixel flood probability map (512Γ—512)
49
+ └─ OOD detection: colour check + spatial coherence + coverage cap
50
+ └─ Threshold 0.5 β†’ binary mask (1=flooded, 0=dry)
51
+ β”‚
52
+ β–Ό
53
+ 3. Grad-CAM (layer: conv2d_130)
54
+ └─ GradientTape β†’ gradients w.r.t. last conv feature maps
55
+ └─ Weighted sum β†’ activation map β†’ JET colormap overlay
56
+ β”‚
57
+ β–Ό
58
+ 4. ZoeDepth (Intel/zoedepth-nyu, CPU)
59
+ └─ Monocular depth estimation β†’ relative depth map
60
+ └─ Normalise to [0,3m] β†’ stats within flooded pixels only
61
+ β”‚
62
+ β–Ό
63
+ 5. Risk Assessment
64
+ └─ flood_pct + avg_depth_m β†’ Low / Moderate / High / Critical
65
+ └─ Score = flood_pctΓ—0.7 + depth_scoreΓ—0.3
66
+ ```
67
+
68
+ ---
69
+
70
+ ## πŸš€ Deploy to Hugging Face Spaces
71
+
72
+ ### Step 1 β€” Clone your Space
73
+ ```bash
74
+ git clone https://huggingface.co/spaces/YOUR_USERNAME/flood-risk-detection
75
+ cd flood-risk-detection
76
+ ```
77
+
78
+ ### Step 2 β€” Set up Git LFS (for large model files)
79
+ ```bash
80
+ git lfs install
81
+ git lfs track "*.keras" "*.pkl"
82
+ git add .gitattributes
83
+ ```
84
+
85
+ ### Step 3 β€” Copy project files
86
+ Copy these into the cloned folder:
87
+ ```
88
+ app.py
89
+ app/__init__.py
90
+ app/model_utils.py
91
+ models/flood_best_model_export.keras
92
+ models/flood_model_production.pkl
93
+ requirements.txt
94
+ README.md
95
+ ```
96
+
97
+ ### Step 4 β€” Add HF_TOKEN as a Secret
98
+ In your Space β†’ **Settings β†’ Variables and Secrets β†’ New Secret**:
99
+ ```
100
+ Name: HF_TOKEN
101
+ Value: your_huggingface_token
102
+ ```
103
+ This is used by ZoeDepth to download model weights from the Hub.
104
+
105
+ ### Step 5 β€” Push
106
+ ```bash
107
+ git add .
108
+ git commit -m "Deploy flood detection app"
109
+ git push
110
+ ```
111
+ HF Spaces will automatically install `requirements.txt` and launch `app.py`.
112
+
113
+ > **Note:** First inference is slow (~60s) β€” ZoeDepth downloads ~1.4GB weights on first run, then caches them.
114
+
115
+ ---
116
+
117
+ ## πŸ’» Run Locally
118
+
119
+ ```bash
120
+ # Install dependencies
121
+ pip install -r requirements.txt
122
+
123
+ # Set environment variables
124
+ set HF_TOKEN=your_token # Windows CMD
125
+ $env:HF_TOKEN="your_token" # PowerShell
126
+
127
+ # Start both servers
128
+ python run_all.py
129
+
130
+ # OR individually:
131
+ python app.py # Gradio β†’ http://localhost:7860
132
+ uvicorn app.main:app --reload --port 8000 # FastAPI β†’ http://localhost:8000/docs
133
+ ```
134
+
135
+ ---
136
+
137
+ ## πŸ“‘ API Reference
138
+
139
+ ### `POST /predict`
140
+ ```bash
141
+ curl -X POST http://localhost:8000/predict \
142
+ -F "file=@flood_image.jpg"
143
+ ```
144
+ Response:
145
+ ```json
146
+ {
147
+ "mask_b64": "<base64 PNG β€” binary flood mask>",
148
+ "overlay_b64": "<base64 PNG β€” blue flood overlay>",
149
+ "gradcam_b64": "<base64 PNG β€” JET heatmap>",
150
+ "depth_map_b64": "<base64 PNG β€” PLASMA depth map>",
151
+ "depth_flood_b64": "<base64 PNG β€” depth in flood region>",
152
+ "depth_info": {
153
+ "avg_depth_m": 0.82,
154
+ "max_depth_m": 1.45,
155
+ "depth_category": "Moderate (30 cm – 80 cm)"
156
+ },
157
+ "risk": {
158
+ "risk_level": "High",
159
+ "risk_score": 43.5,
160
+ "flood_pct": 38.4,
161
+ "avg_depth_m": 0.82,
162
+ "recommendations": ["..."]
163
+ }
164
+ }
165
+ ```
166
+
167
+ ### `GET /health`
168
+ ```json
169
+ {"status": "ok"}
170
+ ```
171
+
172
+ ---
173
+
174
+ ## πŸ“Š Model Performance
175
+
176
+ | Model | Pixel Accuracy | IoU (Jaccard) | Dice / F1 | Precision | Recall |
177
+ |-------|---------------|---------------|-----------|-----------|--------|
178
+ | UNet (baseline) | 82.55% | 63.94% | 78.00% | 78.75% | 77.26% |
179
+ | **Attention UNet (deployed)** | **89.36%** | **76.91%** | **86.95%** | **85.41%** | **88.54%** |
180
+
181
+ ---
182
+
183
+ ## πŸŽ“ Viva Questions & Answers
184
+
185
+ ### Architecture
186
+
187
+ **Q: Why did you choose UNet for flood segmentation?**
188
+ UNet is a fully convolutional encoder-decoder architecture with skip connections. The skip connections preserve spatial detail lost during downsampling β€” critical for pixel-accurate segmentation. The encoder captures semantic context (what is flooded) while the decoder reconstructs spatial resolution (where exactly).
189
+
190
+ **Q: What is the difference between UNet and Attention UNet?**
191
+ Attention UNet adds attention gates at each skip connection. These gates learn to suppress feature responses in irrelevant regions (dry land, buildings) and amplify responses in relevant regions (water, flooded areas). This is done by computing a soft attention coefficient per spatial location using the decoder signal as a query. Result: IoU improved from 63.94% to 76.91%.
192
+
193
+ **Q: How does the attention gate work mathematically?**
194
+ Given encoder feature `x` and decoder signal `g`:
195
+ ```
196
+ attention = sigmoid(W_x(x) + W_g(g) + b)
197
+ output = x * attention
198
+ ```
199
+ The sigmoid produces values 0–1 per pixel. Multiplying by the encoder feature selectively passes only relevant spatial information to the decoder.
200
+
201
+ **Q: Why 512Γ—512 input size?**
202
+ Balance between spatial resolution and memory. UNet memory scales quadratically with input size due to skip connections storing intermediate feature maps. 512Γ—512 gives sufficient detail for flood boundary detection while fitting in GPU/CPU memory.
203
+
204
+ **Q: What loss function did you use and why?**
205
+ Combined BCE + Dice loss:
206
+ - **Binary Cross-Entropy (BCE)**: pixel-level classification loss, handles each pixel independently
207
+ - **Dice Loss**: `1 - (2Γ—intersection)/(sum+sum)` β€” directly optimises the overlap metric, handles class imbalance (flooded pixels are often a minority in the image)
208
+ - Combined: `L = BCE + Dice` β€” BCE provides stable gradients early in training, Dice refines the boundary
209
+
210
+ **Q: What is IoU and how is it calculated?**
211
+ Intersection over Union (Jaccard Index):
212
+ ```
213
+ IoU = |Predicted ∩ Ground Truth| / |Predicted βˆͺ Ground Truth|
214
+ ```
215
+ Ranges 0–1. Measures overlap between predicted and actual flood regions. More robust than accuracy for imbalanced segmentation tasks.
216
+
217
+ ---
218
+
219
+ ### Explainability
220
+
221
+ **Q: What is Grad-CAM and why did you use it?**
222
+ Gradient-weighted Class Activation Mapping. It computes the gradient of the output with respect to the feature maps of a target convolutional layer, then takes a weighted sum of those feature maps. The result is a spatial heatmap showing which image regions most influenced the prediction.
223
+
224
+ Used because: (1) native to CNNs, (2) computationally cheap β€” one forward + one backward pass, (3) produces spatial maps that directly overlay on the image, (4) no model modification needed.
225
+
226
+ **Q: Which layer did you target for Grad-CAM and why?**
227
+ `conv2d_130` β€” the last convolutional layer before the output head. This layer has the best trade-off: it has learned high-level semantic features (water texture, flood patterns) while still retaining spatial resolution sufficient for meaningful visualisation.
228
+
229
+ **Q: Why JET colormap for Grad-CAM?**
230
+ JET (blue→cyan→green→yellow→red) is intuitive: red = high attention (model is confident about flooding here), blue = low attention. It matches the mental model of "heat" = importance.
231
+
232
+ ---
233
+
234
+ ### Depth Estimation
235
+
236
+ **Q: What is ZoeDepth and how does it work?**
237
+ ZoeDepth (Zero-shot Depth Estimation) is a monocular depth estimation model from Intel. It uses a DPT (Dense Prediction Transformer) backbone pretrained on large datasets, then fine-tuned for metric depth. It takes a single RGB image and outputs a per-pixel depth map in metres β€” no stereo cameras or LiDAR needed.
238
+
239
+ **Q: Why use ZoeDepth instead of a simpler depth estimate?**
240
+ Rule-based depth estimates (e.g., "flood coverage Γ— constant") are not spatially aware. ZoeDepth provides actual per-pixel depth variation β€” a flood scene may have shallow water at the edges and deep water in the centre. This spatial depth information makes the risk assessment more accurate.
241
+
242
+ **Q: ZoeDepth outputs relative depth β€” how do you convert to metres?**
243
+ ZoeDepth's `Intel/zoedepth-nyu` model outputs relative disparity values. We normalise to [0,1] then map to [0, 3m] β€” a realistic maximum flood depth for the scenarios in our training data. The depth is only computed within flooded pixels (masked by the UNet output).
244
+
245
+ ---
246
+
247
+ ### Risk Assessment
248
+
249
+ **Q: How is the risk level determined?**
250
+ Two signals combined:
251
+ 1. **Flood coverage %** β€” what fraction of the image is flooded
252
+ 2. **Average depth (m)** β€” mean ZoeDepth value within flooded pixels
253
+
254
+ ```
255
+ level = max(level_from_pct(flood_pct), level_from_depth(avg_depth_m))
256
+ score = flood_pct Γ— 0.7 + depth_score Γ— 0.3
257
+ ```
258
+
259
+ Thresholds: Low (<15%, <0.3m) β†’ Moderate (15–35%, 0.3–0.8m) β†’ High (35–60%, 0.8–1.5m) β†’ Critical (>60%, >1.5m)
260
+
261
+ **Q: Why does depth only contribute when flood_pct β‰₯ 2%?**
262
+ Below 2% coverage, the flooded pixels are likely noise or false positives. Using depth on noise pixels would produce meaningless depth values that could incorrectly escalate the risk level.
263
+
264
+ ---
265
+
266
+ ### OOD Detection
267
+
268
+ **Q: What is out-of-distribution (OOD) detection and why is it needed?**
269
+ The model was trained only on flood/water imagery. When given a completely different image (car, building, portrait), the model produces garbage predictions β€” it has never seen these inputs during training. OOD detection identifies such inputs and suppresses the results rather than showing false flood detections.
270
+
271
+ **Q: How does your OOD detection work?**
272
+ Four signals:
273
+ 1. **Brightness gate** β€” rejects pitch-black (<15) or overexposed (>240) images
274
+ 2. **Spatial coherence** β€” real flood masks are large connected blobs; OOD produces >50 fragmented disconnected regions with the largest blob <25% of total flood pixels
275
+ 3. **Coverage cap** β€” >90% flood coverage is physically implausible
276
+ 4. **Colour-coverage cross-check** β€” if flood coverage >40% but <15% of pixels have water-like colours (blue hue 100–130Β°, grey low-saturation, brown 8–22Β°) β†’ OOD
277
+
278
+ **Q: Why not use a separate classifier for OOD?**
279
+ A separate classifier would require additional training data and labels. Our heuristic approach is training-free, interpretable, and works well for the specific failure modes of this model (non-water textures being misclassified as flood).
280
+
281
+ ---
282
+
283
+ ### Deployment
284
+
285
+ **Q: Why FastAPI for the backend?**
286
+ FastAPI is async, type-safe (Pydantic models), auto-generates OpenAPI docs, and has excellent performance. The Pydantic response models also prevent schema issues with Gradio's API introspection.
287
+
288
+ **Q: Why Gradio for the frontend?**
289
+ Gradio is purpose-built for ML demos, integrates directly with Python functions (no JavaScript needed), and deploys natively to Hugging Face Spaces with zero configuration.
290
+
291
+ **Q: What is the PKL file and what does it store?**
292
+ The `.pkl` file stores metadata only β€” not the model weights. Contents: threshold (0.5), Grad-CAM target layer name (conv2d_130), risk thresholds, model performance metrics, and custom object keys. The actual weights are in the `.keras` file.
293
+
294
+ **Q: How would you scale this to production?**
295
+ - Replace CPU inference with GPU (CUDA) for 10–50Γ— speedup
296
+ - Add a message queue (Redis/Celery) for async processing of multiple requests
297
+ - Cache ZoeDepth results for similar images
298
+ - Add authentication to the API
299
+ - Deploy on Kubernetes with horizontal scaling
300
+ - Add monitoring (Prometheus/Grafana) for inference latency and error rates
301
+
302
+ ---
303
+
304
+ ## πŸ“¦ Dependencies
305
+
306
+ | Package | Purpose |
307
+ |---------|---------|
308
+ | `tensorflow-cpu` | Attention UNet inference |
309
+ | `torch` | ZoeDepth (PyTorch model) |
310
+ | `transformers` | ZoeDepth pipeline from HuggingFace |
311
+ | `opencv-python-headless` | Image processing, Grad-CAM, colormaps |
312
+ | `gradio` | Frontend UI |
313
+ | `fastapi` + `uvicorn` | REST API backend |
314
+ | `Pillow` | Image loading/conversion |
315
+ | `numpy` | Array operations |
app.py ADDED
@@ -0,0 +1,364 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Gradio frontend β€” Flood Detection + ZoeDepth depth estimation.
3
+ Improved UI: dark theme, styled cards, progress steps, metric badges.
4
+ """
5
+ import gradio as gr
6
+ import numpy as np
7
+ from PIL import Image
8
+ import sys, os
9
+
10
+ sys.path.insert(0, os.path.dirname(__file__))
11
+ from app.model_utils import run_pipeline
12
+
13
+ # ── Risk config ────────────────────────────────────────────────────────────────
14
+ RISK_CFG = {
15
+ "Low": {"colour": "#27ae60", "bg": "#eafaf1", "icon": "🟒", "bar": 20},
16
+ "Moderate": {"colour": "#f39c12", "bg": "#fef9e7", "icon": "🟑", "bar": 50},
17
+ "High": {"colour": "#e67e22", "bg": "#fdf2e9", "icon": "🟠", "bar": 75},
18
+ "Critical": {"colour": "#e74c3c", "bg": "#fdedec", "icon": "πŸ”΄", "bar": 100},
19
+ }
20
+
21
+ CSS = """
22
+ /* ── page ── */
23
+ body, .gradio-container { background: #0f1117 !important; color: #e8eaf0 !important; }
24
+
25
+ /* ── header band ── */
26
+ #header-band {
27
+ background: linear-gradient(135deg, #0d47a1 0%, #1565c0 40%, #0277bd 100%);
28
+ border-radius: 16px; padding: 28px 32px 20px; margin-bottom: 20px;
29
+ box-shadow: 0 4px 24px rgba(0,0,0,0.5);
30
+ }
31
+ #header-band h1 { color: #fff; font-size: 2em; margin: 0 0 6px; }
32
+ #header-band p { color: #bbdefb; margin: 0; font-size: 0.97em; }
33
+
34
+ /* ── metric pill row ── */
35
+ .metric-pill {
36
+ display: inline-block; background: rgba(255,255,255,0.08);
37
+ border: 1px solid rgba(255,255,255,0.18); border-radius: 20px;
38
+ padding: 4px 14px; margin: 4px; font-size: 0.88em; color: #e3f2fd;
39
+ }
40
+ .metric-pill strong { color: #64b5f6; }
41
+
42
+ /* ── upload + button panel ── */
43
+ #left-panel { background: #1a1d27; border-radius: 14px; padding: 16px; }
44
+
45
+ /* ── analyse button ── */
46
+ #analyse-btn {
47
+ background: linear-gradient(90deg, #1565c0, #0277bd) !important;
48
+ color: #fff !important; border: none !important;
49
+ border-radius: 10px !important; font-size: 1.05em !important;
50
+ padding: 12px !important; margin-top: 10px !important;
51
+ box-shadow: 0 3px 12px rgba(2,119,189,0.45) !important;
52
+ transition: transform .15s, box-shadow .15s !important;
53
+ }
54
+ #analyse-btn:hover {
55
+ transform: translateY(-2px) !important;
56
+ box-shadow: 0 6px 20px rgba(2,119,189,0.6) !important;
57
+ }
58
+
59
+ /* ── section labels ── */
60
+ .section-label {
61
+ font-size: 0.78em; font-weight: 700; letter-spacing: .08em;
62
+ text-transform: uppercase; color: #64b5f6; margin: 18px 0 6px;
63
+ }
64
+
65
+ /* ── image cards ── */
66
+ .image-card { background: #1a1d27 !important; border-radius: 12px !important;
67
+ border: 1px solid #2a2d3a !important; overflow: hidden; }
68
+
69
+ /* ── risk card ── */
70
+ #risk-card { border-radius: 14px; overflow: hidden; }
71
+
72
+ /* ── how-it-works table ── */
73
+ .how-table { width:100%; border-collapse:collapse; font-size:0.9em; }
74
+ .how-table th { background:#1565c0; color:#fff; padding:8px 12px; text-align:left; }
75
+ .how-table td { padding:8px 12px; border-bottom:1px solid #2a2d3a; color:#cfd8dc; }
76
+ .how-table tr:last-child td { border-bottom: none; }
77
+ """
78
+
79
+ # ── helpers ───────────────────────────────────────────────────────────────────
80
+ def _score_bar(score: float, colour: str) -> str:
81
+ """Animated CSS progress bar."""
82
+ return f"""
83
+ <div style="background:#2a2d3a; border-radius:8px; height:10px; margin:8px 0 14px; overflow:hidden;">
84
+ <div style="width:{score}%; background:linear-gradient(90deg,{colour}99,{colour});
85
+ height:100%; border-radius:8px;
86
+ transition: width 1s ease;"></div>
87
+ </div>"""
88
+
89
+ def _stat_row(icon, label, value, colour) -> str:
90
+ return f"""
91
+ <div style="display:flex; justify-content:space-between; align-items:center;
92
+ padding:7px 0; border-bottom:1px solid rgba(255,255,255,0.07);">
93
+ <span style="color:#90a4ae;">{icon} {label}</span>
94
+ <span style="font-weight:700; color:{colour};">{value}</span>
95
+ </div>"""
96
+
97
+ def build_risk_html(risk: dict, depth_info: dict) -> str:
98
+ level = risk["risk_level"]
99
+ cfg = RISK_CFG.get(level, RISK_CFG["Low"])
100
+ c = cfg["colour"]
101
+ score = risk["risk_score"]
102
+ conf = risk.get("confidence", 100)
103
+ warning = risk.get("warning", "")
104
+
105
+ # ── Out-of-domain warning banner ──────────────────────────────────────────
106
+ warning_html = ""
107
+ if warning:
108
+ warning_html = f"""
109
+ <div style="background:#7f1d1d; border:1px solid #ef4444; border-radius:8px;
110
+ padding:10px 14px; margin-bottom:14px; font-size:0.9em; color:#fca5a5;">
111
+ {warning}
112
+ </div>"""
113
+
114
+ recs_html = "".join(
115
+ f'<li style="margin:5px 0; color:#cfd8dc;">{r}</li>'
116
+ for r in risk["recommendations"]
117
+ )
118
+
119
+ metrics = risk.get("model_metrics", {})
120
+ metrics_html = ""
121
+ if metrics:
122
+ pills = "".join(
123
+ f'<span class="metric-pill">{k}: <strong>{v}</strong></span>'
124
+ for k, v in metrics.items() if k != "Model"
125
+ )
126
+ metrics_html = f"""
127
+ <div style="margin-top:14px; padding-top:12px;
128
+ border-top:1px solid rgba(255,255,255,0.1);">
129
+ <div style="font-size:0.78em; text-transform:uppercase; letter-spacing:.08em;
130
+ color:#64b5f6; margin-bottom:6px;">πŸ“Š Model Performance</div>
131
+ {pills}
132
+ </div>"""
133
+
134
+ return f"""
135
+ <div id="risk-card" style="background:linear-gradient(160deg,#1a1d27 60%,{c}18);
136
+ border:2px solid {c}; border-radius:14px; padding:20px; font-family:sans-serif;">
137
+
138
+ {warning_html}
139
+
140
+ <!-- header -->
141
+ <div style="display:flex; align-items:center; gap:12px; margin-bottom:4px;">
142
+ <span style="font-size:2.2em;">{cfg['icon']}</span>
143
+ <div>
144
+ <div style="font-size:0.75em; text-transform:uppercase; letter-spacing:.1em;
145
+ color:{c}; font-weight:700;">Risk Level</div>
146
+ <div style="font-size:1.9em; font-weight:800; color:{c}; line-height:1.1;">
147
+ {level}
148
+ </div>
149
+ </div>
150
+ <div style="margin-left:auto; text-align:right;">
151
+ <div style="font-size:2.4em; font-weight:900; color:{c};">{score}</div>
152
+ <div style="font-size:0.75em; color:#78909c;">/ 100</div>
153
+ </div>
154
+ </div>
155
+
156
+ {_score_bar(score, c)}
157
+
158
+ <!-- confidence row -->
159
+ <div style="display:flex; justify-content:space-between; align-items:center;
160
+ padding:5px 0 10px; border-bottom:1px solid rgba(255,255,255,0.07);">
161
+ <span style="color:#90a4ae; font-size:0.88em;">🎯 Model Confidence</span>
162
+ <span style="font-weight:700; color:{'#27ae60' if conf >= 50 else '#e74c3c'};">
163
+ {conf}%
164
+ </span>
165
+ </div>
166
+
167
+ <!-- stats grid -->
168
+ {_stat_row("🌊", "Flood Coverage", f"{risk['flood_pct']}%", c)}
169
+ {_stat_row("πŸ“", "Avg Flood Depth", f"{risk['avg_depth_m']} m", c)}
170
+ {_stat_row("πŸ“", "Max Flood Depth", f"{depth_info['max_depth_m']} m", c)}
171
+ {_stat_row("πŸ”΅", "Depth Category", depth_info['depth_category'], c)}
172
+
173
+ <!-- recommendations -->
174
+ <div style="margin-top:14px; padding-top:12px;
175
+ border-top:1px solid rgba(255,255,255,0.1);">
176
+ <div style="font-size:0.78em; text-transform:uppercase; letter-spacing:.08em;
177
+ color:#64b5f6; margin-bottom:8px;">πŸ“‹ Recommendations</div>
178
+ <ul style="margin:0; padding-left:18px; line-height:1.7;">
179
+ {recs_html}
180
+ </ul>
181
+ </div>
182
+
183
+ {metrics_html}
184
+ </div>"""
185
+
186
+
187
+ # ── inference ─────────────────────────────────────────────────────────────────
188
+ def predict(image: Image.Image):
189
+ if image is None:
190
+ placeholder = """
191
+ <div style="background:#1a1d27; border:2px dashed #2a2d3a; border-radius:14px;
192
+ padding:40px; text-align:center; color:#546e7a; font-family:sans-serif;">
193
+ <div style="font-size:2.5em; margin-bottom:10px;">🌊</div>
194
+ <div style="font-size:1.1em;">Upload an image and click <strong>Analyse</strong></div>
195
+ <div style="font-size:0.85em; margin-top:6px; color:#37474f;">
196
+ Supports satellite, aerial, or ground-level flood images
197
+ </div>
198
+ </div>"""
199
+ return None, None, None, None, None, placeholder
200
+
201
+ result = run_pipeline(image)
202
+ risk = result["risk"]
203
+ depth_info = result["depth_info"]
204
+
205
+ overlay = Image.fromarray(result["overlay"])
206
+ gradcam = Image.fromarray(result["gradcam"])
207
+ depth_map = Image.fromarray(result["depth_map"])
208
+ depth_flood = Image.fromarray(result["depth_overlay"])
209
+
210
+ # B&W mask: white = flooded, black = dry β€” clean and crisp
211
+ mask_bw = Image.fromarray((result["mask"] * 255).astype(np.uint8), mode="L")
212
+
213
+ risk_html = build_risk_html(risk, depth_info)
214
+ return overlay, mask_bw, gradcam, depth_map, depth_flood, risk_html
215
+
216
+
217
+ # ── UI layout ─────────────────────────────────────────────────────────────────
218
+ with gr.Blocks(title="🌊 Flood Detection AI", theme=gr.themes.Soft()) as demo:
219
+
220
+ # ── Header ──────────────────────────────────────────────────────────────
221
+ gr.HTML("""
222
+ <div id="header-band">
223
+ <h1>🌊 Flood Detection &amp; Risk Assessment</h1>
224
+ <p>
225
+ Attention UNet segmentation &nbsp;Β·&nbsp; Grad-CAM explainability
226
+ &nbsp;Β·&nbsp; ZoeDepth metric depth &nbsp;Β·&nbsp; AI-powered risk scoring
227
+ </p>
228
+ <div style="margin-top:12px;">
229
+ <span class="metric-pill">IoU <strong>76.91%</strong></span>
230
+ <span class="metric-pill">Dice/F1 <strong>86.95%</strong></span>
231
+ <span class="metric-pill">Pixel Acc <strong>89.36%</strong></span>
232
+ <span class="metric-pill">Precision <strong>85.41%</strong></span>
233
+ <span class="metric-pill">Recall <strong>88.54%</strong></span>
234
+ </div>
235
+ </div>
236
+ """)
237
+
238
+ # ── Main row: upload + risk card ────────────────────────────────────────
239
+ with gr.Row(equal_height=False):
240
+
241
+ with gr.Column(scale=4, elem_id="left-panel"):
242
+ gr.HTML('<div class="section-label">πŸ“· Input Image</div>')
243
+ input_image = gr.Image(
244
+ type="pil",
245
+ label="",
246
+ elem_classes=["image-card"],
247
+ show_label=False,
248
+ )
249
+ run_btn = gr.Button(
250
+ "πŸ” Analyse Flood Risk",
251
+ variant="primary",
252
+ elem_id="analyse-btn",
253
+ )
254
+ gr.HTML("""
255
+ <div style="margin-top:12px; padding:10px 14px; background:#12151f;
256
+ border-radius:10px; border-left:3px solid #1565c0;">
257
+ <div style="font-size:0.78em; color:#64b5f6; font-weight:700;
258
+ text-transform:uppercase; letter-spacing:.07em; margin-bottom:6px;">
259
+ Pipeline Steps
260
+ </div>
261
+ <div style="font-size:0.85em; color:#90a4ae; line-height:2;">
262
+ 1️⃣ &nbsp;Preprocess β†’ 512Γ—512 RGB, normalise<br>
263
+ 2️⃣ &nbsp;Attention UNet β†’ binary flood mask<br>
264
+ 3️⃣ &nbsp;Grad-CAM β†’ attention heatmap<br>
265
+ 4️⃣ &nbsp;ZoeDepth β†’ per-pixel depth (metres)<br>
266
+ 5️⃣ &nbsp;Risk engine β†’ level + score + advice
267
+ </div>
268
+ </div>
269
+ """)
270
+
271
+ with gr.Column(scale=6):
272
+ gr.HTML('<div class="section-label">⚠️ Risk Assessment</div>')
273
+ risk_display = gr.HTML(
274
+ value="""
275
+ <div style="background:#1a1d27; border:2px dashed #2a2d3a;
276
+ border-radius:14px; padding:40px; text-align:center;
277
+ color:#546e7a; font-family:sans-serif;">
278
+ <div style="font-size:2.5em; margin-bottom:10px;">🌊</div>
279
+ <div style="font-size:1.1em;">
280
+ Upload an image and click <strong>Analyse</strong>
281
+ </div>
282
+ </div>"""
283
+ )
284
+
285
+ # ── Visual outputs ───────────────────────────────────────────────────────
286
+ gr.HTML('<div class="section-label">πŸ—ΊοΈ Segmentation &amp; Explainability</div>')
287
+ with gr.Row():
288
+ overlay_out = gr.Image(
289
+ label="πŸ”΅ Flood Mask Overlay",
290
+ elem_classes=["image-card"],
291
+ )
292
+ mask_bw_out = gr.Image(
293
+ label="⬜ Binary Flood Mask (White = Flooded)",
294
+ elem_classes=["image-card"],
295
+ image_mode="L",
296
+ )
297
+ gradcam_out = gr.Image(
298
+ label="πŸ”₯ Grad-CAM (TURBO)",
299
+ elem_classes=["image-card"],
300
+ )
301
+
302
+ gr.HTML('<div class="section-label">πŸ“ ZoeDepth Estimation</div>')
303
+ with gr.Row():
304
+ depth_map_out = gr.Image(
305
+ label="🌈 Full Depth Map (PLASMA)",
306
+ elem_classes=["image-card"],
307
+ )
308
+ depth_flood_out = gr.Image(
309
+ label="🌊 Flood-Region Depth",
310
+ elem_classes=["image-card"],
311
+ )
312
+
313
+ # ── How it works ─────────────────────────────────────────────────────────
314
+ with gr.Accordion("βš™οΈ How it works", open=False):
315
+ gr.HTML("""
316
+ <table class="how-table">
317
+ <tr>
318
+ <th>Step</th><th>Component</th><th>What it does</th>
319
+ </tr>
320
+ <tr>
321
+ <td>1</td>
322
+ <td><strong>Preprocessing</strong></td>
323
+ <td>Resize to 512Γ—512, convert to RGB, normalise to [0,1]</td>
324
+ </tr>
325
+ <tr>
326
+ <td>2</td>
327
+ <td><strong>Attention UNet</strong></td>
328
+ <td>Predicts binary flood mask β€” attention gates suppress irrelevant features</td>
329
+ </tr>
330
+ <tr>
331
+ <td>3</td>
332
+ <td><strong>Grad-CAM</strong></td>
333
+ <td>Gradient-weighted class activation map at conv2d_130 β€” TURBO colormap with contour</td>
334
+ </tr>
335
+ <tr>
336
+ <td>4</td>
337
+ <td><strong>ZoeDepth (Intel/zoedepth-nyu)</strong></td>
338
+ <td>Monocular metric depth estimation β€” outputs depth in metres per pixel</td>
339
+ </tr>
340
+ <tr>
341
+ <td>5</td>
342
+ <td><strong>Risk Engine</strong></td>
343
+ <td>Combines flood coverage % + avg depth β†’ Low / Moderate / High / Critical</td>
344
+ </tr>
345
+ </table>
346
+ <div style="margin-top:14px; padding:12px 16px; background:#12151f;
347
+ border-radius:10px; font-size:0.88em; color:#90a4ae;">
348
+ <strong style="color:#64b5f6;">Risk thresholds:</strong>
349
+ &nbsp; 🟒 Low (&lt;15% flood, &lt;0.8m)
350
+ &nbsp; 🟑 Moderate (15–35%, 0.8–1.5m)
351
+ &nbsp; 🟠 High (35–60%, 1.5–2.2m)
352
+ &nbsp; πŸ”΄ Critical (&gt;60% or &gt;2.2m)
353
+ </div>
354
+ """)
355
+
356
+ # ── Wire up ───────────────────────────────────────────────────────────────
357
+ run_btn.click(
358
+ fn=predict,
359
+ inputs=[input_image],
360
+ outputs=[overlay_out, mask_bw_out, gradcam_out, depth_map_out, depth_flood_out, risk_display],
361
+ )
362
+
363
+ if __name__ == "__main__":
364
+ demo.launch(server_name="0.0.0.0", server_port=7860, css=CSS)
app/__init__.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ """
2
+ FastAPI backend for Flood Detection + ZoeDepth.
3
+ Endpoints:
4
+ POST /predict β€” full pipeline (mask + gradcam + depth + risk)
5
+ GET /health β€” liveness check
6
+ """
app/main.py ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FastAPI backend for Flood Detection + ZoeDepth.
3
+ Endpoints:
4
+ POST /predict β€” full pipeline (mask + gradcam + depth + risk)
5
+ GET /health β€” liveness check
6
+ """
7
+ from fastapi import FastAPI, File, UploadFile, HTTPException
8
+ from fastapi.middleware.cors import CORSMiddleware
9
+ from pydantic import BaseModel
10
+ from typing import List, Optional
11
+ import numpy as np
12
+ from PIL import Image
13
+ import io
14
+ import base64
15
+ import uvicorn
16
+
17
+ from app.model_utils import run_pipeline
18
+
19
+ app = FastAPI(
20
+ title="Flood Detection API",
21
+ description="Attention UNet + ZoeDepth flood segmentation with Grad-CAM and risk assessment.",
22
+ version="2.0.0",
23
+ )
24
+
25
+ app.add_middleware(
26
+ CORSMiddleware,
27
+ allow_origins=["*"],
28
+ allow_methods=["*"],
29
+ allow_headers=["*"],
30
+ )
31
+
32
+
33
+ # ── Response models ────────────────────────────────────────────────────────────
34
+ class DepthInfo(BaseModel):
35
+ avg_depth_m: float
36
+ max_depth_m: float
37
+ depth_category: str
38
+
39
+ class RiskResult(BaseModel):
40
+ risk_level: str
41
+ risk_score: float
42
+ colour: str
43
+ flood_pct: float
44
+ avg_depth_m: float
45
+ recommendations: List[str]
46
+ model_metrics: Optional[dict] = None
47
+
48
+ class PredictResponse(BaseModel):
49
+ mask_b64: str
50
+ overlay_b64: str
51
+ gradcam_b64: str
52
+ depth_map_b64: str
53
+ depth_flood_b64: str
54
+ depth_info: DepthInfo
55
+ risk: RiskResult
56
+
57
+
58
+ def _ndarray_to_b64(arr: np.ndarray) -> str:
59
+ img = Image.fromarray(arr.astype(np.uint8))
60
+ buf = io.BytesIO()
61
+ img.save(buf, format="PNG")
62
+ return base64.b64encode(buf.getvalue()).decode("utf-8")
63
+
64
+
65
+ @app.get("/health")
66
+ def health():
67
+ return {"status": "ok"}
68
+
69
+
70
+ @app.post("/predict", response_model=PredictResponse)
71
+ async def predict(file: UploadFile = File(...)):
72
+ if not file.content_type.startswith("image/"):
73
+ raise HTTPException(status_code=400, detail="File must be an image.")
74
+
75
+ try:
76
+ contents = await file.read()
77
+ image = Image.open(io.BytesIO(contents))
78
+ except Exception as e:
79
+ raise HTTPException(status_code=400, detail=f"Cannot read image: {e}")
80
+
81
+ try:
82
+ result = run_pipeline(image)
83
+ except Exception as e:
84
+ raise HTTPException(status_code=500, detail=f"Inference error: {e}")
85
+
86
+ mask_uint8 = (result["mask"] * 255).astype(np.uint8)
87
+ depth_info = result["depth_info"]
88
+
89
+ return PredictResponse(
90
+ mask_b64 = _ndarray_to_b64(mask_uint8),
91
+ overlay_b64 = _ndarray_to_b64(result["overlay"]),
92
+ gradcam_b64 = _ndarray_to_b64(result["gradcam"]),
93
+ depth_map_b64 = _ndarray_to_b64(result["depth_map"]),
94
+ depth_flood_b64 = _ndarray_to_b64(result["depth_overlay"]),
95
+ depth_info = DepthInfo(
96
+ avg_depth_m = depth_info["avg_depth_m"],
97
+ max_depth_m = depth_info["max_depth_m"],
98
+ depth_category = depth_info["depth_category"],
99
+ ),
100
+ risk = RiskResult(**result["risk"]),
101
+ )
102
+
103
+
104
+ if __name__ == "__main__":
105
+ uvicorn.run("app.main:app", host="0.0.0.0", port=8000, reload=False)
app/model_utils.py ADDED
@@ -0,0 +1,501 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ model_utils.py β€” loads the .pkl metadata + .keras model and exposes inference helpers.
3
+ ZoeDepth is loaded from HuggingFace Hub (CPU) for flood depth estimation.
4
+
5
+ PKL structure (from flood_model_production.pkl):
6
+ model_name, model_keras_path, input_shape, output_shape, threshold,
7
+ preprocessing, gradcam_layer, metrics, risk_thresholds, custom_objects_keys
8
+ """
9
+ import os
10
+ import pickle
11
+ import numpy as np
12
+ import cv2
13
+ import tensorflow as tf
14
+ from PIL import Image
15
+ import torch
16
+
17
+ # ── HuggingFace token (set via env var β€” never hardcode in production) ─────────
18
+ HF_TOKEN = os.environ.get("HF_TOKEN", "")
19
+
20
+ # ── Custom Keras objects ───────────────────────────────────────────────────────
21
+ def iou_metric(y_true, y_pred):
22
+ y_pred = tf.cast(y_pred > 0.5, tf.float32)
23
+ intersection = tf.reduce_sum(y_true * y_pred)
24
+ union = tf.reduce_sum(y_true) + tf.reduce_sum(y_pred) - intersection
25
+ return intersection / (union + 1e-7)
26
+
27
+ def dice_loss(y_true, y_pred, smooth=1e-7):
28
+ intersection = tf.reduce_sum(y_true * y_pred)
29
+ return 1.0 - (2.0 * intersection + smooth) / (
30
+ tf.reduce_sum(y_true) + tf.reduce_sum(y_pred) + smooth
31
+ )
32
+
33
+ def bce_dice_loss(y_true, y_pred):
34
+ bce = tf.keras.losses.binary_crossentropy(y_true, y_pred)
35
+ dice = dice_loss(y_true, y_pred)
36
+ return bce + dice
37
+
38
+ CUSTOM_OBJECTS = {
39
+ "iou_metric": iou_metric,
40
+ "dice_loss": dice_loss,
41
+ "bce_dice_loss": bce_dice_loss,
42
+ }
43
+
44
+ IMAGE_SIZE = (512, 512)
45
+
46
+ # ── Singletons ─────────────────────────────────────────────────────────────────
47
+ _seg_model = None # Attention UNet (TF/Keras)
48
+ _zoe_model = None # ZoeDepth (PyTorch, CPU)
49
+ _metadata = None # dict from pkl
50
+
51
+
52
+ # ── Metadata ───────────────────────────────────────────────────────────────────
53
+ def _load_metadata() -> dict:
54
+ global _metadata
55
+ if _metadata is not None:
56
+ return _metadata
57
+ pkl_path = os.environ.get("MODEL_PKL", "models/flood_model_production.pkl")
58
+ if os.path.exists(pkl_path):
59
+ with open(pkl_path, "rb") as f:
60
+ _metadata = pickle.load(f)
61
+ else:
62
+ _metadata = {}
63
+ return _metadata
64
+
65
+
66
+ # ── Attention UNet loader ──────────────────────────────────────────────────────
67
+ def load_seg_model():
68
+ global _seg_model
69
+ if _seg_model is not None:
70
+ return _seg_model
71
+ keras_path = os.environ.get("MODEL_KERAS", "models/flood_best_model_export.keras")
72
+ if not os.path.exists(keras_path):
73
+ raise FileNotFoundError(
74
+ f"Keras model not found at '{keras_path}'. "
75
+ "Set MODEL_KERAS env var to the correct path."
76
+ )
77
+ _seg_model = tf.keras.models.load_model(keras_path, custom_objects=CUSTOM_OBJECTS)
78
+ return _seg_model
79
+
80
+
81
+ # ── ZoeDepth loader (CPU, HF Hub) ─────────────────────────────────────────────
82
+ def load_zoe_model():
83
+ """
84
+ Loads ZoeDepth (ZoeD_N) from HuggingFace Hub on CPU.
85
+ Uses the transformers pipeline for simplicity and reliability.
86
+ Model: Intel/zoedepth-nyu (indoor/outdoor depth estimation)
87
+ """
88
+ global _zoe_model
89
+ if _zoe_model is not None:
90
+ return _zoe_model
91
+
92
+ from transformers import pipeline as hf_pipeline
93
+
94
+ token = HF_TOKEN or os.environ.get("HF_TOKEN", "")
95
+
96
+ print("[ZoeDepth] Loading from HuggingFace Hub (CPU)...")
97
+ _zoe_model = hf_pipeline(
98
+ task="depth-estimation",
99
+ model="Intel/zoedepth-nyu",
100
+ device="cpu", # force CPU
101
+ token=token if token else None,
102
+ )
103
+ print("[ZoeDepth] Loaded OK.")
104
+ return _zoe_model
105
+
106
+
107
+ # ── Preprocessing ──────────────────────────────────────────────────────────────
108
+ def preprocess_image(image: Image.Image) -> np.ndarray:
109
+ """PIL Image β†’ normalised (1, 512, 512, 3) float32 array."""
110
+ img = image.convert("RGB").resize(IMAGE_SIZE)
111
+ arr = np.array(img, dtype=np.float32) / 255.0
112
+ return np.expand_dims(arr, axis=0) # (1, H, W, 3)
113
+
114
+
115
+ # ── OOD / scene validation ─────────────────────────────────────────────────────
116
+ def _is_flood_scene(image: Image.Image, pred: np.ndarray, threshold: float) -> tuple:
117
+ """
118
+ Multi-signal check to detect out-of-distribution (non-flood) images.
119
+
120
+ Signals used:
121
+ 1. Colour distribution β€” flood/water images have blue/grey/brown tones.
122
+ Non-flood scenes (cars, indoor, etc.) have very different hue distributions.
123
+ 2. Spatial coherence β€” real flood masks are spatially connected large blobs.
124
+ OOD masks are fragmented noise scattered across the image.
125
+ 3. Raw flood coverage β€” if >85% of image is "flooded" it's almost certainly OOD
126
+ (real floods rarely cover the entire frame uniformly).
127
+
128
+ Returns:
129
+ is_valid : bool β€” True if image looks like a flood scene
130
+ reason : str β€” explanation if invalid
131
+ """
132
+ img_rgb = np.array(image.convert("RGB").resize((512, 512)), dtype=np.float32)
133
+ mask = (pred > threshold).astype(np.uint8)
134
+
135
+ # ── Signal 1: colour check ─────────────────────────────────────────────────
136
+ # Flood scenes have significant blue/grey channel presence.
137
+ # Compute mean of blue channel relative to red β€” water is blue-dominant.
138
+ r_mean = img_rgb[:, :, 0].mean()
139
+ g_mean = img_rgb[:, :, 1].mean()
140
+ b_mean = img_rgb[:, :, 2].mean()
141
+ brightness = (r_mean + g_mean + b_mean) / 3.0
142
+
143
+ # Very dark images (night/indoor) or very bright (overexposed) are suspicious
144
+ if brightness < 15 or brightness > 240:
145
+ return False, "Image too dark or too bright for flood analysis"
146
+
147
+ # ── Signal 2: spatial coherence of the predicted mask ─────────────────────
148
+ # Real flood masks = large connected regions. OOD = scattered small blobs.
149
+ flood_pct = float(mask.mean() * 100)
150
+
151
+ if flood_pct > 0.5: # only check if there's something to check
152
+ num_labels, labels, stats, _ = cv2.connectedComponentsWithStats(
153
+ mask, connectivity=8
154
+ )
155
+ if num_labels > 1:
156
+ # Largest component area (excluding background label 0)
157
+ component_areas = stats[1:, cv2.CC_STAT_AREA]
158
+ largest_area = int(component_areas.max())
159
+ total_flood_px = int(mask.sum())
160
+ # Coherence = fraction of flood pixels in the largest blob
161
+ coherence = largest_area / (total_flood_px + 1e-8)
162
+
163
+ # If flood pixels are highly fragmented (coherence < 0.3 AND many components)
164
+ # it's likely OOD noise
165
+ if coherence < 0.25 and num_labels > 50:
166
+ return False, f"Flood mask is fragmented ({num_labels} disconnected regions) β€” likely not a flood scene"
167
+
168
+ # ── Signal 3: implausibly high coverage ───────────────────────────────────
169
+ if flood_pct > 90:
170
+ return False, f"Flood coverage {flood_pct:.1f}% is implausibly high β€” image may not be a flood scene"
171
+
172
+ # ── Signal 4: colour-coverage cross-check ─────────────────────────────────
173
+ # High flood coverage (>40%) on an image that is NOT predominantly
174
+ # blue/grey/brown (water colours) is suspicious.
175
+ if flood_pct > 40:
176
+ # Convert to HSV to check hue distribution
177
+ img_uint8 = img_rgb.astype(np.uint8)
178
+ hsv = cv2.cvtColor(img_uint8, cv2.COLOR_RGB2HSV)
179
+ hue = hsv[:, :, 0] # 0-179 in OpenCV
180
+ sat = hsv[:, :, 1] # 0-255
181
+
182
+ # Water/flood hues: blue (100-130), grey (any hue, low sat), brown (10-20)
183
+ blue_mask = ((hue >= 100) & (hue <= 130) & (sat > 30))
184
+ grey_mask = (sat < 40)
185
+ brown_mask = ((hue >= 8) & (hue <= 22) & (sat > 40))
186
+ water_px = float((blue_mask | grey_mask | brown_mask).mean() * 100)
187
+
188
+ # If less than 15% of the image has water-like colours but >40% is "flooded"
189
+ # β†’ almost certainly OOD (car, building, vegetation, etc.)
190
+ if water_px < 15:
191
+ return False, (
192
+ f"High flood coverage ({flood_pct:.1f}%) but only {water_px:.1f}% "
193
+ "water-like colours detected β€” image does not appear to be a flood scene"
194
+ )
195
+
196
+ return True, ""
197
+
198
+
199
+ # ── Segmentation prediction ────────────────────────────────────────────────────
200
+ def predict_mask(image: Image.Image) -> tuple:
201
+ """
202
+ Returns:
203
+ mask : (H, W) binary float32 array (0 or 1)
204
+ flood_pct : percentage of image covered by flood (0-100)
205
+ confidence : float 0-100 (display percentage)
206
+ low_conf : bool, True if OOD detected
207
+ ood_reason : str, explanation if OOD
208
+ """
209
+ meta = _load_metadata()
210
+ threshold = meta.get("threshold", 0.5)
211
+ model = load_seg_model()
212
+ inp = preprocess_image(image)
213
+ pred = model.predict(inp, verbose=0)[0, :, :, 0] # (H, W)
214
+
215
+ # Raw confidence metric (how decisive the predictions are)
216
+ confidence_raw = float(np.mean(np.abs(pred - 0.5)) * 2)
217
+
218
+ # OOD detection using multi-signal heuristics
219
+ is_valid, ood_reason = _is_flood_scene(image, pred, threshold)
220
+
221
+ if not is_valid:
222
+ mask = np.zeros_like(pred, dtype=np.float32)
223
+ flood_pct = 0.0
224
+ low_conf = True
225
+ else:
226
+ mask = (pred > threshold).astype(np.float32)
227
+ flood_pct = float(mask.mean() * 100)
228
+ low_conf = False
229
+
230
+ confidence_pct = round(confidence_raw * 100, 1)
231
+ return mask, flood_pct, confidence_pct, low_conf, ood_reason
232
+
233
+
234
+ # ── Grad-CAM ───────────────────────────────────────────────────────────────────
235
+ def compute_gradcam(image: Image.Image) -> np.ndarray:
236
+ """
237
+ Returns an RGB uint8 heatmap overlaid on the original image.
238
+ Uses conv2d_130 (from PKL) β€” the last conv before the output head.
239
+ """
240
+ meta = _load_metadata()
241
+ model = load_seg_model()
242
+ inp = preprocess_image(image)
243
+ target_layer = meta.get("gradcam_layer", "conv2d_130")
244
+
245
+ layer_names = [l.name for l in model.layers]
246
+ if target_layer not in layer_names:
247
+ # fallback to last Conv2D
248
+ target_layer = None
249
+ for layer in reversed(model.layers):
250
+ if isinstance(layer, tf.keras.layers.Conv2D):
251
+ target_layer = layer.name
252
+ break
253
+
254
+ if target_layer is None:
255
+ return np.array(image.convert("RGB").resize(IMAGE_SIZE))
256
+
257
+ grad_model = tf.keras.models.Model(
258
+ inputs=model.inputs,
259
+ outputs=[model.get_layer(target_layer).output, model.output]
260
+ )
261
+
262
+ with tf.GradientTape() as tape:
263
+ inp_tensor = tf.cast(inp, tf.float32)
264
+ conv_out, predictions = grad_model(inp_tensor)
265
+ loss = tf.reduce_mean(predictions)
266
+
267
+ grads = tape.gradient(loss, conv_out)
268
+ pooled = tf.reduce_mean(grads, axis=(0, 1, 2))
269
+ cam = tf.reduce_sum(conv_out[0] * pooled, axis=-1).numpy()
270
+ cam = np.maximum(cam, 0)
271
+ cam = cam / (cam.max() + 1e-8)
272
+
273
+ # Smooth for the classic soft-blob look (like the reference image)
274
+ cam_resized = cv2.resize(cam, IMAGE_SIZE)
275
+ cam_smooth = cv2.GaussianBlur(cam_resized, (15, 15), 0)
276
+
277
+ # JET: blue(low) β†’ cyan β†’ green β†’ yellow β†’ red(high) β€” exactly the reference
278
+ heatmap_bgr = cv2.applyColorMap(np.uint8(255 * cam_smooth), cv2.COLORMAP_JET)
279
+ heatmap_rgb = cv2.cvtColor(heatmap_bgr, cv2.COLOR_BGR2RGB).astype(np.float32)
280
+
281
+ # Blend 55% heatmap over original β€” enough to see the image underneath
282
+ orig = np.array(image.convert("RGB").resize(IMAGE_SIZE), dtype=np.float32)
283
+ blended = (orig * 0.45 + heatmap_rgb * 0.55).clip(0, 255).astype(np.uint8)
284
+ return blended
285
+
286
+
287
+ # ── ZoeDepth flood depth estimation ───────────────────────────────────────────
288
+ def estimate_flood_depth(image: Image.Image, mask: np.ndarray) -> dict:
289
+ """
290
+ Runs ZoeDepth on the image, then analyses depth only within flooded pixels.
291
+
292
+ Returns a dict with:
293
+ depth_map_vis : (H, W, 3) uint8 β€” colourised depth map
294
+ depth_overlay : (H, W, 3) uint8 β€” depth map masked to flood region
295
+ avg_depth_m : float β€” mean depth in flooded area (metres)
296
+ max_depth_m : float β€” max depth in flooded area (metres)
297
+ depth_category : str β€” shallow / moderate / deep / very deep
298
+ """
299
+ zoe = load_zoe_model()
300
+
301
+ # ZoeDepth expects a PIL RGB image (any size β€” it handles resize internally)
302
+ rgb_img = image.convert("RGB")
303
+
304
+ # Run depth estimation
305
+ depth_result = zoe(rgb_img)
306
+ depth_pil = depth_result["depth"] # PIL Image (grayscale, float-like)
307
+ depth_arr = np.array(depth_pil, dtype=np.float32) # (H, W)
308
+
309
+ # Resize depth map to 512Γ—512 to match mask
310
+ depth_512 = cv2.resize(depth_arr, IMAGE_SIZE, interpolation=cv2.INTER_LINEAR)
311
+
312
+ # ── Normalise to [0, 1] relative scale ────────────────────────────────────
313
+ # ZoeDepth returns relative disparity values (not calibrated metres).
314
+ # We normalise across the whole image so values are comparable.
315
+ d_min, d_max = depth_512.min(), depth_512.max()
316
+ depth_norm = (depth_512 - d_min) / (d_max - d_min + 1e-8) # 0β†’1
317
+
318
+ # Map relative depth to an estimated flood depth in metres (0–3 m scale).
319
+ # Higher relative depth in flooded pixels β†’ deeper water estimate.
320
+ FLOOD_DEPTH_MAX_M = 3.0
321
+ depth_metres = depth_norm * FLOOD_DEPTH_MAX_M # (H, W), values 0–3 m
322
+
323
+ # Colourised full depth map (PLASMA colormap)
324
+ depth_vis = cv2.applyColorMap(np.uint8(255 * depth_norm), cv2.COLORMAP_PLASMA)
325
+ depth_vis = cv2.cvtColor(depth_vis, cv2.COLOR_BGR2RGB)
326
+
327
+ # Depth stats within flooded pixels only
328
+ flood_mask_bool = mask.astype(bool)
329
+ flooded_depths = depth_metres[flood_mask_bool]
330
+
331
+ if len(flooded_depths) == 0 or flood_mask_bool.sum() < 50:
332
+ # Fewer than 50 flooded pixels β†’ treat as no flood
333
+ avg_depth_m = 0.0
334
+ max_depth_m = 0.0
335
+ else:
336
+ avg_depth_m = float(flooded_depths.mean())
337
+ max_depth_m = float(flooded_depths.max())
338
+
339
+ # Depth category (ZoeDepth outputs relative depth in metres approx)
340
+ if avg_depth_m < 0.3:
341
+ depth_category = "Shallow (< 30 cm)"
342
+ elif avg_depth_m < 0.8:
343
+ depth_category = "Moderate (30 cm – 80 cm)"
344
+ elif avg_depth_m < 1.5:
345
+ depth_category = "Deep (80 cm – 1.5 m)"
346
+ else:
347
+ depth_category = "Very Deep (> 1.5 m β€” life-threatening)"
348
+
349
+ # Flood-region depth overlay: blend depth_vis with blue flood mask
350
+ flood_depth_overlay = depth_vis.copy()
351
+ # Highlight non-flooded areas in grey
352
+ grey = np.full_like(depth_vis, 128)
353
+ flood_depth_overlay[~flood_mask_bool] = grey[~flood_mask_bool]
354
+
355
+ return {
356
+ "depth_map_vis": depth_vis, # (H, W, 3) uint8
357
+ "depth_overlay": flood_depth_overlay, # (H, W, 3) uint8
358
+ "avg_depth_m": round(avg_depth_m, 3),
359
+ "max_depth_m": round(max_depth_m, 3),
360
+ "depth_category": depth_category,
361
+ }
362
+
363
+
364
+ # ── Risk assessment (uses thresholds from PKL + ZoeDepth depth) ───────────────
365
+ def assess_risk(flood_pct: float, avg_depth_m: float = 0.0) -> dict:
366
+ """
367
+ Combined risk from flood coverage % AND average depth from ZoeDepth.
368
+ Thresholds from PKL:
369
+ low : flood_pct < 15 AND avg_depth_m < 0.80
370
+ medium : flood_pct < 35 AND avg_depth_m < 1.50
371
+ high : flood_pct < 60 AND avg_depth_m < 2.20
372
+ critical : above all
373
+ """
374
+ meta = _load_metadata()
375
+ thresholds = meta.get("risk_thresholds", {
376
+ "low": {"max_flood_pct": 15, "max_avg_depth_m": 80},
377
+ "medium": {"max_flood_pct": 35, "max_avg_depth_m": 150},
378
+ "high": {"max_flood_pct": 60, "max_avg_depth_m": 220},
379
+ "critical": {"above_all": True},
380
+ })
381
+
382
+ # PKL stores depth in cm β€” convert to metres for comparison
383
+ low_pct = thresholds.get("low", {}).get("max_flood_pct", 15)
384
+ med_pct = thresholds.get("medium", {}).get("max_flood_pct", 35)
385
+ high_pct = thresholds.get("high", {}).get("max_flood_pct", 60)
386
+
387
+ # Depth thresholds in metres (realistic flood water levels)
388
+ low_dep = 0.3 # < 30 cm β†’ Low
389
+ med_dep = 0.8 # < 80 cm β†’ Moderate
390
+ high_dep = 1.5 # < 150 cm β†’ High (> 1.5 m β†’ Critical)
391
+
392
+ # Depth only contributes to risk when there is meaningful flood coverage.
393
+ # If flood_pct < 2%, ignore depth entirely (noise / false positives).
394
+ effective_depth = avg_depth_m if flood_pct >= 2.0 else 0.0
395
+
396
+ def _level_from_pct(p):
397
+ if p < low_pct: return 0
398
+ if p < med_pct: return 1
399
+ if p < high_pct: return 2
400
+ return 3
401
+
402
+ def _level_from_dep(d):
403
+ if d < low_dep: return 0
404
+ if d < med_dep: return 1
405
+ if d < high_dep: return 2
406
+ return 3
407
+
408
+ level_idx = max(_level_from_pct(flood_pct), _level_from_dep(effective_depth))
409
+ levels = ["Low", "Moderate", "High", "Critical"]
410
+ colours = ["#2ecc71", "#f39c12", "#e67e22", "#e74c3c"]
411
+ level = levels[level_idx]
412
+ colour = colours[level_idx]
413
+
414
+ # Score: flood coverage drives 70%, depth drives 30%
415
+ pct_score = min(flood_pct / 100 * 100, 100)
416
+ depth_score = min(effective_depth / 3.0 * 100, 100) # 3 m = max realistic scale
417
+ score = round(pct_score * 0.7 + depth_score * 0.3, 1)
418
+
419
+ recs_map = {
420
+ "Low": [
421
+ "Monitor water levels periodically.",
422
+ "Ensure drainage channels are clear.",
423
+ "No immediate evacuation required.",
424
+ ],
425
+ "Moderate": [
426
+ "Alert local emergency services.",
427
+ "Move valuables to higher ground.",
428
+ "Prepare emergency kit.",
429
+ "Monitor weather forecasts closely.",
430
+ ],
431
+ "High": [
432
+ "Initiate partial evacuation of vulnerable populations.",
433
+ "Deploy flood barriers where possible.",
434
+ "Activate emergency response teams.",
435
+ "Avoid flooded roads and areas.",
436
+ ],
437
+ "Critical": [
438
+ "IMMEDIATE EVACUATION REQUIRED.",
439
+ "Contact emergency services (911 / local disaster hotline).",
440
+ "Do not attempt to cross flooded areas.",
441
+ "Seek shelter on highest available ground.",
442
+ "Follow official evacuation routes only.",
443
+ ],
444
+ }
445
+
446
+ deployed_metrics = meta.get("metrics", {}).get("deployed", {})
447
+
448
+ return {
449
+ "risk_level": level,
450
+ "risk_score": score,
451
+ "colour": colour,
452
+ "flood_pct": round(flood_pct, 2),
453
+ "avg_depth_m": round(avg_depth_m, 3),
454
+ "recommendations": recs_map[level],
455
+ "model_metrics": deployed_metrics,
456
+ }
457
+
458
+
459
+ # ── Full pipeline ──────────────────────────────────────────────────────────────
460
+ def run_pipeline(image: Image.Image) -> dict:
461
+ """
462
+ End-to-end:
463
+ 1. Attention UNet β†’ flood mask (with confidence gate)
464
+ 2. Grad-CAM β†’ explainability heatmap
465
+ 3. ZoeDepth β†’ per-pixel depth map, flood depth stats
466
+ 4. Risk assessment β†’ level, score, recommendations
467
+ """
468
+ # Step 1 β€” segmentation + OOD detection
469
+ mask, flood_pct, confidence, low_conf, ood_reason = predict_mask(image)
470
+
471
+ # Step 2 β€” Grad-CAM
472
+ gradcam_img = compute_gradcam(image)
473
+
474
+ # Step 3 β€” ZoeDepth depth estimation
475
+ depth_info = estimate_flood_depth(image, mask)
476
+
477
+ # Step 4 β€” risk
478
+ risk = assess_risk(flood_pct, avg_depth_m=depth_info["avg_depth_m"])
479
+
480
+ # Add OOD info to risk dict for display
481
+ risk["confidence"] = confidence
482
+ risk["low_conf"] = low_conf
483
+ risk["warning"] = (
484
+ f"⚠️ Out-of-distribution image detected: {ood_reason}. "
485
+ "Results suppressed."
486
+ ) if low_conf else ""
487
+
488
+ # Blue-tinted flood overlay on original
489
+ orig_arr = np.array(image.convert("RGB").resize(IMAGE_SIZE), dtype=np.uint8)
490
+ mask_3ch = np.stack([mask * 0, mask * 100, mask * 255], axis=-1).astype(np.uint8)
491
+ overlay = cv2.addWeighted(orig_arr, 0.7, mask_3ch, 0.3, 0)
492
+
493
+ return {
494
+ "mask": mask,
495
+ "overlay": overlay,
496
+ "gradcam": gradcam_img,
497
+ "depth_map": depth_info["depth_map_vis"],
498
+ "depth_overlay": depth_info["depth_overlay"],
499
+ "depth_info": depth_info,
500
+ "risk": risk,
501
+ }
models/flood_best_model_export.keras ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:0406e44d9a989c2899823a60646ed9076c01b884c264953e87a9bc9ec48c1bf8
3
+ size 26297341
models/flood_model_production.pkl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:2934609d3bc1473cab0599d6288a632547a51d773abc5b636990cd3f9e2c21b1
3
+ size 873
requirements.txt ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ tensorflow-cpu>=2.16.0
2
+ numpy>=1.24.0
3
+ opencv-python-headless>=4.8.0
4
+ Pillow>=10.0.0
5
+ gradio>=6.0.0
6
+ torch>=2.1.0
7
+ transformers>=4.40.0,<4.50.0
8
+ timm>=0.9.0