mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-08-19 20:17:12 +02:00
529 lines
22 KiB
Python
529 lines
22 KiB
Python
# /// 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
|
|
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 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:
|
|
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")
|
|
|
|
|
|
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 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."""
|
|
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 = 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(
|
|
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 _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 = group_text_lines(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()
|