sábado, 28 de febrero de 2026

Analizador tramas de calibración

La calibración de las imágenes capturadas es un paso esencial para conseguir unos resultados óptimos, por lo que la calidad de estas imágenes de calibración es crucial.

Con el objeto de determinar si estoy usando la configuración óptima para obtener mis flats he creado un script donde puedo analizar estas imágenes y así determinar su calidad.

He extendido su funcionalidad también a darks, biases y dark-flats para poder ver otros detalles interesantes como son el número de píxeles calientes, ruido MAD o nivel medio ADU

Además, los resultados se pueden ver gráficamente y se guardan en un archivo histórico, de forma que el script puede compararlo con el último análisis y ver así si hay degradación.


Creación y ejecución

Este programa es un script hecho en Python por lo que puede ejecutarse en cualquier ordenador que tenga instalado un intérprete de este lenguaje.

Aquí tenéis el código fuente. Simplemente tenéis que copiarlo, y pegarlo en un archivo de texto.

import os
import sys
import json
import numpy as np
import astropy.io.fits as fits
import matplotlib.pyplot as plt
from pathlib import Path
from datetime import datetime

from PyQt6.QtWidgets import (
QApplication, QWidget, QPushButton, QTextEdit, QRadioButton, QButtonGroup,
QFileDialog, QVBoxLayout, QLabel, QMessageBox, QHBoxLayout, QProgressBar
)
from PyQt6.QtCore import Qt, QThread, pyqtSignal, QObject

HISTORY_FILE = "calibration_history.json"

class AnalysisWorker(QObject):
finished = pyqtSignal(dict)
progress = pyqtSignal(int)
log = pyqtSignal(str)
error = pyqtSignal(str)

def __init__(self, files, mode, folder_path):
super().__init__()
self.files = files
self.mode = mode
self.folder_path = folder_path

def run(self):
try:
means, mads, times, temps = [], [], [], []
gains, offsets, hot_pixels_list = [], [], []
total = len(self.files)

for i, f in enumerate(self.files):
with fits.open(f, ignore_missing_end=True) as hdul:
data = hdul[0].data.astype(np.float64)
hdr = hdul[0].header
m = float(np.mean(data))
med = np.median(data)
std = np.std(data)
means.append(m)
mads.append(float(np.median(np.abs(data - med))))
times.append(hdr.get("EXPTIME", 0))
temps.append(hdr.get("CCD-TEMP", hdr.get("TEMP", 0)))
gains.append(hdr.get("GAIN", "N/A"))
offsets.append(hdr.get("OFFSET", "N/A"))

if self.mode in ["Darks", "Bias"]:
hot_pixels = np.sum(data > (m + 5*std))
hot_pixels_list.append(hot_pixels)

self.progress.emit(int(((i + 1) / total) * 100))

results = {
"date": datetime.now().strftime("%Y-%m-%d %H:%M"),
"mode": self.mode,
"folder": self.folder_path,
"means": np.array(means),
"mads": np.array(mads),
"times": np.array(times),
"temps": np.array(temps),
"gains": gains,
"offsets": offsets,
"hot_pixels": hot_pixels_list
}
self.finished.emit(results)
except Exception as e:
self.error.emit(str(e))

class MasterAnalyzer(QWidget):
def __init__(self):
super().__init__()
self.setWindowTitle("Astro-Calibration Suite v1.0")
self.resize(900, 800)
self.init_ui()

def init_ui(self):
layout = QVBoxLayout()
mode_layout = QHBoxLayout()
self.mode_group = QButtonGroup(self)
for m in ["Flats", "Darks", "Bias", "Dark-Flats"]:
rb = QRadioButton(m)
if m == "Flats": rb.setChecked(True)
mode_layout.addWidget(rb)
self.mode_group.addButton(rb)
layout.addLayout(mode_layout)

btn_layout = QHBoxLayout()
self.btn_run = QPushButton("Analizar Carpeta")
self.btn_run.clicked.connect(self.start_analysis)
btn_layout.addWidget(self.btn_run)
self.btn_plot = QPushButton("Ver Gráfica")
self.btn_plot.setEnabled(False)
self.btn_plot.clicked.connect(lambda: os.system('xdg-open last_analysis.png &'))
btn_layout.addWidget(self.btn_plot)
layout.addLayout(btn_layout)

