Skip to content

Commit ebca56b

Browse files
authored
Merge pull request #61 from apgeorg/test/split-test-suite
test: split test_auto_e2e into per-package test modules
2 parents be00a4e + 12bee13 commit ebca56b

7 files changed

Lines changed: 1324 additions & 1268 deletions

Model/tests/test_auto_e2e.py

Lines changed: 8 additions & 1268 deletions
Large diffs are not rendered by default.

Model/tests/test_backbone.py

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import torch
2+
import sys
3+
sys.path.append('..')
4+
5+
from model_components.backbone import Backbone
6+
7+
8+
class _StubBackboneWithFeatureInfo(torch.nn.Module):
9+
"""Channels-first backbone exposing timm-style feature_info."""
10+
11+
def __init__(self):
12+
super().__init__()
13+
self.stage0 = torch.nn.Conv2d(3, 32, 3, stride=2, padding=1)
14+
self.stage1 = torch.nn.Conv2d(32, 48, 3, stride=2, padding=1)
15+
self.stage2 = torch.nn.Conv2d(48, 64, 3, stride=2, padding=1)
16+
self.feature_info = [{"num_chs": 32}, {"num_chs": 48}, {"num_chs": 64}]
17+
18+
def forward(self, x):
19+
s0 = self.stage0(x)
20+
s1 = self.stage1(s0)
21+
s2 = self.stage2(s1)
22+
return [s0, s1, s2]
23+
24+
25+
class _StubBackboneNoFeatureInfo(torch.nn.Module):
26+
"""Channels-first backbone with NO feature_info (probe fallback path)."""
27+
28+
def __init__(self):
29+
super().__init__()
30+
self.stage0 = torch.nn.Conv2d(3, 24, 3, stride=2, padding=1)
31+
self.stage1 = torch.nn.Conv2d(24, 56, 3, stride=2, padding=1)
32+
self.stage2 = torch.nn.Conv2d(56, 112, 3, stride=2, padding=1)
33+
34+
def forward(self, x):
35+
s0 = self.stage0(x)
36+
s1 = self.stage1(s0)
37+
s2 = self.stage2(s1)
38+
return [s0, s1, s2]
39+
40+
41+
class _StubBackboneSwinLike(torch.nn.Module):
42+
"""Channels-last backbone (B, H, W, C) — exercises permute branch."""
43+
44+
def __init__(self):
45+
super().__init__()
46+
self.stage0 = torch.nn.Conv2d(3, 32, 3, stride=2, padding=1)
47+
self.stage1 = torch.nn.Conv2d(32, 48, 3, stride=2, padding=1)
48+
self.feature_info = [{"num_chs": 32}, {"num_chs": 48}]
49+
50+
def forward(self, x):
51+
s0_cf = self.stage0(x) # [B, 32, H, W]
52+
s1_cf = self.stage1(s0_cf) # [B, 48, H, W]
53+
s0 = s0_cf.permute(0, 2, 3, 1).contiguous() # [B, H, W, 32]
54+
s1 = s1_cf.permute(0, 2, 3, 1).contiguous() # [B, H, W, 48]
55+
return [s0, s1]
56+
57+
58+
class TestBackboneChannelDiscovery:
59+
"""Cover the backbone_channels discovery + layout-detection in Backbone."""
60+
61+
def _make_backbone(self, monkeypatch, stub_module):
62+
# Patch the registry call so build_backbone returns our stub.
63+
monkeypatch.setattr(
64+
"model_components.backbone.build_backbone",
65+
lambda *a, **kw: stub_module,
66+
)
67+
return Backbone(backbone="stub", is_pretrained=False)
68+
69+
def test_feature_info_path_sums_channels(self, monkeypatch):
70+
bb = self._make_backbone(monkeypatch, _StubBackboneWithFeatureInfo())
71+
assert bb.backbone_channels == 32 + 48 + 64
72+
73+
def test_probe_fallback_when_feature_info_missing(self, monkeypatch):
74+
bb = self._make_backbone(monkeypatch, _StubBackboneNoFeatureInfo())
75+
# No feature_info — channels recovered via probing.
76+
assert bb.backbone_channels == 24 + 56 + 112
77+
78+
def test_feature_info_channels_match_forward_output(self, monkeypatch, device):
79+
"""sum(feature_info channels) must equal the actual concat-channel dim
80+
of the forward output."""
81+
bb = self._make_backbone(monkeypatch, _StubBackboneWithFeatureInfo()).to(device)
82+
x = torch.randn(2, 3, 32, 32, device=device)
83+
feats = bb(x)
84+
total_c = sum(f.shape[1] for f in feats)
85+
assert total_c == bb.backbone_channels
86+
87+
def test_probe_channels_match_forward_output(self, monkeypatch, device):
88+
bb = self._make_backbone(monkeypatch, _StubBackboneNoFeatureInfo()).to(device)
89+
x = torch.randn(2, 3, 32, 32, device=device)
90+
feats = bb(x)
91+
total_c = sum(f.shape[1] for f in feats)
92+
assert total_c == bb.backbone_channels
93+
94+
def test_channels_last_backbone_is_permuted(self, monkeypatch, device):
95+
"""Channels-last (B, H, W, C) output must be permuted to (B, C, H, W)
96+
based on tensor shape, NOT on the backbone name."""
97+
bb = self._make_backbone(monkeypatch, _StubBackboneSwinLike()).to(device)
98+
x = torch.randn(2, 3, 32, 32, device=device)
99+
feats = bb(x)
100+
# After Backbone.forward, every feature must be channels-first with the
101+
# expected channel count at dim 1.
102+
assert feats[0].shape[1] == 32
103+
assert feats[1].shape[1] == 48
104+
105+
def test_channels_first_backbone_not_permuted(self, monkeypatch, device):
106+
bb = self._make_backbone(monkeypatch, _StubBackboneWithFeatureInfo()).to(device)
107+
x = torch.randn(2, 3, 32, 32, device=device)
108+
feats = bb(x)
109+
for f, expected in zip(feats, [32, 48, 64]):
110+
assert f.shape[1] == expected

