PyVisualFields 2.0.1__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.
@@ -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
+