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.
- sketchflow-0.1.0/PKG-INFO +76 -0
- sketchflow-0.1.0/README.md +64 -0
- sketchflow-0.1.0/pyproject.toml +22 -0
- sketchflow-0.1.0/setup.cfg +4 -0
- sketchflow-0.1.0/sketchflow.egg-info/PKG-INFO +76 -0
- sketchflow-0.1.0/sketchflow.egg-info/SOURCES.txt +8 -0
- sketchflow-0.1.0/sketchflow.egg-info/dependency_links.txt +1 -0
- sketchflow-0.1.0/sketchflow.egg-info/requires.txt +5 -0
- sketchflow-0.1.0/sketchflow.egg-info/top_level.txt +1 -0
- sketchflow-0.1.0/sketchflow.py +679 -0
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -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"]
|