"""Self-contained HTML output viewer for a MEA-NAP pipeline run.
Walks an output folder (the same tree ``output_folders.py`` creates) and
writes a single ``report.html`` that lets you browse it — a folder tree on
the left, an image gallery on the right, with a caption under each plot
explaining what it shows. No server or external JS/CSS needed; opening the
file directly in a browser works, including over ``file://``.
"""
from __future__ import annotations
import json
import re
from pathlib import Path
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".svg"}
# ── Folder descriptions ────────────────────────────────────────────────────
# Keyed by folder name (not full path) — matches MEA-NAP's fixed top-level
# and second-level folder names from output_folders.py / CreateOutputFolders.m.
FOLDER_DESCRIPTIONS: dict[str, str] = {
"1_SpikeDetection": "Step 1 — spike detection. Raw voltage traces are filtered and thresholded/wavelet-detected to find individual spike times per electrode.",
"1A_SpikeDetectedData": "Detected spike times per recording, saved as .npz (one array per electrode per detection method).",
"1B_SpikeDetectionChecks": "Diagnostic plots for step 1, one subfolder per recording — use these to sanity-check that spike detection worked before trusting anything downstream.",
"2_NeuronalActivity": "Step 2 — neuronal activity. Firing rates and burst structure (single-electrode and network-wide) computed from step 1's spike times.",
"2A_IndividualNeuronalAnalysis": "Per-recording firing rate and burst-detection plots.",
"2B_GroupComparisons": "Firing rate / burst metrics compared across recording groups (not yet populated by the Python port — group-level aggregation isn't implemented).",
"3_EdgeThresholdingCheck": "Step 3 — functional connectivity. Diagnostic plots for the STTC computation and significance thresholding (not yet populated by the Python port's step 3).",
"4_NetworkActivity": "Step 4 — network activity. Graph-theoretic metrics (node degree, clustering, efficiency, centrality, ...) computed from step 3's thresholded adjacency matrices.",
"4A_IndividualNetworkAnalysis": "Per-recording, per-lag network plots and connectivity statistics.",
"4B_GroupComparisons": "Network metrics compared across recording groups (not yet populated by the Python port — group-level aggregation isn't implemented).",
"ExperimentMatFiles": "Per-recording adjacency matrices (STTC, raw + significance-thresholded) saved as .npz, one file per recording.",
}
# ── Plot captions ──────────────────────────────────────────────────────────
# (regex matched against the filename only, title template, caption template)
# Named groups in the regex are available for .format() substitution.
#
# Captions are adapted from the official MEA-NAP figure-legend reference
# (docs/meanap-outputs.rst) wherever a description exists there, reworded to
# describe what this Python port's (simpler, single-panel) plot actually
# shows — MATLAB's originals often also render "scaled to whole dataset" and
# "combined" side-by-side variants that this port doesn't produce yet. Where
# no MATLAB documentation exists at all (six step-2 burst heatmaps —
# confirmed via repo search: MEApipeline.m only lists filenames, no prose),
# the caption is original, written to match the sibling group-comparison
# figures' documented semantics for the same metric.
_PLOT_PATTERNS: list[tuple[re.Pattern, str, str]] = [
(
re.compile(r"^1_ExampleTraces\.png$"),
"Example Traces",
"Sample ~60 ms filtered voltage traces from a few electrodes, each "
"centered on a detected spike. Colored markers indicate which "
"detection method caught that spike (e.g. ‘thr4’/‘thr5’ "
"= median-absolute-deviation threshold methods; ‘bior1.5’ = "
"wavelet method). Lets you compare detection methods at the "
"individual-electrode level. (MEA-NAP docs, Step 1B Figure 1)",
),
(
re.compile(r"^2_SpikeFrequencies\.png$"),
"Spike Frequencies",
"Running spike frequency (1-second bins) over the length of the "
"recording, one line per spike-detection method — compares how "
"sensitive each method/parameter combination is over time. "
"(MEA-NAP docs, Step 1B Figure 2)",
),
(
re.compile(r"^3_Waveforms\.png$"),
"Detected Waveforms",
"Overlaid individual spike waveforms (gray) and the mean waveform "
"(black) detected by each method, from one representative "
"electrode. A tight overlay indicates consistent, clean "
"detections; a messy spread suggests noise is being picked up. "
"(MEA-NAP docs, Step 1B Figure 3)",
),
(
re.compile(r"^1_FiringRateByElectrode\.png$"),
"Firing Rate by Electrode",
"Mean firing rate (spikes/second) of every electrode as a scatter "
"+ violin plot, showing the distribution of activity levels "
"across the whole array. (MEA-NAP docs, Step 2A Figure 1)",
),
(
re.compile(r"^2_Heatmap\.png$"),
"Firing Rate Heatmap",
"Mean firing rate (Hz) of each electrode, arranged spatially to "
"match the physical MEA layout — bright spots mark the most "
"active regions of the culture. (MEA-NAP docs, Step 2A Figure 2 — "
"MATLAB additionally scales a second panel to the whole dataset; "
"this port renders the single recording-scaled heatmap.)",
),
(
re.compile(r"^3_Raster\.png$"),
"Raster Plot",
"Spike raster (each row an electrode, each point a spike) across "
"the whole recording, with a firing-rate histogram alongside — "
"the main plot for spotting synchronous or bursting activity by "
"eye. (MEA-NAP docs, Step 2A Figure 3 — MATLAB additionally shows "
"a second raster scaled to the whole dataset; this port renders "
"the single recording-scaled raster.)",
),
(
re.compile(r"^3_BurstRate_heatmap\.png$"),
"Burst Rate Heatmap",
"Spatial heatmap of single-electrode burst rate (bursts per "
"minute), using the same MEA-layout convention as the firing-rate "
"heatmap.",
),
(
re.compile(r"^4_BurstDur_heatmap\.png$"),
"Burst Duration Heatmap",
"Spatial heatmap of mean single-electrode burst duration (ms).",
),
(
re.compile(r"^5_FractSpikesInBursts_heatmap\.png$"),
"Fraction of Spikes in Bursts",
"Spatial heatmap of what fraction of each electrode's spikes "
"occur inside a detected burst, versus isolated/tonic firing.",
),
(
re.compile(r"^6_ISIwithinBurst_heatmap\.png$"),
"ISI Within Burst",
"Spatial heatmap of the mean inter-spike interval (ms) between "
"spikes within the same burst — smaller values mean tighter, "
"more intense bursts.",
),
(
re.compile(r"^7_ISIoutsideBurst_heatmap\.png$"),
"ISI Outside Burst",
"Spatial heatmap of the mean inter-spike interval (ms) between "
"spikes outside of bursts (baseline/tonic firing).",
),
(
re.compile(r"^8_BurstDetectionInfo\.png$"),
"Burst Detection Overview",
"Full raster vs. a raster restricted to spikes inside detected "
"network bursts, plus an inter-spike-interval distribution — the "
"main check that network burst detection is picking out genuine "
"synchronous events rather than false positives.",
),
(
re.compile(r"^1_adjM(?P<lag>\d+)msConnectivityStats\.png$"),
"Connectivity Stats ({lag} ms lag)",
"Adjacency matrix heatmap of pairwise STTC values, plus bar "
"charts of the max/mean correlation and histograms of node "
"degree, node strength, and significant edge weight — at a "
"{lag} ms STTC lag. The main check that functional connectivity "
"was computed sensibly before deriving network metrics from it. "
"(MEA-NAP docs, Step 4A Figure 1)",
),
(
re.compile(r"^2_MEA_NetworkPlot\.png$"),
"Spatial Network Plot",
"Functional connectivity network drawn at the electrodes' real "
"spatial layout. Node size = node degree (number of significant "
"connections); edges = significant functional connections; edge "
"thickness = connection strength (STTC weight). (MEA-NAP docs, "
"Step 4A Figure 2 — MATLAB additionally produces ‘scaled’ "
"and ‘combined’ variants normalized across the whole "
"dataset; this port renders the single per-recording version.)",
),
(
re.compile(r"^3_MEA_NetworkPlotNodedegreeBetweennesscentrality\.png$"),
"Spatial Network Plot — Betweenness Centrality",
"Same spatial network layout as the base network plot, but node "
"color now encodes betweenness centrality — the proportion of "
"shortest paths between any two other nodes that pass through "
"this node. Highlights which electrodes act as the network's "
"relay hubs. (MEA-NAP docs, Step 4A Figure 3)",
),
(
re.compile(r"^4_MEA_NetworkPlotNodedegreeParticipationcoefficient\.png$"),
"Spatial Network Plot — Participation Coefficient",
"Same spatial network layout as the base network plot, but node "
"color now encodes participation coefficient (normalized) — how "
"spread a node's connections are across different network modules. "
"Values near 0 mean the node's edges stay within its own module; "
"values near 1 mean they're evenly spread across modules. Module "
"assignment is stochastic (consensus clustering) — expect "
"run-to-run variation. (MEA-NAP docs, Step 4A Figure 4)",
),
(
re.compile(r"^5_MEA_NetworkPlotNodestrengthLocalefficiency\.png$"),
"Spatial Network Plot — Local Efficiency",
"Same spatial network layout, but node size now encodes node "
"strength (sum of edge weights) instead of node degree — the one "
"plot in this set that sizes by strength rather than degree, "
"matching MATLAB exactly. Node color encodes local efficiency: how "
"efficiently a node's immediate neighbors could still exchange "
"information if that node were removed — a measure of local "
"network resilience/redundancy around each electrode. (MEA-NAP "
"docs, Step 4A Figure 5)",
),
(
re.compile(r"^9_adjM(?P<lag>\d+)msNodeCartography\.png$"),
"Node Cartography ({lag} ms lag)",
"Each node plotted by normalized participation coefficient (x — how "
"spread its connections are across modules) vs. within-module "
"degree z-score (y — how connected it is within its own module), "
"colored by role: peripheral node, non-hub connector, non-hub "
"kinless node, provincial hub, connector hub, or kinless hub. "
"Boundary lines are fixed thresholds from Params. Module assignment "
"and the participation-coefficient normalization are both "
"stochastic — expect run-to-run variation. (MEA-NAP docs, Step 4A "
"Figure 9)",
),
]
_DATA_FILE_DESCRIPTIONS: list[tuple[re.Pattern, str]] = [
(re.compile(r".*_spikes\.npz$"), "Detected spike times (per electrode, per detection method) and metadata, in NumPy .npz format."),
(re.compile(r".*_adjM\.npz$"), "STTC adjacency matrices for this recording — one raw + one significance-thresholded array per lag value."),
(re.compile(r"^ephys_results\.json$"), "All step 2 (firing rate + burst) metrics for every recording, in one JSON file."),
(re.compile(r"^netmet_results\.json$"), "All step 4 (network) metrics for every recording and lag, in one JSON file."),
]
[docs]
def describe_plot(filename: str) -> tuple[str, str] | None:
"""Returns (title, caption) for a known plot filename, else None."""
for pattern, title_tmpl, caption_tmpl in _PLOT_PATTERNS:
m = pattern.match(filename)
if m:
groups = m.groupdict()
return title_tmpl.format(**groups), caption_tmpl.format(**groups)
return None
[docs]
def describe_data_file(filename: str) -> str | None:
for pattern, desc in _DATA_FILE_DESCRIPTIONS:
if pattern.match(filename):
return desc
return None
[docs]
def describe_folder(name: str) -> str | None:
return FOLDER_DESCRIPTIONS.get(name)
# ── Tree building ───────────────────────────────────────────────────────────
def _build_tree(dir_path: Path, root: Path) -> dict:
children = []
try:
entries = sorted(dir_path.iterdir(), key=lambda p: (p.is_file(), p.name))
except PermissionError:
entries = []
for entry in entries:
if entry.name.startswith("."):
continue
if entry.is_dir():
child = _build_tree(entry, root)
if child["children"] or FOLDER_DESCRIPTIONS.get(entry.name):
children.append(child)
elif entry.suffix.lower() in IMAGE_EXTENSIONS:
described = describe_plot(entry.name)
title, caption = described if described else (entry.stem, "")
children.append({
"type": "image",
"name": entry.name,
"path": str(entry.relative_to(root)).replace("\\", "/"),
"title": title,
"caption": caption,
})
elif entry.suffix.lower() in (".npz", ".json", ".csv", ".mat"):
children.append({
"type": "file",
"name": entry.name,
"path": str(entry.relative_to(root)).replace("\\", "/"),
"caption": describe_data_file(entry.name) or "",
})
return {
"type": "folder",
"name": dir_path.name,
"path": str(dir_path.relative_to(root)).replace("\\", "/") if dir_path != root else "",
"description": describe_folder(dir_path.name) or "",
"children": children,
}
[docs]
def generate_report(output_root: Path | str, out_path: Path | str | None = None) -> Path:
"""Build ``report.html`` for a MEA-NAP output folder. Returns its path."""
output_root = Path(output_root)
out_path = Path(out_path) if out_path else output_root / "report.html"
tree = _build_tree(output_root, output_root)
tree["name"] = output_root.name
html = _HTML_TEMPLATE.replace("__TREE_JSON__", json.dumps(tree))
html = html.replace("__TITLE__", f"MEA-NAP Output Report — {output_root.name}")
out_path.write_text(html)
return out_path
_HTML_TEMPLATE = r"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>__TITLE__</title>
<style>
:root {
--bg: #ffffff; --sidebar-bg: #f6f7f9; --border: #e2e4e8;
--text: #1f2328; --muted: #6b7280; --accent: #2563eb; --card-bg: #ffffff;
}
* { box-sizing: border-box; }
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
color: var(--text); background: var(--bg); }
#layout { display: flex; height: 100vh; }
#sidebar { width: 320px; flex-shrink: 0; background: var(--sidebar-bg); border-right: 1px solid var(--border);
overflow-y: auto; padding: 12px 8px; }
#sidebar h1 { font-size: 14px; padding: 4px 8px 12px; margin: 0; color: var(--text); word-break: break-word; }
#main { flex: 1; overflow-y: auto; padding: 24px 32px; }
ul.tree { list-style: none; margin: 0; padding-left: 14px; }
ul.tree.root { padding-left: 0; }
.tree li { margin: 1px 0; }
.node-label { display: flex; align-items: center; gap: 5px; padding: 4px 6px; border-radius: 6px;
cursor: pointer; font-size: 13px; user-select: none; white-space: nowrap; }
.node-label:hover { background: #eceef2; }
.node-label.selected { background: var(--accent); color: white; }
.node-label .caret { width: 12px; display: inline-block; color: var(--muted); font-size: 10px; }
.node-label.selected .caret { color: white; }
.node-label .icon { width: 16px; text-align: center; }
.count { color: var(--muted); font-size: 11px; margin-left: auto; padding-left: 8px; }
.node-label.selected .count { color: #dbeafe; }
#breadcrumb { font-size: 13px; color: var(--muted); margin-bottom: 6px; }
#folder-desc { font-size: 14px; color: var(--muted); margin: 0 0 20px; padding: 12px 16px;
background: #f6f7f9; border-radius: 8px; border-left: 3px solid var(--accent); max-width: 900px; }
h2#main-title { margin: 0 0 4px; font-size: 20px; }
.gallery { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 20px; }
.card { background: var(--card-bg); border: 1px solid var(--border); border-radius: 10px; overflow: hidden;
display: flex; flex-direction: column; }
.card img { width: 100%; display: block; cursor: zoom-in; background: #fafafa; border-bottom: 1px solid var(--border); }
.card .card-body { padding: 10px 14px 14px; }
.card .card-title { font-weight: 600; font-size: 13.5px; margin: 0 0 4px; }
.card .card-caption { font-size: 12.5px; color: var(--muted); line-height: 1.45; margin: 0; }
.filelist { font-size: 13px; }
.filelist li { padding: 6px 0; border-bottom: 1px solid var(--border); }
.filelist a { color: var(--accent); text-decoration: none; font-family: ui-monospace, monospace; font-size: 12.5px; }
.filelist .file-caption { color: var(--muted); font-size: 12px; margin-top: 2px; }
#lightbox { position: fixed; inset: 0; background: rgba(0,0,0,0.85); display: none;
align-items: center; justify-content: center; z-index: 100; cursor: zoom-out; flex-direction: column; }
#lightbox.open { display: flex; }
#lightbox img { max-width: 92vw; max-height: 82vh; box-shadow: 0 10px 40px rgba(0,0,0,0.5); }
#lightbox .lb-caption { color: #eee; max-width: 700px; text-align: center; margin-top: 16px; font-size: 14px; }
.empty { color: var(--muted); font-size: 14px; }
</style>
</head>
<body>
<div id="layout">
<div id="sidebar">
<h1>__TITLE__</h1>
<ul class="tree root" id="tree-root"></ul>
</div>
<div id="main">
<div id="breadcrumb"></div>
<h2 id="main-title">Select a folder</h2>
<p id="folder-desc" style="display:none;"></p>
<div id="content"></div>
</div>
</div>
<div id="lightbox">
<img id="lightbox-img" src="">
<div class="lb-caption" id="lightbox-caption"></div>
</div>
<script>
const TREE = __TREE_JSON__;
function countImages(node) {
if (node.type !== "folder") return node.type === "image" ? 1 : 0;
return node.children.reduce((sum, c) => sum + countImages(c), 0);
}
function iconFor(node) {
if (node.type === "folder") return "\u{1F4C1}";
if (node.type === "image") return "\u{1F5BC}";
return "\u{1F4C4}";
}
const treeRoot = document.getElementById("tree-root");
const mainTitle = document.getElementById("main-title");
const folderDesc = document.getElementById("folder-desc");
const breadcrumb = document.getElementById("breadcrumb");
const content = document.getElementById("content");
const NODE_REGISTRY = {}; // node.path -> {node, label, childUl}
function buildTreeDOM(node, container, path) {
const li = document.createElement("li");
const label = document.createElement("div");
label.className = "node-label";
const caret = document.createElement("span");
caret.className = "caret";
const hasChildren = node.type === "folder" && node.children.some(c => c.type === "folder");
caret.textContent = hasChildren ? "▶" : "";
label.appendChild(caret);
const icon = document.createElement("span");
icon.className = "icon";
icon.textContent = iconFor(node);
label.appendChild(icon);
const text = document.createElement("span");
text.textContent = node.name;
label.appendChild(text);
if (node.type === "folder") {
const n = countImages(node);
if (n > 0) {
const count = document.createElement("span");
count.className = "count";
count.textContent = n;
label.appendChild(count);
}
}
li.appendChild(label);
container.appendChild(li);
let childUl = null;
if (node.type === "folder") {
childUl = document.createElement("ul");
childUl.className = "tree";
childUl.style.display = "none";
for (const child of node.children) {
if (child.type === "folder") buildTreeDOM(child, childUl, path.concat(node.name));
}
li.appendChild(childUl);
}
function setOpen(open) {
if (!childUl || !hasChildren) return;
childUl.style.display = open ? "block" : "none";
caret.textContent = open ? "▼" : "▶";
}
label.addEventListener("click", (e) => {
e.stopPropagation();
if (node.type === "folder") {
setOpen(childUl.style.display === "none");
selectFolder(node, path.concat(node.name), label);
history.replaceState(null, "", "#" + encodeURIComponent(node.path));
}
});
if (node.type === "folder") {
NODE_REGISTRY[node.path] = { node, label, path: path.concat(node.name), setOpen };
}
return li;
}
let selectedLabel = null;
function selectFolder(node, path, labelEl) {
if (selectedLabel) selectedLabel.classList.remove("selected");
labelEl.classList.add("selected");
selectedLabel = labelEl;
breadcrumb.textContent = path.join(" / ");
mainTitle.textContent = node.name;
if (node.description) {
folderDesc.textContent = node.description;
folderDesc.style.display = "block";
} else {
folderDesc.style.display = "none";
}
content.innerHTML = "";
const images = node.children.filter(c => c.type === "image");
const files = node.children.filter(c => c.type === "file");
const subfolders = node.children.filter(c => c.type === "folder");
if (images.length === 0 && files.length === 0 && subfolders.length > 0) {
const p = document.createElement("p");
p.className = "empty";
p.textContent = "No plots directly in this folder — expand it in the sidebar to browse subfolders.";
content.appendChild(p);
}
if (images.length > 0) {
const gallery = document.createElement("div");
gallery.className = "gallery";
for (const img of images) {
const card = document.createElement("div");
card.className = "card";
const imEl = document.createElement("img");
imEl.src = img.path;
imEl.loading = "lazy";
imEl.addEventListener("click", () => openLightbox(img));
card.appendChild(imEl);
const body = document.createElement("div");
body.className = "card-body";
const title = document.createElement("p");
title.className = "card-title";
title.textContent = img.title;
body.appendChild(title);
if (img.caption) {
const cap = document.createElement("p");
cap.className = "card-caption";
cap.textContent = img.caption;
body.appendChild(cap);
}
card.appendChild(body);
gallery.appendChild(card);
}
content.appendChild(gallery);
}
if (files.length > 0) {
const h3 = document.createElement("h3");
h3.textContent = "Data files";
h3.style.fontSize = "14px";
h3.style.marginTop = images.length ? "28px" : "0";
content.appendChild(h3);
const ul = document.createElement("ul");
ul.className = "filelist";
for (const f of files) {
const li = document.createElement("li");
const a = document.createElement("a");
a.href = f.path;
a.textContent = f.name;
li.appendChild(a);
if (f.caption) {
const cap = document.createElement("div");
cap.className = "file-caption";
cap.textContent = f.caption;
li.appendChild(cap);
}
ul.appendChild(li);
}
content.appendChild(ul);
}
}
const lightbox = document.getElementById("lightbox");
const lightboxImg = document.getElementById("lightbox-img");
const lightboxCaption = document.getElementById("lightbox-caption");
function openLightbox(img) {
lightboxImg.src = img.path;
lightboxCaption.textContent = img.title + (img.caption ? " — " + img.caption : "");
lightbox.classList.add("open");
}
lightbox.addEventListener("click", () => lightbox.classList.remove("open"));
document.addEventListener("keydown", (e) => { if (e.key === "Escape") lightbox.classList.remove("open"); });
buildTreeDOM(TREE, treeRoot, []);
function openHashPath() {
const target = decodeURIComponent(location.hash.replace(/^#/, ""));
if (target && NODE_REGISTRY[target]) {
// Expand every ancestor folder (including the root), then select the target.
if (NODE_REGISTRY[""]) NODE_REGISTRY[""].setOpen(true);
const parts = target.split("/");
for (let i = 1; i <= parts.length; i++) {
const ancestorPath = parts.slice(0, i).join("/");
const entry = NODE_REGISTRY[ancestorPath];
if (entry) entry.setOpen(true);
}
const entry = NODE_REGISTRY[target];
entry.label.scrollIntoView({ block: "center" });
selectFolder(entry.node, entry.path, entry.label);
return true;
}
return false;
}
// Deep-link support: opening report.html#Some/Sub/Folder auto-navigates
// there (path segments match each folder's location relative to the report
// root, joined by "/" — matches the "path" field embedded in TREE).
if (!openHashPath() && treeRoot.firstChild) {
treeRoot.firstChild.querySelector(".node-label").click();
}
window.addEventListener("hashchange", openHashPath);
</script>
</body>
</html>
"""