Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

MOTA revisited #181

Merged
merged 57 commits into from
Jul 9, 2024
Merged
Show file tree
Hide file tree
Changes from 33 commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
73ea420
modify id switches
nikk-nikaznan Jun 5, 2024
768b81e
change the mota and test
nikk-nikaznan Jun 5, 2024
3036b06
Merge branch 'main' into nikkna/id_switches
nikk-nikaznan Jun 5, 2024
f18a44c
change the variable
nikk-nikaznan Jun 14, 2024
6c0b0ed
Merge branch 'main' into nikkna/id_switches
nikk-nikaznan Jun 18, 2024
a851e9f
Merge branch 'main' into nikkna/id_switches
nikk-nikaznan Jun 21, 2024
611bd60
some bug fixed, load from checkpoint
nikk-nikaznan Jun 24, 2024
11bded7
Merge branch 'nikkna/id_switches' of github.com:SainsburyWellcomeCent…
nikk-nikaznan Jun 24, 2024
4b9a794
Merge branch 'main' into nikkna/id_switches
nikk-nikaznan Jun 24, 2024
cfe67ca
change list type, add gt_ids
nikk-nikaznan Jun 25, 2024
7de7561
fixed the error id switches
nikk-nikaznan Jun 25, 2024
25f36c7
changes id switches
nikk-nikaznan Jun 25, 2024
de0b9cd
fixing some test and type hint
nikk-nikaznan Jun 27, 2024
5e56e1a
Merge branch 'main' into nikkna/id_switches
nikk-nikaznan Jun 27, 2024
6aa1b63
fixing test, parametrize the test with additional test
nikk-nikaznan Jun 28, 2024
05faa18
Merge branch 'main' into nikkna/id_switches
nikk-nikaznan Jun 28, 2024
a592c1a
cleane dup
nikk-nikaznan Jun 28, 2024
eefcf33
checking some test
nikk-nikaznan Jun 28, 2024
f0bcf65
rebase
nikk-nikaznan Jun 28, 2024
da0c62b
cleaned up
nikk-nikaznan Jun 28, 2024
01cb0f4
test works
nikk-nikaznan Jun 28, 2024
3ca1872
test works
nikk-nikaznan Jun 28, 2024
4d32f77
aded specific example
nikk-nikaznan Jun 28, 2024
ff059ea
some more test
nikk-nikaznan Jun 28, 2024
1195854
Update crabs/tracker/evaluate_tracker.py
nikk-nikaznan Jul 3, 2024
67fe295
Update crabs/tracker/evaluate_tracker.py
nikk-nikaznan Jul 3, 2024
9bf3a73
combine gt functions, fix test
nikk-nikaznan Jul 3, 2024
93915e1
rename test
nikk-nikaznan Jul 3, 2024
e1a8537
cleaned up linting
nikk-nikaznan Jul 3, 2024
d6401e1
adding some more description
nikk-nikaznan Jul 3, 2024
64ae8e8
Merge branch 'main' into nikkna/id_switches
nikk-nikaznan Jul 3, 2024
73ae064
change the nested folder structure for output
nikk-nikaznan Jul 3, 2024
163cc06
adding device to cli
nikk-nikaznan Jul 4, 2024
cc6bbcf
Merge branch 'main' into nikkna/id_switches
nikk-nikaznan Jul 5, 2024
f042b2b
attempt yesterday
nikk-nikaznan Jul 5, 2024
56ff81d
small changes in docstring
nikk-nikaznan Jul 5, 2024
73607b4
Update crabs/tracker/utils/io.py
nikk-nikaznan Jul 5, 2024
f8c91a9
Update crabs/tracker/evaluate_tracker.py
nikk-nikaznan Jul 5, 2024
21930ac
changes for gt dict
nikk-nikaznan Jul 5, 2024
0e8a687
predicted as dict
nikk-nikaznan Jul 5, 2024
6e12530
rename varibale, fix test
nikk-nikaznan Jul 5, 2024
36757a5
reviewing id switch
nikk-nikaznan Jul 5, 2024
5b5e1de
commented out the test that fail
nikk-nikaznan Jul 5, 2024
e8f4446
commented out the test that fail
nikk-nikaznan Jul 5, 2024
a464d15
seems working
nikk-nikaznan Jul 5, 2024
98e77c3
small modification for the test
nikk-nikaznan Jul 5, 2024
41ab8cc
cleaned up
nikk-nikaznan Jul 5, 2024
491ae68
cleaned up
nikk-nikaznan Jul 8, 2024
43e3c86
Merge branch 'main' into nikkna/id_switches
nikk-nikaznan Jul 8, 2024
5e11991
Update crabs/tracker/track_video.py
nikk-nikaznan Jul 9, 2024
8b8b9c9
Update crabs/tracker/track_video.py
nikk-nikaznan Jul 9, 2024
cc1e8c6
Update crabs/tracker/evaluate_tracker.py
nikk-nikaznan Jul 9, 2024
fcf6e66
Update crabs/tracker/track_video.py
nikk-nikaznan Jul 9, 2024
7a2ba9f
Update crabs/tracker/evaluate_tracker.py
nikk-nikaznan Jul 9, 2024
fa7fa08
Update crabs/tracker/evaluate_tracker.py
nikk-nikaznan Jul 9, 2024
24935ae
fixed frame_number vs frame_idx
nikk-nikaznan Jul 9, 2024
1d45dd6
Update crabs/tracker/evaluate_tracker.py
nikk-nikaznan Jul 9, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
228 changes: 117 additions & 111 deletions crabs/tracker/evaluate_tracker.py
Original file line number Diff line number Diff line change
@@ -1,38 +1,57 @@
import csv
import logging
from typing import Any, Dict, Optional
from typing import Any, Dict, Optional, Tuple

