hbb2obb 1.0.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.
hbb2obb/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ from .__version__ import __version__
2
+
3
+ __all__ = [
4
+ "cli",
5
+ "converter",
6
+ "evaluator",
7
+ "utils"
8
+ ]
hbb2obb/__version__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "1.0.0"
hbb2obb/cli.py ADDED
@@ -0,0 +1,134 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ # Author: Robert Fonod (robert.fonod@ieee.org)
4
+
5
+ """
6
+ Command-line interface for HBB2OBB.
7
+ """
8
+
9
+ import argparse
10
+ from pathlib import Path
11
+
12
+ import tqdm
13
+
14
+ from hbb2obb.converter import hbb2obb, save_obb_annotations
15
+ from hbb2obb.evaluator import evaluate_obb, print_results
16
+ from hbb2obb.utils import get_image_paths, process_ultralytics_kwargs
17
+
18
+ SUPPORTED_SAM_MODELS = [
19
+ "sam_b",
20
+ "sam_l",
21
+ "mobile_sam",
22
+ "sam2_t",
23
+ "sam2_s",
24
+ "sam2_b",
25
+ "sam2.1_t",
26
+ "sam2.1_s",
27
+ "sam2.1_b",
28
+ "FastSAM-s",
29
+ "FastSAM-x",
30
+ ]
31
+
32
+
33
+ def main_hbb2obb():
34
+ """
35
+ Run the HBB to OBB conversion from command line.
36
+ """
37
+
38
+ parser = argparse.ArgumentParser(description="Convert HBB to OBB annotations")
39
+
40
+ # Main arguments
41
+ parser.add_argument("img_source", type=Path, help="Path to an image or directory containing images")
42
+ parser.add_argument("--hbb_dir", "-hd", type=Path, help="Directory containing HBB annotations (default: img_source/../labels_hbb)")
43
+ parser.add_argument("--obb_dir", "-od", type=Path, help="Directory to save OBB annotations (default: img_source/../labels_obb)")
44
+ parser.add_argument("--sam_models", "-sm", type=str, default=["sam_b"], nargs='+', choices=SUPPORTED_SAM_MODELS,
45
+ help="Name(s) of SAM model(s) to use (default: sam_b). Multiple models can be specified to average results.")
46
+ parser.add_argument("--imgsz", type=int, default=1280, help="Image size for SAM model inference (default: 1280)")
47
+ parser.add_argument("--scale_factors", "-sf", type=float, default=[0.05], nargs='+',
48
+ help="Factor(s) to scale HBBs (default: 0.05). Use one value for uniform or two values for short/long sides.")
49
+ parser.add_argument("--opening_kernel_percentage", "-okp", type=float, default=0.15,
50
+ help="Percentage of mask's smaller dimension for morphological opening. Ignored if <= 0.")
51
+
52
+ # Visualization control arguments
53
+ viz_group = parser.add_argument_group('visualization options')
54
+ viz_group.add_argument("--save_img", action="store_true", help="Save visualization images (default: False)")
55
+ viz_group.add_argument("--viz_dir", type=Path, help="Directory to save visualization images (default: same as 'obb_dir')")
56
+ viz_group.add_argument("--show_hbb", action="store_true", default=True, help="Show horizontal bounding boxes (default: True)")
57
+ viz_group.add_argument("--hide_hbb", action="store_false", dest="show_hbb", help="Hide horizontal bounding boxes")
58
+ viz_group.add_argument("--show_masks", action="store_true", default=True, help="Show segmentation masks (default: True)")
59
+ viz_group.add_argument("--hide_masks", action="store_false", dest="show_masks", help="Hide segmentation masks")
60
+ viz_group.add_argument("--show_segments", action="store_true", default=True, help="Show segmentation contours (default: True)")
61
+ viz_group.add_argument("--hide_segments", action="store_false", dest="show_segments", help="Hide segmentation contours")
62
+ viz_group.add_argument("--show_obb", action="store_true", default=True, help="Show oriented bounding boxes (default: True)")
63
+ viz_group.add_argument("--hide_obb", action="store_false", dest="show_obb", help="Hide oriented bounding boxes")
64
+ viz_group.add_argument("--show_labels", action="store_true", default=True, help="Show class labels (default: True)")
65
+ viz_group.add_argument("--hide_labels", action="store_false", dest="show_labels", help="Hide class labels")
66
+
67
+ # Miscellaneous arguments
68
+ parser.add_argument("--model_kwargs", "-k", type=str, help="Additional keyword arguments for ultralytics model inference in format 'key1=value1,key2=value2'")
69
+ parser.add_argument("--no_bar", "-nb", action="store_true", help="Disable tqdm progress bar display")
70
+
71
+ args = parser.parse_args()
72
+ model_kwargs = process_ultralytics_kwargs(args.model_kwargs)
73
+
74
+ image_paths = get_image_paths(args.img_source)
75
+ for img_path in tqdm.tqdm(image_paths, desc="Processing images", leave=True, disable=args.no_bar):
76
+ obb_annotations = hbb2obb(
77
+ img_path=img_path,
78
+ hbb_dir=args.hbb_dir,
79
+ sam_models=args.sam_models,
80
+ imgsz=args.imgsz,
81
+ scale_factors=args.scale_factors,
82
+ opening_kernel_percentage=args.opening_kernel_percentage,
83
+ save_img=args.save_img,
84
+ viz_dir=args.viz_dir if args.viz_dir else args.obb_dir,
85
+ show_hbb=args.show_hbb,
86
+ show_masks=args.show_masks,
87
+ show_segments=args.show_segments,
88
+ show_obb=args.show_obb,
89
+ show_labels=args.show_labels,
90
+ model_kwargs=model_kwargs,
91
+ )
92
+
93
+ # Save OBB annotations to a text file
94
+ save_obb_annotations(obb_annotations, args.obb_dir, img_path)
95
+
96
+
97
+ def main_hbb2obb_eval():
98
+ """
99
+ Run the OBB evaluation from command line.
100
+ """
101
+
102
+ parser = argparse.ArgumentParser(description="Evaluate OBB predictions against ground truth")
103
+
104
+ # Main arguments
105
+ parser.add_argument("gt_dir", type=Path, help="Directory containing ground truth OBB annotations")
106
+ parser.add_argument("pred_dir", type=Path, help="Directory containing predicted/converted OBB annotations")
107
+ parser.add_argument("--excluded_classes", "-e", type=int, nargs='+', default=[], help="Class labels to exclude from evaluation")
108
+ parser.add_argument("--iou_threshold", "-t", type=float, default=0.1, help="IoU threshold for considering a match (default: 0.1)")
109
+ parser.add_argument("--class_agnostic", "-ca", action="store_true", help="Evaluate in class-agnostic mode (match boxes regardless of class labels)")
110
+ parser.add_argument("--map_path", "-mp", type=Path, help="Path to label map YAML file (optional)")
111
+ parser.add_argument("--exclude_edge_cases", "-exc", action="store_true", help="Exclude boxes that touch image edges from evaluation")
112
+ parser.add_argument("--edge_tolerance", "-et", type=int, default=1, help="Tolerance in pixels for detecting boxes at image edges (default: 1)")
113
+ parser.add_argument("--img_width", "-iw", type=int, help="Image width for edge case detection (required if --exclude_edge_cases is used)")
114
+ parser.add_argument("--img_height", "-ih", type=int, help="Image height for edge case detection (required if --exclude_edge_cases is used)")
115
+ parser.add_argument("--debug", "-d", action="store_true", help="Enable debug mode to print detailed matching information")
116
+ parser.add_argument("--no_bar", "-nb", action="store_true", help="Disable tqdm progress bar display")
117
+
118
+ args = parser.parse_args()
119
+
120
+ results = evaluate_obb(
121
+ gt_dir=args.gt_dir,
122
+ pred_dir=args.pred_dir,
123
+ excluded_classes=args.excluded_classes,
124
+ iou_threshold=args.iou_threshold,
125
+ class_agnostic=args.class_agnostic,
126
+ exclude_edge_cases=args.exclude_edge_cases,
127
+ edge_tolerance=args.edge_tolerance,
128
+ img_width=args.img_width,
129
+ img_height=args.img_height,
130
+ debug=args.debug,
131
+ no_bar=args.no_bar,
132
+ )
133
+
134
+ print_results(results, args.map_path)
hbb2obb/converter.py ADDED
@@ -0,0 +1,483 @@
1
+ # -*- coding: utf-8 -*-
2
+ # Author: Robert Fonod (robert.fonod@ieee.org)
3
+
4
+ from pathlib import Path
5
+ from typing import Any, Dict, List, Tuple, Union
6
+
7
+ import cv2
8
+ import numpy as np
9
+ from ultralytics import SAM, FastSAM
10
+
11
+ from hbb2obb.utils import Annotations, get_hbb_dir
12
+
13
+
14
+ def hbb2obb(
15
+ img_path: Path,
16
+ hbb_dir: Path = None,
17
+ sam_models: Union[str, List[str]] = "sam_b.pt",
18
+ imgsz: int = 1280,
19
+ scale_factors: Union[float, Tuple[float, float], List[float]] = 0.05,
20
+ opening_kernel_percentage: float = 0.15,
21
+ save_img: bool = False,
22
+ viz_dir: Path = None,
23
+ show_hbb: bool = True,
24
+ show_masks: bool = True,
25
+ show_segments: bool = True,
26
+ show_obb: bool = True,
27
+ show_labels: bool = True,
28
+ model_kwargs: Dict[str, Any] = None,
29
+ ) -> np.ndarray:
30
+ """
31
+ Convert HBB to OBB annotations using multiple SAM models and aggregating the masks by majority vote.
32
+
33
+ Args:
34
+ img_path: Path to the image
35
+ hbb_dir: Directory containing HBB annotations
36
+ sam_models: Name(s) of SAM model(s) to use. Multiple models can be specified to average results
37
+ imgsz: Image size for SAM model inference
38
+ scale_factors: Factor(s) to scale HBB bounding boxes.
39
+ If single value: same factor for both dimensions.
40
+ If two values: first for shorter side, second for longer side
41
+ opening_kernel_percentage: Percentage of mask's smaller dimension for morphological opening kernel (0 to disable)
42
+ save_img: Save visualization images
43
+ viz_dir: Directory to save visualization images
44
+ show_hbb: Show horizontal bounding boxes
45
+ show_masks: Show segmentation masks
46
+ show_segments: Show segmentation contours
47
+ show_obb: Show oriented bounding boxes
48
+ show_labels: Show class labels
49
+ model_kwargs: Additional keyword arguments for the SAM model
50
+
51
+
52
+ as a numpy array OBB annotations as a numpy array
53
+ """
54
+ hbb_dir = get_hbb_dir(img_path, hbb_dir)
55
+
56
+ # Read the image
57
+ img = cv2.imread(str(img_path))
58
+
59
+ # Load HBB annotations and scale them
60
+ annotations = Annotations(hbb_dir / (img_path.stem + ".txt"), img)
61
+ bbox_prompts = scale_bounding_boxes(annotations, scale_factors)
62
+
63
+ # Convert single model to list for consistent processing
64
+ if isinstance(sam_models, str):
65
+ sam_models = [sam_models]
66
+
67
+ if model_kwargs is None:
68
+ model_kwargs = {}
69
+ masks_all_models = []
70
+
71
+ # Run each model and collect results
72
+ for model_name in sam_models:
73
+ model_path = Path('models') / (model_name if model_name.endswith(".pt") else f"{model_name}.pt")
74
+ if "FastSAM" in model_name:
75
+ model = FastSAM(model_path)
76
+ else:
77
+ model = SAM(model_path)
78
+
79
+ # Run inference with the model
80
+ results = model(
81
+ img,
82
+ bboxes=bbox_prompts[:, 1:],
83
+ retina_masks=True,
84
+ exist_ok=True,
85
+ verbose=False,
86
+ imgsz=imgsz,
87
+ **model_kwargs,
88
+ )
89
+
90
+ result = results[0]
91
+ if result.masks is not None:
92
+ masks = result.masks.cpu().numpy()
93
+ masks_all_models.append(masks.data)
94
+ else:
95
+ print(f"Warning: Model {model_name} produced no masks for {img_path.name}")
96
+
97
+ # Convert segmentation masks within HBBs to OBB annotations
98
+ obb_annotations, aggregated_masks, contours = create_obb_annotations_multi_model(
99
+ bbox_prompts, masks_all_models, opening_kernel_percentage
100
+ )
101
+
102
+ # Save visualization images if enabled
103
+ if save_img:
104
+ visualize_obb_annotations(
105
+ img,
106
+ bbox_prompts,
107
+ aggregated_masks,
108
+ contours,
109
+ obb_annotations,
110
+ viz_dir,
111
+ img_path,
112
+ show_hbb=show_hbb,
113
+ show_masks=show_masks,
114
+ show_segments=show_segments,
115
+ show_obb=show_obb,
116
+ show_labels=show_labels,
117
+ )
118
+
119
+ return obb_annotations
120
+
121
+
122
+ def create_obb_annotations_multi_model(
123
+ hbb_boxes: np.ndarray, masks_all_models: List[np.ndarray], opening_kernel_percentage: float
124
+ ) -> Tuple[np.ndarray, List[np.ndarray], List[np.ndarray]]:
125
+ """
126
+ Convert segmentation masks from multiple SAM models inside the HBBs to OBB an
127
+ using majority voting for mask aggregation and return aggregated masks and contoursnotations
128
+ using majority voting for mask aggregation and return aggregated masks and contours.
129
+
130
+ Args:
131
+ hbb_boxes: HBB annotations as numpy array
132
+ masks_all_models: List of masks from different SAM models
133
+ opening_kernel_percentage: Percentage of mask size for morphological opening kernel (0 to disable)
134
+
135
+ Returns:
136
+ Tuple containing:
137
+ - List of OBB annotations
138
+ - List of aggregated and HBB-cropped masks
139
+ - List of contours
140
+ """
141
+ obb_annotations = []
142
+ aggregated_masks = []
143
+ contours = []
144
+
145
+ for hbb_box in hbb_boxes:
146
+ label, xmin, ymin, xmax, ymax = hbb_box
147
+
148
+ # Convert to integers for mask indexing
149
+ x_min, y_min = max(0, int(xmin)), max(0, int(ymin))
150
+ x_max, y_max = int(xmax), int(ymax)
151
+
152
+ # Find the best mask for each model
153
+ best_hbb_masks = []
154
+
155
+ for masks in masks_all_models:
156
+ # Find the mask with maximum overlap with the bounding box
157
+ best_model_mask = None
158
+ max_overlap = 0
159
+
160
+ for mask in masks:
161
+ # Calculate overlap between mask and bounding box
162
+ overlap = mask[y_min : y_max + 1, x_min : x_max + 1].sum()
163
+ if overlap > max_overlap:
164
+ best_model_mask = mask.copy()
165
+ max_overlap = overlap
166
+
167
+ # If a valid mask was found, add it to the list
168
+ if best_model_mask is not None and max_overlap > 0:
169
+ best_hbb_masks.append(best_model_mask)
170
+
171
+ # If no valid masks were found, use the HBB as OBB
172
+ if not best_hbb_masks:
173
+ box_points = [x_min, y_min, x_max, y_min, x_max, y_max, x_min, y_max]
174
+ obb_annotations.append([int(label), *box_points])
175
+ aggregated_masks.append(None)
176
+ contours.append(None)
177
+ continue
178
+
179
+ # Aggregate masks using majority voting
180
+ aggregated_hbb_mask = aggregate_masks_by_majority_vote(best_hbb_masks)
181
+
182
+ # Constrain mask to image dimensions
183
+ mask_height, mask_width = aggregated_hbb_mask.shape
184
+ x_min_c = max(0, x_min)
185
+ y_min_c = max(0, y_min)
186
+ x_max_c = min(mask_width - 1, x_max)
187
+ y_max_c = min(mask_height - 1, y_max)
188
+
189
+ # Crop mask to bounding box
190
+ aggregated_hbb_mask_cropped = aggregated_hbb_mask.copy()
191
+ aggregated_hbb_mask_cropped[:, : x_min_c + 1] = False
192
+ aggregated_hbb_mask_cropped[:, x_max_c:] = False
193
+ aggregated_hbb_mask_cropped[: y_min_c + 1, :] = False
194
+ aggregated_hbb_mask_cropped[y_max_c:, :] = False
195
+
196
+ # Apply morphological opening to remove small objects / thin protrusions
197
+ aggregated_hbb_mask_final = apply_morphological_opening(aggregated_hbb_mask_cropped, opening_kernel_percentage)
198
+
199
+ # Store the final mask
200
+ aggregated_masks.append(aggregated_hbb_mask_final)
201
+
202
+ # Find contours and minimum area rectangle
203
+ hbb_contours, _ = cv2.findContours(
204
+ aggregated_hbb_mask_final.astype(np.uint8), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
205
+ )
206
+
207
+ # Filter valid contours based on shape heuristics
208
+ valid_hbb_contours = [c for c in hbb_contours if is_valid_contour(c, hbb_area=(xmax - xmin) * (ymax - ymin))]
209
+
210
+ # Fall back to original HBB if no valid contours found
211
+ if not valid_hbb_contours:
212
+ box_points = [x_min, y_min, x_max, y_min, x_max, y_max, x_min, y_max]
213
+ obb_annotations.append([int(label), *box_points])
214
+ contours.append(None)
215
+ continue
216
+
217
+ # Choose largest valid contour
218
+ largest_hbb_contour = max(valid_hbb_contours, key=cv2.contourArea)
219
+ contours.append(largest_hbb_contour)
220
+
221
+ # Compute OBB
222
+ rect = cv2.minAreaRect(largest_hbb_contour)
223
+ box_points = cv2.boxPoints(rect).flatten().astype(np.int32)
224
+ obb_annotations.append([int(label), *box_points])
225
+
226
+ return (np.array(obb_annotations) if obb_annotations else np.array([]), aggregated_masks, contours)
227
+
228
+
229
+ def scale_bounding_boxes(
230
+ annotations: Annotations, factors: Union[float, Tuple[float, float], List[float]]
231
+ ) -> np.ndarray:
232
+ """
233
+ Scale bounding boxes according to specified factors while ensuring they stay within image dimensions.
234
+
235
+ Args:
236
+ annotations: Annotations object containing bounding boxes and image dimensions
237
+ factors: Scale factor(s).
238
+ If a single value, both dimensions are scaled by the same factor.
239
+ If two values, the first applies to the shorter side and the second to the longer side.
240
+
241
+ Returns:
242
+ Scaled bounding boxes
243
+ """
244
+ boxes = annotations.hbb_xyxy
245
+ width, height = annotations.img_shape
246
+
247
+ # Convert factors to a tuple with two values
248
+ if isinstance(factors, (list, tuple)):
249
+ if len(factors) > 2:
250
+ short_factor, long_factor = factors[0], factors[1]
251
+ print(f"Warning: More than two scale factors provided. Using the first two: {short_factor}, {long_factor}")
252
+ elif len(factors) == 2:
253
+ short_factor, long_factor = factors[0], factors[1]
254
+ elif len(factors) == 1:
255
+ short_factor = long_factor = factors[0]
256
+ else:
257
+ short_factor = long_factor = 0.05
258
+ else:
259
+ short_factor = long_factor = float(factors)
260
+
261
+ scaled_bounding_boxes = []
262
+ for box in boxes:
263
+ label, x1, y1, x2, y2 = box
264
+ w = x2 - x1
265
+ h = y2 - y1
266
+
267
+ # Determine which factor to use for each dimension
268
+ if w < h:
269
+ w_factor, h_factor = short_factor, long_factor
270
+ else:
271
+ w_factor, h_factor = long_factor, short_factor
272
+
273
+ # Apply the appropriate factors to each side
274
+ x1 = max(0, x1 - w * w_factor)
275
+ y1 = max(0, y1 - h * h_factor)
276
+ x2 = min(width - 1, x2 + w * w_factor)
277
+ y2 = min(height - 1, y2 + h * h_factor)
278
+
279
+ scaled_bounding_boxes.append([label, x1, y1, x2, y2])
280
+
281
+ return np.array(scaled_bounding_boxes)
282
+
283
+
284
+ def aggregate_masks_by_majority_vote(masks: List[np.ndarray]) -> np.ndarray:
285
+ """
286
+ Aggregate multiple masks using majority voting.
287
+ For each pixel, it's kept if it's confirmed by the majority of models.
288
+
289
+ Args:
290
+ masks: List of boolean masks to aggregate
291
+
292
+ Returns:
293
+ Aggregated boolean mask
294
+ """
295
+ # Stack masks along a new axis
296
+ stacked_masks = np.stack(masks, axis=0)
297
+
298
+ # Determine threshold for majority
299
+ threshold = len(masks) // 2 + 1
300
+
301
+ # Apply majority voting: sum across models and threshold
302
+ aggregated_mask = np.sum(stacked_masks, axis=0) >= threshold
303
+
304
+ return aggregated_mask
305
+
306
+
307
+ def is_valid_contour(
308
+ contour: np.ndarray, hbb_area: float, min_extent: float = 0.2, min_area_ratio: float = 0.1
309
+ ) -> bool:
310
+ """
311
+ Determine if a given contour is valid.
312
+
313
+ Args:
314
+ contour: Contour to evaluate
315
+ hbb_area: Area of the bounding box used for the SAM prompt
316
+ min_extent: Minimum extent (contour area / bounding rect area)
317
+ min_area_ratio: Minimum ratio of contour area to HBB area
318
+
319
+ Returns:
320
+ True if the contour is valid
321
+ """
322
+ if contour is None or len(contour) < 4:
323
+ return False
324
+
325
+ contour_area = cv2.contourArea(contour)
326
+ if contour_area < min_area_ratio * hbb_area:
327
+ return False
328
+
329
+ w, h = cv2.boundingRect(contour)[2:4]
330
+ rect_area = w * h
331
+ if rect_area == 0:
332
+ return False
333
+
334
+ extent = contour_area / rect_area
335
+ if extent < min_extent:
336
+ return False
337
+
338
+ return True
339
+
340
+
341
+ def visualize_obb_annotations(
342
+ img: np.ndarray,
343
+ bbox_prompts: np.ndarray,
344
+ aggregated_masks: List[np.ndarray],
345
+ contours: List[np.ndarray],
346
+ obb_annotations: np.ndarray,
347
+ viz_dir: Path,
348
+ img_path: Path,
349
+ show_hbb: bool = True,
350
+ show_masks: bool = True,
351
+ show_segments: bool = True,
352
+ show_obb: bool = True,
353
+ show_labels: bool = True,
354
+ ):
355
+ """
356
+ Visualize HBB, OBB, and segmentation masks on the image based on visualization flags.
357
+ """
358
+ # Draw HBBs if enabled
359
+ if show_hbb:
360
+ for hbb in bbox_prompts:
361
+ _, x1, y1, x2, y2 = hbb
362
+ cv2.rectangle(img, (int(x1), int(y1)), (int(x2), int(y2)), (255, 0, 0), 2)
363
+
364
+ # Draw segmentation masks if enabled
365
+ if show_masks and aggregated_masks:
366
+ for mask in aggregated_masks:
367
+ if mask is not None:
368
+ green_mask = np.zeros_like(img)
369
+ green_mask[:, :, 1] = 255 * mask.astype(np.uint8)
370
+ img = cv2.addWeighted(img, 1, green_mask, 0.5, 0)
371
+
372
+ # Draw segmentation contours if enabled
373
+ if show_segments and contours:
374
+ for segment in contours:
375
+ if segment is not None:
376
+ cv2.drawContours(img, [segment], 0, (0, 0, 255), 2)
377
+
378
+ # Draw OBBs and labels if enabled
379
+ if show_obb or show_labels:
380
+ for obb in obb_annotations:
381
+ label, x1, y1, x2, y2, x3, y3, x4, y4 = obb
382
+
383
+ # Draw OBB polygons
384
+ if show_obb:
385
+ cv2.polylines(
386
+ img, [np.array([[x1, y1], [x2, y2], [x3, y3], [x4, y4]], np.int32)], True, (0, 255, 255), 3
387
+ )
388
+
389
+ # Draw class labels
390
+ if show_labels:
391
+ text = str(int(label))
392
+ text_size = cv2.getTextSize(text, cv2.FONT_HERSHEY_SIMPLEX, 0.7, 2)[0]
393
+
394
+ # Ensure text is inside the image
395
+ text_x = max(int(x1), 5)
396
+ text_y = max(int(y1), text_size[1] + 5)
397
+
398
+ # Add background rectangle
399
+ cv2.rectangle(
400
+ img,
401
+ (text_x - 2, text_y - text_size[1] - 2),
402
+ (text_x + text_size[0] + 2, text_y + 2),
403
+ (0, 255, 255),
404
+ -1,
405
+ )
406
+ cv2.putText(img, text, (text_x, text_y), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 0), 2)
407
+
408
+ if viz_dir is None:
409
+ viz_dir = img_path.parent.parent / "labels_obb"
410
+ viz_dir.mkdir(exist_ok=True, parents=True)
411
+
412
+ cv2.imwrite(str(viz_dir / img_path.name), img)
413
+ print(f"Saved image with OBB annotations: {viz_dir / img_path.name}")
414
+
415
+
416
+ def save_obb_annotations(obb_annotations: np.ndarray, obb_dir: Path, img_path: Path):
417
+ """
418
+ Save OBB annotations to a text file.
419
+ """
420
+ if obb_dir is None:
421
+ obb_dir = img_path.parent.parent / "labels_obb"
422
+ obb_dir.mkdir(exist_ok=True, parents=True)
423
+ save_filepath = obb_dir / (img_path.stem + ".txt")
424
+
425
+ with open(save_filepath, "w", encoding="utf-8") as f:
426
+ for obb in obb_annotations:
427
+ label, x1, y1, x2, y2, x3, y3, x4, y4 = map(int, obb)
428
+ f.write(f"{label} {x1} {y1} {x2} {y2} {x3} {y3} {x4} {y4}\n")
429
+
430
+
431
+ def apply_morphological_opening(mask: np.ndarray, kernel_percentage: float) -> np.ndarray:
432
+ """
433
+ Applies morphological opening to a boolean mask to remove small objects / thin protrusions.
434
+
435
+ Args:
436
+ mask: The input boolean mask (True for foreground) or None.
437
+ kernel_percentage: The percentage of the mask's smaller dimension to use as kernel size.
438
+ If kernel_percentage <= 0 or mask is None, the original mask is returned unchanged.
439
+
440
+ Returns:
441
+ The processed boolean mask, or None if the input was None.
442
+ """
443
+ # Return immediately if opening is disabled or the mask is invalid/None
444
+ if kernel_percentage <= 0 or mask is None or mask.size == 0 or not mask.any():
445
+ return mask
446
+
447
+ # Ensure mask is boolean before converting to uint8
448
+ if mask.dtype != bool:
449
+ mask = mask.astype(bool)
450
+
451
+ # Calculate kernel size as a percentage of the smaller dimension of the bounding box
452
+ contours, _ = cv2.findContours(mask.astype(np.uint8), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
453
+ if not contours:
454
+ return mask
455
+
456
+ # Get the bounding box of the largest contour
457
+ w, h = cv2.boundingRect(max(contours, key=cv2.contourArea))[2:4]
458
+
459
+ # Calculate the smaller dimension of the bounding box
460
+ smaller_dim = min(w, h)
461
+
462
+ # Calculate the kernel size based on the smaller dimension
463
+ kernel_size = max(1, int(smaller_dim * kernel_percentage))
464
+
465
+ # Ensure kernel size is odd
466
+ kernel_size = kernel_size + 1 if kernel_size % 2 == 0 else kernel_size
467
+
468
+ # Ensure kernel size is at least 3x3
469
+ kernel_size = max(3, kernel_size)
470
+
471
+ # Create the kernel
472
+ kernel = np.ones((kernel_size, kernel_size), np.uint8)
473
+
474
+ # Convert boolean mask to uint8 (0 and 255) for OpenCV function
475
+ mask_uint8 = mask.astype(np.uint8) * 255
476
+
477
+ # Apply morphological opening
478
+ opened_mask_uint8 = cv2.morphologyEx(mask_uint8, cv2.MORPH_OPEN, kernel)
479
+
480
+ # Convert back to boolean mask
481
+ processed_mask = opened_mask_uint8 > 0
482
+
483
+ return processed_mask