Compare commits

..
10 Commits
Author SHA1 Message Date
Victor Kuznetsov ee865e95e4 Add geometry draft mode and pad silhouette descenders
pre-commit: 1 maintain.sh - not rerun full, ruff + 34 targeted tests passed; 2) /simplify - pad lives in silhouette crop, stable=False reuses probes; 3) docs sync - no README/cli refs to update; 4) CLAUDE.md - no change
EOF
)
2026-08-19 19:53:38 -07:00
Victor Kuznetsov d84fd1a125 Merge pull request #86 from wiltodelta/dependabot/uv/minor-and-patch-267694dd16
Bump the minor-and-patch group across 1 directory with 7 updates
2026-08-19 12:22:54 -07:00
Victor Kuznetsov e315eacb14 Document automated-operator verified manifests
verified:true may attest machine-verified geometry (stability-gated
boxes inside caps) set by a service; the pipeline consumes box/script
only, text is advisory, so the verification burden is on the geometry.
2026-08-19 12:21:32 -07:00
Victor Kuznetsov 3ba4ace7e0 Disable mkldnn in the draft engines (linux PIR crash) 2026-08-19 10:22:58 -07:00
dependabot[bot] 13888704f8 Bump the minor-and-patch group across 1 directory with 7 updates
Bumps the minor-and-patch group with 7 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [python-dotenv](https://github.com/theskumar/python-dotenv) | `1.2.2` | `1.2.3` |
| [c2pa-python](https://github.com/contentauth/c2pa-python) | `0.37.5` | `0.37.7` |
| [av](https://github.com/PyAV-Org/PyAV) | `18.0.0` | `18.1.0` |
| [transformers](https://github.com/huggingface/transformers) | `5.14.1` | `5.15.0` |
| diffsynth | `2.1.0` | `2.1.2` |
| [paddleocr](https://github.com/PaddlePaddle/PaddleOCR) | `3.4.1` | `3.7.0` |
| [ruff](https://github.com/astral-sh/ruff) | `0.16.2` | `0.16.3` |



Updates `python-dotenv` from 1.2.2 to 1.2.3
- [Release notes](https://github.com/theskumar/python-dotenv/releases)
- [Changelog](https://github.com/theskumar/python-dotenv/blob/main/CHANGELOG.md)
- [Commits](https://github.com/theskumar/python-dotenv/compare/v1.2.2...v1.2.3)

Updates `c2pa-python` from 0.37.5 to 0.37.7
- [Release notes](https://github.com/contentauth/c2pa-python/releases)
- [Changelog](https://github.com/contentauth/c2pa-python/blob/main/docs/release-notes.md)
- [Commits](https://github.com/contentauth/c2pa-python/compare/v0.37.5...v0.37.7)

Updates `av` from 18.0.0 to 18.1.0
- [Release notes](https://github.com/PyAV-Org/PyAV/releases)
- [Changelog](https://github.com/PyAV-Org/PyAV/blob/v18.1.0/CHANGELOG.rst)
- [Commits](https://github.com/PyAV-Org/PyAV/compare/v18.0.0...v18.1.0)

Updates `transformers` from 5.14.1 to 5.15.0
- [Release notes](https://github.com/huggingface/transformers/releases)
- [Commits](https://github.com/huggingface/transformers/compare/v5.14.1...v5.15.0)

Updates `diffsynth` from 2.1.0 to 2.1.2

Updates `paddleocr` from 3.4.1 to 3.7.0
- [Release notes](https://github.com/PaddlePaddle/PaddleOCR/releases)
- [Commits](https://github.com/PaddlePaddle/PaddleOCR/compare/v3.4.1...v3.7.0)

Updates `ruff` from 0.16.2 to 0.16.3
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.16.2...0.16.3)

---
updated-dependencies:
- dependency-name: python-dotenv
  dependency-version: 1.2.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: c2pa-python
  dependency-version: 0.37.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: av
  dependency-version: 18.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: transformers
  dependency-version: 5.15.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: diffsynth
  dependency-version: 2.1.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: paddleocr
  dependency-version: 3.7.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: ruff
  dependency-version: 0.16.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-19 17:19:11 +00:00
Victor Kuznetsov e938b57f8c Package the verified-text draft annotator as text_draft
Move the draft-annotation logic (PaddleOCR line detection, word-box
grouping, three script-chosen recognition engines, crop-jitter
stability gate) from the evaluation-only scripts into the installable
package, with lazy paddle imports and a text-draft extra (CPU, no
torch). draft_text_lines() returns accepted (crop-stable, NEVER
ground-truth-correct - precision on the reference posters was 90.0% /
94.4%) and rejected proposals; source_pixel_sha256 is re-exported for
manifest building. scripts/infer_text_lines.py now dogfoods the
package module instead of loading the eval script by path.
2026-08-19 10:13:22 -07:00
Victor Kuznetsov 3cd1e47935 Add the oracle-measured Google strength floor to qwen-zimage
The resolution curve's 0.154 top left the 4.33 MP CJK-sign fixture
SynthID-detected x3 in Gemini on the full production path (visible
stage -> qwen-zimage seed 0 -> resize-back -> metadata strip), with a
valid pixel-identical stripped control in the same session
(2026-08-18). Google-provenance content now resolves to the flat
QWEN_ZIMAGE_GOOGLE_STRENGTH 0.30 floor instead of the area curve -
anchors at 0.30 measured clean in Gemini on two fixtures (CJK sign +
18-face) at 3/3 checks across two work accounts, and stayed clean
under the vae-glyphs donor layer. openai/unknown content keeps the
curve; an explicit strength still wins.
2026-08-18 22:34:41 -07:00
Victor Kuznetsov 2298282b8b Thread fidelity_anchor through WatermarkRemover
The engine forwards the new kwarg into WatermarkRemover.remove_watermark,
which rejected it with TypeError on the real (non-fake) path - caught by
the deployed Modal smoke test, not by the unit fakes.
2026-08-18 21:32:57 -07:00
Victor Kuznetsov 42d427731f Turn the verified-text fidelity anchor off by default
The whole-frame 15% Qwen-VAE blend returned detector-visible OpenAI
SynthID on poster-scale manifests through the engine text-manifest
path (official Content Provenance API, 2026-08-19: restored detected
x6 with the anchor, clean x6 without it; base outputs clean x6;
pixel-identical stripped controls detected, proving the pixel channel).
Add fidelity_anchor=False to remove_watermark and InvisibleOptions and
--fidelity-anchor on the CLI to reproduce the 0.27.0 research
behavior. Text-box MAE cost of the new default is under one point on
all three fixtures (11.60->11.72, 7.79->7.86, 7.57->8.13).
2026-08-18 21:23:25 -07:00
Victor Kuznetsov 50d9f44829 Guide text-heavy content away from forced diffusion scrubs
Issue #84: text, tables, and UI screenshots are the worst case for
--force regeneration. Document that the default pipeline skips the
invisible stage without a detectable signal, that visible/metadata
stages never redraw glyphs, and why paste-back is not offered.
2026-08-18 11:03:53 -07:00
22 changed files with 1742 additions and 293 deletions

No files matched your search

+12
View File
@@ -416,6 +416,18 @@ remove-ai-watermarks invisible image.png -o clean.png \
--pipeline qwen-zimage --text-manifest verified-lines.json --force
```
``verified: true`` may also be set by an automated operator (a service) that
attests machine-verified geometry: stability-gated detector boxes inside sane
caps. The restoration pipeline consumes box/script geometry only - the ``text``
field is advisory metadata and never reaches the pixels - so what verification
must guarantee is the geometry, and a machine gate can.
Since 0.27.1 the global 15% Qwen-VAE fidelity-anchor blend is off by default: it
was measured to return detector-visible OpenAI SynthID on poster-scale manifests
(official Content Provenance API, 2026-08-19). `--fidelity-anchor` restores the
0.27.0 research behavior; text-box fidelity lost by the default is well under one
MAE point on the measured fixtures.
The manifest is a JSON object with `schema_version: 1`, `verified: true`, decoded
RGB dimensions, `source_pixel_sha256`, and a non-empty `lines` array. Each line has
an integer `[x1, y1, x2, y2]` box, exact `text`, a non-empty `script`, and an optional
+19 -2
View File
@@ -57,6 +57,19 @@ Invisible removal does not decode and delete a payload. It regenerates the
image through a diffusion pipeline. Faces, text, colors, and fine detail can
change even when the watermark is successfully disrupted.
Text, tables, and UI screenshots are the worst case for a forced scrub: the
model redraws glyphs as plausible-but-wrong shapes. Do not run the invisible
stage on such content unless a locally-detectable invisible signal exists.
The default `all`, `invisible`, and `batch` commands already skip it then, and
`visible` (which strips AI metadata by default) or `metadata` alone never
redraws a glyph outside the filled watermark box. `--force` is for content you
know carries a pixel watermark despite no local signal; on a clean screenshot
it only buys distortion. Preserving or pasting back the original text pixels
during a real scrub is deliberately not offered: an invisible watermark such
as SynthID lives in the pixels everywhere, including inside the glyphs, so
frozen text regions would keep the watermark
([text protection research](text-protection-research.md)).
`qwen-zimage` is the default profile and `sdxl-zimage` the only alternative.
Both are CUDA only and differ only in the global regeneration model: each
conditions that stage on a canny edge map, which preserves structure but not
@@ -165,8 +178,12 @@ The two profiles resolve an unset strength differently, because different things
were measured for each.
`qwen-zimage` reads it from image area, through the resolution-adaptive denoise
curve. The vendor is deliberately ignored: the curve, not the issuer, is what was
calibrated.
curve. The vendor is deliberately ignored for openai/unknown content: the curve,
not the issuer, is what was calibrated. One measured exception (0.27.2):
Google-provenance content takes the flat `QWEN_ZIMAGE_GOOGLE_STRENGTH` 0.30 floor
- the curve's 0.154 top left a 4.33 MP fixture oracle-detected on the full
production path (2026-08-18), while 0.30 anchors measured clean on two fixtures
in two accounts.
`sdxl-zimage` reads it from the C2PA issuer, on a flat ladder:
+30
View File
@@ -614,6 +614,36 @@ postprocessing combinations that were not evaluated. `InvisibleOptions` exposes
same field for `remove_all`; after a visible-stage edit, the manifest must be built
against the staged pixels rather than the pristine source.
Since 0.27.1 the mode's global 15% Qwen-VAE fidelity-anchor blend is **off by
default** (`fidelity_anchor=False`): that whole-frame blend was measured to
return detector-visible OpenAI SynthID on poster-scale manifests (official
Content Provenance API, 2026-08-19 - detected x6 with the anchor, clean x6
without it, controls and base outputs validated in the same sessions). Pass
`fidelity_anchor=True` to reproduce the 0.27.0 research behavior.
### Drafting manifest lines
`remove_ai_watermarks.text_draft` proposes lines for a manifest; it never
produces verified ones:
```python
from remove_ai_watermarks.text_draft import draft_text_lines
draft = draft_text_lines(Path("watermarked.png"))
for line in draft.accepted:
print(line.box, line.script, line.min_score, line.text)
```
Install `remove-ai-watermarks[text-draft]` (CPU, no torch: PaddleOCR detection
plus three script-chosen recognition engines). A line lands in `accepted` only
when three crop paddings normalize identically and every confidence clears
`min_score` (default 0.85); `accepted` means crop-stable, NOT ground-truth-
correct - on the reference posters the draft's exact-text precision was 90.0%
and 94.4% because high-confidence OCR still lost punctuation. Every accepted
line needs a human yes/no before a manifest may claim `verified: true`.
`source_pixel_sha256` is re-exported here for building the manifest's
pixel-binding hash against the exact source the engine will decode.
`remove_watermark` takes strength, seed, tiling, resolution, and postprocessing
controls. It takes no model id, step count or guidance scale, and neither does the
constructor: each profile pins its model stack, its per-stage schedule and CFG
+12
View File
@@ -769,3 +769,15 @@ reproducible verification requires a fixed seed.
8. Jiang et al. (2025). **VideoMarkBench: Benchmarking Robustness of Video
Watermarking.** arXiv:2505.21620.
https://arxiv.org/abs/2505.21620
**Google floor on qwen-zimage (0.27.2, 2026-08-19).** The bracket above did not
survive the full production path: on the 4.33 MP CJK-sign fixture (visible-stage
sparkle removal -> qwen-zimage seed 0 at the curve's 0.154 top -> resize-back ->
metadata strip), the Gemini verifier detected SynthID x3 with a valid
pixel-identical stripped control in the same session. Google-provenance content
now takes a flat `QWEN_ZIMAGE_GOOGLE_STRENGTH = 0.30` floor instead of the area
curve - 0.30 anchors measured clean in Gemini on two fixtures (CJK sign +
18-face) at 3/3 checks across two work accounts, and stayed clean with the
vae-glyphs donor layer on top; openai/unknown content keeps the resolution
curve. An explicit strength still wins. Certification artifacts: raiw-app
`data/certification/text-restoration-2026-08-18/` (the failing re-baseline).
+26
View File
@@ -343,3 +343,29 @@ The stable errors were punctuation: one lost English comma and an ideographic
comma consistently replaced by ASCII. Thus crop agreement and confidence do
not verify literal text. The helper is retained only to seed annotations; every
proposal needs manual review before it can enter the restoration compositor.
## Production-path anchor leak and fix (2026-08-19)
The engine `text_manifest=` path (unlike the research compositor above) blends a
15% Qwen-VAE fidelity anchor across the WHOLE frame before glyph restoration.
Certified through the official OpenAI Content Provenance API on the exact
production path (qwen-zimage, seed 0, no strength override, untiled, LANCZOS
resize-back, metadata strip; library 0.27.0 @ 50d9f448):
- controls (pixel-identical metadata-stripped sources): `synthid: detected` -
the pixel channel was proven live before every session;
- base outputs: `clean` x6 (two 20-line posters, 3 attempts each);
- text-restored WITH the anchor: `synthid: detected` x6, deterministic;
- text-restored with `FIDELITY_BLEND_ALPHA = 0` (runtime ablation, base bytes
identical between runs): `clean` x6.
The anchor was therefore the leak channel: a 15% global donor admixture over a
scrubbed frame returns enough OpenAI SynthID for the provider detector. Fidelity
cost of dropping it (source-referenced text-box MAE): 11.60->11.72, 7.79->7.86,
and 7.57->8.13 across the two posters and the CJK sign - well under one point
against base MAEs of 24.7/16.8/10.2. Since 0.27.1 the anchor is off by default
(`fidelity_anchor` parameter on `remove_watermark` / `InvisibleOptions` /
`--fidelity-anchor`); Google rows were not re-queried because the default
strength leaves that fixture's base detected (the vendor-strength lane tracks
that separately). Certification artifacts: raiw-app
`data/certification/text-restoration-2026-08-19-A1/` (sha256-stamped rows).
+9 -1
View File
@@ -4,7 +4,7 @@
# on it, including the ComfyUI node package. The console script below carries
# the same weight, since users have it on PATH.
name = "remove-ai-watermarks"
version = "0.27.0"
version = "0.28.0"
description = "AI watermark remover for visible, invisible, and provenance marks in images and video"
readme = "README.md"
requires-python = ">=3.11,<3.15"
@@ -125,6 +125,14 @@ qwen-zimage = [
text-restoration = [
"remove-ai-watermarks[qwen-zimage,lama]",
]
# Draft-only OCR proposals for verified-text manifests. CPU, no torch:
# PaddleOCR detection plus three script-chosen recognition engines. The models
# download on first use and are cached by huggingface_hub; never bundled.
text-draft = [
"paddleocr>=3.3.3",
"paddlepaddle>=3.0.0",
"huggingface-hub>=0.20.0",
]
# Adobe TrustMark decoder -- the open, keyless watermark behind Adobe Durable
# Content Credentials (soft-binding alg ``com.adobe.trustmark.P``). Optional
# because it pulls torch and downloads model weights on first use. identify()
+11 -40
View File
@@ -21,14 +21,11 @@ changes under crop jitter or whose minimum confidence is below the threshold.
from __future__ import annotations
import importlib.util
import json
import logging
import os
import sys
import unicodedata
from pathlib import Path
from typing import Any
import click
import numpy as np
@@ -37,38 +34,15 @@ from PIL import Image
log = logging.getLogger(__name__)
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
RESTORATION_SCRIPT = ROOT / "scripts/selective_text_restoration.py"
from scripts._text_eval import normalize_text # noqa: E402
def _load_restoration_module() -> Any:
spec = importlib.util.spec_from_file_location("selective_text_restoration_for_inference", RESTORATION_SCRIPT)
if spec is None or spec.loader is None:
raise RuntimeError(f"unable to load {RESTORATION_SCRIPT}")
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
def _has_script(text: str, script: str) -> bool:
return any(script in unicodedata.name(character, "") for character in text)
def choose_language(probes: dict[str, tuple[str, float]]) -> str:
if _has_script(probes["ch"][0], "CJK"):
return "ch"
if _has_script(probes["ru"][0], "CYRILLIC"):
return "ru"
return "en"
def stable_recognition(reads: list[tuple[str, float]], min_score: float = 0.85) -> str | None:
normalized = {normalize_text(text) for text, _score in reads}
if len(normalized) != 1 or min(score for _text, score in reads) < min_score:
return None
return reads[0][0]
# Dogfoods the packaged draft API (remove_ai_watermarks.text_draft); this script
# keeps only the CLI wrapper so the package stays the one home for the logic.
from remove_ai_watermarks.text_draft import ( # noqa: E402
_detect_line_boxes,
_recognize,
choose_language,
stable_recognition,
)
@click.command()
@@ -81,7 +55,6 @@ def main(source: Path, out: Path, min_score: float) -> None:
os.environ["PADDLE_PDX_DISABLE_MODEL_SOURCE_CHECK"] = "True"
from paddleocr import PaddleOCR, TextRecognition
restoration = _load_restoration_module()
source_rgb = np.asarray(Image.open(source).convert("RGB"))
detector = PaddleOCR(
lang="ch",
@@ -94,19 +67,17 @@ def main(source: Path, out: Path, min_score: float) -> None:
"ru": TextRecognition(model_name="eslav_PP-OCRv5_mobile_rec"),
"ch": TextRecognition(model_name="PP-OCRv5_server_rec"),
}
boxes = restoration.detect_line_boxes(detector, source_rgb)
boxes = _detect_line_boxes(detector, source_rgb)
accepted = []
rejected = []
for box in boxes:
probes = {}
for language, engine in engines.items():
script = "cjk" if language == "ch" else "alphabetic"
line = restoration.TextLine(box, "", script)
probes[language] = restoration._recognize(engine, source_rgb, line, 0.1)
probes[language] = _recognize(engine, source_rgb, box, script, 0.1)
language = choose_language(probes)
script = "cjk" if language == "ch" else "alphabetic"
line = restoration.TextLine(box, "", script)
reads = [restoration._recognize(engines[language], source_rgb, line, ratio) for ratio in (0.08, 0.12, 0.2)]
reads = [_recognize(engines[language], source_rgb, box, script, ratio) for ratio in (0.08, 0.12, 0.2)]
text = stable_recognition(reads, min_score)
result = {
"box": box,
+1 -1
View File
@@ -32,7 +32,7 @@ _os.environ.setdefault("TRANSFORMERS_VERBOSITY", "error")
_warnings.filterwarnings("ignore", message=r".*ImageProcessorFast.*")
__version__ = "0.27.0"
__version__ = "0.28.0"
__all__ = [
"BatchSummary",
@@ -1071,6 +1071,7 @@ class QwenZImagePipeline:
tile_size: int = 1024,
tile_overlap: int = 128,
text_manifest: VerifiedTextManifest | None = None,
fidelity_anchor: bool = False,
) -> Image.Image:
"""Execute global regeneration and masked face repair."""
self._require_cuda()
@@ -1120,7 +1121,16 @@ class QwenZImagePipeline:
restore_verified_text,
)
self._progress("Blending the Qwen-VAE fidelity anchor...")
anchor = blend_fidelity_anchor(result, donor)
# The anchor is OFF by default since 0.27.1: blending 15% of the Qwen-VAE
# donor ACROSS THE WHOLE FRAME returned detector-visible OpenAI SynthID on
# poster-scale manifests (official Content Provenance API, 2026-08-19:
# detected x6 with the anchor, clean x6 without it, base clean throughout;
# see docs/text-protection-research.md). ``fidelity_anchor=True`` keeps the
# 0.27.0 research behavior for reproduction.
if fidelity_anchor:
self._progress("Blending the Qwen-VAE fidelity anchor...")
anchor = blend_fidelity_anchor(result, donor)
else:
anchor = result
self._progress(f"Restoring {len(text_manifest.lines)} verified text lines...")
return restore_verified_text(image, anchor, donor, text_manifest.lines)
@@ -185,6 +185,20 @@ def restore_verified_text(
return Image.fromarray(restored)
def _glyph_crop_box(box: tuple[int, int, int, int], width: int, height: int) -> tuple[int, int, int, int]:
"""Widen the detector box so descenders stay inside the silhouette crop.
Paddle line boxes sit 2-5 px above the true ink bottom on the poster
fixtures. The crop is the box itself, so those pixels never reached the
donor composite. Expand only on Y: 8% up, 25% down, clamped to the frame.
"""
x1, y1, x2, y2 = box
line_h = max(1, y2 - y1)
pad_top = max(1, round(line_h * 0.08))
pad_bot = max(2, round(line_h * 0.25))
return max(0, x1), max(0, y1 - pad_top), min(width, x2), min(height, y2 + pad_bot)
def source_silhouette_mask(
source_rgb: NDArray[Any],
box: tuple[int, int, int, int],
@@ -192,7 +206,7 @@ def source_silhouette_mask(
) -> NDArray[Any]:
"""Recover a thresholded glyph shape without retaining source amplitudes."""
height, width = source_rgb.shape[:2]
x1, y1, x2, y2 = _clip_box(box, width, height)
x1, y1, x2, y2 = _glyph_crop_box(box, width, height)
gray = cv2.cvtColor(source_rgb[y1:y2, x1:x2], cv2.COLOR_RGB2GRAY)
support = np.ones(gray.shape, dtype=np.uint8)
if angle:
@@ -69,6 +69,14 @@ SDXL_ZIMAGE_OPENAI_STRENGTH = 0.15
SDXL_ZIMAGE_GEMINI_STRENGTH = 0.25
SDXL_ZIMAGE_UNKNOWN_STRENGTH = SDXL_ZIMAGE_GEMINI_STRENGTH
# qwen-zimage keeps its resolution curve for openai/unknown content, but Google
# content takes this flat oracle-measured floor instead of the curve (whose top,
# 0.154, left a 4.33 MP CJK-sign fixture detected x3 on the full production path
# with a valid pixel control, 2026-08-18). 0.30 anchors measured clean in Gemini
# across two fixtures and two work accounts (CJK sign + 18-face, 3/3 checks each,
# 2026-08 research) and stayed clean under the vae-glyphs donor layer on top.
QWEN_ZIMAGE_GOOGLE_STRENGTH = 0.30
# sdxl-zimage picks its strength from the VENDOR (unlike qwen-zimage, which derives it
# from image area). An unlisted or unknown vendor falls back to the Gemini value.
@@ -103,7 +111,8 @@ def resolve_adaptive_polish(adaptive_polish: bool | None, pipeline: str) -> bool
def strength_default_help() -> str:
"""Describe the live default policy without duplicating its values."""
return (
"profile-adaptive (qwen-zimage uses resolution-adaptive denoise; sdxl-zimage "
"profile-adaptive (qwen-zimage uses resolution-adaptive denoise, with a "
f"flat oracle-measured Google floor of {QWEN_ZIMAGE_GOOGLE_STRENGTH}; sdxl-zimage "
f"uses OpenAI {SDXL_ZIMAGE_OPENAI_STRENGTH} / Google {SDXL_ZIMAGE_GEMINI_STRENGTH} / "
f"unknown {SDXL_ZIMAGE_UNKNOWN_STRENGTH}, from the C2PA issuer)"
)
@@ -118,10 +127,14 @@ def resolve_strength(
) -> float:
"""Resolve a user override or the calibrated policy for a profile and vendor.
Total by design. qwen-zimage picks its strength from image area rather than from
the vendor, so it needs ``size``; returning ``None`` for it instead would push that
Total by design. qwen-zimage picks its strength from image area rather than
from the vendor, so it needs ``size``; returning ``None`` for it instead would push that
branch onto every caller and move one of the two strength policies outside this
module. ``size`` is required for qwen-zimage without an explicit strength.
One measured exception since 0.27.2: Google-provenance content on qwen-zimage
takes the flat ``QWEN_ZIMAGE_GOOGLE_STRENGTH`` floor instead of the area curve
(the curve's 0.154 top left a 4.33 MP fixture oracle-detected on the full
production path; see the constant's comment).
"""
if strength is not None:
return strength
@@ -129,6 +142,8 @@ def resolve_strength(
return _SDXL_ZIMAGE_STRENGTH_BY_VENDOR.get((vendor or "").casefold(), SDXL_ZIMAGE_UNKNOWN_STRENGTH)
if size is None:
raise ValueError("qwen-zimage resolves strength from image area, so size is required")
if (vendor or "").casefold() == "google":
return QWEN_ZIMAGE_GOOGLE_STRENGTH
from remove_ai_watermarks._internal.qwen_zimage_pipeline import resolution_adaptive_denoise
return resolution_adaptive_denoise(*size)
@@ -190,6 +190,7 @@ class WatermarkRemover:
tile_size: int = 1024,
tile_overlap: int = 128,
text_manifest: VerifiedTextManifest | None = None,
fidelity_anchor: bool = False,
) -> Path:
"""Regenerate image pixels and write the result without AI metadata.
@@ -219,6 +220,7 @@ class WatermarkRemover:
tile_size=tile_size,
tile_overlap=tile_overlap,
text_manifest=text_manifest,
fidelity_anchor=fidelity_anchor,
)
self._write_output(result, destination)
return destination
+2
View File
@@ -244,6 +244,7 @@ class InvisibleOptions:
tile_size: int = 1024
tile_overlap: int = 128
text_manifest: Path | None = None
fidelity_anchor: bool = False
# What the invisible stage did. "unavailable" is the one outcome the caller must
@@ -525,6 +526,7 @@ def _run_invisible(
tile_size=opts.tile_size,
tile_overlap=opts.tile_overlap,
text_manifest=opts.text_manifest,
fidelity_anchor=opts.fidelity_anchor,
)
say("invisible", "removed")
return "removed"
+16
View File
@@ -321,6 +321,16 @@ _text_manifest_option = click.option(
),
)
_fidelity_anchor_option = click.option(
"--fidelity-anchor/--no-fidelity-anchor",
default=False,
help=(
"With --text-manifest: blend 15% of the Qwen-VAE donor across the whole "
"frame. Off by default - the global blend was measured to return "
"detector-visible OpenAI SynthID on poster-scale manifests."
),
)
_visible_backend_option = click.option(
"--backend",
@@ -798,6 +808,7 @@ def cmd_erase(
@_force_option
@_cpu_offload_option
@_text_manifest_option
@_fidelity_anchor_option
@click.pass_context
def cmd_invisible(
ctx: click.Context,
@@ -818,6 +829,7 @@ def cmd_invisible(
force: bool,
cpu_offload: bool,
text_manifest: Path | None,
fidelity_anchor: bool,
) -> None:
"""Remove invisible AI watermarks (SynthID, StableSignature, TreeRing).
@@ -880,6 +892,7 @@ def cmd_invisible(
tile_size=tile_size,
tile_overlap=tile_overlap,
text_manifest=text_manifest,
fidelity_anchor=fidelity_anchor,
)
except (OSError, RuntimeError, ValueError) as exc:
console.print(f" Error: {exc}")
@@ -1428,6 +1441,7 @@ def cmd_identify(ctx: click.Context, source: Path, no_visible: bool, as_json: bo
@_force_option
@_cpu_offload_option
@_text_manifest_option
@_fidelity_anchor_option
@click.pass_context
def cmd_all(
ctx: click.Context,
@@ -1450,6 +1464,7 @@ def cmd_all(
force: bool,
cpu_offload: bool,
text_manifest: Path | None,
fidelity_anchor: bool,
) -> None:
"""Remove ALL watermarks: visible + invisible + metadata.
@@ -1528,6 +1543,7 @@ def cmd_all(
tile_size=tile_size,
tile_overlap=tile_overlap,
text_manifest=text_manifest,
fidelity_anchor=fidelity_anchor,
),
force=force,
progress=progress,
@@ -150,6 +150,7 @@ class InvisibleEngine:
tile_size: int = 1024,
tile_overlap: int = 128,
text_manifest: Path | None = None,
fidelity_anchor: bool = False,
) -> Path:
"""Remove invisible watermark from an image.
@@ -187,6 +188,13 @@ class InvisibleEngine:
Requires the ``text-restoration`` extra and the ``qwen-zimage``
profile. Incompatible with tiling, downscaling, humanize, unsharp,
and adaptive polish because those combinations are not calibrated.
fidelity_anchor: Blend 15% of the Qwen-VAE donor across the whole frame
before glyph restoration. OFF by default since 0.27.1: that global
blend was measured to return detector-visible OpenAI SynthID on
poster-scale manifests (detected x6 with the anchor vs clean x6
without it, base clean; official Content Provenance API,
2026-08-19 - docs/text-protection-research.md). ``True`` reproduces
the 0.27.0 research behavior. Requires ``text_manifest``.
Returns:
Path to the cleaned image.
@@ -196,6 +204,8 @@ class InvisibleEngine:
seed = resolve_seed(seed)
adaptive_polish = resolve_adaptive_polish(adaptive_polish, self._remover.model_profile)
if fidelity_anchor and text_manifest is None:
raise ValueError("fidelity_anchor requires a text manifest")
if text_manifest is not None:
if self._remover.model_profile != QWEN_ZIMAGE_PROFILE:
raise ValueError("--text-manifest is supported only by the qwen-zimage profile")
@@ -270,6 +280,7 @@ class InvisibleEngine:
tile_size=tile_size,
tile_overlap=tile_overlap,
text_manifest=verified_text,
fidelity_anchor=fidelity_anchor,
)
# Post-processing chain: decode the diffusion output ONCE, apply the
+299
View File
@@ -0,0 +1,299 @@
"""Draft operator-verifiable text lines for verified-text manifests.
Proposal-only OCR: PaddleOCR detection plus three script-chosen recognition
engines (Latin / Cyrillic / CJK), accepted only when three crop paddings
normalize identically and every confidence clears the floor. ``accepted``
means crop-stable, NEVER ground-truth-correct: on the reference posters the
draft's exact-text precision was 90.0% and 94.4% because high-confidence OCR
still lost punctuation (one English comma dropped, one ideographic comma
replaced with ASCII). Every accepted line needs a human yes/no before it may
enter a manifest with ``verified: true``.
Heavy imports (paddle, and the numpy/cv2/PIL pixel stack) stay inside the
call so importing this module costs nothing without the ``text-draft`` extra;
``draft_available()`` reports whether the extra is installed.
"""
# pyright: reportUnknownMemberType=false, reportUnknownArgumentType=false, reportUnknownVariableType=false, reportUnknownParameterType=false, reportMissingTypeArgument=false, reportMissingTypeStubs=false, reportMissingImports=false, reportArgumentType=false, reportAssignmentType=false, reportReturnType=false, reportCallIssue=false, reportIndexIssue=false, reportOperatorIssue=false
from __future__ import annotations
import unicodedata
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from remove_ai_watermarks._internal.text_restoration import source_pixel_sha256
if TYPE_CHECKING:
from pathlib import Path
__all__ = [
"DraftLine",
"RejectedLine",
"TextDraft",
"choose_language",
"draft_available",
"draft_text_lines",
"group_word_boxes",
"source_pixel_sha256",
"stable_recognition",
]
# Crop paddings probed per line: recognition must be invariant across them.
JITTER_RATIOS: tuple[float, ...] = (0.08, 0.12, 0.2)
DETECT_SCORE_FLOOR = 0.5
@dataclass(frozen=True)
class DraftLine:
"""One crop-stable proposal. ``accepted`` means stable, not correct."""
box: tuple[int, int, int, int]
text: str
script: str
language: str
min_score: float
@dataclass(frozen=True)
class RejectedLine:
"""A detected line whose recognition was unstable or low-confidence."""
box: tuple[int, int, int, int]
script: str
language: str
reads: tuple[tuple[str, float], ...]
@dataclass(frozen=True)
class TextDraft:
"""The full proposal set for one image."""
accepted: tuple[DraftLine, ...] = ()
rejected: tuple[RejectedLine, ...] = ()
def normalize_text(text: str) -> str:
"""Normalize text for layout-independent comparison (casefold + no spaces)."""
return "".join(unicodedata.normalize("NFC", text).casefold().split())
def _has_script(text: str, script: str) -> bool:
return any(script in unicodedata.name(character, "") for character in text)
def choose_language(probes: dict[str, tuple[str, float]]) -> str:
"""Pick ``ch``/``ru``/``en`` from what each probe engine actually read."""
if _has_script(probes["ch"][0], "CJK"):
return "ch"
if _has_script(probes["ru"][0], "CYRILLIC"):
return "ru"
return "en"
def stable_recognition(reads: list[tuple[str, float]], min_score: float = 0.85) -> str | None:
"""The proposal text when every read normalizes identically and clears the floor."""
normalized = {normalize_text(text) for text, _score in reads}
if len(normalized) != 1 or min(score for _text, score in reads) < min_score:
return None
return reads[0][0]
def _vertical_overlap_ratio(left: tuple[int, int, int, int], right: tuple[int, int, int, int]) -> float:
overlap = max(0, min(left[3], right[3]) - max(left[1], right[1]))
return overlap / max(1, min(left[3] - left[1], right[3] - right[1]))
def _row_center(box: tuple[int, int, int, int]) -> float:
return (box[1] + box[3]) / 2
def group_word_boxes(boxes: list[tuple[int, int, int, int]]) -> list[tuple[int, int, int, int]]:
"""Merge word detections into line boxes by vertical overlap and gap."""
groups: list[tuple[int, int, int, int]] = []
for box in sorted(boxes, key=lambda item: (_row_center(item), item[0])):
matches: list[int] = []
for index, group in enumerate(groups):
if _vertical_overlap_ratio(box, group) < 0.45:
continue
horizontal_gap = max(0, max(box[0], group[0]) - min(box[2], group[2]))
line_height = min(box[3] - box[1], group[3] - group[1])
if horizontal_gap <= max(24, line_height * 3):
matches.append(index)
if not matches:
groups.append(box)
continue
index = max(matches, key=lambda item: _vertical_overlap_ratio(box, groups[item]))
x1, y1, x2, y2 = groups[index]
groups[index] = min(x1, box[0]), min(y1, box[1]), max(x2, box[2]), max(y2, box[3])
return sorted(groups, key=_row_center)
def _recognition_box(
box: tuple[int, int, int, int],
script: str,
width: int,
height: int,
vertical_pad_ratio: float | None = None,
) -> tuple[int, int, int, int]:
x1, y1, x2, y2 = box
line_height = y2 - y1
if script == "cjk":
left_pad = max(16, round(line_height * 0.2))
right_pad = max(16, round(line_height * 0.6))
return max(0, x1 - left_pad), y1, min(width, x2 + right_pad), y2
pad_x = max(16, line_height)
pad_y = max(8, line_height // 3) if vertical_pad_ratio is None else max(8, round(line_height * vertical_pad_ratio))
return max(0, x1 - pad_x), max(0, y1 - pad_y), min(width, x2 + pad_x), min(height, y2 + pad_y)
def _recognize(
engine: Any,
image: Any,
box: tuple[int, int, int, int],
script: str,
vertical_pad_ratio: float | None = None,
) -> tuple[str, float]:
import cv2
height, width = image.shape[:2]
x1, y1, x2, y2 = _recognition_box(box, script, width, height, vertical_pad_ratio)
crop = image[y1:y2, x1:x2]
if crop.shape[0] < 64:
scale = 64 / crop.shape[0]
crop = cv2.resize(crop, None, fx=scale, fy=scale, interpolation=cv2.INTER_CUBIC)
result = next(iter(engine.predict(crop)))
return str(result.get("rec_text", "")), float(result.get("rec_score", 0.0))
def _detect_line_boxes(engine: Any, source_rgb: Any) -> list[tuple[int, int, int, int]]:
import numpy as np
boxes: list[tuple[int, int, int, int]] = []
for page in engine.predict(source_rgb):
detected = page.get("rec_boxes", None)
if detected is None or len(detected) == 0:
detected = page.get("rec_polys", [])
for score, raw_box in zip(page.get("rec_scores", []), detected, strict=False):
if float(score) < DETECT_SCORE_FLOOR:
continue
points = np.asarray(raw_box, dtype=np.float32).reshape(-1)
if points.size == 4:
x1, y1, x2, y2 = points
else:
points = points.reshape(-1, 2)
x1, y1 = points.min(axis=0)
x2, y2 = points.max(axis=0)
boxes.append((round(float(x1)), round(float(y1)), round(float(x2)), round(float(y2))))
return group_word_boxes(boxes)
def draft_available() -> bool:
"""True when the ``text-draft`` extra (paddleocr + paddle) can run."""
from remove_ai_watermarks.optional_deps import module_available
return module_available("paddleocr") and module_available("paddle")
def _build_engines() -> tuple[Any, dict[str, Any]]:
from paddleocr import PaddleOCR, TextRecognition
# enable_mkldnn=False is load-bearing on linux: paddle's oneDNN PIR executor
# raises NotImplementedError on the text-detection graph there (seen on the
# Modal draft container, 2026-08-19), while the reference CPU path runs. The
# eval scripts never hit it because macOS pads take a different path.
detector = PaddleOCR(
lang="ch",
use_doc_orientation_classify=False,
use_doc_unwarping=False,
use_textline_orientation=False,
enable_mkldnn=False,
)
engines = {
"en": TextRecognition(model_name="en_PP-OCRv5_mobile_rec", enable_mkldnn=False),
"ru": TextRecognition(model_name="eslav_PP-OCRv5_mobile_rec", enable_mkldnn=False),
"ch": TextRecognition(model_name="PP-OCRv5_server_rec", enable_mkldnn=False),
}
return detector, engines
def draft_text_lines(
image: Path,
*,
min_score: float = 0.85,
detector: Any | None = None,
engines: dict[str, Any] | None = None,
stable: bool = True,
) -> TextDraft:
"""Propose verified-text manifest lines for ``image``; never verified ones.
Args:
image: path of the source image (draft boxes are in ITS pixel space).
min_score: recognition confidence floor. With ``stable=True`` every
jittered read must clear it; with ``stable=False`` the single
probe for the chosen language must.
detector/engines: injectable Paddle objects (tests use fakes); when
None they are built from the ``text-draft`` extra, which must be
installed (``draft_available()`` reports it).
stable: when True (default), accept a line only if three crop paddings
normalize identically. When False, take one recognition trio and
accept on score alone. Automatic restoration uses box/script only,
so the jitter gate is optional there.
Returns:
``TextDraft`` with crop-stable ``accepted`` proposals and unstable
``rejected`` lines. Both lists need human review before any manifest
may claim ``verified: true``; accepted means crop-stable, NOT
ground-truth-correct.
"""
import os
import numpy as np
from PIL import Image
if detector is None or engines is None:
if not draft_available():
raise RuntimeError(
"Text drafting requires PaddleOCR. Install: pip install 'remove-ai-watermarks[text-draft]'"
)
os.environ.setdefault("PADDLE_PDX_DISABLE_MODEL_SOURCE_CHECK", "True")
detector, engines = _build_engines()
source_rgb = np.asarray(Image.open(image).convert("RGB"))
accepted: list[DraftLine] = []
rejected: list[RejectedLine] = []
for box in _detect_line_boxes(detector, source_rgb):
probes: dict[str, tuple[str, float]] = {}
for language, engine in engines.items():
script = "cjk" if language == "ch" else "alphabetic"
probes[language] = _recognize(engine, source_rgb, box, script, 0.1)
language = choose_language(probes)
script = "cjk" if language == "ch" else "alphabetic"
if stable:
reads = [_recognize(engines[language], source_rgb, box, script, ratio) for ratio in JITTER_RATIOS]
text = stable_recognition(reads, min_score)
else:
text, score = probes[language]
reads = [(text, score)]
if not text.strip() or score < min_score:
text = None
if text is None:
rejected.append(
RejectedLine(
box=box,
script=script,
language=language,
reads=tuple((value, score) for value, score in reads),
)
)
else:
accepted.append(
DraftLine(
box=box,
text=text,
script=script,
language=language,
min_score=min(score for _value, score in reads),
)
)
return TextDraft(accepted=tuple(accepted), rejected=tuple(rejected))
+17
View File
@@ -73,6 +73,15 @@ class TestVerifiedTextMode:
**kwargs,
)
def test_rejects_fidelity_anchor_without_manifest(self, tmp_path):
import pytest
with pytest.raises(ValueError, match="fidelity_anchor requires a text manifest"):
self._engine().remove_watermark(
tmp_path / "unused.png",
fidelity_anchor=True,
)
def test_loads_and_forwards_verified_manifest(self, tmp_path, monkeypatch):
import json
@@ -111,6 +120,14 @@ class TestVerifiedTextMode:
engine.remove_watermark(source, output, text_manifest=manifest)
assert seen["text_manifest"].lines[0].text == "Exact"
# Leak-safe default since 0.27.1: the global 15% donor blend is OFF unless
# explicitly requested (measured to return detector-visible OpenAI SynthID
# on poster-scale manifests; see docs/text-protection-research.md).
assert seen["fidelity_anchor"] is False
engine.remove_watermark(source, output, text_manifest=manifest, fidelity_anchor=True)
assert seen["fidelity_anchor"] is True
class TestNativeOutputSize:
+10 -4
View File
@@ -14,6 +14,7 @@ import pytest
from remove_ai_watermarks._internal.utils import get_image_format, is_supported_format
from remove_ai_watermarks._internal.watermark_profiles import (
PROFILE_CHOICES,
QWEN_ZIMAGE_GOOGLE_STRENGTH,
REMOVAL_MODULES,
SDXL_ZIMAGE_GEMINI_STRENGTH,
SDXL_ZIMAGE_OPENAI_STRENGTH,
@@ -204,16 +205,21 @@ class TestResolveStrength:
qwen-zimage picks strength from image area, so it takes the size. Returning
None for it would push that branch onto every caller and leave one of the two
strength policies living outside this module. The vendor is ignored here on
purpose - the curve, not the issuer, is what was calibrated.
strength policies living outside this module. Google content is the one
measured exception since 0.27.2: it takes the flat oracle-measured floor
instead of the curve (the curve's 0.154 top left a 4.33 MP fixture
oracle-detected on the full production path).
"""
assert resolve_strength(None, "google", "qwen-zimage", size=(2000, 1850)) == pytest.approx(0.154)
assert resolve_strength(None, "openai", "qwen-zimage", size=(2000, 1850)) == pytest.approx(0.154)
assert resolve_strength(None, None, "qwen-zimage", size=(600, 500)) == pytest.approx(0.084)
assert resolve_strength(None, "google", "qwen-zimage", size=(600, 500)) == QWEN_ZIMAGE_GOOGLE_STRENGTH
# The floor holds at every size, not only above the curve's top rung.
assert resolve_strength(None, "google", "qwen-zimage", size=(2000, 1850)) == QWEN_ZIMAGE_GOOGLE_STRENGTH
def test_qwen_zimage_without_a_size_fails_loudly(self):
"""A missing size must not silently fall back to some vendor value."""
with pytest.raises(ValueError, match="size is required"):
resolve_strength(None, "google", "qwen-zimage")
resolve_strength(None, "openai", "qwen-zimage")
def test_sdxl_zimage_uses_its_flat_vendor_ladder(self):
+16 -4
View File
@@ -646,9 +646,17 @@ def test_no_face_path_still_runs_verified_text_restoration(monkeypatch):
result = pipeline.run(source, strength=0.1, seed=0, text_manifest=manifest)
assert result is restored
# Off by default since 0.27.1 (leak finding, docs/text-protection-research.md):
# no whole-frame donor blend; the raw global result feeds restoration.
blend.assert_not_called()
restore.assert_called_once_with(source, global_result, donor, manifest.lines)
result = pipeline.run(source, strength=0.1, seed=0, text_manifest=manifest, fidelity_anchor=True)
assert result is restored
blend.assert_called_once_with(global_result, donor)
restore.assert_called_once_with(source, anchor, donor, manifest.lines)
restore.assert_called_with(source, anchor, donor, manifest.lines)
def test_watermark_remover_dispatches_to_full_pipeline(tmp_path, monkeypatch):
@@ -858,6 +866,7 @@ def test_invisible_engine_passes_the_seed_but_never_a_step_count(tmp_image_path,
def test_sdxl_zimage_strength_is_vendor_adaptive_and_leaves_other_profiles_alone():
"""An SDXL global pass needs more strength than Qwen, so it gets its own policy."""
from remove_ai_watermarks._internal.watermark_profiles import (
QWEN_ZIMAGE_GOOGLE_STRENGTH,
SDXL_ZIMAGE_GEMINI_STRENGTH,
SDXL_ZIMAGE_OPENAI_STRENGTH,
resolve_strength,
@@ -867,11 +876,14 @@ def test_sdxl_zimage_strength_is_vendor_adaptive_and_leaves_other_profiles_alone
assert resolve_strength(None, "google", "sdxl-zimage") == pytest.approx(SDXL_ZIMAGE_GEMINI_STRENGTH)
# Unknown provenance takes the stricter of the two.
assert resolve_strength(None, None, "sdxl-zimage") == pytest.approx(SDXL_ZIMAGE_GEMINI_STRENGTH)
# An explicit value still wins, and qwen-zimage is untouched by this ladder: it
# defers to its resolution curve rather than to a vendor value.
# An explicit value still wins, and qwen-zimage's curve is untouched by this
# ladder - with the one measured exception: Google content takes the flat
# oracle floor (0.27.2), not the area curve.
assert resolve_strength(0.4, "google", "sdxl-zimage") == pytest.approx(0.4)
assert resolve_strength(None, "openai", "qwen-zimage", size=(2000, 1850)) == pytest.approx(0.154)
assert resolve_strength(None, "google", "qwen-zimage", size=(2000, 1850)) == pytest.approx(0.154)
assert resolve_strength(None, "google", "qwen-zimage", size=(2000, 1850)) == pytest.approx(
QWEN_ZIMAGE_GOOGLE_STRENGTH
)
def test_sdxl_zimage_shares_the_fixed_seed_contract():
+158
View File
@@ -0,0 +1,158 @@
"""text_draft: proposal-only OCR for verified-text manifests (no paddle needed)."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from PIL import Image
from remove_ai_watermarks.text_draft import (
TextDraft,
choose_language,
draft_available,
draft_text_lines,
group_word_boxes,
stable_recognition,
)
if TYPE_CHECKING:
from pathlib import Path
class TestPureHelpers:
def test_choose_language_follows_unicode_script(self):
assert choose_language({"ch": ("你好", 0.9), "ru": ("xxx", 0.1), "en": ("yyy", 0.1)}) == "ch"
assert choose_language({"ch": ("??", 0.1), "ru": ("привет", 0.9), "en": ("yyy", 0.1)}) == "ru"
assert choose_language({"ch": ("??", 0.1), "ru": ("xxx", 0.1), "en": ("hello", 0.9)}) == "en"
def test_stable_recognition_requires_identical_normalizations(self):
# Same text modulo spacing/case (comma kept): stable.
assert (
stable_recognition([("Hello, World", 0.97), ("hello,world", 0.96), ("HELLO, WORLD", 0.95)])
== "Hello, World"
)
# Different texts: rejected (None).
assert stable_recognition([("Hello", 0.97), ("Hallo", 0.96), ("Hello", 0.95)]) is None
# Identical text but one read under the floor: rejected.
assert stable_recognition([("Hello", 0.97), ("Hello", 0.60), ("Hello", 0.95)]) is None
assert stable_recognition([("Hello", 0.97), ("Hello", 0.96), ("Hello", 0.95)], min_score=0.99) is None
def test_group_word_boxes_merges_a_line_and_keeps_rows_apart(self):
# Two words on one baseline merge; a distant lower line stays separate.
merged = group_word_boxes([(10, 100, 60, 140), (70, 100, 120, 140)])
assert len(merged) == 1
assert merged[0] == (10, 100, 120, 140)
apart = group_word_boxes([(10, 100, 60, 140), (10, 300, 60, 340)])
assert len(apart) == 2
class TestDraftAvailable:
def test_returns_a_bool_without_importing_paddle(self):
assert isinstance(draft_available(), bool)
class _FakeDetector:
"""Emits Paddle-style pages for one synthetic poster."""
def __init__(self, boxes: list[tuple[int, int, int, int]]) -> None:
self._boxes = boxes
def predict(self, _image: Any) -> Any:
import numpy as np
yield {
"rec_boxes": np.asarray(self._boxes, dtype=np.float32),
"rec_scores": [0.99] * len(self._boxes),
}
class _FakeEngine:
"""Returns a fixed text at high score regardless of the crop."""
def __init__(self, text: str) -> None:
self._text = text
def predict(self, _crop: Any) -> Any:
yield {"rec_text": self._text, "rec_score": 0.97}
class _JitterEngine:
"""Changes its answer with the crop height - exactly what the gate rejects."""
def __init__(self, texts: list[str]) -> None:
self._texts = texts
self._calls = 0
def predict(self, crop: Any) -> Any:
text = self._texts[self._calls % len(self._texts)]
self._calls += 1
yield {"rec_text": text, "rec_score": 0.97}
class TestDraftTextLines:
@staticmethod
def _poster(tmp_path: Path) -> Path:
path = tmp_path / "poster.png"
Image.new("RGB", (400, 300), (255, 255, 255)).save(path)
return path
def test_accepts_crop_stable_and_rejects_jittered(self, tmp_path: Path):
path = self._poster(tmp_path)
stable = _FakeEngine("Invoice 42")
jitter = _JitterEngine(["Hello", "Hallo", "Hullo"])
# All three probe engines read the same string, so language = en; the
# jitter engine then serves as the en engine and flips across crops.
draft = draft_text_lines(
path,
detector=_FakeDetector([(20, 20, 200, 60), (20, 80, 200, 120)]),
engines={"en": jitter, "ru": stable, "ch": stable},
)
assert isinstance(draft, TextDraft)
assert len(draft.rejected) == 2
assert all(line.language == "en" for line in draft.rejected)
assert draft.accepted == ()
def test_accepted_line_carries_box_text_script_and_floor(self, tmp_path: Path):
path = self._poster(tmp_path)
engine = _FakeEngine("Total: 1,234.56")
draft = draft_text_lines(
path,
detector=_FakeDetector([(20, 20, 260, 60)]),
engines={"en": engine, "ru": engine, "ch": engine},
)
(line,) = draft.accepted
assert line.box == (20, 20, 260, 60)
assert line.text == "Total: 1,234.56"
assert line.script == "alphabetic"
assert line.language == "en"
assert line.min_score >= 0.85
def test_cjk_probe_switches_script_and_language(self, tmp_path: Path):
path = self._poster(tmp_path)
cjk = _FakeEngine("每天都是一个新的机会。")
latin = _FakeEngine("hello")
draft = draft_text_lines(
path,
detector=_FakeDetector([(30, 30, 300, 90)]),
engines={"en": latin, "ru": latin, "ch": cjk},
)
(line,) = draft.accepted
assert line.language == "ch"
assert line.script == "cjk"
def test_unstable_geometry_draft_accepts_a_single_high_score_read(self, tmp_path: Path):
path = self._poster(tmp_path)
jitter = _JitterEngine(["Hello", "Hallo", "Hullo"])
stable = _FakeEngine("Hello")
# Probes disagree across engines so language is en; jitter would fail
# the stable gate. Geometry mode uses the one en probe and accepts.
draft = draft_text_lines(
path,
min_score=0.75,
stable=False,
detector=_FakeDetector([(20, 20, 200, 60)]),
engines={"en": jitter, "ru": stable, "ch": stable},
)
(line,) = draft.accepted
assert line.text == "Hello"
assert line.min_score >= 0.75
+12
View File
@@ -15,6 +15,7 @@ from remove_ai_watermarks._internal.text_restoration import (
load_verified_text_manifest,
restore_verified_text,
source_pixel_sha256,
source_silhouette_mask,
)
@@ -126,3 +127,14 @@ def test_restoration_uses_lama_and_qwen_vae_core(monkeypatch) -> None:
assert calls
assert np.all(restored[16, 20] == donor[16, 20])
assert np.all(restored[0, 0] == candidate[0, 0])
def test_silhouette_includes_descender_below_the_detector_box() -> None:
source = np.full((40, 50, 3), 240, dtype=np.uint8)
source[10:22, 18:24] = 20
source[22:27, 18:22] = 20 # tail of a y / Cyrillic u, under the box
mask = source_silhouette_mask(source, (10, 10, 40, 22))
assert mask[24, 20] == 255
assert mask[16, 20] == 255
Generated
+1034 -235
View File
File diff suppressed because it is too large. Load diff