This page was generated from a Jupyter notebook. Check the source code or download the notebook..

Patient-specific mapping of fundus photographs to three-dimensional ocular imaging - Part 2: Analyses#

This example contains the analysis of raytracing results for the paper Patient-specific mapping of fundus photographs to three-dimensional ocular imaging, and compares the proposed method with other fundus mapping methods.

Citation#

Next to citing ZOSPy, please also cite the following paper when using this example or the data provided within this example:

Haasjes, C., Vu, T. H. K., & Beenakker, J.-W. M. (2024). Patient-specific mapping of fundus photographs to three-dimensional ocular imaging. Medical Physics. https://doi.org/10.1002/mp.17576

Warranty and liability#

The presented code and data are made available for research purposes only. There is no warranty and rights can not be derived from them, as is also stated in the general license of this repository.

Import dependencies#

[1]:
from __future__ import annotations

import json

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import seaborn as sns
from helpers import (
    _upper_ellipse,
    ellipse_arc_length,
    euclidean_distance,
    find_ellipse_intersection,
)
from scipy.optimize import curve_fit
[2]:
import warnings

warnings.filterwarnings("ignore", message="invalid value encountered in sqrt")

Load data of the subjected generated by raytracing.ipynb. In addition, reference data of the Navarro eye model are loaded.

[3]:
with open("data/navarro_geometry.json") as f:
    navarro_geometry = json.load(f)

with open("data/geometry.json") as f:
    patient_geometry = json.load(f)

with open("data/geometry_lamberth.json") as f:
    patient_geometry_lamberth = json.load(f)

navarro_ray_trace_data = pd.read_csv("data/navarro_ray_trace_results.csv")
navarro_input_output_angles = pd.read_csv("data/navarro_input_output_angles.csv")

ray_trace_data = pd.read_csv("data/ray_trace_results.csv")
ray_trace_data_lamberth = pd.read_csv("data/ray_trace_results_lamberth.csv")
input_output_angles = pd.read_csv("data/input_output_angles.csv")
input_output_angles_lamberth = pd.read_csv("data/input_output_angles_lamberth.csv")


# Parse tuples in the retina_location column
for df in [
    navarro_input_output_angles,
    input_output_angles,
    input_output_angles_lamberth,
]:
    df.retina_location = df.retina_location.apply(eval)

Plot the relations between camera angles and retinal angles for both the Navarro eye model (solid line) and the patient-specific eye model (dashed line).

[4]:
from matplotlib.lines import Line2D

fig, ax = plt.subplots()

for df, ls in zip([navarro_input_output_angles, input_output_angles], ["-", "--"]):
    sns.lineplot(
        data=df,
        x="input_angle_field",
        y="output_angle_np2",
        ls=ls,
        color="tab:blue",
        label="$2^{\\mathrm{nd}}$ nodal point",
    )
    sns.lineplot(
        data=df,
        x="input_angle_field",
        y="output_angle_retina_center",
        ls=ls,
        color="tab:orange",
        label="Retina center",
    )
    sns.lineplot(
        data=df,
        x="input_angle_field",
        y="output_angle_pupil",
        ls=ls,
        color="tab:green",
        label="Pupil",
    )

# Edit legend
handles = ax.get_legend_handles_labels()[0][:3]
handles += [
    Line2D([], [], linestyle="-", color="black", label="Navarro"),
    Line2D([], [], linestyle="--", color="black", label="Patient"),
]
ax.legend(handles=handles, loc="upper left")

ax.set_xlabel("Camera angle [°]")
ax.set_ylabel("Retina angle [°]")
ax.set_aspect("equal")
ax.grid()
../../_images/examples_Patient-specific_mapping_of_fundus_photographs_to_three-dimensional_ocular_imaging_2_analysis_7_0.png

Fit relations between camera angles and retinal angles#

Fit linear relations between camera angles and retinal angles for the different reference points, on ray trace data from the Navarro eye. These fits are then used as a reference for the non-ractracing method to predict retinal angles from camera angles. Fits are performed on camera angles up to 40°, due to the observed nonlinearity above 40° for the retinal center and pupil.

[5]:
# Fit nodal point method on Navarro data
fit_input_output_angles = navarro_input_output_angles.query("input_angle_field <= 40")

(c1_np2,), _ = curve_fit(
    lambda theta, c1: c1 * theta,
    xdata=fit_input_output_angles.input_angle_field,
    ydata=fit_input_output_angles.output_angle_np2,
    p0=1,
)

