Zurück zur Übersicht

Vlio Control.

Teleskop Control Programm für die Seestar Smart Teleskope

Python
#!/usr/bin/env python3

import csv
import math
import os
import platform
import re
import shutil
import subprocess
import sys
import tempfile
import threading
import time
import tkinter as tk
import urllib.parse
import urllib.request
import webbrowser
import zipfile
from datetime import datetime, timezone
from pathlib import Path
from tkinter import ttk, filedialog, messagebox, colorchooser

import numpy as np

try:
    from PIL import Image, ImageDraw, ImageFont, ImageTk, ImageColor
except ImportError:
    sys.exit("Pillow fehlt:  pip install pillow")

try:
    import cv2
except ImportError:
    cv2 = None

try:
    from seestarpy import stream as ss_stream
    from seestarpy import connection as ss_conn
    from seestarpy import auth as ss_auth
except ImportError:
    ss_stream = None
    ss_conn = None
    ss_auth = None


APP_TITLE = "Vlio Control"
VIEW_TITLE = "Vlio Live"


def find_key():
    """
    Sucht den RSA-Schluessel in derselben Reihenfolge wie seestarpy:
      1. Umgebungsvariable SEESTAR_KEY_PATH
      2. seestar.pem im aktuellen Verzeichnis
      3. ~/.seestarpy/seestar.pem
    Gibt (pfad, quelle) zurueck oder (None, None).
    """
    env = os.environ.get("SEESTAR_KEY_PATH")
    if env and Path(env).is_file():
        return Path(env), "SEESTAR_KEY_PATH"

    cwd = Path.cwd() / "seestar.pem"
    if cwd.is_file():
        return cwd, "Arbeitsverzeichnis"

    home = Path.home() / ".seestarpy" / "seestar.pem"
    if home.is_file():
        return home, "Benutzerordner"

    return None, None


def default_key_path():
    return Path.home() / ".seestarpy" / "seestar.pem"


def valid_ip(text):
    """Grobe Pruefung: vier Zahlenbloecke 0-255, nichts Halbfertiges."""
    parts = text.split(".")
    if len(parts) != 4:
        return False
    try:
        return all(0 <= int(p) <= 255 for p in parts)
    except ValueError:
        return False


def extract_key_from_apk(apk_path, target=None):
    """
    Ruft den Extraktor von seestarpy auf. Gibt (erfolg, ausgabe) zurueck.
    """
    cmd = [sys.executable, "-m", "seestarpy.extract_pem", str(apk_path)]
    if target:
        cmd += ["-o", str(target)]
    try:
        proc = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
    except FileNotFoundError:
        return False, "Python-Interpreter nicht gefunden."
    except subprocess.TimeoutExpired:
        return False, "Zeitueberschreitung beim Entpacken der APK."

    out = (proc.stdout or "") + (proc.stderr or "")
    return proc.returncode == 0, out.strip()


def _hms_to_deg(h, m):
    return (h + m / 60.0) * 15.0


def _dms_to_deg(sign, d, m):
    return sign * (d + m / 60.0)


def _parse_catalog_line(line):
    """Zeilenformat: NAME|NGC|Typ|hh mm.m|+dd mm|Eigenname"""
    parts = [p.strip() for p in line.split("|")]
    name, ngc, typ, ra_txt, dec_txt, common = (parts + [""] * 6)[:6]
    rh, rm = ra_txt.split()
    ra = _hms_to_deg(float(rh), float(rm))
    sign = -1.0 if dec_txt.strip().startswith("-") else 1.0
    dd, dm = dec_txt.replace("+", "").replace("-", "").split()
    dec = _dms_to_deg(sign, float(dd), float(dm))
    return {
        "name": name,
        "ngc": ngc if ngc and ngc != "-" else "",
        "type": typ,
        "common": common,
        "ra": ra,
        "dec": dec,
    }


_MESSIER_RAW = """
M1|1952|SNR|05 34.5|+22 01|Krebsnebel
M2|7089|GCl|21 33.5|-00 49|
M3|5272|GCl|13 42.2|+28 23|
M4|6121|GCl|16 23.6|-26 32|
M5|5904|GCl|15 18.6|+02 05|
M6|6405|OpCl|17 40.1|-32 13|Schmetterlingshaufen
M7|6475|OpCl|17 53.9|-34 49|
M8|6523|Neb|18 03.8|-24 23|Lagunennebel
M9|6333|GCl|17 19.2|-18 31|
M10|6254|GCl|16 57.1|-04 06|
M11|6705|OpCl|18 51.1|-06 16|Wildentenhaufen
M12|6218|GCl|16 47.2|-01 57|
M13|6205|GCl|16 41.7|+36 28|Herkuleshaufen
M14|6402|GCl|17 37.6|-03 15|
M15|7078|GCl|21 30.0|+12 10|
M16|6611|OpCl|18 18.8|-13 47|Adlernebel
M17|6618|Neb|18 20.8|-16 11|Omega-/Schwanennebel
M18|6613|OpCl|18 19.9|-17 08|
M19|6273|GCl|17 02.6|-26 16|
M20|6514|Neb|18 02.6|-23 02|Trifidnebel
M21|6531|OpCl|18 04.6|-22 30|
M22|6656|GCl|18 36.4|-23 54|
M23|6494|OpCl|17 56.8|-19 01|
M24|6603|MWP|18 16.9|-18 29|Kleine Sagittarius-Sternwolke
M25|4725|OpCl|18 31.6|-19 15|
M26|6694|OpCl|18 45.2|-09 24|
M27|6853|PNe|19 59.6|+22 43|Hantelnebel
M28|6626|GCl|18 24.5|-24 52|
M29|6913|OpCl|20 23.9|+38 32|
M30|7099|GCl|21 40.4|-23 11|
M31|224|SpG|00 42.7|+41 16|Andromedagalaxie
M32|221|ElG|00 42.7|+40 52|
M33|598|SpG|01 33.9|+30 39|Dreiecksgalaxie
M34|1039|OpCl|02 42.0|+42 47|
M35|2168|OpCl|06 08.9|+24 20|
M36|1960|OpCl|05 36.1|+34 08|
M37|2099|OpCl|05 52.4|+32 33|
M38|1922|OpCl|05 28.4|+35 50|
M39|7092|OpCl|21 32.2|+48 26|
M40|9999|Bin*|12 22.4|+58 05|
M41|2287|OpCl|06 46.0|-20 44|
M42|1976|Neb|05 35.4|-05 27|Orionnebel
M43|1982|Neb|05 35.6|-05 16|Orionnebel (Teil B)
M44|2632|OpCl|08 40.1|+19 59|Praesepe / Bienenkorbhaufen
M45|9998|OpCl|03 47.0|+24 07|Plejaden
M46|2437|OpCl|07 41.8|-14 49|
M47|2422|OpCl|07 36.6|-14 30|
M48|2548|OpCl|08 13.8|-05 48|
M49|4472|ElG|12 29.8|+08 00|
M50|2323|OpCl|07 03.2|-08 20|
M51|5194|SpG|13 29.9|+47 12|Whirlpoolgalaxie
M52|7654|OpCl|23 24.2|+61 35|
M53|5024|GCl|13 12.9|+18 10|
M54|6715|GCl|18 55.1|-30 29|
M55|6809|GCl|19 40.0|-30 58|
M56|6779|GCl|19 16.6|+30 11|
M57|6720|PNe|18 53.6|+33 02|Ringnebel
M58|4579|SpG|12 37.7|+11 49|
M59|4621|ElG|12 42.0|+11 39|
M60|4649|ElG|12 43.7|+11 33|
M61|4303|SpG|12 21.9|+04 28|
M62|6266|GCl|17 01.2|-30 07|
M63|5055|SpG|13 15.8|+42 02|Sonnenblumengalaxie
M64|4826|SpG|12 56.7|+21 41|Black-Eye-Galaxie
M65|3623|SpG|11 18.9|+13 05|
M66|3627|SpG|11 20.2|+12 59|
M67|2682|OpCl|08 50.4|+11 49|
M68|4590|GCl|12 39.5|-26 45|
M69|6637|GCl|18 31.4|-32 21|
M70|6681|GCl|18 43.2|-32 18|
M71|6838|GCl|19 53.8|+18 47|
M72|6981|GCl|20 53.5|-12 32|
M73|6994|Ast|20 58.9|-12 38|
M74|628|SpG|01 36.7|+15 47|
M75|6864|GCl|20 06.1|-21 55|
M76|650|PNe|01 42.4|+51 34|Kleiner Hantelnebel
M77|1068|SpG|02 42.7|-00 01|
M78|2068|Neb|05 46.7|+00 03|
M79|1904|GCl|05 24.5|-24 33|
M80|6093|GCl|16 17.0|-22 59|
M81|3031|SpG|09 55.6|+69 04|Bodes Galaxie
M82|3034|IrG|09 55.8|+69 41|Zigarrengalaxie
M83|5236|SpG|13 37.0|-29 52|
M84|4374|S0G|12 25.1|+12 53|
M85|4382|S0G|12 25.4|+18 11|
M86|4406|S0G|12 26.2|+12 57|
M87|4486|ElG|12 30.8|+12 24|Virgo A
M88|4501|SpG|12 32.0|+14 25|
M89|4552|ElG|12 35.7|+12 33|
M90|4569|SpG|12 36.8|+13 10|
M91|4548|SpG|12 35.4|+14 30|
M92|6341|GCl|17 17.1|+43 08|
M93|2447|OpCl|07 44.6|-23 52|
M94|4736|SpG|12 50.9|+41 07|
M95|3351|SpG|10 44.0|+11 42|
M96|3368|SpG|10 46.8|+11 49|
M97|3587|PNe|11 14.8|+55 01|Eulennebel
M98|4192|SpG|12 13.8|+14 54|
M99|4254|SpG|12 18.8|+14 25|
M100|4321|SpG|12 22.9|+15 49|
M101|5457|SpG|14 03.2|+54 21|Feuerradgalaxie
M102|5866|S0G|15 06.5|+55 46|
M103|581|OpCl|01 33.2|+60 42|
M104|4594|SpG|12 40.0|-11 37|Sombrerogalaxie
M105|3379|ElG|10 47.8|+12 35|
M106|4258|SpG|12 19.0|+47 18|
M107|6171|GCl|16 32.5|-13 03|
M108|3556|SpG|11 11.5|+55 40|
M109|3992|SpG|11 57.6|+53 23|
M110|205|ElG|00 40.4|+41 41|
"""

_EXTRA_RAW = """
NGC7000||Neb|20 58.8|+44 20|Nordamerikanebel
IC5070||Neb|20 50.8|+44 21|Pelikannebel
NGC6960||SNR|20 45.7|+30 43|Westlicher Schleiernebel
NGC6992||SNR|20 56.4|+31 43|Oestlicher Schleiernebel
NGC6888||Neb|20 12.0|+38 21|Halbmondnebel
IC1396||Neb|21 39.1|+57 30|Elefantenruesselnebel
NGC7635||Neb|23 20.7|+61 12|Blasennebel
NGC281||Neb|00 52.8|+56 37|Pacman-Nebel
IC1805||Neb|02 33.4|+61 26|Herznebel
IC1848||Neb|02 51.4|+60 25|Seelennebel
NGC7023||Neb|21 01.6|+68 10|Irisnebel
NGC1499||Neb|04 00.7|+36 37|Kaliforniennebel
NGC2237||Neb|06 32.3|+05 03|Rosettennebel
NGC2264||Neb|06 41.1|+09 53|Weihnachtsbaum-/Konusnebel
NGC7822||Neb|00 03.6|+67 09|
NGC6946||SpG|20 34.9|+60 09|Feuerwerksgalaxie
NGC891||SpG|02 22.6|+42 21|
NGC253||SpG|00 47.6|-25 17|Sculptor-Galaxie
NGC2403||SpG|07 36.9|+65 36|
NGC6543||PNe|17 58.6|+66 38|Katzenaugennebel
NGC40||PNe|00 13.0|+72 32|
NGC7009||PNe|21 04.2|-11 22|Saturnnebel
NGC2392||PNe|07 29.2|+20 55|Eskimonebel
NGC3242||PNe|10 24.8|-18 38|Jupiters Geist
NGC6826||PNe|19 44.8|+50 31|Blinkender Planetarischer Nebel
NGC869||OpCl|02 19.0|+57 09|Doppelsternhaufen h Persei
NGC884||OpCl|02 22.4|+57 07|Doppelsternhaufen chi Persei
NGC457||OpCl|01 19.6|+58 17|Eulenhaufen
IC434||Neb|05 41.0|-02 28|Pferdekopfnebel-Region
NGC2244||OpCl|06 31.9|+04 56|Rosettenhaufen
NGC6820||Neb|19 43.1|+23 18|
Sh2-155||Neb|22 56.8|+62 37|Hoehlennebel
"""


def _build_catalog():
    entries = []
    for raw in (_MESSIER_RAW, _EXTRA_RAW):
        for line in raw.strip().splitlines():
            line = line.strip()
            if not line:
                continue
            try:
                entries.append(_parse_catalog_line(line))
            except Exception:
                continue
    return entries


DSO_CATALOG = _build_catalog()


def angular_separation_deg(ra1, dec1, ra2, dec2):
    """Winkelabstand zweier Himmelspositionen (Grad) via Haversine."""
    r1, d1, r2, d2 = map(math.radians, (ra1, dec1, ra2, dec2))
    dra = r2 - r1
    dd = d2 - d1
    a = (math.sin(dd / 2) ** 2
         + math.cos(d1) * math.cos(d2) * math.sin(dra / 2) ** 2)
    return math.degrees(2 * math.asin(min(1.0, math.sqrt(max(a, 0.0)))))


def find_catalog_matches(ra_center, dec_center, fov_deg, limit=6):
    """Alle Katalogobjekte, deren Zentrum innerhalb des halben
    Bilddiagonalen-Radius liegt, sortiert nach Abstand vom Bildzentrum."""
    radius = fov_deg / 2.0 + 0.15
    hits = []
    for obj in DSO_CATALOG:
        sep = angular_separation_deg(ra_center, dec_center, obj["ra"], obj["dec"])
        if sep <= radius:
            hits.append((sep, obj))
    hits.sort(key=lambda t: t[0])
    return hits[:limit]


def _split_sexagesimal(value, round_to=1):
    """value in Grad (bzw. Stunden) -> (einheit, minute, sekunde), sauber
    gerundet mit Uebertrag (verhindert z.B. '60.0s')."""
    whole = int(value)
    frac_min = (value - whole) * 60.0
    minute = int(frac_min)
    sec = round((frac_min - minute) * 60.0, round_to)
    if sec >= 60.0:
        sec -= 60.0
        minute += 1
    if minute >= 60:
        minute -= 60
        whole += 1
    return whole, minute, sec


