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

1"""Functions specific to "plotly" backend for surface visualization 

2functions in :obj:`~nilearn.plotting.surface.surf_plotting`. 

3 

4Any imports from "plotly" package, or "plotly" engine specific utility 

5functions in :obj:`~nilearn.plotting.surface` should be in this file. 

6""" 

7 

8import math 

9 

10import numpy as np 

11 

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 

27 

28try: 

29 import plotly.graph_objects as go 

30except ImportError: 

31 from nilearn.plotting._utils import engine_warning 

32 

33 engine_warning("plotly") 

34 

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} 

67 

68 

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} 

80 

81 

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} 

91 

92 

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. 

97 

98 .. note:: 

99 colorbar ranges are not used for 'plotly' engine. 

100 

101 Parameters 

102 ---------- 

103 stat_map : :obj:`str` or :class:`numpy.ndarray` or None, default=None 

104 

105 %(vmin)s 

106 

107 %(vmax)s 

108 

109 %(symmetric_cbar)s 

110 

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 ) 

121 

122 return None, None, vmin, vmax 

123 

124 

125def _adjust_plot_roi_params(params): 

126 """Adjust cbar_tick_format value for 'plotly' engine. 

127 

128 Sets the values in params dict. 

129 

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"] = "." 

138 

139 

140def _configure_title(title, font_size, color="black"): 

141 """Help for plot_surf with plotly engine. 

142 

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 } 

158 

159 

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 } 

200 

201 

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] 

222 

223 

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. 

234 

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 

260 

261 

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) 

270 

271 

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") 

309 

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 

317 

318 coords, faces = load_surf_mesh(surf_mesh) 

319 

320 x, y, z = coords.T 

321 i, j, k = faces.T 

322 

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 ) 

331 

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 ) 

362 

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) 

373 

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 ) 

382 

383 # save figure 

384 plotly_figure = PlotlySurfaceFigure( 

385 figure=fig, output_file=output_file, hemi=hemi 

386 ) 

387 

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() 

396 

397 return plotly_figure