Files
remove-ai-watermarks/docs/known-limitations.md
T
Victor Kuznetsov 8f64869bfc docs: capture the Qwen-improvement research (ship vs improve)
Cited deep-research report (22 sources, 3-vote adversarial verification, 5 refuted)
behind the "ship qwen as-is or improve first?" decision. Verdict: shippable now as
an opt-in text lane; strongest improvement lead is adding a Qwen-Image ControlNet
(InstantX / DiffSynth, Apache-2.0, diffusers QwenImageControlNetPipeline) for face/
skin structure; Z-Image-Turbo (6B, Apache-2.0) is the best cheaper text-preserving
substitute. No improvement has measured face-fidelity at our scrub floors yet --
validate with scripts/fidelity_metrics.py first. Linked from known-limitations.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 15:58:46 -07:00

38 KiB
Raw Blame History

Known limitations: full detail

Relocated verbatim from CLAUDE.md on 2026-06-11 to keep the always-loaded context small. Long single-line entries were reformatted into paragraphs; no content was changed or summarized.

Full detail behind the compact Known-limitations list in CLAUDE.md: measurements, incident history, oracle runs, and the reasoning behind each decision. Read the relevant section here before changing the diffusion pipelines, strength defaults, or metadata coverage.

Invisible-pipeline resolution handling (native / 1024 floor / --max-resolution; MPS memory tiers)

