QRCodes
In this example, we add a robot and start collecting robot data to PostgreSQL, and maps and scanned QR codes to RustFS as image files.
You will also need 3 terminal windows, to:
- Run the simulation + Nav2 launchfile: it starts gz-sim, localization, navigation and RViz
- Run DC
- Drive the robot past the QR codes
Keeping them separate helps reading the JSON printed on the DC terminal, which RViz and gz-sim would otherwise drown out.
| Terminal | Description |
|---|---|
| Nav2 | gz-sim, localization, navigation and RViz |
| DC | Data collection |
| Run | Waypoint follower driving past every QR-coded pallet |
Setup RustFS and PostgreSQL
RustFS
RustFS will be used as storage for the map and camera image Files. To start it, follow the steps — a single RustFS container plus a one-shot container that bootstraps the dc-files bucket the Destinations below upload into, using the default rustfsadmin/rustfsadmin credentials this demo's params file already assumes.
PostgreSQL
PostgreSQL will be used as database storage for our JSON. Later on, backend engineers can make requests on those JSON based on measurement requested and time range. To start it, follow the steps
Vector's postgres sink maps each top-level key of a Record's JSON payload onto an existing column of the same name — it does not create tables or columns itself. tools/infrastructure/docker/config/postgresql/init.sql pre-creates the dc and dc_files tables this demo writes into; see its header comment for the full column reference if you add a measurement whose fields aren't already columns.
Setup the ROS environment
In each terminal, source your environment:
source /opt/ros/jazzy/setup.bash
source install/setup.bash
Nothing else has to be exported. dc_simulation's environment hook puts its own
models/ and worlds/ on GZ_SIM_RESOURCE_PATH, and warehouse.launch.py adds
nav2_minimal_tb3_sim's models (where the TurtleBot3 meshes live now — the
Gazebo Classic turtlebot3_gazebo package has no Jazzy release).
Start Navigation
dc_demos' own launch file brings up gz-sim with the QR-code warehouse world,
spawns dc_simulation's TurtleBot3-Waffle with its ros_gz_bridge, and starts
Nav2 (map_server + AMCL + planners) against dc_simulation/maps/qrcodes.yaml.
It also starts DC, which this demo drives separately, so turn that off here:
ros2 launch dc_demos tb3_qrcodes.launch.py \
headless:=False \
use_dc:=False
gz-sim and RViz will start: you should see the robot in the warehouse, and the
map in RViz. AMCL sets the demo's initial pose itself (set_initial_pose in
qrcodes_nav.yaml), so there is no need to click "2D Pose Estimate" — wait until
the laser scan lines up with the map before starting the run below.
The warehouse world is heavy: 238 model instances, most of them the QR-coded pallets and the props stacked on them. Expect a slow start on a machine without a GPU: gz-sim, the robot and Nav2 come up in well under a minute, but with the real-time factor well under 1 (~0.11-0.2, see dc_simulation's README for the measured breakdown), reaching the first QR-coded pallet and getting the first Record out of the demo can take several minutes of wall clock, and the full 60-waypoint pass over an hour. See #52.
Start DC
Execute
ros2 launch dc_demos tb3_qrcodes_minio_pgsql.launch.py use_sim_time:=True
use_sim_time defaults to False — without the override above, DC's Position
measurement looks up TF transforms on the wall clock while the simulation publishes
them on Gazebo's sim clock, which throws "extrapolation into the past"/"transform does
not exist" errors and can crash measurement_server outright once the two clocks
drift far enough apart (same issue as the PostgreSQL/RustFS
and InfluxDB demos).
With this, all data will be transmitted
Drive the robot past the QR codes
In a fourth terminal, run the waypoint follower. It sends the robot down each aisle, stopping in front of every QR-coded pallet so both cameras can read them, and exits once the pass is complete:
ros2 run dc_demos qrcodes_waypoint_follower
The waypoints are camera stations, and the aisles are narrow enough that positioning accuracy matters: a code is only readable while
|lateral error| + standoff * tan(|yaw error|) + 0.181 <= standoff * tan(30°)
where 0.181 m is half a rendered QR symbol and 30° is half the cameras' field of view.
The tightest station has a 0.73 m standoff, which is why qrcodes_nav.yaml stops the
robot within 0.1 m and 0.1 rad rather than Nav2's more usual tolerances. If you move the
waypoints, the pallets or the cameras, re-run ./tools/sim/scripts/run.sh — its lint
stage re-derives that inequality from the world, the robot model, the waypoints and the
nav params, and its detect stage checks that codes really do come back. See
dc_simulation's README
for the measured geometry.
Understanding the configuration
The full configuration file can be found here.
For this demo, we will reconstruct the yaml configuration element by element, given how large it is. Go through the explanation to understand how it works.
Collect command velocity, position and speed to PostgreSQL as a group
Similarly to the previous tutorial:
dc_bridge:
ros__parameters:
shipper:
data_dir: "$HOME/.dc/buffer"
destinations: ["records_log", "rustfs"]
records_log:
type: file
receives: records
inputs:
[
"/dc/measurement/map",
"/dc/measurement/right_camera",
"/dc/measurement/left_camera",
"/dc/group/robot",
]
path: "/tmp/dc/qrcodes_minio_pgsql_records.ndjson"
time_key: "date"
custom_config_files: ["$HOME/.dc/qrcodes_minio_pgsql_sink.toml"]
group_server:
ros__parameters:
groups: ["robot"]
robot:
inputs:
[
"/dc/measurement/cmd_vel",
"/dc/measurement/position",
"/dc/measurement/speed",
]
output: "/dc/group/robot"
sync_delay: 5.0
group_key: "robot"
measurement_server:
ros__parameters:
custom_keys_str: ["robot_name"]
robot_name: "C3PO"
measurement_plugins: ["cmd_vel", "position", "speed"]
custom_key_str_list: ["robot_name", "id"]
custom_keys_str:
robot_name:
name: robot_name
value: "C3PO"
# Requires systemd package
id:
name: id
value_from_file: /etc/machine-id
run_id:
enabled: true
counter: true
counter_path: "$HOME/run_id"
uuid: false
moving:
plugin: "dc_conditions/Moving"
cmd_vel:
plugin: "dc_measurements/CmdVel"
group_key: "cmd_vel"
enable_validator: true
topic_output: "/dc/measurement/cmd_vel"
include_measurement_name: true
position:
plugin: "dc_measurements/Position"
group_key: "position"
topic_output: "/dc/measurement/position"
enable_validator: true
global_frame: "map"
robot_base_frame: "base_link"
transform_timeout: 0.1
include_measurement_name: true
speed:
plugin: "dc_measurements/Speed"
group_key: "speed"
odom_topic: "/odom"
topic_output: "/dc/measurement/speed"
include_measurement_name: true
In the measurement server, we set 3 measurements: cmd_vel, position and speed being collected once per second, are validated with their respective JSON schemas and publish on their own topics.
Note the include_measurement_name which include measurement name in the JSON, which is used when grouping. The group collects the data from those 3 measurement and republishes it on the group topic /dc/group/robot.
dc_bridge owns every Destination this demo uses. records_log's inputs list already
names every topic this demo produces (/dc/group/robot for this section, plus
/dc/measurement/map, /dc/measurement/right_camera and /dc/measurement/left_camera for
the sections below): unlike the retired per-measurement tags: [...] mechanism, a
Destination's inputs is the single place that decides what reaches it, so we declare it
once and simply grow the measurements that feed those topics as we go.
records_log is a file Destination, not postgres — PostgreSQL is reached through the
ADR-0003 passthrough instead. The
actual postgres sink lives in qrcodes_minio_pgsql_sink.toml, a raw Vector config
snippet loaded via custom_config_files:
# ~/.dc/qrcodes_minio_pgsql_sink.toml
[sinks.pgsql]
type = "postgres"
inputs = [
"dc.dc.measurement.map",
"dc.dc.measurement.right_camera",
"dc.dc.measurement.left_camera",
"dc.dc.group.robot",
]
endpoint = "postgres://dc:password@127.0.0.1:5432/dc"
table = "dc"
[sinks.pgsql.buffer]
type = "disk"
max_size = 268435488
dc_bridge derives its ROS subscriptions and dc.<tag> routes from destinations alone,
never from a passthrough snippet's inputs — that's why records_log still lists every
topic even though the snippet above is what actually reaches PostgreSQL. Copy the snippet
into place before launching:
mkdir -p ~/.dc && cp "$(ros2 pkg prefix dc_demos)/share/dc_demos/config/qrcodes_minio_pgsql_sink.toml" ~/.dc/
Be sure to change the login and password to your current infrastructure configuration. Do it in production setup!
You can find more about the postgres sink recipe here
To take a look at records, go to Adminer. It is by default started at http://localhost:8080, it is a database GUI.:

You can then click on a record, to take a look, edit or delete it:

Send the map image and YAML from nav2_map_server to RustFS
First, we add the map measurement. Its remote_keys names the rustfs Destination, which is what actually uploads the pgm/yaml Files — the map measurement only records where they are, locally and (once uploaded) remotely:
measurement_server:
ros__parameters:
...
measurement_plugins: ["map"]
map:
plugin: "dc_measurements/Map"
group_key: "map"
polling_interval: 5000
save_path: "map/%Y-%m-%dT%H:%M:%S"
topic_output: "/dc/measurement/map"
save_map_timeout: 4.0
remote_prefixes: [""]
remote_keys: ["rustfs"]
enable_validator: true
include_measurement_name: true
...
Then, the Destinations that make this work:
dc_bridge:
ros__parameters:
...
destinations: ["records_log", "rustfs"]
rustfs:
type: s3
receives: files
inputs:
[
"/dc/measurement/map",
"/dc/measurement/right_camera",
"/dc/measurement/left_camera",
]
bucket: "dc-files"
endpoint: "http://127.0.0.1:9000"
region: "us-east-1"
access_key_id: "rustfsadmin"
secret_access_key: "rustfsadmin"
force_path_style: true
files:
delete_when_sent: true
metadata_destination: "records_log"
# ~/.dc/qrcodes_minio_pgsql_sink.toml (continued)
[sinks.pgsql_files]
type = "postgres"
inputs = ["dc.dc.files"]
endpoint = "postgres://dc:password@127.0.0.1:5432/dc"
table = "dc_files"
[sinks.pgsql_files.buffer]
type = "disk"
max_size = 268435488
This introduces the two-way PostgreSQL split this demo relies on:
pgsql(the passthrough sink declared above) carries the map's own metadata Record (dimensions, resolution, local/remote paths) like any other measurement.pgsql_filesis a secondpostgressink in the same passthrough snippet, dedicated to the Uploader's own bookkeeping. It has no ROS topicinputsof its own — it consumes thedc.dc.filesroute instead, whichrecords_loggains from being named asfiles.metadata_destinationbelow.files.metadata_destinationmust name a configuredreceives: recordsDestination (dc_bridgerejects anything else at startup), which is why it namesrecords_lograther than the passthrough sink directly — a passthrough-only sink id isn't eligible.rustfsis thes3Destination that actually uploads the pgm/yaml bytes.receives: filesmarks it as owned bydc_uploader— a separate process fromdc_bridge(ADR-0014) — rather than a Vector sink:dc_bridgesubscribes torustfs'sinputs, and durably enqueues an intent for any Record withremote_pathsentries whose key matches a Destination name — hererustfs, matching the map measurement'sremote_keysabove.dc_uploaderreads that intent, uploads the referenced Files, verifies they landed, and emits a status Record underdc.files, routed torecords_log(and, from there, consumed by thepgsql_filespassthrough sink). Unlikepgsql/pgsql_files,rustfsstays blessed:receives: filesis served entirely bydc_uploaderreading these same ROS params, never by a Vector sink, so there is no passthrough equivalent for it to migrate to.
See Destinations for the full files:/Uploader contract, and ADR-0005 / ADR-0014 for why file uploads are a DC responsibility rather than a Vector sink, and why that logic now runs as its own process.
files.delete_when_sent: true means the local pgm/yaml are removed only once RustFS confirms the upload — never before.
You can check upload status and completion in Grafana's Robot dashboard, whose "Uploaded inspection files" and "Group completion status" panels query the dc_files table pgsql_files writes into:
SELECT to_timestamp(updated_at) AS "time", group_name, robot_name, storage_type, remote_path, content_type, size, uploaded
FROM dc_files WHERE kind = 'file_status' ORDER BY updated_at DESC LIMIT 100
Then, similarly, on Adminer, you can browse the dc table's map rows.
Send QR code images to RustFS
We want to collect pictures taken by the cameras
measurement_server:
ros__parameters:
...
measurement_plugins: ["cmd_vel", "position", "speed", "map", "right_camera", "left_camera"]
condition_plugins: ["moving", "inspected_exists"]
custom_key_str_list: ["robot_name", "id"]
custom_keys_str:
robot_name:
name: robot_name
value: "C3PO"
# Requires systemd package
id:
name: id
value_from_file: /etc/machine-id
run_id:
enabled: true
counter: true
counter_path: "$HOME/run_id"
uuid: false
moving:
plugin: "dc_conditions/Moving"
inspected_exists:
plugin: "dc_conditions/Compare"
key: "inspected"
comparison: "exists"
right_camera:
plugin: "dc_measurements/Camera"
group_key: "right_camera"
if_none_conditions: ["moving"]
if_all_conditions: ["inspected_exists"]
topic_output: "/dc/measurement/right_camera"
init_collect: false
init_max_measurements: -1
condition_max_measurements: 1
node_name: "dc_measurement_camera"
cam_topic: "/right_intel_realsense_r200_depth/image_raw"
cam_name: right_camera
enable_validator: true
draw_det_barcodes: true
save_raw_img: false
save_rotated_img: false
save_detections_img: true
save_inspected_path: "right_camera/inspected/%Y-%m-%dT%H-%M-%S"
rotation_angle: 0
detection_modules: ["barcode"]
remote_prefixes: [""]
remote_keys: ["rustfs"]
include_measurement_name: true
left_camera:
plugin: "dc_measurements/Camera"
group_key: "left_camera"
if_none_conditions: ["moving"]
if_all_conditions: ["inspected_exists"]
topic_output: "/dc/measurement/left_camera"
init_collect: true
init_max_measurements: -1
condition_max_measurements: 1
node_name: "dc_measurement_camera"
cam_topic: "/left_intel_realsense_r200_depth/image_raw"
cam_name: left_camera
enable_validator: true
draw_det_barcodes: true
save_raw_img: false
save_rotated_img: false
save_detections_img: true
save_inspected_path: "left_camera/inspected/%Y-%m-%dT%H-%M-%S"
rotation_angle: 0
detection_modules: ["barcode"]
remote_prefixes: [""]
remote_keys: ["rustfs"]
include_measurement_name: true
...
Taking a look at the cameras, we can understand that:
- Data is only collected when the robot is not moving:
if_none_conditions: ["moving"] - Data is only collected when there is inspected data, so only when a QR code is detected:
if_all_conditions: ["inspected_exists"] - Data is not collected constantly:
init_max_measurements: -1 - Only one record is collected when conditions are triggered:
condition_max_measurements: 1 - Only images with inspected data are collected:
save_raw_img: falsesave_rotated_img: falsesave_detections_img: true
- Barcodes are scanned in each image:
detection_modules: ["barcode"]
include_measurement_name matters here too: the Uploader relies on it to know which top-level field of the Record holds the local_paths/remote_paths it should act on.
No new Destination block is needed for the cameras: records_log and rustfs already list /dc/measurement/right_camera and /dc/measurement/left_camera in their inputs (see the first section above), and the passthrough pgsql sink already consumes the matching dc.<tag> routes those inputs create — a Destination's inputs is a single, global list of topics rather than something declared per measurement, so adding a measurement that feeds an already-configured Destination requires no dc_bridge change at all.
Here, we collect images with the rustfs Destination, and their metadata (which record they belong to, remote path once uploaded, image dimensions where relevant) through the passthrough pgsql sink. pgsql_files, fed by the Uploader, tracks when each image is sent to RustFS and — with files.delete_when_sent: true — is deleted locally once confirmed. Note that a Record's remote_paths can name several Destinations at once; the Uploader sends to every one whose name appears there.
An example Record for right_camera, once the image is inspected:
{
"camera_name": "right_camera",
"date": 1677668926.700422,
"flattened": false,
"id": "be781e5ffb1e7ee4f817fe7b63e92c32",
"nested": false,
"robot_name": "C3PO",
"run_id": "218",
"local_img_paths": {
"inspected": "/root/dc_data/C3PO/2023/03/01/17/right_camera/inspected/2023-03-01T17-12-57.jpg"
},
"remote_paths": {
"rustfs": {
"inspected": "C3PO/2023/03/01/17/right_camera/inspected/2023-03-01T17-12-57.jpg"
}
},
"inspected": {
"barcode": [
{
"data": [81, 82, 99, 111, 100, 101, 45, 49],
"height": 40,
"width": 40,
"top": 120,
"left": 200,
"type": "QRCODE"
}
]
}
}