import numpy as np

from crabs.tracker.utils.tracking import extract_bounding_box_info


class TrackerEvaluate:
def __init__(self, gt_dir: str, tracked_list: list, iou_threshold: float):
def __init__(
self, gt_dir: str, tracked_list: list[np.ndarray], iou_threshold: float
):
"""
Initialize the TrackerEvaluate class with ground truth directory, tracked list, and IoU threshold.

Parameters
----------
gt_dir : str
Directory path of the ground truth CSV file.
tracked_list : List[np.ndarray]
A list where each element is a numpy array representing tracked objects in a frame.
Each numpy array has shape (N, 5), where N is the number of objects.
The columns are [x1, y1, x2, y2, id], where (x1, y1) and (x2, y2)
define the bounding box and id is the object ID.
iou_threshold : float
Intersection over Union (IoU) threshold for evaluating tracking performance.
"""
self.gt_dir = gt_dir
self.tracked_list = tracked_list
nikk-nikaznan marked this conversation as resolved.
Show resolved Hide resolved
self.iou_threshold = iou_threshold

def create_gt_list(
self,
ground_truth_data: list[Dict[str, Any]],
gt_boxes_list: list[np.ndarray],
) -> list[np.ndarray]:
def get_ground_truth_data(self) -> Dict[int, Dict[str, Any]]:
"""
Creates a list of ground truth bounding boxes organized by frame number.

Parameters
----------
ground_truth_data : list[Dict[str, Any]]
A list containing ground truth bounding box data organized by frame number.
gt_boxes_list : list[np.ndarray]
A list to store the ground truth bounding boxes for each frame.
Extract ground truth bounding box data from a CSV file and organize it by frame number.

Returns
-------
list[np.ndarray]:
A list containing ground truth bounding boxes organized by frame number.
Dict[int, Dict[str, Any]]:
A dictionary where the key is the frame number and the value is another dictionary containing:
- 'bbox': A list of numpy arrays with coordinates of the bounding box [x, y, x + width, y + height]
- 'id': The ground truth ID
"""
ground_truth_data = []
nikk-nikaznan marked this conversation as resolved.
Show resolved Hide resolved

with open(self.gt_dir, "r") as csvfile:
csvreader = csv.reader(csvfile)
next(csvreader) # Skip the header row
ground_truth_data = [
extract_bounding_box_info(row) for row in csvreader
]

# Format as a dictionary with key = frame number
ground_truth_dict: dict = {}
for data in ground_truth_data:
frame_number = data["frame_number"]
bbox = np.array(
Expand All @@ -41,53 +60,18 @@ def create_gt_list(
data["y"],
data["x"] + data["width"],
data["y"] + data["height"],
data["id"],
],
dtype=np.float32,
)
if gt_boxes_list[frame_number].size == 0:
gt_boxes_list[frame_number] = bbox.reshape(
1, -1
) # Initialize as a 2D array
else:
gt_boxes_list[frame_number] = np.vstack(
[gt_boxes_list[frame_number], bbox]
)
return gt_boxes_list
track_id = int(float(data["id"]))

def get_ground_truth_data(self) -> list[np.ndarray]:
"""
Extract ground truth bounding box data from a CSV file.
if frame_number not in ground_truth_dict:
nikk-nikaznan marked this conversation as resolved.
Show resolved Hide resolved
ground_truth_dict[frame_number] = {"bbox": [], "id": []}

Parameters
----------
gt_dir : str
The path to the CSV file containing ground truth data.

Returns
-------
list[np.ndarray]:
A list containing ground truth bounding box data organized by frame number.
The numpy array represent the coordinates and ID of the bounding box in the order:
x, y, x + width, y + height, ID
"""
ground_truth_data = []
max_frame_number = 0

