1266 lines
39 KiB
Python
1266 lines
39 KiB
Python
from __future__ import annotations
|
|
|
|
import argparse
|
|
import html
|
|
import os
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
import cv2
|
|
|
|
os.environ.setdefault(
|
|
"MPLCONFIGDIR",
|
|
str(Path(__file__).resolve().parents[1] / ".matplotlib-cache"),
|
|
)
|
|
|
|
import matplotlib
|
|
|
|
matplotlib.use("Agg")
|
|
|
|
import matplotlib.pyplot as plt
|
|
import numpy as np
|
|
|
|
|
|
IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".tif", ".tiff", ".bmp", ".webp"}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class TriangleConnection:
|
|
row: int
|
|
col: int
|
|
corner: str
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class MatrixMapping:
|
|
matrix: np.ndarray
|
|
rendered_mask: np.ndarray
|
|
x_edges: np.ndarray
|
|
y_edges: np.ndarray
|
|
triangles: tuple[TriangleConnection, ...] = ()
|
|
grid_overlay: np.ndarray | None = None
|
|
|
|
|
|
def read_image(path: Path) -> np.ndarray:
|
|
raw = np.fromfile(str(path), dtype=np.uint8)
|
|
image = cv2.imdecode(raw, cv2.IMREAD_COLOR)
|
|
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:
|
|
foreground = (gray < threshold).astype(np.uint8) * 255
|
|
else:
|
|
foreground = (gray > threshold).astype(np.uint8) * 255
|
|
|
|
return foreground
|
|
|
|
|
|
def fill_mask_holes(mask: np.ndarray) -> np.ndarray:
|
|
foreground = mask > 0
|
|
inverse = (~foreground).astype(np.uint8)
|
|
flood = np.zeros((mask.shape[0] + 2, mask.shape[1] + 2), dtype=np.uint8)
|
|
cv2.floodFill(inverse, flood, (0, 0), 2)
|
|
holes = inverse == 1
|
|
filled = foreground | holes
|
|
return filled.astype(np.uint8) * 255
|
|
|
|
|
|
def fill_small_mask_holes(mask: np.ndarray, max_area: int) -> np.ndarray:
|
|
if max_area <= 0:
|
|
return mask
|
|
|
|
foreground = mask > 0
|
|
inverse = (~foreground).astype(np.uint8)
|
|
flood = np.zeros((mask.shape[0] + 2, mask.shape[1] + 2), dtype=np.uint8)
|
|
cv2.floodFill(inverse, flood, (0, 0), 2)
|
|
holes = (flood == 1).astype(np.uint8)
|
|
num_labels, labels, stats, _ = cv2.connectedComponentsWithStats(
|
|
holes, connectivity=8
|
|
)
|
|
|
|
filled = foreground.copy()
|
|
for label in range(1, num_labels):
|
|
area = int(stats[label, cv2.CC_STAT_AREA])
|
|
if area <= max_area:
|
|
filled[labels == label] = True
|
|
|
|
return filled.astype(np.uint8) * 255
|
|
|
|
|
|
def fill_small_background_pockets(
|
|
mask: np.ndarray,
|
|
max_area: int,
|
|
channel_size: int,
|
|
) -> np.ndarray:
|
|
if max_area <= 0 or channel_size <= 1:
|
|
return fill_small_mask_holes(mask, max_area)
|
|
|
|
if channel_size % 2 == 0:
|
|
channel_size += 1
|
|
|
|
kernel = cv2.getStructuringElement(
|
|
cv2.MORPH_ELLIPSE, (channel_size, channel_size)
|
|
)
|
|
expanded = cv2.dilate(mask, kernel)
|
|
external = (expanded == 0).astype(np.uint8)
|
|
flood = np.zeros((mask.shape[0] + 2, mask.shape[1] + 2), dtype=np.uint8)
|
|
cv2.floodFill(external, flood, (0, 0), 2)
|
|
|
|
pockets = ((mask == 0) & (external != 2)).astype(np.uint8)
|
|
num_labels, labels, stats, _ = cv2.connectedComponentsWithStats(
|
|
pockets, connectivity=8
|
|
)
|
|
|
|
filled = mask > 0
|
|
for label in range(1, num_labels):
|
|
area = int(stats[label, cv2.CC_STAT_AREA])
|
|
if area <= max_area:
|
|
filled[labels == label] = True
|
|
|
|
return filled.astype(np.uint8) * 255
|
|
|
|
|
|
def remove_small_components(mask: np.ndarray, min_area: int) -> np.ndarray:
|
|
if min_area <= 0:
|
|
return mask
|
|
|
|
num_labels, labels, stats, _ = cv2.connectedComponentsWithStats(
|
|
mask, connectivity=8
|
|
)
|
|
cleaned = np.zeros_like(mask)
|
|
for label in range(1, num_labels):
|
|
if int(stats[label, cv2.CC_STAT_AREA]) >= min_area:
|
|
cleaned[labels == label] = 255
|
|
|
|
return cleaned
|
|
|
|
|
|
def smooth_binary_edges(mask: np.ndarray, iterations: int = 1) -> np.ndarray:
|
|
foreground = mask > 0
|
|
kernel = np.ones((3, 3), dtype=np.uint8)
|
|
|
|
for _ in range(iterations):
|
|
neighbors = cv2.filter2D(
|
|
foreground.astype(np.uint8),
|
|
ddepth=-1,
|
|
kernel=kernel,
|
|
borderType=cv2.BORDER_CONSTANT,
|
|
)
|
|
foreground = (foreground & (neighbors >= 3)) | (~foreground & (neighbors >= 7))
|
|
|
|
return foreground.astype(np.uint8) * 255
|
|
|
|
|
|
def clean_mask(
|
|
mask: np.ndarray,
|
|
open_kernel_size: int,
|
|
close_kernel_size: int,
|
|
median_size: int,
|
|
min_component_area: int,
|
|
grid_size: int = 17,
|
|
) -> np.ndarray:
|
|
if median_size > 1:
|
|
if median_size % 2 == 0:
|
|
median_size += 1
|
|
mask = cv2.medianBlur(mask, median_size)
|
|
|
|
cleaned = mask
|
|
if open_kernel_size > 1:
|
|
open_kernel = cv2.getStructuringElement(
|
|
cv2.MORPH_RECT, (open_kernel_size, open_kernel_size)
|
|
)
|
|
cleaned = cv2.morphologyEx(cleaned, cv2.MORPH_OPEN, open_kernel)
|
|
|
|
cleaned = remove_small_components(cleaned, min_component_area)
|
|
x1, y1, x2, y2 = _foreground_bbox(cleaned)
|
|
cell_size = max(4.0, min(float(x2 - x1), float(y2 - y1)) / float(grid_size))
|
|
burr_size = min(5, max(3, int(round(cell_size * 0.12)) | 1))
|
|
line_length = max(3, int(round(cell_size * 0.64)))
|
|
pocket_size = min(5, max(3, int(round(cell_size * 0.10)) | 1))
|
|
gap_fill_length = min(7, max(3, int(round(cell_size * 0.18)) | 1))
|
|
max_hole_area = max(4, int(round(cell_size * cell_size * 0.35)))
|
|
|
|
burr_kernel = cv2.getStructuringElement(
|
|
cv2.MORPH_RECT, (burr_size, burr_size)
|
|
)
|
|
cleaned = cv2.morphologyEx(cleaned, cv2.MORPH_OPEN, burr_kernel)
|
|
cleaned = remove_small_components(cleaned, min_component_area)
|
|
|
|
horizontal_kernel = cv2.getStructuringElement(
|
|
cv2.MORPH_RECT, (line_length, 1)
|
|
)
|
|
vertical_kernel = cv2.getStructuringElement(
|
|
cv2.MORPH_RECT, (1, line_length)
|
|
)
|
|
horizontal = cv2.morphologyEx(cleaned, cv2.MORPH_OPEN, horizontal_kernel)
|
|
vertical = cv2.morphologyEx(cleaned, cv2.MORPH_OPEN, vertical_kernel)
|
|
cleaned = cv2.bitwise_or(horizontal, vertical)
|
|
|
|
horizontal_gap_kernel = cv2.getStructuringElement(
|
|
cv2.MORPH_RECT, (gap_fill_length, 1)
|
|
)
|
|
vertical_gap_kernel = cv2.getStructuringElement(
|
|
cv2.MORPH_RECT, (1, gap_fill_length)
|
|
)
|
|
horizontal_filled = cv2.morphologyEx(
|
|
cleaned, cv2.MORPH_CLOSE, horizontal_gap_kernel
|
|
)
|
|
vertical_filled = cv2.morphologyEx(cleaned, cv2.MORPH_CLOSE, vertical_gap_kernel)
|
|
cleaned = cv2.bitwise_or(horizontal_filled, vertical_filled)
|
|
|
|
cleaned = fill_small_background_pockets(
|
|
cleaned,
|
|
max_area=max_hole_area,
|
|
channel_size=pocket_size,
|
|
)
|
|
smooth_size = close_kernel_size if close_kernel_size > 1 else 3
|
|
smooth_kernel = cv2.getStructuringElement(
|
|
cv2.MORPH_CROSS, (smooth_size, smooth_size)
|
|
)
|
|
cleaned = cv2.morphologyEx(cleaned, cv2.MORPH_CLOSE, smooth_kernel)
|
|
cleaned = fill_small_background_pockets(
|
|
cleaned,
|
|
max_area=max_hole_area,
|
|
channel_size=pocket_size,
|
|
)
|
|
cleaned = smooth_binary_edges(cleaned, iterations=1)
|
|
|
|
return remove_small_components(cleaned, min_component_area)
|
|
|
|
|
|
def _render_matrix(
|
|
matrix: np.ndarray,
|
|
x_edges: np.ndarray,
|
|
y_edges: np.ndarray,
|
|
width: int,
|
|
height: int,
|
|
triangles: tuple[TriangleConnection, ...] = (),
|
|
) -> np.ndarray:
|
|
rendered = np.zeros((height, width), dtype=np.uint8)
|
|
|
|
rows, cols = matrix.shape
|
|
for row_index in range(rows):
|
|
y1 = int(y_edges[row_index])
|
|
y2 = int(y_edges[row_index + 1])
|
|
if y2 <= y1:
|
|
continue
|
|
|
|
for col_index in range(cols):
|
|
if matrix[row_index, col_index] == 0:
|
|
continue
|
|
|
|
x1 = int(x_edges[col_index])
|
|
x2 = int(x_edges[col_index + 1])
|
|
if x2 > x1:
|
|
rendered[y1:y2, x1:x2] = 255
|
|
|
|
for triangle in triangles:
|
|
x1 = int(x_edges[triangle.col])
|
|
x2 = int(x_edges[triangle.col + 1])
|
|
y1 = int(y_edges[triangle.row])
|
|
y2 = int(y_edges[triangle.row + 1])
|
|
points_by_corner = {
|
|
"tl": [(x1, y1), (x2, y1), (x1, y2)],
|
|
"tr": [(x2, y1), (x1, y1), (x2, y2)],
|
|
"br": [(x2, y2), (x2, y1), (x1, y2)],
|
|
"bl": [(x1, y2), (x1, y1), (x2, y2)],
|
|
}
|
|
points = np.array(points_by_corner[triangle.corner], dtype=np.int32)
|
|
cv2.fillConvexPoly(rendered, points, 255)
|
|
|
|
return rendered
|
|
|
|
|
|
def pixelate(mask: np.ndarray, factor: int) -> MatrixMapping:
|
|
height, width = mask.shape
|
|
small_width = max(1, round(width / factor))
|
|
small_height = max(1, round(height / factor))
|
|
sampled = cv2.resize(
|
|
mask, (small_width, small_height), interpolation=cv2.INTER_AREA
|
|
)
|
|
small = np.where(sampled >= 96, 255, 0).astype(np.uint8)
|
|
x_edges = np.rint(np.linspace(0, width, small_width + 1)).astype(int)
|
|
y_edges = np.rint(np.linspace(0, height, small_height + 1)).astype(int)
|
|
large = cv2.resize(small, (width, height), interpolation=cv2.INTER_NEAREST)
|
|
return MatrixMapping(small, large, x_edges, y_edges)
|
|
|
|
|
|
def _order_quad_points(points: np.ndarray) -> np.ndarray:
|
|
points = points.astype(np.float32)
|
|
sums = points.sum(axis=1)
|
|
diffs = np.diff(points, axis=1).reshape(-1)
|
|
ordered = np.zeros((4, 2), dtype=np.float32)
|
|
ordered[0] = points[int(np.argmin(sums))]
|
|
ordered[2] = points[int(np.argmax(sums))]
|
|
ordered[1] = points[int(np.argmin(diffs))]
|
|
ordered[3] = points[int(np.argmax(diffs))]
|
|
return ordered
|
|
|
|
|
|
def _foreground_quad(mask: np.ndarray) -> np.ndarray:
|
|
points = cv2.findNonZero(mask)
|
|
if points is None:
|
|
height, width = mask.shape
|
|
return np.array(
|
|
[[0, 0], [width - 1, 0], [width - 1, height - 1], [0, height - 1]],
|
|
dtype=np.float32,
|
|
)
|
|
|
|
contour_points = points.reshape(-1, 2)
|
|
hull = cv2.convexHull(contour_points)
|
|
perimeter = cv2.arcLength(hull, True)
|
|
approx = cv2.approxPolyDP(hull, 0.035 * perimeter, True)
|
|
if len(approx) == 4:
|
|
return _order_quad_points(approx.reshape(4, 2))
|
|
|
|
rect = cv2.minAreaRect(contour_points)
|
|
return _order_quad_points(cv2.boxPoints(rect))
|
|
|
|
|
|
def _quad_bbox_square_edges(
|
|
quad: np.ndarray,
|
|
canvas_width: int,
|
|
canvas_height: int,
|
|
grid_size: int,
|
|
) -> tuple[np.ndarray, np.ndarray]:
|
|
x_min, y_min = np.min(quad, axis=0)
|
|
x_max, y_max = np.max(quad, axis=0)
|
|
center_x = float((x_min + x_max) * 0.5)
|
|
center_y = float((y_min + y_max) * 0.5)
|
|
side = max(float(x_max - x_min), float(y_max - y_min), 1.0)
|
|
|
|
left = center_x - side * 0.5
|
|
top = center_y - side * 0.5
|
|
if left < 0:
|
|
left = 0.0
|
|
if top < 0:
|
|
top = 0.0
|
|
if left + side > canvas_width:
|
|
left = max(0.0, float(canvas_width) - side)
|
|
if top + side > canvas_height:
|
|
top = max(0.0, float(canvas_height) - side)
|
|
|
|
right = min(float(canvas_width), left + side)
|
|
bottom = min(float(canvas_height), top + side)
|
|
x_edges = np.rint(np.linspace(left, right, grid_size + 1)).astype(int)
|
|
y_edges = np.rint(np.linspace(top, bottom, grid_size + 1)).astype(int)
|
|
return x_edges, y_edges
|
|
|
|
|
|
def _warp_to_grid(mask: np.ndarray, grid_size: int, cell_samples: int) -> np.ndarray:
|
|
quad = _foreground_quad(mask)
|
|
side = int(grid_size * cell_samples)
|
|
destination = np.array(
|
|
[[0, 0], [side - 1, 0], [side - 1, side - 1], [0, side - 1]],
|
|
dtype=np.float32,
|
|
)
|
|
transform = cv2.getPerspectiveTransform(quad, destination)
|
|
return cv2.warpPerspective(
|
|
mask,
|
|
transform,
|
|
(side, side),
|
|
flags=cv2.INTER_NEAREST,
|
|
borderMode=cv2.BORDER_CONSTANT,
|
|
borderValue=0,
|
|
)
|
|
|
|
|
|
def _foreground_bbox(mask: np.ndarray) -> tuple[int, int, int, int]:
|
|
ys, xs = np.where(mask > 0)
|
|
if xs.size == 0:
|
|
height, width = mask.shape
|
|
return 0, 0, width, height
|
|
|
|
return int(xs.min()), int(ys.min()), int(xs.max() + 1), int(ys.max() + 1)
|
|
|
|
|
|
def _rotate_mask(mask: np.ndarray, angle: float) -> np.ndarray:
|
|
if abs(angle) < 1e-6:
|
|
return mask
|
|
|
|
height, width = mask.shape
|
|
center = (width / 2.0, height / 2.0)
|
|
transform = cv2.getRotationMatrix2D(center, angle, 1.0)
|
|
return cv2.warpAffine(
|
|
mask,
|
|
transform,
|
|
(width, height),
|
|
flags=cv2.INTER_NEAREST,
|
|
borderMode=cv2.BORDER_CONSTANT,
|
|
borderValue=0,
|
|
)
|
|
|
|
|
|
def _projection_score(mask: np.ndarray) -> float:
|
|
foreground = mask > 0
|
|
row_sums = foreground.sum(axis=1).astype(np.float64)
|
|
col_sums = foreground.sum(axis=0).astype(np.float64)
|
|
return float(np.square(row_sums).sum() + np.square(col_sums).sum())
|
|
|
|
|
|
def _estimate_skew_angle(mask: np.ndarray, max_angle: float = 4.0) -> float:
|
|
best_angle = 0.0
|
|
best_score = _projection_score(mask)
|
|
|
|
for angle in np.arange(-max_angle, max_angle + 0.001, 0.25):
|
|
if abs(float(angle)) < 1e-6:
|
|
continue
|
|
rotated = _rotate_mask(mask, float(angle))
|
|
score = _projection_score(rotated)
|
|
if score > best_score:
|
|
best_score = score
|
|
best_angle = float(angle)
|
|
|
|
return best_angle
|
|
|
|
|
|
def _solidify_hollow_strokes(mask: np.ndarray, grid_size: int) -> np.ndarray:
|
|
x1, y1, x2, y2 = _foreground_bbox(mask)
|
|
cell_width = max(2.0, float(x2 - x1) / float(grid_size))
|
|
cell_height = max(2.0, float(y2 - y1) / float(grid_size))
|
|
cell_size = max(2.0, min(cell_width, cell_height))
|
|
|
|
long_size = max(3, int(round(cell_size * 0.72)) | 1)
|
|
short_size = max(3, int(round(cell_size * 0.14)) | 1)
|
|
|
|
vertical_bridge = cv2.getStructuringElement(
|
|
cv2.MORPH_RECT, (short_size, long_size)
|
|
)
|
|
horizontal_bridge = cv2.getStructuringElement(
|
|
cv2.MORPH_RECT, (long_size, short_size)
|
|
)
|
|
square_cleanup = cv2.getStructuringElement(
|
|
cv2.MORPH_RECT, (max(3, short_size), max(3, short_size))
|
|
)
|
|
|
|
horizontal_strokes = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, vertical_bridge)
|
|
vertical_strokes = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, horizontal_bridge)
|
|
solid = cv2.bitwise_or(horizontal_strokes, vertical_strokes)
|
|
solid = cv2.morphologyEx(solid, cv2.MORPH_CLOSE, square_cleanup)
|
|
return solid
|
|
|
|
|
|
def _triangle_corner_score(cell: np.ndarray, corner: str) -> float:
|
|
size = cell.shape[0]
|
|
yy, xx = np.indices(cell.shape)
|
|
if corner == "tl":
|
|
region = (xx + yy) <= size
|
|
elif corner == "tr":
|
|
region = ((size - 1 - xx) + yy) <= size
|
|
elif corner == "br":
|
|
region = ((size - 1 - xx) + (size - 1 - yy)) <= size
|
|
else:
|
|
region = (xx + (size - 1 - yy)) <= size
|
|
|
|
foreground = cell > 0
|
|
inside = float(foreground[region].mean()) if np.any(region) else 0.0
|
|
outside = float(foreground[~region].mean()) if np.any(~region) else 0.0
|
|
return inside - outside
|
|
|
|
|
|
def _detect_triangle_connections(
|
|
warped: np.ndarray,
|
|
matrix: np.ndarray,
|
|
cell_samples: int,
|
|
triangle_threshold: float,
|
|
) -> tuple[TriangleConnection, ...]:
|
|
grid_size = matrix.shape[0]
|
|
triangles: list[TriangleConnection] = []
|
|
|
|
def add_if_supported(row: int, col: int, corner: str) -> None:
|
|
y1 = row * cell_samples
|
|
y2 = y1 + cell_samples
|
|
x1 = col * cell_samples
|
|
x2 = x1 + cell_samples
|
|
cell = warped[y1:y2, x1:x2]
|
|
coverage = float((cell > 0).mean())
|
|
score = _triangle_corner_score(cell, corner)
|
|
if coverage >= triangle_threshold and score >= triangle_threshold:
|
|
triangles.append(TriangleConnection(row, col, corner))
|
|
|
|
for row in range(grid_size - 1):
|
|
for col in range(grid_size - 1):
|
|
nw = matrix[row, col] > 0
|
|
ne = matrix[row, col + 1] > 0
|
|
sw = matrix[row + 1, col] > 0
|
|
se = matrix[row + 1, col + 1] > 0
|
|
|
|
if nw and se and not ne and not sw:
|
|
add_if_supported(row, col + 1, "bl")
|
|
add_if_supported(row + 1, col, "tr")
|
|
elif ne and sw and not nw and not se:
|
|
add_if_supported(row, col, "br")
|
|
add_if_supported(row + 1, col + 1, "tl")
|
|
|
|
return tuple(triangles)
|
|
|
|
|
|
def _normalize_raw_strokes(
|
|
mask: np.ndarray,
|
|
grid_size: int,
|
|
cell_samples: int,
|
|
) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
|
|
angle = _estimate_skew_angle(mask)
|
|
deskewed = _rotate_mask(mask, angle)
|
|
x1, y1, x2, y2 = _foreground_bbox(deskewed)
|
|
side = int(grid_size * cell_samples)
|
|
candidates: list[tuple[float, np.ndarray, tuple[float, ...]]] = []
|
|
scale_values = (0.92, 0.94, 0.96, 0.98, 1.0, 1.02, 1.04, 1.06)
|
|
shift_x_values = (
|
|
-0.5,
|
|
-0.4,
|
|
-0.3,
|
|
-0.2,
|
|
-0.1,
|
|
0.0,
|
|
0.1,
|
|
0.2,
|
|
0.3,
|
|
0.4,
|
|
0.5,
|
|
)
|
|
shift_y_values = (-0.3, -0.2, -0.1, 0.0, 0.1, 0.2, 0.3)
|
|
for scale_x in scale_values:
|
|
for scale_y in scale_values:
|
|
for shift_x in shift_x_values:
|
|
for shift_y in shift_y_values:
|
|
warped = _warp_bbox_candidate(
|
|
deskewed,
|
|
x1,
|
|
y1,
|
|
x2,
|
|
y2,
|
|
scale_x,
|
|
scale_y,
|
|
shift_x,
|
|
shift_y,
|
|
side,
|
|
)
|
|
candidates.append(
|
|
(
|
|
_grid_fit_score(warped, grid_size),
|
|
warped,
|
|
(
|
|
scale_x,
|
|
scale_y,
|
|
shift_x,
|
|
shift_y,
|
|
0.0,
|
|
0.0,
|
|
0.0,
|
|
0.0,
|
|
),
|
|
)
|
|
)
|
|
|
|
for _score, _warped, params in sorted(
|
|
candidates,
|
|
reverse=True,
|
|
key=lambda item: item[0],
|
|
)[:36]:
|
|
(
|
|
scale_x,
|
|
scale_y,
|
|
shift_x,
|
|
shift_y,
|
|
_shear_x,
|
|
_shear_y,
|
|
_curve_x,
|
|
_curve_y,
|
|
) = params
|
|
for shear_x in (-0.8, -0.4, 0.0, 0.4, 0.8):
|
|
for shear_y in (-0.6, -0.3, 0.0, 0.3, 0.6):
|
|
warped = _warp_bbox_candidate(
|
|
deskewed,
|
|
x1,
|
|
y1,
|
|
x2,
|
|
y2,
|
|
scale_x,
|
|
scale_y,
|
|
shift_x,
|
|
shift_y,
|
|
side,
|
|
shear_x=shear_x,
|
|
shear_y=shear_y,
|
|
)
|
|
candidates.append(
|
|
(
|
|
_grid_fit_score(warped, grid_size),
|
|
warped,
|
|
(
|
|
scale_x,
|
|
scale_y,
|
|
shift_x,
|
|
shift_y,
|
|
shear_x,
|
|
shear_y,
|
|
0.0,
|
|
0.0,
|
|
),
|
|
)
|
|
)
|
|
|
|
for _score, _warped, params in sorted(
|
|
candidates,
|
|
reverse=True,
|
|
key=lambda item: item[0],
|
|
)[:28]:
|
|
(
|
|
scale_x,
|
|
scale_y,
|
|
shift_x,
|
|
shift_y,
|
|
shear_x,
|
|
shear_y,
|
|
_curve_x,
|
|
_curve_y,
|
|
) = params
|
|
for curve_x, curve_y in (
|
|
(-0.65, 0.0),
|
|
(-0.35, 0.0),
|
|
(0.35, 0.0),
|
|
(0.65, 0.0),
|
|
(0.0, -0.65),
|
|
(0.0, -0.35),
|
|
(0.0, 0.35),
|
|
(0.0, 0.65),
|
|
(-0.35, -0.35),
|
|
(-0.35, 0.35),
|
|
(0.35, -0.35),
|
|
(0.35, 0.35),
|
|
):
|
|
warped = _warp_bbox_candidate(
|
|
deskewed,
|
|
x1,
|
|
y1,
|
|
x2,
|
|
y2,
|
|
scale_x,
|
|
scale_y,
|
|
shift_x,
|
|
shift_y,
|
|
side,
|
|
shear_x=shear_x,
|
|
shear_y=shear_y,
|
|
curve_x=curve_x,
|
|
curve_y=curve_y,
|
|
)
|
|
candidates.append(
|
|
(
|
|
_grid_fit_score(warped, grid_size),
|
|
warped,
|
|
(
|
|
scale_x,
|
|
scale_y,
|
|
shift_x,
|
|
shift_y,
|
|
shear_x,
|
|
shear_y,
|
|
curve_x,
|
|
curve_y,
|
|
),
|
|
)
|
|
)
|
|
|
|
if candidates:
|
|
warped = max(candidates, key=lambda item: item[0])[1]
|
|
else:
|
|
crop = deskewed[y1:y2, x1:x2]
|
|
if crop.size == 0:
|
|
crop = deskewed
|
|
warped = cv2.resize(crop, (side, side), interpolation=cv2.INTER_NEAREST)
|
|
|
|
render_x1, render_y1, render_x2, render_y2 = _foreground_bbox(mask)
|
|
x_edges = np.rint(np.linspace(render_x1, render_x2, grid_size + 1)).astype(int)
|
|
y_edges = np.rint(np.linspace(render_y1, render_y2, grid_size + 1)).astype(int)
|
|
return warped, x_edges, y_edges, _grid_overlay_preview(warped, grid_size)
|
|
|
|
|
|
def _grid_overlay_preview(warped: np.ndarray, grid_size: int) -> np.ndarray:
|
|
preview = cv2.cvtColor(mask_to_display(warped), cv2.COLOR_GRAY2RGB)
|
|
height, width = warped.shape
|
|
for index in range(grid_size + 1):
|
|
x = int(round(index * width / grid_size))
|
|
y = int(round(index * height / grid_size))
|
|
thickness = 2 if index in (0, grid_size) else 1
|
|
cv2.line(preview, (x, 0), (x, height - 1), (220, 40, 40), thickness)
|
|
cv2.line(preview, (0, y), (width - 1, y), (220, 40, 40), thickness)
|
|
return preview
|
|
|
|
|
|
def _warp_bbox_candidate(
|
|
mask: np.ndarray,
|
|
x1: int,
|
|
y1: int,
|
|
x2: int,
|
|
y2: int,
|
|
scale_x: float,
|
|
scale_y: float,
|
|
shift_x: float,
|
|
shift_y: float,
|
|
side: int,
|
|
shear_x: float = 0.0,
|
|
shear_y: float = 0.0,
|
|
curve_x: float = 0.0,
|
|
curve_y: float = 0.0,
|
|
) -> np.ndarray:
|
|
width = max(1.0, float(x2 - x1))
|
|
height = max(1.0, float(y2 - y1))
|
|
center_x = ((x1 + x2) * 0.5) + shift_x * width / 17.0
|
|
center_y = ((y1 + y2) * 0.5) + shift_y * height / 17.0
|
|
crop_width = width * scale_x
|
|
crop_height = height * scale_y
|
|
left = int(round(center_x - crop_width * 0.5))
|
|
right = int(round(center_x + crop_width * 0.5))
|
|
top = int(round(center_y - crop_height * 0.5))
|
|
bottom = int(round(center_y + crop_height * 0.5))
|
|
|
|
crop = np.zeros((max(1, bottom - top), max(1, right - left)), dtype=np.uint8)
|
|
source_left = max(0, left)
|
|
source_right = min(mask.shape[1], right)
|
|
source_top = max(0, top)
|
|
source_bottom = min(mask.shape[0], bottom)
|
|
if source_right > source_left and source_bottom > source_top:
|
|
crop[
|
|
source_top - top : source_bottom - top,
|
|
source_left - left : source_right - left,
|
|
] = mask[source_top:source_bottom, source_left:source_right]
|
|
|
|
if (
|
|
abs(shear_x) < 1e-9
|
|
and abs(shear_y) < 1e-9
|
|
and abs(curve_x) < 1e-9
|
|
and abs(curve_y) < 1e-9
|
|
):
|
|
return cv2.resize(crop, (side, side), interpolation=cv2.INTER_NEAREST)
|
|
|
|
yy, xx = np.indices((side, side), dtype=np.float32)
|
|
u = xx / max(1.0, float(side - 1))
|
|
v = yy / max(1.0, float(side - 1))
|
|
centered_u = u - 0.5
|
|
centered_v = v - 0.5
|
|
map_x = (
|
|
u * float(crop.shape[1] - 1)
|
|
+ (shear_x * centered_v + curve_x * (centered_v * centered_v - 0.125))
|
|
* float(crop.shape[1])
|
|
/ 17.0
|
|
)
|
|
map_y = (
|
|
v * float(crop.shape[0] - 1)
|
|
+ (shear_y * centered_u + curve_y * (centered_u * centered_u - 0.125))
|
|
* float(crop.shape[0])
|
|
/ 17.0
|
|
)
|
|
return cv2.remap(
|
|
crop,
|
|
map_x,
|
|
map_y,
|
|
interpolation=cv2.INTER_NEAREST,
|
|
borderMode=cv2.BORDER_CONSTANT,
|
|
borderValue=0,
|
|
)
|
|
|
|
|
|
def _grid_fit_score(warped: np.ndarray, grid_size: int) -> float:
|
|
occupancy = cv2.resize(
|
|
(warped > 0).astype(np.float32),
|
|
(grid_size, grid_size),
|
|
interpolation=cv2.INTER_AREA,
|
|
)
|
|
polar_score = float((np.abs(occupancy - 0.5) * 2.0).mean())
|
|
mixed_cells = float(((occupancy > 0.08) & (occupancy < 0.70)).mean())
|
|
strong_cells = float((occupancy > 0.20).mean())
|
|
foreground_density = float(occupancy.mean())
|
|
border_density = float(
|
|
(
|
|
occupancy[0].mean()
|
|
+ occupancy[-1].mean()
|
|
+ occupancy[:, 0].mean()
|
|
+ occupancy[:, -1].mean()
|
|
)
|
|
* 0.25
|
|
)
|
|
expected_strong_cells = 0.76
|
|
expected_density = 0.58
|
|
return (
|
|
polar_score
|
|
- 0.18 * mixed_cells
|
|
- 0.35 * abs(strong_cells - expected_strong_cells)
|
|
- 0.20 * abs(foreground_density - expected_density)
|
|
+ 0.25 * border_density
|
|
)
|
|
|
|
|
|
def _cell_ratio_features(
|
|
warped: np.ndarray,
|
|
grid_size: int,
|
|
cell_samples: int,
|
|
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
occupancy = np.zeros((grid_size, grid_size), dtype=np.float64)
|
|
center = np.zeros((grid_size, grid_size), dtype=np.float64)
|
|
sides = np.zeros((grid_size, grid_size, 4), dtype=np.float64)
|
|
center_margin = max(1, cell_samples // 4)
|
|
side_width = max(1, cell_samples // 3)
|
|
|
|
for row in range(grid_size):
|
|
for col in range(grid_size):
|
|
y1 = row * cell_samples
|
|
y2 = y1 + cell_samples
|
|
x1 = col * cell_samples
|
|
x2 = x1 + cell_samples
|
|
cell = (warped[y1:y2, x1:x2] > 0).astype(np.float32)
|
|
occupancy[row, col] = float(cell.mean())
|
|
center_cell = cell[
|
|
center_margin : cell_samples - center_margin,
|
|
center_margin : cell_samples - center_margin,
|
|
]
|
|
center[row, col] = float(center_cell.mean()) if center_cell.size else 0.0
|
|
sides[row, col] = (
|
|
float(cell[:, :side_width].mean()),
|
|
float(cell[:, -side_width:].mean()),
|
|
float(cell[:side_width, :].mean()),
|
|
float(cell[-side_width:, :].mean()),
|
|
)
|
|
|
|
return occupancy, center, sides
|
|
|
|
|
|
def _ratio_matrix(
|
|
warped: np.ndarray,
|
|
grid_size: int,
|
|
cell_samples: int,
|
|
threshold: float = 0.44,
|
|
) -> np.ndarray:
|
|
occupancy, center, sides = _cell_ratio_features(
|
|
warped,
|
|
grid_size=grid_size,
|
|
cell_samples=cell_samples,
|
|
)
|
|
score = 0.65 * occupancy + 0.35 * center
|
|
matrix = (score >= threshold).astype(np.uint8)
|
|
matrix = _resolve_thick_stroke_overlaps(matrix, score, sides)
|
|
return matrix.astype(np.uint8) * 255
|
|
|
|
|
|
def _resolve_thick_stroke_overlaps(
|
|
matrix: np.ndarray,
|
|
score: np.ndarray,
|
|
sides: np.ndarray,
|
|
) -> np.ndarray:
|
|
resolved = matrix.astype(bool).copy()
|
|
rows, cols = resolved.shape
|
|
|
|
def active_neighbors(row: int, col: int) -> int:
|
|
total = 0
|
|
for n_row, n_col in (
|
|
(row - 1, col),
|
|
(row + 1, col),
|
|
(row, col - 1),
|
|
(row, col + 1),
|
|
):
|
|
if 0 <= n_row < rows and 0 <= n_col < cols and resolved[n_row, n_col]:
|
|
total += 1
|
|
return total
|
|
|
|
def bridge_score(row: int, col: int) -> int:
|
|
bridge = 0
|
|
if col > 0 and col + 1 < cols and resolved[row, col - 1] and resolved[row, col + 1]:
|
|
bridge += 1
|
|
if row > 0 and row + 1 < rows and resolved[row - 1, col] and resolved[row + 1, col]:
|
|
bridge += 1
|
|
return bridge
|
|
|
|
for _iteration in range(rows * cols):
|
|
weakest: tuple[float, int, int] | None = None
|
|
for row in range(rows - 1):
|
|
for col in range(cols - 1):
|
|
if int(resolved[row : row + 2, col : col + 2].sum()) != 4:
|
|
continue
|
|
|
|
for cell_row in (row, row + 1):
|
|
for cell_col in (col, col + 1):
|
|
side_imbalance = float(
|
|
sides[cell_row, cell_col].max()
|
|
- sides[cell_row, cell_col].min()
|
|
)
|
|
support = (
|
|
float(score[cell_row, cell_col])
|
|
+ 0.10 * active_neighbors(cell_row, cell_col)
|
|
+ 0.25 * bridge_score(cell_row, cell_col)
|
|
- 0.05 * side_imbalance
|
|
)
|
|
candidate = (support, cell_row, cell_col)
|
|
if weakest is None or candidate[0] < weakest[0]:
|
|
weakest = candidate
|
|
|
|
if weakest is None:
|
|
break
|
|
_, row, col = weakest
|
|
resolved[row, col] = False
|
|
|
|
return resolved.astype(np.uint8)
|
|
|
|
|
|
def count_2x2_blocks(matrix: np.ndarray) -> int:
|
|
image = (matrix > 0).astype(np.uint8)
|
|
total = 0
|
|
for row in range(image.shape[0] - 1):
|
|
for col in range(image.shape[1] - 1):
|
|
total += int(image[row : row + 2, col : col + 2].sum() == 4)
|
|
return total
|
|
|
|
|
|
def matrix_map(
|
|
mask: np.ndarray,
|
|
grid_size: int,
|
|
triangle_threshold: float,
|
|
cell_samples: int,
|
|
) -> MatrixMapping:
|
|
height, width = mask.shape
|
|
warped, x_edges, y_edges, grid_overlay = _normalize_raw_strokes(
|
|
mask,
|
|
grid_size=grid_size,
|
|
cell_samples=cell_samples,
|
|
)
|
|
matrix = _ratio_matrix(
|
|
warped,
|
|
grid_size=grid_size,
|
|
cell_samples=cell_samples,
|
|
)
|
|
triangles = _detect_triangle_connections(
|
|
warped,
|
|
matrix,
|
|
cell_samples=cell_samples,
|
|
triangle_threshold=triangle_threshold,
|
|
)
|
|
|
|
rendered = _render_matrix(matrix, x_edges, y_edges, width, height, triangles)
|
|
return MatrixMapping(
|
|
matrix,
|
|
rendered,
|
|
x_edges,
|
|
y_edges,
|
|
triangles=triangles,
|
|
grid_overlay=grid_overlay,
|
|
)
|
|
|
|
|
|
def mask_to_display(mask: np.ndarray) -> np.ndarray:
|
|
return np.where(mask > 0, 0, 255).astype(np.uint8)
|
|
|
|
|
|
def save_comparison(
|
|
path: Path,
|
|
original_bgr: np.ndarray,
|
|
cleaned_mask: np.ndarray,
|
|
grid_overlay: np.ndarray | None,
|
|
mapped_mask: np.ndarray,
|
|
mapped_title: str,
|
|
) -> None:
|
|
original_rgb = cv2.cvtColor(original_bgr, cv2.COLOR_BGR2RGB)
|
|
cleaned = mask_to_display(cleaned_mask)
|
|
mapped = mask_to_display(mapped_mask)
|
|
|
|
fig, axes = plt.subplots(1, 4, figsize=(16, 4), constrained_layout=True)
|
|
panels = [
|
|
("Original", original_rgb, None),
|
|
("Cleaned binary", cleaned, "gray"),
|
|
("Adapted 17x17 grid", grid_overlay if grid_overlay is not None else cleaned, None),
|
|
(mapped_title, mapped, "gray"),
|
|
]
|
|
|
|
for axis, (title, image, cmap) in zip(axes, panels):
|
|
axis.imshow(image, cmap=cmap)
|
|
axis.set_title(title)
|
|
axis.axis("off")
|
|
|
|
fig.savefig(path, dpi=180)
|
|
plt.close(fig)
|
|
|
|
|
|
def small_matrix_to_svg(
|
|
small_mask: np.ndarray,
|
|
original_width: int,
|
|
original_height: int,
|
|
title: str,
|
|
x_edges: np.ndarray,
|
|
y_edges: np.ndarray,
|
|
triangles: tuple[TriangleConnection, ...],
|
|
) -> str:
|
|
rows, cols = small_mask.shape
|
|
escaped_title = html.escape(title)
|
|
parts = [
|
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
(
|
|
f'<svg xmlns="http://www.w3.org/2000/svg" '
|
|
f'width="{original_width}" height="{original_height}" '
|
|
f'viewBox="0 0 {original_width} {original_height}" '
|
|
f'shape-rendering="crispEdges">'
|
|
),
|
|
f" <title>{escaped_title}</title>",
|
|
' <rect width="100%" height="100%" fill="white"/>',
|
|
' <g fill="black">',
|
|
]
|
|
|
|
for row_index in range(rows):
|
|
col_index = 0
|
|
while col_index < cols:
|
|
if small_mask[row_index, col_index] == 0:
|
|
col_index += 1
|
|
continue
|
|
|
|
run_start = col_index
|
|
while col_index < cols and small_mask[row_index, col_index] > 0:
|
|
col_index += 1
|
|
|
|
x = int(x_edges[run_start])
|
|
y = int(y_edges[row_index])
|
|
width = int(x_edges[col_index]) - x
|
|
height = int(y_edges[row_index + 1]) - y
|
|
parts.append(
|
|
f' <rect x="{x:.4f}" y="{y:.4f}" '
|
|
f'width="{width:.4f}" height="{height:.4f}"/>'
|
|
)
|
|
|
|
for triangle in triangles:
|
|
x1 = int(x_edges[triangle.col])
|
|
x2 = int(x_edges[triangle.col + 1])
|
|
y1 = int(y_edges[triangle.row])
|
|
y2 = int(y_edges[triangle.row + 1])
|
|
points_by_corner = {
|
|
"tl": [(x1, y1), (x2, y1), (x1, y2)],
|
|
"tr": [(x2, y1), (x1, y1), (x2, y2)],
|
|
"br": [(x2, y2), (x2, y1), (x1, y2)],
|
|
"bl": [(x1, y2), (x1, y1), (x2, y2)],
|
|
}
|
|
points = " ".join(
|
|
f"{point_x:.4f},{point_y:.4f}"
|
|
for point_x, point_y in points_by_corner[triangle.corner]
|
|
)
|
|
parts.append(f' <polygon points="{points}"/>')
|
|
|
|
parts.extend([" </g>", "</svg>", ""])
|
|
return "\n".join(parts)
|
|
|
|
|
|
def matrix_to_text(
|
|
matrix: np.ndarray,
|
|
triangles: tuple[TriangleConnection, ...],
|
|
) -> str:
|
|
lines = []
|
|
for row in matrix:
|
|
lines.append("".join("1" if value > 0 else "0" for value in row))
|
|
|
|
lines.append("")
|
|
lines.append(f"# 2x2_black_blocks: {count_2x2_blocks(matrix)}")
|
|
|
|
if triangles:
|
|
lines.append("")
|
|
lines.append("# triangles: row,col,corner")
|
|
for triangle in triangles:
|
|
lines.append(f"{triangle.row},{triangle.col},{triangle.corner}")
|
|
|
|
return "\n".join(lines) + "\n"
|
|
|
|
|
|
def process_image(
|
|
image_path: Path,
|
|
processed_dir: Path,
|
|
comparison_dir: Path,
|
|
svg_dir: Path,
|
|
factor: int,
|
|
grid_size: int,
|
|
cell_samples: int,
|
|
open_kernel_size: int,
|
|
close_kernel_size: int,
|
|
median_size: int,
|
|
min_component_area: int,
|
|
mode: str,
|
|
triangle_threshold: float,
|
|
) -> int:
|
|
original = read_image(image_path)
|
|
gray = cv2.cvtColor(original, cv2.COLOR_BGR2GRAY)
|
|
binary = binarize_foreground(gray)
|
|
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,
|
|
)
|
|
if mode == "matrix":
|
|
mapping = matrix_map(
|
|
cleaned,
|
|
grid_size=grid_size,
|
|
triangle_threshold=triangle_threshold,
|
|
cell_samples=cell_samples,
|
|
)
|
|
mapped_title = f"{grid_size}x{grid_size} matrix"
|
|
else:
|
|
mapping = pixelate(cleaned, factor=factor)
|
|
mapped_title = "Pixelated"
|
|
|
|
stem = image_path.stem
|
|
write_image(processed_dir / f"{stem}_cleaned.png", mask_to_display(cleaned))
|
|
write_image(
|
|
processed_dir / f"{stem}_pixelated.png", mask_to_display(mapping.rendered_mask)
|
|
)
|
|
write_image(
|
|
processed_dir / f"{stem}_matrix{mapping.matrix.shape[0]}.png",
|
|
mask_to_display(mapping.matrix),
|
|
)
|
|
(processed_dir / f"{stem}_matrix{mapping.matrix.shape[0]}.txt").write_text(
|
|
matrix_to_text(mapping.matrix, mapping.triangles),
|
|
encoding="utf-8",
|
|
)
|
|
save_comparison(
|
|
comparison_dir / f"{stem}_comparison.png",
|
|
original,
|
|
cleaned,
|
|
mapping.grid_overlay,
|
|
mapping.rendered_mask,
|
|
mapped_title,
|
|
)
|
|
|
|
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}_pixelated.svg").write_text(svg, encoding="utf-8")
|
|
return count_2x2_blocks(mapping.matrix)
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(
|
|
description="Clean, map, and vectorize scanned seal-script characters."
|
|
)
|
|
parser.add_argument(
|
|
"--input-dir",
|
|
type=Path,
|
|
default=Path("PreProcessing/original_characters"),
|
|
)
|
|
parser.add_argument(
|
|
"--processed-dir",
|
|
type=Path,
|
|
default=Path("PreProcessing/processed_characters"),
|
|
)
|
|
parser.add_argument(
|
|
"--comparison-dir",
|
|
type=Path,
|
|
default=Path("PreProcessing/comparisons"),
|
|
)
|
|
parser.add_argument(
|
|
"--svg-dir",
|
|
type=Path,
|
|
default=Path("PreProcessing/svg_characters"),
|
|
)
|
|
parser.add_argument(
|
|
"--factor",
|
|
type=int,
|
|
default=15,
|
|
help="Only used by --mode resize.",
|
|
)
|
|
parser.add_argument(
|
|
"--grid-size",
|
|
type=int,
|
|
default=17,
|
|
help="Strict square matrix size used by --mode matrix.",
|
|
)
|
|
parser.add_argument(
|
|
"--cell-samples",
|
|
type=int,
|
|
default=32,
|
|
help="Samples per grid cell while deskewing and classifying the source.",
|
|
)
|
|
parser.add_argument(
|
|
"--mode",
|
|
choices=("matrix", "resize"),
|
|
default="matrix",
|
|
help="matrix maps to a strict grid; resize keeps the old mosaic-style downsample.",
|
|
)
|
|
parser.add_argument(
|
|
"--triangle-threshold",
|
|
type=float,
|
|
default=0.18,
|
|
help="Minimum triangular evidence in an empty diagonal-connection cell.",
|
|
)
|
|
parser.add_argument("--open-kernel-size", type=int, default=1)
|
|
parser.add_argument(
|
|
"--close-kernel-size",
|
|
type=int,
|
|
default=0,
|
|
help="Optional fixed close kernel for clean; 0 uses a grid-relative size.",
|
|
)
|
|
parser.add_argument("--median-size", type=int, default=1)
|
|
parser.add_argument("--min-component-area", type=int, default=8)
|
|
args = parser.parse_args()
|
|
|
|
for directory in (args.processed_dir, args.comparison_dir, args.svg_dir):
|
|
directory.mkdir(parents=True, exist_ok=True)
|
|
|
|
images = sorted(
|
|
path
|
|
for path in args.input_dir.iterdir()
|
|
if path.is_file() and path.suffix.lower() in IMAGE_SUFFIXES
|
|
)
|
|
if not images:
|
|
raise SystemExit(f"No images found in {args.input_dir}")
|
|
|
|
for image_path in images:
|
|
two_by_two_blocks = process_image(
|
|
image_path=image_path,
|
|
processed_dir=args.processed_dir,
|
|
comparison_dir=args.comparison_dir,
|
|
svg_dir=args.svg_dir,
|
|
factor=args.factor,
|
|
grid_size=args.grid_size,
|
|
cell_samples=args.cell_samples,
|
|
open_kernel_size=args.open_kernel_size,
|
|
close_kernel_size=args.close_kernel_size,
|
|
median_size=args.median_size,
|
|
min_component_area=args.min_component_area,
|
|
mode=args.mode,
|
|
triangle_threshold=args.triangle_threshold,
|
|
)
|
|
print(f"processed: {image_path.name} (2x2_black_blocks={two_by_two_blocks})")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|