diff --git a/apps/studio/config/locales/en_US.json b/apps/studio/config/locales/en_US.json index e886f92..0728779 100644 --- a/apps/studio/config/locales/en_US.json +++ b/apps/studio/config/locales/en_US.json @@ -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": { diff --git a/apps/studio/config/locales/zh_CN.json b/apps/studio/config/locales/zh_CN.json index 244b615..87c1df2 100644 --- a/apps/studio/config/locales/zh_CN.json +++ b/apps/studio/config/locales/zh_CN.json @@ -360,6 +360,7 @@ "device_family": "这份标定属于 {family},不能用于 Finger", "reference": "参考帧", "reference_time": "{seconds} 秒", + "reference_missing": "录制中没有第 {frame} 帧。", "blocked": "这套配置不能用:{reason}" }, "record": { diff --git a/apps/studio/docs/replay-panel.md b/apps/studio/docs/replay-panel.md index 569490a..952e7cf 100644 --- a/apps/studio/docs/replay-panel.md +++ b/apps/studio/docs/replay-panel.md @@ -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). diff --git a/apps/studio/src/orisys_studio/app_window.py b/apps/studio/src/orisys_studio/app_window.py index dba4b5b..71ae872 100644 --- a/apps/studio/src/orisys_studio/app_window.py +++ b/apps/studio/src/orisys_studio/app_window.py @@ -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( diff --git a/apps/studio/src/orisys_studio/replay_controller.py b/apps/studio/src/orisys_studio/replay_controller.py index 8f59c78..07fa1bd 100644 --- a/apps/studio/src/orisys_studio/replay_controller.py +++ b/apps/studio/src/orisys_studio/replay_controller.py @@ -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") diff --git a/apps/studio/src/orisys_studio/ui/force_plots.py b/apps/studio/src/orisys_studio/ui/force_plots.py index 86c31a2..1549631 100644 --- a/apps/studio/src/orisys_studio/ui/force_plots.py +++ b/apps/studio/src/orisys_studio/ui/force_plots.py @@ -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: diff --git a/apps/studio/src/orisys_studio/ui/video_analysis_dialog.py b/apps/studio/src/orisys_studio/ui/video_analysis_dialog.py index 32c38c0..1a6de91 100644 --- a/apps/studio/src/orisys_studio/ui/video_analysis_dialog.py +++ b/apps/studio/src/orisys_studio/ui/video_analysis_dialog.py @@ -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}") ) diff --git a/apps/studio/tests/test_curve_retention.py b/apps/studio/tests/test_curve_retention.py new file mode 100644 index 0000000..2b32b85 --- /dev/null +++ b/apps/studio/tests/test_curve_retention.py @@ -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() diff --git a/apps/studio/tests/test_recording_recalibration.py b/apps/studio/tests/test_recording_recalibration.py new file mode 100644 index 0000000..e59e055 --- /dev/null +++ b/apps/studio/tests/test_recording_recalibration.py @@ -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] diff --git a/shared/src/orisys_app_shared/analysis/bare_video.py b/shared/src/orisys_app_shared/analysis/bare_video.py index a3bea8f..e87362d 100644 --- a/shared/src/orisys_app_shared/analysis/bare_video.py +++ b/shared/src/orisys_app_shared/analysis/bare_video.py @@ -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, diff --git a/shared/src/orisys_app_shared/analysis/service.py b/shared/src/orisys_app_shared/analysis/service.py index a0b5897..d4b309d 100644 --- a/shared/src/orisys_app_shared/analysis/service.py +++ b/shared/src/orisys_app_shared/analysis/service.py @@ -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", diff --git a/shared/src/orisys_app_shared/force/history.py b/shared/src/orisys_app_shared/force/history.py index b7cb56f..b6f3197 100644 --- a/shared/src/orisys_app_shared/force/history.py +++ b/shared/src/orisys_app_shared/force/history.py @@ -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) ]