(c1_retina_center,), _ = curve_fit(
    lambda theta, c1: c1 * theta,
    xdata=fit_input_output_angles.input_angle_field,
    ydata=fit_input_output_angles.output_angle_retina_center,
    p0=1,
)

(c1_pupil,), _ = curve_fit(
    lambda theta, c1: c1 * theta,
    xdata=fit_input_output_angles.input_angle_field,
    ydata=fit_input_output_angles.output_angle_pupil,
    p0=1,
)

print(f"NP2 fit            : {c1_np2:.3f}")
print(f"Retinal center fit : {c1_retina_center:.3f}")
print(f"Pupil fit          : {c1_pupil:.3f}")
NP2 fit            : 0.999
Retinal center fit : 1.354
Pupil fit          : 0.804

Compare the fitted relations with other methods#

Use the fitted relations obtained with the Navarro eye model, to determine the corresponding retinal location of the simulated subject without ray tracing. The earlier obtained ray tracing data of this subject are used as a ground truth.

Reference point methods#

Determine the retinal location using one of the three reference points (second nodal point, retina center and pupil)

[6]:
input_output_angles["output_angle_np2_fit"] = c1_np2 * input_output_angles.input_angle_field
input_output_angles["output_angle_retina_center_fit"] = c1_retina_center * input_output_angles.input_angle_field
input_output_angles["output_angle_pupil_fit"] = c1_pupil * input_output_angles.input_angle_field

# Calculate retinal locations
input_output_angles["retina_location_np2"] = [
    find_ellipse_intersection(
        r.location_np2,
        np.deg2rad(r.output_angle_np2_fit),
        patient_geometry["retina_radius_z"],
        patient_geometry["retina_radius_y"],
        r.location_retina_center,
    )
    for r in input_output_angles.itertuples()
]

input_output_angles["retina_location_retina_center"] = [
    find_ellipse_intersection(
        r.location_retina_center,
        np.deg2rad(r.output_angle_retina_center_fit),
        patient_geometry["retina_radius_z"],
        patient_geometry["retina_radius_y"],
        r.location_retina_center,
    )
    for r in input_output_angles.itertuples()
]

input_output_angles["retina_location_pupil"] = [
    find_ellipse_intersection(
        0,
        np.deg2rad(r.output_angle_pupil_fit),
        patient_geometry["retina_radius_z"],
        patient_geometry["retina_radius_y"],
        r.location_retina_center,
    )
    for r in input_output_angles.itertuples()
]

input_output_angles["distance_np2"] = euclidean_distance(
    input_output_angles.retina_location, input_output_angles.retina_location_np2
)

input_output_angles["distance_retina_center"] = euclidean_distance(
    input_output_angles.retina_location,
    input_output_angles.retina_location_retina_center,
)

input_output_angles["distance_pupil"] = euclidean_distance(
    input_output_angles.retina_location, input_output_angles.retina_location_pupil
)

EYEPLAN#

Determine the relation between camera angles and retinal angles by the method used in EYEPLAN.

[7]:
OPTIC_FIT_FACTOR = 0.126
FIELD_OF_VIEW = 53.4  # degrees
FILM_SIZE = 1  # cm


def inverse_eyeplan_formula(camera_angle: float, fov: float = FIELD_OF_VIEW, off: float = OPTIC_FIT_FACTOR) -> float:
    """Map `camera_angle` to a retinal angle according to EYEPLAN."""
    return camera_angle * fov / (fov - camera_angle * off)


# EYEPLAN defines a "nodal point" at 3.5 mm behind the cornea
input_output_angles["location_np_eyeplan"] = 3.5 - (
    patient_geometry["cornea_thickness"] + patient_geometry["anterior_chamber_depth"]
)
input_output_angles["output_angle_eyeplan_formula"] = inverse_eyeplan_formula(input_output_angles.input_angle_field)

# Calculate retinal locations according to EYEPLAN
input_output_angles["retina_location_eyeplan"] = [
    find_ellipse_intersection(
        r.location_np_eyeplan,
        np.deg2rad(r.output_angle_eyeplan_formula),
        patient_geometry["retina_radius_z"],
        patient_geometry["retina_radius_y"],
        r.location_retina_center,
    )
    for r in input_output_angles.itertuples()
]


