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 将来会拿到什么证据,以及哪些结论还不能形成。
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、环境记录、人工编写的夹具真值与差异已在下文展开,读者可以直接逐项比对。
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.pyrun.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 结果来伪造错误。
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 记录。这里给出的是人工操作与记录设计。自动化测试只检查机器证据,没有通过脚本冒充审核者。若将来实现复核入口,还需要专门测试旧图哈希、错误词框、原词绑定和纠正记录不可覆盖原始输出等情况。
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
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 输入,未画框
直接输入 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。