@@ -40,10 +40,18 @@ public bool IsMovingGaze
4040 internal NDArray ? RawGazeLocations = null ;
4141 internal NDArray ? FilteredGazeLocations = null ;
4242
43- internal NDArray ? DisplayedGazeLocations => _isGazeFilterEnabled && ( ( object ) FilteredGazeLocations != null )
43+ // the filtered or raw gaze, before manual keyframe corrections are applied
44+ private NDArray ? GazeBaseline => _isGazeFilterEnabled && ( ( object ) FilteredGazeLocations != null )
4445 ? FilteredGazeLocations
4546 : RawGazeLocations ;
4647
48+ // the gaze that is displayed and saved: the baseline with keyframe corrections applied
49+ internal NDArray ? CorrectedGazeLocations = null ;
50+
51+ internal NDArray ? DisplayedGazeLocations => ( object ) CorrectedGazeLocations != null
52+ ? CorrectedGazeLocations
53+ : GazeBaseline ;
54+
4755 protected override NDArray ? GazeData => DisplayedGazeLocations ;
4856
4957 public Color GazeStrokeColor
@@ -114,7 +122,10 @@ public bool IsGazeFilterEnabled
114122 if ( value )
115123 ApplyFilter ( ) ;
116124 else
125+ {
126+ RebuildCorrectedGaze ( ) ;
117127 RebuildTTLMarkerCache ( ) ;
128+ }
118129 }
119130 }
120131
@@ -267,6 +278,7 @@ public override async void LoadVideo()
267278 IsGazeLoaded = false ;
268279 RawGazeLocations = null ;
269280 FilteredGazeLocations = null ;
281+ CorrectedGazeLocations = null ;
270282 VideoKeyFrames . Clear ( ) ;
271283 GazeX = 0 ;
272284 GazeY = 0 ;
@@ -283,40 +295,45 @@ public override async void LoadVideo()
283295 // after gaze is manually edited, updates it.
284296 public void UpdateGaze ( )
285297 {
286- NDArray ? gazeLocations = DisplayedGazeLocations ;
287- if ( ( object ) gazeLocations == null || dataFrame == null )
298+ if ( ( object ) DisplayedGazeLocations == null || dataFrame == null )
288299 return ;
289300
290- double deltaX = GazeX - gazeLocations [ dataFrame , 0 ] ;
291- double deltaY = GazeY - gazeLocations [ dataFrame , 1 ] ;
292-
293- AddKeyFrame ( ) ;
294-
295- // if there is a keyframe before, ramp the delta from the previous keyframe
296- // to this new keyframe
297- if ( PreviousDataKeyFrame . HasValue )
298- {
299- int numFrames = dataFrame . Value - PreviousDataKeyFrame . Value ;
300- double multiplier ;
301- for ( int i = PreviousDataKeyFrame . Value ; i < dataFrame ; i ++ )
302- {
303- multiplier = ( double ) ( i - PreviousDataKeyFrame . Value ) / numFrames ;
304- gazeLocations [ i , 0 ] += multiplier * deltaX ;
305- gazeLocations [ i , 1 ] += multiplier * deltaY ;
306- }
307- }
308-
309- // update all following data frames with this delta
310- gazeLocations [ new Slice ( dataFrame . Value , null ) , 0 ] += deltaX ;
311- gazeLocations [ new Slice ( dataFrame . Value , null ) , 1 ] += deltaY ;
301+ AddKeyFrame ( CurrentVideoFrame , GazeX , GazeY ) ;
302+ UpdateCorrectedGazeAround ( dataFrame . Value ) ;
312303 }
313304
305+ /// <summary>
306+ /// Adds a keyframe at the current video frame, recording the displayed gaze position.
307+ /// </summary>
314308 public void AddKeyFrame ( )
315309 {
316310 AddKeyFrame ( CurrentVideoFrame ) ;
317311 }
318312
313+ /// <summary>
314+ /// Adds a keyframe at the given video frame, recording the currently displayed gaze position
315+ /// at that frame.
316+ /// </summary>
317+ /// <param name="frame">Video frame at which to add the keyframe</param>
319318 public void AddKeyFrame ( int frame )
319+ {
320+ if ( ! ( dataStartFrame . HasValue && ( frame >= dataStartFrame . Value ) &&
321+ ( frame <= dataEndFrame ) ) )
322+ return ;
323+ if ( ( object ) DisplayedGazeLocations == null )
324+ return ;
325+ int index = VideoTimeToDataIndex ( frame ) ;
326+ AddKeyFrame ( frame , ( double ) DisplayedGazeLocations [ index , 0 ] , ( double ) DisplayedGazeLocations [ index , 1 ] ) ;
327+ }
328+
329+ /// <summary>
330+ /// Adds a keyframe at the given video frame with an explicit gaze position, replacing any
331+ /// existing keyframe at the same data index. The keyframe list is kept sorted by video frame.
332+ /// </summary>
333+ /// <param name="frame">Video frame at which to add the keyframe</param>
334+ /// <param name="gazeX">Screen-space X of the gaze at the keyframe</param>
335+ /// <param name="gazeY">Screen-space Y of the gaze at the keyframe</param>
336+ public void AddKeyFrame ( int frame , double gazeX , double gazeY )
320337 {
321338 if ( dataStartFrame . HasValue && ( frame >= dataStartFrame . Value ) &&
322339 ( frame <= dataEndFrame ) )
@@ -333,12 +350,130 @@ public void AddKeyFrame(int frame)
333350 }
334351
335352 VideoKeyFrames . Add ( new VideoKeyFrame ( frame , index , videoReader . FramesToTimecode ( frame ) ,
336- DisplayedGazeLocations [ index , 0 ] , DisplayedGazeLocations [ index , 1 ] ) ) ;
353+ gazeX , gazeY ) ) ;
337354 VideoKeyFrames =
338355 new ObservableCollection < VideoKeyFrame > ( VideoKeyFrames . OrderBy ( ( keyframe ) => keyframe . VideoFrame ) ) ;
339356 }
340357 }
341358
359+ /// <summary>
360+ /// Rebuilds the corrected gaze array from the baseline and keyframes. Frames before the first
361+ /// keyframe stay at the baseline, the correction is linearly interpolated between consecutive
362+ /// keyframes, and the last keyframe's correction is held flat to the end of the data.
363+ /// </summary>
364+ private void RebuildCorrectedGaze ( )
365+ {
366+ NDArray ? baseline = GazeBaseline ;
367+ if ( ( object ) baseline == null )
368+ {
369+ CorrectedGazeLocations = null ;
370+ return ;
371+ }
372+
373+ NDArray corrected = baseline . Clone ( ) as NDArray ;
374+ if ( VideoKeyFrames . Count > 0 )
375+ {
376+ // interpolate the correction between each pair of consecutive keyframes
377+ for ( int keyFrameIndex = 0 ; keyFrameIndex < VideoKeyFrames . Count - 1 ; keyFrameIndex ++ )
378+ ApplyInterpolatedSegment ( baseline , corrected , VideoKeyFrames [ keyFrameIndex ] ,
379+ VideoKeyFrames [ keyFrameIndex + 1 ] ) ;
380+
381+ // hold the last keyframe's correction flat to the end of the data
382+ ApplyFlatTail ( baseline , corrected , VideoKeyFrames [ VideoKeyFrames . Count - 1 ] ) ;
383+ }
384+
385+ CorrectedGazeLocations = corrected ;
386+ }
387+
388+ /// <summary>
389+ /// Updates only the corrected-gaze segments adjacent to the keyframe at the given data index,
390+ /// after that keyframe was added or moved. Recomputes the segment from the previous keyframe and
391+ /// the segment to the next keyframe, or the flat tail if it is the last keyframe.
392+ /// </summary>
393+ /// <param name="dataIndex">Data index of the keyframe that changed</param>
394+ private void UpdateCorrectedGazeAround ( int dataIndex )
395+ {
396+ NDArray ? baseline = GazeBaseline ;
397+ if ( ( object ) baseline == null || ( object ) CorrectedGazeLocations == null )
398+ {
399+ RebuildCorrectedGaze ( ) ;
400+ return ;
401+ }
402+
403+ int keyFramePosition = - 1 ;
404+ for ( int i = 0 ; i < VideoKeyFrames . Count ; i ++ )
405+ if ( VideoKeyFrames [ i ] . DataIndex == dataIndex )
406+ {
407+ keyFramePosition = i ;
408+ break ;
409+ }
410+ if ( keyFramePosition < 0 )
411+ {
412+ RebuildCorrectedGaze ( ) ;
413+ return ;
414+ }
415+
416+ VideoKeyFrame editedKeyFrame = VideoKeyFrames [ keyFramePosition ] ;
417+
418+ // recompute the segment behind the edited keyframe
419+ if ( keyFramePosition > 0 )
420+ ApplyInterpolatedSegment ( baseline , CorrectedGazeLocations ,
421+ VideoKeyFrames [ keyFramePosition - 1 ] , editedKeyFrame ) ;
422+
423+ // recompute the segment ahead of the edited keyframe, or the flat tail if it is last
424+ if ( keyFramePosition < VideoKeyFrames . Count - 1 )
425+ ApplyInterpolatedSegment ( baseline , CorrectedGazeLocations ,
426+ editedKeyFrame , VideoKeyFrames [ keyFramePosition + 1 ] ) ;
427+ else
428+ ApplyFlatTail ( baseline , CorrectedGazeLocations , editedKeyFrame ) ;
429+ }
430+
431+ /// <summary>
432+ /// Writes the baseline plus a linearly interpolated correction into the corrected array over the
433+ /// half-open range from the first keyframe's data index to the second keyframe's data index. The
434+ /// correction ramps from the start keyframe's offset to the end keyframe's offset.
435+ /// </summary>
436+ /// <param name="baseline">Baseline gaze the correction is added to</param>
437+ /// <param name="corrected">Corrected gaze array written into</param>
438+ /// <param name="startKeyFrame">Keyframe at the start of the segment</param>
439+ /// <param name="endKeyFrame">Keyframe at the end of the segment</param>
440+ private void ApplyInterpolatedSegment ( NDArray baseline , NDArray corrected ,
441+ VideoKeyFrame startKeyFrame , VideoKeyFrame endKeyFrame )
442+ {
443+ int startIndex = startKeyFrame . DataIndex ;
444+ int endIndex = endKeyFrame . DataIndex ;
445+ double startDeltaX = startKeyFrame . GazeX - ( double ) baseline [ startIndex , 0 ] ;
446+ double startDeltaY = startKeyFrame . GazeY - ( double ) baseline [ startIndex , 1 ] ;
447+ double endDeltaX = endKeyFrame . GazeX - ( double ) baseline [ endIndex , 0 ] ;
448+ double endDeltaY = endKeyFrame . GazeY - ( double ) baseline [ endIndex , 1 ] ;
449+ int numFrames = endIndex - startIndex ;
450+ for ( int i = startIndex ; i < endIndex ; i ++ )
451+ {
452+ double multiplier = numFrames == 0 ? 0.0 : ( double ) ( i - startIndex ) / numFrames ;
453+ corrected [ i , 0 ] = ( double ) baseline [ i , 0 ] + startDeltaX + multiplier * ( endDeltaX - startDeltaX ) ;
454+ corrected [ i , 1 ] = ( double ) baseline [ i , 1 ] + startDeltaY + multiplier * ( endDeltaY - startDeltaY ) ;
455+ }
456+ }
457+
458+ /// <summary>
459+ /// Writes the baseline plus the keyframe's correction held flat from the keyframe's data index to
460+ /// the end of the data.
461+ /// </summary>
462+ /// <param name="baseline">Baseline gaze the correction is added to</param>
463+ /// <param name="corrected">Corrected gaze array written into</param>
464+ /// <param name="keyFrame">Keyframe whose correction is held flat to the end</param>
465+ private void ApplyFlatTail ( NDArray baseline , NDArray corrected , VideoKeyFrame keyFrame )
466+ {
467+ int startIndex = keyFrame . DataIndex ;
468+ double deltaX = keyFrame . GazeX - ( double ) baseline [ startIndex , 0 ] ;
469+ double deltaY = keyFrame . GazeY - ( double ) baseline [ startIndex , 1 ] ;
470+ for ( int i = startIndex ; i < baseline . Shape [ 0 ] ; i ++ )
471+ {
472+ corrected [ i , 0 ] = ( double ) baseline [ i , 0 ] + deltaX ;
473+ corrected [ i , 1 ] = ( double ) baseline [ i , 1 ] + deltaY ;
474+ }
475+ }
476+
342477 private int ? PreviousVideoKeyFrame
343478 {
344479 get
@@ -406,12 +541,18 @@ await Task.Run(() =>
406541 EyetrackingFPS = capturedSampleRate ;
407542 IsGazeLoaded = true ;
408543 gazeFileName = fileName ;
409- if ( videoReader != null )
410- SetCurrentAsDataStart ( ) ;
411544 if ( _isGazeFilterEnabled )
412545 await ApplyFilter ( ) ;
413546 else
547+ {
548+ RebuildCorrectedGaze ( ) ;
414549 RebuildTTLMarkerCache ( ) ;
550+ }
551+ if ( videoReader != null )
552+ {
553+ SetCurrentAsDataStart ( ) ;
554+ UpdateDisplay ( ) ;
555+ }
415556 IsLoadingGaze = false ;
416557 }
417558
@@ -450,6 +591,8 @@ public void SetCurrentAsDataStart()
450591 AddKeyFrame ( videoReader . frameCount - 1 ) ;
451592 else AddKeyFrame ( dataEndFrame . Value - 1 ) ;
452593 }
594+
595+ RebuildCorrectedGaze ( ) ;
453596 }
454597
455598 public void SendToRecentering ( )
@@ -527,6 +670,7 @@ private async Task ApplyFilter()
527670 OutlierThresholdY ,
528671 OutlierThresholdRadius ) ) ;
529672 FilteredGazeLocations = result ;
673+ RebuildCorrectedGaze ( ) ;
530674 RebuildTTLMarkerCache ( ) ;
531675
532676 IsProgressBarVisible = false ;
0 commit comments