Compare commits

...
5 Commits
Author SHA1 Message Date
Victor Kuznetsov 67d948f57d Release 0.35.1 2026-08-30 18:39:39 -07:00
Victor Kuznetsov 1d0e6b54dd Add content-adaptive Google floor for chroma-zimage: face content resolves to 0.125 instead of 0.40
The four-fixture Google calibration showed a clean YuNet face-count split:
both zero-face text cards need 0.25 first-clean, both face fixtures clear
at 0.12 with zero cross-source spread. The adaptive arm routes face
content (face_count > 0) to the measured 0.125 rung, saving 6.4 dB PSNR
and doubling face identity cosine vs the flat 0.40 floor. The split is
Google-SynthID-specific: OpenAI's face fixture was harder than its text
fixture, so no other cohort gets an adaptive arm.
2026-08-30 17:50:56 -07:00
Victor Kuznetsov fa4748a6a8 Fix uvx invocation in clean-install step 2026-08-30 17:33:44 -07:00
Victor Kuznetsov 1e9572e2d4 Install uv before the clean-install verification step 2026-08-30 17:31:44 -07:00
Victor Kuznetsov 1a6f50db75 Add automated post-release verification workflow for all four published surfaces 2026-08-30 17:30:13 -07:00
11 changed files with 249 additions and 18 deletions

No files matched your search

