手搓一个 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.interface 的 scaling-factor 或 text-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
- 打开 https://console.bce.baidu.com/ai/#/ai/ocr/overview/index
- 创建文字识别应用,拿到 AppID、API Key、Secret Key
- 填入代码头部:
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 服务配置 |