mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-08-19 12:07:13 +02:00
189 lines
6.9 KiB
Python
189 lines
6.9 KiB
Python
"""Run a positive-only exact-geometry SynthID research detector.
|
|
|
|
The detector requires independently frozen RGB and HSV phase models. It emits
|
|
``positive`` only when both branches and both support gates pass; every other
|
|
case is ``abstain``. It never claims that SynthID is absent.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
from dataclasses import asdict, dataclass
|
|
from pathlib import Path
|
|
|
|
import click
|
|
from PIL import Image
|
|
from synthid_color_space_probe import ColorPhaseModel, ColorPhaseScore, load_model, score_image
|
|
from synthid_research_manifest import artifact_sha256
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class EnsembleConfig:
|
|
"""Frozen paths, hashes, geometry, and positive thresholds."""
|
|
|
|
width: int
|
|
height: int
|
|
rgb_model_path: Path
|
|
rgb_model_sha256: str
|
|
rgb_evidence_threshold: float
|
|
rgb_active_threshold: float
|
|
hsv_model_path: Path
|
|
hsv_model_sha256: str
|
|
hsv_sv_evidence_threshold: float
|
|
hsv_active_threshold: float
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class EnsembleVerdict:
|
|
"""Positive-only verdict with the evidence needed to audit it."""
|
|
|
|
path: str
|
|
verdict: str
|
|
reason: str
|
|
rgb_evidence: float | None
|
|
rgb_active_support: float | None
|
|
hsv_sv_evidence: float | None
|
|
hsv_active_support: float | None
|
|
|
|
|
|
def load_config(path: Path) -> EnsembleConfig:
|
|
"""Load a frozen epoch manifest and verify both model artifacts."""
|
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
if payload.get("verdict_scope") != "positive-only exact-geometry research detector":
|
|
raise ValueError("config is not a positive-only exact-geometry detector")
|
|
rgb = payload["rgb_model"]
|
|
hsv = payload["hsv_model"]
|
|
geometry = payload["geometry"]
|
|
config = EnsembleConfig(
|
|
width=int(geometry["width"]),
|
|
height=int(geometry["height"]),
|
|
rgb_model_path=Path(rgb["path"]),
|
|
rgb_model_sha256=str(rgb["sha256"]),
|
|
rgb_evidence_threshold=float(rgb["evidence_threshold"]),
|
|
rgb_active_threshold=float(rgb["active_support_threshold"]),
|
|
hsv_model_path=Path(hsv["path"]),
|
|
hsv_model_sha256=str(hsv["sha256"]),
|
|
hsv_sv_evidence_threshold=float(hsv["sv_evidence_threshold"]),
|
|
hsv_active_threshold=float(hsv["active_support_threshold"]),
|
|
)
|
|
if config.width < 64 or config.height < 64:
|
|
raise ValueError("invalid detector geometry")
|
|
for model_path, expected_hash in (
|
|
(config.rgb_model_path, config.rgb_model_sha256),
|
|
(config.hsv_model_path, config.hsv_model_sha256),
|
|
):
|
|
if not model_path.is_file():
|
|
raise ValueError(f"model artifact does not exist: {model_path}")
|
|
if artifact_sha256(model_path) != expected_hash:
|
|
raise ValueError(f"model artifact hash mismatch: {model_path}")
|
|
return config
|
|
|
|
|
|
def load_models(config: EnsembleConfig) -> tuple[ColorPhaseModel, ColorPhaseModel]:
|
|
"""Load and cross-check the RGB and HSV models in CONFIG."""
|
|
rgb_model = load_model(config.rgb_model_path)
|
|
hsv_model = load_model(config.hsv_model_path)
|
|
if rgb_model.color_space != "rgb" or hsv_model.color_space != "hsv":
|
|
raise ValueError("detector requires one RGB model and one HSV model")
|
|
expected_geometry = (config.height, config.width)
|
|
if (rgb_model.height, rgb_model.width) != expected_geometry:
|
|
raise ValueError("RGB model geometry does not match config")
|
|
if (hsv_model.height, hsv_model.width) != expected_geometry:
|
|
raise ValueError("HSV model geometry does not match config")
|
|
return rgb_model, hsv_model
|
|
|
|
|
|
def classify_scores(
|
|
path: Path,
|
|
rgb_score: ColorPhaseScore,
|
|
hsv_score: ColorPhaseScore,
|
|
config: EnsembleConfig,
|
|
) -> EnsembleVerdict:
|
|
"""Apply CONFIG's positive-only rule to precomputed branch scores."""
|
|
hsv_sv_evidence = float(sum(hsv_score.channel_evidence[1:]))
|
|
rgb_support = rgb_score.active_weight_fraction >= config.rgb_active_threshold
|
|
hsv_support = hsv_score.active_weight_fraction >= config.hsv_active_threshold
|
|
rgb_pass = rgb_score.evidence_score >= config.rgb_evidence_threshold
|
|
hsv_pass = hsv_sv_evidence >= config.hsv_sv_evidence_threshold
|
|
if rgb_support and hsv_support and rgb_pass and hsv_pass:
|
|
verdict = "positive"
|
|
reason = "ensemble_pass"
|
|
elif not rgb_support or not hsv_support:
|
|
verdict = "abstain"
|
|
reason = "insufficient_support"
|
|
elif rgb_pass != hsv_pass:
|
|
verdict = "abstain"
|
|
reason = "branch_disagreement"
|
|
else:
|
|
verdict = "abstain"
|
|
reason = "below_positive_threshold"
|
|
return EnsembleVerdict(
|
|
path=str(path),
|
|
verdict=verdict,
|
|
reason=reason,
|
|
rgb_evidence=rgb_score.evidence_score,
|
|
rgb_active_support=rgb_score.active_weight_fraction,
|
|
hsv_sv_evidence=hsv_sv_evidence,
|
|
hsv_active_support=hsv_score.active_weight_fraction,
|
|
)
|
|
|
|
|
|
def detect_image(
|
|
path: Path,
|
|
config: EnsembleConfig,
|
|
rgb_model: ColorPhaseModel,
|
|
hsv_model: ColorPhaseModel,
|
|
) -> EnsembleVerdict:
|
|
"""Evaluate PATH or abstain when its geometry is unsupported."""
|
|
with Image.open(path) as image:
|
|
if image.size != (config.width, config.height):
|
|
return EnsembleVerdict(
|
|
path=str(path),
|
|
verdict="abstain",
|
|
reason="unsupported_geometry",
|
|
rgb_evidence=None,
|
|
rgb_active_support=None,
|
|
hsv_sv_evidence=None,
|
|
hsv_active_support=None,
|
|
)
|
|
return classify_scores(
|
|
path,
|
|
score_image(path, rgb_model),
|
|
score_image(path, hsv_model),
|
|
config,
|
|
)
|
|
|
|
|
|
@click.command()
|
|
@click.argument("config_path", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
|
@click.argument("images", nargs=-1, required=True, type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
|
@click.option("--report-out", type=click.Path(dir_okay=False, path_type=Path), required=True)
|
|
def main(config_path: Path, images: tuple[Path, ...], report_out: Path) -> None:
|
|
"""Score IMAGES with the frozen positive-only detector CONFIG_PATH."""
|
|
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
|
config = load_config(config_path)
|
|
rgb_model, hsv_model = load_models(config)
|
|
verdicts = [detect_image(image, config, rgb_model, hsv_model) for image in images]
|
|
report_out.parent.mkdir(parents=True, exist_ok=True)
|
|
report_out.write_text(
|
|
json.dumps(
|
|
{
|
|
"config": str(config_path),
|
|
"positive_count": sum(verdict.verdict == "positive" for verdict in verdicts),
|
|
"abstain_count": sum(verdict.verdict == "abstain" for verdict in verdicts),
|
|
"verdicts": [asdict(verdict) for verdict in verdicts],
|
|
},
|
|
indent=2,
|
|
)
|
|
+ "\n",
|
|
encoding="utf-8",
|
|
)
|
|
log.info("Wrote %d positive-only detector verdicts: %s", len(verdicts), report_out)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|