# Open the CSV file and read its contents line by line
with open(self.gt_dir, "r") as csvfile:
csvreader = csv.reader(csvfile)
next(csvreader) # Skip the header row
for row in csvreader:
data = extract_bounding_box_info(row)
ground_truth_data.append(data)
max_frame_number = max(max_frame_number, data["frame_number"])

# Initialize a list to store the ground truth bounding boxes for each frame
gt_boxes_list = [np.array([]) for _ in range(max_frame_number + 1)]
ground_truth_dict[frame_number]["bbox"].append(bbox)
ground_truth_dict[frame_number]["id"].append(track_id)

gt_boxes_list = self.create_gt_list(ground_truth_data, gt_boxes_list)
return gt_boxes_list
return ground_truth_dict
nikk-nikaznan marked this conversation as resolved.
Show resolved Hide resolved

def calculate_iou(self, box1: np.ndarray, box2: np.ndarray) -> float:
"""
Expand Down Expand Up @@ -131,46 +115,53 @@ def calculate_iou(self, box1: np.ndarray, box2: np.ndarray) -> float:

def count_identity_switches(
self,
prev_frame_ids: Optional[list[list[int]]],
current_frame_ids: Optional[list[list[int]]],
prev_frame_id_map: Optional[Dict[int, int]],
sfmig marked this conversation as resolved.
Show resolved Hide resolved
current_frame_id_map: Dict[int, int],
) -> int:
"""
Count the number of identity switches between two sets of object IDs.

Parameters
----------
prev_frame_ids : Optional[list[list[int]]]
List of object IDs in the previous frame.
current_frame_ids : Optional[list[list[int]]]
List of object IDs in the current frame.
prev_frame_id_map : Optional[Dict[int, int]]
A dictionary mapping ground truth IDs to predicted IDs from the previous frame.
gt_to_tracked_map : Dict[int, int]
A dictionary mapping ground truth IDs to predicted IDs for the current frame.


