refactor: unify C2PA vendor registry + code-health fixes + uv publish

Three P2 cleanups from a library-wide review.

Detection -- single C2PA_AI_VENDORS registry (noai/constants.py):
- C2PA_ISSUERS, SYNTHID_C2PA_ISSUERS, and identify._ISSUER_PLATFORM now derive
  from one C2paAiVendor table, so adding a C2PA vendor is one entry instead of
  edits in three places across two files. Behavior-identical (262 detection
  tests pass; the kept `needle` field is load-bearing -- it differs from `org`
  for Google and ByteDance, with no mechanical derivation).

Code-health:
- region_eraser.erase_lama now accepts grayscale/BGRA like erase_cv2 (it
  crashed on grayscale and silently dropped alpha on BGRA). +2 regression tests.
- batch frees the device cache between images via a shared try_empty_device_cache
  helper (generalized from the MPS-only _try_clear_mps_cache, now reused by both
  the MPS->CPU fallback and the batch loop).
- batch gained --controlnet-scale (parity with invisible/all).

CI / packaging:
- publish.yml uploads via `uv publish` (PyPI trusted publishing over OIDC),
  replacing pypa/gh-action-pypi-publish so uploads no longer depend on that
  action's bundled twine accepting the Metadata-Version. Workflow filename +
  pypi environment unchanged, so PyPI's trusted-publisher entry still matches.
- hatchling pin relaxed <1.28 -> <1.31 (verified against hatch's changelog:
  1.30.0 made Metadata 2.5 the default, 1.30.1 reverted to 2.4; 1.27-1.29 were
  always 2.4). Kept as belt-and-suspenders so the first uv-publish release ships
  2.4, isolating the uploader swap from the metadata-version bump.

