# /// 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()