遇到不熟悉的词,先看这 2 个解释
打开完整术语小抄 ↗模型在一张缩小后的截图里找到了按钮中心 [210, 135],把这两个数字直接交给鼠标接口,指针却偏了三百多个 CSS 像素。识别可能没有错,错的是把“哪张图上的坐标”丢掉了。
我们用固定数字构造一次观察,反向撤销裁剪、缩放和设备像素比例。程序只做坐标计算,不截屏、不连接浏览器、不点击任何界面;这样可以把坐标协议单独验证清楚。
把例子带到自己的电脑上
下载完整练习包
需要 Python 3.10 或更新版本,无第三方依赖;制作时实测 Windows / Python 3.14.0。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。
下载 ZIP · 5 个文件包里有什么
- screenshot_coordinates.py:完整可运行示例
- README.md:运行说明与独立练习
- observed.stdout.txt:本次实际输出
- verification.json:源码和产物验证记录
- SHA256SUMS.txt:内容摘要
在这个文件夹打开 PowerShell,逐行运行
python --version
python -B screenshot_coordinates.py解压到独立练习文件夹,在含 screenshot_coordinates.py 的目录打开终端运行。固定视口截图元数据的坐标逆变换与过期标记检查;未截屏、未运行视觉模型、未操控浏览器或点击。
找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。
每个坐标都应该带着所属空间
显示给模型的图片坐标、原始截图栅格像素、网页视口 CSS 坐标与整页文档坐标,可能是四个不同空间。同样写 [210, 135],没有空间名称就无法正确解释。
Playwright 的 Mouse 接口使用主框架视口内的 CSS 像素。截图接口的 scale 可以选择 css 或 device,二者可能产生不同像素尺寸。因此“截图一个像素等于鼠标一个单位”不能默认成立。
本文限定为普通的视口截图,先裁剪,再独立按横纵比例缩放,没有旋转、透视、边框留白或整页拼接。实际链路若增加了这些处理,必须把相应变换也记录下来,不能继续套用这份缩小模型。
这里的 crop_raster 表示已截图像裁剪后保留的光栅像素区域;它不是 Playwright screenshot(clip=...) 的参数。后者使用 CSS 像素,调用真实工具前必须核对它的坐标约定,不能直接套用这里的光栅数值。
按相反顺序撤销每一步
设视口为 1280×720 CSS 像素,原始截图为 2560×1440 栅格像素。裁剪框左上角是 [600,300],大小为 1200×800,再缩成 600×400 给模型。模型返回 [210,135]。
第一步撤销缩放:[210×1200/600, 135×800/400]=[420,270],这是裁剪前局部图里的栅格坐标。第二步加回裁剪原点:[420+600,270+300]=[1020,570],回到完整截图。
第三步按完整截图与视口宽高之比换算:[1020×1280/2560,570×720/1440]=[510,285] CSS 像素。这才是本例鼠标视口坐标。直接使用 [210,135] 会产生约 335.410197 CSS 像素的欧氏距离误差。
脚本使用实际记录的栅格宽高与 CSS 视口宽高计算比例,没有把设备像素比写死为 2。若截图已经选择 scale=css,就不能再无条件除以设备像素比。
滚动偏移只在需要文档坐标时加
若页面向下滚了 900 CSS 像素,视口坐标 [510,285] 对应文档坐标 [510,1185]。程序同时输出两者,并明确命名。把 1185 再交给要求视口坐标的 Mouse 接口,相当于多加了一次滚动量。
W3C 的 CSSOM View 定义区分 clientX/clientY 与 pageX/pageY;本文借此固定普通主文档中的坐标含义。移动端视觉视口、浏览器缩放、嵌套滚动与 iframe 还可能需要额外关系,示例没有覆盖这些情形。
另建一份 scale=css 的截图元数据,把裁剪原点与大小相应减半,仍显示为 600×400。对同一个 [210,135] 反算,最终视口点仍为 [510,285]。这项检查证明本例两套元数据表达了同一位置。
几何正确,不等于观察仍然有效
截图之后页面可能滚动、展开菜单或加载出新内容。旧截图算出的坐标在数学上仍然正确,却可能已经指向新的元素。于是每份观察携带一个 epoch,例如 view-1;消费坐标时必须与当前状态版本一致。
脚本把当前版本改成 view-2 后拒绝使用旧坐标;还拒绝恰好位于显示图右边界 x=600 的点。这里采用左闭右开范围 0≤x<宽度、0≤y<高度,避免把边界外位置当成有效像素。
epoch 本身不会自动发现页面变化。它有效的前提是调用方确实在相关变化发生后更新版本。真实自动化还需要重新观察和定位核对;本文只演示过期标记的协议,不声称单靠一个字符串就能保证点击目标正确。
先验证来回变换,再接真实界面
保存为 screenshot_coordinates.py,运行 python -B screenshot_coordinates.py。程序核对四个点的正向与逆向变换、两种截图比例等价、过期版本和图外坐标,并写入 report.json。
正向检查把恢复出的视口点再映射回显示图;如果不能回到原点,应先修复元数据或公式。即使检查通过,也只证明这一套给定变换自洽,没有证明视觉模型真的找到了正确按钮。
展开收起代码 · 小实验
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
"""Invert an explicitly described screenshot crop/resize. Performs no UI action."""
from dataclasses import dataclass
import json
import math
from pathlib import Path
@dataclass(frozen=True)
class Observation:
viewport_css: tuple
raster_size: tuple
crop_raster: tuple # left, top, width, height, before resizing
displayed_size: tuple
scroll_css: tuple
epoch: str
def validate(self):
vw, vh = self.viewport_css
rw, rh = self.raster_size
x, y, w, h = self.crop_raster
dw, dh = self.displayed_size
if min(vw, vh, rw, rh, w, h, dw, dh) <= 0:
raise ValueError('Dimensions must be positive.')
if x < 0 or y < 0 or x + w > rw or y + h > rh:
raise ValueError('Crop must lie inside the viewport screenshot.')
def invert(self, point, current_epoch):
self.validate()
if current_epoch != self.epoch:
raise ValueError('Observation epoch is stale; obtain a new observation.')
px, py = point
dw, dh = self.displayed_size
if not (0 <= px < dw and 0 <= py < dh):
raise ValueError('Point must lie inside displayed image, excluding right/bottom edge.')
left, top, width, height = self.crop_raster
rx = left + px * width / dw
ry = top + py * height / dh
vw, vh = self.viewport_css
rw, rh = self.raster_size
cx, cy = rx * vw / rw, ry * vh / rh
sx, sy = self.scroll_css
return {'raster': [rx, ry], 'viewport_css':[cx, cy], 'document_css':[cx + sx, cy + sy]}
def forward(self, viewport_point):
cx, cy = viewport_point
vw, vh = self.viewport_css
rw, rh = self.raster_size
left, top, width, height = self.crop_raster
dw, dh = self.displayed_size
return [(cx * rw / vw - left) * dw / width,
(cy * rh / vh - top) * dh / height]
def main():
device = Observation((1280,720),(2560,1440),(600,300,1200,800),(600,400),(0,900),'view-1')
css = Observation((1280,720),(1280,720),(300,150,600,400),(600,400),(0,900),'view-1')
detected = (210,135)
result = device.invert(detected, 'view-1')
assert result['viewport_css'] == [510.0,285.0]
assert result['document_css'] == [510.0,1185.0]
assert css.invert(detected, 'view-1')['viewport_css'] == result['viewport_css']
assert device.forward(result['viewport_css']) == list(detected)
naive_error = math.dist(detected, result['viewport_css'])
print(f'detected in resized crop: {list(detected)}')
print(f"full screenshot raster: {result['raster']}")
print(f"mouse viewport CSS: {result['viewport_css']}")
print(f"document CSS with scroll: {result['document_css']}")
print(f'naive viewport error: {naive_error:.6f} CSS px')
print('CSS-scale and device-scale screenshots: same recovered viewport point')
rejected = []
for name, point, epoch in [('stale',(210,135),'view-2'),('outside',(600,100),'view-1')]:
try:
device.invert(point, epoch)
except ValueError:
rejected.append(name)
print(f'{name}: rejected')
else:
raise AssertionError(f'{name} was not rejected')
for point in [(0,0),(1,1),(599.5,399.5),(210,135)]:
recovered = device.invert(point, 'view-1')['viewport_css']
assert all(math.isclose(a,b,abs_tol=1e-10) for a,b in zip(device.forward(recovered),point))
report = {'detected':detected,'recovered':result,'naive_error_css_px':naive_error,'rejected':rejected,
'scope':'Fixed viewport screenshot metadata and affine arithmetic; no screenshot captured and no click performed.'}
Path('report.json').write_text(json.dumps(report, indent=2, allow_nan=False)+'\n', encoding='utf-8')
print('checks passed: round trips, scale equivalence, stale epoch, image bounds')
if __name__ == '__main__':
main()
怎样运行:在独立练习文件夹中执行 python -B screenshot_coordinates.py。2026-10-17 已在 Windows、Python 3.14.0 运行此份源码并通过自带检查。固定视口截图元数据的坐标逆变换与过期标记检查;未截屏、未运行视觉模型、未操控浏览器或点击。
运行后对照结果
detected in resized crop: [210, 135]
full screenshot raster: [1020.0, 570.0]
mouse viewport CSS: [510.0, 285.0]
document CSS with scroll: [510.0, 1185.0]
naive viewport error: 335.410197 CSS px
CSS-scale and device-scale screenshots: same recovered viewport point
stale: rejected
outside: rejected
checks passed: round trips, scale equivalence, stale epoch, image bounds交给 Agent 的应是一份观察记录
需要保存的最小信息包括视口 CSS 宽高、原始截图栅格宽高、裁剪原点与大小、最终显示宽高、滚动偏移、观察版本以及坐标空间名称。若图像处理链路改变,应同时改变元数据,不能让文字说明和图片各走各的。
本次只运行固定元数据的仿射算术,没有获取真实截图、调用视觉模型、操控浏览器或验证网页命中率。后续接入真实接口时还要按实际截图选项、框架坐标规范和页面变化机制补齐检查。
记住这些要点
- 图片上的坐标必须连同坐标空间和处理元数据保存。
- 撤销顺序是缩放、裁剪,再换成接口要求的像素单位。
- 文档滚动偏移不能加给要求视口坐标的鼠标接口。
- 几何变换正确与观察仍然有效是两项独立条件。
别把例子用过头
- 截图已是 CSS 比例仍再除一次设备像素比。
- 只记录缩放比例,丢失裁剪左上角。
- 把 page 坐标直接当成 viewport 坐标点击。
- 有 epoch 字段但从不在页面变化后更新它。
自己试一小步
如果图片先等比例缩小,再补黑边变成正方形,该怎样恢复按钮坐标?
- 明确记录左右或上下补边的大小。
- 先判断检测点是否落在补边中。
- 撤销补边,再反向执行缩放与裁剪。
- 加入横图、竖图、零补边和过期观察四类场景。
做到这些,就算完成
- 用至少三个内点验证来回变换。
- 补边中的点必须被拒绝。
- 只提交坐标报告,不把算术验证写成真实点击成功。
查原始资料
想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。
- Playwright:Page.screenshot (新标签页)↗
截图 scale 的 css 与 device 语义,以及截图选项必须记录。
- Playwright:Mouse (新标签页)↗
鼠标位置使用主框架视口 CSS 像素。
- CSSOM View Module (新标签页)↗
clientX/clientY、pageX/pageY和滚动偏移的坐标定义。