Ring CNN — well-geometry detector

TadPose measures tadpole motion in millimetres and thigmotaxis as a fraction of the well radius. Both need the pixel geometry of every well: its centre (cx, cy) and radius r in the crop. The Ring CNN (RingNet) regresses that geometry directly from a single clean-well image, replacing the brittle Hough/edge detectors that failed on reflections, meniscus glare and uneven illumination.

The detector is deliberately small and trained from scratch — the compute nodes have no internet, so no ImageNet backbone is downloaded, and the task is narrow enough (one bright ring on a near-uniform field) that a compact network converges cleanly on a modest hand-annotated set.

What it predicts

Input is a 128 × 128 × 3 RGB crop of one well (the background projection, see below). Output is three numbers passed through a sigmoid, so they are image fractions in [0, 1]:

RingNet(crop)  ->  (cx, cy, r)          # fractions of crop width / height

Pixels are recovered by multiplying back by the crop size (cx * w, cy * h, r * w). The physical scale follows from the caliper-confirmed well diameter of 15.6 mm:

pix2mm = 2 * r_px / 15.6                 # px per mm, per well

Predicting fractions rather than pixels makes the head resolution-independent and keeps the three outputs on the same numeric scale for the loss.

Architecture

A compact VGG-idiom convolutional regressor, 0.6 M parameters:

Stage

Layers

Feature block × 4

Conv3×3 BatchNorm ReLU twice, then MaxPool2×2

Channel widths

3 32 64 128 128 (width = 32)

Bottleneck

AdaptiveAvgPool2d(1) — global average pool to a 128-vector

Head

Flatten Linear(128→64) ReLU Dropout(0.2) Linear(64→3) Sigmoid

Global average pooling (rather than a large flatten) keeps the parameter count low and makes the network tolerant of small crop-size variation. Batch-norm on every conv stabilises the from-scratch training.

class RingNet(nn.Module):
    """Compact conv regressor -> sigmoid(cx, cy, r) in [0, 1]."""

    def __init__(self, width: int = 32):
        super().__init__()

        def block(ci: int, co: int) -> nn.Sequential:
            return nn.Sequential(
                nn.Conv2d(ci, co, 3, padding=1), nn.BatchNorm2d(co), nn.ReLU(inplace=True),
                nn.Conv2d(co, co, 3, padding=1), nn.BatchNorm2d(co), nn.ReLU(inplace=True),
                nn.MaxPool2d(2),
            )

        self.features = nn.Sequential(
            block(3, width), block(width, width * 2),
            block(width * 2, width * 4), block(width * 4, width * 4),
            nn.AdaptiveAvgPool2d(1),
        )
        self.head = nn.Sequential(
            nn.Flatten(), nn.Linear(width * 4, width * 2), nn.ReLU(inplace=True),
            nn.Dropout(0.2), nn.Linear(width * 2, 3), nn.Sigmoid(),
        )

Training data

Circles are annotated with the arena_annotator (circle_annotator) tool and exported as COCO. Each annotation carries attributes.centre_x / centre_y / radius; targets are normalised to image fractions on load.

Split by plate, never by well. A plate holds 24 near-identical wells, so a random per-well split would leak almost the same image into train and test. Splitting on the plate stem (parsed from <src>__<stem>_well_NN.png) keeps every plate wholly inside one of train / val / test (0.70 / 0.15 / 0.15).

Target-aware augmentation. Each geometric transform is applied to the label as well as the image, so the supervision stays correct:

Transform (prob)

Label update

Horizontal flip (0.5)

cx 1 cx

Vertical flip (0.5)

cy 1 cy

90° rotation × k (uniform k)

(cx, cy) (cy, 1 cx) per turn

Brightness / contrast jitter (0.7)

image only

Loss and optimisation

  • LossSmoothL1 (Huber) on the three normalised targets; robust to the occasional mis-annotated circle.

  • Optimiser — Adam, lr = 1e-3.

  • ScheduleReduceLROnPlateau(factor=0.5, patience=15) on the validation score.

  • Selection — best checkpoint by val centre_px_mean + radius_px_mean.

  • Defaults — 300 epochs, batch 32, img_size = 128, seed 0.

Evaluation reports centre and radius error back in pixels (de-normalised by crop width/height); qc_overlays.png draws predicted (vermilion, dashed) vs annotated (bluish-green) circles on held-out test crops.

The background projection — the decisive design choice

A well crop is a video, not a still. The tadpole is dark and moves; the well rim is static. Collapsing 40 evenly-spaced frames to one image removes the animal and exposes a clean ring. Two projections were compared:

  • Median — per-pixel median over frames; smooth, but the rim softens slightly.

  • Max — per-channel maximum over frames; the dark animal is beaten at every pixel by the brighter background, giving the sharpest rim.

Because the radius sets pix2mm, a crisp rim matters more than a crisp centre. Across annotation sets and projections (held-out test split):

Run

Projection

centre (px)

centre med

radius (px)

radius med

upper rim

median

4.37

3.76

1.77

1.42

lower edge (small)

median

4.32

3.63

1.78

1.47

lower edge v3

median

1.74

1.65

1.34

1.22

combined

median

1.42

1.13

1.52

1.45

combined

max

1.53

1.27

1.44

1.22

all-max (deployed)

max

2.64

1.83

1.80

1.75

Why the lower edge. The well is a shallow truncated cone. The upper rim sits above the water and is displaced by parallax; the lower edge is the water plane the tadpole actually swims in. Annotating the lower edge (v3 onward) collapsed the centre error from > 4 px to < 2 px and is the correct plane for pix2mm.

Why max is deployed. The max projection gives the best radius (and radius is what pix2mm depends on). The production model, all-max, extends the combined max set with the newer plate cohorts (e.g. the July-2026 PPP3CA recordings) so the detector has seen the imaging conditions it is applied to. Its held-out numbers look slightly higher only because that split contains the harder, more varied late plates; on the deployment data it produces a tight, uniform geometry (median well radius 51–52 px across all 24 wells).

Inference and deployment

ring_infer.py applies the trained model over an experiment’s videos:

  1. For each video of the requested experiment_type, and each of its 24 wells, build the max background projection (40 frames) and predict (cx, cy, r) in pixels.

  2. pix2mm for the video is 2 · median(r over wells) / 15.6.

  3. Write two JSON artefacts:

    • {video_id: pix2mm} — per-video scale.

    • {video_id: {well: [cx, cy, r]}} — per-well geometry.

The kinematics loader consumes the per-well geometry so each trajectory is re-expressed in well-centred millimetres (origin at that well’s (cx, cy), scaled by its own 2r / 15.6). Thigmotaxis is then √(x² + y²) / R with R = 7.8 mm. Feeding the ring geometry into the PPP3CA report moved the occupancy mass onto the wall (periphery fraction 0.95–0.98), fixing the earlier “centroids miles from the wall” artefact caused by stale stored geometry.

Reproducing

# train (COCO export from circle_annotator in <data-dir>)
python ring_train.py --data-dir <data-dir> --out-dir <run-dir> \
    --epochs 300 --batch 32 --img-size 128 --lr 1e-3

# infer geometry for one experiment_type
python ring_infer.py --model <run-dir>/ring_net_best.pt \
    --out pix2mm.json --geometry-out geometry.json \
    --experiment-type <id>

Outputs per run: ring_net_best.pt, metrics.json (full history + test scores), and qc_overlays.png.

Note

Data roots and the database path are machine-specific and resolve through the gitignored local_paths.json (see Installation); the commands above use <placeholder> paths.