self.pbar = QProgressBar()
layout.addWidget(self.pbar)

self.log_area = QTextEdit()
self.log_area.setReadOnly(True)
self.log_area.setStyleSheet("background-color: #121212; color: white; font-family: 'DejaVu Sans Mono', monospace; font-size: 13px;")
layout.addWidget(self.log_area)
self.setLayout(layout)

def save_to_history(self, current_data):
history = []
if os.path.exists(HISTORY_FILE):
try:
with open(HISTORY_FILE, "r") as f:
history = json.load(f)
except: history = []
entry = {
"date": current_data["date"],
"mode": current_data["mode"],
"folder": current_data["folder"],
"mean": float(np.mean(current_data["means"])),
"temp": float(np.mean(current_data["temps"])),
"hot_pixels": int(np.mean(current_data["hot_pixels"])) if current_data["hot_pixels"] else 0
}
# Buscar el último registro del mismo tipo para comparar
last_match = next((item for item in reversed(history) if item["mode"] == entry["mode"]), None)
history.append(entry)
with open(HISTORY_FILE, "w") as f:
json.dump(history, f, indent=4)
return last_match

def format_row(self, label, value, ref_text, is_ok):
color = "#2ecc71" if is_ok else "#e74c3c"
icon = "●" if is_ok else "⚠️"
safe_ref = ref_text.replace("<", "&lt;").replace(">", "&gt;")
return f"""
<div style='margin:2px 0;'>
<span style='color:white;'>{icon} <b>{label:22}</b></span>
<span style='color:{color}; font-weight: bold;'> {value:>10}</span>
<span style='color:#888;'>&nbsp;&nbsp;&nbsp;[Ref: {safe_ref}]</span>
</div>
"""

def start_analysis(self):
folder = QFileDialog.getExistingDirectory(self, "Seleccionar Carpeta")
if not folder: return
files = sorted([str(p) for p in Path(folder).iterdir() if p.suffix.lower() in (".fit", ".fits")])
if not files: return
self.btn_run.setEnabled(False)
self.log_area.clear()
self.thread = QThread()
self.worker = AnalysisWorker(files, self.mode_group.checkedButton().text(), folder)
self.worker.moveToThread(self.thread)
self.thread.started.connect(self.worker.run)
self.worker.progress.connect(self.pbar.setValue)
self.worker.finished.connect(self.process_results)
self.worker.finished.connect(self.thread.quit)
self.thread.start()

def process_results(self, data):
self.btn_run.setEnabled(True)
self.btn_plot.setEnabled(True)
# Guardar en JSON y obtener comparación
last_data = self.save_to_history(data)
# Gráfica
plt.figure(figsize=(8, 4))
plt.plot(data["means"], marker='o', color='#3498db')
plt.title(f"Estabilidad {data['mode']} - {data['date']}")
plt.grid(True, alpha=0.2)
plt.savefig("last_analysis.png")
plt.close()

m_val = np.mean(data["means"])
std_val = np.std(data["means"])
mad_val = np.mean(data["mads"])
avg_temp = np.mean(data["temps"])
avg_exp = data["times"][0]
html = f"<h2 style='color:#3498db; margin-bottom:0;'>📋 INFORME ANALÍTICO: {data['mode'].upper()}</h2>"
html += f"<p style='color:#bbb;'>Captura: {data['date']} | <b>Exp: {avg_exp}s</b> | Temp: {avg_temp:.1f}°C</p>"
html += f"<p style='color:#777; font-size: 11px;'>Path: {data['folder']}</p>"
if last_data:
dt = avg_temp - last_data["temp"]
diff_color = "#f1c40f" if abs(dt) > 2 else "#888"
html += f"<p style='color:{diff_color}; font-size: 12px;'><i>Comparación: Δ Temp vs anterior: {'+' if dt>=0 else ''}{dt:.1f}°C</i></p>"
html += "<hr color='#444'>"
if data["mode"] == "Flats":
html += self.format_row("Promedio ADU", f"{m_val:.1f}", "20.000 - 45.000", 20000 < m_val < 45000)
html += self.format_row("Estabilidad (σ)", f"{std_val:.2f}", "< 500.00", std_val < 500)
elif data["mode"] == "Darks":
hp = int(np.mean(data["hot_pixels"]))
html += self.format_row("Nivel Medio (Offset)", f"{m_val:.1f}", "1.500 - 2.500", 1500 < m_val < 2500)
html += self.format_row("Hot Pixels", f"{hp}", "< 10.000", hp < 10000)
if last_data:
d_hp = hp - last_data["hot_pixels"]
html += f"<p style='color:#888; margin-left:25px;'>Δ Hot Pixels vs anterior: {'+' if d_hp>=0 else ''}{d_hp}</p>"

