首次提交

This commit is contained in:
2026-09-28 16:09:31 +08:00
commit 4a6839319a
71 changed files with 12144 additions and 0 deletions

View File

@@ -0,0 +1,372 @@
# -*- 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")