This commit is contained in:
gr
2026-10-23 14:08:44 +08:00
28 changed files with 3310 additions and 66 deletions
@@ -0,0 +1,59 @@
# 本地图片文字核对
本服务给现有 Dify 图片请求补充 OCR 原文、置信度和来源位置。原始图片仍提交给同一模型;OCR 不作诊断,也不将自己的输出标为已经核实的事实。原图与文字读数冲突时,提示模型并列标明待核实,不能擅自选择数值。
## 运行环境
使用独立 Python 3.11 环境,不在桌面客户端的 `app/.venv` 内安装依赖。安装固定版本:
```powershell
python -m venv <本机专用OCR环境路径>
& <本机专用OCR环境路径>/Scripts/python.exe -m pip install -r server/scripts/clinical_image_ocr.requirements.txt
& <本机专用OCR环境路径>/Scripts/python.exe -m pip check
```
依赖从官方 PyPI 安装。运行时使用 RapidOCR 包内的固定本地模型,不自动下载模型,不访问网络。PHP 必须允许 `proc_open`,并能运行配置的解释器。缺少依赖、模型、权限或可用时间时,辅助识别明确不可用,保留原图请求。
在后端 `.env` 的 `[prescription_ai]` 段配置以下非敏感项:
```ini
LOCAL_OCR_ENABLED = true
LOCAL_OCR_PYTHON = <解释器绝对路径>
LOCAL_OCR_TIMEOUT = 15
```
默认未启用,不能只部署 PHP 文件就宣称 OCR 已生效。不要将开发机解释器路径复制到其他主机。本次本地测试已在独立环境安装并启用,不涉及正式后端部署。
## 资源、取消和真实性
- 单批最多使用配置的 15 秒;整次分批请求累计最多约 30 秒(按实际耗时扣减),并为原图模型请求预留 8 秒和进程清理 0.8 秒。时间不足时跳过辅助识别,不重新获得整单超时预算。
- OCR 原文通过请求私有文件描述符传递,清理后不保留。患者内容、源地址、令牌不放在进程参数或日志中。CPU 线程受限;文件字节、像素、尺寸、行数、输出量有界。此机制不是操作系统级硬内存配额。
- 取消后终止本地子进程,取消成立时不继续调用模型。上游停止接口的“接受停止”与“已经停止运行”须区分。
- 客户端仅收到安全状态:`processed` 是“已附带文字供模型核对”,不是“数字必然正确”;`unreadable`/`unavailable` 不阻断原图分析;`withheld` 表示可选 OCR 文本超出本轮容量而未附加。
- 超过模型单次附件上限时,逐批提取观察,再带完整原任务综合。每个原始来源使用稳定序号;缺失、不可判读、未返回唯一观察或综合容量不足,必须明确列入缺口。不能把附件“已上传”当作内容“已读取并综合”。
- 新增证据的字节预算是应用侧保护,不是上游 token 容量承诺。任意数量、任意长度素材都完整综合的能力不作保证。
## 验证
PHP 离线测试分别执行,避免命名空间桩互相污染:
```powershell
php server/tests/ClinicalImageTextServiceTest.php
php server/tests/DifyOcrBudgetTest.php
php server/tests/DifyImageTextEvidenceTest.php
php server/tests/DifyLargeAttachmentContractTest.php
```
Python worker 测试需要 Pillow,使用上面的 OCR 环境或另一个安装了 Pillow 的测试环境,不能使用未安装这些依赖的桌面应用环境:
```powershell
& <含Pillow的测试解释器> -m unittest discover -s server/tests -p test_clinical_image_ocr.py -v
```
显式本地进程测试只使用预先生成的合成 PNG,检查实际子进程、取消、超时与清理;不读取病例:
```powershell
php server/tests/ClinicalImageTextBridgeProcessTest.php <OCR解释器绝对路径> <合成PNG绝对路径>
```
模型内容验收须另外核对最终正文的数值、小数点、正负号、单位与原始来源。不能用上传成功、HTTP 200 或 OCR `processed` 单独代替内容正确性验收。
+239
View File
@@ -0,0 +1,239 @@
"""Offline, request-private OCR worker. Paths and text arrive only on stdin.
Never fetch a model or URL. The PHP owner enforces the hard process deadline;
checkpoints keep already completed sources available when native inference stops.
"""
from __future__ import annotations
import contextlib
import importlib.metadata
import importlib.util
import json
import logging
import math
import os
import socket
import stat
import sys
import time
import warnings
from pathlib import Path
MAX_BYTES = 20 * 1024 * 1024
MAX_PIXELS = 40_000_000
MAX_OUTPUT = 512 * 1024
MAX_INPUT = 1024 * 1024
MODEL_NAMES = {
"Det": "PP-OCRv6_det_small.onnx",
"Cls": "ch_ppocr_mobile_v2.0_cls_mobile.onnx",
"Rec": "PP-OCRv6_rec_small.onnx",
}
class Unavailable(Exception):
"""A fixed, non-sensitive failure code."""
def deny_network(*_args, **_kwargs):
raise OSError("network_disabled")
def validated_request(request):
if not isinstance(request, dict):
raise Unavailable("invalid_request")
root_value = request.get("allowed_root")
if not isinstance(root_value, str) or not root_value or "\0" in root_value or root_value.startswith(("//", "\\\\")) or not Path(root_value).is_absolute():
raise Unavailable("invalid_request")
root = Path(root_value).resolve(strict=True)
images = request.get("images")
if not root.is_dir() or not isinstance(images, list) or len(images) > 256:
raise Unavailable("invalid_request")
seen, rows = set(), []
for item in images:
if not isinstance(item, dict) or type(item.get("index")) is not int or not 0 < item["index"] <= 1_000_000_000:
raise Unavailable("invalid_request")
if item["index"] in seen:
raise Unavailable("invalid_request")
seen.add(item["index"])
value = item.get("path")
if not isinstance(value, str) or not value or "\0" in value or "://" in value or value.startswith(("//", "\\\\")) or not Path(value).is_absolute():
raise Unavailable("invalid_source")
path = Path(value).resolve(strict=True)
metadata = path.stat()
if root not in path.parents or not stat.S_ISREG(metadata.st_mode) or not 0 < metadata.st_size <= MAX_BYTES:
raise Unavailable("invalid_source")
rows.append((item["index"], path))
threads, detection = request.get("threads", 1), request.get("detection_limit", 640)
duration = request.get("deadline_ms", 0)
budget = request.get("max_text_bytes", 131072)
if type(threads) is not int or threads not in (1, 2) or type(detection) is not int or detection not in (640, 736):
raise Unavailable("invalid_request")
if type(duration) is not int or not 0 <= duration <= 300000 or type(budget) is not int or not 1 <= budget <= 262144:
raise Unavailable("invalid_request")
return rows, threads, detection, duration / 1000, budget
def load_image(path):
from PIL import Image, ImageOps
Image.MAX_IMAGE_PIXELS = MAX_PIXELS
with warnings.catch_warnings():
warnings.simplefilter("error", Image.DecompressionBombWarning)
with Image.open(path) as image:
if not 0 < image.width * image.height <= MAX_PIXELS:
raise Unavailable("image_limits")
if max(image.size) > 12000 or max(image.size) / min(image.size) > 32:
raise Unavailable("image_limits")
if image.format not in {"PNG", "JPEG", "WEBP", "BMP", "TIFF", "GIF"}:
raise Unavailable("invalid_source")
if getattr(image, "n_frames", 1) != 1:
raise Unavailable("multi_frame_unsupported")
image.seek(0)
return ImageOps.exif_transpose(image).convert("RGB")
def load_engine(threads, detection):
for package, version in (("rapidocr", "3.10.0"), ("onnxruntime", "1.31.0")):
if importlib.metadata.version(package) != version:
raise Unavailable("dependency_unavailable")
spec = importlib.util.find_spec("rapidocr")
if spec is None or not spec.origin:
raise Unavailable("dependency_unavailable")
models = Path(spec.origin).resolve().parent / "models"
params = {
"Global.log_level": "critical", "Det.limit_side_len": detection, "Det.limit_type": "max",
"EngineConfig.onnxruntime.intra_op_num_threads": threads,
"EngineConfig.onnxruntime.inter_op_num_threads": 1,
"EngineConfig.onnxruntime.use_cuda": False, "EngineConfig.onnxruntime.use_dml": False,
"EngineConfig.onnxruntime.use_cann": False, "EngineConfig.onnxruntime.use_coreml": False,
}
for stage, name in MODEL_NAMES.items():
path = (models / name).resolve(strict=True)
if models.resolve() not in path.parents or not path.is_file():
raise Unavailable("model_unavailable")
params[f"{stage}.model_path"] = str(path)
import cv2
from rapidocr import RapidOCR
cv2.setNumThreads(threads)
return RapidOCR(params=params)
def normalized_lines(detected, budget):
texts, scores, boxes = detected.txts, detected.scores, detected.boxes
if texts is None or scores is None or boxes is None or not len(texts):
raise Unavailable("no_text")
if not len(texts) == len(scores) == len(boxes) or len(texts) > 1024:
raise Unavailable("invalid_output")
lines, used = [], 0
for text, score, raw_box in zip(texts, scores, boxes, strict=True):
if not isinstance(text, str) or not text.strip() or any(ord(char) < 32 and char not in "\n\t" for char in text):
raise Unavailable("invalid_output")
size = len(text.encode("utf-8"))
if size > 8192 or used + size > budget:
raise Unavailable("text_limit")
confidence = float(score)
box = raw_box.tolist() if hasattr(raw_box, "tolist") else raw_box
if not math.isfinite(confidence) or not 0 <= confidence <= 1 or not isinstance(box, list) or len(box) != 4:
raise Unavailable("invalid_output")
safe_box = []
for point in box:
if not isinstance(point, (list, tuple)) or len(point) != 2:
raise Unavailable("invalid_output")
coordinates = [float(value) for value in point]
if any(not math.isfinite(value) or abs(value) > 1_000_000 for value in coordinates):
raise Unavailable("invalid_output")
safe_box.append(coordinates)
lines.append({"text": text, "confidence": confidence, "box": safe_box})
used += size
return lines, used
def run(request, *, engine_factory=load_engine, image_loader=load_image, checkpoint=None):
started = time.monotonic()
try:
sources, threads, detection, duration, text_budget = validated_request(request)
except Exception: # noqa: BLE001 - The process boundary must never expose private paths.
return {"status": "unavailable", "reason": "invalid_request", "images": []}
deadline = started + duration
rows = [{"index": index, "status": "unreadable", "reason": "not_processed", "lines": []}
for index, _ in sources]
result = {"status": "partial", "reason": "not_processed", "images": rows}
if not sources:
return {"status": "completed", "images": []}
if duration <= 0:
return {**result, "status": "unavailable", "reason": "deadline"}
for name in ("OMP_NUM_THREADS", "OPENBLAS_NUM_THREADS", "MKL_NUM_THREADS", "NUMEXPR_NUM_THREADS"):
os.environ[name] = str(threads)
logging.disable(logging.CRITICAL)
original = (socket.socket.connect, socket.socket.connect_ex, socket.create_connection, socket.getaddrinfo)
socket.socket.connect = socket.socket.connect_ex = socket.create_connection = socket.getaddrinfo = deny_network
try:
with open(os.devnull, "w", encoding="utf-8") as quiet, contextlib.redirect_stdout(quiet), contextlib.redirect_stderr(quiet):
try:
engine = engine_factory(threads, detection)
except Exception: # noqa: BLE001 - Optional native engines may raise arbitrary errors.
return {**result, "status": "unavailable", "reason": "dependency_unavailable"}
for position, (_, path) in enumerate(sources):
if time.monotonic() >= deadline:
result["reason"] = "deadline"
break
try:
with open(os.devnull, "w", encoding="utf-8") as quiet, contextlib.redirect_stdout(quiet), contextlib.redirect_stderr(quiet):
image = image_loader(path)
try:
import numpy as np
detected = engine(np.asarray(image))
finally:
image.close()
lines, used = normalized_lines(detected, text_budget)
candidate = {**rows[position], "status": "processed", "lines": lines}
candidate.pop("reason", None)
trial = {**result, "images": [candidate if index == position else row for index, row in enumerate(rows)]}
if len(json.dumps(trial, ensure_ascii=False, allow_nan=False).encode("utf-8")) > MAX_OUTPUT:
raise Unavailable("output_limit")
rows[position] = candidate
text_budget -= used
except Exception as error: # noqa: BLE001 - One decoder failure must not erase other sources.
reason = str(error) if isinstance(error, Unavailable) else "inference_failed"
rows[position] = {**rows[position], "reason": reason}
if checkpoint:
checkpoint(result)
processed = sum(row["status"] == "processed" for row in rows)
result["status"] = "completed" if processed == len(rows) else ("partial" if processed else "unavailable")
if result["status"] == "completed":
result.pop("reason", None)
elif result["reason"] != "deadline":
result["reason"] = "unreadable_sources"
return result
finally:
socket.socket.connect, socket.socket.connect_ex, socket.create_connection, socket.getaddrinfo = original
def main():
stream = sys.stdout
stream.reconfigure(encoding="utf-8", errors="strict")
def write(result):
data = json.dumps(result, ensure_ascii=False, allow_nan=False)
if stream.seekable():
stream.seek(0)
stream.truncate()
stream.write(data)
stream.flush()
try:
raw = sys.stdin.buffer.read(MAX_INPUT + 1)
if len(raw) > MAX_INPUT:
raise Unavailable("invalid_request")
request = json.loads(raw)
result = run(request, checkpoint=write if stream.seekable() else None)
except Exception: # noqa: BLE001 - Emit only the fixed safe wire error at the worker boundary.
result = {"status": "unavailable", "reason": "invalid_request", "images": []}
write(result)
if __name__ == "__main__":
main()
@@ -0,0 +1,23 @@
# Exact private-runtime lock from the reviewed RapidOCR 3.10 probe (Python 3.11).
# Install into a dedicated environment; do not use the workstation app runtime.
certifi==2026.10.19
charset-normalizer==3.5.2
colorama==0.4.6
colorlog==6.12.0
flatbuffers==25.12.19
idna==3.20
numpy==2.4.6
omegaconf==2.4.0
onnxruntime==1.31.0
opencv-python-headless==5.0.0.93
packaging==26.3
pillow==13.0.0
protobuf==7.36.2
pyclipper==1.4.0
PyYAML==6.0.3
rapidocr==3.10.0
requests==2.34.2
shapely==2.2.0
six==1.17.0
tqdm==4.70.1
urllib3==2.8.0