Consignes

La note ne valide pas seulement le résultat de votre programme, mais également son style :

Vérifiez ces points avant de demander à votre intervenant de valider votre code.

Liens utiles

1. Dégrader une image pour mieux la compresser

Au TP2, nous avons vu que plus les données présentent des répétitions, plus les algorithmes de compression peuvent être efficaces.

Il est parfois intéressant de transformer les données d'une image avant d'utiliser un algorithme de compression. Un exemple connu est par exemple la transformée de Burrows Wheeler. Ces transformations ne modifie pas la taille des données, mais facilite les algorithmes de compressions utilisés ensuite...

Certaines transformations entrainent une perte de qualité : on parle alors de compression de données avec perte. L'objectif est de sacrifier un peu d'information pour gagner beaucoup en compression.

1.1. Compression avec perte

Nous allons nous intéresser à la compression d'images. Pour cela, nous allons réutiliser la bibliothèque image.py du TP1.

1.1.1. Une transformation simple

La manière la plus simple pour augmenter les répétitions dans une image est de considérer les pixels par blocs et de tous leur affecter la même couleur.

Telechargez les fichiers image.py, vector.py, tp4-titi_grosminet.py et lac.ppm. Vous allez completer le fichier tp4-titi_grosminet.py dans la suite du TP...

Complétez la fonction moyenneParBlocs(T, lng) qui prend en entrée un tableau T et un entier lng. Cette fonction retourne un tableau de la même longueur que T dont les lng premières valeurs sont toutes égales à la moyenne des lng premières valeurs de T. Ensuite, un deuxième bloc prend la valeur des lng valeurs suivantes, et ainsi de suite.

Exemples:

>>> T = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
>>> moyenneParBlocs(T, 5)
[2, 2, 2, 2, 2, 7, 7, 7, 7, 7]
>>> moyenneParBlocs(T, 4)
[1, 1, 1, 1, 5, 5, 5, 5, 8, 8]
>>> moyenneParBlocs(T, 3)
[1, 1, 1, 4, 4, 4, 7, 7, 7, 9]

Le code de cette fonction ressemble à ceci. Modifiez le pour prendre en compte le dernier bloc, dont la taille peut être différente de lng.

def moyenneParBlocs(T, lng):
    """
    Retourne un tableau dont les ``lng`` premières valeurs sont égales à la
    moyenne des ``lng`` première valeurs de ``T``, les ``lng`` suivantes sont
    égales à la moyenne des ``lng`` valeurs suivantes de ``T`` et ainsi de
    suite.
    """
    res = []
    for i in range(0, len(T), lng):
        taille_bloc = lng     # modifier pour gérer le dernier bloc
        moyenne = sum(T[i:i+lng]) // taille_bloc
        res.extend([moyenne]*taille_bloc)
    return res

1.1.2. Impact sur la compression d'une image

On peut charger une image à partir d'un fichier de la manière suivante :

>>> im = Image('lac.ppm')
>>> im
Image de dimensions 500 x 353

Une fois l'image chargée, on peut la convertir en une chaine de caractères avec la méthode im.to_string.

>>> s = im.to_string()
>>> len(s) # une très très très longue chaine...
1770291
>>> s[:100] # l'image est décrite comme une suite de nombres
'500 353 151 128 126 120 121 127 134 140 140 140 133 126 124 124 121 119 119 119 119 120 119 119 119 '

La procédure (fournie) simplifieImageMoyenne(im, lng) utilise votre fonction moyenneParBlocs pour simplifier les couleurs d'une image, créant ainsi des blocs de répétitions de taille lng.

  1. Écrivez la procédure suivante :

    def testCompresseMoyenne(fichier, lng):
        """
        Charge l'image contenu dans le fichier et
          - affiche la taille de l'image (sous forme de chaine)
          - affiche la taille de l'image compressée (sous forme de tableau
            d'octets)
          - simplifie l'image en remplaçant les blocs par leurs moyennes
          - affiche la taille de l'image simplifiée (sous forme de chaine)
          - affiche la taille de l'image simplifiée compressée (sous forme
            de tableau d'octets)
          - sauve l'image simplifiée dans un fichier "...-lng.ppm"
        """
    

    Les fonctions fournies compresse et decompresse permettent de compresser/décompresser une chaine de caractères (str) et produisent un tableau d'octets (bytearray). Ces fonctions utilisent une variante de l'algorithme LZ78 étudié au TP2.

    Par exemple sur l'un des fichiers du TP1:

    >>> testCompresseMoyenne("vague.ppm", 8)
    image :                                     1901226
    image compressée :                           511636
    image après simplification :                1903335
    image après simplification compressée :      113996
    

    L'image obtenue ressemble alors à

    ./TP4/vague-8.jpg
  2. Exécutez le code suivant :

    >>> testCompresseMoyenne("lac.ppm", 5)
    ???
    
    >>> testCompresseMoyenne("lac.ppm", 8)
    ???
    
    >>> testCompresseMoyenne("lac.ppm", 20)
    ???
    
    

    Observez les images obtenues dans les fichiers lac-5.ppm, lac-8.ppm et lac-20.ppm.

    • Que pouvez-vous dire de la taille de l'image une fois compressée en fonction de la taille des blocs ?

    • Que pouvez-vous dire de la qualité visuelle de l'image obtenue en fonction de la taille des blocs ?

    Inscrivez vos observations dans votre fichier.