input_output_angles["distance_eyeplan"] = euclidean_distance(
    input_output_angles.retina_location, input_output_angles.retina_location_eyeplan
)

Corcoran#

Formula proposed by Corcoran et al., used in (early versions of) the Optos ophthalmoscope.

[8]:
def corcoran_formula(
    external_angle: float,
    m: float = 0.819,
    R: float = 12,  # noqa: N803
    x: float = 3.68,
) -> float:
    """Corcoran (Optos) mapping formula.

    Converts an external angle (camera angle) to an internal angle (retinal angle) w.r.t. retina center using the
    Corcoran formula.
    """
    external_angle_rad = np.deg2rad(external_angle)

    internal_angle = np.rad2deg(
        m * external_angle_rad + 2 * np.arcsin((R - x) / R * np.sin(m * external_angle_rad / 2))
    )

    return internal_angle


input_output_angles["output_angle_corcoran"] = corcoran_formula(input_output_angles.input_angle_field)

# Calculate retinal locations according to Corcoran formula
input_output_angles["retina_location_corcoran"] = [
    find_ellipse_intersection(
        r.location_retina_center,
        np.deg2rad(r.output_angle_corcoran),
        patient_geometry["retina_radius_z"],
        patient_geometry["retina_radius_y"],
        r.location_retina_center,
    )
    for r in input_output_angles.itertuples()
]

input_output_angles["distance_corcoran"] = euclidean_distance(
    input_output_angles.retina_location, input_output_angles.retina_location_corcoran
)

The following projection methods work slightly different from the methods evaluated above, as they take cartesian coordinates instead of angles as input. This requires an additional conversion step between input angles and image coordinates. The constants of this conversion are obtained through a fit on ray tracing data of the Navarro eye model.

Lamberth Azimuthal Equal-Area Projection#

[9]:
def lamberth_image_to_retina_coordinate(
    y_image: float, r: float = 1, z_retina_center: float = 0
) -> tuple[float, float]:
    """
    Convert an image coordinate to a retinal location using the Lamberth Azimuthal Equal-Area projection.

    Parameters
    ----------
    y_image : float
        Image coordinate.
    r : float
        Radius of the retina. Only spheres are supported.

    Returns
    -------
    tuple[float, float]
        Axial and radial retinal coordinates.
    """
    # Lamberth projection uses coordinates on the unit sphere
    y_retina_norm = np.sqrt(1 - y_image**2 / 4) * y_image
    z_retina_norm = -1 + y_image**2 / 2

    y_retina = y_retina_norm * r
    z_retina = z_retina_norm * r

    # Flip the z-axis: otherwise the back of the retina will get a negative z-coordinate
    return -1 * z_retina + z_retina_center, y_retina


def lamberth_retina_to_image_coordinate(
    z_retina: float, y_retina: float, r: float = 1, z_retina_center: float = 0
) -> float:
    """
    Convert a retinal location to an image coordinate using the Lamberth Azimuthal Equal-Area projection.

    Parameters
    ----------
    z_retina : float
        Axial retinal coordinate.
    y_retina : float
        Radial retinal coordinate.
    r : float
        Radius of the retina. Only spheres are supported.

    Returns
    -------
    float
        Image coordinate.
    """
    y_retina_norm = y_retina / r
    z_retina_norm = (z_retina - z_retina_center) / r

    y_image = np.sqrt(2 / (1 + z_retina_norm)) * y_retina_norm

    return y_image


assert np.isclose(
    0.5,
    lamberth_retina_to_image_coordinate(*lamberth_image_to_retina_coordinate(0.5)),
), "Projection roundtrip fails."
[10]:
def lamberth_angle_conversion_factor(angle: float = 5) -> float:
    """Calculate a scale factor to convert from a camera angle to a Lamberth projection image coordinate.

    Image coordinates are in 'Lamberth projection space'. The Lamberth projection is defined on the unit
    sphere, so all projected images have the same size.

    Parameters
    ----------
    angle : float
        Angle for which the ray trace result is used to calculate the conversion factor.

    Returns
    -------
    float
        Conversion factor in millimeters / degree.
    """
    geometry = navarro_geometry
    ray_trace_data = navarro_ray_trace_data

    mean_retinal_radius = (geometry["retina_radius_y"] + geometry["retina_radius_z"]) / 2
    retina_center = geometry["axial_length"] - (
        geometry["cornea_thickness"] + geometry["anterior_chamber_depth"] + mean_retinal_radius
    )

    retina_coordinate = ray_trace_data.query("Surf == '7' and InputAngle == @angle").iloc[0][
        ["Z-coordinate", "Y-coordinate"]
    ]

    image_coordinate = lamberth_retina_to_image_coordinate(*retina_coordinate, mean_retinal_radius, retina_center)

    return image_coordinate / angle
