tests/test_opportunity_metrics.py
import inspect
import json
import numpy as np
import pytest
import torch
from mcss.vision_probe.geometry import mask_propagation_metrics
from mcss.vision_probe.opportunity_metrics import (
binary_scores,
correspondence,
evaluate_pair,
size_bucket,
transfer_probabilities,
)
def _identity_features(height=4, width=4):
return torch.eye(height * width).reshape(height, width, -1)
def test_matching_is_mask_free_and_respects_spatial_permutation():
assert list(inspect.signature(correspondence).parameters) == [
"source_features",
"target_features",
]
source = _identity_features()
result = correspondence(source, source.flip(1))
torch.testing.assert_close(result, torch.arange(16).reshape(4, 4).flip(1))
def test_pixel_transfer_identity_rectangular_original_size_and_json():
features = _identity_features()
mask = np.zeros((40, 80), dtype=np.uint8)
mask[:, :40] = 1
result = evaluate_pair(features, features, mask, mask)
assert result["J"] == result["F"] == result["JF"] == 1.0
assert result["foreground_pixel_count"] == 1600
assert result["foreground_token_counts"] == {"224": 128, "448": 512}
assert result["objects"][0]["size_bucket"] == "large"
json.dumps(result, allow_nan=False)
def test_target_labels_cannot_change_matching_or_transferred_prediction(monkeypatch):
import mcss.vision_probe.opportunity_metrics as metrics
features = _identity_features()
source = np.zeros((16, 16), dtype=np.uint8)
source[:, :8] = 1
original = metrics.transfer_probabilities
captured = []
def record(*args, **kwargs):
result = original(*args, **kwargs)
captured.append(result)
return result
monkeypatch.setattr(metrics, "transfer_probabilities", record)
correct = evaluate_pair(features, features, source, source)
changed_target = np.full_like(source, 2)
changed = evaluate_pair(features, features, source, changed_target)
np.testing.assert_array_equal(captured[0][1], captured[1][1])
assert set(captured[1]) == {1} # target-only object never enters prediction
assert correct["J"] == 1 and changed["J"] == 0
assert [row["object_id"] for row in changed["objects"]] == [1, 2]
def test_area_occupancy_is_not_nearest_label_and_tiny_objects_are_retained():
source = np.zeros((16, 16), dtype=np.uint8)
source[0, 0] = 7
features = _identity_features(1, 1)
probability = transfer_probabilities(
source, (1, 1), torch.zeros(1, 1, dtype=torch.long), (16, 16)
)
np.testing.assert_allclose(probability[7], 1 / 256)
result = evaluate_pair(features, features, source, source)
assert result["J"] == 0.0
assert len(result["objects"]) == 1
assert result["objects"][0]["pixel_count"] == 1
assert result["objects"][0]["size_bucket"] == "tiny"
def test_binary_scores_empty_shifted_and_disjoint_geometry():
empty = np.zeros((100, 100), dtype=bool)
square = empty.copy()
square[20:40, 20:40] = True
assert binary_scores(empty, empty) == (1.0, 1.0)
assert binary_scores(square, empty) == (0.0, 0.0)
assert binary_scores(empty, square) == (0.0, 0.0)
assert binary_scores(square, square) == (1.0, 1.0)
shifted = np.roll(square, 1, axis=1)
jaccard, boundary_f = binary_scores(shifted, square)
assert jaccard == pytest.approx(380 / 420)
assert boundary_f == 1.0 # ceil(.008 * sqrt(2) * 100) == 2
assert binary_scores(np.roll(square, 50, axis=1), square) == (0.0, 0.0)
def test_void_does_not_count_as_an_object_or_foreground():
features = _identity_features()
mask = np.zeros((4, 4), dtype=np.uint8)
mask[:, :2] = 1
mask[:, 3] = 255
result = evaluate_pair(features, features, mask, mask)
assert result["J"] == result["F"] == 1.0
assert result["foreground_pixel_count"] == 8
assert [row["object_id"] for row in result["objects"]] == [1]
def test_legacy_token_iou_matches_existing_metric():
source = _identity_features()
target = source.flip(1)
labels = torch.tensor([[0, 0, 1, 1]] * 4)
expected = mask_propagation_metrics(source, target, labels, labels)["mean_iou"]
result = evaluate_pair(source, target, labels.numpy(), labels.numpy())
assert result["token_iou"] == expected
@pytest.mark.parametrize(
"fraction,expected",
[
(0.0, "tiny"),
(0.0049, "tiny"),
(0.005, "small"),
(0.0199, "small"),
(0.02, "medium"),
(0.099, "medium"),
(0.1, "large"),
],
)
def test_fixed_size_bucket_boundaries(fraction, expected):
assert size_bucket(fraction) == expected
def test_source_ignore_does_not_filter_primary_matching():
features = _identity_features(1, 2)
torch.testing.assert_close(correspondence(features, features), torch.tensor([[0, 1]]))
mask = np.array([[255, 1]], dtype=np.uint8)
result = evaluate_pair(features, features, mask, np.array([[1, 0]], dtype=np.uint8))
assert result["J"] == 0.0
def test_bad_features_and_masks_fail_explicitly():
features = _identity_features()
with pytest.raises(ValueError, match="finite"):
correspondence(features, features * float("nan"))
with pytest.raises(ValueError, match="integer mask"):
evaluate_pair(features, features, np.ones((4, 4)), np.ones((4, 4)))