mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-09-04 19:26:34 +02:00
feat(photomaker): SynthID-safe face-identity restoration via PhotoMaker-V2
Adds the second face-restore mechanism, selectable via the new CLI option `--restore-faces-method=photomaker`. Unlike the existing GFPGAN path (which runs on the watermarked ORIGINAL and was oracle-confirmed to re-introduce SynthID by partial pixel blending), PhotoMaker carries identity in a SynthID-invariant OpenCLIP embedding and regenerates fresh face pixels conditioned on it — the pixels in the output are diffusion-fresh, so the watermark cannot be transported. The load-bearing assumption (embedding invariance to SynthID-magnitude pixel noise) was empirically validated in the prior commit (smoke test): cosine drift 0.002 under a ±2 LSB low-freq carrier, an order of magnitude less than JPEG90 drift which SynthID survives at >=99% TPR. End-to-end commercial-safe: - PhotoMaker-V2 weights: Apache-2.0 (TencentARC) - ID encoder: OpenCLIP-ViT-H/14 (MIT) - SDXL base: shared with the main pipeline - NO InsightFace (the non-commercial blocker for IP-Adapter FaceID / InstantID / PuLID / Arc2Face) Two-pass architecture (PhotoMaker has no ControlNetImg2img class in diffusers): 1) main controlnet/default removal pass cleans SynthID + drifts faces 2) PhotoMaker txt2img regenerates each face from its embedding, feather-composited back into the cleaned image New module `photomaker_restore.py` mirrors `face_restore.py`: lazy pipeline singleton (double-checked lock), `is_available()` gate, pure `_face_crop_square` and `_composite_faces` helpers, all unit-tested without the model (9 new tests). New `InvisibleEngine._restore_faces_photomaker` runs after the diffusion pass, mirroring `_restore_faces`. CLI flag `--restore-faces-method=[gfpgan|photomaker]` threaded through `cmd_invisible`/`cmd_all`/`cmd_batch` + `_process_batch_image`. New optional `photomaker` extra (Apache-2.0 + Apache-2.0/MIT deps, no basicsr). `[tool.hatch.metadata] allow-direct-references = true` is required because the upstream PhotoMaker package lives only on GitHub. The next step (separate work) is oracle validation: run a 6-image cert sweep through the new pipeline (default/controlnet at the certified strength + --restore-faces-method=photomaker) and confirm SynthID stays clean while face identity is recovered. The required infrastructure (`raiw-app/modal_cert.py`) is already in place. ruff + strict pyright(src/) clean; 586 tests pass (+ 9 new in tests/test_photomaker_restore.py). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
f8f247308b
commit
1439eb0714
@@ -236,22 +236,32 @@ def _warn_if_esrgan_unavailable(upscaler: str) -> None:
|
||||
|
||||
|
||||
def _restore_faces_options(f: Any) -> Any:
|
||||
"""Attach the shared GFPGAN face-restoration flags to an invisible-pipeline command."""
|
||||
"""Attach the shared face-restoration flags to an invisible-pipeline command."""
|
||||
restore_flag = click.option(
|
||||
"--restore-faces/--no-restore-faces",
|
||||
default=False,
|
||||
help="EXPERIMENTAL, opt-in. Restore face identity with a GFPGAN post-pass when "
|
||||
"faces are present (needs the 'restore' extra); off by default, auto-skips when no "
|
||||
"face is detected or the extra is absent.",
|
||||
help="EXPERIMENTAL, opt-in. Restore face identity with a post-pass when faces are "
|
||||
"present; off by default, auto-skips when no face is detected or the chosen extra "
|
||||
"is absent.",
|
||||
)
|
||||
method_flag = click.option(
|
||||
"--restore-faces-method",
|
||||
type=click.Choice(["gfpgan", "photomaker"]),
|
||||
default="gfpgan",
|
||||
help="Face-restore mechanism: 'gfpgan' (cheap, needs 'restore' extra, BUT runs on "
|
||||
"the watermarked original and re-introduces SynthID) or 'photomaker' (PhotoMaker-V2, "
|
||||
"needs the 'photomaker' extra; carries identity via a SynthID-invariant OpenCLIP "
|
||||
"embedding so the regenerated face pixels are watermark-free). Default: gfpgan.",
|
||||
)
|
||||
weight_flag = click.option(
|
||||
"--restore-faces-weight",
|
||||
type=float,
|
||||
default=0.5,
|
||||
help="GFPGAN fidelity weight (0-1); lower = more GAN regeneration (cleaner "
|
||||
"watermark scrub), higher = closer to the input.",
|
||||
"watermark scrub), higher = closer to the input. Ignored when "
|
||||
"--restore-faces-method=photomaker.",
|
||||
)
|
||||
return restore_flag(weight_flag(f))
|
||||
return restore_flag(method_flag(weight_flag(f)))
|
||||
|
||||
|
||||
def _watermark_region(det: DetectionResult, width: int, height: int) -> tuple[int, int, int, int]:
|
||||
@@ -603,6 +613,7 @@ def cmd_invisible(
|
||||
controlnet_scale: float,
|
||||
restore_faces: bool,
|
||||
restore_faces_weight: float,
|
||||
restore_faces_method: str,
|
||||
upscaler: str,
|
||||
auto: bool,
|
||||
adaptive_polish: bool,
|
||||
@@ -666,6 +677,7 @@ def cmd_invisible(
|
||||
vendor=vendor,
|
||||
restore_faces=restore_faces,
|
||||
restore_faces_weight=restore_faces_weight,
|
||||
restore_faces_method=restore_faces_method,
|
||||
)
|
||||
elapsed = time.monotonic() - t0
|
||||
|
||||
@@ -868,6 +880,7 @@ def cmd_all(
|
||||
controlnet_scale: float,
|
||||
restore_faces: bool,
|
||||
restore_faces_weight: float,
|
||||
restore_faces_method: str,
|
||||
upscaler: str,
|
||||
auto: bool,
|
||||
adaptive_polish: bool,
|
||||
@@ -977,6 +990,7 @@ def cmd_all(
|
||||
vendor=vendor,
|
||||
restore_faces=restore_faces,
|
||||
restore_faces_weight=restore_faces_weight,
|
||||
restore_faces_method=restore_faces_method,
|
||||
)
|
||||
console.print(" Invisible watermark removed")
|
||||
|
||||
@@ -1033,6 +1047,7 @@ def _process_batch_image(
|
||||
min_resolution: int = 1024,
|
||||
restore_faces: bool = False,
|
||||
restore_faces_weight: float = 0.5,
|
||||
restore_faces_method: str = "gfpgan",
|
||||
controlnet_scale: float = 1.0,
|
||||
upscaler: str = "lanczos",
|
||||
auto: bool = False,
|
||||
@@ -1112,6 +1127,7 @@ def _process_batch_image(
|
||||
upscaler=upscaler,
|
||||
restore_faces=restore_faces,
|
||||
restore_faces_weight=restore_faces_weight,
|
||||
restore_faces_method=restore_faces_method,
|
||||
# Detect the vendor from the pristine original (`img_path`), not the
|
||||
# visible-processed `out_path` whose C2PA is already gone.
|
||||
vendor=vendor_for_strength(img_path),
|
||||
@@ -1195,6 +1211,7 @@ def cmd_batch(
|
||||
min_resolution: int,
|
||||
restore_faces: bool,
|
||||
restore_faces_weight: float,
|
||||
restore_faces_method: str,
|
||||
controlnet_scale: float,
|
||||
upscaler: str,
|
||||
auto: bool,
|
||||
@@ -1255,6 +1272,7 @@ def cmd_batch(
|
||||
min_resolution=min_resolution,
|
||||
restore_faces=restore_faces,
|
||||
restore_faces_weight=restore_faces_weight,
|
||||
restore_faces_method=restore_faces_method,
|
||||
controlnet_scale=controlnet_scale,
|
||||
upscaler=upscaler,
|
||||
auto=auto,
|
||||
|
||||
Reference in New Issue
Block a user