Skip to content

Commit c3fd911

Browse files
authored
Merge pull request #2112 from pupil-labs/hmd_accuracy
Implement accuracy visualisation and calculation for HMD calibrations
2 parents f814533 + 3edf5e3 commit c3fd911

File tree

2 files changed

+141
-37
lines changed

2 files changed

+141
-37
lines changed

pupil_src/shared_modules/accuracy_visualizer.py

Lines changed: 139 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,49 @@
3636

3737
logger = 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

4484
class 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:

pupil_src/shared_modules/gaze_producer/worker/validate_gaze.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ def validate(
7272
for ref in refs_in_validation_range
7373
]
7474

75-
accuracy_result, precision_result, _ = Accuracy_Visualizer.calc_acc_prec_errlines(
75+
result = Accuracy_Visualizer.calc_acc_prec_errlines(
7676
g_pool=g_pool,
7777
gazer_class=gazer_class,
7878
gazer_params=gazer_params,
@@ -81,7 +81,7 @@ def validate(
8181
intrinsics=g_pool.capture.intrinsics,
8282
outlier_threshold=gaze_mapper.validation_outlier_threshold_deg,
8383
)
84-
return accuracy_result, precision_result
84+
return result.accuracy, result.precision
8585

8686

8787
def _create_ref_dict(ref, frame_size):

0 commit comments

Comments
 (0)