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,483 @@
1
+ #!/usr/bin/env python3
2
+ """Compare sampled GPU rIoU vs exact Shapely polygon IoU.
3
+
4
+ Generates stratified oriented box pairs (squares, elongated ships, thin slivers),
5
+ computes exact IoU on CPU (Shapely) and sampling-based IoU via
6
+ ``oriented_box_iou_gpu``, then reports absolute / relative error statistics.
7
+
8
+ Use this to validate geometry sampling defaults in ``oriented_det/ops/gpu_ops.py``:
9
+
10
+ target_spacing_px=2.0, min_samples=25, max_samples=1024, min_points_short=3
11
+
12
+ Examples:
13
+ python tools/measure_sampled_riou_error.py
14
+ python tools/measure_sampled_riou_error.py --pairs 5000 --seed 0
15
+ python tools/measure_sampled_riou_error.py --sweep-spacing 2 3 4 5 6 8
16
+ python tools/measure_sampled_riou_error.py --target-spacing 4 --max-samples 1024 --csv out.csv
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import argparse
22
+ import csv
23
+ import math
24
+ import sys
25
+ from dataclasses import dataclass
26
+ from pathlib import Path
27
+ from typing import Iterator, List, Optional, Sequence, Tuple
28
+
29
+ import numpy as np
30
+ import torch
31
+
32
+ from oriented_det.geometry import RBox
33
+ from oriented_det.ops.gpu_ops import (
34
+ _DEFAULT_MAX_SAMPLES,
35
+ _DEFAULT_MIN_SAMPLES,
36
+ _DEFAULT_TARGET_SPACING_PX,
37
+ _MIN_POINTS_ALONG_SHORT_SIDE,
38
+ geometry_sample_count_for_boxes,
39
+ oriented_box_iou_gpu,
40
+ )
41
+ from oriented_det.ops.iou import rbox_iou
42
+ from oriented_det.ops.utils import SHAPELY_AVAILABLE
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class GeometryParams:
47
+ target_spacing_px: float = _DEFAULT_TARGET_SPACING_PX
48
+ min_samples: int = _DEFAULT_MIN_SAMPLES
49
+ max_samples: int = _DEFAULT_MAX_SAMPLES
50
+ min_points_short: int = _MIN_POINTS_ALONG_SHORT_SIDE
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class PairRecord:
55
+ category: str
56
+ w1: float
57
+ h1: float
58
+ w2: float
59
+ h2: float
60
+ exact: float
61
+ sampled: float
62
+ num_samples: int
63
+
64
+ @property
65
+ def abs_error(self) -> float:
66
+ return abs(self.sampled - self.exact)
67
+
68
+ @property
69
+ def signed_error(self) -> float:
70
+ return self.sampled - self.exact
71
+
72
+ @property
73
+ def rel_error_pct(self) -> float:
74
+ if self.exact <= 1e-8:
75
+ return float("nan")
76
+ return 100.0 * self.abs_error / self.exact
77
+
78
+
79
+ def _require_shapely() -> None:
80
+ if not SHAPELY_AVAILABLE:
81
+ print(
82
+ "Shapely is required for exact IoU. Install oriented-det with shapely.",
83
+ file=sys.stderr,
84
+ )
85
+ sys.exit(1)
86
+
87
+
88
+ def _box_tensor(cx: float, cy: float, w: float, h: float, angle: float) -> torch.Tensor:
89
+ return torch.tensor([cx, cy, w, h, angle], dtype=torch.float32)
90
+
91
+
92
+ def _exact_iou(a: torch.Tensor, b: torch.Tensor) -> float:
93
+ ra = RBox(*a.tolist())
94
+ rb = RBox(*b.tolist())
95
+ return float(rbox_iou(ra, rb, intersection_backend="shapely"))
96
+
97
+
98
+ def _sampled_iou_batch(
99
+ boxes1: torch.Tensor,
100
+ boxes2: torch.Tensor,
101
+ *,
102
+ device: torch.device,
103
+ num_samples: int,
104
+ ) -> torch.Tensor:
105
+ b1 = boxes1.to(device)
106
+ b2 = boxes2.to(device)
107
+ mat = oriented_box_iou_gpu(b1, b2, num_samples=num_samples)
108
+ return torch.diagonal(mat).cpu()
109
+
110
+
111
+ def _resolve_num_samples(
112
+ boxes1: torch.Tensor,
113
+ boxes2: torch.Tensor,
114
+ params: GeometryParams,
115
+ ) -> int:
116
+ combined = torch.cat([boxes1, boxes2], dim=0)
117
+ return geometry_sample_count_for_boxes(
118
+ combined,
119
+ target_spacing_px=params.target_spacing_px,
120
+ min_samples=params.min_samples,
121
+ max_samples=params.max_samples,
122
+ min_points_short=params.min_points_short,
123
+ )
124
+
125
+
126
+ def _random_box(rng: np.random.Generator, spec: str) -> torch.Tensor:
127
+ if spec == "tiny_square":
128
+ side = rng.uniform(4.0, 16.0)
129
+ w = h = side
130
+ elif spec == "small_square":
131
+ side = rng.uniform(16.0, 32.0)
132
+ w = h = side
133
+ elif spec == "vehicle":
134
+ # Cars / trucks in DOTA tiles (~10–25 px short side)
135
+ side = rng.uniform(10.0, 25.0)
136
+ w = h = side
137
+ elif spec == "medium_square":
138
+ side = rng.uniform(32.0, 128.0)
139
+ w = h = side
140
+ elif spec == "large_square":
141
+ side = rng.uniform(128.0, 512.0)
142
+ w = h = side
143
+ elif spec == "elongated":
144
+ short = rng.uniform(8.0, 40.0)
145
+ aspect = rng.uniform(3.0, 8.0)
146
+ w, h = short, short * aspect
147
+ if rng.random() < 0.5:
148
+ w, h = h, w
149
+ elif spec == "elongated_thin":
150
+ short = rng.uniform(4.0, 12.0)
151
+ aspect = rng.uniform(8.0, 40.0)
152
+ w, h = short, short * aspect
153
+ if rng.random() < 0.5:
154
+ w, h = h, w
155
+ else:
156
+ w = rng.uniform(4.0, 512.0)
157
+ h = rng.uniform(4.0, 512.0)
158
+
159
+ angle = rng.uniform(-math.pi / 2, math.pi / 2)
160
+ cx = rng.uniform(0.0, 1024.0)
161
+ cy = rng.uniform(0.0, 1024.0)
162
+ return _box_tensor(cx, cy, w, h, angle)
163
+
164
+
165
+ def _offset_for_target_iou(
166
+ rng: np.random.Generator,
167
+ box_a: torch.Tensor,
168
+ box_b: torch.Tensor,
169
+ ) -> torch.Tensor:
170
+ """Shift box_b center to vary overlap (rough control via fraction of max side)."""
171
+ overlap_frac = rng.uniform(0.0, 1.2)
172
+ max_side = max(float(box_a[2]), float(box_a[3]), float(box_b[2]), float(box_b[3]))
173
+ dist = (1.0 - overlap_frac) * max_side
174
+ angle = rng.uniform(0.0, 2.0 * math.pi)
175
+ cx = float(box_a[0]) + dist * math.cos(angle)
176
+ cy = float(box_a[1]) + dist * math.sin(angle)
177
+ out = box_b.clone()
178
+ out[0] = cx
179
+ out[1] = cy
180
+ return out
181
+
182
+
183
+ def generate_pairs(
184
+ rng: np.random.Generator,
185
+ *,
186
+ n_per_category: int,
187
+ categories: Sequence[str],
188
+ ) -> Iterator[Tuple[str, torch.Tensor, torch.Tensor]]:
189
+ for cat in categories:
190
+ for _ in range(n_per_category):
191
+ a = _random_box(rng, cat)
192
+ b = _random_box(rng, cat)
193
+ b = _offset_for_target_iou(rng, a, b)
194
+ yield cat, a, b
195
+
196
+
197
+ DEFAULT_CATEGORIES = (
198
+ "tiny_square",
199
+ "vehicle",
200
+ "small_square",
201
+ "medium_square",
202
+ "large_square",
203
+ "elongated",
204
+ "elongated_thin",
205
+ )
206
+
207
+
208
+ def evaluate_pairs(
209
+ pairs: Sequence[Tuple[str, torch.Tensor, torch.Tensor]],
210
+ *,
211
+ device: torch.device,
212
+ params: GeometryParams,
213
+ chunk_size: int = 256,
214
+ ) -> List[PairRecord]:
215
+ records: List[PairRecord] = []
216
+ n = len(pairs)
217
+ for start in range(0, n, chunk_size):
218
+ chunk = pairs[start : start + chunk_size]
219
+ boxes1 = torch.stack([p[1] for p in chunk])
220
+ boxes2 = torch.stack([p[2] for p in chunk])
221
+ num_samples = _resolve_num_samples(boxes1, boxes2, params)
222
+ sampled = _sampled_iou_batch(
223
+ boxes1, boxes2, device=device, num_samples=num_samples
224
+ )
225
+ for i, (cat, a, b) in enumerate(chunk):
226
+ exact = _exact_iou(a, b)
227
+ records.append(
228
+ PairRecord(
229
+ category=cat,
230
+ w1=float(a[2]),
231
+ h1=float(a[3]),
232
+ w2=float(b[2]),
233
+ h2=float(b[3]),
234
+ exact=exact,
235
+ sampled=float(sampled[i].item()),
236
+ num_samples=num_samples,
237
+ )
238
+ )
239
+ return records
240
+
241
+
242
+ @dataclass
243
+ class ErrorStats:
244
+ count: int
245
+ mean_abs: float
246
+ p50_abs: float
247
+ p90_abs: float
248
+ p99_abs: float
249
+ max_abs: float
250
+ mean_signed: float
251
+ mean_rel_pct: float
252
+ p90_rel_pct: float
253
+ frac_gt_5pct: float
254
+ frac_gt_10pct: float
255
+ frac_gt_20pct: float
256
+ mean_samples: float
257
+
258
+
259
+ def _summarize(records: Sequence[PairRecord]) -> ErrorStats:
260
+ if not records:
261
+ return ErrorStats(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)
262
+ abs_err = np.array([r.abs_error for r in records], dtype=np.float64)
263
+ signed = np.array([r.signed_error for r in records], dtype=np.float64)
264
+ rel = np.array([r.rel_error_pct for r in records], dtype=np.float64)
265
+ rel = rel[np.isfinite(rel)]
266
+ exact = np.array([r.exact for r in records], dtype=np.float64)
267
+ samples = np.array([r.num_samples for r in records], dtype=np.float64)
268
+
269
+ def _frac(th: float) -> float:
270
+ mask = exact > 1e-6
271
+ if not mask.any():
272
+ return 0.0
273
+ return float((abs_err[mask] > th).mean())
274
+
275
+ return ErrorStats(
276
+ count=len(records),
277
+ mean_abs=float(abs_err.mean()),
278
+ p50_abs=float(np.percentile(abs_err, 50)),
279
+ p90_abs=float(np.percentile(abs_err, 90)),
280
+ p99_abs=float(np.percentile(abs_err, 99)),
281
+ max_abs=float(abs_err.max()),
282
+ mean_signed=float(signed.mean()),
283
+ mean_rel_pct=float(rel.mean()) if rel.size else float("nan"),
284
+ p90_rel_pct=float(np.percentile(rel, 90)) if rel.size else float("nan"),
285
+ frac_gt_5pct=_frac(0.05),
286
+ frac_gt_10pct=_frac(0.10),
287
+ frac_gt_20pct=_frac(0.20),
288
+ mean_samples=float(samples.mean()),
289
+ )
290
+
291
+
292
+ def _fmt_stats(label: str, stats: ErrorStats) -> str:
293
+ if stats.count == 0:
294
+ return f"{label:16s} (no pairs)"
295
+ return (
296
+ f"{label:16s} n={stats.count:5d} "
297
+ f"|err| mean={stats.mean_abs:.4f} p50={stats.p50_abs:.4f} "
298
+ f"p90={stats.p90_abs:.4f} p99={stats.p99_abs:.4f} max={stats.max_abs:.4f} "
299
+ f"rel% mean={stats.mean_rel_pct:6.1f} p90={stats.p90_rel_pct:6.1f} "
300
+ f">5%={stats.frac_gt_5pct:.1%} >10%={stats.frac_gt_10pct:.1%} >20%={stats.frac_gt_20pct:.1%} "
301
+ f"grid≈{stats.mean_samples:.0f}"
302
+ )
303
+
304
+
305
+ def _print_report(
306
+ records: List[PairRecord],
307
+ params: GeometryParams,
308
+ *,
309
+ title: str = "Sampled vs Shapely rIoU",
310
+ ) -> None:
311
+ print(f"\n=== {title} ===")
312
+ print(
313
+ f"Geometry: spacing={params.target_spacing_px}px "
314
+ f"min={params.min_samples} max={params.max_samples} "
315
+ f"min_short_pts={params.min_points_short}"
316
+ )
317
+ overall = _summarize(records)
318
+ print(_fmt_stats("ALL", overall))
319
+
320
+ by_cat: dict[str, List[PairRecord]] = {}
321
+ for r in records:
322
+ by_cat.setdefault(r.category, []).append(r)
323
+ for cat in sorted(by_cat):
324
+ print(_fmt_stats(cat, _summarize(by_cat[cat])))
325
+
326
+ # IoU bins (exact)
327
+ bins = [(0.0, 0.0), (0.0, 0.3), (0.3, 0.7), (0.7, 1.0), (1.0, 1.0 + 1e-9)]
328
+ labels = ["exact=0", "0-0.3", "0.3-0.7", "0.7-1", "exact=1"]
329
+ print("\nBy exact IoU bin:")
330
+ for (lo, hi), lab in zip(bins, labels):
331
+ if lo == hi == 0.0:
332
+ subset = [r for r in records if r.exact <= 1e-8]
333
+ elif lo >= 1.0:
334
+ subset = [r for r in records if r.exact >= 1.0 - 1e-6]
335
+ else:
336
+ subset = [r for r in records if lo < r.exact <= hi]
337
+ print(f" {_fmt_stats(lab, _summarize(subset))}")
338
+
339
+
340
+ def _write_csv(path: Path, records: Sequence[PairRecord], params: GeometryParams) -> None:
341
+ path.parent.mkdir(parents=True, exist_ok=True)
342
+ with path.open("w", newline="") as f:
343
+ w = csv.writer(f)
344
+ w.writerow(
345
+ [
346
+ "category",
347
+ "w1",
348
+ "h1",
349
+ "w2",
350
+ "h2",
351
+ "exact_iou",
352
+ "sampled_iou",
353
+ "abs_error",
354
+ "signed_error",
355
+ "rel_error_pct",
356
+ "num_samples",
357
+ "target_spacing_px",
358
+ "min_samples",
359
+ "max_samples",
360
+ "min_points_short",
361
+ ]
362
+ )
363
+ for r in records:
364
+ w.writerow(
365
+ [
366
+ r.category,
367
+ f"{r.w1:.4f}",
368
+ f"{r.h1:.4f}",
369
+ f"{r.w2:.4f}",
370
+ f"{r.h2:.4f}",
371
+ f"{r.exact:.6f}",
372
+ f"{r.sampled:.6f}",
373
+ f"{r.abs_error:.6f}",
374
+ f"{r.signed_error:.6f}",
375
+ f"{r.rel_error_pct:.4f}",
376
+ r.num_samples,
377
+ params.target_spacing_px,
378
+ params.min_samples,
379
+ params.max_samples,
380
+ params.min_points_short,
381
+ ]
382
+ )
383
+
384
+
385
+ def _build_pairs(
386
+ rng: np.random.Generator,
387
+ *,
388
+ pairs: int,
389
+ categories: Sequence[str],
390
+ ) -> List[Tuple[str, torch.Tensor, torch.Tensor]]:
391
+ n_per = max(1, pairs // len(categories))
392
+ out = list(generate_pairs(rng, n_per_category=n_per, categories=categories))
393
+ if len(out) < pairs:
394
+ extra = pairs - len(out)
395
+ out.extend(
396
+ generate_pairs(rng, n_per_category=1, categories=categories[:extra])
397
+ )
398
+ return out[:pairs]
399
+
400
+
401
+ def main(argv: Optional[Sequence[str]] = None) -> int:
402
+ _require_shapely()
403
+
404
+ parser = argparse.ArgumentParser(
405
+ description="Measure sampling error vs exact Shapely rIoU.",
406
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
407
+ )
408
+ parser.add_argument("--pairs", type=int, default=3000, help="Number of box pairs")
409
+ parser.add_argument("--seed", type=int, default=42)
410
+ parser.add_argument(
411
+ "--categories",
412
+ nargs="+",
413
+ default=list(DEFAULT_CATEGORIES),
414
+ choices=list(DEFAULT_CATEGORIES),
415
+ help="Strata to sample",
416
+ )
417
+ parser.add_argument("--target-spacing", type=float, default=_DEFAULT_TARGET_SPACING_PX)
418
+ parser.add_argument("--min-samples", type=int, default=_DEFAULT_MIN_SAMPLES)
419
+ parser.add_argument("--max-samples", type=int, default=_DEFAULT_MAX_SAMPLES)
420
+ parser.add_argument(
421
+ "--min-points-short", type=int, default=_MIN_POINTS_ALONG_SHORT_SIDE
422
+ )
423
+ parser.add_argument(
424
+ "--device",
425
+ default="cuda" if torch.cuda.is_available() else "cpu",
426
+ help="Device for oriented_box_iou_gpu",
427
+ )
428
+ parser.add_argument("--chunk-size", type=int, default=256)
429
+ parser.add_argument("--csv", type=Path, default=None, help="Write per-pair CSV")
430
+ parser.add_argument(
431
+ "--sweep-spacing",
432
+ type=float,
433
+ nargs="+",
434
+ default=None,
435
+ help="Sweep target_spacing_px values (overrides --target-spacing)",
436
+ )
437
+ args = parser.parse_args(argv)
438
+
439
+ device = torch.device(args.device)
440
+ rng = np.random.default_rng(args.seed)
441
+ pairs = _build_pairs(rng, pairs=args.pairs, categories=args.categories)
442
+
443
+ spacings: List[float]
444
+ if args.sweep_spacing:
445
+ spacings = list(args.sweep_spacing)
446
+ else:
447
+ spacings = [args.target_spacing]
448
+
449
+ all_records: List[PairRecord] = []
450
+ for spacing in spacings:
451
+ params = GeometryParams(
452
+ target_spacing_px=spacing,
453
+ min_samples=args.min_samples,
454
+ max_samples=args.max_samples,
455
+ min_points_short=args.min_points_short,
456
+ )
457
+ title = f"spacing={spacing}px"
458
+ records = evaluate_pairs(
459
+ pairs, device=device, params=params, chunk_size=args.chunk_size
460
+ )
461
+ _print_report(records, params, title=title)
462
+ if args.csv and len(spacings) == 1:
463
+ _write_csv(args.csv, records, params)
464
+ all_records = records
465
+
466
+ if args.csv and len(spacings) > 1:
467
+ print("\nNote: --csv ignored during multi-value --sweep-spacing", file=sys.stderr)
468
+
469
+ if len(spacings) == 1 and all_records:
470
+ worst = sorted(all_records, key=lambda r: r.abs_error, reverse=True)[:10]
471
+ print("\nTop 10 |error| pairs:")
472
+ for r in worst:
473
+ print(
474
+ f" {r.category:14s} box1={r.w1:.1f}x{r.h1:.1f} box2={r.w2:.1f}x{r.h2:.1f} "
475
+ f"exact={r.exact:.4f} sampled={r.sampled:.4f} |err|={r.abs_error:.4f} "
476
+ f"grid={r.num_samples}"
477
+ )
478
+
479
+ return 0
480
+
481
+
482
+ if __name__ == "__main__":
483
+ raise SystemExit(main())