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,611 @@
|
|
|
1
|
+
"""Airbus Playground CSV generation and runtime dataset loader."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import json
|
|
7
|
+
import random
|
|
8
|
+
import re
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from collections import Counter, defaultdict
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Dict, Iterable, List, Literal, Optional, Sequence, Set, Tuple
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
from PIL import Image
|
|
17
|
+
except ImportError:
|
|
18
|
+
Image = None # type: ignore
|
|
19
|
+
|
|
20
|
+
from .dota import DOTAAnnotation, DOTASample
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class TileRecord:
|
|
25
|
+
"""A single tile discovered in an Airbus Playground export."""
|
|
26
|
+
|
|
27
|
+
dataset_id: str
|
|
28
|
+
zone_id: str
|
|
29
|
+
image_id: str
|
|
30
|
+
tile_id: str
|
|
31
|
+
tile_relpath: str
|
|
32
|
+
label_relpath: str
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _require_pillow() -> None:
|
|
36
|
+
if Image is None:
|
|
37
|
+
raise RuntimeError("PIL/Pillow is required.")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _resolve_csv_path(data_root: Path, path_or_name: str | Path) -> Path:
|
|
41
|
+
path = Path(path_or_name)
|
|
42
|
+
if path.is_absolute():
|
|
43
|
+
return path
|
|
44
|
+
return data_root / path
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
_DATED_PLAYGROUND_CSV_RE = re.compile(r"^(annotations|split)_(\d{8})\.csv$")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _playground_csv_date_stamp(
|
|
51
|
+
when: datetime | None = None,
|
|
52
|
+
*,
|
|
53
|
+
utc: bool = True,
|
|
54
|
+
) -> str:
|
|
55
|
+
dt = when if when is not None else datetime.now(timezone.utc if utc else None)
|
|
56
|
+
if utc and dt.tzinfo is None:
|
|
57
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
58
|
+
return dt.strftime("%Y%m%d")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def dated_playground_csv_filenames(
|
|
62
|
+
when: datetime | None = None,
|
|
63
|
+
*,
|
|
64
|
+
utc: bool = True,
|
|
65
|
+
) -> Tuple[str, str]:
|
|
66
|
+
"""Return paired dated CSV basenames from one run, e.g. annotations/split ``_20260516.csv``."""
|
|
67
|
+
stamp = _playground_csv_date_stamp(when, utc=utc)
|
|
68
|
+
return f"annotations_{stamp}.csv", f"split_{stamp}.csv"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def timestamped_split_filename(
|
|
72
|
+
when: datetime | None = None,
|
|
73
|
+
*,
|
|
74
|
+
utc: bool = True,
|
|
75
|
+
) -> str:
|
|
76
|
+
"""Return the dated split CSV basename from :func:`dated_playground_csv_filenames`."""
|
|
77
|
+
return dated_playground_csv_filenames(when, utc=utc)[1]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def timestamped_annotations_filename(
|
|
81
|
+
when: datetime | None = None,
|
|
82
|
+
*,
|
|
83
|
+
utc: bool = True,
|
|
84
|
+
) -> str:
|
|
85
|
+
"""Return the dated annotations CSV basename from :func:`dated_playground_csv_filenames`."""
|
|
86
|
+
return dated_playground_csv_filenames(when, utc=utc)[0]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def resolve_playground_csv_filenames(
|
|
90
|
+
annotations_file: str | None,
|
|
91
|
+
split_file: str | None,
|
|
92
|
+
when: datetime | None = None,
|
|
93
|
+
*,
|
|
94
|
+
utc: bool = True,
|
|
95
|
+
) -> Tuple[str, str]:
|
|
96
|
+
"""Resolve CLI/config CSV names; default is a same-day annotations + split pair."""
|
|
97
|
+
if annotations_file is None and split_file is None:
|
|
98
|
+
return dated_playground_csv_filenames(when, utc=utc)
|
|
99
|
+
|
|
100
|
+
ann = annotations_file or "annotations.csv"
|
|
101
|
+
split = split_file or "split.csv"
|
|
102
|
+
if annotations_file is None and split_file is not None:
|
|
103
|
+
paired = _paired_dated_playground_csv(split_file, want="annotations")
|
|
104
|
+
if paired is not None:
|
|
105
|
+
ann = paired
|
|
106
|
+
if split_file is None and annotations_file is not None:
|
|
107
|
+
paired = _paired_dated_playground_csv(annotations_file, want="split")
|
|
108
|
+
if paired is not None:
|
|
109
|
+
split = paired
|
|
110
|
+
return ann, split
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _paired_dated_playground_csv(name: str, *, want: Literal["annotations", "split"]) -> str | None:
|
|
114
|
+
m = _DATED_PLAYGROUND_CSV_RE.match(name)
|
|
115
|
+
if not m or m.group(1) == want:
|
|
116
|
+
return None
|
|
117
|
+
return f"{want}_{m.group(2)}.csv"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _normalize_dota_coords(coords: Sequence[float]) -> str:
|
|
121
|
+
# Keep compact but stable formatting for portability/readability.
|
|
122
|
+
return " ".join(f"{float(value):.3f}" for value in coords)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def discover_airbus_tiles(data_root: str | Path) -> List[TileRecord]:
|
|
126
|
+
"""Discover all tiles in Airbus Playground export folders.
|
|
127
|
+
|
|
128
|
+
Expected layout:
|
|
129
|
+
data_root/dataset_id/samples/zone_id/image_id/tile_id.jpg
|
|
130
|
+
data_root/dataset_id/labels/zone_id/tile_id.json
|
|
131
|
+
"""
|
|
132
|
+
root = Path(data_root)
|
|
133
|
+
records: List[TileRecord] = []
|
|
134
|
+
|
|
135
|
+
for dataset_dir in sorted(p for p in root.iterdir() if p.is_dir()):
|
|
136
|
+
samples_dir = dataset_dir / "samples"
|
|
137
|
+
labels_dir = dataset_dir / "labels"
|
|
138
|
+
if not samples_dir.exists() or not labels_dir.exists():
|
|
139
|
+
continue
|
|
140
|
+
|
|
141
|
+
dataset_id = dataset_dir.name
|
|
142
|
+
for zone_dir in sorted(p for p in samples_dir.iterdir() if p.is_dir()):
|
|
143
|
+
zone_id = zone_dir.name
|
|
144
|
+
for image_dir in sorted(p for p in zone_dir.iterdir() if p.is_dir()):
|
|
145
|
+
image_id = image_dir.name
|
|
146
|
+
for tile_path in sorted(image_dir.glob("*.jpg")):
|
|
147
|
+
tile_id = tile_path.stem
|
|
148
|
+
label_path = labels_dir / zone_id / f"{tile_id}.json"
|
|
149
|
+
records.append(
|
|
150
|
+
TileRecord(
|
|
151
|
+
dataset_id=dataset_id,
|
|
152
|
+
zone_id=zone_id,
|
|
153
|
+
image_id=image_id,
|
|
154
|
+
tile_id=tile_id,
|
|
155
|
+
tile_relpath=str(tile_path.relative_to(root)),
|
|
156
|
+
label_relpath=str(label_path.relative_to(root)),
|
|
157
|
+
)
|
|
158
|
+
)
|
|
159
|
+
return records
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _load_airbus_label_rows(
|
|
163
|
+
label_path: Path,
|
|
164
|
+
*,
|
|
165
|
+
difficulty: int = 0,
|
|
166
|
+
ignore_labels: Optional[set[str]] = None,
|
|
167
|
+
map_labels: Optional[Dict[str, str]] = None,
|
|
168
|
+
stats: Optional[Dict[str, Any]] = None,
|
|
169
|
+
) -> List[Tuple[str, str, int]]:
|
|
170
|
+
"""Parse one Airbus label JSON into DOTA coordinate rows.
|
|
171
|
+
|
|
172
|
+
Returns list of tuples: (dota_coords, class_name, difficult).
|
|
173
|
+
"""
|
|
174
|
+
if not label_path.exists():
|
|
175
|
+
return []
|
|
176
|
+
|
|
177
|
+
try:
|
|
178
|
+
from shapely.geometry import shape
|
|
179
|
+
except ImportError as exc:
|
|
180
|
+
raise RuntimeError("shapely is required to convert polygons to OBB.") from exc
|
|
181
|
+
|
|
182
|
+
content = json.loads(label_path.read_text(encoding="utf-8"))
|
|
183
|
+
features = content.get("features", [])
|
|
184
|
+
rows: List[Tuple[str, str, int]] = []
|
|
185
|
+
|
|
186
|
+
for feature in features:
|
|
187
|
+
properties = feature.get("properties", {}) or {}
|
|
188
|
+
geometry = feature.get("geometry")
|
|
189
|
+
if geometry is None:
|
|
190
|
+
continue
|
|
191
|
+
if properties.get("mask", False):
|
|
192
|
+
continue
|
|
193
|
+
|
|
194
|
+
tags = properties.get("tags", [])
|
|
195
|
+
if not tags:
|
|
196
|
+
continue
|
|
197
|
+
# Airbus uses many tags per object; sort then concatenate with ", " for stable labels
|
|
198
|
+
class_name = ", ".join(sorted(str(t).strip() for t in tags)).strip()
|
|
199
|
+
if not class_name:
|
|
200
|
+
continue
|
|
201
|
+
if stats is not None:
|
|
202
|
+
stats["raw_label_counts"][class_name] += 1
|
|
203
|
+
|
|
204
|
+
if ignore_labels is not None and class_name in ignore_labels:
|
|
205
|
+
if stats is not None:
|
|
206
|
+
stats["ignored_label_counts"][class_name] += 1
|
|
207
|
+
stats["ignored_objects"] += 1
|
|
208
|
+
continue
|
|
209
|
+
|
|
210
|
+
mapped_name = map_labels.get(class_name, class_name) if map_labels is not None else class_name
|
|
211
|
+
if mapped_name != class_name and stats is not None:
|
|
212
|
+
stats["mapped_objects"] += 1
|
|
213
|
+
stats["mapping_pairs"][f"{class_name}->{mapped_name}"] += 1
|
|
214
|
+
|
|
215
|
+
try:
|
|
216
|
+
polygon = shape(geometry)
|
|
217
|
+
obb = polygon.minimum_rotated_rectangle
|
|
218
|
+
coords = list(obb.exterior.coords)
|
|
219
|
+
except Exception:
|
|
220
|
+
continue
|
|
221
|
+
|
|
222
|
+
if len(coords) >= 5:
|
|
223
|
+
coords = coords[:-1]
|
|
224
|
+
if len(coords) != 4:
|
|
225
|
+
continue
|
|
226
|
+
|
|
227
|
+
flat = [float(v) for xy in coords for v in xy]
|
|
228
|
+
rows.append((_normalize_dota_coords(flat), mapped_name, int(difficulty)))
|
|
229
|
+
if stats is not None:
|
|
230
|
+
stats["final_label_counts"][mapped_name] += 1
|
|
231
|
+
stats["kept_objects"] += 1
|
|
232
|
+
|
|
233
|
+
return rows
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _image_group_key(tile: TileRecord) -> Tuple[str, str, str]:
|
|
237
|
+
return (tile.dataset_id, tile.zone_id, tile.image_id)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def detect_airbus_split_csv_format(split_values: Sequence[str]) -> Literal["train_val", "fold_ids"]:
|
|
241
|
+
"""Infer whether split.csv uses legacy train/val strings or integer fold ids.
|
|
242
|
+
|
|
243
|
+
Fold mode: every non-empty value must parse as a non-negative int. Training code treats
|
|
244
|
+
fold ``val_split_id`` (default 0) as validation and all other folds as training.
|
|
245
|
+
"""
|
|
246
|
+
non_empty = [str(s).strip() for s in split_values if s is not None and str(s).strip() != ""]
|
|
247
|
+
if not non_empty:
|
|
248
|
+
raise ValueError("split.csv contains no non-empty split values.")
|
|
249
|
+
lowered = {s.lower() for s in non_empty}
|
|
250
|
+
if lowered <= {"train", "val"}:
|
|
251
|
+
return "train_val"
|
|
252
|
+
for s in non_empty:
|
|
253
|
+
try:
|
|
254
|
+
v = int(s)
|
|
255
|
+
except ValueError as exc:
|
|
256
|
+
raise ValueError(
|
|
257
|
+
f"split.csv: expected integer fold id or 'train'/'val'; got {s!r}"
|
|
258
|
+
) from exc
|
|
259
|
+
if v < 0:
|
|
260
|
+
raise ValueError(f"split.csv: fold id must be non-negative; got {v}")
|
|
261
|
+
return "fold_ids"
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def generate_airbus_playground_csvs(
|
|
265
|
+
data_root: str | Path,
|
|
266
|
+
*,
|
|
267
|
+
annotations_file: str = "annotations.csv",
|
|
268
|
+
split_file: str = "split.csv",
|
|
269
|
+
num_splits: int = 10,
|
|
270
|
+
seed: int = 42,
|
|
271
|
+
ignore_labels: Optional[Sequence[str]] = None,
|
|
272
|
+
map_labels: Optional[Dict[str, str]] = None,
|
|
273
|
+
include_stats: bool = False,
|
|
274
|
+
) -> Tuple[Path, Path] | Tuple[Path, Path, Dict[str, Any]]:
|
|
275
|
+
"""Generate annotations.csv and split.csv from Airbus export folders.
|
|
276
|
+
|
|
277
|
+
Each image group ``(dataset_id, zone_id, image_id)`` is assigned an integer fold id in
|
|
278
|
+
``0 .. num_splits-1`` (balanced round-robin on a shuffled group list). The ``split`` column
|
|
279
|
+
stores that integer as text. By convention fold ``0`` is the default validation fold; set
|
|
280
|
+
``dataset.val_split_id`` in the training config to use another fold as validation.
|
|
281
|
+
"""
|
|
282
|
+
if num_splits < 2:
|
|
283
|
+
raise ValueError("num_splits must be at least 2.")
|
|
284
|
+
|
|
285
|
+
root = Path(data_root)
|
|
286
|
+
tiles = discover_airbus_tiles(root)
|
|
287
|
+
if not tiles:
|
|
288
|
+
raise FileNotFoundError(f"No Airbus Playground tiles found under {root}")
|
|
289
|
+
|
|
290
|
+
groups: Dict[Tuple[str, str, str], List[TileRecord]] = defaultdict(list)
|
|
291
|
+
for tile in tiles:
|
|
292
|
+
groups[_image_group_key(tile)].append(tile)
|
|
293
|
+
|
|
294
|
+
group_keys = sorted(groups.keys())
|
|
295
|
+
rng = random.Random(seed)
|
|
296
|
+
rng.shuffle(group_keys)
|
|
297
|
+
group_to_fold = {gk: str(i % num_splits) for i, gk in enumerate(group_keys)}
|
|
298
|
+
|
|
299
|
+
ignore_set = set(ignore_labels or [])
|
|
300
|
+
map_dict = dict(map_labels or {})
|
|
301
|
+
stats: Dict[str, Any] = {
|
|
302
|
+
"raw_label_counts": Counter(),
|
|
303
|
+
"ignored_label_counts": Counter(),
|
|
304
|
+
"final_label_counts": Counter(),
|
|
305
|
+
"mapping_pairs": Counter(),
|
|
306
|
+
"ignored_objects": 0,
|
|
307
|
+
"mapped_objects": 0,
|
|
308
|
+
"kept_objects": 0,
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
split_rows: List[Dict[str, str]] = []
|
|
312
|
+
annotation_rows: List[Dict[str, str]] = []
|
|
313
|
+
for tile in sorted(tiles, key=lambda x: x.tile_relpath):
|
|
314
|
+
split_value = group_to_fold[_image_group_key(tile)]
|
|
315
|
+
split_rows.append(
|
|
316
|
+
{
|
|
317
|
+
"tile_relpath": tile.tile_relpath,
|
|
318
|
+
"dataset_id": tile.dataset_id,
|
|
319
|
+
"zone_id": tile.zone_id,
|
|
320
|
+
"image_id": tile.image_id,
|
|
321
|
+
"tile_id": tile.tile_id,
|
|
322
|
+
"split": split_value,
|
|
323
|
+
}
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
label_rows = _load_airbus_label_rows(
|
|
327
|
+
root / tile.label_relpath,
|
|
328
|
+
ignore_labels=ignore_set,
|
|
329
|
+
map_labels=map_dict,
|
|
330
|
+
stats=stats,
|
|
331
|
+
)
|
|
332
|
+
for dota_coords, class_name, difficult in label_rows:
|
|
333
|
+
annotation_rows.append(
|
|
334
|
+
{
|
|
335
|
+
"tile_relpath": tile.tile_relpath,
|
|
336
|
+
"dataset_id": tile.dataset_id,
|
|
337
|
+
"zone_id": tile.zone_id,
|
|
338
|
+
"image_id": tile.image_id,
|
|
339
|
+
"tile_id": tile.tile_id,
|
|
340
|
+
"dota_coords": dota_coords,
|
|
341
|
+
"class_name": class_name,
|
|
342
|
+
"difficult": str(difficult),
|
|
343
|
+
}
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
annotations_path = _resolve_csv_path(root, annotations_file)
|
|
347
|
+
split_path = _resolve_csv_path(root, split_file)
|
|
348
|
+
annotations_path.parent.mkdir(parents=True, exist_ok=True)
|
|
349
|
+
split_path.parent.mkdir(parents=True, exist_ok=True)
|
|
350
|
+
|
|
351
|
+
with annotations_path.open("w", encoding="utf-8", newline="") as f:
|
|
352
|
+
writer = csv.DictWriter(
|
|
353
|
+
f,
|
|
354
|
+
fieldnames=[
|
|
355
|
+
"tile_relpath",
|
|
356
|
+
"dataset_id",
|
|
357
|
+
"zone_id",
|
|
358
|
+
"image_id",
|
|
359
|
+
"tile_id",
|
|
360
|
+
"dota_coords",
|
|
361
|
+
"class_name",
|
|
362
|
+
"difficult",
|
|
363
|
+
],
|
|
364
|
+
)
|
|
365
|
+
writer.writeheader()
|
|
366
|
+
writer.writerows(annotation_rows)
|
|
367
|
+
|
|
368
|
+
with split_path.open("w", encoding="utf-8", newline="") as f:
|
|
369
|
+
writer = csv.DictWriter(
|
|
370
|
+
f,
|
|
371
|
+
fieldnames=["tile_relpath", "dataset_id", "zone_id", "image_id", "tile_id", "split"],
|
|
372
|
+
)
|
|
373
|
+
writer.writeheader()
|
|
374
|
+
writer.writerows(split_rows)
|
|
375
|
+
|
|
376
|
+
if include_stats:
|
|
377
|
+
split_counts = Counter(row["split"] for row in split_rows)
|
|
378
|
+
tiles_per_split = {k: int(split_counts[k]) for k in sorted(split_counts.keys(), key=int)}
|
|
379
|
+
summary = {
|
|
380
|
+
"total_tiles": len(split_rows),
|
|
381
|
+
"total_groups": len(group_keys),
|
|
382
|
+
"num_splits": num_splits,
|
|
383
|
+
"tiles_per_split": tiles_per_split,
|
|
384
|
+
"default_val_fold_id": 0,
|
|
385
|
+
"seed": seed,
|
|
386
|
+
"ignore_labels": sorted(ignore_set),
|
|
387
|
+
"map_labels": map_dict,
|
|
388
|
+
"raw_objects": int(sum(stats["raw_label_counts"].values())),
|
|
389
|
+
"ignored_objects": int(stats["ignored_objects"]),
|
|
390
|
+
"mapped_objects": int(stats["mapped_objects"]),
|
|
391
|
+
"kept_objects": int(stats["kept_objects"]),
|
|
392
|
+
"raw_label_counts": dict(sorted(stats["raw_label_counts"].items())),
|
|
393
|
+
"ignored_label_counts": dict(sorted(stats["ignored_label_counts"].items())),
|
|
394
|
+
"final_label_counts": dict(sorted(stats["final_label_counts"].items())),
|
|
395
|
+
"mapping_pairs": dict(sorted(stats["mapping_pairs"].items())),
|
|
396
|
+
}
|
|
397
|
+
return annotations_path, split_path, summary
|
|
398
|
+
|
|
399
|
+
return annotations_path, split_path
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
class AirbusPlaygroundCSVDataset:
|
|
403
|
+
"""CSV-backed Airbus Playground dataset yielding DOTASample objects."""
|
|
404
|
+
|
|
405
|
+
def __init__(
|
|
406
|
+
self,
|
|
407
|
+
data_root: str | Path,
|
|
408
|
+
*,
|
|
409
|
+
split: str,
|
|
410
|
+
annotations_file: str | Path = "annotations.csv",
|
|
411
|
+
split_file: str | Path = "split.csv",
|
|
412
|
+
val_split_id: int = 0,
|
|
413
|
+
allowed_classes: Optional[Sequence[str]] = None,
|
|
414
|
+
difficult_strategy: str = "drop",
|
|
415
|
+
ignore_labels: Optional[Sequence[str]] = None,
|
|
416
|
+
map_labels: Optional[Dict[str, str]] = None,
|
|
417
|
+
filter_empty_gt: bool = False,
|
|
418
|
+
):
|
|
419
|
+
if split not in {"train", "val"}:
|
|
420
|
+
raise ValueError(f"Unsupported split '{split}'. Expected 'train' or 'val'.")
|
|
421
|
+
|
|
422
|
+
if val_split_id < 0:
|
|
423
|
+
raise ValueError(f"val_split_id must be non-negative; got {val_split_id}")
|
|
424
|
+
|
|
425
|
+
ds = (difficult_strategy or "drop").strip().lower()
|
|
426
|
+
if ds not in {"drop", "ignore", "keep"}:
|
|
427
|
+
raise ValueError(
|
|
428
|
+
f"Invalid difficult_strategy={difficult_strategy!r}; expected 'drop', 'ignore', or 'keep'."
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
self.data_root = Path(data_root)
|
|
432
|
+
self.split = split
|
|
433
|
+
self.val_split_id = int(val_split_id)
|
|
434
|
+
self.allowed_classes = set(allowed_classes) if allowed_classes is not None else None
|
|
435
|
+
self.difficult_strategy = ds
|
|
436
|
+
self.ignore_labels = set(ignore_labels or [])
|
|
437
|
+
self.map_labels = dict(map_labels or {})
|
|
438
|
+
self.filter_empty_gt = bool(filter_empty_gt)
|
|
439
|
+
|
|
440
|
+
self.annotations_path = _resolve_csv_path(self.data_root, annotations_file)
|
|
441
|
+
self.split_path = _resolve_csv_path(self.data_root, split_file)
|
|
442
|
+
if not self.annotations_path.exists():
|
|
443
|
+
raise FileNotFoundError(f"Annotations CSV not found: {self.annotations_path}")
|
|
444
|
+
if not self.split_path.exists():
|
|
445
|
+
raise FileNotFoundError(f"Split CSV not found: {self.split_path}")
|
|
446
|
+
|
|
447
|
+
self._samples_meta = self._load_split_rows()
|
|
448
|
+
self._tiles_before_empty_filter = len(self._samples_meta)
|
|
449
|
+
self._annotations_by_tile = self._load_annotations_rows()
|
|
450
|
+
if self.filter_empty_gt:
|
|
451
|
+
self._samples_meta = [
|
|
452
|
+
row
|
|
453
|
+
for row in self._samples_meta
|
|
454
|
+
if len(self._annotations_by_tile.get(row.get("tile_relpath", ""), [])) > 0
|
|
455
|
+
]
|
|
456
|
+
self._empty_gt_filtered_count = self._tiles_before_empty_filter - len(self._samples_meta)
|
|
457
|
+
|
|
458
|
+
@property
|
|
459
|
+
def tiles_discovered_count(self) -> int:
|
|
460
|
+
"""Tiles in split CSV before ``filter_empty_gt`` (if enabled)."""
|
|
461
|
+
return self._tiles_before_empty_filter
|
|
462
|
+
|
|
463
|
+
@property
|
|
464
|
+
def empty_gt_filtered_count(self) -> int:
|
|
465
|
+
"""Tiles removed by ``filter_empty_gt`` at init."""
|
|
466
|
+
return self._empty_gt_filtered_count
|
|
467
|
+
|
|
468
|
+
def _load_split_rows(self) -> List[Dict[str, str]]:
|
|
469
|
+
with self.split_path.open("r", encoding="utf-8", newline="") as f:
|
|
470
|
+
reader = csv.DictReader(f)
|
|
471
|
+
all_rows = list(reader)
|
|
472
|
+
if not all_rows:
|
|
473
|
+
return []
|
|
474
|
+
|
|
475
|
+
mode = detect_airbus_split_csv_format([r.get("split", "") for r in all_rows])
|
|
476
|
+
want_val = self.split == "val"
|
|
477
|
+
rows: List[Dict[str, str]] = []
|
|
478
|
+
for row in all_rows:
|
|
479
|
+
raw = row.get("split", "")
|
|
480
|
+
if mode == "train_val":
|
|
481
|
+
if raw.strip().lower() == self.split:
|
|
482
|
+
rows.append(row)
|
|
483
|
+
else:
|
|
484
|
+
try:
|
|
485
|
+
sid = int(str(raw).strip())
|
|
486
|
+
except ValueError:
|
|
487
|
+
continue
|
|
488
|
+
is_val = sid == self.val_split_id
|
|
489
|
+
if is_val == want_val:
|
|
490
|
+
rows.append(row)
|
|
491
|
+
|
|
492
|
+
# Stable order for reproducibility.
|
|
493
|
+
rows.sort(key=lambda row: row.get("tile_relpath", ""))
|
|
494
|
+
return rows
|
|
495
|
+
|
|
496
|
+
def _load_annotations_rows(self) -> Dict[str, List[DOTAAnnotation]]:
|
|
497
|
+
selected_tiles = {row.get("tile_relpath", "") for row in self._samples_meta}
|
|
498
|
+
annotations_by_tile: Dict[str, List[DOTAAnnotation]] = defaultdict(list)
|
|
499
|
+
|
|
500
|
+
with self.annotations_path.open("r", encoding="utf-8", newline="") as f:
|
|
501
|
+
reader = csv.DictReader(f)
|
|
502
|
+
for row in reader:
|
|
503
|
+
tile_relpath = row.get("tile_relpath", "")
|
|
504
|
+
if tile_relpath not in selected_tiles:
|
|
505
|
+
continue
|
|
506
|
+
|
|
507
|
+
class_name = str(row.get("class_name", "")).strip()
|
|
508
|
+
if not class_name:
|
|
509
|
+
continue
|
|
510
|
+
if class_name in self.ignore_labels:
|
|
511
|
+
continue
|
|
512
|
+
class_name = self.map_labels.get(class_name, class_name)
|
|
513
|
+
if self.allowed_classes is not None and class_name not in self.allowed_classes:
|
|
514
|
+
continue
|
|
515
|
+
|
|
516
|
+
difficult = int(str(row.get("difficult", "0")).strip() or "0")
|
|
517
|
+
if self.difficult_strategy == "drop" and difficult == 1:
|
|
518
|
+
continue
|
|
519
|
+
|
|
520
|
+
coords = str(row.get("dota_coords", "")).strip()
|
|
521
|
+
if not coords:
|
|
522
|
+
continue
|
|
523
|
+
line = f"{coords} {class_name} {difficult}"
|
|
524
|
+
try:
|
|
525
|
+
ann = DOTAAnnotation.from_line(line)
|
|
526
|
+
except Exception:
|
|
527
|
+
continue
|
|
528
|
+
annotations_by_tile[tile_relpath].append(ann)
|
|
529
|
+
|
|
530
|
+
return annotations_by_tile
|
|
531
|
+
|
|
532
|
+
def get_raw_class_names_from_csv(self) -> List[str]:
|
|
533
|
+
"""Return unique class_name values in the CSV for the current split (before map_labels/ignore_labels).
|
|
534
|
+
Useful to verify CSV content and that map_labels keys match."""
|
|
535
|
+
selected_tiles = {row.get("tile_relpath", "") for row in self._samples_meta}
|
|
536
|
+
raw: Set[str] = set()
|
|
537
|
+
with self.annotations_path.open("r", encoding="utf-8", newline="") as f:
|
|
538
|
+
reader = csv.DictReader(f)
|
|
539
|
+
for row in reader:
|
|
540
|
+
if row.get("tile_relpath", "") not in selected_tiles:
|
|
541
|
+
continue
|
|
542
|
+
cn = str(row.get("class_name", "")).strip()
|
|
543
|
+
if cn:
|
|
544
|
+
raw.add(cn)
|
|
545
|
+
return sorted(raw)
|
|
546
|
+
|
|
547
|
+
def _load_image_size(self, image_path: Path) -> Tuple[int, int]:
|
|
548
|
+
_require_pillow()
|
|
549
|
+
with Image.open(image_path) as img:
|
|
550
|
+
return img.size
|
|
551
|
+
|
|
552
|
+
def __len__(self) -> int:
|
|
553
|
+
return len(self._samples_meta)
|
|
554
|
+
|
|
555
|
+
def __getitem__(self, idx: int) -> DOTASample:
|
|
556
|
+
if idx < 0 or idx >= len(self):
|
|
557
|
+
raise IndexError(f"Index {idx} out of range for dataset of size {len(self)}")
|
|
558
|
+
|
|
559
|
+
row = self._samples_meta[idx]
|
|
560
|
+
tile_relpath = row.get("tile_relpath", "")
|
|
561
|
+
if not tile_relpath:
|
|
562
|
+
raise ValueError("split.csv row is missing tile_relpath")
|
|
563
|
+
|
|
564
|
+
image_path = self.data_root / tile_relpath
|
|
565
|
+
if not image_path.exists():
|
|
566
|
+
raise FileNotFoundError(f"Image not found: {image_path}")
|
|
567
|
+
|
|
568
|
+
width, height = self._load_image_size(image_path)
|
|
569
|
+
annotations = tuple(self._annotations_by_tile.get(tile_relpath, []))
|
|
570
|
+
return DOTASample(
|
|
571
|
+
image_path=image_path,
|
|
572
|
+
width=width,
|
|
573
|
+
height=height,
|
|
574
|
+
annotations=annotations,
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
def __iter__(self) -> Iterable[DOTASample]:
|
|
578
|
+
for idx in range(len(self)):
|
|
579
|
+
yield self[idx]
|
|
580
|
+
|
|
581
|
+
def get_class_names(self) -> List[str]:
|
|
582
|
+
classes = set()
|
|
583
|
+
for annotations in self._annotations_by_tile.values():
|
|
584
|
+
for ann in annotations:
|
|
585
|
+
classes.add(ann.class_name)
|
|
586
|
+
return sorted(classes)
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def format_airbus_empty_gt_filter_log(dataset: AirbusPlaygroundCSVDataset, *, split: str) -> str:
|
|
590
|
+
"""One-line summary for train logs when ``filter_empty_gt`` is enabled."""
|
|
591
|
+
discovered = dataset.tiles_discovered_count
|
|
592
|
+
filtered = dataset.empty_gt_filtered_count
|
|
593
|
+
kept = discovered - filtered
|
|
594
|
+
return (
|
|
595
|
+
f" {split}: filter_empty_gt dropped {filtered} / {discovered} tiles "
|
|
596
|
+
f"({kept} kept)"
|
|
597
|
+
)
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
__all__ = [
|
|
601
|
+
"TileRecord",
|
|
602
|
+
"discover_airbus_tiles",
|
|
603
|
+
"detect_airbus_split_csv_format",
|
|
604
|
+
"generate_airbus_playground_csvs",
|
|
605
|
+
"dated_playground_csv_filenames",
|
|
606
|
+
"resolve_playground_csv_filenames",
|
|
607
|
+
"timestamped_annotations_filename",
|
|
608
|
+
"timestamped_split_filename",
|
|
609
|
+
"AirbusPlaygroundCSVDataset",
|
|
610
|
+
"format_airbus_empty_gt_filter_log",
|
|
611
|
+
]
|