Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Generate report with phys2bids outputs #407

Draft
wants to merge 2 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions phys2bids/cli/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,12 @@ def _get_parser():
help='full path to file with info needed to generate '
'participant.tsv file ',
default='')
optional.add_argument('-report', '--report',
dest='make_report',
action='store_true',
help='Generate a report with the data and generated folder structure. '
'Default is False.',
default=False)
optional.add_argument('-debug', '--debug',
dest='debug',
action='store_true',
Expand Down
7 changes: 6 additions & 1 deletion phys2bids/phys2bids.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
from phys2bids import utils, viz, _version, bids
from phys2bids.cli.run import _get_parser
from phys2bids.physio_obj import BlueprintOutput
from phys2bids.reporting.html_report import generate_report
from phys2bids.slice4phys import slice4phys

from . import __version__
Expand Down Expand Up @@ -129,7 +130,8 @@ def print_json(outfile, samp_freq, time_offset, ch_name):
cite_module=True)
def phys2bids(filename, info=False, indir='.', outdir='.', heur_file=None,
sub=None, ses=None, chtrig=0, chsel=None, num_timepoints_expected=None,
tr=None, thr=None, pad=9, ch_name=[], yml='', debug=False, quiet=False):
tr=None, thr=None, pad=9, ch_name=[], yml='', make_report=False,
debug=False, quiet=False):
"""
Run main workflow of phys2bids.

Expand Down Expand Up @@ -438,6 +440,9 @@ def phys2bids(filename, info=False, indir='.', outdir='.', heur_file=None,
os.path.join(conversion_path,
os.path.splitext(os.path.basename(phys_out[key].filename)
)[0]))
# Only generate report if specified by the user
if make_report:
generate_report(conversion_path, logname, phys_out[key])


def _main(argv=None):
Expand Down
1 change: 1 addition & 0 deletions phys2bids/reporting/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Visual reporting tools for inspecting phys2bids workflow outputs."""
Binary file added phys2bids/reporting/assets/apple-icon-180x180.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
102 changes: 102 additions & 0 deletions phys2bids/reporting/assets/main.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
html, body {
margin: 0;
padding: 0;
font-family: 'Lato', sans-serif;
overflow-x: hidden;
overflow-y: scroll;
}
* {
box-sizing: border-box;
}

.header {
background: linear-gradient(90deg, rgba(0,240,141,1) 0%, rgba(0,73,133,1) 100%);
height: 70px;
width: 100%;
position: fixed;
overflow: hidden;
margin: 0;
z-index: 100;
}

.header a, span {
color: white;
text-decoration: none;
font-weight: 700;
}

.header_logo {
display: inline-block;
float: left;
}

.header_logo img{
height: 50px;
top: 0;
left: 0;
padding-top: 15px;
}

.header_links {
top: 0;
left: 0;
padding-top: 25px;
margin-left: 20px;
margin-right: 20px;
float: left;
display: inline-block;
}
.clear {
clear: both;
}

.content {
margin-top: 100px;
display: flex;
width: 100%;
}

.tree {
margin-left: 50px;
margin-right: 50px;
flex: 0.5;
min-width: 300px;
float: left;
}

.tree_text {
margin-top: 10px;
margin-bottom: 70px;
width: 100%;
}

.bk-root {
display: inline-block;
margin-top: 10px;
width: 100%;
}

.bokeh_plots {
margin-left: 50px;
margin-right: 50px;
flex: 1;
min-width: 500px;
float: left;
}

@media screen and (max-width: 600px) {
.content {
flex-wrap: wrap;
}
.tree {
flex-basis: 100%;
}
.bokeh_plots {
flex-basis: 100%;
}
}

