Getting Started Cameras & Video Detection & Recording Automation & Events Actions Integration & Connectivity Network & Discovery AI & Remote Control MQTT Modbus Pi4J & Raspberry Pi GPIO ZeroMQ System & Administration Comparisons Use Cases Troubleshooting About & Legal
Home / Documentation / Integrating Python ML into a Java Control System
White paper Makers & DIY 14 min read

Integrating Python ML into a Java Control System Without Rewriting the Stack

A field-tested pattern for moving camera frames through Python inference and turning results into explainable MQTT actions.

Machine-learning demos often stop at a bounding box or a notebook. This build connects inference to a physical result: a Java-based Banalytics node captures video, a separate Python/OpenCV process detects motion, Event Manager decides what the event means, and MQTT switches the headlights of an RC car.

Based on the original Banalytics lab note published on Reddit. The diagrams, dashboard captures, hardware photographs and videos below come from that working prototype.


Keep acquisition, inference, decisions and actuation as separate responsibilities

The prototype began with three independent assets: an existing Java control environment, a Python computer-vision service, and an RC car whose front lights could be controlled over MQTT. Replacing the Java runtime with a Python application would have discarded mature camera, event, dashboard and device-integration capabilities. Embedding CPython into the JVM would have coupled dependency lifecycles and made failures harder to isolate.

The selected design treats Python as a local processing module with a narrow message contract. JPEG frames travel from Java to Python over ZeroMQ. Python returns compact semantic events such as MOTION and NO_MOTION. Banalytics receives those events, evaluates an explicit rule, and publishes the requested state through MQTT. The device-side Agent then converts the MQTT state into a GPIO or controller action.

CameraJPEG→ ZeroMQ →Python / OpenCV→ ZeroMQ →Event Manager→ MQTT →RC car lights

The architectural result

  • The Java and Python runtimes can be deployed, restarted and upgraded independently.
  • The model can change without rewriting acquisition, rule or device-control logic.
  • The decision remains visible in Event Manager instead of being hidden inside model code.
  • MQTT gives the physical device a stable command contract that is independent of the inference implementation.

Integrate around the existing control system rather than replacing it

Existing capabilityDecisionReason
Java camera acquisitionKeep it as the source of frames and live H.264 viewing.The camera lifecycle, streaming and operator view were already operational.
Python ML/CVRun it as a separate process.Python keeps its own packages, native libraries and model release cadence.
ZeroMQUse one channel for frames and another for result events.The transport is lightweight, local-network friendly and language-neutral.
Event ManagerOwn the transition from observation to command.Conditions and actions remain inspectable, testable and replaceable.
MQTTCarry the final device state.The RC car consumes a small, stable topic contract instead of knowing anything about Python.

The same pattern works when the Python side contains YOLO, segmentation, OCR, anomaly detection or a custom scientific model. The model is not allowed to become the whole application; it remains one replaceable stage in a larger operational pipeline.

Two compute nodes, three protocol boundaries and one physical outcome

The industrial workstation runs camera acquisition, JPEG conversion, ZeroMQ exchange, Event Manager and the MQTT publisher. A Python process on the same workstation subscribes to frames and publishes motion-state events. The RC car carries another Banalytics Agent, subscribes to the selected MQTT topic and applies the resulting ON/OFF state to its lighting hardware.

The demonstrator used a RabbitMQ MQTT endpoint between the two Agents. That broker placement is not a platform requirement: a DIY build can use the embedded MQTT Server or another broker on the local network. What matters is the topic and payload contract, not where the broker runs.

Architecture of a Banalytics Java control system exchanging JPEG frames and motion events with Python over ZeroMQ before sending MQTT commands to an RC car
Original prototype architecture. Steps 1–7 run on the workstation; steps 8–11 run through MQTT and the embedded controller on the RC car.

Follow the frame from camera acquisition to the headlights

StageWhat happensOperational boundary
1. Capture and live viewThe camera grabber continuously receives frames. When an operator connects, the same source can expose an H.264 stream for live viewing.Viewing remains independent of inference availability.
2–3. Frame deliveryA selected frame is converted to JPEG and published to the Python process through ZeroMQ.Rate, resolution and queue policy must prevent stale-frame buildup.
3–4. InferencePython decodes the JPEG, applies grayscale, blur, frame differencing and contour filtering, then publishes a state event.The result contract is semantic; downstream logic does not receive OpenCV objects.
4–5. Event ingestionA ZeroMQ listener receives MOTION or NO_MOTION and creates a Banalytics event.Transport parsing and event creation are observable separately from the model.
5–7. DecisionEvent Manager tests the event and selects the corresponding MQTT publish action.The action policy is visible and can be disabled without stopping inference.
8–11. ActuationThe RC car receives the MQTT state and its local rule invokes the ON or OFF lighting action.The device node owns the final hardware mapping and can enforce local interlocks.

