Live stacking

Derrière ce vocable, on évoque généralement une fonction qui permet

  • d’empiler à la volée les images prises par la caméra
  • voir apparaître à l’écran la cible au fur et à mesure des empilages/alignement
  • améliorer en temps réel l’image affichée (en boostant les capacités optiques du télescope)

Les darks, flats, offsets habituels sont appliqués en continu afin de garder une qualité optimale. Il est a remarquer que dans ce type de solution, la configuration est fixée, afin de minimiser les effets d’une capture à l’autre.

Cette fonction est évidemment fort prisée dans le domaine du VA ou EAA, et les produits phares, tel que Unistellar EVScope ou Vaonis Stellina vont largement la mettre en avant.

Exemple : on parle de « live light accumulation » chez Unistellar, et « Empilement des images en temps réel » chez Vaonis.

Revoyons le principe :

  • une configuration est fixée (ne bouge pas), de préférence avec l’échantillonnage le plus approprié pour l’observation de la classe d’objet considéré (si larges nébuleuses, généralement avec un réducteur de focale).
  • Le F/D est généralement faible, pour permettre une capture rapide, mais aussi faciliter le traitement
  • la mise au point est faite d’une manière correcte (avec masque de Bahtinov, FWHM ou technique associée, automatique ou manuelle)
  • la capture est assurée par une caméra (APN, CCD, CMOS) disposant de la sensibilité requise (inutile de venir avec une webcam de base pour des objets faibles)
  • préalablement à la capture proprement dite, des darks, flats et offset ont été réalisés (soit par l’utilisateur, soit en usine)
  • pendant la capture (durée unitaire courte ou longue, selon la précision du suivi), chaque image va être traitée et empilée directement.
  • Généralement, la situation de l’empilage est montré à l’utilisateur (ce qui accentue l’aspect « magique » du processus 🙂 )
  • Les images ayant été sauvées, un vrai traitement de réduction photographique sera possible ensuite.

Est-ce que cela existe déjà ?

Plusieurs logiciels (Open source, propriétaires) offrent la possibilité de le réaliser. Mais il faut bien remarquer que chaque solution possède ses avantages/inconvénients et que l’intégration n’est pas souvent aussi « simple » que la solution vendue clé sur porte.

Mais pour s’en inspirer, voyons en quelques-uns :

Sharpcap : Le plus « abouti », avec des fonctions évoluées… Dédié Windows, il capture parfaitement mais demande un PC sérieux pour assumer toutes les fonctions.

ASICap / ASIStudio : Principalement dédiés aux capteurs de la marque, ils offrent désormais les fonctions de « Live stack », qui sera intégrée avec une solution complète dans leur boitier ASIAIR.
Le tout sur plusieurs environnements : Windows, Mac, Linux et Android…

DeepSkyStacker : La solution bien connue de « stacking » pour le ciel profond dispose de sa version « live » avec des restrictions qui seront intéressantes à considérer dans la solution Python. L’avantage de cette solution est la mise en oeuvre via « hot foldering » quel que soit la solution de capture considérée.

CCDCiel : Le « preview with stack » répond en partie au besoin…

ASTAP : Un logiciel dédié au « stacking », disposant de nombreuses options

Etc, etc… Comme il faut s’arrêter quelque part, j’ai pris une voie stratégique via les open-source (garanti) : INDI, Kstars, EAB et APN

Ekos / KStars

Pour ceux qui connaissent, cette solution complète d’astrophotographie intègre toutes les fonction nécessaires à la VA, en se basant notamment sur la norme INDI pour l’accès à différents matériels du marché…

Le but de la faire fonctionner sur une configuration EAB (voir la page dédiée sur le sujet), et de rajouter un « live stacking » dans le fonctionnement de Ekos

La première solution est de combiner deux logiciels : DSS Live ou Sharpcap et Ekos. Le partage des images est réalisé par réseau (file share).

Schéma simplifié : capture / VA en deux phases

Cela se gère via l’onglet « File settings » et ses paramètres dans Ekos

Il suffit de relire la documentation officielle, dont je vais reprendre le texte ici :

Prefix: Specify the prefix to append to the generated filename. You may also append the frame type, filter, expose duration, and ISO 8601 timestamp. For example, if you specify Prefix as M45 and checked the Type and Filter checkboxes, and assuming your filter was set to Red and your frame type is Light, the generated file names will be as follows:

M45_Light_Red_001.fits

M45_Light_Red_002.fitsIf TS was checked, a timestamp will be appended to the filename, e.g.

M45_Light_Red_001_2016-11-09T23-47-46.fits

M45_Light_Red_002_2016-11-09T23-48-34.fits

Script: Specify an optional script to be executed after each capture is complete. The full path of the script must be specified and it must executable. To denote success, the script must return zero as this would allow the sequence to continue. If a non-zero value is returned by the script, the sequence is aborted.

Directory: Local directory to save the sequence images to.

Upload: Select how captured images are uploaded:

Client: Captured images are only uploaded to Ekos and saved to the local directory specified above.

Local: Captured images are only saved locally on the remote computer.

Both: Captured images are saved on the remote device and uploaded to Ekos.When selecting Local or Both, you must specify the remote directory where the remote images are saved to. By default, all captured images are uploaded to Ekos.

Remote: When selecting either Local or Both modes above, you must specify the remote directory where remote images are saved to.

