oriented-det 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. export/__init__.py +9 -0
  2. export/ort_runtime.py +67 -0
  3. export/postprocess.py +151 -0
  4. export/scripts/__init__.py +6 -0
  5. export/scripts/build_faster_rcnn_savedmodel.py +104 -0
  6. export/scripts/export_onnx.py +210 -0
  7. export/scripts/onnx_to_savedmodel.py +35 -0
  8. export/scripts/predict_savedmodel.py +94 -0
  9. export/scripts/save_predictions_tf.py +447 -0
  10. export/scripts/to_tflite.py +32 -0
  11. export/tests/test_export_onnx_optional.py +82 -0
  12. export/tests/test_export_wrappers.py +105 -0
  13. export/tests/test_faster_rcnn_export_parity.py +201 -0
  14. export/tests/test_ort_runtime.py +41 -0
  15. export/tf_serving_model.py +96 -0
  16. export/val_dataset.py +116 -0
  17. export/wrappers.py +161 -0
  18. oriented_det/__init__.py +77 -0
  19. oriented_det/cli/__init__.py +92 -0
  20. oriented_det/cli/train.py +18 -0
  21. oriented_det/configs/_base_/augmentation.json +21 -0
  22. oriented_det/configs/_base_/datasets/dota_le90.json +21 -0
  23. oriented_det/configs/_base_/fp16.json +5 -0
  24. oriented_det/configs/_base_/models/oriented_rcnn_r50.json +26 -0
  25. oriented_det/configs/_base_/models/rotated_faster_rcnn_r50.json +53 -0
  26. oriented_det/configs/_base_/models/rotated_retinanet_r50.json +30 -0
  27. oriented_det/configs/_base_/preprocessing.json +8 -0
  28. oriented_det/configs/_base_/schedules/1x.json +33 -0
  29. oriented_det/configs/config.schema.json +404 -0
  30. oriented_det/configs/oriented_rcnn/dota_le90_1x.json +121 -0
  31. oriented_det/configs/oriented_rcnn/dota_le90_3x.json +11 -0
  32. oriented_det/configs/rotated_faster_rcnn/dota_le90_1x.json +119 -0
  33. oriented_det/configs/rotated_faster_rcnn/dota_le90_3x.json +9 -0
  34. oriented_det/configs/rotated_retinanet/dota_le90_1x.json +107 -0
  35. oriented_det/configs/rotated_retinanet/dota_le90_3x.json +30 -0
  36. oriented_det/data/__init__.py +89 -0
  37. oriented_det/data/airbus_playground.py +611 -0
  38. oriented_det/data/dota.py +741 -0
  39. oriented_det/data/dota_classes.py +26 -0
  40. oriented_det/data/evaluation.py +648 -0
  41. oriented_det/data/flips.py +115 -0
  42. oriented_det/data/preprocessing.py +335 -0
  43. oriented_det/data/tiling.py +399 -0
  44. oriented_det/data/transforms.py +377 -0
  45. oriented_det/geometry/__init__.py +8 -0
  46. oriented_det/geometry/poly.py +127 -0
  47. oriented_det/geometry/qbox.py +63 -0
  48. oriented_det/geometry/rbox.py +250 -0
  49. oriented_det/geometry/transforms.py +266 -0
  50. oriented_det/models/__init__.py +36 -0
  51. oriented_det/models/backbones/__init__.py +11 -0
  52. oriented_det/models/backbones/resnet_fpn.py +79 -0
  53. oriented_det/models/backbones/utils.py +81 -0
  54. oriented_det/models/bbox_coder.py +355 -0
  55. oriented_det/models/faster_rcnn_inference.py +494 -0
  56. oriented_det/models/horizontal_roi_coder.py +155 -0
  57. oriented_det/models/oriented_rcnn.py +1256 -0
  58. oriented_det/models/oriented_roi.py +1664 -0
  59. oriented_det/models/oriented_rpn.py +2104 -0
  60. oriented_det/models/rotated_retinanet.py +1030 -0
  61. oriented_det/models/utils.py +590 -0
  62. oriented_det/ops/__init__.py +58 -0
  63. oriented_det/ops/gpu_ops.py +1109 -0
  64. oriented_det/ops/iou.py +172 -0
  65. oriented_det/ops/kfiou.py +275 -0
  66. oriented_det/ops/nms.py +202 -0
  67. oriented_det/ops/probiou.py +165 -0
  68. oriented_det/ops/rotated_ops.py +122 -0
  69. oriented_det/ops/utils.py +257 -0
  70. oriented_det/pretrained/__init__.py +23 -0
  71. oriented_det/pretrained/hub.py +249 -0
  72. oriented_det/pretrained/manifest.json +46 -0
  73. oriented_det/runtime/__init__.py +29 -0
  74. oriented_det/runtime/checkpoint.py +274 -0
  75. oriented_det/runtime/collate.py +348 -0
  76. oriented_det/runtime/inference.py +1286 -0
  77. oriented_det/train/__init__.py +102 -0
  78. oriented_det/train/config.py +872 -0
  79. oriented_det/train/engine.py +2933 -0
  80. oriented_det/train/grouped_ce.py +139 -0
  81. oriented_det/train/piecewise_schedule.py +33 -0
  82. oriented_det/train/profiler.py +287 -0
  83. oriented_det/train/utils.py +999 -0
  84. oriented_det/utils/__init__.py +31 -0
  85. oriented_det/utils/config.py +376 -0
  86. oriented_det/utils/device.py +35 -0
  87. oriented_det/utils/logging.py +163 -0
  88. oriented_det/utils/progress.py +62 -0
  89. oriented_det/utils/viz.py +181 -0
  90. oriented_det-0.1.0.dist-info/METADATA +313 -0
  91. oriented_det-0.1.0.dist-info/RECORD +115 -0
  92. oriented_det-0.1.0.dist-info/WHEEL +5 -0
  93. oriented_det-0.1.0.dist-info/entry_points.txt +2 -0
  94. oriented_det-0.1.0.dist-info/licenses/LICENSE +202 -0
  95. oriented_det-0.1.0.dist-info/top_level.txt +3 -0
  96. tools/__init__.py +1 -0
  97. tools/app.py +1284 -0
  98. tools/dataset_stats.py +389 -0
  99. tools/dota_labels_to_comma.py +132 -0
  100. tools/free_gpu.py +142 -0
  101. tools/generate_airbus_playground_csv.py +154 -0
  102. tools/image_demo.py +200 -0
  103. tools/lr_finder.py +771 -0
  104. tools/measure_sampled_riou_error.py +483 -0
  105. tools/playground_to_dota.py +290 -0
  106. tools/pretrained_download.py +49 -0
  107. tools/preview_augmentation.py +510 -0
  108. tools/publish_checkpoint.py +96 -0
  109. tools/save_predictions.py +2350 -0
  110. tools/sync_vendored_configs.py +94 -0
  111. tools/tile_dota.py +447 -0
  112. tools/train.py +2324 -0
  113. tools/train_example.py +244 -0
  114. tools/train_multi_gpu.py +367 -0
  115. tools/visualize_boxes.py +183 -0
