diff --git a/README.md b/README.md index bfb677e..aa7cc6a 100644 --- a/README.md +++ b/README.md @@ -202,8 +202,9 @@ remove-ai-watermarks invisible image.png -o clean.png --force ``` Typography-heavy images can opt into the experimental verified-text post-pass. -It requires manually reviewed strings and line boxes; it never trusts OCR as ground -truth or runs automatically: +It accepts manually reviewed strings and line boxes or an operator-verified +geometry-only manifest; it never treats raw OCR output as ground truth or runs +automatically: ```bash uv tool install --force "remove-ai-watermarks[text-restoration]" diff --git a/docs/cli.md b/docs/cli.md index 947688b..6184790 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -408,7 +408,7 @@ the source with the Qwen VAE, blends 15% of that reconstruction into the normal only the reconstructed glyph cores through source-derived silhouettes. It does not run OCR or choose which strings are correct. -Install the combined extra and run only with a manually reviewed manifest: +Install the combined extra and run only with an operator-verified manifest: ```bash uv tool install --force "remove-ai-watermarks[text-restoration]" @@ -416,11 +416,10 @@ 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. +``verified: true`` may also be set by an automated operator that attests +machine-verified geometry: stability-gated detector boxes inside sane caps. Such +operators should use the geometry-only schema 2, which carries no transcription +or script metadata. 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 @@ -428,10 +427,12 @@ was measured to return detector-visible OpenAI SynthID on poster-scale manifests 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 -angle from -30 to 30 degrees. Lines must be in top-to-bottom, left-to-right order. +The manifest is a JSON object with `verified: true`, decoded RGB dimensions, +`source_pixel_sha256`, and a non-empty `lines` array. Schema 1 is retained for +manually reviewed annotations: each line has an integer `[x1, y1, x2, y2]` box, +exact `text`, a non-empty `script`, and an optional angle from -30 to 30 degrees. +Schema 2 is geometry-only: each line has the box and optional angle, with no +required `text` or `script`. Lines must be in top-to-bottom, left-to-right order. The hash binds the annotations to decoded RGB geometry and pixels, so metadata-only container changes remain valid while a resized or edited source fails closed. The experimental helper diff --git a/docs/module-internals.md b/docs/module-internals.md index dbde242..b6bfd64 100644 --- a/docs/module-internals.md +++ b/docs/module-internals.md @@ -960,9 +960,10 @@ requires the same provider-oracle and identity evaluation as a model change. [`_internal/text_restoration.py`](../src/remove_ai_watermarks/_internal/text_restoration.py) implements the opt-in `vae-glyphs` stage. A versioned manifest carries manually -reviewed strings and source-space line boxes, plus a SHA-256 over decoded RGB width, -height, and pixels. Validation happens before model loading. The product never treats -OCR confidence as verification. +reviewed strings and source-space line boxes in schema 1, or verified source-space +geometry alone in schema 2, plus a SHA-256 over decoded RGB width, height, and pixels. +Validation happens before model loading. The library never treats OCR confidence as +verification, and geometry-only operators do not need to invent text or script fields. When enabled, `QwenZImagePipeline` reconstructs the source once through its already loaded Qwen VAE, runs the ordinary global and face stages, blends 15% of the VAE diff --git a/docs/python-api.md b/docs/python-api.md index 81351a5..bff2d77 100644 --- a/docs/python-api.md +++ b/docs/python-api.md @@ -615,6 +615,10 @@ donor uses the same overlapping tiles as the global pass. `InvisibleOptions` exp same field for `remove_all`; after a visible-stage edit, the manifest must be built against the staged pixels rather than the pristine source. +Use manifest schema 1 for manually reviewed text plus script metadata. Automated +operators that verify only text-region geometry should emit schema 2 lines with a +`box` and optional `angle`; no placeholder transcription or script is required. + 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 diff --git a/src/remove_ai_watermarks/_internal/text_restoration.py b/src/remove_ai_watermarks/_internal/text_restoration.py index 58431d2..ab995c6 100644 --- a/src/remove_ai_watermarks/_internal/text_restoration.py +++ b/src/remove_ai_watermarks/_internal/text_restoration.py @@ -13,30 +13,33 @@ import cv2 import numpy as np from PIL import Image +from remove_ai_watermarks._internal.schema import require_schema_version + if TYPE_CHECKING: from collections.abc import Sequence from pathlib import Path from numpy.typing import NDArray -TEXT_MANIFEST_SCHEMA = 1 +TEXT_MANIFEST_SCHEMA = 2 +_SUPPORTED_TEXT_MANIFEST_SCHEMAS = frozenset({1, TEXT_MANIFEST_SCHEMA}) FIDELITY_BLEND_ALPHA = 0.15 GLYPH_FEATHER = 0.5 @dataclass(frozen=True) class VerifiedTextLine: - """One operator-verified source line in source-pixel coordinates.""" + """One operator-verified source region in source-pixel coordinates.""" box: tuple[int, int, int, int] - text: str - script: str + text: str | None = None + script: str | None = None angle: float = 0.0 @dataclass(frozen=True) class VerifiedTextManifest: - """Text annotations cryptographically bound to one decoded RGB source.""" + """Verified text regions cryptographically bound to one decoded RGB source.""" source_pixel_sha256: str width: int @@ -55,17 +58,20 @@ def source_pixel_sha256(image: Image.Image) -> str: def load_verified_text_manifest(path: Path, source: Image.Image) -> VerifiedTextManifest: - """Load and validate a manually verified manifest for exactly ``source``.""" + """Load and validate an operator-verified manifest for exactly ``source``.""" try: payload = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as exc: raise ValueError(f"Cannot read text manifest {path}: {exc}") from exc if not isinstance(payload, dict): raise ValueError("Text manifest must be a JSON object") - if payload.get("schema_version") != TEXT_MANIFEST_SCHEMA: - raise ValueError(f"Text manifest schema_version must be {TEXT_MANIFEST_SCHEMA}") + schema_version = require_schema_version( + payload.get("schema_version"), + contract="text manifest", + supported=_SUPPORTED_TEXT_MANIFEST_SCHEMAS, + ) if payload.get("verified") is not True: - raise ValueError("Text manifest must contain verified=true after manual review") + raise ValueError("Text manifest must contain verified=true after operator verification") rgb = source.convert("RGB") width = _manifest_integer(payload, "width") @@ -82,7 +88,7 @@ def load_verified_text_manifest(path: Path, source: Image.Image) -> VerifiedText raw_lines = payload.get("lines") if not isinstance(raw_lines, list) or not raw_lines: raise ValueError("Text manifest lines must be a non-empty list") - lines = tuple(_load_line(item, width, height, index) for index, item in enumerate(raw_lines)) + lines = tuple(_load_line(item, width, height, index, schema_version) for index, item in enumerate(raw_lines)) if list(lines) != sorted(lines, key=lambda line: (line.box[1], line.box[0])): raise ValueError("Text manifest lines must be in top-to-bottom, left-to-right reading order") return VerifiedTextManifest(actual_hash, width, height, lines) @@ -95,7 +101,7 @@ def _manifest_integer(payload: dict[str, Any], key: str) -> int: return value -def _load_line(item: Any, width: int, height: int, index: int) -> VerifiedTextLine: +def _load_line(item: Any, width: int, height: int, index: int, schema_version: int) -> VerifiedTextLine: if not isinstance(item, dict): raise ValueError(f"Text manifest line {index} must be an object") raw_box = item.get("box") @@ -109,12 +115,15 @@ def _load_line(item: Any, width: int, height: int, index: int) -> VerifiedTextLi x1, y1, x2, y2 = box if not (0 <= x1 < x2 <= width and 0 <= y1 < y2 <= height): raise ValueError(f"Text manifest line {index} box is outside the source dimensions") - text = item.get("text") - script = item.get("script") - if not isinstance(text, str) or not text.strip(): - raise ValueError(f"Text manifest line {index} text must be non-empty") - if not isinstance(script, str) or not script.strip(): - raise ValueError(f"Text manifest line {index} script must be non-empty") + text: str | None = None + script: str | None = None + if schema_version == 1: + text = item.get("text") + script = item.get("script") + if not isinstance(text, str) or not text.strip(): + raise ValueError(f"Text manifest line {index} text must be non-empty") + if not isinstance(script, str) or not script.strip(): + raise ValueError(f"Text manifest line {index} script must be non-empty") angle_value = item.get("angle", 0.0) if isinstance(angle_value, bool) or not isinstance(angle_value, int | float): raise ValueError(f"Text manifest line {index} angle must be numeric") diff --git a/tests/test_text_restoration.py b/tests/test_text_restoration.py index bdc0e2e..6b79bd3 100644 --- a/tests/test_text_restoration.py +++ b/tests/test_text_restoration.py @@ -37,6 +37,17 @@ def _manifest(image: Image.Image) -> dict[str, object]: } +def _geometry_manifest(image: Image.Image) -> dict[str, object]: + return { + "schema_version": 2, + "verified": True, + "source_pixel_sha256": source_pixel_sha256(image), + "width": image.width, + "height": image.height, + "lines": [{"box": [8, 8, 40, 24], "angle": 0.0}], + } + + def test_pixel_hash_ignores_container_metadata(tmp_path) -> None: image = Image.new("RGB", (48, 32), (10, 20, 30)) plain = tmp_path / "plain.png" @@ -63,6 +74,40 @@ def test_verified_manifest_is_bound_to_source_pixels(tmp_path) -> None: assert loaded.lines == (VerifiedTextLine((8, 8, 40, 24), "Exact text", "alphabetic", 0.0),) +def test_geometry_manifest_needs_no_transcription_or_script(tmp_path) -> None: + source = Image.new("RGB", (48, 32), (10, 20, 30)) + path = tmp_path / "regions.json" + path.write_text(json.dumps(_geometry_manifest(source)), encoding="utf-8") + + loaded = load_verified_text_manifest(path, source) + + assert loaded.lines == (VerifiedTextLine((8, 8, 40, 24), angle=0.0),) + + +@pytest.mark.parametrize("field", ["text", "script"]) +def test_schema_one_still_requires_text_metadata(tmp_path, field) -> None: + source = Image.new("RGB", (48, 32), (10, 20, 30)) + payload = _manifest(source) + del payload["lines"][0][field] + path = tmp_path / "lines.json" + path.write_text(json.dumps(payload), encoding="utf-8") + + with pytest.raises(ValueError, match=field): + load_verified_text_manifest(path, source) + + +@pytest.mark.parametrize("schema_version", [True, 0, 3]) +def test_manifest_rejects_unsupported_schema_versions(tmp_path, schema_version) -> None: + source = Image.new("RGB", (48, 32), (10, 20, 30)) + payload = _geometry_manifest(source) + payload["schema_version"] = schema_version + path = tmp_path / "lines.json" + path.write_text(json.dumps(payload), encoding="utf-8") + + with pytest.raises(ValueError, match="Unsupported text manifest schema"): + load_verified_text_manifest(path, source) + + @pytest.mark.parametrize( ("mutation", "message"), [ @@ -120,7 +165,7 @@ def test_restoration_uses_lama_and_qwen_vae_core(monkeypatch) -> None: Image.fromarray(source), Image.fromarray(candidate), Image.fromarray(donor), - (VerifiedTextLine((8, 8, 48, 28), "Exact text", "alphabetic"),), + (VerifiedTextLine((8, 8, 48, 28)),), ) restored = np.asarray(result)