diff --git a/autoafids/workflow/Snakefile b/autoafids/workflow/Snakefile
index 6427d08..2abef3e 100644
--- a/autoafids/workflow/Snakefile
+++ b/autoafids/workflow/Snakefile
@@ -382,6 +382,13 @@ rule all:
)
if config["LEAD_DBS_DIR"] or config["FMRIPREP_DIR"]
else [],
+ regqc_summary=[
+ os.path.join(
+ root, "dataset", "regqc", "dataset_desc-reg_qc_summary.html"
+ )
+ ]
+ if config["LEAD_DBS_DIR"] or config["FMRIPREP_DIR"]
+ else [],
afidspred=inputs[config["modality"]].expand(
bids(
root=root,
diff --git a/autoafids/workflow/envs/regqc.yaml b/autoafids/workflow/envs/regqc.yaml
index 48accc1..317a379 100644
--- a/autoafids/workflow/envs/regqc.yaml
+++ b/autoafids/workflow/envs/regqc.yaml
@@ -2,7 +2,7 @@
name: regqc
channels: [khanlab,conda-forge, defaults]
dependencies:
- - python=3.9
+ - python=3.10
- plotly
- simpleitk
- scipy
diff --git a/autoafids/workflow/rules/regqc.smk b/autoafids/workflow/rules/regqc.smk
index fe6074e..22ee15f 100644
--- a/autoafids/workflow/rules/regqc.smk
+++ b/autoafids/workflow/rules/regqc.smk
@@ -183,3 +183,44 @@ rule regqc:
"../envs/regqc.yaml"
script:
"../scripts/regqc.py"
+
+
+rule regqc_summary:
+ input:
+ csvs=inputs[config["modality"]].expand(
+ bids(
+ root=root,
+ datatype="regqc",
+ desc="reg",
+ suffix="qc.csv",
+ **inputs[config["modality"]].wildcards,
+ )
+ ),
+ htmls=inputs[config["modality"]].expand(
+ bids(
+ root=root,
+ datatype="regqc",
+ desc="reg",
+ suffix="qc.html",
+ **inputs[config["modality"]].wildcards,
+ )
+ ),
+ fcsvs=inputs[config["modality"]].expand(
+ bids(
+ root=root,
+ datatype="regqc",
+ desc="reg",
+ suffix="afids.fcsv",
+ **inputs[config["modality"]].wildcards,
+ )
+ ),
+ output:
+ summary_html=os.path.join(
+ root, "dataset", "regqc", "dataset_desc-reg_qc_summary.html"
+ ),
+ params:
+ gt_fcsv=lambda wildcards: get_ref_paths()[1],
+ conda:
+ "../envs/regqc.yaml"
+ script:
+ "../scripts/regqc_summary.py"
diff --git a/autoafids/workflow/scripts/regqc_summary.py b/autoafids/workflow/scripts/regqc_summary.py
new file mode 100644
index 0000000..b842b99
--- /dev/null
+++ b/autoafids/workflow/scripts/regqc_summary.py
@@ -0,0 +1,974 @@
+# === DATASET-LEVEL REGISTRATION QC SUMMARY ===
+"""
+Aggregates per-subject regqc CSVs into a single interactive HTML report.
+
+Columns expected in each CSV:
+ AFID, dx (mm), dy (mm), dz (mm), ED (mm)
+
+Output: one self-contained HTML file with:
+ - Dataset stats tiles
+ - Subject summary table with hyperlinks to individual reports
+ - Heatmap: subjects x AFIDs coloured by ED
+ - Boxplot: ED distribution per AFID across subjects
+ - Bar chart: mean ED per subject (sorted, worst first)
+ - 3D scatter: all subjects' AFIDs in MNI space vs ground truth
+"""
+
+import os
+import re
+from pathlib import Path
+
+import numpy as np
+import pandas as pd
+import plotly.graph_objects as go
+import plotly.io as pio
+from jinja2 import Template
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+def _parse_bids_entities(filename: str) -> dict:
+ """Extract BIDS key-value entities from a filename."""
+ entities = {}
+ for match in re.finditer(r"([a-zA-Z]+)-([a-zA-Z0-9]+)", filename):
+ entities[match.group(1)] = match.group(2)
+ return entities
+
+
+def _subject_label(entities: dict) -> str:
+ """Build a compact human-readable subject label from BIDS entities."""
+ parts = []
+ for key in ("sub", "ses", "acq", "run"):
+ if key in entities:
+ parts.append(f"{key}-{entities[key]}")
+ return "_".join(parts) if parts else "unknown"
+
+
+def load_all_csvs(csv_paths: list, html_paths: list) -> pd.DataFrame:
+ """
+ Read every per-subject CSV and concatenate into a long-form DataFrame.
+
+ Returns a DataFrame with columns:
+ subject_label, AFID, dx (mm), dy (mm), dz (mm), ED (mm),
+ html_relpath (relative path to per-subject HTML)
+ """
+ records = []
+ # Build a mapping from subject_label → html_path
+ html_map = {}
+ for hp in html_paths:
+ ents = _parse_bids_entities(Path(hp).name)
+ label = _subject_label(ents)
+ html_map[label] = hp
+
+ for cp in csv_paths:
+ ents = _parse_bids_entities(Path(cp).name)
+ label = _subject_label(ents)
+ try:
+ df = pd.read_csv(cp)
+ except Exception:
+ continue
+ df["subject_label"] = label
+ df["html_path"] = html_map.get(label, "")
+ records.append(df)
+
+ if not records:
+ raise ValueError("No valid regqc CSVs found.")
+ return pd.concat(records, ignore_index=True)
+
+
+def load_fcsv(path: str) -> tuple:
+ """Load a Slicer FCSV file (skip 2 header lines).
+
+ Returns
+ -------
+ coords : np.ndarray of shape (N, 3)
+ labels : list of AFID labels (ints or strings)
+ """
+ df = pd.read_csv(path, skiprows=2)
+ coords = df[["x", "y", "z"]].to_numpy(dtype=float)
+ labels = df["label"].tolist()
+ return coords, labels
+
+
+def load_all_fcsvs(
+ fcsv_paths: list,
+) -> list:
+ """Load all per-subject FCSVs.
+
+ Returns list of dicts: {subject_label, coords, labels}
+ """
+ result = []
+ for fp in fcsv_paths:
+ ents = _parse_bids_entities(Path(fp).name)
+ label = _subject_label(ents)
+ try:
+ coords, afid_labels = load_fcsv(fp)
+ except Exception:
+ continue
+ result.append({
+ "subject_label": label,
+ "coords": coords,
+ "labels": afid_labels,
+ })
+ return result
+
+
+def compute_error_decomposition(
+ subject_fcsvs: list,
+ gt_coords: np.ndarray,
+ gt_labels: list,
+) -> pd.DataFrame:
+ """Decompose per-AFID error into systematic bias and random scatter.
+
+ For each AFID, across all subjects:
+ - Signed error vector = subject_coord - gt_coord
+ - Bias = mean signed error vector (systematic component)
+ - Scatter = std of signed error vectors (random component)
+ - Bias magnitude |bias|, scatter magnitude (RMS of stds)
+ - Bias-to-total ratio = |bias| / (|bias| + scatter_rms)
+
+ Parameters
+ ----------
+ subject_fcsvs : list of dicts with {subject_label, coords (N,3), labels}
+ gt_coords : (N, 3) ground-truth MNI coordinates
+ gt_labels : list of AFID labels
+
+ Returns
+ -------
+ DataFrame with one row per AFID and columns:
+ label, bias_x, bias_y, bias_z, bias_mag,
+ scatter_x, scatter_y, scatter_z, scatter_rms,
+ total_error, bias_ratio, n_subjects,
+ gt_x, gt_y, gt_z
+ """
+ n_afids = len(gt_labels)
+ # Collect signed error vectors: shape (n_subjects, n_afids, 3)
+ all_errors = []
+ for sub in subject_fcsvs:
+ if sub["coords"].shape[0] != n_afids:
+ continue # skip if AFID count mismatch
+ signed_err = sub["coords"] - gt_coords # (N, 3)
+ all_errors.append(signed_err)
+
+ if not all_errors:
+ return pd.DataFrame()
+
+ errors = np.stack(all_errors, axis=0) # (S, N, 3)
+
+ # Per-AFID statistics
+ bias = errors.mean(axis=0) # (N, 3) — systematic
+ scatter = errors.std(axis=0, ddof=1) if errors.shape[0] > 1 \
+ else np.zeros_like(bias) # (N, 3) — random
+
+ bias_mag = np.linalg.norm(bias, axis=1) # (N,)
+ scatter_rms = np.sqrt((scatter ** 2).mean(axis=1)) # (N,)
+ total_error = np.linalg.norm(errors, axis=2).mean(axis=0) # (N,)
+ bias_ratio = bias_mag / (bias_mag + scatter_rms + 1e-9)
+
+ return pd.DataFrame({
+ "label": gt_labels,
+ "bias_x": bias[:, 0],
+ "bias_y": bias[:, 1],
+ "bias_z": bias[:, 2],
+ "bias_mag": bias_mag,
+ "scatter_x": scatter[:, 0],
+ "scatter_y": scatter[:, 1],
+ "scatter_z": scatter[:, 2],
+ "scatter_rms": scatter_rms,
+ "total_error": total_error,
+ "bias_ratio": bias_ratio,
+ "n_subjects": errors.shape[0],
+ "gt_x": gt_coords[:, 0],
+ "gt_y": gt_coords[:, 1],
+ "gt_z": gt_coords[:, 2],
+ })
+
+
+# ---------------------------------------------------------------------------
+# Plotly chart builders
+# ---------------------------------------------------------------------------
+
+def make_heatmap(long_df: pd.DataFrame) -> str:
+ """
+ Subjects (rows) x AFIDs (cols), colour = ED (mm).
+ Rows sorted by mean ED descending (worst on top).
+ """
+ pivot = long_df.pivot_table(
+ index="subject_label", columns="AFID", values="ED (mm)"
+ )
+ row_order = pivot.mean(axis=1).sort_values(ascending=False).index
+ pivot = pivot.loc[row_order]
+
+ fig = go.Figure(data=go.Heatmap(
+ z=pivot.values,
+ x=[f"AFID {c}" for c in pivot.columns],
+ y=list(pivot.index),
+ colorscale=[[0.0, "rgb(255,255,255)"], [1.0, "rgb(220,0,0)"]],
+ colorbar=dict(title="ED (mm)"),
+ hovertemplate=(
+ "%{y}
AFID %{x}
ED = %{z:.2f} mm
Mean ED = %{x:.2f} mm
x=%{x:.1f} y=%{y:.1f} z=%{z:.1f}"
+ "
x=%{x:.1f} y=%{y:.1f} z=%{z:.1f}"
+ "
"
+ f"Bias: {row.bias_mag:.2f} mm
"
+ f"Scatter: {row.scatter_rms:.2f} mm
"
+ f"Ratio: {row.bias_ratio:.0%} systematic"
+ for _, row in df.iterrows()
+ ],
+ hoverinfo="text",
+ ))
+
+ # Endpoint markers (bias tip) for error lines
+ tip_x = df["gt_x"] + df["bias_x"]
+ tip_y = df["gt_y"] + df["bias_y"]
+ tip_z = df["gt_z"] + df["bias_z"]
+ fig.add_trace(go.Scatter3d(
+ x=tip_x, y=tip_y, z=tip_z,
+ mode="markers",
+ name="Bias Endpoint",
+ marker=dict(size=2, color="red", opacity=0.5),
+ hoverinfo="skip",
+ showlegend=False,
+ ))
+
+ fig.update_layout(
+ scene=dict(
+ xaxis_title="X (mm)", yaxis_title="Y (mm)", zaxis_title="Z (mm)",
+ bgcolor="rgb(250,250,250)",
+ aspectmode="data",
+ ),
+ height=700, width=950,
+ margin=dict(t=50, b=30, l=20, r=20),
+ template="plotly_white",
+ title="Systematic Bias Vectors at Each AFID",
+ legend=dict(x=0.01, y=0.99),
+ )
+ return pio.to_html(fig, full_html=False, include_plotlyjs=False)
+
+
+def make_decomposition_bar(decomp_df: pd.DataFrame) -> str:
+ """Grouped bar chart: bias magnitude vs scatter (RMS) per AFID."""
+ df = decomp_df.sort_values("label")
+ labels = [f"AFID {i}" for i in df["label"]]
+
+ fig = go.Figure()
+ fig.add_trace(go.Bar(
+ x=labels, y=df["bias_mag"],
+ name="Systematic Bias |μ|",
+ marker_color="rgb(215,48,39)",
+ hovertemplate="%{x}
Bias = %{y:.2f} mm
Scatter = %{y:.2f} mm
+ {{ n_subjects }} subjects | + {{ n_afids }} AFIDs per subject +
+| Subject ▾ | +Mean ED (mm) ▾ | +Median ED (mm) ▾ | +Max ED (mm) ▾ | +Worst AFID ▾ | +Report | +
|---|---|---|---|---|---|
{{ row.subject_label }} |
+ + + {{ "%.2f"|format(row.mean_ED) }} + + | +{{ "%.2f"|format(row.median_ED) }} | +{{ "%.2f"|format(row.max_ED) }} | +AFID {{ row.worst_afid }} | ++ {% if row.html_relpath %} + + Open ↗ + + {% else %} + — + {% endif %} + | +
+ Each subject's warped AFIDs are plotted alongside the MNI ground-truth + template (green diamonds). Hover to identify individual AFIDs. +
++ Registration error at each AFID is decomposed into a systematic + bias (mean signed error vector, consistent across subjects — + shown in red) and + random scatter (standard deviation, varies between + subjects — shown in + + blue + ). + Systematic bias is potentially correctable; random scatter represents + the precision floor of the registration. +
+ + ++ Cones show the direction and magnitude of systematic bias at each AFID. + Colour encodes the bias-to-total ratio: + red = mostly + systematic (correctable), + blue = mostly + random (precision limit). Hover for details. +
+