This commit is contained in:
Eskimue
2026-07-14 12:22:39 +02:00
7 changed files with 953 additions and 9 deletions
+6
View File
@@ -0,0 +1,6 @@
{
"host": "10.42.42.102",
"port": 22,
"user": "tm",
"password": "heute1234"
}
+248
View File
@@ -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.
Binary file not shown.
+573 -9
View File
@@ -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("<<NotebookTabChanged>>", 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("<Double-1>", 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("<HH", header[6:10])
def _read_bmp_size(self, path: str) -> 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("<ii", header[18:26])
return abs(width), abs(height)
def _read_jpeg_size(self, path: str) -> 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)
# -----------------------------------------------------------------------
+60
View File
@@ -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": ""
}
+60
View File
@@ -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": ""
}
+6
View File
@@ -0,0 +1,6 @@
{
"host": "10.42.44.25",
"port": 22,
"user": "eskimue",
"password": "Preside"
}