ddecosmo commited on
Commit
69f383a
Β·
verified Β·
1 Parent(s): 2512b81

Upload 2 files

Browse files
Files changed (2) hide show
  1. requirements.txt +10 -0
  2. untitled2.py +532 -0
requirements.txt ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ gradio
2
+ numpy
3
+ pandas
4
+ Pillow
5
+ huggingface-hub
6
+ tensorflow
7
+ scipy
8
+ matplotlib
9
+ folium
10
+ autogluon.multimodal
untitled2.py ADDED
@@ -0,0 +1,532 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """Untitled2.ipynb
3
+
4
+ Automatically generated by Colab.
5
+
6
+ Original file is located at
7
+ https://colab.research.google.com/drive/1UcsSFSmZqIdAQTsD_4_CmwwAcAzz0h60
8
+ """
9
+
10
+ import numpy as np
11
+ import pandas as pd
12
+ import os
13
+ import io
14
+ import matplotlib.pyplot as plt
15
+ import matplotlib.cm as cm
16
+ import folium
17
+ import matplotlib.colors
18
+ from scipy.stats import gaussian_kde
19
+ from PIL import Image
20
+ import gradio as gr
21
+ import huggingface_hub
22
+ from huggingface_hub import HfApi, hf_hub_download, create_repo, file_exists, upload_file
23
+ import tempfile
24
+ import pathlib
25
+ import json
26
+ import uuid
27
+ import shutil
28
+ import zipfile
29
+ from datetime import datetime
30
+
31
+ # Import MultiModalPredictor for the model loading logic
32
+ # NOTE: This import assumes 'autogluon.multimodal' is installed in the environment.
33
+ try:
34
+ from autogluon.multimodal import MultiModalPredictor
35
+ AUTOGLUON_IMPORTED = True
36
+ except ImportError:
37
+ # Set flag to False if the complex dependency is missing
38
+ AUTOGLUON_IMPORTED = False
39
+ class MultiModalPredictor:
40
+ @staticmethod
41
+ def load(path):
42
+ raise ImportError("AutoGluon MultiModalPredictor is not installed or failed to import.")
43
+
44
+ # --- 1. CLASSIFICATION CONFIGURATION & MODEL LOADING ---
45
+ MODEL_REPO_ID = "ddecosmo/lanternfly_classifier"
46
+ ZIP_FILENAME = "autogluon_image_predictor_dir.zip"
47
+ MODEL_DIR_NAME = "autogluon_predictor_extracted"
48
+ CLASSIFICATION_LABELS = ["Lanternfly", "Other Insect", "Neither"]
49
+
50
+ PREDICTOR = None
51
+ MODEL_STATUS = "Attempting to load model..."
52
+
53
+ # Robust download and extraction of the AutoGluon model zip file
54
+ def _prepare_predictor_dir(repo_id, zip_filename, extract_dir_name) -> str:
55
+ """Downloads the zipped model and extracts it to a clean directory."""
56
+ base_extract_dir = os.path.join(os.getcwd(), extract_dir_name)
57
+ try:
58
+ # 1. Download the zipped model file from Hugging Face Hub
59
+ zip_path = hf_hub_download(repo_id=repo_id, filename=zip_filename)
60
+
61
+ # 2. Prepare directories
62
+ if os.path.exists(base_extract_dir):
63
+ shutil.rmtree(base_extract_dir)
64
+ temp_extract_dir = os.path.join(os.getcwd(), "temp_ag_extract")
65
+ os.makedirs(temp_extract_dir, exist_ok=True)
66
+
67
+ # 3. Extract contents
68
+ with zipfile.ZipFile(zip_path, 'r') as zip_ref:
69
+ zip_ref.extractall(temp_extract_dir)
70
+
71
+ # 4. Handle nested directory structure (common with zip creation)
72
+ extracted_contents = os.listdir(temp_extract_dir)
73
+ if len(extracted_contents) == 1 and os.path.isdir(os.path.join(temp_extract_dir, extracted_contents[0])):
74
+ final_model_dir = os.path.join(temp_extract_dir, extracted_contents[0])
75
+ shutil.move(final_model_dir, base_extract_dir)
76
+ shutil.rmtree(temp_extract_dir)
77
+ else:
78
+ os.rename(temp_extract_dir, base_extract_dir)
79
+
80
+ return base_extract_dir
81
+ except Exception as e:
82
+ print(f"Error during model prep: {e}")
83
+ return ""
84
+
85
+ # Global initialization on startup
86
+ if AUTOGLUON_IMPORTED:
87
+ try:
88
+ predictor_dir = _prepare_predictor_dir(MODEL_REPO_ID, ZIP_FILENAME, MODEL_DIR_NAME)
89
+ if predictor_dir:
90
+ PREDICTOR = MultiModalPredictor.load(predictor_dir)
91
+ MODEL_STATUS = f"βœ… Model Active: {MODEL_REPO_ID}"
92
+ else:
93
+ MODEL_STATUS = "❌ Initialization failed during extraction/download."
94
+ except Exception as e:
95
+ PREDICTOR = None
96
+ MODEL_STATUS = f"❌ Error loading model: {type(e).__name__} (Load Fail)"
97
+ else:
98
+ MODEL_STATUS = "❌ AutoGluon not imported. Classification tab is disabled."
99
+
100
+ # Core Lanternfly classification function
101
+ def classify_image(img: Image.Image):
102
+ """Predicts the class of the input image using the loaded AutoGluon model."""
103
+ if PREDICTOR is None:
104
+ return "MODEL FAILED TO LOAD", 0.0, 0.0, 0.0
105
+
106
+ if img is None:
107
+ return "NO IMAGE PROVIDED", 0.0, 0.0, 0.0
108
+
109
+ final_output = [0.0] * len(CLASSIFICATION_LABELS)
110
+ final_result = "PREDICTION FAILED"
111
+
112
+ # Save image to a temporary path for AutoGluon to read
113
+ temp_dir = pathlib.Path(tempfile.mkdtemp())
114
+ img_path = temp_dir / "input.png"
115
+ img.save(img_path)
116
+
117
+ try:
118
+ df_path = pd.DataFrame({"image": [str(img_path)]})
119
+ proba_df = PREDICTOR.predict_proba(df_path, as_pandas=True)
120
+ scores_dict = proba_df.iloc[0].to_dict()
121
+
122
+ # Map scores to the expected order of CLASSIFICATION_LABELS
123
+ scores = [float(scores_dict.get(label, 0.0))
124
+ for label in CLASSIFICATION_LABELS]
125
+
126
+ predicted_class_label = max(scores_dict, key=scores_dict.get)
127
+ final_output = scores
128
+ final_result = f"Predicted Class: **{predicted_class_label}**"
129
+
130
+ except Exception as e:
131
+ final_result = f"CRITICAL PREDICTION FAILURE: {type(e).__name__} - Check AutoGluon dependencies."
132
+ finally:
133
+ shutil.rmtree(temp_dir)
134
+
135
+ return final_result, final_output[0], final_output[1], final_output[2]
136
+
137
+
138
+ # --- 2. GPS CAPTURE & SAVE CONFIGURATION & FUNCTIONS ---
139
+ HF_TOKEN = os.getenv("HF_TOKEN") or os.getenv("HF_TOKEN_SPACE")
140
+ DATASET_REPO = os.getenv("DATASET_REPO", "rlogh/lanternfly-data")
141
+ METADATA_PATH = "metadata/entries.jsonl"
142
+ api = None
143
+
144
+ if HF_TOKEN and DATASET_REPO:
145
+ api = HfApi(token=HF_TOKEN)
146
+ try:
147
+ # Ensure the dataset repository exists
148
+ create_repo(DATASET_REPO, repo_type="dataset", exist_ok=True, token=HF_TOKEN)
149
+ GPS_SAVE_STATUS = "βœ… Dataset saving enabled."
150
+ except Exception as e:
151
+ GPS_SAVE_STATUS = f"⚠️ Error creating dataset repo: {e}"
152
+ api = None
153
+ else:
154
+ GPS_SAVE_STATUS = "⚠️ Running in test mode - no HF credentials (dataset saving disabled)."
155
+
156
+
157
+ def get_gps_js():
158
+ """JavaScript function to be injected into Gradio to capture GPS coordinates."""
159
+ return """
160
+ () => {
161
+ // Look for the hidden textbox element by its ID
162
+ const textarea = document.querySelector('#hidden_gps_input textarea');
163
+ if (!textarea) return;
164
+
165
+ if (!navigator.geolocation) {
166
+ textarea.value = JSON.stringify({error: "Geolocation not supported by this browser/device."});
167
+ textarea.dispatchEvent(new Event('input', { bubbles: true }));
168
+ return;
169
+ }
170
+ // Request current position
171
+ navigator.geolocation.getCurrentPosition(
172
+ function(position) {
173
+ const data = {
174
+ latitude: position.coords.latitude,
175
+ longitude: position.coords.longitude,
176
+ accuracy: position.coords.accuracy,
177
+ timestamp: position.timestamp
178
+ };
179
+ // Write JSON string to the hidden textbox and trigger a change event
180
+ textarea.value = JSON.stringify(data);
181
+ textarea.dispatchEvent(new Event('input', { bubbles: true }));
182
+ },
183
+ function(err) {
184
+ textarea.value = JSON.stringify({ error: err.message });
185
+ textarea.dispatchEvent(new Event('input', { bubbles: true }));
186
+ },
187
+ { enableHighAccuracy: true, timeout: 10000 }
188
+ );
189
+ }
190
+ """
191
+
192
+ def handle_gps_location(json_str):
193
+ """Parses the GPS JSON string and updates the Gradio text boxes."""
194
+ try:
195
+ data = json.loads(json_str)
196
+ if 'error' in data:
197
+ status_msg = f"❌ **GPS Error**: {data['error']}"
198
+ return status_msg, "", "", "", ""
199
+
200
+ lat = str(data.get('latitude', ''))
201
+ lon = str(data.get('longitude', ''))
202
+ accuracy = str(data.get('accuracy', ''))
203
+ timestamp_ms = data.get('timestamp')
204
+
205
+ # Convert timestamp (milliseconds since epoch) to ISO string
206
+ device_ts = ""
207
+ if timestamp_ms and isinstance(timestamp_ms, (int, float)):
208
+ device_ts = datetime.fromtimestamp(timestamp_ms / 1000).isoformat()
209
+
210
+ status_msg = f"βœ… **GPS Captured**: {lat[:8]}, {lon[:8]} (accuracy: {accuracy}m)"
211
+ return status_msg, lat, lon, accuracy, device_ts
212
+
213
+ except Exception as e:
214
+ status_msg = f"❌ **Error parsing GPS data**: {str(e)}"
215
+ return status_msg, "", "", "", ""
216
+
217
+
218
+ def _save_image_to_repo(pil_img: Image.Image, dest_rel_path: str) -> None:
219
+ """Uploads a PIL image into the dataset repo via a memory buffer."""
220
+ img_bytes = io.BytesIO()
221
+ pil_img.save(img_bytes, format="JPEG", quality=90)
222
+ img_bytes.seek(0)
223
+ upload_file(
224
+ path_or_fileobj=img_bytes, path_in_repo=dest_rel_path,
225
+ repo_id=DATASET_REPO, repo_type="dataset", token=HF_TOKEN,
226
+ commit_message=f"Upload image {dest_rel_path}",
227
+ )
228
+
229
+ def _append_jsonl_in_repo(new_row: dict) -> None:
230
+ """Appends a new JSON line to the metadata file in the dataset repo."""
231
+ buf = io.BytesIO()
232
+ existing_lines = []
233
+
234
+ try:
235
+ # 1. Download existing metadata file if it exists
236
+ if file_exists(DATASET_REPO, METADATA_PATH, repo_type="dataset", token=HF_TOKEN):
237
+ local_path = hf_hub_download(
238
+ repo_id=DATASET_REPO, filename=METADATA_PATH,
239
+ repo_type="dataset", token=HF_TOKEN
240
+ )
241
+ with open(local_path, "r", encoding="utf-8") as f:
242
+ existing_lines = f.read().splitlines()
243
+ except Exception:
244
+ # Ignore download failure if the file doesn't exist yet
245
+ pass
246
+
247
+ # 2. Append the new line
248
+ existing_lines.append(json.dumps(new_row, ensure_ascii=False))
249
+ data = "\n".join(existing_lines).encode("utf-8")
250
+ buf.write(data); buf.seek(0)
251
+
252
+ # 3. Upload the updated file
253
+ upload_file(
254
+ path_or_fileobj=buf, path_in_repo=METADATA_PATH,
255
+ repo_id=DATASET_REPO, repo_type="dataset", token=HF_TOKEN,
256
+ commit_message=f"Append 1 entry at {datetime.now().isoformat()}Z",
257
+ )
258
+
259
+
260
+ def save_to_dataset(image, lat, lon, accuracy_m, device_ts):
261
+ """Validates data and saves the image and metadata to the Hugging Face dataset."""
262
+ try:
263
+ if image is None:
264
+ return "❌ **Error**: No image captured.", ""
265
+ if not lat or not lon:
266
+ return "❌ **Error**: GPS coordinates missing.", ""
267
+
268
+ # Convert image to PIL if it's a numpy array (common in Gradio)
269
+ if isinstance(image, np.ndarray):
270
+ image = Image.fromarray(image.astype('uint8'))
271
+
272
+ # --- Test Mode ---
273
+ if not api:
274
+ img_id = str(uuid.uuid4())
275
+ timestamp_str = datetime.now().strftime("%Y%m%d_%H%M%S")
276
+ row = {"id": img_id, "image": f"test_{timestamp_str}_{img_id[:8]}.jpg",
277
+ "latitude": float(lat), "longitude": float(lon),
278
+ "mode": "test"}
279
+ status = f"πŸ” **Test Mode**: Data validated successfully! Sample {img_id[:8]}"
280
+ preview = json.dumps(row, indent=2)
281
+ return status, preview
282
+
283
+ # --- Production Mode ---
284
+ sample_id = str(uuid.uuid4())
285
+ timestamp_str = datetime.now().strftime("%Y%m%d_%H%M%S")
286
+ image_rel_path = f"images/lanternfly_{timestamp_str}_{sample_id[:8]}.jpg"
287
+
288
+ # 1. Save image
289
+ _save_image_to_repo(image, image_rel_path)
290
+ server_ts_utc = datetime.now().isoformat() + "Z"
291
+
292
+ # 2. Prepare and save metadata
293
+ row = {
294
+ "id": sample_id, "image": image_rel_path,
295
+ "latitude": float(lat), "longitude": float(lon),
296
+ "accuracy_m": float(accuracy_m) if accuracy_m else None,
297
+ "device_timestamp": device_ts if device_ts else None,
298
+ "server_timestamp_utc": server_ts_utc,
299
+ }
300
+ _append_jsonl_in_repo(row)
301
+
302
+ status = f"βœ… **Success!** Saved to dataset! Image: `{image_rel_path}`"
303
+ preview = json.dumps(row, indent=2)
304
+ return status, preview
305
+
306
+ except Exception as e:
307
+ error_msg = f"❌ **Error during save**: {str(e)}"
308
+ return error_msg, ""
309
+
310
+ # --- 3. KDE CONFIGURATION & FUNCTIONS (UPDATED FOR LIVE DATA) ---
311
+ HUGGINGFACE_DATA_REPO = "rlogh/lanternfly-data"
312
+ METADATA_PATH = "metadata/entries.jsonl"
313
+
314
+ # Define the Pittsburgh coordinate range (used for visualization extent)
315
+ pittsburgh_lat_min, pittsburgh_lat_max = 40.3, 40.6
316
+ pittsburgh_lon_min, pittsburgh_lon_max = -80.2, -79.8
317
+
318
+
319
+ def load_lanternfly_data_from_hf():
320
+ """Downloads the JSONL metadata file from HF and extracts latitude/longitude."""
321
+ try:
322
+ # Download the file
323
+ local_path = hf_hub_download(
324
+ repo_id=HUGGINGFACE_DATA_REPO,
325
+ filename=METADATA_PATH,
326
+ repo_type="dataset"
327
+ )
328
+
329
+ latitudes = []
330
+ longitudes = []
331
+
332
+ # Parse the JSONL file
333
+ with open(local_path, 'r', encoding='utf-8') as f:
334
+ for line in f:
335
+ try:
336
+ data = json.loads(line)
337
+ lat = data.get('latitude')
338
+ lon = data.get('longitude')
339
+
340
+ if isinstance(lat, (float, int)) and isinstance(lon, (float, int)):
341
+ # Filter points to be within the Pittsburgh area for relevance
342
+ if pittsburgh_lat_min <= lat <= pittsburgh_lat_max and \
343
+ pittsburgh_lon_min <= lon <= pittsburgh_lon_max:
344
+ latitudes.append(lat)
345
+ longitudes.append(lon)
346
+
347
+ except json.JSONDecodeError:
348
+ continue # Skip malformed lines
349
+
350
+ if not latitudes:
351
+ return None, None, "Error: Found no valid coordinates in the dataset."
352
+
353
+ return np.array(latitudes), np.array(longitudes), None
354
+
355
+ except Exception as e:
356
+ return None, None, f"Error downloading or parsing HF data: {type(e).__name__} - {e}"
357
+
358
+
359
+ def calculate_kde_and_points():
360
+ """Loads data, calculates KDE, and prepares data for visualization."""
361
+ latitudes, longitudes, error = load_lanternfly_data_from_hf()
362
+
363
+ if error:
364
+ return None, None, None, error
365
+
366
+ try:
367
+ # Combine coordinates into a 2D array for KDE
368
+ coordinates = np.vstack([longitudes, latitudes])
369
+
370
+ # Compute the kernel density estimate
371
+ kde_object = gaussian_kde(coordinates)
372
+
373
+ return latitudes, longitudes, kde_object, None
374
+
375
+ except Exception as e:
376
+ return None, None, None, f"Error calculating KDE: {type(e).__name__} - {e}"
377
+
378
+
379
+ def plot_kde_and_points(min_lat, max_lat, min_lon, max_lon, original_latitudes, original_longitudes, kde_object):
380
+ """Generates an interactive Folium map with points colored by KDE density."""
381
+ # --- Folium Interactive Map with Colored Points ---
382
+
383
+ # 1. Calculate density at each original point
384
+ original_coordinates = np.vstack([original_longitudes, original_latitudes])
385
+ density_at_original_points = kde_object(original_coordinates)
386
+ # Normalize density for coloring
387
+ density_normalized = (density_at_original_points - density_at_original_points.min()) / (density_at_original_points.max() - density_at_original_points.min() + 1e-9)
388
+
389
+ # 2. Setup map
390
+ colormap = cm.get_cmap('viridis')
391
+ map_center_lat = np.mean(original_latitudes)
392
+ map_center_lon = np.mean(original_longitudes)
393
+ m_colored_points = folium.Map(location=[map_center_lat, map_center_lon], zoom_start=12)
394
+
395
+ # 3. Add points to map
396
+ for lat, lon, density_norm in zip(original_latitudes, original_longitudes, density_normalized):
397
+ color = matplotlib.colors.rgb2hex(colormap(density_norm))
398
+ folium.CircleMarker(
399
+ location=[lat, lon], radius=5, color=color, fill=True, fill_color=color, fill_opacity=0.7,
400
+ tooltip=f"Lat: {lat:.5f}, Lon: {lon:.5f}"
401
+ ).add_to(m_colored_points)
402
+
403
+ colored_points_map_html = m_colored_points._repr_html_()
404
+
405
+ # The original plot_kde_and_points also returned a Matplotlib image, but the Gradio tab was updated to remove it.
406
+ # We return None for the image output to match the function signature expected by Gradio.
407
+ return None, colored_points_map_html
408
+
409
+
410
+ def update_visualization_live():
411
+ """Main visualization function for the Gradio interface."""
412
+ latitudes, longitudes, kde_object, error = calculate_kde_and_points()
413
+
414
+ if error:
415
+ # Return blank outputs and the error message
416
+ return None, f"<h1>{error}</h1>", f"Error: {error}"
417
+
418
+ # Use the predefined Pittsburgh coordinate bounds for the map extent
419
+ pil_image, colored_points_map_html = plot_kde_and_points(
420
+ pittsburgh_lat_min, pittsburgh_lat_max, pittsburgh_lon_min, pittsburgh_lon_max,
421
+ latitudes, longitudes, kde_object
422
+ )
423
+
424
+ # pil_image is None, but the function signature must match the output count
425
+ return pil_image, colored_points_map_html, ""
426
+
427
+ # --- 4. GRADIO INTERFACE (COMBINED) ---
428
+
429
+ with gr.Blocks(title="Unified Spatial/Classification Tool") as app:
430
+
431
+ gr.Markdown("# Unified Spatial Data and Image Classification Tool")
432
+
433
+ with gr.Tab("1. Field Capture & Classification"):
434
+ gr.Markdown(f"## πŸ“Έ Lanternfly Classification and GPS Data Capture")
435
+ gr.Markdown(f"**Model Status**: {MODEL_STATUS}")
436
+ gr.Markdown(f"**GPS Save Status**: {GPS_SAVE_STATUS}")
437
+
438
+ with gr.Row():
439
+ # --- Column 1: Image Input & Classification Output ---
440
+ with gr.Column(scale=1):
441
+ image_in = gr.Image(
442
+ type="pil", label="1. Upload or Capture Image",
443
+ value="https://placehold.co/224x224/ff6347/ffffff?text=Lanternfly",
444
+ sources=["upload", "webcam"]
445
+ )
446
+ # Disable classification button if model failed to load
447
+ run_classify_btn = gr.Button("πŸ” Run Classification", variant="primary", interactive=PREDICTOR is not None)
448
+
449
+ gr.Markdown("### Classification Result")
450
+ final_result_box = gr.Textbox(label="Prediction Result", interactive=False)
451
+ with gr.Row():
452
+ conf_0 = gr.Number(label=f"Confidence: {CLASSIFICATION_LABELS[0]}", interactive=False)
453
+ conf_1 = gr.Number(label=f"Confidence: {CLASSIFICATION_LABELS[1]}", interactive=False)
454
+ conf_2 = gr.Number(label=f"Confidence: {CLASSIFICATION_LABELS[2]}", interactive=False)
455
+
456
+
457
+ # --- Column 2: GPS Capture & Save ---
458
+ with gr.Column(scale=1):
459
+ gr.Markdown("## πŸ“ GPS Data Capture")
460
+ gps_btn = gr.Button("πŸ“ Get GPS", variant="primary")
461
+ # Hidden textbox to receive location data from JavaScript
462
+ hidden_gps_input = gr.Textbox(visible=False, elem_id="hidden_gps_input")
463
+
464
+ with gr.Row():
465
+ lat_box = gr.Textbox(label="Latitude", interactive=True)
466
+ lon_box = gr.Textbox(label="Longitude", interactive=True)
467
+ with gr.Row():
468
+ accuracy_box = gr.Textbox(label="Accuracy (m)", interactive=True)
469
+ device_ts_box = gr.Textbox(label="Device Timestamp", interactive=True)
470
+
471
+ # Disable save button if HF credentials are missing
472
+ save_btn = gr.Button("πŸ’Ύ Save Image & GPS to Dataset", variant="secondary", interactive=api is not None)
473
+
474
+ gr.Markdown("### Save Status & Preview")
475
+ gps_status = gr.Markdown("πŸ”„ **Ready for GPS capture and saving.**")
476
+ preview = gr.JSON(label="Preview JSON")
477
+
478
+ # Handlers for Classification
479
+ if PREDICTOR is not None:
480
+ run_classify_btn.click(
481
+ fn=classify_image,
482
+ inputs=[image_in],
483
+ outputs=[final_result_box, conf_0, conf_1, conf_2]
484
+ )
485
+
486
+ # Handlers for GPS
487
+ gps_btn.click(
488
+ fn=None, inputs=[], outputs=[], js=get_gps_js()
489
+ )
490
+ hidden_gps_input.change(
491
+ fn=handle_gps_location,
492
+ inputs=[hidden_gps_input],
493
+ outputs=[gps_status, lat_box, lon_box, accuracy_box, device_ts_box]
494
+ )
495
+ save_btn.click(
496
+ fn=save_to_dataset,
497
+ inputs=[image_in, lat_box, lon_box, accuracy_box, device_ts_box],
498
+ outputs=[gps_status, preview]
499
+ )
500
+
501
+
502
+ with gr.Tab("2. Spatial Data Visualization (KDE)"):
503
+ gr.Markdown("## πŸ—ΊοΈ Kernel Density Estimation of Lanternfly Sightings")
504
+ gr.Markdown(f"**Data Source**: {HUGGINGFACE_DATA_REPO} - Automatically loaded from `metadata/entries.jsonl`")
505
+
506
+ refresh_btn = gr.Button("πŸ”„ Refresh Map from Hugging Face Data", variant="primary")
507
+ kde_error_box = gr.Textbox(label="Error/Debug Message", visible=False)
508
+
509
+ with gr.Row():
510
+ interactive_map_out = gr.HTML(label="Interactive Points Map Colored by KDE (Folium)")
511
+
512
+ # Placeholder for the removed Matplotlib image output (to keep update_visualization_live signature intact)
513
+ matplotlib_placeholder = gr.State(value=None)
514
+
515
+ # Handler for Refresh Button
516
+ refresh_btn.click(
517
+ fn=update_visualization_live,
518
+ inputs=[],
519
+ outputs=[matplotlib_placeholder, interactive_map_out, kde_error_box]
520
+ )
521
+
522
+ # Trigger initial load
523
+ app.load(
524
+ fn=update_visualization_live,
525
+ inputs=[],
526
+ outputs=[matplotlib_placeholder, interactive_map_out, kde_error_box]
527
+ )
528
+
529
+
530
+ # Launch the combined app
531
+ if __name__ == "__main__":
532
+ app.launch()