Skip to content

Keep Jetson video pipelines tensor-native - #2759

Draft
alexnorell wants to merge 255 commits into
mainfrom
agent/mic712-jp62-rtsp-v2
Draft

Keep Jetson video pipelines tensor-native#2759
alexnorell wants to merge 255 commits into
mainfrom
agent/mic712-jp62-rtsp-v2

Conversation

@alexnorell

@alexnorell alexnorell commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Summary

  • route per-source RTSP options, including scoped self-signed TLS handling
  • support UniFi SDES-SRTP in the native Jetson pipeline without exposing key material to Python
  • move NVMM-to-CUDA conversion out of the GStreamer decoder callback
  • add bounded, checksummed TensorRT dependency fetching to the JP62 image build
  • add the GStreamer codec timestamper plugin and repair H.264 RTSP startup with RTCP keyframe requests
  • expose single-source or aligned per-source producer options through every public InferencePipeline factory without changing legacy positional argument order
  • provide a clean-checkout, checksummed JP62 fetch/build entrypoint shared with CI

Root causes

Geary connected to nvv4l2decoder, but RTSP began mid-GOP and supplied no DTS. During the observed startup window every input was a delta frame; the decoder never established a reference picture, and subsequent B-frame timestamps caused GstVideoDecoder to discard output. The H.264 pipeline now requests a keyframe at the depayloader and reconstructs DTS before NVDEC. GStreamer 1.24 does not expose those keyframe properties on rtph265depay, so H.265 receives the timestamper without invalid launch properties.

UniFi also needed source-scoped TLS configuration and SDES-SRTP caps normalization before srtpdec.

The first source-option API was present only on workflow/custom factories. The primary InferencePipeline.init() could not forward it, and its dictionary-only annotation contradicted the already-supported aligned multi-source list. All factories now share VideoSourceOptions and the primary factory propagates it. The new option is trailing on every public factory and source helper so existing positional callers retain their exact pre-change binding.

The JP62 Dockerfile intentionally installs a pinned TensorRT local-repository package, but a clean checkout did not contain or fetch that generated multi-GB artifact. docker/scripts/build_jetson_6_2_image.sh now invokes the shared fetcher first. The fetcher uses pinned URL/SHA defaults, bounded retries/timeouts, verified cache reuse, an atomic temporary file, Linux/macOS checksum tools, and cleanup on failure. CI calls the same fetcher.

Validation

  • 97 focused camera/video-source tests pass
  • aligned source-option broadcast/list tests pass; public-factory signature, propagation, and exact legacy positional-order coverage added
  • ARM64 GStreamer 1.24.12 build succeeds and verifies h264timestamper and h265timestamper
  • exact plugin loads on a JetPack 6.2 MIC712 with all dependencies resolved
  • Geary native bridge: CUDA uint8 (3, 1080, 1920), NVMM frame, one CUDA conversion kernel, zero host-to-device copies, zero device-to-host copies
  • Geary and UniFi both select JetsonVideoFrameProducer and remain running without OpenCV fallback
  • live device rates observed: Geary 27.8 camera FPS; UniFi 29.7 camera FPS
  • TensorRT backend remains active (YOLOv8ForObjectDetectionTRT)
  • fetch-script fixture checks cover initial download, verified-cache reuse, checksum rejection, exact content, and temporary-file cleanup
  • shell syntax, ShellCheck, actionlint, compileall, targeted Ruff, Black, and git diff --check pass
  • exact-head JP62 image: roboflow/roboflow-inference-server-jetson-6.2.0:1.4.0-tensor-exp-33
  • exact head: d9c65cf324278d226ff22fc0487c7c03fb7e2095
  • ARM64 digest: sha256:7a19b84954bdd0a7c146a817e47d3f2d992f4ace35910c935a0de9bfd0534ed9
  • canonical build: GitHub Actions run 31004292564

Dependency

The stacked roboflow-edge PR #605 pins this JP62 image, enables tensor output only for JP62, emits tensors from supported industrial cameras, and defers device-to-host materialization until an explicit CPU consumer.

PawelPeczek-Roboflow and others added 30 commits May 21, 2026 17:16
Add an optional torch.Tensor representation to WorkflowImageData with
lazy BGR<->RGB conversion between the two backings. Layout contract:
numpy is HWC uint8 BGR (cv2 native), tensor is HWC uint8 RGB
(inference-models / torch convention). dtype is preserved; no implicit
device moves.

- __init__ accepts tensor_image; "empty" check covers the new field.
- numpy_image property: if only tensor is set, materialize via
  detach().to("cpu").numpy() with a channel flip and cache the result.
