mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-06-10 12:53:56 +02:00
refactor(face-restore): drop GFPGAN, ship PhotoMaker-V2 as the sole restore (non-commercial)
Visual review of the GFPGAN-on-cleaned output (9-face grid, 1448x1086) showed it only polished the already-drifted face without restoring identity — useless for the "restore who is in the photo" intent. Dropping it. The shipped restore path is now PhotoMaker-V2, which delivers true identity-from- embedding face regeneration via a CLIP+ArcFace dual encoder. The ArcFace branch pulls InsightFace antelopev2/buffalo_l model packs at runtime, which InsightFace releases under a research-only license, so the whole extra is **NON-COMMERCIAL**. raiw.cc and any monetized deployment must NOT install the `photomaker` extra. This is called out at every entry point: CLI flag help, module docstring, pyproject extra block, CLAUDE.md extras bullet, README install snippet. Changes: - Deleted `src/remove_ai_watermarks/face_restore.py` and its tests. - Deleted the `restore` extra (gfpgan/facexlib/basicsr + scipy<1.18 / numba<0.60 pins) and the basicsr setuptools<69 build pin from pyproject.toml. - Restored `src/remove_ai_watermarks/photomaker_restore.py` (V2 this time: `TencentARC/PhotoMaker-V2`, `photomaker-v2.bin`, no `pm_version='v1'` override). - Restored the `photomaker` extra in pyproject with all the upstream-compat pins (einops, peft, onnxruntime, insightface) and the `allow-direct-references` hatch metadata block. - `InvisibleEngine` swapped `_restore_faces` -> `_restore_faces_photomaker`; `--restore-faces-method` removed (only one method, no choice). - CLI flag help, CLAUDE.md, README, docs/synthid.md, and docs/controlnet-removal-pipeline-research.md all updated. - docs/synthid-robust-identity-research.md status notice rewritten to list both abandoned commercial-safe attempts (V1 + GFPGAN-on-cleaned) and the non-commercial trade-off we accepted. ruff + strict pyright(src/) clean; 578 tests pass (the 9 GFPGAN tests are gone, the 11 PhotoMaker tests stay green). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,85 +0,0 @@
|
||||
"""Tests for the GFPGAN face-restoration post-pass.
|
||||
|
||||
The pure feather-composite helper is unit-tested without the model; the
|
||||
model-running paths are gated behind ``is_available()`` (a multi-hundred-MB
|
||||
download), matching the discipline used for the other ML-adjacent modules.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from remove_ai_watermarks import face_restore
|
||||
|
||||
|
||||
class TestIsAvailable:
|
||||
def test_returns_bool(self):
|
||||
assert isinstance(face_restore.is_available(), bool)
|
||||
|
||||
def test_reflects_dependencies(self):
|
||||
import importlib.util
|
||||
|
||||
expected = all(importlib.util.find_spec(m) is not None for m in ("gfpgan", "facexlib"))
|
||||
assert face_restore.is_available() is expected
|
||||
|
||||
|
||||
class TestCompositeFaces:
|
||||
"""Unit tests for the pure ``_composite_faces`` helper (cv2/numpy only)."""
|
||||
|
||||
def _base_and_restored(self, h: int = 100, w: int = 120):
|
||||
base = np.zeros((h, w, 3), dtype=np.uint8) # black
|
||||
restored = np.full((h, w, 3), 255, dtype=np.uint8) # white
|
||||
return base, restored
|
||||
|
||||
def test_output_shape_and_dtype(self):
|
||||
base, restored = self._base_and_restored()
|
||||
out = face_restore._composite_faces(base, restored, [(40.0, 30.0, 80.0, 70.0)])
|
||||
assert out.shape == base.shape
|
||||
assert out.dtype == np.uint8
|
||||
|
||||
def test_box_region_pulls_toward_restored(self):
|
||||
base, restored = self._base_and_restored()
|
||||
out = face_restore._composite_faces(base, restored, [(40.0, 30.0, 80.0, 70.0)])
|
||||
# Center of the box should be near the restored (white) value.
|
||||
cy, cx = 50, 60
|
||||
assert out[cy, cx].mean() > 200
|
||||
|
||||
def test_far_from_box_stays_base(self):
|
||||
base, restored = self._base_and_restored()
|
||||
out = face_restore._composite_faces(base, restored, [(40.0, 30.0, 80.0, 70.0)], pad=2)
|
||||
# Top-left corner is far from the box and feather, so it stays black.
|
||||
assert out[0, 0].mean() < 5
|
||||
|
||||
def test_empty_boxes_returns_base_unchanged(self):
|
||||
base, restored = self._base_and_restored()
|
||||
out = face_restore._composite_faces(base, restored, [])
|
||||
assert np.array_equal(out, base)
|
||||
|
||||
def test_box_fully_outside_is_skipped(self):
|
||||
base, restored = self._base_and_restored(h=100, w=120)
|
||||
# Box entirely beyond the right/bottom edge -> clipped to empty -> no-op.
|
||||
out = face_restore._composite_faces(base, restored, [(200.0, 200.0, 260.0, 260.0)], pad=0)
|
||||
assert np.array_equal(out, base)
|
||||
|
||||
def test_near_edge_box_clips_without_error(self):
|
||||
base, restored = self._base_and_restored(h=100, w=120)
|
||||
# Box reaching past the bottom-right corner must clip, not raise.
|
||||
out = face_restore._composite_faces(base, restored, [(100.0, 80.0, 130.0, 110.0)], pad=10)
|
||||
assert out.shape == base.shape
|
||||
# The clipped in-bounds region still pulls toward white.
|
||||
assert out[95, 115].mean() > 100
|
||||
|
||||
|
||||
@pytest.mark.skipif(not face_restore.is_available(), reason="requires the 'restore' extra (gfpgan/facexlib)")
|
||||
class TestRestoreFacesModel:
|
||||
"""Model-running smoke test, gated behind the optional extra."""
|
||||
|
||||
def test_no_faces_returns_cleaned_unchanged(self):
|
||||
# A flat gray image has no faces; restore_faces must return the cleaned
|
||||
# input unchanged (the no-op path).
|
||||
cleaned = np.full((128, 128, 3), 127, dtype=np.uint8)
|
||||
original = np.full((128, 128, 3), 127, dtype=np.uint8)
|
||||
out = face_restore.restore_faces(original, cleaned)
|
||||
assert out.shape == cleaned.shape
|
||||
assert np.array_equal(out, cleaned)
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Tests for the PhotoMaker-V2 face identity restoration helper.
|
||||
|
||||
These tests cover the pure-Python parts (face crop math, composite, the no-faces
|
||||
no-op, the is_available guard) WITHOUT loading PhotoMaker or SDXL -- the model-loading
|
||||
path is gated behind ``is_available()`` and exercised manually via the Modal cert
|
||||
sweep, mirroring the convention used for ``face_restore`` and ``upscaler``.
|
||||
|
||||
The end-to-end PhotoMaker run is monkey-patched: we replace ``_get_pipeline`` with a
|
||||
fake pipeline whose ``__call__`` returns a known constant-color face, so we can verify
|
||||
that the right boxes get the right pixels composited back.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from remove_ai_watermarks import photomaker_restore
|
||||
|
||||
|
||||
class TestIsAvailable:
|
||||
def test_returns_bool(self):
|
||||
assert isinstance(photomaker_restore.is_available(), bool)
|
||||
|
||||
|
||||
class TestV2WeightPins:
|
||||
"""Pin the V2 repo + weights so a maintainer change is visible in a code review."""
|
||||
|
||||
def test_repo_is_v2(self):
|
||||
assert photomaker_restore._PHOTOMAKER_REPO == "TencentARC/PhotoMaker-V2"
|
||||
|
||||
def test_weight_filename_is_v2(self):
|
||||
assert photomaker_restore._PHOTOMAKER_FILE == "photomaker-v2.bin"
|
||||
|
||||
|
||||
class TestFaceCropSquare:
|
||||
def test_centers_on_face_box(self):
|
||||
img = np.full((400, 400, 3), 128, dtype=np.uint8)
|
||||
crop, box = photomaker_restore._face_crop_square(img, (100, 150, 80, 80))
|
||||
x1, y1, x2, y2 = box
|
||||
# The crop covers the requested box (with padding)
|
||||
assert x1 <= 100
|
||||
assert y1 <= 150
|
||||
assert x2 >= 180
|
||||
assert y2 >= 230
|
||||
assert crop.shape[0] == y2 - y1
|
||||
assert crop.shape[1] == x2 - x1
|
||||
|
||||
def test_clips_at_image_edges(self):
|
||||
img = np.full((200, 200, 3), 128, dtype=np.uint8)
|
||||
crop, (x1, y1, x2, y2) = photomaker_restore._face_crop_square(img, (180, 180, 30, 30))
|
||||
# Box must be clipped within the image
|
||||
assert x1 >= 0
|
||||
assert y1 >= 0
|
||||
assert x2 <= 200
|
||||
assert y2 <= 200
|
||||
assert crop.shape[0] == y2 - y1
|
||||
assert crop.shape[1] == x2 - x1
|
||||
|
||||
def test_pad_widens_the_crop(self):
|
||||
img = np.full((400, 400, 3), 128, dtype=np.uint8)
|
||||
_, no_pad = photomaker_restore._face_crop_square(img, (150, 150, 50, 50), pad=0.0)
|
||||
_, with_pad = photomaker_restore._face_crop_square(img, (150, 150, 50, 50), pad=0.5)
|
||||
assert (with_pad[2] - with_pad[0]) > (no_pad[2] - no_pad[0])
|
||||
|
||||
|
||||
class TestCompositeFaces:
|
||||
def test_empty_list_returns_base_unchanged(self):
|
||||
base = np.full((100, 100, 3), 64, dtype=np.uint8)
|
||||
out = photomaker_restore._composite_faces(base, [])
|
||||
assert np.array_equal(out, base)
|
||||
|
||||
def test_box_outside_image_is_skipped(self):
|
||||
base = np.full((100, 100, 3), 64, dtype=np.uint8)
|
||||
crop = np.full((40, 40, 3), 200, dtype=np.uint8)
|
||||
out = photomaker_restore._composite_faces(base, [(crop, (200, 200, 240, 240))])
|
||||
assert np.array_equal(out, base)
|
||||
|
||||
def test_composited_box_pulls_pixel_value_toward_crop(self):
|
||||
base = np.full((200, 200, 3), 40, dtype=np.uint8)
|
||||
crop = np.full((50, 50, 3), 220, dtype=np.uint8)
|
||||
# Place the crop fully inside the image at (60, 60)..(110, 110)
|
||||
out = photomaker_restore._composite_faces(base, [(crop, (60, 60, 110, 110))])
|
||||
# The box center should be heavily biased toward the crop color (>120) ...
|
||||
assert out[85, 85, 0] > 120
|
||||
# ... and corners (well outside the feathered region) stay close to base
|
||||
assert int(out[0, 0, 0]) - int(base[0, 0, 0]) <= 1
|
||||
|
||||
|
||||
class TestRestoreFacesPhotomakerControlFlow:
|
||||
"""End-to-end control flow with a fake pipeline -- no diffusion model loaded."""
|
||||
|
||||
@staticmethod
|
||||
def _fake_pipeline_class(fill_value: int = 200):
|
||||
"""Class-based fake (no ``__call__`` on a SimpleNamespace, which Python won't dispatch)."""
|
||||
from PIL import Image
|
||||
|
||||
size = photomaker_restore._PHOTOMAKER_FACE_SIZE
|
||||
fake_face = Image.fromarray(np.full((size, size, 3), fill_value, dtype=np.uint8))
|
||||
|
||||
class _FakePipe:
|
||||
device = "cpu"
|
||||
|
||||
def __call__(self, **_kwargs):
|
||||
return SimpleNamespace(images=[fake_face])
|
||||
|
||||
return _FakePipe()
|
||||
|
||||
def test_no_faces_returns_cleaned_unchanged(self, monkeypatch):
|
||||
# Force is_available so we never hit the missing-extra branch
|
||||
monkeypatch.setattr(photomaker_restore, "is_available", lambda: True)
|
||||
monkeypatch.setattr(photomaker_restore, "_get_pipeline", lambda: self._fake_pipeline_class())
|
||||
|
||||
orig = np.full((200, 200, 3), 30, dtype=np.uint8)
|
||||
cleaned = np.full((200, 200, 3), 90, dtype=np.uint8)
|
||||
out = photomaker_restore.restore_faces_photomaker(orig, cleaned, detect_faces_fn=lambda _b: [])
|
||||
assert np.array_equal(out, cleaned)
|
||||
|
||||
def test_one_face_gets_composited_into_cleaned(self, monkeypatch):
|
||||
monkeypatch.setattr(photomaker_restore, "is_available", lambda: True)
|
||||
monkeypatch.setattr(photomaker_restore, "_get_pipeline", lambda: self._fake_pipeline_class(fill_value=210))
|
||||
|
||||
orig = np.full((400, 400, 3), 30, dtype=np.uint8)
|
||||
cleaned = np.full((400, 400, 3), 90, dtype=np.uint8)
|
||||
# Mark the original face region with a distinctive color so we can confirm the
|
||||
# crop reached the pipeline (not strictly tested here, but useful sanity).
|
||||
cv2.rectangle(orig, (150, 150), (250, 250), (200, 100, 50), -1)
|
||||
|
||||
out = photomaker_restore.restore_faces_photomaker(
|
||||
orig, cleaned, detect_faces_fn=lambda _b: [(150, 150, 100, 100)]
|
||||
)
|
||||
# The cleaned image should have shifted toward the fake-face fill (210) inside
|
||||
# the face region.
|
||||
assert out[200, 200, 0] > 150
|
||||
# And the corner pixels (well outside the feather) should still be near the base.
|
||||
assert int(out[0, 0, 0]) - int(cleaned[0, 0, 0]) <= 1
|
||||
Reference in New Issue
Block a user