Returns
-------
int
The number of identity switches between the two sets of object IDs.
"""

if prev_frame_ids is None or current_frame_ids is None:
if prev_frame_id_map is None:
return 0

# Initialize count of identity switches
num_switches = 0
prev_frame_gt_id_map = {v: k for k, v in prev_frame_id_map.items()}
nikk-nikaznan marked this conversation as resolved.
Show resolved Hide resolved

prev_ids = set(prev_frame_ids[0])
current_ids = set(current_frame_ids[0])
switch_count = 0

# Calculate the number of switches by finding the difference in IDs
num_switches = len(prev_ids.symmetric_difference(current_ids))
for current_gt_id, current_tracked_id in current_frame_id_map.items():
prev_tracked_id = prev_frame_id_map.get(current_gt_id)
nikk-nikaznan marked this conversation as resolved.
Show resolved Hide resolved
prev_gt_id = prev_frame_gt_id_map.get(current_tracked_id)
if prev_tracked_id is not None:
if prev_tracked_id != current_tracked_id:
switch_count += 1
elif prev_gt_id is not None:
if current_gt_id != prev_gt_id:
switch_count += 1
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like it, it's compact!

But we also discussed offline a different implementation that I feel structures the problem a bit more and reads a bit better.

We also initially thought it would cover cases that are missing here, but I think with all the fixes it doesn't anymore 😅. So just an alternative.

Below the pseudocode.

definitions

  • map_gt_to_pred_id_prev: dictionary that maps from gtID in f-1 to predID in f-1
  • map_gt_to_pred_id_current: dictionary that maps from gtID in f to predID in f

For both of these maps, if a crab is not detected in the frame, then predID = np.nan.

pseudocode

# Compute number of ID switches from previous frame (f-1) to current frame (f)
switch_counter = 0

# Compute lists of the groundtruth IDs of crabs that continue to exist, disappear and appear, when going from frame f-1 to f
gt_ids_current_frame = map_gt_to_pred_id_current.keys()
gt_ids_prev_frame = map_gt_to_pred_id_prev.keys()

gt_ids_cont = list(set(gt_ids_current_frame) & set(gt_ids_prev_frame))
gt_ids_disappear = list(set(gt_ids_prev_frame) - set(gt_ids_current_frame))
gt_ids_appear = list(set(gt_ids_current_frame) - set(gt_ids_prev_frame))

# 1: crabs that continue to exist
# 1: crabs that continue to exist
for elem in gt_ids_cont:
	previous_predID = map_gt_to_pred_id_prev[elem]
	curr_predID = map_gt_to_pred_id_prev[elem]
	if (not np.isnan(previous_predID)) and (not np.isnan(curr_predID)):
		if (curr_predID != previous_predID):
			switch_counter += 1
		
# 2: crabs that disappear
for elem in gt_ids_disappear:
	previous_predID = map_gt_to_pred_id_prev[elem]
	if not np.isnan(previous_predID):  # exclude ifmissed detection in previous frame
		if previous_predID in map_gt_to_pred_id_current.values():
			switch_counter += 1
			
# 3: crabs that appear
for elem in gt_ids_appear:
	current_predID = map_gt_to_pred_id_current[elem]
	if not np.isnan(current_predID):  # exclude if missed detection in this frame
		if current_predID in map_gt_to_pred_id_prev.values():
			switch_counter += 1

Copy link
Collaborator Author

@nikk-nikaznan nikk-nikaznan Jul 5, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Example 1:
previous frame = {1: 11, 2: 12, 3: 13},
current frame = {1: 11, 2: 12, 4: 13},

The previous gt ID 3 disappear, but the corresponding predicted ID 13 is in current frame. So we consider this ID switch.
The current gt ID 4 appear, and the current predicted ID 13 is in the previous frame. SO we consider this ID switch. In this example, switch_counter is equal to 2. It should be only one right?

Example 2:
previous frame = {1: 11, 2: 12, 3: 13, 4: 14},
current frame = {1: 11, 2: 12, 3: 14},

The gt ID 3 is continue to exist, but the current predicted ID is not the same to the previous predicted ID. We count this as 1 ID switch.
The gt ID 4 is disappear, but the predicted ID 14 is still exist in current frame. so we have total of 2 ID switch. This also should be one right?

The problem we have, we will check every condition, even if we already count the switch. So some example that can apply on more than one cases, will be counted more than once
I have added another condition to see if we already used red_ids in the previous case, then we will not count the switch -- considering we should not count the ID switch on the same predicted ID for more than once.

Let me know what you think

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks @nikk-nikaznan this is an excellent catch.

I think I agree with example 1 and 2 having an expected switch count = 1

I have added another condition to see if we already used red_ids in the previous case, then we will not count the switch -- considering we should not count the ID switch on the same predicted ID for more than once.

I see, you used used_pred_ids to log if IDs in either the current or the previous frame have been involved in an already counted ID switch.

I think this makes sense to me! I added a comment to that line just to mega clarify.


return num_switches
return switch_count

def evaluate_mota(
self,
gt_boxes: np.ndarray,
gt_ids: np.ndarray,
tracked_boxes: np.ndarray,
iou_threshold: float,
prev_frame_ids: Optional[list[list[int]]],
) -> float:
prev_frame_id_map: Optional[Dict[int, int]],
) -> Tuple[float, Dict[int, int]]:
"""
Evaluate MOTA (Multiple Object Tracking Accuracy).

