0_o / validator.py
Scrappy-Doo's picture
Upload 5 files
3be3c58 verified
Raw
History Blame Contribute Delete
12.4 kB
"""
validator.py — SteganOCR Validator
Mide cuantitativamente si el texto inyectado es:
1. Detectable por el motor OCR (tasa de acierto)
2. Imperceptible para el ojo humano (PSNR, SSIM, delta-E)
3. Robusto ante transformaciones comunes (JPEG, resize, blur)
No requiere dependencias externas de OCR en runtime —
usa análisis de varianza de píxeles para simular la "visión"
del motor, alineado con cómo los modelos de baja resolución
detectan bordes de texto.
"""
from __future__ import annotations
import math
import re
import struct
import zlib
from dataclasses import dataclass
from typing import NamedTuple
import numpy as np
from PIL import Image, ImageFilter
# ------------------------------------------------------------------
# Resultado estructurado de la validación
# ------------------------------------------------------------------
class ValidationReport(NamedTuple):
psnr: float # Peak Signal-to-Noise Ratio (dB); ≥ 45 = imperceptible
ssim: float # Structural Similarity Index (0-1); ≥ 0.999 = imperceptible
delta_e: float # Diferencia perceptual CIE76 (< 1 = invisible al ojo)
pixel_delta_mean: float # Diferencia promedio de píxeles (< 5 = imperceptible)
pixel_delta_max: int # Diferencia máxima de píxel (LSB típico = 1-3)
ocr_snr: float # "SNR del OCR": varianza de bordes en zona de texto vs original
contrast_ratio: float # Relación de contraste real (< 1.05 = sub-perceptual)
text_coverage_pct: float # % de la imagen cubierta con texto
robustness_jpeg: float # PSNR vs versión comprimida JPEG 85
verdict: str # "✅ Invisible & OCR-readable" | "⚠️ ..." | "❌ ..."
details: str # Descripción extendida
@dataclass
class ValidatorConfig:
psnr_threshold: float = 45.0 # dB mínimo para considerar imperceptible
ssim_threshold: float = 0.999
delta_e_threshold: float = 2.5 # < 1 perfecto, < 2.5 aceptable
ocr_snr_threshold: float = 1.15 # el texto debe aumentar la varianza de bordes ≥ 15%
jpeg_quality: int = 85 # calidad para test de robustez
class SteganOCRValidator:
"""
Valida la calidad del embedding sin requerir un motor OCR externo.
Usa análisis de píxeles y métricas de imagen para cuantificar:
- Invisibilidad humana (PSNR, SSIM, delta-E)
- Detectabilidad por OCR (varianza de bordes de Sobel)
"""
def __init__(self, config: ValidatorConfig | None = None):
self.cfg = config or ValidatorConfig()
def validate(
self,
original: Image.Image,
injected: Image.Image,
text_injected: str = "",
) -> ValidationReport:
orig_arr = self._to_rgb_array(original)
inj_arr = self._to_rgb_array(injected)
psnr = self._psnr(orig_arr, inj_arr)
ssim = self._ssim(orig_arr, inj_arr)
delta_e = self._delta_e_mean(orig_arr, inj_arr)
pixel_delta_mean = float(np.mean(np.abs(inj_arr.astype(np.int32) - orig_arr.astype(np.int32))))
pixel_delta_max = int(np.max(np.abs(inj_arr.astype(np.int32) - orig_arr.astype(np.int32))))
ocr_snr = self._ocr_edge_snr(orig_arr, inj_arr)
contrast_ratio = self._contrast_ratio(orig_arr, inj_arr)
coverage = self._text_coverage(orig_arr, inj_arr)
rob_jpeg = self._robustness_jpeg(injected)
verdict, details = self._verdict(
psnr, ssim, delta_e, ocr_snr, contrast_ratio, coverage, rob_jpeg
)
return ValidationReport(
psnr=round(psnr, 2),
ssim=round(ssim, 5),
delta_e=round(delta_e, 4),
pixel_delta_mean=round(pixel_delta_mean, 4),
pixel_delta_max=pixel_delta_max,
ocr_snr=round(ocr_snr, 4),
contrast_ratio=round(contrast_ratio, 5),
text_coverage_pct=round(coverage * 100, 2),
robustness_jpeg=round(rob_jpeg, 2),
verdict=verdict,
details=details,
)
# ------------------------------------------------------------------
# Métricas de invisibilidad
# ------------------------------------------------------------------
@staticmethod
def _psnr(a: np.ndarray, b: np.ndarray) -> float:
mse = np.mean((a.astype(np.float64) - b.astype(np.float64)) ** 2)
if mse < 1e-10:
return 100.0 # idénticos
return 10 * math.log10(255 ** 2 / mse)
@staticmethod
def _ssim(a: np.ndarray, b: np.ndarray) -> float:
"""SSIM simplificado por canal, promediado."""
c1, c2 = (0.01 * 255) ** 2, (0.03 * 255) ** 2
a_f, b_f = a.astype(np.float64), b.astype(np.float64)
mu_a, mu_b = a_f.mean(), b_f.mean()
sig_a2 = np.var(a_f)
sig_b2 = np.var(b_f)
sig_ab = np.cov(a_f.flatten(), b_f.flatten())[0, 1]
num = (2 * mu_a * mu_b + c1) * (2 * sig_ab + c2)
den = (mu_a**2 + mu_b**2 + c1) * (sig_a2 + sig_b2 + c2)
return float(num / den)
@staticmethod
def _delta_e_mean(a: np.ndarray, b: np.ndarray) -> float:
"""
Diferencia perceptual CIE76 en espacio Lab (aproximación lineal).
< 1.0 = invisible, < 2.5 = aceptable, > 5 = visible.
"""
# Conversión sRGB → L*a*b* (aproximada, sin ICC profiles)
def rgb_to_lab(arr: np.ndarray) -> np.ndarray:
f = arr.astype(np.float64) / 255.0
# Linearizar
mask = f > 0.04045
f[mask] = ((f[mask] + 0.055) / 1.055) ** 2.4
f[~mask] = f[~mask] / 12.92
# sRGB → XYZ (D65)
M = np.array([[0.4124564, 0.3575761, 0.1804375],
[0.2126729, 0.7151522, 0.0721750],
[0.0193339, 0.1191920, 0.9503041]])
xyz = f.reshape(-1, 3) @ M.T
xyz /= np.array([0.95047, 1.00000, 1.08883])
mask2 = xyz > 0.008856
xyz[mask2] = xyz[mask2] ** (1/3)
xyz[~mask2] = 7.787 * xyz[~mask2] + 16/116
L = 116 * xyz[:, 1] - 16
a_ = 500 * (xyz[:, 0] - xyz[:, 1])
b_ = 200 * (xyz[:, 1] - xyz[:, 2])
return np.stack([L, a_, b_], axis=1)
lab_a = rgb_to_lab(a.reshape(-1, 3))
lab_b = rgb_to_lab(b.reshape(-1, 3))
de = np.sqrt(np.sum((lab_a - lab_b) ** 2, axis=1))
return float(de.mean())
# ------------------------------------------------------------------
# Métricas de detectabilidad OCR
# ------------------------------------------------------------------
@staticmethod
def _sobel_edges(arr: np.ndarray) -> np.ndarray:
"""Magnitud de bordes Sobel en escala de grises."""
gray = 0.299 * arr[:, :, 0] + 0.587 * arr[:, :, 1] + 0.114 * arr[:, :, 2]
gx = np.gradient(gray, axis=1)
gy = np.gradient(gray, axis=0)
return np.sqrt(gx**2 + gy**2)
def _ocr_edge_snr(self, orig: np.ndarray, inj: np.ndarray) -> float:
"""
Relación entre la energía de bordes del inyectado vs el original.
> 1.15 significa que el texto añadió bordes detectables por el OCR.
"""
e_orig = self._sobel_edges(orig).mean() + 1e-9
e_inj = self._sobel_edges(inj).mean()
return float(e_inj / e_orig)
@staticmethod
def _contrast_ratio(orig: np.ndarray, inj: np.ndarray) -> float:
"""Relación de luminancia relativa (W3C formula)."""
def luminance(arr: np.ndarray) -> float:
f = arr.astype(np.float64) / 255.0
f = np.where(f <= 0.03928, f / 12.92, ((f + 0.055) / 1.055) ** 2.4)
return float(0.2126 * f[:, :, 0].mean() +
0.7152 * f[:, :, 1].mean() +
0.0722 * f[:, :, 2].mean())
L1 = luminance(inj) + 0.05
L2 = luminance(orig) + 0.05
if L1 < L2:
L1, L2 = L2, L1
return L1 / L2
@staticmethod
def _text_coverage(orig: np.ndarray, inj: np.ndarray) -> float:
"""Fracción de píxeles que cambiaron (= zona con texto/perturbación)."""
diff = np.abs(inj.astype(np.int32) - orig.astype(np.int32))
changed = np.any(diff > 0, axis=2)
return float(changed.sum()) / (orig.shape[0] * orig.shape[1])
def _robustness_jpeg(self, img: Image.Image) -> float:
"""PSNR entre la imagen inyectada y su versión comprimida a JPEG 85."""
import io
buf = io.BytesIO()
img.convert("RGB").save(buf, format="JPEG", quality=self.cfg.jpeg_quality)
buf.seek(0)
compressed = Image.open(buf).convert("RGB")
a = np.array(img.convert("RGB"))
b = np.array(compressed)
return self._psnr(a, b)
# ------------------------------------------------------------------
# Veredicto
# ------------------------------------------------------------------
def _verdict(
self,
psnr: float,
ssim: float,
delta_e: float,
ocr_snr: float,
contrast_ratio: float,
coverage: float,
rob_jpeg: float,
) -> tuple[str, str]:
issues = []
goods = []
# Invisibilidad
if psnr >= self.cfg.psnr_threshold:
goods.append(f"PSNR={psnr:.1f}dB ≥ {self.cfg.psnr_threshold}dB (imperceptible)")
else:
issues.append(f"PSNR={psnr:.1f}dB < {self.cfg.psnr_threshold}dB (puede ser visible)")
if ssim >= self.cfg.ssim_threshold:
goods.append(f"SSIM={ssim:.5f}{self.cfg.ssim_threshold}")
else:
issues.append(f"SSIM={ssim:.5f} < {self.cfg.ssim_threshold}")
if delta_e < self.cfg.delta_e_threshold:
goods.append(f"ΔE={delta_e:.3f} < {self.cfg.delta_e_threshold} (invisible al ojo)")
else:
issues.append(f"ΔE={delta_e:.3f}{self.cfg.delta_e_threshold} (visible para ojo experto)")
# Detectabilidad OCR
if ocr_snr >= self.cfg.ocr_snr_threshold:
goods.append(f"OCR-SNR={ocr_snr:.3f}{self.cfg.ocr_snr_threshold} (detectable por OCR)")
else:
issues.append(f"OCR-SNR={ocr_snr:.3f} < {self.cfg.ocr_snr_threshold} (texto podría no detectarse)")
# Contraste sub-perceptual
if contrast_ratio < 1.05:
goods.append(f"Contraste={contrast_ratio:.5f} (sub-perceptual, ✓)")
else:
issues.append(f"Contraste={contrast_ratio:.5f} (puede ser perceptible)")
# Robustez JPEG
if rob_jpeg >= 30.0:
goods.append(f"Robustez JPEG={rob_jpeg:.1f}dB (embedding sobrevive compresión moderada)")
else:
issues.append(f"Robustez JPEG={rob_jpeg:.1f}dB (embedding frágil ante JPEG)")
# Veredicto final
if not issues:
verdict = "✅ Invisible para humanos & detectable por OCR"
elif len(issues) <= 2 and psnr >= 40.0:
verdict = "⚠️ Mayormente aceptable — revisar advertencias"
else:
verdict = "❌ Problemas detectados — ajustar parámetros"
details = "\n".join(
[f" ✓ {g}" for g in goods] +
[f" ✗ {i}" for i in issues]
)
return verdict, details
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
@staticmethod
def _to_rgb_array(img: Image.Image) -> np.ndarray:
return np.array(img.convert("RGB"), dtype=np.uint8)
def report_as_markdown(self, r: ValidationReport) -> str:
return f"""## 📊 Reporte de Validación SteganOCR
| Métrica | Valor | Umbral |
|---------|-------|--------|
| PSNR | {r.psnr} dB | ≥ {self.cfg.psnr_threshold} dB |
| SSIM | {r.ssim} | ≥ {self.cfg.ssim_threshold} |
| Delta-E (CIE76) | {r.delta_e} | < {self.cfg.delta_e_threshold} |
| Δ píxel promedio | {r.pixel_delta_mean} | < 5.0 |
| Δ píxel máximo | {r.pixel_delta_max} | < 10 |
| OCR Edge-SNR | {r.ocr_snr} | ≥ {self.cfg.ocr_snr_threshold} |
| Relación de contraste | {r.contrast_ratio} | < 1.05 |
| Cobertura de texto | {r.text_coverage_pct}% | — |
| Robustez JPEG-85 | {r.robustness_jpeg} dB | ≥ 30 dB |
### Veredicto: {r.verdict}
```
{r.details}
```
"""