diff --git a/PreProcessing/.gitignore b/PreProcessing/.gitignore new file mode 100644 index 0000000..ce78dca --- /dev/null +++ b/PreProcessing/.gitignore @@ -0,0 +1,14 @@ +# Generated preprocessing outputs +comparisons/ +manual_diffs/ +manual_matrices/ +original_characters/ +processed_characters/ +svg_characters/ +out_cleaned/ +out_matrix/ + +# Local caches +.matplotlib-cache/ +scripts/__pycache__/ +*.pyc diff --git a/PreProcessing/README.md b/PreProcessing/README.md new file mode 100644 index 0000000..cf117dc --- /dev/null +++ b/PreProcessing/README.md @@ -0,0 +1,49 @@ +# PreProcessing 预处理 + +这个目录包含17*17型特殊种类篆文字图像的预处理流程。日常批量处理请使用 `scripts/run_preprocessing.py`。 + +## 脚本用法 + +依赖见 `scripts/requirements.txt`。如果环境里还没有安装,可以先运行: + +```bash +.venv/bin/pip install -r PreProcessing/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 +``` + +参数含义: + +- `--input-dir`:原始图像输入文件夹 +- `--cleaned-dir`:cleaned 二值图输出文件夹 +- `--matrix-dir`:17x17 矩阵文本输出文件夹 + +输出命名会和输入文件名对齐: + +- cleaned 图像:`原文件名_cleaned.png` +- 矩阵文本:`原文件名_matrix.txt` + +矩阵文本是纯 17 行,每行 17 个字符,只包含 `0` 和 `1`。 + +## 可选调参 + +默认参数会输出 17x17 矩阵。必要时可以追加这些参数: + +- `--grid-size`,默认 `17` +- `--cell-samples`,默认 `32` +- `--open-kernel-size` +- `--close-kernel-size` +- `--median-size` +- `--min-component-area` +- `--triangle-threshold` + +## 目录说明 + +`comparisons/`、`manual_diffs/`、`manual_matrices/`、`processed_characters/`、`svg_characters/` 等测试和比较用目录已经在 `PreProcessing/.gitignore` 中忽略。 diff --git a/PreProcessing/comparisons/篆文字_page_017_text_17_comparison.png b/PreProcessing/comparisons/篆文字_page_017_text_17_comparison.png index 57a6902..ce2ee5e 100644 Binary files a/PreProcessing/comparisons/篆文字_page_017_text_17_comparison.png and b/PreProcessing/comparisons/篆文字_page_017_text_17_comparison.png differ diff --git a/PreProcessing/processed_characters/篆文字_page_017_text_17_cleaned.png b/PreProcessing/processed_characters/篆文字_page_017_text_17_cleaned.png index f5d2bac..7a4536b 100644 Binary files a/PreProcessing/processed_characters/篆文字_page_017_text_17_cleaned.png and b/PreProcessing/processed_characters/篆文字_page_017_text_17_cleaned.png differ diff --git a/PreProcessing/processed_characters/篆文字_page_017_text_17_pixelated.png b/PreProcessing/processed_characters/篆文字_page_017_text_17_pixelated.png index 3a8ebda..e83df3a 100644 Binary files a/PreProcessing/processed_characters/篆文字_page_017_text_17_pixelated.png and b/PreProcessing/processed_characters/篆文字_page_017_text_17_pixelated.png differ diff --git a/PreProcessing/scripts/compare_manual_matrix17.py b/PreProcessing/scripts/compare_manual_matrix17.py new file mode 100644 index 0000000..13a2808 --- /dev/null +++ b/PreProcessing/scripts/compare_manual_matrix17.py @@ -0,0 +1,237 @@ +from __future__ import annotations + +import argparse +from dataclasses import dataclass +from pathlib import Path + +import cv2 +import numpy as np + + +GRID_SIZE = 17 + + +@dataclass(frozen=True) +class MatrixMetrics: + stem: str + true_positive: int + false_positive: int + false_negative: int + manual_cells: int + generated_cells: int + manual_2x2: int + generated_2x2: int + + @property + def precision(self) -> float: + denominator = self.true_positive + self.false_positive + return self.true_positive / denominator if denominator else 1.0 + + @property + def recall(self) -> float: + denominator = self.true_positive + self.false_negative + return self.true_positive / denominator if denominator else 1.0 + + @property + def f1(self) -> float: + denominator = self.precision + self.recall + return 2.0 * self.precision * self.recall / denominator if denominator else 0.0 + + +def read_matrix(path: Path) -> np.ndarray: + rows: list[str] = [] + for line in path.read_text(encoding="utf-8").splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + rows.append(stripped) + if len(rows) == GRID_SIZE: + break + + if len(rows) != GRID_SIZE: + raise ValueError(f"{path} has {len(rows)} matrix rows, expected {GRID_SIZE}") + + matrix = np.zeros((GRID_SIZE, GRID_SIZE), dtype=np.uint8) + for row_index, row_text in enumerate(rows): + if len(row_text) < GRID_SIZE: + raise ValueError( + f"{path} row {row_index + 1} has {len(row_text)} columns, " + f"expected {GRID_SIZE}" + ) + for col_index, char in enumerate(row_text[:GRID_SIZE]): + matrix[row_index, col_index] = 1 if char == "1" else 0 + + return matrix + + +def count_2x2_blocks(matrix: np.ndarray) -> int: + total = 0 + for row in range(GRID_SIZE - 1): + for col in range(GRID_SIZE - 1): + total += int(matrix[row : row + 2, col : col + 2].sum() == 4) + return total + + +def diff_image(manual: np.ndarray, generated: np.ndarray, scale: int) -> np.ndarray: + both = (manual == 1) & (generated == 1) + false_positive = (manual == 0) & (generated == 1) + false_negative = (manual == 1) & (generated == 0) + + image = np.full((GRID_SIZE, GRID_SIZE, 3), 255, dtype=np.uint8) + image[both] = (0, 0, 0) + image[false_positive] = (40, 40, 220) + image[false_negative] = (220, 80, 40) + return cv2.resize(image, (GRID_SIZE * scale, GRID_SIZE * scale), interpolation=cv2.INTER_NEAREST) + + +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 metrics_for_pair(manual_path: Path, generated_path: Path) -> MatrixMetrics: + manual = read_matrix(manual_path) + generated = read_matrix(generated_path) + true_positive = int(((manual == 1) & (generated == 1)).sum()) + false_positive = int(((manual == 0) & (generated == 1)).sum()) + false_negative = int(((manual == 1) & (generated == 0)).sum()) + stem = manual_path.name.removesuffix("_matrix17.txt") + return MatrixMetrics( + stem=stem, + true_positive=true_positive, + false_positive=false_positive, + false_negative=false_negative, + manual_cells=int(manual.sum()), + generated_cells=int(generated.sum()), + manual_2x2=count_2x2_blocks(manual), + generated_2x2=count_2x2_blocks(generated), + ) + + +def print_table(metrics: list[MatrixMetrics]) -> None: + header = ( + "stem", + "P", + "R", + "F1", + "TP", + "FP", + "FN", + "manual", + "gen", + "manual_2x2", + "gen_2x2", + ) + print( + f"{header[0]:42} {header[1]:>6} {header[2]:>6} {header[3]:>6} " + f"{header[4]:>4} {header[5]:>4} {header[6]:>4} {header[7]:>6} " + f"{header[8]:>6} {header[9]:>11} {header[10]:>8}" + ) + for item in metrics: + print( + f"{item.stem:42} {item.precision:6.3f} {item.recall:6.3f} {item.f1:6.3f} " + f"{item.true_positive:4d} {item.false_positive:4d} {item.false_negative:4d} " + f"{item.manual_cells:6d} {item.generated_cells:6d} " + f"{item.manual_2x2:11d} {item.generated_2x2:8d}" + ) + + total_tp = sum(item.true_positive for item in metrics) + total_fp = sum(item.false_positive for item in metrics) + total_fn = sum(item.false_negative for item in metrics) + total_manual = sum(item.manual_cells for item in metrics) + total_generated = sum(item.generated_cells for item in metrics) + total_manual_2x2 = sum(item.manual_2x2 for item in metrics) + total_generated_2x2 = sum(item.generated_2x2 for item in metrics) + total = MatrixMetrics( + stem="TOTAL", + true_positive=total_tp, + false_positive=total_fp, + false_negative=total_fn, + manual_cells=total_manual, + generated_cells=total_generated, + manual_2x2=total_manual_2x2, + generated_2x2=total_generated_2x2, + ) + print("-" * 113) + print( + f"{total.stem:42} {total.precision:6.3f} {total.recall:6.3f} {total.f1:6.3f} " + f"{total.true_positive:4d} {total.false_positive:4d} {total.false_negative:4d} " + f"{total.manual_cells:6d} {total.generated_cells:6d} " + f"{total.manual_2x2:11d} {total.generated_2x2:8d}" + ) + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Compare manual 17x17 labels against generated matrix output." + ) + parser.add_argument( + "--manual-dir", + type=Path, + default=Path("PreProcessing/manual_matrices"), + ) + parser.add_argument( + "--generated-dir", + type=Path, + default=Path("PreProcessing/processed_characters"), + ) + parser.add_argument( + "--diff-dir", + type=Path, + default=Path("PreProcessing/manual_diffs"), + ) + parser.add_argument("--scale", type=int, default=24) + parser.add_argument( + "--include-empty-manual", + action="store_true", + help="Include manual matrices with no black cells instead of treating them as unlabelled.", + ) + args = parser.parse_args() + + manual_paths = sorted(args.manual_dir.glob("*_matrix17.txt")) + if not manual_paths: + raise SystemExit(f"No manual matrices found in {args.manual_dir}") + + args.diff_dir.mkdir(parents=True, exist_ok=True) + metrics: list[MatrixMetrics] = [] + missing: list[Path] = [] + skipped_empty: list[Path] = [] + + for manual_path in manual_paths: + manual = read_matrix(manual_path) + if not args.include_empty_manual and int(manual.sum()) == 0: + skipped_empty.append(manual_path) + continue + + generated_path = args.generated_dir / manual_path.name + if not generated_path.exists(): + missing.append(generated_path) + continue + + generated = read_matrix(generated_path) + metrics.append(metrics_for_pair(manual_path, generated_path)) + stem = manual_path.name.removesuffix("_matrix17.txt") + write_image(args.diff_dir / f"{stem}_diff.png", diff_image(manual, generated, args.scale)) + + if missing: + print("Missing generated matrices:") + for path in missing: + print(f" {path}") + + if skipped_empty: + print("Skipped empty manual matrices:") + for path in skipped_empty: + print(f" {path}") + + if not metrics: + raise SystemExit("No comparable matrix pairs found.") + + print_table(metrics) + print(f"\nDiff images written to: {args.diff_dir}") + print("Diff colors: black=match, red=generated extra, blue=manual missing.") + + +if __name__ == "__main__": + main() diff --git a/PreProcessing/scripts/draw_matrix17.py b/PreProcessing/scripts/draw_matrix17.py new file mode 100644 index 0000000..c8ace35 --- /dev/null +++ b/PreProcessing/scripts/draw_matrix17.py @@ -0,0 +1,364 @@ +from __future__ import annotations + +import argparse +from pathlib import Path +import tkinter as tk +from tkinter import messagebox + +import cv2 +import numpy as np + + +IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".tif", ".tiff", ".bmp", ".webp"} +GRID_SIZE = 17 +CELL_SIZE = 28 +PREVIEW_SIZE = 560 + + +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 matrix_to_text(matrix: np.ndarray) -> str: + lines = [] + for row in matrix: + lines.append("".join("1" if value else "0" for value in row)) + return "\n".join(lines) + "\n" + + +def text_to_matrix(path: Path) -> np.ndarray: + matrix = np.zeros((GRID_SIZE, GRID_SIZE), dtype=np.uint8) + if not path.exists(): + return matrix + + rows = [] + for line in path.read_text(encoding="utf-8").splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + rows.append(stripped) + if len(rows) == GRID_SIZE: + break + + for row_index, row_text in enumerate(rows[:GRID_SIZE]): + for col_index, char in enumerate(row_text[:GRID_SIZE]): + matrix[row_index, col_index] = 1 if char == "1" else 0 + + return matrix + + +def matrix_to_png(matrix: np.ndarray, scale: int = 24) -> np.ndarray: + image = np.where(matrix > 0, 0, 255).astype(np.uint8) + return cv2.resize( + image, + (GRID_SIZE * scale, GRID_SIZE * scale), + interpolation=cv2.INTER_NEAREST, + ) + + +class MatrixEditor: + def __init__( + self, + root: tk.Tk, + image_paths: list[Path], + output_dir: Path, + load_generated_dir: Path | None, + ) -> None: + self.root = root + self.image_paths = image_paths + self.output_dir = output_dir + self.load_generated_dir = load_generated_dir + self.output_dir.mkdir(parents=True, exist_ok=True) + + self.index = 0 + self.matrix = np.zeros((GRID_SIZE, GRID_SIZE), dtype=np.uint8) + self.paint_value = 1 + self.preview_photo: tk.PhotoImage | None = None + self.preview_temp = Path("/private/tmp/matrix17_editor_preview.png") + + self.root.title("17x17 Matrix Editor") + self._build_ui() + self._bind_keys() + self.load_current_image() + + @property + def image_path(self) -> Path: + return self.image_paths[self.index] + + @property + def answer_path(self) -> Path: + return self.output_dir / f"{self.image_path.stem}_matrix17.txt" + + @property + def answer_png_path(self) -> Path: + return self.output_dir / f"{self.image_path.stem}_matrix17.png" + + def _build_ui(self) -> None: + main = tk.Frame(self.root) + main.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) + + left = tk.Frame(main) + left.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + right = tk.Frame(main) + right.pack(side=tk.LEFT, fill=tk.Y, padx=(12, 0)) + + self.preview_canvas = tk.Canvas( + left, + width=PREVIEW_SIZE, + height=PREVIEW_SIZE, + bg="white", + highlightthickness=1, + highlightbackground="#999", + ) + self.preview_canvas.pack(fill=tk.BOTH, expand=True) + + self.grid_canvas = tk.Canvas( + right, + width=GRID_SIZE * CELL_SIZE + 1, + height=GRID_SIZE * CELL_SIZE + 1, + bg="white", + highlightthickness=1, + highlightbackground="#777", + ) + self.grid_canvas.pack() + self.grid_canvas.bind("", self._on_left_click) + self.grid_canvas.bind("", self._on_left_drag) + self.grid_canvas.bind("", self._on_right_click) + self.grid_canvas.bind("", self._on_right_drag) + + buttons = tk.Frame(right) + buttons.pack(fill=tk.X, pady=(10, 0)) + + tk.Button(buttons, text="上一张", command=self.prev_image).grid( + row=0, column=0, sticky="ew", padx=2, pady=2 + ) + tk.Button(buttons, text="下一张", command=self.next_image).grid( + row=0, column=1, sticky="ew", padx=2, pady=2 + ) + tk.Button(buttons, text="保存", command=self.save_answer).grid( + row=1, column=0, sticky="ew", padx=2, pady=2 + ) + tk.Button(buttons, text="清空", command=self.clear_matrix).grid( + row=1, column=1, sticky="ew", padx=2, pady=2 + ) + tk.Button(buttons, text="载入生成结果", command=self.load_generated).grid( + row=2, column=0, columnspan=2, sticky="ew", padx=2, pady=2 + ) + + buttons.columnconfigure(0, weight=1) + buttons.columnconfigure(1, weight=1) + + self.status = tk.Label(right, text="", anchor="w", justify="left") + self.status.pack(fill=tk.X, pady=(8, 0)) + + def _bind_keys(self) -> None: + self.root.bind("", lambda _event: self.prev_image()) + self.root.bind("", lambda _event: self.next_image()) + self.root.bind("s", lambda _event: self.save_answer()) + self.root.bind("S", lambda _event: self.save_answer()) + self.root.bind("c", lambda _event: self.clear_matrix()) + self.root.bind("C", lambda _event: self.clear_matrix()) + self.root.bind("1", lambda _event: self.set_paint_value(1)) + self.root.bind("0", lambda _event: self.set_paint_value(0)) + + def set_paint_value(self, value: int) -> None: + self.paint_value = 1 if value else 0 + self._update_status() + + def load_current_image(self) -> None: + self.matrix = text_to_matrix(self.answer_path) + self._draw_preview() + self._draw_grid() + self._update_status() + + def _draw_preview(self) -> None: + image = read_image(self.image_path) + height, width = image.shape[:2] + scale = min(PREVIEW_SIZE / height, PREVIEW_SIZE / width) + new_width = max(1, int(round(width * scale))) + new_height = max(1, int(round(height * scale))) + resized = cv2.resize(image, (new_width, new_height), interpolation=cv2.INTER_NEAREST) + + canvas = np.full((PREVIEW_SIZE, PREVIEW_SIZE, 3), 255, dtype=np.uint8) + x = (PREVIEW_SIZE - new_width) // 2 + y = (PREVIEW_SIZE - new_height) // 2 + canvas[y : y + new_height, x : x + new_width] = resized + write_image(self.preview_temp, canvas) + self.preview_photo = tk.PhotoImage(file=str(self.preview_temp)) + self.preview_canvas.delete("all") + self.preview_canvas.create_image(0, 0, anchor="nw", image=self.preview_photo) + + def _draw_grid(self) -> None: + self.grid_canvas.delete("all") + for row in range(GRID_SIZE): + for col in range(GRID_SIZE): + x1 = col * CELL_SIZE + y1 = row * CELL_SIZE + x2 = x1 + CELL_SIZE + y2 = y1 + CELL_SIZE + fill = "black" if self.matrix[row, col] else "white" + self.grid_canvas.create_rectangle( + x1, + y1, + x2, + y2, + fill=fill, + outline="#888", + ) + + for index in range(GRID_SIZE + 1): + pos = index * CELL_SIZE + width = 2 if index in (0, GRID_SIZE) else 1 + self.grid_canvas.create_line(0, pos, GRID_SIZE * CELL_SIZE, pos, fill="#555", width=width) + self.grid_canvas.create_line(pos, 0, pos, GRID_SIZE * CELL_SIZE, fill="#555", width=width) + + def _grid_cell_from_event(self, event: tk.Event) -> tuple[int, int] | None: + col = int(event.x // CELL_SIZE) + row = int(event.y // CELL_SIZE) + if 0 <= row < GRID_SIZE and 0 <= col < GRID_SIZE: + return row, col + return None + + def _set_cell_from_event(self, event: tk.Event, value: int) -> None: + cell = self._grid_cell_from_event(event) + if cell is None: + return + row, col = cell + if self.matrix[row, col] == value: + return + self.matrix[row, col] = value + self._draw_grid() + self._update_status() + + def _on_left_click(self, event: tk.Event) -> None: + cell = self._grid_cell_from_event(event) + if cell is None: + return + row, col = cell + self.paint_value = 0 if self.matrix[row, col] else 1 + self.matrix[row, col] = self.paint_value + self._draw_grid() + self._update_status() + + def _on_left_drag(self, event: tk.Event) -> None: + self._set_cell_from_event(event, self.paint_value) + + def _on_right_click(self, event: tk.Event) -> None: + self.paint_value = 0 + self._set_cell_from_event(event, 0) + self._update_status() + + def _on_right_drag(self, event: tk.Event) -> None: + self._set_cell_from_event(event, 0) + + def save_answer(self) -> None: + self.answer_path.write_text(matrix_to_text(self.matrix), encoding="utf-8") + write_image(self.answer_png_path, matrix_to_png(self.matrix)) + self._update_status(saved=True) + + def clear_matrix(self) -> None: + self.matrix[:, :] = 0 + self._draw_grid() + self._update_status() + + def load_generated(self) -> None: + if self.load_generated_dir is None: + messagebox.showinfo("提示", "未配置生成结果目录") + return + + path = self.load_generated_dir / f"{self.image_path.stem}_matrix17.txt" + if not path.exists(): + messagebox.showinfo("提示", f"找不到生成结果:\n{path}") + return + + self.matrix = text_to_matrix(path) + self._draw_grid() + self._update_status() + + def prev_image(self) -> None: + self.save_answer() + self.index = (self.index - 1) % len(self.image_paths) + self.load_current_image() + + def next_image(self) -> None: + self.save_answer() + self.index = (self.index + 1) % len(self.image_paths) + self.load_current_image() + + def _update_status(self, saved: bool = False) -> None: + filled = int(self.matrix.sum()) + two_by_two = count_2x2_blocks(self.matrix) + saved_text = "已保存\n" if saved else "" + self.status.config( + text=( + f"{saved_text}" + f"{self.index + 1}/{len(self.image_paths)}\n" + f"{self.image_path.name}\n" + f"黑格:{filled}\n" + f"2x2 全黑:{two_by_two}\n" + f"左键:切换/拖拽绘制\n" + f"右键:擦除\n" + f"S 保存,←/→ 切换" + ) + ) + + +def count_2x2_blocks(matrix: np.ndarray) -> int: + image = matrix > 0 + total = 0 + for row in range(GRID_SIZE - 1): + for col in range(GRID_SIZE - 1): + total += int(image[row : row + 2, col : col + 2].sum() == 4) + return total + + +def main() -> None: + parser = argparse.ArgumentParser(description="Draw manual 17x17 matrix labels.") + parser.add_argument( + "--input-dir", + type=Path, + default=Path("PreProcessing/original_characters"), + ) + parser.add_argument( + "--output-dir", + type=Path, + default=Path("PreProcessing/manual_matrices"), + ) + parser.add_argument( + "--load-generated-dir", + type=Path, + default=Path("PreProcessing/processed_characters"), + ) + args = parser.parse_args() + + image_paths = sorted( + path + for path in args.input_dir.iterdir() + if path.is_file() and path.suffix.lower() in IMAGE_SUFFIXES + ) + if not image_paths: + raise SystemExit(f"No images found in {args.input_dir}") + + root = tk.Tk() + MatrixEditor( + root=root, + image_paths=image_paths, + output_dir=args.output_dir, + load_generated_dir=args.load_generated_dir, + ) + root.mainloop() + + +if __name__ == "__main__": + main() diff --git a/PreProcessing/scripts/process_characters.py b/PreProcessing/scripts/process_characters.py index 2012731..94fad66 100644 --- a/PreProcessing/scripts/process_characters.py +++ b/PreProcessing/scripts/process_characters.py @@ -3,6 +3,7 @@ from __future__ import annotations import argparse import html import os +from dataclasses import dataclass from pathlib import Path import cv2 @@ -23,6 +24,23 @@ import numpy as np IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".tif", ".tiff", ".bmp", ".webp"} +@dataclass(frozen=True) +class TriangleConnection: + row: int + col: int + corner: str + + +@dataclass(frozen=True) +class MatrixMapping: + matrix: np.ndarray + rendered_mask: np.ndarray + x_edges: np.ndarray + y_edges: np.ndarray + triangles: tuple[TriangleConnection, ...] = () + grid_overlay: np.ndarray | None = None + + def read_image(path: Path) -> np.ndarray: raw = np.fromfile(str(path), dtype=np.uint8) image = cv2.imdecode(raw, cv2.IMREAD_COLOR) @@ -39,10 +57,13 @@ def write_image(path: Path, image: np.ndarray) -> None: 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 + 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( [ @@ -62,29 +83,223 @@ def binarize_foreground(gray: np.ndarray) -> np.ndarray: return foreground +def fill_mask_holes(mask: np.ndarray) -> np.ndarray: + foreground = mask > 0 + inverse = (~foreground).astype(np.uint8) + flood = np.zeros((mask.shape[0] + 2, mask.shape[1] + 2), dtype=np.uint8) + cv2.floodFill(inverse, flood, (0, 0), 2) + holes = inverse == 1 + filled = foreground | holes + return filled.astype(np.uint8) * 255 + + +def fill_small_mask_holes(mask: np.ndarray, max_area: int) -> np.ndarray: + if max_area <= 0: + return mask + + foreground = mask > 0 + inverse = (~foreground).astype(np.uint8) + flood = np.zeros((mask.shape[0] + 2, mask.shape[1] + 2), dtype=np.uint8) + cv2.floodFill(inverse, flood, (0, 0), 2) + holes = (flood == 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]) + if area <= max_area: + filled[labels == label] = True + + return filled.astype(np.uint8) * 255 + + +def fill_small_background_pockets( + mask: np.ndarray, + max_area: int, + channel_size: int, +) -> np.ndarray: + if max_area <= 0 or channel_size <= 1: + return fill_small_mask_holes(mask, max_area) + + if channel_size % 2 == 0: + channel_size += 1 + + kernel = cv2.getStructuringElement( + cv2.MORPH_ELLIPSE, (channel_size, channel_size) + ) + expanded = cv2.dilate(mask, kernel) + external = (expanded == 0).astype(np.uint8) + flood = np.zeros((mask.shape[0] + 2, mask.shape[1] + 2), dtype=np.uint8) + cv2.floodFill(external, flood, (0, 0), 2) + + pockets = ((mask == 0) & (external != 2)).astype(np.uint8) + num_labels, labels, stats, _ = cv2.connectedComponentsWithStats( + pockets, connectivity=8 + ) + + filled = mask > 0 + for label in range(1, num_labels): + area = int(stats[label, cv2.CC_STAT_AREA]) + if area <= max_area: + filled[labels == label] = True + + return filled.astype(np.uint8) * 255 + + +def remove_small_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): + if int(stats[label, cv2.CC_STAT_AREA]) >= min_area: + cleaned[labels == label] = 255 + + return cleaned + + +def smooth_binary_edges(mask: np.ndarray, iterations: int = 1) -> np.ndarray: + foreground = mask > 0 + kernel = np.ones((3, 3), dtype=np.uint8) + + for _ in range(iterations): + neighbors = cv2.filter2D( + foreground.astype(np.uint8), + ddepth=-1, + kernel=kernel, + borderType=cv2.BORDER_CONSTANT, + ) + foreground = (foreground & (neighbors >= 3)) | (~foreground & (neighbors >= 7)) + + return foreground.astype(np.uint8) * 255 + + def clean_mask( mask: np.ndarray, open_kernel_size: int, close_kernel_size: int, median_size: int, + min_component_area: int, + grid_size: int = 17, ) -> 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) + cleaned = mask + if open_kernel_size > 1: + open_kernel = cv2.getStructuringElement( + cv2.MORPH_RECT, (open_kernel_size, open_kernel_size) + ) + cleaned = cv2.morphologyEx(cleaned, cv2.MORPH_OPEN, open_kernel) + + cleaned = remove_small_components(cleaned, min_component_area) + x1, y1, x2, y2 = _foreground_bbox(cleaned) + cell_size = max(4.0, min(float(x2 - x1), float(y2 - y1)) / float(grid_size)) + burr_size = min(5, max(3, int(round(cell_size * 0.12)) | 1)) + line_length = max(3, int(round(cell_size * 0.64))) + pocket_size = min(5, max(3, int(round(cell_size * 0.10)) | 1)) + gap_fill_length = min(7, max(3, int(round(cell_size * 0.18)) | 1)) + max_hole_area = max(4, int(round(cell_size * cell_size * 0.35))) + + burr_kernel = cv2.getStructuringElement( + cv2.MORPH_RECT, (burr_size, burr_size) ) - opened = cv2.morphologyEx(mask, cv2.MORPH_OPEN, open_kernel) + cleaned = cv2.morphologyEx(cleaned, cv2.MORPH_OPEN, burr_kernel) + cleaned = remove_small_components(cleaned, min_component_area) - close_kernel = cv2.getStructuringElement( - cv2.MORPH_RECT, (close_kernel_size, close_kernel_size) + horizontal_kernel = cv2.getStructuringElement( + cv2.MORPH_RECT, (line_length, 1) ) - return cv2.morphologyEx(opened, cv2.MORPH_CLOSE, close_kernel) + vertical_kernel = cv2.getStructuringElement( + cv2.MORPH_RECT, (1, line_length) + ) + horizontal = cv2.morphologyEx(cleaned, cv2.MORPH_OPEN, horizontal_kernel) + vertical = cv2.morphologyEx(cleaned, cv2.MORPH_OPEN, vertical_kernel) + cleaned = cv2.bitwise_or(horizontal, vertical) + + horizontal_gap_kernel = cv2.getStructuringElement( + cv2.MORPH_RECT, (gap_fill_length, 1) + ) + vertical_gap_kernel = cv2.getStructuringElement( + cv2.MORPH_RECT, (1, gap_fill_length) + ) + horizontal_filled = cv2.morphologyEx( + cleaned, cv2.MORPH_CLOSE, horizontal_gap_kernel + ) + vertical_filled = cv2.morphologyEx(cleaned, cv2.MORPH_CLOSE, vertical_gap_kernel) + cleaned = cv2.bitwise_or(horizontal_filled, vertical_filled) + + cleaned = fill_small_background_pockets( + cleaned, + max_area=max_hole_area, + channel_size=pocket_size, + ) + smooth_size = close_kernel_size if close_kernel_size > 1 else 3 + smooth_kernel = cv2.getStructuringElement( + cv2.MORPH_CROSS, (smooth_size, smooth_size) + ) + cleaned = cv2.morphologyEx(cleaned, cv2.MORPH_CLOSE, smooth_kernel) + cleaned = fill_small_background_pockets( + cleaned, + max_area=max_hole_area, + channel_size=pocket_size, + ) + cleaned = smooth_binary_edges(cleaned, iterations=1) + + return remove_small_components(cleaned, min_component_area) -def pixelate(mask: np.ndarray, factor: int) -> tuple[np.ndarray, np.ndarray]: +def _render_matrix( + matrix: np.ndarray, + x_edges: np.ndarray, + y_edges: np.ndarray, + width: int, + height: int, + triangles: tuple[TriangleConnection, ...] = (), +) -> np.ndarray: + rendered = np.zeros((height, width), dtype=np.uint8) + + rows, cols = matrix.shape + for row_index in range(rows): + y1 = int(y_edges[row_index]) + y2 = int(y_edges[row_index + 1]) + if y2 <= y1: + continue + + for col_index in range(cols): + if matrix[row_index, col_index] == 0: + continue + + x1 = int(x_edges[col_index]) + x2 = int(x_edges[col_index + 1]) + if x2 > x1: + rendered[y1:y2, x1:x2] = 255 + + for triangle in triangles: + x1 = int(x_edges[triangle.col]) + x2 = int(x_edges[triangle.col + 1]) + y1 = int(y_edges[triangle.row]) + y2 = int(y_edges[triangle.row + 1]) + points_by_corner = { + "tl": [(x1, y1), (x2, y1), (x1, y2)], + "tr": [(x2, y1), (x1, y1), (x2, y2)], + "br": [(x2, y2), (x2, y1), (x1, y2)], + "bl": [(x1, y2), (x1, y1), (x2, y2)], + } + points = np.array(points_by_corner[triangle.corner], dtype=np.int32) + cv2.fillConvexPoly(rendered, points, 255) + + return rendered + + +def pixelate(mask: np.ndarray, factor: int) -> MatrixMapping: height, width = mask.shape small_width = max(1, round(width / factor)) small_height = max(1, round(height / factor)) @@ -92,8 +307,671 @@ def pixelate(mask: np.ndarray, factor: int) -> tuple[np.ndarray, np.ndarray]: mask, (small_width, small_height), interpolation=cv2.INTER_AREA ) small = np.where(sampled >= 96, 255, 0).astype(np.uint8) + x_edges = np.rint(np.linspace(0, width, small_width + 1)).astype(int) + y_edges = np.rint(np.linspace(0, height, small_height + 1)).astype(int) large = cv2.resize(small, (width, height), interpolation=cv2.INTER_NEAREST) - return small, large + return MatrixMapping(small, large, x_edges, y_edges) + + +def _order_quad_points(points: np.ndarray) -> np.ndarray: + points = points.astype(np.float32) + sums = points.sum(axis=1) + diffs = np.diff(points, axis=1).reshape(-1) + ordered = np.zeros((4, 2), dtype=np.float32) + ordered[0] = points[int(np.argmin(sums))] + ordered[2] = points[int(np.argmax(sums))] + ordered[1] = points[int(np.argmin(diffs))] + ordered[3] = points[int(np.argmax(diffs))] + return ordered + + +def _foreground_quad(mask: np.ndarray) -> np.ndarray: + points = cv2.findNonZero(mask) + if points is None: + height, width = mask.shape + return np.array( + [[0, 0], [width - 1, 0], [width - 1, height - 1], [0, height - 1]], + dtype=np.float32, + ) + + contour_points = points.reshape(-1, 2) + hull = cv2.convexHull(contour_points) + perimeter = cv2.arcLength(hull, True) + approx = cv2.approxPolyDP(hull, 0.035 * perimeter, True) + if len(approx) == 4: + return _order_quad_points(approx.reshape(4, 2)) + + rect = cv2.minAreaRect(contour_points) + return _order_quad_points(cv2.boxPoints(rect)) + + +def _quad_bbox_square_edges( + quad: np.ndarray, + canvas_width: int, + canvas_height: int, + grid_size: int, +) -> tuple[np.ndarray, np.ndarray]: + x_min, y_min = np.min(quad, axis=0) + x_max, y_max = np.max(quad, axis=0) + center_x = float((x_min + x_max) * 0.5) + center_y = float((y_min + y_max) * 0.5) + side = max(float(x_max - x_min), float(y_max - y_min), 1.0) + + left = center_x - side * 0.5 + top = center_y - side * 0.5 + if left < 0: + left = 0.0 + if top < 0: + top = 0.0 + if left + side > canvas_width: + left = max(0.0, float(canvas_width) - side) + if top + side > canvas_height: + top = max(0.0, float(canvas_height) - side) + + right = min(float(canvas_width), left + side) + bottom = min(float(canvas_height), top + side) + x_edges = np.rint(np.linspace(left, right, grid_size + 1)).astype(int) + y_edges = np.rint(np.linspace(top, bottom, grid_size + 1)).astype(int) + return x_edges, y_edges + + +def _warp_to_grid(mask: np.ndarray, grid_size: int, cell_samples: int) -> np.ndarray: + quad = _foreground_quad(mask) + side = int(grid_size * cell_samples) + destination = np.array( + [[0, 0], [side - 1, 0], [side - 1, side - 1], [0, side - 1]], + dtype=np.float32, + ) + transform = cv2.getPerspectiveTransform(quad, destination) + return cv2.warpPerspective( + mask, + transform, + (side, side), + flags=cv2.INTER_NEAREST, + borderMode=cv2.BORDER_CONSTANT, + borderValue=0, + ) + + +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 _rotate_mask(mask: np.ndarray, angle: float) -> np.ndarray: + if abs(angle) < 1e-6: + return mask + + height, width = mask.shape + center = (width / 2.0, height / 2.0) + transform = cv2.getRotationMatrix2D(center, angle, 1.0) + return cv2.warpAffine( + mask, + transform, + (width, height), + flags=cv2.INTER_NEAREST, + borderMode=cv2.BORDER_CONSTANT, + borderValue=0, + ) + + +def _projection_score(mask: np.ndarray) -> float: + foreground = mask > 0 + row_sums = foreground.sum(axis=1).astype(np.float64) + col_sums = foreground.sum(axis=0).astype(np.float64) + return float(np.square(row_sums).sum() + np.square(col_sums).sum()) + + +def _estimate_skew_angle(mask: np.ndarray, max_angle: float = 4.0) -> float: + best_angle = 0.0 + best_score = _projection_score(mask) + + for angle in np.arange(-max_angle, max_angle + 0.001, 0.25): + if abs(float(angle)) < 1e-6: + continue + rotated = _rotate_mask(mask, float(angle)) + score = _projection_score(rotated) + if score > best_score: + best_score = score + best_angle = float(angle) + + return best_angle + + +def _solidify_hollow_strokes(mask: np.ndarray, grid_size: int) -> np.ndarray: + x1, y1, x2, y2 = _foreground_bbox(mask) + cell_width = max(2.0, float(x2 - x1) / float(grid_size)) + cell_height = max(2.0, float(y2 - y1) / float(grid_size)) + cell_size = max(2.0, min(cell_width, cell_height)) + + long_size = max(3, int(round(cell_size * 0.72)) | 1) + short_size = max(3, int(round(cell_size * 0.14)) | 1) + + vertical_bridge = cv2.getStructuringElement( + cv2.MORPH_RECT, (short_size, long_size) + ) + horizontal_bridge = cv2.getStructuringElement( + cv2.MORPH_RECT, (long_size, short_size) + ) + square_cleanup = cv2.getStructuringElement( + cv2.MORPH_RECT, (max(3, short_size), max(3, short_size)) + ) + + horizontal_strokes = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, vertical_bridge) + vertical_strokes = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, horizontal_bridge) + solid = cv2.bitwise_or(horizontal_strokes, vertical_strokes) + solid = cv2.morphologyEx(solid, cv2.MORPH_CLOSE, square_cleanup) + return solid + + +def _triangle_corner_score(cell: np.ndarray, corner: str) -> float: + size = cell.shape[0] + yy, xx = np.indices(cell.shape) + if corner == "tl": + region = (xx + yy) <= size + elif corner == "tr": + region = ((size - 1 - xx) + yy) <= size + elif corner == "br": + region = ((size - 1 - xx) + (size - 1 - yy)) <= size + else: + region = (xx + (size - 1 - yy)) <= size + + foreground = cell > 0 + inside = float(foreground[region].mean()) if np.any(region) else 0.0 + outside = float(foreground[~region].mean()) if np.any(~region) else 0.0 + return inside - outside + + +def _detect_triangle_connections( + warped: np.ndarray, + matrix: np.ndarray, + cell_samples: int, + triangle_threshold: float, +) -> tuple[TriangleConnection, ...]: + grid_size = matrix.shape[0] + triangles: list[TriangleConnection] = [] + + def add_if_supported(row: int, col: int, corner: str) -> None: + y1 = row * cell_samples + y2 = y1 + cell_samples + x1 = col * cell_samples + x2 = x1 + cell_samples + cell = warped[y1:y2, x1:x2] + coverage = float((cell > 0).mean()) + score = _triangle_corner_score(cell, corner) + if coverage >= triangle_threshold and score >= triangle_threshold: + triangles.append(TriangleConnection(row, col, corner)) + + for row in range(grid_size - 1): + for col in range(grid_size - 1): + nw = matrix[row, col] > 0 + ne = matrix[row, col + 1] > 0 + sw = matrix[row + 1, col] > 0 + se = matrix[row + 1, col + 1] > 0 + + if nw and se and not ne and not sw: + add_if_supported(row, col + 1, "bl") + add_if_supported(row + 1, col, "tr") + elif ne and sw and not nw and not se: + add_if_supported(row, col, "br") + add_if_supported(row + 1, col + 1, "tl") + + return tuple(triangles) + + +def _normalize_raw_strokes( + mask: np.ndarray, + grid_size: int, + cell_samples: int, +) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + angle = _estimate_skew_angle(mask) + deskewed = _rotate_mask(mask, angle) + x1, y1, x2, y2 = _foreground_bbox(deskewed) + side = int(grid_size * cell_samples) + candidates: list[tuple[float, np.ndarray, tuple[float, ...]]] = [] + scale_values = (0.92, 0.94, 0.96, 0.98, 1.0, 1.02, 1.04, 1.06) + shift_x_values = ( + -0.5, + -0.4, + -0.3, + -0.2, + -0.1, + 0.0, + 0.1, + 0.2, + 0.3, + 0.4, + 0.5, + ) + shift_y_values = (-0.3, -0.2, -0.1, 0.0, 0.1, 0.2, 0.3) + for scale_x in scale_values: + for scale_y in scale_values: + for shift_x in shift_x_values: + for shift_y in shift_y_values: + warped = _warp_bbox_candidate( + deskewed, + x1, + y1, + x2, + y2, + scale_x, + scale_y, + shift_x, + shift_y, + side, + ) + candidates.append( + ( + _grid_fit_score(warped, grid_size), + warped, + ( + scale_x, + scale_y, + shift_x, + shift_y, + 0.0, + 0.0, + 0.0, + 0.0, + ), + ) + ) + + for _score, _warped, params in sorted( + candidates, + reverse=True, + key=lambda item: item[0], + )[:36]: + ( + scale_x, + scale_y, + shift_x, + shift_y, + _shear_x, + _shear_y, + _curve_x, + _curve_y, + ) = params + for shear_x in (-0.8, -0.4, 0.0, 0.4, 0.8): + for shear_y in (-0.6, -0.3, 0.0, 0.3, 0.6): + warped = _warp_bbox_candidate( + deskewed, + x1, + y1, + x2, + y2, + scale_x, + scale_y, + shift_x, + shift_y, + side, + shear_x=shear_x, + shear_y=shear_y, + ) + candidates.append( + ( + _grid_fit_score(warped, grid_size), + warped, + ( + scale_x, + scale_y, + shift_x, + shift_y, + shear_x, + shear_y, + 0.0, + 0.0, + ), + ) + ) + + for _score, _warped, params in sorted( + candidates, + reverse=True, + key=lambda item: item[0], + )[:28]: + ( + scale_x, + scale_y, + shift_x, + shift_y, + shear_x, + shear_y, + _curve_x, + _curve_y, + ) = params + for curve_x, curve_y in ( + (-0.65, 0.0), + (-0.35, 0.0), + (0.35, 0.0), + (0.65, 0.0), + (0.0, -0.65), + (0.0, -0.35), + (0.0, 0.35), + (0.0, 0.65), + (-0.35, -0.35), + (-0.35, 0.35), + (0.35, -0.35), + (0.35, 0.35), + ): + warped = _warp_bbox_candidate( + deskewed, + x1, + y1, + x2, + y2, + scale_x, + scale_y, + shift_x, + shift_y, + side, + shear_x=shear_x, + shear_y=shear_y, + curve_x=curve_x, + curve_y=curve_y, + ) + candidates.append( + ( + _grid_fit_score(warped, grid_size), + warped, + ( + scale_x, + scale_y, + shift_x, + shift_y, + shear_x, + shear_y, + curve_x, + curve_y, + ), + ) + ) + + if candidates: + warped = max(candidates, key=lambda item: item[0])[1] + else: + crop = deskewed[y1:y2, x1:x2] + if crop.size == 0: + crop = deskewed + warped = cv2.resize(crop, (side, side), interpolation=cv2.INTER_NEAREST) + + render_x1, render_y1, render_x2, render_y2 = _foreground_bbox(mask) + x_edges = np.rint(np.linspace(render_x1, render_x2, grid_size + 1)).astype(int) + y_edges = np.rint(np.linspace(render_y1, render_y2, grid_size + 1)).astype(int) + return warped, x_edges, y_edges, _grid_overlay_preview(warped, grid_size) + + +def _grid_overlay_preview(warped: np.ndarray, grid_size: int) -> np.ndarray: + preview = cv2.cvtColor(mask_to_display(warped), cv2.COLOR_GRAY2RGB) + height, width = warped.shape + for index in range(grid_size + 1): + x = int(round(index * width / grid_size)) + y = int(round(index * height / grid_size)) + thickness = 2 if index in (0, grid_size) else 1 + cv2.line(preview, (x, 0), (x, height - 1), (220, 40, 40), thickness) + cv2.line(preview, (0, y), (width - 1, y), (220, 40, 40), thickness) + return preview + + +def _warp_bbox_candidate( + mask: np.ndarray, + x1: int, + y1: int, + x2: int, + y2: int, + scale_x: float, + scale_y: float, + shift_x: float, + shift_y: float, + side: int, + shear_x: float = 0.0, + shear_y: float = 0.0, + curve_x: float = 0.0, + curve_y: float = 0.0, +) -> np.ndarray: + width = max(1.0, float(x2 - x1)) + height = max(1.0, float(y2 - y1)) + center_x = ((x1 + x2) * 0.5) + shift_x * width / 17.0 + center_y = ((y1 + y2) * 0.5) + shift_y * height / 17.0 + crop_width = width * scale_x + crop_height = height * scale_y + left = int(round(center_x - crop_width * 0.5)) + right = int(round(center_x + crop_width * 0.5)) + top = int(round(center_y - crop_height * 0.5)) + bottom = int(round(center_y + crop_height * 0.5)) + + crop = np.zeros((max(1, bottom - top), max(1, right - left)), dtype=np.uint8) + source_left = max(0, left) + source_right = min(mask.shape[1], right) + source_top = max(0, top) + source_bottom = min(mask.shape[0], bottom) + if source_right > source_left and source_bottom > source_top: + crop[ + source_top - top : source_bottom - top, + source_left - left : source_right - left, + ] = mask[source_top:source_bottom, source_left:source_right] + + if ( + abs(shear_x) < 1e-9 + and abs(shear_y) < 1e-9 + and abs(curve_x) < 1e-9 + and abs(curve_y) < 1e-9 + ): + return cv2.resize(crop, (side, side), interpolation=cv2.INTER_NEAREST) + + yy, xx = np.indices((side, side), dtype=np.float32) + u = xx / max(1.0, float(side - 1)) + v = yy / max(1.0, float(side - 1)) + centered_u = u - 0.5 + centered_v = v - 0.5 + map_x = ( + u * float(crop.shape[1] - 1) + + (shear_x * centered_v + curve_x * (centered_v * centered_v - 0.125)) + * float(crop.shape[1]) + / 17.0 + ) + map_y = ( + v * float(crop.shape[0] - 1) + + (shear_y * centered_u + curve_y * (centered_u * centered_u - 0.125)) + * float(crop.shape[0]) + / 17.0 + ) + return cv2.remap( + crop, + map_x, + map_y, + interpolation=cv2.INTER_NEAREST, + borderMode=cv2.BORDER_CONSTANT, + borderValue=0, + ) + + +def _grid_fit_score(warped: np.ndarray, grid_size: int) -> float: + occupancy = cv2.resize( + (warped > 0).astype(np.float32), + (grid_size, grid_size), + interpolation=cv2.INTER_AREA, + ) + polar_score = float((np.abs(occupancy - 0.5) * 2.0).mean()) + mixed_cells = float(((occupancy > 0.08) & (occupancy < 0.70)).mean()) + strong_cells = float((occupancy > 0.20).mean()) + foreground_density = float(occupancy.mean()) + border_density = float( + ( + occupancy[0].mean() + + occupancy[-1].mean() + + occupancy[:, 0].mean() + + occupancy[:, -1].mean() + ) + * 0.25 + ) + expected_strong_cells = 0.76 + expected_density = 0.58 + return ( + polar_score + - 0.18 * mixed_cells + - 0.35 * abs(strong_cells - expected_strong_cells) + - 0.20 * abs(foreground_density - expected_density) + + 0.25 * border_density + ) + + +def _cell_ratio_features( + warped: np.ndarray, + grid_size: int, + cell_samples: int, +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + occupancy = np.zeros((grid_size, grid_size), dtype=np.float64) + center = np.zeros((grid_size, grid_size), dtype=np.float64) + sides = np.zeros((grid_size, grid_size, 4), dtype=np.float64) + center_margin = max(1, cell_samples // 4) + side_width = max(1, cell_samples // 3) + + for row in range(grid_size): + for col in range(grid_size): + y1 = row * cell_samples + y2 = y1 + cell_samples + x1 = col * cell_samples + x2 = x1 + cell_samples + cell = (warped[y1:y2, x1:x2] > 0).astype(np.float32) + occupancy[row, col] = float(cell.mean()) + center_cell = cell[ + center_margin : cell_samples - center_margin, + center_margin : cell_samples - center_margin, + ] + center[row, col] = float(center_cell.mean()) if center_cell.size else 0.0 + sides[row, col] = ( + float(cell[:, :side_width].mean()), + float(cell[:, -side_width:].mean()), + float(cell[:side_width, :].mean()), + float(cell[-side_width:, :].mean()), + ) + + return occupancy, center, sides + + +def _ratio_matrix( + warped: np.ndarray, + grid_size: int, + cell_samples: int, + threshold: float = 0.44, +) -> np.ndarray: + occupancy, center, sides = _cell_ratio_features( + warped, + grid_size=grid_size, + cell_samples=cell_samples, + ) + score = 0.65 * occupancy + 0.35 * center + matrix = (score >= threshold).astype(np.uint8) + matrix = _resolve_thick_stroke_overlaps(matrix, score, sides) + return matrix.astype(np.uint8) * 255 + + +def _resolve_thick_stroke_overlaps( + matrix: np.ndarray, + score: np.ndarray, + sides: np.ndarray, +) -> np.ndarray: + resolved = matrix.astype(bool).copy() + rows, cols = resolved.shape + + def active_neighbors(row: int, col: int) -> int: + total = 0 + for n_row, n_col in ( + (row - 1, col), + (row + 1, col), + (row, col - 1), + (row, col + 1), + ): + if 0 <= n_row < rows and 0 <= n_col < cols and resolved[n_row, n_col]: + total += 1 + return total + + def bridge_score(row: int, col: int) -> int: + bridge = 0 + if col > 0 and col + 1 < cols and resolved[row, col - 1] and resolved[row, col + 1]: + bridge += 1 + if row > 0 and row + 1 < rows and resolved[row - 1, col] and resolved[row + 1, col]: + bridge += 1 + return bridge + + for _iteration in range(rows * cols): + weakest: tuple[float, int, int] | None = None + for row in range(rows - 1): + for col in range(cols - 1): + if int(resolved[row : row + 2, col : col + 2].sum()) != 4: + continue + + for cell_row in (row, row + 1): + for cell_col in (col, col + 1): + side_imbalance = float( + sides[cell_row, cell_col].max() + - sides[cell_row, cell_col].min() + ) + support = ( + float(score[cell_row, cell_col]) + + 0.10 * active_neighbors(cell_row, cell_col) + + 0.25 * bridge_score(cell_row, cell_col) + - 0.05 * side_imbalance + ) + candidate = (support, cell_row, cell_col) + if weakest is None or candidate[0] < weakest[0]: + weakest = candidate + + if weakest is None: + break + _, row, col = weakest + resolved[row, col] = False + + return resolved.astype(np.uint8) + + +def count_2x2_blocks(matrix: np.ndarray) -> int: + image = (matrix > 0).astype(np.uint8) + total = 0 + for row in range(image.shape[0] - 1): + for col in range(image.shape[1] - 1): + total += int(image[row : row + 2, col : col + 2].sum() == 4) + return total + + +def matrix_map( + mask: np.ndarray, + grid_size: int, + triangle_threshold: float, + cell_samples: int, +) -> MatrixMapping: + height, width = mask.shape + warped, x_edges, y_edges, grid_overlay = _normalize_raw_strokes( + mask, + grid_size=grid_size, + cell_samples=cell_samples, + ) + matrix = _ratio_matrix( + warped, + grid_size=grid_size, + cell_samples=cell_samples, + ) + triangles = _detect_triangle_connections( + warped, + matrix, + cell_samples=cell_samples, + triangle_threshold=triangle_threshold, + ) + + rendered = _render_matrix(matrix, x_edges, y_edges, width, height, triangles) + return MatrixMapping( + matrix, + rendered, + x_edges, + y_edges, + triangles=triangles, + grid_overlay=grid_overlay, + ) def mask_to_display(mask: np.ndarray) -> np.ndarray: @@ -104,17 +982,20 @@ def save_comparison( path: Path, original_bgr: np.ndarray, cleaned_mask: np.ndarray, - pixelated_mask: np.ndarray, + grid_overlay: np.ndarray | None, + mapped_mask: np.ndarray, + mapped_title: str, ) -> None: original_rgb = cv2.cvtColor(original_bgr, cv2.COLOR_BGR2RGB) cleaned = mask_to_display(cleaned_mask) - pixelated = mask_to_display(pixelated_mask) + mapped = mask_to_display(mapped_mask) - fig, axes = plt.subplots(1, 3, figsize=(12, 4), constrained_layout=True) + fig, axes = plt.subplots(1, 4, figsize=(16, 4), constrained_layout=True) panels = [ ("Original", original_rgb, None), ("Cleaned binary", cleaned, "gray"), - ("Pixelated", pixelated, "gray"), + ("Adapted 17x17 grid", grid_overlay if grid_overlay is not None else cleaned, None), + (mapped_title, mapped, "gray"), ] for axis, (title, image, cmap) in zip(axes, panels): @@ -131,10 +1012,11 @@ def small_matrix_to_svg( original_width: int, original_height: int, title: str, + x_edges: np.ndarray, + y_edges: np.ndarray, + triangles: tuple[TriangleConnection, ...], ) -> str: rows, cols = small_mask.shape - cell_width = original_width / cols - cell_height = original_height / rows escaped_title = html.escape(title) parts = [ '', @@ -160,28 +1042,71 @@ def small_matrix_to_svg( 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 + x = int(x_edges[run_start]) + y = int(y_edges[row_index]) + width = int(x_edges[col_index]) - x + height = int(y_edges[row_index + 1]) - y parts.append( f' ' + f'width="{width:.4f}" height="{height:.4f}"/>' ) + for triangle in triangles: + x1 = int(x_edges[triangle.col]) + x2 = int(x_edges[triangle.col + 1]) + y1 = int(y_edges[triangle.row]) + y2 = int(y_edges[triangle.row + 1]) + points_by_corner = { + "tl": [(x1, y1), (x2, y1), (x1, y2)], + "tr": [(x2, y1), (x1, y1), (x2, y2)], + "br": [(x2, y2), (x2, y1), (x1, y2)], + "bl": [(x1, y2), (x1, y1), (x2, y2)], + } + points = " ".join( + f"{point_x:.4f},{point_y:.4f}" + for point_x, point_y in points_by_corner[triangle.corner] + ) + parts.append(f' ') + parts.extend([" ", "", ""]) return "\n".join(parts) +def matrix_to_text( + matrix: np.ndarray, + triangles: tuple[TriangleConnection, ...], +) -> str: + lines = [] + for row in matrix: + lines.append("".join("1" if value > 0 else "0" for value in row)) + + lines.append("") + lines.append(f"# 2x2_black_blocks: {count_2x2_blocks(matrix)}") + + if triangles: + lines.append("") + lines.append("# triangles: row,col,corner") + for triangle in triangles: + lines.append(f"{triangle.row},{triangle.col},{triangle.corner}") + + return "\n".join(lines) + "\n" + + def process_image( image_path: Path, processed_dir: Path, comparison_dir: Path, svg_dir: Path, factor: int, + grid_size: int, + cell_samples: int, open_kernel_size: int, close_kernel_size: int, median_size: int, -) -> None: + min_component_area: int, + mode: str, + triangle_threshold: float, +) -> int: original = read_image(image_path) gray = cv2.cvtColor(original, cv2.COLOR_BGR2GRAY) binary = binarize_foreground(gray) @@ -190,24 +1115,60 @@ def process_image( open_kernel_size=open_kernel_size, close_kernel_size=close_kernel_size, median_size=median_size, + min_component_area=min_component_area, + grid_size=grid_size, ) - small, pixelated = pixelate(cleaned, factor=factor) + if mode == "matrix": + mapping = matrix_map( + cleaned, + grid_size=grid_size, + triangle_threshold=triangle_threshold, + cell_samples=cell_samples, + ) + mapped_title = f"{grid_size}x{grid_size} matrix" + else: + mapping = pixelate(cleaned, factor=factor) + mapped_title = "Pixelated" 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)) + write_image( + processed_dir / f"{stem}_pixelated.png", mask_to_display(mapping.rendered_mask) + ) + write_image( + processed_dir / f"{stem}_matrix{mapping.matrix.shape[0]}.png", + mask_to_display(mapping.matrix), + ) + (processed_dir / f"{stem}_matrix{mapping.matrix.shape[0]}.txt").write_text( + matrix_to_text(mapping.matrix, mapping.triangles), + encoding="utf-8", + ) save_comparison( - comparison_dir / f"{stem}_comparison.png", original, cleaned, pixelated + comparison_dir / f"{stem}_comparison.png", + original, + cleaned, + mapping.grid_overlay, + mapping.rendered_mask, + mapped_title, ) height, width = gray.shape - svg = small_matrix_to_svg(small, width, height, stem) + svg = small_matrix_to_svg( + mapping.matrix, + width, + height, + stem, + mapping.x_edges, + mapping.y_edges, + mapping.triangles, + ) (svg_dir / f"{stem}_pixelated.svg").write_text(svg, encoding="utf-8") + return count_2x2_blocks(mapping.matrix) def main() -> None: parser = argparse.ArgumentParser( - description="Clean, pixelate, and vectorize scanned seal-script characters." + description="Clean, map, and vectorize scanned seal-script characters." ) parser.add_argument( "--input-dir", @@ -229,10 +1190,45 @@ def main() -> None: 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) + parser.add_argument( + "--factor", + type=int, + default=15, + help="Only used by --mode resize.", + ) + parser.add_argument( + "--grid-size", + type=int, + default=17, + help="Strict square matrix size used by --mode matrix.", + ) + parser.add_argument( + "--cell-samples", + type=int, + default=32, + help="Samples per grid cell while deskewing and classifying the source.", + ) + parser.add_argument( + "--mode", + choices=("matrix", "resize"), + default="matrix", + help="matrix maps to a strict grid; resize keeps the old mosaic-style downsample.", + ) + parser.add_argument( + "--triangle-threshold", + type=float, + default=0.18, + help="Minimum triangular evidence in an empty diagonal-connection cell.", + ) + parser.add_argument("--open-kernel-size", type=int, default=1) + parser.add_argument( + "--close-kernel-size", + type=int, + default=0, + help="Optional fixed close kernel for clean; 0 uses a grid-relative size.", + ) + parser.add_argument("--median-size", type=int, default=1) + parser.add_argument("--min-component-area", type=int, default=8) args = parser.parse_args() for directory in (args.processed_dir, args.comparison_dir, args.svg_dir): @@ -247,17 +1243,22 @@ def main() -> None: raise SystemExit(f"No images found in {args.input_dir}") for image_path in images: - process_image( + two_by_two_blocks = process_image( image_path=image_path, processed_dir=args.processed_dir, comparison_dir=args.comparison_dir, svg_dir=args.svg_dir, factor=args.factor, + grid_size=args.grid_size, + cell_samples=args.cell_samples, open_kernel_size=args.open_kernel_size, close_kernel_size=args.close_kernel_size, median_size=args.median_size, + min_component_area=args.min_component_area, + mode=args.mode, + triangle_threshold=args.triangle_threshold, ) - print(f"processed: {image_path.name}") + print(f"processed: {image_path.name} (2x2_black_blocks={two_by_two_blocks})") if __name__ == "__main__": diff --git a/PreProcessing/scripts/run_preprocessing.py b/PreProcessing/scripts/run_preprocessing.py new file mode 100644 index 0000000..0240e53 --- /dev/null +++ b/PreProcessing/scripts/run_preprocessing.py @@ -0,0 +1,143 @@ +from __future__ import annotations + +import argparse +from pathlib import Path + +import cv2 +import numpy as np + +from process_characters import ( + IMAGE_SUFFIXES, + binarize_foreground, + clean_mask, + matrix_map, + mask_to_display, + read_image, + write_image, +) + + +def matrix_to_text(matrix: np.ndarray) -> str: + return "\n".join( + "".join("1" if value > 0 else "0" for value in row) for row in matrix + ) + "\n" + + +def iter_image_paths(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, + cleaned_dir: Path, + matrix_dir: Path, + grid_size: int = 17, + cell_samples: int = 32, + open_kernel_size: int = 1, + close_kernel_size: int = 0, + median_size: int = 1, + min_component_area: int = 8, + triangle_threshold: float = 0.18, +) -> 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, + min_component_area=min_component_area, + grid_size=grid_size, + ) + mapping = matrix_map( + cleaned, + grid_size=grid_size, + triangle_threshold=triangle_threshold, + cell_samples=cell_samples, + ) + + stem = image_path.stem + write_image(cleaned_dir / f"{stem}_cleaned.png", mask_to_display(cleaned)) + (matrix_dir / f"{stem}_matrix.txt").write_text( + matrix_to_text(mapping.matrix), + encoding="utf-8", + ) + + +def process_directory( + input_dir: Path, + cleaned_dir: Path, + matrix_dir: Path, + grid_size: int = 17, + cell_samples: int = 32, + open_kernel_size: int = 1, + close_kernel_size: int = 0, + median_size: int = 1, + min_component_area: int = 8, + triangle_threshold: float = 0.18, +) -> int: + cleaned_dir.mkdir(parents=True, exist_ok=True) + matrix_dir.mkdir(parents=True, exist_ok=True) + + image_paths = iter_image_paths(input_dir) + if not image_paths: + raise SystemExit(f"No images found in {input_dir}") + + for image_path in image_paths: + process_image( + image_path=image_path, + cleaned_dir=cleaned_dir, + matrix_dir=matrix_dir, + grid_size=grid_size, + cell_samples=cell_samples, + open_kernel_size=open_kernel_size, + close_kernel_size=close_kernel_size, + median_size=median_size, + min_component_area=min_component_area, + triangle_threshold=triangle_threshold, + ) + print(f"processed: {image_path.name}") + + return len(image_paths) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Clean seal-script images and export 17x17 matrix text files." + ) + 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("--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) + parser.add_argument("--close-kernel-size", type=int, default=0) + parser.add_argument("--median-size", type=int, default=1) + parser.add_argument("--min-component-area", type=int, default=8) + parser.add_argument("--triangle-threshold", type=float, default=0.18) + return parser + + +def main() -> None: + args = build_parser().parse_args() + process_directory( + input_dir=args.input_dir, + cleaned_dir=args.cleaned_dir, + matrix_dir=args.matrix_dir, + grid_size=args.grid_size, + cell_samples=args.cell_samples, + open_kernel_size=args.open_kernel_size, + close_kernel_size=args.close_kernel_size, + median_size=args.median_size, + min_component_area=args.min_component_area, + triangle_threshold=args.triangle_threshold, + ) + + +if __name__ == "__main__": + main() diff --git a/PreProcessing/svg_characters/篆文字_page_017_text_17_pixelated.svg b/PreProcessing/svg_characters/篆文字_page_017_text_17_pixelated.svg index 4a91823..569dedb 100644 --- a/PreProcessing/svg_characters/篆文字_page_017_text_17_pixelated.svg +++ b/PreProcessing/svg_characters/篆文字_page_017_text_17_pixelated.svg @@ -3,141 +3,55 @@ 篆文字_page_017_text_17 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +