diff --git a/README.md b/README.md index 477e79f..009b2b5 100644 --- a/README.md +++ b/README.md @@ -199,6 +199,19 @@ image came from an AI generator, add `--force`: 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: + +```bash +uv tool install --force "remove-ai-watermarks[text-restoration]" +remove-ai-watermarks invisible image.png -o clean.png \ + --text-manifest verified-lines.json --force +``` + +See the [CLI guide](docs/cli.md#restore-operator-verified-text) for the manifest +schema, compatibility restrictions, and oracle caveats. + See the [installation guide](docs/installation.md) for Homebrew, uv, optional features, and development setup. diff --git a/data/evaluations/fidelity/README.md b/data/evaluations/fidelity/README.md index 73e1af7..497b4bd 100644 --- a/data/evaluations/fidelity/README.md +++ b/data/evaluations/fidelity/README.md @@ -250,6 +250,16 @@ Laplacian variance. The tracked script reproduced the feathered file byte for byte. These two exact-byte verdicts do not certify other images or the larger matrix masks, and the global smoothing fails a strict unchanged-image criterion. +The opt-in production port was rechecked separately on 2026-08-15. Its current +LaMa runtime did not reproduce the earlier evaluation PNG byte for byte, but all +changed pixels were confined to the erased background outside the donor glyph +core. The exact production artifact returned `No OpenAI signals detected` in +3/3 OpenAI Verify runs, while the matched source control returned `Generated +with OpenAI tools` in 2/2 runs in the same Chrome session; expanded details +identified SynthID and no C2PA manifest on the control. The private control and +artifact hashes remain outside the public repository. This certifies only that +runtime, verified manifest, and output, not arbitrary text masks or images. + The Google result is negative. On the synthetic CJK sign case, two separate work-account runs both detected SynthID in the resaved source control and in the exact Qwen-VAE donor output. The candidate improved mean text-box SSIM from diff --git a/docs/cli.md b/docs/cli.md index e296578..116184c 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -398,6 +398,37 @@ schedule, CFG 1.0 and CUDA, so every one of those flags existed only to be refus several layers down. They are not parsed at all now, which fails at the point the user can act on rather than after a model load. +### Restore operator-verified text + +`--text-manifest` enables the experimental `vae-glyphs` post-pass. It reconstructs +the source with the Qwen VAE, blends 15% of that reconstruction into the normal +`qwen-zimage` result, erases the annotated candidate glyphs with LaMa, and composites +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: + +```bash +uv tool install --force "remove-ai-watermarks[text-restoration]" +remove-ai-watermarks invisible image.png -o clean.png \ + --pipeline qwen-zimage --text-manifest verified-lines.json --force +``` + +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 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 +`remove_ai_watermarks._internal.text_restoration.source_pixel_sha256` computes it. + +This mode is supported only by `qwen-zimage` at native untiled geometry with +`humanize=0`, `unsharp=0`, and adaptive polish disabled. `all` also accepts the flag, +but its manifest must match the pixels entering the invisible stage; if visible-mark +removal changes those pixels, the hash check rejects the run. One oracle verdict does +not certify another manifest, seed, model/runtime version, or output hash. + ### Work with limited memory Lower CUDA memory pressure: diff --git a/docs/installation.md b/docs/installation.md index dcca6ef..c249ef8 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -87,6 +87,15 @@ removal, metadata stripping and every `identify` command still run anywhere. Video SynthID regeneration is a separate VAE path and does still run on CPU or MPS; it needs the `diffusion` extra, not this one. +The experimental verified-text post-pass additionally needs LaMa: + +```bash +uv tool install --force "remove-ai-watermarks[text-restoration]" +``` + +That extra includes `qwen-zimage` and `lama`; it does not add OCR. Text strings and +line boxes must be reviewed before the run. + ## Feature extras Extras are composable. Install only the capabilities and file formats the @@ -104,6 +113,7 @@ application actually uses: | `migan` | MI-GAN ONNX fill backend | `visible`, ONNX Runtime | Model download, no Torch | | `lama` | big-LaMa ONNX fill backend | `visible`, ONNX Runtime | Model download, no Torch | | `qwen-zimage` | Invisible image-watermark removal, both CUDA-only profiles | `diffusion`, DiffSynth | Yes | +| `text-restoration` | Opt-in verified Qwen-VAE glyph restoration | `qwen-zimage`, `lama` | Yes | | `all` | Every production feature available on the active Python | All compatible rows above | Yes | | `dev` | Tests, linting, typing, and upstream parity checks | `video`, `detect`, upstream invisible-watermark | Yes, for parity tests | @@ -118,6 +128,8 @@ flowchart LR migan --> visible lama --> visible qwen["qwen-zimage"] --> diffusion + text["text-restoration"] --> qwen + text --> lama heif trustmark ``` diff --git a/docs/known-limitations.md b/docs/known-limitations.md index bb52aa4..bf66a78 100644 --- a/docs/known-limitations.md +++ b/docs/known-limitations.md @@ -69,15 +69,15 @@ difficult faces. The measurements and their OCR and oracle caveats are tracked in [`data/evaluations/fidelity/`](../data/evaluations/fidelity/README.md). A global Z-Image Turbo prototype preserved text substantially better at low strength, but it has no useful cross-provider operating point and is not a -supported profile. The evaluated text restorers also remain research-only: -fresh-font and silhouette variants visibly changed typography, while the -higher-fidelity `vae-glyphs` route still requires verified strings, line -geometry, a separately generated donor, and an independently clean global -anchor. Automatic OCR and line-box proposals are not reliable enough to remove -those requirements, and the exact oracle results do not establish a general -mask, seed, or provider operating range. Qwen-Image-2.0 is hosted-only and -exposes no equivalent low-strength denoise control. Exact experiments, controls, -and pass rates are kept in +supported profile. Automatic text restorers also remain research-only: +fresh-font and silhouette variants visibly changed typography. The higher-fidelity +`vae-glyphs` route is available only as an experimental opt-in with verified strings +and line geometry. It builds its donor internally but still requires an independently +clean global anchor. Automatic OCR and line-box proposals are not reliable enough to +remove those requirements, and exact oracle results do not establish a general mask, +seed, runtime, or provider operating range. Qwen-Image-2.0 is hosted-only and exposes +no equivalent low-strength denoise control. Exact experiments, controls, and pass +rates are kept in [`text-protection-research.md`](text-protection-research.md) and the [`fidelity` evaluation record](../data/evaluations/fidelity/README.md). @@ -192,7 +192,8 @@ certified at a fixed seed. The live resolver is | `qwen-zimage` | CUDA only, large model stack, and limited broad certification across seeds and content. | | `sdxl-zimage` | CUDA only. Its strength ladder is flat per vendor, not a resolution curve, because flat values are what was measured. | -The evaluated text-restoration prototypes are not optional production stages. +Only manually verified `vae-glyphs` is an optional production stage, and it is +experimental rather than a default. OCR plus LaMa recovered literal poster text but changed fonts and worsened whole-image fidelity. Restricting it to OCR-mismatched lines improved the tradeoff but still left a local shadow on one poster. The published AnyText2 SD1.5 checkpoint diff --git a/docs/module-internals.md b/docs/module-internals.md index cdbeb16..c715df0 100644 --- a/docs/module-internals.md +++ b/docs/module-internals.md @@ -949,6 +949,29 @@ orchestration, YuNet integration, SAM selection, masks, sizing helpers, and pixe compositing are implemented for this runtime. Changing a calibrated model input requires the same provider-oracle and identity evaluation as a model change. +#### Verified text restoration + +[`_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. + +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 +reconstruction into that clean result, and calls the shared restoration compositor. +The compositor derives binary source and candidate silhouettes, groups nearby lines, +uses LaMa for the initial and residual-glyph erase passes, paints fresh silhouette +edges, then copies the Qwen-VAE core with a 0.5-pixel feather. The evaluation script +imports these same mask and compositing helpers so the two implementations cannot +silently drift. + +The stage is deliberately narrower than the engine: it rejects `sdxl-zimage`, tiles, +resolution caps, humanize, unsharp, and adaptive polish. Those combinations change +geometry or final pixels after the verified layer and have no measured oracle result. +It remains opt-in because annotations are manual and provider verdicts apply only to +the exact tested output hashes, not to the mechanism in general. + A matched stage-isolation check on the 18-face Gemini portrait grid confirms the division of responsibility. The visible-cleaned, metadata-stripped control and the Z-Image face-only output were both SynthID-positive; Qwen global-only and the full diff --git a/docs/python-api.md b/docs/python-api.md index 36efb46..0f7d876 100644 --- a/docs/python-api.md +++ b/docs/python-api.md @@ -596,6 +596,23 @@ engine = InvisibleEngine(pipeline="sdxl-zimage") The `qwen-zimage` extra is required for both profiles: each runs the same DiffSynth Z-Image face stage. +The opt-in verified-text stage uses the same `text_manifest` argument as the CLI: + +```python +engine.remove_watermark( + Path("watermarked.png"), + Path("clean.png"), + text_manifest=Path("verified-lines.json"), +) +``` + +Install `remove-ai-watermarks[text-restoration]`. The manifest schema and safety +constraints are documented in the CLI guide. The engine verifies its decoded RGB +hash before loading the diffusion models and rejects SDXL, tiling, downscaling, and +postprocessing combinations that were not evaluated. `InvisibleOptions` exposes the +same field for `remove_all`; after a visible-stage edit, the manifest must be built +against the staged pixels rather than the pristine source. + `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 diff --git a/pyproject.toml b/pyproject.toml index cf59807..cb1d37c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -120,6 +120,11 @@ qwen-zimage = [ "diffsynth>=2.0.17,<3", "torchvision>=0.20.0", ] +# Opt-in verified-text reconstruction over qwen-zimage. LaMa removes the changed +# candidate glyphs before exact Qwen-VAE cores are composited back. +text-restoration = [ + "remove-ai-watermarks[qwen-zimage,lama]", +] # 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() @@ -161,7 +166,7 @@ dev = [ ] # ``qwen-zimage`` already pulls ``diffusion``; naming both would suggest diffusion is # independently sufficient for a removal, which it is not. -all = ["remove-ai-watermarks[video,heif,detect,trustmark,qwen-zimage,lama,migan]"] +all = ["remove-ai-watermarks[video,heif,detect,trustmark,text-restoration,migan]"] [project.scripts] remove-ai-watermarks = "remove_ai_watermarks.cli:main" diff --git a/scripts/selective_text_restoration.py b/scripts/selective_text_restoration.py index 80973ca..4c8c0fc 100644 --- a/scripts/selective_text_restoration.py +++ b/scripts/selective_text_restoration.py @@ -33,7 +33,7 @@ import os import shutil import sys import unicodedata -from dataclasses import asdict, dataclass +from dataclasses import asdict from pathlib import Path from typing import Any @@ -48,6 +48,14 @@ sys.path.insert(0, str(ROOT)) sys.path.insert(0, str(ROOT / "src")) from remove_ai_watermarks import region_eraser # noqa: E402 +from remove_ai_watermarks._internal.text_restoration import VerifiedTextLine as TextLine # noqa: E402 +from remove_ai_watermarks._internal.text_restoration import ( # noqa: E402 + composite_fresh_text_edges, + composite_reconstructed_glyphs, + group_text_lines, + residual_glyph_mask, + source_silhouette_mask, +) from scripts._text_eval import normalize_text, normalized_edit_distance # noqa: E402 if ROOT not in Path(region_eraser.__file__).resolve().parents: @@ -58,14 +66,6 @@ BOLD_FONT = Path("/System/Library/Fonts/Supplemental/Arial Bold.ttf") CJK_FONT = Path("/System/Library/Fonts/STHeiti Medium.ttc") -@dataclass(frozen=True) -class TextLine: - box: tuple[int, int, int, int] - text: str - script: str - angle: float = 0.0 - - def should_preserve_line( expected: str, source_text: str, @@ -80,16 +80,6 @@ def should_preserve_line( return normalize_text(source_text) == normalize_text(candidate_text) -def residual_glyph_mask( - background_rgb: np.ndarray, - original_mask: np.ndarray, - box: tuple[int, int, int, int], -) -> np.ndarray: - residual = foreground_mask(background_rgb, box) - residual = cv2.bitwise_and(residual, original_mask) - return cv2.dilate(residual, np.ones((5, 5), np.uint8), iterations=1) - - def composite_source_glyphs( source_rgb: np.ndarray, background_rgb: np.ndarray, @@ -98,131 +88,13 @@ def composite_source_glyphs( feather: float = 0.7, ) -> np.ndarray: """Composite exact source pixels inside a glyph mask with an outer feather.""" - return _composite_exact_core( - source_rgb, - background_rgb, - glyph_mask, - feather=feather, - round_output=False, - ) - - -def source_silhouette_mask( - source_rgb: np.ndarray, - box: tuple[int, int, int, int], - angle: float = 0.0, -) -> np.ndarray: - """Recover the thresholded glyph shape without retaining source amplitudes.""" - height, width = source_rgb.shape[:2] - x1, y1, x2, y2 = _clip_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: - box_width, box_height = x2 - x1, y2 - y1 - theta = math.radians(abs(angle)) - cosine, sine = math.cos(theta), math.sin(theta) - denominator = cosine * cosine - sine * sine - rect_width = (box_width * cosine - box_height * sine) / denominator - rect_height = (box_height * cosine - box_width * sine) / denominator - rotated = cv2.boxPoints( - ( - (box_width / 2, box_height / 2), - (max(1.0, rect_width * 0.92), max(1.0, rect_height * 0.62)), - -angle, - ) - ) - support.fill(0) - cv2.fillConvexPoly(support, np.rint(rotated).astype(np.int32), 1) - values = gray[support > 0] - background_luma = float(np.median(values)) - else: - ring_pad = max(6, min(20, (y2 - y1) // 4)) - rx1, ry1, rx2, ry2 = _clip_box((x1, y1, x2, y2), width, height, pad=ring_pad) - context = cv2.cvtColor(source_rgb[ry1:ry2, rx1:rx2], cv2.COLOR_RGB2GRAY) - ring = np.ones(context.shape, dtype=bool) - ring[y1 - ry1 : y2 - ry1, x1 - rx1 : x2 - rx1] = False - background_luma = float(np.median(context[ring])) if ring.any() else float(np.median(gray)) - values = gray.reshape(-1) - low, high = float(np.percentile(values, 2)), float(np.percentile(values, 98)) - dark_contrast, light_contrast = background_luma - low, high - background_luma - contrast = max(light_contrast, dark_contrast) - threshold = max(16.0, min(56.0, contrast * 0.22)) - if light_contrast > dark_contrast: - crop_mask = (gray.astype(np.float32) >= background_luma + threshold).astype(np.uint8) * 255 - else: - crop_mask = (gray.astype(np.float32) <= background_luma - threshold).astype(np.uint8) * 255 - crop_mask[support == 0] = 0 - result = np.zeros((height, width), dtype=np.uint8) - result[y1:y2, x1:x2] = crop_mask - return result - - -def composite_fresh_silhouette( - background_rgb: np.ndarray, - glyph_mask: np.ndarray, - color: tuple[int, int, int], - *, - feather: float = 0.35, -) -> np.ndarray: - """Render a binary source shape with fresh color and antialiasing.""" - if background_rgb.shape[:2] != glyph_mask.shape: - raise ValueError("background and glyph mask dimensions must match") - antialiased = cv2.GaussianBlur(glyph_mask, (0, 0), feather) if feather > 0 else glyph_mask - alpha = antialiased.astype(np.float32) / 255.0 - alpha = alpha[..., None] - foreground = np.empty_like(background_rgb) - foreground[:, :] = color - combined = foreground.astype(np.float32) * alpha + background_rgb.astype(np.float32) * (1.0 - alpha) - return np.clip(combined, 0, 255).astype(np.uint8) - - -def composite_fresh_text_edges( - source_rgb: np.ndarray, - background_rgb: np.ndarray, - lines: list[TextLine], - masks: list[np.ndarray], -) -> np.ndarray: - """Render fresh antialiased edges for a set of source-derived glyph masks.""" - restored = background_rgb - for line, mask in zip(lines, masks, strict=True): - color = _sample_text_color(source_rgb, mask, line.box) - restored = composite_fresh_silhouette(restored, mask, color) - return restored - - -def composite_reconstructed_glyphs( - donor_rgb: np.ndarray, - background_rgb: np.ndarray, - glyph_mask: np.ndarray, - *, - feather: float = 0.5, -) -> np.ndarray: - """Composite an exact reconstructed core with a narrow donor edge.""" - return _composite_exact_core( - donor_rgb, - background_rgb, - glyph_mask, - feather=feather, - round_output=True, - ) - - -def _composite_exact_core( - foreground_rgb: np.ndarray, - background_rgb: np.ndarray, - glyph_mask: np.ndarray, - *, - feather: float, - round_output: bool, -) -> np.ndarray: - if foreground_rgb.shape != background_rgb.shape or foreground_rgb.shape[:2] != glyph_mask.shape: - raise ValueError("foreground, background, and glyph mask dimensions must match") + if source_rgb.shape != background_rgb.shape or source_rgb.shape[:2] != glyph_mask.shape: + raise ValueError("source, background, and glyph mask dimensions must match") blurred = cv2.GaussianBlur(glyph_mask, (0, 0), feather) if feather > 0 else glyph_mask alpha = np.maximum(glyph_mask, blurred).astype(np.float32) / 255.0 alpha = alpha[..., None] - combined = foreground_rgb.astype(np.float32) * alpha + background_rgb.astype(np.float32) * (1.0 - alpha) - output = np.rint(combined) if round_output else combined - return np.clip(output, 0, 255).astype(np.uint8) + combined = source_rgb.astype(np.float32) * alpha + background_rgb.astype(np.float32) * (1.0 - alpha) + return np.clip(combined, 0, 255).astype(np.uint8) def source_box_mask( @@ -355,21 +227,6 @@ def _write_manifest(path: Path | None, payload: dict[str, Any]) -> None: path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") -def _groups(lines: list[TextLine]) -> list[list[int]]: - groups: list[list[int]] = [] - for index, line in enumerate(lines): - if not groups: - groups.append([index]) - continue - previous = lines[groups[-1][-1]] - gap = line.box[1] - previous.box[3] - if line.script != previous.script or gap > max(60, int((previous.box[3] - previous.box[1]) * 1.1)): - groups.append([index]) - else: - groups[-1].append(index) - return groups - - 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])) @@ -606,7 +463,7 @@ def main( candidate_masks = [foreground_mask(candidate_rgb, line.box) for line in selected] masks = [np.maximum(left, right) for left, right in zip(source_masks, candidate_masks, strict=True)] del candidate_masks - groups = _groups(selected) + groups = group_text_lines(selected) if erase_background: background = cv2.cvtColor(candidate_rgb, cv2.COLOR_RGB2BGR) for group in groups: diff --git a/src/remove_ai_watermarks/_internal/qwen_zimage_pipeline.py b/src/remove_ai_watermarks/_internal/qwen_zimage_pipeline.py index 3a9969e..64671a9 100644 --- a/src/remove_ai_watermarks/_internal/qwen_zimage_pipeline.py +++ b/src/remove_ai_watermarks/_internal/qwen_zimage_pipeline.py @@ -30,6 +30,8 @@ from remove_ai_watermarks._internal.watermark_profiles import resolve_seed if TYPE_CHECKING: from collections.abc import Callable + from remove_ai_watermarks._internal.text_restoration import VerifiedTextManifest + log = logging.getLogger(__name__) QWEN_IMAGE_2512_MODEL_ID = "Qwen/Qwen-Image-2512" @@ -973,6 +975,30 @@ class QwenZImagePipeline: result = result.resize(image.size, Image.Resampling.LANCZOS) return result.convert("RGB") + def _qwen_vae_roundtrip(self, image: Image.Image) -> Image.Image: + """Reconstruct source pixels through the already loaded Qwen VAE.""" + import torch + + pipe, _controlnet_input_cls = self._load_qwen() + source_width, source_height = image.size + pad_width = (-source_width) % 8 + pad_height = (-source_height) % 8 + padded = image.convert("RGB") + if pad_width or pad_height: + padded = Image.fromarray( + np.pad( + np.asarray(padded), + ((0, pad_height), (0, pad_width), (0, 0)), + mode="edge", + ) + ) + pipe.load_models_to_device(["vae"]) + tensor = pipe.preprocess_image(padded).to(device=self.device, dtype=self.torch_dtype) + with torch.inference_mode(): + latents = pipe.vae.encode(tensor) + decoded = pipe.vae.decode(latents) + return pipe.vae_output_to_image(decoded).crop((0, 0, source_width, source_height)).convert("RGB") + @staticmethod def _detail_size( crop_size: tuple[int, int], @@ -1044,10 +1070,15 @@ class QwenZImagePipeline: tile: bool = False, tile_size: int = 1024, tile_overlap: int = 128, + text_manifest: VerifiedTextManifest | None = None, ) -> Image.Image: """Execute global regeneration and masked face repair.""" self._require_cuda() seed = resolve_seed(seed) + donor = None + if text_manifest is not None: + self._progress("Reconstructing the verified text donor with the Qwen VAE...") + donor = self._qwen_vae_roundtrip(image) global_strength = ( resolution_adaptive_denoise(image.width, image.height) if strength is None else float(strength) ) @@ -1068,14 +1099,28 @@ class QwenZImagePipeline: boxes = detect_faces(image) if not boxes: self._progress("No faces detected; keeping the Qwen global result.") - return global_result - masks = self._sam_masks(image, boxes) - face_strength = largest_face_denoise(boxes, image.size) * FACE_DENOISE_SCALE - return self._run_faces( - image, - global_result, - boxes, - masks, - strength=face_strength, - seed=seed, + result = global_result + else: + masks = self._sam_masks(image, boxes) + face_strength = largest_face_denoise(boxes, image.size) * FACE_DENOISE_SCALE + result = self._run_faces( + image, + global_result, + boxes, + masks, + strength=face_strength, + seed=seed, + ) + if text_manifest is None: + return result + if donor is None: + raise RuntimeError("Verified text restoration requires a Qwen-VAE donor") + from remove_ai_watermarks._internal.text_restoration import ( + blend_fidelity_anchor, + restore_verified_text, ) + + self._progress("Blending the Qwen-VAE fidelity anchor...") + anchor = blend_fidelity_anchor(result, donor) + self._progress(f"Restoring {len(text_manifest.lines)} verified text lines...") + return restore_verified_text(image, anchor, donor, text_manifest.lines) diff --git a/src/remove_ai_watermarks/_internal/text_restoration.py b/src/remove_ai_watermarks/_internal/text_restoration.py new file mode 100644 index 0000000..b956add --- /dev/null +++ b/src/remove_ai_watermarks/_internal/text_restoration.py @@ -0,0 +1,353 @@ +"""Opt-in restoration of verified text from a Qwen VAE reconstruction.""" + +# 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 hashlib +import json +import math +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +import cv2 +import numpy as np +from PIL import Image + +if TYPE_CHECKING: + from collections.abc import Sequence + from pathlib import Path + + from numpy.typing import NDArray + +TEXT_MANIFEST_SCHEMA = 1 +FIDELITY_BLEND_ALPHA = 0.15 +GLYPH_FEATHER = 0.5 + + +@dataclass(frozen=True) +class VerifiedTextLine: + """One operator-verified source line in source-pixel coordinates.""" + + box: tuple[int, int, int, int] + text: str + script: str + angle: float = 0.0 + + +@dataclass(frozen=True) +class VerifiedTextManifest: + """Text annotations cryptographically bound to one decoded RGB source.""" + + source_pixel_sha256: str + width: int + height: int + lines: tuple[VerifiedTextLine, ...] + + +def source_pixel_sha256(image: Image.Image) -> str: + """Hash decoded RGB geometry and bytes, independent of container metadata.""" + rgb = image.convert("RGB") + digest = hashlib.sha256() + digest.update(rgb.width.to_bytes(8, "big")) + digest.update(rgb.height.to_bytes(8, "big")) + digest.update(rgb.tobytes()) + return digest.hexdigest() + + +def load_verified_text_manifest(path: Path, source: Image.Image) -> VerifiedTextManifest: + """Load and validate a manually 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}") + if payload.get("verified") is not True: + raise ValueError("Text manifest must contain verified=true after manual review") + + rgb = source.convert("RGB") + width = _manifest_integer(payload, "width") + height = _manifest_integer(payload, "height") + if (width, height) != rgb.size: + raise ValueError(f"Text manifest dimensions {width}x{height} do not match source {rgb.width}x{rgb.height}") + expected_hash = payload.get("source_pixel_sha256") + if not isinstance(expected_hash, str) or len(expected_hash) != 64: + raise ValueError("Text manifest source_pixel_sha256 must be a 64-character SHA-256") + actual_hash = source_pixel_sha256(rgb) + if expected_hash.casefold() != actual_hash: + raise ValueError("Text manifest source_pixel_sha256 does not match the decoded source pixels") + + 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)) + 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) + + +def _manifest_integer(payload: dict[str, Any], key: str) -> int: + value = payload.get(key) + if isinstance(value, bool) or not isinstance(value, int) or value <= 0: + raise ValueError(f"Text manifest {key} must be a positive integer") + return value + + +def _load_line(item: Any, width: int, height: int, index: int) -> VerifiedTextLine: + if not isinstance(item, dict): + raise ValueError(f"Text manifest line {index} must be an object") + raw_box = item.get("box") + if ( + not isinstance(raw_box, list) + or len(raw_box) != 4 + or any(isinstance(value, bool) or not isinstance(value, int) for value in raw_box) + ): + raise ValueError(f"Text manifest line {index} box must contain four integers") + box = tuple(raw_box) + 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") + 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") + angle = float(angle_value) + if not math.isfinite(angle) or abs(angle) > 30.0: + raise ValueError(f"Text manifest line {index} angle must be between -30 and 30 degrees") + return VerifiedTextLine(box, text, script, angle) + + +def blend_fidelity_anchor(clean: Image.Image, donor: Image.Image) -> Image.Image: + """Blend 15% Qwen-VAE reconstruction into the oracle-clean pipeline output.""" + clean_rgb = np.asarray(clean.convert("RGB"), dtype=np.float32) + donor_rgb = np.asarray(donor.convert("RGB"), dtype=np.float32) + if clean_rgb.shape != donor_rgb.shape: + raise ValueError("Clean result and Qwen-VAE donor dimensions must match") + blended = np.rint(clean_rgb * (1.0 - FIDELITY_BLEND_ALPHA) + donor_rgb * FIDELITY_BLEND_ALPHA) + return Image.fromarray(np.clip(blended, 0, 255).astype(np.uint8)) + + +def restore_verified_text( + source: Image.Image, + candidate: Image.Image, + donor: Image.Image, + lines: tuple[VerifiedTextLine, ...], +) -> Image.Image: + """Erase candidate glyphs, then composite verified Qwen-VAE glyph cores.""" + from remove_ai_watermarks import region_eraser + + if not region_eraser.lama_available(): + raise RuntimeError( + "Verified text restoration requires LaMa. Install: pip install 'remove-ai-watermarks[text-restoration]'" + ) + source_rgb = np.asarray(source.convert("RGB")) + candidate_rgb = np.asarray(candidate.convert("RGB")) + donor_rgb = np.asarray(donor.convert("RGB")) + if source_rgb.shape != candidate_rgb.shape or source_rgb.shape != donor_rgb.shape: + raise ValueError("Source, candidate, and Qwen-VAE donor dimensions must match") + + source_masks = [source_silhouette_mask(source_rgb, line.box, line.angle) for line in lines] + for index, mask in enumerate(source_masks): + if not np.any(mask): + raise ValueError(f"Verified text line {index} produced no source glyph pixels") + candidate_masks = [source_silhouette_mask(candidate_rgb, line.box, line.angle) for line in lines] + erase_masks = [] + for line, source_mask, candidate_mask in zip(lines, source_masks, candidate_masks, strict=True): + radius = 5 if line.box[3] - line.box[1] >= 48 else 3 + kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * radius + 1,) * 2) + erase_masks.append(cv2.dilate(np.maximum(source_mask, candidate_mask), kernel)) + del candidate_masks + + groups = group_text_lines(lines) + background = cv2.cvtColor(candidate_rgb, cv2.COLOR_RGB2BGR) + for group in groups: + background = region_eraser.erase_lama(background, np.maximum.reduce([erase_masks[index] for index in group])) + background_rgb = cv2.cvtColor(background, cv2.COLOR_BGR2RGB) + residual_masks = [ + residual_glyph_mask(background_rgb, mask, line.box) for line, mask in zip(lines, erase_masks, strict=True) + ] + for group in groups: + residual = np.maximum.reduce([residual_masks[index] for index in group]) + if np.any(residual): + background = region_eraser.erase_lama(background, residual) + del erase_masks, residual_masks + restored = cv2.cvtColor(background, cv2.COLOR_BGR2RGB) + restored = composite_fresh_text_edges(source_rgb, restored, lines, source_masks) + source_glyph_mask = np.maximum.reduce(source_masks) + restored = composite_reconstructed_glyphs(donor_rgb, restored, source_glyph_mask) + return Image.fromarray(restored) + + +def source_silhouette_mask( + source_rgb: NDArray[Any], + box: tuple[int, int, int, int], + angle: float = 0.0, +) -> 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) + gray = cv2.cvtColor(source_rgb[y1:y2, x1:x2], cv2.COLOR_RGB2GRAY) + support = np.ones(gray.shape, dtype=np.uint8) + if angle: + box_width, box_height = x2 - x1, y2 - y1 + theta = math.radians(abs(angle)) + cosine, sine = math.cos(theta), math.sin(theta) + denominator = cosine * cosine - sine * sine + rect_width = (box_width * cosine - box_height * sine) / denominator + rect_height = (box_height * cosine - box_width * sine) / denominator + rotated = cv2.boxPoints( + ((box_width / 2, box_height / 2), (max(1.0, rect_width * 0.92), max(1.0, rect_height * 0.62)), -angle) + ) + support.fill(0) + cv2.fillConvexPoly(support, np.rint(rotated).astype(np.int32), 1) + values = gray[support > 0] + background_luma = float(np.median(values)) + else: + ring_pad = max(6, min(20, (y2 - y1) // 4)) + rx1, ry1, rx2, ry2 = _clip_box((x1, y1, x2, y2), width, height, pad=ring_pad) + context = cv2.cvtColor(source_rgb[ry1:ry2, rx1:rx2], cv2.COLOR_RGB2GRAY) + ring = np.ones(context.shape, dtype=bool) + ring[y1 - ry1 : y2 - ry1, x1 - rx1 : x2 - rx1] = False + background_luma = float(np.median(context[ring])) if ring.any() else float(np.median(gray)) + values = gray.reshape(-1) + low, high = float(np.percentile(values, 2)), float(np.percentile(values, 98)) + dark_contrast, light_contrast = background_luma - low, high - background_luma + threshold = max(16.0, min(56.0, max(light_contrast, dark_contrast) * 0.22)) + if light_contrast > dark_contrast: + crop_mask = (gray.astype(np.float32) >= background_luma + threshold).astype(np.uint8) * 255 + else: + crop_mask = (gray.astype(np.float32) <= background_luma - threshold).astype(np.uint8) * 255 + crop_mask[support == 0] = 0 + result = np.zeros((height, width), dtype=np.uint8) + result[y1:y2, x1:x2] = crop_mask + return result + + +def residual_glyph_mask( + background_rgb: NDArray[Any], + original_mask: NDArray[Any], + box: tuple[int, int, int, int], +) -> NDArray[Any]: + """Find glyph-like contrast left after the first inpaint pass.""" + residual = _foreground_mask(background_rgb, box) + residual = cv2.bitwise_and(residual, original_mask) + return cv2.dilate(residual, np.ones((5, 5), np.uint8), iterations=1) + + +def composite_fresh_text_edges( + source_rgb: NDArray[Any], + background_rgb: NDArray[Any], + lines: tuple[VerifiedTextLine, ...], + masks: list[NDArray[Any]], +) -> NDArray[Any]: + """Render fresh antialiased edges for source-derived glyph masks.""" + restored = background_rgb + for line, mask in zip(lines, masks, strict=True): + color = _sample_text_color(source_rgb, mask, line.box) + restored = composite_fresh_silhouette(restored, mask, color) + return restored + + +def composite_reconstructed_glyphs( + donor_rgb: NDArray[Any], + background_rgb: NDArray[Any], + glyph_mask: NDArray[Any], + *, + feather: float = GLYPH_FEATHER, +) -> NDArray[Any]: + """Composite an exact reconstructed core with a narrow donor edge.""" + if donor_rgb.shape != background_rgb.shape or donor_rgb.shape[:2] != glyph_mask.shape: + raise ValueError("donor, background, and glyph mask dimensions must match") + blurred = cv2.GaussianBlur(glyph_mask, (0, 0), feather) if feather > 0 else glyph_mask + alpha = np.maximum(glyph_mask, blurred).astype(np.float32) / 255.0 + combined = donor_rgb.astype(np.float32) * alpha[..., None] + background_rgb.astype(np.float32) * ( + 1.0 - alpha[..., None] + ) + return np.clip(np.rint(combined), 0, 255).astype(np.uint8) + + +def composite_fresh_silhouette( + background_rgb: NDArray[Any], + glyph_mask: NDArray[Any], + color: tuple[int, int, int], + *, + feather: float = 0.35, +) -> NDArray[Any]: + """Render a binary source shape with fresh color and antialiasing.""" + if background_rgb.shape[:2] != glyph_mask.shape: + raise ValueError("background and glyph mask dimensions must match") + antialiased = cv2.GaussianBlur(glyph_mask, (0, 0), feather) if feather > 0 else glyph_mask + alpha = antialiased.astype(np.float32)[..., None] / 255.0 + foreground = np.empty_like(background_rgb) + foreground[:, :] = color + combined = foreground.astype(np.float32) * alpha + background_rgb.astype(np.float32) * (1.0 - alpha) + return np.clip(combined, 0, 255).astype(np.uint8) + + +def _clip_box(box: tuple[int, int, int, int], width: int, height: int, pad: int = 0) -> tuple[int, int, int, int]: + x1, y1, x2, y2 = box + return max(0, x1 - pad), max(0, y1 - pad), min(width, x2 + pad), min(height, y2 + pad) + + +def _foreground_mask(source_rgb: NDArray[Any], box: tuple[int, int, int, int]) -> NDArray[Any]: + height, width = source_rgb.shape[:2] + line_height = box[3] - box[1] + x1, y1, x2, y2 = _clip_box(box, width, height, pad=max(6, int(line_height * 0.12))) + gray = cv2.cvtColor(source_rgb[y1:y2, x1:x2], cv2.COLOR_RGB2GRAY) + ring_pad = max(8, min(24, (y2 - y1) // 5)) + rx1, ry1, rx2, ry2 = _clip_box((x1, y1, x2, y2), width, height, pad=ring_pad) + context = cv2.cvtColor(source_rgb[ry1:ry2, rx1:rx2], cv2.COLOR_RGB2GRAY) + ring = np.ones(context.shape, dtype=bool) + ring[y1 - ry1 : y2 - ry1, x1 - rx1 : x2 - rx1] = False + background_luma = float(np.median(context[ring])) if ring.any() else float(np.median(gray)) + low, high = float(np.percentile(gray, 4)), float(np.percentile(gray, 96)) + dark_contrast, light_contrast = background_luma - low, high - background_luma + threshold = max(24.0, min(72.0, max(light_contrast, dark_contrast) * 0.32)) + if light_contrast > dark_contrast: + mask = (gray.astype(np.float32) >= background_luma + threshold).astype(np.uint8) * 255 + else: + mask = (gray.astype(np.float32) <= background_luma - threshold).astype(np.uint8) * 255 + mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, np.ones((2, 2), np.uint8)) + dilation = 5 if line_height >= 48 else 3 + mask = cv2.dilate(mask, cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * dilation + 1,) * 2)) + result = np.zeros((height, width), dtype=np.uint8) + result[y1:y2, x1:x2] = mask + return result + + +def _sample_text_color( + source_rgb: NDArray[Any], mask: NDArray[Any], box: tuple[int, int, int, int] +) -> tuple[int, int, int]: + height, width = source_rgb.shape[:2] + x1, y1, x2, y2 = _clip_box(box, width, height, pad=2) + crop = source_rgb[y1:y2, x1:x2] + pixels = crop[mask[y1:y2, x1:x2] > 0] + luma = pixels.mean(axis=1) + background_luma = float(crop[[0, -1], :, :].reshape(-1, 3).mean(axis=1).mean()) + selected = ( + pixels[luma <= np.percentile(luma, 20)] if background_luma >= 128 else pixels[luma >= np.percentile(luma, 80)] + ) + return tuple(int(value) for value in np.median(selected, axis=0)) + + +def group_text_lines(lines: Sequence[VerifiedTextLine]) -> list[list[int]]: + """Group nearby same-script lines for a shared LaMa erase pass.""" + groups: list[list[int]] = [] + for index, line in enumerate(lines): + if not groups: + groups.append([index]) + continue + previous = lines[groups[-1][-1]] + gap = line.box[1] - previous.box[3] + if line.script != previous.script or gap > max(60, int((previous.box[3] - previous.box[1]) * 1.1)): + groups.append([index]) + else: + groups[-1].append(index) + return groups diff --git a/src/remove_ai_watermarks/_internal/watermark_remover.py b/src/remove_ai_watermarks/_internal/watermark_remover.py index 1567798..b704e27 100644 --- a/src/remove_ai_watermarks/_internal/watermark_remover.py +++ b/src/remove_ai_watermarks/_internal/watermark_remover.py @@ -26,6 +26,8 @@ if TYPE_CHECKING: from collections.abc import Callable from pathlib import Path + from remove_ai_watermarks._internal.text_restoration import VerifiedTextManifest + logger = logging.getLogger(__name__) try: @@ -187,6 +189,7 @@ class WatermarkRemover: tile: bool = False, tile_size: int = 1024, tile_overlap: int = 128, + text_manifest: VerifiedTextManifest | None = None, ) -> Path: """Regenerate image pixels and write the result without AI metadata. @@ -203,6 +206,10 @@ class WatermarkRemover: resolved_strength = resolve_strength(strength, vendor, self.model_profile, size=source.size) if not 0.0 <= resolved_strength <= 1.0: raise ValueError(f"Strength must be between 0.0 and 1.0, got {resolved_strength}") + if text_manifest is not None and self.model_profile == SDXL_ZIMAGE_PROFILE: + raise ValueError("Verified text restoration is supported only by the qwen-zimage profile") + if text_manifest is not None and tile: + raise ValueError("Verified text restoration is not calibrated with tiled diffusion") result = self._load_qwen_zimage_pipeline().run( source, @@ -211,6 +218,7 @@ class WatermarkRemover: tile=tile, tile_size=tile_size, tile_overlap=tile_overlap, + text_manifest=text_manifest, ) self._write_output(result, destination) return destination diff --git a/src/remove_ai_watermarks/api.py b/src/remove_ai_watermarks/api.py index 9a8d178..117623f 100644 --- a/src/remove_ai_watermarks/api.py +++ b/src/remove_ai_watermarks/api.py @@ -243,6 +243,7 @@ class InvisibleOptions: tile: bool = False tile_size: int = 1024 tile_overlap: int = 128 + text_manifest: Path | None = None # What the invisible stage did. "unavailable" is the one outcome the caller must @@ -523,6 +524,7 @@ def _run_invisible( tile=opts.tile, tile_size=opts.tile_size, tile_overlap=opts.tile_overlap, + text_manifest=opts.text_manifest, ) say("invisible", "removed") return "removed" diff --git a/src/remove_ai_watermarks/cli.py b/src/remove_ai_watermarks/cli.py index 21313de..4849edb 100644 --- a/src/remove_ai_watermarks/cli.py +++ b/src/remove_ai_watermarks/cli.py @@ -311,6 +311,16 @@ _cpu_offload_option = click.option( ), ) +_text_manifest_option = click.option( + "--text-manifest", + type=click.Path(exists=True, dir_okay=False, path_type=Path), + default=None, + help=( + "Experimental verified-text restoration manifest. Requires qwen-zimage, " + "the text-restoration extra, native untiled geometry, and no postprocessing." + ), +) + _visible_backend_option = click.option( "--backend", @@ -787,6 +797,7 @@ def cmd_erase( @_tile_options @_force_option @_cpu_offload_option +@_text_manifest_option @click.pass_context def cmd_invisible( ctx: click.Context, @@ -806,6 +817,7 @@ def cmd_invisible( tile_overlap: int, force: bool, cpu_offload: bool, + text_manifest: Path | None, ) -> None: """Remove invisible AI watermarks (SynthID, StableSignature, TreeRing). @@ -853,20 +865,25 @@ def cmd_invisible( console.print(f" Strength: {_resolved_strength_for_display(source, strength, vendor, pipeline)}") t0 = time.monotonic() - result_path = engine.remove_watermark( - image_path=source, - output_path=output, - strength=strength, - seed=seed, - humanize=humanize, - unsharp=unsharp, - adaptive_polish=adaptive_polish, - max_resolution=max_resolution, - vendor=vendor, - tile=tile, - tile_size=tile_size, - tile_overlap=tile_overlap, - ) + try: + result_path = engine.remove_watermark( + image_path=source, + output_path=output, + strength=strength, + seed=seed, + humanize=humanize, + unsharp=unsharp, + adaptive_polish=adaptive_polish, + max_resolution=max_resolution, + vendor=vendor, + tile=tile, + tile_size=tile_size, + tile_overlap=tile_overlap, + text_manifest=text_manifest, + ) + except (OSError, RuntimeError, ValueError) as exc: + console.print(f" Error: {exc}") + raise SystemExit(1) from exc elapsed = time.monotonic() - t0 size_kb = result_path.stat().st_size / 1024 @@ -1410,6 +1427,7 @@ def cmd_identify(ctx: click.Context, source: Path, no_visible: bool, as_json: bo @_tile_options @_force_option @_cpu_offload_option +@_text_manifest_option @click.pass_context def cmd_all( ctx: click.Context, @@ -1431,6 +1449,7 @@ def cmd_all( tile_overlap: int, force: bool, cpu_offload: bool, + text_manifest: Path | None, ) -> None: """Remove ALL watermarks: visible + invisible + metadata. @@ -1508,6 +1527,7 @@ def cmd_all( tile=tile, tile_size=tile_size, tile_overlap=tile_overlap, + text_manifest=text_manifest, ), force=force, progress=progress, diff --git a/src/remove_ai_watermarks/invisible_engine.py b/src/remove_ai_watermarks/invisible_engine.py index ace5700..4ca8a59 100644 --- a/src/remove_ai_watermarks/invisible_engine.py +++ b/src/remove_ai_watermarks/invisible_engine.py @@ -18,6 +18,7 @@ from typing import TYPE_CHECKING from ._internal.watermark_profiles import ( DEFAULT_PROFILE, + QWEN_ZIMAGE_PROFILE, REMOVAL_MODULES, resolve_adaptive_polish, resolve_seed, @@ -148,6 +149,7 @@ class InvisibleEngine: tile: bool = False, tile_size: int = 1024, tile_overlap: int = 128, + text_manifest: Path | None = None, ) -> Path: """Remove invisible watermark from an image. @@ -180,6 +182,11 @@ class InvisibleEngine: Engages only when the long side exceeds ``tile_size``. tile_size: Tile dimension in px (default 1024). tile_overlap: Overlap between adjacent tiles in px (default 128). + text_manifest: Operator-verified text lines bound to the decoded source + pixels. Enables the experimental Qwen-VAE ``vae-glyphs`` post-pass. + 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. Returns: Path to the cleaned image. @@ -189,6 +196,23 @@ class InvisibleEngine: seed = resolve_seed(seed) adaptive_polish = resolve_adaptive_polish(adaptive_polish, self._remover.model_profile) + 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") + if max_resolution != 0: + raise ValueError("--text-manifest requires --max-resolution 0") + if tile: + raise ValueError("--text-manifest is not calibrated with --tile") + if humanize > 0.0 or unsharp > 0.0 or adaptive_polish: + raise ValueError("--text-manifest requires humanize=0, unsharp=0, and adaptive polish disabled") + from remove_ai_watermarks import region_eraser + + if not region_eraser.lama_available(): + raise RuntimeError( + "Verified text restoration requires LaMa. Install: " + "pip install 'remove-ai-watermarks[text-restoration]'" + ) + from PIL import Image, ImageOps # Resolution policy: a max_resolution cap (0 = none) bounds memory on huge @@ -205,6 +229,11 @@ class InvisibleEngine: # Full-res original, kept for the adaptive-polish detail target (image is # reassigned to the resized copy below; PIL resize returns a new object). reference_pil = image + verified_text = None + if text_manifest is not None: + from remove_ai_watermarks._internal.text_restoration import load_verified_text_manifest + + verified_text = load_verified_text_manifest(text_manifest, reference_pil) # Both profiles run at the input's native geometry, so only the explicit max # cap can move it, and it can only ever scale down. @@ -240,6 +269,7 @@ class InvisibleEngine: tile=tile, tile_size=tile_size, tile_overlap=tile_overlap, + text_manifest=verified_text, ) # Post-processing chain: decode the diffusion output ONCE, apply the diff --git a/tests/test_api.py b/tests/test_api.py index b5c6753..ba8d519 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -245,6 +245,7 @@ class TestInvisibleOptionsMirrorTheEngine: tile=True, tile_size=768, tile_overlap=64, + text_manifest=tmp_path / "verified-lines.json", ) seen: dict[str, object] = {} diff --git a/tests/test_invisible_engine.py b/tests/test_invisible_engine.py index cd85e90..f459aa0 100644 --- a/tests/test_invisible_engine.py +++ b/tests/test_invisible_engine.py @@ -43,6 +43,76 @@ class TestInvisibleEngineInit: assert engine._preload_kwargs == {"global_only": True} +class TestVerifiedTextMode: + """The experimental mode must fail before loading models on unmeasured inputs.""" + + @staticmethod + def _engine(profile: str = "qwen-zimage") -> InvisibleEngine: + engine = object.__new__(InvisibleEngine) + engine._progress_callback = None + engine._remover = SimpleNamespace(model_profile=profile) + return engine + + def test_rejects_incompatible_pipeline_options(self, tmp_path): + import pytest + + manifest = tmp_path / "manifest.json" + manifest.write_text("{}", encoding="utf-8") + cases = ( + ("sdxl-zimage", {}, "qwen-zimage"), + ("qwen-zimage", {"max_resolution": 1024}, "max-resolution 0"), + ("qwen-zimage", {"tile": True}, "not calibrated"), + ("qwen-zimage", {"humanize": 1.0}, "humanize=0"), + ("qwen-zimage", {"adaptive_polish": True}, "polish disabled"), + ) + for profile, kwargs, message in cases: + with pytest.raises(ValueError, match=message): + self._engine(profile).remove_watermark( + tmp_path / "unused.png", + text_manifest=manifest, + **kwargs, + ) + + def test_loads_and_forwards_verified_manifest(self, tmp_path, monkeypatch): + import json + + from remove_ai_watermarks import region_eraser + from remove_ai_watermarks._internal.text_restoration import source_pixel_sha256 + + source = tmp_path / "source.png" + output = tmp_path / "output.png" + image = Image.new("RGB", (48, 32), (10, 20, 30)) + image.save(source) + manifest = tmp_path / "manifest.json" + manifest.write_text( + json.dumps( + { + "schema_version": 1, + "verified": True, + "source_pixel_sha256": source_pixel_sha256(image), + "width": 48, + "height": 32, + "lines": [{"box": [8, 8, 40, 24], "text": "Exact", "script": "alphabetic"}], + } + ), + encoding="utf-8", + ) + seen = {} + + def fake_remove(**kwargs): + seen.update(kwargs) + Image.open(kwargs["image_path"]).save(kwargs["output_path"]) + return kwargs["output_path"] + + engine = self._engine() + engine._remover.remove_watermark = fake_remove + monkeypatch.setattr(region_eraser, "lama_available", lambda: True) + + engine.remove_watermark(source, output, text_manifest=manifest) + + assert seen["text_manifest"].lines[0].text == "Exact" + + class TestNativeOutputSize: """Model-side latent-grid rounding must not change the public output size.""" diff --git a/tests/test_qwen_zimage_pipeline.py b/tests/test_qwen_zimage_pipeline.py index c4cce8b..e1b4824 100644 --- a/tests/test_qwen_zimage_pipeline.py +++ b/tests/test_qwen_zimage_pipeline.py @@ -579,6 +579,78 @@ def test_cli_qwen_zimage_keeps_profile_postprocess_default(tmp_image_path, monke assert mock_engine.remove_watermark.call_args.kwargs["adaptive_polish"] is True +def test_cli_forwards_verified_text_manifest(tmp_image_path, tmp_path, monkeypatch): + from remove_ai_watermarks import cli + + manifest = tmp_path / "manifest.json" + manifest.write_text("{}", encoding="utf-8") + mock_engine = MagicMock() + mock_engine.remove_watermark.return_value = tmp_image_path + monkeypatch.setattr("remove_ai_watermarks.invisible_engine.is_available", lambda: True) + monkeypatch.setattr("remove_ai_watermarks.invisible_engine.InvisibleEngine", MagicMock(return_value=mock_engine)) + + result = CliRunner().invoke( + cli.main, + ["invisible", str(tmp_image_path), "--text-manifest", str(manifest), "--force"], + ) + + assert result.exit_code == 0, result.output + assert mock_engine.remove_watermark.call_args.kwargs["text_manifest"] == manifest + + +def test_cli_reports_verified_text_manifest_errors(tmp_image_path, tmp_path, monkeypatch): + from remove_ai_watermarks import cli + + manifest = tmp_path / "manifest.json" + manifest.write_text("{}", encoding="utf-8") + mock_engine = MagicMock() + mock_engine.remove_watermark.side_effect = ValueError("manifest pixels do not match") + monkeypatch.setattr("remove_ai_watermarks.invisible_engine.is_available", lambda: True) + monkeypatch.setattr("remove_ai_watermarks.invisible_engine.InvisibleEngine", MagicMock(return_value=mock_engine)) + + result = CliRunner().invoke( + cli.main, + ["invisible", str(tmp_image_path), "--text-manifest", str(manifest), "--force"], + ) + + assert result.exit_code == 1 + assert "manifest pixels do not match" in result.output + + +def test_no_face_path_still_runs_verified_text_restoration(monkeypatch): + from remove_ai_watermarks._internal import qwen_zimage_pipeline, text_restoration + from remove_ai_watermarks._internal.qwen_zimage_pipeline import QwenZImagePipeline + from remove_ai_watermarks._internal.text_restoration import VerifiedTextLine, VerifiedTextManifest + + pipeline = object.__new__(QwenZImagePipeline) + pipeline.device = "cuda" + pipeline.progress_callback = None + source = Image.new("RGB", (32, 32), (10, 20, 30)) + donor = Image.new("RGB", (32, 32), (40, 50, 60)) + global_result = Image.new("RGB", (32, 32), (70, 80, 90)) + anchor = Image.new("RGB", (32, 32), (100, 110, 120)) + restored = Image.new("RGB", (32, 32), (130, 140, 150)) + pipeline._qwen_vae_roundtrip = MagicMock(return_value=donor) + pipeline._run_global = MagicMock(return_value=global_result) + monkeypatch.setattr(qwen_zimage_pipeline, "detect_faces", lambda _image: []) + blend = MagicMock(return_value=anchor) + restore = MagicMock(return_value=restored) + monkeypatch.setattr(text_restoration, "blend_fidelity_anchor", blend) + monkeypatch.setattr(text_restoration, "restore_verified_text", restore) + manifest = VerifiedTextManifest( + "0" * 64, + 32, + 32, + (VerifiedTextLine((4, 4, 20, 16), "Exact", "alphabetic"),), + ) + + result = pipeline.run(source, strength=0.1, seed=0, text_manifest=manifest) + + assert result is restored + blend.assert_called_once_with(global_result, donor) + restore.assert_called_once_with(source, anchor, donor, manifest.lines) + + def test_watermark_remover_dispatches_to_full_pipeline(tmp_path, monkeypatch): from remove_ai_watermarks._internal.watermark_remover import WatermarkRemover @@ -601,6 +673,7 @@ def test_watermark_remover_dispatches_to_full_pipeline(tmp_path, monkeypatch): _, kwargs = runtime.run.call_args assert kwargs["strength"] == pytest.approx(0.084) assert kwargs["seed"] == 0 + assert kwargs["text_manifest"] is None assert output.exists() diff --git a/tests/test_selective_text_restoration.py b/tests/test_selective_text_restoration.py index ce30582..b51a620 100644 --- a/tests/test_selective_text_restoration.py +++ b/tests/test_selective_text_restoration.py @@ -8,6 +8,8 @@ from pathlib import Path import numpy as np import pytest +from remove_ai_watermarks._internal import text_restoration + SCRIPT = Path(__file__).parents[1] / "scripts" / "selective_text_restoration.py" SPEC = importlib.util.spec_from_file_location("selective_text_restoration", SCRIPT) assert SPEC is not None @@ -87,7 +89,7 @@ def test_fresh_silhouette_uses_new_color_instead_of_source_pixels() -> None: mask = np.zeros((9, 9), dtype=np.uint8) mask[3:6, 3:6] = 255 - result = module.composite_fresh_silhouette(background, mask, (220, 180, 40), feather=0) + result = text_restoration.composite_fresh_silhouette(background, mask, (220, 180, 40), feather=0) assert np.all(result[3:6, 3:6] == (220, 180, 40)) np.testing.assert_array_equal(result[0, 0], background[0, 0]) @@ -99,7 +101,7 @@ def test_fresh_silhouette_antialiasing_softens_binary_edges() -> None: mask = np.zeros((9, 9), dtype=np.uint8) mask[3:6, 3:6] = 255 - result = module.composite_fresh_silhouette(background, mask, (220, 180, 40), feather=1.0) + result = text_restoration.composite_fresh_silhouette(background, mask, (220, 180, 40), feather=1.0) assert np.all(result[3, 3] > background[3, 3]) assert np.all(result[3, 3] < (220, 180, 40)) @@ -113,7 +115,7 @@ def test_reconstructed_glyphs_keep_exact_donor_core_and_fresh_edge() -> None: mask = np.zeros((9, 9), dtype=np.uint8) mask[3:6, 3:6] = 255 - fresh_edge = module.composite_fresh_silhouette(background, mask, (220, 180, 40)) + fresh_edge = text_restoration.composite_fresh_silhouette(background, mask, (220, 180, 40)) result = module.composite_reconstructed_glyphs(donor, fresh_edge, mask, feather=0.5) np.testing.assert_array_equal(result[3:6, 3:6], donor[3:6, 3:6]) @@ -163,13 +165,15 @@ def test_detect_line_boxes_fails_closed_on_count_mismatch() -> None: def test_residual_mask_is_limited_to_original_glyph_positions(monkeypatch) -> None: + from remove_ai_watermarks._internal import text_restoration + background = np.zeros((8, 8, 3), dtype=np.uint8) original = np.zeros((8, 8), dtype=np.uint8) original[3, 3] = 255 detected = np.zeros((8, 8), dtype=np.uint8) detected[3, 3] = 255 detected[6, 6] = 255 - monkeypatch.setattr(module, "foreground_mask", lambda _image, _box: detected) + monkeypatch.setattr(text_restoration, "_foreground_mask", lambda _image, _box: detected) residual = module.residual_glyph_mask(background, original, (0, 0, 8, 8)) diff --git a/tests/test_text_restoration.py b/tests/test_text_restoration.py new file mode 100644 index 0000000..9ea4887 --- /dev/null +++ b/tests/test_text_restoration.py @@ -0,0 +1,128 @@ +"""Verified-text manifest and compositor tests without model downloads.""" + +from __future__ import annotations + +import json + +import numpy as np +import pytest +from PIL import Image, PngImagePlugin + +from remove_ai_watermarks._internal.text_restoration import ( + FIDELITY_BLEND_ALPHA, + VerifiedTextLine, + blend_fidelity_anchor, + load_verified_text_manifest, + restore_verified_text, + source_pixel_sha256, +) + + +def _manifest(image: Image.Image) -> dict[str, object]: + return { + "schema_version": 1, + "verified": True, + "source_pixel_sha256": source_pixel_sha256(image), + "width": image.width, + "height": image.height, + "lines": [ + { + "box": [8, 8, 40, 24], + "text": "Exact text", + "script": "alphabetic", + "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" + tagged = tmp_path / "tagged.png" + image.save(plain) + metadata = PngImagePlugin.PngInfo() + metadata.add_text("note", "different container bytes") + image.save(tagged, pnginfo=metadata) + + with Image.open(plain) as left, Image.open(tagged) as right: + assert plain.read_bytes() != tagged.read_bytes() + assert source_pixel_sha256(left) == source_pixel_sha256(right) + + +def test_verified_manifest_is_bound_to_source_pixels(tmp_path) -> None: + source = Image.new("RGB", (48, 32), (10, 20, 30)) + path = tmp_path / "lines.json" + path.write_text(json.dumps(_manifest(source)), encoding="utf-8") + + loaded = load_verified_text_manifest(path, source) + + assert loaded.width == 48 + assert loaded.height == 32 + assert loaded.lines == (VerifiedTextLine((8, 8, 40, 24), "Exact text", "alphabetic", 0.0),) + + +@pytest.mark.parametrize( + ("mutation", "message"), + [ + ({"verified": False}, "verified=true"), + ({"source_pixel_sha256": "0" * 64}, "does not match"), + ({"width": 49}, "dimensions"), + ({"lines": []}, "non-empty"), + ], +) +def test_manifest_rejects_unverified_or_unbound_input(tmp_path, mutation, message) -> None: + source = Image.new("RGB", (48, 32), (10, 20, 30)) + payload = _manifest(source) + payload.update(mutation) + path = tmp_path / "lines.json" + path.write_text(json.dumps(payload), encoding="utf-8") + + with pytest.raises(ValueError, match=message): + load_verified_text_manifest(path, source) + + +def test_fidelity_anchor_uses_the_calibrated_rounding() -> None: + clean = Image.fromarray(np.array([[[1, 2, 3], [100, 150, 200]]], dtype=np.uint8)) + donor = Image.fromarray(np.array([[[255, 254, 253], [200, 100, 50]]], dtype=np.uint8)) + + result = np.asarray(blend_fidelity_anchor(clean, donor)) + expected = np.rint( + np.asarray(clean, dtype=np.float32) * (1.0 - FIDELITY_BLEND_ALPHA) + + np.asarray(donor, dtype=np.float32) * FIDELITY_BLEND_ALPHA + ).astype(np.uint8) + + assert np.array_equal(result, expected) + + +def test_restoration_uses_lama_and_qwen_vae_core(monkeypatch) -> None: + from remove_ai_watermarks import region_eraser + + source = np.full((40, 64, 3), 20, dtype=np.uint8) + source[12:24, 12:44] = 235 + candidate = np.full_like(source, 30) + candidate[12:24, 12:44] = 150 + donor = np.full_like(source, 40) + donor[12:24, 12:44] = (210, 220, 230) + calls: list[np.ndarray] = [] + + def fake_erase(image_bgr, mask): + calls.append(mask.copy()) + output = image_bgr.copy() + output[mask > 0] = (30, 30, 30) + return output + + monkeypatch.setattr(region_eraser, "lama_available", lambda: True) + monkeypatch.setattr(region_eraser, "erase_lama", fake_erase) + + result = restore_verified_text( + Image.fromarray(source), + Image.fromarray(candidate), + Image.fromarray(donor), + (VerifiedTextLine((8, 8, 48, 28), "Exact text", "alphabetic"),), + ) + + restored = np.asarray(result) + assert calls + assert np.all(restored[16, 20] == donor[16, 20]) + assert np.all(restored[0, 0] == candidate[0, 0]) diff --git a/uv.lock b/uv.lock index 9b3d6e1..4fa8b82 100644 --- a/uv.lock +++ b/uv.lock @@ -597,7 +597,7 @@ name = "cuda-bindings" version = "13.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cuda-pathfinder" }, + { name = "cuda-pathfinder", marker = "sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/51/6b/457ca12dad3ee9bfcc9a545cfd6b64b359ba49de40f776f6e028e678f262/cuda_bindings-13.3.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c5879712accf6e14bb01aa5e67440eb84998b8d104b509cc7a6dc0b8f656a474", size = 6053539, upload-time = "2026-05-29T23:11:43.19Z" }, @@ -630,43 +630,43 @@ wheels = [ [package.optional-dependencies] cublas = [ - { name = "nvidia-cublas", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, - { name = "nvidia-cuda-nvrtc", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cublas", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-cuda-nvrtc", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] cudart = [ - { name = "nvidia-cuda-runtime", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cuda-runtime", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] cufft = [ - { name = "nvidia-cufft", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, - { name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cufft", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] cufile = [ - { name = "nvidia-cufile", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cufile", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] cupti = [ - { name = "nvidia-cuda-cupti", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cuda-cupti", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] curand = [ - { name = "nvidia-curand", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-curand", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] cusolver = [ - { name = "nvidia-cublas", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, - { name = "nvidia-cusolver", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, - { name = "nvidia-cusparse", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, - { name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cublas", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-cusolver", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-cusparse", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] cusparse = [ - { name = "nvidia-cusparse", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, - { name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cusparse", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] nvjitlink = [ - { name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] nvrtc = [ - { name = "nvidia-cuda-nvrtc", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cuda-nvrtc", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] nvtx = [ - { name = "nvidia-nvtx", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-nvtx", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] [[package]] @@ -1094,15 +1094,15 @@ name = "lightning" version = "2.6.5" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "fsspec", extra = ["http"] }, - { name = "lightning-utilities" }, - { name = "packaging" }, - { name = "pytorch-lightning" }, - { name = "pyyaml" }, - { name = "torch" }, - { name = "torchmetrics" }, - { name = "tqdm" }, - { name = "typing-extensions" }, + { name = "fsspec", extra = ["http"], marker = "python_full_version < '3.13'" }, + { name = "lightning-utilities", marker = "python_full_version < '3.13'" }, + { name = "packaging", marker = "python_full_version < '3.13'" }, + { name = "pytorch-lightning", marker = "python_full_version < '3.13'" }, + { name = "pyyaml", marker = "python_full_version < '3.13'" }, + { name = "torch", marker = "python_full_version < '3.13'" }, + { name = "torchmetrics", marker = "python_full_version < '3.13'" }, + { name = "tqdm", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c9/1d/83be8536bec71a0173e762a9a1fd92a24a5ad0d0f74c59550c3c4e6c103b/lightning-2.6.5.tar.gz", hash = "sha256:16a30310ed69afde3748491feb5d13508908effd70390d2bfc203dc0812a4b4a", size = 659201, upload-time = "2026-05-27T14:33:41.806Z" } wheels = [ @@ -1114,8 +1114,8 @@ name = "lightning-utilities" version = "0.15.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "packaging" }, - { name = "typing-extensions" }, + { name = "packaging", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f1/45/7fa8f56b17dc0f0a41ec70dd307ecd6787254483549843bef4c30ab5adce/lightning_utilities-0.15.3.tar.gz", hash = "sha256:792ae0204c79f6859721ac7f386c237a33b0ed06ba775009cb894e010a842033", size = 33553, upload-time = "2026-02-22T14:48:53.348Z" } wheels = [ @@ -1518,7 +1518,7 @@ name = "nvidia-cublas" version = "13.1.1.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cuda-nvrtc" }, + { name = "nvidia-cuda-nvrtc", marker = "sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/a7/a1/0bd24ee8c8d03adac032fd2909426a00c88f8c57961b1277ded97f91119f/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:b7a210458267ac818974c53038fbec2e969d5c99f305ab15c72522fa9f001dd5", size = 542848918, upload-time = "2026-04-08T18:46:22.985Z" }, @@ -1557,7 +1557,7 @@ name = "nvidia-cudnn-cu13" version = "9.20.0.48" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas" }, + { name = "nvidia-cublas", marker = "sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/56/c5/83384d846b2fd17c44bd499b36c75a45ed4f095fbbb2252294e89cea5c5c/nvidia_cudnn_cu13-9.20.0.48-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:e31454ae00094b0c55319d9d15b6fa2fc50a9e1c0f5c8c80fb75258234e731e1", size = 444574296, upload-time = "2026-03-09T19:28:27.751Z" }, @@ -1569,7 +1569,7 @@ name = "nvidia-cufft" version = "12.0.0.61" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-nvjitlink" }, + { name = "nvidia-nvjitlink", marker = "sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/8b/ae/f417a75c0259e85c1d2f83ca4e960289a5f814ed0cea74d18c353d3e989d/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5", size = 214053554, upload-time = "2025-09-04T08:31:38.196Z" }, @@ -1599,9 +1599,9 @@ name = "nvidia-cusolver" version = "12.0.4.66" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas" }, - { name = "nvidia-cusparse" }, - { name = "nvidia-nvjitlink" }, + { name = "nvidia-cublas", marker = "sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "nvidia-cusparse", marker = "sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "nvidia-nvjitlink", marker = "sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/c8/c3/b30c9e935fc01e3da443ec0116ed1b2a009bb867f5324d3f2d7e533e776b/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2", size = 223467760, upload-time = "2025-09-04T08:33:04.222Z" }, @@ -1613,7 +1613,7 @@ name = "nvidia-cusparse" version = "12.6.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-nvjitlink" }, + { name = "nvidia-nvjitlink", marker = "sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/f8/94/5c26f33738ae35276672f12615a64bd008ed5be6d1ebcb23579285d960a9/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c", size = 162155568, upload-time = "2025-09-04T08:33:42.864Z" }, @@ -1670,8 +1670,8 @@ name = "omegaconf" version = "2.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "antlr4-python3-runtime" }, - { name = "pyyaml" }, + { name = "antlr4-python3-runtime", marker = "python_full_version < '3.13'" }, + { name = "pyyaml", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ce/3d/e4b57b8d9008c6ebe0d5eff901f91d5700cf7bdb8c8863df817463a7fd5e/omegaconf-2.3.1.tar.gz", hash = "sha256:e5e7de64aeebeddaf8e6d3f7a783b32ac2a01c0fbd9c878012caecb891a1f42a", size = 3298472, upload-time = "2026-06-11T05:05:12.885Z" } wheels = [ @@ -2273,14 +2273,14 @@ name = "pytorch-lightning" version = "2.6.5" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "fsspec", extra = ["http"] }, - { name = "lightning-utilities" }, - { name = "packaging" }, - { name = "pyyaml" }, - { name = "torch" }, - { name = "torchmetrics" }, - { name = "tqdm" }, - { name = "typing-extensions" }, + { name = "fsspec", extra = ["http"], marker = "python_full_version < '3.13'" }, + { name = "lightning-utilities", marker = "python_full_version < '3.13'" }, + { name = "packaging", marker = "python_full_version < '3.13'" }, + { name = "pyyaml", marker = "python_full_version < '3.13'" }, + { name = "torch", marker = "python_full_version < '3.13'" }, + { name = "torchmetrics", marker = "python_full_version < '3.13'" }, + { name = "tqdm", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/52/2c/8e73a3929b4c4bd600cafd38a97aaf7242a8cf518fb9f33d27c274ec898f/pytorch_lightning-2.6.5.tar.gz", hash = "sha256:1c32cefa76a1a9c4c5250338272d961d1e48b180e68396849efe128538ddb28e", size = 661673, upload-time = "2026-05-27T14:33:41.961Z" } wheels = [ @@ -2604,6 +2604,21 @@ qwen-zimage = [ { name = "torchvision" }, { name = "transformers" }, ] +text-restoration = [ + { name = "accelerate" }, + { name = "diffsynth" }, + { name = "diffusers" }, + { name = "huggingface-hub" }, + { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" }, + { name = "numpy", version = "2.5.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13'" }, + { name = "onnxruntime" }, + { name = "opencv-python-headless" }, + { name = "safetensors" }, + { name = "tokenizers" }, + { name = "torch" }, + { name = "torchvision" }, + { name = "transformers" }, +] trustmark = [ { name = "trustmark", marker = "python_full_version < '3.13'" }, ] @@ -2650,8 +2665,9 @@ requires-dist = [ { name = "remove-ai-watermarks", extras = ["pixels"], marker = "extra == 'detect'" }, { name = "remove-ai-watermarks", extras = ["pixels"], marker = "extra == 'diffusion'" }, { name = "remove-ai-watermarks", extras = ["pixels"], marker = "extra == 'visible'" }, + { name = "remove-ai-watermarks", extras = ["qwen-zimage", "lama"], marker = "extra == 'text-restoration'" }, { name = "remove-ai-watermarks", extras = ["video"], marker = "extra == 'dev'" }, - { name = "remove-ai-watermarks", extras = ["video", "heif", "detect", "trustmark", "qwen-zimage", "lama", "migan"], marker = "extra == 'all'" }, + { name = "remove-ai-watermarks", extras = ["video", "heif", "detect", "trustmark", "text-restoration", "migan"], marker = "extra == 'all'" }, { name = "remove-ai-watermarks", extras = ["visible"], marker = "extra == 'lama'" }, { name = "remove-ai-watermarks", extras = ["visible"], marker = "extra == 'migan'" }, { name = "remove-ai-watermarks", extras = ["visible"], marker = "extra == 'video'" }, @@ -2663,7 +2679,7 @@ requires-dist = [ { name = "transformers", marker = "extra == 'diffusion'", specifier = ">=5,<6" }, { name = "trustmark", marker = "python_full_version < '3.13' and extra == 'trustmark'", specifier = ">=0.8.0" }, ] -provides-extras = ["pixels", "heif", "visible", "video", "detect", "diffusion", "qwen-zimage", "trustmark", "lama", "migan", "dev", "all"] +provides-extras = ["pixels", "heif", "visible", "video", "detect", "diffusion", "qwen-zimage", "text-restoration", "trustmark", "lama", "migan", "dev", "all"] [[package]] name = "requests" @@ -2968,10 +2984,10 @@ name = "torchmetrics" version = "1.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "lightning-utilities" }, - { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" } }, - { name = "packaging" }, - { name = "torch" }, + { name = "lightning-utilities", marker = "python_full_version < '3.13'" }, + { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" }, + { name = "packaging", marker = "python_full_version < '3.13'" }, + { name = "torch", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/81/34/39b8b749333db56c0585d7a11fa62a283c087bb1dfc897d69fb8cedbefb1/torchmetrics-1.9.0.tar.gz", hash = "sha256:a488609948600df52d3db4fcdab02e62aab2a85ef34da67037dc3e65b8512faa", size = 581765, upload-time = "2026-03-09T17:41:22.443Z" } wheels = [ @@ -3066,13 +3082,13 @@ name = "trustmark" version = "0.9.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "einops" }, - { name = "lightning" }, - { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" } }, - { name = "omegaconf" }, - { name = "six" }, - { name = "torch" }, - { name = "torchvision" }, + { name = "einops", marker = "python_full_version < '3.13'" }, + { name = "lightning", marker = "python_full_version < '3.13'" }, + { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" }, + { name = "omegaconf", marker = "python_full_version < '3.13'" }, + { name = "six", marker = "python_full_version < '3.13'" }, + { name = "torch", marker = "python_full_version < '3.13'" }, + { name = "torchvision", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/87/0a/0a4232030c6a62d12b6a02ae73bdce6e99c8532bc8f05a5a2e6ce103da82/trustmark-0.9.1.tar.gz", hash = "sha256:dc79e3fb070f5d94765acf8868a51f50a612cc05b53223cf1e6b605d4ff1e0ae", size = 63949, upload-time = "2026-04-09T08:59:52.472Z" }