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,201 @@
1
+ """Parity: PyTorch full detect vs pre-NMS export + postprocess."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import numpy as np
9
+ import pytest
10
+ import torch
11
+
12
+ _REPO_ROOT = Path(__file__).resolve().parents[2]
13
+ _EXPORT_DIR = Path(__file__).resolve().parents[1]
14
+ if str(_REPO_ROOT) not in sys.path:
15
+ sys.path.insert(0, str(_REPO_ROOT))
16
+ if str(_EXPORT_DIR) not in sys.path:
17
+ sys.path.insert(0, str(_EXPORT_DIR))
18
+
19
+ from export.postprocess import finalize_detections_numpy, meta_to_finalize_kwargs # noqa: E402
20
+ import wrappers as _wrappers # noqa: E402
21
+ from oriented_det import RotatedFasterRCNN # noqa: E402
22
+ from oriented_det.models.faster_rcnn_inference import faster_rcnn_inference # noqa: E402
23
+ from oriented_det.models.utils import rboxes_to_tensor # noqa: E402
24
+
25
+ RotatedFasterRCNNPreNmsExportWrapper = _wrappers.RotatedFasterRCNNPreNmsExportWrapper
26
+
27
+ _DEPLOY_CONFIG = _REPO_ROOT / "deploy/app/config.json"
28
+ _DEPLOY_CKPT = _REPO_ROOT / "deploy/app/weights/model.pth"
29
+
30
+
31
+ @pytest.fixture
32
+ def tiny_faster_rcnn() -> RotatedFasterRCNN:
33
+ return RotatedFasterRCNN(
34
+ num_classes=3,
35
+ backbone_name="resnet18",
36
+ pretrained_backbone=False,
37
+ trainable_layers=5,
38
+ rpn_post_nms_top_n=128,
39
+ rpn_pre_nms_top_n=128,
40
+ inference_pre_nms_score_threshold=0.01,
41
+ final_nms_iou_threshold=0.3,
42
+ nms_class_agnostic=True,
43
+ final_nms_use_cpu=True,
44
+ max_detections_per_image=64,
45
+ roi_inference_top_class_only=True,
46
+ )
47
+
48
+
49
+ def _finalize_kwargs_from_model(model: RotatedFasterRCNN) -> dict:
50
+ return {
51
+ "nms_class_agnostic": model.nms_class_agnostic,
52
+ "final_nms_iou_threshold": model.final_nms_iou_threshold,
53
+ "max_detections_per_image": model.max_detections_per_image,
54
+ "final_nms_use_cpu": model.final_nms_use_cpu,
55
+ "score_threshold": 0.05,
56
+ "per_class_score_threshold": None,
57
+ "class_id_to_name": {i + 1: f"class_{i}" for i in range(model.num_classes)},
58
+ "max_output_slots": model.max_detections_per_image,
59
+ }
60
+
61
+
62
+ def test_pre_nms_finalize_matches_full_inference(tiny_faster_rcnn: RotatedFasterRCNN) -> None:
63
+ """Export path + postprocess should match faster_rcnn_inference (deterministic RPN)."""
64
+ model = tiny_faster_rcnn
65
+ model.eval()
66
+ h, w = 128, 128
67
+ torch.manual_seed(0)
68
+ x = torch.rand(1, 3, h, w, dtype=torch.float32)
69
+
70
+ with torch.no_grad():
71
+ full_out = faster_rcnn_inference(model, [x[0]], deterministic_rpn=True)[0]
72
+ wrap = RotatedFasterRCNNPreNmsExportWrapper(model, height=h, width=w, max_candidates=128)
73
+ boxes, scores, labels, count = wrap(x)
74
+ det_np, num = finalize_detections_numpy(
75
+ boxes.cpu().numpy(),
76
+ scores.cpu().numpy(),
77
+ labels.cpu().numpy(),
78
+ int(count.item()),
79
+ **_finalize_kwargs_from_model(model),
80
+ )
81
+
82
+ if full_out["scores"].numel() == 0:
83
+ assert num == 0
84
+ return
85
+
86
+ full_boxes = rboxes_to_tensor(full_out["rboxes"])
87
+ full_scores = full_out["scores"]
88
+ full_labels = full_out["labels"]
89
+
90
+ assert num == int(full_boxes.shape[0])
91
+ assert torch.allclose(
92
+ torch.from_numpy(det_np[:num, :5]),
93
+ full_boxes,
94
+ atol=1e-4,
95
+ rtol=1e-4,
96
+ )
97
+ assert torch.allclose(
98
+ torch.from_numpy(det_np[:num, 5]),
99
+ full_scores,
100
+ atol=1e-4,
101
+ rtol=1e-4,
102
+ )
103
+ assert torch.equal(
104
+ torch.from_numpy(det_np[:num, 6].astype(np.int64)),
105
+ full_labels,
106
+ )
107
+
108
+
109
+ def test_faster_rcnn_pre_nms_onnx_checker(tiny_faster_rcnn: RotatedFasterRCNN) -> None:
110
+ pytest.importorskip("onnx")
111
+ import io
112
+
113
+ import onnx
114
+
115
+ model = tiny_faster_rcnn
116
+ model.eval()
117
+ h, w = 128, 128
118
+ wrap = RotatedFasterRCNNPreNmsExportWrapper(model, height=h, width=w, max_candidates=64)
119
+ x = torch.randn(1, 3, h, w, dtype=torch.float32)
120
+ buf = io.BytesIO()
121
+ torch.onnx.export(
122
+ wrap,
123
+ x,
124
+ buf,
125
+ input_names=["images"],
126
+ output_names=["pre_nms_boxes", "pre_nms_scores", "pre_nms_labels", "pre_nms_count"],
127
+ opset_version=17,
128
+ do_constant_folding=True,
129
+ )
130
+ buf.seek(0)
131
+ onnx.checker.check_model(onnx.load(buf))
132
+
133
+
134
+ def test_faster_rcnn_pre_nms_onnx_ort_random_input(tiny_faster_rcnn: RotatedFasterRCNN) -> None:
135
+ """ORT must run on non-zero images (regression: pad_pre_nms used invalid Expand)."""
136
+ pytest.importorskip("onnx")
137
+ pytest.importorskip("onnxruntime")
138
+ import io
139
+
140
+ import numpy as np
141
+ import onnxruntime as ort
142
+
143
+ model = tiny_faster_rcnn
144
+ model.eval()
145
+ h, w = 128, 128
146
+ wrap = RotatedFasterRCNNPreNmsExportWrapper(model, height=h, width=w, max_candidates=64)
147
+ buf = io.BytesIO()
148
+ torch.onnx.export(
149
+ wrap,
150
+ torch.zeros(1, 3, h, w, dtype=torch.float32),
151
+ buf,
152
+ input_names=["images"],
153
+ output_names=["pre_nms_boxes", "pre_nms_scores", "pre_nms_labels", "pre_nms_count"],
154
+ opset_version=17,
155
+ do_constant_folding=True,
156
+ )
157
+ buf.seek(0)
158
+ sess = ort.InferenceSession(buf.getvalue(), providers=["CPUExecutionProvider"])
159
+ for seed in range(8):
160
+ torch.manual_seed(seed)
161
+ x = torch.rand(1, 3, h, w, dtype=torch.float32).numpy()
162
+ outs = sess.run(None, {"images": x})
163
+ assert outs[0].shape == (64, 5)
164
+ assert int(np.asarray(outs[3]).reshape(-1)[0]) >= 0
165
+
166
+
167
+ @pytest.mark.skipif(
168
+ not _DEPLOY_CKPT.is_file() or not _DEPLOY_CONFIG.is_file(),
169
+ reason="deploy/app weights or config missing",
170
+ )
171
+ def test_deploy_checkpoint_pre_nms_finalize() -> None:
172
+ """Smoke parity on published deploy weights (slow; optional)."""
173
+ import json
174
+
175
+ from oriented_det.runtime.checkpoint import load_model_from_checkpoint
176
+
177
+ model, config, class_names = load_model_from_checkpoint(
178
+ str(_DEPLOY_CKPT), str(_DEPLOY_CONFIG), device="cpu"
179
+ )
180
+ assert isinstance(model, RotatedFasterRCNN)
181
+
182
+ meta = {
183
+ "production": json.loads(_DEPLOY_CONFIG.read_text()).get("production"),
184
+ "class_names": class_names or [],
185
+ }
186
+ kwargs = meta_to_finalize_kwargs(meta)
187
+ h, w = 256, 256
188
+ wrap = RotatedFasterRCNNPreNmsExportWrapper(
189
+ model, height=h, width=w, max_candidates=model.rpn_post_nms_top_n
190
+ )
191
+ x = torch.rand(1, 3, h, w, dtype=torch.float32)
192
+ with torch.no_grad():
193
+ boxes, scores, labels, count = wrap(x)
194
+ _, num = finalize_detections_numpy(
195
+ boxes.numpy(),
196
+ scores.numpy(),
197
+ labels.numpy(),
198
+ int(count.item()),
199
+ **kwargs,
200
+ )
201
+ assert num >= 0
@@ -0,0 +1,41 @@
1
+ """Tests for ORT device / provider resolution (no GPU required)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pytest
6
+
7
+ from export.ort_runtime import (
8
+ clear_ort_session_cache,
9
+ configure_ort_device,
10
+ get_ort_device,
11
+ ort_providers_for_device,
12
+ set_ort_device,
13
+ )
14
+
15
+
16
+ def test_cpu_providers():
17
+ set_ort_device("cpu")
18
+ assert ort_providers_for_device() == ["CPUExecutionProvider"]
19
+
20
+
21
+ def test_unknown_device_raises():
22
+ with pytest.raises(ValueError, match="Unknown ORT device"):
23
+ ort_providers_for_device("tpu")
24
+
25
+
26
+ def test_configure_returns_cpu_by_default():
27
+ clear_ort_session_cache()
28
+ set_ort_device(None)
29
+ providers = configure_ort_device("cpu")
30
+ assert providers == ["CPUExecutionProvider"]
31
+ assert get_ort_device() == "cpu"
32
+
33
+
34
+ def test_cuda_without_gpu_raises():
35
+ import onnxruntime as ort
36
+
37
+ if "CUDAExecutionProvider" in ort.get_available_providers():
38
+ pytest.skip("CUDA EP available; skip missing-CUDA test")
39
+ set_ort_device("cuda")
40
+ with pytest.raises(RuntimeError, match="CUDAExecutionProvider"):
41
+ ort_providers_for_device()
@@ -0,0 +1,96 @@
1
+ """Keras-serializable Faster R-CNN detect pipeline (ORT core + Python rotated NMS)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any, Dict, List, Optional
8
+
9
+ import numpy as np
10
+ import tensorflow as tf
11
+
12
+ from export.postprocess import finalize_detections_numpy, ort_pre_nms_to_detections
13
+
14
+
15
+ @tf.keras.utils.register_keras_serializable(package="oriented_det_export")
16
+ class FasterRCNNDetectLayer(tf.keras.layers.Layer):
17
+ """Single end-to-end detect: ONNX Runtime pre-NMS + exact CPU rotated NMS."""
18
+
19
+ def __init__(
20
+ self,
21
+ onnx_path: str,
22
+ ort_output_names: List[str],
23
+ finalize_kwargs: Dict[str, Any],
24
+ max_output_slots: int,
25
+ **kwargs: Any,
26
+ ) -> None:
27
+ super().__init__(**kwargs)
28
+ self.onnx_path = str(onnx_path)
29
+ self.ort_output_names = list(ort_output_names)
30
+ self.finalize_kwargs = dict(finalize_kwargs)
31
+ self.max_output_slots = int(max_output_slots)
32
+
33
+ def call(self, images: tf.Tensor) -> Dict[str, tf.Tensor]:
34
+ detections, num_detections = tf.numpy_function(
35
+ self._infer_numpy,
36
+ [images],
37
+ (tf.float32, tf.int32),
38
+ )
39
+ detections.set_shape([self.max_output_slots, 7])
40
+ num_detections.set_shape([])
41
+ return {"detections": detections, "num_detections": num_detections}
42
+
43
+ def _infer_numpy(self, images: np.ndarray) -> tuple[np.ndarray, np.int32]:
44
+ detections, num = ort_pre_nms_to_detections(
45
+ images,
46
+ self.onnx_path,
47
+ self.ort_output_names,
48
+ self.finalize_kwargs,
49
+ )
50
+ return detections.astype(np.float32), np.int32(num)
51
+
52
+ def get_config(self) -> Dict[str, Any]:
53
+ return {
54
+ "onnx_path": self.onnx_path,
55
+ "ort_output_names": self.ort_output_names,
56
+ "finalize_kwargs": self.finalize_kwargs,
57
+ "max_output_slots": self.max_output_slots,
58
+ }
59
+
60
+
61
+ def build_keras_detect_model(
62
+ onnx_path: Path,
63
+ meta: Dict[str, Any],
64
+ finalize_kwargs: Dict[str, Any],
65
+ ) -> tf.keras.Model:
66
+ max_out = int(finalize_kwargs["max_output_slots"])
67
+ inputs = tf.keras.Input(shape=(3, None, None), batch_size=1, name="images")
68
+ # Fixed H,W enforced at call time; channel-first matches export convention.
69
+ out = FasterRCNNDetectLayer(
70
+ onnx_path=str(onnx_path.resolve()),
71
+ ort_output_names=list(meta.get("output_names") or []),
72
+ finalize_kwargs=finalize_kwargs,
73
+ max_output_slots=max_out,
74
+ name="faster_rcnn_detect",
75
+ )(inputs)
76
+ return tf.keras.Model(inputs=inputs, outputs=[out["detections"], out["num_detections"]])
77
+
78
+
79
+ def save_keras_detect_bundle(
80
+ model: tf.keras.Model,
81
+ output_path: Path,
82
+ meta: Dict[str, Any],
83
+ ) -> Path:
84
+ """Save ``keras_model.keras`` + ``export_meta.json`` (load with :func:`load_keras_detect_model`)."""
85
+ output_path.mkdir(parents=True, exist_ok=True)
86
+ keras_path = output_path / "keras_model.keras"
87
+ model.save(keras_path)
88
+ (output_path / "export_meta.json").write_text(json.dumps(meta, indent=2), encoding="utf-8")
89
+ return keras_path
90
+
91
+
92
+ def load_keras_detect_model(path: Path) -> tf.keras.Model:
93
+ return tf.keras.models.load_model(
94
+ path,
95
+ custom_objects={"FasterRCNNDetectLayer": FasterRCNNDetectLayer},
96
+ )
export/val_dataset.py ADDED
@@ -0,0 +1,116 @@
1
+ """Validation image enumeration (shared with tools/save_predictions.py semantics)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import List, Optional, Tuple
7
+
8
+ from oriented_det.data.airbus_playground import AirbusPlaygroundCSVDataset
9
+ from oriented_det.data.dota import collect_dota_split_image_paths
10
+ from oriented_det.train.config import TrainingExperimentConfig
11
+
12
+
13
+ def collect_split_images(
14
+ config: TrainingExperimentConfig,
15
+ data_root: Path,
16
+ data_split: str = "val",
17
+ val_dir: Optional[Path] = None,
18
+ *,
19
+ filter_empty_gt: Optional[bool] = None,
20
+ ) -> Tuple[List[Path], Optional[Path], str]:
21
+ """Return image paths, label directory (DOTA), and dataset format string.
22
+
23
+ ``filter_empty_gt``: when ``None``, use ``config.dataset.filter_empty_gt`` (training).
24
+ ``tools/save_predictions.py`` (``make preds`` / ``make metrics``) always passes ``False``
25
+ so inference includes all tiles; training still uses the config flag.
26
+ """
27
+ dataset_format = getattr(getattr(config, "dataset", None), "format", None) or "dota"
28
+ data_root = Path(data_root)
29
+
30
+ if dataset_format == "airbus_playground":
31
+ ds_config = config.dataset
32
+ if not getattr(ds_config, "annotations_file", None) or not getattr(ds_config, "split_file", None):
33
+ raise ValueError(
34
+ "Airbus Playground format requires dataset.annotations_file and dataset.split_file."
35
+ )
36
+ if data_split not in ("train", "val"):
37
+ raise ValueError(f"Airbus dataset supports only train or val split, got {data_split!r}.")
38
+ airbus_dataset = AirbusPlaygroundCSVDataset(
39
+ data_root=data_root,
40
+ split=data_split,
41
+ annotations_file=ds_config.annotations_file,
42
+ split_file=ds_config.split_file,
43
+ val_split_id=getattr(ds_config, "val_split_id", 0),
44
+ difficult_strategy=ds_config.difficult_strategy,
45
+ allowed_classes=getattr(ds_config, "allowed_classes", None),
46
+ ignore_labels=getattr(ds_config, "ignore_labels", None) or [],
47
+ map_labels=getattr(ds_config, "map_labels", None) or {},
48
+ )
49
+ split_images = [Path(airbus_dataset[idx].image_path) for idx in range(len(airbus_dataset))]
50
+ return split_images, None, dataset_format
51
+
52
+ if getattr(config, "dataset", None):
53
+ if data_split == "val" and val_dir is not None:
54
+ tile_roots = [Path(val_dir)]
55
+ elif data_split == "val":
56
+ tile_roots = config.dataset.get_val_tile_roots()
57
+ elif data_split == "train":
58
+ tile_roots = config.dataset.get_train_tile_roots()
59
+ else:
60
+ tile_roots = [data_root / data_split]
61
+ same_folder = getattr(config.dataset, "same_folder", False)
62
+ difficult_strategy = getattr(config.dataset, "difficult_strategy", "drop")
63
+ allowed_classes = getattr(config.dataset, "allowed_classes", None)
64
+ ignore_labels = getattr(config.dataset, "ignore_labels", None)
65
+ if filter_empty_gt is None:
66
+ filter_empty_gt = bool(getattr(config.dataset, "filter_empty_gt", False))
67
+ split_images = collect_dota_split_image_paths(
68
+ tile_roots,
69
+ split=data_split,
70
+ same_folder=same_folder,
71
+ difficult_strategy=difficult_strategy,
72
+ allowed_classes=allowed_classes,
73
+ ignore_labels=ignore_labels,
74
+ filter_empty_gt=filter_empty_gt,
75
+ log_filter_empty_gt=filter_empty_gt,
76
+ )
77
+ if not split_images:
78
+ raise ValueError(f"No images found under DOTA tile roots: {tile_roots}")
79
+ label_dir = None
80
+ if len(tile_roots) == 1:
81
+ split_root = tile_roots[0]
82
+ split_tiles_dir = split_root / "tiles_1024"
83
+ if same_folder:
84
+ label_dir = split_root
85
+ elif (split_root / "labels").exists():
86
+ label_dir = split_root / "labels"
87
+ elif split_tiles_dir.exists() and (split_tiles_dir / "labels").exists():
88
+ label_dir = split_tiles_dir / "labels"
89
+ elif (split_root / "labelTxt").exists():
90
+ label_dir = split_root / "labelTxt"
91
+ return split_images, label_dir, dataset_format
92
+
93
+ split_root = data_root / data_split
94
+ split_tiles_dir = split_root / "tiles_1024"
95
+ same_folder = getattr(getattr(config, "dataset", None), "same_folder", False)
96
+
97
+ if same_folder:
98
+ image_dir = split_root
99
+ label_dir = split_root
100
+ elif split_tiles_dir.exists() and (split_tiles_dir / "images").exists():
101
+ image_dir = split_tiles_dir / "images"
102
+ label_dir = split_tiles_dir / "labels"
103
+ elif (split_root / "images").exists():
104
+ image_dir = split_root / "images"
105
+ label_dir = (
106
+ split_root / "labels"
107
+ if (split_root / "labels").exists()
108
+ else split_root / "labelTxt"
109
+ )
110
+ else:
111
+ raise ValueError(f"Could not find images under {split_tiles_dir} or {split_root}")
112
+
113
+ split_images = sorted(list(image_dir.glob("*.jpg")) + list(image_dir.glob("*.png")))
114
+ if not split_images:
115
+ raise ValueError(f"No images found in {image_dir}")
116
+ return split_images, label_dir, dataset_format
export/wrappers.py ADDED
@@ -0,0 +1,161 @@
1
+ """ONNX-friendly ``nn.Module`` wrappers around oriented-det PyTorch models.
2
+
3
+ Export targets **tensor-in / tensor-out** subgraphs. Post-decode NMS and RBox
4
+ assembly stay in Python or a separate TF/TFLite graph (see export/PARITY.md).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import List, Sequence, Tuple
10
+
11
+ import torch
12
+ import torch.nn.functional as F
13
+ from torch import nn
14
+
15
+ from oriented_det.models.faster_rcnn_inference import faster_rcnn_inference_pre_nms_padded
16
+ from oriented_det.models.oriented_rcnn import RotatedFasterRCNN
17
+ from oriented_det.models.oriented_rpn import generate_oriented_anchors
18
+ from oriented_det.models.rotated_retinanet import RotatedRetinaNet
19
+ from oriented_det.models.utils import derive_fpn_strides_from_grid, extract_backbone_features
20
+
21
+
22
+ def _ordered_fpn_values(features: object) -> Tuple[torch.Tensor, ...]:
23
+ """Turn backbone output into a tuple of tensors in a stable order."""
24
+ if isinstance(features, dict):
25
+ keys = []
26
+ for k in sorted(features.keys(), key=lambda x: str(x)):
27
+ ks = str(k)
28
+ if ks.isdigit() or ks.startswith("fpn"):
29
+ keys.append(k)
30
+ if not keys:
31
+ keys = list(features.keys())
32
+ return tuple(features[k] for k in keys)
33
+ if isinstance(features, (list, tuple)):
34
+ return tuple(features)
35
+ return (features,) # type: ignore[return-value]
36
+
37
+
38
+ class BackboneExportWrapper(nn.Module):
39
+ """Exports ``backbone(images)`` as a flat tuple of FPN tensors.
40
+
41
+ Works for any model that exposes a ``backbone`` attribute (ResNet+FPN).
42
+
43
+ Args:
44
+ backbone: Module accepting ``[B, 3, H, W]`` float tensor.
45
+ """
46
+
47
+ def __init__(self, backbone: nn.Module) -> None:
48
+ super().__init__()
49
+ self.backbone = backbone
50
+
51
+ def forward(self, images: torch.Tensor) -> Tuple[torch.Tensor, ...]:
52
+ feats = self.backbone(images)
53
+ return _ordered_fpn_values(feats)
54
+
55
+
56
+ class RetinaNetBackboneHeadExportWrapper(nn.Module):
57
+ """Exports Rotated RetinaNet **backbone + classification/regression head** only.
58
+
59
+ Outputs alternate per FPN level: ``cls_lvl0``, ``bbox_lvl0``, ``cls_lvl1``, …
60
+ Classification tensors are sigmoid logits ``[B, A*K, H, W]``; bbox tensors are
61
+ ``[B, A*5, H, W]`` in model order. Decoding, anchor generation, thresholding,
62
+ and oriented NMS are **not** included (run in PyTorch inference or downstream TF).
63
+ """
64
+
65
+ def __init__(self, model: RotatedRetinaNet) -> None:
66
+ super().__init__()
67
+ self.backbone = model.backbone
68
+ self.extra_fpn_conv = model.extra_fpn_conv
69
+ self.head = model.head
70
+
71
+ def forward(self, images: torch.Tensor) -> Tuple[torch.Tensor, ...]:
72
+ # List of (C, H, W) — use unbind so batch size stays symbolic for ONNX.
73
+ img_list: Sequence[torch.Tensor] = torch.unbind(images, dim=0)
74
+ feat_list = extract_backbone_features(
75
+ self.backbone,
76
+ img_list,
77
+ use_checkpoint=False,
78
+ training=False,
79
+ include_pool_level=True, # match RotatedRetinaNet.forward (P6 pool level)
80
+ )
81
+ if self.extra_fpn_conv is not None:
82
+ extra = F.relu(self.extra_fpn_conv(feat_list[-1]))
83
+ feat_list = list(feat_list) + [extra]
84
+ cls_list, bbox_list = self.head(feat_list)
85
+ out: List[torch.Tensor] = []
86
+ for c, b in zip(cls_list, bbox_list):
87
+ out.append(c)
88
+ out.append(b)
89
+ return tuple(out)
90
+
91
+
92
+ class RotatedFasterRCNNPreNmsExportWrapper(nn.Module):
93
+ """Rotated Faster R-CNN through ROI decode; outputs padded pre-NMS tensors.
94
+
95
+ Input: ``images`` ``[1, 3, H, W]`` float32 in [0, 1] RGB.
96
+ Outputs: ``pre_nms_boxes``, ``pre_nms_scores``, ``pre_nms_labels``, ``pre_nms_count``.
97
+ Final rotated NMS and production score filters run in the TF SavedModel wrapper.
98
+ """
99
+
100
+ def __init__(
101
+ self,
102
+ model: RotatedFasterRCNN,
103
+ height: int,
104
+ width: int,
105
+ max_candidates: int | None = None,
106
+ ) -> None:
107
+ super().__init__()
108
+ self.model = model
109
+ self.height = int(height)
110
+ self.width = int(width)
111
+ self.max_candidates = int(max_candidates or model.rpn_post_nms_top_n)
112
+
113
+ with torch.no_grad():
114
+ dummy = torch.zeros(1, 3, self.height, self.width, dtype=torch.float32)
115
+ feat_list = extract_backbone_features(
116
+ model.backbone,
117
+ [dummy[0]],
118
+ use_checkpoint=False,
119
+ training=False,
120
+ include_pool_level=True, # P6 for the RPN, matching RotatedFasterRCNN.forward
121
+ )
122
+ feature_map_sizes = [(f.shape[2], f.shape[3]) for f in feat_list]
123
+ fpn_strides_live = derive_fpn_strides_from_grid(
124
+ (self.height, self.width), feature_map_sizes
125
+ )
126
+ anchors = generate_oriented_anchors(
127
+ image_size=(self.height, self.width),
128
+ feature_map_sizes=feature_map_sizes,
129
+ anchor_scales=model.anchor_scales,
130
+ anchor_ratios=model.anchor_ratios,
131
+ anchor_angles=model.anchor_angles,
132
+ stride_per_level=fpn_strides_live,
133
+ )
134
+
135
+ self._num_anchor_levels = len(anchors)
136
+ for i, anchor_tensor in enumerate(anchors):
137
+ self.register_buffer(f"_anchor_{i}", anchor_tensor)
138
+ self._fpn_strides_list = [int(s) for s in fpn_strides_live]
139
+
140
+ def _anchor_list(self) -> List[torch.Tensor]:
141
+ return [getattr(self, f"_anchor_{i}") for i in range(self._num_anchor_levels)]
142
+
143
+ def forward(
144
+ self, images: torch.Tensor
145
+ ) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
146
+ return faster_rcnn_inference_pre_nms_padded(
147
+ self.model,
148
+ images,
149
+ self.max_candidates,
150
+ anchors=self._anchor_list(),
151
+ fpn_strides_live=self._fpn_strides_list,
152
+ deterministic_rpn=True,
153
+ )
154
+
155
+
156
+ __all__ = [
157
+ "BackboneExportWrapper",
158
+ "RetinaNetBackboneHeadExportWrapper",
159
+ "RotatedFasterRCNNPreNmsExportWrapper",
160
+ "_ordered_fpn_values",
161
+ ]
@@ -0,0 +1,77 @@
1
+ """OrientedDet: rotated object detection for aerial imagery.
2
+
3
+ Exports geometry (``RBox``, ``QBox``, ``Polygon``), ops (``iou``, ``nms``), data loaders,
4
+ and detectors (``OrientedRCNN``, ``RotatedFasterRCNN``, ``RotatedRetinaNet``).
5
+ Training configs: ``oriented_det.train.config.TrainingExperimentConfig``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from .geometry import Polygon, QBox, RBox, transforms as geometry_transforms
11
+
12
+ # Alias for convenience (geometry_transforms is the canonical name)
13
+ transforms = geometry_transforms
14
+
15
+ __all__ = [
16
+ "Polygon",
17
+ "QBox",
18
+ "RBox",
19
+ "transforms",
20
+ "geometry_transforms",
21
+ "iou",
22
+ "nms",
23
+ "DOTADataset",
24
+ "DOTASample",
25
+ "DOTAAnnotation",
26
+ "build_dota_loader",
27
+ "ImageTiler",
28
+ "compute_oriented_map",
29
+ "OrientedRCNN",
30
+ "RotatedFasterRCNN",
31
+ "RotatedRetinaNet",
32
+ "DeltaXYWHBBoxCoder", # 4 params, RPN (MMRotate Rotated Faster R-CNN)
33
+ "DeltaXYWHAHBBoxCoder", # 5 params, ROI (MMRotate)
34
+ "MidpointOffsetCoder", # 6 params, RPN (MMRotate Oriented R-CNN)
35
+ ]
36
+
37
+
38
+ _LAZY_ATTRS = {
39
+ # ops
40
+ "iou": (".ops", "iou"),
41
+ "nms": (".ops", "nms"),
42
+ # data
43
+ "DOTADataset": (".data", "DOTADataset"),
44
+ "DOTASample": (".data", "DOTASample"),
45
+ "DOTAAnnotation": (".data", "DOTAAnnotation"),
46
+ "build_dota_loader": (".data", "build_dota_loader"),
47
+ "ImageTiler": (".data", "ImageTiler"),
48
+ "compute_oriented_map": (".data", "compute_oriented_map"),
49
+ # models
50
+ "OrientedRCNN": (".models", "OrientedRCNN"),
51
+ "RotatedFasterRCNN": (".models", "RotatedFasterRCNN"),
52
+ "RotatedRetinaNet": (".models", "RotatedRetinaNet"),
53
+ "DeltaXYWHBBoxCoder": (".models", "DeltaXYWHBBoxCoder"),
54
+ "DeltaXYWHAHBBoxCoder": (".models", "DeltaXYWHAHBBoxCoder"),
55
+ "MidpointOffsetCoder": (".models", "MidpointOffsetCoder"),
56
+ }
57
+
58
+
59
+ def __getattr__(name: str):
60
+ """Lazy import torch-heavy modules on first access.
61
+
62
+ This keeps lightweight utilities (e.g. `oriented_det.pretrained`) usable in
63
+ environments where importing PyTorch/torchvision kernels is undesirable.
64
+ """
65
+ if name not in _LAZY_ATTRS:
66
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
67
+ module_path, attr = _LAZY_ATTRS[name]
68
+ import importlib
69
+
70
+ module = importlib.import_module(module_path, __name__)
71
+ value = getattr(module, attr)
72
+ globals()[name] = value
73
+ return value
74
+
75
+
76
+ def __dir__():
77
+ return sorted(set(globals().keys()) | set(_LAZY_ATTRS.keys()))