A ce stade, la solution va fonctionner, mais exige tout de même pas mal de synchronisation (et un PC puissant).

Mais, il faut toujours lire la documentation… Car L’onglet « script » indiqué dans la documentation est tout juste ce qu’il me faut…
Si on cumule cela avec un affichage dynamique (quand une image cible change, on recharge la dernière version), tout peut être couvert.

Avant cela, regardons si d’autres logiciels sont meilleurs ?

ASTAP + Ekos

Fonction de « Live Stacking » de ASTAP

Le live stacking de ASTAP fonctionne de la même manière que celle de DSS live et Sharpcap : les images sont enregistrées dans une sub-dir et sont traitées à la volée par le programme, qui affiche le résultat.

Si la capture dévie de 0.2°, un nouveau stack est démarré.
Prenons note que dans ce programme, il n’y a pas de réjection sur les images de faible qualité, elles sont toutes traitées sur la même base.

L’algorithme de stacking est en soi très simple : en considérant que les images capturées sont A,B,C,D, E…

Nous aurons :

result1:=A
result2:=(result1+B )/2
result3:=(result2*2+C)/3
result4:=(result3*3+D)/4
result5:=(result4*4+E)/5

Cette logique peut donc être utilisée dans un script indépendant.
Il est temps de s’y mettre…

Programme « LiveStack »

Si je crois vraiment que l’intérêt du « live stacking » se retrouve principalement dans les séances publiques d’astronomie (public, club, école, etc…). cela se sera d’autant plus que la solution sera accessible au plus grand nombre !
Ce qui ne sera pas forcément le cas avec les solutions du commerce actuelle (je vois mal un petit club investir dans 2000-4000 eur de matériel uniquement pour une « journée de l’astronomie » par an) .

Donc, je réduis volontairement le cadre de la solution présentée ici :

  • « Démonstration et éducation »
    Le but est de « montrer » quelque chose facilement et rapidement, mais sans exploiter le maximum des capacités du capteur ou du traitement possible. Cela, cela pourra toujours se faire par ensuite…
  • « Matériel optique non spécifique« ,
    ce qui permet de faire varier les cibles : on pourra tout autant viser des grands objets du ciel profond que plus précis, selon le suivi disponible… Au pire, cela devrait pouvoir fonctionner avec un Dobson (avec limitations, évidemment)
  • « Limiter les investissements »
    Pour ne pas forcément investir dans une CCD dédiée, autant le rendre compatible avec les APN du marché… Mais si on en dispose, pas de soucis, des améliorations dans le code (voir le contenu du site sur les formats d’images) sont largement possibles.
  • « Fonctions utiles »
    Les fonctions d’empilement seront correctes, en intégrant dark et offset, mais pas flat ! Le flat est toujours indispensable pour le traitement final des images, mais en soit : peut être omis dans la première version de l’outil pour le « live stack ». Si le gradient est trop présent, il suffit de redémarrer l’empilement.
    Là aussi, celui veut plus n’a que le rajouter : Python, c’est gratuit…
  • « Rapide et économe en ressources »
    Comme le but est de le faire fonctionner sur un boitier de type PI qui devra déjà faire fonctionner Kstars et Ekos, il faudra viser l’efficacité tout en traitant les images de type APN (soit de 4 à 20Mpix). Donc, je vais faire ce qui est nécessaire et rien de plus.

Alignement des images

La lecture de deux documents m’a fourni une méthode simple pour la réaliser.

http://xanthippi.ceid.upatras.gr/people/evangelidis/george_files/PAMI_2008.pdf

La séquence pour l’alignement devient de-facto très simple :

Logique d’alignement / empilement

Et OpenCV dispose des fonctions adéquates, pour le faire , coupé aux fonctions de lecture/écriture d’image (voir les pages dédiées)

En plus, pour mettre tout ceci au point, je vais faire une abomination (au sens astrophoto) : je ne vais traiter que des images JPEG issue des captures APN RAW+JPEG…

Pourquoi ?

  1. Le but est d’expliquer le principe, ensuite il suffira d’adapter la même logique avec un pré-traitement/extraction approprié (ex : RAW, mais en traitant plan par plan ou en mode RGB)
    D’autant plus que générer du RAW+JPEG en automatique, c’est à la portée de tous les APN récents (et même moins). Avec les logiciels de « remote » livré avec eux, il est très facile de capturer en séquence et déposer les images dans une sub-dir donnée (philosophie de DSS live)
  2. Je veux que cela soit rapide et comme exprimé : c’est pour « démontrer », pas « traiter ». Donc : au plus compact les fichiers, au plus rapide le stacking…
    Est-ce JPEG (255 niveaux) pour « montrer », cela ne suffirait pas ?
    En soi, tous les sites web d’astrophoto prouvent tous les jours qu’une qualité de ce type « suffit » pour regarder une image 30 sec lors d’un exposé.
  3. Comme le bruit sera faible, car déjà compressé (évidemment, l’efficacité ne sera pas la même), on peut se contenter du traitement de Dark pour commencer.

Le code est relativement simple, les fonctions complexes sont réalisées via OpenCV :

#define scanning area for aligment
M = np.eye(3, 3, dtype=np.float32)

#init
previous_image = None
stacked_image = None

#process
image = cv2.imread(file,1).astype(np.float32) / 255
if previous_image is None:
   # convert to gray scale floating point image
   previous_image = cv2.cvtColor(image,cv2.COLOR_BGR2GRAY)
   stacked_image = image
