add session browsing, calibration diagnostics and the 2D layout preset
Phase 2 of the review: browse the whole session instead of only the rolling window, diagnose a calibration from the recorded points, and provide the reviewed two-panel layout. What - SessionStore gains extent(), visible(t0, t1, max_points) and statistics(t0, t1, unit). visible() walks only the blocks that overlap the window and decimates by taking each bucket's minimum and maximum, so a spike survives decimation and browsing a long session does not copy all of it. - ForcePlotsPanel gains follow/browse modes, a whole-session overview strip with the current window, pan and wheel zoom, a rubber-band zoom toggle, a shared time cursor across all three channels, and a selection readout (duration, valid samples, per-axis peak/mean/std). - CalibrationPoint records a bounded per-axis trace, an excluded flag, a validation flag and its fit residual. CalibrationRecorder gains set_excluded/set_validation/fit; the fit is a least-squares line over the non-excluded, non-validation points with per-point residuals and an independent validation error. - The calibration table becomes the reviewed diagnostic view (reference load | FN | FX | FY | stability | state) with the residual shown next to the loaded axis, plus exclude/restore and mark-validation actions and a fit summary line. Double clicking a row opens the point's three-axis trace. - Add a layout preset: free layout or 2D mode (depth map on the left, force charts on the right, calibration folded away), persisted in preferences. Why - Browsing is a display pause, not a capture pause: the record channel keeps running while the charts show an older window. - Excluding a point removes it from the fit and from the exported calibration but keeps it on record, so the evidence for the decision is not destroyed. - The non-loaded axes are recorded but never given a reference load: their true load is unknown during a single-axis calibration, so it stays empty. Verification - shared/tests/test_record_channel.py grows to 18 cases, covering extent, peak-preserving decimation, windowed reads, inverted/empty windows, unit-aware statistics and the time origin. - tools/ui_check.py gains "session browsing" and "layout preset" sections (223 checks, 0 failures overall). - apps/studio/tests/test_ui_smoke.py covers the same invariants. Not in this change: synchronised playback of video, depth and curves. It needs a decision on how depth is stored for replay (per-frame PNG, reduced-rate PNG, or offline recomputation); the cursor plumbing (cursor_moved / set_cursor) is already in place for it.
This commit is contained in:
@@ -61,6 +61,23 @@ def section(name: str) -> None:
|
||||
print(f"\n== {name} ==")
|
||||
|
||||
|
||||
def _calibration_sample(index: int, *, fn_raw: float, fx_raw: float = 0.4, fy_raw: float = -0.2):
|
||||
"""A sample shaped for the calibration recorder."""
|
||||
from orisys_app_shared.force.session import SessionSample
|
||||
|
||||
return SessionSample(
|
||||
frame_id=index,
|
||||
t_rel_s=index / 100.0,
|
||||
fn_raw=fn_raw,
|
||||
fx_raw=fx_raw,
|
||||
fy_raw=fy_raw,
|
||||
fn_n=fn_raw / 100.0,
|
||||
fx_n=fx_raw / 100.0,
|
||||
fy_n=fy_raw / 100.0,
|
||||
valid=True,
|
||||
)
|
||||
|
||||
|
||||
def _fake_packet(
|
||||
frame_id: int,
|
||||
*,
|
||||
@@ -75,6 +92,8 @@ def _fake_packet(
|
||||
|
||||
nan = float("nan")
|
||||
blank = np.zeros((8, 8, 3), np.uint8)
|
||||
# A single tall sample makes it easy to prove decimation keeps peaks.
|
||||
fn_raw = 99.0 if frame_id == 700 else (9.0 + frame_id * 0.01)
|
||||
return SdkPacket(
|
||||
raw_frame=blank,
|
||||
processed_frame=blank,
|
||||
@@ -495,15 +514,71 @@ def main() -> int:
|
||||
|
||||
window.cal_panel.set_running(True)
|
||||
check(window.cal_panel.btn_record.isEnabled(), "record button stays disabled")
|
||||
window.cal_panel._store.upsert(
|
||||
"fn", CalSample(weight_n=1.0, raw_mean=9.7, n=120, std=0.02)
|
||||
check(
|
||||
window.cal_panel.table.columnCount() == 6,
|
||||
"the calibration table is no longer the six-column diagnostic view",
|
||||
)
|
||||
window.cal_panel._refresh_table()
|
||||
|
||||
# Drive a real point through the recorder: the table is a view over
|
||||
# the recorded points, not over a separate sample list.
|
||||
window.cal_recorder.start(
|
||||
channel="fn", reference_load_n=1.0, duration_s=1.0
|
||||
)
|
||||
for index in range(40):
|
||||
window.cal_recorder.feed(
|
||||
_calibration_sample(index, fn_raw=100.0 + index * 0.01)
|
||||
)
|
||||
point = window.cal_recorder.finish_now()
|
||||
window.cal_panel.on_record_finished(point)
|
||||
app.processEvents()
|
||||
check(window.cal_panel.table.rowCount() == 1, "calibration row missing")
|
||||
check(
|
||||
not window.cal_panel.empty_hint.isVisible(),
|
||||
"empty hint still visible with rows",
|
||||
)
|
||||
check(
|
||||
window.cal_panel.fit_label.text() != "",
|
||||
"the fit summary is empty",
|
||||
)
|
||||
|
||||
# Three more points, one of them off-trend, so the fit has a residual
|
||||
# that excluding the outlier must reduce and enough left over to hold
|
||||
# one out as a validation point.
|
||||
for load, raw in ((2.0, 400.0), (3.0, 300.6), (5.0, 500.9)):
|
||||
window.cal_recorder.start(
|
||||
channel="fn", reference_load_n=load, duration_s=1.0
|
||||
)
|
||||
for index in range(40):
|
||||
window.cal_recorder.feed(_calibration_sample(index, fn_raw=raw))
|
||||
window.cal_panel.on_record_finished(window.cal_recorder.finish_now())
|
||||
app.processEvents()
|
||||
check(window.cal_panel.table.rowCount() == 4, "calibration rows missing")
|
||||
|
||||
worst = window.cal_recorder.fit("fn").max_residual_n
|
||||
check(worst > 0.05, f"the off-trend point produced no residual: {worst}")
|
||||
window.cal_panel.table.setCurrentCell(1, 0)
|
||||
window.cal_panel._on_toggle_excluded()
|
||||
app.processEvents()
|
||||
check(
|
||||
window.cal_recorder.fit("fn").max_residual_n < worst,
|
||||
"excluding an outlier did not tighten the fit",
|
||||
)
|
||||
check(
|
||||
window.cal_panel.table.item(1, 5).text() == t("cal.state_excluded"),
|
||||
"the excluded row does not show its state",
|
||||
)
|
||||
|
||||
# A validation point is held out of the fit and reported separately.
|
||||
window.cal_panel.table.setCurrentCell(2, 0)
|
||||
window.cal_panel._on_toggle_validation()
|
||||
app.processEvents()
|
||||
check(
|
||||
t("cal.fit_validation").split("{")[0].strip()
|
||||
in window.cal_panel.fit_label.text(),
|
||||
"no validation summary after marking a validation point",
|
||||
)
|
||||
window.cal_recorder.reset_session()
|
||||
window.cal_panel._refresh_table()
|
||||
|
||||
window._set_phase("running")
|
||||
check("status_running" not in window.status_chip.text(), "chip not localized")
|
||||
@@ -843,6 +918,135 @@ def main() -> int:
|
||||
stuck.release()
|
||||
_shutil.rmtree(scratch, ignore_errors=True)
|
||||
|
||||
section("session browsing")
|
||||
import numpy as np
|
||||
|
||||
stuck = _StuckWorker()
|
||||
window = StudioMainWindow(
|
||||
prefs={
|
||||
"language": "zh_CN",
|
||||
"theme": "dark",
|
||||
"sensor_type": "box",
|
||||
"source_mode": "camera",
|
||||
}
|
||||
)
|
||||
window.resize(1440, 880)
|
||||
window.show()
|
||||
window._ensure_workers = lambda: None
|
||||
window._box_worker = stuck
|
||||
window._finger_worker = stuck
|
||||
window._start_run()
|
||||
window._run_timer.stop()
|
||||
window._set_phase("running")
|
||||
for index in range(1500):
|
||||
stuck.emit(_fake_packet(index))
|
||||
app.processEvents()
|
||||
|
||||
plots = window.force_plots
|
||||
check(plots.mode() == "follow", "the charts do not start in live mode")
|
||||
check(
|
||||
plots.stats_label.text() == "",
|
||||
"the selection readout is not blank while following live",
|
||||
)
|
||||
|
||||
first, last = window.session_store.extent()
|
||||
check(last > first, "the session store has no extent after recording")
|
||||
|
||||
plots.show_all()
|
||||
check(plots.mode() == "browse", "the overview did not enter browse mode")
|
||||
check(
|
||||
all(plot.getViewBox().state["mouseEnabled"][0] for plot in plots.plots),
|
||||
"browsing does not enable panning",
|
||||
)
|
||||
window_texts = plots.stats_label.text()
|
||||
check(window_texts != "", "no selection statistics in browse mode")
|
||||
check("峰" in window_texts, f"the peak is missing from the readout: {window_texts!r}")
|
||||
|
||||
# Decimation must keep the spike while bounding the point count.
|
||||
drawn = max(int(curve.getData()[0].size) for curve in plots.curves)
|
||||
check(drawn <= 2 * plots._max_draw_points, f"overview drew {drawn} points")
|
||||
spike = float(np.nanmax(plots.curves[0].getData()[1]))
|
||||
check(spike > 90.0, f"the overview lost the spike: {spike}")
|
||||
|
||||
plots.set_window(10.0, 20.0)
|
||||
check(plots.window() == (10.0, 20.0), f"set_window did not apply: {plots.window()}")
|
||||
check(
|
||||
plots.overview_curve.getData()[0].size > 0,
|
||||
"the whole-session overview strip is empty",
|
||||
)
|
||||
|
||||
# A user gesture enters browse; "back to latest" must actually leave it.
|
||||
plots._syncing = False
|
||||
plots.plots[0].setXRange(5.0, 8.0, padding=0.0)
|
||||
app.processEvents()
|
||||
check(plots.mode() == "browse", "a user pan did not enter browse mode")
|
||||
plots.follow_latest()
|
||||
check(plots.mode() == "follow", "back-to-latest did not restore live mode")
|
||||
|
||||
plots.set_cursor(3.5, emit=True)
|
||||
check(
|
||||
[round(cursor.value(), 3) for cursor in plots.cursors] == [3.5, 3.5, 3.5],
|
||||
"the time cursor is not shared across the three channels",
|
||||
)
|
||||
|
||||
# History browsing is a display pause: capture and recording keep going.
|
||||
plots.show_all()
|
||||
before = window.session_store.count
|
||||
for index in range(1500, 1560):
|
||||
stuck.emit(_fake_packet(index))
|
||||
check(
|
||||
window.session_store.count > before,
|
||||
"browsing history stopped the record channel",
|
||||
)
|
||||
|
||||
window.close()
|
||||
app.processEvents()
|
||||
stuck.release()
|
||||
|
||||
section("layout preset")
|
||||
stuck = _StuckWorker()
|
||||
window = StudioMainWindow(
|
||||
prefs={
|
||||
"language": "zh_CN",
|
||||
"theme": "dark",
|
||||
"sensor_type": "finger",
|
||||
"source_mode": "camera",
|
||||
"show_finger_3d": True,
|
||||
}
|
||||
)
|
||||
window.resize(1600, 900)
|
||||
window.show()
|
||||
window._ensure_workers = lambda: None
|
||||
window._box_worker = stuck
|
||||
window._finger_worker = stuck
|
||||
app.processEvents()
|
||||
|
||||
check(window._layout_preset == "default", "the layout preset did not load")
|
||||
check(window.mesh3d_panel.isVisible(), "the 3D panel is hidden in the free layout")
|
||||
|
||||
window._on_layout_preset("2d")
|
||||
app.processEvents()
|
||||
check(window._layout_preset == "2d", "the 2D preset did not apply")
|
||||
check(not window.mesh3d_panel.isVisible(), "the 2D preset keeps the 3D panel")
|
||||
check(
|
||||
window.arrow_panel.view_mode() == "depth",
|
||||
"the 2D preset does not show the depth map",
|
||||
)
|
||||
check(not window.cal_panel.isVisible(), "the 2D preset keeps calibration expanded")
|
||||
check(not window.act_finger_3d.isEnabled(), "the 3D toggle is live in the 2D preset")
|
||||
check(window.act_layout_2d.isChecked(), "the 2D menu entry is not checked")
|
||||
|
||||
window._on_layout_preset("default")
|
||||
app.processEvents()
|
||||
check(window.mesh3d_panel.isVisible(), "the free layout did not restore the 3D panel")
|
||||
check(window.act_finger_3d.isEnabled(), "the 3D toggle stayed disabled")
|
||||
window._persist_prefs()
|
||||
check(window.prefs.get("layout_preset") == "default", "the preset was not persisted")
|
||||
|
||||
window.close()
|
||||
app.processEvents()
|
||||
stuck.release()
|
||||
|
||||
print(f"\nscenes: {scene_count} checks: {CHECKS} failures: {len(FAILURES)}")
|
||||
for failure in FAILURES:
|
||||
print(f" FAIL: {failure}")
|
||||
|
||||
Reference in New Issue
Block a user