Skip to content

Pupil Labs Neon Recording

ci documentation uv ruff pre-commit pypi version python version

pl-neon-recording banner

Functionality for loading Neon recordings in native recording format

Installation

pip install pupil-labs-neon-recording

or

pip install -e git+https://github.com/pupil-labs/pl-neon-recording.git

Documentation

Documentation is available at https://pupil-labs.github.io/pl-neon-recording/

Usage

Basic Usage

import sys

import pupil_labs.neon_recording as nr

if len(sys.argv) < 2:
    print("Usage:")
    print("python basic_usage.py path/to/recording/folder")

# Open a recording
recording = nr.open(sys.argv[1])

# get basic info
print("Recording Info:")
print(f"\tStart time (ns): {recording.start_time}")
print(f"\tWearer         : {recording.wearer['name']}")
print(f"\tDevice serial  : {recording.device_serial}")
print(f"\tGaze samples   : {len(recording.gaze)}")
print("")

# read 10 gaze samples
print("First 10 gaze samples:")
timestamps = recording.gaze.time[:10]
subsample = recording.gaze.sample(timestamps)
for gaze_datum in subsample:
    print(
        f"\t{gaze_datum.time} :",
        f"({gaze_datum.point[0]:0.2f}, {gaze_datum.point[1]:0.2f})",
    )

recording.close()
import sys

import cv2
import numpy as np
from tqdm import tqdm

# Workaround for https://github.com/opencv/opencv/issues/21952
cv2.imshow("cv/av bug", np.zeros(1))
cv2.destroyAllWindows()

import pupil_labs.neon_recording as nr  # noqa: E402
from pupil_labs.neon_recording import match_ts  # noqa: E402
from pupil_labs.video import Writer  # noqa: E402


def write_text(image, text, x, y):
    return cv2.putText(
        image,
        text,
        (x, y),
        cv2.FONT_HERSHEY_SIMPLEX,
        1,
        (0, 0, 255),
        2,
        cv2.LINE_AA,
    )


def match_events(target_time, events):
    # Blink start needs to be <= target_time
    matches = match_ts(target_time, events.start_time, method="backward")

    # Blink end needs to be >= target_time
    matches_end = match_ts(target_time, events.stop_time, method="forward")

    matches[np.isnan(matches_end)] = np.nan
    matches[matches != matches_end] = np.nan

    return matches


def make_overlaid_video(recording_dir, output_video_path):
    recording = nr.open(recording_dir)

    video_writer = Writer(
        output_video_path,
    )
    video_start_time = recording.eye.time[0]

    blink_matches = match_events(recording.eye.time, recording.blinks)
    fixation_matches = match_events(recording.eye.time, recording.fixations)

    blink_count = 0
    fixation_count = 0
    for frame, blink_index, fixation_index in tqdm(
        zip(recording.eye, blink_matches, fixation_matches, strict=False),
        total=len(recording.eye),
    ):
        frame_pixels = frame.bgr

        if not np.isnan(blink_index):
            blink_count = int(blink_index + 1)
        frame_pixels = write_text(frame_pixels, f"Blinks: {blink_count}", 0, 40)

        if not np.isnan(fixation_index):
            fixation_count = int(fixation_index + 1)
        frame_pixels = write_text(frame_pixels, f"Fixations: {fixation_count}", 0, 80)

        video_time = (frame.time - video_start_time) / 1e9
        video_writer.write_image(frame_pixels, time=video_time)

        cv2.imshow("Frame", frame_pixels)
        cv2.pollKey()

    video_writer.close()
    recording.close()


if __name__ == "__main__":
    make_overlaid_video(sys.argv[1], "blinks-and-fixations.mp4")

CSV Export

import json
import sys
import time
from pathlib import Path

import cv2
import numpy as np
import pandas as pd
from scipy.spatial.transform import Rotation

import pupil_labs.neon_recording as nr


def unproject_points(points_2d, camera_matrix, distortion_coefs, normalize=False):
    """Undistorts points according to the camera model.

    :param pts_2d, shape: Nx2
    :return: Array of unprojected 3d points, shape: Nx3
    """
    # Convert type to numpy arrays (OpenCV requirements)
    camera_matrix = np.array(camera_matrix)
    distortion_coefs = np.array(distortion_coefs)
    points_2d = np.asarray(points_2d, dtype=np.float32)

    # Add third dimension the way cv2 wants it
    points_2d = points_2d.reshape((-1, 1, 2))

    # Undistort 2d pixel coordinates
    points_2d_undist = cv2.undistortPoints(points_2d, camera_matrix, distortion_coefs)
    # Unproject 2d points into 3d directions; all points. have z=1
    points_3d = cv2.convertPointsToHomogeneous(points_2d_undist)
    points_3d.shape = -1, 3

    if normalize:
        # normalize vector length to 1
        points_3d /= np.linalg.norm(points_3d, axis=1)[:, np.newaxis]

    return points_3d


