Coverage for nilearn/plotting/surface/_plotly_backend.py: 0%
88 statements
« prev ^ index » next coverage.py v7.9.1, created at 2025-06-16 12:32 +0200
« prev ^ index » next coverage.py v7.9.1, created at 2025-06-16 12:32 +0200
1"""Functions specific to "plotly" backend for surface visualization
2functions in :obj:`~nilearn.plotting.surface.surf_plotting`.
4Any imports from "plotly" package, or "plotly" engine specific utility
5functions in :obj:`~nilearn.plotting.surface` should be in this file.
6"""
8import math
10import numpy as np
12from nilearn import DEFAULT_DIVERGING_CMAP
13from nilearn._utils.helpers import is_kaleido_installed
14from nilearn.plotting._utils import get_colorbar_and_data_ranges
15from nilearn.plotting.displays import PlotlySurfaceFigure
16from nilearn.plotting.js_plotting_utils import colorscale
17from nilearn.plotting.surface._utils import (
18 DEFAULT_ENGINE,
19 DEFAULT_HEMI,
20 VALID_HEMISPHERES,
21 check_engine_params,
22 check_surf_map,
23 get_surface_backend,
24 sanitize_hemi_view,
25)
26from nilearn.surface import load_surf_data, load_surf_mesh
28try:
29 import plotly.graph_objects as go
30except ImportError:
31 from nilearn.plotting._utils import engine_warning
33 engine_warning("plotly")
35CAMERAS = {
36 "left": {
37 "eye": {"x": -1.5, "y": 0, "z": 0},
38 "up": {"x": 0, "y": 0, "z": 1},
39 "center": {"x": 0, "y": 0, "z": 0},
40 },
41 "right": {
42 "eye": {"x": 1.5, "y": 0, "z": 0},
43 "up": {"x": 0, "y": 0, "z": 1},
44 "center": {"x": 0, "y": 0, "z": 0},
45 },
46 "dorsal": {
47 "eye": {"x": 0, "y": 0, "z": 1.5},
48 "up": {"x": -1, "y": 0, "z": 0},
49 "center": {"x": 0, "y": 0, "z": 0},
50 },
51 "ventral": {
52 "eye": {"x": 0, "y": 0, "z": -1.5},
53 "up": {"x": 1, "y": 0, "z": 0},
54 "center": {"x": 0, "y": 0, "z": 0},
55 },
56 "anterior": {
57 "eye": {"x": 0, "y": 1.5, "z": 0},
58 "up": {"x": 0, "y": 0, "z": 1},
59 "center": {"x": 0, "y": 0, "z": 0},
60 },
61 "posterior": {
62 "eye": {"x": 0, "y": -1.5, "z": 0},
63 "up": {"x": 0, "y": 0, "z": 1},
64 "center": {"x": 0, "y": 0, "z": 0},
65 },
66}
69AXIS_CONFIG = {
70 "showgrid": False,
71 "showline": False,
72 "ticks": "",
73 "title": "",
74 "showticklabels": False,
75 "zeroline": False,
76 "showspikes": False,
77 "spikesides": False,
78 "showbackground": False,
79}
82LAYOUT = {
83 "scene": {
84 "dragmode": "orbit",
85 **{f"{dim}axis": AXIS_CONFIG for dim in ("x", "y", "z")},
86 },
87 "paper_bgcolor": "#fff",
88 "hovermode": False,
89 "margin": {"l": 0, "r": 0, "b": 0, "t": 0, "pad": 0},
90}
93def _adjust_colorbar_and_data_ranges(
94 stat_map, vmin=None, vmax=None, symmetric_cbar=None
95):
96 """Adjust colorbar and data ranges for 'plotly' engine.
98 .. note::
99 colorbar ranges are not used for 'plotly' engine.
101 Parameters
102 ----------
103 stat_map : :obj:`str` or :class:`numpy.ndarray` or None, default=None
105 %(vmin)s
107 %(vmax)s
109 %(symmetric_cbar)s
111 Returns
112 -------
113 cbar_vmin, cbar_vmax, vmin, vmax
114 """
115 _, _, vmin, vmax = get_colorbar_and_data_ranges(
116 stat_map,
117 vmin=vmin,
118 vmax=vmax,
119 symmetric_cbar=symmetric_cbar,
120 )
122 return None, None, vmin, vmax
125def _adjust_plot_roi_params(params):
126 """Adjust cbar_tick_format value for 'plotly' engine.
128 Sets the values in params dict.
130 Parameters
131 ----------
132 params : dict
133 dictionary to set the adjusted parameters
134 """
135 cbar_tick_format = params.get("cbar_tick_format", "auto")
136 if cbar_tick_format == "auto":
137 params["cbar_tick_format"] = "."
140def _configure_title(title, font_size, color="black"):
141 """Help for plot_surf with plotly engine.
143 This function configures the title if provided.
144 """
145 if title is None:
146 return {}
147 return {
148 "text": title,
149 "font": {
150 "size": font_size,
151 "color": color,
152 },
153 "y": 0.96,
154 "x": 0.5,
155 "xanchor": "center",
156 "yanchor": "top",
157 }
160def _get_camera_view_from_elevation_and_azimut(view):
161 """Compute plotly camera parameters from elevation and azimut."""
162 elev, azim = view
163 # The radius is useful only when using a "perspective" projection,
164 # otherwise, if projection is "orthographic",
165 # one should tweak the "aspectratio" to emulate zoom
166 r = 1.5
167 # The camera position and orientation is set by three 3d vectors,
168 # whose coordinates are independent of the plotted data.
169 return {
170 # Where the camera should look at
171 # (it should always be looking at the center of the scene)
172 "center": {"x": 0, "y": 0, "z": 0},
173 # Where the camera should be located
174 "eye": {
175 "x": (
176 r
177 * math.cos(azim / 360 * 2 * math.pi)
178 * math.cos(elev / 360 * 2 * math.pi)
179 ),
180 "y": (
181 r
182 * math.sin(azim / 360 * 2 * math.pi)
183 * math.cos(elev / 360 * 2 * math.pi)
184 ),
185 "z": r * math.sin(elev / 360 * 2 * math.pi),
186 },
187 # How the camera should be rotated.
188 # It is determined by a 3d vector indicating which direction
189 # should look up in the generated plot
190 "up": {
191 "x": math.sin(elev / 360 * 2 * math.pi)
192 * math.cos(azim / 360 * 2 * math.pi + math.pi),
193 "y": math.sin(elev / 360 * 2 * math.pi)
194 * math.sin(azim / 360 * 2 * math.pi + math.pi),
195 "z": math.cos(elev / 360 * 2 * math.pi),
196 },
197 # "projection": {"type": "perspective"},
198 "projection": {"type": "orthographic"},
199 }
202def _get_camera_view_from_string_view(hemi, view):
203 """Return plotly camera parameters from string view."""
204 if hemi in ["left", "right"]:
205 if view == "lateral":
206 return CAMERAS[hemi]
207 elif view == "medial":
208 return CAMERAS[
209 (
210 VALID_HEMISPHERES[0]
211 if hemi == VALID_HEMISPHERES[1]
212 else VALID_HEMISPHERES[1]
213 )
214 ]
215 elif hemi == "both" and view in ["lateral", "medial"]:
216 raise ValueError(
217 "Invalid view definition: when hemi is 'both', "
218 "view cannot be 'lateral' or 'medial'.\n"
219 "Maybe you meant 'left' or 'right'?"
220 )
221 return CAMERAS[view]
224def _get_cbar(
225 colorscale,
226 vmin,
227 vmax,
228 cbar_tick_format,
229 fontsize=25,
230 color="black",
231 height=0.5,
232):
233 """Help for _plot_surf_plotly.
235 This function configures the colorbar and creates a small
236 invisible plot that uses the appropriate cmap to trigger
237 the generation of the colorbar. This dummy plot has then to
238 be added to the figure.
239 """
240 dummy = {
241 "opacity": 0,
242 "colorbar": {
243 "tickfont": {"size": fontsize, "color": color},
244 "tickformat": cbar_tick_format,
245 "len": height,
246 },
247 "type": "mesh3d",
248 "colorscale": colorscale,
249 "x": [1, 0, 0],
250 "y": [0, 1, 0],
251 "z": [0, 0, 1],
252 "i": [0],
253 "j": [1],
254 "k": [2],
255 "intensity": [0.0],
256 "cmin": vmin,
257 "cmax": vmax,
258 }
259 return dummy
262def _get_view_plot_surf(hemi, view):
263 """Check ``hemi`` and ``view``, and return camera view for plotly
264 engine.
265 """
266 view = sanitize_hemi_view(hemi, view)
267 if isinstance(view, str):
268 return _get_camera_view_from_string_view(hemi, view)
269 return _get_camera_view_from_elevation_and_azimut(view)
272def _plot_surf(
273 surf_mesh,
274 surf_map=None,
275 bg_map=None,
276 hemi=DEFAULT_HEMI,
277 view=None,
278 cmap=None,
279 symmetric_cmap=None,
280 colorbar=True,
281 avg_method=None,
282 threshold=None,
283 alpha=None,
284 bg_on_data=False,
285 darkness=0.7,
286 vmin=None,
287 vmax=None,
288 cbar_vmin=None,
289 cbar_vmax=None,
290 cbar_tick_format="auto",
291 title=None,
292 title_font_size=None,
293 output_file=None,
294 axes=None,
295 figure=None,
296):
297 """Implement 'plotly' backend code for
298 `~nilearn.plotting.surface.surf_plotting.plot_surf` function.
299 """
300 parameters_not_implemented_in_plotly = {
301 "avg_method": avg_method,
302 "alpha": alpha,
303 "cbar_vmin": cbar_vmin,
304 "cbar_vmax": cbar_vmax,
305 "axes": axes,
306 "figure": figure,
307 }
308 check_engine_params(parameters_not_implemented_in_plotly, "plotly")
310 # adjust values
311 cbar_tick_format = (
312 ".1f" if cbar_tick_format == "auto" else cbar_tick_format
313 )
314 cmap = DEFAULT_DIVERGING_CMAP if cmap is None else cmap
315 symmetric_cmap = False if symmetric_cmap is None else symmetric_cmap
316 title_font_size = 18 if title_font_size is None else title_font_size
318 coords, faces = load_surf_mesh(surf_mesh)
320 x, y, z = coords.T
321 i, j, k = faces.T
323 bg_data = None
324 if bg_map is not None:
325 bg_data = load_surf_data(bg_map)
326 if bg_data.shape[0] != coords.shape[0]:
327 raise ValueError(
328 "The bg_map does not have the same number "
329 "of vertices as the mesh."
330 )
332 backend = get_surface_backend(DEFAULT_ENGINE)
333 if surf_map is not None:
334 check_surf_map(surf_map, coords.shape[0])
335 colors = colorscale(
336 cmap,
337 surf_map,
338 threshold,
339 vmax=vmax,
340 vmin=vmin,
341 symmetric_cmap=symmetric_cmap,
342 )
343 vertexcolor = backend._get_vertexcolor(
344 surf_map,
345 colors["cmap"],
346 colors["norm"],
347 absolute_threshold=colors["abs_threshold"],
348 bg_map=bg_data,
349 bg_on_data=bg_on_data,
350 darkness=darkness,
351 )
352 else:
353 if bg_data is None:
354 bg_data = np.zeros(coords.shape[0])
355 colors = colorscale("Greys", bg_data, symmetric_cmap=False)
356 vertexcolor = backend._get_vertexcolor(
357 bg_data,
358 colors["cmap"],
359 colors["norm"],
360 absolute_threshold=colors["abs_threshold"],
361 )
363 mesh_3d = go.Mesh3d(x=x, y=y, z=z, i=i, j=j, k=k, vertexcolor=vertexcolor)
364 fig_data = [mesh_3d]
365 if colorbar:
366 dummy = _get_cbar(
367 colors["colors"],
368 float(colors["vmin"]),
369 float(colors["vmax"]),
370 cbar_tick_format,
371 )
372 fig_data.append(dummy)
374 # instantiate plotly figure
375 camera_view = _get_view_plot_surf(hemi, view)
376 fig = go.Figure(data=fig_data)
377 fig.update_layout(
378 scene_camera=camera_view,
379 title=_configure_title(title, title_font_size),
380 **LAYOUT,
381 )
383 # save figure
384 plotly_figure = PlotlySurfaceFigure(
385 figure=fig, output_file=output_file, hemi=hemi
386 )
388 if output_file is not None:
389 if not is_kaleido_installed():
390 msg = (
391 "Saving figures to file with engine='plotly' requires "
392 "that ``kaleido`` is installed."
393 )
394 raise ImportError(msg)
395 plotly_figure.savefig()
397 return plotly_figure