Files
Solve-Field-GUI/astrometry_gui.py
T
2026-06-28 19:48:42 +02:00

1340 lines
57 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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
import struct
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 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()
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_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)
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_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()
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()
self.results_files: dict[str, dict] = {}
# -----------------------------------------------------------------------
# UI aufbauen
# -----------------------------------------------------------------------
def _build_ui(self):
top = ttk.Frame(self.root, padding=4)
top.pack(fill=tk.X)
self._build_conn_bar(top)
self.nb = ttk.Notebook(self.root)
self.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(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)
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)
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=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)
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."),
("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"
"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)
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))
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)
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="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)
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
# -----------------------------------------------------------------------
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("--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)
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 _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 _show_results_tab(self):
self.nb.select(self.results_tab)
def _refresh_results_tab(self):
remote_dir = self.v_remote_dir.get().strip()
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))
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([]))
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"],
),
)
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 Datei im Ergebnis-Tab auswählen.")
return
entry = self.results_files.get(selection[0])
if not entry:
messagebox.showwarning("Datei fehlt", "Die ausgewählte Datei konnte nicht gefunden werden.")
return
suggested = os.path.basename(entry["remote"])
local_dir = filedialog.askdirectory(
title="Zielordner für Download auswählen",
)
if not local_dir:
return
local_path = os.path.normpath(os.path.join(local_dir, suggested))
self._submit(self._async_download_result(entry["remote"], local_path))
async def _async_download_result(self, remote_path: str, local_path: str):
self._log(f"[INFO] Lade Ergebnis herunter: {remote_path} → {local_path}\n", "info")
try:
os.makedirs(os.path.dirname(local_path), exist_ok=True)
ssh = self._make_ssh()
await ssh.connect()
await ssh.download(remote_path, local_path)
await ssh.close()
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")
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.")
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()
rc = await ssh.exec_stream(cmd, self._log)
await ssh.close()
self._current_task = None
self.root.after(0, self._show_results_tab)
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:
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 _on_local_file_changed(self, *_):
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",
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()