手搓一个 Linux 桌面视觉控制系统:截屏→OCR→鼠标键盘全自动化

手搓一个 Linux 桌面视觉控制系统

截屏 → 百度OCR → 坐标换算 → 鼠标键盘,全链路实战


一、这个项目解决什么问题?

有些场景只能通过"看屏幕、动手点"来完成:

  • 管理后台没有 API,只有浏览器页面
  • 想自动化操作企业微信、钉钉之类的桌面应用
  • 每周机械重复:打开页面 → 读数据 → 填表单 → 点提交

常规方案(Selenium / PyAutoGUI)要么太重,要么在 Wayland 下跑不了。我需要的是一套 Linux 桌面通吃、代码量小、接入简单 的方案。


二、整体架构

项目分两层:

复制代码
Agent(调用方)
    │
    ▼
DaemonDesktopController ------ Unix socket JSON 协议
    │                              │
    │                              ▼
    │                    dctrl_daemon(常驻守护进程)
    │                         ├ 缓存百度 OCR token
    │                         ├ UInput 设备已初始化
    │                         └ Python 模块已导入
    │
    ▼
    物理桌面(Wayland / X11 自动适配)

为什么需要守护进程?因为每次 OCR 都重新启动 Python 太慢:

操作 耗时
Python 解释器启动 ~0.2s
导入 requests/PIL/pynput/evdev ~0.3s
获取百度 OCR token(HTTP 请求) 0.51s
守护进程每次调用 ~0.3s(只有截屏 + OCR 网络请求)

守护进程把固定开销摊平成一次,后续调用只需要传 socket 命令,省掉了 Python 启动和模块导入。


三、核心代码逐段分析

1. 平台检测 ------ mss 还是 gnome-screenshot?

文件头部,第一步判断当前桌面环境:

python 复制代码
# desktop_control.py 第 29-45 行
import os
from mss import mss as _mss

_USE_MSS = False
_try_mss = os.environ.get("XDG_SESSION_TYPE", "") != "wayland"
if _try_mss:
    try:
        from mss import mss as _mss
        _USE_MSS = True
    except ImportError:
        pass

核心逻辑:

  • 读取环境变量 XDG_SESSION_TYPE,如果是 "wayland" 就跳过 mss
  • X11 下优先用 mss(纯 Python 截图库,快、不需要外部进程)
  • Wayland 下回退到外部命令 gnome-screenshot

2. DesktopController 初始化 ------ 创建鼠标和键盘设备

python 复制代码
# desktop_control.py 第 70-91 行
class DesktopController:
    def __init__(self):
        self._is_wayland = os.environ.get("XDG_SESSION_TYPE", "") == "wayland"
        self._scale = self._detect_scale_factor()

        self.keyboard = KeyboardController()  # pynput 键盘

        if self._is_wayland:
            from evdev import UInput, ecodes
            cap = {
                ecodes.EV_KEY: [ecodes.BTN_LEFT, ecodes.BTN_RIGHT],
                ecodes.EV_REL: [ecodes.REL_X, ecodes.REL_Y, ecodes.REL_WHEEL],
            }
            self._uinput = UInput(cap, name="desktop-control", version=0x1)
        else:
            self._mouse = MouseController()    # pynput 鼠标

        self.access_token = None
        self._refresh_token()

这里有三个要点:

a) 键盘统一用 pynput 。不管 Wayland 还是 X11,pynput 的 KeyboardController 都能工作,因为它的底层走的是 /dev/uinput 或 XTest,都行。

b) 鼠标分两套方案 。X11 用 pynput 鼠标(直接用 Controller.position = (x,y) 设绝对坐标),Wayland 不行 ------ 因为 Wayland 安全模型不允许客户端程序读取或设置鼠标位置。Wayland 下只能用 evdev 的 UInput,模拟一个输入设备,发**相对移动(REL)**事件来移动光标。

c) 自动检测缩放系数。后面单独讲。

3. 显示缩放检测 ------ 为什么需要?

Ubuntu 默认开 200% 缩放,截屏拿到的是物理分辨率(如 2560×1440),但用户感知的是逻辑分辨率(如 1280×720)。百度 OCR 返回的坐标也是物理像素,如果直接操作鼠标会点到错误位置。

