Merge branch 'master' of http://git.zhenyangtang.com.cn/ZYT/zyt
This commit is contained in:
@@ -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` 单独代替内容正确性验收。
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user