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"

Datei anzeigen

@@ -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; }

Datei anzeigen

@@ -108,6 +108,19 @@
<span class="layer-count" id="count-infra">-</span>
</label>
<div class="layer-status" id="status-infra"></div>
<label class="layer-toggle">
<input type="checkbox" id="layer-overpass" title="Overpass Turbo: Beliebige OSM-Daten abfragen">
<span class="layer-dot dot-overpass"></span>
<span class="layer-name" title="OverpassQL-Abfragen gegen OpenStreetMap">Overpass</span>
<span class="layer-count" id="count-overpass">-</span>
</label>
<div class="layer-status" id="status-overpass"></div>
<label class="layer-toggle">
<input type="checkbox" id="layer-vlm" title="VLM-Bildanalyse: Bild hochladen, Claude analysiert, Overpass sucht passende Orte">
<span class="layer-dot dot-vlm"></span>
<span class="layer-name" title="Satellitenbild-Analyse mit Claude VLM">Bildanalyse</span>
<span class="layer-count" id="count-vlm">-</span>
</label>
<label class="layer-toggle">
<input type="checkbox" id="layer-iss" title="ISS Echtzeit-Position (5s Refresh)">
<span class="layer-dot dot-iss"></span>
@@ -195,6 +208,11 @@
<div id="city-links" class="city-links"></div>
<!-- Rechte Sidebar: Datenpunkt-Uebersicht -->
<!-- Overpass Panel -->
<div id="overpass-panel" class="overpass-panel" style="display:none"></div>
<!-- VLM Bildanalyse Panel -->
<div id="vlm-panel" class="vlm-panel" style="display:none"></div>
<aside id="sidebar-right" class="sidebar-right">
<button id="sidebar-toggle" class="sidebar-toggle" title="Seitenleiste ein-/ausblenden">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
@@ -240,6 +258,9 @@
<script src="/static/js/layers/military.js"></script>
<script src="/static/js/layers/cables.js"></script>
<script src="/static/js/layers/infra.js"></script>
<script src="/static/js/layers/overpass.js"></script>
<script src="/static/js/ui/overpass.js"></script>
<script src="/static/js/ui/vlm.js"></script>
<script src="/static/js/layers/iss.js"></script>
<script src="/static/js/layers/terminator.js"></script>
<script src="/static/js/layers/timezones.js"></script>

Datei anzeigen

