En août 2021, Apple a annoncé son intention d’analyser chaque photo de votre iPhone avant de la télécharger sur iCloud. En septembre de la même année, après deux semaines de controverse, l’entreprise a retiré cette proposition. En décembre 2022, elle a déclaré officiellement l’idée comme abandonnée. En 2022, la Commission européenne a proposé un règlement qui obligerait WhatsApp, Signal et Telegram à faire exactement ce qu’Apple avait décidé de ne pas mettre en œuvre. Ce règlement, connu sous le nom de Chat Control, reste actif au Conseil européen à ce jour.

Ces deux initiatives reposent sur le même algorithme : un hachage perceptuel de 64 bits mis en œuvre en 40 lignes de Python. Ce n’est ni une exagération ni une métaphore : il s’agit bien de 40 lignes, et à la fin de cet article, vous aurez une version exécutable sur votre machine.

La majorité du débat public — aussi bien en Suisse qu’ailleurs — se déroule sans que les intervenants comprennent réellement le fonctionnement de ce mécanisme. La réglementation est débattue en des termes purement politiques (« vie privée contre sécurité des enfants »), tandis que le cœur technique reste une boîte noire. Cet article vise à être l’antidote. Une fois que vous l’aurez lu, vous serez à même de vous forger une opinion avec une base technique solide. Et si ce n’est pas votre souhait, vous saurez au moins ce qu’on vous demande d’accepter ou de rejeter.

Le problème : SHA-256 ne peut pas identifier deux photos identiques

Le réflexe initial de tout développeur serait de résoudre ce problème avec SHA-256. Si deux images produisent la même empreinte (hash), alors ce sont les mêmes images. Simple, rapide, et incontestablement robuste sur le plan cryptographique.

Mais cela ne fonctionne pas.

import hashlib

def sha256(path):
    with open(path, "rb") as f:
        return hashlib.sha256(f.read()).hexdigest()

# Même photo, compressée différemment
sha256("photo.jpg")          # "a7f3..."
sha256("photo_reencodee.jpg") # "9c2e..." (totalement différent)

Modifiez un seul bit — aussi minime qu’un changement de qualité JPEG de 95 à 90, une conversion de format WebP en JPEG, ou un ajustement imperceptible de la luminosité — et SHA-256 génère une empreinte complètement différente. C’est intentionnel : la ressemblance cryptographique est volontairement intolérante aux modifications. Cette propriété est un avantage pour les objectifs de sécurité, mais ne convient pas ici.

Pour détecter « la même photo bien qu’elle ait été compressée autrement », il faut un algorithme tolérant. Il faut un hachage perceptuel : une signature de 64 bits capable de résister à des modifications qui n’altèrent pas ce que l’image représente visuellement.

aHash : Moyenne de luminosité

Le premier algorithme utile dans ce contexte est connu sous le nom de Average Hash ou aHash. Il repose sur une idée extrêmement simple :

import numpy as np
from PIL import Image

def average_hash(path, size=8):
    img = Image.open(path).convert("L").resize((size, size))
    arr = np.asarray(img, dtype=np.float32)
    mean = arr.mean()
    bits = (arr.flatten() > mean).astype(int)
    return "".join(str(b) for b in bits)

def hamming(a, b):
    return sum(x != y for x, y in zip(a, b))

On réduit l’image à une résolution de 8×8 pixels en niveaux de gris, on calcule la moyenne des intensités lumineuses, et chaque pixel devient 1 s’il dépasse cette moyenne ou 0 s’il est en dessous. Le résultat : 64 bits représentant la distribution des intensités lumineuses globales.

Deux images « identiques » produiront des hachages avec une distance de Hamming faible (≤ 5 bits de différence). Deux images différentes conduiront en revanche à des distances importantes (40 bits ou plus). Cet algorithme peut résister à des redimensionnements, des recadrages légers ou des recompressions. Mais il échoue lorsqu’il s’agit de gérer des ajustements sur le contraste ou la luminosité. C’est une première étape.

dHash : Gradients relatifs

La seconde étape, publiée par Neal Krawetz en 2011, s’appelle le Difference Hash ou dHash :

def difference_hash(path, size=8):
    img = Image.open(path).convert("L").resize((size + 1, size))
    arr = np.asarray(img, dtype=np.float32)
    diff = arr[:, 1:] > arr[:, :-1]
    return "".join("1" if v else "0" for v in diff.flatten())

Plutôt que de mesurer les intensités lumineuses absolues, cet algorithme mesure les gradients horizontaux relatifs : un pixel est codé comme 1 si sa luminosité est plus grande que celle du pixel à sa droite. Si l’image s’assombrit globalement de 30 %, les gradients relatifs sont préservés. dHash résiste ainsi aux variations de contraste, de gamma ou de traitement des couleurs — des scénarios où aHash échoue.

Quarante lignes de Python, deux bibliothèques standards comme PIL, et vous voilà en possession d’un outil au cœur des débats réglementaires européens les plus controversés de l’année.

De 40 lignes à une infrastructure mondiale

Ce que vous venez de faire avec average_hash et difference_hash est, avec quelques ajustements, le fondement des technologies qui permettent d’analyser une grande partie des images qui circulent sur le web.

…

Cet article a été publié en espagnol et traduit avec l’aide de l’IA.