refactor(face-restore): rollback PhotoMaker, restore GFPGAN on the CLEANED image

After 7 cascading upstream-compat fixes (insightface dep, peft dep, pm_version,
device, etc.), the PhotoMaker V1 cert sweep still hit a CFG batch-dim mismatch
inside the denoising loop. The upstream PhotoMaker `pipeline.py` is forked from
diffusers v0.29.1 and our env runs 0.38; SDXL prompt-encoder handling changed
significantly between those versions, so making PhotoMaker work end-to-end
needs a proper fork or a diffusers downgrade — both expensive. Not worth
shipping today.

Pivot: restore `face_restore.py` (GFPGAN) with a single-line fix that makes it
SynthID-safe by construction. The previous design ran GFPGAN.enhance on the
ORIGINAL watermarked image and was oracle-confirmed to re-add SynthID via the
weight-0.5 pixel blend. The fix is to run GFPGAN on the diffusion-CLEANED
image — whatever pixels GFPGAN derives from are already SynthID-free, so the
partial blend cannot transport the watermark. Identity fidelity is lower than
a true identity-as-embedding stack would deliver, but it ships and works.

Changes:
- `src/remove_ai_watermarks/face_restore.py` restored from pre-wipe state with
  one line changed: `restorer.enhance(cleaned_bgr, ...)` instead of
  `restorer.enhance(original_bgr, ...)`. `original_bgr` is kept as an unused
  positional argument for API stability.
- `src/remove_ai_watermarks/photomaker_restore.py` and its tests REMOVED. The
  research note (`docs/synthid-robust-identity-research.md`) keeps a "status
  notice" documenting why PhotoMaker is parked for now and what the path back
  in would look like.
- `pyproject.toml` `restore` extra restored (gfpgan/facexlib/basicsr +
  scipy<1.18 + numba<0.60 pins + the basicsr setuptools<69 build pin), plus
  `photomaker` extra (with its einops/insightface/peft pile) and the
  `[tool.hatch.metadata] allow-direct-references = true` block REMOVED.
- `InvisibleEngine._restore_faces_photomaker` removed; `_restore_faces`
  restored. The `--restore-faces` CLI flag and its plumbing through cmd_*
  signatures are unchanged.
- CLAUDE.md, README.md, docs/synthid.md, docs/controlnet-removal-pipeline-
  research.md updated to describe the shipped GFPGAN-on-cleaned design and to
  reference PhotoMaker only as the parked alternative.

