Skip to content

Commit 2e52eb5

Browse files
committed
Mesh atlas per-parcel custom statistics (#222)
1 parent 27e76b6 commit 2e52eb5

File tree

6 files changed

+326
-18
lines changed

6 files changed

+326
-18
lines changed

examples/mesh.atlas.suit.ipynb

Lines changed: 29 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,7 @@
77
"source": [
88
"# Cerebellar statistical overlay\n",
99
"\n",
10-
"This demo shows the [SUIT atlas](https://www.diedrichsenlab.org/imaging/suit.htm). This jupyter notebook mimics the [SUIT atlas web page](https://niivue.com/demos/features/mesh.atlas.suit.html).\n",
11-
"\n",
12-
"**Note atlas_values and outline_border will require upgrade to NiiVue 0.65**"
10+
"This demo shows the [SUIT atlas](https://www.diedrichsenlab.org/imaging/suit.htm). This jupyter notebook mimics the [SUIT atlas web page](https://niivue.com/demos/features/mesh.atlas.suit.html).\n"
1311
]
1412
},
1513
{
@@ -36,13 +34,13 @@
3634
"mesh_layers = [\n",
3735
" {\n",
3836
" \"path\": \"./images/SUIT.shape.gii\",\n",
39-
" \"opacity\": 1.0,\n",
37+
" \"opacity\": 1,\n",
4038
" \"colormap\": \"gray\",\n",
4139
" \"cal_min\": -1.5,\n",
4240
" \"cal_max\": 1\n",
4341
" },\n",
4442
" {\"path\": \"./images/Lobules.label.gii\", \"opacity\": 0.05 },\n",
45-
" {\"path\": \"./images/Lobules.label.gii\", \"opacity\": 0.05 }\n",
43+
" {\"path\": \"./images/Lobules.label.gii\", \"opacity\": 0.65 }\n",
4644
"]\n",
4745
"\n",
4846
"nv.load_meshes([\n",
@@ -54,27 +52,29 @@
5452
"nv.set_render_azimuth_elevation(0,90)\n",
5553
"nv.set_mesh_shader(nv.meshes[0].id, \"Rim\")\n",
5654
"\n",
55+
"\n",
5756
"@nv.on_mesh_loaded\n",
5857
"def on_mesh_loaded(mesh):\n",
5958
" \"\"\"Handle event after mesh is loaded and ready.\"\"\"\n",
6059
" nv.meshes[0].layers[kStatLayer].cal_min = 2.3\n",
6160
" nv.meshes[0].layers[kStatLayer].cal_max = 5\n",
6261
" nv.meshes[0].layers[kStatLayer].colormap = 'warm'\n",
6362
" nv.meshes[0].layers[kStatLayer].colormap_negative = 'winter'\n",
64-
" nv.meshes[0].layers[kStatLayer].atlas_values = [\n",
63+
" nv.meshes[0].layers[kStatLayer].use_negative_cmap = True\n",
64+
" nv.meshes[0].layers[kCurvLayer].colorbar_visible = False\n",
65+
" nv.meshes[0].layers[kAltasLayer].colorbar_visible = False\n",
66+
" vals = [\n",
6567
" 0, 2, 0, 0, 0, 3, 0, 0, 0, 0, 0, 6, 0, -4,\n",
6668
" 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0\n",
6769
" ]\n",
68-
" nv.set_mesh_shader(nv.meshes[0].id, \"Rim\")\n",
69-
" nv.meshes[0].layers[kCurvLayer].colorbar_visible = False\n",
70-
" nv.meshes[0].layers[kAltasLayer].colorbar_visible = False\n",
70+
" nv.set_mesh_layer_property(nv.meshes[0].id, kStatLayer, 'atlas_values', vals)\n",
7171
"\n",
7272
"## User interface widgets\n",
7373
"\n",
7474
"curv_slider = widgets.IntSlider(\n",
75-
" value=50,\n",
75+
" value=100,\n",
7676
" min=1,\n",
77-
" max=99,\n",
77+
" max=100,\n",
7878
" description=\"Curvature\",\n",
7979
" readout=False\n",
8080
")\n",
@@ -91,9 +91,9 @@
9191
"curv_slider.observe(on_curv_change, names=\"value\")\n",
9292
"\n",
9393
"atlas_slider = widgets.IntSlider(\n",
94-
" value=1,\n",
95-
" min=1,\n",
96-
" max=99,\n",
94+
" value=5,\n",
95+
" min=0,\n",
96+
" max=100,\n",
9797
" description=\"Atlas\",\n",
9898
" readout=False\n",
9999
")\n",
@@ -110,9 +110,9 @@
110110
"atlas_slider.observe(on_atlas_change, names=\"value\")\n",
111111
"\n",
112112
"stat_slider = widgets.IntSlider(\n",
113-
" value=1,\n",
114-
" min=1,\n",
115-
" max=99,\n",
113+
" value=65,\n",
114+
" min=0,\n",
115+
" max=100,\n",
116116
" description=\"Stats\",\n",
117117
" readout=False\n",
118118
")\n",
@@ -138,7 +138,6 @@
138138
"def on_border_change(change):\n",
139139
" \"\"\"Set mesh border style.\"\"\"\n",
140140
" value_name = change[\"new\"]\n",
141-
"\n",
142141
" # Default borderValue for \"No border\"\n",
143142
" borderValue = 0.0\n",
144143
" if value_name == \"Dark border\":\n",
@@ -154,6 +153,10 @@
154153
" \"outline_border\",\n",
155154
" borderValue\n",
156155
" )\n",
156+
" labels = nv.meshes[0].layers[kStatLayer].atlas_labels\n",
157+
" nlabels = len(labels)\n",
158+
" print(f\"labels [{nlabels}]: {labels}\")\n",
159+
" \n",
157160
"\n",
158161
"border_dropdown.observe(on_border_change, names=\"value\")\n",
159162
"\n",
@@ -182,6 +185,14 @@
182185
"metadata": {},
183186
"outputs": [],
184187
"source": []
188+
},
189+
{
190+
"cell_type": "code",
191+
"execution_count": null,
192+
"id": "917e787c-d58e-4834-b6a7-a26f1334519a",
193+
"metadata": {},
194+
"outputs": [],
195+
"source": []
185196
}
186197
],
187198
"metadata": {},

