diff --git a/__pycache__/astrometry_gui.cpython-312.pyc b/__pycache__/astrometry_gui.cpython-312.pyc new file mode 100644 index 0000000..44e363e Binary files /dev/null and b/__pycache__/astrometry_gui.cpython-312.pyc differ diff --git a/astrometry_gui.py b/astrometry_gui.py index ec17b89..5f552be 100644 --- a/astrometry_gui.py +++ b/astrometry_gui.py @@ -15,6 +15,7 @@ import os import shlex import queue import stat +import struct try: import asyncssh @@ -144,6 +145,26 @@ class AsyncSSH: progress_handler=_progress, ) + async def download(self, remote_path: str, local_path: str, progress_cb=None): + if self._conn is None: + await self.connect() + local_path = os.path.normpath(local_path) + + async with self._conn.start_sftp_client() as sftp: + attrs = await sftp.stat(remote_path) + total = int(getattr(attrs, "size", 0) or 0) + done = 0 + async with sftp.open(remote_path, "rb") as remote_file: + with open(local_path, "wb") as local_file: + while True: + chunk = await remote_file.read(1024 * 1024) + if not chunk: + break + local_file.write(chunk) + done += len(chunk) + if progress_cb: + progress_cb(done, total) + async def close(self): if self._conn: self._conn.close() @@ -194,6 +215,7 @@ class AstrometryGUI: 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) @@ -220,6 +242,11 @@ class AstrometryGUI: self.v_depth = tk.StringVar() self.v_cpulimit = tk.StringVar(value="300") + self.v_plot_scale = tk.StringVar() + self.v_plot_scale_auto = tk.BooleanVar(value=True) + self.v_plot_target_long_edge = tk.StringVar(value="900") + self.v_plot_target_long_edge.trace_add("write", self._on_plot_scale_settings_changed) + self.v_plot_scale_auto.trace_add("write", self._on_plot_scale_settings_changed) self.v_downsample = tk.StringVar(value="6") self.v_tweak_order = tk.StringVar(value="2") self.v_odds_solve = tk.StringVar() @@ -244,6 +271,7 @@ class AstrometryGUI: self.v_invert = tk.BooleanVar(value=False) self.v_extra = tk.StringVar() + self.results_files: dict[str, dict] = {} # ----------------------------------------------------------------------- # UI aufbauen @@ -254,8 +282,8 @@ class AstrometryGUI: top.pack(fill=tk.X) self._build_conn_bar(top) - nb = ttk.Notebook(self.root) - nb.pack(fill=tk.BOTH, expand=True, padx=6, pady=(0, 4)) + self.nb = ttk.Notebook(self.root) + self.nb.pack(fill=tk.BOTH, expand=True, padx=6, pady=(0, 4)) for title, builder in [ ("Input / Output", self._tab_io), @@ -265,10 +293,14 @@ class AstrometryGUI: ("Befehlsvorschau", self._tab_cmd), ("⚙ Server Setup", self._tab_setup), ]: - f = ttk.Frame(nb, padding=6) - nb.add(f, text=title) + f = ttk.Frame(self.nb, padding=6) + self.nb.add(f, text=title) builder(f) + self.results_tab = ttk.Frame(self.nb, padding=6) + self._tab_results(self.results_tab) + self.nb.add(self.results_tab, text="Remote-Ergebnisse") + btn_bar = ttk.Frame(self.root, padding=4) btn_bar.pack(fill=tk.X) @@ -358,9 +390,14 @@ class AstrometryGUI: "Überträgt die lokale Datei asynchron per SFTP auf den Server.\n" "Nach dem Upload wird der Remote-Pfad automatisch eingetragen.") self.upload_btn.grid(row=1, column=2, padx=4) + tip(ttk.Button(uf, text="Remote-Inhalt l\u00f6schen", command=self._delete_remote_dir), + "L\u00f6scht nur den Inhalt des angegebenen Remote-Verzeichnisses auf dem Server.\n" + "Das Verzeichnis selbst bleibt erhalten.\n" + "Vorher erscheint eine Sicherheitsabfrage." + ).grid(row=1, column=3, padx=4) self.upload_progress = ttk.Progressbar(uf, mode="determinate", maximum=100) - self.upload_progress.grid(row=2, column=0, columnspan=3, sticky=tk.EW, padx=4, pady=4) + self.upload_progress.grid(row=2, column=0, columnspan=4, sticky=tk.EW, padx=4, pady=4) tip(self.upload_progress, "Fortschritt des Datei-Uploads in Prozent.") rf = ttk.LabelFrame(parent, text="Eingabedatei auf Remote-Rechner", padding=6) @@ -492,6 +529,10 @@ class AstrometryGUI: ("CPU-Limit (--cpulimit, s):", self.v_cpulimit, "Maximale Rechenzeit in Sekunden.\n" "300s ist ein guter Kompromiss. 0 = kein Limit."), + ("Plot-Skalierung (--plot-scale):", self.v_plot_scale, + "Skaliert nur die erzeugten Plot-Bilder.\n" + "0.25 erzeugt kleinere Plots, 2.0 größere.\n" + "Hat keinen Einfluss auf die eigentliche Astrometrie."), ("Downsampling (--downsample):", self.v_downsample, "Verkleinert das Bild vor der Verarbeitung.\n" "6 = 1/6 der Auflösung. Beschleunigt die Quellerkennung stark.\n" @@ -521,6 +562,23 @@ class AstrometryGUI: tooltip ).grid(row=row, column=col * 2 + 1, sticky=tk.W, padx=(0, 4), pady=2) + plotf = ttk.LabelFrame(parent, text="Plot-Helfer", padding=6) + plotf.pack(fill=tk.X, pady=(0, 8)) + plotf.columnconfigure(3, weight=1) + + tip(ttk.Checkbutton(plotf, text="Plot-Skalierung automatisch aus lokaler Bildgröße", variable=self.v_plot_scale_auto), + "Berechnet --plot-scale aus der langen Bildkante der lokal ausgewählten Datei.\n" + "Manuelle Änderungen am Feld sind weiterhin möglich." + ).grid(row=0, column=0, columnspan=2, sticky=tk.W, padx=4, pady=2) + ttk.Label(plotf, text="Zielkantenlänge (px):").grid(row=0, column=2, sticky=tk.W, padx=4, pady=2) + tip(ttk.Entry(plotf, textvariable=self.v_plot_target_long_edge, width=10), + "Die lange Kante der erzeugten Plots wird grob auf diesen Wert skaliert.\n" + "Standard: 900 px." + ).grid(row=0, column=3, sticky=tk.W, padx=4, pady=2) + tip(ttk.Button(plotf, text="Jetzt berechnen", command=self._recalculate_plot_scale), + "Liest die Größe der lokalen Datei und setzt --plot-scale neu." + ).grid(row=0, column=4, sticky=tk.W, padx=4, pady=2) + ff = ttk.LabelFrame(parent, text="Schalter (Flags)", padding=6) ff.pack(fill=tk.X, pady=(0, 8)) @@ -685,6 +743,34 @@ class AstrometryGUI: " • Freier Speicherplatz" ).pack(side=tk.LEFT, padx=4, pady=4) + def _tab_results(self, parent): + head = ttk.Frame(parent) + head.pack(fill=tk.X, pady=(0, 4)) + + tip(ttk.Label(head, text="Remote-Ergebnisse nach solve-field:"), + "Zeigt den Inhalt des Remote-Verzeichnisses nach einem solve-field-Lauf an." + ).pack(side=tk.LEFT) + tip(ttk.Button(head, text="Aktualisieren", command=self._refresh_results_tab), + "Liest den Inhalt des aktuell eingestellten Remote-Verzeichnisses erneut vom Server ein." + ).pack(side=tk.RIGHT, padx=4) + tip(ttk.Button(head, text="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("", lambda _e: self._download_selected_result()) + # ----------------------------------------------------------------------- # Befehl bauen # ----------------------------------------------------------------------- @@ -754,6 +840,7 @@ class AstrometryGUI: add("--depth", self.v_depth) add("--cpulimit", self.v_cpulimit) + add("--plot-scale", self.v_plot_scale) add("--downsample", self.v_downsample) add("--odds-to-solve", self.v_odds_solve) add("--odds-to-reject", self.v_odds_reject) @@ -843,6 +930,163 @@ class AstrometryGUI: finally: self.root.after(0, lambda: self.upload_btn.config(state=tk.NORMAL)) + def _delete_remote_dir(self): + remote_dir = self.v_remote_dir.get().strip() + if not remote_dir: + messagebox.showwarning("Remote-Verzeichnis fehlt", "Bitte ein Remote-Verzeichnis angeben.") + return + if remote_dir == "/": + messagebox.showerror("Ung\u00fcltiges Verzeichnis", "Das Root-Verzeichnis '/' darf nicht gel\u00f6scht werden.") + return + if not messagebox.askyesno( + "Remote-Inhalt l\u00f6schen", + f"Soll nur der Inhalt dieses Verzeichnisses auf dem Server rekursiv gel\u00f6scht werden?\n\n{remote_dir}" + ): + return + self._submit(self._async_delete_remote_dir(remote_dir)) + + async def _async_delete_remote_dir(self, remote_dir: str): + quoted_dir = shlex.quote(remote_dir) + cmd = ( + f"dir={quoted_dir}; " + "if [ -z \"$dir\" ] || [ \"$dir\" = \"/\" ]; then " + "echo '[FEHLER] Unsicheres Remote-Verzeichnis.'; exit 1; " + "fi; " + "if [ ! -e \"$dir\" ]; then " + "echo '[INFO] Remote-Verzeichnis existiert nicht.'; exit 0; " + "fi; " + "find \"$dir\" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +; " + "echo \"[OK] Remote-Inhalt gel\u00f6scht: $dir\"" + ) + self._log(f"[INFO] L\u00f6sche Remote-Inhalt: {remote_dir}\n", "info") + try: + ssh = self._make_ssh() + await ssh.connect() + await ssh.exec_stream(cmd, self._log) + await ssh.close() + except Exception as exc: + self._log(f"[FEHLER] Remote-Inhalt l\u00f6schen: {exc}\n", "error") + + def _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.") @@ -855,10 +1099,14 @@ class AstrometryGUI: try: ssh = self._make_ssh() await ssh.connect() - await ssh.exec_stream(cmd, self._log) + rc = await ssh.exec_stream(cmd, self._log) await ssh.close() + self._current_task = None + 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: @@ -882,6 +1130,123 @@ class AstrometryGUI: if not current or (current.endswith(".fits") and current.startswith(out_dir)): self.v_new_fits.set(auto_path) + def _on_local_file_changed(self, *_): + if self.v_plot_scale_auto.get(): + self._recalculate_plot_scale() + + def _on_plot_scale_settings_changed(self, *_): + if self.v_plot_scale_auto.get(): + self._recalculate_plot_scale() + + def _recalculate_plot_scale(self): + path = self.v_local_file.get().strip() + if not path or not os.path.isfile(path): + return + try: + target = float(self.v_plot_target_long_edge.get().replace(",", ".")) + if target <= 0: + return + width, height = self._read_image_size(path) + long_edge = max(width, height) + if long_edge <= 0: + return + scale = target / long_edge + self.v_plot_scale.set(f"{scale:.3f}".rstrip("0").rstrip(".")) + except Exception: + return + + def _read_image_size(self, path: str) -> tuple[int, int]: + ext = os.path.splitext(path)[1].lower() + if ext in (".fits", ".fit", ".fts"): + return self._read_fits_size(path) + if ext == ".png": + return self._read_png_size(path) + if ext in (".jpg", ".jpeg"): + return self._read_jpeg_size(path) + if ext == ".gif": + return self._read_gif_size(path) + if ext == ".bmp": + return self._read_bmp_size(path) + raise ValueError("Nicht unterstütztes Bildformat") + + def _read_fits_size(self, path: str) -> tuple[int, int]: + width = height = None + with open(path, "rb") as f: + while True: + block = f.read(2880) + if not block: + break + for i in range(0, len(block), 80): + card = block[i:i + 80].decode("ascii", errors="ignore") + key = card[:8].strip() + if key == "END": + if width and height: + return width, height + break + if "=" not in card: + continue + value = card[10:30].split("/")[0].strip() + if key == "NAXIS1": + width = int(value) + elif key == "NAXIS2": + height = int(value) + raise ValueError("FITS-Bildgröße nicht lesbar") + + def _read_png_size(self, path: str) -> tuple[int, int]: + with open(path, "rb") as f: + header = f.read(24) + if header[:8] != b"\x89PNG\r\n\x1a\n": + raise ValueError("Ungültige PNG-Datei") + return struct.unpack(">II", header[16:24]) + + def _read_gif_size(self, path: str) -> tuple[int, int]: + with open(path, "rb") as f: + header = f.read(10) + if header[:6] not in (b"GIF87a", b"GIF89a"): + raise ValueError("Ungültige GIF-Datei") + return struct.unpack(" tuple[int, int]: + with open(path, "rb") as f: + header = f.read(26) + if header[:2] != b"BM": + raise ValueError("Ungültige BMP-Datei") + width, height = struct.unpack(" tuple[int, int]: + with open(path, "rb") as f: + if f.read(2) != b"\xff\xd8": + raise ValueError("Ungültige JPEG-Datei") + while True: + marker_prefix = f.read(1) + if not marker_prefix: + break + if marker_prefix != b"\xff": + continue + marker = f.read(1) + while marker == b"\xff": + marker = f.read(1) + if marker in (b"\xd8", b"\xd9"): + continue + size_bytes = f.read(2) + if len(size_bytes) != 2: + break + segment_size = struct.unpack(">H", size_bytes)[0] + if marker in ( + b"\xc0", b"\xc1", b"\xc2", b"\xc3", + b"\xc5", b"\xc6", b"\xc7", + b"\xc9", b"\xca", b"\xcb", + b"\xcd", b"\xce", b"\xcf", + ): + data = f.read(5) + if len(data) != 5: + break + height, width = struct.unpack(">HH", data[1:5]) + return width, height + f.seek(segment_size - 2, os.SEEK_CUR) + raise ValueError("JPEG-Bildgröße nicht lesbar") + def _browse_local(self): path = filedialog.askopenfilename( title="Astro-Bild auswählen",