def cart_to_spherical(points_3d, apply_rad2deg=True):
    points_3d = np.asarray(points_3d)
    # convert cartesian to spherical coordinates
    # source: http://stackoverflow.com/questions/4116658/faster-numpy-cartesian-to-spherical-coordinate-conversion
    x = points_3d[:, 0]
    y = points_3d[:, 1]
    z = points_3d[:, 2]
    radius = np.sqrt(x**2 + y**2 + z**2)
    # elevation: vertical direction
    #   positive numbers point up
    #   negative numbers point bottom
    elevation = np.arccos(y / radius) - np.pi / 2
    # azimuth: horizontal direction
    #   positive numbers point right
    #   negative numbers point left
    azimuth = np.pi / 2 - np.arctan2(z, x)

    if apply_rad2deg:
        elevation = np.rad2deg(elevation)
        azimuth = np.rad2deg(azimuth)

    return radius, elevation, azimuth


def find_ranged_index(values, left_boundaries, right_boundaries):
    left_ids = np.searchsorted(left_boundaries, values, side="right") - 1
    right_ids = np.searchsorted(right_boundaries, values, side="right")

    return np.where(left_ids == right_ids, left_ids, -1)


def export_gaze(recording, export_path):
    try:
        fixations = recording.fixations

        fixation_ids = (
            find_ranged_index(
                recording.gaze.time, fixations.start_time, fixations.stop_time
            )
            + 1
        )
    except AttributeError:
        fixation_ids = -1

    try:
        blink_ids = (
            find_ranged_index(
                recording.gaze.time,
                recording.blinks.start_time,
                recording.blinks.stop_time,
            )
            + 1
        )
    except AttributeError:
        blink_ids = -1

    spherical_coords = cart_to_spherical(
        unproject_points(
            recording.gaze.point,
            recording.calibration.scene_camera_matrix,
            recording.calibration.scene_distortion_coefficients,
        )
    )

    gaze = pd.DataFrame({
        "recording id": recording.info["recording_id"],
        "timestamp [ns]": recording.gaze.time,
        "gaze x [px]": recording.gaze.point[:, 0],
        "gaze y [px]": recording.gaze.point[:, 1],
        "worn": recording.worn.worn,
        "fixation id": fixation_ids,
        "blink id": blink_ids,
        "azimuth [deg]": spherical_coords[2],
        "elevation [deg]": spherical_coords[1],
    })

    gaze["fixation id"] = gaze["fixation id"].replace(0, None)
    gaze["blink id"] = gaze["blink id"].replace(0, None)

    export_file = export_path / "gaze.csv"
    gaze.to_csv(export_file, index=False)
    print(f"Wrote {export_file}")


def export_blinks(recording, export_path):
    blinks = pd.DataFrame({
        "recording id": recording.info["recording_id"],
        "blink id": 1 + np.arange(len(recording.blinks)),
        "start timestamp [ns]": recording.blinks.start_time,
        "end timestamp [ns]": recording.blinks.stop_time,
        "duration [ms]": (recording.blinks.stop_time - recording.blinks.start_time)
        / 1e6,
    })
    export_file = export_path / "blinks.csv"
    blinks.to_csv(export_file, index=False)
    print(f"Wrote {export_file}")


def export_fixations(recording, export_path):
    fixations = recording.fixations

    spherical_coords = cart_to_spherical(
        unproject_points(
            fixations.mean_gaze_point,
            recording.calibration.scene_camera_matrix,
            recording.calibration.scene_distortion_coefficients,
        )
    )

    fixations_df = pd.DataFrame({
        "recording id": recording.info["recording_id"],
        "fixation id": 1 + np.arange(len(fixations)),
        "start timestamp [ns]": fixations.start_time,
        "end timestamp [ns]": fixations.stop_time,
        "duration [ms]": (fixations.stop_time - fixations.start_time) / 1e6,
        "fixation x [px]": fixations.mean_gaze_point[:, 0],
        "fixation y [px]": fixations.mean_gaze_point[:, 1],
        "azimuth [deg]": spherical_coords[2],
        "elevation [deg]": spherical_coords[1],
    })

    export_file = export_path / "fixations.csv"
    fixations_df.to_csv(export_file, index=False)
    print(f"Wrote {export_file}")


def export_saccades(recording, export_path):
    saccades = recording.saccades

    saccades_df = pd.DataFrame({
        "recording id": recording.info["recording_id"],
        "saccade id": 1 + np.arange(len(saccades)),
        "start timestamp [ns]": saccades.start_time,
        "end timestamp [ns]": saccades.stop_time,
        "duration [ms]": (saccades.stop_time - saccades.start_time) / 1e6,
        "amplitude [deg]": saccades.amplitude,
        "mean velocity [px/s]": saccades.mean_velocity,
        "peak velocity [px/s]": saccades.max_velocity,
    })

    export_file = export_path / "saccades.csv"
    saccades_df.to_csv(export_file, index=False)
    print(f"Wrote {export_file}")