- tensor_image property: mirror fallback from numpy with channel flip.
- copy_and_replace propagates tensor_image.
- create_crop_from_tensor: tensor-native sibling of create_crop with
  identical metadata math.
- _read_shape_without_materialization avoids forcing device->host just
  to fill parent_metadata / workflow_root_ancestor_metadata origin
  coordinates when only the tensor representation is set.

Public surface is unchanged; the field is opt-in. Serialization (via
base64_image -> numpy_image -> JPEG) continues to work transparently.
New common/deserializers_tensor.py and common/serializers_tensor.py.
The numpy files are untouched per the plan's locked [ITERATE 4.A]
decision; the tensor file's deserialize_image_kind handles raw
torch.Tensor input and the dict-shape {"type": "tensor", "value": ...}
input, and delegates all other inputs (np.ndarray, base64, URL, dict
with type=base64/url) to the numpy implementation. The serializer
sibling currently re-exports the numpy functions because the lazy
tensor->numpy fallback in WorkflowImageData makes serialise_image
correct in both modes; the file exists to keep the loader's import
swap symmetric and to give future tensor-aware optimisations a
landing spot without touching the loader contract.
Mirror module per the plan's Step 5b. attach_parents_coordinates_*
helpers go through ImageParentMetadata.origin_coordinates, which is
populated by WorkflowImageData.parent_metadata /
workflow_root_ancestor_metadata via _read_shape_without_materialization,
so the tensor mirrors currently delegate to the numpy implementations.
The module exists so future tensor-specific divergence has a landing
spot the loader can swap to without touching the numpy file.
…path

map_inference_kwargs unconditionally sets input_color_format="bgr",
which is the right default for the cv2-derived numpy paths (preprocess
/ predict / postprocess) but breaks the new run_tensor_native_inference
entry points: workflows tensor blocks pass RGB tensors per the
workflows tensor-data-representation plan and need a way to opt out of
the BGR override.

In each of the four adapter classes that implement
run_tensor_native_inference (object detection, instance segmentation,
classification, semantic segmentation), pop input_color_format from
kwargs before map_inference_kwargs (default None), then restore it
afterwards. map_inference_kwargs itself is untouched, so every old
execution path keeps the BGR default it has today.

Effect: callers of run_tensor_native_inference can pass
input_color_format="rgb" (or "bgr", or leave it None) and the value
travels through to the underlying model unchanged.
New v3_tensor.py next to v3.py. Per the plan's locked decisions:
- Manifest is verbatim (same type literal, name, version, description,
  fields, outputs, ui_manifest). Class name unchanged so the loader's
  if/else swap binds the same identifier in both branches.
