#!/usr/bin/env catnip
# Error Level Analysis (ELA) avec pyvips
#
# ELA re-compresse une image JPEG à une qualité fixe puis mesure, pixel par
# pixel, l'écart avec l'original. Une région déjà proche de son optimum JPEG
# bouge peu à la ré-compression ; une région dont l'historique de compression
# diffère (collage, texte incrusté, zone ré-encodée) bouge davantage. L'écart
# brut est minuscule, donc on l'amplifie pour le rendre visible : c'est la
# heatmap ELA.
#
# AVERTISSEMENT : l'ELA ne PROUVE PAS une retouche. La texture locale, le nombre
# de ré-encodages déjà subis et la qualité JPEG choisie influencent aussi le
# signal — un aplat uniforme et un bord net réagissent différemment sans qu'aucun
# n'ait été modifié. À lire comme une visualisation pédagogique, pas comme un
# détecteur de fraude. Réf : https://en.wikipedia.org/wiki/Error_level_analysis
#
# DEPS: pyvips[binary]
pyvips = import('pyvips')
import('pathlib', 'Path')
script_dir = Path(META.file).parent
input_path = script_dir / 'data/catnip_test.jpg'
output_dir = script_dir / 'output'
output_dir.mkdir(exist_ok=True)
# quality : qualité JPEG de la ré-compression sonde. amplification : gain
# linéaire appliqué au résidu (le « scale » ELA) qui règle la sensibilité.
struct ELAConfig { quality: int; amplification: float; }
# Les trois images du pipeline. residual est déjà la heatmap uchar affichable.
struct ELAResult { original; recompressed; residual; }
# Cœur ELA : sauvegarde JPEG en mémoire à `quality`, recharge, diffère, amplifie.
# La normalisation finale est le cast saturant vers uchar : tout écart amplifié
# au-delà de 255 sature en blanc, ce qui borne le résidu dans la plage
# d'affichage sans écraser les faibles écarts.
analyze = (image, config: ELAConfig): ELAResult => {
buffer = image.jpegsave_buffer(Q=config.quality)
recompressed = pyvips.Image.new_from_buffer(buffer, '').colourspace('srgb')
diff = (image - recompressed).abs()
residual = (diff * config.amplification).cast('uchar')
ELAResult(image, recompressed, residual)
}
# Écart brut moyen (avant amplification) : signal numérique interprétable,
# recalculé depuis les champs stockés. Sert de contrôle et de légende chiffrée.
raw_error = (result: ELAResult): float => {
(result.original - result.recompressed).abs().avg()
}
# access='random' : l'image tient en mémoire et est réutilisée par chaque config
# sans relecture arrière (contrairement à access='sequential', qui interdirait
# de repartir de la source pour chaque niveau de qualité).
print("⇒ Chargement")
original = pyvips.Image.new_from_file(str(input_path), access='random').autorot().colourspace('srgb')
print(f" {original.width}×{original.height}, {original.bands} bandes, format {original.format}")
# Amplification constante, qualité décroissante : les panneaux restent
# comparables entre eux (même gain) et seule la qualité de ré-encodage varie.
AMPLIFICATION = 20.0
configs = list(
ELAConfig(90, AMPLIFICATION),
ELAConfig(75, AMPLIFICATION),
ELAConfig(50, AMPLIFICATION),
ELAConfig(25, AMPLIFICATION),
)
# Le pipeline est diffusé sur la liste de configs : une expression, pas de
# boucle. Diffuser sur des structs est sûr (chaque config passe entière).
print()
print("⇒ ELA diffusé sur les niveaux de qualité")
results = configs.[(cfg) => { analyze(original, cfg) }]
# Contrôle numérique : à amplification fixe, l'écart brut moyen doit croître
# quand la qualité baisse (plus de ré-compression = plus d'erreur réintroduite).
errors = results.[(r) => { raw_error(r) }]
print()
print("⇒ Écart brut moyen par qualité (contrôle)")
i = 0
monotone = True
while i < len(configs) {
print(f" Q={configs[i].quality:>3} : écart moyen = {round(errors[i], 4)}")
if i > 0 {
if errors[i] < errors[i - 1] { monotone = False }
}
i = i + 1
}
print(f" qualité décroissante → erreur croissante : {monotone}")
# Le résidu à Q=75 sert de heatmap principale : sombre là où la ré-compression
# change peu, clair là où l'erreur est forte.
print()
print("⇒ Écriture des heatmaps")
primary_path = output_dir / 'pyvips_ela_Q75.jpg'
results[1].residual.write_to_file(str(primary_path), Q=92, strip=True)
print(f" heatmap Q=75 → {primary_path.name}")
# Planche 2×2 des quatre résidus (Q=90/75/50/25) : lecture visuelle de la montée
# d'erreur quand la qualité chute.
heatmaps = results.[(r) => { r.residual }]
comparison = pyvips.Image.arrayjoin(heatmaps, across=2, shim=8, background=list(30, 30, 30))
comparison_path = output_dir / 'pyvips_ela_comparison.jpg'
comparison.write_to_file(str(comparison_path), Q=92, strip=True)
print(f" planche 2×2 (Q=90/75/50/25) → {comparison_path.name}")
print()
print(f"⇒ Sorties disponibles dans {output_dir}")
# Aperçu navigateur : planche de toutes les images produites, servie une fois.
# --no-browser garde un chemin headless (les fichiers restent écrits ci-dessus).
if '--no-browser' not in import('sys').argv {
http = import('http')
base64 = import('base64')
import('builtins', 'sorted')
cells = list()
for p in sorted(output_dir.glob('pyvips_ela_*.jpg')) {
b64 = base64.b64encode(p.read_bytes()).decode('ascii')
cells.append(f'<figure style="margin:0"><img style="width:100%;display:block" src="data:image/jpeg;base64,{b64}"><figcaption style="padding:4px;font:12px system-ui;color:#8b949e">{p.name}</figcaption></figure>')
}
grid = "".join(cells)
http.serve(f'<!doctype html><meta charset="utf-8"><title>pyvips ELA forensics</title><body style="margin:0;background:#0d1117"><main style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:10px;padding:14px">{grid}</main>',
0, 'text/html; charset=utf-8', True)
}