Table des matières

Stockage et visualisation de signaux

1. Introduction

Les oscilloscopes numériques de dernière génération sont capables de mémoriser des signaux de plusieurs dizaines de millions d'échantillons, ce qui pose la question de leur stockage et de leur transfert sur le web.

Cette page montre comment stocker dans un fichier d'image PNG les signaux acquis sur un oscilloscope numérique (ou une carte d'acquisition), pouvant comporter des millions d'échantillons. La compression sans perte mise en œuvre dans le format PNG permet de réduire la taille des fichiers par rapport à un stockage binaire sans compression et encore bien plus par rapport à un format texte de type CSV.

Le stockage d'une valeur de tension dans un fichier CSV nécessite une dizaine de caractères alors que, dans le cas d'un oscilloscope 8 bits, un seul octet suffirait. Il faut donc faire un stockage binaire, ce qui permettra en outre de bénéficier d'algorithmes de compression de données.

L'objectif principal est de rendre ces signaux accessibles par HTTP avec un minimum de données à transmettre. Nous présenterons aussi un module javascript permettant de visualiser les signaux dans une page HTML.

Le format de stockage permettra de stocker soit une seule acquisition très longue, soit un ensemble de formes d'onde acquises par un oscilloscope numérique.

2. Informations à stocker

Le signal à stocker est décomposé en fenêtres (ou Frames). Sur un oscilloscope, une fenêtre correspond à une acquisition visible sur l'écran. Les fenêtres ne sont pas nécessairement contigues mais elles sont en général très proches. Certains oscilloscopes peuvent enregistrer dans leur mémoire plusieurs dizaines de milliers de fenêtres (appelées aussi formes d'onde) par seconde. Dans le cas d'un signal périodique, le déclenchement synchronisé permet de dérouler l'affichage des fenêtres. La visualisation de ces fenêtres enregistrées en mémoire permet d'observer des phénomènes rapides impossibles à voir lors d'une utilisation en temps réel de l'oscilloscope. Les oscilloscopes permettent une exportation de ces données sous forme de fichier CSV.

