上个月帮朋友处理一批纸质单据,扫描件堆了大半个文件夹,需要把里面的文字抠出来整理成电子表格。人工打一遍得两三天,我当时想的是直接用Python调OCR接口,让程序自动跑。试了一圈方案之后,最后留下了汉王OCR的云接口——调用方式不复杂,中文识别尤其是手写体识别确实有两把刷子。这篇文章就把整个对接过程完整记录下来:从注册拿密钥、搞懂签名鉴权、写一个能跑的Python Demo,再到后面排查各种坑的经验。无论你是第一次接OCR接口,还是想给手头脚本换一个更稳的文字识别方案,这份记录都能给你一个可以直接参考的模板。
1. 先聊聊为什么是汉王OCR——选型思路与适用场景
1.1 汉王OCR到底适合哪一类需求
汉王这家公司在中文识别领域的积累比较早,早年间很多手写输入、扫描识别产品用的就是他们的技术。现在它的OCR能力是以云API的形式开放出来的,不需要自己去部署模型,也不需要GPU服务器,只要会发HTTP请求就能用。
从实际的识别效果来看,它对中文印刷体、规范手写体的识别能力比较稳,尤其是手写数字、手写汉字这类通用场景比较有优势。如果你手里的图片是以中文为主,还夹杂着不少手写内容,这种需求交给汉王OCR会比通用开源方案省事很多。
它不是万能的,但很擅长文字识别本身。像身份证识别、名片识别、表格还原这类场景,平台一般也会提供专门的接口或者预置能力,做起来更省力气。如果你要做的是通用文字提取、手写笔记结构化、票据信息录入,这类需求正好在它的射程范围内。
1.2 云OCR方案对比
我在选型的时候大概对比过几类方案,各有各的取舍,给各位也做一个参考。
| 方案 | 上手难度 | 中文手写效果 | 部署成本 | 数据隐私 |
|---|---|---|---|---|
| 通用云厂商OCR | 低,签名简单 | 一般,偏印刷体 | 按调用量计费 | 图片需上传云端 |
| 汉王OCR云API | 低,签名稍讲究 | 相对较好 | 按调用量计费 | 图片需上传云端 |
| PaddleOCR本地部署 | 中高,需装环境 | 可通过微调提升 | 需要机器,最好有GPU | 数据不出内网 |
| Tesseract | 中,需调参数 | 对中文支持一般 | 免费,部署低 | 数据不出内网 |
通用云厂商OCR的优势是生态完善,跟存储、AI服务都好联动,但如果你对中文手写体有明确要求,普通接口的识别结果往往不够用。PaddleOCR是开源里很能打的一个,离线可用、模型可以自己训练,但前提是你有精力折腾环境、调参,还得有一台还不错的机器。Tesseract胜在轻量,但对中文场景,连我自己都经常被识别出来的乱码搞到头大。
汉王OCR云API的位置比较巧妙:它把专业模型直接做成了服务,你不用管模型怎么部署,只要上传图片拿结果就行。缺点和所有云服务一样,图片要送到服务端,敏感数据需要慎重评估。
1.3 什么样的情况值得选它
结合我自己的经验,这几类情况用汉王OCR是划算的:
- 图片内容以中文为主,尤其是手写中文、手写数字占比高。
- 项目开发周期短,没有精力和机器去维护OCR模型。
- 调用量不是特别夸张,按次计费的成本在可接受范围。
- 想先验证业务效果,后续再决定要不要上本地模型。
反过来,如果你的数据特别敏感、完全不能出内网,或者日均调用量巨大、成本敏感,那就应该认真考虑PaddleOCR这类离线方案。选型没有绝对的对错,核心是搞清楚自己的场景更看重什么:识别效果、开发速度、部署成本、隐私要求,这四样你得排序。
2. 准备工作:拿到钥匙再动手
2.1 注册开发者账号、创建应用,拿到AppID和AppSecret
对接汉王OCR的第一步是去开放平台注册开发者账号。这里提醒一句,很多人在这一步会随便填个应用名就往下走,其实后面调试的时候发现信息对不上,就白折腾了。
注册登录之后,在控制台里创建一个应用,选择你需要的OCR能力,比如通用文字识别。创建完成后,控制台会给你两个关键参数:应用ID(一般叫AppID)和应用密钥(AppSecret)。
AppSecret是非常重要的凭证,它相当于你程序的钥匙,负责请求签名。这个值只在创建时展示或允许重新生成,千万不要泄露到Git仓库里,尤其是公开仓库。不管你是个人开发者还是在公司,都应该把它放到环境变量或者本地配置文件里,而不是写死在代码中。
我当时就是直接把密钥写在了脚本里,传代码的时候手滑差点带出去,后来才改成环境变量。这个习惯如果一开始就养成,后面能省掉不少麻烦。
2.2 看清接口文档里的鉴权要求
在写代码之前,先花半小时把平台提供的接口文档完整看一遍,重点是这几块:
- 接口地址:各环境(联调、生产)的域名和路径。
- 请求方法:一般是POST,但请求体是form格式还是JSON格式有讲究。
- 鉴权方式:常见的有AppID+AppSecret做HMAC签名,也有用OAuth2临时凭证的。
- 参数列表:哪些是公共参数,哪些是业务参数,参数是否要参与签名。
- 返回码和错误码说明:这决定了你排错的方向。
汉王OCR平台的鉴权属于典型的签名式鉴权,核心思路是:调用方用AppSecret对请求参数做一次不可逆的签名计算,服务端收到后用同样的算法算一遍,两个签名一致才放行。它的优点是每次调用独立鉴权,服务器不保存临时令牌,也就不存在令牌过期的问题。
需要特别注意:文档里会说明哪些参数参与签名、签名结果用十六进制还是Base64、时间戳单位是秒还是毫秒。这些细节直接决定你算出来的签名对不对。
2.3 本地Python环境准备
代码本身很简单,依赖也没几个,但环境还是建议好好准备。推荐Python 3.8以上版本,配合虚拟环境使用。
基础依赖只需要一个requests库。安装命令:
pip install requests如果你后面要做图片压缩,还需要Pillow:
pip install pillow建议在项目目录下建一个虚拟环境再安装,避免把依赖装得全局都是。另外准备一张测试图片,最好找一张文字清晰、方向正常、光线均匀的图片,比如一张白纸黑字的照片或者截图。先把这张图跑通了,再去碰那些歪歪扭扭的扫描件。
3. 签名计算与请求组装:最容易错的一步
3.1 为什么签名机制里到处是坑
签名这步是整个对接过程中最容易出错的地方,而且报错往往很抽象,只告诉你“签名校验失败”。我第一次接的时候,就因为这个折腾了两个多小时。
签名机制的核心目的有三个:防止请求被伪造、防止请求参数被篡改、防止明显过期的请求被重放。理解了这个逻辑,你就明白为什么要带上时间戳和随机数了。
时间戳用来限制请求的有效期,服务端收到请求后如果发现时间差超过阈值,比如5分钟,就直接拒绝。随机数(nonce)是为了保证即使是同一秒发出的两个请求,签名的消息串也不会完全一样,避免被构造重放的固定请求。
常用的签名算法是HMAC-SHA256,它比MD5更适合这个场景。MD5只做校验,不带密钥的话谁都能伪造;HMAC-SHA256是密钥相关的哈希运算,没有AppSecret算不出相同的签名,安全性高一个量级。
3.2 HMAC-SHA256签名流程拆解
以汉王OCR平台的签名式鉴权为例(具体请以最新文档为准,但核心流程是通用的),签名计算分四步:
第一步,收集所有参与签名的参数,包括公共参数和业务参数。公共参数一般有AppID、时间戳、随机数,业务参数就是业务相关的字段,比如语言类型、图片内容。
第二步,把所有参数按照参数名的字典序排序,也就是按key的ASCII码从小到大排列。
第三步,把排好序的参数用URL编码格式拼接成一个字符串,格式类似app_id=xxx&language=ch&nonce=123456×tamp=1700000000。注意值的编码规则,中文要按UTF-8编码后再做URL编码。
第四步,用AppSecret作为密钥,对拼接好的消息串做HMAC-SHA256运算,再把结果做Base64编码或转成十六进制字符串,放到请求参数里。
完整示例:
import hashlib import hmac import base64 import urllib.parse def build_sign(params: dict, app_secret: str) -> str: sorted_items = sorted(params.items()) query_string = urllib.parse.urlencode(sorted_items) message = query_string.encode("utf-8") digest = hmac.new( app_secret.encode("utf-8"), message, hashlib.sha256 ).digest() return base64.b64encode(digest).decode("utf-8")注意这里是先用urlencode处理,再对编码后的字符串做HMAC。如果文档要求的是先拼接原文再编码,结果就会完全不同。
3.3 容易被忽略的编码细节
我遇到过好几种因为编码细节导致的签名失败,这里列出来,你们可以直接避坑。
第一,URL编码时空格到底编码成%20还是+。有些平台的签名算法会用%20,有些会用+。requests库里的data参数和urlencode默认行为还不完全一样,一旦服务端和客户端理解不一致,签名就废了。
第二,参数字符串里的中文如果不做URL编码,直接拼进签名字符串,服务端拿到后按UTF-8解码再算一次签名,两边结果不同,就会报签名错误。所以图片的Base64内容尤其要注意,它本身只包含字母、数字和+/=,一般不受影响,但业务字段里如果有中文备注,就必须保持一致。
第三,时间戳单位到底是秒还是毫秒。如果是毫秒,你用秒去填,服务端校验时间窗口时会认为这个请求早就过期了。
第四,签名完成之后千万不要再改动任何参与签名的参数。很多人是先构造完整params,算签名,然后又往里面塞了一个version字段,结果服务端把version也纳入了签名校验,你算的时候没有它,当然对不上。
4. 完整Python代码:能直接跑的识别Demo
4.1 客户端类的结构与设计思路
到了写代码的环节,我的建议和很多看文档就上手的人不一样:不要一上来就把所有功能堆在一个脚本里。先设计一个最小客户端类,把签名、请求、解析三块逻辑拆开,后面无论是接批量任务还是换成别的OCR接口,都能快速复用。
类的核心成员就四个:AppID、AppSecret、接口地址、一个requests.Session。用Session的好处是它会自动复用TCP连接,连续多次调用的场景下速度会明显改善。
方法上分三个层次:底层是签名计算,中层是HTTP请求发送,上层是业务方法recognize。业务方法只关心图片路径和语言参数,不用关心签名怎么算,这样代码读起来清爽,改起来也方便。
4.2 完整代码实现
这里给出一个最小可用的完整实现,你们拿到后只需替换AppID、AppSecret和接口地址,再把图片路径改掉就能跑。
import base64 import hashlib import hmac import json import time import random import urllib.parse import requests class HanwangOCRClient: def __init__(self, app_id: str, app_secret: str, base_url: str): self.app_id = app_id self.app_secret = app_secret self.base_url = base_url self.session = requests.Session() def _sign(self, params: dict) -> str: sorted_items = sorted(params.items()) query_string = urllib.parse.urlencode(sorted_items) message = query_string.encode("utf-8") digest = hmac.new( self.app_secret.encode("utf-8"), message, hashlib.sha256 ).digest() return base64.b64encode(digest).decode("utf-8") def _build_params(self, image_base64: str, language: str) -> dict: params = { "app_id": self.app_id, "timestamp": str(int(time.time())), "nonce": str(random.randint(100000, 999999)), "language": language, "image": image_base64, } params["sign"] = self._sign(params) return params def recognize(self, image_path: str, language: str = "ch"): with open(image_path, "rb") as f: image_base64 = base64.b64encode(f.read()).decode("utf-8") params = self._build_params(image_base64, language) resp = self.session.post( self.base_url, data=json.dumps(params), headers={"Content-Type": "application/json"}, timeout=30, ) resp.raise_for_status() return resp.json() if __name__ == "__main__": client = HanwangOCRClient( app_id="你的AppID", app_secret="你的AppSecret", base_url="https://openapi.xxx.com/ocr/recognize", ) result = client.recognize("demo.png") print(json.dumps(result, ensure_ascii=False, indent=2))代码里有个关键点:签名是在_build_params里完成的,sign字段加进params之后,这个字典就不要再动了,否则服务端重新计算签名时会发现不一致。另外image参数就是图片的Base64字符串,参与签名时用的也是这个字符串,这个和平台文档保持一致就行。
关于接口地址,这里用的是占位符,实际地址要以开放平台文档里给出的正式域名为准。不同环境、不同能力接口的路径也不一样,直接复制文档里的真实地址。
4.3 从响应里把文字安全地取出来
跑通接口之后,返回的JSON里到底怎么把文字取出来,是第二个容易卡住的地方。
正常的响应结构一般是这样的:
{ "code": 0, "message": "ok", "data": { "text": "识别的文字内容", "items": [ {"text": "区域文字", "confidence": 0.99, "box": {...}} ] } }要想不踩坑,第一步永远是拿真实返回打印出来看一遍,用我上面代码里的print(json.dumps(result, ...)),先搞清楚字段再写解析逻辑。不要凭想象去取result["data"]["text"],万一只返回了带坐标的items,你后面全报KeyError。
稳妥的解析方式是这样:
def extract_text(result: dict) -> str: if result.get("code") != 0: raise RuntimeError(f"识别失败: {result.get('message')}") data = result.get("data") or {} if isinstance(data, str): return data if isinstance(data, list): return "\n".join(item.get("text", "") for item in data) if isinstance(data, dict): if "text" in data and isinstance(data["text"], str): return data["text"] items = data.get("items") or [] return "\n".join(item.get("text", "") for item in items) return ""这段代码兼容了字符串、列表、字典三种常见的返回结构,后面换不同厂商的接口时也能少改两行。识别结果里如果有置信度字段,建议顺手把低置信度的行过滤掉或者单独标记,方便人工复核。
5. 调试实录:我踩过的5个坑
5.1 坑一:签名总是不通过
对接过程中遇到最多的问题就是签名校验失败。我当时排查了很久,最后发现是业务参数里有一个中文备注字段没有做URL编码。我本地拼签名字符串时直接用了原始中文,服务端收到后按UTF-8转码再拼一次,两边算出来的HMAC当然对不上。
排查这类问题有个笨但有效的方法:把客户端拼出来的待签名串打印出来,再在服务端文档给出的示例里用同样的参数算一遍,对比每一个字符。有时候是空格编码的问题,有时候是参数名大小写的问题,但只要一行行对着看,总能找到差异。
我自己整理了一个排查清单,照着查效率很高:
- 参数名是否完全按文档的拼写,大小写是否一致。
- 是否所有参与签名的参数都做了字典排序。
- 签名字符串是否用了URL编码,空格是
%20还是+。 - 时间戳单位是秒还是毫秒,是否在有效期窗口内。
- 签名完成后是否又往参数里加了新字段。
- 算法选择是否正确,HMAC-SHA256的密钥是不是AppSecret本身。
5.2 坑二:图片越大越容易超时
有一段时间我批量识别扫描件,图片都是几百KB到几MB的高清扫描图,直接把原图读出来Base64编码后POST上去,经常报超时。
原因很简单:Base64会把文件体积膨胀约33%,一张3MB的图传上去变成4MB的请求体,再加上网络波动,30秒超时很正常。
解决方案是两步走。第一步,优化图片体积,转成JPEG并适当压缩;第二步,把超时时间调大,同时用Session保持长连接。
图片压缩的实用代码:
from PIL import Image def compress_image(image_path: str, max_width: int = 2000, quality: int = 85) -> str: img = Image.open(image_path) if img.width > max_width: ratio = max_width / img.width img = img.resize((max_width, int(img.height * ratio))) out_path = image_path.rsplit(".", 1)[0] + "_compressed.jpg" img.convert("RGB").save(out_path, "JPEG", quality=quality) return out_path实测下来,一张原来1.5MB的扫描件,压到85%质量、限制宽度2000像素后,基本能控制在300KB左右,识别效果几乎不受影响。OCR识别对清晰度有要求,但有效分辨率超过一定程度后,继续堆像素只会拖慢速度和增加成本,并不会让准确率继续上升。
5.3 坑三:清晰图片识别率却很低
还有一类问题让我特别困惑:图片肉眼看起来很清晰,识别率却很差。后来仔细排查,发现原因是图片方向不对和背景干扰。
手机拍的合同照片,经常是旋转了90度或者180度;扫描件虽然方向正常,但纸面发黄、有阴影、有手指挡光的地方。OCR模型对这类图会明显掉点。
如果图片是垂直方向拍的,处理起来很简单:在送接口之前先用Pillow把方向校正成正常阅读方向。如果背景太脏,可以先转灰度再做简单的二值化处理。不要过度处理,OCR接口本身接受灰度图和彩色图,你只需要保证文字和背景对比度够高就行。
另外一个容易忽略的点是:图片里如果混合了打印体和手写体,接口的识别模式要选对。很多OCR服务会提供识别模式参数,默认是印刷体优先,遇到手写体效果就变差。汉王接口对中文手写体有独立能力,如果你要识别的图片以手写为主,一定先确认参数是否支持手写模式,再决定是否需要在业务上多做一步人工审核。
5.4 坑四:并发一高就被限流
批量跑的时候,我最开始是直接用多线程开了一堆请求,结果跑了几十张之后,接口开始返回频率超限的错误码。
云OCR接口一般都会做QPS限制,汉王OCR开放平台的免费/默认档位通常都不高。如果有会员等级或套餐,QPS会相应提高。千万别拿免费的额度去跑大规模并发。
通用的做法是在客户端里加一个信号量控制并发数,让并发的请求数稳定在一个保守的水平,比如2到4个。
from concurrent.futures import ThreadPoolExecutor, as_completed import time def process_one(client: HanwangOCRClient, path: str): for attempt in range(3): try: result = client.recognize(path) if result.get("code") == 0: return path, result, None if "frequency" in str(result.get("code")): time.sleep(2 * (attempt + 1)) else: return path, None, result.get("message") except Exception as exc: time.sleep(1) return path, None, "failed after retries" paths = [...] # 图片路径列表 client = HanwangOCRClient(...) with ThreadPoolExecutor(max_workers=2) as pool: futures = [pool.submit(process_one, client, p) for p in paths] for future in as_completed(futures): path, result, err = future.result() if err: print(f"失败: {path} -> {err}") else: print(f"成功: {path}")核心逻辑是:遇到频率限制错误码时,指数退避重试,重试次数不要太多,3次就够。直接无脑重试不仅治标不治本,还会把限流越搞越严重。
如果要跑几百张以上的任务,建议自己控制节奏:每批提交20张,等这批全部完成再提交下一批。这种“小批量串行+批内少量并发”的方式最稳。
5.5 坑五:看不懂返回码
调试初期,我经常看着返回的code一脸茫然。后来把文档里的错误码说明整理成一份速查表,排查效率高了很多。下面是我自己整理的一个简化版,具体还是以你们拿到的官方文档为准。
| 返回码 | 含义 | 常见原因 |
|---|---|---|
| 0 | 成功 | 无 |
| 40001 | 参数错误 | 必填参数缺失或格式不对 |
| 40003 | 签名错误 | 签名算法、参数排序或编码不一致 |
| 40005 | 频率超限 | 超过了当前账号的QPS限制 |
| 40100 | 鉴权失败 | AppID或AppSecret错误 |
| 50000 | 服务端内部错误 | 服务端异常,等待后重试 |
遇到未知返回码,第一件事不是去猜,而是去开放平台文档里搜“错误码”或者“返回码”的章节,看官方解释。如果文档里也没有,就用最小请求复现,截全请求头发给平台技术支持。大部分厂商的支持响应都还可以,前提是你把截图和请求内容准备得足够完整。
6. 从Demo到生产:批量识别、结果存储与成本控制
6.1 批量文件夹识别:从串行到可控并发
Demo跑通之后,下一步就是把单个文件变成文件夹批量处理。这个阶段最忌讳的是把所有图片一股脑丢进线程池,直接把限流打爆。
我的建议是先做一个文件夹遍历函数,收集所有图片路径:
import os SUPPORTED_SUFFIX = {".jpg", ".jpeg", ".png", ".bmp", ".tif", ".tiff", ".pdf"} def collect_images(folder: str): image_paths = [] for root, _, files in os.walk(folder): for name in files: suffix = os.path.splitext(name)[1].lower() if suffix in SUPPORTED_SUFFIX: image_paths.append(os.path.join(root, name)) return image_paths然后配合上一节提到的ThreadPoolExecutor做并发,但并发数一定要克制。如果接口文档写的是单账号10 QPS,保守起见你就开到4个并发,给其他业务留足余量。
批量任务一定要支持断点续跑。最简单的做法是每处理完一张图,就把结果立即写入JSONL文件,而不是全部跑完再一次性写。这样即使中间网络断了,或者程序崩了,已经处理过的结果不会白跑。
6.2 结果落盘与字段结构化
识别结果直接存成三个文件会让后期使用方便很多:
- 原始JSON文件:保留完整识别信息,方便复现和debug。
- CSV文件:按文件名、识别文本、耗时列组织,方便用Excel查看。
- 汇总文本文件:纯文本拼接,方便后续做全文搜索。
具体写CSV的时候,识别文本里如果有换行符,要用双引号包起来,否则CSV会被拆成多行。直接用Python的csv模块可以自动处理这部分。
如果做的是固定版式单据识别,还可以在后处理阶段用正则或者简单规则把关键字段抽出来,比如日期、编号、金额。字段抽取的规则建议单独写一个函数,不要和OCR调用的代码搅在一起,方便后续维护。
6.3 省钱的三个思路:缓存、压缩、指数退避
云OCR接口是按调用量计费的,同一个任务跑两遍就要付两份钱,所以控制成本的核心思路是减少无效调用。
第一个思路是缓存。对图片文件做MD5哈希,记录“图片哈希 -> 识别结果”的映射关系。同一张图如果被重复识别,直接读缓存结果。这个思路在处理同名图片被多次处理、或者定时任务反复扫描同一目录时特别有效。
import hashlib import json def image_md5(path: str) -> str: h = hashlib.md5() with open(path, "rb") as f: for chunk in iter(lambda: f.read(8192), b""): h.update(chunk) return h.hexdigest()第二个思路是压缩后再上传,这既是省钱也是提速。图片体积变小之后,请求体变小,传输时间下降,如果接口按次计费,图片体积并不影响单价,但如果是按时长或流量计费的套餐,节省就非常明显。
第三个思路是失败重试用指数退避,而不是固定间隔。固定间隔重试在服务端抖动时容易集中打过去,指数退避则可以给服务端留出恢复时间,也减少自己触发限流的概率。
6.4 隐私与合规注意事项
最后说一个容易被忽略的话题:把图片送到云端OCR服务,本质上就是数据出域。如果图片里有身份证号、手机号、合同金额这类敏感信息,一定要走内部的敏感数据审批流程,和业务方确认是否允许这样做。
实操建议是三条:
- 正式环境强制使用HTTPS,平台一般也是强制要求的。
- 不要在日志里打印完整图片的Base64内容,调试时可以打印前几十个字符,确认有值就行。
- 批量任务完成后,本地临时图片和中间文件及时清理,不要长期堆在服务器上。
另外要认真看一下平台服务协议里关于数据保存期限的规定,有的云服务会明确说明请求数据保留多长时间、是否用于模型优化。如果你的数据敏感,选择“不用于优化模型”的选项或者干脆本地化,这个决定尽早做,别等上线了再改。
最后再分享一点我的体会。接这类云接口,真正的难度不在代码写得多么精巧,而在签名细节和参数约束上。建议你第一次对接时,先别急着做并发和批量,把最小的Demo完整跑通,把返回的JSON彻底看明白,然后再往外面包一层。手里一定留一份请求日志,遇到问题可以随时回查。这个思路不只在汉王OCR上适用,我后来接其他家OCR接口时基本也是同一套流程。如果你想进一步扩展,把这段识别代码封装成命令行工具或者一个FastAPI接口,团队的同事也就能直接用了,整个自动化流程的价值会再翻一倍。