oriented-det 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. export/__init__.py +9 -0
  2. export/ort_runtime.py +67 -0
  3. export/postprocess.py +151 -0
  4. export/scripts/__init__.py +6 -0
  5. export/scripts/build_faster_rcnn_savedmodel.py +104 -0
  6. export/scripts/export_onnx.py +210 -0
  7. export/scripts/onnx_to_savedmodel.py +35 -0
  8. export/scripts/predict_savedmodel.py +94 -0
  9. export/scripts/save_predictions_tf.py +447 -0
  10. export/scripts/to_tflite.py +32 -0
  11. export/tests/test_export_onnx_optional.py +82 -0
  12. export/tests/test_export_wrappers.py +105 -0
  13. export/tests/test_faster_rcnn_export_parity.py +201 -0
  14. export/tests/test_ort_runtime.py +41 -0
  15. export/tf_serving_model.py +96 -0
  16. export/val_dataset.py +116 -0
  17. export/wrappers.py +161 -0
  18. oriented_det/__init__.py +77 -0
  19. oriented_det/cli/__init__.py +92 -0
  20. oriented_det/cli/train.py +18 -0
  21. oriented_det/configs/_base_/augmentation.json +21 -0
  22. oriented_det/configs/_base_/datasets/dota_le90.json +21 -0
  23. oriented_det/configs/_base_/fp16.json +5 -0
  24. oriented_det/configs/_base_/models/oriented_rcnn_r50.json +26 -0
  25. oriented_det/configs/_base_/models/rotated_faster_rcnn_r50.json +53 -0
  26. oriented_det/configs/_base_/models/rotated_retinanet_r50.json +30 -0
  27. oriented_det/configs/_base_/preprocessing.json +8 -0
  28. oriented_det/configs/_base_/schedules/1x.json +33 -0
  29. oriented_det/configs/config.schema.json +404 -0
  30. oriented_det/configs/oriented_rcnn/dota_le90_1x.json +121 -0
  31. oriented_det/configs/oriented_rcnn/dota_le90_3x.json +11 -0
  32. oriented_det/configs/rotated_faster_rcnn/dota_le90_1x.json +119 -0
  33. oriented_det/configs/rotated_faster_rcnn/dota_le90_3x.json +9 -0
  34. oriented_det/configs/rotated_retinanet/dota_le90_1x.json +107 -0
  35. oriented_det/configs/rotated_retinanet/dota_le90_3x.json +30 -0
  36. oriented_det/data/__init__.py +89 -0
  37. oriented_det/data/airbus_playground.py +611 -0
  38. oriented_det/data/dota.py +741 -0
  39. oriented_det/data/dota_classes.py +26 -0
  40. oriented_det/data/evaluation.py +648 -0
  41. oriented_det/data/flips.py +115 -0
  42. oriented_det/data/preprocessing.py +335 -0
  43. oriented_det/data/tiling.py +399 -0
  44. oriented_det/data/transforms.py +377 -0
  45. oriented_det/geometry/__init__.py +8 -0
  46. oriented_det/geometry/poly.py +127 -0
  47. oriented_det/geometry/qbox.py +63 -0
  48. oriented_det/geometry/rbox.py +250 -0
  49. oriented_det/geometry/transforms.py +266 -0
  50. oriented_det/models/__init__.py +36 -0
  51. oriented_det/models/backbones/__init__.py +11 -0
  52. oriented_det/models/backbones/resnet_fpn.py +79 -0
  53. oriented_det/models/backbones/utils.py +81 -0
  54. oriented_det/models/bbox_coder.py +355 -0
  55. oriented_det/models/faster_rcnn_inference.py +494 -0
  56. oriented_det/models/horizontal_roi_coder.py +155 -0
  57. oriented_det/models/oriented_rcnn.py +1256 -0
  58. oriented_det/models/oriented_roi.py +1664 -0
  59. oriented_det/models/oriented_rpn.py +2104 -0
  60. oriented_det/models/rotated_retinanet.py +1030 -0
  61. oriented_det/models/utils.py +590 -0
  62. oriented_det/ops/__init__.py +58 -0
  63. oriented_det/ops/gpu_ops.py +1109 -0
  64. oriented_det/ops/iou.py +172 -0
  65. oriented_det/ops/kfiou.py +275 -0
  66. oriented_det/ops/nms.py +202 -0
  67. oriented_det/ops/probiou.py +165 -0
  68. oriented_det/ops/rotated_ops.py +122 -0
  69. oriented_det/ops/utils.py +257 -0
  70. oriented_det/pretrained/__init__.py +23 -0
  71. oriented_det/pretrained/hub.py +249 -0
  72. oriented_det/pretrained/manifest.json +46 -0
  73. oriented_det/runtime/__init__.py +29 -0
  74. oriented_det/runtime/checkpoint.py +274 -0
  75. oriented_det/runtime/collate.py +348 -0
  76. oriented_det/runtime/inference.py +1286 -0
  77. oriented_det/train/__init__.py +102 -0
  78. oriented_det/train/config.py +872 -0
  79. oriented_det/train/engine.py +2933 -0
  80. oriented_det/train/grouped_ce.py +139 -0
  81. oriented_det/train/piecewise_schedule.py +33 -0
  82. oriented_det/train/profiler.py +287 -0
  83. oriented_det/train/utils.py +999 -0
  84. oriented_det/utils/__init__.py +31 -0
  85. oriented_det/utils/config.py +376 -0
  86. oriented_det/utils/device.py +35 -0
  87. oriented_det/utils/logging.py +163 -0
  88. oriented_det/utils/progress.py +62 -0
  89. oriented_det/utils/viz.py +181 -0
  90. oriented_det-0.1.0.dist-info/METADATA +313 -0
  91. oriented_det-0.1.0.dist-info/RECORD +115 -0
  92. oriented_det-0.1.0.dist-info/WHEEL +5 -0
  93. oriented_det-0.1.0.dist-info/entry_points.txt +2 -0
  94. oriented_det-0.1.0.dist-info/licenses/LICENSE +202 -0
  95. oriented_det-0.1.0.dist-info/top_level.txt +3 -0
  96. tools/__init__.py +1 -0
  97. tools/app.py +1284 -0
  98. tools/dataset_stats.py +389 -0
  99. tools/dota_labels_to_comma.py +132 -0
  100. tools/free_gpu.py +142 -0
  101. tools/generate_airbus_playground_csv.py +154 -0
  102. tools/image_demo.py +200 -0
  103. tools/lr_finder.py +771 -0
  104. tools/measure_sampled_riou_error.py +483 -0
  105. tools/playground_to_dota.py +290 -0
  106. tools/pretrained_download.py +49 -0
  107. tools/preview_augmentation.py +510 -0
  108. tools/publish_checkpoint.py +96 -0
  109. tools/save_predictions.py +2350 -0
  110. tools/sync_vendored_configs.py +94 -0
  111. tools/tile_dota.py +447 -0
  112. tools/train.py +2324 -0
  113. tools/train_example.py +244 -0
  114. tools/train_multi_gpu.py +367 -0
  115. tools/visualize_boxes.py +183 -0
