The visible-mark path had grown three copies of one ladder sweep, four
near-identical `detect` arms, and four hand-rolled `footprint_mask` overrides;
mark knowledge sat in five hand-maintained tables across three modules; and the
flagship `all`/`batch` pipeline existed only in cli.py, written twice with
divergent behavior.
Detection is now one measurement. `_ladder_best` replaces the three sweeps,
`_scan`/`_verdict` replace the four arms, and the winning box travels to the
mask on `TextMarkDetection.match_box` instead of being swept a second time.
`detect_both` returns the strict and relaxed verdicts from one scan, which
halves the arbiter's perception cost (260 -> 130 matchTemplate calls on a 2048²
image, verdicts identical field for field). A per-mark demotion goes in the new
`_post_gate` hook, never in a `detect` override -- an override is invisible to
the single-pass path, which is how the RunningHub and Yuanbao anchor gates
briefly stopped applying.
Everything about a mark is now one registry row: product, label regime, the
platform sentence `identify` reports, the metadata signals that confirm it, and
its TC260 producer codes. `identify._VISIBLE_MARK_PLATFORM`, the signal mapping
in `api.visible_provenance`, `_PRODUCT_OF` and the pill veto are derived from
those rows.
`api.remove_all` / `api.remove_batch` are the library form of the `all` and
`batch` commands; the CLI is a wrapper that owns console text and exit codes.
Progress is a `(stage, detail)` pair of stable tokens, so the CLI keys its
wording off structure rather than parsing the library's prose back.
Two intentional behavior changes, both verified against a recorded 811-image
sample of detector verdicts, removal-mask hashes, arbiter decisions and
`identify` reports:
* A TC260 label now relaxes the vendor its `ContentProducer` names rather than
ByteDance's pair on every China-AIGC image. 333 of 811 samples move; on 185
of them the previously relaxed pair was simply the wrong vendor, and the
mark actually present never reached the relaxed gate its own
`provenance_ncc_factor` was calibrated for.
* A confident LibLibAI detection suppresses the Jimeng pill, like every other
TC260 product's mark. It was registered alongside RunningHub and Baidu, both
of which were added to the hand-written veto list, and it was not. 1 sample
moves, and it is exactly the co-firing case.
Nothing else in that record changes: detector verdicts, mask hashes and
`identify` verdicts are byte-identical, and all 200 calibration constants are
untouched.
Also: `aigc_label` and friends plus `extract_c2pa_info` are memoized on
(path, mtime_ns, size) -- size because this package rewrites in place; the
native TC260 container readers route on magic bytes instead of the file
extension, so a mislabeled AVI or FLV is no longer invisible; `identify` shares
one pixel decode between the DWT-DCT and visible stages (TrustMark keeps its own
Pillow decode, which is not substitutable); and the six `stabilize_*` video
wrappers collapse into one policy table.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An adversarial review of 52b2c11 (five independent audits, each finding put to
two skeptics, plus a completeness critic) found four defects that commit
introduced and several stale claims it should have caught.
The install hint no longer installs -- again. Folding five hints into one
INVISIBLE_EXTRA constant dropped the shell quoting the originals had, so the
printed remediation was `pip install remove-ai-watermarks[qwen-zimage]`. Bare
brackets are a glob in zsh, the macOS default shell: it dies with "no matches
found" before pip runs. That is the exact failure 52b2c11 existed to stop
producing, reintroduced in a different form by a bulk replace. The constant is
quoted now, and a test asserts the quotes rather than the bare substring -- the
old assertions passed either way, which is why nothing caught it.
Three tests were not guarding what they claimed:
- The commit's headline behaviour change, per-profile polish resolution inside
the engine, had no test at all. Rebinding resolve_adaptive_polish to the
pre-commit `bool(value)` left the full suite green. Now covered by a test that
drives the real engine and observes whether humanizer.adaptive_polish ran;
that mutation now fails it.
- TestAvailability still asserted the pre-commit (torch, diffusers) contract, so
in a diffusion-only environment it was simply wrong, and comparing each gate to
a tuple copied from itself could never catch the two gates disagreeing -- the
drift the shared REMOVAL_MODULES was introduced to prevent. Replaced with a
test that simulates each module's absence and requires BOTH gates to close.
- Both CUDA-refusal guards skipped in every environment, including CI: they were
gated on the diffusion stack, which no CI job installs. The refusal fires
before any torch attribute is read, so they now run everywhere; only the dtype
assertion keeps its skip.
Also: the retired-knob test covered `invisible` but not `all` or `batch`, though
all three declared those options separately; and smoke_matrix.py still called
remove_watermark(region=...), a parameter 52b2c11 deleted, with the resulting
TypeError swallowed into a skip by a broad except.
Stale documentation the previous sweep missed: known-limitations still described
an MPS out-of-memory fallback and a lighter-pipeline escape that no code can
produce; module-internals declared Canny thresholds of 100/200 as a compatibility
contract while the code uses 13/64, attributed enable_model_cpu_offload to
deleted profiles, and still warned that the engine and CLI defaults differ (this
commit's predecessor made them identical); cli.md gated `all` on the `diffusion`
extra; python-api claimed "cuda" was the only accepted explicit device when
"auto" is too. The claim that `device` is not a parameter was wrong in both
module-internals and .claude/rules/development.md -- it is one, deliberately, and
now says so. `--cpu-offload` help and the pipeline's CUDA guard both still
pointed at MPS.
Not fixed here, reported instead -- both are outside this repo:
- ComfyUI-remove-ai-watermarks nodes.py:332 passes num_inference_steps and
guidance_scale (plus min_resolution/upscaler from bf4bfc1). distribute.yml's
comfyui job runs on every release and fails the release if the node sync fails,
so 0.25.0 needs that node updated first.
- raiw-app modal_app.py:422-425 forwards the same two kwargs into
remove_watermark. Latent: it is pinned to 1a77e24 and nothing supplies a value
today, so it fires on the next pin bump.
pre-commit: 1) maintain.sh - exit 0 (1093 tests, Pyright 0 errors, no
vulnerabilities); 2) /simplify - not re-run, this commit is the applied output of
a five-dimension adversarial review; 3) docs sync - grepped MPS/mps, the extras
names and every symbol touched across README, docs/, scripts/, .claude/; updated
6 docs; 4) CLAUDE.md - corrected the device claim in .claude/rules/development.md
and added the shell-quoting rule
Verified by execution, not assertion: smoke_matrix --quick 51 pass / 0 fail,
_knob_rows driven directly 10 pass / 0 fail / 7 skip (no CUDA), the install hint
rendered and round-tripped through zsh, and each new test confirmed to fail
under the mutation it is meant to catch.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The CLI still advertised --model, --steps, --guidance-scale, --device and a
deprecated --auto. Each pinned a value the two surviving profiles fix -- the
model stack, the per-stage distilled schedule, CFG 1.0, CUDA -- so the only
outcome any of them had was an error raised several frames below the caller,
under a message naming an internal profile. A flag whose sole result is a
refusal is worse than no flag: it advertises a capability that does not exist,
and it lets a wrapper thread a value that will silently do nothing. They are
gone from the parser, from InvisibleEngine, and from WatermarkRemover, so the
failure is now a TypeError or a Click "No such option" at the point the caller
can act on.
The install hint was wrong in the same way. is_available() checked torch and
diffusers, then told the user to install [diffusion] -- which contains neither
DiffSynth nor the Z-Image face stage both profiles run. Following the advice
produced a second, different failure. The module list and the extra name now
live once in watermark_profiles (REMOVAL_MODULES, INVISIBLE_EXTRA) and are read
by both the CLI gate and the remover's precondition, which cannot drift apart
because they are the same tuple.
The adaptive-polish default moved out of the argument parser. It was resolved by
reading Click's parameter source, which put per-profile data in the CLI layer,
left the engine declaring the opposite default (False vs True) so a library
caller and a CLI caller on one profile got different output, and lost the polish
entirely for anything that supplies the flag non-interactively. The flag is now
tri-state (default=None) and resolve_adaptive_polish owns the per-profile
answer. The seed follows the same rule: the CLI stopped pre-resolving it.
Dead code removed with it: six scan_*_video wrappers and the _scan_video helper
none of them had a caller for, PNG_METADATA_KEYS, feather_region_composite and
the remover region path that was only reachable from a no-caller convenience
wrapper, remove_watermark_batch on both layers, try_empty_device_cache, the
_generate/_run_qwen_zimage pass-through pair, self.model_id, and the _internal
PEP 562 shim that no caller ever went through. get_device now answers cuda or
cpu only: mps and xpu travelled one frame to the same CUDA-only refusal while
costing a device probe each, and that refusal now names the resolved device, so
device=None on a CUDA-less host says 'cpu' rather than 'None'. The XPU wheel
index went with them.
Docs: README, cli, installation, python-api, supported-signals,
known-limitations and module-internals all still described the removed profiles,
the CPU/MPS/XPU ladder, a `default`->`sdxl` alias, and the wrong extra.
known-limitations still listed the retired SDXL strength ladder as current.
scripts/smoke_matrix.py and real_examples_e2e.py drove --device mps.
Next release is 0.25.0, not a patch: this removes public parameters and
narrows a published extra on top of the released 0.24.0.
pre-commit: 1) maintain.sh - exit 0 (1091 tests, Pyright 0 errors, no
vulnerabilities); 2) /simplify - 4 agents, 11 findings applied, 2 skipped
(dropping the `device` parameter entirely, which raiw-app pins; folding
diffsynth into the `diffusion` extra, which video-only callers do not need);
3) docs sync - grepped every removed identifier across README, docs/, scripts/,
.claude/; updated 9 docs; 4) CLAUDE.md - added the no-error-only-knobs rule to
.claude/rules/development.md
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The min-resolution floor lifted small inputs toward SDXL's ~1024 training size,
and Real-ESRGAN was an optional way to do that lifting. Both surviving profiles
run at native geometry, so the engine forced the floor to 0 on every path; the
floor never fired, `upscaling` was never true, and nothing downstream of it could
execute. Gone: upscaler.py, _esrgan_upscale, the min_resolution and upscaler
parameters, _target_size's floor branch, --min-resolution, --upscaler,
_warn_if_esrgan_unavailable and the `esrgan` extra. max_resolution stays and is
now the only lever on geometry; it can only scale down.
scripts/smoke_matrix.py was the one live consumer and neither gate saw it -
Pyright is scoped to src/ and Ruff cannot resolve its function-local import - so
`--diffusion` would have died at import. Its knob rows were written for the
removed profiles besides (--pipeline sdxl, --steps 20, --guidance-scale 5.0,
--device mps), so they are rewritten rather than patched: most now assert a knob
is REJECTED, which is the coverage worth having when the CLI accepts a value the
library refuses several layers down. Accepted-knob rows skip without CUDA, so the
row count is host-dependent and verification-plan.md no longer claims a fixed 68.
This removes a public module, a CLI option and a published extra, so the next
release is 0.25.0, not a patch.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
qwen-zimage becomes the default and sdxl-zimage the only alternative. The
controlnet, sdxl, qwen and default profiles are gone, and with them the CPU and
MPS paths for invisible-watermark removal: neither matched the two-stage
recipe's face preservation, so keeping them advertised a quality this library no
longer delivers. Visible-mark removal and every identify command still run
anywhere.
Retired names are rejected rather than remapped. Silently routing --pipeline
sdxl onward would run an old script at a different strength, on a different
model, at a different quality, and report success.
CUDA is now checked when the remover is constructed instead of when the model
loads. Auto-detection cheerfully returned mps on a Mac, so the failure arrived
several layers down, after the dependency check and the pipeline import, in a
message naming whichever internal pipeline happened to raise. _DEVICES collapses
to {"cuda"} and the cpu/mps float32 branch goes with it.
resolve_strength stays total. It briefly returned None for qwen-zimage, meaning
"ask the resolution curve", which pushed a branch onto both callers and left one
of the two strength policies outside the strength module; the CLI copy had
already grown an `or 0.0` guarding a path its own comment called unreachable. It
now takes the image size and answers for both profiles, so the displayed value
cannot drift from the executed one.
Deletion fallout removed with it: img2img_runner and progress.py (the MPS
recovery path and its progress monitor had no callers left), viable_steps, the
fp16 degenerate-output retry, the fp16 VAE fix, and the Qwen img2img call
builders. try_empty_device_cache moved into watermark_remover rather than
leaving a module whose docstring outlived its code. _HAS_DIFFUSERS routes
through optional_deps.module_available, which is what the rest of the library
uses and what correctly rejects a pruned namespace remnant.
--steps, --guidance-scale and --model now have exactly one legal value each and
are still accepted at parse time, then rejected in remove(). Their help text
says so, but validating them beside the option would be better.
Not addressed, and worth its own decision: invisible_engine forces
min_resolution to 0 for both profiles, so the --min-resolution floor, --upscaler,
_esrgan_upscale, upscaler.py and the esrgan extra are all unreachable.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The sdxl-zimage profile crashed on every image containing a face. The remover
gives it torch.float16, because SDXL ships fp16 weights and an fp16-safe VAE,
and that dtype reached the inherited _load_zimage while _zimage_vram_config
hardcodes bfloat16 for its offload, onload and computation dtypes. Z-Image was
therefore built bf16 and handed fp16 latents, dying in the VAE with "Input type
(c10::Half) and bias type (c10::BFloat16) should be the same". Zero-face inputs
never enter _run_faces, so the profile passed every timing run it was given, and
its tests avoid model downloads, so nothing exercised the loader.
Every face-stage loader now reads _face_stage_dtype(), the computation dtype of
the VRAM config it is paired with. SAM is routed through it too: it never
crashed, since it casts its own inputs and leaves through .float(), but it read
the same field and would have re-landed the bug for the next profile with a
different global dtype. That field was never the global dtype on this profile
anyway - _load_sdxl hardcodes fp16 for its own ControlNet, VAE and pipeline - so
its only readers were face-stage code.
This also fixes a second instance transitively: the persisted prompt-embedding
cache restores payloads at the DiffSynth pipe's dtype, which was fp16 into a
bf16 stack before this change.
For qwen-zimage the whole change is a strict no-op. The remover already hands it
bfloat16, the same value _face_stage_dtype() returns, so production is
untouched; verified on an H100 against the deployed pin.
The guard asserts the dtype the Z-Image and SAM loaders actually receive rather
than comparing the accessor to the config it derives from, which would restate
the implementation and pass for any consistently wrong value. Both assertions
were mutation-tested against the pre-fix line.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
SdxlZImagePipeline subclasses QwenZImagePipeline and overrides only _run_global
and preload, so the face stage is inherited rather than copied and cannot drift
between the two profiles. A test asserts the shared methods are the same objects.
Four things are architecture-bound and swap with the model: the ControlNet, the
four-step distillation LoRA (SDXL-Lightning at its documented 1.0, not the
reference graph's 0.8, which belongs to a different LoRA), the sampler (Euler
trailing, no AuraFlow shift), and the latent grid at 8 px against Qwen's 16.
Strength is architecture-bound too, which is the easy mistake and cost two wrong
conclusions before it was caught. An SDXL global pass leaves SynthID at the
strength Qwen needs: through the Gemini app on a native 2816x1536 original, 0.154
is FOUND while 0.20, 0.25 and 0.30 are clean. The profile therefore takes a flat
vendor policy - OpenAI 0.15, Gemini 0.25, unknown following Gemini - rather than
resolution_adaptive_denoise, because flat values are what was measured and no
size dependence has been established for this stage.
requested_steps exists because the runtimes truncate differently: DiffSynth sets
sigma_start = denoising_strength and runs every requested step, while Diffusers
img2img truncates the step count, so four steps at 0.15 executes zero and returns
a bare VAE round-trip.
Also records both measured provider boundaries for the shipped qwen-zimage curve
- OpenAI detected at 0.06 and clean from 0.08, Gemini detected at 0.08 and clean
from 0.10 - together with the two low-resolution Gemini verdicts that explain why
the curve's sub-1 MP rungs are not under-driven despite looking short against a
boundary measured at 4.33 MP. The curve is left unchanged; nothing measured fails.
The profile is not deployed and not production-ready: every verdict so far comes
from one fixture and one seed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The parameter names invite a misreading. _run_faces regenerates the ENTIRE
expanded crop with Z-Image and only then cross-fades on a blurred SAM mask, so
the generation is conditioned on a fully noised neighbourhood and the pixels the
mask later discards were regenerated too.
Records the alternative that has never been tried here - passing the mask into
the sampler as a latent noise mask, so only masked pixels are denoised and the
edge transition happens inside the generation - because the face stage is the
largest measured quality contributor, worth 3.5 dB and 6.1 dB inside the face
boxes on the two fixtures that have one.
Also records that FACE_DENOISE_SCALE = 0.5 is coupled to this compositing choice
rather than independently calibrated: regenerating a whole crop and blending is
stronger than masked denoising, so changing the compositing without revisiting
the scale would move output strength by about a factor of two.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Downscaled a Gemini original to 0.57 and 1.40 MP and ran the deployed worker on
both, so the profile applied its own low-end strengths of 0.0896 and 0.1066. Both
come back clean in the Gemini app.
That rules out the failure mode the ladder raised: 0.08 failed at 4.33 MP, and the
curve sends sub-1 MP images to 0.084-0.094, which looked like it might mean small
Gemini uploads were under-processed in production. They are not, at these sizes.
Written as validation of the shipped curve rather than as evidence that the
boundary moves with resolution. 0.0896 sits inside the untested gap at 4.33 MP,
where only 0.08 and 0.10 were probed, so it may clear at both sizes; the direction
of any resolution dependence stays unproven. These are also downscales rather than
natively small Gemini outputs, which remain untested.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The document asserted it in two places and recommended capping Gemini at 1536
with 0.30, or native-calibrating to ~0.35+. Nothing measured that, and the one
relevant measurement points the other way: the 2026-06-14 deployed-worker re-test
cleared Gemini at 0.15 on two NATIVE 2816x1536 images, the same rung as capped
1536. The document already recorded that as contradicting the "native >= 0.30"
guess, then kept the guess anyway in the historical-certification paragraph and
restated it as fact in the strength floors.
Replaced with what was measured, plus an explicit statement that the direction is
unproven and the low-resolution end has never been through the Gemini oracle on
any pipeline.
Also removes the same appeal from the qwen-zimage denoise-boundary note added
earlier in this branch, which had used the unproven trend as reassurance that the
bottom of the adaptive curve is safe. It is not reassurance; it is an open
question, and it is now written as one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A ladder on one native 2816x1536 Gemini original at seed 0, verified through the
Gemini app, puts the boundary between 0.08 and 0.10: 0.154, 0.12 and 0.10 read
clean, 0.08 reads SynthID FOUND. Fidelity rises monotonically all the way down,
so 0.10 buys +1.54 dB whole-image and +0.98 dB inside the face boxes over the
0.154 the profile ships for that size.
Recorded with the two constraints that stop it being acted on directly. It
brackets rather than calibrates - one image, one seed, and shipping the lowest
clean rung means shipping at the measured cliff edge. And the untested end is the
bottom, not the top: every Gemini oracle fixture is 2816x1536, so the Google-side
certification only ever covered 0.154, while the curve sends sub-1 MP images to
0.084-0.094. The resolution trend already recorded in this document says lower
processing resolution needs less strength, which is the shape the curve has, but
that is an inference and no small Gemini original has ever been through the
oracle. Downscaling is valid test material since SynthID survives it by design.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both qwen-zimage stages prompt with module constants, and at CFG 1.0 DiffSynth's
PipelineUnitRunner reuses the positive embedding for the negative side rather
than encoding it, so exactly one embedding per stage is ever computed. Persist it
and neither text encoder has to be loaded at all.
Measured on an H100 volume: this drops 15.45 GiB (Qwen2.5-VL) and 7.49 GiB
(Z-Image) of an 87.6 GiB per-request read, worth a median 11.76 s and 4.10 s of
load time paired within five containers. A nine-face fixture returned
sha256 c8567e11077de32a both with and without the cache, so the output is
byte-identical and the provider-oracle clearance is untouched.
The cache key carries the cache version, model id, pipeline output params and the
exact prompt, so a model bump or a prompt edit recomputes instead of reading a
stale embedding. The write is atomic because a torn file must never read back as
a hit, and a miss after the text encoder was already dropped raises rather than
calling a model that is not loaded.
_model_cache_dir now prefers HF_HOME: on a scale-to-zero runner that is the only
persistently mounted path, so anything below it is re-derived every request. The
YuNet download follows the same root.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Port of the same change made on the v0.20.1 line, reapplied here because the
package layout moved under _internal/ in the meantime.
The mandatory Qwen stack was configured to offload to disk unconditionally.
DiffSynth implements that by dropping the weights to the meta device and
re-reading every parameter through its DiskMap on the next onload, and the
pipeline moves between text encoder, transformer and VAE on every pass, so
each generation paid a full model reload. That is the right trade on a
consumer card, where it is what makes a 20B model runnable at all, and pure
waste on a card that can simply hold the stack.
Residency is now resolved from total VRAM, mirroring how the optional
Z-Image face stack is already gated. Above the floor the config passes no
"disk" value anywhere, which is what actually disables the behavior:
DiffSynth latches disk_offload once from offload_dtype, so pointing every
device at CUDA while leaving the sentinel would keep both the meta-drop and
the re-read.
Measured on an H100: a warm global pass went from 37.3s at 0.8 GiB resident
to 2.2s at 28.7 GiB, with both stacks resident peaking at 48.0 GiB of 79.2.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
New engines, each calibrated on its TC260 USCC cohort and validated by a
full-corpus sweep (42009 files):
- runninghub: top-left corner (new corner="tl"), faint mid-gray text via
the new raw-grayscale "gray" detection front-end, anchor-position gate
- baidu: text-run-only template (pill is a bright-blob magnet), load-bearing
Doubao+Qwen rival margins, corner-extended footprint for the white tag
- liblib: bottom-center (new corner="bc"), Arial silhouette (font is the
discriminative lever against latin UI text), logo-extended footprint
Qingyan parked (no clean-arm separation at any render/box), MiniMax/Hailuo
parked (1 visible frame, the xinghui rule); silhouettes kept as starting
points.
Kling (USCC cohort 91110108335469089C, n=30): kling_engine.py, gate 0.35
(clean p99 0.304 / max 0.320), strict-only, unimodal 0.12/short on the
shared ladder, fitted locate box, no rival margin (crossfire 1/400 doubao
below gate, 0 jimeng, 0 clean), parity 9/9 detect->fill->re-detect.
Suppresses the jimeng pill like doubao/qwen. identify gains visible_kling.
Yuanbao: measured negative -- the two-line italic block does not separate
from clean corners on either front-end at any render/box/font setting;
the fitted recipe stays in render_vendor_silhouettes.py MARK_OPTS.
cat-logo: cohort has only 2 unique carriers, parked on evidence; the
draw_catlogo silhouette already separates (0.50 vs clean max 0.333), so
registration is a gate pick once more uniques arrive.
vendor_mark_calibrate: --fit-geometry takes locate-box overrides (two-line
marks were clipped by the inherited box) and the aspect sweep reaches 0.62.
Calibrated on the 117-frame TC260-producer cohort (vendor_cohort_harvest +
vendor_mark_calibrate, both committed here): per-mark 2-rung ladder
(0.78, 1.27) for the two measured size modes, fitted locate box (the mark
sits ~0.025 of the short side off the edge; doubao's box clipped the first
glyph), measured template aspect 0.26, gate 0.45 (clean p99 0.301).
Strict-only (the sub-gate band is non-Qwen banners), no rival margin
(0 cross-fires on 400 doubao / 298 jimeng / 286 clean frames).
83/83 real marks detector-clean after cv2 fill.
TextMarkConfig gains a per-mark ladder field; the shipped 3-rung default
is unchanged for every other mark.