test: add face tracker unit tests — 27 tests for IoU, distance, embedding sim, match logic, and tracking pipeline
This commit is contained in:
230
scripts/test_face_tracker.py
Normal file
230
scripts/test_face_tracker.py
Normal file
@@ -0,0 +1,230 @@
|
||||
#!/opt/homebrew/bin/python3.11
|
||||
"""Unit tests for face_tracker.py"""
|
||||
|
||||
import sys
|
||||
import json
|
||||
import math
|
||||
import unittest
|
||||
import numpy as np
|
||||
from typing import Dict, List, Set
|
||||
|
||||
sys.path.insert(0, "scripts/utils")
|
||||
from face_tracker import (
|
||||
calculate_bbox_iou,
|
||||
calculate_bbox_distance,
|
||||
calculate_embedding_similarity,
|
||||
match_faces,
|
||||
track_faces,
|
||||
)
|
||||
|
||||
|
||||
class TestBboxIoU(unittest.TestCase):
|
||||
def test_identical_bboxes(self):
|
||||
bbox = {"x": 100, "y": 100, "width": 200, "height": 200}
|
||||
self.assertAlmostEqual(calculate_bbox_iou(bbox, bbox), 1.0)
|
||||
|
||||
def test_no_overlap(self):
|
||||
b1 = {"x": 0, "y": 0, "width": 100, "height": 100}
|
||||
b2 = {"x": 200, "y": 200, "width": 100, "height": 100}
|
||||
self.assertEqual(calculate_bbox_iou(b1, b2), 0.0)
|
||||
|
||||
def test_partial_overlap(self):
|
||||
b1 = {"x": 0, "y": 0, "width": 100, "height": 100}
|
||||
b2 = {"x": 50, "y": 50, "width": 100, "height": 100}
|
||||
iou = calculate_bbox_iou(b1, b2)
|
||||
self.assertGreater(iou, 0.0)
|
||||
self.assertLess(iou, 1.0)
|
||||
self.assertAlmostEqual(iou, 2500 / (10000 + 10000 - 2500))
|
||||
|
||||
def test_contained(self):
|
||||
b1 = {"x": 0, "y": 0, "width": 200, "height": 200}
|
||||
b2 = {"x": 50, "y": 50, "width": 50, "height": 50}
|
||||
iou = calculate_bbox_iou(b1, b2)
|
||||
self.assertGreater(iou, 0.0)
|
||||
self.assertLess(iou, 1.0)
|
||||
|
||||
def test_zero_area(self):
|
||||
b1 = {"x": 0, "y": 0, "width": 0, "height": 100}
|
||||
b2 = {"x": 0, "y": 0, "width": 100, "height": 100}
|
||||
self.assertEqual(calculate_bbox_iou(b1, b2), 0.0)
|
||||
|
||||
|
||||
class TestBboxDistance(unittest.TestCase):
|
||||
def test_same_center(self):
|
||||
bbox = {"x": 100, "y": 100, "width": 100, "height": 100}
|
||||
self.assertAlmostEqual(calculate_bbox_distance(bbox, bbox), 0.0)
|
||||
|
||||
def test_horizontal_shift(self):
|
||||
b1 = {"x": 0, "y": 0, "width": 100, "height": 100}
|
||||
b2 = {"x": 100, "y": 0, "width": 100, "height": 100}
|
||||
self.assertAlmostEqual(calculate_bbox_distance(b1, b2), 100.0)
|
||||
|
||||
def test_diagonal_shift(self):
|
||||
b1 = {"x": 0, "y": 0, "width": 100, "height": 100}
|
||||
b2 = {"x": 100, "y": 100, "width": 100, "height": 100}
|
||||
expected = math.sqrt(100**2 + 100**2)
|
||||
self.assertAlmostEqual(calculate_bbox_distance(b1, b2), expected)
|
||||
|
||||
|
||||
class TestEmbeddingSimilarity(unittest.TestCase):
|
||||
def test_identical(self):
|
||||
emb = [1.0, 0.0, 0.0]
|
||||
self.assertAlmostEqual(calculate_embedding_similarity(emb, emb), 1.0)
|
||||
|
||||
def test_opposite(self):
|
||||
self.assertAlmostEqual(
|
||||
calculate_embedding_similarity([1.0, 0.0], [-1.0, 0.0]), -1.0
|
||||
)
|
||||
|
||||
def test_orthogonal(self):
|
||||
self.assertAlmostEqual(
|
||||
calculate_embedding_similarity([1.0, 0.0], [0.0, 1.0]), 0.0
|
||||
)
|
||||
|
||||
def test_partial_match(self):
|
||||
sim = calculate_embedding_similarity([1.0, 0.0, 0.0], [0.5, 0.5, 0.0])
|
||||
self.assertGreater(sim, 0.5)
|
||||
self.assertLess(sim, 1.0)
|
||||
|
||||
def test_none_embedding(self):
|
||||
self.assertEqual(calculate_embedding_similarity(None, [1.0, 0.0]), 0.0)
|
||||
self.assertEqual(calculate_embedding_similarity([1.0, 0.0], None), 0.0)
|
||||
|
||||
def test_zero_norm(self):
|
||||
self.assertEqual(calculate_embedding_similarity([0.0, 0.0], [1.0, 0.0]), 0.0)
|
||||
|
||||
|
||||
class TestMatchFaces(unittest.TestCase):
|
||||
def make_face(self, x, y, w, h, embedding=None, c=1.0):
|
||||
f = {"x": x, "y": y, "width": w, "height": h, "confidence": c}
|
||||
if embedding:
|
||||
f["embedding"] = embedding
|
||||
return f
|
||||
|
||||
def test_no_previous(self):
|
||||
curr = [self.make_face(0, 0, 100, 100)]
|
||||
result = match_faces(curr, [])
|
||||
self.assertEqual(result, {0: -1})
|
||||
|
||||
def test_same_position_match(self):
|
||||
curr = [self.make_face(0, 0, 100, 100, embedding=[1.0, 0.0])]
|
||||
prev = [self.make_face(0, 0, 100, 100, embedding=[1.0, 0.0])]
|
||||
result = match_faces(curr, prev)
|
||||
self.assertEqual(result, {0: 0})
|
||||
|
||||
def test_reject_low_sim_low_iou(self):
|
||||
curr = [self.make_face(300, 300, 100, 100, embedding=[0.0, 1.0])]
|
||||
prev = [self.make_face(0, 0, 100, 100, embedding=[1.0, 0.0])]
|
||||
result = match_faces(curr, prev)
|
||||
self.assertEqual(result, {0: -1})
|
||||
|
||||
def test_match_by_position_only(self):
|
||||
curr = [self.make_face(0, 0, 100, 100, embedding=[0.0, 1.0])]
|
||||
prev = [self.make_face(0, 0, 100, 100, embedding=[1.0, 0.0])]
|
||||
result = match_faces(curr, prev)
|
||||
self.assertEqual(result, {0: 0})
|
||||
|
||||
def test_reject_size_change(self):
|
||||
curr = [self.make_face(0, 0, 10, 10, embedding=[0.3, 0.7])]
|
||||
prev = [self.make_face(0, 0, 100, 100, embedding=[0.7, 0.3])]
|
||||
result = match_faces(curr, prev)
|
||||
self.assertEqual(result, {0: -1})
|
||||
|
||||
def test_cut_boundary_split(self):
|
||||
curr = [self.make_face(0, 0, 100, 100)]
|
||||
prev = [self.make_face(0, 0, 100, 100)]
|
||||
|
||||
def test_edge_exit_reject(self):
|
||||
curr = [self.make_face(200, 200, 100, 100, embedding=[0.3, 0.7])]
|
||||
prev = [self.make_face(0, 0, 100, 100, embedding=[0.7, 0.3])]
|
||||
result = match_faces(curr, prev)
|
||||
self.assertEqual(result, {0: -1})
|
||||
|
||||
def test_cut_boundary_split(self):
|
||||
curr = [self.make_face(0, 0, 100, 100)]
|
||||
prev = [self.make_face(0, 0, 100, 100)]
|
||||
result = match_faces(curr, prev, cut_boundaries={5}, prev_frame=4, curr_frame=6)
|
||||
self.assertEqual(result, {0: -1})
|
||||
|
||||
def test_edge_exit_reject(self):
|
||||
curr = [self.make_face(200, 200, 100, 100, embedding=[0.3, 0.7])]
|
||||
prev = [self.make_face(0, 0, 100, 100, embedding=[0.7, 0.3])]
|
||||
result = match_faces(curr, prev)
|
||||
self.assertEqual(result, {0: -1})
|
||||
|
||||
def test_multiple_faces_no_conflict(self):
|
||||
curr = [
|
||||
self.make_face(0, 0, 100, 100, embedding=[1.0, 0.0, 0.0]),
|
||||
self.make_face(200, 200, 100, 100, embedding=[0.0, 1.0, 0.0]),
|
||||
]
|
||||
prev = [
|
||||
self.make_face(0, 0, 100, 100, embedding=[1.0, 0.0, 0.0]),
|
||||
self.make_face(200, 200, 100, 100, embedding=[0.0, 1.0, 0.0]),
|
||||
]
|
||||
result = match_faces(curr, prev)
|
||||
self.assertEqual(result, {0: 0, 1: 1})
|
||||
|
||||
|
||||
class TestTrackFaces(unittest.TestCase):
|
||||
def make_face_data(self, frames_data: Dict[int, List[Dict]]) -> Dict:
|
||||
frames = {}
|
||||
for fnum, face_list in frames_data.items():
|
||||
frames[str(fnum)] = {"faces": face_list}
|
||||
return {"frames": frames, "metadata": {"fps": 25.0}}
|
||||
|
||||
def test_single_frame(self):
|
||||
data = self.make_face_data({
|
||||
0: [{"x": 0, "y": 0, "width": 100, "height": 100, "confidence": 1.0}]
|
||||
})
|
||||
result = track_faces(data)
|
||||
traces = result.get("traces", {})
|
||||
self.assertEqual(len(traces), 1)
|
||||
t = traces.get("0", {})
|
||||
self.assertEqual(t["start_frame"], 0)
|
||||
self.assertEqual(t["end_frame"], 0)
|
||||
|
||||
def test_face_appears_disappears(self):
|
||||
data = self.make_face_data({
|
||||
0: [{"x": 0, "y": 0, "width": 100, "height": 100, "confidence": 1.0}],
|
||||
1: [],
|
||||
2: [{"x": 100, "y": 100, "width": 100, "height": 100, "confidence": 1.0}],
|
||||
})
|
||||
result = track_faces(data)
|
||||
traces = result.get("traces", {})
|
||||
self.assertEqual(len(traces), 2)
|
||||
|
||||
def test_same_face_stable(self):
|
||||
face = {"x": 50, "y": 50, "width": 100, "height": 100, "confidence": 1.0}
|
||||
data = self.make_face_data({
|
||||
0: [dict(face)],
|
||||
1: [dict(face)],
|
||||
2: [dict(face)],
|
||||
})
|
||||
result = track_faces(data)
|
||||
traces = result.get("traces", {})
|
||||
self.assertEqual(len(traces), 1)
|
||||
t = list(traces.values())[0]
|
||||
self.assertEqual(t["start_frame"], 0)
|
||||
self.assertEqual(t["end_frame"], 2)
|
||||
|
||||
def test_cut_splits_trace(self):
|
||||
data = self.make_face_data({
|
||||
0: [{"x": 50, "y": 50, "width": 100, "height": 100, "confidence": 1.0}],
|
||||
1: [{"x": 50, "y": 50, "width": 100, "height": 100, "confidence": 1.0}],
|
||||
})
|
||||
result = track_faces(data, cut_boundaries={1})
|
||||
self.assertEqual(len(result["traces"]), 2)
|
||||
|
||||
def test_trace_stats_present(self):
|
||||
data = self.make_face_data({
|
||||
0: [{"x": 0, "y": 0, "width": 100, "height": 100, "confidence": 0.9}],
|
||||
})
|
||||
result = track_faces(data)
|
||||
stats = result["metadata"]["trace_stats"]
|
||||
self.assertIn("total_traces", stats)
|
||||
self.assertIn("active_traces", stats)
|
||||
self.assertIn("long_traces", stats)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user