File size: 2,954 Bytes
b379113
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
"""Quadrant mapping from the OD-fovea axis.

Needed for ICDR's 4-2-1 rule (severe NPDR: haemorrhages in 4 quadrants, or
venous beading in 2, or IRMA in 1), which counts lesions per retinal
quadrant. This module defines the four quadrants geometrically, as the
roadmap specifies: one dividing line through the optic disc along the
OD-fovea axis, one perpendicular to it through the same point.

**Labelling deliberately avoids true anatomical nasal/temporal names.**
Whether the "away from fovea" side of the OD is nasal or temporal depends on
which eye (OD/OS) the image is of, and neither IDRiD nor this project's
other datasets carry reliable per-image eye-laterality labels to resolve
that. The labels used here -- "foveal-side" / "disc-side" for the axis-
parallel direction, "superior" / "inferior" for the perpendicular one --
describe the geometry precisely without asserting an anatomical mapping
this project cannot verify. A deployment with real laterality metadata
could remap these to true nasal/temporal in one place (`QUADRANT_LABELS`)
without touching the geometry.
"""

from __future__ import annotations

import numpy as np

__all__ = ["quadrant_axes", "assign_quadrant", "QUADRANT_LABELS"]

QUADRANT_LABELS = {
    (1, 1): "superior-foveal-side",
    (1, -1): "inferior-foveal-side",
    (-1, 1): "superior-disc-side",
    (-1, -1): "inferior-disc-side",
}


def quadrant_axes(
    od_xy: tuple[float, float], fovea_xy: tuple[float, float]
) -> tuple[np.ndarray, np.ndarray]:
    """Unit vectors (along_axis, perpendicular_axis) through the OD, where
    along_axis points from OD toward the fovea. Raises if OD and fovea
    coincide (undefined axis) -- that should never happen for real data and
    signals a genuine upstream bug (e.g. a broken localisation checkpoint)
    if it does.
    """
    od = np.asarray(od_xy, dtype=float)
    fovea = np.asarray(fovea_xy, dtype=float)
    axis = fovea - od
    norm = np.linalg.norm(axis)
    if norm < 1e-6:
        raise ValueError(f"OD and fovea coincide ({od_xy} == {fovea_xy}) -- cannot define an axis.")
    along = axis / norm
    # Perpendicular, image coordinates (y grows downward): rotate -90 degrees
    # so positive `perp` points toward smaller y (up the image, superior).
    perp = np.array([along[1], -along[0]])
    return along, perp


def assign_quadrant(
    point_xy: tuple[float, float], od_xy: tuple[float, float], fovea_xy: tuple[float, float]
) -> str:
    """Which of the 4 quadrants `point_xy` falls in, relative to the OD as
    origin and the OD-fovea line as one dividing axis. See module docstring
    for why the labels describe geometry, not asserted anatomy."""
    along, perp = quadrant_axes(od_xy, fovea_xy)
    od = np.asarray(od_xy, dtype=float)
    v = np.asarray(point_xy, dtype=float) - od

    along_sign = 1 if np.dot(v, along) >= 0 else -1
    perp_sign = 1 if np.dot(v, perp) >= 0 else -1
    return QUADRANT_LABELS[(along_sign, perp_sign)]