PAROS 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- PAROS/__init__.py +10 -0
- PAROS/data/__init__.py +70 -0
- PAROS/data/calibration/Topcon/TRC_50DX.csv +10 -0
- PAROS/data/calibration/Topcon/TRC_50IX.csv +11 -0
- PAROS/data/calibration/__init__.py +1 -0
- PAROS/data/lenses/__init__.py +1 -0
- PAROS/data/lenses/phantom.csv +13 -0
- PAROS/eyephantom.py +197 -0
- PAROS/fundscale.py +667 -0
- paros-0.1.0.dist-info/METADATA +89 -0
- paros-0.1.0.dist-info/RECORD +13 -0
- paros-0.1.0.dist-info/WHEEL +4 -0
- paros-0.1.0.dist-info/licenses/LICENSE +21 -0
PAROS/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""PARaxial Optical fundus Scaling (PAROS)
|
|
2
|
+
|
|
3
|
+
Paros is a method to calculate the magnification of fundus images based on the optical characteristics of the patient's
|
|
4
|
+
eye. The full method and validation are described in (article reference to be added upon acceptance).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from PAROS import data, eyephantom, fundscale
|
|
8
|
+
|
|
9
|
+
__all__ = ("data", "eyephantom", "fundscale")
|
|
10
|
+
__version__ = "0.1.0"
|
PAROS/data/__init__.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Calibration and lens data files."""
|
|
2
|
+
|
|
3
|
+
import importlib.resources
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import cast
|
|
6
|
+
|
|
7
|
+
import pandas as pd
|
|
8
|
+
|
|
9
|
+
__all__ = ("get_camera_calibration_data", "get_lens_data")
|
|
10
|
+
|
|
11
|
+
calibration = importlib.resources.files("PAROS.data.calibration")
|
|
12
|
+
lenses = importlib.resources.files("PAROS.data.lenses")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _find_available_manufacturers():
|
|
16
|
+
return [i.name for i in calibration.iterdir() if i.is_dir()]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
_camera_manufacturers = _find_available_manufacturers()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_camera_calibration_data(manufacturer: str, camera: str) -> pd.DataFrame:
|
|
23
|
+
"""Get camera calibration data for a given manufacturer and camera.
|
|
24
|
+
|
|
25
|
+
Reads the data file at `PAROS/data/calibration/{manufacturer}/{camera}.csv` and
|
|
26
|
+
returns it as a pandas DataFrame.
|
|
27
|
+
|
|
28
|
+
Parameters
|
|
29
|
+
----------
|
|
30
|
+
manufacturer : str
|
|
31
|
+
Camera manufacturer name.
|
|
32
|
+
camera : str
|
|
33
|
+
Camera model name.
|
|
34
|
+
|
|
35
|
+
Returns
|
|
36
|
+
-------
|
|
37
|
+
pandas.DataFrame
|
|
38
|
+
Dataframe containing the camera calibration data.
|
|
39
|
+
"""
|
|
40
|
+
if manufacturer not in _camera_manufacturers:
|
|
41
|
+
raise KeyError(f"Manufacturer {manufacturer} does not exist.")
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
camera_data_file = cast(Path, calibration.joinpath(manufacturer, camera + ".csv"))
|
|
45
|
+
|
|
46
|
+
return pd.read_csv(camera_data_file)
|
|
47
|
+
except FileNotFoundError as e:
|
|
48
|
+
raise KeyError(f"Camera {camera} does not exist for manufacturer {manufacturer}.") from e
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def get_lens_data(name: str) -> pd.DataFrame:
|
|
52
|
+
"""Get lens data.
|
|
53
|
+
|
|
54
|
+
Reads the data file at `PAROS/data/lenses/{name}.csv` and returns it as a pandas DataFrame.
|
|
55
|
+
|
|
56
|
+
Parameters
|
|
57
|
+
----------
|
|
58
|
+
name : str
|
|
59
|
+
Name of the lens data set.
|
|
60
|
+
|
|
61
|
+
Returns
|
|
62
|
+
-------
|
|
63
|
+
pandas.DataFrame
|
|
64
|
+
Dataframe containing the lens data.
|
|
65
|
+
"""
|
|
66
|
+
try:
|
|
67
|
+
lens_data_file = cast(Path, lenses.joinpath(name + ".csv"))
|
|
68
|
+
return pd.read_csv(lens_data_file)
|
|
69
|
+
except FileNotFoundError as e:
|
|
70
|
+
raise KeyError(f"Lens data does not exist for name {name}.") from e
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
experiment_number,lens_1,lens_2,uncorrected_measurement,distance_camera_eye_mm,distance_camera_pinhole_mm,distance_pinhole_lens1_mm,distance_lens1_lens2_mm,distance_lens2_grid_mm,axial_length
|
|
2
|
+
0,LB_1471,LB_1945,6.966666667,5.5,49.61969784,2.880302163,3.027080745,29.69261709,40.71969784
|
|
3
|
+
1,LB_1471,LB_1945,6.726666667,5.5,49.61969784,2.880302163,3.027080745,31.69261709,42.71969784
|
|
4
|
+
2,LB_1471,LB_1945,6.6,5.5,49.61969784,2.880302163,3.027080745,33.69261709,44.71969784
|
|
5
|
+
3,LB_1471,LB_1945,6.37,5.5,49.61969784,2.880302163,3.027080745,35.69261709,46.71969784
|
|
6
|
+
4,LB_1471,LB_1945,6.25,5.5,49.61969784,2.880302163,3.027080745,37.69261709,48.71969784
|
|
7
|
+
5,LB_1471,LB_1945,6.086666667,5.5,49.61969784,2.880302163,3.027080745,39.69261709,50.71969784
|
|
8
|
+
6,LB_1471,LB_1945,5.973333333,5.5,49.61969784,2.880302163,3.027080745,41.69261709,52.71969784
|
|
9
|
+
7,LB_1471,LB_1945,5.746666667,5.5,49.61969784,2.880302163,3.027080745,43.69261709,54.71969784
|
|
10
|
+
8,LB_1471,LB_1945,5.613333333,5.5,49.61969784,2.880302163,3.027080745,45.39261709,56.41969784
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
lens_1,lens_2,axial_length,distance_camera_eye_mm,uncorrected_measurement,distance_camera_pinhole_mm,distance_pinhole_lens1_mm,distance_lens1_lens2_mm,distance_lens2_grid_mm,experiment_number
|
|
2
|
+
LE_1156,LB_1945,83.9874534,5.5,3.31,50.2374534,2.262546596,6.344836312,71.54261709,0
|
|
3
|
+
LE_1156,LB_1945,90.7374534,5.5,3.13,50.2374534,2.262546596,6.344836312,78.29261709,1
|
|
4
|
+
LE_1156,LB_1945,78.0374534,5.5,3.46,50.2374534,2.262546596,6.344836312,65.59261709,2
|
|
5
|
+
LE_1156,LB_1945,134.6374534,5.5,2.24,50.2374534,2.262546596,6.344836312,122.1926171,3
|
|
6
|
+
LE_1156,LB_1945,107.9374534,5.5,2.69,50.2374534,2.262546596,6.344836312,95.49261709,4
|
|
7
|
+
LE_1156,LB_1945,72.0374534,5.5,3.71,50.2374534,2.262546596,6.344836312,59.59261709,5
|
|
8
|
+
LE_1156,LB_1945,63.6374534,5.5,4.08,50.2374534,2.262546596,6.344836312,51.19261709,6
|
|
9
|
+
LE_1234,LB_1056,62.51915876,5,4.28,45.31915876,2.180841236,5.205295461,51.1138633,7
|
|
10
|
+
LE_1234,LB_1056,75.91915876,5,3.66,45.31915876,2.180841236,5.205295461,64.5138633,8
|
|
11
|
+
LE_1234,LB_1056,96.51915876,5,2.98,45.31915876,2.180841236,5.205295461,85.1138633,9
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Camera calibration data files."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Lens data files."""
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
name,lens_type,thickness_centre,thickness_edge,diameter,power,front_radius,back_radius,refractive_index,power_calculated
|
|
2
|
+
LB_1409,biconvex,2.2,2.0,25.6,1.0,1029.8,-1029.8,1.515,1.0001942124684402
|
|
3
|
+
LB_1869,biconvex,2.3,2.0,25.6,2.0,514.7,-514.7,1.515,2.001165727608315
|
|
4
|
+
LB_1056_1,biconvex,2.6,2.0,25.6,4.0,257.1,-257.1,1.515,4.006223259432127
|
|
5
|
+
LE_1234,convex-concave,3.6,2.0,25.6,10.0,-82.2,-32.1,1.515,9.77840689451304
|
|
6
|
+
LE_1156,convex-concave,3.3,2.0,25.6,8.0,-106.9,-40.6,1.515,7.867142534572616
|
|
7
|
+
LE_1104,convex-concave,3.1,2.0,25.6,6.7,-131.6,-49.1,1.515,6.575424510489725
|
|
8
|
+
LE_1202,convex-concave,2.8,2.0,25.6,5.0,-182.2,-66.2,1.515,4.952891978205283
|
|
9
|
+
LB_1945,biconvex,2.8,2.0,25.6,5.0,205.6,-205.6,1.515,5.009727626459142
|
|
10
|
+
LB_1056,biconvex,2.6,2.0,25.6,4.0,257.1,-257.1,1.515,4.006223259432127
|
|
11
|
+
LB_1779,biconvex,2.5,2.0,25.6,3.3,308.6,-308.6,1.515,3.3376539209332456
|
|
12
|
+
LB_1471,biconvex,5.2,2.0,25.6,20.0,50.6,-50.6,1.515,20.355731225296400
|
|
13
|
+
LB_1757,biconvex,7.7,2.0,25.6,33.3,29.5,-29.5,1.515,34.91525423728813
|
PAROS/eyephantom.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Paraxial optical modelling of an eye phantom for fundus camera calibrations.
|
|
2
|
+
|
|
3
|
+
This module contains classes to model an eye phantom used to calibrate a fundus camera.
|
|
4
|
+
Lenses are specified using the `Lens` class. The full phantom is modeled with the `EyePhantom` class.
|
|
5
|
+
Lens and phantom data is included in `PAROS.data`.
|
|
6
|
+
This module can be used in conjunction with `PAROS.fundscale` to calculate the central magnification of the phantom.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Literal
|
|
12
|
+
|
|
13
|
+
import pandas as pd
|
|
14
|
+
import sympy as sp
|
|
15
|
+
|
|
16
|
+
from PAROS.fundscale import spherical_interface, uniform_medium
|
|
17
|
+
|
|
18
|
+
sp.init_printing(use_unicode=True)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Lens:
|
|
22
|
+
"""Ray transfer model of a lens."""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
name: str,
|
|
27
|
+
center_thickness: float,
|
|
28
|
+
edge_thickness: float,
|
|
29
|
+
back_curvature: float,
|
|
30
|
+
front_curvature: float,
|
|
31
|
+
refractive_index: float,
|
|
32
|
+
diameter: float,
|
|
33
|
+
power: float,
|
|
34
|
+
lens_type: Literal["biconvex", "convex-concave"],
|
|
35
|
+
):
|
|
36
|
+
"""Initialize a lens model.
|
|
37
|
+
|
|
38
|
+
Parameters
|
|
39
|
+
----------
|
|
40
|
+
name : str
|
|
41
|
+
Name of the lens.
|
|
42
|
+
center_thickness : float
|
|
43
|
+
Central thickness of the lens, in millimeters.
|
|
44
|
+
edge_thickness : float
|
|
45
|
+
Edge thickness of the lens, in millimeters.
|
|
46
|
+
back_curvature : float
|
|
47
|
+
Radius of curvature of the back surface, in millimeters.
|
|
48
|
+
front_curvature : float
|
|
49
|
+
Radius of curvature of the front surface, in millimeters.
|
|
50
|
+
refractive_index : float
|
|
51
|
+
Refractive index of the lens.
|
|
52
|
+
diameter : float
|
|
53
|
+
Diameter of the lens, in millimeters.
|
|
54
|
+
power : float
|
|
55
|
+
Power according to the lens data sheet, in diopters.
|
|
56
|
+
lens_type : str
|
|
57
|
+
Lens type, one of ['biconvex', 'convex-concave'].
|
|
58
|
+
"""
|
|
59
|
+
self.name = name
|
|
60
|
+
self.center_thickness = center_thickness
|
|
61
|
+
self.edge_thickness = edge_thickness
|
|
62
|
+
self.back_curvature = back_curvature
|
|
63
|
+
self.front_curvature = front_curvature
|
|
64
|
+
self.refractive_index = refractive_index
|
|
65
|
+
self.lens_type = lens_type
|
|
66
|
+
self.diameter = diameter
|
|
67
|
+
self.power = power
|
|
68
|
+
|
|
69
|
+
def __str__(self):
|
|
70
|
+
return f"{self.name}"
|
|
71
|
+
|
|
72
|
+
def ray_transfer_matrix(self) -> sp.Matrix:
|
|
73
|
+
"""Calculate the ray transfer matrix for the lens.
|
|
74
|
+
|
|
75
|
+
Returns
|
|
76
|
+
-------
|
|
77
|
+
sympy.Matrix
|
|
78
|
+
Ray transfer matrix for the lens.
|
|
79
|
+
"""
|
|
80
|
+
air_refractive_index = 1.0
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
spherical_interface(self.refractive_index, air_refractive_index, self.back_curvature * 1e-3)
|
|
84
|
+
* uniform_medium(self.center_thickness * 1e-3)
|
|
85
|
+
* spherical_interface(air_refractive_index, self.refractive_index, self.front_curvature * 1e-3)
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def make_lens(lens_file: pd.DataFrame, name: str) -> Lens:
|
|
90
|
+
"""Create a `Lens` object for the lens `name` from `lens_file`.
|
|
91
|
+
|
|
92
|
+
Parameters
|
|
93
|
+
----------
|
|
94
|
+
lens_file : pandas.DataFrame
|
|
95
|
+
DataFrame with lens specifications.
|
|
96
|
+
name : str
|
|
97
|
+
Name of the lens, must occur in `lens_file`.
|
|
98
|
+
|
|
99
|
+
Returns
|
|
100
|
+
-------
|
|
101
|
+
Lens
|
|
102
|
+
`Lens` object.
|
|
103
|
+
"""
|
|
104
|
+
lens_info = lens_file[lens_file["name"] == name].iloc[0]
|
|
105
|
+
|
|
106
|
+
return Lens(
|
|
107
|
+
name=lens_info["name"], # 'name' is a property of pandas.Series
|
|
108
|
+
center_thickness=float(lens_info.thickness_centre),
|
|
109
|
+
edge_thickness=float(lens_info.thickness_edge),
|
|
110
|
+
back_curvature=float(lens_info.back_radius),
|
|
111
|
+
front_curvature=float(lens_info.front_radius),
|
|
112
|
+
refractive_index=float(lens_info.refractive_index),
|
|
113
|
+
diameter=float(lens_info.diameter),
|
|
114
|
+
power=float(lens_info.power),
|
|
115
|
+
lens_type=lens_info.lens_type,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class EyePhantom:
|
|
120
|
+
"""Eye phantom consisting of two lenses and a grid.
|
|
121
|
+
|
|
122
|
+
Specification of an eye phantom consisting of two lenses and a grid.
|
|
123
|
+
The first lens models the cornea, the second lens the eye lens.
|
|
124
|
+
|
|
125
|
+
Attributes
|
|
126
|
+
----------
|
|
127
|
+
name : str
|
|
128
|
+
Name of the phantom.
|
|
129
|
+
iol : Lens
|
|
130
|
+
`Lens` object for the eye lens.
|
|
131
|
+
cornea : Lens
|
|
132
|
+
`Lens` object for the cornea.
|
|
133
|
+
lens_to_lens_distance : float
|
|
134
|
+
Distance between the cornea and the lens.
|
|
135
|
+
lens_to_grid_distance : float
|
|
136
|
+
Distance between the lens and the grid.
|
|
137
|
+
camera_distance : float
|
|
138
|
+
Distance from the cornea front to the camera.
|
|
139
|
+
ray_transfer_matrix : sympy.Matrix
|
|
140
|
+
Ray transfer matrix for the phantom.
|
|
141
|
+
ray_transfer_matrix_to_camera : sympy.Matrix
|
|
142
|
+
Ray transfer matrix between the phantom and the camera.
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
def __init__(self, experiment: pd.Series = None, lens_file: pd.DataFrame = None):
|
|
146
|
+
"""Initialize an eye phantom from a dataframe.
|
|
147
|
+
|
|
148
|
+
Currently only implemented for models of type 'set1'. If the dataframe
|
|
149
|
+
contains another model type, an empty model is created.
|
|
150
|
+
|
|
151
|
+
Parameters
|
|
152
|
+
----------
|
|
153
|
+
experiment : pandas.Series
|
|
154
|
+
Series with experiment parameters.
|
|
155
|
+
lens_file : pandas.DataFrame
|
|
156
|
+
DataFrame with lens specifications.
|
|
157
|
+
"""
|
|
158
|
+
if experiment is not None and lens_file is not None:
|
|
159
|
+
self.build_from_dataframe(experiment, lens_file)
|
|
160
|
+
else:
|
|
161
|
+
self.name = "unknown"
|
|
162
|
+
self.iol = None
|
|
163
|
+
self.cornea = None
|
|
164
|
+
self.lens_to_lens_distance = None
|
|
165
|
+
self.lens_to_grid_distance = None
|
|
166
|
+
self.camera_distance = None
|
|
167
|
+
self.ray_transfer_matrix = None
|
|
168
|
+
self.ray_transfer_matrix_to_camera = None
|
|
169
|
+
|
|
170
|
+
def __str__(self):
|
|
171
|
+
return f"{self.name}"
|
|
172
|
+
|
|
173
|
+
def build_from_dataframe(self, experiment: pd.Series, lens_file: pd.DataFrame) -> None:
|
|
174
|
+
"""Initializes an `EyePhantom` instance from a pandas DataFrame.
|
|
175
|
+
|
|
176
|
+
Parameters
|
|
177
|
+
----------
|
|
178
|
+
experiment : pandas.Series
|
|
179
|
+
Series with experiment parameters.
|
|
180
|
+
lens_file : pandas.DataFrame
|
|
181
|
+
DataFrame with lens specifications.
|
|
182
|
+
"""
|
|
183
|
+
self.name: str = experiment.get("name")
|
|
184
|
+
self.iol: Lens = make_lens(lens_file, experiment.lens_2)
|
|
185
|
+
self.cornea: Lens = make_lens(lens_file, experiment.lens_1)
|
|
186
|
+
self.lens_to_lens_distance: float = float(experiment.distance_lens1_lens2_mm)
|
|
187
|
+
self.lens_to_grid_distance: float = float(experiment.distance_lens2_grid_mm)
|
|
188
|
+
self.camera_distance: float = experiment.distance_camera_eye_mm
|
|
189
|
+
|
|
190
|
+
matrix = (
|
|
191
|
+
self.cornea.ray_transfer_matrix()
|
|
192
|
+
* uniform_medium(self.lens_to_lens_distance * 1e-3)
|
|
193
|
+
* self.iol.ray_transfer_matrix()
|
|
194
|
+
* uniform_medium(self.lens_to_grid_distance * 1e-3)
|
|
195
|
+
)
|
|
196
|
+
self.ray_transfer_matrix: sp.Matrix = matrix
|
|
197
|
+
self.ray_transfer_matrix_to_camera = uniform_medium(self.camera_distance * 1e-3)
|
PAROS/fundscale.py
ADDED
|
@@ -0,0 +1,667 @@
|
|
|
1
|
+
"""Paraxial central scaling of fundus images.
|
|
2
|
+
|
|
3
|
+
This module implements a paraxial method to calculate the central scaling
|
|
4
|
+
of fundus images. The scaling is calculated by defining an eye using the
|
|
5
|
+
`Eye` class and a fundus camera using the `Camera` class. The magnification
|
|
6
|
+
can then be calculated using `calculate_magnification`.
|
|
7
|
+
|
|
8
|
+
Throughout this module, the following abbreviations are used for ocular
|
|
9
|
+
geometrical parameters:
|
|
10
|
+
|
|
11
|
+
- R_corF: cornea front radius
|
|
12
|
+
- R_corB: cornea back radius
|
|
13
|
+
- R_lensF: lens front radius
|
|
14
|
+
- R_lensB: lens back radius
|
|
15
|
+
- D_cor: cornea thickness
|
|
16
|
+
- D_ACD: anterior chamber depth
|
|
17
|
+
- D_lens: lens thickness
|
|
18
|
+
- D_vitr: vitreous thickness
|
|
19
|
+
|
|
20
|
+
Unless otherwise specified, sizes and distances are specified in meters.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import copy
|
|
26
|
+
import math
|
|
27
|
+
from typing import Literal, NamedTuple, Union
|
|
28
|
+
from warnings import warn
|
|
29
|
+
|
|
30
|
+
import numpy as np
|
|
31
|
+
import sympy as sp
|
|
32
|
+
|
|
33
|
+
sp.init_printing(use_unicode=True)
|
|
34
|
+
|
|
35
|
+
NumberOrSymbol = Union[int, float, sp.Symbol]
|
|
36
|
+
EyeModelType = Literal["Navarro", "VughtIOL"]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _round_expr(expr: sp.Expr, num_digits: int = 3) -> sp.Expr:
|
|
40
|
+
return expr.xreplace({n: round(n, num_digits) for n in expr.atoms(sp.Number)})
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def uniform_medium(thickness: float) -> sp.Matrix:
|
|
44
|
+
"""Uniform medium without refraction.
|
|
45
|
+
|
|
46
|
+
Parameters
|
|
47
|
+
----------
|
|
48
|
+
thickness : float
|
|
49
|
+
Thickness of the medium.
|
|
50
|
+
|
|
51
|
+
Returns
|
|
52
|
+
-------
|
|
53
|
+
sympy.Matrix
|
|
54
|
+
Ray transfer matrix for the uniform medium.
|
|
55
|
+
"""
|
|
56
|
+
return sp.Matrix([[1, thickness], [0, 1]])
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def medium_change(n_in: float, n_out: float) -> sp.Matrix:
|
|
60
|
+
"""Change between media.
|
|
61
|
+
|
|
62
|
+
Parameters
|
|
63
|
+
----------
|
|
64
|
+
n_in : float
|
|
65
|
+
Refractive index of the first medium.
|
|
66
|
+
n_out : float
|
|
67
|
+
Refractive index of the second medium.
|
|
68
|
+
|
|
69
|
+
Returns
|
|
70
|
+
-------
|
|
71
|
+
sympy.Matrix
|
|
72
|
+
Ray transfer matrix for the medium change.
|
|
73
|
+
"""
|
|
74
|
+
return sp.Matrix([[1, 0], [0, n_in / n_out]])
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def spherical_interface(n_in: float, n_out: float, curvature: float) -> sp.Matrix:
|
|
78
|
+
"""Spherical interface between two media.
|
|
79
|
+
|
|
80
|
+
Parameters
|
|
81
|
+
----------
|
|
82
|
+
n_in : float
|
|
83
|
+
Refractive index of the first medium.
|
|
84
|
+
n_out : float
|
|
85
|
+
Refractive index of the second medium.
|
|
86
|
+
curvature : float
|
|
87
|
+
Radius of curvature of the interface.
|
|
88
|
+
|
|
89
|
+
Returns
|
|
90
|
+
-------
|
|
91
|
+
sympy.Matrix
|
|
92
|
+
Ray transfer matrix for the spherical interface.
|
|
93
|
+
"""
|
|
94
|
+
return sp.Matrix([[1, 0], [(n_in - n_out) / (n_out * curvature), (n_in / n_out)]])
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def calculate_piol_curvature(piol_power: float, thickness: float = 0.2e-3, n_iol: float = 1.47) -> float:
|
|
98
|
+
"""Calculate the radius of curvature for a phakic IOL.
|
|
99
|
+
|
|
100
|
+
Calculate the IOL radii for a pIOL with power `piol_power`. The front and back
|
|
101
|
+
curvatures of the IOL are equal.
|
|
102
|
+
|
|
103
|
+
The radii are calculated according to ISO 11979-2-2014 A.2.1:
|
|
104
|
+
|
|
105
|
+
.. math:: D_{iol} = 2 D_{front} - (t / n_{iol}) D_{front} ^ 2
|
|
106
|
+
.. math:: D_{front} = (n_{iol} - n_{medium}) / R_{front}
|
|
107
|
+
.. math:: n_{medium} = 1.336
|
|
108
|
+
|
|
109
|
+
Parameters
|
|
110
|
+
----------
|
|
111
|
+
piol_power : float
|
|
112
|
+
Power of the pIOL, in diopters.
|
|
113
|
+
thickness : float
|
|
114
|
+
Thickness of the pIOL, in meters.
|
|
115
|
+
n_iol : float
|
|
116
|
+
Refractive index of the pIOL.
|
|
117
|
+
|
|
118
|
+
Returns
|
|
119
|
+
-------
|
|
120
|
+
float
|
|
121
|
+
Radius of curvature of the front and back pIOL surface.
|
|
122
|
+
|
|
123
|
+
Notes
|
|
124
|
+
-----
|
|
125
|
+
The calculation of the pIOL curvature seems to be in congruence with ANSI standard
|
|
126
|
+
and signs are correct.
|
|
127
|
+
"""
|
|
128
|
+
n_medium = 1.336
|
|
129
|
+
|
|
130
|
+
A = -thickness / n_iol # noqa: N806
|
|
131
|
+
B = 2 # noqa: N806
|
|
132
|
+
C = -piol_power # noqa: N806
|
|
133
|
+
|
|
134
|
+
front_power = (-B + math.sqrt(B * B - 4 * A * C)) / (2 * A)
|
|
135
|
+
|
|
136
|
+
return (n_iol - n_medium) / front_power
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def calculate_piol_matrix(
|
|
140
|
+
piol_power: NumberOrSymbol,
|
|
141
|
+
thickness: float,
|
|
142
|
+
n_iol: float,
|
|
143
|
+
distance_iol_lens: float,
|
|
144
|
+
n_aq: float,
|
|
145
|
+
) -> sp.Matrix:
|
|
146
|
+
"""Calculate the ray transfer matrix for a phakic IOL.
|
|
147
|
+
|
|
148
|
+
Parameters
|
|
149
|
+
----------
|
|
150
|
+
piol_power : float
|
|
151
|
+
Power of the pIOL, in diopters.
|
|
152
|
+
thickness : float
|
|
153
|
+
Thickness of the pIOL, in meters.
|
|
154
|
+
n_iol : float
|
|
155
|
+
Refractive index of the pIOL.
|
|
156
|
+
distance_iol_lens : float
|
|
157
|
+
Distance between the pIOL and the crystalline lens, in m.
|
|
158
|
+
n_aq : float
|
|
159
|
+
Refractive index of the aqueous humor.
|
|
160
|
+
|
|
161
|
+
Returns
|
|
162
|
+
-------
|
|
163
|
+
sympy.Matrix
|
|
164
|
+
Ray transfer matrix for the pIOL.
|
|
165
|
+
"""
|
|
166
|
+
piol_curvature = calculate_piol_curvature(piol_power, thickness, n_iol)
|
|
167
|
+
|
|
168
|
+
piol_matrix = (
|
|
169
|
+
spherical_interface(n_iol, n_aq, -1 * piol_curvature)
|
|
170
|
+
* uniform_medium(thickness)
|
|
171
|
+
* spherical_interface(n_aq, n_iol, piol_curvature)
|
|
172
|
+
)
|
|
173
|
+
distance_matrix = uniform_medium(distance_iol_lens)
|
|
174
|
+
|
|
175
|
+
return piol_matrix * distance_matrix
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class PhakicIOL(NamedTuple):
|
|
179
|
+
"""Parameters of a phakic IOL.
|
|
180
|
+
|
|
181
|
+
Attributes
|
|
182
|
+
----------
|
|
183
|
+
power : NumberOrSymbol
|
|
184
|
+
Power in diopters.
|
|
185
|
+
thickness : NumberOrSymbol
|
|
186
|
+
Thickness in meters.
|
|
187
|
+
refractive_index : NumberOrSymbol
|
|
188
|
+
Refractive index of the pIOL material.
|
|
189
|
+
lens_distance : NumberOrSymbol
|
|
190
|
+
Distance to the crystalline lens in meters.
|
|
191
|
+
"""
|
|
192
|
+
|
|
193
|
+
power: NumberOrSymbol
|
|
194
|
+
thickness: NumberOrSymbol
|
|
195
|
+
refractive_index: NumberOrSymbol
|
|
196
|
+
lens_distance: NumberOrSymbol
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class Eye:
|
|
200
|
+
"""Symbolic representation of an eye model."""
|
|
201
|
+
|
|
202
|
+
def __init__(
|
|
203
|
+
self,
|
|
204
|
+
name: str = "testEye",
|
|
205
|
+
geometry: dict[str, float] | None = None,
|
|
206
|
+
model_type: EyeModelType = "Navarro",
|
|
207
|
+
NType: EyeModelType = "Navarro", # noqa: N803
|
|
208
|
+
refraction: float | None = None,
|
|
209
|
+
pIOL: PhakicIOL | None = None, # noqa: N803
|
|
210
|
+
) -> None:
|
|
211
|
+
"""Initialize an eye model.
|
|
212
|
+
|
|
213
|
+
This class works with a symbolic representation of the eye model, in which the
|
|
214
|
+
numeric values of the model parameters as defined in `geometry` are substituted
|
|
215
|
+
when needed. The eye's geometry can be passed to this function as a dictionary.
|
|
216
|
+
If this is a partial dictionary, the geometrical parameters corresponding to
|
|
217
|
+
`model_type` are updated with these values.
|
|
218
|
+
|
|
219
|
+
Parameters
|
|
220
|
+
----------
|
|
221
|
+
name : str
|
|
222
|
+
Name of the eye model.
|
|
223
|
+
geometry : dict[str, float]
|
|
224
|
+
Dictionary with numerical geometric parameters of the eye. If `None`, the
|
|
225
|
+
geometry is based on `model_type`. Can be a partial dictionary, in which
|
|
226
|
+
case missing values are based on the eye model specified by `model_type`.
|
|
227
|
+
model_type : EyeModelType
|
|
228
|
+
Standard eye model on which the eye geometry is based. One of
|
|
229
|
+
["Navarro", "VughtIOL"].
|
|
230
|
+
NType : EyeModelType
|
|
231
|
+
Standard eye model from which the refractive indices are used. One of
|
|
232
|
+
["Navarro", "VughtIOL"].
|
|
233
|
+
refraction : float
|
|
234
|
+
Spherical equivalent of refraction of the eye model. The lens back curvature
|
|
235
|
+
can be adjusted to obtain this refraction with `Eye.adjust_lens_back`.
|
|
236
|
+
pIOL : PhakicIOL
|
|
237
|
+
Properties of the pIOL, if present. Tuple of power, thickness, refractive
|
|
238
|
+
index, distance to lens.
|
|
239
|
+
"""
|
|
240
|
+
self.name = name
|
|
241
|
+
self.spherical_equivalent = refraction
|
|
242
|
+
self.is_pseudophakic = model_type == "VughtIOL"
|
|
243
|
+
self.axial_length = None
|
|
244
|
+
self.pIOL = pIOL
|
|
245
|
+
|
|
246
|
+
self.geometry = self._build_geometry_dictionary(model_type, geometry)
|
|
247
|
+
|
|
248
|
+
if NType == "Navarro":
|
|
249
|
+
self.refractive_indices = {
|
|
250
|
+
"cor": 1.3777,
|
|
251
|
+
"aq": 1.3391,
|
|
252
|
+
"lens": 1.4222,
|
|
253
|
+
"vit": 1.3377,
|
|
254
|
+
}
|
|
255
|
+
elif NType == "VughtIOL":
|
|
256
|
+
self.refractive_indices = {
|
|
257
|
+
"cor": 1.3777,
|
|
258
|
+
"aq": 1.3391,
|
|
259
|
+
"lens": 1.47,
|
|
260
|
+
"vit": 1.3377,
|
|
261
|
+
}
|
|
262
|
+
else:
|
|
263
|
+
raise ValueError(f"Model type {NType} is undefined.")
|
|
264
|
+
|
|
265
|
+
self.R_corF, self.R_corB, self.R_lensF, self.R_lensB = sp.symbols("R_corF R_corB R_lensF R_lensB")
|
|
266
|
+
self.D_cor, self.D_ACD, self.D_lens, self.D_vitr = sp.symbols("D_cor D_ACD D_lens D_vitr")
|
|
267
|
+
|
|
268
|
+
self.axial_length = (
|
|
269
|
+
self.geometry["D_cor"] + self.geometry["D_ACD"] + self.geometry["D_lens"] + self.geometry["D_vitr"]
|
|
270
|
+
)
|
|
271
|
+
self.ray_transfer_matrix = self.calculate_ray_transfer_matrix()
|
|
272
|
+
|
|
273
|
+
def __str__(self) -> str:
|
|
274
|
+
return f"{self.name}"
|
|
275
|
+
|
|
276
|
+
@staticmethod
|
|
277
|
+
def _build_geometry_dictionary(model_type: EyeModelType, partial_geometry: dict[str, float] | None = None):
|
|
278
|
+
if model_type == "Navarro":
|
|
279
|
+
geometry = {
|
|
280
|
+
"R_corF": -7.72e-3,
|
|
281
|
+
"R_corB": -6.50e-3,
|
|
282
|
+
"R_lensF": -10.20e-3,
|
|
283
|
+
"R_lensB": +6.00e-3,
|
|
284
|
+
"D_cor": 0.55e-3,
|
|
285
|
+
"D_ACD": 3.05e-3,
|
|
286
|
+
"D_lens": 4.00e-3,
|
|
287
|
+
"D_vitr": 16.3203e-3,
|
|
288
|
+
}
|
|
289
|
+
elif model_type == "VughtIOL":
|
|
290
|
+
geometry = {
|
|
291
|
+
"R_corF": -7.72e-3,
|
|
292
|
+
"R_corB": -6.50e-3,
|
|
293
|
+
"R_lensF": -8.16e-3,
|
|
294
|
+
"R_lensB": +11.18e-3,
|
|
295
|
+
"D_cor": 0.55e-3,
|
|
296
|
+
"D_ACD": 3.05e-3,
|
|
297
|
+
"D_lens": 0.6896e-3,
|
|
298
|
+
"D_vitr": 19.3203e-3,
|
|
299
|
+
}
|
|
300
|
+
else:
|
|
301
|
+
raise ValueError(f"Model type {model_type} is undefined.")
|
|
302
|
+
|
|
303
|
+
if partial_geometry is not None:
|
|
304
|
+
# Estimate the cornea back curvature if it is unspecified and the cornea
|
|
305
|
+
# front curvature is specified
|
|
306
|
+
if "R_corF" in partial_geometry and "R_corB" not in partial_geometry:
|
|
307
|
+
partial_geometry["R_corB"] = 0.81 * partial_geometry["R_corF"]
|
|
308
|
+
|
|
309
|
+
# Update the geometry with the parameters specified in partial_geometry
|
|
310
|
+
geometry.update(partial_geometry)
|
|
311
|
+
|
|
312
|
+
return geometry
|
|
313
|
+
|
|
314
|
+
def calculate_ray_transfer_matrix(self) -> sp.Matrix:
|
|
315
|
+
"""Calculate the eye's ray transfer matrix.
|
|
316
|
+
|
|
317
|
+
Returns
|
|
318
|
+
-------
|
|
319
|
+
sp.Matrix
|
|
320
|
+
Ray transfer matrix of the eye.
|
|
321
|
+
"""
|
|
322
|
+
|
|
323
|
+
# Cornea
|
|
324
|
+
cornea = (
|
|
325
|
+
spherical_interface(self.refractive_indices["cor"], 1.0, self.R_corF)
|
|
326
|
+
* uniform_medium(self.D_cor)
|
|
327
|
+
* spherical_interface(
|
|
328
|
+
self.refractive_indices["aq"],
|
|
329
|
+
self.refractive_indices["cor"],
|
|
330
|
+
self.R_corB,
|
|
331
|
+
)
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
# Lens
|
|
335
|
+
lens = (
|
|
336
|
+
spherical_interface(
|
|
337
|
+
self.refractive_indices["lens"],
|
|
338
|
+
self.refractive_indices["aq"],
|
|
339
|
+
self.R_lensF,
|
|
340
|
+
)
|
|
341
|
+
* uniform_medium(self.D_lens)
|
|
342
|
+
* spherical_interface(
|
|
343
|
+
self.refractive_indices["vit"],
|
|
344
|
+
self.refractive_indices["lens"],
|
|
345
|
+
self.R_lensB,
|
|
346
|
+
)
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
# pIOL
|
|
350
|
+
if self.pIOL: # (Diol,tiol,Niol,d_iollens)
|
|
351
|
+
phakic_iol = calculate_piol_matrix(
|
|
352
|
+
self.pIOL[0],
|
|
353
|
+
self.pIOL[1],
|
|
354
|
+
self.pIOL[2],
|
|
355
|
+
self.pIOL[3],
|
|
356
|
+
self.refractive_indices["aq"],
|
|
357
|
+
)
|
|
358
|
+
else: # Identity matrix
|
|
359
|
+
phakic_iol = sp.Matrix([[1, 0], [0, 1]])
|
|
360
|
+
|
|
361
|
+
# Retina to cornea
|
|
362
|
+
return cornea * uniform_medium(self.D_ACD) * phakic_iol * lens * uniform_medium(self.D_vitr)
|
|
363
|
+
|
|
364
|
+
def adjust_lens_back(self, target_refraction: float, *, update_model: bool = False) -> tuple[float, float]:
|
|
365
|
+
"""Fit the lens back curvature to the eye's refraction.
|
|
366
|
+
|
|
367
|
+
A corrective lens (glasses) for an eye with `target_refraction` is place in
|
|
368
|
+
front of the eye. The lens back surface is then solved for a focused image on
|
|
369
|
+
the retina. A vertex distance of 1.4 cm between the glasses and the eye is
|
|
370
|
+
assumed.
|
|
371
|
+
|
|
372
|
+
Parameters
|
|
373
|
+
----------
|
|
374
|
+
target_refraction : float
|
|
375
|
+
Desired spherical of refraction of the eye in diopters.
|
|
376
|
+
update_model : bool
|
|
377
|
+
If `True`, the eye's lens back curvature is set to the calculated value.
|
|
378
|
+
|
|
379
|
+
Returns
|
|
380
|
+
-------
|
|
381
|
+
lens_back_curvature : float
|
|
382
|
+
Radius of curvature of the lens back surface.
|
|
383
|
+
r_glasses : float
|
|
384
|
+
Radius of curvature of the glasses.
|
|
385
|
+
"""
|
|
386
|
+
n_glasses = 1.5
|
|
387
|
+
r_glasses = 2.0 * (n_glasses - 1.0) / (target_refraction + 0.0000000001)
|
|
388
|
+
|
|
389
|
+
m_glasses = spherical_interface(n_glasses, 1, -r_glasses) * spherical_interface(1, n_glasses, r_glasses)
|
|
390
|
+
|
|
391
|
+
# Reversed eye with glasses
|
|
392
|
+
# Assuming average vertex distance of 1.4cm
|
|
393
|
+
matrix_glasses_eye = m_glasses * uniform_medium(0.014) * self.ray_transfer_matrix
|
|
394
|
+
temp_geometry = copy.deepcopy(self.geometry)
|
|
395
|
+
|
|
396
|
+
temp_geometry.pop("R_lensB")
|
|
397
|
+
|
|
398
|
+
solve_output = sp.solveset(
|
|
399
|
+
matrix_glasses_eye.evalf(subs=temp_geometry)[1, 1], self.R_lensB
|
|
400
|
+
) # parallel rays from glasses
|
|
401
|
+
|
|
402
|
+
if len(list(solve_output)) != 1:
|
|
403
|
+
warn(f"Multiple solutions found. {target_refraction=}, {solve_output=}")
|
|
404
|
+
|
|
405
|
+
lens_back_curvature = next(iter(solve_output))
|
|
406
|
+
|
|
407
|
+
if update_model:
|
|
408
|
+
self.geometry["R_lensB"] = lens_back_curvature
|
|
409
|
+
|
|
410
|
+
return lens_back_curvature, r_glasses
|
|
411
|
+
|
|
412
|
+
def calculate_refraction(self, *, display=True) -> tuple[float, float]:
|
|
413
|
+
"""Calculate the refraction of the eye.
|
|
414
|
+
|
|
415
|
+
A corrective lens (glasses) for an eye with `target_refraction` is place in
|
|
416
|
+
front of the eye. Its radius of curvature is then solved for a focused image on
|
|
417
|
+
the retina. A vertex distance of 1.4 cm between the glasses and the eye is
|
|
418
|
+
assumed.
|
|
419
|
+
|
|
420
|
+
Parameters
|
|
421
|
+
----------
|
|
422
|
+
display : bool
|
|
423
|
+
If `True`, the results are printed.
|
|
424
|
+
|
|
425
|
+
Returns
|
|
426
|
+
-------
|
|
427
|
+
glasses_curvature : float
|
|
428
|
+
Radius of curvature of the corrective lens, in meters.
|
|
429
|
+
glasses_power : float
|
|
430
|
+
Power of the corrective lens, in diopters.
|
|
431
|
+
|
|
432
|
+
Warnings
|
|
433
|
+
--------
|
|
434
|
+
When multiple solutions for the curvature are found, a warning is displayed.
|
|
435
|
+
"""
|
|
436
|
+
n_glasses = 1.5
|
|
437
|
+
R = sp.symbols("R") # noqa: N806
|
|
438
|
+
glasses = spherical_interface(n_glasses, 1, -R * 10**-3) * spherical_interface(1, n_glasses, R * 10**-3)
|
|
439
|
+
eye = self.evaluate_matrix()
|
|
440
|
+
|
|
441
|
+
glasses_curvature_solutions = sp.solveset((glasses * uniform_medium(0.014) * eye)[1, 1], R)
|
|
442
|
+
if len(list(glasses_curvature_solutions)) != 1:
|
|
443
|
+
warn(f"Multiple solutions found: {glasses_curvature_solutions}")
|
|
444
|
+
|
|
445
|
+
glasses_curvature = next(iter(glasses_curvature_solutions)) * 1e-3
|
|
446
|
+
glasses_power = 2.0 * (n_glasses - 1.0) / (glasses_curvature)
|
|
447
|
+
if display:
|
|
448
|
+
print(f"{glasses_curvature=:.2f} m, {glasses_power=:.2f} D") # noqa: T201
|
|
449
|
+
|
|
450
|
+
return glasses_curvature, glasses_power
|
|
451
|
+
|
|
452
|
+
def evaluate_matrix(self) -> sp.Matrix:
|
|
453
|
+
"""Evaluate the eye's ray transfer matrix using its geometrical parameters.
|
|
454
|
+
|
|
455
|
+
Returns
|
|
456
|
+
-------
|
|
457
|
+
sympy.Matrix
|
|
458
|
+
Ray transfer matrix for the eye model.
|
|
459
|
+
"""
|
|
460
|
+
return self.ray_transfer_matrix.evalf(subs=self.geometry)
|
|
461
|
+
|
|
462
|
+
def calculate_camera_magnification(
|
|
463
|
+
self, camera: Camera, distance_eye_camera: float = 0.05
|
|
464
|
+
) -> tuple[float, sp.Matrix, float]:
|
|
465
|
+
"""Calculate the total magnification of the eye - camera system.
|
|
466
|
+
|
|
467
|
+
A structure on the central retina is depicted `magnification` times larger
|
|
468
|
+
on the camera sensor.
|
|
469
|
+
|
|
470
|
+
Parameters
|
|
471
|
+
----------
|
|
472
|
+
camera : Camera
|
|
473
|
+
Camera model.
|
|
474
|
+
distance_eye_camera : float
|
|
475
|
+
Distance between eye and camera in meters, measured from the cornea front to
|
|
476
|
+
the camera lens front.
|
|
477
|
+
|
|
478
|
+
Returns
|
|
479
|
+
-------
|
|
480
|
+
magnification : float
|
|
481
|
+
Central magnification of the eye - camera system.
|
|
482
|
+
focused_system_matrix : sympy.Matrix
|
|
483
|
+
Ray transfer matrix for the full system.
|
|
484
|
+
focus_lens_radius : float
|
|
485
|
+
Radius of curvature of the camera's focal length, in meters.
|
|
486
|
+
"""
|
|
487
|
+
eye_matrix = self.evaluate_matrix()
|
|
488
|
+
focused_system_matrix, focus_lens_radius = camera.focused_system_matrix(
|
|
489
|
+
eye_matrix, distance_eye_camera, return_focus_lens_curvature=True
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
magnification = focused_system_matrix[0, 0]
|
|
493
|
+
|
|
494
|
+
return magnification, focused_system_matrix, focus_lens_radius
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
class Camera:
|
|
498
|
+
def __init__(
|
|
499
|
+
self,
|
|
500
|
+
F_cond: NumberOrSymbol = None, # noqa: N803
|
|
501
|
+
a1: NumberOrSymbol = None,
|
|
502
|
+
camera_type: Literal["default"] = "default",
|
|
503
|
+
) -> None:
|
|
504
|
+
self.camera_type = "lensTaylor"
|
|
505
|
+
self.F_cond = sp.Symbol("F_cond", real=True)
|
|
506
|
+
self.d_CCD = sp.symbols("d_CCD")
|
|
507
|
+
self.R_foc = sp.Symbol("R_foc", real=True) # in m
|
|
508
|
+
self.a1 = sp.Symbol("a1", real=True)
|
|
509
|
+
|
|
510
|
+
if camera_type == "default":
|
|
511
|
+
self.d_CCD = self.F_cond
|
|
512
|
+
else:
|
|
513
|
+
raise NotImplementedError("Custom CCD distances are not implemented.")
|
|
514
|
+
|
|
515
|
+
self.n_glas = 1.5
|
|
516
|
+
self.a1_value = a1
|
|
517
|
+
|
|
518
|
+
self.condenser_lens = spherical_interface(self.n_glas, 1.0, -self.F_cond) * spherical_interface(
|
|
519
|
+
1.0, self.n_glas, self.F_cond
|
|
520
|
+
)
|
|
521
|
+
self.focus_lens = spherical_interface(self.n_glas, 1.0, -self.R_foc) * spherical_interface(
|
|
522
|
+
1.0, self.n_glas, self.R_foc
|
|
523
|
+
)
|
|
524
|
+
self.correction_term = sp.Matrix([[1 + self.a1 / self.R_foc, 0], [0, 1.0 / (1 + self.a1 / self.R_foc)]])
|
|
525
|
+
self.ray_transfer_matrix = (
|
|
526
|
+
self.correction_term * uniform_medium(self.d_CCD) * self.focus_lens * self.condenser_lens
|
|
527
|
+
)
|
|
528
|
+
|
|
529
|
+
self.M_camera_alg = self.ray_transfer_matrix
|
|
530
|
+
|
|
531
|
+
if F_cond and (a1 is not None):
|
|
532
|
+
self.ray_transfer_matrix = self.M_camera_alg.evalf(subs={self.F_cond: F_cond, self.a1: a1})
|
|
533
|
+
|
|
534
|
+
def calculate_focus_lens_radius(self, eye_matrix: sp.Matrix, distance_eye_camera: float = 0.05) -> float:
|
|
535
|
+
"""Calculate the radius of curvature of the focus lens.
|
|
536
|
+
|
|
537
|
+
Solves for the radius of curvature of the camera's focus lens, so that the image
|
|
538
|
+
of an object on the retina is in focus.
|
|
539
|
+
|
|
540
|
+
Parameters
|
|
541
|
+
----------
|
|
542
|
+
eye_matrix : sympy.Matrix
|
|
543
|
+
Ray transfer matrix of the eye model.
|
|
544
|
+
distance_eye_camera : float
|
|
545
|
+
Distance between the eye and camera in meters, measured from the cornea
|
|
546
|
+
front to the camera lens front. Defaults to 0.05 m.
|
|
547
|
+
|
|
548
|
+
Returns
|
|
549
|
+
-------
|
|
550
|
+
float
|
|
551
|
+
Radius of curvature of the focus lens, in meters.
|
|
552
|
+
"""
|
|
553
|
+
system_matrix = self.ray_transfer_matrix * uniform_medium(distance_eye_camera) * eye_matrix
|
|
554
|
+
|
|
555
|
+
# Ror contact cameras such as the Panoret fundus camera, the refractive index (of air) needs to be changed to
|
|
556
|
+
# that of the medium used between the eye and camera. This can be done by changing the use of uniform_medium()
|
|
557
|
+
# to medium_change().
|
|
558
|
+
# The camera should correct for the patients refraction, so the image should be
|
|
559
|
+
# focused, i.e. B = 0
|
|
560
|
+
B = system_matrix[0, 1] + 0.0000000001 # noqa: N806
|
|
561
|
+
|
|
562
|
+
if self.a1 in B.free_symbols:
|
|
563
|
+
possible_curvatures = list(sp.solve(B.evalf(subs={self.a1: 0}), self.R_foc))
|
|
564
|
+
else:
|
|
565
|
+
possible_curvatures = list(sp.solve(B, self.R_foc))
|
|
566
|
+
|
|
567
|
+
return np.max(np.abs(possible_curvatures))
|
|
568
|
+
|
|
569
|
+
def focused_system_matrix(
|
|
570
|
+
self,
|
|
571
|
+
eye_matrix: sp.Matrix,
|
|
572
|
+
distance_eye_camera: float = 0.05,
|
|
573
|
+
*,
|
|
574
|
+
return_focus_lens_curvature: bool = False,
|
|
575
|
+
) -> sp.Matrix | tuple[sp.Matrix, float]:
|
|
576
|
+
system_matrix = self.ray_transfer_matrix * uniform_medium(distance_eye_camera) * eye_matrix
|
|
577
|
+
|
|
578
|
+
# For contact cameras such as the Panoret fundus camera, the refractive index (of air) needs to be changed to
|
|
579
|
+
# that of the medium use dbetween the eye and camera. This can be done by changing the use of uniform_medium()
|
|
580
|
+
# to medium_change().
|
|
581
|
+
focus_lens_curvature = self.calculate_focus_lens_radius(eye_matrix, distance_eye_camera)
|
|
582
|
+
if return_focus_lens_curvature:
|
|
583
|
+
return (
|
|
584
|
+
system_matrix.evalf(subs={self.R_foc: focus_lens_curvature}),
|
|
585
|
+
focus_lens_curvature,
|
|
586
|
+
)
|
|
587
|
+
|
|
588
|
+
return system_matrix.evalf(subs={self.R_foc: focus_lens_curvature})
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
# Maximum allowed difference between the calculated and specified refraction of the eye model
|
|
592
|
+
_MAXIMUM_REFRACTION_DEVIATION = 0.05
|
|
593
|
+
|
|
594
|
+
# Maximum allowed value of the B-element in a ray transfer matrix for a focused system
|
|
595
|
+
_MAXIMUM_FOCUS_DEVIATION = 0.0001
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def calculate_magnification(
|
|
599
|
+
eye: Eye,
|
|
600
|
+
camera: Camera,
|
|
601
|
+
distance_eye_camera: float = 0.05,
|
|
602
|
+
focus_lens_radius: float | None = None,
|
|
603
|
+
*,
|
|
604
|
+
suppress_warnings: bool = False,
|
|
605
|
+
) -> tuple[float, float, Eye]:
|
|
606
|
+
"""Calculate the total magnification of the eye - camera system.
|
|
607
|
+
|
|
608
|
+
A structure on the central retina is depicted `magnification` times larger
|
|
609
|
+
on the camera sensor.
|
|
610
|
+
|
|
611
|
+
Parameters
|
|
612
|
+
----------
|
|
613
|
+
eye : Eye
|
|
614
|
+
Eye model.
|
|
615
|
+
camera : Camera
|
|
616
|
+
Camera model.
|
|
617
|
+
distance_eye_camera : float
|
|
618
|
+
Distance between eye and camera in meters, measured from the cornea front to
|
|
619
|
+
the camera lens front.
|
|
620
|
+
focus_lens_radius : float
|
|
621
|
+
Optional radius of curvature of the focus lens. If not specified, the focus lens
|
|
622
|
+
curvature is determined using `Camera.calculate_focus_lens_radius`.
|
|
623
|
+
suppress_warnings : bool
|
|
624
|
+
If `True`, no warning is issued if the calculated glasses power differs
|
|
625
|
+
significantly from `eye.spherical_equivalent`.
|
|
626
|
+
|
|
627
|
+
Returns
|
|
628
|
+
-------
|
|
629
|
+
magnification : float
|
|
630
|
+
Central magnification of the eye - camera system.
|
|
631
|
+
glasses_power : float
|
|
632
|
+
Power of the corrective lens required to correct for the eye's refraction, in
|
|
633
|
+
diopters.
|
|
634
|
+
eye_model : Eye
|
|
635
|
+
Eye model used to calculate the magnification.
|
|
636
|
+
|
|
637
|
+
Warnings
|
|
638
|
+
--------
|
|
639
|
+
When the calculated glasses power differs significantly from
|
|
640
|
+
`eye.spherical_equivalent`, a warning is displayed.
|
|
641
|
+
"""
|
|
642
|
+
glasses_curvature, glasses_power = eye.calculate_refraction(display=False)
|
|
643
|
+
|
|
644
|
+
if not suppress_warnings and abs(glasses_power - eye.spherical_equivalent) > _MAXIMUM_REFRACTION_DEVIATION:
|
|
645
|
+
warn(
|
|
646
|
+
f"model refraction {glasses_power:.2f} not matching clinical refraction"
|
|
647
|
+
f" {eye.spherical_equivalent:.2f} for {eye.name}"
|
|
648
|
+
)
|
|
649
|
+
|
|
650
|
+
# For contact cameras such as the Panoret fundus camera, this refractive index (of air) needs to be changed to that
|
|
651
|
+
# of the medium between the eye and camera. This can be done by changing the use of uniform_medium() to
|
|
652
|
+
# medium_change().
|
|
653
|
+
system_matrix = camera.ray_transfer_matrix * uniform_medium(distance_eye_camera) * eye.evaluate_matrix()
|
|
654
|
+
|
|
655
|
+
if focus_lens_radius is None:
|
|
656
|
+
# system is in focus so B=0
|
|
657
|
+
solutions = list(sp.solve(system_matrix[0, 1] + 0.00001, camera.R_foc))
|
|
658
|
+
focus_lens_radius = solutions[np.argmax(np.abs(np.array(solutions) + camera.a1_value))]
|
|
659
|
+
|
|
660
|
+
focused_system_matrix = system_matrix.evalf(subs=({camera.R_foc: focus_lens_radius}))
|
|
661
|
+
|
|
662
|
+
magnification: float = focused_system_matrix[0, 0]
|
|
663
|
+
|
|
664
|
+
if abs(focused_system_matrix[0, 1]) > _MAXIMUM_FOCUS_DEVIATION:
|
|
665
|
+
warn(f"focused_system_matrix not in focus for patient {eye.name}")
|
|
666
|
+
|
|
667
|
+
return magnification, glasses_power, eye
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: PAROS
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: PARaxial Optical fundus Scaling (PAROS)
|
|
5
|
+
Author-email: "Lennart J. Pors" <l.j.pors@lumc.nl>, "Jan-Willem M. Beenakker" <j.w.m.beenakker@lumc.nl>, Corné Haasjes <c.haasjes@lumc.nl>
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2024 Lennart Pors
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
License-File: LICENSE
|
|
28
|
+
Keywords: ophthalmology,paraxial optics
|
|
29
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
30
|
+
Classifier: Programming Language :: Python :: 3
|
|
31
|
+
Classifier: Topic :: Scientific/Engineering :: Physics
|
|
32
|
+
Classifier: Typing :: Typed
|
|
33
|
+
Requires-Python: >=3.9
|
|
34
|
+
Requires-Dist: matplotlib
|
|
35
|
+
Requires-Dist: numpy
|
|
36
|
+
Requires-Dist: pandas
|
|
37
|
+
Requires-Dist: scipy
|
|
38
|
+
Requires-Dist: sympy
|
|
39
|
+
Description-Content-Type: text/markdown
|
|
40
|
+
|
|
41
|
+
# PARaxial Optical fundus Scaling (PAROS)
|
|
42
|
+
|
|
43
|
+
Paros is a method to calculate the magnification of fundus images based on the optical characteristics of the patient's eye. The full method and validation are described in (article reference to be added upon acceptance)
|
|
44
|
+
|
|
45
|
+
## License
|
|
46
|
+
|
|
47
|
+
The code is provided as is, without any warranty, under the [MIT license](LICENSE). This license requires that reusers give credit to the creator. It allows reusers to distribute, remix, adapt, and build upon the material in any medium or format.
|
|
48
|
+
|
|
49
|
+
## Basic usage
|
|
50
|
+
|
|
51
|
+
A basic version of PAROS can most easily be run online here: [Basic version](https://mreye-lumc.github.io/PAROS)
|
|
52
|
+
|
|
53
|
+
The full package is also available for download via PiP:
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
pip install PAROS
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
PAROS consists of two main functions:
|
|
60
|
+
|
|
61
|
+
- ‘fundus_camera_calibration.ipynb’: a method to calibrate camera using eye phantom measurements.
|
|
62
|
+
- ‘calculate_ocular_magnification.ipynb’: a method to calculate magnification fundus images of a subject.
|
|
63
|
+
|
|
64
|
+
## Implementation
|
|
65
|
+
|
|
66
|
+
The implementation of PAROS in this repository is functional for the camera in our center, and with the specific software used at our center. Both have impact on the calculated magnification. We therefore recommend calibration of the camera and software using the method described in the article mentioned below before implementation for quantitative purposes.
|
|
67
|
+
|
|
68
|
+
## Camera constants
|
|
69
|
+
|
|
70
|
+
Please find here a table of known camera calibration constants; this can be added upon by other contributors.
|
|
71
|
+
| Camera type | CCD type | Condenser lens power (D) | First order calibration term |
|
|
72
|
+
| --------------- | -------- | ------------------------ | ---------------------------- |
|
|
73
|
+
| Topcon TRC-50DX | | 38.0 | -0.011 |
|
|
74
|
+
| Topcon TRC-50IX | | 38.0 | -0.012 |
|
|
75
|
+
|
|
76
|
+
## Referencing
|
|
77
|
+
|
|
78
|
+
When publishing results obtained with this package, please cite the paper that describes the full method and validation: Pors, L.J., et al. (2023). "Correction method for optical scaling of fundoscopy images: development, validation and first implementation" in press.
|
|
79
|
+
|
|
80
|
+
## Contributing
|
|
81
|
+
|
|
82
|
+
Please read our [contribution guidelines](CONTRIBUTING.md) prior to opening a Pull Request.
|
|
83
|
+
|
|
84
|
+
## Contact
|
|
85
|
+
|
|
86
|
+
Feel free to contact us for any inquiries:
|
|
87
|
+
|
|
88
|
+
- L.J. Pors ([email](mailto:l.j.pors@lumc.nl))
|
|
89
|
+
- J.W.M. Beenakker ([email](mailto:j.w.m.beenakker@lumc.nl))
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
PAROS/__init__.py,sha256=tDPXVwi1l7LKfd6ZxyuDcLCzs_mn64fEye2gIv1jQO4,386
|
|
2
|
+
PAROS/eyephantom.py,sha256=YpsKcKeKWi6bvRRQdTQK7cE-VqiMXo6nKL0_AqSSFy8,6836
|
|
3
|
+
PAROS/fundscale.py,sha256=ovYS3bzCcPuYyVyJJi6VGIOxKCr3uelOo6rOmng1zjM,23340
|
|
4
|
+
PAROS/data/__init__.py,sha256=k2WEcTh10TvSqee1H5xmgpW8RnP91gKCtfXhuCVT0_s,1982
|
|
5
|
+
PAROS/data/calibration/__init__.py,sha256=nHbgxHMZ76tUASGtaq18JeTs2TF9ATZHP118ppJAQ4A,37
|
|
6
|
+
PAROS/data/calibration/Topcon/TRC_50DX.csv,sha256=mfCCaJqjb_CNrlWIl3l6s1Vb6j-QtJenOcRpYQPSc2c,1016
|
|
7
|
+
PAROS/data/calibration/Topcon/TRC_50IX.csv,sha256=-GjlzBEufJVoQ2TekElZJKOwnOAfPAxm5MGB8J1Mu4o,1041
|
|
8
|
+
PAROS/data/lenses/__init__.py,sha256=D9Afgofn4S5yRS_2IsMVsvgXDP4snT9ttpkEdITxJdw,23
|
|
9
|
+
PAROS/data/lenses/phantom.csv,sha256=XpOnnU0gQiWO6ur_CMA3GaisAt7Y2hdRGt9vOLT1zjY,1000
|
|
10
|
+
paros-0.1.0.dist-info/METADATA,sha256=2PbZhXIgUxQbNluL_YRWnhjijUV50X4i1VGvm1Q4Dvc,4327
|
|
11
|
+
paros-0.1.0.dist-info/WHEEL,sha256=mRYSEL3Ih6g5a_CVMIcwiF__0Ae4_gLYh01YFNwiq1k,87
|
|
12
|
+
paros-0.1.0.dist-info/licenses/LICENSE,sha256=bdHn5SWJdOrfnRkSGDabdWMbgvwLsv2I60Ygzta2ySA,1069
|
|
13
|
+
paros-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Lennart Pors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|