3636
3737logger = logging .getLogger (__name__ )
3838
39- Calculation_Result = namedtuple (
40- "Calculation_Result" , ["result" , "num_used" , "num_total" ]
41- )
39+
40+ class CalculationResult (T .NamedTuple ):
41+ result : float
42+ num_used : int
43+ num_total : int
44+
45+
46+ class CorrelatedAndCoordinateTransformedResult (T .NamedTuple ):
47+ """Holds result from correlating reference and gaze data and their respective
48+ transformations into norm, image, and camera coordinate systems.
49+ """
50+
51+ norm_space : np .ndarray # shape: 2*n, 2
52+ image_space : np .ndarray # shape: 2*n, 2
53+ camera_space : np .ndarray # shape: 2*n, 3
54+
55+ @staticmethod
56+ def empty () -> "CorrelatedAndCoordinateTransformedResult" :
57+ return CorrelatedAndCoordinateTransformedResult (
58+ norm_space = np .ndarray ([]),
59+ image_space = np .ndarray ([]),
60+ camera_space = np .ndarray ([]),
61+ )
62+
63+
64+ class CorrelationError (ValueError ):
65+ pass
66+
67+
68+ class AccuracyPrecisionResult (T .NamedTuple ):
69+ accuracy : CalculationResult
70+ precision : CalculationResult
71+ error_lines : np .ndarray
72+ correlation : CorrelatedAndCoordinateTransformedResult
73+
74+ @staticmethod
75+ def failed () -> "AccuracyPrecisionResult" :
76+ return AccuracyPrecisionResult (
77+ accuracy = CalculationResult (0.0 , 0 , 0 ),
78+ precision = CalculationResult (0.0 , 0 , 0 ),
79+ error_lines = np .array ([]),
80+ correlation = CorrelatedAndCoordinateTransformedResult .empty (),
81+ )
4282
4383
4484class ValidationInput :
@@ -105,10 +145,6 @@ def update(
105145
106146 @staticmethod
107147 def __gazer_class_from_name (gazer_class_name : str ) -> T .Optional [T .Any ]:
108- if "HMD" in gazer_class_name :
109- logger .info ("Accuracy visualization is disabled for HMD calibration" )
110- return None
111-
112148 gazers_by_name = gazer_classes_by_class_name (registered_gazer_classes ())
113149
114150 try :
@@ -337,7 +373,7 @@ def recalculate(self):
337373 succession_threshold = self .succession_threshold ,
338374 )
339375
340- accuracy = results [ 0 ] .result
376+ accuracy = results . accuracy .result
341377 if np .isnan (accuracy ):
342378 self .accuracy = None
343379 logger .warning (
@@ -349,7 +385,7 @@ def recalculate(self):
349385 "Angular accuracy: {}. Used {} of {} samples." .format (* results [0 ])
350386 )
351387
352- precision = results [ 1 ] .result
388+ precision = results . precision .result
353389 if np .isnan (precision ):
354390 self .precision = None
355391 logger .warning (
@@ -361,9 +397,8 @@ def recalculate(self):
361397 "Angular precision: {}. Used {} of {} samples." .format (* results [1 ])
362398 )
363399
364- self .error_lines = results [2 ]
365-
366- ref_locations = [loc ["norm_pos" ] for loc in self .recent_input .ref_list ]
400+ self .error_lines = results .error_lines
401+ ref_locations = results .correlation .norm_space [1 ::2 , :]
367402 if len (ref_locations ) >= 3 :
368403 hull = ConvexHull (ref_locations ) # requires at least 3 points
369404 self .calibration_area = hull .points [hull .vertices , :]
@@ -378,36 +413,25 @@ def calc_acc_prec_errlines(
378413 intrinsics ,
379414 outlier_threshold ,
380415 succession_threshold = np .cos (np .deg2rad (0.5 )),
381- ):
416+ ) -> AccuracyPrecisionResult :
382417 gazer = gazer_class (g_pool , params = gazer_params )
383418
384419 gaze_pos = gazer .map_pupil_to_gaze (pupil_list )
385420 ref_pos = ref_list
386421
387- width , height = intrinsics .resolution
388-
389- # reuse closest_matches_monocular to correlate one label to each prediction
390- # correlated['ref']: prediction, correlated['pupil']: label location
391- correlated = closest_matches_monocular (gaze_pos , ref_pos )
392- # [[pred.x, pred.y, label.x, label.y], ...], shape: n x 4
393- locations = np .array (
394- [(* e ["ref" ]["norm_pos" ], * e ["pupil" ]["norm_pos" ]) for e in correlated ]
395- )
396- if locations .size == 0 :
397- accuracy_result = Calculation_Result (0.0 , 0 , 0 )
398- precision_result = Calculation_Result (0.0 , 0 , 0 )
399- error_lines = np .array ([])
400- return accuracy_result , precision_result , error_lines
401- error_lines = locations .copy () # n x 4
402- locations [:, ::2 ] *= width
403- locations [:, 1 ::2 ] = (1.0 - locations [:, 1 ::2 ]) * height
404- locations .shape = - 1 , 2
422+ try :
423+ correlation_result = Accuracy_Visualizer .correlate_and_coordinate_transform (
424+ gaze_pos , ref_pos , intrinsics
425+ )
426+ error_lines = correlation_result .norm_space .reshape (- 1 , 4 )
427+ undistorted_3d = correlation_result .camera_space
428+ except CorrelationError :
429+ return AccuracyPrecisionResult .failed ()
405430
406431 # Accuracy is calculated as the average angular
407432 # offset (distance) (in degrees of visual angle)
408433 # between fixations locations and the corresponding
409434 # locations of the fixation targets.
410- undistorted_3d = intrinsics .unprojectPoints (locations , normalize = True )
411435
412436 # Cosine distance of A and B: (A @ B) / (||A|| * ||B||)
413437 # No need to calculate norms, since A and B are normalized in our case.
@@ -426,7 +450,7 @@ def calc_acc_prec_errlines(
426450 - 1 , 2
427451 ) # shape: num_used x 2
428452 accuracy = np .rad2deg (np .arccos (selected_samples .clip (- 1.0 , 1.0 ).mean ()))
429- accuracy_result = Calculation_Result (accuracy , num_used , num_total )
453+ accuracy_result = CalculationResult (accuracy , num_used , num_total )
430454
431455 # lets calculate precision: (RMS of distance of succesive samples.)
432456 # This is a little rough as we do not compensate headmovements in this test.
@@ -457,9 +481,89 @@ def calc_acc_prec_errlines(
457481 precision = np .sqrt (
458482 np .mean (np .rad2deg (np .arccos (succesive_distances .clip (- 1.0 , 1.0 ))) ** 2 )
459483 )
460- precision_result = Calculation_Result (precision , num_used , num_total )
484+ precision_result = CalculationResult (precision , num_used , num_total )
485+
486+ return AccuracyPrecisionResult (
487+ accuracy_result , precision_result , error_lines , correlation_result
488+ )
489+
490+ @staticmethod
491+ def correlate_and_coordinate_transform (
492+ gaze_pos , ref_pos , intrinsics
493+ ) -> CorrelatedAndCoordinateTransformedResult :
494+ # reuse closest_matches_monocular to correlate one label to each prediction
495+ # correlated['ref']: prediction, correlated['pupil']: label location
496+ # NOTE the switch of the ref and pupil keys! This effects mostly hmd data.
497+ correlated = closest_matches_monocular (gaze_pos , ref_pos )
498+ # [[pred.x, pred.y, label.x, label.y], ...], shape: n x 4
499+ if not correlated :
500+ raise CorrelationError ("No correlation possible" )
461501
462- return accuracy_result , precision_result , error_lines
502+ try :
503+ return Accuracy_Visualizer ._coordinate_transform_ref_in_norm_space (
504+ correlated , intrinsics
505+ )
506+ except KeyError as err :
507+ if "norm_pos" in err .args :
508+ return Accuracy_Visualizer ._coordinate_transform_ref_in_camera_space (
509+ correlated , intrinsics
510+ )
511+ else :
512+ raise
513+
514+ @staticmethod
515+ def _coordinate_transform_ref_in_norm_space (
516+ correlated , intrinsics
517+ ) -> CorrelatedAndCoordinateTransformedResult :
518+ width , height = intrinsics .resolution
519+ locations_norm = np .array (
520+ [(* e ["ref" ]["norm_pos" ], * e ["pupil" ]["norm_pos" ]) for e in correlated ]
521+ )
522+ locations_image = locations_norm .copy () # n x 4
523+ locations_image [:, ::2 ] *= width
524+ locations_image [:, 1 ::2 ] = (1.0 - locations_image [:, 1 ::2 ]) * height
525+ locations_image .shape = - 1 , 2
526+ locations_norm .shape = - 1 , 2
527+ locations_camera = intrinsics .unprojectPoints (locations_image , normalize = True )
528+ return CorrelatedAndCoordinateTransformedResult (
529+ locations_norm , locations_image , locations_camera
530+ )
531+
532+ @staticmethod
533+ def _coordinate_transform_ref_in_camera_space (
534+ correlated , intrinsics
535+ ) -> CorrelatedAndCoordinateTransformedResult :
536+ width , height = intrinsics .resolution
537+ locations_mixed = np .array (
538+ # NOTE: This looks incorrect, but is actually correct. The switch comes from
539+ # using closest_matches_monocular() above with switched arguments.
540+ [(* e ["ref" ]["norm_pos" ], * e ["pupil" ]["mm_pos" ]) for e in correlated ]
541+ ) # n x 5
542+ pupil_norm = locations_mixed [:, 0 :2 ] # n x 2
543+ pupil_image = pupil_norm .copy ()
544+ pupil_image [:, 0 ] *= width
545+ pupil_image [:, 1 ] = (1.0 - pupil_image [:, 1 ]) * height
546+ pupil_camera = intrinsics .unprojectPoints (pupil_image , normalize = True ) # n x 3
547+
548+ ref_camera = locations_mixed [:, 2 :5 ] # n x 3
549+ ref_camera /= np .linalg .norm (ref_camera , axis = 1 , keepdims = True )
550+ ref_image = intrinsics .projectPoints (ref_camera ) # n x 2
551+ ref_norm = ref_image .copy ()
552+ ref_norm [:, 0 ] /= width
553+ ref_norm [:, 1 ] = 1.0 - (ref_norm [:, 1 ] / height )
554+
555+ locations_norm = np .hstack ([pupil_norm , ref_norm ]) # n x 4
556+ locations_norm .shape = - 1 , 2
557+
558+ locations_image = np .hstack ([pupil_image , ref_image ]) # n x 4
559+ locations_image .shape = - 1 , 2
560+
561+ locations_camera = np .hstack ([pupil_camera , ref_camera ]) # n x 6
562+ locations_camera .shape = - 1 , 3
563+
564+ return CorrelatedAndCoordinateTransformedResult (
565+ locations_norm , locations_image , locations_camera
566+ )
463567
464568 def gl_display (self ):
465569 if self .vis_mapping_error and self .error_lines is not None :
0 commit comments