Agent 接收 OCR 前,先保留原图、词框和未复核状态

一串读得通的编号也可能认错。用真实 OCR 输出,把每个词连回原图,并保留尚未复核的状态。

你能带走什么
  • 词级证据绑定原图哈希、TSV 行与原像素框
  • 本例阈值无法分开对错,高分仍然未复核
  • 真实人工复核后另存纠正,保留原始 OCR

1. 一个产品编号,怎样带着证据进入 Agent

一张订单图片经过 OCR,输出里出现了 0011-2026。把这一串文字交给 Agent 查询商品、整理表格或生成摘要,后续步骤很容易把它当成原文。本文的原图写的是 O0I1-2026。识别结果已经能读通,编号却发生了变化。

这个例子来自两张专门为本文生成的英文订单图片。内容完全虚构,分别用 30px 和 12px 的 DejaVu Sans 字体排版,再交给本地 Tesseract 5.5.0 识别。每张图的尺寸和字号预先固定,没有为制造误读反复调整图像,也没有给图片注入噪声、模糊或旋转。它们是数字生成的样本,不能代表真实扫描件的识别表现。

练习要完成的是一条能回查的输入链:生成原图,运行实际 OCR,保存未改动的 TSV 与 TXT,为每个词记录原图哈希、词框和复核状态。本文没有调用 Agent 模型,也没有把识别到的订单发送到外部系统。这样可以先检查 Agent 将来会拿到什么证据,以及哪些结论还不能形成。

原图经 OCR 产生原始 TSV,再派生带图像哈希和状态的词级证据
图 1 原创证据关系示意。词框和哈希绑定同一张输入图,不是产品界面截图。

2. 先把语言、版面和环境条件写清楚

本次实际环境已有 Python 3.12.14、Pillow 12.3.0 和 /usr/bin/tesseract 5.5.0。语言列表只有 eng 与 osd,未安装中文语言数据。因此正文使用英文夹具,不能从这次运行推断中文识别能力。所有处理均在本地执行,没有安装软件、下载语言包或使用真实用户文件。

两个夹具包含同样九行文本:订单号、产品、数量、单价、合计、保存条件、批次和虚构声明。大字图为 1000×600 像素,小字图为 430×240 像素。它们分别绘制,并非把大图缩小。150 DPI 是生成时写入的图像元数据;字体仍按明确的像素字号渲染。

识别命令采用 -l eng、--oem 1 和 --psm 6,同时输出 tsv 与 txt。官方命令行说明介绍了语言、引擎和输出格式。PSM 6 对应单一统一文本块的假设,适合这里有意简化的单块夹具;遇到多栏或复杂表格,需要重新判断分割条件。图像质量说明讨论了缩放、倾斜与分割模式对识别的影响。本次没有做这些预处理实验。

3. 两次识别留下了什么

30px 图中,O0I1-2026 被识别为 0011-2026,词置信值为 40.913246,原图像素框为 [126,63,280,85]。同一张图的批次 rn01-lI08 被识别为 rn01-ll08,置信值为 35.508789。看起来清晰的大字图也留下了需要回图核对的字符。

12px 图中,产品末尾的 M8 被识别为 M&,置信值达到 82.609863,词框为 [152,62,168,71]。小字图的订单号也发生相同误读,置信值为 81.088173。假如仅用“小于 80 才复核”的筛选规则,这两个错误都不会进入低分队列。

另一方面,小字图中识别正确的 15-25 和 C.,置信值均为 79.812729。这些结果足以说明当前示例阈值不能把对错分开,不能据此计算通用准确率、比较模型优劣或推荐生产阈值。原始图片、TSV、TXT、环境记录、人工编写的夹具真值与差异已在下文展开,读者可以直接逐项比对。

四个真实词置信值显示阈值80漏掉两个错误,且会标记一个正确词
图 2 柱长取自真实 TSV。虚线表示教学阈值 80;颜色按原创夹具文本比对,不是置信度校准结果。

4. conf 的含义需要与任务验证分开

Tesseract 5.5.0 的 LTRResultIterator::Confidence 源码注释使用了“percent probability”这一措辞。实现将内部 certainty 按 100 + 5 × mean_certainty 转换,并限制到 0 到 100 的范围。这里不能把官方描述改写成“官方说它不是概率”。

本文能说明的界限更具体:这次实验没有用当前任务的代表性语料校准分数,因此不能把 82.6 当成“这个业务字段经过验证有 82.6% 概率正确”。字段可能跨多个词,业务上也可能只容许完全匹配。词级分数与字段验收之间还缺少相应评估。

下面的代码把低于 80 的词标记为 needs_review,把其余词标记为 unreviewed。后者仍然未复核。订单号、金额、数量和否定语句等关键内容,应按任务需要回到原图核对,不能因为分数较高就自动写成 verified。阈值在本例只用于演示工作队列的一个入口。

5. 从 TSV 取词时保留原坐标

TSV 包含页、块、段、行和词等层级。5.5.0 的 GetTSVText 实现把词写为 level=5,同时输出位置与置信值。本例只消费 level=5 且文本非空的行。上层结构的 conf=-1 不应混入低置信词队列。

每条派生记录保留 TSV 行号和由 page_num、block_num、par_num、line_num、word_num 组成的词 ID。词 ID 只在对应的输入及识别输出内解释,不能跨运行单独用来定位。原始 text 不做纠正,conf 直接转成数值,bbox_px 采用 [left,top,left+width,top+height]。

坐标原点是输入图左上角,x 向右,y 向下,单位为原图像素。本例没有缩放、旋转、裁剪或 deskew,所以词框可以直接回到该 PNG。若展示时缩放了图片,要在显示层换算;若 OCR 前已经改变图像,则需要保存前处理与原图之间的映射。本文没有测试这些映射,更没有把像素框当作 PDF point 坐标。

source.sha256 绑定具体图像字节,新脚本同时保存 raw_tsv_sha256。只保存文件名会遗漏同名文件被替换的情况。哈希能帮助发现文件变化,但不能证明来源可信、内容正确或没有漏字。OCR 根本没检测到的文字不会自动出现在词列表里。

6. 在一个空目录里运行完整实验

把下面两份完整源码分别保存为 run.py 与 test_run.py。环境需要预先具备 Python、Pillow、Tesseract 和 eng 数据。脚本不负责安装或下载;缺少依赖时应停止并准备环境。默认字体路径是本次 Linux 环境实际存在的 /usr/share/fonts/truetype/dejavu/DejaVuSans.ttf。若你的环境不同,可以将 OCR_FONT 指向已有的字体文件;字体变化可能改变像素与识别结果。

首次运行创建 run-output 目录;目录已经存在时会失败,以免覆盖上一轮证据。第二次实验请使用另一个空目录。生成结果以当前程序输出为准,不需要强行复现本文所有误读。下文展示本次固定版本的原图、TSV、TXT、diff 和环境记录,可与新运行结果对照;本页不提供历史 manifest.json 或 evidence JSON 的下载入口。重新运行所需的输入、参数和派生规则已包含在下列源码中,无需先取得这些历史 JSON。

python run.py
python test_run.py
run.py:生成图像、实际 OCR、保留输出与派生证据
"""Local synthetic images only. Requires existing Pillow, Tesseract and eng data."""
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
import csv, difflib, hashlib, json, os, platform, shutil, subprocess

OUT = Path('run-output')
FONT = Path(os.environ.get('OCR_FONT', '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf'))
LINES = ['SYNTHETIC PRODUCT ORDER', 'Order: O0I1-2026',
         'Product: Mini bracket M8', 'Quantity: 10',
         'Unit price: USD 18.90', 'Total: USD 189.00',
         'Keep dry. Store at 15-25 C.', 'Batch: rn01-lI08',
         'This is fictional test data.']

def sha(path):
    return hashlib.sha256(path.read_bytes()).hexdigest()

def write_json(path, value):
    path.write_text(json.dumps(value, ensure_ascii=False, indent=2)+'\n', encoding='utf-8')

def main():
    exe = shutil.which('tesseract')
    if not exe or not FONT.is_file():
        raise SystemExit('Need preinstalled tesseract and an existing OCR_FONT file; nothing is installed.')
    import PIL
    version = subprocess.check_output([exe, '--version'], text=True)
    languages = subprocess.check_output([exe, '--list-langs'], text=True)
    if 'eng' not in languages.splitlines():
        raise SystemExit('eng traineddata missing; no download is attempted.')
    OUT.mkdir(exist_ok=False)  # Never overwrite a previous run.
    write_json(OUT/'environment.json', {'python': platform.python_version(),
        'pillow': PIL.__version__, 'tesseract': version, 'languages': languages,
        'font': str(FONT), 'font_sha256': sha(FONT)})
    (OUT/'groundtruth.txt').write_text('\n'.join(LINES)+'\n', encoding='utf-8')
    variants = []
    for name, size, width, height in [('clear',30,1000,600), ('small',12,430,240)]:
        im = Image.new('RGB', (width,height), 'white')
        draw = ImageDraw.Draw(im)
        font = ImageFont.truetype(str(FONT), size)
        for i, line in enumerate(LINES):
            draw.text((20,15+i*(size+12 if name=='clear' else size+10)), line, font=font, fill='black')
        image = OUT/f'{name}.png'
        im.save(image, dpi=(150,150))
        cmd = [exe, str(image), str(OUT/name), '-l','eng','--oem','1','--psm','6','tsv','txt']
        proc = subprocess.run(cmd, capture_output=True, text=True, check=True)
        (OUT/f'{name}.stderr.txt').write_text(proc.stderr, encoding='utf-8')
        with (OUT/f'{name}.tsv').open(encoding='utf-8', newline='') as stream:
            rows = list(csv.DictReader(stream, delimiter='\t'))
        words = []
        for row1, r in enumerate(rows, 2):
            if r['level'] != '5' or not r['text'].strip():
                continue
            x,y,w,h = [int(r[k]) for k in ('left','top','width','height')]
            if not (0<=x<x+w<=width and 0<=y<y+h<=height):
                raise ValueError('word box outside input image')
            conf = float(r['conf'])
            words.append({'row1':row1, 'id':'.'.join(r[k] for k in
                ('page_num','block_num','par_num','line_num','word_num')),
                'raw_text':r['text'], 'confidence':conf,
                'bbox_px':[x,y,x+w,y+h],
                'review_status':'needs_review' if conf<80 else 'unreviewed'})
        write_json(OUT/f'{name}.evidence.json', {
            'source':{'path':image.name,'sha256':sha(image),'image_size_px':[width,height]},
            'raw_tsv_sha256':sha(OUT/f'{name}.tsv'),
            'engine':version.splitlines()[0], 'language':'eng','oem':1,'psm':6,
            'coordinates':'original input pixels, top-left origin, xyxy', 'words':words})
        actual = (OUT/f'{name}.txt').read_text(encoding='utf-8')
        diff = ''.join(difflib.unified_diff([s+'\n' for s in LINES], actual.splitlines(True),
            fromfile='authored-ground-truth', tofile='actual-ocr'))
        (OUT/f'{name}.diff.txt').write_text(diff, encoding='utf-8')
        variants.append({'name':name,'font_size':size,'size_px':[width,height], 'command':cmd})
        print(name, 'words:',len(words), '\n'+diff)
    write_json(OUT/'manifest.json', {'lines':LINES,'variants':variants})

