Skip to content

Commit 7112511

Browse files
committed
FEAT: Change position data from Point back to a large JSON object and create functions to support it. See commit description
We are making changes to allow `state_manager_node` to manage all sensory data and make decisions. For this, we simply passing through the face JSON object to the face / plaipin display node, and the head motion / head tracking node (that calls the dynamixel node). In state_manager_node we have also comment out subscribing to `/vision/face_position` and instead leverage a new topic `/vision/face_position_v2` and callback `face_position_callback_v2`
1 parent fd8a242 commit 7112511

File tree

4 files changed

+172
-11
lines changed

4 files changed

+172
-11
lines changed

coffee_ws/src/coffee_expressions/coffee_expressions/plaipin_expressive_eyes.py

Lines changed: 123 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import time
66
from geometry_msgs.msg import Point
77
from coffee_expressions_msgs.msg import AffectiveState
8+
import json
89
import os
910
import sys
1011

@@ -32,10 +33,21 @@ def __init__(self):
3233
# self.screen = pygame.display.set_mode((self.screen_width, self.screen_height))
3334
pygame.display.set_caption("Coffee Buddy - Plaipin Eyes")
3435

36+
# Add parameters for mapping
37+
self.declare_parameter('invert_x', False) # Default FALSE for correct eye movement
38+
self.declare_parameter('invert_y', False) # Default FALSE for correct eye movement
39+
40+
# The constraint for the values of the eye movement in the UI
41+
self.declare_parameter('eye_range', 1.0) # Max range for eye movement (-3.0 to 3.0)
42+
43+
self.invert_x = self.get_parameter('invert_x').value
44+
self.invert_y = self.get_parameter('invert_y').value
45+
self.eye_range = self.get_parameter('eye_range').value
46+
3547
# Create custom eye configuration
3648
config = EyeConfig(
37-
width=120, # Scaled down for 800x400 display
38-
height=480, # Scaled down for 800x400 display
49+
width=200, # Scaled down for 800x400 display
50+
height=720, # Scaled down for 800x400 display
3951
spacing=140, # Scaled down for 800x400 display
4052
blink_interval=120,
4153
blink_speed=0.1,
@@ -95,13 +107,117 @@ def affective_state_callback(self, msg: AffectiveState):
95107
if not msg.is_idle:
96108
# Convert ROS Point to normalized coordinates for plaipin
97109
# Assuming gaze_target is in the range [-1, 1] for x and y
98-
self.eye_controller.set_eye_positions(
99-
(msg.gaze_target.x, msg.gaze_target.y)
100-
)
110+
# TODO: COMMENTED THIS OUT
111+
# self.eye_controller.set_eye_positions(
112+
# (msg.gaze_target.x, msg.gaze_target.y)
113+
# )
114+
self.handle_faces(msg.gaze_target_v2)
115+
# self.eye_controller.set_eye_positions((gaze_target_x, gaze_target_y))
116+
101117
else:
102118
# Return to center when idle
103-
# self.eye_controller.set_eye_positions((0.0, 0.0))
104-
self.eye_controller.set_eye_positions((msg.gaze_target.x, msg.gaze_target.y))
119+
self.eye_controller.set_eye_positions((0.0, 0.0))
120+
# self.eye_controller.set_eye_positions((msg.gaze_target.x, msg.gaze_target.y))
121+
122+
# SEE `face_data_callback` in `coffee_eyes.py` for details
123+
def handle_faces(self, msg):
124+
"""Process incoming face detection data"""
125+
try:
126+
# Parse the JSON data
127+
data = json.loads(msg)
128+
129+
# Update frame dimensions if provided
130+
if 'frame_width' in data and 'frame_height' in data:
131+
self.frame_width = data['frame_width']
132+
self.frame_height = data['frame_height']
133+
134+
# Update face positions
135+
self.face_positions = data.get('faces', [])
136+
self.last_face_update = time.time()
137+
138+
# If no faces detected, just return
139+
if not self.face_positions:
140+
self.target_face_position = None
141+
return
142+
143+
# Select target face (largest/closest)
144+
largest_area = 0
145+
largest_face = None
146+
147+
for face in self.face_positions:
148+
width = face['x2'] - face['x1']
149+
height = face['y2'] - face['y1']
150+
area = width * height
151+
152+
if area > largest_area:
153+
largest_area = area
154+
largest_face = face
155+
156+
if largest_face:
157+
self.target_face_position = (
158+
largest_face['center_x'],
159+
largest_face['center_y']
160+
)
161+
162+
# Log face position before transformation
163+
face_x = self.target_face_position[0]
164+
face_y = self.target_face_position[1]
165+
center_x = self.frame_width / 2
166+
center_y = self.frame_height / 2
167+
dx = face_x - center_x
168+
dy = face_y - center_y
169+
170+
self.get_logger().debug(f"Face detected at ({face_x:.1f}, {face_y:.1f}), offset from center: ({dx:.1f}, {dy:.1f})")
171+
172+
# Transform camera coordinates to eye controller coordinates
173+
eye_position = self.transform_camera_to_eye_coords(
174+
self.target_face_position[0],
175+
self.target_face_position[1]
176+
)
177+
178+
# Call go_to_pos only if we have a valid position
179+
if eye_position:
180+
# self.controller.go_to_pos(eye_position)
181+
self.eye_controller.set_eye_positions((eye_position[0], eye_position[1]))
182+
self.get_logger().info(f'Moving eyes to position: ({eye_position[0]:.2f}, {eye_position[1]:.2f})')
183+
184+
except Exception as e:
185+
self.get_logger().error(f"Error processing face data: {e}")
186+
187+
188+
def transform_camera_to_eye_coords(self, camera_x, camera_y):
189+
"""Transform camera coordinates to eye controller coordinates (-3.0 to 3.0 range)"""
190+
# Normalize to -1.0 to 1.0
191+
# Note: We invert the coordinates to ensure proper eye direction
192+
# (When face is on right side, eyes should look right)
193+
norm_x = (camera_x - self.frame_width/2) / (self.frame_width/2)
194+
norm_y = (camera_y - self.frame_height/2) / (self.frame_height/2)
195+
196+
# Add sensitivity multiplier (like in eye_tracking.py)
197+
sensitivity = 1.5 # Higher = more sensitive eye movement
198+
norm_x *= sensitivity
199+
norm_y *= sensitivity
200+
201+
# Apply inversions if configured
202+
# Note: By default we want norm_x to be positive when face is on right side
203+
# So default should have invert_x=False
204+
if self.invert_x:
205+
norm_x = -norm_x
206+
if self.invert_y:
207+
norm_y = -norm_y
208+
209+
# Scale to eye controller range (-3.0 to 3.0)
210+
eye_x = norm_x * self.eye_range
211+
eye_y = norm_y * self.eye_range
212+
213+
# Clamp values to valid range
214+
eye_x = max(-self.eye_range, min(self.eye_range, eye_x))
215+
eye_y = max(-self.eye_range, min(self.eye_range, eye_y))
216+
217+
# Debug output for tuning
218+
self.get_logger().debug(f'Camera coords: ({camera_x}, {camera_y}) -> Eye coords: ({eye_x}, {eye_y})')
219+
220+
return (eye_x, eye_y)
105221

106222
def run(self):
107223
"""Main animation loop"""

coffee_ws/src/coffee_expressions_msgs/msg/AffectiveState.msg

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,8 @@ string trigger_source # "audio", "vision", "payment", "coffee"
77
# Where the robot should direct its gaze (e.g. face position)
88
geometry_msgs/Point gaze_target
99

10+
# Where the robot should direct its gaze (e.g. face position)
11+
string gaze_target_v2
12+
1013
# Whether the robot is in idle state (e.g. no one is interacting)
11-
bool is_idle
14+
bool is_idle

coffee_ws/src/coffee_expressions_state_manager/coffee_expressions_state_manager/state_manager_node.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ def __init__(self):
4747
self._base_expression = self.get_parameter('default_expression').value
4848
self._last_voice_intent = "None"
4949
self._last_face_position = Point(x=0.0, y=0.0, z=1.0)
50+
self._last_face_position_v2 = String()
5051
self._override_expression: Optional[str] = None
5152
self._override_reason: Optional[str] = None
5253
self._override_expire_time: Optional[float] = None
@@ -61,8 +62,11 @@ def __init__(self):
6162
String, '/vision/emotion', self.vision_callback, qos)
6263
self.create_subscription(
6364
String, '/voice/intent', self.voice_callback, qos)
65+
# self.create_subscription(
66+
# Point, '/vision/face_position', self.face_position_callback, qos)
6467
self.create_subscription(
65-
Point, '/vision/face_position', self.face_position_callback, qos)
68+
String, '/vision/face_position_v2', self.face_position_callback_v2, qos)
69+
6670
self.create_subscription(
6771
String, '/system/event', self.event_callback, qos)
6872

