#!/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)
}