diff --git a/doc/changes/latest.inc b/doc/changes/latest.inc index e9d410202d5..006a6a00d0d 100644 --- a/doc/changes/latest.inc +++ b/doc/changes/latest.inc @@ -87,6 +87,8 @@ Enhancements - Add timeformat ``YYYY/MM/DD`` to ``mne.io.ctf.info._convert_time`` (:gh:`10674` by `Simon Kern`_) +- :meth:`mne.Report.add_figure` and :meth:`mne.Report.add_image` gained a new parameter, ``section``, allowing to put multiple figures or images into the same "content block". Previously, the only way to group figures and images was by means of a slider, which sometimes made content hard to discover. The slider remains available as an alternative. (:gh:`10694` by `Richard Höchenberger`_) + Bugs ~~~~ diff --git a/mne/html_templates/report/bem.html.jinja b/mne/html_templates/report/bem.html.jinja new file mode 100644 index 00000000000..a04a4c08d8f --- /dev/null +++ b/mne/html_templates/report/bem.html.jinja @@ -0,0 +1,8 @@ +{% extends "section.html.jinja" %} +{% block html_content %} +
+ {{ html_slider_axial | safe }} + {{ html_slider_sagittal | safe }} + {{ html_slider_coronal | safe }} +
+{% endblock html_content %} diff --git a/mne/html_templates/report/code.html.jinja b/mne/html_templates/report/code.html.jinja index 4550d8515e3..63783e4594e 100644 --- a/mne/html_templates/report/code.html.jinja +++ b/mne/html_templates/report/code.html.jinja @@ -1,24 +1,6 @@ -
-
- -
- -
-
-
-        {{ code }}
-      
-
-
-
+{% extends "section.html.jinja" %} +{% block html_content %} +
+  {{ code }}
+
+{% endblock html_content %} diff --git a/mne/html_templates/report/epochs.html.jinja b/mne/html_templates/report/epochs.html.jinja deleted file mode 100644 index 19c640c87f2..00000000000 --- a/mne/html_templates/report/epochs.html.jinja +++ /dev/null @@ -1,27 +0,0 @@ -
-
- -
- -
-
- {{ repr | safe }} - {{ drop_log | safe }} - {{ metadata | safe }} - {{ erp_imgs | safe }} - {{ psd | safe }} - {{ ssp_projs | safe }} -
-
-
diff --git a/mne/html_templates/report/evoked.html.jinja b/mne/html_templates/report/evoked.html.jinja deleted file mode 100644 index bd5da6c4563..00000000000 --- a/mne/html_templates/report/evoked.html.jinja +++ /dev/null @@ -1,26 +0,0 @@ -
-
- -
- -
-
- {{joint | safe}} - {{slider | safe}} - {{gfp | safe}} - {{whitened | safe}} - {{ssp_projs | safe}} -
-
-
diff --git a/mne/html_templates/report/forward.html.jinja b/mne/html_templates/report/forward.html.jinja index 687c4eb855a..1a46f4ccd81 100644 --- a/mne/html_templates/report/forward.html.jinja +++ b/mne/html_templates/report/forward.html.jinja @@ -1,23 +1,5 @@ -
-
- -
- -
-
- {{repr | safe}} - {{sensitivity_maps | safe }} -
-
-
+{% extends "section.html.jinja" %} +{% block html_content %} +{{repr | safe}} +{{sensitivity_maps | safe}} +{% endblock html_content %} diff --git a/mne/html_templates/report/ica.html.jinja b/mne/html_templates/report/ica.html.jinja deleted file mode 100644 index 988552fff14..00000000000 --- a/mne/html_templates/report/ica.html.jinja +++ /dev/null @@ -1,29 +0,0 @@ -
-
- -
- -
-
- {{repr | safe}} - {{overlay | safe}} - {{ecg_scores | safe}} - {{ecg | safe}} - {{eog_scores | safe}} - {{eog | safe}} - {{topographies | safe}} - {{properties | safe}} -
-
-
diff --git a/mne/html_templates/report/image.html.jinja b/mne/html_templates/report/image.html.jinja index be8fe0b5f8e..6f80960ccc3 100644 --- a/mne/html_templates/report/image.html.jinja +++ b/mne/html_templates/report/image.html.jinja @@ -1,36 +1,17 @@ -
-
- +{% extends "section.html.jinja" %} +{% block html_content %} +
+ {% if image_format == 'svg' %} +
+ {{ img }}
+ {% else %} + {{ title }} + {% endif %} -
-
-
- {% if image_format == 'svg' %} -
- {{ img }} -
- {% else %} - {{ title }} - {% endif %} - - {% if caption is not none %} -
{{ caption }}
- {% endif %} -
-
-
-
+ {% if caption is not none %} +
{{ caption }}
+ {% endif %} + +{% endblock html_content %} diff --git a/mne/html_templates/report/inverse.html.jinja b/mne/html_templates/report/inverse.html.jinja index 12f6ba5fcc1..ca03f3c29af 100644 --- a/mne/html_templates/report/inverse.html.jinja +++ b/mne/html_templates/report/inverse.html.jinja @@ -1,23 +1,5 @@ -
-
- -
- -
-
- {{repr | safe}} - {{source_space | safe}} -
-
-
+{% extends "section.html.jinja" %} +{% block html_content %} +{{repr | safe}} +{{source_space | safe}} +{% endblock html_content %} diff --git a/mne/html_templates/report/raw.html.jinja b/mne/html_templates/report/raw.html.jinja deleted file mode 100644 index 719069b6b21..00000000000 --- a/mne/html_templates/report/raw.html.jinja +++ /dev/null @@ -1,25 +0,0 @@ -
-
- -
- -
-
- {{ repr| safe }} - {{ psd | safe }} - {{ butterfly | safe }} - {{ ssp_projs | safe }} -
-
-
diff --git a/mne/html_templates/report/cov.html.jinja b/mne/html_templates/report/section.html.jinja similarity index 63% rename from mne/html_templates/report/cov.html.jinja rename to mne/html_templates/report/section.html.jinja index 02d36617f34..b7daaa2db6d 100644 --- a/mne/html_templates/report/cov.html.jinja +++ b/mne/html_templates/report/section.html.jinja @@ -1,7 +1,7 @@ -
-
+
+
+
- {{ matrix | safe }} - {{ svd | safe }} + {% block html_content %} + {% for html in htmls %} + {{ html | safe }} + {% endfor %} + {% endblock %}
diff --git a/mne/html_templates/report/toc.html.jinja b/mne/html_templates/report/toc.html.jinja index 1396a97595e..0b714632ae5 100644 --- a/mne/html_templates/report/toc.html.jinja +++ b/mne/html_templates/report/toc.html.jinja @@ -3,9 +3,9 @@
Table of contents
diff --git a/mne/report/report.py b/mne/report/report.py index 3a18371d17e..80525875271 100644 --- a/mne/report/report.py +++ b/mne/report/report.py @@ -9,7 +9,7 @@ import io import dataclasses from dataclasses import dataclass -from typing import Tuple +from typing import Tuple, Optional from collections.abc import Sequence import base64 from io import BytesIO, StringIO @@ -130,51 +130,10 @@ def _html_footer_element(*, mne_version, date): return t_rendered -def _html_toc_element(*, content_elements): +def _html_toc_element(*, titles, dom_ids, tags): from ..html_templates import report_templates_env t = report_templates_env.get_template('toc.html.jinja') - t_rendered = t.render(content_elements=content_elements) - return t_rendered - - -def _html_raw_element(*, id, repr, psd, butterfly, ssp_projs, title, tags): - from ..html_templates import report_templates_env - t = report_templates_env.get_template('raw.html.jinja') - t_rendered = t.render( - id=id, repr=repr, psd=psd, butterfly=butterfly, ssp_projs=ssp_projs, - tags=tags, title=title - ) - return t_rendered - - -def _html_epochs_element(*, id, repr, metadata, erp_imgs, drop_log, psd, - ssp_projs, title, tags): - from ..html_templates import report_templates_env - t = report_templates_env.get_template('epochs.html.jinja') - t_rendered = t.render( - id=id, repr=repr, metadata=metadata, erp_imgs=erp_imgs, - drop_log=drop_log, psd=psd, ssp_projs=ssp_projs, tags=tags, title=title - ) - return t_rendered - - -def _html_evoked_element(*, id, joint, slider, gfp, whitened, ssp_projs, title, - tags): - from ..html_templates import report_templates_env - t = report_templates_env.get_template('evoked.html.jinja') - t_rendered = t.render( - id=id, joint=joint, slider=slider, gfp=gfp, whitened=whitened, - ssp_projs=ssp_projs, tags=tags, title=title - ) - return t_rendered - - -def _html_cov_element(*, id, matrix, svd, title, tags): - from ..html_templates import report_templates_env - t = report_templates_env.get_template('cov.html.jinja') - t_rendered = t.render( - id=id, matrix=matrix, svd=svd, tags=tags, title=title - ) + t_rendered = t.render(titles=titles, dom_ids=dom_ids, tags=tags) return t_rendered @@ -197,18 +156,6 @@ def _html_inverse_operator_element(*, id, repr, source_space, title, tags): return t_rendered -def _html_ica_element(*, id, repr, overlay, ecg, eog, ecg_scores, eog_scores, - properties, topographies, title, tags): - from ..html_templates import report_templates_env - t = report_templates_env.get_template('ica.html.jinja') - t_rendered = t.render( - id=id, repr=repr, overlay=overlay, ecg=ecg, eog=eog, - ecg_scores=ecg_scores, eog_scores=eog_scores, properties=properties, - topographies=topographies, tags=tags, title=title - ) - return t_rendered - - def _html_slider_element(*, id, images, captions, start_idx, image_format, title, tags, klass=''): from ..html_templates import report_templates_env @@ -248,6 +195,30 @@ def _html_code_element(*, id, code, language, title, tags): return t_rendered +def _html_section_element(*, id, div_klass, htmls, title, tags): + from ..html_templates import report_templates_env + t = report_templates_env.get_template('section.html.jinja') + t_rendered = t.render( + id=id, div_klass=div_klass, htmls=htmls, title=title, tags=tags + ) + return t_rendered + + +def _html_bem_element( + *, id, div_klass, html_slider_axial, html_slider_sagittal, + html_slider_coronal, title, tags +): + from ..html_templates import report_templates_env + t = report_templates_env.get_template('bem.html.jinja') + t_rendered = t.render( + id=id, div_klass=div_klass, html_slider_axial=html_slider_axial, + html_slider_sagittal=html_slider_sagittal, + html_slider_coronal=html_slider_coronal, title=title, + tags=tags + ) + return t_rendered + + def _html_element(*, id, div_klass, html, title, tags): from ..html_templates import report_templates_env t = report_templates_env.get_template('html.html.jinja') @@ -260,6 +231,7 @@ def _html_element(*, id, div_klass, html, title, tags): @dataclass class _ContentElement: name: str + section: Optional[str] dom_id: str tags: Tuple[str] html: str @@ -796,10 +768,19 @@ def _get_state_params(): 'fname' ) - def _get_dom_id(self): - """Get id of plot.""" - self._dom_id += 1 - return f'global{self._dom_id}' + def _get_dom_id(self, increment=True): + """Get unique ID for content to append to the DOM. + + This method is just a counter. + + increment : bool + Whether to increment the counter. If ``False``, simply returns the + latest DOM ID used. + """ + if increment: + self._dom_id += 1 + + return f'global-{self._dom_id}' def _validate_topomap_kwargs(self, topomap_kwargs): _validate_type(topomap_kwargs, (dict, None), 'topomap_kwargs') @@ -827,9 +808,86 @@ def _validate_input(self, items, captions, tag, comments=None): ) return items, captions, comments + def _content_as_html(self): + """Generate HTML representations based on the added content & sections. + + Returns + ------- + htmls : list of str + The HTML representations of the content. + dom_ids : list of str + The DOM IDs corresponding to the HTML representations. + titles : list of str + The titles corresponding to the HTML representations. + tags : list of tuple of str + The tags corresponding to the HTML representations. + """ + section_dom_ids = [] + htmls = [] + dom_ids = [] + titles = [] + tags = [] + + content_elements = self._content.copy() # shallow copy + + # We loop over all content elements and implement special treatment + # for those that are part of a section: Those sections don't actually + # exist in `self._content` – we're creating them on-the-fly here! + for idx, content_element in enumerate(content_elements): + if content_element.section: + if content_element.section in titles: + # The section and all its child elements have already been + # added + continue + + # Add all elements belonging to the current section + section_elements = [ + el for el in content_elements[idx:] + if el.section == content_element.section + ] + section_htmls = [el.html for el in section_elements] + section_tags = tuple( + sorted((set([t + for el in section_elements + for t in el.tags]))) + ) + # Generate a unique DOM ID, but don't alter the global counter + if section_dom_ids: + section_dom_id = section_dom_ids[-1] + else: + section_dom_id = self._get_dom_id(increment=False) + + label, counter = section_dom_id.split('-') + section_dom_id = f'{label}-{int(counter) + 1}' + section_dom_ids.append(section_dom_id) + + # Finally, create the section HTML element. + section_html = _html_section_element( + id=section_dom_id, + htmls=section_htmls, + tags=section_tags, + title=content_element.section, + div_klass='section', + ) + htmls.append(section_html) + dom_ids.append(section_dom_id) + titles.append(content_element.section) + tags.append(section_tags) + else: + # The element is not part of a section, so we can simply + # append it as-is. + htmls.append(content_element.html) + dom_ids.append(content_element.dom_id) + titles.append(content_element.name) + tags.append(content_element.tags) + + return htmls, dom_ids, titles, tags + @property def html(self): - return [element.html for element in self._content] + """A list of HTML representations for all content elements.""" + htmls, _, _, _ = self._content_as_html() + return htmls @property def tags(self): @@ -914,45 +972,25 @@ def add_epochs( .. versionadded:: 0.24.0 """ tags = _check_tags(tags) - add_projs = self.projs if projs is None else projs - - htmls = self._render_epochs( + self._add_epochs( epochs=epochs, psd=psd, add_projs=add_projs, topomap_kwargs=topomap_kwargs, drop_log_ignore=drop_log_ignore, + section=title, tags=tags, image_format=self.image_format, - ) - (repr_html, metadata_html, erp_imgs_html, drop_log_html, psd_html, - ssp_projs_html) = htmls - - dom_id = self._get_dom_id() - html = _html_epochs_element( - repr=repr_html, - metadata=metadata_html, - erp_imgs=erp_imgs_html, - drop_log=drop_log_html, - psd=psd_html, - ssp_projs=ssp_projs_html, - tags=tags, - title=title, - id=dom_id, - ) - self._add_or_replace( - name=title, - dom_id=dom_id, - tags=tags, - html=html, - replace=replace + replace=replace, ) @fill_doc - def add_evokeds(self, evokeds, *, titles=None, noise_cov=None, projs=None, - n_time_points=None, tags=('evoked',), replace=False, - topomap_kwargs=None, n_jobs=None): + def add_evokeds( + self, evokeds, *, titles=None, noise_cov=None, projs=None, + n_time_points=None, tags=('evoked',), replace=False, + topomap_kwargs=None, n_jobs=None + ): """Add `~mne.Evoked` objects to the report. Parameters @@ -1016,37 +1054,17 @@ def add_evokeds(self, evokeds, *, titles=None, noise_cov=None, projs=None, add_projs = self.projs if projs is None else projs for evoked, title in zip(evokeds, titles): - evoked_htmls = self._render_evoked( + self._add_evoked( evoked=evoked, noise_cov=noise_cov, image_format=self.image_format, add_projs=add_projs, n_time_points=n_time_points, tags=tags, + section=title, topomap_kwargs=topomap_kwargs, - n_jobs=n_jobs - ) - - (joint_html, slider_html, gfp_html, whitened_html, - ssp_projs_html) = evoked_htmls - - dom_id = self._get_dom_id() - html = _html_evoked_element( - id=dom_id, - joint=joint_html, - slider=slider_html, - gfp=gfp_html, - whitened=whitened_html, - ssp_projs=ssp_projs_html, - title=title, - tags=tags - ) - self._add_or_replace( - dom_id=dom_id, - name=title, - tags=tags, - html=html, - replace=replace + n_jobs=n_jobs, + replace=replace, ) @fill_doc @@ -1094,7 +1112,7 @@ def add_raw( add_projs = self.projs if projs is None else projs - htmls = self._render_raw( + self._add_raw( raw=raw, add_psd=add_psd, add_projs=add_projs, @@ -1103,30 +1121,16 @@ def add_raw( image_format=self.image_format, tags=tags, topomap_kwargs=topomap_kwargs, - ) - repr_html, psd_img_html, butterfly_imgs_html, ssp_proj_img_html = htmls - dom_id = self._get_dom_id() - html = _html_raw_element( - repr=repr_html, - psd=psd_img_html, - butterfly=butterfly_imgs_html, - ssp_projs=ssp_proj_img_html, - tags=tags, - title=title, - id=dom_id, - ) - self._add_or_replace( - dom_id=dom_id, - name=title, - tags=tags, - html=html, - replace=replace + section=title, + replace=replace, ) @fill_doc - def add_stc(self, stc, title, *, subject=None, subjects_dir=None, - n_time_points=None, tags=('source-estimate',), replace=False, - stc_plot_kwargs=None): + def add_stc( + self, stc, title, *, subject=None, subjects_dir=None, + n_time_points=None, tags=('source-estimate',), + replace=False, stc_plot_kwargs=None + ): """Add a `~mne.SourceEstimate` (STC) to the report. Parameters @@ -1155,8 +1159,7 @@ def add_stc(self, stc, title, *, subject=None, subjects_dir=None, .. versionadded:: 0.24.0 """ tags = _check_tags(tags) - - html, dom_id = self._render_stc( + self._add_stc( stc=stc, title=title, tags=tags, @@ -1164,19 +1167,16 @@ def add_stc(self, stc, title, *, subject=None, subjects_dir=None, subject=subject, subjects_dir=subjects_dir, n_time_points=n_time_points, - stc_plot_kwargs=stc_plot_kwargs - ) - self._add_or_replace( - dom_id=dom_id, - name=title, - tags=tags, - html=html, - replace=replace + stc_plot_kwargs=stc_plot_kwargs, + section=None, + replace=replace, ) @fill_doc - def add_forward(self, forward, title, *, subject=None, subjects_dir=None, - tags=('forward-solution',), replace=False): + def add_forward( + self, forward, title, *, subject=None, subjects_dir=None, + tags=('forward-solution',), replace=False + ): """Add a forward solution. Parameters @@ -1201,22 +1201,18 @@ def add_forward(self, forward, title, *, subject=None, subjects_dir=None, """ tags = _check_tags(tags) - html, dom_id = self._render_forward( + self._add_forward( forward=forward, subject=subject, subjects_dir=subjects_dir, - title=title, image_format=self.image_format, tags=tags - ) - self._add_or_replace( - dom_id=dom_id, - name=title, - tags=tags, - html=html, - replace=replace + title=title, image_format=self.image_format, section=None, + tags=tags, replace=replace, ) @fill_doc - def add_inverse_operator(self, inverse_operator, title, *, subject=None, - subjects_dir=None, trans=None, - tags=('inverse-operator',), replace=False): + def add_inverse_operator( + self, inverse_operator, title, *, subject=None, + subjects_dir=None, trans=None, tags=('inverse-operator',), + replace=False + ): """Add an inverse operator. Parameters @@ -1248,22 +1244,18 @@ def add_inverse_operator(self, inverse_operator, title, *, subject=None, (trans is not None and subject is None)): raise ValueError('Please pass subject AND trans, or neither.') - html, dom_id = self._render_inverse_operator( + self._add_inverse_operator( inverse_operator=inverse_operator, subject=subject, subjects_dir=subjects_dir, trans=trans, title=title, - image_format=self.image_format, tags=tags - ) - self._add_or_replace( - dom_id=dom_id, - name=title, - tags=tags, - html=html, - replace=replace + image_format=self.image_format, section=None, tags=tags, + replace=replace, ) @fill_doc - def add_trans(self, trans, *, info, title, subject=None, subjects_dir=None, - alpha=None, tags=('coregistration',), replace=False): + def add_trans( + self, trans, *, info, title, subject=None, subjects_dir=None, + alpha=None, tags=('coregistration',), replace=False + ): """Add a coregistration visualization to the report. Parameters @@ -1293,27 +1285,22 @@ def add_trans(self, trans, *, info, title, subject=None, subjects_dir=None, .. versionadded:: 0.24.0 """ tags = _check_tags(tags) - - html, dom_id = self._render_trans( + self._add_trans( trans=trans, info=info, subject=subject, subjects_dir=subjects_dir, alpha=alpha, title=title, - tags=tags - ) - self._add_or_replace( - dom_id=dom_id, - name=title, + section=None, tags=tags, - html=html, - replace=replace + replace=replace, ) @fill_doc - def add_covariance(self, cov, *, info, title, tags=('covariance',), - replace=False): + def add_covariance( + self, cov, *, info, title, tags=('covariance',), replace=False + ): """Add covariance to the report. Parameters @@ -1332,33 +1319,20 @@ def add_covariance(self, cov, *, info, title, tags=('covariance',), .. versionadded:: 0.24.0 """ tags = _check_tags(tags) - htmls = self._render_cov( + self._add_cov( cov=cov, info=info, image_format=self.image_format, - tags=tags - ) - cov_matrix_html, cov_svd_html = htmls - - dom_id = self._get_dom_id() - html = _html_cov_element( - matrix=cov_matrix_html, - svd=cov_svd_html, - tags=tags, - title=title, - id=dom_id - ) - self._add_or_replace( - dom_id=dom_id, - name=title, + section=title, tags=tags, - html=html, - replace=replace + replace=replace, ) @fill_doc - def add_events(self, events, title, *, event_id=None, sfreq, first_samp=0, - tags=('events',), replace=False): + def add_events( + self, events, title, *, event_id=None, sfreq, first_samp=0, + tags=('events',), replace=False + ): """Add events to the report. Parameters @@ -1382,21 +1356,16 @@ def add_events(self, events, title, *, event_id=None, sfreq, first_samp=0, .. versionadded:: 0.24.0 """ tags = _check_tags(tags) - html, dom_id = self._render_events( + self._add_events( events=events, event_id=event_id, sfreq=sfreq, first_samp=first_samp, title=title, + section=None, image_format=self.image_format, - tags=tags - ) - self._add_or_replace( - dom_id=dom_id, - name=title, tags=tags, - html=html, - replace=replace + replace=replace, ) @fill_doc @@ -1424,28 +1393,15 @@ def add_projs(self, *, info, projs=None, title, topomap_kwargs=None, .. versionadded:: 0.24.0 """ tags = _check_tags(tags) - output = self._render_ssp_projs( + self._add_projs( info=info, projs=projs, title=title, - image_format=self.image_format, tags=tags, - topomap_kwargs=topomap_kwargs, - ) - if output is None: - raise ValueError( - 'The provided data does not contain digitization information. ' - 'However, this is required for rendering the SSP projectors.' - ) - else: - html, dom_id = output - - self._add_or_replace( - dom_id=dom_id, - name=title, - tags=tags, - html=html, - replace=replace + image_format=self.image_format, section=None, tags=tags, + topomap_kwargs=topomap_kwargs, replace=replace ) - def _render_ica_overlay(self, *, ica, inst, image_format, tags): + def _add_ica_overlay( + self, *, ica, inst, image_format, section, tags, replace + ): if isinstance(inst, BaseRaw): inst_ = inst else: # Epochs @@ -1457,24 +1413,26 @@ def _render_ica_overlay(self, *, ica, inst, image_format, tags): _constrain_fig_resolution( fig, max_width=MAX_IMG_WIDTH, max_res=MAX_IMG_RES ) - img = _fig_to_img(fig, image_format=image_format) - dom_id = self._get_dom_id() - overlay_html = _html_image_element( - img=img, div_klass='ica', img_klass='ica', - title='Original and cleaned signal', caption=None, show=True, - image_format=image_format, id=dom_id, tags=tags + self._add_figure( + fig=fig, + title='Original and cleaned signal', + caption=None, + image_format=image_format, + section=section, + tags=tags, + replace=replace, + own_figure=True, ) - return overlay_html - - def _render_ica_properties(self, *, ica, picks, inst, n_jobs, image_format, - tags): + def _add_ica_properties( + self, *, ica, picks, inst, n_jobs, image_format, section, tags, replace + ): ch_type = _get_ch_type(inst=ica.info, ch_type=None) if not _check_ch_locs(info=ica.info, ch_type=ch_type): ch_type_name = _handle_default("titles")[ch_type] warn(f'No {ch_type_name} channel locations found, cannot ' f'create ICA properties plots') - return '' + return figs = _plot_ica_properties_as_arrays( ica=ica, inst=inst, picks=picks, n_jobs=n_jobs @@ -1502,54 +1460,71 @@ def _render_ica_properties(self, *, ica, picks, inst, n_jobs, image_format, title = 'ICA component properties' # Only render a slider if we have more than 1 component. if len(figs) == 1: - img = _fig_to_img(fig=figs[0], image_format=image_format) - dom_id = self._get_dom_id() - properties_html = _html_image_element( - img=img, div_klass='ica', img_klass='ica', - title=title, caption=captions[0], show=True, - image_format=image_format, id=dom_id, tags=tags + self._add_figure( + fig=figs[0], + title=title, + caption=captions[0], + image_format=image_format, + tags=tags, + section=section, + replace=replace, + own_figure=True, ) else: - properties_html, _ = self._render_slider( - figs=figs, imgs=None, title=title, captions=captions, - start_idx=0, image_format=image_format, tags=tags + self._add_slider( + figs=figs, + imgs=None, + title=title, + captions=captions, + start_idx=0, + image_format=image_format, + section=section, + tags=tags, + replace=replace, + own_figure=True, ) - return properties_html - - def _render_ica_artifact_sources(self, *, ica, inst, artifact_type, - image_format, tags): + def _add_ica_artifact_sources( + self, *, ica, inst, artifact_type, image_format, section, tags, replace + ): with use_browser_backend('matplotlib'): fig = ica.plot_sources(inst=inst, show=False) _constrain_fig_resolution( fig, max_width=MAX_IMG_WIDTH, max_res=MAX_IMG_RES ) - img = _fig_to_img(fig, image_format=image_format) - dom_id = self._get_dom_id() - html = _html_image_element( - img=img, div_klass='ica', img_klass='ica', - title=f'Original and cleaned {artifact_type} epochs', caption=None, - show=True, image_format=image_format, id=dom_id, tags=tags + self._add_figure( + fig=fig, + title=f'Original and cleaned {artifact_type} epochs', + caption=None, + image_format=image_format, + tags=tags, + section=section, + replace=replace, + own_figure=True, ) - return html - def _render_ica_artifact_scores(self, *, ica, scores, artifact_type, - image_format, tags): + def _add_ica_artifact_scores( + self, *, ica, scores, artifact_type, image_format, section, tags, + replace + ): fig = ica.plot_scores(scores=scores, title=None, show=False) _constrain_fig_resolution( fig, max_width=MAX_IMG_WIDTH, max_res=MAX_IMG_RES ) - img = _fig_to_img(fig, image_format=image_format) - dom_id = self._get_dom_id() - html = _html_image_element( - img=img, div_klass='ica', img_klass='ica', + self._add_figure( + fig=fig, title=f'Scores for matching {artifact_type} patterns', - caption=None, show=True, image_format=image_format, id=dom_id, - tags=tags + caption=None, + image_format=image_format, + tags=tags, + section=section, + replace=replace, + own_figure=True, ) - return html - def _render_ica_components(self, *, ica, picks, image_format, tags): + def _add_ica_components( + self, *, ica, picks, image_format, section, tags, replace + ): ch_type = _get_ch_type(inst=ica.info, ch_type=None) if not _check_ch_locs(info=ica.info, ch_type=ch_type): ch_type_name = _handle_default("titles")[ch_type] @@ -1572,25 +1547,34 @@ def _render_ica_components(self, *, ica, picks, image_format, tags): _constrain_fig_resolution( fig, max_width=MAX_IMG_WIDTH, max_res=MAX_IMG_RES ) - img = _fig_to_img(fig=fig, image_format=image_format) - dom_id = self._get_dom_id() - topographies_html = _html_image_element( - img=img, div_klass='ica', img_klass='ica', - title=title, caption=None, show=True, - image_format=image_format, id=dom_id, tags=tags + self._add_figure( + fig=fig, + title=title, + caption=None, + image_format=image_format, + tags=tags, + section=section, + replace=replace, + own_figure=True, ) else: - captions = [None] * len(figs) - topographies_html, _ = self._render_slider( - figs=figs, imgs=None, title=title, captions=captions, - start_idx=0, image_format=image_format, tags=tags + self._add_slider( + figs=figs, + imgs=None, + title=title, + captions=[None] * len(figs), + start_idx=0, + image_format=image_format, + section=section, + tags=tags, + replace=replace ) - return topographies_html - - def _render_ica(self, *, ica, inst, picks, ecg_evoked, - eog_evoked, ecg_scores, eog_scores, title, image_format, - tags, n_jobs): + def _add_ica( + self, *, ica, inst, picks, ecg_evoked, + eog_evoked, ecg_scores, eog_scores, title, image_format, + section, tags, n_jobs, replace + ): if _path_like(ica): ica = read_ica(ica) @@ -1607,7 +1591,7 @@ def _render_ica(self, *, ica, inst, picks, ecg_evoked, fname = str(inst) # could e.g. be a Path! raw_kwargs = dict(fname=fname, preload=False) if fname.endswith(('.fif', '.fif.gz')): - raw_kwargs['allow_maxshield'] = True + raw_kwargs['allow_maxshield'] = 'yes' try: inst = read_raw(**raw_kwargs) @@ -1633,86 +1617,63 @@ def _render_ica(self, *, ica, inst, picks, ecg_evoked, eog_evoked = read_evokeds(fname=eog_evoked, condition=0) # Summary table - dom_id = self._get_dom_id() - repr_html = _html_element( - div_klass='ica', - id=dom_id, - tags=tags, + self._add_html_repr( + inst=ica, title='Info', - html=ica._repr_html_() + tags=tags, + section=section, + replace=replace, + div_klass='ica', ) # Overlay plot if inst: - overlay_html = self._render_ica_overlay( - ica=ica, inst=inst, image_format=image_format, tags=tags + self._add_ica_overlay( + ica=ica, inst=inst, image_format=image_format, section=section, + tags=tags, replace=replace, ) - else: - overlay_html = '' # ECG artifact if ecg_scores is not None: - ecg_scores_html = self._render_ica_artifact_scores( + self._add_ica_artifact_scores( ica=ica, scores=ecg_scores, artifact_type='ECG', - image_format=image_format, tags=tags + image_format=image_format, section=section, tags=tags, + replace=replace, ) - else: - ecg_scores_html = '' - if ecg_evoked: - ecg_html = self._render_ica_artifact_sources( + self._add_ica_artifact_sources( ica=ica, inst=ecg_evoked, artifact_type='ECG', - image_format=image_format, tags=tags + image_format=image_format, section=section, tags=tags, + replace=replace, ) - else: - ecg_html = '' # EOG artifact if eog_scores is not None: - eog_scores_html = self._render_ica_artifact_scores( + self._add_ica_artifact_scores( ica=ica, scores=eog_scores, artifact_type='EOG', - image_format=image_format, tags=tags + image_format=image_format, section=section, tags=tags, + replace=replace, ) - else: - eog_scores_html = '' - if eog_evoked: - eog_html = self._render_ica_artifact_sources( + self._add_ica_artifact_sources( ica=ica, inst=eog_evoked, artifact_type='EOG', - image_format=image_format, tags=tags + image_format=image_format, section=section, tags=tags, + replace=replace, ) - else: - eog_html = '' # Component topography plots - topographies_html = self._render_ica_components( - ica=ica, picks=picks, image_format=image_format, tags=tags + self._add_ica_components( + ica=ica, picks=picks, image_format=image_format, section=section, + tags=tags, replace=replace, ) # Properties plots if inst: - properties_html = self._render_ica_properties( + self._add_ica_properties( ica=ica, picks=picks, inst=inst, n_jobs=n_jobs, - image_format=image_format, tags=tags + image_format=image_format, section=section, tags=tags, + replace=replace, ) - else: - properties_html = '' - - dom_id = self._get_dom_id() - html = _html_ica_element( - id=dom_id, - repr=repr_html, - overlay=overlay_html, - ecg=ecg_html, - eog=eog_html, - ecg_scores=ecg_scores_html, - eog_scores=eog_scores_html, - properties=properties_html, - topographies=topographies_html, - title=title, - tags=tags - ) - return dom_id, html @fill_doc def add_ica( @@ -1751,20 +1712,12 @@ def add_ica( .. versionadded:: 0.24.0 """ tags = _check_tags(tags) - - dom_id, html = self._render_ica( + self._add_ica( ica=ica, inst=inst, picks=picks, ecg_evoked=ecg_evoked, eog_evoked=eog_evoked, ecg_scores=ecg_scores, eog_scores=eog_scores, - title=title, image_format=self.image_format, tags=tags, - n_jobs=n_jobs - ) - self._add_or_replace( - name=title, - dom_id=dom_id, - tags=tags, - html=html, - replace=replace + title=title, image_format=self.image_format, + tags=tags, section=title, n_jobs=n_jobs, replace=replace, ) def remove(self, *, title=None, tags=None, remove_all=False): @@ -1823,7 +1776,10 @@ def remove(self, *, title=None, tags=None, remove_all=False): return remove_idx - def _add_or_replace(self, *, name, dom_id, tags, html, replace=False): + @fill_doc + def _add_or_replace( + self, *, name, section, dom_id, tags, html, replace=False + ): """Append HTML content report, or replace it if it already exists. Parameters @@ -1832,6 +1788,7 @@ def _add_or_replace(self, *, name, dom_id, tags, html, replace=False): The entry under which the content shall be listed in the table of contents. If it already exists, the content will be replaced if ``replace`` is ``True`` + %(section_report)s dom_id : str A unique element ``id`` in the DOM. tags : tuple of str @@ -1845,6 +1802,7 @@ def _add_or_replace(self, *, name, dom_id, tags, html, replace=False): new_content = _ContentElement( name=name, + section=section, dom_id=dom_id, tags=tags, html=html @@ -1862,7 +1820,7 @@ def _add_or_replace(self, *, name, dom_id, tags, html, replace=False): # Simply append new content (no replace) self._content.append(new_content) - def _render_code(self, *, code, title, language, tags): + def _add_code(self, *, code, title, language, section, tags, replace): if isinstance(code, Path): code = Path(code).read_text() @@ -1874,11 +1832,20 @@ def _render_code(self, *, code, title, language, tags): code=code, language=language ) - return html, dom_id + self._add_or_replace( + dom_id=dom_id, + name=title, + section=section, + tags=tags, + html=html, + replace=replace + ) @fill_doc - def add_code(self, code, title, *, language='python', tags=('code',), - replace=False): + def add_code( + self, code, title, *, language='python', tags=('code',), + replace=False + ): """Add a code snippet (e.g., an analysis script) to the report. Parameters @@ -1903,19 +1870,13 @@ def add_code(self, code, title, *, language='python', tags=('code',), """ tags = _check_tags(tags) language = language.lower() - html, dom_id = self._render_code( - code=code, title=title, language=language, tags=tags - ) - self._add_or_replace( - dom_id=dom_id, - name=title, - tags=tags, - html=html, - replace=replace + self._add_code( + code=code, title=title, language=language, section=None, tags=tags, + replace=replace, ) @fill_doc - def add_sys_info(self, title, *, tags=('mne-sysinfo',)): + def add_sys_info(self, title, *, tags=('mne-sysinfo',), replace=False): """Add a MNE-Python system information to the report. This is a convenience method that captures the output of @@ -1926,6 +1887,7 @@ def add_sys_info(self, title, *, tags=('mne-sysinfo',)): title : str The title to assign. %(tags_report)s + %(replace_report)s Notes ----- @@ -1937,11 +1899,46 @@ def add_sys_info(self, title, *, tags=('mne-sysinfo',)): sys_info() info = f.getvalue() - self.add_code(code=info, title=title, language='shell', tags=tags) + self.add_code( + code=info, title=title, language='shell', tags=tags, + replace=replace + ) + + def _add_image( + self, *, img, title, caption, image_format, tags, section, replace, + ): + dom_id = self._get_dom_id() + html = _html_image_element( + img=img, div_klass='custom-image', img_klass='custom-image', + title=title, caption=caption, show=True, + image_format=image_format, id=dom_id, tags=tags + ) + self._add_or_replace( + dom_id=dom_id, + name=title, + section=section, + tags=tags, + html=html, + replace=replace + ) + + def _add_figure( + self, *, fig, title, caption, image_format, tags, section, replace, + own_figure + ): + img = _fig_to_img( + fig=fig, image_format=image_format, own_figure=own_figure + ) + self._add_image( + img=img, title=title, caption=caption, image_format=image_format, + tags=tags, section=section, replace=replace + ) @fill_doc - def add_figure(self, fig, title, *, caption=None, image_format=None, - tags=('custom-figure',), replace=False): + def add_figure( + self, fig, title, *, caption=None, image_format=None, + tags=('custom-figure',), section=None, replace=False + ): """Add figures to the report. Parameters @@ -1958,6 +1955,7 @@ def add_figure(self, fig, title, *, caption=None, image_format=None, The caption(s) to add to the figure(s). %(image_format_report)s %(tags_report)s + %(section_report)s %(replace_report)s Notes @@ -1996,32 +1994,23 @@ def add_figure(self, fig, title, *, caption=None, image_format=None, assert figs if len(figs) == 1: - img = _fig_to_img(fig=figs[0], image_format=image_format, - own_figure=False) - dom_id = self._get_dom_id() - html = _html_image_element( - img=img, div_klass='custom-image', img_klass='custom-image', - title=title, caption=captions[0], show=True, - image_format=image_format, id=dom_id, tags=tags + self._add_figure( + title=title, fig=figs[0], caption=captions[0], + image_format=image_format, section=section, tags=tags, + replace=replace, own_figure=False ) else: - html, dom_id = self._render_slider( + self._add_slider( figs=figs, imgs=None, title=title, captions=captions, - start_idx=0, image_format=image_format, tags=tags, - own_figure=False + start_idx=0, image_format=image_format, section=section, + tags=tags, own_figure=False, replace=replace ) - self._add_or_replace( - dom_id=dom_id, - name=title, - tags=tags, - html=html, - replace=replace - ) - @fill_doc - def add_image(self, image, title, *, caption=None, tags=('custom-image',), - replace=False): + def add_image( + self, image, title, *, caption=None, tags=('custom-image',), + section=None, replace=False + ): """Add an image (e.g., PNG or JPEG pictures) to the report. Parameters @@ -2033,6 +2022,7 @@ def add_image(self, image, title, *, caption=None, tags=('custom-image',), caption : str | None If not ``None``, the caption to add to the image. %(tags_report)s + %(section_report)s %(replace_report)s Notes @@ -2048,23 +2038,20 @@ def add_image(self, image, title, *, caption=None, tags=('custom-image',), _check_option('Image format', value=img_format, allowed_values=('png', 'gif', 'svg')) - dom_id = self._get_dom_id() - img_html = _html_image_element( - img=img_base64, div_klass='custom-image', - img_klass='custom-image', title=title, caption=caption, - show=True, image_format=img_format, id=dom_id, - tags=tags - ) - self._add_or_replace( - dom_id=dom_id, - name=title, + self._add_image( + img=img_base64, + title=title, + caption=caption, + image_format=img_format, tags=tags, - html=img_html, + section=section, replace=replace ) @fill_doc - def add_html(self, html, title, *, tags=('custom-html',), replace=False): + def add_html( + self, html, title, *, tags=('custom-html',), replace=False + ): """Add HTML content to the report. Parameters @@ -2089,14 +2076,17 @@ def add_html(self, html, title, *, tags=('custom-html',), replace=False): self._add_or_replace( dom_id=dom_id, name=title, + section=None, tags=tags, html=html_element, replace=replace ) @fill_doc - def add_bem(self, subject, title, *, subjects_dir=None, decim=2, width=512, - n_jobs=None, tags=('bem',), replace=False): + def add_bem( + self, subject, title, *, subjects_dir=None, decim=2, width=512, + n_jobs=None, tags=('bem',), replace=False + ): """Render a visualization of the boundary element model (BEM) surfaces. Parameters @@ -2124,28 +2114,18 @@ def add_bem(self, subject, title, *, subjects_dir=None, decim=2, width=512, """ tags = _check_tags(tags) width = _ensure_int(width, 'width') - html = self._render_bem(subject=subject, subjects_dir=subjects_dir, - decim=decim, n_jobs=n_jobs, width=width, - image_format=self.image_format, tags=tags) - - dom_id = self._get_dom_id() - html = _html_element( - div_klass='bem', - id=dom_id, - tags=tags, - title=title, - html=html, - ) - self._add_or_replace( - dom_id=dom_id, - name=title, - tags=tags, - html=html, + self._add_bem( + subject=subject, subjects_dir=subjects_dir, + decim=decim, n_jobs=n_jobs, width=width, + image_format=self.image_format, section=title, tags=tags, replace=replace ) - def _render_slider(self, *, figs, imgs, title, captions, start_idx, - image_format, tags, klass='', own_figure=True): + def _render_slider( + self, *, figs, imgs, title, captions, start_idx, + image_format, tags, klass, own_figure + ): + # This method only exists to make add_bem()'s life easier… if figs is not None and imgs is not None: raise ValueError('Must only provide either figs or imgs') @@ -2174,9 +2154,26 @@ def _render_slider(self, *, figs, imgs, title, captions, start_idx, start_idx=start_idx, klass=klass ) - return html, dom_id + def _add_slider( + self, *, figs, imgs, title, captions, start_idx, + image_format, tags, section, replace, klass='', own_figure=True + ): + html, dom_id = self._render_slider( + figs=figs, imgs=imgs, title=title, captions=captions, + start_idx=start_idx, image_format=image_format, tags=tags, + klass=klass, own_figure=own_figure + ) + self._add_or_replace( + name=title, + section=section, + dom_id=dom_id, + tags=tags, + html=html, + replace=replace + ) + ########################################################################### # global rendering functions @verbose @@ -2394,7 +2391,7 @@ def parse_folder(self, data_path, pattern=None, n_jobs=None, mri_decim=2, if _endswith(fname, ('raw', 'sss', 'meg')): kwargs = dict(fname=fname, preload=False) if fname.endswith(('.fif', '.fif.gz')): - kwargs['allow_maxshield'] = True + kwargs['allow_maxshield'] = 'yes' inst = read_raw(**kwargs) if len(inst.filenames) > 1: @@ -2573,7 +2570,13 @@ def save(self, fname=None, open_browser=True, overwrite=False, mne_logo_img=mne_logo ) - toc_html = _html_toc_element(content_elements=self._content) + # toc_html = _html_toc_element(content_elements=self._content) + _, dom_ids, titles, tags = self._content_as_html() + toc_html = _html_toc_element( + titles=titles, + dom_ids=dom_ids, + tags=tags + ) with warnings.catch_warnings(record=True): warnings.simplefilter('ignore') @@ -2656,13 +2659,28 @@ def _render_one_bem_axis(self, *, mri_fname, surfaces, image_format=image_format, start_idx=start_idx, tags=tags, - klass='bem col-md' + klass='bem col-md', + own_figure=True, ) return html - def _render_raw_butterfly_segments( - self, *, raw: BaseRaw, n_segments, scalings, image_format, tags + def _add_html_repr( + self, *, inst, title, tags, section, replace, div_klass + ): + html = inst._repr_html_() + self._add_html_element( + html=html, + title=title, + tags=tags, + section=section, + replace=replace, + div_klass=div_klass, + ) + + def _add_raw_butterfly_segments( + self, *, raw: BaseRaw, n_segments, scalings, image_format, tags, + section, replace ): # Pick n_segments + 2 equally-spaced 1-second time slices, but omit # the first and last slice, so we end up with n_segments slices @@ -2706,15 +2724,17 @@ def _render_raw_butterfly_segments( captions = [f'Segment {i+1} of {len(images)}' for i in range(len(images))] - html, _ = self._render_slider( + self._add_slider( figs=None, imgs=images, title='Time series', captions=captions, - start_idx=0, image_format=image_format, tags=tags + start_idx=0, image_format=image_format, tags=tags, section=section, + replace=replace ) - return html - - def _render_raw(self, *, raw, add_psd, add_projs, butterfly, - butterfly_scalings, image_format, tags, topomap_kwargs): + def _add_raw( + self, *, raw, add_psd, add_projs, butterfly, + butterfly_scalings, image_format, tags, topomap_kwargs, + section, replace + ): """Render raw.""" if isinstance(raw, BaseRaw): fname = raw.filenames[0] @@ -2722,33 +2742,31 @@ def _render_raw(self, *, raw, add_psd, add_projs, butterfly, fname = str(raw) # could e.g. be a Path! kwargs = dict(fname=fname, preload=False) if fname.endswith(('.fif', '.fif.gz')): - kwargs['allow_maxshield'] = True + kwargs['allow_maxshield'] = 'yes' raw = read_raw(**kwargs) # Summary table - dom_id = self._get_dom_id() - repr_html = _html_element( - div_klass='raw', - id=dom_id, - tags=tags, + self._add_html_repr( + inst=raw, title='Info', - html=raw._repr_html_() + tags=tags, + section=section, + replace=replace, + div_klass='raw' ) # Butterfly plot if butterfly: n_butterfly_segments = 10 if butterfly is True else butterfly - butterfly_imgs_html = self._render_raw_butterfly_segments( + self._add_raw_butterfly_segments( raw=raw, scalings=butterfly_scalings, n_segments=n_butterfly_segments, - image_format=image_format, tags=tags + image_format=image_format, tags=tags, replace=replace, + section=section ) - else: - butterfly_imgs_html = '' # PSD if isinstance(add_psd, dict): - dom_id = self._get_dom_id() if raw.info['lowpass'] is not None: fmax = raw.info['lowpass'] + 15 # Must not exceed half the sampling frequency @@ -2762,40 +2780,28 @@ def _render_raw(self, *, raw, add_psd, add_projs, butterfly, _constrain_fig_resolution( fig, max_width=MAX_IMG_WIDTH, max_res=MAX_IMG_RES ) - - img = _fig_to_img(fig, image_format=image_format) - psd_img_html = _html_image_element( - img=img, div_klass='raw', img_klass='raw', - title='PSD', caption=None, show=True, - image_format=image_format, id=dom_id, tags=tags + self._add_figure( + fig=fig, + title='PSD', + caption=None, + image_format=image_format, + tags=tags, + section=section, + replace=replace, + own_figure=True ) - else: - psd_img_html = '' - - ssp_projs_html = self._ssp_projs_html( - add_projs=add_projs, info=raw, image_format=image_format, - tags=tags, topomap_kwargs=topomap_kwargs) - - return [repr_html, psd_img_html, butterfly_imgs_html, ssp_projs_html] - def _ssp_projs_html(self, *, add_projs, info, image_format, tags, - topomap_kwargs): + # SSP projectors if add_projs: - output = self._render_ssp_projs( - info=info, projs=None, title='SSP Projectors', + self._add_projs( + info=raw, projs=None, title='Projectors', image_format=image_format, tags=tags, - topomap_kwargs=topomap_kwargs, + topomap_kwargs=topomap_kwargs, section=section, + replace=replace ) - if output is None: - ssp_projs_html = '' - else: - ssp_projs_html, _ = output - else: - ssp_projs_html = '' - return ssp_projs_html - def _render_ssp_projs(self, *, info, projs, title, image_format, tags, - topomap_kwargs): + def _add_projs(self, *, info, projs, title, image_format, tags, section, + topomap_kwargs, replace): if isinstance(info, Info): # no-op pass elif hasattr(info, 'info'): # try to get the file name @@ -2816,12 +2822,15 @@ def _render_ssp_projs(self, *, info, projs, title, image_format, tags, fname = projs projs = read_proj(fname) - if not projs: # Abort mission! - return None + if not projs: + raise ValueError('No SSP projectors found') if not _check_ch_locs(info=info): - warn('No channel locations found, cannot create projector plots') - return '', None + raise ValueError( + 'The provided data does not contain digitization ' + 'information (channel locations). However, this is ' + 'required for rendering the projectors.' + ) topomap_kwargs = self._validate_topomap_kwargs(topomap_kwargs) fig = plot_projs_topomap( @@ -2837,19 +2846,21 @@ def _render_ssp_projs(self, *, info, projs, title, image_format, tags, _constrain_fig_resolution( fig, max_width=MAX_IMG_WIDTH, max_res=MAX_IMG_RES ) - img = _fig_to_img(fig=fig, image_format=image_format) - - dom_id = self._get_dom_id() - html = _html_image_element( - img=img, div_klass='ssp', img_klass='ssp', - title=title, caption=None, show=True, image_format=image_format, - id=dom_id, tags=tags + self._add_figure( + fig=fig, + title=title, + caption=None, + image_format=image_format, + tags=tags, + section=section, + replace=replace, + own_figure=True, ) - return html, dom_id - - def _render_forward(self, *, forward, subject, subjects_dir, title, - image_format, tags): + def _add_forward( + self, *, forward, subject, subjects_dir, title, image_format, + section, tags, replace + ): """Render forward solution.""" if not isinstance(forward, Forward): forward = read_forward_solution(forward) @@ -2873,11 +2884,20 @@ def _render_forward(self, *, forward, subject, subjects_dir, title, title=title, tags=tags ) - return html, dom_id + self._add_or_replace( + name=title, + section=section, + dom_id=dom_id, + tags=tags, + html=html, + replace=replace, + ) - def _render_inverse_operator(self, *, inverse_operator, subject, - subjects_dir, trans, title, image_format, - tags): + def _add_inverse_operator( + self, *, inverse_operator, subject, + subjects_dir, trans, title, image_format, + section, tags, replace, + ): """Render inverse operator.""" if not isinstance(inverse_operator, InverseOperator): inverse_operator = read_inverse_operator(inverse_operator) @@ -2923,11 +2943,19 @@ def _render_inverse_operator(self, *, inverse_operator, subject, title=title, tags=tags, ) - return html, dom_id + self._add_or_replace( + name=title, + section=section, + dom_id=dom_id, + tags=tags, + html=html, + replace=replace, + ) - def _render_evoked_joint(self, evoked, ch_types, image_format, tags, - topomap_kwargs): - htmls = [] + def _add_evoked_joint( + self, *, evoked, ch_types, image_format, section, tags, topomap_kwargs, + replace + ): for ch_type in ch_types: if not _check_ch_locs(info=evoked.info, ch_type=ch_type): ch_type_name = _handle_default("titles")[ch_type] @@ -2946,27 +2974,18 @@ def _render_evoked_joint(self, evoked, ch_types, image_format, tags, _constrain_fig_resolution( fig, max_width=MAX_IMG_WIDTH, max_res=MAX_IMG_RES ) - img = _fig_to_img(fig=fig, image_format=image_format) title = f'Time course ({_handle_default("titles")[ch_type]})' - dom_id = self._get_dom_id() - - htmls.append( - _html_image_element( - img=img, - div_klass='evoked evoked-joint', - img_klass='evoked evoked-joint', - tags=tags, - title=title, - caption=None, - show=True, - image_format=image_format, - id=dom_id - ) + self._add_figure( + fig=fig, + title=title, + caption=None, + image_format=image_format, + tags=tags, + section=section, + replace=replace, + own_figure=True, ) - html = '\n'.join(htmls) - return html - def _plot_one_evoked_topomap_timepoint( self, *, evoked, time, ch_types, vmin, vmax, topomap_kwargs ): @@ -3010,9 +3029,10 @@ def _plot_one_evoked_topomap_timepoint( fig_array = plt.imread(buff, format='png') return fig_array - def _render_evoked_topomap_slider(self, *, evoked, ch_types, n_time_points, - image_format, tags, topomap_kwargs, - n_jobs): + def _add_evoked_topomap_slider( + self, *, evoked, ch_types, n_time_points, image_format, section, tags, + topomap_kwargs, n_jobs, replace + ): if n_time_points is None: n_time_points = min(len(evoked.times), 21) elif n_time_points > len(evoked.times): @@ -3055,8 +3075,7 @@ def _render_evoked_topomap_slider(self, *, evoked, ch_types, n_time_points, vmin[ch_type] = -vmax[ch_type] if not (vmin and vmax): # we only had EEG data and no digpoints - html = '' - dom_id = None + return # No need to warn here, we did that above else: topomap_kwargs = self._validate_topomap_kwargs(topomap_kwargs) parallel, p_fun, n_jobs = parallel_func( @@ -3071,19 +3090,21 @@ def _render_evoked_topomap_slider(self, *, evoked, ch_types, n_time_points, ) captions = [f'Time point: {round(t, 3):0.3f} s' for t in times] - html, dom_id = self._render_slider( + self._add_slider( figs=fig_arrays, imgs=None, captions=captions, title='Topographies', image_format=image_format, start_idx=t_zero_idx, - tags=tags + section=section, + tags=tags, + replace=replace, ) - return html, dom_id - - def _render_evoked_gfp(self, evoked, ch_types, image_format, tags): + def _add_evoked_gfp( + self, *, evoked, ch_types, image_format, section, tags, replace + ): # Make legend labels shorter by removing the multiplicative factors pattern = r'\d\.\d* × ' label = evoked.comment @@ -3092,8 +3113,6 @@ def _render_evoked_gfp(self, evoked, ch_types, image_format, tags): for match in re.findall(pattern=pattern, string=label): label = label.replace(match, '') - dom_id = self._get_dom_id() - import matplotlib.pyplot as plt fig, ax = plt.subplots(len(ch_types), 1, sharex=True) if len(ch_types) == 1: @@ -3117,26 +3136,22 @@ def _render_evoked_gfp(self, evoked, ch_types, image_format, tags): _constrain_fig_resolution( fig, max_width=MAX_IMG_WIDTH, max_res=MAX_IMG_RES ) - img = _fig_to_img(fig=fig, image_format=image_format) title = 'Global field power' - html = _html_image_element( - img=img, - id=dom_id, - tags=tags, - div_klass='evoked evoked-gfp', - img_klass='evoked evoked-gfp', + self._add_figure( + fig=fig, title=title, caption=None, image_format=image_format, - show=True + section=section, + tags=tags, + replace=replace, + own_figure=True, ) - return html - - def _render_evoked_whitened(self, evoked, *, noise_cov, image_format, - tags): + def _add_evoked_whitened( + self, *, evoked, noise_cov, image_format, section, tags, replace + ): """Render whitened evoked.""" - dom_id = self._get_dom_id() fig = evoked.plot_white( noise_cov=noise_cov, show=False @@ -3145,56 +3160,70 @@ def _render_evoked_whitened(self, evoked, *, noise_cov, image_format, _constrain_fig_resolution( fig, max_width=MAX_IMG_WIDTH, max_res=MAX_IMG_RES ) - img = _fig_to_img(fig=fig, image_format=image_format) title = 'Whitened' - html = _html_image_element( - img=img, id=dom_id, div_klass='evoked', - img_klass='evoked evoked-whitened', title=title, caption=None, - show=True, image_format=image_format, tags=tags + self._add_figure( + fig=fig, + title=title, + caption=None, + image_format=image_format, + tags=tags, + section=section, + replace=replace, + own_figure=True, ) - return html - def _render_evoked(self, evoked, noise_cov, add_projs, n_time_points, - image_format, tags, topomap_kwargs, n_jobs): + def _add_evoked( + self, *, evoked, noise_cov, add_projs, n_time_points, + image_format, section, tags, topomap_kwargs, n_jobs, replace + ): ch_types = _get_ch_types(evoked) - joint_html = self._render_evoked_joint( + self._add_evoked_joint( evoked=evoked, ch_types=ch_types, - image_format=image_format, tags=tags, - topomap_kwargs=topomap_kwargs, + image_format=image_format, section=section, tags=tags, + topomap_kwargs=topomap_kwargs, replace=replace, ) - slider_html, _ = self._render_evoked_topomap_slider( + self._add_evoked_topomap_slider( evoked=evoked, ch_types=ch_types, n_time_points=n_time_points, image_format=image_format, - tags=tags, topomap_kwargs=topomap_kwargs, - n_jobs=n_jobs + section=section, tags=tags, topomap_kwargs=topomap_kwargs, + n_jobs=n_jobs, replace=replace, ) - gfp_html = self._render_evoked_gfp( + self._add_evoked_gfp( evoked=evoked, ch_types=ch_types, image_format=image_format, - tags=tags + section=section, tags=tags, replace=replace, ) if noise_cov is not None: - whitened_html = self._render_evoked_whitened( + self._add_evoked_whitened( evoked=evoked, noise_cov=noise_cov, image_format=image_format, - tags=tags + section=section, + tags=tags, + replace=replace, ) - else: - whitened_html = '' # SSP projectors - ssp_projs_html = self._ssp_projs_html( - add_projs=add_projs, info=evoked, image_format=image_format, - tags=tags, topomap_kwargs=topomap_kwargs) + if add_projs: + self._add_projs( + info=evoked, + projs=None, + title='Projectors', + image_format=image_format, + section=section, + tags=tags, + topomap_kwargs=topomap_kwargs, + replace=replace, + ) logger.debug('Evoked: done') - return joint_html, slider_html, gfp_html, whitened_html, ssp_projs_html - def _render_events(self, events, *, event_id, sfreq, first_samp, title, - image_format, tags): + def _add_events( + self, *, events, event_id, sfreq, first_samp, title, section, + image_format, tags, replace + ): """Render events.""" if not isinstance(events, np.ndarray): events = read_events(filename=events) @@ -3209,93 +3238,103 @@ def _render_events(self, events, *, event_id, sfreq, first_samp, title, _constrain_fig_resolution( fig, max_width=MAX_IMG_WIDTH, max_res=MAX_IMG_RES ) - img = _fig_to_img( + self._add_figure( fig=fig, - image_format=image_format, - ) - - dom_id = self._get_dom_id() - html = _html_image_element( - img=img, - id=dom_id, - div_klass='events', - img_klass='events', - tags=tags, title=title, caption=None, - show=True, - image_format=image_format + image_format=image_format, + tags=tags, + section=section, + replace=replace, + own_figure=True, ) - return html, dom_id - def _epochs_psd_img_html( - self, *, epochs, psd, image_format, tags + def _add_epochs_psd( + self, *, epochs, psd, image_format, tags, section, replace ): - if psd: - epoch_duration = epochs.tmax - epochs.tmin - - if psd is True: # Entire time range -> all epochs - epochs_for_psd = epochs # Avoid creating a copy - else: # Only a subset of epochs - signal_duration = len(epochs) * epoch_duration - n_epochs_required = int( - np.ceil(psd / epoch_duration) + epoch_duration = epochs.tmax - epochs.tmin + + if psd is True: # Entire time range -> all epochs + epochs_for_psd = epochs # Avoid creating a copy + else: # Only a subset of epochs + signal_duration = len(epochs) * epoch_duration + n_epochs_required = int( + np.ceil(psd / epoch_duration) + ) + if n_epochs_required > len(epochs): + raise ValueError( + f'You requested to calculate PSD on a duration of ' + f'{psd:.3f} sec, but all your epochs ' + f'are only {signal_duration:.1f} sec long' ) - if n_epochs_required > len(epochs): - raise ValueError( - f'You requested to calculate PSD on a duration of ' - f'{psd:.3f} sec, but all your epochs ' - f'are only {signal_duration:.1f} sec long' - ) - epochs_idx = np.round( - np.linspace( - start=0, - stop=len(epochs) - 1, - num=n_epochs_required - ) - ).astype(int) - # Edge case: there might be duplicate indices due to rounding? - epochs_idx_unique = np.unique(epochs_idx) - if len(epochs_idx_unique) != len(epochs_idx): - duration = round( - len(epochs_idx_unique) * epoch_duration, 1 - ) - warn(f'Using {len(epochs_idx_unique)} epochs, only ' - f'covering {duration:.1f} sec of data') - del duration + epochs_idx = np.round( + np.linspace( + start=0, + stop=len(epochs) - 1, + num=n_epochs_required + ) + ).astype(int) + # Edge case: there might be duplicate indices due to rounding? + epochs_idx_unique = np.unique(epochs_idx) + if len(epochs_idx_unique) != len(epochs_idx): + duration = round( + len(epochs_idx_unique) * epoch_duration, 1 + ) + warn(f'Using {len(epochs_idx_unique)} epochs, only ' + f'covering {duration:.1f} sec of data') + del duration - epochs_for_psd = epochs[epochs_idx_unique] + epochs_for_psd = epochs[epochs_idx_unique] - dom_id = self._get_dom_id() - if epochs.info['lowpass'] is not None: - fmax = epochs.info['lowpass'] + 15 - # Must not exceed half the sampling frequency - if fmax > 0.5 * epochs.info['sfreq']: - fmax = np.inf - else: + if epochs.info['lowpass'] is None: + fmax = np.inf + else: + fmax = epochs.info['lowpass'] + 15 + # Must not exceed half the sampling frequency + if fmax > 0.5 * epochs.info['sfreq']: fmax = np.inf - fig = epochs_for_psd.plot_psd(fmax=fmax, show=False) - _constrain_fig_resolution( - fig, max_width=MAX_IMG_WIDTH, max_res=MAX_IMG_RES - ) - img = _fig_to_img(fig=fig, image_format=image_format) - duration = round(epoch_duration * len(epochs_for_psd), 1) - caption = ( - f'PSD calculated from {len(epochs_for_psd)} epochs ' - f'({duration:.1f} sec).' - ) - psd_img_html = _html_image_element( - img=img, id=dom_id, div_klass='epochs', img_klass='epochs', - show=True, image_format=image_format, title='PSD', - caption=caption, tags=tags - ) - else: - psd_img_html = '' + fig = epochs_for_psd.plot_psd(fmax=fmax, show=False) + _constrain_fig_resolution( + fig, max_width=MAX_IMG_WIDTH, max_res=MAX_IMG_RES + ) + duration = round(epoch_duration * len(epochs_for_psd), 1) + caption = ( + f'PSD calculated from {len(epochs_for_psd)} epochs ' + f'({duration:.1f} sec).' + ) + self._add_figure( + fig=fig, + image_format=image_format, + title='PSD', + caption=caption, + tags=tags, + section=section, + replace=replace, + own_figure=True, + ) - return psd_img_html + def _add_html_element( + self, *, html, title, tags, section, replace, div_klass + ): + dom_id = self._get_dom_id() + html = _html_element( + div_klass=div_klass, + id=dom_id, + tags=tags, + title=title, + html=html + ) + self._add_or_replace( + name=title, + section=section, + dom_id=dom_id, + tags=tags, + html=html, + replace=replace + ) - def _render_epochs_metadata(self, *, epochs, tags): + def _add_epochs_metadata(self, *, epochs, section, tags, replace): metadata = epochs.metadata.copy() # Ensure we have a named index @@ -3362,19 +3401,18 @@ def _render_epochs_metadata(self, *, epochs, tags): ) html = '\n'.join(htmls) - dom_id = self._get_dom_id() - metadata_html = _html_element( + self._add_html_element( div_klass='epochs', - id=dom_id, tags=tags, title='Metadata', - html=html + html=html, + section=section, + replace=replace, ) - return metadata_html - def _render_epochs( + def _add_epochs( self, *, epochs, psd, add_projs, topomap_kwargs, drop_log_ignore, - image_format, tags + image_format, section, tags, replace ): """Render epochs.""" if isinstance(epochs, BaseEpochs): @@ -3384,26 +3422,23 @@ def _render_epochs( epochs = read_epochs(fname, preload=False) # Summary table - dom_id = self._get_dom_id() - repr_html = _html_element( - div_klass='epochs', - id=dom_id, - tags=tags, + self._add_html_repr( + inst=epochs, title='Info', - html=epochs._repr_html_() + tags=tags, + section=section, + replace=replace, + div_klass='epochs', ) # Metadata table if epochs.metadata is not None: - metadata_html = self._render_epochs_metadata( - epochs=epochs, tags=tags + self._add_epochs_metadata( + epochs=epochs, tags=tags, section=section, replace=replace ) - else: - metadata_html = '' # ERP/ERF image(s) ch_types = _get_ch_types(epochs) - erp_img_htmls = [] epochs.load_data() for ch_type in ch_types: @@ -3417,7 +3452,6 @@ def _render_epochs( _constrain_fig_resolution( fig, max_width=MAX_IMG_WIDTH, max_res=MAX_IMG_RES ) - img = _fig_to_img(fig=fig, image_format=image_format) if ch_type in ('mag', 'grad'): title_start = 'ERF image' else: @@ -3426,31 +3460,31 @@ def _render_epochs( title = (f'{title_start} ' f'({_handle_default("titles")[ch_type]})') - dom_id = self._get_dom_id() - erp_img_htmls.append( - _html_image_element( - img=img, - div_klass='epochs erp-image', - img_klass='epochs erp-image', - tags=tags, - title=title, - caption=None, - show=True, - image_format=image_format, - id=dom_id - ) + + self._add_figure( + fig=fig, + title=title, + caption=None, + image_format=image_format, + tags=tags, + section=section, + replace=replace, + own_figure=True, ) - erp_imgs_html = '\n'.join(erp_img_htmls) # Drop log if epochs._bad_dropped: title = 'Drop log' - dom_id = self._get_dom_id() + if epochs.drop_log_stats(ignore=drop_log_ignore) == 0: # No drops - drop_log_img_html = _html_element( + self._add_html_element( html='No epochs exceeded the rejection thresholds. ' 'Nothing was dropped.', - id=dom_id, div_klass='epochs', title=title, tags=tags + div_klass='epochs', + title=title, + tags=tags, + section=section, + replace=replace, ) else: fig = epochs.plot_drop_log( @@ -3460,27 +3494,35 @@ def _render_epochs( _constrain_fig_resolution( fig, max_width=MAX_IMG_WIDTH, max_res=MAX_IMG_RES ) - img = _fig_to_img(fig=fig, image_format=image_format) - drop_log_img_html = _html_image_element( - img=img, id=dom_id, div_klass='epochs', img_klass='epochs', - show=True, image_format=image_format, title=title, - caption=None, tags=tags + self._add_figure( + fig=fig, + image_format=image_format, + title=title, + caption=None, + tags=tags, + section=section, + replace=replace, + own_figure=True, ) - else: - drop_log_img_html = '' - psd_img_html = self._epochs_psd_img_html( - epochs=epochs, psd=psd, image_format=image_format, tags=tags - ) - ssp_projs_html = self._ssp_projs_html( - add_projs=add_projs, info=epochs, image_format=image_format, - tags=tags, topomap_kwargs=topomap_kwargs - ) - - return (repr_html, metadata_html, erp_imgs_html, drop_log_img_html, - psd_img_html, ssp_projs_html) + if psd: + self._add_epochs_psd( + epochs=epochs, psd=psd, image_format=image_format, tags=tags, + section=section, replace=replace + ) + if add_projs: + self._add_projs( + info=epochs, + projs=None, + title='Projections', + image_format=image_format, + tags=tags, + topomap_kwargs=topomap_kwargs, + section=section, + replace=replace, + ) - def _render_cov(self, cov, *, info, image_format, tags): + def _add_cov(self, *, cov, info, image_format, section, tags, replace): """Render covariance matrix & SVD.""" if not isinstance(cov, Covariance): cov = read_cov(cov) @@ -3490,30 +3532,29 @@ def _render_cov(self, cov, *, info, image_format, tags): fig_cov, fig_svd = plot_cov(cov=cov, info=info, show=False, show_svd=True) figs = [fig_cov, fig_svd] - htmls = [] - titles = ( 'Covariance matrix', 'Singular values' ) - for fig, title in zip(figs, titles): _constrain_fig_resolution( fig, max_width=MAX_IMG_WIDTH, max_res=MAX_IMG_RES ) - img = _fig_to_img(fig=fig, image_format=image_format) - dom_id = self._get_dom_id() - html = _html_image_element( - img=img, id=dom_id, div_klass='covariance', - img_klass='covariance', title=title, caption=None, - image_format=image_format, tags=tags, show=True + self._add_figure( + fig=fig, + title=title, + caption=None, + image_format=image_format, + tags=tags, + section=section, + replace=replace, + own_figure=True, ) - htmls.append(html) - - return htmls - def _render_trans(self, *, trans, info, subject, subjects_dir, alpha, - title, tags): + def _add_trans( + self, *, trans, info, subject, subjects_dir, alpha, title, section, + tags, replace + ): """Render trans (only PNG).""" if not isinstance(trans, Transform): trans = read_trans(trans) @@ -3525,18 +3566,22 @@ def _render_trans(self, *, trans, info, subject, subjects_dir, alpha, meg=['helmet', 'sensors'], show_axes=True, coord_frame='mri') img, caption = _iterate_trans_views( - function=plot_alignment, alpha=alpha, **kwargs) - - dom_id = self._get_dom_id() - html = _html_image_element( - img=img, id=dom_id, div_klass='trans', - img_klass='trans', title=title, caption=caption, - show=True, image_format='png', tags=tags + function=plot_alignment, alpha=alpha, **kwargs + ) + self._add_image( + img=img, + title=title, + section=section, + caption=caption, + image_format='png', + tags=tags, + replace=replace, ) - return html, dom_id - def _render_stc(self, *, stc, title, subject, subjects_dir, n_time_points, - image_format, tags, stc_plot_kwargs): + def _add_stc( + self, *, stc, title, subject, subjects_dir, n_time_points, + image_format, section, tags, stc_plot_kwargs, replace, + ): """Render STC.""" if isinstance(stc, SourceEstimate): if subject is None: @@ -3656,19 +3701,22 @@ def _render_stc(self, *, stc, title, subject, subjects_dir, n_time_points, brain_rh.close() captions = [f'Time point: {round(t, 3):0.3f} s' for t in times] - html, dom_id = self._render_slider( + self._add_slider( figs=figs, imgs=None, captions=captions, title=title, image_format=image_format, start_idx=t_zero_idx, - tags=tags + section=section, + tags=tags, + replace=replace, ) - return html, dom_id - def _render_bem(self, *, subject, subjects_dir, decim, n_jobs, width=512, - image_format, tags): + def _add_bem( + self, *, subject, subjects_dir, decim, n_jobs, width=512, + image_format, section, tags, replace + ): """Render mri+bem (only PNG).""" if subjects_dir is None: subjects_dir = self.subjects_dir @@ -3686,18 +3734,39 @@ def _render_bem(self, *, subject, subjects_dir, decim, n_jobs, width=512, if not surfaces: warn('No BEM surfaces found, rendering empty MRI') - htmls = [] - htmls.append('
') + htmls = dict() for orientation in _BEM_VIEWS: - html = self._render_one_bem_axis( + htmls[orientation] = self._render_one_bem_axis( mri_fname=mri_fname, surfaces=surfaces, orientation=orientation, decim=decim, n_jobs=n_jobs, width=width, image_format=image_format, tags=tags ) - htmls.append(html) - htmls.append('
') - return '\n'.join(htmls) + + # Special handling to deal with our tests, where we monkey-patch + # _BEM_VIEWS to save time + html_slider_axial = htmls['axial'] if 'axial' in htmls else '' + html_slider_sagittal = htmls['sagittal'] if 'sagittal' in htmls else '' + html_slider_coronal = htmls['coronal'] if 'coronal' in htmls else '' + + dom_id = self._get_dom_id() + html = _html_bem_element( + id=dom_id, + div_klass='bem', + html_slider_axial=html_slider_axial, + html_slider_sagittal=html_slider_sagittal, + html_slider_coronal=html_slider_coronal, + tags=tags, + title=section, + ) + self._add_or_replace( + name=section, + section=None, # avoid nesting + dom_id=dom_id, + tags=tags, + html=html, + replace=replace, + ) def _clean_tags(tags): diff --git a/mne/report/tests/test_report.py b/mne/report/tests/test_report.py index 730eb95324a..4da0c7d215c 100644 --- a/mne/report/tests/test_report.py +++ b/mne/report/tests/test_report.py @@ -153,12 +153,12 @@ def test_render_report(renderer_pyvistaqt, tmp_path, invisible_fig): titles = [op.basename(x) for x in fnames if not x.endswith('-ave.fif')] titles.append(f'{op.basename(evoked_fname)}: {evoked.comment}') - content_names = [element.name for element in report._content] + _, _, content_titles, _ = report._content_as_html() for title in titles: - assert title in content_names + assert title in content_titles assert (''.join(report.html).find(title) != -1) - assert len(report._content) == len(fnames) + assert len(content_titles) == len(fnames) # Check saving functionality report.data_path = tempdir @@ -171,8 +171,6 @@ def test_render_report(renderer_pyvistaqt, tmp_path, invisible_fig): assert 'Topographies' in html assert 'Global field power' in html - assert len(report._content) == len(fnames) - # Check saving same report to new filename report.save(fname=op.join(tempdir, 'report2.html'), open_browser=False) assert (op.isfile(op.join(tempdir, 'report2.html'))) @@ -248,7 +246,7 @@ def test_render_report_extra(renderer_pyvistaqt, tmp_path, invisible_fig): assert op.isfile(fname) html = Path(fname).read_text(encoding='utf-8') # Projectors in Raw.info - assert 'SSP Projectors' in html + assert 'Projectors' in html def test_add_custom_css(tmp_path): @@ -306,12 +304,12 @@ def test_render_non_fiff(tmp_path): raw_butterfly=False) # Check correct paths and filenames - content_names = [element.name for element in report._content] - for fname in fnames_out: + _, _, content_titles, _ = report._content_as_html() + for fname in content_titles: assert (op.basename(fname) in - [op.basename(x) for x in content_names]) + [op.basename(x) for x in content_titles]) - assert len(report._content) == len(fnames_out) + assert len(content_titles) == len(fnames_out) report.data_path = tempdir fname = op.join(tempdir, 'report.html') @@ -812,15 +810,18 @@ def test_manual_report_2d(tmp_path, invisible_fig): for ch in evoked_no_ch_locs.info['chs']: ch['loc'][:3] = np.nan - with pytest.warns(RuntimeWarning, match='No EEG channel locations'): + with pytest.warns( + RuntimeWarning, + match='No EEG channel locations found, cannot create joint plot' + ): r.add_evokeds( evokeds=evoked_no_ch_locs, titles=['evoked no chan locs'], - tags=('evoked',), projs=True, n_time_points=1 + tags=('evoked',), projs=False, n_time_points=1 ) assert 'Time course' not in r._content[-1].html assert 'Topographies' not in r._content[-1].html assert evoked.info['projs'] # only then the following test makes sense - assert 'SSP' not in r._content[-1].html + assert 'Projectors' not in r._content[-1].html assert 'Global field power' in r._content[-1].html # Drop locations from Info used for projs @@ -828,7 +829,10 @@ def test_manual_report_2d(tmp_path, invisible_fig): for ch in info_no_ch_locs['chs']: ch['loc'][:3] = np.nan - with pytest.warns(RuntimeWarning, match='No channel locations found'): + with pytest.raises( + ValueError, + match='does not contain.*channel locations' + ): r.add_projs(info=info_no_ch_locs, title='Projs no chan locs') # Drop locations from ICA diff --git a/mne/utils/docs.py b/mne/utils/docs.py index 341d477f311..65d00d7c9cf 100644 --- a/mne/utils/docs.py +++ b/mne/utils/docs.py @@ -2731,6 +2731,18 @@ def _reflow_param_docstring(docstring, has_first_line=True, width=75): (SDR) morph. """ +docdict['section_report'] = """ +section : str | None + The name of the section (or content block) to add the content to. This + feature is useful for grouping multiple related content elements + together under a single, collapsible section. Each content element will + retain its own title and functionality, but not appear separately in the + table of contents. Hence, using sections is a way to declutter the table + of contents, and to easy navigation of the report. + + .. versionadded:: 1.1 +""" + docdict['seed'] = """ seed : None | int | instance of ~numpy.random.RandomState A seed for the NumPy random number generator (RNG). If ``None`` (default), diff --git a/tutorials/intro/70_report.py b/tutorials/intro/70_report.py index cdcef629d16..76dd99e6505 100644 --- a/tutorials/intro/70_report.py +++ b/tutorials/intro/70_report.py @@ -429,6 +429,35 @@ report.save('report_custom_figure.html', overwrite=True) plt.close(fig) +# %% +# Multiple figures can be grouped into a single section via the ``section`` +# parameter. + +fig_1, ax_1 = plt.subplots() +ax_1.plot([1, 2, 3]) + +fig_2, ax_2 = plt.subplots() +ax_2.plot([3, 2, 1]) + +section = 'Section example' + +report = mne.Report(title='Figure section example') +report.add_figure( + fig=fig_1, + title='Figure 1', + section=section, + tags='fig-1' +) +report.add_figure( + fig=fig_2, + title='Figure 2', + section=section, + tags='fig-2' +) +report.save('report_custom_figure_sections.html', overwrite=True) +plt.close(fig_1) +plt.close(fig_2) + # %% # The :meth:`mne.Report.add_figure` method can add multiple figures at once. In # this case, a slider will appear, allowing users to intuitively browse the