1458 lines
62 KiB
Python
1458 lines
62 KiB
Python
#!/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 base64
|
||
import json
|
||
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 vom Astronomiemuseum")
|
||
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):
|
||
self._build_menu()
|
||
|
||
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))
|
||
self.nb.bind("<<NotebookTabChanged>>", self._on_tab_changed)
|
||
|
||
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
|
||
|
||
def _build_menu(self):
|
||
menubar = tk.Menu(self.root)
|
||
|
||
profile_menu = tk.Menu(menubar, tearoff=False)
|
||
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
|
||
# -----------------------------------------------------------------------
|
||
|
||
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-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,
|
||
"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-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)
|
||
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 _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"],
|
||
),
|
||
)
|
||
|
||
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 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"])
|
||
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
|
||
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
|
||
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())
|
||
|
||
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}")
|
||
|
||
# -----------------------------------------------------------------------
|
||
# 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()
|