SENSING GMSL Camera#
Bring-up for SENSING GMSL2 cameras on a Jetson AGX Orin, where capture goes
through SIPL rather than Argus or V4L2, and camera_plugin_sensing, the
C++ plugin that captures from them — encoding H.264 on the Jetson’s V4L2 engine
or handing frames to another process as CUDA device memory. Source and scripts
live in src/plugins/sensing.
Supported hardware#
This plugin is developed and verified against exactly one configuration:
Host |
NVIDIA Jetson AGX Orin ( |
Software |
JetPack 7.2.1 / L4T R39.2.1 |
Carrier |
SENSING SG8A-AGON-G2Y-A1 (GMSL2) |
Cameras |
Two Astra SHW5G (VB1940), 2560×1984 @ 60 fps, RAW10 → ISP |
Platform config |
|
Device tree |
|
The carrier also takes S56C and SHF3L modules, and the vendor package ships
S56C_1_SHF3L_2 and SHF3L_2_SHW5G_2 configs for them. Those remain
addressable — the config name and JSON path are arguments — but nothing is
built or tested for them. They would also need an ICP passthrough path, since
the SHF3L modules are YUV422 sensors that bypass the ISP entirely.
Drivers come from the vendor SIPL package, which supplies userspace device
drivers (libnvuddf_*.so), a device-tree overlay, NITO ISP tuning files and
the nvsipl_camera reference application. The scripts below wrap it; they do
not replace it.
SENSING publishes it in nvidia-jetson-camera-drivers as a
directory rather than a release, so setup.sh takes a blobless, shallow,
sparse clone of just this board’s tree — 3.6 MB of a ~700 MB repo — into a
gitignored .cache/. Nothing needs sourcing by hand.
What SIPL changes#
If you know the previous Argus-based rig, almost every operational assumption moved:
Argus (SG10A) |
SIPL (SG8A) |
|
|---|---|---|
Sensor drivers |
kernel |
userspace |
Device nodes |
|
none |
Arbitration |
|
in-process, exclusive |
Per-boot step |
|
none |
Frame sync |
PWM trigger + J19 strap |
deserializer |
Geometry |
|
read from the platform config |
Privilege |
container user + |
container user + two groups |
Warning
SIPL takes exclusive ownership of the hardware. nvsipl_camera and
camera_plugin_sensing cannot run at the same time; stop one before
starting the other. There is no daemon to arbitrate between them.
Setup#
Drivers are installed on the host, always — a container cannot do it, and does not need to. Whether you then run the camera on the host or in a container only decides what comes after:
# 1. On the Jetson, once: install the drivers.
src/plugins/sensing/scripts/setup.sh
# 2. Confirm the rig streams. Same script on the host and in a container.
src/plugins/sensing/scripts/validate.sh
# 3. Only if you want a container:
src/plugins/sensing/scripts/run_docker_example.sh # ready-made, drops you at a shell
src/plugins/sensing/docker/setup_container.sh # or adapt your own
scripts/ holds what you run —
on the host, or on either side in the case of validate.sh and
verify.sh. docker/ holds what
runs inside a container: the example image and the two scripts that only
make sense there. verify.sh is a read-only report and is what to
paste into a bug. Every script names the privileged actions it will take before
the first sudo prompt and asks before each optional one; --yes accepts
them all, and a non-interactive stdin declines them all.
Note
First install only: after the drivers land you must select the device-tree
overlay and reboot before anything streams. setup.sh stops and says
so. That is the one step no script can do for you.
Host#
src/plugins/sensing/scripts/setup.sh
It refreshes the vendor package, compares every file install.sh would copy
against what is installed, and offers to run it when anything is missing or
stale. --pkg DIR uses a package you already have, --no-fetch forbids
the download, and --install-drivers reinstalls unconditionally.
Every file is classified by camera module, and only the modules this rig
populates — SHW5G — are compared or installed. A drop that touches only
S56C changes nothing here:
==> Installed vs package (modules: SHW5G)
· package SG8A-AGON-G2Y-A1-JetPack7.2.1 @ f4a59f7 (installed)
✓ libnvuddf_max96712_library.so [shared]
- libnvuddf_s56c_cameramodule_library.so [S56C] not on this rig
! libnvuddf_shw5g_cameramodule_library.so [SHW5G] stale
! SHW5G.nito [SHW5G] stale
Note
Staleness is checked by content, not by version. SENSING reuses the package
name across drops and does not bump Version.md, so a release that only
rebuilds the drivers and retunes the ISP is invisible to any check that
looks at names or timestamps. What was installed is recorded in
/var/lib/sensing/installed.json — a per-module digest plus the upstream
commit, which is the version to quote when reporting a problem to SENSING.
Such a drop needs no reboot either: only files are copied, and the overlay
is the sole artifact the kernel reads at boot. The script says so rather
than sending you through jetson-io again.
Warning
Setup copies the files itself rather than running the vendor
install.sh, which wipes /usr/lib/nvsipl_drv and installs every
module’s driver. The trade is that a new vendor artifact could go
unnoticed, so each run first checks that every source install.sh copies
is one setup knows about, and says so loudly if not.
Then select the overlay and reboot:
sudo /opt/nvidia/jetson-io/jetson-io.py
# Configure Jetson AGX CSI Connector
# -> Configure for compatible hardware
# -> Jetson Sensing SG8A-AGON-G2Y-A1 SIPL GMSL2x8
sudo nvpmodel -m 0 && sudo jetson_clocks
Note
There is no per-boot step. The vendor install.sh is copy-only, so
once the overlay is selected the rig comes up on its own. The old
sensing-load.sh and sensing-camera.service no longer exist.
Groups, not root#
SIPL needs no root, but it does need two supplementary groups beyond video:
Node |
Group |
Why |
|---|---|---|
|
|
the two MAX96712 deserializers |
|
|
deserializer power enable |
sudo usermod -aG i2c,gpio $USER # then log out and back in
Omit either and SIPL fails with exactly one line — Master SetPlatformConfig
(Camera HAL) failed. status: 10 — which names neither the node nor the
permission. verify.sh diagnoses it properly.
Container#
To build against this rig from inside a container:
src/plugins/sensing/docker/setup_container.sh
It checks the container can reach the device nodes, then provisions both build
SDKs — public downloads resolved from the version in /etc/nv_tegra_release.
The Jetson Multimedia API comes from the apt pool; the SIPL API is a
tarball published alongside the L4T release, fetched by URL. To install that
one by hand instead:
sudo tar xf Jetson_SIPL_API_R39.2.1_aarch64.tbz2 -C /usr/src/
Running the cameras needs none of that, only what the next two sections cover.
Validating in a container#
Before adapting your own image, prove the rig works from a throwaway one:
src/plugins/sensing/scripts/run_docker_example.sh
# ... builds, then drops you at a shell in the container:
./validate.sh
The script builds docker/Dockerfile.example — which does nothing but add
the EGL runtime and run image_setup.sh — and starts it with the flags no
Dockerfile can express. --print-args and --json emit those flags for
your own container instead; -- validate.sh runs the check and exits rather
than opening a shell.
Five ingredients, and leaving any one out fails in a way that names something else:
Ingredient |
What its absence looks like |
|---|---|
|
|
|
every node unreadable no matter which groups the process holds |
|
|
|
|
the two |
|
Adapting your own image#
Group membership belongs in the image, not in runArgs: Docker resolves
supplementary groups for the container user from the image’s own
/etc/group, so putting the user in i2c, gpio and video at build
time covers PID 1 and docker exec alike. Verified on this rig — with the
gids in the image and no --group-add, all of /dev/i2c-9,
/dev/i2c-10, /dev/gpiochip0 and /dev/nvmap are readable. The
reverse does not hold: --group-add alone leaves an interactive shell
without them, because docker exec ignores HostConfig.GroupAdd.
The gids are host-specific, so read them off the host rather than copying:
$ src/plugins/sensing/scripts/run_docker_example.sh --print-gids
114:i2c 985:gpio 44:video
The image half is one RUN. Expose the directory as a named build context —
docker build cannot COPY across into a submodule:
docker build --build-context sensing=path/to/src/plugins/sensing/docker ...
RUN --mount=type=bind,from=sensing,target=/mnt/sensing \
/mnt/sensing/image_setup.sh "$USERNAME" 114:i2c 985:gpio 44:video
The bind mount leaves nothing behind, so the script is not baked into a layer. To layer onto an existing image instead of editing its Dockerfile:
docker build -f docker/Dockerfile.example --build-arg BASE=my-image:latest \
--build-arg SENSING_USER=me -t my-image:sensing src/plugins/sensing/docker
The runtime half comes from the same script:
docker run $(src/plugins/sensing/scripts/run_docker_example.sh --print-args) my-image:sensing ...
--json emits the same flags as devcontainer.json runArgs elements.
--with-groups adds --group-add for an image built without
image_setup.sh; it is redundant otherwise.
nvsipl_camera#
The vendor reference application, which setup installs to /usr/sbin. It is
the ground truth for “is the rig itself alive”: it drives
the same SIPL API as any other client, so if it cannot stream, nothing else
will either. Reach for it before debugging anything of ours.
validate.sh wraps it with this rig’s arguments and runs the same way on the
host and inside a container:
src/plugins/sensing/scripts/validate.sh # stream both sensors for 10 s
src/plugins/sensing/scripts/validate.sh --query # ask the config, no hardware
src/plugins/sensing/scripts/validate.sh -t 0 # until 'q'
It resolves the package itself and fails with the reason rather than a status code when the config or the binary is missing. To drive the tool directly:
cd src/plugins/sensing/.cache/SG8A-AGON-G2Y-A1-JetPack7.2.1
nvsipl_camera -t query/sg8a_agth_g2a/shw5g.json -c SHW5G_2 \
-m "0x0000 0x1100" --enable-camera-hal -s -Z -r 10
Flag |
Why this rig needs it |
|---|---|
|
platform config JSON, resolved against the working directory — hence the |
|
config within that file; |
|
one mask per deserializer in transport order: nothing on CSI-GH, links 2 and 3 on CSI-CD |
|
the camera-HAL path; without it SIPL takes the legacy DevBlk path, which this carrier does not support |
|
skip sensor authentication — these modules carry no auth keys |
|
print FPS every 2 s, which is how you tell streaming from merely starting |
|
exit after N seconds, and disable the interactive menu |
|
raise verbosity when a failure names nothing |
Expect ~60 fps per sensor with zero drops. Without -r it runs until q.
nvsipl_query answers what the config declares, with no hardware and no
capture:
nvsipl_query -t query/sg8a_agth_g2a/shw5g.json -l # config names
nvsipl_query -t query/sg8a_agth_g2a/shw5g.json -c SHW5G_2
Building and running#
cmake -B build -DBUILD_PLUGIN_SENSING=ON
cmake --build build --target camera_plugin_sensing
The platform config is vendored at src/plugins/sensing/configs/shw5g.json
and resolved relative to the executable, so the plugin runs without the vendor
package on disk; --platform-config=PATH points it at a newer vendor drop.
Note
The published package has no SHW5G_2 config — only the mixed
populations. SENSING sends shw5g.json directly to customers with a
2× SHW5G rig, so the vendored copy is the only one a fresh checkout has, and
setup.sh drops it into the fetched package for nvsipl_camera.
Start by asking the platform config what exists. This needs no hardware — the query API only parses the driver database and the JSON, so it works with the cameras unplugged:
./build/src/plugins/sensing/app/camera_plugin_sensing --list-sensors
SHW5G_2: 2 sensor(s)
sensor=0 SHW5G 2560x1984 @ 60 fps
sensor=1 SHW5G 2560x1984 @ 60 fps
Warning
sensor=N is the SIPL pipeline index, and for SHW5G_2 it is not
the number the JSON appears to name. The GMSL link indices, the CSI virtual
channels and the JSON’s sensorInfo.id are all 2 and 3; the pipeline
indices are 0 and 1. Always take the number from --list-sensors.
In the vendor’s S56C_1_SHF3L_2 config the two coincide, which is exactly
why this is worth stating.
Geometry comes from the platform config#
There is no --width, --height, --fps or --sensor-mode. SIPL has
no runtime mode index; resolution and frame rate are properties of the virtual
channel in the platform config, and the plugin sizes its buffers from what the
query reports.
Encoding at 5 MP60#
Two sensors at 2560×1984 @ 60 is 4.9× the pixel rate the 1080p30 defaults were chosen for, so the codec settings moved with it:
Setting |
Value |
Why |
|---|---|---|
level |
5.2 |
1,190,400 MB/s exceeds Level 5.1’s 983,040 by 21% |
|
40 Mbps |
0.13 bits/pixel; 20 Mbps was 0.066 |
|
60 Mbps |
VBR ceiling; pass |
80 Mbps for the pair, about 36 GB/hour.
Live streaming with camera_viz#
ipc=<socket> serves a sensor’s frames to another process as CUDA device
memory — RGBA8, no encode, no host round-trip — and
camera_viz consumes it with
type: cuda_ipc. It is independent of output=; an ipc-only stream
never starts an encoder.
# terminal 1 — producer
./build/src/plugins/sensing/app/camera_plugin_sensing \
--add-stream=sensor=0,ipc=/tmp/sensing0.sock \
--add-stream=sensor=1,ipc=/tmp/sensing1.sock
# terminal 2 — viewer
cd examples/camera_viz
./camera_viz.sh setup # one-time
./camera_viz.sh run configs/cuda_ipc.yaml --mode window # desktop window
./camera_viz.sh run configs/cuda_ipc.yaml # XR headset
configs/cuda_ipc.yaml needs only the socket path and the frame size:
cameras:
- name: left
type: cuda_ipc
socket: /tmp/sensing0.sock # must match the producer's ipc= path
width: 2560 # must match what the producer serves
height: 1984
Order does not matter — the source retries until the socket appears and survives
the producer restarting under it. width/height are checked during the
handshake and a mismatch is refused rather than rendered at the wrong stride.
One consumer at a time: the producer serves whoever connected most recently,
so a second viewer silently takes the feed from the first.
Pairing the two eyes#
Both sensors are driven from one deserializer fsync generator, and every frame
carries SIPL’s frameCaptureTSC on a timebase shared across the whole rig.
It is recorded in the MCAP metadata as capture_tsc_ns.
Important
Pair on capture_tsc_ns, not on the wrapper timestamps. The wrapper stamps
are CLOCK_MONOTONIC taken after per-sensor conversion, so they carry each
sensor’s own queueing jitter; the TSC does not.
Testing without a camera#
sensing_ipc_testsrc publishes an animated pattern over the same protocol. It
needs CUDA only — no SIPL, no encoder — so the viewer can be developed with
nothing attached:
cmake --build build --target sensing_ipc_testsrc
./build/src/plugins/sensing/tools/sensing_ipc_testsrc --socket=/tmp/sensing0.sock \
--width=2560 --height=1984 --fps=60
Each frame carries its 16-bit frame number as a binary bar across the top, so a stale or torn frame is visible rather than merely suspected.
Troubleshooting#
Symptom |
Cause |
|---|---|
|
missing |
|
|
|
drivers were never installed — re-run |
|
the fetched package ships no |
|
using the link index or the JSON id; run |
capture hangs, no frames, no error |
another SIPL client holds the hardware — stop |
|
the ISP refused BT.601; see the note in |
|
|
DISPLAY and the EGL vendor#
Two X servers usually exist on this box: :1 is a real server the Tegra
driver can drive, :99 is Xvfb and it cannot. With both 10_nvidia.json
and 50_mesa.json installed, GLVND hands out Mesa’s EGL whenever
DISPLAY names a server Tegra EGL cannot drive, and
NvBufSurfaceMapEglImage() then fails with Could not get EglImage from
fd.
The container defaults to DISPLAY=:99, so anything that renders needs:
export DISPLAY=:1 # nvsipl_camera --egl-display, camera_viz, any viewer
camera_plugin_sensing is unaffected: it never renders and calls
unsetenv("DISPLAY") before its first EGL call, which selects the Tegra
driver regardless. Do not remove that because the capture path “has no
display” — NvBufSurfaceMapEglImage() is how a SIPL buffer reaches CUDA, so
EGL is on the critical path even headless.