2. Changement de représentation

Il est parfois utile de changer la manière de représenter les données, afin de simplifier des calcul ou améliorer l'efficacité de la compression.

Nous allons utiliser un changement de représentation qui correspond à un changement de base dans un espace vectoriel !

2.1. Préliminaires : vecteurs

Parmi les fichiers fournis, le fichier vector.py implémente le type Vector. Un Vector est initialisé avec une liste de nombres.

>>> v = Vector([1,2,3,4])
>>> v
Vector([1, 2, 3, 4])

Les variables de types Vector peuvent être additionnées, soustraites ou multipliées par un nombre.

>>> u = Vector([1,1,1])
>>> v = Vector([2,0,-1])
>>> u+v
Vector([3, 1, 0])
>>> u-v
Vector(-[1, 1, 2])
>>> 2*u
Vector([2, 2, 2])
>>> 3*u - v
Vector([1, 3, 4])

Toutes les autres opérations sur les listes (autres que + et *) sont valides sur les vecteurs.

>>> u = Vector([])
>>> u.append(5)
>>> u.append(2)
>>> u.append(21)
>>> u
Vector([5, 2, 21])
>>> for i in range(len(u)):
...     print('coordonnée {} : {}'.format(i, u[i]))
...
coordonnée 0 : 5
coordonnée 1 : 2
coordonnée 2 : 21

2.2. Changement de bases

Il est évident que le vecteur (a,b,c) peut s'écrire sous la forme a*(1,0,0) + b*(0,1,0) + c*(0,0,1). Si on appelle e1 le vecteur (1,0,0), e2 le vecteur (0,1,0) et e3 le vecteur (0,0,1), on peut dire que (a,b,c) est égal à a*e1 + b*e2 + c*e3.

Les vecteurs e1, e2 et e3 forment une base. La base e1, e2, e3 est si naturelle qu'on l'appelle la base canonique.

On peut faire exactement la même chose en utilisant d'autres vecteurs que e1, e2, e3. Par exemple, si on choisi comme base les vecteurs (1,1,0), (1,0,1), (0,1,1), alors (a,b,c) désignera le vecteur a*(1,1,0) + b*(1,0,1) + c*(0,1,1).

Écrivez la fonction changeBase qui prend en argument

et qui calcule le nouveau vecteur x1*v1 + ... + xk * vk.

def changeBase(v, B):
    """
    calcule les coordonnées du vecteur ``v`` dans la base ``B``
    Note : la taille de ``v`` doit être égale à la taille de ``B`` !
    """

En terme d'algèbre matricielle, la fonction changeBase n'est rien d'autre que le produit du vecteur v par la matrice B où les lignes de B seraient les vecteurs de la base en question.

Dans l'exemple précédent :

               [ 1, 1, 0 |
 (2, 0, -1) x  | 1, 0, 1 | = (2, 1, -1)
               | 0, 1, 1 ]

Nous allons maintenant considérer une base différente pour des vecteurs de longueurs arbitraires.

La base escalier

La fonction fournie baseEscalier(n) retourne n vecteurs formant la base escalier de dimension n.

>>> B = baseEscalier(6)
>>> B
[(1, 1, 1, 1, 1, 1),
 (0, 1, 1, 1, 1, 1),
 (0, 0, 1, 1, 1, 1),
 (0, 0, 0, 1, 1, 1),
 (0, 0, 0, 0, 1, 1),
 (0, 0, 0, 0, 0, 1)]
>>> v = Vector([3, 2, 1, -4, -5, -6])
>>> u = changeBase(v, B)
>>> u
(3, 5, 6, 2, -3, -9)

On peut vérifier que (3,5,6,2,-3,-9) est bien égal à 3*(1,1,1,1,1,1) + 2*(0,1,1,1,1,1) + 1*(0,0,1,1,1,1) + ....

Vérifiez que votre fonction changeBase produit bien le même résultat que les exemples précédents.

