feat: GFPGAN face-identity restoration post-pass

Add an optional, commercial-safe face-restoration post-pass that recovers
face identity the diffusion removal pass drifts (canny holds structure, not
likeness) while still scrubbing the pixel watermark in the face regions.

- face_restore.py: GFPGANer singleton (CPU unless CUDA), the basicsr
  torchvision.transforms.functional_tensor shim, and the pure feather
  _composite_faces helper (unit-tested without the model). GFPGAN
  re-synthesizes each face from a StyleGAN2 prior, so composited face pixels
  are GAN-generated (no watermark, no pixel-copy) -- oracle-clean at weight 0.5
  with identity preserved.
- InvisibleEngine.remove_watermark: restore_faces / restore_faces_weight,
  best-effort, auto-skips when the extra is absent or no face is detected.
- CLI --restore-faces/--no-restore-faces + --restore-faces-weight on
  invisible/all/batch (on by default).
- restore extra (gfpgan/facexlib/basicsr), numpy<2-pinned (scipy<1.18,
  numba<0.60) and kept out of `all`; basicsr needs Python <3.13 + setuptools<69
  to build, so pin .python-version 3.12.

Commercial-safe: GFPGAN Apache-2.0, RetinaFace MIT. The CodeFormer alternative
is non-commercial and is not shipped. The earlier IP-Adapter FaceID layer was
removed (footgun: needs high strength, corrupts faces at the low removal
strength).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Victor Kuznetsov
2026-06-03 16:51:52 -07:00
parent d90d5d886a
commit 411ef16ec3
11 changed files with 1624 additions and 14 deletions
+4
View File
@@ -46,3 +46,7 @@ data/jimeng_capture/seeds/
data/jimeng_capture/captures/jimeng_content_*.png
data/gemini_capture/seeds/
data/gemini_capture/captures/gemini_content_*.png
# GFPGAN downloads its RetinaFace/parsing weights to a CWD ./gfpgan/weights/
# working dir on first use (the restore extra). Runtime artifact, never committed.
gfpgan/
+1
View File
@@ -0,0 +1 @@
3.12
+5 -3
View File
@@ -10,7 +10,7 @@ You are a **principal Python engineer** maintaining a CLI tool and library for r
- `uv run remove-ai-watermarks identify <image>` — provenance verdict (platform + watermark inventory + confidence); `--json` for machine output, `--no-visible` to skip the cv2 sparkle detector
- `uv run remove-ai-watermarks metadata <image.png> --check` — inspect AI metadata (C2PA, EXIF, PNG chunks)
- `uv run remove-ai-watermarks metadata <image.png> --remove -o <out.png>` — strip all AI metadata
- `uv run remove-ai-watermarks batch <directory>` — process every supported image in a directory (output defaults to `<directory>_clean/`, set with `-o`). `--mode visible|invisible|metadata|all` (default `visible`); the invisible/all path reuses the same `--strength`/`--steps`/`--pipeline`/`--device`/`--max-resolution`/`--seed`/`--hf-token` knobs as `invisible`, `--inpaint/--no-inpaint` for the visible pass, and `--humanize` for the Analog Humanizer
- `uv run remove-ai-watermarks batch <directory>` — process every supported image in a directory (output defaults to `<directory>_clean/`, set with `-o`). `--mode visible|invisible|metadata|all` (default `visible`); the invisible/all path reuses the same `--strength`/`--steps`/`--pipeline`/`--device`/`--max-resolution`/`--seed`/`--hf-token` knobs as `invisible`, `--inpaint/--no-inpaint` for the visible pass, `--humanize` for the Analog Humanizer, and `--restore-faces/--no-restore-faces` + `--restore-faces-weight` for the GFPGAN face-identity post-pass
## Test and lint
@@ -27,6 +27,7 @@ You are a **principal Python engineer** maintaining a CLI tool and library for r
- GPU/ML modules (invisible_engine, watermark_remover) are optional — guard imports with `is_available()` checks
- Optional detection extras: `detect` (imwatermark — open SD/SDXL/FLUX watermark) and `trustmark` (Adobe TrustMark decoder; pulls torch + downloads weights). Both are guarded by `is_available()` and skipped by `identify` when absent.
- Optional `restore` extra (gfpgan/facexlib/basicsr): the GFPGAN face-identity post-pass (`face_restore.py`, CLI `--restore-faces`, ON by default). Guarded by `face_restore.is_available()`; the default-on flag auto-skips with a debug log when the extra is absent or no face is detected. numpy<2-pinned and Python-3.12-pinned (see the `face_restore.py` Key-modules bullet).
- Tests for the *model-running* paths are limited to availability checks (multi-GB downloads). But the **pure helpers inside ML-adjacent modules are unit-tested without any download** and must stay that way: `_target_size` (native-vs-downscale, `test_invisible_engine.py`) and the MPS->CPU fallback control flow via mocked pipelines (`test_img2img_runner.py`, 100% cover). Don't skip these as "ML, needs a model" — only `remove_watermark`/the diffusion bodies do.
## Key modules
@@ -42,7 +43,8 @@ You are a **principal Python engineer** maintaining a CLI tool and library for r
- `region_eraser.py` — universal region eraser (`erase` CLI). `erase(image, boxes=|mask=, backend=)` normalizes grayscale (2D) and RGBA (4-channel) inputs up front (`erase_cv2` splits off any alpha plane and re-attaches it on the result): `boxes_to_mask``cv2.inpaint` (`cv2` backend, default, no deps) or big-LaMa via onnxruntime (`lama` backend, extra `lama`, `Carve/LaMa-ONNX` Apache-2.0 model downloaded on first use, never bundled). `erase_lama` crops a padded region around the mask, runs LaMa at its fixed 512² input, pastes only masked pixels back (untouched areas stay pixel-exact). Lazy `_get_lama_session` singleton; `lama_available()` guards the optional import. **LaMa-ONNX costs ~3.5-4 GB peak RAM and ~5-6 s/call on CPU** (FFC working set, not arena — `enable_cpu_mem_arena=False` does not help), so it does NOT fit a minimal droplet; the cv2 backend (tens of MB, ~30 ms) does. LaMa quality at low RAM = serverless/GPU, mirroring how raiw.cc offloads SDXL to fal.
- `invisible_watermark.py``detect_invisible_watermark(path)` decodes the OPEN DWT-DCT watermarks (public decoder, no key) embedded by Stable Diffusion / SDXL / FLUX via the `imwatermark` library. Known fixed patterns (verified against upstream source) live in `_BITS_48` (SDXL 48-bit, FLUX.2 48-bit) and `_SD1_STRING` ("StableDiffusionV1", SD 1.x/2.x). Optional dep (extra `detect`); returns None when absent. The `detect` extra pulls **torch** transitively (invisible-watermark declares torch a hard dep, and `WatermarkDecoder` eagerly imports `rivaGan` -> `torch` at import time), so detection needs torch present even though dwtDct runs CPU-only on cv2/numpy/pywavelets — no GPU and no separate `gpu` extra required. **Unlike SynthID this is locally detectable**, but the watermark is fragile (does not survive JPEG re-encode/resize — verified gone after JPEG q90), so it confirms origin only on pristine files. Add new known patterns here. The file carries a top-of-module pyright pragma because imwatermark/cv2 ship no type stubs.
- `trustmark_detector.py``detect_trustmark(path)` decodes the OPEN, keyless **Adobe TrustMark** watermark (the soft binding behind Adobe Durable Content Credentials, `alg` `com.adobe.trustmark.P`) via the optional `trustmark` package (extra `trustmark`; pulls torch, downloads model weights on first use). Mirrors `invisible_watermark.py` (lazy singleton guarded by a double-checked `threading.Lock` so concurrent callers do not double-download the weights, top-of-module pyright pragma, returns None when absent). It detects *provenance*, not AI origin as such (TrustMark also marks human-authored content), so `identify` lists it as a watermark without setting `is_ai_generated`. Other soft-binding vendors (Digimarc/Imatag/Steg.AI/...) have no public decoder — they are only *named* via the `C2PA_SOFT_BINDINGS` scan, not decoded. **False-positive gate (added 2026-05-29):** TrustMark's `wm_present` is a BCH error-correction validity flag that spuriously validates on a content-correlated fraction of un-watermarked images — AI-generated textures trip it far more than camera photos (verified 2026-05-29 on real files: it fires on Gemini/OpenAI/Doubao output that *cannot* carry Adobe's watermark, with a random-bytes decoded secret, while signal-free camera photos did not trip it). A genuine TrustMark is a *durable* soft binding engineered to survive re-encoding, so `detect_trustmark` re-decodes after a mild JPEG round-trip (`_survives_reencode`, `_REENCODE_QUALITY` 95) and requires the same schema both times; every observed false positive collapsed (none survived even q95), so the gate is the durability property the watermark guarantees. The second decode runs only on the rare initial hit, so the cost is negligible. Do NOT remove the gate to "catch more" — a lone TrustMark hit without it is almost always content noise.
- `noai/watermark_remover.py` — the `WatermarkRemover` class has two diffusion pipelines, selected by the explicit `pipeline` ctor arg (NOT inferred from `model_id` -- both use the same SDXL base, `DEFAULT_MODEL_ID`). **`default`** runs plain SDXL img2img (`_run_img2img`). **`controlnet`** (`_run_controlnet`, `_load_controlnet_pipeline`) runs `StableDiffusionXLControlNetImg2ImgPipeline` with the SDXL-native canny ControlNet `xinsir/controlnet-canny-sdxl-1.0` (`watermark_profiles.CONTROLNET_CANNY_MODEL`): the control image is `cv2.Canny(gray, 100, 200)` stacked to 3 channels (`_CANNY_LOW`/`_CANNY_HIGH`, prompt `_CONTROLNET_PROMPT` / `_CONTROLNET_NEGATIVE`). **Removal still comes from the img2img regeneration (`strength`); the ControlNet only PRESERVES text and face STRUCTURE via the edge map -- no original pixels are copied or frozen, so SynthID does not survive.** Canny holds face STRUCTURE but NOT identity (the regenerated face drifts in likeness -- canny carries edges, not identity; face identity is a separate future face-restoration post-pass, see docs). `controlnet_conditioning_scale` (ctor arg, default 1.0) is the structure-preservation knob. Same dtype rule as `default` (fp32 on cpu/mps, fp16 only on cuda/xpu; the fp16-fixed SDXL VAE `_SDXL_FP16_VAE_ID` is swapped in on fp16 GPUs -- issue #29) and the same MPS->CPU fallback (reload on cpu/fp32, drop a non-cpu generator, retry once).
- `noai/watermark_remover.py` — the `WatermarkRemover` class has two diffusion pipelines, selected by the explicit `pipeline` ctor arg (NOT inferred from `model_id` -- both use the same SDXL base, `DEFAULT_MODEL_ID`). **`default`** runs plain SDXL img2img (`_run_img2img`). **`controlnet`** (`_run_controlnet`, `_load_controlnet_pipeline`) runs `StableDiffusionXLControlNetImg2ImgPipeline` with the SDXL-native canny ControlNet `xinsir/controlnet-canny-sdxl-1.0` (`watermark_profiles.CONTROLNET_CANNY_MODEL`): the control image is `cv2.Canny(gray, 100, 200)` stacked to 3 channels (`_CANNY_LOW`/`_CANNY_HIGH`, prompt `_CONTROLNET_PROMPT` / `_CONTROLNET_NEGATIVE`). **Removal still comes from the img2img regeneration (`strength`); the ControlNet only PRESERVES text and face STRUCTURE via the edge map -- no original pixels are copied or frozen, so SynthID does not survive.** Canny holds face STRUCTURE but NOT identity (the regenerated face drifts in likeness -- canny carries edges, not identity; face identity is preserved by the optional `--restore-faces` GFPGAN post-pass, auto when faces present -- see `face_restore.py`). `controlnet_conditioning_scale` (ctor arg, default 1.0) is the structure-preservation knob. Same dtype rule as `default` (fp32 on cpu/mps, fp16 only on cuda/xpu; the fp16-fixed SDXL VAE `_SDXL_FP16_VAE_ID` is swapped in on fp16 GPUs -- issue #29) and the same MPS->CPU fallback (reload on cpu/fp32, drop a non-cpu generator, retry once).
- `face_restore.py` — optional GFPGAN face-restoration post-pass (cv2/torch/gfpgan boundary, top-of-file pyright pragma). Runs AFTER the diffusion removal pass (`InvisibleEngine.remove_watermark`, params `restore_faces=True` / `restore_faces_weight=0.5`; CLI `--restore-faces`/`--no-restore-faces` + `--restore-faces-weight` on `invisible`/`all`/`batch`, ON by default). **Restores face IDENTITY while still scrubbing the pixel watermark:** GFPGAN re-synthesizes each face from a StyleGAN2 prior (codebook/GAN pixels, NOT the original), so the composited face regions carry no watermark and no pixel-copy -- oracle-validated clean at weight 0.5 with identity preserved. Flow: GFPGANer.enhance runs on the ORIGINAL (watermarked) image -> identity faces + RetinaFace boxes (`restorer.face_helper.det_faces`); `_composite_faces` feather-composites those restored face REGIONS into the diffusion-cleaned image. `is_available()` gates on gfpgan + facexlib; lazily-built `GFPGANer` singleton forces CPU unless CUDA (the pip GFPGANer has an MPS device-mismatch bug; it is a cheap post-pass on a few face crops). `_apply_basicsr_shim()` recreates the removed `torchvision.transforms.functional_tensor` module that basicsr imports. The pure `_composite_faces` helper (Gaussian-feathered rectangular alpha per box, `out = restored*a + base*(1-a)`) is unit-tested without the model (`tests/test_face_restore.py`); the model-running path is gated behind `is_available()`. **Commercial-safe** (GFPGAN Apache-2.0 + RetinaFace MIT); the CodeFormer alternative is NON-COMMERCIAL and is NOT shipped. The `restore` extra (gfpgan/facexlib/basicsr) is kept OUT of `all` (heavy + the GFPGANv1.4 + RetinaFace weights download on first use, never bundled). **`restore` pins numpy<2** (same trap class as the removed faceid/insightface extra): basicsr/gfpgan/facexlib are an old ecosystem, so the extra caps `scipy<1.18` (>=1.18 uses `np.long`, gone in numpy 1.24-1.26) and `numba<0.60` to keep the whole env on one numpy 1.26 resolution; verified the `--extra dev --extra gpu` gate env stays numpy 1.26.4 + `diffusers.loaders.peft` importable with `restore` present. **basicsr 1.4.2 builds only on Python <3.13** (its `setup.py get_version()` uses `exec(...)` + `locals()['__version__']`, which the 3.13 fast-locals change broke -> `KeyError: '__version__'`), so the project is pinned to Python 3.12 via `.python-version` and `[tool.uv.extra-build-dependencies] basicsr = ["setuptools<69"]`. basicsr ships sdist-only (no wheel).
- `image_io.py` — Unicode-safe cv2 IO (issue #17). `imread(path, flags=None)` / `imwrite(path, img)` wrap `np.fromfile`+`cv2.imdecode` / `cv2.imencode`+`tofile` so non-ASCII paths work on Windows -- bare `cv2.imread`/`cv2.imwrite` use the platform ANSI code-page API there and fail (empty decode + `can't open/read file`) on Chinese/Cyrillic/accented filenames. `imread` keeps `cv2.imread` semantics (defaults to `IMREAD_COLOR`, returns `None` on missing/empty/undecodable). **Every cv2 file read/write in the package routes through here; do not call `cv2.imread`/`cv2.imwrite` directly.** `imwrite` returns `False` on an unwritable path (`OSError` caught) instead of raising, matching `cv2.imwrite` semantics. macOS/Linux already accept UTF-8 paths, so it is behavior-neutral there (the bug only reproduces on Windows). cv2/numpy are imported lazily inside the functions, so the module is cheap to import in a bare env.
### Doubao clean-reverse-alpha distillation (re-investigated 2026-05-29)
@@ -86,4 +88,4 @@ Who embeds what, and whether it is locally detectable (so we know which gaps are
- **External AI-vs-real classifier models are out of scope (decided 2026-05-24).** Generic HuggingFace detectors (`Organika/sdxl-detector` Swin Transformer, `umm-maybe/AI-image-detector`, and fine-tunes) exist and report ~0.98 on their *own* SDXL-vs-real validation sets, but they are per-generator and the model cards themselves note degraded accuracy off-distribution; they are untested on gpt-image / Gemini Nano Banana (the metadata-stripped surfaces we care about), and our own light SDXL pass would likely defeat them the same way it defeats SynthID. Detection here stays local + signal-based (metadata + visible sparkle); do not add a bundled classifier dependency.
- **DEFAULT STRENGTH IS NOW VENDOR-ADAPTIVE (2026-06-01, SUPERSEDES every fixed-default claim in this bullet and the next).** `resolve_strength(strength, profile, vendor)` + `vendor_for_strength(path)` (`watermark_profiles.py`) read the C2PA issuer (`metadata.synthid_source`) on the ORIGINAL input and pick `OPENAI_STRENGTH` **0.10** / `GEMINI_STRENGTH` **0.15** / `UNKNOWN_STRENGTH` **0.15** when `--strength` is unset; explicit `--strength` always wins. The CLI detects the vendor from the pristine source (before the visible pass / metadata-strip removes C2PA from the temp file) and passes it to the engine, so display and execution agree; `cmd_invisible`/`cmd_all`/`batch` + the module-level `remove_watermark` all thread `vendor`. **This replaces the single 0.30 default AND the prior "do NOT build a vendor-adaptive default" policy** -- both came from the now-debunked region-rescrub-contaminated study (the per-region re-scrub that contaminated those numbers was removed in the controlnet refactor). Basis: the oracle-verified June 2026 controlled study (clean v0.8.6, protect OFF): OpenAI clears at 0.05 across 1024-1600 (n=4, resolution-independent); Google needs 0.15 on the capped-1536 path (n=4). `docs/synthid.md` §2.2 (data) + §5.2 (the adaptive default) are authoritative. CAVEAT: Google's 0.15 was validated only on `--max-resolution 1536`; native large Gemini (2816) was not locally measurable (OOM on M-series) and is pending GPU validation on raiw.cc -- if it survives 0.15 native, raise `--strength`. **Everything below in this bullet about a fixed 0.10/0.30 default is HISTORICAL; trust the vendor-adaptive constants + docs/synthid.md.**
- **SynthID removal: strength + oracle scope.** Default strength is vendor-adaptive (see the bullet above); `docs/synthid.md` §2.2 is authoritative for the numbers. **Oracle scope (load-bearing):** the Gemini app "Verify with SynthID" is the ONLY valid SynthID oracle (detects Google's mark on any image); `openai.com/verify` is scoped to OpenAI provenance (its own C2PA), NOT a SynthID oracle -- a negative there is meaningless for SynthID. There is no local SynthID detector, so the tool cannot self-check; if the oracle still reads SynthID, raise `--strength` to the lowest value that verifies clean. Only the `default` (plain SDXL img2img) and `controlnet` (SDXL + canny ControlNet) profiles exist; the local `invisible` default is weight-for-weight identical to raiw.cc prod (`fal-ai/fast-sdxl` = `stabilityai/stable-diffusion-xl-base-1.0`, runtime-downloaded, not bundled). **Forensic-stealth caveat** (arXiv:2605.09203): defeating the SynthID verifier is NOT forensic invisibility -- independent detectors flag *removal-processed* images vs genuinely-clean ones at >98% TPR@1%FPR, so do not over-claim "indistinguishable from a real photo".
- **`controlnet` pipeline (text/face STRUCTURE preservation, opt-in `--pipeline controlnet`).** SDXL + the canny ControlNet `xinsir/controlnet-canny-sdxl-1.0` via `StableDiffusionXLControlNetImg2ImgPipeline` (`watermark_remover._run_controlnet` / `_load_controlnet_pipeline`). **Removal still comes from the img2img regeneration (`strength`); the ControlNet only PRESERVES text and face STRUCTURE by conditioning on the canny edge map** (`cv2.Canny(gray, 100, 200)`, 3-channel). Canny preserves edges, NOT face identity (a regenerated face drifts in likeness); face identity is a separate future face-restoration post-pass (CodeFormer/GFPGAN, researched + prototyped, not yet shipped -- see `docs/controlnet-removal-pipeline-research.md`). The earlier `--face-id` IP-Adapter FaceID layer was REMOVED (footgun: it needs high strength and corrupts faces at the low removal strength). **No original pixels are copied or frozen, so SynthID does not survive** -- unlike the deleted text/face-protection subsystems, which restored or re-scrubbed original pixels and could shield the watermark. `controlnet_conditioning_scale` (CLI `--controlnet-scale`, default 1.0) is the structure-preservation knob (higher = closer to the original structure). It shares the SDXL base, so it uses the SAME vendor-adaptive strength as `default` (`resolve_strength`); fp32 on cpu/mps, fp16-fixed VAE on cuda/xpu. The `controlnet` profile is threaded explicitly (`WatermarkRemover(pipeline=...)` / `InvisibleEngine(pipeline=...)`), NOT inferred from `model_id`. This productionizes the `scripts/controlnet_sweep.py` prototype; see `docs/controlnet-removal-pipeline-research.md`. **Forensic-stealth caveat still applies** (arXiv:2605.09203): defeating the SynthID verifier is not forensic invisibility -- a "this image went through a removal pipeline" classifier can still flag the output.
- **`controlnet` pipeline (text/face STRUCTURE preservation, opt-in `--pipeline controlnet`).** SDXL + the canny ControlNet `xinsir/controlnet-canny-sdxl-1.0` via `StableDiffusionXLControlNetImg2ImgPipeline` (`watermark_remover._run_controlnet` / `_load_controlnet_pipeline`). **Removal still comes from the img2img regeneration (`strength`); the ControlNet only PRESERVES text and face STRUCTURE by conditioning on the canny edge map** (`cv2.Canny(gray, 100, 200)`, 3-channel). Canny preserves edges, NOT face identity (a regenerated face drifts in likeness); face identity is preserved by the optional `--restore-faces` GFPGAN post-pass (auto when faces present -- see `face_restore.py`, the `restore` extra), which re-synthesizes each face from a StyleGAN2 prior so the composited face pixels carry no watermark. The CodeFormer alternative stays NON-COMMERCIAL and is not shipped. The earlier `--face-id` IP-Adapter FaceID layer was REMOVED (footgun: it needs high strength and corrupts faces at the low removal strength). **No original pixels are copied or frozen, so SynthID does not survive** -- unlike the deleted text/face-protection subsystems, which restored or re-scrubbed original pixels and could shield the watermark. `controlnet_conditioning_scale` (CLI `--controlnet-scale`, default 1.0) is the structure-preservation knob (higher = closer to the original structure). It shares the SDXL base, so it uses the SAME vendor-adaptive strength as `default` (`resolve_strength`); fp32 on cpu/mps, fp16-fixed VAE on cuda/xpu. The `controlnet` profile is threaded explicitly (`WatermarkRemover(pipeline=...)` / `InvisibleEngine(pipeline=...)`), NOT inferred from `model_id`. This productionizes the `scripts/controlnet_sweep.py` prototype; see `docs/controlnet-removal-pipeline-research.md`. **Forensic-stealth caveat still applies** (arXiv:2605.09203): defeating the SynthID verifier is not forensic invisibility -- a "this image went through a removal pipeline" classifier can still flag the output.
+12 -3
View File
@@ -23,7 +23,7 @@ If this tool saves you time, consider [sponsoring its development](https://githu
- **AI metadata stripping** — EXIF, PNG text chunks, C2PA provenance manifests (PNG / JPEG / AVIF / HEIF / JPEG-XL, **MP4 / MOV / M4V / M4A** at the container level, and **WebM / MP3 / WAV / FLAC / OGG** losslessly via ffmpeg), XMP DigitalSourceType
- **"Made with AI" label removal** — removes the AI-disclosure metadata that platforms read to apply automatic labels (useful for clearing a false-positive label from a human-edited photograph)
- **Analog Humanizer** — optional film grain and chromatic aberration post-processing
- **Text and face preservation** — optional `--pipeline controlnet` adds a canny ControlNet that keeps text and face structure sharp through the removal pass (without copying original pixels, so SynthID is still removed). Note: canny preserves face *structure*, not *identity* (the regenerated face drifts in likeness); preserving identity is a separate face-restoration post-pass, researched but not yet shipped
- **Text and face preservation** — optional `--pipeline controlnet` adds a canny ControlNet that keeps text and face structure sharp through the removal pass (without copying original pixels, so SynthID is still removed). Canny preserves face *structure*, not *identity* (the regenerated face drifts in likeness); identity is preserved by the `--restore-faces` GFPGAN post-pass (on by default, auto when faces present)
- **Batch processing** — process entire directories
- **Detection** — three-stage NCC watermark detection with confidence scoring
- **Provenance detection (`identify`)** — aggregate C2PA issuer, the C2PA soft-binding forensic-watermark vendor (Adobe TrustMark, Digimarc, Imatag, ...), IPTC "Made with AI" plus the IPTC 2025.1 `AISystemUsed` field, embedded SD/ComfyUI params, EXIF/XMP generator tags, the xAI/Grok EXIF signature, the China TC260 AIGC label (XMP, PNG chunk, or EXIF), the HuggingFace `hf-job-id` job marker, the SynthID metadata proxy, the visible marks (Gemini sparkle plus the Doubao "豆包AI生成" / Jimeng "即梦AI" text marks), the open SD/SDXL/FLUX invisible watermark, and (with the `trustmark` extra) the open Adobe TrustMark watermark into one origin-platform + watermark-inventory verdict (`--json` for machine output)
@@ -119,7 +119,7 @@ image → encode to latent space (VAE) at native resolution
>
> **`--pipeline controlnet` preserves text and face structure.** It runs the same SDXL img2img scrub but adds a canny ControlNet that conditions the regeneration on the image's edge map, so text and structure stay sharp at the strengths that remove SynthID. The watermark removal still comes from the img2img regeneration (`--strength`); the ControlNet only preserves structure — no original pixels are copied or frozen, so SynthID does not survive. `--controlnet-scale` tunes the preservation strength (higher = closer to the original structure). Runs fp32 on mps/cpu (fp16 only on cuda/xpu, where the fp16-fixed SDXL VAE is loaded automatically).
>
> **Face identity is not preserved yet.** Canny preserves where a face is, but not who it is — the regenerated face drifts in likeness. (An IP-Adapter FaceID approach was tried and removed: it needs high denoise strength and corrupts faces at the low strength used for removal.) The validated direction is a separate face-restoration post-pass (CodeFormer/GFPGAN at a low fidelity weight, run after the removal pass — it re-synthesizes each face from a codebook, so it scrubs the watermark while holding identity) — researched and prototyped (see `docs/controlnet-removal-pipeline-research.md`) but not yet shipped.
> **`--restore-faces` preserves face identity (GFPGAN, on by default).** Canny preserves where a face is, but not who it is — the regenerated face drifts in likeness. The `--restore-faces` post-pass (on by default; needs the `restore` extra) fixes this: after the removal pass it runs GFPGAN on the original faces and composites the restored face regions into the cleaned image. GFPGAN re-synthesizes each face from a StyleGAN2 prior, so those pixels are GAN-generated (not copied) — the watermark is still scrubbed in the face regions while identity is held (oracle-confirmed clean). It auto-skips when no face is detected or the extra is absent. Tune fidelity with `--restore-faces-weight` (default `0.5`; lower = more regeneration / cleaner scrub, higher = closer to the input). Commercial-safe (GFPGAN is Apache-2.0, its RetinaFace detector MIT); the CodeFormer alternative is non-commercial and is not shipped. (An IP-Adapter FaceID approach was tried earlier and removed: it needs high denoise strength and corrupts faces at the low strength used for removal.)
SDXL is the default since May 2026: empirically defeats SynthID v2 on Gemini 3 Pro outputs, where the older SD-1.5 pipeline at 768 px did not. The SD-1.5 path was removed once it was verified not to handle v2. Note the scope: this defeats the SynthID *verifier*, which is not the same as being forensically indistinguishable from a real photo. Recent work ([arXiv:2605.09203](https://arxiv.org/abs/2605.09203)) shows watermark-removal pipelines leave detectable traces, so a separate "this image was processed" classifier can still flag the output.
@@ -127,7 +127,7 @@ SDXL is the default since May 2026: empirically defeats SynthID v2 on Gemini 3 P
> **Technical deep-dive:** see [`docs/synthid.md`](docs/synthid.md) for a primary-source-cited breakdown of how SynthID works mechanically (post-hoc encoder/decoder, 136-bit payload, pixel-space embedding), what it empirically survives (JPEG, crop, resize: ~99.98% TPR at 0.1% FPR from arXiv:2510.09263), what removes it, and the forensic-stealth tradeoff (all known removal attacks are detectable at >98% TPR@1%FPR per arXiv:2605.09203).
**Text and face preservation** (opt-in `--pipeline controlnet`): adds a canny ControlNet so text and face *structure* stay sharp through the removal pass, without copying or freezing any original pixels (so SynthID is still removed). Tune the preservation strength with `--controlnet-scale`. Canny preserves structure but not face *identity* (preserving identity is a future face-restoration post-pass, not yet shipped — see the callout above).
**Text and face preservation** (opt-in `--pipeline controlnet`): adds a canny ControlNet so text and face *structure* stay sharp through the removal pass, without copying or freezing any original pixels (so SynthID is still removed). Tune the preservation strength with `--controlnet-scale`. Canny preserves structure but not face *identity* (identity is preserved by the `--restore-faces` GFPGAN post-pass, on by default — see the callout above).
**Analog Humanizer**: optional film grain and chromatic aberration injection that mimics a photo of a screen, raising the bar for AI-generated image classifiers. (It frustrates generic classifiers but does not guarantee forensic invisibility — see the [arXiv:2605.09203](https://arxiv.org/abs/2605.09203) note above.)
@@ -204,6 +204,15 @@ After installation the `remove-ai-watermarks` command is available system-wide.
> ```bash
> pip install -e ".[trustmark]" # or: uv pip install -e ".[trustmark]"
> ```
>
> To preserve face identity after invisible removal (the `--restore-faces`
> GFPGAN post-pass, on by default), install the `restore` extra. The GFPGANv1.4
> and RetinaFace weights download on first use. It needs Python < 3.13 (basicsr
> does not build on 3.13):
>
> ```bash
> pip install -e ".[restore]" # or: uv pip install -e ".[restore]"
> ```
#### Invisible watermark removal
+9 -6
View File
@@ -384,12 +384,15 @@ conditioning, never by copying original pixels.**
while every pixel is still regenerated -- SynthID is removed everywhere. Verified
better than plain img2img at the same strength (text stays legible where plain
garbles it), and the controlnet background scrub reads clean on the oracle.
- **Face identity:** canny holds face *structure* but not *identity*. The validated
approach (researched + prototyped 2026-06-03, not yet shipped) is a face-restoration
post-pass: CodeFormer/GFPGAN RE-SYNTHESIZES each face from a discrete codebook
(codebook pixels, not original -> scrubs SynthID) at a low fidelity weight
(`w~0.5`), composited into the cleaned image. Oracle-confirmed clean in face
regions with identity preserved. (An IP-Adapter FaceID approach was tried and
- **Face identity:** canny holds face *structure* but not *identity*. Shipped as the
optional `--restore-faces` GFPGAN post-pass (`face_restore.py`, the `restore`
extra, ON by default, auto when faces present). It runs GFPGAN on the ORIGINAL
faces and feather-composites the restored face REGIONS into the cleaned image:
GFPGAN RE-SYNTHESIZES each face from a StyleGAN2 prior (GAN pixels, not original
-> scrubs SynthID) at a low fidelity weight (`--restore-faces-weight`, default
`0.5`). Oracle-confirmed clean in face regions with identity preserved. Commercial-
safe (GFPGAN Apache-2.0 + RetinaFace MIT); the CodeFormer alternative is
NON-COMMERCIAL and is not shipped. (An IP-Adapter FaceID approach was tried and
REMOVED -- it needs high denoise strength and corrupts faces at removal strength;
see `docs/controlnet-removal-pipeline-research.md`.)
+24
View File
@@ -76,6 +76,22 @@ lama = [
"onnxruntime>=1.16.0",
"huggingface-hub>=0.20.0",
]
# Optional GFPGAN face-restoration post-pass (commercial-safe Apache-2.0 GFPGAN +
# MIT RetinaFace). Re-synthesizes each face from a StyleGAN2 prior after the
# diffusion removal pass, so it restores identity while still scrubbing the pixel
# watermark. The GFPGANv1.4 weights + RetinaFace detector download on first use;
# they are never bundled. gfpgan/basicsr/facexlib are an OLD ecosystem and must
# stay on numpy < 2.0 to match the pinned gpu diffusion stack -- scipy is capped
# < 1.18 (>= 1.18 uses np.long, gone in numpy 1.24-1.26) and numba < 0.60 to keep
# the whole env on one numpy 1.26 resolution (same trap class as the removed
# faceid/insightface extra). Kept OUT of `all` (heavy + model download).
restore = [
"gfpgan>=1.3.8",
"facexlib>=0.3.0",
"basicsr>=1.4.2",
"scipy<1.18",
"numba<0.60",
]
dev = [
"pytest>=8.0.0",
"pytest-cov>=4.1.0",
@@ -92,6 +108,14 @@ all = ["remove-ai-watermarks[gpu,detect,trustmark,lama,dev]"]
[tool.uv]
prerelease = "allow"
# basicsr 1.4.2 (pulled by the `restore` GFPGAN extra) ships sdist-only and its
# setup.py get_version() reads basicsr/version.py in a way that newer setuptools
# (>= 69) breaks with ``KeyError: '__version__'`` under isolated PEP 517 builds.
# Pin an old setuptools as its build dependency so the sdist builds; this is
# scoped to basicsr and does not affect the rest of the resolution.
[tool.uv.extra-build-dependencies]
basicsr = ["setuptools<69"]
# PyTorch Intel-GPU (XPU) wheel index. ``explicit = true`` keeps it inert for
# the default CPU/CUDA install: uv consults it only when a torch install
# explicitly targets it (see the ``gpu`` extra comment), so it does not alter
+38
View File
@@ -147,6 +147,25 @@ _controlnet_scale_option = click.option(
)
def _restore_faces_options(f: Any) -> Any:
"""Attach the shared GFPGAN face-restoration flags to an invisible-pipeline command."""
restore_flag = click.option(
"--restore-faces/--no-restore-faces",
default=True,
help="Restore face identity with a GFPGAN post-pass when faces are present "
"(needs the 'restore' extra); on by default, auto-skips when no face is detected "
"or the extra is absent.",
)
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.",
)
return restore_flag(weight_flag(f))
def _watermark_region(det: DetectionResult, width: int, height: int) -> tuple[int, int, int, int]:
"""Pick a watermark bbox: detector's region if confident, else the default config slot."""
if det.confidence > 0.15:
@@ -472,6 +491,7 @@ def cmd_erase(
help="Cap long side (px) before diffusion; 0 = native (best quality, like raiw.cc). Raise only on GPU/MPS OOM.",
)
@_controlnet_scale_option
@_restore_faces_options
@click.pass_context
def cmd_invisible(
ctx: click.Context,
@@ -486,6 +506,8 @@ def cmd_invisible(
humanize: float,
max_resolution: int,
controlnet_scale: float,
restore_faces: bool,
restore_faces_weight: float,
) -> None:
"""Remove invisible AI watermarks (SynthID, StableSignature, TreeRing).
@@ -537,6 +559,8 @@ def cmd_invisible(
humanize=humanize,
max_resolution=max_resolution,
vendor=vendor,
restore_faces=restore_faces,
restore_faces_weight=restore_faces_weight,
)
elapsed = time.monotonic() - t0
@@ -712,6 +736,7 @@ def cmd_identify(ctx: click.Context, source: Path, no_visible: bool, as_json: bo
help="Cap long side (px) before diffusion; 0 = native (best quality, like raiw.cc). Raise only on GPU/MPS OOM.",
)
@_controlnet_scale_option
@_restore_faces_options
@click.pass_context
def cmd_all(
ctx: click.Context,
@@ -729,6 +754,8 @@ def cmd_all(
humanize: float,
max_resolution: int,
controlnet_scale: float,
restore_faces: bool,
restore_faces_weight: float,
) -> None:
"""Remove ALL watermarks: visible + invisible + metadata.
@@ -826,6 +853,8 @@ def cmd_all(
humanize=humanize,
max_resolution=max_resolution,
vendor=vendor,
restore_faces=restore_faces,
restore_faces_weight=restore_faces_weight,
)
console.print(" Invisible watermark removed")
@@ -878,6 +907,8 @@ def _process_batch_image(
hf_token: str | None,
humanize: float,
max_resolution: int = 0,
restore_faces: bool = True,
restore_faces_weight: float = 0.5,
) -> None:
"""Process a single image for batch mode.
@@ -938,6 +969,8 @@ def _process_batch_image(
seed=seed,
humanize=humanize,
max_resolution=max_resolution,
restore_faces=restore_faces,
restore_faces_weight=restore_faces_weight,
# 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),
@@ -995,6 +1028,7 @@ def _process_batch_image(
default=0,
help="Cap long side (px) before diffusion; 0 = native (best quality, like raiw.cc). Raise only on GPU/MPS OOM.",
)
@_restore_faces_options
@click.pass_context
def cmd_batch(
ctx: click.Context,
@@ -1010,6 +1044,8 @@ def cmd_batch(
inpaint: bool,
humanize: float,
max_resolution: int,
restore_faces: bool,
restore_faces_weight: float,
) -> None:
"""Process all images in a directory."""
_banner()
@@ -1060,6 +1096,8 @@ def cmd_batch(
hf_token=hf_token,
humanize=humanize,
max_resolution=max_resolution,
restore_faces=restore_faces,
restore_faces_weight=restore_faces_weight,
)
processed += 1
+191
View File
@@ -0,0 +1,191 @@
"""Optional GFPGAN face-restoration post-pass for the invisible removal pipeline.
The diffusion removal pass scrubs the watermark everywhere but lets faces drift in
likeness (canny holds face *structure*, not *identity*). This module restores each
face's identity by running GFPGAN on the ORIGINAL (watermarked) image and
feather-compositing the restored face REGIONS into the cleaned image.
GFPGAN RE-SYNTHESIZES each face from a StyleGAN2 prior -- the composited pixels are
GAN-generated, NOT copied from the original -- so the pixel watermark is scrubbed in
the face regions too, while identity is preserved (oracle-validated at weight 0.5).
Both GFPGAN (Apache-2.0) and its RetinaFace detector (MIT) are commercial-safe.
The GFPGANv1.4 weights and the RetinaFace detector download on first use and are
never bundled. Requires the optional ``restore`` extra (gfpgan/facexlib/basicsr).
"""
# cv2/torch/gfpgan boundary: gfpgan/basicsr/facexlib ship no usable type stubs and
# this module wraps cv2 (feather composite) and torch; relax the unknown-type rules
# for this file only.
# pyright: reportUnknownMemberType=false, reportUnknownArgumentType=false, reportUnknownVariableType=false, reportUnknownParameterType=false, reportMissingTypeArgument=false, reportMissingTypeStubs=false, reportMissingImports=false, reportArgumentType=false, reportAssignmentType=false, reportReturnType=false, reportCallIssue=false, reportIndexIssue=false, reportOperatorIssue=false, reportOptionalMemberAccess=false, reportOptionalCall=false, reportOptionalSubscript=false, reportOptionalOperand=false, reportAttributeAccessIssue=false, reportPrivateImportUsage=false, reportPrivateUsage=false, reportInvalidTypeForm=false, reportConstantRedefinition=false, reportUnnecessaryComparison=false
from __future__ import annotations
import logging
import sys
import threading
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from numpy.typing import NDArray
logger = logging.getLogger(__name__)
# GFPGANv1.4 weights (Apache-2.0). Downloaded on first use, never bundled.
_GFPGAN_MODEL_URL = "https://github.com/TencentARC/GFPGAN/releases/download/v1.3.0/GFPGANv1.4.pth"
_GFPGAN_ARCH = "clean"
_GFPGAN_CHANNEL_MULTIPLIER = 2
_restorer: Any | None = None
_restorer_lock = threading.Lock()
def is_available() -> bool:
"""True when the optional GFPGAN face-restoration deps are importable."""
import importlib.util
return importlib.util.find_spec("gfpgan") is not None and importlib.util.find_spec("facexlib") is not None
def _apply_basicsr_shim() -> None:
"""Install the ``torchvision.transforms.functional_tensor`` compatibility shim.
basicsr (a GFPGAN dependency) imports ``rgb_to_grayscale`` from the
``torchvision.transforms.functional_tensor`` module, which newer torchvision
removed. Recreate that module pointing at the public functional API. Idempotent:
only installed when the real module is missing.
"""
import importlib.util
if importlib.util.find_spec("torchvision.transforms.functional_tensor") is not None:
return
if "torchvision.transforms.functional_tensor" in sys.modules:
return
import types
import torchvision.transforms.functional as tv_functional
shim = types.ModuleType("torchvision.transforms.functional_tensor")
shim.rgb_to_grayscale = tv_functional.rgb_to_grayscale
sys.modules["torchvision.transforms.functional_tensor"] = shim
def _select_device() -> str:
"""Pick the GFPGAN device: CUDA when present, else CPU.
The pip GFPGANer has an MPS device-mismatch bug, and this is a cheap post-pass
on a few face crops, so MPS is deliberately avoided -- CPU is the safe default
on Apple silicon.
"""
try:
import torch
if torch.cuda.is_available():
return "cuda"
except Exception as e:
logger.debug("face_restore: CUDA probe failed (%s); using CPU", e)
return "cpu"
def _get_restorer() -> Any:
"""Return the lazily-built GFPGANer singleton (downloads weights on first use)."""
global _restorer
if _restorer is not None:
return _restorer
with _restorer_lock:
if _restorer is None:
_apply_basicsr_shim()
from gfpgan import GFPGANer
_restorer = GFPGANer(
model_path=_GFPGAN_MODEL_URL,
upscale=1,
arch=_GFPGAN_ARCH,
channel_multiplier=_GFPGAN_CHANNEL_MULTIPLIER,
device=_select_device(),
)
return _restorer
def _composite_faces(
base_bgr: NDArray[Any],
restored_bgr: NDArray[Any],
boxes: list[tuple[float, float, float, float]],
pad: int = 14,
feather_div: int = 6,
) -> NDArray[Any]:
"""Feather-composite restored face regions from ``restored_bgr`` into ``base_bgr``.
Pure cv2/numpy helper (no gfpgan), so it is unit-testable without the model.
For each ``(x1, y1, x2, y2)`` box: pad and clip to the image, build a Gaussian-
feathered rectangular alpha, and blend ``restored * a + base * (1 - a)``. Boxes
that fall fully outside the image (or an empty list) leave ``base_bgr`` unchanged.
"""
import cv2
import numpy as np
out = base_bgr.astype(np.float32)
h, w = base_bgr.shape[:2]
for box in boxes:
x1 = int(box[0]) - pad
y1 = int(box[1]) - pad
x2 = int(box[2]) + pad
y2 = int(box[3]) + pad
x1 = max(0, min(x1, w))
y1 = max(0, min(y1, h))
x2 = max(0, min(x2, w))
y2 = max(0, min(y2, h))
bw = x2 - x1
bh = y2 - y1
if bw <= 0 or bh <= 0:
continue
alpha = np.zeros((h, w), dtype=np.float32)
alpha[y1:y2, x1:x2] = 1.0
k = max(3, (min(bw, bh) // feather_div) | 1) # odd kernel >= 3
alpha = cv2.GaussianBlur(alpha, (k, k), 0)
alpha = alpha[:, :, None]
out = restored_bgr.astype(np.float32) * alpha + out * (1.0 - alpha)
return np.clip(out, 0, 255).astype(np.uint8)
def restore_faces(
original_bgr: NDArray[Any],
cleaned_bgr: NDArray[Any],
weight: float = 0.5,
pad: int = 14,
feather_div: int = 6,
) -> NDArray[Any]:
"""Restore face identity in ``cleaned_bgr`` using GFPGAN on ``original_bgr``.
Runs GFPGAN on the ORIGINAL (watermarked) image to recover the true-identity,
GAN-regenerated faces plus the RetinaFace boxes, then feather-composites those
face regions into the cleaned image. The composited pixels are GFPGAN-generated
(not original), so no watermark and no pixel-copy. Returns ``cleaned_bgr``
unchanged when no face is detected.
Args:
original_bgr: The original (watermarked) image as cv2 BGR.
cleaned_bgr: The diffusion-cleaned image as cv2 BGR (faces drifted).
weight: GFPGAN fidelity weight (0-1); lower = more GAN regeneration.
pad: Pixels to grow each face box before compositing.
feather_div: Larger = sharper composite edge (box-min // feather_div kernel).
"""
restorer = _get_restorer()
_, _, restored_img = restorer.enhance(
original_bgr,
has_aligned=False,
only_center_face=False,
paste_back=True,
weight=weight,
)
det_faces = getattr(restorer.face_helper, "det_faces", None) or []
boxes = [(float(b[0]), float(b[1]), float(b[2]), float(b[3])) for b in det_faces]
if not boxes:
logger.debug("face_restore: no faces detected; returning cleaned image unchanged")
return cleaned_bgr
return _composite_faces(cleaned_bgr, restored_img, boxes, pad=pad, feather_div=feather_div)
+65 -1
View File
@@ -17,7 +17,7 @@ import logging
import os
import warnings
from pathlib import Path
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from collections.abc import Callable
@@ -126,6 +126,8 @@ class InvisibleEngine:
humanize: float = 0.0,
max_resolution: int = 0,
vendor: str | None = None,
restore_faces: bool = True,
restore_faces_weight: float = 0.5,
) -> Path:
"""Remove invisible watermark from an image.
@@ -138,6 +140,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: Run the optional GFPGAN face-restoration post-pass when
faces are present (needs the ``restore`` extra). Auto-skips with a
debug log when the extra is absent or no face is detected.
restore_faces_weight: GFPGAN fidelity weight (0-1); lower = more GAN
regeneration (cleaner watermark scrub), higher = closer to input.
max_resolution: Cap the long side (px) before diffusion. 0 (default)
= native resolution, no pre-downscale -- matches the hosted
raiw.cc backend. Set a positive value only to bound GPU/MPS
@@ -234,12 +241,69 @@ class InvisibleEngine:
out_cv = cv2.resize(out_cv, orig_size, interpolation=cv2.INTER_LANCZOS4)
image_io.imwrite(out_path, out_cv)
# Optional GFPGAN face-restoration post-pass: restore face identity that
# the diffusion regeneration drifted, while still scrubbing the pixel
# watermark (GFPGAN re-synthesizes faces from a StyleGAN2 prior). Runs on
# the cleaned output at its final resolution; auto-skips when faces are
# absent or the optional extra is not installed.
if restore_faces:
self._restore_faces(out_path, image, restore_faces_weight)
return out_path
finally:
# _tmp_path is always set above (we persist the image unconditionally).
if _tmp_path.exists():
_tmp_path.unlink()
def _restore_faces(
self,
out_path: Path,
original_image: Any,
weight: float,
) -> None:
"""Run the GFPGAN face-restoration post-pass on the cleaned ``out_path``.
Composites GFPGAN-restored (identity-preserving, watermark-scrubbed) face
regions from the ORIGINAL image into the cleaned output. 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 default-on
flag must never error when the extra is absent or no face is present).
"""
from remove_ai_watermarks import face_restore
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: could not read cleaned output %s; skipping", out_path)
return
# Original (EXIF-transposed) as BGR, aligned to the cleaned image so the
# GFPGAN face boxes land in the cleaned image's coordinate space. The
# cleaned output is already restored to the original resolution above, so
# this resize is normally a no-op (it only fires if a max-resolution cap
# left the source PIL image smaller).
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 (GFPGAN post-pass)...")
restored = face_restore.restore_faces(original_bgr, cleaned_bgr, weight=weight)
image_io.imwrite(out_path, restored)
except Exception as e:
logger.warning("restore_faces post-pass failed (%s); keeping un-restored output", e)
def remove_watermark_batch(
self,
input_dir: Path,
+85
View File
@@ -0,0 +1,85 @@
"""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)
Generated
+1190 -1
View File
File diff suppressed because it is too large Load Diff