else:
   # Estimate perspective transform
   s, M = cv2.findTransformECC(cv2.cvtColor(image,cv2.COLOR_BGR2GRAY),
previous_image, M, cv2.MOTION_HOMOGRAPHY)
   w, h, _ = image.shape
   # Align image to first image
   image = cv2.warpPerspective(image, M, (h, w))
   previous_image = cv2.cvtColor(image,cv2.COLOR_BGR2GRAY)

cv2.cvtColor va convertir l’image (couleur) vers un niveau gris plus facile à traiter au niveau des déviations.

cv2.findTransformECC va déterminer la translation entre deux images successives (au début, évidemment = 0)

cv2.warpPerspective va appliquer la déformation/translation au contenu réel de l’image

Le résultat sera une image alignée sur la précédente…

Une réflexion se pose : sur quoi aligner ?
– on peut continuellement aligner sur la première image et garder cette image comme référence. Si drift de capture, l’écart va grandir et il faudra toujours appliquer une déformation plus grande pour récupérer l’alignement (et parfois, cela va rater car l’écart sera trop important !)
– ou on peut aligner sur la dernière image « alignée » et ne calculer que le « delta » du au mauvais suivi. La transformation sera donc devenue une caractéristique du PEC. Mais là aussi, si un décalage trop grand se produit, il faudra redémarrer.

Il faudra tester/valider les deux modèles.

Le tout, c’est de bien décider comment on fait l’empilement après (que sur l’image alignée)… Quoi qu’il en soit, pour l’alignement, c’est terminé en 21 lignes de Python… 🙂

Empilement des images

Une différence fondamentale entre le « stacking » habituel et celui-ci est qu’on ignore le nombre d’images qui sera présent lors des opérations.

On peut donc considérer deux voies : celle où le stack +1 ne s’effectue que sur la dernière image ou celle où à chaque itération, toutes les images seront considérées.

Les méthodes « avancées » d’empilement exigent une analyse statistique sur les caractéristiques des images présentes, analyse qui se réalise généralement par itération.

Ex : mathématiquement, l’empilement normal d’une image couleur peut se calculer par :

  1. Détermination du master flat via :
    master flat: = (1/n ∑ [flat] – 1/n ∑ [flat darks] )
    dans laquelle une image de bias peut d’ailleurs être utilisé comme « Dark » dans ce cas.
    Remarquons que ce master flat devra aussi être moyenné sur 2×2 pour enlever les artefacts de la matrice de Bayer.
  2. Ensuite, chaque image sera calculée comme :
    (image- {∑ [darks]/n} ) / master flat. Un résultat sur lequel la matrice de Bayer sera appliquée, puis l’image est empilée dans un mode ou l’autre (moyenne ou avec réjection).

Pour un mode « moyenne » : final image:= 1/n ∑ Bayer(image)

Et pour optimiser la rapidité, autant se focaliser autour des empilements de type « par la Moyenne ». D’autre part, comme aucune calibration préalable n’est possible, une simple détection et suppression des pixels chauds sera utilisée sur les images entrantes (via un MasterDark) avant de les empiler.

On va donc se construire ce MasterDark à partit d’une série de Dark déjà réalisés et stockés dans une sub-dir dédiée.

dark_list = []
for file in glob.glob(dark_folder + "*.*"):
	dark_list.append(file)
dark_list.sort()

for file in dark_list:
	dark_image = cv2.imread(file,1).astype(np.float32) / 255
	if masterdark_image is None:
		masterdark_image = dark_image
	else:
		masterdark_image = np.where(dark_image > masterdark_image, (dark_image + masterdark_image)/2, masterdark_image)

La logique de création du Masterdark est simple, sur base d’une simple moyenne avec une fonction numpy. il suffira ensuite de l’appliquer sur chaque image entrante pour relativement supprimer tous les « gros » défauts lors de l’empilage.

Ensuite vient le choix de la stratégie d’empilement… Je prends deux décisions : d’abord, de tout effectuer sur 32bits (afin de pouvoir intégrer tous les types d’images plus tard), ensuite de permettre le choix entre plusieurs méthodes issues de la moyenne pondérée et/ou auto-adaptative.

En considérant les deux jeux de valeurs : stacked et image, les méthodes sont (appliquée à chaque pixel de l’image) :

  • ADD : simple addition sans rejection mais limitée pour saturation.
    Pour chaque pixel, si(stacked+image)>1 alors 1 sinon stacked + image.
  • ADD2 : idem que ADD, mais moins saturant
    Pour chaque pixel, si(stacked+image)<1 alors stacked+image sinon stacked
  • ADDMAX : on ne garde que les maximum
    Pour chaque pixel, si image>stacked alors image sinon stacked
  • ADDMAX2 : idem que ADDMAX, mais la somme est repondérée sur la dynamique disponible.
    Pour chaque pixel, si image>stacked alors image sinon stacked, mais on applique en finale 255 / max(stacked) * stacked au niveau global de l’image
  • ADDMEAN : simple moyenne des valeurs non nulles
    Pour chaque pixel, si image >0 alors (stacked+image)/2, sinon stacked
  • ADDCOMP : une méthode différente, intégrant le nombre d’image
    Si n est le nombre d’images déjà additionnée,
    si (stacked*(n-1)+image)/n < 1 alors (stacked*(n-1)+image)/n sinon stacked

