Files
remove-ai-watermarks/docs/known-limitations.md
T
Victor Kuznetsov 4c6b56f888 lower(strength): drop vendor-adaptive floor to OpenAI 0.10 / Google 0.15
A 2026-06-14 oracle re-test on the deployed Modal controlnet worker (v0.10.0)
cleared SynthID at OpenAI 0.10 (2 photoreal) and Google 0.15 (2 native
2816x1536, retiring the "native >= 0.30" guess), while a pixel sweep showed the
2026-06-04 cert floors (0.20/0.30) over-regenerated for no efficacy gain
(Google MAE -20% at 0.15). Lowers OPENAI_STRENGTH 0.20->0.10, GEMINI_STRENGTH
and UNKNOWN_STRENGTH 0.30->0.15.

Caveats documented in watermark_profiles.py + docs: removal near this floor is
seed-non-deterministic (a service must pin a verified seed), and the n=2 re-test
did not cover flat-graphic hard cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 13:17:11 -07:00

31 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-based diffusion is still the proper long-term fix.

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).

Still NOT built: an Exif item inside the meta box (rare -- AI labels are XMP) needs full iinf/iloc surgery (offset rewrite) with corruption risk -- exiftool (R/W/C for HEIC/AVIF EXIF+XMP, verified on exiftool.org 2026-05-27) would do it but is a non-installed binary dep, so it stays a documented gap.

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.