Hace unos meses analicé un reto similar al que titulé Dos por Uno. En aquella ocasión, el autor había introducido una pequeña modificación en las imágenes, pero con algo de picardía todavía era posible resolverlo utilizando StegSolve.

En esta ocasión volvemos a disponer de dos imágenes que, a simple vista, parecen ruido aleatorio en blanco y negro. Sus características son las siguientes:

  • Nombre: Image1.png
  • Formato: PNG
  • Resolución: 2142 × 1130 px
  • Modo de color: RGBA
  • Tamaño de archivo: 445320 bytes (434 KB)
  • SHA-256: e283e2929ad5029c3688d8a0053320c30cdc6359aece1bec19bf2b6587b5a3dd
  • Nombre: Image2.png
  • Formato: PNG
  • Resolución: 2142 × 1130 px
  • Modo de color: RGBA
  • Tamaño de archivo: 445050 bytes (434 KB)
  • SHA-256: d7d63daac7a5e1667351605fc847f285fbfecd5c2891fe4f5da2ad7e0f981f2d

A priori, todo invita a realizar un primer ataque con StegSolve, en particular con la opción Image Combiner. Sin embargo, se trata de una herramienta sobradamente conocida y, desde hace tiempo, los creadores de este tipo de retos suelen jugar precisamente con esa ventaja, introduciendo técnicas o pequeñas variaciones que se salen de lo que StegSolve resuelve de forma directa.

Por eso, aunque el primer impulso sea probar las combinaciones clásicas, en este caso conviene no dar por hecho que un simple XOR, una resta o una inversión de colores van a revelar el mensaje. Cuando ambas imágenes tienen el mismo tamaño, presentan un patrón de ruido muy parecido y no muestran diferencias evidentes, también hay que plantearse otras posibilidades, como transformaciones geométricas o combinaciones visuales menos habituales.

Para no depender únicamente de las pruebas manuales con StegSolve, decidí automatizar el proceso con tres scripts bastante sencillos, cada uno con un enfoque algo más amplio que el anterior. El primero reproducía las combinaciones más típicas del Image Combiner: XOR, OR, AND, sumas, restas, multiplicaciones y sus variantes, incluyendo también las versiones invertidas de ambas imágenes. La idea era comprobar rápidamente si el reto caía por la vía clásica.

a.py

# =========================================================
# Autor: deurus
# Blog : https://deurus.info
# Descripción: Script de análisis y resolución de retos
#              de esteganografía / criptografía visual.
# =========================================================

import os
import numpy as np
from PIL import Image

# =========================================================
# UTILIDADES
# =========================================================

def ensure_output():
    if not os.path.exists("output"):
        os.makedirs("output_a")

def load_rgb(path):
    img = Image.open(path).convert("RGB")
    return np.array(img, dtype=np.uint32)

def save_rgb(arr, name):
    Image.fromarray(arr.astype(np.uint8), "RGB").save(os.path.join("output", name))

def invert_xor(arr):
    """Colour Inversion (Xor) de StegSolve."""
    out = arr.copy()
    out[..., :3] = 255 - out[..., :3]
    return out

# =========================================================
# FUNCIONES DE COMBINER EXACTAS DE STEGSOLVE
# =========================================================

def to24(arr):
    """Convierte RGB → entero 0xRRGGBB."""
    return ((arr[..., 0] << 16) |
            (arr[..., 1] << 8)  |
             arr[..., 2])

def from24(c):
    """Convierte entero 0xRRGGBB → RGB."""
    R = (c >> 16) & 0xFF
    G = (c >> 8)  & 0xFF
    B = c & 0xFF
    return np.stack([R, G, B], axis=-1).astype(np.uint8)

# ------------------------------
# Funciones auxiliares
# ------------------------------

def comb_xor(c1, c2):
    return from24((c1 ^ c2) & 0xFFFFFF)

def comb_or(c1, c2):
    return from24((c1 | c2) & 0xFFFFFF)

def comb_and(c1, c2):
    return from24((c1 & c2) & 0xFFFFFF)

def comb_add(c1, c2):
    return from24((c1 + c2) & 0xFFFFFF)

def comb_add_sep(c1, c2):
    R = (((c1 >> 16) & 0xFF) + ((c2 >> 16) & 0xFF)) & 0xFF
    G = (((c1 >> 8)  & 0xFF) + ((c2 >> 8)  & 0xFF)) & 0xFF
    B = ((c1 & 0xFF) + (c2 & 0xFF)) & 0xFF
    return from24((R << 16) | (G << 8) | B)

