SolvePlate

This commit is contained in:
Eskimue
2026-06-28 13:57:08 +02:00
commit e94488def7
3 changed files with 997 additions and 0 deletions
+974
View File
@@ -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("<Enter>", self._schedule, add="+")
widget.bind("<Leave>", self._cancel, add="+")
widget.bind("<ButtonPress>", 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()