La fréquence d'échantillonnage est fixée. Plusieurs voies sont stockées (jusqu'à 6), chacune ayant son propre calibre de tension. On choisit la précision de numérisation (de 8 à 16 bits). Les nombres (par ex. extraits d'un fichier CSV) sont fournis sous forme de flottants mais ils sont convertis en nombres entiers, au moyen de la précision et du calibre. On choisira bien sûr la précision correspondant au matériel ayant effectué la numérisation, par exemple 8 bits pour un oscilloscope ou 12 bits pour une carte d'acquisition Sysam SP5.

La longueur des signaux est définie par le nombre d'échantillons dans chaque fenêtre (Nf) et le nombre de fenêtres (N). Le nombre total d'échantillons par voie est donc Nt=NNf. Un échantillon occupe un octet (8 bits) si la précision est 8 bits, deux octets si la précision est entre 9 bits et 16 bits. Si la précision est 12 bits, ce stockage perd 4 bits par échantillon. Il serait possible d'utiliser exactement le nombre de bits nécessaire mais au prix d'une grande complication de l'implémentation.

La figure suivante montre les deux possibilités de répartition des fenêtres sur l'axe temporel (dans le cas de 4 fenêtres).

fenetres.svgFigure pleine page

Dans le premier cas, les fenêtres sont contigues, ce qui correspond au cas d'un signal de longueur Nt acquis en une seule fois. Le déclenchement de l'acquisition (D) se fait dans la première fenêtre. Les fenêtres constituent simplement un découpage du signal qui permettra sa visualisation. Celle-ci pourra d'ailleurs se faire par une fenêtre glissante, de la même largeur que la fenêtre définie dans le stockage.

Dans le second cas, les fenêtres ne sont pas contigues, car chacune constitue une acquisition déclenchée par une condition sur un des signaux (le déclenchement est souvent placé au centre de la fenêtre). Ce mode d'enregistrement est utilisé pour la visualisation des signaux périodiques (ou quasi périodiques). La visualisation se fait fenêtre par fenêtre. Il faut remarquer que l'information sur la position des fenêtres sur l'axe temporel n'est pas donnée; seule la durée de la fenêtre est connue. Par ailleurs, il peut être intéressant de faire l'acquisition expérimentale en un seul bloc de grande durée puis de faire le découpage en fenêtres de manière logicielle, en mettant en œuvre un déclenchement logiciel (lequel est effectivement logiciel dans les oscilloscopes numériques).

3. Format d'enregistrement

Les informations sur les signaux et les signaux eux-mêmes sont stockés dans un tableau d'octets (8 bits). Ce tableau est interprété comme une image RGBA comportant trois couches de 8 bits. Elle sera enregistrée au format PNG, qui offre une compression sans perte très efficace. Pour lire le fichier et restituer les signaux, il faudra évidemment disposer d'un décodeur de format PNG, qui effectue la décompression et restitue le tableau d'octets initial. Le fait que la compression soit sans perte est bien sûr primordial.

Pour une raison qui apparaîtra plus loin, nous n'utiliserons pas la couche A (alpha) de l'image RGBA. Cette couche est utilisée au moment d'écrire l'image dans un tampon graphique (frame buffer) pour définir un coefficient de mélange avec la valeur qui existe déjà dans ce tampon. De ce fait, l'information présente dans la couche alpha est perdue dans cette opération d'écriture.

Le tableau définissant l'image est interprété comme un tableau à trois dimensions, de largeur w, de hauteur h, et de profondeur c=4. Il faut remarquer que les tableaux, qu'ils soient en mémoire ou dans un fichier, sont toujours unidimensionnels par nature, et les informations w,h sont en fait ajoutées dans le fichier PNG. Les données sont rangées ligne par ligne (le nombre de lignes est h). Dans chaque ligne, il y a w pixels. Les 4 octets pour chaque pixel sont rangés de manière contigue. Si l'on veut un indice qui parcourt tous les pixels d'une couche (par exemple la couche R), il faudra que cet indice soit incrémenté de 4 à chaque itération.

Chaque voie sera stockée dans une couche de l'image. Nous utiliserons les trois couches R,G,B, portant les numéros 0,1 et 2. Considérons dans un premier temps le stockage de trois voies maximum.

Le fichier devra aussi contenir les informations relatives au signal (que l'on désignera simplement par informations par opposition à signaux). Un nombre P d'octets est réservé pour ces informations (P=100). Elles seront stockées dans la couche 0, juste avant le signal. Pour des raisons d'alignement des données et de facilité d'implémentation, le stockage du signal dans les trois couches se fera après P octets , qui seront inutilisés pour les couches 1 et 2. Le gaspillage d'octets qui en résulte (200 octets) est infime comparé au nombre total d'octets (plusieurs centaines de milliers). Finalement, le nombre d'octets stockés dans chaque couche est No=P+Nt pour des signaux codés en 8 bits ou No=P+2Nt pour des signaux codés en 16 bits. Pour le codage en 16 bits, on mettra d'abord l'octet de poids faible puis l'octet de poids fort. Le nombre d'octets de chaque couche doit être égal au nombres de pixels dans l'image. Cependant, l'image doit avoir wh pixels. Il est théoriquement possible (en Python) de définir une image ne comportant qu'une seule ligne, mais les navigateurs web n'autorisent pas l'ouverture d'images dont une des dimensions est trop grande. On opte donc pour une image carrée w=h. Pour définir sa taille, on commence par calculer w afin que w2 soit supérieur au nombres d'octets à stocker dans chaque couche (pour chaque voie). Finalement, le tableau contenant chaque voie devra être complété par Nz=w2-No octets (contenant des zéros) pour avoir la taille voulue. Pour résumer, chacune de trois voies est stockée dans un tableau contenant P octets pour les informations, Nt ou 2Nt pour le signal lui-même et enfin Nz octets de zéros pour obtenir la longueur w2 (mais Nz n'est pas fixe car il dépend de Nt).

Dans le cas où le nombre de voies est 4,5 ou 6, on définira un second tableau de même structure que le précédent et les deux tableaux seront assemblés pour constituer une seule image de largeur w et de hauteur 2w. Nous nous limitons à 6 voies maximum mais la méthode pourra facilement être appliquée avec un nombre de voies plus grand. Par exemple, pour stocker 8 voies, il faudra une image de hauteur 3w. On pourrait s'inquiéter du gaspillages d'octets lorsque certaines voies ne sont pas utilisées (par exemple si on a besoin de 4 voies, 2 sont inutilisées) mais ce gaspillage est en réalité négligeable grace à la compression.

Analysons la structure du tableau final, qui sera stocké dans le fichier image et restitué après son décodage. Il est important de comprendre que les octets des 3 voies sont entrelaçés, puisque les 4 octets de chaque pixel sont contigus en mémoire. Les 4P premiers octets de l'image sont donc occupés par les P premiers octets de chaque voie. On utilise en fait seulement la couche 0 pour stocker les informations. L'indice de l'octet numéro n de la couche c est i=4n+c. L'octet de début du signal stocké dans la couche c se trouve à l'indice istart=4P+c si c<4 et istart=4P+4Not+4Nz+4P+c-3 si c>3, où Not désigne le nombre d'octets utilisés pour stocker le signal d'une voie (égal à Nt pour des signaux 8 bits, 2Nt pour des signaux 16 bits).

Afin de permettre la lecture des données, les indices istart seront stockés dans les informations (car le nombre de zéros ajoutés n'est pas donné). Il ne faudra pas oublier que la lecture des octets d'une voie à partir de l'indice istart se fera par incrément de 4.

La figure suivante représente les premiers octets du tableau de l'image et permet de préciser l'entrelaçement des différentes voies. Les couleurs permettent de repérer les octets des voies R,G,B,A (rouge, vert, bleu, noir).

tableau.svgFigure pleine page

Pour finir, voici le nombre d'octets d'une image contenant 3 voies pour des signaux stockés en 8 bits :

Nb=4(P+Nt+Nz)(1)

L'ordre de grandeur de ce nombre est 4Nt, le double pour des signaux en 16 bits. Nt pourra aller de 105 à 108. Pour une valeur de 106, la taille du fichier sans compression serait de 4 Mo, ce qui est déjà environ 10 fois moins que le stockage en CSV. La compression permettra de réduire la taille à quelques 100 Ko et rendra donc le fichier facilement accessible depuis une page HTML, pour y être visualisé ou même traité par un code Javascript.

Voici les informations relatives aux signaux, qui seront stockées dans les P premiers octets de la voie 0 :

Il faudra bien sûr définir la position de ces informations dans les P premiers octets de la couche 0.

Le format retenu pour le stockage de la fréquence d'échantillonnage ne permet pas de traiter le cas de fréquences inférieures au Hz, mais il sera aisé d'adapter l'utilisation à ce cas (en changeant l'unité de la fréquence).

4. Implémentation en Python

Nous implémentons en Python le stockage, ce qui permettra de traiter des fichiers CSV générés par un oscilloscope numérique ou une carte d'acquisition. On implémente aussi la lecture et on verra un exemple de visualisation des signaux sous forme d'animation.

L'enregistrement d'une image avec la fonction imageio.imwrite (ou avec des fonctions équivalentes d'autres modules) nécessite de fournir un tableau numpy.ndarray tridimensionnel de dimensions (h,w,4). Ce tableau est applati (c.a.d. converti en un tableau unidimensionnel) par la fonction imwrite. À notre connaissance, il n'est pas possible de fournir un tableau déjà applati. Or il est beaucoup plus simple de générer le tableau de l'image sous forme applati (tableau à une dimension) car la taille des lignes de l'image (w) n'a aucun lien avec la taille des fenêtres ou des signaux. C'est d'ailleurs sous cette forme que les données seront disponibles lors de la lecture de l'image en Javascript (voir plus loin). Pour l'implémentation en Python, on doit d'abord générer chaque voie sous la forme d'un tableau à une dimension puis assembler les voies sous la forme d'un tableau de dimensions (h,w,4). Ces opérations sont assez simples à programmer avec numpy, mais l'implémentation dans un langage de bas niveau (comme le langage C) serait évidemment beaucoup plus directe et efficace car on générerait directement le tableau unidimensionnel de l'image.

La première classe contient seulement des constantes, les positions des informations qui seront stockées au début de la couche 0 :

savePNG.py
import numpy as np
import imageio


class SavePNGDataPos:
    # informations sur le signal
    # position des données (indices d'octets)
    SAMPLEFREQ = 0 # fréquence d'échantillonnage en Hz (32 bits)
    FRAMESIZE = 4# taille des fenêtres (32 bits)
    NUMFRAMES = 8# nombre de fenêtres (16 bits)
    SAMPLESIZE = 10# taille des échantillons (8 bits)
    NUMCHANNELS = 11# nombre de voies (8 bits)
    SIGNALPOS = 12 # début du signal (16 bits)
    CH0SCALE = 14# échelle de tension de la voie 0 multipliée par 100 (16 bits)
    CH1SCALE = 16# échelle de tension de la voie 1 multipliée par 100 (16 bits)
    CH2SCALE = 18# échelle de tension de la voie 2 multipliée par 100 (16 bits)
    CH3SCALE = 20# échelle de tension de la voie 3 multipliée par 100 (16 bits)
    # etc
    CH0POS = 50 # début du signal de la voie 0 (position en octets) (32 bits)
    CH1POS = 54 # début du signal de la voie 1 (position en octets) (32 bits)
    CH2POS = 58 # début du signal de la voie 2 (position en octets) (32 bits)
    CH3POS = 62 # début du signal de la voie 3 (position en octets) (32 bits)
    # etc             
                

En ce qui concerne l'échelle de tension, seule la première constante CH0SCALE sera vraiment utilisée. Il suffira d'ajouter à cette valeur 4 fois le numéro de la voie. Il en est de même pour la constante CH0POS. Toutes ces données tiennent largement dans P=100 octets (on pourrait aller jusqu'à 12 voies mais l'implémentation qui suit se limite à 6 voies).

Ces constantes devront bien sûr être reprises par l'application Javascript de lecture des signaux. Si on doit ajouter une information, il faudra mettre à jour ces constantes.

La classe SavePNG effectue l'encodage des données et l'enregistrement sous forme d'image.

Dans le constructeur (fonction __init__), on définit un tableau bidimensionnel d'entiers 8 bits, nommé self.data, qui contiendra les échantillons de chaque voie. Le constructeur calcule quelques tailles utilisées par la suite et enregistre les informations dans les premiers octets de la voie 0.

class SavePNG:
    def __init__(self,sampleFreq,frameSize,numFrames,sampleSize,numChannels,scale):
        const = SavePNGDataPos()
        if (numChannels > 6):
            raise ValueError('Number of channels must be <= 6')
        if numChannels != len(scale):
            raise ValueError('scale length must be numChannels')
        self.sampleFreq = sampleFreq # fréquence d'échantillonnage en Hz
        self.frameSize = frameSize # Nombre d'échantillons dans chaque fenêtre (Nf)
        self.numFrames = numFrames # Nombre de fenêtres (N)
        self.sampleSize = sampleSize # 8 à 16 bits
        self.numChannels = numChannels #nombre de voies
        self.scale = scale # liste des échelles des voies (en volts)
        self.signalStart = 100 # P

        self.frameBytes = self.frameSize
        if self.sampleSize > 8:
            self.frameBytes *=2
        self.signalSize = self.frameSize*self.numFrames 
        self.signalBytes = self.signalSize
        if self.sampleSize > 8:
            self.signalBytes *= 2
        self.channelLength = self.signalStart + self.signalBytes
        
        # calcul des dimensions de l'image
        Nimg = int(np.around(np.sqrt(self.channelLength))+2)
        self.nzeros = Nimg*Nimg-self.channelLength
        self.imageWidth = Nimg
        self.imageHeight = Nimg
        if self.numChannels > 3:
            self.imageHeight *= 2
            
        self.data = np.zeros((self.channelLength+self.nzeros,self.numChannels),dtype=np.uint8)

        # enregistrement des informations
        self.write_int(self.sampleFreq,const.SAMPLEFREQ,4)
        self.write_int(self.frameSize,const.FRAMESIZE,4)
        self.write_int(self.numFrames,const.NUMFRAMES,2)
        self.write_int(self.sampleSize,const.SAMPLESIZE,1)
        self.write_int(self.numChannels,const.NUMCHANNELS,1)
        self.write_int(self.signalStart,const.SIGNALPOS,2)
        
        p=const.CH0SCALE
        for c in range(numChannels):
            self.write_int(scale[c]*100,p,2)
            p += 2

        p=const.CH0POS
        for c in range(numChannels):
            if c in [0,1,2]:
                self.write_int(self.signalStart*4+c,p+c*4,4)
            if c in [3,4,5]:
                self.write_int(self.signalStart*4+self.signalBytes*4+self.signalStart*4+self.nzeros*4+(c-3),p+c*4,4)
        
        
    def write_int(self,x,pos,nbytes):
        # écriture d'une information
        x = round(x)
        for i in range(nbytes):
            self.data[pos,0] = x & 0xFF
            x = x >> 8
            pos += 1

    

    def write_signal_8bits(self,x,channel):
        if len(x) != self.signalSize:
            raise ValueError('signal length must be %d'%self.signalSize)
        y = np.around((x.copy()+self.scale[channel])*0xFF/(2*self.scale[channel]))
        i = self.signalStart
        self.data[i:i+self.signalBytes,channel] = np.array(y,dtype=np.uint8)

    def write_signal_16bits(self,x,channel):
        if len(x) != self.signalSize:
            raise ValueError('signal length must be %d'%self.signalSize)
        y = np.around((x.copy()+self.scale[channel])*0xFFFF/(2*self.scale[channel]))
        z = np.array(y,dtype=np.uint16)
        z_low = np.array(z & 0xFF,dtype=np.uint8)
        z_high = np.array((z >> 8)& 0xFF,dtype=np.uint8)
        zz = np.array([z_low,z_high]).T
        i = self.signalStart
        self.data[i:i+self.signalBytes,channel] = np.ravel(zz)
            

    def write_frame_8bits(self,x,iframe,channel):
        if len(x) != self.frameSize:
            raise ValueError("frame size must %d"%self.frameSize)
        y = np.around((x.copy()+self.scale[channel])*0xFF/(2*self.scale[channel]))
        i = self.signalStart+iframe*self.frameBytes
        self.data[i:i+self.frameBytes,channel] = np.array(y,dtype=np.uint8)

    def write_frame_16bits(self,x,iframe,channel):
        if len(x) != self.frameSize:
            raise ValueError("frame size must %d"%self.frameSize)
        y = np.around((x.copy()+self.scale[channel])*0xFFFF/(2*self.scale[channel]))
        z = np.array(y,dtype=np.uint16)
        z_low = np.array(z & 0xFF,dtype=np.uint8)
        z_high = np.array((z >> 8)& 0xFF,dtype=np.uint8)
        zz = np.array([z_low,z_high]).T
        i = self.signalStart+iframe*self.frameBytes
        self.data[i:i+self.frameBytes,channel] = np.ravel(zz)

    # méthodes publiques :

    def write_signal(self,x,channel):
        # écriture d'un signal complet (N fenêtres)
        if self.sampleSize <=8:
            self.write_signal_8bits(x,channel)
        else:
            self.write_signal_16bits(x,channel)

    def write_frame(self,x,iframe,channel):
        # écriture d'une signal
        if self.sampleSize <=8:
            self.write_frame_8bits(x,iframe,channel)
        else:
            self.write_frame_16bits(x,iframe,channel)
        
    def save(self,fileName):
        # enregistrement de l'image PNG
        w = self.imageWidth
        c = []
        for i in range(self.numChannels): # mise de chaque couche sous forme d'une matrice carrée
            c.append(np.reshape(self.data[:,i],(w,w)))      
        
        image = np.zeros((self.imageHeight,self.imageWidth,4),dtype=np.uint8) # matrice contenant l'image
        for i in range(self.numChannels): # remplissage de la matrice
            if i in [0,1,2]:
                image[0:w,:,i] = c[i]
            else:
                image[w:2*w,:,i-3] = c[i]
        image[:,:,3] = 0xFF # couche alpha
        imageio.imwrite(fileName,image)
                  
                  

Quelques remarques sur cette classe :

La classe LoadPNG effectue la lecture d'une image PNG et restitue les signaux. Le tableau renvoyé par la fonction imageio.imread est un tableau tri-dimensionnel, que l'on applatit avec la fonction numpy.ravel. Dans le tableau à une dimension que l'on obtient, les octets sont rangés comme décrit plus haut, avec un entrelaçement des couches R,G,B,A de l'image. En effet, pour un tableau de dimensions (h,w,c), l'applatissement commence par la dernière dimension : l'itération de fait d'abord sur le troisième indice, puis sur le deuxième et enfin sur le premier. Le constructeur se charge de décoder le tableau et de placer les signaux dans un tableau à deux dimensions nommé self.signalData. La seconde dimension de ce tableau indique la voie, la premier l'indice de l'échantillon.

class LoadPNG:
    def __init__(self,imageFile):
        self.image = imageio.imread(imageFile)
        (self.imageHeight,self.imageWidth,nc) = self.image.shape
        const = SavePNGDataPos()
        self.imageData = np.ravel(self.image) # applatissement du tableau
        
        self.sampleFreq = self.read_int(const.SAMPLEFREQ,4)
        self.frameSize = self.read_int(const.FRAMESIZE,4)
        self.numFrames = self.read_int(const.NUMFRAMES,2)
        self.sampleSize = self.read_int(const.SAMPLESIZE,1)
        self.numChannels = self.read_int(const.NUMCHANNELS,1)
        self.signalStart = self.read_int(const.SIGNALPOS,2)

        print("Sample rate (Hz) = %d"%self.sampleFreq)
        print("Frame size = %d"%self.frameSize)
        print("Num frames = %d"%self.numFrames)

        self.scale = []
        self.channelStart = []
        for c in range(self.numChannels):
            self.scale.append(self.read_int(const.CH0SCALE+c*2,2)*1.0/100)
            self.channelStart.append(self.read_int(const.CH0POS+c*4,4))

        self.signalSize = self.frameSize*self.numFrames
        self.signalBytes = self.signalSize
        if self.sampleSize > 8:
            self.signalBytes *= 2
            
        self.signalData = np.zeros((self.signalSize,self.numChannels),dtype=np.float32)
        
        for c in range(self.numChannels):
            data = self.imageData[self.channelStart[c]:self.channelStart[c]+self.signalBytes*4:4]
            if self.sampleSize <=8:
                self.signalData[:,c] = data/0xFF*(2*self.scale[c])-self.scale[c]
            else:
                low = np.array(data[0::2],dtype=np.uint16)
                high = np.array(data[1::2],dtype=np.uint16)
            
                self.signalData[:,c] =(low+high*0xFF)/0xFFFF*(2*self.scale[c])-self.scale[c]

        self.time = np.arange(self.signalSize)/(self.sampleFreq)
        self.frame_time = np.arange(self.frameSize)/(self.sampleFreq)
        

    def read_int(self,pos,nbytes):
        # lecture d'une information
        x = 0
        p = 0
        for i in range(nbytes):
            x += self.image[0,pos+i,0]*2**p
            p+= 8
        return int(x)
        
    # méthodes publiques

    def get_signal(self,channel):
        # récupération d'un signal complet
        return self.time.copy(),self.signalData[:,channel]

    def get_frame(self,channel,iframe):
        # récupération d'une fenêtre
        i = iframe*self.frameSize
        return self.frame_time.copy(),self.signalData[i:i+self.frameSize,channel]
                  
                  

5. Exemple

Bien que l'objectif soit de stocker des signaux provenant d'un oscilloscope (ou d'une carte d'acquisition), nous faisons un test avec des signaux de synthèse, ce qui permettra de tester l'influence du bruit sur le taux de compression.

Le script suivant génère trois signaux sinusoïdaux ou carrés, avec un bruit de phase et un bruit de tension ajustables. La première sauvegarde (fichier signal-test-1.png) enregistre les signaux en entier; dans ce cas, la taille de la fenêtre permet de définir la largeur de la fenêtre de visualisation du signal. La seconde sauvegarde (fichier signal-test-2.png) enregistre un découpage du signal en fenêtres, chacune étant obtenue par une condition de déclenchement. Les fenêtres sont générées grace à un déclenchement logiciel sur le premier signal. Le déclenchement se fait lorsque ce signal passe d'une valeur négative à une valeur positive (seuil 0.0 V avec un front montant) puis confirmation des valeurs positives sur 20 échantillons, afin que la synchronisation se fasse correctement même en présence de bruit. Le nombre d'échantillons pour chacune des trois voies est de 520 000 environ (il varie en fonction du niveau de bruit).

Le fichier PNG est généré avec la classe SavePNG puis lu avec la classe LoadPNG. Une animation permet de faire défiler les fenêtres.

savePNG-test.py
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.animation as animation
from savePNG import SavePNG,LoadPNG
from numpy.random import normal

freq = 10354
fechant = 1e7
te = 1/fechant
frameSize = 10000
N = 60 # environ (valeur surestimée)
Nt = N*frameSize

sigma = 0.1
carre=False
t = np.arange(Nt)*te
phase1 = 2*np.pi*freq*t % (2*np.pi) + normal(0,0.01,Nt)
u1 = 5.0*np.sin(phase1)
phase2 = phase1+np.pi/2
u2 = 3.0*np.sin(phase2)
phase3 = phase1+np.pi/3
u3 = 5.0+1.0*np.sin(phase3)

if carre:
    for i in range(Nt):
        if u1[i] > 0:
            u1[i] = 5.0
        else:
            u1[i] = -5.0
        if u2[i] > 0:
            u2[i] = 3.0
        else:
            u2[i] = -3.0
        if u3[i] > 0:
            u3[i] = 7.0
        else:
            u3[i] = 5.0

# ajout du bruit
u1 +=normal(0,sigma,Nt)
u2 += normal(0,sigma,Nt)

sampleSize = 8
numChannels = 3
scale = [10,10,10]

# enregistrement des signaux complets
numFrames = N
savePNG = SavePNG(fechant,frameSize,numFrames,sampleSize,numChannels,scale)
savePNG.write_signal(u1,0)
savePNG.write_signal(u2,1)
savePNG.write_signal(u3,2)
savePNG.save('signal-test-1.png')

# découpage en fenêtres avec une condition de déclenchement sur u1
startFrame = []
i = frameSize//2
while i + frameSize < Nt:
    while i + frameSize < Nt:
        if u1[i]<0.0 and u1[i+1] > 0.0: # détection d'un front montant
            detect = True
            for k in range(20): # confirmation du changement de signe
                i += 1
                if u1[i]<0.0:
                    detect = False
                    break
            if detect:
                break
        i += 1
    startFrame.append(i)
    i += frameSize

numFrames = len(startFrame)
Nt = numFrames*frameSize
print('Nf = %d'%numFrames)
print('Nt = %d'%Nt)


# enregistrement des signaux avec découpage en fenêtres synchronisées
savePNG = SavePNG(fechant,frameSize,numFrames,sampleSize,numChannels,scale)

for f in range(len(startFrame)):
    iframe = startFrame[f]-frameSize//2
    savePNG.write_frame(u1[iframe:iframe+frameSize],f,0)
    savePNG.write_frame(u2[iframe:iframe+frameSize],f,1)
    savePNG.write_frame(u3[iframe:iframe+frameSize],f,2)
    
savePNG.save('signal-test-2.png')

#lecture et animation
print("Lecture des signaux")
load = LoadPNG('signal-test.png')

fig,ax = plt.subplots()
t,u0 = load.get_frame(0,0)
t,u1 = load.get_frame(1,0)
t,u2 = load.get_frame(2,0)
t*=1e6


line0, = ax.plot(t,u0)
line1, = ax.plot(t,u1)
line2, = ax.plot(t,u2)
ax.grid()
plt.xlabel('t (us)')
plt.ylabel('volts')
maxU = 10
ax.axis([t[0],t[len(t)-1],-maxU,maxU])
numFrames = load.numFrames

def animate(i):
    global load,line0,line1,t
    (tt,u0) = load.get_frame(0,i)
    (tt,u1) = load.get_frame(1,i)
    (tt,u2) = load.get_frame(2,i)
    
    line0.set_data(t,u0)
    line1.set_data(t,u1)
    line2.set_data(t,u2)

ani =animation.FuncAnimation(fig,animate,frames=numFrames,repeat=True,interval = 200)
plt.show()
              
              

Le nombre d'octets nécessaires au stockage de ces trois signaux (en binaire brut) serait 1560ko. La taille du fichier PNG dépend considérablement du bruit et de la forme des signaux.

Pour des signaux sinus avec des valeurs de sigma (écart-type du bruit) égaux à 0, 0.0.1 et 0.1, on obtient des taux de compression respectivement égaux à 5.5, 5.1, 2.8.

Pour des signaux carrés avec des valeurs de sigma égaux à 0, 0.0.1 et 0.1, on obtient des taux de compression respectivement égaux à 99, 26 et 3.0.

La compression est la plus efficace sur le signal carré sans bruit, ce qui n'est pas étonnant puisque ce signal contient des grandes plages de valeurs constantes. La compression d'image PNG est en effet particulièrement efficace pour les figures comportant des dessins sur un fond uniforme ou, dans une moindre mesure, pour des photographies comportant beaucoup d'aplats de couleur (par ex. un ciel bien bleu).

Pour des signaux ayant un caractère aléatoire très marqué, le taux de compression est d'environ 3.

Pour comparer à un stockage CSV, il faut connaître le nombre de chiffres significatifs utilisés pour écrire chaque tension. Dans les fichiers CSV générés par les oscilloscopes numériques, ce nombre n'est pas optimisé et il est de l'ordre de 10, ce qui fait que le stockage CSV occupe 10 fois plus de place qu'un fichier binaire et environ 30 fois plus que le fichier PNG (dans le pire des cas).

Pour le transfert des données sur le web, cela fait une très grande différence. Par ailleurs, l'extraction des signaux sous forme binaire par un code Javascript sera extrêmement plus rapide que la lecture de chaînes de caractères.

6. Module Javascript

Le fichier oscilloscope.js contient la définition d'une classe nommée Oscilloscope, qui permet de lire un fichier PNG et de tracer les signaux qu'il contient en statique ou en animation (dans un canvas HTML).

La description détaillée de cette classe dépasse le cadre de ce document mais nous allons tout de même décrire son utilisation au sein d'une page HTML et montrer son fonctionnement.

Le téléchargement de l'image et son décodage (conversion de PNG en format binaire brut) ne sont pas implémentés dans cette classe. Il sera en effet possible de créer plusieurs instances de cette classes avec le même fichier source.

Le script oscilloscope.js est chargé avec l'élément suivant placé dans l'élément head de la page :

<script src='oscilloscope.js'></script>                
                

La manière la plus simple (à notre connaissance la seule en javascript standard) de récupérer le contenu binaire d'une image PNG est de la dessiner sur un canvas avec la fonction context.drawImage, puis de récupérer le contenu du canvas sous la forme d'un tableau d'octets avec la fonction context.getImageData. Le décodage du fichier PNG se fait dans la fonction drawImage mais l'écriture dans le canvas implique évidemment une perte de l'information contenue dans la couche alpha, c'est pourquoi nous n'avons pas utilisé cette couche. La fonction getImageData renvoie une image (sous la forme d'un tableau d'octets conforme au format décrit plus haut) qui contient bien une couche alpha (car l'image est RGBA) mais avec une valeur de 0xFF sur tous les pixels. Lors de la création de l'image, nous avons attribué la valeur 0xFF pour la couche alpha de tous les pixels afin que l'image soit bien dessinée sur le canvas.

La page HTML doit contenir un canvas pour acceuillir la fenêtre de l'oscilloscope :

<div><canvas id='canvasA'/></div>

La classe Image permet de générer une image HTML (non visible par défaut) et de télécharger le fichier PNG de manière asynchrone. L'attribut onload permet de définir la fonction à appeler lorsque l'image est chargée. C'est dans cette fonction que l'on récupère le contenu de l'image et qu'on crée une instance de la classe Oscilloscope. Voici le code qui fait tout cela :

// canvas non visible, pour la récupération de l'image
let canvas = document.createElement('canvas');
let context = canvas.getContext('2d');
let imageA = new Image();
imageA.src = '../../../../javascript/oscillo/signal-test-2.png';
imageA.onload = function(e) {
    canvas.width = imageA.width;
    canvas.height = imageA.height;
    context.drawImage(imageA, 0, 0 );
    let imgData = context.getImageData(0, 0, imageA.width, imageA.height);
    let width = 1200;
    let height = 600;
    let buttonSize = 50;
    let scanMode = 0; // 0 pour un tracé fenêtre par fenêtre, 1 pour une fenêtre glissante
    let period = 200; // période de rafraichissement de l'animation (en ms)
    oscilloscopeA = new Oscilloscope(imgData,'canvasA',width,height,buttonSize,scanMode,period);
    oscilloscopeA.setChannelsLegend(['voie 1','voie 2','voie 3', 'voie 4']);
    oscilloscopeA.draw();
}    
                     

Pour le fichier signal-test-2.png, nous avons choisi scanMode = 0 car le signal a été découpé en fenêtres synchronisées par une condition de déclenchement. Il est possible de faire défiler les fenêtres une par une (avec les boutons NEXT et LAST) ou bien de déclencher une animation avec START. L'instant d'un point particulier (par rapport au début de la fenêtre) peut être obtenu en cliquant avec la souris sur le graphique (lorsque l'animation est arrêtée). Il s'affiche en dessous du graphique (Δt). Les boutons FASTER et SLOWER permettent d'augmenter ou de réduire la période de rafraichissement de l'animation, mais en dessous d'une certaine période, la vitesse est limitée par la durée de tracé des courbes, plus ou moins longue en fonction du nombre d'échantillons par fenêtre (ici 10000).

Le fichier signal-test-1.png contient les signaux complets. Dans ce cas les fenêtres sont contigues et il est judicieux d'utiliser scanMode = 1 pour la visualisation. Ce cas correspond plutôt à l'enregistrement d'un phénomène transitoire (par opposition à périodique) très long.

let imageB = new Image();
imageB.src = 'signal-test-1.png';
imageB.onload = function(e) {
    canvas.width = imageB.width;
    canvas.height = imageB.height;
    context.drawImage(imageB, 0, 0 );
    let imgData = context.getImageData(0, 0, imageB.width, imageB.height);
    let width = 1200;
    let height = 600;
    let buttonSize = 50;
    let scanMode = 1; // 0 pour un tracé fenêtre par fenêtre, 1 pour une fenêtre glissante
    let period = 200;
    oscilloscopeB = new Oscilloscope(imgData,'canvasB',width,height,buttonSize,scanMode,period);
    oscilloscopeB.setChannelsLegend(['voie 1','voie 2','voie 3', 'voie 4']);
    oscilloscopeB.draw();
}                             
               

Le temps affiché en haut à gauche (t_min) est l'instant de début de la fenêtre par rapport au début du signal complet. Lorsqu'on clique sur le graphique (animation stoppée), on obtient en dessous du graphique la durée écoulée du point par rapport au début de la fenêtre (Δt) et le temps écoulé depuis le début du signal (t). Les boutons FASTER et SLOWER permettent d'augmenter ou de diminuer l'incrément de la fenêtre glissante. La période de rafraichissement de l'animation est fixe mais elle peut être choisie au moment de la création de l'objet Oscilloscope grace au paramètre period.

Creative Commons LicenseTextes et figures sont mis à disposition sous contrat Creative Commons.