Expose every boundary on the integration dashboard

The demonstrator dashboard places the frame sender, Python process, event receiver, Event Manager rules, MQTT publisher, remote subscriber and hardware actions in one operational view. The numbered annotations correspond to the architecture diagram. This makes it possible to ask a precise question when the lights do not react: was a frame sent, did Python emit an event, did the rule match, was MQTT published, and did the device-side action run?

Banalytics dashboard showing the camera, ZeroMQ frame sender, Python motion detector, event receiver, rules, MQTT bridge and RC car lighting action
Original integration dashboard. Components 1–7 belong to the workstation pipeline; components 8–11 represent the MQTT and physical-device side.

Send binary frames in one direction and small state events in the other

The frame channel carries JPEG bytes rather than Java or Python objects. This removes language-specific serialization and lets the receiver use numpy.frombuffer() followed by cv2.imdecode(). The result channel deliberately carries a much smaller vocabulary: the prototype emits MOTION on transition into the active state and NO_MOTION after a quiet interval.

ChannelPrototype contractProduction extension
Java → PythonOne JPEG byte array per selected frame.Add source ID, capture timestamp, sequence number and encoding metadata in a small envelope or multipart message.
Python → JavaMOTION or NO_MOTION.Add schema version, event ID, source ID, model version, event time, confidence and optional evidence reference.
Java → MQTTLIGHT_ON or LIGHT_OFF on the configured topic.Use a desired-state schema, correlation ID, expiry, QoS policy and an acknowledged device-state topic.
Do not build an unbounded video queue. For real-time control, the newest frame is usually more valuable than every historical frame. Set an explicit sampling rate and high-water mark, and drop or replace stale frames when inference falls behind.

A compact OpenCV motion detector with transition events

The original service subscribes to JPEG frames on port 5555 and publishes state transitions on port 5556. It uses frame differencing rather than a trained model, which keeps the example easy to reproduce while exercising the same process and protocol boundaries that a larger model would use.

import zmq
import numpy as np
import cv2
from datetime import datetime
import time

INPUT_ENDPOINT = "tcp://localhost:5555"  # input with JPEG
OUTPUT_ENDPOINT = "tcp://*:5556"         # sending events

context = zmq.Context()

# Receiver (JPEG frames)
receiver = context.socket(zmq.SUB)
receiver.connect(INPUT_ENDPOINT)
receiver.setsockopt_string(zmq.SUBSCRIBE, "")

# Sender (events)
sender = context.socket(zmq.PUB)
sender.bind(OUTPUT_ENDPOINT)

print(f"Listening on {INPUT_ENDPOINT}, sending events to {OUTPUT_ENDPOINT}")

prev_frame = None

CONTOUR_THRESHOLD = 3000
BLUR_SIZE = (5, 5)
THRESHOLD = 25

MOTION_COOLDOWN = 1.0
NO_MOTION_INTERVAL = 3.0

last_motion_time = 0
motion_active = False

while True:
    data = receiver.recv()

    np_arr = np.frombuffer(data, np.uint8)
    frame = cv2.imdecode(np_arr, cv2.IMREAD_COLOR)
    if frame is None:
        continue

    gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
    gray = cv2.GaussianBlur(gray, BLUR_SIZE, 0)

    if prev_frame is None:
        prev_frame = gray
        continue

    delta = cv2.absdiff(prev_frame, gray)
    thresh = cv2.threshold(delta, THRESHOLD, 255, cv2.THRESH_BINARY)[1]
    thresh = cv2.dilate(thresh, None, iterations=2)

    contours, _ = cv2.findContours(
        thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
    )

    motion = False
    max_area = 0

    for contour in contours:
        area = cv2.contourArea(contour)
        if area > CONTOUR_THRESHOLD:
            motion = True
            max_area = max(max_area, area)

    now_time = time.time()

    if motion:
        if not motion_active and now_time - last_motion_time > MOTION_COOLDOWN:
            timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
            message = "MOTION"
            sender.send_string(message)
            print(
                f"[{timestamp}] Motion Detected - "
                f"Zone size: {int(max_area)} px | Sent: {message}"
            )
            motion_active = True

        last_motion_time = now_time
    elif motion_active and now_time - last_motion_time > NO_MOTION_INTERVAL:
        timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
        message = "NO_MOTION"
        sender.send_string(message)
        print(f"[{timestamp}] No motion detected | Sent: {message}")
        motion_active = False

    prev_frame = gray

