Marcin Modest Trzaska

mosaic

Tak coś mnie tknęło… dawno nie robiłem żadnej mozaiki Seestarem. Nie robiłem, bo się zniechęciłem — jakoś nie wychodziło to najlepiej…

No i coś mnie tknęło, żeby sprawdzić pokrycie na mapie/obrazku…

I takie coś mam od Klaudiusza[^1]. ;–)

#!/usr/bin/env python3
"""
seestar_mosaic_coverage.py

Wczytuje pliki FIT (Seestar, tryb mozaiki) z podanego folderu i rysuje
mape pokrycia nieba: kolorem pokazuje, ile klatek pokrywa dany fragment.

Wymagania:
    pip install astropy numpy matplotlib

Uzycie:
    python seestar_mosaic_coverage.py /sciezka/do/folderu_z_fit
    python seestar_mosaic_coverage.py /sciezka --fov-w 0.75 --fov-h 1.33 --grid 400
"""

import argparse
import glob
import os
import sys

import numpy as np
from astropy.io import fits
from astropy.wcs import WCS
import matplotlib.pyplot as plt
from matplotlib.path import Path as MplPath

# ---------- odczyt naglowkow ----------

RA_KEYS = ["RA", "OBJCTRA", "CRVAL1"]
DEC_KEYS = ["DEC", "OBJCTDEC", "CRVAL2"]
ROT_KEYS = ["ROTATION", "PA", "ROTATANG", "ORIENTAT", "ROT"]


def _first_present(header, keys):
    for k in keys:
        if k in header:
            return header[k]
    return None


def _to_decimal_ra(val):
    """RA moze byc liczba (stopnie) albo string HH:MM:SS."""
    if isinstance(val, (int, float)):
        return float(val)
    val = str(val).strip()
    if ":" in val:
        h, m, s = [float(x) for x in val.split(":")]
        return (h + m / 60 + s / 3600) * 15.0
    return float(val)


def _to_decimal_dec(val):
    if isinstance(val, (int, float)):
        return float(val)
    val = str(val).strip()
    sign = -1.0 if val.startswith("-") else 1.0
    val = val.lstrip("+-")
    if ":" in val:
        d, m, s = [float(x) for x in val.split(":")]
        return sign * (d + m / 60 + s / 3600)
    return float(val) * sign


def read_frame_pointing(path):
    """Zwraca (ra_deg, dec_deg, rotation_deg, footprint_lub_None) dla jednej klatki FIT."""
    header = fits.getheader(path)

    # Sciezka 1: pelne WCS (jesli plik byl plate-solved, np. przez ASTAP)
    if "CTYPE1" in header and "CRVAL1" in header:
        try:
            wcs = WCS(header)
            footprint = wcs.calc_footprint(header=header)  # 4x2 [ra,dec] w stopniach
            ra_c, dec_c = footprint.mean(axis=0)
            return ra_c, dec_c, 0.0, footprint
        except Exception:
            pass

    # Sciezka 2: naglowek Seestara (RA/DEC srodka klatki + ewentualna rotacja)
    ra_raw = _first_present(header, RA_KEYS)
    dec_raw = _first_present(header, DEC_KEYS)
    if ra_raw is None or dec_raw is None:
        raise ValueError("brak RA/DEC w naglowku")

    ra_deg = _to_decimal_ra(ra_raw)
    dec_deg = _to_decimal_dec(dec_raw)
    rot_raw = _first_present(header, ROT_KEYS)
    rot_deg = float(rot_raw) if rot_raw is not None else 0.0

    return ra_deg, dec_deg, rot_deg, None


# ---------- projekcja i footprinty ----------

def gnomonic(ra_deg, dec_deg, ra0_deg, dec0_deg):
    """Rzutuje RA/Dec na plaszczyzne styczna wzgledem (ra0, dec0). Zwraca stopnie."""
    ra, dec = np.radians(ra_deg), np.radians(dec_deg)
    ra0, dec0 = np.radians(ra0_deg), np.radians(dec0_deg)
    dra = ra - ra0
    denom = np.sin(dec0) * np.sin(dec) + np.cos(dec0) * np.cos(dec) * np.cos(dra)
    x = np.cos(dec) * np.sin(dra) / denom
    y = (np.cos(dec0) * np.sin(dec) - np.sin(dec0) * np.cos(dec) * np.cos(dra)) / denom
    return np.degrees(x), np.degrees(y)


def frame_corners_tangent(ra_c, dec_c, rot_deg, fov_w, fov_h, ra0, dec0):
    """Rogi klatki (prostokat obrocony o rot_deg) w ukladzie stycznym."""
    xc, yc = gnomonic(ra_c, dec_c, ra0, dec0)
    hw, hh = fov_w / 2, fov_h / 2
    local = np.array([(-hw, -hh), (hw, -hh), (hw, hh), (-hw, hh)])
    theta = np.radians(rot_deg)
    rot = np.array([[np.cos(theta), -np.sin(theta)],
                    [np.sin(theta), np.cos(theta)]])
    rotated = local @ rot.T
    return rotated + np.array([xc, yc])