def export_eyestates(recording, export_path):
    eyeball = recording.eyeball
    pupil = recording.pupil
    eyelid = recording.eyelid
    eyestates = pd.DataFrame({
        "recording id": recording.info["recording_id"],
        "timestamp [ns]": eyeball.time,
        "pupil diameter left [mm]": pupil.diameter_left,
        "pupil diameter right [mm]": pupil.diameter_right,
        "eyeball center left x [mm]": eyeball.center_left[:, 0],
        "eyeball center left y [mm]": eyeball.center_left[:, 1],
        "eyeball center left z [mm]": eyeball.center_left[:, 2],
        "eyeball center right x [mm]": eyeball.center_right[:, 0],
        "eyeball center right y [mm]": eyeball.center_right[:, 1],
        "eyeball center right z [mm]": eyeball.center_right[:, 2],
        "optical axis left x": eyeball.optical_axis_left[:, 0],
        "optical axis left y": eyeball.optical_axis_left[:, 1],
        "optical axis left z": eyeball.optical_axis_left[:, 2],
        "optical axis right x": eyeball.optical_axis_right[:, 0],
        "optical axis right y": eyeball.optical_axis_right[:, 1],
        "optical axis right z": eyeball.optical_axis_right[:, 2],
        "eyelid angle top left [rad]": eyelid.angle_left[:, 0],
        "eyelid angle bottom left [rad]": eyelid.angle_left[:, 1],
        "eyelid aperture left [mm]": eyelid.aperture_left,
        "eyelid angle top right [rad]": eyelid.angle_right[:, 0],
        "eyelid angle bottom right [rad]": eyelid.angle_right[:, 1],
        "eyelid aperture right [mm]": eyelid.aperture_right,
    })

    export_file = export_path / "3d_eye_states.csv"
    eyestates.to_csv(export_file, index=False)
    print(f"Wrote {export_file}")


def export_imu(recording, export_path):
    rotations = Rotation.from_quat(recording.imu.rotation)
    eulers = rotations.as_euler(seq="yxz", degrees=True)

    imu = pd.DataFrame({
        "recording id": recording.info["recording_id"],
        "timestamp [ns]": recording.imu.time,
        "gyro x [deg/s]": recording.imu.angular_velocity[:, 0],
        "gyro y [deg/s]": recording.imu.angular_velocity[:, 1],
        "gyro z [deg/s]": recording.imu.angular_velocity[:, 2],
        "acceleration x [g]": recording.imu.acceleration[:, 0],
        "acceleration y [g]": recording.imu.acceleration[:, 1],
        "acceleration z [g]": recording.imu.acceleration[:, 2],
        "roll [deg]": eulers[:, 0],
        "pitch [deg]": eulers[:, 1],
        "yaw [deg]": eulers[:, 2],
        "quaternion w": recording.imu.rotation[:, 3],
        "quaternion x": recording.imu.rotation[:, 0],
        "quaternion y": recording.imu.rotation[:, 1],
        "quaternion z": recording.imu.rotation[:, 2],
    })

    export_file = export_path / "imu.csv"
    imu.to_csv(export_file, index=False)
    print(f"Wrote {export_file}")


def export_events(recording, export_path):
    events = pd.DataFrame({
        "recording id": recording.info["recording_id"],
        "timestamp [ns]": recording.events.time,
        "name": recording.events.event,
        "type": "recording",
    })

    export_file = export_path / "events.csv"
    events.to_csv(export_file, index=False)
    print(f"Wrote {export_file}")


def export_info(recording, export_path):
    with (export_path / "info.json").open("w") as f:
        json.dump(recording.info, f, indent=4, sort_keys=True)


def export_scene_camera_calibration(recording, export_path):
    distortion = recording.calibration.scene_distortion_coefficients.reshape([1, -1])
    camera_info = {
        "camera_matrix": recording.calibration.scene_camera_matrix.tolist(),
        "distortion_coefficients": distortion.tolist(),
        "serial_number": recording.calibration.serial,
    }
    with (export_path / "scene_camera.json").open("w") as f:
        json.dump(camera_info, f, indent=4, sort_keys=True)


def export_world_timestamps(recording, export_path):
    events = pd.DataFrame({
        "recording id": recording.info["recording_id"],
        "timestamp [ns]": recording.scene.time,
    })

    export_file = export_path / "world_timestamps.csv"
    events.to_csv(export_file, index=False)
    print(f"Wrote {export_file}")


