05|看懂图片和演示视频:把客户操作变成可回查的证据

27 阅读11分钟

“我点了几次都正常。”阿杰在话题里回了一句。

门店负责人很快发来一段录屏:“你看这个,顾客就是这么点的。”

顾客选了两杯水果茶,结算页显示 32 元。点下确认支付,页面短暂闪过一条提示,又停在原处。只看最后一帧,没有明显报错;把中间那一小段放慢,才能看清“价格已更新,请重新确认”。

小林问:“那现在该查价格,还是查按钮没反应?”

原始文字写的是“点不了支付”。如果 Agent 只拿这句话去搜,网络、按钮事件、支付服务都可能被列进排查范围。客户已经上传的画面,却没有参与调查。

这是《从工单开始,做一个 AI Agent》的第 05 篇。上一版能按活动条件查资料,这次让附件提供可回查的线索。人物和工单仍是虚构场景;下面的抽帧和 OCR 实验实际运行过,输入是代码生成的合成视频,没有使用客户资料。

先分清画面告诉了我们什么

这条提示能支持“页面出现过价格变化提示”,暂时支持不了“优惠券计算错了”。后台可能返回了不同金额,也可能只是价格版本变化;还可能是前端对一个异常码的统一展示。要判断哪种情况,需要继续读代码和查运行记录。

图片与视频进入 Agent 后,常见的错误是把观察和解释混在一个摘要里。比如模型写出“顾客使用优惠券导致支付失败”,这句话同时添加了因果关系、后台状态和支付结果,画面本身并没有给出这些证据。

我们先收窄这版能力:识别截图和视频采样帧中的文字,把候选文字、位置、时间和原始附件关联起来。没有动作识别,没有语音转写,也没有训练一个会理解所有门店操作的视频模型。前面的故事提供业务背景,实验只检验其中“短暂提示是否被采到并识别”这一件事。

附件到可回查证据的处理流程

流程分成两层。MediaReader 负责处理已授权的本地附件;media_handler 把它包装成 Agent 工具,限制模型能查的附件和返回量。第 02 篇保存的飞书附件引用还没有自动下载到这一层,这次使用本地授权目录演示。接真实飞书资源接口时,还需要把租户鉴权、资源获取和失效处理补到入口。

不能只保存一句识别结果

假设回复写着“视频里出现价格更新”。阿杰要复核时,仍然得从头播放一遍。换成下面的记录,就能回到具体位置:

{
  "source": "attachment://checkout#frame=12",
  "media_time_s": 1.2,
  "frame_index": 12,
  "text": "价格已更新,请重新确认",
  "confidence": 0.5,
  "certainty": "uncertain_text",
  "event_at": null
}

实际记录还带附件 SHA-256、原消息 ID、接收时间和文字框位置。完整结构见 media.py。

这里有三个容易混淆的时间:消息接收时间、视频播放时间、业务事件发生时间。视频第 1.2 秒只是相对位置;客户可能晚了十分钟才上传,也可能发的是上午的录屏。因此 observed_at 保存接收记录的时间,media_time_s 保存帧时间,event_at 保持空值,不能把上传时间自动当成下单时间。

位置来自 OCR 的归一化文字框,坐标原点在左下角。以后做浏览器高亮,如果页面坐标原点在左上角,需要转换纵坐标;否则文字能认对,框却画到了另一处。我们把这个坐标约定保留在字段名里,不让消费者猜。

SHA-256 则用于确认复核时拿到的是同一份附件。它不证明附件内容真实,也不证明文件来自可信客户;它只解决“这次解析的字节和登记时是否一致”。授权范围仍然由品牌、门店及附件目录约束。

做一个会漏掉提示的实验

样本是一段 720×960、10 fps、3 秒、无音轨的视频。两杯饮品合计 32 元,提示仅出现在第 11、12、13 帧,对应区间 [1.1, 1.4) 秒。输入生成器和真值文件放在 fixtures/media,内容明确标注为合成演示。

