Rekin po raz drugi
Próbuję po raz drugi ogarnąć Dark Shark Nebula, tym razem na szerzej, to znaczy w trybie mozaiki x2 (czyli pole widzenia Seestara S50 x 4).

Ciekawi mnie, jak to wyjdzie. Na razie za malo materiału, żeby cokolwiek zobaczyć.
Tak przy okazji — na dysku pojawiła się nowa wersja skryptu a tak wygląda wynik jego pracy:

#!/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.
"""
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
from matplotlib.ticker import MaxNLocator
# ---------- 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):
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):
header = fits.getheader(path)
if "CTYPE1" in header and "CRVAL1" in header:
try:
wcs = WCS(header)
footprint = wcs.calc_footprint(header=header)
ra_c, dec_c = footprint.mean(axis=0)
return ra_c, dec_c, 0.0, footprint
except Exception:
pass
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):
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):
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.")
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.15 * 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):
# Ciemny motyw pasuje do astronomii i poprawia kontrast
plt.style.use('dark_background')
fig, ax = plt.subplots(figsize=(10, 9))
# Maskowanie obszarów gdzie count == 0, żeby tło było czyste
count_masked = np.ma.masked_equal(count, 0)
max_count = max(1, int(count.max()))
# Użycie palety 'plasma' lub 'magma' – są bardziej czytelne niż viridis na czarnym tle
mesh = ax.pcolormesh(XX, YY, count_masked, shading="auto",
cmap="plasma", vmin=1, vmax=max_count)
cbar = fig.colorbar(mesh, ax=ax, boundaries=np.arange(0.5, max_count + 1.5, 1))
cbar.set_label("Liczba nakładających się klatek", color='white')
cbar.locator = MaxNLocator(integer=True)
cbar.update_ticks()
# Rysowanie klatek tylko wtedy, gdy jest ich rozsądna liczba,
# żeby nie zrobić z wykresu białej plamy
if len(polygons) <= 150:
for corners in polygons:
closed = np.vstack([corners, corners[0]])
ax.plot(closed[:, 0], closed[:, 1], color="cyan", linewidth=0.3, alpha=0.4)
ax.set_xlabel(f"Odsunięcie ΔRA [deg] względem RA={ra0:.3f}°", fontsize=11)
ax.set_ylabel(f"Odsunięcie ΔDec [deg] względem Dec={dec0:.3f}°", fontsize=11)
ax.set_title(f"Mapa pokrycia mozaiki ({n_frames} klatek)", fontsize=13, pad=12)
ax.invert_xaxis() # Konwencja astronomiczna: RA rośnie w lewo
ax.set_aspect("equal")
ax.grid(True, color='gray', linestyle='--', linewidth=0.5, alpha=0.3)
fig.tight_layout()
fig.savefig(out_path, dpi=200, facecolor=fig.get_facecolor(), edgecolor='none')
print(f"Zapisano ulepszoną mapę: {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="Szerokość FOV w stopniach")
ap.add_argument("--fov-h", type=float, default=1.33, help="Wysokość FOV w stopniach")
ap.add_argument("--grid", type=int, default=500, help="Rozdzielczość siatki (domyślnie 500 dla wyższej gładkości)")
ap.add_argument("--out", default="coverage_improved.png", help="Plik wyjściowy 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()
#Seestar #S50 #astrofotografia
— Marcin “czach” Trzaska reply-to: @czach@mastodon.argilus.online