if __name__ == '__main__':
    main()
test_run.py:检查本轮证据绑定、词框和未复核状态
"""Structural checks for the current run, not an OCR accuracy benchmark."""
from pathlib import Path
import csv, hashlib, json, unittest
from PIL import Image
ROOT=Path('run-output')
class EvidenceTests(unittest.TestCase):
    def test_images_and_raw_binding(self):
        for name in ('clear','small'):
            e=json.loads((ROOT/f'{name}.evidence.json').read_text())
            self.assertEqual(e['source']['sha256'],hashlib.sha256((ROOT/f'{name}.png').read_bytes()).hexdigest())
            self.assertEqual(e['raw_tsv_sha256'],hashlib.sha256((ROOT/f'{name}.tsv').read_bytes()).hexdigest())
            with Image.open(ROOT/f'{name}.png') as im:
                self.assertEqual(e['source']['image_size_px'],list(im.size))
    def test_word_values_and_status(self):
        for name in ('clear','small'):
            e=json.loads((ROOT/f'{name}.evidence.json').read_text())
            with (ROOT/f'{name}.tsv').open(newline='') as f:
                rows=list(csv.DictReader(f,delimiter='\t'))
            self.assertEqual(len(e['words']),sum(r['level']=='5' and bool(r['text'].strip()) for r in rows))
            width,height=e['source']['image_size_px']
            for w in e['words']:
                r=rows[w['row1']-2]
                self.assertEqual(w['raw_text'],r['text'])
                self.assertEqual(w['confidence'],float(r['conf']))
                self.assertEqual(w['id'],'.'.join(r[k] for k in ('page_num','block_num','par_num','line_num','word_num')))
                x,y,rgt,btm=w['bbox_px']
                self.assertEqual([x,y,rgt,btm],[int(r['left']),int(r['top']),int(r['left'])+int(r['width']),int(r['top'])+int(r['height'])])
                self.assertTrue(0<=x<rgt<=width and 0<=y<btm<=height)
                self.assertEqual(w['review_status'],'needs_review' if w['confidence']<80 else 'unreviewed')
    def test_groundtruth(self):
        from run import LINES
        self.assertEqual((ROOT/'groundtruth.txt').read_text(),'\n'.join(LINES)+'\n')
if __name__=='__main__':
    unittest.main(verbosity=2)

程序中的 LINES 是完整输入真值,下节也逐行展开。run-output 中会出现两套 PNG、TSV、TXT、stderr、evidence JSON 与 diff,再加上 groundtruth.txt、manifest.json 和 environment.json。原始 OCR 文件不会被派生步骤改写。测试比较当前运行自身的图像、TSV 与 JSON,不要求不同版本或机器产生相同错误。

7. 输入全文与本次输出可以直接对照

原创输入全文;两幅图共享这些行
SYNTHETIC PRODUCT ORDER
Order: O0I1-2026
Product: Mini bracket M8
Quantity: 10
Unit price: USD 18.90
Total: USD 189.00
Keep dry. Store at 15-25 C.
Batch: rn01-lI08
This is fictional test data.
clear.txt:本次未修改的 OCR 全文
SYNTHETIC PRODUCT ORDER
Order: 0011-2026

Product: Mini bracket M8
Quantity: 10

Unit price: USD 18.90

Total: USD 189.00

Keep dry. Store at 15-25 C.
Batch: rn01-ll08

This is fictional test data.
clear.diff.txt:原创真值与实际输出差异
--- authored-ground-truth
+++ actual-ocr
@@ -1,9 +1,14 @@
 SYNTHETIC PRODUCT ORDER
-Order: O0I1-2026
+Order: 0011-2026
+
 Product: Mini bracket M8
 Quantity: 10
+
 Unit price: USD 18.90
+
 Total: USD 189.00
+
 Keep dry. Store at 15-25 C.
-Batch: rn01-lI08
+Batch: rn01-ll08
+
 This is fictional test data.
clear.tsv:本次原始输出全文
level	page_num	block_num	par_num	line_num	word_num	left	top	width	height	conf	text
1	1	0	0	0	0	0	0	1000	600	-1	
2	1	1	0	0	0	20	21	440	358	-1	
3	1	1	1	0	0	22	21	438	64	-1	
4	1	1	1	1	0	22	21	438	22	-1	
5	1	1	1	1	1	22	21	164	22	96.782806	SYNTHETIC
5	1	1	1	1	2	200	21	144	22	95.889740	PRODUCT
5	1	1	1	1	3	356	21	104	22	96.759674	ORDER
4	1	1	1	2	0	22	62	258	23	-1	
5	1	1	1	2	1	22	62	90	23	96.337448	Order:
5	1	1	1	2	2	126	63	154	22	40.913246	0011-2026
3	1	1	2	0	0	22	104	368	71	-1	
4	1	1	2	1	0	23	104	367	23	-1	
5	1	1	2	1	1	23	104	118	23	96.583443	Product:
5	1	1	2	1	2	157	104	56	23	96.521042	Mini
5	1	1	2	1	3	228	104	109	23	96.290703	bracket
5	1	1	2	1	4	350	105	40	22	95.195618	M8
4	1	1	2	2	0	22	146	181	29	-1	
5	1	1	2	2	1	22	146	132	29	96.000664	Quantity:
5	1	1	2	2	2	170	147	33	22	96.928894	10
3	1	1	3	0	0	23	188	319	29	-1	
4	1	1	3	1	0	23	188	319	29	-1	
5	1	1	3	1	1	23	188	57	23	95.781662	Unit
5	1	1	3	1	2	94	188	78	29	96.162613	price:
5	1	1	3	1	3	188	189	59	22	96.225502	USD
5	1	1	3	1	4	262	189	80	22	95.680229	18.90
3	1	1	4	0	0	20	230	266	23	-1	
4	1	1	4	1	0	20	230	266	23	-1	
5	1	1	4	1	1	20	230	77	23	95.898552	Total:
5	1	1	4	1	2	113	231	59	22	95.818657	USD
5	1	1	4	1	3	186	231	100	22	95.958122	189.00
3	1	1	5	0	0	23	272	397	65	-1	
4	1	1	5	1	0	23	272	397	29	-1	
5	1	1	5	1	1	23	273	69	28	96.560860	Keep
5	1	1	5	1	2	106	272	49	29	96.560860	dry.
5	1	1	5	1	3	170	273	75	22	96.649788	Store
5	1	1	5	1	4	258	274	28	21	96.632828	at
5	1	1	5	1	5	299	273	81	22	92.382133	15-25
5	1	1	5	1	6	395	273	25	22	90.468155	C.
4	1	1	5	2	0	23	314	236	23	-1	
5	1	1	5	2	1	23	314	90	23	93.278656	Batch:
5	1	1	5	2	2	129	314	130	23	35.508789	rn01-ll08
3	1	1	6	0	0	20	356	374	23	-1	
4	1	1	6	1	0	20	356	374	23	-1	
5	1	1	6	1	1	20	356	60	23	96.382874	This
5	1	1	6	1	2	94	356	19	23	94.809586	is
5	1	1	6	1	3	125	356	117	23	95.622887	fictional
5	1	1	6	1	4	254	358	56	21	96.006233	test
5	1	1	6	1	5	323	356	71	23	95.788696	data.
small.txt:本次未修改的 OCR 全文
SYNTHETIC PRODUCT ORDER
Order: 0011-2026

Product: Mini bracket M&
Quantity: 10

Unit price: USD 18.90

Total: USD 189.00

Keep dry. Store at 15-25 C.
Batch: rn01-1108

This is fictional test data.
small.diff.txt:原创真值与实际输出差异
--- authored-ground-truth
+++ actual-ocr
@@ -1,9 +1,14 @@
 SYNTHETIC PRODUCT ORDER
-Order: O0I1-2026
-Product: Mini bracket M8
+Order: 0011-2026
+
+Product: Mini bracket M&
 Quantity: 10
+
 Unit price: USD 18.90
+
 Total: USD 189.00
+
 Keep dry. Store at 15-25 C.
-Batch: rn01-lI08
+Batch: rn01-1108
+
 This is fictional test data.
