Compare commits

...
8 Commits
19 changed files with 437 additions and 34 deletions

No files matched your search

+105 -3
View File
@@ -1,11 +1,13 @@
name: Distribute on release
# Fans a published GitHub Release out to the channels that need a nudge.
# PyPI is handled by publish.yml; conda-forge is handled by its autotick bot.
# This workflow event-drives the two channels that would otherwise be manual:
# PyPI is handled by publish.yml; the conda-forge channel is handled by its
# autotick bot. This workflow updates the repository recipe and event-drives
# the three channels that would otherwise be manual:
# - Homebrew tap: rewrite the formula's url + sha256 to the new sdist.
# - HF Space: factory-rebuild so it reinstalls the latest sdist from PyPI.
# Both wait for the freshly published sdist to appear on PyPI first, since the
# - ComfyUI: sync, test, and publish the node against the exact library release.
# All wait for the freshly published sdist to appear on PyPI first, since the
# Release event fires in parallel with publish.yml's upload.
on:
@@ -87,6 +89,34 @@ jobs:
git commit -m "remove-ai-watermarks $VERSION" || { echo "Formula already current"; exit 0; }
git push
conda-recipe:
needs: resolve
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout main
uses: actions/checkout@v7
with:
ref: main
- name: Bump the repository conda recipe
env:
SHA: ${{ needs.resolve.outputs.sha }}
VERSION: ${{ needs.resolve.outputs.version }}
run: |
set -euo pipefail
python scripts/sync_conda_recipe.py --version "$VERSION" --sha256 "$SHA"
git diff --check
if git diff --quiet -- packaging/conda/recipe.yaml; then
echo "Conda recipe already current"
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add packaging/conda/recipe.yaml
git commit -m "Sync conda recipe with v$VERSION"
git push
hf-space:
needs: resolve
runs-on: ubuntu-latest
@@ -104,3 +134,75 @@ jobs:
)
print("HF Space factory rebuild triggered")
PY
comfyui:
needs: resolve
runs-on: ubuntu-latest
steps:
- name: Sync and publish the ComfyUI nodes
env:
GH_TOKEN: ${{ secrets.COMFYUI_RELEASE_TOKEN }}
run: |
set -euo pipefail
repo=wiltodelta/ComfyUI-remove-ai-watermarks
workflow=sync-library.yml
dispatched_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)
gh workflow run "$workflow" \
--repo "$repo" \
--ref main \
--raw-field library_version="${{ needs.resolve.outputs.version }}"
run_id=""
for attempt in $(seq 1 30); do
run_id=$(
gh run list \
--repo "$repo" \
--workflow "$workflow" \
--event workflow_dispatch \
--limit 10 \
--json databaseId,createdAt \
| python3 -c '
import json
import sys
dispatched_at = sys.argv[1]
runs = json.load(sys.stdin)
print(next(
(run["databaseId"] for run in runs if run["createdAt"] >= dispatched_at),
"",
))
' "$dispatched_at"
)
if [ -n "$run_id" ]; then
break
fi
echo "ComfyUI sync run not visible yet (attempt $attempt), waiting..."
sleep 2
done
if [ -z "$run_id" ]; then
echo "::error::The dispatched ComfyUI sync run did not appear"
exit 1
fi
for attempt in $(seq 1 180); do
read -r status conclusion <<< "$(
gh run view "$run_id" \
--repo "$repo" \
--json status,conclusion \
--jq '[.status, (.conclusion // "")] | @tsv'
)"
if [ "$status" = "completed" ]; then
if [ "$conclusion" = "success" ]; then
echo "ComfyUI sync run $run_id succeeded"
exit 0
fi
echo "::error::ComfyUI sync run $run_id completed with $conclusion"
exit 1
fi
echo "ComfyUI sync run $run_id is $status (attempt $attempt), waiting..."
sleep 5
done
echo "::error::ComfyUI sync run $run_id did not finish in time"
exit 1
+2 -2
View File
@@ -56,7 +56,7 @@ Compact map. The full per-module detail (design decisions, tuned thresholds, cal
- `noai/c2pa.py` — C2PA reading. `extract_c2pa_info(path)` uses the official **c2pa-python `Reader`** first (core dep, any container; `read_manifest_store_json` returns the WHOLE store JSON — active + ingredient manifests — so an AI marker on a parent manifest is seen), and falls back to the hand-rolled caBX/CBOR parser (`has_c2pa_metadata` / `extract_c2pa_chunk` / `_extract_c2pa_info_png`) for synthetic/partial blobs the validator rejects or a broken/absent wheel. The registry scan (issuer / source-type / SynthID / soft-binding) is shared by both paths via `_populate_registry_fields`, so the return-dict shape is identical. Do not reimplement chunk parsing; chunk reads are clamped to the remaining file size by design. `extract_c2pa_chunk`/`inject_c2pa_chunk` stay PNG-only (raw caBX bytes, test/extractor use).
- `noai/constants.py` — the single `C2PA_AI_VENDORS` registry (+ `C2PA_SOFT_BINDINGS`) from which `C2PA_ISSUERS` / `SYNTHID_C2PA_ISSUERS` / `C2PA_IDENTITY_AI_ORGS` / `identify._ISSUER_PLATFORM` are all derived. Add a new vendor as one registry entry; never edit the derived dicts and never add inline. A vendor's `asserts_ai=True` flag means its mere presence asserts AI generation even without a `trainedAlgorithmicMedia` digital-source-type (a pure-generator brand with a distinctive issuer/generator string, e.g. **Dreamina** — ByteDance's international Jimeng brand, signed as "Bytedance Pte. Ltd." with a "Dreamina/x.y" claim generator and no source-type); NEVER set it for common-word issuers (Adobe/Google/OpenAI/Microsoft) that appear incidentally in unrelated bytes — those stay source-type-gated in `identify._attribute_platform`.
- `metadata.py` — `scan_head(path)` is the shared (memoized) input for every C2PA/AIGC/IPTC byte scan; use it instead of `open().read(1MB)` for any new marker scan. Also home to `synthid_source`, `xai_signature`, `iptc_ai_system`, `aigc_label`, `huggingface_job`, `samsung_genai`, and `remove_ai_metadata` (fail-safe `strip_c2pa_boxes`). **A caller that REPORTS an outcome must use `strip_and_verify`, not `remove_ai_metadata` directly** -- the stripper is deliberately fail-safe (a file PIL cannot decode is copied through UNCHANGED rather than crashing), so its return value cannot distinguish a no-op from a real strip. `metadata --remove` and `batch --mode metadata|all` both re-scan the OUTPUT through it and fail loudly; verified on Samsung Galaxy S22 C2PA PNGs, where the command printed "stripped" and exited 0 while the output still read as AI (2026-07-19). **`remove_ai_metadata` is the SINGLE metadata stripper** (the legacy PIL-re-encoding `noai/cleaner` was deleted; the diffusion core and the public `noai.remove_ai_metadata` re-export now point here). It strips **losslessly** per container: ISOBMFF (HEIC/AVIF/MP4) blanks tokens / strips boxes in place; **JPEG uses `_strip_jpeg_metadata_lossless`** — a marker-segment walk that drops the AI-bearing APP segments (C2PA APP11; XMP APP1 carrying C2PA, a China-AIGC token, OR an IPTC `digitalSourceType` / 2025.1 AI-disclosure marker; IPTC-IIM APP13) and scrubs AI EXIF tags via piexif, copying the entropy-coded scan verbatim so **the pixels are bit-identical** (no DCT re-encode). **Detection<->removal parity across every marker placement is load-bearing** — anything a scanner flags, the strip must reach, or a re-served file still reads as AI: (a) the APP1-XMP branch of `_jpeg_app_carries_ai` checks the IPTC marker sets too, not only C2PA/AIGC (the Instagram/MidJourney/Meta "Made with AI" `digitalSourceType` lives in XMP, not the APP13 IIM record); (b) a bare `AIGC{...}` / `{"AIGC":{...}}` block in ANY JPEG APP segment — the specific C2PA(APP11)/XMP(APP1)/IPTC(APP13) checks FALL THROUGH to a generic `_is_aigc_exif_value` drop, so a bare AIGC in APP11 (the supported placement, NOT a C2PA manifest) is caught, not swallowed by the C2PA-only 0xEB branch — plus the same AIGC block in a STANDARD **PNG text chunk** value (e.g. `Description`, which `_is_ai_key` keeps) is dropped on the value; (c) the China TC260 `{"AIGC":{...}}` block in EXIF `UserComment`/`ImageDescription` is scrubbed by `_scrub_ai_exif` (Doubao producer + Tencent service-provider schemas); (d) the Samsung Galaxy AI `PhotoEditor_Re_Edit_Data` trailer past the JPEG EOI is truncated by `_strip_samsung_trailer` (and `samsung_genai` reads the file tail so a multi-MB photo's trailer past the 512 KB quick-scan window is still DETECTED). Pixels stay bit-identical throughout, so a `--strip-metadata` on a q100 removal output does NOT crush it back to q75; PNG/WebP re-saves are pixel-lossless (WebP written at cv2 lossless mode, quality 101 — quality 1-100 is lossy). **The PIL-fallback save format is chosen by the source's CONTENT, not its file extension** (`_sniff_image_format`, and the JPEG-lossless gate is content-gated too): inputs can be misnamed (a PNG served as `.jpg` is the common one), and routing on the extension re-encoded a lossless PNG/WebP into a real JPEG — a silent degradation that broke "work with originals". A **misnamed** lossless source (source-extension format != content) is preserved in its true format; a **correctly-named** source still honors a deliberate output-extension conversion (e.g. `source.png -> output.jpg`). Not yet handled: a 16-bit PNG is downconverted to 8-bit on the PIL re-save (rare; would need a byte-level PNG chunk stripper). Regression: `tests/test_metadata.py::TestHasAiMetadata::test_strip_preserves_lossless_content_with_mismatched_extension`. **`remove_ai_metadata` is fail-safe on an undecodable image:** a truncated/corrupt file (PIL raises `OSError` decoding it; some inputs) is copied through UNCHANGED rather than crashing a direct library caller (a web worker would 500 on a partial upload), mirroring `strip_c2pa_boxes` — we cannot strip what we cannot parse, but we never raise. Regression: `tests/test_metadata.py::TestHasAiMetadata::test_remove_ai_metadata_failsafe_on_truncated_png`. Regression: `tests/test_metadata.py::TestHasAiMetadata::{test_jpeg_metadata_strip_is_pixel_lossless, test_jpeg_strip_removes_iptc_marker_in_xmp}`, `TestSamsungGenai::{test_remove_strips_post_eoi_trailer, test_detects_trailer_past_scan_window}`, the AIGC-EXIF/bare-APP removal tests, and `tests/test_noai.py::TestISOBMFF::{test_blank_aigc_block_in_exif, test_blank_xai_signature_pair_in_exif}`. `exif_generator` matches a VALUE against `AI_GENERATOR_TOKENS` across EXIF `Software`/`Make`/`Artist`/`ImageDescription`, XMP `CreatorTool`, AND PNG `tEXt` chunks (`Software`/`Source`/`Title`/`Description` — NovelAI stamps there, not EXIF). **Detection and removal must stay in parity:** a Line truncated
- `metadata.py` — `scan_head(path)` is the shared (memoized) input for every C2PA/AIGC/IPTC byte scan; use it instead of `open().read(1MB)` for any new marker scan. Also home to `synthid_source`, `xai_signature`, `iptc_ai_system`, `aigc_label`, `huggingface_job`, `samsung_genai`, and `remove_ai_metadata` (fail-safe `strip_c2pa_boxes`). **A caller that REPORTS an outcome must use `strip_and_verify`, not `remove_ai_metadata` directly** -- the stripper is deliberately fail-safe (a file PIL cannot decode is copied through UNCHANGED rather than crashing), so its return value cannot distinguish a no-op from a real strip. `strip_and_verify` re-scans the output and, when metadata survived but `image_io` can still decode the raster, normalizes the container and scans again; that recovery preserves pixels but drops standard metadata. A truly undecodable file keeps the surviving-marker result. `metadata --remove` and `batch --mode metadata|all` use this verified path. **`remove_ai_metadata` is the SINGLE metadata stripper** (the legacy PIL-re-encoding `noai/cleaner` was deleted; the diffusion core and the public `noai.remove_ai_metadata` re-export now point here). It strips **losslessly** per container: ISOBMFF (HEIC/AVIF/MP4) blanks tokens / strips boxes in place; **JPEG uses `_strip_jpeg_metadata_lossless`** — a marker-segment walk that drops the AI-bearing APP segments (C2PA APP11; XMP APP1 carrying C2PA, a China-AIGC token, OR an IPTC `digitalSourceType` / 2025.1 AI-disclosure marker; IPTC-IIM APP13) and scrubs AI EXIF tags via piexif, copying the entropy-coded scan verbatim so **the pixels are bit-identical** (no DCT re-encode). **Detection<->removal parity across every marker placement is load-bearing** — anything a scanner flags, the strip must reach, or a re-served file still reads as AI: (a) the APP1-XMP branch of `_jpeg_app_carries_ai` checks the IPTC marker sets too, not only C2PA/AIGC (the Instagram/MidJourney/Meta "Made with AI" `digitalSourceType` lives in XMP, not the APP13 IIM record); (b) a bare `AIGC{...}` / `{"AIGC":{...}}` block in ANY JPEG APP segment — the specific C2PA(APP11)/XMP(APP1)/IPTC(APP13) checks FALL THROUGH to a generic `_is_aigc_exif_value` drop, so a bare AIGC in APP11 (the supported placement, NOT a C2PA manifest) is caught, not swallowed by the C2PA-only 0xEB branch — plus the same AIGC block in a STANDARD **PNG text chunk** value (e.g. `Description`, which `_is_ai_key` keeps) is dropped on the value; (c) the China TC260 `{"AIGC":{...}}` block in EXIF `UserComment`/`ImageDescription` is scrubbed by `_scrub_ai_exif` (Doubao producer + Tencent service-provider schemas); (d) the Samsung Galaxy AI `PhotoEditor_Re_Edit_Data` trailer past the JPEG EOI is truncated by `_strip_samsung_trailer` (and `samsung_genai` reads the file tail so a multi-MB photo's trailer past the 512 KB quick-scan window is still DETECTED). Pixels stay bit-identical throughout, so a `--strip-metadata` on a q100 removal output does NOT crush it back to q75; PNG/WebP re-saves are pixel-lossless (WebP written at cv2 lossless mode, quality 101 — quality 1-100 is lossy). **The PIL-fallback save format is chosen by the source's CONTENT, not its file extension** (`_sniff_image_format`, and the JPEG-lossless gate is content-gated too): inputs can be misnamed (a PNG served as `.jpg` is the common one), and routing on the extension re-encoded a lossless PNG/WebP into a real JPEG — a silent degradation that broke "work with originals". A **misnamed** lossless source (source-extension format != content) is preserved in its true format; a **correctly-named** source still honors a deliberate output-extension conversion (e.g. `source.png -> output.jpg`). Not yet handled: a 16-bit PNG is downconverted to 8-bit on the PIL re-save (rare; would need a byte-level PNG chunk stripper). Regression: `tests/test_metadata.py::TestHasAiMetadata::test_strip_preserves_lossless_content_with_mismatched_extension`. **`remove_ai_metadata` is fail-safe on an undecodable image:** a truncated/corrupt file (PIL raises `OSError` decoding it; some inputs) is copied through UNCHANGED rather than crashing a direct library caller (a web worker would 500 on a partial upload), mirroring `strip_c2pa_boxes` — we cannot strip what we cannot parse, but we never raise. Regression: `tests/test_metadata.py::TestHasAiMetadata::{test_remove_ai_metadata_failsafe_on_truncated_png,test_strip_and_verify_normalizes_decodable_copy_through,test_strip_and_verify_reports_markers_in_undecodable_copy_through}`. Regression: `tests/test_metadata.py::TestHasAiMetadata::{test_jpeg_metadata_strip_is_pixel_lossless, test_jpeg_strip_removes_iptc_marker_in_xmp}`, `TestSamsungGenai::{test_remove_strips_post_eoi_trailer, test_detects_trailer_past_scan_window}`, the AIGC-EXIF/bare-APP removal tests, and `tests/test_noai.py::TestISOBMFF::{test_blank_aigc_block_in_exif, test_blank_xai_signature_pair_in_exif}`. `exif_generator` matches a VALUE against `AI_GENERATOR_TOKENS` Line truncated
- `identify.py` — aggregates every locally-readable signal into one `ProvenanceReport`; `is_ai_generated` is True or None, never asserted False. `ProvenanceReport.ai_source_kind` exposes the C2PA digital-source-type split — `"generated"` (trainedAlgorithmicMedia, fully AI) vs `"enhanced"` (compositeWithTrainedAlgorithmicMedia, a real photo with an AI-composited region), else None — so a caller branches full-frame scrub vs region-targeted clean (see `noai/tiling.feather_region_composite` + `WatermarkRemover.remove_watermark(region=...)`). The sparkle provenance threshold is the SHARED `watermark_registry.GEMINI_SPARKLE_TRUST_CONF` (imported, not a private copy) so the provenance "is there a sparkle" verdict and the removal "take the sparkle" decision can never drift. `import identify` is deliberately light (lazy `noai/__init__`, fits a 512 MB host) — keep heavy imports out (the `watermark_registry` constant import stays light: engines are lazy there). Add capture-camera tokens to `_DEVICE_C2PA_PLATFORM` only when verified against a real C2PA file; editing-app/AI-device signer tokens go to `_SIGNER_C2PA_PLATFORM`; generator/issuer platforms to `C2PA_AI_VENDORS` in `constants.py`. The IPTC `digitalSourceType` **`algorithmicMedia`** (bare) is PROCEDURAL (an algorithm not trained on sampled data), NOT AI/ML generation, so it is deliberately absent from `IPTC_AI_MARKERS` — flagging it made `identify` assert AI + `has_invisible_target` True, scrubbing clean procedural content (it is a distinct token from `trainedAlgorithmicMedia`, so real "Made with AI" labels are unaffected; regression `test_metadata.py::...test_bare_algorithmic_media_not_flagged_ai`). Integrity-clash detection is high-precision by design (only hard generator stamps feed it, source-grouped independence). `_vendor_of` normalizes ByteDance/Canva/ElevenLabs/Black Forest Labs (as well as OpenAI/Google/... ) so their C2PA claims participate in the clash check; the generic **China TC260 AIGC label names no specific vendor**, so when a TC260-applying vendor (ByteDance, `_TC260_VENDORS`) is co-attributed the label is attributed to it (a legit Doubao image carrying its own TC260 label must NOT clash), while a NON-TC260 vendor next to a TC260 label still clashes as a laundering tell. The vendor normalization must not introduce clashes on clean compatibility samples.
- `watermark_registry.py` — the single catalog of known visible watermarks (gemini / doubao / jimeng / qwen / kling / samsung / runninghub / baidu / liblib / jimeng_pill). **Removal is LOCALIZE -> FILL for every mark:** each mark is localized to a binary full-frame footprint mask (a `Localization`), then ONE shared, swappable fill inpaints that mask via `fill(image, mask, backend=...)` (delegates to `region_eraser.erase`). Reverse-alpha (the old `original = (wm - a*logo)/(1-a)` inversion of a captured alpha map + thin residual inpaint) is GONE for ALL marks; why it was dropped is recorded in `docs/module-internals.md`. Backends: `cv2` (classical inpaint, no deps, the floor), `migan` (MI-GAN ONNX, light, the memory-tight pick where LaMa will not fit), `lama` (big-LaMa ONNX, best quality, heavier, auto-preferred when a learned backend is available); `auto` = LaMa > MI-GAN > cv2, best available. The captured alpha maps (`scripts/visible_alpha_solve.py`) are still used to DETECT the marks and to shape the mask, but NOT for pixel recovery. **`--mark auto` removes EVERY detected mark in one pass** via `remove_auto_marks(image, *, sensitivity="auto", provenance=frozenset(), backend="auto")` (marks coexist -- a Jimeng-basic image has the top-left pill AND the bottom-right wordmark; a single-strongest pick would leave one). **Three orthogonal axes:** `backend` (the fill), `sensitivity` (how hard to trust a borderline mark: `auto`/`strict`, see the `Sensitivity` literal), and `provenance` (vendor keys metadata confirms -- the evidence that drives `auto`). **Perception / decision / action are separated:** `_build_candidates(image)` runs every detector at BOTH trust levels (strict + relaxed) and packages raw verdicts + features into `Candidate`s (no policy); the pure arbiter `decide(candidates, Context(sensitivity, provenance)) -> [Decision]` makes every keep/drop call (per-mark `resolve_trust` + the assumed-trust floor + the pill gate) with no image/IO, so it is unit-testable in isolation; then each winner is localized -> filled. Do NOT put policy back into the engines (the one exception, the Gemini FP gate, stays in `gemini_engine` because `identify` shares that confidence). `detect_marks(..., provenance=frozenset())` stays strict (identify verdict, precision over recall); `KnownMark.remove/detect/localize(..., provenance: bool)` take the already-resolved boolean. **How `auto` decides (this is metadata-INDEPENDENT for recall):** the visual detectors are pixel-based and need no metadata; the recall gain comes from RELAXING the false-positive gate, not from metadata. `strict` never relaxes (clean images untouched); `auto` relaxes a mark only on same-product evidence -- metadata provenance for that vendor OR a confidently detected sibling mark of the SAME product (`_PRODUCT_OF`; Doubao and Jimeng are both bottom-right ByteDance but distinct products, so they do NOT cross-relax). **`resolve_trust` resolves TWO levels:** `confirmed` bypasses the engine's false-positive gate, and only `confirmed` has evidence naming THAT vendor, which is exactly what the bypass is contracted to require (`GeminiEngine.detect_watermark`'s `trust_provenance` docstring: "external metadata already proves this is a Google generation"). **Historical, kept as the reason the third level is gone:** a removed `assumed` level let `assume_ai` bypass the gate on the bare assertion an image is AI. It produced unacceptable false positives on clean camera images and was removed. A wrong relaxation only fills a small corner near-losslessly (the localize -> fill benign failure mode), which is what made a SMALL false-fire rate arguable; it was never a licence for a 60% one. Metadata provenance mapping (feeds `auto`, read by `cli._visible_provenance`): Google/Gemini C2PA issuer -> gemini; China-AIGC (TC260) label -> doubao/jimeng; `samsung_genai` -> samsung. **The `jimeng_pill` is capture-less.** It uses a synthetic silhouette and a fixed top-left footprint. `_keep_pill` requires either a confirmed sibling wordmark or Jimeng provenance with a flat footprint. Do not loosen the flatness guard. **`assume_ai` was REMOVED (2026-07-19); `--sensitivity` is now `auto`/`strict` only.** It relaxed every mark's FP gate on the bare assertion an image is AI -- which names no vendor and no location, exactly what the bypass requires -- and had no place in the model (detector finds -> remove; finds nothing -> leave alone; user SEES a mark -> act on that). It took `_ASSUMED_CONF_FLOOR` / `assumed_floor_ok` / the `assumed` trust level with it, collapsing the ladder to `strict`/`confirmed`, and `_keep_pill` lost its `sensitivity` arg. Recall/precision on the unbiased sample are unchanged, so nothing on the default path moved. **Replacement advice is per mark:** `erase --region` is sound by construction; `--mark <text-mark> --no-detect` is reasonable (forced mask = the real glyph blob, non-empty 13/13); **`--mark gemini --no-detect` is NOT** -- it falls back to a fixed slot thLine truncated
- `gemini_engine.py` — visible Gemini-sparkle detector + localizer (cv2/numpy, no GPU): top-K size-weighted fusion candidate selection (`_SELECT_TOPK`), corner-promote, false-positive gate (the provenance prior relaxes the gate + lowers the trust threshold when a Google/Gemini C2PA issuer confirms the vendor). **White-core rescue:** the FP gate demotes a low-gradient match (soft edges), but a real FAINT sparkle also has soft edges -- so the gate keeps a low-grad match that is a strong (conf ≥ `_SPARKLE_KEEP_CONF` 0.52), bright (margin), near-WHITE-core sparkle (`_core_saturation` ≤ `_SPARKLE_WHITE_SAT` 0.20): a real sparkle core is white, a clean bright corner that shape-matches (sky/sun) is colored. This recovers ~14/20 metadata-stripped faint sparkles under the DEFAULT strict/auto (no flag, no metadata) at ~1.25% clean false-fire (baseline 0.55%); the ~0.51-scoring bright-bg FPs stay demoted (below 0.52). A learned classifier on the SAME features was measured WORSE than the tuned gate (2026-07 tier-1: MLP 86.7% recall vs 90.8% at equal FP), so the heuristic stays; a patch-CNN with richer features is the only lever left (roadmapped P2, low expected value -- the wall is fundamental). Detection scores the top-K size-weighted matches by full fusion (spatial+gradient+variance) and keeps the highest — NOT the raw-NCC argmax, which re-admits the tiny-patch FPs the size weight suppresses (the osachub 2026-06-12 sub-0.85 corner-sparkle regression; see `docs/module-internals.md`). Keep the 0.85 corner-promote NCC gate; a margin/chroma-gated lower promote was measured and REJECTED 2026-06-11 (~33% FP on non-Google content). Removal is localize -> fill: `footprint_mask` returns the sparkle footprint (the captured alpha thresholded LOW so the faint halo is included, then dilated by a sparkle-relative margin), and the shared `watermark_registry.fill` inpaints it. The captured alpha maps are used only to detect and to shape the mask, not for pixel recovery. `detect_sparkle_confidence` reuses a process-wide `_shared_engine()` singleton (lru_cache) — the engine holds only constant assets (captures, alpha maps, a precomputed 16..118 template ladder) and takes the image as an arg, so do NOT reconstruct `GeminiEngine()` per call: that reloaded assets + recomputed alpha maps + rebuilt the template cache on every one of ~34k `identify` calls (−24% on the sparkle path once made a singleton, output byte-identical). `detect_watermark`/`footprint_mask` guard `image.size == 0` before `to_bgr`, and return an empty (detected=False) result when no template scale fits (short side < 16 px), rather than dereferencing an empty candidate list.
@@ -73,7 +73,7 @@ Compact map. The full per-module detail (design decisions, tuned thresholds, cal
- `invisible_watermark.py` — decodes the OPEN DWT-DCT watermarks (SD / SDXL / FLUX) via `imwatermark` (extra `detect`, pulls torch). Fragile two ways: (1) does not survive JPEG re-encode/resize; (2) **carrier-fragile on a broad class of pristine images** -- a clean encode->decode round-trip recovers 48/48 on chatgpt/firefly/random but FAILS (28-39/48, below the `_MATCH_48`=44 gate) on the FLUX fox, doubao, a flat FLUX generation, AND a clean synthetic flat fill with no watermark. The failure does NOT track texture; it goes with a degenerate **all-ones decode that is a CARRIER ARTIFACT, not a watermark** (synthetic clean image reproduces it). So `detect_invisible_watermark` is **positive-only**: trust a hit; a `None` is inconclusive unless a same-carrier positive-control embed first recovers >=44. Verified 2026-06-19; full caveat in `docs/watermarking-landscape.md`.
- `trustmark_detector.py` — Adobe TrustMark open decoder (extra `trustmark`). Do NOT remove the JPEG re-encode false-positive gate — a lone TrustMark hit without it is almost always content noise.
- `noai/watermark_remover.py` — `WatermarkRemover` with four diffusion pipelines selected by the explicit `pipeline` ctor arg, never inferred from `model_id`: `sdxl` (plain SDXL img2img), `controlnet` (SDXL + canny ControlNet, **the compatibility and cost DEFAULT since 2026-06-09**), `qwen` (Qwen-Image 20B img2img), and `qwen-zimage` (delegates to the fixed two-stage runtime below). Removal comes from img2img strength. Both SDXL loaders pass `add_watermarker=False`; diffusers otherwise re-stamps an open SDXL DWT-DCT watermark. Qwen's certified floors and fidelity results remain as documented below. The base `qwen` profile stays the manual text lane; `qwen-zimage` is the recommended high-quality manual mode, especially for face identity, while remaining experimental rather than an auto-router.
- `noai/qwen_zimage_pipeline.py` — CUDA-only Qwen-Image-2512 Lightning + DiffSynth Canny full-frame regeneration, followed by YuNet face boxes, SAM masks, and Z-Image Turbo regeneration from original face crops. Ports both adaptive denoise formulas from Synthid-Bypass v2. The active upstream face path is YOLO + SAM; this port keeps its center-point and box prompts, proposal selection, detector-box intersection, crop factor, and paste feather while replacing YOLO with YuNet to avoid an AGPL runtime. The port is architectural, not bit-identical: it uses full safetensors instead of GGUF, DiffSynth samplers instead of the Comfy sampler pairs, and no latent detailer feather. DiffSynth input pixels, Canny control, and explicit dimensions must share the same /16 grid. SAM pixels follow the model dtype, geometric prompts stay float32, and bfloat16 outputs convert through float32 before NumPy. The YuNet download verifies its SHA-256. Separate `qwen-zimage` extra; fixed four-step global and eight-step face schedules; no custom `--model`; `--tile` applies only to the global stage, followed by one full-frame face stage; CLI adaptive polish defaults off. GPUs with at least 64 GiB VRAM keep the face stack resident, while smaller devices retain CPU offload. Fixed prompt embeddings are cached only when they do not depend on an edit image. The exact seed-0 release candidate passed the corresponding provider-oracle checks; broader seeded text, face, and tiled-output certification remains open.
- `noai/qwen_zimage_pipeline.py` — CUDA-only Qwen-Image-2512 Lightning + DiffSynth Canny full-frame regeneration, followed by YuNet face boxes, SAM masks, and Z-Image Turbo regeneration from original face crops. Ports both adaptive denoise formulas from Synthid-Bypass v2, then scales the face result by 0.5 because this runtime lacks the reference latent noise-mask feather and uses a different sampler/compositing path; paired face evaluations and both provider oracles certified the scaled value while the global stage stayed unchanged. The active upstream face path is YOLO + SAM; this port keeps its center-point and box prompts, proposal selection, detector-box intersection, crop factor, and paste feather while replacing YOLO with YuNet to avoid an AGPL runtime. The port is architectural, not bit-identical: it uses full safetensors instead of GGUF, DiffSynth samplers instead of the Comfy sampler pairs, and no latent detailer feather. DiffSynth input pixels, Canny control, and explicit dimensions must share the same /16 grid. SAM pixels follow the model dtype, geometric prompts stay float32, and bfloat16 outputs convert through float32 before NumPy. The YuNet download verifies its SHA-256. Separate `qwen-zimage` extra; fixed four-step global and eight-step face schedules; no custom `--model`; `--tile` applies only to the global stage, followed by one full-frame face stage; CLI adaptive polish defaults off. `InvisibleEngine.preload(global_only=True)` warms Qwen and YuNet while leaving Z-Image and SAM lazy until a face is detected; the default `preload()` remains a full preload. GPUs with at least 64 GiB VRAM keep the face stack resident, while smaller devices retain CPU offload. Fixed prompt embeddings are cached only when they do not depend on an edit image. The exact seed-0 release candidate passed the corresponding provider-oracle checks; broader seeded text, face, and tiled-output certification remains open.
- `noai/tiling.py` — sliding-window tiled diffusion for large inputs (CLI `--tile`). The SDXL, ControlNet, and base Qwen paths branch to `run_tiled` when `tile` is set AND the long side exceeds `tile_size`, refactoring the single-pass `_generate` into a per-tile `_generate_one` (the ControlNet edge map is rebuilt per tile inside it). `qwen-zimage` instead calls `run_tiled` only around its global Qwen stage, blends the tiles, then runs one full-frame face stage. Pure helpers `plan_tiles` (uniform-size tiles, last one flush to the edge) and `feather_weights` (strictly-positive separable taper -> partition-of-unity blend) are unit-tested without the model. Also home to `feather_region_composite(base, regenerated, box, *, feather)` — the pure region-targeted compositor for **AI-enhanced composites** (`ai_source_kind == "enhanced"`): blends the regenerated AI box back over the original with a feathered seam, leaving the real photo OUTSIDE the box pixel-exact. It backs `WatermarkRemover.remove_watermark(region=...)` (regenerate ONLY the AI region, not the whole frame); the no-model lossless region path stays `region_eraser.erase`. New tile/region-blend tuning goes in these pure helpers; do not inline blend math into the runner.
- `auto_config.py` + the content-detection layer were REMOVED 2026-06-09; `--auto` is a deprecated no-op (controlnet is the default pipeline and adaptive polish is ON by default for the original profiles, while `qwen-zimage` leaves it off to preserve the upstream two-stage output).
- `upscaler.py` — optional Real-ESRGAN pre-diffusion super-resolution for small inputs (extra `esrgan`, spandrel only). Manual opt-in; the default `--upscaler` stays `lanczos` and the engine always falls back to Lanczos on absence/error. ESRGAN can degrade faces and thin text.
+4 -1
View File
@@ -178,7 +178,10 @@ cannot parse the input.
`remove_ai_metadata` may copy an undecodable file through unchanged instead of
raising. User facing callers must use `strip_and_verify` and inspect its
surviving marker mapping before reporting success. The CLI does this.
surviving marker mapping before reporting success. `strip_and_verify` recovers
when `image_io` can still decode the raster by normalizing the container and
checking again. A truly undecodable file still reports the surviving markers.
The CLI uses this verified path.
### Sixteen bit PNG output is not preserved
+16 -1
View File
@@ -114,7 +114,10 @@ Key contracts:
- The low-level remover is fail-safe and can copy an undecodable file through
unchanged.
- A caller that reports success must use `strip_and_verify`, which scans the
written output for surviving markers.
written output for surviving markers. If the metadata-preserving decoder
rejected the container but `image_io` can still decode its raster,
`strip_and_verify` normalizes that raster and scans again. A truly undecodable
file keeps the surviving-marker result.
Detection and removal must stay in parity. A new marker is incomplete until the
scanner can find it, the remover can reach every supported placement, and a
@@ -266,6 +269,10 @@ is the source of truth for:
- the minimum viable step calculation.
The current profiles are `controlnet`, `sdxl`, `qwen`, and `qwen-zimage`.
For serverless cold starts, `InvisibleEngine.preload(global_only=True)` loads the
mandatory Qwen stage and YuNet while leaving the optional Z-Image and SAM face
stack lazy until a face is detected. The default `preload()` still loads every
stage.
`default` is a legacy alias for `sdxl`. There is no content-dependent automatic
router.
@@ -308,6 +315,14 @@ The profile rejects a custom model identifier. Its global and face model stack
is fixed by the implementation. When tiling is enabled, only the global stage
is tiled; the face stage runs once after the tiles are blended.
The resolution and largest-face adaptive formulas remain exact ports of the
reference workflow. The face stage applies half the reference result because
this port uses a different sampler and composites regenerated SAM pixels rather
than using the reference latent inpaint mask and noise feather. Paired face
evaluations favored this scale on identity, perceptual distance, and full-image
similarity, and the exact OpenAI and Gemini candidates both passed their
matching provider oracle. The global stage stays unchanged.
Regression coverage:
- [`test_qwen_zimage_pipeline.py`](../tests/test_qwen_zimage_pipeline.py)
+4 -1
View File
@@ -119,7 +119,10 @@ if has_ai_metadata(source):
Use `strip_and_verify` when your application reports that stripping succeeded.
It checks the written output and returns `(output_path, surviving_markers)`.
Treat a nonempty `surviving_markers` mapping as a failure.
When the first strip leaves markers in a malformed but raster-decodable image,
it normalizes the container through `image_io` and checks again. That recovery
path preserves the pixels but drops standard metadata. Treat a nonempty
`surviving_markers` mapping as a failure.
`remove_ai_metadata` is the lower level fail-safe transformer. It may copy an
undecodable input through unchanged, so its return alone must not be presented
+19 -3
View File
@@ -43,6 +43,7 @@ PyPI API token from the repository.
waits for the matching source distribution to appear on PyPI, then:
- updates the Homebrew tap formula URL and SHA-256;
- updates the repository conda recipe version and source-distribution SHA-256;
- triggers a factory rebuild of the Hugging Face Space.
The workflow can also be started manually with an optional version input.
@@ -52,6 +53,12 @@ If a distribution job fails because a repository or Hugging Face credential is
invalid, rotate the corresponding GitHub secret and rerun the failed job. A
manual Homebrew formula update is the fallback when its automation is blocked.
The conda job uses the published artifact rather than a locally built archive
as the hash source and commits the resulting recipe change to `main`. Runtime
dependency mapping remains review-controlled: keep it aligned with the core
dependencies in `pyproject.toml`, and document any conda-forge package that is
unavailable and must be omitted.
## Source distribution boundary
The wheel includes the package under `src/`.
@@ -69,10 +76,16 @@ The package uses hatchling through the unpinned `hatchling` build requirement in
## Other channels
The repository includes a conda recipe under `packaging/conda/recipe.yaml`.
Keep its runtime dependencies aligned with `pyproject.toml`.
The ComfyUI nodes are maintained and versioned separately from this package.
A library release does not by itself publish a new ComfyUI node version.
The ComfyUI nodes are maintained and versioned in their own repository. After
the matching source distribution appears on PyPI, `distribute.yml` dispatches
that repository's sync workflow with the exact library version and waits for it
to finish. The sync updates the dependency floor, runs compatibility tests,
bumps the node patch version, and publishes to the ComfyUI Registry only when
those tests pass. Its daily schedule remains as a recovery path if a release
dispatch is interrupted. The `COMFYUI_RELEASE_TOKEN` repository secret is a
fine-grained token limited to the ComfyUI node repository, with Actions read and
write access.
## Release verification
@@ -82,4 +95,7 @@ After publication, verify:
- the package version matches the tag;
- the Homebrew formula points to the new source distribution;
- the distribution workflow completed successfully;
- the repository's conda recipe matches the published version and source
distribution;
- the ComfyUI Registry node requires the new library version;
- a clean install can run `remove-ai-watermarks --version`.
+11 -8
View File
@@ -1,7 +1,7 @@
schema_version: 1
context:
version: "0.10.1"
version: "0.20.1"
python_min: "3.10"
package:
@@ -10,7 +10,7 @@ package:
source:
url: https://pypi.org/packages/source/r/remove-ai-watermarks/remove_ai_watermarks-${{ version }}.tar.gz
sha256: ec92b450363d947cd897f0b75c18e75c2988ff79314ea247333ba892470b5a77
sha256: da2370d8b3a8386a2b0483e7bea6bb3c218b2e65cab5fab289eed9ea26c75014
build:
noarch: python
@@ -25,11 +25,15 @@ requirements:
run:
- python >=${{ python_min }}
- pillow >=10.0.0
- pillow-heif >=0.13.0
- piexif >=1.1.3
- numpy >=1.24.0
- py-opencv >=4.8.0
- click >=8.0.0
- python-dotenv >=1.0.0
# c2pa-python is a core PyPI dependency but is not packaged on conda-forge.
# The guarded import falls back to the built-in C2PA byte scanner when it is
# absent. Add it here once a c2pa-python feedstock exists.
tests:
- python:
@@ -48,12 +52,11 @@ about:
homepage: https://github.com/wiltodelta/remove-ai-watermarks
summary: Remove visible and invisible AI watermarks from images
description: |
Detect and remove visible AI watermarks (Gemini / Nano Banana sparkle,
ByteDance Doubao and Jimeng, Samsung Galaxy AI) and strip AI-provenance
metadata (C2PA, EXIF, IPTC, PNG text chunks) from images. The core package
covers the identify / metadata / visible / erase command surface; optional
pip extras add SynthID diffusion removal and additional invisible-watermark
detectors.
Detect and remove registered visible AI-provenance marks and strip
AI-provenance metadata (C2PA, EXIF, IPTC, and PNG text chunks) from images.
The core package covers the identify, metadata, visible, and erase command
surface. Optional pip extras add SynthID diffusion removal and additional
invisible-watermark detectors.
license: Apache-2.0
license_file: LICENSE
repository: https://github.com/wiltodelta/remove-ai-watermarks
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "remove-ai-watermarks"
version = "0.20.0"
version = "0.20.2"
description = "AI watermark remover: strip visible and invisible AI watermarks (Gemini / Nano Banana sparkle, SynthID) and provenance metadata (C2PA, EXIF) from images"
readme = "README.md"
requires-python = ">=3.10.1"
+53
View File
@@ -0,0 +1,53 @@
"""Update the conda recipe to a published package version and sdist hash."""
from __future__ import annotations
import argparse
import logging
import re
from pathlib import Path
log = logging.getLogger(__name__)
_VERSION_LINE = re.compile(r'^ version: "[^"]+"$', re.MULTILINE)
_SHA256_LINE = re.compile(r"^ sha256: [0-9a-f]{64}$", re.MULTILINE)
def update_recipe(text: str, *, version: str, sha256: str) -> str:
"""Return recipe text with exactly one context version and source hash updated."""
if not version or any(char.isspace() for char in version):
raise ValueError("version must be a non-empty value without whitespace")
if re.fullmatch(r"[0-9a-f]{64}", sha256) is None:
raise ValueError("sha256 must be 64 lowercase hexadecimal characters")
updated, version_count = _VERSION_LINE.subn(f' version: "{version}"', text)
updated, sha_count = _SHA256_LINE.subn(f" sha256: {sha256}", updated)
if version_count != 1 or sha_count != 1:
raise ValueError(
"expected exactly one context version and one source sha256 "
f"(found version={version_count}, sha256={sha_count})"
)
return updated
def main() -> None:
"""Update the selected recipe in place."""
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--version", required=True)
parser.add_argument("--sha256", required=True)
parser.add_argument(
"--recipe",
type=Path,
default=Path("packaging/conda/recipe.yaml"),
)
args = parser.parse_args()
original = args.recipe.read_text()
updated = update_recipe(original, version=args.version, sha256=args.sha256)
args.recipe.write_text(updated)
log.info("Updated %s to version %s", args.recipe, args.version)
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
main()
+1 -1
View File
@@ -25,7 +25,7 @@ _os.environ.setdefault("TRANSFORMERS_VERBOSITY", "error")
_warnings.filterwarnings("ignore", message=r".*ImageProcessorFast.*")
__version__ = "0.20.0"
__version__ = "0.20.2"
__all__ = ["__version__", "remove_visible", "visible_provenance"]
+8 -3
View File
@@ -139,9 +139,14 @@ class InvisibleEngine:
)
self._progress_callback = progress_callback
def preload(self) -> None:
"""Eagerly load the pipeline so download progress is visible."""
self._remover.preload()
def preload(self, *, global_only: bool = False) -> None:
"""Eagerly load the pipeline so download progress is visible.
For ``qwen-zimage``, ``global_only=True`` loads the mandatory Qwen stage
and leaves the optional Z-Image and SAM face stack lazy until a face is
detected. Other profiles have no optional stage and ignore the flag.
"""
self._remover.preload(global_only=global_only)
def _esrgan_upscale(self, image: Any, target: tuple[int, int]) -> Any:
"""Upscale a PIL image to ``target`` with Real-ESRGAN, else Lanczos.
+23
View File
@@ -1091,9 +1091,32 @@ def strip_and_verify(
exposed this when `metadata --remove` reported success while the output still read
as AI.
If markers survive but OpenCV can still decode the raster, the verified path
normalizes the container through :mod:`image_io` and scans once more. This
intentionally drops standard metadata on that recovery path because the original
container is too malformed for the metadata-preserving decoder.
Returns ``(output_path, surviving_markers)``; an empty mapping means a real strip.
"""
out = remove_ai_metadata(source_path, output_path, keep_standard=keep_standard)
remaining = get_ai_metadata(out)
if not remaining:
return out, {}
import cv2
from remove_ai_watermarks import image_io
image = image_io.imread(out, cv2.IMREAD_UNCHANGED)
if image is None:
return out, remaining
logger.warning(
"AI metadata survived stripping; normalizing decodable raster: path=%s fields=%s",
out,
",".join(sorted(remaining)),
)
if not image_io.imwrite(out, image):
raise OSError(f"Failed to normalize image after incomplete metadata stripping: {out}")
return out, get_ai_metadata(out)
@@ -10,7 +10,8 @@ This profile ports the two-stage architecture used by cebeuq/Synthid-Bypass:
The runtime intentionally uses permissively licensed YuNet instead of the reference
workflow's Ultralytics detector. All diffusion and segmentation models remain the same
model families and the denoise formulas are direct ports of the reference custom node.
model families. The adaptive formulas are direct ports, while the face result is scaled
for this runtime's different sampler and mask-compositing path.
"""
# DiffSynth, torch, transformers, and cv2 expose mostly untyped tensor/array APIs.
@@ -64,6 +65,11 @@ GLOBAL_CFG = 1.0
FACE_CFG = 1.0
GLOBAL_CONTROLNET_SCALE = 1.0
RESIDENT_FACE_MODEL_MIN_VRAM_GIB = 64.0
# The reference face denoise assumes its ComfyUI detailer sampler, latent
# noise-mask feather, and inpaint path. Applying that value unchanged to this
# DiffSynth crop-regeneration port over-processes faces. Paired public-fixture
# measurements and both provider oracles certified half the reference value.
FACE_DENOISE_SCALE = 0.5
# The source graph uses normalized Canny thresholds 0.05 and 0.25. OpenCV takes
# byte thresholds, so round 255*x to the matching integer values.
@@ -767,12 +773,13 @@ class QwenZImagePipeline:
log.warning("SAM face-mask refinement failed (%s); using box-derived ellipse masks", exc)
return _ellipse_masks(boxes, image.size)
def preload(self) -> None:
"""Eagerly load both diffusion stages and the face segmentation model."""
def preload(self, *, global_only: bool = False) -> None:
"""Eagerly load the mandatory stage and, by default, the face stack."""
self._load_qwen()
self._load_zimage()
self._load_sam()
_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()
@@ -892,7 +899,7 @@ class QwenZImagePipeline:
self._progress("No faces detected; keeping the Qwen global result.")
return global_result
masks = self._sam_masks(image, boxes)
face_strength = largest_face_denoise(boxes, image.size)
face_strength = largest_face_denoise(boxes, image.size) * FACE_DENOISE_SCALE
return self._run_faces(
image,
global_result,
@@ -442,10 +442,13 @@ class WatermarkRemover:
# ── Preload ──────────────────────────────────────────────────────
def preload(self) -> None:
"""Eagerly load the pipeline so download progress bars are visible."""
def preload(self, *, global_only: bool = False) -> None:
"""Eagerly load the pipeline so download progress bars are visible.
``global_only`` applies to qwen-zimage, whose face stage is optional.
"""
if self.model_profile == QWEN_ZIMAGE_PROFILE:
self._load_qwen_zimage_pipeline().preload()
self._load_qwen_zimage_pipeline().preload(global_only=global_only)
elif self.model_profile == "qwen":
self._load_qwen_pipeline()
elif self.model_profile == "controlnet":
+8
View File
@@ -35,6 +35,14 @@ class TestInvisibleEngineInit:
# SDXL base became the default in May 2026 (defeats SynthID v2).
assert InvisibleEngine.DEFAULT_MODEL_ID == "stabilityai/stable-diffusion-xl-base-1.0"
def test_preload_forwards_global_only(self):
engine = object.__new__(InvisibleEngine)
engine._remover = SimpleNamespace(preload=lambda **kwargs: setattr(engine, "_preload_kwargs", kwargs))
engine.preload(global_only=True)
assert engine._preload_kwargs == {"global_only": True}
class TestNativeOutputSize:
"""Model-side latent-grid rounding must not change the public output size."""
+56
View File
@@ -22,6 +22,7 @@ from remove_ai_watermarks.metadata import (
iptc_ai_system,
remove_ai_metadata,
samsung_genai,
strip_and_verify,
synthid_source,
xai_signature,
)
@@ -29,6 +30,28 @@ from remove_ai_watermarks.metadata import (
# Real, committed C2PA sample images used to ground the SynthID-source tests.
SAMPLES_DIR = Path(__file__).resolve().parent.parent / "data" / "fixtures" / "provenance"
def _png_chunk(kind: bytes, payload: bytes, *, corrupt_crc: bool = False) -> bytes:
"""Encode one PNG chunk, optionally corrupting its CRC."""
import zlib
crc = zlib.crc32(kind + payload) & 0xFFFFFFFF
if corrupt_crc:
crc ^= 1
return struct.pack(">I", len(payload)) + kind + payload + struct.pack(">I", crc)
def _corrupt_c2pa_png(path: Path) -> Path:
"""Write a synthetic PNG that Pillow rejects but OpenCV can decode."""
Image.new("RGB", (32, 32), (80, 120, 160)).save(path)
png = path.read_bytes()
idat_start = png.index(b"IDAT") - 4
fake_c2pa = _png_chunk(b"caBX", b"\x00\x00\x00\x10jumbsynthetic-c2pa")
bad_exif = _png_chunk(b"eXIf", b"Exif\x00\x00synthetic", corrupt_crc=True)
path.write_bytes(png[:idat_start] + fake_c2pa + bad_exif + png[idat_start:])
return path
# ── Key detection ───────────────────────────────────────────────────
@@ -276,6 +299,39 @@ class TestHasAiMetadata:
assert result == out
assert out.read_bytes() == truncated.read_bytes()
def test_strip_and_verify_normalizes_decodable_copy_through(self, tmp_path: Path):
"""A decodable raster must not retain C2PA after verified stripping."""
import numpy as np
from remove_ai_watermarks import image_io
src = _corrupt_c2pa_png(tmp_path / "source.png")
out = tmp_path / "clean.png"
assert has_ai_metadata(src)
before = image_io.imread(src)
assert before is not None
result, remaining = strip_and_verify(src, out)
assert result == out
assert remaining == {}
assert not has_ai_metadata(out)
after = image_io.imread(out)
assert after is not None
assert np.array_equal(after, before)
def test_strip_and_verify_reports_markers_in_undecodable_copy_through(self, tmp_path: Path):
"""A truly undecodable file keeps the established fail-safe result."""
src = _corrupt_c2pa_png(tmp_path / "source.png")
src.write_bytes(src.read_bytes()[: src.stat().st_size // 2])
out = tmp_path / "copy.png"
result, remaining = strip_and_verify(src, out)
assert result == out
assert "c2pa_manifest" in remaining
assert out.read_bytes() == src.read_bytes()
class TestC2paMarkerIn:
"""The C2PA presence check requires a JUMBF wrapper or the C2PA uuid box, so
+65
View File
@@ -435,11 +435,76 @@ def test_qwen_tiling_runs_global_tiles_then_one_full_frame_face_stage(monkeypatc
assert all(strength == pytest.approx(resolution_adaptive_denoise(1500, 1500)) for _, strength, _ in global_calls)
assert all(seed == 0 for _, _, seed in global_calls)
face_stage.assert_called_once()
# Keep this literal independent from the runtime helper: the port deliberately
# uses half the upstream face denoise because it lacks the reference latent
# noise-mask feather and uses a different sampler/runtime.
assert face_stage.call_args.kwargs["strength"] == pytest.approx(0.0296296296)
assert face_stage.call_args.args[0] is image
assert face_stage.call_args.args[1].size == image.size
assert result.size == image.size
def test_global_only_preload_skips_face_models(monkeypatch):
from remove_ai_watermarks.noai.qwen_zimage_pipeline import QwenZImagePipeline
runtime = QwenZImagePipeline(device="cuda", torch_dtype="bf16")
qwen = MagicMock()
zimage = MagicMock()
sam = MagicMock()
yunet = MagicMock()
monkeypatch.setattr(runtime, "_load_qwen", qwen)
monkeypatch.setattr(runtime, "_load_zimage", zimage)
monkeypatch.setattr(runtime, "_load_sam", sam)
monkeypatch.setattr(
"remove_ai_watermarks.noai.qwen_zimage_pipeline._yunet_model_path",
yunet,
)
runtime.preload(global_only=True)
qwen.assert_called_once_with()
zimage.assert_not_called()
sam.assert_not_called()
yunet.assert_called_once_with()
def test_full_preload_still_loads_face_models(monkeypatch):
from remove_ai_watermarks.noai.qwen_zimage_pipeline import QwenZImagePipeline
runtime = QwenZImagePipeline(device="cuda", torch_dtype="bf16")
qwen = MagicMock()
zimage = MagicMock()
sam = MagicMock()
yunet = MagicMock()
monkeypatch.setattr(runtime, "_load_qwen", qwen)
monkeypatch.setattr(runtime, "_load_zimage", zimage)
monkeypatch.setattr(runtime, "_load_sam", sam)
monkeypatch.setattr(
"remove_ai_watermarks.noai.qwen_zimage_pipeline._yunet_model_path",
yunet,
)
runtime.preload()
qwen.assert_called_once_with()
zimage.assert_called_once_with()
sam.assert_called_once_with()
yunet.assert_called_once_with()
def test_watermark_remover_forwards_global_only_preload(monkeypatch):
from remove_ai_watermarks.noai.watermark_remover import WatermarkRemover
runtime = MagicMock()
remover = WatermarkRemover.__new__(WatermarkRemover)
remover.model_profile = "qwen-zimage"
monkeypatch.setattr(remover, "_load_qwen_zimage_pipeline", lambda: runtime)
remover.preload(global_only=True)
runtime.preload.assert_called_once_with(global_only=True)
def test_qwen_zimage_rejects_runtime_knobs_that_change_fixed_graph(tmp_path, monkeypatch):
from remove_ai_watermarks.noai.watermark_remover import WatermarkRemover
+41
View File
@@ -0,0 +1,41 @@
"""Tests for the release-time conda recipe synchronizer."""
from __future__ import annotations
import pytest
from scripts.sync_conda_recipe import update_recipe
_OLD_SHA = "a" * 64
_NEW_SHA = "b" * 64
_RECIPE = f"""\
schema_version: 1
context:
version: "1.2.3"
source:
sha256: {_OLD_SHA}
"""
def test_update_recipe_replaces_version_and_hash() -> None:
updated = update_recipe(_RECIPE, version="2.0.0", sha256=_NEW_SHA)
assert ' version: "2.0.0"' in updated
assert f" sha256: {_NEW_SHA}" in updated
assert "1.2.3" not in updated
assert _OLD_SHA not in updated
@pytest.mark.parametrize("sha256", ["", "A" * 64, "f" * 63, "not-a-hash"])
def test_update_recipe_rejects_invalid_hash(sha256: str) -> None:
with pytest.raises(ValueError, match="sha256"):
update_recipe(_RECIPE, version="2.0.0", sha256=sha256)
def test_update_recipe_rejects_ambiguous_recipe() -> None:
duplicate = _RECIPE + f"\nsource_two:\n sha256: {_OLD_SHA}\n"
with pytest.raises(ValueError, match="exactly one"):
update_recipe(duplicate, version="2.0.0", sha256=_NEW_SHA)
Generated
+1 -1
View File
@@ -3187,7 +3187,7 @@ wheels = [
[[package]]
name = "remove-ai-watermarks"
version = "0.20.0"
version = "0.20.2"
source = { editable = "." }
dependencies = [
{ name = "c2pa-python" },