Skip to content

Commit edf191a

Browse files
committed
feat(vision pipeline): speccing consumer pipeline, producer pipeline happy path test passing
1 parent 9d4140a commit edf191a

6 files changed

Lines changed: 466 additions & 123 deletions

File tree

docs/software/consumer_pipeline.md

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
# Consumer pipeline: ready pool -> RKNN -> detections -> release (thread boundary)
2+
3+
This document describes the **per-frame hot path** through the consumer side of the vision pipeline:
4+
5+
**ImageBufferPool latest-ready slot** -> **RKNN inference (NPU)** -> **deterministic postprocess (decode + NMS + inverse letterbox)** -> **publish canonical detections** -> **release pool slot**.
6+
7+
The v1 design goal is strict simplicity:
8+
9+
- **Latest-wins latency policy** (never build consumer queues).
10+
- **One in-flight frame** per consumer thread.
11+
- **No per-frame allocations** in steady state.
12+
- **Minimal status surface** (`status + stage + errno`) instead of error taxonomy sprawl.
13+
14+
---
15+
16+
## Glossary
17+
18+
- **Latest-wins**: consumer always processes the freshest ready slot and ignores stale work.
19+
- **ImageBufferPool**: SPSC handoff between producer and consumer with one atomic ready index.
20+
- **RKNN input binding**: FD-backed tensor binding via `rknn_create_mem_from_fd` + `rknn_set_io_mem`.
21+
- **Remap config**: immutable letterbox geometry (`scale`, `pad_x`, `pad_y`, source/model sizes) from producer preflight.
22+
- **Detections packet**: compact output for downstream consumers (`class_id`, `score`, `bbox_px` + timing ids).
23+
24+
---
25+
26+
## 0) One-time setup (preflight / arm)
27+
28+
`ConsumerPipeline::preflight()` performs all heavy setup once:
29+
30+
1. Validate collaborators:
31+
- `ImageBufferPool` available
32+
- RKNN runner available/configured
33+
- optional telemetry sink
34+
2. Query RKNN model I/O attributes (`n_input`, `n_output`, tensor attrs).
35+
3. Configure required input binding:
36+
- FD-backed input via `rknn_create_mem_from_fd` + `rknn_set_io_mem`
37+
4. Preallocate all long-lived buffers:
38+
- RKNN IO memory wrappers
39+
- output tensor storage (or preallocated output structs)
40+
- postprocess scratch arrays (`top_k`, `max_det` bounded)
41+
5. Warm-up a small number of inferences.
42+
6. Cache immutable remap geometry in one location (shared pipeline config, not duplicated per frame).
43+
44+
### Runtime observation from local probe
45+
46+
On this RKNN 2.3.0 stack, FD-backed binding worked only when `rknn_create_mem_from_fd` received a **valid mapped `virt_addr`** for the DMA-BUF. Passing `virt_addr = nullptr` failed in probe runs.
47+
48+
---
49+
50+
## 1) Acquire latest ready slot (latest-wins)
51+
52+
Consumer tick starts with:
53+
54+
- `pool.acquire_read(idx)`
55+
56+
Outcomes:
57+
58+
- **No slot ready** -> return `ConsumerTickStatus::NoReadyBuffer`, stage `AcquireRead`.
59+
- **Success** -> read `pool.buffer_at(idx)` and capture per-frame metadata (`sequence`, `capture_ts_real_ns`, `pool_index`), then continue.
60+
61+
**Invariant:** every successful acquire must end in exactly one `publish_release(idx)`.
62+
63+
---
64+
65+
## 2) Infer stage (NPU)
66+
67+
Input is producer-written model-ready RGB/BGR data in DMA-BUF-backed `ImageBuffer`.
68+
69+
### FD-backed binding (required in v1)
70+
71+
- Bind DMA-BUF-backed tensor memory to RKNN input.
72+
- Perform required cache sync (`rknn_mem_sync(..., RKNN_MEMORY_SYNC_TO_DEVICE)`) when needed.
73+
- Call `rknn_run`.
74+
75+
Then:
76+
77+
- Retrieve outputs (`rknn_outputs_get`) into preallocated output storage.
78+
79+
On any inference failure:
80+
81+
- return `ConsumerTickStatus::InferError`, stage `Infer`, set `stage_errno` as available.
82+
83+
No per-frame allocations are allowed in this stage.
84+
85+
---
86+
87+
## 3) Postprocess stage (deterministic)
88+
89+
v1 postprocess is intentionally narrow and bounded:
90+
91+
1. Decode model outputs into fixed-capacity candidate arrays.
92+
2. Apply confidence threshold early.
93+
3. Apply class-wise NMS with deterministic caps:
94+
- `top_k_per_class`
95+
- `max_det`
96+
4. Map boxes from model coordinates back to source image coordinates using immutable remap config:
97+
- `x_src = (x_net - pad_x) / scale`
98+
- `y_src = (y_net - pad_y) / scale`
99+
- clamp to `[0, src_w/src_h]`
100+
5. Emit a compact canonical detections packet.
101+
102+
v1 excludes tracking/smoothing from the consumer hot path.
103+
104+
---
105+
106+
## 4) Publish stage (single canonical output)
107+
108+
Consumer publishes one canonical detections result per consumed frame.
109+
110+
v1 rule:
111+
112+
- `ConsumerPipeline` publishes through one interface boundary.
113+
- ROS2/overlay/logging/triggers fan-out downstream, not in the hot path.
114+
115+
This keeps consumer timing isolated from sink backpressure.
116+
117+
---
118+
119+
## 5) Release stage (return slot to pool)
120+
121+
After publish (or on any early-return path after successful acquire):
122+
123+
- `pool.publish_release(idx)`
124+
125+
This transitions the slot back to free for producer reuse.
126+
127+
**Invariant:** release must happen exactly once for each successful acquire.
128+
129+
---
130+
131+
## 6) Telemetry and latency accounting
132+
133+
When telemetry is enabled, emit one `ConsumerSample` with:
134+
135+
- `acquire_read_ns`
136+
- `infer_ns`
137+
- `postprocess_ns`
138+
- `publish_ns`
139+
- `release_ns`
140+
- `total_ns`
141+
- `stage_mask`, `consumer_status`, `infer_status`, `infer_errno`
142+
- `sequence` / `frame_id` when available
143+
144+
Derived metrics:
145+
146+
- consumer total latency
147+
- NPU latency
148+
- postprocess latency
149+
- capture->publish latency (with producer timestamp correlation)
150+
- effective drop/freshness behavior (produced vs consumed)
151+
152+
---
153+
154+
## 7) Failure policy and return contract
155+
156+
- `preflight()` may throw on startup/configuration failures.
157+
- `run()` is `noexcept` and returns `ConsumerTick`.
158+
- v1 keeps statuses minimal:
159+
- `Consumed`
160+
- `NoReadyBuffer`
161+
- `InferError`
162+
163+
Detailed diagnosis comes from:
164+
165+
- `stage` (`AcquireRead`, `Infer`, `Postprocess`, `Publish`, `Release`)
166+
- `stage_errno`
167+
- telemetry stage mask and status fields
168+
169+
---
170+
171+
## Definition of Done (v1)
172+
173+
- [ ] Single-thread consumer loop with latest-wins semantics.
174+
- [ ] One in-flight frame max, no consumer backlog queue.
175+
- [ ] No per-frame allocations in steady state.
176+
- [ ] RKNN preflight includes IO query + warm-up.
177+
- [ ] RKNN input path uses FD-backed `rknn_create_mem_from_fd` + `rknn_set_io_mem`.
178+
- [ ] Deterministic postprocess bounds (`top_k_per_class`, `max_det`).
179+
- [ ] Inverse letterbox mapping implemented and unit-tested.
180+
- [ ] Single canonical publish boundary in consumer.
181+
- [ ] Proper release of acquired pool slots on all exit paths.
182+
- [ ] Telemetry emitted with consumer stage timings and status mask.
183+
- [ ] Clean shutdown (stop loop, release resources, stop publishers).
184+
185+
---
186+
187+
## Compact diagram
188+
189+
```
190+
Consumer thread:
191+
192+
acquire_read(idx) --> ImageBuffer view (latest ready)
193+
|
194+
v
195+
RKNN infer (fd-backed set_io_mem path)
196+
|
197+
v
198+
decode -> threshold -> NMS -> inverse letterbox
199+
|
200+
v
201+
publish canonical detections
202+
|
203+
v
204+
publish_release(idx)
205+
```

