Audit and suppress nested detector proposals

This commit is contained in:
Jens
2026-07-27 09:58:25 +02:00
parent efd3272bda
commit 51db5baa9c
6 changed files with 264 additions and 4 deletions
+45 -1
View File
@@ -52,6 +52,29 @@ def metrics(tp: int, fp: int, fn: int) -> dict[str, float | int]:
}
def containment_overlap(
left: tuple[float, float, float, float], right: tuple[float, float, float, float]
) -> float:
x1, y1 = max(left[0], right[0]), max(left[1], right[1])
x2, y2 = min(left[2], right[2]), min(left[3], right[3])
intersection = max(0.0, x2 - x1) * max(0.0, y2 - y1)
left_area = max(0.0, left[2] - left[0]) * max(0.0, left[3] - left[1])
right_area = max(0.0, right[2] - right[0]) * max(0.0, right[3] - right[1])
denominator = min(left_area, right_area)
return intersection / denominator if denominator > 0 else 0.0
def suppress_contained_predictions(
predictions: list[tuple[tuple[float, float, float, float], float]], threshold: float
) -> list[tuple[tuple[float, float, float, float], float]]:
kept: list[tuple[tuple[float, float, float, float], float]] = []
for candidate in sorted(predictions, key=lambda item: -item[1]):
if any(containment_overlap(candidate[0], existing[0]) >= threshold for existing in kept):
continue
kept.append(candidate)
return kept
def ensemble_predictions(
primary: list[tuple[tuple[float, float, float, float], float]],
secondary: list[tuple[tuple[float, float, float, float], float]],
@@ -128,6 +151,18 @@ def main() -> int:
default=1000,
help="Maximum detections retained per tile; dense Belgian urban tiles exceed YOLO's default 300.",
)
parser.add_argument(
"--nms-iou",
type=float,
default=0.7,
help="Inference NMS IoU; freeze calibration-selected values before protected test evaluation.",
)
parser.add_argument(
"--containment-nms",
type=float,
default=1.0,
help="Suppress lower-score nested boxes at this intersection-over-minimum-area threshold.",
)
parser.add_argument("--box-scale", type=float, default=1.0)
parser.add_argument("--box-offset-x", type=float, default=0.0)
parser.add_argument("--box-offset-y", type=float, default=0.0)
@@ -141,6 +176,10 @@ def main() -> int:
args = parser.parse_args()
if args.proposal_classifier_batch < 1:
parser.error("--proposal-classifier-batch must be positive")
if not 0.0 < args.nms_iou < 1.0:
parser.error("--nms-iou must be between zero and one")
if not 0.0 < args.containment_nms <= 1.0:
parser.error("--containment-nms must be above zero and at most one")
from ultralytics import YOLO
proposal_classifier = None
@@ -173,13 +212,14 @@ def main() -> int:
augment=args.augment,
imgsz=args.imgsz,
max_det=args.max_det,
iou=args.nms_iou,
verbose=False,
)
additional_results = None
if args.additional_model:
additional_results = YOLO(str(args.additional_model)).predict(
image_paths, conf=min(args.thresholds), device=args.device, augment=args.augment,
imgsz=args.imgsz, max_det=args.max_det, verbose=False,
imgsz=args.imgsz, max_det=args.max_det, iou=args.nms_iou, verbose=False,
)
observations: list[dict[str, Any]] = []
for result_index, (tile, result) in enumerate(zip(tiles, results, strict=True)):
@@ -193,6 +233,7 @@ def main() -> int:
)
for box, score in zip(result.boxes.xyxy.cpu().tolist(), result.boxes.conf.cpu().tolist(), strict=True)
]
predictions = suppress_contained_predictions(predictions, args.containment_nms)
if additional_results is not None:
secondary = [
(
@@ -204,6 +245,7 @@ def main() -> int:
additional_results[result_index].boxes.conf.cpu().tolist(), strict=True,
)
]
secondary = suppress_contained_predictions(secondary, args.containment_nms)
predictions = ensemble_predictions(
predictions, secondary, match_iou=args.ensemble_match_iou, mode=args.ensemble_mode
)
@@ -274,6 +316,8 @@ def main() -> int:
"test_time_augmentation": args.augment,
"inference_imgsz": args.imgsz,
"max_detections_per_tile": args.max_det,
"nms_iou": args.nms_iou,
"containment_nms": args.containment_nms,
"box_scale": args.box_scale,
"box_offset_x": args.box_offset_x,
"box_offset_y": args.box_offset_y,