Si on applique cela sur un set de données de test (6 images, 5 valeurs caractéristiques par image, de fort à faible signal)

Jeu de données test

Les méthodes fournissent respectivement :

Après application des méthodes choisies

Comme on peut le voir, les méthodes de « ADD » simples sont utiles pour les basses intensités, ADDMEAN s’appliquera mieux avec beaucoup d’images alors ADDMAX pour capturer des valeurs fugitives (mais cela avec les impacts bien connus, tel que le passage d’un satellite).

Rajouter des méthodes tel que sigma clipping ou chi² peuvent peut être apporter des améliorations, mais vu le cadre de l’affichage recherché, j’en doute à ce stade.

Assemblons les différentes partie dans une boucle de traitement :

previous_image 	= None
stacked_image 	= None
masterdark_image= None
output_image 	= None
looptest	= True

while looptest:
	if new_image():
		image = cv2.imread(file,1).astype(np.float32) / 255
		cpt_image+=1
		print("Image "+ str(cpt_image) + ": "+ str(file))
		new_image = False
		
		if masterdark_image is not None:
			#extract masterdark value
			if len(masterdark_image.shape) > len(image.shape):
				masterdark_image = masterdark_image[0,:,:]

			image = np.where(image > masterdark_image, image - masterdark_image, image)

		if previous_image is None:
			# convert to gray scale floating point image
			previous_image = cv2.cvtColor(image,cv2.COLOR_BGR2GRAY)
			stacked_image = image
		else:
			# Estimate perspective transform
			s, M = cv2.findTransformECC(cv2.cvtColor(image,cv2.COLOR_BGR2GRAY), previous_image, M, cv2.MOTION_HOMOGRAPHY)
			w, h, _ = image.shape
			# Align image to first image
			image = cv2.warpPerspective(image, M, (h, w))
			previous_image = cv2.cvtColor(image,cv2.COLOR_BGR2GRAY)

			if stacking_mode == "ADD":
				stacked_image = np.where((image+stacked_image) > 1, 1, image+stacked_image)
			if stacking_mode == "ADD2":
				stacked_image = np.where((image+stacked_image) < 1, image+stacked_image, stacked_image)
			if stacking_mode == "ADDMAX":
				stacked_image = np.where(image > stacked_image, image, stacked_image)
			if stacking_mode == "ADDMAX2":
				stacked_image = np.where(image > stacked_image, image, stacked_image)
			if stacking_mode == "ADDMEAN":
				stacked_image = np.where(image > 0, (stacked_image+image)/2, stacked_image)
			if stacking_mode == "ADDCOMP":
				stacked_image = np.where(((stacked_image*(cpt_image-1)+image)/cpt_image) > 0,(stacked_image*(cpt_image-1)+image)/cpt_image, stacked_image)

			if debug:
				output_file = output_folder + image_name + "_" + stacking_mode + "_" + str(cpt_image) + ".png"
				if stacking_mode == "ADDMAX2" :
					temp_image = (255/np.max(stacked_image)*stacked_image).astype(np.uint8)
				else:
					temp_image = (255*stacked_image).astype(np.uint8)
				cv2.imwrite(output_file,temp_image)
				print("Debug file written : ",output_file)

		if stacking_mode == "ADDMAX2" :
			output_image = (255/np.max(stacked_image)*stacked_image).astype(np.uint8)
		else:
			output_image = (255*stacked_image).astype(np.uint8)

	else:
		if end_process():
			looptest = False
		else:
			time.sleep(loop_delay)

A ce stade, la théorie est posée, il reste à valider ce que cela donne avec la réalité…

Les premiers tests

Afin de rester dans le matériel « simple », j’ai trois jeux d’images tests, réalisé dans les mêmes conditions de ciel :

  • APN (Sony A7S) courant (non défiltré)
  • Zoom 70-200mm F4 ou 50mm F1.8
  • Monture Adventurer (mini)
  • Capture de 10 sec et 5 sec
  • 6400, 3200 ou 1600 ISO de gain
  • Ecriture RAW+JPEG

Le code ci-dessus a simplement été inclus dans une fonction (stackImages) appliquée à chaque image trouvée dans la même subdir et idem pour les Dark.

Remarque : les images trouvées sont triées sur leur nom avant empilement, afin de minimiser les surprises….

    #Configuration
    image_name = "M31"
    image_folder = "X:/livestack/jpeg3/input/"
    output_folder = "X:/livestack/jpeg3/"
    dark_folder  = "X:/livestack/jpeg3/dark/"

    #Build file lists
    file_list = []
    dark_list = []
    for file in glob.glob(image_folder + "*.*"):
        file_list.append(file)
    for file in glob.glob(dark_folder + "*.*"):
        dark_list.append(file)
    file_list.sort()
    dark_list.sort()

    methods =("ADD","ADD2","ADDMAX","ADDMAX2","ADDMEAN","ADDCOMP")

    if len(file_list) > 0:
        for method in methods:
            # Stack images using CV2 ECC method with suited stacking method
            tic = time()
            description = "Stacking images using ECC method, "+ str(method) + " stacking"
            print(description)
            stacked_image = stackImages(file_list,dark_list,method,Debug=False)
            print("Stacked {0} images in {1} seconds".format(len(file_list), (time()-tic) ))
            output_file = output_folder + image_name + "_" + str(method) + ".png"
            print("Saved {}".format(output_file))
            cv2.imwrite(output_file,stacked_image)