下载实验视频。下面这张图是从视频第 12 帧实际解码得到的,不是另画的一张示意图。

视频 1.2 秒处实际抽取的画面

先每秒抽一帧。采样点在 0、1、2 秒,正好避开提示。然后由操作者把窗口缩到 0.8—1.6 秒,改成每秒 5 帧,采样 0.8、1.0、1.2、1.4 秒。这个窗口是人工指定的,不能写成 Agent 自己已经学会定位可疑片段。

策略处理帧数提示文字结果
全段 1 fps3没有匹配到
局部 5 fps,0.8—1.6 秒41.2 秒帧出现候选,置信分数 0.5

这次使用 macOS Vision 的文字识别,FFmpeg 负责解码,FFprobe 提供帧时间。本地环境是 macOS 27.0、FFmpeg 8.1.2,结果保存在 experiment-results.json。没有调用云模型 API。

它不是准确率测试。只有一个人为安排提示时机的样本,能说明这种稀疏采样会漏掉短提示,以及局部加密在这个样本上找到了候选。换一台设备、一个字体或一种编码,OCR 输出和分数可能变化,不能从这张表推导线上识别成功率。

为什么不一开始就处理每一帧?因为成本随采样数增加。十几秒录屏还容易接受,几分钟的视频加上几十张截图,很快会把值班队列占满。实际系统可以先做低成本扫描,再对客户指出的时间段或候选变化区间加密;代价是没采到的区间仍然未知,回复不能省略这个限制。

帧号和时间要来自解码结果

代码没有用“第 N 张导出图片 × 抽帧间隔”冒充原视频时间。它先读取 best_effort_timestamp_time,根据这些时间选择原始帧号,再用 FFmpeg 的 select 导出对应帧。

selected = sample_frames(times, fps=5, start=0.8, end=1.6)
# 当前样本:[(8, 0.8), (10, 1.0), (12, 1.2), (14, 1.4)]

这种方式保留了原视频中的位置,适合演示固定频率采样。遇到可变帧率视频,也不把帧号除以一个假定帧率当成时间。如果容器没有可用时间戳,当前实现直接返回元数据错误,不补一个看起来合理的时间。

导出后检查帧数是否和选择结果一致,再逐帧 OCR。临时 PNG 在处理完成后清理,原视频、帧号、时间和哈希用于重新提取。本文展示的一张实验帧单独保留,读者可以直接看,不必先安装工具。

识别正确的文字,也可能只有低分

这次提示文字识别对了,但 Vision 返回的分数是 0.5。代码用 0.8 作为演示阈值,把它标为 uncertain_text,没有为了让结果好看就改成高置信度。

这个阈值没有经过业务数据校准,分数也不是“这句话有 50% 概率正确”。它只是识别器的候选置信分数,适合决定是否提示人工回看,不能直接当成诊断概率。换场景后应当用标注样本评估阈值,而不是沿用一个小数就算完成验收。

同一次 OCR 还把画面里的“优惠券”识别成了“优惠劵”。对人来说容易理解,对按字匹配的工具却可能造成漏检。当前工具按查询文字做子串匹配,因此查“价格已更新”能命中,查一个被识别错的完整句子可能查不到。后续可以增加别字候选或文本相似匹配,但匹配放宽后,要继续保留原始候选,不能把修正后的词悄悄当成原文。

同样,画面上的“确认支付”是按钮文字,不是支付成功记录;“优惠券待核对”是页面状态,不是后台优惠资格判断。OCR 做完,只是多了一组有位置的观察。

把解析能力注册成一个受限工具

MEDIA_TOOL 只让模型传 query。附件 ID、采样频率和时间窗口由应用层绑定,模型不能把一个路径或 URL 塞进参数,让工具去下载别人的文件。

from ticket_agent.media import MEDIA_TOOL, MediaReader, media_handler

reader = MediaReader('/tmp/vision-ocr')
handler = media_handler(reader, 'checkout', fps=5, start=0.8, end=1.6)
# 交给现有执行器:
# tools=[MEDIA_TOOL], handlers={'inspect_media': handler}

