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.
Files changed (37) hide show
  1. frame_processors/__init__.py +37 -0
  2. frame_source/__init__.py +50 -0
  3. framesource/__init__.py +93 -0
  4. framesource/_msmf_config.py +26 -0
  5. framesource/_version.py +24 -0
  6. framesource/discovery.py +109 -0
  7. framesource/errors.py +82 -0
  8. framesource/factory.py +290 -0
  9. framesource/processors/__init__.py +34 -0
  10. framesource/processors/equirectangular360_processor.py +577 -0
  11. framesource/processors/fisheye2equirectangular_processor.py +328 -0
  12. framesource/processors/frame_processor.py +30 -0
  13. framesource/processors/hyperspectral_processor.py +23 -0
  14. framesource/processors/realsense_depth_processor.py +90 -0
  15. framesource/py.typed +0 -0
  16. framesource/sources/__init__.py +40 -0
  17. framesource/sources/audiospectrogram_capture.py +1068 -0
  18. framesource/sources/basler_capture.py +477 -0
  19. framesource/sources/folder_capture.py +920 -0
  20. framesource/sources/genicam_capture.py +681 -0
  21. framesource/sources/huateng_capture.py +245 -0
  22. framesource/sources/ipcamera_capture.py +254 -0
  23. framesource/sources/mvsdk.py +2454 -0
  24. framesource/sources/realsense_capture.py +565 -0
  25. framesource/sources/screen_capture.py +800 -0
  26. framesource/sources/video_capture_base.py +560 -0
  27. framesource/sources/video_file_capture.py +259 -0
  28. framesource/sources/webcam_capture.py +511 -0
  29. framesource/sources/ximea_capture.py +299 -0
  30. framesource/threading_utils.py +790 -0
  31. framesource-0.3.0.dist-info/METADATA +787 -0
  32. framesource-0.3.0.dist-info/RECORD +37 -0
  33. framesource-0.3.0.dist-info/WHEEL +5 -0
  34. framesource-0.3.0.dist-info/licenses/LICENSE +21 -0
  35. framesource-0.3.0.dist-info/scm_file_list.json +276 -0
  36. framesource-0.3.0.dist-info/scm_version.json +8 -0
  37. 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()