Dans le langage de l'algèbre linéaire, la matrice "escalier"

   [ 1, 1, 1, 1, 1, 1, 1, 1  |
   | 0, 1, 1, 1, 1, 1, 1, 1  |
   | 0, 0, 1, 1, 1, 1, 1, 1  |
   | 0, 0, 0, 1, 1, 1, 1, 1  |
   | 0, 0, 0, 0, 1, 1, 1, 1  |
   | 0, 0, 0, 0, 0, 1, 1, 1  |
   | 0, 0, 0, 0, 0, 0, 1, 1  |
   | 0, 0, 0, 0, 0, 0, 0, 1  ]

est inversible et a pour inverse la matrice

   [ 1, -1,  0,  0,  0,  0,  0,  0  |
   | 0,  1, -1,  0,  0,  0,  0,  0  |
   | 0,  0,  1, -1,  0,  0,  0,  0  |
   | 0,  0,  0,  1, -1,  0,  0,  0  |
   | 0,  0,  0,  0,  1, -1,  0,  0  |
   | 0,  0,  0,  0,  0,  1, -1,  0  |
   | 0,  0,  0,  0,  0,  0,  1, -1  |
   | 0,  0,  0,  0,  0,  0,  0,  1  ]
  1. Écrivez la fonction baseEscalierInverse qui calcule la base correspondante.

  2. Vérifiez que cette base permet effectivement de retrouver un vecteur v à partir du résultat de changeBase(v, baseEscalier(len(v))).

Conclusion : un changement de base n'est qu'une manière alternative de représenter la même information.

3. Une base utile

Nous allons maintenant étudier un changement de base appelé Transformée de Fourier Discrète (TFD), très utilisée en pratique.

La fonction fournie baseTFD(n) produit une base de vecteurs entiers pour la TFD en dimension n.

  1. Exécutez le code suivante et observez les images obtenues :

    >>> B = baseTFD(100)
    
    >>> barGraph(B[1]).save('TFD-1.ppm')
    >>> barGraph(B[51]).save('TFD-51.ppm')
    
    >>> barGraph(B[2]).save('TFD-2.ppm')
    >>> barGraph(B[52]).save('TFD-52.ppm')
    
    >>> barGraph(B[3]).save('TFD-3.ppm')
    >>> barGraph(B[53]).save('TFD-53.ppm')
    
  2. Quelle est la forme générale des vecteurs de la base donnée par baseTFD ?

La fonction fournie TFD transforme un vecteur exprimé dans la base canonique dans le même vecteur exprimé dans la base produite par baseTFD.

Autrement dit, elle fait théoriquement la transformation inverse à changeBase(_, baseTFD).

Exécutez le code :

>>> v = Vector((1,2,3,4,5))

>>> u = TFD(v)

>>> B = baseTFD(5)
>>> v2 = changeBase(u, B)
>>> v == v2
False
>>> print(v2)
???

Observez le vecteur v2, comment le corriger pour avoir v == v2 ?

Écrivez la fonction inverseTFD(u) qui prend en entré un vecteur exprimé dans la base TFD et retourne le vecteur correspondant dans la base canonique dont toutes les coordonnées sont des entiers.

Testez votre code :

>>> v = randomVector(1000)
>>> u = TFD(v)
>>> v2 = inverseTFD(u)
>>> v == v2
True

Aide : la fonction round arrondi un flottant à l'entier le plus proche...

3.1. Des TFD et des images

En exécutant le code suivant, on constate que la représentation après application de la TFD contient beaucou de petites valeurs, mais jamais de 0.

>>> im = Image("lac.ppm")
>>> v = im.bvalues()                # récupère les intensités de bleu
>>> v = v[2000:2200]                # on ne garde que 200 pixels consécutifs
>>> barGraph(v).save('out.ppm')     # on visualise ces intensités

>>> u = TFD(v)               # on change la représentation
>>> barGraph(u).save('out.ppm')     # on observe quelques grandes valeurs et beaucoup de petites
>>> u.count(0)                      # beaucoup de petites valeurs mais jamais de 0
0

Programmez une fonction filtre qui remplace les petites valeurs d'un vecteur par 0.

Pour ceci, vous devez :

Par exemple :

>>> v = Vector([27, -27, -6, -5, -16, -2, -56, 17, 5, 74, -9, -43, -19, 73, 8, 6])
>>> filtre(v, 0.5)
Vector([27, -27, 0, 0, 0, 0, -56, 17, 0, 74, 0, -43, -19, 73, 0, 0])

>>> filtre(Vector([0,1,2,3,4,5,6,7,8,9]), 0.7)
Vector([0, 0, 0, 3, 4, 5, 6, 7, 8, 9])

Inscrivez les réponses aux questions suivantes dans votre fichier :

3.2. Compression avec perte

Nous allons maintenant utiliser le principe exposé à la question précédent pour faire une compression d'images avec perte.