invisible pipeline processes at native resolution for inputs whose long side is >= 1024px, and auto-upscales smaller inputs UP to a 1024px floor (min_resolution=1024, the default; --min-resolution 0 disables) before diffusion -- SDXL img2img distorts badly on a tiny latent (a 381x512 portrait wrecks at native, the #36 follow-up), and the output is restored to the original input size so the floor is a transparent quality boost (it adds time/memory on small inputs). The floor upscale uses Lanczos by default; --upscaler esrgan (opt-in, the esrgan extra) runs Real-ESRGAN first for better detail before the Lanczos resize to the exact target (upscaler.py / InvisibleEngine._esrgan_upscale, falls back to Lanczos if the extra is absent). max_resolution=0 (default) means no downscale cap, matching the hosted raiw.cc backend (fal fast-sdxl, no pre-downscale). The old forced downscale-to-1024 -> upscale-back round-trip for LARGE images was the main quality loss (issue #10) and is gone; at strength ~0.05 SDXL img2img does not need a downscale.

Final --unsharp post-filter (humanizer.unsharp_mask, opt-in, default 0): applied LAST (after the face-restore pass, else it would be smoothed over) to counter the soft/over-smoothed look diffusion + restoration leave (an AI tell); ~0.5-0.8 safe, higher risks halos. Pairs with --humanize (grain adds sensor-noise texture, unsharp adds crispness). --max-resolution N re-introduces an opt-in long-side cap purely to bound GPU/MPS memory on very large inputs (it reintroduces the lossy round-trip). For huge images that OOM at native, --tile is the lossless alternative -- see the tiled-diffusion subsection below.

Tiled diffusion for large inputs (--tile, issue #10)

--tile (OFF by default; --tile-size default 1024, --tile-overlap default 128) processes the diffusion pass in overlapping sliding-window tiles instead of one forward pass, so a large image is regenerated at native resolution without the OOM and without the lossy --max-resolution downscale round-trip. It engages only when the long side exceeds --tile-size; a sub-tile image runs a single pass unchanged. WatermarkRemover.remove_watermark refactors the single-image _generate into a per-tile _generate_one (the ControlNet canny edge map is rebuilt per tile, so structure preservation works tile-local) and routes it through noai.tiling.run_tiled when tiling is active. The geometry and blend math are pure helpers, unit-tested without the model (tests/test_tiling.py):

  • plan_tiles(w, h, tile_size, overlap) lays out a row-major grid where every tile is exactly tile_size (the last tile on each axis is pulled back flush to the far edge, simply overlapping its predecessor more). Uniform tile size keeps each diffusion pass at SDXL's preferred dimension.
  • feather_weights(w, h, overlap) is a separable linear taper, ~1 in the interior and ramping toward each edge, kept strictly positive so the normalised accumulate-and-divide blend (accum / weight_sum) is a partition of unity: a region covered by one feathered edge (an image corner) still divides cleanly. Identical (unchanged) tiles therefore reconstruct the input exactly -- the seam-free guarantee, asserted in test_identity_generate_reconstructs_image.

CAVEAT: each tile is an independent low-strength regeneration. At the certified removal strengths (0.20-0.30) the per-tile drift is small and the feather blend hides the seams, but tiling is a memory workaround, not a quality upgrade over a single native pass -- a 32 GB MPS box that clears the native UNet peak should prefer no tiling. The MPS->CPU fallback still applies per tile; if the first tile falls back to CPU, the device stays CPU for the rest of the image.

Concrete MPS data points (the OOM is memory-tier-dependent, NOT a hard MPS limit): on a ~24 GB unified-memory machine (verified 2026-05-25, 1254x1254 gpt-image SDXL, fp32) native res OOMs at the UNet step (peak ~17 GiB), not only the VAE decode, and the auto-fallback in img2img_runner reloads on CPU and finishes (slow, ~13 min) -- the output is still weight-identical and defeats SynthID, so "looks hung/crashed" on Mac is usually this CPU fallback, not a pipeline error. On a 32 GB unified-memory machine the same default SDXL pass runs entirely on MPS with no CPU fallback (verified 2026-05-31, 1122x1402 gpt-image, all/default, ~155 s end-to-end), so 32 GB clears the native-res UNet peak that 24 GB could not. Adding enable_vae_tiling() alone does NOT prevent the 24 GB OOM (the peak is the UNet, not the VAE). The fast Mac workarounds for memory-constrained machines are fp16 on MPS (roughly halves memory) or --max-resolution to cap the long side; neither is wired as the default. The controlnet pipeline adds the canny ControlNet weights on top of SDXL, so its peak is a bit higher than the plain default pass; the same MPS->CPU fallback covers an OOM. The native-vs-cap-vs-floor decision lives in the pure helper invisible_engine._target_size(w, h, max_resolution, min_resolution) (returns None for native, a target tuple for a downscale cap OR an upscale floor; cap takes precedence, the floor is skipped on a min>max misconfig) so it is unit-tested (tests/test_invisible_engine.py::TestTargetSize, the #10/#15/#36 regression guard) without loading the model -- keep that logic in the helper, don't re-inline it.

fp16 VAE black-output fix (issue #29) + degenerate-output fp32 backstop (issue #41)

fp16 VAE black-output fix (issue #29, 2026-05-30): on a CUDA/XPU fp16 backend the stock SDXL VAE overflows to NaN and the plain img2img path decodes to an all-black image (reproduced on the raiw.cc result: a 1086x1448 input -> a uniformly black 4.6 KB PNG, mean 0). watermark_remover._load_pipeline / _load_controlnet_pipeline swap in the fp16-fixed SDXL VAE (madebyollin/sdxl-vae-fp16-fix = _SDXL_FP16_VAE_ID) when _needs_fp16_vae_fix(model_id, DEFAULT_MODEL_ID, is_fp16) is true -- only the default SDXL checkpoint on fp16.

cpu/mps run fp32 (the stock VAE is fine there, which is why the bug never reproduces on Mac). A custom non-SDXL model_id keeps its own VAE (the fp16-fix VAE is SDXL-architecture-specific). The decision is a pure helper, unit-tested without a download (tests/test_platform.py::TestFp16VaeFix); the actual black->clean recovery needs a CUDA GPU.

Confirmed on real CUDA hardware 2026-06-03: running all on a 1086x1448 OpenAI gpt-image (the #29 repro size) at fp16 produced a normal (non-black) output, so the fp16-fix VAE swap resolves the all-black decode. (It was not reproducible on this MPS machine, which runs fp32, so the verification had to happen on an NVIDIA box.)

Follow-up safety net (issue #41, 2026-06-04): the swap is gated to model_id == DEFAULT_MODEL_ID, so a custom model, a stale pre-fix install, or a fal/custom loader can still hit the black decode -- a new reporter did (gpt-image 1448x1086, the #29 size, with the exact image_processor.py:142 invalid value encountered in cast warning the NaN->0 cast emits). remove_watermark now adds a model-agnostic backstop: after generation, if the run was fp16 AND the output is degenerate (_is_degenerate_image: mean and std both below _DEGENERATE_THRESHOLD 1.0 -- a uniform all-black/NaN frame; the variance guard spares a legitimately dark-but-textured photo), it rebuilds the pipeline in fp32 on the SAME device and re-runs once. fp32 is the verified-clean path, so the user never gets a black image regardless of model_id/version. Mirrors the existing MPS->CPU fallback's self-mutation pattern (reset torch_dtype + clear _pipeline/_controlnet_pipeline); batch inherits it through remove_watermark, and once one image trips it the rest of the batch stays on the safe fp32. The detector is a pure helper, unit-tested without a model (tests/test_platform.py::TestDegenerateOutputGuard); the full fp16->detect->fp32-retry chain was verified e2e on this MPS machine by forcing fp16 with the swap disabled (first pass black, guard fired, retry produced a normal image). CAVEAT: the fp32 retry uses ~2x memory, so on a VRAM-constrained GPU it can OOM (a visible error, still better than a silent black frame; the MPS->CPU fallback covers that path). The reporter's "CPU also black" symptom is NOT reproducible here -- fp32 (cpu/mps) decodes clean -- so it points at an old version or a non-fp32 run, pending their version + command.

rich was dropped (plain-text CLI and scripts)

rich was dropped (CLI + scripts print plain text via click.echo).

cli.py renders through small _Console/_Table/_Progress shims; the analysis scripts (scripts/synthid_corpus.py, synthid_pixel_probe.py, text_detection_benchmark.py, corpus_gap_scan.py) import Console/Table from the shared scripts/_plain_console.py shim (markup like [bold]/[/] is stripped, tables render aligned). Consequences: (1) rich is NOT a dependency, so anything that imports it breaks a clean uv sync --frozen (CI installs core+dev only) — this exact gap red-failed CI after the refactor when those 4 scripts still imported rich; if you add a script, use the _plain_console shim, not rich. (2) The old [gpu]-bracket-eaten bug (#19) is gone — plain click.echo prints pip install 'remove-ai-watermarks[gpu]' verbatim, no escaping needed (regression-guarded by tests/test_cli.py::TestGpuHintMarkup). (3) No Unicode glyphs / colors / progress bars in CLI output by design.

AVIF/HEIF/JPEG-XL metadata, ISOBMFF/ffmpeg removal, audio watermark detection

Metadata detection for AVIF/HEIF/JPEG-XL relies on a binary scan for C2PA_UUID + IPTC_AI_MARKERS, plus EXIF Software / XMP CreatorTool generator tags via metadata.exif_generator (validated with synthesized AVIF/JPEG fixtures + an XMP raw-scan fixture). C2PA removal in those containers is implemented via noai/isobmff.py (top-level uuid / jumb box stripper, no re-encoding), which now also drops a top-level XMP uuid box that carries an AI label (matched by AI-marker content, not by the XMP UUID, so byte-order-robust) and covers MP4/MOV/M4V/M4A by content sniff.

Non-ISOBMFF audio/video removal is via ffmpeg (_FFMPEG_STRIP_EXTS -> _strip_with_ffmpeg): WebM/Matroska (EBML), MP3 (ID3), WAV/FLAC/OGG (RIFF/Vorbis) are stripped losslessly with ffmpeg -map_metadata -1 -map_chapters -1 -c copy (codec data untouched). Requires ffmpeg on PATH; raises RuntimeError if absent or if ffmpeg can't parse the file. Verified end-to-end (a real ffmpeg-made WAV/MP3 with a title=Suno AI tag -> tag gone, audio bytes preserved).

Meta-box XMP now handled (isobmff.blank_ai_xmp_packets, v0.6.9): an AI-label XMP packet stored as a meta-box mime item (AVIF/HEIF) is blanked in place (overwritten with spaces of the same length, so iloc offsets and the coded image stay valid).

Exif item inside the meta box (AVIF/HEIF), now handled in place (2026-06-19): an AI-generator token in an EXIF item (its TIFF bytes live in mdat/idat) is blanked by isobmff.blank_ai_exif_tokens — it finds EXIF TIFF blocks by their II/MM byte-order header, validates each with piexif (a coincidental II/MM run in pixel data won't parse as a TIFF IFD, so it is ignored), and overwrites any Software/Make/Artist/ImageDescription value carrying an AI_GENERATOR_TOKENS token with spaces of the same length. Same-length means every box size and iloc offset stays valid and the coded image is untouched — so it avoids the full iinf/iloc surgery (offset rewrite) that exiftool would need (exiftool is a non-installed binary dep, deliberately not used). It scrubs only the AI-token value; camera/editor EXIF is preserved. Wired into remove_ai_metadata's ISOBMFF path after blank_ai_xmp_packets. Limitation: covers the AI-generator-token case (the realistic one); a future xAI-signature-in-meta-box-EXIF (Grok is JPEG-only today) is not separately handled. Still NOT built: Resemble PerTh audio detection (no presence/confidence flag exists).

Audio watermark DETECTION (Resemble PerTh) was evaluated and NOT built (2026-05-26): resemble-perth's PerthImplicitWatermarker.get_watermark() returns a raw bit-array with no presence/confidence flag (clean audio decodes to arbitrary bits too), so reliably distinguishing watermarked-from-clean needs either Resemble's fixed payload or a confidence API -- neither is public, and there's no real Resemble sample to calibrate against. Same wall-class as the SynthID pixel detector: the decode exists, reliable presence-detection does not. (perth's top-level PerthImplicitWatermarker is also gated to None unless librosa is importable.)

SynthID detection is metadata-only (no local pixel detector)

SynthID detection is metadata-only.

There is no reliable local detector of the SynthID pixel watermark — Google's decoder is proprietary, no public spec or API (only a waitlisted portal). Authoritative confirmation: Google DeepMind's own paper "SynthID-Image: Image watermarking at internet scale" (Gowal et al., arXiv:2510.09263) states the verification service is restricted to "trusted testers" and does not release detector weights or a reproducible algorithm — so a local pixel detector is infeasible by design, not just unbuilt. https://arxiv.org/abs/2510.09263 We detect SynthID by its C2PA companion (synthid_source / SYNTHID_C2PA_ISSUERS), which is reliable while the manifest is intact but says nothing once C2PA is stripped.

Surface-dependent blind spot (verified 2026-05-24): the same Google model emits different metadata per surface -- the Gemini app wraps outputs in Google C2PA, but the API/playground (AI Studio, Nano Banana / gemini-2.5-flash-image) emits the SynthID pixel watermark (confirmed via the Gemini-app oracle) + the visible sparkle but no C2PA/IPTC at all, so synthid_source returns None despite SynthID being present. Only the pixel oracle or the visible-sparkle detector catches those. (Meta AI is another surface mismatch: it writes the IPTC digitalSourceType=trainedAlgorithmicMedia marker, not C2PA and not SynthID.) Google→SynthID is long-standing; OpenAI→SynthID is confirmed by OpenAI's Help Center (ChatGPT/Codex/API "include both C2PA metadata and SynthID watermarks", updated 2026-05-21) but time-gated (pre-rollout OpenAI images carry C2PA without SynthID), so the OpenAI verdict is hedged "likely". Oracles: Gemini app "Verify with SynthID" (Google), openai.com/verify (OpenAI).

Each vendor's oracle detects only its OWN content (verified on the page 2026-05-31): openai.com/research/verify states verbatim "OpenAI generation signals will only be detected if the image was generated with our tools" and "Content could also still be AI-generated by another company's model, which the tool currently does not detect" -- SynthID is shared tech but the verifier is keyed to its own vendor's payload, so a Google-SynthID image reads clean on OpenAI's verifier and vice-versa.

This explains the recurring "oracle says clean but identify still flags SynthID" report (#14): the oracle reads the pixel watermark (gone after our SDXL pass), while identify reads the C2PA-metadata proxy (still present if the manifest survived). Different signals, not a contradiction -- strip the metadata too (metadata --remove / all) and the proxy goes quiet, but a quiet proxy is not proof the pixel watermark is gone.

SynthID is durable to JPEG re-encode by design, so a GitHub-recompressed issue attachment is still a valid SynthID test subject (verified 2026-06-01 on issue #14's pic3: the GitHub-served JPEG survived re-encoding and openai.com/verify still detected SynthID). Do NOT dismiss issue-attachment JPEGs as "not faithful originals" when reproducing a SynthID-survival report: the recompression strips the C2PA metadata (so identify reads Unknown on the attachment) but NOT the pixel watermark that openai.com/verify reads. A true byte-original only matters for the metadata/C2PA path, not for the pixel-SynthID-removal test. (Contrast the open imwatermark above, which IS fragile to JPEG.) The spectral phase-coherence approach from github.com/aloshdenny/reverse-SynthID was evaluated (May 2026) and does not work for real-content detection: on its own shipped codebook + validation set, watermarked and cleaned images were indistinguishable (conf within noise, cleaned often higher); it only fires on pure-black 1024x1024 reference images at exact resolution (the controlled case it was calibrated on). The README's "90% / conf=0.91" reproduces only in that lab condition. Do not build a production detector on it; if revisited, it is experimental/diagnostic only and needs a per-resolution, per-model reference corpus. A from-scratch gpt-image pilot (2026-05-24) confirmed this independently: 5 independent solid-black gpt-image outputs share a near-identical fixed signature (pairwise residual correlation 0.92, avg-template retains 97% energy), so the watermark/carrier IS strongly present and consistent on flat content — but the carrier frequencies extracted from it do NOT discriminate real content (carrier-to-random ratio: cleaned 1.86 > watermarked 1.53; a non-gpt-image image scored highest at 3.67). The signature drowns in content texture. Net: a perfectly consistent solid-color signature still yields no real-content pixel detector with magnitude/carrier methods. A corpus discrimination test (2026-05-24, scripts/synthid_pixel_probe.py, raw zero-mean residual NCC) independently re-confirms this: at matched resolution, SynthID positives do NOT cluster apart from negatives (within-Gemini 0.07; at 1024 px pos-vs-neg >= pos-vs-pos). The only high correlations were near-duplicate content (5 ChatGPT renders of one prompt at ~0.92, while a distinct ChatGPT image scored ~0 against them) — content, not a carrier. The probe is solid-fills-only and EXPERIMENTAL/DIAGNOSTIC; do not use it on real content.

Correction (deeper re-examination 2026-05-25): the carrier IS real on solid fills — the earlier "no carrier" was a method artifact of using spatial / FFT-magnitude NCC, which can't see it. The carrier is a fixed phase at specific low frequencies, so the right metric is per-bin phase coherence. On 8 white gemini-2.5-flash-image fills (generated via the reverse-SynthID trick: identity-edit prompt "Recreate this image exactly as it is" on a synthetic pure-white PNG — this bypasses the recitation block that rejects text prompts for pure colors), phase coherence at the white carriers (0,±7..±12,±20..±23) = 0.86 vs 0.31 random; single-image leave-one-out phase-match +0.83 vs real photos -0.24. (Black 2.5-flash fills clip to std≈0 — SynthID can't push values below 0, so no carrier in black; the repo's dark carriers come from nano-banana-pro.)

But it does not generalize: (a) carriers are model-version + resolution + color specific — the repo's v4 codebook (built for gemini-3.1-flash-image-preview + nano-banana-pro-preview) scores ~0.527 on my 2.5-flash white fills, indistinguishable from negatives (~0.50), i.e. carriers shift across model versions and need a per-model codebook; (b) on real content (30 2.5-flash images) the carrier collapses — set phase coherence at carriers 0.37 ≈ random 0.42, and the repo's v4 detector gives content 0.518 ≈ negatives 0.504 (no separation; a faint +0.24 single-image lean is likely a brightness confound). Net: the spectral/phase approach is a real controlled-fill characterizer, NOT an arbitrary-real-content detector, and is brittle to model version. Metadata proxy + visible sparkle + online oracles remain the ceiling for real content.

External AI-vs-real classifier models are out of scope

External AI-vs-real classifier models are out of scope (decided 2026-05-24).

Generic HuggingFace detectors (Organika/sdxl-detector Swin Transformer, umm-maybe/AI-image-detector, and fine-tunes) exist and report ~0.98 on their own SDXL-vs-real validation sets, but they are per-generator and the model cards themselves note degraded accuracy off-distribution; they are untested on gpt-image / Gemini Nano Banana (the metadata-stripped surfaces we care about), and our own light SDXL pass would likely defeat them the same way it defeats SynthID. Detection here stays local + signal-based (metadata + visible sparkle); do not add a bundled classifier dependency.

Default strength is vendor-adaptive, one ladder for both pipelines

DEFAULT STRENGTH IS VENDOR-ADAPTIVE, ONE LADDER FOR BOTH PIPELINES (LOWERED 2026-06-14; raised + unified 2026-06-09; vendor-adaptive since 2026-06-01, SUPERSEDES every fixed-default claim in this bullet and the next).

resolve_strength(strength, vendor) + vendor_for_strength(path) (watermark_profiles.py) read the C2PA issuer (metadata.synthid_source) on the ORIGINAL input and pick OPENAI_STRENGTH 0.10 / GEMINI_STRENGTH 0.15 / UNKNOWN_STRENGTH 0.15 when --strength is unset; explicit --strength always wins.

The SAME ladder applies to BOTH pipelines (sdxl and controlnet). 2026-06-14: lowered from the 2026-06-04 cert floors (OpenAI 0.20 / Google 0.30) back toward the original 2026-06-01 study (OpenAI ~0.05-0.10 / Google 0.15). A re-test on the deployed Modal controlnet worker cleared SynthID on the oracle at OpenAI 0.10 (2 photoreal, 1402/1448 px) and Google 0.15 (2 NATIVE 2816x1536 images -- retiring the "native ~2816 likely needs >=0.30" guess), while a pixel sweep showed 0.20/0.30 over-regenerated for no efficacy gain (Google MAE -20% at 0.15). See watermark_profiles.py "Data basis". CAVEATS that stand: (1) removal near this floor is SEED-NON-DETERMINISTIC (the 2026-06-09 finding below) -- a SERVICE on this ladder must pin a fixed, oracle-verified seed, not rely on a random one; (2) the re-test is n=2 per vendor on photoreal/landscape, NOT flat graphics (the sdxl weak spot), so raise --strength if an oracle reads SynthID on a flat output.

Why one ladder (NOT a per-pipeline split): the cert was run on controlnet and does NOT transfer to sdxl by symmetry (opposite hard cases -- controlnet leaves SynthID on photoreal, sdxl on flat graphics), BUT on its OWN hard case (flat fills) sdxl is the WEAKER remover (plain img2img barely perturbs a flat region at low strength), so it needs AT LEAST controlnet's strength -- hence the certified floor is the right floor for sdxl too. It is a MARGIN argument for sdxl, not a fresh certification (no local SynthID detector to self-verify); raise --strength if an oracle still reads a flat sdxl output. The higher strength costs little quality because controlnet is now the default pipeline AND the only --auto pick, so sdxl is reached only via an explicit --pipeline sdxl (a deliberate opt-down for inputs without faces/text), where over-regeneration has nothing to damage. (A short-lived per-pipeline split ladder -- sdxl 0.15/0.20 vs controlnet 0.20/0.30 -- existed on 2026-06-09 before being unified the same day; the resolve_strength pipeline param and the CONTROLNET_*_STRENGTH constants were removed.) The CLI detects the vendor from the pristine source (before the visible pass / metadata-strip removes C2PA from the temp file) and passes it to display calls so display and execution agree; cmd_invisible/cmd_all/batch thread vendor.

This replaces the single 0.30 default AND the prior "do NOT build a vendor-adaptive default" policy -- both came from the now-debunked region-rescrub-contaminated study (the per-region re-scrub that contaminated those numbers was removed in the controlnet refactor). Basis: the oracle-verified June 2026 controlled study (clean v0.8.6, protect OFF): OpenAI clears at 0.05 across 1024-1600 (n=4, resolution-independent); Google needs 0.15 on the capped-1536 path (n=4). docs/synthid.md §2.2 (data) + §5.2 (the adaptive default) are authoritative.

CAVEAT (oracle pass 2026-06-04): the OpenAI 0.10 default is content-dependent, NOT universal -- a flat-graphic OpenAI logo/poster still read SynthID-detected after default at 0.10, and photoreal images after controlnet at 0.10/0.15 (low-change regions under-perturbed). Removal at 0.10/0.15 is content×pipeline dependent (see the controlnet Known-limitations bullet); the lever is a higher strength, oracle-revalidated per content type. Do NOT assume the vendor-adaptive default clears every image.

CAVEAT: Google's 0.15 was validated only on --max-resolution 1536; native large Gemini (2816) was not locally measurable (OOM on M-series) and is pending GPU validation on raiw.cc -- if it survives 0.15 native, raise --strength.

Everything below in this bullet about a fixed 0.10/0.30 default is HISTORICAL; trust the vendor-adaptive constants + docs/synthid.md.

SynthID removal: strength + oracle scope

SynthID removal: strength + oracle scope.

Default strength is vendor-adaptive (see the bullet above); docs/synthid.md §2.2 is authoritative for the numbers.

Oracle scope (load-bearing): the Gemini app "Verify with SynthID" is the ONLY valid SynthID oracle (detects Google's mark on any image); openai.com/verify is scoped to OpenAI provenance (its own C2PA), NOT a SynthID oracle -- a negative there is meaningless for SynthID. There is no local SynthID detector, so the tool cannot self-check; if the oracle still reads SynthID, raise --strength to the lowest value that verifies clean. Only the sdxl (plain SDXL img2img; default is a back-compat alias) and controlnet (SDXL + canny ControlNet) profiles exist; the local invisible default is weight-for-weight identical to raiw.cc prod (fal-ai/fast-sdxl = stabilityai/stable-diffusion-xl-base-1.0, runtime-downloaded, not bundled).

Forensic-stealth caveat (arXiv:2605.09203): defeating the SynthID verifier is NOT forensic invisibility -- independent detectors flag removal-processed images vs genuinely-clean ones at >98% TPR@1%FPR, so do not over-claim "indistinguishable from a real photo".

controlnet pipeline: content x pipeline removal, certified floors, no face-restore

controlnet pipeline (text/face STRUCTURE preservation, THE DEFAULT since 2026-06-09; --pipeline default opts down to plain SDXL).

SDXL + the canny ControlNet xinsir/controlnet-canny-sdxl-1.0 via StableDiffusionXLControlNetImg2ImgPipeline (watermark_remover._run_controlnet / _load_controlnet_pipeline).

Removal still comes from the img2img regeneration (strength); the ControlNet only PRESERVES text and face STRUCTURE by conditioning on the canny edge map (cv2.Canny(gray, 100, 200), 3-channel). Canny preserves edges, NOT face identity (a regenerated face drifts in likeness). The drifted cleaned face is the LEAST-AI state we can reach without re-introducing SynthID; the library does NOT ship a face-restore extra (every approach evaluated 2026-06-04 - 2026-06-08 -- GFPGAN-on-cleaned, PhotoMaker-V2, InstantID txt2img, InstantID img2img-on-cleaned at three parameter sweeps -- regenerated the face via SDXL and made it look MORE AI-generated). Full empirical conclusion in docs/synthid-robust-identity-research-2026-06-08.md "Empirical follow-up". For production face preservation, ship the cleaned image as-is. No original pixels are copied or frozen, BUT removal at the low vendor-adaptive strength is CONTENT × PIPELINE dependent and NEITHER pipeline clears all content -- oracle-validated against the OpenAI verifier 2026-06-04 (8 images, strength 0.10/0.15, --max-resolution 1536).

The survivors FLIP by content type: photoreal (a 9-face grid, a bracelet product photo) SURVIVES controlnet but CLEARS default (controlnet's dense edge map keeps the regen too close to the original, so the SynthID-destroying perturbation never happens; plain img2img perturbs photoreal texture enough); flat graphic (a logo/poster with large flat color fills) SURVIVES default but CLEARS controlnet (at low strength img2img barely changes flat fills so SynthID persists there, while controlnet repaints them more freely); a flat text card cleared under both.

Root cause is insufficient STRENGTH, not the pipeline: at 0.10 the low-change regions -- dense-edge photoreal under controlnet, large flat fills under default -- are not perturbed enough to destroy SynthID. The vendor-adaptive 0.10 from the June study is NOT universally sufficient (that study's content happened to clear at 0.10).

The robust fix is a HIGHER strength, oracle-revalidated per content type (controlnet can be cranked harder without losing structure; a lower controlnet_conditioning_scale also frees the regen on photoreal). So at today's default strength both pipelines AND --auto can LEAVE SynthID on some content -- a removal-priority caller (raiw.cc) MUST oracle-validate strength across content types before adopting, not pick a pipeline and assume removal.

**Follow-up same day: re-running the two photoreal survivors through controlnet at an explicit --strength 0.15 cleared BOTH on the oracle -- BUT one of them (the bracelet) had SURVIVED the SAME 0.15 controlnet config in the first pass (only the random, unset seed differed). So removal near the threshold is SEED-NON-DETERMINISTIC: the same image+pipeline+strength+resolution can pass or fail run-to-run (img2img uses seed=None/random unless --seed is passed, and there is no local SynthID detector to self-verify). 0.15 is the borderline, NOT a robust floor -- pick a strength with MARGIN (controlnet ~>= 0.20) rather than exactly on it; the content×pipeline table's 0.15 data point is near-threshold noise. A confirming run at --strength 0.20 controlnet cleared BOTH photoreal survivors on the oracle (ladder: 0.10 grid detected → 0.15 borderline/non-deterministic → 0.20 both clean), so 0.20 is the recommended robust controlnet floor for OpenAI photoreal (one margin run, not an N-run repeatability proof -- a service should add margin or verify repeatability since there is no local SynthID detector to self-check).

Engineering follow-up DONE 2026-06-09 (three coupled changes): (1) strength raised + unified -- resolve_strength(strength, vendor) now applies ONE vendor-adaptive ladder (the certified controlnet floors 0.20/0.30/0.30) to BOTH pipelines; see the DEFAULT STRENGTH bullet above for why one ladder covers sdxl. (2) controlnet is now the DEFAULT pipeline (CLI --pipeline default = controlnet + both engine ctors). Rationale: with the certified higher ladder it clears BOTH content classes that flipped in the content-x-pipeline table (photoreal AND flat graphic), whereas plain SDXL left SynthID on flat graphics -- so controlnet is the more removal-robust default. Cost: every non---auto run now downloads the canny ControlNet weights + a higher memory peak (MPS->CPU fallback covers OOM). (3) the plain-SDXL profile was renamed default -> sdxl (watermark_profiles.SDXL_PROFILE/normalize_profile); default stays as a back-compat CLI/ctor alias (the --pipeline Choice accepts sdxl/controlnet/default, a click callback _normalize_pipeline maps default->sdxl AND warns that default is deprecated). (4) the content-detection layer + --auto planner were removed and --auto was retired to a deprecated alias for --adaptive-polish -- see the dedicated auto_config.py-removal bullet above (controlnet is the default pipeline and the polish self-gates, so detection changed nothing). raiw.cc still needs its own per-vendor/content calibration on the GPU worker for native resolution. The Gemini-native resolution caveat stands: controlnet 0.30 is certified only <=1536.** **CERTIFIED 2026-06-04 via the isolated raiw-controlnet-cert Modal app (raiw-app/modal_cert.py), restore OFF, ≤1536, each vendor on its own oracle: controlnet floors are OpenAI 0.20 (2 photoreal × 3 seeds = 6/6 clean; the 0.15-flipper is seed-robust at 0.20) and Gemini 0.30 (0.20 detected → 0.30 clean on 2/2 seeds). OpenAI 0.20 transfers to prod (resolution-independent); Gemini 0.30 holds only ≤1536 — Gemini is resolution-sensitive and raiw.cc runs NATIVE (max_resolution=0), so cap Gemini ≤1536 + use 0.30, or native-calibrate (~0.35+). Prod recipe: controlnet + per-vendor floor in resolve_strength (not the default ladder) + FIXED seed (kills the non-determinism).

No face-restore in the library: every approach evaluated (GFPGAN-on-cleaned, PhotoMaker-V2, InstantID txt2img, InstantID img2img-on-cleaned, 2026-06-04 - 2026-06-08 cert sweeps) regenerated the face via SDXL diffusion -- the output face inherited SDXL "clean skin" gloss and lost original identity precision, looking MORE AI-generated than the cleaned image, not less. The drifted face from controlnet 0.20 is the least-AI state we can reach; for a paid service that's the prod output. See docs/synthid-robust-identity-research-2026-06-08.md "Empirical follow-up".**

See docs/synthid.md §5.5 + docs/controlnet-removal-pipeline-research.md (certified floors table).** Lesson: visual-quality + face-recovery validation does NOT prove watermark removal -- only the SynthID oracle does, across MULTIPLE content types; never infer removal from sharpness/identity, and never conclude from a partial result (the photoreal-only data first read as "controlnet shields, default removes" -- the flat-graphic result reversed it).

controlnet_conditioning_scale (CLI --controlnet-scale, default 1.0) is the structure-preservation knob (higher = closer to the original structure); fp32 on cpu/mps, fp16-fixed VAE on cuda/xpu. The controlnet profile is threaded explicitly (WatermarkRemover(pipeline=...) / InvisibleEngine(pipeline=...)), NOT inferred from model_id. This productionizes the scripts/controlnet_sweep.py prototype; see docs/controlnet-removal-pipeline-research.md.

Forensic-stealth caveat still applies (arXiv:2605.09203): defeating the SynthID verifier is not forensic invisibility -- a "this image went through a removal pipeline" classifier can still flag the output.

qwen pipeline (experimental, Qwen-Image 20B, certified floors)

--pipeline qwen runs QwenImageImg2ImgPipeline on Qwen/Qwen-Image (20B MMDiT, Apache-2.0 code AND weights), as an img2img alternative to the SDXL pipelines. Motivation: the controlnet over-regeneration problem above (it plasticizes real photos / loses fine text at the scrub floor). Qwen-Image renders text natively (incl. CJK) and preserves structure markedly better, so at the strength that removes SynthID it damages real content far less.

The scrub still comes from the img2img strength (same lever as SDXL); the call shape lives in the pure _build_qwen_kwargs (uses Qwen's true_cfg_scale, not SDXL's guidance_scale — the CLI --guidance-scale maps onto it, and ~4.0 is typical vs the SDXL default 7.5). bf16 on CUDA. It is CUDA/cloud-class — the 20B does not fit MPS — so _run_qwen has NO MPS→CPU fallback (unlike the SDXL paths). Cost on Modal A100-80GB is ~$0.05-0.10/image vs SDXL.

Certified oracle floors (Modal A100-80GB, 2026-06-20): on native-resolution OpenAI and Gemini cert inputs (data/qwen_in/, both controls SynthID-POSITIVE): OpenAI 0.10 (0.05 and 0.075 still detected; 0.10 clean and SEED-ROBUST — clean on seeds 0-4, so a random seed is safe) and Gemini 0.25 (0.20 still detected, 0.25 clean on both images; lowered from the 0.30 first measured). Gemini seed-repeat is single-seed (seed 0): the Gemini oracle rate-limits volume, so PIN a seed in production rather than relying on seed-robustness there.

Fidelity vs controlnet was MEASURED, not eyeballed (scripts/fidelity_metrics.py, text scored against a vision-transcribed ground truth in data/qwen_in/ground_truth.json + PaddleOCR on the variants; an initial eyeball read was wrong and overturned by the metrics). Methodology rule: only compare fidelity at each pipeline's OWN oracle-confirmed scrub floor -- i.e. between outputs where SynthID is actually removed in BOTH (controlnet OpenAI 0.10 / Gemini 0.15; Qwen OpenAI 0.10 / Gemini 0.25). An equal-strength comparison is invalid where it leaves one pipeline un-scrubbed (Qwen at 0.15 does NOT clear Gemini SynthID, so that run was dropped). At those scrub floors:

  • Text: Qwen wins on substantial Latin/mixed-script text -- OCR CER, controlnet vs Qwen: openai_1 (EN+RU+ZH, both 0.10) 0.385 vs 0.241, openai_2 (EN, both 0.10) 0.341 vs 0.290. On a SHORT CJK sign (gemini_1, cnet 0.15 / Qwen 0.25) it is a TIE (0.037 vs 0.037 -- both near-perfect; the earlier Qwen 0.000 was at the higher 0.30, not the certified floor).
  • Faces: controlnet wins -- gemini_3, 18 faces (cnet 0.15 / Qwen 0.25): ArcFace identity 0.546 vs 0.382, Laplacian-variance retention 0.62 vs 0.40, face LPIPS 0.09 vs 0.17 (Qwen smooths faces MORE; the gap narrows vs Qwen 0.30 but controlnet still wins clearly).

Conclusion: Qwen is the better TEXT-preserving remover (substantial Latin/mixed text), NOT a universal fidelity win — controlnet's canny edge map holds face skin detail better, so the path is a content-routed lane (text→qwen, faces→controlnet), not a blanket migration. Caveat: resolve_strength is shared and pipeline-independent, so the Gemini default (0.15) UNDER-scrubs Gemini on qwen (floor 0.25) — pass --strength 0.25 for Gemini on qwen until a Qwen ladder is wired. Flat-graphic content was not in the sample.

Improving Qwen (ship vs improve): the cited research on fixing the face-smoothing while keeping the text win (Qwen-Image ControlNet for structure conditioning, Qwen-Image-Edit, Z-Image-Turbo as a cheaper text-preserving substitute, non-regenerative detail restoration) lives in docs/qwen-improvement-research.md -- read it before extending the qwen pipeline. Verdict: shippable now as an opt-in text lane; the strongest improvement lead is adding a Qwen-Image ControlNet, but no improvement has measured face-fidelity at our floors yet (validate with scripts/fidelity_metrics.py first).

Seed as a quality lever (measured, openai_1 at 0.10, seeds 0-4): the seed barely moves whole-image fidelity (img LPIPS 0.062-0.065, SSIM 0.855-0.857, PSNR 28.5-28.7 — flat) but does shift TEXT legibility (OCR CER 0.241-0.290, ~17% spread) -- the seed changes WHICH details get regenerated, not the overall level. So a per-image best-of-N-seed selection is a WEAK, text-only lever (pick the lowest-CER seed that still scrubs; fidelity selection needs no oracle). Not worth the N× cost for general use -- pin one decent seed in prod; reserve best-of-N for text-heavy premium cases.