Source: tests/test_vision_probe_geometry.py · Plain text

tests/test_vision_probe_geometry.py

from pathlib import Path

import pytest
import torch
from PIL import Image
from torch import nn

from mcss.vision_probe.features import FrozenDinoExtractor
from mcss.vision_probe.geometry import (
    homography_correspondence,
    mask_propagation_metrics,
)


class _StubBackbone(nn.Module):
    def __init__(self) -> None:
        super().__init__()
        self.embed_dim = 384
        self.patch_size = 14
        self.weight = nn.Parameter(torch.ones(1))
        self.forward_inputs: list[torch.Tensor] = []
        self.loaded_state: object = None
        self.loaded_strict: bool | None = None

    def load_state_dict(self, state: object, strict: bool = True) -> None:
        self.loaded_state = state
        self.loaded_strict = strict

    def forward(self, _input: torch.Tensor) -> torch.Tensor:
        raise AssertionError("FrozenDinoExtractor must call forward_features directly")

    def forward_features(self, input_tensor: torch.Tensor) -> dict[str, torch.Tensor]:
        self.forward_inputs.append(input_tensor.detach().cpu())
        return {"x_norm_patchtokens": torch.zeros(1, 256, 384, device=input_tensor.device)}


def test_extractor_uses_full_resized_input_and_returns_cpu_patch_values(
    tmp_path: Path, monkeypatch
) -> None:
    repo_root = tmp_path / "dinov2"
    repo_root.mkdir()
    weights = tmp_path / "weights.pth"
    weights.write_bytes(b"stub")
    image_path = tmp_path / "image.png"
    Image.new("RGB", (9, 7), (255, 0, 128)).save(image_path)

    backbone = _StubBackbone()
    hub_calls: list[tuple[tuple[object, ...], dict[str, object]]] = []

    def load(*args: object, **kwargs: object) -> _StubBackbone:
        hub_calls.append((args, kwargs))
        return backbone

    state_dict = {"weight": torch.ones(1)}
    monkeypatch.setattr(torch.hub, "load", load)
    monkeypatch.setattr(torch, "load", lambda *args, **kwargs: state_dict)

    extractor = FrozenDinoExtractor(repo_root, weights, device="cpu")
    result = extractor.extract(image_path)

    assert hub_calls == [
        (
            (str(repo_root.resolve()), "dinov2_vits14"),
            {"source": "local", "pretrained": False},
        )
    ]
    assert backbone.loaded_state is state_dict
    assert backbone.loaded_strict is True
    assert len(backbone.forward_inputs) == 1
    assert tuple(backbone.forward_inputs[0].shape) == (1, 3, 224, 224)
    assert result["features"].shape == (16, 16, 384)
    assert result["rgb"].shape == (16, 16, 3)
    assert result["features"].device.type == "cpu"
    assert result["rgb"].device.type == "cpu"
    assert result["original_size"] == (9, 7)
    assert result["image_size"] == (224, 224)
    assert torch.allclose(result["rgb"][0, 0], torch.tensor([1.0, 0.0, 128.0 / 255.0]))
    assert not backbone.training
    assert all(not parameter.requires_grad for parameter in backbone.parameters())


def test_identity_homography_maps_all_token_centers_to_integer_coordinates() -> None:
    valid_indices, target_xy = homography_correspondence(
        torch.eye(3),
        src_original_size=(224, 224),
        dst_original_size=(224, 224),
    )

    assert torch.equal(valid_indices, torch.arange(256, dtype=torch.long))
    assert target_xy.shape == (256, 2)
    assert torch.equal(target_xy[0], torch.tensor([0.0, 0.0]))
    assert torch.equal(target_xy[-1], torch.tensor([15.0, 15.0]))


def test_mask_propagation_identity_and_disjoint_target_labels() -> None:
    features = torch.eye(4).reshape(2, 2, 4)
    source_labels = torch.tensor([[0, 1], [0, 1]], dtype=torch.int64)

    identity = mask_propagation_metrics(features, features, source_labels, source_labels)
    assert identity == {
        "mean_iou": 1.0,
        "foreground_iou": 1.0,
        "n_objects": 1,
        "accuracy": 1.0,
    }

    wrong_target = torch.tensor([[0, 0], [1, 1]], dtype=torch.int64)
    wrong = mask_propagation_metrics(features, features, source_labels, wrong_target)
    assert wrong["n_objects"] == 1
    assert wrong["mean_iou"] == pytest.approx(1.0 / 3.0)
    assert wrong["foreground_iou"] == pytest.approx(1.0 / 3.0)
    assert wrong["accuracy"] == 0.5


def test_mask_propagation_excludes_source_and_target_ignore_255() -> None:
    source_features = torch.eye(4).reshape(2, 2, 4)
    target_features = source_features.clone()
    target_features[0, 1] = source_features[0, 0]
    source_labels = torch.tensor([[255, 1], [0, 1]], dtype=torch.int64)
    target_labels = torch.tensor([[255, 1], [0, 1]], dtype=torch.int64)

    metrics = mask_propagation_metrics(
        source_features,
        target_features,
        source_labels,
        target_labels,
    )

    assert metrics == {
        "mean_iou": 1.0,
        "foreground_iou": 1.0,
        "n_objects": 1,
        "accuracy": 1.0,
    }