Files
orisys-app/docs/architecture/studio-stream-pipeline-migration.md
T
ykcheungab 8a027bfcb9 接管 Studio 流水线迁移记录
将历史比较放回产品仓库并标明现行实现所有者。文档与仓库门禁通过,原记录正文保留。
2026-10-19 10:15:45 +08:00

184 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Studio acquisition on the SDK's concurrent pipeline
Historical migration record, 2026-10-01; moved to its product owner on 2026-10-19.
Numbers, failures and proposed SDK changes below describe that run, not current acceptance.
The original scope changed App workers and read the SDK; no SDK change was part of it.
Current implementation owners are the [SDK worker](../../shared/src/orisys_app_shared/acquisition/sdk_worker.py),
[finger worker](../../shared/src/orisys_app_shared/acquisition/finger_worker.py) and
[packet mapping](../../shared/src/orisys_app_shared/acquisition/models.py).
The open contact-detection and live-camera questions were not revalidated by this move.
## What changed
The box/mini worker (`shared/src/orisys_app_shared/acquisition/sdk_worker.py`) replaced
its `get_img()` → `process_chain()` → `read_forces_from_sensor()` loop with
`StreamPipeline(sensor, producer, contact=…, include=…)` and now consumes the finished
`FrameSnapshot`. The finger worker (`.../acquisition/finger_worker.py`) replaced
`read_source()` → `process_frame()` → `process_chain(tactile)` with a pipeline over
`runtime.tactile.raw_sensor`, built the way `runtime.pipeline()` builds it;
`runtime.warmup(color=False)` still runs first.
The worker's public contract is unchanged: `pause()/resume()/paused`, `latest()`,
`reset()`, `stop()`, `consume_end_reason()`, `phase`, `capture_status()`,
`filter_state()`, the record-channel sinks, and every `SdkPacket` field the UI reads.
The record channel still sees every published frame: the pipeline's `on_frame` hands
each snapshot to the worker, which builds one packet and emits it as the serial loop
did. `SdkPacket` construction now lives in one place,
`models.from_snapshot(snapshot, sensor, …)`, rather than being spelled out field by
field in each worker.
| Snapshot / app fact | Packet field |
|---|---|
| `seq` | `frame_id` |
| `timestamp`, collect time (`timestamp − age_ms`) | `timestamp`, `wall_t`, `mono_t` |
| `fps` | `fps` |
| `normal_force`, `shear_x`, `shear_y`, `*_raw` | same |
| `unit` | `force_unit` |
| `image` → BGR | `processed_frame` |
| `flow`, `depth` | same |
| `centroid` + sensor `centroid_found` | `contact` (`ContactObservation`) |
| stage times | `process_ms` |
| app | `session_id`, `valid`, `invalid_reason`, `stale_forces`, `force_calibrated`, `calibrated_axes`, `calibration_version`, `raw_available`, `capture_fps`, `capture_dropped` |
`mono_t` is the collect time for a live source and `frame_id × frame period` for a file,
so a replay keeps the video's own timeline. `include=` asks only for what the app can
display; flow and depth are both carried because the 3D pane lets the operator switch
between them at runtime.
## The filter decision: the SDK filters, the app does not
Studio already pushed its filter choice into the device (`FilterSettings.apply_to` →
`Sensor.configure`) and read the filtered values back; no app-side force filter exists.
The migration consumes the SDK's fields as they are: `snapshot.normal_force`/`shear_*`
(filtered and calibrated) and `snapshot.normal_force_raw`/`shear_*_raw` (the unfiltered
HHD integrator output). `ForcePresentation` (offsets, deadband, tare) is untouched and
still display-only.
The packet diff decided this, not inspection. With the SDK filter chain off, the two
paths agree **exactly** on all six force fields — only possible if neither side filters
separately. With `median(3) + lowpass(10 Hz)` pushed into the device, the *raw* six
still agree exactly while the filtered three do not — and running the **same path
twice** on the same clip reproduces that divergence (max |Δnormal_force| ≈ 0.010 N,
0 at the raw fields). The SDK's `_update_force_sample_rate` re-estimates the low-pass
sample rate from wall-clock cadence, so a *filtered* value on a file source is a
function of throughput as well as of the frames. A filtered force on a replay is
reproducible on neither path, and the honest comparison is the raw one.
## Packet diff
Same clip through both paths, one `SdkPacket` per frame, every field compared.
| Run | Frames | Fields | Mismatches |
|---|---|---|---|
| box | 26 | 27 | none; `contact` only differs because the serial loop stated none |
| finger | 32 | 29 | one frame's contact detection |
| box, 264-frame clip | 252 | 27 | none; the same `contact` difference |
| finger, 264-frame clip | 263 | 29 | 23 frames' contact detection |
Tolerances: forces and `*_raw` at 1e-6, arrays at 1e-6 max absolute difference,
ids/units/flags/provenance exactly equal. `fps_present`, `valid_mask_matches_field` and
`contact_frame_id` are consistency checks. The only real difference is the `contact`
observation the serial loop never stated: on 23 of 263 finger frames the new path finds
a contact (a centre around (150, 250) in UV) where the serial path finds none. The
serial path computes optical flow **twice per frame** (`compute_deformation` calls
`compute_flow` after `get_img` already did), so its expansion mask and the pipeline's
are not the same measurement. Which is right is unresolved.
## Performance, measured
264-frame 1920×1080 box clip and the same frames through the finger runtime, two
repetitions each, this machine.
| Path | Wall (median) | Settled fps | `process_ms` |
|---|---|---|---|
| box, serial (252 frames) | 9.42 s | 51.9 | 16.5 |
| box, pipeline | 7.40 s | 93.9 | 24.7 |
| finger, serial (263 frames) | 6.31 s | 47.9 | 14.1 |
| finger, pipeline | 2.56 s | 130.5 | 18.6 |
Read the columns separately. The rate is what the pipeline buys — **1.8×** for the box,
**2.7×** for the finger — and it is the number the rail shows. The wall-clock gain is
smaller (1.27× box, 2.47× finger) because a file pass also pays a fixed cost for
opening the device and filling the SDK's caches; on the 33-frame clip that cost
dominated completely (2.25 s vs 2.27 s) while the rate still went 66.6 → 112.3 fps.
`process_ms` rises because it is now the **stage sum** the SDK measures. A live camera
cannot be exercised here; its gain should be the pipeline's own.
## Behavioural checks
- **Pause keeps the device open and publishes nothing.** `pause()` holds the pipeline at
`PauseLevel.CAPTURE_ONLY`: the read continues (exposure, white balance and frame rate
stay converged) while flow and HHD are skipped and no snapshot is published. A frame
already in flight is dropped rather than handed over. `resume(reset=True)` rebases
the tracker, which keeps a `CAPTURE_ONLY` hold out of the force reading.
- **A recording session is structurally unchanged.** Packets are still built in order,
one per published frame, from the acquisition thread; `frame_id`, `mono_t` and the
per-frame provenance flags mean what they did.
- **No orphaned threads.** `stop()` joins all three pipeline threads. A new test
(`shared/tests/test_stream_pipeline_acquisition.py`) asserts that
`orisys-pipeline-produce/-flow/-hhd` are gone after `stop()`, that a stop/start cycle
runs a second session, and that the packet mapping carries every UI field.
- **End of input.** A file's end is reported as `ended` exactly as before, and a source
that yields nothing reports an error instead of waiting out the ten-second deadline.
## Verification actually run
| Gate | Result | Before |
|---|---|---|
| `shared/tests` | 389: 386 passed, 1 skipped, 2 failed | 383: 382 passed, 1 skipped, 2 failed |
| `apps/studio/tests` | 375: 358 passed, 17 skipped, 0 failed | same |
| `apps/autocal/tests` | 358 passed, 0 failed | same |
| `apps/studio/tools/ui_check.py` | 600 checks, 0 failures | same |
| real-GL smoke | 14: 13 passed, 1 failed | same |
| `tools/lint_gate.py`, changed files | 3 findings, all at HEAD (S110 ×3) | same |
The two shared failures are the documented wall-clock pair in
`test_worker_frame_rate.py`: the fake camera delivers ~21.5 fps on this machine against
the 30 fps its thresholds assume, and the serial path measures 21.5 fps in the same
conditions, so both are limited by the fake device's own thread. The GL failure is the
pre-existing `test_an_automatic_colour_scale_follows_the_frame`.
## What was kept, and what was decided alone
The serial chain is **kept** behind `use_stream_pipeline=False` on both workers; it is
the reference the packet diff runs against, not the path a session uses. Decided here,
not in the brief:
1. **The finger pipeline is constructed directly**, not through `runtime.pipeline()`,
which hardcodes `grab_and_process` as the producer and so cannot see the source
frame the record channel's pre-roll history wants, nor skip a repeated frame.
2. **The producer holds the read until the capture source has published another frame**,
for a live camera. The serial loop did this through the source's sequence counter,
and without it a 30 fps device is recorded at the polling rate with every
measurement duplicated.
3. **A file pass is paced by the declared frame period.** Without it a replay runs as
fast as the CPU allows and stamps 122 s of video time in 0.4 s of wall clock.
4. **The SDK's contact `centroid_found` decides whether there is a contact**, not the
centre itself: the SDK leaves `centroid` at `(0, 0)` when it found nothing, and
reporting that would put a marker in the frame's corner that nothing measured.
5. **`capture_dropped` prefers the capture source's own counter**, falling back to the
pipeline's produced-minus-published backlog, which is all a file source can answer.
6. **The packet's `fps` is measured at the collection times**, the same basis the serial
loop used, so a replay's reported rate means the same thing on both paths.
Left open: the finger contact-detection difference, and a live-camera measurement of the
throughput gain.
## Proposed SDK changes
The migration needed no SDK change, but three would remove workarounds the app carries.
1. **Let a caller supply the producer to `runtime.pipeline()`** (or accept a
`raw_frame=True` / frame-sink option), so a finger caller gets the pre-unroll source
frame and repeated-frame suppression without building the `StreamPipeline` itself.
`for_finger_runtime` hardcodes `grab_and_process` today.
2. **Pin the force low-pass sample rate for file sources** instead of re-estimating it
from wall-clock cadence (`_update_force_sample_rate`, `sensor.py`:814-844).
`_lowpass_rate_auto` is already off for external-frame sources; the same for an
opened video would make a filtered force reproducible, which is now false on both
acquisition paths.
3. **Expose the frames a pipeline has produced and published**, and a documented way to
wait for the next delivered frame. The app reaches into `StreamPipeline._done` and
re-implements the repeat check around `CaptureSource`, because `wait()` is
latest-wins and the only every-frame hook is `on_frame`, which the app must then
drain itself for ordering and backpressure.