3a2c66702c
Tests on CPU (scheduled) / check-skip (push) Has been cancelled
Tests on CPU (scheduled) / pre-tests (push) Has been cancelled
Tests on CPU (scheduled) / tests-cpu-ubuntu (float32) (push) Has been cancelled
Tests on CPU (scheduled) / tests-cpu-ubuntu (float64) (push) Has been cancelled
Tests on CPU (scheduled) / tests-cpu-windows (3.11, float32, 2.5.1) (push) Has been cancelled
Tests on CPU (scheduled) / tests-cpu-windows (3.11, float32, 2.9.1) (push) Has been cancelled
Tests on CPU (scheduled) / tests-cpu-windows (3.11, float64, 2.5.1) (push) Has been cancelled
Tests on CPU (scheduled) / tests-cpu-windows (3.11, float64, 2.9.1) (push) Has been cancelled
Tests on CPU (scheduled) / tests-cpu-windows (3.12, float32, 2.5.1) (push) Has been cancelled
Tests on CPU (scheduled) / tests-cpu-windows (3.12, float32, 2.9.1) (push) Has been cancelled
Tests on CPU (scheduled) / tests-cpu-windows (3.12, float64, 2.5.1) (push) Has been cancelled
Tests on CPU (scheduled) / tests-cpu-windows (3.12, float64, 2.9.1) (push) Has been cancelled
Tests on CPU (scheduled) / tests-cpu-windows (3.13, float32, 2.9.1) (push) Has been cancelled
Tests on CPU (scheduled) / tests-cpu-windows (3.13, float64, 2.9.1) (push) Has been cancelled
Tests on CPU (scheduled) / tests-cpu-mac (3.11, float32, 2.5.1) (push) Has been cancelled
Tests on CPU (scheduled) / tests-cpu-mac (3.11, float32, 2.9.1) (push) Has been cancelled
Tests on CPU (scheduled) / tests-cpu-mac (3.12, float32, 2.5.1) (push) Has been cancelled
Tests on CPU (scheduled) / tests-cpu-mac (3.12, float32, 2.9.1) (push) Has been cancelled
Tests on CPU (scheduled) / tests-cpu-mac (3.13, float32, 2.9.1) (push) Has been cancelled
Tests on CPU (scheduled) / coverage (push) Has been cancelled
Tests on CPU (scheduled) / typing (push) Has been cancelled
Tests on CPU (scheduled) / tutorials (push) Has been cancelled
Tests on CPU (scheduled) / docs (push) Has been cancelled
Lint / TOML Format (push) Has been cancelled
247 lines
10 KiB
Python
247 lines
10 KiB
Python
# LICENSE HEADER MANAGED BY add-license-header
|
|
#
|
|
# Copyright 2018 Kornia Team
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
#
|
|
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
import torch
|
|
|
|
from kornia.core._compat import torch_version_le
|
|
from kornia.feature import DescriptorMatcher, GFTTAffNetHardNet, LocalFeatureMatcher, SIFTFeature
|
|
from kornia.geometry import rescale, transform_points
|
|
from kornia.tracking import HomographyTracker
|
|
|
|
from testing.base import BaseTester
|
|
|
|
|
|
def _make_tracker(minimum_inliers_num: int = 5) -> HomographyTracker:
|
|
"""Return a HomographyTracker whose heavy sub-modules are replaced with lightweight mocks."""
|
|
initial_matcher = MagicMock()
|
|
fast_matcher = MagicMock()
|
|
ransac = MagicMock()
|
|
# Set extract_features to None so isinstance(..., nn.Module) guard skips feature pre-extraction
|
|
initial_matcher.extract_features = None
|
|
fast_matcher.extract_features = None
|
|
return HomographyTracker(
|
|
initial_matcher=initial_matcher,
|
|
fast_matcher=fast_matcher,
|
|
ransac=ransac,
|
|
minimum_inliers_num=minimum_inliers_num,
|
|
)
|
|
|
|
|
|
def _match_dict(n_keypoints: int, device: torch.device, dtype: torch.dtype) -> dict:
|
|
"""Produce a fake match dict with n_keypoints matches for batch 0."""
|
|
return {
|
|
"keypoints0": torch.rand(n_keypoints, 2, device=device, dtype=dtype),
|
|
"keypoints1": torch.rand(n_keypoints, 2, device=device, dtype=dtype),
|
|
"batch_indexes": torch.zeros(n_keypoints, dtype=torch.long, device=device),
|
|
}
|
|
|
|
|
|
class TestHomographyTrackerUnit:
|
|
"""Fast unit tests for HomographyTracker using mocked sub-modules."""
|
|
|
|
def test_reset_tracking_clears_homography(self):
|
|
tracker = _make_tracker()
|
|
image = torch.rand(1, 1, 8, 8)
|
|
tracker.set_target(image)
|
|
tracker.previous_homography = torch.eye(3)
|
|
tracker.reset_tracking()
|
|
assert tracker.previous_homography is None
|
|
|
|
def test_set_target_without_extract_features(self):
|
|
tracker = _make_tracker()
|
|
image = torch.rand(1, 1, 8, 8)
|
|
tracker.set_target(image)
|
|
assert torch.equal(tracker.target, image)
|
|
# Representations stay empty when extract_features not present
|
|
assert tracker.target_initial_representation == {}
|
|
assert tracker.target_fast_representation == {}
|
|
|
|
def test_set_target_with_extract_features(self):
|
|
from torch import nn
|
|
|
|
fake_feats = {"desc": torch.rand(1, 4)}
|
|
|
|
class _FakeExtract(nn.Module):
|
|
def forward(self, x):
|
|
return fake_feats
|
|
|
|
initial_matcher = MagicMock()
|
|
fast_matcher = MagicMock()
|
|
initial_matcher.extract_features = _FakeExtract()
|
|
fast_matcher.extract_features = _FakeExtract()
|
|
tracker = HomographyTracker(
|
|
initial_matcher=initial_matcher,
|
|
fast_matcher=fast_matcher,
|
|
ransac=MagicMock(),
|
|
)
|
|
image = torch.rand(1, 1, 8, 8)
|
|
tracker.set_target(image)
|
|
assert tracker.target_initial_representation == fake_feats
|
|
assert tracker.target_fast_representation == fake_feats
|
|
|
|
def test_no_match_returns_empty_tensor_and_false(self):
|
|
tracker = _make_tracker()
|
|
image = torch.rand(1, 1, 8, 8)
|
|
tracker.set_target(image)
|
|
H, success = tracker.no_match()
|
|
assert not success
|
|
assert H.shape == (3, 3)
|
|
assert tracker.inliers_num == 0
|
|
|
|
def test_match_initial_too_few_keypoints(self):
|
|
# Fewer than minimum_inliers_num keypoints → no_match
|
|
tracker = _make_tracker(minimum_inliers_num=10)
|
|
image = torch.rand(1, 1, 8, 8)
|
|
tracker.set_target(image)
|
|
tracker.initial_matcher.return_value = _match_dict(3, torch.device("cpu"), torch.float32)
|
|
_, success = tracker.match_initial(torch.rand(1, 1, 8, 8))
|
|
assert not success
|
|
|
|
def test_match_initial_too_few_inliers(self):
|
|
# Enough keypoints but RANSAC reports few inliers → no_match
|
|
tracker = _make_tracker(minimum_inliers_num=5)
|
|
image = torch.rand(1, 1, 8, 8)
|
|
tracker.set_target(image)
|
|
tracker.initial_matcher.return_value = _match_dict(20, torch.device("cpu"), torch.float32)
|
|
# RANSAC returns homography + inlier mask with only 2 inliers
|
|
inliers = torch.zeros(20, dtype=torch.bool)
|
|
inliers[:2] = True
|
|
tracker.ransac.return_value = (torch.eye(3), inliers)
|
|
_, success = tracker.match_initial(torch.rand(1, 1, 8, 8))
|
|
assert not success
|
|
|
|
def test_match_initial_success(self):
|
|
tracker = _make_tracker(minimum_inliers_num=5)
|
|
image = torch.rand(1, 1, 8, 8)
|
|
tracker.set_target(image)
|
|
tracker.initial_matcher.return_value = _match_dict(20, torch.device("cpu"), torch.float32)
|
|
inliers = torch.ones(20, dtype=torch.bool)
|
|
H_expected = torch.eye(3) * 2
|
|
tracker.ransac.return_value = (H_expected, inliers)
|
|
H, success = tracker.match_initial(torch.rand(1, 1, 8, 8))
|
|
assert success
|
|
assert tracker.previous_homography is not None
|
|
assert torch.allclose(H, H_expected)
|
|
|
|
def test_forward_routes_to_match_initial_when_no_previous(self):
|
|
tracker = _make_tracker(minimum_inliers_num=5)
|
|
image = torch.rand(1, 1, 8, 8)
|
|
tracker.set_target(image)
|
|
tracker.initial_matcher.return_value = _match_dict(20, torch.device("cpu"), torch.float32)
|
|
inliers = torch.ones(20, dtype=torch.bool)
|
|
tracker.ransac.return_value = (torch.eye(3), inliers)
|
|
assert tracker.previous_homography is None
|
|
_, success = tracker(torch.rand(1, 1, 8, 8))
|
|
assert success
|
|
|
|
def test_forward_routes_to_track_next_frame_when_previous_set(self):
|
|
tracker = _make_tracker(minimum_inliers_num=5)
|
|
image = torch.rand(1, 1, 8, 8)
|
|
tracker.set_target(image)
|
|
tracker.previous_homography = torch.eye(3)
|
|
tracker.fast_matcher.return_value = _match_dict(20, torch.device("cpu"), torch.float32)
|
|
inliers = torch.ones(20, dtype=torch.bool)
|
|
tracker.ransac.return_value = (torch.eye(3), inliers)
|
|
_, success = tracker(torch.rand(1, 1, 8, 8))
|
|
assert success
|
|
|
|
def test_track_next_frame_too_few_keypoints_resets(self):
|
|
tracker = _make_tracker(minimum_inliers_num=10)
|
|
image = torch.rand(1, 1, 8, 8)
|
|
tracker.set_target(image)
|
|
tracker.previous_homography = torch.eye(3)
|
|
tracker.fast_matcher.return_value = _match_dict(3, torch.device("cpu"), torch.float32)
|
|
_, success = tracker.track_next_frame(torch.rand(1, 1, 8, 8))
|
|
assert not success
|
|
assert tracker.previous_homography is None # reset_tracking was called
|
|
|
|
def test_track_next_frame_too_few_inliers_resets(self):
|
|
tracker = _make_tracker(minimum_inliers_num=5)
|
|
image = torch.rand(1, 1, 8, 8)
|
|
tracker.set_target(image)
|
|
tracker.previous_homography = torch.eye(3)
|
|
tracker.fast_matcher.return_value = _match_dict(20, torch.device("cpu"), torch.float32)
|
|
inliers = torch.zeros(20, dtype=torch.bool)
|
|
inliers[:2] = True
|
|
tracker.ransac.return_value = (torch.eye(3), inliers)
|
|
_, success = tracker.track_next_frame(torch.rand(1, 1, 8, 8))
|
|
assert not success
|
|
assert tracker.previous_homography is None # reset_tracking was called
|
|
|
|
|
|
@pytest.fixture()
|
|
def data_url():
|
|
url = "https://github.com/kornia/data_test/blob/main/loftr_outdoor_and_homography_data.pt?raw=true"
|
|
return url
|
|
|
|
|
|
class TestHomographyTracker(BaseTester):
|
|
@pytest.mark.slow
|
|
def test_smoke(self, device):
|
|
tracker = HomographyTracker().to(device)
|
|
assert tracker is not None
|
|
|
|
@pytest.mark.slow
|
|
def test_nomatch(self, device, dtype, data_url):
|
|
data = torch.hub.load_state_dict_from_url(data_url)
|
|
|
|
# This is not unit test, but that is quite good integration test
|
|
matcher = LocalFeatureMatcher(SIFTFeature(100), DescriptorMatcher("smnn", 0.95)).to(device, dtype)
|
|
tracker = HomographyTracker(matcher, matcher, minimum_inliers_num=100)
|
|
for k in data.keys():
|
|
if isinstance(data[k], torch.Tensor):
|
|
data[k] = data[k].to(device, dtype)
|
|
tracker.set_target(data["image0"])
|
|
torch.random.manual_seed(0)
|
|
_, success = tracker(torch.zeros_like(data["image0"]))
|
|
assert not success
|
|
|
|
@pytest.mark.slow
|
|
@pytest.mark.skipif(torch_version_le(1, 9, 1), reason="Fails for bached torch.linalg.solve")
|
|
def test_real(self, device, dtype, data_url):
|
|
data = torch.hub.load_state_dict_from_url(data_url)
|
|
# This is not unit test, but that is quite good integration test
|
|
for k in data.keys():
|
|
if isinstance(data[k], torch.Tensor):
|
|
data[k] = data[k].to(device, dtype)
|
|
|
|
data["image0"] = rescale(data["image0"], 0.5, interpolation="bilinear", align_corners=False)
|
|
data["image1"] = rescale(data["image1"], 0.5, interpolation="bilinear", align_corners=False)
|
|
|
|
matcher = LocalFeatureMatcher(GFTTAffNetHardNet(1000), DescriptorMatcher("snn", 0.8)).to(device, dtype)
|
|
torch.manual_seed(8) # issue kornia#2027
|
|
tracker = HomographyTracker(matcher, matcher).to(device, dtype)
|
|
|
|
with torch.no_grad():
|
|
tracker.set_target(data["image0"])
|
|
torch.manual_seed(8) # issue kornia#2027
|
|
homography, success = tracker(data["image1"])
|
|
assert success
|
|
pts_src = data["pts0"].to(device, dtype) / 2.0
|
|
pts_dst = data["pts1"].to(device, dtype) / 2.0
|
|
# Reprojection error of 5px is OK
|
|
self.assert_close(transform_points(homography[None], pts_src[None]), pts_dst[None], rtol=5e-2, atol=5)
|
|
|
|
with torch.no_grad():
|
|
torch.manual_seed(6)
|
|
homography, success = tracker(data["image1"])
|
|
assert success
|
|
self.assert_close(transform_points(homography[None], pts_src[None]), pts_dst[None], rtol=5e-2, atol=5)
|