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.
- export/__init__.py +9 -0
- export/ort_runtime.py +67 -0
- export/postprocess.py +151 -0
- export/scripts/__init__.py +6 -0
- export/scripts/build_faster_rcnn_savedmodel.py +104 -0
- export/scripts/export_onnx.py +210 -0
- export/scripts/onnx_to_savedmodel.py +35 -0
- export/scripts/predict_savedmodel.py +94 -0
- export/scripts/save_predictions_tf.py +447 -0
- export/scripts/to_tflite.py +32 -0
- export/tests/test_export_onnx_optional.py +82 -0
- export/tests/test_export_wrappers.py +105 -0
- export/tests/test_faster_rcnn_export_parity.py +201 -0
- export/tests/test_ort_runtime.py +41 -0
- export/tf_serving_model.py +96 -0
- export/val_dataset.py +116 -0
- export/wrappers.py +161 -0
- oriented_det/__init__.py +77 -0
- oriented_det/cli/__init__.py +92 -0
- oriented_det/cli/train.py +18 -0
- oriented_det/configs/_base_/augmentation.json +21 -0
- oriented_det/configs/_base_/datasets/dota_le90.json +21 -0
- oriented_det/configs/_base_/fp16.json +5 -0
- oriented_det/configs/_base_/models/oriented_rcnn_r50.json +26 -0
- oriented_det/configs/_base_/models/rotated_faster_rcnn_r50.json +53 -0
- oriented_det/configs/_base_/models/rotated_retinanet_r50.json +30 -0
- oriented_det/configs/_base_/preprocessing.json +8 -0
- oriented_det/configs/_base_/schedules/1x.json +33 -0
- oriented_det/configs/config.schema.json +404 -0
- oriented_det/configs/oriented_rcnn/dota_le90_1x.json +121 -0
- oriented_det/configs/oriented_rcnn/dota_le90_3x.json +11 -0
- oriented_det/configs/rotated_faster_rcnn/dota_le90_1x.json +119 -0
- oriented_det/configs/rotated_faster_rcnn/dota_le90_3x.json +9 -0
- oriented_det/configs/rotated_retinanet/dota_le90_1x.json +107 -0
- oriented_det/configs/rotated_retinanet/dota_le90_3x.json +30 -0
- oriented_det/data/__init__.py +89 -0
- oriented_det/data/airbus_playground.py +611 -0
- oriented_det/data/dota.py +741 -0
- oriented_det/data/dota_classes.py +26 -0
- oriented_det/data/evaluation.py +648 -0
- oriented_det/data/flips.py +115 -0
- oriented_det/data/preprocessing.py +335 -0
- oriented_det/data/tiling.py +399 -0
- oriented_det/data/transforms.py +377 -0
- oriented_det/geometry/__init__.py +8 -0
- oriented_det/geometry/poly.py +127 -0
- oriented_det/geometry/qbox.py +63 -0
- oriented_det/geometry/rbox.py +250 -0
- oriented_det/geometry/transforms.py +266 -0
- oriented_det/models/__init__.py +36 -0
- oriented_det/models/backbones/__init__.py +11 -0
- oriented_det/models/backbones/resnet_fpn.py +79 -0
- oriented_det/models/backbones/utils.py +81 -0
- oriented_det/models/bbox_coder.py +355 -0
- oriented_det/models/faster_rcnn_inference.py +494 -0
- oriented_det/models/horizontal_roi_coder.py +155 -0
- oriented_det/models/oriented_rcnn.py +1256 -0
- oriented_det/models/oriented_roi.py +1664 -0
- oriented_det/models/oriented_rpn.py +2104 -0
- oriented_det/models/rotated_retinanet.py +1030 -0
- oriented_det/models/utils.py +590 -0
- oriented_det/ops/__init__.py +58 -0
- oriented_det/ops/gpu_ops.py +1109 -0
- oriented_det/ops/iou.py +172 -0
- oriented_det/ops/kfiou.py +275 -0
- oriented_det/ops/nms.py +202 -0
- oriented_det/ops/probiou.py +165 -0
- oriented_det/ops/rotated_ops.py +122 -0
- oriented_det/ops/utils.py +257 -0
- oriented_det/pretrained/__init__.py +23 -0
- oriented_det/pretrained/hub.py +249 -0
- oriented_det/pretrained/manifest.json +46 -0
- oriented_det/runtime/__init__.py +29 -0
- oriented_det/runtime/checkpoint.py +274 -0
- oriented_det/runtime/collate.py +348 -0
- oriented_det/runtime/inference.py +1286 -0
- oriented_det/train/__init__.py +102 -0
- oriented_det/train/config.py +872 -0
- oriented_det/train/engine.py +2933 -0
- oriented_det/train/grouped_ce.py +139 -0
- oriented_det/train/piecewise_schedule.py +33 -0
- oriented_det/train/profiler.py +287 -0
- oriented_det/train/utils.py +999 -0
- oriented_det/utils/__init__.py +31 -0
- oriented_det/utils/config.py +376 -0
- oriented_det/utils/device.py +35 -0
- oriented_det/utils/logging.py +163 -0
- oriented_det/utils/progress.py +62 -0
- oriented_det/utils/viz.py +181 -0
- oriented_det-0.1.0.dist-info/METADATA +313 -0
- oriented_det-0.1.0.dist-info/RECORD +115 -0
- oriented_det-0.1.0.dist-info/WHEEL +5 -0
- oriented_det-0.1.0.dist-info/entry_points.txt +2 -0
- oriented_det-0.1.0.dist-info/licenses/LICENSE +202 -0
- oriented_det-0.1.0.dist-info/top_level.txt +3 -0
- tools/__init__.py +1 -0
- tools/app.py +1284 -0
- tools/dataset_stats.py +389 -0
- tools/dota_labels_to_comma.py +132 -0
- tools/free_gpu.py +142 -0
- tools/generate_airbus_playground_csv.py +154 -0
- tools/image_demo.py +200 -0
- tools/lr_finder.py +771 -0
- tools/measure_sampled_riou_error.py +483 -0
- tools/playground_to_dota.py +290 -0
- tools/pretrained_download.py +49 -0
- tools/preview_augmentation.py +510 -0
- tools/publish_checkpoint.py +96 -0
- tools/save_predictions.py +2350 -0
- tools/sync_vendored_configs.py +94 -0
- tools/tile_dota.py +447 -0
- tools/train.py +2324 -0
- tools/train_example.py +244 -0
- tools/train_multi_gpu.py +367 -0
- 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
|
+
]
|
oriented_det/__init__.py
ADDED
|
@@ -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()))
|