vision/apps/producer_pipeline_test.cpp

Lines changed: 27 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@
1010
#include <vector>
1111

1212
#include "omniseer/vision/dma_heap_alloc.hpp"
13+
#include "omniseer/vision/image_buffer_pool.hpp"
1314
#include "omniseer/vision/pipeline.hpp"
15+
#include "omniseer/vision/rga_preprocess.hpp"
1416
#include "omniseer/vision/telemetry.hpp"
1517

1618
namespace
@@ -57,8 +59,6 @@ namespace
5759
{
5860
case ProducerStage::None:
5961
return "none";
60-
case ProducerStage::Preconditions:
61-
return "preconditions";
6262
case ProducerStage::Dequeue:
6363
return "dequeue";
6464
case ProducerStage::AcquireWrite:
@@ -100,36 +100,6 @@ namespace
100100
};
101101
} // namespace
102102

103-
TEST(ProducerPipeline, NotArmedReportsPreconditionsAndTelemetry)
104-
{
105-
omniseer::vision::V4l2Capture capture({
106-
.device = "/dev/video12",
107-
.width = 1280,
108-
.height = 720,
109-
.fourcc = V4L2_PIX_FMT_NV12,
110-
.buffer_count = 4,
111-
});
112-
omniseer::vision::ImageBufferPool pool;
113-
omniseer::vision::RgaPreprocess preprocess;
114-
TestTelemetry telemetry;
115-
116-
omniseer::vision::ProducerPipeline producer(capture, preprocess, pool, &telemetry);
117-
118-
const auto tick = producer.run();
119-
EXPECT_EQ(tick.status, omniseer::vision::ProducerTickStatus::CaptureFatalError);
120-
EXPECT_EQ(tick.stage, omniseer::vision::ProducerStage::Preconditions);
121-
EXPECT_EQ(tick.stage_errno, EPERM);
122-
EXPECT_EQ(tick.stage_mask, 0u);
123-
124-
ASSERT_EQ(telemetry.producer_samples.size(), 1u);
125-
EXPECT_EQ(telemetry.producer_samples[0].producer_status,
126-
static_cast<uint8_t>(omniseer::vision::ProducerTickStatus::CaptureFatalError));
127-
128-
telemetry.enabled = false;
129-
(void) producer.run();
130-
EXPECT_EQ(telemetry.producer_samples.size(), 1u);
131-
}
132-
133103
TEST(ProducerPipeline, PreflightThenProducesFrames)
134104
{
135105
const char* dev_env = std::getenv("VISION_V4L2_DEV");
@@ -191,19 +161,22 @@ TEST(ProducerPipeline, PreflightThenProducesFrames)
191161
TestTelemetry telemetry;
192162
omniseer::vision::ProducerPipeline producer(capture, preprocess, pool, &telemetry);
193163

194-
const auto pre_preflight = producer.run();
195-
EXPECT_EQ(pre_preflight.status, omniseer::vision::ProducerTickStatus::CaptureFatalError);
196-
EXPECT_EQ(pre_preflight.stage, omniseer::vision::ProducerStage::Preconditions);
197-
EXPECT_EQ(pre_preflight.stage_errno, EPERM);
198-
EXPECT_EQ(pre_preflight.stage_mask, 0u);
199-
EXPECT_EQ(pre_preflight.capture.status, omniseer::vision::CaptureStatus::FatalError);
200-
EXPECT_EQ(pre_preflight.capture.sys_errno, EPERM);
201-
ASSERT_EQ(telemetry.producer_samples.size(), 1u);
202-
203164
ASSERT_NO_THROW(producer.preflight());
204-
205-
int produced = 0;
206-
int nonfatal = 0;
165+
EXPECT_TRUE(producer.is_armed());
166+
167+
const auto& remap = producer.remap();
168+
EXPECT_EQ(remap.source_size.w, static_cast<int>(src_w));
169+
EXPECT_EQ(remap.source_size.h, static_cast<int>(src_h));
170+
EXPECT_EQ(remap.model_input_size.w, dst_w);
171+
EXPECT_EQ(remap.model_input_size.h, dst_h);
172+
EXPECT_GT(remap.scale, 0.0f);
173+
EXPECT_GE(remap.pad_x, 0);
174+
EXPECT_GE(remap.pad_y, 0);
175+
EXPECT_GT(remap.resized_w, 0);
176+
EXPECT_GT(remap.resized_h, 0);
177+
178+
int produced = 0;
179+
uint64_t last_frame_id = 0;
207180
constexpr int max_ticks = 400;
208181
for (int i = 0; i < max_ticks && produced < 3; ++i)
209182
{
@@ -218,14 +191,20 @@ TEST(ProducerPipeline, PreflightThenProducesFrames)
218191
else
219192
{
220193
EXPECT_EQ(telemetry.producer_samples.size(), emitted_before + 1);
194+
ASSERT_LT(emitted_before, telemetry.producer_samples.size());
195+
EXPECT_EQ(telemetry.producer_samples[emitted_before].producer_status,
196+
static_cast<uint8_t>(tick.status));
221197
}
222198

223199
if (tick.status == omniseer::vision::ProducerTickStatus::Produced)
224200
{
225201
++produced;
226-
EXPECT_TRUE(tick.preprocess.ok());
227-
EXPECT_EQ(tick.capture.status, omniseer::vision::CaptureStatus::Ok);
228202
EXPECT_EQ(tick.stage, omniseer::vision::ProducerStage::Requeue);
203+
EXPECT_EQ(tick.stage_errno, 0);
204+
EXPECT_GT(tick.capture_ts_real_ns, 0u);
205+
EXPECT_GE(tick.pool_index, 0);
206+
EXPECT_GT(tick.frame_id, last_frame_id);
207+
last_frame_id = tick.frame_id;
229208

230209
const uint32_t expected_mask =
231210
stage_mask_bit(omniseer::vision::ProducerStageMask::Dequeue) |
@@ -239,24 +218,21 @@ TEST(ProducerPipeline, PreflightThenProducesFrames)
239218

240219
if (tick.status == omniseer::vision::ProducerTickStatus::NoWritableBuffer)
241220
{
242-
EXPECT_TRUE(tick.stage == omniseer::vision::ProducerStage::AcquireWrite ||
243-
tick.stage == omniseer::vision::ProducerStage::Requeue)
221+
EXPECT_EQ(tick.stage, omniseer::vision::ProducerStage::AcquireWrite)
244222
<< "unexpected stage for NoWritableBuffer: " << stage_name(tick.stage);
245223
}
246224

247225
if (tick.status == omniseer::vision::ProducerTickStatus::CaptureFatalError ||
248226
tick.status == omniseer::vision::ProducerTickStatus::PreprocessError)
249227
{
250228
FAIL() << "unexpected fatal-ish status: " << tick_status_name(tick.status)
251-
<< ", capture_errno=" << tick.capture.sys_errno;
229+
<< ", stage_errno=" << tick.stage_errno;
252230
}
253231

254-
++nonfatal;
255232
std::this_thread::sleep_for(std::chrono::milliseconds(1));
256233
}
257234

258235
EXPECT_GT(produced, 0) << "producer did not produce any frames in " << max_ticks << " ticks";
259-
EXPECT_GE(nonfatal, 0);
260236

261237
ASSERT_NO_THROW(capture.stop());
262238
}

0 commit comments

Comments
 (0)