From 65a57ba28bb4a9cddb8250c36b4c55edf7beba42 Mon Sep 17 00:00:00 2001 From: ttm25 Date: Fri, 10 Jul 2026 16:11:29 +0100 Subject: [PATCH 1/4] Add standalone Python 3 / TF 2.15 port (no ROS) Modernised, ROS-free port of the engagement detector so it runs on current systems (tested: Apple Silicon, Python 3.11, tensorflow-macos 2.15, the last Keras-2 release). ResNeXt50 comes from the archived keras-applications package; the pretrained LSTM h5 loads unchanged. Includes a live webcam demo and pinned requirements. Verified against the original pipeline: same preprocessing, same feature shapes, pretrained weights load without custom objects. --- .gitignore | 13 ++++ models/.gitkeep | 0 standalone_demo/README.md | 53 ++++++++++++++ standalone_demo/engagement.py | 108 ++++++++++++++++++++++++++++ standalone_demo/requirements.txt | 13 ++++ standalone_demo/test_resnext.py | 44 ++++++++++++ standalone_demo/webcam_demo.py | 120 +++++++++++++++++++++++++++++++ 7 files changed, 351 insertions(+) create mode 100644 .gitignore create mode 100644 models/.gitkeep create mode 100644 standalone_demo/README.md create mode 100644 standalone_demo/engagement.py create mode 100644 standalone_demo/requirements.txt create mode 100644 standalone_demo/test_resnext.py create mode 100644 standalone_demo/webcam_demo.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7295ed3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +# Python virtual environments +.venv/ +standalone_demo/.venv/ + +# Python caches +__pycache__/ +*.py[cod] + +# Model weights (large; obtained via download, not committed) +models/*.h5 + +# macOS +.DS_Store diff --git a/models/.gitkeep b/models/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/standalone_demo/README.md b/standalone_demo/README.md new file mode 100644 index 0000000..71f8911 --- /dev/null +++ b/standalone_demo/README.md @@ -0,0 +1,53 @@ +# Standalone engagement demo (Python 3, no ROS) + +A modernised, ROS-free port of the LCAS `engagement_detector` (Del Duchetto, +Baxter & Hanheide 2020), so it runs on a current Mac (Apple Silicon, Python 3.11, +TensorFlow 2.15) instead of the original Python 2 / TF 1.14 / ROS stack. + +Pipeline: `10 frames -> ResNeXt50 (2048-d features/frame) -> LSTM -> engagement score in [0,1]`. + +## Files +- `engagement.py` — the reusable `EngagementDetector` class (feed it numpy frames). +- `webcam_demo.py` — live webcam demo with an on-screen score overlay. +- `test_resnext.py` — checks just the ResNeXt50 feature extractor. +- `requirements.txt` — exact pins for the fragile TF/Keras combo (see comments inside). +- `.venv/` — the isolated Python 3.11 environment (not committed; recreate as below). + +## Setup + +Requires **Python 3.11** (tensorflow-macos 2.15 has no 3.12 wheels): + +```bash +# from this folder: +python3.11 -m venv .venv +.venv/bin/pip install -r requirements.txt +``` + +On **Linux**, replace `tensorflow-macos==2.15.0` in `requirements.txt` with +`tensorflow==2.15.0` (same version, different distribution name). + +## Model weights (required for meaningful output) +The pretrained LSTM `lstm_10_50_runsigm_runsigm.h5` (~256 MB) must sit in +`../models/`. Download it with the repo's `download_model.sh`, or directly: + +```bash +curl -L -o ../models/lstm_10_50_runsigm_runsigm.h5 \ + 'https://lcas.lincoln.ac.uk/nextcloud/index.php/s/UHWU1g5qXU9RiAp/download' +``` + +(The weights are not committed to git — they are the authors' artefact, served +from their host.) **Without the file, the demo still runs but falls back to a +random stand-in LSTM and the scores are not meaningful** (the overlay says so). + +## Run it + +```bash +# from this folder: +.venv/bin/python webcam_demo.py # live mode, press q to quit +.venv/bin/python webcam_demo.py --selftest # headless: print a few scores and exit +``` + +On macOS the first run triggers a **camera-permission prompt** for your terminal +(System Settings > Privacy & Security > Camera). Allow it, then re-run. + +The first run also downloads the ResNeXt50 ImageNet weights (~90 MB) once. diff --git a/standalone_demo/engagement.py b/standalone_demo/engagement.py new file mode 100644 index 0000000..486d481 --- /dev/null +++ b/standalone_demo/engagement.py @@ -0,0 +1,108 @@ +""" +Standalone, ROS-free port of the LCAS engagement_detector core. + +Pipeline (unchanged from the original paper / repo): + 10 frames -> ResNeXt50 (per-frame 2048-d features) -> LSTM -> scalar in [0,1] + +The only differences from src/engagement_detector/engagement_detector.py: + * Python 3 + TF 2.15 (tf.keras) instead of Python 2 + TF 1.14 + classic Keras. + * No ROS. You feed it plain numpy frames. + * If the pretrained LSTM weights are missing, it falls back to a randomly + initialised stand-in LSTM of the same shape, so the pipeline still runs + (predictions are meaningless until the real weights are dropped in). + +Reference: Del Duchetto, Baxter & Hanheide (2020), "Are You Still With Me? +Continuous Engagement Assessment From a Robot's Point of View." +""" +import os +os.environ.setdefault("TF_CPP_MIN_LOG_LEVEL", "3") + +import numpy as np +from PIL import Image +import tensorflow as tf +from keras_applications.resnext import ResNeXt50, preprocess_input + +WINDOW_SIZE = 10 # the model consumes exactly 10 frames +FEATURE_DIM = 2048 # ResNeXt50(pooling='max') output width +DEFAULT_WEIGHTS = os.path.join( + os.path.dirname(os.path.dirname(os.path.realpath(__file__))), + "models", "lstm_10_50_runsigm_runsigm.h5", +) + +# tf.keras module set, injected into the backend-agnostic keras_applications. +_KA_KWARGS = dict( + backend=tf.keras.backend, + layers=tf.keras.layers, + models=tf.keras.models, + utils=tf.keras.utils, +) + + +class EngagementDetector: + def __init__(self, weights_path=DEFAULT_WEIGHTS): + self.window_size = WINDOW_SIZE + self.using_real_weights = False + + # Front half: ResNeXt50 feature extractor (ImageNet weights). + self.resnet = ResNeXt50( + include_top=False, + input_shape=(224, 224, 3), + pooling="max", + weights="imagenet", + **_KA_KWARGS, + ) + + # Back half: the engagement LSTM. + if weights_path and os.path.exists(weights_path): + self.lstm = tf.keras.models.load_model(weights_path, compile=False) + self.using_real_weights = True + print("[EngagementDetector] Loaded pretrained LSTM:", weights_path) + else: + self.lstm = self._build_standin_lstm() + print("[EngagementDetector] WARNING: pretrained weights not found at") + print(" ", weights_path) + print(" Using a RANDOM stand-in LSTM. Predictions are NOT meaningful") + print(" until the real lstm_10_50_runsigm_runsigm.h5 is in place.") + + def _build_standin_lstm(self): + """A shape-compatible placeholder: (10, 2048) -> scalar in [0,1]. + + Matches the original naming hints (10 frames, ~50 units, sigmoid output) + so the surrounding code path is exercised exactly as it will be for real. + """ + model = tf.keras.Sequential([ + tf.keras.layers.Input(shape=(WINDOW_SIZE, FEATURE_DIM)), + tf.keras.layers.LSTM(50, recurrent_activation="sigmoid"), + tf.keras.layers.Dense(1, activation="sigmoid"), + ]) + return model + + def predict(self, frames_seq): + """frames_seq: list of exactly WINDOW_SIZE HxWx3 uint8 frames. + + Returns a float engagement score in [0,1], or None if the window is + not yet full. + """ + if len(frames_seq) != self.window_size: + return None + + # Resize each frame to 224x224 (PIL, same as the original) and preprocess. + frames = [np.array(Image.fromarray(f).resize((224, 224))) for f in frames_seq] + batch = preprocess_input(np.array(frames, dtype="float32"), **_KA_KWARGS) + + # Per-frame features -> (10, 2048), then LSTM over the sequence. + features = self.resnet.predict_on_batch(batch) # (10, 2048) + prediction = self.lstm.predict(features[np.newaxis, :], verbose=0) # (1, 1) + return float(np.squeeze(prediction)) + + +if __name__ == "__main__": + # Smoke test with dummy frames: proves the full front-to-back path runs. + det = EngagementDetector() + dummy_window = [ + np.random.randint(0, 256, size=(480, 640, 3), dtype="uint8") + for _ in range(WINDOW_SIZE) + ] + score = det.predict(dummy_window) + print("Engagement score on random frames:", score) + print("Using real weights:", det.using_real_weights) diff --git a/standalone_demo/requirements.txt b/standalone_demo/requirements.txt new file mode 100644 index 0000000..48809a1 --- /dev/null +++ b/standalone_demo/requirements.txt @@ -0,0 +1,13 @@ +# Environment for the Python 3 / ROS-free port of the engagement detector. +# REQUIRES Python 3.11 (tensorflow-macos 2.15 has no 3.12 wheels). +# These pins are load-bearing — see README. Do not upgrade casually: +# - tensorflow-macos 2.15 is the LAST version bundling Keras 2; the pretrained +# lstm_10_50_runsigm_runsigm.h5 does not load under Keras 3. +# - Keras-Applications 1.0.8 provides ResNeXt50, which modern TF dropped. +# - numpy <2 required by TF 2.15. +tensorflow-macos==2.15.0 +Keras-Applications==1.0.8 +numpy==1.26.4 +opencv-python==4.11.0.86 +pillow==12.3.0 +h5py==3.16.0 diff --git a/standalone_demo/test_resnext.py b/standalone_demo/test_resnext.py new file mode 100644 index 0000000..f3f222a --- /dev/null +++ b/standalone_demo/test_resnext.py @@ -0,0 +1,44 @@ +""" +Step 3 test: build the ResNeXt50 feature extractor on modern TF 2.15. + +The original repo pulls ResNeXt50 from the old `keras_applications` package, +which is "backend-agnostic": you hand it the backend/layers/models/utils to +build against. The original injected classic Keras; here we inject TF 2.15's +bundled Keras (tf.keras). If this works, we get the SAME architecture + ImageNet +weights the LSTM was trained on, running on a stack that installs on Apple Silicon. + +Expected output: a (1, 2048) feature vector for one 224x224x3 frame +(2048 because pooling='max' collapses the spatial dims of ResNeXt50's final block). +""" +import os +os.environ["TF_CPP_MIN_LOG_LEVEL"] = "3" # quiet TF's startup logs + +import numpy as np +import tensorflow as tf +from keras_applications.resnext import ResNeXt50, preprocess_input + +# tf.keras is Keras 2.15 here — exactly the module set keras_applications expects. +kwargs = dict( + backend=tf.keras.backend, + layers=tf.keras.layers, + models=tf.keras.models, + utils=tf.keras.utils, +) + +print("Building ResNeXt50 (downloads ImageNet weights on first run)...") +resnet = ResNeXt50( + include_top=False, + input_shape=(224, 224, 3), + pooling="max", + weights="imagenet", + **kwargs, +) +print("Built. Model output shape:", resnet.output_shape) + +# One dummy frame -> preprocess -> features +dummy = np.random.randint(0, 256, size=(224, 224, 3)).astype("float32") +batch = preprocess_input(dummy[np.newaxis, ...], **kwargs) +features = resnet.predict(batch, verbose=0) +print("Feature vector shape:", features.shape) # want (1, 2048) +assert features.shape == (1, 2048), "Unexpected feature shape!" +print("OK: ResNeXt50 feature extractor works.") diff --git a/standalone_demo/webcam_demo.py b/standalone_demo/webcam_demo.py new file mode 100644 index 0000000..0bb155c --- /dev/null +++ b/standalone_demo/webcam_demo.py @@ -0,0 +1,120 @@ +""" +Standalone webcam engagement demo (no ROS). + +Live mode (default): + python webcam_demo.py + Opens your webcam, runs the engagement model on a rolling 10-frame window, + and overlays the score. Press 'q' to quit. + +Self-test mode (no GUI): + python webcam_demo.py --selftest + Grabs a handful of windows, prints the scores, and exits. Useful to confirm + camera access + the full pipeline work before opening the live window. + +Until the real lstm_10_50_runsigm_runsigm.h5 weights are in ../models/, the score +comes from a random stand-in LSTM and is NOT meaningful (the overlay says so). +""" +import argparse +import time +from collections import deque + +import cv2 + +from engagement import EngagementDetector, WINDOW_SIZE + +PREDICT_EVERY = 3 # run a prediction every N captured frames (keeps video smooth) + + +def open_camera(index=0): + cap = cv2.VideoCapture(index) + if not cap.isOpened(): + raise RuntimeError( + "Could not open webcam (index %d). On macOS, grant camera permission " + "to your terminal in System Settings > Privacy & Security > Camera." % index + ) + return cap + + +def run_selftest(camera_index=0, num_windows=3): + det = EngagementDetector() + cap = open_camera(camera_index) + buf = deque(maxlen=WINDOW_SIZE) + scores = [] + print("Warming up camera and filling the 10-frame window...") + frames_grabbed = 0 + t0 = time.time() + while len(scores) < num_windows and time.time() - t0 < 30: + ok, frame = cap.read() + if not ok: + continue + buf.append(frame) + frames_grabbed += 1 + if len(buf) == WINDOW_SIZE and frames_grabbed % PREDICT_EVERY == 0: + s = det.predict(list(buf)) + scores.append(s) + print(" window %d -> engagement = %.3f" % (len(scores), s)) + cap.release() + print("Self-test done. real_weights=%s scores=%s" % (det.using_real_weights, scores)) + + +def run_live(camera_index=0): + det = EngagementDetector() + cap = open_camera(camera_index) + buf = deque(maxlen=WINDOW_SIZE) + + tag = "REAL weights" if det.using_real_weights else "STAND-IN (not meaningful)" + last_score = None + frame_count = 0 + fps_t0, fps_n = time.time(), 0 + + print("Live mode. Press 'q' in the video window to quit.") + while True: + ok, frame = cap.read() + if not ok: + break + buf.append(frame) + frame_count += 1 + + if len(buf) == WINDOW_SIZE and frame_count % PREDICT_EVERY == 0: + last_score = det.predict(list(buf)) + + # --- overlay --- + h, w = frame.shape[:2] + if last_score is not None: + # colour: red (low) -> green (high) + colour = (0, int(255 * last_score), int(255 * (1 - last_score))) + cv2.putText(frame, "engagement: %.2f" % last_score, (20, 40), + cv2.FONT_HERSHEY_SIMPLEX, 1.0, colour, 2) + # bar + bar_w = int((w - 40) * last_score) + cv2.rectangle(frame, (20, 55), (20 + bar_w, 75), colour, -1) + cv2.rectangle(frame, (20, 55), (w - 20, 75), (200, 200, 200), 1) + else: + cv2.putText(frame, "filling window...", (20, 40), + cv2.FONT_HERSHEY_SIMPLEX, 1.0, (200, 200, 200), 2) + cv2.putText(frame, tag, (20, h - 20), + cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 255, 0), 1) + + cv2.imshow("Engagement demo (press q to quit)", frame) + if cv2.waitKey(1) & 0xFF == ord("q"): + break + + fps_n += 1 + if time.time() - fps_t0 >= 5: + print(" ~%.1f fps, last score=%s" % (fps_n / (time.time() - fps_t0), last_score)) + fps_t0, fps_n = time.time(), 0 + + cap.release() + cv2.destroyAllWindows() + + +if __name__ == "__main__": + ap = argparse.ArgumentParser() + ap.add_argument("--selftest", action="store_true", help="headless: print a few scores and exit") + ap.add_argument("--camera", type=int, default=0, help="camera index (default 0)") + args = ap.parse_args() + + if args.selftest: + run_selftest(args.camera) + else: + run_live(args.camera) From c550f7e51e53eee06c04f543338b1828eb930a4b Mon Sep 17 00:00:00 2001 From: ttm25 Date: Mon, 13 Jul 2026 12:36:42 +0100 Subject: [PATCH 2/4] Add localhost HTTP scoring service Wraps EngagementDetector behind POST /score (10 base64 JPEG frames -> engagement float) and GET /health on 127.0.0.1:8100, so apps in other Python environments can consume scores across the process boundary. Frames decode via cv2.imdecode (BGR), matching the validated webcam path. Loads models before binding; refuses stand-in weights unless --allow-standin. Round-trip test proves HTTP score equals direct predict (diff <1e-7) and covers the 400/404 error paths. --- standalone_demo/README.md | 21 +++++ standalone_demo/engagement_service.py | 128 ++++++++++++++++++++++++++ standalone_demo/run_service.sh | 5 + standalone_demo/test_service.py | 108 ++++++++++++++++++++++ 4 files changed, 262 insertions(+) create mode 100644 standalone_demo/engagement_service.py create mode 100755 standalone_demo/run_service.sh create mode 100644 standalone_demo/test_service.py diff --git a/standalone_demo/README.md b/standalone_demo/README.md index 71f8911..62650d1 100644 --- a/standalone_demo/README.md +++ b/standalone_demo/README.md @@ -11,6 +11,9 @@ Pipeline: `10 frames -> ResNeXt50 (2048-d features/frame) -> LSTM -> engagement - `webcam_demo.py` — live webcam demo with an on-screen score overlay. - `test_resnext.py` — checks just the ResNeXt50 feature extractor. - `requirements.txt` — exact pins for the fragile TF/Keras combo (see comments inside). +- `engagement_service.py` — localhost HTTP service exposing the detector (see below). +- `run_service.sh` — launcher for the service (uses the local venv, works from any cwd). +- `test_service.py` — round-trip regression test for the service. - `.venv/` — the isolated Python 3.11 environment (not committed; recreate as below). ## Setup @@ -51,3 +54,21 @@ On macOS the first run triggers a **camera-permission prompt** for your terminal (System Settings > Privacy & Security > Camera). Allow it, then re-run. The first run also downloads the ResNeXt50 ImageNet weights (~90 MB) once. + +## Score service (for consuming apps) + +`engagement_service.py` exposes the detector to other processes (e.g. apps on a +different Python version) over localhost HTTP: + +```bash +./run_service.sh # serves http://127.0.0.1:8100 once models load +./run_service.sh --port 8200 # alternative port +``` + +- `GET /health` → `{"status": "ok"}` (answers only once the model is ready) +- `POST /score` with `{"frames": [ x10, chronological]}` → + `{"score": }`; frames are decoded as BGR (OpenCV convention) +- Refuses to start without the real weights unless `--allow-standin` (test only) + +Regression test: `.venv/bin/python test_service.py` (starts its own instance +on port 8199, no camera needed). diff --git a/standalone_demo/engagement_service.py b/standalone_demo/engagement_service.py new file mode 100644 index 0000000..6e37683 --- /dev/null +++ b/standalone_demo/engagement_service.py @@ -0,0 +1,128 @@ +""" +Localhost HTTP service wrapping EngagementDetector (frames in -> score out). + +Contract (consumed by the thesis app, reachy_mini_conversation_app): + POST /score body: {"frames": ["", ... exactly 10, chronological]} + -> 200 {"score": } + -> 400 {"error": "..."} on bad input + GET /health -> 200 {"status": "ok"} + +Design notes: + * The model (ResNeXt50 + LSTM) is loaded ONCE, before the socket opens. + /health answering at all therefore implies the model is ready; the + contract's 503 "still loading" state cannot occur by construction. + * Frames are decoded with cv2.imdecode -> BGR, matching exactly what the + validated webcam demo feeds EngagementDetector.predict(). Do NOT switch + to PIL (RGB): it would silently flip channels vs. the validated setup. + * stdlib http.server handles requests serially -- intentional; predict() + is CPU-heavy and the contract requires no concurrency. + * Binds 127.0.0.1 only. Default port 8100 (7860/8765 taken by thesis app). + * Refuses to serve stand-in (random-LSTM) scores unless --allow-standin. +""" +import argparse +import base64 +import json +import sys +import time +from http.server import BaseHTTPRequestHandler, HTTPServer + +import cv2 +import numpy as np + +from engagement import EngagementDetector, WINDOW_SIZE + +DETECTOR = None # set in main() before the server starts + + +def decode_frame(b64_string): + """base64 JPEG -> HxWx3 uint8 BGR array, or None if undecodable.""" + try: + raw = base64.b64decode(b64_string, validate=True) + except Exception: + return None + return cv2.imdecode(np.frombuffer(raw, np.uint8), cv2.IMREAD_COLOR) + + +class Handler(BaseHTTPRequestHandler): + + def _send_json(self, status, payload): + body = json.dumps(payload).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, fmt, *args): + pass # silence per-request stderr noise; we log our own line in do_POST + + def do_GET(self): + if self.path == "/health": + self._send_json(200, {"status": "ok"}) + else: + self._send_json(404, {"error": "unknown path %s" % self.path}) + + def do_POST(self): + if self.path != "/score": + self._send_json(404, {"error": "unknown path %s" % self.path}) + return + + t0 = time.time() + try: + length = int(self.headers.get("Content-Length", 0)) + payload = json.loads(self.rfile.read(length)) + except Exception: + self._send_json(400, {"error": "body is not valid JSON"}) + return + + frames_b64 = payload.get("frames") + if not isinstance(frames_b64, list) or len(frames_b64) != WINDOW_SIZE: + self._send_json(400, { + "error": "expected 'frames' as a list of exactly %d base64 JPEG strings" + % WINDOW_SIZE}) + return + + frames = [] + for i, s in enumerate(frames_b64): + img = decode_frame(s) + if img is None: + self._send_json(400, {"error": "frame %d could not be decoded" % i}) + return + frames.append(img) + + score = DETECTOR.predict(frames) + latency_ms = (time.time() - t0) * 1000.0 + print("[score] frames=%d score=%.4f latency=%.0fms" + % (len(frames), score, latency_ms), flush=True) + self._send_json(200, {"score": score}) + + +def main(): + global DETECTOR + ap = argparse.ArgumentParser() + ap.add_argument("--port", type=int, default=8100) + ap.add_argument("--allow-standin", action="store_true", + help="serve even without the real .h5 (testing only)") + args = ap.parse_args() + + print("[startup] loading models (this takes a while)...", flush=True) + DETECTOR = EngagementDetector() + if not DETECTOR.using_real_weights: + if not args.allow_standin: + print("[startup] FATAL: real weights missing; refusing to serve " + "meaningless scores. Use --allow-standin to override for " + "plumbing tests.", flush=True) + sys.exit(1) + print("[startup] WARNING: serving STAND-IN scores (--allow-standin).", flush=True) + + server = HTTPServer(("127.0.0.1", args.port), Handler) + print("[startup] ready on http://127.0.0.1:%d (GET /health, POST /score)" + % args.port, flush=True) + try: + server.serve_forever() + except KeyboardInterrupt: + print("\n[shutdown] bye", flush=True) + + +if __name__ == "__main__": + main() diff --git a/standalone_demo/run_service.sh b/standalone_demo/run_service.sh new file mode 100755 index 0000000..07f93b0 --- /dev/null +++ b/standalone_demo/run_service.sh @@ -0,0 +1,5 @@ +#!/bin/sh +# Launch the engagement scoring service (see engagement_service.py for the API). +# Works from any cwd; extra args pass through (e.g. ./run_service.sh --port 8200). +cd "$(dirname "$0")" || exit 1 +exec .venv/bin/python engagement_service.py "$@" diff --git a/standalone_demo/test_service.py b/standalone_demo/test_service.py new file mode 100644 index 0000000..f9c0461 --- /dev/null +++ b/standalone_demo/test_service.py @@ -0,0 +1,108 @@ +""" +Round-trip test for engagement_service.py. + +Proves the HTTP layer adds no distortion: a score fetched over HTTP must equal +EngagementDetector.predict() called directly on the same (JPEG-decoded) frames. +Also exercises the contract's error paths (bad count, bad frame, bad path). + +Run: .venv/bin/python test_service.py +Starts its own service instance on a throwaway port (8199); needs no camera. +""" +import base64 +import json +import subprocess +import sys +import time +import urllib.error +import urllib.request + +import cv2 +import numpy as np + +PORT = 8199 +BASE = "http://127.0.0.1:%d" % PORT + + +def http(method, path, payload=None): + """Minimal JSON client. Returns (status, parsed_body).""" + req = urllib.request.Request(BASE + path, method=method) + data = None + if payload is not None: + data = json.dumps(payload).encode("utf-8") + req.add_header("Content-Type", "application/json") + try: + with urllib.request.urlopen(req, data=data, timeout=120) as r: + return r.status, json.loads(r.read()) + except urllib.error.HTTPError as e: # 4xx/5xx also carry a JSON body + return e.code, json.loads(e.read()) + + +def main(): + # 1. Deterministic synthetic frames -> base64 JPEGs + rng = np.random.RandomState(42) + frames = [rng.randint(0, 256, size=(480, 640, 3)).astype(np.uint8) + for _ in range(10)] + b64 = [] + for f in frames: + ok, jpg = cv2.imencode(".jpg", f) + assert ok + b64.append(base64.b64encode(jpg.tobytes()).decode()) + + # 2. Expected score, computed in-process on the JPEG-decoded frames + # (JPEG is lossy; the service sees decoded frames, so compare on those). + from engagement_service import decode_frame + from engagement import EngagementDetector + decoded = [decode_frame(s) for s in b64] + assert all(d is not None for d in decoded) + print("[test] computing expected score in-process (loads models once)...") + expected = EngagementDetector().predict(decoded) + print("[test] expected score: %.8f" % expected) + + # 3. Launch the real service and wait for /health + print("[test] starting service on port %d ..." % PORT) + proc = subprocess.Popen(["./run_service.sh", "--port", str(PORT)]) + try: + deadline = time.time() + 120 + while True: + try: + status, _ = http("GET", "/health") + if status == 200: + break + except (urllib.error.URLError, ConnectionError): + pass # not up yet + if time.time() > deadline: + raise RuntimeError("service did not become healthy in 120s") + time.sleep(1) + print("[test] service healthy.") + + # 4. Assertions + status, body = http("POST", "/score", {"frames": b64}) + assert status == 200, body + got = body["score"] + print("[test] service score: %.8f (diff %.2e)" + % (got, abs(got - expected))) + assert abs(got - expected) < 1e-6, "HTTP score deviates from direct predict!" + + status, body = http("POST", "/score", {"frames": b64[:9]}) + assert status == 400, (status, body) + print("[test] 9 frames -> 400 OK:", body["error"]) + + bad = list(b64) + bad[3] = "definitely-not-a-jpeg" + status, body = http("POST", "/score", {"frames": bad}) + assert status == 400 and "3" in body["error"], (status, body) + print("[test] corrupt frame -> 400 OK:", body["error"]) + + status, _ = http("GET", "/nonsense") + assert status == 404 + print("[test] unknown path -> 404 OK") + + print("\nALL TESTS PASSED") + finally: + proc.terminate() + proc.wait(timeout=10) + print("[test] service stopped.") + + +if __name__ == "__main__": + main() From 510052c687d779272bc028b7caf9248f6f81fea7 Mon Sep 17 00:00:00 2001 From: ttm25 Date: Fri, 31 Jul 2026 12:50:30 +0100 Subject: [PATCH 3/4] added --host flag so the service can serve LAN clients --- standalone_demo/engagement_service.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/standalone_demo/engagement_service.py b/standalone_demo/engagement_service.py index 6e37683..d75efdf 100644 --- a/standalone_demo/engagement_service.py +++ b/standalone_demo/engagement_service.py @@ -101,6 +101,9 @@ def main(): global DETECTOR ap = argparse.ArgumentParser() ap.add_argument("--port", type=int, default=8100) + ap.add_argument("--host", default="127.0.0.1", + help="bind address; default localhost-only. Use 0.0.0.0 to " + "serve LAN clients (e.g. the app running on the robot's Pi)") ap.add_argument("--allow-standin", action="store_true", help="serve even without the real .h5 (testing only)") args = ap.parse_args() @@ -115,9 +118,9 @@ def main(): sys.exit(1) print("[startup] WARNING: serving STAND-IN scores (--allow-standin).", flush=True) - server = HTTPServer(("127.0.0.1", args.port), Handler) - print("[startup] ready on http://127.0.0.1:%d (GET /health, POST /score)" - % args.port, flush=True) + server = HTTPServer((args.host, args.port), Handler) + print("[startup] ready on http://%s:%d (GET /health, POST /score)" + % (args.host, args.port), flush=True) try: server.serve_forever() except KeyboardInterrupt: From de9cfc7b8ef7b1512a46e52dfbf8cb487ca7b83e Mon Sep 17 00:00:00 2001 From: ttm25 Date: Tue, 25 Aug 2026 10:05:34 +0100 Subject: [PATCH 4/4] fix: stale model weights download link updated --- download_model.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/download_model.sh b/download_model.sh index feebb09..eedafed 100755 --- a/download_model.sh +++ b/download_model.sh @@ -1,5 +1,5 @@ #!/bin/sh mkdir -p models -wget -O models/lstm_10_50_runsigm_runsigm.h5 'http://lcas.lincoln.ac.uk/owncloud/index.php/s/UHWU1g5qXU9RiAp/download' +wget -O models/lstm_10_50_runsigm_runsigm.h5 'https://lcas.lincoln.ac.uk/nextcloud/index.php/s/UHWU1g5qXU9RiAp/download'