feat: Praezisere Geolocation durch Koordinatenschaetzung + Feature-Erkennung

VLM-Schema:
- estimated_coordinates: Claude schaetzt Lat/Lon + Radius direkt
- identified_features: Konkreter Gewaessername, engste Region, Landmarken
- landscape_clues um water_characteristics erweitert

VLM-Prompt: Komplett ueberarbeitet
- Primaerziel ist jetzt Reverse Geolocation statt nur Objekterkennung
- Spezifische Anleitungen: Fluss-ID (Rhein vs Donau), Kiesfarbe, Uferform
- Explizite Aufforderung fuer Koordinatenschaetzung

BBox-Kaskade (Prioritaet):
1. EXIF-GPS (exakt)
2. estimated_coordinates + Radius (VLM-Schaetzung)
3. identified_features (Gewaesser/Region aus Matching)
4. estimated_location_type (grobe Region)
5. Weltweit (Fallback)

Regions-Mapping: 30+ neue Eintraege
- Deutsche Bundeslaender (NRW, Bayern, Hessen, etc.)
- Flussgebiete (Niederrhein, Oberrhein, Mosel, Elbe, etc.)
- Staedte (Duesseldorf, Koeln, Bonn, Neuss)

Frontend:
- Gruen hervorgehobene Koordinaten-Box mit Radius
- Identifizierte Features (Gewaesser, Region, Landmarken)
- Fly-To zur geschaetzten Position
Dieser Commit ist enthalten in:
Claude Dev
2026-03-26 10:38:22 +01:00
Ursprung 9709536036
Commit 51e85b2572
2 geänderte Dateien mit 150 neuen und 20 gelöschten Zeilen

Datei anzeigen