def comb_sub(c1, c2):
    return from24((c1 - c2) & 0xFFFFFF)

def comb_sub_sep(c1, c2):
    R = (((c1 >> 16) & 0xFF) - ((c2 >> 16) & 0xFF)) & 0xFF
    G = (((c1 >> 8)  & 0xFF) - ((c2 >> 8)  & 0xFF)) & 0xFF
    B = ((c1 & 0xFF) - (c2 & 0xFF)) & 0xFF
    return from24((R << 16) | (G << 8) | B)

def comb_mul(c1, c2):
    """MUL EXACTO StegSolve"""
    return from24((c1 * c2) & 0xFFFFFF)

def comb_mul_sep(c1, c2):
    R = (((c1 >> 16) & 0xFF) * ((c2 >> 16) & 0xFF)) & 0xFF
    G = (((c1 >> 8)  & 0xFF) * ((c2 >> 8)  & 0xFF)) & 0xFF
    B = ((c1 & 0xFF) * (c2 & 0xFF)) & 0xFF
    return from24((R << 16) | (G << 8) | B)

def comb_lightest(c1, c2):
    """Máximo por canal"""
    R = np.maximum((c1 >> 16) & 0xFF, (c2 >> 16) & 0xFF)
    G = np.maximum((c1 >> 8)  & 0xFF, (c2 >> 8)  & 0xFF)
    B = np.maximum(c1 & 0xFF, c2 & 0xFF)
    return from24((R << 16) | (G << 8) | B)

def comb_darkest(c1, c2):
    """Mínimo por canal"""
    R = np.minimum((c1 >> 16) & 0xFF, (c2 >> 16) & 0xFF)
    G = np.minimum((c1 >> 8)  & 0xFF, (c2 >> 8)  & 0xFF)
    B = np.minimum(c1 & 0xFF, c2 & 0xFF)
    return from24((R << 16) | (G << 8) | B)

# Lista de transformaciones
TRANSFORMS = {
    "xor": comb_xor,
    "or": comb_or,
    "and": comb_and,
    "add": comb_add,
    "add_sep": comb_add_sep,
    "sub": comb_sub,
    "sub_sep": comb_sub_sep,
    "mul": comb_mul,
    "mul_sep": comb_mul_sep,
    "lightest": comb_lightest,
    "darkest": comb_darkest,
}

# =========================================================
# GENERACIÓN DE TODAS LAS COMBINACIONES
# =========================================================

def generate_all(imA, imB, labelA, labelB):
    print(f"Generando combinaciones: {labelA} vs {labelB}")

    c1 = to24(imA)
    c2 = to24(imB)

    for name, fun in TRANSFORMS.items():
        out = fun(c1, c2)
        save_rgb(out, f"{labelA}__{labelB}__{name}.png")

    print(f"{labelA}-{labelB} completado.")

# =========================================================
# MAIN
# =========================================================

ensure_output()

print("Cargando imágenes...")
im1 = load_rgb("Image1.png")
im2 = load_rgb("Image2.png")

print("Generando invertidas estilo StegSolve...")
im1_x = invert_xor(im1)
im2_x = invert_xor(im2)

save_rgb(im1_x, "v1_xored.png")
save_rgb(im2_x, "v2_xored.png")

# Generar las 52 combinaciones:
generate_all(im1,   im2,   "v1",   "v2")
generate_all(im1_x, im2,   "v1x",  "v2")
generate_all(im1,   im2_x, "v1",   "v2x")
generate_all(im1_x, im2_x, "v1x",  "v2x")

print("\nResultados en carpeta ./output/")

Como por ahí no apareció nada convincente, pasé a un segundo script algo más fino. En esta fase probé diferencias absolutas, diferencias amplificadas, análisis en escala de grises, extracción de planos de bits y pruebas sobre el LSB, además de pequeños desplazamientos entre las dos imágenes por si hubiese algún desfase intencionado. Era, en cierto modo, una forma de buscar no ya una imagen evidente, sino cualquier pequeña anomalía que delatase una estructura oculta.

b.py

# =========================================================
# Autor: deurus
# Blog : https://deurus.info
# Descripción: Script de análisis y resolución de retos
#              de esteganografía / criptografía visual.
# =========================================================

