from __future__ import annotations import importlib.util import json import sys 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 assert SPEC.loader is not None module = importlib.util.module_from_spec(SPEC) sys.modules[SPEC.name] = module SPEC.loader.exec_module(module) def test_normalized_edit_distance_ignores_case_and_whitespace() -> None: assert module.normalized_edit_distance(" Sample text ", "sample\ntext") == 0.0 def test_preserve_requires_source_candidate_agreement() -> None: assert module.should_preserve_line("clean text", "clean text", 0.9, "clean text", 0.9) assert not module.should_preserve_line("clean text", "clean text", 0.9, "damaged", 0.9) def test_preserve_rejects_unreliable_source_recognition() -> None: assert not module.should_preserve_line("expected", "unrelated", 0.9, "unrelated", 0.9) assert not module.should_preserve_line("expected", "expected", 0.7, "expected", 0.9) def test_cjk_recognition_box_excludes_overlapping_neighbor_lines() -> None: line = module.TextLine((1281, 650, 2357, 818), "每天都是一个新的机会。", "cjk") assert module._recognition_box(line, 2816, 1536) == (1247, 650, 2458, 818) def test_latin_recognition_box_keeps_context_padding() -> None: line = module.TextLine((100, 200, 300, 260), "Sample text", "latin") assert module._recognition_box(line, 1000, 1000) == (40, 180, 360, 280) assert module._recognition_box(line, 1000, 1000, 0.1) == (40, 192, 360, 268) def test_verified_lines_cover_each_ground_truth_string() -> None: root = Path(__file__).parents[1] lines = json.loads((root / "data/evaluations/fidelity/text-lines.json").read_text(encoding="utf-8")) ground_truth = json.loads((root / "data/evaluations/fidelity/ground-truth.json").read_text(encoding="utf-8")) assert lines.keys() == ground_truth.keys() for source, expected in ground_truth.items(): observed = " ".join(line["text"] for line in lines[source]) assert module.normalize_text(observed) == module.normalize_text(expected) def test_group_word_boxes_merges_words_but_not_neighboring_lines() -> None: boxes = [(10, 10, 30, 30), (32, 12, 60, 29), (10, 35, 50, 55)] assert module.group_word_boxes(boxes) == [(10, 10, 60, 30), (10, 35, 50, 55)] def test_group_word_boxes_does_not_merge_distant_columns() -> None: boxes = [(10, 10, 60, 30), (500, 11, 560, 31)] assert module.group_word_boxes(boxes) == boxes def test_source_glyph_composite_keeps_masked_pixels_exact() -> None: source = np.zeros((9, 9, 3), dtype=np.uint8) source[:, :] = (220, 180, 40) background = np.zeros((9, 9, 3), dtype=np.uint8) background[:, :] = (10, 20, 30) mask = np.zeros((9, 9), dtype=np.uint8) mask[3:6, 3:6] = 255 result = module.composite_source_glyphs(source, background, mask, feather=0.7) np.testing.assert_array_equal(result[3:6, 3:6], source[3:6, 3:6]) np.testing.assert_array_equal(result[0, 0], background[0, 0]) def test_fresh_silhouette_uses_new_color_instead_of_source_pixels() -> None: background = np.zeros((9, 9, 3), dtype=np.uint8) background[:, :] = (10, 20, 30) mask = np.zeros((9, 9), dtype=np.uint8) mask[3:6, 3:6] = 255 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]) def test_fresh_silhouette_antialiasing_softens_binary_edges() -> None: background = np.zeros((9, 9, 3), dtype=np.uint8) background[:, :] = (10, 20, 30) mask = np.zeros((9, 9), dtype=np.uint8) mask[3:6, 3:6] = 255 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)) def test_reconstructed_glyphs_keep_exact_donor_core_and_fresh_edge() -> None: donor = np.zeros((9, 9, 3), dtype=np.uint8) donor[:, :] = (180, 140, 60) background = np.zeros((9, 9, 3), dtype=np.uint8) background[:, :] = (10, 20, 30) mask = np.zeros((9, 9), dtype=np.uint8) mask[3:6, 3:6] = 255 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]) assert np.any(result[2, 3] != fresh_edge[2, 3]) np.testing.assert_array_equal(result[0, 0], background[0, 0]) def test_source_silhouette_discards_foreground_amplitudes() -> None: source = np.full((15, 15, 3), 20, dtype=np.uint8) source[5:10, 6:9] = 230 source[6:9, 7] = 180 mask = module.source_silhouette_mask(source, (4, 4, 11, 11)) assert mask.dtype == np.uint8 assert set(np.unique(mask)) <= {0, 255} assert mask[7, 7] == 255 assert mask[4, 4] == 0 def test_rotated_source_silhouette_excludes_axis_aligned_corners() -> None: source = np.full((80, 160, 3), 20, dtype=np.uint8) source[10:70, 10:150] = 230 mask = module.source_silhouette_mask(source, (0, 0, 160, 80), angle=12) assert mask[0, 0] == 0 assert mask[79, 159] == 0 def test_source_box_mask_pads_and_clips_boxes() -> None: mask = module.source_box_mask((20, 30), [(1, 2, 11, 10), (25, 15, 30, 20)]) assert mask.shape == (20, 30) assert mask[0, 0] == 255 assert mask[19, 29] == 255 assert mask[0, 22] == 0 def test_detect_line_boxes_fails_closed_on_count_mismatch() -> None: class Engine: def predict(self, _image): return [{"rec_scores": [0.9], "rec_boxes": [[10, 10, 30, 30]]}] with pytest.raises(module.click.ClickException, match="detected 1 source lines; expected exactly 2"): module.detect_line_boxes(Engine(), np.zeros((50, 50, 3), dtype=np.uint8), expected_count=2) 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(text_restoration, "_foreground_mask", lambda _image, _box: detected) residual = module.residual_glyph_mask(background, original, (0, 0, 8, 8)) assert residual[3, 3] == 255 assert residual[6, 6] == 0