Voyons ce que cela donne…

ADDMAX2 sur M31, 200mm f4, 5 x 10 sec de pose, 6400ISO, 7MB
ADDMEAN sur M13, 200mm f4, 4 x 10 sec, 3200ISO, 7MB
ADDCOMP sur les Pléiades, 50mm F1.8, 4 x 5sec, 1600ISO, 13MB

Deux constatations immédiates :
– sur des objets « larges et diffus », le stacking semble efficace, mais sur des objets « petits et faibles », le suivi pose problème.
– sur des grands champs (tel que 50mm), le vignettage est au rendez-vous (mais avec un 50mm Canon sur un FF Sony avec une bague, cela parait normal)

Mais si on agrandit M13, on voit un problème non présent dans les autres cas…

Erreur d’alignement M13, mode « stack on previous », 1 image sur 4 est décalée

Un test rapide nous permet d’éliminer la question « alignement depuis la première image ou la suivante ?« , car si on applique le suivi depuis la première dans l’algorithme, on obtient :

Erreur d’alignement M13, mode « stack on first », 4 images visibles

On garde définitivement le mode « alignement sur la précédente » et en fait, l’autre mode permettrait théoriquement de récupérer le PEC de sa monture (et en plus, fonctionnerait avec n’importe quelle image contrastée). On y reviendra un jour… Mais pour le moment, pourquoi cette erreur de suivi ?

Il suffit de comparer les images pour identifier le problème… 🙂

Une des images n’a pas la même sensibilité ! 3200 ISO (1) au lieu de 6400 ISO (3)…

Il suffit de supprimer l’image du pack d’entrée et le problème est réglé :

ADDMAX2, M13, 50mm F1.8, 3 x 5 sec, 6400 ISO

On retrouve ce qui était attendu… Et revient à la possibilité d’intégrer un empilement de type « sigma clipping » pour éliminer automatiquement l’image trop déviante de la pile.

Mais en soi en « live stacking »…
Les conditions devant rester inchangées pendant la capture, si on y touche en cours de soirée, il suffit de redémarrer l’empilement pour résoudre le problème !

Intégration complète

On intègre tous les composants dans un programme avec gestion par arguments , afin de le rendre multi-usage…

Il pourra :
– travailler en mode simple exécution (1x) ou permanente (arrêt via CTRL-C, intercepté par le code, afin de terminer « proprement » = return code 0)
– empiler soit toutes les images trouvées dans la subdir, soit une partie (les « n » dernières)
– choisir le nom de l’image de sortie (par défaut, cela sera le nom de la méthode d’empilement)
– choisir ou non de recharger la dernière image empilée, ce qui permet en combinant : -number 1 -reload YES d’obtenir uniquement l’ajout de la dernière image
– choisir sa méthode d’empilement, et pour tester la meilleure, il suffit de laisser par défaut, ce qui les génère toutes
– et évidemment, d’indiquer les subdir « utiles » séparemment ou d’utiliser celles par défaut, depuis la subdir courante d’éxécution.

A l’usage (-h) :

usage: LiveStack_V1.0.py [-h] [-input_dir INPUT_DIR] [-output_dir OUTPUT_DIR]
[-dark_dir DARK_DIR] [-output_image OUTPUT_IMAGE]
[-method {ADD,ADD2,ADDMAX,ADDMAX2,ADDCOMP}]
[-mode {1,LOOP}] [-number NUMBER] [-reload {YES,NO}]

optional arguments:
-h, –help show this help message and exit
-input_dir INPUT_DIR Input directory of images (default is input/)
-output_dir OUTPUT_DIR
Input directory of images ((default is output/))
-dark_dir DARK_DIR Dark directory of images ((default is dark/))
-output_image OUTPUT_IMAGE
Output image name or * to use method name, default is
*
-method {ADD,ADD2,ADDMAX,ADDMAX2,ADDCOMP}
Stacking method, default will uses all
-mode {1,LOOP} Run mode 1 =one time, LOOP=forever, default is 1
-number NUMBER Number of images to stack, default is 0 for all
present
-reload {YES,NO} if YES, reload previous stacked image, default is NO

Le code source devient plus long… Avec l’intégration du code de stacking dans une fonction dédiée…

# -*- coding: utf-8 -*-
"""
Created on Fri Jan  8 19:39:33 2021

@author: TTF
"""
import os
import sys
import cv2
import glob
from time import time
import numpy as np
import argparse
import signal

#Global definitions to simplify debugging...
#will be supressed later..
image = None
saveimage = None
masterdark_image= None
dark_image=None

#default loop mode
loopstack=True

#Interrupt execution on CTRL-C
def receiveSignal(signalNumber, frame):
    print("Signal detected - program ended")
    sys.exit(0)
    return False

# Align and stack images with ECC method
# if present : with dark subtract via masterdark creation