调用结果最多返回 6 条匹配候选,超出时标记 truncated。每条候选的引用文字包含不确定标记和“不是后台执行证据”。现有执行器要求最终答案原样带回全部本轮证据,脚本化测试验证了模型若删掉这个限定,会被拒绝。

这也不代表任意自然语言解释就都可靠了。当前回复约束比较保守,适合把“发现了什么”交给值班人员;它没有自动把附件、订单和资料组织成完整调查,也没有让模型自行选择最佳视频片段。工具协议是后续接入点,真实模型对它的调用效果尚未测试。

附件处理本身也需要预算

本地目录按品牌和门店过滤,文件必须位于登记目录内,内容哈希必须匹配。演示限制单文件 20 MB、视频 20 秒、画面 400 万像素、最多处理 40 帧,采样频率上限 10 fps。预算超出直接返回状态,不静默删掉后半段,再声称“完整看完”。

外部命令通过参数列表调用,没有让模型生成 Shell;FFmpeg 和 FFprobe 只允许本地文件相关协议。每个子进程最多运行 30 秒。这是单条命令超时,不是整个附件任务 30 秒硬截止,逐帧识别累积仍可能更长。

这些约束只是 Demo 的输入边界。实际接客户附件,还要考虑解码库隔离、并发限额、存储保留期、用户撤回后的清理,以及录屏里的个人信息。当前既没有生产隔离沙箱,也没有自动隐私脱敏,因此使用的都是可公开的合成输入。

失败也要区分。hash_mismatch 表示内容变化,attachment_unavailable 表示登记文件不可用,frame_budget_exceeded 表示采样计划超限。它们都不是“视频里没问题”。no_matching_text 也只表示选中帧的 OCR 候选没有命中,不能扩展为整段视频没有这个提示。

在本机重跑

本篇代码快照 包含累计源码、测试、已生成视频和实验记录。解压后进入 code/。Python 使用 3.11+;原生 OCR 路径需要 macOS、Swift 编译器和 FFmpeg。其他平台仍可运行纯 Python 的逻辑测试,但没有实现替代 OCR 后端。

swiftc scripts/vision_ocr.swift -o /tmp/vision-ocr
python3 -m ticket_agent.media checkout --ocr /tmp/vision-ocr --fps 1
python3 -m ticket_agent.media checkout --ocr /tmp/vision-ocr --fps 5 --start 0.8 --end 1.6
python3 -m ticket_agent.media notice --ocr /tmp/vision-ocr
TICKET_OCR_BINARY=/tmp/vision-ocr python3 -m unittest discover -s tests -v

本次装有前文固定的飞书 SDK,并设置了 OCR 路径,累计 70 项测试全部通过,包含真正解码和识别该视频的集成测试。不设 OCR 环境变量会跳过原生集成测试;没有飞书 SDK 时还会跳过对应 SDK 契约测试。

需要重新生成视频时,再安装 Pillow;本次生成使用 Pillow 12.3.0。生成器用 --font 接收本机可用中文字体,仓库没有分发系统字体。直接重跑识别不需要 Pillow。具体说明见共享代码运行文档。

回到话题里

阿杰点开 1.2 秒处的画面,重新看了一遍。

“提示对得上。先查价格更新的分支,别直接写支付按钮没响应。”

小林问:“那能说优惠券算错了吗?”

“还不能。识别结果分数也不高,先人工确认文字。视频没有后台返回值。”

她在调查记录里留下候选文字、帧位置和待核对项。客户不必再把视频重述一遍,研发也知道接下来该从哪里查。

阿杰接着打开结算页:“我找一下这句话在哪个分支弹出来。”

老周补了一句:“先确认门店用的是哪个版本。主分支的代码,不一定就是出问题时的代码。”

下一篇从这个问题开始,把只读代码工具接上,并区分“源码里存在一条路径”和“本次请求确实走过这条路径”。

参考资料