tools/app.py ADDED
@@ -0,0 +1,1284 @@
1
+ #!/usr/bin/env python
2
+ """
3
+ Gradio app to browse inference results (predictions mode) or explore a DOTA
4
+ dataset without predictions (dataset mode). Supports tiled and original
5
+ DOTA directory layouts.
6
+ """
7
+
8
+ import os
9
+ import json
10
+ import argparse
11
+ import tempfile
12
+ from pathlib import Path
13
+ from typing import Any, Dict, List, Tuple, Optional
14
+
15
+ # Patch Gradio to handle schema errors
16
+ try:
17
+ from gradio_client import utils as gradio_client_utils
18
+
19
+ # Patch _json_schema_to_python_type to handle boolean schemas
20
+ original_json_schema = gradio_client_utils._json_schema_to_python_type
21
+
22
+ def patched_json_schema(schema, defs=None):
23
+ """Patched version that handles boolean and invalid schemas"""
24
+ if isinstance(schema, bool):
25
+ return "Any"
26
+ if not isinstance(schema, dict):
27
+ return "Any"
28
+ try:
29
+ return original_json_schema(schema, defs)
30
+ except Exception:
31
+ return "Any"
32
+
33
+ gradio_client_utils._json_schema_to_python_type = patched_json_schema
34
+
35
+ # Also patch get_type
36
+ if hasattr(gradio_client_utils, 'get_type'):
37
+ original_get_type = gradio_client_utils.get_type
38
+
39
+ def patched_get_type(schema):
40
+ if isinstance(schema, bool):
41
+ return "Any"
42
+ if not isinstance(schema, dict):
43
+ return "Any"
44
+ try:
45
+ return original_get_type(schema)
46
+ except Exception:
47
+ return "Any"
48
+
49
+ gradio_client_utils.get_type = patched_get_type
50
+ except Exception:
51
+ pass # If patching fails, continue anyway
52
+
53
+ import gradio as gr
54
+ import cv2
55
+ import numpy as np
56
+ from PIL import Image
57
+
58
+ # Add project root to path
59
+ import sys
60
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
61
+
62
+ from oriented_det.geometry import RBox
63
+ from oriented_det.data import DOTAAnnotation
64
+
65
+
66
+ # Image extensions to scan in dataset mode
67
+ _IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.bmp', '.tif', '.tiff'}
68
+
69
+
70
+ def detect_data_root() -> str:
71
+ """Detect the data root directory - placeholder for now."""
72
+ # Try to detect from common locations or use environment variable
73
+ data_root = os.environ.get('DOTA_DATA_ROOT', '/mnt/data/share/DOTA-v2.0')
74
+ return data_root
75
+
76
+
77
+ def list_dota_images(data_root: str, tiles_dir: Optional[str] = None) -> List[Dict]:
78
+ """Build a list of image entries by scanning a DOTA dataset directory.
79
+
80
+ Supports both tiled layout (e.g. train/tiles_1024/images) and flat layout
81
+ (e.g. train/images or train/ with images directly inside).
82
+
83
+ Args:
84
+ data_root: Root path of the dataset (e.g. /path/to/dota).
85
+ tiles_dir: Optional subpath (e.g. 'train', 'val', 'train/tiles_1024').
86
+ If None, scans data_root directly.
87
+
88
+ Returns:
89
+ List of dicts with keys: image_path (relative to data_root), and
90
+ optionally image_width, image_height (left unset; filled lazily in get_images).
91
+ """
92
+ root = Path(data_root)
93
+ base = root if not tiles_dir else root / tiles_dir
94
+ images_dir = base / "images" if (base / "images").is_dir() else base
95
+ if not images_dir.is_dir():
96
+ raise FileNotFoundError(f"Images directory not found: {images_dir}")
97
+
98
+ entries = []
99
+ for path in sorted(images_dir.iterdir()):
100
+ if path.is_file() and path.suffix.lower() in _IMAGE_EXTENSIONS:
101
+ try:
102
+ rel = path.relative_to(root)
103
+ except ValueError:
104
+ rel = path
105
+ entries.append({
106
+ "image_path": str(rel).replace(os.sep, '/'),
107
+ "predictions": [],
108
+ })
109
+ return entries
110
+
111
+
112
+ def format_stats_markdown(metadata: Dict, result: Optional[Dict], class_names: Optional[List[str]] = None) -> str:
113
+ """Format diagnostics (global) and per-image stats as markdown for the Stats tab."""
114
+ class_names = class_names or []
115
+ lines = []
116
+ diag = metadata.get("diagnostics")
117
+ if diag:
118
+ lines.append("## Global diagnostics")
119
+ if isinstance(diag.get('mAP'), (int, float)):
120
+ lines.append(f"- **mAP** (IoU={diag.get('iou_threshold', 0.5)}): {diag['mAP']:.4f}")
121
+ else:
122
+ lines.append(f"- **mAP**: {diag.get('mAP_error', 'N/A')}")
123
+ lines.append(f"- Pipeline: raw → after threshold → after NMS: **{diag.get('total_raw', 0)}** → **{diag.get('total_after_threshold', 0)}** → **{diag.get('total_after_nms', 0)}**")
124
+ if diag.get('score_min') is not None:
125
+ lines.append(f"- Score (all images): min={diag['score_min']:.4f} max={diag['score_max']:.4f} mean={diag['score_mean']:.4f}")
126
+ if diag.get('class_aps'):
127
+ lines.append("\n**Per-class AP:**")
128
+ for cname, ap in sorted(diag["class_aps"].items()):
129
+ lines.append(f" - {cname}: {ap:.4f}")
130
+ best_f1 = metadata.get("best_threshold_f1") or metadata.get("best_threshold_f2")
131
+ if isinstance(best_f1, dict) and best_f1:
132
+ lines.append("\n**Best threshold by F1:**")
133
+ lines.append(
134
+ f" - threshold={best_f1.get('threshold', 0.0):.4f}, "
135
+ f"precision={best_f1.get('precision', 0.0):.4f}, "
136
+ f"recall={best_f1.get('recall', 0.0):.4f}, "
137
+ f"F1={best_f1.get('f1', 0.0):.4f}"
138
+ )
139
+ lines.append("")
140
+ if result and result.get("stats"):
141
+ st = result["stats"]
142
+ lines.append("## This image")
143
+ lines.append(f"- Raw → after threshold → after NMS: **{st.get('num_raw', 0)}** → **{st.get('num_after_threshold', 0)}** → **{st.get('num_after_nms', 0)}**")
144
+ if st.get("score_min") is not None:
145
+ lines.append(f"- Score: min={st['score_min']:.4f} max={st['score_max']:.4f} mean={st['score_mean']:.4f}")
146
+ def _class_label(k, names):
147
+ ki = int(k) if isinstance(k, str) and str(k).lstrip("-").isdigit() else (k if isinstance(k, int) else -1)
148
+ return names[ki] if isinstance(ki, int) and 0 <= ki < len(names) else f"class_{k}"
149
+
150
+ if st.get("per_class_raw"):
151
+ items = st["per_class_raw"].items()
152
+ sorted_items = sorted(items, key=lambda kv: (0, kv[0]) if isinstance(kv[0], int) else (1, str(kv[0])))
153
+ lines.append("\n**Per-class (raw):** " + ", ".join(f"{_class_label(k, class_names)}:{v}" for k, v in sorted_items))
154
+ if st.get("per_class_final"):
155
+ items = st["per_class_final"].items()
156
+ sorted_items = sorted(items, key=lambda kv: (0, kv[0]) if isinstance(kv[0], int) else (1, str(kv[0])))
157
+ lines.append("\n**Per-class (after NMS):** " + ", ".join(f"{_class_label(k, class_names)}:{v}" for k, v in sorted_items))
158
+ if not lines:
159
+ return (
160
+ "No diagnostics in this predictions directory. "
161
+ "`make preds` writes inference only; run **`make metrics`** on that output folder "
162
+ "(or **`make metrics`**) to compute mAP / PR and per-image stats, then reopen the viewer."
163
+ )
164
+ return "\n".join(lines)
165
+
166
+
167
+ def load_dota_annotations(txt_file: str) -> Tuple[np.ndarray, List[str]]:
168
+ """Load DOTA format annotations from txt file
169
+ Returns: (boxes array, class_names list)"""
170
+ rboxes = []
171
+ class_names = []
172
+ if not os.path.exists(txt_file):
173
+ return np.array([]).reshape(0, 5), []
174
+
175
+ with open(txt_file, 'r') as f:
176
+ for line in f:
177
+ line = line.strip()
178
+ if not line:
179
+ continue
180
+ try:
181
+ ann = DOTAAnnotation.from_line(line)
182
+ rboxes.append(ann.rbox)
183
+ class_names.append(ann.class_name)
184
+ except Exception:
185
+ continue
186
+
187
+ if len(rboxes) == 0:
188
+ return np.array([]).reshape(0, 5), []
189
+
190
+ # Convert to array format (cx, cy, w, h, angle)
191
+ rbox_array = np.array([[rbox.cx, rbox.cy, rbox.width, rbox.height, rbox.angle]
192
+ for rbox in rboxes])
193
+ return rbox_array, class_names
194
+
195
+
196
+ def array_to_rbox(bbox_array: List[float]) -> RBox:
197
+ """Convert array format [cx, cy, w, h, angle] to RBox."""
198
+ return RBox(cx=bbox_array[0], cy=bbox_array[1],
199
+ width=bbox_array[2], height=bbox_array[3],
200
+ angle=bbox_array[4])
201
+
202
+
203
+ def _canonicalize_name(name: str) -> str:
204
+ return str(name).strip().lower()
205
+
206
+
207
+ def _to_float(value: Any, default: float) -> float:
208
+ try:
209
+ return float(value)
210
+ except (TypeError, ValueError):
211
+ return float(default)
212
+
213
+
214
+ def _slider_index(value: Any, default: int = 0) -> int:
215
+ """Coerce Gradio slider/state payloads; None fails Slider preprocess (min bound check)."""
216
+ if value is None:
217
+ return default
218
+ try:
219
+ return int(value)
220
+ except (TypeError, ValueError):
221
+ return default
222
+
223
+
224
+ class SimpleViewer:
225
+ """Unified viewer for inference results or dataset-only exploration."""
226
+
227
+ def __init__(
228
+ self,
229
+ predictions_dir: Optional[str] = None,
230
+ data_root: Optional[str] = None,
231
+ results: Optional[List[Dict]] = None,
232
+ ):
233
+ self.current_idx = 0
234
+ self.sorted_indices = []
235
+ self.current_sort = "no order"
236
+ self.last_thresh_signature = None
237
+ self._gt_count_cache: Dict[int, int] = {} # for dataset mode sort by object count
238
+
239
+ if predictions_dir is not None:
240
+ self._init_from_predictions(predictions_dir, data_root)
241
+ elif results is not None and data_root is not None:
242
+ self._init_from_dataset(results, data_root)
243
+ else:
244
+ raise ValueError(
245
+ "Provide either predictions_dir (and optional data_root) "
246
+ "or results and data_root for dataset mode."
247
+ )
248
+
249
+ def _init_from_predictions(self, predictions_dir: str, data_root: Optional[str]) -> None:
250
+ self.has_predictions = True
251
+ self.predictions_dir = predictions_dir
252
+ self.json_path = os.path.join(predictions_dir, 'predictions.json')
253
+ if not os.path.exists(self.json_path):
254
+ raise ValueError(f"predictions.json not found in {predictions_dir}")
255
+ with open(self.json_path, 'r') as f:
256
+ data = json.load(f)
257
+ self.metadata = data['metadata']
258
+ self.results = data['results']
259
+ self.class_names = list(self.metadata.get("class_names") or [])
260
+ best_meta = self.metadata.get("best_threshold_f1") or self.metadata.get("best_threshold_f2")
261
+ best_thr = (
262
+ best_meta.get("threshold")
263
+ if isinstance(best_meta, dict)
264
+ else None
265
+ )
266
+ if isinstance(best_thr, (int, float)):
267
+ self.conf_threshold = float(best_thr)
268
+ else:
269
+ self.conf_threshold = _to_float(
270
+ self.metadata.get('score_threshold', self.metadata.get('conf_threshold', 0.3)),
271
+ 0.3,
272
+ )
273
+ self.per_class_threshold_defaults = {
274
+ cname: float(self.conf_threshold) for cname in self.class_names
275
+ }
276
+ self.best_threshold_per_class: Dict[str, Dict[str, Any]] = {}
277
+ self._set_defaults_from_config_thresholds()
278
+ if data_root is not None:
279
+ self.data_root = data_root
280
+ elif 'data_root' in self.metadata:
281
+ self.data_root = self.metadata['data_root']
282
+ else:
283
+ self.data_root = detect_data_root()
284
+ self.f1_scores = {}
285
+ self.load_f1_scores()
286
+ self._set_defaults_from_analysis_thresholds()
287
+ if self.best_threshold_per_class:
288
+ self.metadata["best_threshold_per_class"] = self.best_threshold_per_class
289
+ self.sorted_indices = list(range(len(self.results)))
290
+ print(f"Using data root: {self.data_root}")
291
+ print(f"Loaded {len(self.results)} images from {predictions_dir}")
292
+
293
+ def _init_from_dataset(self, results: List[Dict], data_root: str) -> None:
294
+ self.has_predictions = False
295
+ self.predictions_dir = None
296
+ self.metadata = {}
297
+ self.results = results
298
+ self.conf_threshold = 0.3
299
+ self.class_names = []
300
+ self.per_class_threshold_defaults = {}
301
+ self.best_threshold_per_class = {}
302
+ self.data_root = data_root
303
+ self.f1_scores = {}
304
+ self.sorted_indices = list(range(len(self.results)))
305
+ print(f"Using data root: {self.data_root}")
306
+ print(f"Dataset mode: loaded {len(self.results)} images")
307
+
308
+ def _set_defaults_from_config_thresholds(self) -> None:
309
+ raw = self.metadata.get("per_class_score_threshold")
310
+ if not isinstance(raw, dict):
311
+ return
312
+ by_key = {_canonicalize_name(k): _to_float(v, self.conf_threshold) for k, v in raw.items()}
313
+ for cname in self.class_names:
314
+ thr = by_key.get(_canonicalize_name(cname))
315
+ if thr is not None:
316
+ self.per_class_threshold_defaults[cname] = float(thr)
317
+
318
+ def _set_defaults_from_analysis_thresholds(self) -> None:
319
+ if not isinstance(self.best_threshold_per_class, dict):
320
+ return
321
+ by_key = {_canonicalize_name(k): v for k, v in self.best_threshold_per_class.items()}
322
+ for cname in self.class_names:
323
+ entry = by_key.get(_canonicalize_name(cname))
324
+ if isinstance(entry, dict) and "threshold" in entry:
325
+ self.per_class_threshold_defaults[cname] = _to_float(entry.get("threshold"), self.conf_threshold)
326
+
327
+ def load_f1_scores(self):
328
+ """Load F1 scores from analysis JSON if available"""
329
+ analysis_file = self.metadata.get("analysis_file", "analysis_iou0.50.json")
330
+ analysis_path = (
331
+ analysis_file
332
+ if os.path.isabs(str(analysis_file))
333
+ else os.path.join(self.predictions_dir, str(analysis_file))
334
+ )
335
+ if os.path.exists(analysis_path):
336
+ try:
337
+ with open(analysis_path, 'r') as f:
338
+ analysis_data = json.load(f)
339
+ per_image_metrics = analysis_data.get('per_image_metrics', [])
340
+ for metric in per_image_metrics:
341
+ image_name = metric.get('image_name')
342
+ f1 = metric.get('f1', 0.0)
343
+ if image_name:
344
+ self.f1_scores[image_name] = float(f1)
345
+ best_per_class = analysis_data.get("best_threshold_per_class") or {}
346
+ if isinstance(best_per_class, dict):
347
+ self.best_threshold_per_class = best_per_class
348
+ print(f"Loaded F1 scores for {len(self.f1_scores)} images from analysis JSON")
349
+ except Exception as e:
350
+ print(f"Warning: Could not load F1 scores from analysis JSON: {e}")
351
+
352
+ def _effective_threshold_for_pred(
353
+ self,
354
+ pred: Dict[str, Any],
355
+ threshold: float,
356
+ use_per_class_thresholds: bool = False,
357
+ per_class_thresholds: Optional[Dict[str, float]] = None,
358
+ ) -> float:
359
+ if not use_per_class_thresholds or not per_class_thresholds:
360
+ return float(threshold)
361
+ cls_name = str(pred.get("class_name", ""))
362
+ if cls_name in per_class_thresholds:
363
+ return float(per_class_thresholds[cls_name])
364
+ key = _canonicalize_name(cls_name)
365
+ if key in per_class_thresholds:
366
+ return float(per_class_thresholds[key])
367
+ return float(threshold)
368
+
369
+ def get_detection_count(
370
+ self,
371
+ idx: int,
372
+ threshold: float,
373
+ use_per_class_thresholds: bool = False,
374
+ per_class_thresholds: Optional[Dict[str, float]] = None,
375
+ ) -> int:
376
+ """Get number of detections for an image at given threshold."""
377
+ result = self.results[idx]
378
+ count = 0
379
+ for pred in result.get('predictions', []):
380
+ thr = self._effective_threshold_for_pred(
381
+ pred,
382
+ threshold,
383
+ use_per_class_thresholds=use_per_class_thresholds,
384
+ per_class_thresholds=per_class_thresholds,
385
+ )
386
+ if pred['score'] >= thr:
387
+ count += 1
388
+ return count
389
+
390
+ def get_stats_markdown(self, display_idx: int) -> str:
391
+ """Get stats markdown for the image at display_idx (after sorting)."""
392
+ if display_idx < 0 or display_idx >= len(self.sorted_indices):
393
+ return ""
394
+ actual_idx = self.sorted_indices[display_idx]
395
+ result = self.results[actual_idx]
396
+ class_names = self.metadata.get("class_names") or []
397
+ return format_stats_markdown(self.metadata, result, class_names)
398
+
399
+ def get_image_label_markdown(self, display_idx: int) -> str:
400
+ """Return markdown identifying the current image (path or name)."""
401
+ if display_idx < 0 or display_idx >= len(self.sorted_indices):
402
+ return ""
403
+ actual_idx = self.sorted_indices[display_idx]
404
+ result = self.results[actual_idx]
405
+ rel_path = str(result.get("image_path", "")).replace(os.sep, "/")
406
+ name = result.get("image_name")
407
+ if rel_path:
408
+ display = rel_path
409
+ elif name:
410
+ display = name
411
+ else:
412
+ return ""
413
+ idx_str = f"{display_idx + 1} / {len(self.sorted_indices)}"
414
+ return f"**Image {idx_str}:** `{display}`"
415
+
416
+ def _get_gt_count(self, idx: int) -> int:
417
+ """Get number of ground-truth objects for an image (for dataset mode sorting)."""
418
+ if idx in self._gt_count_cache:
419
+ return self._gt_count_cache[idx]
420
+ result = self.results[idx]
421
+ relative_path = result['image_path']
422
+ img_path = os.path.join(self.data_root, relative_path) if not os.path.isabs(relative_path) else relative_path
423
+ img_path_obj = Path(img_path)
424
+ possible_label_dirs = [
425
+ img_path_obj.parent.parent / 'labels',
426
+ img_path_obj.parent.parent / 'labelTxt',
427
+ img_path_obj.parent / 'labels',
428
+ ]
429
+ txt_path = None
430
+ for label_dir in possible_label_dirs:
431
+ potential_txt = label_dir / f"{img_path_obj.stem}.txt"
432
+ if potential_txt.exists():
433
+ txt_path = potential_txt
434
+ break
435
+ if txt_path is None:
436
+ txt_path = img_path_obj.with_suffix('.txt')
437
+ gt_boxes, _ = load_dota_annotations(str(txt_path))
438
+ count = len(gt_boxes)
439
+ self._gt_count_cache[idx] = count
440
+ return count
441
+
442
+ def apply_sorting(
443
+ self,
444
+ sort_mode: str,
445
+ threshold: float,
446
+ use_per_class_thresholds: bool = False,
447
+ per_class_thresholds: Optional[Dict[str, float]] = None,
448
+ ) -> None:
449
+ """Apply sorting to results and update sorted_indices."""
450
+ self.current_sort = sort_mode
451
+ self.last_thresh_signature = (
452
+ float(threshold),
453
+ bool(use_per_class_thresholds),
454
+ tuple(sorted((str(k), float(v)) for k, v in (per_class_thresholds or {}).items())),
455
+ )
456
+
457
+ if sort_mode == "no order":
458
+ self.sorted_indices = list(range(len(self.results)))
459
+ elif not self.has_predictions:
460
+ # Dataset mode: only no order and object count
461
+ if sort_mode == "objs asc":
462
+ indices_with_objs = [(idx, self._get_gt_count(idx)) for idx in range(len(self.results))]
463
+ indices_with_objs.sort(key=lambda x: x[1])
464
+ self.sorted_indices = [idx for idx, _ in indices_with_objs]
465
+ elif sort_mode == "objs desc":
466
+ indices_with_objs = [(idx, self._get_gt_count(idx)) for idx in range(len(self.results))]
467
+ indices_with_objs.sort(key=lambda x: x[1], reverse=True)
468
+ self.sorted_indices = [idx for idx, _ in indices_with_objs]
469
+ else:
470
+ self.sorted_indices = list(range(len(self.results)))
471
+ else:
472
+ # Predictions mode: F1 and det count
473
+ if sort_mode == "F1 asc":
474
+ indices_with_f1 = []
475
+ for idx in range(len(self.results)):
476
+ result = self.results[idx]
477
+ image_name = result.get('image_name', Path(self.results[idx]['image_path']).stem)
478
+ f1 = self.f1_scores.get(image_name, 0.0)
479
+ indices_with_f1.append((idx, f1))
480
+ indices_with_f1.sort(key=lambda x: x[1])
481
+ self.sorted_indices = [idx for idx, _ in indices_with_f1]
482
+ elif sort_mode == "F1 desc":
483
+ indices_with_f1 = []
484
+ for idx in range(len(self.results)):
485
+ result = self.results[idx]
486
+ image_name = result.get('image_name', Path(self.results[idx]['image_path']).stem)
487
+ f1 = self.f1_scores.get(image_name, 0.0)
488
+ indices_with_f1.append((idx, f1))
489
+ indices_with_f1.sort(key=lambda x: x[1], reverse=True)
490
+ self.sorted_indices = [idx for idx, _ in indices_with_f1]
491
+ elif sort_mode == "dets asc":
492
+ indices_with_dets = [
493
+ (
494
+ idx,
495
+ self.get_detection_count(
496
+ idx,
497
+ threshold,
498
+ use_per_class_thresholds=use_per_class_thresholds,
499
+ per_class_thresholds=per_class_thresholds,
500
+ ),
501
+ )
502
+ for idx in range(len(self.results))
503
+ ]
504
+ indices_with_dets.sort(key=lambda x: x[1])
505
+ self.sorted_indices = [idx for idx, _ in indices_with_dets]
506
+ elif sort_mode == "dets desc":
507
+ indices_with_dets = [
508
+ (
509
+ idx,
510
+ self.get_detection_count(
511
+ idx,
512
+ threshold,
513
+ use_per_class_thresholds=use_per_class_thresholds,
514
+ per_class_thresholds=per_class_thresholds,
515
+ ),
516
+ )
517
+ for idx in range(len(self.results))
518
+ ]
519
+ indices_with_dets.sort(key=lambda x: x[1], reverse=True)
520
+ self.sorted_indices = [idx for idx, _ in indices_with_dets]
521
+ else:
522
+ self.sorted_indices = list(range(len(self.results)))
523
+
524
+ def get_images(
525
+ self,
526
+ idx: int,
527
+ threshold: float,
528
+ zoom_scale: float = 2.0,
529
+ show_labels: bool = True,
530
+ view_mode: str = "preds_gt",
531
+ use_per_class_thresholds: bool = False,
532
+ per_class_thresholds: Optional[Dict[str, float]] = None,
533
+ ) -> Image.Image:
534
+ """Render one view of the current image.
535
+
536
+ view_mode:
537
+ - ``image``: original image only
538
+ - ``gt``: ground truth only (green boxes)
539
+ - ``preds``: predictions only (red boxes)
540
+ - ``preds_gt``: GT (green) then predictions (red) on top
541
+
542
+ Args:
543
+ idx: Displayed index (after sorting)
544
+ threshold: Confidence threshold for predictions
545
+ zoom_scale: Zoom scale factor
546
+ """
547
+ if idx < 0 or idx >= len(self.sorted_indices):
548
+ idx = 0
549
+
550
+ self.current_idx = idx
551
+ # Map displayed index to actual result index
552
+ actual_idx = self.sorted_indices[idx]
553
+ result = self.results[actual_idx]
554
+
555
+ # Load image
556
+ relative_path = result['image_path']
557
+ if os.path.isabs(relative_path):
558
+ img_path = relative_path
559
+ else:
560
+ img_path = os.path.join(self.data_root, relative_path)
561
+
562
+ img = cv2.imread(img_path)
563
+ if img is None:
564
+ return Image.new('RGB', (800, 600), color='red')
565
+
566
+ img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
567
+ img_height, img_width = img_rgb.shape[:2]
568
+
569
+ # Scale from preprocessed (model) space to the image we're drawing on.
570
+ # Use the actual loaded image dimensions so boxes always match the displayed image
571
+ # (avoids mismatch when JSON has different image_width/height than the file on disk).
572
+ # Match training/save_predictions: fixed resize to (target_h, target_w).
573
+ resize_mode = result.get('resize_mode', 'fixed')
574
+ target = result.get('target_size', [1024, 1024])
575
+ if isinstance(target, (list, tuple)):
576
+ target_h, target_w = int(target[0]), int(target[1])
577
+ else:
578
+ target_h = target_w = int(target)
579
+ pad_offset_x = pad_offset_y = 0
580
+ if resize_mode == 'pad':
581
+ from oriented_det.data.preprocessing import build_spatial_meta_from_dims
582
+ meta = build_spatial_meta_from_dims('pad', img_width, img_height, target)
583
+ inv = 1.0 / max(meta.scale, 1e-8)
584
+ scale_back_x = scale_back_y = inv
585
+ pad_offset_x = meta.pad_left
586
+ pad_offset_y = meta.pad_top
587
+ elif resize_mode == 'crop':
588
+ from oriented_det.data.preprocessing import build_spatial_meta_from_dims
589
+ meta = build_spatial_meta_from_dims('crop', img_width, img_height, target)
590
+ scale_back_x = scale_back_y = 1.0
591
+ pad_offset_x = meta.pad_left - meta.crop_left
592
+ pad_offset_y = meta.pad_top - meta.crop_top
593
+ else:
594
+ scale_back_x = img_width / max(target_w, 1)
595
+ scale_back_y = img_height / max(target_h, 1)
596
+
597
+ # save_predictions (pad/tile inference): bboxes are already in the loaded image's pixel space.
598
+ if self.metadata.get('bbox_coordinate_space') == 'image_pixels':
599
+ scale_back_x = scale_back_y = 1.0
600
+ pad_offset_x = pad_offset_y = 0
601
+
602
+ # Predictions: red (BGR); ground truth: green
603
+ pred_color_bgr = (0, 0, 255) # red in BGR
604
+ gt_colors = {
605
+ 'car': (0, 255, 0),
606
+ 'truck': (0, 255, 0),
607
+ }
608
+
609
+ # Ground truth boxes (prefer GT serialized in predictions.json; fallback to label files)
610
+ gt_items = result.get("ground_truths", [])
611
+ if gt_items:
612
+ gt_boxes = np.array([g.get("bbox", [0, 0, 0, 0, 0]) for g in gt_items], dtype=np.float32)
613
+ gt_class_names = [g.get("class_name", "unknown") for g in gt_items]
614
+ else:
615
+ img_path_obj = Path(img_path)
616
+ possible_label_dirs = [
617
+ img_path_obj.parent.parent / 'labels',
618
+ img_path_obj.parent.parent / 'labelTxt',
619
+ img_path_obj.parent / 'labels',
620
+ ]
621
+
622
+ txt_path = None
623
+ for label_dir in possible_label_dirs:
624
+ potential_txt = label_dir / f"{img_path_obj.stem}.txt"
625
+ if potential_txt.exists():
626
+ txt_path = potential_txt
627
+ break
628
+
629
+ if txt_path is None:
630
+ txt_path = img_path_obj.with_suffix('.txt')
631
+
632
+ gt_boxes, gt_class_names = load_dota_annotations(str(txt_path))
633
+
634
+ if view_mode == "image":
635
+ out_rgb = img_rgb
636
+ else:
637
+ out_bgr = cv2.cvtColor(img_rgb.copy(), cv2.COLOR_RGB2BGR)
638
+ pred_font = cv2.FONT_HERSHEY_SIMPLEX
639
+ pred_font_scale = 0.4
640
+ pred_font_thickness = 1
641
+ pred_label_bg_bgr = (0, 0, 0)
642
+ pred_label_fg_bgr = (255, 255, 255)
643
+ gt_fill_alpha = 0.25
644
+ font = cv2.FONT_HERSHEY_SIMPLEX
645
+ font_scale = 0.35
646
+ font_thickness = 1
647
+ label_bg_bgr = (0, 0, 0)
648
+ label_fg_bgr = (255, 255, 255)
649
+
650
+ draw_gt = view_mode in ("gt", "preds_gt")
651
+ draw_preds = view_mode in ("preds", "preds_gt") and self.has_predictions
652
+
653
+ if draw_gt:
654
+ gt_draw_items = []
655
+ gt_overlay = out_bgr.copy()
656
+ for i, gt_box in enumerate(gt_boxes):
657
+ rbox = array_to_rbox(gt_box)
658
+ polygon = rbox.to_polygon()
659
+ points = np.array([list(p) for p in polygon.points], dtype=np.int32)
660
+ class_name = gt_class_names[i] if i < len(gt_class_names) else 'unknown'
661
+ color_rgb = gt_colors.get(class_name, (0, 255, 0))
662
+ color_bgr = (color_rgb[2], color_rgb[1], color_rgb[0])
663
+ gt_draw_items.append((points, color_bgr))
664
+ # Fill GT polygons lightly so overlaps with predictions are easier to inspect.
665
+ cv2.fillPoly(gt_overlay, [points], color_bgr)
666
+ if show_labels:
667
+ (text_w, text_h), _ = cv2.getTextSize(class_name, font, font_scale, font_thickness)
668
+ right_x = int(np.max(points[:, 0]))
669
+ center_y = int(np.mean(points[:, 1]))
670
+ tx = max(0, min(right_x + 4, img_width - text_w - 4))
671
+ ty = max(text_h + 2, min(center_y, img_height - 2))
672
+ cv2.rectangle(
673
+ out_bgr, (tx - 2, ty - text_h - 2), (tx + text_w + 2, ty + 2),
674
+ label_bg_bgr, -1,
675
+ )
676
+ cv2.putText(out_bgr, class_name, (tx, ty), font, font_scale, label_fg_bgr, font_thickness)
677
+ out_bgr = cv2.addWeighted(gt_overlay, gt_fill_alpha, out_bgr, 1.0 - gt_fill_alpha, 0)
678
+ for points, color_bgr in gt_draw_items:
679
+ cv2.polylines(out_bgr, [points], isClosed=True, color=color_bgr, thickness=2)
680
+
681
+ if draw_preds:
682
+ pred_draw_items = []
683
+ for pred in result.get("predictions", []):
684
+ class_name = pred.get('class_name', 'car')
685
+ effective_thr = self._effective_threshold_for_pred(
686
+ pred,
687
+ threshold,
688
+ use_per_class_thresholds=use_per_class_thresholds,
689
+ per_class_thresholds=per_class_thresholds,
690
+ )
691
+ if pred['score'] < effective_thr:
692
+ continue
693
+ bbox = pred['bbox']
694
+ rbox = array_to_rbox(bbox)
695
+ rbox_scaled = RBox(
696
+ cx=(rbox.cx - pad_offset_x) * scale_back_x,
697
+ cy=(rbox.cy - pad_offset_y) * scale_back_y,
698
+ width=rbox.width * scale_back_x,
699
+ height=rbox.height * scale_back_y,
700
+ angle=rbox.angle,
701
+ )
702
+ polygon = rbox_scaled.to_polygon()
703
+ points = np.array([list(p) for p in polygon.points], dtype=np.int32)
704
+ pred_draw_items.append((points, pred_color_bgr))
705
+ if show_labels:
706
+ label_text = f"{class_name}: {float(pred['score']):.2f}"
707
+ (tw, th), _ = cv2.getTextSize(label_text, pred_font, pred_font_scale, pred_font_thickness)
708
+ right_x = int(np.max(points[:, 0]))
709
+ center_y = int(np.mean(points[:, 1]))
710
+ tx = max(0, min(right_x + 4, img_width - tw - 4))
711
+ ty = max(th + 2, min(center_y, img_height - 2))
712
+ cv2.rectangle(
713
+ out_bgr,
714
+ (tx - 2, ty - th - 2),
715
+ (tx + tw + 2, ty + 2),
716
+ pred_label_bg_bgr,
717
+ -1,
718
+ )
719
+ cv2.putText(
720
+ out_bgr,
721
+ label_text,
722
+ (tx, ty),
723
+ pred_font,
724
+ pred_font_scale,
725
+ pred_label_fg_bgr,
726
+ pred_font_thickness,
727
+ )
728
+ for points, color_bgr in pred_draw_items:
729
+ cv2.polylines(out_bgr, [points], isClosed=True, color=color_bgr, thickness=2)
730
+
731
+ out_rgb = cv2.cvtColor(out_bgr, cv2.COLOR_BGR2RGB)
732
+
733
+ def apply_zoom(img_array: np.ndarray, scale: float) -> Image.Image:
734
+ if scale == 1.0:
735
+ return Image.fromarray(img_array, "RGB")
736
+ h, w = img_array.shape[:2]
737
+ new_h, new_w = int(h * scale), int(w * scale)
738
+ resized = cv2.resize(img_array, (new_w, new_h), interpolation=cv2.INTER_LINEAR)
739
+ return Image.fromarray(resized, "RGB")
740
+
741
+ return apply_zoom(out_rgb, zoom_scale)
742
+
743
+
744
+ def create_app(
745
+ mode: str = "predictions",
746
+ predictions_dir: Optional[str] = None,
747
+ data_root: Optional[str] = None,
748
+ tiles_dir: Optional[str] = None,
749
+ threshold: Optional[float] = None,
750
+ ):
751
+ """Create Gradio app for predictions viewer or dataset explorer.
752
+
753
+ Args:
754
+ mode: "predictions" or "dataset"
755
+ predictions_dir: Path to predictions directory (required when mode is "predictions")
756
+ data_root: Path to data root (required for dataset mode; optional for predictions)
757
+ tiles_dir: Subpath for dataset (e.g. "train", "train/tiles_1024") when mode is "dataset"
758
+ threshold: Confidence threshold for predictions mode (default: 0.3)
759
+ """
760
+ if mode == "dataset":
761
+ if not data_root:
762
+ raise ValueError("Dataset mode requires --data-root")
763
+ results = list_dota_images(data_root, tiles_dir)
764
+ if not results:
765
+ raise ValueError(f"No images found under {data_root}" + (f" / {tiles_dir}" if tiles_dir else ""))
766
+ viewer = SimpleViewer(results=results, data_root=data_root)
767
+ else:
768
+ if not predictions_dir:
769
+ raise ValueError("Predictions mode requires --predictions-dir")
770
+ viewer = SimpleViewer(predictions_dir=predictions_dir, data_root=data_root)
771
+ if threshold is not None:
772
+ viewer.conf_threshold = threshold
773
+
774
+ def _build_per_class_thresholds(values: Tuple[Any, ...]) -> Dict[str, float]:
775
+ if not viewer.class_names:
776
+ return {}
777
+ return {
778
+ cname: _to_float(v, viewer.conf_threshold)
779
+ for cname, v in zip(viewer.class_names, values)
780
+ }
781
+
782
+ def _maybe_sort(
783
+ sort_mode: str,
784
+ threshold_val: float,
785
+ use_per_class_val: bool = False,
786
+ per_class_thresholds: Optional[Dict[str, float]] = None,
787
+ ) -> None:
788
+ needs_resort = viewer.current_sort != sort_mode
789
+ if viewer.has_predictions and sort_mode in ["dets asc", "dets desc"]:
790
+ threshold_signature = (
791
+ float(threshold_val),
792
+ bool(use_per_class_val),
793
+ tuple(sorted((str(k), float(v)) for k, v in (per_class_thresholds or {}).items())),
794
+ )
795
+ if viewer.last_thresh_signature != threshold_signature:
796
+ needs_resort = True
797
+ if needs_resort:
798
+ viewer.apply_sorting(
799
+ sort_mode,
800
+ float(threshold_val),
801
+ use_per_class_thresholds=use_per_class_val,
802
+ per_class_thresholds=per_class_thresholds,
803
+ )
804
+
805
+ def refresh_prediction_tabs(
806
+ idx: int,
807
+ threshold_val: float,
808
+ zoom_str: str,
809
+ sort_mode: str,
810
+ show_labels_val: bool,
811
+ use_per_class_val: bool,
812
+ *per_class_vals: Any,
813
+ ):
814
+ per_class_thresholds = _build_per_class_thresholds(per_class_vals)
815
+ _maybe_sort(sort_mode, threshold_val, use_per_class_val, per_class_thresholds)
816
+ zoom = float(zoom_str.replace("x", ""))
817
+ i = int(idx)
818
+ img_pg = viewer.get_images(
819
+ i,
820
+ float(threshold_val),
821
+ zoom,
822
+ show_labels_val,
823
+ view_mode="preds_gt",
824
+ use_per_class_thresholds=use_per_class_val,
825
+ per_class_thresholds=per_class_thresholds,
826
+ )
827
+ img_p = viewer.get_images(
828
+ i,
829
+ float(threshold_val),
830
+ zoom,
831
+ show_labels_val,
832
+ view_mode="preds",
833
+ use_per_class_thresholds=use_per_class_val,
834
+ per_class_thresholds=per_class_thresholds,
835
+ )
836
+ img_g = viewer.get_images(
837
+ i,
838
+ float(threshold_val),
839
+ zoom,
840
+ show_labels_val,
841
+ view_mode="gt",
842
+ use_per_class_thresholds=use_per_class_val,
843
+ per_class_thresholds=per_class_thresholds,
844
+ )
845
+ img_i = viewer.get_images(
846
+ i,
847
+ float(threshold_val),
848
+ zoom,
849
+ show_labels_val,
850
+ view_mode="image",
851
+ use_per_class_thresholds=use_per_class_val,
852
+ per_class_thresholds=per_class_thresholds,
853
+ )
854
+ stats_md = viewer.get_stats_markdown(i)
855
+ image_label = viewer.get_image_label_markdown(i)
856
+ return img_pg, img_p, img_g, img_i, stats_md, image_label
857
+
858
+ def _tabs_and_slider(
859
+ idx: int,
860
+ threshold_val: float,
861
+ zoom_str: str,
862
+ sort_mode: str,
863
+ show_labels_val: bool,
864
+ use_per_class_val: bool,
865
+ slider_idx: int,
866
+ *per_class_vals: Any,
867
+ ):
868
+ """Same as ``refresh_prediction_tabs`` but order matches Gradio: 4 images, slider, then markdown."""
869
+ img_pg, img_p, img_g, img_i, stats_md, image_label = refresh_prediction_tabs(
870
+ idx,
871
+ threshold_val,
872
+ zoom_str,
873
+ sort_mode,
874
+ show_labels_val,
875
+ use_per_class_val,
876
+ *per_class_vals,
877
+ )
878
+ idx = _slider_index(slider_idx, 0)
879
+ return img_pg, img_p, img_g, img_i, idx, idx, stats_md, image_label
880
+
881
+ def refresh_dataset_tabs(idx: int, zoom_str: str, sort_mode: str):
882
+ _maybe_sort(sort_mode, threshold_for_api)
883
+ zoom = float(zoom_str.replace("x", ""))
884
+ i = int(idx)
885
+ img_g = viewer.get_images(i, threshold_for_api, zoom, show_labels=False, view_mode="gt")
886
+ img_i = viewer.get_images(i, threshold_for_api, zoom, show_labels=False, view_mode="image")
887
+ image_label = viewer.get_image_label_markdown(i)
888
+ return img_g, img_i, image_label
889
+
890
+ def build_download_image_predictions(
891
+ idx: int,
892
+ threshold_val: float,
893
+ zoom_str: str,
894
+ sort_mode: str,
895
+ show_labels_val: bool,
896
+ use_per_class_val: bool,
897
+ active_view_mode: str,
898
+ *per_class_vals: Any,
899
+ ) -> str:
900
+ per_class_thresholds = _build_per_class_thresholds(per_class_vals)
901
+ _maybe_sort(sort_mode, threshold_val, use_per_class_val, per_class_thresholds)
902
+ zoom = float(zoom_str.replace("x", ""))
903
+ i = int(idx)
904
+ view_mode = str(active_view_mode or "preds_gt")
905
+ if view_mode not in {"preds_gt", "preds", "gt", "image"}:
906
+ view_mode = "preds_gt"
907
+ rendered = viewer.get_images(
908
+ i,
909
+ float(threshold_val),
910
+ zoom,
911
+ show_labels_val,
912
+ view_mode=view_mode,
913
+ use_per_class_thresholds=use_per_class_val,
914
+ per_class_thresholds=per_class_thresholds,
915
+ )
916
+ with tempfile.NamedTemporaryFile(prefix="viewer_", suffix=".png", delete=False) as f:
917
+ out_path = f.name
918
+ rendered.save(out_path, format="PNG")
919
+ return out_path
920
+
921
+ def build_download_image_dataset(
922
+ idx: int,
923
+ zoom_str: str,
924
+ sort_mode: str,
925
+ active_view_mode: str,
926
+ ) -> str:
927
+ _maybe_sort(sort_mode, threshold_for_api)
928
+ zoom = float(zoom_str.replace("x", ""))
929
+ i = int(idx)
930
+ view_mode = "gt" if str(active_view_mode) == "gt" else "image"
931
+ rendered = viewer.get_images(i, threshold_for_api, zoom, show_labels=False, view_mode=view_mode)
932
+ with tempfile.NamedTemporaryFile(prefix="viewer_", suffix=".png", delete=False) as f:
933
+ out_path = f.name
934
+ rendered.save(out_path, format="PNG")
935
+ return out_path
936
+
937
+ if viewer.has_predictions:
938
+ initial_pg, initial_p, initial_g, initial_i, initial_stats, initial_image_label = refresh_prediction_tabs(
939
+ 0, viewer.conf_threshold, "2x", "no order", False, False
940
+ )
941
+ else:
942
+ initial_g, initial_i, initial_image_label = refresh_dataset_tabs(0, "2x", "no order")
943
+ initial_stats = ""
944
+
945
+ threshold_for_api = viewer.conf_threshold
946
+
947
+ app_title = "Dataset Explorer" if mode == "dataset" else "Object Detection Viewer"
948
+ with gr.Blocks(title=app_title) as app:
949
+ gr.Markdown("# Dataset Explorer" if mode == "dataset" else "# Object Detection Inference Viewer")
950
+ image_label_display = gr.Markdown(value=initial_image_label)
951
+
952
+ with gr.Tabs():
953
+ if viewer.has_predictions:
954
+ with gr.Tab(label="Predictions + Ground Truth"):
955
+ tab_preds_gt = gr.Image(value=initial_pg, label="Predictions (red) + ground truth (green)", height=800)
956
+ with gr.Tab(label="Predictions Only"):
957
+ tab_preds = gr.Image(value=initial_p, label="Predictions only", height=800)
958
+ with gr.Tab(label="Ground Truth Only"):
959
+ tab_gt = gr.Image(value=initial_g, label="Ground truth only", height=800)
960
+ with gr.Tab(label="Original Image"):
961
+ tab_image = gr.Image(value=initial_i, label="Original image", height=800)
962
+ else:
963
+ with gr.Tab(label="Ground Truth Only"):
964
+ tab_gt = gr.Image(value=initial_g, label="Ground truth only", height=800)
965
+ with gr.Tab(label="Original Image"):
966
+ tab_image = gr.Image(value=initial_i, label="Original image", height=800)
967
+
968
+ active_view_mode = gr.State("preds_gt" if viewer.has_predictions else "gt")
969
+
970
+ with gr.Row():
971
+ first_btn = gr.Button("First")
972
+ prev_btn = gr.Button("Previous")
973
+ next_btn = gr.Button("Next")
974
+ last_btn = gr.Button("Last")
975
+
976
+ with gr.Row():
977
+ image_idx = gr.Slider(
978
+ minimum=0,
979
+ maximum=max(0, len(viewer.results) - 1),
980
+ value=0,
981
+ step=1,
982
+ label="Image Index",
983
+ )
984
+ image_idx_state = gr.State(0)
985
+ zoom_scale = gr.Radio(
986
+ choices=["1x", "2x", "4x"],
987
+ value="2x",
988
+ label="Zoom Scale",
989
+ interactive=True,
990
+ )
991
+ if viewer.has_predictions:
992
+ conf_threshold = gr.Slider(
993
+ minimum=0.0,
994
+ maximum=1.0,
995
+ value=viewer.conf_threshold,
996
+ step=0.01,
997
+ label="Confidence Threshold",
998
+ )
999
+ with gr.Column():
1000
+ show_labels = gr.Checkbox(value=False, label="Show Labels")
1001
+ use_per_class_thresholds = gr.Checkbox(
1002
+ value=False,
1003
+ label="Use per-class thresholds (from metrics/config)",
1004
+ )
1005
+ per_class_sliders: List[gr.Slider] = []
1006
+ if viewer.has_predictions and viewer.class_names:
1007
+ with gr.Accordion("Per-class thresholds", open=False):
1008
+ gr.Markdown(
1009
+ "Defaults are loaded from per-class metrics when available "
1010
+ "(otherwise from config, then global threshold)."
1011
+ )
1012
+ for cname in viewer.class_names:
1013
+ default_thr = _to_float(
1014
+ viewer.per_class_threshold_defaults.get(cname, viewer.conf_threshold),
1015
+ viewer.conf_threshold,
1016
+ )
1017
+ per_class_sliders.append(
1018
+ gr.Slider(
1019
+ minimum=0.0,
1020
+ maximum=1.0,
1021
+ value=default_thr,
1022
+ step=0.01,
1023
+ label=f"{cname} threshold",
1024
+ )
1025
+ )
1026
+ with gr.Row():
1027
+ sort_choices = (
1028
+ ["no order", "objs asc", "objs desc"]
1029
+ if mode == "dataset"
1030
+ else ["no order", "F1 asc", "F1 desc", "dets asc", "dets desc"]
1031
+ )
1032
+ sort_mode = gr.Radio(
1033
+ choices=sort_choices,
1034
+ value="no order",
1035
+ label="Sort by",
1036
+ interactive=True,
1037
+ )
1038
+
1039
+ with gr.Row():
1040
+ download_btn = gr.Button("Download Current Image")
1041
+ download_file = gr.File(label="Download (PNG)", interactive=False)
1042
+
1043
+ if viewer.has_predictions:
1044
+ gr.Markdown("### Diagnostics")
1045
+ stats_display = gr.Markdown(value=initial_stats)
1046
+ tab_outputs_p = [tab_preds_gt, tab_preds, tab_gt, tab_image, stats_display, image_label_display]
1047
+ tab_outputs_p_nav = [
1048
+ tab_preds_gt, tab_preds, tab_gt, tab_image, image_idx, image_idx_state, stats_display, image_label_display
1049
+ ]
1050
+
1051
+ def update_sorting_predictions(threshold_val, zoom_str, sort_mode_val, show_labels_val, use_pc_val, *pc_vals):
1052
+ return _tabs_and_slider(0, threshold_val, zoom_str, sort_mode_val, show_labels_val, use_pc_val, 0, *pc_vals)
1053
+
1054
+ def nav_first_p(t, z, s, sl, upc, *pc_vals):
1055
+ return _tabs_and_slider(0, t, z, s, sl, upc, 0, *pc_vals)
1056
+
1057
+ def nav_prev_p(cur, t, z, s, sl, upc, *pc_vals):
1058
+ new_idx = max(0, _slider_index(cur, 0) - 1)
1059
+ return _tabs_and_slider(new_idx, t, z, s, sl, upc, new_idx, *pc_vals)
1060
+
1061
+ def nav_next_p(cur, t, z, s, sl, upc, *pc_vals):
1062
+ last = len(viewer.results) - 1
1063
+ new_idx = min(last, _slider_index(cur, 0) + 1)
1064
+ return _tabs_and_slider(new_idx, t, z, s, sl, upc, new_idx, *pc_vals)
1065
+
1066
+ def nav_last_p(t, z, s, sl, upc, *pc_vals):
1067
+ last = len(viewer.results) - 1
1068
+ return _tabs_and_slider(last, t, z, s, sl, upc, last, *pc_vals)
1069
+
1070
+ def on_image_idx_change(slider_val, t, z, s, sl, upc, *pc_vals):
1071
+ idx = _slider_index(slider_val, 0)
1072
+ tabs = refresh_prediction_tabs(idx, t, z, s, sl, upc, *pc_vals)
1073
+ return tabs + (idx,)
1074
+
1075
+ prediction_inputs = [
1076
+ image_idx_state, conf_threshold, zoom_scale, sort_mode, show_labels, use_per_class_thresholds
1077
+ ] + per_class_sliders
1078
+ prediction_inputs_no_idx = [
1079
+ conf_threshold, zoom_scale, sort_mode, show_labels, use_per_class_thresholds
1080
+ ] + per_class_sliders
1081
+
1082
+ first_btn.click(
1083
+ fn=nav_first_p,
1084
+ inputs=prediction_inputs_no_idx,
1085
+ outputs=tab_outputs_p_nav,
1086
+ )
1087
+ prev_btn.click(
1088
+ fn=nav_prev_p,
1089
+ inputs=prediction_inputs,
1090
+ outputs=tab_outputs_p_nav,
1091
+ )
1092
+ next_btn.click(
1093
+ fn=nav_next_p,
1094
+ inputs=prediction_inputs,
1095
+ outputs=tab_outputs_p_nav,
1096
+ )
1097
+ last_btn.click(
1098
+ fn=nav_last_p,
1099
+ inputs=prediction_inputs_no_idx,
1100
+ outputs=tab_outputs_p_nav,
1101
+ )
1102
+ image_idx.change(
1103
+ fn=on_image_idx_change,
1104
+ inputs=[image_idx] + prediction_inputs_no_idx,
1105
+ outputs=tab_outputs_p + [image_idx_state],
1106
+ )
1107
+ conf_threshold.change(
1108
+ fn=refresh_prediction_tabs,
1109
+ inputs=prediction_inputs,
1110
+ outputs=tab_outputs_p,
1111
+ )
1112
+ zoom_scale.change(
1113
+ fn=refresh_prediction_tabs,
1114
+ inputs=prediction_inputs,
1115
+ outputs=tab_outputs_p,
1116
+ )
1117
+ show_labels.change(
1118
+ fn=refresh_prediction_tabs,
1119
+ inputs=prediction_inputs,
1120
+ outputs=tab_outputs_p,
1121
+ )
1122
+ use_per_class_thresholds.change(
1123
+ fn=refresh_prediction_tabs,
1124
+ inputs=prediction_inputs,
1125
+ outputs=tab_outputs_p,
1126
+ )
1127
+ sort_mode.change(
1128
+ fn=update_sorting_predictions,
1129
+ inputs=prediction_inputs_no_idx,
1130
+ outputs=tab_outputs_p_nav,
1131
+ )
1132
+ for pc_slider in per_class_sliders:
1133
+ pc_slider.change(
1134
+ fn=refresh_prediction_tabs,
1135
+ inputs=prediction_inputs,
1136
+ outputs=tab_outputs_p,
1137
+ )
1138
+ tab_preds_gt.select(fn=lambda: "preds_gt", outputs=active_view_mode)
1139
+ tab_preds.select(fn=lambda: "preds", outputs=active_view_mode)
1140
+ tab_gt.select(fn=lambda: "gt", outputs=active_view_mode)
1141
+ tab_image.select(fn=lambda: "image", outputs=active_view_mode)
1142
+ download_btn.click(
1143
+ fn=build_download_image_predictions,
1144
+ inputs=[image_idx_state, conf_threshold, zoom_scale, sort_mode, show_labels, use_per_class_thresholds, active_view_mode] + per_class_sliders,
1145
+ outputs=download_file,
1146
+ )
1147
+ else:
1148
+ tab_outputs_d = [tab_gt, tab_image, image_label_display]
1149
+ tab_outputs_d_nav = [tab_gt, tab_image, image_idx, image_idx_state, image_label_display]
1150
+
1151
+ def update_sorting_dataset(zoom_str, sort_mode_val):
1152
+ img_g, img_i, image_label = refresh_dataset_tabs(0, zoom_str, sort_mode_val)
1153
+ return img_g, img_i, 0, 0, image_label
1154
+
1155
+ def nav_first_d(z, s):
1156
+ img_g, img_i, image_label = refresh_dataset_tabs(0, z, s)
1157
+ return img_g, img_i, 0, 0, image_label
1158
+
1159
+ def nav_prev_d(cur, z, s):
1160
+ new_idx = max(0, _slider_index(cur, 0) - 1)
1161
+ img_g, img_i, image_label = refresh_dataset_tabs(new_idx, z, s)
1162
+ return img_g, img_i, new_idx, new_idx, image_label
1163
+
1164
+ def nav_next_d(cur, z, s):
1165
+ last = len(viewer.results) - 1
1166
+ new_idx = min(last, _slider_index(cur, 0) + 1)
1167
+ img_g, img_i, image_label = refresh_dataset_tabs(new_idx, z, s)
1168
+ return img_g, img_i, new_idx, new_idx, image_label
1169
+
1170
+ def nav_last_d(z, s):
1171
+ last = len(viewer.results) - 1
1172
+ img_g, img_i, image_label = refresh_dataset_tabs(last, z, s)
1173
+ return img_g, img_i, last, last, image_label
1174
+
1175
+ def on_image_idx_change_d(slider_val, z, s):
1176
+ idx = _slider_index(slider_val, 0)
1177
+ img_g, img_i, image_label = refresh_dataset_tabs(idx, z, s)
1178
+ return img_g, img_i, idx, image_label
1179
+
1180
+ first_btn.click(
1181
+ fn=nav_first_d,
1182
+ inputs=[zoom_scale, sort_mode],
1183
+ outputs=tab_outputs_d_nav,
1184
+ )
1185
+ prev_btn.click(
1186
+ fn=nav_prev_d,
1187
+ inputs=[image_idx_state, zoom_scale, sort_mode],
1188
+ outputs=tab_outputs_d_nav,
1189
+ )
1190
+ next_btn.click(
1191
+ fn=nav_next_d,
1192
+ inputs=[image_idx_state, zoom_scale, sort_mode],
1193
+ outputs=tab_outputs_d_nav,
1194
+ )
1195
+ last_btn.click(
1196
+ fn=nav_last_d,
1197
+ inputs=[zoom_scale, sort_mode],
1198
+ outputs=tab_outputs_d_nav,
1199
+ )
1200
+ image_idx.change(
1201
+ fn=on_image_idx_change_d,
1202
+ inputs=[image_idx, zoom_scale, sort_mode],
1203
+ outputs=tab_outputs_d + [image_idx_state],
1204
+ )
1205
+ zoom_scale.change(
1206
+ fn=refresh_dataset_tabs,
1207
+ inputs=[image_idx_state, zoom_scale, sort_mode],
1208
+ outputs=tab_outputs_d,
1209
+ )
1210
+ sort_mode.change(
1211
+ fn=update_sorting_dataset,
1212
+ inputs=[zoom_scale, sort_mode],
1213
+ outputs=tab_outputs_d_nav,
1214
+ )
1215
+ tab_gt.select(fn=lambda: "gt", outputs=active_view_mode)
1216
+ tab_image.select(fn=lambda: "image", outputs=active_view_mode)
1217
+ download_btn.click(
1218
+ fn=build_download_image_dataset,
1219
+ inputs=[image_idx_state, zoom_scale, sort_mode, active_view_mode],
1220
+ outputs=download_file,
1221
+ )
1222
+ return app
1223
+
1224
+
1225
+ def main():
1226
+ parser = argparse.ArgumentParser(
1227
+ description="Launch Gradio app: predictions viewer or dataset explorer."
1228
+ )
1229
+ parser.add_argument(
1230
+ "--mode",
1231
+ type=str,
1232
+ choices=["predictions", "dataset"],
1233
+ default="predictions",
1234
+ help="Mode: 'predictions' (browse inference results) or 'dataset' (explore dataset only)",
1235
+ )
1236
+ parser.add_argument(
1237
+ "--predictions-dir",
1238
+ type=str,
1239
+ default=None,
1240
+ help="Path to predictions directory (required when --mode predictions)",
1241
+ )
1242
+ parser.add_argument(
1243
+ "--data-root",
1244
+ type=str,
1245
+ default=None,
1246
+ help="Path to data root (required when --mode dataset; optional for predictions)",
1247
+ )
1248
+ parser.add_argument(
1249
+ "--tiles-dir",
1250
+ type=str,
1251
+ default=None,
1252
+ help="Subpath under data-root for dataset mode (e.g. train, train/tiles_1024)",
1253
+ )
1254
+ parser.add_argument(
1255
+ "--threshold",
1256
+ type=float,
1257
+ default=None,
1258
+ help="Confidence threshold for predictions mode (default: 0.3)",
1259
+ )
1260
+ parser.add_argument(
1261
+ "--port",
1262
+ type=int,
1263
+ default=7860,
1264
+ help="Port to run on",
1265
+ )
1266
+ args = parser.parse_args()
1267
+
1268
+ if args.mode == "predictions" and not args.predictions_dir:
1269
+ parser.error("--predictions-dir is required when --mode predictions")
1270
+ if args.mode == "dataset" and not args.data_root:
1271
+ parser.error("--data-root is required when --mode dataset")
1272
+
1273
+ app = create_app(
1274
+ mode=args.mode,
1275
+ predictions_dir=args.predictions_dir,
1276
+ data_root=args.data_root,
1277
+ tiles_dir=args.tiles_dir,
1278
+ threshold=args.threshold,
1279
+ )
1280
+ app.launch(server_port=args.port, server_name="0.0.0.0")
1281
+
1282
+
1283
+ if __name__ == '__main__':
1284
+ main()