Evaluate selective text restoration

This commit is contained in:
Victor Kuznetsov
2026-08-15 12:20:25 -07:00
parent f8003f505a
commit 8c00525946
19 changed files with 1779 additions and 14 deletions
+34
View File
@@ -0,0 +1,34 @@
"""Pure text-normalization helpers shared by evaluation scripts."""
from __future__ import annotations
import unicodedata
def normalize_text(text: str) -> str:
"""Normalize text for layout-independent evaluation comparisons."""
return "".join(unicodedata.normalize("NFC", text).casefold().split())
def levenshtein_normalized(left: str, right: str) -> float:
"""Return normalized Levenshtein distance without changing either input."""
if not left and not right:
return 0.0
previous = list(range(len(right) + 1))
for left_index, left_character in enumerate(left, start=1):
current = [left_index]
for right_index, right_character in enumerate(right, start=1):
current.append(
min(
current[-1] + 1,
previous[right_index] + 1,
previous[right_index - 1] + (left_character != right_character),
)
)
previous = current
return previous[-1] / max(len(left), len(right))
def normalized_edit_distance(left: str, right: str) -> float:
"""Normalize text, then return Levenshtein distance over the result."""
return levenshtein_normalized(normalize_text(left), normalize_text(right))
+8 -7
View File
@@ -3,10 +3,9 @@
# dependencies = [
# "click",
# "numpy",
# "opencv-python-headless",
# "opencv-python-headless<5",
# "pillow",
# "scikit-image",
# "rapidfuzz",
# "torch",
# "lpips",
# "paddleocr",
@@ -29,7 +28,7 @@ metrics run only where faces are detected, text metrics only where text is.
Two subcommands:
ocr -- OCR images (PaddleOCR PP-OCRv6) into a JSON {basename: text} file.
ocr -- OCR images (PaddleOCR defaults) into a JSON {basename: text} file.
Run this on the ORIGINALS, hand-verify/correct the file, and it
becomes the ground truth for ``compare --ground-truth`` -- the clean
way to score text, since OCR-vs-OCR is doubly noisy (errors on both
@@ -54,6 +53,7 @@ Usage:
from __future__ import annotations
import json
import sys
import unicodedata
from dataclasses import dataclass, field
from pathlib import Path
@@ -65,7 +65,10 @@ import numpy as np
from _plain_console import Console, Table
console = Console()
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from scripts._text_eval import levenshtein_normalized # noqa: E402
# ── helpers ──────────────────────────────────────────────────────────
@@ -89,7 +92,7 @@ def _norm(text: str) -> str:
return "".join(unicodedata.normalize("NFC", text).split())
# ── text: PaddleOCR (PP-OCRv6) ───────────────────────────────────────
# ── text: PaddleOCR defaults ─────────────────────────────────────────
# Our lang codes -> PaddleOCR lang. The 'ch' model also reads Latin; 'ru' reads
# Cyrillic + Latin. Multiple langs in one image -> run each model, union detections.
@@ -165,9 +168,7 @@ def _ocr_lines(bgr: np.ndarray, langs: list[str], min_score: float = 0.5) -> lis
def _cer(ref: str, hyp: str) -> float:
from rapidfuzz.distance import Levenshtein
return Levenshtein.normalized_distance(_norm(ref), _norm(hyp))
return levenshtein_normalized(_norm(ref), _norm(hyp))
# ── face: detection + ArcFace + texture ──────────────────────────────
+128
View File
@@ -0,0 +1,128 @@
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "click",
# "huggingface-hub>=0.20.0",
# "numpy",
# "onnxruntime>=1.24.0",
# "opencv-python-headless<5",
# "paddleocr>=3.3.3",
# "paddlepaddle",
# "pillow",
# ]
# ///
"""Infer stable source-text lines without modifying an image.
This evaluation-only dry run proposes line annotations for selective text
restoration. Every proposal still needs human verification: stable OCR can lose
punctuation with high confidence. It separately flags lines whose recognition
changes under crop jitter or whose minimum confidence is below the threshold.
"""
from __future__ import annotations
import importlib.util
import json
import logging
import os
import sys
import unicodedata
from pathlib import Path
from typing import Any
import click
import numpy as np
from PIL import Image
log = logging.getLogger(__name__)
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
RESTORATION_SCRIPT = ROOT / "scripts/selective_text_restoration.py"
from scripts._text_eval import normalize_text # noqa: E402
def _load_restoration_module() -> Any:
spec = importlib.util.spec_from_file_location("selective_text_restoration_for_inference", RESTORATION_SCRIPT)
if spec is None or spec.loader is None:
raise RuntimeError(f"unable to load {RESTORATION_SCRIPT}")
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
def _has_script(text: str, script: str) -> bool:
return any(script in unicodedata.name(character, "") for character in text)
def choose_language(probes: dict[str, tuple[str, float]]) -> str:
if _has_script(probes["ch"][0], "CJK"):
return "ch"
if _has_script(probes["ru"][0], "CYRILLIC"):
return "ru"
return "en"
def stable_recognition(reads: list[tuple[str, float]], min_score: float = 0.85) -> str | None:
normalized = {normalize_text(text) for text, _score in reads}
if len(normalized) != 1 or min(score for _text, score in reads) < min_score:
return None
return reads[0][0]
@click.command()
@click.argument("source", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option("--out", required=True, type=click.Path(dir_okay=False, path_type=Path))
@click.option("--min-score", default=0.85, show_default=True, type=click.FloatRange(0.0, 1.0))
def main(source: Path, out: Path, min_score: float) -> None:
"""Write draft line text for SOURCE; manually verify every proposal."""
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
os.environ["PADDLE_PDX_DISABLE_MODEL_SOURCE_CHECK"] = "True"
from paddleocr import PaddleOCR, TextRecognition
restoration = _load_restoration_module()
source_rgb = np.asarray(Image.open(source).convert("RGB"))
detector = PaddleOCR(
lang="ch",
use_doc_orientation_classify=False,
use_doc_unwarping=False,
use_textline_orientation=False,
)
engines = {
"en": TextRecognition(model_name="en_PP-OCRv5_mobile_rec"),
"ru": TextRecognition(model_name="eslav_PP-OCRv5_mobile_rec"),
"ch": TextRecognition(model_name="PP-OCRv5_server_rec"),
}
boxes = restoration.detect_line_boxes(detector, source_rgb)
accepted = []
rejected = []
for box in boxes:
probes = {}
for language, engine in engines.items():
script = "cjk" if language == "ch" else "alphabetic"
line = restoration.TextLine(box, "", script)
probes[language] = restoration._recognize(engine, source_rgb, line, 0.1)
language = choose_language(probes)
script = "cjk" if language == "ch" else "alphabetic"
line = restoration.TextLine(box, "", script)
reads = [restoration._recognize(engines[language], source_rgb, line, ratio) for ratio in (0.08, 0.12, 0.2)]
text = stable_recognition(reads, min_score)
result = {
"box": box,
"script": script,
"language": language,
"reads": [{"text": value, "score": score} for value, score in reads],
}
if text is None:
rejected.append(result)
else:
accepted.append({"box": box, "text": text, "script": script, "min_score": min(score for _, score in reads)})
payload = {"source": source.name, "accepted": accepted, "rejected": rejected}
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
log.info("Accepted %s lines and rejected %s uncertain lines", len(accepted), len(rejected))
if __name__ == "__main__":
main()
+671
View File
@@ -0,0 +1,671 @@
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "click",
# "huggingface-hub>=0.20.0",
# "numpy",
# "onnxruntime>=1.24.0",
# "opencv-python-headless<5",
# "paddleocr>=3.3.3",
# "paddlepaddle",
# "pillow",
# ]
# ///
"""Evaluation-only text restoration over a scrubbed image.
``vae-glyphs`` composites only thresholded glyph-core pixels from a separately
generated VAE reconstruction over a fresh silhouette edge. ``source-glyphs``
can preserve typeface, layout, color, and antialiasing when
SOURCE is itself a regenerated layer. It must not be treated as safe when SOURCE
is the watermarked original: a provider oracle detected SynthID after that exact
paste-back experiment. ``source-silhouette`` instead transfers only a
thresholded glyph shape, then synthesizes fresh flat-color pixels and
antialiasing. ``rerender`` retains the system-font negative control. This is
not a production stage and does not add PaddleOCR to the package graph.
"""
from __future__ import annotations
import json
import logging
import math
import os
import shutil
import sys
import unicodedata
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any
import click
import cv2
import numpy as np
from PIL import Image, ImageDraw, ImageFont
log = logging.getLogger(__name__)
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "src"))
from remove_ai_watermarks import region_eraser # noqa: E402
from scripts._text_eval import normalize_text, normalized_edit_distance # noqa: E402
if ROOT not in Path(region_eraser.__file__).resolve().parents:
raise RuntimeError("selective_text_restoration imported outside the current worktree")
REGULAR_FONT = Path("/System/Library/Fonts/Supplemental/Arial.ttf")
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,
source_score: float,
candidate_text: str,
candidate_score: float,
) -> bool:
if min(source_score, candidate_score) < 0.75:
return False
if normalized_edit_distance(expected, source_text) > 0.25:
return False
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,
glyph_mask: np.ndarray,
*,
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")
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)
def source_box_mask(
shape: tuple[int, int],
boxes: list[tuple[int, int, int, int]],
) -> np.ndarray:
"""Build a padded text-line mask for aligned regenerated layer compositing."""
height, width = shape
mask = np.zeros((height, width), dtype=np.uint8)
for x1, y1, x2, y2 in boxes:
pad = max(8, (y2 - y1) // 4)
x1, y1, x2, y2 = _clip_box((x1, y1, x2, y2), width, height, pad=pad)
mask[y1:y2, x1:x2] = 255
return mask
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: np.ndarray, box: tuple[int, int, int, int]) -> np.ndarray:
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
contrast = max(light_contrast, dark_contrast)
threshold = max(24.0, min(72.0, 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 _line_language(line: TextLine) -> str:
if line.script == "cjk":
return "ch"
return "ru" if any("CYRILLIC" in unicodedata.name(character, "") for character in line.text) else "en"
def _recognition_box(
line: TextLine,
width: int,
height: int,
vertical_pad_ratio: float | None = None,
) -> tuple[int, int, int, int]:
x1, y1, x2, y2 = line.box
line_height = y2 - y1
if line.script == "cjk":
left_pad = max(16, round(line_height * 0.2))
right_pad = max(16, round(line_height * 0.6))
return max(0, x1 - left_pad), y1, min(width, x2 + right_pad), y2
pad_x = max(16, line_height)
pad_y = max(8, line_height // 3) if vertical_pad_ratio is None else max(8, round(line_height * vertical_pad_ratio))
return max(0, x1 - pad_x), max(0, y1 - pad_y), min(width, x2 + pad_x), min(height, y2 + pad_y)
def _recognize(
engine: Any,
image: np.ndarray,
line: TextLine,
vertical_pad_ratio: float | None = None,
) -> tuple[str, float]:
height, width = image.shape[:2]
x1, y1, x2, y2 = _recognition_box(line, width, height, vertical_pad_ratio)
crop = image[y1:y2, x1:x2]
if crop.shape[0] < 64:
scale = 64 / crop.shape[0]
crop = cv2.resize(crop, None, fx=scale, fy=scale, interpolation=cv2.INTER_CUBIC)
result = next(iter(engine.predict(crop)))
return str(result.get("rec_text", "")), float(result.get("rec_score", 0.0))
def _sample_text_color(
source_rgb: np.ndarray,
mask: np.ndarray,
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]
active = mask[y1:y2, x1:x2] > 0
pixels = crop[active]
luma = pixels.mean(axis=1)
background_luma = float(crop[[0, -1], :, :].reshape(-1, 3).mean(axis=1).mean())
if background_luma >= 128:
selected = pixels[luma <= np.percentile(luma, 20)]
else:
selected = pixels[luma >= np.percentile(luma, 80)]
return tuple(int(value) for value in np.median(selected, axis=0))
def _render_line(image: Image.Image, line: TextLine, color: tuple[int, int, int]) -> None:
font_path = CJK_FONT if line.script == "cjk" else (BOLD_FONT if line.box[3] - line.box[1] >= 55 else REGULAR_FONT)
target_width, target_height = line.box[2] - line.box[0], line.box[3] - line.box[1]
draw = ImageDraw.Draw(image)
low, high = 4, max(8, target_height * 2)
font = ImageFont.truetype(str(font_path), low)
while low <= high:
size = (low + high) // 2
candidate = ImageFont.truetype(str(font_path), size)
bounds = draw.textbbox((0, 0), line.text, font=candidate)
if bounds[2] - bounds[0] <= target_width * 1.03 and bounds[3] - bounds[1] <= target_height * 1.08:
font, low = candidate, size + 1
else:
high = size - 1
bounds = draw.textbbox((0, 0), line.text, font=font)
y = line.box[1] + math.floor((target_height - (bounds[3] - bounds[1])) / 2) - bounds[1]
draw.text((line.box[0], y), line.text, fill=color, font=font)
def _write_manifest(path: Path | None, payload: dict[str, Any]) -> None:
if path is None:
return
path.parent.mkdir(parents=True, exist_ok=True)
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]))
def group_word_boxes(boxes: list[tuple[int, int, int, int]]) -> list[tuple[int, int, int, int]]:
groups: list[tuple[int, int, int, int]] = []
for box in sorted(boxes, key=lambda item: ((item[1] + item[3]) / 2, item[0])):
matches = []
for index, group in enumerate(groups):
if _vertical_overlap_ratio(box, group) < 0.45:
continue
horizontal_gap = max(0, max(box[0], group[0]) - min(box[2], group[2]))
line_height = min(box[3] - box[1], group[3] - group[1])
if horizontal_gap <= max(24, line_height * 3):
matches.append(index)
if not matches:
groups.append(box)
continue
index = max(matches, key=lambda item: _vertical_overlap_ratio(box, groups[item]))
x1, y1, x2, y2 = groups[index]
groups[index] = min(x1, box[0]), min(y1, box[1]), max(x2, box[2]), max(y2, box[3])
return sorted(groups, key=lambda item: ((item[1] + item[3]) / 2, item[0]))
def detect_line_boxes(
engine: Any,
source_rgb: np.ndarray,
expected_count: int | None = None,
) -> list[tuple[int, int, int, int]]:
boxes: list[tuple[int, int, int, int]] = []
for page in engine.predict(source_rgb):
detected = page.get("rec_boxes", None)
if detected is None or len(detected) == 0:
detected = page.get("rec_polys", [])
for score, raw_box in zip(page.get("rec_scores", []), detected, strict=False):
if float(score) < 0.5:
continue
points = np.asarray(raw_box, dtype=np.float32).reshape(-1)
if points.size == 4:
x1, y1, x2, y2 = points
else:
points = points.reshape(-1, 2)
x1, y1 = points.min(axis=0)
x2, y2 = points.max(axis=0)
boxes.append((round(float(x1)), round(float(y1)), round(float(x2)), round(float(y2))))
lines = group_word_boxes(boxes)
if expected_count is not None and len(lines) != expected_count:
raise click.ClickException(f"detected {len(lines)} source lines; expected exactly {expected_count}")
return lines
def _load_lines(path: Path, key: str) -> list[TextLine]:
payload = json.loads(path.read_text(encoding="utf-8"))
return [
TextLine(tuple(item["box"]), item["text"], item["script"], float(item.get("angle", 0.0)))
for item in payload[key]
]
@click.command()
@click.argument("source", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.argument("candidate", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option("--output", required=True, type=click.Path(dir_okay=False, path_type=Path))
@click.option(
"--lines-json",
default=ROOT / "data/evaluations/fidelity/text-lines.json",
type=click.Path(exists=True, dir_okay=False, path_type=Path),
)
@click.option("--source-key", help="Key in lines JSON; defaults to source basename.")
@click.option(
"--detect-boxes",
is_flag=True,
help="Detect source boxes; fail unless their count matches verified lines.",
)
@click.option(
"--restoration",
type=click.Choice(("vae-glyphs", "source-glyphs", "source-silhouette", "rerender")),
required=True,
help="Choose VAE glyph cores, regenerated pixels, fresh source shapes, or the system-font control.",
)
@click.option(
"--glyph-donor",
type=click.Path(exists=True, dir_okay=False, path_type=Path),
help="VAE reconstruction used only by --restoration vae-glyphs.",
)
@click.option(
"--glyph-feather",
type=click.FloatRange(min=0.0),
default=0.5,
show_default=True,
help="Outer donor-edge feather used only by --restoration vae-glyphs.",
)
@click.option(
"--selection",
type=click.Choice(("all", "changed")),
default="all",
show_default=True,
help="Restore every verified line or only OCR-confirmed changes.",
)
@click.option(
"--erase-background/--keep-background",
default=True,
show_default=True,
help="Erase candidate glyphs before compositing, or directly blend an aligned regenerated glyph layer.",
)
@click.option(
"--composite-mask",
type=click.Choice(("glyphs", "boxes")),
default="glyphs",
show_default=True,
help="Composite isolated glyphs or complete aligned text-line boxes.",
)
@click.option("--manifest", type=click.Path(dir_okay=False, path_type=Path))
def main(
source: Path,
candidate: Path,
output: Path,
lines_json: Path,
source_key: str | None,
detect_boxes: bool,
restoration: str,
glyph_donor: Path | None,
glyph_feather: float,
selection: str,
erase_background: bool,
composite_mask: str,
manifest: Path | None,
) -> None:
"""Restore SOURCE text over the scrubbed CANDIDATE."""
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
if restoration == "rerender":
for font in (REGULAR_FONT, BOLD_FONT, CJK_FONT):
if not font.exists():
raise click.ClickException(f"required evaluation font is unavailable: {font}")
if restoration == "vae-glyphs" and glyph_donor is None:
raise click.ClickException("--glyph-donor is required for --restoration vae-glyphs")
if restoration != "vae-glyphs" and glyph_donor is not None:
raise click.ClickException("--glyph-donor is only valid with --restoration vae-glyphs")
os.environ["PADDLE_PDX_DISABLE_MODEL_SOURCE_CHECK"] = "True"
source_rgb = np.asarray(Image.open(source).convert("RGB"))
candidate_rgb = np.asarray(Image.open(candidate).convert("RGB"))
if candidate_rgb.shape != source_rgb.shape:
raise click.ClickException("source and candidate dimensions must match")
donor_rgb = np.asarray(Image.open(glyph_donor).convert("RGB")) if glyph_donor else None
if donor_rgb is not None and donor_rgb.shape != source_rgb.shape:
raise click.ClickException("source and glyph donor dimensions must match")
lines = _load_lines(lines_json, source_key or source.name)
annotation_boxes = [line.box for line in lines]
if detect_boxes:
from paddleocr import PaddleOCR
page_engine = PaddleOCR(
lang="ch",
use_doc_orientation_classify=False,
use_doc_unwarping=False,
use_textline_orientation=False,
)
boxes = detect_line_boxes(page_engine, source_rgb, len(lines))
lines = [TextLine(box, line.text, line.script) for line, box in zip(lines, boxes, strict=True)]
vertical_pad_ratio = 0.1 if detect_boxes else None
decisions = []
selected = list(lines)
if selection == "all":
decisions = [{"line": asdict(line), "selected": True, "reason": "all-lines"} for line in lines]
else:
from paddleocr import TextRecognition
engines = {
"en": TextRecognition(model_name="en_PP-OCRv5_mobile_rec"),
"ru": TextRecognition(model_name="eslav_PP-OCRv5_mobile_rec"),
"ch": TextRecognition(model_name="PP-OCRv5_server_rec"),
}
selected = []
for line in lines:
language = _line_language(line)
source_text, source_score = _recognize(engines[language], source_rgb, line, vertical_pad_ratio)
candidate_text, candidate_score = _recognize(engines[language], candidate_rgb, line, vertical_pad_ratio)
preserve = should_preserve_line(line.text, source_text, source_score, candidate_text, candidate_score)
decisions.append(
{
"line": asdict(line),
"source_text": source_text,
"source_score": source_score,
"candidate_text": candidate_text,
"candidate_score": candidate_score,
"preserve": preserve,
"selected": not preserve,
}
)
if not preserve:
selected.append(line)
output.parent.mkdir(parents=True, exist_ok=True)
mask_path = output.with_name(output.stem + "_mask.png")
manifest_common = {
"source": source.name,
"candidate": candidate.name,
"output": output.name,
"mask": mask_path.name,
"glyph_donor": glyph_donor.name if glyph_donor else None,
"glyph_feather": glyph_feather if restoration == "vae-glyphs" else None,
"restoration": restoration,
"selection": selection,
"erase_background": erase_background,
"composite_mask": composite_mask,
"box_source": "detector" if detect_boxes else "verified_annotations",
"annotation_boxes": annotation_boxes,
"decisions": decisions,
}
if not selected:
combined = np.zeros(source_rgb.shape[:2], dtype=np.uint8)
shutil.copyfile(candidate, output)
Image.fromarray(combined).save(mask_path)
payload = {
**manifest_common,
"mask_fraction": 0.0,
"source_glyph_fraction": 0.0,
"source_layer_fraction": 0.0,
}
_write_manifest(manifest, payload)
log.info("Copied %s unchanged because every line passed", output)
return
if restoration in {"source-silhouette", "vae-glyphs"}:
source_masks = [source_silhouette_mask(source_rgb, line.box, line.angle) for line in selected]
candidate_masks = [source_silhouette_mask(candidate_rgb, line.box, line.angle) for line in selected]
line_masks = []
for line, source_mask, candidate_mask in zip(selected, 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)
line_masks.append(cv2.dilate(np.maximum(source_mask, candidate_mask), kernel))
masks = line_masks
else:
source_masks = [foreground_mask(source_rgb, line.box) for line in selected]
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)
if erase_background:
background = cv2.cvtColor(candidate_rgb, cv2.COLOR_RGB2BGR)
for group in groups:
background = region_eraser.erase_lama(
background,
np.maximum.reduce([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(selected, 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)
background_rgb = cv2.cvtColor(background, cv2.COLOR_BGR2RGB)
else:
background_rgb = candidate_rgb
residual_masks = []
source_glyph_mask = np.maximum.reduce(source_masks)
source_layer_mask = source_glyph_mask
if restoration == "source-glyphs":
source_layer_mask = (
source_box_mask(source_rgb.shape[:2], [line.box for line in selected])
if composite_mask == "boxes"
else source_glyph_mask
)
restored = composite_source_glyphs(source_rgb, background_rgb, source_layer_mask, feather=3.0)
Image.fromarray(restored).save(output)
elif restoration in {"source-silhouette", "vae-glyphs"}:
restored = composite_fresh_text_edges(source_rgb, background_rgb, selected, source_masks)
if restoration == "vae-glyphs":
if donor_rgb is None:
raise RuntimeError("VAE glyph restoration requires a loaded donor")
restored = composite_reconstructed_glyphs(
donor_rgb,
restored,
source_layer_mask,
feather=glyph_feather,
)
Image.fromarray(restored).save(output)
else:
rendered = Image.fromarray(background_rgb)
for line, source_mask in zip(selected, source_masks, strict=True):
_render_line(rendered, line, _sample_text_color(source_rgb, source_mask, line.box))
rendered.save(output)
combined = np.maximum.reduce([*masks, *residual_masks])
if restoration == "source-glyphs" and not erase_background:
combined = source_layer_mask
Image.fromarray(combined).save(mask_path)
payload = {
**manifest_common,
"mask_fraction": float((combined > 0).mean()),
"source_glyph_fraction": float((source_glyph_mask > 0).mean()),
"source_layer_fraction": float((source_layer_mask > 0).mean()),
}
_write_manifest(manifest, payload)
log.info("Wrote %s with %.4f edited fraction", output, payload["mask_fraction"])
if __name__ == "__main__":
main()