OCR 500: GPU 回退 CPU、返回详细错误、增加 test-ocr 诊断。
This commit is contained in:
@@ -177,7 +177,8 @@ def _process_wrong_question(question_id: uuid.UUID):
|
|||||||
if "libGL" in str(exc):
|
if "libGL" in str(exc):
|
||||||
msg += " 请在服务器执行: sudo bash deploy/install-ocr-deps.sh && systemctl restart grade-archive"
|
msg += " 请在服务器执行: sudo bash deploy/install-ocr-deps.sh && systemctl restart grade-archive"
|
||||||
elif ocr_url:
|
elif ocr_url:
|
||||||
msg += f" 请检查 OCR 服务是否可达: {ocr_url} (可浏览器访问 {ocr_url.rstrip('/')}/health)"
|
if "OCR 服务" not in msg:
|
||||||
|
msg += " 诊断: bash deploy/ocr-screen.sh status && bash deploy/ocr-worker/test-ocr.sh"
|
||||||
wq.error_message = msg
|
wq.error_message = msg
|
||||||
db.commit()
|
db.commit()
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -130,7 +130,17 @@ def _run_remote_ocr(service_url: str, image_path: str) -> dict:
|
|||||||
files = {"file": (Path(image_path).name, handle, "image/jpeg")}
|
files = {"file": (Path(image_path).name, handle, "image/jpeg")}
|
||||||
with httpx.Client(timeout=settings.OCR_TIMEOUT_SECONDS) as client:
|
with httpx.Client(timeout=settings.OCR_TIMEOUT_SECONDS) as client:
|
||||||
resp = client.post(url, files=files, headers=headers)
|
resp = client.post(url, files=files, headers=headers)
|
||||||
resp.raise_for_status()
|
if resp.status_code >= 400:
|
||||||
|
detail = resp.text
|
||||||
|
try:
|
||||||
|
body = resp.json()
|
||||||
|
if isinstance(body.get("detail"), str):
|
||||||
|
detail = body["detail"]
|
||||||
|
elif isinstance(body.get("detail"), list):
|
||||||
|
detail = str(body["detail"])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
raise RuntimeError(f"OCR 服务 {resp.status_code}: {detail}")
|
||||||
return resp.json()
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -210,6 +210,9 @@ setup_ocr_gpu() {
|
|||||||
else
|
else
|
||||||
log_warn "未检测到 NVIDIA GPU,OCR 将使用 CPU(较慢)"
|
log_warn "未检测到 NVIDIA GPU,OCR 将使用 CPU(较慢)"
|
||||||
fi
|
fi
|
||||||
|
if [[ -x "${INSTALL_DIR}/deploy/install-ocr-deps.sh" ]]; then
|
||||||
|
bash "${INSTALL_DIR}/deploy/install-ocr-deps.sh" || log_warn "OCR 系统库安装跳过"
|
||||||
|
fi
|
||||||
install_ocr_worker
|
install_ocr_worker
|
||||||
start_ocr_screen
|
start_ocr_screen
|
||||||
wait_ocr_healthy 30 || log_warn "OCR 后台加载中,继续安装主程序…"
|
wait_ocr_healthy 30 || log_warn "OCR 后台加载中,继续安装主程序…"
|
||||||
|
|||||||
+75
-22
@@ -1,7 +1,9 @@
|
|||||||
"""局域网 OCR 服务:在带 NVIDIA 显卡的机器上运行,供成绩档案系统调用。"""
|
"""局域网 OCR 服务:在带 NVIDIA 显卡的机器上运行,供成绩档案系统调用。"""
|
||||||
|
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
|
from io import BytesIO
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import FastAPI, File, Header, HTTPException, UploadFile
|
from fastapi import FastAPI, File, Header, HTTPException, UploadFile
|
||||||
@@ -9,12 +11,16 @@ from PIL import Image
|
|||||||
|
|
||||||
os.environ.setdefault("OPENCV_IO_ENABLE_OPENEXR", "0")
|
os.environ.setdefault("OPENCV_IO_ENABLE_OPENEXR", "0")
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
||||||
|
logger = logging.getLogger("ocr-worker")
|
||||||
|
|
||||||
OCR_MAX_SIDE = int(os.getenv("OCR_MAX_SIDE", "1280"))
|
OCR_MAX_SIDE = int(os.getenv("OCR_MAX_SIDE", "1280"))
|
||||||
OCR_API_KEY = os.getenv("OCR_API_KEY", "").strip()
|
OCR_API_KEY = os.getenv("OCR_API_KEY", "").strip()
|
||||||
OCR_USE_GPU = os.getenv("OCR_USE_GPU", "true").lower() in {"1", "true", "yes"}
|
OCR_USE_GPU = os.getenv("OCR_USE_GPU", "true").lower() in {"1", "true", "yes"}
|
||||||
|
|
||||||
app = FastAPI(title="Grade Archive OCR Worker", version="1.0.0")
|
app = FastAPI(title="Grade Archive OCR Worker", version="1.0.0")
|
||||||
_engine = None
|
_engine = None
|
||||||
|
_engine_mode = "none"
|
||||||
|
|
||||||
|
|
||||||
def _check_key(key: str | None) -> None:
|
def _check_key(key: str | None) -> None:
|
||||||
@@ -22,21 +28,53 @@ def _check_key(key: str | None) -> None:
|
|||||||
raise HTTPException(status_code=401, detail="Invalid OCR API key")
|
raise HTTPException(status_code=401, detail="Invalid OCR API key")
|
||||||
|
|
||||||
|
|
||||||
def get_engine():
|
def _create_engine(use_gpu: bool):
|
||||||
global _engine
|
from paddleocr import PaddleOCR
|
||||||
if _engine is None:
|
|
||||||
from paddleocr import PaddleOCR
|
|
||||||
|
|
||||||
_engine = PaddleOCR(
|
return PaddleOCR(
|
||||||
use_angle_cls=False,
|
use_angle_cls=False,
|
||||||
lang="ch",
|
lang="ch",
|
||||||
show_log=False,
|
show_log=False,
|
||||||
use_gpu=OCR_USE_GPU,
|
use_gpu=use_gpu,
|
||||||
enable_mkldnn=not OCR_USE_GPU,
|
enable_mkldnn=not use_gpu,
|
||||||
det_limit_side_len=min(OCR_MAX_SIDE, 1280),
|
det_limit_side_len=min(OCR_MAX_SIDE, 1280),
|
||||||
rec_batch_num=8,
|
rec_batch_num=8,
|
||||||
)
|
)
|
||||||
return _engine
|
|
||||||
|
|
||||||
|
def get_engine(force_cpu: bool = False):
|
||||||
|
global _engine, _engine_mode
|
||||||
|
if _engine is not None and not force_cpu:
|
||||||
|
return _engine
|
||||||
|
|
||||||
|
modes: list[bool] = [False] if force_cpu or not OCR_USE_GPU else [True, False]
|
||||||
|
last_err: Exception | None = None
|
||||||
|
for use_gpu in modes:
|
||||||
|
try:
|
||||||
|
logger.info("Loading PaddleOCR use_gpu=%s", use_gpu)
|
||||||
|
_engine = _create_engine(use_gpu)
|
||||||
|
_engine_mode = "gpu" if use_gpu else "cpu"
|
||||||
|
logger.info("PaddleOCR ready mode=%s", _engine_mode)
|
||||||
|
return _engine
|
||||||
|
except Exception as exc:
|
||||||
|
last_err = exc
|
||||||
|
logger.warning("PaddleOCR init failed use_gpu=%s: %s", use_gpu, exc)
|
||||||
|
_engine = None
|
||||||
|
_engine_mode = "none"
|
||||||
|
|
||||||
|
hint = ""
|
||||||
|
err_text = str(last_err or "")
|
||||||
|
if "libGL" in err_text:
|
||||||
|
hint = " 请执行: sudo bash deploy/install-ocr-deps.sh 后重启 OCR"
|
||||||
|
elif any(x in err_text.lower() for x in ("cuda", "cudnn", "gpu", "out of memory")):
|
||||||
|
hint = " 显存不足或 CUDA 异常,可设置 OCR_USE_GPU=false 用 CPU"
|
||||||
|
raise RuntimeError(f"PaddleOCR 初始化失败: {last_err}{hint}") from last_err
|
||||||
|
|
||||||
|
|
||||||
|
def _reset_engine():
|
||||||
|
global _engine, _engine_mode
|
||||||
|
_engine = None
|
||||||
|
_engine_mode = "none"
|
||||||
|
|
||||||
|
|
||||||
def _bbox_from_box(box: list) -> list[float]:
|
def _bbox_from_box(box: list) -> list[float]:
|
||||||
@@ -50,12 +88,12 @@ def _scale_box(box: list, scale_x: float, scale_y: float) -> list:
|
|||||||
|
|
||||||
|
|
||||||
def _prepare_image_bytes(content: bytes) -> tuple[bytes, float, float, int, int]:
|
def _prepare_image_bytes(content: bytes) -> tuple[bytes, float, float, int, int]:
|
||||||
with Image.open(__import__("io").BytesIO(content)) as img:
|
with Image.open(BytesIO(content)) as img:
|
||||||
img = img.convert("RGB")
|
img = img.convert("RGB")
|
||||||
orig_w, orig_h = img.size
|
orig_w, orig_h = img.size
|
||||||
longest = max(orig_w, orig_h)
|
longest = max(orig_w, orig_h)
|
||||||
if longest <= OCR_MAX_SIDE:
|
if longest <= OCR_MAX_SIDE:
|
||||||
buf = __import__("io").BytesIO()
|
buf = BytesIO()
|
||||||
img.save(buf, format="JPEG", quality=88)
|
img.save(buf, format="JPEG", quality=88)
|
||||||
return buf.getvalue(), 1.0, 1.0, orig_w, orig_h
|
return buf.getvalue(), 1.0, 1.0, orig_w, orig_h
|
||||||
|
|
||||||
@@ -63,14 +101,14 @@ def _prepare_image_bytes(content: bytes) -> tuple[bytes, float, float, int, int]
|
|||||||
new_w = max(1, int(orig_w * ratio))
|
new_w = max(1, int(orig_w * ratio))
|
||||||
new_h = max(1, int(orig_h * ratio))
|
new_h = max(1, int(orig_h * ratio))
|
||||||
resized = img.resize((new_w, new_h), Image.Resampling.LANCZOS)
|
resized = img.resize((new_w, new_h), Image.Resampling.LANCZOS)
|
||||||
buf = __import__("io").BytesIO()
|
buf = BytesIO()
|
||||||
resized.save(buf, format="JPEG", quality=85)
|
resized.save(buf, format="JPEG", quality=85)
|
||||||
scale_x = orig_w / new_w
|
scale_x = orig_w / new_w
|
||||||
scale_y = orig_h / new_h
|
scale_y = orig_h / new_h
|
||||||
return buf.getvalue(), scale_x, scale_y, orig_w, orig_h
|
return buf.getvalue(), scale_x, scale_y, orig_w, orig_h
|
||||||
|
|
||||||
|
|
||||||
def run_ocr_on_bytes(content: bytes) -> dict:
|
def _run_ocr_impl(content: bytes) -> dict:
|
||||||
engine = get_engine()
|
engine = get_engine()
|
||||||
image_bytes, scale_x, scale_y, orig_w, orig_h = _prepare_image_bytes(content)
|
image_bytes, scale_x, scale_y, orig_w, orig_h = _prepare_image_bytes(content)
|
||||||
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as tmp:
|
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as tmp:
|
||||||
@@ -107,15 +145,29 @@ def run_ocr_on_bytes(content: bytes) -> dict:
|
|||||||
"lines": lines,
|
"lines": lines,
|
||||||
"width": orig_w,
|
"width": orig_w,
|
||||||
"height": orig_h,
|
"height": orig_h,
|
||||||
|
"engine_mode": _engine_mode,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def run_ocr_on_bytes(content: bytes) -> dict:
|
||||||
|
try:
|
||||||
|
return _run_ocr_impl(content)
|
||||||
|
except Exception as exc:
|
||||||
|
err = str(exc).lower()
|
||||||
|
gpu_fail = _engine_mode == "gpu" and any(
|
||||||
|
x in err for x in ("cuda", "cudnn", "gpu", "out of memory", "resource exhausted", "precondition")
|
||||||
|
)
|
||||||
|
if gpu_fail and OCR_USE_GPU:
|
||||||
|
logger.warning("GPU OCR runtime failed, retry CPU: %s", exc)
|
||||||
|
_reset_engine()
|
||||||
|
get_engine(force_cpu=True)
|
||||||
|
return _run_ocr_impl(content)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
def health():
|
def health():
|
||||||
return {"status": "ok", "gpu": OCR_USE_GPU}
|
return {"status": "ok", "gpu_requested": OCR_USE_GPU, "engine_mode": _engine_mode}
|
||||||
|
|
||||||
|
|
||||||
# 首次 /api/ocr/regions 请求时再加载模型(/health 立即响应,避免安装脚本长时间等待)
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/ocr/regions")
|
@app.post("/api/ocr/regions")
|
||||||
@@ -130,4 +182,5 @@ async def ocr_regions(
|
|||||||
try:
|
try:
|
||||||
return run_ocr_on_bytes(content)
|
return run_ocr_on_bytes(content)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
logger.exception("OCR failed")
|
||||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
PORT="${OCR_PORT:-23567}"
|
||||||
|
TMP="/tmp/ocr-test-$$.jpg"
|
||||||
|
|
||||||
|
if [[ ! -d "${ROOT}/.venv" ]]; then
|
||||||
|
echo "请先 bash install.sh"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
source "${ROOT}/.venv/bin/activate"
|
||||||
|
python3 -c "from PIL import Image; Image.new('RGB',(200,80),(255,255,255)).save('${TMP}')"
|
||||||
|
|
||||||
|
echo "==> GET /health"
|
||||||
|
curl -sS "http://127.0.0.1:${PORT}/health" || { echo "FAIL: OCR 未启动"; exit 1; }
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "==> POST /api/ocr/regions"
|
||||||
|
curl -sS -w "\nHTTP %{http_code}\n" -F "file=@${TMP};type=image/jpeg" \
|
||||||
|
"http://127.0.0.1:${PORT}/api/ocr/regions"
|
||||||
|
rm -f "${TMP}"
|
||||||
Reference in New Issue
Block a user