scigraphs-engine 0.1.1__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.
- scigraphs_engine/__init__.py +50 -0
- scigraphs_engine/_camera_math.py +85 -0
- scigraphs_engine/adaptive.py +468 -0
- scigraphs_engine/api.py +1284 -0
- scigraphs_engine/backends/__init__.py +4 -0
- scigraphs_engine/backends/wgpu/__init__.py +31 -0
- scigraphs_engine/backends/wgpu/buffers.py +232 -0
- scigraphs_engine/backends/wgpu/camera.py +148 -0
- scigraphs_engine/backends/wgpu/device.py +108 -0
- scigraphs_engine/backends/wgpu/pipelines.py +180 -0
- scigraphs_engine/backends/wgpu/render.py +250 -0
- scigraphs_engine/backends/wgpu/renderer.py +257 -0
- scigraphs_engine/backends/wgpu/shaders.py +381 -0
- scigraphs_engine/backends/wgpu/target.py +115 -0
- scigraphs_engine/backends/wgpu/wgsl/arrow.json +78 -0
- scigraphs_engine/backends/wgpu/wgsl/arrow.wgsl +76 -0
- scigraphs_engine/backends/wgpu/wgsl/arrow_flat.json +48 -0
- scigraphs_engine/backends/wgpu/wgsl/arrow_flat.wgsl +82 -0
- scigraphs_engine/backends/wgpu/wgsl/heb_line.json +81 -0
- scigraphs_engine/backends/wgpu/wgsl/heb_line.wgsl +137 -0
- scigraphs_engine/backends/wgpu/wgsl/line_f.json +108 -0
- scigraphs_engine/backends/wgpu/wgsl/line_f.wgsl +115 -0
- scigraphs_engine/backends/wgpu/wgsl/ribbon.json +93 -0
- scigraphs_engine/backends/wgpu/wgsl/ribbon.wgsl +131 -0
- scigraphs_engine/backends/wgpu/wgsl/ribbon_f.json +162 -0
- scigraphs_engine/backends/wgpu/wgsl/ribbon_f.wgsl +204 -0
- scigraphs_engine/backends/wgpu/wgsl/ribbon_id.json +54 -0
- scigraphs_engine/backends/wgpu/wgsl/ribbon_id.wgsl +103 -0
- scigraphs_engine/backends/wgpu/wgsl/round_point.json +22 -0
- scigraphs_engine/backends/wgpu/wgsl/round_point.wgsl +68 -0
- scigraphs_engine/backends/wgpu/wgsl/round_point_f.json +118 -0
- scigraphs_engine/backends/wgpu/wgsl/round_point_f.wgsl +141 -0
- scigraphs_engine/backends/wgpu/wgsl/smooth_color.json +32 -0
- scigraphs_engine/backends/wgpu/wgsl/smooth_color.wgsl +40 -0
- scigraphs_engine/backends/wgpu/wgsl/sphere.json +83 -0
- scigraphs_engine/backends/wgpu/wgsl/sphere.wgsl +115 -0
- scigraphs_engine/backends/wgpu/wgsl/sphere_f.json +152 -0
- scigraphs_engine/backends/wgpu/wgsl/sphere_f.wgsl +185 -0
- scigraphs_engine/backends/wgpu/wgsl/sphere_id.json +44 -0
- scigraphs_engine/backends/wgpu/wgsl/sphere_id.wgsl +80 -0
- scigraphs_engine/backends/wgpu/wgsl/uniform_color.json +37 -0
- scigraphs_engine/backends/wgpu/wgsl/uniform_color.wgsl +42 -0
- scigraphs_engine/blocks.py +76 -0
- scigraphs_engine/bundling/__init__.py +11 -0
- scigraphs_engine/bundling/fdeb.py +310 -0
- scigraphs_engine/bundling/mingle.py +480 -0
- scigraphs_engine/bundling/routed.py +420 -0
- scigraphs_engine/bundling/sbeb.py +292 -0
- scigraphs_engine/channels.py +91 -0
- scigraphs_engine/communities.py +125 -0
- scigraphs_engine/edge_styles.py +566 -0
- scigraphs_engine/filters.py +754 -0
- scigraphs_engine/gpu_compute.py +17 -0
- scigraphs_engine/lod.py +89 -0
- scigraphs_engine/mesh.py +480 -0
- scigraphs_engine/palette.py +68 -0
- scigraphs_engine/settings.py +151 -0
- scigraphs_engine/simplify.py +352 -0
- scigraphs_engine/source.py +143 -0
- scigraphs_engine-0.1.1.dist-info/METADATA +103 -0
- scigraphs_engine-0.1.1.dist-info/RECORD +64 -0
- scigraphs_engine-0.1.1.dist-info/WHEEL +5 -0
- scigraphs_engine-0.1.1.dist-info/licenses/LICENSE +21 -0
- scigraphs_engine-0.1.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Nothing in this package may import bpy, gpu, bmesh or mathutils; tests in the
|
|
2
|
+
# SciGraphs repository parse this directory for those names. Everything below
|
|
3
|
+
# numpy is lazy, so a MeshSpec caller never pays for igraph and scipy.
|
|
4
|
+
|
|
5
|
+
import importlib as _importlib
|
|
6
|
+
|
|
7
|
+
__version__ = "0.1.1"
|
|
8
|
+
|
|
9
|
+
from .mesh import ( # noqa: F401
|
|
10
|
+
DrawState, MeshGroup, MeshSpec, ShaderRef, LINES, POINTS, TRIS,
|
|
11
|
+
)
|
|
12
|
+
from .settings import Clause, Settings, PREFIX # noqa: F401
|
|
13
|
+
from .source import ArraySource, GraphSource, EDGE, POINT # noqa: F401
|
|
14
|
+
|
|
15
|
+
_LAZY = (
|
|
16
|
+
"adaptive", "api", "blocks", "channels", "communities", "edge_styles",
|
|
17
|
+
"filters", "gpu_compute", "lod", "mesh", "palette", "settings", "simplify",
|
|
18
|
+
"source",
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
# Lets `from scigraphs_engine import Graph` resolve without importing api.
|
|
22
|
+
_API_NAMES = (
|
|
23
|
+
"BackendUnavailable", "Camera", "ChannelResult", "ChannelSpec",
|
|
24
|
+
"ChannelUnavailable", "Condition", "EngineError", "Geometry", "Graph",
|
|
25
|
+
"Image", "Report", "UnknownChannel", "channel", "channel_names",
|
|
26
|
+
"channel_specs", "colormap_names", "gpu_available",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"ArraySource", "Clause", "DrawState", "EDGE", "GraphSource", "LINES",
|
|
31
|
+
"MeshGroup", "MeshSpec", "POINT", "POINTS", "PREFIX", "Settings",
|
|
32
|
+
"ShaderRef", "TRIS", "__version__",
|
|
33
|
+
] + list(_API_NAMES) + list(_LAZY)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def __getattr__(name):
|
|
37
|
+
"""Import a submodule or a public API name on first access (PEP 562)."""
|
|
38
|
+
if name in _LAZY:
|
|
39
|
+
module = _importlib.import_module("." + name, __name__)
|
|
40
|
+
globals()[name] = module
|
|
41
|
+
return module
|
|
42
|
+
if name in _API_NAMES:
|
|
43
|
+
value = getattr(_importlib.import_module(".api", __name__), name)
|
|
44
|
+
globals()[name] = value
|
|
45
|
+
return value
|
|
46
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def __dir__():
|
|
50
|
+
return sorted(set(globals()) | set(_LAZY) | set(_API_NAMES))
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# Transcribed from backends/wgpu/camera.py, held float32-exact against it by
|
|
2
|
+
# tests/test_camera.py. Depth is z in [0, 1] (WebGPU), not OpenGL's.
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _normalize(v):
|
|
8
|
+
v = np.asarray(v, dtype=np.float64)
|
|
9
|
+
n = np.linalg.norm(v)
|
|
10
|
+
return v / n if n > 1e-12 else v
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def look_at(eye, target, up=(0.0, 0.0, 1.0)):
|
|
14
|
+
"""World -> view, right-handed, looking down -z. +Z up like Blender."""
|
|
15
|
+
eye = np.asarray(eye, dtype=np.float64)
|
|
16
|
+
f = _normalize(np.asarray(target, dtype=np.float64) - eye)
|
|
17
|
+
up = _normalize(up)
|
|
18
|
+
if abs(float(np.dot(f, up))) > 0.9999:
|
|
19
|
+
up = np.array([0.0, 1.0, 0.0]) if abs(f[1]) < 0.9 else \
|
|
20
|
+
np.array([1.0, 0.0, 0.0])
|
|
21
|
+
s = _normalize(np.cross(f, up))
|
|
22
|
+
u = np.cross(s, f)
|
|
23
|
+
m = np.eye(4, dtype=np.float64)
|
|
24
|
+
m[0, :3], m[1, :3], m[2, :3] = s, u, -f
|
|
25
|
+
m[0, 3] = -float(np.dot(s, eye))
|
|
26
|
+
m[1, 3] = -float(np.dot(u, eye))
|
|
27
|
+
m[2, 3] = float(np.dot(f, eye))
|
|
28
|
+
return m.astype(np.float32)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def perspective(fov_y_deg, aspect, near, far):
|
|
32
|
+
"""View -> clip with z in [0, 1]."""
|
|
33
|
+
f = 1.0 / np.tan(np.radians(float(fov_y_deg)) * 0.5)
|
|
34
|
+
m = np.zeros((4, 4), dtype=np.float64)
|
|
35
|
+
m[0, 0] = f / float(aspect)
|
|
36
|
+
m[1, 1] = f
|
|
37
|
+
m[2, 2] = far / (near - far)
|
|
38
|
+
m[2, 3] = (far * near) / (near - far)
|
|
39
|
+
m[3, 2] = -1.0
|
|
40
|
+
return m.astype(np.float32)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def bounding_sphere(coords):
|
|
44
|
+
"""(center, radius) of the box's circumsphere. Over-frames a flat layout."""
|
|
45
|
+
coords = np.asarray(coords, dtype=np.float32).reshape(-1, 3)
|
|
46
|
+
if coords.size == 0:
|
|
47
|
+
return np.zeros(3, dtype=np.float32), 1.0
|
|
48
|
+
lo = coords.min(axis=0)
|
|
49
|
+
hi = coords.max(axis=0)
|
|
50
|
+
center = (lo + hi) * 0.5
|
|
51
|
+
radius = float(np.linalg.norm(hi - lo)) * 0.5
|
|
52
|
+
return center, (radius if radius > 1e-6 else 1.0)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def fit_view(coords, size, fov_y_deg=45.0, margin=1.15,
|
|
56
|
+
direction=(0.0, -1.0, 0.35), up=(0.0, 0.0, 1.0)):
|
|
57
|
+
"""A resolved api.Camera framing all of ``coords`` at ``size``."""
|
|
58
|
+
from .api import Camera
|
|
59
|
+
|
|
60
|
+
width, height = size
|
|
61
|
+
center, radius = bounding_sphere(coords)
|
|
62
|
+
aspect = float(width) / float(height)
|
|
63
|
+
# Fit the tighter axis: vertical-only fitting crops a portrait render.
|
|
64
|
+
half_v = np.radians(float(fov_y_deg)) * 0.5
|
|
65
|
+
half_h = np.arctan(np.tan(half_v) * aspect)
|
|
66
|
+
dist = radius * float(margin) / np.sin(min(half_v, half_h))
|
|
67
|
+
|
|
68
|
+
eye = center - _normalize(direction).astype(np.float32) * dist
|
|
69
|
+
near = max(dist - radius * 2.0, dist * 1e-3)
|
|
70
|
+
far = dist + radius * 4.0
|
|
71
|
+
return Camera(look_at(eye, center, up),
|
|
72
|
+
perspective(fov_y_deg, aspect, near, far), near, far)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def look_at_camera(eye, target, up, fov_y_deg, size, near=None, far=None):
|
|
76
|
+
from .api import Camera
|
|
77
|
+
|
|
78
|
+
width, height = size
|
|
79
|
+
eye = np.asarray(eye, dtype=np.float64)
|
|
80
|
+
span = float(np.linalg.norm(np.asarray(target, dtype=np.float64) - eye))
|
|
81
|
+
near = float(near) if near is not None else max(span * 1e-3, 1e-4)
|
|
82
|
+
far = float(far) if far is not None else max(span * 10.0, near * 100.0)
|
|
83
|
+
return Camera(look_at(eye, target, up),
|
|
84
|
+
perspective(fov_y_deg, float(width) / float(height),
|
|
85
|
+
near, far), near, far)
|
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
# Level of detail over simplify.build_hierarchy's community tree: one ancestor
|
|
2
|
+
# per node. Crowding opens a community, the caller's ratios merge it back.
|
|
3
|
+
|
|
4
|
+
import collections
|
|
5
|
+
import math
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
Camera = collections.namedtuple(
|
|
10
|
+
"Camera", "persp focal_y half_w half_h forward eye")
|
|
11
|
+
|
|
12
|
+
CROWDING_REFRESH_COS = 0.9994 # ~2 deg; crowding varies only with direction
|
|
13
|
+
|
|
14
|
+
# Expand over this, merge under COLLAPSE_MAX_MEASURED. The gap damps thrash.
|
|
15
|
+
EXPAND_MIN_PREDICTED = 0.55
|
|
16
|
+
|
|
17
|
+
EXPAND_MIN_PX = 2.5
|
|
18
|
+
|
|
19
|
+
COLLAPSE_MAX_MEASURED = 0.25
|
|
20
|
+
|
|
21
|
+
COLLAPSE_HIDDEN_FRACTION = 0.5
|
|
22
|
+
|
|
23
|
+
DIRECTION_BINS = 192 # ~15 degree bins over the sphere
|
|
24
|
+
|
|
25
|
+
BLAME_SHARE_DEG = 0.0 # also file a merge under directions within this angle
|
|
26
|
+
|
|
27
|
+
EXPAND_HYSTERESIS = 0.0
|
|
28
|
+
|
|
29
|
+
MAX_REGROUP_FRACTION = 1.0
|
|
30
|
+
|
|
31
|
+
MAX_MERGE_LOSS = 0.3 # 0 draws every node, unbounded merges them all
|
|
32
|
+
|
|
33
|
+
SIGMA_K = 2.0
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class CutState:
|
|
37
|
+
"""Per-object persistent state for the adaptive cut."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, levels):
|
|
40
|
+
self.sizes = [lvl["centers"].shape[0] for lvl in levels]
|
|
41
|
+
self.hidden = [{} for _ in levels]
|
|
42
|
+
self.futile = [{} for _ in levels]
|
|
43
|
+
self.tried = [{} for _ in levels]
|
|
44
|
+
self.expanded = [np.zeros(n, dtype=bool) for n in self.sizes]
|
|
45
|
+
self.mean_child_r = [None] * len(levels)
|
|
46
|
+
self.crowd = [None] * len(levels)
|
|
47
|
+
self.crowd_dir = np.zeros(3)
|
|
48
|
+
self.changes = 0
|
|
49
|
+
self.frames = 0
|
|
50
|
+
|
|
51
|
+
def _from(self, caches, level_index, bin_index, create=False):
|
|
52
|
+
mask = caches[level_index].get(bin_index)
|
|
53
|
+
if mask is None:
|
|
54
|
+
mask = np.zeros(self.sizes[level_index], dtype=bool)
|
|
55
|
+
if create:
|
|
56
|
+
caches[level_index][bin_index] = mask
|
|
57
|
+
return mask
|
|
58
|
+
|
|
59
|
+
def hidden_from(self, level_index, bin_index, create=False):
|
|
60
|
+
"""Which entries of this level scored poorly from this direction."""
|
|
61
|
+
return self._from(self.hidden, level_index, bin_index, create)
|
|
62
|
+
|
|
63
|
+
def futile_from(self, level_index, bin_index, create=False):
|
|
64
|
+
"""Where merging was tried from this direction and did not pay."""
|
|
65
|
+
return self._from(self.futile, level_index, bin_index, create)
|
|
66
|
+
|
|
67
|
+
def tried_from(self, level_index, bin_index, create=False):
|
|
68
|
+
"""Whose merge has already been tried from this direction."""
|
|
69
|
+
return self._from(self.tried, level_index, bin_index, create)
|
|
70
|
+
|
|
71
|
+
def reset(self):
|
|
72
|
+
for caches in (self.hidden, self.futile, self.tried):
|
|
73
|
+
for cache in caches:
|
|
74
|
+
cache.clear()
|
|
75
|
+
for arr in self.expanded:
|
|
76
|
+
arr[:] = False
|
|
77
|
+
self.crowd = [None] * len(self.crowd)
|
|
78
|
+
self.changes = 0
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def camera_terms(persp_mat, window_mat, height):
|
|
82
|
+
"""Camera terms for the predictor. The focal term must come from
|
|
83
|
+
``window_mat``; taken off ``persp_mat`` it picks up orientation."""
|
|
84
|
+
window = np.asarray(window_mat, dtype=np.float64)
|
|
85
|
+
persp = np.asarray(persp_mat, dtype=np.float64)
|
|
86
|
+
focal_y = float(window[1][1])
|
|
87
|
+
aspect = focal_y / float(window[0][0]) if window[0][0] else 1.0
|
|
88
|
+
# w = -z_view, so the bottom row is the viewing direction in source space.
|
|
89
|
+
forward = persp[3, :3]
|
|
90
|
+
forward = forward / (np.linalg.norm(forward) + 1e-12)
|
|
91
|
+
# Dividing the projection out of persp leaves the modelview.
|
|
92
|
+
modelview = np.linalg.solve(window, persp)
|
|
93
|
+
eye = -modelview[:3, :3].T @ modelview[:3, 3]
|
|
94
|
+
return Camera(persp, focal_y, 0.5 * height * aspect, 0.5 * height,
|
|
95
|
+
forward, eye)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def depth_of(points, camera):
|
|
99
|
+
"""Perspective divide (distance along the view axis) for each point."""
|
|
100
|
+
return np.abs(points @ camera.persp[3, :3] + camera.persp[3, 3]) + 1e-9
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def project_px(points, camera):
|
|
104
|
+
"""Project points to pixel coordinates, plus pixels per unit at each depth."""
|
|
105
|
+
homog = np.empty((points.shape[0], 4), dtype=np.float64)
|
|
106
|
+
homog[:, :3] = points
|
|
107
|
+
homog[:, 3] = 1.0
|
|
108
|
+
clip = homog @ camera.persp.T
|
|
109
|
+
w = np.abs(clip[:, 3]) + 1e-9
|
|
110
|
+
px = np.empty((points.shape[0], 2), dtype=np.float64)
|
|
111
|
+
px[:, 0] = clip[:, 0] / w * camera.half_w
|
|
112
|
+
px[:, 1] = clip[:, 1] / w * camera.half_h
|
|
113
|
+
return px, camera.focal_y / w * camera.half_h
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def children_of(levels, level_index, leaf):
|
|
117
|
+
"""Centers, radii and parent map one step below ``level_index``. Below
|
|
118
|
+
level 0 that is the original nodes, taken from ``leaf``."""
|
|
119
|
+
if level_index > 0:
|
|
120
|
+
child = levels[level_index - 1]
|
|
121
|
+
return (child["centers"].astype(np.float64),
|
|
122
|
+
child["radii"].astype(np.float64),
|
|
123
|
+
child["parent"])
|
|
124
|
+
radii = np.asarray(leaf["radii"], dtype=np.float64)
|
|
125
|
+
coords = np.asarray(leaf["coords"], dtype=np.float64)
|
|
126
|
+
if radii.ndim == 0:
|
|
127
|
+
radii = np.full(coords.shape[0], float(radii))
|
|
128
|
+
return coords, radii, levels[0]["member_of"]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def crowding(levels, leaf, level_index, camera):
|
|
132
|
+
"""Screen room the children have, in [0, 1]: footprint over the area their
|
|
133
|
+
centers cover. The ellipse is dilated or it collapses on two children."""
|
|
134
|
+
n_parent = levels[level_index]["centers"].shape[0]
|
|
135
|
+
centers, radii, parent = children_of(levels, level_index, leaf)
|
|
136
|
+
|
|
137
|
+
px, scale = project_px(centers, camera)
|
|
138
|
+
child_px = radii * scale
|
|
139
|
+
|
|
140
|
+
count = np.bincount(parent, minlength=n_parent).astype(np.float64)
|
|
141
|
+
safe = np.maximum(count, 1.0)
|
|
142
|
+
mean_x = np.bincount(parent, weights=px[:, 0], minlength=n_parent) / safe
|
|
143
|
+
mean_y = np.bincount(parent, weights=px[:, 1], minlength=n_parent) / safe
|
|
144
|
+
dx = px[:, 0] - mean_x[parent]
|
|
145
|
+
dy = px[:, 1] - mean_y[parent]
|
|
146
|
+
s_xx = np.bincount(parent, weights=dx * dx, minlength=n_parent) / safe
|
|
147
|
+
s_yy = np.bincount(parent, weights=dy * dy, minlength=n_parent) / safe
|
|
148
|
+
s_xy = np.bincount(parent, weights=dx * dy, minlength=n_parent) / safe
|
|
149
|
+
|
|
150
|
+
trace = s_xx + s_yy
|
|
151
|
+
gap = np.sqrt(np.maximum(trace * trace - 4.0 * (s_xx * s_yy - s_xy * s_xy),
|
|
152
|
+
0.0))
|
|
153
|
+
mean_r = np.bincount(parent, weights=child_px, minlength=n_parent) / safe
|
|
154
|
+
semi_major = SIGMA_K * np.sqrt(np.maximum(0.5 * (trace + gap), 0.0)) + mean_r
|
|
155
|
+
semi_minor = SIGMA_K * np.sqrt(np.maximum(0.5 * (trace - gap), 0.0)) + mean_r
|
|
156
|
+
available = np.pi * semi_major * semi_minor
|
|
157
|
+
|
|
158
|
+
demand = np.bincount(parent, weights=np.pi * child_px * child_px,
|
|
159
|
+
minlength=n_parent)
|
|
160
|
+
ratio = demand / np.maximum(available, 1e-12)
|
|
161
|
+
return np.clip(1.0 / np.maximum(ratio, 1.0), 0.0, 1.0), count
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def legibility(levels, leaf, level_index, camera, state=None):
|
|
165
|
+
"""Mean on-screen radius the children of each community would have."""
|
|
166
|
+
level = levels[level_index]
|
|
167
|
+
mean_r = _mean_child_radius(levels, leaf, level_index, state)
|
|
168
|
+
return mean_r * camera.focal_y / depth_of(
|
|
169
|
+
level["centers"], camera) * camera.half_h
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _mean_child_radius(levels, leaf, level_index, state=None):
|
|
173
|
+
"""Mean world radius of each community's children. Camera independent."""
|
|
174
|
+
if state is not None and state.mean_child_r[level_index] is not None:
|
|
175
|
+
return state.mean_child_r[level_index]
|
|
176
|
+
n_parent = levels[level_index]["centers"].shape[0]
|
|
177
|
+
_centers, radii, parent = children_of(levels, level_index, leaf)
|
|
178
|
+
count = np.maximum(np.bincount(parent, minlength=n_parent), 1)
|
|
179
|
+
mean_r = np.bincount(parent, weights=radii, minlength=n_parent) / count
|
|
180
|
+
if state is not None:
|
|
181
|
+
state.mean_child_r[level_index] = mean_r
|
|
182
|
+
return mean_r
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def predict_expand(levels, leaf, level_index, camera, state=None,
|
|
186
|
+
min_predicted=EXPAND_MIN_PREDICTED, min_px=EXPAND_MIN_PX,
|
|
187
|
+
hysteresis=0.0):
|
|
188
|
+
"""``(ok, predicted, child_px)``: whether communities here may be opened.
|
|
189
|
+
``hysteresis`` keeps a drifting camera from flipping one open and shut."""
|
|
190
|
+
predicted, count = None, None
|
|
191
|
+
if state is not None:
|
|
192
|
+
cached = state.crowd[level_index]
|
|
193
|
+
if cached is not None and \
|
|
194
|
+
float(state.crowd_dir @ camera.forward) >= CROWDING_REFRESH_COS:
|
|
195
|
+
predicted, count = cached
|
|
196
|
+
if predicted is None:
|
|
197
|
+
predicted, count = crowding(levels, leaf, level_index, camera)
|
|
198
|
+
if state is not None:
|
|
199
|
+
state.crowd[level_index] = (predicted, count)
|
|
200
|
+
|
|
201
|
+
child_px = legibility(levels, leaf, level_index, camera, state)
|
|
202
|
+
open_now = (state.expanded[level_index] if state is not None
|
|
203
|
+
else np.zeros(count.shape, dtype=bool))
|
|
204
|
+
slack = np.where(open_now, 1.0 - hysteresis, 1.0 + hysteresis)
|
|
205
|
+
ok = ((count > 0) & (predicted >= min_predicted * slack)
|
|
206
|
+
& (child_px >= min_px * slack))
|
|
207
|
+
return ok, predicted, child_px
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _bin_directions(count=DIRECTION_BINS):
|
|
211
|
+
"""Fibonacci spiral, not a lat-long grid: at the poles a lat-long grid bins
|
|
212
|
+
two identical directions apart on the sign of a zero."""
|
|
213
|
+
i = np.arange(count, dtype=np.float64) + 0.5
|
|
214
|
+
z = 1.0 - 2.0 * i / count
|
|
215
|
+
r = np.sqrt(np.maximum(0.0, 1.0 - z * z))
|
|
216
|
+
theta = math.pi * (1.0 + math.sqrt(5.0)) * i
|
|
217
|
+
return np.column_stack((r * np.cos(theta), r * np.sin(theta), z))
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
BIN_DIRECTIONS = _bin_directions()
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _bin_neighbors(degrees=BLAME_SHARE_DEG):
|
|
224
|
+
if degrees <= 0.0:
|
|
225
|
+
return [np.array([i]) for i in range(DIRECTION_BINS)]
|
|
226
|
+
close = BIN_DIRECTIONS @ BIN_DIRECTIONS.T >= math.cos(math.radians(degrees))
|
|
227
|
+
return [np.flatnonzero(row) for row in close]
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
BIN_NEIGHBORS = _bin_neighbors()
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def direction_bin(view_dir):
|
|
234
|
+
d = np.asarray(view_dir, dtype=np.float64)
|
|
235
|
+
return int(np.argmax(BIN_DIRECTIONS @ (d / (np.linalg.norm(d) + 1e-12))))
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def apply_measurement(levels, state, drawn, measured, view_dir,
|
|
239
|
+
max_measured=COLLAPSE_MAX_MEASURED,
|
|
240
|
+
min_hidden_fraction=COLLAPSE_HIDDEN_FRACTION,
|
|
241
|
+
max_merge_loss=MAX_MERGE_LOSS, share=True):
|
|
242
|
+
"""Record a collapse wherever the caller's ratios came back below threshold.
|
|
243
|
+
``drawn`` is what :func:`select_cut` returns; ``measured`` holds ratios in
|
|
244
|
+
[0, 1]. A parent is penalized when over ``min_hidden_fraction`` of its shown
|
|
245
|
+
children fall below ``max_measured``. Returns the entries changed for
|
|
246
|
+
``view_dir``; callers loop until it is zero, so count every change."""
|
|
247
|
+
bin_index = direction_bin(view_dir)
|
|
248
|
+
bins = BIN_NEIGHBORS[bin_index] if share else (bin_index,)
|
|
249
|
+
added = 0
|
|
250
|
+
for child_level, (indices, ratios) in enumerate(zip(drawn, measured)):
|
|
251
|
+
if indices is None or len(indices) == 0:
|
|
252
|
+
continue
|
|
253
|
+
indices = np.asarray(indices, dtype=np.int64)
|
|
254
|
+
ratios = np.nan_to_num(np.asarray(ratios, dtype=np.float64), nan=1.0)
|
|
255
|
+
|
|
256
|
+
# A supernode that measured poorly is opened once, to see if it helps.
|
|
257
|
+
if child_level < len(levels):
|
|
258
|
+
poor = indices[ratios < max_measured]
|
|
259
|
+
for blame_bin in bins:
|
|
260
|
+
tried = state.tried_from(child_level, int(blame_bin),
|
|
261
|
+
create=True)
|
|
262
|
+
fresh = poor[~tried[poor]]
|
|
263
|
+
if not fresh.size:
|
|
264
|
+
continue
|
|
265
|
+
if blame_bin == bin_index:
|
|
266
|
+
added += int(fresh.size)
|
|
267
|
+
tried[fresh] = True
|
|
268
|
+
state.futile_from(child_level, int(blame_bin),
|
|
269
|
+
create=True)[fresh] = True
|
|
270
|
+
|
|
271
|
+
# Ratios for level L-1 penalize level L; the top has no parent.
|
|
272
|
+
if child_level == len(levels):
|
|
273
|
+
parent_level, parent_of = 0, levels[0]["member_of"]
|
|
274
|
+
elif child_level + 1 < len(levels):
|
|
275
|
+
parent_level, parent_of = child_level + 1, levels[child_level]["parent"]
|
|
276
|
+
else:
|
|
277
|
+
continue
|
|
278
|
+
parent = parent_of[indices]
|
|
279
|
+
|
|
280
|
+
n_parent = state.sizes[parent_level]
|
|
281
|
+
shown = np.bincount(parent, minlength=n_parent)
|
|
282
|
+
poorly = (ratios < max_measured).astype(np.float64)
|
|
283
|
+
hidden = np.bincount(parent, weights=poorly, minlength=n_parent)
|
|
284
|
+
blame = (shown > 0) & (hidden >= min_hidden_fraction * shown)
|
|
285
|
+
|
|
286
|
+
added += int((blame & ~state.hidden_from(
|
|
287
|
+
parent_level, bin_index)).sum())
|
|
288
|
+
for blame_bin in bins:
|
|
289
|
+
state.hidden_from(parent_level, int(blame_bin),
|
|
290
|
+
create=True)[:] |= blame
|
|
291
|
+
|
|
292
|
+
# Cost is counted in members, not drawn elements, and charged to every
|
|
293
|
+
# ancestor, because the cut can drop several levels at once.
|
|
294
|
+
weight = (np.ones(indices.size) if child_level == len(levels)
|
|
295
|
+
else levels[child_level]["counts"][indices].astype(np.float64))
|
|
296
|
+
ancestor, level_index = parent, parent_level
|
|
297
|
+
while True:
|
|
298
|
+
size = state.sizes[level_index]
|
|
299
|
+
members = np.bincount(ancestor, weights=weight, minlength=size)
|
|
300
|
+
lost = np.bincount(ancestor, weights=weight * poorly,
|
|
301
|
+
minlength=size)
|
|
302
|
+
seen = members > 0
|
|
303
|
+
cost = np.divide(lost, members, out=np.ones(size), where=seen)
|
|
304
|
+
for blame_bin in bins:
|
|
305
|
+
judged = seen & state.tried_from(level_index, int(blame_bin))
|
|
306
|
+
if not judged.any():
|
|
307
|
+
continue
|
|
308
|
+
verdict = (1.0 - cost[judged]) > max_merge_loss
|
|
309
|
+
settled = state.futile_from(level_index, int(blame_bin),
|
|
310
|
+
create=True)
|
|
311
|
+
if blame_bin == bin_index:
|
|
312
|
+
added += int((settled[judged] != verdict).sum())
|
|
313
|
+
settled[judged] = verdict
|
|
314
|
+
if level_index + 1 >= len(levels):
|
|
315
|
+
break
|
|
316
|
+
ancestor = levels[level_index]["parent"][ancestor]
|
|
317
|
+
level_index += 1
|
|
318
|
+
return added
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def select_cut(levels, leaf, state, camera, max_regroup=MAX_REGROUP_FRACTION,
|
|
322
|
+
hysteresis=EXPAND_HYSTERESIS, **predict_kwargs):
|
|
323
|
+
"""Which communities to draw, top level down. ``drawn[i]`` masks level ``i``
|
|
324
|
+
(finest first) plus a final mask over the original nodes; a community is
|
|
325
|
+
drawn when every ancestor was expanded and it was not."""
|
|
326
|
+
top = len(levels) - 1
|
|
327
|
+
want = [None] * len(levels)
|
|
328
|
+
reached = np.ones(levels[top]["centers"].shape[0], dtype=bool)
|
|
329
|
+
stale = float(state.crowd_dir @ camera.forward) < CROWDING_REFRESH_COS
|
|
330
|
+
|
|
331
|
+
bin_index = direction_bin(camera.forward)
|
|
332
|
+
|
|
333
|
+
for level_index in range(top, -1, -1):
|
|
334
|
+
may_open, _predicted, _px = predict_expand(
|
|
335
|
+
levels, leaf, level_index, camera, state,
|
|
336
|
+
hysteresis=hysteresis, **predict_kwargs)
|
|
337
|
+
# Recorded poor from here stays whole, unless the merge did not pay.
|
|
338
|
+
may_open &= ~(state.hidden_from(level_index, bin_index)
|
|
339
|
+
& ~state.futile_from(level_index, bin_index))
|
|
340
|
+
may_open &= reached
|
|
341
|
+
want[level_index] = may_open
|
|
342
|
+
if level_index > 0:
|
|
343
|
+
reached = may_open[levels[level_index - 1]["parent"]]
|
|
344
|
+
if stale:
|
|
345
|
+
state.crowd_dir = np.asarray(camera.forward, dtype=np.float64).copy()
|
|
346
|
+
|
|
347
|
+
# Budget in original nodes: one collapse near the root rewrites half the
|
|
348
|
+
# screen. Collapses go first, since they fix a misleading fragment.
|
|
349
|
+
reach = reach_masks(levels, state.expanded)
|
|
350
|
+
n_nodes = levels[0]["member_of"].shape[0]
|
|
351
|
+
budget = (None if max_regroup >= 1.0
|
|
352
|
+
else max(1, int(round(max_regroup * n_nodes))))
|
|
353
|
+
changes = 0
|
|
354
|
+
for collapsing in (True, False):
|
|
355
|
+
for level_index in range(top, -1, -1):
|
|
356
|
+
# Reachability follows the level above, recomputed in the descent.
|
|
357
|
+
if level_index < top:
|
|
358
|
+
reach[level_index] = (
|
|
359
|
+
state.expanded[level_index + 1] & reach[level_index + 1]
|
|
360
|
+
)[levels[level_index]["parent"]]
|
|
361
|
+
if budget is not None and budget <= 0:
|
|
362
|
+
continue
|
|
363
|
+
current = state.expanded[level_index]
|
|
364
|
+
# Flipping an entry buried under a collapsed ancestor is free.
|
|
365
|
+
differing = np.flatnonzero((current != want[level_index])
|
|
366
|
+
& reach[level_index]
|
|
367
|
+
& (current if collapsing else ~current))
|
|
368
|
+
if differing.size == 0:
|
|
369
|
+
continue
|
|
370
|
+
if budget is not None:
|
|
371
|
+
# Cheapest first; one change is always allowed, per frame.
|
|
372
|
+
cost = levels[level_index]["counts"][differing]
|
|
373
|
+
differing = differing[np.argsort(cost, kind='stable')]
|
|
374
|
+
affordable = int(np.searchsorted(
|
|
375
|
+
np.cumsum(np.sort(cost)), budget, 'right'))
|
|
376
|
+
differing = differing[:max(affordable,
|
|
377
|
+
1 if changes == 0 else 0)]
|
|
378
|
+
if differing.size == 0:
|
|
379
|
+
continue
|
|
380
|
+
budget -= int(levels[level_index]["counts"][differing].sum())
|
|
381
|
+
current[differing] = want[level_index][differing]
|
|
382
|
+
changes += int(differing.size)
|
|
383
|
+
|
|
384
|
+
# Derive the cut from the applied flags: a rate-limited frame stays valid.
|
|
385
|
+
drawn = [r & ~(e & r) for r, e in zip(reach, state.expanded)]
|
|
386
|
+
expanded_leaf = state.expanded[0] & reach[0]
|
|
387
|
+
drawn.append(expanded_leaf[levels[0]["member_of"]])
|
|
388
|
+
|
|
389
|
+
state.changes = changes
|
|
390
|
+
state.frames += 1
|
|
391
|
+
return drawn, changes
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def regrouped(before, after):
|
|
395
|
+
"""Which nodes are drawn in another group; compares partitions, not labels."""
|
|
396
|
+
before = np.asarray(before, dtype=np.int64)
|
|
397
|
+
after = np.asarray(after, dtype=np.int64)
|
|
398
|
+
joint = before * (after.max() + 1) + after
|
|
399
|
+
return ((np.bincount(joint)[joint] != np.bincount(before)[before])
|
|
400
|
+
| (np.bincount(joint)[joint] != np.bincount(after)[after]))
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def reach_masks(levels, expanded):
|
|
404
|
+
"""Which entries of each level are on screen: every ancestor was expanded."""
|
|
405
|
+
top = len(levels) - 1
|
|
406
|
+
reach = [None] * len(levels)
|
|
407
|
+
mask = np.ones(levels[top]["centers"].shape[0], dtype=bool)
|
|
408
|
+
for level_index in range(top, -1, -1):
|
|
409
|
+
reach[level_index] = mask
|
|
410
|
+
if level_index > 0:
|
|
411
|
+
mask = (expanded[level_index] & mask)[
|
|
412
|
+
levels[level_index - 1]["parent"]]
|
|
413
|
+
return reach
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def cut_labels(levels, drawn, n_nodes):
|
|
417
|
+
"""One label per node, coarsest first: what ``build_coarse_level`` consumes."""
|
|
418
|
+
labels = np.full(n_nodes, -1, dtype=np.int64)
|
|
419
|
+
offset = 0
|
|
420
|
+
for level_index in range(len(levels) - 1, -1, -1):
|
|
421
|
+
mask = drawn[level_index]
|
|
422
|
+
taken = int(mask.sum())
|
|
423
|
+
if taken == 0:
|
|
424
|
+
continue
|
|
425
|
+
gid = np.full(mask.size, -1, dtype=np.int64)
|
|
426
|
+
gid[mask] = np.arange(taken, dtype=np.int64) + offset
|
|
427
|
+
offset += taken
|
|
428
|
+
mine = gid[levels[level_index]["member_of"]]
|
|
429
|
+
labels = np.where(mine >= 0, mine, labels)
|
|
430
|
+
|
|
431
|
+
leaf = drawn[len(levels)]
|
|
432
|
+
taken = int(leaf.sum())
|
|
433
|
+
if taken:
|
|
434
|
+
labels[leaf] = np.arange(taken, dtype=np.int64) + offset
|
|
435
|
+
return labels
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def split_by_level(levels, drawn, values):
|
|
439
|
+
"""Undo :func:`cut_labels`: split per-element values back onto the levels."""
|
|
440
|
+
values = np.asarray(values)
|
|
441
|
+
indices, out = [None] * (len(levels) + 1), [None] * (len(levels) + 1)
|
|
442
|
+
offset = 0
|
|
443
|
+
order = list(range(len(levels) - 1, -1, -1)) + [len(levels)]
|
|
444
|
+
for slot in order:
|
|
445
|
+
idx = np.flatnonzero(drawn[slot])
|
|
446
|
+
indices[slot] = idx
|
|
447
|
+
out[slot] = values[offset:offset + idx.size]
|
|
448
|
+
offset += idx.size
|
|
449
|
+
return indices, out
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def cut_summary(levels, drawn):
|
|
453
|
+
"""One line describing what a cut draws."""
|
|
454
|
+
parts = []
|
|
455
|
+
total_nodes = 0
|
|
456
|
+
for level_index in range(len(levels) - 1, -1, -1):
|
|
457
|
+
count = int(drawn[level_index].sum())
|
|
458
|
+
if count:
|
|
459
|
+
members = int(levels[level_index]["counts"][drawn[level_index]].sum())
|
|
460
|
+
total_nodes += members
|
|
461
|
+
parts.append(f"L{level_index + 1}: {count} blobs ({members} nodes)")
|
|
462
|
+
leaf = int(drawn[len(levels)].sum())
|
|
463
|
+
if leaf:
|
|
464
|
+
total_nodes += leaf
|
|
465
|
+
parts.append(f"L0: {leaf} nodes")
|
|
466
|
+
drawn_count = sum(int(d.sum()) for d in drawn)
|
|
467
|
+
return (f"{drawn_count} drawn elements covering {total_nodes} nodes | "
|
|
468
|
+
+ ", ".join(parts))
|