.main{
margin-top: 100px;
margin-left: 100px;
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
234 changes: 234 additions & 0 deletions phys2bids/reporting/html_report.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
"""Reporting functionality for phys2bids."""
import sys
from distutils.dir_util import copy_tree
from os.path import join
from pathlib import Path
from string import Template
from bokeh.plotting import figure, ColumnDataSource
from bokeh.embed import components
from bokeh.layouts import gridplot

from phys2bids import _version


def _save_as_html(log_html_path, log_content, qc_html_path):
"""
Save an HTML report out to a file.

Parameters
----------
log_html_path : str
Body for HTML report with embedded figures
log_content: str
String containing the logs generated by phys2bids
qc_html_path : str
Path to the quality check section of the report

Returns
-------
html: HTML code of the report

Outcome
-------
Saves the html file
"""
resource_path = Path(__file__).resolve().parent
head_template_name = 'report_log_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())

html = head_tpl.substitute(version=_version.get_versions()['version'],
log_html_path=log_html_path, log_content=log_content,
qc_html_path=qc_html_path)
return html


def _update_fpage_template(tree_string, bokeh_id, bokeh_js, log_html_path, qc_html_path):
"""
Populate a report with content.

Parameters
----------
tree_string: str
Tree of files in directory.
bokeh_id : str
HTML div created by bokeh.embed.components
bokeh_js : str
Javascript created by bokeh.embed.components
log_html_path : str
Path to the log section of the report
qc_html_path : str
Path to the quality check section of the report

Returns
-------
body : Body for HTML report with embedded figures
"""
resource_path = Path(__file__).resolve().parent

body_template_name = 'report_plots_template.html'
body_template_path = resource_path.joinpath(body_template_name)
with open(str(body_template_path), 'r') as body_file:
body_tpl = Template(body_file.read())
body = body_tpl.substitute(tree=tree_string,
content=bokeh_id,
javascript=bokeh_js,
version=_version.get_versions()['version'],
log_html_path=log_html_path,
qc_html_path=qc_html_path)
return body


def _generate_file_tree(out_dir):
"""
Populate a report with content.

Parameters
----------
outdir : str
Path to the output directory

Returns
-------
tree_string: String with the tree of files in directory
"""
# prefix components:
space = ' '
branch = '│ '
# pointers:
tee = '├── '
last = '└── '

def tree(dir_path: Path, prefix: str = ''):
"""Generate tree structure.

Given a directory Path object
will yield a visual tree structure line by line
with each line prefixed by the same characters

from https://stackoverflow.com/questions/9727673/list-directory-tree-structure-in-python
"""
contents = list(dir_path.iterdir())
# contents each get pointers that are ├── with a final └── :
pointers = [tee] * (len(contents) - 1) + [last]
for pointer, path in zip(pointers, contents):
yield prefix + pointer + path.name
if path.is_dir(): # extend the prefix and recurse:
extension = branch if pointer == tee else space
# i.e. space because last, └── , above so no more |
yield from tree(path, prefix=prefix + extension)

tree_string = ''
for line in tree(Path(out_dir)):
tree_string += line + '<br>'
return tree_string


def _generate_bokeh_plots(phys_in, figsize=(250, 500)):
"""
Plot all the channels for visualizations as linked line plots for dynamic report.

Parameters
----------
phys_in: BlueprintInput object
Object returned by BlueprintInput class
figsize: tuple
Size of the figure expressed as (size_x, size_y),
Default is 250x750px

Outcome
-------
Creates new plot with path specified in outfile.

See Also
--------
https://phys2bids.readthedocs.io/en/latest/howto.html
"""
colors = ['#ff7a3c', '#008eba', '#ff96d3', '#3c376b', '#ffd439']

time = phys_in.timeseries.T[0] # assumes first phys_in.timeseries is time
ch_num = len(phys_in.ch_name)
if ch_num > len(colors):
colors *= 2

downsample = int(phys_in.freq / 100)
plot_list = []
for row, timeser in enumerate(phys_in.timeseries.T[1:]):
# build a data source for each plot, with only the data + index (time)
# for the purpose of reporting, data is downsampled 10x
# doesn't make much of a difference to the naked eye, fine for reports
source = ColumnDataSource(data=dict(
x=time[::downsample],
y=timeser[::downsample]))

i = row + 1

tools = ['wheel_zoom,pan,reset']
q = figure(plot_height=figsize[0], plot_width=figsize[1],
tools=tools,
title=f' Channel {i}: {phys_in.ch_name[i]}',
sizing_mode='stretch_both',
x_range=(0, 100))
q.line('x', 'y', color=colors[i - 1], alpha=0.9, source=source)
q.xaxis.axis_label = 'Time (s)'
# hovertool commented for posterity because I (KB) will be triumphant
# eventually
# q.add_tools(HoverTool(tooltips=[
# (phys_in.ch_name[i], '@y{0.000} ' + phys_in.units[i]),
# ('HELP', '100 :D')
# ], mode='vline'))
plot_list.append([q])
p = gridplot(plot_list, toolbar_location='right',
plot_height=250, plot_width=750,
merge_tools=True)
script, div = components(p)
return script, div


def generate_report(out_dir, log_path, phys_in):
"""
Plot all the channels for visualizations as linked line plots for dynamic report.

Parameters
----------
out_dir : str
File path to a completed phys2bids output directory
log_path: path
Path to the logged output of phys2bids
phys_in: BlueprintInput object
Object returned by BlueprintInput class

Outcome
-------
Creates new plot with path specified in outfile.

See Also
--------
https://phys2bids.readthedocs.io/en/latest/howto.html
"""
# Copy assets into output folder
pkgdir = sys.modules['phys2bids'].__path__[0]
assets_path = join(pkgdir, 'reporting', 'assets')
copy_tree(assets_path, join(out_dir, 'assets'))

# Read log
with open(log_path, 'r') as f:
log_content = f.read()

log_content = log_content.replace('\n', '<br>')
log_html_path = join(out_dir, 'phys2bids_report_log.html')
qc_html_path = join(out_dir, 'phys2bids_report.html')

html = _save_as_html(log_html_path, log_content, qc_html_path)

with open(log_html_path, 'wb') as f:
f.write(html.encode('utf-8'))

# Read in output directory structure & create tree
tree_string = _generate_file_tree(out_dir)
bokeh_js, bokeh_div = _generate_bokeh_plots(phys_in, figsize=(250, 750))
html = _update_fpage_template(tree_string, bokeh_div, bokeh_js, log_html_path, qc_html_path)

with open(qc_html_path, 'wb') as f:
f.write(html.encode('utf-8'))
Loading