[11]:
input_output_angles_lamberth["lamberth_angle_conversion_factor"] = lamberth_angle_conversion_factor()

input_output_angles_lamberth["lamberth_projected_image_size"] = (
    input_output_angles_lamberth.lamberth_angle_conversion_factor * input_output_angles_lamberth.input_angle_field
)

input_output_angles_lamberth["retina_location_lamberth"] = input_output_angles_lamberth.apply(
    lambda r: lamberth_image_to_retina_coordinate(
        r.lamberth_projected_image_size,
        r=abs(patient_geometry_lamberth["retina_curvature"]),
        z_retina_center=r.location_retina_center,
    ),
    axis=1,
)

input_output_angles_lamberth["distance_lamberth"] = euclidean_distance(
    input_output_angles_lamberth.retina_location,
    input_output_angles_lamberth.retina_location_lamberth,
)

input_output_angles[["distance_lamberth", "retina_location_lamberth"]] = input_output_angles_lamberth[
    ["distance_lamberth", "retina_location_lamberth"]
]

Equidistant polar projection#

[12]:
from scipy.optimize import minimize_scalar


def octopus_image_to_retina_coordinate(y_image: float, geometry: dict[str, float | int]) -> tuple[float, float]:
    solve_z = minimize_scalar(
        lambda z: abs(
            ellipse_arc_length(
                x1=z,
                x2=geometry["retina_radius_z"],
                r_x=geometry["retina_radius_z"],
                r_y=geometry["retina_radius_y"],
            )
            - y_image
        ),
        bounds=(-geometry["retina_radius_z"], geometry["retina_radius_z"]),
    )

    if not solve_z.success:
        raise RuntimeError(f"Could not solve coordinate for arc length {y_image=}.")

    z_retina = solve_z.x

    y_retina = _upper_ellipse(
        z_retina,
        r_x=geometry["retina_radius_z"],
        r_y=geometry["retina_radius_y"],
    )

    z_retina_center = geometry["lens_thickness"] + geometry["vitreous_thickness"] - geometry["retina_radius_z"]
    z_retina += z_retina_center

    return z_retina, y_retina


def octopus_retina_to_image_coordinate(z_retina: float, y_retina: float, geometry: dict[str, float | int]) -> float:
    z_retina_center = geometry["lens_thickness"] + geometry["vitreous_thickness"] - geometry["retina_radius_z"]

    z_retina -= z_retina_center

    assert np.isclose(
        _upper_ellipse(
            z_retina,
            r_x=geometry["retina_radius_z"],
            r_y=geometry["retina_radius_y"],
        ),
        y_retina,
    )

    arc_length = ellipse_arc_length(
        z_retina,
        geometry["retina_radius_z"],
        r_x=geometry["retina_radius_z"],
        r_y=geometry["retina_radius_y"],
    )

    return arc_length


assert np.isclose(
    octopus_retina_to_image_coordinate(
        *octopus_image_to_retina_coordinate(2 * np.pi * 12 / 8, navarro_geometry),
        navarro_geometry,
    ),
    2 * np.pi * 12 / 8,
), "Projection roundtrip fails."
[13]:
def octopus_angle_conversion_factor(angle: float = 5) -> float:
    """Calculate a scale factor to convert from a camera angle to a Lamberth projection image coordinate."""
    retina_coordinate = navarro_ray_trace_data.query("Surf == '7' and InputAngle == @angle").iloc[0][
        ["Z-coordinate", "Y-coordinate"]
    ]

    image_coordinate = octopus_retina_to_image_coordinate(*retina_coordinate, navarro_geometry)

    return image_coordinate / angle
[14]:
input_output_angles["polar_angle_conversion_factor"] = octopus_angle_conversion_factor()

input_output_angles["polar_projected_image_size"] = (
    input_output_angles.polar_angle_conversion_factor * input_output_angles.input_angle_field
)

input_output_angles["retina_location_polar"] = input_output_angles.apply(
    lambda r: octopus_image_to_retina_coordinate(
        r.polar_projected_image_size,
        geometry=patient_geometry,
    ),
    axis=1,
)