Docs (CLAUDE.md, pyproject) synced; corrected the inaccurate "hatchling 1.28+
emits 2.5" note.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Victor Kuznetsov
2026-06-03 20:54:39 -07:00
parent 9bd2c17cc4
commit 5cf68a6a3d
10 changed files with 155 additions and 42 deletions
+5
View File
@@ -990,6 +990,7 @@ def _process_batch_image(
min_resolution: int = 1024,
restore_faces: bool = False,
restore_faces_weight: float = 0.5,
controlnet_scale: float = 1.0,
) -> None:
"""Process a single image for batch mode.
@@ -1040,6 +1041,7 @@ def _process_batch_image(
device=None if device == "auto" else device,
pipeline=pipeline,
hf_token=hf_token,
controlnet_conditioning_scale=controlnet_scale,
)
engine_inv = ctx.obj["_inv_engine"]
engine_inv.remove_watermark(
@@ -1114,6 +1116,7 @@ def _process_batch_image(
@_restore_faces_options
@_min_resolution_option
@_unsharp_option
@_controlnet_scale_option
@click.pass_context
def cmd_batch(
ctx: click.Context,
@@ -1133,6 +1136,7 @@ def cmd_batch(
min_resolution: int,
restore_faces: bool,
restore_faces_weight: float,
controlnet_scale: float,
) -> None:
"""Process all images in a directory."""
_banner()
@@ -1187,6 +1191,7 @@ def cmd_batch(
min_resolution=min_resolution,
restore_faces=restore_faces,
restore_faces_weight=restore_faces_weight,
controlnet_scale=controlnet_scale,
)
processed += 1
+8 -14
View File
@@ -40,7 +40,7 @@ from remove_ai_watermarks.metadata import (
xai_signature,
)
from remove_ai_watermarks.noai.c2pa import cbor_text_after, extract_c2pa_info, soft_binding_vendors_in
from remove_ai_watermarks.noai.constants import C2PA_AI_TOOLS, C2PA_ISSUERS
from remove_ai_watermarks.noai.constants import C2PA_AI_TOOLS, C2PA_AI_VENDORS, C2PA_ISSUERS
if TYPE_CHECKING:
from pathlib import Path
@@ -59,19 +59,13 @@ _SCAN_BYTES = 1024 * 1024
# signal (e.g. an OpenAI image scored 0.37 -- below threshold, correctly dropped).
_SPARKLE_THRESHOLD = 0.5
# Issuer (C2PA signer) -> human-readable generating platform. Ordered: when a
# manifest names several issuers (Microsoft Designer signs as "OpenAI,
# Microsoft"), the first match wins so the product, not the backend, is named.
_ISSUER_PLATFORM: tuple[tuple[str, str], ...] = (
# Microsoft signs both Designer and Bing Image Creator; Bing now runs its
# own MAI-Image model (not DALL-E), so the label stays model-neutral.
("Microsoft", "Microsoft (Bing Image Creator / Designer)"),
("Adobe", "Adobe Firefly"),
("OpenAI", "OpenAI (ChatGPT / gpt-image / DALL-E / Sora)"),
("Google", "Google (Gemini / Imagen)"),
("Stability AI", "Stability AI (Stable Image / DreamStudio)"),
("Black Forest Labs", "Black Forest Labs (FLUX)"),
("ByteDance", "ByteDance (Doubao / Jimeng / Volcano Engine)"),
# Issuer (C2PA signer) -> human-readable generating platform, derived from the
# single C2PA_AI_VENDORS registry. Ordered: when a manifest names several issuers
# (Microsoft Designer signs as "OpenAI, Microsoft"), the first match wins so the
# product, not the backend, is named -- the registry order encodes that priority.
# Signing authorities without an AI platform (e.g. Truepic) are skipped here.
_ISSUER_PLATFORM: tuple[tuple[str, str], ...] = tuple(
(v.needle, v.platform) for v in C2PA_AI_VENDORS if v.platform is not None and v.needle is not None
)
# PNG-text / EXIF keys that indicate a local diffusion pipeline (vs. a hosted
+46 -12
View File
@@ -4,6 +4,8 @@ All modules reference these constants rather than hard-coding values,
so adding a new AI tool or metadata key requires updating only this file.
"""
from typing import NamedTuple
# Supported image formats
SUPPORTED_FORMATS = {".png", ".jpg", ".jpeg", ".webp"}
@@ -78,25 +80,56 @@ C2PA_SIGNATURES = [
b"manifest",
]
# C2PA known issuers
C2PA_ISSUERS = {
b"Google": "Google LLC",
b"Adobe": "Adobe",
b"Microsoft": "Microsoft",
b"OpenAI": "OpenAI",
b"Truepic": "Truepic",
# Single source of truth for every C2PA-signing vendor. The three per-vendor
# facts that used to live in separate tables -- the issuer byte signature
# (C2PA_ISSUERS), the SynthID pairing (SYNTHID_C2PA_ISSUERS), and the human
# platform label (identify._ISSUER_PLATFORM) -- are all fields here, so adding a
# new C2PA vendor is a single append below; the views derive automatically.
class C2paAiVendor(NamedTuple):
issuer: bytes # distinctive byte signature scanned in the manifest (cert org / signer)
org: str # resolved issuer/cert-org display name (the old C2PA_ISSUERS value)
# Human platform label for identify; None marks a signing authority / non-generator
# (e.g. Truepic), which never names an AI platform on its own.
platform: str | None
# Substring matched against the joined issuer-org names for platform attribution
# (usually a shorter form of org, e.g. "Google" for "Google LLC"); None when platform is.
needle: str | None
synthid: bool = False # vendor pairs an invisible SynthID pixel watermark with its C2PA manifest
# C2PA known vendors, ORDERED for first-match-wins platform attribution: when a
# manifest names several issuers (Microsoft Designer signs as "OpenAI, Microsoft"),
# the earlier entry wins so the product, not the backend engine, is named.
# Used by Google Imagen, Adobe Firefly, Microsoft Designer, OpenAI, etc.
C2PA_AI_VENDORS: tuple[C2paAiVendor, ...] = (
# Microsoft signs both Designer and Bing Image Creator; Bing now runs its own
# MAI-Image model (not DALL-E), so the label stays model-neutral.
C2paAiVendor(b"Microsoft", "Microsoft", "Microsoft (Bing Image Creator / Designer)", "Microsoft"),
C2paAiVendor(b"Adobe", "Adobe", "Adobe Firefly", "Adobe"),
C2paAiVendor(b"OpenAI", "OpenAI", "OpenAI (ChatGPT / gpt-image / DALL-E / Sora)", "OpenAI", synthid=True),
C2paAiVendor(b"Google", "Google LLC", "Google (Gemini / Imagen)", "Google", synthid=True),
# Stability AI signs C2PA as "Stability AI" (cert org "Stability AI Ltd").
# Verified on a live Brand Studio (DreamStudio successor) output, 2026-05-24.
b"Stability AI": "Stability AI",
C2paAiVendor(b"Stability AI", "Stability AI", "Stability AI (Stable Image / DreamStudio)", "Stability AI"),
# Black Forest Labs (FLUX) API output: claim_generator_info "Black Forest
# Labs API" + a c2pa.ai_generated_content assertion + trainedAlgorithmicMedia.
# Verified on a real signed FLUX JPEG, 2026-05-29.
b"Black Forest Labs": "Black Forest Labs",
C2paAiVendor(b"Black Forest Labs", "Black Forest Labs", "Black Forest Labs (FLUX)", "Black Forest Labs"),
# ByteDance's Volcano Engine (Volcengine) signs its AI image output with a
# cert from certificate_center@volcengine.com -- the platform behind Doubao /
# Jimeng. Verified on two real signed JPEGs, 2026-05-29.
b"volcengine": "ByteDance (Volcano Engine)",
}
C2paAiVendor(
b"volcengine", "ByteDance (Volcano Engine)", "ByteDance (Doubao / Jimeng / Volcano Engine)", "ByteDance"
),
# Truepic is a C2PA signing authority, not an AI generator: no platform label,
# never asserts is_ai (the verdict comes from the digital-source-type).
C2paAiVendor(b"Truepic", "Truepic", None, None),
)
# Derived view -- add a vendor to C2PA_AI_VENDORS above, not here.
# C2PA issuer signature -> resolved org name, for the manifest byte-scan.
C2PA_ISSUERS: dict[bytes, str] = {v.issuer: v.org for v in C2PA_AI_VENDORS}
# C2PA issuers whose signed outputs also carry an invisible SynthID pixel
# watermark -- a metadata proxy for "SynthID is in the pixels":
@@ -117,7 +150,8 @@ C2PA_ISSUERS = {
# C2PA manifest alone is not a SynthID signal -- the issuer is. The pixel
# watermark is not locally detectable (proprietary decoder); the C2PA companion
# is the proxy, and only while the manifest is intact.
SYNTHID_C2PA_ISSUERS = frozenset({b"Google", b"OpenAI"})
# Derived from the `synthid` flag on C2PA_AI_VENDORS -- set it there, not here.
SYNTHID_C2PA_ISSUERS: frozenset[bytes] = frozenset(v.issuer for v in C2PA_AI_VENDORS if v.synthid)
# C2PA known AI tools
C2PA_AI_TOOLS = {
@@ -102,7 +102,7 @@ def run_img2img_with_mps_fallback(
if device == "mps" and is_mps_error(error):
logger.warning("MPS error detected: %s. Falling back to CPU.", error)
set_progress("MPS error! Clearing cache and retrying on CPU...")
_try_clear_mps_cache()
try_empty_device_cache("mps")
pipeline = reload_on_cpu()
img = run_img2img(
pipeline, image, strength, num_inference_steps, guidance_scale, None, "cpu", set_progress, extra_kwargs
@@ -137,9 +137,16 @@ def _call_pipeline(
return pipeline(**kwargs)
def _try_clear_mps_cache() -> None:
def try_empty_device_cache(device: str) -> None:
"""Best-effort free of cached GPU/MPS/XPU memory for ``device``.
``torch.<device>.empty_cache()`` exists for cuda/mps/xpu but not cpu (the
hasattr guard skips the cpu no-op). Never raises -- callers use it as cleanup
(the MPS->CPU fallback here, and the batch loop in watermark_remover).
"""
with contextlib.suppress(Exception):
import torch
if hasattr(torch, "mps"):
torch.mps.empty_cache() # type: ignore[attr-defined]
backend = getattr(torch, device, None)
if backend is not None and hasattr(backend, "empty_cache"):
backend.empty_cache() # type: ignore[attr-defined]
@@ -692,6 +692,9 @@ class WatermarkRemover:
output_dir.mkdir(parents=True, exist_ok=True)
cleaned_paths: list[Path] = []
# Lazy import keeps this module torch-optional; frees device cache per image.
from remove_ai_watermarks.noai.img2img_runner import try_empty_device_cache
for ext in extensions:
for image_path in input_dir.glob(f"*{ext}"):
output_path = output_dir / image_path.name
@@ -705,6 +708,7 @@ class WatermarkRemover:
cleaned_paths.append(result_path)
except Exception as e:
logger.error("Failed to process %s: %s", image_path, e)
try_empty_device_cache(self.device)
return cleaned_paths
+12
View File
@@ -108,7 +108,19 @@ def erase_lama(image_bgr: NDArray[Any], mask: NDArray[Any]) -> NDArray[Any]:
LaMa runs at a fixed square input size. To preserve full-image resolution we
crop a padded region around the mask, inpaint that crop at the model size,
and paste only the masked pixels back -- so untouched areas stay pixel-exact.
Like ``erase_cv2``, accepts 1-channel (grayscale) and 4-channel (BGRA) input:
LaMa runs on 3-channel BGR, so grayscale is promoted to BGR (result demoted
back) and a BGRA alpha plane is split off and re-attached unchanged. Without
this the ``cv2.cvtColor(..., BGR2RGB)`` below would crash on grayscale and
silently drop alpha on BGRA.
"""
if image_bgr.ndim == 2:
bgr = erase_lama(cv2.cvtColor(image_bgr, cv2.COLOR_GRAY2BGR), mask)
return cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY)
if image_bgr.ndim == 3 and image_bgr.shape[2] == 4:
bgr = erase_lama(np.ascontiguousarray(image_bgr[:, :, :3]), mask)
return np.dstack([bgr, image_bgr[:, :, 3]])
session = _get_lama_session()
inp = session.get_inputs() # type: ignore[attr-defined]
img_name = inp[0].name