#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ eyes.py -- 画面を見て、文字を読む やっていること ──────────── ① screencapture で画面を1枚撮る(macOS が最初から持っている) ② tools/see(Vision)で、そこに写っている文字を座標つきで読む ③ 「押したい文字」を探して、押すべき場所(点)を返す ネットには出ない。全部この機械の中で終わる。 【つまずいたところ】 Retina の画面では、撮った絵が画面の2倍の大きさになる。 画面 1792 × 1120 撮った絵 3584 × 2240 読み取った座標をそのまま押すと、画面の外を押してしまう。 だから必ず「絵の大きさ ÷ 画面の大きさ」で割り戻す。 """ import json, os, subprocess, tempfile, time HERE = os.path.dirname(os.path.abspath(__file__)) SEE = os.path.join(HERE, "tools", "see") POINT = os.path.join(HERE, "tools", "point") def ready(): return os.path.exists(SEE) def screen_size(): """画面(押すときに使う座標)の大きさ""" try: d = json.loads(subprocess.run([POINT, "screen"], capture_output=True, text=True, timeout=10).stdout) return d["幅"], d["高さ"] except Exception: return None, None def _rect(rect): """screencapture に渡せる (x, y, 幅, 高さ) に整える。""" if rect is None: return None if not isinstance(rect, (list, tuple)) or len(rect) != 4: raise ValueError("枠は (x, y, 幅, 高さ) で指定してください") try: x, y, w, h = (int(float(v)) for v in rect) except (TypeError, ValueError): raise ValueError("枠の座標が不正です") if w <= 0 or h <= 0: raise ValueError("枠の幅と高さは正の数にしてください") return x, y, w, h def shot(path=None, window=None, rect=None): """画面を1枚撮る。 window にアプリ名を渡すと、そのアプリを前に出してから撮る。 rect に (x, y, 幅, 高さ) を渡すと、その枠だけを撮る。 """ path = path or os.path.join(tempfile.gettempdir(), f"kernel-shot-{int(time.time()*1000)}.png") if window: subprocess.run(["osascript", "-e", f'tell application "{window}" to activate'], capture_output=True, timeout=10) time.sleep(0.6) # -x は「カシャッ」という音を鳴らさない。人の作業のじゃまをしない rect = _rect(rect) argv = ["screencapture", "-x", "-t", "png"] if rect: argv += ["-R", "%d,%d,%d,%d" % rect] r = subprocess.run(argv + [path], capture_output=True, timeout=25) if r.returncode != 0 or not os.path.exists(path): raise Exception("画面を撮れませんでした。" "システム設定 → プライバシーとセキュリティ → 画面収録 " "で許可が要ります") return path def read(path, fast=False, langs="ja-JP,en-US", rect=None): """画像の中の文字を読む。座標は「画面の座標」に直して返す""" if not ready(): raise Exception("読み取りの道具がありません(tools/see)") argv = [SEE, path, "--lang", langs] + (["--fast"] if fast else []) r = subprocess.run(argv, capture_output=True, text=True, timeout=90) if r.returncode != 0: raise Exception((r.stderr or "読めませんでした").strip()[:200]) d = json.loads(r.stdout) # Retina のぶんを割り戻す。窓だけの画像は、窓の左上も足して # 全画面と同じ座標へ戻す。 sw, sh = screen_size() iw = d["大きさ"]["幅"] rect = _rect(rect) moto_w = rect[2] if rect else sw scale = (iw / moto_w) if (moto_w and iw) else 1.0 ox, oy = (rect[0], rect[1]) if rect else (0, 0) for it in d["文字"]: for k in ("箱", "まんなか"): it[k]["x"] = int(it[k]["x"] / scale + ox) it[k]["y"] = int(it[k]["y"] / scale + oy) if "幅" in it["箱"]: it["箱"]["幅"] = int(it["箱"]["幅"] / scale) it["箱"]["高さ"] = int(it["箱"]["高さ"] / scale) d["倍率"] = round(scale, 2) return d def look(window=None, fast=False, keep=False, rect=None): """撮って、読む。いちばんよく使う入口""" p = shot(window=window, rect=rect) try: return read(p, fast=fast, rect=rect) finally: if not keep: try: os.remove(p) except OSError: pass def find(text, seen=None, window=None, fast=False, rect=None): """画面から、その文字を探す。 戻り値: [{"文", "まんなか": {"x","y"}, "確からしさ"}] を、近い順に """ d = seen or look(window=window, fast=fast, rect=rect) q = text.strip().lower() if not q: return [] exact, part = [], [] for it in d["文字"]: s = it["文"].strip().lower() if s == q: exact.append(it) elif q in s or s in q: part.append(it) # ぴったり一致 → 部分一致。同じ中では、確からしさの高い順 exact.sort(key=lambda x: -x["確からしさ"]) part.sort(key=lambda x: -x["確からしさ"]) return exact + part def text_of(window=None, fast=False, rect=None): """画面に出ている文字を、まるごと1つの文字列で""" return look(window=window, fast=fast, rect=rect)["全文"] if __name__ == "__main__": import sys if len(sys.argv) > 1: hits = find(" ".join(sys.argv[1:])) if not hits: print(" 見つかりませんでした") for h in hits[:10]: c = h["まんなか"] print(f" ({c['x']:>5},{c['y']:>5}) {h['確からしさ']:.2f} {h['文'][:50]}") else: d = look() print(f" 画面 {screen_size()} / 撮った絵 " f"{d['大きさ']['幅']}×{d['大きさ']['高さ']} / 倍率 {d['倍率']}") print(f" 読めた文字: {len(d['文字'])} 個\n") for it in d["文字"][:20]: c = it["まんなか"] print(f" ({c['x']:>5},{c['y']:>5}) {it['文'][:56]}")