oriented-det 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. export/__init__.py +9 -0
  2. export/ort_runtime.py +67 -0
  3. export/postprocess.py +151 -0
  4. export/scripts/__init__.py +6 -0
  5. export/scripts/build_faster_rcnn_savedmodel.py +104 -0
  6. export/scripts/export_onnx.py +210 -0
  7. export/scripts/onnx_to_savedmodel.py +35 -0
  8. export/scripts/predict_savedmodel.py +94 -0
  9. export/scripts/save_predictions_tf.py +447 -0
  10. export/scripts/to_tflite.py +32 -0
  11. export/tests/test_export_onnx_optional.py +82 -0
  12. export/tests/test_export_wrappers.py +105 -0
  13. export/tests/test_faster_rcnn_export_parity.py +201 -0
  14. export/tests/test_ort_runtime.py +41 -0
  15. export/tf_serving_model.py +96 -0
  16. export/val_dataset.py +116 -0
  17. export/wrappers.py +161 -0
  18. oriented_det/__init__.py +77 -0
  19. oriented_det/cli/__init__.py +92 -0
  20. oriented_det/cli/train.py +18 -0
  21. oriented_det/configs/_base_/augmentation.json +21 -0
  22. oriented_det/configs/_base_/datasets/dota_le90.json +21 -0
  23. oriented_det/configs/_base_/fp16.json +5 -0
  24. oriented_det/configs/_base_/models/oriented_rcnn_r50.json +26 -0
  25. oriented_det/configs/_base_/models/rotated_faster_rcnn_r50.json +53 -0
  26. oriented_det/configs/_base_/models/rotated_retinanet_r50.json +30 -0
  27. oriented_det/configs/_base_/preprocessing.json +8 -0
  28. oriented_det/configs/_base_/schedules/1x.json +33 -0
  29. oriented_det/configs/config.schema.json +404 -0
  30. oriented_det/configs/oriented_rcnn/dota_le90_1x.json +121 -0
  31. oriented_det/configs/oriented_rcnn/dota_le90_3x.json +11 -0
  32. oriented_det/configs/rotated_faster_rcnn/dota_le90_1x.json +119 -0
  33. oriented_det/configs/rotated_faster_rcnn/dota_le90_3x.json +9 -0
  34. oriented_det/configs/rotated_retinanet/dota_le90_1x.json +107 -0
  35. oriented_det/configs/rotated_retinanet/dota_le90_3x.json +30 -0
  36. oriented_det/data/__init__.py +89 -0
  37. oriented_det/data/airbus_playground.py +611 -0
  38. oriented_det/data/dota.py +741 -0
  39. oriented_det/data/dota_classes.py +26 -0
  40. oriented_det/data/evaluation.py +648 -0
  41. oriented_det/data/flips.py +115 -0
  42. oriented_det/data/preprocessing.py +335 -0
  43. oriented_det/data/tiling.py +399 -0
  44. oriented_det/data/transforms.py +377 -0
  45. oriented_det/geometry/__init__.py +8 -0
  46. oriented_det/geometry/poly.py +127 -0
  47. oriented_det/geometry/qbox.py +63 -0
  48. oriented_det/geometry/rbox.py +250 -0
  49. oriented_det/geometry/transforms.py +266 -0
  50. oriented_det/models/__init__.py +36 -0
  51. oriented_det/models/backbones/__init__.py +11 -0
  52. oriented_det/models/backbones/resnet_fpn.py +79 -0
  53. oriented_det/models/backbones/utils.py +81 -0
  54. oriented_det/models/bbox_coder.py +355 -0
  55. oriented_det/models/faster_rcnn_inference.py +494 -0
  56. oriented_det/models/horizontal_roi_coder.py +155 -0
  57. oriented_det/models/oriented_rcnn.py +1256 -0
  58. oriented_det/models/oriented_roi.py +1664 -0
  59. oriented_det/models/oriented_rpn.py +2104 -0
  60. oriented_det/models/rotated_retinanet.py +1030 -0
  61. oriented_det/models/utils.py +590 -0
  62. oriented_det/ops/__init__.py +58 -0
  63. oriented_det/ops/gpu_ops.py +1109 -0
  64. oriented_det/ops/iou.py +172 -0
  65. oriented_det/ops/kfiou.py +275 -0
  66. oriented_det/ops/nms.py +202 -0
  67. oriented_det/ops/probiou.py +165 -0
  68. oriented_det/ops/rotated_ops.py +122 -0
  69. oriented_det/ops/utils.py +257 -0
  70. oriented_det/pretrained/__init__.py +23 -0
  71. oriented_det/pretrained/hub.py +249 -0
  72. oriented_det/pretrained/manifest.json +46 -0
  73. oriented_det/runtime/__init__.py +29 -0
  74. oriented_det/runtime/checkpoint.py +274 -0
  75. oriented_det/runtime/collate.py +348 -0
  76. oriented_det/runtime/inference.py +1286 -0
  77. oriented_det/train/__init__.py +102 -0
  78. oriented_det/train/config.py +872 -0
  79. oriented_det/train/engine.py +2933 -0
  80. oriented_det/train/grouped_ce.py +139 -0
  81. oriented_det/train/piecewise_schedule.py +33 -0
  82. oriented_det/train/profiler.py +287 -0
  83. oriented_det/train/utils.py +999 -0
  84. oriented_det/utils/__init__.py +31 -0
  85. oriented_det/utils/config.py +376 -0
  86. oriented_det/utils/device.py +35 -0
  87. oriented_det/utils/logging.py +163 -0
  88. oriented_det/utils/progress.py +62 -0
  89. oriented_det/utils/viz.py +181 -0
  90. oriented_det-0.1.0.dist-info/METADATA +313 -0
  91. oriented_det-0.1.0.dist-info/RECORD +115 -0
  92. oriented_det-0.1.0.dist-info/WHEEL +5 -0
  93. oriented_det-0.1.0.dist-info/entry_points.txt +2 -0
  94. oriented_det-0.1.0.dist-info/licenses/LICENSE +202 -0
  95. oriented_det-0.1.0.dist-info/top_level.txt +3 -0
  96. tools/__init__.py +1 -0
  97. tools/app.py +1284 -0
  98. tools/dataset_stats.py +389 -0
  99. tools/dota_labels_to_comma.py +132 -0
  100. tools/free_gpu.py +142 -0
  101. tools/generate_airbus_playground_csv.py +154 -0
  102. tools/image_demo.py +200 -0
  103. tools/lr_finder.py +771 -0
  104. tools/measure_sampled_riou_error.py +483 -0
  105. tools/playground_to_dota.py +290 -0
  106. tools/pretrained_download.py +49 -0
  107. tools/preview_augmentation.py +510 -0
  108. tools/publish_checkpoint.py +96 -0
  109. tools/save_predictions.py +2350 -0
  110. tools/sync_vendored_configs.py +94 -0
  111. tools/tile_dota.py +447 -0
  112. tools/train.py +2324 -0
  113. tools/train_example.py +244 -0
  114. tools/train_multi_gpu.py +367 -0
  115. tools/visualize_boxes.py +183 -0