ruff + strict pyright(src/) clean; 578 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Victor Kuznetsov
2026-06-08 16:55:45 -07:00
co-authored by Claude Opus 4.8
parent d1b85ee6a8
commit 01fe98bf54
13 changed files with 1273 additions and 851 deletions
+30 -40
View File
@@ -180,13 +180,11 @@ class InvisibleEngine:
guidance_scale: Classifier-free guidance scale.
seed: Random seed for reproducibility.
humanize: Intensity of Analog Humanizer film grain (0 = off).
restore_faces: EXPERIMENTAL, opt-in (default False). Run the PhotoMaker-V2
face-identity post-pass when faces are present (needs the
``photomaker`` extra). Carries identity via a SynthID-invariant OpenCLIP
embedding and regenerates fresh face pixels conditioned on it, so the
pixel watermark is not transported. Auto-skips with a debug log when the
extra is absent or no face is detected. See
``docs/synthid-robust-identity-research.md``.
restore_faces: EXPERIMENTAL, opt-in (default False). Run the GFPGAN
face-polish post-pass when faces are present (needs the ``restore``
extra). Runs on the diffusion-CLEANED image (not the original), so
SynthID is not re-introduced. Auto-skips with a debug log when the
extra is absent or no face is detected.
unsharp: Final unsharp-mask sharpening strength (0 = off, default).
Applied last (after face restoration) to counter the soft,
over-smoothed look of the diffusion + restoration; ~0.5-0.8 is a
@@ -312,13 +310,13 @@ class InvisibleEngine:
out_cv = cv2.resize(out_cv, orig_size, interpolation=cv2.INTER_LANCZOS4)
image_io.imwrite(out_path, out_cv)
# Optional PhotoMaker-V2 face-identity post-pass: restore face identity that
# the diffusion regeneration drifted, carrying identity in a SynthID-invariant
# OpenCLIP embedding so the regenerated face pixels are watermark-free. Runs
# on the cleaned output at its final resolution; auto-skips when faces are
# absent or the optional extra is not installed.
# Optional GFPGAN face-polish post-pass: sharpens and re-synthesizes each
# face from GFPGAN's StyleGAN2 prior, running on the DIFFUSION-CLEANED image
# (not the original) -- so SynthID is not re-introduced (the input pixels
# GFPGAN derives from are already SynthID-free). Auto-skips when faces are
# absent or the optional `restore` extra is not installed.
if restore_faces:
self._restore_faces_photomaker(out_path, image, seed)
self._restore_faces(out_path)
# Final sharpening, LAST so it crisps the face-restored result too (a
# pre-restore sharpen would be smoothed back over by the face pass).
@@ -357,50 +355,42 @@ class InvisibleEngine:
if _tmp_path.exists():
_tmp_path.unlink()
def _restore_faces_photomaker(
self,
out_path: Path,
original_image: Any,
seed: int | None,
) -> None:
"""Run the PhotoMaker-V2 SynthID-safe face-identity restoration post-pass.
def _restore_faces(self, out_path: Path) -> None:
"""Run the GFPGAN face-polish post-pass on the cleaned ``out_path``.
Unlike the GFPGAN path (which blends watermarked original face pixels back into
the cleaned output and re-introduces SynthID), PhotoMaker carries identity in a
SynthID-invariant OpenCLIP embedding and regenerates fresh face pixels conditioned
on it. Best-effort: any failure (missing extra, model load, runtime error) logs a
warning and leaves the un-restored cleaned output in place. See
``docs/synthid-robust-identity-research.md`` and ``photomaker_restore.py``.
SynthID-safe: GFPGAN is run on the diffusion-CLEANED image (not the original),
so the partial pixel-blend it does at fidelity weight 0.5 cannot re-introduce
the watermark -- the input pixels GFPGAN derives from are already SynthID-free.
Best-effort: any failure logs a warning and leaves the un-restored cleaned
output in place; a missing ``restore`` extra is logged at debug and skipped
(the flag must never error when the extra is absent or no face is present).
"""
from remove_ai_watermarks import photomaker_restore
from remove_ai_watermarks import face_restore
if not photomaker_restore.is_available():
logger.debug("restore_faces=photomaker requested but the 'photomaker' extra is not installed; skipping")
if not face_restore.is_available():
logger.debug("restore_faces requested but the 'restore' extra is not installed; skipping")
return
try:
import cv2
import numpy as np
from remove_ai_watermarks import image_io
cleaned_bgr = image_io.imread(out_path, cv2.IMREAD_COLOR)
if cleaned_bgr is None:
logger.warning("restore_faces_photomaker: could not read cleaned output %s; skipping", out_path)
logger.warning("restore_faces: could not read cleaned output %s; skipping", out_path)
return
original_rgb = original_image.convert("RGB")
original_bgr = cv2.cvtColor(np.array(original_rgb), cv2.COLOR_RGB2BGR)
cleaned_size = (cleaned_bgr.shape[1], cleaned_bgr.shape[0])
if (original_bgr.shape[1], original_bgr.shape[0]) != cleaned_size:
original_bgr = cv2.resize(original_bgr, cleaned_size, interpolation=cv2.INTER_LANCZOS4)
if self._progress_callback:
self._progress_callback("Restoring face identity (PhotoMaker-V2 post-pass)...")
restored = photomaker_restore.restore_faces_photomaker(original_bgr, cleaned_bgr, seed=seed)
self._progress_callback("Polishing face identity (GFPGAN on cleaned image)...")
# original_bgr is unused (GFPGAN runs on cleaned_bgr); pass an empty array
# for positional API stability with the legacy signature.
import numpy as np
restored = face_restore.restore_faces(np.empty((0, 0, 3), dtype=np.uint8), cleaned_bgr)
image_io.imwrite(out_path, restored)
except Exception as e:
logger.warning("restore_faces_photomaker post-pass failed (%s); keeping un-restored output", e)
logger.warning("restore_faces post-pass failed (%s); keeping un-restored output", e)
def remove_watermark_batch(
self,