spelunkai/training/tests/test_targets.py
Jonas 07e75247c4 Implement v1 training pipeline: CenterNet-style detector
Add the anchor-free, center-heatmap detector CLAUDE.md §3.3 specifies:
- targets.py: encodes ground-truth boxes into a per-class Gaussian
  heatmap + wh regression target + center mask, using the standard
  CornerNet/CenterNet gaussian-radius formulation.
- model.py: a small conv backbone (stride 4) with heatmap (sigmoid)
  and wh regression heads - exactly the two outputs CLAUDE.md
  specifies, sized with the 66ms/tick budget in mind.
- losses.py: modified focal loss (heatmap) + masked L1 (wh), combined
  with the standard CenterNet wh_weight=0.1.
- client.py / dataset.py: pull a promoted dataset version + its set's
  class list from the labeling backend and turn it into a
  torch.utils.data.Dataset, reading images from local disk (same
  machine as FRAMES_ROOT).
- train.py: wires it into a basic DataLoader -> train loop ->
  per-epoch checkpoint.

The exact loss weighting/architecture sizing is a reasonable, standard
v1 default, not a tuned final answer - CLAUDE.md's own roadmap flags
the exact formulation as still open; this is the starting point to
iterate from.

16/16 unit tests pass on CPU with synthetic data (target/model/loss
correctness, dataset-version parsing). Beyond that, ran a real
end-to-end smoke test: live labeling backend -> promoted dataset
version -> `spelunkai-train` actually training one real epoch against
it and writing a checkpoint. Not verified: multi-epoch convergence on
real data, which needs jai's GPU and real labeled frames.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-16 13:48:45 +02:00

44 lines
1.6 KiB
Python

import numpy as np
from spelunkai_training.targets import Box, encode_targets, gaussian_radius
def test_gaussian_radius_is_positive_for_a_reasonable_box():
assert gaussian_radius(height=20, width=15) > 0
def test_encode_targets_places_peak_at_box_center():
box = Box(class_index=0, x=8, y=16, width=8, height=8) # center = (12, 20)
targets = encode_targets([box], num_classes=1, image_width=64, image_height=64, output_stride=4)
# center in output space: (12/4, 20/4) = (3, 5) -> (x=3, y=5)
assert targets.heatmap.shape == (1, 16, 16)
assert targets.heatmap[0, 5, 3] == 1.0
assert targets.heatmap.max() == 1.0
def test_encode_targets_sets_wh_and_mask_only_at_center():
box = Box(class_index=0, x=8, y=16, width=8, height=12)
targets = encode_targets([box], num_classes=1, image_width=64, image_height=64, output_stride=4)
assert targets.wh[0, 5, 3] == 8
assert targets.wh[1, 5, 3] == 12
assert targets.mask[5, 3] == 1.0
assert targets.mask.sum() == 1.0
def test_encode_targets_rejects_out_of_range_class_index():
box = Box(class_index=5, x=0, y=0, width=4, height=4)
try:
encode_targets([box], num_classes=2, image_width=32, image_height=32, output_stride=4)
assert False, "expected ValueError"
except ValueError:
pass
def test_encode_targets_with_no_boxes_is_all_zero():
targets = encode_targets([], num_classes=3, image_width=32, image_height=32, output_stride=4)
assert targets.heatmap.shape == (3, 8, 8)
assert np.all(targets.heatmap == 0)
assert np.all(targets.mask == 0)