@@ -0,0 +1,26 @@
1
+ """DOTA v1.0 canonical class list (aligned with MMRotate / common DOTA LE90 configs)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import FrozenSet
6
+
7
+ # Official 15 foreground categories; order matches MMRotate DOTADataset / typical 1x DOTA configs.
8
+ DOTA_V1_CLASSES: list[str] = [
9
+ "plane",
10
+ "baseball-diamond",
11
+ "bridge",
12
+ "ground-track-field",
13
+ "small-vehicle",
14
+ "large-vehicle",
15
+ "ship",
16
+ "tennis-court",
17
+ "basketball-court",
18
+ "storage-tank",
19
+ "soccer-ball-field",
20
+ "roundabout",
21
+ "harbor",
22
+ "swimming-pool",
23
+ "helicopter",
24
+ ]
25
+
26
+ DOTA_V1_CLASS_SET: FrozenSet[str] = frozenset(DOTA_V1_CLASSES)
@@ -0,0 +1,648 @@
1
+ """Oriented mAP evaluation compatible with DOTA protocol.
2
+
3
+ Uses detection and ground-truth RBoxes exactly as provided (no coordinate rescaling).
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from collections import defaultdict
9
+ from collections.abc import Sequence
10
+ from dataclasses import dataclass
11
+ from typing import Any, Dict, List, Optional, Tuple
12
+ import math
13
+ import sys
14
+
15
+ try:
16
+ from tqdm import tqdm
17
+ except ImportError:
18
+ tqdm = None # type: ignore
19
+
20
+ try:
21
+ import torch
22
+ except ImportError:
23
+ torch = None # type: ignore
24
+
25
+ # Type hint for device - use Optional[torch.device] if torch is available
26
+ if torch is not None:
27
+ DeviceType = Optional[torch.device]
28
+ else:
29
+ DeviceType = Optional[object] # Fallback when torch is not available
30
+
31
+ from ..geometry import RBox
32
+ from ..ops import iou
33
+ from ..ops.utils import resolve_exact_polygon_iou_backend
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class Detection:
38
+ """Single detection result."""
39
+
40
+ rbox: RBox
41
+ score: float
42
+ class_id: int
43
+ class_name: str
44
+ image_id: Optional[str] = None # Required for correct mAP: match only to GTs from same image
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class GroundTruth:
49
+ """Single ground truth annotation."""
50
+
51
+ rbox: RBox
52
+ class_id: int
53
+ class_name: str
54
+ difficult: int = 0
55
+ image_id: Optional[str] = None # Required for correct mAP: match only to detections from same image
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class ClassEvalMetrics:
60
+ """Per-class detection stats (MMRotate-style eval table)."""
61
+
62
+ num_gts: int
63
+ num_dets: int
64
+ recall: float
65
+ ap: float
66
+
67
+
68
+ class APCalculator:
69
+ """Calculate Average Precision (AP) for oriented object detection."""
70
+
71
+ def __init__(
72
+ self,
73
+ iou_threshold: float = 0.5,
74
+ class_names: Optional[Sequence[str]] = None,
75
+ device: DeviceType = None,
76
+ *,
77
+ use_exact_rotated_iou: bool = True,
78
+ ):
79
+ if not 0 < iou_threshold <= 1:
80
+ raise ValueError("IoU threshold must be in (0, 1]")
81
+ self.iou_threshold = iou_threshold
82
+ self.class_names = class_names or []
83
+ self.class_to_id = {name: i for i, name in enumerate(self.class_names)} if self.class_names else {}
84
+ self.use_exact_rotated_iou = bool(use_exact_rotated_iou)
85
+ self.device = None if self.use_exact_rotated_iou else device
86
+ self._exact_iou_backend: Optional[str] = (
87
+ resolve_exact_polygon_iou_backend() if self.use_exact_rotated_iou else None
88
+ )
89
+
90
+ def compute_ap(
91
+ self,
92
+ detections: Sequence[Detection],
93
+ ground_truths: Sequence[GroundTruth],
94
+ *,
95
+ class_name: Optional[str] = None,
96
+ show_progress: bool = False,
97
+ progress_stream: Optional[Any] = None,
98
+ max_iou_calculations: Optional[int] = None,
99
+ min_box_size: float = 1.0,
100
+ ) -> Tuple[float, ClassEvalMetrics]:
101
+ """Compute Average Precision for a single class.
102
+
103
+ Args:
104
+ detections: List of detections
105
+ ground_truths: List of ground truths
106
+ class_name: Optional class name
107
+ show_progress: Whether to show progress bar
108
+ max_iou_calculations: Maximum number of IoU calculations to allow.
109
+ If exceeded, will use approximate method or return 0.0.
110
+ min_box_size: Minimum box size (width/height) in pixels to consider valid.
111
+ Boxes smaller than this will be filtered out (default: 1.0).
112
+ """
113
+ if class_name:
114
+ detections = [d for d in detections if d.class_name == class_name]
115
+ ground_truths = [gt for gt in ground_truths if gt.class_name == class_name]
116
+
117
+ # Filter out degenerated boxes
118
+ original_det_count = len(detections)
119
+ original_gt_count = len(ground_truths)
120
+ detections = [d for d in detections if d.rbox.is_valid(min_size=min_box_size)]
121
+ ground_truths = [gt for gt in ground_truths if gt.rbox.is_valid(min_size=min_box_size)]
122
+
123
+ if show_progress:
124
+ filtered_dets = original_det_count - len(detections)
125
+ filtered_gts = original_gt_count - len(ground_truths)
126
+ if filtered_dets > 0 or filtered_gts > 0:
127
+ filter_msg = f" Filtered {filtered_dets} degenerated detections, {filtered_gts} degenerated ground truths"
128
+ print(filter_msg)
129
+
130
+ if not ground_truths:
131
+ ap = 0.0 if detections else 1.0
132
+ return ap, ClassEvalMetrics(
133
+ num_gts=0,
134
+ num_dets=len(detections),
135
+ recall=0.0,
136
+ ap=ap,
137
+ )
138
+
139
+ # Check if we should optimize due to too many calculations
140
+ num_dets = len(detections)
141
+ num_gts = len(ground_truths)
142
+ total_calculations = num_dets * num_gts
143
+
144
+ # Use batch IoU if available and calculations are too many
145
+ use_batch_iou = max_iou_calculations and total_calculations > max_iou_calculations
146
+
147
+ # Exact polygon mAP (final_nms_use_cpu): never use GPU sampling IoU
148
+ from ..utils import is_gpu_device
149
+ use_gpu = (
150
+ not self.use_exact_rotated_iou
151
+ and torch is not None
152
+ and self.device is not None
153
+ and is_gpu_device(self.device)
154
+ )
155
+
156
+ sorted_dets = sorted(detections, key=lambda d: d.score, reverse=True)
157
+ tp_fp_from_batch = False
158
+ tp: List[int] = []
159
+ fp: List[int] = []
160
+
161
+ if use_batch_iou:
162
+ # Try using batch IoU computation which is much faster
163
+ try:
164
+ det_rboxes = [d.rbox for d in sorted_dets]
165
+ gt_rboxes = [gt.rbox for gt in ground_truths]
166
+
167
+ # Use chunking for very large matrices to avoid memory issues
168
+ # Process detections in chunks to limit memory usage.
169
+ # GPU IoU builds [chunk*S, num_gt] tensors with S ~= default oriented IoU samples
170
+ # (typically 100; tier/env can raise it). When num_gt is large (e.g. 72k)
171
+ # a fixed 5000-detect chunk can OOM. Cap chunk so chunk*S*num_gt <= ~500M elements.
172
+ num_gt = len(ground_truths)
173
+ max_elements = 500_000_000 # ~500M elements for bool tensor (~500 MB)
174
+ samples_per_box = 100 # conservative match to oriented_box_iou_gpu default envelope
175
+ adaptive_chunk = max(1, max_elements // (samples_per_box * max(1, num_gt)))
176
+ CHUNK_SIZE = min(5000, adaptive_chunk)
177
+ num_dets = len(sorted_dets)
178
+
179
+ if show_progress and tqdm is not None:
180
+ if self.use_exact_rotated_iou:
181
+ iou_type = f"CPU exact ({self._exact_iou_backend})"
182
+ else:
183
+ iou_type = "GPU-accelerated" if use_gpu else "CPU"
184
+ print(f" Using chunked batch IoU computation ({iou_type}) for {class_name} ({total_calculations:,} calculations)")
185
+ if num_dets > CHUNK_SIZE:
186
+ print(f" Processing {num_dets:,} detections in chunks of {CHUNK_SIZE:,}")
187
+
188
+ # Process detections in chunks
189
+ gt_matched = [False] * len(ground_truths)
190
+ tp = []
191
+ fp = []
192
+
193
+ # Process detections in chunks; tqdm to progress_stream so console sees it but train.log does not
194
+ tqdm_file = progress_stream if progress_stream is not None else sys.stderr
195
+ det_iter = (
196
+ tqdm(range(0, num_dets, CHUNK_SIZE), desc=" Processing chunks", unit="chunk", leave=False, file=tqdm_file)
197
+ if (show_progress and tqdm is not None and num_dets > CHUNK_SIZE)
198
+ else range(0, num_dets, CHUNK_SIZE)
199
+ )
200
+
201
+ for chunk_start in det_iter:
202
+ chunk_end = min(chunk_start + CHUNK_SIZE, num_dets)
203
+ chunk_dets = sorted_dets[chunk_start:chunk_end]
204
+ chunk_det_rboxes = det_rboxes[chunk_start:chunk_end]
205
+
206
+ # Compute IoU matrix for this chunk
207
+ chunk_iou_matrix = None
208
+ try:
209
+ if use_gpu:
210
+ # Use GPU-accelerated IoU (much faster)
211
+ from ..ops.gpu_ops import oriented_box_iou_gpu
212
+ from ..ops.utils import rboxes_to_tensor
213
+
214
+ # Convert RBox objects to tensors
215
+ chunk_det_tensors = rboxes_to_tensor(chunk_det_rboxes, device=self.device)
216
+ gt_tensors = rboxes_to_tensor(gt_rboxes, device=self.device)
217
+
218
+ # Compute IoU matrix on GPU
219
+ iou_matrix_gpu = oriented_box_iou_gpu(chunk_det_tensors, gt_tensors)
220
+
221
+ # Convert to CPU list of lists for compatibility
222
+ chunk_iou_matrix = iou_matrix_gpu.cpu().tolist()
223
+ else:
224
+ from ..ops.iou import batch_rbox_iou
225
+ chunk_iou_matrix = batch_rbox_iou(
226
+ chunk_det_rboxes,
227
+ gt_rboxes,
228
+ device=self.device,
229
+ intersection_backend=(
230
+ self._exact_iou_backend
231
+ if self.use_exact_rotated_iou
232
+ else "auto"
233
+ ),
234
+ )
235
+ except Exception as chunk_e:
236
+ # If chunk batch fails, fall back to per-detection computation for this chunk
237
+ if show_progress:
238
+ print(f" Chunk batch IoU failed, using per-detection for chunk {chunk_start}-{chunk_end}: {chunk_e}")
239
+ chunk_iou_matrix = None
240
+
241
+ # Process each detection in this chunk
242
+ for local_idx, det in enumerate(chunk_dets):
243
+ det_idx = chunk_start + local_idx
244
+ best_iou = 0.0
245
+ best_gt_idx = -1
246
+
247
+ if chunk_iou_matrix is not None:
248
+ # Use pre-computed IoU matrix
249
+ for gt_idx, gt in enumerate(ground_truths):
250
+ if gt_matched[gt_idx] or gt.class_name != det.class_name:
251
+ continue
252
+ if det.image_id is not None and gt.image_id is not None and det.image_id != gt.image_id:
253
+ continue
254
+ det_iou = chunk_iou_matrix[local_idx][gt_idx]
255
+ if det_iou > best_iou:
256
+ best_iou = det_iou
257
+ best_gt_idx = gt_idx
258
+ else:
259
+ # Fall back to per-detection IoU computation
260
+ for gt_idx, gt in enumerate(ground_truths):
261
+ if gt_matched[gt_idx] or gt.class_name != det.class_name:
262
+ continue
263
+ if det.image_id is not None and gt.image_id is not None and det.image_id != gt.image_id:
264
+ continue
265
+ try:
266
+ from ..ops.iou import rbox_iou
267
+ det_iou = rbox_iou(
268
+ det.rbox,
269
+ gt.rbox,
270
+ intersection_backend=(
271
+ self._exact_iou_backend
272
+ if self.use_exact_rotated_iou
273
+ else "auto"
274
+ ),
275
+ )
276
+ if det_iou > best_iou:
277
+ best_iou = det_iou
278
+ best_gt_idx = gt_idx
279
+ except Exception:
280
+ continue
281
+
282
+ # Check if match is valid
283
+ if best_iou >= self.iou_threshold and best_gt_idx >= 0:
284
+ gt = ground_truths[best_gt_idx]
285
+ if gt.difficult == 0:
286
+ gt_matched[best_gt_idx] = True
287
+ tp.append(1)
288
+ fp.append(0)
289
+ else:
290
+ tp.append(0)
291
+ fp.append(0)
292
+ else:
293
+ tp.append(0)
294
+ fp.append(1)
295
+
296
+ # Clear chunk matrix from memory
297
+ del chunk_iou_matrix
298
+ if torch is not None and torch.cuda.is_available():
299
+ torch.cuda.empty_cache()
300
+ elif torch is not None and getattr(torch.backends, "mps", None) and torch.backends.mps.is_available():
301
+ torch.mps.empty_cache()
302
+
303
+ tp_fp_from_batch = True
304
+
305
+ except Exception as e:
306
+ # Fall back to regular computation if batch fails
307
+ if show_progress:
308
+ print(f" Batch IoU failed for {class_name}, using regular method (will be slow): {e}")
309
+ use_batch_iou = False
310
+
311
+ if not tp_fp_from_batch:
312
+ # Track which ground truths have been matched (standard per-detection path)
313
+ gt_matched = [False] * len(ground_truths)
314
+ tp = []
315
+ fp = []
316
+
317
+ # Use progress bar if requested and many detections; write to progress_stream so console sees it but train.log does not
318
+ tqdm_file = progress_stream if progress_stream is not None else sys.stderr
319
+ use_pbar = show_progress and tqdm is not None and len(sorted_dets) > 5000
320
+ det_iter = tqdm(sorted_dets, desc=f" {class_name or 'All'}", unit="det", leave=False, ncols=100, file=tqdm_file) if use_pbar else sorted_dets
321
+
322
+ for det_idx, det in enumerate(det_iter):
323
+ best_iou = 0.0
324
+ best_gt_idx = -1
325
+
326
+ # Find best matching ground truth (only from same image when image_id is set)
327
+ for gt_idx, gt in enumerate(ground_truths):
328
+ if gt_matched[gt_idx] or gt.class_name != det.class_name:
329
+ continue
330
+ if det.image_id is not None and gt.image_id is not None and det.image_id != gt.image_id:
331
+ continue
332
+ try:
333
+ det_iou = iou.rbox_iou(
334
+ det.rbox,
335
+ gt.rbox,
336
+ intersection_backend=(
337
+ self._exact_iou_backend
338
+ if self.use_exact_rotated_iou
339
+ else "auto"
340
+ ),
341
+ )
342
+ if det_iou > best_iou:
343
+ best_iou = det_iou
344
+ best_gt_idx = gt_idx
345
+ except Exception:
346
+ # Skip problematic IoU calculations and continue with next GT
347
+ continue
348
+
349
+ # Check if match is valid
350
+ if best_iou >= self.iou_threshold and best_gt_idx >= 0:
351
+ gt = ground_truths[best_gt_idx]
352
+ if gt.difficult == 0: # Only count non-difficult as TP
353
+ gt_matched[best_gt_idx] = True
354
+ tp.append(1)
355
+ fp.append(0)
356
+ else:
357
+ tp.append(0)
358
+ fp.append(0) # Difficult GTs don't count as FP either
359
+ else:
360
+ tp.append(0)
361
+ fp.append(1)
362
+
363
+ # Compute precision and recall
364
+ tp_cumsum = []
365
+ fp_cumsum = []
366
+ cumsum_tp = 0
367
+ cumsum_fp = 0
368
+
369
+ for t, f in zip(tp, fp):
370
+ cumsum_tp += t
371
+ cumsum_fp += f
372
+ tp_cumsum.append(cumsum_tp)
373
+ fp_cumsum.append(cumsum_fp)
374
+
375
+ num_positives = sum(1 for gt in ground_truths if gt.difficult == 0)
376
+ num_dets = len(sorted_dets)
377
+ if num_positives == 0:
378
+ return 0.0, ClassEvalMetrics(
379
+ num_gts=0,
380
+ num_dets=num_dets,
381
+ recall=0.0,
382
+ ap=0.0,
383
+ )
384
+
385
+ recalls = [t / num_positives for t in tp_cumsum]
386
+ precisions = [
387
+ tp_cumsum[i] / (tp_cumsum[i] + fp_cumsum[i]) if (tp_cumsum[i] + fp_cumsum[i]) > 0 else 0.0
388
+ for i in range(len(tp_cumsum))
389
+ ]
390
+
391
+ final_recall = recalls[-1] if recalls else 0.0
392
+
393
+ # Compute AP using 11-point interpolation
394
+ ap = 0.0
395
+ for t in [0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]:
396
+ precisions_at_recall = [p for r, p in zip(recalls, precisions) if r >= t]
397
+ if precisions_at_recall:
398
+ ap += max(precisions_at_recall) / 11.0
399
+
400
+ return ap, ClassEvalMetrics(
401
+ num_gts=num_positives,
402
+ num_dets=num_dets,
403
+ recall=final_recall,
404
+ ap=ap,
405
+ )
406
+
407
+ def compute_map(
408
+ self,
409
+ all_detections: Dict[str, List[Detection]], # image_id -> detections
410
+ all_ground_truths: Dict[str, List[GroundTruth]], # image_id -> ground_truths
411
+ show_progress: bool = True,
412
+ progress_stream: Optional[Any] = None,
413
+ max_iou_calculations_per_class: int = 10_000_000, # 10M IoU calculations max per class
414
+ min_box_size: float = 1.0,
415
+ ) -> Tuple[Dict[str, float], Dict[str, ClassEvalMetrics]]:
416
+ """Compute mAP across all classes.
417
+
418
+ Args:
419
+ all_detections: Dictionary mapping image_id -> list of Detection objects
420
+ all_ground_truths: Dictionary mapping image_id -> list of GroundTruth objects
421
+ show_progress: Whether to show progress bars
422
+ max_iou_calculations_per_class: Maximum IoU calculations per class before optimization
423
+ min_box_size: Minimum box size (width/height) in pixels to consider valid.
424
+ Boxes smaller than this will be filtered out (default: 1.0).
425
+
426
+ Returns:
427
+ ``(class_aps, class_metrics)`` — AP and MMRotate-style stats per class.
428
+ """
429
+ # Collect all unique class names
430
+ all_classes = set()
431
+ for dets in all_detections.values():
432
+ all_classes.update(d.class_name for d in dets)
433
+ for gts in all_ground_truths.values():
434
+ all_classes.update(gt.class_name for gt in gts)
435
+
436
+ if self.class_names:
437
+ all_classes = all_classes.intersection(set(self.class_names))
438
+
439
+ # Aggregate detections and ground truths by class
440
+ class_aps: Dict[str, float] = {}
441
+ class_metrics: Dict[str, ClassEvalMetrics] = {}
442
+ sorted_classes = sorted(all_classes)
443
+ num_classes = len(sorted_classes)
444
+
445
+ # Use progress bar for classes; write to progress_stream so console sees it but train.log does not
446
+ tqdm_file = progress_stream if progress_stream is not None else sys.stderr
447
+ use_pbar = show_progress and tqdm is not None and num_classes > 1
448
+ class_iter = tqdm(sorted_classes, desc="Computing AP per class", unit="class", ncols=120, file=tqdm_file) if use_pbar else sorted_classes
449
+
450
+ for class_name in class_iter:
451
+ try:
452
+ class_detections = []
453
+ class_ground_truths = []
454
+
455
+ for image_id in set(all_detections.keys()) | set(all_ground_truths.keys()):
456
+ dets = all_detections.get(image_id, [])
457
+ gts = all_ground_truths.get(image_id, [])
458
+
459
+ class_detections.extend([d for d in dets if d.class_name == class_name])
460
+ class_ground_truths.extend([gt for gt in gts if gt.class_name == class_name])
461
+
462
+ # Log class statistics
463
+ num_dets = len(class_detections)
464
+ num_gts = len(class_ground_truths)
465
+
466
+ # Compute AP for this class (show progress if many detections or ground truths)
467
+ # Show progress if computation will be slow (many IoU calculations)
468
+ total_iou_calculations = num_dets * num_gts
469
+ show_det_progress = total_iou_calculations > 100000 or num_dets > 5000 or num_gts > 1000
470
+
471
+ # Warn if too many calculations
472
+ if total_iou_calculations > max_iou_calculations_per_class:
473
+ warning_msg = (f" WARNING: {class_name} has {total_iou_calculations:,} IoU calculations "
474
+ f"({num_dets:,} dets × {num_gts:,} GTs). This will be slow!")
475
+ if use_pbar and isinstance(class_iter, tqdm):
476
+ class_iter.write(warning_msg)
477
+ else:
478
+ print(warning_msg)
479
+
480
+ # Update progress bar with class info
481
+ if use_pbar and isinstance(class_iter, tqdm):
482
+ if show_det_progress:
483
+ class_iter.set_postfix_str(f"{class_name}: {num_dets} dets × {num_gts} GTs = {total_iou_calculations:,} IoUs")
484
+ class_iter.write(f" Computing AP for {class_name}: {num_dets:,} dets, {num_gts:,} GTs ({total_iou_calculations:,} IoU calculations)")
485
+ else:
486
+ class_iter.set_postfix_str(f"{class_name}: {num_dets} dets, {num_gts} GTs")
487
+
488
+ ap, metrics = self.compute_ap(
489
+ class_detections,
490
+ class_ground_truths,
491
+ class_name=class_name,
492
+ show_progress=show_det_progress,
493
+ progress_stream=progress_stream,
494
+ max_iou_calculations=max_iou_calculations_per_class,
495
+ min_box_size=min_box_size,
496
+ )
497
+ class_aps[class_name] = ap
498
+ class_metrics[class_name] = metrics
499
+
500
+ # Update progress bar with current AP if using tqdm
501
+ if use_pbar and isinstance(class_iter, tqdm):
502
+ class_iter.set_postfix_str(f"{class_name}: AP={ap:.4f}")
503
+
504
+ except Exception as e:
505
+ # Log error and continue with other classes
506
+ error_msg = f"Error computing AP for class '{class_name}': {e}"
507
+ if use_pbar and isinstance(class_iter, tqdm):
508
+ class_iter.write(f" ERROR: {error_msg}")
509
+ else:
510
+ print(f" ERROR: {error_msg}")
511
+ import traceback
512
+ traceback.print_exc()
513
+ # Set AP to 0.0 for failed classes
514
+ class_aps[class_name] = 0.0
515
+ class_metrics[class_name] = ClassEvalMetrics(
516
+ num_gts=0,
517
+ num_dets=0,
518
+ recall=0.0,
519
+ ap=0.0,
520
+ )
521
+ if use_pbar and isinstance(class_iter, tqdm):
522
+ class_iter.set_postfix_str(f"{class_name}: ERROR")
523
+
524
+ return class_aps, class_metrics
525
+
526
+
527
+ def compute_oriented_map(
528
+ detections: Dict[str, List[Detection]],
529
+ ground_truths: Dict[str, List[GroundTruth]],
530
+ *,
531
+ iou_threshold: float = 0.5,
532
+ class_names: Optional[Sequence[str]] = None,
533
+ show_progress: bool = True,
534
+ progress_stream: Optional[Any] = None,
535
+ max_iou_calculations_per_class: int = 10_000_000,
536
+ device: DeviceType = None,
537
+ min_box_size: float = 1.0,
538
+ use_exact_rotated_iou: bool = True,
539
+ ) -> Tuple[float, Dict[str, float], Dict[str, ClassEvalMetrics]]:
540
+ """Compute mean Average Precision (mAP) for oriented detection.
541
+
542
+ Args:
543
+ detections: Dictionary mapping image_id -> list of Detection objects
544
+ ground_truths: Dictionary mapping image_id -> list of GroundTruth objects
545
+ iou_threshold: IoU threshold for positive matches
546
+ class_names: Optional list of class names to evaluate
547
+ show_progress: Whether to show progress bars during computation
548
+ progress_stream: If set (e.g. original stderr when stdout is teed to a log file),
549
+ progress bars write here so they appear on console but not in the log file.
550
+ max_iou_calculations_per_class: Maximum IoU calculations per class before using optimization
551
+ device: Optional torch device for GPU acceleration. If None, uses CPU.
552
+ Ignored when ``use_exact_rotated_iou`` is True (Shapely/python polygon IoU only).
553
+ min_box_size: Minimum box size (width/height) in pixels to consider valid.
554
+ Boxes smaller than this will be filtered out (default: 1.0).
555
+ use_exact_rotated_iou: When True, mAP matching uses exact polygon IoU on CPU
556
+ (Shapely when installed); never ``oriented_box_iou_gpu``. When False, uses GPU
557
+ sampling IoU (controlled by ``evaluation.use_exact_rotated_iou`` in training config).
558
+
559
+ Returns:
560
+ Tuple of (mAP, class_ap_dict, class_metrics) where mAP is the mean of all
561
+ class APs and ``class_metrics`` holds per-class gts/dets/recall (MMRotate-style).
562
+ """
563
+ if use_exact_rotated_iou and show_progress:
564
+ backend = resolve_exact_polygon_iou_backend()
565
+ print(
566
+ f"mAP: exact rotated IoU on CPU (backend={backend!r}; "
567
+ "GPU sampling IoU disabled)",
568
+ flush=True,
569
+ )
570
+ calculator = APCalculator(
571
+ iou_threshold=iou_threshold,
572
+ class_names=class_names,
573
+ device=device,
574
+ use_exact_rotated_iou=use_exact_rotated_iou,
575
+ )
576
+ class_aps, class_metrics = calculator.compute_map(
577
+ detections,
578
+ ground_truths,
579
+ show_progress=show_progress,
580
+ progress_stream=progress_stream,
581
+ max_iou_calculations_per_class=max_iou_calculations_per_class,
582
+ min_box_size=min_box_size,
583
+ )
584
+
585
+ if not class_aps:
586
+ return 0.0, {}, {}
587
+
588
+ mean_ap = sum(class_aps.values()) / len(class_aps)
589
+ return mean_ap, class_aps, class_metrics
590
+
591
+
592
+ def format_mmrotate_class_metrics_table(
593
+ class_metrics: Dict[str, ClassEvalMetrics],
594
+ class_names: Optional[Sequence[str]] = None,
595
+ *,
596
+ mean_ap: Optional[float] = None,
597
+ ) -> str:
598
+ """Format per-class gts / dets / recall / ap table like MMRotate eval output."""
599
+ if not class_metrics:
600
+ return ""
601
+
602
+ if class_names:
603
+ ordered = [c for c in class_names if c in class_metrics]
604
+ ordered.extend(c for c in sorted(class_metrics) if c not in ordered)
605
+ else:
606
+ ordered = sorted(class_metrics.keys())
607
+
608
+ rows: List[Tuple[str, ClassEvalMetrics]] = [
609
+ (name, class_metrics[name]) for name in ordered
610
+ ]
611
+ class_w = max(len("class"), *(len(name) for name, _ in rows))
612
+ col_gts = max(len("gts"), 3)
613
+ col_dets = max(len("dets"), 4)
614
+ col_recall = max(len("recall"), 6)
615
+ col_ap = max(len("ap"), 2)
616
+
617
+ def _sep() -> str:
618
+ return (
619
+ f"|{'-' * (class_w + 2)}|{'-' * (col_gts + 2)}:|"
620
+ f"{'-' * (col_dets + 2)}:|{'-' * (col_recall + 2)}:|{'-' * (col_ap + 2)}:|"
621
+ )
622
+
623
+ lines = [
624
+ f"| {'class'.ljust(class_w)} | {'gts'.rjust(col_gts)} | "
625
+ f"{'dets'.rjust(col_dets)} | {'recall'.rjust(col_recall)} | {'ap'.rjust(col_ap)} |",
626
+ _sep(),
627
+ ]
628
+ for name, m in rows:
629
+ lines.append(
630
+ f"| {name.ljust(class_w)} | {m.num_gts:>{col_gts}d} | {m.num_dets:>{col_dets}d} | "
631
+ f"{m.recall:>{col_recall}.3f} | {m.ap:>{col_ap}.3f} |"
632
+ )
633
+ if mean_ap is not None:
634
+ lines.append(
635
+ f"| {'mAP'.ljust(class_w)} | {'':>{col_gts}} | {'':>{col_dets}} | "
636
+ f"{'':>{col_recall}} | {mean_ap:>{col_ap}.3f} |"
637
+ )
638
+ return "\n".join(lines)
639
+
640
+
641
+ __all__ = [
642
+ "Detection",
643
+ "GroundTruth",
644
+ "ClassEvalMetrics",
645
+ "APCalculator",
646
+ "compute_oriented_map",
647
+ "format_mmrotate_class_metrics_table",
648
+ ]