Model/tests/test_feature_fusion.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import torch
2+
import sys
3+
sys.path.append('..')
4+
5+
from model_components.feature_fusion import FeatureFusion
6+
7+
8+
class TestFeatureFusionComponent:
9+
def test_output_shape(self, device):
10+
fusion = FeatureFusion(num_views=8, fusion_mode="concat").to(device)
11+
features = [
12+
torch.randn(16, 96, 64, 64, device=device),
13+
torch.randn(16, 192, 32, 32, device=device),
14+
torch.randn(16, 384, 16, 16, device=device),
15+
torch.randn(16, 768, 8, 8, device=device),
16+
]
17+
out = fusion(features, B=2, V=8)
18+
assert out.shape == (2, 256, 8, 8)
19+
20+
def test_view_reduction_changes_output(self, device):
21+
"""Verify that view_reduce is not identity (actually mixes views)."""
22+
fusion = FeatureFusion(num_views=8, fusion_mode="concat").to(device)
23+
fusion.eval()
24+
25+
features_a = [
26+
torch.randn(8, 96, 64, 64, device=device),
27+
torch.randn(8, 192, 32, 32, device=device),
28+
torch.randn(8, 384, 16, 16, device=device),
29+
torch.randn(8, 768, 8, 8, device=device),
30+
]
31+
out_a = fusion(features_a, B=1, V=8)
32+
33+
features_b = [f.clone() for f in features_a]
34+
features_b[0][3] = torch.randn_like(features_b[0][3])
35+
out_b = fusion(features_b, B=1, V=8)
36+
37+
assert not torch.allclose(out_a, out_b, atol=1e-5)
38+
39+
40+
class TestFeatureFusionWithSwinChannels:
41+
def test_dynamic_backbone_channels_with_swin_sizes(self, device):
42+
"""FeatureFusion should accept Swin's per-stage channels (96, 192, 384, 768)
43+
at their natural spatial resolutions and produce the expected fused shape."""
44+
backbone_channels = 96 + 192 + 384 + 768 # 1440
45+
fusion = FeatureFusion(
46+
num_views=8, backbone_channels=backbone_channels, fusion_mode="concat",
47+
).to(device)
48+
49+
# Per-stage Swin spatial dims for a 256x256 input
50+
features = [
51+
torch.randn(16, 96, 64, 64, device=device),
52+
torch.randn(16, 192, 32, 32, device=device),
53+
torch.randn(16, 384, 16, 16, device=device),
54+
torch.randn(16, 768, 8, 8, device=device),
55+
]
56+
out = fusion(features, B=2, V=8)
57+
assert out.shape == (2, 256, 8, 8)
58+
assert torch.isfinite(out).all()