if __name__ == "__main__":
    func_map = {
        "gaze": export_gaze,
        "blinks": export_blinks,
        "fixations": export_fixations,
        "saccades": export_saccades,
        "eyestates": export_eyestates,
        "imu": export_imu,
        "events": export_events,
        "info": export_info,
        "scene-camera": export_scene_camera_calibration,
        "world-timestamps": export_world_timestamps,
    }

    if len(sys.argv) < 2 or "--help" in sys.argv:
        arg_list = "[--help] [--all] "
        arg_list += " ".join([f"[--{s}]" for s in func_map])
        print(f"Usage: python csv_export.py path/to/recording/folder {arg_list}")
        sys.exit(0)

    recording_path = Path(sys.argv[1])
    recording = nr.open(recording_path)

    timestamp = time.strftime("%Y-%m-%d_%H-%M-%S")
    safe_timestamp = timestamp.replace(":", "-").replace(" ", "_")

    export_path = recording_path / "exports" / safe_timestamp
    export_path.mkdir(parents=True, exist_ok=True)

    if "--all" in sys.argv or len(sys.argv) == 2:
        sys.argv += [f"--{k}" for k in func_map]

    for stream_name, export_func in func_map.items():
        if f"--{stream_name}" in sys.argv:
            try:
                export_func(recording, export_path)
            except AttributeError as err:
                print(f"Could not export {stream_name}: {err}", file=sys.stderr)

    recording.close()

Data Access

import sys
from collections.abc import Mapping
from datetime import datetime

import numpy as np

import pupil_labs.neon_recording as nr
from pupil_labs.neon_recording.timeseries.timeseries import Timeseries

if len(sys.argv) < 2:
    print("Usage:")
    print("python basic_usage.py path/to/recording/folder")

# Open a recording
recording = nr.open(sys.argv[1])


def pretty_format(mapping: Mapping):
    output = []
    pad = "    "
    keys = mapping.keys()
    n = max(len(key) for key in keys)
    for k, v in mapping.items():
        v_repr_lines = str(v).splitlines()
        output.append(f"{pad}{k:>{n}}: {v_repr_lines[0]}")
        if len(v_repr_lines) > 1:
            output.extend(f"{pad + '  '}{n * ' '}{line}" for line in v_repr_lines[1:])
    return "\n".join(output)


print("Basic Recording Info:")
print_data = {
    "Recording ID": recording.id,
    "Start time (ns since unix epoch)": f"{recording.start_time}",
    "Start time (datetime)": f"{datetime.fromtimestamp(recording.start_time / 1e9)}",
    "Duration (nanoseconds)": f"{recording.duration}",
    "Duration (seconds)": f"{recording.duration / 1e9}",
    "Wearer": f"{recording.wearer['name']} ({recording.wearer['uuid']})",
    "Device serial": recording.device_serial,
    "App version": recording.info["app_version"],
    "Data format": recording.info["data_format_version"],
    "Gaze Offset": recording.info["gaze_offset"],
}
print(pretty_format(print_data))

streams: list[Timeseries] = [
    recording.gaze,
    recording.imu,
    recording.eyeball,
    recording.pupil,
    recording.eyelid,
    recording.blinks,
    recording.fixations,
    recording.saccades,
    recording.worn,
    recording.eye,
    recording.scene,
    recording.audio,
]
print()
print("Recording streams:")
print(
    pretty_format({
        f"{stream.name} ({len(stream)} samples)": "\n" + pretty_format(stream[0])
        for stream in streams
    })
)

print()
print("Getting data from a stream:")

print()
print("Gaze points", recording.gaze.point)
# GazeArray([(1741948698620648018, 966.3677 , 439.58817),
#            (1741948698630654018, 965.9669 , 441.60403),
#            (1741948698635648018, 964.2665 , 442.4974 ), ...,
#            (1741948717448190018, 757.85815, 852.34644),
#            (1741948717453190018, 766.53174, 857.3709 ),
#            (1741948717458190018, 730.93604, 851.53723)],
#           dtype=[('ts', '<i8'), ('x', '<f4'), ('y', '<f4')])

print()
print("Gaze timestamps", recording.gaze.time)
# array([1741948698620648018, 1741948698630654018, 1741948698635648018, ...,
#        1741948717448190018, 1741948717453190018, 1741948717458190018])


# All stream data can also be accesses as structured numpy arrays and pandas dataframes.

print()
print("Gaze data as a structured numpy array:")
gaze_np = recording.gaze.data
print()
print(gaze_np)


print()
print("Gaze data as pandas dataframe:")
gaze_df = recording.gaze.pd
print()
print(gaze_df)

print()
print("Sampling data:")