small.tsv:本次原始输出全文
level	page_num	block_num	par_num	line_num	word_num	left	top	width	height	conf	text
1	1	0	0	0	0	0	0	430	240	-1	
2	1	1	0	0	0	20	18	176	185	-1	
3	1	1	1	0	0	21	18	175	31	-1	
4	1	1	1	1	0	21	18	175	9	-1	
5	1	1	1	1	1	21	18	66	9	96.469749	SYNTHETIC
5	1	1	1	1	2	92	18	57	9	96.470505	PRODUCT
5	1	1	1	1	3	155	18	41	9	94.872665	ORDER
4	1	1	1	2	0	21	39	103	10	-1	
5	1	1	1	2	1	21	39	36	10	90.929001	Order:
5	1	1	1	2	2	63	40	61	9	81.088173	0011-2026
3	1	1	2	0	0	21	61	147	35	-1	
4	1	1	2	1	0	21	61	147	10	-1	
5	1	1	2	1	1	21	61	48	10	96.157707	Product:
5	1	1	2	1	2	74	62	23	9	96.533936	Mini
5	1	1	2	1	3	103	61	44	10	93.305931	bracket
5	1	1	2	1	4	152	62	16	9	82.609863	M&
4	1	1	2	2	0	21	84	73	12	-1	
5	1	1	2	2	1	21	84	53	12	90.102188	Quantity:
5	1	1	2	2	2	80	84	14	9	96.630440	10
3	1	1	3	0	0	21	106	128	12	-1	
4	1	1	3	1	0	21	106	128	12	-1	
5	1	1	3	1	1	21	95	24	30	96.135704	Unit
5	1	1	3	1	2	49	95	32	30	95.934326	price:
5	1	1	3	1	3	87	106	24	9	92.261604	USD
5	1	1	3	1	4	116	106	33	9	95.921379	18.90
3	1	1	4	0	0	20	127	107	10	-1	
4	1	1	4	1	0	20	127	107	10	-1	
5	1	1	4	1	1	20	127	31	10	93.572784	Total:
5	1	1	4	1	2	57	128	24	9	96.314537	USD
5	1	1	4	1	3	86	128	41	9	88.878128	189.00
3	1	1	5	0	0	21	149	160	32	-1	
4	1	1	5	1	0	21	149	160	13	-1	
5	1	1	5	1	1	21	150	28	12	89.445251	Keep
5	1	1	5	1	2	55	149	19	13	87.477448	dry.
5	1	1	5	1	3	80	150	30	9	96.667847	Store
5	1	1	5	1	4	116	150	11	9	96.667847	at
5	1	1	5	1	5	132	150	33	9	79.812729	15-25
5	1	1	5	1	6	170	150	11	9	79.812729	C.
4	1	1	5	2	0	21	171	95	10	-1	
5	1	1	5	2	1	21	171	37	10	93.224983	Batch:
5	1	1	5	2	2	63	171	53	10	73.106155	rn01-1108
3	1	1	6	0	0	20	193	150	10	-1	
4	1	1	6	1	0	20	193	150	10	-1	
5	1	1	6	1	1	20	193	24	10	94.210213	This
5	1	1	6	1	2	49	196	9	7	96.267784	is
5	1	1	6	1	3	62	193	46	10	89.734245	fictional
5	1	1	6	1	4	113	194	24	9	96.526764	test
5	1	1	6	1	5	141	193	29	10	91.650894	data.

上面的差异由已知夹具真值自动比较得到。它能揭示本例文字是否相同,不等同于一条人工审核记录。下面两幅 PNG 是原始输入图,可按原尺寸查看;图中文字从生成程序直接绘制,没有覆盖 OCR 结果来伪造错误。

clear 原始英文虚构订单输入图
clear:1000×600 / 30px,原始输入图。
small 原始英文虚构订单输入图
small:430×240 / 12px,原始输入图。

8. 人工改字应另存,原始识别仍然可查

先打开与记录哈希对应的原图,再定位词框并阅读附近内容。对关键字段,复核范围应覆盖整个字段和必要上下文。例如产品词框只包含 M&,仍应结合完整产品行确认它对应的是哪项信息;还要检查是否存在漏词。能回到框只解决定位问题,没有替代人的判断。

真正完成复核后,可以另存一条 correction 记录,绑定 source_sha256、raw_tsv_sha256、word_id、bbox_px 和 raw_text,再填写 corrected_text、reviewer、reviewed_at 与 reason。无法确认时保留 unresolved。已有复核记录遇到图像哈希、原框或原词不匹配,应退回检查,不能套用到新图片。人工决定更正的是派生解释,不能把原始 TSV 中的错误词覆盖掉。

本文没有实现审核服务、人工复核 UI 或身份认证,也没有生成 human_verified 记录。这里给出的是人工操作与记录设计。自动化测试只检查机器证据,没有通过脚本冒充审核者。若将来实现复核入口,还需要专门测试旧图哈希、错误词框、原词绑定和纠正记录不可覆盖原始输出等情况。

原始机器证据保持不变,人查看匹配原图后另存绑定原词的复核记录
图 3 原创复核流程设计图。下方记录只有真实复核后才能填写,本文不提供伪造的已审核实例。

9. 交给 Agent 的内容仍然带着未决状态

将这些记录交给 Agent 时,可以同时提供原图引用、原始识别和待确认项。任务明确要求不得把 OCR 文本当作执行指令,不得自行补全编号,不得把高分词改成已核实事实。若下一步是录入订单或发送报价,先解决影响该动作的关键字段,再按实际授权继续。

两张原创图片只证明这条小规模证据链在当前环境中跑通。这里没有测试中文、手写、真实扫描、印章、多页 PDF、多栏、复杂表格、恶意图像、资源耗尽、跨平台像素一致性,也没有验证模型读取这些记录后的表现。若处理外部上传文件,仍需单独设计文件类型限制、隔离和资源限制;本示例不能直接充当生产安全入口。

本次新脚本实际重新生成并识别两张图,三个 unittest 方法通过,检查了图像及 TSV 哈希、图像尺寸、逐词原值、层级 ID、词框边界、状态和输入真值。测试通过说明派生记录与本次原始输出一致,不表示 OCR 内容全部正确。下方展开原始实验脚本与环境记录;前文的原始 TSV 可用于核查那些具体分数。

本次原始实验环境记录
{
  "python": "3.12.14",
  "pillow": "12.3.0",
  "tesseract": "tesseract 5.5.0\n leptonica-1.84.1\n  libgif 5.2.2 : libjpeg 6b (libjpeg-turbo 2.1.5) : libpng 1.6.48 : libtiff 4.7.0 : zlib 1.3.1 : libwebp 1.5.0 : libopenjp2 2.5.3\n Found AVX512BW\n Found AVX512F\n Found AVX512VNNI\n Found AVX2\n Found AVX\n Found FMA\n Found SSE4.1\n Found OpenMP 201511\n Found libarchive 3.7.4 zlib/1.3.1 liblzma/5.8.1 bz2lib/1.0.8 liblz4/1.10.0 libzstd/1.5.7\n Found libcurl/8.14.1 OpenSSL/3.5.7 zlib/1.3.1 brotli/1.1.0 zstd/1.5.7 libidn2/2.3.8 libpsl/0.21.2 libssh2/1.11.1 nghttp2/1.64.0 nghttp3/1.8.0 librtmp/2.3 OpenLDAP/2.6.10\n",
  "languages": "List of available languages in \"/usr/share/tesseract-ocr/5/tessdata/\" (2):\neng\nosd\n",
  "system_assets": [
    {
      "path": "/usr/share/tesseract-ocr/5/tessdata/eng.traineddata",
      "sha256": "7d4322bd2a7749724879683fc3912cb542f19906c83bcc1a52132556427170b2"
    },
    {
      "path": "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
      "sha256": "57f73e11f51999432bf7ab22ce55b6f945d5eca1bf824404cfa9ec2e3718c84e"
    }
  ]
}
原始 probe.py:产生本文历史输出的固定环境实验脚本
from pathlib import Path
from PIL import Image,ImageDraw,ImageFont
import csv,json,subprocess,hashlib,difflib
ROOT=Path(__file__).resolve().parent
FONT=Path('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf')
LINES=['SYNTHETIC PRODUCT ORDER','Order: O0I1-2026','Product: Mini bracket M8','Quantity: 10','Unit price: USD 18.90','Total: USD 189.00','Keep dry. Store at 15-25 C.','Batch: rn01-lI08','This is fictional test data.']
# Fixed original content, two predeclared rendering sizes. No corruption/noise, no edits to OCR.
manifest={'font':str(FONT),'font_sha256':hashlib.sha256(FONT.read_bytes()).hexdigest(),'lines':LINES,'variants':[]}
for name,size in [('clear',30),('small',12)]:
 width,height=(1000,600) if name=='clear' else (430,240)
 im=Image.new('RGB',(width,height),'white'); draw=ImageDraw.Draw(im); font=ImageFont.truetype(str(FONT),size)
 for i,line in enumerate(LINES): draw.text((20,15+i*(size+12 if name=='clear' else size+10)),line,font=font,fill='black')
 path=ROOT/f'{name}.png'; im.save(path,dpi=(150,150))
 cmd=['/usr/bin/tesseract',str(path),str(ROOT/name),'-l','eng','--oem','1','--psm','6','tsv','txt']
 proc=subprocess.run(cmd,capture_output=True,text=True,check=True)
 (ROOT/f'{name}.stderr.txt').write_text(proc.stderr)
 rows=list(csv.DictReader((ROOT/f'{name}.tsv').open(),delimiter='\t'))
 words=[]
 for i,r in enumerate(rows):
  if r['level']!='5' or not r['text'].strip(): continue
  x,y,w,h=[int(r[k]) for k in ['left','top','width','height']]
  assert 0<=x<x+w<=width and 0<=y<y+h<=height
  words.append({'row1':i+2,'id':'.'.join(r[k] for k in ['page_num','block_num','par_num','line_num','word_num']),'raw_text':r['text'],'confidence':float(r['conf']),'bbox_px':[x,y,x+w,y+h],'review_status':'needs_review' if float(r['conf'])<80 else 'unreviewed','review_reasons':['confidence_below_demo_threshold'] if float(r['conf'])<80 else []})
 result={'source':{'path':path.name,'sha256':hashlib.sha256(path.read_bytes()).hexdigest(),'image_size_px':[width,height]},'method':'ocr','engine':'tesseract 5.5.0','language':'eng','oem':1,'psm':6,'coordinates':'image pixels; origin top-left; x right, y down; xyxy','words':words}
 (ROOT/f'{name}.evidence.json').write_text(json.dumps(result,indent=2))
 text=(ROOT/f'{name}.txt').read_text()
 (ROOT/f'{name}.diff.txt').write_text(''.join(difflib.unified_diff([x+'\n' for x in LINES],text.splitlines(True),fromfile='authored-ground-truth',tofile='actual-ocr')))
 print(name,repr(text)); print('LOW',[(w['raw_text'],w['confidence'],w['bbox_px']) for w in words if w['confidence']<80])
 manifest['variants'].append({'name':name,'size_px':[width,height],'font_size':size,'command':cmd,'word_count':len(words)})
(ROOT/'manifest.json').write_text(json.dumps(manifest,indent=2))

原始 probe.py 里的 engine 字符串固定为当时实测的 tesseract 5.5.0,仅作为历史实验源码保存。上面的 run.py 会读取实际版本,适合重新运行。本页不提供系统字体或 traineddata;其原路径和哈希已列在上方环境记录中。

10. 补测:图片方向也会改变证据坐标

前文的两张 PNG 没有旋转、缩放或裁剪,词框直接对应输入像素。这个范围不变。下面是 2026-10-13 另做的一次实验:一张图片在查看器中看起来是正的,不代表 OCR 实际读取的像素方向也相同。