Model/tests/test_future_state.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import torch
2+
import sys
3+
sys.path.append('..')
4+
5+
from model_components.future_state import FutureState
6+
7+
8+
class TestFutureStateComponent:
9+
def test_accepts_ego_hidden(self, device):
10+
future = FutureState(embed_dim=256, ego_hidden_dim=256).to(device)
11+
feats = torch.randn(2, 256, 8, 8, device=device)
12+
ego_hidden = torch.randn(2, 256, device=device)
13+
out = future(feats, ego_hidden)
14+
assert len(out) == 4
15+
for f in out:
16+
assert f.shape == (2, 256, 8, 8)
17+
18+
def test_ego_hidden_influences_output(self, device):
19+
future = FutureState(embed_dim=256, ego_hidden_dim=256).to(device)
20+
future.eval()
21+
feats = torch.randn(1, 256, 8, 8, device=device)
22+
23+
out_a = future(feats, torch.randn(1, 256, device=device))
24+
out_b = future(feats, torch.randn(1, 256, device=device))
25+
26+
assert not torch.allclose(out_a[0], out_b[0], atol=1e-5), \
27+
"ego_hidden should influence future predictions"
28+
29+
30+
class TestFutureStateChunkSplit:
31+
def test_four_outputs_are_distinct(self, device):
32+
"""torch.chunk must split along channels, not return 4 views of the same data."""
33+
torch.manual_seed(0)
34+
future = FutureState(embed_dim=256, ego_hidden_dim=256).to(device)
35+
future.eval()
36+
feats = torch.randn(2, 256, 8, 8, device=device)
37+
ego_hidden = torch.randn(2, 256, device=device)
38+
39+
out = future(feats, ego_hidden)
40+
assert len(out) == 4
41+
for i in range(4):
42+
for j in range(i + 1, 4):
43+
assert not torch.allclose(out[i], out[j], atol=1e-5), \
44+
f"FutureState outputs {i} and {j} are identical — chunk is broken"
45+
46+
def test_ego_hidden_changes_all_four_outputs(self, device):
47+
"""Different ego_hidden must shift every one of the 4 future predictions."""
48+
torch.manual_seed(0)
49+
future = FutureState(embed_dim=256, ego_hidden_dim=256).to(device)
50+
future.eval()
51+
feats = torch.randn(1, 256, 8, 8, device=device)
52+
53+
ego_a = torch.randn(1, 256, device=device)
54+
ego_b = torch.randn(1, 256, device=device)
55+
56+
out_a = future(feats, ego_a)
57+
out_b = future(feats, ego_b)
58+
59+
for i in range(4):
60+
assert not torch.allclose(out_a[i], out_b[i], atol=1e-5), \
61+
f"Future output {i} did not change when ego_hidden changed"

