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).
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
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 :
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 ?
- 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) - 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é. - 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 :
- 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. - 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)
Les méthodes fournissent respectivement :
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…
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…
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 :
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… 🙂
Il suffit de supprimer l’image du pack d’entrée et le problème est réglé :
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é…
Mais ce n’est pas mon objectif à ce stade (qui sait plus tard).