本次从白底生成三行英文,再把存储像素逆时针转 90°,保存为带 EXIF Orientation=6 的 JPEG。文件实际存储 360×900 像素;按方向标签规范化后是 900×360。它是专门生成的虚构夹具,没有使用用户照片,也没有测试任何手机应用。

同一环境使用 Tesseract 5.5.0 -l eng --oem 1 --psm 6 识别:直接输入 JPEG,TSV 页尺寸为 360×900,输出没有恢复三行文本;输入规范化 PNG,TSV 页尺寸为 900×360,得到下面七个词。这里观察的是这份文件与这条命令,不概括所有 OCR 包装器或查看器的行为。

LOCAL OCR FIXTURE
Record: ALPHA-2048
Quantity: 17
源 JPEG 存储为 360×900;规范化后 900×360。ALPHA-2048 的词框必须映射,才能回到同一存储像素区域。
补充图:原创夹具的实际像素与坐标示意。左侧预览去掉方向元数据,只为展示存储像素;它不是原 JPEG 文件。红框是本次识别出的同一词。

11. 规范化像素时,一并处理方向标签

Pillow 的 exif_transpose 文档说明:按非 1 的 EXIF 方向变换图像,并移除方向信息。本次查阅的 12.3.0 实现将方向 6 映射为 ROTATE_270,也就是像素顺时针转 90°。默认返回新图,本例保留原 JPEG 字节,另存 normalized.png。

不能只把像素转正,再把旧 EXIF 原封不动写回。本例故意把方向 6 重新附到已转正的 PNG;下一次调用时,它又转了一次,尺寸回到 360×900。正常输出没有方向标签,再规范化一次仍保持相同像素与尺寸。缺失标签和方向 1 的对照只检查像素不变,不声称值为 1 的标签也会被删掉。

下面是本次记录的一个词。词框绑定的是规范化 PNG 的文件哈希、尺寸与坐标空间,原 JPEG 的身份另存。TSV 哈希、行号、层级词 ID、原词和置信值也一起保留;识别出了可读编号,状态仍是 unreviewed。

{
  "source": {
    "file": "source-orientation6.jpg",
    "sha256": "37d0cb16568c42e03eeb88e75e3ef535b5da667ab2549f509b097ed4b490d0ed",
    "stored_size_px": [
      360,
      900
    ],
    "orientation": 6
  },
  "ocr_input": {
    "input_file": "normalized.png",
    "input_sha256": "6001403ad73ebe282f69c710ef6765845f6734ac3be77d1fcbfdd9c276445bd0",
    "raw_tsv_sha256": "8313bdbac9b9ce1d0287c6a907e959422417acc02132091fda519eb355be9c31",
    "coordinate_space": "normalized.png",
    "box_convention": "half-open pixel edges, top-left origin, x right, y down",
    "page_size_from_tsv": [
      900,
      360
    ]
  },
  "word": {
    "tsv_row1": 11,
    "word_id": "1.1.1.2.2",
    "text": "ALPHA-2048",
    "bbox_px": [
      214,
      138,
      459,
      168
    ],
    "conf": 90.587784,
    "review_status": "unreviewed",
    "stored_raster_bbox_px": [
      138,
      441,
      168,
      686
    ]
  }
}

方向归一化不等于清除全部元数据,也不是隐私清理步骤。文件哈希可以检测字节是否变化,不能认证图片来自谁。

12. 只把方向 6 的词框映回存储像素

最简单的复核方式,是直接显示规范化 PNG,并在它自己的坐标系画框。确实需要查看原 JPEG 的未变换存储像素时,才使用下面的逆映射。普通查看器可能已经应用 EXIF;不知道它的坐标空间时,不能再套用这个公式。

本例用左上角为原点、x 向右、y 向下的半开像素边界 [x0,y0,x1,y1]。方向 6 的逆映射是 [y0,H-x1,y1,H-x0],其中 H 是存储图像的高度。ALPHA-2048 的规范化框为 [214,138,459,168],H=900,得到存储像素框 [138,441,168,686]。直接复制原框,右边界 459 甚至超过存储宽度 360。

这里用的是矩形边界,不能写成 H-1-x。后者用于单个像素索引。代码先用 6×4 的 24 个不同灰度值和手写顺时针排列检查方向,再核对一个独立写定的矩形。测试还遍历该网格的 210 个非空矩形,并用逐像素关系核对裁剪结果。真实 OCR 的七个词框也逐一映回并检查裁剪像素相同。

inverse_box6 只接受方向 6,其他方向明确抛出 ValueError;越界或空矩形同样拒绝。这不是八种 EXIF 方向的通用坐标库。JPEG 有损,所以比较对象是同一 JPEG 解码后的存储像素与其规范化副本,而非编码前的白底绘图。

13. 运行完整夹具与检查

本次环境为 Python 3.12.14、Pillow 12.3.0、Tesseract 5.5.0,使用已有 eng 数据和 DejaVu Sans 字体。Tesseract 命令行文档列出 TSV 的位置、尺寸和文本列。本例原样保留 TSV/TXT,再派生证据记录,没有调用模型。

把下面三份完整源码放在同一目录,或下载源码与本次输出。用已有且获准的环境执行;new-run 必须尚不存在。程序不安装或下载依赖,不读取外部图片,也没有上传步骤。

python -B exif_lab.py new-run
python -B test_exif_lab.py
python -B mutation_check.py
exif_lab.py:生成 JPEG、规范化、真实 OCR 与 23 个命名检查
"""Small generated EXIF=6 OCR boundary experiment; no external input or network."""
from __future__ import annotations

import csv
import hashlib
import io
import json
from pathlib import Path
import platform
import subprocess
import sys

import PIL
from PIL import Image, ImageDraw, ImageFont, ImageOps

ROOT = Path(__file__).resolve().parent
ORIENTATION = 274
FONT = Path('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf')


def sha(data):
    return hashlib.sha256(data).hexdigest()


def normalize(image):
    """Return a new image; Pillow applies orientation and removes the active tag."""
    return ImageOps.exif_transpose(image)


def inverse_box6(box, stored_size, orientation):
    """Half-open pixel EDGES from normalized to stored-raster coordinates."""
    if orientation != 6:
        raise ValueError('inverse mapping supports EXIF orientation 6 only')
    x0, y0, x1, y1 = box
    w, h = stored_size
    if not (0 <= x0 < x1 <= h and 0 <= y0 < y1 <= w):
        raise ValueError('normalized box outside orientation-6 raster')
    return [y0, h - x1, y1, h - x0]


