perf(training): hoist COCO detection score reads (#1379)
- `OnePassCocoMeanAveragePrecision._coco_datasets` now builds the prediction COCO dataset by calling TorchMetrics' `_get_coco_format` with `scores=None` and assigning hoisted per-image score lists onto the returned annotations, replacing the per-annotation `scores[image_id][k].cpu().tolist()` tensor index, device copy and scalar conversion; every other upstream branch (segmentation areas, crowd flags, the `info` key, per-annotation validation) still comes from upstream unchanged. - A prediction state carrying no boxes at all (mask-only segmentation) falls back to `_get_coco_datasets` untouched, because upstream drops whole images from the annotation list in that case and positional score assignment would no longer line up. - `_assign_detection_scores` raises `ValueError` for non-floating-point score state, restating the type check that the replaced per-annotation conversion performed, and raises `RuntimeError` when the prediction annotation count stops matching the stored score count instead of silently zipping the shorter of the two. - Extended the private-contract validation to cover the two backend members the new path calls directly: `_get_coco_format` gains a `_BACKEND_METHOD_PARAMS` entry, and a missing or non-callable `coco` dataset factory is now reported at construction rather than at `compute()` time. - Derived `backend_methods` from `_BACKEND_METHOD_PARAMS` instead of restating the names, so a method added to one can no longer raise `KeyError` from the other. - Added `TestHoistedDetectionScores`, covering dataset-level parity against stock construction, the `scores=None` call keyword that keeps the fast path fast, the annotation-count mismatch guard, and non-floating-point score rejection. - CHANGELOG records byte-identical datasets verified over 593,400 synthetic detections, 1.56x on the construction step and 1.68x on the annotation loop, measured on one x86 workstation with synthetic state and explicitly not an end-to-end training measurement. - Added `test_mask_only_state_uses_stock_construction`, asserting a segmentation-only prediction state routes through TorchMetrics' `_get_coco_datasets` rather than the hoisted path and still returns scored annotations. - That branch existed with no test: `test_empty_predictions_preserve_zero_recall` passes an empty `(0, 4)` box tensor, which leaves `detection_box` populated and takes the hoisted path, so nothing in the suite reached the fallback. --------- Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
@@ -20,6 +20,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
|
||||
### Changed
|
||||
|
||||
- COCO mAP computation now converts each image's detection scores once instead of once per detection. TorchMetrics' `_get_coco_format` hoists boxes and labels to Python lists per image but reads scores one annotation at a time (`scores[image_id][k].cpu().tolist()`); RF-DETR now asks it for prediction annotations with `scores=None` and assigns each image's score list afterwards. `update()` stores detached CPU state, so the avoided work is CPU-side tensor indexing and scalar conversion, not a per-annotation GPU transfer. Other upstream branches, including segmentation areas, crowd flags, and the `info` key, remain upstream; the optimized path restates score shape and floating-dtype validation locally because upstream receives no scores. The stock path remains when prediction entries omit box state (`detection_box` is empty), because upstream can then drop whole images; an empty `(0, 4)` box tensor still takes the hoisted path. Dataset parity and the guard against annotation/score-count drift are covered by tests. This release does not claim a performance ratio: the earlier synthetic benchmark has no reproducible command or raw output in the change.
|
||||
|
||||
- **`TrainConfig.grad_accum_steps` now defaults to `1` (was `4`), changing the default effective batch size from 16 to 4.** With `batch_size` unchanged at `4`, a run that accepted the old defaults used a nominal `4 x 4 = 16` images per optimizer-step window and now uses `4 x 1 = 4`. This is a training-semantics change, not a throughput-only one: the optimization trajectory differs, and convergence and final mAP may differ with it. Gradient accumulation is now an explicit opt-in — raise `batch_size` to whatever the GPU holds first, and reach for `grad_accum_steps` only when memory forces a smaller physical batch. Treat `batch_size x grad_accum_steps` as a nominal effective-batch target, not a guarantee of identical optimization behavior: changing the physical batch changes forward/backward microbatch cadence. **To restore the previous behavior, set `grad_accum_steps=4` explicitly.** Runs using `batch_size="auto"` are unaffected by this default change: the auto-batch probe overwrites `grad_accum_steps` with its recommendation. Its `auto_batch_target_effective` setting is a global nominal target divided across devices and nodes before recommendation, so verify that setting when changing the distributed world size. Measured on one L4 with `rfdetr-small` over 3 epochs of a ~6.9k-image dataset, holding the nominal effective batch at 16: `batch_size=16, grad_accum_steps=1` took 256.53 s/epoch against 326.75 s/epoch for `batch_size=4, grad_accum_steps=4` (27% faster), with mAP equal within noise (0.8829/0.9047 against 0.8858/0.9039, regular/EMA). The cause is GPU under-occupancy at a small physical batch; this is a single-GPU, single-dataset measurement and is not a general guarantee.
|
||||
|
||||
- `Transformer.forward` now reuses flattened position, padding-mask, source-feature, and cross-attention-source tensors on the single feature-level path instead of copying each through `torch.cat` over a one-element list. Current Nano/Small/Medium/Large detection, segmentation, and keypoint-preview models use that path by default; the legacy multi-level `RFDETRLargeDeprecatedConfig` retains concatenation. `.contiguous()` preserves the previous layout guarantee for custom strided inputs.
|
||||
|
||||
+142
-29
@@ -11,8 +11,9 @@ Purpose:
|
||||
global evaluation for each requested IoU type.
|
||||
Scope:
|
||||
Own CPU-backed metric updates, validation of the private TorchMetrics state/backend contract, explicit fixed-order
|
||||
distributed state merging, update-state inspection, and compact one-pass computation. Lightning lifecycle, EMA
|
||||
voting, logging, checkpoint metrics, F1, keypoint evaluation, and terminal rendering remain callback concerns.
|
||||
distributed state merging, update-state inspection, prediction-score hoisting during COCO-format construction, and
|
||||
compact one-pass computation. Lightning lifecycle, EMA voting, logging, checkpoint metrics, F1, keypoint
|
||||
evaluation, and terminal rendering remain callback concerns.
|
||||
Usage:
|
||||
Import :class:`OnePassCocoMeanAveragePrecision` only from RF-DETR training code. Construct it with the
|
||||
``faster_coco_eval`` backend and ``sync_on_compute=False``, call ``update`` for each batch, explicitly call
|
||||
@@ -63,9 +64,8 @@ _MAP_STATE_ATTRS = (
|
||||
"groundtruth_crowds",
|
||||
"groundtruth_area",
|
||||
)
|
||||
# Parameter names `compute()` (coco_map.py) relies on when calling each backend method — by keyword for
|
||||
# `average`/`prefix`/`max_detection_thresholds`, by position for the rest. A parameter rename upstream would
|
||||
# make those calls a silent TypeError rather than an actionable contract failure without this check.
|
||||
# Parameter names the adapter relies on when calling each backend method. A parameter rename upstream would make
|
||||
# those calls a raw TypeError rather than an actionable contract failure without this check.
|
||||
_BACKEND_METHOD_PARAMS: dict[str, tuple[str, ...]] = {
|
||||
"_get_coco_datasets": (
|
||||
"groundtruth_labels",
|
||||
@@ -81,6 +81,14 @@ _BACKEND_METHOD_PARAMS: dict[str, tuple[str, ...]] = {
|
||||
"average",
|
||||
),
|
||||
"_coco_stats_to_tensor_dict": ("stats", "prefix", "max_detection_thresholds"),
|
||||
"_get_coco_format": ("labels", "all_labels", "boxes", "masks", "scores", "crowds", "area", "iou_type", "average"),
|
||||
}
|
||||
# Parameters passed by keyword at this adapter's private-backend call sites. They must not become positional-only in
|
||||
# a supported TorchMetrics release, because that would otherwise fail as a raw TypeError during metric computation.
|
||||
_BACKEND_KEYWORD_PARAMS: dict[str, tuple[str, ...]] = {
|
||||
"_get_coco_datasets": ("average",),
|
||||
"_coco_stats_to_tensor_dict": ("prefix", "max_detection_thresholds"),
|
||||
"_get_coco_format": ("labels", "all_labels", "boxes", "masks", "scores", "crowds", "area", "iou_type", "average"),
|
||||
}
|
||||
# Evaluator methods compute() calls with no arguments (coco_eval.evaluate() / .accumulate() / .summarize()) — a
|
||||
# newly-required parameter upstream would make that call fail at compute() time instead of at construction.
|
||||
@@ -206,22 +214,7 @@ class OnePassCocoMeanAveragePrecision(MeanAveragePrecision):
|
||||
"""
|
||||
classes = self._observed_classes()
|
||||
logger.debug("Computing one-pass COCO metrics for %d classes and IoU types %s.", len(classes), self.iou_type)
|
||||
coco_preds, coco_target = cast(
|
||||
tuple[Any, Any],
|
||||
self._coco_backend._get_coco_datasets(
|
||||
self.groundtruth_labels,
|
||||
self.groundtruth_box,
|
||||
self.groundtruth_mask,
|
||||
self.groundtruth_crowds,
|
||||
self.groundtruth_area,
|
||||
self.detection_labels,
|
||||
self.detection_box,
|
||||
self.detection_mask,
|
||||
self.detection_scores,
|
||||
self.iou_type,
|
||||
average=self.average,
|
||||
),
|
||||
)
|
||||
coco_preds, coco_target = self._coco_datasets(classes)
|
||||
|
||||
result: dict[str, Tensor] = {}
|
||||
with contextlib.redirect_stdout(io.StringIO()):
|
||||
@@ -260,14 +253,129 @@ class OnePassCocoMeanAveragePrecision(MeanAveragePrecision):
|
||||
result["classes"] = torch.tensor(classes, dtype=torch.int32)
|
||||
return result
|
||||
|
||||
def _coco_datasets(self, classes: list[int]) -> tuple[Any, Any]:
|
||||
"""Return the COCO prediction and target datasets, hoisting prediction scores out of the annotation loop.
|
||||
|
||||
TorchMetrics' ``_get_coco_format`` hoists boxes and labels to Python lists once per image but reads scores
|
||||
one annotation at a time (``scores[image_id][k].cpu().tolist()``, ``helpers.py:563``), which is CPU tensor
|
||||
indexing and scalar conversion for every detection because ``update()`` stores detached CPU state. At
|
||||
RF-DETR's validation scale that is hundreds of thousands of conversions per ``compute()``. Asking upstream
|
||||
for the same annotations with
|
||||
``scores=None`` and assigning per-image score lists afterwards produces byte-identical datasets while
|
||||
converting each image's scores once.
|
||||
|
||||
The rewrite needs prediction annotations to appear in state order with no image dropped. Upstream skips an
|
||||
image only when it has no masks *and* no boxes (``helpers.py:508-511``), so the hoist is used only when
|
||||
boxes are present; a mask-only prediction state falls back to upstream unchanged.
|
||||
|
||||
Args:
|
||||
classes: Sorted class IDs observed in predictions or targets, used as COCO category IDs.
|
||||
|
||||
Returns:
|
||||
The prediction and target datasets, in the order ``_get_coco_datasets`` returns them.
|
||||
|
||||
Raises:
|
||||
ValueError: If stored detection scores are not one-dimensional floating-point tensors.
|
||||
RuntimeError: If upstream stops emitting one annotation for each stored detection score.
|
||||
"""
|
||||
backend = self._coco_backend
|
||||
detection_boxes = self.detection_box if len(self.detection_box) > 0 else None
|
||||
if detection_boxes is None:
|
||||
return cast(
|
||||
tuple[Any, Any],
|
||||
backend._get_coco_datasets(
|
||||
self.groundtruth_labels,
|
||||
self.groundtruth_box,
|
||||
self.groundtruth_mask,
|
||||
self.groundtruth_crowds,
|
||||
self.groundtruth_area,
|
||||
self.detection_labels,
|
||||
self.detection_box,
|
||||
self.detection_mask,
|
||||
self.detection_scores,
|
||||
self.iou_type,
|
||||
average=self.average,
|
||||
),
|
||||
)
|
||||
|
||||
coco_factory = cast(Callable[[], Any], backend.coco)
|
||||
coco_target, coco_preds = coco_factory(), coco_factory()
|
||||
# `_get_coco_datasets` passes this same list of Python ints (helpers.py:216) even though the parameter is
|
||||
# annotated `list[Tensor]`; the values only ever become COCO category IDs.
|
||||
all_labels = cast(list[Tensor], classes)
|
||||
coco_target.dataset = backend._get_coco_format(
|
||||
labels=self.groundtruth_labels,
|
||||
boxes=self.groundtruth_box if len(self.groundtruth_box) > 0 else None,
|
||||
masks=self.groundtruth_mask if len(self.groundtruth_mask) > 0 else None,
|
||||
crowds=self.groundtruth_crowds,
|
||||
area=self.groundtruth_area,
|
||||
iou_type=self.iou_type,
|
||||
all_labels=all_labels,
|
||||
average=self.average,
|
||||
)
|
||||
coco_preds.dataset = backend._get_coco_format(
|
||||
labels=self.detection_labels,
|
||||
boxes=detection_boxes,
|
||||
masks=self.detection_mask if len(self.detection_mask) > 0 else None,
|
||||
scores=None,
|
||||
iou_type=self.iou_type,
|
||||
all_labels=all_labels,
|
||||
average=self.average,
|
||||
)
|
||||
self._assign_detection_scores(coco_preds.dataset["annotations"])
|
||||
|
||||
with contextlib.redirect_stdout(io.StringIO()):
|
||||
coco_target.createIndex()
|
||||
coco_preds.createIndex()
|
||||
return coco_preds, coco_target
|
||||
|
||||
def _assign_detection_scores(self, annotations: list[dict[str, Any]]) -> None:
|
||||
"""Attach stored detection scores to prediction annotations, converting one image of scores at a time.
|
||||
|
||||
Args:
|
||||
annotations: Prediction annotations built by TorchMetrics with ``scores=None``, in state order.
|
||||
|
||||
Raises:
|
||||
ValueError: If an image's scores are not one-dimensional floating-point tensors, which upstream rejects
|
||||
per annotation.
|
||||
RuntimeError: If the annotation count no longer matches the stored score count.
|
||||
"""
|
||||
for image_id, image_scores in enumerate(self.detection_scores):
|
||||
if image_scores.ndim != 1:
|
||||
raise ValueError(
|
||||
f"Invalid input score of sample {image_id} "
|
||||
f"(expected one-dimensional tensor, got {image_scores.ndim} dimensions)"
|
||||
)
|
||||
if not torch.is_floating_point(image_scores):
|
||||
raise ValueError(
|
||||
f"Invalid input score of sample {image_id} (expected floating point, got {image_scores.dtype})"
|
||||
)
|
||||
flat_scores = [score for image_scores in self.detection_scores for score in image_scores.cpu().tolist()]
|
||||
if len(flat_scores) != len(annotations):
|
||||
# TorchMetrics validates one score for each prediction box and label at update time
|
||||
# (`_input_validator`, helpers.py:95-102), so a mismatch here means its annotation loop changed shape.
|
||||
message = (
|
||||
f"OnePassCocoMeanAveragePrecision built {len(annotations)} prediction annotations for "
|
||||
f"{len(flat_scores)} detection scores with torchmetrics {torchmetrics.__version__}. "
|
||||
"Re-verify rfdetr.training.coco_map before upgrade."
|
||||
)
|
||||
logger.error(message)
|
||||
raise RuntimeError(message)
|
||||
for annotation, score in zip(annotations, flat_scores):
|
||||
annotation["score"] = score
|
||||
|
||||
@staticmethod
|
||||
def _mismatched_backend_signatures(backend: Any, present_methods: list[str]) -> list[str]:
|
||||
"""Return backend method names missing a parameter this adapter's call sites rely on."""
|
||||
return [
|
||||
name
|
||||
for name in present_methods
|
||||
if not set(_BACKEND_METHOD_PARAMS[name]) <= set(inspect.signature(getattr(backend, name)).parameters)
|
||||
]
|
||||
"""Return backend methods whose required parameters cannot accept this adapter's calls."""
|
||||
mismatched = []
|
||||
for name in present_methods:
|
||||
parameters = inspect.signature(getattr(backend, name)).parameters
|
||||
if not set(_BACKEND_METHOD_PARAMS[name]) <= set(parameters) or any(
|
||||
parameters[parameter].kind is inspect.Parameter.POSITIONAL_ONLY
|
||||
for parameter in _BACKEND_KEYWORD_PARAMS[name]
|
||||
):
|
||||
mismatched.append(name)
|
||||
return mismatched
|
||||
|
||||
@staticmethod
|
||||
def _evaluator_methods_now_requiring_args(evaluator_type: Any, present_methods: list[str]) -> list[str]:
|
||||
@@ -287,7 +395,7 @@ class OnePassCocoMeanAveragePrecision(MeanAveragePrecision):
|
||||
expected_states = set(_MAP_STATE_ATTRS)
|
||||
missing_states = sorted(expected_states - installed_states)
|
||||
stale_states = sorted(installed_states - expected_states)
|
||||
backend_methods = ("_get_coco_datasets", "_coco_stats_to_tensor_dict")
|
||||
backend_methods = tuple(_BACKEND_METHOD_PARAMS)
|
||||
backend = getattr(self, "_coco_backend", None)
|
||||
missing_methods = (
|
||||
["_coco_backend"]
|
||||
@@ -299,12 +407,17 @@ class OnePassCocoMeanAveragePrecision(MeanAveragePrecision):
|
||||
self._mismatched_backend_signatures(backend, present_backend_methods) if present_backend_methods else []
|
||||
)
|
||||
try:
|
||||
# `_coco_datasets` calls the `coco` dataset factory directly, so a rename upstream must fail here
|
||||
# rather than at compute() time.
|
||||
evaluator_type = backend.cocoeval if backend is not None else None
|
||||
coco_factory = backend.coco if backend is not None else None
|
||||
except (AttributeError, TypeError, ImportError):
|
||||
# `CocoBackend.cocoeval` lazily imports the backend package (e.g. `faster_coco_eval`) and
|
||||
# raises `ModuleNotFoundError` (an `ImportError`) when it is absent; without catching it
|
||||
# here that exception propagates raw instead of the actionable RuntimeError below.
|
||||
evaluator_type = None
|
||||
evaluator_type = coco_factory = None
|
||||
if backend is not None and not callable(coco_factory):
|
||||
missing_methods = [*missing_methods, "coco"]
|
||||
missing_evaluator_methods = (
|
||||
["cocoeval"]
|
||||
if evaluator_type is None
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"""Contract tests for RF-DETR's one-pass TorchMetrics COCO adapter."""
|
||||
|
||||
import sys
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, PropertyMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -230,6 +231,160 @@ def test_adapter_matches_torchmetrics_on_nontrivial_multiclass_multiimage_data()
|
||||
torch.testing.assert_close(actual[key].reshape(-1), expected[key].reshape(-1), rtol=1e-4, atol=1e-6)
|
||||
|
||||
|
||||
class TestHoistedDetectionScores:
|
||||
"""Prediction COCO datasets built with per-image score conversion instead of TorchMetrics' per-annotation read."""
|
||||
|
||||
predictions = [
|
||||
{
|
||||
"boxes": torch.tensor([[0.0, 0.0, 10.0, 10.0], [20.0, 20.0, 28.0, 28.0]]),
|
||||
"scores": torch.tensor([0.9, 0.8]),
|
||||
"labels": torch.tensor([3, 17]),
|
||||
},
|
||||
{
|
||||
"boxes": torch.tensor([[0.0, 0.0, 10.0, 10.0]]),
|
||||
"scores": torch.tensor([0.95]),
|
||||
"labels": torch.tensor([42]),
|
||||
},
|
||||
]
|
||||
targets = [
|
||||
{"boxes": torch.tensor([[0.0, 0.0, 10.0, 10.0]]), "labels": torch.tensor([3])},
|
||||
{"boxes": torch.tensor([[5.0, 5.0, 15.0, 15.0]]), "labels": torch.tensor([3])},
|
||||
]
|
||||
|
||||
def _updated_metric(self) -> OnePassCocoMeanAveragePrecision:
|
||||
"""Return a metric holding this class's two-image prediction and target state.
|
||||
|
||||
Examples:
|
||||
>>> metric = TestHoistedDetectionScores()._updated_metric()
|
||||
>>> [scores.numel() for scores in metric.detection_scores]
|
||||
[2, 1]
|
||||
"""
|
||||
metric = OnePassCocoMeanAveragePrecision(class_metrics=True)
|
||||
metric.update(self.predictions, self.targets)
|
||||
return metric
|
||||
|
||||
def test_datasets_match_stock_construction(self) -> None:
|
||||
"""Hoisted construction must produce the same COCO datasets TorchMetrics' own helper produces.
|
||||
|
||||
The whole optimization rests on ``scores=None`` plus a second assignment pass being indistinguishable from the
|
||||
per-annotation read. Comparing the raw dataset dicts catches a divergence -- a dropped ``info`` key, a shifted
|
||||
``annotation_id``, a misaligned score -- that aggregate mAP values could average away.
|
||||
"""
|
||||
metric = self._updated_metric()
|
||||
expected_preds, expected_target = metric._coco_backend._get_coco_datasets(
|
||||
metric.groundtruth_labels,
|
||||
metric.groundtruth_box,
|
||||
metric.groundtruth_mask,
|
||||
metric.groundtruth_crowds,
|
||||
metric.groundtruth_area,
|
||||
metric.detection_labels,
|
||||
metric.detection_box,
|
||||
metric.detection_mask,
|
||||
metric.detection_scores,
|
||||
metric.iou_type,
|
||||
average=metric.average,
|
||||
)
|
||||
|
||||
actual_preds, actual_target = metric._coco_datasets(metric._observed_classes())
|
||||
|
||||
assert actual_preds.dataset == expected_preds.dataset
|
||||
assert actual_target.dataset == expected_target.dataset
|
||||
|
||||
def test_scores_are_converted_once_for_each_image(self) -> None:
|
||||
"""Prediction scores must cross the CPU conversion boundary once per image.
|
||||
|
||||
Dataset parity cannot detect a silent regression back to TorchMetrics' per-annotation score conversion. The two
|
||||
stored score tensors contain three detections, so recording two vector conversions proves this adapter converts
|
||||
scores once per image; a per-annotation implementation would instead record three scalar conversions.
|
||||
"""
|
||||
metric = self._updated_metric()
|
||||
recorded = MagicMock(side_effect=metric._coco_backend._get_coco_format)
|
||||
original_cpu = torch.Tensor.cpu
|
||||
score_storage_addresses = {scores.untyped_storage().data_ptr() for scores in metric.detection_scores}
|
||||
score_conversion_shapes: list[tuple[int, ...]] = []
|
||||
|
||||
def record_cpu(tensor: torch.Tensor, *args: Any, **kwargs: Any) -> torch.Tensor:
|
||||
if tensor.untyped_storage().data_ptr() in score_storage_addresses:
|
||||
score_conversion_shapes.append(tuple(tensor.shape))
|
||||
return original_cpu(tensor, *args, **kwargs)
|
||||
|
||||
with (
|
||||
patch.object(CocoBackend, "_get_coco_format", recorded),
|
||||
patch.object(torch.Tensor, "cpu", new=record_cpu),
|
||||
):
|
||||
metric._coco_datasets(metric._observed_classes())
|
||||
|
||||
prediction_call = recorded.call_args_list[-1]
|
||||
assert prediction_call.kwargs["scores"] is None
|
||||
assert prediction_call.kwargs["labels"] is metric.detection_labels
|
||||
assert score_conversion_shapes == [(2,), (1,)]
|
||||
|
||||
def test_annotation_count_mismatch_is_rejected(self) -> None:
|
||||
"""A prediction annotation count that no longer matches stored scores must fail loudly.
|
||||
|
||||
Assigning scores positionally is only sound while upstream emits one annotation per stored detection. If its
|
||||
loop ever starts dropping or adding annotations, silently zipping the shorter of the two would attach wrong
|
||||
scores to real detections and quietly corrupt every reported mAP.
|
||||
"""
|
||||
metric = self._updated_metric()
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
CocoBackend, "_get_coco_format", return_value={"images": [], "annotations": [], "categories": []}
|
||||
),
|
||||
pytest.raises(RuntimeError, match="prediction annotations"),
|
||||
):
|
||||
metric._coco_datasets(metric._observed_classes())
|
||||
|
||||
def test_mask_only_state_uses_stock_construction(self) -> None:
|
||||
"""Segmentation-only predictions must keep using TorchMetrics' own helper.
|
||||
|
||||
Without boxes, upstream drops an image that has no masks from the annotation list entirely
|
||||
(``helpers.py:508-511``), so annotation order stops tracking stored score order and positional assignment would
|
||||
attach one image's scores to another's detections. Nothing else in the suite reaches this branch: an empty ``(0,
|
||||
4)`` box tensor still leaves ``detection_box`` populated and takes the hoisted path.
|
||||
"""
|
||||
mask = torch.zeros(1, 8, 8, dtype=torch.bool)
|
||||
mask[:, 1:5, 1:5] = True
|
||||
metric = OnePassCocoMeanAveragePrecision(iou_type="segm", class_metrics=True)
|
||||
metric.update(
|
||||
[{"masks": mask.clone(), "scores": torch.tensor([0.9]), "labels": torch.tensor([7])}],
|
||||
[{"masks": mask.clone(), "labels": torch.tensor([7])}],
|
||||
)
|
||||
recorded = MagicMock(side_effect=metric._coco_backend._get_coco_datasets)
|
||||
|
||||
with patch.object(CocoBackend, "_get_coco_datasets", recorded):
|
||||
coco_preds, _ = metric._coco_datasets(metric._observed_classes())
|
||||
|
||||
assert recorded.call_count == 1
|
||||
assert [annotation["score"] for annotation in coco_preds.dataset["annotations"]] == pytest.approx([0.9])
|
||||
|
||||
def test_non_float_scores_are_rejected(self) -> None:
|
||||
"""Integer score state must raise, matching the per-annotation type check the hoist replaces.
|
||||
|
||||
TorchMetrics validates that scores are a tensor but not that they are floating point; the float check only
|
||||
happens during conversion. Converting a whole image at once skips that check, so it has to be restated.
|
||||
"""
|
||||
metric = self._updated_metric()
|
||||
metric.detection_scores = [scores.long() for scores in metric.detection_scores]
|
||||
|
||||
with pytest.raises(ValueError, match="expected floating point"):
|
||||
metric._coco_datasets(metric._observed_classes())
|
||||
|
||||
def test_column_vector_scores_are_rejected(self) -> None:
|
||||
"""Column-vector scores must fail instead of becoming nested COCO score lists.
|
||||
|
||||
TorchMetrics' original per-annotation conversion rejects a list result. The hoisted conversion must preserve
|
||||
that scalar-score contract before assigning annotations, because nested scores can otherwise survive until a
|
||||
later backend operation and obscure the input error.
|
||||
"""
|
||||
metric = self._updated_metric()
|
||||
metric.detection_scores = [scores.unsqueeze(1) for scores in metric.detection_scores]
|
||||
|
||||
with pytest.raises(ValueError, match="one-dimensional"):
|
||||
metric._coco_datasets(metric._observed_classes())
|
||||
|
||||
|
||||
def test_empty_predictions_preserve_zero_recall() -> None:
|
||||
"""A class with ground truth but no predictions must report zero AP/AR instead of a missing-value sentinel."""
|
||||
metric = OnePassCocoMeanAveragePrecision(class_metrics=True)
|
||||
@@ -303,6 +458,15 @@ def test_adapter_rejects_stale_torchmetrics_state_contract() -> None:
|
||||
OnePassCocoMeanAveragePrecision()
|
||||
|
||||
|
||||
def test_adapter_rejects_non_callable_coco_backend_factory() -> None:
|
||||
"""Construction must reject a non-callable COCO dataset factory before metric computation."""
|
||||
with (
|
||||
patch.object(CocoBackend, "coco", new=object()),
|
||||
pytest.raises(RuntimeError, match=r"missing backend methods: \['coco'\]"),
|
||||
):
|
||||
OnePassCocoMeanAveragePrecision()
|
||||
|
||||
|
||||
def test_adapter_rejects_backend_method_missing_a_relied_on_parameter() -> None:
|
||||
"""Construction must fail when a backend method drops a keyword compute() calls by name.
|
||||
|
||||
@@ -351,6 +515,24 @@ class TestMismatchedBackendSignatures:
|
||||
|
||||
assert mismatched == []
|
||||
|
||||
def test_flags_keyword_call_to_a_positional_only_parameter(self) -> None:
|
||||
"""A positional-only backend parameter must fail before its keyword call reaches compute().
|
||||
|
||||
A parameter-name-only check accepts this signature even though ``_coco_datasets`` passes every
|
||||
``_get_coco_format`` argument by keyword. Construction-time rejection turns a private API shift into the
|
||||
adapter's actionable compatibility error instead of a raw TypeError after state accumulation.
|
||||
"""
|
||||
|
||||
class _Backend:
|
||||
def _get_coco_format(
|
||||
self, labels, /, all_labels, boxes, masks, scores, crowds, area, iou_type, average
|
||||
) -> None:
|
||||
pass
|
||||
|
||||
mismatched = OnePassCocoMeanAveragePrecision._mismatched_backend_signatures(_Backend(), ["_get_coco_format"])
|
||||
|
||||
assert mismatched == ["_get_coco_format"]
|
||||
|
||||
|
||||
class TestEvaluatorMethodsNowRequiringArgs:
|
||||
"""Unit coverage for the evaluator zero-arg-method regression check."""
|
||||
|
||||
Reference in New Issue
Block a user