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 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' )
@@ -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