@@ -159,6 +159,14 @@ const Globe = {
'layer-satellites': function(on) { on ? SatellitesLayer.start(Globe.viewer) : SatellitesLayer.stop(); },
'layer-cables': function(on) { on ? CablesLayer.start(Globe.viewer) : CablesLayer.stop(); },
'layer-infra': function(on) { on ? InfraLayer.start(Globe.viewer) : InfraLayer.stop(); },
'layer-overpass': function(on) {
if (on) { OverpassLayer.start(Globe.viewer); OverpassUI.show(); }
else { OverpassLayer.stop(); OverpassUI.hide(); }
},
'layer-vlm': function(on) {
if (on) { VlmUI.show(); }
else { VlmUI.hide(); }
},
'layer-iss': function(on) { on ? ISSLayer.start(Globe.viewer) : ISSLayer.stop(); },
'layer-disasters': function(on) { on ? DisastersLayer.start(Globe.viewer) : DisastersLayer.stop(); },
'layer-weather': function(on) { on ? WeatherLayer.start(Globe.viewer) : WeatherLayer.stop(); },

211
static/js/layers/overpass.js Normale Datei
Datei anzeigen

@@ -0,0 +1,211 @@
/**
* Overpass Layer: Rendert Overpass-API-Ergebnisse auf dem CesiumJS-Globus.
* Nodes als Punkte, Ways als Linien, Relations als Punkte (Center).
*/
const OverpassLayer = {
_viewer: null,
_points: null,
_labels: null,
_polylines: null,
_count: 0,
_data: null,
_handler: null,
_nodeIndex: [],
_currentColor: '#ff9800',
start(viewer) {
if (this._points) return;
this._viewer = viewer;
this._points = viewer.scene.primitives.add(new Cesium.PointPrimitiveCollection());
this._labels = viewer.scene.primitives.add(new Cesium.LabelCollection());
this._polylines = viewer.scene.primitives.add(new Cesium.PolylineCollection());
this._setupClickHandler();
},
stop() {
if (this._handler) { this._handler.destroy(); this._handler = null; }
if (this._points && this._viewer) { this._viewer.scene.primitives.remove(this._points); this._points = null; }
if (this._labels && this._viewer) { this._viewer.scene.primitives.remove(this._labels); this._labels = null; }
if (this._polylines && this._viewer) { this._viewer.scene.primitives.remove(this._polylines); this._polylines = null; }
this._count = 0;
this._data = null;
this._nodeIndex = [];
var countEl = document.getElementById('count-overpass');
if (countEl) countEl.textContent = '-';
},
clear() {
if (this._points) this._points.removeAll();
if (this._labels) this._labels.removeAll();
if (this._polylines) this._polylines.removeAll();
this._count = 0;
this._data = null;
this._nodeIndex = [];
var countEl = document.getElementById('count-overpass');
if (countEl) countEl.textContent = '-';
},
setColor(hexColor) {
this._currentColor = hexColor || '#ff9800';
},
render(data) {
this.clear();
if (!this._points) return;
this._data = data;
var color = Cesium.Color.fromCssColorString(this._currentColor);
var colorDim = color.withAlpha(0.6);
// Nodes
var nodes = data.nodes || [];
for (var i = 0; i < nodes.length; i++) {
var n = nodes[i];
if (!n.lat || !n.lon) continue;
this._points.add({
position: Cesium.Cartesian3.fromDegrees(n.lon, n.lat, 0),
pixelSize: 7,
color: color,
outlineColor: Cesium.Color.BLACK,
outlineWidth: 1,
});
this._nodeIndex.push({ lat: n.lat, lon: n.lon, tags: n.tags, name: n.name, type: 'node' });
if (n.name) {
this._labels.add({
position: Cesium.Cartesian3.fromDegrees(n.lon, n.lat, 0),
text: n.name,
font: '10px monospace',
fillColor: color.withAlpha(0.8),
outlineColor: Cesium.Color.BLACK,
outlineWidth: 2,
style: Cesium.LabelStyle.FILL_AND_OUTLINE,
pixelOffset: new Cesium.Cartesian2(6, -4),
scale: 0.7,
distanceDisplayCondition: new Cesium.DistanceDisplayCondition(0, 500000),
});
}
}
// Ways
var ways = data.ways || [];
for (var j = 0; j < ways.length; j++) {
var w = ways[j];
if (w.geometry && w.geometry.length > 1) {
var positions = [];
for (var k = 0; k < w.geometry.length; k++) {
var g = w.geometry[k];
if (g.lat && g.lon) {
positions.push(Cesium.Cartesian3.fromDegrees(g.lon, g.lat, 0));
}
}
if (positions.length > 1) {
this._polylines.add({
positions: positions,
width: 2.5,
material: Cesium.Material.fromType('Color', { color: colorDim }),
});
}
}
if (w.lat && w.lon) {
this._points.add({
position: Cesium.Cartesian3.fromDegrees(w.lon, w.lat, 0),
pixelSize: 5,
color: colorDim,
});
this._nodeIndex.push({ lat: w.lat, lon: w.lon, tags: w.tags, name: w.name, type: 'way' });
if (w.name) {
this._labels.add({
position: Cesium.Cartesian3.fromDegrees(w.lon, w.lat, 0),
text: w.name,
font: '10px monospace',
fillColor: color.withAlpha(0.7),
outlineColor: Cesium.Color.BLACK,
outlineWidth: 2,
style: Cesium.LabelStyle.FILL_AND_OUTLINE,
pixelOffset: new Cesium.Cartesian2(6, -4),
scale: 0.7,
distanceDisplayCondition: new Cesium.DistanceDisplayCondition(0, 500000),
});
}
}
}
// Relations (Center-Punkt)
var rels = data.relations || [];
for (var m = 0; m < rels.length; m++) {
var r = rels[m];
if (!r.lat || !r.lon) continue;
this._points.add({
position: Cesium.Cartesian3.fromDegrees(r.lon, r.lat, 0),
pixelSize: 9,
color: color,
outlineColor: color.withAlpha(0.3),
outlineWidth: 3,
});
this._nodeIndex.push({ lat: r.lat, lon: r.lon, tags: r.tags, name: r.name, type: 'relation' });
if (r.name) {
this._labels.add({
position: Cesium.Cartesian3.fromDegrees(r.lon, r.lat, 0),
text: r.name,
font: '11px monospace',
fillColor: color,
outlineColor: Cesium.Color.BLACK,
outlineWidth: 2,
style: Cesium.LabelStyle.FILL_AND_OUTLINE,
pixelOffset: new Cesium.Cartesian2(8, -6),
scale: 0.8,
distanceDisplayCondition: new Cesium.DistanceDisplayCondition(0, 800000),
});
}
}
this._count = nodes.length + ways.length + rels.length;
var countEl = document.getElementById('count-overpass');
if (countEl) countEl.textContent = this._count.toLocaleString('de-DE');
},
_setupClickHandler() {
var self = this;
this._handler = new Cesium.ScreenSpaceEventHandler(this._viewer.scene.canvas);
this._handler.setInputAction(function(click) {
if (!self._nodeIndex.length) return;
var cart = self._viewer.scene.pickPosition(click.position);
if (!cart) {
var ray = self._viewer.scene.camera.getPickRay(click.position);
cart = self._viewer.scene.globe.pick(ray, self._viewer.scene);
}
if (!cart) return;
var c = Cesium.Cartographic.fromCartesian(cart);
var lat = Cesium.Math.toDegrees(c.latitude);
var lon = Cesium.Math.toDegrees(c.longitude);
var best = null, bd = 999;
for (var i = 0; i < self._nodeIndex.length; i++) {
var el = self._nodeIndex[i];
var d = Math.abs(el.lat - lat) + Math.abs(el.lon - lon);
if (d < bd) { bd = d; best = el; }
}
if (best && bd < 0.3) {
var name = best.name || 'Unbenannt';
var typeLabels = { node: 'Node', way: 'Way', relation: 'Relation' };
var tagsHtml = '';
var tags = best.tags || {};
var tagKeys = Object.keys(tags);
for (var t = 0; t < tagKeys.length && t < 20; t++) {
var key = tagKeys[t];
tagsHtml += '<tr><td style="color:#00ff88;padding:2px 8px 2px 0">' + key + '</td>' +
'<td style="color:#ccc">' + tags[key] + '</td></tr>';
}
self._viewer.trackedEntity = undefined;
self._viewer.selectedEntity = new Cesium.Entity({
name: name,
description: '<div style="font-family:monospace;font-size:12px;padding:8px;color:#e8eaf0">' +
'<div style="font-size:10px;color:#888;margin-bottom:4px">' + typeLabels[best.type] + ' | OpenStreetMap</div>' +
'<strong style="color:' + self._currentColor + ';font-size:14px">' + name + '</strong>' +
'<table style="margin-top:8px;border-collapse:collapse;width:100%">' + tagsHtml + '</table></div>',
});
}
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
},
getCount: function() { return this._count; },
};

207
static/js/ui/overpass.js Normale Datei
Datei anzeigen

@@ -0,0 +1,207 @@
/**
* Overpass UI: Query-Editor-Panel mit Template-Browser und BBox-Integration.
*/
const OverpassUI = {
_panel: null,
_editor: null,
_templates: [],
_categories: [],
_activeCategory: null,
_isLoading: false,
_useBbox: true,
init: function() {
this._createPanel();
this._loadTemplates();
},
show: function() {
if (!this._panel) this.init();
this._panel.style.display = 'block';
if (!OverpassLayer._points) OverpassLayer.start(Globe.viewer);
},
hide: function() {
if (this._panel) this._panel.style.display = 'none';
},
_createPanel: function() {
var panel = document.getElementById('overpass-panel');
if (!panel) return;
this._panel = panel;
panel.innerHTML =
'<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">' +
'<div style="font-size:11px;font-weight:700;letter-spacing:2px;color:var(--accent)">OVERPASS QUERY</div>' +
'<button onclick="OverpassUI.hide();var cb=document.getElementById(\'layer-overpass\');if(cb){cb.checked=false;}OverpassLayer.stop();" ' +
'style="background:none;border:none;color:var(--text-dim);cursor:pointer;font-size:18px;line-height:1" title="Schliessen">&times;</button>' +
'</div>' +
'<div id="overpass-cat-tabs" class="overpass-cat-tabs"></div>' +
'<div id="overpass-template-list" style="max-height:160px;overflow-y:auto;margin-bottom:10px"></div>' +
'<div class="panel-divider"></div>' +
'<div style="font-size:9px;letter-spacing:1.5px;color:var(--text-dim);margin-bottom:4px">OVERPASSQL EDITOR</div>' +
'<textarea id="overpass-editor" rows="6" spellcheck="false" placeholder="[out:json][timeout:25];\n(\n node[&quot;amenity&quot;]({{bbox}});\n);\nout center body;"></textarea>' +
'<div style="display:flex;align-items:center;gap:8px;margin:10px 0">' +
'<label style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text);cursor:pointer;flex:1">' +
'<input type="checkbox" id="overpass-bbox" checked onchange="OverpassUI._useBbox=this.checked" style="accent-color:var(--accent);width:14px;height:14px">' +
'Viewport als BBox' +
'</label>' +
'</div>' +
'<button class="overpass-exec-btn" id="overpass-exec-btn" onclick="OverpassUI._executeQuery()">AUSFUEHREN</button>' +
'<div id="overpass-result" style="margin-top:10px;font-size:11px;color:var(--text-dim);display:none"></div>' +
'<button id="overpass-clear-btn" onclick="OverpassUI._clearResults()" ' +
'style="display:none;width:100%;padding:6px;margin-top:6px;background:none;border:1px solid var(--border);color:var(--text-dim);' +
'font-family:var(--font-mono);font-size:11px;border-radius:4px;cursor:pointer;letter-spacing:1px">ERGEBNISSE LOESCHEN</button>';
this._editor = document.getElementById('overpass-editor');
},
_loadTemplates: function() {
var self = this;
fetch('/api/overpass/templates')
.then(function(r) { return r.json(); })
.then(function(data) {
self._categories = data.categories || [];
if (self._categories.length > 0) {
self._activeCategory = self._categories[0].id;
self._renderCategoryTabs();
self._renderTemplateList();
}
})
.catch(function(e) { console.warn('Overpass Templates:', e); });
},
_renderCategoryTabs: function() {
var el = document.getElementById('overpass-cat-tabs');
if (!el) return;
var self = this;
var html = '';
this._categories.forEach(function(cat) {
var active = cat.id === self._activeCategory ? ' active' : '';
html += '<button class="overpass-cat-tab' + active + '" data-cat="' + cat.id + '" ' +
'onclick="OverpassUI._selectCategory(\'' + cat.id + '\')">' + cat.label + '</button>';
});
el.innerHTML = html;
},
_selectCategory: function(catId) {
this._activeCategory = catId;
this._renderCategoryTabs();
this._renderTemplateList();
},
_renderTemplateList: function() {
var el = document.getElementById('overpass-template-list');
if (!el) return;
var self = this;
var cat = this._categories.find(function(c) { return c.id === self._activeCategory; });
if (!cat) { el.innerHTML = ''; return; }
var html = '';
cat.templates.forEach(function(t) {
html += '<button class="overpass-template-btn" onclick="OverpassUI._applyTemplate(\'' + t.id + '\')" title="' + t.description + '">' +
'<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:' + t.color + ';margin-right:8px"></span>' +
t.name + '</button>';
});
el.innerHTML = html;
},
_applyTemplate: function(templateId) {
var tpl = null;
for (var i = 0; i < this._categories.length; i++) {
var found = this._categories[i].templates.find(function(t) { return t.id === templateId; });
if (found) { tpl = found; break; }
}
if (tpl && this._editor) {
this._editor.value = tpl.query;
OverpassLayer.setColor(tpl.color);
}
},
_getBboxFromViewport: function() {
if (!Globe.viewer) return null;
var rect = Globe.viewer.camera.computeViewRectangle();
if (!rect) return null;
return [
Cesium.Math.toDegrees(rect.south),
Cesium.Math.toDegrees(rect.west),
Cesium.Math.toDegrees(rect.north),
Cesium.Math.toDegrees(rect.east),
];
},
_executeQuery: function() {
if (this._isLoading || !this._editor) return;
var query = this._editor.value.trim();
if (!query) return;
var bbox = null;
if (this._useBbox) {
bbox = this._getBboxFromViewport();
}
this._isLoading = true;
var btn = document.getElementById('overpass-exec-btn');
if (btn) { btn.disabled = true; btn.textContent = 'LADE...'; }
var resultEl = document.getElementById('overpass-result');
if (resultEl) { resultEl.style.display = 'block'; resultEl.textContent = 'Anfrage wird gesendet...'; resultEl.style.color = 'var(--text-dim)'; }
var startTime = Date.now();
var self = this;
fetch('/api/overpass/query', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: query, bbox: bbox }),
})
.then(function(r) {
if (r.status === 429) {
return r.json().then(function(d) { throw new Error(d.detail || 'Rate-Limit erreicht'); });
}
if (!r.ok) {
return r.json().then(function(d) { throw new Error(d.detail || 'Fehler ' + r.status); });
}
return r.json();
})
.then(function(data) {
var elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
OverpassLayer.render(data);
if (resultEl) {
var text = data.total + ' Objekte (' + elapsed + 's)';
if (data.cached) text += ' [Cache]';
if (data.truncated) text += ' [max. ' + data.total + ' angezeigt]';
resultEl.textContent = text;
resultEl.style.color = 'var(--accent)';
}
var clearBtn = document.getElementById('overpass-clear-btn');
if (clearBtn) clearBtn.style.display = 'block';
})
.catch(function(e) {
if (resultEl) {
resultEl.textContent = 'Fehler: ' + e.message;
resultEl.style.color = '#ff5252';
}
})
.finally(function() {
self._isLoading = false;
if (btn) { btn.disabled = false; btn.textContent = 'AUSFUEHREN'; }
});
},
_clearResults: function() {
OverpassLayer.clear();
var resultEl = document.getElementById('overpass-result');
if (resultEl) { resultEl.style.display = 'none'; resultEl.style.color = 'var(--text-dim)'; }
var clearBtn = document.getElementById('overpass-clear-btn');
if (clearBtn) clearBtn.style.display = 'none';
},
executeQueryDirect: function(query, bbox, color) {
if (this._editor) this._editor.value = query;
if (color) OverpassLayer.setColor(color);
this._useBbox = !!bbox;
var bboxCb = document.getElementById('overpass-bbox');
if (bboxCb) bboxCb.checked = this._useBbox;
this.show();
this._executeQuery();
},
};

