Skip to content

Commit 955b4ad

Browse files
authored
Merge pull request #2113 from pupil-labs/develop
Pupil v3.2 Release Candidate 1
2 parents b8b8010 + c3fd911 commit 955b4ad

File tree

17 files changed

+345
-87
lines changed

17 files changed

+345
-87
lines changed

pupil_src/launchables/player.py

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -572,6 +572,18 @@ def set_window_size():
572572
g_pool.gui.append(g_pool.quickbar)
573573

574574
# we always load these plugins
575+
_pupil_producer_plugins = [
576+
# In priority order (first is default)
577+
("Pupil_From_Recording", {}),
578+
("Offline_Pupil_Detection", {}),
579+
]
580+
_pupil_producer_plugins = list(reversed(_pupil_producer_plugins))
581+
_gaze_producer_plugins = [
582+
# In priority order (first is default)
583+
("GazeFromRecording", {}),
584+
("GazeFromOfflineCalibration", {}),
585+
]
586+
_gaze_producer_plugins = list(reversed(_gaze_producer_plugins))
575587
default_plugins = [
576588
("Plugin_Manager", {}),
577589
("Seek_Control", {}),
@@ -582,14 +594,25 @@ def set_window_size():
582594
("System_Graphs", {}),
583595
("System_Timelines", {}),
584596
("World_Video_Exporter", {}),
585-
("Pupil_From_Recording", {}),
586-
("GazeFromRecording", {}),
597+
*_pupil_producer_plugins,
598+
*_gaze_producer_plugins,
587599
("Audio_Playback", {}),
588600
]
589-
590-
g_pool.plugins = Plugin_List(
591-
g_pool, session_settings.get("loaded_plugins", default_plugins)
592-
)
601+
_plugins_to_load = session_settings.get("loaded_plugins", None)
602+
if _plugins_to_load is None:
603+
# If no plugins are available from a previous session,
604+
# then use the default plugin list
605+
_plugins_to_load = default_plugins
606+
else:
607+
# If there are plugins available from a previous session,
608+
# then prepend plugins that are required, but might have not been available before
609+
_plugins_to_load = [
610+
*_pupil_producer_plugins,
611+
*_gaze_producer_plugins,
612+
*_plugins_to_load,
613+
]
614+
615+
g_pool.plugins = Plugin_List(g_pool, _plugins_to_load)
593616

594617
# Manually add g_pool.capture to the plugin list
595618
g_pool.plugins._plugins.append(g_pool.capture)

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/audio_playback.py

Lines changed: 13 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -123,17 +123,10 @@ def _setup_input_audio_part(self, part_idx):
123123
self.audio_paused = False
124124

125125
self.audio.stream.seek(0)
126-
first_frame = next(self.audio_frame_iterator)
127-
self.audio_pts_rate = first_frame.samples
128-
self.audio_start_pts = first_frame.pts
129-
130-
logger.debug(
131-
"audio_pts_rate = {} start_pts = {}".format(
132-
self.audio_pts_rate, self.audio_start_pts
133-
)
134-
)
135-
self.check_ts_consistency(reference_frame=first_frame)
136-
self.seek_to_audio_frame(0)
126+
if self.should_check_ts_consistency:
127+
first_frame = next(self.audio_frame_iterator)
128+
self.check_ts_consistency(reference_frame=first_frame)
129+
self.seek_to_audio_frame(0)
137130

138131
logger.debug(
139132
"Audio file format {} chans {} rate {} framesize {}".format(
@@ -166,6 +159,12 @@ def _setup_output_audio(self):
166159

167160
except ValueError:
168161
self.pa_stream = None
162+
except OSError:
163+
self.pa_stream = None
164+
import traceback
165+
166+
logger.warning("Audio found, but playback failed (#2103)")
167+
logger.debug(traceback.format_exc())
169168

170169
def _setup_audio_vis(self):
171170
self.audio_timeline = None
@@ -254,13 +253,11 @@ def get_audio_frame_iterator(self):
254253
yield frame
255254

256255
def audio_idx_to_pts(self, idx):
257-
return idx * self.audio_pts_rate
256+
return self.audio.pts[idx]
258257

259258
def seek_to_audio_frame(self, seek_pos):
260259
try:
261-
self.audio.stream.seek(
262-
self.audio_start_pts + self.audio_idx_to_pts(seek_pos)
263-
)
260+
self.audio.stream.seek(self.audio_idx_to_pts(seek_pos))
264261
except av.AVError:
265262
raise FileSeekError()
266263
else:
@@ -321,7 +318,7 @@ def update_audio_viz(self):
321318
self.audio_viz_data, finished = self.audio_viz_trans.get_data(
322319
log_scale=self.log_scale
323320
)
324-
if not finished:
321+
if not finished and self.audio_timeline:
325322
self.audio_timeline.refresh()
326323

327324
def setup_pyaudio_output_if_necessary(self):

0 commit comments

Comments
 (0)