# ------------------------------------------------------------------------ # RF-DETR # Copyright (c) 2025 Roboflow. All Rights Reserved. # Licensed under the Apache License, Version 2.0 [see LICENSE for details] # ------------------------------------------------------------------------ """Unit tests for COCOEvalCallback (PTL Ch3/T3).""" from unittest.mock import MagicMock, patch import numpy as np import pytest import torch from rfdetr.training.callbacks.coco_eval import COCOEvalCallback # --------------------------------------------------------------------------- # Shared helpers # --------------------------------------------------------------------------- def _make_pl_module() -> MagicMock: """Return a minimal mock LightningModule.""" return MagicMock(name="pl_module") def _make_trainer(datamodule=None, callbacks: list[object] | None = None) -> MagicMock: """Return a minimal mock Trainer with an optional DataModule.""" trainer = MagicMock(name="trainer") trainer.datamodule = datamodule trainer.callbacks = callbacks or [] return trainer def _detection_preds(n: int = 0) -> list[dict]: """Return a list with one per-image prediction dict.""" return [ { "boxes": torch.zeros(n, 4), "scores": torch.zeros(n), "labels": torch.zeros(n, dtype=torch.long), } ] def _detection_targets(cx=0.5, cy=0.5, w=0.1, h=0.1, label=1) -> list[dict]: """Return a single-image target dict with one box in normalised CxCyWH.""" return [ { "boxes": torch.tensor([[cx, cy, w, h]]), "labels": torch.tensor([label]), "orig_size": torch.tensor([100, 200]), # H=100, W=200 } ] def _minimal_metrics(pfx: str = "", max_dets: int = 500) -> dict: """Return a minimal torchmetrics-style metrics dict.""" return { f"{pfx}map": torch.tensor(0.4), f"{pfx}map_50": torch.tensor(0.6), f"{pfx}map_75": torch.tensor(0.3), f"{pfx}mar_{max_dets}": torch.tensor(0.5), } # --------------------------------------------------------------------------- # Tests # --------------------------------------------------------------------------- class TestSetup: """setup() creates map_metric with correct configuration.""" def test_init_defaults_notebook_flag_to_false_without_ipython(self) -> None: """Constructor sets _in_notebook=False when IPython import is unavailable.""" original_import = __import__ def _import_with_missing_ipython(name: str, *args, **kwargs): if name == "IPython": raise ImportError("IPython not installed") return original_import(name, *args, **kwargs) with patch("builtins.__import__", side_effect=_import_with_missing_ipython): cb = COCOEvalCallback(in_notebook=None) assert cb._in_notebook is False def test_detection_iou_type_is_bbox(self) -> None: """Detection mode uses iou_type='bbox'.""" cb = COCOEvalCallback(max_dets=300, segmentation=False) cb.setup(_make_trainer(), _make_pl_module(), stage="fit") assert "bbox" in cb.map_metric.iou_type assert "segm" not in cb.map_metric.iou_type def test_detection_max_detection_thresholds(self) -> None: """max_dets is forwarded to max_detection_thresholds.""" cb = COCOEvalCallback(max_dets=300, segmentation=False) cb.setup(_make_trainer(), _make_pl_module(), stage="fit") assert 300 in cb.map_metric.max_detection_thresholds def test_segmentation_iou_type_includes_segm(self) -> None: """Segmentation mode uses iou_type=['bbox','segm'].""" cb = COCOEvalCallback(segmentation=True) cb.setup(_make_trainer(), _make_pl_module(), stage="fit") assert "segm" in cb.map_metric.iou_type def test_map_metric_created_on_every_setup_call(self) -> None: """Repeated setup() calls replace map_metric (idempotent).""" cb = COCOEvalCallback() trainer, module = _make_trainer(), _make_pl_module() cb.setup(trainer, module, stage="fit") first = cb.map_metric cb.setup(trainer, module, stage="validate") assert cb.map_metric is not first def test_detection_uses_faster_coco_eval_backend(self) -> None: """Detection mode always uses faster_coco_eval backend to avoid map=-1 bug.""" cb = COCOEvalCallback(segmentation=False) cb.setup(_make_trainer(), _make_pl_module(), stage="fit") assert cb.map_metric._coco_backend.backend == "faster_coco_eval" def test_segmentation_uses_faster_coco_eval_backend(self) -> None: """Segmentation mode always uses faster_coco_eval backend.""" cb = COCOEvalCallback(segmentation=True) cb.setup(_make_trainer(), _make_pl_module(), stage="fit") assert cb.map_metric._coco_backend.backend == "faster_coco_eval" class TestOnFitStart: """on_fit_start() populates class names from the datamodule.""" def test_class_names_loaded_from_datamodule(self) -> None: """Class names are taken from trainer.datamodule.class_names.""" dm = MagicMock() dm.class_names = ["cat", "dog"] cb = COCOEvalCallback() cb.on_fit_start(_make_trainer(datamodule=dm), _make_pl_module()) assert cb._class_names == ["cat", "dog"] def test_no_datamodule_leaves_class_names_empty(self) -> None: """Absent datamodule keeps class_names as empty list.""" trainer = _make_trainer(datamodule=None) cb = COCOEvalCallback() cb.on_fit_start(trainer, _make_pl_module()) assert cb._class_names == [] def test_datamodule_without_class_names_attr_leaves_empty(self) -> None: """DataModule without class_names attr keeps class_names empty.""" dm = MagicMock(spec=[]) # no attributes cb = COCOEvalCallback() cb.on_fit_start(_make_trainer(datamodule=dm), _make_pl_module()) assert cb._class_names == [] def test_cat_id_to_name_uses_label2cat_when_available(self) -> None: """When coco.label2cat is present (remap_category_ids=True) the mapping uses 0-based remapped label IDs so class names align with predictions.""" coco = MagicMock() coco.cats = {1: {"name": "fish"}, 2: {"name": "shark"}} # label2cat: remapped_label → original_cat_id (cat2label inverse) coco.label2cat = {0: 1, 1: 2} dataset = MagicMock() dataset.coco = coco dm = MagicMock() dm.class_names = ["fish", "shark"] dm._dataset_val = dataset dm._dataset_train = None cb = COCOEvalCallback() cb.on_fit_start(_make_trainer(datamodule=dm), _make_pl_module()) # 0-based label indices must map to names, not original cat IDs assert cb._cat_id_to_name == {0: "fish", 1: "shark"} def test_cat_id_to_name_falls_back_to_raw_cats_without_label2cat(self) -> None: """Without coco.label2cat (standard COCO), original category IDs are used.""" coco = MagicMock(spec=["cats"]) # no label2cat attribute coco.cats = {1: {"name": "fish"}, 2: {"name": "shark"}} dataset = MagicMock() dataset.coco = coco dm = MagicMock() dm.class_names = ["fish", "shark"] dm._dataset_val = dataset dm._dataset_train = None cb = COCOEvalCallback() cb.on_fit_start(_make_trainer(datamodule=dm), _make_pl_module()) assert cb._cat_id_to_name == {1: "fish", 2: "shark"} @pytest.mark.parametrize( "hook,stage", [ pytest.param("on_validation_batch_end", "fit", id="val"), pytest.param("on_test_batch_end", "test", id="test"), ], ) class TestBatchEndCommon: """map_metric accumulation shared by on_validation_batch_end and on_test_batch_end.""" def test_map_metric_update_called_once_per_batch(self, hook, stage) -> None: """map_metric.update is called exactly once per batch.""" cb = COCOEvalCallback() cb.setup(_make_trainer(), _make_pl_module(), stage=stage) cb.map_metric = MagicMock(name="map_metric") outputs = {"results": _detection_preds(0), "targets": _detection_targets()} getattr(cb, hook)(_make_trainer(), _make_pl_module(), outputs, None, 0) assert cb.map_metric.update.call_count == 1 def test_f1_accumulator_grows_across_batches(self, hook, stage) -> None: """Calling the batch-end hook twice accumulates more GT in F1 state.""" cb = COCOEvalCallback() cb.setup(_make_trainer(), _make_pl_module(), stage=stage) cb.map_metric = MagicMock(name="map_metric") outputs = {"results": _detection_preds(0), "targets": _detection_targets(label=1)} getattr(cb, hook)(_make_trainer(), _make_pl_module(), outputs, None, 0) total_after_1 = sum(v["total_gt"] for v in cb._f1_local.values()) getattr(cb, hook)(_make_trainer(), _make_pl_module(), outputs, None, 1) total_after_2 = sum(v["total_gt"] for v in cb._f1_local.values()) assert total_after_2 == total_after_1 * 2 def test_targets_converted_before_update(self, hook, stage) -> None: """map_metric.update receives targets with absolute xyxy boxes.""" cb = COCOEvalCallback() cb.setup(_make_trainer(), _make_pl_module(), stage=stage) captured = {} def _capture_update(preds, targets): captured["targets"] = targets cb.map_metric = MagicMock(name="map_metric") cb.map_metric.update.side_effect = _capture_update outputs = { "results": _detection_preds(0), "targets": _detection_targets(cx=0.5, cy=0.5, w=0.1, h=0.1), } getattr(cb, hook)(_make_trainer(), _make_pl_module(), outputs, None, 0) # Expected: CxCyWH(0.5,0.5,0.1,0.1) × scale(W=200,H=100) → xyxy(90,45,110,55) boxes = captured["targets"][0]["boxes"] assert boxes.shape == (1, 4) assert boxes[0, 0].item() == pytest.approx(90.0) assert boxes[0, 1].item() == pytest.approx(45.0) assert boxes[0, 2].item() == pytest.approx(110.0) assert boxes[0, 3].item() == pytest.approx(55.0) class TestOnTestBatchEnd: """Test-loop-specific behaviour of on_test_batch_end.""" def test_dataloader_idx_param_has_default(self) -> None: """on_test_batch_end must accept calls with an explicit dataloader_idx.""" cb = COCOEvalCallback() cb.setup(_make_trainer(), _make_pl_module(), stage="test") cb.map_metric = MagicMock(name="map_metric") outputs = {"results": _detection_preds(0), "targets": _detection_targets()} # Must not raise with explicit dataloader_idx=0 cb.on_test_batch_end(_make_trainer(), _make_pl_module(), outputs, None, 0, dataloader_idx=0) @pytest.mark.parametrize( "stage,hook,prefix", [ pytest.param("fit", "on_validation_epoch_end", "val/", id="val"), pytest.param("test", "on_test_epoch_end", "test/", id="test"), ], ) class TestEpochEndCommon: """Metric logging and state reset shared by on_validation_epoch_end and on_test_epoch_end.""" def test_detection_core_metrics_are_logged(self, stage, hook, prefix) -> None: """mAP_50_95, mAP_50, mAP_75, mAR are always logged under the correct prefix.""" cb = COCOEvalCallback(max_dets=500) cb.setup(_make_trainer(), _make_pl_module(), stage=stage) cb.map_metric = MagicMock(name="map_metric") cb.map_metric.compute.return_value = _minimal_metrics() module = _make_pl_module() getattr(cb, hook)(_make_trainer(), module) logged_keys = {c.args[0] for c in module.log.call_args_list} assert f"{prefix}mAP_50_95" in logged_keys assert f"{prefix}mAP_50" in logged_keys assert f"{prefix}mAP_75" in logged_keys assert f"{prefix}mAR" in logged_keys def test_f1_metrics_logged_when_gt_present(self, stage, hook, prefix) -> None: """F1, precision, recall are logged when GT exists.""" cb = COCOEvalCallback() cb.setup(_make_trainer(), _make_pl_module(), stage=stage) cb.map_metric = MagicMock(name="map_metric") cb.map_metric.compute.return_value = _minimal_metrics() cb._f1_local = { 0: { "scores": np.array([0.9], dtype=np.float32), "matches": np.array([1], dtype=np.int64), "ignore": np.array([False]), "total_gt": 1, } } module = _make_pl_module() getattr(cb, hook)(_make_trainer(), module) logged_keys = {c.args[0] for c in module.log.call_args_list} assert f"{prefix}F1" in logged_keys assert f"{prefix}precision" in logged_keys assert f"{prefix}recall" in logged_keys def test_f1_metrics_zero_when_no_gt(self, stage, hook, prefix) -> None: """F1 == 0.0 when no predictions were accumulated (empty epoch).""" cb = COCOEvalCallback() cb.setup(_make_trainer(), _make_pl_module(), stage=stage) cb.map_metric = MagicMock(name="map_metric") cb.map_metric.compute.return_value = _minimal_metrics() module = _make_pl_module() getattr(cb, hook)(_make_trainer(), module) f1_call = next(c for c in module.log.call_args_list if c.args[0] == f"{prefix}F1") assert f1_call.args[1] == pytest.approx(0.0) def test_state_reset_after_epoch(self, stage, hook, prefix) -> None: """map_metric.reset() is called and _f1_local is cleared after epoch end.""" cb = COCOEvalCallback() cb.setup(_make_trainer(), _make_pl_module(), stage=stage) cb.map_metric = MagicMock(name="map_metric") cb.map_metric.compute.return_value = _minimal_metrics() cb._f1_local = { 0: { "scores": np.array([0.9], dtype=np.float32), "matches": np.array([1], dtype=np.int64), "ignore": np.array([False]), "total_gt": 1, } } getattr(cb, hook)(_make_trainer(), _make_pl_module()) cb.map_metric.reset.assert_called_once() assert cb._f1_local == {} def test_segmentation_extra_metrics_logged(self, stage, hook, prefix) -> None: """segm_mAP_50_95 and segm_mAP_50 are logged in segmentation mode.""" cb = COCOEvalCallback(segmentation=True) cb.setup(_make_trainer(), _make_pl_module(), stage=stage) cb.map_metric = MagicMock(name="map_metric") segm_metrics = _minimal_metrics(pfx="bbox_") segm_metrics["segm_map"] = torch.tensor(0.35) segm_metrics["segm_map_50"] = torch.tensor(0.55) cb.map_metric.compute.return_value = segm_metrics module = _make_pl_module() getattr(cb, hook)(_make_trainer(), module) logged_keys = {c.args[0] for c in module.log.call_args_list} assert f"{prefix}segm_mAP_50_95" in logged_keys assert f"{prefix}segm_mAP_50" in logged_keys def test_per_class_ap_logged_when_classes_present(self, stage, hook, prefix) -> None: """AP/ is logged for each class when class metrics are present.""" cb = COCOEvalCallback() cb._class_names = ["cat", "dog"] cb._cat_id_to_name = {0: "cat", 1: "dog"} cb.setup(_make_trainer(), _make_pl_module(), stage=stage) cb.map_metric = MagicMock(name="map_metric") metrics = _minimal_metrics() metrics["map_per_class"] = torch.tensor([0.5, 0.4]) metrics["classes"] = torch.tensor([0, 1]) cb.map_metric.compute.return_value = metrics module = _make_pl_module() getattr(cb, hook)(_make_trainer(), module) logged_keys = {c.args[0] for c in module.log.call_args_list} assert f"{prefix}AP/cat" in logged_keys assert f"{prefix}AP/dog" in logged_keys def test_per_class_ap_falls_back_to_str_id_when_no_class_names(self, stage, hook, prefix) -> None: """AP/ is logged when class_names is empty.""" cb = COCOEvalCallback() cb.setup(_make_trainer(), _make_pl_module(), stage=stage) cb.map_metric = MagicMock(name="map_metric") metrics = _minimal_metrics() metrics["map_per_class"] = torch.tensor([0.5]) metrics["classes"] = torch.tensor([3]) cb.map_metric.compute.return_value = metrics module = _make_pl_module() getattr(cb, hook)(_make_trainer(), module) logged_keys = {c.args[0] for c in module.log.call_args_list} assert f"{prefix}AP/3" in logged_keys class TestOnValidationEpochEnd: """Validation-specific behaviour of on_validation_epoch_end.""" def test_ema_metrics_logged_when_map_metric_ema_populated(self) -> None: """val/ema_* metrics are logged when map_metric_ema has accumulated data. EMA metrics are now computed from a separate map_metric_ema that is populated during on_validation_batch_end (not aliased from base metrics). """ cb = COCOEvalCallback(max_dets=500) cb.setup(_make_trainer(), _make_pl_module(), stage="fit") cb.map_metric = MagicMock(name="map_metric") cb.map_metric.compute.return_value = _minimal_metrics() # Simulate map_metric_ema being populated by on_validation_batch_end. cb.map_metric_ema = MagicMock(name="map_metric_ema") cb.map_metric_ema.compute.return_value = _minimal_metrics() module = _make_pl_module() cb.on_validation_epoch_end(_make_trainer(), module) logged_keys = {c.args[0] for c in module.log.call_args_list} assert "val/ema_mAP_50_95" in logged_keys assert "val/ema_mAP_50" in logged_keys assert "val/ema_mAR" in logged_keys cb.map_metric_ema.reset.assert_called_once() def test_eval_interval_skips_non_matching_epochs(self) -> None: """Validation metric computation is skipped on non-interval epochs.""" cb = COCOEvalCallback(eval_interval=3) trainer = _make_trainer() trainer.current_epoch = 0 # epoch 1 (1-based) is not divisible by 3 trainer.max_epochs = 10 cb.setup(trainer, _make_pl_module(), stage="fit") cb.map_metric = MagicMock(name="map_metric") cb.map_metric.compute.return_value = _minimal_metrics() module = _make_pl_module() cb.on_validation_epoch_end(trainer, module) cb.map_metric.compute.assert_not_called() cb.map_metric.reset.assert_called_once() module.log.assert_not_called() def test_eval_interval_runs_on_matching_epochs(self) -> None: """Validation metric computation runs on interval-aligned epochs.""" cb = COCOEvalCallback(eval_interval=3) trainer = _make_trainer() trainer.current_epoch = 2 # epoch 3 (1-based) is divisible by 3 trainer.max_epochs = 10 cb.setup(trainer, _make_pl_module(), stage="fit") cb.map_metric = MagicMock(name="map_metric") cb.map_metric.compute.return_value = _minimal_metrics() module = _make_pl_module() cb.on_validation_epoch_end(trainer, module) cb.map_metric.compute.assert_called_once() module.log.assert_called() def test_per_class_ap_can_be_disabled(self) -> None: """log_per_class_metrics=False suppresses val/AP/ logging.""" cb = COCOEvalCallback(log_per_class_metrics=False) cb._class_names = ["cat", "dog"] cb._cat_id_to_name = {0: "cat", 1: "dog"} cb.setup(_make_trainer(), _make_pl_module(), stage="fit") cb.map_metric = MagicMock(name="map_metric") metrics = _minimal_metrics() metrics["map_per_class"] = torch.tensor([0.5, 0.4]) metrics["classes"] = torch.tensor([0, 1]) cb.map_metric.compute.return_value = metrics module = _make_pl_module() cb.on_validation_epoch_end(_make_trainer(), module) logged_keys = {c.args[0] for c in module.log.call_args_list} assert not any(k.startswith("val/AP/") for k in logged_keys) def test_callback_metrics_updated_for_model_checkpoint(self) -> None: """Core metrics written to trainer.callback_metrics each epoch so ModelCheckpoint / BestModelCallback detect improvement. pl_module.log() from a callback's on_validation_epoch_end goes only to logged_metrics (external loggers), not callback_metrics. """ cb = COCOEvalCallback(max_dets=500) trainer = _make_trainer() trainer.callback_metrics = {} cb.setup(trainer, _make_pl_module(), stage="fit") cb.map_metric = MagicMock(name="map_metric") cb.map_metric.compute.return_value = _minimal_metrics() cb.on_validation_epoch_end(trainer, _make_pl_module()) assert "val/mAP_50_95" in trainer.callback_metrics assert "val/mAP_50" in trainer.callback_metrics assert "val/mAP_75" in trainer.callback_metrics assert "val/mAR" in trainer.callback_metrics assert trainer.callback_metrics["val/mAP_50_95"].item() == pytest.approx(0.4) assert trainer.callback_metrics["val/mAP_50"].item() == pytest.approx(0.6) def test_callback_metrics_updated_with_ema_when_map_metric_ema_populated(self) -> None: """EMA metrics are written to callback_metrics when map_metric_ema has data.""" cb = COCOEvalCallback(max_dets=500) trainer = _make_trainer() trainer.callback_metrics = {} cb.setup(trainer, _make_pl_module(), stage="fit") cb.map_metric = MagicMock(name="map_metric") cb.map_metric.compute.return_value = _minimal_metrics() cb.map_metric_ema = MagicMock(name="map_metric_ema") cb.map_metric_ema.compute.return_value = _minimal_metrics() cb.on_validation_epoch_end(trainer, _make_pl_module()) assert "val/ema_mAP_50_95" in trainer.callback_metrics assert "val/ema_mAP_50" in trainer.callback_metrics assert "val/ema_mAR" in trainer.callback_metrics def test_ghost_class_with_negative_ar_sentinel_is_filtered(self) -> None: """A class where both ap=-1 and ar=-1 (negative sentinels, not NaN) must be excluded from the per-class table. The old filter checked for NaN only, so ar=-1 (a valid float) escaped the guard.""" cb = COCOEvalCallback() cb._cat_id_to_name = {0: "fish"} trainer = _make_trainer() trainer.callback_metrics = {} cb.setup(trainer, _make_pl_module(), stage="fit") cb.map_metric = MagicMock(name="map_metric") metrics = _minimal_metrics() # class 0 is a real class; class 8 is a ghost with both sentinels = -1 metrics["map_per_class"] = torch.tensor([0.5, -1.0]) metrics["classes"] = torch.tensor([0, 8]) # ar=-1 for ghost (negative sentinel, not NaN) metrics["mar_500_per_class"] = torch.tensor([0.6, -1.0]) cb.map_metric.compute.return_value = metrics module = _make_pl_module() cb.on_validation_epoch_end(trainer, module) logged_keys = {c.args[0] for c in module.log.call_args_list} # real class logged, ghost class suppressed assert "val/AP/fish" in logged_keys assert "val/AP/8" not in logged_keys # --------------------------------------------------------------------------- # Test-epoch-end-only behaviour # --------------------------------------------------------------------------- class TestOnTestEpochEnd: """Test-loop-specific behaviour of on_test_epoch_end.""" def test_no_ema_aliases_for_test(self) -> None: """test/ema_* aliases are NOT logged — test always runs with EMA weights via the RFDETREMACallback swap so test/mAP_50 is already the EMA result.""" cb = COCOEvalCallback(max_dets=500) cb.setup(_make_trainer(), _make_pl_module(), stage="test") cb.map_metric = MagicMock(name="map_metric") cb.map_metric.compute.return_value = _minimal_metrics() module = _make_pl_module() cb.on_test_epoch_end(_make_trainer(), module) logged_keys = {c.args[0] for c in module.log.call_args_list} assert not any(k.startswith("test/ema_") for k in logged_keys) def test_val_prefix_not_logged(self) -> None: """test_epoch_end must not emit val/ keys — prefixes must not bleed across loops.""" cb = COCOEvalCallback(max_dets=500) cb.setup(_make_trainer(), _make_pl_module(), stage="test") cb.map_metric = MagicMock(name="map_metric") cb.map_metric.compute.return_value = _minimal_metrics() module = _make_pl_module() cb.on_test_epoch_end(_make_trainer(), module) logged_keys = {c.args[0] for c in module.log.call_args_list} assert not any(k.startswith("val/") for k in logged_keys) class TestConvertPreds: """_convert_preds() normalizes prediction dicts for metric consumers.""" @pytest.mark.parametrize( ("boxes", "expected_kept_idxs"), [ pytest.param( # Degenerate first -> keep original index 1 (non-zero keep idx). [[2.0, 2.0, 2.0, 4.0], [0.0, 0.0, 3.0, 3.0], [5.0, 5.0, 5.0, 7.0]], [1], id="degenerate-first-keeps-index-1", ), pytest.param( # Degenerate between valid boxes -> keep non-contiguous original indices. [[0.0, 0.0, 3.0, 3.0], [2.0, 2.0, 2.0, 4.0], [4.0, 4.0, 6.0, 6.0]], [0, 2], id="degenerate-middle-keeps-noncontiguous", ), ], ) def test_masks_remain_aligned_with_original_indices_after_degenerate_filtering( self, boxes: list[list[float]], expected_kept_idxs: list[int], ) -> None: """Filtering degenerate boxes must preserve mask alignment via original indices. Regression context: when a degenerate box is not last, keep indices are non-zero/non-contiguous. Downstream filtering must keep masks from the same original prediction indices. """ cb = COCOEvalCallback() # Distinct one-hot masks so index/mask misalignment is easy to detect. masks = torch.zeros(3, 1, 2, 2, dtype=torch.bool) masks[0, 0, 0, 0] = True masks[1, 0, 0, 1] = True masks[2, 0, 1, 0] = True preds = [ { "boxes": torch.tensor(boxes, dtype=torch.float32), "scores": torch.tensor([0.9, 0.8, 0.7], dtype=torch.float32), "labels": torch.tensor([0, 0, 0], dtype=torch.int64), "masks": masks, } ] out = cb._convert_preds(preds) out_boxes = out[0]["boxes"] out_masks = out[0]["masks"] assert out_masks.shape == (3, 2, 2) keep = torch.where((out_boxes[:, 2] > out_boxes[:, 0]) & (out_boxes[:, 3] > out_boxes[:, 1]))[0] assert keep.tolist() == expected_kept_idxs assert torch.equal(out_masks[keep], masks.squeeze(1)[keep]) class TestConvertTargets: """_convert_targets() converts normalised CxCyWH to absolute xyxy.""" def test_box_conversion_known_values(self) -> None: """CxCyWH(0.5,0.5,0.4,0.6) × (W=100,H=200) → xyxy(30,40,70,160).""" cb = COCOEvalCallback() targets = [ { "boxes": torch.tensor([[0.5, 0.5, 0.4, 0.6]]), "labels": torch.tensor([0]), "orig_size": torch.tensor([200, 100]), # H=200, W=100 } ] out = cb._convert_targets(targets) boxes = out[0]["boxes"] # cx=0.5*100=50, cy=0.5*200=100, w=0.4*100=40, h=0.6*200=120 # → x1=50-20=30, y1=100-60=40, x2=50+20=70, y2=100+60=160 assert boxes[0, 0].item() == pytest.approx(30.0) assert boxes[0, 1].item() == pytest.approx(40.0) assert boxes[0, 2].item() == pytest.approx(70.0) assert boxes[0, 3].item() == pytest.approx(160.0) def test_labels_passed_through(self) -> None: """labels tensor is preserved unchanged.""" cb = COCOEvalCallback() targets = [ { "boxes": torch.zeros(1, 4), "labels": torch.tensor([7]), "orig_size": torch.tensor([100, 100]), } ] out = cb._convert_targets(targets) assert out[0]["labels"][0].item() == 7 def test_masks_passed_through_as_bool(self) -> None: """masks tensor is cast to bool and included in output.""" cb = COCOEvalCallback() targets = [ { "boxes": torch.zeros(1, 4), "labels": torch.tensor([0]), "orig_size": torch.tensor([8, 8]), "masks": torch.ones(1, 8, 8, dtype=torch.uint8), } ] out = cb._convert_targets(targets) assert "masks" in out[0] assert out[0]["masks"].dtype == torch.bool def test_iscrowd_passed_through(self) -> None: """iscrowd tensor is included when present.""" cb = COCOEvalCallback() targets = [ { "boxes": torch.zeros(1, 4), "labels": torch.tensor([0]), "orig_size": torch.tensor([100, 100]), "iscrowd": torch.tensor([1]), } ] out = cb._convert_targets(targets) assert "iscrowd" in out[0] assert out[0]["iscrowd"][0].item() == 1 def test_no_masks_no_iscrowd_keys_absent(self) -> None: """Output dict contains exactly boxes and labels when extras are absent.""" cb = COCOEvalCallback() targets = [ { "boxes": torch.zeros(1, 4), "labels": torch.tensor([0]), "orig_size": torch.tensor([100, 100]), } ] out = cb._convert_targets(targets) assert set(out[0].keys()) == {"boxes", "labels"}