修复回放标定选择与曲线显示数据保留
让标定选择进入处理和缓存,避免显示设置清空曲线。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,19 +1436,20 @@ 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.
|
||||
wanted = "N" if self._charts.unit() == "N" else "raw"
|
||||
unit = self.unit_for(reader)
|
||||
self._engine.set_unit(unit)
|
||||
self._host.set_display_unit(unit)
|
||||
if unit != wanted:
|
||||
self._host.notify(t("status.units_raw_fallback"))
|
||||
self._charts.show_unit_fallback()
|
||||
self._charts.show_all()
|
||||
if kind == "recorded":
|
||||
wanted = "N" if self._charts.unit() == "N" else "raw"
|
||||
unit = self.unit_for(reader)
|
||||
self._engine.set_unit(unit)
|
||||
self._host.set_display_unit(unit)
|
||||
if unit != wanted:
|
||||
self._host.notify(t("status.units_raw_fallback"))
|
||||
self._charts.show_unit_fallback()
|
||||
self._charts.show_all()
|
||||
self._image_panel.set_source_kind("replay")
|
||||
return
|
||||
if active and self._engine.is_video_only():
|
||||
@@ -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]
|
||||
Reference in New Issue
Block a user