import os
import numpy as np
from PIL import Image, ImageOps

# =========================================================
# CONFIGURACIÓN
# =========================================================

IMG1_PATH = "Image1.png"
IMG2_PATH = "Image2.png"
OUTPUT_DIR = "output_b"

SHIFT_RANGE = range(-3, 4)   # desplazamientos de -3 a +3
AMPLIFY_FACTORS = [4, 8, 16, 32, 64]
THRESHOLDS = [32, 64, 96, 128, 160, 192]

# =========================================================
# UTILIDADES
# =========================================================

def ensure_output():
    if not os.path.exists(OUTPUT_DIR):
        os.makedirs(OUTPUT_DIR)

def load_rgb(path):
    img = Image.open(path).convert("RGB")
    return np.array(img, dtype=np.uint8)

def save_rgb(arr, name):
    img = Image.fromarray(arr.astype(np.uint8), "RGB")
    img.save(os.path.join(OUTPUT_DIR, name))

def save_gray(arr, name):
    img = Image.fromarray(arr.astype(np.uint8), "L")
    img.save(os.path.join(OUTPUT_DIR, name))

def invert_rgb(arr):
    return 255 - arr

def shift_image(arr, dx, dy):
    """
    Desplaza la imagen usando np.roll.
    dx > 0 mueve a la derecha
    dy > 0 mueve hacia abajo
    """
    return np.roll(np.roll(arr, dy, axis=0), dx, axis=1)

def to_gray(arr):
    """
    Convierte RGB a escala de grises.
    """
    return np.mean(arr, axis=2).astype(np.uint8)

def normalize_to_255(arr):
    """
    Normaliza un array cualquiera al rango 0..255.
    """
    arr = arr.astype(np.float64)
    min_val = arr.min()
    max_val = arr.max()

    if max_val == min_val:
        return np.zeros(arr.shape, dtype=np.uint8)

    norm = (arr - min_val) * 255.0 / (max_val - min_val)
    return norm.astype(np.uint8)

def threshold_gray(arr, thr):
    """
    Binariza una imagen en escala de grises.
    """
    return np.where(arr >= thr, 255, 0).astype(np.uint8)

def equalize_gray(arr):
    """
    Ecualización de histograma usando PIL.
    """
    img = Image.fromarray(arr.astype(np.uint8), "L")
    eq = ImageOps.equalize(img)
    return np.array(eq, dtype=np.uint8)

# =========================================================
# OPERACIONES PRINCIPALES
# =========================================================

def diff_abs(im1, im2):
    """
    Diferencia absoluta por canal.
    """
    return np.abs(im1.astype(np.int16) - im2.astype(np.int16)).astype(np.uint8)

def diff_signed_centered(im1, im2, factor=16):
    """
    Diferencia con signo, centrada en 128 y amplificada.
    Muy útil para hacer visibles diferencias pequeñas.
    """
    d = im1.astype(np.int16) - im2.astype(np.int16)
    out = np.clip(d * factor + 128, 0, 255)
    return out.astype(np.uint8)

def xor_full(im1, im2):
    return np.bitwise_xor(im1, im2)

def and_full(im1, im2):
    return np.bitwise_and(im1, im2)

def or_full(im1, im2):
    return np.bitwise_or(im1, im2)

def lsb_plane(im):
    """
    Extrae el bit menos significativo de cada canal.
    """
    return ((im & 1) * 255).astype(np.uint8)

def bit_plane(im, bit_index):
    """
    Extrae un plano de bits concreto (0 = LSB, 7 = MSB).
    """
    return (((im >> bit_index) & 1) * 255).astype(np.uint8)

def xor_bit_plane(im1, im2, bit_index):
    """
    XOR de un plano de bits concreto entre dos imágenes.
    """
    b1 = (im1 >> bit_index) & 1
    b2 = (im2 >> bit_index) & 1
    return ((b1 ^ b2) * 255).astype(np.uint8)

def gray_diff(im1, im2):
    g1 = to_gray(im1).astype(np.int16)
    g2 = to_gray(im2).astype(np.int16)
    return np.abs(g1 - g2).astype(np.uint8)

def gray_xor(im1, im2):
    g1 = to_gray(im1)
    g2 = to_gray(im2)
    return np.bitwise_xor(g1, g2)

# =========================================================
# PROCESADO AUXILIAR
# =========================================================

