图像实战:读扫描件、核对 OCR 结果
💡 一句话总结:用 Jev 图像识别建立两条纸质文档流水线 —— 扫描件「Noul 找在不在、Box 找在哪、Choice 读值」,OCR 结果「整页核对一遍、裁剪细看一遍」,低分转人工。
你在先修课 Box 与图像里学会了发带图的请求、读懂四条边。这一课把它拼成完整项目。官方给了三个图像 cookbook:Reading a scanned vaccination record(读疫苗扫描件)、OCR confidence and bounding boxes(核对发票 OCR 值)、Feeder-cam visit log(喂鸟相机物种日志)。本课走通前两个,第三个在进阶技巧里合并讲解。
官方的数字先摆出来,看看这套方法的实际效果:16 种候选疫苗一次请求全部判对——在记录上的 6 种概率 0.97 以上,长得很像的「甲肝乙肝联合疫苗」只有 0.02;被贴纸盖住的日期,模型答「看不清」而不是硬猜;两张发票 OCR 的三处误读(AN400074、INV03070、16/09/2028)全部被两次检查抓出。本课约 20 分钟;官方示例带 json_cache.json 答案缓存——按官方说明,缓存放在运行目录里时脚本重放缓存而不是真请求,没 Key 也能跑出与本页相同的数字。
学完你能做什么
- 写出扫描件读取流水线:Noul + Box 找到表格里你要的每一行,Choice 读出值,看不清时拿到
unreadable而不是瞎猜的年份 - 把「读图」和「按读数判断」拆成两个请求——官方实测:合并成一个问题的版本让两处问题全部漏过复核线
- 核对 OCR 抽取值:每个字段整页查一遍、裁剪图再细看一遍,低于 0.25 转人工,并在原图上画出复核框
- 用同一套题处理连续视频帧:喂鸟相机 101 帧里有没有鸟判全对(101/101),物种认对 132/133
为什么用 Jev 图像识别,而不是让多模态 LLM 直接看图?
「把扫描件发给 GPT 让它帮我读」是很多人的第一反应——能跑,但你拿到的是一段话,不是能进代码的判断。
| 能力 | 多模态 LLM 直接看图 | Jev 图像识别 |
|---|---|---|
| 看不清的内容 | ❌ 倾向于还是给一个答案——贴纸下的日期照样「读」出来 | ✅ Choice 里放 unreadable 选项,官方示例里模型真的选了它,旁边可读性 Noul 只有 0.05 |
| 位置信息 | ❌「表格中偏上一行」式的散文描述 | ✅ Box 返回四条边比例,直接裁剪出那一行给复核的人看 |
| 把握度 | ❌ 每次问「你确定吗」答案还会变 | ✅ 每道题带概率:在页上的疫苗 0.97+、不在的 0.02,阈值写在你代码里 |
| 判断与读图 | ❌ 一个 prompt 全包了,错了不知道是没读对还是判断错 | ✅ 读图一个请求、按读数判断另一个请求——哪一步错了各自看得清 |
| 答案形态 | ❌ 自由文本,下游要写解析器 | ✅ 类型化答案 + 概率分布,if 直接用 |
一句话:LLM 替你「描述」图,Jev 替你「判断」图——判断才有概率,概率才能设复核线。
你现在的困境
- 「堆成山的纸质扫描件要录入系统,OCR 跑完了,但没人知道哪些字段读错了——逐个核对的时间比手打还长」
- 「让多模态模型读扫描件,被污渍盖住的日期它也敢给我一个年份,错了完全不报警」
- 「想让模型告诉我目标在图里哪个位置,它回我一段『画面中偏左处』的散文,裁剪代码根本没法写」
什么时候用这一招
当你需要:
- 从扫描件、拍照的单据里读结构化信息:表格行、日期、编号、金额
- OCR 已经抽出了值,但要有东西逐字段把关:值对不对、在不在页上、看没看错
- 判断和位置一起拿:这一行说了什么、在图的哪个位置(好裁出来给人复核)
而且不想:
- 训练或部署一个专用视觉模型
- 解析多模态模型自由发挥的自然语言回复
- 在「读图」和「判断」搅在一起的答案里猜哪一步错了
🎒 开始前的准备
- 完成了 Box 与图像:会用 Python SDK 的
Image发图、知道TypeSafe-Danger-Zone-Images: true请求头、能读懂四条边比例 - 建议读过 Noul 是非题和 Choice 选择题:本课每一步都是这两种题加 Box 的组合
- 跑通过安装与第一个请求:
.venv可用。本课前半部分不设 Key 也能跑(官方缓存重放);想真发请求再设TYPESAFE_API_KEY - 一杯水 ☕——本课是 C 路线的收官之作:三个官方 cookbook,读扫描件、核发票、看鸟,全是同一套「守门 + 定位 + 读值」的套路
跟我做
新建 image_docs.py,把下面每一步的代码块按顺序贴进同一个文件。第 1–5 步是疫苗记录扫描件(官方示例),第 6 步换成发票 OCR 核对(官方另一个示例,改动量:换图、换字段表)。暂时没有 Key?照样能跑——官方 json_cache.json 里存着本页所有数字的原始响应,脚本检测到缓存就重放,不真发请求(想真跑就把缓存文件删掉)。
第 1 步:装环境、下官方扫描件示例与缓存
为什么:官方 cookbook 把每次 API 响应都录进了 json_cache.json——官方原话:缓存在,脚本就重放这些响应而不是调用 API,所以你得到和官方一样的数字。先把物料备齐,后面每一步才有官方数字可对。
安装依赖(官方疫苗记录 cookbook 的安装命令,加了 pillow——第 5 步裁剪要用):
pip install ipython matplotlib pillow 'typesafe-sdk>=0.7.5,<0.8.0' \
'cooksafe>=0.2.0,<0.3.0'从官方示例仓库下载扫描件和缓存(typesafe-public-examples,和脚本放同一目录):
curl -sL -o vaccination_record.jpg https://raw.githubusercontent.com/typesafe-ai/typesafe-public-examples/main/cookbooks/reading_scanned_documents/vaccination_record.jpg
curl -sL -o json_cache.json https://raw.githubusercontent.com/typesafe-ai/typesafe-public-examples/main/cookbooks/reading_scanned_documents/json_cache.json贴入文件头——客户端、阈值常量、带缓存的请求函数:
import hashlib
import json
import os
from datetime import date
from pathlib import Path
from time import perf_counter
from cooksafe import JsonCache
from typesafe_sdk import Image, TypeSafeClient
TYPESAFE_MODEL = "jev-1.14.0" # Box questions need Jev 1.14
IMAGES_ON = {"TypeSafe-Danger-Zone-Images": "true"} # opt in to the image preview
READ_CUTOFF = 0.90 # below this, a person checks the reading against the paper
LEGIBLE_CUTOFF = 0.50 # the legibility Noul gets its own, lower cutoff
IN_DATE_CUTOFF = 0.50 # below this, a nurse checks whether the dose still covers the trip
client = TypeSafeClient(
api_key=os.environ.get("TYPESAFE_API_KEY", "cache-only"), # keyless kernels replay
timeout=120.0,
)
json_cache = JsonCache(Path("json_cache.json"))
PAGE = Path("vaccination_record.jpg")
PAGE_SHA = hashlib.sha256(PAGE.read_bytes()).hexdigest()[:16] # cache key for the scan
@json_cache
def ask(model: str, page_sha: str, questions_json: str) -> dict:
"""One request: the scan as the state, plus a set of questions about it."""
started = perf_counter()
response = client.system_one(
state={"page": Image(PAGE)},
questions=json.loads(questions_json),
model=model,
extra_headers=IMAGES_ON,
)
return {
"answers": {k: a.model_dump(mode="json") for k, a in response.answers.items()},
"seconds": round(perf_counter() - started, 2),
}
def ask_page(questions: dict) -> dict:
if not questions: # nothing to ask, such as a page with no rows found
return {"answers": {}}
return ask(TYPESAFE_MODEL, PAGE_SHA, json.dumps(questions, sort_keys=True))
print("setup ok")运行 python image_docs.py。
你应该看到:setup ok——这一步只是装配,还没发请求。
⚠️ 实验特性,官方原话要贴在墙上
Images 和 Box 是早期预览(官方叫 dreamer-ready,未达生产标准):带图或带 Box 的请求必须加 TypeSafe-Danger-Zone-Images: true 头,且请求格式可能在版本之间变化。Box 问题需要 Jev 1.14——jev-latest 已指向 jev-1.14.0。原型阶段放心玩,上生产自己掂量。图片本身也有限制:PNG/JPEG、每边 280–1568 像素、单文件 ≤ 16 MiB,高分辨率扫描件先缩再发,详见 Box 与图像一课的限制表。
setup ok打印出来了,vaccination_record.jpg和json_cache.json都在运行目录里
第 2 步:Jev 图像识别第一问——Noul 在不在、Box 在哪一行
为什么:Jev 不做「把图提取成数据列表」这件事——它回答关于图的问题。所以官方的拆法是:你有一张清单(这家诊所打的 16 种疫苗),对每个候选问两道题。这正是 Box 永远返回一个框的解法:Noul 管「在不在」,Box 管「在哪」,Noul 说不在就不理会那个框。
一次请求发 33 道题:16 个疫苗 ×(Noul + Box)+ 1 道 Noul 查「表里有没有清单外的疫苗」——最后一道是官方专门加的:其他 32 道题抓不到「列表漏了」这种情况。混题型并行提问的机制见并行提问与投机扇出。
VACCINES = [
"Hepatitis A", "Hepatitis B", "Hepatitis A and B combined", "Typhoid",
"Hepatitis A and typhoid combined", "Yellow fever", "Rabies", "Japanese encephalitis",
"Tick-borne encephalitis", "Cholera", "MenACWY", "MenB",
"Tetanus, diphtheria and polio (Td/IPV)", "MMR", "Varicella", "Influenza",
]
PRESENT_CUTOFF = 0.5 # P(recorded) at or above this counts the vaccine as on the page
def find_questions() -> dict:
questions = {"unlisted": {
"type": "noul",
"instructions": {
"question": "Does the vaccines table in `page` record any vaccine that is not "
"in this list?",
"list": VACCINES,
},
}}
for i, vaccine in enumerate(VACCINES):
questions[f"vac_{i:02d}"] = {
"type": "noul",
"instructions": f"Is {vaccine} recorded in the vaccines table in `page`, with "
"exactly this name?",
}
questions[f"vac_{i:02d}|box"] = {
"type": "box",
"instructions": f"the whole {vaccine} row of the vaccines table in `page`",
}
return questions
found = ask_page(find_questions())
F = found["answers"]
present = []
for i, vaccine in enumerate(VACCINES):
p, box = F[f"vac_{i:02d}"]["noul"], F[f"vac_{i:02d}|box"]
print(f"{vaccine:<40}recorded {p:.2f} box top {box['box']['top']:.2f}")
if p >= PRESENT_CUTOFF:
present.append({"vaccine": vaccine, "present": p, "box": box["box"],
"box_confidence": box["confidence"]})
print(f"\nanother vaccine on the page: {F['unlisted']['noul']:.2f}")
rows = sorted(present, key=lambda r: r["box"]["top"]) # box tops give the page order
for n, row in enumerate(rows, 1):
row["id"], row["position"] = f"row-{n}", n运行 python image_docs.py。
你应该看到(官方 jev-1.14.0 录制数字):
Hepatitis A recorded 0.98 box top 0.33
Hepatitis B recorded 0.98 box top 0.40
Hepatitis A and B combined recorded 0.02 box top 0.36
Typhoid recorded 0.98 box top 0.48
Hepatitis A and typhoid combined recorded 0.01 box top 0.34
Yellow fever recorded 0.98 box top 0.55
Rabies recorded 0.97 box top 0.69
Japanese encephalitis recorded 0.01 box top 0.35
Tick-borne encephalitis recorded 0.01 box top 0.34
Cholera recorded 0.01 box top 0.34
MenACWY recorded 0.00 box top 0.34
MenB recorded 0.01 box top 0.35
Tetanus, diphtheria and polio (Td/IPV) recorded 0.98 box top 0.62
MMR recorded 0.01 box top 0.33
Varicella recorded 0.01 box top 0.34
Influenza recorded 0.01 box top 0.34
another vaccine on the page: 0.02三个读法:
- 长得像的名字骗不过它:清单里故意放了「甲肝乙肝联合疫苗」「甲肝伤寒联合疫苗」这类近亲——真在页上的 6 种全在 0.97 以上,近亲全在 0.02 以下,一档拉开
box top就是免费的行号:6 行按top边排序,正好是它们在页面上的顺序——第 5 步裁剪、给复核的人看,全靠这个顺序- 列表外的疫苗也没漏:那道「守门 Noul」答 0.02——表里没有清单外的疫苗。要是它高于 0.5,整页都要标出来人工看(第 5 步)
官方还提醒了一件事:16 种疫苗 33 道题没问题,但清单有几千项时题目可能装不进一个请求(上下文限制)——先按类别分组、每组一道 Noul 缩小范围,再对命中的组逐项细问。
💡 这就是 Jev 图像识别的独有价值
每个候选一个概率,让你能把「在不在」变成阈值问题:0.97 和 0.02 之间没有灰色地带,PRESENT_CUTOFF = 0.5 一刀切得干干净净。多模态 LLM 给你的「我看到甲肝和乙肝的记录」里没有这些数字,你只能选择信或不信。
- 16 行概率打出来了:6 种 ≥ 0.97、近亲 ≤ 0.02、
unlisted0.02;我能说出为什么最后那道 Noul 不能省
第 3 步:Choice 读年份,看不清就答 unreadable
为什么:找到行只是上半场,还得读出接种年份才知道针还在不在有效期。关键的坑:Choice 永远会挑一个选项——日期被贴纸盖住时,不给逃生舱它就硬挑一个最像的年份。官方的解法:选项列表末尾放 other 和 unreadable,让模型可以明说「读不出」;再加一道独立的 Noul 问「日期是否完全可读」,作为第二信号。
YEAR = {
**{f"y{n}": str(n) for n in range(2016, 2027)},
"other": "A year before 2016 or after 2026",
"unreadable": "The year is covered, stained, smudged or damaged and can't be read",
}
def read_questions(row: dict) -> dict:
where = f"the DATE GIVEN cell of the {row['vaccine']} row of the vaccines table in `page`"
i = row["id"]
return {
f"{i}|year": {
"type": "choice", "criteria": YEAR,
"instructions": "Dates on this record are written day/month/year, so the year "
f"is the last part. What year is written in {where}?",
},
f"{i}|legible": {
"type": "noul",
"instructions": f"Is the date in {where} fully legible, with nothing covering, "
"staining, smudging or blurring any of it?",
},
}
read = ask_page({k: q for row in rows for k, q in read_questions(row).items()})
A = read["answers"]
for row in rows:
i = row["id"]
row["year"], row["year_confidence"] = A[f"{i}|year"]["choice"], A[f"{i}|year"]["confidence"]
row["legible"] = A[f"{i}|legible"]["noul"]
UNRESOLVED = ("other", "unreadable") # picks that leave the row without a year
def year_given(row: dict) -> int | None:
return None if row["year"] in UNRESOLVED else int(row["year"][1:])
def as_text(row: dict) -> str:
year = year_given(row)
return str(year) if year else row["year"]
print(f"{'vaccine':<40}{'year':<12}legible")
for row in rows:
print(f"{row['vaccine']:<40}{as_text(row):<12}{row['legible']:.2f}")运行。
你应该看到(官方 jev-1.14.0 录制数字):
vaccine year legible
Hepatitis A 2026 0.96
Hepatitis B 2026 0.86
Typhoid 2021 0.96
Yellow fever 2025 0.96
Tetanus, diphtheria and polio (Td/IPV) 2024 0.96
Rabies unreadable 0.05两个细节值得停下来看:
狂犬病那行,模型答了
unreadable——它的日期被一张批次贴纸盖住了。可读性 Noul 同步给出 0.05,两个信号互相印证。这就是逃生舱的意义:读不出被报出来,而不是被猜出来可读的日期,可读性 Noul 也只有 0.86–0.96(其中 0.86 那行被荧光笔划过)——比 Choice 的置信度低,所以官方给可读性单独设了更低的 0.50 复核线,而不是沿用 0.90。不同问题有自己的分数尺度,阈值别一刀切
六行年份打印出来了;我能说出
other和unreadable各自挡住什么(列表外的年份 vs 根本读不出的日期)
第 4 步:纯文本请求核对有效期——读图与判断分开
为什么:这一步是 cookbook 里很值得学的一个设计。读对了日期还不够——伤寒针 2021 年打的,字迹清楚,但保护期约三年,2026 年 11 月出发时早过了。这个判断官方不让模型对着图做,而是把读出的年份作为纯文本发第三个请求:图不带了,判断只关于「这一针在出发日还有没有效」,和读图彻底解耦。
日期算术(距今几年)由代码算好放进 state——日期比较是 Jev 的已知弱项,算术永远在代码里。
DEPARTURE = date(2026, 11, 12) # from the traveller's booking
def before_departure(year: int) -> str:
years = DEPARTURE.year - year
return "less than a year" if years == 0 else f"about {years} year{'s' * (years != 1)}"
CHECK_CONTEXT = (
"Each entry is one line of an adult traveller's vaccination record, as read from a "
f"scan. The traveller leaves on {DEPARTURE.day} November {DEPARTURE.year}. Judge "
"only whether the dose will still protect the traveller on that date, by usual "
"travel-health guidance for adults."
)
IN_DATE_QUESTION = "Will this dose still protect the traveller on the departure date?"
IN_DATE_CRITERIA = {
"true": "The dose is within the usual protection period for this vaccine on the "
"departure date.",
"false": "The dose is past the usual protection period for this vaccine by the "
"departure date.",
}
def check_questions(readable: list[dict]) -> dict:
return {
row["id"]: {
"type": "noul",
"instructions": {"question": IN_DATE_QUESTION, "entry": row["id"]},
"criteria": IN_DATE_CRITERIA,
}
for row in readable
}
def check_state(readable: list[dict]) -> dict:
return {
"context": CHECK_CONTEXT,
"entries": [{"id": r["id"], "vaccine": r["vaccine"], "year_given": as_text(r),
"given_before_departure": before_departure(year_given(r))}
for r in readable],
}第三个请求不带图、也不加 images 请求头——判断只关于读出的年份文本,同样套上缓存装饰器:
@json_cache
def check(model: str, state_json: str, questions_json: str) -> dict:
started = perf_counter()
response = client.system_one(
state=json.loads(state_json), questions=json.loads(questions_json), model=model
)
return {
"answers": {k: a.noul for k, a in response.answers.items()},
"seconds": round(perf_counter() - started, 2),
}
readable = [r for r in rows if year_given(r) is not None]
checked = check(TYPESAFE_MODEL, json.dumps(check_state(readable)),
json.dumps(check_questions(readable))) if readable else {"answers": {}}
for row in rows:
row["in_date"] = checked["answers"].get(row["id"])
year = year_given(row)
ago = before_departure(year) if year else "-"
shown = " -" if row["in_date"] is None else f"{row['in_date']:.2f}"
print(f"{row['vaccine']:<40}{as_text(row):<12}{ago:<20}{shown}")运行。
你应该看到(官方 jev-1.14.0 录制数字):
Hepatitis A 2026 less than a year 0.94
Hepatitis B 2026 less than a year 0.83
Typhoid 2021 about 5 years 0.38
Yellow fever 2025 about 1 year 0.95
Tetanus, diphtheria and polio (Td/IPV) 2024 about 2 years 0.93
Rabies unreadable - -伤寒针 0.38——被拎出来了。狂犬病没有分数,因为年份没读出来(第 3 步答了 unreadable),代码直接不送它进这个请求。
为什么一定要拆成两个请求?官方在附录里做了反例实验——把有效期问题直接对着图问(出发日期写在页眉上):
vaccine from the image from the readings
Hepatitis A 0.88 0.94
Hepatitis B 0.85 0.83
Typhoid 0.50 0.38
Yellow fever 0.96 0.95
Tetanus, diphtheria and polio (Td/IPV) 0.84 0.93
Rabies 0.88 -对着图问:狂犬病 0.88 过关了(日期明明被贴纸盖着);伤寒 0.50 正好压线也过关。官方点破原因:一个答案盖了两步——你分不清是模型没读对日期,还是判断本身错了。拆开后(本页主流程),狂犬病在读数一步就被拦下,伤寒的判断分 0.38 也送了人工。
💡 这就是 Jev 图像识别的独有价值
流水线每一站都有自己的概率和自己的复核线,哪一站出问题、该谁去看,答案里写得明明白白。合并成一个问题,等于把两条报警器焊成一条——还都失灵。
- 伤寒 0.38、狂犬病
-都对上了;我能对着附录反例说出「读图与判断分开」的两个好处
第 5 步:低分标记,按 Box 裁剪送人工
为什么:三步读数 + 一步判断,现在把所有低分汇成一张复核清单。规则全是纯代码——Jev 只出概率,送谁去人工是你的阈值说了算。
def reading_score(row: dict) -> float:
"""The weakest of the answers that say the row was found and read."""
return min(row["present"], row["year_confidence"])
def review_reasons(row: dict) -> list[str]:
reasons = []
if (row["year"] in UNRESOLVED or reading_score(row) < READ_CUTOFF
or row["legible"] < LEGIBLE_CUTOFF):
reasons.append("Check the reading")
if row["in_date"] is not None and row["in_date"] < IN_DATE_CUTOFF:
reasons.append("Check the dose")
return reasons
def page_reasons() -> list[str]:
"""Flags that belong to the whole page rather than to one row."""
if F["unlisted"]["noul"] >= PRESENT_CUTOFF:
return ["Check the page: it may record a vaccine outside the list"]
return []
for row in rows:
in_date = "–" if row["in_date"] is None else f"{row['in_date']:.2f}"
flags = ", ".join(review_reasons(row)) or "–"
print(f"| {row['vaccine']} | {as_text(row)} | {reading_score(row):.2f} | "
f"{row['legible']:.2f} | {in_date} | {flags} |")
print(f"page: {', '.join(page_reasons()) or 'not flagged'}")运行。
你应该看到(官方 jev-1.14.0 录制数字):
| Hepatitis A | 2026 | 0.98 | 0.96 | 0.94 | – |
| Hepatitis B | 2026 | 0.98 | 0.86 | 0.83 | – |
| Typhoid | 2021 | 0.98 | 0.96 | 0.38 | Check the dose |
| Yellow fever | 2025 | 0.98 | 0.96 | 0.95 | – |
| Tetanus, diphtheria and polio (Td/IPV) | 2024 | 0.98 | 0.96 | 0.93 | – |
| Rabies | unreadable | 0.97 | 0.05 | – | Check the reading |
page: not flagged (another vaccine on the page: 0.02)两条标记规则、一条页级规则:
- 「核对读数」:年份落进
other/unreadable、读数分(present与year_confidence取较弱)低于 0.90、可读性低于 0.50——任一命中 - 「核对针剂」:有效期分低于 0.50。注意伤寒的高读数分救不了低判断分——官方原话:判断是基于一个可能读错的年份做的,两条标记互不抵消
- 页级标记:
unlisted的 Noul ≥ 0.5 就整页送审——那个疫苗没有自己的行,行级规则抓不到它
最后是给人看的部分:按 Box 裁出被标记的整行(官方代码用 matplotlib 画图,此处给出裁剪核心——第 2 步存下的 box 现在派上用场):
PAD = 0.02 # fraction of the page added around each box before cropping
LOW_BOX_CONFIDENCE, LOW_PAD = 0.6, 0.05 # a less certain box gets a wider margin
def crop(box: dict, confidence: float):
from PIL import Image
page = Image.open(PAGE)
w, h = page.size
pad = LOW_PAD if confidence < LOW_BOX_CONFIDENCE else PAD
top, bottom = max(0.0, box["top"] - pad), min(1.0, box["bottom"] + pad)
left, right = max(0.0, box["left"] - pad), min(1.0, box["right"] + pad)
return page.crop((int(left * w), int(top * h), int(right * w), int(bottom * h)))
for row in rows:
if review_reasons(row):
crop(row["box"], row["box_confidence"]).save(f"review-{row['id']}.jpg")
print(f"saved review-{row['id']}.jpg ({', '.join(review_reasons(row))})")运行后,review-row-3.jpg(伤寒,核对针剂)和 review-row-6.jpg(狂犬病,核对读数)落在运行目录里——复核的人不用在整页扫描件上找,直接看裁出来的那一行。
官方最后给了调参建议:这些阈值(0.90 / 0.50 / 0.50)是起点,不是圣旨——拿人工核对过的记录调出你自己的线。阈值思路的原理见 Confidence 置信度。
- 两条标记互不抵消:伤寒只标了针剂、狂犬病只标了读数;我的
review-*.jpg裁出来了
第 6 步:发票 OCR 核对——两次检查抓出三处误读
为什么:扫描件读出来了,反过来呢?OCR 从发票照片里抽值——大部分是对的,但逐个核对的时间和手打一样长。官方的打法:Jev 把每个抽出的值核对两遍——对着整页查一遍(页面核对),对着裁剪图再查一遍(裁剪核对),任一低于 0.25 转人工。复核的人只看被标出来的那几个。
场景很真实:一张是折痕很深的复写发票(Milkwood,1393×1080),一张是中间有道阴影的(Siyakhula,899×1599)——OCR 读出 8 个字段(供应商、发票号、日期、应付金额、银行四项),其中三处读错:
| 发票 | 字段 | OCR 抽到的 | 纸上印的 |
|---|---|---|---|
| Milkwood | 发票号 | AN400074 | IN400074 |
| Siyakhula | 发票号 | INV03070 | INV03079 |
| Siyakhula | 日期 | 16/09/2028 | 16/09/2026 |
官方的数字来自 jev-1.14.0(2026-10-15 录制)。这部分需要另装 typesafe-sdk[pil] 扩展并下载第二套示例,依赖与下载:
pip install ipython pillow 'typesafe-sdk[pil]>=0.7.5' 'cooksafe>=0.2.0,<0.3.0'发票照片、extractions.json(OCR 抽出的值)和第二套 json_cache.json 从 官方示例仓库的 ocr_confidence_bounding_boxes 目录下载,照片放 inputs/ 文件夹。注意这套缓存和第 1 步的 json_cache.json 同名,放同一目录会互相覆盖——给发票示例单开一个文件夹,或先把前五步跑完再下载。官方明说:OCR 和 LLM 那两步(他们用的是 Apple Vision 加一个 LLM)不在代码里——任何抽取器都行,因为核对只需要照片和抽出的值。
三个请求、每张发票各跑一遍。第一个请求:整页照片 + 每个字段两道题(Box 找位置、Noul 查「抽到的值是不是这个字段」):
FIELDS = {
# field: what it is, in the words every question uses
"supplier_name": "the supplier's (seller's) business name",
"invoice_number": "the invoice or document number",
"invoice_date": "the invoice date",
"amount_due": "the final total amount payable",
"bank_name": "the name of the supplier's bank in the payment details",
"account_holder": "the account holder name in the supplier's bank details",
"account_number": "the supplier's bank account number",
"branch_code": "the supplier's bank branch code",
}
SAME_VALUE = "Ignore differences in spacing, punctuation, currency symbols and letter case."
# request 1: the whole photo, a Box + a Noul per field
def page_questions(values: dict) -> dict:
from typesafe_sdk import Box, Noul
questions = {}
for field, value in sorted(values.items()):
questions[f"box_{field}"] = Box(
instructions=f"{FIELDS[field]}, printed as “{value}” in `page`"
)
questions[f"fits_{field}"] = Noul(
instructions=f"Is “{value}” {FIELDS[field]} on `page`? {SAME_VALUE}"
)
return questions第二个请求把每个字段的框周围区域裁出来放大重问 Box(区域至少占页宽 30%、页高 15%,第一次的框偏了也不会出区域);第三个请求裁出每个新框(四边加余量)问一道 Noul:「这张裁剪图上清楚印着这个值吗?被切断、看不清、或不一样都答否」——Ignore differences in spacing, punctuation... 让空格、标点、大小写、货币符号的差异不算错。每个字段的裁剪图各自嵌在自己那道题的 instructions 里、client.attach_assets() 一次带出(图像引用进正文的机制见 Box 与图像一课的进阶技巧)。
全部落进一个判定函数:任一检查低于 BAR = 0.25 就 review。官方跑完的记分板:
photo # field extracted fits page crop verdict
milkwood.jpg 1 supplier_name MILKWOOD mushrooms 0.98 0.95 ok
milkwood.jpg 2 invoice_number AN400074 0.88 0.18 REVIEW
milkwood.jpg 3 invoice_date 01/09/2026 0.53 0.82 ok
milkwood.jpg 4 amount_due 504.70 0.95 0.80 ok
milkwood.jpg 5 bank_name Standard Bank 0.98 0.53 ok
milkwood.jpg 6 account_holder MILKWOOD 0.90 0.57 ok
milkwood.jpg 7 account_number 080 11 4941 0.35 0.92 ok
siyakhula.jpg 1 supplier_name Siyakhula Trading Services 0.96 0.31 ok
siyakhula.jpg 2 invoice_number INV03070 0.02 0.04 REVIEW
siyakhula.jpg 3 invoice_date 16/09/2028 0.17 0.66 REVIEW
siyakhula.jpg 4 amount_due R1,305.65 0.97 0.84 ok
siyakhula.jpg 5 bank_name Standard Bank 0.97 0.98 ok
siyakhula.jpg 6 account_holder Siyakhula Trading 0.93 0.96 ok
siyakhula.jpg 7 account_number 10 13 205 260 5 0.96 0.78 ok
siyakhula.jpg 8 branch_code 051001 0.97 0.94 ok盯着三处误读的分数看——这是全课最要紧的一张表:
AN400074(纸上印的是IN400074,首字母 I 被认成了 A):页面核对 0.88 放过了(整页照片里小字太小),裁剪核对 0.18 抓住16/09/2028(纸上印的是16/09/2026,末位 6 被认成了 8):裁剪核对 0.66 放过了,页面核对 0.17 抓住INV03070(纸上印的是INV03079,末位 9 被认成了 0):两道都低(0.02 / 0.04),双杀
官方原话:单靠哪一次检查都抓不全三处。两次检查视角互补——整页看「这个值在这个版面里像不像」,裁剪看「这几个字符到底是不是」。而全部正确的值两次都过了(最低 0.35 / 0.31)。
- 我能说出三处误读各自被哪次检查抓住、哪次放过;
BAR = 0.25的判定是「或」不是「且」
📋 魔法 Prompt
本页的「Prompt」是问题与代码模板——抄走、替换【】占位符就能接到你自己的扫描件上。三件套:找行二人组、读值逃生舱、OCR 双检查。
📝 找行二人组(Noul + Box 版)
预期效果:凡是「清单上的东西可能在文档里、要找到并定位」的需求——疫苗、签名、印章、条目行——同一副题换清单就能用。
# 必填项:【清单】换你要找的东西列表;instructions 里的 `page` 换你的图在 state 里的字段名
questions = {
"unlisted": Noul(instructions={
"question": "Does the table in `page` record any 【东西类型】 that is not in this list?",
"list": 【清单】,
}),
}
for i, item in enumerate(【清单】):
questions[f"item_{i:02d}"] = Noul(
instructions=f"Is 【东西】 recorded in the table in `page`, with exactly this name?")
questions[f"item_{i:02d}|box"] = Box(
instructions=f"the whole 【东西】 row of the table in `page`")
# 选填项:近亲名称故意放进清单里测区分度(官方放了 Hepatitis A and B combined)?
# 输出规范:P(yes) ≥ 0.5 视为在页上;按 box["box"]["top"] 排序得到页面顺序
# 约束条件:
# ✅ Noul 说不在页上,就不理会它那个 Box——Box 永远返回一个框
# ✅ unlisted 那道守门题保留:行级规则抓不到「列表漏了」
# ❌ 清单几千项别硬塞一个请求——先按组 Noul 预筛,再对命中组细问✅ 读值逃生舱(Choice 版)
预期效果:从图里读任何封闭集的值(年份、月份、面额、版号),读不出时拿到明确的 unreadable 而不是瞎猜。
# 必填项:【选项集】+ 两个逃生舱;【值在原文里的写法】写进 instructions 帮模型定位
VALUE = {
**{【你的选项集】},
"other": "【选项集之外的值】",
"unreadable": "The value is covered, stained, smudged or damaged and can't be read",
}
question = Choice(
instructions="【值在原文里怎么写的(如 day/month/year,年份是最后一部分)】"
"What 【值】 is written in 【位置描述,如 the DATE GIVEN cell of the X row】?",
criteria=VALUE,
)
# 选填项:旁边加一道 Noul 问「这段完全可读吗」做第二信号(官方:可读性的分数尺度更低,阈值单独设)?
# 输出规范:answer.choice + answer.confidence;other/unreadable 让该行无值可判,直接送人工
# 约束条件:
# ✅ Choice 永远挑一个——没有逃生舱它就硬猜,这是本模板存在的理由
# ❌ 不要把「读值」和「按值判断」合并成一道题对着图问(见第 4 步附录反例)🔧 OCR 双检查流水线(代码版)
预期效果:把 OCR 抽出的每个字段核对两遍,低于阈值转人工并画出复核框。
BAR = 0.25 # a check scoring below this flags the field
def check_invoice(photo, values) -> dict:
first = find_fields(photo, values) # 请求 1:整页 Box + Noul 页面核对
tight = zoom_fields(photo, values, first["boxes"]) # 请求 2:放大重框
shown = check_crops(photo, values, tight["boxes"]) # 请求 3:裁剪 Noul 细看
return {
field: {
"value": values[field],
"box": tight["boxes"][field],
"fits": first["fits"][field], # 页面核对分
"shows": shown["shows"][field], # 裁剪核对分
"review": first["fits"][field] < BAR or shown["shows"][field] < BAR,
}
for field in sorted(values)
}
# 必填项:【照片】【values:OCR 抽出的字段 map】;阈值 BAR 按你的容错调
# 选填项:复核框画在原图上——review 橙色、通过蓝色,复核的人对着位置看?
# 输出规范:每字段 {value, box, fits, shows, review};review=True 进人工队列
# 约束条件:
# ✅ 任一检查低于阈值就转人工——「或」不是「且」(单次检查抓不全)
# ✅ SAME_VALUE 句式保留:空格、标点、大小写、货币符号差异不算错
# ❌ 别指望整页一次看全:小字要靠裁剪放大,两次检查视角互补检查点 ✅
- 我跑通了疫苗扫描件全流程:33 道题找行 → Choice 读年份(
unreadable逃生舱)→ 纯文本请求核有效期 → 低分标记 + 裁剪 - 我能说出为什么读图与判断要拆两个请求,以及合并版漏掉了什么(狂犬病 0.88 过关、伤寒 0.50 压线过关)
- 我能对着记分板说出三处 OCR 误读各被哪次检查抓住:
AN400074裁剪、16/09/2028整页、INV03070双杀 - 我的阈值都标了归属:读数 0.90、可读性 0.50、有效期 0.50、OCR 0.25——且知道要拿人工核对过的样本自己调
- 我知道两个请求都带了
TypeSafe-Danger-Zone-Images: true,第三个纯文本请求不用带
踩坑提醒
❌ 不给逃生舱,Choice 就硬猜
被贴纸盖住的日期、被污渍糊掉的编号——Choice 永远挑一个选项,没有「读不出」这个出口时,它会挑一个最像的年份给你,而且 confidence 还不低。官方的解法双保险:选项列表末尾放 other(列表外)和 unreadable(读不出),旁边再加一道独立的可读性 Noul。官方示例里狂犬病那行:Choice 答 unreadable、可读性 Noul 0.05,两个信号都对了。
❌ 读图和判断合并成一道题
「这针在出发日还有效吗?出发日期在页眉上」——对着图直接问,官方实测狂犬病拿 0.88 过关(日期明明被盖住)、伤寒 0.50 压线过关。原因:一个答案盖了两步,你分不清是没读对还是判断错。正确拆法:读图请求出结构化读数,判断请求只收文本——狂犬病在读数步被拦,伤寒判断 0.38 送人工。
❌ 拿着 Box 的框直接当真
Box 永远返回一个框——图里没有的东西,它照样给四条边。疫苗记录里 10 种不在页上的疫苗,个个都有 box top 0.33 这样的框。正确做法就是第 2 步的二人组:Noul 说在(≥ 0.5)才用框;列表外的守门 Noul ≥ 0.5 时整页送审。另外注意:BoxAnswer 的 confidence 按 API schema 可能为 null(模型不提供时),阈值判断前先判空,别让代码直接 TypeError。
❌ 小图直接发、大图不缩
API 的图片限制超了直接拒、不会自动缩放:PNG/JPEG 静态图、每边 280–1568 像素、总像素 78,400–2,458,624、单文件 ≤ 16 MiB。高分辨率扫描件先把最长边压到 1568;反过来,裁剪出的小图小于 280 的边要放大(官方把放大重框的区域图放大到 1200、裁剪核对的图放大到 800 再发,实在太小就贴到白画布中央、画布位置记下来换算回坐标)。还有一条阴人的:API 处理时会重编码,处理后的图可能比原文件还大——发一堆小图也可能撞上「请求里全部图片合计 32 MiB」的上限。
进阶技巧(可选)
同一个套路看连续视频帧:喂鸟相机物种日志。 官方第三个图像 cookbook(Feeder-cam visit log):一段 101 秒的喂鸟相机视频,每秒切一帧、每帧发 5 道题——有没有鸟(Noul)、几只(Choice:none/one/several)、最显眼的鸟在哪(Box)、最左和最右的鸟是什么物种(两道 Choice,10 种鸟加 other,每个选项写的是「这种鸟在相机画面里长什么样」而不是 field guide 式描述)。官方结果:bird_present 和人工标注 101/101 全对,物种认对 132/133;每帧中位耗时 0.54 秒,4 线程并行。
三个值得抄的设计:一,Box 每题只回一个框——两种鸟同框时第二个请求按物种名各要一个框(「the male northern cardinal (the bright red bird)」);两个框重叠度(交并比)≥ 0.6 视为同一只鸟认了两次,只留 confidence 高的。二,visits() 把帧答案变成「到访日志」,三个参数(物种概率 ≥ 0.6、断档 ≤ 2 帧、至少 2 帧才算一次到访)全是代码的事,Jev 看不见——重调参数不用重发请求。三,同种鸟成群时分不开——官方建议零样本检测器(如 Meta 的 SAM 3)先按「bird」找出所有框,Jev 再对每个裁剪的鸟认物种。
中文扫描件先测再上。 官方说明 Jev 主要用英语训练,其他语言(含中文)能用但准确度较低——中文单据上线前先拿自己的扫描件跑一批,重点看概率分布和 confidence(语言支持说明见模型与价格)。
费用心里有数。 图像没有单独计价——每张图按输入 token 计费、与文本同价,usage.input_tokens 里含所有图的 token;图也占上下文(64k per-request / 32k per-question),一次塞太多图会 max_tokens_exceeded。官方示例的缓存机制帮你省开发期的钱:响应按请求内容缓存,重跑不重发。
接你自己的文档。 官方明说这套打法适配「答案集已知」的任何文档:VACCINES 换你的清单、YEAR 换你的值域、有效期问题换成你的业务判断,other 和 unreadable 两个逃生舱保留。值抽取(日期、邮箱、电话、金额)的纯文本版本见值提取;抽取和核对该怎么在多个模型之间分工省钱,见级联提取。
本课小结
✅ Jev 图像识别读扫描件:Noul 找在不在、Box 找在哪一行(top 边排序就是页面顺序)、Choice 读值带 other/unreadable 逃生舱——读不出的被报出来,不是被猜出来。
✅ 读图与判断分开:读数请求带图,判断请求纯文本。官方反例:合并成一道题,狂犬病 0.88 错过关、伤寒 0.50 压线过关——一个答案盖两步,报警器全失灵。
✅ OCR 双检查:整页核对 + 裁剪核对,任一低于 0.25 转人工。三处误读全被抓出,单靠任一次检查都有漏网。复核框画在原图上,人工只看被标的。
✅ 视频帧同款套路:守门 Noul + Box + Choice 物种题,101/101 判对有没有鸟;到访日志的阈值全在代码里,调参不用重发请求。
关键配置字段回顾:
| 字段 | 本页的值 | 说明 |
|---|---|---|
TYPESAFE_MODEL | jev-1.14.0 | Box 问题需要 Jev 1.14;锁版本 ID,别用会漂移的 jev-latest |
IMAGES_ON | {"TypeSafe-Danger-Zone-Images": "true"} | 每个带图/带 Box 的请求都要;纯文本请求不用 |
PRESENT_CUTOFF | 0.5 | P(在页上) ≥ 0.5 才用那个 Box |
READ_CUTOFF / LEGIBLE_CUTOFF | 0.90 / 0.50 | 读数分与可读性分尺度不同,阈值分开设 |
IN_DATE_CUTOFF | 0.50 | 有效期判断低于它送人工;与读数标记互不抵消 |
BAR | 0.25 | OCR 双检查的复核线;任一检查低于它即 review |
unreadable / other | 逃生舱 | 前者挡「读不出」,后者挡「值域之外」 |
| 图片限制 | 每边 280–1568 像素、单文件 ≤ 16 MiB | 超限直接拒不缩放;小裁剪图要放大再发 |
相关教程:
- Box 与图像——本页的地基:Image 装法、四条边换算、Noul 守门
- 值提取——日期、邮箱、电话、金额的纯文本抽取:部件化提问 + 正则候选
- Confidence 置信度——三档用法(高自动执行 / 中标记 / 低转人工)与按风险调阈值
下一课预告
C 路线到此收官:文本的判断、值的抽取、图的识别你都有了。接下来进入进阶手册——Jev 的另一个形态:浏览器 Agent。下一课:进阶手册:Jev Ultrafast 浏览器 Agent 与避坑指南。
你会学到:
- Jev Ultrafast 是什么:给一个目标,Jev 选操作和元素,小 LLM 只在打字时写字符串
- 动态索引动作空间:每次观察生成新元素表,一次往返同时决定操作和目标
- 7 秒跑完一次真实 Google Flights 搜索的原理与安装
- Jev 1.13 的 9 种已知失败模式——本页「拆题」思想的另一面