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,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
+ ]