Files
remove-ai-watermarks/scripts/synthid_hybrid_attack.py
T

141 lines
4.6 KiB
Python

"""Build non-generative hybrid phase-projection and fragmentation candidates.
The matrix combines two independently measured mechanisms: sparse RGB/HSV
phase projection and spatially varying subpixel displacement. It is intended
for frozen provider-oracle batches with a visible-mark-removed source control.
"""
from __future__ import annotations
import json
import logging
from dataclasses import asdict
from pathlib import Path
from typing import TYPE_CHECKING
import click
from PIL import Image
from synthid_ensemble_attack import alternating_projection
from synthid_ensemble_detector import detect_image, load_config, load_models
from synthid_fragment_attack import bounded_smooth_warp, color_nudge, jpeg_chain
from synthid_pixel_attack import load_rgb, measure, norm_matched_noise, resize_squeeze, smooth_warp
if TYPE_CHECKING:
import numpy as np
from synthid_color_space_probe import ColorPhaseModel
log = logging.getLogger(__name__)
def build_candidates(
source: np.ndarray,
rgb_model: ColorPhaseModel,
hsv_model: ColorPhaseModel,
) -> dict[str, np.ndarray]:
"""Return a frozen mechanism matrix derived from SOURCE."""
projected_075 = alternating_projection(
source,
rgb_model,
hsv_model,
strength=0.75,
iterations=1,
)
projected_100 = alternating_projection(
source,
rgb_model,
hsv_model,
strength=1.0,
iterations=1,
)
bounded_100 = bounded_smooth_warp(
source,
max_displacement=1.0,
sigma=56.0,
seed=20260824,
)
projected_bounded_100 = bounded_smooth_warp(
projected_075,
max_displacement=1.0,
sigma=56.0,
seed=20260824,
)
bounded_polish = resize_squeeze(projected_bounded_100, 0.98)
bounded_polish = color_nudge(
bounded_polish,
brightness=0.002,
contrast=0.003,
saturation=-0.003,
hue_degrees=0.1,
)
bounded_polish = jpeg_chain(bounded_polish, (96,))
elastic_combo = smooth_warp(projected_100, amplitude=0.75, sigma=56.0, seed=20260825)
elastic_combo = resize_squeeze(elastic_combo, 0.98)
elastic_combo = jpeg_chain(elastic_combo, (96,))
return {
"projection-075": projected_075,
"bounded-100": bounded_100,
"projection-075-bounded-100": projected_bounded_100,
"projection-075-bounded-polish": bounded_polish,
"projection-100-elastic-075": elastic_combo,
}
@click.command()
@click.argument("config_path", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.argument("source", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.argument("output_dir", type=click.Path(file_okay=False, path_type=Path))
def main(config_path: Path, source: Path, output_dir: Path) -> None:
"""Write a frozen non-generative hybrid attack matrix for SOURCE."""
logging.basicConfig(level=logging.INFO, format="%(message)s")
config = load_config(config_path)
rgb_model, hsv_model = load_models(config)
reference = load_rgb(source)
if reference.shape != (config.height, config.width, 3):
raise click.BadParameter("source geometry does not match detector config")
output_dir.mkdir(parents=True, exist_ok=True)
variants: list[dict[str, object]] = []
candidates = build_candidates(reference, rgb_model, hsv_model)
for name, pixels in candidates.items():
path = output_dir / f"{name}.png"
Image.fromarray(pixels, mode="RGB").save(path)
variants.append(
{
**asdict(measure(reference, pixels, name=name, path=path)),
**asdict(detect_image(path, config, rgb_model, hsv_model)),
}
)
selected = candidates["projection-075-bounded-100"]
sham = norm_matched_noise(reference, selected, seed=20260826)
sham_path = output_dir / "sham-projection-bounded-rms.png"
Image.fromarray(sham, mode="RGB").save(sham_path)
variants.append(
{
**asdict(measure(reference, sham, name="sham-projection-bounded-rms", path=sham_path)),
**asdict(detect_image(sham_path, config, rgb_model, hsv_model)),
}
)
report_path = output_dir / "report.json"
report_path.write_text(
json.dumps(
{
"source": str(source),
"config": str(config_path),
"variants": variants,
},
indent=2,
)
+ "\n",
encoding="utf-8",
)
log.info("Wrote %d frozen hybrid candidates: %s", len(variants), report_path)
if __name__ == "__main__":
main()