sketchflow 0.1.0__tar.gz

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.
@@ -0,0 +1,76 @@
1
+ Metadata-Version: 2.4
2
+ Name: sketchflow
3
+ Version: 0.1.0
4
+ Summary: A Python pencil sketch and outline processing library
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: numpy
9
+ Requires-Dist: opencv-python
10
+ Provides-Extra: animation
11
+ Requires-Dist: pygame; extra == "animation"
12
+
13
+ # SketchFlow
14
+
15
+ **SketchFlow** is a Python library for converting images into pencil sketches and artistic outlines.
16
+
17
+ ## Features
18
+
19
+ - Convert images into pencil sketches.
20
+ - Generate outline drawings.
21
+ - Apply charcoal, soft, realistic, and detailed styles.
22
+ - Customize image processing options.
23
+ - Display and save processed images.
24
+ - Animate the drawing process with optional Pygame support.
25
+
26
+ ## Installation
27
+
28
+ ```bash
29
+ pip install sketchflow
30
+ ```
31
+
32
+ For animation support:
33
+
34
+ ```bash
35
+ pip install "sketchflow[animation]"
36
+ ```
37
+
38
+ ## Quick Start
39
+
40
+ ```python
41
+ import sketchflow
42
+
43
+ image = sketchflow.read("input.jpg")
44
+
45
+ image.set_style("outline")
46
+
47
+ image.show()
48
+ ```
49
+
50
+ ## Save the Result
51
+
52
+ ```python
53
+ import sketchflow
54
+
55
+ image = sketchflow.read("input.jpg")
56
+ image.set_style("realistic")
57
+ image.save("output.png")
58
+ ```
59
+
60
+ ## Available Styles
61
+
62
+ - `realistic`
63
+ - `outline`
64
+ - `charcoal`
65
+ - `soft`
66
+ - `detailed`
67
+
68
+ ## Requirements
69
+
70
+ - Python 3.10+
71
+ - NumPy
72
+ - OpenCV
73
+
74
+ ## License
75
+
76
+ MIT License
@@ -0,0 +1,64 @@
1
+ # SketchFlow
2
+
3
+ **SketchFlow** is a Python library for converting images into pencil sketches and artistic outlines.
4
+
5
+ ## Features
6
+
7
+ - Convert images into pencil sketches.
8
+ - Generate outline drawings.
9
+ - Apply charcoal, soft, realistic, and detailed styles.
10
+ - Customize image processing options.
11
+ - Display and save processed images.
12
+ - Animate the drawing process with optional Pygame support.
13
+
14
+ ## Installation
15
+
16
+ ```bash
17
+ pip install sketchflow
18
+ ```
19
+
20
+ For animation support:
21
+
22
+ ```bash
23
+ pip install "sketchflow[animation]"
24
+ ```
25
+
26
+ ## Quick Start
27
+
28
+ ```python
29
+ import sketchflow
30
+
31
+ image = sketchflow.read("input.jpg")
32
+
33
+ image.set_style("outline")
34
+
35
+ image.show()
36
+ ```
37
+
38
+ ## Save the Result
39
+
40
+ ```python
41
+ import sketchflow
42
+
43
+ image = sketchflow.read("input.jpg")
44
+ image.set_style("realistic")
45
+ image.save("output.png")
46
+ ```
47
+
48
+ ## Available Styles
49
+
50
+ - `realistic`
51
+ - `outline`
52
+ - `charcoal`
53
+ - `soft`
54
+ - `detailed`
55
+
56
+ ## Requirements
57
+
58
+ - Python 3.10+
59
+ - NumPy
60
+ - OpenCV
61
+
62
+ ## License
63
+
64
+ MIT License
@@ -0,0 +1,22 @@
1
+
2
+ [build-system]
3
+ requires = ["setuptools>=68", "wheel"]
4
+ build-backend = "setuptools.build_meta"
5
+
6
+ [project]
7
+ name = "sketchflow"
8
+ version = "0.1.0"
9
+ description = "A Python pencil sketch and outline processing library"
10
+ readme = "README.md"
11
+ requires-python = ">=3.10"
12
+ license = "MIT"
13
+ dependencies = [
14
+ "numpy",
15
+ "opencv-python"
16
+ ]
17
+
18
+ [project.optional-dependencies]
19
+ animation = ["pygame"]
20
+
21
+ [tool.setuptools]
22
+ py-modules = ["sketchflow"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,76 @@
1
+ Metadata-Version: 2.4
2
+ Name: sketchflow
3
+ Version: 0.1.0
4
+ Summary: A Python pencil sketch and outline processing library
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: numpy
9
+ Requires-Dist: opencv-python
10
+ Provides-Extra: animation
11
+ Requires-Dist: pygame; extra == "animation"
12
+
13
+ # SketchFlow
14
+
15
+ **SketchFlow** is a Python library for converting images into pencil sketches and artistic outlines.
16
+
17
+ ## Features
18
+
19
+ - Convert images into pencil sketches.
20
+ - Generate outline drawings.
21
+ - Apply charcoal, soft, realistic, and detailed styles.
22
+ - Customize image processing options.
23
+ - Display and save processed images.
24
+ - Animate the drawing process with optional Pygame support.
25
+
26
+ ## Installation
27
+
28
+ ```bash
29
+ pip install sketchflow
30
+ ```
31
+
32
+ For animation support:
33
+
34
+ ```bash
35
+ pip install "sketchflow[animation]"
36
+ ```
37
+
38
+ ## Quick Start
39
+
40
+ ```python
41
+ import sketchflow
42
+
43
+ image = sketchflow.read("input.jpg")
44
+
45
+ image.set_style("outline")
46
+
47
+ image.show()
48
+ ```
49
+
50
+ ## Save the Result
51
+
52
+ ```python
53
+ import sketchflow
54
+
55
+ image = sketchflow.read("input.jpg")
56
+ image.set_style("realistic")
57
+ image.save("output.png")
58
+ ```
59
+
60
+ ## Available Styles
61
+
62
+ - `realistic`
63
+ - `outline`
64
+ - `charcoal`
65
+ - `soft`
66
+ - `detailed`
67
+
68
+ ## Requirements
69
+
70
+ - Python 3.10+
71
+ - NumPy
72
+ - OpenCV
73
+
74
+ ## License
75
+
76
+ MIT License
@@ -0,0 +1,8 @@
1
+ README.md
2
+ pyproject.toml
3
+ sketchflow.py
4
+ sketchflow.egg-info/PKG-INFO
5
+ sketchflow.egg-info/SOURCES.txt
6
+ sketchflow.egg-info/dependency_links.txt
7
+ sketchflow.egg-info/requires.txt
8
+ sketchflow.egg-info/top_level.txt
@@ -0,0 +1,5 @@
1
+ numpy
2
+ opencv-python
3
+
4
+ [animation]
5
+ pygame
@@ -0,0 +1 @@
1
+ sketchflow
@@ -0,0 +1,679 @@
1
+
2
+
3
+ import os
4
+ import sys
5
+ import time
6
+ import random
7
+ import subprocess
8
+ import platform
9
+ from pathlib import Path
10
+
11
+ import cv2
12
+ import numpy as np
13
+
14
+
15
+ # ============================================================
16
+ # DEFAULT SETTINGS
17
+ # ============================================================
18
+
19
+ MAX_WIDTH = 1500
20
+ MAX_HEIGHT = 900
21
+
22
+ PAPER_WIDTH = 1400
23
+ PAPER_HEIGHT = 820
24
+
25
+ FPS = 60
26
+ PAPER_RGB = (250, 248, 242)
27
+ WINDOW_BG = (43, 45, 48)
28
+ HUD_COLOR = (45, 45, 45)
29
+ HUD_SHADOW = (250, 248, 242)
30
+
31
+ SEGMENTS_PER_FRAME = 12000
32
+ MIN_SEGMENTS = 100
33
+ MAX_SEGMENTS = 100000
34
+
35
+ BLUR_KERNEL = 25
36
+ DODGE_STRENGTH = 260.0
37
+ CONTRAST_GAMMA = 1.25
38
+ EDGE_BLEND = 0.30
39
+ GRAIN_STRENGTH = 10
40
+ PAPER_TEXTURE_STRENGTH = 0.14
41
+
42
+
43
+ # ============================================================
44
+ # IMAGE UTILITIES
45
+ # ============================================================
46
+
47
+ def load_image_bgr(path):
48
+ """Load an image, including paths containing Unicode characters."""
49
+ if not path:
50
+ raise ValueError("Please provide an image path.")
51
+
52
+ path = os.fspath(path)
53
+
54
+ if not os.path.isfile(path):
55
+ raise FileNotFoundError(f"Image not found: {path}")
56
+
57
+ data = np.fromfile(path, dtype=np.uint8)
58
+ image = cv2.imdecode(data, cv2.IMREAD_COLOR)
59
+
60
+ if image is None:
61
+ raise ValueError(
62
+ f"Could not decode image: {path}. "
63
+ "Please use a valid JPG, PNG, BMP or supported image."
64
+ )
65
+
66
+ return image
67
+
68
+
69
+ def fit_to_box(image, max_width, max_height):
70
+ """Resize an image while preserving its aspect ratio."""
71
+ height, width = image.shape[:2]
72
+
73
+ scale = min(max_width / width, max_height / height)
74
+
75
+ new_width = max(1, round(width * scale))
76
+ new_height = max(1, round(height * scale))
77
+
78
+ interpolation = (
79
+ cv2.INTER_AREA if scale < 1 else cv2.INTER_CUBIC
80
+ )
81
+
82
+ return cv2.resize(
83
+ image,
84
+ (new_width, new_height),
85
+ interpolation=interpolation
86
+ )
87
+
88
+
89
+ def make_paper_texture(shape, strength=PAPER_TEXTURE_STRENGTH):
90
+ """Create a subtle, repeatable paper texture."""
91
+ height, width = shape[:2]
92
+
93
+ rng = np.random.default_rng(42)
94
+
95
+ coarse = rng.normal(
96
+ 0,
97
+ 1,
98
+ (height // 8 + 1, width // 8 + 1)
99
+ ).astype(np.float32)
100
+
101
+ texture = cv2.resize(
102
+ coarse,
103
+ (width, height),
104
+ interpolation=cv2.INTER_CUBIC
105
+ )
106
+
107
+ texture = cv2.GaussianBlur(
108
+ texture,
109
+ (0, 0),
110
+ sigmaX=6,
111
+ sigmaY=6
112
+ )
113
+
114
+ texture = cv2.normalize(
115
+ texture,
116
+ None,
117
+ 0,
118
+ 1,
119
+ cv2.NORM_MINMAX
120
+ )
121
+
122
+ return 1.0 - strength * texture
123
+
124
+
125
+ # ============================================================
126
+ # PENCIL SKETCH ENGINE
127
+ # ============================================================
128
+
129
+ def pencil_sketch(
130
+ bgr,
131
+ blur_kernel=BLUR_KERNEL,
132
+ dodge_strength=DODGE_STRENGTH,
133
+ contrast_gamma=CONTRAST_GAMMA,
134
+ edge_blend=EDGE_BLEND,
135
+ grain_strength=GRAIN_STRENGTH,
136
+ paper_texture_strength=PAPER_TEXTURE_STRENGTH,
137
+ ):
138
+ """Convert a BGR image into a graphite-style pencil sketch."""
139
+
140
+ gray = cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY)
141
+ gray = cv2.GaussianBlur(gray, (3, 3), 0)
142
+
143
+ inverted = 255 - gray
144
+
145
+ kernel = max(1, int(blur_kernel))
146
+ if kernel % 2 == 0:
147
+ kernel += 1
148
+
149
+ blurred = cv2.GaussianBlur(
150
+ inverted,
151
+ (kernel, kernel),
152
+ 0
153
+ )
154
+
155
+ blurred = np.maximum(blurred, 1)
156
+
157
+ dodge = np.clip(
158
+ gray.astype(np.float32) * dodge_strength / blurred,
159
+ 0,
160
+ 255
161
+ )
162
+
163
+ dodge = np.clip(
164
+ 255.0 * np.power(dodge / 255.0, contrast_gamma),
165
+ 0,
166
+ 255
167
+ )
168
+
169
+ edges = cv2.Canny(gray, 35, 120).astype(np.float32)
170
+ edges = cv2.GaussianBlur(edges, (3, 3), 0)
171
+ edges = 255.0 - edges
172
+
173
+ blend = float(np.clip(edge_blend, 0, 1))
174
+
175
+ sketch = (
176
+ dodge * (1.0 - blend)
177
+ + edges * blend
178
+ )
179
+
180
+ rng = np.random.default_rng(7)
181
+
182
+ grain = rng.normal(
183
+ 0,
184
+ max(0, grain_strength),
185
+ sketch.shape
186
+ ).astype(np.float32)
187
+
188
+ sketch = np.clip(sketch + grain, 0, 255)
189
+
190
+ texture = make_paper_texture(
191
+ sketch.shape,
192
+ float(np.clip(paper_texture_strength, 0, 1))
193
+ )
194
+
195
+ sketch = np.clip(sketch * texture, 0, 255)
196
+
197
+ return sketch.astype(np.uint8)
198
+
199
+
200
+ # ============================================================
201
+ # ANIMATION REVEAL ORDER
202
+ # ============================================================
203
+
204
+ def build_reveal_order(sketch):
205
+ """
206
+ Generate a randomized reveal sequence.
207
+ Darker regions tend to appear earlier in the animation.
208
+ """
209
+ height, width = sketch.shape
210
+
211
+ intensity = (255 - sketch).astype(np.float32)
212
+ intensity /= intensity.max() + 1e-6
213
+
214
+ rng = np.random.default_rng(123)
215
+
216
+ jitter = rng.normal(
217
+ 0,
218
+ 0.06,
219
+ intensity.shape
220
+ ).astype(np.float32)
221
+
222
+ score = np.clip(intensity + jitter, 0, 1)
223
+
224
+ band_count = 64
225
+
226
+ bands = np.clip(
227
+ (score * (band_count - 1)).astype(np.uint8),
228
+ 0,
229
+ band_count - 1
230
+ )
231
+
232
+ # Avoid creating two full-size coordinate grids.
233
+ band_values = bands.ravel()
234
+ order_parts = []
235
+
236
+ for band in range(band_count - 1, -1, -1):
237
+ indices = np.flatnonzero(band_values == band)
238
+
239
+ if indices.size == 0:
240
+ continue
241
+
242
+ rng.shuffle(indices)
243
+
244
+ ys = indices // width
245
+ xs = indices % width
246
+
247
+ order_parts.append(
248
+ np.column_stack((ys, xs))
249
+ )
250
+
251
+ if order_parts:
252
+ order = np.concatenate(
253
+ order_parts
254
+ ).astype(np.int32, copy=False)
255
+ else:
256
+ order = np.empty((0, 2), dtype=np.int32)
257
+
258
+ return order
259
+
260
+
261
+ # ============================================================
262
+ # SKETCHFLOW IMAGE CLASS
263
+ # ============================================================
264
+
265
+ class SketchFlowImage:
266
+ """A processed sketch with preview, export and animation methods."""
267
+
268
+ def __init__(self, path):
269
+ self.path = os.fspath(path)
270
+
271
+ self.canvas_w = min(PAPER_WIDTH, MAX_WIDTH)
272
+ self.canvas_h = min(PAPER_HEIGHT, MAX_HEIGHT)
273
+
274
+ self.options = {
275
+ "blur_kernel": BLUR_KERNEL,
276
+ "dodge_strength": DODGE_STRENGTH,
277
+ "contrast_gamma": CONTRAST_GAMMA,
278
+ "edge_blend": EDGE_BLEND,
279
+ "grain_strength": GRAIN_STRENGTH,
280
+ "paper_texture_strength": PAPER_TEXTURE_STRENGTH,
281
+ }
282
+
283
+ self.orig_bgr = None
284
+ self.sketch = None
285
+ self.paper = None
286
+ self.order = None
287
+ self.revealed = None
288
+
289
+ self.img_h = 0
290
+ self.img_w = 0
291
+ self.offset_x = 0
292
+ self.offset_y = 0
293
+ self.total = 0
294
+ self.pos = 0
295
+
296
+ self._load()
297
+
298
+ def _load(self):
299
+ self.orig_bgr = fit_to_box(
300
+ load_image_bgr(self.path),
301
+ self.canvas_w,
302
+ self.canvas_h
303
+ )
304
+
305
+ self._process()
306
+
307
+ def _process(self):
308
+ """Regenerate sketch and animation data using current settings."""
309
+ self.sketch = pencil_sketch(
310
+ self.orig_bgr,
311
+ **self.options
312
+ )
313
+
314
+ self.img_h, self.img_w = self.sketch.shape
315
+
316
+ self.offset_x = (self.canvas_w - self.img_w) // 2
317
+ self.offset_y = (self.canvas_h - self.img_h) // 2
318
+
319
+ self.order = build_reveal_order(self.sketch)
320
+ self.total = len(self.order)
321
+ self.pos = self.total
322
+
323
+ self.revealed = np.ones(
324
+ (self.img_h, self.img_w),
325
+ dtype=bool
326
+ )
327
+
328
+ self.paper = self._create_paper()
329
+
330
+ def _require_image(self):
331
+ if self.sketch is None:
332
+ raise ValueError(
333
+ "No image loaded. Use sketchflow.read(path) first."
334
+ )
335
+
336
+ def _create_paper(self):
337
+ """Create a paper-coloured background with subtle texture."""
338
+ paper = np.empty(
339
+ (self.canvas_h, self.canvas_w, 3),
340
+ dtype=np.uint8
341
+ )
342
+
343
+ # OpenCV uses BGR channel order.
344
+ paper[:] = PAPER_RGB[::-1]
345
+
346
+ texture = make_paper_texture(
347
+ (self.canvas_h, self.canvas_w),
348
+ self.options["paper_texture_strength"]
349
+ )
350
+
351
+ return np.clip(
352
+ paper.astype(np.float32) * texture[..., None],
353
+ 0,
354
+ 255
355
+ ).astype(np.uint8)
356
+
357
+ def _generate_frame(self):
358
+ """Render the current animation state onto the paper."""
359
+ frame = self.paper.copy()
360
+
361
+ region = frame[
362
+ self.offset_y:self.offset_y + self.img_h,
363
+ self.offset_x:self.offset_x + self.img_w
364
+ ]
365
+
366
+ sketch_bgr = cv2.cvtColor(
367
+ self.sketch,
368
+ cv2.COLOR_GRAY2BGR
369
+ )
370
+
371
+ region[self.revealed] = sketch_bgr[self.revealed]
372
+
373
+ return frame
374
+
375
+ # --------------------------------------------------------
376
+ # PUBLIC API
377
+ # --------------------------------------------------------
378
+
379
+ def show(self):
380
+ """Show the complete sketch in an OpenCV window."""
381
+ self._require_image()
382
+
383
+ cv2.imshow(
384
+ "SketchFlow - Pencil Sketch",
385
+ self._generate_frame()
386
+ )
387
+
388
+ cv2.waitKey(0)
389
+ cv2.destroyAllWindows()
390
+
391
+ return self
392
+
393
+ def save(self, filename):
394
+ """
395
+ Save the pure sketch without paper padding.
396
+ The file extension determines the output format.
397
+ """
398
+ self._require_image()
399
+
400
+ filename = os.fspath(filename)
401
+ extension = Path(filename).suffix.lower()
402
+
403
+ if not extension:
404
+ raise ValueError(
405
+ "Provide a filename extension, such as .png or .jpg."
406
+ )
407
+
408
+ success, encoded = cv2.imencode(
409
+ extension,
410
+ self.sketch
411
+ )
412
+
413
+ if not success:
414
+ raise ValueError(
415
+ f"Unsupported output format: {extension}"
416
+ )
417
+
418
+ encoded.tofile(filename)
419
+
420
+ print(f"[SketchFlow] Saved: {os.path.abspath(filename)}")
421
+
422
+ return self
423
+
424
+
425
+ def open(self, filename="sketchflow_preview.png"):
426
+ """Save the sketch and open it using the OS default image viewer."""
427
+ self._require_image()
428
+
429
+ # Save the sketch first
430
+ self.save(filename)
431
+
432
+ abs_path = os.path.abspath(os.fspath(filename))
433
+ system = platform.system()
434
+
435
+ try:
436
+ if system == "Windows":
437
+ os.startfile(abs_path)
438
+
439
+ elif system == "Darwin":
440
+ subprocess.Popen(["open", abs_path])
441
+
442
+ else:
443
+ subprocess.Popen(["xdg-open", abs_path])
444
+
445
+ except (OSError, subprocess.SubprocessError) as exc:
446
+ raise RuntimeError(
447
+ f"Could not open image with the system viewer: {exc}"
448
+ ) from exc
449
+
450
+ return self
451
+
452
+
453
+ def set_options(self, **kwargs):
454
+ """
455
+ Change sketch parameters and regenerate the image.
456
+
457
+ Supported:
458
+ blur_kernel
459
+ dodge_strength
460
+ contrast_gamma
461
+ edge_blend
462
+ grain_strength
463
+ paper_texture_strength
464
+ """
465
+ self._require_image()
466
+
467
+ unknown = set(kwargs) - set(self.options)
468
+
469
+ if unknown:
470
+ raise ValueError(
471
+ "Unknown option(s): "
472
+ + ", ".join(sorted(unknown))
473
+ + ". Supported options: "
474
+ + ", ".join(self.options)
475
+ )
476
+
477
+ updated = dict(self.options)
478
+ updated.update(kwargs)
479
+
480
+ if updated["dodge_strength"] <= 0:
481
+ raise ValueError("dodge_strength must be greater than 0.")
482
+
483
+ if updated["contrast_gamma"] <= 0:
484
+ raise ValueError("contrast_gamma must be greater than 0.")
485
+
486
+ if updated["blur_kernel"] < 1:
487
+ raise ValueError("blur_kernel must be at least 1.")
488
+
489
+ if updated["grain_strength"] < 0:
490
+ raise ValueError("grain_strength cannot be negative.")
491
+
492
+ if not 0 <= updated["edge_blend"] <= 1:
493
+ raise ValueError("edge_blend must be between 0 and 1.")
494
+
495
+ if not 0 <= updated["paper_texture_strength"] <= 1:
496
+ raise ValueError(
497
+ "paper_texture_strength must be between 0 and 1."
498
+ )
499
+
500
+ self.options = updated
501
+ self._process()
502
+
503
+ return self
504
+
505
+ def draw(self):
506
+ """Run the interactive Pygame pencil drawing animation."""
507
+ self._require_image()
508
+
509
+ try:
510
+ import pygame
511
+ except ImportError as exc:
512
+ raise ImportError(
513
+ "Animation requires pygame. Install it using: "
514
+ "pip install pygame"
515
+ ) from exc
516
+
517
+ self.pos = 0
518
+ self.revealed[:] = False
519
+
520
+ pygame.init()
521
+
522
+ try:
523
+ pygame.display.set_caption(
524
+ "SketchFlow - Pencil Drawing Animation"
525
+ )
526
+
527
+ screen = pygame.display.set_mode(
528
+ (self.canvas_w, self.canvas_h),
529
+ pygame.RESIZABLE
530
+ )
531
+
532
+ clock = pygame.time.Clock()
533
+ font = pygame.font.SysFont("consolas", 16, bold=True)
534
+
535
+ speed = SEGMENTS_PER_FRAME
536
+ paused = False
537
+ show_hud = True
538
+ running = True
539
+
540
+ while running:
541
+ for event in pygame.event.get():
542
+ if event.type == pygame.QUIT:
543
+ running = False
544
+
545
+ elif event.type == pygame.KEYDOWN:
546
+ if event.key == pygame.K_ESCAPE:
547
+ running = False
548
+
549
+ elif event.key == pygame.K_SPACE:
550
+ paused = not paused
551
+
552
+ elif event.key == pygame.K_r:
553
+ self.pos = 0
554
+ self.revealed[:] = False
555
+
556
+ elif event.key == pygame.K_h:
557
+ show_hud = not show_hud
558
+
559
+ elif event.key in (
560
+ pygame.K_PLUS,
561
+ pygame.K_EQUALS,
562
+ pygame.K_KP_PLUS
563
+ ):
564
+ speed = min(MAX_SEGMENTS, speed * 2)
565
+
566
+ elif event.key in (
567
+ pygame.K_MINUS,
568
+ pygame.K_KP_MINUS
569
+ ):
570
+ speed = max(MIN_SEGMENTS, speed // 2)
571
+
572
+ elif event.key == pygame.K_s:
573
+ self.save("sketchflow_frame.png")
574
+
575
+ if not paused and self.pos < self.total:
576
+ end = min(self.pos + speed, self.total)
577
+ batch = self.order[self.pos:end]
578
+
579
+ if batch.size:
580
+ self.revealed[
581
+ batch[:, 0],
582
+ batch[:, 1]
583
+ ] = True
584
+
585
+ self.pos = end
586
+
587
+ frame = self._generate_frame()
588
+ rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
589
+
590
+ surface = pygame.surfarray.make_surface(
591
+ rgb.swapaxes(0, 1)
592
+ )
593
+
594
+ screen.fill(WINDOW_BG)
595
+
596
+ win_w, win_h = screen.get_size()
597
+
598
+ # Fit the drawing to the available window area.
599
+ scale = min(
600
+ win_w / self.canvas_w,
601
+ win_h / self.canvas_h
602
+ )
603
+
604
+ display_w = max(1, int(self.canvas_w * scale))
605
+ display_h = max(1, int(self.canvas_h * scale))
606
+
607
+ if (display_w, display_h) != surface.get_size():
608
+ surface = pygame.transform.smoothscale(
609
+ surface,
610
+ (display_w, display_h)
611
+ )
612
+
613
+ x = (win_w - display_w) // 2
614
+ y = (win_h - display_h) // 2
615
+
616
+ screen.blit(surface, (x, y))
617
+
618
+ if show_hud:
619
+ progress = (
620
+ self.pos / max(self.total, 1)
621
+ ) * 100
622
+
623
+ status = (
624
+ "PAUSED" if paused
625
+ else "DONE" if self.pos >= self.total
626
+ else "DRAWING"
627
+ )
628
+
629
+ lines = [
630
+ f"Progress: {progress:5.1f}%",
631
+ f"Speed: {speed:,} pixels/frame",
632
+ f"State: {status}",
633
+ "SPACE Pause | R Restart | H HUD | ESC Quit",
634
+ "+/- Speed | S Save frame",
635
+ ]
636
+
637
+ for index, line in enumerate(lines):
638
+ y_text = 10 + index * 21
639
+
640
+ shadow = font.render(
641
+ line, True, HUD_SHADOW
642
+ )
643
+ text_surface = font.render(
644
+ line, True, HUD_COLOR
645
+ )
646
+
647
+ screen.blit(
648
+ shadow,
649
+ (13, y_text + 1)
650
+ )
651
+ screen.blit(
652
+ text_surface,
653
+ (12, y_text)
654
+ )
655
+
656
+ pygame.display.flip()
657
+ clock.tick(FPS)
658
+
659
+ finally:
660
+ pygame.quit()
661
+
662
+ return self
663
+
664
+
665
+ # ============================================================
666
+ # SIMPLE LIBRARY ENTRY POINT
667
+ # ============================================================
668
+
669
+ def read(path):
670
+ """
671
+ Load an image and create a SketchFlowImage.
672
+
673
+ Example:
674
+ image = sketchflow.read("photo.jpg")
675
+ """
676
+ return SketchFlowImage(path)
677
+
678
+
679
+ __all__ = ["read", "SketchFlowImage"]