print()
print("Get closest gaze for scene frames")
closest_gaze_to_scene = recording.gaze.sample(recording.scene.time)
print(closest_gaze_to_scene)
print(
    "closest_gaze_to_scene_times",
    (closest_gaze_to_scene.time - recording.start_time) / 1e9,
)

print()
print("Sampled data can be resampled")

print()
print("Closest gaze sampled at 1 fps")
closest_gaze_to_scene_at_one_fps = closest_gaze_to_scene.sample(
    np.arange(
        closest_gaze_to_scene.time[0],
        closest_gaze_to_scene.time[-1],
        1e9 / 1,
        dtype=np.int64,
    ),
)
print(closest_gaze_to_scene_at_one_fps)
print(
    "closest_gaze_to_scene_at_one_fps_times",
    (closest_gaze_to_scene_at_one_fps.time - recording.start_time) / 1e9,
)

# Close the recording when done to free up resources
recording.close()

Eye Overlay

import sys

import cv2
import numpy as np
from tqdm import tqdm

# Workaround for https://github.com/opencv/opencv/issues/21952
cv2.imshow("cv/av bug", np.zeros(1))
cv2.destroyAllWindows()

import pupil_labs.neon_recording as nr  # noqa: E402
from pupil_labs.neon_recording.timeseries.av.video import (  # noqa: E402
    GrayFrame,
)


def overlay_image(img, img_overlay, x, y):
    """Overlay `img_overlay` onto `img` at (x, y)."""
    # Image ranges
    y1, y2 = max(0, y), min(img.shape[0], y + img_overlay.shape[0])
    x1, x2 = max(0, x), min(img.shape[1], x + img_overlay.shape[1])

    # Overlay ranges
    y1o, y2o = max(0, -y), min(img_overlay.shape[0], img.shape[0] - y)
    x1o, x2o = max(0, -x), min(img_overlay.shape[1], img.shape[1] - x)

    if y1 >= y2 or x1 >= x2 or y1o >= y2o or x1o >= x2o:
        return

    img_crop = img[y1:y2, x1:x2]
    img_overlay_crop = img_overlay[y1o:y2o, x1o:x2o]
    img_crop[:] = img_overlay_crop


def make_overlaid_video(recording_dir, output_video_path, fps=30):
    recording = nr.open(recording_dir)

    video_writer = cv2.VideoWriter(
        str(output_video_path),
        cv2.VideoWriter_fourcc(*"MJPG"),
        fps,
        (recording.scene.width, recording.scene.height),
    )

    output_timestamps = np.arange(
        recording.scene.time[0], recording.scene.time[-1], int(1e9 / fps)
    )

    combined_data = zip(
        output_timestamps,
        recording.scene.sample(output_timestamps),
        recording.eye.sample(output_timestamps),
        strict=False,
    )

    frame_idx = 0
    for ts, scene_frame, eye_frame in tqdm(combined_data, total=len(output_timestamps)):
        frame_idx += 1  # noqa: SIM113

        if abs(scene_frame.time - ts) < 2e9 / fps:
            frame_pixels = scene_frame.bgr
        else:
            # if the video frame timestamp is too far ahead or behind temporally,
            # replace it with a gray frame
            frame_pixels = GrayFrame(scene_frame.width, scene_frame.height).bgr

        if abs(eye_frame.time - ts) < 2e9 / fps:
            eye_pixels = cv2.cvtColor(eye_frame.gray, cv2.COLOR_GRAY2BGR)
        else:
            # if the video frame timestamp is too far ahead or behind temporally,
            # replace it with a gray frame
            eye_pixels = GrayFrame(eye_frame.width, eye_frame.height).bgr

        overlay_image(frame_pixels, eye_pixels, 50, 50)

        video_writer.write(frame_pixels)
        cv2.imshow("Frame", frame_pixels)
        cv2.pollKey()

    video_writer.release()
    recording.close()


if __name__ == "__main__":
    make_overlaid_video(sys.argv[1], "eye-overlay-output-video.avi")

Eye State

import sys

import cv2
import numpy as np
from tqdm import tqdm

# Workaround for https://github.com/opencv/opencv/issues/21952
cv2.imshow("cv/av bug", np.zeros(1))
cv2.destroyAllWindows()

import pupil_labs.neon_recording as nr  # noqa: E402
from pupil_labs.video import Writer  # noqa: E402


def overlay_image(img, img_overlay, x, y):
    """Overlay `img_overlay` onto `img` at (x, y)."""
    # Image ranges
    y1, y2 = max(0, y), min(img.shape[0], y + img_overlay.shape[0])
    x1, x2 = max(0, x), min(img.shape[1], x + img_overlay.shape[1])

    # Overlay ranges
    y1o, y2o = max(0, -y), min(img_overlay.shape[0], img.shape[0] - y)
    x1o, x2o = max(0, -x), min(img_overlay.shape[1], img.shape[1] - x)

    if y1 >= y2 or x1 >= x2 or y1o >= y2o or x1o >= x2o:
        return

    img_crop = img[y1:y2, x1:x2]
    img_overlay_crop = img_overlay[y1o:y2o, x1o:x2o]
    img_crop[:] = img_overlay_crop


