From 629086e53c376e3e5592ab014baa45eb74a326bb Mon Sep 17 00:00:00 2001 From: CrbnsCat10n Date: Wed, 13 May 2026 16:57:16 +0800 Subject: [PATCH] CommonZhuan Preprocessing Scripts 1.0 --- PreProcessing_Common/.gitignore | 9 + PreProcessing_Common/README.md | 153 +++++++++ .../scripts/clean_characters.py | 299 ++++++++++++++++++ .../scripts/make_clean_comparisons.py | 164 ++++++++++ .../scripts/run_preprocessing.py | 143 +++++++++ .../scripts/vectorize_characters.py | 213 +++++++++++++ PreProcessing_MatrixZhuan/.gitignore | 1 + PreProcessing_MatrixZhuan/README.md | 15 +- .../scripts/run_preprocessing.py | 18 ++ 9 files changed, 1009 insertions(+), 6 deletions(-) create mode 100644 PreProcessing_Common/.gitignore create mode 100644 PreProcessing_Common/README.md create mode 100644 PreProcessing_Common/scripts/clean_characters.py create mode 100644 PreProcessing_Common/scripts/make_clean_comparisons.py create mode 100644 PreProcessing_Common/scripts/run_preprocessing.py create mode 100644 PreProcessing_Common/scripts/vectorize_characters.py diff --git a/PreProcessing_Common/.gitignore b/PreProcessing_Common/.gitignore new file mode 100644 index 0000000..0bd488e --- /dev/null +++ b/PreProcessing_Common/.gitignore @@ -0,0 +1,9 @@ +original_characters/ +cleaned_characters/ +smoothed_characters/ +processed_characters/ +comparisons/ +svg_characters/ +vector_characters/ +scripts/__pycache__/ +*.pyc diff --git a/PreProcessing_Common/README.md b/PreProcessing_Common/README.md new file mode 100644 index 0000000..4413f70 --- /dev/null +++ b/PreProcessing_Common/README.md @@ -0,0 +1,153 @@ +# PreProcessing_Common + +这个目录用于处理复杂度更高、艺术性更强的篆文字图像。当前提供轻量 clean、平滑和 SVG 矢量化三部分能力。 + +## 一键处理 + +推荐使用统一入口脚本,从原图直接输出 clean+smoothed PNG 和 SVG: + +```bash +.venv/bin/python PreProcessing_Common/scripts/run_preprocessing.py \ + --input-dir PreProcessing_Common/original_characters \ + --image-output-dir PreProcessing_Common/processed_characters \ + --svg-output-dir PreProcessing_Common/vector_characters +``` + +输出: + +- `--image-output-dir`:`原文件名_cleaned_smoothed.png` +- `--svg-output-dir`:`原文件名.svg` + +脚本内部会先做轻量 clean,再做平滑和矢量化。笔画密度高、线条复杂的字会自动使用更少平滑和更低轮廓简化比例,以保留更多细节和线条感。 + +可选参数: + +```bash +.venv/bin/python PreProcessing_Common/scripts/run_preprocessing.py \ + --input-dir PreProcessing_Common/original_characters \ + --image-output-dir PreProcessing_Common/processed_characters \ + --svg-output-dir PreProcessing_Common/vector_characters \ + --max-isolated-speck-area 32 \ + --max-hole-area 40 \ + --vector-smooth-iterations 1 \ + --epsilon-ratio 0.001 +``` + +`--epsilon-ratio` 越小,SVG 保留的轮廓点越多,细节越多,文件也越大。 + +## 分步 clean + +从项目根目录运行: + +```bash +.venv/bin/python PreProcessing_Common/scripts/clean_characters.py +``` + +默认输入: + +```text +PreProcessing_Common/original_characters +``` + +默认输出: + +```text +PreProcessing_Common/cleaned_characters +``` + +输出文件会保留原文件名,并追加 `_cleaned`: + +```text +篆文字_page_001_text_00.png -> 篆文字_page_001_text_00_cleaned.png +``` + +## 清理策略 + +这套脚本不会做矩阵化或强形态学重构,只做: + +- 去除极小黑色噪点 +- 去除远离主体笔画的孤立黑色污点 +- 填补笔画内部的小白色气泡 +- 去除靠边的细长扫描/裁切线 +- 一次轻量 3x3 边缘平滑 + +可选参数: + +```bash +.venv/bin/python PreProcessing_Common/scripts/clean_characters.py \ + --input-dir PreProcessing_Common/original_characters \ + --output-dir PreProcessing_Common/cleaned_characters \ + --min-component-area 8 \ + --max-isolated-speck-area 32 \ + --max-hole-area 40 +``` + +如果需要完全跳过边缘平滑,可以加 `--no-smooth`。 + +`--max-isolated-speck-area` 只影响孤立黑色污点清理;`--max-hole-area` 控制笔画内部白色气泡填补。 + +## 输出 clean 前后对比图 + +生成原图和 cleaned 图的左右并排对比: + +```bash +.venv/bin/python PreProcessing_Common/scripts/make_clean_comparisons.py +``` + +默认读取: + +```text +PreProcessing_Common/original_characters +PreProcessing_Common/cleaned_characters +``` + +默认输出: + +```text +PreProcessing_Common/comparisons +``` + +也可以指定目录: + +```bash +.venv/bin/python PreProcessing_Common/scripts/make_clean_comparisons.py \ + --original-dir PreProcessing_Common/original_characters \ + --cleaned-dir PreProcessing_Common/cleaned_characters \ + --output-dir PreProcessing_Common/comparisons +``` + +## 平滑和矢量化 + +对 cleaned 图进行轻度平滑,并输出 SVG: + +```bash +.venv/bin/python PreProcessing_Common/scripts/vectorize_characters.py +``` + +默认读取: + +```text +PreProcessing_Common/cleaned_characters +``` + +默认输出: + +```text +PreProcessing_Common/smoothed_characters +PreProcessing_Common/svg_characters +``` + +脚本会根据轮廓复杂度自动调整参数:笔画密度高、线条复杂的字会使用更少平滑和更低轮廓简化比例,以保留更多细节和线条感。 + +可选参数: + +```bash +.venv/bin/python PreProcessing_Common/scripts/vectorize_characters.py \ + --input-dir PreProcessing_Common/cleaned_characters \ + --smoothed-dir PreProcessing_Common/smoothed_characters \ + --svg-dir PreProcessing_Common/svg_characters \ + --smooth-iterations 1 \ + --epsilon-ratio 0.001 +``` + +`--epsilon-ratio` 越小,SVG 保留的轮廓点越多,细节越多,文件也越大。 diff --git a/PreProcessing_Common/scripts/clean_characters.py b/PreProcessing_Common/scripts/clean_characters.py new file mode 100644 index 0000000..d0e0a56 --- /dev/null +++ b/PreProcessing_Common/scripts/clean_characters.py @@ -0,0 +1,299 @@ +from __future__ import annotations + +import argparse +from pathlib import Path + +import cv2 +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: + threshold, _ = cv2.threshold( + gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU + ) + gray_min = int(gray.min()) + gray_max = int(gray.max()) + if threshold <= gray_min or threshold >= gray_max: + threshold = (gray_min + gray_max) / 2.0 + + border = np.concatenate([gray[0, :], gray[-1, :], gray[:, 0], gray[:, -1]]) + background_is_light = np.median(border) >= 128 + if background_is_light: + return (gray < threshold).astype(np.uint8) * 255 + return (gray > threshold).astype(np.uint8) * 255 + + +def foreground_bbox(mask: np.ndarray) -> tuple[int, int, int, int]: + ys, xs = np.where(mask > 0) + if xs.size == 0: + height, width = mask.shape + return 0, 0, width, height + return int(xs.min()), int(ys.min()), int(xs.max() + 1), int(ys.max() + 1) + + +def remove_tiny_components(mask: np.ndarray, min_area: int) -> np.ndarray: + if min_area <= 0: + return mask + + num_labels, labels, stats, _ = cv2.connectedComponentsWithStats( + mask, connectivity=8 + ) + cleaned = np.zeros_like(mask) + for label in range(1, num_labels): + area = int(stats[label, cv2.CC_STAT_AREA]) + if area >= min_area: + cleaned[labels == label] = 255 + return cleaned + + +def remove_isolated_specks( + mask: np.ndarray, + max_area: int, + support_radius: int, +) -> np.ndarray: + if max_area <= 0 or support_radius <= 0: + return mask + + num_labels, labels, stats, _ = cv2.connectedComponentsWithStats( + mask, connectivity=8 + ) + support = np.zeros_like(mask) + candidate_labels: list[int] = [] + + for label in range(1, num_labels): + area = int(stats[label, cv2.CC_STAT_AREA]) + if area <= max_area: + candidate_labels.append(label) + else: + support[labels == label] = 255 + + if not candidate_labels or not np.any(support): + return mask + + kernel_size = support_radius * 2 + 1 + kernel = cv2.getStructuringElement( + cv2.MORPH_ELLIPSE, (kernel_size, kernel_size) + ) + nearby_support = cv2.dilate(support, kernel) + cleaned = mask.copy() + + for label in candidate_labels: + component = labels == label + if not np.any(nearby_support[component]): + cleaned[component] = 0 + + return cleaned + + +def remove_border_hairlines(mask: np.ndarray, max_thickness: int = 8) -> np.ndarray: + num_labels, labels, stats, _ = cv2.connectedComponentsWithStats( + mask, connectivity=8 + ) + height, width = mask.shape + cleaned = mask.copy() + border_margin = max(4, int(round(min(width, height) * 0.02))) + + for label in range(1, num_labels): + x = int(stats[label, cv2.CC_STAT_LEFT]) + y = int(stats[label, cv2.CC_STAT_TOP]) + comp_width = int(stats[label, cv2.CC_STAT_WIDTH]) + comp_height = int(stats[label, cv2.CC_STAT_HEIGHT]) + near_border = ( + x <= border_margin + or y <= border_margin + or x + comp_width >= width - border_margin + or y + comp_height >= height - border_margin + ) + vertical_hairline = comp_width <= max_thickness and comp_height >= 24 + horizontal_hairline = comp_height <= max_thickness and comp_width >= 24 + if near_border and (vertical_hairline or horizontal_hairline): + cleaned[labels == label] = 0 + + return cleaned + + +def fill_small_holes(mask: np.ndarray, max_area: int) -> np.ndarray: + if max_area <= 0: + return mask + + foreground = mask > 0 + inverse = (~foreground).astype(np.uint8) + flood_mask = np.zeros((mask.shape[0] + 2, mask.shape[1] + 2), dtype=np.uint8) + cv2.floodFill(inverse, flood_mask, (0, 0), 2) + + holes = (inverse == 1).astype(np.uint8) + num_labels, labels, stats, _ = cv2.connectedComponentsWithStats( + holes, connectivity=8 + ) + + filled = foreground.copy() + for label in range(1, num_labels): + area = int(stats[label, cv2.CC_STAT_AREA]) + width = int(stats[label, cv2.CC_STAT_WIDTH]) + height = int(stats[label, cv2.CC_STAT_HEIGHT]) + if area <= max_area and max(width, height) <= max(3, int(max_area**0.5) * 4): + filled[labels == label] = True + + return filled.astype(np.uint8) * 255 + + +def smooth_edges_once(mask: np.ndarray) -> np.ndarray: + foreground = mask > 0 + kernel = np.ones((3, 3), dtype=np.uint8) + neighbors = cv2.filter2D( + foreground.astype(np.uint8), + ddepth=-1, + kernel=kernel, + borderType=cv2.BORDER_CONSTANT, + ) + smoothed = (foreground & (neighbors >= 3)) | (~foreground & (neighbors >= 7)) + return smoothed.astype(np.uint8) * 255 + + +def clean_mask( + mask: np.ndarray, + min_component_area: int, + max_hole_area: int, + max_isolated_speck_area: int, + smooth: bool = True, +) -> np.ndarray: + x1, y1, x2, y2 = foreground_bbox(mask) + support_radius = max(12, min(42, int(round(min(x2 - x1, y2 - y1) * 0.035)))) + cleaned = remove_tiny_components(mask, min_component_area) + cleaned = remove_isolated_specks( + cleaned, + max_area=max_isolated_speck_area, + support_radius=support_radius, + ) + cleaned = remove_border_hairlines(cleaned) + cleaned = fill_small_holes(cleaned, max_hole_area) + if smooth: + cleaned = smooth_edges_once(cleaned) + cleaned = remove_isolated_specks( + cleaned, + max_area=max_isolated_speck_area, + support_radius=support_radius, + ) + cleaned = remove_border_hairlines(cleaned) + cleaned = fill_small_holes(cleaned, max_hole_area) + return remove_tiny_components(cleaned, min_component_area) + + +def adaptive_clean_params(mask: np.ndarray) -> tuple[int, int, int]: + x1, y1, x2, y2 = foreground_bbox(mask) + bbox_area = max(1, (x2 - x1) * (y2 - y1)) + min_component_area = max(4, min(24, int(round(bbox_area * 0.000018)))) + max_hole_area = max(12, min(90, int(round(bbox_area * 0.00008)))) + max_isolated_speck_area = max( + min_component_area + 4, + min(48, int(round(bbox_area * 0.000055))), + ) + return min_component_area, max_hole_area, max_isolated_speck_area + + +def mask_to_display(mask: np.ndarray) -> np.ndarray: + return np.where(mask > 0, 0, 255).astype(np.uint8) + + +def process_image( + image_path: Path, + output_dir: Path, + min_component_area: int | None, + max_hole_area: int | None, + max_isolated_speck_area: int | None, + smooth: bool, +) -> None: + original = read_image(image_path) + gray = cv2.cvtColor(original, cv2.COLOR_BGR2GRAY) + binary = binarize_foreground(gray) + ( + adaptive_min_area, + adaptive_max_hole_area, + adaptive_max_isolated_speck_area, + ) = adaptive_clean_params(binary) + cleaned = clean_mask( + binary, + min_component_area=( + adaptive_min_area if min_component_area is None else min_component_area + ), + max_hole_area=( + adaptive_max_hole_area if max_hole_area is None else max_hole_area + ), + max_isolated_speck_area=( + adaptive_max_isolated_speck_area + if max_isolated_speck_area is None + else max_isolated_speck_area + ), + smooth=smooth, + ) + write_image(output_dir / f"{image_path.stem}_cleaned.png", mask_to_display(cleaned)) + + +def iter_images(input_dir: Path) -> list[Path]: + return sorted( + path + for path in input_dir.iterdir() + if path.is_file() and path.suffix.lower() in IMAGE_SUFFIXES + ) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Gently clean complex seal-script characters." + ) + parser.add_argument( + "--input-dir", + type=Path, + default=Path("PreProcessing_Common/original_characters"), + ) + parser.add_argument( + "--output-dir", + type=Path, + default=Path("PreProcessing_Common/cleaned_characters"), + ) + parser.add_argument("--min-component-area", type=int, default=None) + parser.add_argument("--max-hole-area", type=int, default=None) + parser.add_argument("--max-isolated-speck-area", type=int, default=None) + parser.add_argument("--no-smooth", action="store_true") + return parser + + +def main() -> None: + args = build_parser().parse_args() + args.output_dir.mkdir(parents=True, exist_ok=True) + image_paths = iter_images(args.input_dir) + if not image_paths: + raise SystemExit(f"No images found in {args.input_dir}") + + for image_path in image_paths: + process_image( + image_path=image_path, + output_dir=args.output_dir, + min_component_area=args.min_component_area, + max_hole_area=args.max_hole_area, + max_isolated_speck_area=args.max_isolated_speck_area, + smooth=not args.no_smooth, + ) + print(f"processed: {image_path.name}") + + +if __name__ == "__main__": + main() diff --git a/PreProcessing_Common/scripts/make_clean_comparisons.py b/PreProcessing_Common/scripts/make_clean_comparisons.py new file mode 100644 index 0000000..855a569 --- /dev/null +++ b/PreProcessing_Common/scripts/make_clean_comparisons.py @@ -0,0 +1,164 @@ +from __future__ import annotations + +import argparse +from pathlib import Path + +import cv2 +import numpy as np + + +IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".tif", ".tiff", ".bmp", ".webp"} + + +def read_gray(path: Path) -> np.ndarray: + raw = np.fromfile(str(path), dtype=np.uint8) + image = cv2.imdecode(raw, cv2.IMREAD_GRAYSCALE) + 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 fit_on_canvas(image: np.ndarray, width: int, height: int) -> np.ndarray: + scale = min(width / image.shape[1], height / image.shape[0]) + resized_width = max(1, int(round(image.shape[1] * scale))) + resized_height = max(1, int(round(image.shape[0] * scale))) + resized = cv2.resize( + image, + (resized_width, resized_height), + interpolation=cv2.INTER_AREA, + ) + canvas = np.full((height, width), 255, dtype=np.uint8) + x = (width - resized_width) // 2 + y = (height - resized_height) // 2 + canvas[y : y + resized_height, x : x + resized_width] = resized + return canvas + + +def make_comparison( + original_path: Path, + cleaned_path: Path, + output_path: Path, + panel_size: int, + label_height: int, +) -> None: + original = read_gray(original_path) + cleaned = read_gray(cleaned_path) + panel_width = panel_size + panel_height = panel_size + + original_panel = fit_on_canvas(original, panel_width, panel_height) + cleaned_panel = fit_on_canvas(cleaned, panel_width, panel_height) + divider = np.full((panel_height + label_height, 2), 210, dtype=np.uint8) + comparison = np.full( + (panel_height + label_height, panel_width * 2 + divider.shape[1]), + 255, + dtype=np.uint8, + ) + comparison[:panel_height, :panel_width] = original_panel + comparison[:panel_height, panel_width + divider.shape[1] :] = cleaned_panel + comparison[:, panel_width : panel_width + divider.shape[1]] = divider + + baseline = panel_height + 24 + cv2.putText( + comparison, + "original", + (12, baseline), + cv2.FONT_HERSHEY_SIMPLEX, + 0.7, + 0, + 2, + cv2.LINE_AA, + ) + cv2.putText( + comparison, + "cleaned", + (panel_width + divider.shape[1] + 12, baseline), + cv2.FONT_HERSHEY_SIMPLEX, + 0.7, + 0, + 2, + cv2.LINE_AA, + ) + cv2.putText( + comparison, + original_path.stem, + (12, panel_height + label_height - 12), + cv2.FONT_HERSHEY_SIMPLEX, + 0.45, + 80, + 1, + cv2.LINE_AA, + ) + write_image(output_path, comparison) + + +def iter_originals(input_dir: Path) -> list[Path]: + return sorted( + path + for path in input_dir.iterdir() + if path.is_file() and path.suffix.lower() in IMAGE_SUFFIXES + ) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Create side-by-side original/cleaned comparison images." + ) + parser.add_argument( + "--original-dir", + type=Path, + default=Path("PreProcessing_Common/original_characters"), + ) + parser.add_argument( + "--cleaned-dir", + type=Path, + default=Path("PreProcessing_Common/cleaned_characters"), + ) + parser.add_argument( + "--output-dir", + type=Path, + default=Path("PreProcessing_Common/comparisons"), + ) + parser.add_argument("--panel-size", type=int, default=520) + parser.add_argument("--label-height", type=int, default=72) + return parser + + +def main() -> None: + args = build_parser().parse_args() + args.output_dir.mkdir(parents=True, exist_ok=True) + + originals = iter_originals(args.original_dir) + if not originals: + raise SystemExit(f"No images found in {args.original_dir}") + + count = 0 + for original_path in originals: + cleaned_path = args.cleaned_dir / f"{original_path.stem}_cleaned.png" + if not cleaned_path.exists(): + print(f"missing cleaned image: {cleaned_path}") + continue + + make_comparison( + original_path=original_path, + cleaned_path=cleaned_path, + output_path=args.output_dir / f"{original_path.stem}_comparison.png", + panel_size=args.panel_size, + label_height=args.label_height, + ) + print(f"comparison: {original_path.name}") + count += 1 + + if count == 0: + raise SystemExit("No comparisons were written.") + + +if __name__ == "__main__": + main() diff --git a/PreProcessing_Common/scripts/run_preprocessing.py b/PreProcessing_Common/scripts/run_preprocessing.py new file mode 100644 index 0000000..e32143d --- /dev/null +++ b/PreProcessing_Common/scripts/run_preprocessing.py @@ -0,0 +1,143 @@ +from __future__ import annotations + +import argparse +from pathlib import Path + +import cv2 + +import clean_characters +import vectorize_characters + + +def process_image( + image_path: Path, + image_output_dir: Path, + svg_output_dir: Path, + min_component_area: int | None, + max_hole_area: int | None, + max_isolated_speck_area: int | None, + clean_smooth: bool, + vector_smooth_iterations: int | None, + epsilon_ratio: float | None, +) -> None: + original = clean_characters.read_image(image_path) + gray = cv2.cvtColor(original, cv2.COLOR_BGR2GRAY) + binary = clean_characters.binarize_foreground(gray) + ( + adaptive_min_area, + adaptive_max_hole_area, + adaptive_max_isolated_speck_area, + ) = clean_characters.adaptive_clean_params(binary) + + cleaned = clean_characters.clean_mask( + binary, + min_component_area=( + adaptive_min_area if min_component_area is None else min_component_area + ), + max_hole_area=( + adaptive_max_hole_area if max_hole_area is None else max_hole_area + ), + max_isolated_speck_area=( + adaptive_max_isolated_speck_area + if max_isolated_speck_area is None + else max_isolated_speck_area + ), + smooth=clean_smooth, + ) + + auto_smooth_iterations, auto_epsilon_ratio = vectorize_characters.vector_params( + cleaned + ) + smoothed = vectorize_characters.smooth_mask( + cleaned, + iterations=( + auto_smooth_iterations + if vector_smooth_iterations is None + else vector_smooth_iterations + ), + ) + ratio = auto_epsilon_ratio if epsilon_ratio is None else epsilon_ratio + + stem = image_path.stem + clean_characters.write_image( + image_output_dir / f"{stem}_cleaned_smoothed.png", + clean_characters.mask_to_display(smoothed), + ) + svg = vectorize_characters.mask_to_svg( + smoothed, + title=stem, + epsilon_ratio=ratio, + ) + (svg_output_dir / f"{stem}.svg").write_text(svg, encoding="utf-8") + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Clean, smooth, and vectorize complex seal-script characters." + ) + parser.add_argument( + "--input-dir", + type=Path, + default=Path("PreProcessing_Common/original_characters"), + ) + parser.add_argument( + "--image-output-dir", + type=Path, + default=Path("PreProcessing_Common/processed_characters"), + help="Output folder for clean+smoothed PNG files.", + ) + parser.add_argument( + "--svg-output-dir", + type=Path, + default=Path("PreProcessing_Common/vector_characters"), + help="Output folder for SVG vector files.", + ) + parser.add_argument("--min-component-area", type=int, default=None) + parser.add_argument("--max-hole-area", type=int, default=None) + parser.add_argument("--max-isolated-speck-area", type=int, default=None) + parser.add_argument( + "--no-clean-smooth", + action="store_true", + help="Skip the light 3x3 smoothing inside the clean stage.", + ) + parser.add_argument( + "--vector-smooth-iterations", + type=int, + default=None, + help="Override automatic vector smoothing. Dense characters default to less smoothing.", + ) + parser.add_argument( + "--epsilon-ratio", + type=float, + default=None, + help="Override contour simplification ratio. Smaller values keep more detail.", + ) + return parser + + +def main() -> None: + args = build_parser().parse_args() + args.image_output_dir.mkdir(parents=True, exist_ok=True) + args.svg_output_dir.mkdir(parents=True, exist_ok=True) + + image_paths = clean_characters.iter_images(args.input_dir) + if not image_paths: + raise SystemExit(f"No images found in {args.input_dir}") + + for image_path in image_paths: + process_image( + image_path=image_path, + image_output_dir=args.image_output_dir, + svg_output_dir=args.svg_output_dir, + min_component_area=args.min_component_area, + max_hole_area=args.max_hole_area, + max_isolated_speck_area=args.max_isolated_speck_area, + clean_smooth=not args.no_clean_smooth, + vector_smooth_iterations=args.vector_smooth_iterations, + epsilon_ratio=args.epsilon_ratio, + ) + print(f"processed: {image_path.name}") + + +if __name__ == "__main__": + main() diff --git a/PreProcessing_Common/scripts/vectorize_characters.py b/PreProcessing_Common/scripts/vectorize_characters.py new file mode 100644 index 0000000..80265cb --- /dev/null +++ b/PreProcessing_Common/scripts/vectorize_characters.py @@ -0,0 +1,213 @@ +from __future__ import annotations + +import argparse +import html +from pathlib import Path + +import cv2 +import numpy as np + + +IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".tif", ".tiff", ".bmp", ".webp"} + + +def read_gray(path: Path) -> np.ndarray: + raw = np.fromfile(str(path), dtype=np.uint8) + image = cv2.imdecode(raw, cv2.IMREAD_GRAYSCALE) + 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 display_to_mask(gray: np.ndarray) -> np.ndarray: + return (gray < 128).astype(np.uint8) * 255 + + +def mask_to_display(mask: np.ndarray) -> np.ndarray: + return np.where(mask > 0, 0, 255).astype(np.uint8) + + +def foreground_bbox(mask: np.ndarray) -> tuple[int, int, int, int]: + ys, xs = np.where(mask > 0) + if xs.size == 0: + height, width = mask.shape + return 0, 0, width, height + return int(xs.min()), int(ys.min()), int(xs.max() + 1), int(ys.max() + 1) + + +def contour_complexity(mask: np.ndarray) -> float: + x1, y1, x2, y2 = foreground_bbox(mask) + bbox_area = max(1, (x2 - x1) * (y2 - y1)) + contours, _ = cv2.findContours(mask, cv2.RETR_LIST, cv2.CHAIN_APPROX_NONE) + perimeter = sum(cv2.arcLength(contour, True) for contour in contours) + return float(perimeter / np.sqrt(bbox_area)) + + +def vector_params(mask: np.ndarray) -> tuple[int, float]: + complexity = contour_complexity(mask) + if complexity >= 28.0: + return 1, 0.0009 + if complexity >= 18.0: + return 1, 0.0014 + return 2, 0.0022 + + +def smooth_mask(mask: np.ndarray, iterations: int) -> np.ndarray: + smoothed = mask.copy() + kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3)) + for _ in range(iterations): + blurred = cv2.GaussianBlur(smoothed, (3, 3), 0) + smoothed = np.where(blurred >= 128, 255, 0).astype(np.uint8) + smoothed = cv2.morphologyEx(smoothed, cv2.MORPH_CLOSE, kernel) + return smoothed + + +def points_to_smooth_path(points: np.ndarray) -> str: + if len(points) < 3: + return "" + + midpoints = (points + np.roll(points, -1, axis=0)) * 0.5 + commands = [f"M {midpoints[-1, 0]:.2f} {midpoints[-1, 1]:.2f}"] + for point, midpoint in zip(points, midpoints): + commands.append( + f"Q {point[0]:.2f} {point[1]:.2f} {midpoint[0]:.2f} {midpoint[1]:.2f}" + ) + commands.append("Z") + return " ".join(commands) + + +def contour_to_path(contour: np.ndarray, epsilon_ratio: float) -> str: + perimeter = cv2.arcLength(contour, True) + epsilon = max(0.35, perimeter * epsilon_ratio) + approx = cv2.approxPolyDP(contour, epsilon, True).reshape(-1, 2) + if len(approx) < 3: + return "" + + points = approx.astype(np.float64) + return points_to_smooth_path(points) + + +def mask_to_svg(mask: np.ndarray, title: str, epsilon_ratio: float) -> str: + height, width = mask.shape + contours, _ = cv2.findContours(mask, cv2.RETR_TREE, cv2.CHAIN_APPROX_NONE) + contours = sorted(contours, key=cv2.contourArea, reverse=True) + x1, y1, x2, y2 = foreground_bbox(mask) + bbox_area = max(1, (x2 - x1) * (y2 - y1)) + min_area = max(2.0, bbox_area * 0.000008) + + paths = [] + for contour in contours: + if abs(cv2.contourArea(contour)) < min_area: + continue + path = contour_to_path(contour, epsilon_ratio=epsilon_ratio) + if path: + paths.append(path) + + escaped_title = html.escape(title) + path_data = " ".join(paths) + return "\n".join( + [ + '', + f'', + f" {escaped_title}", + ' ', + f' ', + "", + "", + ] + ) + + +def iter_images(input_dir: Path) -> list[Path]: + return sorted( + path + for path in input_dir.iterdir() + if path.is_file() and path.suffix.lower() in IMAGE_SUFFIXES + ) + + +def process_image( + image_path: Path, + smoothed_dir: Path, + svg_dir: Path, + smooth_iterations: int | None, + epsilon_ratio: float | None, +) -> None: + gray = read_gray(image_path) + mask = display_to_mask(gray) + auto_smooth_iterations, auto_epsilon_ratio = vector_params(mask) + iterations = ( + auto_smooth_iterations if smooth_iterations is None else smooth_iterations + ) + ratio = auto_epsilon_ratio if epsilon_ratio is None else epsilon_ratio + + smoothed = smooth_mask(mask, iterations=iterations) + stem = image_path.stem.removesuffix("_cleaned") + write_image(smoothed_dir / f"{stem}_smoothed.png", mask_to_display(smoothed)) + svg = mask_to_svg(smoothed, title=stem, epsilon_ratio=ratio) + (svg_dir / f"{stem}.svg").write_text(svg, encoding="utf-8") + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Smooth and vectorize cleaned complex seal-script characters." + ) + parser.add_argument( + "--input-dir", + type=Path, + default=Path("PreProcessing_Common/cleaned_characters"), + ) + parser.add_argument( + "--smoothed-dir", + type=Path, + default=Path("PreProcessing_Common/smoothed_characters"), + ) + parser.add_argument( + "--svg-dir", + type=Path, + default=Path("PreProcessing_Common/svg_characters"), + ) + parser.add_argument( + "--smooth-iterations", + type=int, + default=None, + help="Override automatic smoothing. Dense characters default to less smoothing.", + ) + parser.add_argument( + "--epsilon-ratio", + type=float, + default=None, + help="Override contour simplification ratio. Smaller values keep more detail.", + ) + return parser + + +def main() -> None: + args = build_parser().parse_args() + args.smoothed_dir.mkdir(parents=True, exist_ok=True) + args.svg_dir.mkdir(parents=True, exist_ok=True) + image_paths = iter_images(args.input_dir) + if not image_paths: + raise SystemExit(f"No images found in {args.input_dir}") + + for image_path in image_paths: + process_image( + image_path=image_path, + smoothed_dir=args.smoothed_dir, + svg_dir=args.svg_dir, + smooth_iterations=args.smooth_iterations, + epsilon_ratio=args.epsilon_ratio, + ) + print(f"vectorized: {image_path.name}") + + +if __name__ == "__main__": + main() diff --git a/PreProcessing_MatrixZhuan/.gitignore b/PreProcessing_MatrixZhuan/.gitignore index ce78dca..f308bcc 100644 --- a/PreProcessing_MatrixZhuan/.gitignore +++ b/PreProcessing_MatrixZhuan/.gitignore @@ -7,6 +7,7 @@ processed_characters/ svg_characters/ out_cleaned/ out_matrix/ +out_svg/ # Local caches .matplotlib-cache/ diff --git a/PreProcessing_MatrixZhuan/README.md b/PreProcessing_MatrixZhuan/README.md index cf117dc..530ad25 100644 --- a/PreProcessing_MatrixZhuan/README.md +++ b/PreProcessing_MatrixZhuan/README.md @@ -7,16 +7,17 @@ 依赖见 `scripts/requirements.txt`。如果环境里还没有安装,可以先运行: ```bash -.venv/bin/pip install -r PreProcessing/scripts/requirements.txt +.venv/bin/pip install -r PreProcessing_MatrixZhuan/scripts/requirements.txt ``` 从项目根目录运行: ```bash -.venv/bin/python PreProcessing/scripts/run_preprocessing.py \ - --input-dir PreProcessing/original_characters \ - --cleaned-dir PreProcessing/out_cleaned \ - --matrix-dir PreProcessing/out_matrix +.venv/bin/python PreProcessing_MatrixZhuan/scripts/run_preprocessing.py \ + --input-dir PreProcessing_MatrixZhuan/original_characters \ + --cleaned-dir PreProcessing_MatrixZhuan/out_cleaned \ + --matrix-dir PreProcessing_MatrixZhuan/out_matrix \ + --svg-dir PreProcessing_MatrixZhuan/out_svg ``` 参数含义: @@ -24,11 +25,13 @@ - `--input-dir`:原始图像输入文件夹 - `--cleaned-dir`:cleaned 二值图输出文件夹 - `--matrix-dir`:17x17 矩阵文本输出文件夹 +- `--svg-dir`:矢量图 SVG 输出文件夹 输出命名会和输入文件名对齐: - cleaned 图像:`原文件名_cleaned.png` - 矩阵文本:`原文件名_matrix.txt` +- 矢量图:`原文件名.svg` 矩阵文本是纯 17 行,每行 17 个字符,只包含 `0` 和 `1`。 @@ -46,4 +49,4 @@ ## 目录说明 -`comparisons/`、`manual_diffs/`、`manual_matrices/`、`processed_characters/`、`svg_characters/` 等测试和比较用目录已经在 `PreProcessing/.gitignore` 中忽略。 +`comparisons/`、`manual_diffs/`、`manual_matrices/`、`processed_characters/`、`svg_characters/` 等测试和比较用目录已经在 `PreProcessing_MatrixZhuan/.gitignore` 中忽略。 diff --git a/PreProcessing_MatrixZhuan/scripts/run_preprocessing.py b/PreProcessing_MatrixZhuan/scripts/run_preprocessing.py index 0240e53..8ec80ce 100644 --- a/PreProcessing_MatrixZhuan/scripts/run_preprocessing.py +++ b/PreProcessing_MatrixZhuan/scripts/run_preprocessing.py @@ -13,6 +13,7 @@ from process_characters import ( matrix_map, mask_to_display, read_image, + small_matrix_to_svg, write_image, ) @@ -35,6 +36,7 @@ def process_image( image_path: Path, cleaned_dir: Path, matrix_dir: Path, + svg_dir: Path, grid_size: int = 17, cell_samples: int = 32, open_kernel_size: int = 1, @@ -67,12 +69,24 @@ def process_image( matrix_to_text(mapping.matrix), encoding="utf-8", ) + height, width = gray.shape + svg = small_matrix_to_svg( + mapping.matrix, + width, + height, + stem, + mapping.x_edges, + mapping.y_edges, + mapping.triangles, + ) + (svg_dir / f"{stem}.svg").write_text(svg, encoding="utf-8") def process_directory( input_dir: Path, cleaned_dir: Path, matrix_dir: Path, + svg_dir: Path, grid_size: int = 17, cell_samples: int = 32, open_kernel_size: int = 1, @@ -83,6 +97,7 @@ def process_directory( ) -> int: cleaned_dir.mkdir(parents=True, exist_ok=True) matrix_dir.mkdir(parents=True, exist_ok=True) + svg_dir.mkdir(parents=True, exist_ok=True) image_paths = iter_image_paths(input_dir) if not image_paths: @@ -93,6 +108,7 @@ def process_directory( image_path=image_path, cleaned_dir=cleaned_dir, matrix_dir=matrix_dir, + svg_dir=svg_dir, grid_size=grid_size, cell_samples=cell_samples, open_kernel_size=open_kernel_size, @@ -113,6 +129,7 @@ def build_parser() -> argparse.ArgumentParser: parser.add_argument("--input-dir", type=Path, required=True) parser.add_argument("--cleaned-dir", type=Path, required=True) parser.add_argument("--matrix-dir", type=Path, required=True) + parser.add_argument("--svg-dir", type=Path, required=True) parser.add_argument("--grid-size", type=int, default=17) parser.add_argument("--cell-samples", type=int, default=32) parser.add_argument("--open-kernel-size", type=int, default=1) @@ -129,6 +146,7 @@ def main() -> None: input_dir=args.input_dir, cleaned_dir=args.cleaned_dir, matrix_dir=args.matrix_dir, + svg_dir=args.svg_dir, grid_size=args.grid_size, cell_samples=args.cell_samples, open_kernel_size=args.open_kernel_size,