@@ -115,6 +119,11 @@ def face_position_callback(self, msg: Point):
115119
"""Handle incoming face position updates."""
116120
self._last_face_position = msg
117121
self._last_active_time = time.time()
122+
123+
def face_position_callback_v2(self, msg: String):
124+
"""Handle incoming face position updates."""
125+
self._last_face_position_v2.data = msg.data
126+
self._last_active_time = time.time()
118127

119128
def event_callback(self, msg: String):
120129
"""Handle incoming system events."""
@@ -159,6 +168,7 @@ def publish_state(self):
159168
msg.expression = expression
160169
msg.trigger_source = trigger_source
161170
msg.gaze_target = self._last_face_position
171+
msg.gaze_target_v2 = self._last_face_position_v2.data
162172
msg.is_idle = is_idle
163173

164174
self.state_pub.publish(msg)

coffee_ws/src/coffee_head/coffee_head/camera_node.py

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ def __init__(self, node=None):
107107
if self.node:
108108
self.face_pub = node.create_publisher(String, 'face_detection_data', 10)
109109
self.face_position_pub = node.create_publisher(Point, '/vision/face_position', 10)
110+
self.face_position_pub_v2 = node.create_publisher(String, '/vision/face_position_v2', 10)
110111
self.frame_pub = node.create_publisher(Image, 'camera_frame', 10)
111112
self.face_image_pub = node.create_publisher(Image, 'face_images', 10)
112113
self.bridge = CvBridge()
@@ -203,6 +204,37 @@ def publish_face_data(self, faces):
203204
msg.data = json.dumps(face_data)
204205
self.face_pub.publish(msg)
205206

