strokemap 0.1.0__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.
strokemap/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .generator import PaintByNumbersGenerator
2
+ from .pdf_generator import generate_pdf
3
+
4
+ __all__ = ["PaintByNumbersGenerator", "generate_pdf"]
strokemap/cli.py ADDED
@@ -0,0 +1,67 @@
1
+ import argparse
2
+ import os
3
+ import sys
4
+ import traceback
5
+
6
+ from .generator import PaintByNumbersGenerator
7
+ from .pdf_generator import generate_pdf
8
+
9
+
10
+ def main():
11
+ parser = argparse.ArgumentParser(
12
+ description="Convert any image to a print-ready Paint by Numbers PDF."
13
+ )
14
+ parser.add_argument("image_path", help="Path to the input image file (JPEG, PNG, etc.)")
15
+ parser.add_argument("output_pdf", help="Path where the output PDF should be saved")
16
+ parser.add_argument(
17
+ "-c",
18
+ "--colors",
19
+ type=int,
20
+ default=20,
21
+ help="Number of colors to use for the painting (default: 20)",
22
+ )
23
+ parser.add_argument(
24
+ "-d",
25
+ "--difficulty",
26
+ choices=["easy", "medium", "hard"],
27
+ default="medium",
28
+ help="Difficulty level which controls region sizes and details (default: medium)",
29
+ )
30
+
31
+ args = parser.parse_args()
32
+
33
+ if not os.path.exists(args.image_path):
34
+ print(f"Error: Input image file '{args.image_path}' does not exist.", file=sys.stderr)
35
+ sys.exit(1)
36
+
37
+ print(f"Processing image: {args.image_path}")
38
+ print(f"Difficulty level: {args.difficulty}")
39
+ print(f"Target colors: {args.colors}")
40
+
41
+ # Run the generator pipeline
42
+ generator = PaintByNumbersGenerator(difficulty=args.difficulty)
43
+ try:
44
+ numbered_img, clean_img, colorized_img, palette = generator.process(
45
+ args.image_path, args.colors
46
+ )
47
+
48
+ print(f"Generated outlines with {len(palette)} final colors.")
49
+ print(f"Compiling PDF to: {args.output_pdf}")
50
+
51
+ generate_pdf(
52
+ output_pdf_path=args.output_pdf,
53
+ numbered_img=numbered_img,
54
+ clean_img=clean_img,
55
+ colorized_img=colorized_img,
56
+ palette=palette,
57
+ )
58
+ print("Success! Paint by Numbers PDF generated successfully.")
59
+
60
+ except Exception as e:
61
+ print(f"Error generating Paint by Numbers: {e}", file=sys.stderr)
62
+ traceback.print_exc()
63
+ sys.exit(1)
64
+
65
+
66
+ if __name__ == "__main__":
67
+ main()
strokemap/generator.py ADDED
@@ -0,0 +1,350 @@
1
+ import os
2
+
3
+ import cv2
4
+ import numpy as np
5
+ from PIL import Image, ImageDraw, ImageFont
6
+ from skimage.segmentation import slic
7
+ from sklearn.cluster import KMeans
8
+
9
+
10
+ class PaintByNumbersGenerator:
11
+ def __init__(
12
+ self,
13
+ difficulty="medium",
14
+ outline_color=(180, 185, 200),
15
+ number_color=(100, 105, 115),
16
+ clean_outline_color=(0, 0, 0),
17
+ ):
18
+ self.difficulty = difficulty.lower()
19
+ self.outline_color = outline_color
20
+ self.number_color = number_color
21
+ self.clean_outline_color = clean_outline_color
22
+
23
+ # Difficulty parameters
24
+ if self.difficulty == "easy":
25
+ self.n_segments_base = 800
26
+ self.slic_compactness = 5.0
27
+ self.slic_sigma = 3.0
28
+ self.min_area_fraction = 0.0005
29
+ elif self.difficulty == "hard":
30
+ self.n_segments_base = 4000
31
+ self.slic_compactness = 10.0
32
+ self.slic_sigma = 1.0
33
+ self.min_area_fraction = 0.00005
34
+ else: # medium
35
+ self.n_segments_base = 2000
36
+ self.slic_compactness = 8.0
37
+ self.slic_sigma = 2.0
38
+ self.min_area_fraction = 0.0002
39
+
40
+ def load_image(self, image_path):
41
+ """Loads image, handles transparency, and returns RGB numpy array."""
42
+ # Read with cv2 (BGR)
43
+ img = cv2.imread(image_path, cv2.IMREAD_UNCHANGED)
44
+ if img is None:
45
+ raise FileNotFoundError(f"Could not load image from {image_path}")
46
+
47
+ # Convert BGR/BGRA to RGB
48
+ if len(img.shape) == 3 and img.shape[2] == 4:
49
+ # BGRA to BGR blending with white background
50
+ alpha = img[:, :, 3:] / 255.0
51
+ img = (img[:, :, :3] * alpha + 255.0 * (1.0 - alpha)).astype(np.uint8)
52
+ img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
53
+ elif len(img.shape) == 3 and img.shape[2] == 3:
54
+ img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
55
+ else:
56
+ # Grayscale to RGB
57
+ img = cv2.cvtColor(img, cv2.COLOR_GRAY2RGB)
58
+
59
+ return img
60
+
61
+ def preprocess_image(self, img):
62
+ """Applies median filtering to remove noise before segmentation."""
63
+ # Using a small median filter, cv2.medianBlur is very fast
64
+ ksize = 5 if self.difficulty == "easy" else 3
65
+ return cv2.medianBlur(img, ksize)
66
+
67
+ def quantize_colors(self, img, n_colors):
68
+ """Segments image using SLIC superpixels and clusters their mean colors."""
69
+ h, w, c = img.shape
70
+
71
+ # Calculate number of segments based on image resolution
72
+ scale_factor = (h * w) / 1000000.0
73
+ n_segments = int(self.n_segments_base * max(0.5, scale_factor))
74
+
75
+ # 1. SLIC Superpixels
76
+ segments = slic(
77
+ img,
78
+ n_segments=n_segments,
79
+ compactness=self.slic_compactness,
80
+ sigma=self.slic_sigma,
81
+ start_label=0,
82
+ )
83
+
84
+ num_segments = np.max(segments) + 1
85
+
86
+ # Convert to LAB space for perceptual operations
87
+ lab_img = cv2.cvtColor(img, cv2.COLOR_RGB2LAB)
88
+
89
+ # 2. Compute mean color for each segment
90
+ segment_means = np.zeros((num_segments, 3), dtype=np.float32)
91
+
92
+ # Fast mean computation using bincount
93
+ for channel in range(3):
94
+ channel_sums = np.bincount(
95
+ segments.ravel(), weights=lab_img[:, :, channel].ravel(), minlength=num_segments
96
+ )
97
+ pixel_counts = np.bincount(segments.ravel(), minlength=num_segments)
98
+ pixel_counts[pixel_counts == 0] = 1 # Avoid division by zero
99
+ segment_means[:, channel] = channel_sums / pixel_counts
100
+
101
+ # 3. KMeans clustering on the segment means
102
+ kmeans = KMeans(n_clusters=n_colors, random_state=42, n_init="auto")
103
+ segment_labels = kmeans.fit_predict(segment_means)
104
+ centers_lab = kmeans.cluster_centers_
105
+
106
+ # 4. Map superpixel labels back to image
107
+ labels_2d = segment_labels[segments]
108
+
109
+ # Build palette
110
+ palette = []
111
+ for i, center in enumerate(centers_lab):
112
+ center_pixel = center.reshape(1, 1, 3).astype(np.uint8)
113
+ rgb_pixel = cv2.cvtColor(center_pixel, cv2.COLOR_LAB2RGB)[0, 0]
114
+ rgb = (int(rgb_pixel[0]), int(rgb_pixel[1]), int(rgb_pixel[2]))
115
+ hex_code = f"#{rgb[0]:02X}{rgb[1]:02X}{rgb[2]:02X}"
116
+ palette.append({"old_index": i, "rgb": rgb, "hex": hex_code})
117
+
118
+ # Sort palette by perceived brightness (luminance)
119
+ palette.sort(key=lambda x: 0.299 * x["rgb"][0] + 0.587 * x["rgb"][1] + 0.114 * x["rgb"][2])
120
+
121
+ # Create a remapping array for speed
122
+ remap_arr = np.zeros(n_colors, dtype=np.int32)
123
+ for new_idx, item in enumerate(palette):
124
+ remap_arr[item["old_index"]] = new_idx
125
+
126
+ labels_2d_sorted = remap_arr[labels_2d]
127
+
128
+ # Clean palette data structure
129
+ sorted_palette = [{"rgb": item["rgb"], "hex": item["hex"]} for item in palette]
130
+
131
+ return labels_2d_sorted, sorted_palette
132
+
133
+ def merge_small_regions(self, labels_2d, n_colors, min_area):
134
+ """Iteratively merges components smaller than min_area into their largest neighbors."""
135
+ h, w = labels_2d.shape
136
+ result = labels_2d.copy()
137
+
138
+ iteration = 0
139
+ max_iterations = 10
140
+
141
+ while iteration < max_iterations:
142
+ changed = False
143
+ small_components = []
144
+
145
+ # Find connected components for each color index
146
+ unique_colors = np.unique(result)
147
+ for c in unique_colors:
148
+ mask = (result == c).astype(np.uint8)
149
+ num_labels, labels, stats, centroids = cv2.connectedComponentsWithStats(
150
+ mask, connectivity=4
151
+ )
152
+
153
+ for label_idx in range(1, num_labels):
154
+ area = stats[label_idx, cv2.CC_STAT_AREA]
155
+ if area < min_area:
156
+ comp_mask = labels == label_idx
157
+ small_components.append({"color": c, "area": area, "mask": comp_mask})
158
+
159
+ if not small_components:
160
+ break
161
+
162
+ # Sort by area ascending to merge smallest first
163
+ small_components.sort(key=lambda x: x["area"])
164
+
165
+ for comp in small_components:
166
+ comp_mask = comp["mask"]
167
+ current_vals = result[comp_mask]
168
+ if len(current_vals) == 0:
169
+ continue
170
+
171
+ val = current_vals[0]
172
+ if not np.all(current_vals == val):
173
+ # Component has already been modified in this iteration, skip
174
+ continue
175
+
176
+ # Dilate mask to find neighbor pixels
177
+ kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3))
178
+ dilated = cv2.dilate(comp_mask.astype(np.uint8), kernel)
179
+ border = (dilated == 1) & (~comp_mask)
180
+
181
+ neighbor_colors = result[border]
182
+ if len(neighbor_colors) == 0:
183
+ continue
184
+
185
+ # Count frequencies of neighboring colors
186
+ counts = np.bincount(neighbor_colors, minlength=val + 1)
187
+ counts[val] = 0 # Ignore current color to force merge with a neighbor
188
+
189
+ if np.max(counts) == 0:
190
+ continue
191
+
192
+ best_neighbor = np.argmax(counts)
193
+ result[comp_mask] = best_neighbor
194
+ changed = True
195
+
196
+ if not changed:
197
+ break
198
+
199
+ iteration += 1
200
+
201
+ # Re-index remaining colors to start from 1 to N without gaps
202
+ unique_remaining = sorted(np.unique(result).tolist())
203
+ label_map = {old_lbl: new_lbl + 1 for new_lbl, old_lbl in enumerate(unique_remaining)}
204
+
205
+ final_labels = np.vectorize(label_map.get)(result)
206
+
207
+ return final_labels, unique_remaining
208
+
209
+ def get_outlines(self, final_labels):
210
+ """Generates 1-pixel boundary edge map."""
211
+ h, w = final_labels.shape
212
+ edges = np.zeros((h, w), dtype=bool)
213
+
214
+ # Compare each pixel with its right and bottom neighbors
215
+ edges[:-1, :] |= final_labels[:-1, :] != final_labels[1:, :]
216
+ edges[:, :-1] |= final_labels[:, :-1] != final_labels[:, 1:]
217
+
218
+ # Add outer canvas border
219
+ edges[0, :] = True
220
+ edges[-1, :] = True
221
+ edges[:, 0] = True
222
+ edges[:, -1] = True
223
+
224
+ return edges
225
+
226
+ def generate_templates(self, final_labels, edges, num_colors):
227
+ """Creates the numbered template and the clean outlines template images."""
228
+ h, w = final_labels.shape
229
+
230
+ # Determine scaling for drawing (lines, fonts)
231
+ max_dim = max(h, w)
232
+ outline_thickness = max(1, int(max_dim / 1500))
233
+ font_size = max(8, int(max_dim * 0.0055))
234
+
235
+ # Build dilated edges for template drawing
236
+ if outline_thickness > 1:
237
+ kernel = cv2.getStructuringElement(
238
+ cv2.MORPH_RECT, (outline_thickness, outline_thickness)
239
+ )
240
+ edges_drawn = cv2.dilate(edges.astype(np.uint8), kernel) > 0
241
+ else:
242
+ edges_drawn = edges
243
+
244
+ # 1. Clean outline image (solid black lines on white)
245
+ clean_np = np.ones((h, w, 3), dtype=np.uint8) * 255
246
+ clean_np[edges_drawn] = self.clean_outline_color
247
+ clean_img = Image.fromarray(clean_np)
248
+
249
+ # 2. Numbered outline image (gray lines on white)
250
+ numbered_np = np.ones((h, w, 3), dtype=np.uint8) * 255
251
+ numbered_np[edges_drawn] = self.outline_color
252
+ numbered_img = Image.fromarray(numbered_np)
253
+
254
+ draw = ImageDraw.Draw(numbered_img)
255
+
256
+ # Load clean sans-serif font
257
+ font = None
258
+ font_paths = [
259
+ "/System/Library/Fonts/Supplemental/Arial.ttf",
260
+ "/System/Library/Fonts/Helvetica.ttc",
261
+ "/System/Library/Fonts/SFNS.ttf",
262
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
263
+ "C:\\Windows\\Fonts\\arial.ttf",
264
+ ]
265
+ for path in font_paths:
266
+ if os.path.exists(path):
267
+ try:
268
+ font = ImageFont.truetype(path, size=font_size)
269
+ break
270
+ except Exception:
271
+ pass
272
+ if font is None:
273
+ font = ImageFont.load_default()
274
+
275
+ min_dist_to_draw = font_size * 0.45
276
+
277
+ # Place numbers using distance transform on connected components
278
+ for v in range(1, num_colors + 1):
279
+ mask = (final_labels == v).astype(np.uint8)
280
+ num_labels, labels, stats, centroids = cv2.connectedComponentsWithStats(
281
+ mask, connectivity=8
282
+ )
283
+
284
+ for i in range(1, num_labels):
285
+ comp_mask = (labels == i).astype(np.uint8)
286
+
287
+ # Compute distance transform of component mask
288
+ dist_transform = cv2.distanceTransform(comp_mask, cv2.DIST_L2, 5)
289
+ _, max_val, _, max_loc = cv2.minMaxLoc(dist_transform)
290
+
291
+ # Only write number if it fits nicely inside the region
292
+ if max_val >= min_dist_to_draw:
293
+ cx, cy = max_loc
294
+ text_str = str(v)
295
+
296
+ # Center the text
297
+ bbox = draw.textbbox((0, 0), text_str, font=font)
298
+ text_w = bbox[2] - bbox[0]
299
+ text_h = bbox[3] - bbox[1]
300
+
301
+ text_x = cx - text_w / 2
302
+ text_y = cy - text_h / 2
303
+
304
+ draw.text((text_x, text_y), text_str, fill=self.number_color, font=font)
305
+
306
+ return numbered_img, clean_img
307
+
308
+ def process(self, image_path, n_colors):
309
+ """Runs the entire pipeline on the input image and returns output images and palette."""
310
+ # 1. Load & preprocess
311
+ img = self.load_image(image_path)
312
+ smoothed = self.preprocess_image(img)
313
+
314
+ # 2. Quantize
315
+ labels_2d, sorted_palette = self.quantize_colors(smoothed, n_colors)
316
+
317
+ # Smooth the initial labels to create curvy, organic boundaries
318
+ h, w = labels_2d.shape
319
+ k_size = max(5, int(max(h, w) * 0.005))
320
+ if k_size % 2 == 0:
321
+ k_size += 1
322
+ labels_2d = cv2.medianBlur(labels_2d.astype(np.uint8), k_size).astype(np.int32)
323
+
324
+ # 3. Merge small regions
325
+ min_area = int(h * w * self.min_area_fraction)
326
+ final_labels, unique_remaining = self.merge_small_regions(labels_2d, n_colors, min_area)
327
+
328
+ # 4. Filter palette to remaining colors
329
+ final_palette = []
330
+ for i, old_idx in enumerate(unique_remaining):
331
+ color_info = sorted_palette[old_idx]
332
+ final_palette.append(
333
+ {"index": i + 1, "rgb": color_info["rgb"], "hex": color_info["hex"]}
334
+ )
335
+
336
+ num_final_colors = len(final_palette)
337
+
338
+ # 5. Extract outlines and draw templates
339
+ edges = self.get_outlines(final_labels)
340
+ numbered_img, clean_img = self.generate_templates(final_labels, edges, num_final_colors)
341
+
342
+ # 6. Build colorized preview image
343
+ colorized_np = np.zeros((h, w, 3), dtype=np.uint8)
344
+ for color_info in final_palette:
345
+ idx = color_info["index"]
346
+ rgb = color_info["rgb"]
347
+ colorized_np[final_labels == idx] = rgb
348
+ colorized_img = Image.fromarray(colorized_np)
349
+
350
+ return numbered_img, clean_img, colorized_img, final_palette
@@ -0,0 +1,231 @@
1
+ import io
2
+
3
+ from reportlab.lib.colors import HexColor
4
+ from reportlab.lib.enums import TA_CENTER
5
+ from reportlab.lib.pagesizes import A4
6
+ from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
7
+ from reportlab.platypus import Image as RLImage
8
+ from reportlab.platypus import PageBreak, Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle
9
+ from reportlab.platypus.flowables import Flowable
10
+
11
+
12
+ class ColorSwatch(Flowable):
13
+ def __init__(self, index, rgb, hex_code, width=80, height=70):
14
+ super().__init__()
15
+ self.index = index
16
+ self.rgb = rgb # tuple (r, g, b)
17
+ self.hex_code = hex_code
18
+ self.width = width
19
+ self.height = height
20
+
21
+ def wrap(self, availWidth, availHeight):
22
+ return self.width, self.height
23
+
24
+ def draw(self):
25
+ # Draw color box
26
+ # Normalize color to 0.0 - 1.0 for reportlab
27
+ r, g, b = (val / 255.0 for val in self.rgb)
28
+ self.canv.setFillColorRGB(r, g, b)
29
+ self.canv.setStrokeColorRGB(0.1, 0.1, 0.1)
30
+ self.canv.setLineWidth(1)
31
+
32
+ # Center square horizontally
33
+ square_size = 36
34
+ sq_x = (self.width - square_size) / 2
35
+ sq_y = self.height - square_size - 2
36
+
37
+ self.canv.rect(sq_x, sq_y, square_size, square_size, fill=1, stroke=1)
38
+
39
+ # Draw color index number
40
+ self.canv.setFont("Helvetica-Bold", 10)
41
+ self.canv.setFillColorRGB(0.1, 0.1, 0.1)
42
+ self.canv.drawCentredString(self.width / 2, sq_y - 12, str(self.index))
43
+
44
+ # Draw Hex Code
45
+ self.canv.setFont("Helvetica", 8)
46
+ self.canv.setFillColorRGB(0.4, 0.4, 0.4)
47
+ self.canv.drawCentredString(self.width / 2, sq_y - 24, self.hex_code)
48
+
49
+
50
+ def generate_pdf(
51
+ output_pdf_path,
52
+ numbered_img,
53
+ clean_img,
54
+ colorized_img,
55
+ palette,
56
+ ):
57
+ # Margins and page size
58
+ margin = 36 # 0.5 inch
59
+ doc = SimpleDocTemplate(
60
+ output_pdf_path,
61
+ pagesize=A4,
62
+ leftMargin=margin,
63
+ rightMargin=margin,
64
+ topMargin=margin,
65
+ bottomMargin=margin,
66
+ )
67
+
68
+ page_w, page_h = A4
69
+ max_w = page_w - 2 * margin
70
+ max_h = page_h - 2 * margin
71
+
72
+ story = []
73
+
74
+ # ----------------------------------------------------
75
+ # Page 1: Numbered Template Image
76
+ # ----------------------------------------------------
77
+ num_buf = io.BytesIO()
78
+ numbered_img.save(num_buf, format="PNG")
79
+ num_buf.seek(0)
80
+
81
+ # Calculate dimensions to maintain aspect ratio
82
+ img_w, img_h = numbered_img.size
83
+ aspect_ratio = img_w / img_h
84
+
85
+ if max_w / aspect_ratio <= max_h:
86
+ draw_w = max_w
87
+ draw_h = max_w / aspect_ratio
88
+ else:
89
+ draw_h = max_h
90
+ draw_w = max_h * aspect_ratio
91
+
92
+ # Spacer to vertically center the image on the page
93
+ v_spacer_1 = (max_h - draw_h) / 2
94
+ if v_spacer_1 > 0:
95
+ story.append(Spacer(1, v_spacer_1))
96
+
97
+ story.append(RLImage(num_buf, width=draw_w, height=draw_h))
98
+ story.append(PageBreak())
99
+
100
+ # ----------------------------------------------------
101
+ # Page 2: Clean Outline Image
102
+ # ----------------------------------------------------
103
+ clean_buf = io.BytesIO()
104
+ clean_img.save(clean_buf, format="PNG")
105
+ clean_buf.seek(0)
106
+
107
+ v_spacer_2 = (max_h - draw_h) / 2
108
+ if v_spacer_2 > 0:
109
+ story.append(Spacer(1, v_spacer_2))
110
+
111
+ story.append(RLImage(clean_buf, width=draw_w, height=draw_h))
112
+ story.append(PageBreak())
113
+
114
+ # ----------------------------------------------------
115
+ # Page 3: Color Preview Image (How it should look)
116
+ # ----------------------------------------------------
117
+ preview_buf = io.BytesIO()
118
+ colorized_img.save(preview_buf, format="PNG")
119
+ preview_buf.seek(0)
120
+
121
+ v_spacer_3 = (max_h - draw_h) / 2
122
+ if v_spacer_3 > 0:
123
+ story.append(Spacer(1, v_spacer_3))
124
+
125
+ story.append(RLImage(preview_buf, width=draw_w, height=draw_h))
126
+ story.append(PageBreak())
127
+
128
+ # ----------------------------------------------------
129
+ # Page 4: Palette Sheet
130
+ # ----------------------------------------------------
131
+ styles = getSampleStyleSheet()
132
+
133
+ # Custom typography styles
134
+ title_style = ParagraphStyle(
135
+ "PaletteTitle",
136
+ parent=styles["Normal"],
137
+ fontName="Helvetica-Bold",
138
+ fontSize=26,
139
+ leading=32,
140
+ alignment=TA_CENTER,
141
+ spaceAfter=6,
142
+ )
143
+
144
+ instruction_title_style = ParagraphStyle(
145
+ "InstructionTitle",
146
+ parent=styles["Normal"],
147
+ fontName="Helvetica-Bold",
148
+ fontSize=11,
149
+ leading=15,
150
+ spaceAfter=6,
151
+ )
152
+
153
+ instruction_body_style = ParagraphStyle(
154
+ "InstructionBody",
155
+ parent=styles["Normal"],
156
+ fontName="Helvetica",
157
+ fontSize=9.5,
158
+ leading=14,
159
+ textColor=HexColor("#333333"),
160
+ spaceAfter=4,
161
+ )
162
+
163
+ # Title
164
+ story.append(Paragraph("Strokemap", title_style))
165
+
166
+ # Color palette grid
167
+ num_cols = 6
168
+ col_w = max_w / num_cols
169
+
170
+ swatches = []
171
+ for color in palette:
172
+ swatches.append(
173
+ ColorSwatch(
174
+ index=color["index"],
175
+ rgb=color["rgb"],
176
+ hex_code=color["hex"],
177
+ width=col_w,
178
+ height=70,
179
+ )
180
+ )
181
+
182
+ # Arrange into grid matrix
183
+ table_data = []
184
+ current_row = []
185
+ for _, swatch in enumerate(swatches):
186
+ current_row.append(swatch)
187
+ if len(current_row) == num_cols:
188
+ table_data.append(current_row)
189
+ current_row = []
190
+ if current_row:
191
+ # Pad last row with empty flowables
192
+ while len(current_row) < num_cols:
193
+ current_row.append("")
194
+ table_data.append(current_row)
195
+
196
+ palette_table = Table(table_data, colWidths=[col_w] * num_cols)
197
+ palette_table.setStyle(
198
+ TableStyle(
199
+ [
200
+ ("ALIGN", (0, 0), (-1, -1), "CENTER"),
201
+ ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
202
+ ("LEFTPADDING", (0, 0), (-1, -1), 0),
203
+ ("RIGHTPADDING", (0, 0), (-1, -1), 0),
204
+ ("TOPPADDING", (0, 0), (-1, -1), 6),
205
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 6),
206
+ ]
207
+ )
208
+ )
209
+
210
+ story.append(palette_table)
211
+ story.append(Spacer(1, 24))
212
+
213
+ # Instructions at the bottom
214
+ story.append(Paragraph("Instructions:", instruction_title_style))
215
+
216
+ bullets = [
217
+ "<b>Page 1 - Numbered template:</b> Find numbered areas and match to colors on page 4.",
218
+ "<b>Page 2 - Clean borders:</b> For those who want a clean painting experience.",
219
+ "<b>Page 3 - Colored preview:</b> A reference picture showing how the final painting looks.",
220
+ "1. Choose your preferred template (numbered or clean borders).",
221
+ "2. Match the numbers to the colors on the palette sheet (page 4).",
222
+ "3. Paint each area with the corresponding color.",
223
+ "4. Use the colored preview page as a reference.",
224
+ "5. Take your time and enjoy the process!",
225
+ ]
226
+
227
+ for bullet in bullets:
228
+ story.append(Paragraph(bullet, instruction_body_style))
229
+
230
+ # Build Document
231
+ doc.build(story)
@@ -0,0 +1,137 @@
1
+ Metadata-Version: 2.4
2
+ Name: strokemap
3
+ Version: 0.1.0
4
+ Summary: Convert any image into a Paint by Numbers printable PDF
5
+ Author-email: Developer <developer@example.com>
6
+ License: MIT
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ License-File: AUTHORS.md
11
+ Requires-Dist: numpy
12
+ Requires-Dist: pillow
13
+ Requires-Dist: opencv-python
14
+ Requires-Dist: scikit-learn
15
+ Requires-Dist: reportlab
16
+ Requires-Dist: scikit-image
17
+ Provides-Extra: dev
18
+ Requires-Dist: pytest>=7.0; extra == "dev"
19
+ Requires-Dist: pytest-cov; extra == "dev"
20
+ Requires-Dist: pre-commit; extra == "dev"
21
+ Requires-Dist: ruff; extra == "dev"
22
+ Dynamic: license-file
23
+
24
+ # Strokemap - Paint by Numbers Generator
25
+
26
+ A Python package and CLI tool to convert any image into a high-quality, print-ready "Paint by Numbers" PDF template.
27
+
28
+ The generated PDF matches premium standards, split into four cleanly laid-out pages:
29
+ 1. **Page 1 - Numbered Template**: Light gray outlines with a small, centered index number in each region.
30
+ 2. **Page 2 - Clean Outlines**: Clean black outlines without any numbers, perfect for clean canvas painting.
31
+ 3. **Page 3 - Colorized Preview**: A color reference picture showing what the finished painting should look like.
32
+ 4. **Page 4 - Color Palette Sheet**: A beautifully aligned grid of color swatches showing index numbers, hex codes, paint color blocks, and step-by-step instructions.
33
+
34
+ ---
35
+
36
+ ## Preview
37
+
38
+ Here is an example of the generator's output using the standard **Lenna** test image:
39
+
40
+ | Original Image | Numbered Template (Page 1) | Clean Outlines (Page 2) | Colorized Preview (Page 3) |
41
+ | :---: | :---: | :---: | :---: |
42
+ | ![Original Lenna](tests/assets/lenna.png) | ![Numbered Template](tests/assets/lenna_numbered.png) | ![Clean Outlines](tests/assets/lenna_clean.png) | ![Colorized Preview](tests/assets/lenna_colorized.png) |
43
+
44
+ > [!NOTE]
45
+ > **Image Citation**: Lenna (or Lena) is a standard digital image processing test image, originally from the USC-SIPI Image Database. It is widely used for testing image processing algorithms.
46
+
47
+ ---
48
+
49
+ ## Installation
50
+
51
+ Create a virtual environment and install the package locally:
52
+
53
+ ```bash
54
+ python3 -m venv .venv
55
+ source .venv/bin/activate
56
+ pip install -e .
57
+ ```
58
+
59
+ ### Dependencies
60
+ The package relies on the following standard python packages:
61
+ - `numpy`
62
+ - `pillow`
63
+ - `opencv-python`
64
+ - `scikit-learn`
65
+ - `reportlab`
66
+ - `scikit-image`
67
+
68
+ ---
69
+
70
+ ## Usage
71
+
72
+ ### Command Line Interface
73
+
74
+ You can convert any image directly from your terminal:
75
+
76
+ ```bash
77
+ strokemap input.jpg output.pdf --colors 20 --difficulty medium
78
+ ```
79
+
80
+ #### CLI Options:
81
+ * `image_path` (required): Path to the input image file.
82
+ * `output_pdf` (required): Path where the final PDF should be saved.
83
+ * `-c`, `--colors` (optional): Target number of colors (default: 20).
84
+ * `-d`, `--difficulty` (optional): Level of region detail (`easy`, `medium`, `hard`) (default: `medium`).
85
+
86
+ ### Python API
87
+
88
+ You can also use the package programmatically:
89
+
90
+ ```python
91
+ from strokemap import PaintByNumbersGenerator, generate_pdf
92
+
93
+ # 1. Initialize generator with difficulty settings
94
+ generator = PaintByNumbersGenerator(difficulty="medium")
95
+
96
+ # 2. Process image to get templates and palette
97
+ numbered_img, clean_img, colorized_img, palette = generator.process("input.jpg", n_colors=20)
98
+
99
+ # 3. Compile everything into a 4-page A4 PDF
100
+ generate_pdf(
101
+ output_pdf_path="output.pdf",
102
+ numbered_img=numbered_img,
103
+ clean_img=clean_img,
104
+ colorized_img=colorized_img,
105
+ palette=palette,
106
+ )
107
+ ```
108
+
109
+ ---
110
+
111
+ ## Algorithms Used
112
+
113
+ 1. **Superpixel Segmentation (SLIC)**: Uses the Simple Linear Iterative Clustering (SLIC) algorithm to cluster pixels into contiguous, edge-conforming "superpixels". This ensures that the boundaries of regions natively stick to the actual physical boundaries and details in the image (such as eyes, text, and fine lines).
114
+ 2. **Color Quantization**: Performs K-Means clustering on the average colors of the superpixels in the CIELAB color space. Working in CIELAB space allows color distances to match human perception, resulting in a vibrant and accurate palette.
115
+ 3. **Detail Reduction & Region Merging**: Small, hard-to-paint micro-regions are intelligently merged into their dominant neighbor using connected components analysis, with thresholds dynamically adjusted by the selected difficulty level.
116
+ 4. **Outline Extraction**: Computes a pixel-wise transition grid to produce clean, single-pixel outlines.
117
+ 5. **Optimal Label Placement**: Uses a distance transform (`cv2.distanceTransform`) to find the center of the largest inscribed circle within each region, placing number labels at the most readable point.
118
+
119
+ ---
120
+
121
+ ## Authors
122
+
123
+ - Developer
124
+
125
+ For additional author and maintainer details, see [AUTHORS.md](AUTHORS.md).
126
+
127
+ ## Contributors
128
+
129
+ This project is maintained by the community. See [CONTRIBUTORS.md](CONTRIBUTORS.md) for the current list of contributors and how to get involved.
130
+
131
+ ## Contributing
132
+
133
+ Contributions are welcome! Please read [CONTRIBUTING.md](CONTRIBUTING.md) before opening an issue or pull request.
134
+
135
+ ## Security
136
+
137
+ If you discover a vulnerability, please do not publish it publicly. Refer to [SECURITY.md](SECURITY.md) for our security reporting policy.
@@ -0,0 +1,11 @@
1
+ strokemap/__init__.py,sha256=Xrnp-RPl2sp-4fWDL-pJzqemQCUf3PSLrHq7BAnWcSA,142
2
+ strokemap/cli.py,sha256=zdDJX4RwdWQiBHjYKVSnKP7h5QA9hWxO1PT7kEFTEZ0,2048
3
+ strokemap/generator.py,sha256=HjO-SJzh1-X8cn2zB6wATYeQ68JmXtPEOuvlUzgjrZo,13340
4
+ strokemap/pdf_generator.py,sha256=1LhGTZXwC90BY3NXMt2chLFiFYu8-0MPhLT0ZbSUjfU,7074
5
+ strokemap-0.1.0.dist-info/licenses/AUTHORS.md,sha256=tU33bFtXPJWfvgKauqVIRRlbwyjyh5e18ChMltevCi8,312
6
+ strokemap-0.1.0.dist-info/licenses/LICENSE,sha256=GXcGYpuNQ4p9sJ_Ob2r6q0ddThtuKdEr6YuZbmdHYik,1069
7
+ strokemap-0.1.0.dist-info/METADATA,sha256=j_BkwlMHoNVXi59bm_0UdnmFJ7qyx5NH-qYzUKS9U9k,5166
8
+ strokemap-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
9
+ strokemap-0.1.0.dist-info/entry_points.txt,sha256=JtAs7jR9ujeY9bOJ19RbXJDm7jm_Z-U7DFrFP-gDeZE,49
10
+ strokemap-0.1.0.dist-info/top_level.txt,sha256=PFB39esYYaNf9op_bsnm8AZk2uHpuzUFmH7ciyvy58s,10
11
+ strokemap-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ strokemap = strokemap.cli:main
@@ -0,0 +1,11 @@
1
+ # Authors
2
+
3
+ ## Primary Author
4
+
5
+ - [Dipin Nair](https://github.com/dipinknair)
6
+
7
+ ## Acknowledgements
8
+
9
+ Thank you to everyone who contributes bug reports, documentation updates, and feature improvements.
10
+
11
+ If you would like to be listed here, please contribute to the project and open a pull request with your details.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dipin K Nair
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ strokemap