def save_gray_variants(base_gray, prefix):
    """
    Guarda variantes útiles de una imagen gris:
    - original
    - normalizada
    - ecualizada
    - binarizada con varios umbrales
    """
    save_gray(base_gray, f"{prefix}.png")

    norm = normalize_to_255(base_gray)
    save_gray(norm, f"{prefix}_norm.png")

    eq = equalize_gray(norm)
    save_gray(eq, f"{prefix}_eq.png")

    for thr in THRESHOLDS:
        bw = threshold_gray(eq, thr)
        save_gray(bw, f"{prefix}_eq_thr_{thr}.png")

def save_rgb_variants(base_rgb, prefix):
    """
    A partir de una RGB, guarda:
    - RGB directa
    - gris
    - gris normalizada/ecualizada/binarizada
    """
    save_rgb(base_rgb, f"{prefix}.png")
    gray = to_gray(base_rgb)
    save_gray_variants(gray, f"{prefix}_gray")

# =========================================================
# ANÁLISIS DE BASE
# =========================================================

def basic_analysis(im1, im2):
    print("Generando análisis base...")

    # Originales e invertidas
    save_rgb(im1, "img1_original.png")
    save_rgb(im2, "img2_original.png")
    save_rgb(invert_rgb(im1), "img1_invertida.png")
    save_rgb(invert_rgb(im2), "img2_invertida.png")

    # Operaciones clásicas
    save_rgb_variants(diff_abs(im1, im2), "diff_abs")
    save_rgb_variants(xor_full(im1, im2), "xor_full")
    save_rgb_variants(and_full(im1, im2), "and_full")
    save_rgb_variants(or_full(im1, im2), "or_full")

    # Diferencias amplificadas
    for factor in AMPLIFY_FACTORS:
        d12 = diff_signed_centered(im1, im2, factor)
        d21 = diff_signed_centered(im2, im1, factor)
        save_rgb_variants(d12, f"diff_signed_1_minus_2_x{factor}")
        save_rgb_variants(d21, f"diff_signed_2_minus_1_x{factor}")

    # LSB y planos de bits
    save_rgb_variants(lsb_plane(im1), "img1_lsb")
    save_rgb_variants(lsb_plane(im2), "img2_lsb")
    save_rgb_variants(xor_bit_plane(im1, im2, 0), "xor_bit0")
    save_rgb_variants(xor_bit_plane(im1, im2, 1), "xor_bit1")
    save_rgb_variants(xor_bit_plane(im1, im2, 2), "xor_bit2")
    save_rgb_variants(xor_bit_plane(im1, im2, 3), "xor_bit3")

    for bit_idx in range(8):
        save_rgb_variants(bit_plane(im1, bit_idx), f"img1_bitplane_{bit_idx}")
        save_rgb_variants(bit_plane(im2, bit_idx), f"img2_bitplane_{bit_idx}")

    # Variantes en gris
    save_gray_variants(gray_diff(im1, im2), "gray_diff")
    save_gray_variants(gray_xor(im1, im2), "gray_xor")

    print("Análisis base completado.")

# =========================================================
# ANÁLISIS CON DESPLAZAMIENTO
# =========================================================

def shifted_analysis(im1, im2):
    print("Generando análisis con desplazamientos...")

    shifts_dir = os.path.join(OUTPUT_DIR, "shifts")
    if not os.path.exists(shifts_dir):
        os.makedirs(shifts_dir)

    for dx in SHIFT_RANGE:
        for dy in SHIFT_RANGE:
            shifted = shift_image(im2, dx, dy)

            # Diferencia absoluta
            d_abs = diff_abs(im1, shifted)
            d_abs_gray = to_gray(d_abs)
            d_abs_norm = normalize_to_255(d_abs_gray)
            d_abs_eq = equalize_gray(d_abs_norm)

            # XOR LSB
            x_lsb = xor_bit_plane(im1, shifted, 0)
            x_lsb_gray = to_gray(x_lsb)
            x_lsb_norm = normalize_to_255(x_lsb_gray)
            x_lsb_eq = equalize_gray(x_lsb_norm)

            name_base = f"shift_dx{dx}_dy{dy}"

            save_gray(d_abs_gray, os.path.join("shifts", f"{name_base}_diff_gray.png"))
            save_gray(d_abs_norm, os.path.join("shifts", f"{name_base}_diff_gray_norm.png"))
            save_gray(d_abs_eq, os.path.join("shifts", f"{name_base}_diff_gray_eq.png"))

            save_gray(x_lsb_gray, os.path.join("shifts", f"{name_base}_lsb_xor_gray.png"))
            save_gray(x_lsb_norm, os.path.join("shifts", f"{name_base}_lsb_xor_gray_norm.png"))
            save_gray(x_lsb_eq, os.path.join("shifts", f"{name_base}_lsb_xor_gray_eq.png"))

    print("Análisis con desplazamientos completado.")

