argus-cv 1.4.0__py3-none-any.whl → 1.5.1__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.
Potentially problematic release.
This version of argus-cv might be problematic. Click here for more details.
- argus/__init__.py +1 -1
- argus/cli.py +345 -1
- argus/core/__init__.py +20 -0
- argus/core/coco.py +46 -8
- argus/core/convert.py +277 -0
- argus/core/filter.py +670 -0
- argus/core/yolo.py +29 -0
- {argus_cv-1.4.0.dist-info → argus_cv-1.5.1.dist-info}/METADATA +1 -1
- argus_cv-1.5.1.dist-info/RECORD +16 -0
- argus_cv-1.4.0.dist-info/RECORD +0 -14
- {argus_cv-1.4.0.dist-info → argus_cv-1.5.1.dist-info}/WHEEL +0 -0
- {argus_cv-1.4.0.dist-info → argus_cv-1.5.1.dist-info}/entry_points.txt +0 -0
argus/core/convert.py
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""Conversion functions for dataset format transformation."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import shutil
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import cv2
|
|
10
|
+
import numpy as np
|
|
11
|
+
import yaml
|
|
12
|
+
from numpy.typing import NDArray
|
|
13
|
+
|
|
14
|
+
from argus.core.mask import MaskDataset
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class ConversionParams:
|
|
21
|
+
"""Parameters for mask-to-polygon conversion.
|
|
22
|
+
|
|
23
|
+
Attributes:
|
|
24
|
+
class_id: Class ID for the resulting polygon.
|
|
25
|
+
epsilon_factor: Douglas-Peucker simplification factor (relative to perimeter).
|
|
26
|
+
min_area: Minimum contour area in pixels to include.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
class_id: int = 0
|
|
30
|
+
epsilon_factor: float = 0.005
|
|
31
|
+
min_area: float = 100.0
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class Polygon:
|
|
36
|
+
"""A polygon annotation with class ID and normalized points.
|
|
37
|
+
|
|
38
|
+
Attributes:
|
|
39
|
+
class_id: Class ID for this polygon.
|
|
40
|
+
points: List of (x, y) points normalized to [0, 1].
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
class_id: int
|
|
44
|
+
points: list[tuple[float, float]]
|
|
45
|
+
|
|
46
|
+
def to_yolo(self) -> str:
|
|
47
|
+
"""Convert to YOLO segmentation format string.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
String in format: "class_id x1 y1 x2 y2 ... xn yn"
|
|
51
|
+
"""
|
|
52
|
+
coords = " ".join(f"{x:.6f} {y:.6f}" for x, y in self.points)
|
|
53
|
+
return f"{self.class_id} {coords}"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def mask_to_polygons(
|
|
57
|
+
mask: NDArray[np.uint8],
|
|
58
|
+
params: ConversionParams | None = None,
|
|
59
|
+
) -> list[Polygon]:
|
|
60
|
+
"""Convert a binary mask to simplified polygons.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
mask: Binary mask (255 for foreground, 0 for background).
|
|
64
|
+
params: Conversion parameters. Uses defaults if None.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
List of Polygon objects with normalized coordinates.
|
|
68
|
+
"""
|
|
69
|
+
if params is None:
|
|
70
|
+
params = ConversionParams()
|
|
71
|
+
|
|
72
|
+
h, w = mask.shape[:2]
|
|
73
|
+
polygons: list[Polygon] = []
|
|
74
|
+
|
|
75
|
+
# Find contours
|
|
76
|
+
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
|
77
|
+
|
|
78
|
+
for contour in contours:
|
|
79
|
+
area = cv2.contourArea(contour)
|
|
80
|
+
if area < params.min_area:
|
|
81
|
+
continue
|
|
82
|
+
|
|
83
|
+
# Simplify polygon using Douglas-Peucker
|
|
84
|
+
perimeter = cv2.arcLength(contour, closed=True)
|
|
85
|
+
epsilon = params.epsilon_factor * perimeter
|
|
86
|
+
simplified = cv2.approxPolyDP(contour, epsilon, closed=True)
|
|
87
|
+
|
|
88
|
+
# Need at least 3 points for a valid polygon
|
|
89
|
+
if len(simplified) < 3:
|
|
90
|
+
continue
|
|
91
|
+
|
|
92
|
+
# Normalize coordinates to [0, 1]
|
|
93
|
+
points: list[tuple[float, float]] = []
|
|
94
|
+
for point in simplified:
|
|
95
|
+
x, y = point[0]
|
|
96
|
+
points.append((x / w, y / h))
|
|
97
|
+
|
|
98
|
+
polygons.append(Polygon(class_id=params.class_id, points=points))
|
|
99
|
+
|
|
100
|
+
return polygons
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def convert_mask_to_yolo_labels(
|
|
104
|
+
mask: np.ndarray,
|
|
105
|
+
class_ids: list[int],
|
|
106
|
+
epsilon_factor: float = 0.005,
|
|
107
|
+
min_area: float = 100.0,
|
|
108
|
+
) -> list[str]:
|
|
109
|
+
"""Convert a multi-class mask to YOLO label lines.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
mask: Grayscale mask where pixel values represent class IDs.
|
|
113
|
+
class_ids: List of class IDs to extract (excluding ignore index).
|
|
114
|
+
epsilon_factor: Douglas-Peucker simplification factor.
|
|
115
|
+
min_area: Minimum contour area in pixels.
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
List of YOLO format label strings.
|
|
119
|
+
"""
|
|
120
|
+
lines: list[str] = []
|
|
121
|
+
|
|
122
|
+
for class_id in class_ids:
|
|
123
|
+
# Create binary mask for this class
|
|
124
|
+
binary_mask = (mask == class_id).astype(np.uint8) * 255
|
|
125
|
+
|
|
126
|
+
params = ConversionParams(
|
|
127
|
+
class_id=class_id,
|
|
128
|
+
epsilon_factor=epsilon_factor,
|
|
129
|
+
min_area=min_area,
|
|
130
|
+
)
|
|
131
|
+
polygons = mask_to_polygons(binary_mask, params)
|
|
132
|
+
lines.extend(poly.to_yolo() for poly in polygons)
|
|
133
|
+
|
|
134
|
+
return lines
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def convert_mask_to_yolo_seg(
|
|
138
|
+
dataset: MaskDataset,
|
|
139
|
+
output_path: Path,
|
|
140
|
+
epsilon_factor: float = 0.005,
|
|
141
|
+
min_area: float = 100.0,
|
|
142
|
+
progress_callback: Callable[[int, int], None] | None = None,
|
|
143
|
+
) -> dict[str, int]:
|
|
144
|
+
"""Convert a MaskDataset to YOLO segmentation format.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
dataset: Source MaskDataset to convert.
|
|
148
|
+
output_path: Output directory for YOLO dataset.
|
|
149
|
+
epsilon_factor: Douglas-Peucker simplification factor.
|
|
150
|
+
min_area: Minimum contour area in pixels.
|
|
151
|
+
progress_callback: Optional callback(current, total) for progress updates.
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
Dictionary with conversion statistics:
|
|
155
|
+
- "images": Total images processed
|
|
156
|
+
- "labels": Total label files created
|
|
157
|
+
- "polygons": Total polygons extracted
|
|
158
|
+
- "skipped": Images skipped (no mask or empty)
|
|
159
|
+
- "warnings": Number of warnings (dimension mismatch, etc.)
|
|
160
|
+
"""
|
|
161
|
+
stats = {
|
|
162
|
+
"images": 0,
|
|
163
|
+
"labels": 0,
|
|
164
|
+
"polygons": 0,
|
|
165
|
+
"skipped": 0,
|
|
166
|
+
"warnings": 0,
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
# Create output directory structure
|
|
170
|
+
output_path.mkdir(parents=True, exist_ok=True)
|
|
171
|
+
|
|
172
|
+
# Get class mapping and build id-to-name for data.yaml
|
|
173
|
+
class_mapping = dataset.get_class_mapping()
|
|
174
|
+
class_ids = sorted(class_mapping.keys())
|
|
175
|
+
|
|
176
|
+
# Build data.yaml content
|
|
177
|
+
data_yaml: dict = {
|
|
178
|
+
"path": ".",
|
|
179
|
+
"names": {i: class_mapping[i] for i in class_ids},
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
# Determine splits to process
|
|
183
|
+
splits = dataset.splits if dataset.splits else [None]
|
|
184
|
+
|
|
185
|
+
# Count total images for progress
|
|
186
|
+
total_images = 0
|
|
187
|
+
for split in splits:
|
|
188
|
+
total_images += len(dataset.get_image_paths(split))
|
|
189
|
+
|
|
190
|
+
current_image = 0
|
|
191
|
+
|
|
192
|
+
for split in splits:
|
|
193
|
+
split_name = split if split else "train" # Default to train if unsplit
|
|
194
|
+
|
|
195
|
+
# Create directories
|
|
196
|
+
images_dir = output_path / "images" / split_name
|
|
197
|
+
labels_dir = output_path / "labels" / split_name
|
|
198
|
+
images_dir.mkdir(parents=True, exist_ok=True)
|
|
199
|
+
labels_dir.mkdir(parents=True, exist_ok=True)
|
|
200
|
+
|
|
201
|
+
# Add split to data.yaml
|
|
202
|
+
data_yaml[split_name] = f"images/{split_name}"
|
|
203
|
+
|
|
204
|
+
# Process images in this split
|
|
205
|
+
image_paths = dataset.get_image_paths(split)
|
|
206
|
+
|
|
207
|
+
for image_path in image_paths:
|
|
208
|
+
current_image += 1
|
|
209
|
+
if progress_callback:
|
|
210
|
+
progress_callback(current_image, total_images)
|
|
211
|
+
|
|
212
|
+
stats["images"] += 1
|
|
213
|
+
|
|
214
|
+
# Load mask
|
|
215
|
+
mask = dataset.load_mask(image_path)
|
|
216
|
+
if mask is None:
|
|
217
|
+
logger.warning(f"No mask found for {image_path.name}, skipping")
|
|
218
|
+
stats["skipped"] += 1
|
|
219
|
+
continue
|
|
220
|
+
|
|
221
|
+
# Load image to check dimensions
|
|
222
|
+
img = cv2.imread(str(image_path))
|
|
223
|
+
if img is None:
|
|
224
|
+
logger.warning(f"Could not load image {image_path.name}, skipping")
|
|
225
|
+
stats["skipped"] += 1
|
|
226
|
+
continue
|
|
227
|
+
|
|
228
|
+
# Check dimension match
|
|
229
|
+
if img.shape[:2] != mask.shape[:2]:
|
|
230
|
+
logger.warning(
|
|
231
|
+
f"Dimension mismatch for {image_path.name}: "
|
|
232
|
+
f"image={img.shape[:2]}, mask={mask.shape[:2]}"
|
|
233
|
+
)
|
|
234
|
+
stats["warnings"] += 1
|
|
235
|
+
# Continue anyway - mask might still be usable
|
|
236
|
+
|
|
237
|
+
# Get unique class IDs present in this mask (excluding ignore index)
|
|
238
|
+
unique_ids = [
|
|
239
|
+
int(v)
|
|
240
|
+
for v in np.unique(mask)
|
|
241
|
+
if v != dataset.ignore_index and v in class_ids
|
|
242
|
+
]
|
|
243
|
+
|
|
244
|
+
if not unique_ids:
|
|
245
|
+
# Empty mask (only background/ignored)
|
|
246
|
+
logger.debug(f"Empty mask for {image_path.name}")
|
|
247
|
+
stats["skipped"] += 1
|
|
248
|
+
continue
|
|
249
|
+
|
|
250
|
+
# Convert mask to YOLO labels
|
|
251
|
+
label_lines = convert_mask_to_yolo_labels(
|
|
252
|
+
mask, unique_ids, epsilon_factor, min_area
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
if not label_lines:
|
|
256
|
+
# No polygons extracted (all contours too small)
|
|
257
|
+
logger.debug(f"No valid polygons for {image_path.name}")
|
|
258
|
+
stats["skipped"] += 1
|
|
259
|
+
continue
|
|
260
|
+
|
|
261
|
+
# Copy image to output
|
|
262
|
+
dest_image = images_dir / image_path.name
|
|
263
|
+
shutil.copy2(image_path, dest_image)
|
|
264
|
+
|
|
265
|
+
# Write label file
|
|
266
|
+
label_file = labels_dir / f"{image_path.stem}.txt"
|
|
267
|
+
label_file.write_text("\n".join(label_lines) + "\n")
|
|
268
|
+
|
|
269
|
+
stats["labels"] += 1
|
|
270
|
+
stats["polygons"] += len(label_lines)
|
|
271
|
+
|
|
272
|
+
# Write data.yaml
|
|
273
|
+
data_yaml_path = output_path / "data.yaml"
|
|
274
|
+
with open(data_yaml_path, "w") as f:
|
|
275
|
+
yaml.dump(data_yaml, f, default_flow_style=False, sort_keys=False)
|
|
276
|
+
|
|
277
|
+
return stats
|