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/dsh/index.js ADDED
@@ -0,0 +1,514 @@
1
+ /**
2
+ * dsh-ocr-local — host half (cordis plugin), Web only.
3
+ *
4
+ * 1. Registers the `ocr_image` tool: local PP-OCRv5 (ONNX Runtime) OCR on an
5
+ * image path, fully offline. Models cached in ~/.dsh-ocr/models.
6
+ * 2. Registers the `ocr_setup` tool: one-command bootstrap (venv + deps +
7
+ * models), so first use is automatic instead of a manual pip dance.
8
+ * 3. Auto-OCR (`autoOcr`): watches `user/message` events for image
9
+ * attachments. The Web composer intakes pasted image files natively, so
10
+ * the attachment is the single entry point — no client-side interception.
11
+ * Only when the routed model is **explicitly** declared as not accepting
12
+ * image input does the plugin save the image to ~/.dsh/ocr/cache and
13
+ * inject its path, so the text-only model can call `ocr_image` on it.
14
+ *
15
+ * - model declares `image` → silent; the vision path handles it, and the
16
+ * harness already gives the model a read-only copy path it may OCR when
17
+ * verbatim text matters.
18
+ * - model declares text only → the harness substitutes an opaque
19
+ * "[image omitted ...]" placeholder with no path, so this plugin is the
20
+ * only way the model can read the image.
21
+ * - cannot be determined → silent (see dsh/capability.js).
22
+ *
23
+ * Loaded via cordis.patch.yml; zero runtime dependencies (node builtins).
24
+ */
25
+ import { execFile } from 'node:child_process'
26
+ import { createHash } from 'node:crypto'
27
+ import { existsSync, mkdirSync, readdirSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
28
+ import { homedir } from 'node:os'
29
+ import { dirname, join } from 'node:path'
30
+ import { fileURLToPath } from 'node:url'
31
+ import { defineTool } from '@deepseek-ai/dsh-tools'
32
+ import { autoOcrMode, modalityCacheKey, pickRoute, shouldInjectOcrPath } from './capability.js'
33
+
34
+ export const name = 'dsh-ocr-local'
35
+ export const inject = ['tools', 'agents']
36
+
37
+ const __dirname = dirname(fileURLToPath(import.meta.url))
38
+ const PLUGIN_ROOT = join(__dirname, '..')
39
+ const OCR_SCRIPT = join(PLUGIN_ROOT, 'ocr', 'ocr.py')
40
+ const SETUP_SCRIPT = join(PLUGIN_ROOT, 'ocr', 'setup.py')
41
+ const OCR_HOME = join(homedir(), '.dsh-ocr')
42
+ const MODELS_DIR = join(OCR_HOME, 'models')
43
+ const VENV_DIR = join(OCR_HOME, 'venv')
44
+ /** 图片缓存目录(内容去重 + 按数量/天数清理)。 */
45
+ const CACHE_DIR = join(homedir(), '.dsh', 'ocr', 'cache')
46
+
47
+ /* ------------------------------------------------------------------ */
48
+ /* 环境解析 */
49
+ /* ------------------------------------------------------------------ */
50
+
51
+ function venvPythonPath() {
52
+ return process.platform === 'win32'
53
+ ? join(VENV_DIR, 'Scripts', 'python.exe')
54
+ : join(VENV_DIR, 'bin', 'python')
55
+ }
56
+
57
+ /** python 解析链:config.pythonPath → DSH_OCR_PYTHON → 内置 venv → python3 → python */
58
+ function resolvePython(config = {}) {
59
+ const candidates = [
60
+ config.pythonPath,
61
+ process.env.DSH_OCR_PYTHON,
62
+ venvPythonPath(),
63
+ process.platform === 'win32' ? 'python.exe' : 'python3',
64
+ 'python',
65
+ ].filter(Boolean)
66
+ for (const c of candidates) {
67
+ if (!/[/\\]/.test(c) || existsSync(c)) return c
68
+ }
69
+ return candidates[0]
70
+ }
71
+
72
+ /* ------------------------------------------------------------------ */
73
+ /* OCR 执行与诊断 */
74
+ /* ------------------------------------------------------------------ */
75
+
76
+ function runDoctor(config = {}) {
77
+ return new Promise(resolve => {
78
+ const python = resolvePython(config)
79
+ execFile(
80
+ python,
81
+ ['-X', 'utf8', OCR_SCRIPT, '--doctor', ...(config.modelDir ? ['--model-dir', config.modelDir] : [])],
82
+ { encoding: 'utf8', windowsHide: true, timeout: 30000 },
83
+ (error, stdout) => {
84
+ if (error) {
85
+ resolve({ ok: false, python: { ok: false, error: 'python 不可用:' + String(error.message || error).slice(0, 120) } })
86
+ return
87
+ }
88
+ try {
89
+ resolve(JSON.parse(stdout))
90
+ } catch {
91
+ resolve({ ok: false, python: { ok: true, error: 'doctor 输出无法解析' } })
92
+ }
93
+ },
94
+ )
95
+ })
96
+ }
97
+
98
+ function runOcr(path, config = {}) {
99
+ return new Promise(resolve => {
100
+ const python = resolvePython(config)
101
+ const args = [OCR_SCRIPT, path, '--full', ...(config.modelDir ? ['--model-dir', config.modelDir] : [])]
102
+ execFile(
103
+ python,
104
+ ['-X', 'utf8', ...args],
105
+ { encoding: 'utf8', windowsHide: true, timeout: 120000, maxBuffer: 32 * 1024 * 1024 },
106
+ (error, stdout) => {
107
+ if (error) {
108
+ let pyErr = null
109
+ try {
110
+ pyErr = JSON.parse(stdout.trim())
111
+ } catch { /* stdout 不是 JSON */ }
112
+ const reason = pyErr && pyErr.error ? pyErr.error : String(error.message || error).slice(0, 300)
113
+ runDoctor(config).then(doctor => resolve({ text: '', path, error: reason, doctor }))
114
+ return
115
+ }
116
+ let data = null
117
+ try {
118
+ data = JSON.parse(stdout)
119
+ } catch { /* ignore */ }
120
+ if (!data || !Array.isArray(data.lines)) {
121
+ resolve({ text: '', path, error: 'OCR 输出无法解析' })
122
+ return
123
+ }
124
+ resolve({
125
+ text: data.lines.map(l => l.text).join('\n'),
126
+ lines: data.lines,
127
+ blocks: data.blocks || [],
128
+ path,
129
+ engine: 'ppocrv5',
130
+ })
131
+ },
132
+ )
133
+ })
134
+ }
135
+
136
+ /* ------------------------------------------------------------------ */
137
+ /* 渲染 */
138
+ /* ------------------------------------------------------------------ */
139
+
140
+ function missingNames(doctor) {
141
+ const missing = []
142
+ if (doctor.python && doctor.python.ok === false) missing.push('python')
143
+ for (const [k, v] of Object.entries(doctor.dependencies || {})) if (!v.ok) missing.push(k)
144
+ for (const [k, v] of Object.entries(doctor.models || {})) {
145
+ if (!v.present) missing.push(k)
146
+ else if (v.sha256_ok === false) missing.push(`${k}(损坏)`)
147
+ }
148
+ return missing
149
+ }
150
+
151
+ function renderText(value) {
152
+ if (value.error) {
153
+ const doc = value.doctor
154
+ let hint
155
+ if (doc && doc.ok === false) {
156
+ const missing = missingNames(doc).join('、')
157
+ hint = `环境未就绪,缺少: ${missing}。\n调用 ocr_setup 工具可一键安装(建 venv + 装依赖 + 下模型),或手动运行:\n ${SETUP_SCRIPT}`
158
+ } else {
159
+ hint = `可调用 ocr_setup 工具检查/安装环境。`
160
+ }
161
+ return `[dsh-ocr] ${value.error}\n${hint}`
162
+ }
163
+ const head = `图片识别结果(${value.path}):`
164
+ const lines = value.lines || []
165
+ const body = lines.map(l => l.text).join('\n').trim()
166
+ const low = lines.filter(l => l.low_confidence)
167
+ let tail = ''
168
+ if (low.length) {
169
+ const names = low.map(l => `「${l.text.slice(0, 10)}」(字高${l.font_px ?? '?'}px/置信${Math.round((l.confidence ?? 0) * 100)}%)`).join('、')
170
+ tail = `\n\n⚠ 以下 ${low.length} 行字太小或检测置信度低,可能有误: ${names}`
171
+ }
172
+ return body ? `${head}\n${body}${tail}` : `${head}\n(未识别到文字)`
173
+ }
174
+
175
+ function renderSetup(value) {
176
+ if (value.error) return `[dsh-ocr] 安装失败: ${value.error}`
177
+ if (value.checkOnly) return `[dsh-ocr] ${value.ok ? '环境就绪 ✓' : '环境未就绪 ✗'}`
178
+ const steps = Object.entries(value.steps || {})
179
+ .map(([k, v]) => ` - ${k}: ${v}`)
180
+ .join('\n')
181
+ return `[dsh-ocr] 安装${value.ok ? '完成 ✓' : '未完成 ✗'}\n${steps}`
182
+ }
183
+
184
+ /* ------------------------------------------------------------------ */
185
+ /* 图片缓存:去重 + 类型感知命名 + 清理 */
186
+ /* ------------------------------------------------------------------ */
187
+
188
+ /** 附件媒体类型 → 落盘扩展名。 */
189
+ const IMAGE_EXT = {
190
+ 'image/png': '.png',
191
+ 'image/jpeg': '.jpg',
192
+ 'image/webp': '.webp',
193
+ 'image/gif': '.gif',
194
+ 'image/bmp': '.bmp',
195
+ }
196
+
197
+ /** timestamped cache filename: yyyyMMdd-HHmmss.fffffff-<hash8><ext> */
198
+ function pasteName(ext, hash, now = new Date()) {
199
+ const p = (n, w) => String(n).padStart(w, '0')
200
+ const base = `${now.getFullYear()}${p(now.getMonth() + 1, 2)}${p(now.getDate(), 2)}-` +
201
+ `${p(now.getHours(), 2)}${p(now.getMinutes(), 2)}${p(now.getSeconds(), 2)}.` +
202
+ `${p(now.getMilliseconds() * 10000, 7)}-${hash}`
203
+ return `${base}${ext}`
204
+ }
205
+
206
+ /** 按内容哈希查重:返回已存在的相同图片路径 */
207
+ function findByHashIn(hash, dir) {
208
+ let names
209
+ try {
210
+ names = readdirSync(dir)
211
+ } catch {
212
+ return null
213
+ }
214
+ for (const n of names) {
215
+ if (n.includes(`-${hash}.`)) {
216
+ const p = join(dir, n)
217
+ if (existsSync(p)) return p
218
+ }
219
+ }
220
+ return null
221
+ }
222
+
223
+ function pruneCacheIn(dir, maxFiles, maxAgeDays) {
224
+ if (maxFiles <= 0 && maxAgeDays <= 0) return
225
+ let entries
226
+ try {
227
+ entries = readdirSync(dir)
228
+ .map(n => {
229
+ try {
230
+ const s = statSync(join(dir, n))
231
+ return s.isFile() ? { n, m: s.mtimeMs } : null
232
+ } catch {
233
+ return null
234
+ }
235
+ })
236
+ .filter(Boolean)
237
+ } catch {
238
+ return
239
+ }
240
+ const now = Date.now()
241
+ if (maxAgeDays > 0) {
242
+ for (const e of entries) {
243
+ if (now - e.m > maxAgeDays * 864e5) {
244
+ try {
245
+ unlinkSync(join(dir, e.n))
246
+ } catch { /* ignore */ }
247
+ }
248
+ }
249
+ }
250
+ if (maxFiles > 0) {
251
+ const remaining = readdirSync(dir).length
252
+ const excess = remaining - maxFiles
253
+ if (excess > 0) {
254
+ const alive = entries
255
+ .sort((a, b) => a.m - b.m)
256
+ .slice(0, excess)
257
+ for (const e of alive) {
258
+ try {
259
+ unlinkSync(join(dir, e.n))
260
+ } catch { /* ignore */ }
261
+ }
262
+ }
263
+ }
264
+ }
265
+
266
+ /** 保存图片字节到缓存目录(内容去重 + 类型命名 + 清理)。返回路径。 */
267
+ function saveImageToCache(buffer, mediaType, opts = {}) {
268
+ const dir = opts.cacheDir || CACHE_DIR
269
+ const maxFiles = Number(opts.maxFiles ?? 300)
270
+ const maxAgeDays = Number(opts.maxAgeDays ?? 30)
271
+ const hash = createHash('sha1').update(buffer).digest('hex').slice(0, 8)
272
+ const existing = findByHashIn(hash, dir)
273
+ if (existing) return { path: existing, deduped: true }
274
+ mkdirSync(dir, { recursive: true })
275
+ const ext = IMAGE_EXT[mediaType] || '.png'
276
+ const target = join(dir, pasteName(ext, hash))
277
+ writeFileSync(target, buffer)
278
+ pruneCacheIn(dir, maxFiles, maxAgeDays)
279
+ return { path: target, deduped: false }
280
+ }
281
+
282
+ /**
283
+ * 读取一个**可选**服务,不写进 inject 契约。
284
+ *
285
+ * `ctx.reflect.get(name)` 默认 strict:只返回「提供该服务的 fiber 当前处于活动
286
+ * 状态」的实现。这里再退一步用 strict=false 兜底,避免因为作用域判定而让插件
287
+ * 永久静默——静默本身是本插件的正常行为,但它必须来自「模型能力判定」,
288
+ * 而不是来自「我们拿不到服务」。
289
+ *
290
+ * @param ctx - cordis 上下文。
291
+ * @param name - 服务名。
292
+ * @returns 服务实例,取不到时 undefined。
293
+ */
294
+ function optionalService(ctx, name) {
295
+ const reflect = ctx?.reflect
296
+ if (!reflect || typeof reflect.get !== 'function') return undefined
297
+ try {
298
+ return reflect.get(name) ?? reflect.get(name, false)
299
+ } catch {
300
+ return undefined
301
+ }
302
+ }
303
+
304
+ /**
305
+ * Auto-OCR: 监听 user/message 里的图片附件,**只在路由到的模型明确不支持
306
+ * 图片输入时**把图片存到 ~/.dsh/ocr/cache 并把路径注入 agent 上下文,让该模型
307
+ * 调 ocr_image 本地识别。
308
+ *
309
+ * Web 端粘贴的图片由 composer 原生收进附件流程,插件不做任何客户端拦截。
310
+ * 视觉模型下插件完全静默:harness 自己会在图片前附带只读副本路径,模型需要
311
+ * 逐字核对时可直接调 ocr_image。判定逻辑见 dsh/capability.js。
312
+ */
313
+ function registerAutoOcr(ctx, config = {}) {
314
+ const mode = autoOcrMode(config)
315
+ if (mode === 'off') return
316
+ const maxFiles = Number(config.maxCacheFiles ?? 300)
317
+ const maxAgeDays = Number(config.maxCacheAgeDays ?? 30)
318
+ // 已处理过的附件引用(防事件重放重复注入);有界缓存。
319
+ const seen = new Set()
320
+ // session.id → 用户刚切换、尚未发请求的模型选择(来源优先级见 pickRoute)。
321
+ const pendingRoutes = new Map()
322
+ // `${provider}\0${model}` → inputModalities 数组 | null(查询失败)。
323
+ const modalityCache = new Map()
324
+
325
+ /** 解析某个 provider/model 声明的输入模态;失败或服务缺失返回 undefined。 */
326
+ async function resolveModalities(route) {
327
+ if (!route) return undefined
328
+ const key = modalityCacheKey(route.provider, route.model)
329
+ if (modalityCache.has(key)) return modalityCache.get(key) ?? undefined
330
+ let modalities
331
+ try {
332
+ const llm = optionalService(ctx, 'llm')
333
+ if (llm && typeof llm.resolveModelInfo === 'function') {
334
+ const info = await llm.resolveModelInfo(route.provider, route.model)
335
+ if (Array.isArray(info?.inputModalities) && info.inputModalities.length > 0) {
336
+ modalities = [...info.inputModalities]
337
+ }
338
+ }
339
+ } catch { /* 未注册 provider / 未知模型 / 适配器报错 → 视为无法确定 */ }
340
+ modalityCache.set(key, modalities ?? null)
341
+ return modalities
342
+ }
343
+
344
+ ctx.on('session/event', async (session, event) => {
345
+ // 用户在客户端切换模型 → 记下待生效路由,供下一次判定优先采用。
346
+ if (event.type === 'model/selection') {
347
+ const route = pickRoute({ pending: event.data })
348
+ if (route) pendingRoutes.set(session.id, route)
349
+ return
350
+ }
351
+ if (event.type !== 'user/message') return
352
+ const content = Array.isArray(event.data?.content) ? event.data.content : []
353
+ const refs = content
354
+ .filter(b => b && b.type === 'image' && b.attachment)
355
+ .map(b => b.attachment)
356
+ if (refs.length === 0) return
357
+
358
+ const agent = ctx.agents?.get?.(session.id) || ctx.agents?.list?.().find(a => a.session?.id === session.id)
359
+ if (!agent || typeof agent.inject !== 'function') return
360
+
361
+ // 'always' 模式无条件介入,不必查询模型能力。
362
+ if (mode !== 'always') {
363
+ let header
364
+ try {
365
+ header = session.requestHeader?.()?.config
366
+ } catch { /* 无请求头则退回其它来源 */ }
367
+ const route = pickRoute({ pending: pendingRoutes.get(session.id), header, options: agent.options })
368
+ const modalities = await resolveModalities(route)
369
+ if (!shouldInjectOcrPath(mode, modalities)) return
370
+ }
371
+
372
+ const attachments = optionalService(ctx, 'attachments')
373
+ if (!attachments || typeof attachments.readImage !== 'function') return
374
+ const paths = []
375
+ for (const ref of refs) {
376
+ // 稳定标识是 attachmentId(sha256:…);ref.id 只是兼容兜底。
377
+ const refKey = ref.attachmentId ?? ref.id
378
+ if (refKey && seen.has(refKey)) continue
379
+ try {
380
+ const stored = await attachments.readImage(ref)
381
+ const bytes = Buffer.from(stored.data ?? stored)
382
+ if (bytes.length === 0) continue
383
+ const saved = saveImageToCache(bytes, ref.mediaType || 'image/png', { maxFiles, maxAgeDays })
384
+ if (refKey) seen.add(refKey)
385
+ paths.push(saved.path)
386
+ } catch { /* 附件读取失败则跳过,不影响其它图片 */ }
387
+ }
388
+ if (seen.size > 5000) seen.clear()
389
+ if (paths.length === 0) return
390
+ agent.inject({
391
+ role: 'user',
392
+ content: [{
393
+ type: 'text',
394
+ text: '用户附上了图片,当前模型不支持图片输入,图片已保存到本地缓存。'
395
+ + '请用 ocr_image 工具读取其中的文字:\n' + paths.join('\n'),
396
+ }],
397
+ source: {
398
+ kind: 'plugin',
399
+ plugin: 'dsh-ocr-local',
400
+ form: 'notice',
401
+ summary: `已保存 ${paths.length} 张图片到本地 OCR 缓存`,
402
+ },
403
+ })
404
+ })
405
+ }
406
+
407
+ /* ------------------------------------------------------------------ */
408
+ /* 工具注册 */
409
+ /* ------------------------------------------------------------------ */
410
+
411
+ function runSetup(python, argv) {
412
+ return new Promise(resolve => {
413
+ execFile(
414
+ python,
415
+ ['-X', 'utf8', ...argv],
416
+ { encoding: 'utf8', windowsHide: true, timeout: 900000, maxBuffer: 16 * 1024 * 1024 },
417
+ (error, stdout, stderr) => {
418
+ if (error) {
419
+ let data = null
420
+ try {
421
+ data = JSON.parse(stdout.trim())
422
+ } catch { /* ignore */ }
423
+ resolve(data || { ok: false, error: (data?.error) || String(error.message || error).slice(0, 300) + (stderr ? ' / ' + stderr.slice(-200) : '') })
424
+ return
425
+ }
426
+ try {
427
+ resolve(JSON.parse(stdout.trim()))
428
+ } catch {
429
+ resolve({ ok: false, error: 'setup 输出无法解析' })
430
+ }
431
+ },
432
+ )
433
+ })
434
+ }
435
+
436
+ export function apply(ctx, config = {}) {
437
+ registerAutoOcr(ctx, config)
438
+
439
+ ctx.tools.register(defineTool({
440
+ name: 'ocr_image',
441
+ description:
442
+ 'Run local OCR (PP-OCRv5, fully offline) on an image file and return its text content. ' +
443
+ 'Use it when the user references an image file and you need the text in it — no vision model ' +
444
+ 'required. With a model that accepts image input the harness also sends the image itself plus a ' +
445
+ 'read-only copy path; call this tool on that path when you need verbatim characters (code, error ' +
446
+ 'messages, logs, table numbers) or per-line confidence instead of a visual reading. ' +
447
+ 'Returns the recognized text lines with per-line confidence, or a diagnosis (plus a hint to run ' +
448
+ 'the ocr_setup tool) when the OCR engine is not installed yet.',
449
+ parameters: {
450
+ path: {
451
+ type: 'string',
452
+ required: true,
453
+ description: 'Absolute path of the image file (png/jpg/webp).',
454
+ },
455
+ full: {
456
+ type: 'boolean',
457
+ description: 'Return structured JSON (lines + blocks with confidence and box coordinates) instead of plain text.',
458
+ },
459
+ },
460
+ output: {
461
+ schema: { type: 'object', additionalProperties: true },
462
+ render: (_args, value) => [{ type: 'text', text: renderText(value) }],
463
+ },
464
+ execute: async args => {
465
+ const path = String(args.path ?? '').trim()
466
+ if (!path) return { text: '', error: '缺少 path 参数(图片文件路径)', path: '' }
467
+ if (!existsSync(path)) {
468
+ return { text: '', error: `图片文件不存在:${path}`, path }
469
+ }
470
+ const result = await runOcr(path, config)
471
+ if (args.full) result.full = { lines: result.lines || [], blocks: result.blocks || [] }
472
+ return result
473
+ },
474
+ timeoutMs: 120000,
475
+ }))
476
+
477
+ ctx.tools.register(defineTool({
478
+ name: 'ocr_setup',
479
+ description:
480
+ 'Install or verify the local OCR engine (creates a venv, installs onnxruntime/numpy/opencv, ' +
481
+ 'downloads the PP-OCRv5 models with sha256 verification). Use when ocr_image reports the engine ' +
482
+ 'is not ready. Idempotent — safe to run repeatedly. Supports a mirror via DSH_OCR_MODELS_MIRROR.',
483
+ parameters: {
484
+ checkOnly: {
485
+ type: 'boolean',
486
+ description: 'Only check readiness (python + deps + models), do not install anything.',
487
+ },
488
+ noModels: {
489
+ type: 'boolean',
490
+ description: 'Install dependencies only, skip model download.',
491
+ },
492
+ force: {
493
+ type: 'boolean',
494
+ description: 'Force reinstall dependencies even if imports succeed.',
495
+ },
496
+ },
497
+ output: {
498
+ schema: { type: 'object', additionalProperties: true },
499
+ render: (_args, value) => [{ type: 'text', text: renderSetup(value) }],
500
+ },
501
+ execute: async args => {
502
+ const python = resolvePython(config)
503
+ const argv = [SETUP_SCRIPT, '--json']
504
+ if (args.checkOnly) argv.push('--check')
505
+ if (args.noModels) argv.push('--no-models')
506
+ if (args.force) argv.push('--force')
507
+ if (config.modelDir) argv.push('--model-dir', config.modelDir)
508
+ const result = await runSetup(python, argv)
509
+ result.checkOnly = Boolean(args.checkOnly)
510
+ return result
511
+ },
512
+ timeoutMs: 900000,
513
+ }))
514
+ }
@@ -0,0 +1,130 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ 下载 PP-OCRv5 ONNX 模型与字典到缓存目录(默认 ~/.dsh-ocr/models)。
5
+
6
+ 特点:
7
+ - sha256 校验:已存在且校验通过则跳过;损坏/不完整文件自动删除重下
8
+ - 镜像支持:DSH_OCR_MODELS_MIRROR 指定镜像前缀(ghproxy 风格,直接拼在原 URL 前)
9
+ - 原子写入:先写 .part 再 rename,中断不会留下半截"可用"文件
10
+ - 自动重试:单文件最多 3 次
11
+
12
+ 用法:
13
+ python download_models.py # 下载到 ~/.dsh-ocr/models
14
+ python download_models.py --model-dir D # 自定义目录
15
+ DSH_OCR_MODELS_MIRROR=https://ghproxy.com/ python download_models.py
16
+
17
+ 模型来源:paddleocr-onnx 社区发布(PaddleOCR v5 官方模型导出),Apache-2.0 许可。
18
+ """
19
+ import argparse
20
+ import hashlib
21
+ import os
22
+ import sys
23
+ import time
24
+ import urllib.request
25
+ from pathlib import Path
26
+
27
+ BASE = "https://github.com/MeKo-Christian/paddleocr-onnx/releases/download/v1.0.0"
28
+ DICT_URL = "https://raw.githubusercontent.com/PaddlePaddle/PaddleOCR/main/ppocr/utils/dict/ppocrv5_dict.txt"
29
+
30
+ # 文件 -> (默认 URL, sha256)。镜像通过环境变量 DSH_OCR_MODELS_MIRROR 注入。
31
+ MANIFEST = {
32
+ "PP-OCRv5_mobile_det.onnx": (
33
+ f"{BASE}/PP-OCRv5_mobile_det.onnx",
34
+ "ca3014670099126189c9519ef770470c03bf41695fb138c6bc19737bd4ba2875",
35
+ ),
36
+ "PP-OCRv5_mobile_rec.onnx": (
37
+ f"{BASE}/PP-OCRv5_mobile_rec.onnx",
38
+ "64ea1b54ea0506609378a3638ff5b2547af7e24809b890e501fb0cce54de21f7",
39
+ ),
40
+ "ppocrv5_dict.txt": (
41
+ DICT_URL,
42
+ "d1979e9f794c464c0d2e0b70a7fe14dd978e9dc644c0e71f14158cdf8342af1b",
43
+ ),
44
+ }
45
+
46
+ MODELS_DIR = Path(os.environ.get("DSH_OCR_MODELS") or Path.home() / ".dsh-ocr" / "models")
47
+ RETRIES = 3
48
+
49
+
50
+ def sha256_of(path: Path) -> str:
51
+ h = hashlib.sha256()
52
+ with open(path, "rb") as f:
53
+ for chunk in iter(lambda: f.read(256 * 1024), b""):
54
+ h.update(chunk)
55
+ return h.hexdigest()
56
+
57
+
58
+ def effective_url(url: str) -> str:
59
+ mirror = os.environ.get("DSH_OCR_MODELS_MIRROR", "").strip().rstrip("/")
60
+ return f"{mirror}/{url}" if mirror else url
61
+
62
+
63
+ def download(url: str, dest: Path, expected_sha: str) -> bool:
64
+ if dest.exists():
65
+ try:
66
+ if dest.stat().st_size > 0 and sha256_of(dest) == expected_sha:
67
+ print(f" 已存在且校验通过,跳过: {dest.name}")
68
+ return True
69
+ print(f" 文件损坏(sha256 不匹配),重新下载: {dest.name}")
70
+ dest.unlink()
71
+ except OSError as e:
72
+ print(f" 校验失败: {dest.name}: {e}", file=sys.stderr)
73
+ return False
74
+
75
+ url = effective_url(url)
76
+ for attempt in range(1, RETRIES + 1):
77
+ tmp = dest.with_suffix(dest.suffix + f".part{attempt}")
78
+ try:
79
+ print(f" 下载 {dest.name} (尝试 {attempt}/{RETRIES}) ...")
80
+ req = urllib.request.Request(url, headers={"User-Agent": "dsh-ocr-local/0.3.3"})
81
+ with urllib.request.urlopen(req, timeout=120) as resp, open(tmp, "wb") as f:
82
+ total = int(resp.headers.get("Content-Length") or 0)
83
+ done = 0
84
+ while True:
85
+ chunk = resp.read(256 * 1024)
86
+ if not chunk:
87
+ break
88
+ f.write(chunk)
89
+ done += len(chunk)
90
+ if total:
91
+ pct = done * 100 // total
92
+ sys.stdout.write(f"\r {pct}% ({done // 1024 // 1024}MB/{total // 1024 // 1024}MB)")
93
+ sys.stdout.flush()
94
+ sys.stdout.write("\n")
95
+ actual = sha256_of(tmp)
96
+ if actual != expected_sha:
97
+ print(f" sha256 不匹配(期望 {expected_sha[:12]}…,实际 {actual[:12]}…),重试", file=sys.stderr)
98
+ tmp.unlink(missing_ok=True)
99
+ continue
100
+ tmp.rename(dest)
101
+ print(f" 完成: {dest.name} ({dest.stat().st_size} 字节)")
102
+ return True
103
+ except Exception as e:
104
+ tmp.unlink(missing_ok=True)
105
+ print(f" 失败: {dest.name}: {e}", file=sys.stderr)
106
+ if attempt < RETRIES:
107
+ time.sleep(1)
108
+ return False
109
+
110
+
111
+ def main() -> int:
112
+ ap = argparse.ArgumentParser(description="下载 PP-OCRv5 ONNX 模型(含 sha256 校验)")
113
+ ap.add_argument("--model-dir", default=str(MODELS_DIR), help="模型缓存目录")
114
+ args = ap.parse_args()
115
+ md = Path(args.model_dir)
116
+ md.mkdir(parents=True, exist_ok=True)
117
+ print(f"模型目录: {md}")
118
+ ok = True
119
+ for name, (url, sha) in MANIFEST.items():
120
+ if not download(url, md / name, sha):
121
+ ok = False
122
+ if ok:
123
+ print("全部完成 ✓")
124
+ return 0
125
+ print("存在失败项,请检查网络或设置 DSH_OCR_MODELS_MIRROR", file=sys.stderr)
126
+ return 1
127
+
128
+
129
+ if __name__ == "__main__":
130
+ sys.exit(main())