# =========================================================
# INFORME SIMPLE EN CONSOLA
# =========================================================

def print_image_stats(im1, im2):
    print("=== ESTADÍSTICAS ===")
    print(f"Forma imagen 1: {im1.shape}")
    print(f"Forma imagen 2: {im2.shape}")

    if im1.shape != im2.shape:
        print("ERROR: Las imágenes no tienen el mismo tamaño.")
        return False

    abs_diff = diff_abs(im1, im2)
    gray = to_gray(abs_diff)

    print(f"Diferencia absoluta - mínimo: {gray.min()}")
    print(f"Diferencia absoluta - máximo: {gray.max()}")
    print(f"Diferencia absoluta - media : {gray.mean():.4f}")

    xor0 = xor_bit_plane(im1, im2, 0)
    xor0_gray = to_gray(xor0)
    white_pixels = np.sum(xor0_gray > 0)
    total_pixels = xor0_gray.size

    print(f"Pixels distintos en XOR bit 0: {white_pixels} / {total_pixels} ({100.0 * white_pixels / total_pixels:.2f}%)")

    return True

# =========================================================
# MAIN
# =========================================================

def main():
    ensure_output()

    print("Cargando imágenes...")
    im1 = load_rgb(IMG1_PATH)
    im2 = load_rgb(IMG2_PATH)

    if not print_image_stats(im1, im2):
        return

    basic_analysis(im1, im2)
    shifted_analysis(im1, im2)

    print()
    print("Proceso terminado.")
    print(f"Revisa la carpeta: {OUTPUT_DIR}")
    print("1) diff_abs_gray_eq.png")
    print("2) diff_signed_1_minus_2_x16_gray_eq.png")
    print("3) xor_bit0_gray_eq.png")
    print("4) gray_diff_eq.png")
    print("5) carpeta output/shifts/")

if __name__ == "__main__":
    main()

El tercer script ya fue un ataque más general y sistemático. Añadí umbralizados, overlays binarios, rotaciones, espejos, inversiones, combinaciones como minimum, maximum y average, pruebas con reducción de resolución y hasta extracción por patrones par/impar.

c.py

# =========================================================
# Autor: deurus
# Blog : https://deurus.info
# Descripción: Script de análisis y resolución de retos
#              de esteganografía / criptografía visual.
# =========================================================

import os
import itertools
import numpy as np
from PIL import Image, ImageOps, ImageChops

# =========================================================
# CONFIG
# =========================================================

IMG1_PATH = "Image1.png"
IMG2_PATH = "Image2.png"
OUTPUT_DIR = "output_c"

SHIFT_RANGE = range(-4, 5)
THRESHOLDS = [64, 96, 112, 120, 124, 128, 132, 136, 144, 160, 192]

# =========================================================
# UTILIDADES
# =========================================================

def ensure_dir(path):
    if not os.path.exists(path):
        os.makedirs(path)

def save_img(img, relpath):
    full = os.path.join(OUTPUT_DIR, relpath)
    ensure_dir(os.path.dirname(full))
    img.save(full)

def load_gray(path):
    return Image.open(path).convert("L")

def np_from_img(img):
    return np.array(img)

def img_from_np(arr, mode="L"):
    return Image.fromarray(arr.astype(np.uint8), mode)

def shift_np(arr, dx, dy):
    return np.roll(np.roll(arr, dy, axis=0), dx, axis=1)

def threshold_np(arr, thr):
    return np.where(arr >= thr, 255, 0).astype(np.uint8)

def normalize_np(arr):
    arr = arr.astype(np.float64)
    mn = arr.min()
    mx = arr.max()
    if mx == mn:
        return np.zeros_like(arr, dtype=np.uint8)
    return ((arr - mn) * 255.0 / (mx - mn)).astype(np.uint8)

def downsample_2x(arr):
    h, w = arr.shape
    h2 = h // 2
    w2 = w // 2
    arr = arr[:h2*2, :w2*2]
    return arr.reshape(h2, 2, w2, 2).mean(axis=(1, 3)).astype(np.uint8)

def extract_checker(arr, mode="00"):
    """
    mode:
      00 -> filas pares, cols pares
      01 -> filas pares, cols impares
      10 -> filas impares, cols pares
      11 -> filas impares, cols impares
    """
    r = 0 if mode[0] == "0" else 1
    c = 0 if mode[1] == "0" else 1
    return arr[r::2, c::2]

def transform_variant(img, name):
    if name == "orig":
        return img
    elif name == "flip_h":
        return ImageOps.mirror(img)
    elif name == "flip_v":
        return ImageOps.flip(img)
    elif name == "rot90":
        return img.rotate(90, expand=False)
    elif name == "rot180":
        return img.rotate(180, expand=False)
    elif name == "rot270":
        return img.rotate(270, expand=False)
    elif name == "inv":
        return ImageOps.invert(img)
    elif name == "inv_flip_h":
        return ImageOps.mirror(ImageOps.invert(img))
    elif name == "inv_flip_v":
        return ImageOps.flip(ImageOps.invert(img))
    elif name == "inv_rot180":
        return ImageOps.invert(img.rotate(180, expand=False))
    else:
        raise ValueError(f"Transformación no soportada: {name}")

# =========================================================
# COMBINADORES
# =========================================================

def comb_diff_abs(a, b):
    return np.abs(a.astype(np.int16) - b.astype(np.int16)).astype(np.uint8)

def comb_diff_centered(a, b, factor=8):
    d = a.astype(np.int16) - b.astype(np.int16)
    return np.clip(d * factor + 128, 0, 255).astype(np.uint8)

def comb_xor(a, b):
    return np.bitwise_xor(a, b)

def comb_min(a, b):
    return np.minimum(a, b).astype(np.uint8)

def comb_max(a, b):
    return np.maximum(a, b).astype(np.uint8)

