Spaces:
Running
Running
| """ | |
| 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 | |
| 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 | |
| # ------------------------------------------------------------------ | |
| 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) | |
| 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) | |
| 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 | |
| # ------------------------------------------------------------------ | |
| 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) | |
| 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 | |
| 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 | |
| # ------------------------------------------------------------------ | |
| 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} | |
| ``` | |
| """ | |