Skip to content
🎯 场景实战第 16 / 16 节

图像实战:读扫描件、核对 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 步裁剪要用):

bash
pip install ipython matplotlib pillow 'typesafe-sdk>=0.7.5,<0.8.0' \
    'cooksafe>=0.2.0,<0.3.0'

从官方示例仓库下载扫描件和缓存(typesafe-public-examples,和脚本放同一目录):

bash
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

贴入文件头——客户端、阈值常量、带缓存的请求函数:

python
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 道题抓不到「列表漏了」这种情况。混题型并行提问的机制见并行提问与投机扇出。

python
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 录制数字):

text
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、unlisted 0.02;我能说出为什么最后那道 Noul 不能省

第 3 步:Choice 读年份,看不清就答 unreadable ​

为什么:找到行只是上半场,还得读出接种年份才知道针还在不在有效期。关键的坑:Choice 永远会挑一个选项——日期被贴纸盖住时,不给逃生舱它就硬挑一个最像的年份。官方的解法:选项列表末尾放 other 和 unreadable,让模型可以明说「读不出」;再加一道独立的 Noul 问「日期是否完全可读」,作为第二信号。

python
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 录制数字):

text
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 的已知弱项,算术永远在代码里。

python
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 请求头——判断只关于读出的年份文本,同样套上缓存装饰器:

python
@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 录制数字):

text
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),代码直接不送它进这个请求。

为什么一定要拆成两个请求?官方在附录里做了反例实验——把有效期问题直接对着图问(出发日期写在页眉上):

text
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 只出概率,送谁去人工是你的阈值说了算。

python
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 录制数字):

text
| 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 现在派上用场):

python
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发票号AN400074IN400074
Siyakhula发票号INV03070INV03079
Siyakhula日期16/09/202816/09/2026

官方的数字来自 jev-1.14.0(2026-10-15 录制)。这部分需要另装 typesafe-sdk[pil] 扩展并下载第二套示例,依赖与下载:

bash
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 查「抽到的值是不是这个字段」):

python
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。官方跑完的记分板:

text
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 版) ​

预期效果:凡是「清单上的东西可能在文档里、要找到并定位」的需求——疫苗、签名、印章、条目行——同一副题换清单就能用。

python
# 必填项:【清单】换你要找的东西列表;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 而不是瞎猜。

python
# 必填项:【选项集】+ 两个逃生舱;【值在原文里的写法】写进 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 抽出的每个字段核对两遍,低于阈值转人工并画出复核框。

python
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_MODELjev-1.14.0Box 问题需要 Jev 1.14;锁版本 ID,别用会漂移的 jev-latest
IMAGES_ON{"TypeSafe-Danger-Zone-Images": "true"}每个带图/带 Box 的请求都要;纯文本请求不用
PRESENT_CUTOFF0.5P(在页上) ≥ 0.5 才用那个 Box
READ_CUTOFF / LEGIBLE_CUTOFF0.90 / 0.50读数分与可读性分尺度不同,阈值分开设
IN_DATE_CUTOFF0.50有效期判断低于它送人工;与读数标记互不抵消
BAR0.25OCR 双检查的复核线;任一检查低于它即 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 种已知失败模式——本页「拆题」思想的另一面
搞懂AI关于我们怎么写隐私说明

独立的第三方教程,与文中提到的任何产品及其公司没有隶属或合作关系;产品名称和商标归各自所有者。价格和政策以官方页面为准。