The vital_sqi web app
vital_sqi ships a Dash-based web application that wraps the library’s
most common workflows: load a raw recording, run the SQI extraction
pipeline, browse the results segment by segment, tweak the classifier
thresholds, and export the per-segment decisions.
It is implemented in vital_sqi.app and is the recommended way to
explore a recording before committing to a programmatic batch run.
Launching the app
Development mode (with auto-reload disabled by default — see Why is auto-reload off?):
python -m vital_sqi.app.index
The Dash server starts on http://127.0.0.1:8050 and prints something like:
INFO:vital_sqi.app.app:Background callbacks enabled; cache at .cache/vital_sqi_app
Dash is running on http://127.0.0.1:8050/
Press Ctrl-C in the terminal to stop.
For a production deployment, import the underlying Flask server
object and run it behind any WSGI host (gunicorn, uvicorn, waitress, …):
# wsgi.py
from vital_sqi.app.app import server
if __name__ == "__main__":
from waitress import serve
serve(server, host="0.0.0.0", port=8050)
Background-callback cache
The Compute view’s long-running SQI extraction runs as a Dash background
callback backed by diskcache.
The cache directory is ./.cache/vital_sqi_app/ by default and is
included in .gitignore. Override with the
VITAL_SQI_APP_CACHE_DIR environment variable if you need a different
location.
If diskcache is not installed the app still imports and runs, but
the Compute view falls back to a foreground callback (the UI blocks
until the run finishes). Install it with:
pip install diskcache
Why is auto-reload off?
Werkzeug’s auto-reloader sometimes throws OSError [WinError 10038]
when restarting the dev server on Windows. We default
use_reloader=False in the __main__ entry point so the log stays
clean. Re-enable with:
set VITAL_SQI_APP_RELOAD=1
python -m vital_sqi.app.index
The Compute view
End-to-end flow:
+------------------------------------------------------------+
| [Drag & drop a recording] |
+------------------------------------------------------------+
| Wave type: PPG ▾ | fs: auto | duration: 30 s | overlap: 0 |
+------------------------------------------------------------+
| Signal column: [pleth — array of 100 samples/row (rec.)] ▾ |
+------------------------------------------------------------+
| SQI dictionary: ◉ Bundled ○ Upload custom |
| Parallel workers: 1 |
+------------------------------------------------------------+
| [ Run ] [ Cancel ] Computing SQIs over 278 segments… |
| ████████████████░░░░░░░░░ 50 % |
+------------------------------------------------------------+
Supported recording formats
Handled by vital_sqi.app.util.waveform_loader:
- Flat CSV (one sample per row)
Columns may include a timestamp (datetime or numeric seconds / milliseconds / microseconds — detected from the column name’s unit suffix) and one or more numeric signal columns. The loader picks a sensible default and shows the others in the Signal column dropdown.
- Oucru row-per-second CSV
The SmartCare / OUCRU exporter writes one row per second; each waveform column is a JSON list of samples for that second. The loader detects this automatically by sniffing the first non-empty cell of the chosen signal column. Sampling rate is inferred from the array length of the first row (i.e. “100 samples per second means 100 Hz”).
- EDF / EDF+ (
.edf,.bdf) Delegated to
vital_sqi.data.signal_io.ECG_reader()/PPG_reader(). Single-channel output; the dropdown is hidden.- MIT-WFDB (
.hea+.dat/.mat) Same delegation as EDF. Drop the
.heafile; the matching sidecar must be uploaded too if your browser doesn’t ship it automatically (rare with modern Chrome).
Signal-column dropdown
As soon as a CSV is staged, introspect_columns()
scans every column and ranks the candidates. Recommended columns
appear first with (recommended) after their name; for the OUCRU PPG
file you would see:
pleth — array of 100 samples/row (recommended)
red — array of 100 samples/row
ir — array of 100 samples/row
perfusion — array of 100 samples/row
hr — numeric, 8338 rows
o2 — numeric, 8338 rows
…
Picking a different column re-runs the loader against the same upload — no re-drop required.
Choosing the SQI dictionary
Two options:
Bundled calibrated default — points at the
vital_sqi/resource/sqi_dict.jsonshipped with the package; covers the full SQI catalogue documented in Introduction.Upload custom JSON — drop a hand-crafted
sqi_dict.jsonto restrict computation to a subset of SQIs or to override per-SQI arguments.
What happens on Run
The Run callback (background-callback when diskcache is installed) chains:
load_from_upload()→LoadedWaveform.split_segment()→segments,milestones.extract_sqi()→ per-segment SQI DataFrame.Writes both the SQI table and the raw waveform to the app’s
dcc.Storeso the Inspect view can read them.Unlocks the Inspect and Export tabs.
Failures at any step display a one-line error in the status bar and a full traceback in the server log; the disabled tabs stay disabled.
The Inspect view
Phase 2 of the app enhancement plan. Layout:
╔════════════════════════════════════════════════════════════════╗
║ Recording: rec.csv — PPG @ 100 Hz, 833 800 samples (8338 s) ║
║ 278 segments × 49 SQI columns ║
╚════════════════════════════════════════════════════════════════╝
╔══ Rules ════════════════════════════════════════════════════════╗
║ Threshold mode: ● Quantile ○ Auto-tune ○ Manual ║
║ Accept band: ░───●────────────────●─── p5–p95 ║
║ ☑ kurtosis_sqi ☑ perfusion_sqi ☑ correlogram_sqi ║
║ ☑ msq_sqi ☑ dtw_sqi ║
║ Auto-skipped: zero_crossings_rate_sqi, ectopic_sqi, hfe_sqi, … ║
║ [ Drop strictest rule ] would drop: msq_sqi (rejected 36) ║
╚═════════════════════════════════════════════════════════════════╝
╔══ Timeline ════════════════════════════════════════════════════╗
║ ▏██▏▏▏██████▏██████████▏▏▏███████████▏▏██████▏▏██████████▏ ║
║ ║
║ Click a cell to inspect… ║
╚════════════════════════════════════════════════════════════════╝
╔══ Segment 0023 • t=690 s • Δ30.0 s • accept ═══════════════════╗
║ ┌──── waveform ────┐ ┌──── SQI values ────┐ ║
║ │ │ │ kurtosis 3.21 │ ║
║ │ ~~~~~~~~~~~~~~~ │ │ perfusion 12.13 │ ║
║ │ │ │ correlogram 0.84 │ ║
║ └──────────────────┘ └────────────────────┘ ║
║ ║
║ Rule trace ║
║ 1. kurtosis_sqi 3.21 ACCEPT ║
║ 2. perfusion_sqi 12.13 ACCEPT ║
║ 3. correlogram_sqi 0.84 ACCEPT ║
║ 4. msq_sqi 0.95 ACCEPT ║
║ 5. dtw_sqi 32.45 ACCEPT ║
╚════════════════════════════════════════════════════════════════╝
Rule selection panel
Three concepts the user controls:
- Threshold mode
See SQI Pipeline and Calibration for the math. Three options:
Quantile — symmetric
(lower, upper)trim controlled by the range slider. Default p5/p95. Tightening rejects more, loosening (e.g. p1/p99) accepts more.Auto-tune — the per-rule quantile is computed from the target slider so the joint accept rate hits the target (default 85 %) under the independence approximation. Much more forgiving than plain Quantile when several rules are active.
Manual — the bounds in
rule_dict.jsonare used verbatim. Pick this when applying an externally calibrated rule set.
- Rules checklist
Every SQI that has a definition in the active rule dict and a non-degenerate distribution in this recording appears as a toggleable chip. The default pre-checks a small validated subset:
DEFAULT_RULE_COLUMNS = ( "kurtosis_sqi", "perfusion_sqi", "correlogram_sqi", "msq_sqi", "dtw_sqi", )
SQIs whose values are essentially constant across the recording (e.g.
zero_crossings_rate_sqion mean-centred clean PPG, orectopic_sqiwhen no ectopics are detected) collapse to a zero-width auto-mode band. They would reject every segment and drown out the real quality signal, so they are listed as Auto-skipped below the checklist and disabled.- Drop strictest rule
A one-click button that flags whichever active rule has an upward-outlier rejection count (modified Z-score on per-rule rejection tallies, threshold = median + 3·MAD). When you trust the underlying signal but suspect one SQI is too strict for this recording, click it to drop the rule and watch the timeline update.
Timeline & detail panel
The colour-coded bar chart shows every segment in chronological order; each cell is one segment, coloured green (
accept) or red (reject). Click a cell to load it into the detail panel.The segment-picker dropdown holds the same set, filterable by the all / accept / reject radios.
The detail panel plots the segment’s raw samples via Plotly (using
Scatterglfor performance — works up to ~10 M points). The SQI values for that row are shown in a side table.The rule trace at the bottom lists every active rule with its value and outcome. The first rule to reject is highlighted in red with a
← decisivemarker so you can see why a segment was flagged.
Classification runs on demand inside the view: whenever any of the rule-panel controls or the loaded SQI table change, the timeline and detail panel update without a page reload.
Robust mode (Phase 4)
Picking Robust in the Threshold mode radio replaces the entire
rule-band pipeline with
classify_segments_robust(). The classifier
ignores the rule dictionary and instead:
Rank-normalises every SQI column within the recording.
Combines them into a per-segment consensus score in
[0, 1].Detects the recording-level regime (
clean/bimodal/heavy_noise) using a bimodality coefficient + GMM check.Applies one of three regime-specific decision functions.
A small badge appears next to the Rules title showing the detected
regime, the mean consensus score, and a file flagged marker when
fewer than 20 % of segments accept — useful triage for very poor
recordings.
The rule checklist still applies as a column whitelist: only the checked SQIs contribute to the consensus score. The sliders are hidden because robust mode has no per-rule trim to set.
The Calibrate view (Phase 3)
Wraps vital_sqi.calibration.run_calibration.calibrate() in a
background callback so the user can re-derive thresholds without
touching a notebook. Layout:
╔════════════════════════════════════════════════════════════════╗
║ Wave type: PPG ▾ | Accept: 200 | Reject: 50 | duration 30 s ║
║ Lower pct: 5 | Upper pct: 95 | Seed: 42 ║
╚════════════════════════════════════════════════════════════════╝
║ [ Run calibration ] [ Cancel ] ║
║ █████████████████████████░░░░░ 88 % ║
╠════════════════════════════════════════════════════════════════╣
║ Thresholds (latest dry run) ║
║ sqi calibrated lower upper n_accept … ║
║ kurtosis_sqi True 0.52 7.14 1850 … ║
║ perfusion_sqi True 12.30 95.21 1850 … ║
║ … ║
╠════════════════════════════════════════════════════════════════╣
║ [ Save as default ] ✓ Saved 49 rules + 49 SQI templates. ║
║ Backup: backup_20260518_142331 ║
╚════════════════════════════════════════════════════════════════╝
Run always invokes calibrate(dry_run=True). The thresholds
are stored in a session-only dcc.Store and displayed as a
sortable / filterable DataTable; uncalibrated SQIs (constant
distribution, all-NaN, etc.) are still shown but italicised in grey
so the user can see exactly what got skipped.
Save as default is the only place the app writes files. It
re-runs the standard exporters
(export_rule_dict() /
export_sqi_dict()) on the
cached thresholds, which timestamp-back-up the previous defaults
before overwriting vital_sqi/resource/rule_dict.json and
vital_sqi/resource/sqi_dict.json. The Save button stays disabled
until a successful Run, so the destructive write requires two
deliberate clicks.
The Run button itself is independent of the rest of the app’s state — no recording or SQI table needs to be loaded. This is the calibration step that produces the bundled rule dict; it doesn’t read from any other view.
The Export view
Phase 5 replaces the old Rules / Apply pair with a single Export page. Each card on the page produces one self-contained artefact the user can share or feed into a downstream pipeline; no files touch disk until the corresponding button is pressed.
- Decisions CSV
The SQI table with the current accept/reject column from Inspect’s classifier. One row per segment; one column per SQI plus a final
decisioncolumn.- Rule dict JSON
A snapshot of the bounds Inspect is currently applying, in the same shape as
vital_sqi/resource/rule_dict.json. Replay with:from vital_sqi.pipeline.pipeline_functions import classify_segments classify_segments( sqis, rule_dict_filename="rule_dict_snapshot.json", ruleset_order=…, auto_mode="manual", # use the snapshot bounds verbatim )Only available in Quantile and Auto-tune modes (Manual mode would just re-export the bundled defaults; Robust mode has no per-rule bounds).
- Accepted segments ZIP
Per-segment CSVs of the raw waveform for every segment Inspect flagged as accept, bundled into a single archive. Requires the raw waveform to be available — i.e. the user must have come through Compute rather than uploading a pre-computed SQI table.
- HTML report
A standalone single-file report with the recording summary, accept/reject counts, per-SQI summary statistics, and the active configuration. Inline CSS, no external dependencies, safe to email or commit.
How state flows between views
State persists in browser stores (dcc.Store). Phase 5 simplified
this map significantly — there’s no more rule-set-store or
rule-dataframe; the rule dict always comes from the bundled file.
Store ID |
Lifetime |
What’s in it |
|---|---|---|
|
memory |
The current SQI table (one row per segment). Written by Compute (or by the SQI-table upload card on Compute), read by Inspect and Export. |
|
memory |
Raw signal samples plus sampling rate / wave type. Set only when Compute runs from a recording. Required for the Inspect view’s per-segment waveform panel and for Export’s “accepted-segments ZIP” download. |
|
memory |
|
|
memory |
The per-segment accept/reject decisions Inspect computes. Promoted to the app root in Phase 5 so Export can read the same decisions without re-running the classifier. |
All stores are memory-only, which is intentional: a 2-hour PPG
recording trivially exceeds the browser’s 5 MB localStorage
quota, and the workflow assumes a single linear session (drop a
recording, run Compute, then Inspect + Export immediately). A page
refresh resets everything — by design.
Troubleshooting
- Inspect / Export tabs stay disabled.
They unlock only after a valid SQI table is in the
dataframestore. Run Compute, or use the “Already have an SQI table?” card at the bottom of the Compute view.- “All segments rejected” on an apparently clean recording.
The default classifier is Quantile p5/p95 across five rules; independent trimming compounds. Switch the threshold mode to Auto-tune (slider at 85 %) — that’s the configuration that gave ~80 % accept on our reference SmartCare PPG recording (the same file is documented as a worked example in SQI Pipeline and Calibration).
- “None-XX.csv” files appearing under the notebook tutorial folder.
A notebook-execution artefact; the files are
save_segment’s output when called withsegment_name=None. Cleaned up automatically by the latest tutorials and excluded from version control.- ``OSError: [WinError 10038]`` in the server log.
Werkzeug auto-reloader closing a dev socket during a restart. Harmless; we disable the reloader by default to suppress it (see Why is auto-reload off?).
- Browser console errors after a callback.
Open the browser dev-tools network tab. The Python traceback appears both in the failed JSON response and in the terminal where you launched the app. Every callback uses
logger.exceptionrather thanprint, so each line is tagged with the module name (vital_sqi.app.views.<name>) for grep.
What’s coming
Phase 6 of the app enhancement plan:
Server-side state (Flask-Caching + per-session keys) so refreshes survive and multi-tab use stays isolated.
Phases 0 – 5 are shipped:
Phase 0 — housekeeping (logging, example downloads, app entry-point docs).
Phase 1 — Compute view + CSV/EDF/WFDB/Oucru loader.
Phase 2 — Inspect view with timeline, per-segment waveform, and the threshold-mode/auto-tune controls.
Phase 3 — Calibrate view.
Phase 4 — Robust classifier as a fourth threshold mode in Inspect.
Phase 5 — Streamlined sidebar (Compute → Inspect → Calibrate → Export); replaced the legacy Home / Rules / Apply dashboards with a single Export view that produces the decisions CSV, snapshot rule JSON, accepted-segment ZIP, and HTML report.
See also
SQI Pipeline and Calibration — the underlying programmatic workflow exposed by the GUI, including the full math behind the threshold-mode options.
Rule engine (vital_sqi.rule) — API reference for
Rule,RuleSet, and theauto_thresholdhelpers.Pipeline (vital_sqi.pipeline) — API reference for
extract_sqi()andclassify_segments().