from __future__ import annotations import argparse import html import os from pathlib import Path import cv2 os.environ.setdefault( "MPLCONFIGDIR", str(Path(__file__).resolve().parents[1] / ".matplotlib-cache"), ) import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt import numpy as np IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".tif", ".tiff", ".bmp", ".webp"} def read_image(path: Path) -> np.ndarray: raw = np.fromfile(str(path), dtype=np.uint8) image = cv2.imdecode(raw, cv2.IMREAD_COLOR) if image is None: raise ValueError(f"Cannot read image: {path}") return image def write_image(path: Path, image: np.ndarray) -> None: ok, encoded = cv2.imencode(path.suffix, image) if not ok: raise ValueError(f"Cannot encode image: {path}") encoded.tofile(str(path)) def binarize_foreground(gray: np.ndarray) -> np.ndarray: blurred = cv2.GaussianBlur(gray, (3, 3), 0) threshold, _ = cv2.threshold( blurred, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU ) border = np.concatenate( [ gray[0, :], gray[-1, :], gray[:, 0], gray[:, -1], ] ) background_is_light = np.median(border) >= 128 if background_is_light: foreground = (gray < threshold).astype(np.uint8) * 255 else: foreground = (gray > threshold).astype(np.uint8) * 255 return foreground def clean_mask( mask: np.ndarray, open_kernel_size: int, close_kernel_size: int, median_size: int, ) -> np.ndarray: if median_size > 1: if median_size % 2 == 0: median_size += 1 mask = cv2.medianBlur(mask, median_size) open_kernel = cv2.getStructuringElement( cv2.MORPH_RECT, (open_kernel_size, open_kernel_size) ) opened = cv2.morphologyEx(mask, cv2.MORPH_OPEN, open_kernel) close_kernel = cv2.getStructuringElement( cv2.MORPH_RECT, (close_kernel_size, close_kernel_size) ) return cv2.morphologyEx(opened, cv2.MORPH_CLOSE, close_kernel) def pixelate(mask: np.ndarray, factor: int) -> tuple[np.ndarray, np.ndarray]: height, width = mask.shape small_width = max(1, round(width / factor)) small_height = max(1, round(height / factor)) sampled = cv2.resize( mask, (small_width, small_height), interpolation=cv2.INTER_AREA ) small = np.where(sampled >= 96, 255, 0).astype(np.uint8) large = cv2.resize(small, (width, height), interpolation=cv2.INTER_NEAREST) return small, large def mask_to_display(mask: np.ndarray) -> np.ndarray: return np.where(mask > 0, 0, 255).astype(np.uint8) def save_comparison( path: Path, original_bgr: np.ndarray, cleaned_mask: np.ndarray, pixelated_mask: np.ndarray, ) -> None: original_rgb = cv2.cvtColor(original_bgr, cv2.COLOR_BGR2RGB) cleaned = mask_to_display(cleaned_mask) pixelated = mask_to_display(pixelated_mask) fig, axes = plt.subplots(1, 3, figsize=(12, 4), constrained_layout=True) panels = [ ("Original", original_rgb, None), ("Cleaned binary", cleaned, "gray"), ("Pixelated", pixelated, "gray"), ] for axis, (title, image, cmap) in zip(axes, panels): axis.imshow(image, cmap=cmap) axis.set_title(title) axis.axis("off") fig.savefig(path, dpi=180) plt.close(fig) def small_matrix_to_svg( small_mask: np.ndarray, original_width: int, original_height: int, title: str, ) -> str: rows, cols = small_mask.shape cell_width = original_width / cols cell_height = original_height / rows escaped_title = html.escape(title) parts = [ '', ( f'' ), f" {escaped_title}", ' ', ' ', ] for row_index in range(rows): col_index = 0 while col_index < cols: if small_mask[row_index, col_index] == 0: col_index += 1 continue run_start = col_index while col_index < cols and small_mask[row_index, col_index] > 0: col_index += 1 x = run_start * cell_width y = row_index * cell_height width = (col_index - run_start) * cell_width parts.append( f' ' ) parts.extend([" ", "", ""]) return "\n".join(parts) def process_image( image_path: Path, processed_dir: Path, comparison_dir: Path, svg_dir: Path, factor: int, open_kernel_size: int, close_kernel_size: int, median_size: int, ) -> None: original = read_image(image_path) gray = cv2.cvtColor(original, cv2.COLOR_BGR2GRAY) binary = binarize_foreground(gray) cleaned = clean_mask( binary, open_kernel_size=open_kernel_size, close_kernel_size=close_kernel_size, median_size=median_size, ) small, pixelated = pixelate(cleaned, factor=factor) stem = image_path.stem write_image(processed_dir / f"{stem}_cleaned.png", mask_to_display(cleaned)) write_image(processed_dir / f"{stem}_pixelated.png", mask_to_display(pixelated)) save_comparison( comparison_dir / f"{stem}_comparison.png", original, cleaned, pixelated ) height, width = gray.shape svg = small_matrix_to_svg(small, width, height, stem) (svg_dir / f"{stem}_pixelated.svg").write_text(svg, encoding="utf-8") def main() -> None: parser = argparse.ArgumentParser( description="Clean, pixelate, and vectorize scanned seal-script characters." ) parser.add_argument( "--input-dir", type=Path, default=Path("PreProcessing/original_characters"), ) parser.add_argument( "--processed-dir", type=Path, default=Path("PreProcessing/processed_characters"), ) parser.add_argument( "--comparison-dir", type=Path, default=Path("PreProcessing/comparisons"), ) parser.add_argument( "--svg-dir", type=Path, default=Path("PreProcessing/svg_characters"), ) parser.add_argument("--factor", type=int, default=15) parser.add_argument("--open-kernel-size", type=int, default=2) parser.add_argument("--close-kernel-size", type=int, default=3) parser.add_argument("--median-size", type=int, default=5) args = parser.parse_args() for directory in (args.processed_dir, args.comparison_dir, args.svg_dir): directory.mkdir(parents=True, exist_ok=True) images = sorted( path for path in args.input_dir.iterdir() if path.is_file() and path.suffix.lower() in IMAGE_SUFFIXES ) if not images: raise SystemExit(f"No images found in {args.input_dir}") for image_path in images: process_image( image_path=image_path, processed_dir=args.processed_dir, comparison_dir=args.comparison_dir, svg_dir=args.svg_dir, factor=args.factor, open_kernel_size=args.open_kernel_size, close_kernel_size=args.close_kernel_size, median_size=args.median_size, ) print(f"processed: {image_path.name}") if __name__ == "__main__": main()