diff --git a/src/AutoSplitImage.py b/src/AutoSplitImage.py index 6b04852f..32078e6c 100644 --- a/src/AutoSplitImage.py +++ b/src/AutoSplitImage.py @@ -10,20 +10,8 @@ import numpy as np import error_messages -from compare import ( - check_if_image_has_transparency, - extract_and_compare_text, - get_comparison_method_by_index, -) -from utils import ( - BGR_CHANNEL_COUNT, - MAXBYTE, - TESSERACT_PATH, - ColorChannel, - ImageShape, - imread, - is_valid_image, -) +from compare import extract_and_compare_text, get_comparison_method_by_index +from utils import MAXBYTE, TESSERACT_PATH, imread, is_valid_image if TYPE_CHECKING: from cv2.typing import MatLike @@ -52,8 +40,6 @@ class AutoSplitImage: image_type: ImageType byte_array: MatLike | None = None mask: MatLike | None = None - # This value is internal, check for mask instead - _has_transparency = False # These values should be overridden by some Defaults if None. Use getters instead __delay_time: float | None = None __comparison_method: int | None = None @@ -167,17 +153,19 @@ def __read_image_bytes(self, path: str): error_messages.image_type(path) return - self._has_transparency = check_if_image_has_transparency(image) + transparency, alpha_nonzero_count = get_image_transparency(image) + if transparency == ImageTransparency.ERROR_FULLY_TRANSPARENT: + error_messages.image_fully_transparent(path) + elif transparency == ImageTransparency.ERROR_PARTIAL_TRANSPARENCY: + error_messages.image_partial_transparency(path) + # If image has transparency, create a mask - if self._has_transparency: + if transparency == ImageTransparency.HAS_MASK: # Adaptively determine the target size according to # the number of nonzero elements in the alpha channel of the split image. # This may result in images bigger than COMPARISON_RESIZE if there's plenty of transparency. # noqa: E501 # Which wouldn't incur any performance loss in methods where masked regions are ignored. - scale = min( - 1, - sqrt(COMPARISON_RESIZE_AREA / cv2.countNonZero(image[:, :, ColorChannel.Alpha])), - ) + scale = min(1, sqrt(COMPARISON_RESIZE_AREA / alpha_nonzero_count)) image = cv2.resize( image, @@ -191,8 +179,8 @@ def __read_image_bytes(self, path: str): self.mask = cv2.inRange(image, MASK_LOWER_BOUND, MASK_UPPER_BOUND) else: image = cv2.resize(image, COMPARISON_RESIZE, interpolation=cv2.INTER_NEAREST) - # Add Alpha channel if missing - if image.shape[ImageShape.Channels] == BGR_CHANNEL_COUNT: + if transparency == ImageTransparency.NO_MASK_NO_ALPHA_CHANNEL: + # Add Alpha channel if missing image = cv2.cvtColor(image, cv2.COLOR_BGR2BGRA) self.byte_array = image @@ -237,9 +225,11 @@ def compare_with_capture(self, default: AutoSplit | int, capture: MatLike | None if True: from split_parser import ( + ImageTransparency, comparison_method_from_filename, delay_time_from_filename, flags_from_filename, + get_image_transparency, loop_from_filename, pause_from_filename, threshold_from_filename, diff --git a/src/compare.py b/src/compare.py index 574c9c8f..f9eecfdc 100644 --- a/src/compare.py +++ b/src/compare.py @@ -6,14 +6,7 @@ import Levenshtein import numpy as np -from utils import ( - BGRA_CHANNEL_COUNT, - MAXBYTE, - ColorChannel, - ImageShape, - is_valid_image, - run_tesseract, -) +from utils import MAXBYTE, ColorChannel, is_valid_image, run_tesseract if TYPE_CHECKING: from cv2.typing import MatLike @@ -94,7 +87,7 @@ def compare_template(source: MatLike, capture: MatLike, mask: MatLike | None = N # The old scipy-based implementation. -# Turns out this cuases an extra 25 MB build compared to opencv-contrib-python-headless +# Turns out this causes an extra 25 MB build compared to opencv-contrib-python-headless # # from scipy import fft # def __cv2_scipy_compute_phash(image: MatLike, hash_size: int, highfreq_factor: int = 4): # """Implementation copied from https://github.com/JohannesBuchner/imagehash/blob/38005924fe9be17cfed145bbc6d83b09ef8be025/imagehash/__init__.py#L260 .""" # noqa: E501 @@ -201,19 +194,3 @@ def get_comparison_method_by_index(comparison_method_index: int): return compare_phash case _: return __compare_dummy - - -def check_if_image_has_transparency(image: MatLike): - # Check if there's a transparency channel (4th channel) - # and if at least one pixel is transparent (< 255) - if image.shape[ImageShape.Channels] != BGRA_CHANNEL_COUNT: - return False - mean: float = image[:, :, ColorChannel.Alpha].mean() - if mean == 0: - # Non-transparent images code path is usually faster and simpler, so let's return that - return False - # TODO: error message if all pixels are transparent - # (the image appears as all black in windows, - # so it's not obvious for the user what they did wrong) - - return mean != MAXBYTE diff --git a/src/error_messages.py b/src/error_messages.py index 40461308..024e2a70 100644 --- a/src/error_messages.py +++ b/src/error_messages.py @@ -87,6 +87,22 @@ def image_type(image: str): ) +def image_fully_transparent(image: str): + file_app = "Explorer" if sys.platform == "win32" else "Manager" + _set_text_message( + f"{image!r} is fully transparent. " + + "Every pixel has an alpha of 0, so there is nothing left to compare against. " + + f"The image may be appearing as all black in your File {file_app}." + ) + + +def image_partial_transparency(image: str): + _set_text_message( + f"{image!r} contains semi-transparent pixels. " + + "To avoid confusion, only fully solid or fully transparent pixels are allowed." + ) + + def region(): _set_text_message( "No region is selected or the Capture Region window is not open. " diff --git a/src/split_parser.py b/src/split_parser.py index b8aa0817..500e931b 100644 --- a/src/split_parser.py +++ b/src/split_parser.py @@ -4,13 +4,16 @@ import re import sys from collections.abc import Callable +from enum import IntEnum, auto from functools import partial from stat import UF_HIDDEN from typing import TYPE_CHECKING, TypeVar +import numpy as np + import error_messages from AutoSplitImage import RESET_KEYWORD, START_KEYWORD, AutoSplitImage, ImageType -from utils import is_valid_image +from utils import BGRA_CHANNEL_COUNT, MAXBYTE, ColorChannel, ImageShape, is_valid_image if sys.platform == "win32": from stat import FILE_ATTRIBUTE_HIDDEN, FILE_ATTRIBUTE_SYSTEM @@ -18,6 +21,7 @@ if TYPE_CHECKING: from _typeshed import StrPath + from cv2.typing import MatLike from AutoSplit import AutoSplit @@ -26,7 +30,7 @@ BELOW_FLAG, PAUSE_FLAG, *_, - # Keep combined bitflags under 256 (Python cached small integers) + # Keep combined bitflags <= 256 (Python cached small integers) ) = tuple(1 << i for i in range(8)) FileFlagValueT = TypeVar("FileFlagValueT", str, int, float) @@ -35,6 +39,53 @@ # / \ : * ? " < > | +class ImageTransparency(IntEnum): + """Classification of a split image's alpha channel.""" + + NO_MASK_NO_ALPHA_CHANNEL = auto() + """No alpha channel at all (a 3-channel image).""" + NO_MASK_FULLY_SOLID = auto() + """Has an alpha channel, but every pixel is fully opaque (alpha of 255).""" + HAS_MASK = auto() + """Has transparency using only fully transparent and fully opaque pixels.""" + ERROR_FULLY_TRANSPARENT = auto() + """Every pixel is fully transparent (alpha of 0).""" + ERROR_PARTIAL_TRANSPARENCY = auto() + """At least one semi-transparent pixel (alpha strictly between 0 and 255).""" + + +def get_image_transparency(image: MatLike): + """ + Classify an image's transparency from its alpha channel. + + Returns the classification along with the alpha channel's non-zero pixel + count (`0` when no count was needed to classify), so callers don't have to + recompute it. + + Optimized for the common, valid outcomes (`NO_MASK_*` and `HAS_MASK`) using + cheap, allocation-free reductions. The `ERROR_*` outcomes are rare and lead + to a user-facing error, so they're allowed to be slow. + """ + if image.shape[ImageShape.Channels] != BGRA_CHANNEL_COUNT: + return ImageTransparency.NO_MASK_NO_ALPHA_CHANNEL, 0 + alpha = image[:, :, ColorChannel.Alpha] + # Fully opaque is the most common case; a single reduction rules it in. + if alpha.min() == MAXBYTE: + return ImageTransparency.NO_MASK_FULLY_SOLID, 0 + # Detect a valid mask (only fully transparent/opaque pixels) + # without allocating per-pixel comparison masks: + # such an alpha channel sums to 255x its non-zero pixel count. + nonzero_count = np.count_nonzero(alpha) + if alpha.sum() == MAXBYTE * nonzero_count: + # A fully transparent image (no non-zero pixels) is already an error, + # so there's no need to further consider partial transparency. + if nonzero_count == 0: + return ImageTransparency.ERROR_FULLY_TRANSPARENT, 0 + return ImageTransparency.HAS_MASK, nonzero_count + # At least one semi-transparent pixel remains. + return ImageTransparency.ERROR_PARTIAL_TRANSPARENCY, 0 + + def __value_from_filename( filename: str, delimiters: str,