def deg_to_hms(ra_deg):
    hh, mm, ss = _split_sexagesimal(ra_deg / 15.0)
    return f"{hh:02d}h {mm:02d}m {ss:04.1f}s"


def deg_to_dms(dec_deg):
    sign = "+" if dec_deg >= 0 else "-"
    dd, mm, ss = _split_sexagesimal(abs(dec_deg))
    return f"{sign}{dd:02d}\u00b0 {mm:02d}' {ss:04.1f}\""


def guess_astap_path():
    """Ein paar uebliche Installationsorte fuer astap_cli/astap.exe
    (siehe https://www.hnsky.org/astap.htm)."""
    candidates = [
        r"C:\Program Files\astap\astap_cli.exe",
        r"C:\Program Files\astap\astap.exe",
        r"C:\astap\astap_cli.exe",
        str(Path.home() / ".astap_cli" / "astap_cli.exe"),
        str(Path.home() / ".astap_cli" / "astap_cli"),
        "/opt/astap/astap_cli",
        "/opt/astap/astap",
        "/usr/bin/astap_cli",
        "/usr/local/bin/astap_cli",
    ]
    for c in candidates:
        if Path(c).is_file():
            return c
    return ""


_ASTAP_CLI_URLS = {
    ("Windows", "64"): "https://sourceforge.net/projects/astap-program/files/windows_installer/astap_command-line_version_win64.zip/download",
    ("Windows", "32"): "https://sourceforge.net/projects/astap-program/files/windows_installer/astap_command-line_version_win32.zip/download",
    ("Linux", "64"): "https://sourceforge.net/projects/astap-program/files/linux_installer/astap_command-line_version_Linux_amd64.zip/download",
    ("Darwin", "arm64"): "https://sourceforge.net/projects/astap-program/files/macOS%20installer/astap_command-line_version_macOS_M1.zip/download",
    ("Darwin", "64"): "https://sourceforge.net/projects/astap-program/files/macOS%20installer/astap_command-line_version_macOS_x86_64.zip/download",
}


def _platform_key():
    system = platform.system()
    machine = platform.machine().lower()
    if system == "Darwin" and ("arm" in machine or machine == "arm64"):
        return (system, "arm64")
    bits = "64" if sys.maxsize > 2**32 else "32"
    return (system, bits)


def default_astap_dir():
    d = Path.home() / ".astap_cli"
    d.mkdir(parents=True, exist_ok=True)
    return d


def download_and_extract_astap(progress_cb=None):
    """Laedt NUR das astap_cli-Programm selbst herunter (ein paar MB, ein
    normales ZIP-Archiv - laesst sich sicher automatisiert entpacken) und
    legt es in ~/.astap_cli ab. Gibt den Pfad zur ausfuehrbaren Datei
    zurueck.

    WICHTIG: Der eigentliche Sternkatalog (die Datenbank, die ASTAP zum
    Loesen braucht, z.B. 'D50', mehrere hundert MB) wird bewusst NICHT
    automatisch heruntergeladen und ausgefuehrt. Der wird als Windows-
    Installer-.exe bzw. groessere Archive vertrieben, die nicht zuverlaessig
    "still" automatisierbar sind - ein Skript, das unbeaufsichtigt ein
    heruntergeladenes .exe ausfuehrt, ist ein Sicherheitsrisiko, das ich
    bewusst vermeide. Stattdessen wird die passende Downloadseite im
    Browser geoeffnet, du installierst den Katalog dort mit einem
    Doppelklick - einmalig, danach laeuft alles automatisch.
    """
    key = _platform_key()
    url = _ASTAP_CLI_URLS.get(key)
    if url is None:
        raise RuntimeError(
            f"Keine bekannte astap_cli-Downloadadresse fuer {key[0]} "
            f"({key[1]}-bit). Bitte manuell von www.hnsky.org/astap.htm laden.")

    if progress_cb:
        progress_cb("Lade astap_cli herunter ...")

    target_dir = default_astap_dir()
    tmp_zip = target_dir / "_astap_download.zip"
    req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
    with urllib.request.urlopen(req, timeout=60) as resp, open(tmp_zip, "wb") as f:
        shutil.copyfileobj(resp, f)

    if progress_cb:
        progress_cb("Entpacke astap_cli ...")

    exe_name = "astap_cli.exe" if key[0] == "Windows" else "astap_cli"
    extracted_path = None
    with zipfile.ZipFile(tmp_zip) as zf:
        for member in zf.namelist():
            if os.path.basename(member).lower() in (exe_name.lower(), "astap_cli"):
                zf.extract(member, target_dir)
                extracted_path = target_dir / member
                break
        if extracted_path is None:
            zf.extractall(target_dir)
            for p in target_dir.rglob("astap_cli*"):
                if p.is_file() and os.access(p, os.X_OK) or p.suffix == ".exe":
                    extracted_path = p
                    break

    tmp_zip.unlink(missing_ok=True)

    if extracted_path is None or not extracted_path.is_file():
        raise RuntimeError("Download war erfolgreich, aber astap_cli wurde "
                            "im Archiv nicht gefunden. Bitte manuell pruefen: "
                            + str(target_dir))

    final_path = target_dir / exe_name
    if extracted_path != final_path:
        shutil.move(str(extracted_path), str(final_path))

    if key[0] != "Windows":
        os.chmod(final_path, 0o755)

    if progress_cb:
        progress_cb("astap_cli eingerichtet.")

    return str(final_path)


def open_astap_database_page():
    """Oeffnet die offizielle Downloadseite fuer die Sternkatalog-
    Datenbanken im Standardbrowser. Das ist der Schritt, den du einmalig
    selbst per Doppelklick erledigen musst (siehe Docstring oben)."""
    webbrowser.open("https://www.hnsky.org/astap.htm#astap_databases")


_SIMBAD_TAP_URL = "https://simbad.cds.unistra.fr/simbad/sim-tap/sync"


def query_simbad_distances(ra_deg, dec_deg, radius_deg, limit=10, timeout=20):
    """ADQL-Abfrage bei SIMBAD: alle Objekte mit bekannter Parallaxe im
    angegebenen Radius um (ra_deg, dec_deg), sortiert nach Abstand vom
    Zentrum. Gibt eine Liste von Dicts zurueck (main_id, ra, dec,
    parallax_mas, distance_pc, distance_ly, sep_deg)."""
    adql = (
        "SELECT TOP {limit} main_id, ra, dec, plx_value, "
        "DISTANCE(POINT('ICRS', ra, dec), POINT('ICRS', {ra}, {dec})) AS sep "
        "FROM basic "
        "WHERE plx_value IS NOT NULL AND plx_value > 0 "
        "AND CONTAINS(POINT('ICRS', ra, dec), "
        "CIRCLE('ICRS', {ra}, {dec}, {radius})) = 1 "
        "ORDER BY sep ASC"
    ).format(limit=int(limit), ra=ra_deg, dec=dec_deg, radius=radius_deg)

    params = urllib.parse.urlencode({
        "request": "doQuery", "lang": "adql", "format": "csv", "query": adql,
    })
    req = urllib.request.Request(
        _SIMBAD_TAP_URL + "?" + params,
        headers={"User-Agent": "Mozilla/5.0"})

    with urllib.request.urlopen(req, timeout=timeout) as resp:
        text = resp.read().decode("utf-8", errors="ignore")

    rows = list(csv.DictReader(text.splitlines()))
    out = []
    for row in rows:
        try:
            plx = float(row["plx_value"])
            if plx <= 0:
                continue
            dist_pc = 1000.0 / plx
            out.append({
                "name": row["main_id"],
                "ra": float(row["ra"]),
                "dec": float(row["dec"]),
                "parallax_mas": plx,
                "distance_pc": dist_pc,
                "distance_ly": dist_pc * 3.26156,
                "sep_deg": float(row["sep"]),
            })
        except (KeyError, ValueError):
            continue
    return out


def recenter_star(luminance, x, y, search=6):
    """Sucht das lokale Helligkeitsmaximum in einem +-search Fenster um
    (x, y), um kleine Nachfuehr-/Zentrierfehler zwischen Aufnahmen
    auszugleichen. Gibt (x, y) als Float zurueck."""
    h, w = luminance.shape
    x0, x1 = max(0, int(x - search)), min(w, int(x + search) + 1)
    y0, y1 = max(0, int(y - search)), min(h, int(y + search) + 1)
    if x1 <= x0 or y1 <= y0:
        return float(x), float(y)
    patch = luminance[y0:y1, x0:x1]
    iy, ix = np.unravel_index(np.argmax(patch), patch.shape)
    return float(x0 + ix), float(y0 + iy)


def aperture_photometry(arr, x, y, r_ap=4.0, r_in=7.0, r_out=10.0, search=6):
    """Liefert (flux, cx, cy) - Gesamtfluss in der Apertur nach Abzug des
    lokalen Himmelshintergrunds (Median im Ring zwischen r_in und r_out),
    sowie die nachzentrierte Sternposition."""
    lum = arr.mean(axis=2) if arr.ndim == 3 else arr
    cx, cy = recenter_star(lum, x, y, search=search)

    h, w = lum.shape
    pad = int(r_out) + 2
    x0, x1 = max(0, int(cx - pad)), min(w, int(cx + pad) + 1)
    y0, y1 = max(0, int(cy - pad)), min(h, int(cy + pad) + 1)
    if x1 <= x0 or y1 <= y0:
        return 0.0, cx, cy

    yy, xx = np.mgrid[y0:y1, x0:x1]
    rr = np.sqrt((xx - cx) ** 2 + (yy - cy) ** 2)
    patch = lum[y0:y1, x0:x1]

    ap_mask = rr <= r_ap
    bg_mask = (rr >= r_in) & (rr <= r_out)

    if not np.any(ap_mask):
        return 0.0, cx, cy
    bg_level = float(np.median(patch[bg_mask])) if np.any(bg_mask) else 0.0
    flux = float(np.sum(patch[ap_mask] - bg_level))
    return max(flux, 1e-9), cx, cy


def format_mpc80_line(designation, dt_utc, ra_deg, dec_deg,
                      mag=None, band="V", note1=" ", note2="C", obs_code=""):
    desig = (designation or "")[:7].ljust(7)
    field1_5 = "     "
    field6_12 = desig
    discovery = " "

    day_frac = (dt_utc.day + dt_utc.hour / 24.0 + dt_utc.minute / 1440.0
                + dt_utc.second / 86400.0)
    date_str = f"{dt_utc.year:04d} {dt_utc.month:02d} {day_frac:08.5f}"
    date_field = date_str.ljust(17)[:17]

    ra_h, ra_m, ra_s = _split_sexagesimal(ra_deg / 15.0, round_to=2)
    ra_str = f"{ra_h:02d} {ra_m:02d} {ra_s:05.2f}"
    ra_field = ra_str.ljust(12)[:12]

    sign = "+" if dec_deg >= 0 else "-"
    dd, dm, ds = _split_sexagesimal(abs(dec_deg), round_to=1)
    dec_str = f"{sign}{dd:02d} {dm:02d} {ds:04.1f}"
    dec_field = dec_str.ljust(12)[:12]

    blank_57_65 = " " * 9
    if mag is not None:
        mag_field = f"{mag:5.2f}{band[:1]}"
    else:
        mag_field = " " * 6
    blank_72_77 = " " * 6
    obs_field = (obs_code or "")[:3].ljust(3)

    line = (field1_5 + field6_12 + discovery + note1[:1] + note2[:1]
            + date_field + ra_field + dec_field + blank_57_65
            + mag_field + blank_72_77 + obs_field)
    line = line[:80].ljust(80)
    assert len(line) == 80
    return line


def parse_wcs_result(path):
    """Liest die von ASTAP erzeugte .wcs/.ini Ergebnisdatei (FITS-Header-
    aehnliches Textformat: SCHLUESSEL = Wert / Kommentar). Liefert alle
    Parameter, die fuer eine echte Pixel<->Himmelskoordinaten-Umrechnung
    noetig sind (nicht nur das Bildzentrum)."""
    text = Path(path).read_text(errors="ignore")
    vals = {}
    for line in text.splitlines():
        m = re.match(r"\s*([A-Z0-9_-]+)\s*=\s*'?([^/'\n]+)'?", line)
        if m:
            key = m.group(1).strip()
            val = m.group(2).strip()
            vals[key] = val
    if vals.get("PLTSOLVD", "").upper().startswith("F"):
        return None
    try:
        crval1 = float(vals["CRVAL1"])
        crval2 = float(vals["CRVAL2"])
        naxis1 = float(vals.get("NAXIS1", 0))
        naxis2 = float(vals.get("NAXIS2", 0))
        crpix1 = float(vals.get("CRPIX1", naxis1 / 2.0))
        crpix2 = float(vals.get("CRPIX2", naxis2 / 2.0))

        if all(k in vals for k in ("CD1_1", "CD1_2", "CD2_1", "CD2_2")):
            cd = (float(vals["CD1_1"]), float(vals["CD1_2"]),
                  float(vals["CD2_1"]), float(vals["CD2_2"]))
        else:
            cdelt1 = float(vals.get("CDELT1", -abs(float(vals.get("CDELT2", 0.0)))))
            cdelt2 = float(vals.get("CDELT2", abs(cdelt1)))
            crota2 = math.radians(float(vals.get("CROTA2", 0.0)))
            cd = (cdelt1 * math.cos(crota2), -cdelt2 * math.sin(crota2),
                  cdelt1 * math.sin(crota2), cdelt2 * math.cos(crota2))

        return {
            "ra": crval1, "dec": crval2,
            "crpix1": crpix1, "crpix2": crpix2,
            "cd": cd,
            "cdelt": abs(float(vals.get("CDELT2", vals.get("CDELT1", cd[3])))),
            "naxis1": naxis1, "naxis2": naxis2,
        }
    except (KeyError, ValueError):
        return None


