pyvisionauto 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pyvisionauto/__init__.py +36 -0
- pyvisionauto/config.py +23 -0
- pyvisionauto/envcheck.py +87 -0
- pyvisionauto/errors.py +26 -0
- pyvisionauto/highlighter.py +74 -0
- pyvisionauto/input.py +103 -0
- pyvisionauto/models.py +77 -0
- pyvisionauto/overlay.py +51 -0
- pyvisionauto/py.typed +0 -0
- pyvisionauto/recorder.py +61 -0
- pyvisionauto/screen.py +457 -0
- pyvisionauto/vision.py +196 -0
- pyvisionauto-0.1.0.dist-info/METADATA +64 -0
- pyvisionauto-0.1.0.dist-info/RECORD +17 -0
- pyvisionauto-0.1.0.dist-info/WHEEL +5 -0
- pyvisionauto-0.1.0.dist-info/licenses/LICENSE +8 -0
- pyvisionauto-0.1.0.dist-info/top_level.txt +1 -0
pyvisionauto/__init__.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from .config import DEFAULT_CONFIDENCE, DEFAULT_POLL_INTERVAL
|
|
2
|
+
from .envcheck import EnvCheck
|
|
3
|
+
from .errors import (
|
|
4
|
+
EnvironmentNotSupportedError,
|
|
5
|
+
OverlayError,
|
|
6
|
+
PyVisionAutoError,
|
|
7
|
+
RecorderError,
|
|
8
|
+
TemplateNotFoundError,
|
|
9
|
+
VanishTimeoutError,
|
|
10
|
+
WaitTimeoutError,
|
|
11
|
+
)
|
|
12
|
+
from .highlighter import Highlighter
|
|
13
|
+
from .input import Input
|
|
14
|
+
from .models import EnvironmentReport, Match, TimingProfile
|
|
15
|
+
from .recorder import Recorder
|
|
16
|
+
from .screen import Screen
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"DEFAULT_CONFIDENCE",
|
|
20
|
+
"DEFAULT_POLL_INTERVAL",
|
|
21
|
+
"EnvCheck",
|
|
22
|
+
"EnvironmentNotSupportedError",
|
|
23
|
+
"EnvironmentReport",
|
|
24
|
+
"Highlighter",
|
|
25
|
+
"Input",
|
|
26
|
+
"Match",
|
|
27
|
+
"OverlayError",
|
|
28
|
+
"PyVisionAutoError",
|
|
29
|
+
"Recorder",
|
|
30
|
+
"RecorderError",
|
|
31
|
+
"Screen",
|
|
32
|
+
"TemplateNotFoundError",
|
|
33
|
+
"TimingProfile",
|
|
34
|
+
"VanishTimeoutError",
|
|
35
|
+
"WaitTimeoutError",
|
|
36
|
+
]
|
pyvisionauto/config.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from .models import TimingProfile
|
|
2
|
+
|
|
3
|
+
DEFAULT_CONFIDENCE = 0.88
|
|
4
|
+
DEFAULT_POLL_INTERVAL = 0.5
|
|
5
|
+
|
|
6
|
+
HIGHLIGHT_ENABLED = True
|
|
7
|
+
HIGHLIGHT_COLOR = "#ff0000"
|
|
8
|
+
HIGHLIGHT_THICKNESS = 3
|
|
9
|
+
HIGHLIGHT_DURATION_MS = 700
|
|
10
|
+
|
|
11
|
+
DEFAULT_TIMING = TimingProfile(
|
|
12
|
+
typing_delay_min=0.04,
|
|
13
|
+
typing_delay_max=0.12,
|
|
14
|
+
special_char_extra_delay=0.03,
|
|
15
|
+
key_press_delay_min=0.01,
|
|
16
|
+
key_press_delay_max=0.04,
|
|
17
|
+
key_post_delay_min=0.03,
|
|
18
|
+
key_post_delay_max=0.08,
|
|
19
|
+
hotkey_gap_min=0.02,
|
|
20
|
+
hotkey_gap_max=0.06,
|
|
21
|
+
human_like_default=True,
|
|
22
|
+
deterministic_mode=False,
|
|
23
|
+
)
|
pyvisionauto/envcheck.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import platform
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
|
|
8
|
+
from .errors import EnvironmentNotSupportedError
|
|
9
|
+
from .models import EnvironmentReport
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class EnvCheck:
|
|
13
|
+
"""Validate runtime prerequisites for PyVisionAuto on Linux desktops."""
|
|
14
|
+
|
|
15
|
+
def check(self, strict: bool = True) -> EnvironmentReport:
|
|
16
|
+
"""Run platform and dependency checks.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
strict: If ``True``, raise when environment is not supported.
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
Structured environment report.
|
|
23
|
+
|
|
24
|
+
Raises:
|
|
25
|
+
EnvironmentNotSupportedError: If ``strict=True`` and checks fail.
|
|
26
|
+
"""
|
|
27
|
+
is_linux = platform.system().lower() == "linux"
|
|
28
|
+
has_display = bool(os.environ.get("DISPLAY"))
|
|
29
|
+
|
|
30
|
+
x11_ok = False
|
|
31
|
+
if has_display:
|
|
32
|
+
try:
|
|
33
|
+
out = subprocess.run(
|
|
34
|
+
["sh", "-lc", "echo ${XDG_SESSION_TYPE:-unknown}"],
|
|
35
|
+
capture_output=True,
|
|
36
|
+
text=True,
|
|
37
|
+
check=False,
|
|
38
|
+
)
|
|
39
|
+
x11_ok = out.stdout.strip().lower() in {"x11", "unknown"}
|
|
40
|
+
except Exception:
|
|
41
|
+
x11_ok = False
|
|
42
|
+
|
|
43
|
+
tk_ok = True
|
|
44
|
+
try:
|
|
45
|
+
import tkinter # noqa: F401
|
|
46
|
+
except Exception:
|
|
47
|
+
tk_ok = False
|
|
48
|
+
|
|
49
|
+
xdotool_ok = shutil.which("xdotool") is not None
|
|
50
|
+
wmctrl_ok = shutil.which("wmctrl") is not None
|
|
51
|
+
ibus_ok = shutil.which("ibus") is not None
|
|
52
|
+
ffmpeg_ok = shutil.which("ffmpeg") is not None
|
|
53
|
+
|
|
54
|
+
messages: list[str] = []
|
|
55
|
+
if not is_linux:
|
|
56
|
+
messages.append("Only Linux is supported in v0.1")
|
|
57
|
+
if not has_display:
|
|
58
|
+
messages.append("DISPLAY is missing")
|
|
59
|
+
if not x11_ok:
|
|
60
|
+
messages.append("X11 session not detected")
|
|
61
|
+
if not tk_ok:
|
|
62
|
+
messages.append("tkinter is unavailable (install python3-tk)")
|
|
63
|
+
if not (xdotool_ok or wmctrl_ok):
|
|
64
|
+
messages.append("Neither xdotool nor wmctrl is installed")
|
|
65
|
+
|
|
66
|
+
supported = is_linux and has_display and x11_ok and tk_ok and (xdotool_ok or wmctrl_ok)
|
|
67
|
+
report = EnvironmentReport(
|
|
68
|
+
is_supported=supported,
|
|
69
|
+
platform_ok=is_linux,
|
|
70
|
+
display_ok=has_display,
|
|
71
|
+
x11_ok=x11_ok,
|
|
72
|
+
tk_ok=tk_ok,
|
|
73
|
+
xdotool_ok=xdotool_ok,
|
|
74
|
+
wmctrl_ok=wmctrl_ok,
|
|
75
|
+
ibus_ok=ibus_ok,
|
|
76
|
+
ffmpeg_ok=ffmpeg_ok,
|
|
77
|
+
messages=messages,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
if strict and not report.is_supported:
|
|
81
|
+
raise EnvironmentNotSupportedError("; ".join(report.messages) or "Unsupported environment")
|
|
82
|
+
return report
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def check_env(strict: bool = True) -> EnvironmentReport:
|
|
86
|
+
"""Convenience wrapper for :meth:`EnvCheck.check`."""
|
|
87
|
+
return EnvCheck().check(strict=strict)
|
pyvisionauto/errors.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
class PyVisionAutoError(Exception):
|
|
2
|
+
"""Base exception for PyVisionAuto."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class TemplateNotFoundError(PyVisionAutoError):
|
|
6
|
+
"""Raised when an image template file path does not exist."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class WaitTimeoutError(PyVisionAutoError):
|
|
10
|
+
"""Raised when waiting for a match times out."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class VanishTimeoutError(PyVisionAutoError):
|
|
14
|
+
"""Raised when waiting for an image to vanish times out."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class EnvironmentNotSupportedError(PyVisionAutoError):
|
|
18
|
+
"""Raised when runtime prerequisites are not met."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class OverlayError(PyVisionAutoError):
|
|
22
|
+
"""Raised when highlight overlay cannot be rendered."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class RecorderError(PyVisionAutoError):
|
|
26
|
+
"""Raised when recording actions fail."""
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .config import HIGHLIGHT_COLOR, HIGHLIGHT_DURATION_MS, HIGHLIGHT_THICKNESS
|
|
9
|
+
from .errors import OverlayError
|
|
10
|
+
from .models import Match
|
|
11
|
+
|
|
12
|
+
LOGGER = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Highlighter:
|
|
16
|
+
"""Overlay helper used to draw temporary highlight borders for matches."""
|
|
17
|
+
|
|
18
|
+
def __init__(self) -> None:
|
|
19
|
+
"""Initialize overlay script path."""
|
|
20
|
+
self._overlay_script = Path(__file__).with_name("overlay.py")
|
|
21
|
+
|
|
22
|
+
def show(
|
|
23
|
+
self,
|
|
24
|
+
match: Match,
|
|
25
|
+
duration_ms: int | None = None,
|
|
26
|
+
color: str | None = None,
|
|
27
|
+
thickness: int | None = None,
|
|
28
|
+
) -> None:
|
|
29
|
+
"""Spawn overlay process to highlight one match rectangle.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
match: Match rectangle to visualize.
|
|
33
|
+
duration_ms: Overlay lifetime in milliseconds. ``None`` uses default.
|
|
34
|
+
color: Border color. ``None`` uses default.
|
|
35
|
+
thickness: Border width in pixels. ``None`` uses default.
|
|
36
|
+
|
|
37
|
+
Raises:
|
|
38
|
+
OverlayError: If the overlay process cannot be started.
|
|
39
|
+
"""
|
|
40
|
+
resolved_duration = duration_ms if duration_ms is not None else HIGHLIGHT_DURATION_MS
|
|
41
|
+
resolved_color = color if color is not None else HIGHLIGHT_COLOR
|
|
42
|
+
resolved_thickness = thickness if thickness is not None else HIGHLIGHT_THICKNESS
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
subprocess.Popen(
|
|
46
|
+
[
|
|
47
|
+
sys.executable,
|
|
48
|
+
str(self._overlay_script),
|
|
49
|
+
str(match.x),
|
|
50
|
+
str(match.y),
|
|
51
|
+
str(match.w),
|
|
52
|
+
str(match.h),
|
|
53
|
+
str(resolved_duration),
|
|
54
|
+
resolved_color,
|
|
55
|
+
str(resolved_thickness),
|
|
56
|
+
],
|
|
57
|
+
stdout=subprocess.DEVNULL,
|
|
58
|
+
stderr=subprocess.DEVNULL,
|
|
59
|
+
)
|
|
60
|
+
except Exception as exc: # pragma: no cover
|
|
61
|
+
raise OverlayError(str(exc)) from exc
|
|
62
|
+
|
|
63
|
+
def safe_show(
|
|
64
|
+
self,
|
|
65
|
+
match: Match,
|
|
66
|
+
duration_ms: int | None = None,
|
|
67
|
+
color: str | None = None,
|
|
68
|
+
thickness: int | None = None,
|
|
69
|
+
) -> None:
|
|
70
|
+
"""Best-effort highlight wrapper that never interrupts user actions."""
|
|
71
|
+
try:
|
|
72
|
+
self.show(match, duration_ms=duration_ms, color=color, thickness=thickness)
|
|
73
|
+
except OverlayError as exc:
|
|
74
|
+
LOGGER.warning("Highlight failed but action continues: %s", exc)
|
pyvisionauto/input.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
import random
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
from .config import DEFAULT_TIMING
|
|
8
|
+
from .models import TimingProfile
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Input:
|
|
12
|
+
"""Keyboard text/input helper with optional human-like timing behavior."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, timing: TimingProfile | None = None, deterministic: bool | None = None) -> None:
|
|
15
|
+
"""Create input helper.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
timing: Timing profile. ``None`` uses ``DEFAULT_TIMING``.
|
|
19
|
+
deterministic: Override deterministic mode. ``None`` follows timing profile.
|
|
20
|
+
"""
|
|
21
|
+
self.timing = timing or DEFAULT_TIMING
|
|
22
|
+
self.deterministic = self.timing.deterministic_mode if deterministic is None else deterministic
|
|
23
|
+
|
|
24
|
+
def _get_pyautogui(self):
|
|
25
|
+
"""Lazily import pyautogui to reduce module import constraints."""
|
|
26
|
+
return importlib.import_module("pyautogui")
|
|
27
|
+
|
|
28
|
+
def _pause(self, low: float, high: float) -> None:
|
|
29
|
+
"""Sleep for a delay interval, deterministic or random based on settings."""
|
|
30
|
+
if self.deterministic:
|
|
31
|
+
time.sleep((low + high) / 2.0)
|
|
32
|
+
return
|
|
33
|
+
time.sleep(random.uniform(low, high))
|
|
34
|
+
|
|
35
|
+
def type_text(
|
|
36
|
+
self,
|
|
37
|
+
text: str,
|
|
38
|
+
human_like: bool | None = None,
|
|
39
|
+
delay_min: float | None = None,
|
|
40
|
+
delay_max: float | None = None,
|
|
41
|
+
) -> None:
|
|
42
|
+
"""Type text into the active UI control.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
text: Text content to type.
|
|
46
|
+
human_like: Whether to use per-character delays. ``None`` uses timing default.
|
|
47
|
+
delay_min: Minimum delay between characters in seconds.
|
|
48
|
+
delay_max: Maximum delay between characters in seconds.
|
|
49
|
+
"""
|
|
50
|
+
pyautogui = self._get_pyautogui()
|
|
51
|
+
use_human = self.timing.human_like_default if human_like is None else human_like
|
|
52
|
+
if not use_human:
|
|
53
|
+
pyautogui.write(text)
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
low = self.timing.typing_delay_min if delay_min is None else delay_min
|
|
57
|
+
high = self.timing.typing_delay_max if delay_max is None else delay_max
|
|
58
|
+
|
|
59
|
+
for ch in text:
|
|
60
|
+
pyautogui.write(ch)
|
|
61
|
+
self._pause(low, high)
|
|
62
|
+
if ch in "/-_.":
|
|
63
|
+
time.sleep(self.timing.special_char_extra_delay)
|
|
64
|
+
|
|
65
|
+
def press(self, key: str, human_like: bool | None = None) -> None:
|
|
66
|
+
"""Press one key.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
key: Key name understood by pyautogui (for example ``"enter"``).
|
|
70
|
+
human_like: Whether to apply pre/post action delays.
|
|
71
|
+
"""
|
|
72
|
+
pyautogui = self._get_pyautogui()
|
|
73
|
+
use_human = self.timing.human_like_default if human_like is None else human_like
|
|
74
|
+
if use_human:
|
|
75
|
+
self._pause(self.timing.key_press_delay_min, self.timing.key_press_delay_max)
|
|
76
|
+
pyautogui.press(key)
|
|
77
|
+
if use_human:
|
|
78
|
+
self._pause(self.timing.key_post_delay_min, self.timing.key_post_delay_max)
|
|
79
|
+
|
|
80
|
+
def hotkey(self, *keys: str, human_like: bool | None = None) -> None:
|
|
81
|
+
"""Send a key combination.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
*keys: Ordered key sequence, for example ``("ctrl", "v")``.
|
|
85
|
+
human_like: Whether to apply delay between key down/up events.
|
|
86
|
+
"""
|
|
87
|
+
pyautogui = self._get_pyautogui()
|
|
88
|
+
use_human = self.timing.human_like_default if human_like is None else human_like
|
|
89
|
+
if not use_human:
|
|
90
|
+
pyautogui.hotkey(*keys)
|
|
91
|
+
return
|
|
92
|
+
|
|
93
|
+
for key in keys:
|
|
94
|
+
pyautogui.keyDown(key)
|
|
95
|
+
self._pause(self.timing.hotkey_gap_min, self.timing.hotkey_gap_max)
|
|
96
|
+
for key in reversed(keys):
|
|
97
|
+
pyautogui.keyUp(key)
|
|
98
|
+
self._pause(self.timing.hotkey_gap_min, self.timing.hotkey_gap_max)
|
|
99
|
+
|
|
100
|
+
def clear_text(self) -> None:
|
|
101
|
+
"""Clear active input field using Ctrl+A followed by Backspace."""
|
|
102
|
+
self.hotkey("ctrl", "a")
|
|
103
|
+
self.press("backspace")
|
pyvisionauto/models.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass(frozen=True)
|
|
7
|
+
class Match:
|
|
8
|
+
"""Rectangle and confidence returned by template matching.
|
|
9
|
+
|
|
10
|
+
Attributes:
|
|
11
|
+
x: Absolute screen X of the matched top-left corner.
|
|
12
|
+
y: Absolute screen Y of the matched top-left corner.
|
|
13
|
+
w: Matched width in pixels.
|
|
14
|
+
h: Matched height in pixels.
|
|
15
|
+
score: Normalized confidence score in [0.0, 1.0].
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
x: int
|
|
19
|
+
y: int
|
|
20
|
+
w: int
|
|
21
|
+
h: int
|
|
22
|
+
score: float
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def center(self) -> tuple[int, int]:
|
|
26
|
+
"""Return center point ``(x + w // 2, y + h // 2)``."""
|
|
27
|
+
return (self.x + self.w // 2, self.y + self.h // 2)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class TimingProfile:
|
|
32
|
+
"""Timing configuration used by :class:`pyvisionauto.input.Input`."""
|
|
33
|
+
|
|
34
|
+
typing_delay_min: float
|
|
35
|
+
typing_delay_max: float
|
|
36
|
+
special_char_extra_delay: float
|
|
37
|
+
key_press_delay_min: float
|
|
38
|
+
key_press_delay_max: float
|
|
39
|
+
key_post_delay_min: float
|
|
40
|
+
key_post_delay_max: float
|
|
41
|
+
hotkey_gap_min: float
|
|
42
|
+
hotkey_gap_max: float
|
|
43
|
+
human_like_default: bool = True
|
|
44
|
+
deterministic_mode: bool = False
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class EnvironmentReport:
|
|
49
|
+
"""Result container for environment compatibility checks."""
|
|
50
|
+
|
|
51
|
+
is_supported: bool
|
|
52
|
+
platform_ok: bool
|
|
53
|
+
display_ok: bool
|
|
54
|
+
x11_ok: bool
|
|
55
|
+
tk_ok: bool
|
|
56
|
+
xdotool_ok: bool
|
|
57
|
+
wmctrl_ok: bool
|
|
58
|
+
ibus_ok: bool
|
|
59
|
+
ffmpeg_ok: bool
|
|
60
|
+
messages: list[str] = field(default_factory=list)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class Region:
|
|
65
|
+
"""Search/capture rectangle in absolute screen coordinates.
|
|
66
|
+
|
|
67
|
+
Attributes:
|
|
68
|
+
x: Absolute screen X of the region top-left corner.
|
|
69
|
+
y: Absolute screen Y of the region top-left corner.
|
|
70
|
+
w: Region width in pixels.
|
|
71
|
+
h: Region height in pixels.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
x: int
|
|
75
|
+
y: int
|
|
76
|
+
w: int
|
|
77
|
+
h: int
|
pyvisionauto/overlay.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
import tkinter as tk
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def main() -> int:
|
|
8
|
+
if len(sys.argv) != 8:
|
|
9
|
+
return 2
|
|
10
|
+
|
|
11
|
+
x = int(sys.argv[1])
|
|
12
|
+
y = int(sys.argv[2])
|
|
13
|
+
w = int(sys.argv[3])
|
|
14
|
+
h = int(sys.argv[4])
|
|
15
|
+
duration_ms = int(sys.argv[5])
|
|
16
|
+
color = sys.argv[6]
|
|
17
|
+
thickness = int(sys.argv[7])
|
|
18
|
+
|
|
19
|
+
# Keep center visually transparent by drawing border only on a canvas.
|
|
20
|
+
root = tk.Tk()
|
|
21
|
+
root.overrideredirect(True)
|
|
22
|
+
root.attributes("-topmost", True)
|
|
23
|
+
root.geometry(f"{w}x{h}+{x}+{y}")
|
|
24
|
+
|
|
25
|
+
bg = "black"
|
|
26
|
+
root.configure(bg=bg)
|
|
27
|
+
try:
|
|
28
|
+
root.wm_attributes("-transparentcolor", bg)
|
|
29
|
+
except tk.TclError:
|
|
30
|
+
# Some X11 window managers do not support transparentcolor.
|
|
31
|
+
root.attributes("-alpha", 0.35)
|
|
32
|
+
|
|
33
|
+
canvas = tk.Canvas(root, width=w, height=h, highlightthickness=0, bg=bg)
|
|
34
|
+
canvas.pack(fill="both", expand=True)
|
|
35
|
+
inset = max(1, thickness // 2)
|
|
36
|
+
canvas.create_rectangle(
|
|
37
|
+
inset,
|
|
38
|
+
inset,
|
|
39
|
+
max(inset + 1, w - inset),
|
|
40
|
+
max(inset + 1, h - inset),
|
|
41
|
+
outline=color,
|
|
42
|
+
width=thickness,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
root.after(duration_ms, root.destroy)
|
|
46
|
+
root.mainloop()
|
|
47
|
+
return 0
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
if __name__ == "__main__":
|
|
51
|
+
raise SystemExit(main())
|
pyvisionauto/py.typed
ADDED
|
File without changes
|
pyvisionauto/recorder.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .errors import RecorderError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Recorder:
|
|
12
|
+
"""Simple ffmpeg-based screen recorder for X11 sessions."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, display: str | None = None) -> None:
|
|
15
|
+
"""Create recorder.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
display: X11 display string (for example ``":0"``). ``None`` reads ``DISPLAY``.
|
|
19
|
+
"""
|
|
20
|
+
self.display = display or os.environ.get("DISPLAY", ":0")
|
|
21
|
+
self._proc: subprocess.Popen[bytes] | None = None
|
|
22
|
+
|
|
23
|
+
def start(self, output_file: str | Path, fps: int = 15) -> None:
|
|
24
|
+
"""Start recording the desktop.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
output_file: Video file output path.
|
|
28
|
+
fps: Capture frame rate.
|
|
29
|
+
|
|
30
|
+
Raises:
|
|
31
|
+
RecorderError: If ffmpeg is missing or recorder is already running.
|
|
32
|
+
"""
|
|
33
|
+
if shutil.which("ffmpeg") is None:
|
|
34
|
+
raise RecorderError("ffmpeg is required for recording APIs")
|
|
35
|
+
if self._proc is not None and self._proc.poll() is None:
|
|
36
|
+
raise RecorderError("Recorder is already running")
|
|
37
|
+
|
|
38
|
+
out = str(output_file)
|
|
39
|
+
cmd = [
|
|
40
|
+
"ffmpeg",
|
|
41
|
+
"-y",
|
|
42
|
+
"-video_size",
|
|
43
|
+
"1920x1080",
|
|
44
|
+
"-framerate",
|
|
45
|
+
str(fps),
|
|
46
|
+
"-f",
|
|
47
|
+
"x11grab",
|
|
48
|
+
"-i",
|
|
49
|
+
self.display,
|
|
50
|
+
out,
|
|
51
|
+
]
|
|
52
|
+
self._proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
53
|
+
|
|
54
|
+
def stop(self) -> None:
|
|
55
|
+
"""Stop recording if active. Safe to call repeatedly."""
|
|
56
|
+
if self._proc is None:
|
|
57
|
+
return
|
|
58
|
+
if self._proc.poll() is None:
|
|
59
|
+
self._proc.terminate()
|
|
60
|
+
self._proc.wait(timeout=3)
|
|
61
|
+
self._proc = None
|
pyvisionauto/screen.py
ADDED
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
import logging
|
|
5
|
+
import subprocess
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from .config import HIGHLIGHT_ENABLED
|
|
11
|
+
from .envcheck import check_env
|
|
12
|
+
from .highlighter import Highlighter
|
|
13
|
+
from .input import Input
|
|
14
|
+
from .models import Match, Region
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from .vision import Vision
|
|
18
|
+
|
|
19
|
+
LOGGER = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _get_pyautogui():
|
|
23
|
+
"""Lazily import pyautogui to keep module import cross-platform friendly."""
|
|
24
|
+
return importlib.import_module("pyautogui")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class MatchHandle:
|
|
29
|
+
"""Chainable handle around a resolved image match.
|
|
30
|
+
|
|
31
|
+
A handle stores the matched rectangle and allows fluent follow-up actions
|
|
32
|
+
such as highlight, click, hover, and waiting for disappearance.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
screen: "Screen"
|
|
36
|
+
image: str
|
|
37
|
+
match: Match
|
|
38
|
+
region: Region | None = None
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def center(self) -> tuple[int, int]:
|
|
42
|
+
"""Return the match center in absolute screen coordinates."""
|
|
43
|
+
return self.match.center
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def score(self) -> float:
|
|
47
|
+
"""Return normalized match confidence in the range [0.0, 1.0]."""
|
|
48
|
+
return self.match.score
|
|
49
|
+
|
|
50
|
+
def highlight(
|
|
51
|
+
self,
|
|
52
|
+
duration_ms: int | None = None,
|
|
53
|
+
color: str | None = None,
|
|
54
|
+
thickness: int | None = None,
|
|
55
|
+
) -> "MatchHandle":
|
|
56
|
+
"""Render a temporary highlight overlay around the current match.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
duration_ms: Overlay lifetime in milliseconds. ``None`` uses config default.
|
|
60
|
+
color: Border color string (for example ``"#ff0000"``). ``None`` uses default.
|
|
61
|
+
thickness: Border thickness in pixels. ``None`` uses default.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
The current handle for method chaining.
|
|
65
|
+
"""
|
|
66
|
+
self.screen._highlight_match(self.match, duration_ms=duration_ms, color=color, thickness=thickness)
|
|
67
|
+
return self
|
|
68
|
+
|
|
69
|
+
def click(self, button: str = "left", highlight: bool = True) -> "MatchHandle":
|
|
70
|
+
"""Click at the current match center.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
button: Mouse button name accepted by pyautogui (``"left"``, ``"right"``, etc.).
|
|
74
|
+
highlight: Whether to show highlight overlay before clicking.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
The current handle for method chaining.
|
|
78
|
+
"""
|
|
79
|
+
if highlight:
|
|
80
|
+
self.highlight()
|
|
81
|
+
x, y = self.match.center
|
|
82
|
+
pyautogui = _get_pyautogui()
|
|
83
|
+
pyautogui.click(x=x, y=y, button=button)
|
|
84
|
+
return self
|
|
85
|
+
|
|
86
|
+
def double_click(self, highlight: bool = True) -> "MatchHandle":
|
|
87
|
+
"""Double-click at the current match center.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
highlight: Whether to show highlight overlay before the action.
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
The current handle for method chaining.
|
|
94
|
+
"""
|
|
95
|
+
if highlight:
|
|
96
|
+
self.highlight()
|
|
97
|
+
x, y = self.match.center
|
|
98
|
+
pyautogui = _get_pyautogui()
|
|
99
|
+
pyautogui.doubleClick(x=x, y=y)
|
|
100
|
+
return self
|
|
101
|
+
|
|
102
|
+
def right_click(self, highlight: bool = True) -> "MatchHandle":
|
|
103
|
+
"""Right-click at the current match center.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
highlight: Whether to show highlight overlay before the action.
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
The current handle for method chaining.
|
|
110
|
+
"""
|
|
111
|
+
return self.click(button="right", highlight=highlight)
|
|
112
|
+
|
|
113
|
+
def hover(self, highlight: bool = True) -> "MatchHandle":
|
|
114
|
+
"""Move the pointer to the current match center.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
highlight: Whether to show highlight overlay before pointer move.
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
The current handle for method chaining.
|
|
121
|
+
"""
|
|
122
|
+
if highlight:
|
|
123
|
+
self.highlight()
|
|
124
|
+
x, y = self.match.center
|
|
125
|
+
pyautogui = _get_pyautogui()
|
|
126
|
+
pyautogui.moveTo(x, y)
|
|
127
|
+
return self
|
|
128
|
+
|
|
129
|
+
def wait_vanish(
|
|
130
|
+
self,
|
|
131
|
+
timeout: float,
|
|
132
|
+
confidence: float | None = None,
|
|
133
|
+
poll: float | None = None,
|
|
134
|
+
strict: bool = True,
|
|
135
|
+
) -> bool:
|
|
136
|
+
"""Wait until this handle's image is no longer detected.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
timeout: Max wait duration in seconds.
|
|
140
|
+
confidence: Match threshold. ``None`` uses configured default.
|
|
141
|
+
poll: Poll interval in seconds. ``None`` uses configured default.
|
|
142
|
+
strict: If ``True``, raise on timeout. If ``False``, return ``False``.
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
``True`` if vanished before timeout; ``False`` only when ``strict=False``.
|
|
146
|
+
"""
|
|
147
|
+
return self.screen.wait_vanish(
|
|
148
|
+
self.image,
|
|
149
|
+
timeout=timeout,
|
|
150
|
+
confidence=confidence,
|
|
151
|
+
poll=poll,
|
|
152
|
+
region=self.region,
|
|
153
|
+
strict=strict,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class RegionScreen:
|
|
158
|
+
"""A Screen proxy that automatically scopes all operations to one region."""
|
|
159
|
+
|
|
160
|
+
def __init__(self, screen: "Screen", region: Region) -> None:
|
|
161
|
+
self._screen = screen
|
|
162
|
+
self._region = region
|
|
163
|
+
|
|
164
|
+
def find(self, image: str | Path, confidence: float | None = None) -> MatchHandle | None:
|
|
165
|
+
"""Find one image inside this pre-scoped region."""
|
|
166
|
+
return self._screen.find(image=image, confidence=confidence, region=self._region)
|
|
167
|
+
|
|
168
|
+
def wait(
|
|
169
|
+
self,
|
|
170
|
+
image: str | Path,
|
|
171
|
+
timeout: float,
|
|
172
|
+
confidence: float | None = None,
|
|
173
|
+
poll: float | None = None,
|
|
174
|
+
) -> MatchHandle:
|
|
175
|
+
"""Wait for an image to appear inside this pre-scoped region."""
|
|
176
|
+
return self._screen.wait(
|
|
177
|
+
image=image,
|
|
178
|
+
timeout=timeout,
|
|
179
|
+
confidence=confidence,
|
|
180
|
+
poll=poll,
|
|
181
|
+
region=self._region,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
def click(
|
|
185
|
+
self,
|
|
186
|
+
image: str | Path,
|
|
187
|
+
timeout: float = 10,
|
|
188
|
+
confidence: float | None = None,
|
|
189
|
+
poll: float | None = None,
|
|
190
|
+
highlight: bool = True,
|
|
191
|
+
) -> MatchHandle:
|
|
192
|
+
"""Wait for and click an image inside this pre-scoped region."""
|
|
193
|
+
return self._screen.click(
|
|
194
|
+
image=image,
|
|
195
|
+
timeout=timeout,
|
|
196
|
+
confidence=confidence,
|
|
197
|
+
poll=poll,
|
|
198
|
+
highlight=highlight,
|
|
199
|
+
region=self._region,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
def wait_vanish(
|
|
203
|
+
self,
|
|
204
|
+
image: str | Path,
|
|
205
|
+
timeout: float,
|
|
206
|
+
confidence: float | None = None,
|
|
207
|
+
poll: float | None = None,
|
|
208
|
+
strict: bool = True,
|
|
209
|
+
) -> bool:
|
|
210
|
+
"""Wait for an image to disappear inside this pre-scoped region."""
|
|
211
|
+
return self._screen.wait_vanish(
|
|
212
|
+
image=image,
|
|
213
|
+
timeout=timeout,
|
|
214
|
+
confidence=confidence,
|
|
215
|
+
poll=poll,
|
|
216
|
+
region=self._region,
|
|
217
|
+
strict=strict,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class Screen:
|
|
222
|
+
"""High-level image-driven automation API for Linux X11 desktops."""
|
|
223
|
+
|
|
224
|
+
def __init__(self) -> None:
|
|
225
|
+
from .vision import Vision
|
|
226
|
+
|
|
227
|
+
self.vision = Vision()
|
|
228
|
+
self.highlighter = Highlighter()
|
|
229
|
+
self.input = Input()
|
|
230
|
+
|
|
231
|
+
def region(self, x: int, y: int, w: int, h: int) -> RegionScreen:
|
|
232
|
+
"""Create a region-scoped helper.
|
|
233
|
+
|
|
234
|
+
Args:
|
|
235
|
+
x: Absolute screen X coordinate of the region's top-left corner.
|
|
236
|
+
y: Absolute screen Y coordinate of the region's top-left corner.
|
|
237
|
+
w: Region width in pixels.
|
|
238
|
+
h: Region height in pixels.
|
|
239
|
+
|
|
240
|
+
Returns:
|
|
241
|
+
A ``RegionScreen`` instance that limits find/wait/click operations
|
|
242
|
+
to ``Region(x, y, w, h)``.
|
|
243
|
+
"""
|
|
244
|
+
return RegionScreen(self, Region(x, y, w, h))
|
|
245
|
+
|
|
246
|
+
def _highlight_match(
|
|
247
|
+
self,
|
|
248
|
+
match: Match,
|
|
249
|
+
duration_ms: int | None = None,
|
|
250
|
+
color: str | None = None,
|
|
251
|
+
thickness: int | None = None,
|
|
252
|
+
) -> None:
|
|
253
|
+
"""Display a temporary highlight for one match rectangle."""
|
|
254
|
+
self.highlighter.safe_show(match, duration_ms=duration_ms, color=color, thickness=thickness)
|
|
255
|
+
|
|
256
|
+
@staticmethod
|
|
257
|
+
def _normalize_region(region: Region | tuple[int, int, int, int] | None) -> Region | None:
|
|
258
|
+
"""Convert tuple region values to ``Region`` while preserving ``None``."""
|
|
259
|
+
if region is None:
|
|
260
|
+
return None
|
|
261
|
+
if isinstance(region, Region):
|
|
262
|
+
return region
|
|
263
|
+
x, y, w, h = region
|
|
264
|
+
return Region(int(x), int(y), int(w), int(h))
|
|
265
|
+
|
|
266
|
+
def find(
|
|
267
|
+
self,
|
|
268
|
+
image: str | Path,
|
|
269
|
+
confidence: float | None = None,
|
|
270
|
+
region: Region | tuple[int, int, int, int] | None = None,
|
|
271
|
+
highlight: bool = HIGHLIGHT_ENABLED,
|
|
272
|
+
) -> MatchHandle | None:
|
|
273
|
+
"""Find an image once and return a chainable handle when matched.
|
|
274
|
+
|
|
275
|
+
Args:
|
|
276
|
+
image: Template image path.
|
|
277
|
+
confidence: Match threshold. ``None`` uses configured default.
|
|
278
|
+
region: Search region as ``Region`` or ``(x, y, w, h)`` tuple.
|
|
279
|
+
highlight: Whether to show highlight overlay when matched.
|
|
280
|
+
|
|
281
|
+
Returns:
|
|
282
|
+
``MatchHandle`` when found, otherwise ``None``.
|
|
283
|
+
"""
|
|
284
|
+
normalized_region = self._normalize_region(region)
|
|
285
|
+
match = self.vision.find(image=image, confidence=confidence, region=region)
|
|
286
|
+
if match is None:
|
|
287
|
+
return None
|
|
288
|
+
if highlight:
|
|
289
|
+
self._highlight_match(match)
|
|
290
|
+
return MatchHandle(screen=self, image=str(image), match=match, region=normalized_region)
|
|
291
|
+
|
|
292
|
+
def wait(
|
|
293
|
+
self,
|
|
294
|
+
image: str | Path,
|
|
295
|
+
timeout: float,
|
|
296
|
+
confidence: float | None = None,
|
|
297
|
+
poll: float | None = None,
|
|
298
|
+
region: Region | tuple[int, int, int, int] | None = None,
|
|
299
|
+
highlight: bool = HIGHLIGHT_ENABLED,
|
|
300
|
+
) -> MatchHandle:
|
|
301
|
+
"""Wait until an image appears and return a chainable handle.
|
|
302
|
+
|
|
303
|
+
Args:
|
|
304
|
+
image: Template image path.
|
|
305
|
+
timeout: Max wait duration in seconds.
|
|
306
|
+
confidence: Match threshold. ``None`` uses configured default.
|
|
307
|
+
poll: Poll interval in seconds. ``None`` uses configured default.
|
|
308
|
+
region: Search region as ``Region`` or ``(x, y, w, h)`` tuple.
|
|
309
|
+
highlight: Whether to show highlight overlay on success.
|
|
310
|
+
|
|
311
|
+
Returns:
|
|
312
|
+
A ``MatchHandle`` for fluent actions.
|
|
313
|
+
"""
|
|
314
|
+
normalized_region = self._normalize_region(region)
|
|
315
|
+
match = self.vision.wait(image=image, timeout=timeout, confidence=confidence, poll=poll, region=region)
|
|
316
|
+
if highlight:
|
|
317
|
+
self._highlight_match(match)
|
|
318
|
+
return MatchHandle(screen=self, image=str(image), match=match, region=normalized_region)
|
|
319
|
+
|
|
320
|
+
def wait_vanish(
|
|
321
|
+
self,
|
|
322
|
+
image: str | Path,
|
|
323
|
+
timeout: float,
|
|
324
|
+
confidence: float | None = None,
|
|
325
|
+
poll: float | None = None,
|
|
326
|
+
region: Region | tuple[int, int, int, int] | None = None,
|
|
327
|
+
strict: bool = True,
|
|
328
|
+
) -> bool:
|
|
329
|
+
"""Wait until an image disappears from the screen or region.
|
|
330
|
+
|
|
331
|
+
Args:
|
|
332
|
+
image: Template image path.
|
|
333
|
+
timeout: Max wait duration in seconds.
|
|
334
|
+
confidence: Match threshold. ``None`` uses configured default.
|
|
335
|
+
poll: Poll interval in seconds. ``None`` uses configured default.
|
|
336
|
+
region: Search region as ``Region`` or ``(x, y, w, h)`` tuple.
|
|
337
|
+
strict: If ``True``, raise on timeout. If ``False``, return ``False``.
|
|
338
|
+
|
|
339
|
+
Returns:
|
|
340
|
+
``True`` if image vanished before timeout.
|
|
341
|
+
"""
|
|
342
|
+
return self.vision.wait_vanish(
|
|
343
|
+
image=image,
|
|
344
|
+
timeout=timeout,
|
|
345
|
+
confidence=confidence,
|
|
346
|
+
poll=poll,
|
|
347
|
+
region=region,
|
|
348
|
+
strict=strict,
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
def click(
|
|
352
|
+
self,
|
|
353
|
+
image: str | Path,
|
|
354
|
+
timeout: float = 10,
|
|
355
|
+
confidence: float | None = None,
|
|
356
|
+
poll: float | None = None,
|
|
357
|
+
highlight: bool = HIGHLIGHT_ENABLED,
|
|
358
|
+
region: Region | tuple[int, int, int, int] | None = None,
|
|
359
|
+
) -> MatchHandle:
|
|
360
|
+
"""Wait for an image and click its center.
|
|
361
|
+
|
|
362
|
+
Args:
|
|
363
|
+
image: Template image path.
|
|
364
|
+
timeout: Max wait duration in seconds.
|
|
365
|
+
confidence: Match threshold. ``None`` uses configured default.
|
|
366
|
+
poll: Poll interval in seconds. ``None`` uses configured default.
|
|
367
|
+
highlight: Whether to show highlight overlay before clicking.
|
|
368
|
+
region: Search region as ``Region`` or ``(x, y, w, h)`` tuple.
|
|
369
|
+
|
|
370
|
+
Returns:
|
|
371
|
+
A ``MatchHandle`` representing the clicked match.
|
|
372
|
+
"""
|
|
373
|
+
handle = self.wait(
|
|
374
|
+
image=image,
|
|
375
|
+
timeout=timeout,
|
|
376
|
+
confidence=confidence,
|
|
377
|
+
poll=poll,
|
|
378
|
+
region=region,
|
|
379
|
+
highlight=False,
|
|
380
|
+
)
|
|
381
|
+
return handle.click(highlight=highlight)
|
|
382
|
+
|
|
383
|
+
def click_and_wait_vanish(
|
|
384
|
+
self,
|
|
385
|
+
click_image: str | Path,
|
|
386
|
+
vanish_image: str | Path | None = None,
|
|
387
|
+
timeout: float = 10,
|
|
388
|
+
confidence: float | None = None,
|
|
389
|
+
poll: float | None = None,
|
|
390
|
+
highlight: bool = HIGHLIGHT_ENABLED,
|
|
391
|
+
region: Region | tuple[int, int, int, int] | None = None,
|
|
392
|
+
strict: bool = True,
|
|
393
|
+
) -> bool:
|
|
394
|
+
"""Click one image and wait for an image to disappear.
|
|
395
|
+
|
|
396
|
+
Args:
|
|
397
|
+
click_image: Image used for click targeting.
|
|
398
|
+
vanish_image: Image to monitor for disappearance. ``None`` uses ``click_image``.
|
|
399
|
+
timeout: Max wait duration in seconds.
|
|
400
|
+
confidence: Match threshold. ``None`` uses configured default.
|
|
401
|
+
poll: Poll interval in seconds. ``None`` uses configured default.
|
|
402
|
+
highlight: Whether to highlight before click.
|
|
403
|
+
region: Search region as ``Region`` or ``(x, y, w, h)`` tuple.
|
|
404
|
+
strict: If ``True``, raise on timeout. If ``False``, return ``False``.
|
|
405
|
+
|
|
406
|
+
Returns:
|
|
407
|
+
``True`` if the vanish target disappears before timeout.
|
|
408
|
+
"""
|
|
409
|
+
self.click(
|
|
410
|
+
image=click_image,
|
|
411
|
+
timeout=timeout,
|
|
412
|
+
confidence=confidence,
|
|
413
|
+
poll=poll,
|
|
414
|
+
highlight=highlight,
|
|
415
|
+
region=region,
|
|
416
|
+
)
|
|
417
|
+
target = vanish_image if vanish_image is not None else click_image
|
|
418
|
+
return self.wait_vanish(
|
|
419
|
+
image=target,
|
|
420
|
+
timeout=timeout,
|
|
421
|
+
confidence=confidence,
|
|
422
|
+
poll=poll,
|
|
423
|
+
region=region,
|
|
424
|
+
strict=strict,
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
def activate_window(self, title_substring: str) -> bool:
|
|
428
|
+
"""Attempt to activate a window by title fragment.
|
|
429
|
+
|
|
430
|
+
Tries ``xdotool`` first and falls back to ``wmctrl``.
|
|
431
|
+
|
|
432
|
+
Args:
|
|
433
|
+
title_substring: Case-sensitive fragment used by window manager tools.
|
|
434
|
+
|
|
435
|
+
Returns:
|
|
436
|
+
``True`` when activation command succeeds, otherwise ``False``.
|
|
437
|
+
"""
|
|
438
|
+
xdotool_cmd = ["xdotool", "search", "--name", title_substring, "windowactivate"]
|
|
439
|
+
wmctrl_cmd = ["wmctrl", "-a", title_substring]
|
|
440
|
+
|
|
441
|
+
try:
|
|
442
|
+
proc = subprocess.run(xdotool_cmd, capture_output=True, check=False)
|
|
443
|
+
if proc.returncode == 0:
|
|
444
|
+
return True
|
|
445
|
+
except FileNotFoundError:
|
|
446
|
+
LOGGER.debug("xdotool not available")
|
|
447
|
+
|
|
448
|
+
try:
|
|
449
|
+
proc = subprocess.run(wmctrl_cmd, capture_output=True, check=False)
|
|
450
|
+
return proc.returncode == 0
|
|
451
|
+
except FileNotFoundError:
|
|
452
|
+
LOGGER.debug("wmctrl not available")
|
|
453
|
+
return False
|
|
454
|
+
|
|
455
|
+
def check_env(self, strict: bool = True):
|
|
456
|
+
"""Run environment checks for platform, display, and required tools."""
|
|
457
|
+
return check_env(strict=strict)
|
pyvisionauto/vision.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import cv2
|
|
7
|
+
import mss
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
from .config import DEFAULT_CONFIDENCE, DEFAULT_POLL_INTERVAL
|
|
11
|
+
from .errors import TemplateNotFoundError, VanishTimeoutError, WaitTimeoutError
|
|
12
|
+
from .models import Match, Region
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _as_region(region: Region | tuple[int, int, int, int] | None) -> Region | None:
|
|
16
|
+
"""Normalize region input into a ``Region`` instance.
|
|
17
|
+
|
|
18
|
+
Accepts either an existing ``Region`` object or a tuple ``(x, y, w, h)``.
|
|
19
|
+
"""
|
|
20
|
+
if region is None:
|
|
21
|
+
return None
|
|
22
|
+
if isinstance(region, Region):
|
|
23
|
+
return region
|
|
24
|
+
x, y, w, h = region
|
|
25
|
+
return Region(int(x), int(y), int(w), int(h))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Vision:
|
|
29
|
+
"""Low-level OpenCV template matching and polling engine."""
|
|
30
|
+
|
|
31
|
+
def __init__(self) -> None:
|
|
32
|
+
"""Initialize screen capture backend."""
|
|
33
|
+
self._mss = mss.mss()
|
|
34
|
+
|
|
35
|
+
def _load_template(self, image: str | Path) -> np.ndarray:
|
|
36
|
+
"""Load a template image from disk.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
image: Template image path.
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
BGR image array.
|
|
43
|
+
|
|
44
|
+
Raises:
|
|
45
|
+
TemplateNotFoundError: If the file does not exist or cannot be decoded.
|
|
46
|
+
"""
|
|
47
|
+
image_path = Path(image)
|
|
48
|
+
if not image_path.exists():
|
|
49
|
+
raise TemplateNotFoundError(f"Template not found: {image_path}")
|
|
50
|
+
template = cv2.imread(str(image_path), cv2.IMREAD_COLOR)
|
|
51
|
+
if template is None:
|
|
52
|
+
raise TemplateNotFoundError(f"Unable to load template: {image_path}")
|
|
53
|
+
return template
|
|
54
|
+
|
|
55
|
+
def _capture_screen(self, region: Region | None = None) -> np.ndarray:
|
|
56
|
+
"""Capture the full screen or a specific region.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
region: Optional capture region. ``None`` captures primary monitor.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
Captured BGR frame as a numpy array.
|
|
63
|
+
"""
|
|
64
|
+
if region is None:
|
|
65
|
+
monitor = self._mss.monitors[1]
|
|
66
|
+
frame = self._mss.grab(monitor)
|
|
67
|
+
return np.array(frame)[:, :, :3]
|
|
68
|
+
|
|
69
|
+
monitor = {
|
|
70
|
+
"left": region.x,
|
|
71
|
+
"top": region.y,
|
|
72
|
+
"width": region.w,
|
|
73
|
+
"height": region.h,
|
|
74
|
+
}
|
|
75
|
+
frame = self._mss.grab(monitor)
|
|
76
|
+
return np.array(frame)[:, :, :3]
|
|
77
|
+
|
|
78
|
+
def find(
|
|
79
|
+
self,
|
|
80
|
+
image: str | Path,
|
|
81
|
+
confidence: float | None = None,
|
|
82
|
+
region: Region | tuple[int, int, int, int] | None = None,
|
|
83
|
+
) -> Match | None:
|
|
84
|
+
"""Run one template match operation.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
image: Template image path.
|
|
88
|
+
confidence: Match threshold in [0.0, 1.0]. ``None`` uses configured default.
|
|
89
|
+
region: Search region as ``Region`` or ``(x, y, w, h)`` tuple.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
``Match`` on success, otherwise ``None``.
|
|
93
|
+
"""
|
|
94
|
+
resolved_region = _as_region(region)
|
|
95
|
+
threshold = confidence if confidence is not None else DEFAULT_CONFIDENCE
|
|
96
|
+
template = self._load_template(image)
|
|
97
|
+
screen = self._capture_screen(resolved_region)
|
|
98
|
+
|
|
99
|
+
result = cv2.matchTemplate(screen, template, cv2.TM_CCOEFF_NORMED)
|
|
100
|
+
_, max_val, _, max_loc = cv2.minMaxLoc(result)
|
|
101
|
+
|
|
102
|
+
if max_val < threshold:
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
h, w = template.shape[:2]
|
|
106
|
+
abs_x = max_loc[0] + (resolved_region.x if resolved_region else 0)
|
|
107
|
+
abs_y = max_loc[1] + (resolved_region.y if resolved_region else 0)
|
|
108
|
+
return Match(x=abs_x, y=abs_y, w=w, h=h, score=float(max_val))
|
|
109
|
+
|
|
110
|
+
def wait(
|
|
111
|
+
self,
|
|
112
|
+
image: str | Path,
|
|
113
|
+
timeout: float,
|
|
114
|
+
confidence: float | None = None,
|
|
115
|
+
poll: float | None = None,
|
|
116
|
+
region: Region | tuple[int, int, int, int] | None = None,
|
|
117
|
+
) -> Match:
|
|
118
|
+
"""Poll until an image appears or timeout occurs.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
image: Template image path.
|
|
122
|
+
timeout: Max wait duration in seconds.
|
|
123
|
+
confidence: Match threshold in [0.0, 1.0]. ``None`` uses configured default.
|
|
124
|
+
poll: Poll interval in seconds. ``None`` uses configured default.
|
|
125
|
+
region: Search region as ``Region`` or ``(x, y, w, h)`` tuple.
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
The first successful ``Match``.
|
|
129
|
+
|
|
130
|
+
Raises:
|
|
131
|
+
WaitTimeoutError: If timeout elapses before any match reaches threshold.
|
|
132
|
+
"""
|
|
133
|
+
interval = poll if poll is not None else DEFAULT_POLL_INTERVAL
|
|
134
|
+
deadline = time.time() + timeout
|
|
135
|
+
last_score = 0.0
|
|
136
|
+
resolved_region = _as_region(region)
|
|
137
|
+
threshold = confidence if confidence is not None else DEFAULT_CONFIDENCE
|
|
138
|
+
template = self._load_template(image)
|
|
139
|
+
h, w = template.shape[:2]
|
|
140
|
+
|
|
141
|
+
while time.time() < deadline:
|
|
142
|
+
screen = self._capture_screen(resolved_region)
|
|
143
|
+
result = cv2.matchTemplate(screen, template, cv2.TM_CCOEFF_NORMED)
|
|
144
|
+
_, max_val, _, max_loc = cv2.minMaxLoc(result)
|
|
145
|
+
score = float(max_val)
|
|
146
|
+
last_score = max(last_score, score)
|
|
147
|
+
if score >= threshold:
|
|
148
|
+
abs_x = max_loc[0] + (resolved_region.x if resolved_region else 0)
|
|
149
|
+
abs_y = max_loc[1] + (resolved_region.y if resolved_region else 0)
|
|
150
|
+
return Match(x=abs_x, y=abs_y, w=w, h=h, score=score)
|
|
151
|
+
time.sleep(interval)
|
|
152
|
+
|
|
153
|
+
raise WaitTimeoutError(
|
|
154
|
+
f"wait timeout image={image} timeout={timeout}s confidence={threshold} last_score={last_score:.4f}"
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
def wait_vanish(
|
|
158
|
+
self,
|
|
159
|
+
image: str | Path,
|
|
160
|
+
timeout: float,
|
|
161
|
+
confidence: float | None = None,
|
|
162
|
+
poll: float | None = None,
|
|
163
|
+
region: Region | tuple[int, int, int, int] | None = None,
|
|
164
|
+
strict: bool = True,
|
|
165
|
+
) -> bool:
|
|
166
|
+
"""Poll until an image can no longer be matched.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
image: Template image path.
|
|
170
|
+
timeout: Max wait duration in seconds.
|
|
171
|
+
confidence: Match threshold in [0.0, 1.0]. ``None`` uses configured default.
|
|
172
|
+
poll: Poll interval in seconds. ``None`` uses configured default.
|
|
173
|
+
region: Search region as ``Region`` or ``(x, y, w, h)`` tuple.
|
|
174
|
+
strict: If ``True``, raise on timeout. If ``False``, return ``False``.
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
``True`` if image vanishes before timeout; otherwise ``False`` when ``strict=False``.
|
|
178
|
+
|
|
179
|
+
Raises:
|
|
180
|
+
VanishTimeoutError: If timeout occurs while ``strict=True``.
|
|
181
|
+
"""
|
|
182
|
+
interval = poll if poll is not None else DEFAULT_POLL_INTERVAL
|
|
183
|
+
deadline = time.time() + timeout
|
|
184
|
+
|
|
185
|
+
while time.time() < deadline:
|
|
186
|
+
match = self.find(image=image, confidence=confidence, region=region)
|
|
187
|
+
if match is None:
|
|
188
|
+
return True
|
|
189
|
+
time.sleep(interval)
|
|
190
|
+
|
|
191
|
+
if strict:
|
|
192
|
+
threshold = confidence if confidence is not None else DEFAULT_CONFIDENCE
|
|
193
|
+
raise VanishTimeoutError(
|
|
194
|
+
f"wait_vanish timeout image={image} timeout={timeout}s confidence={threshold}"
|
|
195
|
+
)
|
|
196
|
+
return False
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyvisionauto
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: PyVisionAuto: Linux end-to-end automation toolkit with visual image matching, mouse/keyboard control, and screen recording
|
|
5
|
+
Author: PyVisionAuto contributors
|
|
6
|
+
License-Expression: LicenseRef-Proprietary
|
|
7
|
+
Project-URL: Homepage, https://pypi.org/project/pyvisionauto/
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
15
|
+
Classifier: Environment :: X11 Applications
|
|
16
|
+
Classifier: Topic :: Software Development :: Testing
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: opencv-python>=4.8.0
|
|
21
|
+
Requires-Dist: mss>=9.0.0
|
|
22
|
+
Requires-Dist: numpy>=1.24.0
|
|
23
|
+
Requires-Dist: pyautogui>=0.9.54
|
|
24
|
+
Requires-Dist: pillow>=10.0.0
|
|
25
|
+
Provides-Extra: test
|
|
26
|
+
Requires-Dist: pytest>=8.0; extra == "test"
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
# PyVisionAuto
|
|
30
|
+
|
|
31
|
+
PyVisionAuto (`pyvisionauto`) is a Linux end-to-end automation testing toolkit.
|
|
32
|
+
It is centered on visual image matching and also includes screen recording, mouse automation, and keyboard automation capabilities.
|
|
33
|
+
|
|
34
|
+
## Scope
|
|
35
|
+
|
|
36
|
+
- Linux only
|
|
37
|
+
- X11 session only
|
|
38
|
+
- Real physical display required
|
|
39
|
+
|
|
40
|
+
## Install
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install pyvisionauto
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## System dependencies
|
|
47
|
+
|
|
48
|
+
- python3-tk (for border overlay highlight)
|
|
49
|
+
- xdotool (preferred for window activation)
|
|
50
|
+
- wmctrl (fallback for window activation)
|
|
51
|
+
- ffmpeg (optional, only for recording APIs)
|
|
52
|
+
|
|
53
|
+
## Quick start
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
from pyvisionauto import Screen
|
|
57
|
+
|
|
58
|
+
screen = Screen()
|
|
59
|
+
screen.wait("login_button.png", timeout=10).highlight().click()
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Notes
|
|
63
|
+
|
|
64
|
+
Wayland-first and headless-only environments are not supported in v0.1.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
pyvisionauto/__init__.py,sha256=n8d_57ZPt7_L7QvZY2MyOXu_K2wuY36ZyRP4a55NuBk,888
|
|
2
|
+
pyvisionauto/config.py,sha256=0m8FW_LFz3Jm5iUn7qPbbAamPaW00KtSTMgyOdtXjBo,569
|
|
3
|
+
pyvisionauto/envcheck.py,sha256=Ewa9ldTb1GSd5MyOdoay4SFe5f7IyY9qtgm8fL7ibIA,2907
|
|
4
|
+
pyvisionauto/errors.py,sha256=yu1dE2CTNM3xi0Ss9BIIGSfLsQ0Tb1W2l4M1I_UtY_4,733
|
|
5
|
+
pyvisionauto/highlighter.py,sha256=0DvB2_zJaArKeE3C1TcxxFC7PdEfkMUocX6Qv6I8kfc,2581
|
|
6
|
+
pyvisionauto/input.py,sha256=J4sqtTfSBTZPQRanLKJsXvk1w8oDJaby6F4jjhBw2qc,3954
|
|
7
|
+
pyvisionauto/models.py,sha256=482REXLPKHahnx37pq8L7JiqchXDWDjeDVno8JHhVOA,1938
|
|
8
|
+
pyvisionauto/overlay.py,sha256=cHF8sQNH89B0FLh-kpvPJE5UJTRXpQlj4trxL45jVp0,1289
|
|
9
|
+
pyvisionauto/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
+
pyvisionauto/recorder.py,sha256=AaP8Nc7dEm-WCpf0PtzpTYXe8g8HRkB-TqxTxxsNtOw,1856
|
|
11
|
+
pyvisionauto/screen.py,sha256=qFzvjp0skwczU7pFjBiiOHFS9Ffc_-vFPaff4SVPJzU,15861
|
|
12
|
+
pyvisionauto/vision.py,sha256=HjQ7tF5eFbWQcjxCqTvLWMcn2LDt2iWchyFzBSyZ_IY,7177
|
|
13
|
+
pyvisionauto-0.1.0.dist-info/licenses/LICENSE,sha256=ipwM6eUlm4Jvp5a_ixwEJgkko112aJnPjd2rMLmxUsM,411
|
|
14
|
+
pyvisionauto-0.1.0.dist-info/METADATA,sha256=wiz2VCVJgb2UZEW8ueYwFt8MOGSIijBQeidk8wlHobI,1935
|
|
15
|
+
pyvisionauto-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
16
|
+
pyvisionauto-0.1.0.dist-info/top_level.txt,sha256=3obi1phVrm33prkM9B3t0STWnyvkPF0RcBoUvVJhOH8,13
|
|
17
|
+
pyvisionauto-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
Copyright (c) 2026 PyVisionAuto contributors. All rights reserved.
|
|
2
|
+
|
|
3
|
+
This software and its source code are proprietary and confidential.
|
|
4
|
+
Redistribution, modification, or use of the source code in any form is
|
|
5
|
+
prohibited without explicit written permission from the copyright holder.
|
|
6
|
+
|
|
7
|
+
The compiled/distributed package may be used in accordance with the terms
|
|
8
|
+
provided at: https://pypi.org/project/pyvisionauto/
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pyvisionauto
|