stippler 0.2.0.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.
- stippler/__init__.py +54 -0
- stippler/classic.py +278 -0
- stippler/illustration.py +311 -0
- stippler/pipeline.py +679 -0
- stippler/voronoi.py +253 -0
- stippler-0.2.0.0.dist-info/METADATA +210 -0
- stippler-0.2.0.0.dist-info/RECORD +11 -0
- stippler-0.2.0.0.dist-info/WHEEL +5 -0
- stippler-0.2.0.0.dist-info/entry_points.txt +2 -0
- stippler-0.2.0.0.dist-info/licenses/LICENSE.txt +26 -0
- stippler-0.2.0.0.dist-info/top_level.txt +1 -0
stippler/__init__.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Hand-drawn weighted Voronoi stippling.
|
|
2
|
+
|
|
3
|
+
Grayscale image -> stipple geometry/output, with controls for early-stopped
|
|
4
|
+
relaxation, varying dot size, positional jitter, imperfect dot edges and
|
|
5
|
+
contrast (gamma). Optional Lu et al. scientific-illustration enhancements
|
|
6
|
+
(boundary/silhouette density, interior sparsity, lighting, depth, silhouette
|
|
7
|
+
curves) are toggled via :class:`IllustrationParams`. Targets Python 3.9.10 for
|
|
8
|
+
Rhino 8 CPython compatibility.
|
|
9
|
+
|
|
10
|
+
The high-level entry point is :func:`stipple`, which returns a
|
|
11
|
+
:class:`StippleResult` carrying the points, radii, per-dot polygons and
|
|
12
|
+
optional silhouette curves. Render helpers (:func:`render_matplotlib`,
|
|
13
|
+
:func:`render_svg`, :func:`save_points`) turn that into files.
|
|
14
|
+
"""
|
|
15
|
+
from . import voronoi
|
|
16
|
+
from .illustration import IllustrationParams
|
|
17
|
+
from .pipeline import (
|
|
18
|
+
StippleResult,
|
|
19
|
+
stipple,
|
|
20
|
+
load_density,
|
|
21
|
+
prepare_density,
|
|
22
|
+
normalize,
|
|
23
|
+
initialization,
|
|
24
|
+
relax,
|
|
25
|
+
assign_radii,
|
|
26
|
+
apply_position_jitter,
|
|
27
|
+
dot_polygons,
|
|
28
|
+
render_matplotlib,
|
|
29
|
+
render_svg,
|
|
30
|
+
save_points,
|
|
31
|
+
main,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
__version__ = "0.2.0.0"
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"StippleResult",
|
|
38
|
+
"stipple",
|
|
39
|
+
"IllustrationParams",
|
|
40
|
+
"load_density",
|
|
41
|
+
"prepare_density",
|
|
42
|
+
"normalize",
|
|
43
|
+
"initialization",
|
|
44
|
+
"relax",
|
|
45
|
+
"assign_radii",
|
|
46
|
+
"apply_position_jitter",
|
|
47
|
+
"dot_polygons",
|
|
48
|
+
"render_matplotlib",
|
|
49
|
+
"render_svg",
|
|
50
|
+
"save_points",
|
|
51
|
+
"main",
|
|
52
|
+
"voronoi",
|
|
53
|
+
"__version__",
|
|
54
|
+
]
|
stippler/classic.py
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
#! /usr/bin/env python3
|
|
2
|
+
# -----------------------------------------------------------------------------
|
|
3
|
+
# Weighted Voronoi Stippler
|
|
4
|
+
# Copyright (2017) Nicolas P. Rougier - BSD license
|
|
5
|
+
#
|
|
6
|
+
# Implementation of:
|
|
7
|
+
# Weighted Voronoi Stippling, Adrian Secord
|
|
8
|
+
# Symposium on Non-Photorealistic Animation and Rendering (NPAR), 2002
|
|
9
|
+
# -----------------------------------------------------------------------------
|
|
10
|
+
# Some usage examples
|
|
11
|
+
#
|
|
12
|
+
# stippler.py boots.jpg --save --force --n_point 20000 --n_iter 50
|
|
13
|
+
# --pointsize 0.5 2.5 --figsize 8 --interactive
|
|
14
|
+
# stippler.py plant.png --save --force --n_point 20000 --n_iter 50
|
|
15
|
+
# --pointsize 0.5 1.5 --figsize 8
|
|
16
|
+
# stippler.py gradient.png --save --force --n_point 5000 --n_iter 50
|
|
17
|
+
# --pointsize 1.0 1.0 --figsize 6
|
|
18
|
+
# -----------------------------------------------------------------------------
|
|
19
|
+
# usage: stippler.py [-h] [--n_iter n] [--n_point n] [--epsilon n]
|
|
20
|
+
# [--pointsize min,max) (min,max] [--figsize w,h] [--force]
|
|
21
|
+
# [--save] [--display] [--interactive]
|
|
22
|
+
# image filename
|
|
23
|
+
#
|
|
24
|
+
# Weighted Vororonoi Stippler
|
|
25
|
+
#
|
|
26
|
+
# positional arguments:
|
|
27
|
+
# image filename Density image filename
|
|
28
|
+
#
|
|
29
|
+
# optional arguments:
|
|
30
|
+
# -h, --help show this help message and exit
|
|
31
|
+
# --n_iter n Maximum number of iterations
|
|
32
|
+
# --n_point n Number of points
|
|
33
|
+
# --epsilon n Early stop criterion
|
|
34
|
+
# --pointsize (min,max) (min,max)
|
|
35
|
+
# Point mix/max size for final display
|
|
36
|
+
# --figsize w,h Figure size
|
|
37
|
+
# --force Force recomputation
|
|
38
|
+
# --save Save computed points
|
|
39
|
+
# --display Display final result
|
|
40
|
+
# --interactive Display intermediate results (slower)
|
|
41
|
+
# -----------------------------------------------------------------------------
|
|
42
|
+
import tqdm
|
|
43
|
+
import os.path
|
|
44
|
+
import numpy as np
|
|
45
|
+
import scipy.ndimage
|
|
46
|
+
from PIL import Image
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
from . import voronoi
|
|
50
|
+
except ImportError: # allow running the file directly (python classic.py)
|
|
51
|
+
import voronoi
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def imread(filename):
|
|
55
|
+
"""Load an image as a 2D float grayscale array.
|
|
56
|
+
|
|
57
|
+
Replaces the removed ``scipy.misc.imread(..., flatten=True, mode='L')``
|
|
58
|
+
so the code runs on modern SciPy / Python 3.9.10 (Rhino CPython runtime).
|
|
59
|
+
"""
|
|
60
|
+
img = Image.open(filename).convert("L")
|
|
61
|
+
return np.asarray(img, dtype=np.float64)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def normalize(D):
|
|
65
|
+
Vmin, Vmax = D.min(), D.max()
|
|
66
|
+
if Vmax - Vmin > 1e-5:
|
|
67
|
+
D = (D-Vmin)/(Vmax-Vmin)
|
|
68
|
+
else:
|
|
69
|
+
D = np.zeros_like(D)
|
|
70
|
+
return D
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def initialization(n, D):
|
|
74
|
+
"""
|
|
75
|
+
Return n points distributed over [xmin, xmax] x [ymin, ymax]
|
|
76
|
+
according to (normalized) density distribution.
|
|
77
|
+
|
|
78
|
+
with xmin, xmax = 0, density.shape[1]
|
|
79
|
+
ymin, ymax = 0, density.shape[0]
|
|
80
|
+
|
|
81
|
+
The algorithm here is a simple rejection sampling.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
samples = []
|
|
85
|
+
while len(samples) < n:
|
|
86
|
+
# X = np.random.randint(0, D.shape[1], 10*n)
|
|
87
|
+
# Y = np.random.randint(0, D.shape[0], 10*n)
|
|
88
|
+
X = np.random.uniform(0, D.shape[1], 10*n)
|
|
89
|
+
Y = np.random.uniform(0, D.shape[0], 10*n)
|
|
90
|
+
P = np.random.uniform(0, 1, 10*n)
|
|
91
|
+
index = 0
|
|
92
|
+
while index < len(X) and len(samples) < n:
|
|
93
|
+
x, y = X[index], Y[index]
|
|
94
|
+
x_, y_ = int(np.floor(x)), int(np.floor(y))
|
|
95
|
+
if P[index] < D[y_, x_]:
|
|
96
|
+
samples.append([x, y])
|
|
97
|
+
index += 1
|
|
98
|
+
return np.array(samples)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
if __name__ == '__main__':
|
|
102
|
+
import argparse
|
|
103
|
+
import matplotlib.pyplot as plt
|
|
104
|
+
from matplotlib.animation import FuncAnimation
|
|
105
|
+
|
|
106
|
+
default = {
|
|
107
|
+
"n_point": 5000,
|
|
108
|
+
"n_iter": 50,
|
|
109
|
+
"threshold": 255,
|
|
110
|
+
"force": False,
|
|
111
|
+
"save": False,
|
|
112
|
+
"figsize": 6,
|
|
113
|
+
"display": False,
|
|
114
|
+
"interactive": False,
|
|
115
|
+
"pointsize": (1.0, 1.0),
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
description = "Weighted Vororonoi Stippler"
|
|
119
|
+
parser = argparse.ArgumentParser(description=description)
|
|
120
|
+
parser.add_argument('filename', metavar='image filename', type=str,
|
|
121
|
+
help='Density image filename ')
|
|
122
|
+
parser.add_argument('--n_iter', metavar='n', type=int,
|
|
123
|
+
default=default["n_iter"],
|
|
124
|
+
help='Maximum number of iterations')
|
|
125
|
+
parser.add_argument('--n_point', metavar='n', type=int,
|
|
126
|
+
default=default["n_point"],
|
|
127
|
+
help='Number of points')
|
|
128
|
+
parser.add_argument('--pointsize', metavar='(min,max)', type=float,
|
|
129
|
+
nargs=2, default=default["pointsize"],
|
|
130
|
+
help='Point mix/max size for final display')
|
|
131
|
+
parser.add_argument('--figsize', metavar='w,h', type=int,
|
|
132
|
+
default=default["figsize"],
|
|
133
|
+
help='Figure size')
|
|
134
|
+
parser.add_argument('--force', action='store_true',
|
|
135
|
+
default=default["force"],
|
|
136
|
+
help='Force recomputation')
|
|
137
|
+
parser.add_argument('--threshold', metavar='n', type=int,
|
|
138
|
+
default=default["threshold"],
|
|
139
|
+
help='Grey level threshold')
|
|
140
|
+
parser.add_argument('--save', action='store_true',
|
|
141
|
+
default=default["save"],
|
|
142
|
+
help='Save computed points')
|
|
143
|
+
parser.add_argument('--display', action='store_true',
|
|
144
|
+
default=default["display"],
|
|
145
|
+
help='Display final result')
|
|
146
|
+
parser.add_argument('--interactive', action='store_true',
|
|
147
|
+
default=default["interactive"],
|
|
148
|
+
help='Display intermediate results (slower)')
|
|
149
|
+
args = parser.parse_args()
|
|
150
|
+
|
|
151
|
+
filename = args.filename
|
|
152
|
+
density = imread(filename)
|
|
153
|
+
|
|
154
|
+
# We want (approximately) 500 pixels per voronoi region
|
|
155
|
+
zoom = (args.n_point * 500) / (density.shape[0]*density.shape[1])
|
|
156
|
+
zoom = int(round(np.sqrt(zoom)))
|
|
157
|
+
density = scipy.ndimage.zoom(density, zoom, order=0)
|
|
158
|
+
# Apply threshold onto image
|
|
159
|
+
# Any color > threshold will be white
|
|
160
|
+
density = np.minimum(density, args.threshold)
|
|
161
|
+
|
|
162
|
+
density = 1.0 - normalize(density)
|
|
163
|
+
density = density[::-1, :]
|
|
164
|
+
density_P = density.cumsum(axis=1)
|
|
165
|
+
density_Q = density_P.cumsum(axis=1)
|
|
166
|
+
|
|
167
|
+
dirname = os.path.dirname(filename)
|
|
168
|
+
basename = (os.path.basename(filename).split('.'))[0]
|
|
169
|
+
pdf_filename = os.path.join(dirname, basename + "-stipple.pdf")
|
|
170
|
+
png_filename = os.path.join(dirname, basename + "-stipple.png")
|
|
171
|
+
dat_filename = os.path.join(dirname, basename + "-stipple.npy")
|
|
172
|
+
|
|
173
|
+
# Initialization
|
|
174
|
+
if not os.path.exists(dat_filename) or args.force:
|
|
175
|
+
points = initialization(args.n_point, density)
|
|
176
|
+
print("Nb points:", args.n_point)
|
|
177
|
+
print("Nb iterations:", args.n_iter)
|
|
178
|
+
else:
|
|
179
|
+
points = np.load(dat_filename)
|
|
180
|
+
print("Nb points:", len(points))
|
|
181
|
+
print("Nb iterations: -")
|
|
182
|
+
print("Density file: %s (resized to %dx%d)" % (
|
|
183
|
+
filename, density.shape[1], density.shape[0]))
|
|
184
|
+
print("Output file (PDF): %s " % pdf_filename)
|
|
185
|
+
print(" (PNG): %s " % png_filename)
|
|
186
|
+
print(" (DAT): %s " % dat_filename)
|
|
187
|
+
|
|
188
|
+
xmin, xmax = 0, density.shape[1]
|
|
189
|
+
ymin, ymax = 0, density.shape[0]
|
|
190
|
+
bbox = np.array([xmin, xmax, ymin, ymax])
|
|
191
|
+
ratio = (xmax-xmin)/(ymax-ymin)
|
|
192
|
+
|
|
193
|
+
# Interactive display
|
|
194
|
+
if args.interactive:
|
|
195
|
+
|
|
196
|
+
# Setup figure
|
|
197
|
+
fig = plt.figure(figsize=(args.figsize, args.figsize/ratio),
|
|
198
|
+
facecolor="white")
|
|
199
|
+
ax = fig.add_axes([0, 0, 1, 1], frameon=False)
|
|
200
|
+
ax.set_xlim([xmin, xmax])
|
|
201
|
+
ax.set_xticks([])
|
|
202
|
+
ax.set_ylim([ymin, ymax])
|
|
203
|
+
ax.set_yticks([])
|
|
204
|
+
scatter = ax.scatter(points[:, 0], points[:, 1], s=1,
|
|
205
|
+
facecolor="k", edgecolor="None")
|
|
206
|
+
|
|
207
|
+
def update(frame):
|
|
208
|
+
global points
|
|
209
|
+
# Recompute weighted centroids
|
|
210
|
+
regions, points = voronoi.centroids(
|
|
211
|
+
points,
|
|
212
|
+
density,
|
|
213
|
+
density_P,
|
|
214
|
+
density_Q
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
# Update figure
|
|
218
|
+
Pi = points.astype(int)
|
|
219
|
+
X = np.maximum(np.minimum(Pi[:, 0], density.shape[1]-1), 0)
|
|
220
|
+
Y = np.maximum(np.minimum(Pi[:, 1], density.shape[0]-1), 0)
|
|
221
|
+
sizes = (args.pointsize[0] +
|
|
222
|
+
(args.pointsize[1]-args.pointsize[0])*density[Y, X])
|
|
223
|
+
scatter.set_offsets(points)
|
|
224
|
+
scatter.set_sizes(sizes)
|
|
225
|
+
bar.update()
|
|
226
|
+
|
|
227
|
+
# Save result at last frame
|
|
228
|
+
if (frame == args.n_iter-2 and
|
|
229
|
+
(not os.path.exists(dat_filename) or args.save)):
|
|
230
|
+
np.save(dat_filename, points)
|
|
231
|
+
plt.savefig(pdf_filename)
|
|
232
|
+
plt.savefig(png_filename)
|
|
233
|
+
|
|
234
|
+
bar = tqdm.tqdm(total=args.n_iter)
|
|
235
|
+
animation = FuncAnimation(fig, update,
|
|
236
|
+
repeat=False, frames=args.n_iter-1)
|
|
237
|
+
plt.show()
|
|
238
|
+
|
|
239
|
+
elif not os.path.exists(dat_filename) or args.force:
|
|
240
|
+
for i in tqdm.trange(args.n_iter):
|
|
241
|
+
regions, points = voronoi.centroids(
|
|
242
|
+
points,
|
|
243
|
+
density,
|
|
244
|
+
density_P,
|
|
245
|
+
density_Q
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
if (args.save or args.display) and not args.interactive:
|
|
249
|
+
fig = plt.figure(figsize=(args.figsize, args.figsize/ratio),
|
|
250
|
+
facecolor="white")
|
|
251
|
+
ax = fig.add_axes([0, 0, 1, 1], frameon=False)
|
|
252
|
+
ax.set_xlim([xmin, xmax])
|
|
253
|
+
ax.set_xticks([])
|
|
254
|
+
ax.set_ylim([ymin, ymax])
|
|
255
|
+
ax.set_yticks([])
|
|
256
|
+
scatter = ax.scatter(points[:, 0], points[:, 1], s=1,
|
|
257
|
+
facecolor="k", edgecolor="None")
|
|
258
|
+
Pi = points.astype(int)
|
|
259
|
+
X = np.maximum(np.minimum(Pi[:, 0], density.shape[1]-1), 0)
|
|
260
|
+
Y = np.maximum(np.minimum(Pi[:, 1], density.shape[0]-1), 0)
|
|
261
|
+
sizes = (args.pointsize[0] +
|
|
262
|
+
(args.pointsize[1]-args.pointsize[0])*density[Y, X])
|
|
263
|
+
scatter.set_offsets(points)
|
|
264
|
+
scatter.set_sizes(sizes)
|
|
265
|
+
|
|
266
|
+
# Save stipple points and tippled image
|
|
267
|
+
if not os.path.exists(dat_filename) or args.save:
|
|
268
|
+
np.save(dat_filename, points)
|
|
269
|
+
plt.savefig(pdf_filename)
|
|
270
|
+
plt.savefig(png_filename)
|
|
271
|
+
|
|
272
|
+
if args.display:
|
|
273
|
+
plt.show()
|
|
274
|
+
|
|
275
|
+
# Plot voronoi regions if you want
|
|
276
|
+
# for region in vor.filtered_regions:
|
|
277
|
+
# vertices = vor.vertices[region, :]
|
|
278
|
+
# ax.plot(vertices[:, 0], vertices[:, 1], linewidth=.5, color='.5' )
|
stippler/illustration.py
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
#! /usr/bin/env python3
|
|
2
|
+
# -----------------------------------------------------------------------------
|
|
3
|
+
# Scientific-illustration density and silhouette-curve helpers.
|
|
4
|
+
#
|
|
5
|
+
# Optional enhancements adapted from Lu et al., "Non-Photorealistic Volume
|
|
6
|
+
# Rendering Using Stippling Techniques" (vis_stipple.pdf). Each feature is
|
|
7
|
+
# independently toggled via :class:`IllustrationParams`.
|
|
8
|
+
# -----------------------------------------------------------------------------
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
import scipy.ndimage
|
|
13
|
+
from PIL import Image
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _unit3(v):
|
|
17
|
+
v = np.asarray(v, dtype=np.float64)
|
|
18
|
+
n = np.linalg.norm(v)
|
|
19
|
+
if n < 1e-12:
|
|
20
|
+
return np.array([0.0, 0.0, 1.0])
|
|
21
|
+
return v / n
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _normalize01(field):
|
|
25
|
+
lo, hi = float(field.min()), float(field.max())
|
|
26
|
+
if hi - lo > 1e-8:
|
|
27
|
+
return (field - lo) / (hi - lo)
|
|
28
|
+
return np.zeros_like(field)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class IllustrationParams(object):
|
|
32
|
+
"""Optional Lu et al. illustration controls (all off by default)."""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
boundary=False,
|
|
37
|
+
boundary_kgc=0.4,
|
|
38
|
+
boundary_kgs=0.5,
|
|
39
|
+
boundary_kge=1.0,
|
|
40
|
+
silhouette_density=False,
|
|
41
|
+
silhouette_ksc=0.3,
|
|
42
|
+
silhouette_kss=0.5,
|
|
43
|
+
silhouette_kse=1.0,
|
|
44
|
+
interior=False,
|
|
45
|
+
interior_kte=0.5,
|
|
46
|
+
lighting=False,
|
|
47
|
+
light=(1.0, 1.0, 1.0),
|
|
48
|
+
lighting_kle=2.0,
|
|
49
|
+
depth=False,
|
|
50
|
+
depth_kde=1.0,
|
|
51
|
+
gradient_sigma=1.0,
|
|
52
|
+
gradient_size=False,
|
|
53
|
+
gradient_size_strength=1.0,
|
|
54
|
+
silhouette_curves=False,
|
|
55
|
+
curve_threshold_log=0.12,
|
|
56
|
+
curve_threshold_eye=0.35,
|
|
57
|
+
curve_threshold_grad=0.2,
|
|
58
|
+
curve_length=3.0,
|
|
59
|
+
curve_stride=2,
|
|
60
|
+
view=(0.0, 0.0, 1.0),
|
|
61
|
+
):
|
|
62
|
+
self.boundary = boundary
|
|
63
|
+
self.boundary_kgc = boundary_kgc
|
|
64
|
+
self.boundary_kgs = boundary_kgs
|
|
65
|
+
self.boundary_kge = boundary_kge
|
|
66
|
+
self.silhouette_density = silhouette_density
|
|
67
|
+
self.silhouette_ksc = silhouette_ksc
|
|
68
|
+
self.silhouette_kss = silhouette_kss
|
|
69
|
+
self.silhouette_kse = silhouette_kse
|
|
70
|
+
self.interior = interior
|
|
71
|
+
self.interior_kte = interior_kte
|
|
72
|
+
self.lighting = lighting
|
|
73
|
+
self.light = _unit3(light)
|
|
74
|
+
self.lighting_kle = lighting_kle
|
|
75
|
+
self.depth = depth
|
|
76
|
+
self.depth_kde = depth_kde
|
|
77
|
+
self.gradient_sigma = gradient_sigma
|
|
78
|
+
self.gradient_size = gradient_size
|
|
79
|
+
self.gradient_size_strength = gradient_size_strength
|
|
80
|
+
self.silhouette_curves = silhouette_curves
|
|
81
|
+
self.curve_threshold_log = curve_threshold_log
|
|
82
|
+
self.curve_threshold_eye = curve_threshold_eye
|
|
83
|
+
self.curve_threshold_grad = curve_threshold_grad
|
|
84
|
+
self.curve_length = curve_length
|
|
85
|
+
self.curve_stride = max(1, int(curve_stride))
|
|
86
|
+
self.view = _unit3(view)
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def active(self):
|
|
90
|
+
return (
|
|
91
|
+
self.boundary
|
|
92
|
+
or self.silhouette_density
|
|
93
|
+
or self.interior
|
|
94
|
+
or self.lighting
|
|
95
|
+
or self.depth
|
|
96
|
+
or self.gradient_size
|
|
97
|
+
or self.silhouette_curves
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def load_aux_map(filename, target_shape, mode="L"):
|
|
102
|
+
"""Load and resize an auxiliary image to ``target_shape`` (H, W)."""
|
|
103
|
+
img = Image.open(filename)
|
|
104
|
+
if mode == "RGB":
|
|
105
|
+
img = img.convert("RGB")
|
|
106
|
+
else:
|
|
107
|
+
img = img.convert("L")
|
|
108
|
+
arr = np.asarray(img, dtype=np.float64)
|
|
109
|
+
if arr.shape[:2] != target_shape:
|
|
110
|
+
zoom_y = target_shape[0] / arr.shape[0]
|
|
111
|
+
zoom_x = target_shape[1] / arr.shape[1]
|
|
112
|
+
if mode == "RGB":
|
|
113
|
+
arr = scipy.ndimage.zoom(arr, (zoom_y, zoom_x, 1.0), order=1)
|
|
114
|
+
else:
|
|
115
|
+
arr = scipy.ndimage.zoom(arr, zoom_y, order=1)
|
|
116
|
+
return arr
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def normals_from_rgb(rgb):
|
|
120
|
+
"""Decode an RGB normal map (0–255) into unit normals (H, W, 3)."""
|
|
121
|
+
n = rgb / 127.5 - 1.0
|
|
122
|
+
length = np.linalg.norm(n, axis=-1, keepdims=True)
|
|
123
|
+
length = np.maximum(length, 1e-8)
|
|
124
|
+
return n / length
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def normals_from_gradient(gx, gy):
|
|
128
|
+
"""Infer approximate normals from a 2D tone gradient (H, W)."""
|
|
129
|
+
nx = -gx
|
|
130
|
+
ny = -gy
|
|
131
|
+
nz = np.sqrt(np.maximum(1.0 - np.clip(nx * nx + ny * ny, 0.0, 1.0), 0.0))
|
|
132
|
+
n = np.stack([nx, ny, nz], axis=-1)
|
|
133
|
+
length = np.linalg.norm(n, axis=-1, keepdims=True)
|
|
134
|
+
length = np.maximum(length, 1e-8)
|
|
135
|
+
return n / length
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def compute_image_gradient(field, sigma=1.0):
|
|
139
|
+
"""Return smoothed ``(gx, gy, magnitude)`` for a tone/density field."""
|
|
140
|
+
work = field
|
|
141
|
+
if sigma > 0:
|
|
142
|
+
work = scipy.ndimage.gaussian_filter(field, sigma)
|
|
143
|
+
gy, gx = np.gradient(work)
|
|
144
|
+
mag = np.hypot(gx, gy)
|
|
145
|
+
return gx, gy, _normalize01(mag)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def compute_log(field, sigma=1.0):
|
|
149
|
+
"""Laplacian-of-Gaussian response (used for silhouette-curve detection)."""
|
|
150
|
+
blurred = (
|
|
151
|
+
scipy.ndimage.gaussian_filter(field, sigma)
|
|
152
|
+
if sigma > 0
|
|
153
|
+
else field
|
|
154
|
+
)
|
|
155
|
+
return scipy.ndimage.laplace(blurred)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def resolve_normals(tone, gx, gy, normal_map_rgb=None):
|
|
159
|
+
"""Return unit normals, using a normal map when provided."""
|
|
160
|
+
if normal_map_rgb is not None:
|
|
161
|
+
return normals_from_rgb(normal_map_rgb)
|
|
162
|
+
return normals_from_gradient(gx, gy)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def boundary_factor(tone, grad_mag, kgc, kgs, kge):
|
|
166
|
+
"""Lu et al. boundary emphasis (Eq. 5), normalized to [0, 1]."""
|
|
167
|
+
raw = tone * (kgc + kgs * np.power(grad_mag, kge))
|
|
168
|
+
return _normalize01(raw)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def silhouette_factor(tone, silhouette_strength, ksc, kss, kse):
|
|
172
|
+
"""Lu et al. silhouette emphasis (Eq. 6), normalized to [0, 1].
|
|
173
|
+
|
|
174
|
+
``silhouette_strength`` is ``1 - |n·view|`` when a normal map is available,
|
|
175
|
+
otherwise a high-gradient edge proxy in ``[0, 1]``.
|
|
176
|
+
"""
|
|
177
|
+
raw = tone * (ksc + kss * np.power(silhouette_strength, kse))
|
|
178
|
+
return _normalize01(raw)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def interior_factor(grad_mag, kte):
|
|
182
|
+
"""Lu et al. interior/sparsity factor (Eq. 9), normalized to [0, 1]."""
|
|
183
|
+
return _normalize01(np.power(np.clip(grad_mag, 0.0, 1.0), kte))
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def lighting_factor(normals, light, kle):
|
|
187
|
+
"""Shading modulation from surface normals, normalized to [0, 1]."""
|
|
188
|
+
ndotl = np.sum(normals * light, axis=-1)
|
|
189
|
+
raw = np.power(np.clip(1.0 - ndotl, 0.0, 1.0), kle)
|
|
190
|
+
return _normalize01(raw)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def depth_factor(depth01, kde):
|
|
194
|
+
"""Depth attenuation: far regions sparser (simplified from Eq. 8)."""
|
|
195
|
+
raw = np.power(np.clip(1.0 - depth01, 0.0, 1.0), max(kde, 1e-3))
|
|
196
|
+
return _normalize01(raw)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def build_illustration_density(
|
|
200
|
+
tone,
|
|
201
|
+
params,
|
|
202
|
+
grad_mag=None,
|
|
203
|
+
gx=None,
|
|
204
|
+
gy=None,
|
|
205
|
+
normals=None,
|
|
206
|
+
normals_from_map=False,
|
|
207
|
+
depth01=None,
|
|
208
|
+
):
|
|
209
|
+
"""
|
|
210
|
+
Combine tone with optional illustration factors into one density field.
|
|
211
|
+
"""
|
|
212
|
+
if not params.active:
|
|
213
|
+
return tone
|
|
214
|
+
|
|
215
|
+
if grad_mag is None or gx is None or gy is None:
|
|
216
|
+
gx, gy, grad_mag = compute_image_gradient(tone, params.gradient_sigma)
|
|
217
|
+
if normals is None and (
|
|
218
|
+
params.silhouette_density
|
|
219
|
+
or params.lighting
|
|
220
|
+
or params.silhouette_curves
|
|
221
|
+
):
|
|
222
|
+
normals = normals_from_gradient(gx, gy)
|
|
223
|
+
|
|
224
|
+
density = tone.copy()
|
|
225
|
+
if params.boundary:
|
|
226
|
+
density *= boundary_factor(
|
|
227
|
+
tone,
|
|
228
|
+
grad_mag,
|
|
229
|
+
params.boundary_kgc,
|
|
230
|
+
params.boundary_kgs,
|
|
231
|
+
params.boundary_kge
|
|
232
|
+
)
|
|
233
|
+
if params.silhouette_density:
|
|
234
|
+
if normals_from_map:
|
|
235
|
+
sil = (
|
|
236
|
+
1.0 - np.clip(
|
|
237
|
+
np.abs(np.sum(normals * params.view, axis=-1)),
|
|
238
|
+
0.0,
|
|
239
|
+
1.0
|
|
240
|
+
)
|
|
241
|
+
)
|
|
242
|
+
else:
|
|
243
|
+
sil = grad_mag
|
|
244
|
+
density *= silhouette_factor(
|
|
245
|
+
tone,
|
|
246
|
+
sil,
|
|
247
|
+
params.silhouette_ksc,
|
|
248
|
+
params.silhouette_kss,
|
|
249
|
+
params.silhouette_kse
|
|
250
|
+
)
|
|
251
|
+
if params.interior:
|
|
252
|
+
density *= interior_factor(grad_mag, params.interior_kte)
|
|
253
|
+
if params.lighting:
|
|
254
|
+
density *= lighting_factor(normals, params.light, params.lighting_kle)
|
|
255
|
+
if params.depth and depth01 is not None:
|
|
256
|
+
density *= depth_factor(depth01, params.depth_kde)
|
|
257
|
+
|
|
258
|
+
return np.clip(density, 0.0, 1.0)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def gradient_size_scale(grad_mag, strength):
|
|
262
|
+
"""Per-pixel radius multiplier from gradient magnitude."""
|
|
263
|
+
if strength <= 0:
|
|
264
|
+
return None
|
|
265
|
+
return 1.0 + strength * grad_mag
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def extract_silhouette_curves(tone, gx, gy, grad_mag, params, normals=None,
|
|
269
|
+
normals_from_map=False):
|
|
270
|
+
"""Return line segments ``[((x0,y0),(x1,y1)), ...]`` in y-up coords."""
|
|
271
|
+
if not params.silhouette_curves:
|
|
272
|
+
return []
|
|
273
|
+
|
|
274
|
+
log = compute_log(tone, params.gradient_sigma)
|
|
275
|
+
|
|
276
|
+
grad_dot_view = gx * params.view[0] + gy * params.view[1]
|
|
277
|
+
if np.linalg.norm(params.view[:2]) < 1e-6:
|
|
278
|
+
grad_dot_view = gy
|
|
279
|
+
|
|
280
|
+
mask = (
|
|
281
|
+
(tone * log < params.curve_threshold_log)
|
|
282
|
+
& (grad_dot_view < params.curve_threshold_eye)
|
|
283
|
+
& (grad_mag > params.curve_threshold_grad)
|
|
284
|
+
)
|
|
285
|
+
if normals_from_map and normals is not None:
|
|
286
|
+
ndv = np.abs(np.sum(normals * params.view, axis=-1))
|
|
287
|
+
mask &= ndv < params.curve_threshold_eye
|
|
288
|
+
|
|
289
|
+
dir_x = gy
|
|
290
|
+
dir_y = -gx
|
|
291
|
+
length = np.hypot(dir_x, dir_y)
|
|
292
|
+
length = np.maximum(length, 1e-8)
|
|
293
|
+
dir_x = dir_x / length
|
|
294
|
+
dir_y = dir_y / length
|
|
295
|
+
|
|
296
|
+
ys, xs = np.where(mask)
|
|
297
|
+
if params.curve_stride > 1:
|
|
298
|
+
keep = (
|
|
299
|
+
(xs % params.curve_stride == 0) &
|
|
300
|
+
(ys % params.curve_stride == 0)
|
|
301
|
+
)
|
|
302
|
+
xs = xs[keep]
|
|
303
|
+
ys = ys[keep]
|
|
304
|
+
|
|
305
|
+
half = params.curve_length * 0.5
|
|
306
|
+
segments = []
|
|
307
|
+
for x, y in zip(xs, ys):
|
|
308
|
+
dx = dir_x[y, x] * half
|
|
309
|
+
dy = dir_y[y, x] * half
|
|
310
|
+
segments.append(((x - dx, y - dy), (x + dx, y + dy)))
|
|
311
|
+
return segments
|