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:
Victor Kuznetsov
2026-06-08 15:20:29 -07:00
co-authored by Claude Opus 4.8
parent f8f247308b
commit 1439eb0714
7 changed files with 532 additions and 9 deletions
+24 -6
View File
@@ -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,