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