Skip to content

Commit

Permalink
img - more tests, refactors, fixes. snip DONE
Browse files Browse the repository at this point in the history
  • Loading branch information
IGalat committed Sep 30, 2024
1 parent a780ee7 commit 555c6cb
Show file tree
Hide file tree
Showing 7 changed files with 223 additions and 29 deletions.
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ pythonpath = [
testpaths = [
"tests",
]
tmp_path_retention_count = 0


[tool.mypy]
Expand Down
6 changes: 6 additions & 0 deletions src/tapper/helper/_util/image/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ def from_path(pathlike: ImagePathT) -> ImagePixelMatrixT:
return np.asarray(pil_img)


def api_from_path(pathlike: ImagePathT, cache: bool) -> ImagePixelMatrixT:
if not cache:
from_path.cache_clear()
return from_path(pathlike) # type: ignore


def to_pixel_matrix(image: ImageT | None) -> ImagePixelMatrixT | None:
if image is None:
return None
Expand Down
85 changes: 85 additions & 0 deletions src/tapper/helper/_util/image/snip_util.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import os
from typing import Any
from typing import Callable

import tapper
from tapper.helper._util.image import base
from tapper.helper.model_types import BboxT
from tapper.helper.model_types import ImagePixelMatrixT
from tapper.helper.model_types import XyCoordsT

snip_start_coords: XyCoordsT | None = None


def toggle_snip(
prefix: str | None = None,
bbox_to_name: bool = True,
override_existing: bool = True,
bbox_callback: Callable[[tuple[int, int, int, int]], Any] | None = None,
picture_callback: Callable[[ImagePixelMatrixT], Any] | None = None,
) -> None:
global snip_start_coords
if not snip_start_coords:
start_snip()
else:
stop_coords = tapper.mouse.get_pos()
x1 = min(snip_start_coords[0], stop_coords[0])
x2 = max(snip_start_coords[0], stop_coords[0])
y1 = min(snip_start_coords[1], stop_coords[1])
y2 = max(snip_start_coords[1], stop_coords[1])
snip_start_coords = None
finish_snip_with_callback(
prefix,
bbox_to_name,
(x1, y1, x2, y2),
override_existing,
bbox_callback,
picture_callback,
)


def start_snip() -> None:
global snip_start_coords
snip_start_coords = tapper.mouse.get_pos()


def finish_snip_with_callback(
prefix: str | None = None,
bbox_to_name: bool = True,
bbox: BboxT | None = None,
override_existing: bool = True,
bbox_callback: Callable[[tuple[int, int, int, int]], Any] | None = None,
picture_callback: Callable[[ImagePixelMatrixT], Any] | None = None,
) -> None:
nd_sct, bbox = finish_snip(prefix, bbox, bbox_to_name, override_existing)
if bbox and bbox_callback:
bbox_callback(bbox)
if picture_callback:
picture_callback(nd_sct)


def finish_snip(
prefix: str | None,
bbox: BboxT | None,
bbox_to_name: bool,
override_existing: bool,
) -> tuple[ImagePixelMatrixT, BboxT | None]:
sct = base.get_screenshot_if_none_and_cut(None, bbox)
if prefix is not None:
bbox_str = (
f"-BBOX({bbox[0]},{bbox[1]},{bbox[2]},{bbox[3]})"
if bbox and bbox_to_name
else ""
)
ending = bbox_str + ".png"
full_name = ""
if override_existing or not os.path.exists(prefix + ending):
full_name = prefix + ending
else:
for i in range(1, 100):
potential_name = prefix + f"({i})" + ending
if not os.path.exists(potential_name):
full_name = potential_name
break
base.save_to_disk(sct, full_name)
return sct, bbox
6 changes: 3 additions & 3 deletions src/tapper/helper/_util/image_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -253,11 +253,11 @@ def save_to_disk(
sct: ImagePixelMatrixT,
prefix: str,
bbox: BboxT | None,
bbox_in_name: bool,
bbox_to_name: bool,
) -> None:
bbox_str = (
f"-(BBOX_{bbox[0]}_{bbox[1]}_{bbox[2]}_{bbox[3]})"
if bbox and bbox_in_name
if bbox and bbox_to_name
else ""
)
ending = bbox_str + ".png"
Expand Down Expand Up @@ -335,5 +335,5 @@ def pixel_find(
first_match = matching_px[0]
x = start_x + first_match[1]
y = start_y + first_match[0]
return x, y
return x, y # noqa - np variables are fine as ints
return None
52 changes: 32 additions & 20 deletions src/tapper/helper/img.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from tapper.helper._util import image_util as _image_util
from tapper.helper._util.image import base as _base_util
from tapper.helper._util.image import find_util as _find_util
from tapper.helper._util.image import snip_util as _snip_util
from tapper.helper.model_types import BboxT
from tapper.helper.model_types import ImagePathT
from tapper.helper.model_types import ImagePixelMatrixT
Expand All @@ -32,10 +33,10 @@ def _check_dependencies() -> None:
)


def from_path(pathlike: ImagePathT) -> ImagePixelMatrixT:
def from_path(pathlike: ImagePathT, cache: bool = True) -> ImagePixelMatrixT:
"""Get image from file path."""
_check_dependencies()
return _base_util.from_path(pathlike) # type: ignore
return _base_util.api_from_path(pathlike, cache) # type: ignore


def find(
Expand All @@ -62,9 +63,9 @@ def find(


def find_one_of(
targets: list[ImageT]
| tuple[list[ImageT], BboxT]
| list[tuple[ImageT, BboxT | None]],
targets: (
list[ImageT] | tuple[list[ImageT], BboxT] | list[tuple[ImageT, BboxT | None]]
),
outer: str | ImagePixelMatrixT | None = None,
precision: float = STD_PRECISION,
) -> tuple[ImageT, XyCoordsT] | tuple[None, None]:
Expand Down Expand Up @@ -109,9 +110,9 @@ def wait_for(


def wait_for_one_of(
targets: list[ImageT]
| tuple[list[ImageT], BboxT]
| list[tuple[ImageT, BboxT | None]],
targets: (
list[ImageT] | tuple[list[ImageT], BboxT] | list[tuple[ImageT, BboxT | None]]
),
timeout: int | float = 5,
interval: float = 0.4,
precision: float = STD_PRECISION,
Expand Down Expand Up @@ -156,10 +157,8 @@ def get_find_raw(
) -> tuple[float, XyCoordsT]:
"""
Find an image within a region of the screen or image, and return raw result.
Immediate function, wrap in lambda if setting as action of Tap.
:param target: what to find. Path to an image, or image object(numpy array).
:param bbox: bounding box of where to search in the outer.
:param outer: Optional image in which to find, path or numpy array. If not specified, will search on screen.
Expand All @@ -169,11 +168,11 @@ def get_find_raw(
return _find_util.api_find_raw(target, bbox, outer)


# todo add param bool to overwrite existing on save to disk / add (2) etc
def snip(
prefix: str | None = "snip",
bbox_to_name: bool = True,
bbox_callback: Callable[[int, int, int, int], Any] | None = None,
override_existing: bool = True,
bbox_callback: Callable[[tuple[int, int, int, int]], Any] | None = None,
picture_callback: Callable[[ImagePixelMatrixT], Any] | None = None,
) -> Callable[[], None]:
"""
Expand All @@ -184,8 +183,9 @@ def snip(
has to be a path, absolute or relative to that dir.
:param bbox_to_name: If true, will include in the name "-(BBOX_{x1}_{y1}_{x2}_{y2})", with actual coordinates.
This is useful for precise-position search with `find` and `wait_for` methods.
:param override_existing: Will override existing file if prefix exists, otherwise will save as prefix(2).png
:param bbox_callback: Action to be applied to bbox coordinates when snip is taken.
This is an alternative to bbox_in_name, if you want to supply it separately later.
This is an alternative to bbox_to_name, if you want to supply it separately later.
:param picture_callback: Action to be applied to the array of resulting picture RGB.
:return: callable toggle, to be set into a Tap
Expand All @@ -194,19 +194,25 @@ def snip(
Mouseover a corner of desired snip, click "a", mouseover diagonal corner, click "a",
and you'll get an image with default name and bounding box in the name in the working dir of the script.
{"a": img.snip("image", False, pyperclip.copy)}
Same procedure to get an image, but this will be called "image.png" without bounding box in the name,
instead it will be copied to your clipboard. Package pyperclip if required for this.
{"a": img.snip(prefix=None, bbox_callback=pyperclip.copy)}
This will only copy bounding box to your clipboard. Package pyperclip if required for this.
"""
_check_dependencies()
return partial(
_image_util.toggle_snip, prefix, bbox_to_name, bbox_callback, picture_callback
_snip_util.toggle_snip,
prefix=prefix,
bbox_to_name=bbox_to_name,
override_existing=override_existing,
bbox_callback=bbox_callback,
picture_callback=picture_callback,
)


def get_snip(
bbox: BboxT | None,
prefix: str | None = None,
bbox_in_name: bool = True,
bbox_to_name: bool = True,
override_existing: bool = True,
) -> ImagePixelMatrixT:
"""
Screenshot with specified bounding box, or entire screen. Optionally saves to disk.
Expand All @@ -216,15 +222,21 @@ def get_snip(
:param bbox: Bounding box of the screenshot or image. If None, the whole screen or image is snipped.
:param prefix: Optional name, may be a path of image to save, without extension. If not specified,
will not be saved to disk.
:param bbox_in_name: If true, will append to the name -(BBOX_{x1}_{y1}_{x2}_{y2}), with corner coordinates.
:param bbox_to_name: If true, will append to the name "-BBOX({x1},{y1},{x2},{y2})", with corner coordinates.
:param override_existing: Will override existing file if prefix exists, otherwise will save as prefix(2).png
:return: Resulting image RGB, transformed to numpy array.
Usage:
my_pic = img.get_snip(bbox=(100, 100, 200, 400))
...
img.wait_for(my_pic)
"""
return _image_util.finish_snip(prefix, bbox, bbox_in_name)[0]
return _snip_util.finish_snip(
bbox=bbox,
prefix=prefix,
bbox_to_name=bbox_to_name,
override_existing=override_existing,
)[0]


def pixel_info(
Expand Down
4 changes: 2 additions & 2 deletions tests/tapper/helper/image/test_img.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@
def mock_get_sct() -> None:
with patch(
"tapper.helper._util.image.base.get_screenshot_if_none_and_cut"
) as mock_get_sct:
yield mock_get_sct
) as mock_sct:
yield mock_sct


class TestFind:
Expand Down
98 changes: 94 additions & 4 deletions tests/tapper/helper/image/test_snip.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,97 @@
import os
import random
import shutil
from pathlib import Path
from string import ascii_uppercase
from unittest.mock import call
from unittest.mock import MagicMock
from unittest.mock import patch

import img_test_util
import numpy
import pytest
from tapper.helper import img

absolutes = img_test_util.absolutes()


@pytest.fixture
def mock_get_sct() -> MagicMock:
with patch(
"tapper.helper._util.image.base.get_screenshot_if_none_and_cut"
) as mock_sct:
yield mock_sct


@pytest.fixture
def temp_dir(tmpdir_factory) -> Path:
temp_name = "".join(random.choice(ascii_uppercase) for i in range(12))
my_tmpdir = tmpdir_factory.mktemp(temp_name)
yield my_tmpdir
shutil.rmtree(str(my_tmpdir))


@pytest.fixture
def mock_save_to_disk() -> MagicMock:
with patch("tapper.helper._util.image.base.save_to_disk") as mock_save:
yield mock_save


@pytest.fixture
def mock_mouse_pos() -> MagicMock:
with patch("tapper.mouse.get_pos") as mock_mouse_get_pos:
yield mock_mouse_get_pos


class TestSnip:
def test_simplest(self) -> None:
pass
def test_simplest(self, mock_get_sct) -> None:
mock_get_sct.return_value = absolutes
snipped = img.get_snip(None)
assert numpy.array_equal(snipped, absolutes)

def test_saved_image_same_as_on_disk(self, temp_dir, mock_get_sct) -> None:
mock_get_sct.return_value = absolutes
get_name = lambda name: str(Path(temp_dir / name))
img.get_snip(bbox=None, prefix=get_name("qwe"))
on_disk = img.from_path(get_name("qwe.png"), cache=False)
assert numpy.array_equal(on_disk, absolutes)

def test_bbox_to_name(self, mock_get_sct, mock_save_to_disk) -> None:
mock_get_sct.return_value = absolutes
img.get_snip(bbox=(0, 0, 20, 20), prefix="qwe", bbox_to_name=True)
assert mock_save_to_disk.call_count == 1
assert mock_save_to_disk.call_args == call(absolutes, "qwe-BBOX(0,0,20,20).png")

def test_no_override_creates_different_file(self, temp_dir, mock_get_sct) -> None:
mock_get_sct.return_value = absolutes
get_name = lambda name: str(Path(temp_dir / name))
img.get_snip(bbox=None, prefix=get_name("qwe"))
on_disk_0 = img.from_path(get_name("qwe.png"), cache=False)
img.get_snip(bbox=None, prefix=get_name("qwe"), override_existing=False)
on_disk_1 = img.from_path(get_name("qwe(1).png"), cache=False)
assert numpy.array_equal(on_disk_0, on_disk_1)
assert not os.path.exists(get_name("qwe(2).png"))

def test_override(self, temp_dir, mock_get_sct) -> None:
get_name = lambda name: str(Path(temp_dir / name))
mock_get_sct.return_value = absolutes
img.get_snip(bbox=None, prefix=get_name("qwe"))
on_disk_0 = img.from_path(get_name("qwe.png"), cache=False)

mock_get_sct.return_value = img_test_util.btn_yellow()
img.get_snip(bbox=None, prefix=get_name("qwe"), override_existing=True)
on_disk_1 = img.from_path(get_name("qwe.png"), cache=False)
assert not os.path.exists(get_name("qwe(1).png"))
assert len([name for name in os.listdir(temp_dir)]) == 1
assert numpy.array_equal(on_disk_0, absolutes)
assert numpy.array_equal(on_disk_1, img_test_util.btn_yellow())

def test_saved_image_same_as_on_disk(self) -> None:
pass
def test_bbox_is_correct(
self, mock_get_sct, mock_save_to_disk, mock_mouse_pos
) -> None:
snip_fn = img.snip()
mock_mouse_pos.return_value = 100, 450
snip_fn()
mock_mouse_pos.return_value = 300, 0
snip_fn()
assert mock_get_sct.call_args == call(None, (100, 0, 300, 450))

0 comments on commit 555c6cb

Please sign in to comment.