mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-06-10 12:53:56 +02:00
20d7eda96a
Empirical conclusion from the 2026-06-04 - 2026-06-08 Modal cert sweeps: every face-restore approach we built (GFPGAN-on-cleaned, PhotoMaker-V2, InstantID txt2img, InstantID img2img-on-cleaned at three parameter settings) regenerates the face via SDXL diffusion rather than preserves it. Output face pixels are diffusion-fresh, so the regenerated face inherits SDXL "clean skin" aesthetic and loses original identity precision -- it looks MORE AI-generated than the cleaned image, not less. The cleaned image from the main controlnet 0.20 removal pass is the least-AI face state we can reach without re-introducing SynthID. Nothing in the restore family achieves the actual goal (preserve the original person's face). Keeping them around as opt-in invites users to ship something that defeats the point. Removing entirely. Library changes: - Deleted src/remove_ai_watermarks/instantid_restore.py - Deleted src/remove_ai_watermarks/photomaker_restore.py - Deleted tests/test_instantid_restore.py - Deleted tests/test_photomaker_restore.py - Removed `instantid` and `photomaker` extras from pyproject.toml - Removed `[tool.hatch.metadata] allow-direct-references = true` (was only needed for the photomaker git+ URL) - InvisibleEngine.remove_watermark: dropped `restore_faces` + `restore_faces_method` params, removed both `_restore_faces_instantid` and `_restore_faces_photomaker` private methods, removed dispatch - CLI: dropped `_restore_faces_options` decorator, all four cmd_* signatures lose `restore_faces` + `restore_faces_method`, kwarg passes to remove_watermark dropped - _apply_auto: dropped `restore_faces` from tuple shape (was unused after the engine no longer takes it) - auto_config.AutoConfig: dropped `restore_faces` field; `plan()` no longer sets it; `reason` no longer mentions it - Tests updated accordingly (test_auto_config.TestReason no longer asserts "face-restore on" in the reason string) Docs updated: - CLAUDE.md: removed the photomaker extras bullet, the Face restore trade-off bullet, the instantid_restore.py + photomaker_restore.py module bullets; replaced restore mentions in watermark_remover and controlnet bullets and prod recipe with the empirical conclusion - README.md: removed both `--restore-faces` callouts and the install snippet; the feature bullet and auto-mode comment updated - docs/synthid-robust-identity-research.md: added Status-retired notice at the top pointing at the 2026-06-08 followup raiw-app: - modal_cert.py: dropped `--restore-faces` flag entirely; sweep() no longer takes restore_faces; pinned _LIB_SPEC to `[gpu]` extras (no `photomaker` / `instantid` extras), points at main ruff + strict pyright clean; 569 tests pass; 18 restore-specific tests gone. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
118 lines
4.6 KiB
Python
118 lines
4.6 KiB
Python
"""Tests for the --auto pipeline planner (content-adaptive mode selection).
|
|
|
|
Detection runs on synthetic images; the face-present routing is exercised by
|
|
monkeypatching ``detect_face`` (a real detectable face fixture is private, never
|
|
committed). The planner is cv2-only and torch-free.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import cv2
|
|
import numpy as np
|
|
|
|
from remove_ai_watermarks import auto_config, image_io
|
|
|
|
|
|
def _write(img, tmp_path, name="x.png"):
|
|
p = tmp_path / name
|
|
image_io.imwrite(p, img)
|
|
return p
|
|
|
|
|
|
class TestDetectors:
|
|
def test_detect_face_false_on_flat(self):
|
|
flat = np.full((200, 200, 3), 128, dtype=np.uint8)
|
|
assert auto_config.detect_face(flat) is False
|
|
|
|
def test_edge_density_flat_near_zero(self):
|
|
flat = np.full((200, 200, 3), 128, dtype=np.uint8)
|
|
assert auto_config.edge_density(flat) < 0.001
|
|
|
|
def test_edge_density_text_higher_than_blank(self):
|
|
blank = np.full((200, 400, 3), 255, dtype=np.uint8)
|
|
text = blank.copy()
|
|
cv2.putText(text, "HELLO AI TEXT", (10, 120), cv2.FONT_HERSHEY_SIMPLEX, 2.0, (0, 0, 0), 3)
|
|
assert auto_config.edge_density(text) > auto_config.edge_density(blank)
|
|
|
|
def test_dbnet_detects_text_card(self):
|
|
"""The bundled PP-OCRv3 DBNet model fires on a clear text card and not on flat."""
|
|
card = np.full((300, 500, 3), 255, dtype=np.uint8)
|
|
cv2.putText(card, "INVOICE TOTAL 1234", (10, 170), cv2.FONT_HERSHEY_SIMPLEX, 2.0, (0, 0, 0), 4)
|
|
assert auto_config._detect_text_dbnet(card) is True
|
|
assert auto_config._detect_text_dbnet(np.full((300, 500, 3), 128, dtype=np.uint8)) is False
|
|
|
|
def test_detect_text_falls_back_to_mser_when_dbnet_unavailable(self, monkeypatch):
|
|
"""If DBNet can't load (returns None), detect_text uses the MSER heuristic."""
|
|
monkeypatch.setattr(auto_config, "_detect_text_dbnet", lambda _img: None)
|
|
called = {}
|
|
|
|
def _fake_mser(_img):
|
|
called["mser"] = True
|
|
return True
|
|
|
|
monkeypatch.setattr(auto_config, "_detect_text_mser", _fake_mser)
|
|
assert auto_config.detect_text(np.full((100, 100, 3), 128, dtype=np.uint8)) is True
|
|
assert called.get("mser") is True
|
|
|
|
|
|
class TestPlan:
|
|
def test_unreadable_returns_none(self, tmp_path):
|
|
assert auto_config.plan(tmp_path / "does_not_exist.png") is None
|
|
|
|
def test_flat_image_is_default_pipeline_no_polish(self, tmp_path):
|
|
flat = np.full((300, 300, 3), 128, dtype=np.uint8)
|
|
cfg = auto_config.plan(_write(flat, tmp_path))
|
|
assert cfg is not None
|
|
assert cfg.pipeline == "default" # structure-less -> plain SDXL
|
|
assert cfg.adaptive_polish is False # no smoothing pass -> no polish
|
|
assert cfg.unsharp == 0.0
|
|
assert cfg.humanize == 0.0
|
|
assert cfg.min_resolution == 1024
|
|
|
|
def test_text_image_uses_controlnet(self, tmp_path):
|
|
img = np.full((300, 500, 3), 255, dtype=np.uint8)
|
|
cv2.putText(img, "INVOICE TOTAL 1234", (10, 170), cv2.FONT_HERSHEY_SIMPLEX, 2.0, (0, 0, 0), 4)
|
|
cfg = auto_config.plan(_write(img, tmp_path))
|
|
assert cfg is not None
|
|
# Text creates edges above the structure-less floor -> controlnet preserves them.
|
|
assert cfg.pipeline == "controlnet"
|
|
|
|
def test_face_routes_to_controlnet_and_polish(self, tmp_path, monkeypatch):
|
|
monkeypatch.setattr(auto_config, "detect_face", lambda _img: True)
|
|
flat = np.full((300, 300, 3), 128, dtype=np.uint8)
|
|
cfg = auto_config.plan(_write(flat, tmp_path))
|
|
assert cfg is not None
|
|
assert cfg.has_face
|
|
assert cfg.pipeline == "controlnet"
|
|
assert cfg.adaptive_polish # smoothing pass ran -> adaptive polish on
|
|
assert cfg.unsharp == 0.0 # fixed knobs off; the adaptive polish replaces them
|
|
assert cfg.humanize == 0.0
|
|
|
|
def test_text_signal_forces_controlnet_on_flat(self, tmp_path, monkeypatch):
|
|
monkeypatch.setattr(auto_config, "detect_text", lambda _img: True)
|
|
flat = np.full((300, 300, 3), 128, dtype=np.uint8)
|
|
cfg = auto_config.plan(_write(flat, tmp_path))
|
|
assert cfg is not None
|
|
assert cfg.has_text
|
|
assert cfg.pipeline == "controlnet"
|
|
|
|
|
|
class TestReason:
|
|
def test_reason_summarizes_plan(self):
|
|
cfg = auto_config.AutoConfig(
|
|
pipeline="controlnet",
|
|
adaptive_polish=True,
|
|
unsharp=0.0,
|
|
humanize=0.0,
|
|
min_resolution=1024,
|
|
has_face=True,
|
|
has_text=False,
|
|
edge_density=0.05,
|
|
width=800,
|
|
height=600,
|
|
)
|
|
r = cfg.reason
|
|
assert "controlnet" in r
|
|
assert "face" in r
|
|
assert "adaptive polish" in r
|