src/mcss/vision_probe/geometry.py
"""Homography correspondences and discrete patch matching metrics."""
from __future__ import annotations
from collections.abc import Sequence
import torch
from torch import Tensor
from torch.nn import functional as F
def homography_correspondence(
homo: Tensor,
src_original_size: tuple[int, int],
dst_original_size: tuple[int, int],
grid_size: int = 16,
image_size: int | tuple[int, int] = 224,
) -> tuple[Tensor, Tensor]:
"""Map source token centers through an original-image homography.
Image sizes are ``(width, height)``. Token centers use cell-center
coordinates over the resized image domain, so an identity homography maps
each source token to its integer target-grid coordinate.
"""
matrix = _as_homography(homo)
src_width, src_height = _positive_size(src_original_size, "src_original_size")
dst_width, dst_height = _positive_size(dst_original_size, "dst_original_size")
if isinstance(grid_size, bool) or not isinstance(grid_size, int) or grid_size <= 0:
raise ValueError("grid_size must be a positive integer")
image_width, image_height = _image_size(image_size)
coordinate_dtype = matrix.dtype if matrix.is_floating_point() else torch.float32
matrix = matrix.to(dtype=coordinate_dtype)
device = matrix.device
y, x = torch.meshgrid(
(torch.arange(grid_size, device=device, dtype=coordinate_dtype) + 0.5)
* (image_height / grid_size),
(torch.arange(grid_size, device=device, dtype=coordinate_dtype) + 0.5)
* (image_width / grid_size),
indexing="ij",
)
source_resized = torch.stack((x, y), dim=-1).reshape(-1, 2)
source_original = source_resized * torch.tensor(
(src_width / image_width, src_height / image_height),
device=device,
dtype=coordinate_dtype,
)
homogeneous = torch.cat(
(
source_original,
torch.ones((source_original.shape[0], 1), device=device, dtype=coordinate_dtype),
),
dim=-1,
)
mapped_homogeneous = homogeneous @ matrix.T
denominator = mapped_homogeneous[:, 2]
if not torch.isfinite(denominator).all() or denominator.abs().le(
torch.finfo(coordinate_dtype).eps
).any():
raise ValueError("homography produces a non-finite or zero homogeneous denominator")
destination_original = mapped_homogeneous[:, :2] / denominator.unsqueeze(-1)
destination_grid = destination_original / torch.tensor(
(dst_width, dst_height), device=device, dtype=coordinate_dtype
) * grid_size - 0.5
valid = (
torch.isfinite(destination_grid).all(dim=-1)
& (destination_grid[:, 0] >= 0)
& (destination_grid[:, 0] <= grid_size - 1)
& (destination_grid[:, 1] >= 0)
& (destination_grid[:, 1] <= grid_size - 1)
)
source_indices = torch.arange(grid_size * grid_size, device=device, dtype=torch.long)
return source_indices[valid], destination_grid[valid].to(dtype=torch.float32)
def matching_metrics(
source_feat: Tensor,
target_feat: Tensor,
valid_src_indices: Tensor,
gt_dst_xy: Tensor,
) -> dict[str, float | int]:
"""Evaluate discrete cosine nearest-neighbor matching on valid source tokens."""
_validate_features(source_feat, "source_feat")
_validate_features(target_feat, "target_feat")
if source_feat.shape[-1] != target_feat.shape[-1]:
raise ValueError("source_feat and target_feat must have the same feature dimension")
if source_feat.device != target_feat.device:
raise ValueError("source_feat and target_feat must be on the same device")
if not isinstance(valid_src_indices, Tensor) or valid_src_indices.ndim != 1:
raise ValueError("valid_src_indices must have shape [N]")
if valid_src_indices.dtype != torch.long:
raise ValueError("valid_src_indices must have dtype torch.long")
if not isinstance(gt_dst_xy, Tensor) or gt_dst_xy.ndim != 2 or gt_dst_xy.shape[-1] != 2:
raise ValueError("gt_dst_xy must have shape [N, 2]")
if valid_src_indices.shape[0] != gt_dst_xy.shape[0]:
raise ValueError("valid_src_indices and gt_dst_xy must contain the same number of points")
if not torch.isfinite(gt_dst_xy).all():
raise ValueError("gt_dst_xy must contain only finite values")
if valid_src_indices.device != source_feat.device:
valid_src_indices = valid_src_indices.to(source_feat.device)
if gt_dst_xy.device != source_feat.device:
gt_dst_xy = gt_dst_xy.to(source_feat.device)
if valid_src_indices.numel() and (
valid_src_indices.min() < 0
or valid_src_indices.max() >= source_feat.shape[0] * source_feat.shape[1]
):
raise ValueError("valid_src_indices contains an out-of-range source index")
n_points = int(valid_src_indices.numel())
if n_points == 0:
return {"pck_1": 0.0, "pck_2": 0.0, "mean_error": float("nan"), "n_points": 0}
source_flat = F.normalize(source_feat.reshape(-1, source_feat.shape[-1]), dim=-1)
target_flat = F.normalize(target_feat.reshape(-1, target_feat.shape[-1]), dim=-1)
scores = source_flat[valid_src_indices] @ target_flat.T
predicted_flat_indices = scores.argmax(dim=1)
target_width = target_feat.shape[1]
predicted_xy = torch.stack(
(
predicted_flat_indices.remainder(target_width),
torch.div(predicted_flat_indices, target_width, rounding_mode="floor"),
),
dim=-1,
).to(dtype=gt_dst_xy.dtype)
error = torch.linalg.vector_norm(predicted_xy - gt_dst_xy, dim=-1)
return {
"pck_1": float((error <= 1.0).float().mean().item()),
"pck_2": float((error <= 2.0).float().mean().item()),
"mean_error": float(error.mean().item()),
"n_points": n_points,
}
def mask_propagation_metrics(
source_features: Tensor,
target_features: Tensor,
source_labels: Tensor,
target_labels: Tensor,
) -> dict[str, float | int]:
"""Propagate source labels to a target token grid with cosine nearest neighbors.
The source descriptor map and labels are treated as the frozen reference.
Each target token selects one source token, and the selected source label is
compared with the target label. ``mean_iou`` is the mean over foreground
object IDs present in either label map; ``foreground_iou`` is the binary
foreground IoU. Objects whose predicted/target union is empty are skipped.
"""
_validate_features(source_features, "source_features")
_validate_features(target_features, "target_features")
if source_features.shape[-1] != target_features.shape[-1]:
raise ValueError("source_features and target_features must have the same feature dimension")
if source_features.device != target_features.device:
raise ValueError("source_features and target_features must be on the same device")
_validate_labels(source_labels, source_features.shape[:2], "source_labels")
_validate_labels(target_labels, target_features.shape[:2], "target_labels")
device = source_features.device
source_labels = source_labels.to(device=device)
target_labels = target_labels.to(device=device)
source_flat = F.normalize(source_features.reshape(-1, source_features.shape[-1]), dim=-1)
target_flat = F.normalize(target_features.reshape(-1, target_features.shape[-1]), dim=-1)
source_flat_labels = source_labels.reshape(-1)
source_valid = source_flat_labels != 255
if not source_valid.any():
raise ValueError("source_labels must contain at least one non-ignore token")
nearest_source = (target_flat @ source_flat[source_valid].T).argmax(dim=1)
propagated = source_flat_labels[source_valid][nearest_source].reshape(target_labels.shape)
target_valid = target_labels != 255
if target_valid.any():
accuracy = float(
(propagated[target_valid] == target_labels[target_valid]).float().mean().item()
)
else:
accuracy = 0.0
source_ids = source_labels[(source_labels != 0) & (source_labels != 255)]
target_ids = target_labels[(target_labels != 0) & (target_labels != 255)]
if source_ids.numel() or target_ids.numel():
object_ids = torch.unique(torch.cat((source_ids, target_ids)))
else:
object_ids = torch.empty(0, dtype=source_labels.dtype, device=device)
object_ious: list[Tensor] = []
for object_id in object_ids:
predicted_mask = (propagated == object_id) & target_valid
target_mask = (target_labels == object_id) & target_valid
union = predicted_mask | target_mask
if not union.any():
continue
intersection = (predicted_mask & target_mask).sum().to(dtype=torch.float32)
object_ious.append(intersection / union.sum().to(dtype=torch.float32))
foreground_predicted = (propagated != 0) & target_valid
foreground_target = (target_labels != 0) & target_valid
foreground_union = foreground_predicted | foreground_target
if foreground_union.any():
foreground_iou = float(
(foreground_predicted & foreground_target)
.sum()
.float()
.div(foreground_union.sum())
.item()
)
else:
foreground_iou = 0.0
mean_iou = float(torch.stack(object_ious).mean().item()) if object_ious else 0.0
return {
"mean_iou": mean_iou,
"foreground_iou": foreground_iou,
"n_objects": len(object_ious),
"accuracy": accuracy,
}
def _as_homography(value: Tensor) -> Tensor:
if not isinstance(value, Tensor):
try:
value = torch.as_tensor(value)
except (TypeError, ValueError) as error:
raise TypeError("homo must be convertible to a tensor") from error
if value.shape != (3, 3):
raise ValueError("homo must have shape [3, 3]")
if not value.is_floating_point():
value = value.float()
if not torch.isfinite(value).all():
raise ValueError("homo must contain only finite values")
if torch.linalg.matrix_rank(value) < 3:
raise ValueError("homo must be invertible")
return value
def _positive_size(value: Sequence[int], label: str) -> tuple[float, float]:
if not isinstance(value, Sequence) or len(value) != 2:
raise ValueError(f"{label} must be (width, height)")
width, height = value
if isinstance(width, bool) or isinstance(height, bool):
raise ValueError(f"{label} values must be positive")
try:
width_float, height_float = float(width), float(height)
except (TypeError, ValueError) as error:
raise ValueError(f"{label} values must be positive") from error
if not torch.isfinite(torch.tensor((width_float, height_float))).all() or min(
width_float, height_float
) <= 0:
raise ValueError(f"{label} values must be positive")
return width_float, height_float
def _image_size(value: int | tuple[int, int]) -> tuple[float, float]:
if isinstance(value, bool):
raise ValueError("image_size must be positive")
if isinstance(value, int):
if value <= 0:
raise ValueError("image_size must be positive")
return float(value), float(value)
return _positive_size(value, "image_size")
def _validate_features(value: Tensor, label: str) -> None:
if not isinstance(value, Tensor) or value.ndim != 3:
raise ValueError(f"{label} must have shape [H, W, C]")
if min(value.shape) <= 0:
raise ValueError(f"{label} dimensions must be positive")
if not value.is_floating_point():
raise ValueError(f"{label} must use a floating-point dtype")
if not torch.isfinite(value).all():
raise ValueError(f"{label} must contain only finite values")
def _validate_labels(value: Tensor, expected_shape: tuple[int, int], label: str) -> None:
if not isinstance(value, Tensor) or value.ndim != 2 or tuple(value.shape) != expected_shape:
raise ValueError(f"{label} must have shape {expected_shape}")
if value.dtype == torch.bool or value.is_floating_point() or value.is_complex():
raise ValueError(f"{label} must use an integer dtype")
if value.numel() and value.min() < 0:
raise ValueError(f"{label} must contain non-negative object IDs")