def plot(img, data, value_range, x_width, color, line_width=2):
    for idx in range(1, len(data)):
        x_values = [int(idx2 * x_width) for idx2 in [idx - 1, idx]]

        y_norms = [
            (data[idx2] - value_range[0]) / (value_range[1] - value_range[0])
            for idx2 in [idx - 1, idx]
        ]
        y_values = [int(y_norm * img.shape[0]) for y_norm in y_norms]

        points = [[*v] for v in zip(x_values, y_values, strict=False)]

        cv2.line(img, points[0], points[1], color, line_width)


def make_eye_state_video(recording_dir, output_video_path):
    recording = nr.open(recording_dir)

    fps = 200
    video_writer = Writer(output_video_path)
    video_start_time = recording.eye.time[0]

    plot_config = [
        {"color": [0, 0, 255]},
        {"color": [0, 255, 0]},
        {"color": [255, 0, 0]},
    ]

    for dim, config in enumerate(plot_config):
        config["range"] = (
            np.min(recording.eyeball.optical_axis_left[:, dim]),
            np.max(recording.eyeball.optical_axis_left[:, dim]),
        )

    plot_duration_secs = 0.5
    plot_point_count = plot_duration_secs * fps
    plot_x_width = recording.eye.width / plot_point_count

    for eye_sample in tqdm(recording.eye):
        eye_pixels = eye_sample.bgr

        for dim, config in enumerate(plot_config):
            min_ts = eye_sample.time - plot_duration_secs * 1e9
            mask = (min_ts < recording.eyeball.time) & (
                recording.eyeball.time <= eye_sample.time
            )
            plot_data = recording.eyeball.optical_axis_left[mask, dim]
            plot(
                eye_pixels,
                plot_data,
                config["range"],
                plot_x_width,
                config["color"],
            )

        video_time = (eye_sample.time - video_start_time) / 1e9
        video_writer.write_image(eye_pixels, time=video_time)
        cv2.imshow("Frame", eye_pixels)
        cv2.pollKey()

    video_writer.close()
    recording.close()


if __name__ == "__main__":
    make_eye_state_video(sys.argv[1], "eye-state-output-video.avi")

Find Clap

import sys

import numpy as np
from tqdm import tqdm

import pupil_labs.neon_recording as nr


def find_clap(recording_dir, window_size_seconds=0.1, first_n_seconds=10):
    recording = nr.open(recording_dir)

    audio_data = np.array([], dtype=np.float32)
    ts_lookup = []
    for frame in recording.audio:
        if first_n_seconds is not None:
            rel_time = (frame.time - recording.start_time) / 1e9
            if rel_time > first_n_seconds:
                break

        # Create a timestamp lookup table
        ts_lookup.append([len(audio_data), frame.time])

        # Gather all the audio samples
        audio_data = np.concatenate((audio_data, frame.to_ndarray().flatten()))

    ts_lookup = np.array(ts_lookup)

    # Calculate RMS over a sliding window
    # Remember the sample index of the loudest window
    max_rms = 0
    loudest_sample_idx = 0

    samples_per_window = int(window_size_seconds * recording.audio.rate)
    for i in tqdm(range(len(audio_data) - samples_per_window)):
        segment = audio_data[i : i + samples_per_window]
        rms = np.sqrt(np.mean(np.square(segment)))

        if rms > max_rms:
            max_rms = rms
            loudest_sample_idx = int(i + samples_per_window / 2)

    # Find the reference timestamp from the lookup table
    lookup_idx = np.searchsorted(ts_lookup[:, 0], loudest_sample_idx) - 1
    reference_ts = ts_lookup[lookup_idx, 1]

    # Calculate the sample timestamp using the reference timestamp
    samples_after_reference = loudest_sample_idx - ts_lookup[lookup_idx, 0]
    loudest_time = reference_ts + (samples_after_reference / recording.audio.rate) * 1e9

    print(f"The loudest audio occurs at {loudest_time:.0f} rms = {max_rms:.3f}.")
    print(
        f"    Relative to recording start: "
        f"{(loudest_time - recording.start_time) / 1e9:0.3f}s"
    )
    print(
        f"    Relative to video start    : "
        f"{(loudest_time - recording.scene.time[0]) / 1e9:0.3f}s"
    )
    print(
        f"    Relative to audio start    : "
        f"{(loudest_time - recording.audio.time[0]) / 1e9:0.3f}s"
    )

    recording.close()


if __name__ == "__main__":
    find_clap(sys.argv[1])