The cooldown prevents rapid repeated activation events. The quiet interval provides hysteresis before the inactive state is announced. For a detector that operates per camera or per zone, keep this state separately for every source key rather than sharing one prev_frame and one timer.

The device side remains a normal MQTT-controlled embedded system

The RC platform contains its own embedded computer, Banalytics Agent, MQTT subscriber and lighting actions. That separation is important: Python requests a semantic state, but the car-side node decides how that state maps to Arduino, Modbus or GPIO hardware. A local rule can also reject a command when the system is in manual mode, the connection is stale or another interlock is active.

Open RC car chassis with embedded controller, wiring and radio hardware used in the Python ML control prototype
Controller, power wiring and radio hardware inside the RC platform.
RC car with front lighting used as the physical output of a Python motion detection and MQTT automation pipeline
The assembled platform used to verify the final physical action.
Original assembly test showing the dashboard-driven lighting response on the RC car.

Harden the boundaries before the model is allowed to affect equipment

01

Backpressure

Sample frames deliberately, cap queues and expose processing lag. Prefer current observations over an ever-growing backlog.

02

Health

Publish process readiness, last-frame time, inference duration, last-event time and model identity. Treat silence as a state, not proof that nothing happened.

03

Idempotency

Give commands and events identifiers. Consumers should tolerate duplicates and reject expired desired-state messages.

04

Security

Keep ZeroMQ on a trusted interface or add authenticated encryption. Protect MQTT with TLS and scoped credentials; never expose control topics anonymously.

05

Interlocks

Start in observation mode. Put rate limits, manual override and equipment-specific safety rules on the device side.

06

Versioning

Include schema and model versions in results. Roll out a new model in shadow mode and compare its events before switching control authority.

ZeroMQ PUB/SUB is intentionally simple and does not by itself provide durable delivery or a complete control audit. MQTT can provide QoS and retained state, but QoS is not a substitute for command expiry, acknowledgement and hardware feedback. Choose semantics according to the physical consequence of a missed, duplicated or delayed message.

Safety boundary: the motion detector and RC-car lighting build are a demonstrator, not a safety-rated control system. Do not use this pattern to bypass a PLC, emergency stop, certified guard, vehicle controller or vendor safety function.

Reproduce the pattern in small, testable increments

  1. Choose a harmless output. Begin with an LED, dashboard indicator or notification. Do not begin with motion, heat, access control or another consequential actuator.
  2. Prove acquisition independently. Configure a USB, RTSP or ONVIF camera and confirm that live video remains usable without the Python process.
  3. Create the frame channel. Use a ZeroMQ Socket and an explicit frame sender. Limit the resolution and rate before measuring Python throughput.
  4. Run the reference service. Install Python, PyZMQ, NumPy and OpenCV in an isolated environment. Verify MOTION/NO_MOTION output with no actuator connected.
  5. Ingest results. Use a ZeroMQ Socket Listener to convert Python output into Banalytics events.
  6. Add an explainable rule. Configure Event Manager with separate ON and OFF branches, cooldowns and a visible disabled state.
  7. Add MQTT last. Connect an MQTT v3 Connector or local broker and publish to a dedicated test topic before attaching hardware.
  8. Commission the device side. Map the topic to a low-risk output through Pi4J, Arduino, Modbus or another controlled interface. Verify manual override and stale-command behaviour.
  9. Introduce failure tests. Stop Python, disconnect MQTT, delay frames, restart either Agent and send duplicate events. The resulting state should be observable and safe.

A practical starter bill of materials is deliberately modest: one PC or mini PC for Banalytics and Python, a USB or network camera, a local MQTT broker, and a low-voltage LED output on a Raspberry Pi or microcontroller. The RC platform is useful for demonstration, but the integration pattern can be proven on a desk before it is mobile.

Replace the model without changing the control contract

Motion detection is only one implementation of the Python stage. A later service can run YOLO, segmentation, pose estimation, OCR, acoustic analysis or a domain-specific model while continuing to publish the same versioned event schema. The Java side remains responsible for camera lifecycle, orchestration, operator visibility and device integration. The physical node remains responsible for its own hardware mapping and interlocks.

This division also helps a small DIY project grow into a multi-device lab. Frame processors can move to a GPU workstation, multiple Agents can publish through MQTT, and dashboards can aggregate events without requiring model code to learn the details of every camera and actuator. The boundaries are the reusable asset.

Build the pipeline one observable boundary at a time

Start with a camera, a Python process and a harmless MQTT output, then add control authority only after failure behaviour is understood.