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