fusiqc 0.0.1a1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,57 @@
1
+ Metadata-Version: 2.4
2
+ Name: fusiqc
3
+ Version: 0.0.1a1
4
+ Summary: Browser-based QC for fUSI-BIDS datasets.
5
+ Author: Samuel Le Meur-Diebolt
6
+ Author-email: Samuel Le Meur-Diebolt <samuel@diebolt.io>
7
+ License-Expression: BSD-3-Clause
8
+ Requires-Dist: confusius==0.0.1a21
9
+ Requires-Dist: pandas>=2.2.3
10
+ Requires-Dist: pybids>=0.18.1
11
+ Requires-Dist: rich>=14.2.0
12
+ Requires-Python: >=3.11, <3.14
13
+ Description-Content-Type: text/markdown
14
+
15
+ [![PyPI version](https://img.shields.io/pypi/v/fusiqc)](https://pypi.org/project/fusiqc/)
16
+ [![Python versions](https://img.shields.io/pypi/pyversions/fusiqc)](https://pypi.org/project/fusiqc/)
17
+ [![License: BSD-3-Clause](https://img.shields.io/badge/license-BSD--3--Clause-blue)](LICENSE)
18
+
19
+ # fUSIQC
20
+
21
+ > [!NOTE]
22
+ > fUSIQC explores what a web-based QC app for [ConfUSIus](https://confusius.tools) could
23
+ > look like. Although it is a proof of concept, it may still be useful if you need to
24
+ > perform quick quality control of a fUSI-BIDS dataset.
25
+
26
+ Browser-based quality control for fUSI-BIDS datasets, powered by
27
+ [ConfUSIus](https://confusius.tools). Generates QC plots (mean power Doppler,
28
+ coefficient of variation, carpet plot, DVARS) for each recording and serves a local web
29
+ app to review and annotate them. QC figures are saved to a derivatives folder for easy
30
+ inspection, and annotations are persisted to a TSV file for downstream analyses.
31
+
32
+ ![fUSIQC interface](docs/screenshot.png)
33
+
34
+ ## Usage
35
+
36
+ ```bash
37
+ uv run fusiqc /path/to/bids-root
38
+ ```
39
+
40
+ On first run, QC plots are generated for all recordings. Subsequent runs reuse existing
41
+ plots and only compute missing ones. Pass `--refresh` to force regeneration.
42
+
43
+ ### Options
44
+
45
+ | Flag | Default | Description |
46
+ |---|---|---|
47
+ | `--output-dir` | `<bids_root>/derivatives/fusiqc/` | QC output directory |
48
+ | `--host` | `127.0.0.1` | Host to bind the web app |
49
+ | `--port` | `8765` | Port to bind the web app |
50
+ | `--workers` | `min(8, cpu_count - 1)` | Parallel workers for plot generation |
51
+ | `--refresh` | | Force regeneration of all QC plots |
52
+ | `--no-browser` | | Don't open a browser automatically |
53
+
54
+ ## Output
55
+
56
+ - `<output_dir>/figures/` — QC plot PNGs organized by subject/session
57
+ - `<output_dir>/quality-control.tsv` — QC status and annotations
@@ -0,0 +1,43 @@
1
+ [![PyPI version](https://img.shields.io/pypi/v/fusiqc)](https://pypi.org/project/fusiqc/)
2
+ [![Python versions](https://img.shields.io/pypi/pyversions/fusiqc)](https://pypi.org/project/fusiqc/)
3
+ [![License: BSD-3-Clause](https://img.shields.io/badge/license-BSD--3--Clause-blue)](LICENSE)
4
+
5
+ # fUSIQC
6
+
7
+ > [!NOTE]
8
+ > fUSIQC explores what a web-based QC app for [ConfUSIus](https://confusius.tools) could
9
+ > look like. Although it is a proof of concept, it may still be useful if you need to
10
+ > perform quick quality control of a fUSI-BIDS dataset.
11
+
12
+ Browser-based quality control for fUSI-BIDS datasets, powered by
13
+ [ConfUSIus](https://confusius.tools). Generates QC plots (mean power Doppler,
14
+ coefficient of variation, carpet plot, DVARS) for each recording and serves a local web
15
+ app to review and annotate them. QC figures are saved to a derivatives folder for easy
16
+ inspection, and annotations are persisted to a TSV file for downstream analyses.
17
+
18
+ ![fUSIQC interface](docs/screenshot.png)
19
+
20
+ ## Usage
21
+
22
+ ```bash
23
+ uv run fusiqc /path/to/bids-root
24
+ ```
25
+
26
+ On first run, QC plots are generated for all recordings. Subsequent runs reuse existing
27
+ plots and only compute missing ones. Pass `--refresh` to force regeneration.
28
+
29
+ ### Options
30
+
31
+ | Flag | Default | Description |
32
+ |---|---|---|
33
+ | `--output-dir` | `<bids_root>/derivatives/fusiqc/` | QC output directory |
34
+ | `--host` | `127.0.0.1` | Host to bind the web app |
35
+ | `--port` | `8765` | Port to bind the web app |
36
+ | `--workers` | `min(8, cpu_count - 1)` | Parallel workers for plot generation |
37
+ | `--refresh` | | Force regeneration of all QC plots |
38
+ | `--no-browser` | | Don't open a browser automatically |
39
+
40
+ ## Output
41
+
42
+ - `<output_dir>/figures/` — QC plot PNGs organized by subject/session
43
+ - `<output_dir>/quality-control.tsv` — QC status and annotations
@@ -0,0 +1,32 @@
1
+ [project]
2
+ name = "fusiqc"
3
+ version = "0.0.1-a1"
4
+ description = "Browser-based QC for fUSI-BIDS datasets."
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Samuel Le Meur-Diebolt", email = "samuel@diebolt.io" }
8
+ ]
9
+ license = "BSD-3-Clause"
10
+ requires-python = ">=3.11,<3.14"
11
+ dependencies = [
12
+ "confusius==0.0.1a21",
13
+ "pandas>=2.2.3",
14
+ "pybids>=0.18.1",
15
+ "rich>=14.2.0",
16
+ ]
17
+
18
+ [project.scripts]
19
+ fusiqc = "fusiqc:main"
20
+
21
+ [dependency-groups]
22
+ dev = [
23
+ "pytest>=8.4.2",
24
+ "ruff>=0.8.0",
25
+ "ty>=0.0.10,<0.1.0",
26
+ "prek>=0.3.0",
27
+ "codespell>=2.4.2",
28
+ ]
29
+
30
+ [build-system]
31
+ requires = ["uv_build>=0.11.2,<0.12.0"]
32
+ build-backend = "uv_build"
@@ -0,0 +1,5 @@
1
+ """fusiqc package."""
2
+
3
+ from fusiqc._cli import main
4
+
5
+ __all__ = ["main"]
@@ -0,0 +1,64 @@
1
+ """fusiqc command-line interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ from pathlib import Path
7
+
8
+ from rich.console import Console
9
+
10
+ from fusiqc._config import make_config
11
+ from fusiqc._qc import refresh_qc_table
12
+ from fusiqc._web import launch_web_app
13
+
14
+
15
+ def build_parser() -> argparse.ArgumentParser:
16
+ """Build the CLI parser."""
17
+ parser = argparse.ArgumentParser(
18
+ prog="fusiqc",
19
+ description="Generate and review QC plots for a fUSI-BIDS dataset.",
20
+ )
21
+ parser.add_argument("bids_root", type=Path, help="Path to the BIDS dataset root.")
22
+ parser.add_argument(
23
+ "--output-dir",
24
+ type=Path,
25
+ default=None,
26
+ help="QC output directory. Defaults to <bids_root>/derivatives/fusiqc.",
27
+ )
28
+ parser.add_argument(
29
+ "--host", default="127.0.0.1", help="Host to bind the local web app."
30
+ )
31
+ parser.add_argument(
32
+ "--port", type=int, default=8765, help="Port to bind the local web app."
33
+ )
34
+ parser.add_argument(
35
+ "--refresh",
36
+ action="store_true",
37
+ help="Force regeneration of all QC plots instead of only computing missing ones.",
38
+ )
39
+ parser.add_argument(
40
+ "--no-browser",
41
+ action="store_true",
42
+ help="Do not open a browser automatically.",
43
+ )
44
+ parser.add_argument(
45
+ "--workers",
46
+ type=int,
47
+ default=None,
48
+ help="Number of worker processes to use while refreshing QC assets.",
49
+ )
50
+ return parser
51
+
52
+
53
+ def main() -> None:
54
+ """Run the fusiqc CLI."""
55
+ args = build_parser().parse_args()
56
+ console = Console()
57
+ config = make_config(
58
+ args.bids_root, output_dir=args.output_dir, workers=args.workers
59
+ )
60
+ table, tsv_path = refresh_qc_table(config, force=args.refresh)
61
+ console.print(f"QC table with {len(table)} recording(s): {tsv_path}")
62
+ launch_web_app(
63
+ config, host=args.host, port=args.port, open_browser=not args.no_browser
64
+ )
@@ -0,0 +1,46 @@
1
+ """Configuration helpers for fusiqc."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class QcConfig:
11
+ """Runtime configuration for one QC session."""
12
+
13
+ bids_root: Path
14
+ output_dir: Path
15
+ workers: int | None = None
16
+
17
+ @property
18
+ def tsv_path(self) -> Path:
19
+ """Return the QC TSV output path."""
20
+ return self.output_dir / "quality-control.tsv"
21
+
22
+ @property
23
+ def figures_dir(self) -> Path:
24
+ """Return the QC figures output directory."""
25
+ return self.output_dir / "figures"
26
+
27
+
28
+ def resolve_output_dir(bids_root: Path, output_dir: Path | None = None) -> Path:
29
+ """Return the resolved QC output directory."""
30
+ if output_dir is None:
31
+ return bids_root / "derivatives" / "fusiqc"
32
+ return output_dir.expanduser().resolve()
33
+
34
+
35
+ def make_config(
36
+ bids_root: Path,
37
+ output_dir: Path | None = None,
38
+ workers: int | None = None,
39
+ ) -> QcConfig:
40
+ """Create a QC configuration object."""
41
+ bids_root = bids_root.expanduser().resolve()
42
+ return QcConfig(
43
+ bids_root=bids_root,
44
+ output_dir=resolve_output_dir(bids_root, output_dir),
45
+ workers=workers,
46
+ )
@@ -0,0 +1,68 @@
1
+ """Dataset discovery for fusiqc."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from functools import lru_cache
7
+ from pathlib import Path
8
+
9
+ from bids import BIDSLayout
10
+ from bids.layout import BIDSLayoutIndexer
11
+
12
+ from fusiqc._config import QcConfig
13
+
14
+ ALLOWED_PWD_SUFFIXES = ("_pwd.nii", "_pwd.nii.gz", "_pwd.zarr", "_pwd.scan")
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class PwdRecording:
19
+ """One power-Doppler recording discovered in a BIDS dataset."""
20
+
21
+ pwd_path: Path
22
+ session_label: str
23
+ subject: str
24
+ session: str
25
+ task: str
26
+ run: str
27
+
28
+
29
+ @lru_cache(maxsize=8)
30
+ def get_bids_layout(bids_root: Path) -> BIDSLayout:
31
+ """Return a cached PyBIDS layout for one dataset root."""
32
+ return BIDSLayout(
33
+ bids_root,
34
+ validate=False,
35
+ indexer=BIDSLayoutIndexer(force_index=[r".*\.zarr(?:/.*)?$", r".*\.scan$"]),
36
+ )
37
+
38
+
39
+ def get_session_label_from_pwd_path(pwd_path: Path) -> str:
40
+ """Return the basename without the power-Doppler suffix."""
41
+ name = pwd_path.name
42
+ for suffix in ALLOWED_PWD_SUFFIXES:
43
+ if name.endswith(suffix):
44
+ return name.removesuffix(suffix)
45
+ return pwd_path.stem
46
+
47
+
48
+ def discover_pwd_recordings(config: QcConfig) -> list[PwdRecording]:
49
+ """Return all supported power-Doppler recordings in the dataset."""
50
+ layout = get_bids_layout(config.bids_root)
51
+ files = layout.get(suffix="pwd", return_type="filename")
52
+ recordings: list[PwdRecording] = []
53
+ for filename in sorted(files):
54
+ pwd_path = Path(filename)
55
+ if not pwd_path.name.endswith(ALLOWED_PWD_SUFFIXES):
56
+ continue
57
+ entities = layout.parse_file_entities(str(pwd_path))
58
+ recordings.append(
59
+ PwdRecording(
60
+ pwd_path=pwd_path,
61
+ session_label=get_session_label_from_pwd_path(pwd_path),
62
+ subject=str(entities.get("subject", entities.get("sub", ""))),
63
+ session=str(entities.get("session", entities.get("ses", ""))),
64
+ task=str(entities.get("task", "")),
65
+ run=str(entities.get("run", "")),
66
+ )
67
+ )
68
+ return recordings
@@ -0,0 +1,352 @@
1
+ """QC refresh and plotting helpers for fusiqc."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from concurrent.futures import ProcessPoolExecutor, as_completed
7
+ from pathlib import Path
8
+
9
+ import confusius as cf
10
+ import matplotlib.figure
11
+ import matplotlib.pyplot as plt
12
+ import numpy as np
13
+ import pandas as pd
14
+ import xarray as xr
15
+ from confusius.qc import compute_cv, compute_dvars
16
+ from rich.console import Console
17
+ from rich.progress import (
18
+ BarColumn,
19
+ MofNCompleteColumn,
20
+ Progress,
21
+ SpinnerColumn,
22
+ TextColumn,
23
+ TimeElapsedColumn,
24
+ track,
25
+ )
26
+
27
+ from fusiqc._config import QcConfig
28
+ from fusiqc._dataset import PwdRecording, discover_pwd_recordings
29
+
30
+ QC_PANELS = ("mean_power_doppler", "cv", "carpet", "dvars")
31
+ QC_COLUMNS = (
32
+ "pwd_path",
33
+ "session_label",
34
+ "subject",
35
+ "session",
36
+ "task",
37
+ "run",
38
+ "n_timepoints",
39
+ "qc_status",
40
+ "qc_notes",
41
+ )
42
+ CONSOLE = Console()
43
+
44
+
45
+ def load_qc_table(config: QcConfig) -> pd.DataFrame:
46
+ """Load the QC TSV, returning an empty table when absent."""
47
+ if not config.tsv_path.exists():
48
+ return pd.DataFrame(columns=QC_COLUMNS)
49
+ table = pd.read_csv(config.tsv_path, sep="\t", dtype=str).fillna("")
50
+ for column in QC_COLUMNS:
51
+ if column not in table.columns:
52
+ table[column] = ""
53
+ return table.loc[:, QC_COLUMNS]
54
+
55
+
56
+ def save_qc_table(config: QcConfig, table: pd.DataFrame) -> Path:
57
+ """Save the QC TSV."""
58
+ config.output_dir.mkdir(parents=True, exist_ok=True)
59
+ table.loc[:, QC_COLUMNS].to_csv(config.tsv_path, sep="\t", index=False)
60
+ return config.tsv_path
61
+
62
+
63
+ def get_qc_plot_paths(config: QcConfig, recording: PwdRecording) -> dict[str, Path]:
64
+ """Return output paths for all QC panels."""
65
+ base_dir = (
66
+ config.figures_dir / f"sub-{recording.subject}" / f"ses-{recording.session}"
67
+ )
68
+ return {
69
+ panel: base_dir / f"{recording.session_label}_{panel}.png"
70
+ for panel in QC_PANELS
71
+ }
72
+
73
+
74
+ def _default_workers() -> int:
75
+ """Return a conservative default worker count for QC refresh."""
76
+ cpu_count = os.cpu_count() or 1
77
+ return max(1, min(8, cpu_count - 1))
78
+
79
+
80
+ def _prepare_preview_map(data: xr.DataArray) -> tuple[xr.DataArray, str, float]:
81
+ """Return a 3D preview volume plus slice settings for one spatial map."""
82
+ preview = data.squeeze()
83
+ if preview.ndim == 2:
84
+ return preview.expand_dims(z=[0.0]), "z", 0.0
85
+ if preview.ndim == 3:
86
+ return (
87
+ preview,
88
+ "z",
89
+ float(preview.coords["z"].values[len(preview.coords["z"]) // 2]),
90
+ )
91
+ raise ValueError(
92
+ f"Expected 2D or 3D preview map, got shape {preview.shape} with dims {list(preview.dims)}."
93
+ )
94
+
95
+
96
+ def _get_map_figsize(preview: xr.DataArray, slice_mode: str) -> tuple[float, float]:
97
+ """Return a figure size that keeps the colorbar close to image height."""
98
+ display_dims = [dim for dim in preview.dims if dim != slice_mode]
99
+ if len(display_dims) != 2:
100
+ return (5.4, 4.8)
101
+ row_dim, col_dim = display_dims
102
+ row_extent = float(preview.sizes[row_dim])
103
+ col_extent = float(preview.sizes[col_dim])
104
+ if row_dim in preview.coords and preview.coords[row_dim].size > 1:
105
+ row_coords = np.asarray(preview.coords[row_dim].values, dtype=float)
106
+ row_extent = float(np.abs(row_coords[-1] - row_coords[0]))
107
+ if col_dim in preview.coords and preview.coords[col_dim].size > 1:
108
+ col_coords = np.asarray(preview.coords[col_dim].values, dtype=float)
109
+ col_extent = float(np.abs(col_coords[-1] - col_coords[0]))
110
+ if row_extent <= 0 or col_extent <= 0:
111
+ return (5.4, 4.8)
112
+ image_aspect = col_extent / row_extent
113
+ figure_height = 4.8
114
+ figure_width = float(np.clip(1.8 + figure_height * image_aspect, 5.0, 8.8))
115
+ return figure_width, figure_height
116
+
117
+
118
+ def _save_map_plot(
119
+ data: xr.DataArray,
120
+ output_path: Path,
121
+ cmap: str,
122
+ cbar_label: str,
123
+ vmin: float | None = None,
124
+ vmax: float | None = None,
125
+ ) -> Path:
126
+ """Save one dark-themed spatial QC plot."""
127
+ preview, slice_mode, slice_coord = _prepare_preview_map(data)
128
+ figure = plt.figure(
129
+ figsize=_get_map_figsize(preview, slice_mode),
130
+ constrained_layout=True,
131
+ )
132
+ try:
133
+ figure.patch.set_alpha(0.0)
134
+ subfig: matplotlib.figure.Figure = figure.subfigures(1, 1)[0]
135
+ subfig.patch.set_alpha(0.0)
136
+ ax = subfig.subplots(1, 1)
137
+ if vmin is None or vmax is None:
138
+ vmin, vmax = np.nanpercentile(preview.values, [1.0, 99.0])
139
+ cf.plotting.plot_volume(
140
+ preview,
141
+ slice_mode=slice_mode,
142
+ slice_coords=[slice_coord],
143
+ figure=subfig,
144
+ axes=np.asarray([[ax]]),
145
+ cmap=cmap,
146
+ black_bg=True,
147
+ show_titles=False,
148
+ show_colorbar=True,
149
+ cbar_label=cbar_label,
150
+ vmin=vmin,
151
+ vmax=vmax,
152
+ )
153
+ output_path.parent.mkdir(parents=True, exist_ok=True)
154
+ figure.savefig(output_path, dpi=150, bbox_inches="tight", transparent=True)
155
+ return output_path
156
+ finally:
157
+ plt.close(figure)
158
+
159
+
160
+ def _save_dvars_plot(power_doppler: xr.DataArray, output_path: Path) -> Path:
161
+ """Save one dark-themed DVARS plot."""
162
+ figure, ax = plt.subplots(figsize=(8.6, 3.6), constrained_layout=True)
163
+ try:
164
+ figure.patch.set_alpha(0.0)
165
+ ax.set_facecolor("none")
166
+ dvars = compute_dvars(power_doppler)
167
+ ax.plot(dvars.coords["time"], dvars.values, linewidth=1.2, color="white")
168
+ ax.set_xlabel("Time (s)")
169
+ ax.set_ylabel("DVARS")
170
+ ax.grid(alpha=0.25, color="white")
171
+ ax.tick_params(colors="white")
172
+ ax.xaxis.label.set_color("white")
173
+ ax.yaxis.label.set_color("white")
174
+ for spine in ax.spines.values():
175
+ spine.set_color("white")
176
+ output_path.parent.mkdir(parents=True, exist_ok=True)
177
+ figure.savefig(output_path, dpi=150, bbox_inches="tight", transparent=True)
178
+ return output_path
179
+ finally:
180
+ plt.close(figure)
181
+
182
+
183
+ def _save_carpet_plot(power_doppler: xr.DataArray, output_path: Path) -> Path:
184
+ """Save one dark-themed carpet plot."""
185
+ figure, ax = plt.subplots(figsize=(9.2, 3.8), constrained_layout=True)
186
+ try:
187
+ figure.patch.set_alpha(0.0)
188
+ power_doppler.fusi.plot.carpet(ax=ax, title=None, black_bg=True)
189
+ output_path.parent.mkdir(parents=True, exist_ok=True)
190
+ figure.savefig(output_path, dpi=150, bbox_inches="tight", transparent=True)
191
+ return output_path
192
+ finally:
193
+ plt.close(figure)
194
+
195
+
196
+ def _save_recording_plots(
197
+ config: QcConfig,
198
+ recording: PwdRecording,
199
+ pwd: xr.DataArray,
200
+ ) -> dict[str, Path]:
201
+ """Save all QC plots for one recording."""
202
+ output_paths = get_qc_plot_paths(config, recording)
203
+ mean_image = pwd.mean(dim="time").compute().fusi.scale.db()
204
+ cv_map = compute_cv(pwd)
205
+ _save_map_plot(
206
+ mean_image,
207
+ output_paths["mean_power_doppler"],
208
+ "gray",
209
+ "Mean power Doppler (dB)",
210
+ )
211
+ _save_map_plot(cv_map, output_paths["cv"], "magma", "CV", vmin=0.0, vmax=1.0)
212
+ _save_carpet_plot(pwd, output_paths["carpet"])
213
+ _save_dvars_plot(pwd, output_paths["dvars"])
214
+ return output_paths
215
+
216
+
217
+ def _plots_exist(config: QcConfig, recording: PwdRecording) -> bool:
218
+ """Return True if all QC plot files exist for a recording."""
219
+ return all(p.exists() for p in get_qc_plot_paths(config, recording).values())
220
+
221
+
222
+ def _refresh_one_recording(
223
+ bids_root: str,
224
+ output_dir: str,
225
+ recording_dict: dict[str, str],
226
+ existing_row: dict[str, str] | None,
227
+ force: bool,
228
+ ) -> dict[str, str]:
229
+ """Refresh one recording row in a worker process."""
230
+ config = QcConfig(bids_root=Path(bids_root), output_dir=Path(output_dir))
231
+ recording = PwdRecording(
232
+ pwd_path=Path(recording_dict["pwd_path"]),
233
+ session_label=recording_dict["session_label"],
234
+ subject=recording_dict["subject"],
235
+ session=recording_dict["session"],
236
+ task=recording_dict["task"],
237
+ run=recording_dict["run"],
238
+ )
239
+ if not force and _plots_exist(config, recording) and existing_row is not None:
240
+ return existing_row
241
+ pwd = cf.load(recording.pwd_path).compute()
242
+ if not _plots_exist(config, recording) or force:
243
+ _save_recording_plots(config, recording, pwd)
244
+ qc_status = "pending"
245
+ qc_notes = ""
246
+ if existing_row is not None:
247
+ qc_status = existing_row.get("qc_status", "") or "pending"
248
+ qc_notes = existing_row.get("qc_notes", "") or ""
249
+ return {
250
+ "pwd_path": str(recording.pwd_path),
251
+ "session_label": recording.session_label,
252
+ "subject": recording.subject,
253
+ "session": recording.session,
254
+ "task": recording.task,
255
+ "run": recording.run,
256
+ "n_timepoints": str(pwd.sizes["time"]),
257
+ "qc_status": qc_status,
258
+ "qc_notes": qc_notes,
259
+ }
260
+
261
+
262
+ def refresh_qc_table(
263
+ config: QcConfig, force: bool = False
264
+ ) -> tuple[pd.DataFrame, Path]:
265
+ """Refresh plots and the QC TSV."""
266
+ recordings = discover_pwd_recordings(config)
267
+ if not recordings:
268
+ raise FileNotFoundError(
269
+ f"No supported *_pwd recordings found under {config.bids_root}."
270
+ )
271
+ existing = load_qc_table(config)
272
+ existing_rows = {
273
+ row["pwd_path"]: row.to_dict()
274
+ for _, row in existing.iterrows()
275
+ if row.get("pwd_path", "")
276
+ }
277
+ rows: list[dict[str, str]] = []
278
+ worker_count = (
279
+ _default_workers() if config.workers is None else max(1, config.workers)
280
+ )
281
+ action = "Refreshing QC table" if not force else "Generating QC assets"
282
+ CONSOLE.log(f"{action} for {len(recordings)} recording(s)")
283
+ if worker_count == 1:
284
+ for recording in track(recordings, description=action, console=CONSOLE):
285
+ rows.append(
286
+ _refresh_one_recording(
287
+ str(config.bids_root),
288
+ str(config.output_dir),
289
+ {
290
+ "pwd_path": str(recording.pwd_path),
291
+ "session_label": recording.session_label,
292
+ "subject": recording.subject,
293
+ "session": recording.session,
294
+ "task": recording.task,
295
+ "run": recording.run,
296
+ },
297
+ existing_rows.get(str(recording.pwd_path)),
298
+ force,
299
+ )
300
+ )
301
+ else:
302
+ CONSOLE.log(f"Using {worker_count} worker(s)")
303
+ with ProcessPoolExecutor(max_workers=worker_count) as executor:
304
+ future_to_recording = {
305
+ executor.submit(
306
+ _refresh_one_recording,
307
+ str(config.bids_root),
308
+ str(config.output_dir),
309
+ {
310
+ "pwd_path": str(recording.pwd_path),
311
+ "session_label": recording.session_label,
312
+ "subject": recording.subject,
313
+ "session": recording.session,
314
+ "task": recording.task,
315
+ "run": recording.run,
316
+ },
317
+ existing_rows.get(str(recording.pwd_path)),
318
+ force,
319
+ ): recording
320
+ for recording in recordings
321
+ }
322
+ with Progress(
323
+ SpinnerColumn(),
324
+ TextColumn("[progress.description]{task.description}"),
325
+ BarColumn(),
326
+ MofNCompleteColumn(),
327
+ TimeElapsedColumn(),
328
+ console=CONSOLE,
329
+ ) as progress:
330
+ task_id = progress.add_task(action, total=len(future_to_recording))
331
+ for future in as_completed(future_to_recording):
332
+ rows.append(future.result())
333
+ progress.advance(task_id)
334
+ table = pd.DataFrame(rows, columns=QC_COLUMNS).sort_values(
335
+ ["subject", "session", "task", "run", "session_label"]
336
+ )
337
+ tsv_path = save_qc_table(config, table)
338
+ CONSOLE.log(f"Saved QC table: {tsv_path}")
339
+ return table, tsv_path
340
+
341
+
342
+ def update_qc_entry(
343
+ config: QcConfig, pwd_path: str, qc_status: str, qc_notes: str
344
+ ) -> Path:
345
+ """Update one QC row and save the TSV."""
346
+ table = load_qc_table(config).copy()
347
+ row_mask = table["pwd_path"] == pwd_path
348
+ if not row_mask.any():
349
+ raise ValueError(f"No QC row found for recording {pwd_path!r}.")
350
+ table.loc[row_mask, "qc_status"] = qc_status
351
+ table.loc[row_mask, "qc_notes"] = qc_notes
352
+ return save_qc_table(config, table)
@@ -0,0 +1,248 @@
1
+ """Small local QC review web app."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import html
6
+ import json
7
+ import mimetypes
8
+ import urllib.parse
9
+ import webbrowser
10
+ from pathlib import Path
11
+ from wsgiref.simple_server import make_server
12
+
13
+ from rich.console import Console
14
+
15
+ from fusiqc._config import QcConfig
16
+ from fusiqc._dataset import PwdRecording, get_session_label_from_pwd_path
17
+ from fusiqc._qc import (
18
+ QC_PANELS,
19
+ get_qc_plot_paths,
20
+ load_qc_table,
21
+ update_qc_entry,
22
+ )
23
+
24
+ QC_STATUSES = ("pending", "good", "bad")
25
+ METADATA_FIELDS = (
26
+ "session_label",
27
+ "subject",
28
+ "session",
29
+ "task",
30
+ "run",
31
+ "n_timepoints",
32
+ )
33
+ CONSOLE = Console()
34
+
35
+
36
+ def _ok(start_response, payload: str = "ok") -> list[bytes]:
37
+ start_response("200 OK", [("Content-Type", "text/plain; charset=utf-8")])
38
+ return [payload.encode("utf-8")]
39
+
40
+
41
+ def _get_filtered_table(config: QcConfig, status_filter: str):
42
+ table = load_qc_table(config)
43
+ if status_filter == "all":
44
+ return table, table
45
+ filtered = table.loc[table["qc_status"] == status_filter].reset_index(drop=True)
46
+ return table, filtered
47
+
48
+
49
+ def _render_filter_links(status_filter: str, counts: dict[str, int]) -> str:
50
+ labels = (("pending", "Pending"), ("good", "Good"), ("bad", "Bad"), ("all", "All"))
51
+ links = []
52
+ for value, label in labels:
53
+ active_class = "active" if value == status_filter else ""
54
+ links.append(
55
+ f'<a class="filter-chip {active_class}" href="/?status={value}&index=0&panel=mean_power_doppler">{label} <span>{counts[value]}</span></a>'
56
+ )
57
+ return "".join(links)
58
+
59
+
60
+ def _render_empty_page(status_filter: str, table) -> str:
61
+ counts = {
62
+ "pending": int((table["qc_status"] == "pending").sum()),
63
+ "good": int((table["qc_status"] == "good").sum()),
64
+ "bad": int((table["qc_status"] == "bad").sum()),
65
+ "all": int(len(table)),
66
+ }
67
+ filter_links = _render_filter_links(status_filter, counts)
68
+ return f"""
69
+ <!doctype html>
70
+ <html><head><meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" />
71
+ <title>fUSIQC</title><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
72
+ <style>
73
+ :root {{ --pico-font-size: 95%; --panel-bg: #0e1420; --panel-border: #243247; }}
74
+ main {{ max-width: 1400px; margin: 0 auto; padding: 1.5rem 1rem 3rem; }}
75
+ .toolbar {{ display: flex; justify-content: space-between; gap: 1rem; align-items: center; flex-wrap: wrap; margin-bottom: 1rem; }}
76
+ .filter-bar {{ display: flex; gap: 0.75rem; flex-wrap: wrap; }}
77
+ .filter-chip {{ padding: 0.45rem 0.8rem; border-radius: 0.35rem; text-decoration: none; background: var(--pico-muted-border-color); color: inherit; }}
78
+ .filter-chip.active {{ background: var(--pico-primary); color: white; }}
79
+ .filter-chip span {{ opacity: 0.8; margin-left: 0.35rem; }}
80
+ .muted {{ color: var(--pico-muted-color); }}
81
+ </style></head>
82
+ <body><main><header><h1>fUSIQC</h1><p class="muted">Review quicklooks and update the TSV directly from the browser.</p></header>
83
+ <div class="toolbar"><div class="filter-bar">{filter_links}</div></div>
84
+ <article><h2>No {html.escape(status_filter)} recordings left.</h2><p><a href="/?status=all&index=0&panel=mean_power_doppler">Open all recordings</a></p></article></main></body></html>
85
+ """
86
+
87
+
88
+ def _serve_qc_plot(
89
+ config: QcConfig, start_response, query: dict[str, list[str]]
90
+ ) -> list[bytes]:
91
+ pwd_path = query.get("pwd_path", [""])[0]
92
+ panel = query.get("panel", ["mean_power_doppler"])[0]
93
+ allowed_panels = set(QC_PANELS)
94
+ if not pwd_path or panel not in allowed_panels:
95
+ start_response("404 Not Found", [("Content-Type", "text/plain; charset=utf-8")])
96
+ return [b"QC plot not found."]
97
+ recording = Path(pwd_path)
98
+ rec = PwdRecording(
99
+ pwd_path=recording,
100
+ session_label=get_session_label_from_pwd_path(recording),
101
+ subject=recording.parts[-4].removeprefix("sub-"),
102
+ session=recording.parts[-3].removeprefix("ses-"),
103
+ task="",
104
+ run="",
105
+ )
106
+ plot_path = get_qc_plot_paths(config, rec)[panel]
107
+ figures_root = config.figures_dir.resolve()
108
+ if not plot_path.exists() or not plot_path.resolve().is_relative_to(figures_root):
109
+ start_response("404 Not Found", [("Content-Type", "text/plain; charset=utf-8")])
110
+ return [b"QC plot not found."]
111
+ mime_type = mimetypes.guess_type(plot_path.name)[0] or "image/png"
112
+ start_response("200 OK", [("Content-Type", mime_type)])
113
+ return [plot_path.read_bytes()]
114
+
115
+
116
+ def _render_page(config: QcConfig, query: dict[str, list[str]]) -> str:
117
+ status_filter = query.get("status", [""])[0] or "pending"
118
+ if status_filter not in (*QC_STATUSES, "all"):
119
+ status_filter = "pending"
120
+ table, filtered = _get_filtered_table(config, status_filter)
121
+ if filtered.empty:
122
+ return _render_empty_page(status_filter, table)
123
+ counts = {
124
+ "pending": int((table["qc_status"] == "pending").sum()),
125
+ "good": int((table["qc_status"] == "good").sum()),
126
+ "bad": int((table["qc_status"] == "bad").sum()),
127
+ "all": int(len(table)),
128
+ }
129
+ initial_index = int(query.get("index", ["0"])[0])
130
+ initial_index = max(0, min(initial_index, len(filtered) - 1))
131
+ initial_panel = query.get("panel", [QC_PANELS[0]])[0]
132
+ if initial_panel not in QC_PANELS:
133
+ initial_panel = QC_PANELS[0]
134
+ status_links = _render_filter_links(status_filter, counts)
135
+ rows_json = json.dumps(table.to_dict(orient="records"))
136
+ filtered_indices_json = json.dumps(
137
+ table.index[table["qc_status"] == status_filter].to_list()
138
+ if status_filter != "all"
139
+ else table.index.to_list()
140
+ )
141
+ panel_labels_json = json.dumps(
142
+ {
143
+ "mean_power_doppler": "Power Doppler",
144
+ "cv": "CV",
145
+ "carpet": "Carpet plot",
146
+ "dvars": "DVARS",
147
+ }
148
+ )
149
+ metadata_fields_json = json.dumps(METADATA_FIELDS)
150
+ initial_filter_json = json.dumps(status_filter)
151
+ initial_index_json = json.dumps(initial_index)
152
+ initial_panel_json = json.dumps(initial_panel)
153
+ return f"""
154
+ <!doctype html>
155
+ <html><head><meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" />
156
+ <title>fUSIQC</title><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
157
+ <style>
158
+ :root {{ --pico-font-size: 95%; --panel-bg: #0e1420; --panel-border: #243247; }}
159
+ main {{ max-width: 1400px; margin: 0 auto; padding: 1.5rem 1rem 3rem; }}
160
+ .layout {{ display: grid; grid-template-columns: 1fr; gap: 1.25rem; align-items: start; }}
161
+ .toolbar {{ display: flex; justify-content: space-between; gap: 1rem; align-items: center; flex-wrap: wrap; margin-bottom: 1rem; }}
162
+ .filter-bar {{ display: flex; gap: 0.75rem; flex-wrap: wrap; }}
163
+ .filter-chip {{ padding: 0.45rem 0.8rem; border-radius: 0.35rem; text-decoration: none; background: var(--pico-muted-border-color); color: inherit; }}
164
+ .filter-chip.active {{ background: var(--pico-primary); color: white; }}
165
+ .filter-chip span {{ opacity: 0.8; margin-left: 0.35rem; }}
166
+ .nav-links {{ display: flex; gap: 1.15rem; align-items: center; flex-wrap: wrap; }}
167
+ .quicklook-panel {{ background: var(--panel-bg); border: 1px solid var(--panel-border); border-radius: 0.5rem; padding: 1rem 1rem 0.45rem; }}
168
+ .viewer-footer {{ display: flex; justify-content: flex-start; margin-top: 0.75rem; padding-left: 0.15rem; }}
169
+ .panel-tabs {{ display: flex; gap: 0.5rem; flex-wrap: wrap; justify-content: flex-start; }}
170
+ .panel-tab {{ border-radius: 0.35rem; padding: 0.35rem 0.8rem; border: 1px solid var(--panel-border); background: rgba(255,255,255,0.04); color: white; cursor: pointer; }}
171
+ .panel-tab.active {{ background: rgba(255,255,255,0.16); }}
172
+ .session-nav-button {{ border-radius: 0.35rem; padding: 0.45rem 0.8rem; }}
173
+ .quicklook-container {{ display: flex; justify-content: center; align-items: center; min-height: 360px; }}
174
+ .quicklook-panel img {{ width: auto; max-width: 100%; max-height: 380px; height: auto; border-radius: 0.35rem; display: block; }}
175
+ .quicklook-empty {{ display: flex; align-items: center; justify-content: center; min-height: 360px; color: white; opacity: 0.8; }}
176
+ .controls-stack {{ display: grid; grid-template-columns: minmax(0, 1fr) minmax(320px, 1fr); gap: 1rem; align-items: start; }}
177
+ .status-buttons {{ display: flex; gap: 0.75rem; flex-wrap: nowrap; margin-bottom: 1rem; }}
178
+ .status-button {{ flex: 1 1 0; min-width: 0; border-radius: 0.35rem; font-weight: 600; }}
179
+ .status-button.good {{ border-color: #2f9e44; color: #2f9e44; background: rgba(47, 158, 68, 0.08); }}
180
+ .status-button.good.selected {{ background: #2f9e44; color: white; }}
181
+ .status-button.bad {{ border-color: #d9485f; color: #d9485f; background: rgba(217, 72, 95, 0.08); }}
182
+ .status-button.bad.selected {{ background: #d9485f; color: white; }}
183
+ .status-button.pending {{ border-color: #f08c00; color: #c76b00; background: rgba(240, 140, 0, 0.1); }}
184
+ .status-button.pending.selected {{ background: #f08c00; color: white; }}
185
+ .metadata-table th {{ width: 36%; }}
186
+ textarea {{ min-height: 150px; }}
187
+ .muted {{ color: var(--pico-muted-color); }}
188
+ .autosave-status {{ min-height: 1.2rem; font-size: 0.9rem; }}
189
+ @media (max-width: 980px) {{ .controls-stack {{ grid-template-columns: 1fr; }} }}
190
+ </style></head>
191
+ <body><main><header><h1>fUSIQC</h1><p class="muted">Review quicklooks and update the TSV directly from the browser.</p></header>
192
+ <div class="toolbar"><div class="filter-bar">{status_links}</div><div class="nav-links"><span id="position-label" class="muted"></span><button id="previous-button" type="button" class="secondary outline session-nav-button">Previous</button><button id="next-button" type="button" class="secondary outline session-nav-button">Next</button></div></div>
193
+ <div class="layout"><article class="quicklook-panel"><div><h2 id="session-title" style="margin-bottom: 0.25rem; color: white;"></h2></div><div id="quicklook-container" class="quicklook-container"></div><div class="viewer-footer"><div id="panel-tabs" class="panel-tabs"></div></div></article>
194
+ <section class="controls-stack"><article><label><strong>QC status</strong></label><div id="status-buttons" class="status-buttons"></div><label for="qc_notes"><strong>Notes</strong></label><textarea id="qc_notes" name="qc_notes"></textarea><div id="autosave-status" class="autosave-status muted"></div></article><article><table id="metadata-table" class="metadata-table"></table></article></section></div></main>
195
+ <script>
196
+ const qcRows = {rows_json}; let currentFilter = {initial_filter_json}; let currentPanel = {initial_panel_json}; let filteredRowIndices = {filtered_indices_json}; let currentPosition = {initial_index_json};
197
+ const panelLabels = {panel_labels_json}; const metadataFields = {metadata_fields_json};
198
+ const notesArea = document.getElementById('qc_notes'); const autosaveStatus = document.getElementById('autosave-status'); const quicklookContainer = document.getElementById('quicklook-container'); const metadataTable = document.getElementById('metadata-table'); const statusButtonsContainer = document.getElementById('status-buttons'); const previousButton = document.getElementById('previous-button'); const nextButton = document.getElementById('next-button'); const positionLabel = document.getElementById('position-label'); const sessionTitle = document.getElementById('session-title'); const panelTabs = document.getElementById('panel-tabs'); let autosaveTimer = null; let autosaveCounter = 0; let suppressNotesAutosave = false;
199
+ function filterIndices() {{ if (currentFilter === 'all') return qcRows.map((_, idx) => idx); return qcRows.flatMap((row, idx) => row.qc_status === currentFilter ? [idx] : []); }}
200
+ function getCurrentRow() {{ return qcRows[filteredRowIndices[currentPosition]]; }}
201
+ function qcPlotUrl(pwdPath, panel) {{ return `/qc_plot?pwd_path=${{encodeURIComponent(pwdPath)}}&panel=${{encodeURIComponent(panel)}}`; }}
202
+ function updateUrl() {{ const params = new URLSearchParams({{ status: currentFilter, index: String(currentPosition), panel: currentPanel }}); window.history.replaceState(null, '', `/?${{params.toString()}}`); }}
203
+ function renderFilterCounts() {{ const counts = {{ pending: 0, good: 0, bad: 0, all: qcRows.length }}; qcRows.forEach((row) => {{ if (row.qc_status in counts) counts[row.qc_status] += 1; }}); document.querySelectorAll('.filter-chip').forEach((chip) => {{ const href = chip.getAttribute('href') || ''; const status = new URL(href, window.location.origin).searchParams.get('status'); chip.classList.toggle('active', status === currentFilter); const span = chip.querySelector('span'); if (span && status in counts) span.textContent = counts[status]; }}); }}
204
+ function renderPanelTabs() {{ panelTabs.innerHTML = Object.entries(panelLabels).map(([panel, label]) => `<button type="button" class="panel-tab ${{panel === currentPanel ? 'active' : ''}}" data-panel="${{panel}}">${{label}}</button>`).join(''); panelTabs.querySelectorAll('[data-panel]').forEach((button) => {{ button.addEventListener('click', () => {{ currentPanel = button.dataset.panel; renderCurrentRow(false); }}); }}); }}
205
+ function renderMetadata(row) {{ metadataTable.innerHTML = metadataFields.map((field) => `<tr><th>${{field}}</th><td>${{row[field] ?? ''}}</td></tr>`).join(''); }}
206
+ async function updateStatus(nextStatus) {{ const row = getCurrentRow(); await fetch('/update_status', {{ method: 'POST', headers: {{ 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' }}, body: new URLSearchParams({{ pwd_path: row.pwd_path, qc_status: nextStatus, qc_notes: notesArea.value }}) }}); row.qc_status = nextStatus; row.qc_notes = notesArea.value; filteredRowIndices = filterIndices(); if (filteredRowIndices.length === 0) {{ window.location.href = `/?status=${{encodeURIComponent(currentFilter)}}&index=0&panel=${{encodeURIComponent(currentPanel)}}`; return; }} const currentRowIndex = qcRows.indexOf(row); const nextFilteredPosition = filteredRowIndices.indexOf(currentRowIndex); currentPosition = nextFilteredPosition === -1 ? Math.min(currentPosition, filteredRowIndices.length - 1) : nextFilteredPosition; renderCurrentRow(); }}
207
+ function renderStatusButtons(row) {{ statusButtonsContainer.innerHTML = ['good', 'bad', 'pending'].map((status) => `<button type="button" class="status-button ${{status}} ${{row.qc_status === status ? 'selected' : ''}}" data-status="${{status}}">${{status.charAt(0).toUpperCase() + status.slice(1)}}</button>`).join(''); statusButtonsContainer.querySelectorAll('[data-status]').forEach((button) => {{ button.addEventListener('click', async () => {{ await updateStatus(button.dataset.status); }}); }}); }}
208
+ function preloadAdjacentImages() {{ [currentPosition - 1, currentPosition + 1].forEach((position) => {{ if (position < 0 || position >= filteredRowIndices.length) return; const adjacentRow = qcRows[filteredRowIndices[position]]; const image = new Image(); image.src = qcPlotUrl(adjacentRow.pwd_path, currentPanel); }}); }}
209
+ function renderQuicklook(row) {{ const img = document.createElement('img'); img.src = qcPlotUrl(row.pwd_path, currentPanel); img.alt = panelLabels[currentPanel]; img.onerror = () => {{ quicklookContainer.innerHTML = '<div class="quicklook-empty">No QC plot available for this recording.</div>'; }}; quicklookContainer.innerHTML = ''; quicklookContainer.appendChild(img); }}
210
+ function renderCurrentRow(updateNotes = true) {{ const row = getCurrentRow(); updateUrl(); renderFilterCounts(); renderPanelTabs(); positionLabel.textContent = `Viewing ${{currentPosition + 1}} / ${{filteredRowIndices.length}} in filter "${{currentFilter}}".`; previousButton.disabled = currentPosition === 0; nextButton.disabled = currentPosition >= filteredRowIndices.length - 1; sessionTitle.textContent = row.session_label; renderMetadata(row); renderStatusButtons(row); if (updateNotes) {{ suppressNotesAutosave = true; notesArea.value = row.qc_notes || ''; suppressNotesAutosave = false; }} renderQuicklook(row); preloadAdjacentImages(); }}
211
+ function scheduleAutosave() {{ if (suppressNotesAutosave) return; autosaveStatus.textContent = 'Saving notes...'; const requestId = ++autosaveCounter; clearTimeout(autosaveTimer); autosaveTimer = setTimeout(async () => {{ const row = getCurrentRow(); const body = new URLSearchParams({{ pwd_path: row.pwd_path, qc_status: row.qc_status, qc_notes: notesArea.value }}); try {{ await fetch('/update_notes', {{ method: 'POST', headers: {{ 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' }}, body }}); row.qc_notes = notesArea.value; if (requestId === autosaveCounter) autosaveStatus.textContent = 'Notes saved.'; }} catch (error) {{ autosaveStatus.textContent = 'Failed to save notes.'; }} }}, 400); }}
212
+ notesArea.addEventListener('input', scheduleAutosave); previousButton.addEventListener('click', () => {{ if (currentPosition > 0) {{ currentPosition -= 1; renderCurrentRow(); }} }}); nextButton.addEventListener('click', () => {{ if (currentPosition < filteredRowIndices.length - 1) {{ currentPosition += 1; renderCurrentRow(); }} }}); renderCurrentRow();
213
+ </script></body></html>
214
+ """
215
+
216
+
217
+ def launch_web_app(
218
+ config: QcConfig, host: str, port: int, open_browser: bool = True
219
+ ) -> None:
220
+ """Launch the local QC review app."""
221
+
222
+ def app(environ, start_response):
223
+ method = environ.get("REQUEST_METHOD", "GET")
224
+ path = environ.get("PATH_INFO", "/")
225
+ query = urllib.parse.parse_qs(environ.get("QUERY_STRING", ""))
226
+ if method == "GET" and path == "/qc_plot":
227
+ return _serve_qc_plot(config, start_response, query)
228
+ if method == "POST" and path in {"/update_notes", "/update_status"}:
229
+ content_length = int(environ.get("CONTENT_LENGTH", "0") or "0")
230
+ body = environ["wsgi.input"].read(content_length).decode("utf-8")
231
+ form = urllib.parse.parse_qs(body)
232
+ update_qc_entry(
233
+ config,
234
+ pwd_path=form.get("pwd_path", [""])[0],
235
+ qc_status=form.get("qc_status", ["pending"])[0],
236
+ qc_notes=form.get("qc_notes", [""])[0],
237
+ )
238
+ return _ok(start_response)
239
+ page = _render_page(config, query)
240
+ start_response("200 OK", [("Content-Type", "text/html; charset=utf-8")])
241
+ return [page.encode("utf-8")]
242
+
243
+ url = f"http://{host}:{port}"
244
+ if open_browser:
245
+ webbrowser.open(url)
246
+ CONSOLE.log(f"Starting QC web app at {url}")
247
+ with make_server(host, port, app) as server:
248
+ server.serve_forever()