Problème

Le temps de calcul de la fonction TFD devient très long lorsqu'on l'applique à de grands vecteurs. Si on essaie de l'appliquer à toutes les lignes d'une image (trois fois par ligne car il y a trois canaux de couleurs : R, G, B), le temps de calcul devient prohibitif.

Écrivez la fonction la fonction encodeTFDParBlocs(v, lng, qualite). Cette fonction reprend les mêmes principes que la fonction moyenneParBlocs mais cette fois-ci, chaque bloc de données est obtenu en appliquant la fonction TFD sur un bloc, puis en remplaçant les petites valeurs par des 0 avec la fonction filtre.

def encodeTFDParBlocs(v, lng, qualite):
    """
    Applique TFD sur le vecteur ``v`` par blocs de taille ``lng``. Chaque
    bloc résultant est filtré avec la fonction ``filtre``.
    """

Exemples :

>>> v = Vector((1,2,3,4,5,6,7,8,9,10))
>>> v
(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)

>>> encodeTFDParBlocs(v, 4, 0.25)
(10, 0, 0, 0, 26, 0, 0, 0, 19, 0)

>>> encodeTFDParBlocs(v, 6, 0.6 )
(21, -6, -6, 0, -10, 0, 34, -4, 0, -4)

>>> encodeTFDParBlocs(v, 3, 0.3 )
(6, 0, 0, 15, 0, 0, 24, 0, 0, 10)

Écrivez la fonction decodeTFDParBlocs(u, lng) qui effectue la transformation inverse de la fonction encodeTFDParBlocs en appelant la fonction inverseTFD sur les blocs de taille lng.

>>> v = randomVector(10000)
>>> u = encodeTFDParBlocs(v, 100, 1)
>>> v2 = decodeTFDParBlocs(u, 100)
>>> v == v2
True

Test

La fonction fournie simplifieImageTFD(im, lng, qualite) applique votre fonction encodeTFDParBlocs aux pixels d'une image. Plus précisément, la liste des intensité de rouge des pixels de l'image est vue comme un très long vecteur et votre fonction encodeTFDParBlocs est appliquée à ce vecteur. Ensuite, on fait de même avec le vert et le bleu.

>>> im = Image("vague.ppm")
>>> simplifieImageTFD(im, 32, 0.5)

À cet instant, im n'est même plus une image : les valeurs des pixels ne sont pas toujours entre 0 et 255 ! Il faut restaurer l'image. La fonction fournie restaurerImageTFD fait appel à votre fonction decodeTFDParBlocs afin de reconstruire une image.

>>> restaurerImageTFD(im, 32)
>>> im.save('tfd-32-50.ppm')

Vous devriez observer une image semblable à la première mais légèrement dégradée. Cette dégradation est le résultat de la suppression de la moitié des valeurs dans chacun des blocs de taille 32.

Comparaison des deux méthodes de compression avec perte

Écrivez la fonction testCompressionTFD, similaire à la fonction testCompresseMoyenne du début du TP.

def testCompresseTFD(fichier, lng, qualite):
    """
    Charge l'image contenu dans le fichier et
      - affiche la taille de l'image (sous forme de chaine)
      - affiche la taille de l'image compressée (sous forme de tableau
        d'octets)
      - simplifie l'image avec la fonction encodeTDFParBloc
      - affiche la taille de l'image simplifiée (sous forme de chaine)
      - affiche la taille de l'image simplifiée compressée (sous forme
        de tableau d'octets)
      - décode l'image simplifiée et la sauve dans un fichier
        "...TFD-lng-qualite.ppm"
    """

Le codage / décodage de l'image peut prendre du temps. Voila une petite image que vous pouvez utiliser pour vos tests..

Exécutez la commande :

>>> testCompresseTFD( 'lac.ppm', 'tfd-100-05.ppm', 100, 0.1 )
  1. Dans votre fichier, notez la taille de l'image compressée.

  2. Déterminez la valeur de lng qui vous permet d'obtenir une taille comparable avec la fonction testeCompressionMoyenne.

  3. Visuellement, quelle image vous parait le plus dégradée ? Notez vos observations dans votre fichier.

Bonus

Après avoir transformé une image avec simplifieImageTFD, lorsqu'on veut reconstruire l'image il faut absolument connaitre le taille des blocs.

Écrivez une fonction qui, à partir d'un nom de fichier :

Ensuite, écrivez une deuxième fonction qui, à partir d'un tel fichier reconstruit une image.

On peut retrouver une image a partir de sa chaine de caractere avec à la fonction Image.init_from_string.

>>> im = Image.init_from_string(s) # Construction d'un image à partir de la chaine de caractères.
>>> im.save( 'out.ppm' )