fovea_geometry
Fovea-relative geometry: distance, region of interest, angular span.
Every quantity here is measured in millimetre space about a reference point, never in index space. The two en-face axes are sampled about twenty times apart, so an index-space radius would be an ellipse and an index-space angle would be skewed by the same factor.
There is no image-centre fallback. When the fovea landmark is unavailable the
caller reports None for every field derived here: a radius or an angular span
measured about the image centre answers a different question from the one the
protocol asks, and would pass or fail a threshold on that different question
with no signal that it had.
Angular span is measured on the lesion's RAW FOOTPRINT, not on its convex hull. A C-shaped atrophy therefore reports the arc it actually occupies, not the near-full circle its hull would close. Raw is the stricter reading, so the error direction is "fails to exclude" rather than "wrongly excludes" — the safer side for a screening proxy whose verdicts a reading centre re-adjudicates. If a reading centre's "surrounds the fovea" turns out to mean the closure, that is a deliberate change of definition, not a bug fix.
Module
Functions
aggregate_angular_span_deg
def aggregate_angular_span_deg(occupancies: Iterable[NDArray[np.bool_]]) ‑> float:Total angle subtended by a group's lesions about the reference point.
The per-lesion occupancies are OR-ed, not summed, so two lesions covering the same arc contribute it once. That is what "an aggregate of more than 270 degrees" asks for.
Arguments
occupancies: Per-lesion occupancy arrays fromangular_occupancy.
Returns
The occupied angle in degrees, 0.0 for no lesions.
angular_occupancy
def angular_occupancy( lesion: RawLesion, reference: tuple[float, float], scale: VoxelScale, *, min_radius_mm: float = 0.0,) ‑> numpy.ndarray[typing.Any, numpy.dtype[numpy.bool_]]:Which angular bins about the reference point the lesion occupies.
Each cell marks every bin its own footprint subtends, not just the bin of
its centre. A cell asserts tissue across a slice_thickness_mm by
pixel_spacing_column_mm slab, not at a point, so binning only the centre
would leave real gaps between adjacent cells wherever the coarse B-scan
spacing makes the centre-to-centre angular step wider than one bin -- close
to the reference point, a lesion that genuinely encircles it would then
read as having holes it does not have.
The trade-off is a bounded over-read at the ends of a genuine gap or
sector: each cell's corners span roughly degrees(slice_thickness_mm / r)
beyond its own true angular position at distance r, so a real edge is
over-stated by about that much, worst near the reference point. This is
the deliberate direction of error. The alternative, binning only cell
centres, silently under-reports an encircling lesion instead -- and the
tissue between B-scans is not imaged at all, so an uncertainty of this
order is a property of the sampling, not of this function; no binning
scheme resolves it exactly.
Taken to its limit, this term is large right next to the reference point: on a standard 0.244 mm / 0.0115 mm cube, a single cell one column away reports about 176 degrees, so two cells straddling the reference point aggregate past a 270-degree threshold from two segmented pixels.
That is not a defect in the binning, and it cannot be fixed in the plane. At
radius r the B-scan sampling gives an angular resolution of about
degrees(slice_thickness_mm / r) per sector end — 14 degrees at 1 mm, 4.7
at 3 mm, meaningless at 0.01 mm — and the 244 µm between B-scans is unimaged
tissue. Working from the model's polygons instead of the rasterised cells
does NOT help: one column out, the B-scan half-slab is 0.122 mm against the
column half-cell's 0.0057 mm, 21 times larger, so exact column edges move
that 176 degrees only to about 170.
Hence min_radius_mm: below it there is no angular resolution to report. It
is a parameter rather than a constant here because the value changes who is
recruited, which is a clinical decision and not a geometry one.
Arguments
lesion: The extracted lesion.reference:(bscan, column)reference point, in index space.scale: The cube's voxel dimensions.min_radius_mm: Cells whose centre lies within this distance of the reference contribute no angle.0.0skips only the containing cell, which is the previous behaviour. Centre distance rather than nearest footprint corner, matchingmin_distance_from_reference_mm, so the two agree about how far away a cell is.
Returns
A boolean array of 360 / ANGULAR_BIN_DEG bins, True where the
lesion's footprint reaches. The cell containing the reference point, and
every cell inside min_radius_mm, are skipped.
min_distance_from_reference_mm
def min_distance_from_reference_mm( lesion: RawLesion, reference: tuple[float, float], scale: VoxelScale,) ‑> float:Distance in mm from the reference point to the lesion's nearest cell.
Nearest cell rather than centroid: "within the central region" asks whether any part of the lesion is inside it, so a large lesion reaching in must not be excluded by a centroid that sits outside.
Arguments
lesion: The extracted lesion.reference:(bscan, column)reference point, in index space.scale: The cube's voxel dimensions.
Returns The minimum distance in mm.
roi_mask
def roi_mask( shape: tuple[int, int], reference: tuple[float, float], radius_mm: float, scale: VoxelScale,) ‑> numpy.ndarray[typing.Any, numpy.dtype[numpy.bool_]]:A circular region of interest about the reference point.
Arguments
shape:(num_bscans, num_cols)of the en-face plane.reference:(bscan, column)reference point, in index space.radius_mm: Radius in mm, not diameter. A protocol naming a diameter (for example, i-SIGHT2's 3000 µm ROI) must pass half of it, 1.5 mm.scale: The cube's voxel dimensions.
Returns
A boolean mask, True inside the radius.