Source: tests/test_opportunity_metrics.py · Plain text

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)))