PyGlaucoMetrics 0.2.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.
Potentially problematic release.
This version of PyGlaucoMetrics might be problematic. Click here for more details.
- PyVisualFields/vf_core.py +228 -0
- PyVisualFields/vfprogression.py +585 -0
- PyVisualFields/visualFields.py +1044 -0
- pyglaucometrics-0.2.0.dist-info/METADATA +64 -0
- pyglaucometrics-0.2.0.dist-info/RECORD +7 -0
- pyglaucometrics-0.2.0.dist-info/WHEEL +5 -0
- pyglaucometrics-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""
|
|
2
|
+
vf_core.py
|
|
3
|
+
Pure-Python replacement for the R `visualFields` package.
|
|
4
|
+
Covers: normative lookup, TD/PD, MD/PSD/VFI, progression regression.
|
|
5
|
+
|
|
6
|
+
Dependencies: numpy, scipy, pandas — no R or rpy2 required.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
import pandas as pd
|
|
11
|
+
from scipy import stats
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
# ---------------------------------------------------------------------------
|
|
16
|
+
# 1. Normative values
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
# Auto-load real Sunyiu 24-2 normatives computed from vfctrSunyiu24d2 dataset.
|
|
19
|
+
# Falls back to Heijl 1987 approximation if CSV not found.
|
|
20
|
+
|
|
21
|
+
def _load_default_normdb() -> np.ndarray:
|
|
22
|
+
"""Load normvals_sunyiu24d2.csv if available, else use Heijl 1987 approximation."""
|
|
23
|
+
csv_path = Path(__file__).parent / 'data' / 'normvals_sunyiu24d2.csv'
|
|
24
|
+
if csv_path.exists():
|
|
25
|
+
df = pd.read_csv(csv_path)
|
|
26
|
+
return df[['intercept', 'slope']].values.astype(np.float32)
|
|
27
|
+
# Heijl 1987 approximation fallback (54 points)
|
|
28
|
+
return np.array([
|
|
29
|
+
[33.5, -0.082], [34.2, -0.082], [33.8, -0.083], [33.1, -0.082],
|
|
30
|
+
[32.9, -0.081], [33.0, -0.081], [33.2, -0.082], [32.8, -0.081],
|
|
31
|
+
[31.5, -0.080], [32.0, -0.081], [32.5, -0.080], [32.1, -0.079],
|
|
32
|
+
[30.8, -0.079], [31.3, -0.079], [31.6, -0.079], [31.0, -0.078],
|
|
33
|
+
[30.1, -0.078], [30.5, -0.078], [30.9, -0.078], [30.3, -0.077],
|
|
34
|
+
[29.5, -0.077], [29.8, -0.077], [30.2, -0.077], [29.6, -0.076],
|
|
35
|
+
[28.9, -0.076], [29.1, -0.076], [29.5, -0.076], [28.8, -0.075],
|
|
36
|
+
[28.0, -0.075], [28.4, -0.075], [28.7, -0.075], [28.1, -0.074],
|
|
37
|
+
[27.2, -0.074], [27.6, -0.074], [27.9, -0.074], [27.3, -0.073],
|
|
38
|
+
[26.5, -0.073], [26.8, -0.073], [27.1, -0.073], [26.5, -0.072],
|
|
39
|
+
[25.8, -0.072], [26.0, -0.072], [26.3, -0.072], [25.7, -0.071],
|
|
40
|
+
[25.0, -0.071], [25.2, -0.071], [25.5, -0.071], [24.9, -0.070],
|
|
41
|
+
[24.2, -0.070], [24.4, -0.070], [24.7, -0.070], [24.1, -0.069],
|
|
42
|
+
[23.5, -0.069], [23.7, -0.069],
|
|
43
|
+
], dtype=np.float32)
|
|
44
|
+
|
|
45
|
+
_NORM_24_2 = _load_default_normdb()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def load_normative_db(csv_path: Optional[str] = None) -> np.ndarray:
|
|
49
|
+
"""
|
|
50
|
+
Load normative table (54 × 2: intercept, slope).
|
|
51
|
+
Pass a CSV path with columns ['intercept', 'slope'] to override defaults.
|
|
52
|
+
"""
|
|
53
|
+
if csv_path and Path(csv_path).exists():
|
|
54
|
+
df = pd.read_csv(csv_path)
|
|
55
|
+
return df[['intercept', 'slope']].values.astype(np.float32)
|
|
56
|
+
return _NORM_24_2
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def age_matched_norms(age: float, norm_db: Optional[np.ndarray] = None) -> np.ndarray:
|
|
60
|
+
"""Expected threshold per test point for a given age (years). Returns shape (54,)."""
|
|
61
|
+
db = norm_db if norm_db is not None else _NORM_24_2
|
|
62
|
+
return db[:, 0] + db[:, 1] * age # intercept + slope × age
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ---------------------------------------------------------------------------
|
|
66
|
+
# 2. Pointwise deviations
|
|
67
|
+
# ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
def total_deviation(sensitivity: np.ndarray, age: float,
|
|
70
|
+
norm_db: Optional[np.ndarray] = None) -> np.ndarray:
|
|
71
|
+
if age is None or np.isnan(age): # ← ADD
|
|
72
|
+
age = 60.0 # ← fallback to population mean
|
|
73
|
+
norms = age_matched_norms(age, norm_db)
|
|
74
|
+
return sensitivity - norms
|
|
75
|
+
|
|
76
|
+
def pattern_deviation(td: np.ndarray, percentile: float = 85.0) -> np.ndarray:
|
|
77
|
+
valid = td[~np.isnan(td)]
|
|
78
|
+
if len(valid) == 0: # ← ADD THIS GUARD
|
|
79
|
+
return np.full_like(td, np.nan)
|
|
80
|
+
general_height = np.percentile(valid, percentile)
|
|
81
|
+
return td - general_height
|
|
82
|
+
|
|
83
|
+
def compute_indices(sensitivity: np.ndarray, age: float,
|
|
84
|
+
weights=None, norm_db=None,
|
|
85
|
+
norm_percentile: float = 85.0) -> dict:
|
|
86
|
+
td = total_deviation(sensitivity, age, norm_db)
|
|
87
|
+
if np.sum(~np.isnan(td)) == 0: # all-NaN row — return safe nulls
|
|
88
|
+
nan54 = np.full(len(td), np.nan)
|
|
89
|
+
return {"td": nan54, "pd": nan54,
|
|
90
|
+
"MD": np.nan, "PSD": np.nan, "VFI": np.nan}
|
|
91
|
+
pd_vals = pattern_deviation(td, norm_percentile)
|
|
92
|
+
return {
|
|
93
|
+
"td": td,
|
|
94
|
+
"pd": pd_vals,
|
|
95
|
+
"MD": mean_deviation(td, weights),
|
|
96
|
+
"PSD": pattern_std(pd_vals, weights),
|
|
97
|
+
"VFI": vfi(pd_vals, weights),
|
|
98
|
+
}
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
# 3. Global indices
|
|
101
|
+
# ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
# Eccentricity-based weights for 24-2 (simplified Heijl 1987 Gaussian weights).
|
|
104
|
+
# In production, load the exact per-point weights from the normative package.
|
|
105
|
+
_WEIGHTS_24_2 = np.ones(54, dtype=np.float32) # uniform fallback
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def mean_deviation(td: np.ndarray, weights: Optional[np.ndarray] = None) -> float:
|
|
109
|
+
w = weights if weights is not None else _WEIGHTS_24_2
|
|
110
|
+
mask = ~np.isnan(td)
|
|
111
|
+
if mask.sum() == 0: # ← ADD
|
|
112
|
+
return np.nan # ← nothing to average
|
|
113
|
+
return float(np.average(td[mask], weights=w[mask]))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def pattern_std(pd_vals: np.ndarray, weights: Optional[np.ndarray] = None) -> float:
|
|
117
|
+
"""
|
|
118
|
+
Pattern Standard Deviation (PSD) in dB.
|
|
119
|
+
Weighted root-mean-square of pattern deviation values.
|
|
120
|
+
"""
|
|
121
|
+
w = weights if weights is not None else _WEIGHTS_24_2
|
|
122
|
+
mask = ~np.isnan(pd_vals)
|
|
123
|
+
pd_m = pd_vals[mask]
|
|
124
|
+
w_m = w[mask]
|
|
125
|
+
w_m = w_m / w_m.sum()
|
|
126
|
+
weighted_mean = np.dot(w_m, pd_m)
|
|
127
|
+
weighted_var = np.dot(w_m, (pd_m - weighted_mean) ** 2)
|
|
128
|
+
return float(np.sqrt(weighted_var))
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def vfi(pd_vals: np.ndarray, weights: Optional[np.ndarray] = None) -> float:
|
|
132
|
+
"""
|
|
133
|
+
Visual Field Index (VFI) — approximate (Bengtsson & Heijl 2008).
|
|
134
|
+
Returns value in [0, 100] where 100 = normal.
|
|
135
|
+
"""
|
|
136
|
+
w = weights if weights is not None else _WEIGHTS_24_2
|
|
137
|
+
mask = ~np.isnan(pd_vals)
|
|
138
|
+
pd_m = pd_vals[mask]
|
|
139
|
+
w_m = w[mask] / w[mask].sum()
|
|
140
|
+
# clamp PD contribution to [−30, 0] then scale to percentage
|
|
141
|
+
contrib = np.clip(pd_m, -30.0, 0.0) / 30.0 # 0 = normal, −1 = fully depressed
|
|
142
|
+
return float(100.0 * (1.0 + np.dot(w_m, contrib)))
|
|
143
|
+
|
|
144
|
+
# ---------------------------------------------------------------------------
|
|
145
|
+
# 4. Progression analysis
|
|
146
|
+
# ---------------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
def vf_progression(dates: list, md_series: list) -> dict:
|
|
149
|
+
"""
|
|
150
|
+
Linear regression of MD over time (OLS — mirrors `vfprogression` PLR).
|
|
151
|
+
|
|
152
|
+
Parameters
|
|
153
|
+
----------
|
|
154
|
+
dates : list of date-like objects or float years (e.g. 2020.5)
|
|
155
|
+
md_series : list/array of MD values (dB)
|
|
156
|
+
|
|
157
|
+
Returns
|
|
158
|
+
-------
|
|
159
|
+
dict: slope (dB/year), intercept, r2, p_value, se, progression_flag
|
|
160
|
+
"""
|
|
161
|
+
if isinstance(dates[0], (int, float)):
|
|
162
|
+
t = np.array(dates, dtype=float)
|
|
163
|
+
else:
|
|
164
|
+
t0 = pd.to_datetime(dates[0])
|
|
165
|
+
t = np.array([(pd.to_datetime(d) - t0).days / 365.25 for d in dates])
|
|
166
|
+
|
|
167
|
+
md = np.array(md_series, dtype=float)
|
|
168
|
+
mask = ~np.isnan(md)
|
|
169
|
+
if mask.sum() < 3:
|
|
170
|
+
return {"slope": np.nan, "intercept": np.nan,
|
|
171
|
+
"r2": np.nan, "p_value": np.nan,
|
|
172
|
+
"se": np.nan, "progression_flag": False}
|
|
173
|
+
|
|
174
|
+
slope, intercept, r, p, se = stats.linregress(t[mask], md[mask])
|
|
175
|
+
# Flag as progressing: slope < −1.0 dB/year AND p < 0.05 (common clinical threshold)
|
|
176
|
+
flag = bool(slope < -1.0 and p < 0.05)
|
|
177
|
+
return {
|
|
178
|
+
"slope": round(slope, 4),
|
|
179
|
+
"intercept": round(intercept, 4),
|
|
180
|
+
"r2": round(r ** 2, 4),
|
|
181
|
+
"p_value": round(p, 4),
|
|
182
|
+
"se": round(se, 4),
|
|
183
|
+
"progression_flag": flag,
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# ---------------------------------------------------------------------------
|
|
188
|
+
# 5. Probability maps (p < 0.05 / 0.02 / 0.01 / 0.005 flags)
|
|
189
|
+
# ---------------------------------------------------------------------------
|
|
190
|
+
# Normal distribution approximation; replace with empirical quantiles if available.
|
|
191
|
+
|
|
192
|
+
def _load_empirical_cutoffs():
|
|
193
|
+
"""Load per-location empirical probability cutoffs if available."""
|
|
194
|
+
csv_path = Path(__file__).parent / 'data' / 'normvals_cutoffs.csv'
|
|
195
|
+
if csv_path.exists():
|
|
196
|
+
return pd.read_csv(csv_path).values # shape (54, 4): p0.005, p0.01, p0.02, p0.05
|
|
197
|
+
return None
|
|
198
|
+
|
|
199
|
+
_EMPIRICAL_CUTOFFS = _load_empirical_cutoffs() # (54,4) or None
|
|
200
|
+
|
|
201
|
+
_TD_PROB_CUTOFFS = {0.05: -2.0, 0.02: -2.5, 0.01: -3.0, 0.005: -3.5}
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def probability_map(deviation: np.ndarray,
|
|
205
|
+
cutoffs: Optional[dict] = None) -> np.ndarray:
|
|
206
|
+
"""
|
|
207
|
+
Assign probability level per test point.
|
|
208
|
+
Uses per-location empirical cutoffs if available (matches R exactly),
|
|
209
|
+
otherwise falls back to normal approximation.
|
|
210
|
+
Returns float array: 0.05, 0.02, 0.01, 0.005, or 1.0 (normal).
|
|
211
|
+
"""
|
|
212
|
+
dev = np.array(deviation, dtype=float)
|
|
213
|
+
levels = np.ones(len(dev))
|
|
214
|
+
|
|
215
|
+
if _EMPIRICAL_CUTOFFS is not None and cutoffs is None:
|
|
216
|
+
n = min(len(dev), _EMPIRICAL_CUTOFFS.shape[0])
|
|
217
|
+
for j, p in zip([3, 2, 1, 0], [0.05, 0.02, 0.01, 0.005]):
|
|
218
|
+
col = _EMPIRICAL_CUTOFFS[:n, j]
|
|
219
|
+
for i in range(n):
|
|
220
|
+
if not np.isnan(dev[i]) and dev[i] <= col[i]:
|
|
221
|
+
levels[i] = p
|
|
222
|
+
else:
|
|
223
|
+
# Fallback: uniform dB cutoffs
|
|
224
|
+
cuts = cutoffs or _TD_PROB_CUTOFFS
|
|
225
|
+
for p_level in sorted(cuts.keys(), reverse=True):
|
|
226
|
+
levels[deviation <= cuts[p_level]] = p_level
|
|
227
|
+
|
|
228
|
+
return levels
|