framesource 0.3.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.
- frame_processors/__init__.py +37 -0
- frame_source/__init__.py +50 -0
- framesource/__init__.py +93 -0
- framesource/_msmf_config.py +26 -0
- framesource/_version.py +24 -0
- framesource/discovery.py +109 -0
- framesource/errors.py +82 -0
- framesource/factory.py +290 -0
- framesource/processors/__init__.py +34 -0
- framesource/processors/equirectangular360_processor.py +577 -0
- framesource/processors/fisheye2equirectangular_processor.py +328 -0
- framesource/processors/frame_processor.py +30 -0
- framesource/processors/hyperspectral_processor.py +23 -0
- framesource/processors/realsense_depth_processor.py +90 -0
- framesource/py.typed +0 -0
- framesource/sources/__init__.py +40 -0
- framesource/sources/audiospectrogram_capture.py +1068 -0
- framesource/sources/basler_capture.py +477 -0
- framesource/sources/folder_capture.py +920 -0
- framesource/sources/genicam_capture.py +681 -0
- framesource/sources/huateng_capture.py +245 -0
- framesource/sources/ipcamera_capture.py +254 -0
- framesource/sources/mvsdk.py +2454 -0
- framesource/sources/realsense_capture.py +565 -0
- framesource/sources/screen_capture.py +800 -0
- framesource/sources/video_capture_base.py +560 -0
- framesource/sources/video_file_capture.py +259 -0
- framesource/sources/webcam_capture.py +511 -0
- framesource/sources/ximea_capture.py +299 -0
- framesource/threading_utils.py +790 -0
- framesource-0.3.0.dist-info/METADATA +787 -0
- framesource-0.3.0.dist-info/RECORD +37 -0
- framesource-0.3.0.dist-info/WHEEL +5 -0
- framesource-0.3.0.dist-info/licenses/LICENSE +21 -0
- framesource-0.3.0.dist-info/scm_file_list.json +276 -0
- framesource-0.3.0.dist-info/scm_version.json +8 -0
- framesource-0.3.0.dist-info/top_level.txt +3 -0
|
@@ -0,0 +1,920 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
import time
|
|
4
|
+
import warnings
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
|
|
7
|
+
import cv2
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
from .video_capture_base import VideoCaptureBase
|
|
11
|
+
|
|
12
|
+
# Try to import watchdog for folder monitoring. These are re-imported locally
|
|
13
|
+
# where used (see below); this pair only probes availability.
|
|
14
|
+
try:
|
|
15
|
+
from watchdog.events import FileSystemEventHandler # noqa: F401
|
|
16
|
+
from watchdog.observers import Observer # noqa: F401
|
|
17
|
+
|
|
18
|
+
WATCHDOG_AVAILABLE = True
|
|
19
|
+
except ImportError:
|
|
20
|
+
WATCHDOG_AVAILABLE = False
|
|
21
|
+
logging.getLogger(__name__).warning(
|
|
22
|
+
"watchdog library not available. Install with 'pip install watchdog' "
|
|
23
|
+
"for automatic folder monitoring."
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class FolderWatcherHandler:
|
|
30
|
+
"""File system event handler for folder watching."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, folder_capture_instance):
|
|
33
|
+
self.folder_capture = folder_capture_instance
|
|
34
|
+
self.valid_exts = (".jpg", ".jpeg", ".png", ".bmp", ".tiff", ".tif")
|
|
35
|
+
|
|
36
|
+
def on_created(self, event):
|
|
37
|
+
"""Handle file creation events."""
|
|
38
|
+
if not event.is_directory and self._is_image_file(event.src_path):
|
|
39
|
+
logger.info(f"New image file detected: {event.src_path}")
|
|
40
|
+
self._add_file(event.src_path)
|
|
41
|
+
|
|
42
|
+
def on_deleted(self, event):
|
|
43
|
+
"""Handle file deletion events."""
|
|
44
|
+
if not event.is_directory and self._is_image_file(event.src_path):
|
|
45
|
+
logger.info(f"Image file deleted: {event.src_path}")
|
|
46
|
+
self._remove_file(event.src_path)
|
|
47
|
+
|
|
48
|
+
def on_moved(self, event):
|
|
49
|
+
"""Handle file move/rename events."""
|
|
50
|
+
if not event.is_directory:
|
|
51
|
+
src_is_image = self._is_image_file(event.src_path)
|
|
52
|
+
dest_is_image = self._is_image_file(event.dest_path)
|
|
53
|
+
|
|
54
|
+
if src_is_image and dest_is_image:
|
|
55
|
+
# Image renamed to another image
|
|
56
|
+
logger.info(f"Image file renamed: {event.src_path} -> {event.dest_path}")
|
|
57
|
+
self._rename_file(event.src_path, event.dest_path)
|
|
58
|
+
elif src_is_image:
|
|
59
|
+
# Image renamed to non-image (effectively deleted)
|
|
60
|
+
logger.info(f"Image file removed: {event.src_path}")
|
|
61
|
+
self._remove_file(event.src_path)
|
|
62
|
+
elif dest_is_image:
|
|
63
|
+
# Non-image renamed to image (effectively added)
|
|
64
|
+
logger.info(f"Image file added: {event.dest_path}")
|
|
65
|
+
self._add_file(event.dest_path)
|
|
66
|
+
|
|
67
|
+
def _is_image_file(self, file_path):
|
|
68
|
+
"""Check if file is an image based on extension."""
|
|
69
|
+
return file_path.lower().endswith(self.valid_exts)
|
|
70
|
+
|
|
71
|
+
def _add_file(self, file_path):
|
|
72
|
+
"""Add a new file to the list immediately."""
|
|
73
|
+
if file_path not in self.folder_capture.image_files:
|
|
74
|
+
self.folder_capture.image_files.append(file_path)
|
|
75
|
+
# Re-sort the list according to the current sorting preference
|
|
76
|
+
if self.folder_capture.sort_by == "date":
|
|
77
|
+
self.folder_capture.image_files.sort(
|
|
78
|
+
key=lambda x: os.path.getctime(x) if os.path.exists(x) else 0
|
|
79
|
+
)
|
|
80
|
+
else:
|
|
81
|
+
self.folder_capture.image_files.sort()
|
|
82
|
+
|
|
83
|
+
self.folder_capture._last_file_count = len(self.folder_capture.image_files)
|
|
84
|
+
logger.info(
|
|
85
|
+
f"Added file to list: {file_path} (total: {len(self.folder_capture.image_files)})"
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
def _remove_file(self, file_path):
|
|
89
|
+
"""Remove a file from the list immediately."""
|
|
90
|
+
if file_path in self.folder_capture.image_files:
|
|
91
|
+
# Store current position before removal
|
|
92
|
+
current_index = self.folder_capture.index - 1 if self.folder_capture.index > 0 else 0
|
|
93
|
+
was_current_file = (
|
|
94
|
+
current_index < len(self.folder_capture.image_files)
|
|
95
|
+
and self.folder_capture.image_files[current_index] == file_path
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
# Get the index of the file to be removed before removing it
|
|
99
|
+
removed_index = self.folder_capture.image_files.index(file_path)
|
|
100
|
+
|
|
101
|
+
self.folder_capture.image_files.remove(file_path)
|
|
102
|
+
self.folder_capture._last_file_count = len(self.folder_capture.image_files)
|
|
103
|
+
|
|
104
|
+
# Adjust index if the removed file was at or before current position
|
|
105
|
+
if removed_index <= current_index:
|
|
106
|
+
if was_current_file:
|
|
107
|
+
# Current file was removed, stay at same index (will get next file)
|
|
108
|
+
pass
|
|
109
|
+
else:
|
|
110
|
+
# File before current was removed, adjust index
|
|
111
|
+
self.folder_capture.index = max(0, self.folder_capture.index - 1)
|
|
112
|
+
|
|
113
|
+
# Ensure index is within bounds
|
|
114
|
+
if self.folder_capture.index >= len(self.folder_capture.image_files):
|
|
115
|
+
self.folder_capture.index = max(0, len(self.folder_capture.image_files) - 1)
|
|
116
|
+
|
|
117
|
+
logger.info(
|
|
118
|
+
f"Removed file from list: {file_path} "
|
|
119
|
+
f"(total: {len(self.folder_capture.image_files)})"
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
def _rename_file(self, old_path, new_path):
|
|
123
|
+
"""Update file path when a file is renamed."""
|
|
124
|
+
if old_path in self.folder_capture.image_files:
|
|
125
|
+
index = self.folder_capture.image_files.index(old_path)
|
|
126
|
+
self.folder_capture.image_files[index] = new_path
|
|
127
|
+
|
|
128
|
+
# Re-sort if necessary
|
|
129
|
+
if self.folder_capture.sort_by == "date":
|
|
130
|
+
self.folder_capture.image_files.sort(
|
|
131
|
+
key=lambda x: os.path.getctime(x) if os.path.exists(x) else 0
|
|
132
|
+
)
|
|
133
|
+
else:
|
|
134
|
+
self.folder_capture.image_files.sort()
|
|
135
|
+
|
|
136
|
+
logger.info(f"Renamed file in list: {old_path} -> {new_path}")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class FolderCapture(VideoCaptureBase):
|
|
140
|
+
"""
|
|
141
|
+
Capture class for reading images from a folder as a video stream.
|
|
142
|
+
Images can be sorted by creation time or by name.
|
|
143
|
+
Supports automatic folder monitoring to detect new/removed images.
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
has_discovery = False # Uses folder paths, not discoverable devices
|
|
147
|
+
supports_exposure = False # Still images have no controllable exposure
|
|
148
|
+
supports_gain = False # Still images have no controllable gain
|
|
149
|
+
|
|
150
|
+
def __init__(
|
|
151
|
+
self,
|
|
152
|
+
source: str,
|
|
153
|
+
sort_by: str = "name",
|
|
154
|
+
width: Optional[int] = None,
|
|
155
|
+
height: Optional[int] = None,
|
|
156
|
+
fps: float = 30.0,
|
|
157
|
+
real_time: bool = True,
|
|
158
|
+
loop: bool = False,
|
|
159
|
+
watch_folder: bool = True,
|
|
160
|
+
**kwargs,
|
|
161
|
+
):
|
|
162
|
+
"""Initialize the folder (image sequence) capture.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
source: Path to the folder containing image files.
|
|
166
|
+
sort_by: ``'name'`` (default) or ``'date'`` ordering of images.
|
|
167
|
+
width: Optional target frame width (images are resized when both
|
|
168
|
+
``width`` and ``height`` are set).
|
|
169
|
+
height: Optional target frame height (see ``width``).
|
|
170
|
+
fps: Playback frame rate in frames per second.
|
|
171
|
+
real_time: Play at ``fps`` (disable for fastest processing).
|
|
172
|
+
loop: Restart from the first image when the end is reached.
|
|
173
|
+
watch_folder: Monitor the folder for added/removed files (requires
|
|
174
|
+
the optional ``watchdog`` dependency).
|
|
175
|
+
**kwargs: Additional passthrough options stored on ``self.config``.
|
|
176
|
+
"""
|
|
177
|
+
super().__init__(source, **kwargs)
|
|
178
|
+
self.sort_by = sort_by
|
|
179
|
+
self.width = width
|
|
180
|
+
self.height = height
|
|
181
|
+
self.fps = fps
|
|
182
|
+
self.real_time = real_time
|
|
183
|
+
self.loop = loop
|
|
184
|
+
self.watch_folder = watch_folder and WATCHDOG_AVAILABLE
|
|
185
|
+
self.image_files: list[str] = []
|
|
186
|
+
self.index = 0
|
|
187
|
+
self.time_of_last_frame = 0.0
|
|
188
|
+
self._capture_thread = None
|
|
189
|
+
self._stop_event = None
|
|
190
|
+
self._latest_frame = None
|
|
191
|
+
self._latest_success = False
|
|
192
|
+
|
|
193
|
+
# Folder watching components
|
|
194
|
+
self._folder_observer = None
|
|
195
|
+
self._folder_handler = None
|
|
196
|
+
self._refresh_lock = None
|
|
197
|
+
self._last_file_count = 0
|
|
198
|
+
|
|
199
|
+
if not WATCHDOG_AVAILABLE and watch_folder:
|
|
200
|
+
logger.warning(
|
|
201
|
+
"Folder watching requested but watchdog library not available. "
|
|
202
|
+
"Install with 'pip install watchdog'."
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
def _start_folder_watching(self):
|
|
206
|
+
"""Start monitoring the folder for file changes."""
|
|
207
|
+
if not self.watch_folder or not WATCHDOG_AVAILABLE:
|
|
208
|
+
return
|
|
209
|
+
|
|
210
|
+
try:
|
|
211
|
+
import threading
|
|
212
|
+
|
|
213
|
+
self._refresh_lock = threading.Lock()
|
|
214
|
+
|
|
215
|
+
# Create and configure the folder watcher
|
|
216
|
+
if WATCHDOG_AVAILABLE:
|
|
217
|
+
from watchdog.events import FileSystemEventHandler
|
|
218
|
+
from watchdog.observers import Observer
|
|
219
|
+
|
|
220
|
+
class WatchdogHandler(FileSystemEventHandler):
|
|
221
|
+
def __init__(self, folder_handler):
|
|
222
|
+
self.folder_handler = folder_handler
|
|
223
|
+
|
|
224
|
+
def on_created(self, event):
|
|
225
|
+
self.folder_handler.on_created(event)
|
|
226
|
+
|
|
227
|
+
def on_deleted(self, event):
|
|
228
|
+
self.folder_handler.on_deleted(event)
|
|
229
|
+
|
|
230
|
+
def on_moved(self, event):
|
|
231
|
+
self.folder_handler.on_moved(event)
|
|
232
|
+
|
|
233
|
+
self._folder_handler = FolderWatcherHandler(self)
|
|
234
|
+
watchdog_handler = WatchdogHandler(self._folder_handler)
|
|
235
|
+
|
|
236
|
+
self._folder_observer = Observer()
|
|
237
|
+
self._folder_observer.schedule(watchdog_handler, self.source, recursive=False)
|
|
238
|
+
self._folder_observer.start()
|
|
239
|
+
|
|
240
|
+
logger.info(f"Started folder watching for: {self.source}")
|
|
241
|
+
except Exception as e:
|
|
242
|
+
logger.error(f"Failed to start folder watching: {e}")
|
|
243
|
+
self._folder_observer = None
|
|
244
|
+
|
|
245
|
+
def _stop_folder_watching(self):
|
|
246
|
+
"""Stop monitoring the folder for file changes."""
|
|
247
|
+
if self._folder_observer:
|
|
248
|
+
try:
|
|
249
|
+
self._folder_observer.stop()
|
|
250
|
+
self._folder_observer.join(timeout=2)
|
|
251
|
+
logger.info("Stopped folder watching")
|
|
252
|
+
except Exception as e:
|
|
253
|
+
logger.error(f"Error stopping folder watcher: {e}")
|
|
254
|
+
finally:
|
|
255
|
+
self._folder_observer = None
|
|
256
|
+
self._folder_handler = None
|
|
257
|
+
|
|
258
|
+
def _refresh_file_list_async(self):
|
|
259
|
+
"""Async version of refresh_file_list to avoid blocking the file watcher."""
|
|
260
|
+
if hasattr(self, "_refresh_lock") and self._refresh_lock:
|
|
261
|
+
with self._refresh_lock:
|
|
262
|
+
self._refresh_file_list()
|
|
263
|
+
else:
|
|
264
|
+
self._refresh_file_list()
|
|
265
|
+
|
|
266
|
+
def connect(self) -> bool:
|
|
267
|
+
if not os.path.isdir(self.source):
|
|
268
|
+
logger.error(f"Folder not found: {self.source}")
|
|
269
|
+
return False
|
|
270
|
+
# List image files
|
|
271
|
+
valid_exts = (".jpg", ".jpeg", ".png", ".bmp", ".tiff", ".tif")
|
|
272
|
+
files = [
|
|
273
|
+
os.path.join(self.source, f)
|
|
274
|
+
for f in os.listdir(self.source)
|
|
275
|
+
if f.lower().endswith(valid_exts)
|
|
276
|
+
]
|
|
277
|
+
if self.sort_by == "date":
|
|
278
|
+
files.sort(key=lambda x: os.path.getctime(x))
|
|
279
|
+
else:
|
|
280
|
+
files.sort() # Default: sort by name
|
|
281
|
+
if not files:
|
|
282
|
+
logger.error(f"No image files found in folder: {self.source}")
|
|
283
|
+
return False
|
|
284
|
+
self.image_files = files
|
|
285
|
+
self.index = 0
|
|
286
|
+
self.is_connected = True
|
|
287
|
+
self.time_of_last_frame = time.time()
|
|
288
|
+
self._last_file_count = len(files) # Initialize file count tracking
|
|
289
|
+
|
|
290
|
+
# Start folder watching if enabled
|
|
291
|
+
self._start_folder_watching()
|
|
292
|
+
|
|
293
|
+
watch_state = "enabled" if self.watch_folder else "disabled"
|
|
294
|
+
logger.info(f"Connected to folder with {len(files)} images. Folder watching: {watch_state}")
|
|
295
|
+
return True
|
|
296
|
+
|
|
297
|
+
def disconnect(self) -> bool:
|
|
298
|
+
# Stop folder watching first
|
|
299
|
+
self._stop_folder_watching()
|
|
300
|
+
|
|
301
|
+
self.is_connected = False
|
|
302
|
+
self.image_files = []
|
|
303
|
+
self.index = 0
|
|
304
|
+
logger.info("Disconnected from folder capture.")
|
|
305
|
+
return True
|
|
306
|
+
|
|
307
|
+
def _refresh_file_list(self) -> bool:
|
|
308
|
+
"""
|
|
309
|
+
Refresh the file list to handle files that may have been added, removed, or renamed.
|
|
310
|
+
|
|
311
|
+
Returns:
|
|
312
|
+
bool: True if file list was successfully refreshed
|
|
313
|
+
"""
|
|
314
|
+
if not os.path.isdir(self.source):
|
|
315
|
+
logger.error(f"Source folder no longer exists: {self.source}")
|
|
316
|
+
return False
|
|
317
|
+
|
|
318
|
+
# Store current file path WITHOUT calling get_current_file_path() to avoid recursion
|
|
319
|
+
current_file = None
|
|
320
|
+
if self.image_files and 0 <= self.index - 1 < len(self.image_files):
|
|
321
|
+
current_file = (
|
|
322
|
+
self.image_files[self.index - 1] if self.index > 0 else self.image_files[0]
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
# Re-scan folder for image files
|
|
326
|
+
valid_exts = (".jpg", ".jpeg", ".png", ".bmp", ".tiff", ".tif")
|
|
327
|
+
files = [
|
|
328
|
+
os.path.join(self.source, f)
|
|
329
|
+
for f in os.listdir(self.source)
|
|
330
|
+
if f.lower().endswith(valid_exts) and os.path.exists(os.path.join(self.source, f))
|
|
331
|
+
]
|
|
332
|
+
|
|
333
|
+
if self.sort_by == "date":
|
|
334
|
+
files.sort(key=lambda x: os.path.getctime(x))
|
|
335
|
+
else:
|
|
336
|
+
files.sort() # Default: sort by name
|
|
337
|
+
|
|
338
|
+
if not files:
|
|
339
|
+
logger.warning(f"No image files found in folder after refresh: {self.source}")
|
|
340
|
+
self.image_files = []
|
|
341
|
+
self.index = 0
|
|
342
|
+
return False
|
|
343
|
+
|
|
344
|
+
# Update file list
|
|
345
|
+
old_count = len(self.image_files)
|
|
346
|
+
self.image_files = files
|
|
347
|
+
new_count = len(self.image_files)
|
|
348
|
+
|
|
349
|
+
# Update the background monitor's file count tracking
|
|
350
|
+
self._last_file_count = new_count
|
|
351
|
+
|
|
352
|
+
# Try to maintain current position if the file still exists
|
|
353
|
+
if current_file and current_file in self.image_files:
|
|
354
|
+
self.index = (
|
|
355
|
+
self.image_files.index(current_file) + 1
|
|
356
|
+
) # +1 because index is incremented after reading
|
|
357
|
+
else:
|
|
358
|
+
# File no longer exists, clamp index to valid range
|
|
359
|
+
self.index = min(self.index, len(self.image_files))
|
|
360
|
+
if self.index <= 0:
|
|
361
|
+
self.index = 0
|
|
362
|
+
|
|
363
|
+
logger.info(
|
|
364
|
+
f"Refreshed file list: {old_count} -> {new_count} files, current index: {self.index}"
|
|
365
|
+
)
|
|
366
|
+
return True
|
|
367
|
+
|
|
368
|
+
def _read_implementation(self) -> tuple[bool, Optional[np.ndarray]]:
|
|
369
|
+
if not self.is_connected or not self.image_files:
|
|
370
|
+
return False, None
|
|
371
|
+
# Real-time playback control
|
|
372
|
+
if self.real_time and self.fps > 0:
|
|
373
|
+
frame_duration = 1.0 / self.fps
|
|
374
|
+
now = time.time()
|
|
375
|
+
elapsed = now - self.time_of_last_frame
|
|
376
|
+
if elapsed < frame_duration:
|
|
377
|
+
time.sleep(frame_duration - elapsed)
|
|
378
|
+
self.time_of_last_frame = time.time()
|
|
379
|
+
# Read image
|
|
380
|
+
if self.index >= len(self.image_files):
|
|
381
|
+
if self.loop:
|
|
382
|
+
self.index = 0
|
|
383
|
+
else:
|
|
384
|
+
return False, None
|
|
385
|
+
img_path = self.image_files[self.index]
|
|
386
|
+
|
|
387
|
+
# Check if file still exists before trying to read it
|
|
388
|
+
if not os.path.exists(img_path):
|
|
389
|
+
logger.warning(f"Image file no longer exists: {img_path}")
|
|
390
|
+
# Try to refresh the file list to see if files were moved/renamed
|
|
391
|
+
self._refresh_file_list()
|
|
392
|
+
# Skip this file and try the next one
|
|
393
|
+
self.index += 1
|
|
394
|
+
return False, None
|
|
395
|
+
|
|
396
|
+
img = cv2.imread(img_path)
|
|
397
|
+
if img is None:
|
|
398
|
+
logger.warning(f"Failed to read image: {img_path}")
|
|
399
|
+
self.index += 1
|
|
400
|
+
return False, None
|
|
401
|
+
# Resize if needed
|
|
402
|
+
if self.width is not None and self.height is not None:
|
|
403
|
+
img = cv2.resize(img, (self.width, self.height), interpolation=cv2.INTER_AREA)
|
|
404
|
+
self.index += 1
|
|
405
|
+
return True, img
|
|
406
|
+
|
|
407
|
+
def set_exposure(self, value: float) -> bool:
|
|
408
|
+
logger.warning("Exposure control not applicable for folder capture.")
|
|
409
|
+
return False
|
|
410
|
+
|
|
411
|
+
def get_exposure(self) -> Optional[float]:
|
|
412
|
+
logger.warning("Exposure control not applicable for folder capture.")
|
|
413
|
+
return None
|
|
414
|
+
|
|
415
|
+
def set_gain(self, value: float) -> bool:
|
|
416
|
+
logger.warning("Gain control not applicable for folder capture.")
|
|
417
|
+
return False
|
|
418
|
+
|
|
419
|
+
def get_gain(self) -> Optional[float]:
|
|
420
|
+
logger.warning("Gain control not applicable for folder capture.")
|
|
421
|
+
return None
|
|
422
|
+
|
|
423
|
+
def enable_auto_exposure(self, enable: bool = True) -> bool:
|
|
424
|
+
logger.warning("Auto exposure control not applicable for folder capture.")
|
|
425
|
+
return False
|
|
426
|
+
|
|
427
|
+
def get_frame_size(self) -> Optional[tuple[int, int]]:
|
|
428
|
+
if not self.image_files:
|
|
429
|
+
return None
|
|
430
|
+
|
|
431
|
+
# Find the first existing image file
|
|
432
|
+
for img_path in self.image_files:
|
|
433
|
+
if os.path.exists(img_path):
|
|
434
|
+
img = cv2.imread(img_path)
|
|
435
|
+
if img is not None:
|
|
436
|
+
h, w = img.shape[:2]
|
|
437
|
+
if self.width is not None and self.height is not None:
|
|
438
|
+
return (self.width, self.height)
|
|
439
|
+
return (w, h)
|
|
440
|
+
|
|
441
|
+
# If no valid images found, try refreshing the file list
|
|
442
|
+
logger.warning("No valid image files found for frame size detection")
|
|
443
|
+
if self._refresh_file_list() and self.image_files:
|
|
444
|
+
# Try again with refreshed list
|
|
445
|
+
for img_path in self.image_files:
|
|
446
|
+
if os.path.exists(img_path):
|
|
447
|
+
img = cv2.imread(img_path)
|
|
448
|
+
if img is not None:
|
|
449
|
+
h, w = img.shape[:2]
|
|
450
|
+
if self.width is not None and self.height is not None:
|
|
451
|
+
return (self.width, self.height)
|
|
452
|
+
return (w, h)
|
|
453
|
+
|
|
454
|
+
return None
|
|
455
|
+
|
|
456
|
+
def set_frame_size(self, width: int, height: int) -> bool:
|
|
457
|
+
self.width = width
|
|
458
|
+
self.height = height
|
|
459
|
+
return True
|
|
460
|
+
|
|
461
|
+
def get_fps(self) -> Optional[float]:
|
|
462
|
+
return self.fps
|
|
463
|
+
|
|
464
|
+
def set_fps(self, fps: float) -> bool:
|
|
465
|
+
self.fps = fps
|
|
466
|
+
return True
|
|
467
|
+
|
|
468
|
+
def get_current_file_path(self) -> Optional[str]:
|
|
469
|
+
"""
|
|
470
|
+
Get the file path of the current image.
|
|
471
|
+
|
|
472
|
+
Returns:
|
|
473
|
+
Optional[str]: Path to the current image file, or None if no current file
|
|
474
|
+
"""
|
|
475
|
+
if not self.is_connected or not self.image_files:
|
|
476
|
+
return None
|
|
477
|
+
|
|
478
|
+
# Get the current index (accounting for the fact that index is incremented after reading)
|
|
479
|
+
current_index = self.index - 1 if self.index > 0 else 0
|
|
480
|
+
|
|
481
|
+
# Handle case where we've reached the end and looping is disabled
|
|
482
|
+
if current_index >= len(self.image_files):
|
|
483
|
+
if self.loop:
|
|
484
|
+
current_index = 0
|
|
485
|
+
else:
|
|
486
|
+
# Return the last valid file path
|
|
487
|
+
current_index = len(self.image_files) - 1
|
|
488
|
+
|
|
489
|
+
# Ensure index is within bounds
|
|
490
|
+
if 0 <= current_index < len(self.image_files):
|
|
491
|
+
file_path = self.image_files[current_index]
|
|
492
|
+
# Check if file still exists
|
|
493
|
+
if os.path.exists(file_path):
|
|
494
|
+
return file_path
|
|
495
|
+
else:
|
|
496
|
+
logger.warning(f"Current file no longer exists: {file_path}")
|
|
497
|
+
# Try to refresh file list
|
|
498
|
+
if self._refresh_file_list():
|
|
499
|
+
# Retry with refreshed list
|
|
500
|
+
if 0 <= current_index < len(self.image_files):
|
|
501
|
+
return self.image_files[current_index]
|
|
502
|
+
|
|
503
|
+
return None
|
|
504
|
+
|
|
505
|
+
def get_current_file_info(self) -> Optional[dict]:
|
|
506
|
+
"""
|
|
507
|
+
Get detailed information about the current image file.
|
|
508
|
+
|
|
509
|
+
Returns:
|
|
510
|
+
Optional[dict]: Dictionary containing file information, or None if no current file
|
|
511
|
+
"""
|
|
512
|
+
current_path = self.get_current_file_path()
|
|
513
|
+
if current_path is None:
|
|
514
|
+
return None
|
|
515
|
+
|
|
516
|
+
try:
|
|
517
|
+
file_stat = os.stat(current_path)
|
|
518
|
+
return {
|
|
519
|
+
"path": current_path,
|
|
520
|
+
"filename": os.path.basename(current_path),
|
|
521
|
+
"size_bytes": file_stat.st_size,
|
|
522
|
+
"creation_time": file_stat.st_ctime,
|
|
523
|
+
"modification_time": file_stat.st_mtime,
|
|
524
|
+
"index": self.index - 1 if self.index > 0 else 0,
|
|
525
|
+
"total_files": len(self.image_files),
|
|
526
|
+
}
|
|
527
|
+
except OSError as e:
|
|
528
|
+
logger.warning(f"Could not get file info for {current_path}: {e}")
|
|
529
|
+
return None
|
|
530
|
+
|
|
531
|
+
def get_file_list(self) -> list[str]:
|
|
532
|
+
"""
|
|
533
|
+
Get the complete list of image files in the folder.
|
|
534
|
+
|
|
535
|
+
Returns:
|
|
536
|
+
List[str]: List of all image file paths
|
|
537
|
+
"""
|
|
538
|
+
return self.image_files.copy()
|
|
539
|
+
|
|
540
|
+
def get_current_index(self) -> int:
|
|
541
|
+
"""
|
|
542
|
+
Get the current file index.
|
|
543
|
+
|
|
544
|
+
Returns:
|
|
545
|
+
int: Current index in the file list
|
|
546
|
+
"""
|
|
547
|
+
return self.index - 1 if self.index > 0 else 0
|
|
548
|
+
|
|
549
|
+
def set_current_index(self, index: int) -> bool:
|
|
550
|
+
"""
|
|
551
|
+
Set the current file index to jump to a specific image.
|
|
552
|
+
|
|
553
|
+
Args:
|
|
554
|
+
index: Index to jump to (0-based)
|
|
555
|
+
|
|
556
|
+
Returns:
|
|
557
|
+
bool: True if index was set successfully
|
|
558
|
+
"""
|
|
559
|
+
if not self.is_connected or not self.image_files:
|
|
560
|
+
return False
|
|
561
|
+
|
|
562
|
+
if 0 <= index < len(self.image_files):
|
|
563
|
+
# Check if the target file exists
|
|
564
|
+
target_file = self.image_files[index]
|
|
565
|
+
if os.path.exists(target_file):
|
|
566
|
+
self.index = index
|
|
567
|
+
return True
|
|
568
|
+
else:
|
|
569
|
+
logger.warning(f"Target file at index {index} no longer exists: {target_file}")
|
|
570
|
+
# Try refreshing file list
|
|
571
|
+
if self._refresh_file_list():
|
|
572
|
+
# Check if index is still valid after refresh
|
|
573
|
+
if 0 <= index < len(self.image_files):
|
|
574
|
+
self.index = index
|
|
575
|
+
return True
|
|
576
|
+
return False
|
|
577
|
+
else:
|
|
578
|
+
logger.warning(f"Index {index} out of range (0-{len(self.image_files) - 1})")
|
|
579
|
+
return False
|
|
580
|
+
|
|
581
|
+
def validate_file_list(self) -> int:
|
|
582
|
+
"""
|
|
583
|
+
Validate all files in the list and remove any that no longer exist.
|
|
584
|
+
|
|
585
|
+
Returns:
|
|
586
|
+
int: Number of files removed
|
|
587
|
+
"""
|
|
588
|
+
if not self.image_files:
|
|
589
|
+
return 0
|
|
590
|
+
|
|
591
|
+
original_count = len(self.image_files)
|
|
592
|
+
current_file = self.get_current_file_path()
|
|
593
|
+
|
|
594
|
+
# Filter out files that no longer exist
|
|
595
|
+
valid_files = [f for f in self.image_files if os.path.exists(f)]
|
|
596
|
+
|
|
597
|
+
removed_count = original_count - len(valid_files)
|
|
598
|
+
|
|
599
|
+
if removed_count > 0:
|
|
600
|
+
logger.warning(f"Removed {removed_count} missing files from list")
|
|
601
|
+
self.image_files = valid_files
|
|
602
|
+
|
|
603
|
+
# Adjust current index
|
|
604
|
+
if current_file and current_file in self.image_files:
|
|
605
|
+
self.index = self.image_files.index(current_file)
|
|
606
|
+
else:
|
|
607
|
+
# Current file was removed, clamp index
|
|
608
|
+
self.index = min(self.index, len(self.image_files) - 1)
|
|
609
|
+
if self.index < 0:
|
|
610
|
+
self.index = 0
|
|
611
|
+
|
|
612
|
+
return removed_count
|
|
613
|
+
|
|
614
|
+
def get_missing_files(self) -> list[str]:
|
|
615
|
+
"""
|
|
616
|
+
Get a list of files that are in the file list but no longer exist on disk.
|
|
617
|
+
|
|
618
|
+
Returns:
|
|
619
|
+
List[str]: List of missing file paths
|
|
620
|
+
"""
|
|
621
|
+
return [f for f in self.image_files if not os.path.exists(f)]
|
|
622
|
+
|
|
623
|
+
def enable_folder_watching(self, enable: bool = True) -> bool:
|
|
624
|
+
"""
|
|
625
|
+
Enable or disable folder watching.
|
|
626
|
+
|
|
627
|
+
Args:
|
|
628
|
+
enable: True to enable, False to disable
|
|
629
|
+
|
|
630
|
+
Returns:
|
|
631
|
+
bool: True if successful
|
|
632
|
+
"""
|
|
633
|
+
if not WATCHDOG_AVAILABLE:
|
|
634
|
+
logger.warning("Folder watching not available - watchdog library not installed")
|
|
635
|
+
return False
|
|
636
|
+
|
|
637
|
+
if enable and not self.watch_folder:
|
|
638
|
+
self.watch_folder = True
|
|
639
|
+
if self.is_connected:
|
|
640
|
+
self._start_folder_watching()
|
|
641
|
+
return True
|
|
642
|
+
elif not enable and self.watch_folder:
|
|
643
|
+
self.watch_folder = False
|
|
644
|
+
self._stop_folder_watching()
|
|
645
|
+
return True
|
|
646
|
+
|
|
647
|
+
return True # Already in desired state
|
|
648
|
+
|
|
649
|
+
def is_folder_watching_enabled(self) -> bool:
|
|
650
|
+
"""
|
|
651
|
+
Check if folder watching is currently enabled.
|
|
652
|
+
|
|
653
|
+
Returns:
|
|
654
|
+
bool: True if folder watching is enabled
|
|
655
|
+
"""
|
|
656
|
+
return self.watch_folder and self._folder_observer is not None
|
|
657
|
+
|
|
658
|
+
def get_folder_watching_status(self) -> dict:
|
|
659
|
+
"""
|
|
660
|
+
Get detailed folder watching status.
|
|
661
|
+
|
|
662
|
+
Returns:
|
|
663
|
+
dict: Status information
|
|
664
|
+
"""
|
|
665
|
+
return {
|
|
666
|
+
"watchdog_available": WATCHDOG_AVAILABLE,
|
|
667
|
+
"watching_enabled": self.watch_folder,
|
|
668
|
+
"observer_running": self._folder_observer is not None
|
|
669
|
+
and self._folder_observer.is_alive()
|
|
670
|
+
if self._folder_observer
|
|
671
|
+
else False,
|
|
672
|
+
"folder_path": self.source,
|
|
673
|
+
"total_files": len(self.image_files),
|
|
674
|
+
"missing_files": len(self.get_missing_files()),
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
def force_refresh(self) -> int:
|
|
678
|
+
"""
|
|
679
|
+
Manually trigger a file list refresh.
|
|
680
|
+
|
|
681
|
+
Returns:
|
|
682
|
+
int: Number of files in the updated list
|
|
683
|
+
"""
|
|
684
|
+
self._refresh_file_list()
|
|
685
|
+
return len(self.image_files)
|
|
686
|
+
|
|
687
|
+
@classmethod
|
|
688
|
+
def discover(cls) -> list:
|
|
689
|
+
"""
|
|
690
|
+
Discover method for folder capture sources.
|
|
691
|
+
|
|
692
|
+
Returns:
|
|
693
|
+
list: Empty list, as discovery is not applicable for folder-based sources.
|
|
694
|
+
Use this class directly with folder paths as the source parameter.
|
|
695
|
+
"""
|
|
696
|
+
# Folder capture doesn't discover devices - it works with folder paths
|
|
697
|
+
logger.info("FolderCapture uses folder paths as sources, not discoverable devices.")
|
|
698
|
+
return []
|
|
699
|
+
|
|
700
|
+
@classmethod
|
|
701
|
+
def get_config_schema(cls) -> dict[str, Any]:
|
|
702
|
+
"""Get configuration schema for folder capture"""
|
|
703
|
+
warnings.warn(
|
|
704
|
+
"get_config_schema() is deprecated and will be removed in a future release; "
|
|
705
|
+
"UI form schemas belong in the consuming application.",
|
|
706
|
+
DeprecationWarning,
|
|
707
|
+
stacklevel=2,
|
|
708
|
+
)
|
|
709
|
+
return {
|
|
710
|
+
"title": "Folder Capture Configuration",
|
|
711
|
+
"description": "Configure image folder as video stream settings",
|
|
712
|
+
"fields": [
|
|
713
|
+
{
|
|
714
|
+
"name": "source",
|
|
715
|
+
"label": "Folder Path",
|
|
716
|
+
"type": "text",
|
|
717
|
+
"placeholder": "path/to/image/folder",
|
|
718
|
+
"description": "Path to folder containing image files",
|
|
719
|
+
"required": True,
|
|
720
|
+
},
|
|
721
|
+
{
|
|
722
|
+
"name": "sort_by",
|
|
723
|
+
"label": "Sort Method",
|
|
724
|
+
"type": "select",
|
|
725
|
+
"options": [
|
|
726
|
+
{"value": "name", "label": "By Name"},
|
|
727
|
+
{"value": "date", "label": "By Creation Date"},
|
|
728
|
+
],
|
|
729
|
+
"description": "How to sort images in the folder",
|
|
730
|
+
"required": False,
|
|
731
|
+
"default": "name",
|
|
732
|
+
},
|
|
733
|
+
{
|
|
734
|
+
"name": "fps",
|
|
735
|
+
"label": "Frame Rate (FPS)",
|
|
736
|
+
"type": "number",
|
|
737
|
+
"min": 1,
|
|
738
|
+
"max": 120,
|
|
739
|
+
"placeholder": "30",
|
|
740
|
+
"description": "Frames per second for playback",
|
|
741
|
+
"required": False,
|
|
742
|
+
"default": 30,
|
|
743
|
+
},
|
|
744
|
+
{
|
|
745
|
+
"name": "loop",
|
|
746
|
+
"label": "Loop Playback",
|
|
747
|
+
"type": "checkbox",
|
|
748
|
+
"description": "Restart from first image when reaching the end",
|
|
749
|
+
"required": False,
|
|
750
|
+
"default": False,
|
|
751
|
+
},
|
|
752
|
+
{
|
|
753
|
+
"name": "real_time",
|
|
754
|
+
"label": "Real-time Playback",
|
|
755
|
+
"type": "checkbox",
|
|
756
|
+
"description": "Play at specified frame rate (disable for fastest processing)",
|
|
757
|
+
"required": False,
|
|
758
|
+
"default": True,
|
|
759
|
+
},
|
|
760
|
+
{
|
|
761
|
+
"name": "watch_folder",
|
|
762
|
+
"label": "Watch for Changes",
|
|
763
|
+
"type": "checkbox",
|
|
764
|
+
"description": "Automatically detect when files are added/removed",
|
|
765
|
+
"required": False,
|
|
766
|
+
"default": True,
|
|
767
|
+
},
|
|
768
|
+
{
|
|
769
|
+
"name": "width",
|
|
770
|
+
"label": "Width",
|
|
771
|
+
"type": "number",
|
|
772
|
+
"min": 160,
|
|
773
|
+
"max": 4096,
|
|
774
|
+
"placeholder": "original",
|
|
775
|
+
"description": "Resize frame width (optional)",
|
|
776
|
+
"required": False,
|
|
777
|
+
},
|
|
778
|
+
{
|
|
779
|
+
"name": "height",
|
|
780
|
+
"label": "Height",
|
|
781
|
+
"type": "number",
|
|
782
|
+
"min": 120,
|
|
783
|
+
"max": 2160,
|
|
784
|
+
"placeholder": "original",
|
|
785
|
+
"description": "Resize frame height (optional)",
|
|
786
|
+
"required": False,
|
|
787
|
+
},
|
|
788
|
+
],
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
# Standalone test code
|
|
793
|
+
def _test_folder_capture():
|
|
794
|
+
import sys
|
|
795
|
+
|
|
796
|
+
# Use command line argument for folder path, or default to a test folder
|
|
797
|
+
if len(sys.argv) > 1:
|
|
798
|
+
folder = sys.argv[1]
|
|
799
|
+
else:
|
|
800
|
+
# Try some common test folders
|
|
801
|
+
test_folders = [
|
|
802
|
+
"C:/Users/olive/OneDrive/Desktop/image_folder",
|
|
803
|
+
"./media/image_seq", # Relative to project root
|
|
804
|
+
"../media/image_seq", # If running from frame_source directory
|
|
805
|
+
"../../media/image_seq", # If running from deeper nested folder
|
|
806
|
+
"./test_images",
|
|
807
|
+
"../test_images",
|
|
808
|
+
]
|
|
809
|
+
|
|
810
|
+
folder = None
|
|
811
|
+
for test_folder in test_folders:
|
|
812
|
+
if os.path.exists(test_folder) and os.path.isdir(test_folder):
|
|
813
|
+
folder = test_folder
|
|
814
|
+
break
|
|
815
|
+
|
|
816
|
+
if folder is None:
|
|
817
|
+
print("No test folder found. Usage: python folder_capture.py <folder_path>")
|
|
818
|
+
print(f"Tried folders: {test_folders}")
|
|
819
|
+
sys.exit(1)
|
|
820
|
+
|
|
821
|
+
print(f"Testing FolderCapture with folder: {folder}")
|
|
822
|
+
|
|
823
|
+
cap = FolderCapture(
|
|
824
|
+
folder, sort_by="date", fps=10, real_time=True, loop=True, watch_folder=True
|
|
825
|
+
)
|
|
826
|
+
if cap.connect():
|
|
827
|
+
print(f"Connected successfully! Found {len(cap.get_file_list())} image files.")
|
|
828
|
+
|
|
829
|
+
# Show folder watching status
|
|
830
|
+
watch_status = cap.get_folder_watching_status()
|
|
831
|
+
print(f"Folder watching status: {watch_status}")
|
|
832
|
+
|
|
833
|
+
# cap.start()
|
|
834
|
+
cv2.namedWindow("FolderCapture", cv2.WINDOW_NORMAL)
|
|
835
|
+
frame_count = 0
|
|
836
|
+
last_validation_time = time.time()
|
|
837
|
+
last_file_count = len(cap.get_file_list())
|
|
838
|
+
|
|
839
|
+
# Check for missing files initially
|
|
840
|
+
missing_files = cap.get_missing_files()
|
|
841
|
+
if missing_files:
|
|
842
|
+
print(f"Warning: {len(missing_files)} files are missing from the start")
|
|
843
|
+
|
|
844
|
+
print(
|
|
845
|
+
"Press 'q' to quit, 's' to skip to next image, 'r' to refresh file list, "
|
|
846
|
+
"'w' to toggle folder watching"
|
|
847
|
+
)
|
|
848
|
+
|
|
849
|
+
while cap.is_connected:
|
|
850
|
+
ret, frame = cap.read()
|
|
851
|
+
if ret:
|
|
852
|
+
frame_count += 1
|
|
853
|
+
if frame is not None:
|
|
854
|
+
# Display current file information
|
|
855
|
+
current_file = cap.get_current_file_path()
|
|
856
|
+
file_info = cap.get_current_file_info()
|
|
857
|
+
|
|
858
|
+
if current_file:
|
|
859
|
+
print(f"Frame {frame_count}: {os.path.basename(current_file)}")
|
|
860
|
+
if file_info:
|
|
861
|
+
print(f" Index: {file_info['index']}/{file_info['total_files'] - 1}")
|
|
862
|
+
print(f" Size: {file_info['size_bytes']} bytes")
|
|
863
|
+
|
|
864
|
+
# Check if file count changed (new files detected)
|
|
865
|
+
current_file_count = len(cap.get_file_list())
|
|
866
|
+
if current_file_count != last_file_count:
|
|
867
|
+
print(f"File count changed: {last_file_count} -> {current_file_count}")
|
|
868
|
+
last_file_count = current_file_count
|
|
869
|
+
|
|
870
|
+
# Periodically validate file list (every 30 seconds)
|
|
871
|
+
current_time = time.time()
|
|
872
|
+
if current_time - last_validation_time > 30:
|
|
873
|
+
removed_count = cap.validate_file_list()
|
|
874
|
+
if removed_count > 0:
|
|
875
|
+
print(f"Validation: Removed {removed_count} missing files")
|
|
876
|
+
last_validation_time = current_time
|
|
877
|
+
|
|
878
|
+
cv2.imshow("FolderCapture", frame)
|
|
879
|
+
|
|
880
|
+
# Handle key presses
|
|
881
|
+
key = cv2.waitKey(1) & 0xFF
|
|
882
|
+
if key == ord("q"):
|
|
883
|
+
print("Quitting...")
|
|
884
|
+
break
|
|
885
|
+
elif key == ord("s"):
|
|
886
|
+
print("Skipping to next image...")
|
|
887
|
+
# Force skip by incrementing index
|
|
888
|
+
current_idx = cap.get_current_index()
|
|
889
|
+
cap.set_current_index(current_idx + 1)
|
|
890
|
+
elif key == ord("r"):
|
|
891
|
+
print("Refreshing file list...")
|
|
892
|
+
new_count = cap.force_refresh()
|
|
893
|
+
print(f"Refreshed: now have {new_count} files")
|
|
894
|
+
elif key == ord("w"):
|
|
895
|
+
# Toggle folder watching
|
|
896
|
+
current_status = cap.is_folder_watching_enabled()
|
|
897
|
+
cap.enable_folder_watching(not current_status)
|
|
898
|
+
new_status = cap.is_folder_watching_enabled()
|
|
899
|
+
print(f"Folder watching {'enabled' if new_status else 'disabled'}")
|
|
900
|
+
|
|
901
|
+
else:
|
|
902
|
+
print("No more frames available")
|
|
903
|
+
if not cap.loop:
|
|
904
|
+
print("Reached end of image sequence (loop=False)")
|
|
905
|
+
break
|
|
906
|
+
|
|
907
|
+
# cap.stop()
|
|
908
|
+
cap.disconnect()
|
|
909
|
+
cv2.destroyAllWindows()
|
|
910
|
+
print("FolderCapture test completed.")
|
|
911
|
+
else:
|
|
912
|
+
print(f"Failed to connect to folder: {folder}")
|
|
913
|
+
print(
|
|
914
|
+
"Make sure the folder exists and contains image files "
|
|
915
|
+
"(.jpg, .jpeg, .png, .bmp, .tiff, .tif)"
|
|
916
|
+
)
|
|
917
|
+
|
|
918
|
+
|
|
919
|
+
if __name__ == "__main__":
|
|
920
|
+
_test_folder_capture()
|