Gaze Overlay

import sys

import cv2
import numpy as np
from tqdm import tqdm

# Workaround for https://github.com/opencv/opencv/issues/21952
cv2.imshow("cv/av bug", np.zeros(1))
cv2.destroyAllWindows()

import pupil_labs.neon_recording as nr  # noqa: E402
from pupil_labs.video import Writer  # noqa: E402


def make_overlaid_video(recording_dir, output_video_path):
    rec = nr.open(recording_dir)

    combined_data = zip(
        rec.scene,
        rec.gaze.sample(rec.scene.time),
        strict=True,
    )

    video_writer = Writer(output_video_path)
    video_start_time = rec.scene.time[0]

    for scene_frame, gaze_datum in tqdm(combined_data, total=len(rec.scene.time)):
        frame_pixels = scene_frame.bgr

        frame_pixels = cv2.circle(
            frame_pixels,
            (int(gaze_datum.point[0]), int(gaze_datum.point[1])),
            50,
            (0, 0, 255),
            10,
        )

        video_time = (scene_frame.time - video_start_time) / 1e9
        video_writer.write_image(frame_pixels, time=video_time)

        cv2.imshow("Frame", frame_pixels)
        cv2.waitKey(30)

    video_writer.close()
    rec.close()


if __name__ == "__main__":
    make_overlaid_video(sys.argv[1], "gaze-overlay-output-video.mp4")

IMU

import sys

import cv2
import numpy as np
from scipy.spatial.transform import Rotation
from tqdm import tqdm

# Workaround for https://github.com/opencv/opencv/issues/21952
cv2.imshow("cv/av bug", np.zeros(1))
cv2.destroyAllWindows()

import pupil_labs.neon_recording as nr  # noqa: E402

if len(sys.argv) < 2:
    print("Usage:")
    print("python imu.py path/to/recording/folder")

# Open a recording
recording = nr.open(sys.argv[1])

# Sample the IMU data at 60Hz
fps = 60
z = recording.gaze[:10]
timestamps = np.arange(
    recording.imu.time[0], recording.imu.time[-1], 1e9 / fps, dtype=np.int64
)
imu_data = recording.imu.sample(timestamps)

# Use scipy to convert the quaternions to euler angles
quaternions = np.array([s.rotation for s in imu_data])
rotations = Rotation.from_quat(quaternions).as_euler(seq="yxz", degrees=True) % 360

# Combine the timestamps and eulers
rotations_with_time = np.column_stack((timestamps, rotations))
timestamped_eulers = np.array(
    [tuple(row) for row in rotations_with_time],
    dtype=[
        ("time", np.int64),
        ("roll", np.float64),
        ("pitch", np.float64),
        ("yaw", np.float64),
    ],
)

# Display the angles
frame_size = 512
colors = {"pitch": (0, 0, 255), "yaw": (0, 255, 0), "roll": (255, 0, 0)}

