修复回放标定选择与曲线显示数据保留

让标定选择进入处理和缓存,避免显示设置清空曲线。102 项针对性检查通过,全量门禁仍有停止时序、模型尺寸与原生崩溃问题。
This commit is contained in:
ykcheungab
2026-10-19 16:30:36 +08:00
parent 768f560820
commit 9d4563e929
12 changed files with 478 additions and 97 deletions
+1
View File
@@ -360,6 +360,7 @@
"device_family": "This calibration belongs to {family}, not Finger", "device_family": "This calibration belongs to {family}, not Finger",
"reference": "Reference frame", "reference": "Reference frame",
"reference_time": "{seconds} s", "reference_time": "{seconds} s",
"reference_missing": "The recording has no frame {frame}.",
"blocked": "This profile cannot be used: {reason}" "blocked": "This profile cannot be used: {reason}"
}, },
"record": { "record": {
+1
View File
@@ -360,6 +360,7 @@
"device_family": "这份标定属于 {family},不能用于 Finger", "device_family": "这份标定属于 {family},不能用于 Finger",
"reference": "参考帧", "reference": "参考帧",
"reference_time": "{seconds} 秒", "reference_time": "{seconds} 秒",
"reference_missing": "录制中没有第 {frame} 帧。",
"blocked": "这套配置不能用:{reason}" "blocked": "这套配置不能用:{reason}"
}, },
"record": { "record": {
+22 -16
View File
@@ -1,9 +1,8 @@
# Replay panel contract # Replay panel contract
The replay panel plays one recording — or one video — against a single cursor. Each The replay panel plays one recording or video against one cursor. Part A
capability states its requirement, data source and a falsifying check. The result format and ([replay-analysis.md](replay-analysis.md)) and Part B
playback semantics are owned by Part A ([replay-analysis.md](replay-analysis.md)) and Part B ([replay-analysis-part-b.md](replay-analysis-part-b.md)) own format and playback semantics.
([replay-analysis-part-b.md](replay-analysis-part-b.md)).
Status: Parts A and B shipped at `cf49b08`, the depth channel at `9e20fd0`, the result-aware mode Status: Parts A and B shipped at `cf49b08`, the depth channel at `9e20fd0`, the result-aware mode
plan at `1aa4543`. The cache root and the lifetime sweep are implemented; the display-control plan at `1aa4543`. The cache root and the lifetime sweep are implemented; the display-control
@@ -11,12 +10,10 @@ separation (Part C) is not.
## 1. Load a video ## 1. Load a video
Selecting a video always gives a playable timeline, and the panel says what the file is. `load_video` treats video beside `forces.csv` as a recording; otherwise it opens a bare video
`load_video` calls a folder holding `forces.csv` beside the video a recording; anything else (`VideoOnlySource`), with `playback.video_only_opened` on the rail and
opens as a bare video (`VideoOnlySource`) and plays, with `playback.video_only_opened` on the `playback.video_only_notice` in the notes. Undecodable files are refused with their reason.
rail and `playback.video_only_notice` in the notes. A file that cannot be decoded is refused Bare video lacks device-measured force rows and `session.json` metadata.
with its reason. What a bare video lacks is force rows *measured by a device*, and
`session.json` metadata.
**A bare video can be analysed, and is asked how.** The two files the pass reads are *generated* **A bare video can be analysed, and is asked how.** The two files the pass reads are *generated*
for the video in an app-owned workspace and every later step is the existing recording path; the for the video in an app-owned workspace and every later step is the existing recording path; the
@@ -72,13 +69,13 @@ One cursor drives all three.
| View | Data source | | View | Data source |
|---|---| |---|---|
| Charts | the recorded `forces.csv` (`RecordingCurveSource`), or — over a bare video — the result's own recomputed trace (`AnalysisCurveSource`) | | Charts | recorded `forces.csv` (`RecordingCurveSource`), or a recomputed trace for bare videos and reconfigured recordings (`AnalysisCurveSource`) |
| 2D canvas | the cached per-frame samples and depth, drawn at display time | | 2D canvas | the cached per-frame samples and depth, drawn at display time |
| 3D pane | the packet built from the cached frame (`AnalysisSession.packet` → `Scene3DView.submit_packet`) | | 3D pane | the packet built from the cached frame (`AnalysisSession.packet` → `Scene3DView.submit_packet`) |
The charts read one of two sources, and the header names which: `chart.mode_replay` for a The charts read one of two sources, and the header names which: `chart.mode_replay` for a
recording's own `forces.csv` (*the* measurement — a device was in the loop) and recording's own `forces.csv` (*the* measurement — a device was in the loop) and
`chart.mode_recomputed` for the trace the pass recomputed from a bare video `chart.mode_recomputed` for the recomputed trace
(`FRAME_FLAG_FORCE`, 28 B/frame; `provenance.sources["forces"]` says which). With neither — (`FRAME_FLAG_FORCE`, 28 B/frame; `provenance.sources["forces"]` says which). With neither —
nothing analysed yet, or a profile whose chain produced no force for this footage — the header nothing analysed yet, or a profile whose chain produced no force for this footage — the header
states why (`chart.mode_no_trace`) rather than leaving an empty plot. states why (`chart.mode_no_trace`) rather than leaving an empty plot.
@@ -98,6 +95,19 @@ sampled on the pass's own grid, so the frame carries that policy (`ImageFrame.ar
Verify: `test_replay_analysis.py::test_the_3d_pane_is_fed_the_same_frame_the_canvas_draws`, Verify: `test_replay_analysis.py::test_the_3d_pane_is_fed_the_same_frame_the_canvas_draws`,
`::test_a_display_change_that_moves_the_grid_is_refused_not_served`. `::test_a_display_change_that_moves_the_grid_is_refused_not_served`.
### Calibration choices and curve retention
Recording setups scope calibration, confirmed device, unloaded and tracker references to one
directory; the profile stays locked. References use recorded IDs/times. Chosen calibration content and force-channel choice enter
the cache key. A complete `sources.forces="recomputed"` result replaces the displayed CSV trace,
labelled `chart.mode_recomputed`; original files remain unchanged. Failed/cancelled passes
replace no trace. Without an override, recorded force retains priority.
Display controls preserve measurements, session time and partial axes, including FN-only.
New live runs remain independent; manual graph reset may clear the graph. Storage remains
bounded and reports drops. See the [SDK calibration contract](../../../../orisys-sdk/docs/calibration-model-contract.zh.md).
Verify: `test_recording_recalibration.py`, `test_curve_retention.py`, `test_force_history.py`.
## 5. What the 2D view can show ## 5. What the 2D view can show
The modes come from the frame's own fields and from whether a result exists (`plan_image`, The modes come from the frame's own fields and from whether a result exists (`plan_image`,
@@ -171,7 +181,3 @@ wipe-on-exit preference and result export (≈70 MB per ten minutes).
* The render cost (≈1.4 ms) and the PNG estimate (180–540 MB per ten minutes) are the owner's * The render cost (≈1.4 ms) and the PNG estimate (180–540 MB per ten minutes) are the owner's
bench; Part B §14's snapshots (4.2–4.9 kB each) suggest the estimate is high. bench; Part B §14's snapshots (4.2–4.9 kB each) suggest the estimate is high.
* A chosen calibration can be applied to the **box** chain only (`Sensor(cal_path=…)`). The
finger runtime opens its sensor without one, so a non-default curve for a finger profile is
refused rather than silently ignored — the SDK has no entry point for it (reported, not
worked around).
+33 -28
View File
@@ -9,6 +9,7 @@ from pathlib import Path
from typing import Any from typing import Any
from orisys_app_shared.acquisition.constants import FORCE_SAMPLE_RATE_HZ from orisys_app_shared.acquisition.constants import FORCE_SAMPLE_RATE_HZ
from orisys_app_shared.analysis.bare_video import BareVideoSetup
from orisys_app_shared.acquisition.display_orientation import ( from orisys_app_shared.acquisition.display_orientation import (
DEFAULT_FINGER_ORIENTATION, DEFAULT_FINGER_ORIENTATION,
effective_orientation, effective_orientation,
@@ -158,9 +159,8 @@ class StudioMainWindow(QMainWindow):
self.prefs: dict[str, Any] = dict(prefs or {}) self.prefs: dict[str, Any] = dict(prefs or {})
self._theme = str(self.prefs.get("theme") or "dark") self._theme = str(self.prefs.get("theme") or "dark")
self._language = str(self.prefs.get("language") or "zh_CN") self._language = str(self.prefs.get("language") or "zh_CN")
#: Reference-frame override for the next analysis pass on a real recording #: Accepted choices are scoped to each recording; display changes only read them.
#: (from Analysis ▸ Analysis setup…). Bare-video workspaces store this in session.json. self._recording_analysis_setups: dict[Path, BareVideoSetup] = {}
self._analysis_reference_override: int | None = None
# The view preset, the calibration panel, the 3D pane's switch and the fps # The view preset, the calibration panel, the 3D pane's switch and the fps
# readout are the controllers' state; the window reads and writes them through # readout are the controllers' state; the window reads and writes them through
# the shims below. # the shims below.
@@ -2330,19 +2330,16 @@ class StudioMainWindow(QMainWindow):
Clearing the curves alone is not a display reset *here*: a recording is only Clearing the curves alone is not a display reset *here*: a recording is only
drawn when its source is (re)attached — measured, the curves stay empty through drawn when its source is (re)attached — measured, the curves stay empty through
playback — so the operator would be left with a blank chart where the trace used playback — so the operator would be left with a blank chart where the trace used
to be. Re-attaching the recording's own source is the path opening it takes: the to be. Re-attaching the current recorded or recomputed source restores the
history, curves, selection and window go back to their defaults, the whole curves, selection and window. The playhead returns to the start so the chart
recording is drawn again, and the playhead returns to the start so the chart
cursor and the transport agree. cursor and the transport agree.
It touches nothing the device owns (there is no device session in this view) and It touches nothing the device owns. The replay controller keeps a supported
no preference: the unit the charts are in is kept, unlike ``force_plots.reset``, unit for the active source instead of resetting the unit to raw.
which drops the display back to raw.
""" """
reader = self.replay.reader() reader = self.replay.reader()
if reader is not None: if reader is not None:
self.force_plots.set_recording_source(reader) self.replay_ctl.apply_source(True)
self.force_plots.show_all()
# The chart cursor is this view's playhead: both go back to the start. The # The chart cursor is this view's playhead: both go back to the start. The
# cursor is set even without a reader, because a data-only recording has no # cursor is set even without a reader, because a data-only recording has no
# video for the engine to move to. # video for the engine to move to.
@@ -2600,32 +2597,31 @@ class StudioMainWindow(QMainWindow):
progress callback; both are ``None`` for a caller that only wants to ask whether progress callback; both are ``None`` for a caller that only wants to ask whether
a compatible result is already stored. a compatible result is already stored.
Two facts a *generated* workspace adds and a recording never has: the calibration An accepted recording setup supplies calibration, device confirmation, zero
the operator chose (a recording names the curve in force, not the file), and window and reference without rewriting session.json. Its pass stores a separate
whether to store the chain's own per-frame force. Beside a recording, force is a force trace; a recording with no override retains its original measured curves.
measurement in ``forces.csv`` and the pass stores none of it; over a bare video
there is no CSV, so the chain's recomputation is all there is and ``synthetic`` in
the session is what says so.
""" """
from orisys_app_shared.analysis.service import AnalysisService, chain_spec_for from orisys_app_shared.analysis.service import AnalysisService, chain_spec_for
from orisys_app_shared.record import RecordingReader from orisys_app_shared.record import RecordingReader
session = RecordingReader(Path(recording)).session() session = RecordingReader(Path(recording)).session()
synthetic = dict(session.get("synthetic") or {}) synthetic = dict(session.get("synthetic") or {})
setup = self._recording_analysis_setups.get(Path(recording).resolve())
calibration = setup.calibration if setup is not None else synthetic.get("calibration")
chain = chain_spec_for( chain = chain_spec_for(
session, session,
calibration=synthetic.get("calibration") or None, calibration=calibration or None,
device_id=synthetic.get("device_id") or None, device_id=setup.device_id if setup is not None else synthetic.get("device_id") or None,
calibration_disabled=(setup is not None or bool(synthetic)) and not calibration,
) )
reference = synthetic.get("reference_frame") reference = setup.reference_frame if setup is not None else synthetic.get("reference_frame")
if reference is None:
reference = getattr(self, "_analysis_reference_override", None)
return AnalysisService( return AnalysisService(
Path(recording), Path(recording),
chain=chain, chain=chain,
cancelled=cancelled, cancelled=cancelled,
on_progress=on_progress, on_progress=on_progress,
store_force=bool(synthetic), store_force=bool(synthetic) or setup is not None,
unloaded_reference_frames=None if setup is None else setup.unloaded_reference_frames,
reference_frame=None if reference is None else int(reference), reference_frame=None if reference is None else int(reference),
) )
@@ -2688,9 +2684,8 @@ class StudioMainWindow(QMainWindow):
def _recording_analysis_overrides(self, recording: Path) -> bool: def _recording_analysis_overrides(self, recording: Path) -> bool:
"""Ask for reference frame (and optional calibration) on a real recording. """Ask for reference frame (and optional calibration) on a real recording.
The profile is locked to the session's own chain. Accept stores a reference-frame The profile stays locked to the session's chain. Accepted choices belong to
override on the window for the next :meth:`_analysis_service` call; declining this recording; declining leaves its current result and choices alone.
leaves the current result alone.
""" """
from orisys_app_shared.analysis import bare_video from orisys_app_shared.analysis import bare_video
from orisys_app_shared.record import RecordingReader from orisys_app_shared.record import RecordingReader
@@ -2706,23 +2701,33 @@ class StudioMainWindow(QMainWindow):
catalog = scan_sensor_catalog() catalog = scan_sensor_catalog()
config_ref = str(dict(session.get("device") or {}).get("config_ref") or "") config_ref = str(dict(session.get("device") or {}).get("config_ref") or "")
selected = self.sensor_picker.selected_entry() selected = self.sensor_picker.selected_entry()
previous = self._recording_analysis_setups.get(Path(recording).resolve())
dialog = VideoAnalysisDialog( dialog = VideoAnalysisDialog(
video=Path(video), video=Path(video),
facts=facts, facts=facts,
catalog=catalog, catalog=catalog,
window=self, window=self,
initial_profile=config_ref or (selected.ref if selected is not None else ""), initial_profile=config_ref or (selected.ref if selected is not None else ""),
initial_reference_frame=getattr(self, "_analysis_reference_override", None), initial_reference_frame=None if previous is None else previous.reference_frame,
initial_calibration=None if previous is None else previous.calibration,
prefer_no_calibration=previous is not None and previous.calibration is None,
initial_device_id=None if previous is None else previous.device_id,
lock_profile=True, lock_profile=True,
theme=self._theme, theme=self._theme,
remembered_calibration=self.live_calibration_for, remembered_calibration=self.live_calibration_for,
) )
dialog.set_reference_times(
{frame.frame_id: frame.t_rel_s for frame in reader.info().frames if frame.in_video},
initial_frame=None if previous is None else previous.reference_frame,
)
if previous is not None:
dialog.unloaded_reference.setChecked(previous.unloaded_reference_frames == 30)
if dialog.exec() != VideoAnalysisDialog.DialogCode.Accepted: if dialog.exec() != VideoAnalysisDialog.DialogCode.Accepted:
return False return False
setup = dialog.choice() setup = dialog.choice()
if setup is None: if setup is None:
return False return False
self._analysis_reference_override = setup.reference_frame self._recording_analysis_setups[Path(recording).resolve()] = setup
return True return True
def _submit_measurement_3d( def _submit_measurement_3d(
@@ -416,8 +416,8 @@ class ReplayController(QObject):
For a bare / synthetic video this rebuilds the workspace from the new answers; For a bare / synthetic video this rebuilds the workspace from the new answers;
a different calibration or reference frame changes the result key, so the pass a different calibration or reference frame changes the result key, so the pass
runs again. For a real recording, only the reference frame is overridden for the runs again. A real recording also accepts calibration and zero/reference
next pass (the session's own profile stays locked). choices while keeping its recorded profile locked.
""" """
from orisys_app_shared.analysis import bare_video from orisys_app_shared.analysis import bare_video
@@ -452,9 +452,8 @@ class ReplayController(QObject):
if not self._host.recording_analysis_overrides(Path(recording)): if not self._host.recording_analysis_overrides(Path(recording)):
return return
self._clear_analysis_state() self._clear_analysis_state()
# Drop a cached result that was keyed under a different reference so the # The service looks up the new key. Keep the last complete result
# new pass is what serves the cursor. # readable until a replacement commits, including after cancellation.
self.results.close()
self.refresh_state() self.refresh_state()
self.on_analysis_requested() self.on_analysis_requested()
return return
@@ -1437,19 +1436,20 @@ class ReplayController(QObject):
"""Point the charts at the recording (or back at the live session).""" """Point the charts at the recording (or back at the live session)."""
if active and self._engine.is_loaded(): if active and self._engine.is_loaded():
reader = self._engine.reader() reader = self._engine.reader()
self._charts.set_recording_source(reader) kind = self._serve_curves()
# A recording is shown in a unit it actually carries: newton only when # A recording is shown in a unit it actually carries: newton only when
# it has a calibrated axis somewhere, otherwise the charts would be # it has a calibrated axis somewhere, otherwise the charts would be
# empty and every card a dash. The operator's preference is kept when # empty and every card a dash. The operator's preference is kept when
# the recording can honour it. # the recording can honour it.
wanted = "N" if self._charts.unit() == "N" else "raw" if kind == "recorded":
unit = self.unit_for(reader) wanted = "N" if self._charts.unit() == "N" else "raw"
self._engine.set_unit(unit) unit = self.unit_for(reader)
self._host.set_display_unit(unit) self._engine.set_unit(unit)
if unit != wanted: self._host.set_display_unit(unit)
self._host.notify(t("status.units_raw_fallback")) if unit != wanted:
self._charts.show_unit_fallback() self._host.notify(t("status.units_raw_fallback"))
self._charts.show_all() self._charts.show_unit_fallback()
self._charts.show_all()
self._image_panel.set_source_kind("replay") self._image_panel.set_source_kind("replay")
return return
if active and self._engine.is_video_only(): if active and self._engine.is_video_only():
@@ -1481,7 +1481,8 @@ class ReplayController(QObject):
the loop. This is the measurement. the loop. This is the measurement.
* ``recomputed`` — the analysis result's per-frame force, which the *pass* produced * ``recomputed`` — the analysis result's per-frame force, which the *pass* produced
by re-running the vision chain over the video (``provenance.sources["forces"]``). by re-running the vision chain over the video (``provenance.sources["forces"]``).
A bare video has nothing else, and it must never be read as the first. A bare video has nothing else. An explicitly reconfigured recording also
uses this trace once its complete result is adopted, preserving its CSV.
When there is neither, the panel is told *why* instead of being left empty under When there is neither, the panel is told *why* instead of being left empty under
wording that implies data is coming: nothing has been analysed yet, or the profile's wording that implies data is coming: nothing has been analysed yet, or the profile's
@@ -1489,11 +1490,13 @@ class ReplayController(QObject):
for frames it cannot calibrate). Returns which of the three happened. for frames it cannot calibrate). Returns which of the three happened.
""" """
reader = self._engine.reader() reader = self._engine.reader()
if reader is not None and (Path(reader.directory) / "forces.csv").is_file():
self._charts.set_recording_source(reader)
return "recorded"
if session is None: if session is None:
session = self.available_result() session = self.available_result()
provenance = getattr(getattr(session, "reader", None), "provenance", None)
recomputed = dict(getattr(provenance, "sources", {}) or {}).get("forces") == "recomputed"
if reader is not None and not recomputed:
self._charts.set_recording_source(reader)
return "recorded"
if session is None: if session is None:
self._charts.set_curve_source( self._charts.set_curve_source(
None, reason=t("chart.trace_not_analysed") None, reason=t("chart.trace_not_analysed")
@@ -240,6 +240,8 @@ class ForcePlotsPanel(QFrame):
lowpass_cutoff_hz=0.0, lowpass_cutoff_hz=0.0,
y_axis_min_span=0.5, y_axis_min_span=0.5,
max_fps=DEFAULT_MAX_FPS, max_fps=DEFAULT_MAX_FPS,
# Live packets and every source already use session-relative seconds.
time_origin=0.0,
) )
root = QVBoxLayout(self) root = QVBoxLayout(self)
@@ -556,8 +558,20 @@ class ForcePlotsPanel(QFrame):
self._render_value_cards() self._render_value_cards()
def set_window_sec(self, sec: float) -> None: def set_window_sec(self, sec: float) -> None:
sec = max(0.1, float(sec))
if sec == self.history.window_sec:
return
self.history.set_window_sec(sec) self.history.set_window_sec(sec)
self._clear_curves() if self._mode == "browse":
self.refresh_from_store()
elif self._source_ready:
self._reload_from_store()
else:
state = self.history.state()
if state is not None:
values = self._curve_values(state.values)
for index, curve in enumerate(self.curves):
curve.setData(state.times, values[:, index])
self._apply_axis_mode() self._apply_axis_mode()
def set_axis_limits( def set_axis_limits(
@@ -945,6 +959,8 @@ class ForcePlotsPanel(QFrame):
def follow_latest(self) -> None: def follow_latest(self) -> None:
"""Leave history browsing and resume the live rolling window.""" """Leave history browsing and resume the live rolling window."""
self._set_mode("follow") self._set_mode("follow")
if self._source_ready:
self._reload_from_store()
self._apply_axis_mode() self._apply_axis_mode()
def show_all(self) -> None: def show_all(self) -> None:
@@ -1326,16 +1342,24 @@ class ForcePlotsPanel(QFrame):
data = self._source.window( data = self._source.window(
max(first, last - span), last, unit=self._unit max(first, last - span), last, unit=self._unit
) )
# This is a display cache, never the source. Replace it rather than append
# another copy of the same rows on each unit/window/source refresh.
self.history.clear()
if len(data) == 0: if len(data) == 0:
self._clear_curves()
return return
self._availability_from_window(data) self._availability_from_window(data)
values = np.asarray(data.values, dtype=float) values = np.asarray(data.values, dtype=float).copy()
for index in range(len(data)): mask = plottable_axis_mask(values, data.valid)
if not bool(data.valid[index]): values[~mask] = np.nan
continue valid_rows = np.flatnonzero(mask.any(axis=1))
if not len(valid_rows):
self._clear_curves()
return
# Raw-only startup frames have no Newton sample. Keep later invalid rows
# as gaps, but do not count the absent prefix as calibrated measurements.
for index in range(int(valid_rows[0]), len(data)):
row = values[index] row = values[index]
if not all(np.isfinite(v) for v in row):
continue
self.history.push( self.history.push(
float(data.times[index]), float(data.times[index]),
float(row[0]), float(row[0]),
@@ -1394,7 +1418,7 @@ class ForcePlotsPanel(QFrame):
def _preview_fixed_y(self) -> bool: def _preview_fixed_y(self) -> bool:
"""Whether to paint fixed axes before any sample has arrived.""" """Whether to paint fixed axes before any sample has arrived."""
if self.history.sample_count > 0: if self.history.sample_count > 0 or self._mode == "browse":
return self._uses_fixed_y() return self._uses_fixed_y()
if self._unit == "N": if self._unit == "N":
return bool(self._fixed_newton) return bool(self._fixed_newton)
@@ -1410,9 +1434,15 @@ class ForcePlotsPanel(QFrame):
plot.getAxis("left").setTicks([_nice_ticks(ymin, ymax, count=5)]) plot.getAxis("left").setTicks([_nice_ticks(ymin, ymax, count=5)])
else: else:
plot.getAxis("left").setTicks(None) plot.getAxis("left").setTicks(None)
plot.enableAutoRange(axis="y", enable=True)
plot.getViewBox().updateAutoRange()
if self._mode == "follow": if self._mode == "follow":
state = self.history.state()
x_range = (
state.x_range if state is not None else (0.0, self.history.window_sec)
)
for plot in self.plots: for plot in self.plots:
plot.setXRange(0.0, self.history.window_sec, padding=0.0) plot.setXRange(*x_range, padding=0.0)
self._sync_zero_lines() self._sync_zero_lines()
def _sync_zero_lines(self) -> None: def _sync_zero_lines(self) -> None:
@@ -102,6 +102,7 @@ class VideoAnalysisDialog(QDialog):
super().__init__(window) super().__init__(window)
self._video = Path(video) self._video = Path(video)
self._facts = facts self._facts = facts
self._reference_times: dict[int, float] | None = None
self._catalog = catalog self._catalog = catalog
self._theme = str(theme) self._theme = str(theme)
self._entries: list[CatalogEntry] = profiles_for_video(catalog.entries) self._entries: list[CatalogEntry] = profiles_for_video(catalog.entries)
@@ -287,6 +288,9 @@ class VideoAnalysisDialog(QDialog):
self.note_label.setText(t("video_analysis.blocked", reason=blocked)) self.note_label.setText(t("video_analysis.blocked", reason=blocked))
return None return None
reference = int(self.reference_spin.value()) reference = int(self.reference_spin.value())
if self._reference_times is not None and reference not in self._reference_times:
self.note_label.setText(t("video_analysis.reference_missing", frame=reference))
return None
problem = self._device_problem() problem = self._device_problem()
if problem: if problem:
self.note_label.setText(problem) self.note_label.setText(problem)
@@ -298,7 +302,10 @@ class VideoAnalysisDialog(QDialog):
# the first frame it processes already *is* the reference and no reset is owed. # the first frame it processes already *is* the reference and no reset is owed.
# Saying so as ``None`` keeps a reset the pass cannot apply from being recorded # Saying so as ``None`` keeps a reset the pass cannot apply from being recorded
# as one that happened. # as one that happened.
reference_frame=None if reference == 1 else reference, reference_frame=(
None if reference == (min(self._reference_times) if self._reference_times else 1)
else reference
),
device_id=self._confirmed_device_id(), device_id=self._confirmed_device_id(),
unloaded_reference_frames=30 if self.unloaded_reference.isChecked() else 0, unloaded_reference_frames=30 if self.unloaded_reference.isChecked() else 0,
) )
@@ -479,9 +486,24 @@ class VideoAnalysisDialog(QDialog):
return return
self.accept() self.accept()
def set_reference_times(self, times: dict[int, float], *, initial_frame: int | None = None) -> None:
"""Use a recording's actual frame IDs and session-relative seconds."""
if not times:
return
self._reference_times = dict(times)
first, last = min(times), max(times)
self.reference_spin.setRange(first, last)
self.reference_spin.setValue(first if initial_frame is None else int(initial_frame))
self._refresh_time_label()
def _refresh_time_label(self) -> None: def _refresh_time_label(self) -> None:
fps = float(self._facts.fps) or 0.0 fps = float(self._facts.fps) or 0.0
seconds = (int(self.reference_spin.value()) - 1) / fps if fps > 0 else 0.0 reference = int(self.reference_spin.value())
seconds = (
self._reference_times.get(reference, 0.0)
if self._reference_times is not None
else (reference - 1) / fps if fps > 0 else 0.0
)
self.reference_time_label.setText( self.reference_time_label.setText(
t("video_analysis.reference_time", seconds=f"{seconds:.3f}") t("video_analysis.reference_time", seconds=f"{seconds:.3f}")
) )
+114
View File
@@ -0,0 +1,114 @@
"""Display controls retain measurements and redraw the same source timeline."""
import numpy as np
import pytest
from orisys_app_shared.force.session import SessionSample, SessionStore
from orisys_app_shared.force.source import StoreCurveSource
from orisys_studio.ui.force_plots import ForcePlotsPanel
def _store(*, partial=False):
store = SessionStore()
for index in range(21):
raw = float(index + 100)
shear = float("nan") if partial else raw / 100
store.append(SessionSample(
frame_id=index, t_rel_s=float(index),
fn_raw=raw, fx_raw=-raw, fy_raw=raw,
fn_n=raw / 100, fx_n=shear, fy_n=shear,
fn_raw_processed=raw, fx_raw_processed=-raw, fy_raw_processed=raw,
fn_n_processed=raw / 100, fx_n_processed=shear, fy_n_processed=shear,
))
return store
def _curve(panel, axis=0):
x, y = panel.curves[axis].getData()
if x is None:
return np.empty(0), np.empty(0)
return np.asarray(x, dtype=float), np.asarray(y, dtype=float)
@pytest.mark.parametrize("browse", [False, True])
def test_y_limit_settings_keep_trace_window_cursor_and_store(make_window, browse):
window = make_window(units="raw", plot_window_sec=4.0)
store = _store()
window.force_plots.set_store(store)
if browse:
window.force_plots.set_window(5.0, 12.0)
window.force_plots.set_cursor(8.0)
before = tuple(array.copy() for array in _curve(window.force_plots))
x_range = window.force_plots.plots[0].viewRange()[0]
# No acquisition tick follows: an idle or paused view must redraw immediately.
window.display_ctl.apply_plot_settings({"fn_raw_max": 2000.0})
for actual, expected in zip(_curve(window.force_plots), before):
np.testing.assert_array_equal(actual, expected)
assert window.force_plots.plots[0].viewRange()[0] == pytest.approx(x_range)
assert window.force_plots.cursor_time() == 8.0
assert len(store.visible(0.0, 20.0).times) == 21
@pytest.mark.parametrize("kind", ["memory", "disk", "analysis"])
def test_window_and_unit_changes_preserve_partial_axes_and_session_time(qt_app, kind):
store = _store(partial=True)
panel = ForcePlotsPanel()
try:
source = StoreCurveSource(store)
source.kind = kind
panel.set_curve_source(source)
for unit in ("N", "raw", "N"):
panel.set_units(unit)
for seconds in (4.0, 8.0, 8.0):
panel.set_window_sec(seconds)
x, y = _curve(panel)
assert len(x) > 1
assert x[-1] == 20.0
assert x[0] == 20.0 - seconds
assert y[-1] == pytest.approx(1.2 if unit == "N" else 120.0)
assert panel.plots[0].viewRange()[0][1] == pytest.approx(20.0)
if unit == "N":
assert np.isnan(_curve(panel, 1)[1]).all()
assert np.isnan(_curve(panel, 2)[1]).all()
assert len(store.visible(0.0, 20.0).times) == 21
finally:
panel.close()
panel.deleteLater()
def test_back_to_latest_reloads_the_live_tail_without_duplicate_samples(qt_app):
panel = ForcePlotsPanel()
try:
panel.set_store(_store())
for _ in range(2):
panel.set_window(2.0, 7.0)
panel.follow_latest()
x, y = _curve(panel)
np.testing.assert_array_equal(x, np.arange(16.0, 21.0))
np.testing.assert_array_equal(y, np.arange(116.0, 121.0))
panel.reset()
assert _curve(panel)[0].size == 0
finally:
panel.close()
panel.deleteLater()
def test_auto_y_range_keeps_replayed_data_visible_after_unit_switch(qt_app):
panel = ForcePlotsPanel()
try:
panel.set_store(_store())
panel.show_all()
panel.set_axis_limits(
fn_min=0, fn_max=20, shear_max=20,
fn_raw_min=0, fn_raw_max=0, shear_raw_max=0,
)
panel.set_units("N")
panel.set_units("raw")
qt_app.processEvents()
x, y = _curve(panel)
assert x.size == 21
low, high = panel.plots[0].viewRange()[1]
assert low <= y.min() <= y.max() <= high
finally:
panel.close()
panel.deleteLater()
@@ -0,0 +1,173 @@
"""A recording's analysis choices reach processing, cache identity and curves."""
import hashlib
import json
from pathlib import Path
from types import SimpleNamespace
import pytest
from orisys_app_shared.analysis.bare_video import BareVideoSetup
from orisys_app_shared.analysis.service import AnalysisService, ChainSpec
from orisys_app_shared.i18n import t
from PySide6.QtWidgets import QDialog
from orisys_studio import app_window, replay_controller
def _calibration(tmp_path):
from orisys.calibration import load
path = tmp_path / "chosen.json"
path.write_text(json.dumps(load(sensor_type="box").to_dict()), encoding="utf-8")
return path
@pytest.mark.parametrize("calibrated", [True, False])
def test_recording_dialog_keeps_all_choices_for_its_own_recording(
make_window, replay_recording, tmp_path, monkeypatch, calibrated,
):
calibration = _calibration(tmp_path) if calibrated else None
setup = BareVideoSetup(
profile_ref="shipped/box", calibration=calibration, reference_frame=12,
device_id="confirmed" if calibrated else None,
unloaded_reference_frames=30 if calibrated else 0,
)
monkeypatch.setattr(app_window.VideoAnalysisDialog, "exec", lambda self: QDialog.Accepted)
monkeypatch.setattr(app_window.VideoAnalysisDialog, "choice", lambda self: setup)
window = make_window()
before = (Path(replay_recording) / "session.json").read_bytes()
assert window._recording_analysis_overrides(replay_recording)
service = window._analysis_service(replay_recording)
assert service.chain.calibration == calibration
assert service.chain.device_id == setup.device_id
assert service.chain.calibration_disabled is (not calibrated)
assert service.reference_frame == 12
assert service.unloaded_reference_frames == setup.unloaded_reference_frames
assert service.store_force
# Another recording must not inherit this one's reference or model.
other = tmp_path / "other"
other.mkdir()
(other / "session.json").write_bytes(before)
untouched = window._analysis_service(other)
assert untouched.chain.calibration is None
assert untouched.reference_frame is None
assert not untouched.store_force
assert (Path(replay_recording) / "session.json").read_bytes() == before
def test_chosen_calibration_content_and_force_channel_change_cache_key(replay_recording, tmp_path):
calibration = _calibration(tmp_path)
chain = ChainSpec(sensor_type="box", calibration=calibration)
service = AnalysisService(replay_recording, chain=chain, store_force=True)
original = service.dependency_report()["key"].digest
data = json.loads(calibration.read_text(encoding="utf-8"))
data["meta"] = {**data.get("meta", {}), "note": "changed coefficients file"}
calibration.write_text(json.dumps(data), encoding="utf-8")
assert service.dependency_report()["key"].digest != original
recorded = AnalysisService(replay_recording, chain=chain, store_force=False)
assert recorded.dependency_report()["key"].digest != service.dependency_report()["key"].digest
def test_reference_selector_uses_recorded_ids_and_times(qt_app, tmp_path):
from orisys_app_shared.analysis.bare_video import VideoFacts
from orisys_app_shared.sensors import scan_sensor_catalog
dialog = app_window.VideoAnalysisDialog(
video=tmp_path / "clip.mp4",
facts=VideoFacts(tmp_path / "clip.mp4", 33, 30.0, 640, 480),
catalog=scan_sensor_catalog(),
)
try:
assert dialog.select_profile("shipped/box")
dialog.select_calibration(None)
dialog.set_reference_times({20: 0.0, 36: .9, 52: 1.9})
assert dialog.reference_spin.minimum() == 20
assert dialog.reference_spin.maximum() == 52
assert dialog.choice().reference_frame is None
dialog.reference_spin.setValue(36)
assert dialog.choice().reference_frame == 36
assert dialog.reference_time_label.text() == t("video_analysis.reference_time", seconds="0.900")
dialog.reference_spin.setValue(35)
assert dialog.choice() is None
assert dialog.note_label.text() == t("video_analysis.reference_missing", frame=35)
finally:
dialog.close()
dialog.deleteLater()
def test_recalibrated_recording_uses_recomputed_trace_through_display_controls(
make_window, replay_recording, monkeypatch,
):
from orisys_app_shared.force.session import SessionSample, SessionStore
from orisys_app_shared.force.source import StoreCurveSource
window = make_window(units="raw")
assert window.replay.open_recording(replay_recording)
session = SimpleNamespace(reader=SimpleNamespace(
provenance=SimpleNamespace(sources={"forces": "recomputed"}), meta={},
))
store = SessionStore()
for index in range(3):
store.append(SessionSample(
frame_id=index, t_rel_s=float(index), fn_raw=100.0 + index,
fn_raw_processed=100.0 + index,
))
source = StoreCurveSource(store)
source.kind = "analysis"
source.reader = session.reader
source.unit_available = lambda unit: True
monkeypatch.setattr(replay_controller, "AnalysisCurveSource", lambda reader: source)
monkeypatch.setattr(window.replay_ctl, "available_result", lambda: session)
files = [Path(replay_recording) / name for name in ("forces.csv", "frames.csv", "session.json")]
before = [hashlib.sha256(path.read_bytes()).hexdigest() for path in files]
assert window.replay_ctl._serve_curves(session) == "recomputed"
assert window.force_plots.curve_kind() == "analysis"
assert window.force_plots.mode_label.text() == t("chart.mode_recomputed")
window.display_ctl.apply_plot_settings({"fn_raw_max": 2000.0})
window.replay_ctl.apply_source(True)
assert window.force_plots.curve_kind() == "analysis"
window._reset_display()
assert window.force_plots.curve_kind() == "analysis"
assert [hashlib.sha256(path.read_bytes()).hexdigest() for path in files] == before
def test_accepting_setup_then_cancelling_keeps_the_last_complete_result(
make_window, replay_recording, monkeypatch,
):
window = make_window()
assert window.replay.open_recording(replay_recording)
previous = SimpleNamespace(recording=Path(replay_recording), close=lambda: None)
window.replay_ctl.results.open_session(previous)
monkeypatch.setattr(window, "_recording_analysis_overrides", lambda recording: True)
monkeypatch.setattr(window.replay_ctl, "refresh_state", lambda: None)
monkeypatch.setattr(window.replay_ctl, "render_frame", lambda: None)
requested = []
monkeypatch.setattr(window.replay_ctl, "on_analysis_requested", lambda: requested.append(True))
window.replay_ctl.on_analysis_setup()
assert requested == [True]
assert window.replay_ctl.available_result() is previous
window.replay_ctl.on_analysis_finished(SimpleNamespace(cancelled=True, frames_written=0))
assert window.replay_ctl.available_result() is previous
@pytest.mark.parametrize("family", ["box", "finger"])
def test_explicit_raw_choice_disables_default_calibration_before_processing(monkeypatch, tmp_path, family):
from orisys_app_shared.analysis import service as service_module
import orisys.finger
applied = []
sensor = SimpleNamespace(
cap=None, _read_frame=lambda: (False, None), get_img=lambda **kwargs: None,
set_calibration=applied.append,
)
runtime = SimpleNamespace(
tactile=sensor, capture=None, read_source=lambda: (False, None),
process_frame=lambda frame: None, set_calibration=applied.append,
)
monkeypatch.setattr(service_module, "_import_sdk", lambda: SimpleNamespace(Sensor=lambda *args, **kwargs: sensor))
monkeypatch.setattr(orisys.finger, "FingerRuntime", SimpleNamespace(open=lambda **kwargs: runtime))
video = tmp_path / "clip.mp4"
video.touch()
chain = ChainSpec(sensor_type=family, calibration_disabled=True)
service_module._OpenChain(chain, video, tmp_path).open()
assert applied == [None]
@@ -63,6 +63,7 @@ __all__ = [
"SyntheticWorkspace", "SyntheticWorkspace",
"VideoFacts", "VideoFacts",
"build_workspace", "build_workspace",
"calibration_identity",
"is_synthetic", "is_synthetic",
"profiles_for_video", "profiles_for_video",
"read_synthetic", "read_synthetic",
@@ -325,7 +326,7 @@ def _session_id(identity: str) -> int:
return int(identity[:12], 16) % (2**31) return int(identity[:12], 16) % (2**31)
def _calibration_identity(path: Path | None) -> str: def calibration_identity(path: Path | None) -> str:
"""A calibration's key component: its name plus a digest of its own content. """A calibration's key component: its name plus a digest of its own content.
The *content* is what decides the numbers the chain computes, so a coefficient file The *content* is what decides the numbers the chain computes, so a coefficient file
@@ -384,7 +385,7 @@ def session_document(
"files": {"video": [VIDEO_FILENAME]}, "files": {"video": [VIDEO_FILENAME]},
"preroll_frames": 0, "preroll_frames": 0,
"preroll_segment": "", "preroll_segment": "",
"calibration_id": _calibration_identity(calibration), "calibration_id": calibration_identity(calibration),
"depth": {"stored": True, "replay": "analysis"}, "depth": {"stored": True, "replay": "analysis"},
"synthetic": { "synthetic": {
"generator": GENERATOR, "generator": GENERATOR,
@@ -168,21 +168,21 @@ class ChainSpec:
flip_source_vertical: bool = True flip_source_vertical: bool = True
mirror: bool = False mirror: bool = False
#: An explicit force-calibration file, or ``None`` for whatever the configuration's #: An explicit force-calibration file, or ``None`` for whatever the configuration's
#: own bundle resolves. It is *not* part of :meth:`identity`, deliberately: a recording #: own bundle resolves. The chosen content belongs to the calibration half of
#: was taken under one calibration and the identity of *what it is* must not move when #: the result key, separate from the recording and sensor configuration identity.
#: a different curve is tried against it. The calibration half of the invalidation key
#: is the recording's own ``calibration_id`` (see
#: :meth:`AnalysisService.dependency_report`), which the generated workspace sets from
#: the chosen file's content.
calibration: Path | None = None calibration: Path | None = None
#: Device serial the operator confirmed for ``calibration``. Passed with ``cal_path`` #: Device serial the operator confirmed for ``calibration``. Passed with ``cal_path``
#: so the SDK can reject a file whose ``sn`` is a different sensor. Not part of #: so the SDK can reject a file whose ``sn`` is a different sensor. Not part of
#: :meth:`identity`: a mismatch fails the open, and a match does not change the file. #: :meth:`identity`: a mismatch fails the open, and a match does not change the file.
device_id: str | None = None device_id: str | None = None
#: Explicit raw-force selection, distinct from resolving the bundle default.
calibration_disabled: bool = False
def __post_init__(self) -> None: def __post_init__(self) -> None:
if self.sensor_type not in ("finger", "box"): if self.sensor_type not in ("finger", "box"):
raise ValueError(f"unsupported sensor type {self.sensor_type!r}") raise ValueError(f"unsupported sensor type {self.sensor_type!r}")
if self.calibration_disabled and self.calibration is not None:
raise ValueError("a disabled calibration cannot name a file")
@property @property
def config_sha256(self) -> str: def config_sha256(self) -> str:
@@ -215,6 +215,7 @@ def chain_spec_for(
export_dir: str | Path | None = None, export_dir: str | Path | None = None,
calibration: str | Path | None = None, calibration: str | Path | None = None,
device_id: str | None = None, device_id: str | None = None,
calibration_disabled: bool = False,
) -> ChainSpec: ) -> ChainSpec:
"""The chain a recording was made with, unless the caller names a different one. """The chain a recording was made with, unless the caller names a different one.
@@ -222,8 +223,8 @@ def chain_spec_for(
recorded ``device.config_ref`` is the configuration the footage was taken under, recorded ``device.config_ref`` is the configuration the footage was taken under,
not whatever happens to be installed now. ``calibration`` is the one fact a *recording* not whatever happens to be installed now. ``calibration`` is the one fact a *recording*
cannot supply — it names the curve in force at record time, not the file it lived in — cannot supply — it names the curve in force at record time, not the file it lived in —
so a caller that knows which file it means (the generated workspace for a bare video) so an explicit setup passes that file. Otherwise the bundle resolves its default,
passes it, and everything else keeps the bundle's own resolution. unless the caller explicitly disables calibration.
""" """
device = dict(session.get("device") or {}) device = dict(session.get("device") or {})
kind = str(sensor_type or device.get("sensor_type") or "") kind = str(sensor_type or device.get("sensor_type") or "")
@@ -246,6 +247,7 @@ def chain_spec_for(
export_dir=bundle, export_dir=bundle,
calibration=None if calibration is None else Path(str(calibration)), calibration=None if calibration is None else Path(str(calibration)),
device_id=serial, device_id=serial,
calibration_disabled=calibration_disabled,
) )
@@ -321,6 +323,8 @@ class _OpenChain:
if self.spec.device_id: if self.spec.device_id:
kwargs["device_id"] = str(self.spec.device_id) kwargs["device_id"] = str(self.spec.device_id)
self._runtime = FingerRuntime.open(**kwargs) self._runtime = FingerRuntime.open(**kwargs)
if self.spec.calibration_disabled:
self._runtime.set_calibration(None)
self.sensor = self._runtime.tactile self.sensor = self._runtime.tactile
self._read = self._runtime.read_source self._read = self._runtime.read_source
self._feed = self._runtime.process_frame self._feed = self._runtime.process_frame
@@ -341,7 +345,10 @@ class _OpenChain:
), ),
verbose=False, verbose=False,
backend="auto", backend="auto",
device_id=self.spec.device_id if self.spec.calibration is not None else None,
) )
if self.spec.calibration_disabled:
sensor.set_calibration(None)
self.sensor = sensor self.sensor = sensor
raw_read = getattr(sensor, "_read_frame", None) raw_read = getattr(sensor, "_read_frame", None)
if not callable(raw_read): if not callable(raw_read):
@@ -524,6 +531,7 @@ class AnalysisService:
max_bytes: int = DEFAULT_MAX_BYTES, max_bytes: int = DEFAULT_MAX_BYTES,
working_directory: str | Path | None = None, working_directory: str | Path | None = None,
store_force: bool = False, store_force: bool = False,
unloaded_reference_frames: int | None = None,
on_progress: Callable[[Progress], None] | None = None, on_progress: Callable[[Progress], None] | None = None,
cancelled: threading.Event | None = None, cancelled: threading.Event | None = None,
) -> None: ) -> None:
@@ -546,11 +554,10 @@ class AnalysisService:
self.working_directory = ( self.working_directory = (
None if working_directory is None else Path(working_directory) None if working_directory is None else Path(working_directory)
) )
#: Whether each frame also stores the chain's own force sample. Off for a recording, #: Store a distinct recomputed trace for a bare video or an explicitly
#: whose force is a *measurement in its own file* (``forces.csv``) and must not be #: reconfigured recording; never rewrite the recording's forces.csv.
#: overwritten in the reader's mind by a recomputation of it; on for a bare video,
#: where the chain's force is the only force there is (see ``bare_video``).
self.store_force = bool(store_force) self.store_force = bool(store_force)
self.unloaded_reference_frames = unloaded_reference_frames
self.on_progress = on_progress self.on_progress = on_progress
self.cancelled = cancelled self.cancelled = cancelled
self._reader: RecordingReader | None = None self._reader: RecordingReader | None = None
@@ -618,12 +625,20 @@ class AnalysisService:
or dict(session.get("calibration") or {}).get("version") or dict(session.get("calibration") or {}).get("version")
or "" or ""
) )
zero_frames = int((session.get("synthetic") or {}).get("unloaded_reference_frames", 0)) if self.chain.calibration_disabled:
if zero_frames not in (0, 30): calibration = ""
raise ValueError("unloaded reference must contain exactly 30 frames") elif self.chain.calibration is not None:
from orisys_app_shared.analysis.bare_video import calibration_identity
calibration = calibration_identity(self.chain.calibration)
zero_frames = self._zero_reference_frames()
key = result_key( key = result_key(
recording=identity, recording=identity,
sensor_config=self.chain.identity() + (";unloaded-reference=30" if zero_frames else ""), sensor_config=(
self.chain.identity()
+ (";unloaded-reference=30" if zero_frames else "")
+ (";force=recomputed" if self.store_force else "")
),
calibration=calibration, calibration=calibration,
reference_frame=self.reference_frame, reference_frame=self.reference_frame,
) )
@@ -813,7 +828,7 @@ class AnalysisService:
from orisys_app_shared.acquisition.sdk_worker import process_chain from orisys_app_shared.acquisition.sdk_worker import process_chain
provenance = state.provenance provenance = state.provenance
zero_frames = int((self.reader().session().get("synthetic") or {}).get("unloaded_reference_frames", 0)) zero_frames = self._zero_reference_frames()
for position, number in enumerate(state.segments): for position, number in enumerate(state.segments):
if state.reasons or self._stop_requested(): if state.reasons or self._stop_requested():
if self._stop_requested(): if self._stop_requested():
@@ -934,6 +949,15 @@ class AnalysisService:
raise ValueError("video ended before 30 unloaded reference frames were available") raise ValueError("video ended before 30 unloaded reference frames were available")
# ------------------------------------------------------------------- internals # ------------------------------------------------------------------- internals
def _zero_reference_frames(self) -> int:
value = self.unloaded_reference_frames
if value is None:
value = (self.reader().session().get("synthetic") or {}).get("unloaded_reference_frames", 0)
frames = int(value)
if frames not in (0, 30):
raise ValueError("unloaded reference must contain exactly 30 frames")
return frames
def _read_force(self, chain: _OpenChain) -> ForceSample | None: def _read_force(self, chain: _OpenChain) -> ForceSample | None:
"""This frame's force sample, or ``None`` when the pass was not asked for one. """This frame's force sample, or ``None`` when the pass was not asked for one.
@@ -1169,10 +1193,8 @@ class AnalysisService:
sources={ sources={
"flow": "recomputed", "flow": "recomputed",
"depth": "recomputed", "depth": "recomputed",
# The one channel whose answer depends on what the input *is*. Beside a # Bare videos and explicitly reconfigured recordings store a separate
# recording, ``forces.csv`` is the measurement and the result stores none of # recomputed trace. Other passes leave force display to forces.csv.
# it; over a bare video there is no CSV, so the chain's own recomputation is
# what a chart can draw — and it says so.
"forces": "recomputed" "forces": "recomputed"
if self.store_force if self.store_force
else "recorded", else "recorded",
@@ -63,6 +63,7 @@ class MetricHistory:
lowpass_cutoff_hz: float = 0.0, lowpass_cutoff_hz: float = 0.0,
y_axis_min_span: float = 0.5, y_axis_min_span: float = 0.5,
max_fps: float = DEFAULT_MAX_FPS, max_fps: float = DEFAULT_MAX_FPS,
time_origin: float | None = None,
): ):
self.channels = int(channels) self.channels = int(channels)
self.window_sec = float(window_sec) self.window_sec = float(window_sec)
@@ -80,6 +81,8 @@ class MetricHistory:
self.lowpass_cutoff_hz = float(lowpass_cutoff_hz) self.lowpass_cutoff_hz = float(lowpass_cutoff_hz)
self.y_axis_min_span = float(y_axis_min_span) self.y_axis_min_span = float(y_axis_min_span)
self._capacity = self.max_points self._capacity = self.max_points
# None uses the first sample; sources already in session seconds pass zero.
self._time_origin = time_origin
self.reset() self.reset()
def set_window_sec(self, window_sec: float) -> None: def set_window_sec(self, window_sec: float) -> None:
@@ -96,7 +99,7 @@ class MetricHistory:
(self._capacity, 1 + self.channels), np.nan, dtype=np.float64 (self._capacity, 1 + self.channels), np.nan, dtype=np.float64
) )
self.sample_count = 0 self.sample_count = 0
self._t0: float | None = None self._t0: float | None = self._time_origin
self._y_range_smooth: list[tuple[float, float] | None] = [ self._y_range_smooth: list[tuple[float, float] | None] = [
None for _ in range(self.channels) None for _ in range(self.channels)
] ]