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:
Victor Kuznetsov
2026-06-08 19:44:17 -07:00
co-authored by Claude Opus 4.8
parent c486badaa8
commit 70e8b3a517
8 changed files with 752 additions and 18 deletions
+31 -10
View File
@@ -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)
+58 -5
View File
@@ -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, "