def probe(out):
    out.mkdir(exist_ok=False)
    checks = {}
    def check(name, condition):
        if not condition:
            raise AssertionError(name)
        checks[name] = True

    # Independent, explicit pixel oracle. Every pixel has a distinct value.
    grid = Image.new('L', (6, 4))
    grid.putdata(list(range(24)))
    expected_clockwise = [
        18,12,6,0, 19,13,7,1, 20,14,8,2,
        21,15,9,3, 22,16,10,4, 23,17,11,5,
    ]
    check('no_orientation_keeps_pixels', normalize(grid).tobytes() == grid.tobytes())
    grid.getexif()[ORIENTATION] = 1
    check('orientation_1_keeps_pixels', normalize(grid).tobytes() == grid.tobytes())
    grid.getexif()[ORIENTATION] = 6
    turned = normalize(grid)
    check('orientation_6_matches_explicit_pixel_oracle', list(turned.tobytes()) == expected_clockwise)
    check('orientation_6_swaps_dimensions', turned.size == (4, 6))
    check('orientation_6_tag_removed', turned.getexif().get(ORIENTATION) is None)
    check('original_metadata_unchanged', grid.getexif().get(ORIENTATION) == 6)
    twice = normalize(turned)
    check('repeat_normalization_pixel_idempotent', twice.size == turned.size and twice.tobytes() == turned.tobytes())
    box = [0, 1, 2, 4]
    stored_box = inverse_box6(box, grid.size, 6)
    check('inverse_box_matches_independent_expected_edges', stored_box == [1, 2, 4, 4])
    normalized_crop = turned.crop(box)
    mapped_crop = grid.crop(stored_box).transpose(Image.Transpose.ROTATE_270)
    check('inverse_crop_pixels_match', normalized_crop.tobytes() == mapped_crop.tobytes() and normalized_crop.size == mapped_crop.size)
    check('copying_box_without_mapping_is_wrong', grid.crop(box).tobytes() != normalized_crop.tobytes())
    wrong_edges = [box[1], grid.height-1-box[2], box[3], grid.height-1-box[0]]
    check('pixel_index_minus_one_is_wrong_for_edges', grid.crop(wrong_edges).transpose(Image.Transpose.ROTATE_270).tobytes() != normalized_crop.tobytes())
    try:
        inverse_box6([0, 0, 5, 2], grid.size, 6)
    except ValueError:
        checks['out_of_bounds_normalized_box_rejected'] = True
    else:
        raise AssertionError('out of bounds accepted')

    # One original synthetic page, generated once; not a real scan or a benchmark.
    upright = Image.new('RGB', (900, 360), 'white')
    draw = ImageDraw.Draw(upright)
    font = ImageFont.truetype(str(FONT), 40)
    lines = ['LOCAL OCR FIXTURE', 'Record: ALPHA-2048', 'Quantity: 17']
    for y, line in zip([40, 130, 220], lines):
        draw.text((50, y), line, font=font, fill='black')
    stored = upright.transpose(Image.Transpose.ROTATE_90)
    exif = Image.Exif()
    exif[ORIENTATION] = 6
    source = out / 'source-orientation6.jpg'
    stored.save(source, quality=95, subsampling=0, exif=exif, dpi=(150,150))
    source_bytes = source.read_bytes()
    raw = Image.open(io.BytesIO(source_bytes))
    raw.load()
    normalized = normalize(raw)
    normalized_path = out / 'normalized.png'
    normalized.save(normalized_path, dpi=(150,150))
    check('jpeg_stored_size', raw.size == (360,900))
    check('jpeg_normalized_size', normalized.size == (900,360))
    check('normalized_png_has_no_orientation', Image.open(normalized_path).getexif().get(ORIENTATION) is None)
    check('normalized_equals_manual_clockwise_raster', normalized.tobytes() == raw.transpose(Image.Transpose.ROTATE_270).tobytes())

    # Deliberately faulty output: transformed pixels with old orientation reattached.
    stale_path = out / 'negative-stale-orientation.png'
    normalized.save(stale_path, exif=raw.getexif())
    stale = Image.open(stale_path)
    stale.load()
    second_rotation = normalize(stale)
    check('stale_metadata_double_rotates', second_rotation.size == (360,900) and second_rotation.tobytes() != normalized.tobytes())
    check('source_bytes_remain_unchanged', source.read_bytes() == source_bytes)
    ocr = {}
    for stem, path in [('raw_source', source), ('normalized', normalized_path)]:
        command = ['tesseract', str(path), str(out/stem), '-l','eng','--oem','1','--psm','6','tsv','txt']
        run = subprocess.run(command, text=True, capture_output=True, timeout=20, check=True)
        (out / f'{stem}.stderr.txt').write_text(run.stderr)
        tsv_bytes = (out / f'{stem}.tsv').read_bytes()
        words = []
        for row_number, r in enumerate(csv.DictReader(io.StringIO(tsv_bytes.decode('utf-8')), delimiter='\t'), 2):
            if r['level'] != '5' or not r['text'].strip():
                continue
            x,y,w,h = (int(r[k]) for k in ['left','top','width','height'])
            word = {'tsv_row1':row_number, 'word_id':'.'.join(r[k] for k in ['page_num','block_num','par_num','line_num','word_num']), 'text':r['text'], 'bbox_px':[x,y,x+w,y+h], 'conf':float(r['conf']), 'review_status':'unreviewed'}
            if stem == 'normalized':
                mapped = inverse_box6(word['bbox_px'], raw.size, raw.getexif().get(ORIENTATION))
                word['stored_raster_bbox_px'] = mapped
                cropped = normalized.crop(word['bbox_px'])
                mapped_crop = raw.crop(mapped).transpose(Image.Transpose.ROTATE_270)
                if cropped.size != mapped_crop.size or cropped.tobytes() != mapped_crop.tobytes():
                    raise AssertionError('OCR word inverse pixel mismatch')
            words.append(word)
        ocr[stem] = {'input_file':path.name, 'input_sha256':sha(path.read_bytes()),
                     'raw_tsv_sha256':sha(tsv_bytes), 'coordinate_space':path.name,
                     'box_convention':'half-open pixel edges, top-left origin, x right, y down', 'text':(out/f'{stem}.txt').read_text(),
                     'page_size_from_tsv':None, 'words':words, 'command':command}
        page = next(csv.DictReader(io.StringIO(tsv_bytes.decode('utf-8')), delimiter='\t'))
        ocr[stem]['page_size_from_tsv'] = [int(page['width']),int(page['height'])]
    check('normalized_ocr_has_words', len(ocr['normalized']['words']) > 0)
    check('normalized_ocr_boxes_map_pixel_exactly', True)
    check('normalized_tsv_page_matches_normalized_raster', ocr['normalized']['page_size_from_tsv'] == list(normalized.size))
    check('source_and_normalized_have_distinct_file_hashes', sha(source_bytes) != sha(normalized_path.read_bytes()))
    check('all_ocr_words_remain_unreviewed', all(w['review_status']=='unreviewed' for p in ocr.values() for w in p['words']))
    result = {'environment':{'python':sys.version.split()[0], 'pillow':PIL.__version__, 'platform':platform.platform(),
               'tesseract':subprocess.check_output(['tesseract','--version'], text=True).splitlines()[0],
               'font_sha256':sha(FONT.read_bytes())},
              'fixture':{'ground_truth_lines':lines, 'font_pixels':40, 'source_sha256':sha(source_bytes),
                'source_orientation':raw.getexif().get(ORIENTATION), 'stored_size':list(raw.size),
                'normalized_sha256':sha(normalized_path.read_bytes()), 'normalized_size':list(normalized.size),
                'transform':'EXIF orientation 6 -> clockwise 90 degrees, remove orientation tag',
                'coordinate_rule':'half-open pixel edges on normalized.png; inverse [y0,H-x1,y1,H-x0] uses stored raster H'},
              'fixed_pixel_oracle':{'stored_size':[6,4], 'normalized_size':[4,6],
                 'normalized_box':box, 'stored_box':stored_box, 'normalized_crop_pixels':list(normalized_crop.tobytes())},
              'checks':checks, 'check_count':len(checks), 'ocr':ocr,
              'scope':'One generated JPEG, EXIF=6 only, existing local binaries; no general OCR accuracy or decoder security conclusion.'}
    (out / 'results.json').write_text(json.dumps(result, indent=2, ensure_ascii=False)+'\n')
    print(json.dumps({'check_count':len(checks), 'checks_passed':all(checks.values()),
      'raw_source_text':ocr['raw_source']['text'], 'normalized_text':ocr['normalized']['text'],
      'raw_tsv_page':ocr['raw_source']['page_size_from_tsv'], 'normalized_tsv_page':ocr['normalized']['page_size_from_tsv'],
      'normalized_word_count':len(ocr['normalized']['words'])}, indent=2))
    return result


if __name__ == '__main__':
    probe(Path(sys.argv[1]) if len(sys.argv)>1 else ROOT/'output')
test_exif_lab.py:12 个测试方法,含固定网格与证据绑定检查
"""Twelve contract tests; recognition text is an observation, not an oracle."""
import contextlib
import csv
import hashlib
import io
import json
from pathlib import Path
import tempfile
import unittest
from PIL import Image
import exif_lab as lab

class GeometryTests(unittest.TestCase):
    def setUp(self):
        self.grid = Image.new('L', (6, 4))
        self.grid.putdata(list(range(24)))
        self.grid.getexif()[274] = 6

    def test_normalized_pixels_use_explicit_oracle(self):
        expected = [18,12,6,0,19,13,7,1,20,14,8,2,21,15,9,3,22,16,10,4,23,17,11,5]
        actual = lab.normalize(self.grid)
        self.assertEqual(actual.size, (4,6))
        self.assertEqual(list(actual.tobytes()), expected)

    def test_orientation_is_removed_not_reattached(self):
        actual = lab.normalize(self.grid)
        self.assertIsNone(actual.getexif().get(274))
        self.assertEqual(self.grid.getexif().get(274), 6)

    def test_normalization_is_pixel_idempotent(self):
        once = lab.normalize(self.grid)
        twice = lab.normalize(once)
        self.assertEqual((once.size, once.tobytes()), (twice.size, twice.tobytes()))

    def test_box_inverse_has_independent_expected_edges(self):
        self.assertEqual(lab.inverse_box6([0,1,2,4], (6,4), 6), [1,2,4,4])
        self.assertEqual(lab.inverse_box6([0,0,4,6], (6,4), 6), [0,0,6,4])

    def test_all_210_small_grid_rectangles_match(self):
        normalized = lab.normalize(self.grid)
        count = 0
        for x0 in range(4):
            for x1 in range(x0+1,5):
                for y0 in range(6):
                    for y1 in range(y0+1,7):
                        box = [x0,y0,x1,y1]
                        mapped = lab.inverse_box6(box, (6,4), 6)
                        # Independent forward-pixel relation: stored(sx,sy) -> (3-sy,sx).
                        expected = bytes(self.grid.getpixel((y,3-x)) for y in range(y0,y1) for x in range(x0,x1))
                        self.assertEqual(normalized.crop(box).tobytes(), expected)
                        crop = self.grid.crop(mapped).transpose(Image.Transpose.ROTATE_270)
                        self.assertEqual((crop.size,crop.tobytes()), ((x1-x0,y1-y0),expected))
                        count += 1
        self.assertEqual(count, 210)

    def test_unsupported_orientation_is_rejected(self):
        for orientation in [None,1,2,3,4,5,7,8,9]:
            with self.subTest(orientation=orientation), self.assertRaises(ValueError):
                lab.inverse_box6([0,1,2,4], (6,4), orientation)

    def test_invalid_box_is_rejected(self):
        for box in [[-1,0,2,2],[0,0,5,2],[0,0,2,7],[0,0,0,2],[0,3,2,2]]:
            with self.subTest(box=box), self.assertRaises(ValueError):
                lab.inverse_box6(box, (6,4), 6)

class EvidenceTests(unittest.TestCase):
    @classmethod
    def setUpClass(cls):
        cls.tmp = tempfile.TemporaryDirectory()
        cls.out = Path(cls.tmp.name)/'fresh'
        with contextlib.redirect_stdout(io.StringIO()):
            cls.result = lab.probe(cls.out)

    @classmethod
    def tearDownClass(cls):
        cls.tmp.cleanup()

    def test_23_fixture_checks_pass(self):
        self.assertEqual(self.result['check_count'],23)
        self.assertTrue(all(self.result['checks'].values()))

    def test_input_identity_and_coordinate_space(self):
        for stem,name,size in [('raw_source','source-orientation6.jpg',[360,900]),('normalized','normalized.png',[900,360])]:
            record = self.result['ocr'][stem]
            self.assertEqual(record['input_file'],name)
            self.assertEqual(record['coordinate_space'],name)
            self.assertEqual(record['input_sha256'],hashlib.sha256((self.out/name).read_bytes()).hexdigest())
            self.assertEqual(record['page_size_from_tsv'],size)
            self.assertEqual(record['raw_tsv_sha256'],hashlib.sha256((self.out/(stem+'.tsv')).read_bytes()).hexdigest())
        self.assertNotEqual(self.result['ocr']['normalized']['input_sha256'],self.result['fixture']['source_sha256'])

    def test_words_preserve_tsv_rows_text_confidence(self):
        for stem,record in self.result['ocr'].items():
            with (self.out/(stem+'.tsv')).open() as f:
                rows = list(csv.DictReader(f,delimiter='\t'))
            expected = [(i+2,r) for i,r in enumerate(rows) if r['level']=='5' and r['text'].strip()]
            self.assertEqual(len(record['words']),len(expected))
            for word,(row_number,r) in zip(record['words'],expected):
                self.assertEqual(word['tsv_row1'],row_number)
                self.assertEqual(word['word_id'],'.'.join(r[k] for k in ['page_num','block_num','par_num','line_num','word_num']))
                self.assertEqual(word['text'],r['text'])
                self.assertEqual(word['conf'],float(r['conf']))
                x,y,w,h = (int(r[k]) for k in ['left','top','width','height'])
                self.assertEqual(word['bbox_px'],[x,y,x+w,y+h])

    def test_all_words_stay_unreviewed(self):
        self.assertEqual({w['review_status'] for record in self.result['ocr'].values() for w in record['words']},{'unreviewed'})

    def test_written_results_match_returned_evidence(self):
        self.assertEqual(json.loads((self.out/'results.json').read_text()),self.result)
        with Image.open(self.out/'source-orientation6.jpg') as source, Image.open(self.out/'normalized.png') as normalized:
            self.assertEqual(source.getexif()[274],6)
            self.assertIsNone(normalized.getexif().get(274))
            self.assertEqual(normalized.tobytes(),source.transpose(Image.Transpose.ROTATE_270).tobytes())

