bplusplus 2.0.4__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.
- bplusplus/__init__.py +15 -0
- bplusplus/collect.py +523 -0
- bplusplus/detector.py +376 -0
- bplusplus/inference.py +1337 -0
- bplusplus/prepare.py +706 -0
- bplusplus/tracker.py +261 -0
- bplusplus/train.py +913 -0
- bplusplus/validation.py +580 -0
- bplusplus-2.0.4.dist-info/LICENSE +21 -0
- bplusplus-2.0.4.dist-info/METADATA +259 -0
- bplusplus-2.0.4.dist-info/RECORD +12 -0
- bplusplus-2.0.4.dist-info/WHEEL +4 -0
bplusplus/detector.py
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Detection Backend Module
|
|
3
|
+
========================
|
|
4
|
+
|
|
5
|
+
This module provides motion-based insect detection utilities used by the inference pipeline.
|
|
6
|
+
It is NOT meant to be run directly - use inference.py instead.
|
|
7
|
+
|
|
8
|
+
Exports:
|
|
9
|
+
- DEFAULT_DETECTION_CONFIG: Default parameters for detection
|
|
10
|
+
- build_detection_params(): Build detection params dict
|
|
11
|
+
- extract_motion_detections(): Extract detections from a frame
|
|
12
|
+
- Path topology functions for track analysis
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import cv2
|
|
16
|
+
import numpy as np
|
|
17
|
+
from collections import defaultdict
|
|
18
|
+
|
|
19
|
+
# Support both standalone and package import
|
|
20
|
+
try:
|
|
21
|
+
from .tracker import InsectTracker
|
|
22
|
+
except ImportError:
|
|
23
|
+
from tracker import InsectTracker
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# ============================================================================
|
|
27
|
+
# DEFAULT CONFIGURATION
|
|
28
|
+
# ============================================================================
|
|
29
|
+
|
|
30
|
+
DEFAULT_DETECTION_CONFIG = {
|
|
31
|
+
# Cohesiveness parameters
|
|
32
|
+
"min_largest_blob_ratio": 0.80, # Min ratio of largest blob to total motion
|
|
33
|
+
"max_num_blobs": 5, # Max blobs in detection region
|
|
34
|
+
|
|
35
|
+
# Shape parameters
|
|
36
|
+
"min_area": 200, # Min contour area (px²)
|
|
37
|
+
"max_area": 40000, # Max contour area (px²)
|
|
38
|
+
"min_density": 3.0, # Min area/perimeter ratio
|
|
39
|
+
"min_solidity": 0.55, # Min convex hull fill ratio
|
|
40
|
+
|
|
41
|
+
# Tracking parameters
|
|
42
|
+
"min_displacement": 50, # Min NET displacement for confirmation (px)
|
|
43
|
+
"min_path_points": 10, # Min path points for analysis
|
|
44
|
+
"max_frame_jump": 100, # Max pixels between frames
|
|
45
|
+
"lost_track_seconds": 1.5, # Track memory duration (seconds)
|
|
46
|
+
|
|
47
|
+
# Path topology parameters
|
|
48
|
+
"max_revisit_ratio": 0.30, # Max revisit ratio (explores new areas)
|
|
49
|
+
"min_progression_ratio": 0.70, # Min progression ratio (moves forward)
|
|
50
|
+
"max_directional_variance": 0.90, # Max directional variance (consistent heading)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def get_default_config():
|
|
55
|
+
"""Return a copy of the default detection configuration."""
|
|
56
|
+
return DEFAULT_DETECTION_CONFIG.copy()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def build_detection_params(**kwargs):
|
|
60
|
+
"""
|
|
61
|
+
Build detection parameters dict from defaults + overrides.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
**kwargs: Parameter overrides (any key from DEFAULT_DETECTION_CONFIG)
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
dict: Complete detection parameters
|
|
68
|
+
"""
|
|
69
|
+
params = get_default_config()
|
|
70
|
+
for key, value in kwargs.items():
|
|
71
|
+
if key in params:
|
|
72
|
+
params[key] = value
|
|
73
|
+
else:
|
|
74
|
+
raise ValueError(f"Unknown detection parameter: {key}")
|
|
75
|
+
return params
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ============================================================================
|
|
79
|
+
# COHESIVENESS ANALYSIS
|
|
80
|
+
# ============================================================================
|
|
81
|
+
|
|
82
|
+
def is_cohesive_blob(fg_mask_region, bbox_area, min_largest_blob_ratio=0.80, max_num_blobs=10):
|
|
83
|
+
"""
|
|
84
|
+
Check if motion in a region is cohesive (insect) vs scattered (plant).
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
fg_mask_region: Foreground mask of the detection region
|
|
88
|
+
bbox_area: Area of bounding box
|
|
89
|
+
min_largest_blob_ratio: Min ratio of largest blob to total motion
|
|
90
|
+
max_num_blobs: Max number of blobs allowed
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
tuple: (is_cohesive, metrics_dict or None)
|
|
94
|
+
"""
|
|
95
|
+
motion_pixels = np.count_nonzero(fg_mask_region)
|
|
96
|
+
|
|
97
|
+
if motion_pixels == 0:
|
|
98
|
+
return False, None
|
|
99
|
+
|
|
100
|
+
motion_ratio = motion_pixels / bbox_area
|
|
101
|
+
if motion_ratio < 0.15:
|
|
102
|
+
return False, None
|
|
103
|
+
|
|
104
|
+
contours, _ = cv2.findContours(fg_mask_region, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
|
105
|
+
|
|
106
|
+
if len(contours) == 0:
|
|
107
|
+
return False, None
|
|
108
|
+
|
|
109
|
+
num_blobs = len(contours)
|
|
110
|
+
if num_blobs > max_num_blobs:
|
|
111
|
+
return False, None
|
|
112
|
+
|
|
113
|
+
largest_blob = max(contours, key=cv2.contourArea)
|
|
114
|
+
largest_blob_area = cv2.contourArea(largest_blob)
|
|
115
|
+
largest_blob_ratio = largest_blob_area / motion_pixels
|
|
116
|
+
|
|
117
|
+
if largest_blob_ratio < min_largest_blob_ratio:
|
|
118
|
+
return False, None
|
|
119
|
+
|
|
120
|
+
return True, {
|
|
121
|
+
'motion_ratio': motion_ratio,
|
|
122
|
+
'num_blobs': num_blobs,
|
|
123
|
+
'largest_blob_ratio': largest_blob_ratio
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def passes_shape_filters(contour, min_area=200, max_area=40000, min_density=3.0, min_solidity=0.55):
|
|
128
|
+
"""
|
|
129
|
+
Check if contour passes size and shape filters.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
contour: OpenCV contour
|
|
133
|
+
min_area: Minimum area in pixels²
|
|
134
|
+
max_area: Maximum area in pixels²
|
|
135
|
+
min_density: Minimum area/perimeter ratio
|
|
136
|
+
min_solidity: Minimum convex hull fill ratio
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
bool: True if contour passes all filters
|
|
140
|
+
"""
|
|
141
|
+
area = cv2.contourArea(contour)
|
|
142
|
+
|
|
143
|
+
if area < min_area or area > max_area:
|
|
144
|
+
return False
|
|
145
|
+
|
|
146
|
+
perimeter = cv2.arcLength(contour, True)
|
|
147
|
+
if perimeter == 0:
|
|
148
|
+
return False
|
|
149
|
+
|
|
150
|
+
density = area / perimeter
|
|
151
|
+
if density < min_density:
|
|
152
|
+
return False
|
|
153
|
+
|
|
154
|
+
hull = cv2.convexHull(contour)
|
|
155
|
+
hull_area = cv2.contourArea(hull)
|
|
156
|
+
solidity = area / hull_area if hull_area > 0 else 0
|
|
157
|
+
|
|
158
|
+
if solidity < min_solidity:
|
|
159
|
+
return False
|
|
160
|
+
|
|
161
|
+
return True
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# ============================================================================
|
|
165
|
+
# MOTION DETECTION
|
|
166
|
+
# ============================================================================
|
|
167
|
+
|
|
168
|
+
def extract_motion_detections(frame, back_sub, morph_kernel, params):
|
|
169
|
+
"""
|
|
170
|
+
Extract motion-based detections from a frame.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
frame: BGR image frame
|
|
174
|
+
back_sub: cv2.BackgroundSubtractor instance
|
|
175
|
+
morph_kernel: Morphological kernel for noise removal
|
|
176
|
+
params: Detection parameters dict
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
tuple: (detections_list, foreground_mask)
|
|
180
|
+
- detections_list: List of [x1, y1, x2, y2] bounding boxes
|
|
181
|
+
- foreground_mask: Binary foreground mask
|
|
182
|
+
"""
|
|
183
|
+
fg_mask = back_sub.apply(frame)
|
|
184
|
+
fg_mask = cv2.morphologyEx(fg_mask, cv2.MORPH_OPEN, morph_kernel)
|
|
185
|
+
fg_mask = cv2.morphologyEx(fg_mask, cv2.MORPH_CLOSE, morph_kernel)
|
|
186
|
+
|
|
187
|
+
contours, _ = cv2.findContours(fg_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
|
188
|
+
|
|
189
|
+
valid_detections = []
|
|
190
|
+
height, width = frame.shape[:2]
|
|
191
|
+
|
|
192
|
+
for contour in contours:
|
|
193
|
+
if not passes_shape_filters(
|
|
194
|
+
contour,
|
|
195
|
+
params["min_area"],
|
|
196
|
+
params["max_area"],
|
|
197
|
+
params["min_density"],
|
|
198
|
+
params["min_solidity"],
|
|
199
|
+
):
|
|
200
|
+
continue
|
|
201
|
+
|
|
202
|
+
x, y, w, h = cv2.boundingRect(contour)
|
|
203
|
+
region = fg_mask[y:y + h, x:x + w]
|
|
204
|
+
is_cohesive, _ = is_cohesive_blob(
|
|
205
|
+
region,
|
|
206
|
+
w * h,
|
|
207
|
+
params["min_largest_blob_ratio"],
|
|
208
|
+
params["max_num_blobs"],
|
|
209
|
+
)
|
|
210
|
+
if not is_cohesive:
|
|
211
|
+
continue
|
|
212
|
+
|
|
213
|
+
x1, y1, x2, y2 = x, y, x + w, y + h
|
|
214
|
+
x1, y1, x2, y2 = max(0, x1), max(0, y1), min(width, x2), min(height, y2)
|
|
215
|
+
if x2 <= x1 or y2 <= y1:
|
|
216
|
+
continue
|
|
217
|
+
|
|
218
|
+
valid_detections.append([x1, y1, x2, y2])
|
|
219
|
+
|
|
220
|
+
return valid_detections, fg_mask
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
# ============================================================================
|
|
224
|
+
# PATH TOPOLOGY ANALYSIS
|
|
225
|
+
# ============================================================================
|
|
226
|
+
|
|
227
|
+
def calculate_revisit_ratio(path, revisit_radius=50):
|
|
228
|
+
"""
|
|
229
|
+
Calculate how much the path revisits previous locations.
|
|
230
|
+
Low ratio = exploring new areas (insect), High ratio = oscillating (plant).
|
|
231
|
+
"""
|
|
232
|
+
revisit_count = 0
|
|
233
|
+
for i in range(len(path)):
|
|
234
|
+
for j in range(i):
|
|
235
|
+
if np.linalg.norm(path[i] - path[j]) < revisit_radius:
|
|
236
|
+
revisit_count += 1
|
|
237
|
+
|
|
238
|
+
max_revisits = len(path) * (len(path) - 1) / 2
|
|
239
|
+
return revisit_count / (max_revisits + 1e-6)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def calculate_progression_ratio(path):
|
|
243
|
+
"""
|
|
244
|
+
Calculate if path progressively explores outward.
|
|
245
|
+
High ratio = linear progression (insect), Low = backtracking (plant).
|
|
246
|
+
"""
|
|
247
|
+
if len(path) < 2:
|
|
248
|
+
return 0
|
|
249
|
+
|
|
250
|
+
start_point = path[0]
|
|
251
|
+
end_point = path[-1]
|
|
252
|
+
net_displacement = np.linalg.norm(end_point - start_point)
|
|
253
|
+
|
|
254
|
+
progressive_distances = [np.linalg.norm(p - start_point) for p in path]
|
|
255
|
+
max_progressive = max(progressive_distances)
|
|
256
|
+
|
|
257
|
+
return net_displacement / (max_progressive + 1e-6)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def calculate_directional_variance(path):
|
|
261
|
+
"""
|
|
262
|
+
Calculate variance in movement direction.
|
|
263
|
+
Low variance = consistent direction (insect), High = random (plant).
|
|
264
|
+
"""
|
|
265
|
+
if len(path) < 2:
|
|
266
|
+
return 1.0
|
|
267
|
+
|
|
268
|
+
directions = []
|
|
269
|
+
for i in range(1, len(path)):
|
|
270
|
+
dx = path[i][0] - path[i-1][0]
|
|
271
|
+
dy = path[i][1] - path[i-1][1]
|
|
272
|
+
if dx != 0 or dy != 0:
|
|
273
|
+
angle = np.arctan2(dy, dx)
|
|
274
|
+
directions.append(angle)
|
|
275
|
+
|
|
276
|
+
if not directions:
|
|
277
|
+
return 1.0
|
|
278
|
+
|
|
279
|
+
mean_sin = np.mean(np.sin(directions))
|
|
280
|
+
mean_cos = np.mean(np.cos(directions))
|
|
281
|
+
return 1 - np.sqrt(mean_sin**2 + mean_cos**2)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def analyze_path_topology(path, params):
|
|
285
|
+
"""
|
|
286
|
+
Analyze path to determine if it exhibits insect-like movement.
|
|
287
|
+
|
|
288
|
+
Args:
|
|
289
|
+
path: List of (x, y) positions
|
|
290
|
+
params: Detection parameters dict
|
|
291
|
+
|
|
292
|
+
Returns:
|
|
293
|
+
tuple: (passes_criteria, metrics_dict)
|
|
294
|
+
"""
|
|
295
|
+
if len(path) < 3:
|
|
296
|
+
return False, {}
|
|
297
|
+
|
|
298
|
+
path = np.array(path)
|
|
299
|
+
|
|
300
|
+
net_displacement = np.linalg.norm(path[-1] - path[0])
|
|
301
|
+
revisit_ratio = calculate_revisit_ratio(path)
|
|
302
|
+
progression_ratio = calculate_progression_ratio(path)
|
|
303
|
+
directional_variance = calculate_directional_variance(path)
|
|
304
|
+
|
|
305
|
+
metrics = {
|
|
306
|
+
'net_displacement': net_displacement,
|
|
307
|
+
'revisit_ratio': revisit_ratio,
|
|
308
|
+
'progression_ratio': progression_ratio,
|
|
309
|
+
'directional_variance': directional_variance
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
passes = (
|
|
313
|
+
net_displacement >= params["min_displacement"] and
|
|
314
|
+
revisit_ratio <= params["max_revisit_ratio"] and
|
|
315
|
+
progression_ratio >= params["min_progression_ratio"] and
|
|
316
|
+
directional_variance <= params["max_directional_variance"]
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
return passes, metrics
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
# ============================================================================
|
|
323
|
+
# TRACK MANAGEMENT
|
|
324
|
+
# ============================================================================
|
|
325
|
+
|
|
326
|
+
def check_track_consistency(prev_pos, curr_pos, prev_area, curr_area, max_frame_jump):
|
|
327
|
+
"""
|
|
328
|
+
Check if track update is consistent (not a bad match).
|
|
329
|
+
|
|
330
|
+
Args:
|
|
331
|
+
prev_pos: Previous (x, y) position
|
|
332
|
+
curr_pos: Current (x, y) position
|
|
333
|
+
prev_area: Previous bounding box area
|
|
334
|
+
curr_area: Current bounding box area
|
|
335
|
+
max_frame_jump: Maximum allowed position jump
|
|
336
|
+
|
|
337
|
+
Returns:
|
|
338
|
+
bool: True if consistent, False if likely bad match
|
|
339
|
+
"""
|
|
340
|
+
# Position jump check
|
|
341
|
+
frame_jump = np.linalg.norm(np.array(curr_pos) - np.array(prev_pos))
|
|
342
|
+
if frame_jump > max_frame_jump:
|
|
343
|
+
return False
|
|
344
|
+
|
|
345
|
+
# Size change check (3x threshold)
|
|
346
|
+
area_ratio = max(curr_area, prev_area) / (min(curr_area, prev_area) + 1e-6)
|
|
347
|
+
if area_ratio > 3.0:
|
|
348
|
+
return False
|
|
349
|
+
|
|
350
|
+
return True
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def create_tracker(height, width, params):
|
|
354
|
+
"""
|
|
355
|
+
Create an InsectTracker with parameters from config.
|
|
356
|
+
|
|
357
|
+
Args:
|
|
358
|
+
height: Frame height
|
|
359
|
+
width: Frame width
|
|
360
|
+
params: Detection parameters dict
|
|
361
|
+
|
|
362
|
+
Returns:
|
|
363
|
+
InsectTracker: Configured tracker instance
|
|
364
|
+
"""
|
|
365
|
+
# Note: lost_track_seconds needs FPS to convert to frames
|
|
366
|
+
# This is handled by the caller who knows the FPS
|
|
367
|
+
return InsectTracker(
|
|
368
|
+
image_height=height,
|
|
369
|
+
image_width=width,
|
|
370
|
+
max_frames=30, # Will be overridden by caller with FPS info
|
|
371
|
+
track_memory_frames=30,
|
|
372
|
+
w_dist=0.6,
|
|
373
|
+
w_area=0.4,
|
|
374
|
+
cost_threshold=0.3,
|
|
375
|
+
debug=False
|
|
376
|
+
)
|