CommonZhuan Preprocessing Scripts 1.0
This commit is contained in:
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()
|
||||
Reference in New Issue
Block a user