几十张截图一次性提交后,结果是一串文本,原图却按文件名排序,麻烦的地方不是识别错,而是对不上号:某条结论来自哪张图,事后查不回来。可行的做法是放量之前先在脚本里把三件事固定下来——输入目录与命名规则、图与问题的映射表、一图一行的输出记录。这样即使中途有失败重试,结果仍能按文件名回到原图。
适用场景:一次提交多张截图、且每张图可能配不同问题。操作动作:进入流程前给文件编号命名,用 CSV 或 JSON 映射表把文件名与问题绑定,输出逐行写回文件名和问题编号。验证方式:先用三张人工已知答案的样本跑一遍,逐行核对文件名、问题文本与返回结果是否一致。风险边界:并发数、超时与重试策略需结合所用接口的文档和环境确认,不要沿用别处默认值。
先定好输入目录和文件命名规则
截图进入流程之前就应该有唯一标识,否则后面所有对齐都无从下手。建议按“批次目录 + 固定位数序号”组织,不要直接把几十张截图堆在下载目录里跑。
shots/
20240612-refund/
001_order-list.png
002_order-detail.png
003_refund-form.png
mapping.csv
out/
results.jsonl
failures.jsonl
命名建议用「三位序号 + 业务标识」的写法:001_ 保证文件在目录里按预期排序,order-list 这类业务标识说明这张图是什么。序号位数固定,避免 2 和 10 在排序时打架;一张图配一个问题就存成一个文件,不要把多个问题塞进一张长截图,否则后面拆不开。
不要依赖接口返回结果的先后顺序来对齐。常见情况是:并发提交时返回顺序被打乱;某一条超时重试后晚到;返回体里不一定带原始文件名。只要顺序成为唯一线索,中间一条失败就会让后面全部错位,而且错得很安静——结果看着正常,只是对应错了图。
在脚本里维护图与问题的映射表
把「哪张图问什么」从临场决定变成可查的配置。建议一个批次一份 mapping.csv,和图片放在同一目录,跟着批次一起归档。
file,qid,question
001_order-list.png,Q1,这张订单列表里一共有几条待发货记录
002_order-detail.png,Q2,订单详情页显示的支付时间是什么
003_refund-form.png,Q3,退款表单里必填项有哪些
字段含义:file 是图片文件名,必须与目录中的实际文件名逐字一致,包括扩展名与大小写;qid 是问题编号,用于在输出和失败清单里引用,避免每行重复长文本;question 是这一行要提交的问题原文,写成完整句子,不要用「同上」这类依赖上下文的写法。同一张图要问多个问题时,让这张图在表里出现多行,file 相同、qid 不同,输出时按 file + qid 定位。
映射表建议存成 UTF-8,读入后先做一次存在性检查:表里有、目录里没有的文件直接报错退出,不要静默跳过。
请求与返回写成一对应的记录
骨架很短:读映射表、逐条提交、把结果写回带文件名的那一行。发送函数先用占位实现,参数和返回结构以实际接口文档为准,替换时只改 send_one 内部即可。
import csv, json, pathlib
def send_one(image_path, question, timeout=60):
# 占位实现:按实际接口文档替换
# 返回 {'ok': True, 'text': '...'} 或 {'ok': False, 'error': '...'}
raise NotImplementedError
def run(batch_dir):
batch_dir = pathlib.Path(batch_dir)
rows = list(csv.DictReader(open(batch_dir / 'mapping.csv', encoding='utf-8')))
out = open(batch_dir / 'out' / 'results.jsonl', 'a', encoding='utf-8')
for r in rows:
img = batch_dir / r['file']
if not img.exists():
continue
rec = {'file': r['file'], 'qid': r['qid'], 'question': r['question']}
try:
resp = send_one(img, r['question'])
rec['ok'] = resp.get('ok', False)
rec['text'] = resp.get('text', '')
rec['error'] = resp.get('error', '')
except Exception as e:
rec['ok'] = False
rec['error'] = type(e).__name__
out.write(json.dumps(rec, ensure_ascii=False) + '\n')
out.flush()
out.close()
关键点是每一行输出都带 file 和 qid,而不是只存一段文本。写 JSONL 而不是一个大 JSON 数组,是为了中途中断也能留下可读的部分结果;每行 flush 一次,长批次跑一半失败不至于全部丢失。
失败和超时单独落一份清单
失败被静默吞掉是最常见的隐患:脚本没报错、结果条数却对不上。建议把失败单独写成一份清单,字段至少包含文件名、问题编号、失败原因、已重试次数,同时明确跳过条件。
MAX_RETRY = 3 # 单条最多重试次数,按接口实际情况调整
TIMEOUT = 60 # 单次请求超时秒数
SKIP_ON = {'FileNotFound', 'InvalidImage'} # 这几类不重试,直接记失败
def submit_with_retry(fn, img, question):
last = 'unknown'
attempt = 0
for attempt in range(1, MAX_RETRY + 1):
try:
resp = fn(img, question, timeout=TIMEOUT)
if resp.get('ok'):
return {'ok': True, 'text': resp.get('text', ''), 'retry': attempt - 1}
last = resp.get('error', 'unknown')
if last in SKIP_ON:
break
except TimeoutError:
last = 'timeout'
except Exception as e:
last = type(e).__name__
if last in SKIP_ON:
break
time.sleep(2 ** attempt) # 简单退避
return {'ok': False, 'error': last, 'retry': attempt - 1}
失败清单建议写到同一批次目录下的 out/failures.jsonl,和 results.jsonl 并列,方便一起归档。复查方式是:拿失败清单里的 file 与 mapping.csv 做差集,确认没有哪个文件既不成功、也不在失败清单里;失败条目修好后可以单独组一个补充批次重跑,qid 保持不变,再和原结果合并。
用三条已知答案的样本验证对齐
放量之前先拿三张人工已知结果的图跑一遍,数量不用多,但必须是你自己一眼能对出答案的截图。
- 挑选样本:建议一张内容简单(如单一列表)、一张含表格或数字、一张排版较乱。人工把答案写在临时文件里,作为核对基准。
- 单独跑:把这三行放进一个临时 mapping.csv,用同一个脚本跑,输出写到单独的 out/smoke/ 目录,不要和正式批次混在一起。
- 逐行核对三件事:results.jsonl 里的 file 是否就是该行对应的图;question 是否与 mapping.csv 中该行文本一致;text 是否与人工答案对得上。再打开原图确认一次,防止文件名相似导致看错。
- 数量核对:输出行数是否等于映射表行数,失败清单是否为空或每条都有明确原因。
不通过时的排查顺序:先查 mapping.csv 里的文件名与目录实际文件名是否逐字一致(大小写、扩展名、全角空格);再查脚本是不是并发提交却按返回顺序写行;然后查发送函数有没有把图片路径和问题参数接反;最后才怀疑识别本身。实际操作中,对不上号多半出在前两处,而不是识别质量。