Skip to content

Commit d86a301

Browse files
authored
Enable offline image detection evaluation + improvements in resizing/padding (#608)
* Refactor image detection resizing to allow for padding to closest divisor * Enable offline image detection evaluation and minor fixes in relevant datasets * Add example for offline image detection evaluation * Fix bug in new yolo dataset parsing * Fix missing attribute * Allow for setting longer side max size alone for image detection * Update streamlit app to support padding and max side for image detection
1 parent 89121a5 commit d86a301

7 files changed

Lines changed: 524 additions & 79 deletions

File tree

app.py

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@ def browse_dataset_path():
171171
if enable_resize:
172172
resize_strategy = st.radio(
173173
"Resize Strategy",
174-
["Fixed Dimensions", "Min Side"],
174+
["Min Side", "Max Side", "Fixed Dimensions"],
175175
key="resize_strategy",
176176
horizontal=True,
177177
label_visibility="collapsed",
@@ -199,7 +199,7 @@ def browse_dataset_path():
199199
key="resize_width",
200200
help="Width to resize images for inference",
201201
)
202-
else:
202+
elif resize_strategy == "Min Side":
203203
st.number_input(
204204
"Min Side",
205205
min_value=1,
@@ -209,6 +209,34 @@ def browse_dataset_path():
209209
key="min_side",
210210
help="Minimum size of the shorter side of the image",
211211
)
212+
elif resize_strategy == "Max Side":
213+
st.number_input(
214+
"Max Side",
215+
min_value=1,
216+
max_value=4096,
217+
value=640,
218+
step=1,
219+
key="max_side",
220+
help="Maximum size of the longer side of the image",
221+
)
222+
else:
223+
st.error("Invalid resize strategy selected")
224+
225+
# Pad to closest multiple
226+
enable_pad = st.checkbox(
227+
"Enable Padding to Closest Multiple", value=True, key="enable_pad"
228+
)
229+
230+
if enable_pad:
231+
st.number_input(
232+
"Divisor",
233+
min_value=1,
234+
max_value=128,
235+
value=32,
236+
step=1,
237+
key="pad_divisor",
238+
help="Pad image dimensions to the closest multiple of this value",
239+
)
212240

213241
# Crop Logic
214242
enable_crop = st.checkbox("Enable Center Crop", key="enable_crop")
@@ -303,9 +331,21 @@ def browse_dataset_path():
303331
"height": resize_height,
304332
"width": resize_width,
305333
}
306-
else:
334+
elif resize_strategy == "Min Side":
307335
min_side = int(st.session_state.get("min_side", 640))
308336
resize_cfg = {"min_side": min_side}
337+
elif resize_strategy == "Max Side":
338+
max_side = int(st.session_state.get("max_side", 640))
339+
resize_cfg = {"max_side": max_side}
340+
else:
341+
st.error("Invalid resize strategy selected")
342+
343+
if enable_pad:
344+
pad_divisor = int(st.session_state.get("pad_divisor", 32))
345+
if resize_cfg is not None:
346+
resize_cfg["closest_divisor"] = pad_divisor
347+
else:
348+
resize_cfg = {"closest_divisor": pad_divisor}
309349

