diff --git a/AstroMac.json b/AstroMac.json new file mode 100644 index 0000000..281147c --- /dev/null +++ b/AstroMac.json @@ -0,0 +1,6 @@ +{ + "host": "10.42.42.102", + "port": 22, + "user": "tm", + "password": "heute1234" +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..619e083 --- /dev/null +++ b/README.md @@ -0,0 +1,248 @@ +# Solve-Field-GUI + +Grafische Windows-Oberfläche für `solve-field` aus Astrometry.net mit Ausführung auf einem Linux-Server per SSH. + +Die Software richtet sich an Anwender, die Astrometrie komfortabel über eine GUI steuern möchten, ohne die `solve-field`-Befehle jedes Mal von Hand zusammenzubauen. + +## Was die Software macht + +Die Anwendung läuft lokal unter Windows und verbindet sich per SSH mit einem entfernten Linux-Server. + +Dort kann sie: + +- Bilddateien auf den Server hochladen +- `solve-field` mit vielen Parametern starten +- die Ausgabe live im Log anzeigen +- Ergebnisdateien im Remote-Verzeichnis anzeigen +- einzelne oder mehrere Ergebnisdateien wieder lokal herunterladen +- Serverfunktionen wie SExtractor-Prüfung und Diagnose ausführen + +## Hauptfunktionen + +- SSH-Verbindung zu einem Linux-Server mit Benutzername, Passwort und Port +- Upload lokaler Bilddateien per SFTP +- grafische Konfiguration fast aller wichtigen `solve-field`-Parameter +- automatische Befehlsvorschau des erzeugten `solve-field`-Aufrufs +- Unterstützung für Maßstab, RA/Dek, Suchradius, Downsampling, CPU-Limit, Tweak-Order und viele weitere Optionen +- Unterstützung für Source Extractor per `--use-source-extractor` +- automatische Berechnung von `--plot-scale` aus der lokalen Bildgröße +- serverseitige Diagnose für `solve-field`, `astrometry.cfg`, Indexdateien, SExtractor und Speicherplatz +- Remote-Ergebnis-Tab mit Dateiliste +- Mehrfachdownload ausgewählter Ergebnisdateien +- Profilspeicherung und Profilladen für komplette GUI-Einstellungen + +## Unterstützte Dateitypen + +Als Eingabedatei lokal: + +- FITS +- FIT +- FTS +- JPG / JPEG +- PNG +- TIFF / TIF + +Für die automatische Plot-Skalierung kann die Anwendung Bildgrößen direkt aus mehreren Formaten lesen, darunter FITS, PNG, JPG, GIF und BMP. + +## Voraussetzungen + +## Lokal unter Windows + +- Python 3 +- `asyncssh` +- Netzwerkzugriff auf den Linux-Server per SSH + +Installation von `asyncssh`: + +```bash +pip install asyncssh +``` + +## Auf dem Linux-Server + +- Astrometry.net mit `solve-field` +- passende Indexdateien +- SSH-Zugang + +Optional: + +- `source-extractor`, wenn SExtractor zur Quellenerkennung genutzt werden soll + +## Starten + +Direkt mit Python: + +```bash +python astrometry_gui.py +``` + +Oder unter Windows über: + +```bash +astrometry_gui.bat +``` + +## Bedienung + +## 1. SSH eintragen + +Oben in der GUI: + +- Host +- Port +- Benutzer +- Passwort + +Mit `Verbinden & testen` kann geprüft werden, ob der Server erreichbar ist und `solve-field` vorhanden ist. + +## 2. Bild auswählen und hochladen + +Im Tab `Input / Output`: + +- lokale Datei auswählen +- Remote-Verzeichnis festlegen +- Datei per `Hochladen` auf den Server übertragen + +Nach dem Upload wird der Remote-Dateipfad automatisch als Eingabedatei gesetzt. + +## 3. Solver-Parameter einstellen + +Je nach Bedarf können unter anderem gesetzt werden: + +- Maßstabsbereich +- RA / Dek / Radius +- CPU-Limit +- Downsampling +- Plot-Skalierung +- Tweak-Order +- Verifikation +- Invertierung +- SExtractor-Nutzung + +## 4. Befehl prüfen und starten + +Im Tab `Befehlsvorschau` sieht man den tatsächlich erzeugten `solve-field`-Befehl. + +Mit `solve-field starten` wird der Befehl auf dem Server ausgeführt. + +## Tabs im Überblick + +## Input / Output + +- lokale Bilddatei +- Remote-Verzeichnis +- Upload +- Eingabedatei auf dem Server +- Ausgabeoptionen wie `--wcs`, `--corr`, `--rdls`, `--match`, `--new-fits` + +## Maßstab & Position + +- `--scale-low` +- `--scale-high` +- `--scale` +- `--scale-err` +- `--ra` +- `--dec` +- `--radius` + +## Solver-Parameter + +- `--depth` +- `--cpulimit` +- `--plot-scale` +- `--downsample` +- `--tweak-order` +- `--odds-*` +- `--objs` +- `--pixel-error` +- zahlreiche Flags wie `--no-plots`, `--invert`, `--no-verify` + +Zusätzlich gibt es einen Plot-Helfer: + +- automatische Berechnung von `plot-scale` +- Zielkantenlänge einstellbar + +## Erweitert + +- manuelle Indexdateien +- zusätzliche freie Kommandozeilenargumente + +## Befehlsvorschau + +- zeigt den generierten Befehl +- kann aktualisiert werden +- kann in die Zwischenablage kopiert werden + +## Server Setup + +- SExtractor prüfen +- SExtractor installieren +- Server-Diagnose ausführen + +## Remote-Ergebnisse + +- zeigt Dateien im aktuellen Remote-Verzeichnis +- lädt den Inhalt beim Öffnen des Tabs automatisch neu +- erlaubt Auswahl und Download von einer oder mehreren Dateien + +## Remote-Ergebnisse herunterladen + +Im Tab `Remote-Ergebnisse`: + +- eine oder mehrere Dateien markieren +- `Herunterladen` klicken +- Zielordner auswählen + +Standardmäßig wird als Zielordner der Ordner der ursprünglich lokal ausgewählten Bilddatei vorgeschlagen. + +## Remote-Verzeichnis leeren + +Im Tab `Input / Output` gibt es die Funktion: + +- `Remote-Inhalt löschen` + +Dabei wird nur der Inhalt des Remote-Verzeichnisses gelöscht, nicht das Verzeichnis selbst. + +## Profile speichern und laden + +Über die Menühleiste: + +- `Profil -> Profil speichern…` +- `Profil -> Profil laden…` + +Gespeichert werden: + +- SSH-Daten +- Solver-Parameter +- Ein-/Ausgabe-Einstellungen +- Zusatzargumente +- Indexliste + +Das Passwort wird nicht im Klartext gespeichert, sondern mit einer einfachen internen Verschlüsselung abgelegt. + +## Typischer Workflow + +1. Serververbindung eintragen +2. Lokales Bild auswählen +3. Datei hochladen +4. Maßstab und Solver-Parameter setzen +5. Optional SExtractor aktivieren +6. `solve-field` starten +7. Ergebnisse im Tab `Remote-Ergebnisse` prüfen +8. Gewünschte Dateien lokal herunterladen + +## Hinweise + +- Die GUI baut nur den Befehl und führt ihn per SSH aus. Die eigentliche Astrometrie läuft vollständig auf dem Server. +- Die installierte Astrometry-Version auf dem Server bestimmt, welche Optionen tatsächlich verfügbar sind. +- Für SExtractor muss auf dem Server in der Regel `source-extractor` installiert sein. +- Für gute Ergebnisse müssen die passenden Indexdateien serverseitig vorhanden und in `astrometry.cfg` korrekt eingebunden sein. + +## Dateien im Projekt + +- [astrometry_gui.py](C:/Users/ich/source/repos/Solve-Field-GUI/astrometry_gui.py) +- [astrometry_gui.bat](C:/Users/ich/source/repos/Solve-Field-GUI/astrometry_gui.bat) + +## Lizenz / Nutzung + +Dieses Repository enthält eine GUI zur Bedienung von Astrometry.net. Die Lizenz und Bedingungen von Astrometry.net sowie von eventuell installierten Serverpaketen gelten zusätzlich zu diesem Projekt. diff --git a/__pycache__/astrometry_gui.cpython-312.pyc b/__pycache__/astrometry_gui.cpython-312.pyc new file mode 100644 index 0000000..70b2bbd Binary files /dev/null and b/__pycache__/astrometry_gui.cpython-312.pyc differ diff --git a/astrometry_gui.py b/astrometry_gui.py index ec17b89..43aadcf 100644 --- a/astrometry_gui.py +++ b/astrometry_gui.py @@ -8,13 +8,17 @@ Abhängigkeiten: """ import asyncio +import base64 +import json import tkinter as tk +import tkinter.font as tkfont from tkinter import ttk, filedialog, messagebox, scrolledtext import threading import os import shlex import queue import stat +import struct try: import asyncssh @@ -23,6 +27,9 @@ except ImportError: HAS_ASYNCSSH = False +SSH_CREDENTIALS_JSON = "ssh_zugangsdaten.json" + + # --------------------------------------------------------------------------- # Tooltip # --------------------------------------------------------------------------- @@ -144,6 +151,26 @@ class AsyncSSH: progress_handler=_progress, ) + async def download(self, remote_path: str, local_path: str, progress_cb=None): + if self._conn is None: + await self.connect() + local_path = os.path.normpath(local_path) + + async with self._conn.start_sftp_client() as sftp: + attrs = await sftp.stat(remote_path) + total = int(getattr(attrs, "size", 0) or 0) + done = 0 + async with sftp.open(remote_path, "rb") as remote_file: + with open(local_path, "wb") as local_file: + while True: + chunk = await remote_file.read(1024 * 1024) + if not chunk: + break + local_file.write(chunk) + done += len(chunk) + if progress_cb: + progress_cb(done, total) + async def close(self): if self._conn: self._conn.close() @@ -159,7 +186,7 @@ class AstrometryGUI: def __init__(self, root: tk.Tk): self.root = root - self.root.title("Astrometry.net — solve-field GUI") + self.root.title("Astrometry.net — solve-field GUI vom Astronomiemuseum") self.root.geometry("1000x840") self.root.minsize(800, 620) @@ -192,8 +219,10 @@ class AstrometryGUI: self.v_port = tk.IntVar(value=22) self.v_user = tk.StringVar(value="eskimue") self.v_pass = tk.StringVar(value="Preside") + self._load_startup_credentials() self.v_local_file = tk.StringVar() + self.v_local_file.trace_add("write", self._on_local_file_changed) 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) @@ -220,6 +249,11 @@ class AstrometryGUI: self.v_depth = tk.StringVar() self.v_cpulimit = tk.StringVar(value="300") + self.v_plot_scale = tk.StringVar() + self.v_plot_scale_auto = tk.BooleanVar(value=True) + self.v_plot_target_long_edge = tk.StringVar(value="900") + self.v_plot_target_long_edge.trace_add("write", self._on_plot_scale_settings_changed) + self.v_plot_scale_auto.trace_add("write", self._on_plot_scale_settings_changed) self.v_downsample = tk.StringVar(value="6") self.v_tweak_order = tk.StringVar(value="2") self.v_odds_solve = tk.StringVar() @@ -244,18 +278,43 @@ class AstrometryGUI: self.v_invert = tk.BooleanVar(value=False) self.v_extra = tk.StringVar() + self.results_files: dict[str, dict] = {} + + def _load_startup_credentials(self): + path = os.path.join(os.getcwd(), SSH_CREDENTIALS_JSON) + if not os.path.isfile(path): + return + try: + self._load_credentials_from_file(path) + except Exception: + pass + + def _load_credentials_from_file(self, path: str): + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + if "host" in data: + self.v_host.set(str(data["host"])) + if "port" in data: + self.v_port.set(int(data["port"])) + if "user" in data: + self.v_user.set(str(data["user"])) + if "password" in data: + self.v_pass.set(str(data["password"])) # ----------------------------------------------------------------------- # UI aufbauen # ----------------------------------------------------------------------- def _build_ui(self): + self._build_menu() + 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)) + self.nb = ttk.Notebook(self.root) + self.nb.pack(fill=tk.BOTH, expand=True, padx=6, pady=(0, 4)) + self.nb.bind("<>", self._on_tab_changed) for title, builder in [ ("Input / Output", self._tab_io), @@ -265,10 +324,14 @@ class AstrometryGUI: ("Befehlsvorschau", self._tab_cmd), ("⚙ Server Setup", self._tab_setup), ]: - f = ttk.Frame(nb, padding=6) - nb.add(f, text=title) + f = ttk.Frame(self.nb, padding=6) + self.nb.add(f, text=title) builder(f) + self.results_tab = ttk.Frame(self.nb, padding=6) + self._tab_results(self.results_tab) + self.nb.add(self.results_tab, text="Remote-Ergebnisse") + btn_bar = ttk.Frame(self.root, padding=4) btn_bar.pack(fill=tk.X) @@ -304,6 +367,22 @@ class AstrometryGUI: self._current_task: asyncio.Future | None = None + def _build_menu(self): + menubar = tk.Menu(self.root) + + profile_menu = tk.Menu(menubar, tearoff=False) + profile_menu.add_command(label="Verbindung laden…", command=self._menu_load_credentials) + profile_menu.add_separator() + profile_menu.add_command(label="Profil speichern…", command=self._save_profile) + profile_menu.add_command(label="Profil laden…", command=self._load_profile) + menubar.add_cascade(label="Profil", menu=profile_menu) + + self.root.config(menu=menubar) + + def _on_tab_changed(self, _event=None): + if self.nb.select() == str(self.results_tab): + self._refresh_results_tab() + # ----------------------------------------------------------------------- # Verbindungs-Leiste # ----------------------------------------------------------------------- @@ -348,6 +427,9 @@ class AstrometryGUI: "Öffnet einen Datei-Dialog um die lokale Astro-Bild-Datei auszuwählen." ).grid(row=0, column=2, padx=4) + self.local_image_size_label = ttk.Label(uf, text="Größe: —", foreground="gray") + self.local_image_size_label.grid(row=0, column=3, sticky=tk.W, padx=4, pady=3) + 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" @@ -358,9 +440,14 @@ class AstrometryGUI: "Ü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) + tip(ttk.Button(uf, text="Remote-Inhalt l\u00f6schen", command=self._delete_remote_dir), + "L\u00f6scht nur den Inhalt des angegebenen Remote-Verzeichnisses auf dem Server.\n" + "Das Verzeichnis selbst bleibt erhalten.\n" + "Vorher erscheint eine Sicherheitsabfrage." + ).grid(row=1, column=3, 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) + self.upload_progress.grid(row=2, column=0, columnspan=4, 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) @@ -492,6 +579,10 @@ class AstrometryGUI: ("CPU-Limit (--cpulimit, s):", self.v_cpulimit, "Maximale Rechenzeit in Sekunden.\n" "300s ist ein guter Kompromiss. 0 = kein Limit."), + ("Plot-Skalierung (--plot-scale):", self.v_plot_scale, + "Skaliert nur die erzeugten Plot-Bilder.\n" + "0.25 erzeugt kleinere Plots, 2.0 größere.\n" + "Hat keinen Einfluss auf die eigentliche Astrometrie."), ("Downsampling (--downsample):", self.v_downsample, "Verkleinert das Bild vor der Verarbeitung.\n" "6 = 1/6 der Auflösung. Beschleunigt die Quellerkennung stark.\n" @@ -521,6 +612,23 @@ class AstrometryGUI: tooltip ).grid(row=row, column=col * 2 + 1, sticky=tk.W, padx=(0, 4), pady=2) + plotf = ttk.LabelFrame(parent, text="Plot-Helfer", padding=6) + plotf.pack(fill=tk.X, pady=(0, 8)) + plotf.columnconfigure(3, weight=1) + + tip(ttk.Checkbutton(plotf, text="Plot-Skalierung automatisch aus lokaler Bildgröße", variable=self.v_plot_scale_auto), + "Berechnet --plot-scale aus der langen Bildkante der lokal ausgewählten Datei.\n" + "Manuelle Änderungen am Feld sind weiterhin möglich." + ).grid(row=0, column=0, columnspan=2, sticky=tk.W, padx=4, pady=2) + ttk.Label(plotf, text="Zielkantenlänge (px):").grid(row=0, column=2, sticky=tk.W, padx=4, pady=2) + tip(ttk.Entry(plotf, textvariable=self.v_plot_target_long_edge, width=10), + "Die lange Kante der erzeugten Plots wird grob auf diesen Wert skaliert.\n" + "Standard: 900 px." + ).grid(row=0, column=3, sticky=tk.W, padx=4, pady=2) + tip(ttk.Button(plotf, text="Jetzt berechnen", command=self._recalculate_plot_scale), + "Liest die Größe der lokalen Datei und setzt --plot-scale neu." + ).grid(row=0, column=4, sticky=tk.W, padx=4, pady=2) + ff = ttk.LabelFrame(parent, text="Schalter (Flags)", padding=6) ff.pack(fill=tk.X, pady=(0, 8)) @@ -547,7 +655,7 @@ class AstrometryGUI: ("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, + ("SExtractor nutzen (--use-source-extractor)", 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, @@ -685,6 +793,37 @@ class AstrometryGUI: " • Freier Speicherplatz" ).pack(side=tk.LEFT, padx=4, pady=4) + def _tab_results(self, parent): + head = ttk.Frame(parent) + head.pack(fill=tk.X, pady=(0, 4)) + + tip(ttk.Label(head, text="Remote-Ergebnisse nach solve-field:"), + "Zeigt den Inhalt des Remote-Verzeichnisses nach einem solve-field-Lauf an." + ).pack(side=tk.LEFT) + tip(ttk.Button(head, text="Aktualisieren", command=self._refresh_results_tab), + "Liest den Inhalt des aktuell eingestellten Remote-Verzeichnisses erneut vom Server ein." + ).pack(side=tk.RIGHT, padx=4) + tip(ttk.Button(head, text="Remote-Inhalt löschen", command=self._delete_remote_dir), + "Löscht den Inhalt des aktuell eingestellten Remote-Verzeichnisses auf dem Server." + ).pack(side=tk.RIGHT, padx=4) + tip(ttk.Button(head, text="Herunterladen", command=self._download_selected_result), + "Lädt die ausgewählte Remote-Datei per SFTP herunter." + ).pack(side=tk.RIGHT, padx=4) + + self.results_info = ttk.Label(parent, text="Noch kein solve-field-Lauf abgeschlossen.", foreground="gray") + self.results_info.pack(anchor=tk.W, pady=(0, 4)) + + cols = ("name", "size", "remote") + self.results_tree = ttk.Treeview(parent, columns=cols, show="headings", height=16, selectmode="extended") + self.results_tree.heading("name", text="Datei") + self.results_tree.heading("size", text="Größe") + self.results_tree.heading("remote", text="Remote-Pfad") + self.results_tree.column("name", width=240, anchor=tk.W) + self.results_tree.column("size", width=100, anchor=tk.E) + self.results_tree.column("remote", width=520, anchor=tk.W) + self.results_tree.pack(fill=tk.BOTH, expand=True) + self.results_tree.bind("", lambda _e: self._download_selected_result()) + # ----------------------------------------------------------------------- # Befehl bauen # ----------------------------------------------------------------------- @@ -713,7 +852,7 @@ class AstrometryGUI: 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("--use-source-extractor", self.v_sextractor) flag("--fits-image", self.v_fits_image) flag("--no-verify", self.v_no_verify) flag("--just-augment", self.v_just_augment) @@ -754,6 +893,7 @@ class AstrometryGUI: add("--depth", self.v_depth) add("--cpulimit", self.v_cpulimit) + add("--plot-scale", self.v_plot_scale) add("--downsample", self.v_downsample) add("--odds-to-solve", self.v_odds_solve) add("--odds-to-reject", self.v_odds_reject) @@ -843,6 +983,195 @@ class AstrometryGUI: finally: self.root.after(0, lambda: self.upload_btn.config(state=tk.NORMAL)) + def _delete_remote_dir(self): + remote_dir = self.v_remote_dir.get().strip() + if not remote_dir: + messagebox.showwarning("Remote-Verzeichnis fehlt", "Bitte ein Remote-Verzeichnis angeben.") + return + if remote_dir == "/": + messagebox.showerror("Ung\u00fcltiges Verzeichnis", "Das Root-Verzeichnis '/' darf nicht gel\u00f6scht werden.") + return + if not messagebox.askyesno( + "Remote-Inhalt l\u00f6schen", + f"Soll nur der Inhalt dieses Verzeichnisses auf dem Server rekursiv gel\u00f6scht werden?\n\n{remote_dir}" + ): + return + self._submit(self._async_delete_remote_dir(remote_dir)) + + async def _async_delete_remote_dir(self, remote_dir: str): + quoted_dir = shlex.quote(remote_dir) + cmd = ( + f"dir={quoted_dir}; " + "if [ -z \"$dir\" ] || [ \"$dir\" = \"/\" ]; then " + "echo '[FEHLER] Unsicheres Remote-Verzeichnis.'; exit 1; " + "fi; " + "if [ ! -e \"$dir\" ]; then " + "echo '[INFO] Remote-Verzeichnis existiert nicht.'; exit 0; " + "fi; " + "find \"$dir\" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +; " + "echo \"[OK] Remote-Inhalt gel\u00f6scht: $dir\"" + ) + self._log(f"[INFO] L\u00f6sche Remote-Inhalt: {remote_dir}\n", "info") + 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] Remote-Inhalt l\u00f6schen: {exc}\n", "error") + + def _refresh_results_tab(self): + remote_dir = self.v_remote_dir.get().strip() + self.root.config(cursor="watch") + self.nb.config(cursor="watch") + self.results_tree.config(cursor="watch") + self._submit(self._async_refresh_results_tab(remote_dir)) + + async def _async_refresh_results_tab(self, remote_dir: str, returncode: int | None = None): + if not remote_dir: + self.root.after(0, lambda: self._set_results_info("Kein Remote-Verzeichnis gesetzt.")) + self.root.after(0, lambda: self._set_results_items([])) + return + + quoted_dir = shlex.quote(remote_dir) + cmd = ( + f"dir={quoted_dir}; " + "if [ ! -e \"$dir\" ]; then " + "echo 'Remote-Verzeichnis existiert nicht.'; exit 0; " + "fi; " + "find \"$dir\" -maxdepth 2 -type f -printf '%s\t%P\t%p\\n' | sort" + ) + info = f"Remote-Verzeichnis: {remote_dir}" + if returncode is not None: + info += f" | solve-field Exit-Code: {returncode}" + self.root.after(0, lambda: self._set_results_info(info)) + self.root.after(0, lambda: self._set_results_items([])) + + entries: list[dict[str, str]] = [] + + def _capture(line: str): + stripped = line.strip() + if not stripped or stripped.startswith("[Prozess beendet"): + return + if "\t" not in stripped: + return + size, rel_path, full_path = stripped.split("\t", 2) + entries.append({ + "name": rel_path or os.path.basename(full_path), + "size": size, + "remote": full_path, + }) + + try: + ssh = self._make_ssh() + await ssh.connect() + await ssh.exec_stream(cmd, _capture) + await ssh.close() + self.root.after(0, lambda: self._set_results_items(entries)) + self.root.after(0, self._clear_wait_cursor) + except Exception as exc: + self.root.after(0, lambda: self._set_results_info(f"[FEHLER] {exc}")) + self.root.after(0, lambda: self._set_results_items([])) + self.root.after(0, self._clear_wait_cursor) + + def _set_results_info(self, text: str): + self.results_info.config(text=text, foreground="gray") + + def _set_results_items(self, entries: list[dict[str, str]]): + self.results_files = {} + for item in self.results_tree.get_children(): + self.results_tree.delete(item) + for i, entry in enumerate(entries): + item_id = f"result_{i}" + self.results_files[item_id] = entry + self.results_tree.insert( + "", + tk.END, + iid=item_id, + values=( + entry["name"], + self._format_size(entry["size"]), + entry["remote"], + ), + ) + self._autosize_results_columns() + + def _autosize_results_columns(self): + body_font = tkfont.nametofont("TkDefaultFont") + head_font = tkfont.nametofont("TkHeadingFont") + padding = 24 + + for col in ("name", "size", "remote"): + header = self.results_tree.heading(col, "text") or col + width = head_font.measure(header) + padding + for item_id in self.results_tree.get_children(): + value = self.results_tree.set(item_id, col) + width = max(width, body_font.measure(str(value)) + padding) + self.results_tree.column(col, width=width) + + def _clear_wait_cursor(self): + self.root.config(cursor="") + self.nb.config(cursor="") + self.results_tree.config(cursor="") + + def _format_size(self, size_text: str) -> str: + try: + size = int(size_text) + except Exception: + return size_text + units = ["B", "KB", "MB", "GB", "TB"] + value = float(size) + for unit in units: + if value < 1024 or unit == units[-1]: + if unit == "B": + return f"{int(value)} {unit}" + return f"{value:.1f} {unit}" + value /= 1024 + return size_text + + def _download_selected_result(self): + selection = self.results_tree.selection() + if not selection: + messagebox.showwarning("Keine Datei ausgewählt", "Bitte zuerst eine oder mehrere Dateien im Ergebnis-Tab auswählen.") + return + entries = [] + for item_id in selection: + entry = self.results_files.get(item_id) + if entry: + entries.append(entry) + if not entries: + messagebox.showwarning("Datei fehlt", "Die ausgewählten Dateien konnten nicht gefunden werden.") + return + source_dir = os.path.dirname(self.v_local_file.get().strip()) if self.v_local_file.get().strip() else "" + initial_dir = source_dir if source_dir and os.path.isdir(source_dir) else os.getcwd() + local_dir = filedialog.askdirectory( + title="Zielordner für Download auswählen", + initialdir=initial_dir, + ) + if not local_dir: + return + downloads = [] + for entry in entries: + suggested = os.path.basename(entry["remote"]) + local_path = os.path.normpath(os.path.join(local_dir, suggested)) + downloads.append((entry["remote"], local_path)) + self._submit(self._async_download_results(downloads)) + + async def _async_download_results(self, downloads: list[tuple[str, str]]): + try: + ssh = self._make_ssh() + await ssh.connect() + for remote_path, local_path in downloads: + self._log(f"[INFO] Lade Ergebnis herunter: {remote_path} → {local_path}\n", "info") + os.makedirs(os.path.dirname(local_path), exist_ok=True) + await ssh.download(remote_path, local_path) + if not os.path.isfile(local_path): + raise FileNotFoundError(f"Datei nach Download nicht gefunden: {local_path}") + self._log(f"[OK] Download abgeschlossen: {local_path}\n", "ok") + await ssh.close() + except Exception as exc: + self._log(f"[FEHLER] Download: {exc}\n", "error") + def _run(self): if not self.v_input_file.get(): messagebox.showwarning("Keine Eingabedatei", "Bitte einen Dateipfad angeben.") @@ -855,10 +1184,13 @@ class AstrometryGUI: try: ssh = self._make_ssh() await ssh.connect() - await ssh.exec_stream(cmd, self._log) + rc = await ssh.exec_stream(cmd, self._log) await ssh.close() + self._current_task = None + await self._async_refresh_results_tab(self.v_remote_dir.get().strip(), rc) except Exception as exc: self._log(f"[FEHLER] {exc}\n", "error") + self._current_task = None def _kill(self): if self._current_task: @@ -882,6 +1214,132 @@ class AstrometryGUI: if not current or (current.endswith(".fits") and current.startswith(out_dir)): self.v_new_fits.set(auto_path) + def _on_local_file_changed(self, *_): + path = self.v_local_file.get().strip() + if not path or not os.path.isfile(path): + self.local_image_size_label.config(text="Größe: —") + else: + try: + width, height = self._read_image_size(path) + self.local_image_size_label.config(text=f"Größe: {width} × {height}") + except Exception: + self.local_image_size_label.config(text="Größe: unbekannt") + if self.v_plot_scale_auto.get(): + self._recalculate_plot_scale() + + def _on_plot_scale_settings_changed(self, *_): + if self.v_plot_scale_auto.get(): + self._recalculate_plot_scale() + + def _recalculate_plot_scale(self): + path = self.v_local_file.get().strip() + if not path or not os.path.isfile(path): + return + try: + target = float(self.v_plot_target_long_edge.get().replace(",", ".")) + if target <= 0: + return + width, height = self._read_image_size(path) + long_edge = max(width, height) + if long_edge <= 0: + return + scale = target / long_edge + self.v_plot_scale.set(f"{scale:.3f}".rstrip("0").rstrip(".")) + except Exception: + return + + def _read_image_size(self, path: str) -> tuple[int, int]: + ext = os.path.splitext(path)[1].lower() + if ext in (".fits", ".fit", ".fts"): + return self._read_fits_size(path) + if ext == ".png": + return self._read_png_size(path) + if ext in (".jpg", ".jpeg"): + return self._read_jpeg_size(path) + if ext == ".gif": + return self._read_gif_size(path) + if ext == ".bmp": + return self._read_bmp_size(path) + raise ValueError("Nicht unterstütztes Bildformat") + + def _read_fits_size(self, path: str) -> tuple[int, int]: + width = height = None + with open(path, "rb") as f: + while True: + block = f.read(2880) + if not block: + break + for i in range(0, len(block), 80): + card = block[i:i + 80].decode("ascii", errors="ignore") + key = card[:8].strip() + if key == "END": + if width and height: + return width, height + break + if "=" not in card: + continue + value = card[10:30].split("/")[0].strip() + if key == "NAXIS1": + width = int(value) + elif key == "NAXIS2": + height = int(value) + raise ValueError("FITS-Bildgröße nicht lesbar") + + def _read_png_size(self, path: str) -> tuple[int, int]: + with open(path, "rb") as f: + header = f.read(24) + if header[:8] != b"\x89PNG\r\n\x1a\n": + raise ValueError("Ungültige PNG-Datei") + return struct.unpack(">II", header[16:24]) + + def _read_gif_size(self, path: str) -> tuple[int, int]: + with open(path, "rb") as f: + header = f.read(10) + if header[:6] not in (b"GIF87a", b"GIF89a"): + raise ValueError("Ungültige GIF-Datei") + return struct.unpack(" tuple[int, int]: + with open(path, "rb") as f: + header = f.read(26) + if header[:2] != b"BM": + raise ValueError("Ungültige BMP-Datei") + width, height = struct.unpack(" tuple[int, int]: + with open(path, "rb") as f: + if f.read(2) != b"\xff\xd8": + raise ValueError("Ungültige JPEG-Datei") + while True: + marker_prefix = f.read(1) + if not marker_prefix: + break + if marker_prefix != b"\xff": + continue + marker = f.read(1) + while marker == b"\xff": + marker = f.read(1) + if marker in (b"\xd8", b"\xd9"): + continue + size_bytes = f.read(2) + if len(size_bytes) != 2: + break + segment_size = struct.unpack(">H", size_bytes)[0] + if marker in ( + b"\xc0", b"\xc1", b"\xc2", b"\xc3", + b"\xc5", b"\xc6", b"\xc7", + b"\xc9", b"\xca", b"\xcb", + b"\xcd", b"\xce", b"\xcf", + ): + data = f.read(5) + if len(data) != 5: + break + height, width = struct.unpack(">HH", data[1:5]) + return width, height + f.seek(segment_size - 2, os.SEEK_CUR) + raise ValueError("JPEG-Bildgröße nicht lesbar") + def _browse_local(self): path = filedialog.askopenfilename( title="Astro-Bild auswählen", @@ -892,6 +1350,8 @@ class AstrometryGUI: ) if path: self.v_local_file.set(path) + if messagebox.askyesno("Datei hochladen", "Soll die ausgewählte Datei jetzt direkt auf den Server hochgeladen werden?"): + self._upload() def _refresh_cmd(self): cmd = self._build_command() @@ -902,6 +1362,110 @@ class AstrometryGUI: self.root.clipboard_clear() self.root.clipboard_append(self.cmd_text.get("1.0", tk.END).strip()) + def _encrypt_text(self, text: str) -> str: + if not text: + return "" + key = b"AstrometryGuiInternalKey-2026" + plain = text.encode("utf-8") + encrypted = bytes(b ^ key[i % len(key)] for i, b in enumerate(plain)) + return base64.b64encode(encrypted).decode("ascii") + + def _decrypt_text(self, token: str) -> str: + if not token: + return "" + key = b"AstrometryGuiInternalKey-2026" + encrypted = base64.b64decode(token.encode("ascii")) + plain = bytes(b ^ key[i % len(key)] for i, b in enumerate(encrypted)) + return plain.decode("utf-8") + + def _collect_profile_data(self) -> dict: + vars_data = {} + for name, value in self.__dict__.items(): + if name.startswith("v_") and hasattr(value, "get"): + vars_data[name] = value.get() + if "v_pass" in vars_data: + vars_data["v_pass"] = "" + return { + "format": 2, + "variables": vars_data, + "secrets": { + "v_pass_enc": self._encrypt_text(self.v_pass.get()), + }, + "idx_text": self.idx_text.get("1.0", tk.END).strip(), + } + + def _apply_profile_data(self, data: dict): + vars_data = data.get("variables", {}) + for name, value in vars_data.items(): + var = getattr(self, name, None) + if var is not None and hasattr(var, "set"): + var.set(value) + + secrets = data.get("secrets", {}) + encrypted_pass = secrets.get("v_pass_enc") or secrets.get("v_pass_dpapi") + if encrypted_pass: + try: + self.v_pass.set(self._decrypt_text(encrypted_pass)) + except Exception: + pass + + self.idx_text.delete("1.0", tk.END) + idx_text = data.get("idx_text", "") + if idx_text: + self.idx_text.insert("1.0", idx_text) + + self._refresh_cmd() + + def _save_profile(self): + profile_dir = os.path.join(os.getcwd(), "profiles") + os.makedirs(profile_dir, exist_ok=True) + path = filedialog.asksaveasfilename( + title="Profil speichern", + initialdir=profile_dir, + defaultextension=".json", + filetypes=[("JSON-Dateien", "*.json"), ("Alle Dateien", "*.*")], + ) + if not path: + return + data = self._collect_profile_data() + try: + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + self._log(f"[OK] Profil gespeichert: {path}\n", "ok") + except Exception as exc: + messagebox.showerror("Profil speichern", f"Speichern fehlgeschlagen:\n{exc}") + + def _load_profile(self): + profile_dir = os.path.join(os.getcwd(), "profiles") + path = filedialog.askopenfilename( + title="Profil laden", + initialdir=profile_dir if os.path.isdir(profile_dir) else os.getcwd(), + filetypes=[("JSON-Dateien", "*.json"), ("Alle Dateien", "*.*")], + ) + if not path: + return + try: + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + self._apply_profile_data(data) + self._log(f"[OK] Profil geladen: {path}\n", "ok") + except Exception as exc: + messagebox.showerror("Profil laden", f"Laden fehlgeschlagen:\n{exc}") + + def _menu_load_credentials(self): + path = filedialog.askopenfilename( + title="Zugangsdaten laden", + initialdir=os.getcwd(), + filetypes=[("JSON-Dateien", "*.json"), ("Alle Dateien", "*.*")], + ) + if not path: + return + try: + self._load_credentials_from_file(path) + self._log(f"[OK] Zugangsdaten geladen: {path}\n", "ok") + except Exception as exc: + messagebox.showerror("Zugangsdaten laden", f"Laden fehlgeschlagen:\n{exc}") + # ----------------------------------------------------------------------- # Log (thread-sicher via Queue) # ----------------------------------------------------------------------- diff --git a/profiles/HU 0815.json b/profiles/HU 0815.json new file mode 100644 index 0000000..0f1da39 --- /dev/null +++ b/profiles/HU 0815.json @@ -0,0 +1,60 @@ +{ + "format": 2, + "variables": { + "v_host": "10.42.44.25", + "v_port": 22, + "v_user": "eskimue", + "v_pass": "", + "v_local_file": "", + "v_remote_dir": "/tmp/astrometry", + "v_input_file": "", + "v_out_prefix": "", + "v_out_dir": "/tmp/astrometry", + "v_new_fits": "", + "v_wcs": "", + "v_corr": "", + "v_rdls": "", + "v_axy": "", + "v_solved": "", + "v_match": "", + "v_scale_units": "degwidth", + "v_scale_low": "20", + "v_scale_high": "30", + "v_scale_est": "", + "v_scale_err": "", + "v_ra": "", + "v_dec": "", + "v_radius": "1.0", + "v_depth": "", + "v_cpulimit": "300", + "v_plot_scale": "", + "v_plot_scale_auto": true, + "v_plot_target_long_edge": "900", + "v_downsample": "6", + "v_tweak_order": "2", + "v_odds_solve": "", + "v_odds_reject": "", + "v_odds_tune": "", + "v_objs": "", + "v_pixel_error": "", + "v_parity": "both", + "v_overwrite": true, + "v_no_plots": false, + "v_no_tweak": false, + "v_resort": false, + "v_verbose": false, + "v_timestamp": true, + "v_no_del_temp": false, + "v_continue": false, + "v_sextractor": false, + "v_fits_image": false, + "v_no_verify": false, + "v_just_augment": false, + "v_invert": true, + "v_extra": "" + }, + "secrets": { + "v_pass_enc": "EQERAQYJAA==" + }, + "idx_text": "" +} \ No newline at end of file diff --git a/profiles/Test.json b/profiles/Test.json new file mode 100644 index 0000000..10b0bb5 --- /dev/null +++ b/profiles/Test.json @@ -0,0 +1,60 @@ +{ + "format": 2, + "variables": { + "v_host": "10.42.44.25", + "v_port": 22, + "v_user": "eskimue", + "v_pass": "", + "v_local_file": "C:/Users/ich/Cloud__Museum/Museumsentwicklung/Shop/Faksimile/2026/Heinlein Dieter/IMG_410328-1956_08_30-20000+2000-Te3-26-50.png", + "v_remote_dir": "/tmp/astrometry", + "v_input_file": "/tmp/astrometry/IMG_410328-1956_08_30-20000+2000-Te3-26-50.png", + "v_out_prefix": "", + "v_out_dir": "/tmp/astrometry", + "v_new_fits": "/tmp/astrometry/IMG_410328-1956_08_30-20000+2000-Te3-26-50.fits", + "v_wcs": "", + "v_corr": "", + "v_rdls": "", + "v_axy": "", + "v_solved": "", + "v_match": "", + "v_scale_units": "degwidth", + "v_scale_low": "20", + "v_scale_high": "30", + "v_scale_est": "", + "v_scale_err": "", + "v_ra": "", + "v_dec": "", + "v_radius": "1.0", + "v_depth": "", + "v_cpulimit": "300", + "v_plot_scale": "0.15", + "v_plot_scale_auto": true, + "v_plot_target_long_edge": "900", + "v_downsample": "6", + "v_tweak_order": "2", + "v_odds_solve": "", + "v_odds_reject": "", + "v_odds_tune": "", + "v_objs": "", + "v_pixel_error": "", + "v_parity": "both", + "v_overwrite": true, + "v_no_plots": false, + "v_no_tweak": false, + "v_resort": false, + "v_verbose": false, + "v_timestamp": true, + "v_no_del_temp": false, + "v_continue": false, + "v_sextractor": false, + "v_fits_image": false, + "v_no_verify": false, + "v_just_augment": false, + "v_invert": true, + "v_extra": "" + }, + "secrets": { + "v_pass_enc": "EQERAQYJAA==" + }, + "idx_text": "" +} \ No newline at end of file diff --git a/ssh_zugangsdaten.json b/ssh_zugangsdaten.json new file mode 100644 index 0000000..646df60 --- /dev/null +++ b/ssh_zugangsdaten.json @@ -0,0 +1,6 @@ +{ + "host": "10.42.44.25", + "port": 22, + "user": "eskimue", + "password": "Preside" +}