mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-08-08 23:16:02 +02:00
feat(face-restore): add InstantID as the default non-commercial restore path
Per the 2026-06-08 deep-research synthesis (docs/synthid-robust-identity- research-2026-06-08.md), the entire ArcFace-class identity-adapter ecosystem for SDXL is blocked from commercial use by InsightFace's non-commercial model packs (antelopev2 / buffalo_l). No commercial-safe ArcFace-grade identity stack exists today. The user explicitly opted into shipping a non-commercial restore path (research / personal use; raiw.cc must NOT install the extra). Architectural choice: InstantID over PhotoMaker-V2 as the default. - PhotoMaker-V2 (CLIP+ArcFace dual encoder, txt2img only): documented upstream identity drift on Asian male faces, visually confirmed in our cert sweep (tatsunari rendered as a generic woman; group photo collapsed into a patchwork). - InstantID (ArcFace cross-attention + landmark ControlNet): semantic identity branch + spatial weak landmark control, decoupled. Per InstantID paper (arXiv:2401.07519) and the research report, stronger identity fidelity on single portraits. Critically: NO original face pixels enter the diffusion (ArcFace embedding is semantic, landmark stick figure is pure geometry), so SynthID is not transported. Implementation: - New `src/remove_ai_watermarks/instantid_restore.py` mirrors the `photomaker_restore.py` shape (lazy singletons for pipeline + FaceAnalysis, per-face crop + _composite_faces from photomaker_restore). Loads the InstantID community pipeline via `DiffusionPipeline.from_pretrained( custom_pipeline="pipeline_stable_diffusion_xl_instantid")` -- no upstream Python package needed; diffusers fetches the file from its community examples. - New `instantid` extra in pyproject (insightface + onnxruntime + huggingface-hub). NON-COMMERCIAL block in the comment explains why. - CLI: `--restore-faces-method [instantid|photomaker]`, default `instantid`. Both methods explicitly labeled NON-COMMERCIAL in the help text. - Engine: dispatch on `restore_faces_method` to either `_restore_faces_instantid` or `_restore_faces_photomaker`. - 9 control-flow tests for InstantID without model download (mirror the photomaker_restore.py test pattern + draw_kps helper checks). 587/587 pass. Diffusers-0.38 compat verified by upstream code inspection: the InstantID pipeline inherits from `StableDiffusionXLControlNetPipeline`, uses only public diffusers APIs (`encode_prompt`, `prepare_image`, `prepare_latents`, `get_guidance_scale_embedding`), uses legacy attention processor API which diffusers preserves for backward compat. No PhotoMaker-V1-style internal text_encoder access. End-to-end execution will be validated by the Modal cert sweep in the next step. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
c486badaa8
commit
70e8b3a517
@@ -236,21 +236,34 @@ def _warn_if_esrgan_unavailable(upscaler: str) -> None:
|
||||
|
||||
|
||||
def _restore_faces_options(f: Any) -> Any:
|
||||
"""Attach the face-restoration flag to an invisible-pipeline command.
|
||||
"""Attach the face-restoration flags to an invisible-pipeline command.
|
||||
|
||||
The post-pass uses PhotoMaker-V2 to regenerate each face from a CLIP+ArcFace
|
||||
embedding. **NON-COMMERCIAL** -- PhotoMaker-V2 pulls InsightFace antelopev2/
|
||||
buffalo_l model packs at runtime, which are research-only. A paid service
|
||||
(raiw.cc, any monetized SaaS) MUST NOT use this flag.
|
||||
Two methods. ``instantid`` (default; the `instantid` extra) regenerates each
|
||||
face from an ArcFace embedding + landmark ControlNet -- semantic identity
|
||||
plus weak spatial control, no original pixels. ``photomaker`` (the
|
||||
`photomaker` extra) uses PhotoMaker-V2's CLIP+ArcFace dual encoder.
|
||||
**BOTH ARE NON-COMMERCIAL**: they pull InsightFace antelopev2 / buffalo_l
|
||||
model packs at runtime, which are research-only. A paid service (raiw.cc,
|
||||
any monetized SaaS) MUST NOT use this flag.
|
||||
"""
|
||||
method = click.option(
|
||||
"--restore-faces-method",
|
||||
type=click.Choice(["instantid", "photomaker"]),
|
||||
default="instantid",
|
||||
help="Face-restore mechanism. 'instantid' (default) uses InstantID's ArcFace + "
|
||||
"landmark ControlNet for stronger identity fidelity on single portraits. "
|
||||
"'photomaker' uses PhotoMaker-V2's CLIP+ArcFace dual encoder. **BOTH are "
|
||||
"NON-COMMERCIAL** (InsightFace antelopev2 / buffalo_l model packs are "
|
||||
"research-only). Pick whichever extra you've installed; for personal / research "
|
||||
"use only. Do NOT use in a paid service.",
|
||||
)(f)
|
||||
return click.option(
|
||||
"--restore-faces/--no-restore-faces",
|
||||
default=False,
|
||||
help="EXPERIMENTAL, opt-in, **NON-COMMERCIAL** -- needs the 'photomaker' extra "
|
||||
"which pulls non-commercial InsightFace model packs. Restores face identity via "
|
||||
"PhotoMaker-V2 (CLIP+ArcFace embedding -> fresh face); off by default, auto-skips "
|
||||
"when no face is detected or the extra is absent.",
|
||||
)(f)
|
||||
help="EXPERIMENTAL, opt-in, **NON-COMMERCIAL**. Restore face identity via the "
|
||||
"chosen --restore-faces-method (default: instantid); off by default, auto-skips "
|
||||
"when no face is detected or the chosen extra is absent.",
|
||||
)(method)
|
||||
|
||||
|
||||
def _watermark_region(det: DetectionResult, width: int, height: int) -> tuple[int, int, int, int]:
|
||||
@@ -601,6 +614,7 @@ def cmd_invisible(
|
||||
min_resolution: int,
|
||||
controlnet_scale: float,
|
||||
restore_faces: bool,
|
||||
restore_faces_method: str,
|
||||
upscaler: str,
|
||||
auto: bool,
|
||||
adaptive_polish: bool,
|
||||
@@ -663,6 +677,7 @@ def cmd_invisible(
|
||||
upscaler=upscaler,
|
||||
vendor=vendor,
|
||||
restore_faces=restore_faces,
|
||||
restore_faces_method=restore_faces_method,
|
||||
)
|
||||
elapsed = time.monotonic() - t0
|
||||
|
||||
@@ -864,6 +879,7 @@ def cmd_all(
|
||||
min_resolution: int,
|
||||
controlnet_scale: float,
|
||||
restore_faces: bool,
|
||||
restore_faces_method: str,
|
||||
upscaler: str,
|
||||
auto: bool,
|
||||
adaptive_polish: bool,
|
||||
@@ -972,6 +988,7 @@ def cmd_all(
|
||||
upscaler=upscaler,
|
||||
vendor=vendor,
|
||||
restore_faces=restore_faces,
|
||||
restore_faces_method=restore_faces_method,
|
||||
)
|
||||
console.print(" Invisible watermark removed")
|
||||
|
||||
@@ -1027,6 +1044,7 @@ def _process_batch_image(
|
||||
max_resolution: int = 0,
|
||||
min_resolution: int = 1024,
|
||||
restore_faces: bool = False,
|
||||
restore_faces_method: str = "instantid",
|
||||
controlnet_scale: float = 1.0,
|
||||
upscaler: str = "lanczos",
|
||||
auto: bool = False,
|
||||
@@ -1105,6 +1123,7 @@ def _process_batch_image(
|
||||
min_resolution=min_resolution,
|
||||
upscaler=upscaler,
|
||||
restore_faces=restore_faces,
|
||||
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),
|
||||
@@ -1187,6 +1206,7 @@ def cmd_batch(
|
||||
max_resolution: int,
|
||||
min_resolution: int,
|
||||
restore_faces: bool,
|
||||
restore_faces_method: str,
|
||||
controlnet_scale: float,
|
||||
upscaler: str,
|
||||
auto: bool,
|
||||
@@ -1246,6 +1266,7 @@ def cmd_batch(
|
||||
max_resolution=max_resolution,
|
||||
min_resolution=min_resolution,
|
||||
restore_faces=restore_faces,
|
||||
restore_faces_method=restore_faces_method,
|
||||
controlnet_scale=controlnet_scale,
|
||||
upscaler=upscaler,
|
||||
auto=auto,
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
"""SynthID-robust face identity restoration via InstantID.
|
||||
|
||||
**NON-COMMERCIAL.** InstantID's runtime depends on the InsightFace ``antelopev2``
|
||||
ArcFace model pack, which InsightFace releases under a research-only license:
|
||||
|
||||
"The training data containing the annotation (and the models trained with
|
||||
these data) are available for non-commercial research purposes only."
|
||||
-- insightface upstream README
|
||||
|
||||
The InstantX maintainers themselves acknowledged on HuggingFace
|
||||
(``InstantX/InstantID`` discussion #2) that "InstantID cannot be Apache 2.0 if it
|
||||
is using Insight Face" and stated intent to retrain on commercial face encoders.
|
||||
As of 2026-06-08 (deep-research synthesis in
|
||||
``docs/synthid-robust-identity-research-2026-06-08.md``) that retrain has not
|
||||
shipped. **A paid service (raiw.cc, any monetized SaaS) MUST NOT use this path.**
|
||||
|
||||
The default ``--restore-faces-method`` is ``instantid`` (this module). The
|
||||
alternative ``photomaker`` is also non-commercial. There is no commercial-safe
|
||||
ArcFace-grade identity-preservation stack for SDXL today.
|
||||
|
||||
Architecture (vs PhotoMaker-V2):
|
||||
- PhotoMaker-V2 conditions on a CLIP+ArcFace embedding and runs as txt2img with
|
||||
no spatial control. Identity drift on Asian male faces is documented upstream
|
||||
and was visually confirmed in our cert sweep.
|
||||
- InstantID conditions on the ArcFace embedding via cross-attention (IP-Adapter
|
||||
style) AND uses a separate landmark ControlNet (5 facial keypoints) for weak
|
||||
pose control. The semantic identity branch and spatial landmark branch are
|
||||
decoupled, which gives stronger identity fidelity per the InstantID paper
|
||||
(arXiv:2401.07519) and our research report. Critically, NO original face
|
||||
pixels enter the diffusion -- only the ArcFace embedding (semantic) and the
|
||||
rendered landmark stick figure (geometry, content-free) -- so SynthID is not
|
||||
transported.
|
||||
|
||||
Pipeline this module wires:
|
||||
1. Detect faces in the CLEANED image (YuNet via ``auto_config``).
|
||||
2. For each face: take the SAME box from the ORIGINAL image, extract its
|
||||
ArcFace embedding + 5 keypoints via InsightFace ``FaceAnalysis(antelopev2)``.
|
||||
3. Render the keypoints as a stick figure (``draw_kps`` from upstream).
|
||||
4. Call the InstantID community pipeline
|
||||
(``StableDiffusionXLInstantIDPipeline``) with the ArcFace embedding as
|
||||
``image_embeds=`` and the landmark image as ``image=`` (the ControlNet
|
||||
conditioning).
|
||||
5. Feather-composite the regenerated face into the cleaned image.
|
||||
|
||||
Requires the optional ``instantid`` extra: ``pip install
|
||||
'remove-ai-watermarks[instantid]'``. Weights download on first use; never
|
||||
bundled. The InstantID adapter weights (IdentityNet ControlNet +
|
||||
``ip-adapter.bin``) are Apache-2.0; the runtime InsightFace ``antelopev2`` model
|
||||
pack is non-commercial.
|
||||
|
||||
Multi-face: like PhotoMaker, this module loops over face boxes and composites
|
||||
back. InstantID's strength is single-portrait; for group photos identity
|
||||
fidelity per-face is preserved but the composite still uses the cleaned-image
|
||||
geometry as the canvas.
|
||||
"""
|
||||
|
||||
# cv2/torch/diffusers boundary: relax 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 importlib.util
|
||||
import logging
|
||||
import threading
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from remove_ai_watermarks.photomaker_restore import _composite_faces, _face_crop_square
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from numpy.typing import NDArray
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# InstantID checkpoint repo on HuggingFace. The IdentityNet ControlNet weights live
|
||||
# under ``ControlNetModel/`` and the IP-Adapter file is ``ip-adapter.bin`` at the
|
||||
# root. Both are Apache-2.0 (the InsightFace runtime dep is what makes the path
|
||||
# non-commercial). Downloaded on first use.
|
||||
_INSTANTID_REPO = "InstantX/InstantID"
|
||||
_INSTANTID_CONTROLNET_SUBFOLDER = "ControlNetModel"
|
||||
_INSTANTID_IP_ADAPTER = "ip-adapter.bin"
|
||||
|
||||
# SDXL base shared with the main pipeline (same checkpoint as `default`/`controlnet`).
|
||||
_SDXL_MODEL_ID = "stabilityai/stable-diffusion-xl-base-1.0"
|
||||
|
||||
# Prompt format. InstantID is less sensitive to prompt than PhotoMaker because the
|
||||
# ID branch is cross-attention; a neutral descriptive prompt is recommended by the
|
||||
# upstream gradio demo.
|
||||
_INSTANTID_PROMPT = "portrait photo of a person, natural skin, soft lighting, sharp focus, best quality"
|
||||
_INSTANTID_NEGATIVE = (
|
||||
"(asymmetry, worst quality, low quality, illustration, 3d, 2d, painting, "
|
||||
"cartoons, sketch), open mouth, blurry, watermark, deformed"
|
||||
)
|
||||
|
||||
# Square size used to feed InstantID. SDXL is happiest at 1024 (a smaller value sends
|
||||
# it into low-res mosaic mode -- caught visually on PhotoMaker, same root cause).
|
||||
_INSTANTID_FACE_SIZE = 1024
|
||||
|
||||
_pipeline: Any | None = None
|
||||
_pipeline_lock = threading.Lock()
|
||||
_face_analyser: Any | None = None
|
||||
_face_analyser_lock = threading.Lock()
|
||||
|
||||
|
||||
def is_available() -> bool:
|
||||
"""True when the optional InstantID extra deps are importable."""
|
||||
return (
|
||||
importlib.util.find_spec("insightface") is not None
|
||||
and importlib.util.find_spec("diffusers") is not None
|
||||
and importlib.util.find_spec("torch") is not None
|
||||
and importlib.util.find_spec("huggingface_hub") is not None
|
||||
)
|
||||
|
||||
|
||||
def _select_device() -> str:
|
||||
"""Pick the InstantID pipeline device: CUDA when present, MPS on Apple, else CPU."""
|
||||
try:
|
||||
import torch
|
||||
|
||||
if torch.cuda.is_available():
|
||||
return "cuda"
|
||||
if torch.backends.mps.is_available():
|
||||
return "mps"
|
||||
except Exception as e:
|
||||
logger.debug("instantid_restore: device probe failed (%s); using CPU", e)
|
||||
return "cpu"
|
||||
|
||||
|
||||
def _get_face_analyser() -> Any:
|
||||
"""Return the InsightFace FaceAnalysis singleton (antelopev2, non-commercial).
|
||||
|
||||
Triggers InsightFace's auto-download of the antelopev2 pack on first
|
||||
instantiation. See the NON-COMMERCIAL notice at the top of the module.
|
||||
"""
|
||||
global _face_analyser
|
||||
if _face_analyser is not None:
|
||||
return _face_analyser
|
||||
with _face_analyser_lock:
|
||||
if _face_analyser is None:
|
||||
import torch
|
||||
from insightface.app import FaceAnalysis
|
||||
|
||||
providers = ["CUDAExecutionProvider"] if torch.cuda.is_available() else ["CPUExecutionProvider"]
|
||||
# InstantID's upstream uses name='antelopev2' and root='./' (which puts
|
||||
# the auto-downloaded pack under ./models/antelopev2/). Use the same root
|
||||
# so the pack lands under the process cwd (Modal volume in prod).
|
||||
fa = FaceAnalysis(name="antelopev2", root="./", providers=providers)
|
||||
fa.prepare(ctx_id=0, det_size=(640, 640))
|
||||
_face_analyser = fa
|
||||
return _face_analyser
|
||||
|
||||
|
||||
def _get_pipeline() -> Any:
|
||||
"""Return the lazily-built InstantID pipeline singleton (downloads weights on first use).
|
||||
|
||||
Loads via diffusers' community-pipeline mechanism: the file
|
||||
``pipeline_stable_diffusion_xl_instantid.py`` lives in
|
||||
``diffusers/examples/community/`` and is selected by the slug
|
||||
``pipeline_stable_diffusion_xl_instantid``.
|
||||
"""
|
||||
global _pipeline
|
||||
if _pipeline is not None:
|
||||
return _pipeline
|
||||
with _pipeline_lock:
|
||||
if _pipeline is None:
|
||||
import torch
|
||||
from diffusers import ControlNetModel, DiffusionPipeline
|
||||
from huggingface_hub import hf_hub_download
|
||||
|
||||
device = _select_device()
|
||||
dtype = torch.float16 if device == "cuda" else torch.float32
|
||||
logger.info("instantid_restore: loading SDXL+InstantID on %s (%s)", device, dtype)
|
||||
|
||||
# IdentityNet ControlNet weights.
|
||||
controlnet = ControlNetModel.from_pretrained(
|
||||
_INSTANTID_REPO,
|
||||
subfolder=_INSTANTID_CONTROLNET_SUBFOLDER,
|
||||
torch_dtype=dtype,
|
||||
)
|
||||
# SDXL base + InstantID community pipeline (txt2img w/ IdentityNet ControlNet
|
||||
# + IP-Adapter cross-attention conditioned on the ArcFace embedding).
|
||||
pipe = DiffusionPipeline.from_pretrained(
|
||||
_SDXL_MODEL_ID,
|
||||
controlnet=controlnet,
|
||||
torch_dtype=dtype,
|
||||
custom_pipeline="pipeline_stable_diffusion_xl_instantid",
|
||||
)
|
||||
pipe.to(device)
|
||||
# IP-Adapter weights that wire the ArcFace embedding into cross-attention.
|
||||
ip_adapter_path = hf_hub_download(repo_id=_INSTANTID_REPO, filename=_INSTANTID_IP_ADAPTER)
|
||||
pipe.load_ip_adapter_instantid(ip_adapter_path)
|
||||
_pipeline = pipe
|
||||
return _pipeline
|
||||
|
||||
|
||||
def _draw_kps(image_size: tuple[int, int], kps: Any) -> Any:
|
||||
"""Render the 5 facial keypoints as a colored stick figure.
|
||||
|
||||
Mirrors upstream's ``draw_kps`` (in ``pipeline_stable_diffusion_xl_instantid.py``):
|
||||
the 5 keypoints (left eye, right eye, nose tip, left mouth corner, right mouth
|
||||
corner) get drawn as colored circles connected by colored lines, on a black
|
||||
background. The result is the ControlNet conditioning image -- pure landmark
|
||||
geometry, no pixels from the original face leak through this branch.
|
||||
|
||||
``image_size`` is ``(width, height)``; ``kps`` is a numpy array of shape (5, 2).
|
||||
"""
|
||||
import cv2
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
# Same color palette as upstream (blue/red/green/purple/yellow).
|
||||
stick_width = 4
|
||||
limb_seq = np.array([[0, 2], [1, 2], [3, 2], [4, 2]])
|
||||
color_list = [
|
||||
(255, 0, 0),
|
||||
(0, 255, 0),
|
||||
(0, 0, 255),
|
||||
(255, 255, 0),
|
||||
(255, 0, 255),
|
||||
]
|
||||
|
||||
w, h = image_size
|
||||
out_img = np.zeros((h, w, 3), dtype=np.uint8)
|
||||
|
||||
kps_arr = np.array(kps)
|
||||
for i in range(len(limb_seq)):
|
||||
index = limb_seq[i]
|
||||
color = color_list[index[0]]
|
||||
x = kps_arr[index][:, 0]
|
||||
y = kps_arr[index][:, 1]
|
||||
length = ((x[0] - x[1]) ** 2 + (y[0] - y[1]) ** 2) ** 0.5
|
||||
angle = np.degrees(np.arctan2(y[0] - y[1], x[0] - x[1]))
|
||||
polygon = cv2.ellipse2Poly(
|
||||
(int(np.mean(x)), int(np.mean(y))),
|
||||
(int(length / 2), stick_width),
|
||||
int(angle),
|
||||
0,
|
||||
360,
|
||||
1,
|
||||
)
|
||||
out_img = cv2.fillConvexPoly(out_img.copy(), polygon, color)
|
||||
out_img = (out_img * 0.6).astype(np.uint8)
|
||||
|
||||
for i, kp in enumerate(kps_arr):
|
||||
x, y = kp
|
||||
out_img = cv2.circle(out_img.copy(), (int(x), int(y)), 10, color_list[i], -1)
|
||||
|
||||
return Image.fromarray(out_img.astype(np.uint8))
|
||||
|
||||
|
||||
def restore_faces_instantid(
|
||||
original_bgr: NDArray[Any],
|
||||
cleaned_bgr: NDArray[Any],
|
||||
num_inference_steps: int = 30,
|
||||
guidance_scale: float = 5.0,
|
||||
ip_adapter_scale: float = 0.8,
|
||||
controlnet_conditioning_scale: float = 0.8,
|
||||
seed: int | None = None,
|
||||
detect_faces_fn: Any | None = None,
|
||||
) -> NDArray[Any]:
|
||||
"""SynthID-robust face identity restoration via InstantID.
|
||||
|
||||
Flow:
|
||||
1. Detect faces in ``cleaned_bgr`` (YuNet via ``auto_config`` by default;
|
||||
override via ``detect_faces_fn`` for tests).
|
||||
2. For each face: take the SAME box from ``original_bgr`` -> square crop ->
|
||||
InsightFace extracts ArcFace embedding + 5 keypoints -> ``_draw_kps``
|
||||
renders the landmark stick figure -> InstantID pipeline generates a
|
||||
fresh face conditioned on the embedding and the landmark control image.
|
||||
3. Feather-composite each regenerated face into ``cleaned_bgr``.
|
||||
|
||||
Faces are read from ``original_bgr`` for the ArcFace embedding + landmarks, but
|
||||
the OUTPUT pixels are diffusion-fresh (ArcFace embedding is semantic; landmark
|
||||
image is pure geometry), so SynthID is not transported.
|
||||
|
||||
``detect_faces_fn`` returns a list of ``(x, y, w, h)`` boxes given a BGR image.
|
||||
"""
|
||||
import cv2
|
||||
import numpy as np
|
||||
import torch
|
||||
|
||||
if detect_faces_fn is None:
|
||||
from remove_ai_watermarks.auto_config import _get_yunet
|
||||
|
||||
det = _get_yunet()
|
||||
|
||||
def _default_detect(bgr: NDArray[Any]) -> list[tuple[int, int, int, int]]:
|
||||
h_d, w_d = bgr.shape[:2]
|
||||
det.setInputSize((w_d, h_d))
|
||||
_, faces = det.detect(bgr)
|
||||
if faces is None:
|
||||
return []
|
||||
return [(int(f[0]), int(f[1]), int(f[2]), int(f[3])) for f in faces if int(f[2]) > 0 and int(f[3]) > 0]
|
||||
|
||||
detect_faces_fn = _default_detect
|
||||
|
||||
boxes = detect_faces_fn(cleaned_bgr)
|
||||
if not boxes:
|
||||
logger.debug("instantid_restore: no faces detected; returning cleaned image unchanged")
|
||||
return cleaned_bgr
|
||||
|
||||
pipeline = _get_pipeline()
|
||||
face_analyser = _get_face_analyser()
|
||||
|
||||
generator = None
|
||||
if seed is not None:
|
||||
generator = torch.Generator(device=pipeline.device).manual_seed(seed)
|
||||
|
||||
restored: list[tuple[NDArray[Any], tuple[int, int, int, int]]] = []
|
||||
for box in boxes:
|
||||
id_crop_bgr, square_box = _face_crop_square(original_bgr, box)
|
||||
if id_crop_bgr.size == 0:
|
||||
continue
|
||||
|
||||
# Resize the crop to the InstantID target so InsightFace + the pipeline both
|
||||
# work in the same coordinate space.
|
||||
crop_resized = cv2.resize(
|
||||
id_crop_bgr, (_INSTANTID_FACE_SIZE, _INSTANTID_FACE_SIZE), interpolation=cv2.INTER_LANCZOS4
|
||||
)
|
||||
|
||||
# InsightFace expects BGR. It returns embedding + 5 keypoints per detected face.
|
||||
# Pick the largest face in the crop (sorted by bbox area).
|
||||
face_infos = face_analyser.get(crop_resized)
|
||||
if not face_infos:
|
||||
logger.debug("instantid_restore: InsightFace did not find a face in the crop; skipping")
|
||||
continue
|
||||
face_info = sorted(
|
||||
face_infos,
|
||||
key=lambda x: (x["bbox"][2] - x["bbox"][0]) * (x["bbox"][3] - x["bbox"][1]),
|
||||
)[-1]
|
||||
face_emb = face_info["embedding"]
|
||||
face_kps = face_info["kps"]
|
||||
|
||||
# Render the landmark stick figure at the same size as the generation target.
|
||||
landmark_img = _draw_kps((_INSTANTID_FACE_SIZE, _INSTANTID_FACE_SIZE), face_kps)
|
||||
|
||||
out = pipeline(
|
||||
prompt=_INSTANTID_PROMPT,
|
||||
negative_prompt=_INSTANTID_NEGATIVE,
|
||||
image_embeds=face_emb,
|
||||
image=landmark_img,
|
||||
controlnet_conditioning_scale=controlnet_conditioning_scale,
|
||||
ip_adapter_scale=ip_adapter_scale,
|
||||
num_inference_steps=num_inference_steps,
|
||||
guidance_scale=guidance_scale,
|
||||
generator=generator,
|
||||
)
|
||||
gen_rgb = out.images[0]
|
||||
gen_bgr = cv2.cvtColor(np.array(gen_rgb), cv2.COLOR_RGB2BGR)
|
||||
restored.append((gen_bgr, square_box))
|
||||
|
||||
if not restored:
|
||||
return cleaned_bgr
|
||||
return _composite_faces(cleaned_bgr, restored)
|
||||
@@ -165,6 +165,7 @@ class InvisibleEngine:
|
||||
min_resolution: int = 1024,
|
||||
vendor: str | None = None,
|
||||
restore_faces: bool = False,
|
||||
restore_faces_method: str = "instantid",
|
||||
unsharp: float = 0.0,
|
||||
adaptive_polish: bool = False,
|
||||
upscaler: str = "lanczos",
|
||||
@@ -181,10 +182,15 @@ class InvisibleEngine:
|
||||
seed: Random seed for reproducibility.
|
||||
humanize: Intensity of Analog Humanizer film grain (0 = off).
|
||||
restore_faces: EXPERIMENTAL, opt-in (default False). **NON-COMMERCIAL.**
|
||||
Run the PhotoMaker-V2 face-identity post-pass when faces are present
|
||||
(needs the ``photomaker`` extra, which pulls non-commercial InsightFace
|
||||
model packs). Auto-skips with a debug log when the extra is absent or no
|
||||
face is detected. See ``photomaker_restore.py`` for the legal notice.
|
||||
Run the face-identity post-pass when faces are present. Method is
|
||||
chosen by ``restore_faces_method`` -- ``instantid`` (default,
|
||||
stronger identity, needs the ``instantid`` extra) or ``photomaker``
|
||||
(PhotoMaker-V2, needs the ``photomaker`` extra). Both extras pull
|
||||
non-commercial InsightFace model packs. Auto-skips with a debug log
|
||||
when the chosen extra is absent or no face is detected. See
|
||||
``instantid_restore.py`` / ``photomaker_restore.py``.
|
||||
restore_faces_method: ``instantid`` (default) or ``photomaker``. Both
|
||||
NON-COMMERCIAL; pick the one whose extra you've installed.
|
||||
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
|
||||
@@ -316,7 +322,10 @@ class InvisibleEngine:
|
||||
# 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)
|
||||
if restore_faces_method == "photomaker":
|
||||
self._restore_faces_photomaker(out_path, image, seed)
|
||||
else:
|
||||
self._restore_faces_instantid(out_path, image, seed)
|
||||
|
||||
# Final sharpening, LAST so it crisps the face-restored result too (a
|
||||
# pre-restore sharpen would be smoothed back over by the face pass).
|
||||
@@ -355,6 +364,50 @@ class InvisibleEngine:
|
||||
if _tmp_path.exists():
|
||||
_tmp_path.unlink()
|
||||
|
||||
def _restore_faces_instantid(
|
||||
self,
|
||||
out_path: Path,
|
||||
original_image: Any,
|
||||
seed: int | None,
|
||||
) -> None:
|
||||
"""Run the InstantID face-identity post-pass on the cleaned ``out_path``.
|
||||
|
||||
**NON-COMMERCIAL** (see ``instantid_restore.py``). InstantID conditions on
|
||||
an ArcFace embedding (semantic) plus a landmark ControlNet (geometry,
|
||||
content-free) -- no original face pixels enter the diffusion. Best-effort:
|
||||
any failure (missing extra, model load, runtime error) logs a warning and
|
||||
leaves the un-restored cleaned output in place.
|
||||
"""
|
||||
from remove_ai_watermarks import instantid_restore
|
||||
|
||||
if not instantid_restore.is_available():
|
||||
logger.debug("restore_faces requested but the 'instantid' 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_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 (InstantID post-pass)...")
|
||||
restored = instantid_restore.restore_faces_instantid(original_bgr, cleaned_bgr, seed=seed)
|
||||
image_io.imwrite(out_path, restored)
|
||||
except Exception as e:
|
||||
logger.warning("restore_faces post-pass failed (%s); keeping un-restored output", e)
|
||||
|
||||
def _restore_faces_photomaker(
|
||||
self,
|
||||
out_path: Path,
|
||||
|
||||
@@ -83,8 +83,7 @@ _SDXL_MODEL_ID = "stabilityai/stable-diffusion-xl-base-1.0"
|
||||
# 2026-06-04: at 512 V2 produced a collage of training-time faces; at 1024 with the
|
||||
# upstream-style descriptive prompt it produces a clean face.
|
||||
_PHOTOMAKER_PROMPT = (
|
||||
"instagram photo, portrait photo of a person img, natural skin, soft lighting, "
|
||||
"best quality, sharp focus"
|
||||
"instagram photo, portrait photo of a person img, natural skin, soft lighting, best quality, sharp focus"
|
||||
)
|
||||
_PHOTOMAKER_NEGATIVE = (
|
||||
"(asymmetry, worst quality, low quality, illustration, 3d, 2d, painting, "
|
||||
|
||||
Reference in New Issue
Block a user