blobtrack 0.1.2__tar.gz

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.
@@ -0,0 +1,8 @@
1
+ /venv
2
+ /.vscode
3
+ __pycache__/
4
+ *.pyc
5
+ dist/
6
+ build/
7
+ *.egg-info/
8
+ .eggs/
@@ -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
+ ![Python](https://img.shields.io/badge/python-3.8+-blue)
25
+ ![License](https://img.shields.io/badge/license-MIT-green)
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,78 @@
1
+ # BlobTrack
2
+
3
+ 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.
4
+
5
+ ![Python](https://img.shields.io/badge/python-3.8+-blue)
6
+ ![License](https://img.shields.io/badge/license-MIT-green)
7
+
8
+ ## Features
9
+
10
+ - **Live motion detection** using MOG2 background subtraction
11
+ - **Customizable overlays** — square/circle/no outline, adjustable size, thickness, and color per blob
12
+ - **Bounding box filters** — apply Invert, Blur, Pixelate, Thermal, or Edge effects inside each detected region
13
+ - **Mesh connection lines** — link each blob to its *k* nearest neighbors with distance-based fade, for a network/web visualization
14
+ - **Adjustable playback speed** (1x–2x) for file sources
15
+ - **Live Resolution scaling** (Native/75%/50%/25%) to trade preview quality for performance without affecting export quality
16
+ - **Video export/recording** — save the processed feed (with all overlays) to `.mp4`, always at full native resolution regardless of preview settings
17
+ - **Fullscreen toggle** and a responsive video panel that resizes cleanly with the window
18
+
19
+ ## Installation
20
+
21
+ Install as an isolated CLI tool with [pipx](https://pypa.github.io/pipx/) (recommended):
22
+
23
+ ```bash
24
+ pipx install blobtrack
25
+ ```
26
+
27
+ Or with pip into your own environment:
28
+
29
+ ```bash
30
+ pip install blobtrack
31
+ ```
32
+
33
+ ## Usage
34
+
35
+ Run with no arguments and use the **Load** button to pick a video file:
36
+
37
+ ```bash
38
+ blobtrack
39
+ ```
40
+
41
+ Or pass a video path or camera index directly:
42
+
43
+ ```bash
44
+ blobtrack --source path/to/video.mp4
45
+ blobtrack --source 0 # webcam
46
+ ```
47
+
48
+ ## Controls
49
+
50
+ | Control | Description |
51
+ |---|---|
52
+ | **Minimum Area** | Filters out blobs smaller than this pixel area |
53
+ | **Max Blobs** | Caps how many blobs are tracked per frame |
54
+ | **Shape** | Bounding shape drawn around each blob (Square / Circle / None) |
55
+ | **Bounding Size / Thickness** | Size and stroke width of the bounding shape |
56
+ | **Connection Lines** | Toggles mesh-style lines between nearby blobs |
57
+ | **Nearest Neighbors** | How many neighbors each blob connects to |
58
+ | **Bounding Box Filter** | Visual effect applied inside each bounding box |
59
+ | **Font Size** | Size of the confidence-score label per blob |
60
+ | **Bounding Box / Connection Line Color** | Preset colors or a custom color picker |
61
+ | **Playback Speed** | Preview and export playback rate (locked during recording) |
62
+ | **Live Resolution** | Preview processing resolution (export always stays native) |
63
+ | **Record** | Opens a save dialog to choose where to export the processed feed, then starts/stops recording |
64
+
65
+ Floating buttons on the video panel: **Load** (open a file), **Pause/Play**, **Full** (fullscreen toggle).
66
+
67
+ ## How it works
68
+
69
+ 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.
70
+
71
+ ## Notes
72
+
73
+ - Recording locks the Playback Speed selector since the export FPS is fixed at recording start.
74
+ - Reaching the end of a file loop automatically stops any active recording and restarts playback from frame 0.
75
+
76
+ ---
77
+
78
+ Built by [@rileriaaa](https://github.com/rileriaaa)
@@ -0,0 +1,36 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "blobtrack"
7
+ version = "0.1.2"
8
+ description = "Real-time motion blob tracker with a Dear PyGui interface"
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ license = { text = "MIT" }
12
+ authors = [
13
+ { name = "rileriaaa" }
14
+ ]
15
+ keywords = ["opencv", "computer-vision", "motion-tracking", "dearpygui"]
16
+ classifiers = [
17
+ "Programming Language :: Python :: 3",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Operating System :: OS Independent",
20
+ "Topic :: Multimedia :: Video",
21
+ ]
22
+ dependencies = [
23
+ "dearpygui>=1.10",
24
+ "opencv-python>=4.5",
25
+ "numpy>=1.20",
26
+ ]
27
+
28
+ [project.urls]
29
+ Homepage = "https://rilerialabs.onrender.com/"
30
+ Repository = "https://github.com/rileriaaa/blobtrack"
31
+
32
+ [project.scripts]
33
+ blobtrack = "blobtrack.app:main"
34
+
35
+ [tool.hatch.build.targets.wheel]
36
+ packages = ["src/blobtrack"]
Binary file
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -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()