Coverage for nilearn/reporting/tests/test_html_report.py: 23%
287 statements
« prev ^ index » next coverage.py v7.9.1, created at 2025-06-20 10:58 +0200
« prev ^ index » next coverage.py v7.9.1, created at 2025-06-20 10:58 +0200
1from collections import Counter
3import numpy as np
4import pytest
5from nibabel import Nifti1Image
6from numpy.testing import assert_almost_equal
8from nilearn._utils.data_gen import generate_random_img
9from nilearn._utils.helpers import is_matplotlib_installed, is_plotly_installed
10from nilearn._utils.html_document import WIDTH_DEFAULT, HTMLDocument
11from nilearn._utils.testing import on_windows_with_old_mpl_and_new_numpy
12from nilearn.conftest import _img_maps
13from nilearn.image import get_data
14from nilearn.maskers import (
15 MultiNiftiLabelsMasker,
16 MultiNiftiMapsMasker,
17 MultiNiftiMasker,
18 NiftiLabelsMasker,
19 NiftiMapsMasker,
20 NiftiMasker,
21 NiftiSpheresMasker,
22 SurfaceMapsMasker,
23 SurfaceMasker,
24)
25from nilearn.surface import SurfaceImage
27# ruff: noqa: ARG001
29# Note: html output by nilearn view_* functions
30# should validate as html5 using https://validator.w3.org/nu/ with no
31# warnings
34def _check_html(html_view, reports_requested=True, is_fit=True):
35 """Check the presence of some expected code in the html viewer.
37 Also ensure some common behavior to all reports.
38 """
39 assert isinstance(html_view, HTMLDocument)
41 # resize width and height
42 html_view.resize(1200, 800)
43 assert html_view.width == 1200
44 assert html_view.height == 800
46 # invalid values fall back on default dimensions
47 with pytest.warns(UserWarning, match="Using default instead"):
48 html_view.width = "foo"
49 assert html_view.width == WIDTH_DEFAULT
51 if reports_requested and is_fit:
52 assert "<th>Parameter</th>" in str(html_view)
53 if "Surface" in str(html_view):
54 assert "data:image/png;base64," in str(html_view)
55 else:
56 assert "data:image/svg+xml;base64," in str(html_view)
57 assert html_view._repr_html_() == html_view.body
60@pytest.fixture
61def niftimapsmasker_inputs():
62 return {"maps_img": _img_maps(n_regions=3)}
65@pytest.fixture
66def labels(n_regions):
67 return ["background"] + [f"region_{i}" for i in range(1, n_regions + 1)]
70@pytest.fixture
71def input_parameters(masker_class, img_mask_eye, labels, img_labels):
72 if masker_class in (NiftiMasker, MultiNiftiMasker):
73 return {"mask_img": img_mask_eye}
74 if masker_class in (NiftiLabelsMasker, MultiNiftiLabelsMasker):
75 return {"labels_img": img_labels, "labels": labels}
76 if masker_class in (NiftiMapsMasker, MultiNiftiMapsMasker):
77 return {"maps_img": _img_maps(n_regions=2)}
78 if masker_class is NiftiSpheresMasker:
79 return {"seeds": [(1, 1, 1)]}
82@pytest.mark.parametrize(
83 "masker_class",
84 [NiftiMasker, NiftiLabelsMasker, NiftiMapsMasker, NiftiSpheresMasker],
85)
86def test_warning_in_report_after_empty_fit(masker_class, input_parameters):
87 """Tests that a warning is both given and written in the report \
88 if no images were provided to fit.
89 """
90 masker = masker_class(**input_parameters)
91 masker.fit()
93 warn_message = f"No image provided to fit in {masker_class.__name__}."
94 with pytest.warns(UserWarning, match=warn_message):
95 html = masker.generate_report()
96 assert warn_message in masker._report_content["warning_message"]
97 _check_html(html)
100@pytest.mark.parametrize("displayed_maps", ["foo", "1", {"foo": "bar"}])
101def test_nifti_maps_masker_report_displayed_maps_errors(
102 niftimapsmasker_inputs, displayed_maps
103):
104 """Tests that a TypeError is raised when the argument `displayed_maps` \
105 of `generate_report()` is not valid.
106 """
107 masker = NiftiMapsMasker(**niftimapsmasker_inputs)
108 masker.fit()
109 with pytest.raises(TypeError, match=("Parameter ``displayed_maps``")):
110 masker.generate_report(displayed_maps)
113@pytest.mark.parametrize("displayed_maps", [[2, 5, 10], [0, 66, 1, 260]])
114def test_nifti_maps_masker_report_maps_number_errors(
115 niftimapsmasker_inputs, displayed_maps
116):
117 """Tests that a ValueError is raised when the argument `displayed_maps` \
118 contains invalid map numbers.
119 """
120 masker = NiftiMapsMasker(**niftimapsmasker_inputs)
121 masker.fit()
122 with pytest.raises(
123 ValueError, match="Report cannot display the following maps"
124 ):
125 masker.generate_report(displayed_maps)
128@pytest.mark.parametrize("displayed_maps", [[1, 2], np.array([0, 1, 2])])
129def test_nifti_maps_masker_report_list_and_arrays_maps_number(
130 niftimapsmasker_inputs, displayed_maps
131):
132 """Tests report generation for NiftiMapsMasker with displayed_maps \
133 passed as a list of a Numpy arrays.
134 """
135 n_regions = niftimapsmasker_inputs["maps_img"].shape[-1]
137 masker = NiftiMapsMasker(**niftimapsmasker_inputs)
138 masker.fit()
139 html = masker.generate_report(displayed_maps)
141 assert masker._report_content["number_of_maps"] == n_regions
142 assert masker._report_content["displayed_maps"] == list(displayed_maps)
143 msg = (
144 "No image provided to fit in NiftiMapsMasker. "
145 "Plotting only spatial maps for reporting."
146 )
147 assert masker._report_content["warning_message"] == msg
148 assert html.body.count("<img") == len(displayed_maps)
151@pytest.mark.parametrize("displayed_maps", [1, 3, 4, "all"])
152def test_nifti_maps_masker_report_integer_and_all_displayed_maps(
153 niftimapsmasker_inputs, displayed_maps
154):
155 """Tests NiftiMapsMasker reporting with no image provided to fit \
156 and displayed_maps provided as an integer or as 'all'.
157 """
158 n_regions = niftimapsmasker_inputs["maps_img"].shape[-1]
160 masker = NiftiMapsMasker(**niftimapsmasker_inputs)
161 masker.fit()
162 expected_n_maps = (
163 n_regions
164 if displayed_maps == "all"
165 else min(n_regions, displayed_maps)
166 )
167 if displayed_maps != "all" and displayed_maps > n_regions:
168 with pytest.warns(UserWarning, match="masker only has .* maps."):
169 html = masker.generate_report(displayed_maps)
170 else:
171 html = masker.generate_report(displayed_maps)
173 assert masker._report_content["number_of_maps"] == n_regions
174 assert masker._report_content["displayed_maps"] == list(
175 range(expected_n_maps)
176 )
177 msg = (
178 "No image provided to fit in NiftiMapsMasker. "
179 "Plotting only spatial maps for reporting."
180 )
181 assert masker._report_content["warning_message"] == msg
182 assert html.body.count("<img") == expected_n_maps
185def test_nifti_maps_masker_report_image_in_fit(
186 niftimapsmasker_inputs, affine_eye
187):
188 """Tests NiftiMapsMasker reporting with image provided to fit."""
189 n_regions = niftimapsmasker_inputs["maps_img"].shape[-1]
191 masker = NiftiMapsMasker(**niftimapsmasker_inputs)
192 image, _ = generate_random_img((13, 11, 12, 3), affine=affine_eye)
193 masker.fit(image)
194 html = masker.generate_report(2)
196 assert masker._report_content["number_of_maps"] == n_regions
198 assert html.body.count("<img") == 2
201@pytest.mark.parametrize("displayed_spheres", ["foo", "1", {"foo": "bar"}])
202def test_nifti_spheres_masker_report_displayed_spheres_errors(
203 displayed_spheres,
204):
205 """Tests that a TypeError is raised when the argument `displayed_spheres` \
206 of `generate_report()` is not valid.
207 """
208 masker = NiftiSpheresMasker(seeds=[(1, 1, 1)])
209 masker.fit()
210 with pytest.raises(TypeError, match=("Parameter ``displayed_spheres``")):
211 masker.generate_report(displayed_spheres)
214def test_nifti_spheres_masker_report_displayed_spheres_more_than_seeds():
215 """Tests that a warning is raised when number of `displayed_spheres` \
216 is greater than number of seeds.
217 """
218 displayed_spheres = 10
219 seeds = [(1, 1, 1)]
220 masker = NiftiSpheresMasker(seeds=seeds)
221 masker.fit()
222 with pytest.warns(UserWarning, match="masker only has 1 seeds."):
223 masker.generate_report(displayed_spheres=displayed_spheres)
226@pytest.mark.parametrize(
227 "displayed_spheres, expected_displayed_maps",
228 [("all", [0, 1, 2, 3]), ([1], [0, 2]), ([0, 2], [0, 1, 3])],
229)
230def test_nifti_spheres_masker_report_displayed_spheres_list(
231 displayed_spheres, expected_displayed_maps
232):
233 """Tests that spheres_to_be_displayed is set correctly.
235 report_content["displayed_maps"]
236 should have one more value than requested
237 as _report_content["displayed_maps"][0]
238 is a glass brain with all the spheres
239 """
240 seeds = [(1, 1, 1), (2, 2, 2), (3, 3, 3)]
241 masker = NiftiSpheresMasker(seeds=seeds)
242 masker.fit()
243 masker.generate_report(displayed_spheres=displayed_spheres)
244 assert masker._report_content["displayed_maps"] == expected_displayed_maps
247def test_nifti_spheres_masker_report_displayed_spheres_list_more_than_seeds():
248 """Tests that a ValueError is raised when list of `displayed_spheres` \
249 maximum is greater than number of seeds.
250 """
251 displayed_spheres = [1, 2, 3]
252 seeds = [(1, 1, 1)]
253 masker = NiftiSpheresMasker(seeds=seeds)
254 masker.fit()
255 with pytest.raises(ValueError, match="masker only has 1 seeds."):
256 masker.generate_report(displayed_spheres=displayed_spheres)
259def test_nifti_spheres_masker_report_1_sphere():
260 """Check the report for sphere actually works for one sphere.
262 See https://github.com/nilearn/nilearn/issues/4268
263 """
264 report = NiftiSpheresMasker([(1, 1, 1)]).fit().generate_report()
266 empty_div = """
267 <img id="map1" class="pure-img" width="100%"
268 src="data:image/svg+xml;base64,D"
269 style="display:none;" alt="image"/>"""
271 assert empty_div not in report.body
274def test_nifti_labels_masker_report_no_image_for_fit(
275 img_3d_rand_eye, n_regions, labels, img_labels
276):
277 """Check no contour in image when no image was provided to fit."""
278 masker = NiftiLabelsMasker(img_labels, labels=labels)
279 masker.fit()
281 # No image was provided to fit, regions are plotted using
282 # plot_roi such that no contour should be in the image
283 display = masker._reporting()
284 for d in ["x", "y", "z"]:
285 assert len(display[0].axes[d].ax.collections) == 0
287 masker.fit(img_3d_rand_eye)
289 display = masker._reporting()
290 for d in ["x", "y", "z"]:
291 assert len(display[0].axes[d].ax.collections) > 0
292 assert len(display[0].axes[d].ax.collections) <= n_regions
295EXPECTED_COLUMNS = [
296 "label value",
297 "region name",
298 "size (in mm^3)",
299 "relative size (in %)",
300]
303def test_nifti_labels_masker_report(
304 img_3d_rand_eye, img_mask_eye, affine_eye, n_regions, labels, img_labels
305):
306 """Check content nifti label masker."""
307 masker = NiftiLabelsMasker(
308 img_labels, labels=labels, mask_img=img_mask_eye
309 )
310 masker.fit_transform(img_3d_rand_eye)
311 report = masker.generate_report()
313 assert masker._reporting_data is not None
315 # Check that background label was left as default
316 assert masker.background_label == 0
317 assert masker._report_content["description"] == (
318 "This reports shows the regions defined by the labels of the mask."
319 )
321 # Check that the number of regions is correct
322 assert masker._report_content["number_of_regions"] == n_regions
324 # Check that all expected columns are present with the right size
325 assert (
326 masker._report_content["summary"]["region name"].to_list()
327 == labels[1:]
328 )
329 assert len(masker._report_content["summary"]) == n_regions
330 for col in EXPECTED_COLUMNS:
331 assert col in masker._report_content["summary"].columns
333 # Check that labels match
335 # Relative sizes of regions should sum to 100%
336 assert_almost_equal(
337 sum(masker._report_content["summary"]["relative size (in %)"]),
338 100,
339 decimal=2,
340 )
342 _check_html(report)
344 assert "Regions summary" in str(report)
346 # Check region sizes calculations
347 expected_region_sizes = Counter(get_data(img_labels).ravel())
348 for r in range(1, n_regions + 1):
349 assert_almost_equal(
350 masker._report_content["summary"]["size (in mm^3)"].to_list()[
351 r - 1
352 ],
353 expected_region_sizes[r]
354 * np.abs(np.linalg.det(affine_eye[:3, :3])),
355 )
358@pytest.mark.parametrize("masker_class", [NiftiLabelsMasker])
359def test_nifti_labels_masker_report_cut_coords(
360 masker_class, input_parameters, img_3d_rand_eye
361):
362 """Test cut coordinate are equal with and without passing data to fit."""
363 masker = masker_class(**input_parameters, reports=True)
364 # Get display without data
365 masker.fit()
366 display = masker._reporting()
367 # Get display with data
368 masker.fit(img_3d_rand_eye)
369 display_data = masker._reporting()
370 assert display[0].cut_coords == display_data[0].cut_coords
373def test_4d_reports(img_mask_eye, affine_eye):
374 # Dummy 4D data
375 data = np.zeros((10, 10, 10, 3), dtype="int32")
376 data[..., 0] = 1
377 data[..., 1] = 2
378 data[..., 2] = 3
379 data_img_4d = Nifti1Image(data, affine_eye)
381 # test .fit method
382 masker = NiftiMasker(mask_strategy="epi")
383 masker.fit(data_img_4d)
385 assert masker._report_content["coverage"] > 0
387 html = masker.generate_report()
388 _check_html(html)
389 assert "The mask includes" in str(html)
391 # test .fit_transform method
392 masker = NiftiMasker(mask_img=img_mask_eye, standardize=True)
393 masker.fit_transform(data_img_4d)
395 html = masker.generate_report()
396 _check_html(html)
399def test_overlaid_report(img_fmri):
400 """Check empty report generated before fit and with image after."""
401 masker = NiftiMasker(
402 mask_strategy="whole-brain-template",
403 mask_args={"threshold": 0.0},
404 target_affine=np.eye(3) * 3,
405 )
406 masker.fit(img_fmri)
407 html = masker.generate_report()
409 assert '<div class="overlay">' in str(html)
412@pytest.mark.parametrize(
413 "reports,expected", [(True, dict), (False, type(None))]
414)
415def test_multi_nifti_masker_generate_report_imgs(reports, expected, img_fmri):
416 """Smoke test for generate_report method with image data."""
417 masker = MultiNiftiMasker(reports=reports)
418 masker.fit([img_fmri, img_fmri])
419 assert isinstance(masker._reporting_data, expected)
420 masker.generate_report()
423def test_multi_nifti_masker_generate_report_mask(
424 img_3d_ones_eye, shape_3d_default, affine_eye
425):
426 """Smoke test for generate_report method with only mask."""
427 masker = MultiNiftiMasker(
428 mask_img=img_3d_ones_eye,
429 # to test resampling lines without imgs
430 target_affine=affine_eye,
431 target_shape=shape_3d_default,
432 )
433 masker.fit().generate_report()
436def test_multi_nifti_masker_generate_report_imgs_and_mask(
437 shape_3d_default, affine_eye, img_fmri
438):
439 """Smoke test for generate_report method with images and mask."""
440 mask = Nifti1Image(np.ones(shape_3d_default), affine_eye)
441 masker = MultiNiftiMasker(
442 mask_img=mask,
443 # to test resampling lines with imgs
444 target_affine=affine_eye,
445 target_shape=shape_3d_default,
446 )
447 masker.fit([img_fmri, img_fmri]).generate_report()
450def test_surface_masker_mask_img_generate_report(surf_img_1d, surf_mask_1d):
451 """Smoke test generate report."""
452 masker = SurfaceMasker(surf_mask_1d, reports=True).fit()
454 assert masker._reporting_data is not None
455 assert masker._reporting_data["images"] is None
457 masker.transform(surf_img_1d)
459 assert isinstance(masker._reporting_data["images"], SurfaceImage)
461 masker.generate_report()
464def test_surface_masker_mask_img_generate_no_report(surf_img_2d, surf_mask_1d):
465 """Smoke test generate report."""
466 masker = SurfaceMasker(surf_mask_1d, reports=False).fit()
468 assert masker._reporting_data is None
470 img = surf_img_2d(5)
471 masker.transform(img)
473 masker.generate_report()
476@pytest.mark.parametrize("reports", [True, False])
477@pytest.mark.parametrize("empty_mask", [True, False])
478def test_surface_masker_minimal_report_no_fit(
479 surf_mask_1d, empty_mask, reports
480):
481 """Test minimal report generation with no fit."""
482 mask = None if empty_mask else surf_mask_1d
483 masker = SurfaceMasker(mask_img=mask, reports=reports)
484 report = masker.generate_report()
486 _check_html(report, reports_requested=reports, is_fit=False)
489@pytest.mark.parametrize("reports", [True, False])
490@pytest.mark.parametrize("empty_mask", [True, False])
491def test_surface_masker_minimal_report_fit(
492 surf_mask_1d, empty_mask, surf_img_1d, reports
493):
494 """Test minimal report generation with fit."""
495 mask = None if empty_mask else surf_mask_1d
496 masker = SurfaceMasker(mask_img=mask, reports=reports)
497 masker.fit_transform(surf_img_1d)
498 report = masker.generate_report()
500 _check_html(report, reports_requested=reports)
501 assert '<div class="image">' in str(report)
502 if not reports:
503 assert 'src="data:image/svg+xml;base64,"' in str(report)
504 else:
505 assert masker._report_content["coverage"] > 0
506 assert "The mask includes" in str(report)
509def test_generate_report_engine_error(surf_maps_img, surf_img_2d):
510 """Test error is raised when engine is not 'plotly' or 'matplotlib'."""
511 masker = SurfaceMapsMasker(surf_maps_img)
512 masker.fit_transform(surf_img_2d(10))
513 with pytest.raises(
514 ValueError,
515 match="should be either 'matplotlib' or 'plotly'",
516 ):
517 masker.generate_report(engine="invalid")
520@pytest.mark.skipif(
521 is_plotly_installed() or not is_matplotlib_installed(),
522 reason="Test requires plotly not to be installed.",
523)
524def test_generate_report_engine_no_plotly_warning(surf_maps_img, surf_img_2d):
525 """Test warning is raised when engine selected is plotly but it is not
526 installed. Only run when plotly is not installed but matplotlib is.
527 """
528 masker = SurfaceMapsMasker(surf_maps_img)
529 masker.fit_transform(surf_img_2d(10))
530 with pytest.warns(match="Plotly is not installed"):
531 masker.generate_report(engine="plotly")
532 # check if the engine is switched to matplotlib
533 assert masker._report_content["engine"] == "matplotlib"
536@pytest.mark.parametrize("displayed_maps", [4, [1, 3, 4, 5], "all", [1]])
537def test_generate_report_displayed_maps_valid_inputs(
538 surf_maps_img, surf_img_2d, displayed_maps
539):
540 """Test all valid inputs for displayed_maps."""
541 masker = SurfaceMapsMasker(surf_maps_img)
542 masker.fit_transform(surf_img_2d(10))
543 masker.generate_report(displayed_maps=displayed_maps)
546@pytest.mark.parametrize("displayed_maps", [4.5, [8.4, 3], "invalid"])
547def test_generate_report_displayed_maps_type_error(
548 surf_maps_img, surf_img_2d, displayed_maps
549):
550 """Test error is raised when displayed_maps is not a list or int or
551 np.ndarray or str(all).
552 """
553 masker = SurfaceMapsMasker(surf_maps_img)
554 masker.fit_transform(surf_img_2d(10))
555 with pytest.raises(
556 TypeError,
557 match="should be either 'all' or an int, or a list/array of ints",
558 ):
559 masker.generate_report(displayed_maps=displayed_maps)
562def test_generate_report_displayed_maps_more_than_regions_warn_int(
563 surf_maps_img, surf_img_2d
564):
565 """Test error is raised when displayed_maps is int and is more than n
566 regions.
567 """
568 masker = SurfaceMapsMasker(surf_maps_img)
569 masker.fit_transform(surf_img_2d(10))
570 with pytest.warns(
571 UserWarning,
572 match="But masker only has 6 maps",
573 ):
574 masker.generate_report(displayed_maps=10)
575 # check if displayed_maps is switched to 6
576 assert masker.displayed_maps == 6
579def test_generate_report_displayed_maps_more_than_regions_warn_list(
580 surf_maps_img, surf_img_2d
581):
582 """Test error is raised when displayed_maps is list has more elements than
583 n regions.
584 """
585 masker = SurfaceMapsMasker(surf_maps_img)
586 masker.fit_transform(surf_img_2d(10))
587 with pytest.raises(
588 ValueError,
589 match="Report cannot display the following maps",
590 ):
591 masker.generate_report(displayed_maps=[1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
594def test_generate_report_before_transform_warn(surf_maps_img):
595 """Test warning is raised when generate_report is called before
596 transform.
597 """
598 masker = SurfaceMapsMasker(surf_maps_img).fit()
599 with pytest.warns(match="SurfaceMapsMasker has not been transformed"):
600 masker.generate_report()
603@pytest.mark.skipif(
604 on_windows_with_old_mpl_and_new_numpy(),
605 reason="Old matplotlib not compatible with numpy 2.0 on windows.",
606)
607def test_generate_report_plotly_out_figure_type(
608 plotly, surf_maps_img, surf_img_2d
609):
610 """Test that the report has a iframe tag when engine is plotly
611 (default).
612 """
613 masker = SurfaceMapsMasker(surf_maps_img)
614 masker.fit_transform(surf_img_2d(10))
615 report = masker.generate_report(engine="plotly")
617 # read the html file and see if plotly figure is inserted
618 # meaning it should have <iframe tag
619 report_str = report.__str__()
620 assert "<iframe" in report_str
621 # and no <img tag
622 assert "<img" not in report_str
625def test_generate_report_matplotlib_out_figure_type(
626 surf_maps_img,
627 surf_img_2d,
628):
629 """Test that the report has a img tag when engine is matplotlib."""
630 masker = SurfaceMapsMasker(surf_maps_img)
631 masker.fit_transform(surf_img_2d(10))
632 report = masker.generate_report(engine="matplotlib")
634 # read the html file and see if matplotlib figure is inserted
635 # meaning it should have <img tag
636 report_str = report.__str__()
637 assert "<img" in report_str
638 # and no <iframe tag
639 assert "<iframe" not in report_str