input_output_angles["distance_polar"] = euclidean_distance(
    input_output_angles.retina_location,
    input_output_angles.retina_location_polar,
)

## for debugging: plot the complete list of all angles for all of the methods
# input_output_angles

Plot the results#

Plot the differences between true (ray tracing) and predicted retinal locations for all methods

[15]:
plt.figure()

sns.lineplot(
    input_output_angles,
    x="input_angle_field",
    y="distance_np2",
    label="$2^{\\mathrm{nd}}$ nodal point",
)
sns.lineplot(
    input_output_angles,
    x="input_angle_field",
    y="distance_retina_center",
    label="Retina center",
)
sns.lineplot(input_output_angles, x="input_angle_field", y="distance_pupil", label="Pupil")
sns.lineplot(input_output_angles, x="input_angle_field", y="distance_eyeplan", label="EYEPLAN")
sns.lineplot(input_output_angles, x="input_angle_field", y="distance_corcoran", label="Corcoran")
sns.lineplot(
    input_output_angles,
    x="input_angle_field",
    y="distance_lamberth",
    label="Lamberth projection",
)
sns.lineplot(
    input_output_angles,
    x="input_angle_field",
    y="distance_polar",
    label="Polar projection",
)

plt.grid()
plt.xlabel("Camera angle [°]")
plt.ylabel("Euclidean distance [mm]")
[15]:
Text(0, 0.5, 'Euclidean distance [mm]')
../../_images/examples_Patient-specific_mapping_of_fundus_photographs_to_three-dimensional_ocular_imaging_2_analysis_27_1.png
[16]:
column_names = {
    "input_angle_field": ("", "Camera angle [°]"),
    "retina_location": ("", "Retina location"),
    "retina_location_np2": ("2nd nodal point", "Retina location"),
    "distance_np2": ("2nd nodal point", "Difference [mm]"),
    "retina_location_retina_center": ("Retina center", "Difference [mm]"),
    "distance_retina_center": ("Retina center", "Difference [mm]"),
    "retina_location_pupil": ("Pupil", "Retina location"),
    "distance_pupil": ("Pupil", "Difference [mm]"),
    "retina_location_eyeplan": ("EYEPLAN", "Retina location"),
    "distance_eyeplan": ("EYEPLAN", "Difference [mm]"),
    "retina_location_corcoran": ("Corcoran", "Retina location"),
    "distance_corcoran": ("Corcoran", "Difference [mm]"),
    "retina_location_lamberth": ("Lamberth", "Retina location"),
    "distance_lamberth": ("Lamberth", "Difference [mm]"),
    "retina_location_polar": ("Polar", "Retina location"),
    "distance_polar": ("Polar", "Difference [mm]"),
}

