eye-cv 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.
Files changed (94) hide show
  1. eye/__init__.py +115 -0
  2. eye/__init___supervision_original.py +120 -0
  3. eye/annotators/__init__.py +0 -0
  4. eye/annotators/base.py +22 -0
  5. eye/annotators/core.py +2699 -0
  6. eye/annotators/line.py +107 -0
  7. eye/annotators/modern.py +529 -0
  8. eye/annotators/trace.py +142 -0
  9. eye/annotators/utils.py +177 -0
  10. eye/assets/__init__.py +2 -0
  11. eye/assets/downloader.py +95 -0
  12. eye/assets/list.py +83 -0
  13. eye/classification/__init__.py +0 -0
  14. eye/classification/core.py +188 -0
  15. eye/config.py +2 -0
  16. eye/core/__init__.py +0 -0
  17. eye/core/trackers/__init__.py +1 -0
  18. eye/core/trackers/botsort_tracker.py +336 -0
  19. eye/core/trackers/bytetrack_tracker.py +284 -0
  20. eye/core/trackers/sort_tracker.py +200 -0
  21. eye/core/tracking.py +146 -0
  22. eye/dataset/__init__.py +0 -0
  23. eye/dataset/core.py +919 -0
  24. eye/dataset/formats/__init__.py +0 -0
  25. eye/dataset/formats/coco.py +258 -0
  26. eye/dataset/formats/pascal_voc.py +279 -0
  27. eye/dataset/formats/yolo.py +272 -0
  28. eye/dataset/utils.py +259 -0
  29. eye/detection/__init__.py +0 -0
  30. eye/detection/auto_convert.py +155 -0
  31. eye/detection/core.py +1529 -0
  32. eye/detection/detections_enhanced.py +392 -0
  33. eye/detection/line_zone.py +859 -0
  34. eye/detection/lmm.py +184 -0
  35. eye/detection/overlap_filter.py +270 -0
  36. eye/detection/tools/__init__.py +0 -0
  37. eye/detection/tools/csv_sink.py +181 -0
  38. eye/detection/tools/inference_slicer.py +288 -0
  39. eye/detection/tools/json_sink.py +142 -0
  40. eye/detection/tools/polygon_zone.py +202 -0
  41. eye/detection/tools/smoother.py +123 -0
  42. eye/detection/tools/smoothing.py +179 -0
  43. eye/detection/tools/smoothing_config.py +202 -0
  44. eye/detection/tools/transformers.py +247 -0
  45. eye/detection/utils.py +1175 -0
  46. eye/draw/__init__.py +0 -0
  47. eye/draw/color.py +154 -0
  48. eye/draw/utils.py +374 -0
  49. eye/filters.py +112 -0
  50. eye/geometry/__init__.py +0 -0
  51. eye/geometry/core.py +128 -0
  52. eye/geometry/utils.py +47 -0
  53. eye/keypoint/__init__.py +0 -0
  54. eye/keypoint/annotators.py +442 -0
  55. eye/keypoint/core.py +687 -0
  56. eye/keypoint/skeletons.py +2647 -0
  57. eye/metrics/__init__.py +21 -0
  58. eye/metrics/core.py +72 -0
  59. eye/metrics/detection.py +843 -0
  60. eye/metrics/f1_score.py +648 -0
  61. eye/metrics/mean_average_precision.py +628 -0
  62. eye/metrics/mean_average_recall.py +697 -0
  63. eye/metrics/precision.py +653 -0
  64. eye/metrics/recall.py +652 -0
  65. eye/metrics/utils/__init__.py +0 -0
  66. eye/metrics/utils/object_size.py +158 -0
  67. eye/metrics/utils/utils.py +9 -0
  68. eye/py.typed +0 -0
  69. eye/quick.py +104 -0
  70. eye/tracker/__init__.py +0 -0
  71. eye/tracker/byte_tracker/__init__.py +0 -0
  72. eye/tracker/byte_tracker/core.py +386 -0
  73. eye/tracker/byte_tracker/kalman_filter.py +205 -0
  74. eye/tracker/byte_tracker/matching.py +69 -0
  75. eye/tracker/byte_tracker/single_object_track.py +178 -0
  76. eye/tracker/byte_tracker/utils.py +18 -0
  77. eye/utils/__init__.py +0 -0
  78. eye/utils/conversion.py +132 -0
  79. eye/utils/file.py +159 -0
  80. eye/utils/image.py +794 -0
  81. eye/utils/internal.py +200 -0
  82. eye/utils/iterables.py +84 -0
  83. eye/utils/notebook.py +114 -0
  84. eye/utils/video.py +307 -0
  85. eye/utils_eye/__init__.py +1 -0
  86. eye/utils_eye/geometry.py +71 -0
  87. eye/utils_eye/nms.py +55 -0
  88. eye/validators/__init__.py +140 -0
  89. eye/web.py +271 -0
  90. eye_cv-1.0.0.dist-info/METADATA +319 -0
  91. eye_cv-1.0.0.dist-info/RECORD +94 -0
  92. eye_cv-1.0.0.dist-info/WHEEL +5 -0
  93. eye_cv-1.0.0.dist-info/licenses/LICENSE +21 -0
  94. eye_cv-1.0.0.dist-info/top_level.txt +1 -0
