diff --git a/.coveragerc b/.coveragerc index a7668d330c..5e2d545938 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,4 +1,5 @@ [run] branch = True parallel = True - +omit = + */nilearn/externals/* diff --git a/Makefile b/Makefile index 50ada3bd2a..ec14caa46b 100644 --- a/Makefile +++ b/Makefile @@ -63,4 +63,3 @@ doc: .PHONY : pdf pdf: make -C doc pdf - diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000000..9fd6de2da3 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,2 @@ +ignore: + - "*externals/.*" # ignore folders and all its contents diff --git a/doc/conf.py b/doc/conf.py index 387905d121..392ccb353d 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -281,6 +281,11 @@ _python_doc_base = 'http://docs.python.org/3.6' +# Scraper, copied from https://github.com/mne-tools/mne-python/ +from nilearn.reporting import _ReportScraper +report_scraper = _ReportScraper() +scrapers = ('matplotlib', report_scraper) + # Example configuration for intersphinx: refer to the Python standard library. intersphinx_mapping = { 'python': (_python_doc_base, None), @@ -302,6 +307,7 @@ 'backreferences_dir': os.path.join('modules', 'generated'), 'reference_url': {'nilearn': None}, 'junit': '../test-results/sphinx-gallery/junit.xml', + 'image_scrapers': scrapers, } # Get rid of spurious warnings due to some interaction between @@ -310,7 +316,6 @@ # details numpydoc_show_class_members = False - def touch_example_backreferences(app, what, name, obj, options, lines): # generate empty examples files, so that we don't get # inclusion errors if there are no examples for a class / module @@ -327,3 +332,4 @@ def touch_example_backreferences(app, what, name, obj, options, lines): def setup(app): app.add_javascript('copybutton.js') app.connect('autodoc-process-docstring', touch_example_backreferences) + report_scraper.app = app diff --git a/doc/images/niftimasker_report.png b/doc/images/niftimasker_report.png new file mode 100644 index 0000000000..664469b9a3 Binary files /dev/null and b/doc/images/niftimasker_report.png differ diff --git a/doc/images/niftimasker_report_params.png b/doc/images/niftimasker_report_params.png new file mode 100644 index 0000000000..7528695bcf Binary files /dev/null and b/doc/images/niftimasker_report_params.png differ diff --git a/doc/manipulating_images/masker_objects.rst b/doc/manipulating_images/masker_objects.rst index fa77719bda..dc2f06dafd 100644 --- a/doc/manipulating_images/masker_objects.rst +++ b/doc/manipulating_images/masker_objects.rst @@ -136,12 +136,25 @@ mask computation parameters. The mask can be retrieved and visualized from the `mask_img_` attribute of the masker: +.. literalinclude:: ../../examples/04_manipulating_images/plot_mask_computation.py + :start-after: # A NiftiMasker with the default strategy + :end-before: # Plot the generated mask using the .generate_report method + +.. figure:: ../auto_examples/04_manipulating_images/images/sphx_glr_plot_mask_computation_002.png + :target: ../auto_examples/04_manipulating_images/plot_mask_computation.html + :align: center + :scale: 40 + +Alternatively, the mask can be visualized using the `generate_report` +method of the masker. The generated report can be viewed in a Jupyter notebook, +opened in a new browser tab using `report.open_in_browser()`, +or saved as a portable HTML file `report.save_as_html(output_filepath)`. + .. literalinclude:: ../../examples/04_manipulating_images/plot_mask_computation.py :start-after: # We need to specify an 'epi' mask_strategy, as this is raw EPI data :end-before: # Generate mask with strong opening - -.. figure:: ../auto_examples/04_manipulating_images/images/sphx_glr_plot_mask_computation_004.png +.. figure:: /images/niftimasker_report.png :target: ../auto_examples/04_manipulating_images/plot_mask_computation.html :scale: 50% @@ -163,7 +176,7 @@ Controling these arguments set the fine aspects of the mask. See the functions documentation, or :doc:`the NiftiMasker example <../auto_examples/04_manipulating_images/plot_mask_computation>`. -.. figure:: ../auto_examples/04_manipulating_images/images/sphx_glr_plot_mask_computation_005.png +.. figure:: /images/niftimasker_report_params.png :target: ../auto_examples/04_manipulating_images/plot_mask_computation.html :scale: 50% @@ -180,9 +193,10 @@ preparation:: >>> masker # doctest: +ELLIPSIS +NORMALIZE_WHITESPACE NiftiMasker(detrend=False, dtype=None, high_pass=None, low_pass=None, mask_args=None, mask_img=None, mask_strategy='background', - memory=Memory(...), memory_level=1, sample_mask=None, - sessions=None, smoothing_fwhm=None, standardize=False, t_r=None, - target_affine=None, target_shape=None, verbose=0) + memory=Memory(...), memory_level=1, reports=True, + sample_mask=None, sessions=None, smoothing_fwhm=None, + standardize=False, t_r=None, target_affine=None, target_shape=None, + verbose=0) .. note:: @@ -234,7 +248,7 @@ Temporal Filtering and confound removal properties, before conversion to voxel signals. - **Standardization**. Parameter ``standardize``: Signals can be - standardized (scaled to unit variance). + standardized (scaled to unit variance). - **Frequency filtering**. Low-pass and high-pass filters can be used to remove artifacts. Parameters: ``high_pass`` and ``low_pass``, specified @@ -242,7 +256,7 @@ properties, before conversion to voxel signals. the ``t_r`` parameter: ``loss_pass=.5, t_r=2.1``). - **Confound removal**. Two ways of removing confounds are provided: simple - detrending or using prespecified confounds, such as behavioral or movement + detrending or using prespecified confounds, such as behavioral or movement information. * Linear trends can be removed by activating the `detrend` parameter. @@ -251,7 +265,7 @@ properties, before conversion to voxel signals. signal of interest (e.g., the neural correlates of cognitive tasks). It is not activated by default in :class:`NiftiMasker` but is recommended in almost all scenarios. - + * More complex confounds, measured during the acquision, can be removed by passing them to :meth:`NiftiMasker.transform`. If the dataset provides a confounds file, just pass its path to the masker. diff --git a/doc/themes/nilearn/static/nature.css_t b/doc/themes/nilearn/static/nature.css_t index 6d5c73b8ff..7397860e39 100644 --- a/doc/themes/nilearn/static/nature.css_t +++ b/doc/themes/nilearn/static/nature.css_t @@ -1689,3 +1689,22 @@ ul#tab li div.contents p{ p.sphx-glr-horizontal { margin-top: 2em; } + + +/* Sphinx-gallery Report embedding */ +div.sg-report { + padding: 0pt; + transform: scale(.95); +} + +div.sg-report iframe { + display: block; + border-style: none; + transform: scale(.85); + height: 470px; + margin-left: -12%; /* Negative because of .8 scaling */ + margin-top: -4%; + padding: 0pt; + margin-bottom: 0pt; + width: 126%; /* More than 100% because of .8 scaling */ +} diff --git a/doc/themes/nilearn/static/sphinxdoc.css b/doc/themes/nilearn/static/sphinxdoc.css deleted file mode 100644 index b680a95710..0000000000 --- a/doc/themes/nilearn/static/sphinxdoc.css +++ /dev/null @@ -1,339 +0,0 @@ -/* - * sphinxdoc.css_t - * ~~~~~~~~~~~~~~~ - * - * Sphinx stylesheet -- sphinxdoc theme. Originally created by - * Armin Ronacher for Werkzeug. - * - * :copyright: Copyright 2007-2011 by the Sphinx team, see AUTHORS. - * :license: BSD, see LICENSE for details. - * - */ - -@import url("basic.css"); - -/* -- page layout ----------------------------------------------------------- */ - -body { - font-family: 'Lucida Grande', 'Lucida Sans Unicode', 'Geneva', - 'Verdana', sans-serif; - font-size: 14px; - letter-spacing: -0.01em; - line-height: 150%; - text-align: center; - background-color: #BFD1D4; - color: black; - padding: 0; - border: 1px solid #aaa; - - margin: 0px 80px 0px 80px; - min-width: 740px; -} - -div.document { - background-color: white; - text-align: left; - background-image: url(contents.png); - background-repeat: repeat-x; -} - -div.bodywrapper { - margin: 0 240px 0 0; - border-right: 1px solid #ccc; -} - -div.body { - margin: 0; - padding: 0.5em 20px 20px 20px; -} - -div.related { - font-size: 1em; -} - -div.related ul { - background-image: url(navigation.png); - height: 2em; - border-top: 1px solid #ddd; - border-bottom: 1px solid #ddd; -} - -div.related ul li { - margin: 0; - padding: 0; - height: 2em; - float: left; -} - -div.related ul li.right { - float: right; - margin-right: 5px; -} - -div.related ul li a { - margin: 0; - padding: 0 5px 0 5px; - line-height: 1.75em; - color: #EE9816; -} - -div.related ul li a:hover { - color: #3CA8E7; -} - -div.sphinxsidebarwrapper { - padding: 0; -} - -div.sphinxsidebar { - margin: 0; - padding: 0.5em 15px 15px 0; - width: 210px; - float: right; - font-size: 1em; - text-align: left; -} - -div.sphinxsidebar h3, div.sphinxsidebar h4 { - margin: 1em 0 0.5em 0; - font-size: 1em; - padding: 0.1em 0 0.1em 0.5em; - color: white; - border: 1px solid #86989B; - background-color: #AFC1C4; -} - -div.sphinxsidebar h3 a { - color: white; -} - -div.sphinxsidebar ul { - padding-left: 1.5em; - margin-top: 7px; - padding: 0; - line-height: 130%; -} - -div.sphinxsidebar ul ul { - margin-left: 20px; -} - -div.footer { - background-color: #E3EFF1; - color: #86989B; - padding: 3px 8px 3px 0; - clear: both; - font-size: 0.8em; - text-align: right; -} - -div.footer a { - color: #86989B; - text-decoration: underline; -} - -/* -- body styles ----------------------------------------------------------- */ - -p { - margin: 0.8em 0 0.5em 0; -} - -a { - color: #CA7900; - text-decoration: none; -} - -a:hover { - color: #2491CF; -} - -div.body a { - text-decoration: underline; -} - -h1 { - margin: 0; - padding: 0.7em 0 0.3em 0; - font-size: 1.5em; - color: #11557C; -} - -h2 { - margin: 1.3em 0 0.2em 0; - font-size: 1.35em; - padding: 0; -} - -h3 { - margin: 1em 0 -0.3em 0; - font-size: 1.2em; -} - -div.body h1 a, div.body h2 a, div.body h3 a, div.body h4 a, div.body h5 a, div.body h6 a { - color: black!important; -} - -h1 a.anchor, h2 a.anchor, h3 a.anchor, h4 a.anchor, h5 a.anchor, h6 a.anchor { - display: none; - margin: 0 0 0 0.3em; - padding: 0 0.2em 0 0.2em; - color: #aaa!important; -} - -h1:hover a.anchor, h2:hover a.anchor, h3:hover a.anchor, h4:hover a.anchor, -h5:hover a.anchor, h6:hover a.anchor { - display: inline; -} - -h1 a.anchor:hover, h2 a.anchor:hover, h3 a.anchor:hover, h4 a.anchor:hover, -h5 a.anchor:hover, h6 a.anchor:hover { - color: #777; - background-color: #eee; -} - -a.headerlink { - color: #c60f0f!important; - font-size: 1em; - margin-left: 6px; - padding: 0 4px 0 4px; - text-decoration: none!important; -} - -a.headerlink:hover { - background-color: #ccc; - color: white!important; -} - -cite, code, tt { - font-family: 'Consolas', 'Deja Vu Sans Mono', - 'Bitstream Vera Sans Mono', monospace; - font-size: 0.95em; - letter-spacing: 0.01em; -} - -tt { - background-color: #f2f2f2; - border-bottom: 1px solid #ddd; - color: #333; -} - -tt.descname, tt.descclassname, tt.xref { - border: 0; -} - -hr { - border: 1px solid #abc; - margin: 2em; -} - -a tt { - border: 0; - color: #CA7900; -} - -a tt:hover { - color: #2491CF; -} - -pre { - font-family: 'Consolas', 'Deja Vu Sans Mono', - 'Bitstream Vera Sans Mono', monospace; - font-size: 0.95em; - letter-spacing: 0.015em; - line-height: 120%; - padding: 0.5em; - border: 1px solid #ccc; - background-color: #f8f8f8; -} - -pre a { - color: inherit; - text-decoration: underline; -} - -td.linenos pre { - padding: 0.5em 0; -} - -div.quotebar { - background-color: #f8f8f8; - max-width: 250px; - float: right; - padding: 2px 7px; - border: 1px solid #ccc; -} - -div.topic { - background-color: #f8f8f8; -} - -table { - border-collapse: collapse; - margin: 0 -0.5em 0 -0.5em; -} - -table td, table th { - padding: 0.2em 0.5em 0.2em 0.5em; -} - -div.admonition, div.warning { - font-size: 0.9em; - margin: 1em 0 1em 0; - border: 1px solid #86989B; - background-color: #f7f7f7; - padding: 0; -} - -div.admonition p, div.warning p { - margin: 0.5em 1em 0.5em 1em; - padding: 0; -} - -div.admonition pre, div.warning pre { - margin: 0.4em 1em 0.4em 1em; -} - -div.admonition p.admonition-title, -div.warning p.admonition-title { - margin: 0; - padding: 0.1em 0 0.1em 0.5em; - color: white; - border-bottom: 1px solid #86989B; - font-weight: bold; - background-color: #AFC1C4; -} - -div.warning { - border: 1px solid #940000; -} - -div.warning p.admonition-title { - background-color: #CF0000; - border-bottom-color: #940000; -} - -div.admonition ul, div.admonition ol, -div.warning ul, div.warning ol { - margin: 0.1em 0.5em 0.5em 3em; - padding: 0; -} - -div.versioninfo { - margin: 1em 0 0 0; - border: 1px solid #ccc; - background-color: #DDEAF0; - padding: 8px; - line-height: 1.3em; - font-size: 0.9em; -} - -.viewcode-back { - font-family: 'Lucida Grande', 'Lucida Sans Unicode', 'Geneva', - 'Verdana', sans-serif; -} - -div.viewcode-block:target { - background-color: #f4debf; - border-top: 1px solid #ac9; - border-bottom: 1px solid #ac9; -} \ No newline at end of file diff --git a/doc/whats_new.rst b/doc/whats_new.rst index b341c8cdb2..df88e6ed22 100644 --- a/doc/whats_new.rst +++ b/doc/whats_new.rst @@ -13,6 +13,9 @@ NEW | - Scikit-learn -- v0.19 | - Scipy -- v0.19 +- A new method for :class:`nilearn.input_data.NiftiMasker` instances + for generating reports viewable in a web browser, Jupyter Notebook, or VSCode. + - joblib is now a dependency - Parcellation method ReNA: Fast agglomerative clustering based on recursive diff --git a/examples/01_plotting/plot_3d_map_to_surface_projection.py b/examples/01_plotting/plot_3d_map_to_surface_projection.py index c8d08a0e6e..0b01f4197e 100644 --- a/examples/01_plotting/plot_3d_map_to_surface_projection.py +++ b/examples/01_plotting/plot_3d_map_to_surface_projection.py @@ -85,15 +85,16 @@ view = plotting.view_surf(fsaverage.infl_right, texture, threshold='90%', bg_map=fsaverage.sulc_right) -# uncomment this to open the plot in a web browser: -# view.open_in_browser() -############################################################################## # In a Jupyter notebook, if ``view`` is the output of a cell, it will # be displayed below the cell - view +############################################################################## + +# uncomment this to open the plot in a web browser: +# view.open_in_browser() + ############################################################################## # We don't need to do the projection ourselves, we can use view_img_on_surf: diff --git a/examples/01_plotting/plot_demo_plotting.py b/examples/01_plotting/plot_demo_plotting.py index 629864a913..9123648522 100644 --- a/examples/01_plotting/plot_demo_plotting.py +++ b/examples/01_plotting/plot_demo_plotting.py @@ -33,6 +33,7 @@ motor_images = datasets.fetch_neurovault_motor_task() stat_img = motor_images.images[0] + ############################################################################### # Plotting statistical maps with function `plot_stat_map` # -------------------------------------------------------- @@ -54,15 +55,15 @@ # for more details. view = plotting.view_img(stat_img, threshold=3) +# In a Jupyter notebook, if ``view`` is the output of a cell, it will +# be displayed below the cell +view + +############################################################################## # uncomment this to open the plot in a web browser: # view.open_in_browser() -############################################################################## -# In a Jupyter notebook, if ``view`` is the output of a cell, it will -# be displayed below the cell - -view ############################################################################### # Plotting statistical maps in a glass brain with function `plot_glass_brain` diff --git a/examples/01_plotting/plot_surf_atlas.py b/examples/01_plotting/plot_surf_atlas.py index 90fb6996dd..85b35a3dab 100644 --- a/examples/01_plotting/plot_surf_atlas.py +++ b/examples/01_plotting/plot_surf_atlas.py @@ -124,14 +124,14 @@ view = plotting.view_surf(fsaverage.infl_left, parcellation, cmap='gist_ncar', symmetric_cmap=False) -# uncomment this to open the plot in a web browser: -# view.open_in_browser() - -############################################################################## # In a Jupyter notebook, if ``view`` is the output of a cell, it will # be displayed below the cell view +############################################################################## + +# uncomment this to open the plot in a web browser: +# view.open_in_browser() ############################################################################## # you can also use :func:`nilearn.plotting.view_connectome` to open an diff --git a/examples/03_connectivity/plot_inverse_covariance_connectome.py b/examples/03_connectivity/plot_inverse_covariance_connectome.py index 779344101b..88c430659b 100644 --- a/examples/03_connectivity/plot_inverse_covariance_connectome.py +++ b/examples/03_connectivity/plot_inverse_covariance_connectome.py @@ -107,11 +107,12 @@ view = plotting.view_connectome(-estimator.precision_, coords) -# uncomment this to open the plot in a web browser: -# view.open_in_browser() - -############################################################################## # In a Jupyter notebook, if ``view`` is the output of a cell, it will # be displayed below the cell - view + +############################################################################## + +# uncomment this to open the plot in a web browser: +# view.open_in_browser() + diff --git a/examples/03_connectivity/plot_probabilistic_atlas_extraction.py b/examples/03_connectivity/plot_probabilistic_atlas_extraction.py index 938cd97ca1..21fb2153b6 100644 --- a/examples/03_connectivity/plot_probabilistic_atlas_extraction.py +++ b/examples/03_connectivity/plot_probabilistic_atlas_extraction.py @@ -88,11 +88,12 @@ view = plotting.view_connectome(correlation_matrix, coords, threshold='80%') -# uncomment this to open the plot in a web browser: -# view.open_in_browser() - -############################################################################## # In a Jupyter notebook, if ``view`` is the output of a cell, it will # be displayed below the cell - view + +############################################################################## + +# uncomment this to open the plot in a web browser: +# view.open_in_browser() + diff --git a/examples/03_connectivity/plot_sphere_based_connectome.py b/examples/03_connectivity/plot_sphere_based_connectome.py index 9247c53faa..4c5bd58440 100644 --- a/examples/03_connectivity/plot_sphere_based_connectome.py +++ b/examples/03_connectivity/plot_sphere_based_connectome.py @@ -143,16 +143,15 @@ view = plotting.view_connectome(partial_correlation_matrix, dmn_coords) -# uncomment this to open the plot in a web browser: -# view.open_in_browser() - - -############################################################################## # In a Jupyter notebook, if ``view`` is the output of a cell, it will # be displayed below the cell - view +############################################################################## + +# uncomment this to open the plot in a web browser: +# view.open_in_browser() + ########################################################################## # Extract signals on spheres from an atlas @@ -344,8 +343,6 @@ ############################################################################### # .. seealso:: # -# :ref:`sphx_glr_auto_examples_03_connectivity_plot_atlas_comparison.py` -# -# .. seealso:: +# * :ref:`sphx_glr_auto_examples_03_connectivity_plot_atlas_comparison.py` # -# :ref:`sphx_glr_auto_examples_03_connectivity_plot_multi_subject_connectome.py` +# * :ref:`sphx_glr_auto_examples_03_connectivity_plot_multi_subject_connectome.py` diff --git a/examples/04_manipulating_images/plot_mask_computation.py b/examples/04_manipulating_images/plot_mask_computation.py index d6b76db8e2..470a2506c4 100644 --- a/examples/04_manipulating_images/plot_mask_computation.py +++ b/examples/04_manipulating_images/plot_mask_computation.py @@ -17,7 +17,6 @@ """ - from nilearn.input_data import NiftiMasker import nilearn.image as image from nilearn.plotting import plot_roi, plot_epi, show @@ -48,10 +47,15 @@ masker = NiftiMasker() masker.fit(miyawaki_filename) -# Plot the generated mask +# Plot the generated mask using the mask_img_ attribute plot_roi(masker.mask_img_, miyawaki_mean_img, title="Mask from already masked data") +############################################################################### +# Plot the generated mask using the .generate_report method +report = masker.generate_report() +report + ############################################################################### # Computing a mask from raw EPI data @@ -77,7 +81,8 @@ # We need to specify an 'epi' mask_strategy, as this is raw EPI data masker = NiftiMasker(mask_strategy='epi') masker.fit(epi_img) -plot_roi(masker.mask_img_, mean_img, title='EPI automatic mask') +report = masker.generate_report() +report ############################################################################### # Generate mask with strong opening @@ -90,7 +95,8 @@ # skull parts in the image. masker = NiftiMasker(mask_strategy='epi', mask_args=dict(opening=10)) masker.fit(epi_img) -plot_roi(masker.mask_img_, mean_img, title='EPI Mask with strong opening') +report = masker.generate_report() +report ############################################################################### # Generate mask with a high lower cutoff @@ -107,8 +113,8 @@ mask_args=dict(upper_cutoff=.9, lower_cutoff=.8, opening=False)) masker.fit(epi_img) -plot_roi(masker.mask_img_, mean_img, - title='EPI Mask: high lower_cutoff') +report = masker.generate_report() +report ############################################################################### # Computing the mask from the MNI template @@ -119,9 +125,27 @@ masker = NiftiMasker(mask_strategy='template') masker.fit(epi_img) -plot_roi(masker.mask_img_, mean_img, - title='Mask from template') +report = masker.generate_report() +report + +############################################################################### +# Compute and resample a mask +############################################################################### +# +# NiftiMasker also allows passing parameters directly to `image.resample_img`. +# We can specify a `target_affine`, a `target_shape`, or both. +# For more information on these arguments, +# see :doc:`plot_affine_transformation`. +# +# The NiftiMasker report allows us to see the mask before and after resampling. +# Simply hover over the report to see the mask from the original image. + +import numpy as np +masker = NiftiMasker(mask_strategy='epi', target_affine=np.eye(3) * 8) +masker.fit(epi_img) +report = masker.generate_report() +report ############################################################################### # After mask computation: extracting time series @@ -136,7 +160,6 @@ detrended_data = detrended.fit_transform(epi_img) # The timeseries are numpy arrays, so we can manipulate them with numpy -import numpy as np print("Trended: mean %.2f, std %.2f" % (np.mean(trended_data), np.std(trended_data))) diff --git a/examples/04_manipulating_images/plot_nifti_simple.py b/examples/04_manipulating_images/plot_nifti_simple.py index 6f3045f4c9..b24122490c 100644 --- a/examples/04_manipulating_images/plot_nifti_simple.py +++ b/examples/04_manipulating_images/plot_nifti_simple.py @@ -10,7 +10,7 @@ # Retrieve the brain development functional dataset from nilearn import datasets -dataset = datasets.fetch_haxby() +dataset = datasets.fetch_development_fmri(n_subjects=1) func_filename = dataset.func[0] # print basic information on the dataset @@ -30,7 +30,7 @@ mask_img = nifti_masker.mask_img_ ########################################################################### -# Visualize the mask +# Visualize the mask using the plot_roi method from nilearn.plotting import plot_roi from nilearn.image.image import mean_img @@ -39,6 +39,13 @@ plot_roi(mask_img, mean_func_img, display_mode='y', cut_coords=4, title="Mask") +########################################################################### +# Visualize the mask using the 'generate_report' method +# This report can be displayed in a Jupyter Notebook, +# opened in-browser using the .open_in_browser() method, +# or saved to a file using the .save_as_html(output_filepath) method. +report = nifti_masker.generate_report() +report ########################################################################### # Preprocess data with the NiftiMasker @@ -60,6 +67,10 @@ # Visualize results from nilearn.plotting import plot_stat_map, show from nilearn.image import index_img +from nilearn.image.image import mean_img + +# calculate mean image for the background +mean_func_img = mean_img(func_filename) plot_stat_map(index_img(components, 0), mean_func_img, display_mode='y', cut_coords=4, title="Component 0") diff --git a/nilearn/externals/README.md b/nilearn/externals/README.md new file mode 100644 index 0000000000..0e6519bbed --- /dev/null +++ b/nilearn/externals/README.md @@ -0,0 +1,5 @@ +This directory contains bundled external dependencies. + +Note for distribution packagers: if you want to remove the duplicated +code and depend on a packaged version, we suggest that you simply do a +symbolic link in this directory. diff --git a/nilearn/externals/__init__.py b/nilearn/externals/__init__.py new file mode 100644 index 0000000000..c861213890 --- /dev/null +++ b/nilearn/externals/__init__.py @@ -0,0 +1,7 @@ +""" +External, bundled dependencies for Nilearn. + +To ignore linting on these files, at the top define: + +# flake8: noqa +""" diff --git a/nilearn/externals/conftest.py b/nilearn/externals/conftest.py new file mode 100644 index 0000000000..f3bb9d9e9a --- /dev/null +++ b/nilearn/externals/conftest.py @@ -0,0 +1,8 @@ +# Do not collect any tests in externals. This is more robust than using +# --ignore because --ignore needs a path and it is not convenient to pass in +# the externals path (very long install-dependent path in site-packages) when +# using --pyargs + + +def pytest_ignore_collect(path, config): + return True diff --git a/nilearn/externals/install_tempita.sh b/nilearn/externals/install_tempita.sh new file mode 100644 index 0000000000..9e6c1d4425 --- /dev/null +++ b/nilearn/externals/install_tempita.sh @@ -0,0 +1,22 @@ +#!/bin/sh +# Script to do a local install of tempita +set +x +export LC_ALL=C +INSTALL_FOLDER=tmp/tempita_install +rm -rf tempita $INSTALL_FOLDER +if [ -z "$1" ] +then + TEMPITA=tempita +else + TEMPITA=$1 +fi + +pip install --no-cache $TEMPITA --target $INSTALL_FOLDER +cp -r $INSTALL_FOLDER/tempita tempita +rm -rf $INSTALL_FOLDER + +# Needed to rewrite the doctests +# Note: BSD sed -i needs an argument unders OSX +# so first renaming to .bak and then deleting backup files +find tempita -name "*.py" | xargs sed -i.bak "s/from tempita/from nilearn.externals.tempita/" +find tempita -name "*.bak" | xargs rm diff --git a/nilearn/externals/tempita/__init__.py b/nilearn/externals/tempita/__init__.py new file mode 100644 index 0000000000..91f4091672 --- /dev/null +++ b/nilearn/externals/tempita/__init__.py @@ -0,0 +1,1311 @@ +# flake8: noqa +""" +A small templating language + +This implements a small templating language. This language implements +if/elif/else, for/continue/break, expressions, and blocks of Python +code. The syntax is:: + + {{any expression (function calls etc)}} + {{any expression | filter}} + {{for x in y}}...{{endfor}} + {{if x}}x{{elif y}}y{{else}}z{{endif}} + {{py:x=1}} + {{py: + def foo(bar): + return 'baz' + }} + {{default var = default_value}} + {{# comment}} + +You use this with the ``Template`` class or the ``sub`` shortcut. +The ``Template`` class takes the template string and the name of +the template (for errors) and a default namespace. Then (like +``string.Template``) you can call the ``tmpl.substitute(**kw)`` +method to make a substitution (or ``tmpl.substitute(a_dict)``). + +``sub(content, **kw)`` substitutes the template immediately. You +can use ``__name='tmpl.html'`` to set the name of the template. + +If there are syntax errors ``TemplateError`` will be raised. +""" +from __future__ import absolute_import, division, print_function + +import re +import sys +try: + from urllib.parse import quote as url_quote + from io import StringIO + from html import escape as html_escape +except ImportError: + from urllib import quote as url_quote + from cStringIO import StringIO + from cgi import escape as html_escape +import os +import tokenize +from ._looper import looper +from .compat3 import ( + PY3, bytes, basestring_, next, is_unicode, coerce_text, iteritems) + +__all__ = ['TemplateError', 'Template', 'sub', 'HTMLTemplate', + 'sub_html', 'html', 'bunch'] + +in_re = re.compile(r'\s+in\s+') +var_re = re.compile(r'^[a-z_][a-z0-9_]*$', re.I) + + +class TemplateError(Exception): + """Exception raised while parsing a template + """ + + def __init__(self, message, position, name=None): + Exception.__init__(self, message) + self.position = position + self.name = name + + def __str__(self): + msg = ' '.join(self.args) + if self.position: + msg = '%s at line %s column %s' % ( + msg, self.position[0], self.position[1]) + if self.name: + msg += ' in %s' % self.name + return msg + + +class _TemplateContinue(Exception): + pass + + +class _TemplateBreak(Exception): + pass + + +def get_file_template(name, from_template): + path = os.path.join(os.path.dirname(from_template.name), name) + return from_template.__class__.from_filename( + path, namespace=from_template.namespace, + get_template=from_template.get_template) + + +class Template(object): + + default_namespace = { + 'start_braces': '{{', + 'end_braces': '}}', + 'looper': looper, + } + + default_encoding = 'utf8' + default_inherit = None + + def __init__(self, content, name=None, namespace=None, stacklevel=None, + get_template=None, default_inherit=None, line_offset=0, + delimeters=None): + self.content = content + + # set delimeters + if delimeters is None: + delimeters = (self.default_namespace['start_braces'], + self.default_namespace['end_braces']) + else: + assert len(delimeters) == 2 and all( + [isinstance(delimeter, basestring_) + for delimeter in delimeters]) + self.default_namespace = self.__class__.default_namespace.copy() + self.default_namespace['start_braces'] = delimeters[0] + self.default_namespace['end_braces'] = delimeters[1] + self.delimeters = delimeters + + self._unicode = is_unicode(content) + if name is None and stacklevel is not None: + try: + caller = sys._getframe(stacklevel) + except ValueError: + pass + else: + globals = caller.f_globals + lineno = caller.f_lineno + if '__file__' in globals: + name = globals['__file__'] + if name.endswith('.pyc') or name.endswith('.pyo'): + name = name[:-1] + elif '__name__' in globals: + name = globals['__name__'] + else: + name = '' + if lineno: + name += ':%s' % lineno + self.name = name + self._parsed = parse( + content, name=name, line_offset=line_offset, + delimeters=self.delimeters) + if namespace is None: + namespace = {} + self.namespace = namespace + self.get_template = get_template + if default_inherit is not None: + self.default_inherit = default_inherit + + def from_filename(cls, filename, namespace=None, encoding=None, + default_inherit=None, get_template=get_file_template): + f = open(filename, 'rb') + c = f.read() + f.close() + if encoding: + c = c.decode(encoding) + elif PY3: + c = c.decode('latin-1') + return cls(content=c, name=filename, namespace=namespace, + default_inherit=default_inherit, get_template=get_template) + + from_filename = classmethod(from_filename) + + def __repr__(self): + return '<%s %s name=%r>' % ( + self.__class__.__name__, + hex(id(self))[2:], self.name) + + def substitute(self, *args, **kw): + if args: + if kw: + raise TypeError( + "You can only give positional *or* keyword arguments") + if len(args) > 1: + raise TypeError( + "You can only give one positional argument") + if not hasattr(args[0], 'items'): + raise TypeError( + ("If you pass in a single argument, you must pass in a ", + "dict-like object (with a .items() method); you gave %r") + % (args[0],)) + kw = args[0] + ns = kw + ns['__template_name__'] = self.name + if self.namespace: + ns.update(self.namespace) + result, defs, inherit = self._interpret(ns) + if not inherit: + inherit = self.default_inherit + if inherit: + result = self._interpret_inherit(result, defs, inherit, ns) + return result + + def _interpret(self, ns): + # __traceback_hide__ = True + parts = [] + defs = {} + self._interpret_codes(self._parsed, ns, out=parts, defs=defs) + if '__inherit__' in defs: + inherit = defs.pop('__inherit__') + else: + inherit = None + return ''.join(parts), defs, inherit + + def _interpret_inherit(self, body, defs, inherit_template, ns): + # __traceback_hide__ = True + if not self.get_template: + raise TemplateError( + 'You cannot use inheritance without passing in get_template', + position=None, name=self.name) + templ = self.get_template(inherit_template, self) + self_ = TemplateObject(self.name) + for name, value in iteritems(defs): + setattr(self_, name, value) + self_.body = body + ns = ns.copy() + ns['self'] = self_ + return templ.substitute(ns) + + def _interpret_codes(self, codes, ns, out, defs): + # __traceback_hide__ = True + for item in codes: + if isinstance(item, basestring_): + out.append(item) + else: + self._interpret_code(item, ns, out, defs) + + def _interpret_code(self, code, ns, out, defs): + # __traceback_hide__ = True + name, pos = code[0], code[1] + if name == 'py': + self._exec(code[2], ns, pos) + elif name == 'continue': + raise _TemplateContinue() + elif name == 'break': + raise _TemplateBreak() + elif name == 'for': + vars, expr, content = code[2], code[3], code[4] + expr = self._eval(expr, ns, pos) + self._interpret_for(vars, expr, content, ns, out, defs) + elif name == 'cond': + parts = code[2:] + self._interpret_if(parts, ns, out, defs) + elif name == 'expr': + parts = code[2].split('|') + base = self._eval(parts[0], ns, pos) + for part in parts[1:]: + func = self._eval(part, ns, pos) + base = func(base) + out.append(self._repr(base, pos)) + elif name == 'default': + var, expr = code[2], code[3] + if var not in ns: + result = self._eval(expr, ns, pos) + ns[var] = result + elif name == 'inherit': + expr = code[2] + value = self._eval(expr, ns, pos) + defs['__inherit__'] = value + elif name == 'def': + name = code[2] + signature = code[3] + parts = code[4] + ns[name] = defs[name] = TemplateDef( + self, name, signature, body=parts, ns=ns, pos=pos) + elif name == 'comment': + return + else: + assert 0, "Unknown code: %r" % name + + def _interpret_for(self, vars, expr, content, ns, out, defs): + # __traceback_hide__ = True + for item in expr: + if len(vars) == 1: + ns[vars[0]] = item + else: + if len(vars) != len(item): + raise ValueError( + 'Need %i items to unpack (got %i items)' + % (len(vars), len(item))) + for name, value in zip(vars, item): + ns[name] = value + try: + self._interpret_codes(content, ns, out, defs) + except _TemplateContinue: + continue + except _TemplateBreak: + break + + def _interpret_if(self, parts, ns, out, defs): + # __traceback_hide__ = True + # @@: if/else/else gets through + for part in parts: + assert not isinstance(part, basestring_) + name, pos = part[0], part[1] + if name == 'else': + result = True + else: + result = self._eval(part[2], ns, pos) + if result: + self._interpret_codes(part[3], ns, out, defs) + break + + def _eval(self, code, ns, pos): + # __traceback_hide__ = True + try: + try: + value = eval(code, self.default_namespace, ns) + except SyntaxError as e: + raise SyntaxError( + 'invalid syntax in expression: %s' % code) + return value + except: + exc_info = sys.exc_info() + e = exc_info[1] + if getattr(e, 'args', None): + arg0 = e.args[0] + else: + arg0 = coerce_text(e) + e.args = (self._add_line_info(arg0, pos),) + if PY3: + raise(e) + else: + raise (exc_info[1], e, exc_info[2]) + + def _exec(self, code, ns, pos): + # __traceback_hide__ = True + try: + exec(code, self.default_namespace, ns) + except: + exc_info = sys.exc_info() + e = exc_info[1] + if e.args: + e.args = (self._add_line_info(e.args[0], pos),) + else: + e.args = (self._add_line_info(None, pos),) + if PY3: + raise(e) + else: + raise (exc_info[1], e, exc_info[2]) + + def _repr(self, value, pos): + # __traceback_hide__ = True + try: + if value is None: + return '' + if self._unicode: + value = str(value) + if not is_unicode(value): + value = value.decode('utf-8') + else: + if not isinstance(value, basestring_): + value = coerce_text(value) + if (is_unicode(value) and self.default_encoding): + value = value.encode(self.default_encoding) + except: + exc_info = sys.exc_info() + e = exc_info[1] + e.args = (self._add_line_info(e.args[0], pos),) + if PY3: + raise(e) + else: + raise (exc_info[1], e, exc_info[2]) + else: + if self._unicode and isinstance(value, bytes): + if not self.default_encoding: + raise UnicodeDecodeError( + 'Cannot decode bytes value %r into unicode ' + '(no default_encoding provided)' % value) + try: + value = value.decode(self.default_encoding) + except UnicodeDecodeError as e: + raise UnicodeDecodeError( + e.encoding, + e.object, + e.start, + e.end, + e.reason + ' in string %r' % value) + elif not self._unicode and is_unicode(value): + if not self.default_encoding: + raise UnicodeEncodeError( + 'Cannot encode unicode value %r into bytes ' + '(no default_encoding provided)' % value) + value = value.encode(self.default_encoding) + return value + + def _add_line_info(self, msg, pos): + msg = "%s at line %s column %s" % ( + msg, pos[0], pos[1]) + if self.name: + msg += " in file %s" % self.name + return msg + + +def sub(content, delimeters=None, **kw): + name = kw.get('__name') + tmpl = Template(content, name=name, delimeters=delimeters) + return tmpl.substitute(kw) + + +def paste_script_template_renderer(content, vars, filename=None): + tmpl = Template(content, name=filename) + return tmpl.substitute(vars) + + +class bunch(dict): + + def __init__(self, **kw): + for name, value in iteritems(kw): + setattr(self, name, value) + + def __setattr__(self, name, value): + self[name] = value + + def __getattr__(self, name): + try: + return self[name] + except KeyError: + raise AttributeError(name) + + def __getitem__(self, key): + if 'default' in self: + try: + return dict.__getitem__(self, key) + except KeyError: + return dict.__getitem__(self, 'default') + else: + return dict.__getitem__(self, key) + + def __repr__(self): + items = [ + (k, v) for k, v in iteritems(self)] + items.sort() + return '<%s %s>' % ( + self.__class__.__name__, + ' '.join(['%s=%r' % (k, v) for k, v in items])) + +############################################################ +# HTML Templating +############################################################ + + +class html(object): + + def __init__(self, value): + self.value = value + + def __str__(self): + return self.value + + def __html__(self): + return self.value + + def __repr__(self): + return '<%s %r>' % ( + self.__class__.__name__, self.value) + + +def html_quote(value, force=True): + if not force and hasattr(value, '__html__'): + return value.__html__() + if value is None: + return '' + if not isinstance(value, basestring_): + value = coerce_text(value) + if sys.version >= "3" and isinstance(value, bytes): + value = html_escape(value.decode('latin1'), 1) + value = value.encode('latin1') + else: + value = html_escape(value, 1) + if sys.version < "3": + if is_unicode(value): + value = value.encode('ascii', 'xmlcharrefreplace') + return value + + +def url(v): + v = coerce_text(v) + if is_unicode(v): + v = v.encode('utf8') + return url_quote(v) + + +def attr(**kw): + kw = list(iteritems(kw)) + kw.sort() + parts = [] + for name, value in kw: + if value is None: + continue + if name.endswith('_'): + name = name[:-1] + parts.append('%s="%s"' % (html_quote(name), html_quote(value))) + return html(' '.join(parts)) + + +class HTMLTemplate(Template): + + default_namespace = Template.default_namespace.copy() + default_namespace.update(dict( + html=html, + attr=attr, + url=url, + html_quote=html_quote)) + + def _repr(self, value, pos): + if hasattr(value, '__html__'): + value = value.__html__() + quote = False + else: + quote = True + plain = Template._repr(self, value, pos) + if quote: + return html_quote(plain) + else: + return plain + + +def sub_html(content, **kw): + name = kw.get('__name') + tmpl = HTMLTemplate(content, name=name) + return tmpl.substitute(kw) + + +class TemplateDef(object): + def __init__(self, template, func_name, func_signature, + body, ns, pos, bound_self=None): + self._template = template + self._func_name = func_name + self._func_signature = func_signature + self._body = body + self._ns = ns + self._pos = pos + self._bound_self = bound_self + + def __repr__(self): + return '' % ( + self._func_name, self._func_signature, + self._template.name, self._pos) + + def __str__(self): + return self() + + def __call__(self, *args, **kw): + values = self._parse_signature(args, kw) + ns = self._ns.copy() + ns.update(values) + if self._bound_self is not None: + ns['self'] = self._bound_self + out = [] + subdefs = {} + self._template._interpret_codes(self._body, ns, out, subdefs) + return ''.join(out) + + def __get__(self, obj, type=None): + if obj is None: + return self + return self.__class__( + self._template, self._func_name, self._func_signature, + self._body, self._ns, self._pos, bound_self=obj) + + def _parse_signature(self, args, kw): + values = {} + sig_args, var_args, var_kw, defaults = self._func_signature + extra_kw = {} + for name, value in iteritems(kw): + if not var_kw and name not in sig_args: + raise TypeError( + 'Unexpected argument %s' % name) + if name in sig_args: + values[sig_args] = value + else: + extra_kw[name] = value + args = list(args) + sig_args = list(sig_args) + while args: + while sig_args and sig_args[0] in values: + sig_args.pop(0) + if sig_args: + name = sig_args.pop(0) + values[name] = args.pop(0) + elif var_args: + values[var_args] = tuple(args) + break + else: + raise TypeError( + 'Extra position arguments: %s' + % ', '.join(repr(v) for v in args)) + for name, value_expr in iteritems(defaults): + if name not in values: + values[name] = self._template._eval( + value_expr, self._ns, self._pos) + for name in sig_args: + if name not in values: + raise TypeError( + 'Missing argument: %s' % name) + if var_kw: + values[var_kw] = extra_kw + return values + + +class TemplateObject(object): + + def __init__(self, name): + self.__name = name + self.get = TemplateObjectGetter(self) + + def __repr__(self): + return '<%s %s>' % (self.__class__.__name__, self.__name) + + +class TemplateObjectGetter(object): + + def __init__(self, template_obj): + self.__template_obj = template_obj + + def __getattr__(self, attr): + return getattr(self.__template_obj, attr, Empty) + + def __repr__(self): + return '<%s around %r>' % ( + self.__class__.__name__, self.__template_obj) + + +class _Empty(object): + def __call__(self, *args, **kw): + return self + + def __str__(self): + return '' + + def __repr__(self): + return 'Empty' + + def __unicode__(self): + return '' if PY3 else u'' + + def __iter__(self): + return iter(()) + + def __bool__(self): + return False + + if sys.version < "3": + __nonzero__ = __bool__ + +Empty = _Empty() +del _Empty + +############################################################ +# Lexing and Parsing +############################################################ + + +def lex(s, name=None, trim_whitespace=True, line_offset=0, delimeters=None): + if delimeters is None: + delimeters = (Template.default_namespace['start_braces'], + Template.default_namespace['end_braces']) + in_expr = False + chunks = [] + last = 0 + last_pos = (line_offset + 1, 1) + token_re = re.compile(r'%s|%s' % (re.escape(delimeters[0]), + re.escape(delimeters[1]))) + for match in token_re.finditer(s): + expr = match.group(0) + pos = find_position(s, match.end(), last, last_pos) + if expr == delimeters[0] and in_expr: + raise TemplateError('%s inside expression' % delimeters[0], + position=pos, + name=name) + elif expr == delimeters[1] and not in_expr: + raise TemplateError('%s outside expression' % delimeters[1], + position=pos, + name=name) + if expr == delimeters[0]: + part = s[last:match.start()] + if part: + chunks.append(part) + in_expr = True + else: + chunks.append((s[last:match.start()], last_pos)) + in_expr = False + last = match.end() + last_pos = pos + if in_expr: + raise TemplateError('No %s to finish last expression' % delimeters[1], + name=name, position=last_pos) + part = s[last:] + if part: + chunks.append(part) + if trim_whitespace: + chunks = trim_lex(chunks) + return chunks + +lex.__doc__ = """ +Lex a string into chunks: + + >>> lex('hey') + ['hey'] + >>> lex('hey {{you}}') + ['hey ', ('you', (1, 7))] + >>> lex('hey {{') + Traceback (most recent call last): + ... + tempita.TemplateError: No }} to finish last expression at line 1 column 7 + >>> lex('hey }}') + Traceback (most recent call last): + ... + tempita.TemplateError: }} outside expression at line 1 column 7 + >>> lex('hey {{ {{') + Traceback (most recent call last): + ... + tempita.TemplateError: {{ inside expression at line 1 column 10 + +""" if PY3 else """ +Lex a string into chunks: + + >>> lex('hey') + ['hey'] + >>> lex('hey {{you}}') + ['hey ', ('you', (1, 7))] + >>> lex('hey {{') + Traceback (most recent call last): + ... + TemplateError: No }} to finish last expression at line 1 column 7 + >>> lex('hey }}') + Traceback (most recent call last): + ... + TemplateError: }} outside expression at line 1 column 7 + >>> lex('hey {{ {{') + Traceback (most recent call last): + ... + TemplateError: {{ inside expression at line 1 column 10 + +""" + +statement_re = re.compile(r'^(?:if |elif |for |def |inherit |default |py:)') +single_statements = ['else', 'endif', 'endfor', 'enddef', 'continue', 'break'] +trail_whitespace_re = re.compile(r'\n\r?[\t ]*$') +lead_whitespace_re = re.compile(r'^[\t ]*\n') + + +def trim_lex(tokens): + last_trim = None + for i in range(len(tokens)): + current = tokens[i] + if isinstance(tokens[i], basestring_): + # we don't trim this + continue + item = current[0] + if not statement_re.search(item) and item not in single_statements: + continue + if not i: + prev = '' + else: + prev = tokens[i - 1] + if i + 1 >= len(tokens): + next_chunk = '' + else: + next_chunk = tokens[i + 1] + if (not + isinstance(next_chunk, basestring_) or + not isinstance(prev, basestring_)): + continue + prev_ok = not prev or trail_whitespace_re.search(prev) + if i == 1 and not prev.strip(): + prev_ok = True + if last_trim is not None and last_trim + 2 == i and not prev.strip(): + prev_ok = 'last' + if (prev_ok and (not next_chunk or lead_whitespace_re.search( + next_chunk) or ( + i == len(tokens) - 2 and not next_chunk.strip()))): + if prev: + if ((i == 1 and not prev.strip()) or prev_ok == 'last'): + tokens[i - 1] = '' + else: + m = trail_whitespace_re.search(prev) + # +1 to leave the leading \n on: + prev = prev[:m.start() + 1] + tokens[i - 1] = prev + if next_chunk: + last_trim = i + if i == len(tokens) - 2 and not next_chunk.strip(): + tokens[i + 1] = '' + else: + m = lead_whitespace_re.search(next_chunk) + next_chunk = next_chunk[m.end():] + tokens[i + 1] = next_chunk + return tokens + +trim_lex.__doc__ = r""" + Takes a lexed set of tokens, and removes whitespace when there is + a directive on a line by itself: + + >>> tokens = lex('{{if x}}\nx\n{{endif}}\ny', trim_whitespace=False) + >>> tokens + [('if x', (1, 3)), '\nx\n', ('endif', (3, 3)), '\ny'] + >>> trim_lex(tokens) + [('if x', (1, 3)), 'x\n', ('endif', (3, 3)), 'y'] + """ if PY3 else r""" + Takes a lexed set of tokens, and removes whitespace when there is + a directive on a line by itself: + + >>> tokens = lex('{{if x}}\nx\n{{endif}}\ny', trim_whitespace=False) + >>> tokens + [('if x', (1, 3)), '\nx\n', ('endif', (3, 3)), '\ny'] + >>> trim_lex(tokens) + [('if x', (1, 3)), 'x\n', ('endif', (3, 3)), 'y'] + """ + + +def find_position(string, index, last_index, last_pos): + """ + Given a string and index, return (line, column) + """ + lines = string.count('\n', last_index, index) + if lines > 0: + column = index - string.rfind('\n', last_index, index) + else: + column = last_pos[1] + (index - last_index) + return (last_pos[0] + lines, column) + + +def parse(s, name=None, line_offset=0, delimeters=None): + + if delimeters is None: + delimeters = (Template.default_namespace['start_braces'], + Template.default_namespace['end_braces']) + tokens = lex(s, name=name, line_offset=line_offset, delimeters=delimeters) + result = [] + while tokens: + next_chunk, tokens = parse_expr(tokens, name) + result.append(next_chunk) + return result + +parse.__doc__ = r""" + Parses a string into a kind of AST + + >>> parse('{{x}}') + [('expr', (1, 3), 'x')] + >>> parse('foo') + ['foo'] + >>> parse('{{if x}}test{{endif}}') + [('cond', (1, 3), ('if', (1, 3), 'x', ['test']))] + >>> parse( + ... 'series->{{for x in y}}x={{x}}{{endfor}}' + ... ) #doctest: +NORMALIZE_WHITESPACE + ['series->', + ('for', (1, 11), ('x',), 'y', ['x=', ('expr', (1, 27), 'x')])] + >>> parse('{{for x, y in z:}}{{continue}}{{endfor}}') + [('for', (1, 3), ('x', 'y'), 'z', [('continue', (1, 21))])] + >>> parse('{{py:x=1}}') + [('py', (1, 3), 'x=1')] + >>> parse( + ... '{{if x}}a{{elif y}}b{{else}}c{{endif}}' + ... ) #doctest: +NORMALIZE_WHITESPACE + [('cond', (1, 3), ('if', (1, 3), 'x', ['a']), + ('elif', (1, 12), 'y', ['b']), ('else', (1, 23), None, ['c']))] + + Some exceptions:: + + >>> parse('{{continue}}') + Traceback (most recent call last): + ... + tempita.TemplateError: continue outside of for loop at line 1 column 3 + >>> parse('{{if x}}foo') + Traceback (most recent call last): + ... + tempita.TemplateError: No {{endif}} at line 1 column 3 + >>> parse('{{else}}') + Traceback (most recent call last): + ... + tempita.TemplateError: else outside of an if block at line 1 column 3 + >>> parse('{{if x}}{{for x in y}}{{endif}}{{endfor}}') + Traceback (most recent call last): + ... + tempita.TemplateError: Unexpected endif at line 1 column 25 + >>> parse('{{if}}{{endif}}') + Traceback (most recent call last): + ... + tempita.TemplateError: if with no expression at line 1 column 3 + >>> parse('{{for x y}}{{endfor}}') + Traceback (most recent call last): + ... + tempita.TemplateError: Bad for (no "in") in 'x y' at line 1 column 3 + >>> parse('{{py:x=1\ny=2}}') #doctest: +NORMALIZE_WHITESPACE + Traceback (most recent call last): + ... + tempita.TemplateError: Multi-line py blocks must start + with a newline at line 1 column 3 + """ if PY3 else r""" + Parses a string into a kind of AST + + >>> parse('{{x}}') + [('expr', (1, 3), 'x')] + >>> parse('foo') + ['foo'] + >>> parse('{{if x}}test{{endif}}') + [('cond', (1, 3), ('if', (1, 3), 'x', ['test']))] + >>> parse( + ... 'series->{{for x in y}}x={{x}}{{endfor}}' + ... ) #doctest: +NORMALIZE_WHITESPACE + ['series->', + ('for', (1, 11), ('x',), 'y', ['x=', ('expr', (1, 27), 'x')])] + >>> parse('{{for x, y in z:}}{{continue}}{{endfor}}') + [('for', (1, 3), ('x', 'y'), 'z', [('continue', (1, 21))])] + >>> parse('{{py:x=1}}') + [('py', (1, 3), 'x=1')] + >>> parse( + ... '{{if x}}a{{elif y}}b{{else}}c{{endif}}' + ... ) #doctest: +NORMALIZE_WHITESPACE + [('cond', (1, 3), ('if', (1, 3), 'x', ['a']), + ('elif', (1, 12), 'y', ['b']), ('else', (1, 23), None, ['c']))] + + Some exceptions:: + + >>> parse('{{continue}}') + Traceback (most recent call last): + ... + TemplateError: continue outside of for loop at line 1 column 3 + >>> parse('{{if x}}foo') + Traceback (most recent call last): + ... + TemplateError: No {{endif}} at line 1 column 3 + >>> parse('{{else}}') + Traceback (most recent call last): + ... + TemplateError: else outside of an if block at line 1 column 3 + >>> parse('{{if x}}{{for x in y}}{{endif}}{{endfor}}') + Traceback (most recent call last): + ... + TemplateError: Unexpected endif at line 1 column 25 + >>> parse('{{if}}{{endif}}') + Traceback (most recent call last): + ... + TemplateError: if with no expression at line 1 column 3 + >>> parse('{{for x y}}{{endfor}}') + Traceback (most recent call last): + ... + TemplateError: Bad for (no "in") in 'x y' at line 1 column 3 + >>> parse('{{py:x=1\ny=2}}') #doctest: +NORMALIZE_WHITESPACE + Traceback (most recent call last): + ... + TemplateError: Multi-line py blocks must start + with a newline at line 1 column 3 + """ + + +def parse_expr(tokens, name, context=()): + if isinstance(tokens[0], basestring_): + return tokens[0], tokens[1:] + expr, pos = tokens[0] + expr = expr.strip() + if expr.startswith('py:'): + expr = expr[3:].lstrip(' \t') + if expr.startswith('\n') or expr.startswith('\r'): + expr = expr.lstrip('\r\n') + if '\r' in expr: + expr = expr.replace('\r\n', '\n') + expr = expr.replace('\r', '') + expr += '\n' + else: + if '\n' in expr: + raise TemplateError( + 'Multi-line py blocks must start with a newline', + position=pos, name=name) + return ('py', pos, expr), tokens[1:] + elif expr in ('continue', 'break'): + if 'for' not in context: + raise TemplateError( + 'continue outside of for loop', + position=pos, name=name) + return (expr, pos), tokens[1:] + elif expr.startswith('if '): + return parse_cond(tokens, name, context) + elif (expr.startswith('elif ') or expr == 'else'): + raise TemplateError( + '%s outside of an if block' % expr.split()[0], + position=pos, name=name) + elif expr in ('if', 'elif', 'for'): + raise TemplateError( + '%s with no expression' % expr, + position=pos, name=name) + elif expr in ('endif', 'endfor', 'enddef'): + raise TemplateError( + 'Unexpected %s' % expr, + position=pos, name=name) + elif expr.startswith('for '): + return parse_for(tokens, name, context) + elif expr.startswith('default '): + return parse_default(tokens, name, context) + elif expr.startswith('inherit '): + return parse_inherit(tokens, name, context) + elif expr.startswith('def '): + return parse_def(tokens, name, context) + elif expr.startswith('#'): + return ('comment', pos, tokens[0][0]), tokens[1:] + return ('expr', pos, tokens[0][0]), tokens[1:] + + +def parse_cond(tokens, name, context): + start = tokens[0][1] + pieces = [] + context = context + ('if',) + while 1: + if not tokens: + raise TemplateError( + 'Missing {{endif}}', + position=start, name=name) + if (isinstance(tokens[0], tuple) and tokens[0][0] == 'endif'): + return ('cond', start) + tuple(pieces), tokens[1:] + next_chunk, tokens = parse_one_cond(tokens, name, context) + pieces.append(next_chunk) + + +def parse_one_cond(tokens, name, context): + (first, pos), tokens = tokens[0], tokens[1:] + content = [] + if first.endswith(':'): + first = first[:-1] + if first.startswith('if '): + part = ('if', pos, first[3:].lstrip(), content) + elif first.startswith('elif '): + part = ('elif', pos, first[5:].lstrip(), content) + elif first == 'else': + part = ('else', pos, None, content) + else: + assert 0, "Unexpected token %r at %s" % (first, pos) + while 1: + if not tokens: + raise TemplateError( + 'No {{endif}}', + position=pos, name=name) + if (isinstance(tokens[0], tuple) and ( + tokens[0][0] == 'endif' or tokens[0][0].startswith( + 'elif ') or tokens[0][0] == 'else')): + return part, tokens + next_chunk, tokens = parse_expr(tokens, name, context) + content.append(next_chunk) + + +def parse_for(tokens, name, context): + first, pos = tokens[0] + tokens = tokens[1:] + context = ('for',) + context + content = [] + assert first.startswith('for ') + if first.endswith(':'): + first = first[:-1] + first = first[3:].strip() + match = in_re.search(first) + if not match: + raise TemplateError( + 'Bad for (no "in") in %r' % first, + position=pos, name=name) + vars = first[:match.start()] + if '(' in vars: + raise TemplateError( + 'You cannot have () in the variable section of a for loop (%r)' + % vars, position=pos, name=name) + vars = tuple([ + v.strip() for v in first[:match.start()].split(',') + if v.strip()]) + expr = first[match.end():] + while 1: + if not tokens: + raise TemplateError( + 'No {{endfor}}', + position=pos, name=name) + if (isinstance(tokens[0], tuple) and tokens[0][0] == 'endfor'): + return ('for', pos, vars, expr, content), tokens[1:] + next_chunk, tokens = parse_expr(tokens, name, context) + content.append(next_chunk) + + +def parse_default(tokens, name, context): + first, pos = tokens[0] + assert first.startswith('default ') + first = first.split(None, 1)[1] + parts = first.split('=', 1) + if len(parts) == 1: + raise TemplateError( + "Expression must be {{default var=value}}; no = found in %r" % + first, position=pos, name=name) + var = parts[0].strip() + if ',' in var: + raise TemplateError( + "{{default x, y = ...}} is not supported", + position=pos, name=name) + if not var_re.search(var): + raise TemplateError( + "Not a valid variable name for {{default}}: %r" + % var, position=pos, name=name) + expr = parts[1].strip() + return ('default', pos, var, expr), tokens[1:] + + +def parse_inherit(tokens, name, context): + first, pos = tokens[0] + assert first.startswith('inherit ') + expr = first.split(None, 1)[1] + return ('inherit', pos, expr), tokens[1:] + + +def parse_def(tokens, name, context): + first, start = tokens[0] + tokens = tokens[1:] + assert first.startswith('def ') + first = first.split(None, 1)[1] + if first.endswith(':'): + first = first[:-1] + if '(' not in first: + func_name = first + sig = ((), None, None, {}) + elif not first.endswith(')'): + raise TemplateError("Function definition doesn't end with ): %s" % + first, position=start, name=name) + else: + first = first[:-1] + func_name, sig_text = first.split('(', 1) + sig = parse_signature(sig_text, name, start) + context = context + ('def',) + content = [] + while 1: + if not tokens: + raise TemplateError( + 'Missing {{enddef}}', + position=start, name=name) + if (isinstance(tokens[0], tuple) and tokens[0][0] == 'enddef'): + return ('def', start, func_name, sig, content), tokens[1:] + next_chunk, tokens = parse_expr(tokens, name, context) + content.append(next_chunk) + + +def parse_signature(sig_text, name, pos): + tokens = tokenize.generate_tokens(StringIO(sig_text).readline) + sig_args = [] + var_arg = None + var_kw = None + defaults = {} + + def get_token(pos=False): + try: + tok_type, tok_string, (srow, scol), (erow, ecol), line = next( + tokens) + except StopIteration: + return tokenize.ENDMARKER, '' + if pos: + return tok_type, tok_string, (srow, scol), (erow, ecol) + else: + return tok_type, tok_string + while 1: + var_arg_type = None + tok_type, tok_string = get_token() + if tok_type == tokenize.ENDMARKER: + break + if tok_type == tokenize.OP and ( + tok_string == '*' or tok_string == '**'): + var_arg_type = tok_string + tok_type, tok_string = get_token() + if tok_type != tokenize.NAME: + raise TemplateError('Invalid signature: (%s)' % sig_text, + position=pos, name=name) + var_name = tok_string + tok_type, tok_string = get_token() + if tok_type == tokenize.ENDMARKER or ( + tok_type == tokenize.OP and tok_string == ','): + if var_arg_type == '*': + var_arg = var_name + elif var_arg_type == '**': + var_kw = var_name + else: + sig_args.append(var_name) + if tok_type == tokenize.ENDMARKER: + break + continue + if var_arg_type is not None: + raise TemplateError('Invalid signature: (%s)' % sig_text, + position=pos, name=name) + if tok_type == tokenize.OP and tok_string == '=': + nest_type = None + unnest_type = None + nest_count = 0 + start_pos = end_pos = None + parts = [] + while 1: + tok_type, tok_string, s, e = get_token(True) + if start_pos is None: + start_pos = s + end_pos = e + if tok_type == tokenize.ENDMARKER and nest_count: + raise TemplateError('Invalid signature: (%s)' % sig_text, + position=pos, name=name) + if (not nest_count and + (tok_type == tokenize.ENDMARKER or + (tok_type == tokenize.OP and tok_string == ','))): + default_expr = isolate_expression( + sig_text, start_pos, end_pos) + defaults[var_name] = default_expr + sig_args.append(var_name) + break + parts.append((tok_type, tok_string)) + if nest_count \ + and tok_type == tokenize.OP \ + and tok_string == nest_type: + nest_count += 1 + elif nest_count \ + and tok_type == tokenize.OP \ + and tok_string == unnest_type: + nest_count -= 1 + if not nest_count: + nest_type = unnest_type = None + elif not nest_count \ + and tok_type == tokenize.OP \ + and tok_string in ('(', '[', '{'): + nest_type = tok_string + nest_count = 1 + unnest_type = {'(': ')', '[': ']', '{': '}'}[nest_type] + return sig_args, var_arg, var_kw, defaults + + +def isolate_expression(string, start_pos, end_pos): + srow, scol = start_pos + srow -= 1 + erow, ecol = end_pos + erow -= 1 + lines = string.splitlines(True) + if srow == erow: + return lines[srow][scol:ecol] + parts = [lines[srow][scol:]] + parts.extend(lines[srow + 1:erow]) + if erow < len(lines): + # It'll sometimes give (end_row_past_finish, 0) + parts.append(lines[erow][:ecol]) + return ''.join(parts) + +_fill_command_usage = """\ +%prog [OPTIONS] TEMPLATE arg=value + +Use py:arg=value to set a Python value; otherwise all values are +strings. +""" + + +def fill_command(args=None): + import sys + import optparse + import pkg_resources + import os + if args is None: + args = sys.argv[1:] + dist = pkg_resources.get_distribution('Paste') + parser = optparse.OptionParser( + version=coerce_text(dist), + usage=_fill_command_usage) + parser.add_option( + '-o', '--output', + dest='output', + metavar="FILENAME", + help="File to write output to (default stdout)") + parser.add_option( + '--html', + dest='use_html', + action='store_true', + help="Use HTML style filling (including automatic HTML quoting)") + parser.add_option( + '--env', + dest='use_env', + action='store_true', + help="Put the environment in as top-level variables") + options, args = parser.parse_args(args) + if len(args) < 1: + print('You must give a template filename') + sys.exit(2) + template_name = args[0] + args = args[1:] + vars = {} + if options.use_env: + vars.update(os.environ) + for value in args: + if '=' not in value: + print('Bad argument: %r' % value) + sys.exit(2) + name, value = value.split('=', 1) + if name.startswith('py:'): + name = name[:3] + value = eval(value) + vars[name] = value + if template_name == '-': + template_content = sys.stdin.read() + template_name = '' + else: + f = open(template_name, 'rb', encoding="latin-1") + template_content = f.read() + f.close() + if options.use_html: + TemplateClass = HTMLTemplate + else: + TemplateClass = Template + template = TemplateClass(template_content, name=template_name) + result = template.substitute(vars) + if options.output: + f = open(options.output, 'wb') + f.write(result) + f.close() + else: + sys.stdout.write(result) + +if __name__ == '__main__': + fill_command() diff --git a/nilearn/externals/tempita/_looper.py b/nilearn/externals/tempita/_looper.py new file mode 100644 index 0000000000..a2a67800d8 --- /dev/null +++ b/nilearn/externals/tempita/_looper.py @@ -0,0 +1,163 @@ +""" +Helper for looping over sequences, particular in templates. + +Often in a loop in a template it's handy to know what's next up, +previously up, if this is the first or last item in the sequence, etc. +These can be awkward to manage in a normal Python loop, but using the +looper you can get a better sense of the context. Use like:: + + >>> for loop, item in looper(['a', 'b', 'c']): + ... print(loop.number, item) + ... if not loop.last: + ... print('---') + 1 a + --- + 2 b + --- + 3 c + +""" +from __future__ import absolute_import, division, print_function + +import sys +from .compat3 import basestring_ + +__all__ = ['looper'] + + +class looper(object): + """ + Helper for looping (particularly in templates) + + Use this like:: + + for loop, item in looper(seq): + if loop.first: + ... + """ + + def __init__(self, seq): + self.seq = seq + + def __iter__(self): + return looper_iter(self.seq) + + def __repr__(self): + return '<%s for %r>' % ( + self.__class__.__name__, self.seq) + + +class looper_iter(object): + + def __init__(self, seq): + self.seq = list(seq) + self.pos = 0 + + def __iter__(self): + return self + + def __next__(self): + if self.pos >= len(self.seq): + raise StopIteration + result = loop_pos(self.seq, self.pos), self.seq[self.pos] + self.pos += 1 + return result + + if sys.version < "3": + next = __next__ + + +class loop_pos(object): + + def __init__(self, seq, pos): + self.seq = seq + self.pos = pos + + def __repr__(self): + return '' % ( + self.seq[self.pos], self.pos) + + def index(self): + return self.pos + index = property(index) + + def number(self): + return self.pos + 1 + number = property(number) + + def item(self): + return self.seq[self.pos] + item = property(item) + + def __next__(self): + try: + return self.seq[self.pos + 1] + except IndexError: + return None + __next__ = property(__next__) + + if sys.version < "3": + next = __next__ + + def previous(self): + if self.pos == 0: + return None + return self.seq[self.pos - 1] + previous = property(previous) + + def odd(self): + return not self.pos % 2 + odd = property(odd) + + def even(self): + return self.pos % 2 + even = property(even) + + def first(self): + return self.pos == 0 + first = property(first) + + def last(self): + return self.pos == len(self.seq) - 1 + last = property(last) + + def length(self): + return len(self.seq) + length = property(length) + + def first_group(self, getter=None): + """ + Returns true if this item is the start of a new group, + where groups mean that some attribute has changed. The getter + can be None (the item itself changes), an attribute name like + ``'.attr'``, a function, or a dict key or list index. + """ + if self.first: + return True + return self._compare_group(self.item, self.previous, getter) + + def last_group(self, getter=None): + """ + Returns true if this item is the end of a new group, + where groups mean that some attribute has changed. The getter + can be None (the item itself changes), an attribute name like + ``'.attr'``, a function, or a dict key or list index. + """ + if self.last: + return True + return self._compare_group(self.item, self.__next__, getter) + + def _compare_group(self, item, other, getter): + if getter is None: + return item != other + elif (isinstance(getter, basestring_) and getter.startswith('.')): + getter = getter[1:] + if getter.endswith('()'): + getter = getter[:-2] + return getattr(item, getter)() != getattr(other, getter)() + else: + return getattr(item, getter) != getattr(other, getter) + elif hasattr(getter, '__call__'): + return getter(item) != getter(other) + else: + return item[getter] != other[getter] diff --git a/nilearn/externals/tempita/compat3.py b/nilearn/externals/tempita/compat3.py new file mode 100644 index 0000000000..861f5aaf1a --- /dev/null +++ b/nilearn/externals/tempita/compat3.py @@ -0,0 +1,56 @@ +# flake8: noqa +from __future__ import absolute_import, division, print_function + +import sys + +__all__ = ['PY3', 'b', 'basestring_', 'bytes', 'next', 'is_unicode', + 'iteritems'] + +PY3 = True if sys.version_info[0] == 3 else False + +if sys.version_info[0] < 3: + + def next(obj): + return obj.next() + + def iteritems(d, **kw): + return d.iteritems(**kw) + + b = bytes = str + basestring_ = basestring + +else: + + def b(s): + if isinstance(s, str): + return s.encode('latin1') + return bytes(s) + + def iteritems(d, **kw): + return iter(d.items(**kw)) + + next = next + basestring_ = (bytes, str) + bytes = bytes + +text = str + + +def is_unicode(obj): + if sys.version_info[0] < 3: + return isinstance(obj, unicode) + else: + return isinstance(obj, str) + + +def coerce_text(v): + if not isinstance(v, basestring_): + if sys.version_info[0] < 3: + attr = '__unicode__' + else: + attr = '__str__' + if hasattr(v, attr): + return unicode(v) + else: + return bytes(v) + return v diff --git a/nilearn/input_data/nifti_masker.py b/nilearn/input_data/nifti_masker.py index f6016d82ed..cd1a29fa36 100644 --- a/nilearn/input_data/nifti_masker.py +++ b/nilearn/input_data/nifti_masker.py @@ -4,6 +4,7 @@ # Author: Gael Varoquaux, Alexandre Abraham # License: simplified BSD +import warnings from copy import copy as copy_object from nilearn._utils.compat import Memory @@ -12,6 +13,7 @@ from .. import _utils from .. import image from .. import masking +from nilearn.reporting import ReportMixin from .._utils import CacheMixin from .._utils.class_inspect import get_params from .._utils.niimg import img_data_dtype @@ -63,7 +65,7 @@ def filter_and_mask(imgs, mask_img_, parameters, return data -class NiftiMasker(BaseMasker, CacheMixin): +class NiftiMasker(BaseMasker, CacheMixin, ReportMixin): """Applying a mask to extract time-series from Niimg-like objects. NiftiMasker is useful when preprocessing (detrending, standardization, @@ -184,7 +186,7 @@ def __init__(self, mask_img=None, sessions=None, smoothing_fwhm=None, mask_strategy='background', mask_args=None, sample_mask=None, dtype=None, memory_level=1, memory=Memory(cachedir=None), - verbose=0 + verbose=0, reports=True, ): # Mask is provided or computed self.mask_img = mask_img @@ -206,9 +208,78 @@ def __init__(self, mask_img=None, sessions=None, smoothing_fwhm=None, self.memory = memory self.memory_level = memory_level self.verbose = verbose + self.reports = reports + self._report_description = ('This report shows the input Nifti ' + 'image overlaid with the outlines of the ' + 'mask (in green). We recommend to inspect ' + 'the report for the overlap between the ' + 'mask and its input image. ') + self._overlay_text = ('\n To see the input Nifti image before ' + 'resampling, hover over the displayed image.') self._shelving = False + def _reporting(self): + """ + Returns + ------- + displays : list + A list of all displays to be rendered. + """ + try: + from nilearn import plotting + except ImportError: + with warnings.catch_warnings(): + mpl_unavail_msg = ('Matplotlib is not imported! ' + 'No reports will be generated.') + warnings.filterwarnings('always', message=mpl_unavail_msg) + warnings.warn(category=ImportWarning, + message=mpl_unavail_msg) + return [None] + + img = self._reporting_data['images'] + mask = self._reporting_data['mask'] + if img is not None: + dim = image.load_img(img).shape + if len(dim) == 4: + # compute middle image from 4D series for plotting + img = image.index_img(img, dim[-1] // 2) + else: # images were not provided to fit + img = mask + + # create display of retained input mask, image + # for visual comparison + init_display = plotting.plot_img(img, + black_bg=False, + cmap='CMRmap_r') + init_display.add_contours(mask, levels=[.5], colors='g', + linewidths=2.5) + + if 'transform' not in self._reporting_data: + return [init_display] + + else: # if resampling was performed + self._report_description = (self._report_description + + self._overlay_text) + + # create display of resampled NiftiImage and mask + # assuming that resampl_img has same dim as img + resampl_img, resampl_mask = self._reporting_data['transform'] + if resampl_img is not None: + if len(dim) == 4: + # compute middle image from 4D series for plotting + resampl_img = image.index_img(resampl_img, dim[-1] // 2) + else: # images were not provided to fit + resampl_img = resampl_mask + + final_display = plotting.plot_img(resampl_img, + black_bg=False, + cmap='CMRmap_r') + final_display.add_contours(resampl_mask, levels=[.5], + colors='g', linewidths=2.5) + + return [init_display, final_display] + def _check_fitted(self): if not hasattr(self, 'mask_img_'): raise ValueError('It seems that %s has not been fitted. ' @@ -254,6 +325,11 @@ def fit(self, imgs=None, y=None): else: self.mask_img_ = _utils.check_niimg_3d(self.mask_img) + if self.reports: # save inputs for reporting + self._reporting_data = {'images': imgs, 'mask': self.mask_img_} + else: + self._reporting_data = None + # If resampling is requested, resample also the mask # Resampling: allows the user to change the affine, the shape or both if self.verbose > 0: @@ -263,14 +339,25 @@ def fit(self, imgs=None, y=None): target_affine=self.target_affine, target_shape=self.target_shape, copy=False, interpolation='nearest') - if self.target_affine is not None: + if self.target_affine is not None: # resample image to target affine self.affine_ = self.target_affine - else: + else: # resample image to mask affine self.affine_ = self.mask_img_.affine # Load data in memory self.mask_img_.get_data() if self.verbose > 10: print("[%s.fit] Finished fit" % self.__class__.__name__) + + if (self.target_shape is not None) or (self.target_affine is not None): + if self.reports: + if imgs is not None: + resampl_imgs = self._cache(image.resample_img)( + imgs, target_affine=self.affine_, + copy=False, interpolation='nearest') + else: # imgs not provided to fit + resampl_imgs = None + self._reporting_data['transform'] = [resampl_imgs, self.mask_img_] + return self def transform_single_imgs(self, imgs, confounds=None, copy=True): diff --git a/nilearn/plotting/__init__.py b/nilearn/plotting/__init__.py index 629ca5b4e3..a47a92d3e2 100644 --- a/nilearn/plotting/__init__.py +++ b/nilearn/plotting/__init__.py @@ -45,7 +45,6 @@ def _set_mpl_backend(): from .html_stat_map import view_img from .html_connectome import view_connectome, view_markers from .surf_plotting import plot_surf, plot_surf_stat_map, plot_surf_roi -from .js_plotting_utils import set_max_img_views_before_warning __all__ = ['cm', 'plot_img', 'plot_anat', 'plot_epi', 'plot_roi', 'plot_stat_map', 'plot_glass_brain', @@ -54,6 +53,5 @@ def _set_mpl_backend(): 'show', 'plot_matrix', 'view_surf', 'view_img_on_surf', 'view_img', 'view_connectome', 'view_markers', 'find_parcellation_cut_coords', 'find_probabilistic_atlas_cut_coords', - 'plot_surf', 'plot_surf_stat_map', 'plot_surf_roi', - 'set_max_img_views_before_warning' + 'plot_surf', 'plot_surf_stat_map', 'plot_surf_roi' ] diff --git a/nilearn/plotting/displays.py b/nilearn/plotting/displays.py index 9f2119e6b1..2ef2594652 100644 --- a/nilearn/plotting/displays.py +++ b/nilearn/plotting/displays.py @@ -1020,7 +1020,7 @@ def savefig(self, filename, dpi=None): Parameters ---------- filename: string - The file name to save to. It's extension determines the + The file name to save to. Its extension determines the file type, typically '.png', '.svg' or '.pdf'. dpi: None or scalar diff --git a/nilearn/plotting/html_connectome.py b/nilearn/plotting/html_connectome.py index e1d08b1c8d..66ffe6ba2d 100644 --- a/nilearn/plotting/html_connectome.py +++ b/nilearn/plotting/html_connectome.py @@ -7,9 +7,10 @@ from .. import datasets from . import cm -from .js_plotting_utils import (add_js_lib, HTMLDocument, mesh_to_plotly, +from .js_plotting_utils import (add_js_lib, mesh_to_plotly, encode, colorscale, get_html_template, to_color_strings) +from nilearn.reporting import HTMLDocument class ConnectomeView(HTMLDocument): diff --git a/nilearn/plotting/html_stat_map.py b/nilearn/plotting/html_stat_map.py index fb8e762b10..edc0af760e 100644 --- a/nilearn/plotting/html_stat_map.py +++ b/nilearn/plotting/html_stat_map.py @@ -13,10 +13,11 @@ from nibabel.affines import apply_affine from ..image import resample_to_img, new_img_like, reorder_img -from .js_plotting_utils import get_html_template, HTMLDocument, colorscale +from .js_plotting_utils import get_html_template, colorscale from ..plotting import cm from ..plotting.find_cuts import find_xyz_cut_coords from ..plotting.img_plotting import _load_anat +from nilearn.reporting import HTMLDocument from .._utils.niimg_conversions import check_niimg_3d from .._utils.param_validation import check_threshold from .._utils.extmath import fast_abs_percentile diff --git a/nilearn/plotting/html_surface.py b/nilearn/plotting/html_surface.py index 858959935d..536a779551 100644 --- a/nilearn/plotting/html_surface.py +++ b/nilearn/plotting/html_surface.py @@ -7,9 +7,10 @@ from .._utils.niimg_conversions import check_niimg_3d from .. import datasets, surface +from nilearn.reporting import HTMLDocument from . import cm from .js_plotting_utils import ( - HTMLDocument, colorscale, mesh_to_plotly, get_html_template, add_js_lib, + colorscale, mesh_to_plotly, get_html_template, add_js_lib, to_color_strings) diff --git a/nilearn/plotting/js_plotting_utils.py b/nilearn/plotting/js_plotting_utils.py index b78a127f9c..2d398b23a3 100644 --- a/nilearn/plotting/js_plotting_utils.py +++ b/nilearn/plotting/js_plotting_utils.py @@ -5,16 +5,8 @@ import os import base64 -import webbrowser -import tempfile import warnings -import subprocess from string import Template -import weakref -try: - from html import escape # Unavailable in Py2 -except ImportError: # Can be removed once we EOL Py2 support for NiLearn - from cgi import escape # Deprecated in Py3, necessary for Py2 import matplotlib as mpl import numpy as np @@ -27,15 +19,6 @@ MAX_IMG_VIEWS_BEFORE_WARNING = 10 -def set_max_img_views_before_warning(new_value): - """Set the number of open views which triggers a warning. - - If `None` or a negative number, disable the memory warning. - """ - global MAX_IMG_VIEWS_BEFORE_WARNING - MAX_IMG_VIEWS_BEFORE_WARNING = new_value - - def add_js_lib(html, embed_js=True): """ Add javascript libraries to html template. @@ -81,139 +64,6 @@ def get_html_template(template_name): return Template(f.read().decode('utf-8')) -def _remove_after_n_seconds(file_name, n_seconds): - script = os.path.join(os.path.dirname(__file__), 'rm_file.py') - subprocess.Popen(['python', script, file_name, str(n_seconds)]) - - -class HTMLDocument(object): - """ - Embeds a plot in a web page. - - If you are running a Jupyter notebook, the plot will be displayed - inline if this object is the output of a cell. - Otherwise, use open_in_browser() to open it in a web browser (or - save_as_html("filename.html") to save it as an html file). - - use str(document) or document.html to get the content of the web page, - and document.get_iframe() to have it wrapped in an iframe. - - """ - _all_open_html_repr = weakref.WeakSet() - - def __init__(self, html, width=600, height=400): - self.html = html - self.width = width - self.height = height - self._temp_file = None - self._check_n_open() - - def _check_n_open(self): - HTMLDocument._all_open_html_repr.add(self) - if MAX_IMG_VIEWS_BEFORE_WARNING is None: - return - if MAX_IMG_VIEWS_BEFORE_WARNING < 0: - return - if len(HTMLDocument._all_open_html_repr - ) > MAX_IMG_VIEWS_BEFORE_WARNING - 1: - warnings.warn('It seems you have created more than {} ' - 'nilearn views. As each view uses dozens ' - 'of megabytes of RAM, you might want to ' - 'delete some of them.'.format( - MAX_IMG_VIEWS_BEFORE_WARNING)) - - def resize(self, width, height): - """Resize the plot displayed in a Jupyter notebook.""" - self.width, self.height = width, height - return self - - def get_iframe(self, width=None, height=None): - """ - Get the document wrapped in an inline frame. - - For inserting in another HTML page of for display in a Jupyter - notebook. - - """ - if width is None: - width = self.width - if height is None: - height = self.height - escaped = escape(self.html, quote=True) - wrapped = ('').format(escaped, width, height) - return wrapped - - def get_standalone(self): - """ Get the plot in an HTML page.""" - return self.html - - def _repr_html_(self): - """ - Used by the Jupyter notebook. - - Users normally won't call this method explicitely. - """ - return self.get_iframe() - - def __str__(self): - return self.html - - def save_as_html(self, file_name): - """ - Save the plot in an HTML file, that can later be opened in a browser. - """ - with open(file_name, 'wb') as f: - f.write(self.html.encode('utf-8')) - - def open_in_browser(self, file_name=None, temp_file_lifetime=30): - """ - Save the plot to a temporary HTML file and open it in a browser. - - Parameters - ---------- - - file_name : str, optional - .html file to use as temporary file - - temp_file_lifetime : float, optional (default=30.) - Time, in seconds, after which the temporary file is removed. - If None, it is never removed. - - """ - if file_name is None: - fd, file_name = tempfile.mkstemp('.html', 'nilearn_plot_') - os.close(fd) - named_file = False - else: - named_file = True - self.save_as_html(file_name) - self._temp_file = file_name - file_size = os.path.getsize(file_name) / 1e6 - if temp_file_lifetime is None: - if not named_file: - warnings.warn( - ("Saved HTML in temporary file: {}\n" - "file size is {:.1f}M, delete it when you're done, " - "for example by calling this.remove_temp_file").format( - file_name, file_size)) - else: - _remove_after_n_seconds(self._temp_file, temp_file_lifetime) - webbrowser.open('file://{}'.format(file_name)) - - def remove_temp_file(self): - """ - Remove the temporary file created by `open_in_browser`, if necessary. - """ - if self._temp_file is None: - return - if not os.path.isfile(self._temp_file): - return - os.remove(self._temp_file) - print('removed {}'.format(self._temp_file)) - self._temp_file = None - - def colorscale(cmap, values, threshold=None, symmetric_cmap=True, vmax=None, vmin=None): """Normalize a cmap, put it in plotly format, get threshold and range.""" diff --git a/nilearn/plotting/tests/test_html_connectome.py b/nilearn/plotting/tests/test_html_connectome.py index 6a67a67ebb..50378f2173 100644 --- a/nilearn/plotting/tests/test_html_connectome.py +++ b/nilearn/plotting/tests/test_html_connectome.py @@ -65,7 +65,6 @@ def test_view_connectome(): check_html(html, False, 'connectome-plot') - def test_params_deprecation_view_connectome(): deprecated_params = {'coords': 'node_coords', 'threshold': 'edge_threshold', @@ -79,7 +78,7 @@ def test_params_deprecation_view_connectome(): warning_msgs = {old_: deprecation_msg.format(old_, new_) for old_, new_ in deprecated_params.items() } - + adj, coord = _make_connectome() with warnings.catch_warnings(record=True) as raised_warnings: html_connectome.view_connectome(adjacency_matrix=adj, @@ -129,12 +128,12 @@ def test_params_deprecation_view_connectome(): 4.2, ) old_params = ['coords', 'threshold', 'cmap', 'marker_size'] - + assert len(raised_warnings) == 4 for old_param_, raised_warning_ in zip(old_params, raised_warnings): assert warning_msgs[old_param_] == str(raised_warning_.message) assert raised_warning_.category is DeprecationWarning - + def test_get_markers(): coords = np.arange(12).reshape((4, 3)) diff --git a/nilearn/plotting/tests/test_js_plotting_utils.py b/nilearn/plotting/tests/test_js_plotting_utils.py index edca130fba..854b9d07df 100644 --- a/nilearn/plotting/tests/test_js_plotting_utils.py +++ b/nilearn/plotting/tests/test_js_plotting_utils.py @@ -2,27 +2,21 @@ import re import base64 import webbrowser -import time import tempfile import numpy as np import matplotlib -from numpy.testing import assert_warns, assert_no_warnings -try: - from lxml import etree - LXML_INSTALLED = True -except ImportError: - LXML_INSTALLED = False -import pytest from nilearn.plotting import js_plotting_utils from nilearn import surface from nilearn.datasets import fetch_surf_fsaverage - -# Note: html output by nilearn view_* functions -# should validate as html5 using https://validator.w3.org/nu/ with no -# warnings +from numpy.testing import assert_warns +try: + from lxml import etree + LXML_INSTALLED = True +except ImportError: + LXML_INSTALLED = False def _normalize_ws(text): @@ -288,64 +282,6 @@ def _check_open_in_browser(html): pass -def test_temp_file_removing(): - html = js_plotting_utils.HTMLDocument('hello') - wb_open = webbrowser.open - webbrowser.open = _open_mock - fd, tmpfile = tempfile.mkstemp() - try: - os.close(fd) - with pytest.warns(None) as record: - html.open_in_browser(file_name=tmpfile, temp_file_lifetime=None) - for warning in record: - assert "Saved HTML in temporary file" not in str(warning.message) - html.open_in_browser(temp_file_lifetime=.5) - assert os.path.isfile(html._temp_file) - time.sleep(1.5) - assert not os.path.isfile(html._temp_file) - with pytest.warns(UserWarning, match="Saved HTML in temporary file"): - html.open_in_browser(temp_file_lifetime=None) - html.open_in_browser(temp_file_lifetime=None) - assert os.path.isfile(html._temp_file) - time.sleep(1.5) - assert os.path.isfile(html._temp_file) - finally: - webbrowser.open = wb_open - try: - os.remove(html._temp_file) - except Exception: - pass - try: - os.remove(tmpfile) - except Exception: - pass - - -def _open_views(): - return [js_plotting_utils.HTMLDocument('') for i in range(12)] - - -def _open_one_view(): - for i in range(12): - v = js_plotting_utils.HTMLDocument('') - return v - - -def test_open_view_warning(): - # opening many views (without deleting the SurfaceView objects) - # should raise a warning about memory usage - assert_warns(UserWarning, _open_views) - assert_no_warnings(_open_one_view) - js_plotting_utils.set_max_img_views_before_warning(15) - assert_no_warnings(_open_views) - js_plotting_utils.set_max_img_views_before_warning(-1) - assert_no_warnings(_open_views) - js_plotting_utils.set_max_img_views_before_warning(None) - assert_no_warnings(_open_views) - js_plotting_utils.set_max_img_views_before_warning(6) - assert_warns(UserWarning, _open_views) - - def test_to_color_strings(): colors = [[0, 0, 1], [1, 0, 0], [.5, .5, .5]] as_str = js_plotting_utils.to_color_strings(colors) diff --git a/nilearn/reporting/__init__.py b/nilearn/reporting/__init__.py new file mode 100644 index 0000000000..2a97d557b5 --- /dev/null +++ b/nilearn/reporting/__init__.py @@ -0,0 +1,12 @@ +""" +Reporting code for nilearn +""" + +from .html_report import (ReportMixin, HTMLReport) + +from .html_document import (HTMLDocument, set_max_img_views_before_warning) + +from .sphinx_report import (_ReportScraper) + +__all__ = ['ReportMixin', 'HTMLDocument', 'HTMLReport', + 'set_max_img_views_before_warning', '_ReportScraper'] diff --git a/nilearn/reporting/data/README.txt b/nilearn/reporting/data/README.txt new file mode 100644 index 0000000000..9a9a58765f --- /dev/null +++ b/nilearn/reporting/data/README.txt @@ -0,0 +1,4 @@ +This directory contains files required for nilearn reporting. + +html/ : templates for HTML files +css/ : styling sheets diff --git a/nilearn/reporting/data/__init__.py b/nilearn/reporting/data/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/nilearn/reporting/data/html/__init__.py b/nilearn/reporting/data/html/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/nilearn/reporting/data/html/report_body_template.html b/nilearn/reporting/data/html/report_body_template.html new file mode 100644 index 0000000000..f662559f53 --- /dev/null +++ b/nilearn/reporting/data/html/report_body_template.html @@ -0,0 +1,226 @@ + + + + +
+

+ {{title}} + {{docstring}} +

+
+
+
+ image + {{if overlay}} +
+ overlay +
+ {{endif}} +
+
+
+

{{description}}

+

+
+ Parameters + + + + + + + + + + {{py: params = parameters.items()}} + {{for p, v in params}} + + + + + {{endfor}} + +
ParameterValue
{{p}}{{v}}
+ +
+
+
+
diff --git a/nilearn/reporting/data/html/report_head_template.html b/nilearn/reporting/data/html/report_head_template.html new file mode 100644 index 0000000000..4656912025 --- /dev/null +++ b/nilearn/reporting/data/html/report_head_template.html @@ -0,0 +1,18 @@ + + + + +HTML report + + + + + $body + + diff --git a/nilearn/reporting/html_document.py b/nilearn/reporting/html_document.py new file mode 100644 index 0000000000..9398497bb3 --- /dev/null +++ b/nilearn/reporting/html_document.py @@ -0,0 +1,151 @@ +import os +import weakref +import warnings +import tempfile +import webbrowser +import subprocess +from html import escape + +MAX_IMG_VIEWS_BEFORE_WARNING = 10 + + +def set_max_img_views_before_warning(new_value): + """Set the number of open views which triggers a warning. + + If `None` or a negative number, disable the memory warning. + """ + global MAX_IMG_VIEWS_BEFORE_WARNING + MAX_IMG_VIEWS_BEFORE_WARNING = new_value + + +def _remove_after_n_seconds(file_name, n_seconds): + script = os.path.join(os.path.dirname(__file__), 'rm_file.py') + subprocess.Popen(['python', script, file_name, str(n_seconds)]) + + +class HTMLDocument(object): + """ + Embeds a plot in a web page. + + If you are running a Jupyter notebook, the plot will be displayed + inline if this object is the output of a cell. + Otherwise, use open_in_browser() to open it in a web browser (or + save_as_html("filename.html") to save it as an html file). + + use str(document) or document.html to get the content of the web page, + and document.get_iframe() to have it wrapped in an iframe. + + """ + _all_open_html_repr = weakref.WeakSet() + + def __init__(self, html, width=600, height=400): + self.html = html + self.width = width + self.height = height + self._temp_file = None + self._check_n_open() + + def _check_n_open(self): + HTMLDocument._all_open_html_repr.add(self) + if MAX_IMG_VIEWS_BEFORE_WARNING is None: + return + if MAX_IMG_VIEWS_BEFORE_WARNING < 0: + return + if len(HTMLDocument._all_open_html_repr + ) > MAX_IMG_VIEWS_BEFORE_WARNING - 1: + warnings.warn('It seems you have created more than {} ' + 'nilearn views. As each view uses dozens ' + 'of megabytes of RAM, you might want to ' + 'delete some of them.'.format( + MAX_IMG_VIEWS_BEFORE_WARNING)) + + def resize(self, width, height): + """Resize the plot displayed in a Jupyter notebook.""" + self.width, self.height = width, height + return self + + def get_iframe(self, width=None, height=None): + """ + Get the document wrapped in an inline frame. + + For inserting in another HTML page of for display in a Jupyter + notebook. + + """ + if width is None: + width = self.width + if height is None: + height = self.height + escaped = escape(self.html, quote=True) + wrapped = ('').format(escaped, width, height) + return wrapped + + def get_standalone(self): + """ Get the plot in an HTML page.""" + return self.html + + def _repr_html_(self): + """ + Used by the Jupyter notebook. + + Users normally won't call this method explicitely. + """ + return self.get_iframe() + + def __str__(self): + return self.html + + def save_as_html(self, file_name): + """ + Save the plot in an HTML file, that can later be opened in a browser. + """ + with open(file_name, 'wb') as f: + f.write(self.get_standalone().encode('utf-8')) + + def open_in_browser(self, file_name=None, temp_file_lifetime=30): + """ + Save the plot to a temporary HTML file and open it in a browser. + + Parameters + ---------- + + file_name : str, optional + .html file to use as temporary file + + temp_file_lifetime : float, optional (default=30.) + Time, in seconds, after which the temporary file is removed. + If None, it is never removed. + + """ + if file_name is None: + fd, file_name = tempfile.mkstemp('.html', 'nilearn_plot_') + os.close(fd) + named_file = False + else: + named_file = True + self.save_as_html(file_name) + self._temp_file = file_name + file_size = os.path.getsize(file_name) / 1e6 + if temp_file_lifetime is None: + if not named_file: + warnings.warn( + ("Saved HTML in temporary file: {}\n" + "file size is {:.1f}M, delete it when you're done, " + "for example by calling this.remove_temp_file").format( + file_name, file_size)) + else: + _remove_after_n_seconds(self._temp_file, temp_file_lifetime) + webbrowser.open('file://{}'.format(file_name)) + + def remove_temp_file(self): + """ + Remove the temporary file created by `open_in_browser`, if necessary. + """ + if self._temp_file is None: + return + if not os.path.isfile(self._temp_file): + return + os.remove(self._temp_file) + print('removed {}'.format(self._temp_file)) + self._temp_file = None diff --git a/nilearn/reporting/html_report.py b/nilearn/reporting/html_report.py new file mode 100644 index 0000000000..dc2b95b9f0 --- /dev/null +++ b/nilearn/reporting/html_report.py @@ -0,0 +1,199 @@ +import io +import copy +import base64 +import warnings +from pathlib import Path +from string import Template + +from .html_document import HTMLDocument +from nilearn.externals import tempita + + +def _embed_img(display): + """ + Parameters + ---------- + display: obj + A Nilearn plotting object to display + + Returns + ------- + embed : str + Binary image string + """ + if display is None: # no image to display + return None + + else: # we were passed a matplotlib display + io_buffer = io.BytesIO() + display.frame_axes.figure.savefig(io_buffer, format='svg', + facecolor='white', + edgecolor='white') + display.close() + + io_buffer.seek(0) + data = base64.b64encode(io_buffer.read()) + + return '{}'.format(data.decode()) + + +def _str_params(params): + """ + Convert NoneType values to the string 'None' + for display. + + Parameters + ---------- + params: dict + A dictionary of input values to a function + """ + params_str = copy.deepcopy(params) + for k, v in params_str.items(): + if v is None: + params_str[k] = 'None' + return params_str + + +def _update_template(title, docstring, content, overlay, + parameters, description=None): + """ + Populate a report with content. + + Parameters + ---------- + title : str + The title for the report + docstring : str + The introductory docstring for the reported object + content : img + The content to display + overlay : img + Overlaid content, to appear on hover + parameters : dict + A dictionary of object parameters and their values + description : str + An optional description of the content + + Returns + ------- + HTMLReport : an instance of a populated HTML report + """ + resource_path = Path(__file__).resolve().parent.joinpath('data', 'html') + + body_template_name = 'report_body_template.html' + body_template_path = resource_path.joinpath(body_template_name) + tpl = tempita.HTMLTemplate.from_filename(str(body_template_path), + encoding='utf-8') + body = tpl.substitute(title=title, content=content, + overlay=overlay, + docstring=docstring, + parameters=parameters, + description=description) + + head_template_name = 'report_head_template.html' + head_template_path = resource_path.joinpath(head_template_name) + with open(str(head_template_path), 'r') as head_file: + head_tpl = Template(head_file.read()) + + return HTMLReport(body=body, head_tpl=head_tpl) + + +class ReportMixin: + """ + A class to provide general reporting functionality + """ + + def _define_overlay(self): + """ + Determine whether an overlay was provided and + update the report text as appropriate. + + Parameters + ---------- + + Returns + ------- + """ + displays = self._reporting() + + if len(displays) == 1: # set overlay to None + overlay, image = None, displays[0] + + elif len(displays) == 2: + overlay, image = displays[0], displays[1] + + return overlay, image + + def generate_report(self): + """ + Generate a report for Nilearn objects. + + Reports are useful to visualize steps in a processing pipeline. + Example use case: visualize the overlap of a mask and reference image + in NiftiMasker. + + Returns + ------- + report : HTMLReport + """ + if not hasattr(self, '_reporting_data'): + warnings.warn('This object has not been fitted yet ! ' + 'Make sure to run `fit` before inspecting reports.') + report = _update_template(title='Empty Report', + docstring=('This report was not ' + 'generated. Please `fit` the ' + 'object.'), + content=_embed_img(None), + overlay=None, + parameters=dict()) + + elif self._reporting_data is None: + warnings.warn('Report generation not enabled ! ' + 'No visual outputs will be created.') + report = _update_template(title='Empty Report', + docstring=('This report was not ' + 'generated. Please check ' + 'that reporting is enabled.'), + content=_embed_img(None), + overlay=None, + parameters=dict()) + + else: # We can create a report + overlay, image = self._define_overlay() + description = self._report_description + parameters = _str_params(self.get_params()) + docstring = self.__doc__ + snippet = docstring.partition('Parameters\n ----------\n')[0] + report = _update_template(title=self.__class__.__name__, + docstring=snippet, + content=_embed_img(image), + overlay=_embed_img(overlay), + parameters=parameters, + description=description) + return report + + +class HTMLReport(HTMLDocument): + """ + A report written as HTML. + Methods such as save_as_html(), open_in_browser() + are inherited from HTMLDocument + """ + def __init__(self, head_tpl, body): + """ The head_tpl is meant for display as a full page, eg writing on + disk. The body is used for embedding in an existing page. + """ + html = head_tpl.substitute(body=body) + super(HTMLReport, self).__init__(html) + self.head_tpl = head_tpl + self.body = body + + def _repr_html_(self): + """ + Used by the Jupyter notebook. + Users normally won't call this method explicitly. + """ + return self.body + + def __str__(self): + return self.body diff --git a/nilearn/plotting/rm_file.py b/nilearn/reporting/rm_file.py similarity index 100% rename from nilearn/plotting/rm_file.py rename to nilearn/reporting/rm_file.py diff --git a/nilearn/reporting/sphinx_report.py b/nilearn/reporting/sphinx_report.py new file mode 100644 index 0000000000..814ec3336f --- /dev/null +++ b/nilearn/reporting/sphinx_report.py @@ -0,0 +1,48 @@ +# Scraper for sphinx-gallery +# Inspired from https://github.com/mne-tools/mne-python/ +import weakref + +from nilearn.reporting import HTMLDocument + + +_SCRAPER_TEXT = ''' +.. only:: builder_html + + .. container:: row sg-report + + .. raw:: html + + {0} + +''' # noqa: E501 + + +def indent_and_escape(text, amount=12): + "Indent, skip empty lines, and escape string delimiters" + return (''.join(amount * ' ' + line.replace("'", '"') + for line in text.splitlines(True) + if line) + amount * ' ') + + +class _ReportScraper: + """Scrape Reports for Sphinx display. + """ + + def __init__(self): + self.app = None + self.displayed_reports = weakref.WeakSet() + + def __repr__(self): + return '' + + def __call__(self, block, block_vars, gallery_conf): + for report in block_vars['example_globals'].values(): + if (isinstance(report, HTMLDocument) and + gallery_conf['builder_name'] == 'html'): + if report in self.displayed_reports: + continue + report_str = report._repr_html_() + data = _SCRAPER_TEXT.format(indent_and_escape(report_str)) + self.displayed_reports.add(report) + return data + return '' diff --git a/nilearn/reporting/tests/__init__.py b/nilearn/reporting/tests/__init__.py new file mode 100644 index 0000000000..20f6b77ed9 --- /dev/null +++ b/nilearn/reporting/tests/__init__.py @@ -0,0 +1,3 @@ +""" +Testing reporting code in nilearn +""" diff --git a/nilearn/reporting/tests/test_html_document.py b/nilearn/reporting/tests/test_html_document.py new file mode 100644 index 0000000000..87b5817f04 --- /dev/null +++ b/nilearn/reporting/tests/test_html_document.py @@ -0,0 +1,74 @@ +import os +import time +import pytest +import tempfile +import webbrowser +from nilearn import reporting + +from numpy.testing import assert_warns, assert_no_warnings + +# Note: html output by nilearn view_* functions +# should validate as html5 using https://validator.w3.org/nu/ with no +# warnings + + +def _open_mock(f): + print('opened {}'.format(f)) + + +def test_temp_file_removing(): + html = reporting.HTMLDocument('hello') + wb_open = webbrowser.open + webbrowser.open = _open_mock + fd, tmpfile = tempfile.mkstemp() + try: + os.close(fd) + with pytest.warns(None) as record: + html.open_in_browser(file_name=tmpfile, temp_file_lifetime=None) + for warning in record: + assert "Saved HTML in temporary file" not in str(warning.message) + html.open_in_browser(temp_file_lifetime=0.5) + assert os.path.isfile(html._temp_file) + time.sleep(1.5) + assert not os.path.isfile(html._temp_file) + with pytest.warns(UserWarning, match="Saved HTML in temporary file"): + html.open_in_browser(temp_file_lifetime=None) + html.open_in_browser(temp_file_lifetime=None) + assert os.path.isfile(html._temp_file) + time.sleep(1.5) + assert os.path.isfile(html._temp_file) + finally: + webbrowser.open = wb_open + try: + os.remove(html._temp_file) + except Exception: + pass + try: + os.remove(tmpfile) + except Exception: + pass + + +def _open_views(): + return [reporting.HTMLDocument('') for i in range(12)] + + +def _open_one_view(): + for i in range(12): + v = reporting.HTMLDocument('') + return v + + +def test_open_view_warning(): + # opening many views (without deleting the SurfaceView objects) + # should raise a warning about memory usage + assert_warns(UserWarning, _open_views) + assert_no_warnings(_open_one_view) + reporting.set_max_img_views_before_warning(15) + assert_no_warnings(_open_views) + reporting.set_max_img_views_before_warning(-1) + assert_no_warnings(_open_views) + reporting.set_max_img_views_before_warning(None) + assert_no_warnings(_open_views) + reporting.set_max_img_views_before_warning(6) + assert_warns(UserWarning, _open_views) diff --git a/nilearn/reporting/tests/test_html_report.py b/nilearn/reporting/tests/test_html_report.py new file mode 100644 index 0000000000..1c1499fd76 --- /dev/null +++ b/nilearn/reporting/tests/test_html_report.py @@ -0,0 +1,102 @@ +import pytest +import numpy as np +from nibabel import Nifti1Image +from nilearn import input_data +from numpy.testing import assert_warns + + +# Note: html output by nilearn view_* functions +# should validate as html5 using https://validator.w3.org/nu/ with no +# warnings + + +def _check_html(html_view): + """ Check the presence of some expected code in the html viewer + """ + assert "Parameters" in str(html_view) + assert "data:image/svg+xml;base64," in str(html_view) + + +def test_3d_reports(): + # Dummy 3D data + data = np.zeros((9, 9, 9)) + data[3:-3, 3:-3, 3:-3] = 10 + data_img_3d = Nifti1Image(data, np.eye(4)) + + # Dummy mask + mask = np.zeros((9, 9, 9)) + mask[4:-4, 4:-4, 4:-4] = True + mask_img_3d = Nifti1Image(data, np.eye(4)) + + # test .fit method + mask = input_data.NiftiMasker() + mask.fit(data_img_3d) + html = mask.generate_report() + _check_html(html) + + # check providing mask to init + masker = input_data.NiftiMasker(mask_img=mask_img_3d) + masker.fit(data_img_3d) + html = masker.generate_report() + _check_html(html) + + # check providing mask to init and no images to .fit + masker = input_data.NiftiMasker(mask_img=mask_img_3d) + masker.fit() + html = masker.generate_report() + _check_html(html) + + +def test_4d_reports(): + # Dummy 4D data + data = np.zeros((10, 10, 10, 3), dtype=int) + data[..., 0] = 1 + data[..., 1] = 2 + data[..., 2] = 3 + data_img_4d = Nifti1Image(data, np.eye(4)) + + # Dummy mask + mask = np.zeros((10, 10, 10), dtype=int) + mask[3:7, 3:7, 3:7] = 1 + mask_img = Nifti1Image(mask, np.eye(4)) + + # test .fit method + mask = input_data.NiftiMasker(mask_strategy='epi') + mask.fit(data_img_4d) + html = mask.generate_report() + _check_html(html) + + # test .fit_transform method + masker = input_data.NiftiMasker(mask_img=mask_img, standardize=True) + masker.fit_transform(data_img_4d) + html = masker.generate_report() + _check_html(html) + + +def _generate_empty_report(): + data = np.zeros((9, 9, 9)) + data[3:-3, 3:-3, 3:-3] = 10 + data_img_3d = Nifti1Image(data, np.eye(4)) + + # turn off reporting + mask = input_data.NiftiMasker(reports=False) + mask.fit(data_img_3d) + mask.generate_report() + + +def test_empty_report(): + assert_warns(UserWarning, _generate_empty_report) + + +def test_overlaid_report(): + pytest.importorskip('matplotlib') + + # Dummy 3D data + data = np.zeros((9, 9, 9)) + data[3:-3, 3:-3, 3:-3] = 10 + data_img_3d = Nifti1Image(data, np.eye(4)) + + mask = input_data.NiftiMasker(target_affine=np.eye(3) * 8) + mask.fit(data_img_3d) + html = mask.generate_report() + assert '
' in str(html) diff --git a/nilearn/reporting/tests/test_sphinx_report.py b/nilearn/reporting/tests/test_sphinx_report.py new file mode 100644 index 0000000000..7a44306777 --- /dev/null +++ b/nilearn/reporting/tests/test_sphinx_report.py @@ -0,0 +1,45 @@ +import numpy as np +import os.path as op +from nibabel import Nifti1Image +from sklearn.utils import Bunch +from nilearn.input_data import NiftiMasker +from nilearn.reporting import _ReportScraper + + +def _gen_report(): + """ Generate an empty HTMLReport for testing """ + + data = np.zeros((9, 9, 9)) + data[3:-3, 3:-3, 3:-3] = 10 + data_img_3d = Nifti1Image(data, np.eye(4)) + + # turn off reporting + mask = NiftiMasker() + mask.fit(data_img_3d) + report = mask.generate_report() + return report + + +def test_scraper(tmpdir): + """Test report scraping.""" + # Mock a Sphinx + sphinx_gallery config + app = Bunch(builder=Bunch(srcdir=str(tmpdir), + outdir=op.join(str(tmpdir), '_build', 'html'))) + scraper = _ReportScraper() + scraper.app = app + gallery_conf = dict(src_dir=app.builder.srcdir, builder_name='html') + img_fname = op.join(app.builder.srcdir, 'auto_examples', 'images', + 'sg_img.png') + target_file = op.join(app.builder.srcdir, 'auto_examples', 'sg.py') + block_vars = dict(image_path_iterator=(img for img in [img_fname]), + example_globals=dict(a=1), target_file=target_file) + # Confirm that HTML isn't accidentally inserted + block = None + rst = scraper(block, block_vars, gallery_conf) + assert rst == '' + + # Confirm that HTML is correctly inserted for HTMLReport + report = _gen_report() + block_vars['example_globals']['report'] = report + rst = scraper(block, block_vars, gallery_conf) + assert "