Files
opencv/modules/python/test/test_linear_assignment.py
T
omrope79 f01a66d18f Merge pull request #29963 from omrope79:linear-assignment
Add cv::linearAssignment() rectangular assignment solver - #29963

Adds `cv::linearAssignment()` to `modules/core`, next to `solveLP`: a Jonker-Volgenant solver that pairs the rows of a cost matrix with its columns at the lowest total cost. It pairs existing tracks with new detections in a tracking pipeline.

`costThreshold` is applied during the solve, so a pair that is too expensive is forbidden and a pair is only made when it beats the price of leaving it unmade. Implemented from Crouse 2016, cited in the doxygen. 

Accuracy and performance tests are included and need no new test data.

### Pull Request Readiness Checklist

See details at https://github.com/opencv/opencv/wiki/How_to_contribute#making-a-good-pull-request

- [x] I agree to contribute to the project under Apache 2 License.
- [x] To the best of my knowledge, the proposed patch is not based on a code under GPL or another license that is incompatible with OpenCV
- [x] The PR is proposed to the proper branch
- [x] There is a reference to the original bug report and related work
- [x] There is accuracy test, performance test and test data in opencv_extra repository, if applicable
      Patch to opencv_extra has the same branch name.
- [x] The feature is well documented and sample code can be built with the project CMake
2026-09-29 17:06:55 +03:00

123 lines
4.7 KiB
Python

#!/usr/bin/env python
from __future__ import print_function
import numpy as np
import cv2 as cv
try:
from lapjv import lapjv
except ImportError:
lapjv = None
from tests_common import NewOpenCVTests
class linear_assignment_test(NewOpenCVTests):
def check_pairs(self, cost, total, assignment, threshold=None):
'''Every reported pair must be legal, each column used once, and the total must match.'''
self.assertEqual(len(assignment), cost.shape[0])
seen = set()
summed = 0.0
for row, col in enumerate(assignment):
if col < 0:
self.assertEqual(col, -1)
continue
self.assertLess(col, cost.shape[1])
self.assertNotIn(col, seen)
seen.add(col)
self.assertTrue(np.isfinite(cost[row, col]))
if threshold is not None:
self.assertLessEqual(cost[row, col], threshold)
summed += cost[row, col]
self.assertAlmostEqual(total, summed, places=9)
def test_basic(self):
cost = np.array([[4, 1, 3],
[2, 0, 5],
[3, 2, 2]], dtype=np.float64)
total, assignment = cv.linearAssignment(cost)
self.assertAlmostEqual(total, 5.0, places=9)
self.assertEqual(list(assignment), [1, 0, 2])
def test_non_square(self):
cost = np.array([[7, 1, 9, 8],
[6, 5, 2, 4]], dtype=np.float32)
total, assignment = cv.linearAssignment(cost)
# Row 0 is cheapest on column 1 and row 1 on column 2, so both rows are matched.
self.assertEqual(list(assignment), [1, 2])
self.assertAlmostEqual(total, 3.0, places=5)
def test_threshold_forbids_expensive_pairs(self):
# Both pairs are affordable but cost 20 together, while leaving one row unmatched
# costs 10. The cheap pair alone wins.
cost = np.array([[0, 10],
[10, 100]], dtype=np.float64)
total, assignment = cv.linearAssignment(cost, costThreshold=10.0)
self.assertEqual(list(assignment), [0, -1])
self.assertAlmostEqual(total, 0.0, places=9)
total, assignment = cv.linearAssignment(cost, costThreshold=50.0)
self.assertEqual(list(assignment), [1, 0])
self.assertAlmostEqual(total, 20.0, places=9)
def test_infinity_forbids_a_pair(self):
cost = np.array([[10, np.inf],
[1, np.inf]], dtype=np.float64)
total, assignment = cv.linearAssignment(cost)
# Column 1 is forbidden on both rows, so only one row can be matched and the solver
# keeps the cheaper of the two. Row 1 costs 1 against row 0's 10, so row 0 is the one
# left at -1.
self.assertEqual(list(assignment), [-1, 0])
self.assertAlmostEqual(total, 1.0, places=9)
def test_empty_input_throws(self):
with self.assertRaises(cv.error):
cv.linearAssignment(np.zeros((0, 0), dtype=np.float64))
with self.assertRaises(cv.error):
cv.linearAssignment(np.zeros((3, 0), dtype=np.float64))
def test_matches_bruteforce_reference(self):
# Without a threshold this is the plain optimal assignment, so a brute force over
# permutations is an exact reference.
from itertools import permutations
rng = np.random.default_rng(12345)
for _ in range(50):
n = int(rng.integers(1, 6))
cost = rng.uniform(-5.0, 20.0, size=(n, n))
expected = min(sum(cost[i, p[i]] for i in range(n))
for p in permutations(range(n)))
total, assignment = cv.linearAssignment(cost)
self.assertAlmostEqual(total, expected, places=9)
self.check_pairs(cost, total, assignment)
self.assertTrue(all(a >= 0 for a in assignment))
def test_matches_lapjv(self):
if lapjv is None:
self.skipTest("lapjv is not available")
# lapjv takes square matrices only and has no threshold, which is exactly the
# unconstrained case this should agree with.
rng = np.random.default_rng(987)
for _ in range(25):
n = int(rng.integers(2, 12))
cost = rng.uniform(-5.0, 20.0, size=(n, n))
total, assignment = cv.linearAssignment(cost)
ref_cols = lapjv(cost)[0]
ref_total = sum(cost[i, ref_cols[i]] for i in range(n))
# Equal totals is the real check. The pairing itself is only unique when no two
# matchings tie, which random costs make overwhelmingly likely but not certain.
self.assertAlmostEqual(total, ref_total, places=9)
self.check_pairs(cost, total, assignment)
if __name__ == '__main__':
NewOpenCVTests.bootstrap()