diff --git a/docs/tutorial.md b/docs/tutorial.md index b9040ddf..eb9b669b 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -124,12 +124,6 @@ If this option is enabled, when the last split meets the threshold and splits, A If this option is disabled, when the last split meets the threshold and splits, AutoSplit will stop running comparisons. This option does not loop single, specific images. See the Custom Split Image Settings section above for this feature. -#### Start also Resets - -If this option is enabled, a "Start" command (ie: from the Start Image) will also send the "Reset" command. This is useful if you want to automatically restart your timer using the Start Image. Since AutoSplit won't be running and won't be checking for the Reset Image. - -Having the reset image check be active at all time would be a better, more organic solution in the future. But that is dependent on migrating to an observer pattern () and being able to reload all images. - #### Enable auto Reset Image This option is mainly meant to be toggled with the `Toggle auto Reset Image` hotkey. You can enable it to temporarily disable the Reset Image if you make a mistake in your run that would cause the Reset Image to trigger. Like exiting back to the game's menu (aka Save&Quit). diff --git a/res/design.ui b/res/design.ui index 4f339507..80d7fda1 100644 --- a/res/design.ui +++ b/res/design.ui @@ -796,7 +796,7 @@ - + 457 @@ -809,7 +809,7 @@ Qt::NoFocus - Reload Start Image + Reload Images diff --git a/res/settings.ui b/res/settings.ui index 3ae4d2f3..f49deecb 100644 --- a/res/settings.ui +++ b/res/settings.ui @@ -579,25 +579,6 @@ It is highly recommended to NOT use pHash if you use masked images, or it'll be Default Delay Time (ms): - - - - 10 - 150 - 261 - 24 - - - - Start also Resets - - - false - - - false - - custom_image_settings_info_label default_delay_time_spinbox enable_auto_reset_image_checkbox @@ -610,7 +591,6 @@ It is highly recommended to NOT use pHash if you use masked images, or it'll be default_pause_time_label default_delay_time_label readme_link_button - start_also_resets_checkbox diff --git a/src/AutoSplit.py b/src/AutoSplit.py index 3e315ac3..bfb36a43 100644 --- a/src/AutoSplit.py +++ b/src/AutoSplit.py @@ -43,7 +43,7 @@ import error_messages import user_profile from AutoControlledThread import AutoControlledThread -from AutoSplitImage import START_KEYWORD, AutoSplitImage, ImageType +from AutoSplitImage import AutoSplitImage, ImageType from capture_method import CaptureMethodBase, CaptureMethodEnum from gen import about, design, settings, update_checker from hotkeys import ( @@ -77,6 +77,7 @@ BGRA_CHANNEL_COUNT, FROZEN, ONE_SECOND, + QTIMER_FPS_LIMIT, RUNNING_WAYLAND, auto_split_directory, decimal, @@ -86,8 +87,6 @@ open_file, ) -CHECK_FPS_ITERATIONS = 10 - if sys.platform == "win32": from win32comext.shell import shell as shell32 @@ -108,16 +107,10 @@ class AutoSplit(QMainWindow, design.Ui_MainWindow): screenshot_signal = QtCore.Signal() after_setting_hotkey_signal = QtCore.Signal() update_checker_widget_signal = QtCore.Signal(str, bool) - reload_start_image_signal = QtCore.Signal(bool, bool) + reload_images_signal = QtCore.Signal(bool) # Use this signal when trying to show an error from outside the main thread show_error_signal = QtCore.Signal(FunctionType) - # Timers - timer_live_image = QtCore.QTimer() - timer_live_image.setTimerType(QtCore.Qt.TimerType.PreciseTimer) - timer_start_image = QtCore.QTimer() - timer_start_image.setTimerType(QtCore.Qt.TimerType.PreciseTimer) - # Widgets AboutWidget: about.Ui_AboutAutoSplitWidget | None = None UpdateCheckerWidget: update_checker.Ui_UpdateChecker | None = None @@ -149,6 +142,7 @@ def __init__(self): # noqa: PLR0915 self.waiting_for_split_delay = False self.split_below_threshold = False self.run_start_time = 0.0 + self.last_reset_time = 0.0 self.start_image: AutoSplitImage | None = None self.reset_image: AutoSplitImage | None = None self.split_images: list[AutoSplitImage] = [] @@ -216,8 +210,8 @@ def _show_error_signal_slot(error_message_box: Callable[..., object]): ) self.align_region_button.clicked.connect(lambda: align_region(self)) self.select_window_button.clicked.connect(lambda: select_window(self)) - self.reload_start_image_button.clicked.connect( - lambda: self.__reload_start_image(started_by_button=True) + self.reload_images_button.clicked.connect( + lambda: self.__reload_images(started_by_button=True) ) self.action_check_for_updates_on_open.changed.connect( lambda: user_profile.set_check_for_updates_on_open( @@ -240,21 +234,14 @@ def _update_checker_widget_signal_slot(latest_version: str, check_on_open: bool) return open_update_checker(self, latest_version, check_on_open=check_on_open) self.update_checker_widget_signal.connect(_update_checker_widget_signal_slot) - self.reload_start_image_signal.connect(self.__reload_start_image) + self.reload_images_signal.connect(self.__reload_images) self.reset_signal.connect(self.reset) self.skip_split_signal.connect(self.skip_split) self.undo_split_signal.connect(self.undo_split) self.pause_signal.connect(self.pause) self.screenshot_signal.connect(self.__take_screenshot) - - # live image checkbox - self.timer_live_image.timeout.connect( - lambda: self.__update_live_image_details(None, called_from_timer=True) - ) - self.timer_live_image.start(int(ONE_SECOND / self.settings_dict["fps_limit"])) - - # Automatic timer start - self.timer_start_image.timeout.connect(self.__compare_capture_for_auto_start) + # Before loading settings since the default value of "Live Capture Region" is True + self.capture_method.subscribe_to_new_frame(self.update_live_image_details) self.show() @@ -287,21 +274,9 @@ def __browse(self): # set the split image folder line to the directory text self.settings_dict["split_image_directory"] = new_split_image_directory self.split_image_folder_input.setText(f"{new_split_image_directory}/") - self.reload_start_image_signal.emit(False, True) - - def __update_live_image_details( - self, - capture: MatLike | None, - *, - called_from_timer: bool = False, - ): - # HACK: Since this is also called in __get_capture_for_comparison, - # we don't need to update anything if the app is running - if called_from_timer: - if self.is_running or self.start_image: - return - capture = self.capture_method.get_frame() + self.reload_images_signal.emit(False) + def update_live_image_details(self, capture: MatLike | None): # Update title from target window or Capture Device name capture_region_window_label = ( self.settings_dict["capture_device_name"] @@ -310,72 +285,75 @@ def __update_live_image_details( ) self.capture_region_window_label.setText(capture_region_window_label) - # Simply clear if "live capture region" setting is off - if not (self.settings_dict["live_capture_region"] and capture_region_window_label): - self.live_image.clear() - # Set live image in UI - else: - set_preview_image(self.live_image, capture) + set_preview_image(self.live_image, capture) - def __reload_start_image(self, *, started_by_button: bool = False, wait_for_delay: bool = True): + def __reload_images(self, *, started_by_button: bool = False): """ Not thread safe (if triggered by LiveSplit for example). - Use `reload_start_image_signal.emit` instead. + Use `reload_images_signal.emit` instead. - 1. Stops the automated start check and clear the current Split Image. - 2. Reloads the Start Image from disk and validate. + 1. Stops the automated comparison checks and clear the current Split Image. + 2. Re-initializes all values affected by the automated checks. + Assume we may get completely different images. + Or even no image where there was one before. 3. If validation passed: - - - - Updates the shown Split Image and Start Image text - - Reinitialise values - - Restart the automated start check + - - Updates the shown Split Image, Start Image text and Reset Image text. + - Restarts the automated checks. """ if self.is_running: - raise RuntimeError("Start Image should never be reloaded whilst running!") + raise RuntimeError("Images should never be reloaded whilst running!") - self.timer_start_image.stop() + # Stop all async comparisons + self.capture_method.unsubscribe_from_new_frame(self.__compare_capture_for_auto_start) + self.capture_method.unsubscribe_from_new_frame(self.__compare_capture_for_auto_reset) + + # Reset values that can be edited by + # __compare_capture_for_auto_start or __compare_capture_for_auto_reset + # Start related + self.split_image_number = 0 + self.highest_similarity = 0.0 + self.split_below_threshold = False self.current_image_file_label.setText("-") + self.table_current_image_live_label.setText("-") + self.table_current_image_highest_label.setText("-") + self.table_current_image_threshold_label.setText("-") self.start_image_status_value_label.setText("not found") + # Reset related + self.reset_highest_similarity = 0.0 + self.table_reset_image_live_label.setText("N/A") + self.table_reset_image_threshold_label.setText("N/A") + self.table_reset_image_highest_label.setText("N/A") set_preview_image(self.current_split_image, None) - if not ( - validate_before_parsing(self, show_error=started_by_button) - and parse_and_validate_images(self) - ): - QApplication.processEvents() - return - - if not self.start_image: - if started_by_button: - error_messages.no_keyword_image(START_KEYWORD) - QApplication.processEvents() - return + if validate_before_parsing(self, show_error=started_by_button, check_region_exists=False): + parse_and_validate_images(self) - if not wait_for_delay and self.start_image.get_pause_time(self) > 0: - self.start_image_status_value_label.setText("paused") - self.table_current_image_highest_label.setText("-") - self.table_current_image_threshold_label.setText("-") - else: + if self.start_image: + self.table_current_image_threshold_label.setText( + decimal(self.start_image.get_similarity_threshold(self)) + ) self.start_image_status_value_label.setText("ready") - self.__update_split_image(self.start_image) - - self.split_image_number = 0 - self.highest_similarity = 0.0 - self.reset_highest_similarity = 0.0 - self.split_below_threshold = False + self.capture_method.subscribe_to_new_frame(self.__compare_capture_for_auto_start) - self.timer_start_image.start(int(ONE_SECOND / self.start_image.get_fps_limit(self))) + if self.reset_image: + self.table_reset_image_live_label.setText("-") + self.table_reset_image_highest_label.setText("-") + self.table_reset_image_threshold_label.setText( + decimal(self.reset_image.get_similarity_threshold(self)) + ) + self.capture_method.subscribe_to_new_frame(self.__compare_capture_for_auto_reset) QApplication.processEvents() - def __compare_capture_for_auto_start(self): + def __compare_capture_for_auto_start(self, capture: MatLike | None): if not self.start_image: raise ValueError("There are no Start Image. How did we even get here?") + # Note: Start Image pause time is actually done at the start of the splits loop + self.start_image_status_value_label.setText("ready") self.__update_split_image(self.start_image) - capture = self.__get_capture_for_comparison() start_image_threshold = self.start_image.get_similarity_threshold(self) start_image_similarity = self.start_image.compare_with_capture(self, capture) @@ -397,13 +375,14 @@ def __compare_capture_for_auto_start(self): if below_flag and not self.split_below_threshold and similarity_diff >= 0: self.split_below_threshold = True return + if ( # noqa: PLR0916 # See above TODO below_flag and self.split_below_threshold and similarity_diff < 0 and is_valid_image(capture) ) or (not below_flag and similarity_diff >= 0): - self.timer_start_image.stop() + self.capture_method.unsubscribe_from_new_frame(self.__compare_capture_for_auto_start) self.split_below_threshold = False if not self.start_image.check_flag(DUMMY_FLAG): @@ -469,7 +448,7 @@ def __take_screenshot(self): screenshot_index += 1 # Grab screenshot of capture region - capture = self.capture_method.get_frame() + capture = self.capture_method.last_captured_image if not is_valid_image(capture): error_messages.region() return @@ -492,23 +471,25 @@ def __check_fps(self): if self.reset_image: images.append(self.reset_image) - # run X iterations of screenshotting capture region + comparison + displaying. - t0 = time() - last_capture: MatLike | None = None - for image in images: - count = 0 - while count < CHECK_FPS_ITERATIONS: - new_capture = self.__get_capture_for_comparison() - _ = image.compare_with_capture(self, new_capture) - # TODO: If an old image is always returned, this becomes an infinite loop - if new_capture is not last_capture: - count += 1 - last_capture = new_capture - - # calculate FPS - t1 = time() - fps = int((CHECK_FPS_ITERATIONS * len(images)) / (t1 - t0)) - self.fps_value_label.setText(str(fps)) + i = 0 + + def new_frames_counter(capture: MatLike | None): + nonlocal i + if self.capture_method.last_captured_image is None: + # Capture target dropped or inexistant + return + for image in images: + _ = image.compare_with_capture(self, capture) + i += 1 + + self.capture_method.set_fps_limit(QTIMER_FPS_LIMIT) + self.capture_method.subscribe_to_new_frame(new_frames_counter) + QTest.qWait(ONE_SECOND) + fps = str(int(i / len(images)) - 1) + self.capture_method.unsubscribe_from_new_frame(new_frames_counter) + self.capture_method.set_fps_limit(self.settings_dict["fps_limit"]) + + self.fps_value_label.setText(fps) def __is_current_split_out_of_range(self): return ( @@ -577,6 +558,8 @@ def reset(self): When the reset button or hotkey is pressed, it will set `is_running` to False, which will trigger in the __auto_splitter function, if running, to abort and change GUI. """ + self.last_reset_time = time() + self.reset_highest_similarity = 0.0 self.is_running = False # Functions for the hotkeys to return to the main thread from signals @@ -596,13 +579,6 @@ def start_auto_splitter(self): self.start_auto_splitter_signal.emit() - def __check_for_reset_state_update_ui(self): - """Check if AutoSplit is started, if not then update the GUI.""" - if not self.is_running: - self.gui_changes_on_reset(safe_to_reload_start_image=True) - return True - return False - def __auto_splitter(self): # noqa: C901,PLR0912,PLR0915 if not self.settings_dict["split_hotkey"] and not self.is_auto_controlled: self.gui_changes_on_reset(safe_to_reload_start_image=True) @@ -614,7 +590,7 @@ def __auto_splitter(self): # noqa: C901,PLR0912,PLR0915 if not (validate_before_parsing(self) and parse_and_validate_images(self)): # `safe_to_reload_start_image: bool = False` - # because __reload_start_image also does this check, + # because __reload_start_and_reset_images also does this check, # we don't want to double a Start/Reset Image error message self.gui_changes_on_reset() return @@ -722,7 +698,7 @@ def __auto_splitter(self): # noqa: C901,PLR0912,PLR0915 return # loop breaks to here when the last image splits - self.is_running = False + self.reset() self.gui_changes_on_reset(safe_to_reload_start_image=True) def __similarity_threshold_loop( @@ -741,9 +717,9 @@ def __similarity_threshold_loop( start = time() while True: - capture = self.__get_capture_for_comparison() + capture = self.capture_method.last_captured_image - if self.__reset_if_should(capture): + if self.__reset_gui_if_not_running(): return True similarity = self.split_image.compare_with_capture(self, capture) @@ -812,7 +788,7 @@ def __pause_loop(self, stop_time: float, message: str): pause_split_image_number = self.split_image_number while True: # Calculate similarity for Reset Image - if self.__reset_if_should(self.__get_capture_for_comparison()): + if self.__reset_gui_if_not_running(): return True time_delta = time() - start_time @@ -834,19 +810,13 @@ def __pause_loop(self, stop_time: float, message: str): return False def gui_changes_on_start(self): - self.timer_start_image.stop() + self.capture_method.unsubscribe_from_new_frame(self.__compare_capture_for_auto_start) self.start_auto_splitter_button.setText("Running...") self.split_image_folder_button.setEnabled(False) - self.reload_start_image_button.setEnabled(False) + self.reload_images_button.setEnabled(False) self.previous_image_button.setEnabled(True) self.next_image_button.setEnabled(True) - # TODO: Do we actually need to disable setting new hotkeys once started? - # What does this achieve? (See below TODO) - if self.SettingsWidget: - for hotkey in HOTKEYS: - getattr(self.SettingsWidget, f"set_{hotkey}_hotkey_button").setEnabled(False) - if not self.is_auto_controlled: self.start_auto_splitter_button.setEnabled(False) self.reset_button.setEnabled(True) @@ -863,20 +833,11 @@ def gui_changes_on_reset(self, *, safe_to_reload_start_image: bool = False): self.table_current_image_live_label.setText("-") self.table_current_image_highest_label.setText("-") self.table_current_image_threshold_label.setText("-") - self.table_reset_image_live_label.setText("-") - self.table_reset_image_highest_label.setText("-") - self.table_reset_image_threshold_label.setText("-") self.split_image_folder_button.setEnabled(True) - self.reload_start_image_button.setEnabled(True) + self.reload_images_button.setEnabled(True) self.previous_image_button.setEnabled(False) self.next_image_button.setEnabled(False) - # TODO: Do we actually need to disable setting new hotkeys once started? - # What does this achieve? (see above TODO) - if self.SettingsWidget and not self.is_auto_controlled: - for hotkey in HOTKEYS: - getattr(self.SettingsWidget, f"set_{hotkey}_hotkey_button").setEnabled(True) - if not self.is_auto_controlled: self.start_auto_splitter_button.setEnabled(True) self.reset_button.setEnabled(False) @@ -885,67 +846,53 @@ def gui_changes_on_reset(self, *, safe_to_reload_start_image: bool = False): QApplication.processEvents() if safe_to_reload_start_image: - self.reload_start_image_signal.emit(False, False) - - def __get_capture_for_comparison(self): - """Grab capture region and resize for comparison.""" - capture = self.capture_method.get_frame() + self.reload_images_signal.emit(False) + + def __is_reset_image_is_paused(self): + if not self.reset_image: + raise ValueError("There are no Reset Image. How did we even get here?") + + current_time = time() + # Check if Reset Image is paused because we recently reset + paused = current_time - self.last_reset_time <= self.reset_image.get_pause_time(self) + # Check if Reset Image is paused because we are too close to just starting + # because the Reset Image being the same as the Start Image is a common use case. + if not paused and self.start_image: + paused = current_time - self.run_start_time <= self.start_image.get_pause_time(self) + return paused + + def __compare_capture_for_auto_reset(self, capture: MatLike | None): + if not self.reset_image: + raise ValueError("There are no Reset Image. How did we even get here?") + + if not self.settings_dict["enable_auto_reset"]: + self.table_reset_image_live_label.setText("disabled") + return - # This most likely means we lost capture - # (ie the captured window was closed, crashed, lost capture device, etc.) - if not is_valid_image(capture): - # Try to recover by using the window name - if self.settings_dict["capture_method"] == CaptureMethodEnum.VIDEO_CAPTURE_DEVICE: - self.live_image.setText("Waiting for capture device...") - else: - message = "Trying to recover window..." - if self.settings_dict["capture_method"] == CaptureMethodEnum.BITBLT: - message += "\n(captured window may be incompatible with BitBlt)" - self.live_image.setText(message) - recovered = self.capture_method.recover_window( - self.settings_dict["captured_window_title"] - ) - if recovered: - capture = self.capture_method.get_frame() + if self.__is_reset_image_is_paused(): + self.table_reset_image_live_label.setText("paused") + return - self.__update_live_image_details(capture) - return capture + similarity = self.reset_image.compare_with_capture(self, capture) + threshold = self.reset_image.get_similarity_threshold(self) + if similarity > self.reset_highest_similarity: + self.reset_highest_similarity = similarity + self.table_reset_image_highest_label.setText(decimal(self.reset_highest_similarity)) + self.table_reset_image_live_label.setText(decimal(similarity)) - def __reset_if_should(self, capture: MatLike | None): - """Checks if we should reset, resets if it's the case, and returns the result.""" - if self.reset_image: - if self.settings_dict["enable_auto_reset"]: - similarity = self.reset_image.compare_with_capture(self, capture) - threshold = self.reset_image.get_similarity_threshold(self) - - pause_times = [self.reset_image.get_pause_time(self)] - if self.start_image: - pause_times.append(self.start_image.get_pause_time(self)) - paused = time() - self.run_start_time <= max(pause_times) - if paused: - should_reset = False - self.table_reset_image_live_label.setText("paused") - else: - should_reset = similarity >= threshold - self.reset_highest_similarity = max(similarity, self.reset_highest_similarity) - self.table_reset_image_highest_label.setText( - decimal(self.reset_highest_similarity) - ) - self.table_reset_image_live_label.setText(decimal(similarity)) - - self.table_reset_image_threshold_label.setText(decimal(threshold)) - - if should_reset: - send_command(self, "reset") - self.reset() - else: - self.table_reset_image_live_label.setText("disabled") - else: - self.table_reset_image_live_label.setText("N/A") - self.table_reset_image_threshold_label.setText("N/A") - self.table_reset_image_highest_label.setText("N/A") + if similarity >= threshold: + send_command(self, "reset") + self.reset() - return self.__check_for_reset_state_update_ui() + def __reset_gui_if_not_running(self): + """ + Checks if we are in a "not running" state, update GUI if it's the case, + and returns the result. + """ + if not self.is_running: + self.gui_changes_on_reset(safe_to_reload_start_image=True) + return True + return False def __update_split_image(self, specific_image: AutoSplitImage | None = None): # Start image is expected to be out of range (index 0 of 0-length array) @@ -1016,11 +963,9 @@ def exit_program() -> NoReturn: self, "AutoSplit", f"Do you want to save changes made to settings file {settings_file_name}?", - ( - QMessageBox.StandardButton.Yes - | QMessageBox.StandardButton.No - | QMessageBox.StandardButton.Cancel - ), + QMessageBox.StandardButton.Yes + | QMessageBox.StandardButton.No + | QMessageBox.StandardButton.Cancel, ) if warning is QMessageBox.StandardButton.Yes: diff --git a/src/capture_method/BitBltCaptureMethod.py b/src/capture_method/BitBltCaptureMethod.py index 3b2fa049..148f5089 100644 --- a/src/capture_method/BitBltCaptureMethod.py +++ b/src/capture_method/BitBltCaptureMethod.py @@ -12,7 +12,7 @@ from cv2.typing import MatLike from typing_extensions import override -from capture_method.CaptureMethodBase import CaptureMethodBase +from capture_method.CaptureMethodBase import ThreadedLoopCaptureMethod from utils import BGRA_CHANNEL_COUNT, get_window_bounds, is_valid_hwnd, try_delete_dc # This is an undocumented nFlag value for PrintWindow @@ -27,7 +27,7 @@ def is_blank(image: MatLike): return not image.any() -class BitBltCaptureMethod(CaptureMethodBase): +class BitBltCaptureMethod(ThreadedLoopCaptureMethod): name = "BitBlt" short_description = "fastest, least compatible" description = """ @@ -35,18 +35,25 @@ class BitBltCaptureMethod(CaptureMethodBase): OpenGL, Hardware Accelerated or Exclusive Fullscreen windows. The smaller the selected region, the more efficient it is.""" + @property + @override + def window_recovery_message(self): + if type(self) is BitBltCaptureMethod: + return ( + super().window_recovery_message + + "\n(captured window may be incompatible with BitBlt)" + ) + return super().window_recovery_message + _render_full_content = False @override - def get_frame(self) -> MatLike | None: + def _read_action(self) -> MatLike | None: selection = self._autosplit_ref.settings_dict["capture_region"] width = selection["width"] height = selection["height"] hwnd = self._autosplit_ref.hwnd - if not self.check_selected_region_exists(): - return None - # If the window closes while it's being manipulated, it could cause a crash try: window_dc = win32gui.GetWindowDC(hwnd) diff --git a/src/capture_method/CaptureMethodBase.py b/src/capture_method/CaptureMethodBase.py index 73cbb9ad..f69bacb1 100644 --- a/src/capture_method/CaptureMethodBase.py +++ b/src/capture_method/CaptureMethodBase.py @@ -1,8 +1,13 @@ -from typing import TYPE_CHECKING +from abc import ABC, abstractmethod +from collections.abc import Callable +from typing import TYPE_CHECKING, ClassVar, final from cv2.typing import MatLike +from PySide6 import QtCore +from typing_extensions import override -from utils import is_valid_hwnd +import error_messages +from utils import ONE_SECOND, QTIMER_FPS_LIMIT, is_valid_hwnd, is_valid_image if TYPE_CHECKING: from AutoSplit import AutoSplit @@ -12,8 +17,12 @@ class CaptureMethodBase: name = "None" short_description = "" description = "" + window_recovery_message = "Trying to recover window..." + last_captured_image: MatLike | None = None _autosplit_ref: "AutoSplit" + # Making _subscriptions a ClassVar ensures the state will be shared across Methods + _subscriptions: ClassVar = set[Callable[[MatLike | None], object]]() def __init__(self, autosplit: "AutoSplit"): self._autosplit_ref = autosplit @@ -26,17 +35,100 @@ def close(self): # Some capture methods don't need any cleanup pass - def get_frame(self) -> MatLike | None: # noqa: PLR6301 - """ - Captures an image of the region for a window matching the given - parameters of the bounding box. - - @return: The image of the region in the window in BGRA format - """ - return None + def set_fps_limit(self, fps: int): + # CaptureMethodBase doesn't actually record. This is implemented by child classes + pass def recover_window(self, captured_window_title: str) -> bool: # noqa: PLR6301 + # Some capture methods can't "recover" and must simply wait return False def check_selected_region_exists(self) -> bool: return is_valid_hwnd(self._autosplit_ref.hwnd) + + def subscribe_to_new_frame(self, callback: Callable[[MatLike | None], object]): + self._subscriptions.add(callback) + + def unsubscribe_from_new_frame(self, callback: Callable[[MatLike | None], object]): + try: + self._subscriptions.remove(callback) + except KeyError: + pass + + @final + def _push_new_frame_to_subscribers(self, frame: MatLike | None): + for subscription in self._subscriptions: + subscription(frame) + + +class ThreadedLoopCaptureMethod(CaptureMethodBase, ABC): + def __init__(self, autosplit: "AutoSplit"): + super().__init__(autosplit) + self.__capture_timer = QtCore.QTimer() + self.__capture_timer.setTimerType(QtCore.Qt.TimerType.PreciseTimer) + self.__capture_timer.timeout.connect(self.__read_loop) + self.__capture_timer.start(int(ONE_SECOND / self._autosplit_ref.settings_dict["fps_limit"])) + + @override + def close(self): + self.__capture_timer.stop() + + @override + def set_fps_limit(self, fps: int): + if fps > QTIMER_FPS_LIMIT: + raise ValueError(f"QTimer supports a resolution of maximum {QTIMER_FPS_LIMIT} FPS") + if fps < 0: + raise ValueError("'fps' must be positive or 0") + interval = 0 if fps == 0 else int(ONE_SECOND / fps) + self.__capture_timer.setInterval(interval) + + @abstractmethod + def _read_action(self) -> MatLike | None: + """The synchronous code that requests a new image from the operating system.""" + raise NotImplementedError + + @final + def __read_loop(self): + # Very useful debug print + # print( + # "subscriptions:", + # len(self._subscriptions), + # [x.__name__ for x in self._subscriptions], + # ) + if len(self._subscriptions) == 0: + # optimisation on idle: no subscriber means no work needed + return + try: + captured_image = None + if self.check_selected_region_exists(): + captured_image = self._read_action() + # HACK: When WindowsGraphicsCaptureMethod tries to get images too quickly, + # it'll return the previous image directly to avoid looking like it dropped signal + if captured_image is not self.last_captured_image: + self.last_captured_image = captured_image + self._push_new_frame_to_subscribers(self.last_captured_image) + else: + self.last_captured_image = None + self._push_new_frame_to_subscribers(None) + + # This most likely means we lost capture + # (ie the captured window was closed, crashed, lost capture device, etc.) + if not is_valid_image(captured_image): + # Try to recover by using the window name + self._autosplit_ref.live_image.setText(self.window_recovery_message) + recovered = self._autosplit_ref.capture_method.recover_window( + self._autosplit_ref.settings_dict["captured_window_title"], + ) + if recovered and not self._autosplit_ref.settings_dict["live_capture_region"]: + self._autosplit_ref.live_image.setText("Live Capture Region hidden") + except Exception as exception: # noqa: BLE001 # We really want to catch everything here + error = exception + self._autosplit_ref.show_error_signal.emit( + lambda: error_messages.exception_traceback( + error, + "AutoSplit encountered an unhandled exception while " + + "trying to grab a frame and has stopped capture. " + + error_messages.CREATE_NEW_ISSUE_MESSAGE, + ), + ) + self.close() diff --git a/src/capture_method/DesktopDuplicationCaptureMethod.py b/src/capture_method/DesktopDuplicationCaptureMethod.py index e13ff11c..89cbebf0 100644 --- a/src/capture_method/DesktopDuplicationCaptureMethod.py +++ b/src/capture_method/DesktopDuplicationCaptureMethod.py @@ -37,11 +37,11 @@ def __init__(self, autosplit: "AutoSplit"): self.desktop_duplication = d3dshot.create(capture_output="numpy") @override - def get_frame(self): + def _read_action(self): selection = self._autosplit_ref.settings_dict["capture_region"] hwnd = self._autosplit_ref.hwnd hmonitor = win32api.MonitorFromWindow(hwnd, win32con.MONITOR_DEFAULTTONEAREST) - if not hmonitor or not self.check_selected_region_exists(): + if not hmonitor: return None left_bounds, top_bounds, *_ = get_window_bounds(hwnd) diff --git a/src/capture_method/Screenshot using QT attempt.py b/src/capture_method/Screenshot using QT attempt.py index abb3d3af..f489604d 100644 --- a/src/capture_method/Screenshot using QT attempt.py +++ b/src/capture_method/Screenshot using QT attempt.py @@ -11,17 +11,14 @@ from PySide6.QtGui import QGuiApplication from typing_extensions import override -from capture_method.CaptureMethodBase import CaptureMethodBase +from capture_method.CaptureMethodBase import ThreadedLoopCaptureMethod -class QtCaptureMethod(CaptureMethodBase): +class QtCaptureMethod(ThreadedLoopCaptureMethod): _render_full_content = False @override - def get_frame(self): - if not self.check_selected_region_exists(): - return None - + def _read_action(self): buffer = QBuffer() buffer.open(QIODeviceBase.OpenModeFlag.ReadWrite) winid = self._autosplit_ref.winId() diff --git a/src/capture_method/ScrotCaptureMethod.py b/src/capture_method/ScrotCaptureMethod.py index 20ed9375..5510ca86 100644 --- a/src/capture_method/ScrotCaptureMethod.py +++ b/src/capture_method/ScrotCaptureMethod.py @@ -11,11 +11,11 @@ from Xlib.display import Display from Xlib.error import BadWindow -from capture_method.CaptureMethodBase import CaptureMethodBase +from capture_method.CaptureMethodBase import ThreadedLoopCaptureMethod from utils import is_valid_image -class ScrotCaptureMethod(CaptureMethodBase): +class ScrotCaptureMethod(ThreadedLoopCaptureMethod): name = "Scrot" short_description = "very slow, may leave files" description = ( @@ -24,9 +24,7 @@ class ScrotCaptureMethod(CaptureMethodBase): ) @override - def get_frame(self): - if not self.check_selected_region_exists(): - return None + def _read_action(self): xdisplay = Display() root = xdisplay.screen().root try: diff --git a/src/capture_method/VideoCaptureDeviceCaptureMethod.py b/src/capture_method/VideoCaptureDeviceCaptureMethod.py index a2229f14..d7072924 100644 --- a/src/capture_method/VideoCaptureDeviceCaptureMethod.py +++ b/src/capture_method/VideoCaptureDeviceCaptureMethod.py @@ -1,4 +1,3 @@ -from threading import Event, Thread from typing import TYPE_CHECKING import cv2 @@ -7,8 +6,7 @@ from cv2.typing import MatLike from typing_extensions import override -from capture_method.CaptureMethodBase import CaptureMethodBase -from error_messages import CREATE_NEW_ISSUE_MESSAGE, exception_traceback +from capture_method.CaptureMethodBase import ThreadedLoopCaptureMethod from utils import ImageShape, get_input_device_resolution, is_valid_image if TYPE_CHECKING: @@ -30,67 +28,21 @@ def is_blank(image: MatLike): ) -class VideoCaptureDeviceCaptureMethod(CaptureMethodBase): +class VideoCaptureDeviceCaptureMethod(ThreadedLoopCaptureMethod): name = "Video Capture Device" short_description = "see below" description = ( "\nUses a Video Capture Device, like a webcam, virtual cam, or capture card. " + "\nYou can select one below. " ) + window_recovery_message = "Waiting for capture device..." capture_device: cv2.VideoCapture - capture_thread: Thread | None = None - stop_thread: Event - last_captured_frame: MatLike | None = None - last_converted_frame: MatLike | None = None - is_old_image = False - - def __read_loop(self): - try: - while not self.stop_thread.is_set(): - try: - result, image = self.capture_device.read() - except cv2.error as cv2_error: - if not ( - cv2_error.code == cv2.Error.STS_ERROR - and ( - # Likely means the camera is occupied - # OR the camera index is out of range (like -1) - cv2_error.msg.endswith("in function 'cv::VideoCapture::grab'\n") - # Some capture cards we cannot use directly - # https://github.com/opencv/opencv/issues/23539 - or cv2_error.msg.endswith("in function 'cv::VideoCapture::retrieve'\n") - ) - ): - raise - result = False - image = None - if not result: - image = None - - # Blank frame. Reuse the previous one. - if image is not None and is_blank(image): - continue - - self.last_captured_frame = image - self.is_old_image = False - except Exception as exception: # noqa: BLE001 # We really want to catch everything here - error = exception - self.capture_device.release() - self._autosplit_ref.show_error_signal.emit( - lambda: exception_traceback( - error, - "AutoSplit encountered an unhandled exception while " - + "trying to grab a frame and has stopped capture. " - + CREATE_NEW_ISSUE_MESSAGE, - ) - ) def __init__(self, autosplit: "AutoSplit"): super().__init__(autosplit) self.capture_device = cv2.VideoCapture(autosplit.settings_dict["capture_device_id"]) self.capture_device.setExceptionMode(True) - self.stop_thread = Event() # The video capture device isn't accessible, don't bother with it. if not self.capture_device.isOpened(): @@ -107,31 +59,33 @@ def __init__(self, autosplit: "AutoSplit"): # Some cameras don't allow changing the resolution pass - self.capture_thread = Thread(target=self.__read_loop) - self.capture_thread.start() - @override def close(self): - self.stop_thread.set() - if self.capture_thread: - self.capture_thread.join() - self.capture_thread = None + super().close() self.capture_device.release() @override - def get_frame(self): - if not self.check_selected_region_exists(): + def _read_action(self): + try: + result, image = self.capture_device.read() + except cv2.error as cv2_error: + if not ( + cv2_error.code == cv2.Error.STS_ERROR + and ( + # Likely means the camera is occupied + # OR the camera index is out of range (like -1) + cv2_error.msg.endswith("in function 'cv::VideoCapture::grab'\n") + # Some capture cards we cannot use directly + # https://github.com/opencv/opencv/issues/23539 + or cv2_error.msg.endswith("in function 'cv::VideoCapture::retrieve'\n") + ) + ): + raise return None - image = self.last_captured_frame - is_old_image = self.is_old_image - self.is_old_image = True - if not is_valid_image(image): + if not result or not is_valid_image(image) or is_blank(image): return None - if is_old_image: - return self.last_converted_frame - selection = self._autosplit_ref.settings_dict["capture_region"] # Ensure we can't go OOB of the image y = min(selection["y"], image.shape[ImageShape.Y] - 1) @@ -140,8 +94,7 @@ def get_frame(self): y : y + selection["height"], x : x + selection["width"], ] - self.last_converted_frame = cv2.cvtColor(image, cv2.COLOR_BGR2BGRA) - return self.last_converted_frame + return cv2.cvtColor(image, cv2.COLOR_BGR2BGRA) @override def check_selected_region_exists(self): diff --git a/src/capture_method/WindowsGraphicsCaptureMethod.py b/src/capture_method/WindowsGraphicsCaptureMethod.py index aa905a32..da02d53a 100644 --- a/src/capture_method/WindowsGraphicsCaptureMethod.py +++ b/src/capture_method/WindowsGraphicsCaptureMethod.py @@ -19,7 +19,7 @@ ) from winrt.windows.graphics.imaging import BitmapBufferAccessMode, SoftwareBitmap -from capture_method.CaptureMethodBase import CaptureMethodBase +from capture_method.CaptureMethodBase import ThreadedLoopCaptureMethod from d3d11 import D3D11_CREATE_DEVICE_FLAG, D3D_DRIVER_TYPE, D3D11CreateDevice from utils import BGRA_CHANNEL_COUNT, WGC_MIN_BUILD, WINDOWS_BUILD_NUMBER, is_valid_hwnd @@ -28,12 +28,14 @@ WGC_NO_BORDER_MIN_BUILD = 20348 +WGC_QTIMER_LIMIT = 30 + async def convert_d3d_surface_to_software_bitmap(surface: IDirect3DSurface | None): return await SoftwareBitmap.create_copy_from_surface_async(surface) -class WindowsGraphicsCaptureMethod(CaptureMethodBase): +class WindowsGraphicsCaptureMethod(ThreadedLoopCaptureMethod): name = "Windows Graphics Capture" short_description = "fast, most compatible, capped at 60fps" description = f""" @@ -46,7 +48,6 @@ class WindowsGraphicsCaptureMethod(CaptureMethodBase): frame_pool: Direct3D11CaptureFramePool | None = None session: GraphicsCaptureSession | None = None """This is stored to prevent session from being garbage collected""" - last_converted_frame: MatLike | None = None def __init__(self, autosplit: "AutoSplit"): super().__init__(autosplit) @@ -96,14 +97,19 @@ def close(self): self.session = None @override - def get_frame(self) -> MatLike | None: + def set_fps_limit(self, fps: int): + """ + There's an issue in the interaction between QTimer and WGC API where setting the interval to + even 1 ms causes twice as many "called `try_get_next_frame` too fast". + So for FPS target above 30, we unlock interval speed. + """ + super().set_fps_limit(fps if fps <= WGC_QTIMER_LIMIT else 0) + + @override + def _read_action(self) -> MatLike | None: selection = self._autosplit_ref.settings_dict["capture_region"] - # We still need to check the hwnd because WGC will return a blank black image - if not ( - self.check_selected_region_exists() - # Only needed for the type-checker - and self.frame_pool - ): + # Only needed for the type-checker + if not self.frame_pool: return None try: @@ -116,39 +122,44 @@ def get_frame(self) -> MatLike | None: # TODO: Consider "add_frame_arrive" instead ! # https://github.com/pywinrt/pywinrt/blob/5bf1ac5ff4a77cf343e11d7c841c368fa9235d81/samples/screen_capture/__main__.py#L67-L78 if not frame: - return self.last_converted_frame + return self.last_captured_image try: software_bitmap = asyncio.run(convert_d3d_surface_to_software_bitmap(frame.surface)) except SystemError as exception: # HACK: can happen when closing the GraphicsCapturePicker if str(exception).endswith("returned a result with an error set"): - return self.last_converted_frame + return self.last_captured_image raise if not software_bitmap: # HACK: Can happen when starting the region selector # TODO: Validate if this is still true - return self.last_converted_frame - # raise ValueError("Unable to convert Direct3D11CaptureFrame to SoftwareBitmap.") + return self.last_captured_image + # raise ValueError("Unable to convert IDirect3DSurface to SoftwareBitmap.") bitmap_buffer = software_bitmap.lock_buffer(BitmapBufferAccessMode.READ_WRITE) if not bitmap_buffer: raise ValueError("Unable to obtain the BitmapBuffer from SoftwareBitmap.") reference = bitmap_buffer.create_reference() image = np.frombuffer(cast(bytes, reference), dtype=np.uint8) image.shape = (self.size.height, self.size.width, BGRA_CHANNEL_COUNT) - image = image[ + return image[ selection["y"] : selection["y"] + selection["height"], selection["x"] : selection["x"] + selection["width"], ] - self.last_converted_frame = image - return image @override def recover_window(self, captured_window_title: str): hwnd = win32gui.FindWindow(None, captured_window_title) if not is_valid_hwnd(hwnd): return False + + # Because of async image obtention and capture initialization, AutoSplit + # could ask for an image too soon after having called recover_window() last iteration. + # WGC *would* have returned an image, but it's asked to reinitialize over again. + if self._autosplit_ref.hwnd == hwnd and self.check_selected_region_exists(): + return True + self._autosplit_ref.hwnd = hwnd try: self.reinitialize() diff --git a/src/capture_method/XcbCaptureMethod.py b/src/capture_method/XcbCaptureMethod.py index f91f1f8b..8e400432 100644 --- a/src/capture_method/XcbCaptureMethod.py +++ b/src/capture_method/XcbCaptureMethod.py @@ -11,11 +11,11 @@ from Xlib.display import Display from Xlib.error import BadWindow -from capture_method.CaptureMethodBase import CaptureMethodBase +from capture_method.CaptureMethodBase import ThreadedLoopCaptureMethod from utils import is_valid_image -class XcbCaptureMethod(CaptureMethodBase): +class XcbCaptureMethod(ThreadedLoopCaptureMethod): name = "X11 XCB" short_description = "fast, requires XCB" description = "\nUses the XCB library to take screenshots of the X11 server." @@ -23,9 +23,7 @@ class XcbCaptureMethod(CaptureMethodBase): _xdisplay: str | None = "" # ":0" @override - def get_frame(self): - if not self.check_selected_region_exists(): - return None + def _read_action(self): xdisplay = Display() root = xdisplay.screen().root try: diff --git a/src/capture_method/__init__.py b/src/capture_method/__init__.py index a494e0f5..1110e7cc 100644 --- a/src/capture_method/__init__.py +++ b/src/capture_method/__init__.py @@ -216,4 +216,8 @@ def get_camera_info(index: int, device_name: str): else None ) - return list(filter(None, starmap(get_camera_info, enumerate(named_video_inputs)))) + return [ + camera_info + for camera_info in starmap(get_camera_info, enumerate(named_video_inputs)) + if camera_info is not None + ] diff --git a/src/compare.py b/src/compare.py index 868293cd..5ff6c8e4 100644 --- a/src/compare.py +++ b/src/compare.py @@ -93,8 +93,8 @@ def compare_template(source: MatLike, capture: MatLike, mask: MatLike | None = N def __cv2_phash(image: MatLike, hash_size: int = 8, highfreq_factor: int = 4): """Implementation copied from https://github.com/JohannesBuchner/imagehash/blob/38005924fe9be17cfed145bbc6d83b09ef8be025/imagehash/__init__.py#L260 .""" # noqa: E501 # OpenCV has its own pHash comparison implementation in `cv2.img_hash`, - # but it requires contrib/extra modules and is inaccurate - # unless we precompute the size with a specific interpolation. + # but it requires contrib/extra modules + # and is inaccurate unless we precompute the size with a specific interpolation. # See: https://github.com/opencv/opencv_contrib/issues/3295#issuecomment-1172878684 # # pHash = cv2.img_hash.PHash.create() diff --git a/src/error_messages.py b/src/error_messages.py index 5dac82ca..37c09293 100644 --- a/src/error_messages.py +++ b/src/error_messages.py @@ -15,7 +15,7 @@ from AutoSplit import AutoSplit -def __exit_program(): +def __exit_program() -> NoReturn: # stop main thread (which is probably blocked reading input) via an interrupt signal os.kill(os.getpid(), signal.SIGINT) sys.exit(1) @@ -95,12 +95,6 @@ def alignment_not_matched(): set_text_message("No area in capture region matched reference image. Alignment failed.") -def no_keyword_image(keyword: str): - set_text_message( - f"Your split image folder does not contain an image with the keyword {keyword!r}." - ) - - def multiple_keyword_images(keyword: str): set_text_message(f"Only one image with the keyword {keyword!r} is allowed.") @@ -235,11 +229,13 @@ def excepthook( if exception_type is KeyboardInterrupt or isinstance(exception, KeyboardInterrupt): sys.exit(0) # HACK: Can happen when starting the region selector while capturing with WindowsGraphicsCapture # noqa: E501 - if exception_type is SystemError and str(exception) == ( - " returned a result with an error set" + if ( + exception_type is SystemError + and str(exception) + == " returned a result with an error set" ): return - # Whithin LiveSplit excepthook needs to use MainWindow's signals to show errors + # Within LiveSplit excepthook needs to use MainWindow's signals to show errors autosplit.show_error_signal.emit(lambda: exception_traceback(exception)) return excepthook diff --git a/src/hotkeys.py b/src/hotkeys.py index 8280a57c..1e02b71b 100644 --- a/src/hotkeys.py +++ b/src/hotkeys.py @@ -85,15 +85,7 @@ def send_command(autosplit: "AutoSplit", command: CommandStr): autosplit.screenshot_signal.emit() match command: case _ if autosplit.is_auto_controlled: - if command == "start" and autosplit.settings_dict["start_also_resets"]: - print("reset", flush=True) print(command, flush=True) - # Note: Rather than having the start image able to also reset the timer, having - # the reset image check be active at all time would be a better, more organic solution. - # But that is dependent on migrating to an observer pattern (#219) and - # being able to reload all images. - case "start" if autosplit.settings_dict["start_also_resets"]: - _send_hotkey(autosplit.settings_dict["reset_hotkey"]) case "reset": _send_hotkey(autosplit.settings_dict["reset_hotkey"]) case "start" | "split": diff --git a/src/menu_bar.py b/src/menu_bar.py index 0b9246f3..1eea3184 100644 --- a/src/menu_bar.py +++ b/src/menu_bar.py @@ -24,7 +24,7 @@ ) from gen import about, design, settings as settings_ui, update_checker from hotkeys import HOTKEYS, HOTKEYS_WHEN_AUTOCONTROLLED, CommandStr, set_hotkey -from utils import AUTOSPLIT_VERSION, GITHUB_REPOSITORY, ONE_SECOND, decimal, fire_and_forget +from utils import AUTOSPLIT_VERSION, GITHUB_REPOSITORY, decimal, fire_and_forget if TYPE_CHECKING: from AutoSplit import AutoSplit @@ -187,7 +187,7 @@ def __init__(self, autosplit: "AutoSplit"): # Don't autofocus any particular field self.setFocus() - # region Build the Capture method combobox # fmt: skip + # region Build the Capture method combobox capture_method_values = CAPTURE_METHODS.values() self.__set_all_capture_devices() @@ -226,7 +226,7 @@ def __update_default_threshold(self, value: Any): self._autosplit_ref.table_reset_image_threshold_label.setText( decimal(self._autosplit_ref.reset_image.get_similarity_threshold(self._autosplit_ref)) if self._autosplit_ref.reset_image - else "-" + else "N/A" ) def __set_value(self, key: str, value: Any): @@ -285,7 +285,7 @@ def __capture_device_changed(self): def __fps_limit_changed(self, value: int): value = self.fps_limit_spinbox.value() self._autosplit_ref.settings_dict["fps_limit"] = value - self._autosplit_ref.timer_live_image.setInterval(int(ONE_SECOND / value)) + self._autosplit_ref.capture_method.set_fps_limit(value) @fire_and_forget def __set_all_capture_devices(self): @@ -400,20 +400,19 @@ def add_or_del(checked: Literal[0, 2], command: CommandStr = command): self._autosplit_ref.settings_dict["default_pause_time"] ) self.loop_splits_checkbox.setChecked(self._autosplit_ref.settings_dict["loop_splits"]) - self.start_also_resets_checkbox.setChecked( - self._autosplit_ref.settings_dict["start_also_resets"] - ) self.enable_auto_reset_image_checkbox.setChecked( self._autosplit_ref.settings_dict["enable_auto_reset"] ) + # endregion # region Binding + # Capture Settings self.fps_limit_spinbox.valueChanged.connect(self.__fps_limit_changed) self.live_capture_region_checkbox.stateChanged.connect( - lambda: self.__set_value( - "live_capture_region", + lambda: user_profile.update_live_capture_region_setting( + self._autosplit_ref, self.live_capture_region_checkbox.isChecked(), ) ) @@ -447,12 +446,6 @@ def add_or_del(checked: Literal[0, 2], command: CommandStr = command): self.loop_splits_checkbox.stateChanged.connect( lambda: self.__set_value("loop_splits", self.loop_splits_checkbox.isChecked()) ) - self.start_also_resets_checkbox.stateChanged.connect( - lambda: self.__set_value( - "start_also_resets", - self.start_also_resets_checkbox.isChecked(), - ) - ) self.enable_auto_reset_image_checkbox.stateChanged.connect( lambda: self.__set_value( "enable_auto_reset", @@ -497,7 +490,6 @@ def get_default_settings_from_ui(autosplit: "AutoSplit"): "default_delay_time": default_settings_dialog.default_delay_time_spinbox.value(), "default_pause_time": default_settings_dialog.default_pause_time_spinbox.value(), "loop_splits": default_settings_dialog.loop_splits_checkbox.isChecked(), - "start_also_resets": default_settings_dialog.start_also_resets_checkbox.isChecked(), "enable_auto_reset": default_settings_dialog.enable_auto_reset_image_checkbox.isChecked(), "split_image_directory": autosplit.split_image_folder_input.text(), "screenshot_directory": default_settings_dialog.screenshot_directory_input.text(), diff --git a/src/region_selection.py b/src/region_selection.py index b21e3fc5..86886072 100644 --- a/src/region_selection.py +++ b/src/region_selection.py @@ -234,7 +234,7 @@ def align_region(autosplit: "AutoSplit"): # Obtaining the capture of a region which contains the # subregion being searched for to align the image. - capture = autosplit.capture_method.get_frame() + capture = autosplit.capture_method.last_captured_image if not is_valid_image(capture): error_messages.region() diff --git a/src/split_parser.py b/src/split_parser.py index 164a66a2..afba5cba 100644 --- a/src/split_parser.py +++ b/src/split_parser.py @@ -185,7 +185,12 @@ def __pop_image_type(split_image: list[AutoSplitImage], image_type: ImageType): return None -def validate_before_parsing(autosplit: "AutoSplit", *, show_error: bool = True): +def validate_before_parsing( + autosplit: "AutoSplit", + *, + show_error: bool = True, + check_region_exists: bool = True, +): error = None split_image_directory = autosplit.settings_dict["split_image_directory"] if not split_image_directory: @@ -194,7 +199,7 @@ def validate_before_parsing(autosplit: "AutoSplit", *, show_error: bool = True): error = partial(error_messages.invalid_directory, split_image_directory) elif not os.listdir(split_image_directory): error = error_messages.split_image_directory_empty - elif not autosplit.capture_method.check_selected_region_exists(): + elif check_region_exists and not autosplit.capture_method.check_selected_region_exists(): error = error_messages.region if error and show_error: error() @@ -279,12 +284,12 @@ def parse_and_validate_images(autosplit: "AutoSplit"): # Check that there's only one Reset Image if image.image_type == ImageType.RESET: - error_message = lambda: error_messages.multiple_keyword_images(RESET_KEYWORD) # noqa: E731 + error_message = partial(error_messages.multiple_keyword_images, RESET_KEYWORD) break # Check that there's only one Start Image if image.image_type == ImageType.START: - error_message = lambda: error_messages.multiple_keyword_images(START_KEYWORD) # noqa: E731 + error_message = partial(error_messages.multiple_keyword_images, START_KEYWORD) break if error_message: diff --git a/src/user_profile.py b/src/user_profile.py index cb27ec40..baf9aac0 100644 --- a/src/user_profile.py +++ b/src/user_profile.py @@ -36,7 +36,6 @@ class UserProfileDict(TypedDict): default_delay_time: int default_pause_time: float loop_splits: bool - start_also_resets: bool enable_auto_reset: bool split_image_directory: str screenshot_directory: str @@ -69,7 +68,6 @@ def copy(): default_delay_time=0, default_pause_time=10, loop_splits=False, - start_also_resets=False, enable_auto_reset=True, split_image_directory="", screenshot_directory="", @@ -165,14 +163,9 @@ def __load_settings_from_file(autosplit: "AutoSplit", load_settings_file_path: s cast(CaptureMethodEnum, autosplit.settings_dict["capture_method"]), autosplit, ) - if autosplit.settings_dict["capture_method"] != CaptureMethodEnum.VIDEO_CAPTURE_DEVICE: - autosplit.capture_method.recover_window(autosplit.settings_dict["captured_window_title"]) - if not autosplit.capture_method.check_selected_region_exists(): - autosplit.live_image.setText( - "Reload settings after opening" - + f"\n{autosplit.settings_dict['captured_window_title']!r}" - + "\nto automatically load Capture Region" - ) + update_live_capture_region_setting(autosplit, autosplit.settings_dict["live_capture_region"]) + autosplit.update_live_image_details(None) + autosplit.reload_images_signal.emit(False) if settings_widget_was_open: open_settings(autosplit) @@ -199,7 +192,7 @@ def load_settings(autosplit: "AutoSplit", from_path: str = ""): autosplit.last_successfully_loaded_settings_file_path = load_settings_file_path # TODO: Should this check be in `__load_start_image` ? if not autosplit.is_running: - autosplit.reload_start_image_signal.emit(False, True) + autosplit.reload_images_signal.emit(False) def load_settings_on_open(autosplit: "AutoSplit"): @@ -253,3 +246,13 @@ def set_check_for_updates_on_open(design_window: design.Ui_MainWindow, value: bo "check_for_updates_on_open", value, ) + + +def update_live_capture_region_setting(autosplit: "AutoSplit", value: bool): # noqa: FBT001 + autosplit.settings_dict["live_capture_region"] = value + if value: + autosplit.capture_method.subscribe_to_new_frame(autosplit.update_live_image_details) + else: + autosplit.update_live_image_details(None) + autosplit.live_image.setText("Live Capture Region Hidden") + autosplit.capture_method.unsubscribe_from_new_frame(autosplit.update_live_image_details) diff --git a/src/utils.py b/src/utils.py index 38a66921..cb39fdf4 100644 --- a/src/utils.py +++ b/src/utils.py @@ -62,6 +62,8 @@ def find_tesseract_path(): MAXBYTE = 255 ONE_SECOND = 1000 """1000 milliseconds in 1 second""" +QTIMER_FPS_LIMIT = 1000 +"""QTimers are accurate to the millisecond""" BGR_CHANNEL_COUNT = 3 """How many channels in a BGR image""" BGRA_CHANNEL_COUNT = 4