rf-detr-temporal / tests /models /test_builder_characterization.py
bongkj's picture
Upload RF-DETR-Temporal code and model card
b08d258 verified
Raw
History Blame Contribute Delete
17.9 kB
# ------------------------------------------------------------------------
# RF-DETR
# Copyright (c) 2025 Roboflow. All Rights Reserved.
# Licensed under the Apache License, Version 2.0 [see LICENSE for details]
# ------------------------------------------------------------------------
"""Characterization tests for build_model() and build_criterion_and_postprocessors().
These tests pin the current behavior of the legacy namespace-based builder
functions. They serve as a safety net during the config-native builder
refactoring: any change that alters these outputs is a regression.
All tests in this file must pass against the CURRENT codebase.
"""
import pytest
import torch
from rfdetr._namespace import _namespace_from_configs
from rfdetr.config import (
RFDETRBaseConfig,
RFDETRNanoConfig,
RFDETRSegNanoConfig,
SegmentationTrainConfig,
TrainConfig,
)
from rfdetr.models.criterion import SetCriterion
from rfdetr.models.lwdetr import LWDETR, build_criterion_and_postprocessors, build_model
from rfdetr.models.postprocess import PostProcess
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_ns(mc=None, tc=None):
"""Build a namespace suitable for builder functions."""
mc = mc or RFDETRBaseConfig(num_classes=80, pretrain_weights=None, device="cpu")
tc = tc or TrainConfig(dataset_dir="/tmp")
return _namespace_from_configs(mc, tc)
# ---------------------------------------------------------------------------
# build_model characterization
# ---------------------------------------------------------------------------
class TestBuildModelCharacterization:
"""Pin current build_model() behaviour for the standard code path."""
def test_returns_lwdetr_instance(self) -> None:
ns = _make_ns()
model = build_model(ns)
assert isinstance(model, LWDETR)
def test_num_classes_plus_one(self) -> None:
"""build_model applies the +1 background class convention."""
mc = RFDETRBaseConfig(num_classes=5, pretrain_weights=None, device="cpu")
ns = _make_ns(mc=mc)
model = build_model(ns)
assert model.class_embed.out_features == 6
def test_num_queries_forwarded(self) -> None:
mc = RFDETRBaseConfig(num_classes=80, pretrain_weights=None, device="cpu")
ns = _make_ns(mc=mc)
model = build_model(ns)
assert model.num_queries == mc.num_queries
@pytest.mark.parametrize(
"config_class, expected_queries",
[
pytest.param(RFDETRBaseConfig, 300, id="base"),
pytest.param(RFDETRNanoConfig, 300, id="nano"),
pytest.param(RFDETRSegNanoConfig, 100, id="seg_nano"),
],
)
def test_num_queries_per_config_variant(self, config_class, expected_queries) -> None:
mc = config_class(pretrain_weights=None, device="cpu")
ns = _make_ns(mc=mc)
model = build_model(ns)
assert model.num_queries == expected_queries
def test_segmentation_head_none_for_detection(self) -> None:
ns = _make_ns()
model = build_model(ns)
assert model.segmentation_head is None
def test_segmentation_head_present_for_seg_config(self) -> None:
mc = RFDETRSegNanoConfig(pretrain_weights=None, device="cpu")
ns = _make_ns(mc=mc)
model = build_model(ns)
assert model.segmentation_head is not None
def test_aux_loss_enabled_by_default(self) -> None:
ns = _make_ns()
model = build_model(ns)
assert model.aux_loss is True
def test_group_detr_forwarded(self) -> None:
mc = RFDETRBaseConfig(num_classes=80, pretrain_weights=None, device="cpu")
ns = _make_ns(mc=mc)
model = build_model(ns)
assert model.group_detr == mc.group_detr
def test_num_feature_levels_set_on_args(self) -> None:
"""build_model mutates args.num_feature_levels = len(projector_scale)."""
mc = RFDETRBaseConfig(num_classes=80, pretrain_weights=None, device="cpu")
ns = _make_ns(mc=mc)
build_model(ns)
assert ns.num_feature_levels == len(mc.projector_scale)
@pytest.mark.parametrize(
"config_class, expected_param_count_range",
[
pytest.param(RFDETRBaseConfig, (25_000_000, 40_000_000), id="base"),
pytest.param(RFDETRNanoConfig, (25_000_000, 40_000_000), id="nano"),
],
)
def test_param_count_in_expected_range(self, config_class, expected_param_count_range) -> None:
"""Sanity check that the model has a plausible number of parameters."""
mc = config_class(num_classes=80, pretrain_weights=None, device="cpu")
ns = _make_ns(mc=mc)
model = build_model(ns)
total = sum(p.numel() for p in model.parameters())
low, high = expected_param_count_range
assert low <= total <= high, f"Expected param count in [{low}, {high}], got {total}"
def test_encoder_only_returns_triple(self) -> None:
"""When encoder_only=True, build_model returns (encoder, None, None)."""
mc = RFDETRBaseConfig(num_classes=80, pretrain_weights=None, device="cpu")
ns = _make_ns(mc=mc)
ns.encoder_only = True
result = build_model(ns)
assert isinstance(result, tuple), f"Expected tuple, got {type(result)}"
assert len(result) == 3
encoder, second, third = result
assert second is None
assert third is None
assert encoder is not None
def test_backbone_only_returns_triple(self) -> None:
"""When backbone_only=True, build_model returns (backbone, None, None)."""
mc = RFDETRBaseConfig(num_classes=80, pretrain_weights=None, device="cpu")
ns = _make_ns(mc=mc)
ns.backbone_only = True
result = build_model(ns)
assert isinstance(result, tuple), f"Expected tuple, got {type(result)}"
assert len(result) == 3
backbone, second, third = result
assert second is None
assert third is None
assert backbone is not None
# ---------------------------------------------------------------------------
# build_criterion_and_postprocessors characterization
# ---------------------------------------------------------------------------
class TestBuildCriterionCharacterization:
"""Pin current build_criterion_and_postprocessors() behaviour."""
def test_returns_criterion_and_postprocess(self) -> None:
ns = _make_ns()
criterion, postprocess = build_criterion_and_postprocessors(ns)
assert isinstance(criterion, SetCriterion)
assert isinstance(postprocess, PostProcess)
def test_detection_losses_list(self) -> None:
"""Detection-only config has exactly ['labels', 'boxes', 'cardinality']."""
ns = _make_ns()
criterion, _ = build_criterion_and_postprocessors(ns)
assert criterion.losses == ["labels", "boxes", "cardinality"]
def test_segmentation_losses_include_masks(self) -> None:
mc = RFDETRSegNanoConfig(pretrain_weights=None, device="cpu")
tc = SegmentationTrainConfig(dataset_dir="/tmp")
ns = _make_ns(mc=mc, tc=tc)
criterion, _ = build_criterion_and_postprocessors(ns)
assert "masks" in criterion.losses
def test_num_select_forwarded_to_postprocess(self) -> None:
mc = RFDETRSegNanoConfig(pretrain_weights=None, device="cpu")
ns = _make_ns(mc=mc)
_, postprocess = build_criterion_and_postprocessors(ns)
assert postprocess.num_select == 100
def test_num_select_default_for_base(self) -> None:
ns = _make_ns()
_, postprocess = build_criterion_and_postprocessors(ns)
assert postprocess.num_select == 300
def test_weight_dict_contains_base_losses(self) -> None:
ns = _make_ns()
criterion, _ = build_criterion_and_postprocessors(ns)
assert "loss_ce" in criterion.weight_dict
assert "loss_bbox" in criterion.weight_dict
assert "loss_giou" in criterion.weight_dict
def test_weight_dict_values_match_namespace(self) -> None:
ns = _make_ns()
criterion, _ = build_criterion_and_postprocessors(ns)
assert criterion.weight_dict["loss_ce"] == ns.cls_loss_coef
assert criterion.weight_dict["loss_bbox"] == ns.bbox_loss_coef
assert criterion.weight_dict["loss_giou"] == ns.giou_loss_coef
def test_segmentation_weight_dict_contains_mask_losses(self) -> None:
mc = RFDETRSegNanoConfig(pretrain_weights=None, device="cpu")
tc = SegmentationTrainConfig(dataset_dir="/tmp")
ns = _make_ns(mc=mc, tc=tc)
criterion, _ = build_criterion_and_postprocessors(ns)
assert "loss_mask_ce" in criterion.weight_dict
assert "loss_mask_dice" in criterion.weight_dict
def test_aux_loss_expands_weight_dict(self) -> None:
"""With aux_loss=True and 3 dec_layers, weight_dict has aux entries _0 and _1."""
mc = RFDETRBaseConfig(num_classes=80, pretrain_weights=None, device="cpu")
ns = _make_ns(mc=mc)
assert ns.aux_loss is True
criterion, _ = build_criterion_and_postprocessors(ns)
# dec_layers=3 -> 2 aux layers (0 and 1)
assert "loss_ce_0" in criterion.weight_dict
assert "loss_ce_1" in criterion.weight_dict
def test_two_stage_adds_enc_losses(self) -> None:
"""With two_stage=True, weight_dict has '_enc' suffix entries."""
mc = RFDETRBaseConfig(num_classes=80, pretrain_weights=None, device="cpu")
ns = _make_ns(mc=mc)
assert ns.two_stage is True
criterion, _ = build_criterion_and_postprocessors(ns)
assert "loss_ce_enc" in criterion.weight_dict
assert "loss_bbox_enc" in criterion.weight_dict
assert "loss_giou_enc" in criterion.weight_dict
def test_criterion_num_classes_plus_one(self) -> None:
mc = RFDETRBaseConfig(num_classes=5, pretrain_weights=None, device="cpu")
ns = _make_ns(mc=mc)
criterion, _ = build_criterion_and_postprocessors(ns)
assert criterion.num_classes == 6
def test_focal_alpha_forwarded(self) -> None:
ns = _make_ns()
criterion, _ = build_criterion_and_postprocessors(ns)
assert criterion.focal_alpha == pytest.approx(0.25)
def test_group_detr_forwarded_to_criterion(self) -> None:
mc = RFDETRBaseConfig(num_classes=80, pretrain_weights=None, device="cpu")
ns = _make_ns(mc=mc)
criterion, _ = build_criterion_and_postprocessors(ns)
assert criterion.group_detr == mc.group_detr
def test_segmentation_criterion_has_mask_point_sample_ratio(self) -> None:
mc = RFDETRSegNanoConfig(pretrain_weights=None, device="cpu")
tc = SegmentationTrainConfig(dataset_dir="/tmp")
ns = _make_ns(mc=mc, tc=tc)
criterion, _ = build_criterion_and_postprocessors(ns)
assert criterion.mask_point_sample_ratio == 16
def test_ia_bce_loss_forwarded(self) -> None:
mc = RFDETRBaseConfig(num_classes=80, pretrain_weights=None, device="cpu")
ns = _make_ns(mc=mc)
criterion, _ = build_criterion_and_postprocessors(ns)
assert criterion.ia_bce_loss == mc.ia_bce_loss
# ---------------------------------------------------------------------------
# _build_model_context characterization
# ---------------------------------------------------------------------------
class TestBuildModelContextCharacterization:
"""Pin current _build_model_context() behaviour.
_build_model_context is the inference-path factory used by RFDETR.get_model().
It has zero test coverage today.
"""
def test_returns_model_context(self) -> None:
from rfdetr.detr import ModelContext, _build_model_context
mc = RFDETRBaseConfig(num_classes=80, pretrain_weights=None, device="cpu")
ctx = _build_model_context(mc)
assert isinstance(ctx, ModelContext)
def test_model_is_lwdetr(self) -> None:
from rfdetr.detr import _build_model_context
mc = RFDETRBaseConfig(num_classes=80, pretrain_weights=None, device="cpu")
ctx = _build_model_context(mc)
assert isinstance(ctx.model, LWDETR)
def test_postprocess_is_postprocess(self) -> None:
from rfdetr.detr import _build_model_context
mc = RFDETRBaseConfig(num_classes=80, pretrain_weights=None, device="cpu")
ctx = _build_model_context(mc)
assert isinstance(ctx.postprocess, PostProcess)
def test_resolution_from_config(self) -> None:
from rfdetr.detr import _build_model_context
mc = RFDETRBaseConfig(num_classes=80, pretrain_weights=None, device="cpu")
ctx = _build_model_context(mc)
assert ctx.resolution == mc.resolution
def test_device_from_config(self) -> None:
from rfdetr.detr import _build_model_context
mc = RFDETRBaseConfig(num_classes=80, pretrain_weights=None, device="cpu")
ctx = _build_model_context(mc)
assert ctx.device == torch.device("cpu")
def test_torch_device_cpu_from_config(self) -> None:
from rfdetr.detr import _build_model_context
mc = RFDETRBaseConfig(num_classes=80, pretrain_weights=None, device=torch.device("cpu"))
ctx = _build_model_context(mc)
assert ctx.device == torch.device("cpu")
def test_class_names_none_without_pretrain(self) -> None:
from rfdetr.detr import _build_model_context
mc = RFDETRBaseConfig(num_classes=80, pretrain_weights=None, device="cpu")
ctx = _build_model_context(mc)
assert ctx.class_names is None
def test_num_select_on_postprocess(self) -> None:
from rfdetr.detr import _build_model_context
mc = RFDETRSegNanoConfig(pretrain_weights=None, device="cpu")
ctx = _build_model_context(mc)
assert ctx.postprocess.num_select == 100
def test_args_namespace_attached(self) -> None:
from rfdetr.detr import _build_model_context
mc = RFDETRBaseConfig(num_classes=80, pretrain_weights=None, device="cpu")
ctx = _build_model_context(mc)
assert hasattr(ctx.args, "num_classes")
assert hasattr(ctx.args, "num_select")
def test_inference_model_initially_none(self) -> None:
from rfdetr.detr import _build_model_context
mc = RFDETRBaseConfig(num_classes=80, pretrain_weights=None, device="cpu")
ctx = _build_model_context(mc)
assert ctx.inference_model is None
# ---------------------------------------------------------------------------
# RFDETRModelModule.__init__ characterization
# ---------------------------------------------------------------------------
class TestRFDETRModelModuleInitCharacterization:
"""Pin RFDETRModelModule.__init__() structural outputs.
The existing test_module_model.py tests the init via mocked build_model and
build_namespace. These tests exercise the REAL init path (no mocks) to
characterize what a freshly built module looks like.
"""
def _make_module(self, mc=None, tc=None):
from rfdetr.training.module_model import RFDETRModelModule
mc = mc or RFDETRBaseConfig(num_classes=5, pretrain_weights=None, device="cpu")
tc = tc or TrainConfig(dataset_dir="/tmp")
return RFDETRModelModule(mc, tc)
def test_model_attribute_is_lwdetr(self) -> None:
module = self._make_module()
# model could be wrapped by torch.compile, so check the underlying type
underlying = getattr(module.model, "_orig_mod", module.model)
assert isinstance(underlying, LWDETR)
def test_criterion_is_set_criterion(self) -> None:
module = self._make_module()
assert isinstance(module.criterion, SetCriterion)
def test_postprocess_is_postprocess(self) -> None:
module = self._make_module()
assert isinstance(module.postprocess, PostProcess)
def test_strict_loading_false(self) -> None:
"""strict_loading=False allows partial state-dict loading."""
module = self._make_module()
assert module.strict_loading is False
def test_configs_stored(self) -> None:
mc = RFDETRBaseConfig(num_classes=5, pretrain_weights=None, device="cpu")
tc = TrainConfig(dataset_dir="/tmp")
module = self._make_module(mc=mc, tc=tc)
assert module.model_config is mc
assert module.train_config is tc
def test_criterion_num_classes_matches_model(self) -> None:
"""Criterion and model must agree on num_classes (both use +1 convention)."""
mc = RFDETRBaseConfig(num_classes=5, pretrain_weights=None, device="cpu")
module = self._make_module(mc=mc)
underlying = getattr(module.model, "_orig_mod", module.model)
assert module.criterion.num_classes == underlying.class_embed.out_features
def test_postprocess_num_select_matches_config(self) -> None:
mc = RFDETRSegNanoConfig(pretrain_weights=None, device="cpu")
tc = SegmentationTrainConfig(dataset_dir="/tmp")
module = self._make_module(mc=mc, tc=tc)
assert module.postprocess.num_select == mc.num_select
def test_segmentation_criterion_with_seg_config(self) -> None:
mc = RFDETRSegNanoConfig(pretrain_weights=None, device="cpu")
tc = SegmentationTrainConfig(dataset_dir="/tmp")
module = self._make_module(mc=mc, tc=tc)
assert "masks" in module.criterion.losses