修复回放标定选择与曲线显示数据保留
让标定选择进入处理和缓存,避免显示设置清空曲线。102 项针对性检查通过,全量门禁仍有停止时序、模型尺寸与原生崩溃问题。
This commit is contained in:
@@ -360,6 +360,7 @@
|
||||
"device_family": "This calibration belongs to {family}, not Finger",
|
||||
"reference": "Reference frame",
|
||||
"reference_time": "{seconds} s",
|
||||
"reference_missing": "The recording has no frame {frame}.",
|
||||
"blocked": "This profile cannot be used: {reason}"
|
||||
},
|
||||
"record": {
|
||||
|
||||
@@ -360,6 +360,7 @@
|
||||
"device_family": "这份标定属于 {family},不能用于 Finger",
|
||||
"reference": "参考帧",
|
||||
"reference_time": "{seconds} 秒",
|
||||
"reference_missing": "录制中没有第 {frame} 帧。",
|
||||
"blocked": "这套配置不能用:{reason}"
|
||||
},
|
||||
"record": {
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
# Replay panel contract
|
||||
|
||||
The replay panel plays one recording — or one video — against a single cursor. Each
|
||||
capability states its requirement, data source and a falsifying check. The result format and
|
||||
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)).
|
||||
The replay panel plays one recording or video against one cursor. 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.
|
||||
|
||||
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
|
||||
@@ -11,12 +10,10 @@ separation (Part C) is not.
|
||||
|
||||
## 1. Load a video
|
||||
|
||||
Selecting a video always gives a playable timeline, and the panel says what the file is.
|
||||
`load_video` calls a folder holding `forces.csv` beside the video a recording; anything else
|
||||
opens as a bare video (`VideoOnlySource`) and plays, with `playback.video_only_opened` on the
|
||||
rail and `playback.video_only_notice` in the notes. A file that cannot be decoded is refused
|
||||
with its reason. What a bare video lacks is force rows *measured by a device*, and
|
||||
`session.json` metadata.
|
||||
`load_video` treats video beside `forces.csv` as a recording; otherwise it opens a bare video
|
||||
(`VideoOnlySource`), with `playback.video_only_opened` on the rail and
|
||||
`playback.video_only_notice` in the notes. Undecodable files are refused with their reason.
|
||||
Bare video lacks device-measured force rows and `session.json` metadata.
|
||||
|
||||
**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
|
||||
@@ -72,13 +69,13 @@ One cursor drives all three.
|
||||
|
||||
| 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 |
|
||||
| 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
|
||||
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 —
|
||||
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.
|
||||
@@ -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`,
|
||||
`::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
|
||||
|
||||
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
|
||||
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).
|
||||
|
||||
@@ -9,6 +9,7 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
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 (
|
||||
DEFAULT_FINGER_ORIENTATION,
|
||||
effective_orientation,
|
||||
@@ -158,9 +159,8 @@ class StudioMainWindow(QMainWindow):
|
||||
self.prefs: dict[str, Any] = dict(prefs or {})
|
||||
self._theme = str(self.prefs.get("theme") or "dark")
|
||||
self._language = str(self.prefs.get("language") or "zh_CN")
|
||||
#: Reference-frame override for the next analysis pass on a real recording
|
||||
#: (from Analysis ▸ Analysis setup…). Bare-video workspaces store this in session.json.
|
||||
self._analysis_reference_override: int | None = None
|
||||
#: Accepted choices are scoped to each recording; display changes only read them.
|
||||
self._recording_analysis_setups: dict[Path, BareVideoSetup] = {}
|
||||
# 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
|
||||
# the shims below.
|
||||
@@ -2330,19 +2330,16 @@ class StudioMainWindow(QMainWindow):
|
||||
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
|
||||
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
|
||||
history, curves, selection and window go back to their defaults, the whole
|
||||
recording is drawn again, and the playhead returns to the start so the chart
|
||||
to be. Re-attaching the current recorded or recomputed source restores the
|
||||
curves, selection and window. The playhead returns to the start so the chart
|
||||
cursor and the transport agree.
|
||||
|
||||
It touches nothing the device owns (there is no device session in this view) and
|
||||
no preference: the unit the charts are in is kept, unlike ``force_plots.reset``,
|
||||
which drops the display back to raw.
|
||||
It touches nothing the device owns. The replay controller keeps a supported
|
||||
unit for the active source instead of resetting the unit to raw.
|
||||
"""
|
||||
reader = self.replay.reader()
|
||||
if reader is not None:
|
||||
self.force_plots.set_recording_source(reader)
|
||||
self.force_plots.show_all()
|
||||
self.replay_ctl.apply_source(True)
|
||||
# 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
|
||||
# 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
|
||||
a compatible result is already stored.
|
||||
|
||||
Two facts a *generated* workspace adds and a recording never has: the calibration
|
||||
the operator chose (a recording names the curve in force, not the file), and
|
||||
whether to store the chain's own per-frame force. Beside a recording, force is a
|
||||
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.
|
||||
An accepted recording setup supplies calibration, device confirmation, zero
|
||||
window and reference without rewriting session.json. Its pass stores a separate
|
||||
force trace; a recording with no override retains its original measured curves.
|
||||
"""
|
||||
from orisys_app_shared.analysis.service import AnalysisService, chain_spec_for
|
||||
from orisys_app_shared.record import RecordingReader
|
||||
|
||||
session = RecordingReader(Path(recording)).session()
|
||||
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(
|
||||
session,
|
||||
calibration=synthetic.get("calibration") or None,
|
||||
device_id=synthetic.get("device_id") or None,
|
||||
calibration=calibration 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")
|
||||
if reference is None:
|
||||
reference = getattr(self, "_analysis_reference_override", None)
|
||||
reference = setup.reference_frame if setup is not None else synthetic.get("reference_frame")
|
||||
return AnalysisService(
|
||||
Path(recording),
|
||||
chain=chain,
|
||||
cancelled=cancelled,
|
||||
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),
|
||||
)
|
||||
|
||||
@@ -2688,9 +2684,8 @@ class StudioMainWindow(QMainWindow):
|
||||
def _recording_analysis_overrides(self, recording: Path) -> bool:
|
||||
"""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
|
||||
override on the window for the next :meth:`_analysis_service` call; declining
|
||||
leaves the current result alone.
|
||||
The profile stays locked to the session's chain. Accepted choices belong to
|
||||
this recording; declining leaves its current result and choices alone.
|
||||
"""
|
||||
from orisys_app_shared.analysis import bare_video
|
||||
from orisys_app_shared.record import RecordingReader
|
||||
@@ -2706,23 +2701,33 @@ class StudioMainWindow(QMainWindow):
|
||||
catalog = scan_sensor_catalog()
|
||||
config_ref = str(dict(session.get("device") or {}).get("config_ref") or "")
|
||||
selected = self.sensor_picker.selected_entry()
|
||||
previous = self._recording_analysis_setups.get(Path(recording).resolve())
|
||||
dialog = VideoAnalysisDialog(
|
||||
video=Path(video),
|
||||
facts=facts,
|
||||
catalog=catalog,
|
||||
window=self,
|
||||
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,
|
||||
theme=self._theme,
|
||||
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:
|
||||
return False
|
||||
setup = dialog.choice()
|
||||
if setup is None:
|
||||
return False
|
||||
self._analysis_reference_override = setup.reference_frame
|
||||
self._recording_analysis_setups[Path(recording).resolve()] = setup
|
||||
return True
|
||||
|
||||
def _submit_measurement_3d(
|
||||
|
||||
@@ -416,8 +416,8 @@ class ReplayController(QObject):
|
||||
|
||||
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
|
||||
runs again. For a real recording, only the reference frame is overridden for the
|
||||
next pass (the session's own profile stays locked).
|
||||
runs again. A real recording also accepts calibration and zero/reference
|
||||
choices while keeping its recorded profile locked.
|
||||
"""
|
||||
from orisys_app_shared.analysis import bare_video
|
||||
|
||||
@@ -452,9 +452,8 @@ class ReplayController(QObject):
|
||||
if not self._host.recording_analysis_overrides(Path(recording)):
|
||||
return
|
||||
self._clear_analysis_state()
|
||||
# Drop a cached result that was keyed under a different reference so the
|
||||
# new pass is what serves the cursor.
|
||||
self.results.close()
|
||||
# The service looks up the new key. Keep the last complete result
|
||||
# readable until a replacement commits, including after cancellation.
|
||||
self.refresh_state()
|
||||
self.on_analysis_requested()
|
||||
return
|
||||
@@ -1437,11 +1436,12 @@ class ReplayController(QObject):
|
||||
"""Point the charts at the recording (or back at the live session)."""
|
||||
if active and self._engine.is_loaded():
|
||||
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
|
||||
# it has a calibrated axis somewhere, otherwise the charts would be
|
||||
# empty and every card a dash. The operator's preference is kept when
|
||||
# the recording can honour it.
|
||||
if kind == "recorded":
|
||||
wanted = "N" if self._charts.unit() == "N" else "raw"
|
||||
unit = self.unit_for(reader)
|
||||
self._engine.set_unit(unit)
|
||||
@@ -1481,7 +1481,8 @@ class ReplayController(QObject):
|
||||
the loop. This is the measurement.
|
||||
* ``recomputed`` — the analysis result's per-frame force, which the *pass* produced
|
||||
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
|
||||
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.
|
||||
"""
|
||||
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:
|
||||
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:
|
||||
self._charts.set_curve_source(
|
||||
None, reason=t("chart.trace_not_analysed")
|
||||
|
||||
@@ -240,6 +240,8 @@ class ForcePlotsPanel(QFrame):
|
||||
lowpass_cutoff_hz=0.0,
|
||||
y_axis_min_span=0.5,
|
||||
max_fps=DEFAULT_MAX_FPS,
|
||||
# Live packets and every source already use session-relative seconds.
|
||||
time_origin=0.0,
|
||||
)
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
@@ -556,8 +558,20 @@ class ForcePlotsPanel(QFrame):
|
||||
self._render_value_cards()
|
||||
|
||||
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._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()
|
||||
|
||||
def set_axis_limits(
|
||||
@@ -945,6 +959,8 @@ class ForcePlotsPanel(QFrame):
|
||||
def follow_latest(self) -> None:
|
||||
"""Leave history browsing and resume the live rolling window."""
|
||||
self._set_mode("follow")
|
||||
if self._source_ready:
|
||||
self._reload_from_store()
|
||||
self._apply_axis_mode()
|
||||
|
||||
def show_all(self) -> None:
|
||||
@@ -1326,16 +1342,24 @@ class ForcePlotsPanel(QFrame):
|
||||
data = self._source.window(
|
||||
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:
|
||||
self._clear_curves()
|
||||
return
|
||||
self._availability_from_window(data)
|
||||
values = np.asarray(data.values, dtype=float)
|
||||
for index in range(len(data)):
|
||||
if not bool(data.valid[index]):
|
||||
continue
|
||||
values = np.asarray(data.values, dtype=float).copy()
|
||||
mask = plottable_axis_mask(values, data.valid)
|
||||
values[~mask] = np.nan
|
||||
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]
|
||||
if not all(np.isfinite(v) for v in row):
|
||||
continue
|
||||
self.history.push(
|
||||
float(data.times[index]),
|
||||
float(row[0]),
|
||||
@@ -1394,7 +1418,7 @@ class ForcePlotsPanel(QFrame):
|
||||
|
||||
def _preview_fixed_y(self) -> bool:
|
||||
"""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()
|
||||
if self._unit == "N":
|
||||
return bool(self._fixed_newton)
|
||||
@@ -1410,9 +1434,15 @@ class ForcePlotsPanel(QFrame):
|
||||
plot.getAxis("left").setTicks([_nice_ticks(ymin, ymax, count=5)])
|
||||
else:
|
||||
plot.getAxis("left").setTicks(None)
|
||||
plot.enableAutoRange(axis="y", enable=True)
|
||||
plot.getViewBox().updateAutoRange()
|
||||
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:
|
||||
plot.setXRange(0.0, self.history.window_sec, padding=0.0)
|
||||
plot.setXRange(*x_range, padding=0.0)
|
||||
self._sync_zero_lines()
|
||||
|
||||
def _sync_zero_lines(self) -> None:
|
||||
|
||||
@@ -102,6 +102,7 @@ class VideoAnalysisDialog(QDialog):
|
||||
super().__init__(window)
|
||||
self._video = Path(video)
|
||||
self._facts = facts
|
||||
self._reference_times: dict[int, float] | None = None
|
||||
self._catalog = catalog
|
||||
self._theme = str(theme)
|
||||
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))
|
||||
return None
|
||||
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()
|
||||
if 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.
|
||||
# Saying so as ``None`` keeps a reset the pass cannot apply from being recorded
|
||||
# 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(),
|
||||
unloaded_reference_frames=30 if self.unloaded_reference.isChecked() else 0,
|
||||
)
|
||||
@@ -479,9 +486,24 @@ class VideoAnalysisDialog(QDialog):
|
||||
return
|
||||
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:
|
||||
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(
|
||||
t("video_analysis.reference_time", seconds=f"{seconds:.3f}")
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
"VideoFacts",
|
||||
"build_workspace",
|
||||
"calibration_identity",
|
||||
"is_synthetic",
|
||||
"profiles_for_video",
|
||||
"read_synthetic",
|
||||
@@ -325,7 +326,7 @@ def _session_id(identity: str) -> int:
|
||||
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.
|
||||
|
||||
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]},
|
||||
"preroll_frames": 0,
|
||||
"preroll_segment": "",
|
||||
"calibration_id": _calibration_identity(calibration),
|
||||
"calibration_id": calibration_identity(calibration),
|
||||
"depth": {"stored": True, "replay": "analysis"},
|
||||
"synthetic": {
|
||||
"generator": GENERATOR,
|
||||
|
||||
@@ -168,21 +168,21 @@ class ChainSpec:
|
||||
flip_source_vertical: bool = True
|
||||
mirror: bool = False
|
||||
#: 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
|
||||
#: was taken under one calibration and the identity of *what it is* must not move when
|
||||
#: 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.
|
||||
#: own bundle resolves. The chosen content belongs to the calibration half of
|
||||
#: the result key, separate from the recording and sensor configuration identity.
|
||||
calibration: Path | None = None
|
||||
#: 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
|
||||
#: :meth:`identity`: a mismatch fails the open, and a match does not change the file.
|
||||
device_id: str | None = None
|
||||
#: Explicit raw-force selection, distinct from resolving the bundle default.
|
||||
calibration_disabled: bool = False
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.sensor_type not in ("finger", "box"):
|
||||
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
|
||||
def config_sha256(self) -> str:
|
||||
@@ -215,6 +215,7 @@ def chain_spec_for(
|
||||
export_dir: str | Path | None = None,
|
||||
calibration: str | Path | None = None,
|
||||
device_id: str | None = None,
|
||||
calibration_disabled: bool = False,
|
||||
) -> ChainSpec:
|
||||
"""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,
|
||||
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 —
|
||||
so a caller that knows which file it means (the generated workspace for a bare video)
|
||||
passes it, and everything else keeps the bundle's own resolution.
|
||||
so an explicit setup passes that file. Otherwise the bundle resolves its default,
|
||||
unless the caller explicitly disables calibration.
|
||||
"""
|
||||
device = dict(session.get("device") or {})
|
||||
kind = str(sensor_type or device.get("sensor_type") or "")
|
||||
@@ -246,6 +247,7 @@ def chain_spec_for(
|
||||
export_dir=bundle,
|
||||
calibration=None if calibration is None else Path(str(calibration)),
|
||||
device_id=serial,
|
||||
calibration_disabled=calibration_disabled,
|
||||
)
|
||||
|
||||
|
||||
@@ -321,6 +323,8 @@ class _OpenChain:
|
||||
if self.spec.device_id:
|
||||
kwargs["device_id"] = str(self.spec.device_id)
|
||||
self._runtime = FingerRuntime.open(**kwargs)
|
||||
if self.spec.calibration_disabled:
|
||||
self._runtime.set_calibration(None)
|
||||
self.sensor = self._runtime.tactile
|
||||
self._read = self._runtime.read_source
|
||||
self._feed = self._runtime.process_frame
|
||||
@@ -341,7 +345,10 @@ class _OpenChain:
|
||||
),
|
||||
verbose=False,
|
||||
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
|
||||
raw_read = getattr(sensor, "_read_frame", None)
|
||||
if not callable(raw_read):
|
||||
@@ -524,6 +531,7 @@ class AnalysisService:
|
||||
max_bytes: int = DEFAULT_MAX_BYTES,
|
||||
working_directory: str | Path | None = None,
|
||||
store_force: bool = False,
|
||||
unloaded_reference_frames: int | None = None,
|
||||
on_progress: Callable[[Progress], None] | None = None,
|
||||
cancelled: threading.Event | None = None,
|
||||
) -> None:
|
||||
@@ -546,11 +554,10 @@ class AnalysisService:
|
||||
self.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,
|
||||
#: whose force is a *measurement in its own file* (``forces.csv``) and must not be
|
||||
#: 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``).
|
||||
#: Store a distinct recomputed trace for a bare video or an explicitly
|
||||
#: reconfigured recording; never rewrite the recording's forces.csv.
|
||||
self.store_force = bool(store_force)
|
||||
self.unloaded_reference_frames = unloaded_reference_frames
|
||||
self.on_progress = on_progress
|
||||
self.cancelled = cancelled
|
||||
self._reader: RecordingReader | None = None
|
||||
@@ -618,12 +625,20 @@ class AnalysisService:
|
||||
or dict(session.get("calibration") or {}).get("version")
|
||||
or ""
|
||||
)
|
||||
zero_frames = int((session.get("synthetic") or {}).get("unloaded_reference_frames", 0))
|
||||
if zero_frames not in (0, 30):
|
||||
raise ValueError("unloaded reference must contain exactly 30 frames")
|
||||
if self.chain.calibration_disabled:
|
||||
calibration = ""
|
||||
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(
|
||||
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,
|
||||
reference_frame=self.reference_frame,
|
||||
)
|
||||
@@ -813,7 +828,7 @@ class AnalysisService:
|
||||
from orisys_app_shared.acquisition.sdk_worker import process_chain
|
||||
|
||||
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):
|
||||
if state.reasons or self._stop_requested():
|
||||
if self._stop_requested():
|
||||
@@ -934,6 +949,15 @@ class AnalysisService:
|
||||
raise ValueError("video ended before 30 unloaded reference frames were available")
|
||||
|
||||
# ------------------------------------------------------------------- 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:
|
||||
"""This frame's force sample, or ``None`` when the pass was not asked for one.
|
||||
|
||||
@@ -1169,10 +1193,8 @@ class AnalysisService:
|
||||
sources={
|
||||
"flow": "recomputed",
|
||||
"depth": "recomputed",
|
||||
# The one channel whose answer depends on what the input *is*. Beside a
|
||||
# recording, ``forces.csv`` is the measurement and the result stores none of
|
||||
# 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.
|
||||
# Bare videos and explicitly reconfigured recordings store a separate
|
||||
# recomputed trace. Other passes leave force display to forces.csv.
|
||||
"forces": "recomputed"
|
||||
if self.store_force
|
||||
else "recorded",
|
||||
|
||||
@@ -63,6 +63,7 @@ class MetricHistory:
|
||||
lowpass_cutoff_hz: float = 0.0,
|
||||
y_axis_min_span: float = 0.5,
|
||||
max_fps: float = DEFAULT_MAX_FPS,
|
||||
time_origin: float | None = None,
|
||||
):
|
||||
self.channels = int(channels)
|
||||
self.window_sec = float(window_sec)
|
||||
@@ -80,6 +81,8 @@ class MetricHistory:
|
||||
self.lowpass_cutoff_hz = float(lowpass_cutoff_hz)
|
||||
self.y_axis_min_span = float(y_axis_min_span)
|
||||
self._capacity = self.max_points
|
||||
# None uses the first sample; sources already in session seconds pass zero.
|
||||
self._time_origin = time_origin
|
||||
self.reset()
|
||||
|
||||
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.sample_count = 0
|
||||
self._t0: float | None = None
|
||||
self._t0: float | None = self._time_origin
|
||||
self._y_range_smooth: list[tuple[float, float] | None] = [
|
||||
None for _ in range(self.channels)
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user