Files
remove-ai-watermarks/tests/test_tiling.py
T
Victor KuznetsovandClaude Opus 5 52b2c115e8 Delete every knob the fixed profiles cannot honor
The CLI still advertised --model, --steps, --guidance-scale, --device and a
deprecated --auto. Each pinned a value the two surviving profiles fix -- the
model stack, the per-stage distilled schedule, CFG 1.0, CUDA -- so the only
outcome any of them had was an error raised several frames below the caller,
under a message naming an internal profile. A flag whose sole result is a
refusal is worse than no flag: it advertises a capability that does not exist,
and it lets a wrapper thread a value that will silently do nothing. They are
gone from the parser, from InvisibleEngine, and from WatermarkRemover, so the
failure is now a TypeError or a Click "No such option" at the point the caller
can act on.

The install hint was wrong in the same way. is_available() checked torch and
diffusers, then told the user to install [diffusion] -- which contains neither
DiffSynth nor the Z-Image face stage both profiles run. Following the advice
produced a second, different failure. The module list and the extra name now
live once in watermark_profiles (REMOVAL_MODULES, INVISIBLE_EXTRA) and are read
by both the CLI gate and the remover's precondition, which cannot drift apart
because they are the same tuple.

The adaptive-polish default moved out of the argument parser. It was resolved by
reading Click's parameter source, which put per-profile data in the CLI layer,
left the engine declaring the opposite default (False vs True) so a library
caller and a CLI caller on one profile got different output, and lost the polish
entirely for anything that supplies the flag non-interactively. The flag is now
tri-state (default=None) and resolve_adaptive_polish owns the per-profile
answer. The seed follows the same rule: the CLI stopped pre-resolving it.

Dead code removed with it: six scan_*_video wrappers and the _scan_video helper
none of them had a caller for, PNG_METADATA_KEYS, feather_region_composite and
the remover region path that was only reachable from a no-caller convenience
wrapper, remove_watermark_batch on both layers, try_empty_device_cache, the
_generate/_run_qwen_zimage pass-through pair, self.model_id, and the _internal
PEP 562 shim that no caller ever went through. get_device now answers cuda or
cpu only: mps and xpu travelled one frame to the same CUDA-only refusal while
costing a device probe each, and that refusal now names the resolved device, so
device=None on a CUDA-less host says 'cpu' rather than 'None'. The XPU wheel
index went with them.

Docs: README, cli, installation, python-api, supported-signals,
known-limitations and module-internals all still described the removed profiles,
the CPU/MPS/XPU ladder, a `default`->`sdxl` alias, and the wrong extra.
known-limitations still listed the retired SDXL strength ladder as current.
scripts/smoke_matrix.py and real_examples_e2e.py drove --device mps.

Next release is 0.25.0, not a patch: this removes public parameters and
narrows a published extra on top of the released 0.24.0.

pre-commit: 1) maintain.sh - exit 0 (1091 tests, Pyright 0 errors, no
vulnerabilities); 2) /simplify - 4 agents, 11 findings applied, 2 skipped
(dropping the `device` parameter entirely, which raiw-app pins; folding
diffsynth into the `diffusion` extra, which video-only callers do not need);
3) docs sync - grepped every removed identifier across README, docs/, scripts/,
.claude/; updated 9 docs; 4) CLAUDE.md - added the no-error-only-knobs rule to
.claude/rules/development.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 15:38:40 -07:00

141 lines
5.5 KiB
Python