def stackImages(stacked_image,file_list,dark_list,stacking_mode="ADDMAX2",debug=False):
    #for debugging purpose
    if debug:
        global image, previous_image, dark_image
        global masterdark_image
        global saveimage

    #define scanning area for aligment
    M = np.eye(3, 3, dtype=np.float32)

    #init
    if stacked_image is not None:
        previous_image=stacked_image
    else:    
        previous_image = None
    
    stacked_image = None
    masterdark_image = None

    for file in dark_list:
        dark_image = cv2.imread(file,1).astype(np.float32) / 255
        if masterdark_image is None:
            masterdark_image = dark_image
        else:
            masterdark_image = np.where(dark_image > masterdark_image, (dark_image + masterdark_image)/2, masterdark_image)

    cpt_image=0
    for file in file_list:
        image = cv2.imread(file,1).astype(np.float32) / 255
        cpt_image+=1
        print("Image "+ str(cpt_image) + ": "+ str(file))

        if masterdark_image is not None:
            #extract masterdark value
            if len(masterdark_image.shape) > len(image.shape):
                masterdark_image = masterdark_image[0,:,:]

            image = np.where(image > masterdark_image, image - masterdark_image, image)

        if previous_image is None:
            # convert to gray scale floating point image
            previous_image = cv2.cvtColor(image,cv2.COLOR_BGR2GRAY)
            stacked_image = image
        else:
            # Estimate perspective transform
            s, M = cv2.findTransformECC(cv2.cvtColor(image,cv2.COLOR_BGR2GRAY), previous_image, M, cv2.MOTION_HOMOGRAPHY)
            w, h, _ = image.shape
            # Align image to first image
            image = cv2.warpPerspective(image, M, (h, w))
            previous_image = cv2.cvtColor(image,cv2.COLOR_BGR2GRAY)

            if stacking_mode == "ADD":
                stacked_image = np.where((image+stacked_image) > 1, 1, image+stacked_image)
            if stacking_mode == "ADD2":
                stacked_image = np.where((image+stacked_image) < 1, image+stacked_image, stacked_image)
            if stacking_mode == "ADDMAX":
                stacked_image = np.where(image > stacked_image, image, stacked_image)
            if stacking_mode == "ADDMAX2":
                stacked_image = np.where(image > stacked_image, image, stacked_image)
            if stacking_mode == "ADDMEAN":
                stacked_image = np.where(image > 0, (stacked_image+image)/2, stacked_image)
            if stacking_mode == "ADDCOMP":
                stacked_image = np.where(((stacked_image*(cpt_image-1)+image)/cpt_image) > 0,(stacked_image*(cpt_image-1)+image)/cpt_image, stacked_image)

            if debug:
                output_file = output_folder + image_name + "_" + stacking_mode + "_" + str(cpt_image) + ".png"
                if stacking_mode == "ADDMAX2" :
                    temp_image = (255/np.max(stacked_image)*stacked_image).astype(np.uint8)
                else:
                    temp_image = (255*stacked_image).astype(np.uint8)
                cv2.imwrite(output_file,temp_image)
                print("Debug file written : ",output_file)

    if stacking_mode == "ADDMAX2" :
        stacked_image = (255/np.max(stacked_image)*stacked_image).astype(np.uint8)
    else:
        stacked_image = (255*stacked_image).astype(np.uint8)

    return stacked_image

# ===== MAIN =====
if __name__ == '__main__':
    parser = argparse.ArgumentParser()
    parser.add_argument('-input_dir',    help='Input directory of images (default is input/)',default="input/")
    parser.add_argument('-output_dir',   help='Input directory of images ((default is output/))',default="output/")
    parser.add_argument('-dark_dir',     help='Dark directory of images ((default is dark/))',default="dark/")
    parser.add_argument('-output_image', help='Output image name or * to use method name, default is *',default="*")
    parser.add_argument('-method',       help='Stacking method, default will uses all',choices=["ADD","ADD2","ADDMAX","ADDMAX2","ADDCOMP"],default="ALL")
    parser.add_argument('-mode',         help='Run mode 1 =one time, LOOP=forever, default is 1',choices=["1","LOOP"],default="1")
    parser.add_argument('-number',       help='Number of images to stack, default is 0 for all present',type=int,default=0)
    parser.add_argument('-reload',       help='if YES, reload previous stacked image, default is NO',choices=["YES","NO"],default="NO")
    args = parser.parse_args()

    image_folder    = args.input_dir
    output_folder   = args.output_dir
    dark_folder     = args.dark_dir
    image_name      = args.output_image

    #Verify args subdirs
    if not os.path.exists(image_folder):
        print("ERROR {} not found!".format(image_folder))
        sys.exit(4)

    if not os.path.exists(output_folder):
        print("ERROR {} not found!".format(image_folder))
        sys.exit(4)

    if not os.path.exists(dark_folder):
        print("ERROR {} not found!".format(image_folder))
        sys.exit(4)

    #Build file lists
    file_list = []
    for file in glob.glob(image_folder + "*.*"):
        file_list.append(file)

    dark_list = []
    for file in glob.glob(dark_folder + "*.*"):
        dark_list.append(file)

    file_list.sort()
    dark_list.sort()

    #limit image list if needed
    if args.number > 0:
        if len(file_list) > 0 and len(file_list) > args.number:
                file_list =  [file_list[-i] for i in range(args.number,0,-1)] 

    #methods =("ADD","ADD2","ADDMAX","ADDMAX2","ADDMEAN","ADDCOMP")
    methods=[]
    if args.method == "ALL":
        methods = ["ADD","ADD2","ADDMAX","ADDMAX2","ADDMEAN","ADDCOMP"]
    else:
        methods.append(args.method)

    print("Stacking run in mode {0} ".format(args.mode))

    signal.signal(2, receiveSignal)
    loopstack=True    

    if args.reload == "YES":
        if args.output_image == "*":
            reload_file = input_folder + str(method) + ".png"
        else:
            reload_file = input_folder + image_name + "_" + str(method) + ".png"

        if not os.file.exists(reload_file):
            print("ERROR {} reload file not found!".format(reload_file))
            sys.exit(4)
        else:
            stacked_image = cv2.imread(reload_file,1).astype(np.float32) / 255
    else:
        stacked_image = None

    while loopstack:
        if len(file_list) > 0:
            for method in methods:
                # Stack images 
                tic = time()
                description = "Stacking {0} images using method : {1}".format(len(file_list), str(method))
                print(description)
                stacked_image = stackImages(stacked_image,file_list,dark_list,method,False)
                if args.output_image == "*":
                    output_file = output_folder + str(method) + ".png"
                else:
                    output_file = output_folder + image_name + "_" + str(method) + ".png"
                cv2.imwrite(output_file,stacked_image)
                print("Stacked in {0:.2f} seconds in {1} ".format((time()-tic),output_file))
                if args.reload != "YES":
                    stacked_image = None
        else:
            print("{0} images found in {1} input_dir".format(len(file_list), image_folder ))

        if args.mode == "1":
            loopstack=False

    sys.exit(0)    