eye/annotators/line.py ADDED
@@ -0,0 +1,107 @@
1
+ """Line zone counting and annotator (supervision-like LineZone).
2
+
3
+ Minimal implementation: track per-tracker last side of the line and detect crossings.
4
+ """
5
+
6
+ import cv2
7
+ import numpy as np
8
+ from typing import Tuple, List, Optional
9
+ from ..core.detections import Detections
10
+
11
+
12
+ class LineZone:
13
+ """A line detector that counts when tracked objects cross the line.
14
+
15
+ The line is defined by two points (x1,y1)->(x2,y2). Use `update(detections)`
16
+ each frame to register detections (requires `detections.tracker_id`).
17
+ """
18
+
19
+ def __init__(self, p1: Tuple[int, int], p2: Tuple[int, int], name: Optional[str] = None):
20
+ self.p1 = np.array(p1, dtype=float)
21
+ self.p2 = np.array(p2, dtype=float)
22
+ self.name = name or f"LineZone_{id(self)}"
23
+
24
+ # analytics
25
+ self.total_count = 0
26
+ self.directional_count = {"pos->neg": 0, "neg->pos": 0}
27
+ # last seen side per tracker id
28
+ self._last_side = {}
29
+
30
+ def _side(self, point: Tuple[float, float]) -> int:
31
+ # sign of cross product from line to point
32
+ px, py = float(point[0]), float(point[1])
33
+ x1, y1 = self.p1
34
+ x2, y2 = self.p2
35
+ return int(np.sign((x2 - x1) * (py - y1) - (y2 - y1) * (px - x1)))
36
+
37
+ def update(self, detections: Detections) -> List[int]:
38
+ """Process detections, update counts, and return list of tracker_ids that crossed."""
39
+ crossed = []
40
+ if len(detections) == 0 or detections.tracker_id is None:
41
+ return crossed
42
+
43
+ centers = detections.center
44
+ for i in range(len(detections)):
45
+ tid = int(detections.tracker_id[i])
46
+ c = centers[i]
47
+ side = self._side(c)
48
+ last = self._last_side.get(tid, None)
49
+ if last is None:
50
+ # first observation
51
+ self._last_side[tid] = side
52
+ continue
53
+
54
+ if side == 0 or last == 0:
55
+ # touching line: treat as no-cross
56
+ self._last_side[tid] = side
57
+ continue
58
+
59
+ if side != last:
60
+ # crossed
61
+ crossed.append(tid)
62
+ if last > 0 and side < 0:
63
+ self.directional_count["pos->neg"] += 1
64
+ elif last < 0 and side > 0:
65
+ self.directional_count["neg->pos"] += 1
66
+ self.total_count += 1
67
+ self._last_side[tid] = side
68
+
69
+ return crossed
70
+
71
+ def reset(self):
72
+ self.total_count = 0
73
+ self.directional_count = {"pos->neg": 0, "neg->pos": 0}
74
+ self._last_side.clear()
75
+
76
+
77
+ class LineZoneAnnotator:
78
+ """Annotator that draws a line and simple counters on the frame."""
79
+
80
+ def __init__(self, color=(0, 255, 0), thickness: int = 2, font_scale: float = 0.6, padding: int = 6):
81
+ self.color = color
82
+ self.thickness = thickness
83
+ self.font_scale = font_scale
84
+ self.padding = padding
85
+
86
+ def annotate(self, image: np.ndarray, line: LineZone) -> np.ndarray:
87
+ annotated = image.copy()
88
+ x1, y1 = int(line.p1[0]), int(line.p1[1])
89
+ x2, y2 = int(line.p2[0]), int(line.p2[1])
90
+
91
+ cv2.line(annotated, (x1, y1), (x2, y2), self.color, self.thickness)
92
+
93
+ # draw arrow to indicate primary direction (p1->p2)
94
+ vx, vy = x2 - x1, y2 - y1
95
+ norm = max(1.0, (vx * vx + vy * vy) ** 0.5)
96
+ ux, uy = int(x1 + 0.8 * vx), int(y1 + 0.8 * vy)
97
+ cv2.arrowedLine(annotated, (ux, uy), (x2, y2), self.color, max(1, self.thickness - 1), tipLength=0.2)
98
+
99
+ # draw counts
100
+ text = f"Total: {line.total_count} +:{line.directional_count.get('neg->pos',0)} -:{line.directional_count.get('pos->neg',0)}"
101
+ (w, h), _ = cv2.getTextSize(text, cv2.FONT_HERSHEY_SIMPLEX, self.font_scale, 1)
102
+ x_text = min(max(10, x1), annotated.shape[1] - w - 10)
103
+ y_text = min(max(10, y1 - 10), annotated.shape[0] - 10)
104
+ cv2.rectangle(annotated, (x_text - self.padding, y_text - h - self.padding), (x_text + w + self.padding, y_text + self.padding), (0, 0, 0), -1)
105
+ cv2.putText(annotated, text, (x_text, y_text), cv2.FONT_HERSHEY_SIMPLEX, self.font_scale, self.color, 1)
106
+
107
+ return annotated
@@ -0,0 +1,529 @@
1
+ """Modern, stunning mask annotator with alpha blending."""
2
+
3
+ import cv2
4
+ import numpy as np
5
+ from typing import Optional
6
+ from eye.detection.core import Detections
7
+ from eye.draw.color import ColorPalette
8
+
9
+
10
+ class MaskAnnotator:
11
+ """Draw segmentation masks with transparency and effects.
12
+
13
+ Stunning features: Gradient fills, edge glow, pattern fills.
14
+ """
15
+
16
+ def __init__(
17
+ self,
18
+ color_palette: Optional[ColorPalette] = None,
19
+ opacity: float = 0.5,
20
+ border_thickness: int = 2,
21
+ edge_glow: bool = False,
22
+ glow_intensity: float = 0.3
23
+ ):
24
+ """
25
+ Args:
26
+ color_palette: Color palette for masks
27
+ opacity: Mask transparency (0-1)
28
+ border_thickness: Border thickness (0 = no border)
29
+ edge_glow: Add soft glow around edges
30
+ glow_intensity: Glow intensity (0-1)
31
+ """
32
+ from eye.draw.color import PredefinedPalettes
33
+ self.colors = color_palette or PredefinedPalettes.bright()
34
+ self.opacity = opacity
35
+ self.border_thickness = border_thickness
36
+ self.edge_glow = edge_glow
37
+ self.glow_intensity = glow_intensity
38
+
39
+ def annotate(
40
+ self,
41
+ image: np.ndarray,
42
+ detections: Detections
43
+ ) -> np.ndarray:
44
+ """Draw masks on image."""
45
+ if 'masks' not in detections.data or detections.data['masks'] is None:
46
+ return image
47
+
48
+ annotated = image.copy()
49
+ masks = detections.data['masks']
50
+
51
+ if len(masks) == 0:
52
+ return annotated
53
+
54
+ # Create overlay
55
+ overlay = image.copy()
56
+
57
+ for i in range(len(detections)):
58
+ if i >= len(masks):
59
+ break
60
+
61
+ mask = masks[i]
62
+ if mask is None:
63
+ continue
64
+
65
+ # Get color
66
+ if detections.class_id is not None:
67
+ color = self.colors.by_id(detections.class_id[i])
68
+ elif detections.tracker_id is not None:
69
+ color = self.colors.by_id(detections.tracker_id[i])
70
+ else:
71
+ color = self.colors[i]
72
+
73
+ bgr_color = color.as_bgr()
74
+
75
+ # Ensure mask is binary
76
+ if mask.dtype != bool:
77
+ mask = mask > 0.5
78
+
79
+ # Fill mask
80
+ overlay[mask] = bgr_color
81
+
82
+ # Edge glow effect
83
+ if self.edge_glow:
84
+ kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
85
+ dilated = cv2.dilate(mask.astype(np.uint8), kernel, iterations=2)
86
+ edge = dilated - mask.astype(np.uint8)
87
+ edge_mask = edge > 0
88
+
89
+ # Soft glow
90
+ glow_color = tuple(min(255, int(c * (1 + self.glow_intensity))) for c in bgr_color)
91
+ overlay[edge_mask] = glow_color
92
+
93
+ # Border
94
+ if self.border_thickness > 0:
95
+ contours, _ = cv2.findContours(
96
+ mask.astype(np.uint8),
97
+ cv2.RETR_EXTERNAL,
98
+ cv2.CHAIN_APPROX_SIMPLE
99
+ )
100
+ cv2.drawContours(overlay, contours, -1, bgr_color, self.border_thickness)
101
+
102
+ # Blend
103
+ annotated = cv2.addWeighted(overlay, self.opacity, annotated, 1 - self.opacity, 0)
104
+
105
+ return annotated
106
+
107
+
108
+ class GradientBoxAnnotator:
109
+ """Draw boxes with gradient fills - Modern look!"""
110
+
111
+ def __init__(
112
+ self,
113
+ color_palette: Optional[ColorPalette] = None,
114
+ thickness: int = 3,
115
+ corner_radius: int = 12,
116
+ gradient: bool = True
117
+ ):
118
+ """
119
+ Args:
120
+ color_palette: Color palette
121
+ thickness: Border thickness
122
+ corner_radius: Corner radius for rounded boxes
123
+ gradient: Fill with gradient
124
+ """
125
+ from eye.draw.color import PredefinedPalettes
126
+ self.colors = color_palette or PredefinedPalettes.bright()
127
+ self.thickness = thickness
128
+ self.corner_radius = corner_radius
129
+ self.gradient = gradient
130
+
131
+ def annotate(
132
+ self,
133
+ image: np.ndarray,
134
+ detections: Detections
135
+ ) -> np.ndarray:
136
+ """Draw gradient boxes."""
137
+ annotated = image.copy()
138
+
139
+ for i in range(len(detections)):
140
+ x1, y1, x2, y2 = detections.xyxy[i].astype(int)
141
+
142
+ # Get color
143
+ if detections.class_id is not None:
144
+ color = self.colors.by_id(detections.class_id[i])
145
+ elif detections.tracker_id is not None:
146
+ color = self.colors.by_id(detections.tracker_id[i])
147
+ else:
148
+ color = self.colors[i]
149
+
150
+ bgr = color.as_bgr()
151
+
152
+ # Gradient fill
153
+ if self.gradient:
154
+ overlay = annotated.copy()
155
+ for y in range(y1, y2):
156
+ alpha = (y - y1) / max(1, (y2 - y1))
157
+ row_color = tuple(int(c * (0.3 + 0.7 * alpha)) for c in bgr)
158
+ cv2.rectangle(overlay, (x1, y), (x2, y+1), row_color, -1)
159
+ cv2.addWeighted(overlay, 0.3, annotated, 0.7, 0, annotated)
160
+
161
+ # Rounded rectangle
162
+ self._draw_rounded_rect(annotated, (x1, y1), (x2, y2), bgr, self.corner_radius, self.thickness)
163
+
164
+ return annotated
165
+
166
+ @staticmethod
167
+ def _draw_rounded_rect(img, pt1, pt2, color, radius, thickness):
168
+ """Draw rounded rectangle."""
169
+ x1, y1 = pt1
170
+ x2, y2 = pt2
171
+
172
+ # Draw rectangles
173
+ cv2.rectangle(img, (x1 + radius, y1), (x2 - radius, y2), color, thickness)
174
+ cv2.rectangle(img, (x1, y1 + radius), (x2, y2 - radius), color, thickness)
175
+
176
+ # Draw circles at corners
177
+ cv2.circle(img, (x1 + radius, y1 + radius), radius, color, thickness)
178
+ cv2.circle(img, (x2 - radius, y1 + radius), radius, color, thickness)
179
+ cv2.circle(img, (x1 + radius, y2 - radius), radius, color, thickness)
180
+ cv2.circle(img, (x2 - radius, y2 - radius), radius, color, thickness)
181
+
182
+
183
+ class NeonTraceAnnotator:
184
+ """Neon-style trails - Ultra modern!"""
185
+
186
+ def __init__(
187
+ self,
188
+ color_palette: Optional[ColorPalette] = None,
189
+ thickness: int = 4,
190
+ trace_length: int = 50,
191
+ glow: bool = True
192
+ ):
193
+ """
194
+ Args:
195
+ color_palette: Color palette
196
+ thickness: Line thickness
197
+ trace_length: Max trail points
198
+ glow: Add neon glow effect
199
+ """
200
+ from eye.draw.color import PredefinedPalettes
201
+ from collections import deque
202
+ self.colors = color_palette or PredefinedPalettes.bright()
203
+ self.thickness = thickness
204
+ self.trace_length = trace_length
205
+ self.glow = glow
206
+ self.history = {}
207
+ # Position smoothing alpha when appending to trace history (0-1).
208
+ # Lower = more smoothing. Default 0.6 is moderate smoothing.
209
+ self.position_smoothing_alpha = 0.6
210
+ # When objects move faster than this (pixels/frame), use `fast_position_smoothing_alpha`
211
+ # to apply stronger smoothing for jitter reduction on fast objects.
212
+ self.position_speed_threshold = 8.0
213
+ # Alpha to use when speed > threshold (lower -> more smoothing)
214
+ self.fast_position_smoothing_alpha = 0.3
215
+
216
+ def annotate(
217
+ self,
218
+ image: np.ndarray,
219
+ detections: Detections
220
+ ) -> np.ndarray:
221
+ """Draw neon trails."""
222
+ if detections.tracker_id is None or len(detections) == 0:
223
+ return image
224
+
225
+ annotated = image.copy()
226
+
227
+ # Update history
228
+ from collections import deque
229
+ from eye.geometry.core import Position
230
+
231
+ # Compute centers using get_anchors_coordinates
232
+ centers = detections.get_anchors_coordinates(anchor=Position.CENTER)
233
+
234
+ for i in range(len(detections)):
235
+ tid = detections.tracker_id[i]
236
+ center = centers[i]
237
+
238
+ if tid not in self.history:
239
+ self.history[tid] = deque(maxlen=self.trace_length)
240
+
241
+ # Apply lightweight temporal smoothing to the appended center
242
+ if len(self.history[tid]) > 0 and self.position_smoothing_alpha is not None:
243
+ last = np.array(self.history[tid][-1], dtype=float)
244
+ newc = np.array(center, dtype=float)
245
+ dist = float(np.linalg.norm(newc - last))
246
+ # choose alpha based on speed
247
+ if self.position_speed_threshold is not None and dist > float(self.position_speed_threshold):
248
+ alpha = float(self.fast_position_smoothing_alpha)
249
+ else:
250
+ alpha = float(self.position_smoothing_alpha)
251
+ center = alpha * newc + (1.0 - alpha) * last
252
+
253
+ self.history[tid].append(center)
254
+
255
+ # Draw trails
256
+ for i in range(len(detections)):
257
+ tid = detections.tracker_id[i]
258
+ if tid not in self.history or len(self.history[tid]) < 2:
259
+ continue
260
+
261
+ points = list(self.history[tid])
262
+ color = self.colors.by_id(detections.class_id[i] if detections.class_id is not None else tid)
263
+
264
+ # Draw with fade and glow
265
+ for j in range(len(points) - 1):
266
+ alpha = (j + 1) / len(points)
267
+ pt1 = tuple(points[j].astype(int))
268
+ pt2 = tuple(points[j + 1].astype(int))
269
+
270
+ # Glow effect (thicker, semi-transparent)
271
+ if self.glow:
272
+ glow_color = tuple(int(c * 0.5) for c in color.as_bgr())
273
+ cv2.line(annotated, pt1, pt2, glow_color, self.thickness + 4, cv2.LINE_AA)
274
+
275
+ # Main line
276
+ main_color = tuple(int(c * alpha) for c in color.as_bgr())
277
+ cv2.line(annotated, pt1, pt2, main_color, self.thickness, cv2.LINE_AA)
278
+
279
+ return annotated
280
+
281
+ def reset(self):
282
+ """Clear history."""
283
+ self.history.clear()
284
+
285
+
286
+ class ShadowBoxAnnotator:
287
+ """Boxes with drop shadows - Depth effect!"""
288
+
289
+ def __init__(
290
+ self,
291
+ color_palette: Optional[ColorPalette] = None,
292
+ thickness: int = 2,
293
+ shadow_offset: int = 5,
294
+ corner_radius: int = 8
295
+ ):
296
+ """
297
+ Args:
298
+ color_palette: Color palette
299
+ thickness: Border thickness
300
+ shadow_offset: Shadow offset in pixels
301
+ corner_radius: Corner radius
302
+ """
303
+ from eye.draw.color import PredefinedPalettes
304
+ self.colors = color_palette or PredefinedPalettes.bright()
305
+ self.thickness = thickness
306
+ self.shadow_offset = shadow_offset
307
+ self.corner_radius = corner_radius
308
+
309
+ def annotate(
310
+ self,
311
+ image: np.ndarray,
312
+ detections: Detections
313
+ ) -> np.ndarray:
314
+ """Draw boxes with shadows."""
315
+ annotated = image.copy()
316
+
317
+ for i in range(len(detections)):
318
+ x1, y1, x2, y2 = detections.xyxy[i].astype(int)
319
+
320
+ color = self.colors.by_id(
321
+ detections.class_id[i] if detections.class_id is not None
322
+ else detections.tracker_id[i] if detections.tracker_id is not None
323
+ else i
324
+ )
325
+
326
+ # Draw shadow
327
+ shadow_color = (30, 30, 30) # Dark gray
328
+ cv2.rectangle(
329
+ annotated,
330
+ (x1 + self.shadow_offset, y1 + self.shadow_offset),
331
+ (x2 + self.shadow_offset, y2 + self.shadow_offset),
332
+ shadow_color,
333
+ self.thickness
334
+ )
335
+
336
+ # Draw main box
337
+ GradientBoxAnnotator._draw_rounded_rect(
338
+ annotated, (x1, y1), (x2, y2),
339
+ color.as_bgr(), self.corner_radius, self.thickness
340
+ )
341
+
342
+ return annotated
343
+
344
+
345
+ class CornerBoxAnnotator:
346
+ """Draw boxes using only corner segments (no full borders)."""
347
+
348
+ def __init__(
349
+ self,
350
+ color_palette: Optional[ColorPalette] = None,
351
+ thickness: int = 3,
352
+ corner_length: int = 20,
353
+ corner_radius: int = 6,
354
+ ):
355
+ from eye.draw.color import PredefinedPalettes
356
+ self.colors = color_palette or PredefinedPalettes.bright()
357
+ self.thickness = thickness
358
+ self.corner_length = corner_length
359
+ self.corner_radius = corner_radius
360
+
361
+ def annotate(self, image: np.ndarray, detections: Detections) -> np.ndarray:
362
+ annotated = image.copy()
363
+ for i in range(len(detections)):
364
+ x1, y1, x2, y2 = detections.xyxy[i].astype(int)
365
+ color = self.colors.by_id(
366
+ detections.class_id[i] if detections.class_id is not None else (
367
+ detections.tracker_id[i] if detections.tracker_id is not None else i
368
+ )
369
+ ).as_bgr()
370
+
371
+ # Top-left
372
+ cv2.line(annotated, (x1, y1), (x1 + self.corner_length, y1), color, self.thickness)
373
+ cv2.line(annotated, (x1, y1), (x1, y1 + self.corner_length), color, self.thickness)
374
+ # Top-right
375
+ cv2.line(annotated, (x2, y1), (x2 - self.corner_length, y1), color, self.thickness)
376
+ cv2.line(annotated, (x2, y1), (x2, y1 + self.corner_length), color, self.thickness)
377
+ # Bottom-left
378
+ cv2.line(annotated, (x1, y2), (x1 + self.corner_length, y2), color, self.thickness)
379
+ cv2.line(annotated, (x1, y2), (x1, y2 - self.corner_length), color, self.thickness)
380
+ # Bottom-right
381
+ cv2.line(annotated, (x2, y2), (x2 - self.corner_length, y2), color, self.thickness)
382
+ cv2.line(annotated, (x2, y2), (x2, y2 - self.corner_length), color, self.thickness)
383
+
384
+ return annotated
385
+
386
+
387
+ class FPSAnnotator:
388
+ """Annotator that shows processing FPS on the frame.
389
+
390
+ Usage: call `update(fps)` from the processing loop to set the current value.
391
+ """
392
+
393
+ def __init__(self, position: str = "top-left", font_scale: float = 0.6, color=(0, 255, 0), bg_color=(0, 0, 0), padding: int = 6):
394
+ self.position = position
395
+ self.font_scale = font_scale
396
+ self.color = color
397
+ self.bg_color = bg_color
398
+ self.padding = padding
399
+ self.fps = None
400
+
401
+ def update(self, fps: float):
402
+ try:
403
+ self.fps = float(fps)
404
+ except Exception:
405
+ self.fps = None
406
+
407
+ def annotate(self, image: np.ndarray, detections: Optional[Detections] = None) -> np.ndarray:
408
+ annotated = image.copy()
409
+ if self.fps is None:
410
+ return annotated
411
+
412
+ text = f"FPS: {self.fps:.1f}"
413
+ font = cv2.FONT_HERSHEY_SIMPLEX
414
+ thickness = 1
415
+ (w, h), _ = cv2.getTextSize(text, font, self.font_scale, thickness)
416
+
417
+ if self.position == "top-left":
418
+ x, y = 10, 10 + h
419
+ elif self.position == "top-right":
420
+ x, y = annotated.shape[1] - w - 10, 10 + h
421
+ elif self.position == "bottom-left":
422
+ x, y = 10, annotated.shape[0] - 10
423
+ else:
424
+ x, y = annotated.shape[1] - w - 10, annotated.shape[0] - 10
425
+
426
+ # Background rectangle
427
+ cv2.rectangle(annotated, (x - self.padding, y - h - self.padding), (x + w + self.padding, y + self.padding), self.bg_color, -1)
428
+ cv2.putText(annotated, text, (x, y), font, self.font_scale, self.color, thickness)
429
+ return annotated
430
+
431
+
432
+ class InfoAnnotator:
433
+ """Flexible information/text/table annotator.
434
+
435
+ - Reads info from `detections.data.get('info')` if present, otherwise from `self.info`.
436
+ - `info` can be a dict (key->value), a list of (k,v) pairs, or a list of rows (for tables).
437
+ """
438
+
439
+ def __init__(
440
+ self,
441
+ position: str = "top-left",
442
+ as_table: bool = False,
443
+ cols: int = 1,
444
+ show_border: bool = False,
445
+ bg_color=(0, 0, 0),
446
+ text_color=(255, 255, 255),
447
+ font_scale: float = 0.5,
448
+ padding: int = 6,
449
+ ):
450
+ self.position = position
451
+ self.as_table = as_table
452
+ self.cols = max(1, int(cols))
453
+ self.show_border = show_border
454
+ self.bg_color = bg_color
455
+ self.text_color = text_color
456
+ self.font_scale = font_scale
457
+ self.padding = padding
458
+ self.info = None
459
+
460
+ def annotate(self, image: np.ndarray, detections: Optional[Detections] = None) -> np.ndarray:
461
+ annotated = image.copy()
462
+ info = None
463
+ if detections is not None and isinstance(detections, Detections):
464
+ info = detections.data.get('info') if isinstance(detections.data, dict) else None
465
+ if info is None:
466
+ info = self.info
467
+ if not info:
468
+ return annotated
469
+
470
+ # Normalize info into list of strings per row
471
+ rows = []
472
+ if isinstance(info, dict):
473
+ for k, v in info.items():
474
+ rows.append(f"{k}: {v}")
475
+ elif isinstance(info, list):
476
+ # Could be list of tuples or list of rows
477
+ for item in info:
478
+ if isinstance(item, (list, tuple)):
479
+ rows.append(" | ".join(str(x) for x in item))
480
+ else:
481
+ rows.append(str(item))
482
+ else:
483
+ rows = [str(info)]
484
+
485
+ # When as_table and cols>1, arrange rows into grid
486
+ if self.as_table and self.cols > 1:
487
+ grid = []
488
+ for i in range(0, len(rows), self.cols):
489
+ grid.append(rows[i:i + self.cols])
490
+ # compute cell sizes
491
+ cell_texts = ["\t".join(r) for r in grid]
492
+ rows = cell_texts
493
+
494
+ # compute text block size
495
+ font = cv2.FONT_HERSHEY_SIMPLEX
496
+ thickness = 1
497
+ line_heights = []
498
+ max_w = 0
499
+ for r in rows:
500
+ (w, h), _ = cv2.getTextSize(r, font, self.font_scale, thickness)
501
+ line_heights.append(h)
502
+ max_w = max(max_w, w)
503
+
504
+ total_h = sum(line_heights) + self.padding * 2 + (len(rows) - 1) * 4
505
+ total_w = max_w + self.padding * 2
506
+
507
+ # choose position
508
+ if self.position == 'top-left':
509
+ x0, y0 = 10, 10
510
+ elif self.position == 'top-right':
511
+ x0, y0 = annotated.shape[1] - total_w - 10, 10
512
+ elif self.position == 'bottom-left':
513
+ x0, y0 = 10, annotated.shape[0] - total_h - 10
514
+ else:
515
+ x0, y0 = annotated.shape[1] - total_w - 10, annotated.shape[0] - total_h - 10
516
+
517
+ # background
518
+ cv2.rectangle(annotated, (x0, y0), (x0 + total_w, y0 + total_h), self.bg_color, -1)
519
+ if self.show_border:
520
+ cv2.rectangle(annotated, (x0, y0), (x0 + total_w, y0 + total_h), (200, 200, 200), 1)
521
+
522
+ # draw lines
523
+ y = y0 + self.padding + line_heights[0]
524
+ for idx, r in enumerate(rows):
525
+ cv2.putText(annotated, r, (x0 + self.padding, y), font, self.font_scale, self.text_color, thickness)
526
+ if idx + 1 < len(rows):
527
+ y += line_heights[idx + 1] + 4
528
+
529
+ return annotated