qinghuan
← 回到知识探索

AI 直觉 / 可下载小项目

视觉 Agent 找对了按钮,为什么坐标却落在别处?

逐步撤销截图缩放与裁剪,分清图片像素、视口 CSS 坐标和文档坐标,再拒绝过期观察。

展开本文目录
  1. 下载完整练习包
  2. 每个坐标都应该带着所属空间
  3. 按相反顺序撤销每一步
  4. 滚动偏移只在需要文档坐标时加
  5. 几何正确,不等于观察仍然有效
  6. 先验证来回变换,再接真实界面
  7. 交给 Agent 的应是一份观察记录
  8. 记住这些要点
  9. 别把例子用过头
  10. 自己试一小步
  11. 查原始资料
开始前,会这些就够了
遇到不熟悉的词,先看这 2 个解释
视口 CSS 坐标
从当前可见网页左上角起算的页面布局坐标;它不一定与图片文件中的像素一比一对应。
坐标空间
说明一对数字是从哪张图、哪一个原点和哪一种单位量出来的。
打开完整术语小抄 ↗

模型在一张缩小后的截图里找到了按钮中心 [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。

正向检查把恢复出的视口点再映射回显示图;如果不能回到原点,应先修复元数据或公式。即使检查通过,也只证明这一套给定变换自洽,没有证明视觉模型真的找到了正确按钮。

展开收起代码 · 小实验python · 89 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

小实验 · python
"""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 字段但从不在页面变化后更新它。

自己试一小步

如果图片先等比例缩小,再补黑边变成正方形,该怎样恢复按钮坐标?

  1. 明确记录左右或上下补边的大小。
  2. 先判断检测点是否落在补边中。
  3. 撤销补边,再反向执行缩放与裁剪。
  4. 加入横图、竖图、零补边和过期观察四类场景。

做到这些,就算完成

  • 用至少三个内点验证来回变换。
  • 补边中的点必须被拒绝。
  • 只提交坐标报告,不把算术验证写成真实点击成功。

查原始资料

想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。