PyVisualFields 2.0.2__py3-none-any.whl → 2.0.3__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.
- PyVisualFields/PyGlaucoMetrics.py +563 -563
- PyVisualFields/__init__.py +1 -1
- PyVisualFields/visualFields.py +104 -39
- {pyvisualfields-2.0.2.dist-info → pyvisualfields-2.0.3.dist-info}/METADATA +178 -180
- {pyvisualfields-2.0.2.dist-info → pyvisualfields-2.0.3.dist-info}/RECORD +7 -8
- pyvisualfields-2.0.2.dist-info/licenses/license +0 -19
- {pyvisualfields-2.0.2.dist-info → pyvisualfields-2.0.3.dist-info}/WHEEL +0 -0
- {pyvisualfields-2.0.2.dist-info → pyvisualfields-2.0.3.dist-info}/top_level.txt +0 -0
|
@@ -1,563 +1,563 @@
|
|
|
1
|
-
|
|
2
|
-
import numpy as np
|
|
3
|
-
import pandas as pd
|
|
4
|
-
|
|
5
|
-
import numpy as np
|
|
6
|
-
import pandas as pd
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
def _normalise_eye(val):
|
|
11
|
-
"""Return 'OD' or 'OS' regardless of input format."""
|
|
12
|
-
v = str(val).strip().lower()
|
|
13
|
-
if v in ('od', 'right', 'r', '1', 'righteye', 're'):
|
|
14
|
-
return 'OD'
|
|
15
|
-
if v in ('os', 'left', 'l', '0', 'lefteye', 'le'):
|
|
16
|
-
return 'OS'
|
|
17
|
-
return None # unknown — caller returns Non-GL
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
def _pt_cols_from_row(row):
|
|
21
|
-
"""Return sorted point column names (s* or l*) from a row's index."""
|
|
22
|
-
cols = sorted(
|
|
23
|
-
[c for c in row.index if len(c) >= 2 and c[0] in ('s', 'l') and c[1:].isdigit()],
|
|
24
|
-
key=lambda x: int(x[1:])
|
|
25
|
-
)
|
|
26
|
-
return cols
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
def _pt_cols_from_df(df):
|
|
30
|
-
"""Return sorted point column names (s* or l*) from a dataframe."""
|
|
31
|
-
cols = sorted(
|
|
32
|
-
[c for c in df.columns if len(c) >= 2 and c[0] in ('s', 'l') and c[1:].isdigit()],
|
|
33
|
-
key=lambda x: int(x[1:])
|
|
34
|
-
)
|
|
35
|
-
return cols
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
def _eye_col(row):
|
|
39
|
-
"""Return the eye value from whichever column name exists (Eye/eye/EYE)."""
|
|
40
|
-
for name in ('Eye', 'eye', 'EYE'):
|
|
41
|
-
if name in row.index:
|
|
42
|
-
return row[name]
|
|
43
|
-
return None
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
def _col(prefix, n, pt_cols):
|
|
47
|
-
"""
|
|
48
|
-
Return the column name for point n (1-indexed) using whatever prefix
|
|
49
|
-
is present in pt_cols (s or l).
|
|
50
|
-
"""
|
|
51
|
-
actual_prefix = pt_cols[0][0] if pt_cols else 's'
|
|
52
|
-
return f'{actual_prefix}{n}'
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
def convertVF_to_2D(VF):
|
|
56
|
-
VF_2D = np.full((8,9), np.nan)
|
|
57
|
-
|
|
58
|
-
VF_2D[0,3:7] = VF[0:4]
|
|
59
|
-
VF_2D[1,2:8] = VF[4:10]
|
|
60
|
-
VF_2D[2,1:9] = VF[10:18]
|
|
61
|
-
VF_2D[3,0:9] = VF[18:27]
|
|
62
|
-
VF_2D[4,0:9] = VF[27:36]
|
|
63
|
-
VF_2D[5,1:9] = VF[36:44]
|
|
64
|
-
VF_2D[6,2:8] = VF[44:50]
|
|
65
|
-
VF_2D[7,3:7] = VF[50:54]
|
|
66
|
-
|
|
67
|
-
return VF_2D
|
|
68
|
-
|
|
69
|
-
##########################################################
|
|
70
|
-
###################################################
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
# def Fn_HAP2(df_PDP):
|
|
74
|
-
# pt_cols = _pt_cols_from_df(df_PDP)
|
|
75
|
-
# data = df_PDP[pt_cols]
|
|
76
|
-
|
|
77
|
-
# counts_05 = (data <= 0.05).sum(axis=1)
|
|
78
|
-
# counts_01 = (data <= 0.01).sum(axis=1)
|
|
79
|
-
|
|
80
|
-
# df_PDP['HAP2_p1_clf'] = np.where(
|
|
81
|
-
# (counts_05 >= 3) & (counts_01 >= 1), 'GL', 'Non-GL')
|
|
82
|
-
|
|
83
|
-
# df_GL = df_PDP[df_PDP['HAP2_p1_clf'] == 'GL'].copy()
|
|
84
|
-
|
|
85
|
-
# # find MD column case-insensitively
|
|
86
|
-
# md_col = next((c for c in df_PDP.columns if c.lower() in ('md', 'tmd')), None)
|
|
87
|
-
# if md_col:
|
|
88
|
-
# md = df_GL[md_col]
|
|
89
|
-
# c05 = counts_05[df_GL.index]
|
|
90
|
-
# c01 = counts_01[df_GL.index]
|
|
91
|
-
# cond1 = (md > -6.0) & c05.between(1, 12) & c01.between(1, 4)
|
|
92
|
-
# cond2 = (md.between(-12.0, -6.01)) | (c05.between(13, 26) & c01.between(5, 13))
|
|
93
|
-
# cond3 = (md < -12.0) | (c05 >= 27)
|
|
94
|
-
# df_GL['HAP2_p2_clf'] = np.select(
|
|
95
|
-
# [cond1, cond2, cond3],
|
|
96
|
-
# ['Stage 1', 'Stage 2', 'Stage 3'],
|
|
97
|
-
# default='GL')
|
|
98
|
-
# else:
|
|
99
|
-
# df_GL['HAP2_p2_clf'] = 'GL'
|
|
100
|
-
|
|
101
|
-
# df_PDP.loc[df_GL.index, 'HAP2_p2_clf'] = df_GL['HAP2_p2_clf']
|
|
102
|
-
# df_PDP['HAP2_p2_clf'] = df_PDP['HAP2_p2_clf'].fillna('Non-GL')
|
|
103
|
-
# return df_PDP
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
def has_pd_cluster_3_with_1_at_1pct(pd_values):
|
|
109
|
-
"""
|
|
110
|
-
Criterion:
|
|
111
|
-
cluster of 3 points at P < 5%,
|
|
112
|
-
with at least 1 of those points at P < 1%.
|
|
113
|
-
"""
|
|
114
|
-
pd_grid = convertVF_to_2D(pd_values)
|
|
115
|
-
|
|
116
|
-
mask_5 = pd_grid < 0.05
|
|
117
|
-
mask_1 = pd_grid < 0.01
|
|
118
|
-
|
|
119
|
-
mask_5 = np.nan_to_num(mask_5, nan=False).astype(bool)
|
|
120
|
-
mask_1 = np.nan_to_num(mask_1, nan=False).astype(bool)
|
|
121
|
-
|
|
122
|
-
visited = np.zeros(mask_5.shape, dtype=bool)
|
|
123
|
-
|
|
124
|
-
neighbors = [(-1,-1), (-1,0), (-1,1),
|
|
125
|
-
(0,-1), (0,1),
|
|
126
|
-
(1,-1), (1,0), (1,1)]
|
|
127
|
-
|
|
128
|
-
for r in range(mask_5.shape[0]):
|
|
129
|
-
for c in range(mask_5.shape[1]):
|
|
130
|
-
if not mask_5[r, c] or visited[r, c]:
|
|
131
|
-
continue
|
|
132
|
-
|
|
133
|
-
stack = [(r, c)]
|
|
134
|
-
visited[r, c] = True
|
|
135
|
-
component = []
|
|
136
|
-
|
|
137
|
-
while stack:
|
|
138
|
-
rr, cc = stack.pop()
|
|
139
|
-
component.append((rr, cc))
|
|
140
|
-
|
|
141
|
-
for dr, dc in neighbors:
|
|
142
|
-
nr, nc = rr + dr, cc + dc
|
|
143
|
-
if (
|
|
144
|
-
0 <= nr < mask_5.shape[0]
|
|
145
|
-
and 0 <= nc < mask_5.shape[1]
|
|
146
|
-
and mask_5[nr, nc]
|
|
147
|
-
and not visited[nr, nc]
|
|
148
|
-
):
|
|
149
|
-
visited[nr, nc] = True
|
|
150
|
-
stack.append((nr, nc))
|
|
151
|
-
|
|
152
|
-
if len(component) >= 3:
|
|
153
|
-
has_1pct_point = any(mask_1[rr, cc] for rr, cc in component)
|
|
154
|
-
if has_1pct_point:
|
|
155
|
-
return True
|
|
156
|
-
|
|
157
|
-
return False
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
def _is_ght_outside_normal_limits(x):
|
|
161
|
-
if pd.isna(x):
|
|
162
|
-
return False
|
|
163
|
-
|
|
164
|
-
x = str(x).strip().lower()
|
|
165
|
-
|
|
166
|
-
return (
|
|
167
|
-
"outside normal limits" in x
|
|
168
|
-
or "outside" in x
|
|
169
|
-
or x in {"onl", "abnormal"}
|
|
170
|
-
)
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
def _is_psd_p_less_5(row):
|
|
174
|
-
"""
|
|
175
|
-
Handles possible column names:
|
|
176
|
-
PSD_P, PSD_p, PSDp, PSD_probability, PSD
|
|
177
|
-
If only PSD is present as a p-value, it checks PSD < 0.05.
|
|
178
|
-
"""
|
|
179
|
-
possible_cols = [
|
|
180
|
-
"PSD_P", "PSD_p", "PSDp", "psd_p",
|
|
181
|
-
"PSD_probability", "psd_probability",
|
|
182
|
-
"PSD", "psd"
|
|
183
|
-
]
|
|
184
|
-
|
|
185
|
-
for col in possible_cols:
|
|
186
|
-
if col in row.index:
|
|
187
|
-
val = pd.to_numeric(row[col], errors="coerce")
|
|
188
|
-
if pd.notna(val) and val < 0.05:
|
|
189
|
-
return True
|
|
190
|
-
|
|
191
|
-
return False
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
def HAP2_clf(row):
|
|
195
|
-
"""
|
|
196
|
-
Criteria:
|
|
197
|
-
1. GHT outside normal limits
|
|
198
|
-
OR
|
|
199
|
-
2. cluster of 3 PD probability points at P < 5%,
|
|
200
|
-
with at least 1 at P < 1%
|
|
201
|
-
OR
|
|
202
|
-
3. PSD at P < 5%
|
|
203
|
-
"""
|
|
204
|
-
pt_cols = _pt_cols_from_df(pd.DataFrame([row]))
|
|
205
|
-
pd_values = pd.to_numeric(row[pt_cols], errors="coerce").values
|
|
206
|
-
|
|
207
|
-
# Criterion 1: GHT outside normal limits
|
|
208
|
-
for ght_col in ["GHT", "ght"]:
|
|
209
|
-
if ght_col in row.index and _is_ght_outside_normal_limits(row[ght_col]):
|
|
210
|
-
return "GL"
|
|
211
|
-
|
|
212
|
-
# Criterion 2: PD cluster
|
|
213
|
-
if has_pd_cluster_3_with_1_at_1pct(pd_values):
|
|
214
|
-
return "GL"
|
|
215
|
-
|
|
216
|
-
# Criterion 3: PSD P < 5%
|
|
217
|
-
if _is_psd_p_less_5(row):
|
|
218
|
-
return "GL"
|
|
219
|
-
|
|
220
|
-
return "Non-GL"
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
def Fn_HAP2(df_PDP):
|
|
224
|
-
out = df_PDP.copy()
|
|
225
|
-
out["HAP2_clf"] = out.apply(HAP2_clf, axis=1)
|
|
226
|
-
return out
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
########################################################
|
|
231
|
-
########################################################
|
|
232
|
-
|
|
233
|
-
# def check_gl_condition(row, threshold=0.01, consecutive_reductions=2):
|
|
234
|
-
# numeric_values = pd.to_numeric(row.values, errors='coerce')
|
|
235
|
-
# below = (numeric_values <= threshold).astype(float)
|
|
236
|
-
# below = np.nan_to_num(below, nan=0.0)
|
|
237
|
-
# counts = np.convolve(below, np.ones(consecutive_reductions), mode='valid')
|
|
238
|
-
# return np.any(counts >= consecutive_reductions)
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
# def Fn_UKGTS(df_TDP, threshold=0.01, consecutive_reductions=2):
|
|
242
|
-
# pt_cols = _pt_cols_from_df(df_TDP)
|
|
243
|
-
# df_TDP['UKGTS_clf'] = np.where(
|
|
244
|
-
# df_TDP[pt_cols].apply(
|
|
245
|
-
# check_gl_condition, axis=1,
|
|
246
|
-
# threshold=threshold,
|
|
247
|
-
# consecutive_reductions=consecutive_reductions),
|
|
248
|
-
# 'GL', 'Non-GL')
|
|
249
|
-
# return df_TDP
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
def has_cluster(mask, min_size=2, connectivity=8):
|
|
254
|
-
mask = np.nan_to_num(mask, nan=False).astype(bool)
|
|
255
|
-
visited = np.zeros(mask.shape, dtype=bool)
|
|
256
|
-
|
|
257
|
-
if connectivity == 8:
|
|
258
|
-
neighbors = [(-1,-1), (-1,0), (-1,1),
|
|
259
|
-
(0,-1), (0,1),
|
|
260
|
-
(1,-1), (1,0), (1,1)]
|
|
261
|
-
else:
|
|
262
|
-
neighbors = [(-1,0), (1,0), (0,-1), (0,1)]
|
|
263
|
-
|
|
264
|
-
for r in range(mask.shape[0]):
|
|
265
|
-
for c in range(mask.shape[1]):
|
|
266
|
-
if not mask[r, c] or visited[r, c]:
|
|
267
|
-
continue
|
|
268
|
-
|
|
269
|
-
stack = [(r, c)]
|
|
270
|
-
visited[r, c] = True
|
|
271
|
-
size = 0
|
|
272
|
-
|
|
273
|
-
while stack:
|
|
274
|
-
rr, cc = stack.pop()
|
|
275
|
-
size += 1
|
|
276
|
-
|
|
277
|
-
for dr, dc in neighbors:
|
|
278
|
-
nr, nc = rr + dr, cc + dc
|
|
279
|
-
if (
|
|
280
|
-
0 <= nr < mask.shape[0]
|
|
281
|
-
and 0 <= nc < mask.shape[1]
|
|
282
|
-
and mask[nr, nc]
|
|
283
|
-
and not visited[nr, nc]
|
|
284
|
-
):
|
|
285
|
-
visited[nr, nc] = True
|
|
286
|
-
stack.append((nr, nc))
|
|
287
|
-
|
|
288
|
-
if size >= min_size:
|
|
289
|
-
return True
|
|
290
|
-
|
|
291
|
-
return False
|
|
292
|
-
|
|
293
|
-
def has_nasal_step_10db(td_grid, nasal_cols=(0, 1, 2, 3, 4)):
|
|
294
|
-
"""
|
|
295
|
-
Assumes right eye.
|
|
296
|
-
Nasal field is assumed to be the left side of the VF grid.
|
|
297
|
-
Checks adjacent superior/inferior pairs across the horizontal midline.
|
|
298
|
-
"""
|
|
299
|
-
diffs = []
|
|
300
|
-
|
|
301
|
-
for c in nasal_cols:
|
|
302
|
-
sup = td_grid[3, c]
|
|
303
|
-
inf = td_grid[4, c]
|
|
304
|
-
|
|
305
|
-
if not np.isnan(sup) and not np.isnan(inf):
|
|
306
|
-
diffs.append(abs(sup - inf) >= 10)
|
|
307
|
-
else:
|
|
308
|
-
diffs.append(False)
|
|
309
|
-
|
|
310
|
-
# cluster of 2 adjacent nasal step points
|
|
311
|
-
for i in range(len(diffs) - 1):
|
|
312
|
-
if diffs[i] and diffs[i + 1]:
|
|
313
|
-
return True
|
|
314
|
-
|
|
315
|
-
return False
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
########################################################
|
|
319
|
-
########################################################
|
|
320
|
-
|
|
321
|
-
def UKGTS_clf(row_prob, row_db):
|
|
322
|
-
prob_values = pd.to_numeric(row_prob, errors="coerce").values
|
|
323
|
-
db_values = pd.to_numeric(row_db, errors="coerce").values
|
|
324
|
-
|
|
325
|
-
prob_grid = convertVF_to_2D(prob_values)
|
|
326
|
-
db_grid = convertVF_to_2D(db_values)
|
|
327
|
-
|
|
328
|
-
# Criterion 1: cluster of 2 points at P < 1%
|
|
329
|
-
if has_cluster(prob_grid < 0.01, min_size=2):
|
|
330
|
-
return "GL"
|
|
331
|
-
|
|
332
|
-
# Criterion 2: cluster of 3 points at P < 5%
|
|
333
|
-
if has_cluster(prob_grid < 0.05, min_size=3):
|
|
334
|
-
return "GL"
|
|
335
|
-
|
|
336
|
-
# Criterion 3: cluster of 2 points with 10 dB difference across nasal horizontal midline
|
|
337
|
-
if has_nasal_step_10db(db_grid):
|
|
338
|
-
return "GL"
|
|
339
|
-
|
|
340
|
-
return "Non-GL"
|
|
341
|
-
|
|
342
|
-
def Fn_UKGTS(df_TDP, df_TD):
|
|
343
|
-
prob_cols = _pt_cols_from_df(df_TDP)
|
|
344
|
-
db_cols = _pt_cols_from_df(df_TD)
|
|
345
|
-
|
|
346
|
-
if len(prob_cols) != 54 or len(db_cols) != 54:
|
|
347
|
-
raise ValueError("Expected 54 VF point columns in both probability and dB dataframes.")
|
|
348
|
-
|
|
349
|
-
out = df_TDP.copy()
|
|
350
|
-
|
|
351
|
-
out["UKGTS_clf"] = [
|
|
352
|
-
UKGTS_clf(df_TDP.loc[i, prob_cols], df_TD.loc[i, db_cols])
|
|
353
|
-
for i in df_TDP.index
|
|
354
|
-
]
|
|
355
|
-
|
|
356
|
-
return out
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
########################################################
|
|
360
|
-
########################################################
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
# def LoGTS_clf(row):
|
|
364
|
-
# numeric_values = pd.to_numeric(row, errors='coerce')
|
|
365
|
-
# return 'GL' if (numeric_values < -10).sum() >= 2 else 'Non-GL'
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
def Fn_LoGTS(df_TD):
|
|
369
|
-
pt_cols = _pt_cols_from_df(df_TD)
|
|
370
|
-
df = df_TD.copy()
|
|
371
|
-
df['LoGTS_clf'] = df[pt_cols].apply(LoGTS_clf, axis=1)
|
|
372
|
-
return df
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
def LoGTS_clf(row):
|
|
376
|
-
values = pd.to_numeric(row, errors='coerce').values
|
|
377
|
-
VF_2D = convertVF_to_2D(values) # ← use the result
|
|
378
|
-
|
|
379
|
-
# criterion 1: cluster of 2 points with TD < -10 dB
|
|
380
|
-
if has_cluster(VF_2D < -10, min_size=2):
|
|
381
|
-
return "GL"
|
|
382
|
-
|
|
383
|
-
# criterion 2: cluster of 3 points with TD < -8 dB
|
|
384
|
-
if has_cluster(VF_2D < -8, min_size=3):
|
|
385
|
-
return "GL"
|
|
386
|
-
|
|
387
|
-
return "Non-GL"
|
|
388
|
-
|
|
389
|
-
########################################################
|
|
390
|
-
########################################################
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
def Kangs_clf(row):
|
|
394
|
-
values = pd.to_numeric(row, errors='coerce').values
|
|
395
|
-
td_grid = convertVF_to_2D(values)
|
|
396
|
-
|
|
397
|
-
# criterion 1: cluster of 3 contiguous points with TD < -5 dB
|
|
398
|
-
if has_cluster(td_grid < -5, min_size=3):
|
|
399
|
-
return 'GL'
|
|
400
|
-
# criterion 2: cluster of 2 contiguous points with TD < -10 dB
|
|
401
|
-
if has_cluster(td_grid < -10, min_size=2):
|
|
402
|
-
return 'GL'
|
|
403
|
-
return 'Non-GL'
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
def Fn_Kangs(df_TD):
|
|
407
|
-
pt_cols = _pt_cols_from_df(df_TD)
|
|
408
|
-
df = df_TD.copy()
|
|
409
|
-
df['Kangs_clf'] = df[pt_cols].apply(Kangs_clf, axis=1)
|
|
410
|
-
return df
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
########################################################
|
|
414
|
-
########################################################
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
# # Sector point indices (1-based) for OD and OS — direction-corrected
|
|
418
|
-
# _FOSTER_OD_SUP = [22,23,24,13,14,15,16,11,12,19,20,21,1,2,5,6,7,8,3,4,9,10]
|
|
419
|
-
# _FOSTER_OD_INF = [31,32,33,39,40,41,42,28,29,30,37,38,45,46,47,48,51,52,49,50,53,54]
|
|
420
|
-
# _FOSTER_OS_SUP = [22,23,24,13,14,15,16,17,18,25,26,27,3,4,8,9,10,1,2,5,6]
|
|
421
|
-
# _FOSTER_OS_INF = [31,32,33,39,40,41,42,34,35,36,43,44,48,49,50,53,54,45,46,51,52]
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
# def Foster_clf(row):
|
|
425
|
-
# eye = _normalise_eye(_eye_col(row))
|
|
426
|
-
# if eye is None:
|
|
427
|
-
# return 'Non-GL'
|
|
428
|
-
|
|
429
|
-
# pt_cols = _pt_cols_from_row(row)
|
|
430
|
-
# if not pt_cols:
|
|
431
|
-
# return 'Non-GL'
|
|
432
|
-
# pfx = pt_cols[0][0] # 's' or 'l'
|
|
433
|
-
|
|
434
|
-
# if eye == 'OD':
|
|
435
|
-
# sup_cols = [f'{pfx}{n}' for n in _FOSTER_OD_SUP]
|
|
436
|
-
# inf_cols = [f'{pfx}{n}' for n in _FOSTER_OD_INF]
|
|
437
|
-
# else:
|
|
438
|
-
# sup_cols = [f'{pfx}{n}' for n in _FOSTER_OS_SUP]
|
|
439
|
-
# inf_cols = [f'{pfx}{n}' for n in _FOSTER_OS_INF]
|
|
440
|
-
|
|
441
|
-
# # keep only cols that actually exist (guards against 52-pt datasets)
|
|
442
|
-
# sup_cols = [c for c in sup_cols if c in row.index]
|
|
443
|
-
# inf_cols = [c for c in inf_cols if c in row.index]
|
|
444
|
-
|
|
445
|
-
# s_vals = pd.to_numeric(row[sup_cols], errors='coerce')
|
|
446
|
-
# i_vals = pd.to_numeric(row[inf_cols], errors='coerce')
|
|
447
|
-
|
|
448
|
-
# gl_sum_flag = (s_vals.sum() > 0.005) and (i_vals.sum() > 0.005)
|
|
449
|
-
# gl_diff_flag = (s_vals.values - i_vals.values[:len(s_vals)]).tolist()
|
|
450
|
-
# gl_diff_flag = abs(sum(gl_diff_flag)) > 0.01
|
|
451
|
-
|
|
452
|
-
# all_pt = pd.to_numeric(row[pt_cols], errors='coerce')
|
|
453
|
-
# count_05 = (all_pt <= 0.05).sum()
|
|
454
|
-
|
|
455
|
-
# if (gl_sum_flag or gl_diff_flag) and (count_05 >= 3):
|
|
456
|
-
# return 'GL'
|
|
457
|
-
# return 'Non-GL'
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
def Foster_clf(row):
|
|
461
|
-
"""
|
|
462
|
-
Foster criteria:
|
|
463
|
-
|
|
464
|
-
GHT outside normal limits
|
|
465
|
-
AND
|
|
466
|
-
cluster of 3 points at P < 5%
|
|
467
|
-
(if PD unavailable due to severe depression,
|
|
468
|
-
cluster criterion automatically satisfied)
|
|
469
|
-
"""
|
|
470
|
-
|
|
471
|
-
# -----------------
|
|
472
|
-
# GHT check
|
|
473
|
-
# -----------------
|
|
474
|
-
ght_value = None
|
|
475
|
-
|
|
476
|
-
for ght_col in ["GHT", "ght"]:
|
|
477
|
-
if ght_col in row.index:
|
|
478
|
-
ght_value = row[ght_col]
|
|
479
|
-
break
|
|
480
|
-
|
|
481
|
-
if pd.isna(ght_value):
|
|
482
|
-
return "Error: missing GHT"
|
|
483
|
-
|
|
484
|
-
ght_ok = _is_ght_outside_normal_limits(ght_value)
|
|
485
|
-
|
|
486
|
-
# -----------------
|
|
487
|
-
# PD cluster check
|
|
488
|
-
# -----------------
|
|
489
|
-
pt_cols = _pt_cols_from_df(pd.DataFrame([row]))
|
|
490
|
-
pd_values = pd.to_numeric(
|
|
491
|
-
row[pt_cols],
|
|
492
|
-
errors="coerce"
|
|
493
|
-
).values
|
|
494
|
-
|
|
495
|
-
prob_grid = convertVF_to_2D(pd_values)
|
|
496
|
-
|
|
497
|
-
cluster_ok = has_cluster(
|
|
498
|
-
prob_grid < 0.05,
|
|
499
|
-
min_size=3
|
|
500
|
-
)
|
|
501
|
-
|
|
502
|
-
# -----------------
|
|
503
|
-
# Final decision
|
|
504
|
-
# -----------------
|
|
505
|
-
return "GL" if (ght_ok and cluster_ok) else "Non-GL"
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
def Fn_Foster(df_PDP):
|
|
510
|
-
df_P = df_PDP.copy()
|
|
511
|
-
df_P['Foster_clf'] = df_P.apply(Foster_clf, axis=1)
|
|
512
|
-
return df_P
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
########################################################
|
|
516
|
-
########################################################
|
|
517
|
-
|
|
518
|
-
# def combine_dataframes(df_input, df1, df2, df3, df4, df5):
|
|
519
|
-
# # Combine the DataFrames along the columns axis
|
|
520
|
-
# result_combined = pd.concat([df1, df2], axis=1)
|
|
521
|
-
|
|
522
|
-
# # Reset indices to ensure proper alignment
|
|
523
|
-
# m1_reset = df_input.reset_index(drop=True)
|
|
524
|
-
# m2_reset = result_combined.reset_index(drop=True)
|
|
525
|
-
|
|
526
|
-
# # Add the specified columns from m1_reset to the first four columns of m2_reset
|
|
527
|
-
# m2_reset.insert(0, 'ID', m1_reset['id'])
|
|
528
|
-
# m2_reset.insert(1, 'Eye', m1_reset['eye'])
|
|
529
|
-
# m2_reset.insert(2, 'Age', m1_reset['age'])
|
|
530
|
-
# m2_reset.insert(3, 'Date', m1_reset['date']) # Add the Date column from df_input
|
|
531
|
-
|
|
532
|
-
# result_combined = m2_reset
|
|
533
|
-
|
|
534
|
-
# # Concatenate df3
|
|
535
|
-
# result_combined = pd.concat([result_combined, df3.reset_index(drop=True)], axis=1)
|
|
536
|
-
|
|
537
|
-
# # Concatenate df4
|
|
538
|
-
# result_combined = pd.concat([result_combined, df4.reset_index(drop=True)], axis=1)
|
|
539
|
-
|
|
540
|
-
# # Concatenate df5
|
|
541
|
-
# result_combined = pd.concat([result_combined, df5.reset_index(drop=True)], axis=1)
|
|
542
|
-
|
|
543
|
-
# # Return the combined DataFrame
|
|
544
|
-
# return result_combined
|
|
545
|
-
|
|
546
|
-
# def Fn_ensemble_decision(df_input, df1, df2, df3, df4, df5, weights):
|
|
547
|
-
# # Call the combine_dataframes function with your DataFrames
|
|
548
|
-
# result_combined = combine_dataframes(df_input, df1, df2, df3, df4, df5)
|
|
549
|
-
|
|
550
|
-
# # Map categorical values to numeric equivalents for all columns except the first four
|
|
551
|
-
# for column in result_combined.columns[4:]:
|
|
552
|
-
# result_combined[column] = result_combined[column].map({'GL': 1, 'Non-GL': 0})
|
|
553
|
-
|
|
554
|
-
# # Convert data type to string to ensure consistency
|
|
555
|
-
# result_combined_en = result_combined.astype(str)
|
|
556
|
-
|
|
557
|
-
# # Calculate weighted average of predictions
|
|
558
|
-
# predictions = result_combined_en.iloc[:, 4:].apply(pd.to_numeric, errors='coerce').fillna(0)
|
|
559
|
-
# result_combined['Ensemble'] = np.average(predictions, axis=1, weights=weights)
|
|
560
|
-
|
|
561
|
-
# return result_combined
|
|
562
|
-
|
|
563
|
-
|
|
1
|
+
|
|
2
|
+
import numpy as np
|
|
3
|
+
import pandas as pd
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
import pandas as pd
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _normalise_eye(val):
|
|
11
|
+
"""Return 'OD' or 'OS' regardless of input format."""
|
|
12
|
+
v = str(val).strip().lower()
|
|
13
|
+
if v in ('od', 'right', 'r', '1', 'righteye', 're'):
|
|
14
|
+
return 'OD'
|
|
15
|
+
if v in ('os', 'left', 'l', '0', 'lefteye', 'le'):
|
|
16
|
+
return 'OS'
|
|
17
|
+
return None # unknown — caller returns Non-GL
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _pt_cols_from_row(row):
|
|
21
|
+
"""Return sorted point column names (s* or l*) from a row's index."""
|
|
22
|
+
cols = sorted(
|
|
23
|
+
[c for c in row.index if len(c) >= 2 and c[0] in ('s', 'l') and c[1:].isdigit()],
|
|
24
|
+
key=lambda x: int(x[1:])
|
|
25
|
+
)
|
|
26
|
+
return cols
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _pt_cols_from_df(df):
|
|
30
|
+
"""Return sorted point column names (s* or l*) from a dataframe."""
|
|
31
|
+
cols = sorted(
|
|
32
|
+
[c for c in df.columns if len(c) >= 2 and c[0] in ('s', 'l') and c[1:].isdigit()],
|
|
33
|
+
key=lambda x: int(x[1:])
|
|
34
|
+
)
|
|
35
|
+
return cols
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _eye_col(row):
|
|
39
|
+
"""Return the eye value from whichever column name exists (Eye/eye/EYE)."""
|
|
40
|
+
for name in ('Eye', 'eye', 'EYE'):
|
|
41
|
+
if name in row.index:
|
|
42
|
+
return row[name]
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _col(prefix, n, pt_cols):
|
|
47
|
+
"""
|
|
48
|
+
Return the column name for point n (1-indexed) using whatever prefix
|
|
49
|
+
is present in pt_cols (s or l).
|
|
50
|
+
"""
|
|
51
|
+
actual_prefix = pt_cols[0][0] if pt_cols else 's'
|
|
52
|
+
return f'{actual_prefix}{n}'
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def convertVF_to_2D(VF):
|
|
56
|
+
VF_2D = np.full((8,9), np.nan)
|
|
57
|
+
|
|
58
|
+
VF_2D[0,3:7] = VF[0:4]
|
|
59
|
+
VF_2D[1,2:8] = VF[4:10]
|
|
60
|
+
VF_2D[2,1:9] = VF[10:18]
|
|
61
|
+
VF_2D[3,0:9] = VF[18:27]
|
|
62
|
+
VF_2D[4,0:9] = VF[27:36]
|
|
63
|
+
VF_2D[5,1:9] = VF[36:44]
|
|
64
|
+
VF_2D[6,2:8] = VF[44:50]
|
|
65
|
+
VF_2D[7,3:7] = VF[50:54]
|
|
66
|
+
|
|
67
|
+
return VF_2D
|
|
68
|
+
|
|
69
|
+
##########################################################
|
|
70
|
+
###################################################
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# def Fn_HAP2(df_PDP):
|
|
74
|
+
# pt_cols = _pt_cols_from_df(df_PDP)
|
|
75
|
+
# data = df_PDP[pt_cols]
|
|
76
|
+
|
|
77
|
+
# counts_05 = (data <= 0.05).sum(axis=1)
|
|
78
|
+
# counts_01 = (data <= 0.01).sum(axis=1)
|
|
79
|
+
|
|
80
|
+
# df_PDP['HAP2_p1_clf'] = np.where(
|
|
81
|
+
# (counts_05 >= 3) & (counts_01 >= 1), 'GL', 'Non-GL')
|
|
82
|
+
|
|
83
|
+
# df_GL = df_PDP[df_PDP['HAP2_p1_clf'] == 'GL'].copy()
|
|
84
|
+
|
|
85
|
+
# # find MD column case-insensitively
|
|
86
|
+
# md_col = next((c for c in df_PDP.columns if c.lower() in ('md', 'tmd')), None)
|
|
87
|
+
# if md_col:
|
|
88
|
+
# md = df_GL[md_col]
|
|
89
|
+
# c05 = counts_05[df_GL.index]
|
|
90
|
+
# c01 = counts_01[df_GL.index]
|
|
91
|
+
# cond1 = (md > -6.0) & c05.between(1, 12) & c01.between(1, 4)
|
|
92
|
+
# cond2 = (md.between(-12.0, -6.01)) | (c05.between(13, 26) & c01.between(5, 13))
|
|
93
|
+
# cond3 = (md < -12.0) | (c05 >= 27)
|
|
94
|
+
# df_GL['HAP2_p2_clf'] = np.select(
|
|
95
|
+
# [cond1, cond2, cond3],
|
|
96
|
+
# ['Stage 1', 'Stage 2', 'Stage 3'],
|
|
97
|
+
# default='GL')
|
|
98
|
+
# else:
|
|
99
|
+
# df_GL['HAP2_p2_clf'] = 'GL'
|
|
100
|
+
|
|
101
|
+
# df_PDP.loc[df_GL.index, 'HAP2_p2_clf'] = df_GL['HAP2_p2_clf']
|
|
102
|
+
# df_PDP['HAP2_p2_clf'] = df_PDP['HAP2_p2_clf'].fillna('Non-GL')
|
|
103
|
+
# return df_PDP
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def has_pd_cluster_3_with_1_at_1pct(pd_values):
|
|
109
|
+
"""
|
|
110
|
+
Criterion:
|
|
111
|
+
cluster of 3 points at P < 5%,
|
|
112
|
+
with at least 1 of those points at P < 1%.
|
|
113
|
+
"""
|
|
114
|
+
pd_grid = convertVF_to_2D(pd_values)
|
|
115
|
+
|
|
116
|
+
mask_5 = pd_grid < 0.05
|
|
117
|
+
mask_1 = pd_grid < 0.01
|
|
118
|
+
|
|
119
|
+
mask_5 = np.nan_to_num(mask_5, nan=False).astype(bool)
|
|
120
|
+
mask_1 = np.nan_to_num(mask_1, nan=False).astype(bool)
|
|
121
|
+
|
|
122
|
+
visited = np.zeros(mask_5.shape, dtype=bool)
|
|
123
|
+
|
|
124
|
+
neighbors = [(-1,-1), (-1,0), (-1,1),
|
|
125
|
+
(0,-1), (0,1),
|
|
126
|
+
(1,-1), (1,0), (1,1)]
|
|
127
|
+
|
|
128
|
+
for r in range(mask_5.shape[0]):
|
|
129
|
+
for c in range(mask_5.shape[1]):
|
|
130
|
+
if not mask_5[r, c] or visited[r, c]:
|
|
131
|
+
continue
|
|
132
|
+
|
|
133
|
+
stack = [(r, c)]
|
|
134
|
+
visited[r, c] = True
|
|
135
|
+
component = []
|
|
136
|
+
|
|
137
|
+
while stack:
|
|
138
|
+
rr, cc = stack.pop()
|
|
139
|
+
component.append((rr, cc))
|
|
140
|
+
|
|
141
|
+
for dr, dc in neighbors:
|
|
142
|
+
nr, nc = rr + dr, cc + dc
|
|
143
|
+
if (
|
|
144
|
+
0 <= nr < mask_5.shape[0]
|
|
145
|
+
and 0 <= nc < mask_5.shape[1]
|
|
146
|
+
and mask_5[nr, nc]
|
|
147
|
+
and not visited[nr, nc]
|
|
148
|
+
):
|
|
149
|
+
visited[nr, nc] = True
|
|
150
|
+
stack.append((nr, nc))
|
|
151
|
+
|
|
152
|
+
if len(component) >= 3:
|
|
153
|
+
has_1pct_point = any(mask_1[rr, cc] for rr, cc in component)
|
|
154
|
+
if has_1pct_point:
|
|
155
|
+
return True
|
|
156
|
+
|
|
157
|
+
return False
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _is_ght_outside_normal_limits(x):
|
|
161
|
+
if pd.isna(x):
|
|
162
|
+
return False
|
|
163
|
+
|
|
164
|
+
x = str(x).strip().lower()
|
|
165
|
+
|
|
166
|
+
return (
|
|
167
|
+
"outside normal limits" in x
|
|
168
|
+
or "outside" in x
|
|
169
|
+
or x in {"onl", "abnormal"}
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _is_psd_p_less_5(row):
|
|
174
|
+
"""
|
|
175
|
+
Handles possible column names:
|
|
176
|
+
PSD_P, PSD_p, PSDp, PSD_probability, PSD
|
|
177
|
+
If only PSD is present as a p-value, it checks PSD < 0.05.
|
|
178
|
+
"""
|
|
179
|
+
possible_cols = [
|
|
180
|
+
"PSD_P", "PSD_p", "PSDp", "psd_p",
|
|
181
|
+
"PSD_probability", "psd_probability",
|
|
182
|
+
"PSD", "psd"
|
|
183
|
+
]
|
|
184
|
+
|
|
185
|
+
for col in possible_cols:
|
|
186
|
+
if col in row.index:
|
|
187
|
+
val = pd.to_numeric(row[col], errors="coerce")
|
|
188
|
+
if pd.notna(val) and val < 0.05:
|
|
189
|
+
return True
|
|
190
|
+
|
|
191
|
+
return False
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def HAP2_clf(row):
|
|
195
|
+
"""
|
|
196
|
+
Criteria:
|
|
197
|
+
1. GHT outside normal limits
|
|
198
|
+
OR
|
|
199
|
+
2. cluster of 3 PD probability points at P < 5%,
|
|
200
|
+
with at least 1 at P < 1%
|
|
201
|
+
OR
|
|
202
|
+
3. PSD at P < 5%
|
|
203
|
+
"""
|
|
204
|
+
pt_cols = _pt_cols_from_df(pd.DataFrame([row]))
|
|
205
|
+
pd_values = pd.to_numeric(row[pt_cols], errors="coerce").values
|
|
206
|
+
|
|
207
|
+
# Criterion 1: GHT outside normal limits
|
|
208
|
+
for ght_col in ["GHT", "ght"]:
|
|
209
|
+
if ght_col in row.index and _is_ght_outside_normal_limits(row[ght_col]):
|
|
210
|
+
return "GL"
|
|
211
|
+
|
|
212
|
+
# Criterion 2: PD cluster
|
|
213
|
+
if has_pd_cluster_3_with_1_at_1pct(pd_values):
|
|
214
|
+
return "GL"
|
|
215
|
+
|
|
216
|
+
# Criterion 3: PSD P < 5%
|
|
217
|
+
if _is_psd_p_less_5(row):
|
|
218
|
+
return "GL"
|
|
219
|
+
|
|
220
|
+
return "Non-GL"
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def Fn_HAP2(df_PDP):
|
|
224
|
+
out = df_PDP.copy()
|
|
225
|
+
out["HAP2_clf"] = out.apply(HAP2_clf, axis=1)
|
|
226
|
+
return out
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
########################################################
|
|
231
|
+
########################################################
|
|
232
|
+
|
|
233
|
+
# def check_gl_condition(row, threshold=0.01, consecutive_reductions=2):
|
|
234
|
+
# numeric_values = pd.to_numeric(row.values, errors='coerce')
|
|
235
|
+
# below = (numeric_values <= threshold).astype(float)
|
|
236
|
+
# below = np.nan_to_num(below, nan=0.0)
|
|
237
|
+
# counts = np.convolve(below, np.ones(consecutive_reductions), mode='valid')
|
|
238
|
+
# return np.any(counts >= consecutive_reductions)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
# def Fn_UKGTS(df_TDP, threshold=0.01, consecutive_reductions=2):
|
|
242
|
+
# pt_cols = _pt_cols_from_df(df_TDP)
|
|
243
|
+
# df_TDP['UKGTS_clf'] = np.where(
|
|
244
|
+
# df_TDP[pt_cols].apply(
|
|
245
|
+
# check_gl_condition, axis=1,
|
|
246
|
+
# threshold=threshold,
|
|
247
|
+
# consecutive_reductions=consecutive_reductions),
|
|
248
|
+
# 'GL', 'Non-GL')
|
|
249
|
+
# return df_TDP
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def has_cluster(mask, min_size=2, connectivity=8):
|
|
254
|
+
mask = np.nan_to_num(mask, nan=False).astype(bool)
|
|
255
|
+
visited = np.zeros(mask.shape, dtype=bool)
|
|
256
|
+
|
|
257
|
+
if connectivity == 8:
|
|
258
|
+
neighbors = [(-1,-1), (-1,0), (-1,1),
|
|
259
|
+
(0,-1), (0,1),
|
|
260
|
+
(1,-1), (1,0), (1,1)]
|
|
261
|
+
else:
|
|
262
|
+
neighbors = [(-1,0), (1,0), (0,-1), (0,1)]
|
|
263
|
+
|
|
264
|
+
for r in range(mask.shape[0]):
|
|
265
|
+
for c in range(mask.shape[1]):
|
|
266
|
+
if not mask[r, c] or visited[r, c]:
|
|
267
|
+
continue
|
|
268
|
+
|
|
269
|
+
stack = [(r, c)]
|
|
270
|
+
visited[r, c] = True
|
|
271
|
+
size = 0
|
|
272
|
+
|
|
273
|
+
while stack:
|
|
274
|
+
rr, cc = stack.pop()
|
|
275
|
+
size += 1
|
|
276
|
+
|
|
277
|
+
for dr, dc in neighbors:
|
|
278
|
+
nr, nc = rr + dr, cc + dc
|
|
279
|
+
if (
|
|
280
|
+
0 <= nr < mask.shape[0]
|
|
281
|
+
and 0 <= nc < mask.shape[1]
|
|
282
|
+
and mask[nr, nc]
|
|
283
|
+
and not visited[nr, nc]
|
|
284
|
+
):
|
|
285
|
+
visited[nr, nc] = True
|
|
286
|
+
stack.append((nr, nc))
|
|
287
|
+
|
|
288
|
+
if size >= min_size:
|
|
289
|
+
return True
|
|
290
|
+
|
|
291
|
+
return False
|
|
292
|
+
|
|
293
|
+
def has_nasal_step_10db(td_grid, nasal_cols=(0, 1, 2, 3, 4)):
|
|
294
|
+
"""
|
|
295
|
+
Assumes right eye.
|
|
296
|
+
Nasal field is assumed to be the left side of the VF grid.
|
|
297
|
+
Checks adjacent superior/inferior pairs across the horizontal midline.
|
|
298
|
+
"""
|
|
299
|
+
diffs = []
|
|
300
|
+
|
|
301
|
+
for c in nasal_cols:
|
|
302
|
+
sup = td_grid[3, c]
|
|
303
|
+
inf = td_grid[4, c]
|
|
304
|
+
|
|
305
|
+
if not np.isnan(sup) and not np.isnan(inf):
|
|
306
|
+
diffs.append(abs(sup - inf) >= 10)
|
|
307
|
+
else:
|
|
308
|
+
diffs.append(False)
|
|
309
|
+
|
|
310
|
+
# cluster of 2 adjacent nasal step points
|
|
311
|
+
for i in range(len(diffs) - 1):
|
|
312
|
+
if diffs[i] and diffs[i + 1]:
|
|
313
|
+
return True
|
|
314
|
+
|
|
315
|
+
return False
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
########################################################
|
|
319
|
+
########################################################
|
|
320
|
+
|
|
321
|
+
def UKGTS_clf(row_prob, row_db):
|
|
322
|
+
prob_values = pd.to_numeric(row_prob, errors="coerce").values
|
|
323
|
+
db_values = pd.to_numeric(row_db, errors="coerce").values
|
|
324
|
+
|
|
325
|
+
prob_grid = convertVF_to_2D(prob_values)
|
|
326
|
+
db_grid = convertVF_to_2D(db_values)
|
|
327
|
+
|
|
328
|
+
# Criterion 1: cluster of 2 points at P < 1%
|
|
329
|
+
if has_cluster(prob_grid < 0.01, min_size=2):
|
|
330
|
+
return "GL"
|
|
331
|
+
|
|
332
|
+
# Criterion 2: cluster of 3 points at P < 5%
|
|
333
|
+
if has_cluster(prob_grid < 0.05, min_size=3):
|
|
334
|
+
return "GL"
|
|
335
|
+
|
|
336
|
+
# Criterion 3: cluster of 2 points with 10 dB difference across nasal horizontal midline
|
|
337
|
+
if has_nasal_step_10db(db_grid):
|
|
338
|
+
return "GL"
|
|
339
|
+
|
|
340
|
+
return "Non-GL"
|
|
341
|
+
|
|
342
|
+
def Fn_UKGTS(df_TDP, df_TD):
|
|
343
|
+
prob_cols = _pt_cols_from_df(df_TDP)
|
|
344
|
+
db_cols = _pt_cols_from_df(df_TD)
|
|
345
|
+
|
|
346
|
+
if len(prob_cols) != 54 or len(db_cols) != 54:
|
|
347
|
+
raise ValueError("Expected 54 VF point columns in both probability and dB dataframes.")
|
|
348
|
+
|
|
349
|
+
out = df_TDP.copy()
|
|
350
|
+
|
|
351
|
+
out["UKGTS_clf"] = [
|
|
352
|
+
UKGTS_clf(df_TDP.loc[i, prob_cols], df_TD.loc[i, db_cols])
|
|
353
|
+
for i in df_TDP.index
|
|
354
|
+
]
|
|
355
|
+
|
|
356
|
+
return out
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
########################################################
|
|
360
|
+
########################################################
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
# def LoGTS_clf(row):
|
|
364
|
+
# numeric_values = pd.to_numeric(row, errors='coerce')
|
|
365
|
+
# return 'GL' if (numeric_values < -10).sum() >= 2 else 'Non-GL'
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def Fn_LoGTS(df_TD):
|
|
369
|
+
pt_cols = _pt_cols_from_df(df_TD)
|
|
370
|
+
df = df_TD.copy()
|
|
371
|
+
df['LoGTS_clf'] = df[pt_cols].apply(LoGTS_clf, axis=1)
|
|
372
|
+
return df
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def LoGTS_clf(row):
|
|
376
|
+
values = pd.to_numeric(row, errors='coerce').values
|
|
377
|
+
VF_2D = convertVF_to_2D(values) # ← use the result
|
|
378
|
+
|
|
379
|
+
# criterion 1: cluster of 2 points with TD < -10 dB
|
|
380
|
+
if has_cluster(VF_2D < -10, min_size=2):
|
|
381
|
+
return "GL"
|
|
382
|
+
|
|
383
|
+
# criterion 2: cluster of 3 points with TD < -8 dB
|
|
384
|
+
if has_cluster(VF_2D < -8, min_size=3):
|
|
385
|
+
return "GL"
|
|
386
|
+
|
|
387
|
+
return "Non-GL"
|
|
388
|
+
|
|
389
|
+
########################################################
|
|
390
|
+
########################################################
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def Kangs_clf(row):
|
|
394
|
+
values = pd.to_numeric(row, errors='coerce').values
|
|
395
|
+
td_grid = convertVF_to_2D(values)
|
|
396
|
+
|
|
397
|
+
# criterion 1: cluster of 3 contiguous points with TD < -5 dB
|
|
398
|
+
if has_cluster(td_grid < -5, min_size=3):
|
|
399
|
+
return 'GL'
|
|
400
|
+
# criterion 2: cluster of 2 contiguous points with TD < -10 dB
|
|
401
|
+
if has_cluster(td_grid < -10, min_size=2):
|
|
402
|
+
return 'GL'
|
|
403
|
+
return 'Non-GL'
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def Fn_Kangs(df_TD):
|
|
407
|
+
pt_cols = _pt_cols_from_df(df_TD)
|
|
408
|
+
df = df_TD.copy()
|
|
409
|
+
df['Kangs_clf'] = df[pt_cols].apply(Kangs_clf, axis=1)
|
|
410
|
+
return df
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
########################################################
|
|
414
|
+
########################################################
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
# # Sector point indices (1-based) for OD and OS — direction-corrected
|
|
418
|
+
# _FOSTER_OD_SUP = [22,23,24,13,14,15,16,11,12,19,20,21,1,2,5,6,7,8,3,4,9,10]
|
|
419
|
+
# _FOSTER_OD_INF = [31,32,33,39,40,41,42,28,29,30,37,38,45,46,47,48,51,52,49,50,53,54]
|
|
420
|
+
# _FOSTER_OS_SUP = [22,23,24,13,14,15,16,17,18,25,26,27,3,4,8,9,10,1,2,5,6]
|
|
421
|
+
# _FOSTER_OS_INF = [31,32,33,39,40,41,42,34,35,36,43,44,48,49,50,53,54,45,46,51,52]
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
# def Foster_clf(row):
|
|
425
|
+
# eye = _normalise_eye(_eye_col(row))
|
|
426
|
+
# if eye is None:
|
|
427
|
+
# return 'Non-GL'
|
|
428
|
+
|
|
429
|
+
# pt_cols = _pt_cols_from_row(row)
|
|
430
|
+
# if not pt_cols:
|
|
431
|
+
# return 'Non-GL'
|
|
432
|
+
# pfx = pt_cols[0][0] # 's' or 'l'
|
|
433
|
+
|
|
434
|
+
# if eye == 'OD':
|
|
435
|
+
# sup_cols = [f'{pfx}{n}' for n in _FOSTER_OD_SUP]
|
|
436
|
+
# inf_cols = [f'{pfx}{n}' for n in _FOSTER_OD_INF]
|
|
437
|
+
# else:
|
|
438
|
+
# sup_cols = [f'{pfx}{n}' for n in _FOSTER_OS_SUP]
|
|
439
|
+
# inf_cols = [f'{pfx}{n}' for n in _FOSTER_OS_INF]
|
|
440
|
+
|
|
441
|
+
# # keep only cols that actually exist (guards against 52-pt datasets)
|
|
442
|
+
# sup_cols = [c for c in sup_cols if c in row.index]
|
|
443
|
+
# inf_cols = [c for c in inf_cols if c in row.index]
|
|
444
|
+
|
|
445
|
+
# s_vals = pd.to_numeric(row[sup_cols], errors='coerce')
|
|
446
|
+
# i_vals = pd.to_numeric(row[inf_cols], errors='coerce')
|
|
447
|
+
|
|
448
|
+
# gl_sum_flag = (s_vals.sum() > 0.005) and (i_vals.sum() > 0.005)
|
|
449
|
+
# gl_diff_flag = (s_vals.values - i_vals.values[:len(s_vals)]).tolist()
|
|
450
|
+
# gl_diff_flag = abs(sum(gl_diff_flag)) > 0.01
|
|
451
|
+
|
|
452
|
+
# all_pt = pd.to_numeric(row[pt_cols], errors='coerce')
|
|
453
|
+
# count_05 = (all_pt <= 0.05).sum()
|
|
454
|
+
|
|
455
|
+
# if (gl_sum_flag or gl_diff_flag) and (count_05 >= 3):
|
|
456
|
+
# return 'GL'
|
|
457
|
+
# return 'Non-GL'
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def Foster_clf(row):
|
|
461
|
+
"""
|
|
462
|
+
Foster criteria:
|
|
463
|
+
|
|
464
|
+
GHT outside normal limits
|
|
465
|
+
AND
|
|
466
|
+
cluster of 3 points at P < 5%
|
|
467
|
+
(if PD unavailable due to severe depression,
|
|
468
|
+
cluster criterion automatically satisfied)
|
|
469
|
+
"""
|
|
470
|
+
|
|
471
|
+
# -----------------
|
|
472
|
+
# GHT check
|
|
473
|
+
# -----------------
|
|
474
|
+
ght_value = None
|
|
475
|
+
|
|
476
|
+
for ght_col in ["GHT", "ght"]:
|
|
477
|
+
if ght_col in row.index:
|
|
478
|
+
ght_value = row[ght_col]
|
|
479
|
+
break
|
|
480
|
+
|
|
481
|
+
if pd.isna(ght_value):
|
|
482
|
+
return "Error: missing GHT"
|
|
483
|
+
|
|
484
|
+
ght_ok = _is_ght_outside_normal_limits(ght_value)
|
|
485
|
+
|
|
486
|
+
# -----------------
|
|
487
|
+
# PD cluster check
|
|
488
|
+
# -----------------
|
|
489
|
+
pt_cols = _pt_cols_from_df(pd.DataFrame([row]))
|
|
490
|
+
pd_values = pd.to_numeric(
|
|
491
|
+
row[pt_cols],
|
|
492
|
+
errors="coerce"
|
|
493
|
+
).values
|
|
494
|
+
|
|
495
|
+
prob_grid = convertVF_to_2D(pd_values)
|
|
496
|
+
|
|
497
|
+
cluster_ok = has_cluster(
|
|
498
|
+
prob_grid < 0.05,
|
|
499
|
+
min_size=3
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
# -----------------
|
|
503
|
+
# Final decision
|
|
504
|
+
# -----------------
|
|
505
|
+
return "GL" if (ght_ok and cluster_ok) else "Non-GL"
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def Fn_Foster(df_PDP):
|
|
510
|
+
df_P = df_PDP.copy()
|
|
511
|
+
df_P['Foster_clf'] = df_P.apply(Foster_clf, axis=1)
|
|
512
|
+
return df_P
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
########################################################
|
|
516
|
+
########################################################
|
|
517
|
+
|
|
518
|
+
# def combine_dataframes(df_input, df1, df2, df3, df4, df5):
|
|
519
|
+
# # Combine the DataFrames along the columns axis
|
|
520
|
+
# result_combined = pd.concat([df1, df2], axis=1)
|
|
521
|
+
|
|
522
|
+
# # Reset indices to ensure proper alignment
|
|
523
|
+
# m1_reset = df_input.reset_index(drop=True)
|
|
524
|
+
# m2_reset = result_combined.reset_index(drop=True)
|
|
525
|
+
|
|
526
|
+
# # Add the specified columns from m1_reset to the first four columns of m2_reset
|
|
527
|
+
# m2_reset.insert(0, 'ID', m1_reset['id'])
|
|
528
|
+
# m2_reset.insert(1, 'Eye', m1_reset['eye'])
|
|
529
|
+
# m2_reset.insert(2, 'Age', m1_reset['age'])
|
|
530
|
+
# m2_reset.insert(3, 'Date', m1_reset['date']) # Add the Date column from df_input
|
|
531
|
+
|
|
532
|
+
# result_combined = m2_reset
|
|
533
|
+
|
|
534
|
+
# # Concatenate df3
|
|
535
|
+
# result_combined = pd.concat([result_combined, df3.reset_index(drop=True)], axis=1)
|
|
536
|
+
|
|
537
|
+
# # Concatenate df4
|
|
538
|
+
# result_combined = pd.concat([result_combined, df4.reset_index(drop=True)], axis=1)
|
|
539
|
+
|
|
540
|
+
# # Concatenate df5
|
|
541
|
+
# result_combined = pd.concat([result_combined, df5.reset_index(drop=True)], axis=1)
|
|
542
|
+
|
|
543
|
+
# # Return the combined DataFrame
|
|
544
|
+
# return result_combined
|
|
545
|
+
|
|
546
|
+
# def Fn_ensemble_decision(df_input, df1, df2, df3, df4, df5, weights):
|
|
547
|
+
# # Call the combine_dataframes function with your DataFrames
|
|
548
|
+
# result_combined = combine_dataframes(df_input, df1, df2, df3, df4, df5)
|
|
549
|
+
|
|
550
|
+
# # Map categorical values to numeric equivalents for all columns except the first four
|
|
551
|
+
# for column in result_combined.columns[4:]:
|
|
552
|
+
# result_combined[column] = result_combined[column].map({'GL': 1, 'Non-GL': 0})
|
|
553
|
+
|
|
554
|
+
# # Convert data type to string to ensure consistency
|
|
555
|
+
# result_combined_en = result_combined.astype(str)
|
|
556
|
+
|
|
557
|
+
# # Calculate weighted average of predictions
|
|
558
|
+
# predictions = result_combined_en.iloc[:, 4:].apply(pd.to_numeric, errors='coerce').fillna(0)
|
|
559
|
+
# result_combined['Ensemble'] = np.average(predictions, axis=1, weights=weights)
|
|
560
|
+
|
|
561
|
+
# return result_combined
|
|
562
|
+
|
|
563
|
+
|