Audience: Frontend implementers.
Status: Draft v0.1 (companion to PRD §14; aligned with v1.0 MVP scope).
Stack: Python 3.11+, PySide6 (Qt 6.6+), QThreadPool/QRunnable for
background work (see ui/store/task_runner.py), matplotlib + pyqtgraph
for plots. No qasync — see docs/dev/DECISIONS.md D-003.
Distribution: packaged with PyInstaller; UI imports track2data engine.
Related: ./TECHNICAL_SPEC.md for the system-level view (tech stack, build, distribution, configuration hierarchy).
The UI is a thin layer over the engine. It owns:
- Wizard navigation (7 stages, PRD §14).
- A reactive project state store (single source of truth).
- Background-task orchestration so the GUI never blocks.
- Validation feedback, run-log surfacing, export workflow.
The UI never re-implements engine logic; it calls track2data.api.Engine.
[ ProjectStore (QObject signals) ]
│ ▲
▼ │
[ Wizard pages (QWidget) ]──────► [ Engine facade ]
│ (track2data.api.Engine)
▼
[ TaskRunner (QThreadPool) ] ─── progress / log signals ─► [ RunLogDock ]
ProjectStore— aQObjectholding the in-memoryProjectManifestand emitting fine-grained Qt signals when fields change. UI widgets bind to those signals.- Pages —
QWidgetsubclasses, one per stage. Pages read from the store, write back via setter methods that emit signals. TaskRunner— wraps engine calls that may take seconds (import, preprocess, run metrics, export) and runs them on aQThreadPoolwith progress reporting.RunLogDock— a dockable Markdown viewer subscribed to the engine's logging callback.
QMainWindow
├── QMenuBar
│ ├── File: New… / Open… / Save / Duplicate as template / Recent ▸ / Quit
│ ├── Edit: Undo / Redo / Preferences
│ ├── Run: Validate / Run pipeline / Export…
│ ├── View: Run log / Command palette / Theme ▸ (Light, Dark)
│ └── Help: About / Open docs / Report issue
│ (a theme pill sits at the right end of the menu bar)
├── Central: page QStackedWidget (one page per wizard stage)
│ ├── RunLogDock: collapsible 150 px drawer, `HH:MM:SS message` lines
│ └── FooterBar: Back · Run log · Run pipeline · Next (Export dataset on Preview)
├── LeftDock: WizardSidebar (painted rows: numbered dot, stage name, live summary)
└── Toasts: a dark pill near the bottom for info messages (status bar hidden)
The toolbar is gone; Back / Next / Run / Cancel remain QActions that the menu and the footer
buttons share. Preview & Export stays locked in the sidebar until a run has produced results.
Colours and type come from app/resources/track2data-{light,dark}.qss (tokens in the Harbor Navy
design handoff); app/theme.py loads them and remembers the choice in QSettings.
Command palette (Ctrl/Cmd-K, PRD §14): ui/dialogs/command_palette.py, a QDialog over the
main window listing stage jumps, run, validate, export, run log, theme and file actions. Each entry
runs what the menus run; entries that do not apply (no project, no run yet) are hidden.
class ProjectStore(QObject):
# Signals (one per top-level field; pages connect only to what they need)
projectChanged = Signal()
sessionsChanged = Signal()
calibrationChanged = Signal()
zonesChanged = Signal()
metadataChanged = Signal()
preprocessChanged = Signal()
metricsChanged = Signal()
exportChanged = Signal()
runLogAppended = Signal(str) # markdown line
taskProgress = Signal(str, int) # task_id, percent
taskFinished = Signal(str, object)# task_id, result
def __init__(self): ...
def manifest(self) -> ProjectManifest: ...
def update_calibration(self, cfg: CalibrationConfig): ...
# ... one setter per fieldState is never mutated in place; setters replace fields and emit
signals. Undo/redo (post-MVP) becomes a QUndoStack of manifest diffs.
Page-by-page contract: what the user sees, engine calls, validation, navigation gate.
For the user-facing perspective on each page (wireframes, decision branches, exact validation message text, save/resume logic) see
USER_WORKFLOW.md.
| Element | Behaviour |
|---|---|
| New / Open / Duplicate buttons | engine.manifest.new() / read() / duplicate() |
| Recent-projects list | from QSettings |
| Project name field | binds to store.manifest.project_name |
| Project directory picker | required to enable Next |
Validation: project name non-empty; directory writable.
Engine calls: manifest.new(name, dir), manifest.read(path).
| Element | Behaviour |
|---|---|
| Folder picker / drag-drop area | calls engine.import_session(path) async |
| Session table (QTableView) | columns: folder, reader, fps, frames, animals, has-video, has-identities, include? |
| Remove / Re-detect buttons | update store |
| Sample-frame thumbnail (right side) | engine.preview_frame(session_id) |
Validation (DV-1): failed imports stay in the table with a red
status icon and a tooltip carrying error.remediation.
Engine calls: Engine.import_sessions(paths) -> list[Session].
Gate: ≥ 1 session marked include.
| Element | Behaviour |
|---|---|
| Radio: scalar vs. body-length | mode selection |
| px-per-cm spinbox + helper "click two points to measure" | scalar mode |
| Min body-length samples spinbox | BL mode |
| Per-session orientation tag dropdown ("FT"/"FD"/custom) | for zone reuse |
Validation (DV-4): scalar mode requires px-per-cm > 0;
BL mode validates per session — sessions failing the sample-count
threshold are listed.
Engine calls: Engine.set_calibration(cfg).
Three-pane layout: thumbnail picker (top), polygon canvas (centre), zone list (right).
| Element | Behaviour |
|---|---|
| Frame thumbnail strip | pick frame from any included session |
Polygon canvas (QGraphicsView) |
draw / drag / delete vertices; live area readout |
| Zone list | name, level (main/secondary/custom), area (optional) |
| Import CSV / Export CSV buttons | round-trip ZoneSet via zones.io |
| Orientation toggle (FT/FD) | swap zone set per orientation tag |
Validation (DV-3): ≥ 3 vertices; no self-intersection
(checked via shapely is_valid from engine); within frame bounds.
Engine calls: Engine.set_zones(zone_set),
zones.io.read_csv(path).
| Element | Behaviour |
|---|---|
| File picker (CSV/XLSX) + "Skip metadata" option | metadata.loader.load |
| Mapping table | columns × canonical fields with dropdowns; aliases pre-applied (condition→treatment, date→trial_date) |
| Join-key configurator | radio: by session_id / by composite / by regex; regex with named groups |
| Live preview panel | matched (green), unmatched (yellow), conflicts (red) |
Validation (DV-2): required canonical fields must resolve. The Next
button stays disabled while any session is unmatched unless the
user explicitly ticks "Proceed without metadata for these sessions".
Engine calls: metadata.join.match(sessions, mapping).
Reality note: through v0.1.0 this was one page, split-pane, and the sidebar could only ever reach the Preprocessing half — Metrics and Processing were reachable only via the toolbar's Next button, never a direct click. They are now two separate screens with their own sidebar rows (
app/navigation.py); the split-pane description below is retained as the original intent but no longer matches the implementation, which is two independent, scrollable single-column screens.
Preprocessing (Page 6): one collapsible group per step
(gap_fill, jump_detect, identity_switch, smoothing, validate)
with enable toggle + parameter widgets. Defaults shown greyed; "Reset
to defaults" link per group.
Metrics (Page 7): three tabs (Individual / Group / Zone)
populated from metrics.list_for_level(level). Each row = checkbox +
name + ⓘ icon + ⚙ config icon (stub in v1 — see below). The registry
id ("IL-1") and the snake_case internal name ("path_length") are
never shown — they're engine/export identifiers, not something a
researcher reads to pick a metric; the id still drives selection
state internally as row user-data.
Identity-aware metrics (Metric.requires_identity) react to
SessionRef.is_identity_free() — the same predicate the engine gates
on — in three states: greyed out with a tooltip when every session is
identity-free; still selectable but tooltipped with the affected session
names when only some are; untouched otherwise. A session is
identity-free when idtracker.ai declared track_wo_identities, or when
the user ticked the Identity-free box for it on Stage 2. Zone tab
disabled when no zones are defined.
Footer: a "Time bins" spinbox (minutes, fractions allowed; the special value
"Whole session" = 0 = off) sits under Quality threshold on the real Metrics screen
and auto-commits to MetricSelection.timepoint_minutes.
Clicking the ⓘ icon next to a metric opens a MetricInfoDialog
(QDialog subclass, constructed from a Metric class — not an id
string) that renders the metric's documentation: MetricDocumentation field:
- Definition · Formula · Inputs · Assumptions / warnings · Reference
- Footer button: Copy citation (writes citation + DOI to clipboard)
- Closes on: title-bar ✕ ·
Escapekey (QDialog's own default behaviour) · click outside the dialog's rect (a QApplication-wide event filter installed for the dialog's lifetime, not a separate overlay widget)
The ⓘ icon is hidden when both documentation.formula_plain and
documentation.citation are None — those metrics fall back to the
existing tooltip-only behaviour. In practice this never currently
hides anything, since MetricDocumentation.formula_plain is a
required (non-None) str field on every built-in metric today; the
check is implemented as specified for forward compatibility. See
METRICS_SPEC.md §6 for the canonical
architecture and the per-metric content this dialog renders.
Validation: PreprocessConfig and MetricSelection are validated
through Pydantic on every change; invalid fields highlight red.
Engine calls: Engine.set_preprocess(cfg),
Engine.set_metric_selection(sel).
Read-only summary of entire pipeline configuration; optionally run a dry-run preview.
| Element | Behaviour |
|---|---|
| Summary table (read-only) | rows: Project name, Sessions count, Calibration mode, Zones count, Metadata (file or skipped), Preprocessing steps enabled, Metrics selected (count breakdown) |
| Validate button | calls Engine.validate_pipeline() (async); shows ✓ "Ready to run" or ✗ "Error: " |
| Run preview button | calls Engine.run_session(selected_session) via TaskRunner; caches result; shows progress bar |
| Preview-log viewer | collapsible; shows live run-log during preview |
Validation: All prior stages must be green. Validation must pass before export.
Engine calls: Engine.validate_pipeline(), Engine.run_session(session_id).
Select export format(s), configure per-exporter options, run export pipeline.
| Element | Behaviour |
|---|---|
| Exporter table (QTableWidget) | columns: enabled (checkbox), format_name, description, output_files; rows from exporters.registry |
| Target directory picker | QLineEdit (editable) + 📂 browse button; default = <project>/exports/<ISO timestamp>/ |
| Existing files list | collapsible QListWidget (shown if directory non-empty); with overwrite_confirmed checkbox |
| CLI snippet disclosure | copyable track2data run … command for headless reproduction |
| Progress bar | shown during export; indeterminate if no progress callbacks |
| Export log | collapsible; shows live run-log output |
| Export success receipt | table of written files + SHA-256 hashes; buttons: Copy CLI / Open folder / New export |
Validation (DV-3 / DV-8): ≥ 1 exporter selected; target directory exists or creatable; if non-empty, overwrite_confirmed must be ticked.
Engine calls: Engine.export(payload, exporters, out_dir).
Gate: Export button enabled only if validation passes.
This section provides implementation-ready detail for all 14 screens: widget types, data bindings, validation rules, error messages, and navigation logic.
Stage: Stage 1 (Project Setup)
Purpose: Initialize project identity and metadata.
Widget List:
- QLabel: "Project name *"
- QLineEdit:
project_name_input(required; alphanumeric + spaces/dashes; platform-illegal chars detected inline) - QLabel: "Project directory *"
- QLineEdit:
project_dir_input(read-only display of selected path) - QPushButton:
browse_dir_button(📂 icon; opens native folder picker) - QLabel: "Created" (section header)
- QLabel:
created_timestamp(read-only; ISO timestamp from manifest) - QLabel: "App version" (section header)
- QLabel:
app_version_label(read-only, e.g. "Track2Data 0.1.0") - QLabel: "Optional metadata" (collapsible section header)
- QLineEdit:
description_input(multi-line; placeholder "pH-tolerance choice trials…") - QLineEdit:
investigator_input(free text; placeholder "M. Bellio") - QLineEdit:
tags_input(comma-separated; placeholder "pilot, zebrafish, temp-stressor")
Data Bindings:
project_name_input↔ProjectStore.manifest.project_name(signal:projectChanged)project_dir_input←ProjectStore.manifest.folder(read-only from store)created_timestamp,app_version_label← manifest (read-only)- Optional metadata fields → future
ProjectManifestextension (not yet in models; coordinate with engine team)
Validation Rules:
project_namenon-empty (required)project_namecontains no OS-illegal chars (Windows:< > : " / \ | ? *; others:/only)project_dirmust exist or be user-creatableproject_dirmust be writable (os.access(path, os.W_OK))- If
project_dirnon-empty AND contains*.t2d.json: emit error "This directory already holds project ''. Choose a different directory or use Open from the Welcome screen."
Error Messages:
- "Project name is required." → red border on
project_name_inputfield + inline error text - "Project name contains illegal characters:
<chars>" → inline red text + suggestion - "Cannot write to ''. Pick a different directory or check folder permissions." → red banner
- "Directory already contains project ''. Choose a different directory or use Open from Welcome." → modal error dialog
Next Button Logic:
- Enabled only when:
project_namenon-empty ANDproject_dirwritable AND no conflicting project found - On Next: emit
store.projectChanged, autosave manifest, advance to Stage 2
Navigation Context:
- Back to Welcome always allowed (discard unsaved metadata)
- Sidebar nav to later stages allowed if this stage is valid (manifest autosaved on every field change)
Stage: Stage 2 (Session Management)
Purpose: Point at the folder a tracking program wrote, confirm which program it was, and add its sessions to the project.
As built (T0-8). Adding is two steps. Add Folders… (or a drop of folders or single files) starts a background scan, shown as an inline progress row with a Cancel button. A scan that fails is written inline (never as a modal). When it finishes, the confirm dialog (
ui/dialogs/confirm_format_dialog.py) opens over the screen; nothing is added until its Add N sessions button. The dialog is a thin view over the Qt-freeConfirmDraftand shows: the scan summary and a banner when the scan stopped at its budget; a chooser when the folder holds more than one kind of output; the software, with the alternatives that also recognised it and a High/Medium/Low confidence; an unverified badge for a reader not yet checked against real tracker output; the evidence; one form for the options the files do not record (frame rate, frame size, which keypoint) with where each value came from; and a session table (include, editable id, source, note). Add is disabled, with a tooltip naming why, until nothing is missing or clashing. When nothing is recognised the dialog lists the file types it did see and the software this version can read, and its only button is Close. The dialog opens withopen()(neverexec()), installs no outside-click filter (a combo popup is a separate window), and Escape cancels. The Reader column comes from the savedSessionRef.reader, so it names the software straight away and after reopening a project. The remainder of this section is the original design; the preview pane, per-row include checkboxes and Re-detect below are not built.
Widget List:
- QLabel: "Drag-drop session folders here, or click ➕ Add…"
- QDropZone / Custom QWidget: Accepts folder drag-drop; visual feedback (blue border on drag-over)
- QPushButton:
add_folders_button(➕ icon; opensQFileDialog.getExistingDirectories()) - QTableWidget:
session_table- Columns:
include(checkbox),session_id,reader_detected,fps,n_frames,n_animals,identity_status,has_video,status(icon + tooltip on error) - Rows: auto-generated from imported sessions; right-click context menu (Remove, Re-detect, Edit session_id)
- Columns:
- QPushButton:
remove_button(🗑 icon; removes selected row) - QPushButton:
redetect_button(🔄 icon; re-runs reader detection on selected rows) - QLabel: "Preview: <session_id>" (right side, section header)
- QLabel:
preview_folder_path(read-only; full path of selected session) - QLabel:
preview_reader_format(read-only; e.g. "idtrackerai v5.1") - QLabel:
preview_fps,preview_n_frames,preview_n_animals(read-only display) - QGraphicsView / QLabel:
preview_frame_canvas(thumbnail image from frame 0; empty if import fails)
Data Bindings:
session_tablerows ←ProjectStore.sessions(list ofSessionRef) (signal:sessionsChanged)- Table selection →
ProjectStore.selected_session_id(new optional field; not persisted) includecheckboxes ← → session internal state (extendSessionRefwithmarked_for_processing: bool = TrueOR use a separate dict inProjectStore)- Preview frame: triggered by
Engine.preview_frame(session_id)→ async viaTaskRunner, result →preview_frame_canvas
Validation Rules:
- ≥ 1 session ticked
include(required to advance) - Each failed-import row: display error icon + tooltip with
error.remediation(e.g. "trajectories.npy not found under '/trajectories'. Re-run idtracker.ai or check the folder layout.") - If mixed FPS across included sessions: yellow banner "Sessions have different frame rates (30, 60). Each will use its own FPS in metric computation."
- If all included sessions are identity-free: info banner "None of the included sessions have stable identities. Individual-level metrics will not be computed."
Error Messages:
- Per-row (on import failure): "'' is not a recognised tracker output. Supported: idtracker.ai 6.x output (the legacy v5 layout is also read; v4 is not yet supported)." (error icon + tooltip)
- Per-row: "trajectories.npy not found under '/trajectories'. …" (error icon + tooltip)
- Mixed FPS: yellow banner with "Proceed?" button or auto-proceed
- All identity-free: info banner (no blocking)
- 0 sessions included: Next button disabled, tooltip "Tick at least one session to continue."
Next Button Logic:
- Enabled only when ≥ 1 session marked
include - On Next: emit
store.sessionsChanged, callEngine.import_sessions(selected_paths)via TaskRunner, saveSessionReflist to manifest - On import failure (async): show error modal with remediation; user stays on this page to retry or fix
Navigation Context:
- Back to Stage 1 allowed; sessions preserved
- Sidebar nav forward allowed if ≥ 1 session ticked
Reality note (Part 2 of the post-v0.1.0 GUI fixes): the real
CalibrationScreenis a single page (mode radios, mode-specific controls, all together; edits auto-commit) rather than this section's aspirational two-screen chooser/data-entry split -- that mismatch predates this note and is unchanged here. What did change: there are now three modes, not two -- a "Session calibration" radio was added alongside Scalar and Body length, using each session's ownlength_unit(seetrack2data/calibration/session_unit.py), with a unit picker, a required confirmation checkbox, and a per-session readiness list. The widget names/behaviour below (measure-on-frame tool, BL sample-count table, orientation pairing) remain unbuilt.The same screen now also holds the project's Camera view (Not set / Top-down / Side view, a combo with a one-line explanation). It has its own auto-commit and writes
store.update_scene, never the calibration, so an unchanged or blocked calibration cannot swallow it and a calibration edit cannot reset it (D-036).
Stage: Stage 3 (Calibration)
Purpose: Select between scalar px-per-cm vs. body-length calibration modes.
Widget List:
- QRadioButton:
radio_scalar("Scalar px-per-cm") - QRadioButton:
radio_bodylength("Body length (recommended)") [pre-selected] - QLabel: Mode explanation text (collapsible)
- QLabel: "Why body-length?" disclosure button + collapsible help text (explaining per-individual calibration)
- QLabel: Sessions compatibility preview
- Shows which sessions have bbox data available (from
Session.bbox_table) - If 0 sessions have bbox: yellow banner "No session has body-length bounding box data. Scalar mode will be used."
- Shows which sessions have bbox data available (from
Data Bindings:
radio_scalar/radio_bodylength↔ProjectStore.calibration.mode(signal:calibrationChanged)- On mode change: mark calibration as dirty; optionally clear per-session data if switching modes
Validation Rules:
- Mode must be selected (always true via radio buttons; default = body-length)
Error Messages:
- (None expected; selection is free)
Next Button Logic:
- Always enabled (default mode is pre-selected)
- On Next: advance to Screen 3.2 (data entry for chosen mode); emit signal but do NOT yet mutate calibration state
Navigation Context:
- Back to Stage 2 allowed
- Changing mode from Screen 3.2 returns here (soft reset of calibration data)
Stage: Stage 3 (Calibration)
Purpose: Enter mode-specific calibration parameters.
Widget List (Scalar Mode):
- QLabel: "Scale factor (px per cm)"
- QDoubleSpinBox:
px_per_cm_spinbox(range 0.1–1000, decimals 2, default empty/0) - QPushButton:
measure_button("📏 Measure on frame")- On click: opens dialog to pick session + frame, click two points on canvas, compute distance, auto-fill spinbox
- QLabel: "Preview: 1 cm = pixels"
Widget List (Body-Length Mode):
- QLabel: "Min body-length samples per session"
- QSpinBox:
bl_min_samples_spinbox(range 1–100, default 30) - QLabel: "Per-session BL status:" (section header)
- QTableWidget:
bl_status_table- Columns:
session_id,bl_samples_count,median_bl_px,status(✓ ready / ⚠ insufficient / × error) - Rows auto-populated from included sessions
- Columns:
- QLabel: "Orientation pairing (for zone reuse)"
- QComboBox:
orientation_tag_combo(dropdown: FT / FD / custom; auto-generated from all included sessions' tags)
Data Bindings:
px_per_cm_spinbox↔ProjectStore.calibration.px_per_cm(scalar mode; signal:calibrationChanged)bl_min_samples_spinbox↔ProjectStore.calibration.bl_min_samples(BL mode)- Per-session orientation tags: read from extended
Sessionmodel (add fieldorientation_tag: str | None = None) - On mode change: clear opposite mode's data from store
Validation Rules (Scalar Mode):
px_per_cm > 0(required); if not, show inline error "px/cm must be greater than zero."
Validation Rules (Body-Length Mode):
- If ≥ 1 session below threshold: yellow banner "N session(s) have fewer than body-length samples and will be excluded from cm-based metrics."
- If all sessions below threshold: red modal "No session has sufficient body-length samples. Choose a lower threshold or use Scalar mode."
Error Messages:
- Scalar: "px/cm must be greater than zero." (inline red)
- BL: " session(s) below threshold…" (yellow banner)
- BL: "No session has sufficient samples. Lower threshold or switch mode." (red modal)
Next Button Logic:
- Scalar: enabled only if
px_per_cm > 0 - BL: enabled only if ≥ 1 session meets threshold (red modal blocks if all fail)
- On Next: emit
store.calibrationChanged, saveCalibrationConfig, advance to Stage 4
Navigation Context:
- Back to Screen 3.1 allowed (returns without losing data entry)
- Back to Stage 2 allowed (soft-resets calibration)
Stage: Stage 4 (Zones/ROIs)
Purpose: Load pre-defined ROIs from CSV or skip to manual drawing.
Widget List:
- QLabel: "Load saved ROI set (optional)"
- QPushButton:
import_csv_button("📥 Import CSV") - QLineEdit:
csv_path_input(read-only; displays selected file) - QLabel: "Or draw ROIs from scratch on the next screen."
- QTableWidget:
imported_rois_preview- Columns:
roi_name,vertex_count,area_px²,status(✓ valid / ✗ self-intersecting) - Empty if no import
- Columns:
Data Bindings:
- Imported
ZoneSet→ProjectStore.zones(signal:zonesChanged)
Validation Rules:
- CSV import is optional
- If imported: each ROI validated via shapely (≥ 3 vertices, not self-intersecting)
Error Messages:
- "Could not read ''. Ensure it is a valid CSV (ROI_index, ROI_name, Vertex_index, X, Y)."
- Per-row: "ROI '' has fewer than 3 vertices."
- Per-row: "ROI '' has crossing edges."
Next Button Logic:
- Always enabled (ROI import is optional)
- On Next: save imported zones, advance to Screen 4.2
Stage: Stage 4 (Zones/ROIs)
Purpose: Draw or edit ROIs interactively on video frames.
Widget List (Left Pane — Canvas):
- QComboBox:
session_picker(dropdown: select included session) - QSpinBox:
frame_number_spinbox(0 to n_frames-1) - QPushButton: "◄ Prev" / "▶ Next"
- QGraphicsView / Custom:
polygon_canvas- Display: video frame + overlaid zones (semi-transparent fills, vertex circles, edges)
- Draw mode: click to add vertices, right-click to finalize, ESC to cancel
- Drag mode: drag vertices, double-click to delete
- Hover: highlight nearby vertices/edges
- Toolbar: "✏ Draw" / "➖ Delete" / "↺ Reset" / "📥 Import CSV" / "📤 Export CSV"
Widget List (Right Pane — Zone List & Properties):
- QLabel: "Zones" (header)
- QListWidget:
zone_list_widget(rows:● zone_name, optional level badge)- Right-click context menu: Rename, Delete, Move up/down
- QPushButton: "+ Add zone"
- QLineEdit:
zone_name_edit(editable when zone selected) - QComboBox:
zone_level_combo(dropdown: main / secondary / custom) - QLabel: "Vertices: " (read-only)
- QLabel: "Orientation"
- QComboBox:
orientation_tag_combo(dropdown: FT / FD / custom)
Data Bindings:
session_picker→ triggers frame loadframe_number_spinbox→ triggers frame load- Polygon vertices → real-time update of area via shapely
zone_list_widget↔ProjectStore.zones.rois
Validation Rules:
- Each ROI ≥ 3 vertices
- No self-intersecting ROIs
- No vertex outside frame bounds (warning allowed)
- No duplicate ROI names
Error Messages:
- "ROI '' has fewer than 3 vertices." (inline, Next disabled)
- "ROI '' has crossing edges. Move a vertex to fix." (inline, Next disabled)
- "ROI extends beyond frame. It will be clipped during metric computation." (warning banner)
Next Button Logic:
- Enabled if all ROIs valid (or no ROIs and user skips zone metrics on Stage 7)
- On Next: emit
store.zonesChanged, advance to Stage 5
Stage: Stage 5 (Metadata & Mapping)
Purpose: Choose metadata file (CSV/XLSX) or skip.
Widget List:
- QLabel: "Attach experimental metadata (optional)"
- QPushButton: "📂 Choose file (CSV / XLSX)"
- QLineEdit:
metadata_path_input(read-only) - QLabel: "File info: rows, columns"
- QPushButton: "Skip metadata for now"
Data Bindings:
metadata_path_input→ProjectStore.metadata_source.path
Validation Rules:
- File must be readable CSV or XLSX
- File must have ≥ 1 row and ≥ 1 column
Error Messages:
- "Could not read ''. Ensure it is a valid CSV or XLSX file."
- "File is empty."
Next Button Logic:
- Enabled if: (file loaded) OR ("Skip" clicked)
- On file selection: trigger async load via TaskRunner, then auto-advance or show Next button
- On Skip: emit
metadataChanged(None), advance to Stage 6
Stage: Stage 5 (Metadata & Mapping)
Purpose: Map CSV columns to canonical fields; define join key; preview matches.
Widget List (Mapping Table):
- QTableWidget:
mapping_table- Columns:
source_column,→,canonical_field(QComboBox dropdown),sample_value(read-only) - Rows auto-populated from CSV header
- Columns:
- QLabel: "Aliases pre-applied (condition→treatment, date→trial_date, ...)"
Widget List (Join Key):
- QRadioButton:
radio_by_session_id("by session_id") - QRadioButton:
radio_by_composite("by composite key:")- Sub-widget: QMultiSelect dropdown of canonical fields
- QRadioButton:
radio_by_regex("by regex pattern:")- Sub-widget: QLineEdit regex (e.g.
N(?P<trial>\d+)_T(?P<rep>\d+))
- Sub-widget: QLineEdit regex (e.g.
Widget List (Match Preview):
- QTableWidget:
preview_table- Columns:
session_id,status,matched_row/error - Rows: one per included session
- Status: ✓ matched / ⚠ unmatched / ✗ conflict / ✗ error
- Columns:
- QLabel: "Unmatched rows in metadata: " (if any)
- QCheckBox:
proceed_without_unmatched("Proceed without metadata for the unmatched sessions")
Data Bindings:
mapping_tableedits →ProjectStore.mapping.rulesradio_by_*selections →ProjectStore.mapping.join_keys/join_regex- On any change: trigger async
metadata.join.match(...)→ update preview table
Validation Rules:
- All required canonical fields must map
- No column mapped to >1 canonical field
- If unmatched sessions:
proceed_without_unmatchedmust be checked - If conflicts (≥2 rows match 1 session): cannot advance
Error Messages:
- "Canonical field '' is required; pick a source column."
- "Column '' is assigned to both '' and ''."
- "Some sessions unmatched. Tick 'Proceed' or refine the join key."
- "Session '' matches >1 metadata row. Refine or remove duplicate."
Next Button Logic:
- Enabled if: required fields mapped AND (all matched OR
proceed_without_unmatchedchecked) AND no conflicts - On Next: emit
metadataChanged, saveMetadataSource+MappingRule, advance to Stage 6
Stage: Stage 6 (Preprocessing)
Purpose: Configure trajectory cleaning steps.
Widget List (5 collapsible sections):
Gap Filling:
- QCheckBox:
gap_fill_enabled(☑, default True) - QSpinBox:
gap_fill_max_frames(range 1–1000, default 30)
Jump Detection:
- QCheckBox:
jump_enabled(☑, default True) - QComboBox:
jump_method(sd_multiple / percentile, default sd_multiple) - QDoubleSpinBox:
jump_sd_mult(shown if sd_multiple; range 1–100, default 10.0) - QDoubleSpinBox:
jump_percentile(shown if percentile; range 0–100, default 99.0) - QDoubleSpinBox:
jump_pct_mult(shown if percentile; default 2.0) - QComboBox:
jump_replacement(nan / linear_interp, default linear_interp)
Identity Switch Correction:
- QCheckBox:
id_switch_enabled(☐, default False — re-permutes 17.1% of a real recording and injects ~640px single-frame teleports, measured against the real corpus; off pending a fragment-boundary-aware replacement, seetrack2data/core/models.py'sIdSwitchCfg) - QDoubleSpinBox:
id_switch_tier1_ratio(range 1–10, default 1.5) - QCheckBox:
id_switch_tier2_hungarian(☑, default True) - QSpinBox:
id_switch_consolidate_window(range 1–100, default 5)
Smoothing:
- QCheckBox:
smoothing_enabled(☑, default True) - QComboBox:
smoothing_method(none / moving_avg / savgol, default savgol) - QSpinBox:
smoothing_window(range 3–51, odd only, default 5) - QSpinBox:
smoothing_polyorder(range 1–5, default 2; validation: < window)
Coverage Validation:
- QCheckBox:
coverage_validation_enabled(☑, default True) - QSpinBox:
coverage_min_track_frames(range 0–N, default 0) - QDoubleSpinBox:
coverage_max_na_pct(range 0–1, default 0.10; display as %)
Global Controls:
- QPushButton: "Reset all to defaults"
Data Bindings:
- All fields ↔
ProjectStore.preprocess(PreprocessConfig) (signal:preprocessChanged) - On invalid field: highlight red; disable Next
Validation Rules:
gap_fill_max_frames > 0jump_sd_mult > 0smoothing_windowodd (auto-round if even + warn)smoothing_polyorder < smoothing_windowcoverage_max_na_pct ≤ 0.5(warn if >0.5)
Error Messages:
- "Smoothing window must be odd; rounding to ." (warning, auto-fix)
- "Polynomial order must be < window length." (inline error)
Next Button Logic:
- Enabled if all validation passes (preprocessing always valid by default)
- On Next: emit
preprocessChanged, savePreprocessConfig, advance to Screen 6.2
Stage: Stage 7 (Metrics)
Purpose: Select which metrics to compute.
Widget List:
- QTabWidget:
metric_tabs(3 tabs: Individual / Group / Zone)
Per-tab structure:
- QTableWidget:
metric_list- Columns:
include(checkbox),metric_name(the display label, e.g. "Distance Travelled" — never the registry id or the snake_case internal name),info(ⓘ icon),config(⚙ icon — stub in v1: shows "Not yet implemented"; no Screen 6.3 or config schema exists yet) - Rows: auto-populated from
metrics.list_for_level(level) - Greyed rows: metrics where
Metric.requires_identityisTrue, when every session in the project satisfiesSessionRef.is_identity_free()(with tooltip). When only some sessions do, the row stays selectable and the tooltip names them ("Will be skipped for 3 of 12 identity-free sessions: …") — metric selection is one global list, so refusing the tick outright would make those metrics unavailable for the sessions that can support them. Sessions whose probe has not landed are unknown, not identity-free, and don't trigger either state. - Disabled Zone tab if no zones defined on Stage 4
- Columns:
MetricInfoDialog (Modal):
- Opens on ⓘ click, constructed from the row's
Metricclass directly - Renders
Metric.documentationfields (definition, formula_plain, formula_latex, inputs, assumptions, warnings, citation, citation_doi) - Footer button: "Copy citation" (to clipboard)
- Close: title-bar ✕, Escape, or a click outside the dialog's rect (QApplication-wide event filter)
Data Bindings:
- Checkboxes ↔
ProjectStore.metrics.individual/.group/.zone SessionRef.has_stable_identitiesandSessionRef.track_wo_identities↔ populated by a background probe (read_sessionviaTaskRunner) when a session is added in Stage 2SessionRef.identity_free_override↔ the Identity-free checkbox on Stage 2;Nonemeans "follow the tracker", and a re-probe never clears it
Validation Rules:
- ≥1 metric selected across all tabs (required)
- Identity-free sessions → rows for
requires_identitymetrics greyed when every session is identity-free; tooltipped-but-selectable when only some are. A checked metric still persists in the manifest either way —Engine.compute_metricsskips it per session and records the skip in the export'sskipped_metricsand the run README. Engine.validate()warns when every session is identity-free and every selected metric requires identity, since the run would then produce diagnostics only- No zones → Z-* tab disabled
Error Messages:
- "Select at least one metric." (Next button disabled + tooltip)
Next Button Logic:
- Next is the shared toolbar ◀ Back / Next ▶ action, not a per-screen button — its enabled state depends only on stack position (
page_index < last_page), not on how many metrics are selected, and it just advances theQStackedWidgetto the next built stage (the Processing screen). It does not itself save the selection.MetricSelectionis saved by the screen itself: ticking a metric or changing the quality threshold auto-commits after a 200 ms pause (ui/widgets/autocommit.py) viaProjectStore.update_metrics()(this is what emitsmetricsChanged), andMainWindowflushes any pending edit when the page is left. - Screen 6.3 (below) is not implemented — there is no config schema or navigation target for it yet, so Next never routes there.
Status: This dedicated full-page screen (one table for every selected metric's parameters, plus a "Global parameters" section) does not exist in the shipped app and nothing below is built. What did ship, on the real Screen 6.2 (§6.10)'s ⚙ button, is smaller in scope: a per-metric modal
MetricConfigDialog(ui/dialogs/metric_config_dialog.py), opened one metric at a time, mirroringMetricInfoDialog's shape rather than this page's. It reuses this section's per-row ↺ reset-to-default idea but not the combined-table or global-parameters layout. Kept as a design reference for a possible future consolidated screen; everything else in this section is proposed, not current behavior. The "6.3" label is unchanged from before Preprocessing/Metrics became separate stages (§5.6) — it was never renumbered to "7.x", since resolving the Screen-X.Y numbering scheme for real needs its own pass, not a side effect of this one.
Stage: Stage 7 (Metrics)
Purpose: Configure metric-specific parameters (e.g., activity threshold).
Widget List:
-
QTableWidget:
metric_config_table- Columns:
metric_id,parameter_name,value,unit,reset - Rows: auto-generated from selected metrics' config schemas
- Example rows:
- IL-4 Activity threshold: QDoubleSpinBox (default 1.0) BL/s
- IL-7 Min freezing bout: QSpinBox (default 5) frames
- GL-3 Minimum speed: QDoubleSpinBox (default 0.1) BL/s
- Reset button (↺) per row
- Columns:
-
QLabel: "Global parameters" (section header)
-
QDoubleSpinBox:
timepoint_minutes_spinbox(range 0–N, 0=whole session, default None) — built, as_timepoint_spinon the real Metrics screen -
QDoubleSpinBox:
quality_threshold_slider(range 0–1, default 0.0; display as slider or spinner; masks per-frame metrics whenid_probabilities[frame, animal] < threshold) — already implemented today, on the real Screen 6.2 (§6.10) as_quality_spin, not gated behind this unbuilt screen
Data Bindings:
timepoint_minutes_spinbox↔ProjectStore.metrics.timepoint_minutesquality_threshold_slider↔ProjectStore.metrics.quality_threshold- Per-metric config → new
MetricSelection.config: dict[str, Any] = {}field
Validation Rules:
timepoint_minutes >= 0quality_thresholdin [0, 1]- Per-metric validation (e.g., activity threshold < 10 BL/s → warning)
Error Messages:
- "Activity threshold > 5 BL/s. Typical cruise is <2 BL/s. Confirm or reset." (warning)
Next Button Logic:
- Enabled if all config parameters valid
- On Next: emit
metricsChanged, advance to Stage 8 (Processing)
| UI surface | Engine entry point |
|---|---|
| Page 1 New/Open | Engine.new_project(name, dir) / Engine.open_project(path) |
| Page 2 import | Engine.import_sessions(paths) -> list[Session] |
| Page 2 thumbnail | Engine.preview_frame(session_id) -> QImage-friendly bytes |
| Page 3 calibration | Engine.set_calibration(cfg) |
| Page 4 zones | Engine.set_zones(zone_set) / zones.io.read_csv |
| Page 5 metadata | metadata.loader.load / metadata.join.match / Engine.set_metadata_mapping |
| Page 6 preprocess | Engine.set_preprocess(cfg) |
| Page 7 metrics | Engine.set_metric_selection(sel) |
| Page 8 processing | Engine.run(out_dir, progress=...) -> RunResult (real entry point; supersedes the aspirational run_session-per-preview flow §5.7 describes) |
| Page 9 export | Engine.export(payload, exporters, out_dir) |
| Run-log dock | engine logging callback → store.runLogAppended signal |
Engine is constructed once per project and lives on the main thread;
mutating methods are cheap (they only update the manifest). Heavy
methods (import_sessions, run_session, export) accept an optional
progress callback and are dispatched through TaskRunner.
class TaskRunner(QObject):
def submit(self, task_id: str, fn: Callable, *args, **kwargs): ...
# emits taskProgress(task_id, percent) and taskFinished(task_id, result)- Uses
QThreadPool.globalInstance()for I/O-bound tasks and delegates CPU-heavy work to the engine'sProcessPoolExecutor(the engine streams(percent, message)back to the runner). - A toolbar Cancel action sends a cooperative cancel signal; long operations check it between sessions.
- Per-task progress is shown in the status bar; failed tasks raise a
modal with
error.remediationplus a "Copy details" button (copies full traceback to clipboard).
| Severity | UI element |
|---|---|
info |
toast in status bar (3 s) |
warning |
yellow banner at top of page; persistent until dismissed |
error |
modal dialog blocking page advance; includes remediation + Copy details |
Every page exposes is_valid: bool and validation_errors: list[str].
The wizard sidebar shows a tick / cross / warning icon per stage. Stage
selection is allowed in either direction; the Run action requires all
stages green.
- Two themes (light + dark) via Qt stylesheets (
app/resources/*.qss); system-preference detection on startup, remembered inQSettings, switchable from View ▸ Theme or the menu-bar pill. Screens setobjectName/ dynamic properties (role,chip,banner,card) and never hard-code colours. - Fonts: Atkinson Hyperlegible and IBM Plex Mono are loaded from
app/resources/fonts/when present; the stylesheets fall back to system fonts otherwise. - All controls keyboard-navigable (
Taborder curated per page). - Screen-reader labels via
setAccessibleName/setAccessibleDescription. - Contrast palette validated against WCAG AA at design time.
- All units (
s,cm,BL/s) shown in labels and tooltips.
| Hook | Use case |
|---|---|
WizardRegistry.register(page_cls, after="zones") |
drop in a custom stage (e.g. a stats page for v1.2) |
MetricConfigWidgetRegistry |
metric plug-ins ship a per-metric config widget that the metrics tab renders automatically |
ExporterRegistry |
exporter plug-ins auto-populate the Export tab |
| Theme plug-ins | additional QSS files discoverable via entry point |
- Plot library: matplotlib (already in scientific deps) vs. pyqtgraph (better interactivity). Current pick: matplotlib for static plots, pyqtgraph for the trajectory canvas where pan/zoom is wanted.
- Whether the polygon canvas allows arbitrary zone naming or constrains
to a fixed level list ("main", "secondary") to stay compatible with
the existing pipeline column names. Current pick: free-text name +
leveldropdown with the two defaults + "custom". Async strategy:Settled (qasyncintegration for first-classasync defengine methods vs.TaskRunner-only.docs/dev/DECISIONS.mdD-003):TaskRunner-only, built onQThreadPool/QRunnableand shipped inui/store/task_runner.py.qasyncis not used anywhere in v1.0; revisit no earlier than v1.1.
See also the user-facing walkthrough with screenshots: docs/guide/USER_GUIDE.md. The screenshots are regenerated by scripts/generate_guide_screenshots.py.