commit e94488def73645046e072a62bb0ffa6794ea8bfd Author: Eskimue Date: Sun Jun 28 13:57:08 2026 +0200 SolvePlate diff --git a/astrometry_gui.bat b/astrometry_gui.bat new file mode 100644 index 0000000..9a69753 --- /dev/null +++ b/astrometry_gui.bat @@ -0,0 +1,17 @@ +@echo off +cd /d "%~dp0" + +python --version >nul 2>&1 +if errorlevel 1 ( + echo Python nicht gefunden. Bitte Python installieren. + pause + exit /b 1 +) + +python -c "import paramiko" >nul 2>&1 +if errorlevel 1 ( + echo Installiere paramiko... + pip install paramiko +) + +python astrometry_gui.py diff --git a/astrometry_gui.py b/astrometry_gui.py new file mode 100644 index 0000000..ec17b89 --- /dev/null +++ b/astrometry_gui.py @@ -0,0 +1,974 @@ +#!/usr/bin/env python3 +""" +Astrometry.net GUI — vollständiges Frontend für solve-field +Verbindet sich per SSH (Passwort) mit dem Remote-Server. + +Abhängigkeiten: + pip install asyncssh +""" + +import asyncio +import tkinter as tk +from tkinter import ttk, filedialog, messagebox, scrolledtext +import threading +import os +import shlex +import queue +import stat + +try: + import asyncssh + HAS_ASYNCSSH = True +except ImportError: + HAS_ASYNCSSH = False + + +# --------------------------------------------------------------------------- +# Tooltip +# --------------------------------------------------------------------------- + +class Tooltip: + DELAY_MS = 600 + BG = "#fffde7" + FG = "#1a1a1a" + FONT = ("Segoe UI", 9) + MAX_WIDTH = 420 + + def __init__(self, widget: tk.Widget, text: str): + self._widget = widget + self._text = text + self._id = None + self._win = None + widget.bind("", self._schedule, add="+") + widget.bind("", self._cancel, add="+") + widget.bind("", self._cancel, add="+") + + def _schedule(self, _=None): + self._cancel() + self._id = self._widget.after(self.DELAY_MS, self._show) + + def _cancel(self, _=None): + if self._id: + self._widget.after_cancel(self._id) + self._id = None + if self._win: + self._win.destroy() + self._win = None + + def _show(self): + if self._win: + return + x = self._widget.winfo_rootx() + 20 + y = self._widget.winfo_rooty() + self._widget.winfo_height() + 4 + self._win = tw = tk.Toplevel(self._widget) + tw.wm_overrideredirect(True) + tw.wm_geometry(f"+{x}+{y}") + tw.attributes("-topmost", True) + tk.Label( + tw, text=self._text, justify=tk.LEFT, + background=self.BG, foreground=self.FG, font=self.FONT, + relief=tk.SOLID, borderwidth=1, + wraplength=self.MAX_WIDTH, padx=8, pady=6, + ).pack() + + +def tip(widget: tk.Widget, text: str) -> tk.Widget: + Tooltip(widget, text) + return widget + + +# --------------------------------------------------------------------------- +# Async SSH-Helper +# --------------------------------------------------------------------------- + +class AsyncSSH: + """Wrapper um asyncssh — alle Methoden sind Coroutinen.""" + + def __init__(self, host, user, password, port=22): + self.host = host + self.user = user + self.password = password + self.port = port + self._conn: asyncssh.SSHClientConnection | None = None + + async def connect(self): + self._conn = await asyncssh.connect( + self.host, + port=self.port, + username=self.user, + password=self.password, + known_hosts=None, + ) + + async def exec_stream(self, cmd: str, output_cb): + """Führt cmd aus und ruft output_cb(line) für jede Ausgabezeile auf.""" + if self._conn is None: + await self.connect() + async with self._conn.create_process( + cmd, + stderr=asyncssh.STDOUT, + encoding="utf-8", + errors="replace", + ) as proc: + async for line in proc.stdout: + output_cb(line if line.endswith("\n") else line + "\n") + output_cb(f"\n[Prozess beendet mit Code {proc.returncode}]\n") + return proc.returncode + + async def mkdir_p(self, remote_dir: str): + if self._conn is None: + await self.connect() + async with self._conn.start_sftp_client() as sftp: + parts = remote_dir.strip("/").split("/") + path = "" + for part in parts: + path = f"/{path}/{part}" if path else f"/{part}" + try: + await sftp.stat(path) + except asyncssh.SFTPError: + await sftp.mkdir(path) + + async def upload(self, local_path: str, remote_path: str, progress_cb=None): + if self._conn is None: + await self.connect() + total = os.path.getsize(local_path) + + def _progress(src, dst, done, _total): + if progress_cb: + progress_cb(done, _total or total) + + async with self._conn.start_sftp_client() as sftp: + await sftp.put( + local_path, + remote_path, + progress_handler=_progress, + ) + + async def close(self): + if self._conn: + self._conn.close() + await self._conn.wait_closed() + self._conn = None + + +# --------------------------------------------------------------------------- +# Haupt-GUI +# --------------------------------------------------------------------------- + +class AstrometryGUI: + + def __init__(self, root: tk.Tk): + self.root = root + self.root.title("Astrometry.net — solve-field GUI") + self.root.geometry("1000x840") + self.root.minsize(800, 620) + + self._log_queue: queue.Queue = queue.Queue() + + # Asyncio-Loop läuft in eigenem Daemon-Thread + self._loop = asyncio.new_event_loop() + threading.Thread(target=self._loop.run_forever, daemon=True, name="asyncio").start() + + self._init_vars() + self._build_ui() + self._poll_log() + + if not HAS_ASYNCSSH: + self._log( + "[WARNUNG] asyncssh nicht gefunden.\n" + "Bitte installieren: pip install asyncssh\n\n" + ) + + def _submit(self, coro): + """Coroutine an den Hintergrund-Loop übergeben.""" + return asyncio.run_coroutine_threadsafe(coro, self._loop) + + # ----------------------------------------------------------------------- + # Variablen + # ----------------------------------------------------------------------- + + def _init_vars(self): + self.v_host = tk.StringVar(value="10.42.44.25") + self.v_port = tk.IntVar(value=22) + self.v_user = tk.StringVar(value="eskimue") + self.v_pass = tk.StringVar(value="Preside") + + self.v_local_file = tk.StringVar() + self.v_remote_dir = tk.StringVar(value="/tmp/astrometry") + self.v_input_file = tk.StringVar() + self.v_input_file.trace_add("write", self._on_input_file_changed) + + self.v_out_prefix = tk.StringVar() + self.v_out_dir = tk.StringVar(value="/tmp/astrometry") + self.v_new_fits = tk.StringVar() + self.v_wcs = tk.StringVar() + self.v_corr = tk.StringVar() + self.v_rdls = tk.StringVar() + self.v_axy = tk.StringVar() + self.v_solved = tk.StringVar() + self.v_match = tk.StringVar() + + self.v_scale_units = tk.StringVar(value="degwidth") + self.v_scale_low = tk.StringVar() + self.v_scale_high = tk.StringVar() + self.v_scale_est = tk.StringVar() + self.v_scale_err = tk.StringVar() + + self.v_ra = tk.StringVar() + self.v_dec = tk.StringVar() + self.v_radius = tk.StringVar(value="1.0") + + self.v_depth = tk.StringVar() + self.v_cpulimit = tk.StringVar(value="300") + self.v_downsample = tk.StringVar(value="6") + self.v_tweak_order = tk.StringVar(value="2") + self.v_odds_solve = tk.StringVar() + self.v_odds_reject = tk.StringVar() + self.v_odds_tune = tk.StringVar() + self.v_objs = tk.StringVar() + self.v_pixel_error = tk.StringVar() + self.v_parity = tk.StringVar(value="both") + + self.v_overwrite = tk.BooleanVar(value=True) + self.v_no_plots = tk.BooleanVar(value=False) + self.v_no_tweak = tk.BooleanVar(value=False) + self.v_resort = tk.BooleanVar(value=False) + self.v_verbose = tk.BooleanVar(value=True) + self.v_timestamp = tk.BooleanVar(value=False) + self.v_no_del_temp = tk.BooleanVar(value=False) + self.v_continue = tk.BooleanVar(value=False) + self.v_sextractor = tk.BooleanVar(value=False) + self.v_fits_image = tk.BooleanVar(value=False) + self.v_no_verify = tk.BooleanVar(value=False) + self.v_just_augment = tk.BooleanVar(value=False) + self.v_invert = tk.BooleanVar(value=False) + + self.v_extra = tk.StringVar() + + # ----------------------------------------------------------------------- + # UI aufbauen + # ----------------------------------------------------------------------- + + def _build_ui(self): + top = ttk.Frame(self.root, padding=4) + top.pack(fill=tk.X) + self._build_conn_bar(top) + + nb = ttk.Notebook(self.root) + nb.pack(fill=tk.BOTH, expand=True, padx=6, pady=(0, 4)) + + for title, builder in [ + ("Input / Output", self._tab_io), + ("Maßstab & Position", self._tab_scale), + ("Solver-Parameter", self._tab_solver), + ("Erweitert", self._tab_advanced), + ("Befehlsvorschau", self._tab_cmd), + ("⚙ Server Setup", self._tab_setup), + ]: + f = ttk.Frame(nb, padding=6) + nb.add(f, text=title) + builder(f) + + btn_bar = ttk.Frame(self.root, padding=4) + btn_bar.pack(fill=tk.X) + + tip(ttk.Button(btn_bar, text="Vorschau aktualisieren", command=self._refresh_cmd), + "Zeigt den aktuell generierten solve-field-Befehl im Tab 'Befehlsvorschau' an." + ).pack(side=tk.LEFT, padx=4) + + tip(ttk.Button(btn_bar, text="▶ solve-field starten", command=self._run), + "Baut den solve-field-Befehl und führt ihn asynchron per SSH auf dem Server aus.\n" + "Die Ausgabe erscheint live im Log-Bereich." + ).pack(side=tk.LEFT, padx=4) + + tip(ttk.Button(btn_bar, text="Abbrechen", command=self._kill), + "Bricht die aktuelle asynchrone Operation ab." + ).pack(side=tk.LEFT, padx=4) + + tip(ttk.Button(btn_bar, text="Ausgabe löschen", command=self._clear_log), + "Leert das Log-Fenster." + ).pack(side=tk.RIGHT, padx=4) + + log_frame = ttk.LabelFrame(self.root, text="Ausgabe / Log", padding=4) + log_frame.pack(fill=tk.BOTH, expand=True, padx=6, pady=(0, 6)) + + self.log = scrolledtext.ScrolledText( + log_frame, font=("Consolas", 9), + bg="#1e1e1e", fg="#d4d4d4", + insertbackground="white", wrap=tk.WORD, state=tk.DISABLED, + ) + self.log.pack(fill=tk.BOTH, expand=True) + self.log.tag_config("error", foreground="#f48771") + self.log.tag_config("ok", foreground="#89d185") + self.log.tag_config("info", foreground="#9cdcfe") + + self._current_task: asyncio.Future | None = None + + # ----------------------------------------------------------------------- + # Verbindungs-Leiste + # ----------------------------------------------------------------------- + + def _build_conn_bar(self, parent): + lf = ttk.LabelFrame(parent, text="SSH-Verbindung", padding=4) + lf.pack(fill=tk.X, pady=(0, 4)) + + for i, (lbl, var, w, tooltip) in enumerate([ + ("Host:", self.v_host, 16, "IP-Adresse oder Hostname des Servers."), + ("Port:", self.v_port, 6, "SSH-Port. Standard: 22."), + ("Benutzer:", self.v_user, 12, "SSH-Benutzername auf dem Remote-Server."), + ("Passwort:", self.v_pass, 14, "SSH-Passwort. Wird nur im Speicher gehalten."), + ]): + ttk.Label(lf, text=lbl).grid(row=0, column=i * 2, padx=(6, 2), sticky=tk.W) + e = ttk.Entry(lf, textvariable=var, width=w, show="*" if "Passwort" in lbl else "") + e.grid(row=0, column=i * 2 + 1, padx=(0, 8)) + tip(e, tooltip) + + tip(ttk.Button(lf, text="Verbinden & testen", command=self._test_conn), + "Öffnet eine asynchrone SSH-Verbindung und prüft ob solve-field verfügbar ist." + ).grid(row=0, column=8, padx=4) + + self.conn_label = ttk.Label(lf, text="● nicht verbunden", foreground="gray") + self.conn_label.grid(row=0, column=9, padx=6) + + # ----------------------------------------------------------------------- + # Tab: Input / Output + # ----------------------------------------------------------------------- + + def _tab_io(self, parent): + uf = ttk.LabelFrame(parent, text="Lokale Datei hochladen (SFTP)", padding=6) + uf.pack(fill=tk.X, pady=(0, 8)) + uf.columnconfigure(1, weight=1) + + ttk.Label(uf, text="Lokale Datei:").grid(row=0, column=0, sticky=tk.W, padx=4, pady=3) + tip(ttk.Entry(uf, textvariable=self.v_local_file), + "Pfad zur lokalen Bild-Datei (FITS, JPEG, PNG, TIFF).\n" + "FITS ist das bevorzugte Format da es Metadaten enthält." + ).grid(row=0, column=1, sticky=tk.EW, padx=4, pady=3) + tip(ttk.Button(uf, text="Durchsuchen…", command=self._browse_local), + "Öffnet einen Datei-Dialog um die lokale Astro-Bild-Datei auszuwählen." + ).grid(row=0, column=2, padx=4) + + ttk.Label(uf, text="Remote-Verzeichnis:").grid(row=1, column=0, sticky=tk.W, padx=4, pady=3) + tip(ttk.Entry(uf, textvariable=self.v_remote_dir), + "Verzeichnis auf dem Server wohin die Datei hochgeladen wird.\n" + "Wird automatisch angelegt falls es nicht existiert." + ).grid(row=1, column=1, sticky=tk.EW, padx=4, pady=3) + + self.upload_btn = tip(ttk.Button(uf, text="Hochladen ↑", command=self._upload), + "Überträgt die lokale Datei asynchron per SFTP auf den Server.\n" + "Nach dem Upload wird der Remote-Pfad automatisch eingetragen.") + self.upload_btn.grid(row=1, column=2, padx=4) + + self.upload_progress = ttk.Progressbar(uf, mode="determinate", maximum=100) + self.upload_progress.grid(row=2, column=0, columnspan=3, sticky=tk.EW, padx=4, pady=4) + tip(self.upload_progress, "Fortschritt des Datei-Uploads in Prozent.") + + rf = ttk.LabelFrame(parent, text="Eingabedatei auf Remote-Rechner", padding=6) + rf.pack(fill=tk.X, pady=(0, 8)) + rf.columnconfigure(1, weight=1) + + ttk.Label(rf, text="Dateipfad (remote):").grid(row=0, column=0, sticky=tk.W, padx=4, pady=3) + tip(ttk.Entry(rf, textvariable=self.v_input_file), + "Absoluter Pfad zur Eingabedatei auf dem Server.\n" + "Wildcards erlaubt, z.B. /data/*.fits" + ).grid(row=0, column=1, sticky=tk.EW, padx=4, pady=3) + tip(ttk.Label(rf, text="(FITS, JPEG, PNG, TIFF – Wildcards erlaubt)"), + "Wildcards wie /pfad/*.fits verarbeiten alle passenden Dateien nacheinander." + ).grid(row=0, column=2, padx=4) + + of = ttk.LabelFrame(parent, text="Ausgabe-Optionen", padding=6) + of.pack(fill=tk.X) + of.columnconfigure(1, weight=1) + of.columnconfigure(3, weight=1) + + for i, (lbl, var, tooltip) in enumerate([ + ("Ausgabe-Präfix (--out, -o):", self.v_out_prefix, + "Präfix für alle Ausgabedateien.\nBeispiel: 'mein_bild' → mein_bild.wcs, mein_bild.corr …"), + ("Ausgabe-Verzeichnis (--dir):", self.v_out_dir, + "Verzeichnis für alle Ausgabedateien.\nStandard: gleiches Verzeichnis wie Eingabedatei."), + ("Neues FITS (--new-fits):", self.v_new_fits, + "Ausgabe-FITS-Datei mit eingebetteten WCS-Headern.\nWird automatisch aus Eingabedateiname gesetzt."), + ("WCS-Datei (--wcs):", self.v_wcs, + "FITS-Datei mit den berechneten Koordinaten-Transformationen.\nEnthält RA/Dek-Bezug für jeden Pixel."), + ("Korrespondenzen (--corr):", self.v_corr, + "Zuordnungen zwischen Bild-Sternen und Referenz-Katalog.\nMit 'none' deaktivieren."), + ("RDLS-Datei (--rdls):", self.v_rdls, + "FITS-Tabelle mit RA/Dek-Koordinaten aller erkannten Sterne.\nMit 'none' deaktivieren."), + ("AXY-Datei (--axy):", self.v_axy, + "x/y-Pixelkoordinaten der erkannten Sterne.\nZwischenergebnis der Quellerkennung."), + ("Solved-Datei (--solved):", self.v_solved, + "Leere Datei die nach erfolgreicher Lösung angelegt wird.\nNützlich als Signal für Skripte."), + ("Match-Datei (--match):", self.v_match, + "Details zum gefundenen Treffer: Index-Datei, Quad-Match, Transformation."), + ]): + row, col = divmod(i, 2) + ttk.Label(of, text=lbl).grid(row=row, column=col * 2, sticky=tk.W, padx=4, pady=2) + tip(ttk.Entry(of, textvariable=var), + tooltip + ).grid(row=row, column=col * 2 + 1, sticky=tk.EW, padx=(0, 16), pady=2) + + # ----------------------------------------------------------------------- + # Tab: Maßstab & Position + # ----------------------------------------------------------------------- + + def _tab_scale(self, parent): + sf = ttk.LabelFrame(parent, text="Bildmaßstab", padding=6) + sf.pack(fill=tk.X, pady=(0, 8)) + sf.columnconfigure(1, weight=1) + + ttk.Label(sf, text="Maßstab-Einheit (--scale-units):").grid(row=0, column=0, sticky=tk.W, padx=4, pady=3) + units_cb = ttk.Combobox(sf, textvariable=self.v_scale_units, state="readonly", width=18) + units_cb["values"] = ["degwidth", "arcminwidth", "arcsecperpix", "focalmm"] + units_cb.grid(row=0, column=1, sticky=tk.W, padx=4, pady=3) + tip(units_cb, + "Einheit für --scale-low / --scale-high:\n\n" + "• degwidth — Breite des Bildfeldes in Grad\n" + "• arcminwidth — Breite in Bogenminuten\n" + "• arcsecperpix — Bogensekunden pro Pixel (Pixelskala)\n" + "• focalmm — Brennweite in Millimeter") + + for i, (lbl, var, tooltip) in enumerate([ + ("Maßstab min (--scale-low):", self.v_scale_low, + "Untergrenze des erwarteten Bildmaßstabs.\n" + "Je enger der Bereich, desto schneller die Lösung.\n\n" + "Beispiele (degwidth): Weitwinkel: 40 | Normalobjektiv: 10 | Teleskop: 0.5"), + ("Maßstab max (--scale-high):", self.v_scale_high, + "Obergrenze des erwarteten Bildmaßstabs.\n" + "Ohne Angabe wird der gesamte verfügbare Bereich durchsucht (langsam).\n" + "Tipp: ±10–20% um den erwarteten Wert."), + ("Maßstab-Schätzung (--scale):", self.v_scale_est, + "Einzelner Schätzwert als Alternative zu min/max.\n" + "Wird mit --scale-err ±% als Suchbereich verwendet."), + ("Maßstab-Fehler % (--scale-err):", self.v_scale_err, + "Toleranz für die Maßstab-Schätzung in Prozent.\n" + "Beispiel: Schätzung=5, Fehler=10 → sucht zwischen 4.5 und 5.5 Grad.\n" + "Standard: 25%."), + ], start=1): + ttk.Label(sf, text=lbl).grid(row=i, column=0, sticky=tk.W, padx=4, pady=2) + tip(ttk.Entry(sf, textvariable=var, width=14), + tooltip + ).grid(row=i, column=1, sticky=tk.W, padx=4, pady=2) + + pf = ttk.LabelFrame(parent, text="Himmelsposition (optional — beschleunigt die Suche erheblich)", padding=6) + pf.pack(fill=tk.X) + pf.columnconfigure(1, weight=1) + + for i, (lbl, var, tooltip) in enumerate([ + ("RA (--ra):", self.v_ra, + "Rektaszension des Bildzentrums.\n" + "Format: Dezimalgrad (83.8221) oder HH:MM:SS (05:35:17)\n\n" + "Zusammen mit --dec und --radius wird nur ein Himmelsausschnitt durchsucht.\n" + "Ohne Angabe: gesamter Himmel (deutlich langsamer)."), + ("Dek (--dec):", self.v_dec, + "Deklination des Bildzentrums.\n" + "Format: Dezimalgrad (-5.3911) oder ±DD:MM:SS (-05:23:28)\n" + "Positiv = nördliche, negativ = südliche Hemisphäre."), + ("Suchradius (--radius, Grad):", self.v_radius, + "Radius des Suchgebiets um RA/Dek in Grad.\n\n" + "Klein (0.5°) → schnell, erfordert genaue RA/Dek-Angabe.\n" + "Groß (5°) → toleranter gegenüber Ungenauigkeiten.\n" + "Standard: 1.0°"), + ]): + ttk.Label(pf, text=lbl).grid(row=i, column=0, sticky=tk.W, padx=4, pady=3) + tip(ttk.Entry(pf, textvariable=var, width=22), + tooltip + ).grid(row=i, column=1, sticky=tk.W, padx=4, pady=3) + + # ----------------------------------------------------------------------- + # Tab: Solver-Parameter + # ----------------------------------------------------------------------- + + def _tab_solver(self, parent): + nf = ttk.LabelFrame(parent, text="Numerische Parameter", padding=6) + nf.pack(fill=tk.X, pady=(0, 8)) + nf.columnconfigure(1, weight=1) + nf.columnconfigure(3, weight=1) + + for i, (lbl, var, tooltip) in enumerate([ + ("Tiefe (--depth):", self.v_depth, + "Anzahl der hellsten Sterne pro Lösungsversuch.\n" + "Format: einzelner Wert (30) oder Liste (10,20,30,50).\n" + "Mehr Sterne → genauer aber langsamer."), + ("CPU-Limit (--cpulimit, s):", self.v_cpulimit, + "Maximale Rechenzeit in Sekunden.\n" + "300s ist ein guter Kompromiss. 0 = kein Limit."), + ("Downsampling (--downsample):", self.v_downsample, + "Verkleinert das Bild vor der Verarbeitung.\n" + "6 = 1/6 der Auflösung. Beschleunigt die Quellerkennung stark.\n" + "Kein Einfluss auf die Genauigkeit der WCS-Lösung."), + ("Tweak-Ordnung (--tweak-order):", self.v_tweak_order, + "Grad des Polynoms für die WCS-Verfeinerung.\n" + "1=linear, 2=quadratisch (Standard), 3-4 für Weitwinkelobjektive."), + ("Odds to solve (--odds-to-solve):", self.v_odds_solve, + "Bayes'sche Schwelle ab der eine Lösung als gefunden gilt.\n" + "Standard ~1e9. Für schwierige Bilder: 1e6 versuchen."), + ("Odds to reject (--odds-to-reject):", self.v_odds_reject, + "Schwelle unterhalb derer ein Kandidat sofort verworfen wird.\n" + "Standard: 1e-100."), + ("Odds to tune up (--odds-to-tune-up):", self.v_odds_tune, + "Schwelle ab der eine genauere Verifikation (Tweak) gestartet wird.\n" + "Standard: 1e6."), + ("Max. Objekte (--objs):", self.v_objs, + "Maximale Sternanzahl aus dem Quellenverzeichnis.\n" + "Standard: alle gefundenen Objekte."), + ("Pixelfehler (--pixel-error):", self.v_pixel_error, + "Maximale Abweichung in Pixeln bei der Stern-Katalog-Zuordnung.\n" + "Standard: 1.0 Pixel."), + ]): + row, col = divmod(i, 2) + ttk.Label(nf, text=lbl).grid(row=row, column=col * 2, sticky=tk.W, padx=4, pady=2) + tip(ttk.Entry(nf, textvariable=var, width=16), + tooltip + ).grid(row=row, column=col * 2 + 1, sticky=tk.W, padx=(0, 4), pady=2) + + ff = ttk.LabelFrame(parent, text="Schalter (Flags)", padding=6) + ff.pack(fill=tk.X, pady=(0, 8)) + + for i, (lbl, var, tooltip) in enumerate([ + ("Überschreiben (-O / --overwrite)", self.v_overwrite, + "Vorhandene Ausgabedateien ohne Nachfrage überschreiben.\n" + "Empfohlen: aktiviert."), + ("Keine Plots (--no-plots)", self.v_no_plots, + "Erzeugt keine Überlagerungsbilder (Annotierungen, Sternpositionen).\n" + "Beschleunigt die Verarbeitung wenn nur WCS-Daten benötigt werden."), + ("Kein Tweak (--no-tweak)", self.v_no_tweak, + "Deaktiviert die WCS-Verfeinerung. Nur einfache TAN-Projektion.\n" + "Schneller, aber weniger genau bei verzerrenden Optiken."), + ("Sterne umsortieren (--resort)", self.v_resort, + "Sortiert die erkannten Sterne neu nach Helligkeit."), + ("Ausführlich (--verbose)", self.v_verbose, + "Detaillierte Fortschrittsinformationen.\n" + "Sehr nützlich zur Fehlersuche wenn keine Lösung gefunden wird."), + ("Zeitstempel (--timestamp)", self.v_timestamp, + "Fügt jedem Log-Eintrag einen Zeitstempel hinzu."), + ("Temp-Dateien behalten (--no-delete-temp)", self.v_no_del_temp, + "Temporäre Zwischendateien nach der Verarbeitung behalten.\n" + "Nützlich zur Fehlersuche."), + ("Weitermachen (--continue)", self.v_continue, + "Überspringt bereits gelöste Bilder (prüft ob .solved-Datei existiert).\n" + "Ideal für unterbrochene Batch-Verarbeitungen."), + ("SExtractor nutzen (--use-sextractor)", self.v_sextractor, + "Verwendet SExtractor statt des eingebauten Quellenerkennungs-Algorithmus.\n" + "Muss auf dem Server installiert sein. Oft besser bei schwierigen Bildern."), + ("Als FITS-Bild behandeln (--fits-image)", self.v_fits_image, + "Erzwingt FITS-Behandlung auch wenn die Endung nicht .fits lautet."), + ("Keine Verifikation (--no-verify)", self.v_no_verify, + "Überspringt den Verifikationsschritt. Schneller aber weniger zuverlässig."), + ("Nur augmentieren (--just-augment)", self.v_just_augment, + "Führt nur die Quellerkennung durch, startet nicht den Solver."), + ("Bild invertieren (--invert)", self.v_invert, + "Invertiert das Bild vor der Verarbeitung.\n" + "Verwenden wenn dunkle Sterne auf hellem Hintergrund vorliegen."), + ]): + row, col = divmod(i, 3) + tip(ttk.Checkbutton(ff, text=lbl, variable=var), + tooltip + ).grid(row=row, column=col, sticky=tk.W, padx=12, pady=2) + + pf = ttk.LabelFrame(parent, text="Parität (--parity)", padding=6) + pf.pack(fill=tk.X) + + for lbl, val, tooltip in [ + ("Beide (automatisch)", "both", + "Testet beide Paritäten. Langsamer, aber funktioniert unabhängig\n" + "ob das Bild gespiegelt ist. Standard."), + ("Positiv", "pos", + "Nur positive Parität (nicht gespiegelt).\n" + "Für Aufnahmen direkt am Fokus ohne Umlenkspiegel."), + ("Negativ", "neg", + "Nur negative Parität (gespiegelt).\n" + "Für Newton-Teleskop, Cassegrain oder andere spiegelnde Designs."), + ]: + tip(ttk.Radiobutton(pf, text=lbl, variable=self.v_parity, value=val), + tooltip + ).pack(side=tk.LEFT, padx=16) + + # ----------------------------------------------------------------------- + # Tab: Erweitert + # ----------------------------------------------------------------------- + + def _tab_advanced(self, parent): + idxf = ttk.LabelFrame(parent, text="Index-Dateien (--index / -i) — eine pro Zeile", padding=6) + idxf.pack(fill=tk.BOTH, expand=True, pady=(0, 8)) + + self.idx_text = scrolledtext.ScrolledText(idxf, height=6, font=("Consolas", 9)) + self.idx_text.pack(fill=tk.BOTH, expand=True) + tip(self.idx_text, + "Index-Dateien die solve-field verwenden soll — eine pro Zeile.\n\n" + "Namensschema:\n" + " index-42xx: Felder von 2° bis 30° Breite\n" + " index-41xx: Felder von 0.3° bis 8° Breite\n" + " index-40xx: Felder von 10' bis 40' Breite\n\n" + "Leer lassen → serverseitige Konfiguration aus astrometry.cfg wird genutzt.") + + ttk.Label(idxf, + text="Leer lassen → serverseitige Standard-Index-Dateien aus astrometry.cfg.", + foreground="gray", + ).pack(anchor=tk.W, padx=4) + + extraf = ttk.LabelFrame(parent, text="Zusätzliche Argumente", padding=6) + extraf.pack(fill=tk.X) + extraf.columnconfigure(1, weight=1) + + ttk.Label(extraf, text="Weitere Flags:").grid(row=0, column=0, padx=4, pady=4) + tip(ttk.Entry(extraf, textvariable=self.v_extra), + "Beliebige zusätzliche Argumente die direkt ans Ende des Befehls angehängt werden.\n\n" + "Beispiele:\n" + " --sigma 5 — Erkennungsschwelle\n" + " --uniformize 0 — Gleichmäßige Verteilung deaktivieren\n" + " --no-background-subtraction — Hintergrundsubtraktion deaktivieren\n" + " --crpix-center — Referenzpixel in Bildmitte" + ).grid(row=0, column=1, sticky=tk.EW, padx=4, pady=4) + + # ----------------------------------------------------------------------- + # Tab: Befehlsvorschau + # ----------------------------------------------------------------------- + + def _tab_cmd(self, parent): + tip(ttk.Label(parent, text="Generierter Befehl (direkt bearbeitbar):"), + "Dieser Befehl wird exakt so per SSH auf dem Server ausgeführt.\n" + "Kann hier manuell angepasst werden." + ).pack(anchor=tk.W, pady=(0, 4)) + + self.cmd_text = scrolledtext.ScrolledText(parent, height=8, font=("Consolas", 9)) + self.cmd_text.pack(fill=tk.BOTH, expand=True) + + btn_row = ttk.Frame(parent) + btn_row.pack(fill=tk.X, pady=4) + + tip(ttk.Button(btn_row, text="Aktualisieren", command=self._refresh_cmd), + "Generiert den Befehl neu aus den aktuellen Formularfeldern." + ).pack(side=tk.LEFT, padx=4) + tip(ttk.Button(btn_row, text="In Zwischenablage", command=self._copy_cmd), + "Kopiert den Befehl in die Windows-Zwischenablage." + ).pack(side=tk.LEFT, padx=4) + + # ----------------------------------------------------------------------- + # Tab: Server Setup + # ----------------------------------------------------------------------- + + def _tab_setup(self, parent): + # SExtractor + sf = ttk.LabelFrame(parent, text="SExtractor", padding=6) + sf.pack(fill=tk.X, pady=(0, 8)) + + ttk.Label(sf, text="Source Extractor (sex) ist ein leistungsstarker Quellenerkennungs-\n" + "algorithmus, der bei schwierigen Bildern bessere Ergebnisse liefert\n" + "als der eingebaute image2xy. Aktiviere ihn im Tab 'Solver-Parameter'.", + justify=tk.LEFT, foreground="gray").pack(anchor=tk.W, padx=4, pady=(0, 6)) + + btn_row = ttk.Frame(sf) + btn_row.pack(fill=tk.X) + + tip(ttk.Button(btn_row, text="SExtractor prüfen", command=self._check_sextractor), + "Prüft ob SExtractor auf dem Server installiert ist und gibt die Version aus." + ).pack(side=tk.LEFT, padx=4, pady=4) + + tip(ttk.Button(btn_row, text="SExtractor installieren (apt)", command=self._install_sextractor), + "Installiert SExtractor auf dem Server via:\n" + " sudo apt-get install -y source-extractor\n\n" + "Benötigt sudo-Rechte auf dem Server.\n" + "Nach der Installation im Tab 'Solver-Parameter' den Haken bei\n" + "'SExtractor nutzen' setzen." + ).pack(side=tk.LEFT, padx=4, pady=4) + + # Diagnose + df = ttk.LabelFrame(parent, text="Server-Diagnose", padding=6) + df.pack(fill=tk.X, pady=(0, 8)) + + tip(ttk.Button(df, text="Server-Status prüfen", command=self._diagnose_server), + "Prüft auf dem Server:\n" + " • solve-field Version\n" + " • astrometry.cfg Inhalt\n" + " • Installierte Index-Dateien\n" + " • SExtractor verfügbar?\n" + " • Freier Speicherplatz" + ).pack(side=tk.LEFT, padx=4, pady=4) + + # ----------------------------------------------------------------------- + # Befehl bauen + # ----------------------------------------------------------------------- + + def _build_command(self) -> str: + def q(s): + return shlex.quote(s) if s else "" + + parts = ["solve-field"] + + def add(flag, var, quote=True): + val = var.get() if hasattr(var, "get") else var + if val: + parts.append(flag) + if quote: + parts.append(q(str(val))) + + def flag(f, v): + if v.get(): + parts.append(f) + + flag("--overwrite", self.v_overwrite) + flag("--no-plots", self.v_no_plots) + flag("--verbose", self.v_verbose) + flag("--timestamp", self.v_timestamp) + flag("--resort", self.v_resort) + flag("--continue", self.v_continue) + flag("--no-delete-temp", self.v_no_del_temp) + flag("--use-sextractor", self.v_sextractor) + flag("--fits-image", self.v_fits_image) + flag("--no-verify", self.v_no_verify) + flag("--just-augment", self.v_just_augment) + flag("--invert", self.v_invert) + + if self.v_no_tweak.get(): + parts.append("--no-tweak") + elif self.v_tweak_order.get(): + parts += ["--tweak-order", self.v_tweak_order.get()] + + if self.v_parity.get() != "both": + parts += ["--parity", self.v_parity.get()] + + add("--out", self.v_out_prefix) + add("--dir", self.v_out_dir) + add("--new-fits", self.v_new_fits) + add("--wcs", self.v_wcs) + add("--corr", self.v_corr) + add("--rdls", self.v_rdls) + add("--axy", self.v_axy) + add("--solved", self.v_solved) + add("--match", self.v_match) + + s_low, s_high = self.v_scale_low.get(), self.v_scale_high.get() + if s_low or s_high: + parts += ["--scale-units", self.v_scale_units.get()] + if s_low: parts += ["--scale-low", s_low] + if s_high: parts += ["--scale-high", s_high] + add("--scale", self.v_scale_est) + add("--scale-err", self.v_scale_err) + + if self.v_ra.get(): + parts += ["--ra", self.v_ra.get()] + if self.v_dec.get(): + parts += ["--dec", self.v_dec.get()] + if self.v_radius.get() and (self.v_ra.get() or self.v_dec.get()): + parts += ["--radius", self.v_radius.get()] + + add("--depth", self.v_depth) + add("--cpulimit", self.v_cpulimit) + add("--downsample", self.v_downsample) + add("--odds-to-solve", self.v_odds_solve) + add("--odds-to-reject", self.v_odds_reject) + add("--odds-to-tune-up", self.v_odds_tune) + add("--objs", self.v_objs) + add("--pixel-error", self.v_pixel_error) + + for line in self.idx_text.get("1.0", tk.END).strip().splitlines(): + line = line.strip() + if line: + parts += ["--index", q(line)] + + if self.v_extra.get(): + parts.append(self.v_extra.get()) + + if self.v_input_file.get(): + parts.append(q(self.v_input_file.get())) + + return " ".join(parts) + + # ----------------------------------------------------------------------- + # Async-Aktionen + # ----------------------------------------------------------------------- + + def _make_ssh(self) -> AsyncSSH: + return AsyncSSH( + self.v_host.get(), + self.v_user.get(), + self.v_pass.get(), + self.v_port.get(), + ) + + def _test_conn(self): + if not HAS_ASYNCSSH: + messagebox.showerror("Fehler", "asyncssh nicht installiert.\npip install asyncssh") + return + self.conn_label.config(text="● verbinde …", foreground="orange") + self._log("[INFO] Verbindungstest …\n", "info") + self._submit(self._async_test_conn()) + + async def _async_test_conn(self): + try: + ssh = self._make_ssh() + await ssh.connect() + self._log("[OK] Verbunden.\n", "ok") + await ssh.exec_stream( + "echo '--- System ---'; uname -a; " + "echo '--- solve-field ---'; " + "which solve-field && solve-field --help 2>&1 | head -40 " + "|| echo 'solve-field nicht im PATH'", + self._log, + ) + await ssh.close() + self.root.after(0, lambda: self.conn_label.config(text="● verbunden", foreground="#89d185")) + except Exception as exc: + self._log(f"[FEHLER] {exc}\n", "error") + self.root.after(0, lambda: self.conn_label.config(text="● Fehler", foreground="#f48771")) + + def _upload(self): + local = self.v_local_file.get() + if not local or not os.path.isfile(local): + messagebox.showwarning("Datei fehlt", "Bitte eine lokale Datei auswählen.") + return + self.upload_btn.config(state=tk.DISABLED) + self.upload_progress["value"] = 0 + self._submit(self._async_upload(local)) + + async def _async_upload(self, local: str): + remote_dir = self.v_remote_dir.get() + remote_path = remote_dir.rstrip("/") + "/" + os.path.basename(local) + self._log(f"[INFO] Lade hoch: {local} → {remote_path}\n", "info") + try: + ssh = self._make_ssh() + await ssh.connect() + await ssh.mkdir_p(remote_dir) + + def _progress(done, total): + pct = int(done / total * 100) if total else 0 + self.root.after(0, lambda: self.upload_progress.config(value=pct)) + + await ssh.upload(local, remote_path, _progress) + await ssh.close() + self._log(f"[OK] Hochladen abgeschlossen: {remote_path}\n", "ok") + self.root.after(0, lambda: self.v_input_file.set(remote_path)) + except Exception as exc: + self._log(f"[FEHLER] Upload: {exc}\n", "error") + finally: + self.root.after(0, lambda: self.upload_btn.config(state=tk.NORMAL)) + + def _run(self): + if not self.v_input_file.get(): + messagebox.showwarning("Keine Eingabedatei", "Bitte einen Dateipfad angeben.") + return + cmd = self._build_command() + self._log(f"\n{'='*60}\n{cmd}\n{'='*60}\n", "info") + self._current_task = self._submit(self._async_run(cmd)) + + async def _async_run(self, cmd: str): + try: + ssh = self._make_ssh() + await ssh.connect() + await ssh.exec_stream(cmd, self._log) + await ssh.close() + except Exception as exc: + self._log(f"[FEHLER] {exc}\n", "error") + + def _kill(self): + if self._current_task: + self._current_task.cancel() + self._log("[INFO] Aufgabe abgebrochen.\n", "info") + self._current_task = None + + # ----------------------------------------------------------------------- + # Hilfsmethoden + # ----------------------------------------------------------------------- + + def _on_input_file_changed(self, *_): + inp = self.v_input_file.get().strip() + if not inp: + return + basename = inp.rsplit("/", 1)[-1] + stem = basename.rsplit(".", 1)[0] if "." in basename else basename + out_dir = self.v_out_dir.get().rstrip("/") + auto_path = f"{out_dir}/{stem}.fits" + current = self.v_new_fits.get() + if not current or (current.endswith(".fits") and current.startswith(out_dir)): + self.v_new_fits.set(auto_path) + + def _browse_local(self): + path = filedialog.askopenfilename( + title="Astro-Bild auswählen", + filetypes=[ + ("Astro-Bilder", "*.fits *.fit *.fts *.jpg *.jpeg *.png *.tiff *.tif"), + ("Alle Dateien", "*.*"), + ], + ) + if path: + self.v_local_file.set(path) + + def _refresh_cmd(self): + cmd = self._build_command() + self.cmd_text.delete("1.0", tk.END) + self.cmd_text.insert("1.0", cmd) + + def _copy_cmd(self): + self.root.clipboard_clear() + self.root.clipboard_append(self.cmd_text.get("1.0", tk.END).strip()) + + # ----------------------------------------------------------------------- + # Log (thread-sicher via Queue) + # ----------------------------------------------------------------------- + + def _log(self, text: str, tag: str = ""): + self._log_queue.put((text, tag)) + + def _poll_log(self): + try: + while True: + text, tag = self._log_queue.get_nowait() + self.log.config(state=tk.NORMAL) + self.log.insert(tk.END, text, tag or "") + self.log.see(tk.END) + self.log.config(state=tk.DISABLED) + except queue.Empty: + pass + self.root.after(100, self._poll_log) + + def _clear_log(self): + self.log.config(state=tk.NORMAL) + self.log.delete("1.0", tk.END) + self.log.config(state=tk.DISABLED) + + def _check_sextractor(self): + self._submit(self._async_run( + "echo '--- SExtractor ---'; " + "which sex 2>/dev/null || which source-extractor 2>/dev/null || echo 'nicht gefunden'; " + "sex --version 2>&1 || source-extractor --version 2>&1 || true" + )) + + def _install_sextractor(self): + self._submit(self._async_run( + "echo Preside | sudo -S apt-get install -y source-extractor 2>&1" + )) + + def _diagnose_server(self): + self._submit(self._async_run( + "echo '=== solve-field ==='; solve-field --version 2>&1 | head -3; " + "echo '=== astrometry.cfg ==='; cat /etc/astrometry.cfg 2>/dev/null || echo 'nicht gefunden'; " + "echo '=== Index-Dateien ==='; ls /usr/share/astrometry/index-*.fits 2>/dev/null | head -30 || echo 'keine gefunden'; " + "echo '=== SExtractor ==='; (which sex || which source-extractor) 2>/dev/null && (sex --version || source-extractor --version) 2>&1 | head -2 || echo 'nicht installiert'; " + "echo '=== Speicherplatz ==='; df -h /usr/share/astrometry 2>/dev/null || df -h /" + )) + + def on_close(self): + self._loop.call_soon_threadsafe(self._loop.stop) + self.root.destroy() + + +# --------------------------------------------------------------------------- +# Einstiegspunkt +# --------------------------------------------------------------------------- + +def main(): + root = tk.Tk() + + style = ttk.Style(root) + for preferred in ("clam", "alt", "default"): + if preferred in style.theme_names(): + style.theme_use(preferred) + break + + app = AstrometryGUI(root) + root.protocol("WM_DELETE_WINDOW", app.on_close) + root.mainloop() + + +if __name__ == "__main__": + main() diff --git a/ssh-zugangsdaten.txt b/ssh-zugangsdaten.txt new file mode 100644 index 0000000..ae1c12e --- /dev/null +++ b/ssh-zugangsdaten.txt @@ -0,0 +1,6 @@ +SSH-Zugangsdaten +================ + +Host: 10.42.44.25 +Benutzer: eskimue +Passwort: Preside