DSP 硬件加速 API
DSP offload client (SDK-2).
Thin wrapper over the camera-daemon DSP service (platform PLAT-1..6):
buffers are allocated on the FD-publisher UDS (/run/aipc/camera.sock,
FD_PUB_MSG_DSP_ALLOC/RESP/BUF_RELEASE, dma-buf fds via SCM_RIGHTS) and
jobs are submitted through the SubmitDspJob gRPC on camera-control.
Hardware-first with a numpy CPU fallback: on a daemon without the DSP
RPC (grpc UNIMPLEMENTED) or with the service not running (error -5) the
*_hw helpers compute the result on CPU instead of raising, and
client.last_used_hw records which path served the last call.
Caveat (P0 platform contract): a job source must be a daemon-registered
dma-buf, so a plain numpy array is copied into one. Zero-copy input IS
available for camera frames: pass a Frame received with
keep_fd=True` (or its ``.handle) and the dma-buf fds are imported
straight into the DSP service (DSP_IMPORT) — no pixel copy, ~15x faster
than the copy-in path on 4K frames.
Rate limiting: the daemon enforces a per-client MPix/s budget (a new
client gets a 1-second burst; it then replenishes continuously). Each
job is charged src + sum(dst) megapixels, so hot-looping 4K sources
(≈8.3 MPix/frame) exhausts the budget within a few jobs and further
submissions raise DspError (code == -3, message like “quota:
MPix/s budget exhausted”). That error is deliberately NOT silently
fallen back to CPU — a switch to CPU is a large latency cliff the app
should see. Pace submissions, or crop to a smaller source first.
Usage:
client = DspClient()
small = client.resize_hw(frame.image, 640, 640, fmt="nv12")
tiles = client.multi_crop_hw(frame.image, rects, fmt="nv12")
nv12 = client.convert_hw(frame.image, "nv12", fmt="rgb24")
jpeg = client.encode_jpeg_hw(frame.image, quality=85, fmt="rgb24")
annotated = client.blend_hw(nv12, [(overlay_rgba, 64, 48)])
# zero-copy: keep the frame's dma-bufs and hand them over directly
frame = media.get_frame("main", keep_fd=True)
small = client.resize_hw(frame, 640, 640)
# device-resident chain: results stay on the daemon between calls,
# no read-back until the tail (P2-2)
ref = client.resize_hw(frame, 640, 640, out="ref")
jpeg = client.encode_jpeg_hw(ref, quality=85)
ref.release()
- class neoruntime_ipc_sdk.dsp.DspBufferPool(client, width, height, fmt, ids, fds, strides, sizes)[source]
Bases:
objectDaemon-allocated dma-buf buffers sharing one geometry.
One wire allocation returns
countbuffers; every buffer exposes_plane_count(fmt)dma-buf fds (NV12: Y + interleaved-UV). Plane rows may be padded (strides> row bytes); write/read copy row-by-row so padding is preserved.release()returns the buffers to the daemon and closes every fd; closing the client’s UDS releases them too (daemon-side cleanup on disconnect).- __init__(client, width, height, fmt, ids, fds, strides, sizes)[source]
- property count: int
- buffer_id(index)[source]
- write(index, arr)[source]
Copy a numpy array into buffer
index(uint8, SDK layout).nv12:
(h*3//2, w)(Y then interleaved UV); rgb24:(h, w, 3); argb:(h, w, 4)(wire byte order [A, R, G, B]); gray8:(h, w).
- read(index)[source]
Read buffer
indexback as a numpy array (SDK layout).
- release()[source]
Return all buffers to the daemon (idempotent).
- class neoruntime_ipc_sdk.dsp.SharedImportPool(client, width, height, ids, maps, fds)[source]
Bases:
objectA round-robin ring of shared ARGB32 imports the daemon can bake.
The deployed HAL refuses ARGB32 dma-buf pool allocation (wire OOM), but its bake path blends ARGB imports fine: the daemon maps the memfd we pass over SCM_RIGHTS as USERPTR planes, so both processes share the same pages.
write()therefore lands directly in the memory the daemon’s blend reads — no dma-buf, no copy through the wire.Lease rules mirror
DspBufferPool: slots recycle round-robin, the caller paces writes, and a slot must not be rewritten while its frame may still sit in a daemon-side queue (queue cap 3 ⇒ ring depth >= 4 for faster-than-bake publishing).Not per-publish import/release: client-streaming has no per-frame ack, so a release could race the daemon’s pin. Not hold-until-RPC-end either: the daemon caps imports at 64 per client. A small persistent ring sidesteps both.
- __init__(client, width, height, ids, maps, fds)[source]
- property count: int
Number of ring slots.
- buffer_id(index)[source]
The daemon registry id of ring slot
index.
- write(index, arr)[source]
Copy one ARGB32 frame into ring slot
index’s shared pages.
- release()[source]
Return every import and close the shared pages (idempotent).
- class neoruntime_ipc_sdk.dsp.PendingDspJob(client, reads, job_id, owns, timeout_s, multi=False)[source]
Bases:
objectThe
wait=Falsereturn of the*_hwjob methods (P2 async).Wraps one submitted job and owns every buffer it needs — destination pool, imported sources — until the job is consumed:
wait()— block, read the destination array(s), release everything. The sync-call equivalent, just later.wait_result()— block but keep the result device-side and the buffers owned: chainbuffer_idintoDspClient.encode_jpeg_hw()(src_buffer_id=) for a zero read-back encode, thenrelease().done()— poll;timeout_s=0is a pure non-blocking check.release()— drop the result and free the buffers (idempotent).
A job the daemon refused or a daemon without the async rpcs never produces one of these — refused jobs fall back to CPU and return pixels (the
wait=Falsecontract only covers accepted jobs), and the sync fallback inside_submit_jobyieldsjob_id=None, meaning the job already ran by construction.- __init__(client, reads, job_id, owns, timeout_s, multi=False)[source]
- property buffer_id: int
Daemon id of the (first) destination buffer.
- done(timeout_s=0.0)[source]
Poll for completion without consuming the result.
timeout_s=0maps to the daemon’s non-blocking wait. A job that failed still counts as done — the error surfaces fromwait()/wait_result().
- wait_result(timeout_s=None)[source]
Block until the job completes, the result staying device-side.
A timed-out job raises but stays pending in the daemon — re-wait with a longer timeout. A failed job raises its error; release the buffers afterwards either way.
- wait(timeout_s=None)[source]
Block, read the destination, release the buffers.
- release()[source]
Drop the result and release the owned buffers (idempotent).
The daemon-side job is not cancelled: a still-pending job is first reaped with one bounded wait (it executes regardless — the daemon’s single worker runs it either way); a job that outlives that wait lingers in the daemon registry until client disconnect.
- class neoruntime_ipc_sdk.dsp.DspBufferRef(client, pool, index, owns)[source]
Bases:
objectThe
out="ref"return of the sync*_hwjob methods (P2-2).Wraps one completed job’s destination buffer — device-side, no read-back — plus everything the call owned to produce it (imported sources, temp pools):
buffer_id/width/height/fmt— the daemon buffer id and its geometry; chain the id into another*_hwcall orencode_jpeg_hwwithout touching the pixels.read()— read the array back on demand (exactly what theout=Nonedefault returns inline).release()— return the owned buffers to the daemon (idempotent). When the destination pool was caller-supplied (dst_pool=) the pool is not owned and stays valid — but the ref is dead either way; re-read through the pool if you need it.
A ref is also a valid job source: passing it as
srcuses its daemon id directly (no re-import, no copy-in). That borrows it — the source call neither consumes nor keeps the ref alive; keep it alive until the consuming job completes, thenrelease()it. There is no GC safety net (same contract asPendingDspJob): rebinding a ref away withoutrelease()leaks its daemon-side pixel budget until the client disconnects — chain users keep every intermediate ref and release it, or use one ref per statement.Context manager support releases on exit. Device-side lifetime: like every client-owned buffer, the daemon reclaims it when the client’s UDS closes — a leaked ref costs its slot until then.
DspClient.close()(explicit, or the accel router dropping the process-resident client after a failed call) also poisons the pool, so a ref outliving its client raises on next use instead of submitting the reclaimed id.- __init__(client, pool, index, owns)[source]
- property buffer_id: int
Daemon id of the wrapped buffer.
- property width: int
- property height: int
- property fmt: str
- property released: bool
- read()[source]
Read the buffer back as a numpy array (SDK layout).
- release()[source]
Release the owned buffers (idempotent).
Frees what the producing call allocated — temp pools and imports; a caller-supplied
dst_poolis left alone. The job itself already completed (out="ref"is the sync path), so there is nothing to reap, unlikePendingDspJob.
- class neoruntime_ipc_sdk.dsp.DspClient(sock_path=None, endpoint=None)[source]
Bases:
GrpcClientHardware resize/crop on the camera-daemon DSP service.
Usage:
dsp = DspClient() out = dsp.resize_hw(frame.image, 640, 640, fmt="nv12")
The
*_hwmethods allocate a source and destination buffer, run one job and return the decoded result. For hot loops, pre-allocate pools withalloc_buffers()and passsrc_pool/dst_pool(dst_poolsfor multi-crop) so each call only writes, submits and reads.Every
*_hwmethod also takescpu_fallback(defaultTrue): when the daemon lacks the DSP surface, array-source calls warn and compute on CPU. Passcpu_fallback=Falseto make unavailability raise instead —neoruntime_ipc_sdk.acceldoes this so its degradation accounting sees the real backend rather than CPU work labeled as hardware.convert_hw()additionally falls back on a job the firmware refused: the pair matrix is device-dependent (hailo15dsp_convert_formattakes RGB<->NV12 and rejects every gray8 pair withHAL_ERR_RESULT), so “the hardware doesn’t do this conversion” is a runtime outcome, not a caller bug.- __init__(sock_path=None, endpoint=None)[source]
- has_live_pools()[source]
True while a pool this client allocated is still usable.
The accel router’s retirement drain polls this before closing a failed resident: pools die with their refs (the WeakSet) or turn stale on
release(), so flipping to False means no ref can still be reading through this client.
- close()[source]
Close both transports. The daemon releases our DSP buffers.
Still-live pools this client allocated are poisoned first: the daemon reclaims their buffers when the UDS drops, so a held
DspBufferRefwould dangle — after close, its (and the pool’s) next use raises instead of submitting reclaimed ids.
- alloc_buffers(width, height, fmt='nv12', count=1)[source]
Allocate
countdaemon-side DSP buffers of one geometry.
- import_shared_buffers(width, height, fmt='argb', count=2, timeout_s=5.0)[source]
Import
countshared ARGB32 buffers as a writable ring.The deployed HAL refuses ARGB32 dma-buf pool allocation, so alpha-blend overlays ride memfd imports instead: each slot is a memfd the daemon maps (SCM_RIGHTS dup, MAP_SHARED both sides) and bakes straight from. Writing the returned pool puts pixels in the exact pages the daemon’s blend reads. See
SharedImportPoolfor the lease contract and why the ring is persistent.
- import_frame(handle)[source]
Import a frame’s dma-bufs into the daemon buffer registry (DSP_IMPORT).
The daemon dups the fds, so the returned id outlives the FrameHandle; it lives in the same registry namespace as pool buffer ids and stays valid until
release_buffer()(or the daemon’s lease watchdog). This is the zero-copy entry point forInferenceClient.infer(frame, ...).
- release_buffer(buffer_id)[source]
Free a registry id (import id or pool buffer) with DSP_BUF_RELEASE.
- resize_hw(src, width, height, fmt=None, interpolation='bilinear', scaling='stretch', priority='normal', timeout_s=5.0, src_pool=None, dst_pool=None, cpu_fallback=True, wait=True, out=None)[source]
Scale
srcto(width, height)on the DSP.srcis a numpy array (copied in), a keep-fd Frame/FrameHandle (imported zero-copy — see the module docstring), or aDspBufferRef(its daemon buffer used directly — no re-import).wait=Falsereturns aPendingDspJobinstead of the array: the job is enqueued without blocking and the buffers stay owned until the pending job consumes them.out="ref"(sync path only) skips the read-back and returns aDspBufferRef— the resized pixels stay device-side for the next hw call;ref.read()brings them back on demand.
- crop_hw(src, x, y, width, height, dst_width=None, dst_height=None, fmt=None, interpolation='bilinear', scaling='stretch', priority='normal', timeout_s=5.0, src_pool=None, dst_pool=None, cpu_fallback=True, wait=True, out=None)[source]
Crop
(x, y, w, h)and scale to the destination size.wait=Falsereturns aPendingDspJob(async submit).out="ref"(sync path only) returns aDspBufferRefinstead of reading the pixels back — seeresize_hw().
- multi_crop_hw(src, rects, fmt=None, interpolation='bilinear', scaling='stretch', priority='normal', timeout_s=5.0, src_pool=None, dst_pools=None, cpu_fallback=True, wait=True)[source]
Crop/resize many windows in one job.
rectsare(x, y, w, h, dst_width, dst_height). Destination buffers are grouped by geometry (one pool per distinct output size); results come back in rect order.wait=Falsereturns aPendingDspJobwhosewait()then yields the list.
- convert_hw(src, dst_fmt, fmt=None, priority='normal', timeout_s=5.0, src_pool=None, dst_pool=None, cpu_fallback=True, wait=True, out=None)[source]
Convert
srctodst_fmt(nv12/rgb24/gray8) on the DSP, keeping the dimensions.The daemon’s CONVERT contract (P0): source and destination share geometry and differ in format — no rects, exactly one destination buffer. Compose with
resize_hw()when you also need scaling, and convert first: NV12 is half the rgb24 bytes, soCONVERT → RESIZEmoves less data than the reverse.Byte order:
rgb24means RGB order on the wire — BGR pixels must be swapped beforehand (or kept on the CPU path viacolor.bgr_to_nv12); the DSP wire has no BGR variant, so unswapped BGR comes back with R/B-swapped chroma.Supported pairs are firmware-dependent: on hailo15 only
rgb24 <-> nv12run on the DSP. Every gray8 pair is refused (HAL_ERR_RESULT) — the first refusal warns and falls back to CPU, and this client remembers: later gray8 pairs go straight to the CPU leg, quietly (no repeat warning,last_used_hw=False).wait=Falsereturns aPendingDspJob(async submit) — only for accepted jobs; a refused pair still returns CPU pixels.out="ref"(sync path only) returns aDspBufferRefinstead of reading the pixels back — seeresize_hw().
- blend_hw(base, overlays, fmt=None, priority='normal', timeout_s=5.0, cpu_fallback=True, wait=True, zero_copy=False, out=None, dst_slot=0)[source]
Composite ARGB32
overlaysonto an NV12baseon the DSP (P1).overlaysis a sequence of(rgba, x, y)— an(h, w, 4)uint8 array plus its position on the base; overlays paste 1:1 in order (no scaling, later overlays draw over earlier ones). The blend runs IN PLACE on a daemon pool copy and the annotated NV12 array is returned — the input is never modified.The base must be NV12 (the vendor op writes NV12 only). Arrays are copied in. Device-side bases — keep-fd Frame/FrameHandle (an import) or a
DspBufferRef(its daemon id, borrowed: keep the ref alive until the call returns, like any ref source) — are refused by default (zero_copy=False): the import->1:1 RESIZE->BLEND chain has wedged the DSP device-wide until a reboot in the field — twice, under media-heap pressure; a controlled re-test on a healthy heap passed 11/11 and a 4000-run soak saw no wedge, so the risk is state-dependent with the root cause still open (see docs/proposals/dsp-offload.md P2 record). Passframe.to_array()/ref.read()— the array path is the proven one.zero_copy=Trueforces the device-side chain (the ref leg skips the import entirely: one in-daemon RESIZE copy, then the blend); nothing about it is guaranteed today. Usedraw.render_overlay_rgba()to turn detection boxes into a minimal overlay canvas, then blend it here; that keeps the overlay small and the DSP footprint (quota chargesbase + overlaysmegapixels) tight.Overlays smaller than 16x16 (the daemon floor) are padded with fully transparent pixels to 16, and odd overlay heights gain one transparent bottom row (the DSP rejects odd overlay heights outright, -2801 — device-ruled: odd width/x/y pass, only height does not). Both pads are semantic no-ops, but they grow the rect: bounds are enforced on the padded geometry. Wire byte order is ARGB32 ([A, R, G, B] per pixel); the RGBA->ARGB pack is internal.
wait=Falsesubmits the blend (and the keep-fd copy leg) without blocking and returns aPendingDspJob.out=picks the destination (sync path only):None(default) — read the annotated pixels back as an ndarray."ref"— skip the read-back; return aDspBufferRefwrapping the annotated buffer (chain itsbuffer_idintoencode_jpeg_hwor another*_hwcall,read()on demand).a
DspBufferPool— blend straight into the caller’s pool atdst_slotand returnNone: no allocation, no read-back, nothing released (the pool stays the caller’s). The pool must match the base geometry and be NV12 (blend keeps dims). This is the zero-copy publish tail — aFramePublisherpool slot filled here is pushed withpush_slot— and with an array base the CPU fallback (if taken) writes the slot too, so the destination contract holds on every path.
- encode_jpeg_hw(src, quality=85, fmt=None, timeout_s=5.0, src_pool=None, cpu_fallback=True, src_buffer_id=None)[source]
Encode
srcas one JPEG frame on the camera-daemon (S-3(a)).Unlike the
*_hwjob methods this is the daemon’s one-shotEncodeImageRPC: the source is pinned in the DSP registry (imported zero-copy for keep-fd frames, copied into a pool buffer for arrays) and the complete JPEG bytes come back in the response — no destination buffer, no read-back. The daemon owns one standalone encoder keyed by(width, height, format, quality)and recreates it when that key changes, so alternating qualities or geometries re-spins the encoder (first frame after a change pays the pipeline start-up).Despite the name, the encoder is N-threaded libjpeg on the DSP core behind a GStreamer dispatch — hailo15 has no dedicated JPEG block. The win is central encode + zero-copy input, not raw speed; keep it out of tight per-frame loops that a CPU encode already serves (see docs/proposals/sdk-hardware-routing.md S-3).
qualityis 1..100. Inputs arergb24/nv12arrays and keep-fd frames (the daemon normalizes RGB through its DSP convert — the encoder pipeline negotiates NV12 only). gray8 arrays up-convert to rgb24 client-side (R=G=B=gray) and ride the same hardware leg; gray8 keep-fd frames raise instead of silently copying — accept the copy yourself withframe.to_array(). ADspBufferRefencodes straight from its daemon buffer (the same zero-copy leg assrc_buffer_id).src_buffer_id(withsrc=None) encodes straight from a daemon-side buffer — the zero-copy chain tail:blend_hw(..., wait=False).wait_result().buffer_idlands here and the annotated frame becomes JPEG without a single read-back. There is no CPU fallback on that leg (the client holds no pixels); unavailability raises.
DspClient
- class neoruntime_ipc_sdk.DspClient(sock_path=None, endpoint=None)[source]
Bases:
GrpcClientHardware resize/crop on the camera-daemon DSP service.
Usage:
dsp = DspClient() out = dsp.resize_hw(frame.image, 640, 640, fmt="nv12")
The
*_hwmethods allocate a source and destination buffer, run one job and return the decoded result. For hot loops, pre-allocate pools withalloc_buffers()and passsrc_pool/dst_pool(dst_poolsfor multi-crop) so each call only writes, submits and reads.Every
*_hwmethod also takescpu_fallback(defaultTrue): when the daemon lacks the DSP surface, array-source calls warn and compute on CPU. Passcpu_fallback=Falseto make unavailability raise instead —neoruntime_ipc_sdk.acceldoes this so its degradation accounting sees the real backend rather than CPU work labeled as hardware.convert_hw()additionally falls back on a job the firmware refused: the pair matrix is device-dependent (hailo15dsp_convert_formattakes RGB<->NV12 and rejects every gray8 pair withHAL_ERR_RESULT), so “the hardware doesn’t do this conversion” is a runtime outcome, not a caller bug.- __init__(sock_path=None, endpoint=None)[source]
- has_live_pools()[source]
True while a pool this client allocated is still usable.
The accel router’s retirement drain polls this before closing a failed resident: pools die with their refs (the WeakSet) or turn stale on
release(), so flipping to False means no ref can still be reading through this client.
- close()[source]
Close both transports. The daemon releases our DSP buffers.
Still-live pools this client allocated are poisoned first: the daemon reclaims their buffers when the UDS drops, so a held
DspBufferRefwould dangle — after close, its (and the pool’s) next use raises instead of submitting reclaimed ids.
- alloc_buffers(width, height, fmt='nv12', count=1)[source]
Allocate
countdaemon-side DSP buffers of one geometry.
- import_shared_buffers(width, height, fmt='argb', count=2, timeout_s=5.0)[source]
Import
countshared ARGB32 buffers as a writable ring.The deployed HAL refuses ARGB32 dma-buf pool allocation, so alpha-blend overlays ride memfd imports instead: each slot is a memfd the daemon maps (SCM_RIGHTS dup, MAP_SHARED both sides) and bakes straight from. Writing the returned pool puts pixels in the exact pages the daemon’s blend reads. See
SharedImportPoolfor the lease contract and why the ring is persistent.
- import_frame(handle)[source]
Import a frame’s dma-bufs into the daemon buffer registry (DSP_IMPORT).
The daemon dups the fds, so the returned id outlives the FrameHandle; it lives in the same registry namespace as pool buffer ids and stays valid until
release_buffer()(or the daemon’s lease watchdog). This is the zero-copy entry point forInferenceClient.infer(frame, ...).
- release_buffer(buffer_id)[source]
Free a registry id (import id or pool buffer) with DSP_BUF_RELEASE.
- resize_hw(src, width, height, fmt=None, interpolation='bilinear', scaling='stretch', priority='normal', timeout_s=5.0, src_pool=None, dst_pool=None, cpu_fallback=True, wait=True, out=None)[source]
Scale
srcto(width, height)on the DSP.srcis a numpy array (copied in), a keep-fd Frame/FrameHandle (imported zero-copy — see the module docstring), or aDspBufferRef(its daemon buffer used directly — no re-import).wait=Falsereturns aPendingDspJobinstead of the array: the job is enqueued without blocking and the buffers stay owned until the pending job consumes them.out="ref"(sync path only) skips the read-back and returns aDspBufferRef— the resized pixels stay device-side for the next hw call;ref.read()brings them back on demand.
- crop_hw(src, x, y, width, height, dst_width=None, dst_height=None, fmt=None, interpolation='bilinear', scaling='stretch', priority='normal', timeout_s=5.0, src_pool=None, dst_pool=None, cpu_fallback=True, wait=True, out=None)[source]
Crop
(x, y, w, h)and scale to the destination size.wait=Falsereturns aPendingDspJob(async submit).out="ref"(sync path only) returns aDspBufferRefinstead of reading the pixels back — seeresize_hw().
- multi_crop_hw(src, rects, fmt=None, interpolation='bilinear', scaling='stretch', priority='normal', timeout_s=5.0, src_pool=None, dst_pools=None, cpu_fallback=True, wait=True)[source]
Crop/resize many windows in one job.
rectsare(x, y, w, h, dst_width, dst_height). Destination buffers are grouped by geometry (one pool per distinct output size); results come back in rect order.wait=Falsereturns aPendingDspJobwhosewait()then yields the list.
- convert_hw(src, dst_fmt, fmt=None, priority='normal', timeout_s=5.0, src_pool=None, dst_pool=None, cpu_fallback=True, wait=True, out=None)[source]
Convert
srctodst_fmt(nv12/rgb24/gray8) on the DSP, keeping the dimensions.The daemon’s CONVERT contract (P0): source and destination share geometry and differ in format — no rects, exactly one destination buffer. Compose with
resize_hw()when you also need scaling, and convert first: NV12 is half the rgb24 bytes, soCONVERT → RESIZEmoves less data than the reverse.Byte order:
rgb24means RGB order on the wire — BGR pixels must be swapped beforehand (or kept on the CPU path viacolor.bgr_to_nv12); the DSP wire has no BGR variant, so unswapped BGR comes back with R/B-swapped chroma.Supported pairs are firmware-dependent: on hailo15 only
rgb24 <-> nv12run on the DSP. Every gray8 pair is refused (HAL_ERR_RESULT) — the first refusal warns and falls back to CPU, and this client remembers: later gray8 pairs go straight to the CPU leg, quietly (no repeat warning,last_used_hw=False).wait=Falsereturns aPendingDspJob(async submit) — only for accepted jobs; a refused pair still returns CPU pixels.out="ref"(sync path only) returns aDspBufferRefinstead of reading the pixels back — seeresize_hw().
- blend_hw(base, overlays, fmt=None, priority='normal', timeout_s=5.0, cpu_fallback=True, wait=True, zero_copy=False, out=None, dst_slot=0)[source]
Composite ARGB32
overlaysonto an NV12baseon the DSP (P1).overlaysis a sequence of(rgba, x, y)— an(h, w, 4)uint8 array plus its position on the base; overlays paste 1:1 in order (no scaling, later overlays draw over earlier ones). The blend runs IN PLACE on a daemon pool copy and the annotated NV12 array is returned — the input is never modified.The base must be NV12 (the vendor op writes NV12 only). Arrays are copied in. Device-side bases — keep-fd Frame/FrameHandle (an import) or a
DspBufferRef(its daemon id, borrowed: keep the ref alive until the call returns, like any ref source) — are refused by default (zero_copy=False): the import->1:1 RESIZE->BLEND chain has wedged the DSP device-wide until a reboot in the field — twice, under media-heap pressure; a controlled re-test on a healthy heap passed 11/11 and a 4000-run soak saw no wedge, so the risk is state-dependent with the root cause still open (see docs/proposals/dsp-offload.md P2 record). Passframe.to_array()/ref.read()— the array path is the proven one.zero_copy=Trueforces the device-side chain (the ref leg skips the import entirely: one in-daemon RESIZE copy, then the blend); nothing about it is guaranteed today. Usedraw.render_overlay_rgba()to turn detection boxes into a minimal overlay canvas, then blend it here; that keeps the overlay small and the DSP footprint (quota chargesbase + overlaysmegapixels) tight.Overlays smaller than 16x16 (the daemon floor) are padded with fully transparent pixels to 16, and odd overlay heights gain one transparent bottom row (the DSP rejects odd overlay heights outright, -2801 — device-ruled: odd width/x/y pass, only height does not). Both pads are semantic no-ops, but they grow the rect: bounds are enforced on the padded geometry. Wire byte order is ARGB32 ([A, R, G, B] per pixel); the RGBA->ARGB pack is internal.
wait=Falsesubmits the blend (and the keep-fd copy leg) without blocking and returns aPendingDspJob.out=picks the destination (sync path only):None(default) — read the annotated pixels back as an ndarray."ref"— skip the read-back; return aDspBufferRefwrapping the annotated buffer (chain itsbuffer_idintoencode_jpeg_hwor another*_hwcall,read()on demand).a
DspBufferPool— blend straight into the caller’s pool atdst_slotand returnNone: no allocation, no read-back, nothing released (the pool stays the caller’s). The pool must match the base geometry and be NV12 (blend keeps dims). This is the zero-copy publish tail — aFramePublisherpool slot filled here is pushed withpush_slot— and with an array base the CPU fallback (if taken) writes the slot too, so the destination contract holds on every path.
- encode_jpeg_hw(src, quality=85, fmt=None, timeout_s=5.0, src_pool=None, cpu_fallback=True, src_buffer_id=None)[source]
Encode
srcas one JPEG frame on the camera-daemon (S-3(a)).Unlike the
*_hwjob methods this is the daemon’s one-shotEncodeImageRPC: the source is pinned in the DSP registry (imported zero-copy for keep-fd frames, copied into a pool buffer for arrays) and the complete JPEG bytes come back in the response — no destination buffer, no read-back. The daemon owns one standalone encoder keyed by(width, height, format, quality)and recreates it when that key changes, so alternating qualities or geometries re-spins the encoder (first frame after a change pays the pipeline start-up).Despite the name, the encoder is N-threaded libjpeg on the DSP core behind a GStreamer dispatch — hailo15 has no dedicated JPEG block. The win is central encode + zero-copy input, not raw speed; keep it out of tight per-frame loops that a CPU encode already serves (see docs/proposals/sdk-hardware-routing.md S-3).
qualityis 1..100. Inputs arergb24/nv12arrays and keep-fd frames (the daemon normalizes RGB through its DSP convert — the encoder pipeline negotiates NV12 only). gray8 arrays up-convert to rgb24 client-side (R=G=B=gray) and ride the same hardware leg; gray8 keep-fd frames raise instead of silently copying — accept the copy yourself withframe.to_array(). ADspBufferRefencodes straight from its daemon buffer (the same zero-copy leg assrc_buffer_id).src_buffer_id(withsrc=None) encodes straight from a daemon-side buffer — the zero-copy chain tail:blend_hw(..., wait=False).wait_result().buffer_idlands here and the annotated frame becomes JPEG without a single read-back. There is no CPU fallback on that leg (the client holds no pixels); unavailability raises.
DspBufferPool
- class neoruntime_ipc_sdk.DspBufferPool(client, width, height, fmt, ids, fds, strides, sizes)[source]
Daemon-allocated dma-buf buffers sharing one geometry.
One wire allocation returns
countbuffers; every buffer exposes_plane_count(fmt)dma-buf fds (NV12: Y + interleaved-UV). Plane rows may be padded (strides> row bytes); write/read copy row-by-row so padding is preserved.release()returns the buffers to the daemon and closes every fd; closing the client’s UDS releases them too (daemon-side cleanup on disconnect).
DspError
PendingDspJob
- class neoruntime_ipc_sdk.PendingDspJob(client, reads, job_id, owns, timeout_s, multi=False)[source]
The
wait=Falsereturn of the*_hwjob methods (P2 async).Wraps one submitted job and owns every buffer it needs — destination pool, imported sources — until the job is consumed:
wait()— block, read the destination array(s), release everything. The sync-call equivalent, just later.wait_result()— block but keep the result device-side and the buffers owned: chainbuffer_idintoDspClient.encode_jpeg_hw()(src_buffer_id=) for a zero read-back encode, thenrelease().done()— poll;timeout_s=0is a pure non-blocking check.release()— drop the result and free the buffers (idempotent).
A job the daemon refused or a daemon without the async rpcs never produces one of these — refused jobs fall back to CPU and return pixels (the
wait=Falsecontract only covers accepted jobs), and the sync fallback inside_submit_jobyieldsjob_id=None, meaning the job already ran by construction.- done(timeout_s=0.0)[source]
Poll for completion without consuming the result.
timeout_s=0maps to the daemon’s non-blocking wait. A job that failed still counts as done — the error surfaces fromwait()/wait_result().
- wait_result(timeout_s=None)[source]
Block until the job completes, the result staying device-side.
A timed-out job raises but stays pending in the daemon — re-wait with a longer timeout. A failed job raises its error; release the buffers afterwards either way.
- release()[source]
Drop the result and release the owned buffers (idempotent).
The daemon-side job is not cancelled: a still-pending job is first reaped with one bounded wait (it executes regardless — the daemon’s single worker runs it either way); a job that outlives that wait lingers in the daemon registry until client disconnect.
使用示例
整帧缩放(模型输入预处理)
from neoruntime_ipc_sdk import FdMediaClient, DspClient, DspError
media = FdMediaClient()
dsp = DspClient()
# keep_fd=True 保留 dma-buf fd,DspClient 零拷贝引用同一缓冲区
frame = media.get_frame("main", timeout_ms=3000, keep_fd=True)
try:
# NV12 in -> NV12 out(h + h/2 行);scaling="stretch" 等价 cv2.resize
small = dsp.resize_hw(frame, 640, 384)
except DspError:
# DSP 不可用且源是 keep-fd 帧时 SDK 会抛错(拒绝静默 CPU 回退),
# 业务侧在此自行走 CPU 路径
small = frame.to_array()
frame.release() # 尽早归还 dma-buf(幂等,可省略)
批量裁剪(letterbox 缩放)
# rects: (x, y, w, h, dst_w, dst_h),源坐标为像素值且需偶数对齐,
# 结果顺序与 rects 一致。适合"一帧多目标"(如车牌 tile)场景,
# 多个裁剪合并为一次硬件任务。
rects = [
(320, 500, 160, 48, 320, 48),
(900, 520, 150, 44, 320, 48),
]
tiles = dsp.multi_crop_hw(frame, rects, scaling="letterbox")
单区域裁剪
# crop_hw 支持裁剪后同步缩放到目标尺寸
tile = dsp.crop_hw(frame, 320, 500, 160, 48, dst_width=320, dst_height=48)
格式转换(RGB ↔ NV12 / 灰度)
# convert_hw 同尺寸换格式(daemon CONVERT 的 P0 契约):无 rect、
# 单目的缓冲;dst_fmt 为 "nv12"/"rgb24"/"gray8"。
# 注意字节序:wire 上的 rgb24 是 RGB 序——BGR 像素需先交换 R/B
#(或 CPU 路径直接用 color.bgr_to_nv12)。
nv12 = dsp.convert_hw(rgb, "nv12", fmt="rgb24")
gray = dsp.convert_hw(rgb, "gray8", fmt="rgb24")
# 需要缩放时先 CONVERT 再 RESIZE:NV12 字节数约为 rgb24 的一半,
# 先转再缩搬运的数据量减半。
small = dsp.resize_hw(nv12, 640, 384)
# DSP 不可用时默认回落 CPU(UserWarning 提示);传 cpu_fallback=False
# 则直接抛 DspError——路由器用它在降级计数里如实记账。
# 固件支持矩阵与设备相关:hailo15 实测仅 rgb24 <-> nv12
# 走 DSP,gray8 各组合被固件拒绝(HAL rc=-2801)。默认
# cpu_fallback=True 下这类"作业被拒"同样回落 CPU 并告警,
# last_used_hw=False 如实记录实际后端。首次被拒后 SDK 会记住该
# 固件缺口:后续 gray8 数组对直接走 CPU 腿——不再告警、不再提交
# 必败作业(keep-fd 源无法走 CPU 腿,仍会如实抛错)。
单帧 JPEG 编码(快照/缩略图)
# encode_jpeg_hw 走 daemon 的 EncodeImage 一发 RPC:源缓冲零拷贝
# 注册进 DSP registry(keep-fd 帧直接导入 dma-buf,numpy 数组拷入
# 池缓冲),完整 JPEG 字节随响应返回——无目的缓冲、无回读。
jpeg = dsp.encode_jpeg_hw(frame, quality=85, fmt="nv12")
# 数组源则 rgb24 默认:jpeg = dsp.encode_jpeg_hw(rgb, quality=85)
# P2 直通腿:src_buffer_id 把编码接到池缓冲/异步作业上——结果
# 留在设备侧,零回读(与 wait=False 的 PendingDspJob.buffer_id
# 组成零拷贝链尾,见下方"异步作业")。
jpeg = dsp.encode_jpeg_hw(None, quality=85, src_buffer_id=job.buffer_id)
# src 与 src_buffer_id 互斥;此腿没有客户端像素可回退,
# DSP 不可用时直接抛 DspError。
# 名字里虽无"硬件块":编码器是 DSP 核上 N 线程 libjpeg(GStreamer
# 分发)——hailo15 没有专用 JPEG 编码块。收益是集中编码 + 零拷贝
# 输入(app 镜像可省掉 cv2/PIL),不是原始速度;逐帧高频编码仍以
# CPU 路径为宜。encoder 按 (宽,高,格式,quality) 复用,参数一变
# 就重建(变更后首帧承担流水线启动开销)。
标注合成(检测框画到 NV12 上,dsp-offload P1)
from neoruntime_ipc_sdk import render_overlay_rgba
# blend_hw 把 ARGB32 overlay 逐个 1:1 粘贴合成到 NV12 base 上
#(不缩放,后贴的盖先贴的)。合成发生在池拷贝上、原地写回——
# 返回已标注的 NV12 数组,输入数组绝不改动。
overlay = np.zeros((64, 96, 4), np.uint8) # (h, w, 4) RGBA
overlay[..., :3] = (255, 0, 0)
overlay[..., 3] = 255 # 直通 alpha
annotated = dsp.blend_hw(nv12, [(overlay, 40, 30)])
# 与 render_overlay_rgba 组合即为"检测画框走硬件":
rgba, x0, y0 = render_overlay_rgba(w, h, boxes, labels, scores, colors)
annotated = dsp.blend_hw(nv12, [(rgba, x0, y0)])
# 更省事的入口是 accel 路由器:router.run("draw_detections", nv12, result)
# SDK 0.7.4 起 draw_detections(nv12, result) 本身就走这条路由
#(RGB 输入仍走软件光栅,keep-fd 帧如实抛错)。
# 契约要点:base 必须是 NV12(vendor op 只写 NV12)。数组走池拷贝
# 原地合成、返回已标注的 NV12 数组;keep-fd 帧默认**直接拒绝**
# (见下方"零拷贝合成链"的固件缺陷记录);
# overlay 小于 16x16(daemon 下限)自动补全透明像素到 16;
# 硬件 ARGB32 内存字节序为 [A, R, G, B],SDK 内部打包;
# quota 按 (base + 各 overlay) 像素量计费——最小画布(上面
# render_overlay_rgba 正是)才省。DSP 不可用/作业被拒时与其它
# *_hw 相同:默认告警回落 CPU(_cpu_blend 直通 alpha 数学一致),
# cpu_fallback=False 直接抛错(keep-fd base 无客户端像素可回退,
# 不可用时必抛)。
异步作业(submit 后择机 wait,dsp-offload P2)
# 五个作业方法(resize/crop/multi_crop/convert/blend)都接受
# wait=False:SubmitDspJobAsync 立即返回 job_id,返回
# PendingDspJob 句柄。老 daemon(无该 rpc)自动回落同步提交——
# 得到的句柄"生而完成",wait() 无额外 RPC。
job1 = dsp.resize_hw(frame, 640, 384, wait=False)
job2 = dsp.multi_crop_hw(frame, rects, wait=False)
... # 提交与执行重叠:单个 worker 线程按提交顺序执行
small = job1.wait() # WaitDspJob + 池回读 + 释放(阻塞)
tiles = job2.wait() # multi_crop 返回列表
if job1.done(): # 非阻塞轮询(timeout 0);完成即缓存
...
job1.wait_result() # 只等完成不回读——结果留在设备侧
bid = job1.buffer_id # 链喂 encode_jpeg_hw(src_buffer_id=)
job1.release() # 丢弃结果、归还缓冲(幂等)
# 语义要点:wait 超时(rc=-4)表项保留可再等;job 失败在 wait
# 时抛 DspError(done() 轮询只报状态);release 后再 wait 抛错;
# wait=False 不改变 CPU 回退语义——回退发生时拿到的就是像素
# 数组(没有可等的硬件作业)。
零拷贝合成链(固件缺陷记录:默认拒绝)
# keep-fd 帧直入 blend_hw 默认抛错——不是能力缺失,而是安全
# 门:这条链在现场**曾两度挂死整个 DSP**(blend 命令提交后
# 固件永不返回,设备级卡死,仅重启可恢复;xrp 驱动自判
# fatal error。状态依赖:media 堆高压时 2/2 复现,堆健康的
# 受控复测 11/11 通过,根因待查)。
# 数组 base(frame.to_array())是已验证的安全路径。
try:
dsp.blend_hw(frame, [(rgba, x0, y0)])
except DspError:
annotated = dsp.blend_hw(frame.to_array(), [(rgba, x0, y0)])
# zero_copy=True 显式强制该链(导入 dma-buf → 1:1 RESIZE 落池 →
# 原地 BLEND,配 wait=False 结果不过 socket,
# encode_jpeg_hw(src_buffer_id=job.buffer_id) 免回读)——供未来
# 固件修复后实验用,今天不保证可用:
job = dsp.blend_hw(frame, [(rgba, x0, y0)],
wait=False, zero_copy=True)
job.wait_result() # 结果留在池里
jpeg = dsp.encode_jpeg_hw(None, src_buffer_id=job.buffer_id)
job.release() # 编码完再归还
# 注意链式顺序:RESIZE 拷贝腿始终同步执行(无人 wait 的异步
# 作业会泄漏 daemon 侧登记项,也占用每连接 32 个未决槽位)——
# 异步的只有 BLEND 合成腿。详情见
# docs/proposals/dsp-offload.md 的 P2 记录。
预分配缓冲池(高帧率重复任务)
# 同几何参数的重复任务可用池化缓冲区,避免每次分配 dma-buf
pool = dsp.alloc_buffers(640, 384, fmt="nv12", count=4)
small = dsp.resize_hw(frame, 640, 384, dst_pool=pool)
# 读取池内缓冲区内容
arr = pool.read(0)
# 不再使用时归还整池
pool.release()
上下文管理器
with DspClient() as dsp:
out = dsp.resize_hw(frame, 416, 416)