1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403
|
"""Visualize the triangulation algorithms used by the Shapes layer.
This example uses napari layers to draw each of the components of a face and
edge triangulation in a Shapes layer.
Shapes layers don't "just" draw polygons, ellipses, and so on: each shape,
as well as its borders, is broken down into a collection of triangles (this
is called a *triangulation*), which are then sent to OpenGL for drawing:
drawing triangles is one of the "visualization primitives" in OpenGL and most
2D and 3D drawing frameworks.
It turns out that converting arbitrary shapes into a collection of triangles
can be quite tricky: very shallow angles cause errors in the algorithms, and
can also make certain desirable properties (such as edges not overlapping with
each other when a polygon makes a sharp turn) actually impossible to achieve
with fast (single-pass) algorithms.
This example draws the Shapes layer normally, but also overlays all the
elements of the triangulation: the triangles themselves, and the normal vectors
on each polygon vertex, from which the triangulation is computed.
"""
from dataclasses import dataclass, fields
from functools import partial
try:
import numba
except ImportError:
# Create a dummy numba.njit to allow running this script without numba
import toolz as tz
class numba:
@tz.curry
def njit(func, cache=True, inline=None):
return func
import numpy as np
import napari
from napari.layers import Shapes
from napari.layers.shapes._accelerated_triangulate_python import (
generate_2D_edge_meshes_py,
)
def generate_regular_polygon(n, radius=1):
"""Generate vertices of a regular n-sided polygon centered at the origin.
Parameters
----------
n : int
The number of sides (vertices).
radius : float, optional
The radius of the circumscribed circle.
Returns
-------
np.ndarray
An array of shape (n, 2) containing the vertex coordinates.
"""
angles = np.linspace(0, 2 * np.pi, n, endpoint=False)
return np.column_stack((radius * np.cos(angles), radius * np.sin(angles)))
def get_reference_edge_triangulation_points(shape: Shapes) -> np.ndarray:
"""Get the non-accelerated points"""
shapes = shape._data_view.shapes
path_list = [(x.data, x._closed) for x in shapes]
mesh_list = [generate_2D_edge_meshes_py(path, closed)
for path, closed in path_list]
mesh = tuple(np.concatenate(el, axis=0) for el in zip(*mesh_list, strict=False))
return mesh[0] + mesh[1]
def generate_order_vectors(path_, closed) -> np.ndarray:
"""Generate the vectors tangent to the path.
Parameters
----------
path_ : np.ndarray, shape (N, 2)
A list of 2D path coordinates.
closed : bool
Whether the coordinates represent a closed polygon or an open
line/path.
Returns
-------
vec : np.ndarray, shape (N, 2, 2)
A set of vectors, defined by a 2D position and a 2D projection.
"""
raw_vecs = np.diff(path_, axis=0)
norm = np.linalg.norm(raw_vecs, axis=1, keepdims=True)
directions = raw_vecs / norm
vec = np.empty((path_.shape[0], 2, 2))
vec[:, 0] = path_
vec[:-1, 1] = directions
if closed:
# point from the last vertex towards the first vertex
vec[-1, 1] = (
(path_[0] - path_[-1]) / np.linalg.norm(path_[-1] - path_[0])
)
else:
# point back towards the penultimate vertex
vec[-1, 1] = -vec[-2, 1]
return vec
def generate_miter_helper_vectors(
direction_vectors_array: np.ndarray
) -> np.ndarray:
"""Generate the miter helper vectors.
The miter helper vectors are a pair of vectors for each point in the path,
which together help define whether a bevel join will be needed. The first
vector is half of the normalized direction vector for that vertex, and the
second is *minus* half of the normalized direction vector for the previous
vertex. Their angle is the angle of the edges at that vertex.
Parameters
----------
direction_vectors_array : array of shape (n, 2)
array of normalized (length 1) direction vectors for each point in the
path.
Returns
-------
array of shape (n, 2, 2)
array of miter helper vectors
"""
half_direction = direction_vectors_array.copy()
half_direction[:, 1] *= 0.5
half_prev_direction = half_direction.copy()
half_prev_direction[:, 1] *= -1
half_prev_direction[:, 1] = np.roll(half_prev_direction[:, 1], 1, axis=0)
half_prev_direction[:, 0] += half_direction[:, 1]
return np.concatenate([half_direction, half_prev_direction], axis=0)
@numba.njit
def generate_orthogonal_vectors(direction_vectors: np.ndarray) -> np.ndarray:
"""Generate the orthogonal vectors to the direction vectors.
The orthogonal vector starts at the middle of the direction vector and is
orthogonal to it in the positive orientation. Its length is half of the
direction vector, to align with the normalized edge width.
Parameters
----------
direction_vectors : array, shape (n, 2, 2)
The direction vector start points (``direction_vectors[:, 0, :]``) and
directions (``direction_vectors[:, 1, :]``).
Returns
-------
orthogonal_vectors : array, shape(n, 2, 2)
The orthogonal vector start points and directions.
"""
position = 0
vector = 1
y, x = 0, 1
half_direction = 0.5 * direction_vectors[:, 1, :]
orthogonal_vectors = direction_vectors.copy()
orthogonal_vectors[:, position] = (
direction_vectors[:, position] + half_direction
)
orthogonal_vectors[:, vector, y] = -half_direction[:, x]
orthogonal_vectors[:, vector, x] = half_direction[:, y]
return orthogonal_vectors
@numba.njit
def generate_miter_vectors(
mesh: tuple[np.ndarray, np.ndarray, np.ndarray]
) -> np.ndarray:
"""For each point on path, generate the vectors pointing to miter points.
Parameters
----------
mesh : tuple[np.ndarray]
vertices, offsets, and triangles of the mesh corresponding to the edges
of a single shape.
Returns
-------
np.ndarray, shape (n, 2, 2)
Positions and projections of vectors corresponding to the miter points
offset from the path points.
"""
vec_points = np.empty((mesh[0].shape[0], 2, 2))
vec_points[:, 0] = mesh[0]
vec_points[:, 1] = mesh[1]
return vec_points
@numba.njit
def generate_edge_triangle_borders(centers, offsets, triangles) -> np.ndarray:
"""Generate 3 vectors that represent the borders of the triangle.
These are vectors to show the *ordering* of the triangles in the data.
Parameters
----------
centers, offsets, triangles : np.ndarray of float
The mesh triangulation of the shape's edge path.
Returns
-------
borders : np.ndarray of float
Positions and projections corresponding to each triangle edge in
the triangulation of the path.
"""
borders = np.empty((len(triangles)*3, 2, 2), dtype=centers.dtype)
position = 0
projection = 1
for i, triangle in enumerate(triangles):
a, b, c = triangle
a1 = centers[a] + offsets[a]
b1 = centers[b] + offsets[b]
c1 = centers[c] + offsets[c]
borders[i * 3, position] = a1
borders[i * 3, projection] = (b1 - a1)
borders[i * 3 + 1, position] = b1
borders[i * 3 + 1, projection] = (c1 - b1)
borders[i * 3 + 2, position] = c1
borders[i * 3 + 2, projection] = (a1 - c1)
return borders
@numba.njit
def generate_face_triangle_borders(vertices, triangles) -> np.ndarray:
"""For each triangle in mesh generate 3 vectors that represent the borders of the triangle.
"""
borders = np.empty((len(triangles)*3, 2, 2), dtype=vertices.dtype)
for i, triangle in enumerate(triangles):
a, b, c = triangle
a1 = vertices[a]
b1 = vertices[b]
c1 = vertices[c]
borders[i * 3, 0] = a1
borders[i * 3, 1] = (b1 - a1)
borders[i * 3 + 1, 0] = b1
borders[i * 3 + 1, 1] = (c1 - b1)
borders[i * 3 + 2, 0] = c1
borders[i * 3 + 2, 1] = (a1 - c1)
return borders
@dataclass
class Helpers:
"""Simple class to hold all auxiliary vector data for a shapes layer."""
reference_join_points: np.ndarray
join_points: np.ndarray
direction_vectors: np.ndarray
miter_helper_vectors: np.ndarray
orthogonal_vectors: np.ndarray
miter_vectors: np.ndarray
triangles_vectors: np.ndarray
face_triangles_vectors: np.ndarray
def asdict(dataclass_instance):
"""Shallow copy version of `dataclasses.asdict`."""
return {f.name: getattr(dataclass_instance, f.name)
for f in fields(dataclass_instance)}
def get_helper_data_from_shapes(shapes_layer: Shapes) -> Helpers:
"""Function to generate all auxiliary data for a shapes layer."""
shapes = shapes_layer._data_view.shapes
mesh_list = [(s._edge_vertices, s._edge_offsets, s._edge_triangles)
for s in shapes]
path_list = [(s.data, s._closed) for s in shapes]
mesh = tuple(np.concatenate(el, axis=0) for el in zip(*mesh_list, strict=False))
face_mesh_list = [(s._face_vertices, s._face_triangles)
for s in shapes
if len(s._face_vertices) > 0]
order_vectors_list = [generate_order_vectors(path_, closed)
for path_, closed in path_list]
helpers = Helpers(
reference_join_points=get_reference_edge_triangulation_points(
shapes_layer
),
join_points=mesh[0] + mesh[1],
direction_vectors=np.concatenate(order_vectors_list, axis=0),
miter_helper_vectors=np.concatenate(
[generate_miter_helper_vectors(o) for o in order_vectors_list],
axis=0,
),
orthogonal_vectors=np.concatenate(
[generate_orthogonal_vectors(o) for o in order_vectors_list],
axis=0,
),
miter_vectors=np.concatenate(
[generate_miter_vectors(m) for m in mesh_list],
axis=0,
),
triangles_vectors=np.concatenate(
[generate_edge_triangle_borders(*m) for m in mesh_list],
axis=0,
),
face_triangles_vectors=np.concatenate(
[generate_face_triangle_borders(*m) for m in face_mesh_list],
axis=0,
),
)
return helpers
def update_helper_layers(viewer: napari.Viewer, source_layer: Shapes):
updated_helpers = get_helper_data_from_shapes(source_layer)
for name, data in asdict(updated_helpers).items():
viewer.layers[name].data = data
def add_helper_layers(viewer: napari.Viewer, source_layer):
"""Add helper layers to the viewer that track with the source shapes."""
helpers = get_helper_data_from_shapes(source_layer)
# sizes and colors are hardcoded based on vibes
sizes = [0.2, 0.1, 0.1, 0.06, 0.04, 0.05, 0.04, 0.04]
colors = ['yellow', 'white', 'red', 'blue',
'green', 'yellow', 'pink', 'magenta']
for (name, data), size, color in zip(
asdict(helpers).items(), sizes, colors, strict=False
):
if 'points' in name:
viewer.add_points(data, name=name, size=size, face_color=color)
else: # all other fields are vectors
viewer.add_vectors(
data,
name=name,
vector_style='arrow', edge_width=size, edge_color=color,
)
source_layer.events.set_data.connect(
partial(update_helper_layers, viewer=viewer, source_layer=source_layer)
)
def pentagram():
radius = 10
n = 5
angles = np.linspace(0, 4 * np.pi, n, endpoint=False)
return np.column_stack((radius * np.cos(angles), radius * np.sin(angles)))
path = np.array([[0,0], [0,1], [1,1], [1,0]]) * 10
sparkle = np.array([[1, 1], [10, 0], [1, -1], [0, -10],
[-1, -1], [-10, 0], [-1, 1], [0, 10]])
fork = np.array([[2, 10], [0, -5], [-2, 10], [-2, -10], [2, -10]])
poly_hole = np.array([[0,0], [10, 0], [10, 10], [0, 10], [0, 0], [2, 5], [5, 8], [8, 5], [5, 2], [2, 5]]) *1.5
polygons = [
# square
generate_regular_polygon(4, radius=1) * 10,
# decagon
generate_regular_polygon(10, radius=1) * 10 + np.array([[25, 0]]),
# triangle
generate_regular_polygon(3, radius=1) * 10 + np.array([[0, 25]]),
# two sharp prongs
fork + np.array([[25, 25]]),
# a four-sided star
sparkle + np.array([[50, 0]]),
# same star, but winding in the opposite direction
sparkle[::-1] + np.array([[50, 26]]),
# problem shape —
# lighting bolt with sharp angles and overlapping edge widths
np.array([[10.97627008, 14.30378733],
[12.05526752, 10.89766366],
[8.47309599, 12.91788226],
[8.75174423, 17.83546002],
[19.27325521, 7.66883038],
[15.83450076, 10.5778984]],
) + np.array([[60, -15]]),
poly_hole + np.array([[65, 20]]),
pentagram() + np.array([[70, 75]]),
]
paths = [
# a simple backwards-c shape
path + np.array([[0, 50]]),
# an unclosed decagon
generate_regular_polygon(10, radius=1) * 10 + np.array([[25, 50]]),
# a three-point straight line (tests collinear points in path)
np.array([[0, -10], [0, 0], [0, 10]]) + np.array([[50, 50]]),
poly_hole + np.array([[65, 45]]),
]
shapes = polygons + paths
shape_types=['polygon'] * len(polygons) + ['path'] * len(paths)
viewer = napari.Viewer()
shapes_layer = viewer.add_shapes(shapes, shape_type=shape_types, name='shapes')
add_helper_layers(viewer, source_layer=shapes_layer)
viewer.layers.selection = {shapes_layer}
viewer.reset_view()
if __name__ == '__main__':
napari.run()
|