mirror of
https://github.com/guillaumemeyer/watermarks-remover.git
synced 2026-08-22 13:11:57 +02:00
* feat: add vendor text-watermark detection and SynthID image scorer sidecar Adds Layer B watermark detection as a first-class service capability: - text_detectors.py: a registry of text-watermark detectors behind one interface — Google's official SynthID-text detector via the Gemini API (taskType DETECT_TEXT_WATERMARK), a Claude placeholder ready for Anthropic's announced detection API, and the MarkLLM research harness (KGW / SynthID, same-config-only). Fail-soft: unconfigured or errored detectors never block cleaning. - server.py: new POST /detect endpoint, detect_before / detect_after options on /clean (before/after scoring for text and images), an opt-in /inspect "detect" flag, and /capabilities gains text_detectors and scorers.synthid_http. - synthid_score_server.py: a stdlib HTTP sidecar for the reverse-SynthID scorer, so the published core image never bundles the non-commercial upstream code; wired via WATERMARKS_SYNTHID_SCORER_URL. - score_synthid.py: extract score_file() so the CLI and the sidecar share one implementation. - compose.yaml / Dockerfile.synthid / .env.example: wr-synthid-score sidecar service and env wiring. - README + skill docs, plus tests for the detectors, the /detect endpoint, and the image sidecar. * feat: per-candidate watermark detection for Layer B rewrite candidates When --candidates N (N > 1) is combined with --markllm-scheme or WATERMARKS_GEMINI_API_KEY, run every configured text detector from the text_detectors.py registry on each candidate and report per-candidate measurements in --json-stats as candidate_scores entries carrying lexical_divergence, selection_score, selected, and per-detector reports (is_watermarked, score, threshold where the detector provides one). Candidate selection stays purely lexical; the detections are observability for correlating lexical divergence with watermark removal (issue #106). Converges rewrite_text.py onto the shared detector registry: - MarkLLMTextDetector gains constructor overrides (scheme, upstream_dir, model, timeout) plus the checkout-venv interpreter preference and the WATERMARKS_MARKLLM_RLIMIT_AS preexec guard ported from rewrite_text.py; the old _markllm_detect / _venv_python / _markllm_preexec helpers are gone. - run_all_text_detectors() accepts an injected MarkLLM instance and an include_markllm switch so CLI flag gating stays intact. - before/after/cleared semantics unchanged; detection remains fail-soft. * docs: pin Watermarks in the Sand reference to arXiv v5 * fix: mark only one rewrite candidate as selected (#110) --------- Co-authored-by: Zhenxin Ai <142008897+ai-kunkun@users.noreply.github.com>
166 lines
5.5 KiB
Python
Executable File
166 lines
5.5 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Optional SynthID pixel-domain scorer backed by an external reverse-SynthID checkout.
|
|
|
|
This script does NOT vendor upstream code. It imports the scorer from a
|
|
user-provided checkout (https://github.com/aloshdenny/reverse-SynthID) at
|
|
runtime, using that environment's optional dependencies (numpy, opencv,
|
|
scipy, PyWavelets, scikit-learn, Pillow).
|
|
|
|
Exit codes:
|
|
0 scored successfully
|
|
1 scorer runtime error
|
|
2 bad input (missing/unreadable image, bad args)
|
|
3 scorer unavailable (not configured / missing deps / missing codebook)
|
|
|
|
The scoring logic lives in :func:score_file so the CLI and the HTTP
|
|
sidecar (synthid_score_server.py) share one implementation.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import contextlib
|
|
import json
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
|
|
def resolve_upstream(raw: str | None) -> Path | None:
|
|
if not raw:
|
|
return None
|
|
upstream = Path(raw).expanduser().resolve()
|
|
if not upstream.is_dir():
|
|
return None
|
|
return upstream
|
|
|
|
|
|
def score_file(
|
|
path: Path,
|
|
*,
|
|
upstream_dir: str | None = None,
|
|
codebook: Path | None = None,
|
|
model: str | None = None,
|
|
) -> tuple[int, dict[str, Any] | None]:
|
|
"""Score *path* with the reverse-SynthID extractor.
|
|
|
|
Returns (exit_code, payload) matching the CLI exit-code contract:
|
|
0 = scored (payload present), 2 = bad input (payload None),
|
|
3 = scorer unavailable (payload None). Errors are printed to stderr so
|
|
callers parsing stdout JSON are never corrupted.
|
|
"""
|
|
if not path.is_file():
|
|
print(f"not a file: {path}", file=sys.stderr)
|
|
return 2, None
|
|
|
|
raw_upstream = upstream_dir or os.environ.get("REVERSE_SYNTHID_DIR")
|
|
upstream = resolve_upstream(str(raw_upstream) if raw_upstream else None)
|
|
if upstream is None:
|
|
print(
|
|
"SynthID scorer not configured: set REVERSE_SYNTHID_DIR or pass --upstream-dir",
|
|
file=sys.stderr,
|
|
)
|
|
return 3, None
|
|
|
|
extraction = upstream / "src" / "extraction"
|
|
if not extraction.is_dir():
|
|
print(f"upstream extraction dir not found: {extraction}", file=sys.stderr)
|
|
return 3, None
|
|
|
|
codebook_path = codebook or upstream / "artifacts" / "spectral_codebook_v4.npz"
|
|
codebook_path = Path(codebook_path).expanduser().resolve()
|
|
if not codebook_path.is_file():
|
|
print(f"codebook not found: {codebook_path}", file=sys.stderr)
|
|
return 3, None
|
|
|
|
sys.path.insert(0, str(extraction))
|
|
try:
|
|
import cv2
|
|
from robust_extractor import RobustSynthIDExtractor
|
|
from synthid_bypass_v4 import SpectralCodebookV4
|
|
except ImportError as e:
|
|
print(f"optional scorer dependencies missing: {e}", file=sys.stderr)
|
|
return 3, None
|
|
|
|
try:
|
|
img = cv2.imread(str(path))
|
|
if img is None:
|
|
print(f"could not load image: {path}", file=sys.stderr)
|
|
return 2, None
|
|
rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
|
|
|
|
# Upstream prints progress ("CodebookV4 loaded: ...") straight to
|
|
# stdout, which corrupts --json for any caller that parses us
|
|
# (image_meta.py json.loads our stdout). Keep stdout ours alone.
|
|
with contextlib.redirect_stdout(sys.stderr):
|
|
codebook_v4 = SpectralCodebookV4()
|
|
codebook_v4.load(str(codebook_path))
|
|
|
|
extractor = RobustSynthIDExtractor()
|
|
result = extractor.detect_from_v4_codebook(rgb, codebook_v4, model=model)
|
|
except Exception as e:
|
|
print(f"scorer error: {e}", file=sys.stderr)
|
|
return 1, None
|
|
|
|
payload = {
|
|
"available": True,
|
|
"upstream_dir": str(upstream),
|
|
"codebook": str(codebook_path),
|
|
"model": model,
|
|
"profile_key": result.details.get("profile_key"),
|
|
"exact_match": result.details.get("exact_match"),
|
|
"is_watermarked": result.is_watermarked,
|
|
"confidence": result.confidence,
|
|
"phase_match": result.phase_match,
|
|
"per_channel_scores": result.details.get("per_channel_scores"),
|
|
"per_channel_n": result.details.get("per_channel_n"),
|
|
"multi_scale_consistency": result.multi_scale_consistency,
|
|
}
|
|
return 0, payload
|
|
|
|
|
|
def main() -> int:
|
|
p = argparse.ArgumentParser(description=__doc__)
|
|
p.add_argument("path", type=Path, help="Image to score (PNG/JPEG/etc.)")
|
|
p.add_argument(
|
|
"--upstream-dir",
|
|
type=Path,
|
|
default=None,
|
|
help="reverse-SynthID checkout root (default: $REVERSE_SYNTHID_DIR)",
|
|
)
|
|
p.add_argument(
|
|
"--codebook",
|
|
type=Path,
|
|
default=None,
|
|
help="spectral_codebook_v4.npz path (default: <upstream>/artifacts/)",
|
|
)
|
|
p.add_argument("--model", type=str, default=None, help="Optional model hint")
|
|
p.add_argument("--json", action="store_true", help="Emit JSON on stdout")
|
|
args = p.parse_args()
|
|
|
|
code, payload = score_file(
|
|
args.path,
|
|
upstream_dir=str(args.upstream_dir) if args.upstream_dir else None,
|
|
codebook=args.codebook,
|
|
model=args.model,
|
|
)
|
|
if code != 0:
|
|
return code
|
|
|
|
if args.json:
|
|
json.dump(payload, sys.stdout, indent=2)
|
|
sys.stdout.write("\n")
|
|
else:
|
|
label = "yes" if payload["is_watermarked"] else "no"
|
|
print(f"SynthID score: confidence {payload['confidence']:.3f} (watermarked: {label})")
|
|
print(f" phase_match: {payload['phase_match']:.3f}")
|
|
if payload.get("profile_key"):
|
|
print(f" profile: {payload['profile_key']}")
|
|
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|