Commit Graph
218 Commits
Author SHA1 Message Date
Victor KuznetsovandClaude Opus 4.8 33fddbc6fa fix(visible): over-subtraction guard for Doubao/Jimeng/Samsung text marks
Port the Gemini sparkle dark-pit guard (commit 41f6797) to the shared
TextMarkEngine reverse-alpha base (roadmap P0#8): on a dark or mid-tone
background the captured alpha can over-estimate this image's mark opacity, and
reverse-alpha leaves a darker-than-background glyph ghost instead of recovering
the true pixels. The sparkle-only fix left the text marks unhandled.

_reverse_alpha_oversubtracts predicts the reverse-alpha output PER PIXEL over the
glyph body from the INPUT ((obs - a*logo)/(1-a), the remover's own math); when
the predicted body lands more than _OVERSUB_DARK_MARGIN (25) gray levels below
the local background ring it abandons the reverse-alpha output for the footprint
and inpaints it from the original surroundings (_inpaint_footprint, wider dilate/
radius than the thin residual pass). Predicting per-pixel from the input (not the
produced output, which depends on which placement the remover picked) keeps a
cleanly captured full-strength mark byte-identical -- it predicts back to the
background everywhere, so the guard never trips on it (verified across all three
engines on white/mid/dark/midgray backgrounds).

Regression-guarded by tests/test_text_mark_oversubtraction.py: predicate True on
faint / False on clean, end-to-end no-dark-pit acceptance, clean-mark byte
identity, and textured-background footprint recovery.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 15:34:39 -07:00
Victor KuznetsovandClaude Opus 4.8 0c215b5b2f feat(identify): C2PA vendor coverage, AI-enhanced split, detect/remove threshold unify
Retained-corpus mining (2026-06-20) surfaced three provenance gaps; all are
oracle-free and regression-guarded.

- C2PA vendor coverage (roadmap): register Volcano Engine under its Chinese
  legal entity 北京火山引擎科技有限公司 (the latin "volcengine" needle misses
  those certs) -> normalizes to the same ByteDance platform; register ElevenLabs
  ("Eleven Labs Inc.", pure generative-AI) as a generator. Document the
  deliberate exclusion of TikTok Inc. and PixelBin.io/"Fynd" (provenance/transform
  signers, not generators) so they are not re-added.

- AI-generated vs AI-enhanced (roadmap): ProvenanceReport.ai_source_kind splits
  the C2PA digital-source-type into "generated" (trainedAlgorithmicMedia) vs
  "enhanced" (compositeWithTrainedAlgorithmicMedia) so a caller branches a
  full-frame scrub from a region-targeted clean. Parsed once in
  noai.c2pa._populate_registry_fields (PNG + any c2pa-python-readable container),
  with a raw head-scan fallback in identify for the non-PNG raw-blob path. CLI
  verdict reads "AI-generated (fully synthetic)" vs "AI-enhanced (real content
  with an AI-composited region)"; surfaced in --json.

- Detect-vs-remove threshold desync (P0#7): identify's sparkle threshold and the
  removal arbitration gate were two independent 0.5 constants. Unify them into the
  single GEMINI_SPARKLE_TRUST_CONF (identify imports it) so they can never drift.
  Lowering the gate to recover faint sub-0.5 sparkles was evaluated and REJECTED:
  a real Doubao text mark scores ~0.40-0.42 as a gemini match with a higher
  core-ring brightness margin than a genuine faint sparkle, so neither confidence
  nor the brightness gate separates them in [0.35, 0.5) -- lowering would trade a
  rare miss for false-positive removals on clean images. Regression-guarded by
  TestSparkleDetectRemoveAlignment (real demo sparkle at borderline opacities;
  identify and best_auto_mark must agree on either side of the line).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 15:34:20 -07:00
Victor KuznetsovandClaude Opus 4.8 7dddfef14e docs: certify qwen scrub floors (OpenAI 0.10 seed-robust, Gemini 0.25)
Oracle seed-repeat + floor refinement (2026-06-20, data/qwen_in):
- OpenAI floor 0.10 is SEED-ROBUST: 0.05 and 0.075 still detected; 0.10 clean on
  seeds 0-4 (5/5) -> a random seed is safe.
- Gemini floor lowered 0.30 -> 0.25 (0.20 still detected, 0.25 clean on both
  images). Single-seed (seed 0): the Gemini oracle rate-limits volume seed-repeat,
  so pin a seed in prod rather than relying on seed-robustness there.

Re-measured fidelity at the certified floors (controlnet 0.15 vs Qwen 0.25 for
Gemini): faces still favor controlnet (ArcFace 0.546 vs 0.382, lapvar 0.62 vs
0.40); the short-CJK text case is now a TIE (gemini_1 0.037 vs 0.037 -- the earlier
Qwen 0.000 was at 0.30, not the floor). Qwen's text win holds on substantial
Latin/mixed text (OpenAI 0.385 vs 0.241 / 0.341 vs 0.290). Update watermark_profiles
comment, CLAUDE.md, module-internals, known-limitations.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 15:16:51 -07:00
Victor KuznetsovandClaude Opus 4.8 76e3d4154c feat(invisible): add Qwen-Image img2img pipeline (--pipeline qwen)
A third diffusion pipeline alongside sdxl/controlnet: Qwen-Image (20B MMDiT,
Apache-2.0 code AND weights) img2img. The scrub still comes from the img2img
strength; Qwen preserves text (incl. CJK) and structure markedly better than
SDXL at the scrub floor, so it over-regenerates real photos far less (directly
targets the controlnet over-regeneration that degrades real uploads).

- watermark_profiles: QWEN_MODEL_ID, normalize_profile accepts "qwen".
- WatermarkRemover: _load_qwen_pipeline (bf16, loads Qwen base unless --model
  overridden, clear ImportError if diffusers lacks the class), _run_qwen (no
  MPS fallback -- 20B is CUDA/cloud-class), dispatch in _generate_one/preload,
  pure _build_qwen_kwargs (true_cfg_scale, not guidance_scale).
- Shared _base_load_kwargs() across all three loaders (dtype + token).
- CLI --pipeline gains "qwen"; invisible_engine threads it through.
- scripts/qwen_scrub_prototype.py: standalone PEP 723 GPU experiment.

Prototype oracle floors (Modal A100-80GB, single seed, controls SynthID-positive,
PENDING seed-repeat cert): OpenAI clears at strength ~0.10, Gemini at ~0.30 (0.20
still detected), with CJK text + faces faithful where controlnet plasticizes. The
Gemini floor is higher than the shared default ladder, so pass an explicit
--strength for Gemini on this pipeline until a Qwen-specific ladder is certified.

The model-running path is CUDA-only (untestable locally); unit tests cover the
pure call-shape (_build_qwen_kwargs) and profile normalization without torch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 20:44:36 -07:00
Victor KuznetsovandClaude Opus 4.8 0c0c6c6b03 feat(invisible): sliding-window tiled diffusion for large inputs (--tile)
Add a lossless alternative to the --max-resolution downscale for large
images that OOM on MPS/GPU: regenerate in overlapping, feather-blended
tiles at native resolution.

- noai/tiling.py: pure plan_tiles (uniform tiles, last flush to edge) +
  feather_weights (strictly-positive separable taper -> partition-of-unity
  blend) + run_tiled (per-tile generate callable, decoupled from the
  pipeline). Unit-tested without the model.
- WatermarkRemover.remove_watermark: refactor _generate into _generate_one
  + a tiled branch that engages only when --tile is set and the long side
  exceeds tile_size (ControlNet canny is rebuilt per tile).
- Thread tile/tile_size/tile_overlap through InvisibleEngine and the
  invisible/all/batch CLI commands via a shared _tile_options decorator.

Verified end-to-end on the real SDXL pipeline (forced 2x2 tiling on a
1024px sample, MPS): non-degenerate output, no gross seam at tile borders.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 11:54:58 -07:00
Victor KuznetsovandClaude Opus 4.8 d5845a72f3 feat(metadata): blank AI-generator tokens in AVIF/HEIF Exif meta-box items
Closes a documented coverage gap (P2#9): an AI Software/Make/Artist/ImageDescription
token in an EXIF item (its TIFF bytes live in mdat/idat) survived remove_ai_metadata
because the top-level box stripper and (absent pillow-heif) the PIL EXIF reader can't
reach it. New isobmff.blank_ai_exif_tokens finds EXIF TIFF blocks by their II/MM
byte-order header, validates each with piexif (a coincidental II/MM run in pixels
won't parse as a TIFF IFD, so it's ignored), and overwrites any AI_GENERATOR_TOKENS-
bearing value with same-length spaces -- so box sizes and iloc offsets stay valid and
the coded image is untouched (mirrors blank_ai_xmp_packets; no iinf/iloc surgery, no
exiftool dep). Camera/editor EXIF without an AI token is preserved. Wired into
remove_ai_metadata's ISOBMFF path. Covers the realistic AI-generator-token case; xAI-
signature-in-meta-box-EXIF (Grok is JPEG-only) stays out.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 10:43:35 -07:00
Victor Kuznetsov f97fdc5b92 chore(release): v0.11.4 2026-06-19 10:06:04 -07:00
Victor KuznetsovandClaude Opus 4.8 99e57c872f perf(text-mark): footprint-sized arrays in reverse-alpha CPU path
The reverse-alpha text-mark engine (Doubao/Jimeng/Samsung) allocated
full-frame arrays where only the glyph footprint is ever read:

  - _fixed_alpha_map / _aligned_alpha_map each built a full (h, w) float32
    alpha map non-zero only inside the glyph box, and two were held at once
    during removal (~96 MB of mostly-zeros on a 12 MP frame);
  - extract_mask built a full (h, w) uint8 mask that every caller cropped to
    the located box (~12 MB, rebuilt per text-mark detector on the
    memory-tight identify path).

Both now return footprint-sized arrays: the alpha helpers return the
glyph-sized block plus its placement (ax, ay, gw, gh), and extract_mask
returns the box-sized mask. _apply_reverse_alpha consumes the block
directly; the residual inpaint embeds it into one full-frame uint8 mask only
at cv2.inpaint time (which needs a full-frame mask). remove_watermark_
reverse_alpha tracks the winning region alongside best_amap to place it.

Peak allocation drops from O(image*4)x2 + O(image) to O(footprint)x2 +
one gated O(image*1) uint8 mask -- a win every consumer gets, motivated by
the 512 MB raiw.cc worker that OOMs on large decodes. GPU path untouched.

Byte-identical to the old full-frame path (verified: 17 output hashes
across the three engines, inpaint/no-inpaint, detect, and the real
doubao-1.png fixture, unchanged before/after). tests/test_text_mark_memory.py
guards it by reconstructing the old full-frame path inline and asserting
equality, so the proof survives a cv2/asset bump, and pins the O(footprint)
shape so a regression to full-frame fails loudly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 10:01:07 -07:00
Victor KuznetsovandClaude Opus 4.8 d4d9429328 feat(identify): attribute Canva and BytePlus C2PA; fix BytePlus->Adobe mislabel
Mining the local production corpus (25,725 imgs) surfaced two AI vendors signing
C2PA that the registry missed:
- Canva (Magic Media) signed "Canva" + trainedAlgorithmicMedia -> detected AI but
  no platform attributed (disproves the old "Canva exports strip C2PA" assumption).
- BytePlus (ByteDance international: Seedream/Seededit) signs "Byteplus Pte. Ltd.";
  the bare volcengine needle missed it, so its output was mis-attributed to "Adobe
  Firefly" via an incidental "Adobe XMP" string the fallback byte-scan picked up.

Adding both to C2PA_AI_VENDORS lets the clean manifest issuer attribute them
directly. Corpus re-run: 16 platform changes, all improvements (3 Adobe->ByteDance
fixes, 4 None/TC260->ByteDance, 9 None->Canva), 0 regressions. An attempted
signer-based attribution fallback was measured and dropped: it regressed 18 images
(friendly ByteDance label -> raw Chinese cert org; IPTC tool name pre-empted).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 21:57:21 -07:00
Victor Kuznetsov be566e8868 chore(release): v0.11.3 2026-06-18 17:28:21 -07:00
Victor KuznetsovandClaude Opus 4.8 9f6c26a439 refactor(c2pa): read manifests via official c2pa-python, keep byte-scan fallback
extract_c2pa_info now uses the c2pa-python Reader first (any container, whole
manifest store incl. ingredient manifests), falling back to the hand-rolled caBX
parser for blobs the validator rejects (synthetic/partial, broken wheel). The
issuer/source-type/SynthID/soft-binding registry scan is shared by both paths
(_populate_registry_fields), so the return-dict contract is unchanged. Also
replaces the dead `from c2pa import has_c2pa_metadata` import in metadata.py with
a real Reader presence check. c2pa-python added as a core dep (MIT/Apache, ~+5MB
RSS, no torch; wheels cover the CI matrix).

Validated on the full local spaces corpus (25,725 imgs): 0 regressions; 384
manifests newly parsed (379 non-PNG JPEG/WebP + 2 PNGs the byte-scanner missed);
3 false Adobe/Microsoft->Google attributions fixed via real-manifest parsing.

The docs/module-internals.md section for this change already landed in 41f6797.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 17:24:58 -07:00
Victor KuznetsovandClaude Opus 4.8 41f67973ce fix(visible): inpaint mid-tone Gemini sparkle instead of a dark diamond
The free `visible` path over-subtracted a faint Gemini sparkle on a
mid-tone background into a darker-than-background brown diamond instead
of removing it (2026-06-18 prod NPS report, "the watermark was not
removed, just its color changed"). The existing over-subtraction guard
only tripped when reverse-alpha drove a footprint pixel fully negative
(the issue #30 dark-background black-pit case); on a mid-tone background
the over-subtraction darkens the core well below the background without
any pixel crossing zero, so the gate missed it and shipped the dark mark.

Add a second over-subtraction signal to `_reverse_alpha_oversubtracts`:
predict the reverse-alpha output at the bright core, (core - a*logo)/(1-a),
and route to the footprint inpaint when it lands more than
`_OVERSUB_DARK_MARGIN` (25) gray levels below the local background ring.
Calibrated wide: clean removals predict within ~12 of background
(demo_banana ~-1), the prod regression ~-40, the issue #30 dark case ~-82.
Corpus-validated on the 479 detected Gemini images: 10 switch reverse-alpha
to inpaint, all of them dark-diamond cases that improve or match; the
other 469 stay byte-identical. demo_banana stays on the reverse-alpha
path (byte-identical).

Also crop both reverse-alpha helpers to the region they actually touch,
a pure O(image) -> O(mark) win that is byte-identical to the full-frame
math (a uint8<->float32 round-trip is exact):
- `GeminiEngine._core_and_bg` converts only the footprint+ring crop to
  gray, not the whole frame (~70 ms -> 0.1 ms on a 12 MP image; it runs
  for both the alpha-gain estimate and the new gate). Verified identical
  across 479 images; detector confidence unchanged.
- `TextMarkEngine._apply_reverse_alpha` computes the blend on the glyph
  crop only (`amap` is zero outside it, so the math is a no-op there):
  ~275 ms -> ~2 ms per placement on a 12 MP frame, up to 2 placements per
  removal. Verified identical across 142 Doubao/Jimeng placements.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 17:19:41 -07:00
Victor Kuznetsov 09fdb4544a fix(invisible): preserve native output dimensions 2026-06-18 16:44:21 -07:00
Victor KuznetsovandClaude Sonnet 4.6 61aa76a591 perf(identify): decode the image once for all visible-mark detectors
identify(check_visible=True) ran the Gemini-sparkle detector and the
Doubao/Jimeng text-mark detector each with its own image_io.imread, so the
same bitmap was fully decoded twice. On a memory-constrained host (the raiw.cc
512 MB web worker, which runs identify on every upload) that doubled the peak
decode allocation and contributed to OOM restarts.

Decode once in identify() and pass the BGR array to both detectors. The detect
methods already accept an NDArray, so this only threads the pre-decoded array
through: detect_sparkle_confidence and the two _visible_* helpers gain an
optional image= param that, when None, preserves the old self-read behavior
(so direct callers and the cv2-missing/unreadable paths are unchanged).

Only the visible path is deduplicated; the optional check_invisible decoders
are unaffected (and off on the web hot path). Adds a test asserting
identify(check_visible=True, check_invisible=False) decodes exactly once.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 11:13:17 -07:00
Victor KuznetsovandClaude Opus 4.8 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
Victor KuznetsovandClaude Opus 4.8 41a2af2ecb fix(cli): preserve SynthID uncertainty in no-visible-mark message
The 'no signal' branch of the visible no-mark path claimed 'No AI provenance
signal found either', which reads as 'the image is clean'. A missing metadata
proxy is not proof an invisible pixel watermark (SynthID) is absent: it cannot
be detected once metadata is gone and may have been stripped upstream. The
message now preserves that uncertainty and routes to both 'all' (regenerate
pixels) and 'erase'. Regression-guarded by the SynthID/all asserts in
test_cli.py. CLAUDE.md visible-command note updated to match.

Also adds a 'Scope and non-goals' section (CLAUDE.md + README): removing
AI-provenance marks on the user's own content is in scope; stripping
stock/paid-content watermarks (Shutterstock/Getty/iStock, classifieds) is out
of scope by principle, not by difficulty.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 19:30:49 -07:00
Victor KuznetsovandClaude Opus 4.8 d8cdc9f478 docs: correct stale strength-ladder values in remove_watermark docstring
The convenience wrapper's docstring still quoted the pre-2026-06 ladder
(0.10 OpenAI / 0.15 Google / 0.15 unknown). The live constants in
watermark_profiles.py are 0.20 / 0.30 / 0.30, applied to both the controlnet
and sdxl pipelines. Docstring only; behaviour was already correct via
vendor_for_strength + resolve_strength.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 09:51:09 -07:00
Victor Kuznetsov 6237429610 chore(release): v0.11.2 2026-06-12 21:37:04 -07:00
Victor KuznetsovandClaude Opus 4.8 30b56f0ea3 fix(cli): stop silent passthrough when visible finds no known mark
When `visible --mark auto` (or an explicit `--mark` with detection on) found
no registered mark, it exited 0 without writing output -- which a wrapping
service reads as success and re-serves the unchanged input. ~74% of real
uploads carry no registered visible mark, so this was the dominant "it didn't
work" / NPS score-0 failure mode.

Now it runs a cheap metadata-only identify, prints actionable guidance (route
to `all` for an invisible/metadata mark, or `erase` for an arbitrary logo),
writes no output file, and exits EXIT_NO_VISIBLE_MARK (2) -- distinct from
success (0) and a hard error (1) so the caller can surface the message.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 21:36:56 -07:00
Victor KuznetsovandClaude Opus 4.8 b08405bece chore(release): v0.11.1
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 12:15:20 -07:00
Victor KuznetsovandClaude Opus 4.8 28569bd05d fix(gemini): recover sub-0.85 corner sparkles via top-K fusion selection
The 256->512 detection-search widening (v0.8) let a large, low-gradient
shape match outrank a genuine mid-size corner sparkle whose raw NCC sits
below the 0.85 corner-promote gate, so `identify` read `unknown` on Gemini
images that v0.7.2 caught (reporter osachub: scale-48 sparkle on light
bedding -- true sparkle spatial 0.775 / grad 0.960 / fusion 0.676, but the
size-weighted argmax locked onto a decoy at spatial 0.628 / grad 0.036).

detect_watermark now keeps the top-K (_SELECT_TOPK=3) size-weighted
candidates (NMS-deduped) plus the corner-promote candidate, scores each by
full fusion (spatial+gradient+variance) via the extracted _grad_var_scores
helper, and selects the highest -- the gradient term lifts the true sparkle
over the decoy. Ranking by the SIZE-WEIGHTED score (not a raw-NCC argmax)
preserves tiny-patch suppression: a raw-NCC argmax re-admitted 16-18px
content false positives (14/65 doubao + 4/11 jimeng visible images). Top-K
adds zero flips on the doubao/jimeng corpora and leaves the 495-image Gemini
set unchanged (479 detected) while recovering the reporter's image at 0.676.

- _grad_var_scores: gradient/variance scoring factored out of detect_watermark
- confidence = best_fused (drop the duplicated fusion recompute)
- tests: rename test_promotion_is_what_rescues_it ->
  test_size_weighted_search_alone_traps_on_the_decoy (corner-promote is no
  longer the sole rescue path); add a deterministic regression test mirroring
  the real spatial/grad signature
- docs: module-internals.md detector section + CLAUDE.md mechanism map

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 12:04:20 -07:00
Victor KuznetsovandClaude Fable 5 c8bc4b7c68 chore(release): v0.11.0
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 10:03:51 -07:00
Victor KuznetsovandClaude Fable 5 a8e218acf6 Make all fail loudly when the gpu extra is missing
Step 2 (invisible/SynthID) was skipped with a quiet inline warning and the
run still exited 0, so a missing [gpu] extra was mistaken for a clean result
(recurring #14/#47). Add a prominent end-of-run banner and a non-zero exit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:58:49 -07:00
Victor KuznetsovandClaude Fable 5 ad7e4ee08b feat(identify): close 3 detector gaps found on the spaces corpus (06-05..06-11)
- AIGC: parse the bare ``AIGC{...}`` blob form (label glued to its JSON in a
  JPEG APP segment near the JFIF header), and scan both raw-JSON forms in one
  fall-through loop so a quoted ``"AIGC"`` later in an XMP packet no longer
  shadows a real bare label earlier in the file (3 files read unknown before).
- Integrity clash rule 2: a camera device + an AI marker from the SAME C2PA
  manifest (Google Pixel Magic Editor / Pixel Studio edit chain) is a legitimate
  edit chain, not a contradiction. Fire only when the AI marker's source is
  independent of the camera's manifest; pure cameras (Leica/Sony/Nikon) are
  unaffected (2 Pixel files mis-flagged before).
- New c2pa_cloud_manifest detector: surface a C2PA 2.4 Durable Content
  Credentials cloud-manifest reference (Adobe cai-manifests.adobe.com) as a
  medium provenance signal when the embedded manifest is stripped. Provenance
  only, never asserts is_ai (2 files read fully unknown before).

identify reuses its already-loaded scan head for the cloud check (no second
read). +7 tests; CLAUDE.md + README synced.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:28:15 -07:00
Victor KuznetsovandClaude Opus 4.8 d763581ed3 chore(release): v0.10.3
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 15:53:50 -07:00
Victor KuznetsovandClaude Opus 4.8 0a77d3198e chore(release): v0.10.2
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 10:38:50 -07:00
Victor KuznetsovandClaude Fable 5 5777458296 chore(release): v0.10.1
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 17:08:44 -07:00
Victor KuznetsovandClaude Fable 5 295e7ada2b chore: project review (dev tools in extras, dep upgrades, optional-deps guard, stale cleanup)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 17:03:17 -07:00
Victor KuznetsovandClaude Opus 4.8 826cfdb82a chore(release): v0.10.0
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 13:24:37 -07:00
Victor KuznetsovandClaude Opus 4.8 2fcd00ced0 fix: address whole-project code review (visible all/batch, engine consolidation, I/O)
Nine findings from a high-effort project-wide review, fixed and verified
(571 passed, ruff/pyright clean):

Correctness:
- all/batch now remove Doubao/Jimeng/Samsung visible text marks: the visible
  step routes through the registry (new cli._remove_visible_auto) instead of a
  hardcoded GeminiEngine, so they no longer leave the wordmark intact.
- batch always reads the original source (dropped the out_path-reuse that
  re-processed already-cleaned outputs on a re-run).
- img2img_runner only retries the diffusion call on the deprecated-callback
  TypeError; any other TypeError now propagates instead of double-running.
- gemini detect/remove and the reverse-alpha engines normalize channels via a
  new image_io.to_bgr, fixing a grayscale/BGRA crash in the FP-gate path.
- _png_late_metadata advances its cursor by the clamped length, so a malformed
  chunk length no longer aborts the late AI-label scan.

Cleanup / efficiency:
- Consolidate the ~90%-identical Doubao/Jimeng/Samsung engines into a shared
  config-driven _text_mark_engine.TextMarkEngine base; each engine is now a thin
  subclass (TextMarkConfig + test shims). Behavior is byte-exact (the three
  engine test suites pass unchanged). Registry adapters collapse to one
  _text_mark(...) row each. Gemini stays a separate engine.
- scan_head is memoized per (path, size, mtime), so identify() reads the file
  head once instead of ~8 times.
- invisible_engine post-processing decodes/encodes the output once (chained in
  memory) instead of 2-4 times across stages.
- Remove the orphaned get_model_id_for_profile (+ CONTROLNET_PROFILE); derive
  the --strength help from the strength constants (strength_default_help) so it
  cannot drift; share the --pipeline/--strength click options; simplify the
  retired --auto resolver.

Net -835 lines. Tests added for the registry-routed visible pass, to_bgr,
the polish/model/guidance wiring, and strength_default_help. CLAUDE.md updated
for the new base module, the engine/registry changes, image_io.to_bgr, and the
scan_head cache.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 13:21:13 -07:00
Victor KuznetsovandClaude Opus 4.8 b1189549b8 feat(invisible): controlnet default, unified strength, retire --auto, add --model/--guidance-scale
Overhaul the diffusion-removal surface around a single robust default and a
complete, consistent CLI.

Pipeline + strength:
- controlnet is now the DEFAULT pipeline (CLI --pipeline + both engine ctors).
  With the certified higher strength it clears both photoreal and flat-graphic
  content, whereas plain SDXL left SynthID on flat graphics.
- Rename the plain-SDXL profile default -> sdxl; "default" stays as a back-compat
  alias (normalize_profile + a click callback that warns).
- Unify the strength ladder: resolve_strength applies ONE vendor-adaptive ladder
  (the certified controlnet floors OpenAI 0.20 / Google 0.30 / unknown 0.30) to
  both pipelines. sdxl is the weaker remover on its own hard case (flat fills),
  so the certified floor is the right floor for it too.

CLI completeness:
- Add --model (HF model id) to invisible + batch (was only on all) and
  --guidance-scale (CFG) to all three diffusion commands; both were library
  knobs the CLI did not expose.
- Flip --adaptive-polish to ON by default (it self-gates to a no-op where there
  is no detail deficit, so default-on is safe).
- Share --pipeline / --strength / --model / --guidance-scale as single
  decorators so invisible/all/batch keep an identical surface; the --strength
  help is derived from the strength constants (strength_default_help) so it can
  never drift from the ladder.

Removals:
- Delete the auto_config content-detection planner + its YuNet/DBNet assets
  (~2.6 MB): with controlnet always the pipeline and the polish self-gating, the
  face/text/edge detection no longer changed behavior. --auto is now a deprecated
  no-op that only warns (the polish it enabled is the default).

Docs (README, CLAUDE.md, docs/synthid.md) updated throughout; added an
InvisibleEngine Python API example. Tests cover the alias warnings, the
polish default, and the --model/--guidance-scale wiring.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 12:40:45 -07:00
Victor KuznetsovandClaude Opus 4.8 efc5b4a9af docs(auto): drop stale face-restore mentions from --auto
The face-restore family was removed in 20d7eda, but the auto_config
module docstring still claimed "PhotoMaker face restoration is enabled
when a face is present" and the --auto help text (CLI + README example)
listed "face restore" as something --auto picks. A detected face now
only routes to the controlnet pipeline (canny preserves face STRUCTURE,
not identity); there is no identity restoration. Comments/docstrings/help
only, no code behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 11:12:53 -07:00
Victor KuznetsovandClaude Opus 4.8 ea098cf1be chore(release): v0.9.0
BREAKING:
- Drop `--restore-faces` / `--restore-faces-method` CLI flags
- Drop `restore`, `photomaker`, `instantid` extras
- Drop `restore_faces` / `restore_faces_method` params from
  InvisibleEngine.remove_watermark and AutoConfig

Rationale (full empirical record in
docs/synthid-robust-identity-research-2026-06-08.md "Empirical follow-up"):
every face-restore approach evaluated 2026-06-04 - 2026-06-08 (GFPGAN-on-
cleaned, PhotoMaker-V2, InstantID txt2img, InstantID img2img-on-cleaned
at three parameter sweeps) regenerates the face via SDXL diffusion --
output face pixels are diffusion-fresh, so the regenerated face inherits
SDXL's "clean skin" aesthetic and loses original identity precision. The
result looks MORE AI-generated than the cleaned image, not less. The
cleaned controlnet 0.20 image is the least-AI face state we can reach
without re-introducing SynthID.

License:
- MIT -> Apache 2.0 (Apache adds an explicit patent grant + trademark
  clause; better fit with the upstream Apache projects this library
  mirrors / depends on -- diffusers, transformers, controlnet-aux,
  xinsir's controlnet weights)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 21:28:09 -07:00
Victor KuznetsovandClaude Opus 4.8 20d7eda96a remove: drop all face-restore code (regeneration, not preservation)
Empirical conclusion from the 2026-06-04 - 2026-06-08 Modal cert sweeps:
every face-restore approach we built (GFPGAN-on-cleaned, PhotoMaker-V2,
InstantID txt2img, InstantID img2img-on-cleaned at three parameter
settings) regenerates the face via SDXL diffusion rather than preserves
it. Output face pixels are diffusion-fresh, so the regenerated face
inherits SDXL "clean skin" aesthetic and loses original identity
precision -- it looks MORE AI-generated than the cleaned image, not
less. The cleaned image from the main controlnet 0.20 removal pass is
the least-AI face state we can reach without re-introducing SynthID.

Nothing in the restore family achieves the actual goal (preserve the
original person's face). Keeping them around as opt-in invites users to
ship something that defeats the point. Removing entirely.

Library changes:
- Deleted src/remove_ai_watermarks/instantid_restore.py
- Deleted src/remove_ai_watermarks/photomaker_restore.py
- Deleted tests/test_instantid_restore.py
- Deleted tests/test_photomaker_restore.py
- Removed `instantid` and `photomaker` extras from pyproject.toml
- Removed `[tool.hatch.metadata] allow-direct-references = true` (was
  only needed for the photomaker git+ URL)
- InvisibleEngine.remove_watermark: dropped `restore_faces` +
  `restore_faces_method` params, removed both `_restore_faces_instantid`
  and `_restore_faces_photomaker` private methods, removed dispatch
- CLI: dropped `_restore_faces_options` decorator, all four cmd_*
  signatures lose `restore_faces` + `restore_faces_method`, kwarg passes
  to remove_watermark dropped
- _apply_auto: dropped `restore_faces` from tuple shape (was unused after
  the engine no longer takes it)
- auto_config.AutoConfig: dropped `restore_faces` field; `plan()` no
  longer sets it; `reason` no longer mentions it
- Tests updated accordingly (test_auto_config.TestReason no longer asserts
  "face-restore on" in the reason string)

Docs updated:
- CLAUDE.md: removed the photomaker extras bullet, the Face restore
  trade-off bullet, the instantid_restore.py + photomaker_restore.py
  module bullets; replaced restore mentions in watermark_remover and
  controlnet bullets and prod recipe with the empirical conclusion
- README.md: removed both `--restore-faces` callouts and the install
  snippet; the feature bullet and auto-mode comment updated
- docs/synthid-robust-identity-research.md: added Status-retired notice
  at the top pointing at the 2026-06-08 followup

raiw-app:
- modal_cert.py: dropped `--restore-faces` flag entirely; sweep() no
  longer takes restore_faces; pinned _LIB_SPEC to `[gpu]` extras (no
  `photomaker` / `instantid` extras), points at main

ruff + strict pyright clean; 569 tests pass; 18 restore-specific tests
gone.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 21:21:58 -07:00
Victor KuznetsovandClaude Opus 4.8 567f3ae729 docs(restore): document that restore methods REGENERATE, not preserve
Empirical conclusion from the 2026-06-04 - 2026-06-08 cert sweeps:
every shipped face-restore method (GFPGAN-on-cleaned, PhotoMaker-V2,
InstantID txt2img, InstantID img2img-on-cleaned at three parameter
settings) regenerates the face from an ArcFace embedding via SDXL
diffusion. Output face pixels are diffusion-fresh, which makes the
regenerated face look MORE AI-generated than the cleaned image (gloss,
symmetric pores, SDXL "clean skin" aesthetic) regardless of license.

The cleaned image from the main controlnet 0.20 removal pass is the
LEAST-AI state we can reach without re-introducing SynthID; any restore
on top trades original-look for embedding-driven regeneration. The
fundamental issue is structural: ArcFace encodes "general look" at 512
dimensions, SDXL decodes that into pixels with the inherent SDXL
aesthetic. Stronger identity push (higher strength + IP-Adapter scale)
makes the face closer to the embedding but more AI-looking; weaker push
leaves identity to drift further. No parameter setting recovers original
identity AND looks less AI than cleaned.

Production conclusion: do not ship `--restore-faces` in any monetized
deployment. The extras (`instantid`, `photomaker`) stay in the library
for research / personal use where users explicitly want regeneration.
Documented at every entry point:
- CLAUDE.md: new "Face restore trade-off" bullet + every restore mention
  rewritten to "REGENERATES, does NOT recover"; controlnet bullet updated
- README.md: feature bullet + callout + secondary mention all updated
- docs/synthid-robust-identity-research-2026-06-08.md: appended
  "Empirical follow-up" section documenting the InstantID sweep phases
  (Phase 1 txt2img v1/v2/v3, Phase 2 img2img defaults + stronger params)
- docs/controlnet-removal-pipeline-research.md: updated restore-faces
  bullet to reflect the empirical conclusion
- CLI help: `_restore_faces_options` docstring + `--restore-faces` /
  `--restore-faces-method` help text all updated

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 21:08:11 -07:00
Victor KuznetsovandClaude Opus 4.8 7d8af7882a tune(instantid): raise IP-Adapter + landmark scale + strength for stronger identity
First img2img cert sweep: scene/lighting integration was excellent on both
single (tatsunari) and group (gemini_3) photos, but the regenerated faces
were "recognizable similar people" rather than the original individuals.
The cleaned face crop (which has already drifted from original through the
main controlnet 0.20 removal pass) was competing as a structural prior;
at the previous parameter settings InstantID's ArcFace branch couldn't
dominate it.

Push the identity signal:
- `ip_adapter_scale`: 0.8 -> 1.0 at load time (full IP-Adapter strength)
- `controlnet_conditioning_scale`: 0.8 -> 1.0 default (landmark anchor)
- `img2img_strength`: 0.55 -> 0.7 default (more denoise, less cleaned
  structure survives, more room for the diffusion to render ArcFace)

The cleaned image already passed the SynthID oracle, so the absolute floor
on strength is "any positive value" -- raising it only increases the
freedom of the diffusion to inject identity (SynthID-safety isn't reduced
by higher strength, because the noise injection only destroys more of the
input pixels).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 20:54:41 -07:00
Victor KuznetsovandClaude Opus 4.8 8ed2d16a23 fix(instantid): pass trust_remote_code=True for local custom_pipeline
The img2img run silently produced an identity output because
DiffusionPipeline.from_pretrained refused to load the local custom_pipeline
.py without `trust_remote_code=True` (emits a single-line warning to stderr,
then falls back to a default class). load_ip_adapter_instantid then
AttributeError'd, our outer except logged + skipped, and the saved file
was the un-restored cleaned image (exact byte size match against the
no-restore baseline -- 250988 bytes).

We fetch the file from a pinned raw.githubusercontent URL we control, so
trust_remote_code is safe to opt in here.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 20:47:26 -07:00
Victor KuznetsovandClaude Opus 4.8 2687604b24 feat(instantid): switch from txt2img to img2img on cleaned crop
The txt2img architecture (generate face from scratch in a fresh 1024 scene)
fundamentally couldn't fix multi-face patchwork: each face was a studio
portrait that didn't belong in the surrounding scene (wrong lighting,
frontal pose, neutral expression vs the original group photo's varied
angles and smiles). Tight crop + elliptical alpha + color match smoothed
the seams but didn't make the faces look like they were SHOT in the scene.

Replacing with img2img-on-cleaned: feed the CLEANED face crop as the img2img
source, so the diffusion sees the actual scene context (shoulders, hair
edges, lighting direction, shadows) and harmonises the regenerated face
with it. Identity still flows through the ArcFace embedding (from original)
+ landmark ControlNet (kps from original) -- both semantic / pure geometry,
neither carries pixels.

SynthID safety preserved by construction:
- img2img source pixels = cleaned crop = already oracle-verified clean
- ArcFace embedding = 512-d semantic vector from original, no pixel content
- Landmark stick figure = colour-coded geometry, no source pixels
- img2img noise injection at strength 0.55 destroys any residual high-freq
  pattern in the cleaned crop
- Pipeline is the upstream StableDiffusionXLInstantIDImg2ImgPipeline,
  inherits from StableDiffusionXLControlNetImg2ImgPipeline; we still patch
  check_inputs to neutralise the same diffusers-0.38 positional shift the
  txt2img variant had

Implementation:
- New _fetch_img2img_pipeline_file() caches the upstream pipeline file from
  GitHub raw on first use (not on PyPI / HF Hub, has to be downloaded
  separately)
- _get_pipeline() now loads StableDiffusionXLInstantIDImg2ImgPipeline via
  custom_pipeline=<cached path>
- restore_faces_instantid() crops the SAME bbox from both original and
  cleaned, runs InsightFace on original (sharper embedding), feeds cleaned
  crop as img2img source, ArcFace+landmark as conditioning
- New img2img_strength=0.55 parameter (was no strength knob in txt2img mode)
- Composite path unchanged (elliptical alpha + color_match)
- 9 control-flow tests still pass (the mock pipe call shape change is
  absorbed by the kwargs-only fake)

Cert sweep will validate on tatsunari (single) first per user request.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 20:43:27 -07:00
Victor KuznetsovandClaude Opus 4.8 cdd6bd1fea feat(instantid): tighter face ellipse + color match for cleaner multi-face composite
Second multi-face iteration. v1-rect: full-1024 frame + Gaussian rectangle ->
patchwork. v2-ellipse: tight crop + ellipse 0.45*bw x 0.55*bh -> ellipse
exceeds bbox vertically and clips forehead/chin on single portrait, plus
group-photo faces visibly drift cooler than the warm bar background. v3:

1. **Smaller ellipse axes**: 0.32*bw x 0.42*bh. Both fit inside the bbox (since
   axes are radii from center, 0.32*bw extends 0.64*bw total width and
   0.42*bh extends 0.84*bh total height) so no chin/forehead clip even on
   non-square boxes. Face shape: vertically elongated (0.42 vs 0.32),
   matching real face geometry.

2. **Wider feather**: `min(bw, bh) // 5` instead of // 8. Edges fade over a
   wider band so the elliptical seam is less visible.

3. **Per-channel mean color match** (`_color_match`): before compositing,
   shift the regenerated face's mean BGR to match the cleaned canvas region
   where it lands. Each InstantID generation has independent SDXL noise so
   white balance drifts -- matching means equalises tone (warm bar / cool
   face -> warm face) without rescaling contrast.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 20:25:34 -07:00
Victor KuznetsovandClaude Opus 4.8 92c7245e2d chore: drop unused _composite_faces import
Linter caught it after the elliptical composite swap.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 20:18:37 -07:00
Victor KuznetsovandClaude Opus 4.8 1786f6de9f feat(instantid): multi-face anti-patchwork (tight-crop + elliptical composite)
Group-photo cert sweep last round produced the same "patchwork quilt" failure
mode as PhotoMaker-V2: each face is regenerated as a fresh 1024x1024 SCENE
(face + background + lighting), then composited as a Gaussian-feathered
RECTANGLE into the 2x square box around the original face. The rectangle's
corners carry regenerated background pixels with different colors / textures
per face, and the rectangular Gaussian feather lets them bleed into the
cleaned image -- 9 face renders with 9 different backgrounds -> patchwork.

Two changes, both surgical:

1. **Tight-crop the regenerated face before composite.** After generation,
   run YuNet again on the 1024 frame to find where the face actually landed,
   then crop tightly around it (matching the 2x padding our input crop uses
   so the face fills its natural slot). Drops the regenerated background's
   peripheral pixels.

2. **Elliptical composite alpha** (`_composite_faces_elliptical`). Instead of
   reusing photomaker_restore's rectangular Gaussian alpha, inscribe an
   ellipse in each face bbox (axes ~0.45*bw x 0.55*bh so the feather edge
   tapers cleanly inside the rectangle, head-silhouette shape), feather only
   the ellipse edge. Bbox corners (regenerated scene context) end up at
   alpha=0 and the cleaned-canvas pixels there stay intact. Only the head
   region is replaced.

Net result: faces stay identity-restored (semantic ArcFace + landmark control
still drives generation) but the canvas around each face is the cleaned
image, not a regenerated frame. No more multi-face patchwork.

Single-portrait case unchanged: there's one face to composite and the cleaned
canvas around it is mostly the background that was already there.

All 9 InstantID control-flow tests still pass (the mock face analyser
responds to both .get() calls with the same fake bbox, so the new
generated-image YuNet step is exercised end-to-end).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 20:18:15 -07:00
Victor KuznetsovandClaude Opus 4.8 4ec8ffec6b fix(instantid): patch check_inputs for diffusers-0.38 + set scale at load time
Two compat bugs caught by the Modal cert sweep, both rooted in diffusers
0.38 vs InstantID's community pipeline expectations:

1. **Positional check_inputs misalignment.** InstantID's __call__ calls
   `self.check_inputs(...)` POSITIONALLY using the parent's ~v0.29 signature.
   Diffusers 0.38 added two new parameters BEFORE `controlnet_conditioning_scale`
   in the parent's signature (`ip_adapter_image`, `ip_adapter_image_embeds`),
   which shifts every positional arg by two slots. The argument that lands in
   the parent's `controlnet_conditioning_scale` slot is actually InstantID's
   `control_guidance_end` -- which a few lines earlier was converted to `[1.0]`
   (a list) by InstantID's auto-broadcasting for the single-controlnet case.
   The parent's check then trips on `not isinstance([1.0], float)` -> TypeError.

   Our inputs are programmatic and validated by our own callers, so neutralising
   `pipe.check_inputs = lambda *a, **k: None` after load is safe. This is the
   standard workaround community ComfyUI ports use for the same compat break.

2. **`ip_adapter_scale` was passed at call time and silently ignored.** It's not
   in `StableDiffusionXLInstantIDPipeline.__call__`'s signature -- the upstream
   API sets the IP-Adapter weight on the ArcFace cross-attention branch at LOAD
   time via `load_ip_adapter_instantid(scale=...)`. Moved the 0.8 default there,
   dropped the call-time kwarg.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 20:07:31 -07:00
Victor KuznetsovandClaude Opus 4.8 53d753f2ad fix(instantid): pre-fetch antelopev2 from HF mirror (InsightFace auto-link is broken)
InsightFace's built-in auto-download for the antelopev2 model pack
(github.com/deepinsight/insightface/releases/download/v0.7/antelopev2.zip)
has been broken since at least 2024 (upstream issues #2517, #2766, called
out in InstantID's README: "manually download via this URL to models/
antelopev2 as the default link is invalid").

When the .onnx files aren't in place, FaceAnalysis.prepare() raises
`assert 'detection' in self.models` -- which is exactly what our Modal
cert sweep hit on the first real run.

Fix: a tiny pre-flight `_ensure_antelopev2()` that pulls the five expected
.onnx files (1k3d68, 2d106det, genderage, glintr100, scrfd_10g_bnkps) from
the HuggingFace mirror `kidyu/antelopev2-for-InstantID-ComfyUI` into
./models/antelopev2/ before FaceAnalysis is instantiated. Idempotent
(skips files that already exist); uses huggingface_hub's cache for free
caching on the Modal volume.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 19:58:40 -07:00
Victor KuznetsovandClaude Opus 4.8 00c559482f fix(invisible-engine): log exc_info + exception class on restore_faces failure
The InstantID cert sweep emitted `restore_faces post-pass failed ()` -- the
exception's str() was empty so the log line told us nothing about what
actually failed. Adding `exc_info=True` plus `type(e).__name__` so the
full traceback and exception class land in the log even when the message
is empty.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 19:53:07 -07:00
Victor KuznetsovandClaude Opus 4.8 a296d5fe46 fix(instantid): inline YuNet detection (the imagined _get_yunet doesn't exist)
The InstantID restore module imported `_get_yunet` from `auto_config`, but
auto_config doesn't export that function -- the YuNet singleton lives inline
inside `detect_face()`. Caught by the Modal cert sweep:

  restore_faces post-pass failed (cannot import name '_get_yunet' from
  'remove_ai_watermarks.auto_config'); keeping un-restored output

Inline the YuNet builder the same way `photomaker_restore` does (read
`auto_config._FACE_SCORE` and the bundled `face_detection_yunet_2023mar.onnx`
asset, build a fresh `FaceDetectorYN` per call). This is the proven pattern
from PhotoMaker and avoids a private-API drift between the modules.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 19:48:21 -07:00
Victor KuznetsovandClaude Opus 4.8 70e8b3a517 feat(face-restore): add InstantID as the default non-commercial restore path
Per the 2026-06-08 deep-research synthesis (docs/synthid-robust-identity-
research-2026-06-08.md), the entire ArcFace-class identity-adapter ecosystem
for SDXL is blocked from commercial use by InsightFace's non-commercial model
packs (antelopev2 / buffalo_l). No commercial-safe ArcFace-grade identity
stack exists today. The user explicitly opted into shipping a non-commercial
restore path (research / personal use; raiw.cc must NOT install the extra).

Architectural choice: InstantID over PhotoMaker-V2 as the default.
- PhotoMaker-V2 (CLIP+ArcFace dual encoder, txt2img only): documented upstream
  identity drift on Asian male faces, visually confirmed in our cert sweep
  (tatsunari rendered as a generic woman; group photo collapsed into a
  patchwork).
- InstantID (ArcFace cross-attention + landmark ControlNet): semantic
  identity branch + spatial weak landmark control, decoupled. Per InstantID
  paper (arXiv:2401.07519) and the research report, stronger identity fidelity
  on single portraits. Critically: NO original face pixels enter the diffusion
  (ArcFace embedding is semantic, landmark stick figure is pure geometry), so
  SynthID is not transported.

Implementation:
- New `src/remove_ai_watermarks/instantid_restore.py` mirrors the
  `photomaker_restore.py` shape (lazy singletons for pipeline + FaceAnalysis,
  per-face crop + _composite_faces from photomaker_restore). Loads the
  InstantID community pipeline via `DiffusionPipeline.from_pretrained(
  custom_pipeline="pipeline_stable_diffusion_xl_instantid")` -- no upstream
  Python package needed; diffusers fetches the file from its community
  examples.
- New `instantid` extra in pyproject (insightface + onnxruntime +
  huggingface-hub). NON-COMMERCIAL block in the comment explains why.
- CLI: `--restore-faces-method [instantid|photomaker]`, default `instantid`.
  Both methods explicitly labeled NON-COMMERCIAL in the help text.
- Engine: dispatch on `restore_faces_method` to either
  `_restore_faces_instantid` or `_restore_faces_photomaker`.
- 9 control-flow tests for InstantID without model download (mirror the
  photomaker_restore.py test pattern + draw_kps helper checks). 587/587 pass.

Diffusers-0.38 compat verified by upstream code inspection: the InstantID
pipeline inherits from `StableDiffusionXLControlNetPipeline`, uses only
public diffusers APIs (`encode_prompt`, `prepare_image`, `prepare_latents`,
`get_guidance_scale_embedding`), uses legacy attention processor API which
diffusers preserves for backward compat. No PhotoMaker-V1-style internal
text_encoder access. End-to-end execution will be validated by the Modal
cert sweep in the next step.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 19:44:17 -07:00
Victor KuznetsovandClaude Opus 4.8 c486badaa8 fix(photomaker-v2): render at SDXL native 1024, use upstream prompt + neg_prompt
The 9-face grid + single-face cert outputs were still mosaic of training-time
faces even after the id_embeds shape fix. WebFetch of the upstream
inference_pmv2.py revealed three mismatches:

1. SDXL at width=height=512 falls into its low-res failure mode (small-detail
   collage / mosaic) on the V2 LoRA. Render at native 1024 then downscale into
   the original face bbox at composite time.
2. Upstream prompt is descriptive ("instagram photo, portrait photo of a woman
   img, colorful, perfect face, natural skin, hard shadows, film grain, best
   quality"). Our generic prompt let SDXL drift away from the ID embedding.
   Adopted the upstream pattern.
3. Upstream V2 explicitly passes negative_prompt; the CFG batch-mismatch we hit
   on V1 isn't a V2 issue. Re-added negative_prompt with the upstream wording
   (asymmetry/worst quality/etc).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 19:11:48 -07:00
Victor KuznetsovandClaude Opus 4.8 b1fed810fd fix(photomaker-v2): don't pre-unsqueeze id_embeds (the pipeline does it)
V2's pipeline forward at line 705 of upstream pipeline.py calls
`id_embeds.unsqueeze(0)` itself to add a batch dim, so callers pass a 2-D
(N_faces, 512) tensor and the pipeline turns it into 3-D. Upstream
inference_pmv2.py shows the canonical form: torch.stack([...]) of per-image
embeddings.

Our previous call .unsqueeze(0)'d on the way in, which the pipeline then
.unsqueeze(0)'d again, giving a (1, 1, 512) shape that the V2 id_encoder
consumed as garbage -- the resulting output was a training-time face collage
(verified visually 2026-06-04 against tatsunari + gemini_3 + the 9-face grid).

Fix: pass torch.stack([torch.from_numpy(embedding)]) -- shape (1, 512) -- so
the pipeline's internal unsqueeze gives the expected (1, 1, 512) inside the
forward. Don't pre-cast dtype either; the pipeline handles that internally.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 19:03:04 -07:00
Victor KuznetsovandClaude Opus 4.8 3d00fed00c fix(photomaker-v2): compute id_embeds via FaceAnalysis2 before pipeline call
The Modal cert sweep against V2 hit the next layer of the API:

  PhotoMakerIDEncoder_CLIPInsightfaceExtendtoken.forward() missing 1 required
  positional argument: 'id_embeds'

V2 forward takes BOTH the CLIP image embedding (computed inside the pipeline from
input_id_images) AND an ArcFace identity embedding (id_embeds) that the caller
must compute. The upstream pipeline does NOT auto-compute it -- inference_pmv2.py
shows the caller using FaceAnalysis2 + analyze_faces to extract the ArcFace
vector from each input ID image and passing id_embeds=torch.stack([...]) into
pipe(...).

Wired the same flow here:
- New _get_face_analyser() singleton (double-checked lock) builds
  FaceAnalysis2(['CUDAExecutionProvider' | 'CPUExecutionProvider']).prepare(...).
  This is the non-commercial step (antelopev2/buffalo_l auto-download on first
  use). Module docstring already calls it out.
- Per face: analyze_faces() -> torch.from_numpy(embedding) -> .unsqueeze(0) to
  match the pipeline's expected (B, D) shape, casting to pipeline.device/dtype.
  Faces InsightFace can't detect inside the crop get skipped (the most likely
  cause would be the diffusion-cleaned face being too small or stylised after
  the main pass; YuNet already gated us into having a face per crop, so this
  should be rare).
- id_embeds= keyword threaded into the pipeline call site alongside the existing
  input_id_images=.

Tests untouched (the V1-only safety guard was already removed in the previous
commit when we swapped V1->V2; the existing 11 tests still pass).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 18:49:10 -07:00
Victor KuznetsovandClaude Opus 4.8 65de8df5c5 refactor(face-restore): drop GFPGAN, ship PhotoMaker-V2 as the sole restore (non-commercial)
Visual review of the GFPGAN-on-cleaned output (9-face grid, 1448x1086) showed it
only polished the already-drifted face without restoring identity — useless for the
"restore who is in the photo" intent. Dropping it.

The shipped restore path is now PhotoMaker-V2, which delivers true identity-from-
embedding face regeneration via a CLIP+ArcFace dual encoder. The ArcFace branch
pulls InsightFace antelopev2/buffalo_l model packs at runtime, which InsightFace
releases under a research-only license, so the whole extra is **NON-COMMERCIAL**.
raiw.cc and any monetized deployment must NOT install the `photomaker` extra.
This is called out at every entry point: CLI flag help, module docstring,
pyproject extra block, CLAUDE.md extras bullet, README install snippet.

Changes:
- Deleted `src/remove_ai_watermarks/face_restore.py` and its tests.
- Deleted the `restore` extra (gfpgan/facexlib/basicsr + scipy<1.18 / numba<0.60
  pins) and the basicsr setuptools<69 build pin from pyproject.toml.
- Restored `src/remove_ai_watermarks/photomaker_restore.py` (V2 this time:
  `TencentARC/PhotoMaker-V2`, `photomaker-v2.bin`, no `pm_version='v1'` override).
- Restored the `photomaker` extra in pyproject with all the upstream-compat
  pins (einops, peft, onnxruntime, insightface) and the `allow-direct-references`
  hatch metadata block.
- `InvisibleEngine` swapped `_restore_faces` -> `_restore_faces_photomaker`;
  `--restore-faces-method` removed (only one method, no choice).
- CLI flag help, CLAUDE.md, README, docs/synthid.md, and
  docs/controlnet-removal-pipeline-research.md all updated.
- docs/synthid-robust-identity-research.md status notice rewritten to list both
  abandoned commercial-safe attempts (V1 + GFPGAN-on-cleaned) and the
  non-commercial trade-off we accepted.

ruff + strict pyright(src/) clean; 578 tests pass (the 9 GFPGAN tests are gone,
the 11 PhotoMaker tests stay green).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 18:41:01 -07:00