Spaces:
Sleeping
Sleeping
| import gradio as gr | |
| import json | |
| import pandas as pd | |
| import plotly.express as px | |
| import tempfile | |
| import numpy as np | |
| import matplotlib.pyplot as plt | |
| # ----------------------------- | |
| # Haversine distance (km) | |
| # ----------------------------- | |
| def haversine(lat1, lon1, lat2, lon2): | |
| R = 6371.0 | |
| phi1, phi2 = np.radians(lat1), np.radians(lat2) | |
| dphi = np.radians(lat2 - lat1) | |
| dlambda = np.radians(lon2 - lon1) | |
| a = np.sin(dphi / 2) ** 2 + np.cos(phi1) * np.cos(phi2) * np.sin(dlambda / 2) ** 2 | |
| return 2 * R * np.arcsin(np.sqrt(a)) | |
| # ----------------------------- | |
| # Angle difference helper | |
| # ----------------------------- | |
| def angle_diff(a, b): | |
| diff = abs(a - b) | |
| return min(diff, 360 - diff) | |
| # ----------------------------- | |
| # Robust timestamp parser | |
| # ----------------------------- | |
| def parse_timestamp(series): | |
| ts = pd.to_datetime(series, errors="coerce", utc=True) | |
| mask = ts.isna() | |
| if mask.any(): | |
| s = series[mask].dropna() | |
| numeric = pd.to_numeric(s, errors="coerce") | |
| if not numeric.empty: | |
| if numeric.median() > 1e12: | |
| ts.loc[mask] = pd.to_datetime(numeric, unit="ms", errors="coerce", utc=True) | |
| else: | |
| ts.loc[mask] = pd.to_datetime(numeric, unit="s", errors="coerce", utc=True) | |
| return ts | |
| # ----------------------------- | |
| # Load MMSIs | |
| # ----------------------------- | |
| def load_geojson(geojson_file): | |
| if geojson_file is None: | |
| return gr.update(choices=[]) | |
| with open(geojson_file.name, "r") as f: | |
| data = json.load(f) | |
| mmsis = sorted({ | |
| str(f.get("properties", {}).get("mmsi")) | |
| for f in data.get("features", []) | |
| if f.get("properties", {}).get("mmsi") is not None | |
| }) | |
| return gr.update(choices=mmsis) | |
| # ----------------------------- | |
| # Anomaly plot | |
| # ----------------------------- | |
| def create_anomaly_plot(df): | |
| df = df.sort_values("last_seen") | |
| fig, ax = plt.subplots(figsize=(5, 5)) | |
| ax.plot( | |
| df["longitude"], | |
| df["latitude"], | |
| marker='.', | |
| linestyle='-', | |
| alpha=0.7, | |
| color='black' | |
| ) | |
| anomal = df[df["anomaly"] == True] | |
| if not anomal.empty: | |
| ax.scatter( | |
| anomal["longitude"], | |
| anomal["latitude"], | |
| color="blue", | |
| s=60, | |
| label="Anomaly" | |
| ) | |
| ax.legend() | |
| ax.set_title("Route + Anomalies") | |
| ax.axis('equal') | |
| ax.axis('off') | |
| return fig | |
| # ----------------------------- | |
| # Clean route plot (UPDATED) | |
| # ----------------------------- | |
| def create_clean_plot(df): | |
| df = df.sort_values("last_seen") | |
| fig, ax = plt.subplots(figsize=(5, 5)) | |
| ax.plot( | |
| df["longitude"], | |
| df["latitude"], | |
| marker='.', | |
| linestyle='-', | |
| alpha=0.7, | |
| color='red' # β updated color | |
| ) | |
| #ax.set_title("Route") # β removed any extra wording | |
| ax.axis('equal') | |
| ax.axis('off') | |
| return fig | |
| # ----------------------------- | |
| # Main processing | |
| # ----------------------------- | |
| def filter_vessel(geojson_file, mmsi): | |
| if geojson_file is None: | |
| raise gr.Error("Upload GeoJSON first") | |
| if not mmsi: | |
| raise gr.Error("Select MMSI") | |
| with open(geojson_file.name, "r") as f: | |
| data = json.load(f) | |
| rows = [] | |
| for ftr in data.get("features", []): | |
| props = ftr.get("properties", {}) | |
| if str(props.get("mmsi")) != str(mmsi): | |
| continue | |
| geom = ftr.get("geometry", {}) | |
| coords = geom.get("coordinates") | |
| if not coords: | |
| continue | |
| lon, lat = coords if geom.get("type") == "Point" else (coords[0], coords[1]) | |
| rows.append({ | |
| "mmsi": props.get("mmsi"), | |
| "latitude": lat, | |
| "longitude": lon, | |
| "speed_knots": props.get("speed_knots"), | |
| "course_deg": props.get("course_deg"), | |
| "last_seen": props.get("timestamp") or props.get("last_seen"), | |
| }) | |
| df = pd.DataFrame(rows) | |
| if df.empty: | |
| raise gr.Error("No data for selected MMSI") | |
| # ----------------------------- | |
| # timestamp fix | |
| # ----------------------------- | |
| df["last_seen"] = df["last_seen"].astype(str).str.strip() | |
| df["last_seen"] = parse_timestamp(df["last_seen"]) | |
| df = df.dropna(subset=["last_seen"]).sort_values("last_seen").reset_index(drop=True) | |
| if df.empty: | |
| raise gr.Error("No valid timestamps after parsing") | |
| # ----------------------------- | |
| # features | |
| # ----------------------------- | |
| df["speed_knots"] = df["speed_knots"].fillna(0) | |
| df["speed_kmh_reported"] = df["speed_knots"] * 1.852 | |
| df["speed_anomaly"] = False | |
| df["teleportation_anomaly"] = False | |
| df["turn_anomaly"] = False | |
| df["anomaly"] = False | |
| if len(df) > 1: | |
| df["prev_lat"] = df["latitude"].shift(1) | |
| df["prev_lon"] = df["longitude"].shift(1) | |
| df["prev_time"] = df["last_seen"].shift(1) | |
| df["prev_course"] = df["course_deg"].shift(1) | |
| # distance | |
| df["distance_km"] = df.apply( | |
| lambda r: haversine(r["prev_lat"], r["prev_lon"], r["latitude"], r["longitude"]) | |
| if pd.notnull(r["prev_lat"]) else 0, | |
| axis=1, | |
| ) | |
| # time fix | |
| df["time_hours"] = (df["last_seen"] - df["prev_time"]).dt.total_seconds() / 3600 | |
| df.loc[df["time_hours"] < 0.001, "time_hours"] = np.nan | |
| # computed speed | |
| df["computed_speed_kmh"] = df["distance_km"] / df["time_hours"] | |
| df["computed_speed_kmh"] = df["computed_speed_kmh"].replace([np.inf, -np.inf], np.nan) | |
| df.loc[df["computed_speed_kmh"] > 1000, "computed_speed_kmh"] = np.nan | |
| # SPEED anomaly | |
| df["speed_anomaly"] = df["speed_kmh_reported"] > 70 | |
| # TELEPORT anomaly | |
| df["teleportation_anomaly"] = df["computed_speed_kmh"] > 120 | |
| # TURN anomaly | |
| df["turn_angle"] = df.apply( | |
| lambda r: angle_diff(r["course_deg"], r["prev_course"]) | |
| if pd.notnull(r["prev_course"]) and pd.notnull(r["course_deg"]) | |
| else np.nan, | |
| axis=1 | |
| ) | |
| moving = df["speed_kmh_reported"] > 5 | |
| df["turn_anomaly"] = (df["turn_angle"] > 90) & moving | |
| df["anomaly"] = ( | |
| df["speed_anomaly"] | | |
| df["teleportation_anomaly"] | | |
| df["turn_anomaly"] | |
| ) | |
| # ----------------------------- | |
| # Plotly map | |
| # ----------------------------- | |
| df["label"] = df["anomaly"].map({True: "Anomaly", False: "Normal"}) | |
| fig_map = px.scatter_mapbox( | |
| df, | |
| lat="latitude", | |
| lon="longitude", | |
| color="label", | |
| hover_data=[ | |
| "speed_knots", | |
| "speed_kmh_reported", | |
| "computed_speed_kmh", | |
| "turn_angle", | |
| "last_seen" | |
| ], | |
| zoom=5, | |
| height=700, | |
| ) | |
| fig_map.add_trace( | |
| dict( | |
| type="scattermapbox", | |
| mode="lines+markers", | |
| lat=df["latitude"], | |
| lon=df["longitude"], | |
| line=dict(width=2, color="black"), | |
| name="Route", | |
| ) | |
| ) | |
| fig_map.update_layout( | |
| mapbox_style="open-street-map", | |
| margin=dict(l=0, r=0, t=30, b=0), | |
| title=f"MMSI {mmsi} - AIS Anomaly Detection", | |
| ) | |
| # ----------------------------- | |
| # matplotlib plots | |
| # ----------------------------- | |
| fig_anomaly = create_anomaly_plot(df) | |
| fig_clean = create_clean_plot(df) | |
| # ----------------------------- | |
| # export | |
| # ----------------------------- | |
| json_output = df.to_json(orient="records", indent=2, date_format="iso") | |
| tmp_file = tempfile.NamedTemporaryFile(suffix=".geojson", delete=False) | |
| with open(tmp_file.name, "w") as f: | |
| json.dump(data, f, indent=2) | |
| return ( | |
| fig_map, | |
| df, | |
| json_output, | |
| fig_anomaly, | |
| fig_clean, | |
| tmp_file.name, | |
| "OK", | |
| "OK", | |
| "OK", | |
| f"Total points: {len(df)}" | |
| ) | |
| # ----------------------------- | |
| # UI | |
| # ----------------------------- | |
| with gr.Blocks(title="AIS Anomaly Detection") as demo: | |
| gr.Markdown("# π’ AIS Vessel Route + Clean Plot Separation") | |
| with gr.Row(): | |
| geojson_file = gr.File(label="Upload GeoJSON") | |
| mmsi_dropdown = gr.Dropdown(label="Select MMSI", choices=[], interactive=True) | |
| geojson_file.change(load_geojson, geojson_file, mmsi_dropdown) | |
| run_btn = gr.Button("Analyze Vessel", variant="primary") | |
| map_out = gr.Plot() | |
| table_out = gr.Dataframe() | |
| json_out = gr.Code(language="json") | |
| mpl_anomaly_out = gr.Plot(label="Route + Anomalies") | |
| mpl_clean_out = gr.Plot(label="Route") | |
| download_out = gr.File() | |
| speed_out = gr.Textbox(label="β‘ Speed Anomaly") | |
| teleport_out = gr.Textbox(label="π§ Teleportation Anomaly") | |
| turn_out = gr.Textbox(label="π Turn Anomaly") | |
| summary_out = gr.Textbox(label="π Summary") | |
| run_btn.click( | |
| filter_vessel, | |
| [geojson_file, mmsi_dropdown], | |
| [ | |
| map_out, | |
| table_out, | |
| json_out, | |
| mpl_anomaly_out, | |
| mpl_clean_out, | |
| download_out, | |
| speed_out, | |
| teleport_out, | |
| turn_out, | |
| summary_out | |
| ], | |
| ) | |
| if __name__ == "__main__": | |
| ## LAunch | |
| demo.launch() |