@@ -70,6 +70,24 @@ _VLM_SCHEMA = json.dumps({
"type": "string", "type": "string",
"description": "Geschaetzter Standort-Typ (z.B. Europa, Naher Osten, Nordamerika)" "description": "Geschaetzter Standort-Typ (z.B. Europa, Naher Osten, Nordamerika)"
}, },
"estimated_coordinates": {
"type": "object",
"properties": {
"latitude": {"type": "number", "description": "Geschaetzte Breite in Dezimalgrad (z.B. 51.2 fuer Duesseldorf-Region)"},
"longitude": {"type": "number", "description": "Geschaetzte Laenge in Dezimalgrad (z.B. 6.8 fuer Duesseldorf-Region)"},
"confidence_radius_km": {"type": "number", "description": "Unsicherheitsradius in km (z.B. 5 = sehr sicher, 50 = Region, 500 = nur Kontinent)"},
"reasoning": {"type": "string", "description": "Begruendung fuer die Koordinatenschaetzung"}
},
"description": "Beste Schaetzung der Aufnahmeposition basierend auf ALLEN visuellen Hinweisen. Versuche so praezise wie moeglich zu sein."
},
"identified_features": {
"type": "object",
"properties": {
"water_body": {"type": "string", "description": "Konkreter Name des Gewaessers falls bestimmbar (z.B. Rhein, Donau, Themse, Nil). Aus Breite, Uferform, Wasserfarbe, Umgebung ableiten."},
"specific_region": {"type": "string", "description": "Engstmoegliche Regionsbestimmung (z.B. Niederrhein, Oberrheingraben, Ruhrgebiet, Muenchner Schotterebene)"},
"nearby_landmarks": {"type": "string", "description": "Erkennbare Landmarken, Berge, Gebaeude, Infrastruktur in der Naehe"}
}
},
"landscape_clues": { "landscape_clues": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -80,11 +98,12 @@ _VLM_SCHEMA = json.dumps({
"signage_language": {"type": "string", "description": "Sprache auf Schildern falls erkennbar"}, "signage_language": {"type": "string", "description": "Sprache auf Schildern falls erkennbar"},
"vehicle_types": {"type": "string", "description": "Fahrzeugtypen und Fahrtrichtung (links/rechts)"}, "vehicle_types": {"type": "string", "description": "Fahrzeugtypen und Fahrtrichtung (links/rechts)"},
"climate_indicators": {"type": "string", "description": "Klimaindikatoren (Schnee, Wueste, tropisch, etc.)"}, "climate_indicators": {"type": "string", "description": "Klimaindikatoren (Schnee, Wueste, tropisch, etc.)"},
"sun_shadow_direction": {"type": "string", "description": "Schattenrichtung falls erkennbar (z.B. N, NW)"} "sun_shadow_direction": {"type": "string", "description": "Schattenrichtung falls erkennbar (z.B. N, NW)"},
"water_characteristics": {"type": "string", "description": "Gewaesser-Details: Breite, Farbe, Stroemung, Ufertyp, Kiesbank, Bewuchs"}
} }
} }
}, },
"required": ["scene_description", "objects"] "required": ["scene_description", "objects", "estimated_coordinates"]
}) })
# --- Objekt-Typ zu OverpassQL Mapping --- # --- Objekt-Typ zu OverpassQL Mapping ---
@@ -141,6 +160,39 @@ _REGION_BBOX = {
"central europe": (5, 45, 20, 56), "central europe": (5, 45, 20, 56),
"deutschland": (5.8, 47.2, 15.1, 55.1), "deutschland": (5.8, 47.2, 15.1, 55.1),
"germany": (5.8, 47.2, 15.1, 55.1), "germany": (5.8, 47.2, 15.1, 55.1),
# Deutsche Regionen
"nordrhein-westfalen": (5.8, 50.3, 9.5, 52.6),
"nrw": (5.8, 50.3, 9.5, 52.6),
"niederrhein": (6.0, 51.0, 7.0, 52.0),
"rheinland": (6.0, 50.3, 7.5, 51.5),
"ruhrgebiet": (6.5, 51.2, 7.8, 51.7),
"bayern": (9.0, 47.2, 13.9, 50.6),
"bavaria": (9.0, 47.2, 13.9, 50.6),
"baden-wuerttemberg": (7.5, 47.5, 10.5, 49.8),
"hessen": (7.7, 49.4, 10.3, 51.7),
"niedersachsen": (6.5, 51.3, 11.6, 53.9),
"sachsen": (11.8, 50.2, 15.1, 51.7),
"berlin": (13.0, 52.3, 13.8, 52.7),
"hamburg": (9.7, 53.4, 10.3, 53.7),
"schleswig-holstein": (8.3, 53.3, 11.4, 55.1),
"rheinland-pfalz": (6.1, 48.9, 8.5, 50.9),
# Flussgebiete
"oberrhein": (7.0, 47.5, 8.5, 49.0),
"oberrheingraben": (7.0, 47.5, 8.5, 49.0),
"mittelrhein": (6.8, 49.8, 7.8, 50.5),
"rhein": (6.0, 47.5, 8.5, 52.0),
"rhine": (6.0, 47.5, 8.5, 52.0),
"donau": (8.0, 47.5, 17.0, 49.0),
"danube": (8.0, 47.5, 29.0, 48.5),
"elbe": (9.5, 50.5, 14.0, 54.0),
"mosel": (6.0, 49.0, 7.6, 50.4),
"main": (8.0, 49.5, 11.5, 50.3),
"weser": (8.5, 51.0, 9.8, 53.5),
"neuss": (6.6, 51.1, 6.9, 51.3),
"duesseldorf": (6.6, 51.1, 6.9, 51.3),
"koeln": (6.8, 50.8, 7.1, 51.1),
"cologne": (6.8, 50.8, 7.1, 51.1),
"bonn": (7.0, 50.6, 7.2, 50.8),
# Naher Osten # Naher Osten
"naher osten": (25, 12, 65, 42), "naher osten": (25, 12, 65, 42),
"middle east": (25, 12, 65, 42), "middle east": (25, 12, 65, 42),
@@ -229,13 +281,16 @@ async def _run_claude(image_path: str, viewport_info: str = "") -> dict:
prompt = ( prompt = (
f"Lies die Bilddatei {image_path} mit dem Read-Tool. " f"Lies die Bilddatei {image_path} mit dem Read-Tool. "
f"{context}" f"{context}"
"Analysiere das Bild fuer GEOINT/OSINT-Zwecke. " "PRIMAERZIEL: Bestimme den Aufnahmeort so praezise wie moeglich (Reverse Geolocation). "
"Identifiziere alle erkennbaren Objekte, Infrastruktur und militaerisch relevante Strukturen. " "Schaetze in estimated_coordinates konkrete Lat/Lon-Werte und einen Unsicherheitsradius. "
"Gib fuer jedes Objekt passende OpenStreetMap-Tags an. " "Nutze ALLE visuellen Hinweise: "
"Analysiere ausserdem Landschaftsmerkmale fuer Reverse Geolocation: " "1) Gewaesser: Identifiziere den konkreten Fluss/See (Rhein, Donau, Elbe, etc.) anhand von Breite, Wasserfarbe, Uferform, Kiesbaenke, Stroemungsrichtung, Auenlandschaft. "
"Vegetation, Bodenfarbe, Strassenmarkierungen, Architekturstil, Schildersprache, " "2) Vegetation: Baumart, Jahreszeit, Reifegrad. Schilf+Kies+Auwald am breiten Fluss = typisch Rhein/Niederrhein. "
"Fahrzeugtypen und Fahrtrichtung, Klimaindikatoren, Schattenrichtung. " "3) Architektur, Schilder, Fahrzeuge, Infrastruktur im Hintergrund. "
"Fuelle das landscape_clues Objekt so detailliert wie moeglich. " "4) Bodenfarbe, Gestein, Gelaendeform. Rheinischer Kies ist hell, Donauschlick dunkel. "
"5) Sonne/Schatten: Richtung und Laenge fuer Breitengrad-Schaetzung. "
"Identifiziere ausserdem Objekte und militaerisch relevante Strukturen mit OSM-Tags. "
"Fuelle identified_features (Gewaessername, Region, Landmarken) und landscape_clues komplett. "
"Antworte ausschliesslich im vorgegebenen JSON-Format." "Antworte ausschliesslich im vorgegebenen JSON-Format."
) )
@@ -359,6 +414,8 @@ class QueryGenRequest(BaseModel):
objects: list[dict] objects: list[dict]
bbox: list[float] | None = None bbox: list[float] | None = None
estimated_location_type: str | None = None estimated_location_type: str | None = None
estimated_coordinates: dict | None = None
identified_features: dict | None = None
@router.post("/vlm/generate-queries") @router.post("/vlm/generate-queries")
@@ -367,14 +424,46 @@ async def generate_queries(req: QueryGenRequest):
if not req.objects: if not req.objects:
raise HTTPException(400, "Keine Objekte angegeben") raise HTTPException(400, "Keine Objekte angegeben")
# BBox bestimmen: explizit > Region > weltweit # BBox bestimmen: explizite BBox > estimated_coordinates > identified_features > Region > weltweit
bbox = req.bbox bbox = req.bbox
region_used = None region_used = None
bbox_source = "explicit" if bbox else None
# 1. Geschaetzte Koordinaten mit Radius (praeziseste Quelle)
if not bbox and req.estimated_coordinates:
ec = req.estimated_coordinates
lat = ec.get("latitude")
lon = ec.get("longitude")
radius_km = ec.get("confidence_radius_km", 50)
if lat is not None and lon is not None:
# Radius in Grad umrechnen (1 Grad ~ 111 km)
radius_deg = max(0.1, min(radius_km / 111, 10))
bbox = [lat - radius_deg, lon - radius_deg, lat + radius_deg, lon + radius_deg]
bbox_source = f"coordinates ({lat:.2f}, {lon:.2f}, r={radius_km}km)"
logger.info(f"BBox aus estimated_coordinates: {lat}, {lon}, radius {radius_km}km -> {bbox}")
# 2. Identifizierte Features (Gewaesser, Region)
if not bbox and req.identified_features:
for key in ["specific_region", "water_body"]:
text = req.identified_features.get(key, "")
if text:
region_bbox = _resolve_region_bbox(text)
if region_bbox:
bbox = [region_bbox[1], region_bbox[0], region_bbox[3], region_bbox[2]]
region_used = text
bbox_source = f"feature: {key}={text}"
break
# 3. Fallback: estimated_location_type
if not bbox and req.estimated_location_type: if not bbox and req.estimated_location_type:
region_bbox = _resolve_region_bbox(req.estimated_location_type) region_bbox = _resolve_region_bbox(req.estimated_location_type)
if region_bbox: if region_bbox:
bbox = [region_bbox[1], region_bbox[0], region_bbox[3], region_bbox[2]] # S,W,N,E bbox = [region_bbox[1], region_bbox[0], region_bbox[3], region_bbox[2]]
region_used = req.estimated_location_type region_used = req.estimated_location_type
bbox_source = f"region: {req.estimated_location_type}"
if bbox_source:
logger.info(f"BBox-Quelle: {bbox_source}")
bbox_str = "" bbox_str = ""
if bbox and len(bbox) == 4: if bbox and len(bbox) == 4:

Datei anzeigen

@@ -238,14 +238,41 @@ const VlmUI = {
} }
} }
// Geschaetzte Koordinaten
var coordsHtml = '';
if (data.estimated_coordinates && data.estimated_coordinates.latitude) {
var ec = data.estimated_coordinates;
var rKm = ec.confidence_radius_km || '?';
coordsHtml = '<div class="vlm-exif-card" style="border-color:rgba(0,255,136,0.3);background:rgba(0,255,136,0.06);margin-top:8px">' +
'<div style="font-size:9px;letter-spacing:1.5px;color:var(--accent);margin-bottom:4px">GESCHAETZTE POSITION</div>' +
'<div style="font-size:13px;color:var(--accent);font-weight:700">' + ec.latitude.toFixed(4) + ', ' + ec.longitude.toFixed(4) +
' <span style="font-size:11px;font-weight:400;color:var(--text-dim)">(&#177;' + rKm + ' km)</span></div>' +
(ec.reasoning ? '<div style="font-size:10px;color:var(--text-dim);margin-top:4px">' + ec.reasoning + '</div>' : '') +
'</div>';
}
// Identifizierte Features
var featHtml = '';
if (data.identified_features) {
var if_ = data.identified_features;
var fp = [];
if (if_.water_body) fp.push('<b>Gewaesser:</b> ' + if_.water_body);
if (if_.specific_region) fp.push('<b>Region:</b> ' + if_.specific_region);
if (if_.nearby_landmarks) fp.push('<b>Landmarken:</b> ' + if_.nearby_landmarks);
if (fp.length > 0) {
featHtml = '<div style="font-size:9px;letter-spacing:1.5px;color:#00bcd4;margin:8px 0 4px">IDENTIFIZIERTE MERKMALE</div>' +
'<div style="font-size:11px;color:var(--text);line-height:1.8">' + fp.join('<br>') + '</div>';
}
}
// Szene // Szene
var sceneEl = document.getElementById('vlm-scene'); var sceneEl = document.getElementById('vlm-scene');
if (sceneEl) { if (sceneEl) {
sceneEl.innerHTML = exifHtml + sceneEl.innerHTML = exifHtml + coordsHtml +
'<div style="margin-top:8px">' + (data.scene_description || 'Keine Beschreibung') + '<div style="margin-top:8px">' + (data.scene_description || 'Keine Beschreibung') +
(data.terrain ? ' | Gelaende: ' + data.terrain : '') + (data.terrain ? ' | Gelaende: ' + data.terrain : '') +
(data.estimated_location_type ? ' | Region: ' + data.estimated_location_type : '') + (data.estimated_location_type ? ' | Region: ' + data.estimated_location_type : '') +
'</div>' + cluesHtml; '</div>' + featHtml + cluesHtml;
} }
// Objekte // Objekte
@@ -350,16 +377,29 @@ const VlmUI = {
}); });
} }
// === BBox bestimmen: EXIF-GPS > Viewport-Checkbox > Region === // === BBox bestimmen: EXIF-GPS > estimated_coordinates > Viewport > Region (Backend) ===
var bbox = null; var bbox = null;
var ec = analysis.estimated_coordinates;
var hasEstCoords = ec && ec.latitude && ec.longitude;
if (hasExifGps) { if (hasExifGps) {
// 0.5 Grad um EXIF-Position
bbox = [ bbox = [
exif.latitude - 0.5, exif.latitude - 0.5, exif.longitude - 0.5,
exif.longitude - 0.5, exif.latitude + 0.5, exif.longitude + 0.5,
exif.latitude + 0.5,
exif.longitude + 0.5,
]; ];
} else if (hasEstCoords) {
// VLM-geschaetzte Koordinaten: Radius in Grad
var rKm = ec.confidence_radius_km || 50;
var rDeg = Math.max(0.1, Math.min(rKm / 111, 10));
bbox = [
ec.latitude - rDeg, ec.longitude - rDeg,
ec.latitude + rDeg, ec.longitude + rDeg,
];
// Zur geschaetzten Position fliegen
Globe.viewer.camera.flyTo({
destination: Cesium.Cartesian3.fromDegrees(ec.longitude, ec.latitude, Math.max(50000, rKm * 2000)),
duration: 2,
});
} else { } else {
var bboxCb = document.getElementById('vlm-use-bbox'); var bboxCb = document.getElementById('vlm-use-bbox');
if (bboxCb && bboxCb.checked && Globe.viewer) { if (bboxCb && bboxCb.checked && Globe.viewer) {
@@ -373,7 +413,6 @@ const VlmUI = {
]; ];
} }
} }
// Kein explizites BBox? Region wird vom Backend aus estimated_location_type abgeleitet
} }
var btn = document.getElementById('vlm-search-btn'); var btn = document.getElementById('vlm-search-btn');
@@ -393,6 +432,8 @@ const VlmUI = {
objects: selected, objects: selected,
bbox: bbox, bbox: bbox,
estimated_location_type: analysis.estimated_location_type || null, estimated_location_type: analysis.estimated_location_type || null,
estimated_coordinates: analysis.estimated_coordinates || null,
identified_features: analysis.identified_features || null,
}), }),
}) })
.then(function(r) { .then(function(r) {