@@ -0,0 +1,510 @@
1
+ #!/usr/bin/env python3
2
+ """Preview training augmentations from an experiment config.
3
+
4
+ Loads the train split, applies the same collate path as training (Albumentations,
5
+ random flips, resize), and writes comparison grids so you can sanity-check
6
+ augmentation.json / recipe overrides before a long run.
7
+
8
+ Usage (from odet-planes/, with oriented-det venv active):
9
+
10
+ python tools/preview_augmentation.py --config configs/rotated_faster_rcnn/airbus_planes_dota081_ft.json
11
+ python tools/preview_augmentation.py --config runs/rotated_faster_rcnn/20260603-090228/config.json --num-images 4 --variants 6
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import json
18
+ import random
19
+ import sys
20
+ from pathlib import Path
21
+ from typing import Any, Optional
22
+
23
+ import torch
24
+ from PIL import Image, ImageDraw, ImageFont
25
+
26
+ from dataclasses import fields
27
+
28
+ from oriented_det.data import AirbusPlaygroundCSVDataset, build_dota_split_dataset, dota_dataset_class_names
29
+ from oriented_det.geometry.rbox import RBox
30
+ from oriented_det.runtime.collate import create_collate_fn, create_train_augmentation
31
+ from oriented_det.train.config import (
32
+ AugmentationConfig,
33
+ CheckpointConfig,
34
+ DataLoaderConfig,
35
+ DatasetConfig,
36
+ EvaluationConfig,
37
+ LossConfig,
38
+ ModelConfig,
39
+ PreprocessingConfig,
40
+ ProductionConfig,
41
+ TensorboardConfig,
42
+ TrainingConfig,
43
+ TrainingExperimentConfig,
44
+ _normalize_legacy_loss_type,
45
+ )
46
+ from oriented_det.utils import viz
47
+ from oriented_det.utils.config import load_config
48
+
49
+
50
+ def _repo_root() -> Path:
51
+ return Path(__file__).resolve().parent.parent
52
+
53
+
54
+ _SECTION_CLASSES = {
55
+ "dataset": DatasetConfig,
56
+ "augmentation": AugmentationConfig,
57
+ "data_loader": DataLoaderConfig,
58
+ "model": ModelConfig,
59
+ "training": TrainingConfig,
60
+ "evaluation": EvaluationConfig,
61
+ "production": ProductionConfig,
62
+ "checkpoint": CheckpointConfig,
63
+ "loss": LossConfig,
64
+ "tensorboard": TensorboardConfig,
65
+ "preprocessing": PreprocessingConfig,
66
+ }
67
+
68
+
69
+ def _filter_section(section: dict, dc_cls: type) -> dict:
70
+ valid = {f.name for f in fields(dc_cls)}
71
+ return {k: v for k, v in section.items() if k in valid}
72
+
73
+
74
+ def load_training_config(path: Path) -> TrainingExperimentConfig:
75
+ """Load config; ignore unknown keys in sections (preview-only lenient mode)."""
76
+ try:
77
+ return TrainingExperimentConfig.load(path)
78
+ except ValueError as exc:
79
+ if "Unknown key" not in str(exc):
80
+ raise
81
+
82
+ frozen_cfg = load_config(path)
83
+ config_dict = frozen_cfg.to_dict()
84
+ _normalize_legacy_loss_type(config_dict)
85
+
86
+ for key in list(config_dict):
87
+ if config_dict[key] is None and key in _SECTION_CLASSES:
88
+ del config_dict[key]
89
+
90
+ root_valid = {f.name for f in fields(TrainingExperimentConfig)}
91
+ root = {k: v for k, v in config_dict.items() if k in root_valid}
92
+
93
+ for section, dc_cls in _SECTION_CLASSES.items():
94
+ if section not in config_dict or config_dict[section] is None:
95
+ continue
96
+ root[section] = dc_cls(**_filter_section(config_dict[section], dc_cls))
97
+
98
+ return TrainingExperimentConfig(**root)
99
+
100
+
101
+ def resolve_class_map(
102
+ config: TrainingExperimentConfig, dataset
103
+ ) -> tuple[dict[str, int], list[str]]:
104
+ if config.class_map:
105
+ names = list(config.class_names or [])
106
+ return dict(config.class_map), names
107
+ if hasattr(dataset, "get_class_names"):
108
+ names = dataset.get_class_names()
109
+ else:
110
+ names = dota_dataset_class_names(dataset)
111
+ class_map = {name: i + 1 for i, name in enumerate(names)}
112
+ return class_map, names
113
+
114
+
115
+ def resolve_config_path(config_arg: str) -> Path:
116
+ raw = Path(config_arg)
117
+ if raw.is_absolute() and raw.exists():
118
+ return raw
119
+ for root in (_repo_root(), Path.cwd()):
120
+ candidate = root / raw
121
+ if candidate.exists():
122
+ return candidate.resolve()
123
+ raise FileNotFoundError(f"Config not found: {config_arg}")
124
+
125
+
126
+ def build_dataset_from_config(config: TrainingExperimentConfig, split: str):
127
+ """Build train or val dataset (same logic as tools/train.py / dataset_stats.py)."""
128
+ dataset_format = getattr(config.dataset, "format", "dota").lower()
129
+ filter_empty = getattr(config.dataset, "filter_empty_gt", False) if split == "train" else False
130
+
131
+ if dataset_format == "airbus_playground":
132
+ if config.dataset.annotations_file is None or config.dataset.split_file is None:
133
+ raise ValueError(
134
+ "Airbus Playground requires dataset.annotations_file and dataset.split_file."
135
+ )
136
+ return AirbusPlaygroundCSVDataset(
137
+ data_root=config.dataset.data_root,
138
+ split=split,
139
+ annotations_file=config.dataset.annotations_file,
140
+ split_file=config.dataset.split_file,
141
+ val_split_id=config.dataset.val_split_id,
142
+ difficult_strategy=config.dataset.difficult_strategy,
143
+ allowed_classes=config.dataset.allowed_classes,
144
+ ignore_labels=config.dataset.ignore_labels,
145
+ map_labels=config.dataset.map_labels,
146
+ filter_empty_gt=filter_empty,
147
+ )
148
+
149
+ if not config.dataset.has_dota_tiles_config():
150
+ raise ValueError(
151
+ "DOTA format requires dataset.train_tiles_dir(s) and dataset.val_tiles_dir(s)."
152
+ )
153
+ tile_roots = (
154
+ config.dataset.get_train_tile_roots()
155
+ if split == "train"
156
+ else config.dataset.get_val_tile_roots()
157
+ )
158
+ same_folder = getattr(config.dataset, "same_folder", False)
159
+ return build_dota_split_dataset(
160
+ tile_roots,
161
+ split=split,
162
+ same_folder=same_folder,
163
+ difficult_strategy=config.dataset.difficult_strategy,
164
+ allowed_classes=config.dataset.allowed_classes,
165
+ ignore_labels=config.dataset.ignore_labels,
166
+ filter_empty_gt=filter_empty,
167
+ )
168
+
169
+
170
+ def get_resize_and_flips(config: TrainingExperimentConfig) -> tuple[str, tuple[int, int], bool, bool, bool, int]:
171
+ from oriented_det.data.preprocessing import parse_canvas_size
172
+
173
+ prep = getattr(config, "preprocessing", None)
174
+ if prep is not None:
175
+ resize_mode = getattr(prep, "resize_mode", "fixed")
176
+ ts = getattr(prep, "target_size", [1024, 1024])
177
+ resize_to = parse_canvas_size(resize_mode, ts)
178
+ else:
179
+ resize_mode = "fixed"
180
+ resize_to = (1024, 1024)
181
+ flip_h = getattr(prep, "enable_flip_horizontal", True) if prep is not None else True
182
+ flip_v = getattr(prep, "enable_flip_vertical", True) if prep is not None else True
183
+ flip_d = getattr(prep, "enable_flip_diagonal", False) if prep is not None else False
184
+ pad_div = getattr(prep, "pad_size_divisor", 32) if prep is not None else 32
185
+ return resize_mode, resize_to, flip_h, flip_v, flip_d, pad_div
186
+
187
+
188
+ def build_train_augmentation_from_config(config: TrainingExperimentConfig):
189
+ if not config.enable_albumentation:
190
+ return None
191
+ aug = config.augmentation
192
+ return create_train_augmentation(
193
+ brightness_limit=aug.brightness_limit,
194
+ contrast_limit=aug.contrast_limit,
195
+ gamma_limit=aug.gamma_limit,
196
+ gauss_noise_var_limit=aug.gauss_noise_var_limit,
197
+ blur_limit=aug.blur_limit,
198
+ clahe_clip_limit=aug.clahe_clip_limit,
199
+ p_brightness_contrast=aug.p_brightness_contrast,
200
+ p_gamma=aug.p_gamma,
201
+ p_noise=aug.p_noise,
202
+ p_blur=aug.p_blur,
203
+ p_clahe=aug.p_clahe,
204
+ )
205
+
206
+
207
+ def make_collate_fn(
208
+ config: TrainingExperimentConfig,
209
+ *,
210
+ augmentation: Any,
211
+ enable_flip_horizontal: bool,
212
+ enable_flip_vertical: bool,
213
+ enable_flip_diagonal: bool,
214
+ ):
215
+ resize_mode, resize_to, _, _, _, pad_div = get_resize_and_flips(config)
216
+ prep = getattr(config, "preprocessing", None)
217
+ norm_mean = getattr(prep, "normalize_mean", None) if prep is not None else None
218
+ norm_std = getattr(prep, "normalize_std", None) if prep is not None else None
219
+ return create_collate_fn(
220
+ config.class_map,
221
+ augmentation=augmentation,
222
+ normalize=False,
223
+ resize_mode=resize_mode,
224
+ resize_to=resize_to,
225
+ pad_size_divisor=pad_div,
226
+ enable_flip_horizontal=enable_flip_horizontal,
227
+ enable_flip_vertical=enable_flip_vertical,
228
+ enable_flip_diagonal=enable_flip_diagonal,
229
+ normalize_mean=norm_mean,
230
+ normalize_std=norm_std,
231
+ difficult_strategy=getattr(config.dataset, "difficult_strategy", "drop"),
232
+ )
233
+
234
+
235
+ def tensor_to_pil(image_tensor: torch.Tensor) -> Image.Image:
236
+ t = image_tensor.detach().cpu().float().clamp(0, 1)
237
+ if t.dim() != 3 or t.shape[0] != 3:
238
+ raise ValueError(f"Expected CHW tensor, got shape {tuple(t.shape)}")
239
+ arr = (t.permute(1, 2, 0).numpy() * 255.0).astype("uint8")
240
+ return Image.fromarray(arr, mode="RGB")
241
+
242
+
243
+ def draw_targets(
244
+ image: Image.Image,
245
+ target: dict,
246
+ class_names: list[str],
247
+ title: str,
248
+ ) -> Image.Image:
249
+ image = image.copy()
250
+ rboxes = target.get("rboxes")
251
+ labels = target.get("labels")
252
+ if rboxes is None or rboxes.numel() == 0:
253
+ specs = []
254
+ box_objs = []
255
+ else:
256
+ box_objs = [
257
+ RBox(
258
+ float(row[0]),
259
+ float(row[1]),
260
+ float(row[2]),
261
+ float(row[3]),
262
+ float(row[4]),
263
+ )
264
+ for row in rboxes
265
+ ]
266
+ specs = [viz.DrawingSpec(outline=(0, 255, 0), width=2) for _ in box_objs]
267
+ image = viz.draw_boxes(image, box_objs, specs=specs)
268
+ if labels is not None and len(labels) == len(box_objs):
269
+ label_texts = []
270
+ for lid in labels.tolist():
271
+ idx = int(lid) - 1
272
+ if 0 <= idx < len(class_names):
273
+ label_texts.append(class_names[idx])
274
+ else:
275
+ label_texts.append(str(lid))
276
+ polys = [b.to_polygon().points for b in box_objs]
277
+ for poly, text in zip(polys, label_texts):
278
+ xs = [p[0] for p in poly]
279
+ ys = [p[1] for p in poly]
280
+ x, y = min(xs), min(ys)
281
+ draw = ImageDraw.Draw(image)
282
+ draw.text((x, y - 12), text, fill=(255, 255, 0))
283
+
284
+ draw = ImageDraw.Draw(image)
285
+ try:
286
+ font = ImageFont.load_default()
287
+ except Exception:
288
+ font = None
289
+ draw.rectangle([0, 0, image.width, 22], fill=(0, 0, 0))
290
+ draw.text((4, 4), title, fill=(255, 255, 255), font=font)
291
+ return image
292
+
293
+
294
+ def stitch_row(panels: list[Image.Image], gap: int = 8) -> Image.Image:
295
+ h = max(im.height for im in panels)
296
+ w = sum(im.width for im in panels) + gap * (len(panels) - 1)
297
+ canvas = Image.new("RGB", (w, h), (32, 32, 32))
298
+ x = 0
299
+ for im in panels:
300
+ canvas.paste(im, (x, 0))
301
+ x += im.width + gap
302
+ return canvas
303
+
304
+
305
+ def stitch_grid(rows: list[Image.Image], gap: int = 12) -> Image.Image:
306
+ w = max(r.width for r in rows)
307
+ h = sum(r.height for r in rows) + gap * (len(rows) - 1)
308
+ canvas = Image.new("RGB", (w, h), (24, 24, 24))
309
+ y = 0
310
+ for row in rows:
311
+ canvas.paste(row, (0, y))
312
+ y += row.height + gap
313
+ return canvas
314
+
315
+
316
+ def print_augmentation_summary(config: TrainingExperimentConfig, resize_to: tuple[int, int]) -> None:
317
+ prep = getattr(config, "preprocessing", None)
318
+ resize_mode, _, flip_h, flip_v, flip_d, _ = get_resize_and_flips(config)
319
+ print(f" enable_albumentation: {config.enable_albumentation}")
320
+ print(f" resize_mode: {resize_mode}")
321
+ print(f" resize_to (H×W): {resize_to[0]}×{resize_to[1]}")
322
+ print(f" flips: horizontal={flip_h}, vertical={flip_v}, diagonal={flip_d}")
323
+ if config.enable_albumentation:
324
+ aug = config.augmentation
325
+ print(" augmentation:")
326
+ for key in (
327
+ "brightness_limit",
328
+ "contrast_limit",
329
+ "gamma_limit",
330
+ "gauss_noise_var_limit",
331
+ "blur_limit",
332
+ "clahe_clip_limit",
333
+ "p_brightness_contrast",
334
+ "p_gamma",
335
+ "p_noise",
336
+ "p_blur",
337
+ "p_clahe",
338
+ ):
339
+ print(f" {key}: {getattr(aug, key)}")
340
+
341
+
342
+ def main() -> None:
343
+ parser = argparse.ArgumentParser(description=__doc__)
344
+ parser.add_argument(
345
+ "--config",
346
+ required=True,
347
+ help="Training recipe or resolved run config.json",
348
+ )
349
+ parser.add_argument(
350
+ "--output-dir",
351
+ type=Path,
352
+ default=None,
353
+ help="Output directory (default: <repo>/previews/augmentation/<config_stem>)",
354
+ )
355
+ parser.add_argument(
356
+ "--split",
357
+ choices=("train", "val"),
358
+ default="train",
359
+ help="Dataset split (default: train)",
360
+ )
361
+ parser.add_argument(
362
+ "--num-images",
363
+ type=int,
364
+ default=3,
365
+ help="Number of distinct tiles to preview (default: 3)",
366
+ )
367
+ parser.add_argument(
368
+ "--variants",
369
+ type=int,
370
+ default=4,
371
+ help="Random augmented variants per tile (default: 4)",
372
+ )
373
+ parser.add_argument(
374
+ "--seed",
375
+ type=int,
376
+ default=42,
377
+ help="RNG seed for tile and augmentation sampling",
378
+ )
379
+ parser.add_argument(
380
+ "--indices",
381
+ type=str,
382
+ default=None,
383
+ help="Comma-separated dataset indices (overrides random sampling)",
384
+ )
385
+ parser.add_argument(
386
+ "--include-albumentations-only",
387
+ action="store_true",
388
+ help="Add a column with Albumentations only (no random flips)",
389
+ )
390
+ args = parser.parse_args()
391
+
392
+ config_path = resolve_config_path(args.config)
393
+ print(f"Loading config: {config_path}")
394
+ config = load_training_config(config_path)
395
+
396
+ dataset = build_dataset_from_config(config, args.split)
397
+ class_map, class_names = resolve_class_map(config, dataset)
398
+ config.class_map = class_map
399
+ config.class_names = class_names
400
+ if len(dataset) == 0:
401
+ print("Dataset is empty.", file=sys.stderr)
402
+ sys.exit(1)
403
+
404
+ resize_mode, resize_to, flip_h, flip_v, flip_d, _ = get_resize_and_flips(config)
405
+ print(f"Building {args.split} dataset ({len(dataset)} samples)")
406
+ print_augmentation_summary(config, resize_to)
407
+
408
+ train_aug = build_train_augmentation_from_config(config)
409
+ collate_baseline = make_collate_fn(
410
+ config,
411
+ augmentation=None,
412
+ enable_flip_horizontal=False,
413
+ enable_flip_vertical=False,
414
+ enable_flip_diagonal=False,
415
+ )
416
+ collate_train = make_collate_fn(
417
+ config,
418
+ augmentation=train_aug,
419
+ enable_flip_horizontal=flip_h,
420
+ enable_flip_vertical=flip_v,
421
+ enable_flip_diagonal=flip_d,
422
+ )
423
+ collate_albu_only = None
424
+ if args.include_albumentations_only and train_aug is not None:
425
+ collate_albu_only = make_collate_fn(
426
+ config,
427
+ augmentation=train_aug,
428
+ enable_flip_horizontal=False,
429
+ enable_flip_vertical=False,
430
+ enable_flip_diagonal=False,
431
+ )
432
+
433
+ if args.indices:
434
+ indices = [int(x.strip()) for x in args.indices.split(",") if x.strip()]
435
+ else:
436
+ rng = random.Random(args.seed)
437
+ k = min(args.num_images, len(dataset))
438
+ indices = rng.sample(range(len(dataset)), k=k)
439
+
440
+ out_dir = args.output_dir
441
+ if out_dir is None:
442
+ out_dir = _repo_root() / "previews" / "augmentation" / config_path.stem
443
+ out_dir = out_dir.resolve()
444
+ out_dir.mkdir(parents=True, exist_ok=True)
445
+
446
+ meta = {
447
+ "config": str(config_path),
448
+ "split": args.split,
449
+ "indices": indices,
450
+ "seed": args.seed,
451
+ "variants": args.variants,
452
+ "enable_albumentation": config.enable_albumentation,
453
+ }
454
+ (out_dir / "meta.json").write_text(json.dumps(meta, indent=2), encoding="utf-8")
455
+
456
+ grid_rows: list[Image.Image] = []
457
+ for idx in indices:
458
+ sample = dataset[idx]
459
+ stem = Path(sample.image_path).stem
460
+ panels: list[Image.Image] = []
461
+
462
+ images, targets = collate_baseline([sample])
463
+ panels.append(
464
+ draw_targets(
465
+ tensor_to_pil(images[0]),
466
+ targets[0],
467
+ class_names,
468
+ "baseline (resize, no aug)",
469
+ )
470
+ )
471
+
472
+ if collate_albu_only is not None:
473
+ images, targets = collate_albu_only([sample])
474
+ panels.append(
475
+ draw_targets(
476
+ tensor_to_pil(images[0]),
477
+ targets[0],
478
+ class_names,
479
+ "albumentations only",
480
+ )
481
+ )
482
+
483
+ label_prefix = "train aug" if train_aug is not None else "train flip"
484
+ for v in range(args.variants):
485
+ random.seed(args.seed + idx * 1000 + v)
486
+ images, targets = collate_train([sample])
487
+ panels.append(
488
+ draw_targets(
489
+ tensor_to_pil(images[0]),
490
+ targets[0],
491
+ class_names,
492
+ f"{label_prefix} #{v + 1}",
493
+ )
494
+ )
495
+
496
+ row = stitch_row(panels)
497
+ row_path = out_dir / f"{idx:05d}_{stem}.png"
498
+ row.save(row_path)
499
+ grid_rows.append(row)
500
+ print(f" wrote {row_path.name}")
501
+
502
+ combined = stitch_grid(grid_rows)
503
+ combined_path = out_dir / "grid_all.png"
504
+ combined.save(combined_path)
505
+ print(f"\nCombined grid: {combined_path}")
506
+ print(f"Output directory: {out_dir}")
507
+
508
+
509
+ if __name__ == "__main__":
510
+ main()
@@ -0,0 +1,96 @@
1
+ #!/usr/bin/env python3
2
+ """Publish a checkpoint for Hub distribution (MMDet-style content hash suffix).
3
+
4
+ Strips optimizer state, saves CPU tensors, appends the first 8 hex chars of SHA-256.
5
+
6
+ Example::
7
+
8
+ python tools/publish_checkpoint.py \\
9
+ runs/rotated_retinanet/20260611-101135/checkpoints/best_mAP_0.70.pth \\
10
+ pretrained/rotated_retinanet_r50_fpn_dota_le90_1x
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import hashlib
17
+ import sys
18
+ from pathlib import Path
19
+ from typing import Iterable, Optional, Sequence
20
+
21
+ import torch
22
+
23
+
24
+ def _sha256_file(path: Path) -> str:
25
+ digest = hashlib.sha256()
26
+ with path.open("rb") as f:
27
+ for chunk in iter(lambda: f.read(1 << 20), b""):
28
+ digest.update(chunk)
29
+ return digest.hexdigest()
30
+
31
+
32
+ def publish_checkpoint(
33
+ in_file: Path,
34
+ out_stem: Path,
35
+ *,
36
+ save_keys: Optional[Sequence[str]] = None,
37
+ ) -> Path:
38
+ """Write a published checkpoint; return final path with ``-{hash8}.pth`` suffix."""
39
+ in_file = in_file.expanduser().resolve()
40
+ if not in_file.is_file():
41
+ raise FileNotFoundError(f"Input checkpoint not found: {in_file}")
42
+
43
+ checkpoint = torch.load(in_file, map_location="cpu", weights_only=False)
44
+ if not isinstance(checkpoint, dict):
45
+ raise TypeError(f"Expected dict checkpoint, got {type(checkpoint).__name__}")
46
+
47
+ if save_keys is not None:
48
+ keys = list(save_keys)
49
+ for key in list(checkpoint):
50
+ if key not in keys:
51
+ checkpoint.pop(key, None)
52
+ else:
53
+ for key in ("optimizer", "ema_state_dict", "lr_scheduler"):
54
+ checkpoint.pop(key, None)
55
+
56
+ out_stem = out_stem.expanduser().resolve()
57
+ if out_stem.suffix == ".pth":
58
+ out_stem = out_stem.with_suffix("")
59
+
60
+ out_stem.parent.mkdir(parents=True, exist_ok=True)
61
+ temp_path = out_stem.parent / f"{out_stem.name}.pth"
62
+ torch.save(checkpoint, temp_path, _use_new_zipfile_serialization=False)
63
+
64
+ sha = _sha256_file(temp_path)
65
+ final_path = out_stem.parent / f"{out_stem.name}-{sha[:8]}.pth"
66
+ if final_path.exists():
67
+ final_path.unlink()
68
+ temp_path.rename(final_path)
69
+ return final_path
70
+
71
+
72
+ def main(argv: Optional[Iterable[str]] = None) -> int:
73
+ parser = argparse.ArgumentParser(description="Publish a checkpoint with SHA256[:8] suffix.")
74
+ parser.add_argument("in_file", type=Path, help="Training checkpoint (.pth)")
75
+ parser.add_argument(
76
+ "out_stem",
77
+ type=Path,
78
+ help="Output path without hash suffix (e.g. pretrained/foo_bar)",
79
+ )
80
+ parser.add_argument(
81
+ "--save-keys",
82
+ nargs="+",
83
+ default=None,
84
+ help="Keep only these top-level keys (default: drop optimizer only)",
85
+ )
86
+ args = parser.parse_args(list(argv) if argv is not None else None)
87
+
88
+ final = publish_checkpoint(args.in_file, args.out_stem, save_keys=args.save_keys)
89
+ sha = _sha256_file(final)
90
+ print(f"Published: {final}")
91
+ print(f"sha256: {sha}")
92
+ return 0
93
+
94
+
95
+ if __name__ == "__main__":
96
+ sys.exit(main())