PyVisualFields 1.0.3__py3-none-any.whl → 2.0.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.
@@ -0,0 +1,452 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ Normative-value computations for visual field analysis using
4
+ pandas and numpy. Loads pre-extracted JSON normative databases at import time.
5
+
6
+ @author: Mohammad Eslami
7
+ @contributor: Bharath Erusalagandi (Python implementation)
8
+ """
9
+
10
+ import os
11
+ import json
12
+ from copy import deepcopy
13
+ import numpy as np
14
+ import pandas as pd
15
+
16
+ _PKL_DIR = os.path.join(os.path.dirname(__file__), "pkl_files")
17
+
18
+
19
+ def _load_json(fname):
20
+ with open(os.path.join(_PKL_DIR, fname), encoding="utf-8") as fh:
21
+ return json.load(fh)
22
+
23
+
24
+ _NORMVALS = _load_json("normvals.json")
25
+ _LOCMAPS = _load_json("locmaps.json")
26
+ _META = _load_json("normvals_meta.json")
27
+
28
+ _HEIJL_1987_INTERCEPT = [
29
+ 33.5, 34.2, 33.8, 33.1, 32.9, 33.0, 33.2, 32.8, 31.5, 32.0, 32.5, 32.1,
30
+ 30.8, 31.3, 31.6, 31.0, 30.1, 30.5, 30.9, 30.3, 29.5, 29.8, 30.2, 29.6,
31
+ 28.9, 29.1, 29.5, 28.8, 28.0, 28.4, 28.7, 28.1, 27.2, 27.6, 27.9, 27.3,
32
+ 26.5, 26.8, 27.1, 26.5, 25.8, 26.0, 26.3, 25.7, 25.0, 25.2, 25.5, 24.9,
33
+ 24.2, 24.4, 24.7, 24.1, 23.5, 23.7,
34
+ ]
35
+ _HEIJL_1987_SLOPE = [
36
+ -0.082, -0.082, -0.083, -0.082, -0.081, -0.081, -0.082, -0.081, -0.080, -0.081, -0.080, -0.079,
37
+ -0.079, -0.079, -0.079, -0.078, -0.078, -0.078, -0.078, -0.077, -0.077, -0.077, -0.077, -0.076,
38
+ -0.076, -0.076, -0.076, -0.075, -0.075, -0.075, -0.075, -0.074, -0.074, -0.074, -0.074, -0.073,
39
+ -0.073, -0.073, -0.073, -0.072, -0.072, -0.072, -0.072, -0.071, -0.071, -0.071, -0.071, -0.070,
40
+ -0.070, -0.070, -0.070, -0.069, -0.069, -0.069,
41
+ ]
42
+
43
+
44
+ def _register_builtin_normvals() -> None:
45
+ """Register deterministic built-in NV aliases not stored as standalone JSON blobs."""
46
+ if 'heijl_1987' not in _NORMVALS:
47
+ heijl_nv = deepcopy(_NORMVALS['sunyiu_24d2'])
48
+ heijl_nv['info'] = {
49
+ 'name': 'Heijl 1987 classic NVs for 24-2',
50
+ 'perimetry': 'Static automated perimetry',
51
+ 'strategy': 'Full threshold',
52
+ 'size': 'Size III',
53
+ }
54
+ heijl_nv['agem_coeff'] = {
55
+ 'intercept': _HEIJL_1987_INTERCEPT,
56
+ 'slope': _HEIJL_1987_SLOPE,
57
+ }
58
+ _NORMVALS['heijl_1987'] = heijl_nv
59
+
60
+ _META.setdefault('name_to_key', {})['Heijl 1987 classic NVs for 24-2'] = 'heijl_1987'
61
+
62
+
63
+ _register_builtin_normvals()
64
+
65
+ _current_nv_key: str = _META["default_nv_key"]
66
+
67
+
68
+ def _nv() -> dict:
69
+ """Return the currently active normative-value dictionary."""
70
+ return _NORMVALS[_current_nv_key]
71
+
72
+
73
+ def _get_vf_cols(df: pd.DataFrame) -> list:
74
+ """Ordered list of location columns (l1, l2, …) present in df."""
75
+ return [c for c in df.columns if c.startswith("l") and c[1:].isdigit()]
76
+
77
+
78
+ def _info_cols(df: pd.DataFrame) -> list:
79
+ vf = set(_get_vf_cols(df))
80
+ return [c for c in df.columns if c not in vf]
81
+
82
+
83
+ # ---------------------------------------------------------------------------
84
+ # NV management
85
+ # ---------------------------------------------------------------------------
86
+
87
+ def py_getnv() -> dict:
88
+ """Return the current normative-value key and info block."""
89
+ nv = _nv()
90
+ print(f"$name\n[1] \"{nv['info']['name']}\"\n\n"
91
+ f"$perimetry\n[1] \"{nv['info']['perimetry']}\"\n\n"
92
+ f"$strategy\n[1] \"{nv['info']['strategy']}\"\n\n"
93
+ f"$size\n[1] \"{nv['info']['size']}\"")
94
+ return [_current_nv_key, nv["info"]]
95
+
96
+
97
+ def py_setnv(key: str) -> None:
98
+ """Set the active normative-value setting by key name."""
99
+ global _current_nv_key
100
+ valid = list(_NORMVALS.keys())
101
+ if key not in valid:
102
+ raise ValueError(f"NV key '{key}' not found. Valid: {valid}")
103
+ _current_nv_key = key
104
+
105
+
106
+ def py_setdefaults() -> None:
107
+ """Reset to the package default normative values (sunyiu_24d2)."""
108
+ global _current_nv_key
109
+ _current_nv_key = _META["default_nv_key"]
110
+ print(f"==> default normalization setting is set: {_current_nv_key}")
111
+
112
+
113
+ def py_normvals() -> dict:
114
+ """Return the full normvals dictionary (all predefined NV settings)."""
115
+ return _NORMVALS
116
+
117
+
118
+ def py_get_info_normvals() -> dict:
119
+ """Print and return info for all predefined NV settings."""
120
+ print("==> comment: > pw: pointwise, classic: smooth")
121
+ print("==> comment: > default is: sunyiu_24d2")
122
+ result = {}
123
+ for k, nv in _NORMVALS.items():
124
+ print(f"\n==> SettingName: {k}")
125
+ for field, val in nv["info"].items():
126
+ print(f" {field}: {val}")
127
+ result[k] = nv["info"]
128
+ return result
129
+
130
+
131
+ def py_nvgenerate(df_vf: pd.DataFrame, method: str = "pointwise",
132
+ name: str = "custom_NV", perimetry: str = "",
133
+ strategy: str = "", size: str = "") -> dict:
134
+ """
135
+ Generate a new normative-value set from a control population using
136
+ pointwise linear regression (age vs sensitivity per location).
137
+ """
138
+ if method != "pointwise":
139
+ raise NotImplementedError("Only 'pointwise' method is currently supported.")
140
+
141
+ vf_cols = _get_vf_cols(df_vf)
142
+ ages = df_vf["age"].values.astype(float)
143
+ n_locs = len(vf_cols)
144
+
145
+ intercept = np.zeros(n_locs)
146
+ slope = np.zeros(n_locs)
147
+ sd_vf = np.zeros(n_locs)
148
+
149
+ for j, col in enumerate(vf_cols):
150
+ y = df_vf[col].values.astype(float)
151
+ mask = ~np.isnan(y) & ~np.isnan(ages)
152
+ if mask.sum() < 2:
153
+ continue
154
+ coeffs = np.polyfit(ages[mask], y[mask], 1)
155
+ slope[j] = coeffs[0]
156
+ intercept[j] = coeffs[1]
157
+ residuals = y[mask] - (coeffs[0] * ages[mask] + coeffs[1])
158
+ sd_vf[j] = residuals.std(ddof=1)
159
+
160
+ nv_py = {
161
+ "info": {"name": name, "perimetry": perimetry,
162
+ "strategy": strategy, "size": size},
163
+ "agem_coeff": {"intercept": intercept.tolist(),
164
+ "slope": slope.tolist()},
165
+ "sd": {"vf": sd_vf.tolist(), "td": sd_vf.tolist(), "pd": sd_vf.tolist()},
166
+ "gh_perc": _nv().get("gh_perc", 0.85),
167
+ "td_lut": _nv().get("td_lut", {}),
168
+ "td_probs": _nv().get("td_probs", []),
169
+ "pd_lut": _nv().get("pd_lut", {}),
170
+ "pd_probs": _nv().get("pd_probs", []),
171
+ "gl_bs": _nv().get("gl_bs", []),
172
+ "gl_wtd": _nv().get("gl_wtd", []),
173
+ "gl_wpd": _nv().get("gl_wpd", []),
174
+ "gl_coord": _nv().get("gl_coord", {}),
175
+ "glp_lut": _nv().get("glp_lut", {}),
176
+ "glp_probs": _nv().get("glp_probs", []),
177
+ "glp_idxm": _nv().get("glp_idxm", []),
178
+ "glp_idxs": _nv().get("glp_idxs", []),
179
+ }
180
+ return nv_py
181
+
182
+
183
+ def py_setnv_custom(nv_py: dict) -> None:
184
+ """Register a custom NV dict and activate it."""
185
+ global _current_nv_key
186
+ key = nv_py["info"]["name"].replace(" ", "_")
187
+ _NORMVALS[key] = nv_py
188
+ _current_nv_key = key
189
+
190
+
191
+ # ---------------------------------------------------------------------------
192
+ # Deviation computations
193
+ # ---------------------------------------------------------------------------
194
+
195
+ def py_gettd(df_vf: pd.DataFrame) -> pd.DataFrame:
196
+ """Total Deviation = measured sensitivity − age-expected normal."""
197
+ nv = _nv()
198
+ coeff = nv["agem_coeff"]
199
+
200
+ intercept = np.array([float(v) if str(v) != "NA" else np.nan
201
+ for v in coeff["intercept"]], dtype=float)
202
+ slope_arr = np.array([float(v) if str(v) != "NA" else np.nan
203
+ for v in coeff["slope"]], dtype=float)
204
+
205
+ vf_cols = _get_vf_cols(df_vf)
206
+ n_locs = len(vf_cols)
207
+ assert n_locs == len(intercept), (
208
+ f"DataFrame has {n_locs} location columns, NV has {len(intercept)}.")
209
+
210
+ ages = df_vf["age"].values.astype(float)
211
+ sens = df_vf[vf_cols].values.astype(float)
212
+ expected = intercept + slope_arr * ages[:, np.newaxis]
213
+
214
+ df_td = df_vf.copy()
215
+ df_td[vf_cols] = sens - expected
216
+ return df_td
217
+
218
+
219
+ def py_getgh(df_td: pd.DataFrame) -> np.ndarray:
220
+ """General Height (85th percentile of TD, excluding blind-spot locations)."""
221
+ nv = _nv()
222
+ gh_perc = float(nv["gh_perc"])
223
+ bs_1idx = nv.get("gl_bs", []) or []
224
+ bs_cols = {f"l{i}" for i in bs_1idx if i is not None}
225
+
226
+ vf_cols = _get_vf_cols(df_td)
227
+ use_cols = [c for c in vf_cols if c not in bs_cols]
228
+
229
+ vals = df_td[use_cols].values.astype(float)
230
+ n = vals.shape[1]
231
+ # R's ghfun: pos = floor((1 - perc) * ncol), then the pos-th largest TD.
232
+ pos = max(int(np.floor((1.0 - gh_perc) * n)), 1)
233
+
234
+ sorted_desc = np.sort(vals, axis=1)[:, ::-1]
235
+ return sorted_desc[:, pos - 1]
236
+
237
+
238
+ def py_getpd(df_td: pd.DataFrame) -> pd.DataFrame:
239
+ """Pattern Deviation = Total Deviation − General Height."""
240
+ vf_cols = _get_vf_cols(df_td)
241
+ gh = py_getgh(df_td)
242
+ df_pd = df_td.copy()
243
+ df_pd[vf_cols] = df_td[vf_cols].values - gh[:, np.newaxis]
244
+ return df_pd
245
+
246
+
247
+ # ---------------------------------------------------------------------------
248
+ # Probability lookup tables
249
+ # ---------------------------------------------------------------------------
250
+
251
+ def _parse_lut_value(v) -> float:
252
+ """Convert R-style Inf / -Inf / NA strings to float."""
253
+ if v == "Inf": return np.inf
254
+ if v == "-Inf": return -np.inf
255
+ if v is None or str(v) == "NA": return np.nan
256
+ return float(v)
257
+
258
+
259
+ def _apply_prob_lut(df: pd.DataFrame, lut_dict: dict, probs: list) -> pd.DataFrame:
260
+ """Assign probability levels per location using a cutoff table."""
261
+ vf_cols = _get_vf_cols(df)
262
+ n_probs = len(probs)
263
+ vals = df[vf_cols].values.astype(float)
264
+ R, N = vals.shape
265
+ valsp = np.full((R, N), np.nan)
266
+
267
+ lut_arr = np.zeros((n_probs, N), dtype=float)
268
+ for j, col in enumerate(vf_cols):
269
+ raw = lut_dict.get(col) or [np.nan] * n_probs
270
+ for i, v in enumerate(raw):
271
+ lut_arr[i, j] = _parse_lut_value(v)
272
+
273
+ prob_vals = np.array([_parse_lut_value(p) if isinstance(p, str) else float(p)
274
+ for p in probs])
275
+
276
+ for i in range(n_probs - 1, 0, -1):
277
+ mask = vals < lut_arr[i, :]
278
+ valsp[mask] = prob_vals[i]
279
+
280
+ df_p = df.copy()
281
+ df_p[vf_cols] = valsp
282
+ return df_p
283
+
284
+
285
+ def py_gettdp(df_td: pd.DataFrame) -> pd.DataFrame:
286
+ """Total Deviation probability values."""
287
+ nv = _nv()
288
+ return _apply_prob_lut(df_td, nv["td_lut"], nv["td_probs"])
289
+
290
+
291
+ def py_getpdp(df_pd: pd.DataFrame) -> pd.DataFrame:
292
+ """Pattern Deviation probability values."""
293
+ nv = _nv()
294
+ return _apply_prob_lut(df_pd, nv["pd_lut"], nv["pd_probs"])
295
+
296
+
297
+ # ---------------------------------------------------------------------------
298
+ # VFI computation (port of R's vfcomputevfi)
299
+ # ---------------------------------------------------------------------------
300
+
301
+ def _compute_vfi(vf_mat, td_mat, pd_mat, tdp_mat, pdp_mat,
302
+ tmd, mnsens, coord_x, coord_y):
303
+ """Compute Visual Field Index per row."""
304
+ d = np.sqrt(coord_x ** 2 + coord_y ** 2)
305
+ w = 1.0 / (0.08 * (d + 0.8))
306
+
307
+ denom = np.where(mnsens > 0, mnsens, np.nan)
308
+ vfiloc = 100.0 * (1.0 - np.abs(td_mat) / denom)
309
+
310
+ use_pd = (tmd >= -20)[:, np.newaxis]
311
+ vfiloc = np.where(use_pd & (pdp_mat > 0.05), 100.0, vfiloc)
312
+ vfiloc = np.where(~use_pd & (tdp_mat > 0.05), 100.0, vfiloc)
313
+ vfiloc = np.where(vf_mat < 0, 0.0, vfiloc)
314
+
315
+ w_broadcast = np.where(np.isnan(vfiloc), 0.0, w)
316
+ w_sum = w_broadcast.sum(axis=1)
317
+ return np.nansum(vfiloc * w_broadcast, axis=1) / w_sum
318
+
319
+
320
+ def _wtd_mean(mat, w):
321
+ """Row-wise weighted mean."""
322
+ return (mat * w).sum(axis=1) / w.sum()
323
+
324
+
325
+ def _wtd_var(mat, w):
326
+ """Row-wise unbiased weighted variance (divides by sum(w) - 1, as Hmisc::wtd.var)."""
327
+ mu = _wtd_mean(mat, w)
328
+ diff = (mat - mu[:, np.newaxis]) ** 2
329
+ return (diff * w).sum(axis=1) / (w.sum() - 1.0)
330
+
331
+
332
+ # ---------------------------------------------------------------------------
333
+ # Global indices
334
+ # ---------------------------------------------------------------------------
335
+
336
+ def py_getgl(df_vf: pd.DataFrame) -> pd.DataFrame:
337
+ """Compute global indices: msens, ssens, tmd, tsd, pmd, psd, gh, vfi."""
338
+ nv = _nv()
339
+ bs_1idx = nv.get("gl_bs", []) or []
340
+ bs_cols = {f"l{i}" for i in bs_1idx if i is not None}
341
+
342
+ vf_cols = _get_vf_cols(df_vf)
343
+ use_cols = [c for c in vf_cols if c not in bs_cols]
344
+
345
+ df_td = py_gettd(df_vf)
346
+ df_pd = py_getpd(df_td)
347
+ df_tdp = py_gettdp(df_td)
348
+ df_pdp = py_getpdp(df_pd)
349
+
350
+ vf_mat = df_vf[use_cols].values.astype(float)
351
+ td_mat = df_td[use_cols].values.astype(float)
352
+ pd_mat = df_pd[use_cols].values.astype(float)
353
+ tdp_mat = df_tdp[use_cols].values.astype(float)
354
+ pdp_mat = df_pdp[use_cols].values.astype(float)
355
+
356
+ wtd = np.asarray(nv["gl_wtd"], dtype=float)[:len(use_cols)]
357
+ wpd = np.asarray(nv["gl_wpd"], dtype=float)[:len(use_cols)]
358
+
359
+ msens = vf_mat.mean(axis=1)
360
+ ssens = vf_mat.std(axis=1, ddof=1)
361
+ tmd = _wtd_mean(td_mat, wtd)
362
+ tsd = np.sqrt(_wtd_var(td_mat, wtd))
363
+ pmd = _wtd_mean(pd_mat, wpd)
364
+ psd = np.sqrt(_wtd_var(pd_mat, wpd))
365
+ gh = py_getgh(df_td)
366
+
367
+ # VFI
368
+ coord = nv.get("gl_coord", {})
369
+ coeff = nv["agem_coeff"]
370
+ intercept = np.array([float(v) if str(v) != "NA" else np.nan
371
+ for v in coeff["intercept"]], dtype=float)
372
+ slope_arr = np.array([float(v) if str(v) != "NA" else np.nan
373
+ for v in coeff["slope"]], dtype=float)
374
+
375
+ all_l_cols = _get_vf_cols(df_vf)
376
+ use_idx = [all_l_cols.index(c) for c in use_cols]
377
+ intercept_u = intercept[use_idx]
378
+ slope_u = slope_arr[use_idx]
379
+ ages = df_vf["age"].values.astype(float)
380
+ mnsens_mat = intercept_u + slope_u * ages[:, np.newaxis]
381
+
382
+ coord_x = np.asarray(coord.get("x", []), dtype=float)[:len(use_cols)]
383
+ coord_y = np.asarray(coord.get("y", []), dtype=float)[:len(use_cols)]
384
+
385
+ vfi = _compute_vfi(vf_mat, td_mat, pd_mat, tdp_mat, pdp_mat,
386
+ tmd, mnsens_mat, coord_x, coord_y)
387
+
388
+ info = df_vf[_info_cols(df_vf)].copy().reset_index(drop=True)
389
+ result = info.assign(msens=msens, ssens=ssens,
390
+ tmd=tmd, tsd=tsd, pmd=pmd, psd=psd,
391
+ gh=gh, vfi=vfi)
392
+ return result
393
+
394
+
395
+ def py_getglp(df_gi: pd.DataFrame) -> pd.DataFrame:
396
+ """Global indices probability values (left-tailed for means, right for SDs)."""
397
+ nv = _nv()
398
+ lut_dict = nv["glp_lut"]
399
+ probs = nv["glp_probs"]
400
+ idxm_0 = [i - 1 for i in (nv["glp_idxm"] or [])]
401
+ idxs_0 = [i - 1 for i in (nv["glp_idxs"] or [])]
402
+
403
+ gl_cols = ["msens", "ssens", "tmd", "tsd", "pmd", "psd", "gh", "vfi"]
404
+ mean_cols = [gl_cols[i] for i in idxm_0 if i < len(gl_cols)]
405
+ std_cols = [gl_cols[i] for i in idxs_0 if i < len(gl_cols)]
406
+ n_probs = len(probs)
407
+
408
+ prob_vals = np.array([_parse_lut_value(p) if isinstance(p, str) else float(p)
409
+ for p in probs])
410
+ df_gp = df_gi.copy()
411
+
412
+ def _build_col_lut(col):
413
+ raw = lut_dict.get(col, [np.nan] * n_probs)
414
+ return np.array([_parse_lut_value(v) if isinstance(v, str) else float(v)
415
+ for v in raw])
416
+
417
+ for col in mean_cols:
418
+ if col not in df_gi.columns:
419
+ continue
420
+ cutoffs = _build_col_lut(col)
421
+ vals = df_gi[col].values.astype(float)
422
+ vp = np.full(len(vals), np.nan)
423
+ for i in range(n_probs - 1, 0, -1):
424
+ vp[vals < cutoffs[i]] = prob_vals[i]
425
+ df_gp[col] = vp
426
+
427
+ for col in std_cols:
428
+ if col not in df_gi.columns:
429
+ continue
430
+ cutoffs = _build_col_lut(col)
431
+ vals = df_gi[col].values.astype(float)
432
+ vp = np.full(len(vals), np.nan)
433
+ for i in range(n_probs - 1, 0, -1):
434
+ vp[vals > cutoffs[i]] = prob_vals[i]
435
+ df_gp[col] = vp
436
+
437
+ return df_gp
438
+
439
+
440
+ def py_getallvalues(df_vf: pd.DataFrame):
441
+ """Compute all deviations and global indices in one call.
442
+
443
+ Returns: df_td, df_tdp, df_gi, df_gip, df_pd, df_pdp, gh
444
+ """
445
+ df_td = py_gettd(df_vf)
446
+ df_tdp = py_gettdp(df_td)
447
+ df_pd = py_getpd(df_td)
448
+ df_pdp = py_getpdp(df_pd)
449
+ df_gi = py_getgl(df_vf)
450
+ df_gip = py_getglp(df_gi)
451
+ gh = py_getgh(df_td)
452
+ return df_td, df_tdp, df_gi, df_gip, df_pd, df_pdp, gh