blobtrack 0.1.2__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.
- blobtrack/__init__.py +1 -0
- blobtrack/app.py +668 -0
- blobtrack-0.1.2.dist-info/METADATA +97 -0
- blobtrack-0.1.2.dist-info/RECORD +6 -0
- blobtrack-0.1.2.dist-info/WHEEL +4 -0
- blobtrack-0.1.2.dist-info/entry_points.txt +2 -0
blobtrack/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
blobtrack/app.py
ADDED
|
@@ -0,0 +1,668 @@
|
|
|
1
|
+
import cv2
|
|
2
|
+
import numpy as np
|
|
3
|
+
import dearpygui.dearpygui as dpg
|
|
4
|
+
import argparse
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
SIZE_PRESETS = [16, 32, 64, 128, 256, 512]
|
|
8
|
+
FILTERS = ["None", "Invert", "Blur", "Pixelate", "Thermal", "Edge"]
|
|
9
|
+
SHAPES = ["Square", "Circle", "None"]
|
|
10
|
+
|
|
11
|
+
SPEED_PRESETS = [ ("1x", 1), ("1.25x", 1.25), ("1.50x", 1.50), ("1.75x", 1.75), ("2x", 2.0)]
|
|
12
|
+
|
|
13
|
+
RESOLUTION_PRESETS = [("Native", 1.0), ("75%", 0.75), ("50%", 0.5), ("25%", 0.25)]
|
|
14
|
+
|
|
15
|
+
COLOR_PRESETS = [
|
|
16
|
+
("Green", (0, 255, 0)),
|
|
17
|
+
("Red", (0, 0, 255)),
|
|
18
|
+
("Blue", (255, 0, 0)),
|
|
19
|
+
("Yellow", (0, 255, 255)),
|
|
20
|
+
("Cyan", (255, 255, 0)),
|
|
21
|
+
("Magenta", (255, 0, 255)),
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
VIDEO_TEXTURE_TAG = "video_texture"
|
|
25
|
+
VIDEO_IMAGE_TAG = "video_image"
|
|
26
|
+
|
|
27
|
+
MAX_TEXTURE_DIM = 1920
|
|
28
|
+
SIDEBAR_WIDTH = 300
|
|
29
|
+
|
|
30
|
+
SELECTED_BTN_COLOR = (70, 130, 220, 255)
|
|
31
|
+
DEFAULT_BTN_COLOR = (45, 45, 48, 255)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def apply_box_filter(roi, filter_name):
|
|
35
|
+
if roi.size == 0:
|
|
36
|
+
return roi
|
|
37
|
+
if filter_name == "Invert":
|
|
38
|
+
return cv2.bitwise_not(roi)
|
|
39
|
+
if filter_name == "Blur":
|
|
40
|
+
return cv2.GaussianBlur(roi, (15, 15), 0)
|
|
41
|
+
if filter_name == "Pixelate":
|
|
42
|
+
h, w = roi.shape[:2]
|
|
43
|
+
small = cv2.resize(roi, (max(w // 10, 1), max(h // 10, 1)), interpolation=cv2.INTER_LINEAR)
|
|
44
|
+
return cv2.resize(small, (w, h), interpolation=cv2.INTER_NEAREST)
|
|
45
|
+
if filter_name == "Thermal":
|
|
46
|
+
gray = cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY)
|
|
47
|
+
return cv2.applyColorMap(gray, cv2.COLORMAP_JET)
|
|
48
|
+
if filter_name == "Edge":
|
|
49
|
+
gray = cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY)
|
|
50
|
+
edges = cv2.Canny(gray, 80, 160)
|
|
51
|
+
return cv2.cvtColor(edges, cv2.COLOR_GRAY2BGR)
|
|
52
|
+
return roi
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def compute_display_size(w, h, max_dim=780):
|
|
56
|
+
"""Only used as a fallback before the real video_panel size is known."""
|
|
57
|
+
scale = min(max_dim / w, max_dim / h, 1.0)
|
|
58
|
+
return max(int(w * scale), 1), max(int(h * scale), 1)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class BlobTrackerApp:
|
|
62
|
+
def __init__(self, source=None):
|
|
63
|
+
self.cap = None
|
|
64
|
+
self.is_file = isinstance(source, str)
|
|
65
|
+
self.frame_w, self.frame_h = 640, 480
|
|
66
|
+
self.tex_w, self.tex_h = compute_display_size(640, 480, MAX_TEXTURE_DIM)
|
|
67
|
+
self.disp_w, self.disp_h = self.tex_w, self.tex_h
|
|
68
|
+
self.fps = 30
|
|
69
|
+
self.total_frames = None
|
|
70
|
+
self.paused = False
|
|
71
|
+
self.fullscreen = False
|
|
72
|
+
self.current_texture_tag = VIDEO_TEXTURE_TAG
|
|
73
|
+
self.texture_counter = 0
|
|
74
|
+
|
|
75
|
+
self.backsub = cv2.createBackgroundSubtractorMOG2(history=500, varThreshold=25, detectShadows=True)
|
|
76
|
+
|
|
77
|
+
self.writer = None
|
|
78
|
+
self.recording = False
|
|
79
|
+
self.record_start_time = None
|
|
80
|
+
self.record_start_frame = 0
|
|
81
|
+
self._record_speed = None
|
|
82
|
+
|
|
83
|
+
self.shape_sel = "Square"
|
|
84
|
+
self.filter_sel = "None"
|
|
85
|
+
self.shape_color_sel = "Green"
|
|
86
|
+
self.line_color_sel = "Green"
|
|
87
|
+
self.playback_speed_sel = "1x"
|
|
88
|
+
self.res_scale_sel = "Native"
|
|
89
|
+
self.custom_colors = {"boxclr_btn": (0, 255, 0), "lineclr_btn": (0, 255, 0)} # BGR
|
|
90
|
+
|
|
91
|
+
self._next_frame_due = None
|
|
92
|
+
self._last_applied_scale = None
|
|
93
|
+
self._rgba_buf = None
|
|
94
|
+
self._tex_buf = None
|
|
95
|
+
self._alloc_texture_buffers()
|
|
96
|
+
|
|
97
|
+
if source is not None:
|
|
98
|
+
self._open_source(source, first_time=True)
|
|
99
|
+
|
|
100
|
+
def _alloc_texture_buffers(self):
|
|
101
|
+
self._rgba_buf = np.empty((self.tex_h, self.tex_w, 4), dtype=np.uint8)
|
|
102
|
+
self._tex_buf = np.empty((self.tex_h, self.tex_w, 4), dtype=np.float32)
|
|
103
|
+
|
|
104
|
+
def _open_source(self, source, first_time=False):
|
|
105
|
+
if source is None:
|
|
106
|
+
return
|
|
107
|
+
if self.cap:
|
|
108
|
+
self.cap.release()
|
|
109
|
+
self.cap = cv2.VideoCapture(source)
|
|
110
|
+
self.is_file = isinstance(source, str)
|
|
111
|
+
if not self.cap.isOpened():
|
|
112
|
+
print(f"Could not open source: {source}")
|
|
113
|
+
return
|
|
114
|
+
|
|
115
|
+
if dpg.does_item_exist("placeholder_text"):
|
|
116
|
+
dpg.hide_item("placeholder_text")
|
|
117
|
+
self.frame_w = int(self.cap.get(cv2.CAP_PROP_FRAME_WIDTH)) or 640
|
|
118
|
+
self.frame_h = int(self.cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) or 480
|
|
119
|
+
self.fps = self.cap.get(cv2.CAP_PROP_FPS) or 30
|
|
120
|
+
self.total_frames = int(self.cap.get(cv2.CAP_PROP_FRAME_COUNT)) if self.is_file else None
|
|
121
|
+
self.backsub = cv2.createBackgroundSubtractorMOG2(history=500, varThreshold=25, detectShadows=True)
|
|
122
|
+
self._next_frame_due = None
|
|
123
|
+
|
|
124
|
+
self.tex_w, self.tex_h = self._compute_tex_dims(self._current_res_scale())
|
|
125
|
+
self._alloc_texture_buffers()
|
|
126
|
+
self._last_applied_scale = self._current_res_scale()
|
|
127
|
+
|
|
128
|
+
if not first_time:
|
|
129
|
+
self._rebuild_texture()
|
|
130
|
+
|
|
131
|
+
def _rebuild_texture(self):
|
|
132
|
+
old_tag = self.current_texture_tag
|
|
133
|
+
new_tag = f"video_texture_{self.texture_counter}"
|
|
134
|
+
self.texture_counter += 1
|
|
135
|
+
|
|
136
|
+
blank = np.zeros((self.tex_h, self.tex_w, 4), dtype=np.float32)
|
|
137
|
+
with dpg.texture_registry():
|
|
138
|
+
dpg.add_dynamic_texture(self.tex_w, self.tex_h, blank.flatten(), tag=new_tag)
|
|
139
|
+
|
|
140
|
+
dpg.configure_item(VIDEO_IMAGE_TAG, texture_tag=new_tag, width=self.disp_w, height=self.disp_h)
|
|
141
|
+
self.current_texture_tag = new_tag
|
|
142
|
+
|
|
143
|
+
if old_tag and dpg.does_item_exist(old_tag):
|
|
144
|
+
dpg.delete_item(old_tag)
|
|
145
|
+
|
|
146
|
+
def _make_button_group(self, options, selected_attr, tag_prefix, columns=3):
|
|
147
|
+
"""A grid of buttons that behave like a single-select group, highlighting the active one."""
|
|
148
|
+
for i, opt in enumerate(options):
|
|
149
|
+
if i % columns == 0 and i > 0:
|
|
150
|
+
pass
|
|
151
|
+
with dpg.table(header_row=False, tag=f"{tag_prefix}_table"):
|
|
152
|
+
for _ in range(columns):
|
|
153
|
+
dpg.add_table_column()
|
|
154
|
+
for i in range(0, len(options), columns):
|
|
155
|
+
with dpg.table_row():
|
|
156
|
+
for opt in options[i:i + columns]:
|
|
157
|
+
tag = f"{tag_prefix}_{opt}"
|
|
158
|
+
dpg.add_button(label=opt, tag=tag, width=-1,
|
|
159
|
+
callback=self._make_select_callback(selected_attr, tag_prefix, options))
|
|
160
|
+
self._refresh_button_group(selected_attr, tag_prefix, options)
|
|
161
|
+
|
|
162
|
+
def _make_select_callback(self, selected_attr, tag_prefix, options):
|
|
163
|
+
def callback(sender, app_data, user_data):
|
|
164
|
+
label = dpg.get_item_configuration(sender)["label"]
|
|
165
|
+
setattr(self, selected_attr, label)
|
|
166
|
+
self._refresh_button_group(selected_attr, tag_prefix, options)
|
|
167
|
+
return callback
|
|
168
|
+
|
|
169
|
+
def _refresh_button_group(self, selected_attr, tag_prefix, options):
|
|
170
|
+
current = getattr(self, selected_attr)
|
|
171
|
+
for opt in options:
|
|
172
|
+
tag = f"{tag_prefix}_{opt}"
|
|
173
|
+
if not dpg.does_item_exist(tag):
|
|
174
|
+
continue
|
|
175
|
+
with dpg.theme() as t:
|
|
176
|
+
with dpg.theme_component(dpg.mvButton):
|
|
177
|
+
color = SELECTED_BTN_COLOR if opt == current else DEFAULT_BTN_COLOR
|
|
178
|
+
dpg.add_theme_color(dpg.mvThemeCol_Button, color)
|
|
179
|
+
dpg.bind_item_theme(tag, t)
|
|
180
|
+
|
|
181
|
+
def _color_button_group(self, tag_prefix, selected_attr):
|
|
182
|
+
names = [c[0] for c in COLOR_PRESETS] + ["Custom"]
|
|
183
|
+
|
|
184
|
+
popup_tag = f"{tag_prefix}_popup"
|
|
185
|
+
with dpg.window(tag=popup_tag, show=False, modal=True, no_title_bar=False,
|
|
186
|
+
label="Pick a color", width=280, height=330, no_resize=True):
|
|
187
|
+
dpg.add_color_picker(
|
|
188
|
+
tag=f"{tag_prefix}_picker", default_value=(0, 255, 0, 255), no_alpha=True,
|
|
189
|
+
width=250,
|
|
190
|
+
callback=lambda s, a: self._on_custom_color_picked(tag_prefix, selected_attr, a)
|
|
191
|
+
)
|
|
192
|
+
dpg.add_button(label="Done", width=-1, callback=lambda: dpg.hide_item(popup_tag))
|
|
193
|
+
|
|
194
|
+
with dpg.table(header_row=False, tag=f"{tag_prefix}_table"):
|
|
195
|
+
for _ in range(3):
|
|
196
|
+
dpg.add_table_column()
|
|
197
|
+
for i in range(0, len(names), 3):
|
|
198
|
+
with dpg.table_row():
|
|
199
|
+
for name in names[i:i + 3]:
|
|
200
|
+
tag = f"{tag_prefix}_{name}"
|
|
201
|
+
if name == "Custom":
|
|
202
|
+
dpg.add_button(label="Custom", tag=tag, width=-1,
|
|
203
|
+
callback=lambda: dpg.show_item(popup_tag))
|
|
204
|
+
else:
|
|
205
|
+
dpg.add_button(label=name, tag=tag, width=-1,
|
|
206
|
+
callback=self._make_select_callback(selected_attr, tag_prefix, names))
|
|
207
|
+
self._refresh_color_group(tag_prefix, selected_attr)
|
|
208
|
+
|
|
209
|
+
def _on_custom_color_picked(self, tag_prefix, selected_attr, rgba_float):
|
|
210
|
+
r, g, b = int(rgba_float[0] * 255), int(rgba_float[1] * 255), int(rgba_float[2] * 255)
|
|
211
|
+
self.custom_colors[tag_prefix] = (b, g, r)
|
|
212
|
+
setattr(self, selected_attr, "Custom")
|
|
213
|
+
self._refresh_color_group(tag_prefix, selected_attr)
|
|
214
|
+
|
|
215
|
+
def _refresh_color_group(self, tag_prefix, selected_attr):
|
|
216
|
+
current = getattr(self, selected_attr)
|
|
217
|
+
preset_map = dict(COLOR_PRESETS)
|
|
218
|
+
names = list(preset_map.keys()) + ["Custom"]
|
|
219
|
+
for name in names:
|
|
220
|
+
tag = f"{tag_prefix}_{name}"
|
|
221
|
+
if not dpg.does_item_exist(tag):
|
|
222
|
+
continue
|
|
223
|
+
with dpg.theme() as t:
|
|
224
|
+
with dpg.theme_component(dpg.mvButton):
|
|
225
|
+
color = SELECTED_BTN_COLOR if name == current else DEFAULT_BTN_COLOR
|
|
226
|
+
dpg.add_theme_color(dpg.mvThemeCol_Button, color)
|
|
227
|
+
dpg.bind_item_theme(tag, t)
|
|
228
|
+
|
|
229
|
+
def _current_speed(self):
|
|
230
|
+
return dict(SPEED_PRESETS).get(self.playback_speed_sel, 1.0)
|
|
231
|
+
|
|
232
|
+
def _current_res_scale(self):
|
|
233
|
+
return dict(RESOLUTION_PRESETS).get(self.res_scale_sel, 1.0)
|
|
234
|
+
|
|
235
|
+
def _compute_tex_dims(self, scale):
|
|
236
|
+
if scale >= 0.999:
|
|
237
|
+
return compute_display_size(self.frame_w, self.frame_h, MAX_TEXTURE_DIM)
|
|
238
|
+
return max(int(self.frame_w * scale), 1), max(int(self.frame_h * scale), 1)
|
|
239
|
+
|
|
240
|
+
def _set_speed_buttons_enabled(self, enabled):
|
|
241
|
+
for label, _ in SPEED_PRESETS:
|
|
242
|
+
tag = f"speed_btn_{label}"
|
|
243
|
+
if not dpg.does_item_exist(tag):
|
|
244
|
+
continue
|
|
245
|
+
if enabled:
|
|
246
|
+
dpg.enable_item(tag)
|
|
247
|
+
else:
|
|
248
|
+
dpg.disable_item(tag)
|
|
249
|
+
|
|
250
|
+
def toggle_record(self):
|
|
251
|
+
if not self.recording:
|
|
252
|
+
dpg.show_item("save_dialog")
|
|
253
|
+
else:
|
|
254
|
+
self._stop_recording()
|
|
255
|
+
|
|
256
|
+
def save_recording_callback(self, sender, app_data):
|
|
257
|
+
path = app_data["file_path_name"]
|
|
258
|
+
if not path:
|
|
259
|
+
return
|
|
260
|
+
if not path.lower().endswith(".mp4"):
|
|
261
|
+
path += ".mp4"
|
|
262
|
+
|
|
263
|
+
if self.is_file and self.cap:
|
|
264
|
+
self.cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
|
|
265
|
+
self._next_frame_due = None
|
|
266
|
+
|
|
267
|
+
if self.paused:
|
|
268
|
+
self.paused = False
|
|
269
|
+
if dpg.does_item_exist("pause_btn"):
|
|
270
|
+
dpg.configure_item("pause_btn", label="Pause")
|
|
271
|
+
|
|
272
|
+
speed = self._current_speed()
|
|
273
|
+
effective_fps = self.fps * speed
|
|
274
|
+
fourcc = cv2.VideoWriter_fourcc(*"mp4v")
|
|
275
|
+
self.writer = cv2.VideoWriter(path, fourcc, effective_fps, (self.frame_w, self.frame_h))
|
|
276
|
+
self.recording = True
|
|
277
|
+
self._record_speed = speed
|
|
278
|
+
self.record_start_time = time.time()
|
|
279
|
+
self.record_start_frame = 0
|
|
280
|
+
dpg.configure_item("record_btn", label="Stop Recording")
|
|
281
|
+
self._set_speed_buttons_enabled(False)
|
|
282
|
+
print(f"Recording started -> {path} (export fps {effective_fps:.2f}, {speed}x speed)")
|
|
283
|
+
|
|
284
|
+
def _stop_recording(self):
|
|
285
|
+
if self.writer:
|
|
286
|
+
self.writer.release()
|
|
287
|
+
self.writer = None
|
|
288
|
+
self.recording = False
|
|
289
|
+
self._record_speed = None
|
|
290
|
+
if dpg.does_item_exist("record_btn"):
|
|
291
|
+
dpg.configure_item("record_btn", label="Record")
|
|
292
|
+
if dpg.does_item_exist("progress_bar"):
|
|
293
|
+
dpg.set_value("progress_bar", 0.0)
|
|
294
|
+
dpg.set_value("progress_text", "")
|
|
295
|
+
self._set_speed_buttons_enabled(True)
|
|
296
|
+
print("Recording stopped and saved.")
|
|
297
|
+
|
|
298
|
+
def toggle_pause(self):
|
|
299
|
+
self.paused = not self.paused
|
|
300
|
+
dpg.configure_item("pause_btn", label="Play" if self.paused else "Pause")
|
|
301
|
+
self._next_frame_due = None
|
|
302
|
+
|
|
303
|
+
def toggle_fullscreen(self):
|
|
304
|
+
self.fullscreen = not self.fullscreen
|
|
305
|
+
dpg.toggle_viewport_fullscreen()
|
|
306
|
+
|
|
307
|
+
if self.fullscreen:
|
|
308
|
+
dpg.hide_item("sidebar")
|
|
309
|
+
else:
|
|
310
|
+
dpg.show_item("sidebar")
|
|
311
|
+
|
|
312
|
+
dpg.set_frame_callback(dpg.get_frame_count() + 5, lambda: self.resize_video_display())
|
|
313
|
+
|
|
314
|
+
def resize_video_display(self):
|
|
315
|
+
if not dpg.does_item_exist("video_panel"):
|
|
316
|
+
return
|
|
317
|
+
w, h = dpg.get_item_rect_size("video_panel")
|
|
318
|
+
if w <= 10 or h <= 10:
|
|
319
|
+
return
|
|
320
|
+
|
|
321
|
+
target_w, target_h = int(w), int(h)
|
|
322
|
+
if target_w != self.disp_w or target_h != self.disp_h:
|
|
323
|
+
self.disp_w, self.disp_h = target_w, target_h
|
|
324
|
+
dpg.configure_item(VIDEO_IMAGE_TAG, width=target_w, height=target_h)
|
|
325
|
+
if dpg.does_item_exist("placeholder_text"):
|
|
326
|
+
tw = dpg.get_text_size("Load a video to begin")
|
|
327
|
+
tw = tw[0] if tw else 180
|
|
328
|
+
dpg.set_item_pos("placeholder_text", [max((target_w - tw) // 2, 0), target_h // 2])
|
|
329
|
+
|
|
330
|
+
def load_video_callback(self, sender, app_data):
|
|
331
|
+
path = app_data["file_path_name"]
|
|
332
|
+
if path:
|
|
333
|
+
self._stop_recording()
|
|
334
|
+
self._open_source(path)
|
|
335
|
+
|
|
336
|
+
def update(self):
|
|
337
|
+
if self.cap is None or not self.cap.isOpened():
|
|
338
|
+
return
|
|
339
|
+
if self.paused:
|
|
340
|
+
return
|
|
341
|
+
|
|
342
|
+
current_scale = self._current_res_scale()
|
|
343
|
+
if current_scale != self._last_applied_scale:
|
|
344
|
+
self.tex_w, self.tex_h = self._compute_tex_dims(current_scale)
|
|
345
|
+
self._alloc_texture_buffers()
|
|
346
|
+
self._rebuild_texture()
|
|
347
|
+
self.backsub = cv2.createBackgroundSubtractorMOG2(history=500, varThreshold=25, detectShadows=True)
|
|
348
|
+
self._last_applied_scale = current_scale
|
|
349
|
+
|
|
350
|
+
now = time.perf_counter()
|
|
351
|
+
frame_interval = 0
|
|
352
|
+
if self.is_file:
|
|
353
|
+
speed = self._current_speed()
|
|
354
|
+
if speed <= 0:
|
|
355
|
+
speed = 1.0
|
|
356
|
+
frame_interval = (1.0 / self.fps) / speed if self.fps > 0 else 0
|
|
357
|
+
|
|
358
|
+
if self._next_frame_due is None:
|
|
359
|
+
self._next_frame_due = now
|
|
360
|
+
|
|
361
|
+
if now < self._next_frame_due:
|
|
362
|
+
return
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
if not self.recording and frame_interval > 0:
|
|
366
|
+
behind = now - self._next_frame_due
|
|
367
|
+
skip_count = min(int(behind / frame_interval), 10) # sanity cap
|
|
368
|
+
for _ in range(skip_count):
|
|
369
|
+
if not self.cap.grab():
|
|
370
|
+
break
|
|
371
|
+
self._next_frame_due += skip_count * frame_interval
|
|
372
|
+
|
|
373
|
+
ret, frame = self.cap.read()
|
|
374
|
+
if not ret:
|
|
375
|
+
if self.is_file:
|
|
376
|
+
if self.recording:
|
|
377
|
+
self._stop_recording()
|
|
378
|
+
print("Reached end of clip — recording auto-stopped.")
|
|
379
|
+
self.cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
|
|
380
|
+
self._next_frame_due = None
|
|
381
|
+
return
|
|
382
|
+
else:
|
|
383
|
+
return
|
|
384
|
+
|
|
385
|
+
if self.is_file:
|
|
386
|
+
self._next_frame_due += frame_interval
|
|
387
|
+
if self._next_frame_due < now - frame_interval:
|
|
388
|
+
self._next_frame_due = now + frame_interval
|
|
389
|
+
|
|
390
|
+
if self.recording or (self.tex_w == self.frame_w and self.tex_h == self.frame_h):
|
|
391
|
+
proc_frame = frame
|
|
392
|
+
else:
|
|
393
|
+
proc_frame = cv2.resize(frame, (self.tex_w, self.tex_h), interpolation=cv2.INTER_AREA)
|
|
394
|
+
proc_h, proc_w = proc_frame.shape[:2]
|
|
395
|
+
frame_area = proc_w * proc_h
|
|
396
|
+
|
|
397
|
+
res_scale = proc_w / self.frame_w if self.frame_w else 1.0
|
|
398
|
+
|
|
399
|
+
min_area = dpg.get_value("min_area") * (res_scale ** 2)
|
|
400
|
+
max_blobs = dpg.get_value("max_blobs")
|
|
401
|
+
shape_size = max(2, round(SIZE_PRESETS[dpg.get_value("box_size_idx")] * res_scale))
|
|
402
|
+
box_thickness = max(1, round(dpg.get_value("box_thk") * res_scale))
|
|
403
|
+
connect_lines = dpg.get_value("connect")
|
|
404
|
+
connect_neighbors = dpg.get_value("connect_neighbors")
|
|
405
|
+
line_thickness = max(1, round(dpg.get_value("line_thk") * res_scale))
|
|
406
|
+
font_scale = dpg.get_value("font_size") * res_scale
|
|
407
|
+
|
|
408
|
+
preset_map = dict(COLOR_PRESETS)
|
|
409
|
+
shape_color = (self.custom_colors["boxclr_btn"] if self.shape_color_sel == "Custom"
|
|
410
|
+
else preset_map[self.shape_color_sel])
|
|
411
|
+
line_color = (self.custom_colors["lineclr_btn"] if self.line_color_sel == "Custom"
|
|
412
|
+
else preset_map[self.line_color_sel])
|
|
413
|
+
|
|
414
|
+
mask = self.backsub.apply(proc_frame)
|
|
415
|
+
_, mask = cv2.threshold(mask, 200, 255, cv2.THRESH_BINARY)
|
|
416
|
+
mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, None, iterations=2)
|
|
417
|
+
mask = cv2.dilate(mask, None, iterations=2)
|
|
418
|
+
|
|
419
|
+
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
|
420
|
+
blobs = [c for c in contours if cv2.contourArea(c) >= min_area]
|
|
421
|
+
blobs = sorted(blobs, key=cv2.contourArea, reverse=True)[:max_blobs]
|
|
422
|
+
|
|
423
|
+
centroids = []
|
|
424
|
+
for c in blobs:
|
|
425
|
+
x, y, w, h = cv2.boundingRect(c)
|
|
426
|
+
cx, cy = x + w // 2, y + h // 2
|
|
427
|
+
centroids.append((cx, cy))
|
|
428
|
+
|
|
429
|
+
half = shape_size // 2
|
|
430
|
+
x1, y1 = max(cx - half, 0), max(cy - half, 0)
|
|
431
|
+
x2, y2 = min(cx + half, proc_w), min(cy + half, proc_h)
|
|
432
|
+
|
|
433
|
+
if self.filter_sel != "None" and x2 > x1 and y2 > y1:
|
|
434
|
+
roi = proc_frame[y1:y2, x1:x2]
|
|
435
|
+
proc_frame[y1:y2, x1:x2] = apply_box_filter(roi, self.filter_sel)
|
|
436
|
+
|
|
437
|
+
if self.shape_sel == "Circle":
|
|
438
|
+
cv2.circle(proc_frame, (cx, cy), half, shape_color, box_thickness)
|
|
439
|
+
elif self.shape_sel == "Square":
|
|
440
|
+
cv2.rectangle(proc_frame, (x1, y1), (x2, y2), shape_color, box_thickness)
|
|
441
|
+
|
|
442
|
+
score = cv2.contourArea(c) / frame_area
|
|
443
|
+
cv2.putText(proc_frame, f"{score:.4f}", (x1, y1 - 6),
|
|
444
|
+
cv2.FONT_HERSHEY_SIMPLEX, font_scale, shape_color, 1, cv2.LINE_AA)
|
|
445
|
+
|
|
446
|
+
if connect_lines and len(centroids) > 1:
|
|
447
|
+
pts = np.array(centroids, dtype=np.float32)
|
|
448
|
+
n = len(pts)
|
|
449
|
+
k = max(1, min(int(connect_neighbors), n - 1))
|
|
450
|
+
diffs = pts[:, None, :] - pts[None, :, :]
|
|
451
|
+
dist_sq = np.sum(diffs * diffs, axis=2)
|
|
452
|
+
np.fill_diagonal(dist_sq, np.inf)
|
|
453
|
+
finite = dist_sq[np.isfinite(dist_sq)]
|
|
454
|
+
max_dist = float(np.sqrt(finite.max())) if finite.size else 1.0
|
|
455
|
+
max_dist = max(max_dist, 1e-6)
|
|
456
|
+
|
|
457
|
+
drawn = set()
|
|
458
|
+
for i in range(n):
|
|
459
|
+
nearest = np.argpartition(dist_sq[i], k)[:k]
|
|
460
|
+
for j in nearest:
|
|
461
|
+
j = int(j)
|
|
462
|
+
pair = (i, j) if i < j else (j, i)
|
|
463
|
+
if pair in drawn:
|
|
464
|
+
continue
|
|
465
|
+
drawn.add(pair)
|
|
466
|
+
dist = float(np.sqrt(dist_sq[i, j]))
|
|
467
|
+
fade = 1.0 - 0.7 * min(dist / max_dist, 1.0)
|
|
468
|
+
faded_color = tuple(int(c * fade) for c in line_color)
|
|
469
|
+
cv2.line(proc_frame, centroids[i], centroids[j], faded_color, line_thickness)
|
|
470
|
+
|
|
471
|
+
if self.recording:
|
|
472
|
+
self.writer.write(proc_frame)
|
|
473
|
+
|
|
474
|
+
if self.recording:
|
|
475
|
+
elapsed = time.time() - self.record_start_time
|
|
476
|
+
if self.is_file and self.total_frames:
|
|
477
|
+
cur_frame = int(self.cap.get(cv2.CAP_PROP_POS_FRAMES))
|
|
478
|
+
frames_done = max(cur_frame - self.record_start_frame, 0)
|
|
479
|
+
pct = min(frames_done / self.total_frames, 1.0)
|
|
480
|
+
dpg.set_value("progress_bar", pct)
|
|
481
|
+
dpg.set_value("progress_text", f"Recording {pct*100:.0f}%")
|
|
482
|
+
else:
|
|
483
|
+
dpg.set_value("progress_bar", 0.0)
|
|
484
|
+
dpg.set_value("progress_text", f"Recording {elapsed:.1f}s")
|
|
485
|
+
|
|
486
|
+
if proc_w == self.tex_w and proc_h == self.tex_h:
|
|
487
|
+
display_frame = proc_frame
|
|
488
|
+
else:
|
|
489
|
+
upscaling = self.tex_w > proc_w or self.tex_h > proc_h
|
|
490
|
+
interp = cv2.INTER_LINEAR if upscaling else cv2.INTER_AREA
|
|
491
|
+
display_frame = cv2.resize(proc_frame, (self.tex_w, self.tex_h), interpolation=interp)
|
|
492
|
+
cv2.cvtColor(display_frame, cv2.COLOR_BGR2RGBA, dst=self._rgba_buf)
|
|
493
|
+
np.divide(self._rgba_buf, 255.0, out=self._tex_buf, casting="unsafe")
|
|
494
|
+
dpg.set_value(self.current_texture_tag, self._tex_buf.ravel())
|
|
495
|
+
|
|
496
|
+
def run(self):
|
|
497
|
+
dpg.create_context()
|
|
498
|
+
|
|
499
|
+
with dpg.theme() as global_theme:
|
|
500
|
+
|
|
501
|
+
with dpg.theme_component(dpg.mvAll):
|
|
502
|
+
dpg.add_theme_color(dpg.mvThemeCol_WindowBg, (30, 30, 30, 255))
|
|
503
|
+
dpg.add_theme_color(dpg.mvThemeCol_ChildBg, (30, 30, 30, 255))
|
|
504
|
+
dpg.add_theme_color(dpg.mvThemeCol_Text, (230, 230, 230, 255))
|
|
505
|
+
dpg.add_theme_color(dpg.mvThemeCol_Button, DEFAULT_BTN_COLOR)
|
|
506
|
+
dpg.add_theme_color(dpg.mvThemeCol_FrameBg, (45, 45, 48, 255))
|
|
507
|
+
dpg.add_theme_style(dpg.mvStyleVar_FrameRounding, 6)
|
|
508
|
+
dpg.add_theme_style(dpg.mvStyleVar_WindowRounding, 0)
|
|
509
|
+
dpg.bind_theme(global_theme)
|
|
510
|
+
|
|
511
|
+
with dpg.theme() as main_window_theme:
|
|
512
|
+
with dpg.theme_component(dpg.mvAll):
|
|
513
|
+
dpg.add_theme_style(dpg.mvStyleVar_WindowPadding, 0, 0)
|
|
514
|
+
dpg.add_theme_style(dpg.mvStyleVar_ItemSpacing, 0, 0)
|
|
515
|
+
dpg.add_theme_style(dpg.mvStyleVar_ChildBorderSize, 0)
|
|
516
|
+
|
|
517
|
+
with dpg.theme() as sidebar_theme:
|
|
518
|
+
with dpg.theme_component(dpg.mvChildWindow):
|
|
519
|
+
dpg.add_theme_style(dpg.mvStyleVar_WindowPadding, 16, 8)
|
|
520
|
+
|
|
521
|
+
with dpg.texture_registry():
|
|
522
|
+
initial = np.zeros((self.tex_h, self.tex_w, 3), dtype=np.uint8)
|
|
523
|
+
initial_rgba = cv2.cvtColor(initial, cv2.COLOR_BGR2RGBA).astype(np.float32) / 255.0
|
|
524
|
+
dpg.add_dynamic_texture(self.tex_w, self.tex_h, initial_rgba.flatten(), tag=VIDEO_TEXTURE_TAG)
|
|
525
|
+
|
|
526
|
+
with dpg.file_dialog(directory_selector=False, show=False, callback=self.load_video_callback,
|
|
527
|
+
tag="file_dialog", width=600, height=400):
|
|
528
|
+
dpg.add_file_extension(".mp4,.avi,.mov,.mkv", color=(0, 255, 0, 255))
|
|
529
|
+
dpg.add_file_extension(".*")
|
|
530
|
+
|
|
531
|
+
with dpg.file_dialog(directory_selector=False, show=False, callback=self.save_recording_callback,
|
|
532
|
+
tag="save_dialog", width=600, height=400, default_filename="recording",
|
|
533
|
+
modal=True):
|
|
534
|
+
dpg.add_file_extension(".mp4", color=(0, 255, 0, 255))
|
|
535
|
+
|
|
536
|
+
with dpg.window(tag="main_window", no_scrollbar=True):
|
|
537
|
+
dpg.bind_item_theme("main_window", main_window_theme)
|
|
538
|
+
with dpg.group(horizontal=True):
|
|
539
|
+
with dpg.child_window(width=SIDEBAR_WIDTH, autosize_y=True, tag="sidebar", border=False):
|
|
540
|
+
with dpg.group(horizontal=True):
|
|
541
|
+
dpg.add_spacer(width=16)
|
|
542
|
+
with dpg.child_window(width=SIDEBAR_WIDTH - 32, autosize_y=True, tag="sidebar_inner", border=False, no_scrollbar=True):
|
|
543
|
+
dpg.add_spacer(height=10)
|
|
544
|
+
|
|
545
|
+
dpg.add_text("Minimum Area")
|
|
546
|
+
dpg.add_slider_int(tag="min_area", default_value=500, min_value=0, max_value=10000, width=-1)
|
|
547
|
+
|
|
548
|
+
dpg.add_text("Max Blobs")
|
|
549
|
+
dpg.add_slider_int(tag="max_blobs", default_value=20, min_value=1, max_value=100, width=-1)
|
|
550
|
+
|
|
551
|
+
dpg.add_text(" Shape")
|
|
552
|
+
self._make_button_group(SHAPES, "shape_sel", "shape_btn", columns=3)
|
|
553
|
+
|
|
554
|
+
dpg.add_text("Bounding Size")
|
|
555
|
+
dpg.add_slider_int(tag="box_size_idx", default_value=1, min_value=0,
|
|
556
|
+
max_value=len(SIZE_PRESETS) - 1, width=-1)
|
|
557
|
+
|
|
558
|
+
dpg.add_text("Bounding Thickness")
|
|
559
|
+
dpg.add_slider_int(tag="box_thk", default_value=2, min_value=1, max_value=20, width=-1)
|
|
560
|
+
|
|
561
|
+
dpg.add_spacer(height=8)
|
|
562
|
+
dpg.add_checkbox(label="Connection Lines", tag="connect", default_value=False)
|
|
563
|
+
|
|
564
|
+
dpg.add_text("Nearest Neighbors")
|
|
565
|
+
dpg.add_slider_int(tag="connect_neighbors", default_value=2, min_value=1, max_value=6, width=-1)
|
|
566
|
+
|
|
567
|
+
dpg.add_text("Connection Line Thickness")
|
|
568
|
+
dpg.add_slider_int(tag="line_thk", default_value=1, min_value=1, max_value=10, width=-1)
|
|
569
|
+
|
|
570
|
+
dpg.add_text("Bounding Box Filter")
|
|
571
|
+
self._make_button_group(FILTERS, "filter_sel", "filter_btn", columns=3)
|
|
572
|
+
|
|
573
|
+
dpg.add_text("Font Size")
|
|
574
|
+
dpg.add_slider_float(tag="font_size", default_value=0.5, min_value=0.2, max_value=1.5, width=-1)
|
|
575
|
+
|
|
576
|
+
dpg.add_text("Bounding Box Color")
|
|
577
|
+
self._color_button_group("boxclr_btn", "shape_color_sel")
|
|
578
|
+
|
|
579
|
+
dpg.add_text("Connection Line Color")
|
|
580
|
+
self._color_button_group("lineclr_btn", "line_color_sel")
|
|
581
|
+
|
|
582
|
+
dpg.add_spacer(height=8)
|
|
583
|
+
dpg.add_text("Playback Speed")
|
|
584
|
+
self._make_button_group([label for label, _ in SPEED_PRESETS],
|
|
585
|
+
"playback_speed_sel", "speed_btn", columns=4)
|
|
586
|
+
|
|
587
|
+
dpg.add_spacer(height=8)
|
|
588
|
+
dpg.add_text("Live Resolution")
|
|
589
|
+
self._make_button_group([label for label, _ in RESOLUTION_PRESETS],
|
|
590
|
+
"res_scale_sel", "res_btn", columns=4)
|
|
591
|
+
|
|
592
|
+
dpg.add_spacer(height=15)
|
|
593
|
+
dpg.add_progress_bar(tag="progress_bar", default_value=0.0, width=-1)
|
|
594
|
+
dpg.add_text("", tag="progress_text")
|
|
595
|
+
|
|
596
|
+
dpg.add_spacer(height=10)
|
|
597
|
+
dpg.add_text("Export")
|
|
598
|
+
dpg.add_button(label="Record", tag="record_btn", width=-1, callback=lambda: self.toggle_record())
|
|
599
|
+
|
|
600
|
+
dpg.add_spacer(height=20)
|
|
601
|
+
dpg.add_text("@rileriaaa", color=(120, 120, 120, 255))
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
with dpg.theme() as panel_theme:
|
|
605
|
+
with dpg.theme_component(dpg.mvChildWindow):
|
|
606
|
+
dpg.add_theme_style(dpg.mvStyleVar_WindowPadding, 0, 0)
|
|
607
|
+
dpg.add_theme_style(dpg.mvStyleVar_ChildBorderSize, 0)
|
|
608
|
+
|
|
609
|
+
with dpg.child_window(tag="video_panel", border=False, no_scrollbar=True):
|
|
610
|
+
dpg.add_image(VIDEO_TEXTURE_TAG, tag=VIDEO_IMAGE_TAG,
|
|
611
|
+
width=self.disp_w, height=self.disp_h)
|
|
612
|
+
dpg.add_text("Load a video to begin", tag="placeholder_text",
|
|
613
|
+
color=(150, 150, 150, 255), show=(self.cap is None))
|
|
614
|
+
|
|
615
|
+
with dpg.group(horizontal=True, horizontal_spacing=10, pos=[15, 15]):
|
|
616
|
+
dpg.add_button(label="Load", tag="icon_load", width=40, height=40,
|
|
617
|
+
callback=lambda: dpg.show_item("file_dialog"))
|
|
618
|
+
dpg.add_button(label="Pause", tag="pause_btn", width=40, height=40,
|
|
619
|
+
callback=lambda: self.toggle_pause())
|
|
620
|
+
dpg.add_button(label="Full", tag="icon_fullscreen", width=40, height=40,
|
|
621
|
+
callback=lambda: self.toggle_fullscreen())
|
|
622
|
+
dpg.bind_item_theme("video_panel", panel_theme)
|
|
623
|
+
|
|
624
|
+
with dpg.theme() as icon_theme:
|
|
625
|
+
with dpg.theme_component(dpg.mvButton):
|
|
626
|
+
dpg.add_theme_color(dpg.mvThemeCol_Button, (20, 20, 20, 180))
|
|
627
|
+
dpg.add_theme_color(dpg.mvThemeCol_ButtonHovered, (60, 60, 60, 200))
|
|
628
|
+
dpg.add_theme_color(dpg.mvThemeCol_Border, (255, 255, 255, 200))
|
|
629
|
+
dpg.add_theme_style(dpg.mvStyleVar_FrameRounding, 20)
|
|
630
|
+
dpg.add_theme_style(dpg.mvStyleVar_FrameBorderSize, 1)
|
|
631
|
+
for tag in ("icon_load", "pause_btn", "icon_fullscreen"):
|
|
632
|
+
dpg.bind_item_theme(tag, icon_theme)
|
|
633
|
+
|
|
634
|
+
viewport_w = SIDEBAR_WIDTH + self.disp_w + 100
|
|
635
|
+
viewport_h = max(self.disp_h + 140, 800)
|
|
636
|
+
dpg.create_viewport(title="BlobTrack", width=viewport_w, height=viewport_h)
|
|
637
|
+
dpg.setup_dearpygui()
|
|
638
|
+
dpg.show_viewport()
|
|
639
|
+
dpg.set_primary_window("main_window", True)
|
|
640
|
+
dpg.set_viewport_resize_callback(lambda s, a: self.resize_video_display())
|
|
641
|
+
|
|
642
|
+
while dpg.is_dearpygui_running():
|
|
643
|
+
self.resize_video_display()
|
|
644
|
+
self.update()
|
|
645
|
+
dpg.render_dearpygui_frame()
|
|
646
|
+
|
|
647
|
+
self._stop_recording()
|
|
648
|
+
if self.cap:
|
|
649
|
+
self.cap.release()
|
|
650
|
+
dpg.destroy_context()
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def main():
|
|
654
|
+
parser = argparse.ArgumentParser(prog="blobtrack")
|
|
655
|
+
parser.add_argument("--source", default=None,
|
|
656
|
+
help="Video path or camera index (optional — omit to start empty and use Load Video)")
|
|
657
|
+
args = parser.parse_args()
|
|
658
|
+
if args.source is None:
|
|
659
|
+
src = None
|
|
660
|
+
else:
|
|
661
|
+
src = int(args.source) if args.source.isdigit() else args.source
|
|
662
|
+
|
|
663
|
+
app = BlobTrackerApp(src)
|
|
664
|
+
app.run()
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
if __name__ == "__main__":
|
|
668
|
+
main()
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: blobtrack
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: Real-time motion blob tracker with a Dear PyGui interface
|
|
5
|
+
Project-URL: Homepage, https://rilerialabs.onrender.com/
|
|
6
|
+
Project-URL: Repository, https://github.com/rileriaaa/blobtrack
|
|
7
|
+
Author: rileriaaa
|
|
8
|
+
License: MIT
|
|
9
|
+
Keywords: computer-vision,dearpygui,motion-tracking,opencv
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Multimedia :: Video
|
|
14
|
+
Requires-Python: >=3.8
|
|
15
|
+
Requires-Dist: dearpygui>=1.10
|
|
16
|
+
Requires-Dist: numpy>=1.20
|
|
17
|
+
Requires-Dist: opencv-python>=4.5
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# BlobTrack
|
|
21
|
+
|
|
22
|
+
A real-time motion blob tracker built with Dear PyGui and OpenCV. Load any video file (or a live camera feed), and BlobTrack detects moving objects via background subtraction, then overlays customizable bounding shapes, labels, and network-style connection lines between detected blobs.
|
|
23
|
+
|
|
24
|
+

|
|
25
|
+

|
|
26
|
+
|
|
27
|
+
## Features
|
|
28
|
+
|
|
29
|
+
- **Live motion detection** using MOG2 background subtraction
|
|
30
|
+
- **Customizable overlays** — square/circle/no outline, adjustable size, thickness, and color per blob
|
|
31
|
+
- **Bounding box filters** — apply Invert, Blur, Pixelate, Thermal, or Edge effects inside each detected region
|
|
32
|
+
- **Mesh connection lines** — link each blob to its *k* nearest neighbors with distance-based fade, for a network/web visualization
|
|
33
|
+
- **Adjustable playback speed** (1x–2x) for file sources
|
|
34
|
+
- **Live Resolution scaling** (Native/75%/50%/25%) to trade preview quality for performance without affecting export quality
|
|
35
|
+
- **Video export/recording** — save the processed feed (with all overlays) to `.mp4`, always at full native resolution regardless of preview settings
|
|
36
|
+
- **Fullscreen toggle** and a responsive video panel that resizes cleanly with the window
|
|
37
|
+
|
|
38
|
+
## Installation
|
|
39
|
+
|
|
40
|
+
Install as an isolated CLI tool with [pipx](https://pypa.github.io/pipx/) (recommended):
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pipx install blobtrack
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Or with pip into your own environment:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pip install blobtrack
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Usage
|
|
53
|
+
|
|
54
|
+
Run with no arguments and use the **Load** button to pick a video file:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
blobtrack
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Or pass a video path or camera index directly:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
blobtrack --source path/to/video.mp4
|
|
64
|
+
blobtrack --source 0 # webcam
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Controls
|
|
68
|
+
|
|
69
|
+
| Control | Description |
|
|
70
|
+
|---|---|
|
|
71
|
+
| **Minimum Area** | Filters out blobs smaller than this pixel area |
|
|
72
|
+
| **Max Blobs** | Caps how many blobs are tracked per frame |
|
|
73
|
+
| **Shape** | Bounding shape drawn around each blob (Square / Circle / None) |
|
|
74
|
+
| **Bounding Size / Thickness** | Size and stroke width of the bounding shape |
|
|
75
|
+
| **Connection Lines** | Toggles mesh-style lines between nearby blobs |
|
|
76
|
+
| **Nearest Neighbors** | How many neighbors each blob connects to |
|
|
77
|
+
| **Bounding Box Filter** | Visual effect applied inside each bounding box |
|
|
78
|
+
| **Font Size** | Size of the confidence-score label per blob |
|
|
79
|
+
| **Bounding Box / Connection Line Color** | Preset colors or a custom color picker |
|
|
80
|
+
| **Playback Speed** | Preview and export playback rate (locked during recording) |
|
|
81
|
+
| **Live Resolution** | Preview processing resolution (export always stays native) |
|
|
82
|
+
| **Record** | Opens a save dialog to choose where to export the processed feed, then starts/stops recording |
|
|
83
|
+
|
|
84
|
+
Floating buttons on the video panel: **Load** (open a file), **Pause/Play**, **Full** (fullscreen toggle).
|
|
85
|
+
|
|
86
|
+
## How it works
|
|
87
|
+
|
|
88
|
+
Each frame runs through OpenCV's `BackgroundSubtractorMOG2` to isolate moving regions, followed by thresholding, morphological opening, and dilation to clean up the mask. Contours are extracted, filtered by minimum area, and the largest N are kept as tracked blobs. Overlays are drawn directly onto the frame before it's uploaded to a Dear PyGui dynamic texture for display — and, if recording, written to disk via `cv2.VideoWriter` at full native resolution independent of what's shown in the live preview.
|
|
89
|
+
|
|
90
|
+
## Notes
|
|
91
|
+
|
|
92
|
+
- Recording locks the Playback Speed selector since the export FPS is fixed at recording start.
|
|
93
|
+
- Reaching the end of a file loop automatically stops any active recording and restarts playback from frame 0.
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
Built by [@rileriaaa](https://github.com/rileriaaa)
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
blobtrack/__init__.py,sha256=Pru0BlFBASFCFo7McHdohtKkUtgMPDwbGfyUZlE2_Vw,21
|
|
2
|
+
blobtrack/app.py,sha256=Fe-8pw_o02D4klccya3gA23N6Ua6fM3zV_lvGUk9Lmw,30370
|
|
3
|
+
blobtrack-0.1.2.dist-info/METADATA,sha256=_uNnMqmJtg-abNBvrvrb3HdLFx01Y3F9YoaUSH6BBZg,4301
|
|
4
|
+
blobtrack-0.1.2.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
5
|
+
blobtrack-0.1.2.dist-info/entry_points.txt,sha256=6y8uyN5cMl936R-Sdc1rh0pmlPeXKfx731X7yumnDiY,49
|
|
6
|
+
blobtrack-0.1.2.dist-info/RECORD,,
|