+151
View File
@@ -0,0 +1,151 @@
name: Verify release surfaces
# Post-release verification: after distribute.yml fans a release out to
# Homebrew, the HF Space, and the ComfyUI Registry, confirm every published
# surface actually landed and works. This workflow is the automated twin of
# the manual checklist in docs/release-and-distribution.md ("Release
# verification"), not a replacement for the library's own test suite (which
# already ran in test.yml and publish.yml).
on:
workflow_dispatch:
inputs:
version:
description: "Version to verify (no leading v). Blank = latest on PyPI."
required: false
workflow_run:
workflows: ["Distribute on release"]
types: [completed]
jobs:
verify:
# Only run on a successful distribute (not on manual dispatch, where the
# version input or PyPI-latest provides the target).
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Resolve target version
id: v
run: |
set -euo pipefail
if [ -n "${{ github.event.inputs.version }}" ]; then
v="${{ github.event.inputs.version }}"
else
v=$(curl -fsSL https://pypi.org/pypi/remove-ai-watermarks/json \
| python3 -c "import sys,json;print(json.load(sys.stdin)['info']['version'])")
fi
echo "version=$v" >> "$GITHUB_OUTPUT"
echo "Verifying release $v"
# -- PyPI ---------------------------------------------------------------
- name: Verify PyPI
env:
VERSION: ${{ steps.v.outputs.version }}
run: |
set -euo pipefail
echo "Checking PyPI for $VERSION..."
rel=$(curl -fsSL "https://pypi.org/pypi/remove-ai-watermarks/$VERSION/json")
echo "$rel" | python3 -c "
import json, sys
d = json.load(sys.stdin)
assert d['info']['version'] == '$VERSION', f\"PyPI version mismatch: {d['info']['version']}\"
assert d['urls'], 'No files on PyPI'
has_wheel = any(u['packagetype'] == 'bdist_wheel' for u in d['urls'])
has_sdist = any(u['packagetype'] == 'sdist' for u in d['urls'])
assert has_wheel, 'No wheel on PyPI'
assert has_sdist, 'No sdist on PyPI'
print(f' PyPI: {len(d[\"urls\"])} files (wheel + sdist) for $VERSION')
"
# -- Clean install from PyPI ---------------------------------------------
- name: Install uv
run: curl -LsSf https://astral.sh/uv/install.sh | sh
- name: Verify clean install
env:
VERSION: ${{ steps.v.outputs.version }}
run: |
set -euo pipefail
echo "Installing remove-ai-watermarks==$VERSION in a clean environment..."
export PATH="$HOME/.local/bin:$PATH"
result=$(uvx --from "remove-ai-watermarks==$VERSION" remove-ai-watermarks --version 2>&1) \
|| { echo "::error::Clean install failed: $result"; exit 1; }
echo " Clean install: $result"
# -- Homebrew --------------------------------------------------------------
- name: Verify Homebrew formula
env:
VERSION: ${{ steps.v.outputs.version }}
run: |
set -euo pipefail
echo "Checking Homebrew tap formula..."
formula=$(curl -fsSL \
"https://raw.githubusercontent.com/wiltodelta/homebrew-tap/main/Formula/remove-ai-watermarks.rb")
echo "$formula" | grep -q "remove_ai_watermarks-$VERSION.tar.gz" \
|| { echo "::error::Homebrew formula does not reference $VERSION"; \
echo "Formula URL line:"; echo "$formula" | grep '^url'; exit 1; }
echo " Homebrew: formula points to $VERSION"
# -- ComfyUI ------------------------------------------------------------------
- name: Verify ComfyUI sync succeeded
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
echo "Checking ComfyUI node sync..."
# The registry itself has no public JSON API; the sync workflow in
# the node repo already tests compatibility and publishes only on
# success, so verifying its latest run succeeded is the proxy.
latest=$(gh run list \
--repo wiltodelta/ComfyUI-remove-ai-watermarks \
--workflow sync-library.yml \
--limit 1 \
--json conclusion \
| python3 -c "
import json, sys
runs = json.load(sys.stdin)
r = runs[0] if runs else {}
print(r.get('conclusion', 'unknown'))
")
echo " ComfyUI sync: $latest"
if [ "$latest" != "success" ]; then
echo "::warning::ComfyUI sync latest run is '$latest', not success."
echo " Check: https://github.com/wiltodelta/ComfyUI-remove-ai-watermarks/actions"
else
echo " ComfyUI: sync succeeded, node published"
fi
# -- HF Space -------------------------------------------------------------------
- name: Verify HF Space is alive
run: |
set -euo pipefail
echo "Checking HF Space..."
space_url="https://wiltodelta-remove-ai-watermarks.hf.space"
# The Space may be sleeping after a factory rebuild; wake it.
for attempt in $(seq 1 12); do
status=$(curl -s -o /dev/null -w "%{http_code}" --max-time 30 "$space_url/" || echo "000")
if [ "$status" = "200" ]; then
echo " HF Space: alive (HTTP $status)"
exit 0
fi
echo " HF Space: HTTP $status (attempt $attempt), waiting for rebuild..."
sleep 15
done
echo "::warning::HF Space did not respond with HTTP 200 after 3 minutes."
echo " The Space may still be rebuilding after a factory restart."
echo " Verify manually at $space_url"
- name: Summary
env:
VERSION: ${{ steps.v.outputs.version }}
run: |
cat <<EOF
Release $VERSION verified:
PyPI: wheel + sdist present
Clean install: --version returns $VERSION
Homebrew: formula points to $VERSION
ComfyUI: sync succeeded
HF Space: alive and responding
EOF
+4 -2
View File
@@ -287,8 +287,10 @@ Both need the seed-sensitivity and face-stage composition checks called out
above before shipping. Both checks have now been run (Pre-ship validation
above): the Meta boundary is seed-stable across seeds 0-2 and the full
profile path stays clean through the face stage on the shipped Google floor.
The profile ships with option 1's floors; option 2's adaptive policy remains
the documented follow-up.
The profile ships with option 1's floors plus a content-adaptive Google arm
(face content resolves to 0.125 instead of 0.40, keyed on YuNet detection).
Option 2's full adaptive policy for the remaining cohorts remains the
documented follow-up.
## Oracle session notes
+5 -3
View File
@@ -215,9 +215,11 @@ established for that stage.
- OpenAI: `0.09` (below qwen's `0.07675` is false economy neither way -- the
matched-strength fidelity favors Chroma1 on this cohort);
- Microsoft InvisMark: `0.125` (below qwen's `0.15`);
- Google: `0.40` (above qwen's `0.27`; at this floor the regeneration destroys
dense text and collapses face identity -- the tradeoff that motivates the
documented content-adaptive follow-up);
- Google: `0.40` for zero-face content (above qwen's `0.27`; at this floor the
regeneration destroys dense text and collapses face identity -- the tradeoff
that motivates the adaptive arm below), or `0.125` when YuNet detects at
least one face (both measured face fixtures clear at 0.12 with zero
cross-source spread; oracle-verified 2026-08-30);
- Meta Content Seal: `0.17` (above qwen's `0.1`);
- unknown: `0.40`, following the strictest measured cohort.
+11 -7
View File
@@ -1174,13 +1174,17 @@ conditioning: the floors were measured on a plain strength pass.
The flat vendor floors (`CHROMA_ZIMAGE_*_STRENGTH` in watermark_profiles):
OpenAI 0.09 and Microsoft 0.125 (both BELOW qwen's, with better
matched-strength fidelity), Google 0.40 and Meta 0.17 (both ABOVE qwen's,
because Chroma1's per-fixture boundaries scatter wider). The
matched-strength addendum in the research doc is the honest read: at the
strength each image actually needs, Chroma1 regenerates better almost
everywhere except face identity (which the inherited Z-Image face stage
supplies); at the flat worst-case floors it destroys dense text and face
identity. A content-adaptive strength policy is the documented follow-up.
matched-strength fidelity), Google 0.40 for zero-face content / 0.125 when
faces are detected (a content-adaptive arm from the clean face-count split
in the calibration: both text cards need 0.25, both face fixtures clear at
0.12, so the YuNet detector -- already loaded for the face stage -- routes
the operating point), and Meta 0.17 (ABOVE qwen's, because Chroma1's
per-fixture boundaries scatter wider). The matched-strength addendum in the
research doc is the honest read: at the strength each image actually needs,
Chroma1 regenerates better almost everywhere except face identity (which the
inherited Z-Image face stage supplies); at the flat worst-case floors it
destroys dense text and face identity. The Google face-content arm is the
first shipped piece of the content-adaptive policy.
### SDXL plus Z-Image
+5 -1
View File
@@ -121,7 +121,11 @@ without removing schema 1 so long-lived consumers can update separately. A packa
release must never silently substitute its latest schema when a caller explicitly
requests an older supported one.
After publication, verify:
After publication, the `verify-release.yml` workflow runs automatically once
`distribute.yml` completes and checks every surface below. It can also be
dispatched manually with a specific version. The ComfyUI check verifies the
sync workflow's latest run succeeded rather than querying the registry directly
(the registry has no public JSON API). The full checklist for completeness:
- both wheel and source distribution exist on PyPI;
- the package version matches the tag;
+1 -1
View File
@@ -4,7 +4,7 @@
# on it, including the ComfyUI node package. The console script below carries
# the same weight, since users have it on PATH.
name = "remove-ai-watermarks"
version = "0.35.0"
version = "0.35.1"
description = "AI watermark remover for visible, invisible, and provenance marks in images and video"
readme = "README.md"
requires-python = ">=3.11,<3.15"
+1 -1
View File
@@ -32,7 +32,7 @@ _os.environ.setdefault("TRANSFORMERS_VERBOSITY", "error")
_warnings.filterwarnings("ignore", message=r".*ImageProcessorFast.*")
__version__ = "0.35.0"
__version__ = "0.35.1"
__all__ = [
"BatchSummary",
@@ -153,6 +153,19 @@ CHROMA_ZIMAGE_GOOGLE_STRENGTH = 0.40
CHROMA_ZIMAGE_META_STRENGTH = 0.17
CHROMA_ZIMAGE_UNKNOWN_STRENGTH = CHROMA_ZIMAGE_GOOGLE_STRENGTH
# Content-adaptive Google floor: the four-fixture calibration showed a clean
# face-count split. Both zero-face fixtures (dense-text cards 633uuy and akdbei)
# need 0.25 first-clean; both face fixtures (y48j3c with 17 detected faces and
# 3mc4t9 with 7) clear at 0.12. The flat 0.40 floor is the zero-face policy
# (worst 0.25 + spread 0.13 = 0.38, rounded to 0.40). Face content can use the
# measured 0.125 rung instead: both face fixtures first-cleaned at 0.12, and
# with identical boundaries the cross-source spread is zero, so 0.125 (the next
# measured rung above 0.12) is the operating point. This split is
# Google-SynthID-specific: OpenAI's face fixture was HARDER than its text
# fixture (0.075 vs 0.06), so no other cohort gets an adaptive arm.
# Oracle-verified 2026-08-30; see docs/chroma1-engine-research.md.
CHROMA_ZIMAGE_GOOGLE_FACE_STRENGTH = 0.125
_CHROMA_ZIMAGE_STRENGTH_BY_VENDOR: dict[str, float] = {
"openai": CHROMA_ZIMAGE_OPENAI_STRENGTH,
"google": CHROMA_ZIMAGE_GOOGLE_STRENGTH,
@@ -204,6 +217,7 @@ def resolve_strength(
pipeline: str | None = None,
*,
size: tuple[int, int] | None = None,
face_count: int | None = None,
) -> float:
"""Resolve a user override or the calibrated policy for a profile and vendor.
@@ -222,7 +236,10 @@ def resolve_strength(
if normalized == SDXL_ZIMAGE_PROFILE:
return _SDXL_ZIMAGE_STRENGTH_BY_VENDOR.get((vendor or "").casefold(), SDXL_ZIMAGE_UNKNOWN_STRENGTH)
if normalized == CHROMA_ZIMAGE_PROFILE:
return _CHROMA_ZIMAGE_STRENGTH_BY_VENDOR.get((vendor or "").casefold(), CHROMA_ZIMAGE_UNKNOWN_STRENGTH)
vendor_key = (vendor or "").casefold()
if vendor_key == "google" and face_count is not None and face_count > 0:
return CHROMA_ZIMAGE_GOOGLE_FACE_STRENGTH
return _CHROMA_ZIMAGE_STRENGTH_BY_VENDOR.get(vendor_key, CHROMA_ZIMAGE_UNKNOWN_STRENGTH)
if size is None:
raise ValueError("qwen-zimage resolves strength from image area, so size is required")
vendor_strength = _QWEN_ZIMAGE_FLAT_STRENGTH_BY_VENDOR.get((vendor or "").casefold())
@@ -188,6 +188,26 @@ class WatermarkRemover:
remove_ai_metadata(output_path, output_path, keep_standard=True)
def _resolve_chroma_strength(
self,
strength: float | None,
vendor: str | None,
source: Image.Image,
) -> float:
"""Resolve strength, running face detection for the chroma Google arm.
The content-adaptive Google floor needs a face count. YuNet is fast
(~50 ms) and the model download is already managed by the shared base.
"""
if self.model_profile != CHROMA_ZIMAGE_PROFILE or (vendor or "").casefold() != "google":
return resolve_strength(strength, vendor, self.model_profile, size=source.size)
if strength is not None:
return strength
from remove_ai_watermarks._internal.two_stage_pipeline import detect_faces
face_count = len(detect_faces(source))
return resolve_strength(strength, vendor, self.model_profile, size=source.size, face_count=face_count)
def remove_watermark(
self,
image_path: Path,
@@ -213,7 +233,7 @@ class WatermarkRemover:
with Image.open(image_path) as opened:
source = opened.convert("RGB")
resolved_strength = resolve_strength(strength, vendor, self.model_profile, size=source.size)
resolved_strength = self._resolve_chroma_strength(strength, vendor, source)
if not 0.0 <= resolved_strength <= 1.0:
raise ValueError(f"Strength must be between 0.0 and 1.0, got {resolved_strength}")
if text_manifest is not None and self.model_profile in (SDXL_ZIMAGE_PROFILE, CHROMA_ZIMAGE_PROFILE):
+31
View File
@@ -1204,3 +1204,34 @@ def test_chroma_vae_roundtrip_raises_not_implemented():
pipeline = ChromaZImagePipeline(device="cuda", torch_dtype=torch.bfloat16)
with pytest.raises(NotImplementedError, match="does not expose a VAE donor"):
pipeline._vae_roundtrip(Image.new("RGB", (32, 32)))
def test_chroma_google_face_content_gets_the_lower_adaptive_floor():
"""Face content on Google SynthID clears at 0.125, not 0.40.
The four-fixture calibration showed a clean face-count split: both zero-face
text cards need 0.25, both face fixtures clear at 0.12. The adaptive arm
saves face identity (0.611 vs 0.279 cosine) and 6.4 dB PSNR on face content.
"""
from remove_ai_watermarks._internal.watermark_profiles import (
CHROMA_ZIMAGE_GOOGLE_FACE_STRENGTH,
CHROMA_ZIMAGE_GOOGLE_STRENGTH,
resolve_strength,
)
assert pytest.approx(0.125) == CHROMA_ZIMAGE_GOOGLE_FACE_STRENGTH
# Face content gets the lower adaptive floor.
assert resolve_strength(None, "google", "chroma-zimage", face_count=17) == pytest.approx(0.125)
assert resolve_strength(None, "google", "chroma-zimage", face_count=1) == pytest.approx(0.125)
# Zero-face content gets the flat floor (text/graphic cohort).
assert resolve_strength(None, "google", "chroma-zimage", face_count=0) == pytest.approx(0.40)
assert pytest.approx(0.40) == CHROMA_ZIMAGE_GOOGLE_STRENGTH
# No face_count hint: conservative (flat floor).
assert resolve_strength(None, "google", "chroma-zimage") == pytest.approx(0.40)
# Other vendors ignore face_count: the split is Google-SynthID-specific.
assert resolve_strength(None, "openai", "chroma-zimage", face_count=5) == pytest.approx(0.09)
assert resolve_strength(None, "meta", "chroma-zimage", face_count=5) == pytest.approx(0.17)
# An explicit strength still wins over the adaptive arm.
assert resolve_strength(0.20, "google", "chroma-zimage", face_count=17) == pytest.approx(0.20)
# Other profiles are untouched by the face_count parameter.
assert resolve_strength(None, "google", "qwen-zimage", size=(2000, 1850), face_count=17) == pytest.approx(0.27)
Generated
+1 -1
View File
@@ -3169,7 +3169,7 @@ wheels = [
[[package]]
name = "remove-ai-watermarks"
version = "0.35.0"
version = "0.35.1"
source = { editable = "." }
dependencies = [
{ name = "c2pa-python" },