Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ jobs:
env:
QT_QPA_PLATFORM: offscreen
run: python tools/run_ci_tests.py
- name: Verify actual Windows workbench cases
if: always()
run: python tools/verify_prediction_workbench_ci.py
- name: Preserve Python test evidence
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
Expand Down
37 changes: 33 additions & 4 deletions desktop/capture_desktop/screen_analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,17 @@
from pathlib import Path
from typing import Any

from PySide6.QtCore import QObject, QThread, Signal, Slot
from PySide6.QtCore import QEvent, QObject, Qt, QThread, Signal, Slot
from PySide6.QtWidgets import (
QComboBox,
QFileDialog,
QFrame,
QHBoxLayout,
QLabel,
QMessageBox,
QProgressBar,
QPushButton,
QScrollArea,
QSplitter,
QTextEdit,
QVBoxLayout,
Expand Down Expand Up @@ -144,7 +146,15 @@ def __init__(
self._build_controls(left_layout)
self._mappings = AnatomicalMappingPanel(persistence)
left_layout.addWidget(self._mappings)
splitter.addWidget(left)
controls_scroll = QScrollArea()
controls_scroll.setObjectName("AnalysisControlsScroll")
controls_scroll.setWidgetResizable(True)
controls_scroll.setFrameShape(QFrame.Shape.NoFrame)
controls_scroll.setMinimumWidth(
left.minimumWidth() + controls_scroll.verticalScrollBar().sizeHint().width()
)
controls_scroll.setWidget(left)
splitter.addWidget(controls_scroll)

self._gallery = FigureGallery()
splitter.addWidget(self._gallery)
Expand All @@ -158,6 +168,24 @@ def __init__(
self.package_loaded.connect(self._sources.load_package)
self._sources.changed.connect(self._sync_enabled)

def event(self, event: QEvent) -> bool:
if (
event.type() == QEvent.Type.ShortcutOverride
and event.key() == Qt.Key.Key_Space
and event.modifiers() == Qt.KeyboardModifier.NoModifier
):
focus = self.focusWidget()
if (
isinstance(focus, QPushButton)
and focus.hasFocus()
and self.isAncestorOf(focus)
):
# The application's checkpoint shortcut must not consume the
# native Space activation of a focused Analysis button.
event.accept()
return True
return super().event(event)

def _build_controls(self, root: QVBoxLayout) -> None:
path_row = QHBoxLayout()
self._path = QLabel("No package selected")
Expand Down Expand Up @@ -333,7 +361,8 @@ def _source_error(self) -> str | None:
return self._sources.error_for(str(self._command.currentData() or "qc"))

def _sync_enabled(self) -> None:
busy = self._thread is not None and self._thread.isRunning()
busy = self._thread is not None
self._extras.set_eval_busy(busy)
locked = self._recording_locked()
has_pkg = self._summary is not None
scope_error = self._scope_error()
Expand Down Expand Up @@ -396,7 +425,7 @@ def _append_log(self, line: str) -> None:
self._log.append(line)

def _start_job(self) -> None:
if self._summary is None or (self._thread and self._thread.isRunning()):
if self._summary is None or self._thread is not None:
return
if self._recording_locked():
QMessageBox.warning(
Expand Down
16 changes: 16 additions & 0 deletions desktop/capture_desktop/widgets_analysis_jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
)

from . import theme
from .widgets_analysis_predictions import EvaluationInput


def list_job_ids(package: Path, *, require_rel: str | None = None) -> list[str]:
Expand Down Expand Up @@ -47,6 +48,7 @@ def __init__(self, parent: QWidget | None = None) -> None:

self._box = QGroupBox("Job dependencies")
form = QFormLayout(self._box)
self._dependency_form = form
self._pose_job = QComboBox()
self._pose_job.setEditable(True)
self._kin_job = QComboBox()
Expand All @@ -67,6 +69,7 @@ def __init__(self, parent: QWidget | None = None) -> None:
win_row.addWidget(QLabel("Hop s"))
win_row.addWidget(self._hop_sec)
form.addRow("ML windows", win_row)
self._non_eval_rows = (self._pose_job, self._kin_job, self._feat_job, win_row)
root.addWidget(self._box)

hint = QLabel(
Expand All @@ -78,8 +81,12 @@ def __init__(self, parent: QWidget | None = None) -> None:
hint.setFont(theme.ui(8))
root.addWidget(hint)

self._evaluation = EvaluationInput()
root.addWidget(self._evaluation)

def set_package(self, package: str) -> None:
self._package = package
self._evaluation.set_package(package)
self.refresh()

def refresh(self) -> None:
Expand Down Expand Up @@ -127,9 +134,15 @@ def set_command(self, command: str) -> None:
self._bundle_job.setEnabled(need_bundle)
self._window_sec.setEnabled(need_win)
self._hop_sec.setEnabled(need_win)
for row in self._non_eval_rows:
self._dependency_form.setRowVisible(row, command != "eval")
self.setVisible(
command in ("pose", "kinematics", "ml_bundle", "eval")
)
self._evaluation.set_command(command)

def set_eval_busy(self, busy: bool) -> None:
self._evaluation.set_busy(busy)

def build_extra(self, command: str) -> dict:
extra: dict = {}
Expand All @@ -150,6 +163,7 @@ def build_extra(self, command: str) -> dict:
bid = self._bundle_job.currentText().strip()
if bid:
extra["ml_bundle_job_id"] = bid
extra.update(self._evaluation.extra())
return extra

def validate_for(self, command: str) -> str | None:
Expand All @@ -163,6 +177,8 @@ def validate_for(self, command: str) -> str | None:
return "Select a features job (radar preferred)."
if command == "eval" and not extra.get("ml_bundle_job_id"):
return "Select an ml_bundle job."
if command == "eval":
return self._evaluation.validate()
return None


Expand Down
155 changes: 155 additions & 0 deletions desktop/capture_desktop/widgets_analysis_predictions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
# SPDX-License-Identifier: GPL-3.0-only
"""Explicit evaluation input for the offline Analysis workbench."""

from __future__ import annotations

from pathlib import Path

from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QComboBox,
QFileDialog,
QGroupBox,
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QVBoxLayout,
QWidget,
)

from . import theme


class EvaluationInput(QGroupBox):
"""Choose a path; the existing evaluator admits its bytes when the job runs."""

def __init__(self, parent: QWidget | None = None) -> None:
super().__init__("Evaluation input", parent)
self.setObjectName("EvaluationInput")
self._package = ""
self._command = "eval"
self._revision = 0
self._busy = False
root = QVBoxLayout(self)
root.setContentsMargins(8, 8, 8, 8)
root.setSpacing(6)

self._mode = QComboBox()
self._mode.setObjectName("EvalInputMode")
self._mode.setAccessibleName("Evaluation input mode")
self._mode.addItem("Identity teacher (simulation)", "identity")
self._mode.addItem("External predictions file", "external")
root.addWidget(self._mode)

self._file_row = QWidget()
file_layout = QHBoxLayout(self._file_row)
file_layout.setContentsMargins(0, 0, 0, 0)
file_layout.setSpacing(6)
self._path = QLineEdit()
self._path.setObjectName("EvalPredictionPath")
self._path.setAccessibleName("Predictions JSON file")
self._path.setReadOnly(True)
self._path.setMinimumWidth(0)
self._path.setPlaceholderText("No predictions file chosen")
self._choose_button = QPushButton("Choose…")
self._choose_button.setObjectName("EvalPredictionChoose")
self._choose_button.setAccessibleName("Choose predictions JSON file")
self._choose_button.clicked.connect(self._choose)
self._clear_button = QPushButton("Clear")
self._clear_button.setObjectName("EvalPredictionClear")
self._clear_button.setAccessibleName("Clear predictions file choice")
self._clear_button.clicked.connect(self._clear)
file_layout.addWidget(self._path, 1)
file_layout.addWidget(self._choose_button)
file_layout.addWidget(self._clear_button)
root.addWidget(self._file_row)

self._help = QLabel()
self._help.setObjectName("Dim")
self._help.setWordWrap(True)
self._help.setTextFormat(Qt.TextFormat.PlainText)
self._help.setFont(theme.ui(8))
root.addWidget(self._help)
self._mode.currentIndexChanged.connect(self._mode_changed)
self._refresh()

def _mode_changed(self) -> None:
self._revision += 1
self._refresh()

def _refresh(self) -> None:
external = self._mode.currentData() == "external"
self._file_row.setVisible(external)
self._clear_button.setEnabled(bool(self._path.text()))
self._path.setToolTip(self._path.text())
self._help.setText(
"Compares supplied predictions with the selected ML bundle. "
"Source and window identities are checked before scoring."
if external
else "Fixture simulation: compares each teacher value with itself."
)

def set_package(self, package: str) -> None:
identity = str(Path(package).resolve()) if package else ""
if identity != self._package:
self._package = identity
self._revision += 1
self._path.clear()
self._refresh()

def set_command(self, command: str) -> None:
if command != self._command:
self._command = command
self._revision += 1
self.setVisible(command == "eval")

def set_busy(self, busy: bool) -> None:
if busy != self._busy:
self._busy = busy
self._revision += 1
self.setEnabled(not busy)

def _choose(self) -> None:
if not self.isEnabled() or self._command != "eval":
return
if self._mode.currentData() != "external":
return
self._revision += 1
revision = self._revision
filename, _selected_filter = QFileDialog.getOpenFileName(
self,
"Choose external predictions JSON",
self._path.text() or self._package,
"Prediction JSON (*.json);;All files (*)",
)
if revision != self._revision or not self.isEnabled() or not filename:
return
# Preserve the exact native choice, including significant whitespace.
self._path.setText(filename)
self._refresh()

def _clear(self) -> None:
self._revision += 1
self._path.clear()
self._refresh()
self._choose_button.setFocus()

def extra(self) -> dict[str, str]:
if self._mode.currentData() != "external":
return {}
# An empty external path remains explicit even if a caller skips validation.
return {"prediction_path": self._path.text()}

def validate(self) -> str | None:
if self._mode.currentData() != "external":
return None
filename = self._path.text()
if not filename:
return "Choose a predictions JSON file for external evaluation."
try:
if Path(filename).is_file():
return None
except (OSError, ValueError):
pass
return "The predictions file is unavailable. Choose an existing JSON file and run again."
18 changes: 18 additions & 0 deletions docs/design/ANALYSIS.md
Original file line number Diff line number Diff line change
Expand Up @@ -524,3 +524,21 @@ held-out participants, calibration, teacher accuracy or clinical validity.
Without --predictions, the prior identity_teacher_sim behavior is preserved.

Verification: python -m pytest tests/analysis/test_external_predictions.py.

### Native workbench: supplied prediction files

In the Analysis tab, select **Eval report**, the source **ML bundle job**, and
**External predictions file** under **Evaluation input**. **Choose…** selects
the JSON file; **Run** sends that explicit choice through the existing job
worker and evaluator. Empty or unavailable external choices block the run and
never select the identity simulation. **Identity teacher (simulation)** remains
the explicit default for fixture checks.

The control column scrolls at smaller window sizes. The workbench preserves a
choice after dialog cancellation and clears it when a different session is
opened. A running job captures its request, disables the evaluation-input controls, and offers the
existing cancellation action. The evaluator retains the bytes it receives when
the job executes; selecting a file does not freeze its contents.

See [Evaluate a predictions file in Analysis](research/EXTERNAL_EVALUATION_WORKBENCH.md)
for the operator flow, file-choice behavior and retained report/figure paths.
Loading
Loading