fluxplot 0.1.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.
- fluxplot/__init__.py +115 -0
- fluxplot/_fieldmap.py +97 -0
- fluxplot/_mesh_reduce.py +54 -0
- fluxplot/_scene3d_size.py +95 -0
- fluxplot/_viewer/THIRD-PARTY.txt +23 -0
- fluxplot/_viewer/flux-model3d-viewer.min.js +4221 -0
- fluxplot/_viewer/stamp.json +4 -0
- fluxplot/api.py +1196 -0
- fluxplot/autotag.py +164 -0
- fluxplot/base.mplstyle +0 -0
- fluxplot/brackets.py +242 -0
- fluxplot/canonical_json.py +23 -0
- fluxplot/capture.py +150 -0
- fluxplot/colorcheck.py +285 -0
- fluxplot/colors.py +727 -0
- fluxplot/colorscale.py +477 -0
- fluxplot/data.py +178 -0
- fluxplot/definitions/colormaps.json +1639 -0
- fluxplot/definitions/flexoki.tokens.json +2571 -0
- fluxplot/definitions/palettes.json +2547 -0
- fluxplot/descriptors.py +87 -0
- fluxplot/fields.py +611 -0
- fluxplot/fits.py +240 -0
- fluxplot/glb.py +84 -0
- fluxplot/ids.py +173 -0
- fluxplot/images.py +362 -0
- fluxplot/integrity.py +27 -0
- fluxplot/manifest.py +788 -0
- fluxplot/mesh3d.py +376 -0
- fluxplot/panels.py +284 -0
- fluxplot/postprocess.py +638 -0
- fluxplot/presets.py +66 -0
- fluxplot/provenance.py +177 -0
- fluxplot/raster.py +295 -0
- fluxplot/recipe.py +178 -0
- fluxplot/render.py +66 -0
- fluxplot/roles.py +147 -0
- fluxplot/scene3d.py +386 -0
- fluxplot/scene3d_manifest.py +112 -0
- fluxplot/scene3d_viewer.py +633 -0
- fluxplot/schemas/.gitkeep +0 -0
- fluxplot/schemas/manifest.schema.json +2479 -0
- fluxplot/schemas/recipe.schema.json +179 -0
- fluxplot/schemas/scene3d.schema.json +461 -0
- fluxplot/seaborn_adapters.py +323 -0
- fluxplot/signature_fluxplots/__init__.py +18 -0
- fluxplot/signature_fluxplots/_colour.py +412 -0
- fluxplot/signature_fluxplots/fluxbox.py +433 -0
- fluxplot/signature_fluxplots/glowbar.py +769 -0
- fluxplot/signature_fluxplots/hexmatrix.py +927 -0
- fluxplot/stats/__init__.py +63 -0
- fluxplot/stats/_common.py +196 -0
- fluxplot/stats/multi_group.py +443 -0
- fluxplot/stats/paired.py +209 -0
- fluxplot/stats/two_group.py +149 -0
- fluxplot/style.py +469 -0
- fluxplot/surface.py +487 -0
- fluxplot/surface3d.py +197 -0
- fluxplot/tagger.py +561 -0
- fluxplot/version.py +19 -0
- fluxplot-0.1.0.dist-info/METADATA +1199 -0
- fluxplot-0.1.0.dist-info/RECORD +65 -0
- fluxplot-0.1.0.dist-info/WHEEL +4 -0
- fluxplot-0.1.0.dist-info/licenses/LICENSE +21 -0
- fluxplot-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +472 -0
fluxplot/surface.py
ADDED
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
"""Surface (brain) maps as first-class, part-addressable FluxPlot marks.
|
|
2
|
+
|
|
3
|
+
Why this module exists
|
|
4
|
+
----------------------
|
|
5
|
+
FluxPlot's contract is that a plot's *science* — which value became which colour — stays **data,
|
|
6
|
+
not pixels**. Every 2D chart honours that: named marks in the SVG, a ``.fluxplot.json`` manifest,
|
|
7
|
+
a ``.recipe.json`` for provenance, all editable in the Flux interface.
|
|
8
|
+
|
|
9
|
+
**Surface maps** (a per-vertex scalar or label field painted on a triangulated mesh — the standard
|
|
10
|
+
way neuroimaging shows a cortical result, but equally any scalar field on any mesh) had no such
|
|
11
|
+
primitive. They were produced by external VTK-backed tools as flat raster PNGs, so the one thing a
|
|
12
|
+
reviewer most wants to correct — *this region should be that colour; this threshold is wrong* — was
|
|
13
|
+
the one thing baked irreversibly into pixels.
|
|
14
|
+
|
|
15
|
+
``surface()`` closes that gap. It draws the mesh with matplotlib primitives only (numpy +
|
|
16
|
+
matplotlib; ``nibabel`` is optional and used only to read GIFTI files), so:
|
|
17
|
+
|
|
18
|
+
* **Categorical / label maps** are split into **one collection per category** — each becomes a
|
|
19
|
+
named, selectable, recolourable part (``atlas.frontal``, ``atlas.parietal``, …). Recolouring a
|
|
20
|
+
region, or hiding it, is a style edit on a named part, exactly like restyling a bar series. This
|
|
21
|
+
is the full part-addressability tier, not a raster with a legend.
|
|
22
|
+
* **Continuous maps** carry their complete value→colour mapping (colormap, vmin/vmax, the
|
|
23
|
+
percentile rule that produced them) in the manifest, with the colorbar as its own named part, so
|
|
24
|
+
range/threshold edits are declarative and re-render deterministically.
|
|
25
|
+
* The **medial wall / missing data** is its own named part, never conflated with a real value.
|
|
26
|
+
|
|
27
|
+
Rendering is vector (one ``PolyCollection`` per part). A typical cortical mesh is tens of thousands
|
|
28
|
+
of triangles per hemisphere, which is exactly the node count :mod:`fluxplot.raster` exists to tame:
|
|
29
|
+
the collections are handed to the standard rasterisation planner, which composites each *part* to a
|
|
30
|
+
single ``<image>`` **while preserving its gid**. So the file stays light and every part stays
|
|
31
|
+
addressable — the mesh is drawn honestly as geometry, and the id contract survives.
|
|
32
|
+
|
|
33
|
+
Correctness rules made first-class (each is a documented failure mode of naive surface plots):
|
|
34
|
+
|
|
35
|
+
* vertices with no data (a mesh's medial wall / non-surface region) are **missing** (NaN) → their
|
|
36
|
+
own grey part, never a data value;
|
|
37
|
+
* a value of **exactly 0 is real** and must stay distinguishable from missing — there is no
|
|
38
|
+
"zero is transparent" behaviour here;
|
|
39
|
+
* a **sentinel code** (e.g. −1 to blank one hemisphere) greys out via ``missing_below`` /
|
|
40
|
+
``missing_values`` rather than becoming a spurious extra category;
|
|
41
|
+
* categorical colour is a **fixed id→colour map** with no silent remapping;
|
|
42
|
+
* back-facing geometry is culled, so a far-side face can never paint over the visible surface.
|
|
43
|
+
|
|
44
|
+
Example
|
|
45
|
+
-------
|
|
46
|
+
>>> import fluxplot as fp, matplotlib.pyplot as plt
|
|
47
|
+
>>> fig, ax = plt.subplots(figsize=(6.4, 1.6))
|
|
48
|
+
>>> fp.surface(ax, labels, series="atlas", # per-vertex integer labels
|
|
49
|
+
... surfaces={"left": "lh.surf.gii", "right": "rh.surf.gii"},
|
|
50
|
+
... kind="label",
|
|
51
|
+
... categories={0: "frontal", 1: "parietal", 2: "temporal"},
|
|
52
|
+
... palette={"frontal": "#4C78A8", "parietal": "#F58518",
|
|
53
|
+
... "temporal": "#54A24B"})
|
|
54
|
+
>>> fp.save(fig, "plots/atlas.svg")
|
|
55
|
+
|
|
56
|
+
Meshes may also be passed as ``(vertices, faces)`` arrays, so the core has no I/O dependency;
|
|
57
|
+
``nibabel`` is imported only when a GIFTI path is given.
|
|
58
|
+
"""
|
|
59
|
+
from __future__ import annotations
|
|
60
|
+
|
|
61
|
+
import warnings
|
|
62
|
+
|
|
63
|
+
import numpy as np
|
|
64
|
+
|
|
65
|
+
# Views are named by what the viewer sees. For each (hemisphere, view) we look down ±x and keep
|
|
66
|
+
# (y, z); the sign flip keeps anterior consistently to the same side of the page across views.
|
|
67
|
+
_VIEW_SPEC = {
|
|
68
|
+
("left", "lateral"): (-1, +1),
|
|
69
|
+
("left", "medial"): (+1, -1),
|
|
70
|
+
("right", "lateral"): (+1, -1),
|
|
71
|
+
("right", "medial"): (-1, +1),
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _load_surface(spec):
|
|
76
|
+
"""``spec`` → ``(vertices[V,3], faces[F,3])``.
|
|
77
|
+
|
|
78
|
+
Accepts an already-loaded ``(vertices, faces)`` pair (keeps fluxplot dependency-free) or a path
|
|
79
|
+
to a GIFTI surface, which needs ``nibabel``.
|
|
80
|
+
"""
|
|
81
|
+
if isinstance(spec, (tuple, list)) and len(spec) == 2:
|
|
82
|
+
v, f = spec
|
|
83
|
+
return np.asarray(v, dtype=float), np.asarray(f, dtype=int)
|
|
84
|
+
try:
|
|
85
|
+
import nibabel as nib
|
|
86
|
+
except ImportError as exc: # pragma: no cover - environment dependent
|
|
87
|
+
raise ImportError(
|
|
88
|
+
"reading a GIFTI surface needs nibabel (pip install nibabel); alternatively pass "
|
|
89
|
+
"surfaces={'left': (vertices, faces), ...} directly so fluxplot stays dependency-free"
|
|
90
|
+
) from exc
|
|
91
|
+
g = nib.load(str(spec))
|
|
92
|
+
verts = g.agg_data("pointset")
|
|
93
|
+
faces = g.agg_data("triangle")
|
|
94
|
+
return np.asarray(verts, dtype=float), np.asarray(faces, dtype=int)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _project(verts, hemi, view):
|
|
98
|
+
"""Orthographic projection of ``verts`` for one (hemisphere, view), plus a depth per vertex.
|
|
99
|
+
|
|
100
|
+
Returns ``(xy[V,2], depth[V], sign_x)`` where larger depth is nearer the viewer and ``sign_x``
|
|
101
|
+
is the axis sign pointing at the camera (used for back-face culling).
|
|
102
|
+
"""
|
|
103
|
+
try:
|
|
104
|
+
sign_x, sign_y = _VIEW_SPEC[(hemi, view)]
|
|
105
|
+
except KeyError:
|
|
106
|
+
raise ValueError(
|
|
107
|
+
f"unknown (hemisphere, view) = ({hemi!r}, {view!r}); "
|
|
108
|
+
f"expected one of {sorted(_VIEW_SPEC)}") from None
|
|
109
|
+
xy = np.column_stack([sign_y * verts[:, 1], verts[:, 2]])
|
|
110
|
+
depth = sign_x * verts[:, 0]
|
|
111
|
+
return xy, depth, sign_x
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _front_facing(verts, faces, sign_x):
|
|
115
|
+
"""Boolean mask of faces whose outward normal points at the camera.
|
|
116
|
+
|
|
117
|
+
Back-face culling — not merely an optimisation, it is what makes per-region collections
|
|
118
|
+
*correct*. Splitting a map into one collection per category means matplotlib draws the
|
|
119
|
+
categories in sequence, so a painter's-algorithm depth sort inside each collection cannot stop a
|
|
120
|
+
far-side face of one category from painting over a near-side face of another.
|
|
121
|
+
This only establishes correct cross-category visibility for convex projections.
|
|
122
|
+
Front-facing faces can still overlap on folded/concave meshes; category layers
|
|
123
|
+
use per-part painter ordering and are not a general depth-buffer renderer.
|
|
124
|
+
"""
|
|
125
|
+
tri = verts[faces]
|
|
126
|
+
normals = np.cross(tri[:, 1] - tri[:, 0], tri[:, 2] - tri[:, 0])
|
|
127
|
+
return sign_x * normals[:, 0] > 0
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _face_shading(verts, faces, sign_x, light, strength):
|
|
131
|
+
"""Per-face diffuse intensity in (0, 1] from the surface normals.
|
|
132
|
+
|
|
133
|
+
A flat-coloured mesh carries no relief: without an illumination term every face of a fold is the
|
|
134
|
+
same colour, so sulci and gyri are invisible however unsmoothed the geometry is. Lambert shading
|
|
135
|
+
restores the form — a face turned away from the light darkens — and it is purely geometric, so it
|
|
136
|
+
changes lightness only, never which value mapped to which hue.
|
|
137
|
+
|
|
138
|
+
``light`` is given in the VIEW frame (x toward the camera, y right, z up) and is flipped with the
|
|
139
|
+
camera so both hemispheres are lit from the same side of the page.
|
|
140
|
+
"""
|
|
141
|
+
tri = verts[faces]
|
|
142
|
+
n = np.cross(tri[:, 1] - tri[:, 0], tri[:, 2] - tri[:, 0])
|
|
143
|
+
n /= np.maximum(np.linalg.norm(n, axis=1, keepdims=True), 1e-12)
|
|
144
|
+
L = np.asarray(light, dtype=float)
|
|
145
|
+
L = L / max(np.linalg.norm(L), 1e-12)
|
|
146
|
+
ndotl = np.abs(n[:, 0] * L[0] * sign_x + n[:, 1] * L[1] + n[:, 2] * L[2])
|
|
147
|
+
return 1.0 - float(strength) * (1.0 - np.clip(ndotl, 0.0, 1.0))
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _shade_rgba(colours, intensity):
|
|
151
|
+
"""Multiply RGB by a per-face intensity, leaving alpha alone."""
|
|
152
|
+
rgba = np.array(colours, dtype=float, copy=True)
|
|
153
|
+
if rgba.ndim == 1:
|
|
154
|
+
rgba = np.tile(rgba, (intensity.size, 1))
|
|
155
|
+
rgba[:, :3] *= intensity[:, None]
|
|
156
|
+
return np.clip(rgba, 0.0, 1.0)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _face_values(values, faces):
|
|
160
|
+
"""Per-face value = mean of its vertices; a face touching missing data is itself missing.
|
|
161
|
+
|
|
162
|
+
Propagating NaN (rather than averaging around it) keeps the medial-wall boundary crisp instead
|
|
163
|
+
of smearing a halo of interpolated colour across it.
|
|
164
|
+
"""
|
|
165
|
+
# A plain mean already propagates NaN, which IS the rule we want: a face touching missing data
|
|
166
|
+
# is itself missing, so the medial-wall boundary stays crisp instead of smearing a halo of
|
|
167
|
+
# interpolated colour across it.
|
|
168
|
+
return values[faces].mean(axis=1)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _face_labels(values, faces):
|
|
172
|
+
"""Per-face label = the MAJORITY label of its three vertices; missing only if a vertex is.
|
|
173
|
+
|
|
174
|
+
A face straddling a boundary between two categories has to be drawn as one of them. Leaving it
|
|
175
|
+
unassigned instead would (a) draw it in the missing/no-data colour, conflating "on a border"
|
|
176
|
+
with "no data", and (b) etch a visible pale crack along every boundary — the artefact this rule
|
|
177
|
+
exists to avoid. Majority assignment is symmetric: each category gives up as many border faces
|
|
178
|
+
as it gains, so no block is systematically fattened. Three mutually distinct vertices (possible
|
|
179
|
+
only where three categories meet) fall back to the first vertex, which affects isolated faces.
|
|
180
|
+
"""
|
|
181
|
+
tri = values[faces]
|
|
182
|
+
out = np.where(tri[:, 0] == tri[:, 1], tri[:, 0],
|
|
183
|
+
np.where(tri[:, 1] == tri[:, 2], tri[:, 1], tri[:, 0]))
|
|
184
|
+
out[np.isnan(tri).any(axis=1)] = np.nan
|
|
185
|
+
return out
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
from ._fieldmap import _normalise_missing, categorical_colors, category_name, continuous_mapping
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def surface(ax, values, *, series, surfaces, kind="auto", categories=None, palette=None,
|
|
192
|
+
cmap=None, color_range=None, percentile=None, views=("lateral", "medial"),
|
|
193
|
+
hemispheres=("left", "right"), missing_below=None, missing_values=(),
|
|
194
|
+
missing_color="#D8D8D8", gap=0.06, edgecolor="none", linewidth=0.0,
|
|
195
|
+
shading=0.0, light=(0.35, -0.25, 0.90), antialiased=False, colorbar=False, cbar_label=None, cbar_ticks=None, legend=None,
|
|
196
|
+
legend_missing=False, legend_kw=None, label=None):
|
|
197
|
+
"""Draw a per-vertex surface map as named, addressable parts.
|
|
198
|
+
|
|
199
|
+
Parameters
|
|
200
|
+
----------
|
|
201
|
+
ax
|
|
202
|
+
Target axes. Every requested (hemisphere, view) is tiled left→right inside it; the axes is
|
|
203
|
+
set to equal aspect with its frame hidden, because a cortical projection has no meaningful
|
|
204
|
+
data axes.
|
|
205
|
+
values
|
|
206
|
+
Per-vertex array over the concatenated hemispheres (left then right), or per hemisphere via
|
|
207
|
+
a ``{"left": ..., "right": ...}`` mapping.
|
|
208
|
+
series
|
|
209
|
+
Addressable name for this map — parts are ``<series>.<category>`` (label maps) or
|
|
210
|
+
``<series>.field`` (continuous), plus ``<series>.missing``.
|
|
211
|
+
surfaces
|
|
212
|
+
``{"left": spec, "right": spec}`` where each spec is a GIFTI path or a ``(vertices, faces)``
|
|
213
|
+
pair.
|
|
214
|
+
kind
|
|
215
|
+
``"label"``, ``"continuous"``, or ``"auto"`` (label when the data are integral and few-valued).
|
|
216
|
+
categories
|
|
217
|
+
``{code: name}`` for label maps. Codes absent from the data are ignored; data codes absent
|
|
218
|
+
here get a ``category-<code>`` name so nothing is silently dropped (negative codes are
|
|
219
|
+
``category-m<abs>``, e.g. ``category-m1``, so ``-1`` and ``+1`` never share an id).
|
|
220
|
+
palette
|
|
221
|
+
``{name_or_code: colour}`` for label maps — a fixed mapping, never remapped.
|
|
222
|
+
cmap, color_range, percentile
|
|
223
|
+
Continuous styling. ``cmap`` is a Colormap or a name: matplotlib names first, then
|
|
224
|
+
fluxplot's collections (``"emerald"``, ``"crameri.batlow"``). ``percentile=(2, 98)``
|
|
225
|
+
clips to those percentiles of the finite data; ``color_range`` wins if both are given.
|
|
226
|
+
The resolved range is recorded in the manifest.
|
|
227
|
+
missing_below, missing_values
|
|
228
|
+
Sentinel handling (e.g. ``missing_below=0`` turns a −1 off-hemisphere sentinel into missing).
|
|
229
|
+
colorbar, cbar_label, cbar_ticks
|
|
230
|
+
Add a colorbar for continuous maps as its own addressable part. ``cbar_ticks`` pins the tick
|
|
231
|
+
positions (e.g. to keep them at round numbers) instead of leaving them to matplotlib.
|
|
232
|
+
shading, light
|
|
233
|
+
Diffuse shading strength in [0, 1] (0 = flat colour, the default). A flat-coloured mesh shows
|
|
234
|
+
no relief no matter how folded the geometry is — there is no illumination model — so sulci and
|
|
235
|
+
gyri are invisible even on an unsmoothed surface. With ``shading > 0`` each face is darkened by
|
|
236
|
+
``1 - shading*(1 - n·L)``, where ``n`` is its unit normal and ``L`` the light direction (given
|
|
237
|
+
in the *view* frame: x toward the camera, y right, z up). ~0.4 gives readable folding without
|
|
238
|
+
distorting the colour mapping; the value→hue relation is untouched, only its lightness.
|
|
239
|
+
legend, legend_missing, legend_kw
|
|
240
|
+
Draw a category legend. Defaults to ``True`` for label maps (a categorical map without a key
|
|
241
|
+
is unreadable) and ``False`` for continuous ones (the colorbar *is* the key). The legend is a
|
|
242
|
+
real matplotlib legend, so it is auto-tagged like any other plot's: the manifest gains a
|
|
243
|
+
``legend`` guide whose entries carry the swatch/label ids and resolve to the addressable
|
|
244
|
+
region part. ``legend_missing=True`` adds an entry for the missing/no-data part;
|
|
245
|
+
``legend_kw`` overrides placement (default: horizontal, under the map, frameless).
|
|
246
|
+
|
|
247
|
+
Returns the list of matplotlib collections drawn (in draw order).
|
|
248
|
+
"""
|
|
249
|
+
from matplotlib.collections import PolyCollection
|
|
250
|
+
from matplotlib.colors import to_rgba
|
|
251
|
+
|
|
252
|
+
from . import tagger as _tagger
|
|
253
|
+
from .descriptors import Mark
|
|
254
|
+
|
|
255
|
+
hemispheres = [h for h in hemispheres if h in surfaces]
|
|
256
|
+
if not hemispheres:
|
|
257
|
+
raise ValueError("no requested hemisphere present in `surfaces`")
|
|
258
|
+
|
|
259
|
+
# ---- per-hemisphere geometry + values -------------------------------------------------
|
|
260
|
+
geom, vals = {}, {}
|
|
261
|
+
offset = 0
|
|
262
|
+
for h in hemispheres:
|
|
263
|
+
v, f = _load_surface(surfaces[h])
|
|
264
|
+
geom[h] = (v, f)
|
|
265
|
+
if isinstance(values, dict):
|
|
266
|
+
hv = np.asarray(values[h], dtype=float)
|
|
267
|
+
else:
|
|
268
|
+
flat = np.asarray(values, dtype=float)
|
|
269
|
+
hv = flat[offset:offset + v.shape[0]]
|
|
270
|
+
offset += v.shape[0]
|
|
271
|
+
if hv.shape[0] != v.shape[0]:
|
|
272
|
+
raise ValueError(
|
|
273
|
+
f"{h}: {hv.shape[0]} values for {v.shape[0]} vertices — a surface map must carry "
|
|
274
|
+
f"exactly one value per vertex")
|
|
275
|
+
vals[h] = _normalise_missing(hv, missing_below, missing_values)
|
|
276
|
+
|
|
277
|
+
all_vals = np.concatenate([vals[h] for h in hemispheres])
|
|
278
|
+
finite = all_vals[np.isfinite(all_vals)]
|
|
279
|
+
# An all-missing map is legitimate, not an error: rendering one hemisphere of a
|
|
280
|
+
# single-hemisphere result gives a view whose every vertex is the sentinel. Draw the grey
|
|
281
|
+
# surface and skip the colour mapping. Only "auto" has to give up, since with no finite value
|
|
282
|
+
# there is nothing from which to infer label-vs-continuous.
|
|
283
|
+
if finite.size == 0:
|
|
284
|
+
if kind == "auto":
|
|
285
|
+
raise ValueError(
|
|
286
|
+
"every vertex is missing and kind='auto' cannot infer label vs continuous — "
|
|
287
|
+
"pass kind= explicitly if an all-missing map is intended")
|
|
288
|
+
warnings.warn("surface(): every vertex is missing; drawing the surface as no-data",
|
|
289
|
+
stacklevel=2)
|
|
290
|
+
|
|
291
|
+
if kind == "auto":
|
|
292
|
+
uniq = np.unique(finite)
|
|
293
|
+
kind = "label" if (uniq.size <= 32 and np.allclose(uniq, np.round(uniq))) else "continuous"
|
|
294
|
+
if kind not in ("label", "continuous"):
|
|
295
|
+
raise ValueError(f"kind must be 'label', 'continuous' or 'auto' (got {kind!r})")
|
|
296
|
+
|
|
297
|
+
# ---- lay the views out side by side in axes coordinates --------------------------------
|
|
298
|
+
panes = [(h, view) for h in hemispheres for view in views]
|
|
299
|
+
spans = []
|
|
300
|
+
for h, view in panes:
|
|
301
|
+
xy, _, _ = _project(geom[h][0], h, view)
|
|
302
|
+
spans.append((np.ptp(xy[:, 0]), np.ptp(xy[:, 1])))
|
|
303
|
+
unit_w = max(s[0] for s in spans)
|
|
304
|
+
unit_h = max(s[1] for s in spans)
|
|
305
|
+
pitch = unit_w * (1.0 + gap)
|
|
306
|
+
|
|
307
|
+
# ---- assemble faces, grouped by the part they belong to --------------------------------
|
|
308
|
+
# parts: name -> list of (polygon vertex arrays); continuous also collects per-face values.
|
|
309
|
+
parts, part_vals, parts_shade = {}, {}, {}
|
|
310
|
+
for i, (h, view) in enumerate(panes):
|
|
311
|
+
verts, faces = geom[h]
|
|
312
|
+
xy, depth, sign_x = _project(verts, h, view)
|
|
313
|
+
xy = xy.copy()
|
|
314
|
+
xy[:, 0] += i * pitch - xy[:, 0].mean()
|
|
315
|
+
xy[:, 1] -= xy[:, 1].mean()
|
|
316
|
+
|
|
317
|
+
front = _front_facing(verts, faces, sign_x)
|
|
318
|
+
vis = faces[front] # only the half facing the camera
|
|
319
|
+
shade_v = _face_shading(verts, vis, sign_x, light, shading) if shading else None
|
|
320
|
+
fv = _face_labels(vals[h], vis) if kind == "label" else _face_values(vals[h], vis)
|
|
321
|
+
order = np.argsort(depth[vis].mean(axis=1)) # painter's algorithm: far → near
|
|
322
|
+
polys = xy[vis[order]]
|
|
323
|
+
fv = fv[order]
|
|
324
|
+
shade_o = shade_v[order] if shade_v is not None else None
|
|
325
|
+
|
|
326
|
+
missing = ~np.isfinite(fv)
|
|
327
|
+
parts.setdefault("missing", []).append(polys[missing])
|
|
328
|
+
if shade_o is not None:
|
|
329
|
+
parts_shade.setdefault("missing", []).append(shade_o[missing])
|
|
330
|
+
if kind == "label":
|
|
331
|
+
for code in np.unique(fv[~missing]):
|
|
332
|
+
name = category_name(code, categories)
|
|
333
|
+
sel = fv == code
|
|
334
|
+
parts.setdefault(name, []).append(polys[sel])
|
|
335
|
+
if shade_o is not None:
|
|
336
|
+
parts_shade.setdefault(name, []).append(shade_o[sel])
|
|
337
|
+
else:
|
|
338
|
+
parts.setdefault("field", []).append(polys[~missing])
|
|
339
|
+
part_vals.setdefault("field", []).append(fv[~missing])
|
|
340
|
+
if shade_o is not None:
|
|
341
|
+
parts_shade.setdefault("field", []).append(shade_o[~missing])
|
|
342
|
+
|
|
343
|
+
reg = _tagger.registry_for(ax.figure)
|
|
344
|
+
drawn = []
|
|
345
|
+
|
|
346
|
+
def _add(name, polys_list, **kw):
|
|
347
|
+
polys = np.concatenate([p for p in polys_list if len(p)]) if any(
|
|
348
|
+
len(p) for p in polys_list) else np.empty((0, 3, 2))
|
|
349
|
+
if polys.shape[0] == 0:
|
|
350
|
+
return None
|
|
351
|
+
# edgecolor="face" + antialiasing is the combination that renders a triangulated field
|
|
352
|
+
# cleanly: each triangle draws its own border in its OWN colour, which both closes the
|
|
353
|
+
# hairline seams between neighbours (the reason one is tempted to disable antialiasing) and
|
|
354
|
+
# keeps the silhouette and any high-contrast internal boundary smooth. With antialiasing off
|
|
355
|
+
# the artefact is invisible on a smooth field but obvious wherever adjacent faces differ
|
|
356
|
+
# sharply — i.e. exactly on categorical maps and steep gradients.
|
|
357
|
+
coll = PolyCollection(list(polys), edgecolor=edgecolor, linewidth=linewidth,
|
|
358
|
+
antialiased=antialiased, **kw)
|
|
359
|
+
ax.add_collection(coll)
|
|
360
|
+
drawn.append(coll)
|
|
361
|
+
return coll
|
|
362
|
+
|
|
363
|
+
# ---- missing data: its own part, never a data value ------------------------------------
|
|
364
|
+
# Missing regions retain the established last-layer convention. This is an
|
|
365
|
+
# approximation for folded meshes, not a claim that all no-data faces are
|
|
366
|
+
# geometrically in front. The manifest records this rendering limitation.
|
|
367
|
+
parts_missing_colour = [None]
|
|
368
|
+
miss_shade = parts_shade.pop("missing", None)
|
|
369
|
+
miss_polys = parts.pop("missing", [])
|
|
370
|
+
|
|
371
|
+
style_common = {
|
|
372
|
+
"views": list(views), "hemispheres": list(hemispheres),
|
|
373
|
+
"missingColor": missing_color,
|
|
374
|
+
"rendering": {"projection": "orthographic", "occlusion": "per-part-painter",
|
|
375
|
+
"crossPartDepth": "convex-only", "missingLayer": "last"},
|
|
376
|
+
"missingRule": {"below": missing_below, "values": [float(m) for m in missing_values],
|
|
377
|
+
"zeroIsData": True},
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if kind == "label":
|
|
381
|
+
# One collection per category → each block is independently selectable and recolourable.
|
|
382
|
+
resolved = categorical_colors(parts, palette, categories)
|
|
383
|
+
for name in sorted(parts):
|
|
384
|
+
colour = resolved[name]
|
|
385
|
+
# The artist carries the category name as its matplotlib label, so a plain ax.legend()
|
|
386
|
+
# builds a real legend that the scaffold auto-tagger names like any other plot's.
|
|
387
|
+
if shading:
|
|
388
|
+
sh = np.concatenate(parts_shade[name]) if parts_shade.get(name) else None
|
|
389
|
+
fc = _shade_rgba(to_rgba(colour), sh) if sh is not None and sh.size else colour
|
|
390
|
+
else:
|
|
391
|
+
fc = colour
|
|
392
|
+
coll = _add(name, parts[name], facecolor=fc, label=name)
|
|
393
|
+
if coll is None:
|
|
394
|
+
continue
|
|
395
|
+
# NB the Mark's `label` stays the series-level one: a region name is the identity of a
|
|
396
|
+
# PART, and letting it become the series label would make the series masquerade as its
|
|
397
|
+
# own first category (and would hijack the legend↔series join below).
|
|
398
|
+
reg.add(Mark(role="surface-region", series=series, name=name, kind="surface",
|
|
399
|
+
label=label,
|
|
400
|
+
artists=[coll],
|
|
401
|
+
data={"surface": dict(style_common, kind="label", part=name,
|
|
402
|
+
color=colour)}))
|
|
403
|
+
summary = {"kind": "label", "palette": resolved, **style_common}
|
|
404
|
+
else:
|
|
405
|
+
import matplotlib as mpl
|
|
406
|
+
cmap_obj, norm = continuous_mapping(finite, cmap, color_range, percentile)
|
|
407
|
+
vmin, vmax = norm.vmin, norm.vmax
|
|
408
|
+
fv = np.concatenate(part_vals["field"])
|
|
409
|
+
fcol = cmap_obj(norm(fv))
|
|
410
|
+
if shading and parts_shade.get("field"):
|
|
411
|
+
fcol = _shade_rgba(fcol, np.concatenate(parts_shade["field"]))
|
|
412
|
+
coll = _add("field", parts["field"], facecolor=fcol)
|
|
413
|
+
summary = {"kind": "continuous", "cmap": getattr(cmap_obj, "name", str(cmap)),
|
|
414
|
+
"vmin": vmin, "vmax": vmax,
|
|
415
|
+
"percentile": list(percentile) if percentile else None, **style_common}
|
|
416
|
+
if coll is not None:
|
|
417
|
+
# Face colors include geometric shading. A collection scalar array
|
|
418
|
+
# would overwrite them on every draw; the key owns a separate mappable.
|
|
419
|
+
coll.set_cmap(cmap_obj)
|
|
420
|
+
coll.set_norm(norm)
|
|
421
|
+
reg.add(Mark(role="surface-field", series=series, name="field", kind="surface",
|
|
422
|
+
label=label, artists=[coll],
|
|
423
|
+
data={"surface": dict(summary, part="field")}))
|
|
424
|
+
if colorbar and coll is not None:
|
|
425
|
+
mappable = mpl.cm.ScalarMappable(norm=norm, cmap=cmap_obj)
|
|
426
|
+
mappable.set_array(fv)
|
|
427
|
+
cb = ax.figure.colorbar(mappable, ax=ax, fraction=0.03, pad=0.02)
|
|
428
|
+
# A rasterized ramp avoids seams. The raster draw scope preserves its
|
|
429
|
+
# identity independently of the surface or other embedded images.
|
|
430
|
+
cb.ax._fluxplot_owner_axes = ax
|
|
431
|
+
if cb.solids is not None:
|
|
432
|
+
cb.solids.set_edgecolor("face")
|
|
433
|
+
if cbar_ticks is not None:
|
|
434
|
+
cb.set_ticks(list(cbar_ticks))
|
|
435
|
+
if cbar_label:
|
|
436
|
+
cb.set_label(cbar_label)
|
|
437
|
+
reg.add(Mark(role="surface-colorbar", series=series, name="colorbar", kind="surface",
|
|
438
|
+
artists=[cb.solids] if cb.solids is not None else [],
|
|
439
|
+
data={"surface": {"part": "colorbar", "vmin": vmin, "vmax": vmax,
|
|
440
|
+
"cmap": summary["cmap"], "label": cbar_label,
|
|
441
|
+
"editable": ["vmin", "vmax", "cmap"]}}))
|
|
442
|
+
|
|
443
|
+
# One summary mark carrying the whole value→colour contract, so the mapping round-trips even if
|
|
444
|
+
# a downstream editor only reads the series-level entry.
|
|
445
|
+
reg.add(Mark(role="surface", series=series, kind="surface", label=label, axes=ax,
|
|
446
|
+
data={"surface": dict(summary, nVertices=int(all_vals.size),
|
|
447
|
+
nMissing=int((~np.isfinite(all_vals)).sum()),
|
|
448
|
+
panes=[f"{h}-{v}" for h, v in panes])}))
|
|
449
|
+
|
|
450
|
+
miss_fc = missing_color
|
|
451
|
+
if shading and miss_shade:
|
|
452
|
+
miss_fc = _shade_rgba(to_rgba(missing_color), np.concatenate(miss_shade))
|
|
453
|
+
coll = _add("missing", miss_polys, facecolor=miss_fc)
|
|
454
|
+
if coll is not None:
|
|
455
|
+
parts_missing_colour[0] = missing_color
|
|
456
|
+
reg.add(Mark(role="surface-missing", series=series, name="missing", kind="surface",
|
|
457
|
+
artists=[coll],
|
|
458
|
+
data={"surface": {"part": "missing", "color": missing_color,
|
|
459
|
+
"meaning": "medial wall / non-cortex / sentinel — not a value"}}))
|
|
460
|
+
|
|
461
|
+
# ---- category legend --------------------------------------------------------------------
|
|
462
|
+
# A categorical map without a key is unreadable, so label maps get one by default; a continuous
|
|
463
|
+
# map does not (its colorbar IS the key). This is a real matplotlib legend, so `autotag_scaffold`
|
|
464
|
+
# names it exactly as it does for a line or bar plot: the manifest gains a `legend` guide whose
|
|
465
|
+
# entries carry the swatch/label ids, and each entry resolves to the addressable region part.
|
|
466
|
+
if legend is None:
|
|
467
|
+
legend = (kind == "label")
|
|
468
|
+
if legend:
|
|
469
|
+
from matplotlib.patches import Patch
|
|
470
|
+
handles = [h for h in drawn if h.get_label() and not h.get_label().startswith("_")]
|
|
471
|
+
if legend_missing and parts_missing_colour[0] is not None:
|
|
472
|
+
handles.append(Patch(facecolor=parts_missing_colour[0], label="no data"))
|
|
473
|
+
if handles:
|
|
474
|
+
kw = {"loc": "lower center", "bbox_to_anchor": (0.5, -0.16),
|
|
475
|
+
"ncol": min(len(handles), 4), "frameon": False,
|
|
476
|
+
"handlelength": 1.1, "handleheight": 1.1, "borderpad": 0.0,
|
|
477
|
+
"columnspacing": 1.4, "handletextpad": 0.5}
|
|
478
|
+
kw.update(legend_kw or {})
|
|
479
|
+
ax.legend(handles=handles, **kw)
|
|
480
|
+
|
|
481
|
+
ax.set_xlim(-unit_w * 0.55, (len(panes) - 1) * pitch + unit_w * 0.55) # panes centred on i*pitch
|
|
482
|
+
ax.set_ylim(-unit_h * 0.55, unit_h * 0.55)
|
|
483
|
+
ax.set_aspect("equal")
|
|
484
|
+
ax.set_axis_off()
|
|
485
|
+
if len(drawn) == 0:
|
|
486
|
+
warnings.warn("surface(): nothing was drawn", stacklevel=2)
|
|
487
|
+
return drawn
|