dsh-ocr-local 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +31 -0
- package/README.en.md +218 -0
- package/README.md +195 -0
- package/cordis.patch.yml +4 -0
- package/dsh/capability.js +82 -0
- package/dsh/index.js +514 -0
- package/ocr/download_models.py +130 -0
- package/ocr/ocr.py +386 -0
- package/ocr/setup.py +168 -0
- package/package.json +52 -0
package/ocr/ocr.py
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
本地 OCR 推理(PP-OCRv5 + ONNX Runtime,完全离线)
|
|
5
|
+
|
|
6
|
+
用法:
|
|
7
|
+
python ocr.py <图片路径> [--full] [--model-dir DIR]
|
|
8
|
+
python ocr.py --doctor [--model-dir DIR]
|
|
9
|
+
|
|
10
|
+
--full 输出 JSON:{"lines": [{text, confidence}], "blocks": [{text, confidence, box}]}
|
|
11
|
+
默认输出为纯文本(行合并后的可读结果)
|
|
12
|
+
--doctor 输出环境诊断 JSON(python / 依赖 / 模型校验),无需依赖也能运行
|
|
13
|
+
|
|
14
|
+
识别增强:
|
|
15
|
+
- 暗色背景自动反色 + Otsu 二值化(4 种预处理候选做多数投票,避免误选)
|
|
16
|
+
- 小字检测框按比例加大内边距并自动放大(目标字高 ~20px),避免丢笔画
|
|
17
|
+
- 去除 320px 宽度上限(CTC 支持长行),上限放宽到 2048
|
|
18
|
+
- 检测框按视觉行聚类,对整行直接识别,避免碎片拼接产生的重复字
|
|
19
|
+
- 输出带检测置信度、字高(font_px)与 low_confidence 风险标记
|
|
20
|
+
(rec 模型 softmax 平坦,不把 rec 概率当置信度)
|
|
21
|
+
"""
|
|
22
|
+
import argparse
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import re
|
|
26
|
+
import sys
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
# 依赖延迟导入:--doctor 在缺依赖时也必须能跑
|
|
30
|
+
try:
|
|
31
|
+
import cv2
|
|
32
|
+
import numpy as np
|
|
33
|
+
import onnxruntime as ort
|
|
34
|
+
DEPS_OK = True
|
|
35
|
+
DEP_ERROR = None
|
|
36
|
+
except Exception as _e: # pragma: no cover - 仅在缺依赖时触发
|
|
37
|
+
DEPS_OK = False
|
|
38
|
+
DEP_ERROR = str(_e)
|
|
39
|
+
cv2 = np = ort = None
|
|
40
|
+
|
|
41
|
+
_MODELS_DIR = os.environ.get("DSH_OCR_MODELS") or str(Path.home() / ".dsh-ocr" / "models")
|
|
42
|
+
MODELS_DIR = Path(_MODELS_DIR)
|
|
43
|
+
DET_MODEL = MODELS_DIR / "PP-OCRv5_mobile_det.onnx"
|
|
44
|
+
REC_MODEL = MODELS_DIR / "PP-OCRv5_mobile_rec.onnx"
|
|
45
|
+
DICT_FILE = MODELS_DIR / "ppocrv5_dict.txt"
|
|
46
|
+
|
|
47
|
+
# PP-OCRv5 参数
|
|
48
|
+
DET_LIMIT_SIDE_LEN = 736 # det 最长边
|
|
49
|
+
DET_MEAN = (0.485, 0.456, 0.406)
|
|
50
|
+
DET_STD = (0.229, 0.224, 0.225)
|
|
51
|
+
REC_HEIGHT = 48 # rec 固定高度
|
|
52
|
+
REC_MAX_WIDTH = 2048 # rec 最大宽度(原 320 会压扁长行)
|
|
53
|
+
MIN_GLYPH_PX = 20 # 送入 rec 前的目标字高下限(小字自动放大)
|
|
54
|
+
MAX_UPSCALE = 6 # 小字放大的最大倍数(防爆内存)
|
|
55
|
+
DB_THRESH = 0.3 # DB 二值化阈值
|
|
56
|
+
DB_BOX_THRESH = 0.4 # 检测框置信度阈值(原 0.5 会静默丢行)
|
|
57
|
+
DB_UNCLIP_RATIO = 1.6 # 框扩展系数
|
|
58
|
+
DB_MIN_SIZE = 3 # 最小框边长(像素)
|
|
59
|
+
LOW_DET_CONF = 0.6 # 检测置信度低于此值的行标记为低置信
|
|
60
|
+
TINY_FONT_PX = 8 # 字高低于此值的行标记为低置信(小字易错)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def load_dict(path):
|
|
64
|
+
"""PaddleOCR 字典:每行一个字符,第一行是空行(对应 class 1 的空字符),
|
|
65
|
+
必须保留空行,否则索引整体偏移。"""
|
|
66
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
67
|
+
return [line.rstrip("\r\n") for line in f]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def require_deps():
|
|
71
|
+
if not DEPS_OK:
|
|
72
|
+
raise RuntimeError(f"Python 依赖缺失: {DEP_ERROR}(请先运行 setup.py 或 pip install onnxruntime numpy opencv-python-headless)")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class PPOCRv5:
|
|
76
|
+
def __init__(self, det_path=None, rec_path=None, dict_path=None):
|
|
77
|
+
# 默认值在调用时读取全局(--model-dir / DSH_OCR_MODELS 才能生效)
|
|
78
|
+
det_path = det_path or DET_MODEL
|
|
79
|
+
rec_path = rec_path or REC_MODEL
|
|
80
|
+
dict_path = dict_path or DICT_FILE
|
|
81
|
+
require_deps()
|
|
82
|
+
self.sess_det = ort.InferenceSession(str(det_path), providers=["CPUExecutionProvider"])
|
|
83
|
+
self.sess_rec = ort.InferenceSession(str(rec_path), providers=["CPUExecutionProvider"])
|
|
84
|
+
self.dict = load_dict(dict_path)
|
|
85
|
+
self.det_in = self.sess_det.get_inputs()[0].name
|
|
86
|
+
self.det_out = self.sess_det.get_outputs()[0].name
|
|
87
|
+
self.rec_in = self.sess_rec.get_inputs()[0].name
|
|
88
|
+
self.rec_out = self.sess_rec.get_outputs()[0].name
|
|
89
|
+
|
|
90
|
+
# ---------- 检测:找文本区域 ----------
|
|
91
|
+
def detect(self, img):
|
|
92
|
+
h, w = img.shape[:2]
|
|
93
|
+
ratio = min(DET_LIMIT_SIDE_LEN / h, DET_LIMIT_SIDE_LEN / w, 1.0)
|
|
94
|
+
nh, nw = int(round(h * ratio)), int(round(w * ratio))
|
|
95
|
+
nh, nw = (nh // 32) * 32, (nw // 32) * 32
|
|
96
|
+
if nh == 0 or nw == 0:
|
|
97
|
+
nh, nw = 32, 32
|
|
98
|
+
resized = cv2.resize(img, (nw, nh))
|
|
99
|
+
mean = np.array(DET_MEAN, dtype=np.float32)
|
|
100
|
+
std = np.array(DET_STD, dtype=np.float32)
|
|
101
|
+
blob = resized.astype(np.float32) / 255.0
|
|
102
|
+
blob = (blob - mean) / std
|
|
103
|
+
blob = blob.transpose(2, 0, 1)[None].astype(np.float32)
|
|
104
|
+
prob = self.sess_det.run([self.det_out], {self.det_in: blob})[0][0, 0]
|
|
105
|
+
prob = cv2.resize(prob, (w, h)) # 还原到原图尺寸
|
|
106
|
+
binary = (prob > DB_THRESH).astype(np.uint8) * 255
|
|
107
|
+
contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
|
108
|
+
boxes = []
|
|
109
|
+
for cnt in contours:
|
|
110
|
+
area = cv2.contourArea(cnt)
|
|
111
|
+
if area < DB_MIN_SIZE * DB_MIN_SIZE:
|
|
112
|
+
continue
|
|
113
|
+
rect = cv2.minAreaRect(cnt)
|
|
114
|
+
box = cv2.boxPoints(rect)
|
|
115
|
+
box = unclip(box, DB_UNCLIP_RATIO)
|
|
116
|
+
box = box.astype(np.int32)
|
|
117
|
+
if cv2.contourArea(box) < DB_MIN_SIZE * DB_MIN_SIZE:
|
|
118
|
+
continue
|
|
119
|
+
mask = np.zeros((h, w), dtype=np.uint8)
|
|
120
|
+
cv2.fillPoly(mask, [box], 1)
|
|
121
|
+
conf = float(prob[mask.astype(bool)].mean()) if mask.any() else 0.0
|
|
122
|
+
if conf < DB_BOX_THRESH:
|
|
123
|
+
continue
|
|
124
|
+
boxes.append((box, conf))
|
|
125
|
+
# 按从上到下、从左到右排序(按中心 y 分块)
|
|
126
|
+
boxes.sort(key=lambda b: (b[0][:, 1].mean() // 20, b[0][:, 0].min()))
|
|
127
|
+
return boxes
|
|
128
|
+
|
|
129
|
+
# ---------- 识别:每个框裁图 → 文字(多候选投票) ----------
|
|
130
|
+
def recognize(self, img, box):
|
|
131
|
+
"""对裁剪区做 4 种预处理(灰度/Otsu/反色/反色+Otsu),
|
|
132
|
+
逐候选解码后按多数投票取结果(rec 模型 softmax 平坦,按置信度选不可靠)。"""
|
|
133
|
+
x, y, w, h = cv2.boundingRect(box)
|
|
134
|
+
# 内边距与框高成正比:小字检测框只覆盖笔画核心,贴边裁剪会丢笔画
|
|
135
|
+
pad = max(4, int(2.0 * h))
|
|
136
|
+
x0, y0 = max(0, x - pad), max(0, y - pad)
|
|
137
|
+
x1, y1 = min(img.shape[1], x + w + pad), min(img.shape[0], y + h + pad)
|
|
138
|
+
crop = img[y0:y1, x0:x1]
|
|
139
|
+
if crop.size == 0:
|
|
140
|
+
return "", 0.0
|
|
141
|
+
# 小字放大:让 rec 看到真实的笔画轮廓
|
|
142
|
+
if crop.shape[0] < MIN_GLYPH_PX:
|
|
143
|
+
scale = min(MAX_UPSCALE, max(2.0, MIN_GLYPH_PX / crop.shape[0]))
|
|
144
|
+
crop = cv2.resize(crop, None, fx=scale, fy=scale, interpolation=cv2.INTER_LANCZOS4)
|
|
145
|
+
gray = cv2.cvtColor(crop, cv2.COLOR_RGB2GRAY)
|
|
146
|
+
variants = [gray]
|
|
147
|
+
_, otsu = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
|
|
148
|
+
variants.append(otsu)
|
|
149
|
+
inv = 255 - gray # 暗底图反色,模型按浅底训练
|
|
150
|
+
variants.append(inv)
|
|
151
|
+
_, inv_otsu = cv2.threshold(inv, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
|
|
152
|
+
variants.append(inv_otsu)
|
|
153
|
+
votes = {}
|
|
154
|
+
for v in variants:
|
|
155
|
+
nw = int(round(v.shape[1] * REC_HEIGHT / v.shape[0]))
|
|
156
|
+
if nw < 4:
|
|
157
|
+
continue
|
|
158
|
+
nw = min(nw, REC_MAX_WIDTH)
|
|
159
|
+
resized = cv2.resize(v, (nw, REC_HEIGHT))
|
|
160
|
+
blob = resized.astype(np.float32) / 255.0
|
|
161
|
+
blob = (blob - 0.5) / 0.5
|
|
162
|
+
blob = np.repeat(blob[None, None], 3, axis=1).astype(np.float32) # (1,3,H,W)
|
|
163
|
+
logits = self.sess_rec.run([self.rec_out], {self.rec_in: blob})[0]
|
|
164
|
+
text, conf = self._decode(logits)
|
|
165
|
+
if not text.strip():
|
|
166
|
+
continue
|
|
167
|
+
rec = votes.get(text)
|
|
168
|
+
if rec is None:
|
|
169
|
+
votes[text] = [1, conf, len(votes)]
|
|
170
|
+
else:
|
|
171
|
+
rec[0] += 1
|
|
172
|
+
rec[1] += conf
|
|
173
|
+
if not votes:
|
|
174
|
+
return "", 0.0
|
|
175
|
+
# 多数投票;票数相同按置信度和优先序(灰度在前)决胜
|
|
176
|
+
best = max(votes.items(), key=lambda kv: (kv[1][0], kv[1][1], -kv[1][2]))
|
|
177
|
+
text, conf = best[0], best[1][1] / best[1][0]
|
|
178
|
+
text = clean_text(text)
|
|
179
|
+
return text, float(conf)
|
|
180
|
+
|
|
181
|
+
def _decode(self, logits):
|
|
182
|
+
probs = np.exp(logits - logits.max(axis=-1, keepdims=True))
|
|
183
|
+
probs /= probs.sum(axis=-1, keepdims=True)
|
|
184
|
+
preds = probs[0].argmax(axis=-1)
|
|
185
|
+
confs = probs[0].max(axis=-1)
|
|
186
|
+
# CTC 贪心解码:去重相邻、去空白
|
|
187
|
+
text_chars, conf_sum, conf_cnt = [], 0.0, 0
|
|
188
|
+
prev = -1
|
|
189
|
+
for t, p in enumerate(preds):
|
|
190
|
+
if p != prev:
|
|
191
|
+
if p != 0 and p - 1 < len(self.dict):
|
|
192
|
+
text_chars.append(self.dict[p - 1])
|
|
193
|
+
conf_sum += confs[t]
|
|
194
|
+
conf_cnt += 1
|
|
195
|
+
prev = p
|
|
196
|
+
text = "".join(text_chars)
|
|
197
|
+
conf = conf_sum / conf_cnt if conf_cnt else 0.0
|
|
198
|
+
return text, conf
|
|
199
|
+
|
|
200
|
+
# ---------- 主流程:检测 → 行聚类 → 整行识别 ----------
|
|
201
|
+
def ocr(self, img):
|
|
202
|
+
if isinstance(img, (str, Path)):
|
|
203
|
+
img = cv2.imread(str(img))
|
|
204
|
+
if img is None:
|
|
205
|
+
raise ValueError(f"无法读取图片: {img}")
|
|
206
|
+
img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) # 模型按 RGB 训练
|
|
207
|
+
boxes = self.detect(img)
|
|
208
|
+
groups = group_boxes_into_lines(boxes)
|
|
209
|
+
lines, blocks = [], []
|
|
210
|
+
for group in groups:
|
|
211
|
+
pts = np.vstack([b[0] for b in group])
|
|
212
|
+
x0, y0 = float(pts[:, 0].min()), float(pts[:, 1].min())
|
|
213
|
+
x1, y1 = float(pts[:, 0].max()), float(pts[:, 1].max())
|
|
214
|
+
union = np.array([[x0, y0], [x1, y0], [x1, y1], [x0, y1]], dtype=np.float32)
|
|
215
|
+
text, _rec_conf = self.recognize(img, union)
|
|
216
|
+
if not text.strip():
|
|
217
|
+
continue
|
|
218
|
+
det_conf = sum(b[1] for b in group) / len(group)
|
|
219
|
+
font_px = round(y1 - y0, 1)
|
|
220
|
+
low = det_conf < LOW_DET_CONF or font_px < TINY_FONT_PX
|
|
221
|
+
lines.append({
|
|
222
|
+
"text": text,
|
|
223
|
+
"confidence": round(float(det_conf), 4),
|
|
224
|
+
"font_px": font_px,
|
|
225
|
+
"low_confidence": bool(low),
|
|
226
|
+
})
|
|
227
|
+
for box, conf in group:
|
|
228
|
+
bpts = box.reshape(-1)
|
|
229
|
+
bx, by = bpts[0::2], bpts[1::2]
|
|
230
|
+
blocks.append({
|
|
231
|
+
"text": text, # 块级文本按整行结果填充(碎片级文本不可靠)
|
|
232
|
+
"confidence": round(float(conf), 4),
|
|
233
|
+
"font_px": round(float(max(by) - min(by)), 1),
|
|
234
|
+
"box": [float(v) for v in bpts],
|
|
235
|
+
})
|
|
236
|
+
return lines, blocks
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
_CJK_LATIN_1 = re.compile(r"([\u4e00-\u9fff\u3400-\u4dbf\uff00-\uffef])([A-Za-z0-9])")
|
|
240
|
+
_CJK_LATIN_2 = re.compile(r"([A-Za-z0-9])([\u4e00-\u9fff\u3400-\u4dbf\uff00-\uffef])")
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def clean_text(text):
|
|
244
|
+
"""识别结果后处理:
|
|
245
|
+
1. 清理行首孤立点号/标点(反色候选会把裁剪区左侧空背景误读成点号);
|
|
246
|
+
2. 中英文交界补空格("下载deb" → "下载 deb",符合中文排版习惯)。"""
|
|
247
|
+
t = text
|
|
248
|
+
while t.startswith((".", ",", "。", ",", "、", ";", ";", ":", ":")):
|
|
249
|
+
# 数字序号 "1." 形式保留("1." 不以 "." 开头,此分支兜底)
|
|
250
|
+
if t.startswith(".") and len(t) > 1 and (t[1].isdigit() or t[1] == " "):
|
|
251
|
+
break
|
|
252
|
+
t = t[1:]
|
|
253
|
+
t = _CJK_LATIN_1.sub(r"\1 \2", t)
|
|
254
|
+
t = _CJK_LATIN_2.sub(r"\1 \2", t)
|
|
255
|
+
return t
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def group_boxes_into_lines(boxes):
|
|
259
|
+
"""把同一视觉行内的检测框聚成一簇(按 y 容差聚类,簇内按 x 排序)。
|
|
260
|
+
返回 [[(box, det_conf), ...], ...]"""
|
|
261
|
+
if not boxes:
|
|
262
|
+
return []
|
|
263
|
+
heights = [b[0][:, 1].max() - b[0][:, 1].min() for b in boxes]
|
|
264
|
+
med_h = sorted(heights)[len(heights) // 2] if heights else 8
|
|
265
|
+
tol = max(8, int(0.6 * med_h))
|
|
266
|
+
groups = {}
|
|
267
|
+
for box, conf in boxes:
|
|
268
|
+
cy = int(box[:, 1].mean())
|
|
269
|
+
x0 = float(box[:, 0].min())
|
|
270
|
+
groups.setdefault(cy // tol, []).append((x0, box, conf))
|
|
271
|
+
result = []
|
|
272
|
+
for key in sorted(groups):
|
|
273
|
+
items = sorted(groups[key], key=lambda t: t[0])
|
|
274
|
+
result.append([(box, conf) for _, box, conf in items])
|
|
275
|
+
return result
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def unclip(box, ratio):
|
|
279
|
+
"""DB 后处理:按面积扩展四边形(保持形状向外扩张)"""
|
|
280
|
+
area = cv2.contourArea(box)
|
|
281
|
+
peri = cv2.arcLength(box, True)
|
|
282
|
+
if peri < 1e-6:
|
|
283
|
+
return box
|
|
284
|
+
dist = area * (ratio - 1) / peri
|
|
285
|
+
result = []
|
|
286
|
+
n = len(box)
|
|
287
|
+
for i in range(n):
|
|
288
|
+
p0 = box[i]
|
|
289
|
+
p1 = box[(i + 1) % n]
|
|
290
|
+
p2 = box[(i + 2) % n]
|
|
291
|
+
v1 = p1 - p0
|
|
292
|
+
v2 = p2 - p1
|
|
293
|
+
n1 = np.array([-v1[1], v1[0]], dtype=np.float32)
|
|
294
|
+
n2 = np.array([-v2[1], v2[0]], dtype=np.float32)
|
|
295
|
+
n1 /= (np.linalg.norm(n1) + 1e-6)
|
|
296
|
+
n2 /= (np.linalg.norm(n2) + 1e-6)
|
|
297
|
+
result.append(p1 + (n1 + n2) * dist)
|
|
298
|
+
return np.array(result, dtype=np.float32)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def doctor(model_dir):
|
|
302
|
+
"""环境诊断(无需依赖即可运行)。返回 JSON 可序列化 dict。"""
|
|
303
|
+
md = Path(model_dir)
|
|
304
|
+
report = {
|
|
305
|
+
"engine": "ppocrv5",
|
|
306
|
+
"ok": False,
|
|
307
|
+
"python": {
|
|
308
|
+
"ok": True,
|
|
309
|
+
"version": f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}",
|
|
310
|
+
"executable": sys.executable,
|
|
311
|
+
"platform": sys.platform,
|
|
312
|
+
},
|
|
313
|
+
"dependencies": {},
|
|
314
|
+
"models": {},
|
|
315
|
+
"models_dir": str(md),
|
|
316
|
+
"venv_dir": str(Path.home() / ".dsh-ocr" / "venv"),
|
|
317
|
+
}
|
|
318
|
+
for name, mod in (("numpy", "numpy"), ("opencv", "cv2"), ("onnxruntime", "onnxruntime")):
|
|
319
|
+
try:
|
|
320
|
+
m = __import__(mod)
|
|
321
|
+
report["dependencies"][name] = {"ok": True, "version": getattr(m, "__version__", "?")}
|
|
322
|
+
except Exception as e:
|
|
323
|
+
report["dependencies"][name] = {"ok": False, "error": str(e)}
|
|
324
|
+
try:
|
|
325
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
326
|
+
from download_models import MANIFEST
|
|
327
|
+
except Exception as e:
|
|
328
|
+
MANIFEST = {}
|
|
329
|
+
report["models"]["_manifest_error"] = str(e)
|
|
330
|
+
for fname in MANIFEST:
|
|
331
|
+
fp = md / fname
|
|
332
|
+
entry = {"present": fp.exists(), "size": fp.stat().st_size if fp.exists() else 0}
|
|
333
|
+
if fp.exists():
|
|
334
|
+
try:
|
|
335
|
+
import hashlib
|
|
336
|
+
h = hashlib.sha256()
|
|
337
|
+
with open(fp, "rb") as f:
|
|
338
|
+
for chunk in iter(lambda: f.read(256 * 1024), b""):
|
|
339
|
+
h.update(chunk)
|
|
340
|
+
entry["sha256_ok"] = h.hexdigest() == MANIFEST[fname][1]
|
|
341
|
+
except Exception as e:
|
|
342
|
+
entry["sha256_ok"] = False
|
|
343
|
+
entry["error"] = str(e)
|
|
344
|
+
else:
|
|
345
|
+
entry["sha256_ok"] = False
|
|
346
|
+
report["models"][fname] = entry
|
|
347
|
+
deps_ok = all(v.get("ok") for v in report["dependencies"].values())
|
|
348
|
+
models_ok = bool(report["models"]) and all(v.get("sha256_ok") for v in report["models"].values())
|
|
349
|
+
report["ok"] = bool(deps_ok and models_ok)
|
|
350
|
+
return report
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def main():
|
|
354
|
+
ap = argparse.ArgumentParser(description="PP-OCRv5 本地 OCR(含环境诊断)")
|
|
355
|
+
ap.add_argument("image", nargs="?", help="图片路径")
|
|
356
|
+
ap.add_argument("--full", action="store_true", help="输出 JSON(行 + 块 + 置信度)")
|
|
357
|
+
ap.add_argument("--doctor", action="store_true", help="输出环境诊断 JSON(无需依赖)")
|
|
358
|
+
ap.add_argument("--model-dir", default=str(MODELS_DIR), help="模型缓存目录")
|
|
359
|
+
args = ap.parse_args()
|
|
360
|
+
global DET_MODEL, REC_MODEL, DICT_FILE
|
|
361
|
+
md = Path(args.model_dir)
|
|
362
|
+
DET_MODEL, REC_MODEL, DICT_FILE = md / "PP-OCRv5_mobile_det.onnx", md / "PP-OCRv5_mobile_rec.onnx", md / "ppocrv5_dict.txt"
|
|
363
|
+
|
|
364
|
+
if args.doctor:
|
|
365
|
+
print(json.dumps(doctor(md), ensure_ascii=False))
|
|
366
|
+
return
|
|
367
|
+
|
|
368
|
+
if not args.image:
|
|
369
|
+
ap.print_usage()
|
|
370
|
+
sys.exit(2)
|
|
371
|
+
|
|
372
|
+
try:
|
|
373
|
+
ocr = PPOCRv5()
|
|
374
|
+
lines, blocks = ocr.ocr(args.image)
|
|
375
|
+
except Exception as e:
|
|
376
|
+
print(json.dumps({"error": str(e), "model_dir": str(md)}, ensure_ascii=False))
|
|
377
|
+
sys.exit(1)
|
|
378
|
+
|
|
379
|
+
if args.full:
|
|
380
|
+
print(json.dumps({"image": args.image, "lines": lines, "blocks": blocks}, ensure_ascii=False))
|
|
381
|
+
else:
|
|
382
|
+
print("\n".join(line["text"] for line in lines))
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
if __name__ == "__main__":
|
|
386
|
+
main()
|
package/ocr/setup.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
dsh-ocr-local 一键自举安装:建 venv → 装依赖 → 下模型(幂等,可重复执行)。
|
|
5
|
+
|
|
6
|
+
由插件的 ocr_setup 工具调用,也可手动运行;只依赖 Python 标准库。
|
|
7
|
+
|
|
8
|
+
用法:
|
|
9
|
+
python setup.py # 完整安装(venv + 依赖 + 模型)
|
|
10
|
+
python setup.py --check # 只检查现状(相当于 ocr.py --doctor 的快捷入口)
|
|
11
|
+
python setup.py --json # 结尾输出单个 JSON 对象(供插件解析)
|
|
12
|
+
python setup.py --no-venv # 不建 venv,直接在当前解释器装依赖
|
|
13
|
+
python setup.py --no-models # 只装依赖,不下载模型
|
|
14
|
+
python setup.py --force # 强制重装依赖(即使 import 成功)
|
|
15
|
+
|
|
16
|
+
环境变量:
|
|
17
|
+
DSH_OCR_VENV venv 目录(默认 ~/.dsh-ocr/venv)
|
|
18
|
+
DSH_OCR_MODELS 模型目录(默认 ~/.dsh-ocr/models)
|
|
19
|
+
DSH_OCR_MODELS_MIRROR 模型下载镜像前缀(ghproxy 风格),透传给 download_models.py
|
|
20
|
+
"""
|
|
21
|
+
import argparse
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import subprocess
|
|
25
|
+
import sys
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
ROOT = Path(__file__).resolve().parent
|
|
29
|
+
DOWNLOAD_SCRIPT = ROOT / "download_models.py"
|
|
30
|
+
DOCTOR_SCRIPT = ROOT / "ocr.py"
|
|
31
|
+
DEFAULT_VENV = Path(os.environ.get("DSH_OCR_VENV") or Path.home() / ".dsh-ocr" / "venv")
|
|
32
|
+
DEFAULT_MODELS = Path(os.environ.get("DSH_OCR_MODELS") or Path.home() / ".dsh-ocr" / "models")
|
|
33
|
+
PIP_PACKAGES = ["onnxruntime", "numpy", "opencv-python-headless"]
|
|
34
|
+
INSTALL_TIMEOUT = 900
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def venv_python(venv_dir: Path) -> Path:
|
|
38
|
+
if os.name == "nt":
|
|
39
|
+
return venv_dir / "Scripts" / "python.exe"
|
|
40
|
+
return venv_dir / "bin" / "python"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def run(cmd, **kw):
|
|
44
|
+
kw.setdefault("capture_output", True)
|
|
45
|
+
kw.setdefault("text", True)
|
|
46
|
+
kw.setdefault("timeout", INSTALL_TIMEOUT)
|
|
47
|
+
kw.setdefault("env", dict(os.environ))
|
|
48
|
+
return subprocess.run(cmd, **kw)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def deps_ok(python: Path):
|
|
52
|
+
r = run([str(python), "-c", "import onnxruntime, numpy, cv2"])
|
|
53
|
+
return r.returncode == 0
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def ensure_venv(venv_dir: Path, no_venv: bool):
|
|
57
|
+
"""返回 (target_python, created: bool, message)"""
|
|
58
|
+
if no_venv:
|
|
59
|
+
return Path(sys.executable), False, "使用当前解释器(--no-venv)"
|
|
60
|
+
if sys.prefix != sys.base_prefix:
|
|
61
|
+
return Path(sys.executable), False, f"已在 venv 中({sys.prefix}),直接使用"
|
|
62
|
+
py = venv_python(venv_dir)
|
|
63
|
+
if py.exists():
|
|
64
|
+
return py, False, f"venv 已存在({venv_dir})"
|
|
65
|
+
print(f"[setup] 创建 venv: {venv_dir} ...")
|
|
66
|
+
r = run([sys.executable, "-m", "venv", str(venv_dir)])
|
|
67
|
+
if r.returncode != 0:
|
|
68
|
+
raise RuntimeError(
|
|
69
|
+
f"创建 venv 失败: {r.stderr.strip()[:500] or r.stdout.strip()[:500]}\n"
|
|
70
|
+
"(Ubuntu/Debian 可能需要: sudo apt install python3-venv)"
|
|
71
|
+
)
|
|
72
|
+
return py, True, f"venv 已创建({venv_dir})"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def ensure_deps(python: Path, force: bool):
|
|
76
|
+
if not force and deps_ok(python):
|
|
77
|
+
return True, "依赖已就绪"
|
|
78
|
+
print(f"[setup] 安装依赖: {' '.join(PIP_PACKAGES)} ...")
|
|
79
|
+
r = run([str(python), "-m", "pip", "install", "--disable-pip-version-check", *PIP_PACKAGES])
|
|
80
|
+
if r.returncode != 0:
|
|
81
|
+
tail = (r.stderr or r.stdout).strip().splitlines()[-5:]
|
|
82
|
+
raise RuntimeError("pip install 失败:\n" + "\n".join(tail))
|
|
83
|
+
if not deps_ok(python):
|
|
84
|
+
raise RuntimeError("依赖安装完成但 import 校验失败(可能是无可用 wheel 的 Python 版本)")
|
|
85
|
+
return True, "依赖安装完成"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def ensure_models(python: Path, model_dir: Path):
|
|
89
|
+
print(f"[setup] 下载模型到 {model_dir} ...")
|
|
90
|
+
r = run([str(python), "-X", "utf8", str(DOWNLOAD_SCRIPT), "--model-dir", str(model_dir)])
|
|
91
|
+
if r.returncode != 0:
|
|
92
|
+
raise RuntimeError("模型下载失败:" + (r.stderr or r.stdout).strip().splitlines()[-3:][-1])
|
|
93
|
+
return True, "模型已就绪"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def check_report(venv_dir: Path, model_dir: Path, no_venv: bool):
|
|
97
|
+
"""--check:用目标解释器跑 ocr.py --doctor,汇总为单个 JSON。"""
|
|
98
|
+
py = venv_python(venv_dir)
|
|
99
|
+
result = {"ok": False, "venv": str(venv_dir), "python": None, "doctor": None, "missing": []}
|
|
100
|
+
if no_venv or py.exists():
|
|
101
|
+
r = run([str(py if not no_venv else sys.executable), "-X", "utf8", str(DOCTOR_SCRIPT), "--doctor", "--model-dir", str(model_dir)])
|
|
102
|
+
try:
|
|
103
|
+
doctor = json.loads(r.stdout)
|
|
104
|
+
except Exception:
|
|
105
|
+
doctor = {"ok": False, "python": {"ok": False, "error": (r.stderr or r.stdout)[:300]}}
|
|
106
|
+
result["python"] = str(py if not no_venv else sys.executable)
|
|
107
|
+
result["doctor"] = doctor
|
|
108
|
+
result["ok"] = bool(doctor.get("ok"))
|
|
109
|
+
result["missing"] = [k for k, v in (doctor.get("dependencies") or {}).items() if not v.get("ok")]
|
|
110
|
+
result["missing"] += [k for k, v in (doctor.get("models") or {}).items() if not v.get("sha256_ok")]
|
|
111
|
+
else:
|
|
112
|
+
result["missing"] = ["venv"]
|
|
113
|
+
return result
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def main():
|
|
117
|
+
ap = argparse.ArgumentParser(description="dsh-ocr-local 自举安装")
|
|
118
|
+
ap.add_argument("--venv", default=str(DEFAULT_VENV), help="venv 目录")
|
|
119
|
+
ap.add_argument("--model-dir", default=str(DEFAULT_MODELS), help="模型目录")
|
|
120
|
+
ap.add_argument("--check", action="store_true", help="只检查,不安装")
|
|
121
|
+
ap.add_argument("--json", action="store_true", help="输出 JSON")
|
|
122
|
+
ap.add_argument("--no-venv", action="store_true", help="不建 venv,直接用当前解释器")
|
|
123
|
+
ap.add_argument("--no-models", action="store_true", help="跳过模型下载")
|
|
124
|
+
ap.add_argument("--force", action="store_true", help="强制重装依赖")
|
|
125
|
+
args = ap.parse_args()
|
|
126
|
+
|
|
127
|
+
venv_dir = Path(args.venv)
|
|
128
|
+
model_dir = Path(args.model_dir)
|
|
129
|
+
|
|
130
|
+
if args.check:
|
|
131
|
+
report = check_report(venv_dir, model_dir, args.no_venv)
|
|
132
|
+
if args.json:
|
|
133
|
+
print(json.dumps(report, ensure_ascii=False))
|
|
134
|
+
else:
|
|
135
|
+
print("检查完成: " + ("✓ 就绪" if report["ok"] else "✗ 未就绪,缺少: " + ", ".join(report["missing"])))
|
|
136
|
+
sys.exit(0 if report["ok"] else 1)
|
|
137
|
+
|
|
138
|
+
steps = []
|
|
139
|
+
ok = False
|
|
140
|
+
try:
|
|
141
|
+
python, created, msg = ensure_venv(venv_dir, args.no_venv)
|
|
142
|
+
steps.append(("venv", created, msg))
|
|
143
|
+
ok1, msg1 = ensure_deps(python, args.force)
|
|
144
|
+
steps.append(("dependencies", ok1, msg1))
|
|
145
|
+
if not args.no_models:
|
|
146
|
+
ok2, msg2 = ensure_models(python, model_dir)
|
|
147
|
+
steps.append(("models", ok2, msg2))
|
|
148
|
+
else:
|
|
149
|
+
steps.append(("models", False, "跳过(--no-models)"))
|
|
150
|
+
ok = True
|
|
151
|
+
except Exception as e:
|
|
152
|
+
if args.json:
|
|
153
|
+
print(json.dumps({"ok": False, "error": str(e), "steps": [s[1] for s in steps]}, ensure_ascii=False))
|
|
154
|
+
else:
|
|
155
|
+
print(f"[setup] 失败: {e}")
|
|
156
|
+
sys.exit(1)
|
|
157
|
+
|
|
158
|
+
if args.json:
|
|
159
|
+
print(json.dumps({"ok": True, "venv": str(venv_dir), "steps": {s[0]: s[2] for s in steps}}, ensure_ascii=False))
|
|
160
|
+
else:
|
|
161
|
+
print("[setup] 完成 ✓")
|
|
162
|
+
for name, _, msg in steps:
|
|
163
|
+
print(f" - {name}: {msg}")
|
|
164
|
+
print(f" 测试: {venv_python(venv_dir)} {DOCTOR_SCRIPT} <图片路径>")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
if __name__ == "__main__":
|
|
168
|
+
main()
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-ocr-local",
|
|
3
|
+
"description": "Local OCR fallback for DeepSeek Harness (Web): when the routed model cannot accept image input, a pasted image is saved locally and its text read by PP-OCRv5 + ONNX Runtime — fully offline, no vision model required. / DeepSeek Harness 本地 OCR 兜底插件(Web):当接入的模型不支持图片输入时,自动把图片存到本地并用 PP-OCRv5 读出文字,完全离线。",
|
|
4
|
+
"version": "0.4.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/grelvan/dsh-ocr-local.git"
|
|
10
|
+
},
|
|
11
|
+
"keywords": [
|
|
12
|
+
"deepseek",
|
|
13
|
+
"harness",
|
|
14
|
+
"dsh",
|
|
15
|
+
"dsh-plugin",
|
|
16
|
+
"ocr",
|
|
17
|
+
"paddleocr",
|
|
18
|
+
"onnx",
|
|
19
|
+
"offline",
|
|
20
|
+
"text-only-model",
|
|
21
|
+
"fallback"
|
|
22
|
+
],
|
|
23
|
+
"dsh": {
|
|
24
|
+
"bundle": {
|
|
25
|
+
"patch": "./cordis.patch.yml"
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"exports": {
|
|
29
|
+
".": "./dsh/index.js",
|
|
30
|
+
"./dsh": "./dsh/index.js",
|
|
31
|
+
"./capability": "./dsh/capability.js",
|
|
32
|
+
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
33
|
+
"./package.json": "./package.json"
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"dsh",
|
|
37
|
+
"ocr",
|
|
38
|
+
"!ocr/__pycache__",
|
|
39
|
+
"cordis.patch.yml",
|
|
40
|
+
"README.en.md"
|
|
41
|
+
],
|
|
42
|
+
"scripts": {
|
|
43
|
+
"test": "node --test test/*.test.mjs"
|
|
44
|
+
},
|
|
45
|
+
"peerDependencies": {
|
|
46
|
+
"@deepseek-ai/cordis": "^4.0.1",
|
|
47
|
+
"@deepseek-ai/dsh-tools": "^0.0.1-rc.1"
|
|
48
|
+
},
|
|
49
|
+
"publishConfig": {
|
|
50
|
+
"registry": "https://registry.npmjs.org/"
|
|
51
|
+
}
|
|
52
|
+
}
|