if __name__ == '__main__':
    unittest.main(verbosity=2)
mutation_check.py:执行九种故意错误的实现
"""Execute nine deliberately wrong implementations against unchanged named tests."""
from pathlib import Path
import json
import shutil
import subprocess
import sys
import tempfile

ROOT = Path(__file__).resolve().parent
SOURCE = (ROOT/'exif_lab.py').read_text()
MUTANTS = [
    ('skip_normalization','return ImageOps.exif_transpose(image)','return image.copy()',
     'GeometryTests.test_normalized_pixels_use_explicit_oracle'),
    ('reattach_stale_orientation','return ImageOps.exif_transpose(image)',
     'result = ImageOps.exif_transpose(image)\n    result.getexif()[ORIENTATION] = image.getexif().get(ORIENTATION, 1)\n    return result',
     'GeometryTests.test_orientation_is_removed_not_reattached'),
    ('rotate_wrong_direction','return ImageOps.exif_transpose(image)','return image.transpose(Image.Transpose.ROTATE_90)',
     'GeometryTests.test_normalized_pixels_use_explicit_oracle'),
    ('copy_box_unchanged','return [y0, h - x1, y1, h - x0]','return list(box)',
     'GeometryTests.test_box_inverse_has_independent_expected_edges'),
    ('use_pixel_index_minus_one','return [y0, h - x1, y1, h - x0]','return [y0, h - 1 - x1, y1, h - 1 - x0]',
     'GeometryTests.test_box_inverse_has_independent_expected_edges'),
    ('omit_bounds_check','if not (0 <= x0 < x1 <= h and 0 <= y0 < y1 <= w):','if False:',
     'GeometryTests.test_invalid_box_is_rejected'),
    ('accept_unsupported_orientation','if orientation != 6:','if False:',
     'GeometryTests.test_unsupported_orientation_is_rejected'),
    ('bind_normalized_words_to_source_hash',"'input_sha256':sha(path.read_bytes())","'input_sha256':sha(source_bytes)",
     'EvidenceTests.test_input_identity_and_coordinate_space'),
    ('label_normalized_space_as_source',"'coordinate_space':path.name","'coordinate_space':source.name",
     'EvidenceTests.test_input_identity_and_coordinate_space'),
]

def main():
    baseline = subprocess.run([sys.executable,'-B','test_exif_lab.py'],cwd=ROOT,capture_output=True,text=True,timeout=30)
    if baseline.returncode != 0 or 'Ran 12 tests' not in baseline.stderr:
        raise RuntimeError('baseline failed: '+baseline.stderr)
    results = []
    for name,old,new,test in MUTANTS:
        if SOURCE.count(old) != 1:
            raise RuntimeError('mutation target is not unique: '+name)
        changed = SOURCE.replace(old,new,1)
        compile(changed,'exif_lab.py','exec')
        with tempfile.TemporaryDirectory() as temp:
            work = Path(temp)
            (work/'exif_lab.py').write_text(changed)
            shutil.copyfile(ROOT/'test_exif_lab.py',work/'test_exif_lab.py')
            command = [sys.executable,'-B','-m','unittest','-v','test_exif_lab.'+test]
            result = subprocess.run(command,cwd=work,capture_output=True,text=True,timeout=30)
        method = test.split('.')[-1]
        if result.returncode != 1 or 'FAIL: '+method not in result.stderr or 'ERROR:' in result.stderr:
            raise RuntimeError('not rejected by the intended assertion: '+name+'\n'+result.stderr)
        results.append({'mutant':name,'failed_test':test,'exit_code':result.returncode,'failure':'assertion'})
    print(json.dumps({'baseline':'12 tests passed','rejected_mutants':results},indent=2))

if __name__ == '__main__':
    main()

两次独立输出目录中的 23 个命名检查全部通过;原图、规范化图、反例图、TSV、TXT 与 stderr 共九个文件分别逐字节相同。报告仅因命令中的输出目录不同而有差异,归一化这两个命令路径后内容相等。这是 23 项检查重复两次,不是 46 种场景。

另外 12 个 unittest 方法通过,其中一个遍历上述 210 个矩形;它们不代表 210 张 OCR 图片。原文 run.py 和三个结构测试也原样重跑通过,原代码另存在包内 original-body/。

两次运行的逐文件哈希对照
{
  "checks_each_run": 23,
  "semantic_reports_equal_after_command_path_normalization": true,
  "identical_non_report_files": {
    "raw_source.txt": "f9b25bab223542602580f5e60094f6adfebe0bf795f992e57fd9c2283353b787",
    "raw_source.tsv": "c6549e404b21e2c5fb35d19bd5d68796023ddd7bbd23d836b3ab001e6a8a4d70",
    "source-orientation6.jpg": "37d0cb16568c42e03eeb88e75e3ef535b5da667ab2549f509b097ed4b490d0ed",
    "normalized.stderr.txt": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
    "negative-stale-orientation.png": "1d739bb78694d8e2039f0ff76fd6484e3b97ea371a438012cbd0ba6ca8659c04",
    "normalized.txt": "510d81371672b6cb0c9888473363f30f0ea4cb2a5533bf21e70a1ed9c3bc1506",
    "normalized.png": "6001403ad73ebe282f69c710ef6765845f6734ac3be77d1fcbfdd9c276445bd0",
    "normalized.tsv": "8313bdbac9b9ce1d0287c6a907e959422417acc02132091fda519eb355be9c31",
    "raw_source.stderr.txt": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
  }
}
运行说明与文件范围
OCR EXIF=6 独立补充实验

环境:Python 3.12.14、Pillow 12.3.0、Tesseract 5.5.0、eng 数据;字体为已安装的 /usr/share/fonts/truetype/dejavu/DejaVuSans.ttf。本包不安装或下载依赖,不附带字体或语言模型。

在本目录用已有且获准的环境运行:
  python -B exif_lab.py new-run
  python -B test_exif_lab.py
  python -B mutation_check.py

new-run 必须尚不存在。程序生成一张虚构的三行 JPEG,运行真实本地 Tesseract,输出原图、规范化 PNG、旧方向标签反例、原始 TSV/TXT/stderr 与 results.json。观察输出位于 observed/ 和 observed-repeat/。这两个目录是已保存的本次结果,请另取新目录重跑。

exif_lab.py 含 23 个命名检查;test_exif_lab.py 有 12 个 unittest 方法,其中一个遍历 6×4 网格的 210 个非空矩形。不是 210 张图,也不是 OCR 准确率实验。mutation_check.py 执行九种故意错误的实现,要求指定断言失败,语法/导入/运行异常不计作成功拦截。

inverse_box6 明确只接受 Orientation=6;其他值抛出 ValueError。normalize 调用 Pillow 原函数,不代表本实验覆盖所有方向。Orientation 缺失或为 1 的对照只检查像素不变,不承诺删除值为 1 的标签。规范化去掉活动方向,不是清除全部元数据。

词框属于 input_sha256、coordinate_space、page_size_from_tsv 标识的 OCR 输入;源 JPEG 哈希另存。所有词保持 unreviewed。哈希检测字节差异,不认证来源。存储像素的逆变换不适用于已经自动应用 EXIF 的查看器坐标。

original-body/ 逐字保存原文 run.py、test_run.py 和历史 probe.py;独立补充没有改动它们。前两份可在 original-body 目录运行;run-output 必须尚不存在。历史 probe.py 固定当时环境并直接写所在目录,保留用于理解旧实验,不建议以它替代 run.py。

本包无网络、模型调用、用户图片、手机应用或生产审核系统。未实现其他方向的逆变换、裁剪/缩放/透视/纠偏组合、畸形元数据与不可信解码隔离。

14. 原始输出与错误对照都能复查

下面展开本次两路 TXT、TSV 和完整记录;输入 JPEG、两张输出 PNG、两次结果和环境信息也都在源码包里。规范化输入本身未画框,可直接查看。识别文本只是这次运行的观察值,测试没有把它写成所有版本必须得到的固定答案。