table = input_output_angles[column_names.keys()]
table.columns = pd.MultiIndex.from_tuples(column_names.values())
table.map(lambda x: tuple(round(y, 2) for y in x) if isinstance(x, tuple) else x).round(decimals=2)
[16]:
2nd nodal point Retina center Pupil EYEPLAN Corcoran Lamberth Polar
Camera angle [°] Retina location Retina location Difference [mm] Difference [mm] Difference [mm] Retina location Difference [mm] Retina location Difference [mm] Retina location Difference [mm] Retina location Difference [mm] Retina location Difference [mm]
0 0.0 (20.4, 0.0) (20.4, 0) 0.00 (20.4, 0) 0.00 (20.4, 0) 0.00 (20.4, 0) 0.00 (20.4, 0) 0.00 (20.4, 0.0) 0.00 (20.4, 0.01) 0.01
1 10.0 (20.02, 2.9) (20.02, 2.92) 0.02 (20.04, 2.83) -0.07 (20.04, 2.83) -0.07 (19.8, 3.65) 0.79 (20.02, 2.89) -0.00 (20.06, 2.83) -0.07 (20.04, 2.84) -0.06
2 20.0 (18.93, 5.59) (18.92, 5.62) 0.03 (19.0, 5.47) -0.14 (19.0, 5.47) -0.14 (17.96, 7.05) 1.75 (18.93, 5.59) -0.01 (19.03, 5.53) -0.09 (18.98, 5.51) -0.10
3 30.0 (17.23, 7.91) (17.21, 7.92) 0.03 (17.36, 7.77) -0.19 (17.36, 7.77) -0.19 (15.01, 9.74) 2.88 (17.22, 7.91) 0.00 (17.33, 7.98) 0.03 (17.3, 7.83) -0.10
4 40.0 (15.07, 9.7) (15.06, 9.71) 0.02 (15.23, 9.6) -0.20 (15.25, 9.58) -0.22 (11.28, 11.35) 4.13 (15.03, 9.73) 0.05 (14.94, 10.01) 0.35 (15.11, 9.68) -0.04
5 50.0 (12.66, 10.92) (12.65, 10.93) 0.01 (12.77, 10.88) -0.11 (12.85, 10.85) -0.20 (7.26, 11.67) 5.45 (12.51, 10.98) 0.16 (11.87, 11.42) 1.03 (12.54, 10.97) 0.13
6 60.0 (10.2, 11.57) (10.16, 11.57) 0.03 (10.09, 11.58) 0.11 (10.32, 11.55) -0.12 (3.53, 10.76) 6.71 (9.8, 11.62) 0.40 (8.12, 11.9) 2.25 (9.75, 11.63) 0.45
7 70.0 (7.85, 11.7) (7.74, 11.7) 0.11 (7.33, 11.67) 0.52 (7.82, 11.7) 0.03 (0.56, 9.01) 7.77 (7.04, 11.65) 0.81 (3.68, 10.89) 4.40 (6.88, 11.63) 0.97
8 80.0 (5.74, 11.45) (5.49, 11.39) 0.25 (4.61, 11.15) 1.16 (5.48, 11.39) 0.26 (-1.46, 6.93) 8.50 (4.36, 11.07) 1.43 (-1.44, 6.57) 8.81 (4.09, 10.98) 1.71
9 5.0 (20.3, 1.46) (20.3, 1.47) 0.01 (20.31, 1.43) -0.04 (20.31, 1.43) -0.04 (20.25, 1.83) 0.37 (20.3, 1.46) -0.00 (20.31, 1.42) -0.04 (20.31, 1.43) -0.03
10 15.0 (19.56, 4.28) (19.55, 4.31) 0.02 (19.6, 4.18) -0.11 (19.6, 4.19) -0.11 (19.03, 5.41) 1.24 (19.56, 4.28) -0.01 (19.63, 4.21) -0.09 (19.59, 4.21) -0.08
11 25.0 (18.14, 6.81) (18.13, 6.83) 0.03 (18.25, 6.67) -0.17 (18.24, 6.67) -0.17 (16.61, 8.51) 2.29 (18.15, 6.8) -0.01 (18.27, 6.8) -0.05 (18.21, 6.72) -0.11
12 35.0 (16.19, 8.88) (16.18, 8.89) 0.02 (16.35, 8.74) -0.20 (16.35, 8.74) -0.21 (13.22, 10.7) 3.49 (16.18, 8.89) 0.02 (16.22, 9.06) 0.16 (16.26, 8.82) -0.08
13 45.0 (13.89, 10.39) (13.88, 10.39) 0.01 (14.04, 10.31) -0.17 (14.08, 10.29) -0.22 (9.27, 11.67) 4.79 (13.8, 10.43) 0.09 (13.49, 10.81) 0.64 (13.86, 10.4) 0.03
14 55.0 (11.42, 11.31) (11.41, 11.32) 0.02 (11.45, 11.31) -0.02 (11.59, 11.27) -0.17 (5.33, 11.35) 6.10 (11.17, 11.38) 0.26 (10.08, 11.8) 1.55 (11.17, 11.38) 0.27
15 65.0 (9.0, 11.69) (8.94, 11.7) 0.06 (8.71, 11.7) 0.29 (9.05, 11.69) -0.06 (1.93, 9.96) 7.27 (8.42, 11.71) 0.58 (5.98, 11.64) 3.17 (8.32, 11.71) 0.68
16 75.0 (6.76, 11.62) (6.59, 11.59) 0.17 (5.96, 11.49) 0.81 (6.62, 11.6) 0.14 (-0.56, 7.97) 8.18 (5.68, 11.43) 1.10 (1.21, 9.42) 6.12 (5.47, 11.39) 1.31
17 85.0 (4.78, 11.21) (4.45, 11.1) 0.35 (3.31, 10.67) 1.57 (4.41, 11.09) 0.39 (-2.14, 5.92) 8.71 (3.09, 10.57) 1.81 (-4.25, nan) NaN (2.77, 10.42) 2.17