From 7be5edd98319cea6accbe3206ebc2f0691079c71 Mon Sep 17 00:00:00 2001 From: Claude Dev Date: Thu, 26 Mar 2026 00:03:01 +0100 Subject: [PATCH] Feature: Overpass Turbo + VLM-Bildanalyse Overpass Turbo Integration: - POST /api/overpass/query: OverpassQL-Proxy mit Caching, Rate-Limiting, Fallback - GET /api/overpass/templates: 30 OSINT-Templates in 6 Kategorien - Frontend: Query-Editor Panel mit Template-Browser, Viewport-BBox - Layer: Nodes/Ways/Relations Rendering auf CesiumJS mit OSM-Tag InfoBox VLM-Bildanalyse: - POST /api/vlm/analyze: Bildupload -> Claude Code headless (Sonnet) -> GEOINT-Analyse - POST /api/vlm/generate-queries: VLM-Erkennungen -> OverpassQL - Frontend: Drag&Drop Upload, Zwei-Stufen-Workflow (Analyse -> Overpass-Suche) - Bild-Resize (Pillow), asyncio Subprocess, Semaphore (max 1 parallel) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/data_overpass.py | 451 +++++++++++++++++++++++++++++++++++ src/data_vlm.py | 300 +++++++++++++++++++++++ src/main.py | 4 + static/css/globe.css | 197 +++++++++++++++ static/index.html | 21 ++ static/js/app.js | 8 + static/js/layers/overpass.js | 211 ++++++++++++++++ static/js/ui/overpass.js | 207 ++++++++++++++++ static/js/ui/vlm.js | 328 +++++++++++++++++++++++++ 9 files changed, 1727 insertions(+) create mode 100644 src/data_overpass.py create mode 100644 src/data_vlm.py create mode 100644 static/js/layers/overpass.js create mode 100644 static/js/ui/overpass.js create mode 100644 static/js/ui/vlm.js diff --git a/src/data_overpass.py b/src/data_overpass.py new file mode 100644 index 0000000..54da278 --- /dev/null +++ b/src/data_overpass.py @@ -0,0 +1,451 @@ +"""Overpass Turbo: OverpassQL-Queries gegen OpenStreetMap ausfuehren.""" +import logging +import time +import hashlib +import httpx +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, Field + +logger = logging.getLogger("globe.overpass") +router = APIRouter() + +# --- Rate Limiting --- +_last_request_time = 0.0 +_RATE_LIMIT_SECONDS = 10 # Overpass Fair-Use Policy + +# --- Cache --- +_cache = {} +_CACHE_TTL = 300 # 5 Minuten +_MAX_CACHE_ENTRIES = 50 + +# --- Overpass API --- +_OVERPASS_URL = "https://overpass-api.de/api/interpreter" +_OVERPASS_FALLBACK = "https://overpass.kumi.systems/api/interpreter" +_MAX_ELEMENTS = 5000 +_TIMEOUT = 60 + + +class OverpassRequest(BaseModel): + query: str = Field(..., max_length=4000) + bbox: list[float] | None = Field(None, min_length=4, max_length=4) + + +# --- Template-Bibliothek --- +_TEMPLATES = [ + # === Militaerisch === + { + "id": "mil_bases", + "name": "Militaerbasen", + "category": "military", + "description": "Militaerische Einrichtungen und Stuetzpunkte", + "color": "#ff4444", + "query": "[out:json][timeout:25];\n(\n node[\"military\"]({{bbox}});\n way[\"military\"]({{bbox}});\n relation[\"military\"]({{bbox}});\n);\nout center body;", + }, + { + "id": "mil_airfields", + "name": "Militaerflughaefen", + "category": "military", + "description": "Militaerische Flugplaetze und Luftwaffenstuetzpunkte", + "color": "#ff4444", + "query": "[out:json][timeout:25];\n(\n node[\"aeroway\"=\"aerodrome\"][\"military\"]({{bbox}});\n way[\"aeroway\"=\"aerodrome\"][\"military\"]({{bbox}});\n);\nout center body;", + }, + { + "id": "mil_naval", + "name": "Marinestuetzpunkte", + "category": "military", + "description": "Marinehaefen und Flottenbasen", + "color": "#ff4444", + "query": "[out:json][timeout:25];\n(\n node[\"military\"=\"naval_base\"]({{bbox}});\n way[\"military\"=\"naval_base\"]({{bbox}});\n node[\"landuse\"=\"military\"][\"name\"~\"[Nn]av|[Ff]leet|[Mm]arine|[Ff]lott\"]({{bbox}});\n);\nout center body;", + }, + { + "id": "mil_radar", + "name": "Radarstationen", + "category": "military", + "description": "Militaerische und zivile Radaranlagen", + "color": "#ff4444", + "query": "[out:json][timeout:25];\n(\n node[\"man_made\"=\"surveillance\"][\"surveillance:type\"=\"radar\"]({{bbox}});\n node[\"military\"=\"radar\"]({{bbox}});\n node[\"radar\"]({{bbox}});\n);\nout center body;", + }, + { + "id": "mil_range", + "name": "Uebungsplaetze", + "category": "military", + "description": "Militaerische Uebungsgelaende und Schiessplaetze", + "color": "#ff4444", + "query": "[out:json][timeout:25];\n(\n node[\"military\"=\"range\"]({{bbox}});\n way[\"military\"=\"range\"]({{bbox}});\n relation[\"military\"=\"range\"]({{bbox}});\n node[\"military\"=\"training_area\"]({{bbox}});\n way[\"military\"=\"training_area\"]({{bbox}});\n);\nout center body;", + }, + { + "id": "mil_bunker", + "name": "Bunker / Munitionslager", + "category": "military", + "description": "Bunkeranlagen und Munitionsdepots", + "color": "#ff4444", + "query": "[out:json][timeout:25];\n(\n node[\"military\"=\"bunker\"]({{bbox}});\n way[\"military\"=\"bunker\"]({{bbox}});\n node[\"military\"=\"ammunition\"]({{bbox}});\n way[\"military\"=\"ammunition\"]({{bbox}});\n);\nout center body;", + }, + # === Transport === + { + "id": "tr_airports", + "name": "Flughaefen", + "category": "transport", + "description": "Zivile und gemischte Flughaefen", + "color": "#4499ff", + "query": "[out:json][timeout:25];\n(\n node[\"aeroway\"=\"aerodrome\"]({{bbox}});\n way[\"aeroway\"=\"aerodrome\"]({{bbox}});\n);\nout center body;", + }, + { + "id": "tr_ports", + "name": "Haefen", + "category": "transport", + "description": "See- und Binnenhaefen", + "color": "#4499ff", + "query": "[out:json][timeout:25];\n(\n node[\"harbour\"]({{bbox}});\n way[\"harbour\"]({{bbox}});\n node[\"landuse\"=\"port\"]({{bbox}});\n way[\"landuse\"=\"port\"]({{bbox}});\n);\nout center body;", + }, + { + "id": "tr_railways", + "name": "Bahnhoefe", + "category": "transport", + "description": "Bahnhoefe und Haltestellen", + "color": "#4499ff", + "query": "[out:json][timeout:25];\n(\n node[\"railway\"=\"station\"]({{bbox}});\n node[\"railway\"=\"halt\"]({{bbox}});\n);\nout center body;", + }, + { + "id": "tr_bridges", + "name": "Bruecken", + "category": "transport", + "description": "Grosse Bruecken und Uebergaenge", + "color": "#4499ff", + "query": "[out:json][timeout:25];\n(\n way[\"bridge\"=\"yes\"][\"name\"]({{bbox}});\n);\nout center body;", + }, + { + "id": "tr_tunnels", + "name": "Tunnel", + "category": "transport", + "description": "Strassen- und Eisenbahntunnel", + "color": "#4499ff", + "query": "[out:json][timeout:25];\n(\n way[\"tunnel\"=\"yes\"][\"name\"]({{bbox}});\n);\nout center body;", + }, + # === Energie === + { + "id": "en_nuclear", + "name": "Kernkraftwerke", + "category": "energy", + "description": "Atomkraftwerke und Reaktoren", + "color": "#ffdd00", + "query": "[out:json][timeout:25];\n(\n node[\"generator:source\"=\"nuclear\"]({{bbox}});\n way[\"generator:source\"=\"nuclear\"]({{bbox}});\n node[\"plant:source\"=\"nuclear\"]({{bbox}});\n way[\"plant:source\"=\"nuclear\"]({{bbox}});\n);\nout center body;", + }, + { + "id": "en_power", + "name": "Kraftwerke", + "category": "energy", + "description": "Alle Arten von Kraftwerken", + "color": "#ffdd00", + "query": "[out:json][timeout:25];\n(\n node[\"power\"=\"plant\"]({{bbox}});\n way[\"power\"=\"plant\"]({{bbox}});\n relation[\"power\"=\"plant\"]({{bbox}});\n);\nout center body;", + }, + { + "id": "en_substations", + "name": "Umspannwerke", + "category": "energy", + "description": "Elektrische Umspannwerke", + "color": "#ffdd00", + "query": "[out:json][timeout:25];\n(\n node[\"power\"=\"substation\"]({{bbox}});\n way[\"power\"=\"substation\"]({{bbox}});\n);\nout center body;", + }, + { + "id": "en_pipelines", + "name": "Pipelines", + "category": "energy", + "description": "Oel- und Gas-Pipelines", + "color": "#ffdd00", + "query": "[out:json][timeout:25];\n(\n way[\"man_made\"=\"pipeline\"]({{bbox}});\n);\nout center body;", + }, + { + "id": "en_refineries", + "name": "Raffinerien", + "category": "energy", + "description": "Oelraffinerien und Verarbeitungsanlagen", + "color": "#ffdd00", + "query": "[out:json][timeout:25];\n(\n node[\"man_made\"=\"petroleum_well\"]({{bbox}});\n way[\"industrial\"=\"refinery\"]({{bbox}});\n node[\"industrial\"=\"refinery\"]({{bbox}});\n);\nout center body;", + }, + { + "id": "en_dams", + "name": "Staudaemme", + "category": "energy", + "description": "Staudaemme und Talsperren", + "color": "#ffdd00", + "query": "[out:json][timeout:25];\n(\n way[\"waterway\"=\"dam\"]({{bbox}});\n node[\"waterway\"=\"dam\"]({{bbox}});\n);\nout center body;", + }, + { + "id": "en_solar", + "name": "Solarparks", + "category": "energy", + "description": "Photovoltaik-Grossanlagen", + "color": "#ffdd00", + "query": "[out:json][timeout:25];\n(\n way[\"plant:source\"=\"solar\"]({{bbox}});\n way[\"generator:source\"=\"solar\"][\"generator:output:electricity\"]({{bbox}});\n);\nout center body;", + }, + { + "id": "en_wind", + "name": "Windparks", + "category": "energy", + "description": "Windkraftanlagen", + "color": "#ffdd00", + "query": "[out:json][timeout:25];\n(\n node[\"generator:source\"=\"wind\"]({{bbox}});\n way[\"plant:source\"=\"wind\"]({{bbox}});\n);\nout center body;", + }, + { + "id": "en_storage", + "name": "Tanklager", + "category": "energy", + "description": "Oel- und Gastanklager, Speichertanks", + "color": "#ffdd00", + "query": "[out:json][timeout:25];\n(\n node[\"man_made\"=\"storage_tank\"]({{bbox}});\n way[\"man_made\"=\"storage_tank\"]({{bbox}});\n);\nout center body;", + }, + # === Kommunikation === + { + "id": "com_towers", + "name": "Sendemasten", + "category": "communication", + "description": "Funk- und Fernmeldetuerme", + "color": "#e040fb", + "query": "[out:json][timeout:25];\n(\n node[\"man_made\"=\"mast\"][\"tower:type\"=\"communication\"]({{bbox}});\n node[\"man_made\"=\"tower\"][\"tower:type\"=\"communication\"]({{bbox}});\n);\nout center body;", + }, + { + "id": "com_datacenters", + "name": "Rechenzentren", + "category": "communication", + "description": "Datenzentren und Serverfarmen", + "color": "#e040fb", + "query": "[out:json][timeout:25];\n(\n node[\"telecom\"=\"data_center\"]({{bbox}});\n way[\"telecom\"=\"data_center\"]({{bbox}});\n node[\"building\"=\"data_centre\"]({{bbox}});\n way[\"building\"=\"data_centre\"]({{bbox}});\n);\nout center body;", + }, + { + "id": "com_satellite", + "name": "Satellitenbodenstationen", + "category": "communication", + "description": "Satellitenkommunikations-Anlagen", + "color": "#e040fb", + "query": "[out:json][timeout:25];\n(\n node[\"man_made\"=\"antenna\"][\"antenna:type\"=\"dish\"]({{bbox}});\n node[\"landuse\"=\"observatory\"]({{bbox}});\n way[\"landuse\"=\"observatory\"]({{bbox}});\n);\nout center body;", + }, + { + "id": "com_cables_landing", + "name": "Seekabel-Landepunkte", + "category": "communication", + "description": "Unterseekabel-Anlandestationen", + "color": "#e040fb", + "query": "[out:json][timeout:25];\n(\n node[\"telecom\"=\"landing_point\"]({{bbox}});\n node[\"communication\"=\"line\"][\"location\"=\"underwater\"]({{bbox}});\n);\nout center body;", + }, + # === Regierung === + { + "id": "gov_embassies", + "name": "Botschaften / Konsulate", + "category": "government", + "description": "Diplomatische Vertretungen", + "color": "#ff9800", + "query": "[out:json][timeout:25];\n(\n node[\"amenity\"=\"embassy\"]({{bbox}});\n way[\"amenity\"=\"embassy\"]({{bbox}});\n node[\"office\"=\"diplomatic\"]({{bbox}});\n);\nout center body;", + }, + { + "id": "gov_buildings", + "name": "Regierungsgebaeude", + "category": "government", + "description": "Parlamente, Ministerien, Regierungssitze", + "color": "#ff9800", + "query": "[out:json][timeout:25];\n(\n node[\"office\"=\"government\"]({{bbox}});\n way[\"office\"=\"government\"]({{bbox}});\n way[\"building\"=\"government\"]({{bbox}});\n);\nout center body;", + }, + { + "id": "gov_prisons", + "name": "Gefaengnisse", + "category": "government", + "description": "Justizvollzugsanstalten und Haftzentren", + "color": "#ff9800", + "query": "[out:json][timeout:25];\n(\n node[\"amenity\"=\"prison\"]({{bbox}});\n way[\"amenity\"=\"prison\"]({{bbox}});\n);\nout center body;", + }, + { + "id": "gov_police", + "name": "Polizeistationen", + "category": "government", + "description": "Polizeiwachen und -dienststellen", + "color": "#ff9800", + "query": "[out:json][timeout:25];\n(\n node[\"amenity\"=\"police\"]({{bbox}});\n way[\"amenity\"=\"police\"]({{bbox}});\n);\nout center body;", + }, + # === Kritische Infrastruktur === + { + "id": "crit_hospitals", + "name": "Krankenhaeuser", + "category": "critical", + "description": "Krankenhaeuser und Kliniken", + "color": "#00bcd4", + "query": "[out:json][timeout:25];\n(\n node[\"amenity\"=\"hospital\"]({{bbox}});\n way[\"amenity\"=\"hospital\"]({{bbox}});\n);\nout center body;", + }, + { + "id": "crit_water", + "name": "Wasserwerke", + "category": "critical", + "description": "Wasseraufbereitung und -versorgung", + "color": "#00bcd4", + "query": "[out:json][timeout:25];\n(\n node[\"man_made\"=\"water_works\"]({{bbox}});\n way[\"man_made\"=\"water_works\"]({{bbox}});\n node[\"man_made\"=\"wastewater_plant\"]({{bbox}});\n way[\"man_made\"=\"wastewater_plant\"]({{bbox}});\n);\nout center body;", + }, + { + "id": "crit_fire", + "name": "Feuerwachen", + "category": "critical", + "description": "Feuerwehrstationen", + "color": "#00bcd4", + "query": "[out:json][timeout:25];\n(\n node[\"amenity\"=\"fire_station\"]({{bbox}});\n way[\"amenity\"=\"fire_station\"]({{bbox}});\n);\nout center body;", + }, +] + + +def _cache_cleanup(): + """Entfernt aelteste Eintraege wenn Cache zu gross.""" + if len(_cache) > _MAX_CACHE_ENTRIES: + sorted_keys = sorted(_cache, key=lambda k: _cache[k][0]) + for k in sorted_keys[: len(_cache) - _MAX_CACHE_ENTRIES]: + del _cache[k] + + +@router.get("/overpass/templates") +async def get_templates(): + """Liefert die Template-Bibliothek.""" + categories = {} + for t in _TEMPLATES: + cat = t["category"] + if cat not in categories: + categories[cat] = [] + categories[cat].append( + { + "id": t["id"], + "name": t["name"], + "description": t["description"], + "color": t["color"], + "query": t["query"], + } + ) + cat_labels = { + "military": "Militaerisch", + "transport": "Transport", + "energy": "Energie", + "communication": "Kommunikation", + "government": "Regierung", + "critical": "Kritische Infrastruktur", + } + return { + "categories": [ + {"id": cid, "label": cat_labels.get(cid, cid), "templates": tpls} + for cid, tpls in categories.items() + ] + } + + +@router.post("/overpass/query") +async def execute_query(req: OverpassRequest): + """Fuehrt eine OverpassQL-Query aus (gecacht, rate-limited).""" + global _last_request_time + + query = req.query.strip() + if not query: + raise HTTPException(400, "Query darf nicht leer sein") + + # BBox-Platzhalter ersetzen + if req.bbox and "{{bbox}}" in query: + bbox_str = f"{req.bbox[0]},{req.bbox[1]},{req.bbox[2]},{req.bbox[3]}" + query = query.replace("{{bbox}}", bbox_str) + elif "{{bbox}}" in query and not req.bbox: + raise HTTPException( + 400, + "Query enthaelt {{bbox}} aber keine BBox wurde mitgesendet. " + "Aktiviere 'Viewport als BBox'.", + ) + + # Cache pruefen + cache_key = hashlib.md5(query.encode()).hexdigest() + if cache_key in _cache and time.time() - _cache[cache_key][0] < _CACHE_TTL: + cached = _cache[cache_key][1] + logger.info(f"Cache-Hit: {cache_key} ({cached.get('total', '?')} Elemente)") + return cached + + # Rate-Limiting + now = time.time() + elapsed = now - _last_request_time + if elapsed < _RATE_LIMIT_SECONDS: + wait = _RATE_LIMIT_SECONDS - elapsed + raise HTTPException( + 429, + f"Rate-Limit: Bitte {wait:.0f}s warten (Overpass Fair-Use)", + headers={"Retry-After": str(int(wait) + 1)}, + ) + + _last_request_time = time.time() + + # Query ausfuehren + data = None + for url in [_OVERPASS_URL, _OVERPASS_FALLBACK]: + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + r = await client.post(url, data={"data": query}) + r.raise_for_status() + data = r.json() + break + except Exception as e: + logger.warning(f"Overpass {url}: {e}") + continue + + if data is None: + raise HTTPException(502, "Overpass API nicht erreichbar") + + elements = data.get("elements", []) + + # Elemente aufteilen + nodes = [] + ways = [] + relations = [] + total = 0 + + for el in elements: + if total >= _MAX_ELEMENTS: + break + total += 1 + el_type = el.get("type") + tags = el.get("tags", {}) + name = tags.get("name", "") + + if el_type == "node": + nodes.append({ + "id": el.get("id"), + "lat": el.get("lat"), + "lon": el.get("lon"), + "tags": tags, + "name": name, + }) + elif el_type == "way": + center = el.get("center", {}) + geom = el.get("geometry", []) + ways.append({ + "id": el.get("id"), + "lat": center.get("lat"), + "lon": center.get("lon"), + "geometry": geom, + "tags": tags, + "name": name, + }) + elif el_type == "relation": + center = el.get("center", {}) + relations.append({ + "id": el.get("id"), + "lat": center.get("lat"), + "lon": center.get("lon"), + "tags": tags, + "name": name, + }) + + result = { + "nodes": nodes, + "ways": ways, + "relations": relations, + "total": total, + "truncated": len(elements) > _MAX_ELEMENTS, + "cached": False, + } + + # Cachen + _cache[cache_key] = (time.time(), {**result, "cached": True}) + _cache_cleanup() + + logger.info( + f"Overpass: {total} Elemente " + f"({len(nodes)} Nodes, {len(ways)} Ways, {len(relations)} Relations)" + ) + return result diff --git a/src/data_vlm.py b/src/data_vlm.py new file mode 100644 index 0000000..d5fa8f4 --- /dev/null +++ b/src/data_vlm.py @@ -0,0 +1,300 @@ +"""VLM-Bildanalyse: Claude Code headless fuer GEOINT-Bildauswertung.""" +import logging +import asyncio +import json +import os +import tempfile +import shutil +from pathlib import Path + +from PIL import Image +from fastapi import APIRouter, HTTPException, UploadFile, File, Form +from pydantic import BaseModel + +logger = logging.getLogger("globe.vlm") +router = APIRouter() + +# --- Konfiguration --- +_CLAUDE_BIN = "/usr/bin/claude" +_TIMEOUT = 90 +_MAX_IMAGE_SIZE = 10 * 1024 * 1024 # 10 MB +_MAX_IMAGE_DIMENSION = 1500 +_ALLOWED_TYPES = {"image/png", "image/jpeg", "image/webp"} + +# Semaphore: max 1 gleichzeitige Analyse +_semaphore = asyncio.Semaphore(1) + +# --- JSON-Schema fuer Claude-Output --- +_VLM_SCHEMA = json.dumps({ + "type": "object", + "properties": { + "scene_description": { + "type": "string", + "description": "Kurze Beschreibung der gesamten Szene" + }, + "objects": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Objekttyp auf Englisch (z.B. airport, military_base, port, bridge, power_plant, radar_station, fuel_depot, barracks, runway, hangar, dam, solar_farm, wind_farm, antenna, prison, hospital, fire_station, railway_station, tunnel)" + }, + "description": { + "type": "string", + "description": "Kurze Beschreibung des erkannten Objekts" + }, + "confidence": { + "type": "string", + "enum": ["high", "medium", "low"] + }, + "osm_tags": { + "type": "array", + "items": {"type": "string"}, + "description": "Passende OpenStreetMap-Tags (z.B. aeroway=aerodrome, military=base)" + }, + "military_relevant": { + "type": "boolean" + } + }, + "required": ["type", "description", "confidence", "osm_tags", "military_relevant"] + } + }, + "terrain": { + "type": "string", + "description": "Gelaendetyp (z.B. urban, rural, coastal, desert, forest, mountain)" + }, + "estimated_location_type": { + "type": "string", + "description": "Geschaetzter Standort-Typ (z.B. Europa, Naher Osten, Nordamerika)" + } + }, + "required": ["scene_description", "objects"] +}) + +# --- Objekt-Typ zu OverpassQL Mapping --- +_OBJECT_TO_OVERPASS = { + "airport": '["aeroway"="aerodrome"]', + "military_base": '["military"]', + "military_airfield": '["aeroway"="aerodrome"]["military"]', + "naval_base": '["military"="naval_base"]', + "port": '["landuse"="port"]', + "harbour": '["harbour"]', + "bridge": '["bridge"="yes"]["name"]', + "tunnel": '["tunnel"="yes"]["name"]', + "power_plant": '["power"="plant"]', + "nuclear_plant": '["generator:source"="nuclear"]', + "radar_station": '["man_made"="surveillance"]["surveillance:type"="radar"]', + "fuel_depot": '["man_made"="storage_tank"]', + "barracks": '["military"="barracks"]', + "runway": '["aeroway"="runway"]', + "hangar": '["aeroway"="hangar"]', + "dam": '["waterway"="dam"]', + "solar_farm": '["plant:source"="solar"]', + "wind_farm": '["generator:source"="wind"]', + "antenna": '["man_made"="antenna"]', + "communication_tower": '["man_made"="tower"]["tower:type"="communication"]', + "prison": '["amenity"="prison"]', + "hospital": '["amenity"="hospital"]', + "fire_station": '["amenity"="fire_station"]', + "police_station": '["amenity"="police"]', + "embassy": '["amenity"="embassy"]', + "government_building": '["office"="government"]', + "railway_station": '["railway"="station"]', + "substation": '["power"="substation"]', + "refinery": '["industrial"="refinery"]', + "pipeline": '["man_made"="pipeline"]', + "water_works": '["man_made"="water_works"]', + "data_center": '["telecom"="data_center"]', + "bunker": '["military"="bunker"]', +} + + +def _resize_image(input_path: str, output_path: str): + """Skaliert Bild auf max _MAX_IMAGE_DIMENSION px.""" + with Image.open(input_path) as img: + w, h = img.size + if max(w, h) > _MAX_IMAGE_DIMENSION: + ratio = _MAX_IMAGE_DIMENSION / max(w, h) + new_size = (int(w * ratio), int(h * ratio)) + img = img.resize(new_size, Image.LANCZOS) + # Immer als PNG speichern + if img.mode in ("RGBA", "P"): + img = img.convert("RGB") + img.save(output_path, "PNG", optimize=True) + + +async def _run_claude(image_path: str, viewport_info: str = "") -> dict: + """Ruft Claude Code headless auf um ein Bild zu analysieren.""" + context = "" + if viewport_info: + context = f"Kontext: Der Nutzer schaut gerade auf {viewport_info}. " + + prompt = ( + f"Lies die Bilddatei {image_path} mit dem Read-Tool. " + f"{context}" + "Analysiere das Bild fuer GEOINT/OSINT-Zwecke. " + "Identifiziere alle erkennbaren Objekte, Infrastruktur und militaerisch relevante Strukturen. " + "Gib fuer jedes Objekt passende OpenStreetMap-Tags an. " + "Antworte ausschliesslich im vorgegebenen JSON-Format." + ) + + cmd = [ + _CLAUDE_BIN, + "-p", prompt, + "--output-format", "json", + "--json-schema", _VLM_SCHEMA, + "--model", "sonnet", + "--allowedTools", "Read", + "--dangerously-skip-permissions", + ] + + proc = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + + try: + stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=_TIMEOUT) + except asyncio.TimeoutError: + proc.kill() + await proc.communicate() + raise HTTPException(504, "VLM-Analyse Timeout (>90s). Bitte kleineres Bild verwenden.") + + if proc.returncode != 0: + err = stderr.decode()[:500] + logger.error(f"Claude Code Fehler (rc={proc.returncode}): {err}") + raise HTTPException(502, f"VLM-Analyse fehlgeschlagen: {err[:200]}") + + # JSON parsen + try: + wrapper = json.loads(stdout.decode()) + + # --json-schema liefert structured_output (bevorzugt) + structured = wrapper.get("structured_output") + if structured and isinstance(structured, dict): + return structured + + # Fallback: result als JSON-String parsen + result_str = wrapper.get("result", "") + if isinstance(result_str, str) and result_str.strip().startswith("{"): + return json.loads(result_str) + + # Fallback: Claude hat Klartext statt JSON geliefert + logger.warning(f"VLM: Kein strukturiertes JSON, Klartext: {result_str[:200]}") + return { + "scene_description": result_str[:500] if result_str else "Analyse konnte kein strukturiertes Ergebnis liefern", + "objects": [], + "terrain": "unknown", + "estimated_location_type": "unknown", + } + except (json.JSONDecodeError, KeyError) as e: + logger.error(f"VLM JSON-Parse-Fehler: {e}, stdout={stdout.decode()[:500]}") + raise HTTPException(502, "VLM-Analyse: Unerwartetes Antwortformat") + + +@router.post("/vlm/analyze") +async def analyze_image( + file: UploadFile = File(...), + viewport_info: str = Form(""), +): + """Analysiert ein hochgeladenes Bild mittels Claude Code VLM.""" + # Validierung + if file.content_type not in _ALLOWED_TYPES: + raise HTTPException(400, f"Ungültiger Dateityp: {file.content_type}. Erlaubt: PNG, JPG, WEBP") + + # Semaphore: max 1 gleichzeitig + if _semaphore.locked(): + raise HTTPException(429, "Eine Analyse laeuft bereits. Bitte warten.") + + async with _semaphore: + tmp_dir = tempfile.mkdtemp(prefix="vlm_") + try: + # Bild speichern + raw_path = os.path.join(tmp_dir, "raw" + _ext(file.content_type)) + content = await file.read() + if len(content) > _MAX_IMAGE_SIZE: + raise HTTPException(400, f"Bild zu gross ({len(content) // 1024 // 1024}MB). Max: 10MB") + + with open(raw_path, "wb") as f: + f.write(content) + + # Resize + resized_path = os.path.join(tmp_dir, "resized.png") + _resize_image(raw_path, resized_path) + + logger.info(f"VLM-Analyse gestartet: {file.filename} ({len(content)} bytes)") + + # Claude Code aufrufen + result = await _run_claude(resized_path, viewport_info) + + logger.info( + f"VLM-Analyse abgeschlossen: {len(result.get('objects', []))} Objekte erkannt" + ) + return result + + finally: + # Temp-Verzeichnis bereinigen + shutil.rmtree(tmp_dir, ignore_errors=True) + + +def _ext(content_type: str) -> str: + return { + "image/png": ".png", + "image/jpeg": ".jpg", + "image/webp": ".webp", + }.get(content_type, ".png") + + +class QueryGenRequest(BaseModel): + objects: list[dict] + bbox: list[float] | None = None + + +@router.post("/vlm/generate-queries") +async def generate_queries(req: QueryGenRequest): + """Generiert OverpassQL-Queries aus VLM-Erkennungen.""" + if not req.objects: + raise HTTPException(400, "Keine Objekte angegeben") + + bbox_str = "" + if req.bbox and len(req.bbox) == 4: + bbox_str = f"({req.bbox[0]},{req.bbox[1]},{req.bbox[2]},{req.bbox[3]})" + + fragments = [] + used_types = set() + + for obj in req.objects: + obj_type = obj.get("type", "").lower().replace(" ", "_") + + # Direkte Zuordnung + if obj_type in _OBJECT_TO_OVERPASS and obj_type not in used_types: + tag = _OBJECT_TO_OVERPASS[obj_type] + fragments.append(f' node{tag}{bbox_str};') + fragments.append(f' way{tag}{bbox_str};') + used_types.add(obj_type) + continue + + # Fallback: OSM-Tags aus VLM-Ergebnis verwenden + osm_tags = obj.get("osm_tags", []) + for tag_str in osm_tags: + if "=" in tag_str and tag_str not in used_types: + key, val = tag_str.split("=", 1) + frag = f'["{key}"="{val}"]' + fragments.append(f' node{frag}{bbox_str};') + fragments.append(f' way{frag}{bbox_str};') + used_types.add(tag_str) + break + + if not fragments: + raise HTTPException(400, "Keine passenden OSM-Tags fuer die erkannten Objekte gefunden") + + query = "[out:json][timeout:30];\n(\n" + "\n".join(fragments) + "\n);\nout center body;" + + return { + "query": query, + "object_count": len(req.objects), + "tag_count": len(used_types), + } diff --git a/src/main.py b/src/main.py index c5263bc..02d6850 100644 --- a/src/main.py +++ b/src/main.py @@ -36,6 +36,8 @@ from data_disasters import router as disasters_router from data_military import router as military_router, start_mil_collector from data_infra import router as infra_router from data_monitor import router as monitor_router +from data_overpass import router as overpass_router +from data_vlm import router as vlm_router from data_push import start_push_service # Alle Daten-APIs hinter Auth @@ -48,6 +50,8 @@ app.include_router(military_router, prefix="/api", dependencies=[Depends(get_cur app.include_router(infra_router, prefix="/api", dependencies=[Depends(get_current_user)]) app.include_router(disasters_router, prefix="/api", dependencies=[Depends(get_current_user)]) app.include_router(monitor_router, prefix="/api", dependencies=[Depends(get_current_user)]) +app.include_router(overpass_router, prefix="/api", dependencies=[Depends(get_current_user)]) +app.include_router(vlm_router, prefix="/api", dependencies=[Depends(get_current_user)]) # --- Static files --- static_dir = Path(__file__).parent.parent / "static" diff --git a/static/css/globe.css b/static/css/globe.css index fdba375..fdd363b 100644 --- a/static/css/globe.css +++ b/static/css/globe.css @@ -589,3 +589,200 @@ html, body { height: 100%; overflow: hidden; background: var(--bg-primary); colo background: rgba(255,255,255,0.02); border-radius: 0 4px 4px 0; } + +/* === Overpass Turbo === */ +.dot-overpass { background: #ff9800; } + +.overpass-panel { + position: fixed; + top: 56px; + right: 12px; + width: 360px; + max-height: calc(100vh - 100px); + overflow-y: auto; + background: var(--bg-panel); + border: 1px solid var(--border); + border-radius: 8px; + backdrop-filter: blur(12px); + box-shadow: 0 8px 32px rgba(0,0,0,0.4); + z-index: 100; + padding: 14px; +} + +#overpass-editor { + width: 100%; + min-height: 100px; + background: rgba(0,0,0,0.3); + color: var(--accent); + font-family: var(--font-mono); + font-size: 12px; + border: 1px solid var(--border); + border-radius: 4px; + padding: 8px; + resize: vertical; + line-height: 1.5; +} +#overpass-editor:focus { + border-color: var(--accent); + outline: none; + box-shadow: 0 0 8px rgba(0,255,136,0.15); +} +#overpass-editor::placeholder { color: var(--text-dim); opacity: 0.5; } + +.overpass-cat-tabs { display: flex; gap: 4px; flex-wrap: wrap; margin-bottom: 8px; } +.overpass-cat-tab { + padding: 4px 8px; + font-size: 10px; + font-family: var(--font-mono); + letter-spacing: 0.5px; + background: rgba(255,255,255,0.05); + border: 1px solid var(--border); + border-radius: 3px; + color: var(--text-dim); + cursor: pointer; + transition: all 0.15s; +} +.overpass-cat-tab:hover { border-color: rgba(0,255,136,0.3); color: var(--text); } +.overpass-cat-tab.active { + color: var(--accent); + border-color: var(--accent); + background: rgba(0,255,136,0.1); +} + +.overpass-template-btn { + display: block; + width: 100%; + text-align: left; + padding: 5px 8px; + margin: 1px 0; + background: transparent; + border: 1px solid transparent; + color: var(--text); + font-family: var(--font-mono); + font-size: 12px; + cursor: pointer; + border-radius: 3px; + transition: all 0.1s; +} +.overpass-template-btn:hover { + background: rgba(0,255,136,0.06); + border-color: var(--border); +} + +.overpass-exec-btn { + width: 100%; + padding: 10px; + background: rgba(0,255,136,0.12); + border: 1px solid var(--accent); + color: var(--accent); + font-family: var(--font-mono); + font-size: 12px; + font-weight: 700; + border-radius: 4px; + cursor: pointer; + text-transform: uppercase; + letter-spacing: 2px; + transition: all 0.15s; +} +.overpass-exec-btn:hover { background: rgba(0,255,136,0.22); } +.overpass-exec-btn:disabled { opacity: 0.4; cursor: not-allowed; } + +.overpass-panel::-webkit-scrollbar { width: 4px; } +.overpass-panel::-webkit-scrollbar-track { background: transparent; } +.overpass-panel::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; } + +/* === VLM Bildanalyse === */ +.dot-vlm { background: #e040fb; } + +.vlm-panel { + position: fixed; + top: 56px; + left: 268px; + width: 360px; + max-height: calc(100vh - 100px); + overflow-y: auto; + background: var(--bg-panel); + border: 1px solid var(--border); + border-radius: 8px; + backdrop-filter: blur(12px); + box-shadow: 0 8px 32px rgba(0,0,0,0.4); + z-index: 100; + padding: 14px; +} + +.vlm-drop-zone { + border: 2px dashed var(--border); + border-radius: 8px; + padding: 28px 16px; + text-align: center; + color: var(--text-dim); + cursor: pointer; + transition: all 0.2s; + display: flex; + flex-direction: column; + align-items: center; +} +.vlm-drop-zone:hover, +.vlm-drop-active { + border-color: var(--accent); + background: rgba(0,255,136,0.04); + color: var(--accent); +} + +.vlm-preview img { + max-width: 100%; + max-height: 180px; + object-fit: contain; + border-radius: 4px; + border: 1px solid var(--border); + margin: 8px 0 4px; +} + +.vlm-loading { + text-align: center; + padding: 20px; + color: var(--accent); + font-size: 12px; +} +.vlm-spinner { + width: 28px; + height: 28px; + border: 2px solid var(--border); + border-top-color: var(--accent); + border-radius: 50%; + animation: vlm-spin 0.8s linear infinite; + margin: 0 auto 10px; +} +@keyframes vlm-spin { to { transform: rotate(360deg); } } + +.vlm-object-card { + display: flex; + align-items: center; + gap: 8px; + padding: 8px; + border: 1px solid var(--border); + border-radius: 4px; + margin: 4px 0; + font-size: 12px; + transition: border-color 0.15s; +} +.vlm-object-card:hover { border-color: rgba(0,255,136,0.3); } + +.vlm-confidence-high { color: #00ff88; } +.vlm-confidence-medium { color: #ff9800; } +.vlm-confidence-low { color: #ff5252; } + +.vlm-badge-mil { + background: rgba(255,82,82,0.2); + color: #ff5252; + font-size: 9px; + font-weight: 700; + padding: 2px 5px; + border-radius: 2px; + letter-spacing: 0.5px; + flex-shrink: 0; +} + +.vlm-panel::-webkit-scrollbar { width: 4px; } +.vlm-panel::-webkit-scrollbar-track { background: transparent; } +.vlm-panel::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; } diff --git a/static/index.html b/static/index.html index c295916..447ab30 100644 --- a/static/index.html +++ b/static/index.html @@ -108,6 +108,19 @@ -
+ +
+