def wcs_pixel_to_radec(x, y, wcs):
    """Standard-Tangentialebenen- (TAN-) Deprojektion nach Calabretta &
    Greisen (2002). x/y in Pixelkoordinaten (1-indexiert wie FITS/CRPIX),
    liefert (ra_deg, dec_deg)."""
    cd1_1, cd1_2, cd2_1, cd2_2 = wcs["cd"]
    dx = x - wcs["crpix1"]
    dy = y - wcs["crpix2"]
    xi = math.radians(cd1_1 * dx + cd1_2 * dy)
    eta = math.radians(cd2_1 * dx + cd2_2 * dy)

    ra0 = math.radians(wcs["ra"])
    dec0 = math.radians(wcs["dec"])

    ra = ra0 + math.atan2(xi, math.cos(dec0) - eta * math.sin(dec0))
    dec = math.atan2(eta * math.cos(dec0) + math.sin(dec0),
                     math.sqrt((math.cos(dec0) - eta * math.sin(dec0)) ** 2 + xi ** 2))
    return math.degrees(ra) % 360.0, math.degrees(dec)


def wcs_radec_to_pixel(ra_deg, dec_deg, wcs):
    """Umkehrung von wcs_pixel_to_radec (gnomonische Vorwaertsprojektion),
    liefert Pixelkoordinaten (x, y)."""
    ra0 = math.radians(wcs["ra"])
    dec0 = math.radians(wcs["dec"])
    ra = math.radians(ra_deg)
    dec = math.radians(dec_deg)

    dra = ra - ra0
    denom = (math.sin(dec) * math.sin(dec0)
             + math.cos(dec) * math.cos(dec0) * math.cos(dra))
    if abs(denom) < 1e-12:
        raise ValueError("Punkt liegt ausserhalb der Tangentialebene (90 Grad entfernt).")
    xi = math.cos(dec) * math.sin(dra) / denom
    eta = (math.sin(dec) * math.cos(dec0)
           - math.cos(dec) * math.sin(dec0) * math.cos(dra)) / denom
    xi_deg, eta_deg = math.degrees(xi), math.degrees(eta)

    cd1_1, cd1_2, cd2_1, cd2_2 = wcs["cd"]
    det = cd1_1 * cd2_2 - cd1_2 * cd2_1
    if abs(det) < 1e-15:
        raise ValueError("Entartete WCS-Matrix.")
    dx = (cd2_2 * xi_deg - cd1_2 * eta_deg) / det
    dy = (cd1_1 * eta_deg - cd2_1 * xi_deg) / det
    return wcs["crpix1"] + dx, wcs["crpix2"] + dy


def position_angle_and_separation(ra1, dec1, ra2, dec2):
    """Klassischer Doppelstern-Positionswinkel (0 Grad = Nord, 90 Grad =
    Ost, wachsend im Uhrzeigersinn) und Winkelabstand in Bogensekunden."""
    r1, d1, r2, d2 = map(math.radians, (ra1, dec1, ra2, dec2))
    dra = r2 - r1
    pa = math.degrees(math.atan2(
        math.sin(dra) * math.cos(d2),
        math.cos(d1) * math.sin(d2) - math.sin(d1) * math.cos(d2) * math.cos(dra)))
    pa = pa % 360.0
    sep = angular_separation_deg(ra1, dec1, ra2, dec2) * 3600.0
    return pa, sep


def run_astap_solve(exe_path, image_path, search_radius=180, fov_deg=None,
                     downsample=2, timeout=120):
    """Ruft astap_cli fuer ein Einzelbild auf. Gibt bei Erfolg ein Dict mit
    ra/dec (Grad, Bildzentrum) und Pixelskala (Grad/Pixel) zurueck, sonst
    hebt eine Exception mit Klartext-Fehlermeldung."""
    if not exe_path or not Path(exe_path).is_file():
        raise RuntimeError(
            "ASTAP-Programm nicht gefunden. Bitte Pfad zu astap_cli(.exe) "
            "unter 'Objekterkennung' eintragen (ASTAP muss separat "
            "installiert werden, inkl. Sternkatalog-Datenbank: "
            "www.hnsky.org/astap.htm).")

    out_prefix = str(Path(image_path).with_suffix(""))
    cmd = [exe_path, "-f", str(image_path), "-r", str(search_radius),
           "-z", str(downsample), "-o", out_prefix, "-wcs"]
    if fov_deg and fov_deg > 0:
        cmd += ["-fov", str(fov_deg)]

    try:
        proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
    except FileNotFoundError:
        raise RuntimeError("astap_cli laesst sich nicht starten (Pfad pruefen).")
    except subprocess.TimeoutExpired:
        raise RuntimeError("Plate-Solving hat zu lange gedauert (Timeout).")

    wcs_path = Path(out_prefix + ".wcs")
    if not wcs_path.is_file():
        wcs_path = Path(out_prefix + ".ini")
    if not wcs_path.is_file():
        out = (proc.stdout or "") + (proc.stderr or "")
        raise RuntimeError("Keine Loesung gefunden.\n" + out.strip()[-400:])

    result = parse_wcs_result(wcs_path)
    if result is None:
        raise RuntimeError("Keine Loesung gefunden (Sternfeld nicht erkannt). "
                            "Mehr Sterne/kontrastreicheres Bild oder groesseren "
                            "Suchradius versuchen.")
    return result


_COLOR_BUCKETS = [
    (-0.30, "blau-weiss (heiss, O/B-aehnlich)"),
    (-0.05, "weiss (A-aehnlich)"),
    (0.15, "gelb-weiss (F-aehnlich)"),
    (0.35, "gelb (G-aehnlich, sonnenaehnlich)"),
    (0.60, "orange (K-aehnlich)"),
    (999.0, "rot (M-aehnlich, kuehl)"),
]


def _classify_color_index(ci):
    for threshold, label in _COLOR_BUCKETS:
        if ci <= threshold:
            return label
    return _COLOR_BUCKETS[-1][1]


def detect_bright_stars(rgb_or_gray, max_stars=15, min_separation=12):
    """Simple, abhaengigkeitsarme Sternerkennung: sucht lokale Helligkeits-
    maxima oberhalb eines robusten Schwellwerts. Kein astrometrisches
    Sternprofil-Fitting, nur eine praktische Naeherung fuer die Uebersicht."""
    if rgb_or_gray.ndim == 3:
        lum = rgb_or_gray.mean(axis=2)
    else:
        lum = rgb_or_gray

    h, w = lum.shape
    sample = lum[::2, ::2]
    med = float(np.median(sample))
    mad = float(np.median(np.abs(sample - med))) * 1.4826 or 1e-4
    thresh = med + 6.0 * mad

    ys, xs = np.where(lum > thresh)
    if len(xs) == 0:
        return []

    order = np.argsort(-lum[ys, xs])
    ys, xs = ys[order], xs[order]

    picked = []
    for y, x in zip(ys, xs):
        if len(picked) >= max_stars:
            break
        too_close = False
        for py, px in picked:
            if (py - y) ** 2 + (px - x) ** 2 < min_separation ** 2:
                too_close = True
                break
        if not too_close:
            picked.append((y, x))
    return picked


def analyze_star_colors(rgb_arr, max_stars=15):
    """Liefert pro erkanntem hellen Stern Position + grobe Farbklasse.
    Braucht ein Farbbild (RGB); bei Graustufen wird nur die Position
    zurueckgegeben (keine Farbschaetzung moeglich)."""
    is_color = rgb_arr.ndim == 3 and rgb_arr.shape[2] >= 3
    stars = detect_bright_stars(rgb_arr, max_stars=max_stars)

    h, w = rgb_arr.shape[:2]
    out = []
    for (y, x) in stars:
        y0, y1 = max(0, y - 2), min(h, y + 3)
        x0, x1 = max(0, x - 2), min(w, x + 3)
        if is_color:
            patch = rgb_arr[y0:y1, x0:x1, :3].reshape(-1, 3)
            r, g, b = patch.mean(axis=0)
            total = max(r + g + b, 1e-6)
            ci = (b - r) / total * -2.5
            label = _classify_color_index(ci)
        else:
            ci = None
            label = "keine Farbdaten (Graustufenbild)"
        out.append({"x": int(x), "y": int(y), "color_index": ci, "label": label})
    return out


def mtf(m, x):
    """Midtone Transfer Function. x und m im Bereich 0..1."""
    x = np.clip(x, 0.0, 1.0)
    denom = (2.0 * m - 1.0) * x - m
    out = np.zeros_like(x)
    np.divide((m - 1.0) * x, denom, out=out, where=(denom != 0))
    return np.clip(out, 0.0, 1.0)


def autostretch(arr, target_bg=0.25, shadow_sigma=2.8):
    """
    Screen-Transfer-Function wie in PixInsight/Siril.
    arr: float array 0..1, Form (h, w) oder (h, w, 3).
    """
    if arr.ndim == 3:
        lum = arr.mean(axis=2)
    else:
        lum = arr

    sample = lum[::4, ::4]
    med = float(np.median(sample))
    mad = float(np.median(np.abs(sample - med))) * 1.4826
    if mad <= 0:
        mad = 1e-4

    black = float(np.clip(med - shadow_sigma * mad, 0.0, 1.0))
    span = max(1.0 - black, 1e-6)
    mid = float(np.clip((med - black) / span, 1e-4, 0.9999))
    m = mtf(target_bg, np.array([mid]))[0]
    m = float(np.clip(m, 1e-4, 0.9999))

    shifted = np.clip((arr - black) / span, 0.0, 1.0)
    return mtf(m, shifted), black, m


def manual_stretch(arr, black, midtone):
    span = max(1.0 - black, 1e-6)
    shifted = np.clip((arr - black) / span, 0.0, 1.0)
    return mtf(max(min(midtone, 0.9999), 1e-4), shifted)


def to_pil(arr_float01):
    """float 0..1 -> PIL Image (RGB oder L)."""
    eight = (np.clip(arr_float01, 0.0, 1.0) * 255.0 + 0.5).astype(np.uint8)
    if eight.ndim == 2:
        return Image.fromarray(eight, mode="L").convert("RGB")
    return Image.fromarray(eight[:, :, :3], mode="RGB")


class SourceBase(threading.Thread):
    def __init__(self):
        super().__init__(daemon=True)
        self._stop_flag = threading.Event()
        self._lock = threading.Lock()
        self._frame = None
        self._meta = {}
        self.status = "gestoppt"
        self.error = None

    def stop(self):
        self._stop_flag.set()

    def latest(self):
        with self._lock:
            return self._frame, dict(self._meta)

    def _publish(self, frame, meta):
        with self._lock:
            self._frame = frame
            self._meta = meta


class RTSPSource(SourceBase):
    """Echtes Video vom eingebauten RTSP-Server."""

    def __init__(self, url):
        super().__init__()
        self.url = url

    def run(self):
        if cv2 is None:
            self.error = "OpenCV fehlt (pip install opencv-python)"
            self.status = "Fehler"
            return

        os.environ["OPENCV_FFMPEG_CAPTURE_OPTIONS"] = (
            "rtsp_transport;tcp|stimeout;5000000|max_delay;500000"
        )
        misses = 0
        cap = None
        frames = 0
        t0 = time.time()
        fps = 0.0

        while not self._stop_flag.is_set():
            if cap is None or not cap.isOpened():
                self.status = "verbinde ..."
                cap = cv2.VideoCapture(self.url, cv2.CAP_FFMPEG)
                try:
                    cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)
                except Exception:
                    pass
                if not cap.isOpened():
                    self.status = "keine Verbindung"
                    time.sleep(2.0)
                    continue

            ok, bgr = cap.read()
            if not ok:
                misses += 1
                if misses > 25:
                    self.status = "Stream weg, neu verbinden"
                    cap.release()
                    cap = None
                    misses = 0
                    time.sleep(1.5)
                continue

            misses = 0
            frames += 1
            if frames % 20 == 0:
                now = time.time()
                fps = 20.0 / max(now - t0, 1e-6)
                t0 = now

            rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB).astype(np.float32) / 255.0
            h, w = rgb.shape[:2]
            self.status = "live"
            self._publish(rgb, {
                "quelle": "RTSP",
                "breite": w,
                "hoehe": h,
                "fps": fps,
            })

        if cap is not None:
            cap.release()
        self.status = "gestoppt"


class StackSource(SourceBase):
    """Pollt das aktuelle Stackbild ueber Port 4800."""

    def __init__(self, ip, interval=3.0, preview=False, wide=False):
        super().__init__()
        self.ip = ip
        self.interval = interval
        self.preview = preview
        self.wide = wide

    def run(self):
        if ss_stream is None:
            self.error = "seestarpy fehlt (pip install seestarpy)"
            self.status = "Fehler"
            return

        last_id = None
        while not self._stop_flag.is_set():
            try:
                kwargs = {}
                if self.ip:
                    kwargs["ip"] = self.ip
                if self.preview:
                    kwargs["method"] = "get_current_img"
                if self.wide:
                    wide_port = getattr(ss_stream, "IMAGE_PORT_WIDE", None)
                    if wide_port:
                        kwargs["port"] = wide_port

                self.status = "hole Bild ..."
                header, payload = ss_stream.get_live_image(**kwargs)
                pixels = ss_stream.decode_payload(payload, header)

                arr = np.asarray(pixels)
                if arr.dtype == np.uint16:
                    arr = arr.astype(np.float32) / 65535.0
                elif arr.dtype == np.uint8:
                    arr = arr.astype(np.float32) / 255.0
                else:
                    arr = arr.astype(np.float32)
                    if arr.max() > 1.5:
                        arr = arr / arr.max()

                img_type = header.get("img_type")
                self.status = "live"
                self._publish(arr, {
                    "quelle": "Stack" if img_type == 5 else "Vorschau",
                    "breite": header.get("width", arr.shape[1]),
                    "hoehe": header.get("height", arr.shape[0]),
                    "frame": header.get("image_id"),
                    "hfd": header.get("hfd"),
                })
                last_id = header.get("image_id")
                self.error = None

            except Exception as exc:
                txt = str(exc)
                if "No image-bearing frame" in txt:
                    self.error = ("Seestar liefert gerade kein Bild. "
                                  "Laeuft eine Deep-Sky-Aufnahme (Stacking)? "
                                  "Port 4800 sendet nur waehrend des Stackens.")
                elif isinstance(exc, (ConnectionRefusedError, TimeoutError)) \
                        or "timed out" in txt or "getaddrinfo" in txt:
                    self.error = (f"Keine Verbindung zu {self.ip or '?'}:4800. "
                                  "IP pruefen; evtl. blockiert die offizielle "
                                  "App den Port oder Firmware verlangt den "
                                  "RSA-Schluessel.")
                else:
                    self.error = txt
                self.status = "Fehler"

            waited = 0.0
            while waited < self.interval and not self._stop_flag.is_set():
                time.sleep(0.2)
                waited += 0.2

        self.status = "gestoppt"


