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/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
+ }