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/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
+ )