This document describes the backend/ service in full: purpose, architecture, how it integrates with Robo Hub and the frontend, and per-file / per-function documentation down to the function level. It follows the same format used previously for robo_hub so you can drop it into your repo as backend/README.md or module-level docs.
- Project overview & purpose
- Quickstart install & run
- High-level architecture & event flow
- Socket.IO events (frontend ↔ backend ↔ robo hub)
- Files & module-level documentation — function-level details
app.pyrobohub_client.pycamera/capture.pycamera/preprocess.pyocr/engine.pyocr/compare.pyutils/draw.pytest_workflow.pyrequirements.txt & backend_installation_packages.txt
- Integration, debugging & troubleshooting guide
- Design notes & suggested improvements
- Appendix — quick reference snippets
This backend/ repo is the OCR backend and proxy between the web frontend and the Robo Hub (which coordinates the UR robot). It:
- Provides a Flask + Flask-SocketIO server
(app.py)that accepts WebSocket connections from the frontend (HMI). - Runs an OCR pipeline (camera capture → preprocess → PaddleOCR via ocr.engine) to read component text on demand.
- Forwards job/trigger messages to the Robo Hub via a Socket.IO client
(robohub_client.py). - Forwards inventory updates received from Robo Hub to the frontend, and exposes control events for camera, benchmark, capture, and robot triggers.
Intended ports (convention used in your codebase):
-
robo_hub(Robo Hub server): 5002 -
backend(this server): 5001 -
frontend(Flask UI): 5000
- Create virtual environment (recommended):
python -m venv backend_env
# Windows
backend_env\Scripts\activate
# Linux / macOS
source backend_env/bin/activate
- Install dependencies:
pip install -r backend/requirements.txt
# or, if needed:
pip install -r requirements.txt
(If you use backend_installation_packages.txt, consult it for system-level packages such as libgl1, ffmpeg, or packages required by paddleocr.)
- Start Robo Hub first (the hub must be available for backend to connect):
# from robo_hub/ or wherever hub.py lives
python robo_hub/hub.py
# or
python app.py # if hub entrypoint is named app.py
- Start backend:
python backend/app.py
# Backend will attempt to connect to Robo Hub (http://127.0.0.1:5002) via RoboHubClient
- Start frontend:
# from frontend/ (if you run a separate frontend Flask)
python frontend/app.py
# Open http://127.0.0.1:5000/control
-
Frontend (HMI) connects via Socket.IO to Backend
(http://localhost:5001). -
Backend uses a
RoboHubClient(Socket.IO client) to connect to Robo Hub(http://localhost:5002. -
Robo Hub sends
update_inventoryevents (a mapping of PN → [t1,t2,t3,t4]) to any connected client (backend). -
Backend normalizes that inventory and emits update_inventory to the frontend with shape
{ inventory: [{ part_number, tray1, tray2, tray3, tray4}, ...] }. -
Frontend renders the table and also requests
get_inventoryon load. On refresh, the frontend should either (a) re-request inventory, or (b) backend emits inventory on new connections.
When the frontend requests actions (set_job, reset_inventory, send_trigger, etc.), the backend forwards (via robohub_client) to Robo Hub. Replies/acks come back through Robo Hub and are forwarded to the frontend.
Textual flow (simplified):
Frontend -> Backend (socket) -> RoboHub (socket) -> UR Robot (TCP)
UR -> RoboHub -> Backend (via RoboHubClient.sio.on('ack')) -> Frontend
RoboHub emits update_inventory -> Backend -> Frontend
This table summarizes the main events used by the system.
| Event name | Payload example | Purpose |
|---|---|---|
ping_test |
{msg: "Hello OCR backend!"} |
Keepalive / connectivity test |
get_inventory |
null |
Ask backend to request inventory from robo hub |
reset_inventory |
null |
Request Robo Hub to reset inventory to defaults |
set_job |
{ part_number: "EM1-2U1", quantity: 10 } |
Set a job (forward to Robo Hub) |
start_robot |
{ part_number: "EM1-2U1", quantity: 10 } (optional) |
Start robot / start program |
start_camera |
null |
Start local camera capture |
stop_camera |
null |
Stop camera feed |
set_benchmark |
{ part_number: "G8NB-17SR" } |
Move robot to benchmark + capture |
capture_component |
null |
Capture a component image + run OCR |
submitPartRequest or UI action |
{part_number, quantity} |
(UI convenience) |
| Event name | Payload example / shape | Purpose |
|---|---|---|
pong_test |
{ msg: "Pong from OCR backend!" } |
Ping response |
update_inventory |
{ inventory: [ {part_number, tray1, tray2, tray3, tray4}, ... ] } OR mapping {PN: [n1,n2,n3,n4], ...} |
Inventory updates; frontend expects inventory array form |
video_frame |
{ frame: "<base64 jpg data>" } |
Live JPEG frames from camera |
benchmark_set |
{ image: "<b64>", part_number: "...", texts: [...] } |
Benchmark capture result |
component_captured |
{ image: "<b64>", texts: [...], match: bool, score: float } |
OCR result for a captured component |
status |
{ msg: "..." } |
Misc status messages |
job_invalid |
{ part_number, requested, available } |
Invalid job (Robo Hub validation) |
reload_required |
{ part: "<PN>" } |
Notify HMI that trays are empty |
| Event name (emitted) | Payload example | Purpose |
|---|---|---|
send_trigger |
{ trigger: "D", event: "program_started", part_number: "..." } |
Ask Robo Hub to send letter to UR |
get_inventory |
null |
Ask Robo Hub to emit update_inventory |
reset_inventory |
null |
Ask Robo Hub to reset inventory |
set_job |
{ part_number: "...", quantity: N } |
Set job in Robo Hub |
Robo Hub → Backend emits update_inventory mapping |
{ PN: [n1,n2,n3,n4], ... } |
Backend uses this to emit to frontend |
Below are the files in backend/ with detailed documentation, function signatures, descriptions, parameters, returns and usage examples.
Purpose
Top-level Flask + Flask-SocketIO server. Hosts Socket.IO endpoints that the frontend connects to. Runs the OCR flow, camera capture, and forwards messages to/from Robo Hub via RoboHubClient.
Key components declared in app.py
- app — Flask application
- socketio — SocketIO server (flask_socketio.SocketIO)
- robohub_client — instance of RoboHubClient (connects to Robo Hub)
- camera — Camera() (from camera.capture)
- ocr_engine — OCREngine() (from ocr.engine)
- part_command_map — mapping PN → letter for UR triggers
- last_inventory — (optional) cache for last inventory mapping sent by Robo Hub
Important functions / Socket.IO handlers (function-level details)
live_feed_loop()
def live_feed_loop():
while _live_feed_running and camera.is_running:
frame = camera.get_frame()
if frame is None:
socketio.sleep(0.03); continue
processed = preprocess_for_ocr(frame)
_, jpeg = cv2.imencode(".jpg", processed)
b64_frame = base64.b64encode(jpeg.tobytes()).decode()
socketio.emit("video_frame", {"frame": b64_frame}, namespace="/")
socketio.sleep(0.03)
- Purpose: background loop to send live camera JPEG frames to all connected frontends.
- Side-effects: emits video_frame events frequently (≈30 fps if camera can).
- Notes: uses
socketio.sleepto cooperate with eventlet or other async workers.
start_live_feed() / stop_live_feed()
- start_live_feed starts the background greenlet/thread with
live_feed_loop. - stop_live_feed sets control flag to stop the loop.
run_capture_component()
- Captures a frame, runs OCR, finds best match to part_number, logs results, and emits component_captured.
- If match found, forwards START_PROGRAM trigger to Robo Hub.
- Uses robohub_client.send_part_code() and robohub_client.wait_for_ack() to synchronize robot movement.
| Handler | Signature | Purpose |
|---|---|---|
handle_start_camera |
@socketio.on("start_camera") |
Start camera + begin live_feed_loop. Emits status. |
handle_stop_camera |
@socketio.on("stop_camera") |
Stop camera/live feed. Emits status. |
handle_set_benchmark |
@socketio.on("set_benchmark") |
Ask Robo Hub to position robot for benchmark, capture frame, run OCR, emit benchmark_set. |
handle_capture_component |
@socketio.on("capture_component") |
Start run_capture_component in background. |
handle_set_job |
@socketio.on("set_job") |
Forward job to Robo Hub via robohub_client.sio.emit("set_job", {...}). |
handle_reset_inventory |
@socketio.on("reset_inventory") |
Forward reset command to robo hub (robohub_client.sio.emit("reset_inventory")). |
handle_get_inventory |
@socketio.on("get_inventory") |
Forward get_inventory to Robo Hub (or emit cached last_inventory if not connected). |
handle_start_robot |
@socketio.on("start_robot") |
Forward send_trigger start request to Robo Hub. |
handle_reload_required |
@socketio.on("reload_required") |
Emit reload_required to frontends. |
handle_ping |
@socketio.on("ping_test") |
Return pong_test for connectivity testing. |
app.py registers robohub_client.sio.on("update_inventory") which normalizes the mapping and emits update_inventory to the frontend with shape:
{ "inventory": [ { "part_number": "...", "tray1": n, "tray2": n, "tray3": n, "tray4": n }, ... ] }
It keeps last_inventory as raw mapping and only broadcasts when the mapping changes.
Usage
- Run
python backend/app.py. - Frontend connects to
http://127.0.0.1:5001 and will receiveupdate_inventory,video_frame, etc.
Purpose
A small Socket.IO client used by the backend to talk to the Robo Hub server (which itself is a Socket.IO server). The class encapsulates connection, emit wrappers, ack handling, and helper methods for sending triggers/part codes and waiting for acknowledgments.
Class: RoboHubClient Constructor
def __init__(self, hub_url="http://127.0.0.1:5002"):
# sets self.sio = socketio.Client(...)
# sets self.hub_url, self.acks = {}, _connect_lock
# registers callbacks on self.sio for "ack", "update_inventory", "reload_required"
Key Methods
| Method | Signature | Description |
|---|---|---|
connect |
connect(self, timeout=5.0) |
Connects to Robo Hub using self.sio.connect(hub_url, namespaces=['/']). Waits until connected or timeout. Returns True/False. |
_ensure_connected |
— | Attempts reconnection if sio is not connected. Called internally before emits. |
send_trigger |
send_trigger(self, event_name) |
Map logical event name to single-letter trigger; emits send_trigger to Robo Hub and marks acks[event_name] = False to be awaited. |
send_part_code |
send_part_code(self, part_letter, part_number="") |
Emit a send_trigger with trigger=part_letter and event=PART_{part_number}. |
wait_for_ack |
wait_for_ack(self, event_name, timeout=10) |
Poll self.acks and wait until event ack is True, or timeout. Returns boolean. |
Socket callbacks registered in constructor
@self.sio.on("ack")→ sets self.acks[event] = True and prints log.@self.sio.on("update_inventory")→ prints debug.@self.sio.on("reload_required")→ prints debug.
Usage Example
client = RoboHubClient("http://127.0.0.1:5002")
client.connect()
client.send_part_code("A", "G8NB-17SR")
client.wait_for_ack("PART_G8NB-17SR", timeout=8)
Errors & pitfalls
- Make sure the Robo Hub server is running and reachable. If
connect()times out, emit calls will fail or be no-ops. - The client uses the root namespace '/'. If the server uses a different namespace, you must update both sides.
Class: Camera Purpose
Threaded camera capture wrapper using OpenCV VideoCapture. Keeps latest frame in memory protected by a lock, and supports start(), stop(), and get_frame().
Key attributes
camera_index— index forcv2.VideoCapture(default 1 in your copy).cap—cv2.VideoCaptureinstance.frame— last captured frame (BGR numpy array).is_running— bool flag.lock—threading.Lockto guardframe.thread— background thread that runsupdate().
Methods
| Method | Signature | Description |
|---|---|---|
start() |
start(self) |
Opens the cv2 capture and starts a daemon thread calling update(). |
update() |
update(self) |
Reads frames in a loop; on success writes to self.frame under self.lock. |
get_frame() |
get_frame(self) |
Returns a copy of the last frame or None. Copy prevents callers from mutating the internal array. |
stop() |
stop(self) |
Releases capture, stops thread loop, clears frame. |
Usage
cam = Camera(0)
cam.start()
time.sleep(1)
frame = cam.get_frame()
cam.stop()
Notes
-
Use
cap = cv2.VideoCapture(index, cv2.CAP_DSHOW)on Windows to reduce lag and warnings (your code does this). -
get_frame()returns None until the camera has produced a frame.
Purpose
Contains preprocess_for_ocr(frame_bgr) which converts BGR camera frames to a preprocessed image suitable for OCR (PaddleOCR). Includes grayscale conversion, bilateral filtering, sharpening, adaptive thresholding, deskew and returns BGR image for PaddleOCR.
Function
def preprocess_for_ocr(frame_bgr):
# returns processed_frame_bgr
Processing steps (detailed)
- Convert to grayscale.
- Bilateral filter with reduced strength to keep edges.
- Sharpen with a mild kernel.
- Adaptive threshold (Gaussian) with smaller block size for smaller text.
- Deskew using cv2.minAreaRect on non-zero pixels.
- Convert back to BGR before returning (PaddleOCR expects BGR).
Why these steps? Improves text contrast, reduces noise, and normalizes rotation so OCR accuracy increases on small printed labels.
Example
from camera.capture import Camera
from camera.preprocess import preprocess_for_ocr
cam = Camera(0)
cam.start()
time.sleep(1)
frame = cam.get_frame()
proc = preprocess_for_ocr(frame) # now feed proc into OCR
Class: OCREngine
Purpose
Wraps the PaddleOCR library, provides preprocess() and run_ocr() helpers and returns normalized output shape (list of rec_texts and det_polygons).
Constructor
def __init__(self, model=None):
self.ocr = PaddleOCR(use_angle_cls=True, lang='en')
Methods
preprocess(self, frame)— delegator tocamera.preprocess.preprocess_for_ocr.run_ocr(self, frame)— runs OCR and adapts PaddleOCR output variations into canonical structure:
Return:
{
"processed_frame": <preprocessed frame>,
"det_polygons": [ list of boxes ],
"rec_texts": [ {"text": "...", "score": <float> }, ... ]
}
Notes
PaddleOCR outputs have had several API versions; run_ocr() contains fallback handling for both old [(box, (text, score)), ...] format and newer dictionary format.
Usage
ocr = OCREngine()
result = ocr.run_ocr(frame)
texts = [t["text"] for t in result["rec_texts"]]
Contains: TextComparator class
Purpose Utilities for comparing recognized OCR text to benchmark strings (part numbers). Provides cleaning, similarity, and best-match logic that can combine adjacent recognized fragments to handle split OCR outputs.
Key methods
clean_text(s)— lowercases and strips non-alphanumeric chars.best_match(benchmark_texts, component_texts, threshold=0.6)— tries single string matching and pairwise concatenation of component fragments. Returns (match_bool, (benchmark,component), score).compare_texts(a,b)— wrapper using difflib.SequenceMatcher.similarity(a,b)— same as compare_texts (convenience).
Usage
ok, (bench, comp), score = TextComparator.best_match(["G8NB-17SR"], ["g8nb","17sr"])
if ok:
# treat as matched
Notes
- Designed to handle OCR split tokens, hyphens, whitespace and OCR noise.
Function: draw_ocr_overlays(frame, result)
Purpose Annotate a frame (BGR) with polygons, bounding boxes and text labels from OCR output. Used to produce the overlay image sent to the frontend on benchmark_set / component_captured.
Input forms handled
- result can be:
- dict with
det_polygonsandrec_texts, where rec_texts is a list of dicts with text and score. - list of
{text, score}dicts.
- dict with
For each polygon, draws a green polyline and red text label with score.
Returns: the annotated frame.
Usage
overlay = draw_ocr_overlays(frame.copy(), result)
_, jpeg = cv2.imencode('.jpg', overlay)
b64 = base64.b64encode(jpeg.tobytes()).decode()
socketio.emit('component_captured', { 'image': b64, ... })
Purpose Script used to exercise the OCR + robo hub flow locally without the full UI. Useful for development and CI.
Typical content / behavior
- Start camera or read a sample image.
- Run the OCR engine on the image.
- Optionally mock RoboHubClient interactions (emulate ack).
- Print results and scores, save overlay images to disk for inspection.
How to use
python backend/test_workflow.py
# Inspect console messages and generated debug images
requirements.txt(important Python packages likely included)
FlaskFlask-SocketIOpython-socketioeventletorgevent(your code uses eventlet as async_mode)opencv-pythonpaddleocr(heavy; may require paddlepaddle; see next)paddlepaddle(CPU/GPU install varies — consult Paddle docs)numpyrequestspillow(if used)other utilities
backend_installation_packages.txt
- may contain platform/system packages required for opencv display, paddlepaddle or other native libs (e.g. libglib2.0-0, libsm6, libxrender1).
Important: paddleocr/paddlepaddle have special install instructions depending on CPU/GPU. Follow their installation docs — on Windows, GPU support is non-trivial.
Below are practical steps and checks for common problems you described (table not updating, reset failing, frontend not printing logs, inventory disappearing after refresh):
Common checks (quick)
1. Ports and URLs
- Backend listens on 5001 (per backend/app.py).
- Robo Hub listens on 5002.
- Frontend connects to backend at
http://localhost:5001— make sure frontend JS matches.
2. Namespace mismatch errors
- Error
"/ is not a connected namespace"means thesocketio.Client.connect(..., namespaces=['/'])call was not successful or the server didn't accept the namespace. - Ensure Robo Hub server and client both use the root namespace '/' (or change both to use a specific namespace).
3. No frontend logs & table disappears after refresh
- Verify browser console: any socket connection errors, CORS blocked, 404 for
socket.ioorscripts.js? - On page load, your
scripts.js should callsocket.emit('get_inventory')— backend should respond by forwarding to Robo Hub or by returning cached last_inventory. If Robo Hub client disconnected, backend should fallback to last_inventory. Your backend@socketio.on('connect')emits cached inventory if present — ensure last_inventory is populated. - Use
console.logand log() (existing functions) inscripts.jsto see events.
4. Reset doesn't work — logs show Failed to forward reset_inventory to Robo Hub: / is not a connected namespace
- Occurs when
robohub_client.sio.connectedis False. Fixes:- Ensure
robohub_client.connect()succeeded on backend startup. Check backend startup logs for Connected athttp://127.0.0.1:5002. - Add fallback in backend
handle_reset_inventory()that, if robohub not connected, directly resetsinventory.json(or emits areset_inventory_result event) so frontend won't be stuck.
- Ensure
Example fallback:
if not robohub_client.sio.connected:
inv = load_inventory()
inv["current"] = inv["default"].copy()
save_inventory(inv)
socketio.emit("update_inventory", { "inventory": normalized_from(inv["current"]) }, namespace="/")
else:
robohub_client.sio.emit("reset_inventory")
5. Inventory only displays once and then disappears
- Ensure update_inventory events sent by backend are in the array-normalized form your frontend expects ({inventory: [...]}).
- Make frontend robust: accept either mapping
{PN: [..], ...}or normalized{ inventory: [...] }. Yourscripts.jsalready contains robust handling; ensure the backend actually sends{inventory: normalized}consistently. - Also ensure on socket.connect event the backend emits or the frontend requests
get_inventory.
6. Latency / lag in updates
- Inventory watcher polls
inventory.json every 0.5s. If Robo Hub updatesinventory.jsonless frequently, there will be apparent lag. - If Robo Hub is writing big files synchronously, reads from backend may block — use small locks or send inventory updates over Socket.IO from Robo Hub instead of file watching.
1. Confirm Robo Hub is up
- Terminal with Robo Hub should show Client connected — sending current inventory: and printed map.
2. Confirm backend connected to Robo Hub
- Backend logs should have
[Backend → Robo Hub] Connected at http://127.0.0.1:5002(or similar).
3.Confirm frontend connects to backend
- Browser console: ✅ Pong: ... or log messages from scripts.js.
- Backend console should show
Ping received from front-end:... orFrontend connected.
4. Manually test inventory flow
- In Robo Hub terminal, force an
update_inventoryby touchinginventory.jsonor using its file watcher. - Check backend logs for
update_inventoryfromRobo Huband frontend logs forupdate_inventoryreceived.
5. Test reset
- Click Reset on frontend; ensure Robo Hub prints
Resetting inventory to defaultsandEmitted update_inventory. - If not, check Robo Hub and backend
connectedstates.
Example expected logs (happy path)
[Robo Hub] Client connected — sending current inventory: {...}
[Backend] update_inventory from Robo Hub: {...}
[Backend] Forwarded normalized inventory to frontend: [ {...}, ... ]
[Frontend] update_inventory received: { inventory: [ ... ] }
[Frontend] Inventory updated (array)
If you see No change in inventory, skipping emit, that's because the mapping equals last_inventory — expected behavior to avoid spam.
- Robust reconnects: Add retry logic and exponential backoff for
RoboHubClient.connect(). Already present but can be hardened. - Better fallback for offline Robo Hub: If Robo Hub is temporarily down, backend should still be able to serve last_inventory to frontend on connect.
app.pyalready caches last_inventory — ensure@socketio.on('connect')always emits cached data (it does in your later revisions). - Atomic file operations for
inventory.jsonwrites to avoid race conditions (write to tmp file + rename). - Event tracing: Add correlation IDs to triggers so logs can match send → ack flows.
- Persist last_inventory to disk if you need state to survive backend restart.
- Decouple camera processing: run heavy OCR in a worker process to avoid blocking SocketIO; use a Queue.
Emit normalized inventory (backend)
normalized = [
{"part_number": pn, "tray1": c[0], "tray2": c[1], "tray3": c[2], "tray4": c[3]}
for pn, c in curr.items()
]
socketio.emit("update_inventory", {"inventory": normalized}, namespace="/")
Frontend update_inventory handler (robust)
socket.on("update_inventory", (data) => {
if (data && Array.isArray(data.inventory)) {
renderInventoryTable(data.inventory)
} else if (data && typeof data === 'object') {
// mapping -> convert
const firstKey = Object.keys(data)[0];
if (firstKey && Array.isArray(data[firstKey])) {
const normalized = Object.entries(data).map(([pn, arr]) => ({
part_number: pn, tray1: arr[0]||0, tray2: arr[1]||0, tray3: arr[2]||0, tray4: arr[3]||0
}));
renderInventoryTable(normalized);
}
}
});
Safe-forwarding reset in backend (fallback pattern suggestion)
@socketio.on("reset_inventory")
def handle_reset_inventory():
if robohub_client.sio and robohub_client.sio.connected:
robohub_client.sio.emit("reset_inventory")
else:
inv = load_inventory()
inv["current"] = {k: v.copy() for k,v in inv["default"].items()}
inv["job"] = {"part_number":"—", "quantity":0}
save_inventory(inv)
normalized = [...]
socketio.emit("update_inventory", {"inventory": normalized})