Transform scene metadata with jq
This guide shows how to use the Data Transformation module to transform scene metadata using jq expressions. It includes ready-to-use jq examples for frame, object track, and object snapshot topics, as well as practical tips you can apply when building your own expressions.
Prerequisites
- A device that supports AXIS Scene Metadata
- cURL installed
Overview
- Retrieve available input topics
- Add a new transform
- Consume the transformed data over MQTT (Optional)
- Verify with statistics (Optional)
- Remove the transform (Optional)
Extra:
- Topics specific jq expression examples
- Practical tips for writing jq expressions
Lets get started!
Step 1: Retrieve available input topics
Check which scene metadata topics are available on the device.
Remember to replace <servername>, <username> and <password> where applicable.
- curl
- HTTP
curl --request GET \
--anyauth \
--user "<username>:<password>" \
--header "Accept: application/json" \
"http://<servername>/config/rest/data-transformation/v1/availableTopics/topics"
GET /config/rest/data-transformation/v1/availableTopics/topics
Host: <servername>
Accept: application/json
200 OK
Content-Type: application/json
{
"status": "success",
"data": [
{
"topicName": "com.axis.scene.frame.v1",
"description": "Scene metadata for a single frame in a video stream, including all object detections present in the frame.",
"version": "1.0.1",
"keys": [
"channel_id"
]
},
{
"topicName": "com.axis.scene.object_snapshot.v1",
"description": "A snapshot of an object, along with information about the source of the image.",
"version": "1.0.1",
"keys": [
"channel_id"
]
},
{
"topicName": "com.axis.scene.object_track.v1",
"description": "A detected and tracked object observed over time.",
"version": "1.0.1",
"keys": [
"channel_id"
]
}
]
}
Topics can appear at any time, so you can set up transforms for topics that aren't currently available.
Or use the Swagger UI for /data-transformation/v1/availableTopics/topics:
http://<servername>/config/web-ui/swagger-ui/?url=/config/discover/apis/data-transformation/v1/openapi.json
Step 2: Add a new transform
Create a transform that strips non-human detections from the frame topic. Every frame is forwarded, but the detections array is filtered to contain only Human entries.
Remember to replace <servername>, <username> and <password> where applicable.
- curl
- HTTP
curl --request POST \
--anyauth \
--user "<username>:<password>" \
--header "Accept: application/json" \
--header "Content-Type: application/json" \
"http://<servername>/config/rest/data-transformation/v1/transforms" \
--data '{
"data": {
"inputTopic": "com.axis.scene.frame.v1",
"jqExpression": ".detections |= (. // [] | map(select(.class.type == \"Human\")))",
"outputTopic": "com.axis.dt.humans_only"
}
}'
POST /config/rest/data-transformation/v1/transforms
Host: <servername>
Accept: application/json
Content-Type: application/json
{
"data": {
"inputTopic": "com.axis.scene.frame.v1",
"jqExpression": ".detections |= (. // [] | map(select(.class.type == \"Human\")))",
"outputTopic": "com.axis.dt.humans_only"
}
}
201 Created
Content-Type: application/json
{
"status": "success",
"data": {
"inputTopic": "com.axis.scene.frame.v1",
"jqExpression": ".detections |= (. // [] | map(select(.class.type == \"Human\")))",
"outputTopic": "com.axis.dt.humans_only",
"outputTopicDescription": "JQ transformation of the topic com.axis.scene.frame.v1 of version 1.0.1",
"outputTopicVersion": "1.0.1"
}
}
The transformed data is now published on com.axis.dt.humans_only. The outputTopicVersion is copied from the input topic.
- The expression must produce a JSON object or
null— not a string, number, or other primitive. If it producesnull(for example, whenselect()doesn't match), no message is published. - The output must include all key fields from the input topic, such as
channel_idfor the metadata topics. If a key field is missing, the message fails to publish.
See Output behavior for details.
Or use the Swagger UI for /data-transformation/v1/transforms:
http://<servername>/config/web-ui/swagger-ui/?url=/config/discover/apis/data-transformation/v1/openapi.json
Step 3: Consume the transformed data over MQTT (Optional)
Set up an MQTT publisher using the Analytics MQTT API. The data source key is the output topic name with a #channelID suffix.
Remember to replace <servername>, <username> and <password> where applicable.
- curl
- HTTP
curl --request POST \
--anyauth \
--user "<username>:<password>" \
--header "Accept: application/json" \
--header "Content-Type: application/json" \
"http://<servername>/config/rest/analytics-mqtt/v1/publishers" \
--data '{
"data": {
"id": "humans_only_publisher",
"data_source_key": "com.axis.dt.humans_only#1",
"mqtt_topic": "my_humans_only"
}
}'
POST /config/rest/analytics-mqtt/v1/publishers
Host: <servername>
Accept: application/json
Content-Type: application/json
{
"data": {
"id": "humans_only_publisher",
"data_source_key": "com.axis.dt.humans_only#1",
"mqtt_topic": "my_humans_only"
}
}
201 Created
Content-Type: application/json
{
"status": "success",
"data": {
"id": "humans_only_publisher",
"data_source_key": "com.axis.dt.humans_only#1",
"mqtt_topic": "my_humans_only",
"qos": 0,
"retain": false,
"use_topic_prefix": false
}
}
The #1 suffix refers to the channel ID. On single-channel devices this is always 1. On multidirectional cameras, 2, 3, and 4 might also be available.
Step 4: Verify with statistics (Optional)
With a consumer active, check the statistics to confirm the transform is working correctly. Let it run under realistic conditions before drawing conclusions.
Remember to replace <servername>, <username> and <password> where applicable.
- curl
- HTTP
curl --request GET \
--anyauth \
--user "<username>:<password>" \
--header "Accept: application/json" \
"http://<servername>/config/rest/data-transformation/v1/transforms/com.axis.dt.humans_only/statistics"
GET /config/rest/data-transformation/v1/transforms/com.axis.dt.humans_only/statistics
Host: <servername>
Accept: application/json
200 OK
Content-Type: application/json
{
"status": "success",
"data": {
"msgInCount": 1200,
"msgOutCount": 1200,
"byteInCount": 845000,
"byteOutCount": 580000,
"avgMsgProcessingTimeNs": 4000,
"avgMsgsPerSec": 10.0,
"msgDroppedCount": 0,
"jqTransformationErrCount": 0,
"publishErrCount": 0,
"lastErrMsg": ""
}
}
If nothing is consuming the output topic — no MQTT publisher configured — all counters will be zero.
A healthy transform has:
msgDroppedCountat 0 — no messages are being droppedjqTransformationErrCountat 0 — the expression handles all messagespublishErrCountat 0 — all transformed messages are published
msgOutCount should equal msgInCount here — every frame is forwarded, just with non-human detections stripped. byteOutCount will be lower than byteInCount because the payloads are smaller.
If msgOutCount > msgInCount, your expression is emitting multiple outputs per message. See Tips for writing jq expressions.
If any error count is increasing, see chapter Statistics in the module reference.
Test under a representative load, not just a quiet scene. See Performance consideration for what can go wrong.
Or use the Swagger UI for /data-transformation/v1/transforms/{outputTopic}/statistics:
http://<servername>/config/web-ui/swagger-ui/?url=/config/discover/apis/data-transformation/v1/openapi.json
Step 5: Remove a transform (Optional)
If you no longer need the transform, remove it.
Remember to replace <servername>, <username> and <password> where applicable.
- curl
- HTTP
curl --request DELETE \
--anyauth \
--user "<username>:<password>" \
--header "Accept: application/json" \
"http://<servername>/config/rest/data-transformation/v1/transforms/com.axis.dt.humans_only"
DELETE /config/rest/data-transformation/v1/transforms/com.axis.dt.humans_only
Host: <servername>
Accept: application/json
200 OK
Content-Type: application/json
{
"status": "success"
}
Or use the Swagger UI for /data-transformation/v1/transforms/{outputTopic}:
http://<servername>/config/web-ui/swagger-ui/?url=/config/discover/apis/data-transformation/v1/openapi.json
Example jq expressions
Ready-to-use expressions for each scene metadata topic. Refer to the schema references for all available fields.
Frame (com.axis.scene.frame.v1)
See the Frame schema for all available fields.
- Track events only
- Only classified objects
- Humans only
- Bounding boxes only
- Numeric timestamp
Drop detections, keep track_events and all other fields:
del(.detections)
Remove detections that have no classification from the detections array. Every frame is forwarded — frames where no detection has a classification arrive with an empty array:
.detections |= (. // [] | map(select(.class.type != null)))
Remove non-human entries from the detections array. Every frame is forwarded — frames with no humans, or no detections field, arrive with an empty array:
.detections |= (. // [] | map(select(.class.type == "Human")))
Reduce each detection to object_track_id and bounding_box, keep all other frame fields:
.detections |= (. // [] | map({object_track_id, bounding_box}))
Add seconds (Unix epoch) and microseconds fields derived from the ISO 8601 timestamp:
(.timestamp | rtrimstr("Z") | split(".")) as $parts
| .seconds = ($parts[0] | strptime("%Y-%m-%dT%H:%M:%S") | mktime)
| .microseconds = (($parts[1] // "0") | tonumber)
Object Track (com.axis.scene.object_track.v1)
Object Tracks are emitted once per tracked object when tracking ends. See the Object Track schema for all available fields.
- Strip path and image
- Top classification only
- Minimum duration
Drop path (trajectory samples) and image (embedded base64 snapshot) — the two largest fields — keep classification and timing summary:
del(.path, .image)
Keep only the highest-scoring classification, dropping the rest. The classes array is already ordered most-likely first:
del(.classes[1:])
Drop tracks shorter than 1 second to filter out spurious detections. Adjust the threshold to match your use case:
select(.duration >= 1.0)
Object Snapshot (com.axis.scene.object_snapshot.v1)
Object Snapshots are emitted when a representative image of a tracked object is captured. See the Object Snapshot schema for all available fields.
- Strip image data
Drop data (base64-encoded image bytes — typically over 99% of message size), keep all metadata:
del(.data)
Tips for writing jq expressions
Filtering the detections array
Scene metadata messages contain arrays like detections and classes. To filter elements within an array, use map() — it produces exactly one output message per input message:
.detections |= (. // [] | map(select(.class.type == "Human")))
Be aware that .detections[] is an iterator — using it directly inside a condition causes the expression to run once per element and emit one output per match. A frame with three Human detections would emit three copies of the frame. msgOutCount > msgInCount in the statistics indicates this phenomenon.
If you instead want to drop the whole message when no element matches, use any():
select(any(.detections[]?; .class.type == "Human"))
Dropping heavy fields
Object Tracks include path (trajectory samples) and image (base64 snapshot). Object Snapshots include data (base64 image). These fields dominate message size. Use del() to remove them while keeping everything else:
del(.path, .image)
del() and |= preserve all fields you don't touch, including timestamp and any new fields added to the schema in the future. Building a message with {field1, field2, ...} only keeps what you list explicitly.
Preserving key fields
Operators like del() and |= preserve all fields you don't explicitly remove, including key fields like channel_id. If you construct the output manually with {field1, field2}, remember to include them:
{channel_id, my_field: .some.nested.value}