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.
- fusiqc-0.0.1a1/PKG-INFO +57 -0
- fusiqc-0.0.1a1/README.md +43 -0
- fusiqc-0.0.1a1/pyproject.toml +32 -0
- fusiqc-0.0.1a1/src/fusiqc/__init__.py +5 -0
- fusiqc-0.0.1a1/src/fusiqc/_cli.py +64 -0
- fusiqc-0.0.1a1/src/fusiqc/_config.py +46 -0
- fusiqc-0.0.1a1/src/fusiqc/_dataset.py +68 -0
- fusiqc-0.0.1a1/src/fusiqc/_qc.py +352 -0
- fusiqc-0.0.1a1/src/fusiqc/_web.py +248 -0
fusiqc-0.0.1a1/PKG-INFO
ADDED
|
@@ -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
|
+
[](https://pypi.org/project/fusiqc/)
|
|
16
|
+
[](https://pypi.org/project/fusiqc/)
|
|
17
|
+
[](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
|
+

|
|
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
|
fusiqc-0.0.1a1/README.md
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
[](https://pypi.org/project/fusiqc/)
|
|
2
|
+
[](https://pypi.org/project/fusiqc/)
|
|
3
|
+
[](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
|
+

|
|
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,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()
|