# -*- coding: utf-8 -*- """ 压测每个 OCR 端点(IP)的实际 QPS,并把结果写成权重文件 config/ocr_weights.json, 供运行时按 QPS 加权分发请求(见 tool/Ocr/tencentcloud_ocr.py 的 OcrDispatcher)。 做法:对每个端点用固定并发、固定请求数发同一张样例图, QPS = 成功请求数 / 墙钟耗时;同时报告平均时延和错误率。 本脚本完全自包含:单文件,不读 config、不 import 项目任何模块,拷过去就能跑。 用法: python bench_ocr_endpoints.py [样例图片] [--requests N] [--concurrency C] [--no-write] python bench_ocr_endpoints.py [样例图片] --url http://IP:PORT [--url http://IP2:PORT ...] 样例图片不给时,自动在 ./composite、./split 下找一张 .jpg。 端点:默认内置 Polaris 地址;要测别的 IP 用 --url(可多次)。 --requests 每个端点发的总请求数(默认 60) --concurrency 每个端点的并发线程数(默认 10) --no-write 只打印,不写 ocr_weights.json """ import argparse import base64 import glob import itertools import json import os import random import threading import time from concurrent.futures import ThreadPoolExecutor, as_completed import requests OCR_PATH = "/youtu/ocrapi/hpgeneralocr_v3" OCR_PORT = 18081 # 内置的多台 OCR 服务 IP(自动负载均衡测试用) DEFAULT_IPS = [ "30.154.78.83" ] DEFAULT_ENDPOINTS = [f"http://{ip}:{OCR_PORT}" for ip in DEFAULT_IPS] WEIGHTS_FILE = "./ocr_weights.json" def get_endpoints(cli_urls): """命令行 --url 优先,否则用内置默认端点。""" if cli_urls: return [u.rstrip("/") for u in cli_urls] return list(DEFAULT_ENDPOINTS) def find_sample_image(given): if given: if os.path.exists(given): return given print(f"指定的图片不存在: {given}") return None for root in ("./composite", "./split"): hits = glob.glob(os.path.join(root, "**", "*.jpg"), recursive=True) if hits: return hits[0] return None def img2base64(img_path): with open(img_path, "rb") as f: return base64.b64encode(f.read()).decode() def ocr_request_once(base_url, image_b64, timeout=60): """向指定端点发一次 OCR 请求,返回原始响应文本;失败抛异常。""" url = base_url.rstrip("/") + OCR_PATH app_id = f"{int(time.time() * 1000)}{random.randint(100000, 999999)}" payload = { "app_id": app_id, "image": image_b64, "ocr_template": "OCR", "options": {"pdf_page_index": 0, "detect_text": True}, } resp = requests.post(url, headers={"Content-Type": "application/json"}, json=payload, timeout=timeout) resp.raise_for_status() return resp.text def _ok(raw): """判断一次响应是否算成功:有内容且不是 error。""" if not raw: return False try: data = json.loads(raw) except (json.JSONDecodeError, TypeError): return False if "TextDetections" in data: return True return data.get("error_code", 0) == 0 def bench_endpoint(base_url, image_b64, requests_n, concurrency): """对单个端点压测,返回统计 dict(含一个失败原因采样 sample_err)。""" latencies = [] ok = 0 err = 0 sample_err = [None] # 采样第一个失败原因 def one_call(): t0 = time.time() try: raw = ocr_request_once(base_url, image_b64, timeout=60) dt = time.time() - t0 if _ok(raw): return (True, dt) # HTTP 200 但内容是错误:采样响应体片段 if sample_err[0] is None: sample_err[0] = f"响应非成功: {raw[:200]}" return (False, dt) except Exception as e: if sample_err[0] is None: sample_err[0] = f"{type(e).__name__}: {e}" return (False, time.time() - t0) t_start = time.time() with ThreadPoolExecutor(max_workers=concurrency) as ex: futures = [ex.submit(one_call) for _ in range(requests_n)] for fut in as_completed(futures): success, dt = fut.result() latencies.append(dt) if success: ok += 1 else: err += 1 wall = time.time() - t_start return { "qps": ok / wall if wall > 0 else 0.0, "ok": ok, "err": err, "wall": wall, "avg_latency": sum(latencies) / len(latencies) if latencies else 0.0, "err_rate": err / requests_n if requests_n else 0.0, "sample_err": sample_err[0], } def soak_endpoint(base_url, image_pool, concurrency, report_interval=5): """对单个端点持续满速压测:固定 concurrency 个线程不停发请求, 每次从 image_pool 随机抽一张图(避免被服务端缓存),每 report_interval 秒打印滚动 QPS, 直到 Ctrl+C。image_pool 可以是单张或几千张。""" stop = threading.Event() lock = threading.Lock() st = {"ok": 0, "err": 0, "lat": 0.0, "n": 0} sample_err = [None] def worker(): while not stop.is_set(): img = random.choice(image_pool) # 每次随机抽一张 t0 = time.time() try: raw = ocr_request_once(base_url, img, timeout=60) success = _ok(raw) if not success and sample_err[0] is None: sample_err[0] = f"响应非成功: {raw[:160]}" except Exception as e: success = False if sample_err[0] is None: sample_err[0] = f"{type(e).__name__}: {e}" dt = time.time() - t0 with lock: st["n"] += 1 st["lat"] += dt if success: st["ok"] += 1 else: st["err"] += 1 threads = [threading.Thread(target=worker, daemon=True) for _ in range(concurrency)] for t in threads: t.start() print(f"持续满速压测 {base_url} 并发={concurrency} 每{report_interval}s 报一次 (Ctrl+C 停止)") print(f"{'用时s':>6} {'累计OK':>9} {'瞬时QPS':>9} {'平均QPS':>9} {'时延ms':>8} {'错误率':>7}") print("-" * 56) t_start = time.time() last_ok, last_t = 0, t_start try: while True: time.sleep(report_interval) now = time.time() with lock: ok, err, n, lat = st["ok"], st["err"], st["n"], st["lat"] inst = (ok - last_ok) / (now - last_t) if now > last_t else 0 avg = ok / (now - t_start) if now > t_start else 0 avg_lat = (lat / n * 1000) if n else 0 er = err / n * 100 if n else 0 print(f"{now-t_start:6.0f} {ok:>9} {inst:>9.1f} {avg:>9.1f} {avg_lat:>8.0f} {er:>6.0f}%", flush=True) last_ok, last_t = ok, now except KeyboardInterrupt: stop.set() el = time.time() - t_start with lock: ok, n = st["ok"], st["n"] print("-" * 56) print(f"停止。{el:.0f}s 内成功 {ok} / 共发 {n},平均 {ok/el if el else 0:.1f} QPS") if sample_err[0]: print(f"失败采样: {sample_err[0]}") def health_check(endpoints, image_b64): """给每个端点发一次请求,返回 (存活列表, 死亡列表)。""" alive, dead = [], [] def _check(ep): try: return ep, _ok(ocr_request_once(ep, image_b64, timeout=15)) except Exception: return ep, False with ThreadPoolExecutor(max_workers=min(32, len(endpoints))) as ex: for ep, ok in ex.map(_check, endpoints): (alive if ok else dead).append(ep) return alive, dead def bench_aggregate(endpoints, image_b64, requests_n, concurrency): """把请求轮询分发到所有端点(模拟运行时自动负载均衡),测合并 QPS。""" rr = itertools.cycle(endpoints) rr_lock = threading.Lock() latencies = [] ok = 0 err = 0 stat_lock = threading.Lock() def next_ep(): with rr_lock: return next(rr) def one_call(_): ep = next_ep() t0 = time.time() try: raw = ocr_request_once(ep, image_b64, timeout=60) success = _ok(raw) except Exception: success = False dt = time.time() - t0 with stat_lock: latencies.append(dt) return success t_start = time.time() with ThreadPoolExecutor(max_workers=concurrency) as ex: for success in ex.map(one_call, range(requests_n)): if success: ok += 1 else: err += 1 wall = time.time() - t_start return { "qps": ok / wall if wall > 0 else 0.0, "ok": ok, "err": err, "wall": wall, "avg_latency": sum(latencies) / len(latencies) if latencies else 0.0, "err_rate": err / requests_n if requests_n else 0.0, } def run_aggregate(endpoints, image_b64, requests_n, concurrency): """先健康检查筛掉死端点,再在存活端点上做聚合负载测试。""" print(f"健康检查 {len(endpoints)} 个端点 ...", flush=True) alive, dead = health_check(endpoints, image_b64) print(f" 存活: {len(alive)} 死亡: {len(dead)}") if dead: for ep in dead: print(f" ✗ {ep}") if not alive: print("没有存活端点,无法测试。") return print("=" * 64) print(f"聚合负载测试: {len(alive)} 个存活端点轮询分发," f"总请求 {requests_n},总并发 {concurrency}") s = bench_aggregate(alive, image_b64, requests_n, concurrency) print("-" * 64) print(f" 合并 QPS = {s['qps']:.2f} 成功={s['ok']} 失败={s['err']}" f" 平均时延={s['avg_latency']*1000:.0f}ms 错误率={s['err_rate']*100:.0f}%") if alive: print(f" 单端点平均 ≈ {s['qps']/len(alive):.2f} QPS" f" ({len(alive)} 台合计 {s['qps']:.0f} QPS)") print("=" * 64) def sweep_endpoint(base_url, image_b64, start, max_c, per_worker): """自动加压找极限 QPS:并发从 start 逐级翻倍到 max_c, QPS 不再随并发上升(或错误率升高)即判定到极限,停止。""" print(f"自动加压 {base_url} (并发 {start}→{max_c} 逐级翻倍)") print(f"{'并发':>6} {'QPS':>9} {'时延ms':>9} {'错误率':>8}") print("-" * 36) best_qps, best_c = 0.0, 0 prev_qps = 0.0 c = start while c <= max_c: reqs = max(c * per_worker, 30) # 每个并发至少发 per_worker 次,样本够稳 s = bench_endpoint(base_url, image_b64, reqs, c) print(f"{c:>6} {s['qps']:>9.2f} {s['avg_latency']*1000:>9.0f} {s['err_rate']*100:>7.0f}%") if s["qps"] > best_qps: best_qps, best_c = s["qps"], c if s["err_rate"] > 0.1: print(" → 错误率升高,已超过服务承载,停止。") break if prev_qps > 0 and s["qps"] < prev_qps * 1.08: print(" → QPS 不再随并发上升,已到极限,停止。") break prev_qps = s["qps"] c *= 2 print("-" * 36) print(f"极限 QPS ≈ {best_qps:.2f} (最佳并发 {best_c})") return best_qps, best_c def gen_random_images(n, size=(1280, 200)): """生成 n 张带随机文字的图片(让 OCR 真干活),返回 base64 列表。需要 Pillow。""" try: import io import string from PIL import Image, ImageDraw except ImportError: print("生成随机图需要 Pillow(pip install pillow);改用 --pool 指向真实图目录") raise chars = string.ascii_letters + string.digits + " ,.!?-" pool = [] print(f"生成 {n} 张随机文字图...", flush=True) for i in range(n): bg = random.randint(200, 255) img = Image.new("RGB", size, (bg, bg, bg)) d = ImageDraw.Draw(img) for row in range(random.randint(1, 3)): text = "".join(random.choice(chars) for _ in range(random.randint(10, 40))) d.text((20, 20 + row * 50), text, fill=(0, 0, 0)) buf = io.BytesIO() img.save(buf, format="JPEG", quality=85) pool.append(base64.b64encode(buf.getvalue()).decode()) if (i + 1) % 1000 == 0: print(f" 已生成 {i+1}/{n}", flush=True) return pool def build_image_pool(args): """构建测试图池(base64 列表): --pool DIR 用真实图目录(最贴近实际负载) > --gen N 随机生成 > 单张样例图。""" if getattr(args, "pool", None): files = (glob.glob(os.path.join(args.pool, "**", "*.jpg"), recursive=True) + glob.glob(os.path.join(args.pool, "**", "*.png"), recursive=True)) if not files: print(f"目录 {args.pool} 下没有图片") return [] random.shuffle(files) cap = args.gen if args.gen else 5000 files = files[:cap] print(f"从 {args.pool} 取 {len(files)} 张真实图作为图池") return [img2base64(f) for f in files] if getattr(args, "gen", 0): return gen_random_images(args.gen) sample = find_sample_image(args.image) if not sample: return [] print(f"单张样例图: {sample}") return [img2base64(sample)] def main(): ap = argparse.ArgumentParser() ap.add_argument("image", nargs="?", default=None) ap.add_argument("--url", action="append", default=None, help="OCR 端点(可多次)") ap.add_argument("--requests", type=int, default=60) ap.add_argument("--concurrency", type=int, default=10) ap.add_argument("--aggregate", action="store_true", help="聚合负载测试:请求轮询分发到所有存活端点,测合并总 QPS") ap.add_argument("--sweep", action="store_true", help="自动加压找极限 QPS(并发逐级翻倍直到 QPS 不再上升)") ap.add_argument("--soak", action="store_true", help="单端点持续满速压测(固定并发不停发,实时滚动QPS,Ctrl+C 停)") ap.add_argument("--interval", type=int, default=5, help="soak 模式打印间隔秒") ap.add_argument("--gen", type=int, default=0, help="随机生成 N 张带文字的图作为图池(每次请求随机抽,避免被缓存)") ap.add_argument("--pool", default=None, help="用该目录下的真实图作为图池(最贴近实际负载,如 ./split/某视频)") ap.add_argument("--start-concurrency", type=int, default=10, help="sweep 起始并发") ap.add_argument("--max-concurrency", type=int, default=320, help="sweep 最大并发") ap.add_argument("--per-worker", type=int, default=6, help="sweep 每档每并发发多少次请求") ap.add_argument("--no-write", action="store_true") args = ap.parse_args() endpoints = get_endpoints(args.url) if not endpoints: print("没有可用的 OCR 端点") return # 构建图池:--pool 真实图目录 / --gen 随机生成 / 默认单张样例 image_pool = build_image_pool(args) if not image_pool: print("没有可用图片:给一张 jpg 路径、或用 --gen N / --pool 目录。") return print(f"图池大小: {len(image_pool)} 张 | 端点数: {len(endpoints)} 并发: {args.concurrency}") print("=" * 64) image_b64 = image_pool[0] # 非 soak 模式用单张(测纯吞吐) # ---- soak 模式:单端点持续满速压测,每次随机抽图,直到 Ctrl+C ---- if args.soak: soak_endpoint(endpoints[0], image_pool, args.concurrency, args.interval) return # ---- aggregate 模式:所有端点轮询分发,测合并总 QPS ---- if args.aggregate: run_aggregate(endpoints, image_b64, args.requests, args.concurrency) return # ---- sweep 模式:自动加压找极限,不写权重文件 ---- if args.sweep: for i, ep in enumerate(endpoints, 1): print(f"\n[{i}/{len(endpoints)}]", end=" ") sweep_endpoint(ep, image_b64, args.start_concurrency, args.max_concurrency, args.per_worker) return results = {} for i, ep in enumerate(endpoints, 1): print(f"[{i}/{len(endpoints)}] 压测 {ep} ...", flush=True) stat = bench_endpoint(ep, image_b64, args.requests, args.concurrency) results[ep] = stat print(f" QPS={stat['qps']:.2f} 成功={stat['ok']} 失败={stat['err']}" f" 平均时延={stat['avg_latency']*1000:.0f}ms" f" 错误率={stat['err_rate']*100:.0f}%") if stat["err"] and stat.get("sample_err"): print(f" 失败原因(采样): {stat['sample_err']}") print("=" * 64) print("汇总(按 QPS 降序):") for ep, s in sorted(results.items(), key=lambda kv: kv[1]["qps"], reverse=True): print(f" {ep:48s} QPS={s['qps']:6.2f} 时延={s['avg_latency']*1000:5.0f}ms" f" 错误率={s['err_rate']*100:.0f}%") total_qps = sum(s["qps"] for s in results.values()) print(f"\n合计可用 QPS ≈ {total_qps:.2f}") # 写权重文件:QPS 即权重;错误率过高(>50%)的端点权重清零(不参与分发) weights = {ep: (round(s["qps"], 3) if s["err_rate"] <= 0.5 else 0.0) for ep, s in results.items()} if args.no_write: print("\n--no-write:不写权重文件。") return os.makedirs(os.path.dirname(WEIGHTS_FILE), exist_ok=True) with open(WEIGHTS_FILE, "w", encoding="utf-8") as f: json.dump(weights, f, ensure_ascii=False, indent=2) print(f"\n已写入权重文件: {WEIGHTS_FILE}") print("运行时会按此权重把 OCR 请求加权分发到各端点(重启进程或新批次生效)。") if __name__ == "__main__": main()