From b8f71f6bee2a2282c1a815c97689c1f81780357a Mon Sep 17 00:00:00 2001 From: JobvAlewijk Date: Sun, 4 Jan 2026 18:56:04 +0100 Subject: [PATCH 1/8] feat: base face detection ref: N25B-397 --- .../endpoints/face_detector.py | 113 ++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 src/robot_interface/endpoints/face_detector.py diff --git a/src/robot_interface/endpoints/face_detector.py b/src/robot_interface/endpoints/face_detector.py new file mode 100644 index 0000000..b25f656 --- /dev/null +++ b/src/robot_interface/endpoints/face_detector.py @@ -0,0 +1,113 @@ +from __future__ import unicode_literals +import logging +import threading +import zmq + +from robot_interface.endpoints.socket_base import SocketBase +from robot_interface.state import state +from robot_interface.core.config import settings + + +class FaceDetectionSender(SocketBase): + """ + Face detection sender endpoint. + + Subscribes to ALFaceDetection and forwards face detection + events over ZeroMQ. + """ + + def __init__(self, zmq_context, port=settings.agent_settings.face_sender_port): + super(FaceDetectionSender, self).__init__("face") + self.create_socket(zmq_context, zmq.PUB, port) + + self._face_service = None + self._memory_service = None + self._subscriber = None + self._thread = None + + def start_face_detection(self): + """ + Initializes ALFaceDetection and starts listening for face events. + """ + if not state.qi_session: + logging.info("No Qi session available. Not starting face detection.") + return + + import qi # Lazy import (same pattern as rest of codebase) + + self._face_service = state.qi_session.service("ALFaceDetection") + self._memory_service = state.qi_session.service("ALMemory") + + # Enable face detection + self._face_service.setTrackingEnabled(True) + self._face_service.setRecognitionEnabled(False) + + # Subscribe to FaceDetected memory event + self._subscriber = self._memory_service.subscriber("FaceDetected") + self._subscriber.signal.connect(self._on_face_detected) + + # Start keep-alive thread + self._thread = threading.Thread(target=self._face_loop) + self._thread.daemon = True + self._thread.start() + + logging.info("Face detection started.") + + def _face_loop(self): + """ + Keeps the face detection alive until shutdown. + """ + while not state.exit_event.is_set(): + state.exit_event.wait(0.1) + + self._cleanup() + + def _on_face_detected(self, value): + """ + Callback for ALMemory FaceDetected event. + + :param value: Face detection data structure from NAOqi + """ + if not value or len(value) < 2: + return + + timestamp = value[0] + faces = value[1] + + face_data = { + "timestamp": timestamp, + "face_count": len(faces), + "faces": [] + } + + for face in faces: + face_info = face[0] # Shape info + extra_info = face[1] # Extra info (ID, score, etc.) + + face_data["faces"].append({ + "alpha": face_info[1], + "beta": face_info[2], + "width": face_info[3], + "height": face_info[4], + "confidence": extra_info[1] if len(extra_info) > 1 else None + }) + + try: + self.socket.send_json(face_data) + except Exception: + logging.warn("Failed to send face detection data.") + + def _cleanup(self): + """ + Cleanup subscriptions and disable face detection. + """ + try: + if self._subscriber: + self._subscriber.signal.disconnect(self._on_face_detected) + + if self._face_service: + self._face_service.setTrackingEnabled(False) + + logging.info("Face detection stopped.") + except Exception: + logging.warn("Error during face detection cleanup.") -- 2.49.1 From 3b470c8f29ad337d47cee117526b770c43b29484 Mon Sep 17 00:00:00 2001 From: JobvAlewijk Date: Wed, 7 Jan 2026 17:56:21 +0100 Subject: [PATCH 2/8] feat: fully working face detection ref: N25B-397 --- src/robot_interface/core/config.py | 4 + .../endpoints/face_detector.py | 100 +++++++----------- .../endpoints/main_receiver.py | 26 +++++ src/robot_interface/main.py | 8 ++ 4 files changed, 75 insertions(+), 63 deletions(-) diff --git a/src/robot_interface/core/config.py b/src/robot_interface/core/config.py index 9e638b5..3caec21 100644 --- a/src/robot_interface/core/config.py +++ b/src/robot_interface/core/config.py @@ -17,6 +17,8 @@ class AgentSettings(object): :vartype video_sender_port: int :ivar audio_sender_port: Port used for sending audio data, defaults to 5558. :vartype audio_sender_port: int + :ivar face_detection_port: Port used for sending face detection events, defaults to 5559. + :vartype face_detection_port: int """ def __init__( self, @@ -25,12 +27,14 @@ class AgentSettings(object): main_receiver_port=None, video_sender_port=None, audio_sender_port=None, + face_detection_port=None, ): self.control_backend_host = get_config(control_backend_host, "AGENT__CONTROL_BACKEND_HOST", "localhost") self.actuation_receiver_port = get_config(actuation_receiver_port, "AGENT__ACTUATION_RECEIVER_PORT", 5557, int) self.main_receiver_port = get_config(main_receiver_port, "AGENT__MAIN_RECEIVER_PORT", 5555, int) self.video_sender_port = get_config(video_sender_port, "AGENT__VIDEO_SENDER_PORT", 5556, int) self.audio_sender_port = get_config(audio_sender_port, "AGENT__AUDIO_SENDER_PORT", 5558, int) + self.face_detection_port = get_config(face_detection_port, "AGENT__FACE_DETECTION_PORT", 5559, int) class VideoConfig(object): diff --git a/src/robot_interface/endpoints/face_detector.py b/src/robot_interface/endpoints/face_detector.py index b25f656..75e0930 100644 --- a/src/robot_interface/endpoints/face_detector.py +++ b/src/robot_interface/endpoints/face_detector.py @@ -1,52 +1,49 @@ from __future__ import unicode_literals + import logging import threading -import zmq +import time from robot_interface.endpoints.socket_base import SocketBase from robot_interface.state import state -from robot_interface.core.config import settings class FaceDetectionSender(SocketBase): """ - Face detection sender endpoint. + Minimal face detection sender with a shared flag. - Subscribes to ALFaceDetection and forwards face detection - events over ZeroMQ. + Polls ALMemory["FaceDetected"] and keeps a simple boolean + indicating if a face is currently detected. """ - def __init__(self, zmq_context, port=settings.agent_settings.face_sender_port): + def __init__(self, zmq_context, port=None): super(FaceDetectionSender, self).__init__("face") - self.create_socket(zmq_context, zmq.PUB, port) - + if port: + self.create_socket(zmq_context, None, port) # PUB not used here self._face_service = None self._memory_service = None - self._subscriber = None self._thread = None + # Shared status for MainReceiver polling + self.face_detected = False + self._last_seen_face_time = 0 + def start_face_detection(self): - """ - Initializes ALFaceDetection and starts listening for face events. - """ if not state.qi_session: logging.info("No Qi session available. Not starting face detection.") return - import qi # Lazy import (same pattern as rest of codebase) - + import qi self._face_service = state.qi_session.service("ALFaceDetection") self._memory_service = state.qi_session.service("ALMemory") - # Enable face detection - self._face_service.setTrackingEnabled(True) + # Enable minimal detection + self._face_service.setTrackingEnabled(False) self._face_service.setRecognitionEnabled(False) - # Subscribe to FaceDetected memory event - self._subscriber = self._memory_service.subscriber("FaceDetected") - self._subscriber.signal.connect(self._on_face_detected) + # Required to activate extractor + self._face_service.subscribe("FaceDetectionSender", 500, 0.0) - # Start keep-alive thread self._thread = threading.Thread(target=self._face_loop) self._thread.daemon = True self._thread.start() @@ -58,56 +55,33 @@ class FaceDetectionSender(SocketBase): Keeps the face detection alive until shutdown. """ while not state.exit_event.is_set(): - state.exit_event.wait(0.1) + try: + value = self._memory_service.getData("FaceDetected", 0) + face_present = ( + value + and len(value) > 1 + and value[1] + and value[1][0] + and len(value[1][0]) > 0 + ) - self._cleanup() + now = time.time() + if face_present: + self._last_seen_face_time = now - def _on_face_detected(self, value): - """ - Callback for ALMemory FaceDetected event. + # Consider face "lost" after 3s + self.face_detected = (now - self._last_seen_face_time) < 3 - :param value: Face detection data structure from NAOqi - """ - if not value or len(value) < 2: - return + except Exception: + logging.exception("Error reading FaceDetected") - timestamp = value[0] - faces = value[1] - - face_data = { - "timestamp": timestamp, - "face_count": len(faces), - "faces": [] - } - - for face in faces: - face_info = face[0] # Shape info - extra_info = face[1] # Extra info (ID, score, etc.) - - face_data["faces"].append({ - "alpha": face_info[1], - "beta": face_info[2], - "width": face_info[3], - "height": face_info[4], - "confidence": extra_info[1] if len(extra_info) > 1 else None - }) + time.sleep(0.1) + def stop_face_detection(self): try: - self.socket.send_json(face_data) - except Exception: - logging.warn("Failed to send face detection data.") - - def _cleanup(self): - """ - Cleanup subscriptions and disable face detection. - """ - try: - if self._subscriber: - self._subscriber.signal.disconnect(self._on_face_detected) - if self._face_service: + self._face_service.unsubscribe("FaceDetectionSender") self._face_service.setTrackingEnabled(False) - logging.info("Face detection stopped.") except Exception: - logging.warn("Error during face detection cleanup.") + logging.warning("Error during face detection cleanup.") diff --git a/src/robot_interface/endpoints/main_receiver.py b/src/robot_interface/endpoints/main_receiver.py index 2882970..0a479d9 100644 --- a/src/robot_interface/endpoints/main_receiver.py +++ b/src/robot_interface/endpoints/main_receiver.py @@ -4,6 +4,7 @@ from robot_interface.endpoints.receiver_base import ReceiverBase from robot_interface.state import state from robot_interface.core.config import settings +from robot_interface.endpoints.face_detector import FaceDetectionSender class MainReceiver(ReceiverBase): @@ -36,6 +37,29 @@ class MainReceiver(ReceiverBase): :rtype: dict[str, str | list[dict]] """ return {"endpoint": "ping", "data": message.get("data")} + + @staticmethod + def _handle_face(message): + """ + Handles sending face data to the cb + Sends if it sees a face or not + + :param message: face data. + :type message: int + + :return: A response to CB containing the amount of faces + :rtype: int + """ + # Poll the FaceDetectionSender status + face_sender = next( + (s for s in state.sockets if isinstance(s, FaceDetectionSender)), + None + ) + if face_sender: + return {"endpoint": "face", "data": face_sender.face_detected} + else: + return {"endpoint": "face", "data": False} + @staticmethod def _handle_port_negotiation(message): @@ -86,6 +110,8 @@ class MainReceiver(ReceiverBase): """ if message["endpoint"] == "ping": return self._handle_ping(message) + elif message["endpoint"] == "face": + return self._handle_face(message) elif message["endpoint"].startswith("negotiate"): return self._handle_negotiation(message) diff --git a/src/robot_interface/main.py b/src/robot_interface/main.py index 816e53b..4b32873 100644 --- a/src/robot_interface/main.py +++ b/src/robot_interface/main.py @@ -12,6 +12,8 @@ from robot_interface.endpoints.video_sender import VideoSender from robot_interface.state import state from robot_interface.core.config import settings from robot_interface.utils.timeblock import TimeBlock +from robot_interface.endpoints.face_detector import FaceDetectionSender + def main_loop(context): @@ -35,6 +37,12 @@ def main_loop(context): video_sender.start_video_rcv() audio_sender.start() + # --- Face detection sender --- + face_sender = FaceDetectionSender(context) + state.sockets.append(face_sender) + face_sender.start_face_detection() + + # Sockets that can run on the main thread. These sockets' endpoints should not block for long (say 50 ms at most). receivers = [main_receiver, actuation_receiver] -- 2.49.1 From 49386ef8cd7394505be3cfcec34430793ee1cf4f Mon Sep 17 00:00:00 2001 From: JobvAlewijk Date: Mon, 12 Jan 2026 14:25:10 +0100 Subject: [PATCH 3/8] feat: communicate face to CB Had to do some weird socket stuff ref: N25B-397 --- src/robot_interface/endpoints/face_detector.py | 3 +++ src/robot_interface/endpoints/main_receiver.py | 7 ++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/robot_interface/endpoints/face_detector.py b/src/robot_interface/endpoints/face_detector.py index 75e0930..ca53798 100644 --- a/src/robot_interface/endpoints/face_detector.py +++ b/src/robot_interface/endpoints/face_detector.py @@ -28,6 +28,9 @@ class FaceDetectionSender(SocketBase): self.face_detected = False self._last_seen_face_time = 0 + def endpoint_description(self): + return None + def start_face_detection(self): if not state.qi_session: logging.info("No Qi session available. Not starting face detection.") diff --git a/src/robot_interface/endpoints/main_receiver.py b/src/robot_interface/endpoints/main_receiver.py index 0a479d9..c5341fd 100644 --- a/src/robot_interface/endpoints/main_receiver.py +++ b/src/robot_interface/endpoints/main_receiver.py @@ -74,7 +74,12 @@ class MainReceiver(ReceiverBase): :return: A response dictionary with endpoint descriptions as data. :rtype: dict[str, list[dict]] """ - endpoints = [socket.endpoint_description() for socket in state.sockets] + endpoints = [ + socket.endpoint_description() + for socket in state.sockets + if socket.endpoint_description() is not None + ] + return {"endpoint": "negotiate/ports", "data": endpoints} -- 2.49.1 From 83099a28103d833dc17d5204d25e74e0e887feaa Mon Sep 17 00:00:00 2001 From: JobvAlewijk Date: Sat, 17 Jan 2026 14:01:32 +0100 Subject: [PATCH 4/8] chore: modified into req reply socket on 5559 --- .../endpoints/face_detector.py | 76 ++++++++++++++----- .../endpoints/main_receiver.py | 31 +------- 2 files changed, 60 insertions(+), 47 deletions(-) diff --git a/src/robot_interface/endpoints/face_detector.py b/src/robot_interface/endpoints/face_detector.py index ca53798..b45d088 100644 --- a/src/robot_interface/endpoints/face_detector.py +++ b/src/robot_interface/endpoints/face_detector.py @@ -1,65 +1,78 @@ from __future__ import unicode_literals +import json import logging import threading import time +import zmq from robot_interface.endpoints.socket_base import SocketBase from robot_interface.state import state +from robot_interface.core.config import settings class FaceDetectionSender(SocketBase): """ - Minimal face detection sender with a shared flag. + Face detection REP endpoint. - Polls ALMemory["FaceDetected"] and keeps a simple boolean - indicating if a face is currently detected. + - Polls ALMemory["FaceDetected"] + - Maintains a rolling boolean state + - Responds to REQ polling over ZMQ REP """ - def __init__(self, zmq_context, port=None): + def __init__(self, zmq_context, port=settings.agent_settings.face_detection_port): super(FaceDetectionSender, self).__init__("face") + if port: - self.create_socket(zmq_context, None, port) # PUB not used here + self.create_socket(zmq_context, zmq.REP, port) + self._face_service = None self._memory_service = None - self._thread = None - # Shared status for MainReceiver polling self.face_detected = False self._last_seen_face_time = 0 + self._face_thread = None + self._rep_thread = None + def endpoint_description(self): - return None + return "face" + + # ------------------------------------------------------------------ + # Face detection logic (runs independently) + # ------------------------------------------------------------------ def start_face_detection(self): if not state.qi_session: - logging.info("No Qi session available. Not starting face detection.") + logging.warning("No Qi session available. Face detection not started.") return import qi + self._face_service = state.qi_session.service("ALFaceDetection") self._memory_service = state.qi_session.service("ALMemory") - # Enable minimal detection self._face_service.setTrackingEnabled(False) self._face_service.setRecognitionEnabled(False) - # Required to activate extractor self._face_service.subscribe("FaceDetectionSender", 500, 0.0) - self._thread = threading.Thread(target=self._face_loop) - self._thread.daemon = True - self._thread.start() + self._face_thread = threading.Thread(target=self._face_loop, daemon=True) + self._face_thread.start() - logging.info("Face detection started.") + self._rep_thread = threading.Thread(target=self._rep_loop, daemon=True) + self._rep_thread.start() + + logging.info("Face detection + REP endpoint started.") def _face_loop(self): """ - Keeps the face detection alive until shutdown. + Continuously updates `self.face_detected`. """ while not state.exit_event.is_set(): try: value = self._memory_service.getData("FaceDetected", 0) + face_present = ( value and len(value) > 1 @@ -72,7 +85,7 @@ class FaceDetectionSender(SocketBase): if face_present: self._last_seen_face_time = now - # Consider face "lost" after 3s + # Face considered lost after 3 seconds self.face_detected = (now - self._last_seen_face_time) < 3 except Exception: @@ -80,6 +93,35 @@ class FaceDetectionSender(SocketBase): time.sleep(0.1) + # ------------------------------------------------------------------ + # REP loop (THIS WAS THE MISSING PIECE) + # ------------------------------------------------------------------ + + def _rep_loop(self): + """ + Handles ZMQ REQ/REP polling from the Control Backend. + """ + while not state.exit_event.is_set(): + try: + # Receive request (content ignored) + _ = self.socket.recv() + + # Respond with current face state + response = { + "endpoint": "face", + "data": self.face_detected, + } + + self.socket.send_json(response) + + except Exception: + logging.exception("Error in face detection REP loop") + time.sleep(0.1) + + # ------------------------------------------------------------------ + # Cleanup + # ------------------------------------------------------------------ + def stop_face_detection(self): try: if self._face_service: diff --git a/src/robot_interface/endpoints/main_receiver.py b/src/robot_interface/endpoints/main_receiver.py index c5341fd..29dba60 100644 --- a/src/robot_interface/endpoints/main_receiver.py +++ b/src/robot_interface/endpoints/main_receiver.py @@ -37,28 +37,6 @@ class MainReceiver(ReceiverBase): :rtype: dict[str, str | list[dict]] """ return {"endpoint": "ping", "data": message.get("data")} - - @staticmethod - def _handle_face(message): - """ - Handles sending face data to the cb - Sends if it sees a face or not - - :param message: face data. - :type message: int - - :return: A response to CB containing the amount of faces - :rtype: int - """ - # Poll the FaceDetectionSender status - face_sender = next( - (s for s in state.sockets if isinstance(s, FaceDetectionSender)), - None - ) - if face_sender: - return {"endpoint": "face", "data": face_sender.face_detected} - else: - return {"endpoint": "face", "data": False} @staticmethod @@ -74,12 +52,7 @@ class MainReceiver(ReceiverBase): :return: A response dictionary with endpoint descriptions as data. :rtype: dict[str, list[dict]] """ - endpoints = [ - socket.endpoint_description() - for socket in state.sockets - if socket.endpoint_description() is not None - ] - + endpoints = [socket.endpoint_description() for socket in state.sockets] return {"endpoint": "negotiate/ports", "data": endpoints} @@ -115,8 +88,6 @@ class MainReceiver(ReceiverBase): """ if message["endpoint"] == "ping": return self._handle_ping(message) - elif message["endpoint"] == "face": - return self._handle_face(message) elif message["endpoint"].startswith("negotiate"): return self._handle_negotiation(message) -- 2.49.1 From 4afceccf46ce6ab7bb19ec2b4b261a14a4053610 Mon Sep 17 00:00:00 2001 From: JobvAlewijk Date: Mon, 19 Jan 2026 16:58:01 +0100 Subject: [PATCH 5/8] feat: fixed connection !!! --- src/robot_interface/endpoints/face_detector.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/robot_interface/endpoints/face_detector.py b/src/robot_interface/endpoints/face_detector.py index b45d088..e33e83d 100644 --- a/src/robot_interface/endpoints/face_detector.py +++ b/src/robot_interface/endpoints/face_detector.py @@ -57,10 +57,10 @@ class FaceDetectionSender(SocketBase): self._face_service.subscribe("FaceDetectionSender", 500, 0.0) - self._face_thread = threading.Thread(target=self._face_loop, daemon=True) + self._face_thread = threading.Thread(target=self._face_loop) self._face_thread.start() - self._rep_thread = threading.Thread(target=self._rep_loop, daemon=True) + self._rep_thread = threading.Thread(target=self._rep_loop) self._rep_thread.start() logging.info("Face detection + REP endpoint started.") @@ -93,9 +93,6 @@ class FaceDetectionSender(SocketBase): time.sleep(0.1) - # ------------------------------------------------------------------ - # REP loop (THIS WAS THE MISSING PIECE) - # ------------------------------------------------------------------ def _rep_loop(self): """ @@ -118,9 +115,6 @@ class FaceDetectionSender(SocketBase): logging.exception("Error in face detection REP loop") time.sleep(0.1) - # ------------------------------------------------------------------ - # Cleanup - # ------------------------------------------------------------------ def stop_face_detection(self): try: -- 2.49.1 From 815fc7bcde1c465c2628cc6e60bbac37c56d94f6 Mon Sep 17 00:00:00 2001 From: Twirre Meulenbelt <43213592+TwirreM@users.noreply.github.com> Date: Thu, 29 Jan 2026 17:18:39 +0100 Subject: [PATCH 6/8] feat: publish face detection instead of req/res ref: N25B-395 --- src/robot_interface/core/config.py | 4 + .../endpoints/face_detector.py | 73 +++++-------------- 2 files changed, 21 insertions(+), 56 deletions(-) diff --git a/src/robot_interface/core/config.py b/src/robot_interface/core/config.py index 3caec21..13d0027 100644 --- a/src/robot_interface/core/config.py +++ b/src/robot_interface/core/config.py @@ -19,6 +19,8 @@ class AgentSettings(object): :vartype audio_sender_port: int :ivar face_detection_port: Port used for sending face detection events, defaults to 5559. :vartype face_detection_port: int + :ivar face_detection_interval: Time between face detection events, defaults to 1000 ms. + :vartype face_detection_interval: int """ def __init__( self, @@ -28,6 +30,7 @@ class AgentSettings(object): video_sender_port=None, audio_sender_port=None, face_detection_port=None, + face_detection_interval=None, ): self.control_backend_host = get_config(control_backend_host, "AGENT__CONTROL_BACKEND_HOST", "localhost") self.actuation_receiver_port = get_config(actuation_receiver_port, "AGENT__ACTUATION_RECEIVER_PORT", 5557, int) @@ -35,6 +38,7 @@ class AgentSettings(object): self.video_sender_port = get_config(video_sender_port, "AGENT__VIDEO_SENDER_PORT", 5556, int) self.audio_sender_port = get_config(audio_sender_port, "AGENT__AUDIO_SENDER_PORT", 5558, int) self.face_detection_port = get_config(face_detection_port, "AGENT__FACE_DETECTION_PORT", 5559, int) + self.face_detection_interval = get_config(face_detection_interval, "AGENT__FACE_DETECTION_INTERVAL", 1000, int) class VideoConfig(object): diff --git a/src/robot_interface/endpoints/face_detector.py b/src/robot_interface/endpoints/face_detector.py index e33e83d..e686fd9 100644 --- a/src/robot_interface/endpoints/face_detector.py +++ b/src/robot_interface/endpoints/face_detector.py @@ -13,61 +13,47 @@ from robot_interface.core.config import settings class FaceDetectionSender(SocketBase): """ - Face detection REP endpoint. + Face detection endpoint. - - Polls ALMemory["FaceDetected"] - - Maintains a rolling boolean state - - Responds to REQ polling over ZMQ REP + Subscribes to and polls ALMemory["FaceDetected"], sends events to CB. """ def __init__(self, zmq_context, port=settings.agent_settings.face_detection_port): super(FaceDetectionSender, self).__init__("face") - if port: - self.create_socket(zmq_context, zmq.REP, port) + self.create_socket(zmq_context, zmq.PUB, port) self._face_service = None self._memory_service = None - self.face_detected = False - self._last_seen_face_time = 0 - self._face_thread = None - self._rep_thread = None - - def endpoint_description(self): - return "face" - - # ------------------------------------------------------------------ - # Face detection logic (runs independently) - # ------------------------------------------------------------------ def start_face_detection(self): if not state.qi_session: logging.warning("No Qi session available. Face detection not started.") return - import qi - self._face_service = state.qi_session.service("ALFaceDetection") self._memory_service = state.qi_session.service("ALMemory") self._face_service.setTrackingEnabled(False) self._face_service.setRecognitionEnabled(False) - self._face_service.subscribe("FaceDetectionSender", 500, 0.0) + self._face_service.subscribe( + "FaceDetectionSender", + settings.agent_settings.face_detection_interval, + 0.0, + ) self._face_thread = threading.Thread(target=self._face_loop) self._face_thread.start() - self._rep_thread = threading.Thread(target=self._rep_loop) - self._rep_thread.start() - - logging.info("Face detection + REP endpoint started.") + logging.info("Face detection started.") def _face_loop(self): """ - Continuously updates `self.face_detected`. + Continuously send face detected to the CB, at the interval set in the + ``start_face_detection`` method. """ while not state.exit_event.is_set(): try: @@ -81,40 +67,11 @@ class FaceDetectionSender(SocketBase): and len(value[1][0]) > 0 ) - now = time.time() - if face_present: - self._last_seen_face_time = now - - # Face considered lost after 3 seconds - self.face_detected = (now - self._last_seen_face_time) < 3 - + self.socket.send(json.dumps({"face_detected": face_present}).encode("utf-8")) except Exception: logging.exception("Error reading FaceDetected") - time.sleep(0.1) - - - def _rep_loop(self): - """ - Handles ZMQ REQ/REP polling from the Control Backend. - """ - while not state.exit_event.is_set(): - try: - # Receive request (content ignored) - _ = self.socket.recv() - - # Respond with current face state - response = { - "endpoint": "face", - "data": self.face_detected, - } - - self.socket.send_json(response) - - except Exception: - logging.exception("Error in face detection REP loop") - time.sleep(0.1) - + time.sleep(settings.agent_settings.face_detection_interval / 1000.0) def stop_face_detection(self): try: @@ -124,3 +81,7 @@ class FaceDetectionSender(SocketBase): logging.info("Face detection stopped.") except Exception: logging.warning("Error during face detection cleanup.") + + def close(self): + super(FaceDetectionSender, self).close() + self.stop_face_detection() -- 2.49.1 From 18a4bde4ca28ad2019c5f234ec13d40607f2e6cf Mon Sep 17 00:00:00 2001 From: JobvAlewijk Date: Thu, 29 Jan 2026 17:51:39 +0100 Subject: [PATCH 7/8] test: added tests and docstrings ref: N25B-397 --- test/unit/test_face_detection_sender.py | 170 ++++++++++++++++++++++++ test/unit/test_main.py | 5 + 2 files changed, 175 insertions(+) create mode 100644 test/unit/test_face_detection_sender.py diff --git a/test/unit/test_face_detection_sender.py b/test/unit/test_face_detection_sender.py new file mode 100644 index 0000000..41ebd6c --- /dev/null +++ b/test/unit/test_face_detection_sender.py @@ -0,0 +1,170 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +import json +import mock +import pytest + +from robot_interface.endpoints.face_detector import FaceDetectionSender +from robot_interface.state import state + + +@pytest.fixture(autouse=True) +def initialized_state(monkeypatch): + """ + Fully initialize global state so __getattribute__ allows access. + """ + # Bypass the initialization guard + monkeypatch.setattr(state, "is_initialized", True, raising=False) + + # Install a controllable exit_event + exit_event = mock.Mock() + exit_event.is_set = mock.Mock(return_value=True) + monkeypatch.setattr(state, "exit_event", exit_event, raising=False) + + # Default qi_session is None unless overridden + monkeypatch.setattr(state, "qi_session", None, raising=False) + + yield + + +def test_start_face_detection_no_qi_session(): + """ + Returns early when qi_session is None. + """ + sender = FaceDetectionSender(mock.Mock()) + sender.start_face_detection() + + assert sender._face_thread is None + assert sender._face_service is None + assert sender._memory_service is None + + +def test_start_face_detection_happy_path(mocker): + """ + Initializes services and starts background thread. + """ + mock_face = mock.Mock() + mock_memory = mock.Mock() + + mock_qi = mock.Mock() + mock_qi.service.side_effect = lambda name: { + "ALFaceDetection": mock_face, + "ALMemory": mock_memory, + }[name] + + state.qi_session = mock_qi + + fake_thread = mock.Mock() + mocker.patch("threading.Thread", return_value=fake_thread) + + sender = FaceDetectionSender(mock.Mock()) + sender.start_face_detection() + + mock_face.setTrackingEnabled.assert_called_with(False) + mock_face.setRecognitionEnabled.assert_called_with(False) + mock_face.subscribe.assert_called_once() + fake_thread.start.assert_called_once() + + +def test_face_loop_face_detected_true(mocker): + """ + Sends face_detected=True when face data exists. + """ + sender = FaceDetectionSender(mock.Mock()) + + sender._memory_service = mock.Mock() + sender._memory_service.getData.return_value = [0, [[1]]] + sender.socket = mock.Mock() + + mocker.patch("time.sleep") + state.exit_event.is_set.side_effect = [False, True] + + sender._face_loop() + + sent = sender.socket.send.call_args[0][0] + payload = json.loads(sent.decode("utf-8")) + + assert payload["face_detected"] is True + + +def test_face_loop_face_detected_false(mocker): + """ + Sends face_detected=False when no face data exists. + """ + sender = FaceDetectionSender(mock.Mock()) + + sender._memory_service = mock.Mock() + sender._memory_service.getData.return_value = [] + sender.socket = mock.Mock() + + mocker.patch("time.sleep") + state.exit_event.is_set.side_effect = [False, True] + + sender._face_loop() + + sent = sender.socket.send.call_args[0][0] + payload = json.loads(sent.decode("utf-8")) + + assert not payload["face_detected"] + + +def test_face_loop_handles_exception(mocker): + """ + Exceptions inside loop are swallowed. + """ + sender = FaceDetectionSender(mock.Mock()) + + sender._memory_service = mock.Mock() + sender._memory_service.getData.side_effect = Exception("boom") + sender.socket = mock.Mock() + + mocker.patch("time.sleep") + state.exit_event.is_set.side_effect = [False, True] + + # Must not raise + sender._face_loop() + + +def test_stop_face_detection_happy_path(): + """ + Unsubscribes and disables tracking. + """ + sender = FaceDetectionSender(mock.Mock()) + + mock_face = mock.Mock() + sender._face_service = mock_face + + sender.stop_face_detection() + + mock_face.unsubscribe.assert_called_once() + mock_face.setTrackingEnabled.assert_called_with(False) + + +def test_stop_face_detection_exception(): + """ + stop_face_detection swallows service exceptions. + """ + sender = FaceDetectionSender(mock.Mock()) + + mock_face = mock.Mock() + mock_face.unsubscribe.side_effect = Exception("fail") + sender._face_service = mock_face + + sender.stop_face_detection() + + +def test_close_calls_stop_face_detection(mocker): + """ + close() calls parent close and stop_face_detection(). + """ + sender = FaceDetectionSender(mock.Mock()) + + mocker.patch.object(sender, "stop_face_detection") + mocker.patch( + "robot_interface.endpoints.face_detector.SocketBase.close" + ) + + sender.close() + + sender.stop_face_detection.assert_called_once() diff --git a/test/unit/test_main.py b/test/unit/test_main.py index f323630..afd4b05 100644 --- a/test/unit/test_main.py +++ b/test/unit/test_main.py @@ -55,6 +55,9 @@ class DummySender: def start(self): self.called = True + def start_face_detection(self): + self.called = True + def close(self): pass @@ -108,11 +111,13 @@ def patched_main_components(monkeypatch, fake_sockets, fake_poll): fake_act = FakeReceiver(act_sock) video_sender = DummySender() audio_sender = DummySender() + face_sender = DummySender() monkeypatch.setattr(main_mod, "MainReceiver", lambda ctx: fake_main) monkeypatch.setattr(main_mod, "ActuationReceiver", lambda ctx: fake_act) monkeypatch.setattr(main_mod, "VideoSender", lambda ctx: video_sender) monkeypatch.setattr(main_mod, "AudioSender", lambda ctx: audio_sender) + monkeypatch.setattr(main_mod, "FaceDetectionSender", lambda ctx: face_sender) # Register sockets for the fake poller fake_poll.registered = {main_sock: zmq.POLLIN, act_sock: zmq.POLLIN} -- 2.49.1 From e129113c9e848f9a95b41ab677575fe272293c1c Mon Sep 17 00:00:00 2001 From: JobvAlewijk Date: Thu, 29 Jan 2026 17:53:57 +0100 Subject: [PATCH 8/8] chore: forgot copyright --- src/robot_interface/endpoints/face_detector.py | 6 ++++++ test/unit/test_face_detection_sender.py | 5 +++++ 2 files changed, 11 insertions(+) diff --git a/src/robot_interface/endpoints/face_detector.py b/src/robot_interface/endpoints/face_detector.py index e686fd9..b8485e5 100644 --- a/src/robot_interface/endpoints/face_detector.py +++ b/src/robot_interface/endpoints/face_detector.py @@ -1,3 +1,9 @@ +# -*- coding: utf-8 -*- +""" +This program has been developed by students from the bachelor Computer Science at Utrecht +University within the Software Project course. +© Copyright Utrecht University (Department of Information and Computing Sciences) +""" from __future__ import unicode_literals import json diff --git a/test/unit/test_face_detection_sender.py b/test/unit/test_face_detection_sender.py index 41ebd6c..64661d8 100644 --- a/test/unit/test_face_detection_sender.py +++ b/test/unit/test_face_detection_sender.py @@ -1,4 +1,9 @@ # -*- coding: utf-8 -*- +""" +This program has been developed by students from the bachelor Computer Science at Utrecht +University within the Software Project course. +© Copyright Utrecht University (Department of Information and Computing Sciences) +""" from __future__ import unicode_literals import json -- 2.49.1