// ----------------------------------------------------------------------- // // Copyright (c) Microsoft Corporation. All rights reserved. // // ----------------------------------------------------------------------- namespace Microsoft.Samples.Kinect.Webserver.Sensor { using System; using System.Collections.Generic; using System.Linq; using Microsoft.Kinect; using Microsoft.Kinect.Toolkit; using Microsoft.Kinect.Toolkit.Interaction; using Microsoft.Samples.Kinect.Webserver.Sensor.Serialization; /// /// Default implementation of interface. /// public class DefaultUserStateManager : IUserStateManager { /// /// Name of state representing a tracked user. /// public const string TrackedStateName = "tracked"; /// /// Name of state representing an engaged user. /// public const string EngagedStateName = "engaged"; /// /// Category of all events originating from this class. /// public const string EventCategory = "userState"; /// /// Event type for primary user changed event. /// public const string PrimaryUserChangedEventType = "primaryUserChanged"; /// /// Event type for user state changed event. /// public const string UserStatesChangedEventType = "userStatesChanged"; /// /// Length (in milliseconds) of period of inactivity required /// before users become candidates for tracking. /// internal const long MinimumInactivityBeforeTrackingMilliseconds = 500; /// /// Object used to keep track of user activity. /// private readonly UserActivityMeter activityMeter = new UserActivityMeter(); /// /// Object used to synchronize modifications to engagement state. /// private readonly object lockObject = new object(); /// /// Helper object used to keep track of previous set of tracked user ids while avoiding /// object allocation while processing each frame. /// private HashSet previousTrackedUserTrackingIds = new HashSet(); /// /// Map between user tracking IDs and user states exposed to clients. /// private Dictionary publicUserStates = new Dictionary(); /// /// Dictionary used to accumulate mappings between user tracking IDs and user states /// before they're ready to be exposed to clients. /// private Dictionary userStatesAccumulator = new Dictionary(); /// /// Initializes a new instance of the class. /// public DefaultUserStateManager() { this.TrackedUserTrackingIds = new HashSet(); } /// /// Event triggered whenever user state changes. /// public event EventHandler UserStateChanged; /// /// Dictionary mapping user tracking Ids to names used for states corresponding to /// those users. /// public IDictionary UserStates { get { return this.publicUserStates; } } /// /// Tracking ID corresponding to primary user. /// /// /// May be an invalid tracking id to represent that no user is currently primary. /// public int PrimaryUserTrackingId { get; set; } /// /// Tracking ID corresponding to engaged user. /// /// /// May be an invalid tracking id to represent that no user is currently engaged. /// private int EngagedUserTrackingId { get; set; } /// /// Set of tracking Ids corresponding to users currently considered to be tracked. /// private HashSet TrackedUserTrackingIds { get; set; } /// /// Resets all state to the initial state, with no users remembered as engaged or tracked. /// public void Reset() { using (var callbackLock = new CallbackLock(this.lockObject)) { this.activityMeter.Clear(); this.TrackedUserTrackingIds.Clear(); this.EngagedUserTrackingId = SharedConstants.InvalidUserTrackingId; this.SetPrimaryUserTrackingId(SharedConstants.InvalidUserTrackingId, callbackLock); this.UpdateUserStates(callbackLock); } } /// /// Determines which users should be tracked in the future, based on selection /// metrics and engagement state. /// /// /// Array of skeletons from which the appropriate user tracking Ids will be selected. /// /// /// Timestamp from skeleton frame. /// /// /// Array that will contain the tracking Ids of users to track, sorted from most /// important to least important user to track. /// public void ChooseTrackedUsers(Skeleton[] frameSkeletons, long timestamp, int[] chosenTrackingIds) { if (frameSkeletons == null) { throw new ArgumentNullException("frameSkeletons"); } if (chosenTrackingIds == null) { throw new ArgumentNullException("chosenTrackingIds"); } var availableSkeletons = new List( from skeleton in frameSkeletons where (skeleton.TrackingId != SharedConstants.InvalidUserTrackingId) && ((skeleton.TrackingState == SkeletonTrackingState.Tracked) || (skeleton.TrackingState == SkeletonTrackingState.PositionOnly)) select skeleton); var trackingCandidateSkeletons = new List(); // Update user activity metrics this.activityMeter.Update(availableSkeletons, timestamp); foreach (var skeleton in availableSkeletons) { UserActivityRecord record; if (this.activityMeter.TryGetActivityRecord(skeleton.TrackingId, out record)) { // The tracked skeletons become candidate skeletons for tracking if we have an activity record for them. trackingCandidateSkeletons.Add(skeleton); } } // sort the currently tracked skeletons according to our tracking choice criteria trackingCandidateSkeletons.Sort((left, right) => this.ComputeTrackingMetric(right).CompareTo(this.ComputeTrackingMetric(left))); for (int i = 0; i < chosenTrackingIds.Length; ++i) { chosenTrackingIds[i] = (i < trackingCandidateSkeletons.Count) ? trackingCandidateSkeletons[i].TrackingId : SharedConstants.InvalidUserTrackingId; } } /// /// Called whenever the set of tracked users has changed. /// /// /// User information from which we'll update the set of tracked users and the primary user. /// /// /// Interaction frame timestamp corresponding to given user information. /// public void UpdateUserInformation(IEnumerable trackedUserInfo, long timestamp) { bool foundEngagedUser = false; int firstTrackedUser = SharedConstants.InvalidUserTrackingId; using (var callbackLock = new CallbackLock(this.lockObject)) { this.previousTrackedUserTrackingIds.Clear(); var nextTrackedIds = this.previousTrackedUserTrackingIds; this.previousTrackedUserTrackingIds = this.TrackedUserTrackingIds; this.TrackedUserTrackingIds = nextTrackedIds; var trackedUserInfoArray = trackedUserInfo as UserInfo[] ?? trackedUserInfo.ToArray(); foreach (var userInfo in trackedUserInfoArray) { if (userInfo.SkeletonTrackingId == SharedConstants.InvalidUserTrackingId) { continue; } if (this.EngagedUserTrackingId == userInfo.SkeletonTrackingId) { this.TrackedUserTrackingIds.Add(userInfo.SkeletonTrackingId); foundEngagedUser = true; } else if (HasTrackedHands(userInfo) && (this.previousTrackedUserTrackingIds.Contains(userInfo.SkeletonTrackingId) || this.IsInactive(userInfo, timestamp))) { // Keep track of the non-engaged users we find that have at least one // tracked hand pointer and also either (1) were previously tracked or // (2) are not moving too much this.TrackedUserTrackingIds.Add(userInfo.SkeletonTrackingId); if (firstTrackedUser == SharedConstants.InvalidUserTrackingId) { // Consider the first non-engaged, stationary user as a candidate for engagement firstTrackedUser = userInfo.SkeletonTrackingId; } } } // If engaged user was not found in list of candidate users, engaged user has become invalid. if (!foundEngagedUser) { this.EngagedUserTrackingId = SharedConstants.InvalidUserTrackingId; } // Decide who should be the primary user, if anyone this.UpdatePrimaryUser(trackedUserInfoArray, callbackLock); // If there's a primary user, it is the preferred candidate for engagement. // Otherwise, the first tracked user seen is the preferred candidate. int candidateUserTrackingId = (this.PrimaryUserTrackingId != SharedConstants.InvalidUserTrackingId) ? this.PrimaryUserTrackingId : firstTrackedUser; // If there is a valid candidate user that is not already the engaged user if ((candidateUserTrackingId != SharedConstants.InvalidUserTrackingId) && (candidateUserTrackingId != this.EngagedUserTrackingId)) { // If there is currently no engaged user, or if candidate user is the // primary user controlling interactions while the currently engaged user // is not interacting if ((this.EngagedUserTrackingId == SharedConstants.InvalidUserTrackingId) || (candidateUserTrackingId == this.PrimaryUserTrackingId)) { this.PromoteCandidateToEngaged(candidateUserTrackingId); } } // Update user states as the very last action, to include results from updates // performed so far this.UpdateUserStates(callbackLock); } } /// /// Promote candidate user to be the engaged user. /// /// /// Tracking Id of user to be promoted to engaged user. /// If tracking Id does not match the Id of one of the currently tracked users, /// no action is taken. /// /// /// True if specified candidate could be confirmed as the new engaged user, /// false otherwise. /// public bool PromoteCandidateToEngaged(int candidateTrackingId) { bool isConfirmed = false; if ((candidateTrackingId != SharedConstants.InvalidUserTrackingId) && this.TrackedUserTrackingIds.Contains(candidateTrackingId)) { using (var callbackLock = new CallbackLock(this.lockObject)) { this.EngagedUserTrackingId = candidateTrackingId; this.UpdateUserStates(callbackLock); } isConfirmed = true; } return isConfirmed; } /// /// Tries to get the last position observed for the specified user tracking Id. /// /// /// User tracking Id for which we're finding the last position observed. /// /// /// Skeleton point, if last position is being tracked for specified /// tracking Id, null otherwise. /// public SkeletonPoint? TryGetLastPositionForId(int trackingId) { if (SharedConstants.InvalidUserTrackingId == trackingId) { return null; } UserActivityRecord record; if (this.activityMeter.TryGetActivityRecord(trackingId, out record)) { return record.LastPosition; } return null; } /// /// Get a JSON friendly array of user-tracking-id-to-state mapping entries /// representing the specified user state map. /// /// /// Dictionary mapping user tracking ids to user state names. /// /// /// Array of objects. /// internal static StateMappingEntry[] GetStateMappingEntryArray(IDictionary userStates) { var mappingEntries = new StateMappingEntry[userStates.Count]; int entryIndex = 0; foreach (var userStateEntry in userStates) { mappingEntries[entryIndex] = new StateMappingEntry { id = userStateEntry.Key, userState = userStateEntry.Value }; ++entryIndex; } return mappingEntries; } internal void SetPrimaryUserTrackingId(int newId, CallbackLock callbackLock) { int oldId = this.PrimaryUserTrackingId; this.PrimaryUserTrackingId = newId; if (oldId != newId) { callbackLock.LockExit += () => this.SendUserStateChanged( new UserTrackingIdChangedEventMessage { category = EventCategory, eventType = PrimaryUserChangedEventType, oldValue = oldId, newValue = newId }); } } /// /// Determine if any of the specified user's hands is tracked. /// /// /// User information from which to determine hand tracking status. /// /// /// True if user has at least one tracked hand pointer. False otherwise. /// private static bool HasTrackedHands(UserInfo userInfo) { return userInfo.HandPointers.Any(handPointer => handPointer.IsTracked); } /// /// Update the primary user being tracked. /// /// /// User information collection from which we will choose a primary user. /// /// /// Lock used to delay all events until after we exit lock section. /// private void UpdatePrimaryUser(IEnumerable candidateUserInfo, CallbackLock callbackLock) { int firstPrimaryUserCandidate = SharedConstants.InvalidUserTrackingId; bool currentPrimaryUserStillPrimary = false; bool engagedUserIsPrimary = false; var trackingIdsAvailable = new HashSet(); foreach (var userInfo in candidateUserInfo) { if (userInfo.SkeletonTrackingId == SharedConstants.InvalidUserTrackingId) { continue; } trackingIdsAvailable.Add(userInfo.SkeletonTrackingId); foreach (var handPointer in userInfo.HandPointers) { if (handPointer.IsPrimaryForUser) { if (this.PrimaryUserTrackingId == userInfo.SkeletonTrackingId) { // If the current primary user still has an active hand, we should continue to consider them the primary user. currentPrimaryUserStillPrimary = true; } else if (SharedConstants.InvalidUserTrackingId == firstPrimaryUserCandidate) { // Else if this is the first user with an active hand, they are the alternative candidate for primary user. firstPrimaryUserCandidate = userInfo.SkeletonTrackingId; } if (this.EngagedUserTrackingId == userInfo.SkeletonTrackingId) { engagedUserIsPrimary = true; } } } } // If engaged user has a primary hand, always pick that user as primary user. // If current primary user still has a primary hand, let them remain primary. // Otherwise default to first primary user candidate seen. int primaryUserTrackingId = engagedUserIsPrimary ? this.EngagedUserTrackingId : (currentPrimaryUserStillPrimary ? this.PrimaryUserTrackingId : firstPrimaryUserCandidate); this.SetPrimaryUserTrackingId(primaryUserTrackingId, callbackLock); } /// /// Calculate how valuable it will be to keep tracking the specified skeleton. /// /// /// Skeleton that is one of several candidates for tracking. /// /// /// A non-negative metric that estimates how valuable it is to keep tracking /// the specified skeleton. The higher the value, the more valuable the skeleton /// is estimated to be. /// private double ComputeTrackingMetric(Skeleton skeleton) { const double MaxCameraDistance = 4.0; // Give preference to engaged users, then to tracked users, then to users // near the center of the Kinect Sensor's field of view that are also // closer (distance) to the KinectSensor and not moving around too much. const double EngagedWeight = 100.0; const double TrackedWeight = 50.0; const double AngleFromCenterWeight = 1.30; const double DistanceFromCameraWeight = 1.15; const double BodyMovementWeight = 0.05; double engagedMetric = (skeleton.TrackingId == this.EngagedUserTrackingId) ? 1.0 : 0.0; double trackedMetric = this.TrackedUserTrackingIds.Contains(skeleton.TrackingId) ? 1.0 : 0.0; double angleFromCenterMetric = (skeleton.Position.Z > 0.0) ? (1.0 - Math.Abs(2 * Math.Atan(skeleton.Position.X / skeleton.Position.Z) / Math.PI)) : 0.0; double distanceFromCameraMetric = (MaxCameraDistance - skeleton.Position.Z) / MaxCameraDistance; UserActivityRecord activityRecord; double bodyMovementMetric = this.activityMeter.TryGetActivityRecord(skeleton.TrackingId, out activityRecord) ? 1.0 - activityRecord.ActivityLevel : 0.0; return (EngagedWeight * engagedMetric) + (TrackedWeight * trackedMetric) + (AngleFromCenterWeight * angleFromCenterMetric) + (DistanceFromCameraWeight * distanceFromCameraMetric) + (BodyMovementWeight * bodyMovementMetric); } /// /// Determine if the specified user information represents a user that has been /// relatively inactive for at least a minimum period of time required for tracking. /// /// /// User information from which to determine inactivity. /// /// /// Current timestamp used to determine how long user has been inactive. /// /// /// True if user is present in scene and has been inactive for a minimum threshold /// period of time. /// private bool IsInactive(UserInfo userInfo, long timestamp) { UserActivityRecord record; return this.activityMeter.TryGetActivityRecord(userInfo.SkeletonTrackingId, out record) && !record.IsActive && (record.StateTransitionTimestamp + MinimumInactivityBeforeTrackingMilliseconds <= timestamp); } /// /// Determines if user states have changed. /// /// /// true if accumulated user states are different from the ones currently visible /// to clients. /// private bool HaveUserStatesChanged() { if (this.publicUserStates.Count != this.userStatesAccumulator.Count) { return true; } foreach (var stateEntry in this.publicUserStates) { string accumulatorState; if (!this.userStatesAccumulator.TryGetValue(stateEntry.Key, out accumulatorState)) { // Key is absent from accumulator but present in current state map return true; } if (!stateEntry.Value.Equals(accumulatorState)) { // state names are present in both maps, but they're different return true; } } return false; } /// /// Update user states exposed to clients, if necessary. /// /// /// Lock used to delay all events until after we exit lock section. /// private void UpdateUserStates(CallbackLock callbackLock) { this.userStatesAccumulator.Clear(); // Add states for tracked users foreach (var trackingId in this.TrackedUserTrackingIds) { this.userStatesAccumulator.Add(trackingId, TrackedStateName); } if (this.EngagedUserTrackingId != SharedConstants.InvalidUserTrackingId) { // Engaged state supersedes all other states this.userStatesAccumulator[this.EngagedUserTrackingId] = EngagedStateName; } if (this.HaveUserStatesChanged()) { var temporaryMap = this.publicUserStates; this.publicUserStates = this.userStatesAccumulator; this.userStatesAccumulator = temporaryMap; var userStatesToSend = GetStateMappingEntryArray(this.publicUserStates); callbackLock.LockExit += () => this.SendUserStateChanged( new UserStatesChangedEventMessage { category = EventCategory, eventType = UserStatesChangedEventType, userStates = userStatesToSend }); } } /// /// Send UserStateChanged event if there are any subscribers. /// /// /// Message to send. /// private void SendUserStateChanged(EventMessage message) { if (this.UserStateChanged != null) { this.UserStateChanged(this, new UserStateChangedEventArgs(message)); } } } }