Model/tests/test_integration.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import pytest
2+
import torch
3+
import sys
4+
sys.path.append('..')
5+
6+
7+
def make_inputs(batch_size, num_views, device, include_camera_params=False):
8+
visual = torch.randn(batch_size, num_views, 3, 256, 256, device=device)
9+
map_input = torch.randn(batch_size, 3, 256, 256, device=device)
10+
visual_history = torch.randn(batch_size, 896, device=device)
11+
egomotion = torch.randn(batch_size, 256, device=device)
12+
if include_camera_params:
13+
camera_params = torch.randn(batch_size, num_views, 3, 4, device=device)
14+
return visual, map_input, visual_history, egomotion, camera_params
15+
return visual, map_input, visual_history, egomotion
16+
17+
18+
# ---------------------------------------------------------------------------
19+
# Integration tests — full backbone (slow, marked for separate CI tier)
20+
# ---------------------------------------------------------------------------
21+
22+
@pytest.mark.integration
23+
class TestFullBackboneIntegration:
24+
"""End-to-end tests with the real pretrained backbone.
25+
26+
These verify that the full pipeline (backbone → fusion → planner → future)
27+
produces correct shapes and numerically stable outputs. Run separately
28+
from unit tests via: pytest -m integration
29+
"""
30+
31+
def test_full_forward_pass(self, full_model, device):
32+
"""Smoke test: full model forward produces expected output shapes."""
33+
visual, map_input, vis_hist, ego = make_inputs(1, 7, device)
34+
target = torch.randn(1, 128, device=device)
35+
loss, ego_hidden, future = full_model(
36+
visual, map_input, vis_hist, ego,
37+
mode="train", trajectory_target=target)
38+
39+
assert loss.dim() == 0
40+
assert ego_hidden.shape == (1, 256)
41+
assert len(future) == 4
42+
for f in future:
43+
assert f.shape == (1, 256, 8, 8)
44+
45+
traj, _, _ = full_model(visual, vis_hist, ego, mode="infer")
46+
assert traj.shape == (1, 128)
47+
48+
def test_full_forward_no_nan(self, full_model, device):
49+
"""Full pipeline must not produce NaN with real backbone weights."""
50+
visual, map_input, vis_hist, ego = make_inputs(2, 7, device)
51+
target = torch.randn(2, 128, device=device)
52+
loss, ego_hidden, future = full_model(visual, map_input, vis_hist, ego,
53+
mode="train", trajectory_target=target)
54+
55+
assert not torch.isnan(loss)
56+
assert not torch.isnan(ego_hidden).any()
57+
for f in future:
58+
assert not torch.isnan(f).any()
59+
60+
61+
@pytest.mark.integration
62+
class TestResNet50Backbone:
63+
"""Exercises the dynamic backbone_channels computation on a backbone
64+
whose feature_info shape differs from Swin (5 stages of channels
65+
64/256/512/1024/2048 vs Swin's 4 stages of 96/192/384/768)."""
66+
67+
def test_resnet50_forward_pass(self, device):
68+
from model_components.auto_e2e import AutoE2E
69+
try:
70+
model = AutoE2E(
71+
backbone="res_net_50", num_views=7, fusion_mode="concat",
72+
is_pretrained=False,
73+
).to(device)
74+
except (FileNotFoundError, OSError) as e:
75+
pytest.skip(f"Backbone construction failed: {e}")
76+
77+
# Dynamic backbone_channels = sum of all 5 ResNet50 stages = 3904
78+
assert model.Backbone.backbone_channels == 64 + 256 + 512 + 1024 + 2048
79+
80+
visual, map_input, vis_hist, ego = make_inputs(1, 7, device)
81+
target = torch.randn(1, 128, device=device)
82+
loss, ego_hidden, future = model(
83+
visual, map_input, vis_hist, ego, mode="train", trajectory_target=target)
84+
85+
assert loss.dim() == 0
86+
assert ego_hidden.shape == (1, 256)
87+
assert len(future) == 4
88+
for f in future:
89+
assert f.shape == (1, 256, 8, 8)
90+
assert torch.isfinite(loss)
91+
assert torch.isfinite(ego_hidden).all()
92+
93+
traj, _, _ = model(visual, vis_hist, ego, mode="infer")
94+
assert traj.shape == (1, 128)
95+
assert torch.isfinite(traj).all()

0 commit comments

Comments
 (0)