""" 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} ``` """