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:
Claude Dev
2026-03-26 00:03:01 +01:00
Ursprung ed7db697f1
Commit 7be5edd983
9 geänderte Dateien mit 1727 neuen und 0 gelöschten Zeilen

451
src/data_overpass.py Normale Datei
Datei anzeigen

@@ -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
Datei anzeigen

@@ -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),
}

Datei anzeigen

@@ -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"