328
static/js/ui/vlm.js Normale Datei
Datei anzeigen

@@ -0,0 +1,328 @@
/**
* VLM UI: Bildanalyse-Panel mit Upload, Zwei-Stufen-Workflow und Overpass-Kopplung.
*/
const VlmUI = {
_panel: null,
_dropZone: null,
_currentAnalysis: null,
_isAnalyzing: false,
init: function() {
this._createPanel();
},
show: function() {
if (!this._panel) this.init();
this._panel.style.display = 'block';
},
hide: function() {
if (this._panel) this._panel.style.display = 'none';
},
_createPanel: function() {
var panel = document.getElementById('vlm-panel');
if (!panel) return;
this._panel = panel;
panel.innerHTML =
'<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">' +
'<div style="font-size:11px;font-weight:700;letter-spacing:2px;color:var(--accent)">BILDANALYSE (VLM)</div>' +
'<button onclick="VlmUI.hide();var cb=document.getElementById(\'layer-vlm\');if(cb){cb.checked=false;}" ' +
'style="background:none;border:none;color:var(--text-dim);cursor:pointer;font-size:18px;line-height:1" title="Schliessen">&times;</button>' +
'</div>' +
// Drop-Zone
'<div id="vlm-drop-zone" class="vlm-drop-zone">' +
'<svg viewBox="0 0 24 24" width="32" height="32" fill="none" stroke="currentColor" stroke-width="1.5" style="margin-bottom:8px">' +
'<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>' +
'</svg>' +
'<div style="font-size:12px">Bild hierher ziehen</div>' +
'<div style="font-size:10px;color:var(--text-dim);margin-top:4px">oder klicken zum Waehlen (PNG/JPG/WEBP, max 10MB)</div>' +
'</div>' +
'<input type="file" id="vlm-file-input" accept="image/png,image/jpeg,image/webp" style="display:none" onchange="VlmUI._onFileSelect(this)">' +
// Preview
'<div id="vlm-preview" class="vlm-preview" style="display:none"></div>' +
// Loading
'<div id="vlm-loading" class="vlm-loading" style="display:none">' +
'<div class="vlm-spinner"></div>' +
'<div id="vlm-loading-text">Analysiere Bild...</div>' +
'</div>' +
// Analyse-Ergebnis
'<div id="vlm-results" style="display:none">' +
'<div class="panel-divider"></div>' +
'<div style="font-size:9px;letter-spacing:1.5px;color:var(--accent);margin-bottom:6px">SZENE</div>' +
'<div id="vlm-scene" style="font-size:12px;color:var(--text);margin-bottom:10px;line-height:1.5"></div>' +
'<div style="font-size:9px;letter-spacing:1.5px;color:var(--accent);margin-bottom:6px">ERKANNTE OBJEKTE</div>' +
'<div id="vlm-objects"></div>' +
'<button class="overpass-exec-btn" id="vlm-search-btn" onclick="VlmUI._confirmAndSearch()" style="margin-top:10px">AUF GLOBE SUCHEN</button>' +
'<div id="vlm-search-result" style="margin-top:8px;font-size:11px;color:var(--text-dim);display:none"></div>' +
'</div>' +
// Reset
'<button id="vlm-reset-btn" onclick="VlmUI._reset()" ' +
'style="display:none;width:100%;padding:6px;margin-top:8px;background:none;border:1px solid var(--border);color:var(--text-dim);' +
'font-family:var(--font-mono);font-size:11px;border-radius:4px;cursor:pointer;letter-spacing:1px">NEUES BILD</button>';
this._dropZone = document.getElementById('vlm-drop-zone');
this._setupDragDrop();
},
_setupDragDrop: function() {
var zone = this._dropZone;
if (!zone) return;
var self = this;
['dragenter', 'dragover'].forEach(function(evt) {
zone.addEventListener(evt, function(e) {
e.preventDefault();
e.stopPropagation();
zone.classList.add('vlm-drop-active');
});
});
['dragleave', 'drop'].forEach(function(evt) {
zone.addEventListener(evt, function(e) {
e.preventDefault();
e.stopPropagation();
zone.classList.remove('vlm-drop-active');
});
});
zone.addEventListener('drop', function(e) {
var file = e.dataTransfer.files[0];
if (file) self._handleFile(file);
});
zone.addEventListener('click', function() {
document.getElementById('vlm-file-input').click();
});
},
_onFileSelect: function(input) {
if (input.files && input.files[0]) {
this._handleFile(input.files[0]);
}
},
_handleFile: function(file) {
// Validierung
var allowed = ['image/png', 'image/jpeg', 'image/webp'];
if (allowed.indexOf(file.type) === -1) {
alert('Ungültiger Dateityp. Erlaubt: PNG, JPG, WEBP');
return;
}
if (file.size > 10 * 1024 * 1024) {
alert('Bild zu gross (max 10MB)');
return;
}
// Preview
this._showPreview(file);
// Upload + Analyse
this._uploadAndAnalyze(file);
},
_showPreview: function(file) {
var previewEl = document.getElementById('vlm-preview');
if (!previewEl) return;
var reader = new FileReader();
reader.onload = function(e) {
previewEl.innerHTML = '<img src="' + e.target.result + '" alt="Vorschau">' +
'<div style="font-size:10px;color:var(--text-dim);margin-top:4px">' + file.name +
' (' + (file.size / 1024).toFixed(0) + ' KB)</div>';
previewEl.style.display = 'block';
};
reader.readAsDataURL(file);
},
_getViewportInfo: function() {
if (!Globe.viewer) return '';
var cam = Globe.viewer.camera;
var carto = cam.positionCartographic;
if (!carto) return '';
var lat = Cesium.Math.toDegrees(carto.latitude).toFixed(2);
var lon = Cesium.Math.toDegrees(carto.longitude).toFixed(2);
var alt = (carto.height / 1000).toFixed(0);
return 'Lat ' + lat + ', Lon ' + lon + ', Hoehe ' + alt + ' km';
},
_uploadAndAnalyze: function(file) {
this._isAnalyzing = true;
this._dropZone.style.display = 'none';
var loadingEl = document.getElementById('vlm-loading');
if (loadingEl) loadingEl.style.display = 'block';
var formData = new FormData();
formData.append('file', file);
formData.append('viewport_info', this._getViewportInfo());
var self = this;
fetch('/api/vlm/analyze', {
method: 'POST',
body: formData,
})
.then(function(r) {
if (r.status === 429) {
throw new Error('Eine Analyse laeuft bereits. Bitte warten.');
}
if (!r.ok) {
return r.json().then(function(d) { throw new Error(d.detail || 'Fehler ' + r.status); });
}
return r.json();
})
.then(function(data) {
self._currentAnalysis = data;
self._showResults(data);
})
.catch(function(e) {
alert('VLM-Fehler: ' + e.message);
self._reset();
})
.finally(function() {
self._isAnalyzing = false;
if (loadingEl) loadingEl.style.display = 'none';
});
},
_showResults: function(data) {
var resultsEl = document.getElementById('vlm-results');
var resetBtn = document.getElementById('vlm-reset-btn');
if (!resultsEl) return;
// Szene
var sceneEl = document.getElementById('vlm-scene');
if (sceneEl) {
var text = data.scene_description || 'Keine Beschreibung';
if (data.terrain) text += ' | Gelände: ' + data.terrain;
if (data.estimated_location_type) text += ' | Region: ' + data.estimated_location_type;
sceneEl.textContent = text;
}
// Objekte
var objectsEl = document.getElementById('vlm-objects');
if (objectsEl) {
var html = '';
var objects = data.objects || [];
objects.forEach(function(obj, idx) {
var confClass = 'vlm-confidence-' + obj.confidence;
var confLabel = { high: 'HIGH', medium: 'MED', low: 'LOW' }[obj.confidence] || '?';
var checked = obj.confidence !== 'low' ? ' checked' : '';
var milBadge = obj.military_relevant ? '<span class="vlm-badge-mil">MIL</span>' : '';
html += '<div class="vlm-object-card">' +
'<input type="checkbox" id="vlm-obj-' + idx + '"' + checked + ' style="accent-color:var(--accent);width:16px;height:16px;flex-shrink:0">' +
'<div style="flex:1;min-width:0">' +
'<div style="font-weight:700;color:var(--text)">' + obj.type.replace(/_/g, ' ') + '</div>' +
'<div style="font-size:10px;color:var(--text-dim);margin-top:2px">' + obj.description + '</div>' +
'</div>' +
'<span class="' + confClass + '" style="font-size:10px;font-weight:700;flex-shrink:0">' + confLabel + '</span>' +
milBadge +
'</div>';
});
if (objects.length === 0) {
html = '<div style="color:var(--text-dim);font-size:12px">Keine Objekte erkannt</div>';
}
objectsEl.innerHTML = html;
}
resultsEl.style.display = 'block';
if (resetBtn) resetBtn.style.display = 'block';
},
_confirmAndSearch: function() {
if (!this._currentAnalysis) return;
// Ausgewaehlte Objekte sammeln
var objects = this._currentAnalysis.objects || [];
var selected = [];
objects.forEach(function(obj, idx) {
var cb = document.getElementById('vlm-obj-' + idx);
if (cb && cb.checked) {
selected.push(obj);
}
});
if (selected.length === 0) {
alert('Bitte mindestens ein Objekt auswaehlen');
return;
}
// BBox vom Viewport
var bbox = null;
if (Globe.viewer) {
var rect = Globe.viewer.camera.computeViewRectangle();
if (rect) {
bbox = [
Cesium.Math.toDegrees(rect.south),
Cesium.Math.toDegrees(rect.west),
Cesium.Math.toDegrees(rect.north),
Cesium.Math.toDegrees(rect.east),
];
}
}
var btn = document.getElementById('vlm-search-btn');
if (btn) { btn.disabled = true; btn.textContent = 'SUCHE LAEUFT...'; }
var searchResult = document.getElementById('vlm-search-result');
if (searchResult) { searchResult.style.display = 'block'; searchResult.textContent = 'Generiere Overpass-Queries...'; searchResult.style.color = 'var(--text-dim)'; }
// Queries generieren
fetch('/api/vlm/generate-queries', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ objects: selected, bbox: bbox }),
})
.then(function(r) {
if (!r.ok) return r.json().then(function(d) { throw new Error(d.detail || 'Fehler'); });
return r.json();
})
.then(function(data) {
if (searchResult) searchResult.textContent = 'Query generiert (' + data.tag_count + ' Tags). Suche OSM-Daten...';
// Overpass-Layer aktivieren und Query ausfuehren
var overpassCb = document.getElementById('layer-overpass');
if (overpassCb && !overpassCb.checked) {
overpassCb.checked = true;
overpassCb.dispatchEvent(new Event('change'));
}
// Query an Overpass senden
OverpassLayer.setColor('#e040fb');
if (typeof OverpassUI !== 'undefined') {
OverpassUI.executeQueryDirect(data.query, bbox, '#e040fb');
}
if (searchResult) {
searchResult.textContent = 'Overpass-Suche gestartet';
searchResult.style.color = 'var(--accent)';
}
})
.catch(function(e) {
if (searchResult) {
searchResult.textContent = 'Fehler: ' + e.message;
searchResult.style.color = '#ff5252';
}
})
.finally(function() {
if (btn) { btn.disabled = false; btn.textContent = 'AUF GLOBE SUCHEN'; }
});
},
_reset: function() {
this._currentAnalysis = null;
this._isAnalyzing = false;
if (this._dropZone) this._dropZone.style.display = 'block';
var els = ['vlm-preview', 'vlm-loading', 'vlm-results', 'vlm-search-result'];
els.forEach(function(id) {
var el = document.getElementById(id);
if (el) el.style.display = 'none';
});
var resetBtn = document.getElementById('vlm-reset-btn');
if (resetBtn) resetBtn.style.display = 'none';
var fileInput = document.getElementById('vlm-file-input');
if (fileInput) fileInput.value = '';
var countEl = document.getElementById('count-vlm');
if (countEl) countEl.textContent = '-';
},
};