Dies und das

This commit is contained in:
2026-06-28 19:48:42 +02:00
parent e94488def7
commit 58ab0d8925
2 changed files with 371 additions and 6 deletions
+371 -6
View File
@@ -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("<Double-1>", 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("<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",