examples/mesh.mosaic.ipynb

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
{
2+
"cells": [
3+
{
4+
"cell_type": "markdown",
5+
"id": "ef04a141",
6+
"metadata": {},
7+
"source": [
8+
"# Mesh mosaics\n",
9+
"\n",
10+
"A mosaic is a custom collection of perspectives for the same scene. With ipyniivue, you use the `set_slice_mosaic_string` to define each tile for a lightbox view. Note that the load_meshes() command is asynchronous, so we need to use the on_mesh_loaded() to set the mosaic string after the mesh is available.\n",
11+
"\n",
12+
"This Jupyter notebook mirrors the [mesh mosaic web page](https://niivue.com/demos/features/mosaics2.mesh.html).\n",
13+
"\n",
14+
"**Note boggle atlas will require upgrade to [NiiVue 0.66](https://github.com/niivue/niivue/issues/1455)**"
15+
]
16+
},
17+
{
18+
"cell_type": "code",
19+
"execution_count": null,
20+
"id": "d1950524-21ee-4281-8909-4b23a6734143",
21+
"metadata": {},
22+
"outputs": [],
23+
"source": [
24+
"import ipywidgets as widgets\n",
25+
"from ipyniivue import NiiVue, SliceType\n",
26+
"\n",
27+
"nv = NiiVue(\n",
28+
" show_3d_crosshair=True,\n",
29+
" back_color=(1, 1, 1, 1),\n",
30+
")\n",
31+
"\n",
32+
"nv.set_slice_type(SliceType.RENDER)\n",
33+
"nv.opts.is_colorbar = True\n",
34+
"nv.opts.show_legend = False\n",
35+
"\n",
36+
"mesh_layers = [\n",
37+
" {\n",
38+
" \"path\": \"./images/lh.curv\",\n",
39+
" \"colormap\": \"gray\",\n",
40+
" \"cal_min\": 0.49,\n",
41+
" \"cal_max\": 0.51,\n",
42+
" \"opacity\": 0.5,\n",
43+
" },\n",
44+
" {\n",
45+
" \"path\": \"./images/boggle.lh.annot\",\n",
46+
" \"opacity\": 0.5,\n",
47+
" },\n",
48+
" {\n",
49+
" \"path\": \"./images/boggle.lh.annot\",\n",
50+
" \"opacity\": 0.6,\n",
51+
" },\n",
52+
"]\n",
53+
"\n",
54+
"nv.load_meshes(\n",
55+
" [\n",
56+
" {\n",
57+
" \"path\": \"./images/lh.pial\",\n",
58+
" \"layers\": mesh_layers,\n",
59+
" },\n",
60+
" ]\n",
61+
")\n",
62+
"\n",
63+
"kCurvLayer = 0\n",
64+
"kAtlasLayer = 1\n",
65+
"kStatLayer = 2\n",
66+
"\n",
67+
"@nv.on_mesh_loaded\n",
68+
"def on_mesh_loaded(mesh):\n",
69+
" nv.meshes[0].layers[kStatLayer].cal_min = 2.3\n",
70+
" nv.meshes[0].layers[kStatLayer].cal_max = 5\n",
71+
" nv.meshes[0].layers[kStatLayer].colormap = 'warm'\n",
72+
" nv.meshes[0].layers[kStatLayer].colormap_negative = 'winter'\n",
73+
" nv.meshes[0].layers[kStatLayer].use_negative_cmap = True\n",
74+
" nv.meshes[0].layers[kStatLayer].atlas_values = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, -3, 0, 0, 0, 0, 0, 0, 0, -5, 0, 0, 7, 0, 0, 4, 7, 0, 0, 0, 0, 0, 0, 0] \n",
75+
" nv.set_mesh_shader(nv.meshes[0].id, \"Rim\")\n",
76+
" nv.meshes[0].layers[kCurvLayer].colorbar_visible = False\n",
77+
" nv.meshes[0].layers[kAtlasLayer].colorbar_visible = False\n",
78+
" nv.opts.slice_mosaic_string = \"A R 0 R -0 S R 0 R -0 C R 0 R -0\" \n",
79+
"\n",
80+
"nv.set_clip_plane(-0.1, 270, 0)\n",
81+
"\n",
82+
"## User interface widgets\n",
83+
"\n",
84+
"curv_slider = widgets.IntSlider(\n",
85+
" value=50,\n",
86+
" min=1,\n",
87+
" max=99,\n",
88+
" description=\"Curvature\",\n",
89+
" readout=False\n",
90+
")\n",
91+
"\n",
92+
"def on_curv_change(change):\n",
93+
" \"\"\"Set curve transparency.\"\"\"\n",
94+
" nv.set_mesh_layer_property(\n",
95+
" mesh_id=nv.meshes[0].id,\n",
96+
" layer_index=kCurvLayer,\n",
97+
" attribute=\"opacity\",\n",
98+
" value=change[\"new\"] * 0.01,\n",
99+
" )\n",
100+
"\n",
101+
"curv_slider.observe(on_curv_change, names=\"value\")\n",
102+
"\n",
103+
"atlas_slider = widgets.IntSlider(\n",
104+
" value=50,\n",
105+
" min=1,\n",
106+
" max=99,\n",
107+
" description=\"Atlas\",\n",
108+
" readout=False\n",
109+
")\n",
110+
"\n",
111+
"def on_atlas_change(change):\n",
112+
" \"\"\"Set atlas transparency.\"\"\"\n",
113+
" nv.set_mesh_layer_property(\n",
114+
" mesh_id=nv.meshes[0].id,\n",
115+
" layer_index=kAtlasLayer,\n",
116+
" attribute=\"opacity\",\n",
117+
" value=change[\"new\"] * 0.01,\n",
118+
" )\n",
119+
"\n",
120+
"atlas_slider.observe(on_atlas_change, names=\"value\")\n",
121+
"\n",
122+
"stat_slider = widgets.IntSlider(\n",
123+
" value=60,\n",
124+
" min=1,\n",
125+
" max=99,\n",
126+
" description=\"Stats\",\n",
127+
" readout=False\n",
128+
")\n",
129+
"\n",
130+
"def on_stat_change(change):\n",
131+
" \"\"\"Set stat transparency.\"\"\"\n",
132+
" nv.set_mesh_layer_property(\n",
133+
" mesh_id=nv.meshes[0].id,\n",
134+
" layer_index=kStatLayer,\n",
135+
" attribute=\"opacity\",\n",
136+
" value=change[\"new\"] * 0.01,\n",
137+
" )\n",
138+
"\n",
139+
"stat_slider.observe(on_stat_change, names=\"value\")\n",
140+
"\n",
141+
"border_options = [\"Dark border\", \"Transparent border\", \"No border\", \"Opaque border\"]\n",
142+
"border_dropdown = widgets.Dropdown(\n",
143+
" options=border_options,\n",
144+
" value=\"No border\",\n",
145+
" description=\"Border\",\n",
146+
")\n",
147+
"\n",
148+
"def on_border_change(change):\n",
149+
" \"\"\"Set mesh border style.\"\"\"\n",
150+
" value_name = change[\"new\"]\n",
151+
"\n",
152+
" # Default borderValue for \"No border\"\n",
153+
" borderValue = 0.0\n",
154+
"\n",
155+
" if value_name == \"Dark border\":\n",
156+
" borderValue = -0.01 # MRIcroGL convention: negative = dark border\n",
157+
" elif value_name == \"Transparent border\":\n",
158+
" borderValue = 0.01 # small positive = transparent border\n",
159+
" elif value_name == \"Opaque border\":\n",
160+
" borderValue = 1.0 # fully opaque border\n",
161+
" # else \"No border\" → 0.0\n",
162+
"\n",
163+
" # Apply to the mesh layer (assuming kAtlasLayer is defined elsewhere)\n",
164+
" nv.set_mesh_layer_property(nv.meshes[0].id, kAtlasLayer, \"outline_border\", borderValue)\n",
165+
" \n",
166+
"\n",
167+
"border_dropdown.observe(on_border_change, names=\"value\")\n",
168+
"\n",
169+
"widgets.VBox(\n",
170+
" [\n",
171+
" widgets.HBox([curv_slider, atlas_slider, stat_slider, border_dropdown]),\n",
172+
" nv,\n",
173+
" ]\n",
174+
")\n"
175+
]
176+
},
177+
{
178+
"cell_type": "code",
179+
"execution_count": null,
180+
"id": "107731f1-e034-4e73-a113-3f0002eda319",
181+
"metadata": {},
182+
"outputs": [],
183+
"source": []
184+
}
185+
],
186+
"metadata": {},
187+
"nbformat": 4,
188+
"nbformat_minor": 5
189+
}

