Stéganographie, partie 1 : cacher une image dans une autre
Chiffrer un message, c'est le rendre illisible : on voit qu'il y a un secret, mais on ne peut pas le lire. La stéganographie fait autre chose : elle cache l'existence même du message. Ici, on va cacher une photo à l'intérieur d'une autre. L'image transmise ressemble à une photo ordinaire, et seul celui qui connaît le procédé sait qu'elle en contient une seconde.
Le procédé abîme les deux images. On va mesurer de combien.
Mise en place
On travaille en niveaux de gris, avec les mêmes outils que pour le flou : le fichier image_io.py, à placer à côté de ton programme, et le type grille.
Il te faut deux photos : la porteuse, celle qu'on verra, et le secret, celle qu'on cache. Elles doivent avoir la même taille. La fonction recadrer est donnée : elle garde le coin en haut à gauche d'une image.
Crée un fichier stegano.py qui commence ainsi :
from image_io import charger, sauver, grille
def recadrer(g: grille, hauteur: int, largeur: int) -> grille:
"""Renvoie une nouvelle grille formée des hauteur premières lignes et des
largeur premières colonnes de g.
Précondition : hauteur <= len(g) et largeur <= len(g[0])."""
res = []
for i in range(0, hauteur):
ligne = []
for j in range(0, largeur):
ligne.append(g[i][j])
res.append(ligne)
return res
if __name__ == "__main__":
porteuse = charger("porteuse.jpg")
secret = charger("secret.jpg")
hauteur = min(len(porteuse), len(secret))
largeur = min(len(porteuse[0]), len(secret[0]))
porteuse = recadrer(porteuse, hauteur, largeur)
secret = recadrer(secret, hauteur, largeur)
Comme pour le flou, tout ce qui n'est pas une fonction reste dans le bloc if __name__ == "__main__":, à la fin du fichier.
Étape 1 - Poids fort, poids faible
Un pixel est un entier entre 0 et 255 : il s'écrit sur 8 bits. On le coupe en deux moitiés de 4 bits :
- les 4 bits de poids fort, à gauche, ceux des puissances de 2 les plus grandes,
- les 4 bits de poids faible, à droite.
Par exemple, \(182 = 1011\ 0110_2\) : les bits de poids fort sont \(1011_2\), les bits de poids faible \(0110_2\).
Couper un pixel en deux
Sur ton cahier, pour le pixel \(200\) :
- Écris son écriture binaire sur 8 bits.
- Écris ses 4 bits de poids fort, et la valeur en base 10 de ces 4 bits lus seuls.
- Même question pour ses 4 bits de poids faible.
- Calcule la valeur du poids fort fois 16, plus celle du poids faible. Que retrouves-tu ?
Réponse
- \(200 = 1100\ 1000_2\)
- \(1100_2\), qui vaut \(12\).
- \(1000_2\), qui vaut \(8\).
- \(12 \times 16 + 8 = 200\) : on retrouve le pixel. Les 4 bits de poids fort comptent en paquets de \(2^4 = 16\).
En Python, c'est donc une division entière par 16 qui donne le poids fort, et son reste qui donne le poids faible :
Lequel compte le plus ?
On modifie un pixel de deux façons. Sur ton cahier, donne dans chaque cas l'écart le plus grand possible entre l'ancien et le nouveau pixel.
- On change seulement ses 4 bits de poids faible.
- On change seulement ses 4 bits de poids fort.
Réponse
- Le poids faible vaut entre \(0000_2 = 0\) et \(1111_2 = 15\) : l'écart vaut au plus \(15\).
- Le poids fort vaut entre \(0\) et \(15\) paquets de 16 : l'écart vaut au plus \(15 \times 16 = 240\).
Un écart de 15 sur une échelle qui va jusqu'à 255 se voit à peine. C'est là qu'on va cacher le secret.
Étape 2 - Cacher un pixel
Le procédé tient en une phrase : on remplace les 4 bits de poids faible de la porteuse par les 4 bits de poids fort du secret. Le pixel transmis garde donc le poids fort de la porteuse, ce qui se voit, et porte le poids fort du secret là où il ne se voit presque pas.
À la main
La porteuse vaut \(182 = 1011\ 0110_2\) et le secret \(77 = 0100\ 1101_2\). Sur ton cahier :
- Écris l'écriture binaire du pixel transmis, puis sa valeur en base 10.
- Quel écart y a-t-il entre la porteuse et le pixel transmis ?
- Quels bits du secret ont été perdus ?
Réponse
- Poids fort de la porteuse, \(1011\), suivi du poids fort du secret, \(0100\) : \(1011\ 0100_2 = 180\).
- \(182 - 180 = 2\).
- Ses 4 bits de poids faible, \(1101_2\) : ils ne sont écrits nulle part.
Écrire cacher_pixel (obligatoire)
def cacher_pixel(p: int, s: int) -> int:
"""Renvoie le pixel formé des 4 bits de poids fort de p, suivis des
4 bits de poids fort de s.
Précondition : p et s sont des entiers entre 0 et 255."""
pass
def test_cacher_pixel():
assert cacher_pixel(182, 77) == 180
assert cacher_pixel(200, 255) == 207
assert cacher_pixel(0, 255) == 15
assert cacher_pixel(255, 0) == 240
Indice léger
Il faut deux morceaux : le poids fort de p, remis à sa place, et le poids fort de s, placé à droite.
Indice plus précis
Le poids fort de p est p // 16. Remis à sa place, il vaut (p // 16) * 16. On lui ajoute s // 16.
Écrire cacher (obligatoire)
La fonction applique cacher_pixel à chaque pixel. Elle renvoie une nouvelle grille et ne modifie ni porteuse ni secret. Complète le test.
def cacher(porteuse: grille, secret: grille) -> grille:
"""Renvoie une nouvelle grille, la porteuse dans laquelle le secret
est caché. Précondition : les deux grilles ont la même taille."""
pass
def test_cacher():
p = [[182, 0], [255, 200]]
s = [[77, 255], [0, 255]]
assert cacher(p, s) == [[180, 15], [240, 207]]
assert p == ...
assert s == ...
Indice léger
C'est la construction ligne par ligne de recadrer, ou de flou.
Cacher ta photo
Ajoute à la fin du bloc if __name__ == "__main__": :
transmise = cacher(porteuse, secret)
sauver(porteuse, "porteuse.png")
sauver(transmise, "transmise.png")
- Avant de lancer, sur ton cahier : verra-t-on une différence entre
porteuse.pngettransmise.png? Appuie-toi sur l'étape 1. - Lance, ouvre les deux images et compare.
Réponse
Aucun pixel ne change de plus de 15 sur 255 : la différence ne se voit pas, ou à peine dans les grandes zones unies.
Étape 3 - Retrouver le secret
Le pixel transmis contient le poids fort du secret, mais à droite, à la place du poids faible. Pour le remettre à gauche, on échange les deux moitiés : les 4 bits de poids faible passent à gauche, les 4 bits de poids fort passent à droite.
À la main
Le pixel transmis vaut \(180 = 1011\ 0100_2\). Le secret valait \(77\). Sur ton cahier :
- Échange ses deux moitiés. Écris le résultat en binaire, puis en base 10.
- Quel écart y a-t-il avec le secret ?
- D'où viennent les 4 bits de droite du résultat ?
- Échange à nouveau les deux moitiés du résultat. Qu'obtiens-tu ?
Réponse
- \(0100\ 1011_2 = 75\).
- \(77 - 75 = 2\).
- Ce sont les 4 bits de poids fort de la porteuse, \(1011\). Ils remplacent les 4 bits de poids faible du secret, qui ont été perdus.
- \(1011\ 0100_2 = 180\) : on retrouve le pixel transmis. Échanger deux fois, c'est ne rien changer.
Trouver la formule de echanger (obligatoire)
Un pixel est un octet : un entier sur 8 bits, entre 0 et 255. Trouve une expression qui, à partir de p, donne l'octet dont les 4 bits de poids fort et les 4 bits de poids faible sont intervertis. N'utilise que //, %, * et +.
Vérifie d'abord ta formule à la main sur \(180\) et sur \(15\), puis écris la fonction. Le dernier test traduit la question 4 : complète-le.
def echanger(p: int) -> int:
"""Renvoie l'octet obtenu en échangeant les 4 bits de poids fort et
les 4 bits de poids faible de p. Précondition : 0 <= p <= 255."""
pass
def test_echanger():
assert echanger(180) == 75
assert echanger(207) == 252
assert echanger(15) == 240
for p in range(0, 256):
assert echanger(echanger(p)) == ...
Indice léger
Sépare d'abord p en deux nombres entre 0 et 15, comme à l'étape 1.
Indice plus précis
Reconstruis ensuite un octet à partir de ces deux nombres, comme dans la question 4 de l'étape 1 (\(12 \times 16 + 8 = 200\)), mais en les plaçant dans l'autre ordre.
Écrire reveler (obligatoire)
Écris la fonction reveler(transmise: grille) -> grille, qui renvoie une nouvelle grille où chaque pixel de transmise a ses deux moitiés échangées. Écris aussi sa fonction de test, avec la grille transmise de test_cacher.
Révéler ta photo
Ajoute à la fin du bloc if __name__ == "__main__": :
- Ouvre
revelee.pnget compare-la avec ta photo secrète. Que remarques-tu dans les zones où le gris varie lentement, comme un ciel ? - Distingue-t-on la porteuse dans
revelee.png? Pourquoi ?
Réponse
- Le gris varie par marches : seuls les 4 bits de poids fort du secret ont été transmis, donc il ne reste que 16 niveaux de gris possibles au lieu de 256.
- À peine : la porteuse n'occupe plus que les 4 bits de poids faible, où elle produit des écarts de 15 au plus.
Étape 4 - Mesurer la perte
Les deux images ont été abîmées : la porteuse en devenant l'image transmise, le secret en devenant l'image révélée. On mesure l'écart pixel par pixel.
Le pire cas
- Sur ton cahier : quel est le plus grand écart possible entre un pixel de la porteuse et le pixel transmis ? Donne une porteuse et un secret qui l'atteignent.
- Même question entre un pixel du secret et le pixel révélé.
Réponse
- Le pixel transmis ne diffère de la porteuse que par ses 4 bits de poids faible : l'écart vaut au plus \(15\). Il est atteint pour une porteuse \(0 = 0000\ 0000_2\) et un secret \(255 = 1111\ 1111_2\), qui donnent \(0000\ 1111_2 = 15\).
- Le pixel révélé ne diffère du secret que par ses 4 bits de poids faible, remplacés par ceux de la porteuse : au plus \(15\) aussi. Avec le même exemple, on révèle \(1111\ 0000_2 = 240\) au lieu de \(255\).
Écrire ecart_max (obligatoire)
def ecart_max(a: grille, b: grille) -> int:
"""Renvoie le plus grand écart, en valeur absolue, entre deux pixels
à la même place dans a et dans b.
Précondition : les deux grilles ont la même taille."""
pass
def test_ecart_max():
assert ecart_max([[1, 2], [3, 4]], [[1, 2], [3, 4]]) == 0
assert ecart_max([[0, 100]], [[15, 90]]) == 15
assert ecart_max([[100, 0]], [[90, 15]]) == 15
Indice léger
C'est la recherche d'un maximum, sur toutes les cases à la fois. La valeur absolue s'écrit abs.
Écrire ecart_moyen (entraînement)
Écris ecart_moyen(a: grille, b: grille) -> float, qui renvoie la moyenne des écarts en valeur absolue sur tous les pixels, avec sa fonction de test.
La perte sur tes photos
Ajoute à la fin du bloc if __name__ == "__main__": :
print(ecart_max(porteuse, transmise), ecart_moyen(porteuse, transmise))
print(ecart_max(secret, revelee), ecart_moyen(secret, revelee))
- Les deux écarts maximaux respectent-ils ta réponse au pire cas ?
- Les écarts moyens sont bien plus petits que 15. Pourquoi ?
Réponse
- Oui : aucun ne dépasse 15.
- L'écart de 15 n'est atteint que si les 4 bits remplacés et les 4 bits qui les remplacent sont à l'opposé,
0000contre1111. La plupart du temps, ils sont plus proches.
Pour aller plus loin
Bonus - Combien de bits cacher ?
On a coupé chaque pixel en 4 bits et 4 bits. On pourrait cacher seulement les 2 bits de poids fort du secret, ou au contraire 6.
- Écris
cacher_n(porteuse: grille, secret: grille, n: int) -> grille, qui cache lesnbits de poids fort du secret dans lesnbits de poids faible de la porteuse, pournentre 1 et 7. Il faut remplacer 16 par une puissance de 2 qui dépend den. - Écris la fonction
reveler_nqui va avec. Échanger les deux moitiés ne suffit plus : pourquoi ? - Pour chaque
nde 1 à 7, mesureecart_moyensur la porteuse et sur le secret. Quelnchoisirais-tu pour que personne ne se doute de rien ? Pour transmettre un secret lisible ?