#!/usr/bin/env python3 """Native Tkinter OpenStreetMap player for raw GPS and ESKF positions.""" from __future__ import annotations import argparse import csv import io import math import queue import threading import urllib.request from concurrent.futures import ThreadPoolExecutor from dataclasses import dataclass from pathlib import Path import numpy as np import tkinter as tk from PIL import Image, ImageDraw, ImageTk from tkinter import ttk from fusion_eskf import enu_to_geodetic BASE_DIR = Path(__file__).resolve().parent TILE_SIZE = 256 MAX_MERCATOR_LAT = 85.05112878 @dataclass class MapSample: t: float raw_lat: float raw_lon: float raw_height: float fused_lat: float fused_lon: float fused_height: float accepted: bool mahalanobis: float separation_m: float def load_samples(gps_path: Path, eskf_path: Path) -> list[MapSample]: with gps_path.open(newline="", encoding="utf-8") as stream: gps = list(csv.DictReader(stream)) with eskf_path.open(newline="", encoding="utf-8") as stream: eskf = list(csv.DictReader(stream)) if not gps or len(gps) != len(eskf): raise ValueError( f"GPS/ESKF row mismatch: gps={len(gps)}, eskf={len(eskf)}" ) reference = ( float(np.median([float(row["lat_deg"]) for row in gps])), float(np.median([float(row["lon_deg"]) for row in gps])), float(np.median([float(row["height_m"]) for row in gps])), ) fused_enu = np.array( [ [float(row["eskf_e_m"]), float(row["eskf_n_m"]), float(row["eskf_u_m"])] for row in eskf ] ) fused_lla = enu_to_geodetic(fused_enu, reference) result = [] for raw, filtered, corrected in zip(gps, eskf, fused_lla): result.append( MapSample( t=float(filtered["t_s"]), raw_lat=float(raw["lat_deg"]), raw_lon=float(raw["lon_deg"]), raw_height=float(raw["height_m"]), fused_lat=float(corrected[0]), fused_lon=float(corrected[1]), fused_height=float(corrected[2]), accepted=bool(int(filtered["gps_accepted"])), mahalanobis=float(filtered["mahalanobis2"]), separation_m=math.hypot( float(filtered["gps_e_m"]) - float(filtered["eskf_e_m"]), float(filtered["gps_n_m"]) - float(filtered["eskf_n_m"]), ), ) ) return result def load_comparison_samples(path: Path, model: str) -> list[MapSample]: """Load fixed/adaptive model_comparison.csv without an intermediate conversion.""" with path.open(newline="", encoding="utf-8") as stream: rows = list(csv.DictReader(stream)) if not rows: raise ValueError(f"empty model-comparison CSV: {path}") prefix = "fixed" if model == "fixed" else "adaptive" required = { "t_s", "lat_deg", "lon_deg", "gps_e_m", "gps_n_m", f"{prefix}_e_m", f"{prefix}_n_m", f"{prefix}_gps_accepted", f"{prefix}_nis", } missing = required.difference(rows[0]) if missing: raise ValueError(f"comparison CSV missing columns: {sorted(missing)}") heights = np.array([float(row.get("height_m") or 0.0) for row in rows]) reference = ( float(np.median([float(row["lat_deg"]) for row in rows])), float(np.median([float(row["lon_deg"]) for row in rows])), float(np.median(heights)), ) fused_enu = np.array( [ [float(row[f"{prefix}_e_m"]), float(row[f"{prefix}_n_m"]), 0.0] for row in rows ] ) fused_lla = enu_to_geodetic(fused_enu, reference) result = [] for row, corrected, raw_height in zip(rows, fused_lla, heights): result.append( MapSample( t=float(row["t_s"]), raw_lat=float(row["lat_deg"]), raw_lon=float(row["lon_deg"]), raw_height=float(raw_height), fused_lat=float(corrected[0]), fused_lon=float(corrected[1]), fused_height=float(corrected[2]), accepted=bool(int(row[f"{prefix}_gps_accepted"])), mahalanobis=float(row[f"{prefix}_nis"]), separation_m=math.hypot( float(row["gps_e_m"]) - float(row[f"{prefix}_e_m"]), float(row["gps_n_m"]) - float(row[f"{prefix}_n_m"]), ), ) ) return result def latlon_to_world(lat: float, lon: float, zoom: int) -> tuple[float, float]: lat = max(-MAX_MERCATOR_LAT, min(MAX_MERCATOR_LAT, lat)) scale = TILE_SIZE * (2**zoom) x = (lon + 180.0) / 360.0 * scale lat_rad = math.radians(lat) y = (1.0 - math.asinh(math.tan(lat_rad)) / math.pi) / 2.0 * scale return x, y def world_to_latlon(x: float, y: float, zoom: int) -> tuple[float, float]: scale = TILE_SIZE * (2**zoom) lon = x / scale * 360.0 - 180.0 mercator = math.pi * (1.0 - 2.0 * y / scale) lat = math.degrees(math.atan(math.sinh(mercator))) return lat, lon class OsmTileStore: def __init__(self, root: tk.Tk, cache_dir: Path, redraw): self.root = root self.cache_dir = cache_dir self.redraw = redraw self.cache_dir.mkdir(parents=True, exist_ok=True) self.executor = ThreadPoolExecutor(max_workers=3, thread_name_prefix="osm") self.pending: set[tuple[int, int, int]] = set() self.completed: queue.Queue[tuple[tuple[int, int, int], str | None]] = queue.Queue() self.closed = False self.root.after(100, self._poll) def path(self, z: int, x: int, y: int) -> Path: return self.cache_dir / str(z) / str(x) / f"{y}.png" def request(self, z: int, x: int, y: int): key = (z, x, y) if key in self.pending or self.path(*key).exists() or self.closed: return self.pending.add(key) self.executor.submit(self._download, key) def _download(self, key): z, x, y = key path = self.path(z, x, y) error = None try: request = urllib.request.Request( f"https://tile.openstreetmap.org/{z}/{x}/{y}.png", headers={"User-Agent": "Rocket-GPS-Fusion/1.0 (local engineering viewer)"}, ) with urllib.request.urlopen(request, timeout=12) as response: content = response.read() image = Image.open(io.BytesIO(content)) image.verify() path.parent.mkdir(parents=True, exist_ok=True) path.write_bytes(content) except Exception as exc: # network/cache failure is shown in the window error = str(exc) self.completed.put((key, error)) def _poll(self): changed = False errors = [] while True: try: key, error = self.completed.get_nowait() except queue.Empty: break self.pending.discard(key) changed = True if error: errors.append(error) if changed: self.redraw(errors[-1] if errors else None) if not self.closed: self.root.after(100, self._poll) def close(self): self.closed = True self.executor.shutdown(wait=False, cancel_futures=True) class GpsMapPlayer: def __init__(self, root: tk.Tk, samples: list[MapSample], cache_dir: Path): self.root = root self.samples = samples self.index = 0 self.play_job = None self.zoom = 19 self.center_lat = float(np.median([sample.raw_lat for sample in samples])) self.center_lon = float(np.median([sample.raw_lon for sample in samples])) self.drag_start = None self.internal_slider = False self.map_photo = None self.tile_error = None self.resize_job = None root.title("GPS / IMU ESKF — OpenStreetMap sample oynatıcı") root.geometry("1180x780") root.minsize(760, 520) controls = ttk.Frame(root, padding=(8, 7)) controls.pack(side=tk.TOP, fill=tk.X) self.play_button = ttk.Button(controls, text="▶ Oynat", command=self.toggle_play) self.play_button.pack(side=tk.LEFT) ttk.Button(controls, text="↺ Başa al", command=self.reset).pack(side=tk.LEFT, padx=(5, 0)) ttk.Button(controls, text="İzleri sığdır", command=self.fit_tracks).pack(side=tk.LEFT, padx=(5, 0)) ttk.Button(controls, text="Konuma git", command=self.center_current).pack(side=tk.LEFT, padx=(5, 0)) ttk.Button(controls, text="Haritayı yenile", command=self.draw).pack(side=tk.LEFT, padx=(5, 0)) ttk.Button(controls, text="−", width=3, command=lambda: self.change_zoom(-1)).pack(side=tk.LEFT, padx=(14, 0)) ttk.Button(controls, text="+", width=3, command=lambda: self.change_zoom(1)).pack(side=tk.LEFT) ttk.Label(controls, text="Hız:").pack(side=tk.LEFT, padx=(14, 4)) self.speed = tk.StringVar(value="1") ttk.Combobox(controls, textvariable=self.speed, values=("0.5", "1", "2", "4"), width=4, state="readonly").pack(side=tk.LEFT) self.tick_label = ttk.Label(controls, text="") self.tick_label.pack(side=tk.RIGHT) timeline = ttk.Frame(root, padding=(8, 0, 8, 6)) timeline.pack(side=tk.TOP, fill=tk.X) self.slider = tk.Scale( timeline, from_=0, to=len(samples) - 1, resolution=1, orient=tk.HORIZONTAL, showvalue=False, command=self.on_slider, highlightthickness=0, ) self.slider.pack(side=tk.LEFT, fill=tk.X, expand=True) self.time_label = ttk.Label(timeline, width=11, anchor=tk.E) self.time_label.pack(side=tk.RIGHT) self.canvas = tk.Canvas(root, bg="#d9dde0", highlightthickness=0) self.canvas.pack(side=tk.TOP, fill=tk.BOTH, expand=True) self.info = ttk.Label(root, padding=(9, 6), anchor=tk.W) self.info.pack(side=tk.BOTTOM, fill=tk.X) self.canvas.bind("", self.pan_start) self.canvas.bind("", self.pan_move) self.canvas.bind("", self.mouse_zoom) self.canvas.bind("", lambda event: self.change_zoom(1)) self.canvas.bind("", lambda event: self.change_zoom(-1)) self.canvas.bind("", self.on_resize) root.bind("", lambda event: self.toggle_play()) root.protocol("WM_DELETE_WINDOW", self.close) self.tiles = OsmTileStore(root, cache_dir, self.tile_update) root.after(150, self.fit_tracks) def tile_update(self, error=None): if error: self.tile_error = error self.draw() def project(self, lat, lon): world_x, world_y = latlon_to_world(lat, lon, self.zoom) center_x, center_y = latlon_to_world(self.center_lat, self.center_lon, self.zoom) return ( self.canvas.winfo_width() / 2 + world_x - center_x, self.canvas.winfo_height() / 2 + world_y - center_y, ) def draw(self): if not self.canvas.winfo_exists(): return self.canvas.delete("all") width = max(1, self.canvas.winfo_width()) height = max(1, self.canvas.winfo_height()) map_image = Image.new("RGB", (width, height), "#d9dde0") image_draw = ImageDraw.Draw(map_image) center_x, center_y = latlon_to_world(self.center_lat, self.center_lon, self.zoom) left, top = center_x - width / 2, center_y - height / 2 count = 2**self.zoom tx0, tx1 = math.floor(left / TILE_SIZE), math.floor((left + width) / TILE_SIZE) ty0, ty1 = math.floor(top / TILE_SIZE), math.floor((top + height) / TILE_SIZE) loaded_tiles = 0 visible_tiles = 0 for tile_y in range(ty0, ty1 + 1): if tile_y < 0 or tile_y >= count: continue for tile_x_unwrapped in range(tx0, tx1 + 1): visible_tiles += 1 tile_x = tile_x_unwrapped % count px = round(tile_x_unwrapped * TILE_SIZE - left) py = round(tile_y * TILE_SIZE - top) path = self.tiles.path(self.zoom, tile_x, tile_y) if path.exists(): try: with Image.open(path) as tile: map_image.paste(tile.convert("RGB"), (px, py)) loaded_tiles += 1 except Exception: path.unlink(missing_ok=True) self.tiles.request(self.zoom, tile_x, tile_y) else: image_draw.rectangle((px, py, px + TILE_SIZE, py + TILE_SIZE), fill="#e3e6e8", outline="#cdd1d4") self.tiles.request(self.zoom, tile_x, tile_y) self.map_photo = ImageTk.PhotoImage(map_image) self.canvas.create_image(0, 0, image=self.map_photo, anchor=tk.NW) shown = self.samples[: self.index + 1] self.draw_track([(s.raw_lat, s.raw_lon) for s in shown], "#d62728", 2) self.draw_track([(s.fused_lat, s.fused_lon) for s in shown], "#1769aa", 3) for sample in shown: x, y = self.project(sample.raw_lat, sample.raw_lon) color = "#d62728" if sample.accepted else "#e67e22" self.canvas.create_oval(x - 2, y - 2, x + 2, y + 2, fill=color, outline="") x, y = self.project(sample.fused_lat, sample.fused_lon) self.canvas.create_oval(x - 2, y - 2, x + 2, y + 2, fill="#1769aa", outline="") current = self.samples[self.index] self.draw_marker(current.raw_lat, current.raw_lon, "#d62728" if current.accepted else "#e67e22", 8) self.draw_marker(current.fused_lat, current.fused_lon, "#2196f3", 7) self.canvas.create_rectangle(10, 10, 205, 77, fill="white", outline="#888") self.canvas.create_oval(22, 23, 32, 33, fill="#d62728", outline="") self.canvas.create_text(40, 28, text="Ham GPS", anchor=tk.W) self.canvas.create_oval(22, 43, 32, 53, fill="#2196f3", outline="") self.canvas.create_text(40, 48, text="ESKF + ZUPT", anchor=tk.W) self.canvas.create_oval(22, 61, 32, 71, fill="#e67e22", outline="") self.canvas.create_text(40, 66, text="Reddedilen GPS", anchor=tk.W) self.canvas.create_text(width - 8, height - 7, text="© OpenStreetMap contributors", anchor=tk.SE, fill="#333") status = f"OSM {loaded_tiles}/{visible_tiles} tile" if self.tile_error and loaded_tiles == 0: status += f" — bağlantı hatası: {self.tile_error}" elif loaded_tiles < visible_tiles: status += " yükleniyor…" self.canvas.create_rectangle(width - 245, 8, width - 8, 32, fill="white", outline="#999") self.canvas.create_text(width - 16, 20, text=status, anchor=tk.E, fill="#333") self.update_labels() def draw_track(self, coordinates, color, width): if len(coordinates) > 1: pixels = [value for coordinate in coordinates for value in self.project(*coordinate)] self.canvas.create_line(*pixels, fill=color, width=width, smooth=False) def draw_marker(self, lat, lon, color, radius): x, y = self.project(lat, lon) self.canvas.create_oval(x - radius, y - radius, x + radius, y + radius, fill=color, outline="white", width=2) def update_labels(self): sample = self.samples[self.index] self.tick_label.configure(text=f"Sample {self.index + 1} / {len(self.samples)}") self.time_label.configure(text=f"{sample.t:.3f} s") gate = "KABUL" if sample.accepted else "RED" self.info.configure( text=( f"GPS {sample.raw_lat:.8f}, {sample.raw_lon:.8f}, h={sample.raw_height:.1f} m | " f"ESKF {sample.fused_lat:.8f}, {sample.fused_lon:.8f}, h={sample.fused_height:.1f} m | " f"yatay fark={sample.separation_m:.2f} m GPS={gate} NIS={sample.mahalanobis:.2f}" ) ) def on_slider(self, value): if self.internal_slider: return requested_index = int(float(value)) # Scale.set() also invokes this callback on some Tk builds. An unchanged # value is therefore a programmatic playback update, not user scrubbing. if requested_index == self.index: return self.stop() self.index = requested_index self.draw() def set_index(self, index): self.index = max(0, min(len(self.samples) - 1, index)) self.internal_slider = True self.slider.set(self.index) self.internal_slider = False self.draw() def toggle_play(self): if self.play_job is not None: self.stop() return if self.index >= len(self.samples) - 1: self.set_index(0) self.play_button.configure(text="⏸ Duraklat") self.schedule_next() def schedule_next(self): if self.index >= len(self.samples) - 1: self.stop() return dt = self.samples[self.index + 1].t - self.samples[self.index].t delay = max(60, min(2000, int(dt * 1000 / float(self.speed.get())))) self.play_job = self.root.after(delay, self.play_step) def play_step(self): self.play_job = None self.set_index(self.index + 1) self.schedule_next() def stop(self): if self.play_job is not None: self.root.after_cancel(self.play_job) self.play_job = None self.play_button.configure(text="▶ Oynat") def reset(self): self.stop() self.set_index(0) def center_current(self): sample = self.samples[self.index] self.center_lat = (sample.raw_lat + sample.fused_lat) / 2.0 self.center_lon = (sample.raw_lon + sample.fused_lon) / 2.0 self.zoom = 20 self.draw() def fit_tracks(self): width = max(400, self.canvas.winfo_width()) height = max(300, self.canvas.winfo_height()) coordinates = [(s.raw_lat, s.raw_lon) for s in self.samples] coordinates += [(s.fused_lat, s.fused_lon) for s in self.samples] self.center_lat = (min(x[0] for x in coordinates) + max(x[0] for x in coordinates)) / 2 self.center_lon = (min(x[1] for x in coordinates) + max(x[1] for x in coordinates)) / 2 for zoom in range(22, 1, -1): pixels = [latlon_to_world(lat, lon, zoom) for lat, lon in coordinates] span_x = max(x for x, _ in pixels) - min(x for x, _ in pixels) span_y = max(y for _, y in pixels) - min(y for _, y in pixels) if span_x <= width * 0.75 and span_y <= height * 0.75: self.zoom = zoom break self.draw() def change_zoom(self, delta): self.zoom = max(2, min(22, self.zoom + delta)) self.draw() def mouse_zoom(self, event): self.change_zoom(1 if event.delta > 0 else -1) def pan_start(self, event): self.drag_start = (event.x, event.y, *latlon_to_world(self.center_lat, self.center_lon, self.zoom)) def pan_move(self, event): if self.drag_start is None: return start_x, start_y, center_x, center_y = self.drag_start self.center_lat, self.center_lon = world_to_latlon( center_x - (event.x - start_x), center_y - (event.y - start_y), self.zoom ) self.draw() def on_resize(self, _event): if self.resize_job is not None: self.root.after_cancel(self.resize_job) self.resize_job = self.root.after(80, self.draw) def close(self): self.stop() self.tiles.close() self.root.destroy() def parse_args(): parser = argparse.ArgumentParser() parser.add_argument( "--comparison", type=Path, help="fixed/adaptive model_comparison.csv (overrides --gps/--eskf)", ) parser.add_argument( "--model", choices=("fixed", "adaptive"), default="adaptive", help="model shown with --comparison (default: adaptive)", ) parser.add_argument("--gps", type=Path, default=BASE_DIR / "output/gps_positions.csv") parser.add_argument("--eskf", type=Path, default=BASE_DIR / "output/eskf_positions.csv") parser.add_argument("--tile-cache", type=Path, default=BASE_DIR / "output/osm_tile_cache") parser.add_argument("--check", action="store_true", help="validate inputs and exit without opening a window") return parser.parse_args() def main(): args = parse_args() samples = ( load_comparison_samples(args.comparison, args.model) if args.comparison else load_samples(args.gps, args.eskf) ) if args.check: print( f"map data OK: samples={len(samples)} duration={samples[-1].t:.3f}s " f"accepted={sum(s.accepted for s in samples)} rejected={sum(not s.accepted for s in samples)}" ) return root = tk.Tk() GpsMapPlayer(root, samples, args.tile_cache) root.mainloop() if __name__ == "__main__": main()