MV3DT Standalone Deployment
Overview
RT-CV-3D couples the RT-DETR 2D detector with the Multi-View 3D Tracking (MV3DT) framework to produce fused 3D Bird’s Eye View (BEV) tracks across cameras with overlapping fields of view. It ships as two container images:
The metadata path is perception → mdx-raw → bev-fusion → mdx-bev.
Perception uses the same vss-rt-cv image as Object Detection and Tracking, configured for multi-view 3D tracking rather than 2D. For MV3DT as deployed by the Warehouse Operations blueprint, see 3D Multi Camera Detection and Tracking (MV3DT). This page covers running the microservice on its own.
Standalone deployment
Unless noted otherwise, run every command below from services/rtvi/rt-cv-3d/rt-cv-mv3dt in a checkout of the blueprint repository. This deployment ships sample configs, utility scripts, and a minimal Docker Compose file that runs both components together. For container image tags and how to build them locally, see the RT-CV-3D README.
Assumptions
- Your cameras are already calibrated and you have a
calibration.jsonfor your camera setup. Refer to the VSS Calibration documentation for how to create one. - Time-synchronized, 30 FPS multi-camera footage. Whether you feed recordings or live streams, the cameras must be synchronized in two ways: at any moment all cameras capture the same scene moment, and frames of that moment carry timestamps that agree to within one frame’s duration (33 ms at 30 FPS), so they bucket into the same frame interval. Larger skew degrades cross-camera alignment and BEV fusion accuracy. For live RTSP that correspondence is carried in-band, as an SEI frame ID embedded by the VST proxy — see §4.
You can feed that footage in one of two ways. The simplest is to point the pipeline at recorded per-camera .mp4 files: this needs no streaming server, and each clip plays once and then the app exits, so it is the easiest way to try RT-CV-3D on your own recordings (see §2.3). The other is live RTSP streams, which you register after launch (see §4). The rest of the setup is identical either way, so it is best to validate on recordings first and move to live RTSP once the results look good.
If you don’t have your own footage yet, the package in §1.1 includes a 4-camera warehouse sample dataset you can run end-to-end.
1. Place models and assets
1.1 Download the assets package
Download the vss-warehouse-app-data package from NGC (substitute <WAREHOUSE_APP_DATA_NGC> / <WAREHOUSE_APP_DATA_DIR> with the resource reference and extracted directory name from the VSS documentation):
Then point MODELS_DIR in docker/.env at the extracted vss-warehouse-app-data/models directory. The stack uses:
The warehouse app-data package does not include $MODELS_DIR/reid/. Before setting REID_ENABLED=1, stage those files with the reid-embed image as described in §3.3.
Sample dataset (optional). The same package also ships a 4-camera warehouse sample you can try RT-CV-3D on without your own footage. It consists of:
- Per-camera videos (in the app-data package) — point
VIDEO_DIRat this directory: calibration.jsonand the BEV mapTop.png(in this repo, underdeploy/.../warehouse-mv3dt-app/calibration/sample-data/warehouse-4cams-20mx20m-synthetic/):calibration.jsonimages/Top.png
To test with the sample dataset, set VIDEO_DIR to the absolute path of the sample videos directory and NUM_CAMS=4 in docker/.env, then continue with §2.2 using its calibration.json, and §2.3 with INPUT_MODE=file.
1.2 Optional: use a different RT-DETR model
For example a smart-city variant:
- place the onnx under
$MODELS_DIR/mtmc/ - update the
onnx-fileandmodel-engine-filenames in configs/ds-pgie-config.yml - if the class set differs: update
configs/ds-detector-labels.txtand theCLASS_SPECSheight/radius priors when generating configs (§2) —CLASS_SPECSis an env override togenerate-configs.sh, whose header documents its format
2. Update configs for your dataset
2.1 Set environment variables
Settings live in docker/.env, in two kinds:
† required, no default — compose won’t start without them.
docker compose reads docker/.env at launch, so everything it needs must be set in that file. The four staging knobs in the second row can instead be set inline — on the command line, e.g. OSD=1 ./scripts/stage-configs.sh — which overrides docker/.env for that run.
2.2 Generate camInfo and MQTT pub/sub configs
Generate from your calibration.json:
Expected: DONE. Generated: with one camInfo file per camera. Outputs go to generated/ (gitignored):
generated/camInfo/<sensor>.yml— per-camera projection matrices + object-model priorsgenerated/pub_sub_info_config.yml— sparse MQTT pub/sub neighbor graph (tune withNEIGHBOR_CRITERIA=top_N:<K>oroverlap_threshold:<T>)
The /trck topic endpoints in the generated pub/sub config default to the MQTT broker at localhost:1883. If your broker is not on localhost or on a different port, pass its address so the generated config points at it:
2.3 Stage the DeepStream configs
Run the staging script below. It writes generated/configs/, the config directory the container mounts. Any variable you put at the front of the command overrides its value in .env.
Pick the command for your case:
Recorded-file input (INPUT_MODE=file): put one .mp4 file per camera under VIDEO_DIR (set in .env), each named <sensor_id>.mp4 to match its sensor id in calibration.json. The clips play once and the container exits at end of stream, so there is no stream registration; skip §4. Everything downstream (Kafka, BEV fusion, visualizers) is identical to live mode.
2.4 Optional: use your own DeepStream / tracker configs
The samples live in configs/ (RT-DETR pgie, MV3DT tracker, main config, Kafka/MQTT adaptors). To replace them:
- Tracker: a tracker config tuned for your specific dataset usually tracks more accurately than the sample config — such a config is typically obtained by manual tuning or with PipeTuner, which is why the
TRACKER_CONFIGoverride is supported. Pass your own tracker config when staging —TRACKER_CONFIG=/path/to/my-tracker-config.yml ./scripts/stage-configs.sh(itscameraModelFilepathmap will still be overwritten properly to your generated camInfo files). - Other DeepStream configs (pgie, main config, adaptors): edit the files in configs/ before running
stage-configs.shor re-running it.
3. Launch
The default images are the NGC release images — run docker login nvcr.io first to be able to pull them (or build them locally instead, see the RT-CV-3D README).
3.1 Option A — bundled brokers
Run the following command to start RT-CV-3D with bundled mosquitto and kafka brokers:
3.2 Option B — your own brokers
The two brokers propagate differently.
MQTT_HOST/MQTT_PORTare read by the container at every start, so editingdocker/.envis enough for MQTT. The Kafka endpoint is written intogenerated/configs/ds-main-config-mv3dt.txtbystage-configs.shand the configs are mounted read-only, so changingKAFKA_BOOTSTRAPwithout restaging leaves the old endpoint in place. Perception then retries a broker that is not there and aborts. The container checks the two against each other at startup and refuses with this remedy rather than failing that way, but restaging is what fixes it.
If you want to use your own mosquitto and kafka brokers, set MQTT_HOST/MQTT_PORT and KAFKA_BOOTSTRAP in docker/.env, restage, then launch without COMPOSE_PROFILES:
3.3 Optional ReID service
The reid Compose profile adds the embedding service and its private Milvus, etcd, and MinIO dependencies. The tracker emits 1280-D CLIP-ReID features in mdx-raw and queries the service at 127.0.0.1:8088; the service consumes the same mdx-raw topic. Compression and the secondary SigLIP embedding are off by default because neither is needed for tracker reassociation.
For an isolated, finite evaluation, edit docker/.env:
Known issue: docker/.env ships
REID_EMBED_IMAGE/REID_EMBED_TAGasnvcr.io/nvstaging/vss-core/reid-embed:3.3.0-26.09.3. That is not the public release image. This is a known issue and a fix is in progress. Until that fix lands, edit the file before the one-shot below and set:
docker login nvcr.iois required to pull the public image. The one-shot andCOMPOSE_PROFILES=...,reidboth read these two variables.
With REID_ENABLED=1, ./scripts/stage-configs.sh requires this file to exist and be readable:
The app-data package does not ship it. From this directory, fill $MODELS_DIR/reid with a one-shot container of the reid-embed image. It already has PyTorch. The container mounts $MODELS_DIR/reid at /opt/storage/reid and runs download-embedding-models.sh --secondary --clipreid there. Do not run that script on the host unless python3 -c 'import torch' succeeds.
NGC_CLI_API_KEY is required because --secondary downloads SigLIP 2 from NGC. --clipreid exports reid_model.onnx inside the container.
reid_model.onnx_b64_gpu0_fp32.engine (batch 64, GPU 0, FP32) is not a prerequisite and is not shipped. TensorRT builds it inside the perception container on the first run that has ReID enabled, writing the plan next to the ONNX, the same way the RT-DETR engine is built. A cold build takes minutes, so the first start is slow and later ones reuse the plan. $MODELS_DIR/reid has to stay writable by the container’s user, which is what download-embedding-models.sh relaxes the directory permissions for.
Then restage and launch the reid profile together with the bundled brokers:
Perception waits for GET /health/ready, calls POST /reset to clear both Milvus collections, and only then starts metropolis_perception_app with --tracker-reid. That ordering means frame 0 of the finite files cannot run before the ReID state is reset. Startup also rejects a stale staged tracker config, so changing REID_ENABLED always requires another ./scripts/stage-configs.sh.
Useful checks while it starts:
To run the comparison baseline, set REID_ENABLED=0, restage, and launch without the reid profile. The ReID data uses named Docker volumes and is not deleted by ordinary docker compose down; avoid down -v. The automatic reset gives each enabled experiment empty collections while retaining the volumes. A manual reset, when perception is stopped, is:
For strict isolation between repeated finite runs, consume/convert the Kafka results after perception exits, then run docker compose --profile "*" down before the next run. Bundled Kafka is intentionally ephemeral, so the next up begins with an empty topic and the startup reset begins with empty Milvus collections. Do not merely restart perception between experiments: records from the previous run can still be buffered in Kafka or in the ReID consumer. With an external persistent Kafka broker, use a fresh topic or explicitly truncate the experiment topic before bringing the ReID service up.
REID_INPUT_TOPIC must equal RAW_TOPIC, and REID_DIMENSION must match the tracker model (1280 for the supplied CLIP-ReID model). Staging checks both the topic wiring and the required files under $MODELS_DIR/reid/ before changing the generated config.
3.4 On screen, display, and GPU selection
GPU_DEVICE in docker/.env picks the GPU the pipeline computes on. With OSD=1 the container also has to reach the GPU that drives the X display, which is often a different one on a multi-GPU host.
stage-configs.sh resolves this: it adds the display GPU to GPU_DEVICE when it is missing, and writes gpu-id into the staged config as the compute GPU’s ordinal within that set. The two are not the same number, because the container runtime orders the visible devices by PCI address rather than by the order you list them.
When the display GPU cannot be determined, staging says so and changes nothing. The on-screen display may then fail with Failed to set pipeline to PAUSED; add the GPU driving the display to GPU_DEVICE and restage.
On a laptop or any host whose display is rendered by Mesa rather than by an NVIDIA GPU, this is a different problem with a different fix: uncomment the devices block in docker/compose.yml. The preflight names whichever of the two it sees.
3.5 Verify startup
Either option — follow the perception logs until the pipeline reports ready:
Expected: the pipeline starts without errors and prints ds-ready: YES. With RTSP input the **PERF blocks stay at 0.0 FPS until streams are registered in §4; with file input (INPUT_MODE=file) the clips start immediately and the container exits when they end.
To see the per-view 3D bounding boxes on screen while the pipeline runs, stage the configs with OSD=1 before launching — see Visualization.
Alternative: launch Perception (MV3DT) only with docker run — no BEV Fusion
Runs only the Perception component (vss-rt-cv: RT-DETR + MV3DT). It still needs the MQTT/Kafka brokers, and it publishes per-sensor measurements to mdx-raw — but without the BEV Fusion component there are no fused mdx-bev tracks. Start the brokers (and bev-fusion, if wanted) separately.
This direct command is the non-ReID baseline. Use the Compose workflow in §3.3 when ReID is enabled so the service readiness and reset ordering are enforced.
4. Add streams dynamically (RTSP)
This is the input step for live RTSP (INPUT_MODE=stream). Skip it when testing on recorded files — see §2.3.
Live RTSP requires SEI-carrying streams. The staged DeepStream config sets
extract-sei-sim-time=1withattach-sys-ts-as-ntp=0, so each frame’s timestamp is read fromNVDS_CUSTOMMETASEI and is never replaced with host time. Every RTSP source must carry that SEI — it is what lets MV3DT line frames up across cameras. If it is missing, streams register successfully andds-readyreportsYES, but no source ever activates and nomdx-rawmetadata is produced.In this deployment the VST proxy injects it (its upstream NVStreamer sources do not: VST unifies their SEI). A source that does not go through VST must supply
NVDS_CUSTOMMETASEI itself.Restage after any
docker/.envchange. Several values are resolved at staging and written intogenerated/: the camera count, the broker endpoints, and the GPU selection. Editingdocker/.envand bringing the stack up without re-running./scripts/stage-configs.shleaves the staged configuration describing the previous settings. The container checks the ones it can and refuses rather than starting wrong.
scripts/add-streams.shchecks this before registering anything and refuses with the remedy. To fix it, set"enable_proxy_server_sei_metadata": truein both the VST and NVStreamervst_config.jsonyour deployment uses, redeploy, then confirm inside the containers:The check finds the proxy by probing ports 30000-30005 and 31000-31005: a VIOS deployment runs several VST services and only the proxy answers
/api/v1/proxy/configuration, so a 404 does not mean VST is absent. SetVST_HTTP_PORTto pin the port. If nothing answers, the run stops rather than registering streams that could never activate; a source that carries the SEI without going through VST needs--no-sei-check.
Register your RTSP streams via the perception REST API — one <sensor_id>=<rtsp_url> pair per camera, for all NUM_CAMS cameras. The key is the camera_id and must exactly match the sensor id in your calibration.json.
Wait for the full camera count before removing. While fewer cameras are registered than the configured
batch-size, the muxer is still waiting for the batch to fill and no source has activated. Removing one then parks the muxer and the perception REST API stops answering, soadd-streams.shrefuses. OnceActive sourceshas reached the full count, a removal no longer leaves the REST API unresponsive. To abandon a partial registration, recreate perception instead:Adding streams after removing all of them needs a recreate. The first-buffer alignment that gives the cameras a common time origin runs once per pipeline and is never re-armed, so streams added after the first batch are not guaranteed to be time synchronized. Recreate perception before registering the cameras again to avoid timing issues.
Expected: the script waits for ds-ready: YES, then reports each stream as added. On the very first run for a given batch size, TensorRT builds the RT-DETR engine — allow several minutes; the script waits automatically.
5. Check logs and receive metadata from Kafka
Expected:
- (a) every registered stream running at its frame rate (30 FPS)
- (b) / (c) prints 20 rows of
(frame timestamp, sensorId, frame id, …)received messages
6. Visualization
Two visualizations are available: the perception app’s own camera view (the tiled 3D-box view, shown on-screen in §6.1 or saved to file in §6.2), and the BEV track view, which renders the object tracks consumed from Kafka onto a top-down, bird’s-eye-view map (a live window in §6.3 or saved to file in §6.4).
6.1 Perception camera view — live window (OSD)
Requires a display on the host. A tiled per-camera view with 3D bounding boxes, rendered by the perception container on your display:
Troubleshooting: OSD window does not appear
Work through the following checks in order:
$DISPLAYis set on the host — runecho $DISPLAY; it should print a non-empty value matching your active display session (e.g.:0). If it is empty, find the correct value for your session and export it:export DISPLAY=<value>.- X11 access is granted — grant the container access with
xhost +SI:localuser:#<container-uid>, orxhost +local:for all local clients. Avoidxhost +, which disables access control for every client, including remote ones. Revoke with the matchingxhost -...form when you are done. - Configs were staged with
OSD=1— If you didn’t already, re-runOSD=1 ./scripts/stage-configs.shand relaunch. - Check the perception container logs
If it shows this line: libEGL warning: egl: failed to create dri2 screen, it often indicates the container cannot access the host DRI device (/dev/dri). In most cases the fix is to pass the host DRI device into the container by adding the following to the perception service in docker/compose.yml:
Then relaunch the container.
6.2 Perception camera view — save as video
Save the perception app’s own annotated camera view (the same 3D-box view as the OSD) to an encoded video, useful on a headless machine with no display. Enable it with SAVE_VIDEO=1, either set in docker/.env or passed inline to the staging command (§2.3), then launch. It writes video-output/grid-view.mkv: all cameras tiled into one video, with the 3D boxes and the class/ID labels overlaid.
- Recorded files (§2.3) — the clips play once and the file finalizes automatically at end-of-stream (the container then exits):
- Live RTSP — the DeepStream file sink writes one continuous file and does not configure segment rotation, size limits, or retention cleanup. Because a live stream has no natural end-of-stream,
stage-configs.shrefusesINPUT_MODE=stream SAVE_VIDEO=1by default. To explicitly accept an unbounded live recording, opt in:
Both supported modes write video-output/grid-view.mkv. To convert it to .mp4 (and, for live RTSP, produce a finalized/seekable file), run a lossless conversion with no re-encode:
NVENC-less GPUs (e.g. A100, H100, H200, GB200, GB300). The video output from the perception container is encoded with the GPU’s NVENC hardware encoder by default (
enc-type=0for[sink2]inconfigs/ds-main-config-mv3dt.txt). WhenSAVE_VIDEO=1,stage-configs.shdetects these GPUs withnvidia-smiand stages the software (CPU) encoder (enc-type=1) instead. The stock image does not carry that encoder, so staging also prepares it: it runs DeepStream’suser_additional_install.shin a throwaway container, commits the result as<tag>-swenc, and pointsPERCEPTION_TAGat it. That takes a few minutes on first use and needs network access to the Ubuntu archives; afterwards it is a no-op. Staging refuses rather than writing a config that would fail at runtime for lack of an encoder. The prepared image is local to that host, so a laterPERCEPTION_TAGbump needs it again — staging notices and redoes it. To prepare it on its own, or just to check:
6.3 BEV visualizer — live window
Requires a display on the host (without one, use §6.4). Reads object tracks from Kafka and renders them on a top-down BEV map.
Step 1 — prepare BEV_DATASET_PATH. This must be a directory containing:
map.png— the BEV floor-plan imagetransforms.yml— 3×3T_ov2pxmatrix mapping world ground plane (meters) → map pixels
If you don’t have transforms.yml, generate it from your calibration.json:
The script warns if the projected reference points don’t land on the map, which indicates a mismatch between map.png and the floor-plan used during calibration.
Sample dataset. Build the
BEV_DATASET_PATHdirectory from the files shipped in this repo:
Step 2 — launch after the Kafka brokers are running:
In the live window: press q to quit. See scripts/bev-visualizer.sh for tuning knobs (ID labels, timestamp bucketing).
6.4 BEV visualizer — save as video
Same BEV_DATASET_PATH and BEV_SOURCE as §6.3. Add BEV_SAVE_VIDEO=1 environment variable to save the video to file:
Expected: a recorded N frames ... progress line every ~10 s. With file input the recorder finalizes and exits on its own shortly after the clips end; with live streams it runs until you stop it with Ctrl-C. Either way the mp4 is saved to ./bev-output/ (Video saved: .../bev-output/trajectory_video_<stamp>.mp4 (N frames)).