"""Unit tests for the sliding-window tiled-diffusion helpers (no GPU/model).
``tiling`` is pure numpy/PIL: the geometry (``plan_tiles`` / ``_axis_positions``),
the feather window (``feather_weights``), and the blend loop (``run_tiled``) are all
exercised here with a plain callable standing in for the diffusion pass, so the
seam-free reconstruction and the tile layout are guarded without loading SDXL.
"""
from __future__ import annotations
import numpy as np
import pytest
from PIL import Image
from remove_ai_watermarks._internal.tiling import (
Tile,
_axis_positions,
feather_weights,
plan_tiles,
run_tiled,
)
class TestAxisPositions:
def test_single_tile_when_length_fits(self):
assert _axis_positions(800, 1024, 128) == [0]
assert _axis_positions(1024, 1024, 128) == [0]
def test_two_tiles_last_flush_to_edge(self):
# 1500 wide, tile 1024, step 1024-128=896: starts [0], then last = 1500-1024=476.
assert _axis_positions(1500, 1024, 128) == [0, 476]
def test_uniform_step_then_flush(self):
# 3000, tile 1024, step 896 -> 0, 896, 1792; last = 1976 appended (2688 would
# overrun). The regular range stops at 1792 (next 2688 > 1976), so flush adds 1976.
assert _axis_positions(3000, 1024, 128) == [0, 896, 1792, 1976]
def test_no_duplicate_when_range_already_hits_edge(self):
# length-tile divisible by step: the last range entry already equals the edge.
# 1024*2-128 = 1920 width, step 896, last = 896 -> range gives [0, 896]; no dup.
assert _axis_positions(1920, 1024, 128) == [0, 896]
def test_overlap_at_least_tile_still_progresses(self):
# Pathological overlap >= tile is clamped to tile-1 so step stays >= 1.
positions = _axis_positions(2000, 1024, 5000)
assert positions[0] == 0
assert positions[-1] == 2000 - 1024
assert positions == sorted(positions)
def test_invalid_tile_raises(self):
with pytest.raises(ValueError, match="tile must be positive"):
_axis_positions(100, 0, 10)
class TestPlanTiles:
def test_single_tile_for_small_image(self):
tiles = plan_tiles(800, 600, 1024, 128)
assert tiles == [Tile(0, 0, 800, 600)]
def test_grid_is_row_major_and_uniform_size(self):
tiles = plan_tiles(1500, 1500, 1024, 128)
# 2x2 grid: xs/ys both [0, 476].
assert [(t.x, t.y) for t in tiles] == [(0, 0), (476, 0), (0, 476), (476, 476)]
# Every tile is exactly tile_size (uniform -> SDXL-friendly).
assert all((t.width, t.height) == (1024, 1024) for t in tiles)
def test_tiles_cover_the_full_canvas(self):
width, height = 2600, 1800
tiles = plan_tiles(width, height, 1024, 128)
covered = np.zeros((height, width), dtype=bool)
for t in tiles:
covered[t.y : t.y + t.height, t.x : t.x + t.width] = True
assert covered.all()
class TestFeatherWeights:
def test_shape_matches_tile(self):
assert feather_weights(64, 48, 8).shape == (48, 64)
def test_strictly_positive(self):
assert (feather_weights(64, 64, 16) > 0).all()
def test_interior_higher_than_edge(self):
w = feather_weights(64, 64, 16)
assert w[32, 32] == pytest.approx(1.0)
# The corner sits in the taper, so it is well below the interior.
assert w[0, 0] < w[32, 32]
def test_symmetric(self):
w = feather_weights(64, 64, 16)
assert np.allclose(w, w[::-1, :])
assert np.allclose(w, w[:, ::-1])
def test_zero_overlap_is_flat(self):
# No taper requested -> a flat window of ones.
assert np.allclose(feather_weights(32, 32, 0), 1.0)
class TestRunTiled:
def test_identity_generate_reconstructs_image(self):
# A blend of identical (unchanged) tiles must reproduce the input exactly,
# regardless of overlap -- the feather weights are a partition-of-unity once
# normalised. This is the seam-free guarantee.
rng = np.random.default_rng(0)
arr = rng.integers(0, 256, size=(1500, 1300, 3), dtype=np.uint8)
image = Image.fromarray(arr)
out = run_tiled(lambda tile: tile, image, tile_size=512, overlap=64)
assert out.size == image.size
assert np.abs(np.asarray(out, dtype=np.int16) - arr.astype(np.int16)).max() <= 1
def test_generate_called_once_per_tile(self):
calls: list[tuple[int, int]] = []
def generate(tile: Image.Image) -> Image.Image:
calls.append(tile.size)
return tile
image = Image.new("RGB", (1500, 1500), (120, 130, 140))
run_tiled(generate, image, tile_size=1024, overlap=128)
assert len(calls) == len(plan_tiles(1500, 1500, 1024, 128)) == 4
def test_single_tile_path_for_small_image(self):
image = Image.new("RGB", (300, 200), (10, 20, 30))
out = run_tiled(lambda tile: tile, image, tile_size=1024, overlap=128)
assert out.size == (300, 200)
assert np.asarray(out)[0, 0].tolist() == [10, 20, 30]
def test_mismatched_generate_output_is_resized_back(self):
# A pipeline that rounds dims to the latent grid returns a slightly different
# size; run_tiled must resize it back so the blend buffers line up.
def generate(tile: Image.Image) -> Image.Image:
w, h = tile.size
return tile.resize((w - w % 8, h - h % 8), Image.Resampling.LANCZOS)
image = Image.new("RGB", (1500, 1100), (200, 100, 50))
out = run_tiled(generate, image, tile_size=1024, overlap=128)
assert out.size == (1500, 1100)