def wcs_corners_tangent(footprint_radec, ra0, dec0):
    x, y = gnomonic(footprint_radec[:, 0], footprint_radec[:, 1], ra0, dec0)
    return np.column_stack([x, y])


# ---------- glowna logika ----------

def build_coverage(folder, fov_w, fov_h, grid_n):
    files = sorted(glob.glob(os.path.join(folder, "*.fit")) +
                    glob.glob(os.path.join(folder, "*.fits")))
    if not files:
        sys.exit(f"Nie znaleziono plikow .fit/.fits w: {folder}")

    raw = []
    for f in files:
        try:
            ra_c, dec_c, rot, footprint = read_frame_pointing(f)
            raw.append((f, ra_c, dec_c, rot, footprint))
        except Exception as e:
            print(f"[pomijam] {os.path.basename(f)}: {e}")

    if not raw:
        sys.exit("Nie udalo sie odczytac zadnej klatki - sprawdz nazwy kluczy w naglowku (patrz ponizej).")

    ra0 = float(np.mean([r[1] for r in raw]))
    dec0 = float(np.mean([r[2] for r in raw]))

    polygons = []
    for f, ra_c, dec_c, rot, footprint in raw:
        if footprint is not None:
            corners = wcs_corners_tangent(footprint, ra0, dec0)
        else:
            corners = frame_corners_tangent(ra_c, dec_c, rot, fov_w, fov_h, ra0, dec0)
        polygons.append(corners)

    all_pts = np.vstack(polygons)
    pad = 0.1 * max(fov_w, fov_h)
    xmin, ymin = all_pts.min(axis=0) - pad
    xmax, ymax = all_pts.max(axis=0) + pad

    xs = np.linspace(xmin, xmax, grid_n)
    ys = np.linspace(ymin, ymax, grid_n)
    XX, YY = np.meshgrid(xs, ys)
    pts = np.column_stack([XX.ravel(), YY.ravel()])

    count = np.zeros(pts.shape[0], dtype=int)
    for corners in polygons:
        path = MplPath(corners)
        count += path.contains_points(pts)

    count = count.reshape(XX.shape)
    return XX, YY, count, polygons, len(raw), ra0, dec0


def plot_coverage(XX, YY, count, polygons, n_frames, ra0, dec0, out_path):
    fig, ax = plt.subplots(figsize=(9, 8))

    max_count = max(1, int(count.max()))
    mesh = ax.pcolormesh(XX, YY, count, shading="auto",
                          cmap="viridis", vmin=0, vmax=max_count)
    cbar = fig.colorbar(mesh, ax=ax)
    cbar.set_label("Liczba nakladajacych sie klatek")

    for corners in polygons:
        closed = np.vstack([corners, corners[0]])
        ax.plot(closed[:, 0], closed[:, 1], color="white", linewidth=0.4, alpha=0.6)

    ax.set_xlabel(f"Odsuniecie RA [deg] wzgledem {ra0:.3f}")
    ax.set_ylabel(f"Odsuniecie Dec [deg] wzgledem {dec0:.3f}")
    ax.set_title(f"Pokrycie mozaiki - {n_frames} klatek")
    ax.invert_xaxis()  # konwencja: RA rosnie w lewo
    ax.set_aspect("equal")

    fig.tight_layout()
    fig.savefig(out_path, dpi=150)
    print(f"Zapisano: {out_path}")
    plt.show()


def main():
    ap = argparse.ArgumentParser(description="Mapa pokrycia mozaiki Seestar z plikow FIT.")
    ap.add_argument("folder", help="Folder z plikami .fit/.fits")
    ap.add_argument("--fov-w", type=float, default=0.75,
                    help="Szerokosc FOV w stopniach (domyslnie 0.75 - Seestar S50)")
    ap.add_argument("--fov-h", type=float, default=1.33,
                    help="Wysokosc FOV w stopniach (domyslnie 1.33 - Seestar S50)")
    ap.add_argument("--grid", type=int, default=300,
                    help="Rozdzielczosc siatki liczacej nakladki (domyslnie 300)")
    ap.add_argument("--out", default="coverage.png", help="Plik wyjsciowy PNG")
    args = ap.parse_args()

    XX, YY, count, polygons, n, ra0, dec0 = build_coverage(
        args.folder, args.fov_w, args.fov_h, args.grid
    )
    plot_coverage(XX, YY, count, polygons, n, ra0, dec0, args.out)


if __name__ == "__main__":
    main()

Najpierw

$ python3 -m venv astro
$ source astro/bin/activate

a później, na przykład:

$ python seestar_mosaic_coverage.py SeestarS50/Messier/M\ 31_manual_mosaic
pokrycie mozaiki pokrycie mozaiki
pokrycie mozaiki pokrycie mozaiki

pokrycie M31 pokrycie NGC700

No i te dwa ostatnie obrazki pokazują mi dobitnie, dlaczego mozaiki były fallusowe.

[^1]: Claude AI

#seestar #mosaic #mozaika #astrophotography #astrofotografia

— Marcin “czach” Trzaska reply-to: @czach@mastodon.argilus.online