"""HTML report generation for dipy_auto pipeline execution.
This module generates detailed HTML reports showing pipeline configuration,
execution graph, timing information, and slice mosaic visualizations of
neuroimaging outputs.
"""
from datetime import datetime
import io # noqa: F401
import os
import re
from dipy.utils.logging import logger
HTML_TEMPLATE = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{title}</title>
<style>
* {{
margin: 0;
padding: 0;
box-sizing: border-box;
}}
body {{
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
font-size: 14px;
line-height: 1.55;
color: #111827;
background: #f5f6fa;
margin: 0;
padding: 0;
}}
/* ── Top bar ─────────────────────────────────────────── */
.topbar {{
position: fixed;
top: 0;
left: 0;
right: 0;
height: 48px;
background: #fff;
border-bottom: 1px solid #e5e7eb;
display: flex;
align-items: center;
padding: 0 20px;
z-index: 200;
gap: 24px;
}}
.topbar-brand {{
display: flex;
align-items: center;
gap: 8px;
font-weight: 700;
font-size: 15px;
color: #111827;
white-space: nowrap;
}}
.topbar-logo {{
width: 28px;
height: 28px;
background: linear-gradient(135deg, #E87722 0%, #D94A38 100%);
border-radius: 7px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 13px;
font-weight: 800;
flex-shrink: 0;
}}
.topbar-nav {{
display: flex;
gap: 4px;
flex: 1;
}}
.topbar-nav a {{
color: #6b7280;
text-decoration: none;
padding: 5px 12px;
border-radius: 6px;
font-size: 13.5px;
font-weight: 500;
transition: background 0.15s, color 0.15s;
}}
.topbar-nav a:hover {{
background: #f3f4f6;
color: #111827;
}}
.topbar-nav a.active {{
background: #E87722;
color: white;
}}
.topbar-meta {{
font-size: 12px;
color: #9ca3af;
white-space: nowrap;
}}
/* ── App shell ───────────────────────────────────────── */
.app {{
padding-top: 48px;
display: flex;
min-height: 100vh;
}}
/* ── Sidebar ─────────────────────────────────────────── */
.sidebar {{
width: 210px;
min-width: 210px;
background: #fff;
border-right: 1px solid #e5e7eb;
position: fixed;
top: 48px;
bottom: 0;
overflow-y: auto;
flex-shrink: 0;
display: flex;
flex-direction: column;
}}
.sidebar-nav {{
padding: 12px 0 24px;
flex: 1;
}}
.sidebar-section-label {{
color: #9ca3af;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.8px;
padding: 14px 16px 4px;
font-weight: 700;
}}
.sidebar-link {{
display: block;
color: #374151;
text-decoration: none;
padding: 6px 16px;
font-size: 13.5px;
transition: background 0.15s, color 0.15s;
border-left: 2px solid transparent;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}}
.sidebar-link:hover {{
background: #f9fafb;
color: #E87722;
border-left-color: #E87722;
}}
.sidebar-sublink {{
padding-left: 28px;
font-size: 13px;
color: #6b7280;
}}
.sidebar-sublink:hover {{
color: #E87722;
}}
/* ── Main content ────────────────────────────────────── */
.main-content {{
flex: 1;
min-width: 0;
margin-left: 210px;
}}
.page-header {{
padding: 28px 36px 22px;
background: #fff;
border-bottom: 1px solid #e5e7eb;
}}
.page-header h1 {{
font-size: 22px;
font-weight: 700;
color: #111827;
margin-bottom: 4px;
padding: 0;
border: none;
}}
.page-header p {{
font-size: 13px;
color: #6b7280;
}}
.info-banner {{
margin: 0 36px 0;
padding: 10px 16px;
background: #fff7ed;
border: 1px solid #fed7aa;
border-radius: 8px;
font-size: 13px;
color: #92400e;
display: flex;
align-items: center;
gap: 8px;
}}
.content {{
padding: 28px 36px 60px;
}}
/* ── Stat cards ──────────────────────────────────────── */
.stat-row {{
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 16px;
margin-bottom: 28px;
}}
.stat-card {{
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 10px;
padding: 18px 20px;
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
}}
.stat-card .stat-label {{
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.7px;
color: #9ca3af;
font-weight: 700;
margin-bottom: 6px;
}}
.stat-card .stat-value {{
font-size: 28px;
font-weight: 700;
color: #111827;
line-height: 1;
}}
/* ── Section headings ────────────────────────────────── */
section {{
margin-bottom: 48px;
}}
h1 {{
font-size: 18px;
font-weight: 700;
color: #111827;
margin-bottom: 16px;
padding-top: 28px;
border: none;
}}
h2 {{
font-size: 15px;
font-weight: 600;
color: #374151;
margin-bottom: 10px;
padding-top: 18px;
}}
h3 {{
font-size: 13.5px;
font-weight: 600;
color: #4b5563;
margin-bottom: 8px;
padding-top: 12px;
}}
/* ── Info cards (config / summary) ───────────────────── */
.info-grid {{
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 14px;
margin: 16px 0;
}}
.info-card {{
background: #fff;
padding: 16px 18px;
border-radius: 10px;
border: 1px solid #e5e7eb;
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
}}
.info-card strong {{
display: block;
color: #9ca3af;
margin-bottom: 5px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.7px;
font-weight: 700;
}}
.info-card p {{
color: #111827;
word-break: break-word;
font-size: 13.5px;
}}
/* ── DAG ─────────────────────────────────────────────── */
.dag-visualization {{
background: #fff;
border: 1px solid #e5e7eb;
padding: 24px;
border-radius: 10px;
overflow-x: auto;
margin: 16px 0;
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
}}
.dag-visualization pre {{
font-family: "SFMono-Regular", "Fira Code", "Consolas", monospace;
font-size: 12.5px;
line-height: 1.8;
color: #374151;
}}
/* ── Table ───────────────────────────────────────────── */
table {{
width: 100%;
border-collapse: collapse;
margin: 16px 0;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 10px;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
}}
thead {{
background: #f9fafb;
border-bottom: 1px solid #e5e7eb;
}}
thead th {{
color: #6b7280;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.7px;
font-weight: 700;
}}
th, td {{
padding: 12px 16px;
text-align: left;
border-bottom: 1px solid #f3f4f6;
}}
tbody tr:last-child td {{
border-bottom: none;
}}
tbody tr:hover {{
background: #fafafa;
}}
/* ── Stage section ───────────────────────────────────── */
.stage-section {{
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 12px;
padding: 22px 24px;
margin: 20px 0;
box-shadow: 0 1px 4px rgba(0,0,0,0.05);
}}
.stage-header {{
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 18px;
padding-bottom: 14px;
border-bottom: 1px solid #f3f4f6;
}}
.stage-title {{
font-size: 15px;
font-weight: 600;
color: #111827;
}}
.stage-time {{
background: #fff7ed;
color: #9a3412;
border: 1px solid #fed7aa;
padding: 4px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
}}
/* ── Viewer / mosaic ─────────────────────────────────── */
.viewer-container {{
margin: 18px 0;
border: 1px solid #e5e7eb;
border-radius: 10px;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
}}
.viewer-header {{
background: #f9fafb;
border-bottom: 1px solid #e5e7eb;
color: #374151;
padding: 10px 16px;
font-weight: 600;
font-size: 13px;
}}
canvas {{
display: block;
width: 100%;
height: 500px;
background: #000;
}}
/* ── Output items ────────────────────────────────────── */
.output-list {{
margin: 14px 0;
}}
.output-item {{
background: #fff;
padding: 13px 16px;
margin: 8px 0;
border-radius: 8px;
border: 1px solid #e5e7eb;
box-shadow: 0 1px 2px rgba(0,0,0,0.04);
}}
.output-item strong {{
color: #9ca3af;
display: block;
margin-bottom: 4px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.6px;
font-weight: 700;
}}
.output-item code {{
background: #f3f4f6;
padding: 2px 7px;
border-radius: 4px;
font-size: 12.5px;
color: #374151;
font-family: "SFMono-Regular", "Fira Code", monospace;
word-break: break-all;
}}
.download-link {{
display: inline-block;
margin-top: 10px;
padding: 6px 14px;
background: #E87722;
color: white;
text-decoration: none;
border-radius: 6px;
font-size: 12.5px;
font-weight: 600;
transition: background 0.2s;
}}
.download-link:hover {{
background: #c9621a;
}}
/* ── Footer ──────────────────────────────────────────── */
footer {{
background: #fff;
border-top: 1px solid #e5e7eb;
color: #9ca3af;
padding: 20px 36px;
text-align: center;
font-size: 12px;
}}
/* ── Status boxes ────────────────────────────────────── */
.error-box {{
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 8px;
padding: 12px 16px;
margin: 12px 0;
color: #991b1b;
font-size: 13.5px;
}}
.success-box {{
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: 8px;
padding: 12px 16px;
margin: 12px 0;
color: #166534;
font-size: 13.5px;
}}
/* ── Methods / boilerplate ───────────────────────────── */
.boilerplate {{
background: #fff;
border: 1px solid #e5e7eb;
padding: 22px 24px;
border-radius: 10px;
margin: 14px 0;
line-height: 1.75;
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
}}
.boilerplate p {{
margin-bottom: 12px;
color: #374151;
font-size: 13.5px;
}}
.citation {{
background: #f9fafb;
border-left: 3px solid #E87722;
border-radius: 0 6px 6px 0;
padding: 13px 16px;
margin: 10px 0;
font-family: "SFMono-Regular", "Fira Code", monospace;
font-size: 12px;
color: #374151;
line-height: 1.7;
}}
/* ── Badges ──────────────────────────────────────────── */
.stage-badge {{
display: inline-block;
padding: 2px 9px;
border-radius: 12px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.2px;
vertical-align: middle;
margin-left: 8px;
}}
.badge-restart {{
background: #eff6ff;
color: #1d4ed8;
border: 1px solid #bfdbfe;
}}
.badge-user-mask {{
background: #f0fdf4;
color: #166534;
border: 1px solid #bbf7d0;
}}
/* ── Profile charts ──────────────────────────────────── */
.profile-charts-grid {{
display: grid;
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
gap: 20px;
margin: 16px 0;
}}
.profile-chart-container {{
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 10px;
padding: 16px;
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
}}
.profile-chart-container canvas {{
height: 240px !important;
background: #fff;
}}
/* ── Badges ──────────────────────────────────────────── */
.stage-badge {{
display: inline-block;
padding: 2px 9px;
border-radius: 12px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.2px;
vertical-align: middle;
margin-left: 8px;
}}
.badge-restart {{
background: #eff6ff;
color: #1d4ed8;
border: 1px solid #bfdbfe;
}}
.badge-user-mask {{
background: #f0fdf4;
color: #166534;
border: 1px solid #bbf7d0;
}}
/* ── Profile charts ──────────────────────────────────── */
.profile-charts-grid {{
display: grid;
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
gap: 20px;
margin: 16px 0;
}}
.profile-chart-container {{
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 10px;
padding: 16px;
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
}}
.profile-chart-container canvas {{
height: 240px !important;
background: #fff;
}}
@media print {{
.topbar, .sidebar {{ display: none; }}
.main-content {{ margin-left: 0; }}
.viewer-container {{ page-break-inside: avoid; }}
}}
</style>
</head>
<body>
<div class="topbar">
<div class="topbar-brand">
<div class="topbar-logo">D</div>
DIPY Auto
</div>
<nav class="topbar-nav">
<a href="#summary" class="active">Summary</a>
<a href="#configuration">Configuration</a>
<a href="#pipeline">Pipeline</a>
<a href="#timing">Timing</a>
<a href="#methods">Methods</a>
</nav>
<div class="topbar-meta">Generated {timestamp}</div>
</div>
<div class="app">
<nav class="sidebar">
<div class="sidebar-nav">
<div class="sidebar-section-label">Report</div>
<a href="#summary" class="sidebar-link">Summary</a>
<a href="#configuration" class="sidebar-link">Configuration</a>
<a href="#pipeline" class="sidebar-link">Pipeline</a>
<a href="#timing" class="sidebar-link">Timing</a>
<div class="sidebar-section-label">Stages</div>
{sidebar_links}
<div class="sidebar-section-label">Info</div>
<a href="#methods" class="sidebar-link">Methods</a>
</div>
</nav>
<div class="main-content">
<div class="page-header">
<h1>{title}</h1>
<p>DIPY Auto Pipeline Report — {title_short}</p>
</div>
<div class="content">
{content}
</div>
<footer>
<p>Generated by DIPY Auto — Diffusion Imaging in Python</p>
</footer>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
<script>
{inline_scripts}
</script>
</body>
</html>
"""
[docs]
def generate_summary_section(config, execution_info):
"""Generate summary section with key information."""
general = config.get("General", {})
io_config = config.get("io", {})
num_stages = len(execution_info.get("stages", []))
total_time = execution_info.get("total_time", 0)
summary_html = """
<section id="summary">
<h1>Summary</h1>
<div class="stat-row">
<div class="stat-card">
<div class="stat-label">Total Stages</div>
<div class="stat-value">{num_stages}</div>
</div>
<div class="stat-card">
<div class="stat-label">Total Duration</div>
<div class="stat-value">{total_time:.0f}
<span style="font-size:14px;font-weight:500;
color:#6b7280;margin-left:4px">s</span>
</div>
</div>
<div class="stat-card">
<div class="stat-label">Status</div>
<div class="stat-value"
style="font-size:20px;color:#16a34a">✓ Done</div>
</div>
</div>
<div class="info-grid">
<div class="info-card">
<strong>Pipeline Name</strong>
<p>{name}</p>
</div>
<div class="info-card">
<strong>Description</strong>
<p>{description}</p>
</div>
<div class="info-card">
<strong>Version</strong>
<p>{version}</p>
</div>
<div class="info-card">
<strong>Author</strong>
<p>{author}</p>
</div>
<div class="info-card">
<strong>Output Directory</strong>
<p><code>{out_dir}</code></p>
</div>
</div>
</section>
""".format(
name=general.get("name", "Unknown"),
description=general.get("description", "No description"),
version=general.get("version", "N/A"),
author=general.get("author", "N/A"),
num_stages=num_stages,
total_time=total_time,
out_dir=io_config.get("out_dir", "."),
)
return summary_html
[docs]
def generate_config_section(config, config_file_path):
"""Generate configuration section with links."""
io_config = config.get("io", {})
config_html = """
<section id="configuration">
<h1>Configuration</h1>
<h2>Input Files</h2>
<div class="output-list">
<div class="output-item">
<strong>DWI Image</strong>
<code>{dwi}</code>
</div>
<div class="output-item">
<strong>B-values</strong>
<code>{bvals}</code>
</div>
<div class="output-item">
<strong>B-vectors</strong>
<code>{bvecs}</code>
</div>
""".format(
dwi=io_config.get("dwi", "Not specified"),
bvals=io_config.get("bvals", "Not specified"),
bvecs=io_config.get("bvecs", "Not specified"),
)
if io_config.get("t1w"):
config_html += """
<div class="output-item">
<strong>T1-weighted Image</strong>
<code>{t1w}</code>
</div>
""".format(t1w=io_config["t1w"])
config_html += f"""
</div>
<h2>Configuration File</h2>
<div class="output-item">
<strong>Config Path</strong>
<code>{config_file_path}</code>
<a href="{config_file_path}" class="download-link">View Config File</a>
</div>
</section>
"""
return config_html
[docs]
def generate_pipeline_section(dag_visualization, execution_order):
"""Generate pipeline DAG and execution order section."""
pipeline_html = """
<section id="pipeline">
<h1>Pipeline Execution Graph</h1>
<p>The following diagram shows the dependency graph and execution order
of pipeline stages:</p>
<div class="dag-visualization">
<pre>{dag}</pre>
</div>
<h2>Execution Order</h2>
<p><strong>{order}</strong></p>
</section>
""".format(
dag=dag_visualization.replace("<", "<").replace(">", ">"),
order=" → ".join(execution_order),
)
return pipeline_html
[docs]
def generate_timing_section(*, stages_info):
"""Generate timing table for all stages.
Parameters
----------
stages_info : list of dict
Each dict must contain ``name``, and optionally ``cli``,
``duration``, ``success``, and ``skipped``. When ``skipped``
is ``"restart"``, ``duration`` may be ``None``.
Returns
-------
str
HTML string for the timing section.
"""
rows = ""
for stage in stages_info:
skipped = stage.get("skipped")
duration = stage.get("duration")
if skipped == "restart":
duration_str = f"{duration:.2f}s" if duration is not None else "N/A"
status_str = (
'<span class="stage-badge badge-restart">Previous Run</span> ✓ Success'
)
elif skipped == "user_mask":
duration_str = "0.00s (skipped)"
status_str = (
'<span class="stage-badge badge-user-mask">User Mask</span> ✓ Skipped'
)
else:
duration_val = duration if duration is not None else 0.0
duration_str = f"{duration_val:.2f}s"
status_str = "✓ Success" if stage.get("success", True) else "✗ Failed"
rows += """
<tr>
<td>{name}</td>
<td>{cli}</td>
<td>{duration}</td>
<td>{status}</td>
</tr>
""".format(
name=stage["name"],
cli=stage.get("cli", "N/A"),
duration=duration_str,
status=status_str,
)
timing_html = f"""
<section id="timing">
<h1>Processing Time</h1>
<table>
<thead>
<tr>
<th>Stage Name</th>
<th>CLI Command</th>
<th>Duration</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{rows}
</tbody>
</table>
</section>
"""
return timing_html
[docs]
def nifti_to_mosaic_svg(*, nifti_path, assets_dir, filename, n_cols=7, n_rows=3):
"""Render a 3-plane mosaic (axial / sagittal / coronal) of a NIfTI volume as SVG.
Slices are drawn on a ``n_rows`` × ``n_cols`` grid. The three rows correspond
to axial, sagittal, and coronal planes respectively. Slice positions are
chosen via a density-projection bounding-box approach (nireports-style) so
that the selected cuts are centred in brain tissue. Each subplot carries
orientation labels (L/R or A/P) at the top corners and a world-space
coordinate label (e.g. ``z=-16``) at the bottom-left.
Parameters
----------
nifti_path : str
Path to the NIfTI file.
assets_dir : str
Directory where the SVG file is saved.
filename : str
Output filename (e.g. ``stage_1_out_fa.svg``).
n_cols : int, optional
Number of columns (cuts per anatomical plane).
n_rows : int, optional
Number of rows. Must be 3 (one per anatomical plane); provided for
API compatibility.
Returns
-------
svg_path : pathlib.Path or None
Path to the saved SVG, or ``None`` if generation failed.
"""
from pathlib import Path
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import nibabel as nib
import numpy as np
try:
img = nib.load(str(nifti_path))
img = nib.as_closest_canonical(img)
affine = img.affine
data = img.get_fdata(dtype=np.float32)
except Exception:
return None
if data.ndim == 4:
data = data[..., data.shape[3] // 2]
if data.ndim != 3:
return None
nonzero = data[data > 0]
if nonzero.size == 0:
return None
vmin, vmax = np.percentile(nonzero, [2, 98])
if vmax - vmin < 1e-6: # binary or near-constant
vmin = 0.0
vmax = float(nonzero.max()) if nonzero.max() > 0 else 1.0
# --- density-projection bounding box (nireports cuts_from_bbox style) ---
low_th = float(np.percentile(nonzero, 5))
if low_th >= float(nonzero.max()): # binary: percentile equals max
low_th = 0.0
mask = data > low_th
nx, ny, nz = mask.shape
density = [
mask.sum(axis=(1, 2)), # axis 0 — sagittal, shape (nx,)
mask.sum(axis=(0, 2)), # axis 1 — coronal, shape (ny,)
mask.sum(axis=(0, 1)), # axis 2 — axial, shape (nz,)
]
thresholds = [
int(np.ceil((ny * nz) * 0.2)), # sagittal: 20 %
int(np.ceil((nx * nz) * 0.1)), # coronal: 10 %
int(np.ceil((nx * ny) * 0.3)), # axial: 30 %
]
indices = {}
for ax_idx, (dens, th) in enumerate(zip(density, thresholds)):
cands = np.argwhere(dens > th).ravel()
if cands.size < 2:
cands = np.argwhere(dens > 0).ravel()
s_min = int(cands[0]) if cands.size else 0
s_max = int(cands[-1]) if cands.size else data.shape[ax_idx] - 1
indices[ax_idx] = np.linspace(s_min, s_max, num=n_cols + 2)[1:-1].astype(int)
# row order: axial (axis 2), sagittal (axis 0), coronal (axis 1)
ROW_AXES = [2, 0, 1]
LEFT_LABEL = {2: "R", 0: "A", 1: "R"}
RIGHT_LABEL = {2: "L", 0: "P", 1: "L"}
AXIS_LETTER = {0: "x", 1: "y", 2: "z"}
fig, axes = plt.subplots(
n_rows,
n_cols,
figsize=(n_cols * 2.2, n_rows * 2.6),
)
fig.patch.set_facecolor("black")
for row, ax_idx in enumerate(ROW_AXES):
letter = AXIS_LETTER[ax_idx]
for col, idx in enumerate(indices[ax_idx]):
ax_obj = axes[row, col]
slc = np.take(data, idx, axis=ax_idx)
ax_obj.imshow(
slc.T,
cmap="gray",
origin="lower",
vmin=vmin,
vmax=vmax,
aspect="equal",
interpolation="nearest",
)
ax_obj.set_axis_off()
# world-space coordinate label
v = np.zeros(4)
v[ax_idx] = idx
v[3] = 1
wcoord = round(float((affine @ v)[ax_idx]))
coord_label = f"{letter}={wcoord}"
# orientation labels (top corners)
ax_obj.text(
0.02,
0.98,
LEFT_LABEL[ax_idx],
color="white",
fontsize=7,
ha="left",
va="top",
transform=ax_obj.transAxes,
)
ax_obj.text(
0.98,
0.98,
RIGHT_LABEL[ax_idx],
color="white",
fontsize=7,
ha="right",
va="top",
transform=ax_obj.transAxes,
)
# coordinate label (bottom-left)
ax_obj.text(
0.02,
0.02,
coord_label,
color="white",
fontsize=7,
ha="left",
va="bottom",
transform=ax_obj.transAxes,
)
fig.tight_layout(pad=0.1)
svg_path = Path(assets_dir) / filename
fig.savefig(
str(svg_path),
format="svg",
bbox_inches="tight",
facecolor="black",
dpi=150,
)
plt.close(fig)
return svg_path
[docs]
def generate_mosaic_viewer(
*, canvas_id, nifti_path, viewer_title, assets_dir, html_dir
):
"""Generate an ``<img>`` tag pointing to a saved SVG mosaic.
Parameters
----------
canvas_id : str
Unique ID used to build the SVG filename.
nifti_path : str
Path to the NIfTI file.
viewer_title : str
Caption shown above the mosaic.
assets_dir : str
Directory where the SVG is saved.
html_dir : str
Directory of the HTML report (for computing relative paths).
Returns
-------
html : str
HTML fragment with the mosaic image.
"""
filename = re.sub(r"[^A-Za-z0-9_-]", "_", canvas_id) + ".svg"
svg_path = nifti_to_mosaic_svg(
nifti_path=nifti_path, assets_dir=assets_dir, filename=filename
)
if svg_path is None:
return ""
rel_path = os.path.relpath(str(svg_path), html_dir)
return (
'<div class="viewer-container">'
f'<div class="viewer-header">{viewer_title}</div>'
f'<img src="{rel_path}" '
'style="width:100%;background:#000;display:block;">'
"</div>"
)
[docs]
def generate_methods_section(stages_info, pipeline_name):
"""Generate methods section with workflow citations.
Parameters
----------
stages_info : list
List of stage information dictionaries.
pipeline_name : str
Name of the pipeline.
"""
from dipy.workflows.cli import cli_flows
methods_html = f"""
<section id="methods">
<h1>Methods</h1>
<div class="boilerplate">
<p>Results included in this report were generated using DIPY Auto
(Diffusion Imaging in Python), a comprehensive pipeline for diffusion
MRI data processing and analysis.</p>
<h2>Pipeline: {pipeline_name}</h2>
<p>The following processing steps were applied:</p>
"""
unique_clis = set()
stage_descriptions = []
for stage in stages_info:
cli_name = stage.get("cli")
stage_name = stage["name"]
if cli_name and cli_name in cli_flows:
unique_clis.add(cli_name)
module_name, class_name = cli_flows[cli_name]
try:
import importlib
module = importlib.import_module(module_name)
workflow_class = getattr(module, class_name)
doc = workflow_class.__doc__ or ""
first_line = doc.strip().split("\n")[0] if doc else cli_name
stage_descriptions.append(
f"<li><strong>{stage_name}</strong>: {first_line} ({cli_name})</li>"
)
except Exception:
stage_descriptions.append(
f"<li><strong>{stage_name}</strong>: {cli_name}</li>"
)
if stage_descriptions:
methods_html += "<ol>" + "\n".join(stage_descriptions) + "</ol>"
methods_html += """
<h2>References</h2>
<p>Please cite the following references when using DIPY Auto:</p>
<div class="citation">
"""
dipy_citation = """Garyfallidis E, Brett M, Amirbekian B, Rokem A, van der Walt S,
Descoteaux M, Nimmo-Smith I, and Dipy Contributors (2014).
DIPY, a library for the analysis of diffusion MRI data.
Frontiers in Neuroinformatics, vol.8, no.8.
https://doi.org/10.3389/fninf.2014.00008"""
methods_html += dipy_citation
methods_html += """
</div>
</div>
</section>
"""
return methods_html
[docs]
def generate_buan_profiles_charts(*, out_dir):
"""Generate Chart.js line plots for BUAN along-tract profiles.
Parameters
----------
out_dir : str
Directory produced by the ``buan_profiles`` stage.
Returns
-------
html : str
HTML fragment containing canvas elements.
js : str
JavaScript snippet initialising Chart.js charts.
"""
from pathlib import Path
import numpy as np
npy_files = sorted(Path(out_dir).glob("*_profile.npy"))
if not npy_files:
return "", ""
bundle_profiles = {}
for f in npy_files:
core = f.stem[:-8] # strip "_profile"
sep = core.rfind("_")
bundle = core[:sep]
metric = core[sep + 1 :]
raw = np.load(f).astype(float)
mean, std = np.nanmean(raw), np.nanstd(raw)
z = (raw - mean) / std if std > 0 else raw - mean
max_abs = np.nanmax(np.abs(z))
normalized = z / max_abs if max_abs > 0 else z
bundle_profiles.setdefault(bundle, {})[metric] = normalized
COLORS = [
"#E87722",
"#3498db",
"#2ecc71",
"#9b59b6",
"#e74c3c",
"#1abc9c",
"#f39c12",
"#e67e22",
]
# Collect ordered unique metrics across all bundles
all_metrics = sorted({m for metrics in bundle_profiles.values() for m in metrics})
metric_color = {m: COLORS[i % len(COLORS)] for i, m in enumerate(all_metrics)}
charts_html_parts = []
js_parts = []
for bundle, metrics in sorted(bundle_profiles.items()):
canvas_id = "buan_" + re.sub(r"[^A-Za-z0-9_]", "_", bundle)
n_points = len(next(iter(metrics.values())))
labels = list(range(n_points))
datasets = []
for metric in all_metrics:
if metric not in metrics:
continue
profile = metrics[metric]
color = metric_color[metric]
data_vals = [
"null" if np.isnan(v) else str(round(float(v), 6)) for v in profile
]
datasets.append(
f'{{"label":"{metric}",'
f'"data":[{",".join(data_vals)}],'
f'"borderColor":"{color}",'
f'"backgroundColor":"{color}22",'
f'"fill":false,"tension":0.3,"pointRadius":0,"borderWidth":2}}'
)
charts_html_parts.append(
f'<div class="profile-chart-container">'
f'<canvas id="{canvas_id}"></canvas>'
f"</div>"
)
js_parts.append(f"""
(function(){{
var ctx=document.getElementById('{canvas_id}').getContext('2d');
var chart=new Chart(ctx,{{
type:'line',
data:{{
labels:{labels},
datasets:[{",".join(datasets)}]
}},
options:{{
responsive:true,
maintainAspectRatio:false,
plugins:{{
title:{{display:true,text:'{bundle}',font:{{size:14,weight:'bold'}}}},
legend:{{display:true,position:'top'}}
}},
scales:{{
x:{{title:{{display:true,text:'% Distance Along Bundle'}}}},
y:{{title:{{display:true,text:'Normalized value (z-score)'}},min:-1,max:1}}
}}
}}
}});
if(typeof buanCharts==='undefined'){{window.buanCharts=[];}}
window.buanCharts.push(chart);
}})();""")
# Build global-toggle checkboxes, one per metric
checkbox_items = []
for metric in all_metrics:
color = metric_color[metric]
cb_id = f"buan_toggle_{re.sub(r'[^A-Za-z0-9_]', '_', metric)}"
checkbox_items.append(
f'<label for="{cb_id}" style="display:inline-flex;align-items:center;'
f'gap:5px;margin-right:14px;cursor:pointer;font-size:0.88em;">'
f'<input type="checkbox" id="{cb_id}" checked '
f'class="buan-metric-toggle" data-metric="{metric}" '
f'style="accent-color:{color};width:15px;height:15px;">'
f'<span style="color:{color};font-weight:600;">{metric}</span>'
f"</label>"
)
toggle_bar = (
'<div style="margin:12px 0 4px;padding:10px 14px;background:#f8f9fa;'
'border-radius:6px;border:1px solid #e0e0e0;">'
'<span style="font-size:0.85em;color:#555;margin-right:12px;'
'font-weight:600;">Show/hide all charts:</span>'
+ "".join(checkbox_items)
+ "</div>"
)
toggle_js = """
(function(){
window.buanCharts = window.buanCharts || [];
document.querySelectorAll('.buan-metric-toggle').forEach(function(cb){
cb.addEventListener('change', function(){
var metric = this.dataset.metric;
var visible = this.checked;
window.buanCharts.forEach(function(chart){
chart.data.datasets.forEach(function(ds, i){
if(ds.label === metric){ chart.setDatasetVisibility(i, visible); }
});
chart.update();
});
});
});
})();"""
html = (
"<h3>Along-tract Profiles</h3>"
+ toggle_bar
+ '<div class="profile-charts-grid">'
+ "".join(charts_html_parts)
+ "</div>"
)
return html, "\n".join(js_parts) + toggle_js
[docs]
def generate_results_section(*, stages_info, html_dir, assets_dir):
"""Generate results section with slice mosaics for each stage.
Parameters
----------
stages_info : list of dict
List of stage information dictionaries. Each dict must contain
``name`` and optionally ``cli``, ``duration``, ``outputs``,
and ``skipped``.
html_dir : str
Directory where the HTML file is saved (for computing relative
paths).
assets_dir : str
Directory where SVG mosaic files are saved.
Returns
-------
tuple of (str, str)
HTML string for the results section and a string of Chart.js
JavaScript snippets to embed (BUAN charts only).
"""
results_html = '<section id="results"><h1>Stage Results</h1>'
buan_scripts = []
for idx, stage in enumerate(stages_info):
stage_name = stage["name"]
cli = stage.get("cli", "Unknown")
duration = stage.get("duration")
outputs = stage.get("outputs", {})
skipped = stage.get("skipped")
if skipped == "restart":
duration_str = f"{duration:.2f}s" if duration is not None else "N/A"
badge_html = '<span class="stage-badge badge-restart">Previous Run</span>'
elif skipped == "user_mask":
duration_str = "Skipped"
badge_html = '<span class="stage-badge badge-user-mask">User Mask</span>'
else:
duration_val = duration if duration is not None else 0.0
duration_str = f"{duration_val:.2f}s"
badge_html = ""
stage_anchor = "stage_" + re.sub(r"[^A-Za-z0-9_-]", "_", stage_name)
results_html += f"""
<div class="stage-section" id="{stage_anchor}">
<div class="stage-header">
<div class="stage-title">Stage {idx + 1}: {stage_name}{badge_html}</div>
<div class="stage-time">{duration_str}</div>
</div>
<p><strong>CLI Command:</strong> <code>{cli}</code></p>
"""
if outputs:
results_html += '<h3>Output Files</h3><div class="output-list">'
for output_param, output_path in outputs.items():
results_html += f"""
<div class="output-item">
<strong>{output_param}</strong>
<code>{output_path}</code>
<a href="{output_path}" class="download-link">Download</a>
</div>
"""
if is_viewable_format(output_path):
viewer_id = f"nv_stage_{idx}_{output_param}"
viewer_html = generate_mosaic_viewer(
canvas_id=viewer_id,
nifti_path=output_path,
viewer_title=f"{output_param}: {os.path.basename(output_path)}",
assets_dir=assets_dir,
html_dir=html_dir,
)
results_html += viewer_html
results_html += "</div>"
else:
results_html += "<p><em>No outputs recorded for this stage.</em></p>"
if stage_name == "buan_profiles":
buan_out = outputs.get("out_dir") or stage.get("params", {}).get("out_dir")
if buan_out and os.path.isdir(buan_out):
charts_html, charts_js = generate_buan_profiles_charts(out_dir=buan_out)
results_html += charts_html
buan_scripts.append(charts_js)
results_html += "</div>"
results_html += "</section>"
return results_html, "\n".join(buan_scripts)
[docs]
def generate_html_report(config, config_file_path, execution_info, output_path):
"""Generate complete HTML report.
Parameters
----------
config : dict
Pipeline configuration dictionary.
config_file_path : str
Path to the configuration file.
execution_info : dict
Execution information including:
- stages: list of stage info dicts
- total_time: total execution time
- dag_visualization: DAG text visualization
- execution_order: list of stage names in execution order
output_path : str
Path where HTML report should be saved.
"""
logger.info(
"Generating HTML report — rendering slice mosaics for each NIfTI "
"output, this may take a few minutes..."
)
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
pipeline_name = config.get("General", {}).get("name", "Pipeline")
title = f"DIPY Auto Pipeline Report - {pipeline_name}"
html_dir = os.path.dirname(os.path.abspath(output_path))
stages = execution_info.get("stages", [])
assets_dir = os.path.join(html_dir, "assets")
os.makedirs(assets_dir, exist_ok=True)
summary = generate_summary_section(config, execution_info)
configuration = generate_config_section(config, config_file_path)
pipeline = generate_pipeline_section(
execution_info.get("dag_visualization", ""),
execution_info.get("execution_order", []),
)
timing = generate_timing_section(stages_info=stages)
results, inline_scripts = generate_results_section(
stages_info=stages,
html_dir=html_dir,
assets_dir=assets_dir,
)
methods = generate_methods_section(stages, pipeline_name)
sidebar_links = generate_sidebar(stages_info=stages)
content = summary + configuration + pipeline + timing + results + methods
html = HTML_TEMPLATE.format(
title=title,
title_short=pipeline_name,
timestamp=timestamp,
content=content,
inline_scripts=inline_scripts,
sidebar_links=sidebar_links,
)
os.makedirs(os.path.dirname(output_path), exist_ok=True)
with open(output_path, "w", encoding="utf-8") as f:
f.write(html)
return output_path