AI inference sidecars¶
stanag4609.sidecar is a first-party, dependency-free integration boundary for
decoded video frames, local models, remote inference services, and ST 0903 VMTI
output. Model runtimes remain optional; the timing and metadata contracts do
not depend on NumPy, PyTorch, CUDA, or a particular video decoder.
Core model¶
FrameEnvelopecarries a decoded frame handle, dimensions, MPEG PTS, optional UTC microseconds, program/video PID identity, and the KLV packets correlated with that frame.FrameMetadataCorrelatorattaches synchronous KLV using per-program 33-bit PTS timelines, rollover-safe exact/latest/nearest policies, a configurable sampling offset, and a bounded packet cache. It can derive frame UTC from a correlated MISB Precision Time Stamp without floating-point conversion.PyAVFrameSourceoptionally decodes common FFmpeg-supported video into BGR arrays or native frames while preserving transport-clock timing.AsyncFrameQueuebounds decoded-frame memory and latency with explicit block, drop-oldest, drop-newest, or raise-on-overload policies. Every enqueue reports the exact dropped frame and cumulative accepted/drop counters.PixelBoundingBoxuses the familiar zero-based, half-open[left, top, right, bottom)convention.Detectionis a model-neutral object detection with a stable target ID, confidence, optional label and algorithm ID, and VMTI lifecycle status.InferenceStageadapts a normal function, a function isolated in a worker thread, or an async service call.SequentialandParallelcompose nested processing graphs. Parallel branches receive the same immutable context, concurrency is bounded, and results merge in declaration order regardless of completion order.encode_embedded_vmticonverts detections to an ST 0903.6 VMTI Local Set for ST 0601 Item 74, including the explicit conversion to one-based row-major pixel numbering.
Runnable, dependency-free examples live in
examples/ai_sidecar:
pipeline_graph.py shows
nested sequential/parallel execution, correlate_frame.py joins a decoded
frame to synchronous KLV, decode_video.py turns PyAV output into public frame
envelopes, queue_frames.py demonstrates low-latency overload
handling, and ontology_vmti.py shows a labeled detection becoming Algorithm,
Ontology, and VObject metadata. ultralytics_yolo.py is the optional packaged
YOLO adapter example; onnx_runtime.py demonstrates the model-specific hooks
used by the packaged ONNX Runtime adapter, triton_async.py constructs a
cancellable remote inference stage, and http_json.py adapts an ordinary
bounded JSON endpoint. emit_vmti_packet.py closes the loop from a named
inference result to timed ST 0601.
Decode video into frame envelopes¶
Install the optional backend and iterate a file, URL, or file-like object:
from stanag4609.sidecar import PyAVFrameSource
for frame in PyAVFrameSource("flight.ts", video_pid=0x101):
# BGR pixels by default; PTS is always on the 90 kHz transport clock.
model_input = frame.pixels
The adapter selects video stream zero by default and accepts video_stream=N
for other tracks. Set pixel_format=None to retain native PyAV VideoFrame
objects, or request a different FFmpeg pixel format for model-specific
preprocessing. Native timestamps are rescaled with exact rational arithmetic
and wrapped into the unsigned 33-bit MPEG PTS domain. Absolute UTC is not
guessed from media-relative PTS; correlate synchronous KLV when it is needed.
Correlate KLV with decoded frames¶
from stanag4609.sidecar import CorrelationMode, FrameMetadataCorrelator
correlator = FrameMetadataCorrelator(
mode=CorrelationMode.LATEST,
maximum_delta_ticks=63_000, # 0.7 seconds on the 90 kHz PTS clock
metadata_pts_offset_ticks=0,
max_packets=1024,
)
for packet in transform_batch.metadata:
correlator.observe(packet)
frame = correlator.correlate(decoded_frame_envelope)
EXACT requires equal effective PTS values. LATEST follows ST 1402's rule
that a synchronous metadata PTS signals when the access unit becomes relevant.
NEAREST is useful only when an integration has a known bounded sampling
offset. A tie selects the earlier relevance time. All policies isolate PTS
epochs by program and handle the 33-bit rollover.
Asynchronous KLV is counted by the correlator but never attached automatically: ST 1402 explicitly says that its synchronization through decoding cannot be guaranteed. An application may present asynchronous packets as an independent metadata feed, or supply its own documented timestamp-association policy.
Segmentation models can emit standards-native VMaskLocalSet values using a
clockwise row-major pixel_contour, a tuple of PixelRun values, or both.
validate_for_frame(width, height) checks contour direction and every pixel
bound before the mask is placed in VTarget Item 101. Converting a model's
bitmap or polygon tensor to that model-neutral representation remains explicit
application post-processing because thresholding and contour simplification
change the semantic result.
Bound live inference latency¶
from stanag4609.sidecar import AsyncFrameQueue, FrameOverflowPolicy
frames = AsyncFrameQueue(
capacity=4,
policy=FrameOverflowPolicy.DROP_OLDEST,
)
result = await frames.put(correlated_frame)
if result.dropped is not None:
metrics.increment("frames_dropped", sequence=result.dropped.sequence_number)
frame = await frames.get()
try:
inference_result = await graph.run(InferenceContext(frame))
finally:
frames.task_done()
Use BLOCK when every frame must be processed and upstream backpressure is
safe. DROP_OLDEST keeps a live display close to real time; DROP_NEWEST
finishes already-queued work; RAISE makes overload explicit for archival or
test pipelines. The queue never silently grows beyond its declared capacity.
Observe inference health and latency¶
Share one bounded InferenceMetrics collector across local, HTTP, Triton, and
nested graph stages:
from stanag4609.sidecar import InferenceMetrics, InferenceStage
metrics = InferenceMetrics(max_stages=64)
detector = InferenceStage("vehicle-detector", model, metrics=metrics)
remote = InferenceStage(
"remote-classifier",
service,
timeout_seconds=0.2,
metrics=metrics,
)
for stage in metrics.snapshot():
export_gauge("inference_in_flight", stage.in_flight, stage=stage.stage)
export_counter("inference_failed", stage.failed, stage=stage.stage)
export_counter("inference_timed_out", stage.timed_out, stage=stage.stage)
export_counter("inference_cancelled", stage.cancelled, stage=stage.stage)
export_counter("inference_duration_seconds", stage.total_duration_seconds,
stage=stage.stage)
Snapshots are immutable and stage-name sorted. The collector records starts, successes, failures, timeouts, cancellations, in-flight work, total/max/last monotonic duration, and only the last exception's type name. It never retains frames, metadata, inference payloads, exception messages, or tracebacks, and its stage cardinality is explicitly bounded. The collector is thread-safe so the same instance can span concurrent branches and worker-thread stages.
Bring a local model¶
Install the optional adapter dependency without changing the core install:
Pass an already-created model to the first-party adapter. It consumes the
documented Results.boxes.xyxy, conf, cls, and optional tracker id
arrays, clips coordinates to the frame, and converts them to the model-neutral
contract:
from ultralytics import YOLO
from stanag4609.sidecar import InferenceStage, UltralyticsYOLODetector
model = YOLO("your-vehicle-model.pt")
detector = UltralyticsYOLODetector(
model,
algorithm_id=1,
mode="track",
predict_kwargs={"conf": 0.35, "iou": 0.6, "tracker": "bytetrack.yaml"},
)
vehicle_stage = InferenceStage("vehicle-detector", detector, threaded=True)
The adapter uses floor for left/top and ceil for right/bottom so fractional
model boxes retain all covered pixels. In track mode it calls Ultralytics
tracking with persist=True by default; keep ordered frames on the same adapter
instance. The configurable track_id_offset (default 1) maps Ultralytics'
zero-based tracker domain into ST 0903's positive target IDs. In predict mode,
or before a tracker confirms a track, result order is used only as a per-frame
identifier and must not be described as persistent identity.
The adapter follows the official
Ultralytics prediction and
tracking result contracts
and accepts duck-typed result objects in tests, so importing stanag4609 never
imports Ultralytics or PyTorch.
Bring any JSON inference service¶
HTTPJSONAdapter turns an ordinary HTTP or HTTPS JSON endpoint into an async
inference processor without adding an HTTP dependency. The two hooks make the
wire schema explicit: one serializes the frame and earlier graph results, and
the other converts service output to InferenceOutput.
from stanag4609.sidecar import HTTPJSONAdapter, InferenceStage
adapter = HTTPJSONAdapter(
"https://inference.example/v1/vehicles",
request_encoder=encode_frame_as_service_json,
response_decoder=decode_service_json,
headers={"Authorization": f"Bearer {token}"},
timeout_seconds=0.5,
max_request_bytes=4 * 1024 * 1024,
max_response_bytes=512 * 1024,
)
http_stage = InferenceStage("http-vehicles", adapter, timeout_seconds=0.6)
The adapter emits UTF-8 application/json, bounds both request and response,
checks declared and actual response sizes, rejects non-success status codes,
and runs blocking standard-library I/O outside the event loop. The endpoint,
credentials, TLS trust, retry policy, and model schema remain application
configuration. Keep the stage timeout slightly larger than the adapter's
socket timeout. For high-throughput connection pooling, streaming tensors, or
shared memory, use Triton or write an async processor around the organization's
chosen HTTP client.
Bring a Triton inference service¶
Install either transport used by NVIDIA's official AsyncIO clients:
The same adapter accepts the gRPC or HTTP AsyncIO client because both expose an
awaitable infer() method. Tensor construction and output decoding remain
explicit because they belong to the deployed model contract, not to STANAG
4609:
from stanag4609.sidecar import InferenceStage, TritonAsyncAdapter
adapter = TritonAsyncAdapter(
triton_async_client,
model_name="vehicles",
input_builder=build_triton_inputs,
output_decoder=decode_triton_response,
requested_outputs=(boxes_output, scores_output),
request_id_builder=lambda context: str(context.frame.sequence_number),
)
triton_stage = InferenceStage("triton-vehicles", adapter, timeout_seconds=0.150)
Reserved request fields are first-class adapter arguments. The shared Triton
request timeout is timeout; gRPC additionally accepts client_timeout.
Headers, transport-specific compression, sequence flags, and parameters pass
through infer_kwargs. Unsupported options are deliberately left for the
installed client to reject rather than silently translated. Stage timeout
cancellation propagates through the awaited Triton request. NVIDIA's official
clients provide HTTP and gRPC APIs plus shared-memory transports; those objects
remain on the adapter side of the boundary. See the
Triton client documentation.
Sequential and parallel models¶
Graphs may be nested. In this example the two detectors run concurrently, then fusion and tracking consume both results in a deterministic order.
from stanag4609.sidecar import InferenceContext, Parallel, Sequential
graph = Sequential(
Parallel(
vehicle_stage,
triton_stage,
max_concurrency=2,
),
fusion_stage,
tracker_stage,
)
context = await graph.run(InferenceContext(frame))
tracked = context.result("tracker")
ONNX Runtime is particularly relevant because its stable InferenceSession
API selects hardware-specific execution providers without coupling the FMV
pipeline to one framework. See the
official ONNX Runtime Python API.
Run an ONNX model¶
ONNX standardizes model graphs and tensors, but not object-detection tensor layouts, image normalization, non-maximum suppression, or class catalogs. The first-party adapter therefore owns session invocation and input-name validation while requiring explicit model-specific hooks:
import onnxruntime as ort
from stanag4609.sidecar import InferenceStage, OnnxRuntimeAdapter
session = ort.InferenceSession(
"vehicle-detector.onnx",
providers=["CUDAExecutionProvider", "CPUExecutionProvider"],
)
adapter = OnnxRuntimeAdapter(
session,
input_builder=preprocess_frame_to_named_tensors,
output_decoder=decode_model_outputs_to_inference_output,
)
onnx_stage = InferenceStage("onnx-vehicles", adapter, threaded=True)
Install the CPU runtime with pip install 'stanag4609[ai-onnx]'. GPU users can
install the appropriate ONNX Runtime package and execution provider themselves,
then install the base stanag4609 package without that extra. output_names
can limit session outputs; input-name validation is enabled by default and can
be disabled only for unusual session wrappers. The adapter uses the official
InferenceSession.run() contract
without importing ONNX Runtime or NumPy from the core package.
Convert detections to VMTI¶
The high-level emitter selects a named graph result and creates a
TimedKLVPacket at the decoded frame's PTS:
from stanag4609.sidecar import VMTIMetadataEmitter
emitter = VMTIMetadataEmitter(
"tracker",
metadata_pid=0x120,
leap_seconds=mission_leap_seconds,
algorithms=algorithms,
ontologies=ontologies,
ontology_by_label={"truck": 12},
)
packet = emitter(context)
batch = transformer.emit_metadata(packet)
transport_sink.write(batch.transport)
When context.frame.metadata contains exactly one matching synchronous ST 0601
packet, its PID/service identity and every unrelated or unknown field are
preserved while the Precision Time Stamp and Item 74 are refreshed for the
frame. metadata_pid may disambiguate multiple correlated parents. With no
parent, the emitter requires that PID and builds a minimal ST 0601 packet on the
already-declared stream. A frame UTC timestamp is always required. Its
leap-second offset must come from leap_seconds or a correlated parent's Item
136; the emitter refuses to write UTC/POSIX microseconds directly into MISP
Item 2. Parent Item 137 is retained and included in the inverse conversion.
The lower-level conversion API remains available when an application needs to control parent mutation itself:
from datetime import datetime, timedelta, timezone
from stanag4609 import (
AlgorithmLocalSet,
FieldDecodingMode,
OntologyLocalSet,
ST0601ValidationContext,
UASLocalSet,
VMTIValidationContext,
update_uas_local_set,
utc_to_misp_timestamp,
)
from stanag4609.sidecar import encode_embedded_vmti
output = context.result("tracker")
algorithms = (
AlgorithmLocalSet(7, "vehicle-detector", "1.0", "detector", 1),
)
ontologies = (
OntologyLocalSet(
ontology_id=12,
ontology_iri="https://example.org/fmv-objects.owl",
entity_iri="https://example.org/fmv-objects.owl#Truck",
label="truck",
),
)
parent = next(
packet for packet in frame.metadata if isinstance(packet.decoded, UASLocalSet)
)
leap_seconds = parent.decoded.value(136)
correction_offset = parent.decoded.value(137, 0)
frame_utc = datetime(1970, 1, 1, tzinfo=timezone.utc) + timedelta(
microseconds=frame.timestamp_microseconds
)
frame_misp_microseconds = utc_to_misp_timestamp(
frame_utc,
leap_seconds=leap_seconds,
correction_offset=correction_offset,
)
vmti = encode_embedded_vmti(
frame,
output.detections,
system_name="vehicle-pipeline",
source_sensor="EO Nose",
algorithms=algorithms,
ontologies=ontologies,
ontology_by_label={"truck": 12},
leap_seconds=leap_seconds,
correction_offset=correction_offset,
)
updated_bytes = update_uas_local_set(
parent.decoded,
{2: frame_misp_microseconds, 74: vmti},
field_decoding=FieldDecodingMode.PRESERVE,
context=ST0601ValidationContext(
metadata_birth_timestamp=frame_misp_microseconds,
vmti_context=VMTIValidationContext(
vmti_frame_timestamp=frame_misp_microseconds,
frame_width=frame.width,
frame_height=frame.height,
),
),
)
updated_packet = parent.with_bytes(updated_bytes)
batch = transformer.emit_metadata(updated_packet)
transport_sink.write(batch.transport)
Supplying the frame facts as external context makes the relay prove that the parent ST 0601 Item 2 represents the metadata time of birth and that Item 74 describes the same image dimensions and instant. The ST 0601 bridge derives the VMTI parent timestamp from Item 2. A mismatch fails the update instead of silently associating new AI results with the wrong video instant.
Every Detection.algorithm_id must resolve to an AlgorithmLocalSet in the
same VMTI packet. Likewise, ontology_by_label maps a convenient model label
to an OntologyLocalSet; the bridge emits a standards-native VObject rather
than smuggling the label into an arbitrary text field. The core validates
packet-local references without fetching an ontology. Verifying that an IRI
identifies OWL content and that its entity and label match is available through
an application-supplied resolver.
Validate ontology semantics without hidden network access¶
Implement OntologyResolver when a producer or receiver has a trusted local
vocabulary, ontology cache, database, or controlled ontology service. The KLV
codec never performs network I/O itself:
from stanag4609 import (
OntologyEntityResolution,
VMTIValidationContext,
decode_vmti_local_set,
)
class VehicleOntologyResolver:
def resolve_entity(self, ontology_iri: str, entity_iri: str):
known = {
(
"https://example.org/fmv-objects.owl",
"https://example.org/fmv-objects.owl#Truck",
): OntologyEntityResolution(
ontology_iri="https://example.org/fmv-objects.owl",
entity_iri="https://example.org/fmv-objects.owl#Truck",
is_owl_ontology=True,
rdfs_labels=frozenset({"truck"}),
skos_preferred_labels=frozenset({"cargo truck"}),
)
}
return known.get((ontology_iri, entity_iri))
vmti = decode_vmti_local_set(
vmti_bytes,
standalone=False,
context=VMTIValidationContext(
ontology_resolver=VehicleOntologyResolver(),
),
)
For every Ontology Local Set, the resolver proves that the referenced document
is an OWL ontology and the entity belongs to it. When Item 6 supplies a label,
the validator requires an exact, case-sensitive match against either the
resolved rdfs:label or skos:prefLabel. Returning None rejects an unknown
entity. Omitting the resolver preserves structural, offline decoding.
Validate frame and parent context¶
Some ST 0903 requirements depend on facts outside the VMTI Local Set itself. Pass those facts explicitly when encoding or decoding instead of relying on ambient state:
from stanag4609 import VMTIValidationContext, decode_vmti_local_set
validation = VMTIValidationContext(
vmti_frame_timestamp=frame.utc_microseconds,
parent_timestamp=parent_timestamp,
frame_period_microseconds=1_000_000 / frame_rate,
frame_width=frame.width,
frame_height=frame.height,
total_targets_detected=len(all_model_detections),
different_image_source=False,
)
vmti = decode_vmti_local_set(
vmti_bytes,
standalone=False,
context=validation,
)
The context checks timestamp agreement, conditional embedded timestamps, whether a culled target subset needs the total target count, pixel-number frame-width declarations, declared frame dimensions, the two-frame limit for parent-relative offsets, and the FOV/MIIS fields required when VMTI describes a different image source. When decoding ST 0601 Item 74, applications can re-decode that item's retained bytes with this context once the video-frame and parent timing facts are known.
Resolve an embedded target's geospatial position with the parent frame center:
from stanag4609 import resolve_vtarget_location
for target in vmti.targets:
location = resolve_vtarget_location(
target,
frame_center_latitude=uas.value(23),
frame_center_longitude=uas.value(24),
)
if location is not None:
publish_detection(target.target_id, location.latitude, location.longitude)
Absolute VTarget Location Item 17 wins when present; otherwise the resolver adds embedded Items 10 and 11 to the parent ST 0601 frame center and retains Item 12 as HAE.
An inference or geolocation stage can attach a known absolute WGS-84 position directly to the model-neutral detection before VMTI emission:
from stanag4609 import Location
from stanag4609.sidecar import Detection, PixelBoundingBox
detection = Detection(
target_id=42,
bounding_box=PixelBoundingBox(100, 200, 301, 401),
confidence=0.965,
label="truck",
algorithm_id=3,
location=Location(49.2827, -123.1207, 112.0),
)
VMTIMetadataEmitter writes the complete Location as VTarget Item 17. The
player and GeoJSON adapter then expose the same latitude, longitude, HAE, and
location source. A pixel bounding box alone does not determine a ground
position: use a sensor-specific ray/terrain intersection or other explicit
geolocator stage, and omit location when no defensible position is available.
Validate target lifecycles¶
Use one bounded state object per VMTI process/sensor stream to detect invalid
state transitions and target-ID reuse after Dropped:
from stanag4609 import VMTILifecycleState
lifecycle = VMTILifecycleState(max_target_ids=100_000)
for vmti in decoded_vmti_packets:
snapshot = lifecycle.observe(vmti)
for issue in snapshot.issues:
metrics.increment(issue.code, target_id=issue.target_id)
Targets omitted from a packet remain in history because ST 0903 permits a
producer to report only a subset of its target list. A target observed as
Dropped remains retired so later reuse is visible. Set
assume_stream_start=True only when the receiver knows it began at the
producer's lifecycle boundary; otherwise a first observation of Inactive or
Coasting is valid for a mid-stream join.
Live-system responsibilities still in progress¶
The graph API already provides bounded branch concurrency, timeouts, immutable inputs, deterministic results, a bounded synchronous PTS correlator, an explicit-overflow decoded-frame queue, optional PyAV video decoding, and cross-frame VMTI lifecycle checks. The optional Ultralytics adapter invokes prediction or persistent ByteTrack/BoT-SORT tracking and covers object-detection boxes and IDs, but not segmentation masks, pose, or oriented boxes. The reference player renders synchronized pixel bounding boxes and centroids in both recorded and bounded low-latency MSE modes. Sub-second WebRTC delivery, adaptive bitrate, and inference scheduling remain deployment concerns; applications should not infer those capabilities merely from the stable sidecar data model.