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
tools/train.py ADDED
@@ -0,0 +1,2324 @@
1
+ #!/usr/bin/env python3
2
+ """Standalone training script for oriented object detection.
3
+
4
+ This script loads training configuration from JSON files and supports multiple model types.
5
+ Supports both single-GPU and multi-GPU (distributed) training. For multi-GPU, use
6
+ tools/train_multi_gpu.py or torchrun; the script detects RANK/WORLD_SIZE/LOCAL_RANK.
7
+
8
+ Usage:
9
+ # Single-GPU
10
+ python tools/train.py --config configs/rotated_faster_rcnn/dota_le90_3x.json [--batch-size 4] [--use-amp]
11
+
12
+ # Multi-GPU (via launcher)
13
+ python tools/train_multi_gpu.py --config configs/.../config.json [--nproc-per-node 4]
14
+
15
+ Command-line options:
16
+ --config Path to config JSON file (required)
17
+ --batch-size Override batch size from config
18
+ --use-amp Enable automatic mixed precision training (overrides config)
19
+ --no-amp Disable automatic mixed precision training (overrides config)
20
+ --debug Enable debug tracing (RPN/ROI match statistics)
21
+ --wizard Run data/config diagnostics and print recommendations
22
+ --local-rank Set by torchrun for distributed training
23
+
24
+ Checkpoint loading is controlled only by the JSON ``checkpoint`` section (paths,
25
+ ``discover_previous_run``, ``resume_from_checkpoint_epoch``, etc.).
26
+
27
+ The script will:
28
+ - Load configuration from JSON file
29
+ - Load the DOTA dataset
30
+ - Discover classes automatically
31
+ - Configure class imbalance handling
32
+ - Train the model (single- or multi-GPU)
33
+ - Save checkpoints and TensorBoard logs (rank 0 only in multi-GPU)
34
+ """
35
+
36
+ import os
37
+ import sys
38
+
39
+ # Set CUDA memory allocation configuration before importing PyTorch
40
+ os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
41
+ # On macOS (MPS): enable CPU fallback for ops not implemented on Metal (e.g. grid_sampler_2d_backward).
42
+ # Must be set before importing torch so the MPS backend picks it up.
43
+ if sys.platform == "darwin":
44
+ os.environ.setdefault("PYTORCH_ENABLE_MPS_FALLBACK", "1")
45
+
46
+ import torch
47
+
48
+ # cuDNN can raise: RuntimeError: GET was unable to find an engine to execute this computation
49
+ # Common causes: conflicting LD_LIBRARY_PATH (system cuDNN vs wheels), or cudnn.benchmark
50
+ # picking a bad algorithm. Workarounds:
51
+ # ORIENTED_DET_CUDNN_BENCHMARK=0 — disable cudnn benchmark (often fixes; slightly slower convs)
52
+ # python tools/train.py ... --no-amp — rule out AMP/autocast interaction
53
+ # unset LD_LIBRARY_PATH — if an old CUDA/cuDNN path shadows PyTorch's bundled libs
54
+ _cudnn_bm = os.environ.get("ORIENTED_DET_CUDNN_BENCHMARK", "").lower()
55
+ if _cudnn_bm in ("0", "false", "no", "off"):
56
+ torch.backends.cudnn.benchmark = False
57
+
58
+ import torch.optim as optim
59
+ import torch.distributed as dist
60
+ from pathlib import Path
61
+ import csv as csv_module
62
+ from torch.utils.data import DataLoader, Dataset, Subset, WeightedRandomSampler
63
+ from torch.utils.data.distributed import DistributedSampler
64
+ # TensorBoard is optional at import time (tests import tools.train utilities).
65
+ try: # pragma: no cover
66
+ from torch.utils.tensorboard import SummaryWriter # type: ignore
67
+ except Exception: # pragma: no cover
68
+ SummaryWriter = None # type: ignore
69
+ from datetime import datetime
70
+ from collections import Counter
71
+ from typing import Optional, Dict, Any, List, Union, Set
72
+ import math
73
+ import numpy as np
74
+ import sys
75
+ import traceback
76
+ import argparse
77
+
78
+ from oriented_det import OrientedRCNN, RotatedFasterRCNN, RotatedRetinaNet
79
+ from oriented_det.data import (
80
+ DOTADataset,
81
+ AirbusPlaygroundCSVDataset,
82
+ format_airbus_empty_gt_filter_log,
83
+ build_dota_split_dataset,
84
+ dota_dataset_class_names,
85
+ format_dota_empty_gt_filter_log,
86
+ )
87
+ from oriented_det.train import train, CheckpointManager, WarmupScheduler, OneCycleWrapper, get_best_checkpoint_path
88
+ from oriented_det.train.utils import (
89
+ capped_subset_indices,
90
+ create_cosine_with_tail_lr_scheduler,
91
+ create_pytorch_cosine_lr_scheduler,
92
+ format_cosine_with_tail_scheduler_description,
93
+ format_pytorch_cosine_scheduler_description,
94
+ model_has_rpn_head,
95
+ set_backbone_requires_grad,
96
+ set_rpn_requires_grad,
97
+ )
98
+ from oriented_det.train.config import (
99
+ TrainingExperimentConfig,
100
+ LossConfig,
101
+ effective_eval_metric_thresholds,
102
+ config_use_exact_rotated_iou_for_map,
103
+ config_use_exact_rotated_iou_for_final_map,
104
+ )
105
+ from oriented_det.utils import enable_tracing
106
+
107
+
108
+ def _dataset_index_to_image_stem(dataset, idx: int) -> str:
109
+ """Resolve image stem for train dataset index (DOTA, Airbus, Subset, or wrappers)."""
110
+ from torch.utils.data import ConcatDataset
111
+
112
+ if isinstance(dataset, Subset):
113
+ return _dataset_index_to_image_stem(dataset.dataset, int(dataset.indices[idx]))
114
+ if isinstance(dataset, ConcatDataset):
115
+ if idx < 0:
116
+ raise IndexError(idx)
117
+ offset = 0
118
+ for sub in dataset.datasets:
119
+ sub_len = len(sub)
120
+ if idx < offset + sub_len:
121
+ return _dataset_index_to_image_stem(sub, idx - offset)
122
+ offset += sub_len
123
+ raise IndexError(idx)
124
+ if hasattr(dataset, "_annotation_files"):
125
+ return Path(dataset._annotation_files[idx]).stem
126
+ if hasattr(dataset, "_samples_meta"):
127
+ row = dataset._samples_meta[idx]
128
+ return Path(str(row.get("tile_relpath", ""))).stem
129
+ raise ValueError("dataset.tile_metrics_csv requires DOTA (annotation list) or Airbus Playground (_samples_meta)")
130
+
131
+
132
+ def _load_tile_metrics_by_stem(csv_path: Path, metric_col: str) -> Dict[str, float]:
133
+ out: Dict[str, float] = {}
134
+ with csv_path.open(newline="", encoding="utf-8") as f:
135
+ reader = csv_module.DictReader(f)
136
+ for row in reader:
137
+ key = row.get("image_id") or row.get("image_name") or ""
138
+ if not key:
139
+ continue
140
+ stem = Path(key).stem
141
+ raw = row.get(metric_col)
142
+ if raw is None or raw == "":
143
+ continue
144
+ try:
145
+ out[stem] = float(raw)
146
+ except ValueError:
147
+ continue
148
+ return out
149
+
150
+
151
+ def _stems_vacuous_true_negatives_from_tile_csv(csv_path: Path) -> Set[str]:
152
+ """Stems with tp=fp=fn=0 in tile metrics (no GT, no preds at eval threshold).
153
+
154
+ Legacy CSVs may still have f1=0 for these rows; they must not be treated as hard tiles.
155
+ """
156
+ stems: Set[str] = set()
157
+ with csv_path.open(newline="", encoding="utf-8") as f:
158
+ reader = csv_module.DictReader(f)
159
+ fields = reader.fieldnames or []
160
+ if not all(c in fields for c in ("tp", "fp", "fn")):
161
+ return stems
162
+ for row in reader:
163
+ try:
164
+ tp = int(row["tp"])
165
+ fp = int(row["fp"])
166
+ fn = int(row["fn"])
167
+ except (ValueError, KeyError):
168
+ continue
169
+ if tp == 0 and fp == 0 and fn == 0:
170
+ key = row.get("image_id") or row.get("image_name") or ""
171
+ if key:
172
+ stems.add(Path(key).stem)
173
+ return stems
174
+
175
+
176
+ class _HardTileExpandedDataset(Dataset):
177
+ """Repeat hard tile indices so DistributedSampler can oversample (DDP)."""
178
+
179
+ def __init__(self, base: Dataset, order: List[int]):
180
+ self.base = base
181
+ self.order = order
182
+
183
+ def __len__(self) -> int:
184
+ return len(self.order)
185
+
186
+ def __getitem__(self, idx: int):
187
+ return self.base[self.order[idx]]
188
+ from oriented_det.runtime.collate import create_collate_fn, create_train_augmentation, check_directories
189
+
190
+ # ============================================================================
191
+ # MAIN TRAINING SCRIPT
192
+ # ============================================================================
193
+
194
+
195
+ def analyze_class_distribution(dataset):
196
+ """Analyze class distribution in dataset and compute class weights."""
197
+ print("Analyzing class distribution in training set...")
198
+
199
+ class_counts = Counter()
200
+ total_objects = 0
201
+
202
+ for sample in dataset:
203
+ for ann in sample.annotations:
204
+ class_counts[ann.class_name] += 1
205
+ total_objects += 1
206
+
207
+ # Handle empty set (e.g. overfit on one image with no matching annotations)
208
+ if not class_counts:
209
+ return class_counts, {}, {}, []
210
+
211
+ # Sort by count (most frequent first)
212
+ sorted_classes = sorted(class_counts.items(), key=lambda x: x[1], reverse=True)
213
+
214
+ max_count = max(class_counts.values())
215
+ min_count = min(class_counts.values())
216
+
217
+ # Compute class weights
218
+ class_weights_inv_freq = {}
219
+ class_weights_sqrt = {}
220
+
221
+ for class_name, count in sorted_classes:
222
+ # Inverse frequency weighting (normalized)
223
+ weight_inv_freq = total_objects / (len(class_counts) * count)
224
+ class_weights_inv_freq[class_name] = weight_inv_freq
225
+ # Square root weighting (softer)
226
+ weight_sqrt = np.sqrt(max_count / count)
227
+ class_weights_sqrt[class_name] = weight_sqrt
228
+
229
+ return class_counts, class_weights_inv_freq, class_weights_sqrt, sorted_classes
230
+
231
+
232
+ def compute_class_weights(class_counts, class_weights_dict, method="sqrt", class_weight_overrides=None):
233
+ """Compute and normalize class weights.
234
+
235
+ Returns:
236
+ (final_weights, computed_weights): ``computed_weights`` are mean-normalized and clipped
237
+ before ``class_weight_overrides``; ``final_weights`` include overrides.
238
+
239
+ class_weight_overrides: optional dict mapping class name -> weight (e.g. {"truck": 0.25})
240
+ to explicitly down-weight dominant classes that the model over-predicts.
241
+ """
242
+ if method == "sqrt":
243
+ weights_dict = {k: np.sqrt(max(class_counts.values()) / v)
244
+ for k, v in class_counts.items()}
245
+ elif method == "inv_freq":
246
+ total = sum(class_counts.values())
247
+ weights_dict = {k: total / (len(class_counts) * v)
248
+ for k, v in class_counts.items()}
249
+ elif method == "effective_num":
250
+ # Class-Balanced Loss (Cui et al., 2019): w_c = (1-β)/(1-β^n_c)
251
+ # β close to 1.0 yields smoother weights than inv_freq.
252
+ beta = 0.9999
253
+ weights_dict = {k: (1.0 - beta) / max(1e-12, (1.0 - (beta ** float(v))))
254
+ for k, v in class_counts.items()}
255
+ elif method == "log_inv_freq":
256
+ total = float(sum(class_counts.values()))
257
+ weights_dict = {k: math.log((total / max(1.0, float(v))) + 1.0)
258
+ for k, v in class_counts.items()}
259
+ else:
260
+ raise ValueError(f"Unknown CLASS_WEIGHT_METHOD: {method}")
261
+
262
+ # Normalize weights to have mean=1.0
263
+ weight_values = list(weights_dict.values())
264
+ mean_weight = np.mean(weight_values)
265
+ weights_dict_normalized = {k: v / mean_weight for k, v in weights_dict.items()}
266
+
267
+ # Clip extreme weights to prevent training instability
268
+ MAX_WEIGHT = 3.0
269
+ MIN_WEIGHT = 0.3
270
+ weights_dict_normalized = {
271
+ k: np.clip(v, MIN_WEIGHT, MAX_WEIGHT)
272
+ for k, v in weights_dict_normalized.items()
273
+ }
274
+
275
+ computed_weights = {k: float(v) for k, v in weights_dict_normalized.items()}
276
+
277
+ # Apply per-class overrides (e.g. to further down-weight a dominant class like truck)
278
+ if class_weight_overrides:
279
+ for class_name, weight in class_weight_overrides.items():
280
+ if class_name in weights_dict_normalized:
281
+ weights_dict_normalized[class_name] = float(weight)
282
+ # background is set separately via loss.background_weight in train.py
283
+
284
+ return weights_dict_normalized, computed_weights
285
+
286
+
287
+ def _print_class_weight_table(
288
+ roi_class_weights: Dict[str, float],
289
+ computed_weights: Dict[str, float],
290
+ class_counts: Dict[str, int],
291
+ *,
292
+ method: str,
293
+ class_weight_overrides: Optional[Dict[str, float]] = None,
294
+ ) -> None:
295
+ """Log per-class ROI weights: computed (pre-override), optional override, and final."""
296
+ print(f"\nUsing {method} class weighting")
297
+ overrides = class_weight_overrides or {}
298
+ if overrides:
299
+ print(f" Config overrides (replace computed after clip): {overrides}")
300
+ print(f"\nClass weights (normalized, mean=1.0):")
301
+ print(f" {'Class Name':<24} {'computed':>10} {'override':>10} {'final':>10} {'count':>8}")
302
+ print(" " + "-" * 76)
303
+ foreground = [
304
+ (name, roi_class_weights[name])
305
+ for name in roi_class_weights
306
+ if name not in ("background", "__background__")
307
+ ]
308
+ for class_name, final_w in sorted(foreground, key=lambda x: x[1], reverse=True):
309
+ comp = computed_weights.get(class_name, final_w)
310
+ ov = overrides.get(class_name)
311
+ ov_str = f"{float(ov):>10.3f}" if ov is not None else f"{'—':>10}"
312
+ count = class_counts.get(class_name, 0)
313
+ print(f" {class_name:<24} {comp:>10.3f} {ov_str} {final_w:>10.3f} {count:>8,}")
314
+ for bg_key in ("background", "__background__"):
315
+ if bg_key in roi_class_weights:
316
+ final_w = roi_class_weights[bg_key]
317
+ print(f" {bg_key:<24} {'—':>10} {'—':>10} {final_w:>10.3f} {'n/a':>8}")
318
+ break
319
+
320
+
321
+ def _weights_dict_to_tensor(
322
+ weights_dict: Dict[str, float],
323
+ class_map: Dict[str, int],
324
+ num_classes: int,
325
+ *,
326
+ device: torch.device,
327
+ ) -> torch.Tensor:
328
+ """Convert weights dict (incl optional 'background') to tensor [num_classes+1]."""
329
+ w = torch.ones(num_classes + 1, dtype=torch.float32, device=device)
330
+ if "background" in weights_dict:
331
+ w[0] = float(weights_dict["background"])
332
+ for name, cid in class_map.items():
333
+ if 1 <= int(cid) <= num_classes and name in weights_dict:
334
+ w[int(cid)] = float(weights_dict[name])
335
+ return w
336
+
337
+
338
+ def _ramp(t: float, power: float) -> float:
339
+ t = float(np.clip(t, 0.0, 1.0))
340
+ p = max(1e-6, float(power))
341
+ return t ** p
342
+
343
+
344
+ def _round_ratio(value: float) -> float:
345
+ """Round anchor ratio to readable, stable values."""
346
+ if value <= 0:
347
+ return 1.0
348
+ if value >= 5:
349
+ return round(value, 1)
350
+ if value >= 2:
351
+ return round(value, 2)
352
+ return round(value, 3)
353
+
354
+
355
+ def gather_wizard_stats(dataset) -> Dict[str, Any]:
356
+ """Collect geometry and density statistics from training samples."""
357
+ per_image_objects: List[int] = []
358
+ widths: List[float] = []
359
+ heights: List[float] = []
360
+ aspects: List[float] = []
361
+ areas: List[float] = []
362
+ angles: List[float] = []
363
+
364
+ for sample in dataset:
365
+ num_objs = len(sample.annotations)
366
+ per_image_objects.append(num_objs)
367
+ for ann in sample.annotations:
368
+ w = max(float(ann.rbox.width), 1e-6)
369
+ h = max(float(ann.rbox.height), 1e-6)
370
+ widths.append(w)
371
+ heights.append(h)
372
+ aspects.append(max(w / h, h / w))
373
+ areas.append(w * h)
374
+ angle = float(ann.rbox.angle)
375
+ wrapped = ((angle + (math.pi / 2.0)) % math.pi) - (math.pi / 2.0)
376
+ angles.append(wrapped)
377
+
378
+ return {
379
+ "num_images": len(per_image_objects),
380
+ "num_objects": len(widths),
381
+ "per_image_objects": np.array(per_image_objects, dtype=np.float32),
382
+ "widths": np.array(widths, dtype=np.float32),
383
+ "heights": np.array(heights, dtype=np.float32),
384
+ "aspects": np.array(aspects, dtype=np.float32),
385
+ "areas": np.array(areas, dtype=np.float32),
386
+ "angles": np.array(angles, dtype=np.float32),
387
+ }
388
+
389
+
390
+ def print_wizard_recommendations(
391
+ stats: Dict[str, Any],
392
+ config: TrainingExperimentConfig,
393
+ *,
394
+ world_size: int,
395
+ ) -> None:
396
+ """Print data-driven config recommendations from training distribution."""
397
+ if stats["num_images"] == 0:
398
+ print("\n[Wizard] Skipped: no training images found.")
399
+ return
400
+
401
+ per_img = stats["per_image_objects"]
402
+ widths = stats["widths"]
403
+ heights = stats["heights"]
404
+ aspects = stats["aspects"]
405
+ areas = stats["areas"]
406
+ angles = stats["angles"]
407
+
408
+ empty_ratio = float((per_img == 0).mean()) if per_img.size > 0 else 0.0
409
+ mean_obj = float(per_img.mean()) if per_img.size > 0 else 0.0
410
+ p95_obj = float(np.percentile(per_img, 95)) if per_img.size > 0 else 0.0
411
+ p99_obj = float(np.percentile(per_img, 99)) if per_img.size > 0 else 0.0
412
+
413
+ print(f"\n{'='*80}")
414
+ print("Wizard: Training Data Diagnostics")
415
+ print(f"{'='*80}")
416
+ print(f"Images: {stats['num_images']:,}")
417
+ print(f"Objects: {stats['num_objects']:,}")
418
+ print(f"Objects/image: mean={mean_obj:.2f}, p95={p95_obj:.1f}, p99={p99_obj:.1f}, max={int(per_img.max()) if per_img.size > 0 else 0}")
419
+ print(f"Empty images: {empty_ratio:.2%}")
420
+
421
+ if widths.size > 0:
422
+ print(
423
+ "Box size (pixels): "
424
+ f"w p10/p50/p90={np.percentile(widths, 10):.1f}/{np.percentile(widths, 50):.1f}/{np.percentile(widths, 90):.1f}, "
425
+ f"h p10/p50/p90={np.percentile(heights, 10):.1f}/{np.percentile(heights, 50):.1f}/{np.percentile(heights, 90):.1f}"
426
+ )
427
+ print(
428
+ "Aspect ratio (max(w/h,h/w)): "
429
+ f"p50={np.percentile(aspects, 50):.2f}, p90={np.percentile(aspects, 90):.2f}, p99={np.percentile(aspects, 99):.2f}"
430
+ )
431
+ print(
432
+ "Area (pixels^2): "
433
+ f"p10/p50/p90={np.percentile(areas, 10):.1f}/{np.percentile(areas, 50):.1f}/{np.percentile(areas, 90):.1f}"
434
+ )
435
+ print(f"Angle spread (std, wrapped le90): {float(np.std(angles)):.3f} rad")
436
+
437
+ print(f"\n[Wizard] Recommendations:")
438
+
439
+ current_max_det = int(getattr(config.model, "max_detections_per_image", 100))
440
+ suggested_max_det = int(np.clip(math.ceil(max(20.0, p99_obj) * 3.0), 100, 3000))
441
+ if current_max_det < int(0.6 * suggested_max_det):
442
+ print(
443
+ f"- max_detections_per_image: {current_max_det} -> ~{suggested_max_det} "
444
+ f"(current is likely tight vs p99 objects/image={p99_obj:.1f})."
445
+ )
446
+ elif current_max_det > int(2.0 * suggested_max_det):
447
+ print(
448
+ f"- max_detections_per_image: {current_max_det} is high for this data; try ~{suggested_max_det} "
449
+ "(can reduce false positives and evaluation overhead)."
450
+ )
451
+ else:
452
+ print(f"- max_detections_per_image: {current_max_det} looks reasonable for this dataset.")
453
+
454
+ current_ratios = [float(r) for r in getattr(config.model, "anchor_ratios", [0.5, 1.0, 2.0]) if float(r) > 0]
455
+ current_aspects = [max(r, 1.0 / r) for r in current_ratios]
456
+ if aspects.size > 0:
457
+ q70 = float(np.percentile(aspects, 70))
458
+ q90 = float(np.percentile(aspects, 90))
459
+ q99 = float(np.percentile(aspects, 99))
460
+ suggested_ratios = sorted(
461
+ {
462
+ _round_ratio(1.0 / max(q90, 1.0)),
463
+ _round_ratio(1.0 / max(q70, 1.0)),
464
+ 1.0,
465
+ _round_ratio(max(q70, 1.0)),
466
+ _round_ratio(max(q90, 1.0)),
467
+ }
468
+ )
469
+ current_min_aspect = min(current_aspects) if current_aspects else 1.0
470
+ current_max_aspect = max(current_aspects) if current_aspects else 1.0
471
+ coverage = float(
472
+ ((aspects >= current_min_aspect) & (aspects <= current_max_aspect)).mean()
473
+ ) if aspects.size > 0 else 1.0
474
+ if coverage < 0.9 or current_max_aspect < 0.8 * q90:
475
+ print(
476
+ "- anchor_ratios: current="
477
+ f"{current_ratios}, suggested~{suggested_ratios} "
478
+ f"(current covers {coverage:.1%} of GT aspect spread; p90={q90:.2f}, p99={q99:.2f})."
479
+ )
480
+ else:
481
+ print(
482
+ f"- anchor_ratios: current {current_ratios} already covers GT aspect spread well "
483
+ f"(coverage {coverage:.1%})."
484
+ )
485
+
486
+ current_target_stds = list(getattr(config.model, "target_stds", (0.1, 0.1, 0.2, 0.2, 0.1)))
487
+ if widths.size > 0:
488
+ spread_w = float(np.std(np.log(np.clip(widths, 1e-6, None))))
489
+ spread_h = float(np.std(np.log(np.clip(heights, 1e-6, None))))
490
+ spread_wh = 0.5 * (spread_w + spread_h)
491
+ spread_angle = float(np.std(angles))
492
+ wh_std = float(np.clip(0.12 + 0.18 * spread_wh, 0.15, 0.4))
493
+ angle_std = float(np.clip(0.05 + 0.25 * (spread_angle / (math.pi / 2.0)), 0.05, 0.25))
494
+ suggested_target_stds = [0.1, 0.1, round(wh_std, 3), round(wh_std, 3), round(angle_std, 3)]
495
+ needs_update = (
496
+ len(current_target_stds) == 5 and (
497
+ abs(float(current_target_stds[2]) - suggested_target_stds[2]) > 0.08
498
+ or abs(float(current_target_stds[4]) - suggested_target_stds[4]) > 0.05
499
+ )
500
+ )
501
+ if needs_update:
502
+ print(
503
+ f"- target_stds: current={current_target_stds}, suggested~{suggested_target_stds} "
504
+ "(derived from GT scale/angle spread; useful when box-reg is unstable)."
505
+ )
506
+ else:
507
+ print(f"- target_stds: current {current_target_stds} looks close to data spread.")
508
+
509
+ batch_size = int(config.data_loader.batch_size)
510
+ grad_acc = max(1, int(config.training.gradient_accumulation_steps))
511
+ global_batch = max(1, batch_size * max(1, world_size))
512
+ batches_per_epoch = int(math.ceil(stats["num_images"] / global_batch))
513
+ opt_steps_per_epoch = int(math.ceil(batches_per_epoch / grad_acc))
514
+
515
+ lr_scaling = 1.0
516
+ if grad_acc > 1 and config.training.lr_scaling_with_accumulation != "none":
517
+ if config.training.lr_scaling_with_accumulation == "linear":
518
+ lr_scaling *= grad_acc
519
+ elif config.training.lr_scaling_with_accumulation == "sqrt":
520
+ lr_scaling *= math.sqrt(grad_acc)
521
+ if world_size > 1 and bool(getattr(config.training, "lr_scale_with_world_size", False)):
522
+ lr_scaling *= world_size
523
+ scaled_lr = float(config.training.learning_rate) * lr_scaling
524
+
525
+ warmup_base_epochs = 1.0 if scaled_lr >= 0.01 else 0.5
526
+ suggested_warmup = int(np.clip(round(warmup_base_epochs * opt_steps_per_epoch), 50, 2000))
527
+ current_warmup = int(getattr(config.training, "lr_warmup_steps", 0))
528
+ if current_warmup < int(0.5 * suggested_warmup):
529
+ print(
530
+ f"- lr_warmup_steps: {current_warmup} -> ~{suggested_warmup} "
531
+ f"(scaled LR≈{scaled_lr:.5f}, optimizer steps/epoch≈{opt_steps_per_epoch})."
532
+ )
533
+ elif current_warmup > int(2.0 * suggested_warmup):
534
+ print(
535
+ f"- lr_warmup_steps: {current_warmup} is long for this setup; try ~{suggested_warmup} "
536
+ f"(scaled LR≈{scaled_lr:.5f})."
537
+ )
538
+ else:
539
+ print(f"- lr_warmup_steps: {current_warmup} looks reasonable.")
540
+
541
+ if empty_ratio > 0.4:
542
+ print(
543
+ "- data quality: many empty tiles detected (>40%). Consider denser tiling/overlap or "
544
+ "increase positive sampling pressure."
545
+ )
546
+
547
+ print(f"{'='*80}\n")
548
+
549
+
550
+ def create_model_from_config(
551
+ config: TrainingExperimentConfig,
552
+ num_classes: int,
553
+ device: torch.device,
554
+ roi_class_weights: Optional[Union[Dict[str, float], torch.Tensor]] = None,
555
+ ):
556
+ """Create model based on config model_type."""
557
+ model_type = config.model_type.lower()
558
+
559
+ # Determine loss type and class weights from config
560
+ loss_config = config.loss
561
+ if loss_config.loss_type in ["cross_entropy", "class_weighted"]:
562
+ roi_loss_type = "cross_entropy"
563
+ elif loss_config.loss_type in ["focal", "focal_weighted"]:
564
+ roi_loss_type = "focal"
565
+ else:
566
+ roi_loss_type = config.model.roi_loss_type
567
+
568
+ # Use loss-section focal params when using focal/focal_weighted so config.loss.focal_alpha applies
569
+ if loss_config.loss_type in ["focal", "focal_weighted"]:
570
+ roi_focal_alpha = getattr(loss_config, "focal_alpha", config.model.roi_focal_alpha)
571
+ roi_focal_gamma = getattr(loss_config, "focal_gamma", config.model.roi_focal_gamma)
572
+ else:
573
+ roi_focal_alpha = config.model.roi_focal_alpha
574
+ roi_focal_gamma = config.model.roi_focal_gamma
575
+ roi_label_smoothing = getattr(config.loss, "label_smoothing", 0.0)
576
+
577
+ # Convert target_means/stds from list to tuple
578
+ target_means = tuple(config.model.target_means) if isinstance(config.model.target_means, list) else config.model.target_means
579
+ target_stds = tuple(config.model.target_stds) if isinstance(config.model.target_stds, list) else config.model.target_stds
580
+
581
+ use_hbb = getattr(config.model, "use_hbb_for_matching", False)
582
+ inference_pre_nms_score_threshold = getattr(config.model, "inference_pre_nms_score_threshold", 0.05)
583
+ fpn_returned_layers = getattr(config.model, "fpn_returned_layers", None)
584
+ fpn_strides = getattr(config.model, "fpn_strides", None)
585
+ # Resolve trainable_layers from frozen_stages if set (MMRotate: frozen_stages=1 -> train stages 2,3,4 -> trainable_layers=3)
586
+ frozen_stages = getattr(config.model, "frozen_stages", None)
587
+ if frozen_stages is not None:
588
+ trainable_layers = 5 if frozen_stages == 0 else max(1, 4 - frozen_stages)
589
+ else:
590
+ trainable_layers = config.model.trainable_layers
591
+ if model_type == "rotated_faster_rcnn":
592
+ model = RotatedFasterRCNN(
593
+ num_classes=num_classes,
594
+ backbone_name=config.model.backbone,
595
+ pretrained_backbone=config.model.pretrained_backbone,
596
+ trainable_layers=trainable_layers,
597
+ returned_layers=fpn_returned_layers,
598
+ fpn_strides=fpn_strides,
599
+ anchor_scales=config.model.anchor_scales,
600
+ anchor_ratios=config.model.anchor_ratios,
601
+ roi_loss_type=roi_loss_type,
602
+ roi_class_weights=roi_class_weights,
603
+ roi_focal_alpha=roi_focal_alpha,
604
+ roi_focal_gamma=roi_focal_gamma,
605
+ roi_label_smoothing=roi_label_smoothing,
606
+ target_means=target_means,
607
+ target_stds=target_stds,
608
+ roi_norm_factor=config.model.roi_norm_factor,
609
+ roi_edge_swap=config.model.roi_edge_swap,
610
+ roi_box_reg_angle_weight=getattr(config.model, "roi_box_reg_angle_weight", 1.0),
611
+ roi_box_reg_angle_schedule_epochs=getattr(
612
+ config.model, "roi_box_reg_angle_schedule_epochs", None
613
+ ),
614
+ roi_box_reg_angle_schedule_values=getattr(
615
+ config.model, "roi_box_reg_angle_schedule_values", None
616
+ ),
617
+ roi_box_reg_iou_weight=getattr(config.model, "roi_box_reg_iou_weight", 0.0),
618
+ roi_box_reg_iou_loss_type=getattr(config.model, "roi_box_reg_iou_loss_type", "riou"),
619
+ roi_box_reg_kfiou_fun=getattr(config.model, "roi_box_reg_kfiou_fun", None),
620
+ roi_box_reg_probiou_mode=getattr(config.model, "roi_box_reg_probiou_mode", None),
621
+ use_hbb_for_matching=use_hbb,
622
+ inference_pre_nms_score_threshold=inference_pre_nms_score_threshold,
623
+ rpn_min_size=getattr(config.model, "rpn_min_size", 0.0),
624
+ rpn_pre_nms_top_n=getattr(config.model, "rpn_pre_nms_top_n", 2000),
625
+ rpn_post_nms_top_n=getattr(config.model, "rpn_post_nms_top_n", 2000),
626
+ max_detections_per_image=getattr(config.model, "max_detections_per_image", 2000),
627
+ rpn_nms_threshold=getattr(config.model, "rpn_nms_threshold", 0.7),
628
+ final_nms_iou_threshold=config.model.final_nms_iou_threshold,
629
+ nms_class_agnostic=getattr(config.model, "nms_class_agnostic", False),
630
+ final_nms_use_cpu=getattr(config.model, "final_nms_use_cpu", False),
631
+ roi_batch_size_per_image=getattr(config.model, "roi_batch_size_per_image", 512),
632
+ rpn_batch_size_per_image=getattr(config.model, "rpn_batch_size_per_image", 256),
633
+ rpn_min_pos_iou=getattr(config.model, "rpn_min_pos_iou", 0.3),
634
+ rpn_match_low_quality=getattr(config.model, "rpn_match_low_quality", True),
635
+ roi_match_low_quality=getattr(config.model, "roi_match_low_quality", False),
636
+ roi_min_pos_iou=getattr(config.model, "roi_min_pos_iou", 0.5),
637
+ add_gt_as_proposals=getattr(config.model, "add_gt_as_proposals", True),
638
+ rpn_positive_iou_threshold=getattr(config.model, "rpn_positive_iou_threshold", 0.7),
639
+ rpn_negative_iou_threshold=getattr(config.model, "rpn_negative_iou_threshold", 0.3),
640
+ roi_positive_iou_threshold=getattr(config.model, "roi_positive_iou_threshold", 0.5),
641
+ roi_negative_iou_threshold=getattr(config.model, "roi_negative_iou_threshold", 0.5),
642
+ final_nms_iou_schedule_epochs=config.model.final_nms_iou_schedule_epochs,
643
+ final_nms_iou_schedule_values=config.model.final_nms_iou_schedule_values,
644
+ roi_box_reg_iou_schedule_epochs=config.model.roi_box_reg_iou_schedule_epochs,
645
+ roi_box_reg_iou_schedule_values=config.model.roi_box_reg_iou_schedule_values,
646
+ roi_inference_top_class_only=getattr(
647
+ config.model, "roi_inference_top_class_only", False
648
+ ),
649
+ )
650
+ elif model_type == "oriented_rcnn":
651
+ model = OrientedRCNN(
652
+ num_classes=num_classes,
653
+ backbone_name=config.model.backbone,
654
+ pretrained_backbone=config.model.pretrained_backbone,
655
+ trainable_layers=trainable_layers,
656
+ anchor_scales=config.model.anchor_scales,
657
+ returned_layers=fpn_returned_layers,
658
+ fpn_strides=fpn_strides,
659
+ anchor_ratios=config.model.anchor_ratios,
660
+ roi_loss_type=roi_loss_type,
661
+ roi_class_weights=roi_class_weights,
662
+ roi_focal_alpha=roi_focal_alpha,
663
+ roi_focal_gamma=roi_focal_gamma,
664
+ roi_label_smoothing=roi_label_smoothing,
665
+ target_means=target_means,
666
+ target_stds=target_stds,
667
+ roi_norm_factor=config.model.roi_norm_factor,
668
+ roi_edge_swap=config.model.roi_edge_swap,
669
+ roi_proj_xy=getattr(config.model, "roi_proj_xy", False),
670
+ roi_box_reg_angle_weight=getattr(config.model, "roi_box_reg_angle_weight", 1.0),
671
+ roi_box_reg_angle_schedule_epochs=getattr(
672
+ config.model, "roi_box_reg_angle_schedule_epochs", None
673
+ ),
674
+ roi_box_reg_angle_schedule_values=getattr(
675
+ config.model, "roi_box_reg_angle_schedule_values", None
676
+ ),
677
+ roi_box_reg_iou_weight=getattr(config.model, "roi_box_reg_iou_weight", 0.0),
678
+ roi_box_reg_iou_loss_type=getattr(config.model, "roi_box_reg_iou_loss_type", "riou"),
679
+ roi_box_reg_kfiou_fun=getattr(config.model, "roi_box_reg_kfiou_fun", None),
680
+ roi_box_reg_probiou_mode=getattr(config.model, "roi_box_reg_probiou_mode", None),
681
+ use_hbb_for_matching=use_hbb,
682
+ inference_pre_nms_score_threshold=inference_pre_nms_score_threshold,
683
+ rpn_pre_nms_top_n=getattr(config.model, "rpn_pre_nms_top_n", 2000),
684
+ rpn_post_nms_top_n=getattr(config.model, "rpn_post_nms_top_n", 1000),
685
+ max_detections_per_image=getattr(config.model, "max_detections_per_image", 100),
686
+ rpn_nms_threshold=getattr(config.model, "rpn_nms_threshold", 0.7),
687
+ final_nms_iou_threshold=config.model.final_nms_iou_threshold,
688
+ nms_class_agnostic=getattr(config.model, "nms_class_agnostic", False),
689
+ final_nms_use_cpu=getattr(config.model, "final_nms_use_cpu", False),
690
+ roi_batch_size_per_image=getattr(config.model, "roi_batch_size_per_image", 512),
691
+ rpn_batch_size_per_image=getattr(config.model, "rpn_batch_size_per_image", 256),
692
+ rpn_min_pos_iou=getattr(config.model, "rpn_min_pos_iou", 0.3),
693
+ rpn_match_low_quality=getattr(config.model, "rpn_match_low_quality", True),
694
+ roi_match_low_quality=getattr(config.model, "roi_match_low_quality", False),
695
+ rpn_positive_iou_threshold=getattr(config.model, "rpn_positive_iou_threshold", 0.5),
696
+ rpn_negative_iou_threshold=getattr(config.model, "rpn_negative_iou_threshold", 0.2),
697
+ roi_positive_iou_threshold=getattr(config.model, "roi_positive_iou_threshold", 0.4),
698
+ roi_negative_iou_threshold=getattr(config.model, "roi_negative_iou_threshold", 0.3),
699
+ final_nms_iou_schedule_epochs=config.model.final_nms_iou_schedule_epochs,
700
+ final_nms_iou_schedule_values=config.model.final_nms_iou_schedule_values,
701
+ roi_box_reg_iou_schedule_epochs=config.model.roi_box_reg_iou_schedule_epochs,
702
+ roi_box_reg_iou_schedule_values=config.model.roi_box_reg_iou_schedule_values,
703
+ add_gt_as_proposals=getattr(config.model, "add_gt_as_proposals", True),
704
+ roi_inference_top_class_only=getattr(
705
+ config.model, "roi_inference_top_class_only", False
706
+ ),
707
+ )
708
+ elif model_type == "rotated_retinanet":
709
+ model = RotatedRetinaNet(
710
+ num_classes=num_classes,
711
+ backbone_name=config.model.backbone,
712
+ pretrained_backbone=config.model.pretrained_backbone,
713
+ trainable_layers=trainable_layers,
714
+ returned_layers=fpn_returned_layers,
715
+ fpn_strides=fpn_strides,
716
+ fpn_extra_level=getattr(config.model, "fpn_extra_level", False),
717
+ anchor_scales=config.model.anchor_scales,
718
+ anchor_ratios=config.model.anchor_ratios,
719
+ octave_base_scale=getattr(config.model, "anchor_octave_base_scale", None),
720
+ scales_per_octave=getattr(config.model, "anchor_scales_per_octave", None),
721
+ stacked_convs=getattr(config.model, "retinanet_stacked_convs", 1),
722
+ positive_iou_threshold=getattr(config.model, "rpn_positive_iou_threshold", 0.5),
723
+ negative_iou_threshold=getattr(config.model, "rpn_negative_iou_threshold", 0.4),
724
+ focal_alpha=roi_focal_alpha,
725
+ focal_gamma=roi_focal_gamma,
726
+ target_means=target_means,
727
+ target_stds=target_stds,
728
+ norm_factor=config.model.roi_norm_factor,
729
+ edge_swap=config.model.roi_edge_swap,
730
+ box_reg_weight=getattr(config.model, "box_reg_weight", 1.0),
731
+ box_reg_loss_type=getattr(config.model, "box_reg_loss_type", "smooth_l1"),
732
+ box_reg_iou_weight=getattr(config.model, "roi_box_reg_iou_weight", 0.0),
733
+ box_reg_iou_loss_type=getattr(config.model, "roi_box_reg_iou_loss_type", "riou"),
734
+ box_reg_kfiou_fun=getattr(config.model, "roi_box_reg_kfiou_fun", None),
735
+ box_reg_probiou_mode=getattr(config.model, "roi_box_reg_probiou_mode", None),
736
+ use_hbb_for_matching=config.model.use_hbb_for_matching,
737
+ score_threshold=inference_pre_nms_score_threshold,
738
+ final_nms_iou_threshold=config.model.final_nms_iou_threshold,
739
+ max_detections_per_image=getattr(config.model, "max_detections_per_image", 100),
740
+ final_nms_iou_schedule_epochs=config.model.final_nms_iou_schedule_epochs,
741
+ final_nms_iou_schedule_values=config.model.final_nms_iou_schedule_values,
742
+ roi_box_reg_iou_schedule_epochs=config.model.roi_box_reg_iou_schedule_epochs,
743
+ roi_box_reg_iou_schedule_values=config.model.roi_box_reg_iou_schedule_values,
744
+ final_nms_use_cpu=getattr(config.model, "final_nms_use_cpu", False),
745
+ )
746
+ else:
747
+ raise ValueError(f"Unknown model_type: {model_type}. Supported: rotated_faster_rcnn, oriented_rcnn, rotated_retinanet")
748
+
749
+ model.to(device)
750
+ return model, roi_loss_type
751
+
752
+
753
+ def _strip_module_prefix(param_name: str) -> str:
754
+ """Normalize DDP/DataParallel parameter names for prefix matching."""
755
+ return param_name[7:] if param_name.startswith("module.") else param_name
756
+
757
+
758
+ def build_optimizer_param_groups(
759
+ model: torch.nn.Module,
760
+ base_lr: float,
761
+ weight_decay: float,
762
+ config: TrainingExperimentConfig,
763
+ *,
764
+ include_frozen_parameters: bool = False,
765
+ ) -> tuple[List[Dict[str, Any]], Dict[str, Dict[str, float]]]:
766
+ """Build optimizer param groups with per-module LR multipliers.
767
+
768
+ - ``backbone.*`` → ``lr_mult_backbone``
769
+ - ``head.*`` (e.g. RetinaNet) → ``lr_mult_head`` if set, else ``lr_mult_other``
770
+ - ``rpn_head.*`` / ``roi_head.*`` → if ``lr_mult_head`` is set, both use that multiplier;
771
+ otherwise ``lr_mult_rpn`` / ``lr_mult_roi``
772
+ - any other trainable tensor → ``lr_mult_other``
773
+
774
+ When ``include_frozen_parameters`` is True, parameters with ``requires_grad=False`` are still
775
+ added to their groups so the optimizer state stays aligned when unfreezing later; SGD skips
776
+ parameters with no gradient.
777
+ """
778
+ multipliers = {
779
+ "backbone": float(getattr(config.training, "lr_mult_backbone", 0.5)),
780
+ "rpn": float(getattr(config.training, "lr_mult_rpn", 0.5)),
781
+ "roi": float(getattr(config.training, "lr_mult_roi", 2.0)),
782
+ "other": float(getattr(config.training, "lr_mult_other", 1.0)),
783
+ }
784
+ lr_mult_head = getattr(config.training, "lr_mult_head", None)
785
+
786
+ grouped_params: Dict[str, List[torch.nn.Parameter]] = {
787
+ "backbone": [],
788
+ "rpn": [],
789
+ "roi": [],
790
+ "head": [],
791
+ "other": [],
792
+ }
793
+ grouped_counts: Dict[str, int] = {k: 0 for k in grouped_params}
794
+
795
+ for name, param in model.named_parameters():
796
+ if not include_frozen_parameters and not param.requires_grad:
797
+ continue
798
+ clean_name = _strip_module_prefix(name)
799
+ if clean_name.startswith("backbone."):
800
+ group_name = "backbone"
801
+ elif clean_name.startswith("head."):
802
+ group_name = "head"
803
+ elif clean_name.startswith("rpn_head."):
804
+ group_name = "rpn"
805
+ elif clean_name.startswith("roi_head."):
806
+ group_name = "roi"
807
+ else:
808
+ group_name = "other"
809
+ grouped_params[group_name].append(param)
810
+ grouped_counts[group_name] += param.numel()
811
+
812
+ def _effective_multiplier(group_name: str) -> float:
813
+ if group_name == "backbone":
814
+ return multipliers["backbone"]
815
+ if lr_mult_head is not None:
816
+ if group_name in ("head", "rpn", "roi"):
817
+ return float(lr_mult_head)
818
+ return multipliers["other"]
819
+ if group_name == "head":
820
+ return multipliers["other"]
821
+ return multipliers[group_name]
822
+
823
+ param_groups: List[Dict[str, Any]] = []
824
+ group_summary: Dict[str, Dict[str, float]] = {}
825
+ for group_name in ["backbone", "rpn", "roi", "head", "other"]:
826
+ params = grouped_params[group_name]
827
+ if not params:
828
+ continue
829
+ eff_mult = _effective_multiplier(group_name)
830
+ group_lr = base_lr * eff_mult
831
+ param_groups.append(
832
+ {
833
+ "params": params,
834
+ "lr": group_lr,
835
+ "weight_decay": weight_decay,
836
+ "group_name": group_name,
837
+ # Used by the training engine to recover config-scale LR for TensorBoard (group 0 is often backbone).
838
+ "lr_multiplier": float(eff_mult),
839
+ }
840
+ )
841
+ group_summary[group_name] = {
842
+ "lr": float(group_lr),
843
+ "multiplier": float(eff_mult),
844
+ "num_params": float(grouped_counts[group_name]),
845
+ }
846
+
847
+ return param_groups, group_summary
848
+
849
+
850
+ def main():
851
+ """Main training function."""
852
+ # Parse command-line arguments
853
+ parser = argparse.ArgumentParser(
854
+ description="Train oriented object detection models on DOTA dataset",
855
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter
856
+ )
857
+ parser.add_argument(
858
+ "--config",
859
+ type=str,
860
+ required=True,
861
+ help="Path to config JSON file"
862
+ )
863
+ parser.add_argument(
864
+ "--batch-size",
865
+ type=int,
866
+ default=None,
867
+ help="Override batch size from config"
868
+ )
869
+ parser.add_argument(
870
+ "--use-amp",
871
+ action="store_true",
872
+ default=None,
873
+ help="Enable automatic mixed precision training (overrides config)"
874
+ )
875
+ parser.add_argument(
876
+ "--no-amp",
877
+ action="store_true",
878
+ help="Disable automatic mixed precision training (overrides config)"
879
+ )
880
+ parser.add_argument(
881
+ "--local-rank",
882
+ type=int,
883
+ default=None,
884
+ help="Local rank for distributed training (set by torchrun)"
885
+ )
886
+ parser.add_argument(
887
+ "--debug",
888
+ action="store_true",
889
+ help="Enable debug logs: config summary, loss breakdown, RPN/ROI stats to TensorBoard, "
890
+ "per-class mAP/det/GT counts, GT cover rates, first-batch GT stats. Use to diagnose low mAP vs MMRotate."
891
+ )
892
+ parser.add_argument(
893
+ "--wizard",
894
+ action="store_true",
895
+ help="Run data/config diagnostics and print recommendations before training"
896
+ )
897
+ args = parser.parse_args()
898
+
899
+ # Detect distributed training (torchrun sets RANK, WORLD_SIZE, LOCAL_RANK)
900
+ is_distributed = "RANK" in os.environ and "WORLD_SIZE" in os.environ
901
+ rank = int(os.environ.get("RANK", 0))
902
+ world_size = int(os.environ.get("WORLD_SIZE", 1))
903
+ local_rank = int(os.environ.get("LOCAL_RANK", args.local_rank or 0))
904
+ if is_distributed:
905
+ torch.cuda.set_device(local_rank)
906
+ device = torch.device(f"cuda:{local_rank}")
907
+ backend = os.environ.get("DIST_BACKEND", "auto")
908
+ if backend == "auto":
909
+ backend = "nccl" if dist.is_backend_available("nccl") else "gloo"
910
+ if backend == "gloo":
911
+ master_addr = os.environ.get("MASTER_ADDR", "127.0.0.1")
912
+ master_port = os.environ.get("MASTER_PORT", "29500")
913
+ init_method = f"tcp://{master_addr}:{master_port}"
914
+ else:
915
+ init_method = "env://"
916
+ if not dist.is_initialized():
917
+ from datetime import timedelta
918
+ # Long runs: periodic full-val mAP on rank 0 can exceed 30 minutes while other
919
+ # ranks wait on Gloo barriers; use a generous default (override via TORCH_DIST_TIMEOUT).
920
+ _dist_timeout_s = int(
921
+ os.environ.get("TORCH_DIST_TIMEOUT_SECONDS", str(24 * 3600))
922
+ )
923
+ dist.init_process_group(
924
+ backend=backend,
925
+ init_method=init_method,
926
+ rank=rank,
927
+ world_size=world_size,
928
+ timeout=timedelta(seconds=max(60, _dist_timeout_s)),
929
+ )
930
+ else:
931
+ from oriented_det.utils import get_device
932
+ device = get_device()
933
+
934
+ # Load config from file (supports vendored configs for PyPI installs)
935
+ from oriented_det.utils.config import _framework_config_roots # local import to keep CLI fast
936
+
937
+ raw = str(args.config)
938
+ config_path = Path(raw)
939
+ if not config_path.exists():
940
+ # Common UX: allow `--config configs/...` after `pip install oriented-det`
941
+ rel = raw.replace("\\", "/").lstrip("/")
942
+ if rel.startswith("configs/"):
943
+ rel = rel[len("configs/") :]
944
+ for fw_root in _framework_config_roots():
945
+ candidate = (fw_root / rel).resolve()
946
+ if candidate.exists():
947
+ config_path = candidate
948
+ break
949
+ if not config_path.exists():
950
+ raise FileNotFoundError(
951
+ f"Config file not found: {raw}. "
952
+ "Tip: pass a path to a JSON config, or use a built-in path like "
953
+ "'configs/rotated_faster_rcnn/dota_le90_3x.json'."
954
+ )
955
+
956
+ config = TrainingExperimentConfig.load(config_path)
957
+
958
+ # Apply command-line overrides
959
+ if args.batch_size is not None:
960
+ config.data_loader.batch_size = args.batch_size
961
+ if rank == 0:
962
+ print(f"Overriding batch size to: {args.batch_size}")
963
+
964
+ if args.no_amp:
965
+ config.training.use_amp = False
966
+ if rank == 0:
967
+ print("Overriding AMP: disabled")
968
+ elif args.use_amp:
969
+ config.training.use_amp = True
970
+ if rank == 0:
971
+ print("Overriding AMP: enabled")
972
+
973
+ # Set experiment timestamp if not set
974
+ if config.experiment_timestamp is None:
975
+ config.experiment_timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
976
+
977
+ # Create experiment directory and tee stdout/stderr to train.log (rank 0 only; multi-GPU safe)
978
+ # In wizard mode we intentionally skip run-folder/log creation.
979
+ from oriented_det.train.utils import get_project_root
980
+
981
+ project_root = get_project_root()
982
+ try:
983
+ config.source_recipe = str(config_path.resolve().relative_to(project_root))
984
+ except ValueError:
985
+ config.source_recipe = str(config_path.resolve())
986
+ EXPERIMENT_DIR = project_root / "runs" / config.model_type / config.experiment_timestamp
987
+ _log_file = None
988
+ _orig_stdout = _orig_stderr = None
989
+ if not args.wizard and rank == 0:
990
+ EXPERIMENT_DIR.mkdir(parents=True, exist_ok=True)
991
+ _log_path = EXPERIMENT_DIR / "train.log"
992
+ _log_file = open(_log_path, "w", encoding="utf-8")
993
+
994
+ class _Tee:
995
+ def __init__(self, stream, file):
996
+ self._stream = stream
997
+ self._file = file
998
+ def write(self, s):
999
+ self._stream.write(s)
1000
+ self._file.write(s)
1001
+ self._stream.flush()
1002
+ self._file.flush()
1003
+ def flush(self):
1004
+ self._stream.flush()
1005
+ self._file.flush()
1006
+ def writable(self):
1007
+ return True
1008
+
1009
+ _orig_stdout = sys.stdout
1010
+ _orig_stderr = sys.stderr
1011
+ sys.stdout = _Tee(_orig_stdout, _log_file)
1012
+ sys.stderr = _Tee(_orig_stderr, _log_file)
1013
+ print(f"Loading configuration from: {config_path}")
1014
+ print(f"Training log file: {_log_path}")
1015
+ elif rank == 0:
1016
+ print(f"Loading configuration from: {config_path}")
1017
+
1018
+ use_amp = config.training.use_amp
1019
+ # MPS backend can fail on float16 in backward (ScalarType::Float/Int/Bool expected).
1020
+ # Disable AMP when using MPS unless the user explicitly requested it and we support it later.
1021
+ if use_amp and (not is_distributed) and device.type == "mps":
1022
+ use_amp = False
1023
+ config.training.use_amp = False
1024
+ if rank == 0:
1025
+ print("Note: AMP disabled on MPS (Apple Silicon) to avoid backward pass dtype errors.")
1026
+
1027
+ if rank == 0:
1028
+ print("=" * 80)
1029
+ print(f"{config.model_type.upper()} Training")
1030
+ print("=" * 80)
1031
+ print(f"PyTorch Version: {torch.__version__}")
1032
+ print(f"CUDA Available: {torch.cuda.is_available()}")
1033
+ mps_available = getattr(torch.backends, "mps", None) and torch.backends.mps.is_available()
1034
+ print(f"MPS (Apple Silicon) Available: {mps_available}")
1035
+ if torch.cuda.is_available():
1036
+ if is_distributed:
1037
+ print(f"Distributed: {world_size} GPUs")
1038
+ else:
1039
+ print(f"CUDA Device: {torch.cuda.get_device_name(0)}")
1040
+ elif mps_available:
1041
+ print("Using MPS (Metal) for GPU acceleration")
1042
+ print(f"Mixed Precision (AMP): {use_amp}")
1043
+ print()
1044
+
1045
+ dataset_format = getattr(config.dataset, "format", "dota").lower()
1046
+
1047
+ if rank == 0 and dataset_format == "dota":
1048
+ print("Checking dataset directories...")
1049
+ check_directories(
1050
+ config.dataset.get_train_tile_roots(),
1051
+ config.dataset.get_val_tile_roots(),
1052
+ )
1053
+ print()
1054
+
1055
+ # Create datasets
1056
+ print("Loading datasets...")
1057
+ if dataset_format == "airbus_playground":
1058
+ if config.dataset.annotations_file is None or config.dataset.split_file is None:
1059
+ raise ValueError(
1060
+ "Airbus Playground dataset format requires dataset.annotations_file "
1061
+ "and dataset.split_file."
1062
+ )
1063
+
1064
+ airbus_filter_empty_gt = getattr(config.dataset, "filter_empty_gt", False)
1065
+ train_dataset = AirbusPlaygroundCSVDataset(
1066
+ data_root=config.dataset.data_root,
1067
+ split="train",
1068
+ annotations_file=config.dataset.annotations_file,
1069
+ split_file=config.dataset.split_file,
1070
+ val_split_id=config.dataset.val_split_id,
1071
+ difficult_strategy=config.dataset.difficult_strategy,
1072
+ allowed_classes=config.dataset.allowed_classes,
1073
+ ignore_labels=config.dataset.ignore_labels,
1074
+ map_labels=config.dataset.map_labels,
1075
+ filter_empty_gt=airbus_filter_empty_gt,
1076
+ )
1077
+ val_dataset = AirbusPlaygroundCSVDataset(
1078
+ data_root=config.dataset.data_root,
1079
+ split="val",
1080
+ annotations_file=config.dataset.annotations_file,
1081
+ split_file=config.dataset.split_file,
1082
+ val_split_id=config.dataset.val_split_id,
1083
+ difficult_strategy=config.dataset.difficult_strategy,
1084
+ allowed_classes=config.dataset.allowed_classes,
1085
+ ignore_labels=config.dataset.ignore_labels,
1086
+ map_labels=config.dataset.map_labels,
1087
+ filter_empty_gt=False,
1088
+ )
1089
+ if rank == 0:
1090
+ map_labels = config.dataset.map_labels or {}
1091
+ ignore_labels = set(config.dataset.ignore_labels or [])
1092
+ raw_in_csv = train_dataset.get_raw_class_names_from_csv()
1093
+ ann_path = getattr(train_dataset, "annotations_path", None)
1094
+ print(
1095
+ f"\nUsing Airbus Playground CSV dataset: {config.dataset.annotations_file}, "
1096
+ f"{config.dataset.split_file} (val_split_id={config.dataset.val_split_id})"
1097
+ )
1098
+ if ann_path is not None:
1099
+ print(f" annotations path: {ann_path}")
1100
+ print(
1101
+ " (val_split_id is the split.csv fold id used as validation when the split column is numeric.)"
1102
+ )
1103
+ print(f" map_labels: {len(map_labels)} key(s). Raw labels in CSV (train): {len(raw_in_csv)} distinct — {raw_in_csv[:15]}{'...' if len(raw_in_csv) > 15 else ''}")
1104
+ if ignore_labels:
1105
+ print(f" ignore_labels: {len(ignore_labels)} value(s) — entries matching these labels are dropped.")
1106
+ if len(raw_in_csv) <= 1:
1107
+ print(f" ⚠ Only one raw label in CSV: check that training reads the regenerated file (config uses annotations_file: annotations.csv; not annotations.cvs).")
1108
+ if airbus_filter_empty_gt:
1109
+ print("Airbus filter_empty_gt (train split only; val keeps all tiles):")
1110
+ print(format_airbus_empty_gt_filter_log(train_dataset, split="train"))
1111
+ else:
1112
+ if not config.dataset.has_dota_tiles_config():
1113
+ raise ValueError(
1114
+ "DOTA dataset format requires dataset.train_tiles_dir or "
1115
+ "dataset.train_tiles_dirs, and dataset.val_tiles_dir or dataset.val_tiles_dirs."
1116
+ )
1117
+ same_folder = getattr(config.dataset, "same_folder", False)
1118
+
1119
+ train_dataset = build_dota_split_dataset(
1120
+ config.dataset.get_train_tile_roots(),
1121
+ split="train",
1122
+ same_folder=same_folder,
1123
+ difficult_strategy=config.dataset.difficult_strategy,
1124
+ allowed_classes=config.dataset.allowed_classes,
1125
+ ignore_labels=config.dataset.ignore_labels,
1126
+ filter_empty_gt=getattr(config.dataset, "filter_empty_gt", False),
1127
+ )
1128
+
1129
+ val_dataset = build_dota_split_dataset(
1130
+ config.dataset.get_val_tile_roots(),
1131
+ split="val",
1132
+ same_folder=same_folder,
1133
+ difficult_strategy=config.dataset.difficult_strategy,
1134
+ allowed_classes=config.dataset.allowed_classes,
1135
+ ignore_labels=config.dataset.ignore_labels,
1136
+ filter_empty_gt=getattr(config.dataset, "filter_empty_gt", False),
1137
+ )
1138
+
1139
+ if rank == 0 and getattr(config.dataset, "filter_empty_gt", False):
1140
+ print("DOTA filter_empty_gt (MMRotate-style):")
1141
+ print(format_dota_empty_gt_filter_log(train_dataset, split="train"))
1142
+ print(format_dota_empty_gt_filter_log(val_dataset, split="val"))
1143
+
1144
+ # Class list from full train split before max_* caps: a limited subset must not shrink num_classes.
1145
+ if dataset_format != "airbus_playground":
1146
+ class_names = dota_dataset_class_names(train_dataset)
1147
+ else:
1148
+ class_names = train_dataset.get_class_names()
1149
+
1150
+ shuffle_seed = getattr(config.dataset, "max_samples_shuffle_seed", None)
1151
+ # Optionally limit to first N samples (e.g. for overfit sanity check)
1152
+ if getattr(config.dataset, "max_train_samples", None) is not None:
1153
+ idx_train = capped_subset_indices(
1154
+ len(train_dataset),
1155
+ int(config.dataset.max_train_samples),
1156
+ shuffle_seed=shuffle_seed,
1157
+ )
1158
+ train_dataset = Subset(train_dataset, idx_train)
1159
+ if rank == 0:
1160
+ _how = (
1161
+ f"deterministic shuffle (max_samples_shuffle_seed={shuffle_seed})"
1162
+ if shuffle_seed is not None
1163
+ else "first-N dataset order"
1164
+ )
1165
+ print(f"Limited training set to {len(idx_train)} sample(s) — {_how}")
1166
+ if getattr(config.dataset, "max_val_samples", None) is not None:
1167
+ idx_val = capped_subset_indices(
1168
+ len(val_dataset),
1169
+ int(config.dataset.max_val_samples),
1170
+ shuffle_seed=shuffle_seed,
1171
+ )
1172
+ val_dataset = Subset(val_dataset, idx_val)
1173
+ if rank == 0:
1174
+ _how = (
1175
+ f"deterministic shuffle (max_samples_shuffle_seed={shuffle_seed})"
1176
+ if shuffle_seed is not None
1177
+ else "first-N dataset order"
1178
+ )
1179
+ print(f"Limited validation set to {len(idx_val)} sample(s) — {_how}\n")
1180
+
1181
+ # Report discovered classes (from full train before caps)
1182
+ if rank == 0:
1183
+ print(f"\nFound {len(class_names)} classes:")
1184
+ for i, cls_name in enumerate(class_names):
1185
+ print(f" {i}: {cls_name}")
1186
+
1187
+ # Create class mapping (class_name -> class_id)
1188
+ class_map = {cls_name: i + 1 for i, cls_name in enumerate(class_names)}
1189
+ num_foreground_classes = len(class_names) # model and config: foreground only (cls_head has +1 for background)
1190
+
1191
+ if rank == 0:
1192
+ print(f"\nNumber of classes (foreground): {num_foreground_classes}")
1193
+ print(f"Class mapping: {class_map}\n")
1194
+
1195
+ weighted_train_sampler: Optional[WeightedRandomSampler] = None
1196
+ tile_csv = getattr(config.dataset, "tile_metrics_csv", None)
1197
+ if tile_csv:
1198
+ csv_path = Path(tile_csv)
1199
+ if not csv_path.is_file():
1200
+ raise FileNotFoundError(f"dataset.tile_metrics_csv not found: {csv_path}")
1201
+ metric_col = getattr(config.dataset, "hard_tile_metric_column", "f1")
1202
+ h_thr = float(getattr(config.dataset, "hard_tile_threshold", 0.8))
1203
+ factor = float(getattr(config.dataset, "hard_tile_oversample_factor", 2.0))
1204
+ stem_metrics = _load_tile_metrics_by_stem(csv_path, metric_col)
1205
+ vacuous_stems = _stems_vacuous_true_negatives_from_tile_csv(csv_path)
1206
+ hard_indices: List[int] = []
1207
+ for i in range(len(train_dataset)):
1208
+ stem = _dataset_index_to_image_stem(train_dataset, i)
1209
+ m = stem_metrics.get(stem)
1210
+ if m is not None and m < h_thr and stem not in vacuous_stems:
1211
+ hard_indices.append(i)
1212
+ extra = max(0, int(round(factor)) - 1)
1213
+ if is_distributed:
1214
+ order = list(range(len(train_dataset)))
1215
+ for hi in hard_indices:
1216
+ for _ in range(extra):
1217
+ order.append(hi)
1218
+ train_dataset = _HardTileExpandedDataset(train_dataset, order)
1219
+ if rank == 0:
1220
+ print(
1221
+ f"Hard-tile oversampling (DDP): {csv_path.name}, {len(hard_indices)} tiles with "
1222
+ f"{metric_col} < {h_thr}; ~{factor}x via {extra} extra index(es) per hard tile"
1223
+ )
1224
+ else:
1225
+ w = torch.ones(len(train_dataset), dtype=torch.double)
1226
+ for hi in hard_indices:
1227
+ w[hi] = factor
1228
+ weighted_train_sampler = WeightedRandomSampler(
1229
+ w, num_samples=len(train_dataset), replacement=True
1230
+ )
1231
+ if rank == 0:
1232
+ print(
1233
+ f"Hard-tile oversampling: {csv_path.name}, {len(hard_indices)} tiles with "
1234
+ f"{metric_col} < {h_thr}; WeightedRandomSampler weight={factor} for those indices"
1235
+ )
1236
+
1237
+ # Analyze class distribution
1238
+ class_counts, class_weights_inv_freq, class_weights_sqrt, sorted_classes = analyze_class_distribution(train_dataset)
1239
+
1240
+ if rank == 0:
1241
+ print(f"\n{'='*80}")
1242
+ print(f"Class Distribution Analysis - Training Set")
1243
+ print(f"{'='*80}")
1244
+ print(f"Total objects: {sum(class_counts.values()):,}")
1245
+ print(f"Total classes: {len(class_counts)}")
1246
+ if class_counts and sorted_classes:
1247
+ print(f"\n{'Class Name':<20} {'Count':<12} {'Percentage':<12}")
1248
+ print("-" * 80)
1249
+ total_objects = sum(class_counts.values())
1250
+ for class_name, count in sorted_classes:
1251
+ percentage = (count / total_objects) * 100
1252
+ print(f"{class_name:<20} {count:>10,} {percentage:>10.2f}%")
1253
+ print("-" * 80)
1254
+ max_count = max(class_counts.values())
1255
+ min_count = min(class_counts.values())
1256
+ imbalance_ratio = max_count / min_count if min_count > 0 else float('inf')
1257
+ print(f"\nImbalance Ratio (max/min): {imbalance_ratio:.2f}x")
1258
+ print(f"Most frequent class: {sorted_classes[0][0]} ({sorted_classes[0][1]:,} instances)")
1259
+ print(f"Least frequent class: {sorted_classes[-1][0]} ({sorted_classes[-1][1]:,} instances)")
1260
+ else:
1261
+ print("(No objects in subset; e.g. overfit on one image with no matching annotations.)")
1262
+
1263
+ # Configure class imbalance handling
1264
+ loss_config = config.loss
1265
+ if loss_config.loss_type in ["class_weighted", "focal_weighted"] and class_counts:
1266
+ # Compute target (end) weights from counts.
1267
+ method = str(getattr(loss_config, "class_weight_method", "sqrt"))
1268
+ overrides = getattr(loss_config, "class_weight_overrides", None)
1269
+ if method == "effective_num":
1270
+ beta = float(getattr(loss_config, "class_weight_beta", 0.9999))
1271
+ # Inline effective_num so we can use config beta.
1272
+ weights_dict = {k: (1.0 - beta) / max(1e-12, (1.0 - (beta ** float(v))))
1273
+ for k, v in class_counts.items()}
1274
+ # Normalize mean=1, clip, overrides (mirror compute_class_weights)
1275
+ mean_weight = float(np.mean(list(weights_dict.values())))
1276
+ roi_class_weights = {k: float(v / mean_weight) for k, v in weights_dict.items()}
1277
+ MAX_WEIGHT = 3.0
1278
+ MIN_WEIGHT = 0.3
1279
+ roi_class_weights = {k: float(np.clip(v, MIN_WEIGHT, MAX_WEIGHT)) for k, v in roi_class_weights.items()}
1280
+ computed_class_weights = {k: float(v) for k, v in roi_class_weights.items()}
1281
+ if overrides:
1282
+ for class_name, weight in overrides.items():
1283
+ if class_name in roi_class_weights:
1284
+ roi_class_weights[class_name] = float(weight)
1285
+ else:
1286
+ roi_class_weights, computed_class_weights = compute_class_weights(
1287
+ class_counts,
1288
+ None,
1289
+ method,
1290
+ class_weight_overrides=overrides,
1291
+ )
1292
+ if getattr(loss_config, "background_weight", None) is not None:
1293
+ roi_class_weights["background"] = float(loss_config.background_weight)
1294
+ if rank == 0:
1295
+ _print_class_weight_table(
1296
+ roi_class_weights,
1297
+ computed_class_weights,
1298
+ class_counts,
1299
+ method=loss_config.class_weight_method,
1300
+ class_weight_overrides=overrides,
1301
+ )
1302
+ else:
1303
+ roi_class_weights = None
1304
+ if rank == 0:
1305
+ print(f"\nClass weighting disabled (loss_type={loss_config.loss_type})")
1306
+
1307
+ if rank == 0:
1308
+ print(f"\nLoss configuration: {loss_config.loss_type}")
1309
+ if loss_config.loss_type in ["focal", "focal_weighted"]:
1310
+ print(f" Focal Loss Alpha: {loss_config.focal_alpha}")
1311
+ print(f" Focal Loss Gamma: {loss_config.focal_gamma}")
1312
+ print()
1313
+
1314
+ if args.wizard:
1315
+ if rank == 0:
1316
+ print("Wizard mode enabled: gathering dataset statistics...")
1317
+ wizard_stats = gather_wizard_stats(train_dataset)
1318
+ print_wizard_recommendations(
1319
+ wizard_stats,
1320
+ config,
1321
+ world_size=world_size if is_distributed else 1,
1322
+ )
1323
+ print("Wizard mode complete: exiting without creating a run directory or starting training.")
1324
+ if is_distributed and dist.is_initialized():
1325
+ dist.barrier()
1326
+ dist.destroy_process_group()
1327
+ return
1328
+
1329
+ # Set runtime data (num_classes = foreground only, so checkpoint and inference stay consistent)
1330
+ config.class_map = class_map
1331
+ config.class_names = class_names
1332
+ config.num_classes = num_foreground_classes
1333
+
1334
+ # Checkpoint source: fully config-driven (paths, discover_previous_run, resume_from_checkpoint_epoch).
1335
+ from oriented_det.train.utils import get_project_root
1336
+
1337
+ project_root = get_project_root()
1338
+ runs_dir = project_root / "runs" / config.model_type
1339
+ current_experiment_dir = runs_dir / config.experiment_timestamp
1340
+ experiment_dirs: List[Path] = []
1341
+ if runs_dir.exists():
1342
+ experiment_dirs = sorted(
1343
+ [d for d in runs_dir.iterdir() if d.is_dir() and d != current_experiment_dir],
1344
+ reverse=True,
1345
+ )
1346
+
1347
+ ckpt_raw = getattr(config.checkpoint, "load_from_checkpoint", None)
1348
+ has_explicit_ckpt_path = bool(ckpt_raw is not None and str(ckpt_raw).strip())
1349
+ ckpt_exists = False
1350
+ if has_explicit_ckpt_path:
1351
+ from oriented_det.pretrained import ensure_checkpoint
1352
+
1353
+ resolved_ckpt = ensure_checkpoint(ckpt_raw, quiet=(rank != 0))
1354
+ config.checkpoint.load_from_checkpoint = resolved_ckpt
1355
+ ckpt_exists = resolved_ckpt.exists()
1356
+ if is_distributed and dist.is_initialized():
1357
+ dist.barrier()
1358
+
1359
+ if config.checkpoint.load_from_experiment is None:
1360
+ discover = bool(getattr(config.checkpoint, "discover_previous_run", False))
1361
+ if discover and not has_explicit_ckpt_path:
1362
+ if experiment_dirs:
1363
+ config.checkpoint.load_from_experiment = str(experiment_dirs[0])
1364
+ if rank == 0:
1365
+ print(
1366
+ f"discover_previous_run: using newest run {experiment_dirs[0].name}"
1367
+ )
1368
+ if config.checkpoint.resume_from_checkpoint_epoch:
1369
+ print(
1370
+ " Loading latest checkpoint_epoch_*.pth (resume_from_checkpoint_epoch=true)"
1371
+ )
1372
+ else:
1373
+ print(
1374
+ " Loading best checkpoint; training starts at epoch 0 unless resumed "
1375
+ "(resume_from_checkpoint_epoch=false)"
1376
+ )
1377
+ elif rank == 0:
1378
+ print(
1379
+ f"Warning: discover_previous_run is true but no other run directory found under {runs_dir}."
1380
+ )
1381
+ elif discover and has_explicit_ckpt_path and not ckpt_exists and rank == 0:
1382
+ print(
1383
+ "Warning: discover_previous_run is true and load_from_checkpoint points to a missing file; "
1384
+ "not auto-setting load_from_experiment. Fix the checkpoint path or omit it to discover a run."
1385
+ )
1386
+
1387
+ if rank == 0:
1388
+ print(f"\nTraining configuration created:")
1389
+ print(f" Model type: {config.model_type}")
1390
+ print(f" Experiment timestamp: {config.experiment_timestamp}")
1391
+ print(f" Number of classes: {config.num_classes}")
1392
+ print(f" Batch size: {config.data_loader.batch_size}")
1393
+ print(f" Learning rate: {config.training.learning_rate}")
1394
+ print(f" Epochs: {config.training.num_epochs}")
1395
+
1396
+ # Create collate functions
1397
+ if rank == 0:
1398
+ print(f"\nCreating collate functions...")
1399
+ if config.enable_albumentation:
1400
+ train_augmentation = create_train_augmentation(
1401
+ brightness_limit=config.augmentation.brightness_limit,
1402
+ contrast_limit=config.augmentation.contrast_limit,
1403
+ gamma_limit=config.augmentation.gamma_limit,
1404
+ gauss_noise_var_limit=config.augmentation.gauss_noise_var_limit,
1405
+ blur_limit=config.augmentation.blur_limit,
1406
+ clahe_clip_limit=config.augmentation.clahe_clip_limit,
1407
+ p_brightness_contrast=config.augmentation.p_brightness_contrast,
1408
+ p_gamma=config.augmentation.p_gamma,
1409
+ p_noise=config.augmentation.p_noise,
1410
+ p_blur=config.augmentation.p_blur,
1411
+ p_clahe=config.augmentation.p_clahe,
1412
+ )
1413
+ # Preprocessing from config (resize + normalization); inference will use the same.
1414
+ prep = getattr(config, "preprocessing", None)
1415
+ if prep is not None:
1416
+ from oriented_det.data.preprocessing import parse_canvas_size
1417
+
1418
+ resize_mode = getattr(prep, "resize_mode", "fixed")
1419
+ ts = getattr(prep, "target_size", [1024, 1024])
1420
+ resize_to = parse_canvas_size(resize_mode, ts)
1421
+ norm_mean = getattr(prep, "normalize_mean", None)
1422
+ norm_std = getattr(prep, "normalize_std", None)
1423
+ pad_div = getattr(prep, "pad_size_divisor", 32)
1424
+ else:
1425
+ resize_mode = "fixed"
1426
+ resize_to = (1024, 1024)
1427
+ norm_mean = norm_std = None
1428
+ pad_div = 32
1429
+
1430
+ flip_h = getattr(prep, "enable_flip_horizontal", True) if prep is not None else True
1431
+ flip_v = getattr(prep, "enable_flip_vertical", True) if prep is not None else True
1432
+ flip_d = getattr(prep, "enable_flip_diagonal", False) if prep is not None else False
1433
+
1434
+ if config.enable_albumentation:
1435
+ train_collate_fn = create_collate_fn(
1436
+ config.class_map,
1437
+ augmentation=train_augmentation,
1438
+ normalize=True,
1439
+ resize_mode=resize_mode,
1440
+ resize_to=resize_to,
1441
+ pad_size_divisor=pad_div,
1442
+ enable_flip_horizontal=flip_h,
1443
+ enable_flip_vertical=flip_v,
1444
+ enable_flip_diagonal=flip_d,
1445
+ normalize_mean=norm_mean,
1446
+ normalize_std=norm_std,
1447
+ difficult_strategy=getattr(config.dataset, "difficult_strategy", "drop"),
1448
+ )
1449
+ if rank == 0:
1450
+ print(" - Training: with Albumentations augmentation")
1451
+ else:
1452
+ train_collate_fn = create_collate_fn(
1453
+ config.class_map,
1454
+ augmentation=None,
1455
+ normalize=True,
1456
+ resize_mode=resize_mode,
1457
+ resize_to=resize_to,
1458
+ pad_size_divisor=pad_div,
1459
+ enable_flip_horizontal=flip_h,
1460
+ enable_flip_vertical=flip_v,
1461
+ enable_flip_diagonal=flip_d,
1462
+ normalize_mean=norm_mean,
1463
+ normalize_std=norm_std,
1464
+ difficult_strategy=getattr(config.dataset, "difficult_strategy", "drop"),
1465
+ )
1466
+ if rank == 0:
1467
+ flip_parts = []
1468
+ if flip_h:
1469
+ flip_parts.append("horizontal")
1470
+ if flip_v:
1471
+ flip_parts.append("vertical")
1472
+ if flip_d:
1473
+ flip_parts.append("diagonal")
1474
+ flip_msg = ", ".join(flip_parts) if flip_parts else "none"
1475
+ print(f" - Training: no Albumentations augmentation (flips: {flip_msg})")
1476
+
1477
+ val_collate_fn = create_collate_fn(
1478
+ config.class_map,
1479
+ augmentation=None,
1480
+ normalize=True,
1481
+ resize_mode=resize_mode,
1482
+ resize_to=resize_to,
1483
+ pad_size_divisor=pad_div,
1484
+ enable_flip_horizontal=False,
1485
+ enable_flip_vertical=False,
1486
+ enable_flip_diagonal=False,
1487
+ normalize_mean=norm_mean,
1488
+ normalize_std=norm_std,
1489
+ difficult_strategy=getattr(config.dataset, "difficult_strategy", "drop"),
1490
+ random_crop=False,
1491
+ )
1492
+ if rank == 0:
1493
+ print(" - Validation: no augmentation")
1494
+
1495
+ # Create data loaders (DistributedSampler when multi-GPU; num_workers=0 for DDP)
1496
+ if rank == 0:
1497
+ print(f"\nCreating data loaders...")
1498
+ num_workers = 0 if is_distributed else config.data_loader.num_workers
1499
+ if is_distributed:
1500
+ train_sampler = DistributedSampler(
1501
+ train_dataset, num_replicas=world_size, rank=rank, shuffle=config.data_loader.shuffle
1502
+ )
1503
+ val_sampler = DistributedSampler(
1504
+ val_dataset, num_replicas=world_size, rank=rank, shuffle=False
1505
+ )
1506
+ train_shuffle = False
1507
+ elif weighted_train_sampler is not None:
1508
+ train_sampler = weighted_train_sampler
1509
+ val_sampler = None
1510
+ train_shuffle = False
1511
+ else:
1512
+ train_sampler = None
1513
+ val_sampler = None
1514
+ train_shuffle = config.data_loader.shuffle
1515
+
1516
+ train_loader = DataLoader(
1517
+ train_dataset,
1518
+ batch_size=config.data_loader.batch_size,
1519
+ shuffle=train_shuffle,
1520
+ sampler=train_sampler,
1521
+ num_workers=num_workers,
1522
+ collate_fn=train_collate_fn,
1523
+ pin_memory=config.data_loader.pin_memory if (torch.cuda.is_available()) else False,
1524
+ )
1525
+
1526
+ val_loader = DataLoader(
1527
+ val_dataset,
1528
+ batch_size=config.data_loader.batch_size,
1529
+ shuffle=False,
1530
+ sampler=val_sampler,
1531
+ num_workers=num_workers,
1532
+ collate_fn=val_collate_fn,
1533
+ pin_memory=config.data_loader.pin_memory if (torch.cuda.is_available()) else False,
1534
+ )
1535
+
1536
+ if rank == 0:
1537
+ print(f"Training samples: {len(train_dataset)}")
1538
+ print(f"Validation samples: {len(val_dataset)}")
1539
+ print(f"Batches per epoch: {len(train_loader)}")
1540
+ if is_distributed:
1541
+ print(f"Effective batch size: {config.data_loader.batch_size * world_size * config.training.gradient_accumulation_steps}")
1542
+
1543
+ # Create model
1544
+ if rank == 0:
1545
+ print(f"\nCreating model...")
1546
+ print(f"Using device: {device}")
1547
+
1548
+ model, roi_loss_type = create_model_from_config(
1549
+ config,
1550
+ num_foreground_classes,
1551
+ device,
1552
+ roi_class_weights=roi_class_weights,
1553
+ )
1554
+
1555
+ # Optional schedule: ramp from uniform weights -> computed weights over epochs.
1556
+ class_weight_schedule = None
1557
+ sched_type = (getattr(loss_config, "class_weight_schedule_type", None) or "").strip().lower()
1558
+ if roi_class_weights is not None and sched_type in ("linear_ramp", "ramp"):
1559
+ start_epoch = int(getattr(loss_config, "class_weight_schedule_start_epoch", 0) or 0)
1560
+ end_epoch = int(getattr(loss_config, "class_weight_schedule_end_epoch", 0) or 0)
1561
+ power = float(getattr(loss_config, "class_weight_schedule_power", 1.0) or 1.0)
1562
+ if end_epoch > start_epoch:
1563
+ w_end = _weights_dict_to_tensor(
1564
+ roi_class_weights, class_map, num_foreground_classes, device=device
1565
+ )
1566
+ # Uniform start (but keep explicit background weight if provided)
1567
+ start_dict = {"background": float(roi_class_weights.get("background", 1.0))}
1568
+ w_start = _weights_dict_to_tensor(
1569
+ start_dict, class_map, num_foreground_classes, device=device
1570
+ )
1571
+
1572
+ def class_weight_schedule(epoch: int) -> torch.Tensor:
1573
+ t = (float(epoch) - float(start_epoch)) / float(end_epoch - start_epoch)
1574
+ a = _ramp(t, power)
1575
+ return (1.0 - a) * w_start + a * w_end
1576
+ if rank == 0:
1577
+ print(f"\nClass weight schedule enabled: {sched_type} (epochs {start_epoch}→{end_epoch}, power={power})")
1578
+ elif rank == 0:
1579
+ print(f"\nNote: class_weight_schedule_type set but end_epoch <= start_epoch; schedule disabled.")
1580
+
1581
+ # Configure class weights on the model (only for models that support it, e.g. OrientedRCNN, RotatedFasterRCNN; RetinaNet uses focal loss and does not)
1582
+ if roi_class_weights is not None and isinstance(roi_class_weights, dict) and hasattr(model, "set_class_weights"):
1583
+ model.set_class_weights(config.class_map, device=device)
1584
+ if rank == 0:
1585
+ if getattr(model, "roi_class_weights", None) is not None:
1586
+ print(f"✓ Class weights configured for {len(roi_class_weights)} classes")
1587
+ else:
1588
+ print("⚠ Class weights requested but not active on this model instance")
1589
+
1590
+ from oriented_det.train.grouped_ce import configure_roi_grouped_ce
1591
+
1592
+ _model_for_grouped_ce = model
1593
+ grouped_ce_active = configure_roi_grouped_ce(
1594
+ _model_for_grouped_ce,
1595
+ loss_config,
1596
+ config.class_map,
1597
+ num_foreground_classes=num_foreground_classes,
1598
+ device=device,
1599
+ )
1600
+ if rank == 0 and grouped_ce_active:
1601
+ sched = getattr(loss_config, "roi_grouped_ce_schedule_type", None) or "step"
1602
+ g_end = int(getattr(loss_config, "roi_grouped_ce_schedule_end_epoch", 0) or 0)
1603
+ g_start = int(getattr(loss_config, "roi_grouped_ce_schedule_start_epoch", 0) or 0)
1604
+ groups = getattr(loss_config, "roi_grouped_ce_groups", None) or {}
1605
+ print(
1606
+ f"\nROI grouped CE curriculum enabled: schedule={sched!r}, "
1607
+ f"epochs {g_start}→{g_end}, {len(groups)} group(s)"
1608
+ )
1609
+
1610
+ # Wrap in DDP for multi-GPU
1611
+ # Partial freeze (freeze_backbone_epochs / freeze_rpn_epochs) toggles requires_grad on subsets;
1612
+ # those parameters skip the loss graph → DDP must traverse unused params or reduction breaks.
1613
+ _fb_pre_ddp = int(getattr(config.training, "freeze_backbone_epochs", 0) or 0)
1614
+ _fr_pre_ddp = int(getattr(config.training, "freeze_rpn_epochs", 0) or 0)
1615
+ _ddp_find_unused = (_fb_pre_ddp > 0 or _fr_pre_ddp > 0)
1616
+ if is_distributed:
1617
+ if _ddp_find_unused and rank == 0:
1618
+ print(
1619
+ " DDP: find_unused_parameters=True (required while backbone/RPN are frozen by epoch)."
1620
+ )
1621
+ model = torch.nn.parallel.DistributedDataParallel(
1622
+ model,
1623
+ device_ids=None,
1624
+ output_device=None,
1625
+ find_unused_parameters=_ddp_find_unused,
1626
+ broadcast_buffers=False,
1627
+ )
1628
+ model_for_weights = model.module
1629
+ else:
1630
+ model_for_weights = model
1631
+
1632
+ if rank == 0:
1633
+ print(f"\nComplete {config.model_type.upper()} model created:")
1634
+ print(f" Backbone: {config.model.backbone}")
1635
+ print(f" Number of classes: {config.num_classes} (foreground)")
1636
+ print(f" Pretrained backbone: {config.model.pretrained_backbone}")
1637
+ _fs = getattr(config.model, "frozen_stages", None)
1638
+ _tl = (5 if _fs == 0 else max(1, 4 - _fs)) if _fs is not None else config.model.trainable_layers
1639
+ print(f" Trainable backbone layers: {_tl} ({'all layers' if _tl == 5 else f'last {_tl} stages'})" + (f" (frozen_stages={_fs})" if _fs is not None else ""))
1640
+ _aa = getattr(model_for_weights, "anchor_angles", None)
1641
+ if _aa:
1642
+ print(
1643
+ " RPN anchor reference angles (fixed horizontal priors, not configurable): "
1644
+ f"{[f'{a*180/math.pi:.1f}°' for a in _aa]}"
1645
+ )
1646
+ print(f" Anchor scales: {config.model.anchor_scales}")
1647
+ print(f" Anchor ratios: {config.model.anchor_ratios}")
1648
+ _fpn_returned = getattr(config.model, "fpn_returned_layers", None)
1649
+ _fpn_strides = getattr(config.model, "fpn_strides", None)
1650
+ if _fpn_strides is None:
1651
+ _fpn_strides = [8, 16, 32, 64] if _fpn_returned == [2, 3, 4] else [4, 8, 16, 32, 64]
1652
+ _num_levels = len(_fpn_strides)
1653
+ _level_names = [f"P{i + 2}" for i in range(_num_levels)] if _fpn_returned != [2, 3, 4] else [f"P{i + 3}" for i in range(_num_levels)]
1654
+ print(f" FPN strides: {_fpn_strides}")
1655
+ print(f" FPN levels: {_num_levels} ({', '.join(_level_names)})")
1656
+ print(f" ROI Loss Type: {roi_loss_type}")
1657
+ if is_distributed:
1658
+ print(f" Distributed: {world_size} GPUs")
1659
+
1660
+ total_params = sum(p.numel() for p in model_for_weights.parameters())
1661
+ trainable_params = sum(p.numel() for p in model_for_weights.parameters() if p.requires_grad)
1662
+ print(f"\n Total parameters: {total_params:,}")
1663
+ print(f" Trainable parameters: {trainable_params:,}")
1664
+
1665
+ CHECKPOINT_DIR = EXPERIMENT_DIR / "checkpoints"
1666
+ if rank == 0:
1667
+ CHECKPOINT_DIR.mkdir(parents=True, exist_ok=True)
1668
+
1669
+ LOG_DIR = EXPERIMENT_DIR
1670
+ if rank == 0 and SummaryWriter is None:
1671
+ raise RuntimeError(
1672
+ "TensorBoard is not installed. Install with `pip install tensorboard` "
1673
+ "or `pip install oriented-det` (PyPI package includes it as a dependency)."
1674
+ )
1675
+ writer = SummaryWriter(log_dir=str(LOG_DIR)) if rank == 0 else None
1676
+
1677
+ if rank == 0:
1678
+ config.save(EXPERIMENT_DIR / "config.json")
1679
+ print(f"\nConfiguration saved to: {EXPERIMENT_DIR / 'config.json'}")
1680
+ # Log config to TensorBoard (Text tab) for easy viewing
1681
+ if writer is not None:
1682
+ config_str = (EXPERIMENT_DIR / "config.json").read_text(encoding="utf-8")
1683
+ writer.add_text("config", f"```json\n{config_str}\n```", 0)
1684
+
1685
+ # Apply learning rate scaling for gradient accumulation and DDP
1686
+ original_lr = config.training.learning_rate
1687
+ scaling_factors = []
1688
+ if config.training.gradient_accumulation_steps > 1 and config.training.lr_scaling_with_accumulation != "none":
1689
+ if config.training.lr_scaling_with_accumulation == "linear":
1690
+ scaling_factors.append(config.training.gradient_accumulation_steps)
1691
+ elif config.training.lr_scaling_with_accumulation == "sqrt":
1692
+ scaling_factors.append(math.sqrt(config.training.gradient_accumulation_steps))
1693
+ if is_distributed and bool(getattr(config.training, "lr_scale_with_world_size", False)):
1694
+ scaling_factors.append(world_size)
1695
+ if scaling_factors:
1696
+ total_scaling = math.prod(scaling_factors)
1697
+ config.training.learning_rate = original_lr * total_scaling
1698
+ if rank == 0:
1699
+ print(f"\n 📈 Learning Rate Scaling:")
1700
+ print(f" Base LR: {original_lr:.6f}")
1701
+ if is_distributed and bool(getattr(config.training, "lr_scale_with_world_size", False)):
1702
+ print(f" DDP scaling: × {world_size} (world_size)")
1703
+ if config.training.gradient_accumulation_steps > 1 and config.training.lr_scaling_with_accumulation != "none":
1704
+ print(f" Gradient accumulation: × {scaling_factors[0]:.3f}")
1705
+ print(f" Total scaling: × {total_scaling:.3f}")
1706
+ print(f" Scaled LR: {config.training.learning_rate:.6f}")
1707
+ eff_bs = config.data_loader.batch_size * (world_size if is_distributed else 1) * config.training.gradient_accumulation_steps
1708
+ print(f" Effective batch size: {eff_bs}")
1709
+
1710
+ # Create optimizer and scheduler
1711
+ use_lr_param_groups = bool(getattr(config.training, "use_lr_param_groups", False))
1712
+ freeze_backbone_epochs = int(getattr(config.training, "freeze_backbone_epochs", 0) or 0)
1713
+ freeze_rpn_epochs = int(getattr(config.training, "freeze_rpn_epochs", 0) or 0)
1714
+ use_phase_freeze = freeze_backbone_epochs > 0 or freeze_rpn_epochs > 0
1715
+ if use_phase_freeze and rank == 0:
1716
+ print(
1717
+ f"\nPartial freeze (0-based epoch indices; ROI always trains): "
1718
+ f"freeze_backbone_epochs={freeze_backbone_epochs}, freeze_rpn_epochs={freeze_rpn_epochs}"
1719
+ )
1720
+ if use_lr_param_groups:
1721
+ optimizer_param_groups, optimizer_group_summary = build_optimizer_param_groups(
1722
+ model,
1723
+ base_lr=config.training.learning_rate,
1724
+ weight_decay=config.training.weight_decay,
1725
+ config=config,
1726
+ include_frozen_parameters=use_phase_freeze,
1727
+ )
1728
+ optimizer = optim.SGD(
1729
+ optimizer_param_groups,
1730
+ lr=config.training.learning_rate,
1731
+ momentum=config.training.momentum,
1732
+ weight_decay=config.training.weight_decay,
1733
+ )
1734
+ if rank == 0:
1735
+ print("\nOptimizer param groups enabled:")
1736
+ for group_name in ["backbone", "rpn", "roi", "head", "other"]:
1737
+ if group_name not in optimizer_group_summary:
1738
+ continue
1739
+ details = optimizer_group_summary[group_name]
1740
+ print(
1741
+ f" - {group_name:8s} "
1742
+ f"lr={details['lr']:.6f} "
1743
+ f"(x{details['multiplier']:.2f}), "
1744
+ f"params={int(details['num_params']):,}"
1745
+ )
1746
+ else:
1747
+ optimizer = optim.SGD(
1748
+ model.parameters(),
1749
+ lr=config.training.learning_rate,
1750
+ momentum=config.training.momentum,
1751
+ weight_decay=config.training.weight_decay,
1752
+ )
1753
+
1754
+ # Create LR scheduler (type from config: multistep/step, reduce_on_plateau, one_cycle, cosine_annealing)
1755
+ sched_type = (getattr(config.training, "lr_scheduler_type", None) or "").strip().lower()
1756
+ lr_milestones = getattr(config.training, "lr_scheduler_milestones", None)
1757
+ plateau_metric = getattr(config.training, "lr_scheduler_plateau_metric", "total_loss")
1758
+
1759
+ if sched_type == "reduce_on_plateau":
1760
+ mode = "max" if plateau_metric.strip().lower() == "map" else "min"
1761
+ factor = getattr(config.training, "lr_scheduler_plateau_factor", 0.1)
1762
+ patience = getattr(config.training, "lr_scheduler_plateau_patience", 5)
1763
+ lr_scheduler = optim.lr_scheduler.ReduceLROnPlateau(
1764
+ optimizer, mode=mode, factor=factor, patience=patience,
1765
+ )
1766
+ if rank == 0:
1767
+ print(f"\nLR scheduler: ReduceLROnPlateau (metric={plateau_metric}, mode={mode}, factor={factor}, patience={patience})")
1768
+ elif sched_type in ("one_cycle", "onecycle"):
1769
+ steps_per_epoch = max(1, len(train_loader) // config.training.gradient_accumulation_steps)
1770
+ total_steps = config.training.num_epochs * steps_per_epoch
1771
+ pct_start = getattr(config.training, "lr_scheduler_one_cycle_pct_start", 0.3)
1772
+ div_factor = getattr(config.training, "lr_scheduler_one_cycle_div_factor", 25.0)
1773
+ final_div_factor = getattr(config.training, "lr_scheduler_one_cycle_final_div_factor", 1e4)
1774
+ one_cycle = optim.lr_scheduler.OneCycleLR(
1775
+ optimizer,
1776
+ max_lr=config.training.learning_rate,
1777
+ total_steps=total_steps,
1778
+ pct_start=pct_start,
1779
+ div_factor=div_factor,
1780
+ final_div_factor=final_div_factor,
1781
+ )
1782
+ lr_scheduler = OneCycleWrapper(one_cycle)
1783
+ if rank == 0:
1784
+ print(
1785
+ f"\nLR scheduler: OneCycleLR (total_steps={total_steps}, pct_start={pct_start}, "
1786
+ f"div_factor={div_factor}, final_div_factor={final_div_factor})"
1787
+ )
1788
+ elif sched_type in ("cosine_annealing", "cosine"):
1789
+ base_lr_scheduler, t_max, eta_min = create_pytorch_cosine_lr_scheduler(
1790
+ optimizer, config.training,
1791
+ )
1792
+ sched_desc = format_pytorch_cosine_scheduler_description(
1793
+ t_max,
1794
+ eta_min,
1795
+ config.training.lr_warmup_steps,
1796
+ config.training.num_epochs,
1797
+ )
1798
+ if config.training.lr_warmup_steps > 0:
1799
+ lr_scheduler = WarmupScheduler(
1800
+ optimizer, base_lr_scheduler, config.training.lr_warmup_steps,
1801
+ )
1802
+ if rank == 0:
1803
+ print(f"\nLR scheduler: {sched_desc}")
1804
+ else:
1805
+ lr_scheduler = base_lr_scheduler
1806
+ if rank == 0:
1807
+ print(f"\nLR scheduler: {sched_desc}")
1808
+ elif sched_type in ("cosine_annealing_with_tail", "cosine_with_tail"):
1809
+ eta_min = float(getattr(config.training, "lr_scheduler_cosine_eta_min", 1e-6))
1810
+ base_lr_scheduler, cosine_ep, tail_ep, tail_lr = create_cosine_with_tail_lr_scheduler(
1811
+ optimizer, config.training,
1812
+ )
1813
+ sched_desc = format_cosine_with_tail_scheduler_description(
1814
+ cosine_ep, tail_ep, eta_min, tail_lr, config.training.lr_warmup_steps,
1815
+ )
1816
+ if config.training.lr_warmup_steps > 0:
1817
+ lr_scheduler = WarmupScheduler(
1818
+ optimizer, base_lr_scheduler, config.training.lr_warmup_steps,
1819
+ )
1820
+ if rank == 0:
1821
+ print(f"\nLR scheduler: {sched_desc}")
1822
+ else:
1823
+ lr_scheduler = base_lr_scheduler
1824
+ if rank == 0:
1825
+ print(f"\nLR scheduler: {sched_desc}")
1826
+ else:
1827
+ # Default: MultiStepLR or StepLR
1828
+ if isinstance(lr_milestones, list) and len(lr_milestones) > 0:
1829
+ base_lr_scheduler = optim.lr_scheduler.MultiStepLR(
1830
+ optimizer,
1831
+ milestones=[int(m) for m in lr_milestones],
1832
+ gamma=config.training.lr_scheduler_gamma,
1833
+ )
1834
+ else:
1835
+ base_lr_scheduler = optim.lr_scheduler.StepLR(
1836
+ optimizer,
1837
+ step_size=config.training.lr_scheduler_step_epochs,
1838
+ gamma=config.training.lr_scheduler_gamma,
1839
+ )
1840
+ if config.training.lr_warmup_steps > 0:
1841
+ lr_scheduler = WarmupScheduler(
1842
+ optimizer, base_lr_scheduler, config.training.lr_warmup_steps,
1843
+ )
1844
+ if rank == 0:
1845
+ print(f"\nLearning rate warmup enabled: {config.training.lr_warmup_steps} optimizer steps")
1846
+ if use_lr_param_groups:
1847
+ warmup_targets = ", ".join(f"{lr:.6f}" for lr in lr_scheduler.base_lrs)
1848
+ print(f" Warmup target LRs: [{warmup_targets}] over {config.training.lr_warmup_steps} steps")
1849
+ else:
1850
+ print(f" Warmup: 0 → {config.training.learning_rate} over {config.training.lr_warmup_steps} steps")
1851
+ if isinstance(lr_milestones, list) and len(lr_milestones) > 0:
1852
+ print(
1853
+ " After warmup: MultiStepLR at epochs "
1854
+ f"{[int(m) for m in lr_milestones]}, gamma={config.training.lr_scheduler_gamma}"
1855
+ )
1856
+ else:
1857
+ print(
1858
+ f" After warmup: StepLR every {config.training.lr_scheduler_step_epochs} epochs, "
1859
+ f"gamma={config.training.lr_scheduler_gamma}"
1860
+ )
1861
+ else:
1862
+ lr_scheduler = base_lr_scheduler
1863
+ if rank == 0:
1864
+ print("\nLearning rate warmup disabled")
1865
+
1866
+ # Create checkpoint manager (best checkpoint: config checkpoint.best_metric, e.g. "mAP" or "total_loss")
1867
+ best_metric = getattr(config.checkpoint, "best_metric", "total_loss") or "total_loss"
1868
+ higher_is_better_cfg = getattr(config.checkpoint, "higher_is_better", None)
1869
+ if higher_is_better_cfg is not None:
1870
+ higher_is_better = bool(higher_is_better_cfg)
1871
+ else:
1872
+ higher_is_better = str(best_metric).strip().lower() == "map"
1873
+ checkpoint_manager = CheckpointManager(
1874
+ CHECKPOINT_DIR,
1875
+ best_metric=best_metric,
1876
+ higher_is_better=higher_is_better,
1877
+ keep_last_n=3,
1878
+ )
1879
+ eval_thr_sc, eval_thr_pc, eval_thr_iou = effective_eval_metric_thresholds(config)
1880
+ eval_use_exact_rotated_iou = config_use_exact_rotated_iou_for_map(config)
1881
+ eval_use_exact_rotated_iou_for_final_map = config_use_exact_rotated_iou_for_final_map(config)
1882
+
1883
+ if rank == 0:
1884
+ print(f"\nExperiment directory: {EXPERIMENT_DIR}")
1885
+ print(f"TensorBoard logging enabled. Logs saved to: {LOG_DIR}")
1886
+ print(f"View logs with: tensorboard --logdir {LOG_DIR.parent}")
1887
+ print(f"View this experiment with: tensorboard --logdir {LOG_DIR}")
1888
+
1889
+ print(f"\nTraining configuration:")
1890
+ print(f" Epochs: {config.training.num_epochs}")
1891
+ print(f" Learning rate: {config.training.learning_rate:.6f}")
1892
+ if use_lr_param_groups:
1893
+ lh = getattr(config.training, "lr_mult_head", None)
1894
+ extra = f", lr_mult_head={lh}" if lh is not None else ""
1895
+ print(
1896
+ " LR multipliers (backbone/rpn/roi/other"
1897
+ f"{extra}): "
1898
+ f"{config.training.lr_mult_backbone}/"
1899
+ f"{config.training.lr_mult_rpn}/"
1900
+ f"{config.training.lr_mult_roi}/"
1901
+ f"{config.training.lr_mult_other}"
1902
+ )
1903
+ print(f" Batch size per GPU: {config.data_loader.batch_size}")
1904
+ print(f" Gradient accumulation: {config.training.gradient_accumulation_steps}")
1905
+ print(f" Best checkpoint metric: {best_metric} (higher_is_better={higher_is_better})")
1906
+ eff_bs = config.data_loader.batch_size * (world_size if is_distributed else 1) * config.training.gradient_accumulation_steps
1907
+ print(f" Effective batch size: {eff_bs}")
1908
+ print(f" Mixed precision: {config.training.use_amp}")
1909
+ _eval_thr_extra = (
1910
+ f", per_class_score_threshold={eval_thr_pc}" if eval_thr_pc else ""
1911
+ )
1912
+ print(
1913
+ f" Eval (mAP / val matching): score_threshold={eval_thr_sc}, "
1914
+ f"iou_threshold={eval_thr_iou}{_eval_thr_extra}"
1915
+ " (production.score_threshold overrides evaluation when set)"
1916
+ )
1917
+ print(
1918
+ f" Eval IoU backend (mAP / GT cover): "
1919
+ f"{'exact CPU polygon' if eval_use_exact_rotated_iou else 'GPU sampling (approx)'}"
1920
+ )
1921
+ print(
1922
+ f" Eval IoU backend (final mAP): "
1923
+ f"{'exact CPU polygon' if eval_use_exact_rotated_iou_for_final_map else 'GPU sampling (approx)'}"
1924
+ )
1925
+
1926
+ # Debug: config summary
1927
+ if getattr(args, "debug", False):
1928
+ print(f"\n [debug] Dataset sizes: train={len(train_dataset)}, val={len(val_dataset)}")
1929
+ print(
1930
+ f"\n [debug] Eval: score_threshold={eval_thr_sc}, iou_threshold={eval_thr_iou} "
1931
+ f"(effective; production overrides evaluation when set)"
1932
+ )
1933
+ print(f" [debug] Classes: {config.class_names}")
1934
+ map_every = getattr(config.evaluation, "compute_map_every_n_epochs", 0)
1935
+ print(f" [debug] mAP computed every N epochs: {map_every if map_every else 'final only'}")
1936
+
1937
+ # Load checkpoint if specified
1938
+ checkpoint_loaded = False
1939
+ checkpoint_epoch = None
1940
+ start_epoch = config.checkpoint.start_epoch
1941
+ checkpoint_path = None
1942
+
1943
+ # Direct checkpoint path takes precedence over load_from_experiment
1944
+ if getattr(config.checkpoint, "load_from_checkpoint", None) is not None:
1945
+ from oriented_det.pretrained import ensure_checkpoint
1946
+
1947
+ direct_path = ensure_checkpoint(
1948
+ config.checkpoint.load_from_checkpoint, quiet=(rank != 0)
1949
+ )
1950
+ if direct_path.exists():
1951
+ checkpoint_path = direct_path
1952
+ if rank == 0:
1953
+ print(f"\nLoading checkpoint from config path: {checkpoint_path}")
1954
+ elif rank == 0:
1955
+ print(f"\nWarning: load_from_checkpoint set but file not found: {direct_path}")
1956
+
1957
+ if checkpoint_path is None and config.checkpoint.load_from_experiment is not None:
1958
+ experiment_dir = Path(config.checkpoint.load_from_experiment)
1959
+ checkpoint_dir = experiment_dir / "checkpoints"
1960
+
1961
+ # Latest checkpoint when resume_from_checkpoint_epoch; else prefer best-metric checkpoint
1962
+ latest_checkpoint = None
1963
+ if checkpoint_dir.exists():
1964
+ checkpoint_files = sorted(checkpoint_dir.glob("checkpoint_epoch_*.pth"), reverse=True)
1965
+ if checkpoint_files:
1966
+ latest_checkpoint = checkpoint_files[0]
1967
+
1968
+ # Fine-tune from experiment dir (resume_from_checkpoint_epoch=false): prefer best-metric checkpoint if it exists
1969
+ # resume_from_checkpoint_epoch: use latest checkpoint so training continues from last epoch
1970
+ if config.checkpoint.resume_from_checkpoint_epoch:
1971
+ checkpoint_path = latest_checkpoint if (latest_checkpoint and latest_checkpoint.exists()) else None
1972
+ if checkpoint_path and rank == 0:
1973
+ print(f"\nLoading latest checkpoint from: {checkpoint_path}")
1974
+ else:
1975
+ checkpoint_path = get_best_checkpoint_path(checkpoint_dir)
1976
+ if checkpoint_path is not None:
1977
+ if rank == 0:
1978
+ print(f"\nLoading best checkpoint from: {checkpoint_path}")
1979
+ elif latest_checkpoint and latest_checkpoint.exists():
1980
+ checkpoint_path = latest_checkpoint
1981
+ if rank == 0:
1982
+ print(f"\nLoading latest checkpoint from: {checkpoint_path} (no best_*.pth found)")
1983
+ else:
1984
+ checkpoint_path = None
1985
+ if checkpoint_path is None and rank == 0:
1986
+ print(f"\nWarning: No checkpoint found in {checkpoint_dir}, starting from scratch")
1987
+
1988
+ if checkpoint_path and checkpoint_path.exists():
1989
+ include_prefixes = getattr(config.checkpoint, "load_include_prefixes", None)
1990
+ exclude_prefixes = getattr(config.checkpoint, "load_exclude_prefixes", None)
1991
+ selective_restore = bool(include_prefixes or exclude_prefixes)
1992
+ load_optimizer_state = bool(config.checkpoint.load_optimizer_state)
1993
+ load_scheduler_state = False
1994
+ forced_optimizer_load = False # True when we force load due to resume (config had false)
1995
+ # When resuming from checkpoint epoch, always load optimizer state for proper continuation
1996
+ if config.checkpoint.resume_from_checkpoint_epoch:
1997
+ forced_optimizer_load = not load_optimizer_state
1998
+ load_optimizer_state = True
1999
+ load_scheduler_state = bool(getattr(config.checkpoint, "load_scheduler_state", True))
2000
+ if selective_restore and load_optimizer_state:
2001
+ load_optimizer_state = False
2002
+ load_scheduler_state = False
2003
+ if rank == 0:
2004
+ print(
2005
+ " Selective model restore requested; disabling optimizer state load "
2006
+ "to avoid param-group mismatch."
2007
+ )
2008
+ opt_to_load = optimizer if load_optimizer_state else None
2009
+ sched_to_load = lr_scheduler if load_scheduler_state else None
2010
+ model_to_load = model_for_weights if is_distributed else model
2011
+ checkpoint = checkpoint_manager.load(
2012
+ checkpoint_path,
2013
+ model_to_load,
2014
+ opt_to_load,
2015
+ sched_to_load,
2016
+ strict=False,
2017
+ include_prefixes=include_prefixes,
2018
+ exclude_prefixes=exclude_prefixes,
2019
+ )
2020
+ checkpoint_epoch = checkpoint.get("epoch", None)
2021
+ checkpoint_loaded = True
2022
+
2023
+ if rank == 0:
2024
+ if not load_optimizer_state:
2025
+ print(" Note: Optimizer state not loaded (only model weights)")
2026
+ elif forced_optimizer_load:
2027
+ print(" Resume: loading optimizer state (overrides config)")
2028
+ if load_scheduler_state:
2029
+ print(" Resume: loading scheduler state")
2030
+ else:
2031
+ print(" Resume: scheduler state not loaded (optional cosine rebuild for continuation)")
2032
+ if checkpoint_epoch is not None:
2033
+ print(f"Checkpoint was saved at epoch {checkpoint_epoch}")
2034
+ if "metrics" in checkpoint:
2035
+ print(f"Checkpoint metrics: {checkpoint['metrics']}")
2036
+ resume_from_ckpt_epoch = bool(config.checkpoint.resume_from_checkpoint_epoch)
2037
+ if selective_restore and resume_from_ckpt_epoch:
2038
+ resume_from_ckpt_epoch = False
2039
+ print(
2040
+ " Selective restore is active; keeping start_epoch from config "
2041
+ f"({start_epoch}) instead of checkpoint epoch."
2042
+ )
2043
+ if resume_from_ckpt_epoch:
2044
+ start_epoch = checkpoint_epoch + 1 if checkpoint_epoch is not None else 0
2045
+ print(f"Resuming from epoch {start_epoch} (checkpoint epoch + 1)")
2046
+ else:
2047
+ print(f"Checkpoint loaded, but starting from epoch {start_epoch} (manual override)")
2048
+ else:
2049
+ resume_from_ckpt_epoch = bool(config.checkpoint.resume_from_checkpoint_epoch)
2050
+ if selective_restore and resume_from_ckpt_epoch:
2051
+ resume_from_ckpt_epoch = False
2052
+ if resume_from_ckpt_epoch:
2053
+ start_epoch = checkpoint_epoch + 1 if checkpoint_epoch is not None else 0
2054
+
2055
+ # Continuation training: original cosine may have T_max=num_epochs from the first run; reloading
2056
+ # that state leaves almost no LR schedule left. Rebuild cosine with a longer T_max and
2057
+ # last_epoch from the checkpoint (requires lr_warmup_steps=0 so the LR schedule is epoch-based).
2058
+ if (
2059
+ bool(config.checkpoint.resume_from_checkpoint_epoch)
2060
+ and not selective_restore
2061
+ and not load_scheduler_state
2062
+ and load_optimizer_state
2063
+ and checkpoint_epoch is not None
2064
+ and sched_type in (
2065
+ "cosine_annealing",
2066
+ "cosine",
2067
+ "cosine_annealing_with_tail",
2068
+ "cosine_with_tail",
2069
+ )
2070
+ and int(getattr(config.training, "lr_warmup_steps", 0) or 0) == 0
2071
+ ):
2072
+ eta_min_cont = float(getattr(config.training, "lr_scheduler_cosine_eta_min", 1e-6))
2073
+ if sched_type in ("cosine_annealing_with_tail", "cosine_with_tail"):
2074
+ lr_scheduler, cosine_ep, tail_ep, tail_lr = create_cosine_with_tail_lr_scheduler(
2075
+ optimizer,
2076
+ config.training,
2077
+ last_epoch=int(checkpoint_epoch),
2078
+ )
2079
+ sched_desc = format_cosine_with_tail_scheduler_description(
2080
+ cosine_ep, tail_ep, eta_min_cont, tail_lr, 0,
2081
+ )
2082
+ else:
2083
+ lr_scheduler, t_max, eta_min_cont = create_pytorch_cosine_lr_scheduler(
2084
+ optimizer,
2085
+ config.training,
2086
+ last_epoch=int(checkpoint_epoch),
2087
+ )
2088
+ sched_desc = format_pytorch_cosine_scheduler_description(
2089
+ t_max, eta_min_cont, 0, config.training.num_epochs,
2090
+ )
2091
+ if rank == 0:
2092
+ print(
2093
+ f"\nRebuilt cosine schedule for continuation (scheduler state not loaded): "
2094
+ f"{sched_desc}, last_epoch={int(checkpoint_epoch)}"
2095
+ )
2096
+
2097
+ if not checkpoint_loaded:
2098
+ start_epoch = 0
2099
+ if rank == 0:
2100
+ print("\nStarting training from scratch (no checkpoint loaded)")
2101
+
2102
+ if rank == 0:
2103
+ print(f"\nStarting epoch: {start_epoch}")
2104
+ if checkpoint_loaded:
2105
+ print(f"Checkpoint loaded: Model weights from epoch {checkpoint_epoch}")
2106
+
2107
+ if use_phase_freeze:
2108
+ set_backbone_requires_grad(
2109
+ model_for_weights,
2110
+ freeze=start_epoch < freeze_backbone_epochs,
2111
+ )
2112
+ set_rpn_requires_grad(
2113
+ model_for_weights,
2114
+ freeze=start_epoch < freeze_rpn_epochs,
2115
+ )
2116
+ if rank == 0:
2117
+ print(
2118
+ f" Backbone: {'FROZEN' if start_epoch < freeze_backbone_epochs else 'trainable'} "
2119
+ f"(freeze_backbone_epochs={freeze_backbone_epochs}) | "
2120
+ f"RPN: {'FROZEN' if start_epoch < freeze_rpn_epochs else 'trainable'} "
2121
+ f"(freeze_rpn_epochs={freeze_rpn_epochs}) at start epoch {start_epoch}"
2122
+ )
2123
+
2124
+ # production.* decode/NMS fields are for saved config + checkpoint inference only
2125
+ # (apply_inference_config_to_model in tools/save_predictions.load_model_from_checkpoint;
2126
+ # deploy/image_demo). Do not patch the live training model here — model.* stays canonical.
2127
+
2128
+ # Initialize profiler if enabled (single-GPU only; DDP uses same code path on all ranks)
2129
+ profiler = None
2130
+ if config.enable_profiling and not is_distributed:
2131
+ from oriented_det.train.profiler import TrainingProfiler
2132
+ from torch.profiler import ProfilerActivity, schedule as profiler_schedule
2133
+
2134
+ PROFILING_DIR = EXPERIMENT_DIR / "profiling"
2135
+ PROFILING_DIR.mkdir(parents=True, exist_ok=True)
2136
+
2137
+ profiler_schedule_config = profiler_schedule(
2138
+ wait=1,
2139
+ warmup=1,
2140
+ active=3,
2141
+ repeat=1,
2142
+ )
2143
+
2144
+ profiler = TrainingProfiler(
2145
+ log_dir=str(PROFILING_DIR),
2146
+ activities=[ProfilerActivity.CUDA, ProfilerActivity.CPU],
2147
+ schedule=profiler_schedule_config,
2148
+ record_shapes=True,
2149
+ profile_memory=True,
2150
+ with_stack=False,
2151
+ with_flops=False,
2152
+ )
2153
+
2154
+ print(f"\nProfiling enabled!")
2155
+ print(f" Profiling directory: {PROFILING_DIR}")
2156
+ else:
2157
+ print("\nProfiling disabled")
2158
+
2159
+ if rank == 0:
2160
+ print("\n" + "=" * 80)
2161
+ print("Starting training...")
2162
+ print("=" * 80)
2163
+ print(f"TensorBoard: tensorboard --logdir {LOG_DIR.parent}")
2164
+ print("=" * 80)
2165
+ print()
2166
+
2167
+ debug_mode = getattr(args, "debug", False)
2168
+ if debug_mode:
2169
+ enable_tracing()
2170
+ if rank == 0:
2171
+ print("Debug tracing enabled (RPN/ROI match statistics).")
2172
+
2173
+ log_debug_anchors = getattr(getattr(config, "tensorboard", None), "log_debug_anchors_proposals", False) or debug_mode
2174
+
2175
+ try:
2176
+ if profiler is not None:
2177
+ if rank == 0:
2178
+ print("Profiling enabled - wrapping training in profiler context")
2179
+ with profiler:
2180
+ history = train(
2181
+ model=model,
2182
+ train_loader=train_loader,
2183
+ optimizer=optimizer,
2184
+ device=device,
2185
+ num_epochs=config.training.num_epochs,
2186
+ val_loader=val_loader,
2187
+ lr_scheduler=lr_scheduler,
2188
+ checkpoint_manager=checkpoint_manager,
2189
+ use_amp=config.training.use_amp,
2190
+ gradient_accumulation_steps=config.training.gradient_accumulation_steps,
2191
+ max_grad_norm=config.training.max_grad_norm,
2192
+ loss_weights=getattr(config.training, 'loss_weights', None),
2193
+ roi_class_weights=class_weight_schedule,
2194
+ start_epoch=start_epoch,
2195
+ writer=writer,
2196
+ class_map=config.class_map,
2197
+ class_names=config.class_names,
2198
+ eval_score_threshold=eval_thr_sc,
2199
+ eval_per_class_score_threshold=eval_thr_pc,
2200
+ eval_vis_score_threshold=getattr(getattr(config, "tensorboard", None), "vis_score_threshold", None),
2201
+ eval_iou_threshold=eval_thr_iou,
2202
+ eval_extended_gt_metrics=getattr(config.evaluation, "extended_gt_metrics", False),
2203
+ eval_compute_map_final=config.evaluation.compute_map_final,
2204
+ eval_compute_map_every_n_epochs=getattr(config.evaluation, "compute_map_every_n_epochs", 0),
2205
+ log_debug_anchors_proposals=log_debug_anchors,
2206
+ profiler=profiler,
2207
+ normalize_mean=norm_mean,
2208
+ normalize_std=norm_std,
2209
+ vis_image_size=resize_to,
2210
+ train_sampler=train_sampler if is_distributed else None,
2211
+ val_sampler=val_sampler if is_distributed else None,
2212
+ rank=rank if is_distributed else None,
2213
+ progress_stream=_orig_stderr if _orig_stderr is not None else None,
2214
+ debug=debug_mode,
2215
+ lr_scheduler_plateau_metric=plateau_metric,
2216
+ early_stop_patience=getattr(config.training, "early_stop_patience", None),
2217
+ early_stop_metric=getattr(config.training, "early_stop_metric", "mAP"),
2218
+ early_stop_min_delta=getattr(config.training, "early_stop_min_delta", 0.0),
2219
+ early_stop_higher_is_better=getattr(config.training, "early_stop_higher_is_better", None),
2220
+ freeze_backbone_epochs=freeze_backbone_epochs,
2221
+ freeze_rpn_epochs=freeze_rpn_epochs,
2222
+ eval_use_exact_rotated_iou=eval_use_exact_rotated_iou,
2223
+ eval_use_exact_rotated_iou_for_final_map=eval_use_exact_rotated_iou_for_final_map,
2224
+ )
2225
+ if rank == 0:
2226
+ print("\n" + "=" * 80)
2227
+ print("Profiling Summary:")
2228
+ profiler.print_summary(sort_by="cuda_time_total", row_limit=20)
2229
+ else:
2230
+ history = train(
2231
+ model=model,
2232
+ train_loader=train_loader,
2233
+ optimizer=optimizer,
2234
+ device=device,
2235
+ num_epochs=config.training.num_epochs,
2236
+ val_loader=val_loader,
2237
+ lr_scheduler=lr_scheduler,
2238
+ checkpoint_manager=checkpoint_manager,
2239
+ use_amp=config.training.use_amp,
2240
+ gradient_accumulation_steps=config.training.gradient_accumulation_steps,
2241
+ max_grad_norm=config.training.max_grad_norm,
2242
+ loss_weights=getattr(config.training, 'loss_weights', None),
2243
+ roi_class_weights=class_weight_schedule,
2244
+ start_epoch=start_epoch,
2245
+ writer=writer,
2246
+ class_map=config.class_map,
2247
+ class_names=config.class_names,
2248
+ eval_score_threshold=eval_thr_sc,
2249
+ eval_per_class_score_threshold=eval_thr_pc,
2250
+ eval_vis_score_threshold=getattr(getattr(config, "tensorboard", None), "vis_score_threshold", None),
2251
+ eval_iou_threshold=eval_thr_iou,
2252
+ eval_extended_gt_metrics=getattr(config.evaluation, "extended_gt_metrics", False),
2253
+ eval_compute_map_final=config.evaluation.compute_map_final,
2254
+ eval_compute_map_every_n_epochs=getattr(config.evaluation, "compute_map_every_n_epochs", 0),
2255
+ log_debug_anchors_proposals=log_debug_anchors,
2256
+ profiler=None,
2257
+ normalize_mean=norm_mean,
2258
+ normalize_std=norm_std,
2259
+ vis_image_size=resize_to,
2260
+ train_sampler=train_sampler if is_distributed else None,
2261
+ val_sampler=val_sampler if is_distributed else None,
2262
+ rank=rank if is_distributed else None,
2263
+ progress_stream=_orig_stderr if _orig_stderr is not None else None,
2264
+ debug=debug_mode,
2265
+ lr_scheduler_plateau_metric=plateau_metric,
2266
+ early_stop_patience=getattr(config.training, "early_stop_patience", None),
2267
+ early_stop_metric=getattr(config.training, "early_stop_metric", "mAP"),
2268
+ early_stop_min_delta=getattr(config.training, "early_stop_min_delta", 0.0),
2269
+ early_stop_higher_is_better=getattr(config.training, "early_stop_higher_is_better", None),
2270
+ freeze_backbone_epochs=freeze_backbone_epochs,
2271
+ freeze_rpn_epochs=freeze_rpn_epochs,
2272
+ eval_use_exact_rotated_iou=eval_use_exact_rotated_iou,
2273
+ eval_use_exact_rotated_iou_for_final_map=eval_use_exact_rotated_iou_for_final_map,
2274
+ )
2275
+
2276
+ if rank == 0:
2277
+ print("\n" + "=" * 80)
2278
+ print("Training completed successfully!")
2279
+ print("=" * 80)
2280
+ print(f"Experiment directory: {EXPERIMENT_DIR}")
2281
+ print(f" Checkpoints saved to: {CHECKPOINT_DIR}")
2282
+ print(f" TensorBoard logs saved to: {LOG_DIR}")
2283
+ print(f" Config saved to: {EXPERIMENT_DIR / 'config.json'}")
2284
+ if profiler is not None:
2285
+ print(f" Profiling traces saved to: {PROFILING_DIR}")
2286
+ print(f"\nView TensorBoard with:")
2287
+ print(f" tensorboard --logdir {LOG_DIR.parent}")
2288
+ print(f" tensorboard --logdir {LOG_DIR}")
2289
+
2290
+ except KeyboardInterrupt:
2291
+ if rank == 0:
2292
+ print("\n\nTraining interrupted by user")
2293
+ print(f"Experiment directory: {EXPERIMENT_DIR}")
2294
+ print(f" Checkpoints saved to: {CHECKPOINT_DIR}")
2295
+ print(f" Config saved to: {EXPERIMENT_DIR / 'config.json'}")
2296
+ if writer is not None:
2297
+ writer.close()
2298
+ except Exception as e:
2299
+ if rank == 0:
2300
+ print(f"\n\nTraining error: {e}")
2301
+ import traceback as _tb
2302
+ _tb.print_exc()
2303
+ print(f"\nExperiment directory: {EXPERIMENT_DIR}")
2304
+ print(f" Checkpoints saved to: {CHECKPOINT_DIR}")
2305
+ print(f" Config saved to: {EXPERIMENT_DIR / 'config.json'}")
2306
+ if writer is not None:
2307
+ writer.close()
2308
+ raise
2309
+ finally:
2310
+ if writer is not None:
2311
+ writer.close()
2312
+ # Restore stdout/stderr and close training log file (rank 0)
2313
+ if _log_file is not None:
2314
+ if _orig_stdout is not None:
2315
+ sys.stdout = _orig_stdout
2316
+ if _orig_stderr is not None:
2317
+ sys.stderr = _orig_stderr
2318
+ _log_file.close()
2319
+ if is_distributed and dist.is_initialized():
2320
+ dist.destroy_process_group()
2321
+
2322
+
2323
+ if __name__ == "__main__":
2324
+ main()