📜 Siril · Siril 1.4
Traitement OSC sans avoir besoin de copier les fichiers - TLB_ASTRO-OSC_Preprocessing v1.5
Ce script permet de préparer les fichiers lights et DOF avant de lancer le pré-traitement et l'empilement qui sont intégrés. Il automatise, via une interface graphique, la conversion, la calibration (bias/dark/flat, chacun optionnel), l'alignement et l'empilement dans Siril 1.4 — y compris quand la cible a été capturée sur plusieurs nuits différentes.
TLB_ASTRO-OSC_Preprocessing.py · 66 Ko
"""
TLB_ASTRO-OSC_Preprocessing.py
TLB Astro - Script Python (sirilpy) pour Siril 1.4+, avec interface
graphique (Tkinter) pour pretraiter et empiler une cible OSC unique,
capturee sur une ou plusieurs sessions (nuits). Une seule session
suffit pour un usage classique ; le formulaire permet d'en ajouter
d'autres si la cible a ete reprise sur plusieurs nuits.
Principe general :
- chaque session a son propre dossier de lights (obligatoire) et,
si besoin, son propre dark et/ou son propre flat (optionnels)
- un bias et un dark "partages" peuvent etre definis une seule fois
pour tout le projet ; une session ne les redefinit que si elle a
vraiment besoin d'un dark/bias different (capteur non refroidi,
changement de gain...). Regle appliquee : valeur de la session si
renseignee, sinon valeur partagee, sinon aucune.
- le flat reste TOUJOURS propre a chaque session (une caméra
demontee/remontee entre deux nuits change son motif de poussiere)
- chaque session est calibree separement (son propre dark/flat),
puis toutes les sessions calibrees sont fusionnees (par lien, pas
par copie) en une seule grande sequence pour un alignement et un
empilement globaux
- alignement en 2 passes (-2pass) : Siril choisit automatiquement la
meilleure image de reference sur l'ensemble des sessions, ce qui
reste pertinent meme a une seule session (choix automatique de la
meilleure brute) et devient important des qu'il y en a plusieurs
(qualite variable d'une nuit a l'autre)
- empilement par lots : avec plusieurs sessions, un stack partiel
par session (utile pour reperer une nuit de moins bonne qualite),
avec sous-decoupage supplementaire uniquement si une session, a
elle seule, depasse le seuil de securite (limite Windows du
nombre de fichiers ouverts simultanement, 2048 au maximum absolu ;
seuil par defaut 2000, modifiable dans l'interface) ; puis un
empilement final consolide tous les stacks partiels en un seul
resultat. Avec une seule session, le decoupage ne s'applique que
si elle depasse ce seuil a elle seule.
- drizzle (Bayer Drizzle) optionnel : si coche, l'interface demande
la longueur focale et le seeing typique du site pour calculer une
echelle de drizzle recommandee (modifiable).
Nom du fichier resultat, construit par le script (pas par les tokens
de path-parsing de Siril, qui ne seraient plus fiables une fois qu'on
empile des stacks de stacks) :
- temps de pose unitaire identique sur toutes les sessions :
result_<OBJECT>_<N>x<EXPTIME>s_<TEMPS_TOTAL>.fit
- temps de pose different d'une session a l'autre :
result_<OBJECT>_<N>poses_<M>sessions_<TEMPS_TOTAL>.fit
<OBJECT> et <EXPTIME> sont lus directement dans l'entete FITS des
brutes (mot-cle standard OBJECT / EXPTIME), pas dans un stack deja
empile, pour ne pas dependre du sort de ces informations une fois
fusionnees.
Un choix de capteurs courants (Player One, ZWO, QHY...) est proposé
dans l'interface, regroupes par capteur reel quand plusieurs cameras
partagent le meme (voir SENSOR_PROFILES) ; une entree "Autre" permet
de saisir directement une taille de pixel personnalisee.
"""
import os
import re
import sys
import glob
import tkinter as tk
from tkinter import ttk, filedialog, messagebox
import sirilpy as s
# ============================================================
# Metadonnees du script
# ============================================================
SCRIPT_VERSION = "1.5.0"
SCRIPT_NAME = "TLB_ASTRO-OSC_Preprocessing"
# Logo TLB Astro, encode en PNG base64 (redimensionne, integre pour que le
# script reste autonome, sans fichier image separe a distribuer)
LOGO_PNG_B64 = (
"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"
"p1qtsHD+zLNaINFlZ3RC3HT23WiFKrniLDMEhMRMza9NeGJwAUeOcwFrXSlnvPCv8txy7OTQ9y1kJ0oPJTLZI7tk8Rw23rSG2TOn"
"g+iQpkGdPnPs1Ojw6c8BbNu2zQGYLVu2hFKwsO+Wo0v2rFw+a9aEkEmpqCglCAvnTidLE5AI1733oKLuIkkVuZ3gpVUZayLYsw6U"
"TlFMyFgo2b+pgd8EOIzYSUB0VIuEgNYK788q0yY84uz5ORvQJ3ieUH6eK//+uisv4ZffdwdpmqAE6lVDpzPC0OixI5/+TNeB9evP"
"SHnCMYC/5557UuDoiaPDW4eGWxtmzOgtqdBIfWitcM7TKYrJXU5MBJU+QGEtSxZMJ000eeEmHzKEwLIlM+MC2p2o6xFK3c9UpYmU"
"tVKpOQqxrpo9s4vuriojo60fSSDR211j+dLZvOGqZdxx61qUEqz1KG0Q5WT3zmP5n3/m2L2/+4nWSBna/CSS3rJliwVk194jD151"
"fPG/mD17Wi9BBZUYcTbGW6WjUGlCQRabq440TRkZaXHX7dewdPF8Wq0WaWpQOnrXuksXYvMc633ZdY2FxxT2OSebe4KLXjTWbLHq"
"kjn8q1/5Jzz82EtnW8rWR4G6ji2q2J52hEDUQfrArJldLFsyi0a9yqpL5jJ/3jQK7+i0c4J3mOBwgE/69bqx1le3Tnz497Segwjh"
"pf3Hn1634/DutWsWXK9MhhKhUk1xNrZ9olVkkhbwHlKJVXWtlnDrTStx1pd9eAMERsab5IWdRM6h1A5OqXv3DleKPbWOKg0kEJTm"
"9g2XccVls2i1LbhAxzq0KBqNKtoY2p2cVrNFURR0cof3HqU1tWpGkRd08pzBwVFqtZTgPONNS1e38QP9x9WBg6f+91ZwU6k7wkc/"
"igLshjv7/2TPnsNWqzDZXMsqGdpotERRpmiDSVKMMbjgEaXwzjN4ZoShkSbDw02GhsYYHBzDWY+IKnWMIQqbXGzyTckC+hjLoig0"
"xrs8bzHeakZ8FCSOTWmFLsUQlWpKtZJgjEHrKGaoVBIgMDrWotUp8EHIiyhK73QcIZjgrA/bdx4++qefO/wJzpIvF6o7yoEP/+Ff"
"PfTJZ5469KdDpwdFG4KEyLYpKRWsOi5McATvMaoUjpsEbTLSpBR2y1meR8oq2rsQu56ip9a7a41OM5I0Q6sUJQkgeBcbfJ1OfC+8"
"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"
"ST/D/wfXpze7SSRmDAAAAABJRU5ErkJggg=="
)
# ============================================================
# Palette "TLB Astro" en mode sombre
# ============================================================
BG_DARK = "#0f1c33" # fond general de la fenetre
BG_PANEL = "#152744" # fond des lignes/panneaux
BG_HEADER = "#1a3a6c" # bandeau d'en-tete (navy de la charte)
BG_ENTRY = "#1e2d47" # fond des champs de saisie
ACCENT = "#c9971f" # or (charte TLB Astro)
BORDER = "#2c5aa0" # navy clair (charte)
TEXT_LIGHT = "#e8edf5"
TEXT_MUTED = "#93a4c2"
def apply_dark_theme(root):
"""Applique un theme sombre coherent avec la charte TLB Astro a tous
les widgets ttk de la fenetre."""
root.configure(bg=BG_DARK)
style = ttk.Style(root)
style.theme_use("clam")
style.configure("TFrame", background=BG_DARK)
style.configure("Panel.TFrame", background=BG_PANEL)
style.configure("Header.TFrame", background=BG_HEADER)
style.configure("TLabel", background=BG_DARK, foreground=TEXT_LIGHT)
style.configure("Panel.TLabel", background=BG_PANEL, foreground=TEXT_LIGHT)
style.configure("Header.TLabel", background=BG_HEADER, foreground=TEXT_LIGHT)
style.configure("Muted.TLabel", background=BG_DARK, foreground=TEXT_MUTED)
style.configure("Version.TLabel", background=BG_HEADER, foreground="#c7d3e8",
font=("", 8))
style.configure("Accent.TLabel", background=BG_DARK, foreground=ACCENT,
font=("", 9, "bold"))
style.configure("TEntry", fieldbackground=BG_ENTRY, foreground=TEXT_LIGHT,
insertcolor=TEXT_LIGHT, bordercolor=BORDER,
lightcolor=BG_ENTRY, darkcolor=BG_ENTRY)
style.configure("TButton", background=BORDER, foreground=TEXT_LIGHT,
borderwidth=0, padding=6, focuscolor=BORDER)
style.map("TButton", background=[("active", ACCENT)],
foreground=[("active", "#1a1a1a")])
style.configure("Accent.TButton", background=ACCENT, foreground="#1a1a1a",
borderwidth=0, padding=6)
style.map("Accent.TButton", background=[("active", "#e0af2c")])
style.configure("TScrollbar", background=BG_PANEL, troughcolor=BG_DARK,
bordercolor=BG_DARK, arrowcolor=TEXT_LIGHT)
style.configure("TCheckbutton", background=BG_DARK, foreground=TEXT_LIGHT,
indicatorbackground=BG_ENTRY, indicatordiameter=13,
focuscolor=BG_DARK)
style.map("TCheckbutton",
indicatorbackground=[("selected", ACCENT), ("active", BORDER)],
background=[("active", BG_DARK)],
foreground=[("active", TEXT_LIGHT)])
style.configure("TSeparator", background=BORDER)
return style
def norm_path(path):
"""Normalise l'affichage d'un chemin en '/' uniquement, pour eviter le
melange '/' et '\\' dans les champs de saisie (ex: chemins construits
avec os.path.join() sous Windows, mixes avec des chemins renvoyes en
'/' par les boites de dialogue)."""
return path.replace("\\", "/") if path else path
def make_logo_image():
"""Cree une image Tkinter a partir du logo TLB Astro embarque."""
try:
return tk.PhotoImage(data=LOGO_PNG_B64)
except Exception:
return None
def show_about(parent):
"""Fenetre 'A propos' : logo, nom du script, version, courte description."""
win = tk.Toplevel(parent)
win.title("A propos")
win.configure(bg=BG_DARK)
win.resizable(False, False)
win.transient(parent)
win.grab_set()
logo_img = make_logo_image()
if logo_img is not None:
win.logo_ref = logo_img # eviter le garbage collection
ttk.Label(win, image=logo_img, style="TLabel").pack(pady=(20, 8))
ttk.Label(win, text="TLB Astro", font=("", 13, "bold")).pack()
ttk.Label(win, text=f"{SCRIPT_NAME}.py — version {SCRIPT_VERSION}",
style="Muted.TLabel").pack(pady=(0, 12))
ttk.Label(
win,
text="Pretraitement et empilement d'une cible OSC unique,\n"
"capturee sur une ou plusieurs sessions (nuits). Calibration\n"
"adaptative bias/dark/flat, drizzle optionnel, pour Siril 1.4+.",
justify="center",
).pack(padx=24, pady=(0, 16))
ttk.Button(win, text="Fermer", style="Accent.TButton",
command=win.destroy).pack(pady=(0, 18))
class FollowingPath:
"""Gere un champ de chemin qui se met a jour automatiquement en
fonction du repertoire de travail (ex: biases/ ou darks/ a l'interieur
de celui-ci), jusqu'a ce que l'utilisateur le modifie lui-meme (saisie
manuelle ou bouton Parcourir) : le suivi automatique s'arrete alors
pour ce champ, meme si le repertoire de travail change ensuite."""
def __init__(self, var, workdir_var, subfolder):
self.var = var
self.workdir_var = workdir_var
self.subfolder = subfolder
self.auto = True
self._updating = False
workdir_var.trace_add("write", self._on_workdir_change)
var.trace_add("write", self._on_var_change)
self._on_workdir_change()
def _on_workdir_change(self, *_):
if self.auto:
wd = self.workdir_var.get().strip()
self._updating = True
self.var.set(norm_path(os.path.join(wd, self.subfolder)) if wd else "")
self._updating = False
def _on_var_change(self, *_):
if not self._updating:
self.auto = False
def browse(self):
d = filedialog.askdirectory(title=f"Choisir le dossier {self.subfolder}")
if d:
self.auto = False
self.var.set(norm_path(d))
def sanitize_name(name):
"""Nettoie un nom de panneau pour un usage sûr dans des chemins de fichiers."""
name = name.strip().replace(" ", "_")
name = re.sub(r"[^A-Za-z0-9_\-]", "", name)
return name
def read_fits_header(path, max_blocks=40):
"""Lit les cartes d'en-tete FITS d'un fichier, sans dependance externe
(pas besoin d'astropy). Un en-tete FITS est une suite de blocs de
2880 octets, chacun compose de 36 cartes de 80 caracteres ASCII ;
la lecture s'arrete a la carte "END" ou apres max_blocks blocs (pour
ne pas lire tout le fichier si l'en-tete est corrompu/atypique).
Renvoie un dict {mot-cle: valeur (str ou float)}."""
header = {}
with open(path, "rb") as f:
for _ in range(max_blocks):
block = f.read(2880)
if len(block) < 2880:
break
for i in range(0, 2880, 80):
card = block[i:i + 80].decode("ascii", errors="replace")
key = card[0:8].strip()
if key == "END":
return header
if not key or key in ("COMMENT", "HISTORY"):
continue
if len(card) >= 10 and card[8:10] == "= ":
value_part = card[10:].split("/")[0].strip()
if value_part.startswith("'"):
value = value_part.strip("'").strip()
else:
try:
value = float(value_part)
except ValueError:
value = value_part
header[key] = value
return header
def read_session_object_exptime(light_dir):
"""Lit OBJECT et EXPTIME dans le premier fichier FITS trouve dans
light_dir (on suppose un temps de pose uniforme au sein d'une meme
session, ce qui est la pratique courante). Renvoie (objet, exptime)
avec None pour ce qui n'a pas pu etre lu."""
candidates = sorted(
glob.glob(os.path.join(light_dir, "*.fit"))
+ glob.glob(os.path.join(light_dir, "*.fits"))
)
if not candidates:
return None, None
header = read_fits_header(candidates[0])
obj = header.get("OBJECT")
exptime = header.get("EXPTIME")
if exptime is None:
exptime = header.get("EXPOSURE")
if isinstance(obj, str):
obj = obj.strip()
if isinstance(exptime, str):
try:
exptime = float(exptime)
except ValueError:
exptime = None
return obj, exptime
def format_duration(total_seconds):
"""Formate une duree en secondes en une chaine lisible, ex: '15h00m',
'45m30s', '38s'. N'affiche que les unites necessaires."""
total_seconds = int(round(total_seconds))
hours, rem = divmod(total_seconds, 3600)
minutes, seconds = divmod(rem, 60)
if hours:
return f"{hours}h{minutes:02d}m" if seconds == 0 else f"{hours}h{minutes:02d}m{seconds:02d}s"
if minutes:
return f"{minutes}m{seconds:02d}s" if seconds else f"{minutes}m"
return f"{seconds}s"
def count_fits_files(light_dir):
"""Compte les brutes (FITS) presentes dans un dossier de lights."""
return len(
glob.glob(os.path.join(light_dir, "*.fit"))
+ glob.glob(os.path.join(light_dir, "*.fits"))
)
def link_or_copy(src, dst):
"""Cree un lien physique (hard link) vers src a l'emplacement dst,
ce qui evite toute copie physique des donnees (fonctionne sans
droits particuliers sous Windows, contrairement au lien symbolique,
tant que src et dst sont sur le meme volume). Repli sur une copie
uniquement si le lien physique echoue (ex: volumes differents)."""
try:
os.link(src, dst)
except OSError:
import shutil
shutil.copy2(src, dst)
def compute_chunks(total, threshold):
"""Decoupe l'intervalle [1..total] (base 1, inclusif) en tranches
contigues d'au plus 'threshold' elements. Renvoie une liste de
tuples (start, end)."""
chunks = []
start = 1
while start <= total:
end = min(start + threshold - 1, total)
chunks.append((start, end))
start = end + 1
return chunks
# ============================================================
# Profils capteur
# ------------------------------------------------------------
# Plusieurs capteurs geres, regroupes par capteur reel (le parametre
# qui compte pour le calcul du drizzle) meme quand plusieurs marques
# vendent des cameras autour du meme capteur Sony. Une entree
# "Autre" permet de saisir directement une taille de pixel si la
# camera de l'utilisateur n'est pas dans la liste.
# ============================================================
SENSOR_PROFILES = {
"Player One Artemis-C Pro / ZWO ASI294MC Pro / QHY294C Pro (IMX294)": {
"pixel_um": 4.63,
},
"Player One Uranus-C / ZWO ASI585MC Pro (IMX585)": {
"pixel_um": 2.90,
},
"ZWO ASI183MC Pro / QHY183C (IMX183)": {
"pixel_um": 2.40,
},
"Player One Neptune-C II / ZWO ASI662MC / iOptron iCam 464C (IMX464)": {
"pixel_um": 2.90,
},
"ZWO ASI533MC Pro / Player One Saturn-C / QHY533C (IMX533)": {
"pixel_um": 3.76,
},
"ZWO ASI2600MC Pro / QHY268C (IMX571)": {
"pixel_um": 3.76,
},
"ZWO ASI6200MC Pro / QHY600C (IMX455)": {
"pixel_um": 3.76,
},
}
DEFAULT_SENSOR = "Player One Artemis-C Pro / ZWO ASI294MC Pro / QHY294C Pro (IMX294)"
# Entree speciale (n'est pas une clef de SENSOR_PROFILES) : selectionnee,
# elle fait apparaitre un champ de saisie libre pour la taille de pixel,
# pour toute camera absente de la liste ci-dessus.
CUSTOM_SENSOR_LABEL = "Autre (taille de pixel personnalisée)"
def compute_sampling_and_scale(pixel_um, focal_mm, seeing_arcsec):
"""Calcule l'echantillonnage (secondes d'arc/pixel), l'echantillonnage
ideal (seeing/2, critere de Nyquist usuel) et l'echelle de drizzle
recommandee (1.0 si deja bien echantillonne, jusqu'a 3.0 si tres
sous-echantillonne). Leve ValueError si pixel_um, focal_mm ou
seeing_arcsec ne sont pas des nombres strictement positifs."""
if pixel_um <= 0 or focal_mm <= 0 or seeing_arcsec <= 0:
raise ValueError("La taille de pixel, la longueur focale et le seeing "
"doivent être positifs.")
sampling = 206.265 * pixel_um / focal_mm
ideal_sampling = seeing_arcsec / 2.0
ratio = sampling / ideal_sampling
recommended_scale = round(min(max(ratio, 1.0), 3.0), 1)
return sampling, ideal_sampling, recommended_scale
class OptionalDirField:
"""Un champ de dossier optionnel (biases / darks / flats) : Entree +
Parcourir + Effacer, avec suivi automatique (FollowingPath) d'un
dossier parent tant que l'utilisateur n'a rien saisi ou efface
lui-meme. Un champ laisse vide desactive l'etape correspondante."""
def __init__(self, parent, row, label, traitement_var, subfolder):
ttk.Label(parent, text=label).grid(
row=row, column=0, sticky="w", pady=(4, 0))
self.var = tk.StringVar()
ttk.Entry(parent, textvariable=self.var).grid(
row=row, column=1, padx=6, pady=(4, 0), sticky="ew")
self.follow = FollowingPath(self.var, traitement_var, subfolder)
ttk.Button(parent, text="Parcourir…", command=self.follow.browse).grid(
row=row, column=2, padx=2, pady=(4, 0))
ttk.Button(parent, text="Effacer", command=self.clear).grid(
row=row, column=3, padx=(2, 0), pady=(4, 0))
def clear(self):
# Efface le champ ; le trace de FollowingPath desactive alors
# automatiquement le suivi (voir _on_var_change), donc le champ
# reste vide meme si le repertoire de traitement change ensuite.
self.var.set("")
def get(self):
return self.var.get().strip()
class SessionRow:
"""Une ligne de la liste des sessions (nuits) dans l'interface."""
def __init__(self, parent, index, remove_callback):
self.frame = ttk.Frame(parent, padding=(4, 6), style="Panel.TFrame")
self.frame.pack(fill="x", expand=True, pady=2)
self.remove_callback = remove_callback
ttk.Label(self.frame, text="Nom :", style="Panel.TLabel").grid(
row=0, column=0, sticky="w")
self.name_var = tk.StringVar(value=f"session{index}")
ttk.Entry(self.frame, textvariable=self.name_var, width=14).grid(
row=0, column=1, padx=4)
ttk.Label(self.frame, text="Dossier lights :", style="Panel.TLabel").grid(
row=0, column=2, sticky="w")
self.light_var = tk.StringVar(value="")
ttk.Entry(self.frame, textvariable=self.light_var, width=32).grid(
row=0, column=3, padx=4)
ttk.Button(self.frame, text="Parcourir…", command=self.browse_light).grid(
row=0, column=4, padx=2)
ttk.Button(self.frame, text="Supprimer cette session",
command=self.remove).grid(row=0, column=5, padx=(10, 0))
ttk.Label(self.frame, text="Bias (si different du partage) :",
style="Panel.TLabel").grid(row=1, column=0, columnspan=2,
sticky="w", pady=(4, 0))
self.bias_var = tk.StringVar(value="")
ttk.Entry(self.frame, textvariable=self.bias_var, width=32).grid(
row=1, column=3, padx=4, pady=(4, 0))
ttk.Button(self.frame, text="Parcourir…", command=self.browse_bias).grid(
row=1, column=4, padx=2, pady=(4, 0))
ttk.Button(self.frame, text="Effacer", command=self.clear_bias).grid(
row=1, column=5, padx=(10, 0), pady=(4, 0))
ttk.Label(self.frame, text="Dark (si different du partage) :",
style="Panel.TLabel").grid(row=2, column=0, columnspan=2,
sticky="w", pady=(4, 0))
self.dark_var = tk.StringVar(value="")
ttk.Entry(self.frame, textvariable=self.dark_var, width=32).grid(
row=2, column=3, padx=4, pady=(4, 0))
ttk.Button(self.frame, text="Parcourir…", command=self.browse_dark).grid(
row=2, column=4, padx=2, pady=(4, 0))
ttk.Button(self.frame, text="Effacer", command=self.clear_dark).grid(
row=2, column=5, padx=(10, 0), pady=(4, 0))
ttk.Label(self.frame, text="Flat (optionnel) :", style="Panel.TLabel").grid(
row=3, column=0, columnspan=2, sticky="w", pady=(4, 0))
self.flat_var = tk.StringVar(value="")
ttk.Entry(self.frame, textvariable=self.flat_var, width=32).grid(
row=3, column=3, padx=4, pady=(4, 0))
ttk.Button(self.frame, text="Parcourir…", command=self.browse_flat).grid(
row=3, column=4, padx=2, pady=(4, 0))
ttk.Button(self.frame, text="Effacer", command=self.clear_flat).grid(
row=3, column=5, padx=(10, 0), pady=(4, 0))
ttk.Separator(self.frame, orient="horizontal").grid(
row=4, column=0, columnspan=6, sticky="ew", pady=(6, 0))
def browse_light(self):
d = filedialog.askdirectory(title="Choisir le dossier des lights de cette session")
if d:
self.light_var.set(norm_path(d))
if not self.name_var.get().strip():
self.name_var.set(os.path.basename(os.path.normpath(d)))
def browse_bias(self):
d = filedialog.askdirectory(title="Choisir le dossier de bias pour cette session")
if d:
self.bias_var.set(norm_path(d))
def browse_dark(self):
d = filedialog.askdirectory(title="Choisir le dossier de dark pour cette session")
if d:
self.dark_var.set(norm_path(d))
def browse_flat(self):
d = filedialog.askdirectory(title="Choisir le dossier de flat pour cette session")
if d:
self.flat_var.set(norm_path(d))
def clear_bias(self):
self.bias_var.set("")
def clear_dark(self):
self.dark_var.set("")
def clear_flat(self):
self.flat_var.set("")
def remove(self):
self.frame.destroy()
self.remove_callback(self)
def get_values(self):
return {
"name": self.name_var.get().strip(),
"light_dir": self.light_var.get().strip(),
"bias_dir": self.bias_var.get().strip() or None,
"dark_dir": self.dark_var.get().strip() or None,
"flat_dir": self.flat_var.get().strip() or None,
}
class DrizzleScaleTracker:
"""Calcule automatiquement l'echelle de drizzle recommandee a partir de
la longueur focale et du seeing renseignes, tant que l'utilisateur n'a
pas modifie lui-meme le champ d'echelle (meme principe que
FollowingPath : l'auto-calcul s'arrete des que l'utilisateur intervient
sur le champ resultat)."""
def __init__(self, scale_var, focal_var, seeing_var, pixel_um, sampling_label_var):
self.scale_var = scale_var
self.focal_var = focal_var
self.seeing_var = seeing_var
self.pixel_um = pixel_um
self.sampling_label_var = sampling_label_var
self.auto = True
self._updating = False
focal_var.trace_add("write", self._recompute)
seeing_var.trace_add("write", self._recompute)
scale_var.trace_add("write", self._on_scale_change)
self._recompute()
def _recompute(self, *_):
try:
focal_mm = float(self.focal_var.get())
seeing_arcsec = float(self.seeing_var.get())
sampling, ideal, recommended = compute_sampling_and_scale(
self.pixel_um, focal_mm, seeing_arcsec)
except ValueError:
self.sampling_label_var.set("Échantillonnage : (valeurs invalides)")
return
self.sampling_label_var.set(
f"Échantillonnage : {sampling:.2f}\"/px — idéal (seeing/2) : {ideal:.2f}\"/px"
)
if self.auto:
self._updating = True
self.scale_var.set(f"{recommended}")
self._updating = False
def _on_scale_change(self, *_):
if not self._updating:
self.auto = False
class MultiSessionConfigGUI:
def __init__(self, default_workdir=""):
self.result = None
self.session_rows = []
self.root = tk.Tk()
self.root.title(f"TLB Astro — Configuration pré-traitement OSC — v{SCRIPT_VERSION}")
self.root.geometry("880x660")
apply_dark_theme(self.root)
# ---------- Bandeau d'en-tete ----------
header = ttk.Frame(self.root, padding=(16, 12), style="Header.TFrame")
header.pack(fill="x")
logo_img = make_logo_image()
if logo_img is not None:
self.logo_ref = logo_img
ttk.Label(header, image=logo_img, style="Header.TLabel").pack(side="left")
title_box = ttk.Frame(header, style="Header.TFrame")
title_box.pack(side="left", padx=12)
ttk.Label(title_box, text="TLB Astro — Pré-traitement OSC",
style="Header.TLabel", font=("", 12, "bold")).pack(anchor="w")
ttk.Label(title_box, text=f"{SCRIPT_NAME}.py — v{SCRIPT_VERSION}",
style="Version.TLabel").pack(anchor="w")
ttk.Button(header, text="À propos", command=lambda: show_about(self.root)).pack(
side="right")
# ---------- Répertoire de traitement ----------
top = ttk.Frame(self.root, padding=10)
top.pack(fill="x")
ttk.Label(top, text="Répertoire de traitement :", font=("", 10, "bold")).pack(
side="left")
self.traitement_var = tk.StringVar(value=default_workdir)
ttk.Entry(top, textvariable=self.traitement_var, width=50).pack(side="left", padx=6)
ttk.Button(top, text="Parcourir…", command=self.browse_traitement).pack(side="left")
ttk.Label(
self.root,
text="Recevra le sous-dossier process/ et le fichier résultat final.",
style="Muted.TLabel",
wraplength=820,
).pack(anchor="w", padx=12)
# ---------- Bias / Dark partages ----------
shared = ttk.Frame(self.root, padding=(10, 10, 10, 4))
shared.pack(fill="x")
shared.columnconfigure(1, weight=1)
self.shared_bias_field = OptionalDirField(
shared, 0, "Bias partagé (optionnel) :", self.traitement_var, "biases")
self.shared_dark_field = OptionalDirField(
shared, 1, "Dark partagé (optionnel) :", self.traitement_var, "darks")
ttk.Label(
self.root,
text="Utilisés pour toutes les sessions qui ne définissent pas leur propre "
"bias/dark ci-dessous (ex : capteur refroidi → un seul dark partagé pour "
"tout le projet). Une session avec son propre bias/dark ignore le partagé "
"pour ce seul réglage.",
style="Muted.TLabel",
wraplength=820,
).pack(anchor="w", padx=12, pady=(0, 4))
# ---------- Sessions ----------
mid_label = ttk.Frame(self.root, padding=(10, 10, 10, 0))
mid_label.pack(fill="x")
ttk.Label(mid_label, text="Sessions (une par défaut, ajoute-en si la cible a été "
"reprise sur plusieurs nuits) :",
font=("", 10, "bold")).pack(side="left")
ttk.Button(mid_label, text="+ Ajouter une session", command=self.add_session).pack(
side="right")
sessions_area = ttk.Frame(self.root)
sessions_area.pack(fill="both", expand=True, padx=10, pady=6)
canvas = tk.Canvas(sessions_area, borderwidth=0, highlightthickness=0, bg=BG_DARK)
scrollbar = ttk.Scrollbar(sessions_area, orient="vertical", command=canvas.yview)
self.sessions_frame = ttk.Frame(canvas)
self.sessions_frame.bind(
"<Configure>",
lambda e: canvas.configure(scrollregion=canvas.bbox("all")),
)
canvas.create_window((0, 0), window=self.sessions_frame, anchor="nw")
canvas.configure(yscrollcommand=scrollbar.set)
canvas.pack(side="left", fill="both", expand=True)
scrollbar.pack(side="right", fill="y")
# Une seule session vide par defaut : le cas courant (une nuit) ne
# doit pas obliger a supprimer une ligne inutile.
self.add_session()
# ---------- Options ----------
options = ttk.Frame(self.root, padding=(10, 8, 10, 4))
options.pack(fill="x")
self.drizzle_var = tk.BooleanVar(value=False)
ttk.Checkbutton(
options, text="Utiliser le drizzle (Bayer Drizzle)",
variable=self.drizzle_var, command=self.toggle_drizzle_params,
).grid(row=0, column=0, sticky="w", columnspan=2)
# --- Parametres drizzle, affiches uniquement si la case est cochee ---
self.drizzle_params_frame = ttk.Frame(options, padding=(20, 6, 0, 4))
self.drizzle_params_frame.grid(row=1, column=0, columnspan=2, sticky="w")
ttk.Label(self.drizzle_params_frame, text="Longueur focale (mm) :").grid(
row=0, column=0, sticky="w")
self.focal_var = tk.StringVar(value="750")
ttk.Entry(self.drizzle_params_frame, textvariable=self.focal_var, width=8).grid(
row=0, column=1, sticky="w", padx=6)
ttk.Label(self.drizzle_params_frame, text="Seeing typique du site (\", FWHM) :").grid(
row=1, column=0, sticky="w", pady=(4, 0))
self.seeing_var = tk.StringVar(value="2.5")
ttk.Entry(self.drizzle_params_frame, textvariable=self.seeing_var, width=8).grid(
row=1, column=1, sticky="w", padx=6, pady=(4, 0))
self.sampling_label_var = tk.StringVar(value="")
ttk.Label(self.drizzle_params_frame, textvariable=self.sampling_label_var,
style="Accent.TLabel").grid(row=2, column=0, columnspan=2, sticky="w",
pady=(4, 0))
ttk.Label(self.drizzle_params_frame,
text="Échelle à utiliser (calculée automatiquement, modifiable) :").grid(
row=3, column=0, sticky="w", pady=(4, 0))
self.scale_var = tk.StringVar(value="1.0")
ttk.Entry(self.drizzle_params_frame, textvariable=self.scale_var, width=8).grid(
row=3, column=1, sticky="w", padx=6, pady=(4, 0))
pixel_um = SENSOR_PROFILES[DEFAULT_SENSOR]["pixel_um"]
self.drizzle_tracker = DrizzleScaleTracker(
self.scale_var, self.focal_var, self.seeing_var, pixel_um,
self.sampling_label_var)
if not self.drizzle_var.get():
self.drizzle_params_frame.grid_remove()
ttk.Label(options, text="Capteur :").grid(row=2, column=0, sticky="w", pady=(8, 0))
self.sensor_var = tk.StringVar(value=DEFAULT_SENSOR)
sensor_combo = ttk.Combobox(
options, textvariable=self.sensor_var,
values=list(SENSOR_PROFILES) + [CUSTOM_SENSOR_LABEL],
state="readonly", width=52)
sensor_combo.grid(row=2, column=1, sticky="w", padx=6, pady=(8, 0))
# --- Taille de pixel personnalisee, affichee seulement pour "Autre" ---
self.custom_sensor_frame = ttk.Frame(options, padding=(20, 4, 0, 0))
self.custom_sensor_frame.grid(row=3, column=0, columnspan=2, sticky="w")
ttk.Label(self.custom_sensor_frame, text="Taille de pixel (µm) :").grid(
row=0, column=0, sticky="w")
self.custom_pixel_var = tk.StringVar(value=f"{pixel_um}")
ttk.Entry(self.custom_sensor_frame, textvariable=self.custom_pixel_var, width=8).grid(
row=0, column=1, sticky="w", padx=6)
self.custom_sensor_frame.grid_remove()
self.sensor_var.trace_add("write", self.on_sensor_change)
self.custom_pixel_var.trace_add("write", self.on_custom_pixel_change)
ttk.Label(options, text="Seuil de découpage par lot :").grid(
row=4, column=0, sticky="w", pady=(8, 0))
self.threshold_var = tk.StringVar(value="2000")
ttk.Entry(options, textvariable=self.threshold_var, width=8).grid(
row=4, column=1, sticky="w", padx=6, pady=(8, 0))
ttk.Label(
options,
text="(limite Windows du nombre de fichiers ouverts simultanément : 2048 au "
"maximum absolu — garder une marge de sécurité)",
style="Muted.TLabel",
).grid(row=5, column=0, columnspan=2, sticky="w")
# ---------- Boutons Annuler / Go ----------
bottom = ttk.Frame(self.root, padding=10)
bottom.pack(fill="x", side="bottom")
ttk.Button(bottom, text="Go", style="Accent.TButton", command=self.on_go).pack(
side="right", padx=4)
ttk.Button(bottom, text="Annuler", command=self.on_cancel).pack(
side="right", padx=4)
self.root.protocol("WM_DELETE_WINDOW", self.on_cancel)
def toggle_drizzle_params(self):
if self.drizzle_var.get():
self.drizzle_params_frame.grid()
else:
self.drizzle_params_frame.grid_remove()
def on_sensor_change(self, *_):
if self.sensor_var.get() == CUSTOM_SENSOR_LABEL:
self.custom_sensor_frame.grid()
self.on_custom_pixel_change()
else:
self.custom_sensor_frame.grid_remove()
pixel_um = SENSOR_PROFILES[self.sensor_var.get()]["pixel_um"]
self.drizzle_tracker.pixel_um = pixel_um
self.drizzle_tracker._recompute()
def on_custom_pixel_change(self, *_):
if self.sensor_var.get() != CUSTOM_SENSOR_LABEL:
return
try:
pixel_um = float(self.custom_pixel_var.get().strip())
except ValueError:
return
self.drizzle_tracker.pixel_um = pixel_um
self.drizzle_tracker._recompute()
def browse_traitement(self):
d = filedialog.askdirectory(title="Choisir le répertoire de traitement")
if d:
self.traitement_var.set(norm_path(d))
def add_session(self):
index = len(self.session_rows) + 1
row = SessionRow(self.sessions_frame, index, self.remove_session)
self.session_rows.append(row)
def remove_session(self, row):
if row in self.session_rows:
self.session_rows.remove(row)
def on_cancel(self):
self.result = None
self.root.destroy()
def on_go(self):
traitement_dir = self.traitement_var.get().strip()
if not traitement_dir or not os.path.isdir(traitement_dir):
messagebox.showerror("Erreur", "Répertoire de traitement invalide ou vide.")
return
shared_bias_dir = self.shared_bias_field.get()
if shared_bias_dir and not os.path.isdir(shared_bias_dir):
messagebox.showerror("Erreur", "Dossier de bias partagé invalide.")
return
shared_dark_dir = self.shared_dark_field.get()
if shared_dark_dir and not os.path.isdir(shared_dark_dir):
messagebox.showerror("Erreur", "Dossier de dark partagé invalide.")
return
try:
threshold = int(self.threshold_var.get().strip())
if threshold <= 0:
raise ValueError
except ValueError:
messagebox.showerror(
"Erreur", "Le seuil de découpage par lot doit être un entier positif.")
return
if threshold > 2048:
messagebox.showerror(
"Erreur", "Le seuil de découpage par lot doit rester ≤ 2048 "
"(limite absolue de Windows).")
return
drizzle = self.drizzle_var.get()
focal_length = None
seeing = None
drizzle_scale = None
if drizzle:
try:
focal_length = float(self.focal_var.get().strip())
seeing = float(self.seeing_var.get().strip())
drizzle_scale = float(self.scale_var.get().strip())
if focal_length <= 0 or seeing <= 0 or drizzle_scale <= 0:
raise ValueError
except ValueError:
messagebox.showerror(
"Erreur", "Longueur focale, seeing et échelle de drizzle doivent être "
"des nombres positifs.")
return
if self.sensor_var.get() == CUSTOM_SENSOR_LABEL:
try:
custom_pixel = float(self.custom_pixel_var.get().strip())
if custom_pixel <= 0:
raise ValueError
except ValueError:
messagebox.showerror(
"Erreur", "La taille de pixel personnalisée doit être un nombre "
"positif.")
return
sessions = [r.get_values() for r in self.session_rows]
if not sessions:
messagebox.showerror("Erreur", "Ajoute au moins une session.")
return
seen_names = set()
for sess in sessions:
if not sess["light_dir"] or not os.path.isdir(sess["light_dir"]):
messagebox.showerror(
"Erreur", f"Dossier de lights invalide pour la session "
f"« {sess['name'] or '(sans nom)'} ».")
return
for key, label in (("bias_dir", "bias"), ("dark_dir", "dark"), ("flat_dir", "flat")):
if sess[key] and not os.path.isdir(sess[key]):
messagebox.showerror(
"Erreur", f"Dossier de {label} invalide pour la session "
f"« {sess['name']} ».")
return
name = sanitize_name(sess["name"]) or f"session{len(seen_names) + 1}"
if name in seen_names:
messagebox.showerror(
"Erreur", f"Nom de session en double : « {name} ». "
f"Donne un nom unique à chaque session.")
return
seen_names.add(name)
sess["name"] = name
self.result = {
"traitement_dir": traitement_dir,
"shared_bias_dir": shared_bias_dir or None,
"shared_dark_dir": shared_dark_dir or None,
"sessions": sessions,
"drizzle": drizzle,
"focal_length_mm": focal_length,
"seeing_arcsec": seeing,
"drizzle_scale": drizzle_scale,
"sensor": self.sensor_var.get(),
"threshold": threshold,
}
self.root.destroy()
def run(self):
self.root.mainloop()
return self.result
# ============================================================
# Connexion a Siril (AVANT la fenetre, pour recuperer son
# repertoire de travail actuel et pre-remplir le champ)
# ============================================================
siril = s.SirilInterface()
try:
siril.connect()
except Exception as e:
print(f"Connexion a Siril impossible : {e}")
raise SystemExit
try:
default_workdir = siril.get_siril_wd()
except Exception:
default_workdir = ""
# ============================================================
# Lancement de l'interface, pre-remplie avec le wd de Siril
# ============================================================
gui = MultiSessionConfigGUI(default_workdir=default_workdir)
config = gui.run()
if config is None:
siril.log("Configuration annulee par l'utilisateur.")
sys.exit(0)
TRAITEMENT_DIR = config["traitement_dir"]
SHARED_BIAS_DIR = config["shared_bias_dir"]
SHARED_DARK_DIR = config["shared_dark_dir"]
SESSIONS = config["sessions"]
USE_DRIZZLE = config["drizzle"]
SENSOR = config["sensor"]
THRESHOLD = config["threshold"]
siril.log(f"Connecte a Siril. ({SCRIPT_NAME}.py v{SCRIPT_VERSION})", s.LogColor.GREEN)
if USE_DRIZZLE:
siril.log(f"Capteur : {SENSOR} | Drizzle : oui (focale {config['focal_length_mm']} mm, "
f"seeing {config['seeing_arcsec']}\", echelle {config['drizzle_scale']}) | "
f"Seuil de lot : {THRESHOLD}")
else:
siril.log(f"Capteur : {SENSOR} | Drizzle : non | Seuil de lot : {THRESHOLD}")
siril.log(f"{len(SESSIONS)} session(s) configuree(s) : "
+ ", ".join(sess["name"] for sess in SESSIONS))
def run(*args):
"""Execute une commande Siril, log et arrete le script en cas d'echec."""
try:
siril.cmd(*args)
except Exception as e:
siril.log(f"ECHEC commande '{' '.join(args)}' : {e}", s.LogColor.RED)
raise
def q(path):
"""Entoure un chemin de guillemets pour Siril et normalise les
separateurs vers '/' (voir TLB_ASTRO-OSC_mosaique.py pour le detail
du probleme des chemins sous Windows)."""
normalized = str(path).replace("\\", "/")
return f'"{normalized}"'
process_dir = os.path.join(TRAITEMENT_DIR, "process")
merge_staging_dir = os.path.join(process_dir, "merge_staging")
merged_dir = os.path.join(process_dir, "merged")
partials_dir = os.path.join(process_dir, "partial_stacks")
# Dossiers distincts pour la consolidation des stacks partiels : Siril
# doit lire une sequence dans un dossier different de celui ou il ecrit
# la sequence convertie (convertir un dossier sur lui-meme corrompt les
# fichiers, source et destination ne doivent jamais coincider).
partial_staging_dir = os.path.join(process_dir, "partial_staging")
partial_seq_dir = os.path.join(process_dir, "partial_seq")
# ============================================================
# Bias / Dark partages (construits une seule fois, utilises par
# toute session qui ne definit pas sa propre surcharge)
# ============================================================
shared_bias_master = None
if SHARED_BIAS_DIR:
siril.log("--- Bias partage ---", s.LogColor.BLUE)
shared_bias_master = os.path.join(process_dir, "bias_stacked_shared")
run("cd", q(SHARED_BIAS_DIR))
run("convert", "bias", q(f"-out={process_dir}"))
run("cd", q(process_dir))
run("stack", "bias", "rej", "3", "3", "-nonorm", q(f"-out={shared_bias_master}"))
else:
siril.log("Pas de bias partage defini.")
shared_dark_master = None
if SHARED_DARK_DIR:
siril.log("--- Dark partage ---", s.LogColor.BLUE)
shared_dark_master = os.path.join(process_dir, "dark_stacked_shared")
run("cd", q(SHARED_DARK_DIR))
run("convert", "dark", q(f"-out={process_dir}"))
run("cd", q(process_dir))
run("stack", "dark", "rej", "3", "3", "-nonorm", q(f"-out={shared_dark_master}"))
else:
siril.log("Pas de dark partage defini.")
# ============================================================
# Traitement par session : bias/dark propres si definis (sinon
# partage), flat propre, conversion + calibration des lights
# ============================================================
os.makedirs(merge_staging_dir, exist_ok=True)
global_index = 0
session_ranges = [] # liste ordonnee de (name, start, end) 1-based
session_counts = [] # nb de brutes calibrees par session, meme ordre
session_objects = [] # OBJECT lu par session (ou None)
session_exptimes = [] # EXPTIME lu par session (ou None)
for sess in SESSIONS:
name = sess["name"]
siril.log(f"=== Session : {name} ===", s.LogColor.BLUE)
sess_process_dir = os.path.join(process_dir, name)
# --- bias/dark propres a cette session, sinon partages ---
bias_master = None
if sess["bias_dir"]:
siril.log(f"Bias specifique pour {name}.")
bias_master = os.path.join(process_dir, f"bias_stacked_{name}")
run("cd", q(sess["bias_dir"]))
run("convert", "bias", q(f"-out={process_dir}"))
run("cd", q(process_dir))
run("stack", "bias", "rej", "3", "3", "-nonorm", q(f"-out={bias_master}"))
else:
bias_master = shared_bias_master
dark_master = None
if sess["dark_dir"]:
siril.log(f"Dark specifique pour {name}.")
dark_master = os.path.join(process_dir, f"dark_stacked_{name}")
run("cd", q(sess["dark_dir"]))
run("convert", "dark", q(f"-out={process_dir}"))
run("cd", q(process_dir))
run("stack", "dark", "rej", "3", "3", "-nonorm", q(f"-out={dark_master}"))
else:
dark_master = shared_dark_master
# --- flat, toujours propre a la session ---
flat_master = None
if sess["flat_dir"]:
siril.log(f"Flat specifique pour {name}.")
flat_dir_process = os.path.join(process_dir, f"{name}_flat")
flat_master = os.path.join(flat_dir_process, "flat_stacked")
run("cd", q(sess["flat_dir"]))
run("convert", "flat", q(f"-out={flat_dir_process}"))
run("cd", q(flat_dir_process))
calibrate_flat_args = ["calibrate", "flat"]
if bias_master:
calibrate_flat_args.append(q(f"-bias={bias_master}"))
run(*calibrate_flat_args)
run("stack", "pp_flat", "rej", "3", "3", "-norm=mul", q(f"-out={flat_master}"))
# --- lecture OBJECT / EXPTIME dans les brutes d'origine (avant
# calibration), pour construire le nom de fichier final ---
obj, exptime = read_session_object_exptime(sess["light_dir"])
session_objects.append(obj)
session_exptimes.append(exptime)
# --- conversion + calibration des lights de cette session ---
run("cd", q(sess["light_dir"]))
run("convert", "light", q(f"-out={sess_process_dir}"))
run("cd", q(sess_process_dir))
calibrate_args = ["calibrate", "light"]
if bias_master:
calibrate_args.append(q(f"-bias={bias_master}"))
if dark_master:
calibrate_args.append(q(f"-dark={dark_master}"))
calibrate_args += ["-cc=dark", "-cfa"]
if bias_master:
calibrate_args.append("-opt")
if flat_master:
calibrate_args.append(q(f"-flat={flat_master}"))
calibrate_args.append("-equalize_cfa")
if not USE_DRIZZLE:
calibrate_args.append("-debayer")
run(*calibrate_args)
# --- fusion : lien physique de chaque brute calibree vers le
# dossier de fusion commun, avec un index global sequentiel qui
# garantit l'ordre des sessions et permet de retrouver plus tard
# les plages d'indices correspondant a chacune ---
calibrated_files = sorted(glob.glob(os.path.join(sess_process_dir, "pp_light_*.fit")))
start_index = global_index + 1
for f in calibrated_files:
global_index += 1
link_or_copy(f, os.path.join(merge_staging_dir, f"{global_index:06d}.fit"))
end_index = global_index
session_ranges.append((name, start_index, end_index))
session_counts.append(end_index - start_index + 1)
siril.log(f"Session {name} : {end_index - start_index + 1} brute(s) calibree(s) "
f"et fusionnee(s).")
total_lights = global_index
if total_lights == 0:
siril.log("Aucune brute calibree n'a ete trouvee, arret.", s.LogColor.RED)
sys.exit(1)
# ============================================================
# Fusion en une sequence unique, alignement global (-2pass)
# ============================================================
siril.log("--- Fusion et alignement global ---", s.LogColor.BLUE)
run("cd", q(merge_staging_dir))
run("convert", "merged", q(f"-out={merged_dir}"))
run("cd", q(merged_dir))
# -2pass : Siril choisit automatiquement la meilleure image de
# reference sur l'ensemble des sessions (plus pertinent ici que sur
# une seule nuit, la qualite pouvant varier fortement d'une session a
# l'autre). -2pass ne fait que calculer les transformations : il faut
# ensuite seqapplyreg pour produire la sequence enregistree.
run("register", "merged", "-2pass")
seqapplyreg_args = ["seqapplyreg", "merged"]
if USE_DRIZZLE:
scale = config["drizzle_scale"]
seqapplyreg_args += ["-drizzle", f"-scale={scale}", "-pixfrac=1.0", "-kernel=square"]
run(*seqapplyreg_args)
# ============================================================
# L'alignement peut exclure certaines brutes (echec de detection
# d'etoiles, qualite insuffisante...) : la sequence enregistree peut
# donc etre plus courte que le nombre de brutes fusionnees. Utiliser
# l'ancien total (total_lights) pour les select/unselect ferait
# depasser le nombre reel d'images de la sequence et provoquerait une
# erreur Siril. On recale donc sur le nombre reellement enregistre.
# ============================================================
actual_registered_count = len(glob.glob(os.path.join(merged_dir, "r_merged_*.fit")))
if actual_registered_count == 0:
siril.log("Aucune image enregistree n'a ete produite par l'alignement, arret.",
s.LogColor.RED)
sys.exit(1)
registration_mismatch = actual_registered_count != total_lights
if registration_mismatch:
siril.log(
f"Attention : {total_lights - actual_registered_count} brute(s) ont ete exclues "
f"pendant l'alignement (echec de detection d'etoiles ou qualite insuffisante). "
f"Sequence enregistree : {actual_registered_count} image(s) sur {total_lights} "
f"fusionnee(s) au depart.",
s.LogColor.RED,
)
if len(SESSIONS) > 1:
siril.log(
"Plusieurs sessions sont configurees : la repartition par session des stacks "
"partiels ne peut plus etre garantie exactement apres cette exclusion. Un "
"decoupage simple (sans distinction de session) sera utilise a la place pour "
"l'empilement.",
s.LogColor.RED,
)
total_lights = actual_registered_count
# ============================================================
# Empilement par lots
# ------------------------------------------------------------
# - une seule session : comportement simple, un seul stack sauf si le
# nombre de brutes depasse le seuil (auquel cas decoupage + stack de
# consolidation, comme pour le script mono-session)
# - plusieurs sessions, sans exclusion pendant l'alignement : toujours
# un stack partiel par session (utile pour reperer une nuit de moins
# bonne qualite), avec sous-decoupage supplementaire uniquement si
# une session depasse a elle seule le seuil ; puis un stack de
# consolidation
# - plusieurs sessions, AVEC exclusion pendant l'alignement : repli
# sur un decoupage simple (voir avertissement ci-dessus)
# ============================================================
if len(SESSIONS) == 1 or registration_mismatch:
chunks = compute_chunks(total_lights, THRESHOLD) if total_lights > THRESHOLD \
else [(1, total_lights)]
else:
chunks = []
for (name, start, end) in session_ranges:
count = end - start + 1
for (s_rel, e_rel) in compute_chunks(count, THRESHOLD):
chunks.append((start + s_rel - 1, start + e_rel - 1))
# ============================================================
# Nom du fichier final, construit a partir des informations lues
# dans les entetes FITS d'origine (pas de token Siril dynamique, qui
# ne serait plus fiable une fois des stacks partiels combines entre
# eux)
# ============================================================
distinct_objects = {o for o in session_objects if o}
reference_object = next((o for o in session_objects if o), None)
if len(distinct_objects) > 1:
siril.log(
f"Attention : OBJECT differe selon les sessions ({', '.join(distinct_objects)}). "
f"Le nom de fichier utilisera : {reference_object}",
s.LogColor.RED,
)
sanitized_object = sanitize_name(reference_object) if reference_object else "cible"
known_exptimes = [e for e in session_exptimes if e is not None]
uniform_exptime = (
len(known_exptimes) == len(SESSIONS)
and len(known_exptimes) > 0
and (max(known_exptimes) - min(known_exptimes)) < 0.01
)
if len(known_exptimes) < len(SESSIONS):
siril.log(
"Attention : temps de pose illisible pour au moins une session ; le temps total "
"sera sous-estime pour cette/ces session(s) et le nom de fichier basculera sur "
"le format <poses>/<sessions>.",
s.LogColor.RED,
)
total_duration = sum(
c * e for c, e in zip(session_counts, session_exptimes) if e is not None
)
if uniform_exptime:
exptime_int = int(round(known_exptimes[0]))
name_part = f"{total_lights}x{exptime_int}s"
else:
name_part = f"{total_lights}poses_{len(SESSIONS)}sessions"
result_filename = f"result_{sanitized_object}_{name_part}_{format_duration(total_duration)}"
final_result_path = os.path.join(TRAITEMENT_DIR, result_filename)
# ============================================================
# Execution de l'empilement (direct ou par lots + consolidation)
# ============================================================
if len(chunks) == 1:
siril.log("--- Empilement (lot unique) ---", s.LogColor.BLUE)
run("stack", "r_merged", "rej", "3", "3", "-norm=addscale", "-output_norm",
"-rgb_equal", q(f"-out={final_result_path}"))
else:
siril.log(f"--- Empilement par lots ({len(chunks)} lot(s)) ---", s.LogColor.BLUE)
os.makedirs(partials_dir, exist_ok=True)
for i, (start, end) in enumerate(chunks, start=1):
run("unselect", "r_merged", "1", str(total_lights))
run("select", "r_merged", str(start), str(end))
partial_path = os.path.join(partials_dir, f"partial_{i:05d}")
siril.log(f"Lot {i}/{len(chunks)} : images {start} a {end} ({end - start + 1}).")
run("stack", "r_merged", "rej", "3", "3", "-norm=addscale", "-output_norm",
"-rgb_equal", q(f"-out={partial_path}"))
run("select", "r_merged", "1", str(total_lights))
siril.log("--- Consolidation des stacks partiels ---", s.LogColor.BLUE)
os.makedirs(partial_staging_dir, exist_ok=True)
partial_files = sorted(glob.glob(os.path.join(partials_dir, "partial_*.fit")))
for i, f in enumerate(partial_files, start=1):
link_or_copy(f, os.path.join(partial_staging_dir, f"{i:05d}.fit"))
run("cd", q(partial_staging_dir))
run("convert", "partialseq", q(f"-out={partial_seq_dir}"))
run("cd", q(partial_seq_dir))
run("stack", "partialseq", "rej", "3", "3", "-norm=addscale", "-output_norm",
"-rgb_equal", q(f"-out={final_result_path}"))
run("cd", q(TRAITEMENT_DIR))
siril.log(
f"Pretraitement termine : {total_lights} brute(s) sur "
f"{len(SESSIONS)} session(s). Fichier resultat : "
f"{os.path.basename(final_result_path)}.fit",
s.LogColor.GREEN,
)