Compare commits
4 Commits
46c2ad1f36
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
629086e53c | ||
|
|
4e26779971 | ||
|
|
f2966f123d | ||
|
|
60dbc51403 |
9
PreProcessing_Common/.gitignore
vendored
Normal file
9
PreProcessing_Common/.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
original_characters/
|
||||||
|
cleaned_characters/
|
||||||
|
smoothed_characters/
|
||||||
|
processed_characters/
|
||||||
|
comparisons/
|
||||||
|
svg_characters/
|
||||||
|
vector_characters/
|
||||||
|
scripts/__pycache__/
|
||||||
|
*.pyc
|
||||||
153
PreProcessing_Common/README.md
Normal file
153
PreProcessing_Common/README.md
Normal file
@@ -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 保留的轮廓点越多,细节越多,文件也越大。
|
||||||
299
PreProcessing_Common/scripts/clean_characters.py
Normal file
299
PreProcessing_Common/scripts/clean_characters.py
Normal file
@@ -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()
|
||||||
164
PreProcessing_Common/scripts/make_clean_comparisons.py
Normal file
164
PreProcessing_Common/scripts/make_clean_comparisons.py
Normal file
@@ -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()
|
||||||
143
PreProcessing_Common/scripts/run_preprocessing.py
Normal file
143
PreProcessing_Common/scripts/run_preprocessing.py
Normal file
@@ -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()
|
||||||
213
PreProcessing_Common/scripts/vectorize_characters.py
Normal file
213
PreProcessing_Common/scripts/vectorize_characters.py
Normal file
@@ -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(
|
||||||
|
[
|
||||||
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||||
|
f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" '
|
||||||
|
f'viewBox="0 0 {width} {height}" role="img" aria-labelledby="title">',
|
||||||
|
f" <title>{escaped_title}</title>",
|
||||||
|
' <rect width="100%" height="100%" fill="white"/>',
|
||||||
|
f' <path d="{path_data}" fill="black" fill-rule="evenodd"/>',
|
||||||
|
"</svg>",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
15
PreProcessing_MatrixZhuan/.gitignore
vendored
Normal file
15
PreProcessing_MatrixZhuan/.gitignore
vendored
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# Generated preprocessing outputs
|
||||||
|
comparisons/
|
||||||
|
manual_diffs/
|
||||||
|
manual_matrices/
|
||||||
|
original_characters/
|
||||||
|
processed_characters/
|
||||||
|
svg_characters/
|
||||||
|
out_cleaned/
|
||||||
|
out_matrix/
|
||||||
|
out_svg/
|
||||||
|
|
||||||
|
# Local caches
|
||||||
|
.matplotlib-cache/
|
||||||
|
scripts/__pycache__/
|
||||||
|
*.pyc
|
||||||
52
PreProcessing_MatrixZhuan/README.md
Normal file
52
PreProcessing_MatrixZhuan/README.md
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# PreProcessing 预处理
|
||||||
|
|
||||||
|
这个目录包含17*17型特殊种类篆文字图像的预处理流程。日常批量处理请使用 `scripts/run_preprocessing.py`。
|
||||||
|
|
||||||
|
## 脚本用法
|
||||||
|
|
||||||
|
依赖见 `scripts/requirements.txt`。如果环境里还没有安装,可以先运行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/pip install -r PreProcessing_MatrixZhuan/scripts/requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
从项目根目录运行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.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
|
||||||
|
```
|
||||||
|
|
||||||
|
参数含义:
|
||||||
|
|
||||||
|
- `--input-dir`:原始图像输入文件夹
|
||||||
|
- `--cleaned-dir`:cleaned 二值图输出文件夹
|
||||||
|
- `--matrix-dir`:17x17 矩阵文本输出文件夹
|
||||||
|
- `--svg-dir`:矢量图 SVG 输出文件夹
|
||||||
|
|
||||||
|
输出命名会和输入文件名对齐:
|
||||||
|
|
||||||
|
- cleaned 图像:`原文件名_cleaned.png`
|
||||||
|
- 矩阵文本:`原文件名_matrix.txt`
|
||||||
|
- 矢量图:`原文件名.svg`
|
||||||
|
|
||||||
|
矩阵文本是纯 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_MatrixZhuan/.gitignore` 中忽略。
|
||||||
237
PreProcessing_MatrixZhuan/scripts/compare_manual_matrix17.py
Normal file
237
PreProcessing_MatrixZhuan/scripts/compare_manual_matrix17.py
Normal file
@@ -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()
|
||||||
364
PreProcessing_MatrixZhuan/scripts/draw_matrix17.py
Normal file
364
PreProcessing_MatrixZhuan/scripts/draw_matrix17.py
Normal file
@@ -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("<Button-1>", self._on_left_click)
|
||||||
|
self.grid_canvas.bind("<B1-Motion>", self._on_left_drag)
|
||||||
|
self.grid_canvas.bind("<Button-3>", self._on_right_click)
|
||||||
|
self.grid_canvas.bind("<B3-Motion>", 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("<Left>", lambda _event: self.prev_image())
|
||||||
|
self.root.bind("<Right>", 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()
|
||||||
1265
PreProcessing_MatrixZhuan/scripts/process_characters.py
Normal file
1265
PreProcessing_MatrixZhuan/scripts/process_characters.py
Normal file
File diff suppressed because it is too large
Load Diff
3
PreProcessing_MatrixZhuan/scripts/requirements.txt
Normal file
3
PreProcessing_MatrixZhuan/scripts/requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
opencv-python-headless
|
||||||
|
numpy
|
||||||
|
matplotlib
|
||||||
161
PreProcessing_MatrixZhuan/scripts/run_preprocessing.py
Normal file
161
PreProcessing_MatrixZhuan/scripts/run_preprocessing.py
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
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,
|
||||||
|
small_matrix_to_svg,
|
||||||
|
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,
|
||||||
|
svg_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",
|
||||||
|
)
|
||||||
|
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,
|
||||||
|
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)
|
||||||
|
svg_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,
|
||||||
|
svg_dir=svg_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("--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)
|
||||||
|
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,
|
||||||
|
svg_dir=args.svg_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()
|
||||||
Reference in New Issue
Block a user