def comb_avg(a, b):
    return ((a.astype(np.uint16) + b.astype(np.uint16)) // 2).astype(np.uint8)

def comb_add_mod(a, b):
    return ((a.astype(np.uint16) + b.astype(np.uint16)) & 0xFF).astype(np.uint8)

def comb_sub_mod(a, b):
    return ((a.astype(np.int16) - b.astype(np.int16)) & 0xFF).astype(np.uint8)

def comb_bw_overlay(a_bw, b_bw):
    """
    Overlay oscuro para imágenes binarias:
    blanco=255, negro=0
    si cualquiera es negro, resultado negro
    """
    return np.minimum(a_bw, b_bw).astype(np.uint8)

# =========================================================
# MÉTRICAS
# =========================================================

def score_binary_balance(arr):
    """
    Cuanto más se aleje de 50/50, más sospechoso/interesante puede ser.
    """
    white_ratio = np.mean(arr > 127)
    return abs(white_ratio - 0.5)

def score_rowcol_variation(arr):
    """
    Si aparece texto/figura, suele aumentar la estructura por filas/columnas.
    """
    row_std = np.std(arr.mean(axis=1))
    col_std = np.std(arr.mean(axis=0))
    return float(row_std + col_std)

def combined_score(arr):
    return score_binary_balance(arr) + score_rowcol_variation(arr) / 255.0

# =========================================================
# PRUEBAS BASE
# =========================================================

def save_base_versions(img1, img2):
    save_img(img1, "01_base/img1_gray.png")
    save_img(img2, "01_base/img2_gray.png")
    save_img(ImageOps.invert(img1), "01_base/img1_inv.png")
    save_img(ImageOps.invert(img2), "01_base/img2_inv.png")

    a = np_from_img(img1)
    b = np_from_img(img2)

    save_img(img_from_np(comb_diff_abs(a, b)), "01_base/diff_abs.png")
    save_img(img_from_np(normalize_np(comb_diff_abs(a, b))), "01_base/diff_abs_norm.png")
    save_img(img_from_np(comb_xor(a, b)), "01_base/xor.png")
    save_img(img_from_np(comb_min(a, b)), "01_base/minimum.png")
    save_img(img_from_np(comb_max(a, b)), "01_base/maximum.png")
    save_img(img_from_np(comb_avg(a, b)), "01_base/average.png")

    for factor in [2, 4, 8, 16, 32]:
        save_img(img_from_np(comb_diff_centered(a, b, factor)), f"01_base/diff_centered_x{factor}.png")
        save_img(img_from_np(comb_diff_centered(b, a, factor)), f"01_base/diff_centered_rev_x{factor}.png")

# =========================================================
# UMBRAL Y OVERLAY BINARIO
# =========================================================

def threshold_overlay_tests(img1, img2):
    a = np_from_img(img1)
    b = np_from_img(img2)

    ranking = []

    for t1 in THRESHOLDS:
        a_bw = threshold_np(a, t1)
        save_img(img_from_np(a_bw), f"02_thresholds/img1_thr_{t1}.png")

    for t2 in THRESHOLDS:
        b_bw = threshold_np(b, t2)
        save_img(img_from_np(b_bw), f"02_thresholds/img2_thr_{t2}.png")

    for t1 in THRESHOLDS:
        a_bw = threshold_np(a, t1)
        for t2 in THRESHOLDS:
            b_bw = threshold_np(b, t2)

            ov = comb_bw_overlay(a_bw, b_bw)
            x = comb_xor(a_bw, b_bw)
            mn = comb_min(a_bw, b_bw)
            mx = comb_max(a_bw, b_bw)

            name = f"t1_{t1}_t2_{t2}"
            save_img(img_from_np(ov), f"03_overlay/{name}_overlay.png")
            save_img(img_from_np(x),  f"03_overlay/{name}_xor.png")
            save_img(img_from_np(mn), f"03_overlay/{name}_min.png")
            save_img(img_from_np(mx), f"03_overlay/{name}_max.png")

            ranking.append(("overlay", t1, t2, combined_score(ov)))
            ranking.append(("xor", t1, t2, combined_score(x)))

    ranking.sort(key=lambda x: x[3], reverse=True)

    with open(os.path.join(OUTPUT_DIR, "03_overlay", "ranking.txt"), "w", encoding="utf-8") as f:
        for item in ranking[:100]:
            f.write(f"{item[0]}  t1={item[1]}  t2={item[2]}  score={item[3]:.6f}\n")

# =========================================================
# ROTACIONES, ESPEJOS E INVERSIONES
# =========================================================

def transformed_tests(img1, img2):
    variants = [
        "orig", "flip_h", "flip_v",
        "rot90", "rot180", "rot270",
        "inv", "inv_flip_h", "inv_flip_v", "inv_rot180"
    ]

    a = img1

    for v in variants:
        b_var = transform_variant(img2, v)
        a_np = np_from_img(a)
        b_np = np_from_img(b_var)

        save_img(b_var, f"04_transforms/b_variant_{v}.png")
        save_img(img_from_np(normalize_np(comb_diff_abs(a_np, b_np))), f"04_transforms/{v}_diff_abs_norm.png")
        save_img(img_from_np(comb_xor(a_np, b_np)), f"04_transforms/{v}_xor.png")
        save_img(img_from_np(comb_min(a_np, b_np)), f"04_transforms/{v}_minimum.png")
        save_img(img_from_np(comb_avg(a_np, b_np)), f"04_transforms/{v}_average.png")

# =========================================================
# DESPLAZAMIENTOS
# =========================================================

def shift_tests(img1, img2):
    a = np_from_img(img1)
    b = np_from_img(img2)

    ranking = []

    for dx in SHIFT_RANGE:
        for dy in SHIFT_RANGE:
            b_s = shift_np(b, dx, dy)

            d = normalize_np(comb_diff_abs(a, b_s))
            x = comb_xor(a, b_s)
            mn = comb_min(a, b_s)
            avg = comb_avg(a, b_s)

            base = f"dx_{dx}_dy_{dy}"
            save_img(img_from_np(d),   f"05_shifts/{base}_diff.png")
            save_img(img_from_np(x),   f"05_shifts/{base}_xor.png")
            save_img(img_from_np(mn),  f"05_shifts/{base}_min.png")
            save_img(img_from_np(avg), f"05_shifts/{base}_avg.png")

            ranking.append((base, "diff", combined_score(d)))
            ranking.append((base, "xor", combined_score(x)))
            ranking.append((base, "min", combined_score(mn)))

    ranking.sort(key=lambda x: x[2], reverse=True)

    with open(os.path.join(OUTPUT_DIR, "05_shifts", "ranking.txt"), "w", encoding="utf-8") as f:
        for item in ranking[:100]:
            f.write(f"{item[0]}  {item[1]}  score={item[2]:.6f}\n")

# =========================================================
# DOWNSAMPLE 2x
# =========================================================

def downsample_tests(img1, img2):
    a = np_from_img(img1)
    b = np_from_img(img2)

    a2 = downsample_2x(a)
    b2 = downsample_2x(b)

    save_img(img_from_np(a2), "06_downsample/img1_ds2.png")
    save_img(img_from_np(b2), "06_downsample/img2_ds2.png")
    save_img(img_from_np(normalize_np(comb_diff_abs(a2, b2))), "06_downsample/diff_abs_ds2.png")
    save_img(img_from_np(comb_xor(a2, b2)), "06_downsample/xor_ds2.png")
    save_img(img_from_np(comb_min(a2, b2)), "06_downsample/min_ds2.png")
    save_img(img_from_np(comb_avg(a2, b2)), "06_downsample/avg_ds2.png")

    for t1 in THRESHOLDS:
        a_bw = threshold_np(a2, t1)
        for t2 in THRESHOLDS:
            b_bw = threshold_np(b2, t2)
            ov = comb_bw_overlay(a_bw, b_bw)
            save_img(img_from_np(ov), f"06_downsample/overlay_ds2_t1_{t1}_t2_{t2}.png")

# =========================================================
# PAR / IMPAR FILAS-COLUMNAS
# =========================================================

def checker_tests(img1, img2):
    a = np_from_img(img1)
    b = np_from_img(img2)

    modes = ["00", "01", "10", "11"]

    for ma in modes:
        a_sub = extract_checker(a, ma)
        save_img(img_from_np(a_sub), f"07_checker/img1_{ma}.png")

    for mb in modes:
        b_sub = extract_checker(b, mb)
        save_img(img_from_np(b_sub), f"07_checker/img2_{mb}.png")

    for ma, mb in itertools.product(modes, modes):
        a_sub = extract_checker(a, ma)
        b_sub = extract_checker(b, mb)

        save_img(img_from_np(normalize_np(comb_diff_abs(a_sub, b_sub))), f"07_checker/{ma}_{mb}_diff.png")
        save_img(img_from_np(comb_xor(a_sub, b_sub)), f"07_checker/{ma}_{mb}_xor.png")
        save_img(img_from_np(comb_min(a_sub, b_sub)), f"07_checker/{ma}_{mb}_min.png")
        save_img(img_from_np(comb_avg(a_sub, b_sub)), f"07_checker/{ma}_{mb}_avg.png")

# =========================================================
# MAIN
# =========================================================

def main():
    ensure_dir(OUTPUT_DIR)

    img1 = load_gray(IMG1_PATH)
    img2 = load_gray(IMG2_PATH)

    if img1.size != img2.size:
        print("Las imágenes no tienen el mismo tamaño.")
        return

    print("Guardando pruebas base...")
    save_base_versions(img1, img2)

    print("Probando umbrales y overlays binarios...")
    threshold_overlay_tests(img1, img2)

    print("Probando rotaciones, espejos e inversiones...")
    transformed_tests(img1, img2)

    print("Probando desplazamientos...")
    shift_tests(img1, img2)

    print("Probando downsample 2x...")
    downsample_tests(img1, img2)

    print("Probando extracción par/impar...")
    checker_tests(img1, img2)

    print()
    print(f"Terminado. Revisa la carpeta: {OUTPUT_DIR}")
    print("  01_base/")
    print("  03_overlay/ranking.txt")
    print("  04_transforms/")
    print("  05_shifts/ranking.txt")
    print("  06_downsample/")
    print("  07_checker/")

if __name__ == "__main__":
    main()

Fue precisamente aquí, al empezar a salir del terreno del “estego clásico” y entrar en el de las transformaciones geométricas, cuando apareció la pista buena. La rotación de 180 grados, combinada con la operación adecuada, terminó revelando que el reto no iba tanto de ocultar información en una imagen, sino de hacer que una completase a la otra.

Al final todo queda en esto:

from PIL import Image, ImageChops
import numpy as np

im1 = Image.open("Image1.png").convert("L")
im2 = Image.open("Image2.png").convert("L")

# Rotar 180º
im2_rot = im2.rotate(180)

# Combinar (visual cryptography)
result = ImageChops.darker(im1, im2_rot)

result.save("resultado.png")