310350
config_data = {
311351
"confidence_threshold": confidence_threshold,

examples/eval_detection_preds.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import argparse
2+
import os
3+
4+
from perceptionmetrics.datasets.yolo import YOLODataset
5+
from perceptionmetrics.datasets.coco import CocoDataset
6+
7+
8+
def parse_args() -> argparse.Namespace:
9+
"""Parse user input arguments.
10+
11+
:return: Parsed arguments
12+
:rtype: argparse.Namespace
13+
"""
14+
parser = argparse.ArgumentParser(
15+
description="Evaluate pre-computed detection predictions against a GT dataset."
16+
)
17+
parser.add_argument(
18+
"--format",
19+
type=str,
20+
choices=["yolo", "coco"],
21+
default="yolo",
22+
help="Dataset format (yolo or coco)",
23+
)
24+
parser.add_argument(
25+
"--dataset",
26+
type=str,
27+
required=True,
28+
help="Dataset configuration file (YAML for YOLO, JSON for COCO)",
29+
)
30+
parser.add_argument(
31+
"--dataset_dir",
32+
type=str,
33+
default=None,
34+
help="Path to the directory containing images. For YOLO, inferred if not provided.",
35+
)
36+
parser.add_argument(
37+
"--predictions_dir",
38+
type=str,
39+
required=True,
40+
help="Root directory containing prediction JSON files",
41+
)
42+
parser.add_argument(
43+
"--split",
44+
type=str,
45+
default="test",
46+
help="Name of the split to evaluate (default: test)",
47+
)
48+
parser.add_argument(
49+
"--out_fname",
50+
type=str,
51+
required=True,
52+
help="CSV file where the evaluation results will be stored",
53+
)
54+
parser.add_argument(
55+
"--ignored_classes",
56+
type=str,
57+
nargs="+",
58+
default=None,
59+
help="List of class names to ignore during evaluation",
60+
)
61+
parser.add_argument(
62+
"--results_per_sample",
63+
action="store_true",
64+
help="Store per-sample results as CSV files next to each prediction",
65+
)
66+
return parser.parse_args()
67+
68+
69+
def main() -> None:
70+
"""Main function."""
71+
args = parse_args()
72+
73+
if args.format == "yolo":
74+
dataset = YOLODataset(dataset_fname=args.dataset, dataset_dir=args.dataset_dir)
75+
elif args.format == "coco":
76+
if args.dataset_dir is None:
77+
raise ValueError("--dataset_dir is required for COCO format")
78+
dataset = CocoDataset(
79+
annotation_file=args.dataset, image_dir=args.dataset_dir, split=args.split
80+
)
81+
else:
82+
raise ValueError(f"Unsupported format: {args.format}")
83+
84+
results = dataset.eval_preds(
85+
predictions_dir=args.predictions_dir,
86+
split=args.split,
87+
ignored_classes=args.ignored_classes,
88+
results_per_sample=args.results_per_sample,
89+
)
90+
91+
os.makedirs(os.path.dirname(args.out_fname), exist_ok=True)
92+
results.to_csv(args.out_fname)
93+
print(f"Results saved to {args.out_fname}")
94+
95+
96+
if __name__ == "__main__":
97+
main()

perceptionmetrics/datasets/coco.py

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from typing import OrderedDict
12
from pycocotools.coco import COCO
23
import os
34
import re
@@ -88,18 +89,18 @@ def build_coco_dataset(
8889
}
8990

9091
# Build dataset DataFrame from COCO image IDs
91-
rows = []
92+
dataset = OrderedDict()
9293
for img_id in coco.getImgIds():
9394
img_info = coco.loadImgs(img_id)[0]
94-
rows.append(
95-
{
96-
"image": img_info["file_name"],
97-
"annotation": str(img_id),
98-
"split": split, # Use provided split parameter
99-
}
95+
sample_name = os.path.basename(img_info["file_name"]).split(".")[0]
96+
dataset[sample_name] = (
97+
img_info["file_name"],
98+
str(img_id),
99+
split, # Use provided split parameter
100100
)
101101

102-
dataset = pd.DataFrame(rows)
102+
cols = ["image", "annotation", "split"]
103+
dataset = pd.DataFrame.from_dict(dataset, orient="index", columns=cols)
103104
dataset.attrs = {"ontology": ontology}
104105

105106
return dataset, ontology

perceptionmetrics/datasets/detection.py

Lines changed: 109 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from abc import abstractmethod
2+
import json
23
import os
34
from typing import List, Optional, Union
45

@@ -7,6 +8,8 @@
78
from tqdm import tqdm
89

910
from perceptionmetrics.datasets.perception import PerceptionDataset
11+
import perceptionmetrics.utils.conversion as uc
12+
import perceptionmetrics.utils.detection_metrics as um
1013

1114

1215
class DetectionDataset(PerceptionDataset):
@@ -100,9 +103,112 @@ def eval_preds(
100103
:return: DataFrame containing evaluation results
101104
:rtype: pd.DataFrame
102105
"""
103-
raise NotImplementedError(
104-
"eval_preds is not yet implemented for ImageDetectionDataset"
105-
)
106+
splits = [split] if isinstance(split, str) else split
107+
self._validate_splits(splits)
108+
109+
df = self.dataset[self.dataset["split"].isin(splits)]
110+
111+
# Determine the evaluation ontology and build a LUT if needed
112+
eval_ontology = self.ontology
113+
lut_ontology = None
114+
115+
if pred_ontology is None:
116+
pred_ontology = self.ontology
117+
118+
if pred_ontology != self.ontology:
119+
if ontology_translation is None:
120+
raise ValueError(
121+
"'ontology_translation' must be provided when GT and prediction "
122+
"ontologies differ."
123+
)
124+
if translation_direction == "dataset_to_model":
125+
eval_ontology = pred_ontology
126+
lut_ontology = uc.get_ontology_conversion_lut(
127+
self.ontology, pred_ontology, ontology_translation
128+
)
129+
else:
130+
lut_ontology = uc.get_ontology_conversion_lut(
131+
pred_ontology, self.ontology, ontology_translation
132+
)
133+
134+
n_classes = len(eval_ontology)
135+
136+
# Retrieve ignored label indices
137+
ignored_label_indices = []
138+
if ignored_classes:
139+
for cls_name in ignored_classes:
140+
ignored_label_indices.append(self.ontology[cls_name]["idx"])
141+
142+
# Init metrics
143+
metrics_factory = um.DetectionMetricsFactory(num_classes=n_classes)
144+
145+
pbar = tqdm(df.iterrows(), total=len(df), leave=True)
146+
for sample_name, row in pbar:
147+
pbar.set_description(f"Evaluating sample: {sample_name}")
148+
149+
# Read GT annotation
150+
gt_ann_fname = row["annotation"]
151+
if self.dataset_dir is not None:
152+
gt_ann_fname = os.path.join(self.dataset_dir, gt_ann_fname)
153+
154+
gt_boxes, gt_labels = self.read_annotation(gt_ann_fname)
155+
gt_boxes = (
156+
np.array(gt_boxes, dtype=np.float32).reshape(-1, 4)
157+
if len(gt_boxes) > 0
158+
else np.zeros((0, 4), dtype=np.float32)
159+
)
160+
gt_labels = np.array(gt_labels, dtype=np.int64)
161+
162+
# Build valid mask from ignored classes
163+
if ignored_label_indices:
164+
valid_mask = np.ones(len(gt_labels), dtype=bool)
165+
for idx in ignored_label_indices:
166+
valid_mask &= gt_labels != idx
167+
gt_boxes = gt_boxes[valid_mask]
168+
gt_labels = gt_labels[valid_mask]
169+
170+
# Read predictions
171+
pred_file = os.path.join(predictions_dir, f"{sample_name}.json")
172+
if not os.path.isfile(pred_file):
173+
raise FileNotFoundError(f"Prediction file not found: {pred_file}")
174+
175+
with open(pred_file, "r") as f:
176+
preds = json.load(f)
177+
178+
pred_boxes = [p["bbox"] for p in preds]
179+
pred_labels = [p["label"] for p in preds]
180+
pred_scores = [p["score"] for p in preds]
181+
182+
pred_boxes = (
183+
np.array(pred_boxes, dtype=np.float32).reshape(-1, 4)
184+
if pred_boxes
185+
else np.zeros((0, 4), dtype=np.float32)
186+
)
187+
pred_labels = np.array(pred_labels, dtype=np.int64)
188+
pred_scores = np.array(pred_scores, dtype=np.float32)
189+
190+
# Apply ontology translation
191+
if lut_ontology is not None:
192+
if translation_direction == "dataset_to_model":
193+
gt_labels = lut_ontology[gt_labels]
194+
else:
195+
pred_labels = lut_ontology[pred_labels]
196+
197+
metrics_factory.update(
198+
gt_boxes, gt_labels, pred_boxes, pred_labels, pred_scores
199+
)
200+
201+
# Per-sample results
202+
if results_per_sample:
203+
sample_mf = um.DetectionMetricsFactory(num_classes=n_classes)
204+
sample_mf.update(
205+
gt_boxes, gt_labels, pred_boxes, pred_labels, pred_scores
206+
)
207+
sample_df = sample_mf.get_metrics_dataframe(eval_ontology)
208+
sample_csv = os.path.join(predictions_dir, f"{sample_name}_metrics.csv")
209+
sample_df.to_csv(sample_csv)
210+
211+
return metrics_factory.get_metrics_dataframe(eval_ontology)
106212

107213

108214
class LiDARDetectionDataset(DetectionDataset):

0 commit comments

Comments
 (0)