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 +8 -0
- hbb2obb/__version__.py +1 -0
- hbb2obb/cli.py +134 -0
- hbb2obb/converter.py +483 -0
- hbb2obb/evaluator.py +391 -0
- hbb2obb/utils.py +224 -0
- hbb2obb-1.0.0.dist-info/METADATA +496 -0
- hbb2obb-1.0.0.dist-info/RECORD +12 -0
- hbb2obb-1.0.0.dist-info/WHEEL +5 -0
- hbb2obb-1.0.0.dist-info/entry_points.txt +3 -0
- hbb2obb-1.0.0.dist-info/licenses/LICENSE +21 -0
- hbb2obb-1.0.0.dist-info/top_level.txt +1 -0
hbb2obb/__init__.py
ADDED
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
|