规范化 PNG:本次真实 OCR 输入,未画框
原创三行英文夹具:LOCAL OCR FIXTURE、Record: ALPHA-2048、Quantity: 17
900×360,来源为本次 JPEG 解码后规范化的像素。
直接输入 JPEG 的原始 TXT
ut ©
ec ct
> Oo
EN
x <
Se
~
ce F&F 4
Oo ge .,.
Oo 1. B
3 COC &
tI 6 ¢€
a ¢ 5s
GS 26
规范化 PNG 的原始 TXT
LOCAL OCR FIXTURE
Record: ALPHA-2048
Quantity: 17
直接输入 JPEG 的原始 TSV
level	page_num	block_num	par_num	line_num	word_num	left	top	width	height	conf	text
1	1	0	0	0	0	0	0	360	900	-1	
2	1	1	0	0	0	48	441	218	407	-1	
3	1	1	1	0	0	48	441	218	407	-1	
4	1	1	1	1	0	49	441	119	20	-1	
5	1	1	1	1	1	49	437	42	32	53.560371	ut
5	1	1	1	1	2	138	441	30	20	85.480331	©
4	1	1	1	2	0	49	466	119	22	-1	
5	1	1	1	2	1	49	462	43	35	51.430817	ec
5	1	1	1	2	2	135	462	33	35	58.839317	ct
4	1	1	1	3	0	49	492	119	26	-1	
5	1	1	1	3	1	49	495	29	23	87.603088	>
5	1	1	1	3	2	130	488	38	34	63.636688	Oo
4	1	1	1	4	0	49	520	119	26	-1	
5	1	1	1	4	1	49	520	119	26	62.795059	EN
4	1	1	1	5	0	49	542	119	40	-1	
5	1	1	1	5	1	49	542	119	40	88.546974	x
5	1	1	1	5	2	129	537	42	56	55.574184	<
4	1	1	1	6	0	49	586	119	22	-1	
5	1	1	1	6	1	49	586	119	22	4.115250	Se
4	1	1	1	7	0	229	606	29	19	-1	
5	1	1	1	7	1	229	606	29	19	0.000000	~
4	1	1	1	8	0	49	613	209	41	-1	
5	1	1	1	8	1	49	613	209	41	25.799515	ce
5	1	1	1	8	2	143	609	41	49	25.604927	F&F
5	1	1	1	8	3	228	609	34	49	50.705532	4
4	1	1	1	9	0	48	646	210	44	-1	
5	1	1	1	9	1	48	646	51	44	39.784431	Oo
5	1	1	1	9	2	139	646	37	44	51.395325	ge
5	1	1	1	9	3	212	646	46	44	36.041531	.,.
4	1	1	1	10	0	48	678	218	37	-1	
5	1	1	1	10	1	48	674	49	45	13.555565	Oo
5	1	1	1	10	2	147	703	21	4	2.942215	1.
5	1	1	1	10	3	231	679	35	36	58.188019	B
4	1	1	1	11	0	49	715	209	34	-1	
5	1	1	1	11	1	49	720	29	18	61.594170	3
5	1	1	1	11	2	139	711	39	42	34.558640	COC
5	1	1	1	11	3	228	721	30	21	40.293152	&
4	1	1	1	12	0	49	743	209	32	-1	
5	1	1	1	12	1	49	739	43	40	5.152710	tI
5	1	1	1	12	2	145	755	23	20	5.152710	6
5	1	1	1	12	3	221	739	37	40	0.000000	¢€
4	1	1	1	13	0	48	768	210	58	-1	
5	1	1	1	13	1	48	772	30	24	19.113876	a
5	1	1	1	13	2	145	779	23	43	0.000000	¢
5	1	1	1	13	3	218	768	40	58	62.139629	5s
4	1	1	1	14	0	48	800	215	48	-1	
5	1	1	1	14	1	48	796	54	63	65.381569	GS
5	1	1	1	14	2	139	821	124	27	81.553757	26
规范化 PNG 的原始 TSV
level	page_num	block_num	par_num	line_num	word_num	left	top	width	height	conf	text
1	1	0	0	0	0	0	0	900	360	-1	
2	1	1	0	0	0	52	48	407	218	-1	
3	1	1	1	0	0	52	48	407	218	-1	
4	1	1	1	1	0	54	48	405	30	-1	
5	1	1	1	1	1	54	48	126	30	96.862572	LOCAL
5	1	1	1	1	2	195	48	83	30	96.125275	OCR
5	1	1	1	1	3	297	49	162	29	96.553650	FIXTURE
4	1	1	1	2	0	54	138	405	30	-1	
5	1	1	1	2	1	54	138	143	30	90.878029	Record:
5	1	1	1	2	2	214	138	245	30	90.587784	ALPHA-2048
4	1	1	1	3	0	52	228	242	38	-1	
5	1	1	1	3	1	52	228	177	38	95.343567	Quantity:
5	1	1	1	3	2	250	229	44	29	96.844383	17
本次完整 results.json
{
  "environment": {
    "python": "3.12.14",
    "pillow": "12.3.0",
    "platform": "Linux-6.18.44-x86_64-with-glibc2.41",
    "tesseract": "tesseract 5.5.0",
    "font_sha256": "57f73e11f51999432bf7ab22ce55b6f945d5eca1bf824404cfa9ec2e3718c84e"
  },
  "fixture": {
    "ground_truth_lines": [
      "LOCAL OCR FIXTURE",
      "Record: ALPHA-2048",
      "Quantity: 17"
    ],
    "font_pixels": 40,
    "source_sha256": "37d0cb16568c42e03eeb88e75e3ef535b5da667ab2549f509b097ed4b490d0ed",
    "source_orientation": 6,
    "stored_size": [
      360,
      900
    ],
    "normalized_sha256": "6001403ad73ebe282f69c710ef6765845f6734ac3be77d1fcbfdd9c276445bd0",
    "normalized_size": [
      900,
      360
    ],
    "transform": "EXIF orientation 6 -> clockwise 90 degrees, remove orientation tag",
    "coordinate_rule": "half-open pixel edges on normalized.png; inverse [y0,H-x1,y1,H-x0] uses stored raster H"
  },
  "fixed_pixel_oracle": {
    "stored_size": [
      6,
      4
    ],
    "normalized_size": [
      4,
      6
    ],
    "normalized_box": [
      0,
      1,
      2,
      4
    ],
    "stored_box": [
      1,
      2,
      4,
      4
    ],
    "normalized_crop_pixels": [
      19,
      13,
      20,
      14,
      21,
      15
    ]
  },
  "checks": {
    "no_orientation_keeps_pixels": true,
    "orientation_1_keeps_pixels": true,
    "orientation_6_matches_explicit_pixel_oracle": true,
    "orientation_6_swaps_dimensions": true,
    "orientation_6_tag_removed": true,
    "original_metadata_unchanged": true,
    "repeat_normalization_pixel_idempotent": true,
    "inverse_box_matches_independent_expected_edges": true,
    "inverse_crop_pixels_match": true,
    "copying_box_without_mapping_is_wrong": true,
    "pixel_index_minus_one_is_wrong_for_edges": true,
    "out_of_bounds_normalized_box_rejected": true,
    "jpeg_stored_size": true,
    "jpeg_normalized_size": true,
    "normalized_png_has_no_orientation": true,
    "normalized_equals_manual_clockwise_raster": true,
    "stale_metadata_double_rotates": true,
    "source_bytes_remain_unchanged": true,
    "normalized_ocr_has_words": true,
    "normalized_ocr_boxes_map_pixel_exactly": true,
    "normalized_tsv_page_matches_normalized_raster": true,
    "source_and_normalized_have_distinct_file_hashes": true,
    "all_ocr_words_remain_unreviewed": true
  },
  "check_count": 23,
  "ocr": {
    "raw_source": {
      "input_file": "source-orientation6.jpg",
      "input_sha256": "37d0cb16568c42e03eeb88e75e3ef535b5da667ab2549f509b097ed4b490d0ed",
      "raw_tsv_sha256": "c6549e404b21e2c5fb35d19bd5d68796023ddd7bbd23d836b3ab001e6a8a4d70",
      "coordinate_space": "source-orientation6.jpg",
      "box_convention": "half-open pixel edges, top-left origin, x right, y down",
      "text": "ut ©\nec ct\n> Oo\nEN\nx <\nSe\n~\nce F&F 4\nOo ge .,.\nOo 1. B\n3 COC &\ntI 6 ¢€\na ¢ 5s\nGS 26\n",
      "page_size_from_tsv": [
        360,
        900
      ],
      "words": [
        {
          "tsv_row1": 6,
          "word_id": "1.1.1.1.1",
          "text": "ut",
          "bbox_px": [
            49,
            437,
            91,
            469
          ],
          "conf": 53.560371,
          "review_status": "unreviewed"
        },
        {
          "tsv_row1": 7,
          "word_id": "1.1.1.1.2",
          "text": "©",
          "bbox_px": [
            138,
            441,
            168,
            461
          ],
          "conf": 85.480331,
          "review_status": "unreviewed"
        },
        {
          "tsv_row1": 9,
          "word_id": "1.1.1.2.1",
          "text": "ec",
          "bbox_px": [
            49,
            462,
            92,
            497
          ],
          "conf": 51.430817,
          "review_status": "unreviewed"
        },
        {
          "tsv_row1": 10,
          "word_id": "1.1.1.2.2",
          "text": "ct",
          "bbox_px": [
            135,
            462,
            168,
            497
          ],
          "conf": 58.839317,
          "review_status": "unreviewed"
        },
        {
          "tsv_row1": 12,
          "word_id": "1.1.1.3.1",
          "text": ">",
          "bbox_px": [
            49,
            495,
            78,
            518
          ],
          "conf": 87.603088,
          "review_status": "unreviewed"
        },
        {
          "tsv_row1": 13,
          "word_id": "1.1.1.3.2",
          "text": "Oo",
          "bbox_px": [
            130,
            488,
            168,
            522
          ],
          "conf": 63.636688,
          "review_status": "unreviewed"
        },
        {
          "tsv_row1": 15,
          "word_id": "1.1.1.4.1",
          "text": "EN",
          "bbox_px": [
            49,
            520,
            168,
            546
          ],
          "conf": 62.795059,
          "review_status": "unreviewed"
        },
        {
          "tsv_row1": 17,
          "word_id": "1.1.1.5.1",
          "text": "x",
          "bbox_px": [
            49,
            542,
            168,
            582
          ],
          "conf": 88.546974,
          "review_status": "unreviewed"
        },
        {
          "tsv_row1": 18,
          "word_id": "1.1.1.5.2",
          "text": "<",
          "bbox_px": [
            129,
            537,
            171,
            593
          ],
          "conf": 55.574184,
          "review_status": "unreviewed"
        },
        {
          "tsv_row1": 20,
          "word_id": "1.1.1.6.1",
          "text": "Se",
          "bbox_px": [
            49,
            586,
            168,
            608
          ],
          "conf": 4.11525,
          "review_status": "unreviewed"
        },
        {
          "tsv_row1": 22,
          "word_id": "1.1.1.7.1",
          "text": "~",
          "bbox_px": [
            229,
            606,
            258,
            625
          ],
          "conf": 0.0,
          "review_status": "unreviewed"
        },
        {
          "tsv_row1": 24,
          "word_id": "1.1.1.8.1",
          "text": "ce",
          "bbox_px": [
            49,
            613,
            258,
            654
          ],
          "conf": 25.799515,
          "review_status": "unreviewed"
        },
        {
          "tsv_row1": 25,
          "word_id": "1.1.1.8.2",
          "text": "F&F",
          "bbox_px": [
            143,
            609,
            184,
            658
          ],
          "conf": 25.604927,
          "review_status": "unreviewed"
        },
        {
          "tsv_row1": 26,
          "word_id": "1.1.1.8.3",
          "text": "4",
          "bbox_px": [
            228,
            609,
            262,
            658
          ],
          "conf": 50.705532,
          "review_status": "unreviewed"
        },
        {
          "tsv_row1": 28,
          "word_id": "1.1.1.9.1",
          "text": "Oo",
          "bbox_px": [
            48,
            646,
            99,
            690
          ],
          "conf": 39.784431,
          "review_status": "unreviewed"
        },
        {
          "tsv_row1": 29,
          "word_id": "1.1.1.9.2",
          "text": "ge",
          "bbox_px": [
            139,
            646,
            176,
            690
          ],
          "conf": 51.395325,
          "review_status": "unreviewed"
        },
        {
          "tsv_row1": 30,
          "word_id": "1.1.1.9.3",
          "text": ".,.",
          "bbox_px": [
            212,
            646,
            258,
            690
          ],
          "conf": 36.041531,
          "review_status": "unreviewed"
        },
        {
          "tsv_row1": 32,
          "word_id": "1.1.1.10.1",
          "text": "Oo",
          "bbox_px": [
            48,
            674,
            97,
            719
          ],
          "conf": 13.555565,
          "review_status": "unreviewed"
        },
        {
          "tsv_row1": 33,
          "word_id": "1.1.1.10.2",
          "text": "1.",
          "bbox_px": [
            147,
            703,
            168,
            707
          ],
          "conf": 2.942215,
          "review_status": "unreviewed"
        },
        {
          "tsv_row1": 34,
          "word_id": "1.1.1.10.3",
          "text": "B",
          "bbox_px": [
            231,
            679,
            266,
            715
          ],
          "conf": 58.188019,
          "review_status": "unreviewed"
        },
        {
          "tsv_row1": 36,
          "word_id": "1.1.1.11.1",
          "text": "3",
          "bbox_px": [
            49,
            720,
            78,
            738
          ],
          "conf": 61.59417,
          "review_status": "unreviewed"
        },
        {
          "tsv_row1": 37,
          "word_id": "1.1.1.11.2",
          "text": "COC",
          "bbox_px": [
            139,
            711,
            178,
            753
          ],
          "conf": 34.55864,
          "review_status": "unreviewed"
        },
        {
          "tsv_row1": 38,
          "word_id": "1.1.1.11.3",
          "text": "&",
          "bbox_px": [
            228,
            721,
            258,
            742
          ],
          "conf": 40.293152,
          "review_status": "unreviewed"
        },
        {
          "tsv_row1": 40,
          "word_id": "1.1.1.12.1",
          "text": "tI",
          "bbox_px": [
            49,
            739,
            92,
            779
          ],
          "conf": 5.15271,
          "review_status": "unreviewed"
        },
        {
          "tsv_row1": 41,
          "word_id": "1.1.1.12.2",
          "text": "6",
          "bbox_px": [
            145,
            755,
            168,
            775
          ],
          "conf": 5.15271,
          "review_status": "unreviewed"
        },
        {
          "tsv_row1": 42,
          "word_id": "1.1.1.12.3",
          "text": "¢€",
          "bbox_px": [
            221,
            739,
            258,
            779
          ],
          "conf": 0.0,
          "review_status": "unreviewed"
        },
        {
          "tsv_row1": 44,
          "word_id": "1.1.1.13.1",
          "text": "a",
          "bbox_px": [
            48,
            772,
            78,
            796
          ],
          "conf": 19.113876,
          "review_status": "unreviewed"
        },
        {
          "tsv_row1": 45,
          "word_id": "1.1.1.13.2",
          "text": "¢",
          "bbox_px": [
            145,
            779,
            168,
            822
          ],
          "conf": 0.0,
          "review_status": "unreviewed"
        },
        {
          "tsv_row1": 46,
          "word_id": "1.1.1.13.3",
          "text": "5s",
          "bbox_px": [
            218,
            768,
            258,
            826
          ],
          "conf": 62.139629,
          "review_status": "unreviewed"
        },
        {
          "tsv_row1": 48,
          "word_id": "1.1.1.14.1",
          "text": "GS",
          "bbox_px": [
            48,
            796,
            102,
            859
          ],
          "conf": 65.381569,
          "review_status": "unreviewed"
        },
        {
          "tsv_row1": 49,
          "word_id": "1.1.1.14.2",
          "text": "26",
          "bbox_px": [
            139,
            821,
            263,
            848
          ],
          "conf": 81.553757,
          "review_status": "unreviewed"
        }
      ],
      "command": [
        "tesseract",
        "observed/source-orientation6.jpg",
        "observed/raw_source",
        "-l",
        "eng",
        "--oem",
        "1",
        "--psm",
        "6",
        "tsv",
        "txt"
      ]
    },
    "normalized": {
      "input_file": "normalized.png",
      "input_sha256": "6001403ad73ebe282f69c710ef6765845f6734ac3be77d1fcbfdd9c276445bd0",
      "raw_tsv_sha256": "8313bdbac9b9ce1d0287c6a907e959422417acc02132091fda519eb355be9c31",
      "coordinate_space": "normalized.png",
      "box_convention": "half-open pixel edges, top-left origin, x right, y down",
      "text": "LOCAL OCR FIXTURE\nRecord: ALPHA-2048\nQuantity: 17\n",
      "page_size_from_tsv": [
        900,
        360
      ],
      "words": [
        {
          "tsv_row1": 6,
          "word_id": "1.1.1.1.1",
          "text": "LOCAL",
          "bbox_px": [
            54,
            48,
            180,
            78
          ],
          "conf": 96.862572,
          "review_status": "unreviewed",
          "stored_raster_bbox_px": [
            48,
            720,
            78,
            846
          ]
        },
        {
          "tsv_row1": 7,
          "word_id": "1.1.1.1.2",
          "text": "OCR",
          "bbox_px": [
            195,
            48,
            278,
            78
          ],
          "conf": 96.125275,
          "review_status": "unreviewed",
          "stored_raster_bbox_px": [
            48,
            622,
            78,
            705
          ]
        },
        {
          "tsv_row1": 8,
          "word_id": "1.1.1.1.3",
          "text": "FIXTURE",
          "bbox_px": [
            297,
            49,
            459,
            78
          ],
          "conf": 96.55365,
          "review_status": "unreviewed",
          "stored_raster_bbox_px": [
            49,
            441,
            78,
            603
          ]
        },
        {
          "tsv_row1": 10,
          "word_id": "1.1.1.2.1",
          "text": "Record:",
          "bbox_px": [
            54,
            138,
            197,
            168
          ],
          "conf": 90.878029,
          "review_status": "unreviewed",
          "stored_raster_bbox_px": [
            138,
            703,
            168,
            846
          ]
        },
        {
          "tsv_row1": 11,
          "word_id": "1.1.1.2.2",
          "text": "ALPHA-2048",
          "bbox_px": [
            214,
            138,
            459,
            168
          ],
          "conf": 90.587784,
          "review_status": "unreviewed",
          "stored_raster_bbox_px": [
            138,
            441,
            168,
            686
          ]
        },
        {
          "tsv_row1": 13,
          "word_id": "1.1.1.3.1",
          "text": "Quantity:",
          "bbox_px": [
            52,
            228,
            229,
            266
          ],
          "conf": 95.343567,
          "review_status": "unreviewed",
          "stored_raster_bbox_px": [
            228,
            671,
            266,
            848
          ]
        },
        {
          "tsv_row1": 14,
          "word_id": "1.1.1.3.2",
          "text": "17",
          "bbox_px": [
            250,
            229,
            294,
            258
          ],
          "conf": 96.844383,
          "review_status": "unreviewed",
          "stored_raster_bbox_px": [
            229,
            606,
            258,
            650
          ]
        }
      ],
      "command": [
        "tesseract",
        "observed/normalized.png",
        "observed/normalized",
        "-l",
        "eng",
        "--oem",
        "1",
        "--psm",
        "6",
        "tsv",
        "txt"
      ]
    }
  },
  "scope": "One generated JPEG, EXIF=6 only, existing local binaries; no general OCR accuracy or decoder security conclusion."
}

