Add geometry-only text manifest schema

This commit is contained in:
Victor Kuznetsov
2026-08-25 11:33:21 -07:00
parent f52e40ae24
commit fa83c1444f
6 changed files with 94 additions and 33 deletions
+3 -2
View File
@@ -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]"
+11 -10
View File
@@ -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
+4 -3
View File
@@ -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
+4
View File
@@ -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
@@ -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")
+46 -1
View File
@@ -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)