207+
def publish_face_position_v2(self, faces):
208+
"""Publish face detection data for other nodes"""
209+
if not self.node or not faces:
210+
return
211+
212+
# Create JSON with face data - convert NumPy types to Python native types
213+
face_data = {
214+
"timestamp": float(time.time()),
215+
"frame_width": int(self.frame_width),
216+
"frame_height": int(self.frame_height),
217+
"faces": [
218+
{
219+
"x1": int(face["x1"]),
220+
"y1": int(face["y1"]),
221+
"x2": int(face["x2"]),
222+
"y2": int(face["y2"]),
223+
"center_x": int(face["center_x"]),
224+
"center_y": int(face["center_y"]),
225+
"confidence": float(face["confidence"]),
226+
"id": str(face.get("id", "Unknown"))
227+
}
228+
for face in faces
229+
]
230+
}
231+
232+
# Publish
233+
msg = String()
234+
msg.data = json.dumps(face_data)
235+
self.face_position_pub_v2.publish(msg)
236+
237+
206238
def publish_face_position(self, faces):
207239
"""Process incoming face detection data"""
208240

@@ -870,17 +902,17 @@ def _publish_loop(self):
870902
self.publish_frame(frame)
871903
# Always publish face position, even when no faces are detected
872904
# This is used so that we can re-center the eyes -- zero them in.
873-
self.publish_face_position(faces)
905+
# self.publish_face_position(faces)
874906
# Only publish other face data if faces are detected
875907
if faces:
908+
self.publish_face_position_v2(faces)
876909
self.publish_face_data(faces)
877910
self.publish_face_images(frame, faces)
878911

879912
self.last_publish_time = current_time
880913
except Exception as e:
881914
self.error.emit(f"Error in publish thread: {str(e)}")
882915

883-
884916
class CameraViewer(QMainWindow):
885917
def __init__(self, node):
886918
super().__init__()

0 commit comments

Comments
 (0)