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 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,6 @@
1
+ APP_VERSION = "1.0.5"
2
+ APPLICATION_TITLE = f"Image Vectorizer v{APP_VERSION}"
3
+ SIDEBAR_TITLE = "Sidebar"
4
+ PREVIEW_AREA_TITLE = "Preview Area"
5
+ CONTROL_PANEL_TITLE = "Control Panel"
6
+ STATUS_READY = "Ready"
@@ -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))