Files
ocr_data/tool/Ocr/tencentcloud_ocr.py
2026-09-28 16:09:31 +08:00

372 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- encoding: utf-8 -*-
'''
@File : tencentcloud_ocr.py
@Time : @
@Author : eason
@Version : 1.0
@Contact :
@License : (C)Copyright 2021-2024
@Desc : 将图片转换为base64编码,并使用内部Youtu OCR服务进行文字识别(app_id自动生成)
'''
import json
import os
import base64
import random
import threading
import time
import requests
from config.config_utils import config # 如果不再需要配置可以移除,但保留其他配置可能使用
# ---- OCR 端点(多 IP 负载均衡)配置 ----
OCR_PATH = "/youtu/ocrapi/hpgeneralocr_v3"
OCR_DEFAULT_PORT = 18081 # config 里只填 IP 时自动补的端口
DEFAULT_ENDPOINTS = ["http://asr.ocr.general.smart.l20.production.polaris:18081"]
WEIGHTS_FILE = "./config/ocr_weights.json"
def normalize_endpoint(e):
"""把 'IP' / 'IP:port' / 'http://IP:port' 统一成 'http://IP:port'。
config 里只填裸 IP 时,自动补 http:// 和默认端口。"""
if not e:
return None
e = str(e).strip().rstrip("/")
if not e:
return None
if not (e.startswith("http://") or e.startswith("https://")):
e = "http://" + e
host = e.split("://", 1)[1].split("/", 1)[0] # 取 host[:port] 部分
if ":" not in host: # 没写端口就补默认端口
e = e + f":{OCR_DEFAULT_PORT}"
return e
def img2base64(img_path: str) -> str:
"""将图片转换为base64编码"""
with open(img_path, 'rb') as f:
image_base64 = base64.b64encode(f.read()).decode()
return image_base64
def get_ocr_endpoints():
"""从配置读取 OCR 端点列表,自动归一化(只填 IP 也行);缺省回退到内置默认。"""
ocr_cfg = config.get_tencent_ocr_config() or {}
eps = ocr_cfg.get("endpoints")
if isinstance(eps, str):
eps = [eps]
if not eps:
eps = DEFAULT_ENDPOINTS
return [u for u in (normalize_endpoint(e) for e in eps) if u]
def load_endpoint_weights():
"""读取压测产出的 QPS 权重文件 {base_url: qps};不存在则返回 {}。"""
if not os.path.exists(WEIGHTS_FILE):
return {}
try:
with open(WEIGHTS_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
return {k.rstrip("/"): float(v) for k, v in data.items() if float(v) > 0}
except (json.JSONDecodeError, ValueError, TypeError, OSError):
return {}
class OcrDispatcher:
"""线程安全的 OCR 端点分发器:用 P-of-d-Choices(默认d=3)做【负载/延迟感知】派发——
随机取 sample_size 个端点, 把请求发给其中【在途更少 × 更快】的那个。慢/积压端点自动少分。
每个进程独立按后端真实快慢反应 → 无需任何协调/中心LB, 也能在 120 台 OCR 上涌现出全局均衡。
d 从 2 提到 3(甚至更多): 端点数越多, 随机抽2个都恰好较差的概率越高, 抽样数多一个能明显
收窄这个方差, 之后收益递减, 不用无限往上加。
失败端点冷却摘除(冷却时长带随机抖动, 防止多个进程对同一端点同时解冷却又一起打回去,
造成"整体一起失败-一起恢复-一起打爆"的同步震荡)。多个并发 OCR 线程共享一个实例。"""
def __init__(self, endpoints, weights=None, cooldown=30.0, sample_size=3):
self.endpoints = list(endpoints)
# PID 打乱(历史原因保留): 让各进程初始顺序不同; 现在 P-of-d 是随机取样, 影响不大但无害。
try:
random.Random(os.getpid()).shuffle(self.endpoints)
except Exception:
pass
weights = weights or {}
# 压测权重(QPS): 作为"处理能力", 强端点 score 更低 → 多分。无权重默认 1.0。
self.weights = {e: max(float(weights.get(e, 1.0)), 0.0001) for e in self.endpoints}
self.cooldown = cooldown
self.sample_size = max(2, int(sample_size)) # 每次候选抽样个数(P-of-d 的 d)
self._lock = threading.Lock()
self._inflight = {e: 0 for e in self.endpoints} # 当前在途请求数(本进程视角)
self._lat = {e: 0.05 for e in self.endpoints} # 延迟 EWMA(秒), 初始乐观→新端点也会被试
self._cooldown_until = {e: 0.0 for e in self.endpoints}
self._rng = random.Random(os.getpid() ^ 0x9E3779B1)
# —— 派发计数(验证分发是否均匀): 每端点累计派发数, 每 _dump_every 次落盘一次本进程文件 ——
self._count = {e: 0 for e in self.endpoints}
self._since_dump = 0
try:
self._dump_every = int((config.get_tencent_ocr_config() or {}).get('dispatch_log_every', 0) or 0)
except (ValueError, TypeError, AttributeError):
self._dump_every = 0
def _score(self, e):
"""负载分: (在途+1) × 延迟EWMA / 权重。越低越优先。"""
return (self._inflight[e] + 1) * self._lat[e] / self.weights[e]
def pick(self):
"""P-of-d: 随机不重复抽 sample_size(默认3) 个可用端点, 选负载分最低的;
选中即占一个在途名额(必须配对 done())。全部冷却时退化为全部可用。"""
now = time.time()
snapshot = None
with self._lock:
available = [e for e in self.endpoints if self._cooldown_until[e] <= now]
if not available:
available = list(self.endpoints) # 全挂了也得发, 给一次机会
if len(available) == 1:
chosen = available[0]
else:
k = min(self.sample_size, len(available))
cands = self._rng.sample(available, k) # 不重复抽样, 比各自 choice() 更有效(不会抽到同一个)
chosen = min(cands, key=self._score)
self._inflight[chosen] = self._inflight.get(chosen, 0) + 1
self._count[chosen] = self._count.get(chosen, 0) + 1
if self._dump_every > 0:
self._since_dump += 1
if self._since_dump >= self._dump_every:
self._since_dump = 0
snapshot = (dict(self._count),
{e: round(self._lat[e], 4) for e in self._lat})
if snapshot is not None: # 落盘在锁外, 不阻塞其它线程派发
self._write_dump(snapshot[0], snapshot[1])
return chosen
def _write_dump(self, counts, lats):
"""把本进程的累计派发计数写到 ./ocr_dispatch/<pid>.json(覆盖式, 单进程单文件无竞争)。"""
try:
d = "./ocr_dispatch"
os.makedirs(d, exist_ok=True)
path = os.path.join(d, f"{os.getpid()}.json")
data = {"pid": os.getpid(), "ts": time.time(),
"total": sum(counts.values()), "counts": counts, "lat_ewma": lats}
tmp = path + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False)
os.replace(tmp, path)
except Exception:
pass
def done(self, endpoint, latency=None, ok=True):
"""请求结束回调: 释放在途名额; 成功则更新延迟EWMA, 失败则冷却摘除(冷却时长带±30%抖动,
避免同一时刻失败的多个进程在同一时刻解冷却、一起冲回去打同一个刚失败的端点)。"""
with self._lock:
if self._inflight.get(endpoint, 0) > 0:
self._inflight[endpoint] -= 1
if endpoint not in self._cooldown_until:
return
if ok:
if latency is not None:
self._lat[endpoint] = 0.7 * self._lat[endpoint] + 0.3 * max(latency, 0.001)
self._cooldown_until[endpoint] = 0.0
else:
jitter = self._rng.uniform(0.7, 1.3)
self._cooldown_until[endpoint] = time.time() + self.cooldown * jitter
# —— 兼容旧接口(内部都走 done) ——
def report_failure(self, endpoint):
self.done(endpoint, ok=False)
def report_success(self, endpoint):
self.done(endpoint, ok=True)
_dispatcher = None
_dispatcher_lock = threading.Lock()
def get_dispatcher():
"""惰性单例分发器(首次调用时读取端点+权重)。"""
global _dispatcher
if _dispatcher is None:
with _dispatcher_lock:
if _dispatcher is None:
eps = get_ocr_endpoints()
_dispatcher = OcrDispatcher(eps, load_endpoint_weights())
return _dispatcher
def _build_payload(image_base64: str) -> dict:
timestamp = int(time.time() * 1000)
rand_suffix = random.randint(100000, 999999)
app_id = f"{timestamp}{rand_suffix}"
return {
"app_id": app_id,
"image": image_base64,
"ocr_template": "OCR",
"options": {"pdf_page_index": 0, "detect_text": True},
}
# 跨进程【全局 OCR 并发上限】信号量。进程池下由 main 在父进程创建、经 initializer 注入每个 worker;
# 不管多少进程×多少线程,全局同时在飞的 OCR 请求数被这个信号量硬性卡住,防止打爆端点。
# 为 None 时不限流(线程池模式 / 未注入)。
_ocr_semaphore = None
def set_ocr_semaphore(sem):
"""由 worker 进程初始化时调用,注入全局并发信号量。"""
global _ocr_semaphore
_ocr_semaphore = sem
# 每个进程一个持久 Session(连接池/keep-alive 复用连接)。
# 原来用 requests.post() 每次新建+关闭连接 → 高并发下海量 TIME_WAIT → 本地临时端口耗尽
# → "Cannot assign requested address" / 连接超时(表现成"所有端点超时", 其实是本机端口用完)。
# 复用连接后, 到每个端点只保持少量长连接, 端口不再churn。惰性创建 → 进程池下各进程独立 Session。
_session = None
_session_lock = threading.Lock()
def _get_session():
global _session
if _session is None:
with _session_lock:
if _session is None:
s = requests.Session()
adapter = requests.adapters.HTTPAdapter(
pool_connections=256, # 连接池数(≥端点数, 每端点一个池)
pool_maxsize=64, # 每端点最大并发连接(复用)
max_retries=0,
pool_block=False)
s.mount("http://", adapter)
s.mount("https://", adapter)
_session = s
return _session
def ocr_request_once(base_url: str, image_base64: str, timeout: int = 30) -> str:
"""向指定端点发一次 OCR 请求,返回原始响应文本;失败抛异常。
受全局信号量限流;用持久 Session 复用连接(防端口耗尽)。"""
url = base_url.rstrip("/") + OCR_PATH
headers = {"Content-Type": "application/json"}
sem = _ocr_semaphore
if sem is not None:
sem.acquire()
try:
resp = _get_session().post(url, headers=headers, json=_build_payload(image_base64),
timeout=timeout)
resp.raise_for_status()
return resp.text
finally:
if sem is not None:
sem.release()
def _normalize_youtu_to_tencent(raw: str) -> str:
"""将 Youtu OCR 返回格式转为项目其他模块期望的腾讯云 OCR 格式。"""
try:
data = json.loads(raw)
except (json.JSONDecodeError, TypeError):
return raw
# 已经是腾讯云格式则直接返回
if "TextDetections" in data:
return raw
if data.get("error_code") != 0:
return json.dumps({"TextDetections": [], "Language": "zh",
"RequestId": data.get("session_id", "")}, ensure_ascii=False)
text_detections = []
for recognize_item in data.get("recognize_list", []):
item_list = recognize_item.get("item_content", {}).get("item_list", [])
for item in item_list:
content = item.get("content", "")
confidence = int(item.get("conf", 0) * 100) # 0-1 → 0-100
vertex = item.get("vertex", {})
if vertex:
polygon = [
{"X": vertex.get("x1", 0), "Y": vertex.get("y1", 0)},
{"X": vertex.get("x2", 0), "Y": vertex.get("y2", 0)},
{"X": vertex.get("x3", 0), "Y": vertex.get("y3", 0)},
{"X": vertex.get("x4", 0), "Y": vertex.get("y4", 0)},
]
else:
coord = item.get("coord", {})
x, y = coord.get("x", 0), coord.get("y", 0)
w, h = coord.get("width", 0), coord.get("height", 0)
polygon = [
{"X": x, "Y": y},
{"X": x + w, "Y": y},
{"X": x + w, "Y": y + h},
{"X": x, "Y": y + h},
]
text_detections.append({
"DetectedText": content,
"Confidence": confidence,
"Polygon": polygon,
})
return json.dumps({
"TextDetections": text_detections,
"Language": "zh",
"RequestId": data.get("session_id", ""),
}, ensure_ascii=False)
def _get_ocr_call_params():
"""从 config 读【单次OCR的重试上限/超时】, 带默认。
封顶重试是为了防"卡1小时": 端点都超时时, 一张图最坏耗时 = max_attempts × timeout,
而不是原来的 len(endpoints)(119) × 30s ≈ 3570s。"""
try:
cfg = config.get_tencent_ocr_config() or {}
max_attempts = int(cfg.get("max_attempts", 4) or 4)
timeout = int(cfg.get("timeout", 12) or 12)
except (ValueError, TypeError, AttributeError):
max_attempts, timeout = 4, 12
return max(1, max_attempts), max(1, timeout)
def youtu_ocr_api(image_base64: str) -> str:
"""
调用内部 Youtu OCR 服务进行文字识别。
按 QPS 权重自动在多个 OCR IP 间分发;某端点失败时摘除冷却并换下一个端点重试。
重试次数封顶(config: ocr.max_attempts), 单次超时可配(ocr.timeout), 避免端点全超时时拖死 worker。
"""
dispatcher = get_dispatcher()
max_attempts, timeout = _get_ocr_call_params()
# 最多试 max_attempts 个端点(且不超过端点总数), 而不是把 119 个全试一遍
attempts = min(max(1, len(dispatcher.endpoints)), max_attempts)
last_err = None
for _ in range(attempts):
url = dispatcher.pick()
t0 = time.time()
try:
raw = ocr_request_once(url, image_base64, timeout=timeout)
# 把真实延迟反馈给分发器 → 慢端点延迟EWMA升高 → 后续自动少分(负载感知核心)
dispatcher.done(url, latency=time.time() - t0, ok=True)
return _normalize_youtu_to_tencent(raw)
except requests.exceptions.RequestException as e:
last_err = e
dispatcher.done(url, latency=time.time() - t0, ok=False)
continue
print(f"Youtu OCR 请求失败(已尝试 {attempts} 个端点, 单次超时 {timeout}s): {last_err}")
return ""
# 为了保持原函数名兼容,将新接口赋值给原名称
tencentcloud_ocr_api = youtu_ocr_api
def tencent_ocr_video_all_frames(pic_dir_path: str,
jsonl_save_path: str = 'frames_ocr.jsonl') -> list:
"""
对一个视频抽帧后的全部图片进行 ocr处理并保存起来
"""
with open(jsonl_save_path, "w", encoding='utf-8') as f:
for root, _, files in os.walk(pic_dir_path):
for pic_name in sorted(files):
if pic_name.endswith(('jpg')):
pic_path = os.path.join(root, pic_name)
img_base64 = img2base64(img_path=pic_path)
json_string = tencentcloud_ocr_api(image_base64=img_base64)
f.write("pic_path: {}\n".format(pic_path))
f.write(json_string + "\n")