python 复制代码
# desktop_control.py 第 95-128 行
@staticmethod
def _detect_scale_factor():
    try:
        # 先截一张全屏,拿到物理分辨率
        subprocess.run(["gnome-screenshot", "-f", tmp_path], ...)
        physical = Image.open(tmp_path).size   # 例:(2560, 1440)

        # 假设 2x 缩放,逻辑分辨率是物理的一半
        logical = (physical[0] // 2, physical[1] // 2)

        scale = round(physical[0] / logical[0], 1)  # 例:2.0
        if scale < 1.0:
            scale = 1.0
        return scale
    except Exception:
        return 1.0  # 检测失败就当没有缩放

这里用的"先假设 2x 缩放再四舍五入"其实是偷懒的做法。更精确的方式是通过 gsettings 读取 org.gnome.desktop.interfacescaling-factortext-scaling-factor。不过在我们的场景下 1x / 2x 基本覆盖了所有常见情况,四舍五入即可。

4. 鼠标移动 ------ Wayland 的复位到零策略

这是整个方案里最棘手的部分。前面说了 Wayland 下只能用相对移动(REL),但相对移动的问题是:你不知道光标当前在哪

解法:先发一个大负偏移,让系统把光标 clamp 到屏幕左上角 (0,0),然后再从原点移动到目标。

python 复制代码
# desktop_control.py 第 131-158 行
@mouse_position.setter
def mouse_position(self, pos):
    target_x, target_y = pos

    if self._is_wayland:
        # 第1步:打回左上角
        self._uinput.write(_ec.EV_REL, _ec.REL_X, -5000)
        self._uinput.write(_ec.EV_REL, _ec.REL_Y, -5000)
        self._uinput.syn()
        time.sleep(0.1)

        # 第2步:从 (0,0) 分步移动到目标
        steps = max(1, target_x // 200, target_y // 200)
        step_x = target_x // steps
        step_y = target_y // steps
        for i in range(steps):
            sx = step_x if i < steps - 1 else target_x - step_x * (steps - 1)
            sy = step_y if i < steps - 1 else target_y - step_y * (steps - 1)
            self._uinput.write(_ec.EV_REL, _ec.REL_X, sx)
            self._uinput.write(_ec.EV_REL, _ec.REL_Y, sy)
            self._uinput.syn()
            time.sleep(0.02)
    else:
        # X11 直接设绝对坐标
        self._mouse.position = (int(target_x), int(target_y))

为什么分步移动?因为发送一个超大偏移(比如 +1920,+1080)可能导致鼠标瞬移、系统来不及跟踪,丢失精度。分 10~20 步发,每步间隔 20ms,保证系统能处理每个事件。

注意:-5000 这个值需要大于屏幕分辨率,才能保证 100% clamp 到左上角。2560×1440 的屏幕用 -5000 就够了,如果遇到 4K 屏可能要调到 -8000 以上。

5. 截屏 ------ 两套方案

python 复制代码
# desktop_control.py 第 168-186 行
def screenshot(self, monitor_index=1):
    if _USE_MSS:
        return self._screenshot_mss(monitor_index)
    else:
        return self._screenshot_wayland()

@staticmethod
def _screenshot_wayland():
    tmp = tempfile.NamedTemporaryFile(suffix=".png", delete=False)
    tmp_path = tmp.name
    tmp.close()
    try:
        subprocess.run(
            ["gnome-screenshot", "-f", tmp_path],
            check=True, timeout=15, capture_output=True,
            env={**os.environ, "DISPLAY": ":0"}
        )
        img = Image.open(tmp_path)
        return img
    finally:
        try:
            os.unlink(tmp_path)  # 用完就删临时文件
        except OSError:
            pass

Wayland 下有一个经典问题:gnome-screenshot 需要有 DISPLAY 环境变量才能工作。拿 systemd 管理的守护进程来说,默认没有这个环境变量,所以启动命令要显式传:

python 复制代码
env={**os.environ, "DISPLAY": ":0"}

6. 百度 OCR 接入

python 复制代码
# desktop_control.py 第 205-226 行
def ocr(self, image=None, mode="accurate"):
    if image is None:
        image = self.screenshot()

    b64 = self._image_to_base64(image)

    if mode == "standard":
        url = f"https://aip.baidubce.com/rest/2.0/ocr/v1/general?access_token={self.access_token}"
    else:
        url = f"https://aip.baidubce.com/rest/2.0/ocr/v1/accurate?access_token={self.access_token}"

    resp = requests.post(url, data={"image": b64}, timeout=15)
    resp.raise_for_status()
    return resp.json()

这里选了百度 OCR 的 accurate(高精度含位置版),因为:

接口 返回坐标 费用 适用场景
general_basic 免费额度多 纯文字提取
general(含位置) 中等 通用定位
accurate(含位置) 较贵 复杂背景、小字体

做桌面控制,必须含位置,否则拿不到点击坐标。而桌面上的按钮文字可能很小、背景复杂,用 accurate 精度更高。

返回的数据结构:

json 复制代码
{
  "words_result": [
    {
      "words": "登录",
      "location": {"left": 1200, "top": 800, "width": 80, "height": 40}
    },
    ...
  ]
}

每个文字块包含像素坐标(物理像素),后面要除以缩放系数。

7. 文字查找与坐标换算

python 复制代码
# desktop_control.py 第 231-262 行
def find_text(self, text, ocr_result=None, partial=True):
    if ocr_result is None:
        ocr_result = self.ocr()

    results = []
    scale = self._scale
    for item in ocr_result.get("words_result", []):
        word = item.get("words", "")
        if (partial and text in word) or (not partial and text == word):
            loc = item["location"]
            # 物理像素 → 逻辑像素(除以缩放系数)
            sl = int(loc["left"] / scale)
            st = int(loc["top"] / scale)
            sw = int(loc["width"] / scale)
            sh = int(loc["height"] / scale)
            cx = sl + sw // 2
            cy = st + sh // 2
            results.append({
                "text": word,
                "center": (cx, cy),
                "location": {"left": sl, "top": st, "width": sw, "height": sh},
            })
    return results

partial=True 是故意做的 ------ 桌面上的文字经常带后缀(如"确定(Enter)"),如果只精确匹配"确定"会漏掉。部分匹配可以容忍这种差异。

缩放处理loc["left"] / scale 把 OCR 返回的物理像素坐标换算成鼠标能用的逻辑像素。

8. 最常用的快捷方法:click_text

python 复制代码
# desktop_control.py 第 300-318 行
def click_text(self, text, partial=True, index=0):
    """查找文字并点击(最常用的快捷方法)"""
    matches = self.find_text(text, partial=partial)
    if not matches:
        print(f"❌ 未找到文字: {text}")
        return False

    if index >= len(matches):
        index = len(matches) - 1

    match = matches[index]
    cx, cy = match["center"]
    self.click(cx, cy)
    print(f"🖱️ 点击 [{match['text']}]  at ({cx}, {cy})")
    return True

三板斧:OCR → 找字 → 点击。调用方一行搞定:

python 复制代码
dc.click_text("提交表单")

如果屏幕上有多个"提交表单"文字(比如表格每行一个),用 index 参数选第几个。

9. 拖拽实现

python 复制代码
# desktop_control.py 第 329-363 行
def drag(self, x1, y1, x2, y2, duration=0.3):
    if self._is_wayland:
        self.mouse_position = (int(x1), int(y1))
        time.sleep(0.1)
        self._uinput.write(ecodes.EV_KEY, ecodes.BTN_LEFT, 1)  # 按下
        self._uinput.syn()
        time.sleep(0.05)
        # 分20步移动到目标
        steps = 20
        for i in range(steps + 1):
            t = i / steps
            nx = int(x1 + (x2 - x1) * t)
            ny = int(y1 + (y2 - y1) * t)
            dx = nx - cur_x
            dy = ny - cur_y
            self._uinput.write(ecodes.EV_REL, ecodes.REL_X, dx)
            self._uinput.write(ecodes.EV_REL, ecodes.REL_Y, dy)
            self._uinput.syn()
            time.sleep(duration / (steps + 2))
        self._uinput.write(ecodes.EV_KEY, ecodes.BTN_LEFT, 0)  # 松开
        self._uinput.syn()

拖拽的关键在于:按 → 移动 → 松。分步移动保证鼠标轨迹平滑,不会触发应用的防拖拽检测。

10. 调试工具 ------ 标注截图

python 复制代码
# desktop_control.py 第 385-417 行
def debug_screenshot(self, ocr_result=None, save_path=None):
    img = self.screenshot()
    if ocr_result is None:
        ocr_result = self.ocr(img)

    draw = ImageDraw.Draw(img)
    font = ImageFont.truetype("/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc", 16)

    for item in ocr_result.get("words_result", []):
        loc = item["location"]
        # 画红色矩形框
        draw.rectangle([
            (loc["left"], loc["top"]),
            (loc["left"] + loc["width"], loc["top"] + loc["height"])
        ], outline="red", width=2)
        # 框上方标注识别的文字
        draw.text((loc["left"], loc["top"] - 18), item["words"][:20], fill="red", font=font)

    img.save(save_path)
    return save_path

这个调试方法非常实用:截屏 → OCR → 把每个识别到的文字框画出来。可以用来检查 OCR 是否准确、有没有漏掉按钮文字。

效果:生成一张带红框标注的截图,保存在 scripts/debug/ 目录下。


四、守护进程架构

为什么要做守护进程?

直接调用 DesktopController 每次 OCR 操作耗时:

复制代码
启动 Python + 导包 + 刷新 Token = ~1.5s 固定开销
截屏 + OCR 网络请求 = ~0.5s 可变开销

做成守护进程后,固定开销只付出一次:

复制代码
socket 通信 = ~0.01s
截屏 + OCR 网络请求 = ~0.5s

通信协议:Unix socket + JSON

python 复制代码
# dctrl_daemon.py ------ 服务端
class DesktopDaemon:
    def run(self):
        sock = socket.socket(AF_UNIX, SOCK_STREAM)
        sock.bind("/tmp/desktop-control.sock")
        sock.listen(10)
        while self.running:
            conn, _ = sock.accept()
            data = conn.recv(65536)
            req = json.loads(data)
            resp = self._handle_command(req["cmd"], req["args"])
            conn.send(json.dumps(resp).encode())
            conn.close()
python 复制代码
# DaemonDesktopController ------ 客户端
def _send(self, cmd, args=None):
    sock = socket.socket(AF_UNIX, SOCK_STREAM)
    sock.settimeout(30)
    sock.connect("/tmp/desktop-control.sock")
    req = json.dumps({"cmd": cmd, "args": args})
    sock.sendall(req.encode())
    resp = sock.recv(65536)
    sock.close()
    result = json.loads(resp)
    if not result["ok"]:
        raise RuntimeError(result["error"])
    return result["data"]

socket 每次调用都建立新连接,用完就关。虽然不如长连接复用效率高,但胜在简单可靠,代码量少。

守护进程对每条命令的处理是纯函数式的,无状态,不存在连接归还问题。

自动启动逻辑

当 Agent 调用 DaemonDesktopController() 时,如果守护进程没在运行,会自动触发启动:

python 复制代码
# desktop_control.py 第 470-502 行
def _ensure_running(self):
    if os.path.exists(self._socket_path):
        return

    # 方法1:走 systemd 用户服务
    subprocess.run(["systemctl", "--user", "start", "desktop-control-daemon"], ...)

    # 方法2:兜底,直接 Popen 启动
    subprocess.Popen(["python3", str(script)], ...)

_ensure_running 做了两层兜底:优先走 systemd(能维持状态、支持 restart=on-failure),systemd 不可用时直接 Popen 启动。

systemd 服务配置

ini 复制代码
[Unit]
Description=Desktop Control Daemon
After=graphical-session.target

[Service]
Type=simple
ExecStart=%h/miniconda3/envs/openclaw/bin/python \
    %h/.openclaw/workspace/skills/desktop-control/scripts/dctrl_daemon.py \
    --foreground
Restart=on-failure
RestartSec=5

[Install]
WantedBy=default.target

关键点:

  • After=graphical-session.target:等图形界面就绪后再启动
  • Restart=on-failure:异常退出自动重启(OCR 网络超时、UInput 设备丢失等)
  • 指定绝对 Python 路径(conda 环境),避免 PATH 问题

五、从零构建这个 Skill

step 1: 安装依赖

bash 复制代码
# 必需
pip install pillow requests evdev pynput

# X11 截图用
pip install mss

# Wayland 截图用(系统工具)
sudo apt install gnome-screenshot

# 设置开机自启
systemctl --user enable desktop-control-daemon

step 2: 注册百度 OCR

  1. 打开 https://console.bce.baidu.com/ai/#/ai/ocr/overview/index
  2. 创建文字识别应用,拿到 AppID、API Key、Secret Key
  3. 填入代码头部:
python 复制代码
APP_ID = "你的AppID"
API_KEY = "你的APIKey"
SECRET_KEY = "你的SecretKey"

step 3: 创建项目目录

复制代码
desktop-control/
├── SKILL.md              # OpenClaw 技能描述
└── scripts/
    ├── desktop_control.py   # 核心类 + 守护进程客户端
    ├── dctrl_daemon.py      # 守护进程
    └── debug/               # 调试截图输出目录

step 4: 编写 SKILL.md 元数据

yaml 复制代码
---
name: desktop-control
description: "截屏 → 百度OCR → 坐标定位 → 鼠标键盘操作。支持Wayland(uinput)和X11(mss)。自动适配显示缩放。"
allowed-tools: [exec]
user-invocable: false
---

allowed-tools: [exec] 表示这个 Skill 通过 exec 调用 Python 脚本来完成工作。

user-invocable: false 表示用户不会直接叫它,而是 Agent 根据场景自动触发。

step 5: 注册 systemd 用户服务

把前面那个 service 文件放到:

复制代码
~/.config/systemd/user/desktop-control-daemon.service

然后:

bash 复制代码
systemctl --user daemon-reload
systemctl --user enable desktop-control-daemon
systemctl --user start desktop-control-daemon

六、完整使用示例

python 复制代码
from scripts.desktop_control import DaemonDesktopController

# 初始化(自动检测并启动守护进程)
dc = DaemonDesktopController()

# 1. 截屏 + OCR,找出"登录"按钮并点击
dc.click_text("登录")

# 2. 查找多个结果,点第二个
dc.click_text("修改密码", index=1)

# 3. 纯坐标点击
dc.click(500, 400)

# 4. 拖拽
dc.drag(100, 100, 800, 600)

# 5. 键盘输入
dc.type_text("hello world")
dc.press_key("enter")
dc.hotkey("ctrl", "c")

# 6. 调试:生成带标注的截图
path = dc.debug_screenshot()  # 返回文件路径

七、踩过的坑

问题 原因 解决
Wayland 下鼠标点不到正确位置 UInput REL 需要从已知原点出发 复位到零策略:先发 -5000、-5000 打回左上角
截屏命令超时 systemd 服务没有 DISPLAY 环境变量 显式传 env={"DISPLAY": ":0"}
OCR 坐标偏移 200% 显示缩放导致物理≠逻辑像素 坐标 ÷ 缩放系数
守护进程启动失败 conda Python 路径不在 PATH ExecStart 写绝对路径
连续多次 OCR 太慢 每次重刷 token、重新导包 用守护进程+缓存 token
mss 在 Wayland 下报错 mss 依赖 X11 共享内存 检测 XDG_SESSION_TYPE 自动切换
验证码 OCR 读不出来 CAPTCHA 设计就是防 OCR 别硬搞,停下来让人处理

八、适用场景 & 局限

✅ 适用

  • 桌面 GUI 应用的自动化操作
  • 老旧 Web 系统的批量操作
  • 产品验收、回归测试时的视觉验证
  • 个人日常工作流自动化

❌ 不适合

  • 验证码 ------ 不解决,这是 OCR 的天花板
  • 大规模并发操作 ------ 每次 ~300ms,不适合高频场景
  • 纯键盘操作 ------ 有更好的方案(xdotool 等)

九、文件清单

文件 行数 作用
scripts/desktop_control.py ~800 行 DesktopController + DaemonDesktopController
scripts/dctrl_daemon.py ~160 行 Unix socket 守护进程
SKILL.md ~30 行 技能元数据 + 使用说明
~/.config/systemd/user/desktop-control-daemon.service ~15 行 systemd 服务配置
相关推荐
lishi_19911 小时前
一键部署MoviePilotV2实现NAS全自动追剧
python·媒体·moviepilot
测试开发-学习笔记3 小时前
代码详细解释
python
u0119608233 小时前
ray-k8s部署
python
PAK向日葵5 小时前
我用 C++ 写了一个轻量级 Python 虚拟机,刚刚开源
c++·python·开源
财经资讯数据_灵砚智能7 小时前
基于全球经济类多源新闻的NLP情感分析与数据可视化(日间)2026年5月26日
大数据·人工智能·python·信息可视化·自然语言处理·ai编程·灵砚智能
我材不敲代码7 小时前
Python基础:列表详解、增删改查及常用高阶操作
开发语言·windows·python
AI玫瑰助手7 小时前
Python运算符:成员运算符(in/not in)的使用场景
开发语言·python·信息可视化
Warson_L8 小时前
python - class 入门
python
水木流年追梦8 小时前
大模型入门-大模型分布式训练2
开发语言·分布式·python·算法·正则表达式·prompt
ZHANG8023ZHEN8 小时前
Diffusion 数学推理
人工智能·python·机器学习