Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 14 additions & 24 deletions src/AutoSplitImage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down
27 changes: 2 additions & 25 deletions src/compare.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
16 changes: 16 additions & 0 deletions src/error_messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -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. "
Expand Down
55 changes: 53 additions & 2 deletions src/split_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,24 @@
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


if TYPE_CHECKING:
from _typeshed import StrPath
from cv2.typing import MatLike

from AutoSplit import AutoSplit

Expand All @@ -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)
Expand All @@ -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,
Expand Down
Loading