silukman-image-vectorizer 1.0.5__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- app/__init__.py +1 -0
- app/cli.py +73 -0
- app/config/__init__.py +1 -0
- app/config/settings.py +40 -0
- app/core/__init__.py +1 -0
- app/core/constants.py +6 -0
- app/core/image_pipeline.py +51 -0
- app/core/paths.py +13 -0
- app/core/vectorization_engine.py +360 -0
- app/core/vectorizer_backend.py +373 -0
- app/main_window.py +1714 -0
- app/resources/__init__.py +1 -0
- app/resources/hero_image.png +0 -0
- app/resources/icon.icns +0 -0
- app/resources/icon.png +0 -0
- app/services/__init__.py +1 -0
- app/services/batch_processor.py +171 -0
- app/services/color_palette.py +110 -0
- app/services/image_loader.py +120 -0
- app/services/svg_exporter.py +191 -0
- app/ui/__init__.py +1 -0
- app/ui/theme.py +316 -0
- silukman_image_vectorizer-1.0.5.dist-info/METADATA +119 -0
- silukman_image_vectorizer-1.0.5.dist-info/RECORD +28 -0
- silukman_image_vectorizer-1.0.5.dist-info/WHEEL +5 -0
- silukman_image_vectorizer-1.0.5.dist-info/entry_points.txt +2 -0
- silukman_image_vectorizer-1.0.5.dist-info/licenses/LICENSE +30 -0
- silukman_image_vectorizer-1.0.5.dist-info/top_level.txt +1 -0
app/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Image Vectorizer application package."""
|
app/cli.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import traceback
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def _show_startup_error(title: str, message: str) -> None:
|
|
6
|
+
try:
|
|
7
|
+
from PySide6.QtWidgets import QApplication, QMessageBox
|
|
8
|
+
|
|
9
|
+
app = QApplication.instance()
|
|
10
|
+
created = False
|
|
11
|
+
if app is None:
|
|
12
|
+
app = QApplication(sys.argv)
|
|
13
|
+
created = True
|
|
14
|
+
QMessageBox.critical(None, title, message)
|
|
15
|
+
if created:
|
|
16
|
+
app.processEvents()
|
|
17
|
+
except Exception:
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _validate_runtime_environment() -> None:
|
|
22
|
+
from app.core.paths import RESOURCES_DIR
|
|
23
|
+
|
|
24
|
+
required_resources = [
|
|
25
|
+
RESOURCES_DIR,
|
|
26
|
+
RESOURCES_DIR / "icon.png",
|
|
27
|
+
RESOURCES_DIR / "hero_image.png",
|
|
28
|
+
]
|
|
29
|
+
missing_resources = [
|
|
30
|
+
str(path)
|
|
31
|
+
for path in required_resources
|
|
32
|
+
if not path.exists()
|
|
33
|
+
]
|
|
34
|
+
if missing_resources:
|
|
35
|
+
missing = "\n".join(missing_resources)
|
|
36
|
+
raise RuntimeError(f"Missing packaged resource(s):\n{missing}")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def main() -> int:
|
|
40
|
+
try:
|
|
41
|
+
from PySide6.QtWidgets import QApplication
|
|
42
|
+
|
|
43
|
+
from app.main_window import MainWindow
|
|
44
|
+
|
|
45
|
+
_validate_runtime_environment()
|
|
46
|
+
|
|
47
|
+
application = QApplication(sys.argv)
|
|
48
|
+
application.setApplicationName("Image Vectorizer")
|
|
49
|
+
|
|
50
|
+
window = MainWindow()
|
|
51
|
+
window.show()
|
|
52
|
+
return application.exec()
|
|
53
|
+
except ImportError as error:
|
|
54
|
+
msg = (
|
|
55
|
+
f"Failed to start Image Vectorizer: missing dependency ({error}).\n\n"
|
|
56
|
+
"Please ensure all dependencies are installed correctly."
|
|
57
|
+
)
|
|
58
|
+
print(msg, file=sys.stderr)
|
|
59
|
+
_show_startup_error("Dependency Error", msg)
|
|
60
|
+
return 1
|
|
61
|
+
except Exception as error:
|
|
62
|
+
print(f"Failed to start Image Vectorizer: {error}", file=sys.stderr)
|
|
63
|
+
traceback.print_exc()
|
|
64
|
+
|
|
65
|
+
_show_startup_error(
|
|
66
|
+
"Startup Error",
|
|
67
|
+
f"Image Vectorizer could not start:\n{error}",
|
|
68
|
+
)
|
|
69
|
+
return 1
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
if __name__ == "__main__":
|
|
73
|
+
sys.exit(main())
|
app/config/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Application configuration package."""
|
app/config/settings.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
|
|
3
|
+
DEFAULT_WINDOW_WIDTH = 1200
|
|
4
|
+
DEFAULT_WINDOW_HEIGHT = 760
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class VTracerSettings:
|
|
8
|
+
"""VTracer-specific settings."""
|
|
9
|
+
colormode: str = "color" # color / binary
|
|
10
|
+
hierarchical: str = "stacked" # stacked / cutout
|
|
11
|
+
mode: str = "spline" # spline / polygon / none
|
|
12
|
+
filter_speckle: int = 4 # speckle size filter
|
|
13
|
+
color_precision: int = 6 # 1 to 8 (safer default max 6 to prevent 128 colors)
|
|
14
|
+
layer_difference: int = 16 # color difference threshold
|
|
15
|
+
corner_threshold: int = 60 # angle threshold for corners
|
|
16
|
+
length_threshold: float = 3.5 # curve length threshold
|
|
17
|
+
max_iterations: int = 16 # optimizer max iteration count
|
|
18
|
+
path_precision: int = 8 # SVG path output decimal precision
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class VectorizationSettings:
|
|
22
|
+
"""Settings that control the vectorization process."""
|
|
23
|
+
# Legacy OpenCV settings
|
|
24
|
+
min_area: float = 100.0 # Minimum contour area to keep (pixels)
|
|
25
|
+
approx_tolerance: float = 2.0 # Douglas-Peucker epsilon for approximation
|
|
26
|
+
smoothing_enabled: bool = False # Whether to apply Gaussian smoothing pre-detection
|
|
27
|
+
invert: bool = False # Invert binary image before detection
|
|
28
|
+
color_mode: str = "Unlimited colors"
|
|
29
|
+
color_count: int = 8
|
|
30
|
+
preserve_edges: bool = False
|
|
31
|
+
remove_background: bool = False
|
|
32
|
+
bg_tolerance: float = 20.0
|
|
33
|
+
threshold_val: int = 127 # Threshold value for legacy engine
|
|
34
|
+
palette_replacements: list[tuple[tuple[int, int, int], tuple[int, int, int]]] = field(default_factory=list)
|
|
35
|
+
|
|
36
|
+
# Active engine type: "VTracer" or "OpenCV Legacy"
|
|
37
|
+
engine_type: str = "VTracer"
|
|
38
|
+
|
|
39
|
+
# VTracer settings object
|
|
40
|
+
vtracer: VTracerSettings = field(default_factory=VTracerSettings)
|
app/core/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Core application utilities."""
|
app/core/constants.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import cv2
|
|
4
|
+
import numpy as np
|
|
5
|
+
from PySide6.QtGui import QImage
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def process_image_pipeline(file_path: str, threshold_val: int) -> tuple[QImage, np.ndarray]:
|
|
9
|
+
"""Run the image processing pipeline.
|
|
10
|
+
|
|
11
|
+
Stages:
|
|
12
|
+
1. Load image and convert to grayscale.
|
|
13
|
+
2. Apply binary thresholding.
|
|
14
|
+
"""
|
|
15
|
+
if not isinstance(threshold_val, int) or isinstance(threshold_val, bool):
|
|
16
|
+
raise ValueError("Threshold value must be an integer.")
|
|
17
|
+
if not 0 <= threshold_val <= 255:
|
|
18
|
+
raise ValueError("Threshold value must be between 0 and 255.")
|
|
19
|
+
|
|
20
|
+
img = cv2.imread(file_path, cv2.IMREAD_UNCHANGED)
|
|
21
|
+
if img is None:
|
|
22
|
+
raise ValueError("Failed to load image for processing pipeline.")
|
|
23
|
+
|
|
24
|
+
if img.ndim == 2:
|
|
25
|
+
gray = img
|
|
26
|
+
elif img.shape[2] == 4:
|
|
27
|
+
gray = cv2.cvtColor(img, cv2.COLOR_BGRA2GRAY)
|
|
28
|
+
elif img.shape[2] == 3:
|
|
29
|
+
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
|
|
30
|
+
else:
|
|
31
|
+
raise ValueError("Unsupported image channel layout.")
|
|
32
|
+
|
|
33
|
+
# Stage 2: Threshold processing
|
|
34
|
+
_, thresholded = cv2.threshold(gray, threshold_val, 255, cv2.THRESH_BINARY)
|
|
35
|
+
|
|
36
|
+
# Convert grayscale/binary numpy array to QImage
|
|
37
|
+
height, width = thresholded.shape
|
|
38
|
+
thresholded = np.ascontiguousarray(thresholded)
|
|
39
|
+
bytes_per_line = thresholded.strides[0]
|
|
40
|
+
|
|
41
|
+
# QImage.Format_Grayscale8 is appropriate for single-channel 8-bit image data
|
|
42
|
+
q_image = QImage(
|
|
43
|
+
thresholded.data,
|
|
44
|
+
width,
|
|
45
|
+
height,
|
|
46
|
+
bytes_per_line,
|
|
47
|
+
QImage.Format.Format_Grayscale8
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# Create a copy to ensure memory ownership of the pixel data is transferred to QImage
|
|
51
|
+
return q_image.copy(), thresholded
|
app/core/paths.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"):
|
|
6
|
+
PROJECT_ROOT = Path(sys._MEIPASS)
|
|
7
|
+
else:
|
|
8
|
+
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
|
9
|
+
|
|
10
|
+
APP_DIR = PROJECT_ROOT / "app"
|
|
11
|
+
RESOURCES_DIR = APP_DIR / "resources"
|
|
12
|
+
DOCS_DIR = PROJECT_ROOT / "docs"
|
|
13
|
+
SCRIPTS_DIR = PROJECT_ROOT / "scripts"
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
"""Vectorization engine for converting binary/threshold images into vector paths.
|
|
2
|
+
|
|
3
|
+
Handles:
|
|
4
|
+
- Vector data model (VectorPath, VectorResult)
|
|
5
|
+
- Contour detection from binary images
|
|
6
|
+
- Contour filtering and simplification
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
import math
|
|
13
|
+
|
|
14
|
+
import cv2
|
|
15
|
+
import numpy as np
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
# Vector data model
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class VectorPath:
|
|
24
|
+
"""A single detected vector path represented as a sequence of (x, y) points."""
|
|
25
|
+
points: np.ndarray # shape (N, 2), dtype int32
|
|
26
|
+
area: float # contour area in pixels
|
|
27
|
+
color: tuple[int, int, int] = (30, 144, 255)
|
|
28
|
+
holes: list[np.ndarray] = field(default_factory=list)
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def point_count(self) -> int:
|
|
32
|
+
return len(self.points)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class VectorResult:
|
|
37
|
+
"""Container for all vector paths detected in an image."""
|
|
38
|
+
paths: list[VectorPath] = field(default_factory=list)
|
|
39
|
+
image_width: int = 0
|
|
40
|
+
image_height: int = 0
|
|
41
|
+
original_point_count: int = 0
|
|
42
|
+
simplified_point_count: int = 0
|
|
43
|
+
fallback_error: str | None = None
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def path_count(self) -> int:
|
|
47
|
+
return len(self.paths)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
# Vectorization settings
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
from app.config.settings import VectorizationSettings
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
# Engine
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
def vectorize(
|
|
62
|
+
binary_array: np.ndarray,
|
|
63
|
+
settings: VectorizationSettings | None = None,
|
|
64
|
+
color_array: np.ndarray | None = None,
|
|
65
|
+
) -> VectorResult:
|
|
66
|
+
"""Convert a binary/threshold numpy image array into a VectorResult.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
binary_array: Single-channel (H, W) uint8 numpy array (binary image).
|
|
70
|
+
settings: Optional VectorizationSettings; defaults used if None.
|
|
71
|
+
color_array: Optional original BGR image for color quantization & background removal.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
VectorResult containing detected VectorPath objects.
|
|
75
|
+
|
|
76
|
+
Raises:
|
|
77
|
+
ValueError: If the input array is invalid.
|
|
78
|
+
"""
|
|
79
|
+
if settings is None:
|
|
80
|
+
settings = VectorizationSettings()
|
|
81
|
+
|
|
82
|
+
if not isinstance(binary_array, np.ndarray) or binary_array.ndim != 2:
|
|
83
|
+
raise ValueError("binary_array must be a 2D (H x W) single-channel image.")
|
|
84
|
+
if binary_array.size == 0:
|
|
85
|
+
raise ValueError("binary_array must not be empty.")
|
|
86
|
+
if not np.issubdtype(binary_array.dtype, np.number):
|
|
87
|
+
raise ValueError("binary_array must contain numeric pixel values.")
|
|
88
|
+
if not np.isfinite(binary_array).all():
|
|
89
|
+
raise ValueError("binary_array must contain only finite pixel values.")
|
|
90
|
+
if not math.isfinite(settings.min_area) or settings.min_area < 0:
|
|
91
|
+
raise ValueError("min_area must be greater than or equal to zero.")
|
|
92
|
+
if not math.isfinite(settings.approx_tolerance) or settings.approx_tolerance < 0:
|
|
93
|
+
raise ValueError("approx_tolerance must be greater than or equal to zero.")
|
|
94
|
+
if not isinstance(settings.color_count, int) or isinstance(settings.color_count, bool):
|
|
95
|
+
raise ValueError("color_count must be an integer.")
|
|
96
|
+
if settings.color_count < 1:
|
|
97
|
+
raise ValueError("color_count must be greater than zero.")
|
|
98
|
+
|
|
99
|
+
height, width = binary_array.shape
|
|
100
|
+
working = np.where(binary_array > 0, 255, 0).astype(np.uint8)
|
|
101
|
+
valid_pixel_mask = np.ones((height, width), dtype=bool)
|
|
102
|
+
|
|
103
|
+
# 1. Edge preservation (apply Bilateral Filter if enabled)
|
|
104
|
+
if settings.preserve_edges:
|
|
105
|
+
working = cv2.bilateralFilter(working, 9, 75, 75)
|
|
106
|
+
|
|
107
|
+
# 2. Color Quantization & Background Removal
|
|
108
|
+
alpha_mask = None
|
|
109
|
+
if color_array is not None and color_array.ndim == 3 and color_array.shape[:2] == (height, width):
|
|
110
|
+
if color_array.shape[2] == 4:
|
|
111
|
+
alpha_mask = color_array[:, :, 3] > 0
|
|
112
|
+
valid_pixel_mask &= alpha_mask
|
|
113
|
+
target_color_array = color_array[:, :, :3].copy()
|
|
114
|
+
elif color_array.shape[2] == 3:
|
|
115
|
+
target_color_array = color_array.copy()
|
|
116
|
+
else:
|
|
117
|
+
raise ValueError("color_array must have three BGR or four BGRA channels.")
|
|
118
|
+
|
|
119
|
+
# Background Removal
|
|
120
|
+
if settings.remove_background:
|
|
121
|
+
# Detect background color from average of the 4 corners
|
|
122
|
+
corners = [
|
|
123
|
+
target_color_array[0, 0],
|
|
124
|
+
target_color_array[0, width - 1],
|
|
125
|
+
target_color_array[height - 1, 0],
|
|
126
|
+
target_color_array[height - 1, width - 1]
|
|
127
|
+
]
|
|
128
|
+
bg_color = np.mean(corners, axis=0).astype(np.uint8)
|
|
129
|
+
|
|
130
|
+
# Mask out background colors based on Euclidean distance and tolerance
|
|
131
|
+
dist = np.linalg.norm(target_color_array.astype(np.float32) - bg_color.astype(np.float32), axis=2)
|
|
132
|
+
bg_mask = dist < settings.bg_tolerance
|
|
133
|
+
foreground_mask = ~bg_mask
|
|
134
|
+
if alpha_mask is not None:
|
|
135
|
+
foreground_mask &= alpha_mask
|
|
136
|
+
working = np.where(foreground_mask, 255, 0).astype(np.uint8)
|
|
137
|
+
elif alpha_mask is not None:
|
|
138
|
+
working[~alpha_mask] = 0
|
|
139
|
+
|
|
140
|
+
# Color vectorization must use the original image regions rather than
|
|
141
|
+
# the single binary threshold mask. The mask remains useful for
|
|
142
|
+
# monochrome input and threshold preview, but it would discard most
|
|
143
|
+
# colors and details from photos and artwork.
|
|
144
|
+
color_foreground = valid_pixel_mask.copy()
|
|
145
|
+
if settings.remove_background:
|
|
146
|
+
color_foreground &= foreground_mask
|
|
147
|
+
|
|
148
|
+
cluster_count = settings.color_count if settings.color_mode == "Custom colors" else 64
|
|
149
|
+
target_color_array = _quantize_colors(
|
|
150
|
+
target_color_array,
|
|
151
|
+
color_foreground,
|
|
152
|
+
cluster_count,
|
|
153
|
+
preserve_edges=settings.preserve_edges,
|
|
154
|
+
)
|
|
155
|
+
working = np.where(color_foreground, 255, 0).astype(np.uint8)
|
|
156
|
+
else:
|
|
157
|
+
target_color_array = None
|
|
158
|
+
|
|
159
|
+
# 3. Optional invert
|
|
160
|
+
if settings.invert:
|
|
161
|
+
working = cv2.bitwise_not(working)
|
|
162
|
+
working[~valid_pixel_mask] = 0
|
|
163
|
+
|
|
164
|
+
# 4. Optional smoothing to reduce noise before contour detection
|
|
165
|
+
if settings.smoothing_enabled:
|
|
166
|
+
working = cv2.GaussianBlur(working, (5, 5), 0)
|
|
167
|
+
_, working = cv2.threshold(working, 127, 255, cv2.THRESH_BINARY)
|
|
168
|
+
working[~valid_pixel_mask] = 0
|
|
169
|
+
|
|
170
|
+
original_points = 0
|
|
171
|
+
simplified_points = 0
|
|
172
|
+
paths: list[VectorPath] = []
|
|
173
|
+
|
|
174
|
+
if (
|
|
175
|
+
target_color_array is not None
|
|
176
|
+
and not settings.remove_background
|
|
177
|
+
and np.all(valid_pixel_mask)
|
|
178
|
+
):
|
|
179
|
+
foreground_colors = target_color_array[working > 0].reshape(-1, 3)
|
|
180
|
+
if len(foreground_colors):
|
|
181
|
+
colors, counts = np.unique(foreground_colors, axis=0, return_counts=True)
|
|
182
|
+
blue, green, red = colors[np.argmax(counts)]
|
|
183
|
+
canvas_points = np.array(
|
|
184
|
+
[[0, 0], [width, 0], [width, height], [0, height]],
|
|
185
|
+
dtype=np.int32,
|
|
186
|
+
)
|
|
187
|
+
paths.append(
|
|
188
|
+
VectorPath(
|
|
189
|
+
points=canvas_points,
|
|
190
|
+
area=float(width * height),
|
|
191
|
+
color=(int(red), int(green), int(blue)),
|
|
192
|
+
)
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
contour_masks = _build_contour_masks(working, target_color_array)
|
|
196
|
+
for contour_mask, region_color in contour_masks:
|
|
197
|
+
contours, hierarchy = cv2.findContours(
|
|
198
|
+
contour_mask,
|
|
199
|
+
cv2.RETR_CCOMP,
|
|
200
|
+
cv2.CHAIN_APPROX_SIMPLE,
|
|
201
|
+
)
|
|
202
|
+
if hierarchy is None:
|
|
203
|
+
continue
|
|
204
|
+
|
|
205
|
+
for contour_index, contour in enumerate(contours):
|
|
206
|
+
# Child contours are holes and are attached to their outer path.
|
|
207
|
+
if hierarchy[0][contour_index][3] != -1:
|
|
208
|
+
continue
|
|
209
|
+
|
|
210
|
+
hole_contours: list[np.ndarray] = []
|
|
211
|
+
hole_index = hierarchy[0][contour_index][2]
|
|
212
|
+
while hole_index != -1:
|
|
213
|
+
hole_contours.append(contours[hole_index])
|
|
214
|
+
hole_index = hierarchy[0][hole_index][0]
|
|
215
|
+
|
|
216
|
+
area = cv2.contourArea(contour) - sum(
|
|
217
|
+
cv2.contourArea(hole) for hole in hole_contours
|
|
218
|
+
)
|
|
219
|
+
if area < settings.min_area:
|
|
220
|
+
continue
|
|
221
|
+
|
|
222
|
+
original_points += len(contour) + sum(len(hole) for hole in hole_contours)
|
|
223
|
+
simplified = cv2.approxPolyDP(
|
|
224
|
+
contour,
|
|
225
|
+
settings.approx_tolerance,
|
|
226
|
+
closed=True,
|
|
227
|
+
)
|
|
228
|
+
simplified_holes = [
|
|
229
|
+
cv2.approxPolyDP(hole, settings.approx_tolerance, closed=True).reshape(-1, 2)
|
|
230
|
+
for hole in hole_contours
|
|
231
|
+
if cv2.contourArea(hole) >= settings.min_area
|
|
232
|
+
]
|
|
233
|
+
simplified_points += len(simplified) + sum(len(hole) for hole in simplified_holes)
|
|
234
|
+
points = simplified.reshape(-1, 2)
|
|
235
|
+
path_color = region_color or _mean_contour_color(
|
|
236
|
+
contour,
|
|
237
|
+
target_color_array,
|
|
238
|
+
binary_array.shape,
|
|
239
|
+
)
|
|
240
|
+
paths.append(
|
|
241
|
+
VectorPath(
|
|
242
|
+
points=points,
|
|
243
|
+
area=area,
|
|
244
|
+
color=path_color,
|
|
245
|
+
holes=simplified_holes,
|
|
246
|
+
)
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
# Larger color regions form the background layers. Painting them first
|
|
250
|
+
# allows smaller internal regions to remain visible in preview and SVG.
|
|
251
|
+
paths.sort(key=lambda path: path.area, reverse=True)
|
|
252
|
+
|
|
253
|
+
return VectorResult(
|
|
254
|
+
paths=paths,
|
|
255
|
+
image_width=width,
|
|
256
|
+
image_height=height,
|
|
257
|
+
original_point_count=original_points,
|
|
258
|
+
simplified_point_count=simplified_points
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _build_contour_masks(
|
|
263
|
+
working: np.ndarray,
|
|
264
|
+
color_array: np.ndarray | None,
|
|
265
|
+
) -> list[tuple[np.ndarray, tuple[int, int, int] | None]]:
|
|
266
|
+
"""Build one foreground mask or separate masks for each detected color."""
|
|
267
|
+
foreground = working > 0
|
|
268
|
+
if color_array is None:
|
|
269
|
+
return [(working, None)]
|
|
270
|
+
|
|
271
|
+
masks: list[tuple[np.ndarray, tuple[int, int, int] | None]] = []
|
|
272
|
+
for bgr_color in np.unique(color_array[foreground].reshape(-1, 3), axis=0):
|
|
273
|
+
color_pixels = np.all(color_array == bgr_color, axis=2)
|
|
274
|
+
region_mask = np.where(foreground & color_pixels, 255, 0).astype(np.uint8)
|
|
275
|
+
rgb_color = (int(bgr_color[2]), int(bgr_color[1]), int(bgr_color[0]))
|
|
276
|
+
masks.append((region_mask, rgb_color))
|
|
277
|
+
return masks
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _quantize_colors(
|
|
281
|
+
color_array: np.ndarray,
|
|
282
|
+
foreground: np.ndarray,
|
|
283
|
+
requested_clusters: int,
|
|
284
|
+
*,
|
|
285
|
+
preserve_edges: bool,
|
|
286
|
+
) -> np.ndarray:
|
|
287
|
+
"""Reduce image colors into stable regions suitable for vector paths."""
|
|
288
|
+
result = color_array.copy()
|
|
289
|
+
pixels = color_array[foreground].reshape((-1, 3))
|
|
290
|
+
if len(pixels) == 0:
|
|
291
|
+
return result
|
|
292
|
+
|
|
293
|
+
# Training K-Means on a representative sample keeps large photos
|
|
294
|
+
# responsive while all foreground pixels are still assigned afterwards.
|
|
295
|
+
max_training_pixels = 100_000
|
|
296
|
+
if len(pixels) > max_training_pixels:
|
|
297
|
+
sample_indices = np.linspace(
|
|
298
|
+
0,
|
|
299
|
+
len(pixels) - 1,
|
|
300
|
+
max_training_pixels,
|
|
301
|
+
dtype=np.int64,
|
|
302
|
+
)
|
|
303
|
+
training_pixels = pixels[sample_indices]
|
|
304
|
+
else:
|
|
305
|
+
training_pixels = pixels
|
|
306
|
+
|
|
307
|
+
unique_training = np.unique(training_pixels, axis=0)
|
|
308
|
+
cluster_count = min(requested_clusters, len(unique_training))
|
|
309
|
+
if cluster_count <= 1:
|
|
310
|
+
result[foreground] = unique_training[0]
|
|
311
|
+
return result
|
|
312
|
+
|
|
313
|
+
criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 0.5)
|
|
314
|
+
cv2.setRNGSeed(42)
|
|
315
|
+
_, _, centers = cv2.kmeans(
|
|
316
|
+
training_pixels.astype(np.float32),
|
|
317
|
+
cluster_count,
|
|
318
|
+
None,
|
|
319
|
+
criteria,
|
|
320
|
+
3,
|
|
321
|
+
cv2.KMEANS_PP_CENTERS,
|
|
322
|
+
)
|
|
323
|
+
centers = np.uint8(np.clip(np.rint(centers), 0, 255))
|
|
324
|
+
|
|
325
|
+
quantized_labels = np.empty(len(pixels), dtype=np.uint8)
|
|
326
|
+
chunk_size = 50_000
|
|
327
|
+
center_values = centers.astype(np.int32)
|
|
328
|
+
for start in range(0, len(pixels), chunk_size):
|
|
329
|
+
chunk = pixels[start:start + chunk_size].astype(np.int32)
|
|
330
|
+
distances = np.sum(
|
|
331
|
+
(chunk[:, None, :] - center_values[None, :, :]) ** 2,
|
|
332
|
+
axis=2,
|
|
333
|
+
)
|
|
334
|
+
quantized_labels[start:start + chunk_size] = np.argmin(distances, axis=1)
|
|
335
|
+
|
|
336
|
+
# Median filtering cluster labels removes isolated photo noise while
|
|
337
|
+
# retaining the finite palette and producing coherent vector regions.
|
|
338
|
+
label_map = np.full(foreground.shape, 255, dtype=np.uint8)
|
|
339
|
+
label_map[foreground] = quantized_labels
|
|
340
|
+
kernel_size = 3 if preserve_edges else 5
|
|
341
|
+
filtered_labels = cv2.medianBlur(label_map, kernel_size)
|
|
342
|
+
invalid_filtered = filtered_labels >= cluster_count
|
|
343
|
+
filtered_labels[invalid_filtered] = label_map[invalid_filtered]
|
|
344
|
+
result[foreground] = centers[filtered_labels[foreground]]
|
|
345
|
+
return result
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _mean_contour_color(
|
|
349
|
+
contour: np.ndarray,
|
|
350
|
+
color_array: np.ndarray | None,
|
|
351
|
+
image_shape: tuple[int, int],
|
|
352
|
+
) -> tuple[int, int, int]:
|
|
353
|
+
"""Return the average RGB color inside a contour."""
|
|
354
|
+
if color_array is None:
|
|
355
|
+
return (30, 144, 255)
|
|
356
|
+
|
|
357
|
+
mask = np.zeros(image_shape, dtype=np.uint8)
|
|
358
|
+
cv2.drawContours(mask, [contour], -1, 255, -1)
|
|
359
|
+
blue, green, red = cv2.mean(color_array, mask=mask)[:3]
|
|
360
|
+
return (int(red), int(green), int(blue))
|