mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-08-31 09:40:38 +02:00
Tiled diffusion was never provider-oracle calibrated with verified text restoration: the tiled VAE donor path ran anyway and produced results no oracle had certified. The combination is now rejected at both the pipeline and the engine seam (ValueError with the reason), and the CLI help no longer implies support. The invisible help is generalized and the metadata container list corrected (MKA/OGA/Opus/AAC). scripts/contentseal_transforms.py reproduces the deterministic crop, resize, and JPEG variants of the Content Seal corpus from manifest.csv, hash-verifying every output; its README gains scripts/README.md context and new data tests. The corpus README is honest about the one crop the daily oracle limit left unchecked, and the eval CSVs carry the updated verdicts. The byte-scan SynthID suppression hoists its soft-binding lookup so the guard is computed once. Staged on top of 0.33.1; no version bump in this commit.
1139 lines
45 KiB
Python
1139 lines
45 KiB
Python
"""Project-native Qwen regeneration with an optional masked face refinement pass.
|
|
|
|
The profile was inspired by public experiments that combine structure-guided global
|
|
regeneration with a second face-only pass. Its orchestration, sizing rules, adaptive
|
|
strength policy, detector, masks, prompts, and compositing are implemented here for
|
|
this library's Pillow and DiffSynth runtime.
|
|
"""
|
|
|
|
# DiffSynth, torch, transformers, and cv2 expose mostly untyped tensor/array APIs.
|
|
# Keep the relaxation local to this optional ML boundary.
|
|
# 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, reportUnnecessaryComparison=false
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import hashlib
|
|
import logging
|
|
import math
|
|
import os
|
|
import tempfile
|
|
import urllib.request
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
import numpy as np
|
|
from PIL import Image
|
|
|
|
from remove_ai_watermarks._internal.watermark_profiles import resolve_seed
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Callable
|
|
|
|
from remove_ai_watermarks._internal.text_restoration import VerifiedTextManifest
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
QWEN_IMAGE_2512_MODEL_ID = "Qwen/Qwen-Image-2512"
|
|
QWEN_CANNY_CONTROLNET_MODEL_ID = "DiffSynth-Studio/Qwen-Image-Blockwise-ControlNet-Canny"
|
|
QWEN_LIGHTNING_MODEL_ID = "lightx2v/Qwen-Image-2512-Lightning"
|
|
QWEN_LIGHTNING_PATTERN = "Qwen-Image-2512-Lightning-4steps-V1.0-bf16.safetensors"
|
|
ZIMAGE_TURBO_MODEL_ID = "Tongyi-MAI/Z-Image-Turbo"
|
|
SAM_MODEL_ID = "facebook/sam-vit-base"
|
|
|
|
YUNET_MODEL_URL = (
|
|
"https://media.githubusercontent.com/media/opencv/opencv_zoo/main/"
|
|
"models/face_detection_yunet/face_detection_yunet_2023mar.onnx"
|
|
)
|
|
YUNET_MODEL_NAME = "face_detection_yunet_2023mar.onnx"
|
|
YUNET_MODEL_SHA256 = "8f2383e4dd3cfbb4553ea8718107fc0423210dc964f9f4280604804ed2552fa4"
|
|
# This threshold retained the faces in the public validation set without admitting
|
|
# decorative background regions as faces.
|
|
YUNET_SCORE_THRESHOLD = 0.5
|
|
|
|
GLOBAL_STEPS = 4
|
|
FACE_STEPS = 8
|
|
GLOBAL_CFG = 1.0
|
|
FACE_CFG = 1.0
|
|
GLOBAL_CONTROLNET_SCALE = 1.0
|
|
RESIDENT_FACE_MODEL_MIN_VRAM_GIB = 64.0
|
|
# Below this floor the mandatory Qwen stack streams from disk, which is what makes a
|
|
# 20B model runnable on a consumer card at all; at or above it, streaming is pure
|
|
# waste. Set equal to the face floor rather than lower because that is the
|
|
# configuration actually measured with both stacks resident; a tighter gate is
|
|
# plausible but unvalidated. Benchmark in docs/module-internals.md, "CPU offload".
|
|
RESIDENT_GLOBAL_MODEL_MIN_VRAM_GIB = 64.0
|
|
FACE_DENOISE_SCALE = 0.5
|
|
|
|
# Both stages prompt with compile-time constants, and at CFG 1.0 DiffSynth reuses the
|
|
# positive embedding for the negative side instead of encoding it, so exactly one
|
|
# embedding per stage is ever needed. Persisting it lets the next container load the
|
|
# stack without its text encoder at all: 15.45 GiB for Qwen and 7.49 GiB for Z-Image
|
|
# of a measured 87.6 GiB read per request. The stored tensors are what the encoder
|
|
# itself produced, so the output stays byte-identical. Bump the version when the
|
|
# stored shape changes; the model id and prompt are already part of the key, so a
|
|
# model or prompt change invalidates itself.
|
|
_PROMPT_CACHE_VERSION = 1
|
|
_PROMPT_CACHE_DIRNAME = "prompt-embeddings"
|
|
# DiffSynth identifies a pipeline unit by what it produces, so these tuples are how
|
|
# the prompt stage is located inside each pipeline and how its cache file is keyed.
|
|
_QWEN_PROMPT_OUTPUTS = ("prompt_emb", "prompt_emb_mask")
|
|
_ZIMAGE_PROMPT_OUTPUTS = ("prompt_embeds",)
|
|
|
|
_CANNY_LOW = 13
|
|
_CANNY_HIGH = 64
|
|
|
|
# These short model inputs are retained as calibrated compatibility parameters.
|
|
# Changing them requires the same provider-oracle and identity evaluation as a model change.
|
|
_GLOBAL_PROMPT = "ultra clear and smoothe skin, spotless skin"
|
|
_GLOBAL_NEGATIVE = "moles, freckes, high detail skin"
|
|
_FACE_PROMPT = ""
|
|
_FACE_NEGATIVE = "blurry, ugly, bad quality,"
|
|
|
|
|
|
def resolve_face_model_residency(
|
|
requested: bool | None,
|
|
*,
|
|
total_memory_gib: float,
|
|
) -> bool:
|
|
"""Keep the Z-Image stack resident when explicitly requested or safely sized."""
|
|
if requested is not None:
|
|
return requested
|
|
return total_memory_gib >= RESIDENT_FACE_MODEL_MIN_VRAM_GIB
|
|
|
|
|
|
def resolve_global_model_residency(
|
|
requested: bool | None,
|
|
*,
|
|
total_memory_gib: float,
|
|
) -> bool:
|
|
"""Keep the mandatory Qwen stack resident when explicitly requested or safely sized."""
|
|
if requested is not None:
|
|
return requested
|
|
return total_memory_gib >= RESIDENT_GLOBAL_MODEL_MIN_VRAM_GIB
|
|
|
|
|
|
def _pin_vram_managed_models(pipe: Any) -> None:
|
|
"""Move the managed Z-Image stack to CUDA once and make offload a no-op."""
|
|
model_names = ["text_encoder", "dit", "vae_encoder", "vae_decoder"]
|
|
for name in model_names:
|
|
model = getattr(pipe, name, None)
|
|
if model is None:
|
|
continue
|
|
for module in model.modules():
|
|
if not all(
|
|
hasattr(module, attribute)
|
|
for attribute in (
|
|
"offload_dtype",
|
|
"offload_device",
|
|
"onload_dtype",
|
|
"onload_device",
|
|
"preparing_dtype",
|
|
"preparing_device",
|
|
"computation_dtype",
|
|
"computation_device",
|
|
)
|
|
):
|
|
continue
|
|
module.offload_dtype = module.computation_dtype
|
|
module.offload_device = module.computation_device
|
|
module.onload_dtype = module.computation_dtype
|
|
module.onload_device = module.computation_device
|
|
module.preparing_dtype = module.computation_dtype
|
|
module.preparing_device = module.computation_device
|
|
pipe.load_models_to_device(model_names)
|
|
|
|
|
|
def _cast_prompt_payload(payload: Any, device: Any, dtype: Any) -> Any:
|
|
"""Move a stored embedding payload onto the runtime device.
|
|
|
|
Only floating tensors take the pipeline dtype. The attention masks travel in the
|
|
same payload and are integer, so casting them would corrupt the prompt.
|
|
"""
|
|
import torch
|
|
|
|
if isinstance(payload, torch.Tensor):
|
|
if dtype is not None and payload.is_floating_point():
|
|
return payload.to(device=device, dtype=dtype)
|
|
return payload.to(device=device)
|
|
if isinstance(payload, dict):
|
|
return {key: _cast_prompt_payload(value, device, dtype) for key, value in payload.items()}
|
|
if isinstance(payload, (list, tuple)):
|
|
return type(payload)(_cast_prompt_payload(value, device, dtype) for value in payload)
|
|
return payload
|
|
|
|
|
|
def _prompt_cache_path(model_id: str, output_params: tuple[str, ...], prompt: str) -> Path:
|
|
"""Locate the stored embedding for one model and one exact prompt string."""
|
|
key = "\x1f".join((str(_PROMPT_CACHE_VERSION), model_id, *output_params, prompt))
|
|
digest = hashlib.sha256(key.encode("utf-8")).hexdigest()[:32]
|
|
return _model_cache_dir() / _PROMPT_CACHE_DIRNAME / f"{digest}.pt"
|
|
|
|
|
|
def _store_prompt_payload(path: Path, payload: Any) -> None:
|
|
"""Write the embedding atomically so a torn write can never read back as a hit."""
|
|
import torch
|
|
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
with tempfile.NamedTemporaryFile(dir=path.parent, suffix=".pt", delete=False) as handle:
|
|
torch.save(_cast_prompt_payload(payload, "cpu", None), handle)
|
|
temporary = Path(handle.name)
|
|
temporary.replace(path)
|
|
|
|
|
|
def _load_prompt_payload(path: Path, device: Any, dtype: Any) -> Any:
|
|
import torch
|
|
|
|
return _cast_prompt_payload(torch.load(path, map_location="cpu", weights_only=True), device, dtype)
|
|
|
|
|
|
def _cached_prompt_process(
|
|
original_process: Any,
|
|
*,
|
|
model_id: str | None,
|
|
output_params: tuple[str, ...],
|
|
require_cache: bool,
|
|
) -> Any:
|
|
cache: dict[str, dict[str, Any]] = {}
|
|
|
|
def cached_process(
|
|
runtime_pipe: Any,
|
|
prompt: str,
|
|
edit_image: Any = None,
|
|
) -> dict[str, Any]:
|
|
if edit_image is not None:
|
|
return original_process(runtime_pipe, prompt, edit_image=edit_image)
|
|
if prompt in cache:
|
|
return cache[prompt]
|
|
path = None if model_id is None else _prompt_cache_path(model_id, output_params, prompt)
|
|
if path is not None and path.exists():
|
|
try:
|
|
cache[prompt] = _load_prompt_payload(path, runtime_pipe.device, runtime_pipe.torch_dtype)
|
|
except Exception:
|
|
log.warning("Discarding unreadable prompt-embedding cache %s", path, exc_info=True)
|
|
else:
|
|
return cache[prompt]
|
|
if require_cache:
|
|
# The text encoder was left out of the model stack on the strength of
|
|
# this file, so there is nothing left to fall back to.
|
|
raise RuntimeError(
|
|
f"The cached prompt embedding {path} disappeared after the stack was loaded without its text encoder."
|
|
)
|
|
produced = original_process(runtime_pipe, prompt, edit_image=None)
|
|
if path is not None:
|
|
try:
|
|
_store_prompt_payload(path, produced)
|
|
except OSError:
|
|
log.warning("Could not persist the prompt embedding to %s", path, exc_info=True)
|
|
cache[prompt] = produced
|
|
return produced
|
|
|
|
return cached_process
|
|
|
|
|
|
def _cache_static_prompt_embeddings(
|
|
pipe: Any,
|
|
output_params: tuple[str, ...],
|
|
*,
|
|
model_id: str | None = None,
|
|
require_cache: bool = False,
|
|
) -> bool:
|
|
"""Memoize a prompt unit when its embedding depends only on static text.
|
|
|
|
With ``model_id`` the memo is also persisted, which is what lets the next
|
|
container skip the text encoder entirely; without it the memo lives only as long
|
|
as the pipeline. ``require_cache`` says the stack was already built without a
|
|
text encoder, so a miss must fail loudly rather than call a model that is absent.
|
|
"""
|
|
for unit in pipe.units:
|
|
if tuple(getattr(unit, "output_params", ())) == output_params:
|
|
unit.process = _cached_prompt_process(
|
|
unit.process,
|
|
model_id=model_id,
|
|
output_params=output_params,
|
|
require_cache=require_cache,
|
|
)
|
|
return True
|
|
return False
|
|
|
|
|
|
def _clamp(value: float, minimum: float, maximum: float) -> float:
|
|
if maximum < minimum:
|
|
minimum, maximum = maximum, minimum
|
|
return max(minimum, min(maximum, value))
|
|
|
|
|
|
def resolution_adaptive_denoise(
|
|
width: int,
|
|
height: int,
|
|
*,
|
|
adaptive_level: int = 6,
|
|
denoise_min: float = 0.08,
|
|
denoise_max: float = 0.15,
|
|
) -> float:
|
|
"""Choose the calibrated global strength from image area and operator level."""
|
|
low, high = sorted((float(denoise_min), float(denoise_max)))
|
|
megapixels = max(1.0, float(width) * float(height)) * 1e-6
|
|
area_fraction = _clamp((megapixels - 0.30) / 3.40, 0.0, 1.0)
|
|
strength_range = high - low
|
|
strength = low + strength_range * area_fraction
|
|
level_delta = float(int(adaptive_level)) - 5.0
|
|
if level_delta >= 0.0:
|
|
strength += level_delta * strength_range * (0.285714 / 5.0)
|
|
else:
|
|
strength += level_delta * strength_range * (0.257143 / 4.0)
|
|
return _clamp(strength, 0.0001, 1.0)
|
|
|
|
|
|
def largest_face_denoise(
|
|
boxes: list[tuple[int, int, int, int]],
|
|
image_size: tuple[int, int],
|
|
*,
|
|
base_denoise: float = 0.10,
|
|
adaptive_ratio: float = 0.03,
|
|
denoise_min: float = 0.05,
|
|
denoise_max: float = 0.28,
|
|
) -> float:
|
|
"""Choose the calibrated face strength from the largest detected face area."""
|
|
width, height = image_size
|
|
image_area = max(1.0, float(width) * float(height))
|
|
largest_ratio = 0.0
|
|
for x1, y1, x2, y2 in boxes:
|
|
box_area = max(0.0, float(x2 - x1)) * max(0.0, float(y2 - y1))
|
|
largest_ratio = max(largest_ratio, box_area / image_area)
|
|
if largest_ratio <= 0.0:
|
|
return _clamp(base_denoise, denoise_min, denoise_max)
|
|
scaled = float(base_denoise) * largest_ratio / max(1e-6, float(adaptive_ratio))
|
|
return _clamp(scaled, denoise_min, denoise_max)
|
|
|
|
|
|
def _target_size(width: int, height: int) -> tuple[int, int]:
|
|
"""Floor image dimensions to the /16 latent grid without changing aspect."""
|
|
return max(16, (width // 16) * 16), max(16, (height // 16) * 16)
|
|
|
|
|
|
def _resize_to_target(image: Image.Image) -> Image.Image:
|
|
"""Resize pixels to the exact latent-grid dimensions passed to DiffSynth."""
|
|
target = _target_size(image.width, image.height)
|
|
if image.size == target:
|
|
return image
|
|
return image.resize(target, Image.Resampling.LANCZOS)
|
|
|
|
|
|
def build_canny_control_image(image: Image.Image) -> Image.Image:
|
|
"""Build the calibrated three-channel Canny conditioning map."""
|
|
import cv2
|
|
|
|
rgb = np.asarray(image.convert("RGB"))
|
|
gray = cv2.cvtColor(rgb, cv2.COLOR_RGB2GRAY)
|
|
edges = cv2.Canny(gray, _CANNY_LOW, _CANNY_HIGH)
|
|
return Image.fromarray(np.repeat(edges[:, :, None], 3, axis=2))
|
|
|
|
|
|
def build_global_kwargs(
|
|
image: Image.Image,
|
|
*,
|
|
strength: float,
|
|
seed: int | None,
|
|
controlnet_input: Any,
|
|
) -> dict[str, Any]:
|
|
"""Build the DiffSynth Qwen call shape without importing the ML runtime."""
|
|
input_image = _resize_to_target(image)
|
|
width, height = input_image.size
|
|
return {
|
|
"prompt": _GLOBAL_PROMPT,
|
|
"negative_prompt": _GLOBAL_NEGATIVE,
|
|
"cfg_scale": GLOBAL_CFG,
|
|
"input_image": input_image,
|
|
"denoising_strength": float(strength),
|
|
"height": height,
|
|
"width": width,
|
|
"seed": seed,
|
|
"rand_device": "cpu",
|
|
"num_inference_steps": GLOBAL_STEPS,
|
|
"exponential_shift_mu": math.log(3.0),
|
|
"blockwise_controlnet_inputs": [controlnet_input],
|
|
}
|
|
|
|
|
|
def build_face_kwargs(crop: Image.Image, *, strength: float, seed: int | None) -> dict[str, Any]:
|
|
"""Build the DiffSynth Z-Image face-detail call shape."""
|
|
input_image = _resize_to_target(crop)
|
|
width, height = input_image.size
|
|
return {
|
|
"prompt": _FACE_PROMPT,
|
|
"negative_prompt": _FACE_NEGATIVE,
|
|
"cfg_scale": FACE_CFG,
|
|
"input_image": input_image,
|
|
"denoising_strength": float(strength),
|
|
"height": height,
|
|
"width": width,
|
|
"seed": seed,
|
|
"rand_device": "cpu",
|
|
"num_inference_steps": FACE_STEPS,
|
|
}
|
|
|
|
|
|
def composite_face(base: np.ndarray, detail: np.ndarray, mask: np.ndarray, *, feather: int = 10) -> np.ndarray:
|
|
"""Feather ``detail`` into ``base`` while preserving every zero-mask pixel."""
|
|
import cv2
|
|
|
|
if base.shape != detail.shape:
|
|
raise ValueError("base and detail must have identical shapes")
|
|
if mask.shape != base.shape[:2]:
|
|
raise ValueError("mask must match the image height and width")
|
|
|
|
alpha = mask.astype(np.float32) / 255.0
|
|
if feather > 0:
|
|
sigma = max(0.1, float(feather) / 3.0)
|
|
alpha = cv2.GaussianBlur(alpha, (0, 0), sigmaX=sigma, sigmaY=sigma)
|
|
# Blurring may introduce tiny values far outside the intended mask. Keep an
|
|
# explicit support dilation so pixels beyond the feather radius stay exact.
|
|
support = cv2.dilate((mask > 0).astype(np.uint8), np.ones((2 * feather + 1, 2 * feather + 1), np.uint8))
|
|
alpha *= support
|
|
alpha = np.clip(alpha, 0.0, 1.0)[:, :, None]
|
|
merged = base.astype(np.float32) * (1.0 - alpha) + detail.astype(np.float32) * alpha
|
|
return np.clip(np.rint(merged), 0, 255).astype(np.uint8)
|
|
|
|
|
|
def _expanded_box(
|
|
box: tuple[int, int, int, int],
|
|
image_size: tuple[int, int],
|
|
*,
|
|
factor: float = 2.5,
|
|
) -> tuple[int, int, int, int]:
|
|
"""Expand a face box around its center to include local lighting context."""
|
|
x1, y1, x2, y2 = box
|
|
image_width, image_height = image_size
|
|
center_x = (x1 + x2) / 2.0
|
|
center_y = (y1 + y2) / 2.0
|
|
width = max(1.0, (x2 - x1) * factor)
|
|
height = max(1.0, (y2 - y1) * factor)
|
|
return (
|
|
max(0, round(center_x - width / 2.0)),
|
|
max(0, round(center_y - height / 2.0)),
|
|
min(image_width, round(center_x + width / 2.0)),
|
|
min(image_height, round(center_y + height / 2.0)),
|
|
)
|
|
|
|
|
|
def _model_cache_dir() -> Path:
|
|
"""Where this library persists downloaded and derived model assets.
|
|
|
|
``HF_HOME`` comes first because on a scale-to-zero runner it is the one path
|
|
mounted persistently; anything under a container-local cache is re-derived on
|
|
every request, which defeats both the YuNet download and the prompt cache.
|
|
"""
|
|
root = os.environ.get("HF_HOME") or os.environ.get("XDG_CACHE_HOME")
|
|
base = Path(root) if root else Path.home() / ".cache"
|
|
return base / "remove-ai-watermarks"
|
|
|
|
|
|
def _yunet_model_path() -> Path:
|
|
"""Download the small MIT-licensed YuNet ONNX model on first use."""
|
|
model_path = _model_cache_dir() / YUNET_MODEL_NAME
|
|
if model_path.exists() and hashlib.sha256(model_path.read_bytes()).hexdigest() == YUNET_MODEL_SHA256:
|
|
return model_path
|
|
|
|
model_path.parent.mkdir(parents=True, exist_ok=True)
|
|
log.info("Downloading YuNet face detector: %s", YUNET_MODEL_URL)
|
|
request = urllib.request.Request(
|
|
YUNET_MODEL_URL,
|
|
headers={"User-Agent": "remove-ai-watermarks"},
|
|
)
|
|
with urllib.request.urlopen(request, timeout=60) as response: # noqa: S310 - fixed HTTPS source
|
|
payload = response.read()
|
|
log.info(
|
|
"YuNet download response: status=%s content_length=%s",
|
|
getattr(response, "status", None),
|
|
len(payload),
|
|
)
|
|
digest = hashlib.sha256(payload).hexdigest()
|
|
if digest != YUNET_MODEL_SHA256:
|
|
raise OSError(
|
|
"YuNet download failed integrity verification: "
|
|
f"expected {YUNET_MODEL_SHA256}, got {digest} ({len(payload)} bytes)"
|
|
)
|
|
with tempfile.NamedTemporaryFile(dir=model_path.parent, suffix=".onnx", delete=False) as handle:
|
|
handle.write(payload)
|
|
temporary = Path(handle.name)
|
|
temporary.replace(model_path)
|
|
return model_path
|
|
|
|
|
|
def _nms_boxes(
|
|
boxes: list[tuple[int, int, int, int]],
|
|
scores: list[float],
|
|
*,
|
|
threshold: float = 0.3,
|
|
) -> list[tuple[int, int, int, int]]:
|
|
"""Apply OpenCV NMS to detections collected at multiple image scales."""
|
|
if not boxes:
|
|
return []
|
|
import cv2
|
|
|
|
xywh = [[x1, y1, x2 - x1, y2 - y1] for x1, y1, x2, y2 in boxes]
|
|
indices = cv2.dnn.NMSBoxes(xywh, scores, score_threshold=0.2, nms_threshold=threshold)
|
|
if len(indices) == 0:
|
|
return []
|
|
return [boxes[int(index)] for index in np.asarray(indices).reshape(-1)]
|
|
|
|
|
|
def detect_faces(image: Image.Image) -> list[tuple[int, int, int, int]]:
|
|
"""Detect face boxes with YuNet at two scales for large and small faces."""
|
|
import cv2
|
|
|
|
rgb = np.asarray(image.convert("RGB"))
|
|
original_height, original_width = rgb.shape[:2]
|
|
detections: list[tuple[int, int, int, int]] = []
|
|
scores: list[float] = []
|
|
model_path = _yunet_model_path()
|
|
|
|
for long_side in (640, 1280):
|
|
scale = min(1.0, long_side / max(original_width, original_height))
|
|
width = max(1, round(original_width * scale))
|
|
height = max(1, round(original_height * scale))
|
|
resized = cv2.resize(rgb, (width, height), interpolation=cv2.INTER_AREA) if scale < 1.0 else rgb
|
|
bgr = cv2.cvtColor(resized, cv2.COLOR_RGB2BGR)
|
|
detector = cv2.FaceDetectorYN.create(
|
|
str(model_path),
|
|
"",
|
|
(width, height),
|
|
YUNET_SCORE_THRESHOLD,
|
|
0.3,
|
|
5000,
|
|
)
|
|
_, rows = detector.detect(bgr)
|
|
if rows is None:
|
|
continue
|
|
inverse = 1.0 / scale
|
|
for row in rows:
|
|
x, y, box_width, box_height = (float(value) for value in row[:4])
|
|
x1 = max(0, round(x * inverse))
|
|
y1 = max(0, round(y * inverse))
|
|
x2 = min(original_width, round((x + box_width) * inverse))
|
|
y2 = min(original_height, round((y + box_height) * inverse))
|
|
if x2 <= x1 or y2 <= y1:
|
|
continue
|
|
detections.append((x1, y1, x2, y2))
|
|
scores.append(float(row[-1]))
|
|
if scale == 1.0:
|
|
break
|
|
return _nms_boxes(detections, scores)
|
|
|
|
|
|
def _ellipse_masks(
|
|
boxes: list[tuple[int, int, int, int]],
|
|
image_size: tuple[int, int],
|
|
) -> list[np.ndarray]:
|
|
"""Safe fallback masks when SAM is unavailable."""
|
|
import cv2
|
|
|
|
width, height = image_size
|
|
masks: list[np.ndarray] = []
|
|
for x1, y1, x2, y2 in boxes:
|
|
mask = np.zeros((height, width), dtype=np.uint8)
|
|
center = ((x1 + x2) // 2, (y1 + y2) // 2)
|
|
axes = (max(1, int((x2 - x1) * 0.55)), max(1, int((y2 - y1) * 0.62)))
|
|
cv2.ellipse(mask, center, axes, 0, 0, 360, 255, -1)
|
|
masks.append(mask)
|
|
return masks
|
|
|
|
|
|
def _prepare_sam_inputs(inputs: Any, device: str, dtype: Any) -> Any:
|
|
"""Move SAM inputs to the target device without casting geometric prompts."""
|
|
prepared = inputs.to(device)
|
|
if "pixel_values" in prepared:
|
|
prepared["pixel_values"] = prepared["pixel_values"].to(dtype=dtype)
|
|
return prepared
|
|
|
|
|
|
def _sam_point_prompts(
|
|
boxes: list[tuple[int, int, int, int]],
|
|
) -> tuple[list[list[list[list[float]]]], list[list[list[int]]]]:
|
|
"""Build Impact Pack's center-1 positive prompt for every face box."""
|
|
points = [[[[(x1 + x2) / 2.0, (y1 + y2) / 2.0]] for x1, y1, x2, y2 in boxes]]
|
|
labels = [[[1] for _box in boxes]]
|
|
return points, labels
|
|
|
|
|
|
def _clip_sam_masks_to_boxes(
|
|
masks: list[np.ndarray],
|
|
boxes: list[tuple[int, int, int, int]],
|
|
image_size: tuple[int, int],
|
|
) -> list[np.ndarray]:
|
|
"""Match Impact Pack by intersecting each SAM mask with its detector box."""
|
|
# Same rectangle primitive the shared fill uses, rather than a private zeros/fill
|
|
# copy. `dilate=0` because this box CLIPS a SAM mask -- growing it would admit the
|
|
# pixels the clip exists to exclude. The function-local import keeps region_eraser's
|
|
# module-scope cv2 off this module's import path.
|
|
from remove_ai_watermarks.region_eraser import boxes_to_mask
|
|
|
|
width, height = image_size
|
|
clipped: list[np.ndarray] = []
|
|
for mask, (x1, y1, x2, y2) in zip(masks, boxes, strict=True):
|
|
# (x, y, w, h) with w = x2 - x1 keeps x + w == x2, so a negative origin clamps
|
|
# to the same span the explicit max/min pair produced.
|
|
box_mask = boxes_to_mask((height, width), [(x1, y1, x2 - x1, y2 - y1)], dilate=0)
|
|
clipped.append(np.bitwise_and(mask.astype(np.uint8), box_mask))
|
|
return clipped
|
|
|
|
|
|
def _select_sam_masks(
|
|
masks: np.ndarray,
|
|
scores: np.ndarray,
|
|
*,
|
|
threshold: float = 0.93,
|
|
) -> list[np.ndarray]:
|
|
"""Select and combine SAM proposals like Impact Pack's ``sub_threshold``."""
|
|
mask_array = np.asarray(masks)
|
|
score_array = np.asarray(scores)
|
|
if mask_array.ndim == 5 and mask_array.shape[0] == 1:
|
|
mask_array = mask_array[0]
|
|
if score_array.ndim == 3 and score_array.shape[0] == 1:
|
|
score_array = score_array[0]
|
|
if mask_array.ndim == 3:
|
|
mask_array = mask_array[:, None, :, :]
|
|
if score_array.ndim == 1:
|
|
score_array = score_array[:, None]
|
|
if mask_array.ndim != 4 or score_array.ndim != 2:
|
|
raise ValueError("SAM masks and scores have unexpected dimensions")
|
|
if mask_array.shape[:2] != score_array.shape:
|
|
raise ValueError("SAM mask proposals and IoU scores do not align")
|
|
|
|
selected_masks: list[np.ndarray] = []
|
|
for candidates, candidate_scores in zip(mask_array, score_array, strict=True):
|
|
selected = np.flatnonzero(candidate_scores >= threshold)
|
|
if selected.size == 0:
|
|
selected = np.asarray([int(np.argmax(candidate_scores))])
|
|
combined = np.any(candidates[selected] > 0, axis=0)
|
|
selected_masks.append(combined.astype(np.uint8) * 255)
|
|
return selected_masks
|
|
|
|
|
|
def _sam_outputs_to_numpy(masks: Any, scores: Any) -> tuple[np.ndarray, np.ndarray]:
|
|
"""Convert SAM tensors through float32 because NumPy rejects bfloat16."""
|
|
mask_array = masks.detach().float().cpu().numpy()
|
|
score_array = scores.detach().float().cpu().numpy()
|
|
return mask_array, score_array
|
|
|
|
|
|
@dataclass
|
|
class QwenZImagePipeline:
|
|
"""Lazy runtime for the two-stage Qwen/Z-Image profile."""
|
|
|
|
device: str
|
|
torch_dtype: Any
|
|
hf_token: str | None = None
|
|
progress_callback: Callable[[str], None] | None = None
|
|
controlnet_conditioning_scale: float = GLOBAL_CONTROLNET_SCALE
|
|
keep_face_models_on_device: bool | None = None
|
|
keep_global_models_on_device: bool | None = None
|
|
cache_prompt_embeddings: bool = True
|
|
|
|
def __post_init__(self) -> None:
|
|
self._qwen_pipe: Any = None
|
|
self._zimage_pipe: Any = None
|
|
self._sam_model: Any = None
|
|
self._sam_processor: Any = None
|
|
|
|
def _progress(self, message: str) -> None:
|
|
if self.progress_callback is not None:
|
|
with contextlib.suppress(Exception):
|
|
self.progress_callback(message)
|
|
|
|
def _require_cuda(self) -> None:
|
|
if self.device != "cuda":
|
|
raise RuntimeError(
|
|
"The qwen-zimage pipeline is CUDA-only; there is no CPU or MPS path "
|
|
"for it. WatermarkRemover already refuses a non-CUDA device, so "
|
|
"reaching this guard means the pipeline was constructed directly."
|
|
)
|
|
|
|
def _vram_limit(self) -> float | None:
|
|
import torch
|
|
|
|
with contextlib.suppress(Exception):
|
|
return max(1.0, torch.cuda.mem_get_info("cuda")[1] / (1024**3) - 0.5)
|
|
return None
|
|
|
|
def _total_vram_gib(self) -> float:
|
|
"""Card capacity for the residency gates, 0.0 when it cannot be read.
|
|
|
|
Separate from ``_vram_limit``, which answers a different question (the budget
|
|
handed to DiffSynth) and must stay ``None`` rather than 0.0 when unknown, so
|
|
an unreadable device means "no limit" there and "assume small" here.
|
|
"""
|
|
import torch
|
|
|
|
with contextlib.suppress(Exception):
|
|
return torch.cuda.get_device_properties("cuda").total_memory / (1024**3)
|
|
return 0.0
|
|
|
|
def _keep_face_models_resident(self) -> bool:
|
|
return resolve_face_model_residency(
|
|
self.keep_face_models_on_device,
|
|
total_memory_gib=self._total_vram_gib(),
|
|
)
|
|
|
|
def _keep_global_models_resident(self) -> bool:
|
|
return resolve_global_model_residency(
|
|
self.keep_global_models_on_device,
|
|
total_memory_gib=self._total_vram_gib(),
|
|
)
|
|
|
|
def _prompt_is_cached(self, model_id: str, output_params: tuple[str, ...], prompt: str) -> bool:
|
|
"""Whether this stack can be built without its text encoder at all."""
|
|
return self.cache_prompt_embeddings and _prompt_cache_path(model_id, output_params, prompt).exists()
|
|
|
|
def _qwen_vram_config(self) -> dict[str, Any]:
|
|
import torch
|
|
|
|
if self._keep_global_models_resident():
|
|
# Same fp8 storage and bf16 computation as the streaming config, but the
|
|
# weights never leave the GPU. Passing no "disk" anywhere is what makes
|
|
# this work: DiffSynth decides `disk_offload` once from `offload_dtype`,
|
|
# so a card large enough to hold the stack skips both the `to("meta")`
|
|
# drop and the DiskMap re-read entirely.
|
|
return {
|
|
"offload_dtype": torch.float8_e4m3fn,
|
|
"offload_device": "cuda",
|
|
"onload_dtype": torch.float8_e4m3fn,
|
|
"onload_device": "cuda",
|
|
"preparing_dtype": torch.float8_e4m3fn,
|
|
"preparing_device": "cuda",
|
|
"computation_dtype": torch.bfloat16,
|
|
"computation_device": "cuda",
|
|
}
|
|
return {
|
|
"offload_dtype": "disk",
|
|
"offload_device": "disk",
|
|
"onload_dtype": torch.float8_e4m3fn,
|
|
"onload_device": "cpu",
|
|
"preparing_dtype": torch.float8_e4m3fn,
|
|
"preparing_device": "cuda",
|
|
"computation_dtype": torch.bfloat16,
|
|
"computation_device": "cuda",
|
|
}
|
|
|
|
@classmethod
|
|
def _face_stage_dtype(cls) -> Any:
|
|
"""The dtype every face-stage model loads and computes in.
|
|
|
|
Deliberately independent of ``self.torch_dtype``, which belongs to the global
|
|
stage. ``sdxl-zimage`` runs its global model in fp16, and inheriting that here
|
|
built the Z-Image modules bf16 (per the VRAM config below) while handing them
|
|
fp16 latents -- a Half/BFloat16 conv mismatch that crashed every face image
|
|
while zero-face inputs passed, so no unit test could see it.
|
|
|
|
Read by Z-Image and by SAM alike, so the whole stage moves together. SAM never
|
|
crashed, because it casts its own inputs and leaves through ``.float()``, but it
|
|
was reading the same wrong field and would re-land the bug for the next profile
|
|
that changes its global dtype.
|
|
"""
|
|
return cls._zimage_vram_config()["computation_dtype"]
|
|
|
|
@staticmethod
|
|
def _zimage_vram_config() -> dict[str, Any]:
|
|
import torch
|
|
|
|
return {
|
|
"offload_dtype": torch.bfloat16,
|
|
"offload_device": "cpu",
|
|
"onload_dtype": torch.bfloat16,
|
|
"onload_device": "cpu",
|
|
"preparing_dtype": torch.bfloat16,
|
|
"preparing_device": "cuda",
|
|
"computation_dtype": torch.bfloat16,
|
|
"computation_device": "cuda",
|
|
}
|
|
|
|
def _load_qwen(self) -> Any:
|
|
if self._qwen_pipe is not None:
|
|
return self._qwen_pipe
|
|
self._require_cuda()
|
|
os.environ.setdefault("DIFFSYNTH_DOWNLOAD_SOURCE", "huggingface")
|
|
if self.hf_token:
|
|
os.environ.setdefault("HF_TOKEN", self.hf_token)
|
|
try:
|
|
from diffsynth.pipelines.qwen_image import ControlNetInput, ModelConfig, QwenImagePipeline
|
|
except ImportError as exc:
|
|
raise ImportError(
|
|
"The qwen-zimage pipeline needs the optional dependency group. "
|
|
"Install: pip install 'remove-ai-watermarks[qwen-zimage]'"
|
|
) from exc
|
|
|
|
self._progress("Loading Qwen-Image-2512, Lightning LoRA, and Canny ControlNet...")
|
|
config = self._qwen_vram_config()
|
|
text_encoder_config = ModelConfig(
|
|
model_id=QWEN_IMAGE_2512_MODEL_ID,
|
|
origin_file_pattern="text_encoder/model*.safetensors",
|
|
**config,
|
|
)
|
|
model_configs = [
|
|
ModelConfig(
|
|
model_id=QWEN_IMAGE_2512_MODEL_ID,
|
|
origin_file_pattern="transformer/diffusion_pytorch_model*.safetensors",
|
|
**config,
|
|
),
|
|
text_encoder_config,
|
|
ModelConfig(
|
|
model_id=QWEN_IMAGE_2512_MODEL_ID,
|
|
origin_file_pattern="vae/diffusion_pytorch_model.safetensors",
|
|
**config,
|
|
),
|
|
ModelConfig(
|
|
model_id=QWEN_CANNY_CONTROLNET_MODEL_ID,
|
|
origin_file_pattern="model.safetensors",
|
|
**config,
|
|
),
|
|
]
|
|
prompt_cached = self._prompt_is_cached(QWEN_IMAGE_2512_MODEL_ID, _QWEN_PROMPT_OUTPUTS, _GLOBAL_PROMPT)
|
|
if prompt_cached:
|
|
model_configs.remove(text_encoder_config)
|
|
log.info("Qwen prompt embedding is cached; loading the stack without its text encoder")
|
|
pipe = QwenImagePipeline.from_pretrained(
|
|
torch_dtype=self.torch_dtype,
|
|
device=self.device,
|
|
model_configs=model_configs,
|
|
tokenizer_config=ModelConfig(
|
|
model_id=QWEN_IMAGE_2512_MODEL_ID,
|
|
origin_file_pattern="tokenizer/",
|
|
),
|
|
vram_limit=self._vram_limit(),
|
|
)
|
|
from diffsynth.diffusion import FlowMatchScheduler
|
|
|
|
# Avoid the base Qwen scheduler's terminal rescale for the distilled LoRA.
|
|
# With exponential_shift_mu=log(3), this is the closest DiffSynth equivalent
|
|
# of the source graph's four-step sgm_uniform + AuraFlow shift 3.
|
|
pipe.scheduler = FlowMatchScheduler("Qwen-Image-Lightning")
|
|
lightning = ModelConfig(
|
|
model_id=QWEN_LIGHTNING_MODEL_ID,
|
|
origin_file_pattern=QWEN_LIGHTNING_PATTERN,
|
|
)
|
|
pipe.load_lora(pipe.dit, lightning, alpha=0.8)
|
|
if self.cache_prompt_embeddings:
|
|
_cache_static_prompt_embeddings(
|
|
pipe,
|
|
_QWEN_PROMPT_OUTPUTS,
|
|
model_id=QWEN_IMAGE_2512_MODEL_ID,
|
|
require_cache=prompt_cached,
|
|
)
|
|
self._qwen_pipe = (pipe, ControlNetInput)
|
|
return self._qwen_pipe
|
|
|
|
def _load_zimage(self) -> Any:
|
|
if self._zimage_pipe is not None:
|
|
return self._zimage_pipe
|
|
self._require_cuda()
|
|
os.environ.setdefault("DIFFSYNTH_DOWNLOAD_SOURCE", "huggingface")
|
|
if self.hf_token:
|
|
os.environ.setdefault("HF_TOKEN", self.hf_token)
|
|
try:
|
|
from diffsynth.pipelines.z_image import ModelConfig, ZImagePipeline
|
|
except ImportError as exc:
|
|
raise ImportError(
|
|
"The qwen-zimage pipeline needs the optional dependency group. "
|
|
"Install: pip install 'remove-ai-watermarks[qwen-zimage]'"
|
|
) from exc
|
|
|
|
self._progress("Loading Z-Image Turbo face-detail model...")
|
|
keep_on_device = self._keep_face_models_resident()
|
|
config = self._zimage_vram_config()
|
|
text_encoder_config = ModelConfig(
|
|
model_id=ZIMAGE_TURBO_MODEL_ID,
|
|
origin_file_pattern="text_encoder/*.safetensors",
|
|
**config,
|
|
)
|
|
model_configs = [
|
|
ModelConfig(
|
|
model_id=ZIMAGE_TURBO_MODEL_ID,
|
|
origin_file_pattern="transformer/*.safetensors",
|
|
**config,
|
|
),
|
|
text_encoder_config,
|
|
ModelConfig(
|
|
model_id=ZIMAGE_TURBO_MODEL_ID,
|
|
origin_file_pattern="vae/diffusion_pytorch_model.safetensors",
|
|
**config,
|
|
),
|
|
]
|
|
prompt_cached = self._prompt_is_cached(ZIMAGE_TURBO_MODEL_ID, _ZIMAGE_PROMPT_OUTPUTS, _FACE_PROMPT)
|
|
if prompt_cached:
|
|
model_configs.remove(text_encoder_config)
|
|
log.info("Z-Image prompt embedding is cached; loading the stack without its text encoder")
|
|
pipe = ZImagePipeline.from_pretrained(
|
|
torch_dtype=self._face_stage_dtype(),
|
|
device=self.device,
|
|
model_configs=model_configs,
|
|
tokenizer_config=ModelConfig(
|
|
model_id=ZIMAGE_TURBO_MODEL_ID,
|
|
origin_file_pattern="tokenizer/",
|
|
),
|
|
vram_limit=self._vram_limit(),
|
|
)
|
|
if keep_on_device:
|
|
_pin_vram_managed_models(pipe)
|
|
if self.cache_prompt_embeddings:
|
|
_cache_static_prompt_embeddings(
|
|
pipe,
|
|
_ZIMAGE_PROMPT_OUTPUTS,
|
|
model_id=ZIMAGE_TURBO_MODEL_ID,
|
|
require_cache=prompt_cached,
|
|
)
|
|
self._zimage_pipe = pipe
|
|
return pipe
|
|
|
|
def _load_sam(self) -> tuple[Any, Any]:
|
|
if self._sam_model is not None and self._sam_processor is not None:
|
|
return self._sam_model, self._sam_processor
|
|
self._progress("Loading SAM face-mask model...")
|
|
try:
|
|
from transformers import AutoModelForMaskGeneration, AutoProcessor
|
|
except ImportError as exc:
|
|
raise ImportError("SAM needs transformers and torchvision from the qwen-zimage extra.") from exc
|
|
kwargs: dict[str, Any] = {}
|
|
if self.hf_token:
|
|
kwargs["token"] = self.hf_token
|
|
processor = AutoProcessor.from_pretrained(SAM_MODEL_ID, **kwargs)
|
|
model = AutoModelForMaskGeneration.from_pretrained(
|
|
SAM_MODEL_ID,
|
|
torch_dtype=self._face_stage_dtype(),
|
|
**kwargs,
|
|
).to(self.device)
|
|
model.eval()
|
|
self._sam_model = model
|
|
self._sam_processor = processor
|
|
return model, processor
|
|
|
|
def _sam_masks(
|
|
self,
|
|
image: Image.Image,
|
|
boxes: list[tuple[int, int, int, int]],
|
|
) -> list[np.ndarray]:
|
|
if not boxes:
|
|
return []
|
|
import torch
|
|
|
|
try:
|
|
model, processor = self._load_sam()
|
|
input_points, input_labels = _sam_point_prompts(boxes)
|
|
inputs = processor(
|
|
images=image,
|
|
input_boxes=[[list(box) for box in boxes]],
|
|
input_points=input_points,
|
|
input_labels=input_labels,
|
|
return_tensors="pt",
|
|
)
|
|
original_sizes = inputs["original_sizes"].clone()
|
|
reshaped_sizes = inputs["reshaped_input_sizes"].clone()
|
|
inputs = _prepare_sam_inputs(inputs, self.device, self._face_stage_dtype())
|
|
with torch.inference_mode():
|
|
outputs = model(**inputs, multimask_output=True)
|
|
processed = processor.post_process_masks(
|
|
outputs.pred_masks.detach().cpu(),
|
|
original_sizes,
|
|
reshaped_sizes,
|
|
)[0]
|
|
mask_array, score_array = _sam_outputs_to_numpy(processed, outputs.iou_scores)
|
|
binary_masks = _select_sam_masks(
|
|
mask_array,
|
|
score_array,
|
|
)
|
|
return _clip_sam_masks_to_boxes(binary_masks, boxes, image.size)
|
|
except Exception as exc:
|
|
log.warning("SAM face-mask refinement failed (%s); using box-derived ellipse masks", exc)
|
|
return _ellipse_masks(boxes, image.size)
|
|
|
|
def preload(self, *, global_only: bool = False) -> None:
|
|
"""Eagerly load the mandatory stage and, by default, the face stack."""
|
|
self._load_qwen()
|
|
_yunet_model_path()
|
|
if not global_only:
|
|
self._load_zimage()
|
|
self._load_sam()
|
|
|
|
def _run_global(self, image: Image.Image, strength: float, seed: int | None) -> Image.Image:
|
|
pipe, controlnet_input_cls = self._load_qwen()
|
|
input_image = _resize_to_target(image)
|
|
control = build_canny_control_image(input_image)
|
|
control_input = controlnet_input_cls(
|
|
image=control,
|
|
scale=float(self.controlnet_conditioning_scale),
|
|
)
|
|
self._progress(f"Running Qwen-Image-2512 Canny pass: strength={strength:.4f}, steps={GLOBAL_STEPS}...")
|
|
result = pipe(
|
|
**build_global_kwargs(
|
|
input_image,
|
|
strength=strength,
|
|
seed=seed,
|
|
controlnet_input=control_input,
|
|
)
|
|
)
|
|
if result.size != image.size:
|
|
result = result.resize(image.size, Image.Resampling.LANCZOS)
|
|
return result.convert("RGB")
|
|
|
|
def _qwen_vae_roundtrip(self, image: Image.Image) -> Image.Image:
|
|
"""Reconstruct source pixels through the already loaded Qwen VAE."""
|
|
import torch
|
|
|
|
pipe, _controlnet_input_cls = self._load_qwen()
|
|
source_width, source_height = image.size
|
|
pad_width = (-source_width) % 8
|
|
pad_height = (-source_height) % 8
|
|
padded = image.convert("RGB")
|
|
if pad_width or pad_height:
|
|
padded = Image.fromarray(
|
|
np.pad(
|
|
np.asarray(padded),
|
|
((0, pad_height), (0, pad_width), (0, 0)),
|
|
mode="edge",
|
|
)
|
|
)
|
|
pipe.load_models_to_device(["vae"])
|
|
tensor = pipe.preprocess_image(padded).to(device=self.device, dtype=self.torch_dtype)
|
|
with torch.inference_mode():
|
|
latents = pipe.vae.encode(tensor)
|
|
decoded = pipe.vae.decode(latents)
|
|
return pipe.vae_output_to_image(decoded).crop((0, 0, source_width, source_height)).convert("RGB")
|
|
|
|
@staticmethod
|
|
def _detail_size(
|
|
crop_size: tuple[int, int],
|
|
face_size: tuple[int, int],
|
|
) -> tuple[int, int]:
|
|
"""Scale a crop toward a 768px face guide while capping it at 1024px."""
|
|
crop_width, crop_height = crop_size
|
|
face_width, face_height = face_size
|
|
scale_for_face = 768.0 / max(1, max(face_width, face_height))
|
|
scale_for_crop = 1024.0 / max(1, max(crop_width, crop_height))
|
|
scale = min(scale_for_face, scale_for_crop)
|
|
# Never shrink below the crop's current size unless the cap requires it.
|
|
if max(crop_width, crop_height) <= 1024:
|
|
scale = max(1.0, scale)
|
|
width = max(16, round(crop_width * scale / 16.0) * 16)
|
|
height = max(16, round(crop_height * scale / 16.0) * 16)
|
|
return width, height
|
|
|
|
def _run_faces(
|
|
self,
|
|
original: Image.Image,
|
|
global_result: Image.Image,
|
|
boxes: list[tuple[int, int, int, int]],
|
|
masks: list[np.ndarray],
|
|
*,
|
|
strength: float,
|
|
seed: int | None,
|
|
) -> Image.Image:
|
|
if not boxes:
|
|
return global_result
|
|
pipe = self._load_zimage()
|
|
base = np.asarray(global_result.convert("RGB")).copy()
|
|
source = np.asarray(original.convert("RGB"))
|
|
detail_seed = None if seed is None else seed + 1
|
|
|
|
for index, (box, mask) in enumerate(zip(boxes, masks, strict=True), start=1):
|
|
crop_box = _expanded_box(box, original.size)
|
|
cx1, cy1, cx2, cy2 = crop_box
|
|
crop_source = source[cy1:cy2, cx1:cx2]
|
|
crop_mask = mask[cy1:cy2, cx1:cx2]
|
|
if crop_source.size == 0 or not np.any(crop_mask):
|
|
continue
|
|
|
|
face_width = box[2] - box[0]
|
|
face_height = box[3] - box[1]
|
|
process_size = self._detail_size((cx2 - cx1, cy2 - cy1), (face_width, face_height))
|
|
crop_image = Image.fromarray(crop_source).resize(process_size, Image.Resampling.LANCZOS)
|
|
self._progress(
|
|
f"Regenerating face {index}/{len(boxes)} with Z-Image: strength={strength:.4f}, steps={FACE_STEPS}..."
|
|
)
|
|
detailed = pipe(**build_face_kwargs(crop_image, strength=strength, seed=detail_seed))
|
|
detailed = detailed.convert("RGB").resize((cx2 - cx1, cy2 - cy1), Image.Resampling.LANCZOS)
|
|
|
|
base_crop = base[cy1:cy2, cx1:cx2]
|
|
base[cy1:cy2, cx1:cx2] = composite_face(
|
|
base_crop,
|
|
np.asarray(detailed),
|
|
crop_mask,
|
|
feather=10,
|
|
)
|
|
return Image.fromarray(base)
|
|
|
|
def run(
|
|
self,
|
|
image: Image.Image,
|
|
*,
|
|
strength: float | None,
|
|
seed: int | None,
|
|
tile: bool = False,
|
|
tile_size: int = 1024,
|
|
tile_overlap: int = 128,
|
|
text_manifest: VerifiedTextManifest | None = None,
|
|
fidelity_anchor: bool = False,
|
|
) -> Image.Image:
|
|
"""Execute global regeneration and masked face repair."""
|
|
if text_manifest is not None and tile:
|
|
raise ValueError("Verified text restoration is not calibrated with tiled diffusion")
|
|
self._require_cuda()
|
|
seed = resolve_seed(seed)
|
|
donor = None
|
|
if text_manifest is not None:
|
|
self._progress("Reconstructing the verified text donor with the Qwen VAE...")
|
|
donor = self._qwen_vae_roundtrip(image)
|
|
global_strength = (
|
|
resolution_adaptive_denoise(image.width, image.height) if strength is None else float(strength)
|
|
)
|
|
if tile and max(image.size) > tile_size:
|
|
from remove_ai_watermarks._internal.tiling import run_tiled
|
|
|
|
global_result = run_tiled(
|
|
lambda tile_image: self._run_global(tile_image, global_strength, seed),
|
|
image,
|
|
tile_size,
|
|
tile_overlap,
|
|
self._progress,
|
|
)
|
|
else:
|
|
global_result = self._run_global(image, global_strength, seed)
|
|
|
|
self._progress("Detecting faces on the original image...")
|
|
boxes = detect_faces(image)
|
|
if not boxes:
|
|
self._progress("No faces detected; keeping the Qwen global result.")
|
|
result = global_result
|
|
else:
|
|
masks = self._sam_masks(image, boxes)
|
|
face_strength = largest_face_denoise(boxes, image.size) * FACE_DENOISE_SCALE
|
|
result = self._run_faces(
|
|
image,
|
|
global_result,
|
|
boxes,
|
|
masks,
|
|
strength=face_strength,
|
|
seed=seed,
|
|
)
|
|
if text_manifest is None:
|
|
return result
|
|
if donor is None:
|
|
raise RuntimeError("Verified text restoration requires a Qwen-VAE donor")
|
|
from remove_ai_watermarks._internal.text_restoration import (
|
|
blend_fidelity_anchor,
|
|
restore_verified_text,
|
|
)
|
|
|
|
# The anchor is OFF by default since 0.27.1: blending 15% of the Qwen-VAE
|
|
# donor ACROSS THE WHOLE FRAME returned detector-visible OpenAI SynthID on
|
|
# poster-scale manifests (official Content Provenance API, 2026-08-19:
|
|
# detected x6 with the anchor, clean x6 without it, base clean throughout;
|
|
# see docs/text-protection-research.md). ``fidelity_anchor=True`` keeps the
|
|
# 0.27.0 research behavior for reproduction.
|
|
if fidelity_anchor:
|
|
self._progress("Blending the Qwen-VAE fidelity anchor...")
|
|
anchor = blend_fidelity_anchor(result, donor)
|
|
else:
|
|
anchor = result
|
|
self._progress(f"Restoring {len(text_manifest.lines)} verified text lines...")
|
|
return restore_verified_text(image, anchor, donor, text_manifest.lines)
|