AIGEEKSHUBH commited on
Commit
5fd2f3a
Β·
verified Β·
1 Parent(s): 0b5d9a8

Upload app.py

Browse files
Files changed (1) hide show
  1. app.py +466 -0
app.py ADDED
@@ -0,0 +1,466 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import streamlit as st
2
+ import torch
3
+ import numpy as np
4
+ from PIL import Image
5
+ import matplotlib.pyplot as plt
6
+ import matplotlib.patches as mpatches
7
+ import cv2
8
+ import tempfile
9
+ import os
10
+ import time
11
+ from transformers import (
12
+ SegformerImageProcessor,
13
+ SegformerForSemanticSegmentation,
14
+ AutoImageProcessor,
15
+ SiglipForImageClassification,
16
+ )
17
+
18
+ # ── Page config ───────────────────────────────────────────────
19
+ st.set_page_config(
20
+ page_title="GeoVision β€” Drone Intelligence Platform",
21
+ page_icon="πŸ›°οΈ",
22
+ layout="wide"
23
+ )
24
+
25
+ st.markdown("""
26
+ <style>
27
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;700&family=Space+Grotesk:wght@500;700&display=swap');
28
+ html, body, [class*="css"] { font-family: 'Inter', sans-serif; background-color: #0D1117; color: #E6EDF3; }
29
+ .main-title { font-family: 'Space Grotesk', sans-serif; font-size: 2.4rem; font-weight: 700; color: #58A6FF; }
30
+ .sub-title { font-size: 1rem; color: #8B949E; margin-bottom: 1.5rem; }
31
+ .tag { display: inline-block; background: #1F3A5F; color: #58A6FF; border-radius: 20px; padding: 3px 12px;
32
+ font-size: 0.75rem; font-weight: 600; margin-right: 6px; margin-bottom: 16px; }
33
+ .stat-box { background: #161B22; border: 1px solid #30363D; border-radius: 10px; padding: 16px; text-align: center; margin-bottom: 8px; }
34
+ .stat-value { font-family: 'Space Grotesk', sans-serif; font-size: 1.6rem; font-weight: 700; color: #58A6FF; }
35
+ .stat-label { font-size: 0.75rem; color: #8B949E; text-transform: uppercase; letter-spacing: 0.05em; }
36
+ .region-card { background: #161B22; border: 1px solid #30363D; border-radius: 10px; padding: 14px; margin-bottom: 10px; }
37
+ .region-title { font-family: 'Space Grotesk', sans-serif; font-size: 0.95rem; font-weight: 600; color: #E6EDF3; }
38
+ .conf-bar-bg { background: #21262D; border-radius: 4px; height: 6px; margin-top: 6px; }
39
+ .live-badge { display: inline-block; background: #C0392B; color: white; border-radius: 4px;
40
+ padding: 2px 8px; font-size: 0.7rem; font-weight: 700; }
41
+ .pipeline-badge { display: inline-block; background: #1a472a; color: #2ECC71; border-radius: 4px;
42
+ padding: 2px 8px; font-size: 0.7rem; font-weight: 700; margin-left: 8px; }
43
+ .footer { text-align: center; color: #484F58; font-size: 0.8rem; margin-top: 3rem;
44
+ padding-top: 1rem; border-top: 1px solid #21262D; }
45
+ .stButton > button { background: linear-gradient(135deg, #1F6FEB, #388BFD); color: white;
46
+ border: none; border-radius: 8px; padding: 0.6rem 2rem;
47
+ font-weight: 600; font-size: 1rem; width: 100%; }
48
+ </style>
49
+ """, unsafe_allow_html=True)
50
+
51
+ # ── Header ─────────────────────────────────────────────────────
52
+ st.markdown('<div class="main-title">πŸ›°οΈ GeoVision</div>', unsafe_allow_html=True)
53
+ st.markdown('<div class="sub-title">Two-Stage Drone Intelligence Pipeline β€” Land Cover Segmentation + Landform Classification</div>', unsafe_allow_html=True)
54
+ st.markdown("""
55
+ <span class="tag">πŸ€– SegFormer-B2</span>
56
+ <span class="tag">πŸ” SigLIP Landform Classifier</span>
57
+ <span class="tag">🌍 Two-Stage AI Pipeline</span>
58
+ <span class="tag">πŸ“‘ Live Feed Ready</span>
59
+ """, unsafe_allow_html=True)
60
+
61
+ # ── Class maps ─────────────────────────────────────────────────
62
+ SEG_MAP = {
63
+ 0: ("Structure", "#7F8C8D"),
64
+ 1: ("Open Area", "#85C1E9"),
65
+ 3: ("Building", "#E74C3C"),
66
+ 4: ("Tree", "#27AE60"),
67
+ 6: ("Road", "#95A5A6"),
68
+ 9: ("Grass", "#2ECC71"),
69
+ 10: ("Forest", "#1E8449"),
70
+ 12: ("Footpath", "#D5DBDB"),
71
+ 13: ("Terrain", "#8E44AD"),
72
+ 16: ("Bare Land", "#D4AC0D"),
73
+ 17: ("Water", "#3498DB"),
74
+ 21: ("Farmland", "#F39C12"),
75
+ 29: ("Open Field", "#F0B27A"),
76
+ 43: ("Sand/Desert", "#F7DC6F"),
77
+ 46: ("Barren", "#CA6F1E"),
78
+ 94: ("Vegetation", "#1ABC9C"),
79
+ }
80
+
81
+ LANDFORM_LABELS = {
82
+ "0": "Annual Crop",
83
+ "1": "Forest",
84
+ "2": "Herbaceous Vegetation",
85
+ "3": "Highway",
86
+ "4": "Industrial",
87
+ "5": "Pasture",
88
+ "6": "Permanent Crop",
89
+ "7": "Residential",
90
+ "8": "River",
91
+ "9": "Sea / Lake"
92
+ }
93
+
94
+ LANDFORM_ICONS = {
95
+ "Annual Crop": "🌾",
96
+ "Forest": "🌲",
97
+ "Herbaceous Vegetation": "🌿",
98
+ "Highway": "πŸ›£οΈ",
99
+ "Industrial": "🏭",
100
+ "Pasture": "πŸ„",
101
+ "Permanent Crop": "πŸ‡",
102
+ "Residential": "🏘️",
103
+ "River": "🏞️",
104
+ "Sea / Lake": "🌊"
105
+ }
106
+
107
+ GEOLOGICAL_HINTS = {
108
+ "Annual Crop": "Likely alluvial/loam soil. Good agricultural fertility.",
109
+ "Forest": "Humid soil with organic layer. Possible clay-rich substrate.",
110
+ "Herbaceous Vegetation": "Thin topsoil over sedimentary or volcanic base.",
111
+ "Highway": "Compacted sub-base. Engineered ground β€” no natural geology.",
112
+ "Industrial": "Artificially modified land. Possible fill or compacted gravel.",
113
+ "Pasture": "Silty or clay-loam soil. Generally flat sedimentary terrain.",
114
+ "Permanent Crop": "Well-drained loamy soil. Likely sedimentary or alluvial.",
115
+ "Residential": "Urban fill. Natural geology obscured.",
116
+ "River": "Active alluvial deposit. Sandy/gravelly riverbed sediment.",
117
+ "Sea / Lake": "Water body. Lakebed may have clay, silt, or sand deposits.",
118
+ }
119
+
120
+ def hex_to_rgb(h):
121
+ h = h.lstrip("#")
122
+ return tuple(int(h[i:i+2], 16) for i in (0, 2, 4))
123
+
124
+ def cid_to_rgb(cid):
125
+ return hex_to_rgb(SEG_MAP[cid][1]) if cid in SEG_MAP else (189, 195, 199)
126
+
127
+ # ── Model loaders ──────────────────────────────────────────────
128
+ @st.cache_resource(show_spinner=False)
129
+ def load_seg_model():
130
+ proc = SegformerImageProcessor.from_pretrained("nvidia/segformer-b2-finetuned-ade-512-512")
131
+ mdl = SegformerForSemanticSegmentation.from_pretrained("nvidia/segformer-b2-finetuned-ade-512-512")
132
+ dev = "cuda" if torch.cuda.is_available() else "cpu"
133
+ return proc, mdl.to(dev).eval(), dev
134
+
135
+ @st.cache_resource(show_spinner=False)
136
+ def load_cls_model():
137
+ proc = AutoImageProcessor.from_pretrained("prithivMLmods/SAT-Landforms-Classifier")
138
+ mdl = SiglipForImageClassification.from_pretrained("prithivMLmods/SAT-Landforms-Classifier")
139
+ dev = "cuda" if torch.cuda.is_available() else "cpu"
140
+ return proc, mdl.to(dev).eval(), dev
141
+
142
+ # ── Stage 1: Segmentation ──────────────────────────────────────
143
+ def run_segmentation(image: Image.Image):
144
+ proc, mdl, dev = load_seg_model()
145
+ img = image.resize((512, 512))
146
+ inputs = proc(images=img, return_tensors="pt").to(dev)
147
+ with torch.no_grad():
148
+ logits = mdl(**inputs).logits
149
+ up = torch.nn.functional.interpolate(logits, size=(512, 512), mode="bilinear", align_corners=False)
150
+ pred = up.argmax(dim=1)[0].cpu().numpy()
151
+
152
+ color_map = np.zeros((512, 512, 3), dtype=np.uint8)
153
+ for cid in np.unique(pred):
154
+ color_map[pred == cid] = cid_to_rgb(cid)
155
+
156
+ seg_img = Image.fromarray(color_map)
157
+ blended = Image.blend(img, seg_img, alpha=0.55)
158
+
159
+ total = 512 * 512
160
+ stats = {}
161
+ for cid in np.unique(pred):
162
+ pct = round(np.sum(pred == cid) / total * 100, 1)
163
+ name = SEG_MAP.get(cid, ("Other",))[0]
164
+ stats[name] = stats.get(name, 0) + pct
165
+
166
+ return img, seg_img, blended, stats, pred
167
+
168
+ # ── Stage 2: Per-region landform classification ────────────────
169
+ def classify_regions(orig_img: Image.Image, pred: np.ndarray):
170
+ proc, mdl, dev = load_cls_model()
171
+ orig_arr = np.array(orig_img)
172
+ results = []
173
+
174
+ detected_cids = [cid for cid in np.unique(pred) if cid in SEG_MAP]
175
+
176
+ for cid in detected_cids:
177
+ mask = pred == cid
178
+ ys, xs = np.where(mask)
179
+ if len(ys) < 500: # skip tiny regions
180
+ continue
181
+
182
+ # Crop bounding box of this region
183
+ y1, y2 = ys.min(), ys.max()
184
+ x1, x2 = xs.min(), xs.max()
185
+ patch = orig_arr[y1:y2+1, x1:x2+1]
186
+ if patch.size == 0:
187
+ continue
188
+ patch_pil = Image.fromarray(patch).resize((224, 224))
189
+
190
+ inputs = proc(images=patch_pil, return_tensors="pt").to(dev)
191
+ with torch.no_grad():
192
+ logits = mdl(**inputs).logits
193
+ probs = torch.nn.functional.softmax(logits, dim=1).squeeze().tolist()
194
+ top_id = int(np.argmax(probs))
195
+ top_lf = LANDFORM_LABELS[str(top_id)]
196
+ top_conf = round(probs[top_id] * 100, 1)
197
+
198
+ seg_name = SEG_MAP[cid][0]
199
+ seg_color = SEG_MAP[cid][1]
200
+ icon = LANDFORM_ICONS.get(top_lf, "πŸ“")
201
+ geo_hint = GEOLOGICAL_HINTS.get(top_lf, "")
202
+
203
+ results.append({
204
+ "seg_name": seg_name,
205
+ "seg_color": seg_color,
206
+ "landform": top_lf,
207
+ "icon": icon,
208
+ "conf": top_conf,
209
+ "geo_hint": geo_hint,
210
+ "all_probs": {LANDFORM_LABELS[str(i)]: round(probs[i]*100,1) for i in range(len(probs))},
211
+ })
212
+
213
+ results.sort(key=lambda x: -x["conf"])
214
+ return results
215
+
216
+ # ── Display helpers ────────────────────────────────────────────
217
+ def show_images(orig, seg, blend):
218
+ c1, c2, c3 = st.columns(3)
219
+ with c1: st.image(orig, caption="πŸ“· Original Drone Image", use_column_width=True)
220
+ with c2: st.image(seg, caption="πŸ—ΊοΈ Segmentation Map", use_column_width=True)
221
+ with c3: st.image(blend, caption="πŸ”€ Blended Overlay", use_column_width=True)
222
+
223
+ def show_stats(stats):
224
+ top = sorted(stats.items(), key=lambda x: -x[1])[:4]
225
+ cols = st.columns(4)
226
+ for i, (name, pct) in enumerate(top):
227
+ with cols[i]:
228
+ st.markdown(f"""
229
+ <div class="stat-box">
230
+ <div class="stat-value">{pct}%</div>
231
+ <div class="stat-label">{name}</div>
232
+ </div>""", unsafe_allow_html=True)
233
+
234
+ def show_pipeline_results(regions):
235
+ st.markdown("---")
236
+ st.markdown("""
237
+ ### πŸ”¬ Stage 2 β€” Landform & Geological Intelligence
238
+ <span class="pipeline-badge">✦ TWO-STAGE AI PIPELINE</span>
239
+ <p style='color:#8B949E; font-size:0.9rem; margin-top:8px;'>
240
+ Each land cover region detected by SegFormer is independently classified by a satellite landform classifier.
241
+ Geological context is inferred from the landform type.
242
+ </p>
243
+ """, unsafe_allow_html=True)
244
+
245
+ if not regions:
246
+ st.info("No significant regions detected for Stage 2 classification.")
247
+ return
248
+
249
+ cols = st.columns(2)
250
+ for i, r in enumerate(regions):
251
+ with cols[i % 2]:
252
+ conf_color = "#2ECC71" if r["conf"] > 60 else "#F39C12" if r["conf"] > 35 else "#E74C3C"
253
+ st.markdown(f"""
254
+ <div class="region-card">
255
+ <div style="display:flex; align-items:center; gap:10px; margin-bottom:8px;">
256
+ <div style="width:14px;height:14px;border-radius:3px;background:{r['seg_color']};flex-shrink:0;"></div>
257
+ <span class="region-title">{r['seg_name']}</span>
258
+ <span style="margin-left:auto;font-size:0.75rem;color:#8B949E;">{r['conf']}% conf.</span>
259
+ </div>
260
+ <div style="font-size:1.4rem;">{r['icon']} <span style="font-size:0.95rem;font-weight:600;color:#E6EDF3;">{r['landform']}</span></div>
261
+ <div class="conf-bar-bg">
262
+ <div style="width:{min(r['conf'],100)}%;background:{conf_color};height:6px;border-radius:4px;"></div>
263
+ </div>
264
+ <div style="font-size:0.8rem;color:#8B949E;margin-top:8px;">πŸͺ¨ <i>{r['geo_hint']}</i></div>
265
+ </div>
266
+ """, unsafe_allow_html=True)
267
+
268
+ def show_summary_chart(regions):
269
+ if not regions:
270
+ return
271
+ st.markdown("### πŸ“Š Landform Distribution")
272
+ lf_counts = {}
273
+ for r in regions:
274
+ lf_counts[r["landform"]] = lf_counts.get(r["landform"], 0) + 1
275
+
276
+ fig, ax = plt.subplots(figsize=(10, 3))
277
+ fig.patch.set_facecolor("#0D1117")
278
+ ax.set_facecolor("#161B22")
279
+ names = list(lf_counts.keys())
280
+ vals = list(lf_counts.values())
281
+ colors = ["#58A6FF","#2ECC71","#E74C3C","#F39C12","#8E44AD","#3498DB"]
282
+ ax.barh(names, vals, color=colors[:len(names)], height=0.5, edgecolor="none")
283
+ ax.set_xlabel("Region Count", color="#8B949E")
284
+ ax.tick_params(colors="#8B949E")
285
+ ax.spines[:].set_color("#30363D")
286
+ plt.tight_layout()
287
+ st.pyplot(fig)
288
+
289
+ # ══════════════════════════════════════════════════════════════
290
+ # MODE SELECTOR
291
+ # ══════════════════════════════════════════════════════════════
292
+ st.markdown("---")
293
+ mode = st.radio("**Select Input Mode:**",
294
+ ["πŸ“· Image", "πŸŽ₯ Video", "πŸ“‘ Live Webcam Feed"], horizontal=True)
295
+ st.markdown("---")
296
+
297
+ # ══════════════════════════════════════════════════════════════
298
+ # MODE 1: IMAGE
299
+ # ══════════════════════════════════════════════════════════════
300
+ if mode == "πŸ“· Image":
301
+ st.markdown("#### πŸ“· Upload Drone / Aerial Image")
302
+ uploaded = st.file_uploader("", type=["jpg","jpeg","png","tif","tiff"])
303
+
304
+ if uploaded:
305
+ image = Image.open(uploaded).convert("RGB")
306
+
307
+ with st.spinner("πŸ” Stage 1 β€” Running land cover segmentation..."):
308
+ orig, seg, blend, stats, pred = run_segmentation(image)
309
+
310
+ st.markdown("### πŸ—ΊοΈ Stage 1 β€” Land Cover Segmentation")
311
+ show_stats(stats)
312
+ st.markdown("<br>", unsafe_allow_html=True)
313
+ show_images(orig, seg, blend)
314
+
315
+ with st.spinner("πŸ”¬ Stage 2 β€” Classifying each region's landform & geology..."):
316
+ regions = classify_regions(orig, pred)
317
+
318
+ show_pipeline_results(regions)
319
+ show_summary_chart(regions)
320
+
321
+ # ══════════════════════════════════════════════════════════════
322
+ # MODE 2: VIDEO
323
+ # ══════════════════════════════════════════════════════════════
324
+ elif mode == "πŸŽ₯ Video":
325
+ st.markdown("#### πŸŽ₯ Upload Drone Video")
326
+ st.info("Uploads a drone video and segments every Nth frame. Stage 2 classification runs on key frames.")
327
+
328
+ video_file = st.file_uploader("", type=["mp4","avi","mov","mkv"])
329
+ frame_skip = st.slider("Process every N frames", 5, 60, 15)
330
+ run_stage2 = st.checkbox("Also run Stage 2 landform classification on key frames", value=True)
331
+
332
+ if video_file:
333
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".mp4") as tmp:
334
+ tmp.write(video_file.read())
335
+ tmp_path = tmp.name
336
+
337
+ cap = cv2.VideoCapture(tmp_path)
338
+ total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
339
+ fps = cap.get(cv2.CAP_PROP_FPS) or 24
340
+ out_path = tmp_path.replace(".mp4", "_geo.mp4")
341
+ fourcc = cv2.VideoWriter_fourcc(*"mp4v")
342
+ out = cv2.VideoWriter(out_path, fourcc, max(fps/frame_skip, 1), (512, 512))
343
+
344
+ progress = st.progress(0, text="Processing...")
345
+ frame_idx = 0
346
+ key_frame_results = []
347
+
348
+ proc_seg, mdl_seg, dev_seg = load_seg_model()
349
+
350
+ with st.spinner("Processing video..."):
351
+ while True:
352
+ ret, frame = cap.read()
353
+ if not ret:
354
+ break
355
+ if frame_idx % frame_skip == 0:
356
+ rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
357
+ pil = Image.fromarray(rgb).resize((512, 512))
358
+ inputs = proc_seg(images=pil, return_tensors="pt").to(dev_seg)
359
+ with torch.no_grad():
360
+ logits = mdl_seg(**inputs).logits
361
+ up = torch.nn.functional.interpolate(logits, size=(512,512), mode="bilinear", align_corners=False)
362
+ pred = up.argmax(dim=1)[0].cpu().numpy()
363
+
364
+ color_map = np.zeros((512,512,3), dtype=np.uint8)
365
+ for cid in np.unique(pred):
366
+ color_map[pred==cid] = cid_to_rgb(cid)
367
+
368
+ color_bgr = cv2.cvtColor(color_map, cv2.COLOR_RGB2BGR)
369
+ frame_resize = cv2.resize(frame, (512,512))
370
+ blended_cv = cv2.addWeighted(frame_resize, 0.5, color_bgr, 0.5, 0)
371
+ out.write(blended_cv)
372
+
373
+ if run_stage2 and frame_idx % (frame_skip * 5) == 0:
374
+ regions = classify_regions(pil, pred)
375
+ if regions:
376
+ key_frame_results.append((frame_idx, regions))
377
+
378
+ progress.progress(min(frame_idx/max(total_frames,1), 1.0),
379
+ text=f"Frame {frame_idx}/{total_frames}")
380
+ frame_idx += 1
381
+
382
+ cap.release()
383
+ out.release()
384
+ progress.progress(1.0, text="βœ… Done!")
385
+
386
+ with open(out_path, "rb") as f:
387
+ st.download_button("⬇️ Download Segmented Video", f,
388
+ file_name="geovision_output.mp4", mime="video/mp4")
389
+
390
+ if key_frame_results:
391
+ st.markdown(f"### πŸ”¬ Stage 2 β€” Key Frame Analysis ({len(key_frame_results)} frames sampled)")
392
+ for fidx, regions in key_frame_results[:3]:
393
+ st.markdown(f"**Frame #{fidx}**")
394
+ show_pipeline_results(regions)
395
+
396
+ os.unlink(tmp_path)
397
+ os.unlink(out_path)
398
+
399
+ # ══════════════════════════════════════════════════════════════
400
+ # MODE 3: LIVE WEBCAM
401
+ # ══════════════════════════════════════════════════════════════
402
+ elif mode == "πŸ“‘ Live Webcam Feed":
403
+ st.markdown(
404
+ '#### πŸ“‘ Live Drone Feed &nbsp;<span class="live-badge">● LIVE</span>'
405
+ '&nbsp;<span class="pipeline-badge">✦ TWO-STAGE</span>',
406
+ unsafe_allow_html=True
407
+ )
408
+ st.info("Simulates a live drone feed. Each frame is processed through the full two-stage pipeline.")
409
+
410
+ col1, col2 = st.columns([1,2])
411
+ with col1:
412
+ interval = st.slider("Capture interval (seconds)", 2, 15, 5)
413
+ run_stage2 = st.checkbox("Enable Stage 2 classification", value=True)
414
+ run_live = st.checkbox("▢️ Start Live Feed", value=False)
415
+ with col2:
416
+ st.markdown("""
417
+ **Two-stage pipeline on every frame:**
418
+ 1. SegFormer segments the frame into land cover regions
419
+ 2. Each region is classified for landform type
420
+ 3. Geological context is inferred in real time
421
+ """)
422
+
423
+ if run_live:
424
+ cap = cv2.VideoCapture(0)
425
+ if not cap.isOpened():
426
+ st.error("❌ Could not access webcam. Please allow camera permissions.")
427
+ else:
428
+ frame_ph = st.empty()
429
+ img_ph = st.empty()
430
+ region_ph = st.empty()
431
+ count = 0
432
+
433
+ while run_live:
434
+ ret, frame = cap.read()
435
+ if not ret:
436
+ break
437
+ count += 1
438
+
439
+ rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
440
+ pil = Image.fromarray(rgb).resize((512, 512))
441
+ orig, seg, blend, stats, pred = run_segmentation(pil)
442
+
443
+ with img_ph.container():
444
+ frame_ph.markdown(f"**πŸ“‘ Live Frame #{count}**")
445
+ c1, c2, c3 = st.columns(3)
446
+ with c1: st.image(orig, caption="Original", use_column_width=True)
447
+ with c2: st.image(seg, caption="Segmentation", use_column_width=True)
448
+ with c3: st.image(blend, caption="Overlay", use_column_width=True)
449
+ show_stats(stats)
450
+
451
+ if run_stage2:
452
+ regions = classify_regions(orig, pred)
453
+ with region_ph.container():
454
+ show_pipeline_results(regions)
455
+
456
+ time.sleep(interval)
457
+
458
+ cap.release()
459
+
460
+ # ── Footer ─────────────────────────────────────────────────────
461
+ st.markdown("""
462
+ <div class="footer">
463
+ GeoVision Β· Two-Stage Geospatial AI Pipeline<br>
464
+ SegFormer-B2 (Land Cover) + SigLIP (Landform Classification) Β· Built with πŸ€— Transformers & Streamlit
465
+ </div>
466
+ """, unsafe_allow_html=True)