elif data["mode"] == "Bias":
html += self.format_row("Nivel Medio (Offset)", f"{m_val:.1f}", "1.500 - 2.500", 1500 < m_val < 2500)
html += self.format_row("Ruido MAD", f"{mad_val:.2f}", "< 20.00", mad_val < 20)

elif data["mode"] == "Dark-Flats":
html += self.format_row("Nivel Medio ADU", f"{m_val:.1f}", "< 2.500", m_val < 2500)

html += "<hr color='#444'>"
self.log_area.setHtml(html)

if __name__ == "__main__":
app = QApplication(sys.argv)
window = MasterAnalyzer()
window.show()
sys.exit(app.exec())

Lo guardáis con el nombre que queráis y extensión .py -por ejemplo CalibrationAnalyzer.py- y lo ejecutáis siguiendo las instrucciones que se detallan a continuación.

Instrucciones para Windows

  • Descargar el último instalador disponible de la versión estable desde aquí: https://www.python.org/downloads/windows/
  • En la misma carpeta donde está el archivo script creáis un nuevo archivo ejecutar.bat y lo abrís con un editor de texto
  • Copiáis el siguiente texto y guardáis
@echo off
if not exist venv (
    echo Creando entorno virtual...
    python -m venv venv
    call venv\Scripts\activate
    pip install numpy matplotlib astropy PyQt6 rawpy
) else (
    call venv\Scripts\activate
)
python CalibrationAnalyzer.py
pause
Nota: donde pone CalibrationAnalyzer.py, cambiarlo por el nombre del archivo que hubierais puesto en el paso anterior.

Para ejecutar el script, simplemente deberéis abrir un explorador de archivos y hacer doble click en el archivo ejecutar.bat

La primera vez tardará más tiempo ya que debe descargar e instalar dependencias.

Instrucciones para Linux


Solo la primera vez:
  • Abrimos un terminal en la carpeta donde tengamos el script e instalamos dependencias
sudo apt install python3-numpy python3-matplotlib python3-scipy python3-pip python3.12-venv libxcb-cursor0
  • Creamos un entorno de ejecución para evitar instalar componentes para todo el sistema
python3 -m venv venv
source venv/bin/activate

  • Instalamos módulos necesarios 

pip install astropy rawpy
pip install matplotlib
pip install PyQt6

Si ya hemos hecho los pasos anteriores con anterioridad solo tenemos que abrir un terminal en la carpeta donde está el script y activar el entorno ya creado:

source venv/bin/activate

Tanto si lo hacemos por primera vez como en subsecuentes usos, para ejecutar el script:

python3 analyze_flats_gui.py

 

Uso y funcionamiento