for row in tqdm(timestamped_eulers):
    # Create a blank image
    frame = np.zeros((frame_size, frame_size, 3), dtype=np.uint8)

    # Define the center and radius of the circles
    center = [frame_size // 2] * 2
    radius = frame.shape[0] // 3

    # Calculate the end points for the angles
    for field, color in colors.items():
        pitch_end = (
            int(center[0] + radius * np.cos(np.deg2rad(row[field]))),
            int(center[1] - radius * np.sin(np.deg2rad(row[field]))),
        )
        cv2.line(frame, center, pitch_end, color, 2)

        # Write the angle values on the image
        cv2.putText(
            frame,
            f"{field}: {row[field]:.2f}",
            (10, 30 + list(colors.keys()).index(field) * 30),
            cv2.FONT_HERSHEY_SIMPLEX,
            0.7,
            color,
            2,
        )

    # Display the image
    cv2.imshow("IMU Angles", frame)
    if cv2.waitKey(1000 // fps) == 27:
        break

cv2.destroyAllWindows()
recording.close()

Interpolate Gaze Data

import sys

import cv2
import numpy as np
from tqdm import tqdm

# Workaround for https://github.com/opencv/opencv/issues/21952
cv2.imshow("cv/av bug", np.zeros(1))
cv2.destroyAllWindows()

import pupil_labs.neon_recording as nr  # noqa: E402
from pupil_labs.video import Writer  # noqa: E402


def make_overlaid_video(recording_dir, output_video_path, fps=None):
    # Open a recording
    recording = nr.open(recording_dir)

    video_writer = Writer(output_video_path)
    video_start_time = recording.scene.time[0]

    # get closest gaze data to scene frame timestamps
    matched_gazes = recording.gaze.sample(recording.scene.time)

    # interpolate gaze data to scene frame timestamps
    interpolated_gazes = recording.gaze.interpolate(recording.scene.time)

    # visualize both
    scene_gaze_pairs = zip(
        recording.scene, matched_gazes, interpolated_gazes, strict=False
    )
    for scene_frame, matched_gaze, interpolated_gaze in tqdm(
        scene_gaze_pairs, total=len(recording.scene)
    ):
        # draw the nearest-time gaze sample in red
        frame = cv2.circle(
            scene_frame.bgr,
            (int(matched_gaze.point[0]), int(matched_gaze.point[1])),
            50,
            (0, 0, 255),
            10,
        )

        # draw the interpolated gaze sample in blue
        if not np.isnan(interpolated_gaze.point[0]) and not np.isnan(
            interpolated_gaze.point[1]
        ):
            frame = cv2.circle(
                frame,
                (int(interpolated_gaze.point[0]), int(interpolated_gaze.point[1])),
                50,
                (255, 0, 0),
                10,
            )

        video_time = (scene_frame.time - video_start_time) / 1e9
        video_writer.write_image(frame, time=video_time)
        cv2.imshow("Gaze sample comparison", frame)
        cv2.pollKey()

    cv2.destroyAllWindows()
    video_writer.close()
    recording.close()


if __name__ == "__main__":
    make_overlaid_video(sys.argv[1], "interpolation-compare.mp4", 30)

Multi-Gaze Overlay

import sys

import cv2
import numpy as np
from tqdm import tqdm

# Workaround for https://github.com/opencv/opencv/issues/21952
cv2.imshow("cv/av bug", np.zeros(1))
cv2.destroyAllWindows()

import pupil_labs.neon_recording as nr  # noqa: E402
from pupil_labs.video import Writer  # noqa: E402


def make_overlaid_video(recording_dir, output_video_path):
    rec = nr.open(recording_dir)

    combined_data = zip(
        rec.scene,
        rec.gaze.sample(rec.scene.time),
        rec.gaze_monocular_left.sample(rec.scene.time),
        rec.gaze_monocular_right.sample(rec.scene.time),
        strict=True,
    )

    video_writer = Writer(output_video_path)
    video_start_time = rec.scene.time[0]

    gaze_colors = [
        (0, 255, 0),
        (255, 0, 0),
        (0, 0, 255),
    ]

    for scene_frame, left_gaze, right_gaze, binocular_gaze in tqdm(
        combined_data, total=len(rec.scene.time)
    ):
        # Prepare the next frame
        frame_pixels = scene_frame.bgr

        for gaze_datum, color in zip(
            [binocular_gaze, left_gaze, right_gaze], gaze_colors
        ):
            frame_pixels = cv2.circle(
                frame_pixels,
                (int(gaze_datum.point[0]), int(gaze_datum.point[1])),
                50,
                color,
                10,
            )

        video_time = (scene_frame.time - video_start_time) / 1e9
        video_writer.write_image(frame_pixels, time=video_time)

        # Render current frame and mark start time
        cv2.imshow("Frame", frame_pixels)
        cv2.waitKey(30)

    video_writer.close()
    rec.close()


if __name__ == "__main__":
    make_overlaid_video(sys.argv[1], "gaze-overlay-output-video.mp4")

Worn

import sys

import cv2
import numpy as np
from tqdm import tqdm

# Workaround for https://github.com/opencv/opencv/issues/21952
cv2.imshow("cv/av bug", np.zeros(1))
cv2.destroyAllWindows()

from pupil_labs import neon_recording as nr  # noqa: E402


def write_text(image, text, x, y):
    return cv2.putText(
        image,
        text,
        (x, y),
        cv2.FONT_HERSHEY_SIMPLEX,
        1,
        (0, 0, 255),
        2,
        cv2.LINE_AA,
    )


def make_overlaid_video(recording_dir, output_video_path, fps=30):
    recording = nr.open(recording_dir)

    video_writer = cv2.VideoWriter(
        str(output_video_path),
        cv2.VideoWriter_fourcc(*"MJPG"),
        fps,
        (recording.eye.width, recording.eye.height),
    )

    output_timestamps = np.arange(
        recording.eye.time[0], recording.eye.time[-1], int(1e9 / fps)
    )
    eyes_and_worn = zip(
        recording.eye.sample(output_timestamps),
        recording.worn.sample(output_timestamps),
        strict=False,
    )

    for frame, worn_record in tqdm(eyes_and_worn, total=len(output_timestamps)):
        frame_pixels = frame.bgr

        text_y = 40
        if worn_record.worn:
            frame_pixels = write_text(frame_pixels, "Worn", 0, text_y)

        video_writer.write(frame_pixels)
        cv2.imshow("Frame", frame_pixels)
        cv2.pollKey()

    video_writer.release()
    recording.close()


if __name__ == "__main__":
    make_overlaid_video(sys.argv[1], "worn.mp4")