Expand All @@ -179,18 +170,22 @@ def evaluate_mota(
Parameters
----------
gt_boxes : np.ndarray
Ground truth bounding boxes of objects.
Ground truth bounding boxes of objects with shape of (N, 4) with (x1, y1, x2, y2).
gt_ids : np.ndarray
Ground truth IDs corresponding to the bounding boxes with shape of (N, 1).
tracked_boxes : np.ndarray
Tracked bounding boxes of objects.
Tracked bounding boxes of objects with shape of (N, 5) with (x1, y1, x2, y2, id).
iou_threshold : float
Intersection over Union (IoU) threshold for considering a match.
prev_frame_ids : Optional[list[list[int]]]
IDs from the previous frame for identity switch detection.
prev_frame_id_map : Optional[Dict[int, int]]
A dictionary mapping ground truth IDs to predicted IDs from the previous frame.

Returns
-------
float
The computed MOTA (Multi-Object Tracking Accuracy) score for the tracking performance.
Dict[int, int]
A dictionary mapping ground truth IDs to predicted IDs for the current frame.

Notes
-----
Expand All @@ -203,81 +198,92 @@ def evaluate_mota(
- Identity Switches: Instances where the tracking algorithm assigns a different ID to an object compared to its ID in the previous frame.
- Total Ground Truth: The total number of ground truth objects in the scene.

The MOTA score ranges from 0 to 1, with higher values indicating better tracking performance.
The MOTA score ranges from -inf to 1, with higher values indicating better tracking performance.
A MOTA score of 1 indicates perfect tracking, where there are no missed detections, false positives, or identity switches.
"""
total_gt = len(gt_boxes)
false_positive = 0
matched_gt_boxes = set()
nikk-nikaznan marked this conversation as resolved.
Show resolved Hide resolved
gt_to_tracked_map = {}

for i, tracked_box in enumerate(tracked_boxes):
best_iou = 0.0
best_match = None

for j, gt_box in enumerate(gt_boxes):
iou = self.calculate_iou(gt_box[:4], tracked_box[:4])
if iou > iou_threshold and iou > best_iou:
best_iou = iou
best_match = j
if j not in matched_gt_boxes:
iou = self.calculate_iou(gt_box[:4], tracked_box[:4])
nikk-nikaznan marked this conversation as resolved.
Show resolved Hide resolved
if iou > iou_threshold and iou > best_iou:
best_iou = iou
best_match = j
nikk-nikaznan marked this conversation as resolved.
Show resolved Hide resolved

if best_match is not None:
# successfully found a matching ground truth box for the tracked box.
# set the corresponding ground truth box to None.
gt_boxes[best_match] = None
matched_gt_boxes.add(best_match)
# Map ground truth ID to tracked ID
gt_to_tracked_map[int(gt_ids[best_match])] = int(
nikk-nikaznan marked this conversation as resolved.
Show resolved Hide resolved
tracked_box[-1]
)
else:
false_positive += 1

missed_detections = 0
for box in gt_boxes:
if box is not None and not np.all(np.isnan(box)):
# if true ground truth box was not matched with any tracked box
missed_detections += 1

tracked_ids = [[box[-1] for box in tracked_boxes]]
missed_detections = total_gt - len(matched_gt_boxes)

num_switches = self.count_identity_switches(
prev_frame_ids, tracked_ids
prev_frame_id_map, gt_to_tracked_map
nikk-nikaznan marked this conversation as resolved.
Show resolved Hide resolved
)

mota = (
1 - (missed_detections + false_positive + num_switches) / total_gt
)
return mota
return mota, gt_to_tracked_map

def evaluate_tracking(self, gt_boxes_list: list) -> list[float]:
def evaluate_tracking(
self,
ground_truth_dict: Dict[int, Dict[str, Any]],
) -> list[float]:
"""
Evaluate tracking performance using the Multi-Object Tracking Accuracy (MOTA) metric.

Parameters
----------
gt_boxes_list : list[list[float]]
List of ground truth bounding boxes for each frame.
tracked_boxes_list : list[list[float]]
List of tracked bounding boxes for each frame.
ground_truth_dict : dict
Dictionary containing ground truth bounding boxes and IDs for each frame, organized by frame number.

Returns
-------
list[float]:
The computed MOTA (Multi-Object Tracking Accuracy) score for the tracking performance.
"""
mota_values = []
prev_frame_ids: Optional[list[list[int]]] = None
for gt_boxes, tracked_boxes in zip(gt_boxes_list, self.tracked_list):
mota = self.evaluate_mota(
gt_boxes,
tracked_boxes,
self.iou_threshold,
prev_frame_ids,
prev_frame_id_map: Optional[dict] = None

for frame_number in sorted(ground_truth_dict.keys()):
gt_data = ground_truth_dict[frame_number]
gt_boxes = np.array(
[[x1, y1, x2, y2] for x1, y1, x2, y2 in gt_data["bbox"]],
dtype=np.float32,
)
nikk-nikaznan marked this conversation as resolved.
Show resolved Hide resolved
mota_values.append(mota)
# Update previous frame IDs for the next iteration
prev_frame_ids = [[box[-1] for box in tracked_boxes]]
gt_ids = np.array(gt_data["id"], dtype=np.float32)

if frame_number < len(self.tracked_list):
tracked_boxes = self.tracked_list[frame_number]
nikk-nikaznan marked this conversation as resolved.
Show resolved Hide resolved
mota, prev_frame_id_map = self.evaluate_mota(
gt_boxes,
gt_ids,
nikk-nikaznan marked this conversation as resolved.
Show resolved Hide resolved
tracked_boxes,
self.iou_threshold,
prev_frame_id_map,
)
mota_values.append(mota)

return mota_values

def run_evaluation(self) -> None:
"""
Run evaluation of tracking based on tracking ground truth.
"""
gt_boxes_list = self.get_ground_truth_data()
mota_values = self.evaluate_tracking(gt_boxes_list)
ground_truth_dict = self.get_ground_truth_data()
mota_values = self.evaluate_tracking(ground_truth_dict)
overall_mota = np.mean(mota_values)
logging.info("Overall MOTA: %f" % overall_mota)
Loading