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) <noreply@anthropic.com>
Dieser Commit ist enthalten in:
451
src/data_overpass.py
Normale Datei
451
src/data_overpass.py
Normale Datei
@@ -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
|
||||
300
src/data_vlm.py
Normale Datei
300
src/data_vlm.py
Normale Datei
@@ -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),
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren