diff --git a/src/data_vlm.py b/src/data_vlm.py index a23e859..2ca4dfb 100644 --- a/src/data_vlm.py +++ b/src/data_vlm.py @@ -19,7 +19,7 @@ router = APIRouter() _CLAUDE_BIN = "/usr/bin/claude" _TIMEOUT = 90 _MAX_IMAGE_SIZE = 10 * 1024 * 1024 # 10 MB -_MAX_IMAGE_DIMENSION = 1500 +_MAX_IMAGE_DIMENSION = 2500 _ALLOWED_TYPES = {"image/png", "image/jpeg", "image/webp"} # Semaphore: max 1 gleichzeitige Analyse @@ -272,24 +272,76 @@ def _resize_image(input_path: str, output_path: str): img.save(output_path, "PNG", optimize=True) -async def _run_claude(image_path: str, viewport_info: str = "") -> dict: +def _extract_filename_timestamp(filename: str) -> str | None: + """Extrahiert Datum/Uhrzeit aus Dateinamen wie 20231010_155544.jpg.""" + import re + # Pattern: YYYYMMDD_HHMMSS oder YYYY-MM-DD_HH-MM-SS + m = re.search(r'(\d{4})(\d{2})(\d{2})[_-](\d{2})(\d{2})(\d{2})', filename or "") + if m: + return f"{m.group(1)}-{m.group(2)}-{m.group(3)}T{m.group(4)}:{m.group(5)}:{m.group(6)}" + m = re.search(r'(\d{4})-(\d{2})-(\d{2})[_T](\d{2})-(\d{2})-(\d{2})', filename or "") + if m: + return f"{m.group(1)}-{m.group(2)}-{m.group(3)}T{m.group(4)}:{m.group(5)}:{m.group(6)}" + return None + + +async def _run_claude(image_path: str, viewport_info: str = "", filename: str = "", exif_data: dict = None) -> dict: """Ruft Claude Code headless auf um ein Bild zu analysieren.""" + # Kontext aus Metadaten aufbauen (NICHT Viewport — das erzeugt Bias!) + context_parts = [] + + # Timestamp aus Dateiname oder EXIF + ts = None + if exif_data and exif_data.get("timestamp"): + ts = exif_data["timestamp"] + context_parts.append(f"EXIF-Zeitstempel: {ts}") + else: + ts = _extract_filename_timestamp(filename) + if ts: + context_parts.append(f"Dateiname-Zeitstempel: {ts} (Lokalzeit, Zeitzone unbekannt)") + + # Sonnenstand berechnen falls Timestamp vorhanden (fuer verschiedene Breitengrade) + if ts: + from data_geoint import _calc_sun_position + from datetime import datetime, timezone + try: + dt_str = ts.replace("Z", "+00:00") + if "+" not in dt_str and len(dt_str) <= 19: + dt_str += "+00:00" # UTC annehmen wenn keine TZ + dt = datetime.fromisoformat(dt_str) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + # Sonnenstand fuer verschiedene Breitengrade berechnen (Hilfe bei Eingrenzung) + sun_info = [] + for test_lat, label in [(48.0, "Sueddeutschland"), (51.0, "NRW/Mitteldeutschland"), (53.5, "Norddeutschland")]: + sun = _calc_sun_position(test_lat, 8.0, dt) + sun_info.append(f"{label} (Lat {test_lat}): Elevation {sun['elevation']}°, Azimut {sun['azimuth']}°") + context_parts.append("Sonnenstand zum Aufnahmezeitpunkt: " + "; ".join(sun_info)) + except Exception: + pass + + # EXIF-GPS als starker Hinweis + if exif_data and exif_data.get("has_gps"): + context_parts.append(f"EXIF-GPS: {exif_data['latitude']}, {exif_data['longitude']} (verifizierte Position!)") + context = "" - if viewport_info: - context = f"Kontext: Der Nutzer schaut gerade auf {viewport_info}. " + if context_parts: + context = "Metadaten: " + " | ".join(context_parts) + ". " prompt = ( f"Lies die Bilddatei {image_path} mit dem Read-Tool. " f"{context}" "PRIMAERZIEL: Bestimme den Aufnahmeort so praezise wie moeglich (Reverse Geolocation). " + "WICHTIG: Schaetze die Position AUSSCHLIESSLICH anhand des Bildinhalts. " + "Lass dich NICHT von Viewport-Positionen oder Vermutungen beeinflussen. " "Schaetze in estimated_coordinates konkrete Lat/Lon-Werte und einen Unsicherheitsradius. " "Nutze ALLE visuellen Hinweise: " - "1) Gewaesser: Identifiziere den konkreten Fluss/See (Rhein, Donau, Elbe, etc.) anhand von Breite, Wasserfarbe, Uferform, Kiesbaenke, Stroemungsrichtung, Auenlandschaft. " - "2) Vegetation: Baumart, Jahreszeit, Reifegrad. Schilf+Kies+Auwald am breiten Fluss = typisch Rhein/Niederrhein. " + "1) Gewaesser: Identifiziere den konkreten Fluss/See anhand von Breite, Wasserfarbe, Uferform, Kiesbaenke, Stroemung. " + "Ein breiter Fluss (>200m) mit hellen Kiesbaenken und Schilfzonen in Mitteleuropa ist wahrscheinlich Rhein, Main oder Elbe. " + "2) Vegetation: Baumart, Jahreszeit, Reifegrad. " "3) Architektur, Schilder, Fahrzeuge, Infrastruktur im Hintergrund. " - "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. " + "4) Bodenfarbe und Gestein. Heller Rheinkies vs. dunkler Donauschotter. " + "5) Schatten: Richtung und Laenge. Vergleiche mit den Sonnenstand-Daten falls vorhanden. " "Fuelle identified_features (Gewaessername, Region, Landmarken) und landscape_clues komplett. " "Antworte ausschliesslich im vorgegebenen JSON-Format." ) @@ -386,14 +438,19 @@ async def analyze_image( if exif_data.get("has_gps"): logger.info(f"EXIF GPS: {exif_data['latitude']}, {exif_data['longitude']}") - # Claude Code aufrufen - result = await _run_claude(resized_path, viewport_info) + # Claude Code aufrufen (Dateiname + EXIF als Kontext, NICHT Viewport) + result = await _run_claude(resized_path, filename=file.filename, exif_data=exif_data) # EXIF-Daten zum Ergebnis hinzufuegen result["exif"] = exif_data + # Ergebnis loggen + ec = result.get("estimated_coordinates", {}) + idf = result.get("identified_features", {}) logger.info( - f"VLM-Analyse abgeschlossen: {len(result.get('objects', []))} Objekte erkannt" + f"VLM-Analyse abgeschlossen: {len(result.get('objects', []))} Objekte, " + f"Koordinaten: {ec.get('latitude')}/{ec.get('longitude')} (r={ec.get('confidence_radius_km')}km), " + f"Gewaesser: {idf.get('water_body')}, Region: {idf.get('specific_region')}" ) return result