| import json |
| import os |
|
|
| import numpy as np |
| import pandas as pd |
| import torch |
| from pycocotools.coco import COCO |
| from torchvision.ops.boxes import box_convert, box_iou |
| from tqdm import tqdm |
|
|
|
|
| class NpEncoder(json.JSONEncoder): |
| """Custom JSON encoder for NumPy data types. |
| |
| This encoder handles NumPy-specific types that are not serializable by |
| the default JSON library by converting them into standard Python types. |
| """ |
|
|
| def default(self, obj): |
| """Converts NumPy objects to their native Python equivalents. |
| |
| Args: |
| obj (any): The object to encode. |
| |
| Returns: |
| any: The JSON-serializable representation of the object. |
| """ |
| if isinstance(obj, np.integer): |
| return int(obj) |
| elif isinstance(obj, np.floating): |
| return float(obj) |
| elif isinstance(obj, np.ndarray): |
| return obj.tolist() |
| else: |
| return super(NpEncoder, self).default(obj) |
|
|
|
|
| class Ensembler: |
| """A class to ensemble predictions from multiple object detection models. |
| |
| This class loads ground truth data and predictions from several models, |
| performs non-maximum suppression (NMS) to merge overlapping detections, |
| and saves the final ensembled results in COCO format. |
| """ |
|
|
| def __init__( |
| self, output_dir, dataset_name, grplist, iou_thresh, coco_gt_path=None, coco_instances_results_fname=None |
| ): |
| """Initializes the Ensembler. |
| |
| Args: |
| output_dir (str): The base directory where model outputs and |
| ensembled results are stored. |
| dataset_name (str): The name of the dataset being evaluated. |
| grplist (list[str]): A list of subdirectory names, where each |
| subdirectory contains the prediction file from one model. |
| iou_thresh (float): The IoU threshold for considering two bounding |
| boxes as overlapping during NMS. |
| coco_gt_path (str, optional): The full path to the ground truth |
| COCO JSON file. If None, it's assumed to be in `output_dir`. |
| Defaults to None. |
| coco_instances_results_fname (str, optional): The filename for the |
| COCO prediction files within each model's subdirectory. |
| Defaults to "coco_instances_results.json". |
| """ |
| self.output_dir = output_dir |
| self.dataset_name = dataset_name |
| self.grplist = grplist |
| self.iou_thresh = iou_thresh |
| self.n_detectors = len(grplist) |
|
|
| if coco_gt_path is None: |
| fname_gt = os.path.join(output_dir, dataset_name + "_coco_format.json") |
| else: |
| fname_gt = coco_gt_path |
|
|
| if coco_instances_results_fname is None: |
| fname_dt = "coco_instances_results.json" |
| else: |
| fname_dt = coco_instances_results_fname |
|
|
| |
| coco_gt = COCO(fname_gt) |
| |
| dtlist = [] |
| for grp in grplist: |
| fname = os.path.join(output_dir, grp, fname_dt) |
| dtlist.append(coco_gt.loadRes(fname)) |
| print("Successfully loaded {} into memory. {} instance detected.\n".format(fname, len(dtlist[-1].anns))) |
|
|
| self.coco_gt = coco_gt |
| self.cats = [cat["id"] for cat in self.coco_gt.dataset["categories"]] |
| self.dtlist = dtlist |
| self.results = [] |
|
|
| print( |
| "Working with {} models, {} categories, and {} images.".format( |
| self.n_detectors, len(self.cats), len(self.coco_gt.imgs.keys()) |
| ) |
| ) |
|
|
| def mean_score_nms(self): |
| """Performs non-maximum suppression by merging overlapping boxes. |
| |
| This method iterates through all images and categories, merging sets of |
| overlapping bounding boxes from different detectors based on the IoU |
| threshold. For each merged set, it calculates a mean score and selects |
| the single box with the highest original score as the representative |
| detection for the ensembled output. |
| |
| Returns: |
| Ensembler: The instance itself, with the `self.results` attribute |
| populated with the ensembled predictions. |
| """ |
|
|
| def nik_merge(lsts): |
| """Niklas B. https://github.com/rikpg/IntersectionMerge/blob/master/core.py""" |
| sets = [set(lst) for lst in lsts if lst] |
| merged = 1 |
| while merged: |
| merged = 0 |
| results = [] |
| while sets: |
| common, rest = sets[0], sets[1:] |
| sets = [] |
| for x in rest: |
| if x.isdisjoint(common): |
| sets.append(x) |
| else: |
| merged = 1 |
| common |= x |
| results.append(common) |
| sets = results |
| return sets |
|
|
| winning_list = [] |
| print( |
| "Computing mean score non-max suppression ensembling for {} images.".format(len(self.coco_gt.imgs.keys())) |
| ) |
| for img in tqdm(self.coco_gt.imgs.keys()): |
| |
| dflist = [] |
| obj_set = set() |
| for i, coco_dt in enumerate(self.dtlist): |
| dflist.append(pd.DataFrame(coco_dt.imgToAnns[img]).assign(det=i)) |
| df = pd.concat(dflist, ignore_index=True) |
| if not df.empty: |
| for cat in self.cats: |
| dfcat = df[df["category_id"] == cat] |
| ts = box_convert( |
| torch.tensor(dfcat["bbox"]), in_fmt="xywh", out_fmt="xyxy" |
| ) |
| iou_bool = np.array((box_iou(ts, ts) > self.iou_thresh)) |
| for i in range(len(dfcat)): |
| fset = frozenset(dfcat.index[iou_bool[i]]) |
| obj_set.add(fset) |
| |
|
|
| |
| |
| |
| |
| |
| obj_set = nik_merge(obj_set) |
| for s in obj_set: |
| dfset = dfcat.loc[list(s)] |
| mean_score = dfset["score"].sum() / max( |
| self.n_detectors, len(s) |
| ) |
| winning_box = dfset.iloc[dfset["score"].argmax()].to_dict() |
| winning_box["score"] = mean_score |
| winning_list.append(winning_box) |
| print("{} resulting instances from NMS".format(len(winning_list))) |
| self.results = winning_list |
| return self |
|
|
| def save_coco_instances(self, fname="coco_instances_results.json"): |
| """Saves the ensembled prediction results to a JSON file. |
| |
| The output file follows the COCO instance format and can be used for |
| further evaluation. |
| |
| Args: |
| fname (str, optional): The filename for the output JSON file. |
| Defaults to "coco_instances_results.json". |
| """ |
| if self.results: |
| with open(os.path.join(self.output_dir, fname), "w") as f: |
| f.write(json.dumps(self.results, cls=NpEncoder)) |
| f.flush() |
|
|
|
|
| if __name__ == "__main__": |
| |
| |
| |
| ens = Ensembler("dev", ["fold1", "fold2", "fold3", "fold4", "fold5"], 0.2) |
| ens.mean_score_nms() |
|
|