372 lines
16 KiB
Python
372 lines
16 KiB
Python
# -*- 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") |