js/mesh.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,12 @@ function setup_layer_property_listeners(
3232
nv.updateGLVolume();
3333
}
3434

35+
function atlas_values_changed() {
36+
layer.atlasValues = layerModel.get("atlas_values");
37+
mesh.updateMesh(nv.gl);
38+
nv.updateGLVolume();
39+
}
40+
3541
function colormap_changed() {
3642
layer.colormap = layerModel.get("colormap");
3743
mesh.updateMesh(nv.gl);
@@ -93,6 +99,7 @@ function setup_layer_property_listeners(
9399
colorbar_visible_changed();
94100

95101
// Set up the event listeners
102+
layerModel.on("change:atlas_values", atlas_values_changed);
96103
layerModel.on("change:opacity", opacity_changed);
97104
layerModel.on("change:colormap", colormap_changed);
98105
layerModel.on("change:colormap_negative", colormap_negative_changed);
@@ -107,6 +114,7 @@ function setup_layer_property_listeners(
107114

108115
// Return a cleanup function
109116
return () => {
117+
layerModel.off("change:atlas_values", atlas_values_changed);
110118
layerModel.off("change:opacity", opacity_changed);
111119
layerModel.off("change:colormap", colormap_changed);
112120
layerModel.off("change:colormap_negative", colormap_negative_changed);
@@ -390,6 +398,13 @@ export async function create_mesh(
390398
layerModel.get("outline_border") ?? 0,
391399
);
392400
layer.id = backendLayerId;
401+
const labels = Array.isArray(layer.colormapLabel?.labels) ? layer.colormapLabel.labels : null;
402+
layerModel.set('atlas_labels', labels);
403+
const values = Array.isArray(layer.atlasValues) ? layer.atlasValues : null;
404+
layerModel.set('atlas_values', values);
405+
layerModel.save_changes?.();
406+
// console.log('colormap via get():', layerModel.get('colormap'));
407+
// console.log('atlas_labels via get():', layerModel.get('atlas_labels'));
393408
mesh.layers.push(layer);
394409
} else if (layerUrl) {
395410
const response = await fetch(layerUrl);
@@ -411,6 +426,11 @@ export async function create_mesh(
411426
layerModel.get("outline_border") ?? 0,
412427
);
413428
layer.id = backendLayerId;
429+
const labels = Array.isArray(layer.colormapLabel?.labels) ? layer.colormapLabel.labels : null;
430+
layerModel.set('atlas_labels', labels);
431+
const values = Array.isArray(layer.atlasValues) ? layer.atlasValues : null;
432+
layerModel.set('atlas_values', values);
433+
layerModel.save_changes?.();
414434
mesh.layers.push(layer);
415435
} else {
416436
throw new Error("Invalid source for mesh layer");

js/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,9 @@ export type MeshLayerModel = AnyModel<{
163163
colormap_invert: boolean;
164164
frame_4d: number;
165165
colorbar_visible: boolean;
166+
167+
atlas_labels?: string[] | null;
168+
atlas_values?: number[] | null;
166169
}>;
167170

168171
export type Model = AnyModel<{

js/widget.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -445,6 +445,12 @@ function attachNiivueEventHandlers(nv: niivue.Niivue, model: Model) {
445445
cal_min: layer.cal_min,
446446
cal_max: layer.cal_max,
447447
outline_border: layer.outlineBorder,
448+
atlas_labels: Array.isArray(layer.colormapLabel?.labels)
449+
? layer.colormapLabel.labels
450+
: null,
451+
atlas_values: Array.isArray(layer.atlasValues)
452+
? layer.atlasValues
453+
: null,
448454
id: layer.id,
449455
};
450456
});

0 commit comments

Comments
 (0)