Mais ne fait que 211 lignes… Ce qui répond au besoin « compact » 🙂

Et le RAW ?

Comme il est « commun » de ne traiter que les images RAW dans les outils actuels, ne pas le proposer ici pourrait être considéré comme une erreur…

Mais pourtant, je crois réellement que dans ce but précis (affichage rapide et pour simplement montrer, pas traiter), le boitier APN fait correctement son travail et fournit déjà des images JPEG de bonne qualité sur des objets suffisamment lumineux.

D’un autre côté, pré-traiter des images RAW demande plus de temps et aussi un choix tactiques sur les paramètres disponibles au niveau de la « Debayerisation ».

Pour plus d’information, j’invite le lecteur à lire le chapitre consacré

Mais voici une version de base du programme de Livestacking adapté au support RAW.

"""
Created on Fri Jan  8 19:39:33 2021

@author: TTF
"""
import os
import sys
import cv2
import glob
from time import time
import numpy as np
import argparse
import signal
import rawpy

#Global definitions to simplify debugging...
#will be supressed later..
image = None
saveimage = None
masterdark_image= None
dark_image=None

#default loop mode
loopstack=True

#Interrupt execution on CTRL-C
def receiveSignal(signalNumber, frame):
    print("Signal detected - program ended")
    sys.exit(0)
    return False

# Align and stack images with ECC method
# if present : with dark subtract via masterdark creation

def stackRawImages(stacked_image,file_list,dark_list,stacking_mode="ADDMAX2",raw_bright=0.5,debug=False):
    #for debugging purpose
    if debug:
        global image, previous_image, dark_image
        global masterdark_image
        global saveimage

    #define scanning area for aligment
    M = np.eye(3, 3, dtype=np.float32)

    #init
    if stacked_image is not None:
        previous_image=stacked_image
    else:    
        previous_image = None
    
    stacked_image = None
    masterdark_image = None

    for file in dark_list:
        if file.endswith(('.ARW','.CR2', '.CRW')):
            with rawpy.imread(file) as raw:
                dark_image = raw.postprocess(use_camera_wb=True,bright=raw_bright,output_bps=16)
                dark_image = cv2.cvtColor(dark_image,cv2.COLOR_RGB2BGR).astype(np.float32) / 65535
                #dark_image = dark_image.astype(np.float32) / 255
        else:
            dark_image = cv2.imread(file,1).astype(np.float32) / 255
        
        if masterdark_image is None:
            masterdark_image = dark_image
        else:
            masterdark_image = np.where(dark_image > masterdark_image, (dark_image + masterdark_image)/2, masterdark_image)

    cpt_image=0
    raw=False
    
    for file in file_list:
        if file.endswith(('.ARW','.CR2', '.CRW')):
            with rawpy.imread(file) as raw:
                image = raw.postprocess(use_camera_wb=True,bright=raw_bright,output_bps=16)
                image = cv2.cvtColor(image,cv2.COLOR_RGB2BGR).astype(np.float32) / 65535
                #image = image.astype(np.float32) / 255
        else:
            image = cv2.imread(file,1).astype(np.float32) / 255

        cpt_image+=1
        print("Image "+ str(cpt_image) + ": "+ str(file))

        if masterdark_image is not None:
            #extract masterdark value
            if len(masterdark_image.shape) > len(image.shape):
                masterdark_image = masterdark_image[0,:,:]

            image = np.where(image > masterdark_image, image - masterdark_image, image)

        if previous_image is None:
            # convert to gray scale floating point image
           previous_image = cv2.cvtColor(image,cv2.COLOR_BGR2GRAY)
           stacked_image = image
        else:
            # Estimate perspective transform
            s, M = cv2.findTransformECC(cv2.cvtColor(image,cv2.COLOR_BGR2GRAY), previous_image, M, cv2.MOTION_HOMOGRAPHY)
            w, h, _ = image.shape
            # Align image to first image
            image = cv2.warpPerspective(image, M, (h, w))
            previous_image = cv2.cvtColor(image,cv2.COLOR_BGR2GRAY)

            if stacking_mode == "ADD":
                stacked_image = np.where((image+stacked_image) > 1, 1, image+stacked_image)
            if stacking_mode == "ADD2":
                stacked_image = np.where((image+stacked_image) < 1, image+stacked_image, stacked_image)
            if stacking_mode == "ADDMAX":
                stacked_image = np.where(image > stacked_image, image, stacked_image)
            if stacking_mode == "ADDMAX2":
                stacked_image = np.where(image > stacked_image, image, stacked_image)
            if stacking_mode == "ADDMEAN":
                stacked_image = np.where(image > 0, (stacked_image+image)/2, stacked_image)
            if stacking_mode == "ADDCOMP":
                stacked_image = np.where(((stacked_image*(cpt_image-1)+image)/cpt_image) > 0,(stacked_image*(cpt_image-1)+image)/cpt_image, stacked_image)

            if debug:
                output_file = output_folder + "debug_" + stacking_mode + "_" + str(cpt_image) + ".png"
                if stacking_mode == "ADDMAX2" :
                        temp_image = (255/np.max(stacked_image)*stacked_image).astype(np.uint8)
                else:
                        temp_image = (255*stacked_image).astype(np.uint8)
                cv2.imwrite(output_file,temp_image)
                print("Debug file written : ",output_file)

    if stacking_mode == "ADDMAX2" :
        stacked_image = (255/np.max(stacked_image)*stacked_image).astype(np.uint8)
    else:
        stacked_image = (255*stacked_image).astype(np.uint8)

    return stacked_image

