From 9b36ac889f9c4a7012911a5d612b9c4ee59d0a78 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Wed, 21 Jan 2026 21:26:50 -0500 Subject: [PATCH 1/2] [app] Allow processing multiple videos without concatenation #258 --- dvr_scan/app/application.py | 79 +++++++++---- dvr_scan/app/scan_window.py | 225 ++++++++++++++++++++++++++++-------- 2 files changed, 233 insertions(+), 71 deletions(-) diff --git a/dvr_scan/app/application.py b/dvr_scan/app/application.py index 3d16089..49cd637 100644 --- a/dvr_scan/app/application.py +++ b/dvr_scan/app/application.py @@ -147,8 +147,7 @@ def __init__(self, root: tk.Widget): variable=self._concatenate, onvalue=True, offvalue=False, - command=self._on_set_time, - state=tk.DISABLED, # TODO: Enable when implemented. + command=self._on_concatenate_changed, ).grid(row=2, column=4, padx=PADDING, sticky=EXPAND_HORIZONTAL) # TODO: Need to prevent start_time >= end_time. @@ -304,15 +303,26 @@ def _on_move_down(self): else: break + def _on_concatenate_changed(self): + """Handle change in concatenation mode.""" + if not self.concatenate: + # When not concatenating, disable and uncheck "Set Time" + self._set_time.set(False) + self._on_set_time() + def _on_set_time(self): - # TODO: When disabled, set start time 0 and end time duration of video. - state = tk.NORMAL if self._set_time.get() else tk.DISABLED - self._set_time_button["state"] = tk.NORMAL if self.concatenate else tk.DISABLED + # When not concatenating, set time is not available + can_set_time = self.concatenate + self._set_time_button["state"] = tk.NORMAL if can_set_time else tk.DISABLED + if not can_set_time: + self._set_time.set(False) + + state = tk.NORMAL if self._set_time.get() and can_set_time else tk.DISABLED self._start_time_label["state"] = state self._start_time["state"] = state self._end_time_label["state"] = state self._end_time["state"] = state - if state == tk.NORMAL and self.concatenate: + if state == tk.NORMAL: self._start_time_label.grid(row=3, column=1, sticky=EXPAND_HORIZONTAL) self._start_time.grid(row=3, column=2, padx=PADDING, sticky=EXPAND_HORIZONTAL) self._end_time.grid(row=3, column=4, padx=PADDING, sticky=EXPAND_HORIZONTAL) @@ -1529,7 +1539,12 @@ def _start_scan(self): if not settings: return - logger.debug(f"ui settings:\n{settings.app_settings}") + if len(settings) > 1: + logger.debug(f"ui settings (multi-video mode, {len(settings)} videos)") + for i, s in enumerate(settings): + logger.debug(f" video {i + 1}: {s.get('input')}") + else: + logger.debug(f"ui settings:\n{settings[0].app_settings}") def on_closed(): logger.debug("scan window closed, restoring focus") @@ -1659,8 +1674,14 @@ def _get_config_settings(self) -> ScanSettings: settings = self._scan_area.save(settings) return settings - def _get_settings(self) -> ty.Optional[ScanSettings]: - """Get current UI state with all options to run a scan.""" + def _get_settings(self) -> ty.Optional[ty.List[ScanSettings]]: + """Get current UI state with all options to run a scan. + + Returns: + A list of ScanSettings objects (one per video when not concatenating, + or a single-element list when concatenating). + None if there are no videos or the user cancelled. + """ settings = copy.deepcopy(self._settings) settings = self._input_area.update(settings) if not settings: @@ -1674,7 +1695,7 @@ def _get_settings(self) -> ty.Optional[ScanSettings]: OutputMode.SCAN_ONLY if settings.get("scan-only") else settings.get("output-mode") ) # Check if we are going to create any output files. We will create files as long as we're - # not in scan-only mode, or if we + # not in scan-only mode, or if we need mask output if not settings.get("output-dir") and ( output_mode != OutputMode.SCAN_ONLY or settings.get("mask-output") ): @@ -1687,15 +1708,29 @@ def _get_settings(self) -> ty.Optional[ScanSettings]: return None settings.set("output-dir", output_dir) - video_name = Path(settings.get("input")[0]).stem - if self._output_area.combine: - settings.set("output", f"{video_name}-events.avi") - - if settings.get("mask-output"): - settings.set("mask-output", f"{video_name}-mask.avi") - - if not self._input_area.concatenate and len(settings.get_arg("input")) > 1: - logger.error("ERROR - TODO: Handle non-concatenated inputs.") - return None - - return settings + videos = settings.get("input") + concatenate = self._input_area.concatenate + mask_output_enabled = settings.get("mask-output") + combine_events = self._output_area.combine + + if concatenate: + # Single scan with all videos concatenated + video_name = Path(videos[0]).stem + if combine_events: + settings.set("output", f"{video_name}-events.avi") + if mask_output_enabled: + settings.set("mask-output", f"{video_name}-mask.avi") + return [settings] + else: + # Separate scan for each video + settings_list = [] + for video_path in videos: + video_settings = copy.deepcopy(settings) + video_settings.set("input", [video_path]) + video_name = Path(video_path).stem + if combine_events: + video_settings.set("output", f"{video_name}-events.avi") + if mask_output_enabled: + video_settings.set("mask-output", f"{video_name}-mask.avi") + settings_list.append(video_settings) + return settings_list diff --git a/dvr_scan/app/scan_window.py b/dvr_scan/app/scan_window.py index ed52634..36ecf43 100644 --- a/dvr_scan/app/scan_window.py +++ b/dvr_scan/app/scan_window.py @@ -17,6 +17,7 @@ import traceback import typing as ty from logging import getLogger +from pathlib import Path from scenedetect import FrameTimecode from tqdm import tqdm @@ -34,19 +35,33 @@ class ScanWindow: def __init__( - self, root: tk.Tk, settings: ScanSettings, on_destroyed: ty.Callable[[], None], padding: int + self, + root: tk.Tk, + settings: ty.List[ScanSettings], + on_destroyed: ty.Callable[[], None], + padding: int, ): self._root = tk.Toplevel(master=root) self._root.withdraw() self._root.title(TITLE) self._root.resizable(True, True) - self._scanner = init_scanner(settings) + + self._settings_list = settings + self._current_video_index = 0 + self._total_videos = len(settings) + self._multi_video_mode = self._total_videos > 1 + + # Initialize scanner for the first video + self._scanner = init_scanner(self._settings_list[0]) self._scanner.set_callbacks( scan_started=self._on_scan_started, processed_frame=self._on_processed_frame, ) + + # Determine if we should open folder on completion + first_settings = self._settings_list[0] self._open_on_completion = ( - settings.get("output-dir") if settings.get("open-output-dir") else None + first_settings.get("output-dir") if first_settings.get("open-output-dir") else None ) self._root.bind("<>", self.stop) @@ -56,42 +71,86 @@ def __init__( self._scan_thread = threading.Thread(target=self._do_scan) self._on_destroyed = on_destroyed + # Layout setup + width = self._root.winfo_reqwidth() + self._root.columnconfigure(0, weight=1, minsize=width / 2) + self._root.columnconfigure(1, weight=1, minsize=width / 2) + + current_row = 0 + + # Multi-video progress (only shown in multi-video mode) + if self._multi_video_mode: + self._video_progress_label = tk.Label( + self._root, text="Video 1 of " + str(self._total_videos) + ) + self._video_progress_label.grid( + row=current_row, column=0, columnspan=2, sticky=tk.NSEW, pady=(padding, 0) + ) + current_row += 1 + + self._video_progress = tk.IntVar(self._root, value=0) + self._video_progress_bar = ttk.Progressbar( + self._root, variable=self._video_progress, maximum=self._total_videos + ) + self._video_progress_bar.grid( + sticky=tk.NSEW, + row=current_row, + columnspan=2, + pady=(padding // 2, padding), + padx=padding, + ) + current_row += 1 + + # Current video name label + video_name = Path(self._settings_list[0].get("input")[0]).name + self._current_video_label = tk.Label(self._root, text=f"Current: {video_name}") + self._current_video_label.grid(row=current_row, column=0, columnspan=2, sticky=tk.NSEW) + current_row += 1 + else: + self._video_progress_label = None + self._video_progress_bar = None + self._video_progress = None + self._current_video_label = None + + # Events found label + self._events_label = tk.Label(self._root) + self._events_label.grid( + row=current_row, column=0, columnspan=2, sticky=tk.NSEW, pady=(padding, 0) + ) + current_row += 1 + + # Frame progress bar self._progress = tk.IntVar(self._root, value=0) self._progress_bar = ttk.Progressbar(self._root, variable=self._progress, maximum=10) - self._elapsed_label = tk.Label(self._root) - self._remaining_label = tk.Label(self._root) - self._stop_button = tk.Button(self._root, text="Stop", command=self.prompt_stop) - self._close_button = tk.Button( - self._root, text="Close", command=self._destroy, state=tk.DISABLED + self._progress_bar.grid( + sticky=tk.NSEW, row=current_row, columnspan=2, pady=padding, padx=padding ) - self._events_label = tk.Label(self._root) + self._progress_bar_row = current_row + current_row += 1 + + # Time remaining label + self._remaining_label = tk.Label(self._root) + self._remaining_label.grid(row=current_row, column=0, columnspan=2, sticky=tk.NSEW) + current_row += 1 + + # Empty row for spacing + self._root.rowconfigure(current_row, weight=32) + current_row += 1 + + # Stats labels self._processed_label = tk.Label(self._root) + self._processed_label.grid(row=current_row, column=0, sticky=tk.NW, padx=padding) self._speed_label = tk.Label(self._root) - self._total_label = tk.Label(self._root) + self._speed_label.grid(row=current_row, column=1, sticky=tk.NE, padx=padding) + current_row += 1 - # Layout - width = self._root.winfo_reqwidth() - self._root.columnconfigure(0, weight=1, minsize=width / 2) - self._root.columnconfigure(1, weight=1, minsize=width / 2) - self._root.rowconfigure(0, weight=1) - self._root.rowconfigure(1, weight=1) - self._root.rowconfigure(2, weight=1) - self._root.rowconfigure(3, weight=32) - self._root.rowconfigure(4, weight=1) - self._root.rowconfigure(5, weight=1) - self._root.rowconfigure(6, weight=1) - self._root.rowconfigure(7, weight=2) - self._root.minsize( - width=2 * self._root.winfo_reqwidth(), height=self._root.winfo_reqheight() - ) + self._total_label = tk.Label(self._root) + self._total_label.grid(row=current_row, column=0, sticky=tk.NW, padx=padding) + self._elapsed_label = tk.Label(self._root) + self._elapsed_label.grid(row=current_row, column=1, sticky=tk.NE, padx=padding) + current_row += 1 - self._events_label.grid(row=0, column=0, columnspan=2, sticky=tk.NSEW, pady=(padding, 0)) - self._progress_bar.grid(sticky=tk.NSEW, row=1, columnspan=2, pady=padding, padx=padding) - self._elapsed_label.grid(row=5, column=1, sticky=tk.NE, padx=padding) - self._remaining_label.grid(row=2, column=0, columnspan=2, sticky=tk.NSEW) - self._processed_label.grid(row=4, column=0, sticky=tk.NW, padx=padding) - self._total_label.grid(row=5, column=0, sticky=tk.NW, padx=padding) - self._speed_label.grid(row=4, column=1, sticky=tk.NE, padx=padding) + # Buttons frame frame = tk.Frame(self._root) frame.rowconfigure(0, weight=1) frame.columnconfigure(0, weight=1) @@ -102,20 +161,29 @@ def __init__( ) self._stop_button.grid(row=0, column=0, pady=padding, sticky=tk.NSEW, padx=padding) self._close_button.grid(row=0, column=1, pady=padding, sticky=tk.NSEW, padx=padding) - frame.grid(row=7, column=0, columnspan=2, sticky=tk.NSEW) + frame.grid(row=current_row, column=0, columnspan=2, sticky=tk.NSEW) + self._root.minsize( + width=2 * self._root.winfo_reqwidth(), height=self._root.winfo_reqheight() + ) + + # State variables self._scan_started = threading.Event() self._scan_finished = threading.Event() + self._video_finished = threading.Event() self._scan_exception = None self._start_time = 0.0 self._last_stats_update_ns = 0 self._expected_num_frames = 0 self._num_events = 0 + self._total_events = 0 # Aggregate across all videos self._frames_processed = 0 + self._total_frames_processed = 0 # Aggregate across all videos self._elapsed = "00:00" self._remaining = "N/A" self._rate = "N/A" self._padding = padding + self._stopped = False def _update(self): if self._scan_finished.is_set(): @@ -132,12 +200,23 @@ def _update(self): return else: logger.debug("scan complete") + + # Update final state + if self._multi_video_mode: + self._video_progress.set(self._total_videos) + self._video_progress_label["text"] = ( + f"Completed {self._total_videos} of {self._total_videos} videos" + ) + self._events_label["text"] = f"Total Events Found: {self._total_events}" + else: + self._events_label["text"] = f"Events Found: {self._num_events}" + self._progress.set(self._expected_num_frames) self._elapsed_label["text"] = f"Elapsed: {self._elapsed}" self._remaining_label["text"] = "\n" self._stop_button["state"] = tk.DISABLED self._close_button["state"] = tk.NORMAL - self._processed_label["text"] = f"Processed: {self._expected_num_frames} frames" + self._processed_label["text"] = f"Processed: {self._total_frames_processed} frames" self._speed_label["text"] = f"Rate: {self._rate} FPS" return False @@ -149,7 +228,7 @@ def _update(self): ) self._progress_bar.grid( sticky=tk.NSEW, - row=1, + row=self._progress_bar_row, columnspan=2, padx=self._padding, pady=self._padding, @@ -158,7 +237,18 @@ def _update(self): # Frames processed is updated from the worker thread, but we don't care about stale values. self._progress.set(self._frames_processed) - self._events_label["text"] = f"Events Found: {self._num_events}" + + if self._multi_video_mode: + self._video_progress.set(self._current_video_index) + self._video_progress_label["text"] = ( + f"Video {self._current_video_index + 1} of {self._total_videos}" + ) + self._events_label["text"] = ( + f"Events Found: {self._num_events} (Total: {self._total_events})" + ) + else: + self._events_label["text"] = f"Events Found: {self._num_events}" + self._elapsed_label["text"] = f"Elapsed: {self._elapsed}" self._remaining_label["text"] = f"Time Remaining:\n{self._remaining}" self._processed_label["text"] = f"Processed: {self._frames_processed:4} frames" @@ -181,7 +271,7 @@ def _destroy(self): logger.debug("root destroyed") def prompt_stop(self): - if self._scanner.is_stopped(): + if self._scanner.is_stopped() and self._scan_finished.is_set(): return if messagebox.askyesno( title="Stop scan?", @@ -192,6 +282,7 @@ def prompt_stop(self): self._destroy() def stop(self): + self._stopped = True if not self._scanner.is_stopped(): logger.debug("stopping scan thread") self._scanner.stop() @@ -231,28 +322,64 @@ def _on_processed_frame(self, progress_bar: tqdm, num_events: int): (self._elapsed, self._remaining, self._rate, _unit) = values def _do_scan(self): - # We'll handle any errors below in the main Tkinter thread. + """Main scanning loop that handles both single and multi-video modes.""" + overall_start_time = time.time() + total_frames = 0 + try: - result = self._scanner.scan() - self._frames_processed = result.num_frames - except Exception as ex: # noqa: E722 + for i, settings in enumerate(self._settings_list): + if self._stopped: + break + + self._current_video_index = i + + # Update video label in multi-video mode + if self._multi_video_mode and self._current_video_label: + video_name = Path(settings.get("input")[0]).name + # Schedule UI update on main thread + self._root.after( + 0, + lambda n=video_name: self._current_video_label.config(text=f"Current: {n}"), + ) + + # Create new scanner for each video (except the first which is already created) + if i > 0: + self._scanner = init_scanner(settings) + self._scanner.set_callbacks( + scan_started=self._on_scan_started, + processed_frame=self._on_processed_frame, + ) + # Reset per-video state + self._frames_processed = 0 + self._num_events = 0 + self._progress.set(0) + + logger.info( + f"Scanning video {i + 1} of {self._total_videos}: {settings.get('input')[0]}" + ) + + result = self._scanner.scan() + self._frames_processed = result.num_frames + total_frames += result.num_frames + self._total_events += len(result.event_list) + self._total_frames_processed = total_frames + + logger.info(f"Video {i + 1} complete: {len(result.event_list)} events found") + + except Exception as ex: self._scan_exception = ex finally: self._scan_finished.set() - elapsed = time.time() - self._start_time + + elapsed = time.time() - overall_start_time self._elapsed = ( FrameTimecode(elapsed, 1000.0) .get_timecode(precision=0, use_rounding=True) .removeprefix("00:") ) - # TODO: This rate will always be a tiny bit slower than the one shown in stdout since we - # use different timers. This can be fixed if we add a callback to the MotionScanner that - # we can use to access the progress bar the scanner created. - self._rate = ( - "%.2f" % (float(self._frames_processed) / elapsed) if self._frames_processed else "N/A" - ) + self._rate = "%.2f" % (float(total_frames) / elapsed) if total_frames else "N/A" # Open the output folder on a successful scan. On error, or if the user stopped the scan, # we don't open the window. - if self._open_on_completion and not self._scanner.is_stopped() and not self._scan_exception: + if self._open_on_completion and not self._stopped and not self._scan_exception: logger.debug("scan complete, opening output folder") open_path(self._open_on_completion) From 7c3d0a40d4ee6480532a3eea905c32c631ab713a Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Wed, 21 Jan 2026 21:26:50 -0500 Subject: [PATCH 2/2] [app] Fix thread safety issues when stopping scan Unlike previously, we're now changing the scanner object in the ScanWindow from the scan thread, which we need to synchronize with the main UI thread. Note that some properties are still accessed without locks, but these are only informational (e.g. the name of the video being processed). --- dvr_scan/app/scan_window.py | 76 +++++++++++++++++-------------------- 1 file changed, 35 insertions(+), 41 deletions(-) diff --git a/dvr_scan/app/scan_window.py b/dvr_scan/app/scan_window.py index 36ecf43..eaf16c7 100644 --- a/dvr_scan/app/scan_window.py +++ b/dvr_scan/app/scan_window.py @@ -51,12 +51,9 @@ def __init__( self._total_videos = len(settings) self._multi_video_mode = self._total_videos > 1 - # Initialize scanner for the first video - self._scanner = init_scanner(self._settings_list[0]) - self._scanner.set_callbacks( - scan_started=self._on_scan_started, - processed_frame=self._on_processed_frame, - ) + # Scanner is created in _do_scan for each video + self._scanner = None + self._scanner_lock = threading.Lock() # Determine if we should open folder on completion first_settings = self._settings_list[0] @@ -102,8 +99,8 @@ def __init__( current_row += 1 # Current video name label - video_name = Path(self._settings_list[0].get("input")[0]).name - self._current_video_label = tk.Label(self._root, text=f"Current: {video_name}") + self._current_video_name = Path(self._settings_list[0].get("input")[0]).name + self._current_video_label = tk.Label(self._root, text=f"{self._current_video_name}") self._current_video_label.grid(row=current_row, column=0, columnspan=2, sticky=tk.NSEW) current_row += 1 else: @@ -192,8 +189,7 @@ def _update(self): logger.critical(f"error during scan:\n{formatted_exception}") messagebox.showerror( "Scan Error", - "Error during scanning. See log messages for more info." - f"\nSummary: {self._scan_exception}", + f"Error during scan: {self._scan_exception}\n\nSee log messages for more info.", parent=self._root, ) self._destroy() @@ -204,12 +200,11 @@ def _update(self): # Update final state if self._multi_video_mode: self._video_progress.set(self._total_videos) - self._video_progress_label["text"] = ( - f"Completed {self._total_videos} of {self._total_videos} videos" - ) - self._events_label["text"] = f"Total Events Found: {self._total_events}" + self._video_progress_label["text"] = f"Scanned {self._total_videos} Videos" + self._events_label["text"] = f"Total Events: {self._total_events}" + self._current_video_label.config(text=f"{self._current_video_name}") else: - self._events_label["text"] = f"Events Found: {self._num_events}" + self._events_label["text"] = f"Total Events: {self._num_events}" self._progress.set(self._expected_num_frames) self._elapsed_label["text"] = f"Elapsed: {self._elapsed}" @@ -243,11 +238,9 @@ def _update(self): self._video_progress_label["text"] = ( f"Video {self._current_video_index + 1} of {self._total_videos}" ) - self._events_label["text"] = ( - f"Events Found: {self._num_events} (Total: {self._total_events})" - ) + self._events_label["text"] = f"Events: {self._num_events} (Total: {self._total_events})" else: - self._events_label["text"] = f"Events Found: {self._num_events}" + self._events_label["text"] = f"Events: {self._num_events}" self._elapsed_label["text"] = f"Elapsed: {self._elapsed}" self._remaining_label["text"] = f"Time Remaining:\n{self._remaining}" @@ -271,8 +264,13 @@ def _destroy(self): logger.debug("root destroyed") def prompt_stop(self): - if self._scanner.is_stopped() and self._scan_finished.is_set(): - return + with self._scanner_lock: + if ( + self._scanner is not None + and self._scanner.is_stopped() + and self._scan_finished.is_set() + ): + return if messagebox.askyesno( title="Stop scan?", message="Are you sure you want to stop the current scan?", @@ -282,10 +280,12 @@ def prompt_stop(self): self._destroy() def stop(self): - self._stopped = True - if not self._scanner.is_stopped(): - logger.debug("stopping scan thread") - self._scanner.stop() + with self._scanner_lock: + self._stopped = True + if self._scanner is not None and not self._scanner.is_stopped(): + logger.debug("stopping scan thread") + self._scanner.stop() + if self._scan_thread.is_alive(): self._scan_thread.join() def show(self): @@ -328,31 +328,25 @@ def _do_scan(self): try: for i, settings in enumerate(self._settings_list): - if self._stopped: - break - self._current_video_index = i # Update video label in multi-video mode if self._multi_video_mode and self._current_video_label: - video_name = Path(settings.get("input")[0]).name - # Schedule UI update on main thread - self._root.after( - 0, - lambda n=video_name: self._current_video_label.config(text=f"Current: {n}"), - ) + self._current_video_name = Path(settings.get("input")[0]).name - # Create new scanner for each video (except the first which is already created) - if i > 0: + # Create scanner for this video + with self._scanner_lock: + if self._stopped: + break self._scanner = init_scanner(settings) self._scanner.set_callbacks( scan_started=self._on_scan_started, processed_frame=self._on_processed_frame, ) - # Reset per-video state - self._frames_processed = 0 - self._num_events = 0 - self._progress.set(0) + # Reset per-video state + self._frames_processed = 0 + self._num_events = 0 + self._progress.set(0) logger.info( f"Scanning video {i + 1} of {self._total_videos}: {settings.get('input')[0]}" @@ -364,7 +358,7 @@ def _do_scan(self): self._total_events += len(result.event_list) self._total_frames_processed = total_frames - logger.info(f"Video {i + 1} complete: {len(result.event_list)} events found") + logger.info(f"Finished scanning video: {len(result.event_list)} events found") except Exception as ex: self._scan_exception = ex