- run_locally calls model_manager.run_tensor_native_inference with
  the per-image torch tensors from WorkflowImageData.tensor_image and
  passes input_color_format="rgb" (adapter now respects caller's value).
  Skips convert_inference_detections_batch_to_sv_detections because
  the adapter returns sv.Detections directly.
- attach_parents_coordinates_to_batch_of_sv_detections_tensor used on
  the local path; numpy mirror reused on the remote path per
  [ITERATE 6.A].
- run_remotely materialises base64_image (which lazily goes
  tensor -> numpy -> JPEG) and hits the same HTTP API as v3.py. Remote
  response is dict-shaped so the numpy converter applies.
- inference_id read from sv.Detections.data when present; uuid4 fallback
  per image per [ITERATE 6.B].
Wire the three pieces the plan's Step 3 calls for, gated on
ENABLE_TENSOR_DATA_REPRESENTATION:

- Import env flag.
- Move serialise_image / serialise_sv_detections / serialise_rle_sv_detections
  and deserialize_image_kind / deserialize_detections_kind /
  deserialize_rle_detections_kind into an if/else swap that picks the
  _tensor module when the flag is on. All other (de)serializer
  functions stay imported from the numpy file (the tensor file only
  mirrors the image/detection trio that has tensor-aware behaviour).
- Object-detection V3 block import becomes an if/else that picks
  v3_tensor.py when the flag is on. Class name unchanged, so the
  blocks = [...] list and load_blocks() are untouched.

load_kinds() and KINDS_SERIALIZERS / KINDS_DESERIALIZERS dict
construction are unchanged per the plan ("no new kinds").
PawelPeczek-Roboflow and others added 29 commits August 3, 2026 13:04
…eline) (#2647)

* feat(workflows): GPU-native mask visualization compositor for tensor pipeline

Replaces the sv.MaskAnnotator path in the tensor-native mask visualization
block with a torch CUDA compositor when the mask carrier is a dense CUDA bool
tensor (CLASS/INDEX color lookups), cutting the viz step from ~210 ms to
~23 ms on Jetson Orin Nano at 1080p with 15 detections. Output is validated
100% pixel-identical to sv.MaskAnnotator (paint order, tie-breaking, and
cv2.addWeighted rounding); any ineligible input or GPU-path error falls back
to the unchanged sv path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(workflows): argmin ownership + tensor-only scene in GPU mask compositor

- Replace the per-instance paint loop (argsort + N masked_fill_ kernels)
  with a single argmin over area-weighted masks: smallest-area mask owns
  each pixel, argmin's first-index tie rule matches supervision's stable
  flip-sort ties, so output stays bit-identical to sv.MaskAnnotator.
  Jetson Orin Nano 1080p/15 masks: 19.3 -> 14.9 ms kernel median.
- Drop the numpy-scene branch in run(): the tensor pipeline always
  carries a CHW RGB device tensor (HTTP base64 ingest is being converted
  to tensor at pipeline start as well).
- Remove the inert InstancesRLEMasks crop side-channel (getattr fields
  that never existed); RLE-carrier predictions take the sv fallback.
- Tests: drop _dense_crops_and_offsets cases (helper deleted); 18 pass
  on Jetson GPU, 7 pass / 11 skipped on CPU CI.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Support RLE mask carrier and switch overlaps to blend-all in GPU mask compositor

Addresses the PR review: predictions.mask can be InstancesRLEMasks (the
tensor pipeline's default via SAM3), not only a dense (N, H, W) bool
tensor. The RLE carrier is now decoded natively — vectorised numpy
varint decode of COCO counts, one packed H2D of per-run tables, and a
device-side run→pixel scatter — so the dense stack is never
materialised (O(foreground pixels) instead of O(N·H·W)).

Overlap semantics change intentionally: overlapping masks are blended
(mean of covering masks' premultiplied colors), replacing the
smallest-area-owns-the-pixel parity machinery. This is order-independent,
simpler, and measured ~30% faster with ~3x lower peak CUDA memory than
the argmin ownership path on Orin Nano (30 detections: 19.7 ms vs
28.6 ms @ 1080p, 175 vs 524 MiB). Pixels covered by a single mask still
match sv.MaskAnnotator bit-for-bit.

Also hardened: mask-canvas/scene size mismatch now raises into the sv
fallback instead of painting misaligned, RLE payload count must match
boxes, CLASS lookup requires class_id, and the GPU path is gated on
image.is_tensor_materialised() so numpy-sourced frames never pay a
forced CHW tensor materialisation.

Verified on Jetson Orin Nano (jetson-6.2.0 container): 49/49 tests pass
on CUDA; decoder fuzzed against pycocotools (300 cases).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Make the GPU mask compositor tensor-only

gpu_mask_composite doubled as a numpy drop-in for sv.MaskAnnotator.annotate
(return_tensor / scene_layout flags, HWC-BGR numpy upload/download branches,
internal BGR->RGB LUT flip) — a test-harness API the production block never
used. The compositor now takes exactly the tensor-pipeline contract: CHW RGB
uint8 device tensor in, the same tensor returned mutated in place, colors
passed as RGB (the block resolves the sv palette with .as_rgb()).

Numpy-based parity tests go through a small _composite_bgr adapter in the
test file, where the conversion belongs.

Re-verified on Jetson Orin Nano: 49/49 tests pass on CUDA; dense 1080p
median 19.68 ms / 175 MiB peak — identical to before the refactor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Support TRACK color lookup in the GPU mask compositor

TRACK never needed the sv path: sv's resolve_color_idx is just
palette.by_idx(tracker_id), and the tensor pipeline carries tracker ids
in bboxes_metadata — the same place to_supervision_for_annotation reads
them from. _resolve_color_ids now resolves palette indices for
CLASS/INDEX/TRACK and raises sv's exact ValueErrors when class or
tracker ids are missing, BEFORE any mask work — previously a doomed run
would densify RLE masks on the sv fallback path just to crash on the
same check.

Verified on Jetson Orin Nano: 53/53 tests pass on CUDA, including
bit-exact TRACK color parity vs sv.MaskAnnotator(color_lookup=TRACK)
with non-contiguous tracker ids.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Paweł Pęczek <146137186+PawelPeczek-Roboflow@users.noreply.github.com>
Base automatically changed from mvp/new-inference-pipeline to main August 12, 2026 08:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants