A replay could draw arrows but not depth, and the recording view said there was
nothing to draw even when a result existed. Depth is now quantised per frame
(14 B empty, 486 B at the worst frame seen), read from the cache by the same
draw path live uses, and the raw-recording note appears only when no result
exists. Format version 2; see apps/studio/docs/replay-analysis.md.
Replay re-ran the SDK chain on every seek: 0.48-0.51 wall-seconds per video
second, about 4.8 minutes for a ten-minute recording. The new
orisys_app_shared.analysis package runs that pass once and stores the flow
samples a replay draws - 4,034 B per frame at the shipped finger grid instead of
the chain's 1,176,000 B; the result contract is in
apps/studio/docs/replay-analysis.md.
Verified: shared/tests 416 passed / 1 skipped (15 new) and apps/studio/tests
unchanged at 425 items / 0 failed / 18 skipped; tools/lint_gate.py clean; a
264-frame pass costs 0.5036 wall-s per video-s against 0.5052 for the bare chain
measured the same day.
The selector now returns candidate locations with an activity flag, so the quiet half of
the finger's sampling grid can be drawn at last: one radius-1 dot per candidate, in the
idle tint and off the flow ramp, on no sample that also carries an arrow. The dots are
`DRAW_IDLE_GRID_DOTS` (on) and `draw_arrow_display(idle_dots=...)`; box and mini keep
their own SDK path.
The selector returned only the arrows it would draw, so a view could not draw
anything at a quiet sample and the below-threshold branch was unreachable. It
now returns every candidate location plus an `active` flag, both callers filter
on it, and the magnitude colour ramp is fixed so one magnitude is one colour
across frames. Verified by pixel parity against HEAD for the 2D canvas and the
3D overlay, and by the shared and studio suites.
The tracker reset was decided by which panel was on screen rather than by the
session, the automatic colour range moved the 3D surface's base colour on the first
press, and the 2D canvas and the 3D overlay applied different flow thresholds (622
arrows against 432 on a real recording). ui_check 600/0; apps/studio 380 items and
shared 390 items with no failures; real-GL 18 items, only the pre-existing
colour-scale failure; AutoCal 358 passed; both views now select identical arrows on
all 42 frames of a real video.
What
- record/index.py: RecordingIndex streams forces.csv once and keeps only the
small things — one time, one validity flag, one frame id per row plus a byte
offset every 4096 rows (~6.5 MB per 500k rows). row_range() bisects the times,
read_rows() seeks to the nearest checkpoint and scans forward, and overview()
folds every row into its bucket during the same pass to build a per-axis
min/max envelope.
- read_rows(max_points, unit) decimates **while streaming**: each bucket keeps
the extreme row of every axis in the requested unit, so a long interval never
has to exist in memory in full and a shear spike survives a flat normal channel.
- RecordingReader: info() decimates above max_rows (default 200k) and marks
itself decimated, with total_rows and a duration taken from the frame index
rather than from the view; window()/window_all()/overview()/statistics() read
the interval being asked about. list/window semantics unchanged for small
recordings.
- force/source.py: StoreCurveSource (live memory) and RecordingCurveSource (disk
interval + cached envelope) implement one interface — extent, window,
envelope, statistics, duration_s — so ForcePlotsPanel asks a *source* instead
of knowing where the data lives.
- force_plots: reads through the source (window, envelope, statistics, extent),
which also replaces its last direct store accesses.
- playback_dialog: draws the whole-session view through window_all() in the unit
being shown (bounded by CHART_MAX_POINTS), and says so when the view is
decimated ("显示 N / 共 M 行") instead of letting the operator count points.
- experiment._run: a failure that lands while the files are opening can no longer
be overwritten by the writer's own transition to "recording" (found by a flaky
test; the state check now covers STATE_FAILED).
Why
The store releases old blocks above ~4.6 h and the replay loaded every CSV row
into Python lists before drawing anything, so neither the live browser nor the
replay could show an earlier interval of a long recording. The index makes the
disk the data source and memory a cache of what is on screen.
Verification
- shared/tests/test_history_source.py (new, 8 cases) over a 12 000-row recording:
the index reports its rows and reads a bounded interval; a large recording is
marked decimated with its true duration and total rows; a small one is not; an
interval read sees the exact rows including a single peak; a decimated window
keeps the peak; the envelope fits the whole session in ≤200 points, keeps the
spike and is cached; the disk and memory sources agree on extent, values and
statistics; the whole session can be read without holding it all. Shared
suites: 146 tests, 0 failures (three consecutive runs).
- apps/studio/tools/ui_check.py: the replay section now checks the index row
count, that an interval read spans only the interval, the envelope size and
total_rows. 324 checks, 0 failures.
- apps/studio/tests/test_ui_smoke.py: 43 tests, 36 pass; the 7 failures are the
unchanged sandbox/harness artifacts.
- D:/orisys_ws/_review_phase12/reproduce.py: all ten lines unchanged;
compileall, locale and prefs parity clean.
- apps/studio/docs/record-channel.md §8 documents the index, the reader API and
the unified source, and lists what is still hardware work.
Not in this change
- Maximum RSS / backlog / loss measurements at real resolution on the target ARM
board (needs the hardware), and range CSV export.
What
- record: one packet->row conversion (record/sample.py). A failed compute chain
records no force value at all, a newton value is written only for an axis that
really has a calibration curve, and mono_t (not the wall clock) orders history
with a per-row calibration hash.
- record lifecycle: explicit starting/recording/stopping/stopped/failed states
owned by the writer thread; stop() reports completed=False on timeout and does
not write ended_at; consecutive write failures stop acceptance immediately.
- record capacity: byte budget for the video queue (512 MiB default); a full
queue sheds the *frame* and parks the tiny data row, so a video overflow no
longer deletes CSV rows. Lost rows leave a dropped=1 index row and are counted
as dropped_gaps / unrecorded_gaps.
- calibration identity: sha256 content hash instead of a path, minted from the
calibration the sensor actually loaded (reported before the first frame) and
confirmed by the acquisition thread; events are timestamped when they happen.
- calibration diagnostics: the fit summary, residual and validation error now
come from the model that export/apply would use (piecewise_linear); validation
points are held out of the build; repeated reference loads are merged with an
n-weighted mean and pooled std.
- calibration record: every point keeps its full three-axis sample series
(frame id, time, raw, newtons, valid) plus diagnostics, written to an
independent calibration_session_*.json that can be reopened for inspection;
save record / export coefficients / apply to device are three separate actions.
- depth replay: the cursor's frame is bound to the depth result by request
sequence and frame id, so a moved cursor never shows another frame's map; the
SDK is stepped from its current position instead of re-reading from the start;
the live chain (box deformation+contact, finger unroll+tactile) is replayed
with the exact resolved config path; algorithm state is rebuilt from a reset
marker or from the recorded pre-roll, and says so when it cannot be.
Why
The review (D:/orisys_ws/_review_phase12/review.md) showed the app writing raw
counts into the newton columns, recording a stale value after a failed compute,
losing CSV rows when the video queue filled, reporting success while a writer was
still running, diagnosing a model nobody applies, applying a calibration before
the device confirmed it, dropping the samples behind a calibration point, showing
a stale depth map under a moved cursor and recomputing depth of the wrong frame.
Verification
- D:/orisys_ws/_review_phase12/reproduce.py: all ten acceptance lines flipped
(log saved as _review_phase12/reproduce_after.log). Including 25/25 rows kept
with 8 video frames shed, frames [2]->[3], depth 8.0/8.0, fit 2/2 points.
- apps/studio/tools/ui_check.py: 249 checks, 0 failures (twice).
- shared suites: 91 tests, 0 failures (new: test_record_semantics,
test_record_lifecycle, test_calibration_model, test_calibration_record,
test_depth_replay).
- apps/studio/tests/test_ui_smoke.py: 23/30; the 7 remaining are harness
artifacts of the sandbox (4 tmp_path PermissionError, 3 parametrized tests the
shim cannot drive), unchanged from before this change.
- compileall clean; locale parity zh/en + t() key coverage: 0 failures.
Not in this change
- P2#10 disk-based browsing of a long recording and range CSV export (next
round, per your choice).
- Stability-triggered calibration sampling, replay fixed colour scale, PyOpenGL
three-dimensional view.
- Hardware verification: no camera attached, so the SDK's real processing chain
and the pre-roll's effect on a live tracker were exercised with fakes only.
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.
Phase 1 of the review asked for one directory per experiment, a force CSV,
video of the pre-processing image, and metadata that ties them together. The
record channel from the previous change already kept the whole session; this
adds the on-disk format.
What
- Add record/experiment.py: ExperimentRecorder writes one directory per
session containing session.json, forces.csv, frames.csv, events.csv,
raw_NNNN.mp4 (segmented) and an optional lossless PNG sequence.
- forces.csv uses the reviewed column order
(session_id, frame_id, t_rel_s, FN_raw, FX_raw, FY_raw, FN_N, FX_N, FY_N,
valid, calibration_id) and leaves a cell empty when a value is unavailable:
an uncalibrated newton value or a failed readout is never written as 0.
- frames.csv is the frame-index mapping: frame_id, t_rel_s, acquisition wall
clock, segment, segment_frame, in_video. forces.csv and frames.csv share the
packet frame_id and the same t_rel_s origin, so video and data can be
aligned after dropped or segmented frames.
- session.json carries format version, session id, start/end time in UTC,
device and config (with a sha256 of the config file), app/shared/SDK
versions, calibration reference, axis convention, filter parameters, video
codec and the lossy-MP4 caveat, plus the event log and end-of-run statistics.
It is written once at start so a crash still leaves identifiable metadata.
- Add a record control to the toolbar: mode (video+data / data only) and a
start/stop toggle that stays enabled while recording.
- Log zeroing, calibration application and session end as timestamped events.
Why
- The recorder is fed from the worker sink list on the acquisition thread and
only appends to a bounded queue, so disk I/O can never stall acquisition.
- On_packet copies the raw frame instead of holding a reference: the packet
frame may alias a buffer the SDK reuses between frames.
- A queue overflow is counted and alarmed (rate limited), and a write or encode
failure is counted, reported and after 5 consecutive errors stops the
recording with failed=true rather than silently losing data.
Verification
- shared/tests/test_experiment_recorder.py: 12 cases covering the directory
layout, the CSV contract, frame-id/time-base agreement, segment rolling,
data-only and lossless modes, queue overflow accounting, encoder failure,
metadata fields, crash-before-stop, and that on_packet never blocks.
- tools/ui_check.py gains an "experiment record" section (169 checks, 0
failures overall), driving a recording through the UI and asserting the
layout, the empty-cell rule and the event sequence.
- apps/studio/tests/test_ui_smoke.py mirrors those invariants.
- docs/record-channel.md documents the format and the remaining phase-2 work.
Not in this change: CSV export of a selected time range, full-session curve
browsing, calibration diagnostics (outlier exclusion, fit residuals) and
PyOpenGL 3D.