527 lines
21 KiB
Python
527 lines
21 KiB
Python
#!/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("<ButtonPress-1>", self.pan_start)
|
||
self.canvas.bind("<B1-Motion>", self.pan_move)
|
||
self.canvas.bind("<MouseWheel>", self.mouse_zoom)
|
||
self.canvas.bind("<Button-4>", lambda event: self.change_zoom(1))
|
||
self.canvas.bind("<Button-5>", lambda event: self.change_zoom(-1))
|
||
self.canvas.bind("<Configure>", self.on_resize)
|
||
root.bind("<space>", 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()
|