AutoMonkey 0.4.1__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.
automonkey/__init__.py ADDED
@@ -0,0 +1,37 @@
1
+ """Top-level package for AutoMonkey."""
2
+
3
+ __author__ = """Mihail-Cosmin Munteanu"""
4
+ __email__ = 'munteanumihailcosmin@gmail.com'
5
+ # Read back from the installed distribution, so [project].version in pyproject.toml stays
6
+ # the only place the version is written (see bump_version.py).
7
+ try:
8
+ from importlib.metadata import version as _installed_version
9
+
10
+ __version__ = _installed_version("AutoMonkey")
11
+ except Exception: # plain source checkout, no installed distribution metadata to read
12
+ __version__ = "0.0.0+unknown"
13
+
14
+
15
+ def _default_display_env():
16
+ """Fill in display/session variables missing when started by a service or an MCP
17
+ client: pyautogui fails to import without DISPLAY, and the AT-SPI backend
18
+ (atspi_funcs.py) can't reach the accessibility bus without DBUS_SESSION_BUS_ADDRESS -
19
+ both are normally set by a login session, which a service/MCP client may not inherit."""
20
+ import os
21
+ import sys
22
+ if not sys.platform.startswith("linux"):
23
+ return
24
+ runtime = os.environ.setdefault("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}")
25
+ if "WAYLAND_DISPLAY" not in os.environ and os.path.exists(os.path.join(runtime, "wayland-0")):
26
+ os.environ["WAYLAND_DISPLAY"] = "wayland-0"
27
+ if "DISPLAY" not in os.environ and os.path.exists("/tmp/.X11-unix/X0"):
28
+ os.environ["DISPLAY"] = ":0"
29
+ if "DBUS_SESSION_BUS_ADDRESS" not in os.environ:
30
+ bus_path = os.path.join(runtime, "bus")
31
+ if os.path.exists(bus_path):
32
+ os.environ["DBUS_SESSION_BUS_ADDRESS"] = f"unix:path={bus_path}"
33
+
34
+
35
+ _default_display_env()
36
+
37
+ from .automonkey import *
@@ -0,0 +1,54 @@
1
+ """AI-friendly layer over AutoMonkey: act + observe in a single call.
2
+
3
+ from automonkey.ai import act, observe
4
+ text, img = act("launch firefox; maximize; url google.ro; type betano.ro")
5
+
6
+ All coordinates are pixels of the screenshot the AI was shown.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from PIL import Image
11
+
12
+ from . import ocr, screen, windows
13
+ from .script import run, split_script
14
+
15
+
16
+ def _context() -> str:
17
+ v = screen.view()
18
+ try:
19
+ w = windows.active_window()
20
+ active = f"active window: {w['title']!r}"
21
+ except Exception:
22
+ active = "active window: unknown"
23
+ return f"{v.describe()}; {active}"
24
+
25
+
26
+ def observe(grid: int = 0, max_side: int = screen.DEFAULT_MAX_SIDE) -> tuple[str, Image.Image]:
27
+ img = screen.shot(grid=grid, max_side=max_side)
28
+ return _context(), img
29
+
30
+
31
+ def act(script: str, screenshot: bool = True, grid: int = 0, settle: bool = True) -> tuple[str, Image.Image | None]:
32
+ """Run an action script, then report what happened (and show the screen)."""
33
+ before = screen.grab()
34
+ log, ok = run(script, settle=settle)
35
+ after = screen.grab()
36
+ img = screen.shot(grid=grid, full=after) if screenshot else None
37
+ lines = log + [screen.diff_summary(before, after), _context()]
38
+ if not ok:
39
+ lines.insert(0, "STOPPED AT A FAILED STEP")
40
+ return "\n".join(lines), img
41
+
42
+
43
+ def window_list() -> str:
44
+ v = screen.view()
45
+ out = []
46
+ for w in windows.list_windows():
47
+ x, y = v.to_image(w["x"], w["y"])
48
+ r, b = v.to_image(w["x"] + w["w"], w["y"] + w["h"])
49
+ out.append(f"{'*' if w['active'] else ' '} {w['id']:>10} {w['title']!r} [{w['wm_class']}] "
50
+ f"x={x}..{r} y={y}..{b}")
51
+ return "\n".join(out) or "no windows"
52
+
53
+
54
+ __all__ = ["act", "observe", "window_list", "ocr", "screen", "windows", "run", "split_script"]
@@ -0,0 +1,76 @@
1
+ """Command line for agents that only have a shell (one command = act + screenshot).
2
+
3
+ python -m automonkey.ai shot [--grid 100]
4
+ python -m automonkey.ai do "launch firefox; maximize; url google.ro"
5
+ python -m automonkey.ai zoom X Y W H
6
+ python -m automonkey.ai windows
7
+ python -m automonkey.ai find "Accept all"
8
+ python -m automonkey.ai read [X Y W H]
9
+
10
+ Screenshots are written to --out (default: $AUTOMONKEY_AI_OUT or
11
+ /tmp/automonkey_ai/screen.png) and the path is printed; open it to look.
12
+ Coordinates are pixels of that screenshot.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import os
18
+ import sys
19
+ import tempfile
20
+
21
+ from . import act, observe, ocr, screen, window_list
22
+
23
+ DEFAULT_OUT = os.environ.get("AUTOMONKEY_AI_OUT",
24
+ os.path.join(tempfile.gettempdir(), "automonkey_ai", "screen.png"))
25
+
26
+
27
+ def _save(img, out: str) -> str:
28
+ os.makedirs(os.path.dirname(os.path.abspath(out)), exist_ok=True)
29
+ img.save(out, compress_level=1)
30
+ return out
31
+
32
+
33
+ def main(argv=None):
34
+ p = argparse.ArgumentParser(prog="python -m automonkey.ai", description=__doc__,
35
+ formatter_class=argparse.RawDescriptionHelpFormatter)
36
+ p.add_argument("--out", default=DEFAULT_OUT, help="where to save the screenshot")
37
+ p.add_argument("--grid", type=int, default=0, help="overlay a labelled grid every N pixels")
38
+ sub = p.add_subparsers(dest="cmd", required=True)
39
+ sub.add_parser("shot", help="take a screenshot")
40
+ d = sub.add_parser("do", help="run an action script, then take a screenshot")
41
+ d.add_argument("script", help="steps separated by ';' or newlines ('-' reads stdin)")
42
+ d.add_argument("--no-shot", action="store_true", help="don't take a screenshot")
43
+ z = sub.add_parser("zoom", help="enlarged view of a region")
44
+ z.add_argument("box", type=int, nargs=4, metavar=("X", "Y", "W", "H"))
45
+ sub.add_parser("windows", help="list windows")
46
+ f = sub.add_parser("find", help="find text on screen (OCR)")
47
+ f.add_argument("text")
48
+ r = sub.add_parser("read", help="read text on screen (OCR)")
49
+ r.add_argument("box", type=int, nargs="*", metavar="X Y W H")
50
+ a = p.parse_args(argv)
51
+
52
+ if a.cmd == "shot":
53
+ text, img = observe(grid=a.grid)
54
+ print(text)
55
+ print("screenshot:", _save(img, a.out))
56
+ elif a.cmd == "do":
57
+ script = sys.stdin.read() if a.script == "-" else a.script
58
+ text, img = act(script, screenshot=not a.no_shot, grid=a.grid)
59
+ print(text)
60
+ if img is not None:
61
+ print("screenshot:", _save(img, a.out))
62
+ return 0 if not text.startswith("STOPPED") else 1
63
+ elif a.cmd == "zoom":
64
+ print("zoom:", _save(screen.zoom(*a.box, grid=a.grid), a.out.replace(".png", "_zoom.png")))
65
+ elif a.cmd == "windows":
66
+ print(window_list())
67
+ elif a.cmd == "find":
68
+ hits = ocr.find_text(a.text)
69
+ print("\n".join(f"{h['text']!r} click at {h['cx']},{h['cy']}" for h in hits) or "not found")
70
+ elif a.cmd == "read":
71
+ print(ocr.read_text(*(a.box or [0, 0, 0, 0])))
72
+ return 0
73
+
74
+
75
+ if __name__ == "__main__":
76
+ sys.exit(main())
@@ -0,0 +1,62 @@
1
+ """Start the MCP server with the project's virtualenv, on any OS.
2
+
3
+ A venv keeps its interpreter in `.venv/bin/python` on Linux/macOS but in
4
+ `.venv\\Scripts\\python.exe` on Windows, and `.mcp.json` can't branch on the OS. So
5
+ `.mcp.json` runs this file with whatever Python is at hand (stdlib only, any 3.x);
6
+ it finds the venv's interpreter and hands over to the real server.
7
+
8
+ python3 automonkey/ai/mcp_launch.py # start the server
9
+ python3 automonkey/ai/mcp_launch.py --which # print the interpreter it would use
10
+
11
+ Looks in $AUTOMONKEY_VENV, then <repo>/.venv, <repo>/venv, then $VIRTUAL_ENV; falls
12
+ back to the running interpreter if it can import the server.
13
+ """
14
+ import importlib.util
15
+ import os
16
+ import subprocess
17
+ import sys
18
+
19
+ ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
20
+
21
+
22
+ def venv_python():
23
+ for venv in (os.environ.get("AUTOMONKEY_VENV"), os.path.join(ROOT, ".venv"), os.path.join(ROOT, "venv"),
24
+ os.environ.get("VIRTUAL_ENV")):
25
+ if not venv:
26
+ continue
27
+ for exe in (os.path.join(venv, "Scripts", "python.exe"), os.path.join(venv, "bin", "python")):
28
+ if os.path.isfile(exe):
29
+ return exe
30
+ # The server runs with cwd=ROOT, so the package comes from there; only its dependencies
31
+ # have to be installed in this interpreter.
32
+ server = os.path.join(ROOT, "automonkey", "ai", "mcp_server.py")
33
+ if os.path.isfile(server) and all(importlib.util.find_spec(m) for m in ("mcp", "pyautogui")):
34
+ return sys.executable
35
+ return None
36
+
37
+
38
+ def main():
39
+ python = venv_python()
40
+ if not python:
41
+ pip = r".venv\Scripts\pip" if sys.platform == "win32" else ".venv/bin/pip"
42
+ sys.stderr.write("automonkey MCP: no virtualenv found in {}. Create it:\n"
43
+ " python -m venv .venv && {} install -e .[ai]\n"
44
+ "or point AUTOMONKEY_VENV at an existing one.\n".format(ROOT, pip))
45
+ return 1
46
+ if "--which" in sys.argv:
47
+ print(python)
48
+ return 0
49
+ cmd = [python, "-m", "automonkey.ai.mcp_server"]
50
+ if sys.platform != "win32":
51
+ os.chdir(ROOT)
52
+ os.execv(python, cmd) # replace this process: signals and stdio go straight to the server
53
+ # Windows has no real exec (os.execv spawns and exits, detaching stdio), so run it as a
54
+ # child that inherits our stdin/stdout pipes, and pass its exit code on.
55
+ try:
56
+ return subprocess.call(cmd, cwd=ROOT)
57
+ except KeyboardInterrupt:
58
+ return 130
59
+
60
+
61
+ if __name__ == "__main__":
62
+ sys.exit(main())
@@ -0,0 +1,114 @@
1
+ """MCP server exposing AutoMonkey to Claude (or any MCP client).
2
+
3
+ The repo's .mcp.json starts it through mcp_launch.py, which finds the venv on any OS.
4
+ Register it by hand with Claude Code (from the repo root):
5
+ claude mcp add automonkey -- python3 "$PWD/automonkey/ai/mcp_launch.py"
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from mcp.server.mcpserver import Image, MCPServer
10
+
11
+ from . import act as _act
12
+ from . import observe, ocr, screen, window_list
13
+ from .script import __doc__ as SCRIPT_DOC
14
+ from .. import list_controls as _list_controls
15
+
16
+ INSTRUCTIONS = f"""Control this computer's screen, mouse and keyboard.
17
+
18
+ Workflow: call `act` with a whole script of steps; it returns a log, a summary of what
19
+ changed on screen, and a fresh screenshot -- you rarely need a separate `screenshot`
20
+ call. Batch everything you are confident about into one `act` call; only stop to look
21
+ when the next step depends on what appears.
22
+
23
+ Coordinates are ALWAYS pixels of the latest screenshot image you were shown (the
24
+ server converts to real screen pixels). The red ring marks the mouse pointer.
25
+
26
+ Tips: use `url` instead of clicking the address bar; `launch` an app rather than
27
+ clicking desktop icons; `zoom` to read small text precisely; `grid=100` overlays
28
+ labelled lines when you need exact coordinates; dismiss unexpected popups with
29
+ `key escape`. OCR tools (find_text/read_text/click_text) are
30
+ {'available' if ocr.available() else 'NOT available yet (tesseract not found); they start working without a restart once it is installed'}.
31
+
32
+ Action script language:
33
+ {SCRIPT_DOC}"""
34
+
35
+ mcp = MCPServer("automonkey", instructions=INSTRUCTIONS)
36
+
37
+
38
+ def _img(pil) -> Image:
39
+ return Image(data=screen.to_png(pil), format="png")
40
+
41
+
42
+ @mcp.tool()
43
+ def act(script: str, screenshot: bool = True, grid: int = 0) -> list:
44
+ """Run an action script (one command per line or ';'-separated), wait for the screen to
45
+ settle, then return the step log, what changed, and a screenshot.
46
+
47
+ Commands: click X Y [button] [N] | dclick X Y | rclick X Y | move X Y | drag X1 Y1 X2 Y2 |
48
+ scroll N [X Y] | type TEXT | key K [K..] | hotkey ctrl+l | wait S | stable [T] | change [T] |
49
+ url ADDRESS | launch CMD | focus WINDOW | maximize [WINDOW] | close WINDOW |
50
+ click_text TEXT | wait_text TEXT | click_image PATH | press [BUTTON] | release [BUTTON] |
51
+ path X1 Y1 X2 Y2 .. (freehand: press, glide through the points, release)
52
+
53
+ Example: "launch firefox; maximize; url google.ro; click 640 290; type betano.ro"
54
+ Set screenshot=false when you don't need to look (saves tokens); grid=100 adds a grid."""
55
+ text, img = _act(script, screenshot=screenshot, grid=grid)
56
+ return [text, _img(img)] if img is not None else [text]
57
+
58
+
59
+ @mcp.tool()
60
+ def screenshot(grid: int = 0) -> list:
61
+ """Take a screenshot. grid=N overlays labelled lines every N pixels for precise coordinates."""
62
+ text, img = observe(grid=grid)
63
+ return [text, _img(img)]
64
+
65
+
66
+ @mcp.tool()
67
+ def zoom(x: int, y: int, width: int, height: int, grid: int = 0) -> list:
68
+ """Enlarged full-resolution view of a screenshot region (screenshot coordinates), for small
69
+ text or precise targeting. Grid labels, if requested, use the same screenshot coordinates."""
70
+ return [f"zoom of x={x}..{x + width} y={y}..{y + height}", _img(screen.zoom(x, y, width, height, grid))]
71
+
72
+
73
+ @mcp.tool()
74
+ def windows() -> str:
75
+ """List open windows ('*' = active) with ids, titles and their screenshot-coordinate bounds."""
76
+ return window_list()
77
+
78
+
79
+ @mcp.tool()
80
+ def list_controls(window: str, depth: int = None) -> str:
81
+ """List a window's controls via the OS accessibility tree (pywinauto on Windows,
82
+ AT-SPI on Linux; not implemented on macOS yet) - role, name and the dict to pass as
83
+ `control` to click/type/etc. Use this instead of screenshots/OCR when the app exposes
84
+ an accessibility tree (most GTK/Qt/native apps do; Electron needs
85
+ --force-renderer-accessibility, canvas-drawn UIs don't expose one at all).
86
+
87
+ window: title of the top-level window, regex matched anywhere in the title.
88
+ depth: how many levels deep to go (default: all)."""
89
+ return _list_controls(window, depth)
90
+
91
+
92
+ @mcp.tool()
93
+ def find_text(text: str) -> str:
94
+ """Locate text on screen via OCR. Returns boxes and click points in screenshot coordinates."""
95
+ hits = ocr.find_text(text)
96
+ if not hits:
97
+ return f"{text!r} not found"
98
+ return "\n".join(f"{h['text']!r} click at {h['cx']},{h['cy']} (box x={h['x']} y={h['y']} w={h['w']} h={h['h']})"
99
+ for h in hits[:10])
100
+
101
+
102
+ @mcp.tool()
103
+ def read_text(x: int = 0, y: int = 0, width: int = 0, height: int = 0) -> str:
104
+ """OCR the screen (or a region in screenshot coordinates) and return the text. Cheaper than
105
+ a screenshot when you only need to read."""
106
+ return ocr.read_text(x, y, width, height) or "(no text found)"
107
+
108
+
109
+ def main():
110
+ mcp.run()
111
+
112
+
113
+ if __name__ == "__main__":
114
+ main()
automonkey/ai/ocr.py ADDED
@@ -0,0 +1,149 @@
1
+ """Optional OCR: find text on screen so the AI can click by label instead of coordinates.
2
+
3
+ Needs the tesseract binary. Installation differs per OS:
4
+ Linux `sudo apt install tesseract-ocr` (add tesseract-ocr-ron for Romanian diacritics)
5
+ macOS `brew install tesseract`
6
+ Windows `winget install UB-Mannheim.TesseractOCR`
7
+
8
+ The binary is located via $TESSERACT_CMD, then PATH, then (Windows) the PATH saved
9
+ in the registry, then the usual install directories -- so OCR works even when the
10
+ PATH of the process that launched us predates the tesseract install (e.g. an
11
+ already-running MCP server, or a macOS GUI app that never sees Homebrew's PATH).
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ import shutil
17
+ import sys
18
+
19
+ from PIL import Image, ImageOps
20
+
21
+ from .screen import grab, view
22
+
23
+ def _candidates() -> list[str]:
24
+ if sys.platform == "win32":
25
+ env = os.environ.get
26
+ dirs = [env("ProgramFiles", r"C:\Program Files"), env("ProgramFiles(x86)", r"C:\Program Files (x86)"),
27
+ os.path.join(env("LOCALAPPDATA", ""), "Programs")] # per-user installs
28
+ return ([os.path.join(d, "Tesseract-OCR", "tesseract.exe") for d in dirs]
29
+ + [os.path.join(env("USERPROFILE", ""), "scoop", "shims", "tesseract.exe"),
30
+ os.path.join(env("ChocolateyInstall", r"C:\ProgramData\chocolatey"), "bin", "tesseract.exe")])
31
+ return ["/opt/homebrew/bin/tesseract", "/usr/local/bin/tesseract", "/opt/local/bin/tesseract",
32
+ "/usr/bin/tesseract", "/snap/bin/tesseract"]
33
+
34
+
35
+ def _registry_path() -> str:
36
+ """PATH as currently saved in the registry: installers update it there, but a running
37
+ process keeps the PATH it started with."""
38
+ import winreg
39
+ parts = []
40
+ for hive, key in ((winreg.HKEY_LOCAL_MACHINE, r"SYSTEM\CurrentControlSet\Control\Session Manager\Environment"),
41
+ (winreg.HKEY_CURRENT_USER, "Environment")):
42
+ try:
43
+ with winreg.OpenKey(hive, key) as k:
44
+ parts.append(os.path.expandvars(winreg.QueryValueEx(k, "Path")[0]))
45
+ except OSError:
46
+ pass
47
+ return os.pathsep.join(parts)
48
+
49
+
50
+ _found: str | None = None
51
+
52
+
53
+ def tesseract_cmd() -> str | None:
54
+ """Locate the tesseract binary, or None if it isn't installed. $TESSERACT_CMD, when
55
+ set, is used exactly (a path or a command name); a wrong value is not silently
56
+ replaced by another tesseract."""
57
+ global _found
58
+ env = os.environ.get("TESSERACT_CMD")
59
+ if env:
60
+ return shutil.which(env) # also accepts a full path, and adds .exe on Windows
61
+ if _found and os.path.isfile(_found):
62
+ return _found
63
+ _found = shutil.which("tesseract")
64
+ if not _found and sys.platform == "win32":
65
+ _found = shutil.which("tesseract", path=_registry_path())
66
+ if not _found:
67
+ _found = next((c for c in _candidates() if os.path.isfile(c)), None)
68
+ return _found
69
+
70
+
71
+ def available() -> bool:
72
+ return tesseract_cmd() is not None
73
+
74
+
75
+ def _require():
76
+ if available():
77
+ return
78
+ env = os.environ.get("TESSERACT_CMD")
79
+ if env:
80
+ raise RuntimeError(f"OCR: TESSERACT_CMD={env!r} is not an executable tesseract")
81
+ if sys.platform == "win32":
82
+ hint = "winget install UB-Mannheim.TesseractOCR (or set TESSERACT_CMD)"
83
+ elif sys.platform == "darwin":
84
+ hint = "brew install tesseract"
85
+ else:
86
+ hint = "sudo apt install tesseract-ocr (add tesseract-ocr-ron for Romanian diacritics)"
87
+ raise RuntimeError(f"OCR needs tesseract: {hint}")
88
+
89
+
90
+ def _pytesseract():
91
+ import pytesseract
92
+ pytesseract.pytesseract.tesseract_cmd = tesseract_cmd()
93
+ return pytesseract
94
+
95
+
96
+ def _words(img: Image.Image, lang: str):
97
+ pytesseract = _pytesseract()
98
+ gray = ImageOps.grayscale(img)
99
+ # Light-on-dark UIs (dark themes) OCR much better when inverted.
100
+ if sum(gray.resize((64, 64)).getdata()) / 4096 < 110:
101
+ gray = ImageOps.invert(gray)
102
+ data = pytesseract.image_to_data(gray, lang=lang, output_type=pytesseract.Output.DICT)
103
+ for i, text in enumerate(data["text"]):
104
+ if text.strip() and float(data["conf"][i]) > 30:
105
+ yield dict(text=text, x=data["left"][i], y=data["top"][i], w=data["width"][i], h=data["height"][i],
106
+ line=(data["block_num"][i], data["par_num"][i], data["line_num"][i]))
107
+
108
+
109
+ def find_text(query: str, lang: str = "eng") -> list[dict]:
110
+ """Every on-screen occurrence of `query` (case-insensitive, may span several words).
111
+ Returns boxes in image coordinates with a click point (cx, cy), best match first."""
112
+ _require()
113
+ v = view()
114
+ full = grab()
115
+ words = list(_words(full, lang))
116
+ q = query.lower().split()
117
+ hits = []
118
+ for i in range(len(words)):
119
+ group = words[i:i + len(q)]
120
+ if len(group) < len(q) or len({w["line"] for w in group}) > 1:
121
+ continue
122
+ joined = " ".join(w["text"].lower() for w in group)
123
+ if " ".join(q) in joined:
124
+ x0 = min(w["x"] for w in group)
125
+ y0 = min(w["y"] for w in group)
126
+ x1 = max(w["x"] + w["w"] for w in group)
127
+ y1 = max(w["y"] + w["h"] for w in group)
128
+ (ix0, iy0), (ix1, iy1) = v.capture_to_image(x0, y0), v.capture_to_image(x1, y1)
129
+ exact = joined == " ".join(q)
130
+ hits.append(dict(text=" ".join(w["text"] for w in group), x=ix0, y=iy0, w=ix1 - ix0, h=iy1 - iy0,
131
+ cx=(ix0 + ix1) // 2, cy=(iy0 + iy1) // 2, exact=exact))
132
+ hits.sort(key=lambda h: (not h["exact"], h["y"], h["x"]))
133
+ return hits
134
+
135
+
136
+ def read_text(x: int = 0, y: int = 0, w: int = 0, h: int = 0, lang: str = "eng") -> str:
137
+ """OCR the whole screen, or an image-coordinate region. Much cheaper for the AI
138
+ than reading text off a screenshot."""
139
+ _require()
140
+ pytesseract = _pytesseract()
141
+ v = view()
142
+ img = grab()
143
+ if w and h:
144
+ s = 1 / v.scale
145
+ img = img.crop((round(x * s), round(y * s), round((x + w) * s), round((y + h) * s)))
146
+ gray = ImageOps.grayscale(img)
147
+ if sum(gray.resize((64, 64)).getdata()) / 4096 < 110:
148
+ gray = ImageOps.invert(gray)
149
+ return pytesseract.image_to_string(gray, lang=lang).strip()