Source code for sic_framework.devices.common_naoqi.motion_affect_transformation
"""
"""
[docs]
class MotionAffectTransformation:
"""
Apply affect-based transformations to NAOqi motion dictionaries.
The methods adjust amplitude, timing, posture, and enforce joint limits based on valence/arousal or emotion labels.
"""
[docs]
def transform_values(self, motion, valence, arousal):
"""
Transform a motion by applying flow, time, and weight changes based on valence and arousal.
:param dict motion: Motion dictionary with the shape `{ "motion": { joint_name: {"angles": list[float], "times": list[float]} } }`.
:param float valence: Valence value in [-1, 1] influencing amplitude and posture.
:param float arousal: Arousal value in [-1, 1] influencing repetition and speed.
:returns: The transformed motion dictionary.
:rtype: dict
"""
motion = self.modify_flow_parameters(motion, valence)
motion = self.modify_time_parameters(motion, arousal)
motion = self.modify_weight_parameters(motion, valence, arousal)
return self.angle_limit(motion)
[docs]
def transform_label(self, motion, emotion_label):
"""
Transform a motion by first mapping an emotion label to valence/arousal values.
:param dict motion: Motion dictionary to transform.
:param str emotion_label: Discrete emotion label (e.g., "happy", "sad", "angry").
:returns: The transformed motion dictionary.
:rtype: dict
"""
valence, arousal = self.values_from_emotion(emotion_label)
return self.transform_values(motion, valence, arousal)
[docs]
def angle_limit(self, motion):
"""
Clamp joint angles to the robot's physical limits.
:param dict motion: Motion dictionary to validate and clamp.
:returns: The motion dictionary with angles limited to valid ranges.
:rtype: dict
"""
for jointName in motion["motion"].keys():
if jointName not in self.hand_joints and jointName not in self.leg_joints:
minimum, maximum = self.limit_check(jointName)
for angle in motion["motion"][jointName]["angles"]:
index = motion["motion"][jointName]["angles"].index(angle)
print(angle)
if angle < minimum:
new_angle = minimum
print(
jointName,
": ",
angle,
" is smaller than ",
minimum,
" so changed to ",
new_angle,
)
motion["motion"][jointName]["angles"][index] = new_angle
elif angle > maximum:
new_angle = maximum
print(
jointName,
": ",
angle,
" is larger than ",
maximum,
" so changed to ",
new_angle,
)
motion["motion"][jointName]["angles"][index] = new_angle
else:
pass
pass
return motion
[docs]
def modify_flow_parameters(self, motion, valence):
"""
Adjust motion amplitudes and blend towards a linear trajectory based on valence.
:param dict motion: Motion dictionary to modify.
:param float valence: Valence value affecting amplitude (positive increases, negative slightly decreases).
:returns: The updated motion dictionary.
:rtype: dict
"""
amplitude = self.amplitude(valence)
pivot_states = self.pivot_states(motion, self.leg_joints)
theta_init = pivot_states[0]
theta_end = pivot_states[-1]
for jointName in motion["motion"].keys():
if jointName not in self.leg_joints:
for i in range(0, len(motion["motion"][jointName]["times"])):
normalized_time = (
motion["motion"][jointName]["times"][i]
- motion["motion"][jointName]["times"][0]
) / (
motion["motion"][jointName]["times"][-1]
- motion["motion"][jointName]["times"][0]
+ 1
)
line_angle = (
theta_init * (1 - normalized_time) + theta_end * normalized_time
)
motion["motion"][jointName]["angles"][i] = (
amplitude * motion["motion"][jointName]["angles"][i]
+ (1 - amplitude) * line_angle
)
return motion
[docs]
def modify_time_parameters(self, motion, arousal):
"""
Adjust repetition (implicit via angle scaling) and speed (time scaling) based on arousal.
:param dict motion: Motion dictionary to modify.
:param float arousal: Arousal value affecting repetition and speed.
:returns: The updated motion dictionary.
:rtype: dict
"""
repetitions = self.repetition(arousal)
for jointName in motion["motion"].keys():
if jointName not in self.leg_joints:
angles = []
for angle in motion["motion"][jointName]["angles"]:
angle = angle * (repetitions + 1)
angles.append(angle)
motion["motion"][jointName]["angles"] = angles
speed = self.speed(arousal)
for jointName in motion["motion"].keys():
if jointName not in self.leg_joints:
times = []
for time in motion["motion"][jointName]["times"]:
time = time * float(speed)
times.append(time)
motion["motion"][jointName]["times"] = times
return motion
[docs]
def modify_weight_parameters(self, motion, valence, arousal):
"""
Add or adjust posture-related joints (e.g., head pitch) based on valence and arousal.
:param dict motion: Motion dictionary to modify.
:param float valence: Valence value influencing posture.
:param float arousal: Arousal value influencing posture and added joints.
:returns: The updated motion dictionary.
:rtype: dict
"""
head_pose = self.head_pose(valence, arousal)
first_joint = list(motion["motion"].keys())[0]
start_time = motion["motion"][first_joint]["times"][0]
if "HeadPitch" not in motion["motion"].keys():
motion["motion"]["HeadPitch"] = {
"angles": [head_pose, head_pose],
"times": [0, start_time],
}
else:
motion["motion"]["HeadPitch"]["angles"] = [
(head_pose + x) for x in motion["motion"]["HeadPitch"]["angles"]
]
if arousal < -0.5:
for jointName in self.bend:
if jointName not in self.leg_joints:
if jointName not in motion["motion"]:
motion["motion"][jointName] = {
"angles": [self.bend[jointName], self.bend[jointName]],
"times": [0, start_time],
}
elif arousal > 0.5:
for jointName in self.upright:
if jointName not in self.leg_joints:
if jointName not in motion["motion"]:
motion["motion"][jointName] = {
"angles": [
self.upright[jointName],
self.upright[jointName],
],
"times": [0, start_time],
}
else:
for jointName in self.neutral:
if jointName not in self.leg_joints:
if jointName not in motion["motion"]:
motion["motion"][jointName] = {
"angles": [
self.neutral[jointName],
self.neutral[jointName],
],
"times": [0, start_time],
}
return motion
[docs]
@staticmethod
def pivot_states(motion, ignore_joints):
"""
Collect unique time points across joints, excluding specified joints.
:param dict motion: Motion dictionary containing joint time arrays.
:param list[str] ignore_joints: Joint names to ignore when collecting time points.
:returns: Sorted unique list of time points.
:rtype: list[float]
"""
time_points = []
for joint_name in motion["motion"].keys():
if joint_name not in ignore_joints:
times = motion["motion"][joint_name]["times"]
for time in times:
time_points.append(time)
return sorted(set(time_points))
[docs]
@staticmethod
def amplitude(valence):
"""
Map valence to an amplitude scaling factor.
:param float valence: Valence value in [-1, 1].
:returns: Amplitude multiplier (>0).
:rtype: float
"""
# correlation between amplitude of motion and valence of the affect
if valence > 0:
amplitude_factor = 1 + valence
else:
amplitude_factor = 1 + 0.5 * valence
return amplitude_factor
[docs]
@staticmethod
def repetition(arousal):
"""
Map arousal to a repetition factor.
Positive arousal increases the factor; non-positive returns 1.
:param float arousal: Arousal value in [-1, 1].
:returns: Repetition multiplier (>=1).
:rtype: float
"""
# positive arousal is associated with an increase in the repetition of the motion
# negative arousal does not change the repetition of the motion
if arousal > 0:
repetition_factor = 1 + abs(2 * arousal)
else:
repetition_factor = 1
return repetition_factor
[docs]
@staticmethod
def speed(arousal):
"""
Map arousal to a speed scaling factor for time values.
:param float arousal: Arousal value in [-1, 1].
:returns: Speed multiplier (>0).
:rtype: float
"""
# speed influences the perceived arousal: increase portrays high arousal,
# whereas reduction in speed portrays low arousal
if arousal > 0:
speed_factor = 1 + arousal
else:
speed_factor = 1 - 0.5 * arousal
return speed_factor
[docs]
@staticmethod
def head_pose(valence, arousal, up=0.506145, down=0.349066):
"""
Compute a head pitch offset based on valence and arousal.
:param float valence: Valence value.
:param float arousal: Arousal value.
:param float up: Maximum upward pitch in radians.
:param float down: Maximum downward pitch in radians.
:returns: Head pitch offset in radians.
:rtype: float
"""
# vertical head pose is important for expressing affects in the first and third quadrant
# pre-defined angels for up and down are set
#
if valence < 0 and arousal < 0:
headpose = -down * valence
elif valence > 0 and arousal > 0:
headpose = up * valence
else:
headpose = 0
return headpose
@property
def leg_joints(self):
"""
Retrieve the list of leg joint names.
:returns: List of leg joint identifiers.
:rtype: list[str]
"""
return [
"LAnklePitch",
"LAnkleRoll",
"LHipPitch",
"LHipRoll",
"LHipYawPitch",
"LKneePitch",
"RAnklePitch",
"RAnkleRoll",
"RHipPitch",
"RHipRoll",
"RHipYawPitch",
"RKneePitch",
]
@property
def hand_joints(self):
"""
Retrieve the list of hand joint names.
:returns: List of hand joint identifiers.
:rtype: list[str]
"""
return ["LHand", "RHand"]
@property
def upright(self):
"""
Retrieve a posture dictionary for an upright (expanded) stance.
:returns: Mapping from joint name to target angle in radians.
:rtype: dict[str, float]
"""
return { # also called expanded
"LHipYawPitch": -0.17,
"LHipRoll": 0.09,
"LHipPitch": 0.13,
"LKneePitch": -0.08,
"LAnklePitch": 0.08,
"LAnkleRoll": -0.13,
"RHipYawPitch": -0.17,
"RHipRoll": -0.09,
"RHipPitch": 0.13,
"RKneePitch": -0.08,
"RAnklePitch": 0.08,
"RAnkleRoll": 0.13,
}
@property
def neutral(self):
"""
Retrieve a posture dictionary for a neutral stance.
:returns: Mapping from joint name to target angle in radians.
:rtype: dict[str, float]
"""
return {
"LHipYawPitch": 0.0,
"LHipRoll": 0.0,
"LHipPitch": 0.0,
"LKneePitch": 0.0,
"LAnklePitch": 0.0,
"LAnkleRoll": 0.0,
"RHipYawPitch": 0.0,
"RHipRoll": 0.0,
"RHipPitch": 0.0,
"RKneePitch": 0.0,
"RAnklePitch": 0.0,
"RAnkleRoll": 0.0,
}
@property
def bend(self):
"""
Retrieve a posture dictionary for a bent (shrunk) stance.
:returns: Mapping from joint name to target angle in radians.
:rtype: dict[str, float]
"""
return { # also called shrunk
"LHipYawPitch": 0.0,
"LHipRoll": 0.0,
"LHipPitch": -0.44,
"LKneePitch": 0.69,
"LAnklePitch": -0.35,
"LAnkleRoll": 0.0,
"RHipYawPitch": 0.0,
"RHipRoll": 0.0,
"RHipPitch": -0.44,
"RKneePitch": 0.69,
"RAnklePitch": -0.35,
"RAnkleRoll": 0.0,
}
[docs]
@staticmethod
def limit_check(joint):
"""
Look up the minimum and maximum allowed angles for a given joint.
:param str joint: Joint name to query.
:returns: Tuple of (minimum, maximum) allowed angles in radians.
:rtype: tuple[float, float]
"""
limit_table = {
"HeadYaw": {"minimum": -2.0857, "maximum": 2.0857},
"HeadPitch": {"minimum": -0.6720, "maximum": 0.5149},
"LShoulderPitch": {"minimum": -2.0857, "maximum": 2.0857},
"LShoulderRoll": {"minimum": -0.3142, "maximum": 1.3265},
"LElbowYaw": {"minimum": -2.0857, "maximum": 2.0857},
"LElbowRoll": {"minimum": -1.5446, "maximum": -0.0349},
"LWristYaw": {"minimum": -1.8238, "maximum": 1.8238},
"RShoulderPitch": {"minimum": -2.0857, "maximum": 2.0857},
"RShoulderRoll": {"minimum": 1.3265, "maximum": 0.3142},
"RElbowYaw": {"minimum": -2.0857, "maximum": 2.0857},
"RElbowRoll": {"minimum": 0.0349, "maximum": 1.5446},
"RWristYaw": {"minimum": -1.8238, "maximum": 1.8238},
"LHipYawPitch": {"minimum": -1.145303, "maximum": 0.740810},
"RHipYawPitch": {"minimum": -1.145303, "maximum": 0.740810},
"LHipRoll": {"minimum": -0.379472, "maximum": 0.790477},
"LHipPitch": {"minimum": -1.535889, "maximum": 0.484090},
"LKneePitch": {"minimum": -0.092346, "maximum": 2.112528},
"LAnklePitch": {"minimum": 1.189516, "maximum": 0.922747},
"LAnkleRoll": {"minimum": -0.397880, "maximum": 0.769001},
"RHipRoll": {"minimum": -0.790477, "maximum": 0.379472},
"RHipPitch": {"minimum": -1.535889, "maximum": 0.484090},
"RKneePitch": {"minimum": -0.103083, "maximum": 2.120198},
"RAnklePitch": {"minimum": 1.186448, "maximum": 0.932056},
"RAnkleRoll": {"minimum": -0.768992, "maximum": 0.397880},
}
return limit_table[joint]["minimum"], limit_table[joint]["maximum"]
[docs]
@staticmethod
def values_from_emotion(emotion_label):
"""
Map an emotion label to its (valence, arousal) pair.
:param str emotion_label: Emotion label key (e.g., "happy", "sad").
:returns: Tuple containing (valence, arousal).
:rtype: tuple[float, float]
"""
value_table = {
"excited": {"valence": 0.3, "arousal": 0.8}, # oranje
"happy": {"valence": 0.9, "arousal": 0.3}, # geel
"pleased": {"valence": 1, "arousal": 0.2}, # lichtguldenroedegeel
"content": {"valence": 0.95, "arousal": -0.2}, # lichtgeel
"calm": {"valence": 0.8, "arousal": -0.4}, # lichtgroen
"relaxed": {"valence": 0.7, "arousal": -0.5}, # ijsblauw
"sleepy": {"valence": 0, "arousal": -0.8}, # blauwpaars
"tired": {"valence": -0.4, "arousal": -0.85}, # paars
"sad": {"valence": -0.7, "arousal": -0.5}, # middenpaars
"frustrated": {"valence": -0.9, "arousal": 0.3}, # middenroodviolet
"disgust": {"valence": -0.4, "arousal": 0.6}, # dieproze
"angry": {"valence": -0.6, "arousal": 0.6}, # donkerrood
"afraid": {"valence": -0.7, "arousal": 0.8}, # donkergroen
"neutral": {"valence": 0, "arousal": 0}, # wit
}
return (
value_table[emotion_label]["valence"],
value_table[emotion_label]["arousal"],
)