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.
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 capability | Decision | Reason |
|---|---|---|
| Java camera acquisition | Keep it as the source of frames and live H.264 viewing. | The camera lifecycle, streaming and operator view were already operational. |
| Python ML/CV | Run it as a separate process. | Python keeps its own packages, native libraries and model release cadence. |
| ZeroMQ | Use one channel for frames and another for result events. | The transport is lightweight, local-network friendly and language-neutral. |
| Event Manager | Own the transition from observation to command. | Conditions and actions remain inspectable, testable and replaceable. |
| MQTT | Carry 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.
Follow the frame from camera acquisition to the headlights
| Stage | What happens | Operational boundary |
|---|---|---|
| 1. Capture and live view | The 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 delivery | A 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. Inference | Python 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 ingestion | A 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. Decision | Event 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. Actuation | The 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?
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.
| Channel | Prototype contract | Production extension |
|---|---|---|
| Java → Python | One JPEG byte array per selected frame. | Add source ID, capture timestamp, sequence number and encoding metadata in a small envelope or multipart message. |
| Python → Java | MOTION or NO_MOTION. | Add schema version, event ID, source ID, model version, event time, confidence and optional evidence reference. |
| Java → MQTT | LIGHT_ON or LIGHT_OFF on the configured topic. | Use a desired-state schema, correlation ID, expiry, QoS policy and an acknowledged device-state topic. |
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.
Harden the boundaries before the model is allowed to affect equipment
Backpressure
Sample frames deliberately, cap queues and expose processing lag. Prefer current observations over an ever-growing backlog.
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.
Idempotency
Give commands and events identifiers. Consumers should tolerate duplicates and reject expired desired-state messages.
Security
Keep ZeroMQ on a trusted interface or add authenticated encryption. Protect MQTT with TLS and scoped credentials; never expose control topics anonymously.
Interlocks
Start in observation mode. Put rate limits, manual override and equipment-specific safety rules on the device side.
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.
Reproduce the pattern in small, testable increments
- Choose a harmless output. Begin with an LED, dashboard indicator or notification. Do not begin with motion, heat, access control or another consequential actuator.
- Prove acquisition independently. Configure a USB, RTSP or ONVIF camera and confirm that live video remains usable without the Python process.
- Create the frame channel. Use a ZeroMQ Socket and an explicit frame sender. Limit the resolution and rate before measuring Python throughput.
- Run the reference service. Install Python, PyZMQ, NumPy and OpenCV in an isolated environment. Verify
MOTION/NO_MOTIONoutput with no actuator connected. - Ingest results. Use a ZeroMQ Socket Listener to convert Python output into Banalytics events.
- Add an explainable rule. Configure Event Manager with separate ON and OFF branches, cooldowns and a visible disabled state.
- Add MQTT last. Connect an MQTT v3 Connector or local broker and publish to a dedicated test topic before attaching hardware.
- 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.
- 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.