class ViewWindow(tk.Toplevel):
    """Anzeigefenster mit Zoom (Mausrad) und Pan (Ziehen bei gedrueckter
    linker Maustaste). Doppelklick oder Taste '0' setzt den Zoom zurueck."""

    ZOOM_MIN = 1.0
    ZOOM_MAX = 12.0
    ZOOM_STEP = 1.25

    def __init__(self, master, app):
        super().__init__(master)
        self.app = app
        self.title(VIEW_TITLE)
        self.configure(bg="black")
        self.geometry("1280x720")
        self.minsize(320, 240)

        self.canvas = tk.Canvas(self, bg="black", highlightthickness=0, bd=0)
        self.canvas.pack(fill="both", expand=True)
        self._photo = None
        self._fullscreen = False
        self._last_pil = None

        self.zoom = 1.0
        self.center_u = 0.5
        self.center_v = 0.5
        self._drag_start = None
        self._drag_center0 = None
        self._pending_click_cb = None

        self._draw_x0 = self._draw_y0 = 0.0
        self._draw_scale = None
        self._draw_nw = self._draw_nh = 0
        self._draw_cw = self._draw_ch = 0

        self.bind("<F11>", self.toggle_fullscreen)
        self.bind("<Escape>", self.leave_fullscreen)
        self.bind("0", self.reset_zoom)
        self.protocol("WM_DELETE_WINDOW", self.hide)

        self.canvas.bind("<MouseWheel>", self._on_wheel)
        self.canvas.bind("<Button-4>", self._on_wheel_linux)
        self.canvas.bind("<Button-5>", self._on_wheel_linux)
        self.canvas.bind("<ButtonPress-1>", self._on_drag_start)
        self.canvas.bind("<B1-Motion>", self._on_drag_move)
        self.canvas.bind("<Double-Button-1>", self.reset_zoom)
        self.canvas.bind("<Configure>", lambda e: self._redraw())

    def toggle_fullscreen(self, _=None):
        self._fullscreen = not self._fullscreen
        self.attributes("-fullscreen", self._fullscreen)

    def leave_fullscreen(self, _=None):
        self._fullscreen = False
        self.attributes("-fullscreen", False)

    def hide(self):
        self.withdraw()

    def show(self):
        self.deiconify()
        self.lift()

    def request_click(self, callback):
        """Naechster Linksklick im Livebild ruft callback(img_x, img_y) mit
        Pixelkoordinaten im *angezeigten* (ggf. geflippten) Bild auf, statt
        zu schwenken. Fuer Sternmarkierung (Photometrie/Astrometrie)."""
        self._pending_click_cb = callback
        self.canvas.configure(cursor="crosshair")
        self.show()

    def cancel_click_request(self):
        self._pending_click_cb = None
        self.canvas.configure(cursor="")

    def canvas_to_image(self, cx, cy):
        if self._draw_scale is None:
            return None
        ox = self._draw_cw // 2 - self._draw_nw // 2
        oy = self._draw_ch // 2 - self._draw_nh // 2
        ix = (cx - ox) / self._draw_scale + self._draw_x0
        iy = (cy - oy) / self._draw_scale + self._draw_y0
        return ix, iy

    def reset_zoom(self, _=None):
        self.zoom = 1.0
        self.center_u = 0.5
        self.center_v = 0.5
        self._redraw()

    def _on_wheel(self, event):
        self._zoom_at(event.x, event.y, 1 if event.delta > 0 else -1)

    def _on_wheel_linux(self, event):
        self._zoom_at(event.x, event.y, 1 if event.num == 4 else -1)

    def _zoom_at(self, px, py, direction):
        if self._last_pil is None:
            return
        old_zoom = self.zoom
        if direction > 0:
            self.zoom = min(self.ZOOM_MAX, self.zoom * self.ZOOM_STEP)
        else:
            self.zoom = max(self.ZOOM_MIN, self.zoom / self.ZOOM_STEP)
        if self.zoom == old_zoom:
            return

        cw = max(self.canvas.winfo_width(), 1)
        ch = max(self.canvas.winfo_height(), 1)
        u_at_cursor = self.center_u + (px - cw / 2) / (cw * old_zoom)
        v_at_cursor = self.center_v + (py - ch / 2) / (ch * old_zoom)
        self.center_u = u_at_cursor - (px - cw / 2) / (cw * self.zoom)
        self.center_v = v_at_cursor - (py - ch / 2) / (ch * self.zoom)
        self._clamp_center()
        self._redraw()

    def _on_drag_start(self, event):
        if self._pending_click_cb is not None:
            pos = self.canvas_to_image(event.x, event.y)
            cb = self._pending_click_cb
            self._pending_click_cb = None
            self.canvas.configure(cursor="")
            if pos is not None:
                cb(pos[0], pos[1])
            return
        self._drag_start = (event.x, event.y)
        self._drag_center0 = (self.center_u, self.center_v)

    def _on_drag_move(self, event):
        if self._drag_start is None or self.zoom <= 1.0:
            return
        cw = max(self.canvas.winfo_width(), 1)
        ch = max(self.canvas.winfo_height(), 1)
        dx = event.x - self._drag_start[0]
        dy = event.y - self._drag_start[1]
        u0, v0 = self._drag_center0
        self.center_u = u0 - dx / (cw * self.zoom)
        self.center_v = v0 - dy / (ch * self.zoom)
        self._clamp_center()
        self._redraw()

    def _clamp_center(self):
        half = 0.5 / self.zoom
        self.center_u = min(1 - half, max(half, self.center_u))
        self.center_v = min(1 - half, max(half, self.center_v))
        if self.zoom <= 1.0:
            self.center_u = 0.5
            self.center_v = 0.5

    def draw(self, pil_img):
        self._last_pil = pil_img
        self._redraw()

    def _redraw(self):
        pil_img = self._last_pil
        if pil_img is None:
            return
        cw = max(self.canvas.winfo_width(), 1)
        ch = max(self.canvas.winfo_height(), 1)
        if cw < 10 or ch < 10:
            return

        iw, ih = pil_img.size

        if self.zoom > 1.0:
            crop_w = iw / self.zoom
            crop_h = ih / self.zoom
            cx, cy = self.center_u * iw, self.center_v * ih
            x0 = max(0, min(iw - crop_w, cx - crop_w / 2))
            y0 = max(0, min(ih - crop_h, cy - crop_h / 2))
            crop = pil_img.crop((int(x0), int(y0),
                                  int(x0 + crop_w), int(y0 + crop_h)))
            src_w, src_h = crop.size
        else:
            x0, y0 = 0.0, 0.0
            crop = pil_img
            src_w, src_h = iw, ih

        scale = min(cw / src_w, ch / src_h)
        nw, nh = max(int(src_w * scale), 1), max(int(src_h * scale), 1)
        resample = Image.BILINEAR if scale < 1 else Image.NEAREST
        shown = crop.resize((nw, nh), resample)

        self._draw_x0, self._draw_y0 = x0, y0
        self._draw_scale = scale
        self._draw_nw, self._draw_nh = nw, nh
        self._draw_cw, self._draw_ch = cw, ch

        self._photo = ImageTk.PhotoImage(shown)
        self.canvas.delete("all")
        self.canvas.create_image(cw // 2, ch // 2, image=self._photo)
        if self.zoom > 1.0:
            self.canvas.create_text(
                cw - 8, 8, anchor="ne", text=f"{self.zoom:.1f}x",
                fill="#cccccc", font=("Segoe UI", 11))

    def clear(self, text=""):
        self._last_pil = None
        self.canvas.delete("all")
        if text:
            cw = max(self.canvas.winfo_width(), 1)
            ch = max(self.canvas.winfo_height(), 1)
            self.canvas.create_text(cw // 2, ch // 2, text=text,
                                    fill="#555555", font=("Segoe UI", 16))


class LightCurveWindow(tk.Toplevel):
    """Schlanker, abhaengigkeitsfreier Lichtkurven-Plot auf einem Tkinter-
    Canvas (keine matplotlib-Abhaengigkeit noetig)."""

    def __init__(self, master, log):
        super().__init__(master)
        self.title("Lichtkurve (Delta-Magnitude)")
        self.geometry("640x420")
        self.configure(bg="white")
        self.canvas = tk.Canvas(self, bg="white", highlightthickness=0)
        self.canvas.pack(fill="both", expand=True)
        self.log = log
        self.canvas.bind("<Configure>", lambda e: self._draw())
        self._draw()

    def _draw(self):
        self.canvas.delete("all")
        w = max(self.canvas.winfo_width(), 100)
        h = max(self.canvas.winfo_height(), 100)
        margin = 50
        if len(self.log) < 2:
            return

        ts = [e["t"] for e in self.log]
        mags = [e["delta_mag"] for e in self.log]
        t0, t1 = min(ts), max(ts)
        m0, m1 = min(mags), max(mags)
        if t1 == t0:
            t1 = t0 + 1
        if m1 == m0:
            m0 -= 0.05
            m1 += 0.05
        pad_m = (m1 - m0) * 0.1
        m0 -= pad_m
        m1 += pad_m

        def px(t):
            return margin + (t - t0) / (t1 - t0) * (w - 2 * margin)

        def py(m):
            return margin + (m - m0) / (m1 - m0) * (h - 2 * margin)

        self.canvas.create_line(margin, h - margin, w - margin, h - margin, fill="#888")
        self.canvas.create_line(margin, margin, margin, h - margin, fill="#888")
        self.canvas.create_text(w // 2, h - margin // 2,
                                text="Zeit", fill="#333")
        self.canvas.create_text(margin // 2, margin, text="Delta-mag",
                                fill="#333", angle=90)

        for i, m in enumerate(np.linspace(m0, m1, 5)):
            y = py(m)
            self.canvas.create_line(margin - 4, y, margin, y, fill="#888")
            self.canvas.create_text(margin - 8, y, text=f"{m:+.2f}",
                                    anchor="e", fill="#333", font=("Segoe UI", 8))

        pts = [(px(e["t"]), py(e["delta_mag"])) for e in self.log]
        for i in range(len(pts) - 1):
            self.canvas.create_line(*pts[i], *pts[i + 1], fill="#2266cc", width=2)
        for x, y in pts:
            self.canvas.create_oval(x - 3, y - 3, x + 3, y + 3,
                                    fill="#2266cc", outline="")


class ControlApp(tk.Tk):
    def __init__(self):
        super().__init__()
        self.title(APP_TITLE)
        self.geometry("500x820")
        self.resizable(True, True)

        self.source = None
        self.view = ViewWindow(self, self)
        self._last_pil = None
        self._save_dir = None
        self._saved_ids = set()

        self._solve_lock = threading.Lock()
        self._solve_busy = False
        self._solve_result = None
        self._auto_solve_job = None

        self._star_report = []

        self._distance_busy = False

        self._phot_target = None
        self._phot_comp = None
        self._phot_log = []
        self._phot_auto_job = None

        self._ds_pt1 = None
        self._ds_pt2 = None
        self._ds_log = []

        self._ast_log = []

        self._ref_frame = None
        self._transient_candidates = []

        self._build_ui()
        self.protocol("WM_DELETE_WINDOW", self.on_close)
        self.after(50, self._tick)

    def _scroll_tab(self, notebook, title):
        """Erstellt einen neuen Reiter mit scrollbarem Inhalt (falls die
        Steuerelemente nicht auf einmal in die Fensterhoehe passen)."""
        outer = ttk.Frame(notebook)
        notebook.add(outer, text=title)
        canvas = tk.Canvas(outer, highlightthickness=0)
        vsb = ttk.Scrollbar(outer, orient="vertical", command=canvas.yview)
        inner = ttk.Frame(canvas)
        inner.bind("<Configure>",
                   lambda e: canvas.configure(scrollregion=canvas.bbox("all")))
        canvas.create_window((0, 0), window=inner, anchor="nw")
        canvas.configure(yscrollcommand=vsb.set)
        canvas.pack(side="left", fill="both", expand=True)
        vsb.pack(side="right", fill="y")

        def _wheel(event):
            canvas.yview_scroll(int(-1 * (event.delta / 120)), "units")

        canvas.bind("<Enter>", lambda e: canvas.bind_all("<MouseWheel>", _wheel))
        canvas.bind("<Leave>", lambda e: canvas.unbind_all("<MouseWheel>"))
        return inner

    def _build_ui(self):
        pad = {"padx": 10, "pady": 4}
        style = ttk.Style(self)
        try:
            style.theme_use("vista")
        except tk.TclError:
            pass

        nb = ttk.Notebook(self)
        nb.pack(fill="both", expand=True)
        t_conn = self._scroll_tab(nb, "Verbindung")
        t_view = self._scroll_tab(nb, "Darstellung")
        t_obj = self._scroll_tab(nb, "Objekte")
        t_dist = self._scroll_tab(nb, "Entfernungen")
        t_phot = self._scroll_tab(nb, "Photometrie")
        t_astro = self._scroll_tab(nb, "Astrometrie")
        t_trans = self._scroll_tab(nb, "Transienten")
        t_save = self._scroll_tab(nb, "Speichern")

        box = ttk.LabelFrame(t_conn, text="Verbindung")
        box.pack(fill="x", **pad)

        row = ttk.Frame(box)
        row.pack(fill="x", padx=8, pady=6)
        ttk.Label(row, text="IP:").pack(side="left")
        self.var_ip = tk.StringVar(value="192.168.1.")
        ttk.Entry(row, textvariable=self.var_ip, width=18).pack(side="left", padx=6)
        ttk.Button(row, text="Suchen", width=9,
                   command=self.discover).pack(side="left")

        box = ttk.LabelFrame(t_conn, text="Schluessel (noetig ab Firmware 7.18)")
        box.pack(fill="x", **pad)

        self.var_key = tk.StringVar(value="")
        ttk.Label(box, textvariable=self.var_key, wraplength=370,
                  justify="left").pack(anchor="w", padx=8, pady=4)
        r = ttk.Frame(box)
        r.pack(fill="x", padx=8, pady=4)
        ttk.Button(r, text="Aus APK holen", command=self.extract_key
                   ).pack(side="left", expand=True, fill="x", padx=2)
        ttk.Button(r, text="PEM waehlen", command=self.pick_key
                   ).pack(side="left", expand=True, fill="x", padx=2)
        ttk.Button(r, text="Neu pruefen", width=11, command=self.refresh_key
                   ).pack(side="left", padx=2)

        box = ttk.LabelFrame(t_conn, text="Bildquelle")
        box.pack(fill="x", **pad)

        self.var_mode = tk.StringVar(value="stack")
        ttk.Radiobutton(box, text="Live-Stack  (Deep Sky, Port 4800)",
                        variable=self.var_mode, value="stack",
                        command=self._mode_changed).pack(anchor="w", padx=8, pady=2)
        ttk.Radiobutton(box, text="RTSP-Sucher  (Mond, Planeten, Landschaft)",
                        variable=self.var_mode, value="rtsp",
                        command=self._mode_changed).pack(anchor="w", padx=8, pady=2)

        self.frm_stack = ttk.Frame(box)
        self.frm_stack.pack(fill="x", padx=20, pady=2)
        self.var_preview = tk.BooleanVar(value=False)
        ttk.Checkbutton(self.frm_stack, text="Live-Einzelbild (statt wachsendem Summenbild)",
                        variable=self.var_preview).pack(anchor="w")
        ttk.Label(self.frm_stack, foreground="#777777", wraplength=380, justify="left", text=
                  "Aus: du siehst das staendig waechsende Summenbild (Rauschen "
                  "sinkt ueber Zeit, aber alte Belichtungen bleiben drin).\n"
                  "An: du siehst jede einzelne Roh-Belichtung einzeln, so "
                  "wie sie gerade reinkommt - gut zum Fokussieren/Rahmen, "
                  "aber verrauschter."
                  ).pack(anchor="w", pady=(0, 4))
        self.var_wide = tk.BooleanVar(value=False)
        ttk.Checkbutton(self.frm_stack, text="Weitwinkelkamera",
                        variable=self.var_wide).pack(anchor="w")
        r = ttk.Frame(self.frm_stack)
        r.pack(fill="x", pady=2)
        ttk.Label(r, text="Abfrage alle").pack(side="left")
        self.var_interval = tk.DoubleVar(value=3.0)
        ttk.Spinbox(r, from_=1.0, to=60.0, increment=1.0, width=5,
                    textvariable=self.var_interval).pack(side="left", padx=4)
        ttk.Label(r, text="Sek.").pack(side="left")

        self.frm_rtsp = ttk.Frame(box)
        self.frm_rtsp.pack(fill="x", padx=20, pady=2)
        ttk.Label(self.frm_rtsp, text="RTSP-URL:").pack(anchor="w")
        self.var_url = tk.StringVar(value="")
        ttk.Entry(self.frm_rtsp, textvariable=self.var_url,
                  width=40).pack(fill="x", pady=2)
        ttk.Label(self.frm_rtsp, text="leer = rtsp://<IP>:4554/stream",
                  foreground="#777777").pack(anchor="w")

        r = ttk.Frame(t_conn)
        r.pack(fill="x", padx=10, pady=6)
        self.btn_start = ttk.Button(r, text="Start", command=self.start_source)
        self.btn_start.pack(side="left", expand=True, fill="x", padx=2)
        self.btn_stop = ttk.Button(r, text="Stop", command=self.stop_source,
                                   state="disabled")
        self.btn_stop.pack(side="left", expand=True, fill="x", padx=2)

        box = ttk.LabelFrame(t_view, text="Darstellung")
        box.pack(fill="x", **pad)

        self.var_auto = tk.BooleanVar(value=True)
        ttk.Checkbutton(box, text="Auto-Stretch", variable=self.var_auto,
                        command=self._auto_toggled).pack(anchor="w", padx=8, pady=2)

        self.var_black = tk.DoubleVar(value=0.0)
        self.var_mid = tk.DoubleVar(value=0.5)
        self.sc_black = self._slider(box, "Schwarzpunkt", self.var_black, 0.0, 0.9)
        self.sc_mid = self._slider(box, "Mitten", self.var_mid, 0.02, 0.98)

        self.var_gray = tk.BooleanVar(value=False)
        ttk.Checkbutton(box, text="Graustufen",
                        variable=self.var_gray).pack(anchor="w", padx=8)
        self.var_flip = tk.BooleanVar(value=False)
        ttk.Checkbutton(box, text="180 Grad drehen",
                        variable=self.var_flip).pack(anchor="w", padx=8)

        box = ttk.LabelFrame(t_view, text="Einblendung im Livebild")
        box.pack(fill="x", **pad)
        self.var_overlay = tk.BooleanVar(value=True)
        ttk.Checkbutton(box, text="Infozeile anzeigen", variable=self.var_overlay
                        ).pack(anchor="w", padx=8, pady=2)
        r = ttk.Frame(box)
        r.pack(fill="x", padx=8, pady=2)
        ttk.Label(r, text="Titel:").pack(side="left")
        self.var_caption = tk.StringVar(value="")
        ttk.Entry(r, textvariable=self.var_caption).pack(
            side="left", fill="x", expand=True, padx=4)

        grid = ttk.Frame(box)
        grid.pack(fill="x", padx=8, pady=(4, 2))
        self.var_show_title = tk.BooleanVar(value=True)
        self.var_show_time = tk.BooleanVar(value=True)
        self.var_show_date = tk.BooleanVar(value=False)
        self.var_show_source = tk.BooleanVar(value=True)
        self.var_show_res = tk.BooleanVar(value=True)
        self.var_show_frame = tk.BooleanVar(value=True)
        self.var_show_hfd = tk.BooleanVar(value=True)
        self.var_show_fps = tk.BooleanVar(value=True)
        self.var_show_object = tk.BooleanVar(value=True)
        toggles = [
            ("Titel", self.var_show_title), ("Uhrzeit", self.var_show_time),
            ("Datum", self.var_show_date), ("Quelle", self.var_show_source),
            ("Aufloesung", self.var_show_res), ("Frame-Nr.", self.var_show_frame),
            ("HFD", self.var_show_hfd), ("FPS", self.var_show_fps),
            ("Erkanntes Objekt", self.var_show_object),
        ]
        for i, (label, var) in enumerate(toggles):
            ttk.Checkbutton(grid, text=label, variable=var).grid(
                row=i // 2, column=i % 2, sticky="w", padx=2, pady=1)

        r = ttk.Frame(box)
        r.pack(fill="x", padx=8, pady=(6, 2))
        ttk.Label(r, text="Position:").pack(side="left")
        self.var_ov_pos = tk.StringVar(value="unten links")
        ttk.Combobox(r, textvariable=self.var_ov_pos, state="readonly", width=13,
                     values=["oben links", "oben rechts",
                             "unten links", "unten rechts"]
                     ).pack(side="left", padx=4)
        ttk.Label(r, text="Groesse:").pack(side="left", padx=(10, 0))
        self.var_ov_size = tk.IntVar(value=0)
        ttk.Spinbox(r, from_=0, to=72, increment=1, width=4,
                    textvariable=self.var_ov_size).pack(side="left", padx=4)
        ttk.Label(r, text="(0=auto)", foreground="#777777").pack(side="left")

        r = ttk.Frame(box)
        r.pack(fill="x", padx=8, pady=(2, 4))
        self.var_ov_color = tk.StringVar(value="#ffffff")
        ttk.Button(r, text="Textfarbe waehlen", command=self._pick_overlay_color
                   ).pack(side="left")
        self.lbl_ov_color = tk.Label(r, text="  ", bg=self.var_ov_color.get(),
                                     width=3, relief="sunken")
        self.lbl_ov_color.pack(side="left", padx=6)

        box = ttk.LabelFrame(t_obj, text="Objekterkennung (ASTAP Plate-Solving)")
        box.pack(fill="x", **pad)
        r = ttk.Frame(box)
        r.pack(fill="x", padx=8, pady=4)
        ttk.Label(r, text="astap_cli:").pack(side="left")
        self.var_astap_path = tk.StringVar(value=guess_astap_path())
        ttk.Entry(r, textvariable=self.var_astap_path).pack(
            side="left", fill="x", expand=True, padx=4)
        ttk.Button(r, text="...", width=3, command=self._pick_astap
                   ).pack(side="left")

        r = ttk.Frame(box)
        r.pack(fill="x", padx=8, pady=(0, 4))
        ttk.Button(r, text="ASTAP automatisch einrichten",
                   command=self.setup_astap).pack(fill="x")

        r = ttk.Frame(box)
        r.pack(fill="x", padx=8, pady=2)
        ttk.Label(r, text="Bildfeld (Bogenmin, optional):").pack(side="left")
        self.var_fov = tk.StringVar(value="")
        ttk.Entry(r, textvariable=self.var_fov, width=8).pack(side="left", padx=4)
        ttk.Label(r, text="Suchradius:").pack(side="left", padx=(10, 0))
        self.var_search_radius = tk.IntVar(value=180)
        ttk.Spinbox(r, from_=5, to=180, width=5,
                    textvariable=self.var_search_radius).pack(side="left", padx=4)

        r = ttk.Frame(box)
        r.pack(fill="x", padx=8, pady=2)
        ttk.Button(r, text="Jetzt loesen", command=self.solve_now
                   ).pack(side="left", expand=True, fill="x", padx=2)
        self.var_auto_solve = tk.BooleanVar(value=False)
        ttk.Checkbutton(r, text="automatisch alle", variable=self.var_auto_solve,
                        command=self._auto_solve_toggled).pack(side="left", padx=(8, 2))
        self.var_solve_interval = tk.IntVar(value=60)
        ttk.Spinbox(r, from_=15, to=600, width=5,
                    textvariable=self.var_solve_interval).pack(side="left")
        ttk.Label(r, text="Sek.").pack(side="left", padx=2)

        self.var_solve_status = tk.StringVar(value="noch nicht geloest")
        ttk.Label(box, textvariable=self.var_solve_status, foreground="#333333",
                  wraplength=390, justify="left").pack(anchor="w", padx=8, pady=(2, 6))

        box = ttk.LabelFrame(t_obj, text="Sternanalyse (grobe Naeherung, keine Spektroskopie)")
        box.pack(fill="x", **pad)
        r = ttk.Frame(box)
        r.pack(fill="x", padx=8, pady=4)
        ttk.Button(r, text="Sterne analysieren", command=self.analyze_stars_now
                   ).pack(side="left", expand=True, fill="x", padx=2)
        self.var_mark_stars = tk.BooleanVar(value=False)
        ttk.Checkbutton(r, text="im Bild markieren",
                        variable=self.var_mark_stars).pack(side="left", padx=6)

        self.txt_stars = tk.Text(box, height=6, wrap="word", font=("Consolas", 9))
        self.txt_stars.pack(fill="x", padx=8, pady=(0, 6))
        self.txt_stars.insert("1.0", "noch keine Analyse durchgefuehrt")
        self.txt_stars.configure(state="disabled")

        box = ttk.LabelFrame(t_dist, text="Sternentfernungen (SIMBAD/Gaia-Katalog)")
        box.pack(fill="x", **pad)
        ttk.Label(box, wraplength=390, justify="left", foreground="#555555", text=
                  "Keine Eigenmessung - fragt bekannte Parallaxen (Gaia) bei "
                  "SIMBAD ab, im Umkreis um die zuletzt geloeste Bildmitte. "
                  "Braucht Internet und einen vorherigen Plate-Solve (Tab "
                  "'Objekte')."
                  ).pack(anchor="w", padx=8, pady=(4, 6))
        r = ttk.Frame(box)
        r.pack(fill="x", padx=8, pady=2)
        ttk.Label(r, text="Suchradius (Bogenmin):").pack(side="left")
        self.var_dist_radius = tk.DoubleVar(value=15.0)
        ttk.Spinbox(r, from_=1, to=120, width=6,
                    textvariable=self.var_dist_radius).pack(side="left", padx=4)
        r = ttk.Frame(box)
        r.pack(fill="x", padx=8, pady=4)
        ttk.Button(r, text="Entfernungen abrufen", command=self.fetch_distances
                   ).pack(fill="x")

        self.txt_dist = tk.Text(box, height=10, wrap="word", font=("Consolas", 9))
        self.txt_dist.pack(fill="x", padx=8, pady=(0, 6))
        self.txt_dist.insert("1.0", "noch keine Abfrage durchgefuehrt")
        self.txt_dist.configure(state="disabled")

        box = ttk.LabelFrame(t_phot, text="Helligkeitskurve (differenzielle Photometrie)")
        box.pack(fill="x", **pad)
        ttk.Label(box, wraplength=390, justify="left", foreground="#555555", text=
                  "Zielstern gegen einen (moeglichst aehnlich hellen, "
                  "aehnlich farbigen) Vergleichsstern messen. Funktioniert "
                  "auf dem ROHEN Bild (vor Stretch), also unabhaengig von "
                  "der Anzeige-Helligkeit."
                  ).pack(anchor="w", padx=8, pady=(4, 6))

        r = ttk.Frame(box)
        r.pack(fill="x", padx=8, pady=2)
        ttk.Button(r, text="Zielstern markieren",
                   command=lambda: self._start_marking("phot_target")
                   ).pack(side="left", expand=True, fill="x", padx=2)
        ttk.Button(r, text="Vergleichsstern markieren",
                   command=lambda: self._start_marking("phot_comp")
                   ).pack(side="left", expand=True, fill="x", padx=2)

        self.var_phot_marks = tk.StringVar(value="Ziel: - | Vergleich: -")
        ttk.Label(box, textvariable=self.var_phot_marks,
                  foreground="#333333").pack(anchor="w", padx=8, pady=2)

        r = ttk.Frame(box)
        r.pack(fill="x", padx=8, pady=2)
        ttk.Label(r, text="Apertur:").pack(side="left")
        self.var_ap_r = tk.DoubleVar(value=4.0)
        ttk.Spinbox(r, from_=2, to=15, width=4,
                    textvariable=self.var_ap_r).pack(side="left", padx=4)
        ttk.Label(r, text="Ring innen/aussen:").pack(side="left", padx=(10, 0))
        self.var_ap_rin = tk.DoubleVar(value=7.0)
        ttk.Spinbox(r, from_=3, to=25, width=4,
                    textvariable=self.var_ap_rin).pack(side="left", padx=4)
        self.var_ap_rout = tk.DoubleVar(value=10.0)
        ttk.Spinbox(r, from_=4, to=35, width=4,
                    textvariable=self.var_ap_rout).pack(side="left", padx=2)

        r = ttk.Frame(box)
        r.pack(fill="x", padx=8, pady=4)
        ttk.Button(r, text="Messpunkt aufnehmen", command=self.take_photometry_point
                   ).pack(side="left", expand=True, fill="x", padx=2)
        self.var_phot_auto = tk.BooleanVar(value=False)
        ttk.Checkbutton(r, text="automatisch alle", variable=self.var_phot_auto,
                        command=self._phot_auto_toggled).pack(side="left", padx=(8, 2))
        self.var_phot_interval = tk.IntVar(value=60)
        ttk.Spinbox(r, from_=10, to=3600, width=6,
                    textvariable=self.var_phot_interval).pack(side="left")
        ttk.Label(r, text="Sek.").pack(side="left", padx=2)

        r = ttk.Frame(box)
        r.pack(fill="x", padx=8, pady=(0, 4))
        ttk.Button(r, text="Lichtkurve anzeigen", command=self.show_light_curve
                   ).pack(side="left", expand=True, fill="x", padx=2)
        ttk.Button(r, text="CSV exportieren", command=self.export_photometry_csv
                   ).pack(side="left", expand=True, fill="x", padx=2)

        self.txt_phot = tk.Text(box, height=8, wrap="none", font=("Consolas", 9))
        self.txt_phot.pack(fill="x", padx=8, pady=(0, 6))
        self.txt_phot.insert("1.0", "noch keine Messpunkte")
        self.txt_phot.configure(state="disabled")

        box = ttk.LabelFrame(t_astro, text="Doppelstern (Positionswinkel & Abstand)")
        box.pack(fill="x", **pad)
        ttk.Label(box, wraplength=390, justify="left", foreground="#555555", text=
                  "Braucht einen aktuellen Plate-Solve (Tab 'Objekte'), "
                  "sonst kann kein echter Himmelswinkel berechnet werden."
                  ).pack(anchor="w", padx=8, pady=(4, 4))
        r = ttk.Frame(box)
        r.pack(fill="x", padx=8, pady=2)
        ttk.Button(r, text="Stern A markieren",
                   command=lambda: self._start_marking("ds_1")
                   ).pack(side="left", expand=True, fill="x", padx=2)
        ttk.Button(r, text="Stern B markieren",
                   command=lambda: self._start_marking("ds_2")
                   ).pack(side="left", expand=True, fill="x", padx=2)
        r = ttk.Frame(box)
        r.pack(fill="x", padx=8, pady=2)
        ttk.Label(r, text="Paar-Bezeichnung:").pack(side="left")
        self.var_ds_name = tk.StringVar(value="")
        ttk.Entry(r, textvariable=self.var_ds_name, width=16).pack(side="left", padx=4)
        ttk.Button(box, text="PA/Abstand berechnen", command=self.compute_double_star
                   ).pack(fill="x", padx=8, pady=4)
        self.var_ds_result = tk.StringVar(value="A: - | B: -")
        ttk.Label(box, textvariable=self.var_ds_result, wraplength=390,
                  justify="left").pack(anchor="w", padx=8, pady=(0, 4))
        ttk.Button(box, text="Messreihe als CSV exportieren",
                   command=self.export_double_star_csv).pack(fill="x", padx=8, pady=(0, 6))

        box = ttk.LabelFrame(t_astro, text="Asteroid / Komet (Positions-Astrometrie)")
        box.pack(fill="x", **pad)
        ttk.Label(box, wraplength=390, justify="left", foreground="#555555", text=
                  "Erzeugt korrekt spaltenformatierte MPC-80-Zeilen. Fuer "
                  "eine ECHTE Meldung ans Minor Planet Center brauchst du "
                  "zusaetzlich einen zugewiesenen Beobachtercode (erst nach "
                  "Kontakt mit dem MPC) und musst das Format vor dem "
                  "Absenden selbst gegenchecken - siehe 'Guide to Minor "
                  "Body Astrometry'."
                  ).pack(anchor="w", padx=8, pady=(4, 4))
        r = ttk.Frame(box)
        r.pack(fill="x", padx=8, pady=2)
        ttk.Label(r, text="Bezeichnung (max 7 Zeichen):").pack(side="left")
        self.var_ast_desig = tk.StringVar(value="")
        ttk.Entry(r, textvariable=self.var_ast_desig, width=10).pack(side="left", padx=4)
        r = ttk.Frame(box)
        r.pack(fill="x", padx=8, pady=2)
        ttk.Label(r, text="Geschaetzte Helligkeit (mag, optional):").pack(side="left")
        self.var_ast_mag = tk.StringVar(value="")
        ttk.Entry(r, textvariable=self.var_ast_mag, width=6).pack(side="left", padx=4)
        ttk.Button(box, text="Position markieren & Zeile erzeugen",
                   command=lambda: self._start_marking("asteroid")
                   ).pack(fill="x", padx=8, pady=4)
        self.txt_ast = tk.Text(box, height=6, wrap="none", font=("Consolas", 9))
        self.txt_ast.pack(fill="x", padx=8, pady=(0, 6))
        self.txt_ast.insert("1.0", "noch keine Positionen erfasst")
        self.txt_ast.configure(state="disabled")

        box = ttk.LabelFrame(t_trans, text="Nova-/Transientensuche (Bildvergleich)")
        box.pack(fill="x", **pad)
        ttk.Label(box, wraplength=390, justify="left", foreground="#555555", text=
                  "Vergleicht das aktuelle geloeste Bild mit einem frueher "
                  "gespeicherten Referenzbild (gleiches Feld). Neue helle "
                  "Punkte, die vorher nicht da waren, werden als Kandidat "
                  "gelistet - kann auch Flugzeuge/Satelliten/Rauschen sein, "
                  "IMMER manuell pruefen, bevor du irgendwo etwas meldest."
                  ).pack(anchor="w", padx=8, pady=(4, 4))
        r = ttk.Frame(box)
        r.pack(fill="x", padx=8, pady=4)
        ttk.Button(r, text="Aktuelles Bild als Referenz speichern",
                   command=self.save_reference_frame).pack(fill="x")
        self.var_ref_status = tk.StringVar(value="keine Referenz gespeichert")
        ttk.Label(box, textvariable=self.var_ref_status,
                  foreground="#333333").pack(anchor="w", padx=8, pady=2)

        r = ttk.Frame(box)
        r.pack(fill="x", padx=8, pady=2)
        ttk.Label(r, text="Schwelle (Sigma ueber Rauschen):").pack(side="left")
        self.var_trans_sigma = tk.DoubleVar(value=8.0)
        ttk.Spinbox(r, from_=4, to=30, width=5,
                    textvariable=self.var_trans_sigma).pack(side="left", padx=4)

        ttk.Button(box, text="Mit Referenz vergleichen",
                   command=self.compare_to_reference).pack(fill="x", padx=8, pady=4)
        self.txt_trans = tk.Text(box, height=8, wrap="word", font=("Consolas", 9))
        self.txt_trans.pack(fill="x", padx=8, pady=(0, 6))
        self.txt_trans.insert("1.0", "noch kein Vergleich durchgefuehrt")
        self.txt_trans.configure(state="disabled")

        box = ttk.LabelFrame(t_save, text="Speichern")
        box.pack(fill="x", **pad)
        r = ttk.Frame(box)
        r.pack(fill="x", padx=8, pady=4)
        ttk.Button(r, text="Screenshot", command=self.snapshot
                   ).pack(side="left", expand=True, fill="x", padx=2)
        self.var_autosave = tk.BooleanVar(value=False)
        ttk.Checkbutton(r, text="jedes Frame sichern",
                        variable=self.var_autosave,
                        command=self.choose_dir).pack(side="left", padx=6)

        box = ttk.LabelFrame(t_save, text="Live-Fenster (das nimmt OBS auf)")
        box.pack(fill="x", **pad)
        r = ttk.Frame(box)
        r.pack(fill="x", padx=8, pady=4)
        ttk.Button(r, text="Anzeigen", command=self.view.show
                   ).pack(side="left", expand=True, fill="x", padx=2)
        ttk.Button(r, text="Vollbild (F11)", command=self.view.toggle_fullscreen
                   ).pack(side="left", expand=True, fill="x", padx=2)
        self.var_top = tk.BooleanVar(value=False)
        ttk.Checkbutton(box, text="immer im Vordergrund",
                        variable=self.var_top,
                        command=self._topmost).pack(anchor="w", padx=8)

        self.var_status = tk.StringVar(value="bereit")
        ttk.Label(self, textvariable=self.var_status, foreground="#333333",
                  wraplength=440, justify="left").pack(
            anchor="w", padx=12, pady=6)

        self._mode_changed()
        self._auto_toggled()
        self.refresh_key()

    def _slider(self, parent, label, var, lo, hi):
        r = ttk.Frame(parent)
        r.pack(fill="x", padx=8, pady=1)
        ttk.Label(r, text=label, width=12).pack(side="left")
        sc = ttk.Scale(r, from_=lo, to=hi, variable=var, orient="horizontal")
        sc.pack(side="left", fill="x", expand=True)
        return sc

    def _auto_toggled(self):
        state = "disabled" if self.var_auto.get() else "normal"
        self.sc_black.state(["disabled"] if state == "disabled" else ["!disabled"])
        self.sc_mid.state(["disabled"] if state == "disabled" else ["!disabled"])

    def _mode_changed(self):
        if self.var_mode.get() == "stack":
            self.frm_stack.pack(fill="x", padx=20, pady=2)
            self.frm_rtsp.forget()
        else:
            self.frm_rtsp.pack(fill="x", padx=20, pady=2)
            self.frm_stack.forget()

    def _topmost(self):
        self.view.attributes("-topmost", self.var_top.get())

    def refresh_key(self):
        path, source = find_key()
        if path:
            if ss_auth is not None:
                try:
                    ss_auth.set_key_path(path)
                except Exception:
                    pass
            self.var_key.set(f"OK - gefunden ueber {source}:\n{path}")
        else:
            self.var_key.set(
                "Kein Schluessel gefunden. Bei Firmware 7.18 und neuer "
                "lehnt der Seestar sonst jede Verbindung ab.\n"
                f"Erwartet unter: {default_key_path()}")
        return path

    def pick_key(self):
        p = filedialog.askopenfilename(
            title="seestar.pem auswaehlen",
            filetypes=[("PEM-Schluessel", "*.pem"), ("Alle Dateien", "*.*")])
        if not p:
            return
        os.environ["SEESTAR_KEY_PATH"] = p
        if ss_auth is not None:
            try:
                ss_auth.set_key_path(p)
            except Exception as exc:
                messagebox.showerror(APP_TITLE, f"Schluessel unbrauchbar: {exc}")
                return
        self.refresh_key()
        self.var_status.set("Schluessel gesetzt und bei seestarpy registriert.")

    def extract_key(self):
        apk = filedialog.askopenfilename(
            title="Seestar-APK auswaehlen",
            filetypes=[("Android-App", "*.apk"), ("Alle Dateien", "*.*")])
        if not apk:
            return

        target = default_key_path()
        target.parent.mkdir(parents=True, exist_ok=True)

        self.var_status.set("Entpacke APK, das kann einen Moment dauern ...")
        self.update_idletasks()

        ok, out = extract_key_from_apk(apk, target)
        if ok and target.is_file():
            self.refresh_key()
            self.var_status.set(f"Schluessel geschrieben: {target}")
            messagebox.showinfo(APP_TITLE, f"Fertig.\n\nSchluessel liegt unter:\n{target}")
        else:
            self.var_status.set("Extraktion fehlgeschlagen.")
            messagebox.showerror(
                APP_TITLE,
                "Es konnte kein Schluessel aus der APK gelesen werden.\n\n"
                "Ausgabe des Extraktors:\n\n" + (out or "(keine)"))

    def discover(self):
        if ss_conn is None:
            messagebox.showerror(APP_TITLE, "seestarpy fehlt.\n\npip install seestarpy")
            return
        self.var_status.set("suche Seestar im Netzwerk ...")
        self.update_idletasks()
        try:
            ss_conn.find_available_ips(1)
            ips = getattr(ss_conn, "AVAILABLE_IPS", {}) or {}
            ips = {k: v for k, v in ips.items() if valid_ip(str(v))}
            if ips:
                first = list(ips.values())[0]
                self.var_ip.set(str(first))
                self.var_status.set(f"gefunden: {first}")
            else:
                self.var_status.set("nichts gefunden - IP bitte manuell eintragen")
        except Exception as exc:
            self.var_status.set(f"Suche fehlgeschlagen: {exc}")

    def start_source(self):
        self.stop_source()
        ip = self.var_ip.get().strip()

        if self.var_mode.get() == "rtsp":
            url = self.var_url.get().strip()
            if not url:
                if not ip:
                    messagebox.showwarning(APP_TITLE, "Bitte IP oder URL angeben.")
                    return
                url = f"rtsp://{ip}:4554/stream"
                self.var_url.set(url)
            self.source = RTSPSource(url)
        else:
            if ss_stream is None:
                messagebox.showerror(APP_TITLE,
                                     "seestarpy fehlt.\n\npip install seestarpy")
                return
            if find_key()[0] is None:
                go_on = messagebox.askyesno(
                    APP_TITLE,
                    "Es ist kein RSA-Schluessel hinterlegt.\n\n"
                    "Bei Firmware 7.18 und neuer verweigert der Seestar dann "
                    "die Verbindung. Bei aelterer Firmware ist das egal.\n\n"
                    "Trotzdem versuchen?")
                if not go_on:
                    return
            if not valid_ip(ip):
                messagebox.showwarning(
                    APP_TITLE,
                    "Fuer den Live-Stack bitte eine vollstaendige IP angeben "
                    f"(aktuell: '{ip or 'leer'}').\n\n"
                    "Tipp: Knopf 'Suchen' benutzen oder in der Seestar-App "
                    "unter Stationsmodus nachsehen.")
                return
            self.source = StackSource(
                ip=ip,
                interval=float(self.var_interval.get()),
                preview=self.var_preview.get(),
                wide=self.var_wide.get(),
            )

        self.source.start()
        self.btn_start.state(["disabled"])
        self.btn_stop.state(["!disabled"])
        self.view.show()

    def stop_source(self):
        if self.source is not None:
            self.source.stop()
            self.source = None
        self.btn_start.state(["!disabled"])
        self.btn_stop.state(["disabled"])
        self.var_status.set("gestoppt")

    def choose_dir(self):
        if self.var_autosave.get():
            d = filedialog.askdirectory(title="Ordner fuer Einzelbilder")
            if d:
                self._save_dir = d
                self._saved_ids.clear()
            else:
                self.var_autosave.set(False)

    def snapshot(self):
        if self._last_pil is None:
            messagebox.showinfo(APP_TITLE, "Noch kein Bild da.")
            return
        path = filedialog.asksaveasfilename(
            defaultextension=".png",
            filetypes=[("PNG", "*.png"), ("JPEG", "*.jpg")],
            initialfile=time.strftime("seestar_%Y%m%d_%H%M%S.png"))
        if path:
            self._last_pil.save(path)
            self.var_status.set(f"gespeichert: {os.path.basename(path)}")

    def _pick_overlay_color(self):
        rgb, hexcode = colorchooser.askcolor(
            color=self.var_ov_color.get(), title="Textfarbe der Einblendung")
        if hexcode:
            self.var_ov_color.set(hexcode)
            self.lbl_ov_color.configure(bg=hexcode)

    def _pick_astap(self):
        p = filedialog.askopenfilename(
            title="astap_cli auswaehlen",
            filetypes=[("ASTAP", "astap*"), ("Alle Dateien", "*.*")])
        if p:
            self.var_astap_path.set(p)

    def setup_astap(self):
        """Laedt astap_cli automatisch herunter und richtet es ein. Der
        Sternkatalog (die eigentliche Datenbank) kann aus Sicherheitsgruenden
        nicht automatisch mitinstalliert werden (siehe download_and_extract_
        astap-Docstring) - dafuer wird die offizielle Downloadseite geoeffnet."""
        self.var_solve_status.set("Richte astap_cli ein ...")
        threading.Thread(target=self._setup_astap_worker, daemon=True).start()

    def _setup_astap_worker(self):
        def progress(msg):
            self.after(0, self.var_solve_status.set, msg)

        try:
            exe_path = download_and_extract_astap(progress_cb=progress)
        except Exception as exc:
            self.after(0, self._setup_astap_done, None, str(exc))
            return
        self.after(0, self._setup_astap_done, exe_path, None)

    def _setup_astap_done(self, exe_path, error):
        if error:
            self.var_solve_status.set(f"Einrichtung fehlgeschlagen: {error}")
            messagebox.showerror(
                "ASTAP-Einrichtung",
                f"Konnte astap_cli nicht automatisch einrichten:\n\n{error}\n\n"
                "Alternativ manuell von www.hnsky.org/astap.htm laden und "
                "den Pfad oben eintragen.")
            return

        self.var_astap_path.set(exe_path)
        self.var_solve_status.set(
            "astap_cli eingerichtet. Es fehlt noch der Sternkatalog "
            "(die Datenbank) - ich oeffne dafuer gleich die Downloadseite.")
        answer = messagebox.askyesno(
            "ASTAP-Programm eingerichtet",
            "Das astap_cli-Programm wurde automatisch heruntergeladen und "
            f"eingerichtet:\n{exe_path}\n\n"
            "Zum Loesen braucht ASTAP zusaetzlich einen Sternkatalog "
            "(mehrere hundert MB, wird als Installer vertrieben). Diesen "
            "einen Schritt kann ich aus Sicherheitsgruenden nicht "
            "automatisch ausfuehren.\n\n"
            "Soll ich jetzt die offizielle Downloadseite oeffnen? Dort "
            "reicht 'D50' (guter Allrounder) oder 'D05' (kleiner, reicht "
            "meist fuer das Seestar-Bildfeld) - Installer starten, "
            "Standardpfad lassen, fertig.")
        if answer:
            open_astap_database_page()

    def _auto_solve_toggled(self):
        if self._auto_solve_job is not None:
            self.after_cancel(self._auto_solve_job)
            self._auto_solve_job = None
        if self.var_auto_solve.get():
            self._schedule_auto_solve()

    def _schedule_auto_solve(self):
        interval_ms = max(15, int(self.var_solve_interval.get())) * 1000
        self._auto_solve_job = self.after(interval_ms, self._auto_solve_tick)

    def _auto_solve_tick(self):
        if self.var_auto_solve.get():
            self.solve_now()
            self._schedule_auto_solve()

    def solve_now(self):
        if self._solve_busy:
            self.var_solve_status.set("laeuft bereits ...")
            return
        if self._last_pil is None:
            self.var_solve_status.set("noch kein Bild vorhanden.")
            return

        exe = self.var_astap_path.get().strip()
        if not exe or not Path(exe).is_file():
            if self.var_auto_solve.get():
                self.var_solve_status.set(
                    "ASTAP nicht gefunden - unten 'automatisch einrichten' klicken.")
                return
            if messagebox.askyesno(
                    "ASTAP nicht gefunden",
                    "Unter dem angegebenen Pfad wurde kein astap_cli gefunden.\n\n"
                    "Jetzt automatisch herunterladen und einrichten?"):
                self.setup_astap()
            return
        try:
            fov_txt = self.var_fov.get().strip()
            fov_deg = (float(fov_txt) / 60.0) if fov_txt else None
        except ValueError:
            fov_deg = None
        radius = int(self.var_search_radius.get())

        snapshot_pil = self._last_pil.convert("RGB")

        self._solve_busy = True
        self.var_solve_status.set("loese Bild (ASTAP laeuft) ...")
        threading.Thread(target=self._solve_worker,
                          args=(exe, snapshot_pil, radius, fov_deg),
                          daemon=True).start()

    def _solve_worker(self, exe, pil_img, radius, fov_deg):
        tmp_dir = tempfile.mkdtemp(prefix="seestar_solve_")
        img_path = os.path.join(tmp_dir, "frame.png")
        try:
            pil_img.save(img_path)
            result = run_astap_solve(exe, img_path, search_radius=radius,
                                     fov_deg=fov_deg)
            w, h = pil_img.size
            fov_img_deg = max(w, h) * result["cdelt"] if result["cdelt"] else 1.0
            matches = find_catalog_matches(result["ra"], result["dec"], fov_img_deg)
            result["matches"] = matches
            result["ts"] = time.time()
            self.after(0, self._apply_solve_result, result, None)
        except Exception as exc:
            self.after(0, self._apply_solve_result, None, str(exc))
        finally:
            try:
                for f in os.listdir(tmp_dir):
                    os.remove(os.path.join(tmp_dir, f))
                os.rmdir(tmp_dir)
            except OSError:
                pass

    def _apply_solve_result(self, result, error):
        self._solve_busy = False
        if error:
            self.var_solve_status.set(f"Plate-Solving fehlgeschlagen: {error}")
            return

        ra_txt = deg_to_hms(result["ra"])
        dec_txt = deg_to_dms(result["dec"])
        matches = result["matches"]
        if matches:
            names = ", ".join(
                (m["common"] or m["name"]) +
                (f" ({m['name']})" if m["common"] and m["name"] != m["common"] else "")
                for _, m in matches[:3])
            self.var_solve_status.set(
                f"Geloest: RA {ra_txt}  Dec {dec_txt}\nGefunden: {names}")
        else:
            self.var_solve_status.set(
                f"Geloest: RA {ra_txt}  Dec {dec_txt}\n"
                "Kein Katalogobjekt in der eingebauten Liste in der Naehe "
                "(nur Messier + gaengige NGC/IC-Ziele enthalten).")
        self._solve_result = result

    def analyze_stars_now(self):
        if self.source is None:
            self.var_status.set("keine Bildquelle aktiv.")
            return
        arr, _meta = self.source.latest()
        if arr is None:
            self.var_status.set("noch kein Bild da.")
            return

        report = analyze_star_colors(arr, max_stars=15)
        self._star_report = report
        self._update_star_text(report)

    def _update_star_text(self, report):
        self.txt_stars.configure(state="normal")
        self.txt_stars.delete("1.0", "end")
        if not report:
            self.txt_stars.insert("1.0", "keine hellen Sterne erkannt.")
        else:
            lines = [f"{i+1:2d}. ({s['x']:4d},{s['y']:4d})  {s['label']}"
                     for i, s in enumerate(report)]
            header = ("Naeherung aus RGB-Farbverhaeltnis, KEINE echte "
                       "Spektralklassifikation:\n")
            self.txt_stars.insert("1.0", header + "\n".join(lines))
        self.txt_stars.configure(state="disabled")

    def _start_marking(self, role):
        """Bewaffnet das Livefenster fuer einen einzelnen Marker-Klick.
        role bestimmt, was mit der geklickten Position passiert."""
        if self._last_pil is None:
            self.var_status.set("noch kein Bild da zum Markieren.")
            return
        self.var_status.set("Klicke im Livebild auf den Stern ...")
        self.view.request_click(lambda ix, iy: self._on_marked(role, ix, iy))

    def _on_marked(self, role, ix, iy):
        w, h = self._last_pil.size
        if role in ("phot_target", "phot_comp"):
            if self.var_flip.get():
                raw_x, raw_y = w - 1 - ix, h - 1 - iy
            else:
                raw_x, raw_y = ix, iy
            if role == "phot_target":
                self._phot_target = (raw_x, raw_y)
            else:
                self._phot_comp = (raw_x, raw_y)
            t = f"{self._phot_target[0]:.0f},{self._phot_target[1]:.0f}" if self._phot_target else "-"
            c = f"{self._phot_comp[0]:.0f},{self._phot_comp[1]:.0f}" if self._phot_comp else "-"
            self.var_phot_marks.set(f"Ziel: {t}  |  Vergleich: {c}")
            self.var_status.set("Stern markiert.")

        elif role in ("ds_1", "ds_2"):
            if role == "ds_1":
                self._ds_pt1 = (ix, iy)
            else:
                self._ds_pt2 = (ix, iy)
            a = f"{self._ds_pt1[0]:.0f},{self._ds_pt1[1]:.0f}" if self._ds_pt1 else "-"
            b = f"{self._ds_pt2[0]:.0f},{self._ds_pt2[1]:.0f}" if self._ds_pt2 else "-"
            self.var_ds_result.set(f"A: {a} | B: {b}")
            self.var_status.set("Stern markiert.")

        elif role == "asteroid":
            self._record_asteroid_position(ix, iy)

    def fetch_distances(self):
        if self._distance_busy:
            return
        if not self._solve_result:
            messagebox.showinfo(
                "Keine Loesung", "Bitte zuerst im Tab 'Objekte' das Bild "
                "loesen (Plate-Solving), sonst ist die Himmelsposition "
                "nicht bekannt.")
            return
        ra, dec = self._solve_result["ra"], self._solve_result["dec"]
        radius_deg = float(self.var_dist_radius.get()) / 60.0
        self._distance_busy = True
        self._set_text(self.txt_dist, "frage SIMBAD/Gaia ab ...")
        threading.Thread(target=self._fetch_distances_worker,
                          args=(ra, dec, radius_deg), daemon=True).start()

    def _fetch_distances_worker(self, ra, dec, radius_deg):
        try:
            rows = query_simbad_distances(ra, dec, radius_deg, limit=15)
            self.after(0, self._fetch_distances_done, rows, None)
        except Exception as exc:
            self.after(0, self._fetch_distances_done, None, str(exc))

    def _fetch_distances_done(self, rows, error):
        self._distance_busy = False
        if error:
            self._set_text(self.txt_dist, f"Abfrage fehlgeschlagen: {error}")
            return
        if not rows:
            self._set_text(self.txt_dist,
                            "Keine Objekte mit bekannter Gaia-Parallaxe in "
                            "diesem Radius gefunden (bei sehr schwachen "
                            "Sternen hat Gaia oft keine brauchbare Parallaxe).")
            return
        lines = ["Katalogwerte (SIMBAD/Gaia), KEINE Eigenmessung:\n"]
        for r in rows:
            lines.append(
                f"{r['name']:<18s} {r['distance_ly']:8.1f} Lj "
                f"({r['distance_pc']:7.2f} pc, Plx {r['parallax_mas']:.2f} mas)")
        self._set_text(self.txt_dist, "\n".join(lines))

    def _set_text(self, widget, text):
        widget.configure(state="normal")
        widget.delete("1.0", "end")
        widget.insert("1.0", text)
        widget.configure(state="disabled")

    def _phot_auto_toggled(self):
        if self._phot_auto_job is not None:
            self.after_cancel(self._phot_auto_job)
            self._phot_auto_job = None
        if self.var_phot_auto.get():
            self._schedule_phot_auto()

    def _schedule_phot_auto(self):
        interval_ms = max(10, int(self.var_phot_interval.get())) * 1000
        self._phot_auto_job = self.after(interval_ms, self._phot_auto_tick)

    def _phot_auto_tick(self):
        if self.var_phot_auto.get():
            self.take_photometry_point()
            self._schedule_phot_auto()

    def take_photometry_point(self):
        if self.source is None:
            self.var_status.set("keine Bildquelle aktiv.")
            return
        if self._phot_target is None or self._phot_comp is None:
            self.var_status.set("bitte erst Ziel- UND Vergleichsstern markieren.")
            return

        arr, meta = self.source.latest()
        if arr is None:
            self.var_status.set("noch kein Bild da.")
            return

        r_ap = float(self.var_ap_r.get())
        r_in = float(self.var_ap_rin.get())
        r_out = float(self.var_ap_rout.get())

        flux_t, cx_t, cy_t = aperture_photometry(
            arr, *self._phot_target, r_ap=r_ap, r_in=r_in, r_out=r_out)
        flux_c, cx_c, cy_c = aperture_photometry(
            arr, *self._phot_comp, r_ap=r_ap, r_in=r_in, r_out=r_out)

        self._phot_target = (cx_t, cy_t)
        self._phot_comp = (cx_c, cy_c)

        if flux_t <= 0 or flux_c <= 0:
            self.var_status.set("Photometrie: kein brauchbares Signal in der Apertur.")
            return

        delta_mag = -2.5 * math.log10(flux_t / flux_c)
        entry = {
            "t": time.time(), "flux_target": flux_t, "flux_comp": flux_c,
            "delta_mag": delta_mag, "frame": meta.get("frame"),
        }
        self._phot_log.append(entry)
        self._update_phot_text()
        self.var_status.set(f"Messpunkt aufgenommen: \u0394m = {delta_mag:+.3f}")

    def _update_phot_text(self):
        if not self._phot_log:
            self._set_text(self.txt_phot, "noch keine Messpunkte")
            return
        lines = ["Zeit                 Delta-mag  (Ziel - Vergleich)"]
        for e in self._phot_log[-40:]:
            ts = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(e["t"]))
            lines.append(f"{ts}   {e['delta_mag']:+.3f}")
        self._set_text(self.txt_phot, "\n".join(lines))

    def export_photometry_csv(self):
        if not self._phot_log:
            self.var_status.set("keine Messpunkte zum Exportieren.")
            return
        path = filedialog.asksaveasfilename(
            defaultextension=".csv",
            filetypes=[("CSV", "*.csv")],
            initialfile="lichtkurve.csv")
        if not path:
            return
        with open(path, "w", newline="", encoding="utf-8") as f:
            w = csv.writer(f)
            w.writerow(["unix_zeit", "iso_zeit_lokal", "delta_mag",
                        "fluss_ziel", "fluss_vergleich", "frame"])
            for e in self._phot_log:
                w.writerow([
                    f"{e['t']:.3f}",
                    time.strftime("%Y-%m-%dT%H:%M:%S", time.localtime(e["t"])),
                    f"{e['delta_mag']:.4f}", f"{e['flux_target']:.2f}",
                    f"{e['flux_comp']:.2f}", e.get("frame", "")])
        self.var_status.set(f"Lichtkurve exportiert: {os.path.basename(path)}")

    def show_light_curve(self):
        if len(self._phot_log) < 2:
            self.var_status.set("mindestens 2 Messpunkte fuer eine Kurve noetig.")
            return
        LightCurveWindow(self, self._phot_log)

    def compute_double_star(self):
        if not self._solve_result:
            messagebox.showinfo(
                "Keine Loesung", "Bitte zuerst im Tab 'Objekte' das Bild "
                "loesen (Plate-Solving).")
            return
        if self._ds_pt1 is None or self._ds_pt2 is None:
            self.var_status.set("bitte beide Sterne markieren.")
            return

        wcs = self._solve_result
        try:
            ra1, dec1 = wcs_pixel_to_radec(self._ds_pt1[0], self._ds_pt1[1], wcs)
            ra2, dec2 = wcs_pixel_to_radec(self._ds_pt2[0], self._ds_pt2[1], wcs)
        except Exception as exc:
            self.var_status.set(f"Umrechnung fehlgeschlagen: {exc}")
            return

        pa, sep = position_angle_and_separation(ra1, dec1, ra2, dec2)
        name = self.var_ds_name.get().strip() or "(unbenannt)"
        entry = {"t": time.time(), "name": name, "pa_deg": pa, "sep_arcsec": sep,
                 "ra1": ra1, "dec1": dec1, "ra2": ra2, "dec2": dec2}
        self._ds_log.append(entry)
        self.var_ds_result.set(
            f"{name}: PA = {pa:.1f}\u00b0 (Nord->Ost),  Abstand = {sep:.2f}\"")

    def export_double_star_csv(self):
        if not self._ds_log:
            self.var_status.set("keine Doppelstern-Messungen zum Exportieren.")
            return
        path = filedialog.asksaveasfilename(
            defaultextension=".csv", filetypes=[("CSV", "*.csv")],
            initialfile="doppelsterne.csv")
        if not path:
            return
        with open(path, "w", newline="", encoding="utf-8") as f:
            w = csv.writer(f)
            w.writerow(["iso_zeit_lokal", "paar", "pa_grad", "abstand_bogensek",
                        "ra1_grad", "dec1_grad", "ra2_grad", "dec2_grad"])
            for e in self._ds_log:
                w.writerow([
                    time.strftime("%Y-%m-%dT%H:%M:%S", time.localtime(e["t"])),
                    e["name"], f"{e['pa_deg']:.2f}", f"{e['sep_arcsec']:.3f}",
                    f"{e['ra1']:.6f}", f"{e['dec1']:.6f}",
                    f"{e['ra2']:.6f}", f"{e['dec2']:.6f}"])
        self.var_status.set(f"Doppelstern-Messreihe exportiert: {os.path.basename(path)}")

    def _record_asteroid_position(self, ix, iy):
        if not self._solve_result:
            messagebox.showinfo(
                "Keine Loesung", "Bitte zuerst im Tab 'Objekte' das Bild "
                "loesen (Plate-Solving), sonst keine Himmelskoordinaten.")
            return
        wcs = self._solve_result
        try:
            ra, dec = wcs_pixel_to_radec(ix, iy, wcs)
        except Exception as exc:
            self.var_status.set(f"Umrechnung fehlgeschlagen: {exc}")
            return

        desig = self.var_ast_desig.get().strip() or "TEMP001"
        mag = None
        try:
            mag_txt = self.var_ast_mag.get().strip()
            mag = float(mag_txt) if mag_txt else None
        except ValueError:
            mag = None

        now_utc = datetime.now(timezone.utc)
        line = format_mpc80_line(desig, now_utc, ra, dec, mag=mag, band="V")
        self._ast_log.append(line)
        self._update_ast_text()
        self.var_status.set("Position erfasst (MPC-Zeile erzeugt).")

    def _update_ast_text(self):
        header = ("MPC-80-Spalten-Format - Beobachtercode (Spalten 78-80) "
                   "fehlt bewusst, siehe Hinweis oben:\n")
        self._set_text(self.txt_ast, header + "\n".join(self._ast_log))

    def save_reference_frame(self):
        if self.source is None:
            self.var_status.set("keine Bildquelle aktiv.")
            return
        if not self._solve_result:
            messagebox.showinfo(
                "Keine Loesung", "Bitte zuerst im Tab 'Objekte' das aktuelle "
                "Bild loesen, damit die Referenz eine bekannte Himmelsposition "
                "hat (fuer die spaetere Ausrichtung noetig).")
            return
        arr, meta = self.source.latest()
        if arr is None:
            self.var_status.set("noch kein Bild da.")
            return

        self._ref_frame = {
            "arr": arr.copy(), "wcs": dict(self._solve_result),
            "ts": time.time(),
        }
        when = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(self._ref_frame["ts"]))
        self.var_ref_status.set(f"Referenz gespeichert: {when}")
        self.var_status.set("Referenzbild gespeichert.")

    def compare_to_reference(self):
        if self._ref_frame is None:
            self.var_status.set("bitte erst eine Referenz speichern.")
            return
        if not self._solve_result:
            messagebox.showinfo(
                "Keine Loesung", "Bitte zuerst im Tab 'Objekte' das aktuelle "
                "Bild loesen (Plate-Solving).")
            return
        if self.source is None:
            self.var_status.set("keine Bildquelle aktiv.")
            return
        arr, meta = self.source.latest()
        if arr is None:
            self.var_status.set("noch kein Bild da.")
            return

        self._set_text(self.txt_trans, "vergleiche ...")
        sigma = float(self.var_trans_sigma.get())
        threading.Thread(target=self._compare_worker, args=(arr, sigma),
                         daemon=True).start()

    def _compare_worker(self, cur_arr, sigma):
        try:
            cur_wcs = self._solve_result
            ref = self._ref_frame
            ref_arr, ref_wcs = ref["arr"], ref["wcs"]

            cur_lum = cur_arr.mean(axis=2) if cur_arr.ndim == 3 else cur_arr
            ref_lum = ref_arr.mean(axis=2) if ref_arr.ndim == 3 else ref_arr
            h, w = cur_lum.shape

            step = max(1, min(h, w) // 120)
            gy, gx = np.mgrid[0:h:step, 0:w:step].astype(float)
            ref_x = np.full(gx.shape, np.nan)
            ref_y = np.full(gx.shape, np.nan)
            for j in range(gx.shape[0]):
                for i in range(gx.shape[1]):
                    ra, dec = wcs_pixel_to_radec(gx[j, i], gy[j, i], cur_wcs)
                    try:
                        rx, ry = wcs_radec_to_pixel(ra, dec, ref_wcs)
                        ref_x[j, i] = rx
                        ref_y[j, i] = ry
                    except ValueError:
                        pass

            rx_img = Image.fromarray(np.nan_to_num(ref_x, nan=-1).astype(np.float32))
            ry_img = Image.fromarray(np.nan_to_num(ref_y, nan=-1).astype(np.float32))
            rx_full = np.array(rx_img.resize((w, h), Image.BILINEAR))
            ry_full = np.array(ry_img.resize((w, h), Image.BILINEAR))

            valid = (rx_full >= 0) & (ry_full >= 0) & \
                    (rx_full < ref_lum.shape[1]) & (ry_full < ref_lum.shape[0])
            ix = np.clip(rx_full.astype(int), 0, ref_lum.shape[1] - 1)
            iy = np.clip(ry_full.astype(int), 0, ref_lum.shape[0] - 1)
            resampled_ref = np.zeros_like(cur_lum)
            resampled_ref[valid] = ref_lum[iy[valid], ix[valid]]

            diff = cur_lum.astype(np.float32) - resampled_ref.astype(np.float32)
            diff[~valid] = 0.0

            med = float(np.median(diff))
            mad = float(np.median(np.abs(diff - med))) * 1.4826 or 1e-4
            thresh = med + sigma * mad

            ys, xs = np.where(diff > thresh)
            candidates = []
            if len(xs) > 0:
                order = np.argsort(-diff[ys, xs])
                ys, xs = ys[order], xs[order]
                picked = []
                for y, x in zip(ys, xs):
                    if len(picked) >= 15:
                        break
                    if any((py - y) ** 2 + (px - x) ** 2 < 10 ** 2 for py, px in picked):
                        continue
                    picked.append((y, x))
                for y, x in picked:
                    ra, dec = wcs_pixel_to_radec(float(x), float(y), cur_wcs)
                    known = find_catalog_matches(ra, dec, 0.05, limit=1)
                    tag = "bekanntes Katalogobjekt in der Naehe" if known else "kein Katalogtreffer"
                    candidates.append({
                        "x": int(x), "y": int(y), "ra": ra, "dec": dec, "tag": tag,
                    })

            self.after(0, self._compare_done, candidates, None)
        except Exception as exc:
            self.after(0, self._compare_done, None, str(exc))

    def _compare_done(self, candidates, error):
        if error:
            self._set_text(self.txt_trans, f"Vergleich fehlgeschlagen: {error}")
            return
        self._transient_candidates = candidates or []
        if not candidates:
            self._set_text(self.txt_trans,
                            "Keine auffaelligen neuen Punkte gefunden.")
            return
        lines = [f"{len(candidates)} Kandidat(en) - IMMER manuell pruefen "
                 "(Flugzeuge/Satelliten/Rauschen moeglich):\n"]
        for c in candidates:
            lines.append(f"px({c['x']},{c['y']})  RA {deg_to_hms(c['ra'])}  "
                        f"Dec {deg_to_dms(c['dec'])}  - {c['tag']}")
        self._set_text(self.txt_trans, "\n".join(lines))

    def _tick(self):
        try:
            self._render()
        except Exception as exc:
            self.var_status.set(f"Anzeigefehler: {exc}")
        self.after(40, self._tick)

    def _render(self):
        if self.source is None:
            return

        arr, meta = self.source.latest()
        if self.source.error:
            self.var_status.set(f"{self.source.status} - {self.source.error}")
        else:
            self.var_status.set(f"Status: {self.source.status}")

        if arr is None:
            self.view.clear("warte auf Bild ...")
            return

        work = arr
        if self.var_mode.get() == "stack" or not self.var_auto.get():
            if self.var_auto.get():
                work, blk, mid = autostretch(work)
                self.var_black.set(round(blk, 4))
                self.var_mid.set(round(mid, 4))
            else:
                work = manual_stretch(work, float(self.var_black.get()),
                                      float(self.var_mid.get()))

        if self.var_gray.get() and work.ndim == 3:
            work = work.mean(axis=2)

        pil = to_pil(work)
        if self.var_flip.get():
            pil = pil.rotate(180)

        if self.var_mark_stars.get() and self._star_report:
            pil = self._mark_stars(pil)

        if self.var_overlay.get():
            pil = self._overlay(pil, meta)

        self._last_pil = pil
        self.view.draw(pil)

        if self.var_autosave.get() and self._save_dir:
            fid = meta.get("frame")
            if fid is not None and fid not in self._saved_ids:
                self._saved_ids.add(fid)
                name = os.path.join(self._save_dir, f"frame_{fid:05d}.png")
                try:
                    pil.save(name)
                except Exception:
                    pass

    def _mark_stars(self, pil):
        pil = pil.copy()
        draw = ImageDraw.Draw(pil)
        for s in self._star_report:
            x, y = s["x"], s["y"]
            draw.ellipse((x - 8, y - 8, x + 8, y + 8), outline=(255, 210, 0), width=1)
        return pil

    def _overlay(self, pil, meta):
        pil = pil.copy()
        draw = ImageDraw.Draw(pil)
        size = self.var_ov_size.get() or max(14, pil.size[1] // 40)
        try:
            font = ImageFont.truetype("segoeui.ttf", size)
        except Exception:
            font = ImageFont.load_default()

        bits = []
        if self.var_show_title.get():
            cap = self.var_caption.get().strip()
            if cap:
                bits.append(cap)
        if self.var_show_source.get() and meta.get("quelle"):
            bits.append(meta["quelle"])
        if self.var_show_res.get() and meta.get("breite"):
            bits.append(f"{meta['breite']}x{meta['hoehe']}")
        if self.var_show_frame.get() and meta.get("frame") is not None:
            bits.append(f"Frame {meta['frame']}")
        if self.var_show_hfd.get() and meta.get("hfd") is not None:
            try:
                bits.append(f"HFD {float(meta['hfd']):.2f}")
            except (TypeError, ValueError):
                pass
        if self.var_show_fps.get() and meta.get("fps"):
            bits.append(f"{meta['fps']:.1f} fps")
        if self.var_show_object.get() and self._solve_result:
            matches = self._solve_result.get("matches") or []
            if matches:
                _, obj = matches[0]
                bits.append(obj["common"] or obj["name"])
            else:
                bits.append(f"RA {deg_to_hms(self._solve_result['ra'])}  "
                            f"Dec {deg_to_dms(self._solve_result['dec'])}")
        if self.var_show_date.get():
            bits.append(time.strftime("%d.%m.%Y"))
        if self.var_show_time.get():
            bits.append(time.strftime("%H:%M:%S"))

        text = "   |   ".join(bits)
        if not text:
            return pil

        try:
            color = ImageColor.getrgb(self.var_ov_color.get())
        except ValueError:
            color = (255, 255, 255)

        bbox = draw.textbbox((0, 0), text, font=font)
        tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
        pw, ph = pil.size
        margin = size // 2
        pos = self.var_ov_pos.get()
        if pos == "oben links":
            x, y = margin, margin
        elif pos == "oben rechts":
            x, y = pw - tw - margin, margin
        elif pos == "unten rechts":
            x, y = pw - tw - margin, ph - th - margin
        else:
            x, y = margin, ph - th - margin

        for dx in (-2, 0, 2):
            for dy in (-2, 0, 2):
                draw.text((x + dx, y + dy), text, font=font, fill=(0, 0, 0))
        draw.text((x, y), text, font=font, fill=color)
        return pil

    def on_close(self):
        if self._auto_solve_job is not None:
            self.after_cancel(self._auto_solve_job)
            self._auto_solve_job = None
        self.stop_source()
        self.destroy()


def main():
    missing = []
    if cv2 is None:
        missing.append("opencv-python (fuer RTSP)")
    if ss_stream is None:
        missing.append("seestarpy (fuer Live-Stack)")

    app = ControlApp()
    if missing:
        app.var_status.set("Fehlende Pakete: " + ", ".join(missing))
    app.mainloop()


if __name__ == "__main__":
    main()

// hinzugefügt am 12.09.2026

Zurück zur Übersicht