El script es muy sencillo de utilizar. Tan solo debemos indicar el tipo de tramas de calibración (Flats, Darks, Bias y Dark-flats y seleccionar la carpeta donde se ubican los archivos en cuestión.
Conviene indicar que esos archivos deben pertenecer a una única sesión ya que si mezclamos imágenes de otras sesiones tomadas con otros tiempos de exposición, ganancia u offset, los resultados no serán correctos.

Una vez seleccionado el tipo de trama de calibración e indicado la carpeta, se realizará el análisis mostrando una barra de progreso durante la carga de cada archivo.
Al finalizar tendremos un pequeño resumen cuyo contenido dependerá del tipo de trama de calibración.
Podemos ver el análisis gráfico pulsando lel boton Ver Gráfica

Aquí os dejo unos ejemplos reales explicación mostrada

Análisis de flats

En el informe podemos ver en la cabecera la carpeta de trabajo seleccionada, fecha de los archivos, tiempo de exposición y temperatura promedio del sensor 


En el resumen se indicará el promedio de ADU y la estabilidad de las muestras así como los valores de referencia. Si estuvieran dentro de los límites, el valor aparecerá en verde. Si no, en rojo.
Si mostramos la gráfica, veremos los valores reales de cada captura. En el ejemplo se aprecia claramente como el brillo de los flats ha ido descendiendo ligeramente.
En este caso el panel flat usado dispone de un potenciómetro y posiblemente no hubiera esperado lo suficiente desde que ajusté su posición hasta que realicé la captura y también afectará seguramente que el tiempo de exposición es muy breve

Esta gráfica se corresponde con unos flats obtenidos por el método de camiseta + tablet :-)


Aunque la dispersión es menor, existen más picos y el valor medio se aleja de los 27-28K deseados

Análisisi de Darks

En este análisis se muestra el nivel medio (offset) ADU (Analog-to-Digital Units) así como un cálculo del número de píxeles 'calientes' detectados, siendo estos comparados con la muestra de darks anterior si la hubiera
En la gráfica se muestra el valor del offset en ADUs de cada muestra


Análisis de Biases

Además del Nivel Medio ADU, se muestra el ruido MAD calculado, y en la gráfica se muestra el valor real del offset en ADU

Análisis de Dark-Flats

Se muestra el nivel medio ADU y en la gráfica los valores de cada muestra 

Historial

Cada vez que se analiza un tipo de tramas de calibración se guarda la información más relevante para realizar comparaciones. 
El archivo tiene el nombre calibration_history.json y, como su extensión indica, utiliza el formato json para organizar la información.

Este es un ejemplo del contenido del archivo:  
[
{
"date": "2026-02-26 09:20",
"mode": "Flats",
"folder": "/media/mahg/14b24c2f-9fd8-42cf-9ac6-03d193d21d8d/home/mahg/Pictures/astro/Sh2_274/Flat",
"mean": 28420.115985328477,
"temp": 16.2075,
"hot_pixels": 0
},
{
"date": "2026-02-26 09:23",
"mode": "Flats",
"folder": "/media/mahg/14b24c2f-9fd8-42cf-9ac6-03d193d21d8d/home/mahg/Pictures/astro/Sh2_274/Flat",
"mean": 28420.115985328477,
"temp": 16.2075,
"hot_pixels": 0
},
{
"date": "2026-02-26 09:26",
"mode": "Darks",
"folder": "/media/mahg/14b24c2f-9fd8-42cf-9ac6-03d193d21d8d/home/mahg/Pictures/astro/Sh2_274/Dark",
"mean": 1703.8495726069702,
"temp": 17.54,
"hot_pixels": 918
},
{
"date": "2026-02-26 09:28",
"mode": "Bias",
"folder": "/media/mahg/14b24c2f-9fd8-42cf-9ac6-03d193d21d8d/home/mahg/Pictures/astro/Sh2_274/Bias",
"mean": 1701.804263334396,
"temp": 16.3,
"hot_pixels": 12742
},
{
"date": "2026-02-26 09:29",
"mode": "Dark-Flats",
"folder": "/media/mahg/14b24c2f-9fd8-42cf-9ac6-03d193d21d8d/home/mahg/Pictures/astro/Sh2_274/DarkFlat",
"mean": 1701.4514656147694,
"temp": 16.035,
"hot_pixels": 0
}
]

No hay comentarios:

Publicar un comentario

Cómo limpiar la lente objetivo de tu refractor

Con el uso, las condiciones climáticas -viento, humedad- y entorno -polvo- nuestra lente objetivo acaba por acumular suciedad. En este post ...