九种错误实现实际执行后,都触发指定断言失败:跳过规范化、重新附上旧标签、转错方向、照抄词框、错用像素索引减一、移除边界检查、放行未支持的方向、绑定错误的原图哈希,以及把规范化坐标空间标成原图。语法、导入或运行异常不计作成功拦截。

九种错误实现及实际失败的测试名
{
  "baseline": "12 tests passed",
  "rejected_mutants": [
    {
      "mutant": "skip_normalization",
      "failed_test": "GeometryTests.test_normalized_pixels_use_explicit_oracle",
      "exit_code": 1,
      "failure": "assertion"
    },
    {
      "mutant": "reattach_stale_orientation",
      "failed_test": "GeometryTests.test_orientation_is_removed_not_reattached",
      "exit_code": 1,
      "failure": "assertion"
    },
    {
      "mutant": "rotate_wrong_direction",
      "failed_test": "GeometryTests.test_normalized_pixels_use_explicit_oracle",
      "exit_code": 1,
      "failure": "assertion"
    },
    {
      "mutant": "copy_box_unchanged",
      "failed_test": "GeometryTests.test_box_inverse_has_independent_expected_edges",
      "exit_code": 1,
      "failure": "assertion"
    },
    {
      "mutant": "use_pixel_index_minus_one",
      "failed_test": "GeometryTests.test_box_inverse_has_independent_expected_edges",
      "exit_code": 1,
      "failure": "assertion"
    },
    {
      "mutant": "omit_bounds_check",
      "failed_test": "GeometryTests.test_invalid_box_is_rejected",
      "exit_code": 1,
      "failure": "assertion"
    },
    {
      "mutant": "accept_unsupported_orientation",
      "failed_test": "GeometryTests.test_unsupported_orientation_is_rejected",
      "exit_code": 1,
      "failure": "assertion"
    },
    {
      "mutant": "bind_normalized_words_to_source_hash",
      "failed_test": "EvidenceTests.test_input_identity_and_coordinate_space",
      "exit_code": 1,
      "failure": "assertion"
    },
    {
      "mutant": "label_normalized_space_as_source",
      "failed_test": "EvidenceTests.test_input_identity_and_coordinate_space",
      "exit_code": 1,
      "failure": "assertion"
    }
  ]
}

本次补测只覆盖一张合成 JPEG 的方向 6。没有验证通用 OCR 准确率、中文、真实扫描、手机拍摄流程、生产审核 UI、模型判断、其他方向的逆变换、透视纠正、裁剪缩放组合或恶意图像解码。官方资料查阅于 2026-10-13。把字读出来之后,仍需按原文流程完成真正的人工复核。

置信值超过 80 就能自动通过吗?

不能。本次小字图的 M8 被读成 M&,分数为 82.609863。80 只是未校准的教学阈值,高分仍保留 unreviewed。

这份代码能识别中文吗?

本次只具备 eng 和 osd 数据,实际实验没有测中文。需要单独准备合适语言数据与任务语料验证。

运行后得到不同误读算失败吗?

不一定。版本、字体、语言数据和环境可能影响输出。测试检查本轮证据一致性,不要求重现固定错误。

是否已经实现人工复核系统?

没有。本文说明复核步骤与独立记录设计,所有自动生成的词保持 needs_review 或 unreviewed。