# ===== MAIN =====
if __name__ == '__main__':
    parser = argparse.ArgumentParser()
    parser.add_argument('-input_dir',    help='Input directory of images (default is input/)',default="input/")
    parser.add_argument('-output_dir',   help='Input directory of images ((default is output/))',default="output/")
    parser.add_argument('-dark_dir',     help='Dark directory of images ((default is dark/))',default="dark/")
    parser.add_argument('-output_image', help='Output image name or * to use method name, default is *',default="*")
    parser.add_argument('-method',       help='Stacking method, default will uses all',choices=["ADD","ADD2","ADDMAX","ADDMAX2","ADDCOMP"],default="ALL")
    parser.add_argument('-mode',         help='Run mode 1 =one time, LOOP=forever, default is 1',choices=["1","LOOP"],default="1")
    parser.add_argument('-number',       help='Number of images to stack, default is 0 for all present',type=int,default=0)
    parser.add_argument('-reload',       help='if YES, reload previous stacked image, default is NO',choices=["YES","NO"],default="NO")
    args = parser.parse_args()

    image_folder    = args.input_dir
    output_folder   = args.output_dir
    dark_folder     = args.dark_dir
    image_name      = args.output_image

    #Verify args subdirs
    if not os.path.exists(image_folder):
        print("ERROR {} not found!".format(image_folder))
        sys.exit(4)

    if not os.path.exists(output_folder):
        print("ERROR {} not found!".format(image_folder))
        sys.exit(4)

    if not os.path.exists(dark_folder):
        print("ERROR {} not found!".format(image_folder))
        sys.exit(4)

    #Build file lists
    file_list = []
    for file in glob.glob(image_folder + "*.*"):
        file_list.append(file)

    dark_list = []
    for file in glob.glob(dark_folder + "*.*"):
        dark_list.append(file)

    file_list.sort()
    dark_list.sort()

    #limit image list if needed
    if args.number > 0:
        if len(file_list) > 0 and len(file_list) > args.number:
                file_list =  [file_list[-i] for i in range(args.number,0,-1)] 

    #methods =("ADD","ADD2","ADDMAX","ADDMAX2","ADDMEAN","ADDCOMP")
    methods=[]
    if args.method == "ALL":
        methods = ["ADD","ADD2","ADDMAX","ADDMAX2","ADDMEAN","ADDCOMP"]
    else:
        methods.append(args.method)

    print("Stacking run in mode {0} ".format(args.mode))

    signal.signal(2, receiveSignal)
    loopstack=True    
    raw_bright=0.5

    if args.reload == "YES":
        if args.output_image == "*":
            reload_file = input_folder + str(method) + ".png"
        else:
            reload_file = input_folder + image_name + "_" + str(method) + ".png"

        if not os.file.exists(reload_file):
            print("ERROR {} reload file not found!".format(reload_file))
            sys.exit(4)
        else:
            stacked_image = cv2.imread(reload_file,1).astype(np.float32) / 255
    else:
        stacked_image = None

    while loopstack:
        if len(file_list) > 0:
            for method in methods:
                # Stack images 
                tic = time()
                description = "Stacking {0} images using method : {1}".format(len(file_list), str(method))
                print(description)
                stacked_image = stackRawImages(stacked_image,file_list,dark_list,method,raw_bright,False)
)
                if args.output_image == "*":
                    output_file = output_folder + str(method) + ".png"
                else:
                    output_file = output_folder + image_name + "_" + str(method) + ".png"
                cv2.imwrite(output_file,stacked_image)
                print("Stacked in {0:.2f} seconds in {1} ".format((time()-tic),output_file))
                if args.reload != "YES":
                    stacked_image = None
        else:
            print("{0} images found in {1} input_dir".format(len(file_list), image_folder ))

        if args.mode == "1":
            loopstack=False

    sys.exit(0)    

A ce stade, le résultat devrait être amélioré…

ADDMAX2 en mode RAW (5 images &quivalentes), via le programme ci-dessus.

Mais ce n’est pas mon objectif à ce stade (qui sait plus tard).