@qvac/ocr-ggml 0.0.1 → 0.1.1

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.
Files changed (57) hide show
  1. package/LICENSE +179 -0
  2. package/NOTICE +63 -0
  3. package/README.md +330 -0
  4. package/addonLogging.d.ts +7 -0
  5. package/addonLogging.js +4 -0
  6. package/binding.js +21 -0
  7. package/index.d.ts +145 -0
  8. package/index.js +383 -0
  9. package/lib/error.js +80 -0
  10. package/ocr-ggml.js +121 -0
  11. package/package.json +96 -7
  12. package/prebuilds/android-arm64/qvac__ocr-ggml/libqvac-ggml-cpu-android_armv8.0_1.so +0 -0
  13. package/prebuilds/android-arm64/qvac__ocr-ggml/libqvac-ggml-cpu-android_armv8.2_1.so +0 -0
  14. package/prebuilds/android-arm64/qvac__ocr-ggml/libqvac-ggml-cpu-android_armv8.2_2.so +0 -0
  15. package/prebuilds/android-arm64/qvac__ocr-ggml/libqvac-ggml-cpu-android_armv8.6_1.so +0 -0
  16. package/prebuilds/android-arm64/qvac__ocr-ggml/libqvac-ggml-cpu-android_armv9.0_1.so +0 -0
  17. package/prebuilds/android-arm64/qvac__ocr-ggml/libqvac-ggml-cpu-android_armv9.2_1.so +0 -0
  18. package/prebuilds/android-arm64/qvac__ocr-ggml/libqvac-ggml-cpu-android_armv9.2_2.so +0 -0
  19. package/prebuilds/android-arm64/qvac__ocr-ggml.bare +0 -0
  20. package/prebuilds/darwin-arm64/qvac__ocr-ggml.bare +0 -0
  21. package/prebuilds/darwin-arm64/qvac__ocr-ggml.bare.exports +4277 -0
  22. package/prebuilds/darwin-x64/qvac__ocr-ggml.bare +0 -0
  23. package/prebuilds/darwin-x64/qvac__ocr-ggml.bare.exports +4322 -0
  24. package/prebuilds/ios-arm64/qvac__ocr-ggml.bare +0 -0
  25. package/prebuilds/ios-arm64/qvac__ocr-ggml.bare.exports +4262 -0
  26. package/prebuilds/ios-arm64-simulator/qvac__ocr-ggml.bare +0 -0
  27. package/prebuilds/ios-arm64-simulator/qvac__ocr-ggml.bare.exports +4262 -0
  28. package/prebuilds/ios-x64-simulator/qvac__ocr-ggml.bare +0 -0
  29. package/prebuilds/ios-x64-simulator/qvac__ocr-ggml.bare.exports +4311 -0
  30. package/prebuilds/linux-arm64/qvac__ocr-ggml.bare +0 -0
  31. package/prebuilds/linux-x64/qvac__ocr-ggml.bare +0 -0
  32. package/prebuilds/win32-x64/qvac__ocr-ggml.bare +0 -0
  33. package/prebuilds/win32-x64/qvac__ocr-ggml.bare.exports +0 -0
  34. package/test/integration/canvas-size.test.js +75 -0
  35. package/test/integration/doctr-basic.test.js +124 -0
  36. package/test/integration/doctr-clinical-chemistry.test.js +64 -0
  37. package/test/integration/doctr-ct-scan.test.js +66 -0
  38. package/test/integration/doctr-lab-results.test.js +65 -0
  39. package/test/integration/doctr-liver-function.test.js +67 -0
  40. package/test/integration/doctr-models.test.js +132 -0
  41. package/test/integration/doctr-param-validation.test.js +83 -0
  42. package/test/integration/error-handling.test.js +302 -0
  43. package/test/integration/full-coverage.test.js +292 -0
  44. package/test/integration/full-ocr-suite.test.js +83 -0
  45. package/test/integration/image-formats.test.js +168 -0
  46. package/test/integration/large-images.test.js +81 -0
  47. package/test/integration/lifecycle.test.js +269 -0
  48. package/test/integration/ocr-basic.test.js +70 -0
  49. package/test/integration/param-validation.test.js +86 -0
  50. package/test/integration/pipeline.test.js +61 -0
  51. package/test/integration/run-internal-ordering.test.js +75 -0
  52. package/test/integration/run-tests.sh +43 -0
  53. package/test/integration/run-with-exit.js +71 -0
  54. package/test/integration/utils.js +694 -0
  55. package/test/mobile/integration-runtime.cjs +71 -0
  56. package/test/mobile/integration.auto.cjs +102 -0
  57. package/test/mobile/test-groups.json +55 -0
@@ -0,0 +1,694 @@
1
+ 'use strict'
2
+
3
+ const fs = require('bare-fs')
4
+ const path = require('bare-path')
5
+ const os = require('bare-os')
6
+ const process = require('bare-process')
7
+
8
+ // Dynamic require via path.join prevents bare-pack from statically resolving
9
+ // these paths during mobile bundling (they live outside the addon package).
10
+ let createPerformanceReporter, evaluateQuality, findGroundTruth
11
+ const _scriptBase = path.join('..', '..', '..', '..', 'scripts', 'test-utils')
12
+ try {
13
+ const perfReporterMod = require(path.join(_scriptBase, 'performance-reporter'))
14
+ const qualityMetricsMod = require(path.join(_scriptBase, 'quality-metrics'))
15
+ perfReporterMod.configure({ fs, path, process, os })
16
+ qualityMetricsMod.configure({ fs, path })
17
+ createPerformanceReporter = perfReporterMod.createPerformanceReporter
18
+ evaluateQuality = qualityMetricsMod.evaluateQuality
19
+ findGroundTruth = qualityMetricsMod.findGroundTruth
20
+ } catch (_) {
21
+ // Mobile bundle — inline lightweight reporter that records metrics and
22
+ // can output the [PERF_REPORT_START]...[PERF_REPORT_END] markers to
23
+ // console so extract-from-log.js can capture them from Device Farm logs.
24
+ createPerformanceReporter = function (opts) {
25
+ const _results = []
26
+ const _startedAt = new Date().toISOString()
27
+ const _addon = (opts && opts.addon) || 'unknown'
28
+ const _addonType = (opts && opts.addonType) || 'generic'
29
+ const _device = {
30
+ name: platform,
31
+ platform,
32
+ os_version: '',
33
+ arch: os.arch ? os.arch() : '',
34
+ runner: 'device-farm'
35
+ }
36
+
37
+ return {
38
+ record (testName, metrics, extra) {
39
+ const entry = {
40
+ test: testName,
41
+ execution_provider: (extra && extra.execution_provider) || null,
42
+ metrics: Object.assign({
43
+ total_time_ms: null,
44
+ detection_time_ms: null,
45
+ recognition_time_ms: null,
46
+ text_regions: null
47
+ }, metrics),
48
+ input: (extra && extra.input) || null,
49
+ output: (extra && extra.output) || null,
50
+ quality: (extra && extra.quality) || undefined
51
+ }
52
+ if (extra && extra.image_path) entry.image_path = extra.image_path
53
+ _results.push(entry)
54
+ },
55
+ toJSON () {
56
+ return {
57
+ schema_version: '1.0',
58
+ addon: _addon,
59
+ addon_type: _addonType,
60
+ timestamp: _startedAt,
61
+ device: _device,
62
+ results: _results
63
+ }
64
+ },
65
+ writeReport () {
66
+ const json = JSON.stringify(this.toJSON())
67
+ let written = false
68
+ const dirs = []
69
+ if (global.testDir) dirs.push(global.testDir)
70
+ if (platform === 'android') {
71
+ dirs.push('/sdcard/Android/data/io.tether.test.qvac/files')
72
+ dirs.push('/storage/emulated/0/Android/data/io.tether.test.qvac/files')
73
+ dirs.push('/data/local/tmp')
74
+ }
75
+ dirs.push('/tmp')
76
+ for (let di = 0; di < dirs.length; di++) {
77
+ try {
78
+ try { fs.mkdirSync(dirs[di], { recursive: true }) } catch (_) {}
79
+ const p = path.join(dirs[di], 'perf-report.json')
80
+ fs.writeFileSync(p, json)
81
+ console.log('[PERF_REPORT_PATH]' + p)
82
+ written = true
83
+ } catch (e) {
84
+ console.log('[perf-reporter] write to ' + dirs[di] + ' failed: ' + e.message)
85
+ }
86
+ }
87
+ if (!written) {
88
+ console.log('[perf-reporter] all write locations failed')
89
+ }
90
+ },
91
+ writeStepSummary () {},
92
+ writeToConsole (opts) {
93
+ try {
94
+ const data = this.toJSON()
95
+ const lightweight = opts && opts.lightweight
96
+ data.results = data.results.map(function (r) {
97
+ let q = r.quality
98
+ if (lightweight && q) {
99
+ q = { cer: q.cer, wer: q.wer, word_recognition_rate: q.word_recognition_rate, keyword_detection_rate: q.keyword_detection_rate, key_value_accuracy: q.key_value_accuracy }
100
+ }
101
+ return { test: r.test, execution_provider: r.execution_provider, metrics: r.metrics, quality: q, image_path: r.image_path || null }
102
+ })
103
+ const json = JSON.stringify(data)
104
+ // Android logcat has per-entry size limits that vary by device.
105
+ // Use a conservative chunk size so header + content stays well
106
+ // under any limit, even with the ReactNativeJS wrapper overhead.
107
+ const CHUNK = 800
108
+ if (json.length <= CHUNK) {
109
+ console.log('[PERF_REPORT_START]' + json + '[PERF_REPORT_END]')
110
+ } else {
111
+ const id = Date.now().toString(36)
112
+ const n = Math.ceil(json.length / CHUNK)
113
+ for (let i = 0; i < n; i++) {
114
+ console.log('[PERF_CHUNK:' + id + ':' + i + ':' + n + ']' + json.substring(i * CHUNK, (i + 1) * CHUNK))
115
+ }
116
+ }
117
+ } catch (err) {
118
+ console.log('[perf-reporter] mobile console write failed: ' + err.message)
119
+ }
120
+ },
121
+ get length () { return _results.length }
122
+ }
123
+ }
124
+ // --- Inline quality metrics for mobile (pure computation, no external deps) ---
125
+
126
+ function _normalize (text) {
127
+ return String(text).replace(/\r\n/g, '\n').replace(/[\t\v\f]/g, ' ').replace(/ {2,}/g, ' ').trim().toLowerCase()
128
+ }
129
+
130
+ function _tokenize (text) {
131
+ return _normalize(text).split(/\s+/).filter(Boolean)
132
+ }
133
+
134
+ function _levenshtein (a, b) {
135
+ const m = a.length
136
+ const n = b.length
137
+ if (m === 0) return n
138
+ if (n === 0) return m
139
+ let prev = new Array(n + 1)
140
+ let curr = new Array(n + 1)
141
+ let j, i
142
+ for (j = 0; j <= n; j++) prev[j] = j
143
+ for (i = 1; i <= m; i++) {
144
+ curr[0] = i
145
+ for (j = 1; j <= n; j++) {
146
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1
147
+ curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost)
148
+ }
149
+ const tmp = prev; prev = curr; curr = tmp
150
+ }
151
+ return prev[n]
152
+ }
153
+
154
+ function _round4 (v) { return Math.round(v * 10000) / 10000 }
155
+
156
+ evaluateQuality = function (ocrTexts, groundTruth) {
157
+ if (!groundTruth) return null
158
+ const texts = Array.isArray(ocrTexts) ? ocrTexts : [String(ocrTexts)]
159
+ const joined = texts.join(' ')
160
+ const gt = groundTruth
161
+ const result = { ground_truth_id: gt.id || null, description: gt.description || null }
162
+
163
+ if (gt.reference_text) {
164
+ const hTokens = _tokenize(joined).sort()
165
+ const rTokens = _tokenize(gt.reference_text).sort()
166
+ const h = hTokens.join(' ')
167
+ const r = rTokens.join(' ')
168
+ result.cer = _round4(r.length === 0 ? (h.length === 0 ? 0 : 1) : _levenshtein(h, r) / r.length)
169
+ result.wer = _round4(rTokens.length === 0 ? (hTokens.length === 0 ? 0 : 1) : _levenshtein(hTokens, rTokens) / rTokens.length)
170
+
171
+ const ocrLower = joined.toLowerCase()
172
+ const uniqueRef = {}
173
+ for (let ri = 0; ri < rTokens.length; ri++) { uniqueRef[rTokens[ri]] = true }
174
+ const refList = Object.keys(uniqueRef)
175
+ let wrrMatched = 0
176
+ const wrrMissed = []
177
+ for (let wri = 0; wri < refList.length; wri++) {
178
+ if (ocrLower.indexOf(refList[wri]) >= 0) wrrMatched++
179
+ else wrrMissed.push(refList[wri])
180
+ }
181
+ result.word_recognition_rate = _round4(refList.length > 0 ? wrrMatched / refList.length : 1)
182
+ result.words_recognized = wrrMatched
183
+ result.words_total = refList.length
184
+ result.words_missed = wrrMissed
185
+ }
186
+
187
+ if (gt.required_keywords && gt.required_keywords.length > 0) {
188
+ const lower = joined.toLowerCase()
189
+ const wordSet = {}
190
+ const _words = lower.split(/\s+/)
191
+ for (let wi = 0; wi < _words.length; wi++) { if (_words[wi]) wordSet[_words[wi]] = true }
192
+ const found = []
193
+ const missing = []
194
+ for (let ki = 0; ki < gt.required_keywords.length; ki++) {
195
+ const kwTarget = gt.required_keywords[ki].toLowerCase()
196
+ let kwMatch = lower.includes(kwTarget)
197
+ if (!kwMatch) {
198
+ const kwParts = kwTarget.split(/\s+/)
199
+ kwMatch = true
200
+ for (let kp = 0; kp < kwParts.length; kp++) {
201
+ if (kwParts[kp] && !wordSet[kwParts[kp]]) { kwMatch = false; break }
202
+ }
203
+ }
204
+ if (kwMatch) found.push(gt.required_keywords[ki])
205
+ else missing.push(gt.required_keywords[ki])
206
+ }
207
+ result.keyword_detection_rate = _round4(found.length / gt.required_keywords.length)
208
+ result.keywords_found = found.length
209
+ result.keywords_total = gt.required_keywords.length
210
+ result.keywords_missing = missing
211
+ }
212
+
213
+ if (gt.key_values && gt.key_values.length > 0) {
214
+ const lowerKV = joined.toLowerCase()
215
+ const kvWordSet = {}
216
+ const _kvWords = lowerKV.split(/\s+/)
217
+ for (let wj = 0; wj < _kvWords.length; wj++) { if (_kvWords[wj]) kvWordSet[_kvWords[wj]] = true }
218
+ const matched = []
219
+ const unmatched = []
220
+ for (let vi = 0; vi < gt.key_values.length; vi++) {
221
+ const pair = gt.key_values[vi]
222
+ const kvKeyLower = pair.key.toLowerCase()
223
+ let keyFound = lowerKV.includes(kvKeyLower)
224
+ if (!keyFound) {
225
+ const keyParts = kvKeyLower.split(/\s+/)
226
+ keyFound = true
227
+ for (let kpi = 0; kpi < keyParts.length; kpi++) {
228
+ if (keyParts[kpi] && !kvWordSet[keyParts[kpi]]) { keyFound = false; break }
229
+ }
230
+ }
231
+ const valueFound = lowerKV.includes(String(pair.value).toLowerCase())
232
+ if (keyFound && valueFound) matched.push(pair)
233
+ else unmatched.push({ key: pair.key, value: pair.value, key_found: keyFound, value_found: valueFound })
234
+ }
235
+ result.key_value_accuracy = _round4(matched.length / gt.key_values.length)
236
+ result.key_values_matched = matched.length
237
+ result.key_values_total = gt.key_values.length
238
+ result.key_values_unmatched = unmatched
239
+ }
240
+
241
+ return result
242
+ }
243
+
244
+ findGroundTruth = function (imagePath) {
245
+ const base = path.basename(imagePath).replace(/\.[^.]+$/, '')
246
+ const gtFilename = base + '.quality.json'
247
+
248
+ // On mobile, look for ground truth in global.assetPaths
249
+ if (global.assetPaths) {
250
+ const assetKey = '../../testAssets/' + gtFilename
251
+ const gtPath = global.assetPaths[assetKey]
252
+ if (gtPath) {
253
+ try {
254
+ const raw = fs.readFileSync(gtPath.replace('file://', ''), 'utf-8')
255
+ return JSON.parse(raw)
256
+ } catch (e) {
257
+ console.log('[quality] failed to load mobile ground truth: ' + e.message)
258
+ }
259
+ }
260
+ }
261
+
262
+ // Fallback: look relative to imagePath (same logic as desktop)
263
+ const dir = path.dirname(imagePath)
264
+ const candidates = [
265
+ path.join(dir, gtFilename),
266
+ path.join(dir, '..', 'quality', gtFilename),
267
+ path.join(dir, 'quality', gtFilename)
268
+ ]
269
+ for (let ci = 0; ci < candidates.length; ci++) {
270
+ try {
271
+ let exists = false
272
+ try { fs.statSync(candidates[ci]); exists = true } catch (_) {}
273
+ if (exists) {
274
+ const data = fs.readFileSync(candidates[ci], 'utf-8')
275
+ return JSON.parse(data)
276
+ }
277
+ } catch (_) {}
278
+ }
279
+ return null
280
+ }
281
+ }
282
+
283
+ const platform = os.platform()
284
+ const isMobile = platform === 'ios' || platform === 'android'
285
+ const isWindows = platform === 'win32'
286
+
287
+ function _envInt (key, fallback) {
288
+ let raw = ''
289
+ if (typeof os.getEnv === 'function') raw = os.getEnv(key) || ''
290
+ if (!raw && process.env) raw = process.env[key] || ''
291
+ const v = parseInt(raw, 10)
292
+ return Number.isFinite(v) && v > 0 ? v : fallback
293
+ }
294
+
295
+ const PERF_RUNS = _envInt('QVAC_PERF_RUNS', 1)
296
+
297
+ // Singleton performance reporter — collects metrics across all OCR integration tests
298
+ const _perfReporter = createPerformanceReporter({
299
+ addon: 'ocr-ggml',
300
+ addonType: 'ocr'
301
+ })
302
+
303
+ const _reportPath = path.resolve('.', 'test/results/performance-report.json')
304
+ let _reportScheduled = false
305
+
306
+ function _flushPerfReport () {
307
+ if (_perfReporter.length > 0) {
308
+ _perfReporter.writeReport(_reportPath)
309
+ _perfReporter.writeToConsole()
310
+ }
311
+ }
312
+
313
+ function _scheduleReportWrite () {
314
+ if (_reportScheduled) return
315
+ _reportScheduled = true
316
+ process.on('exit', _flushPerfReport)
317
+ }
318
+
319
+ // Writable directory for downloaded models on mobile
320
+ const GGML_MODELS_DIR = isMobile
321
+ ? path.join(global.testDir || '/tmp', 'ggml-models')
322
+ : path.resolve('.', 'models')
323
+
324
+ // Mapping from original filename to renamed filename for mobile
325
+ // Files are renamed to avoid Android resource merger conflicts (same base name, different extension)
326
+ const mobileAssetMapping = {
327
+ 'basic_test.bmp': 'basic_test_bmp.bmp',
328
+ 'basic_test.jpg': 'basic_test_jpg.jpg',
329
+ 'basic_test.png': 'basic_test_png.png'
330
+ }
331
+
332
+ /**
333
+ * Get path to a test asset (image or config file) - works on both desktop and mobile
334
+ * @param {string} relativePath - Relative path from root (e.g., '/test/images/basic_test.bmp')
335
+ * @returns {string} Full path to the file
336
+ */
337
+ function getImagePath (relativePath) {
338
+ if (isMobile && global.assetPaths) {
339
+ const originalFilename = path.basename(relativePath)
340
+ // Use renamed filename if mapping exists, otherwise use original
341
+ const filename = mobileAssetMapping[originalFilename] || originalFilename
342
+ const projectPath = `../../testAssets/${filename}`
343
+
344
+ if (global.assetPaths[projectPath]) {
345
+ return global.assetPaths[projectPath].replace('file://', '')
346
+ }
347
+ throw new Error(`Asset not found in testAssets: ${filename} (original: ${originalFilename})`)
348
+ }
349
+
350
+ return path.resolve('.') + relativePath
351
+ }
352
+
353
+ /**
354
+ * Downloads a file from a URL using bare-fetch
355
+ * @param {string} url - URL to download from
356
+ * @param {string} destPath - Destination file path
357
+ */
358
+ async function downloadFile (url, destPath) {
359
+ const fetch = require('bare-fetch')
360
+ console.log(` Downloading: ${url.substring(0, 60)}...`)
361
+
362
+ const response = await fetch(url)
363
+
364
+ if (!response.ok) {
365
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`)
366
+ }
367
+
368
+ const buffer = await response.arrayBuffer()
369
+ fs.writeFileSync(destPath, Buffer.from(buffer))
370
+ console.log(` Downloaded: ${path.basename(destPath)}`)
371
+ }
372
+
373
+ /**
374
+ * Loads the ocr-ggml-model-urls.json config on mobile.
375
+ * Checks global.assetPaths first, then falls back to known filesystem paths.
376
+ * @returns {Object|null} Parsed URL config or null if not found
377
+ */
378
+ function _loadMobileUrlConfig () {
379
+ let urlConfig = null
380
+ if (global.assetPaths) {
381
+ const configPath = global.assetPaths['../../testAssets/ocr-ggml-model-urls.json']
382
+ if (configPath) {
383
+ try {
384
+ urlConfig = JSON.parse(fs.readFileSync(configPath.replace('file://', ''), 'utf8'))
385
+ } catch (_) {}
386
+ }
387
+ }
388
+ if (!urlConfig) {
389
+ for (const p of ['../../testAssets/ocr-ggml-model-urls.json', '../testAssets/ocr-ggml-model-urls.json']) {
390
+ if (fs.existsSync(p)) {
391
+ try { urlConfig = JSON.parse(fs.readFileSync(p, 'utf8')); break } catch (_) {}
392
+ }
393
+ }
394
+ }
395
+ return urlConfig
396
+ }
397
+
398
+ /**
399
+ * Ensures an EasyOCR GGUF model is available and returns its path.
400
+ * On desktop: uses env vars (OCR_GGML_DETECTOR / OCR_GGML_RECOGNIZER) or defaults.
401
+ * On mobile: downloads from presigned URLs in ocr-ggml-model-urls.json.
402
+ *
403
+ * @param {string} modelName - 'detector_craft' or 'recognizer_latin'
404
+ * @returns {Promise<string>} Path to the model file
405
+ */
406
+ async function ensureModelPath (modelName) {
407
+ const desktopDefaults = {
408
+ detector_craft: process.env.OCR_GGML_DETECTOR || 'models/craft_mlt_25k.gguf',
409
+ recognizer_latin: process.env.OCR_GGML_RECOGNIZER || 'models/latin_g2.gguf'
410
+ }
411
+
412
+ if (!isMobile) {
413
+ const modelPath = desktopDefaults[modelName] || `models/${modelName}.gguf`
414
+ if (!fs.existsSync(modelPath)) {
415
+ console.log(`Warning: Model not found at ${modelPath}`)
416
+ }
417
+ return modelPath
418
+ }
419
+
420
+ const mobileFilenames = {
421
+ detector_craft: 'craft_mlt_25k.gguf',
422
+ recognizer_latin: 'latin_g2.gguf'
423
+ }
424
+ const mobileUrlKeys = {
425
+ detector_craft: 'craft_mlt_25k_url',
426
+ recognizer_latin: 'latin_g2_url'
427
+ }
428
+
429
+ const filename = mobileFilenames[modelName]
430
+ const urlKey = mobileUrlKeys[modelName]
431
+ if (!filename) throw new Error(`Unknown model name for mobile: ${modelName}`)
432
+
433
+ const destPath = path.join(GGML_MODELS_DIR, filename)
434
+ if (fs.existsSync(destPath)) {
435
+ console.log(` Model cached: ${filename}`)
436
+ return destPath
437
+ }
438
+
439
+ const urlConfig = _loadMobileUrlConfig()
440
+ if (!urlConfig || !urlConfig[urlKey]) {
441
+ throw new Error(`No presigned URL found for model: ${modelName} (key: ${urlKey})`)
442
+ }
443
+
444
+ fs.mkdirSync(GGML_MODELS_DIR, { recursive: true })
445
+ const maxAttempts = 5
446
+ let lastError
447
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
448
+ try {
449
+ await downloadFile(urlConfig[urlKey], destPath)
450
+ return destPath
451
+ } catch (e) {
452
+ lastError = e
453
+ if (attempt < maxAttempts) {
454
+ const delayMs = attempt * 10000
455
+ console.log(` Attempt ${attempt}/${maxAttempts} failed: ${e.message}. Retrying in ${delayMs / 1000}s...`)
456
+ await new Promise(resolve => setTimeout(resolve, delayMs))
457
+ }
458
+ }
459
+ }
460
+ throw lastError
461
+ }
462
+
463
+ /**
464
+ * Ensures DocTR GGUF models are available and returns their paths.
465
+ * On desktop: uses env vars (OCR_GGML_DOCTR_DETECTOR / OCR_GGML_DOCTR_RECOGNIZER) or defaults.
466
+ * On mobile: downloads from presigned URLs in ocr-ggml-model-urls.json.
467
+ * Returns null on mobile if downloads fail (Device Farm connectivity issues).
468
+ *
469
+ * @returns {Promise<{db_mobilenet_v3_large: string, crnn_mobilenet_v3_small: string}|null>}
470
+ */
471
+ async function ensureDoctrModels () {
472
+ if (!isMobile) {
473
+ return {
474
+ db_mobilenet_v3_large: process.env.OCR_GGML_DOCTR_DETECTOR || 'models/db_mobilenet_v3_large.gguf',
475
+ crnn_mobilenet_v3_small: process.env.OCR_GGML_DOCTR_RECOGNIZER || 'models/crnn_mobilenet_v3_small.gguf'
476
+ }
477
+ }
478
+
479
+ const mobileModels = {
480
+ db_mobilenet_v3_large: { filename: 'db_mobilenet_v3_large.gguf', urlKey: 'db_mobilenet_v3_large_url' },
481
+ crnn_mobilenet_v3_small: { filename: 'crnn_mobilenet_v3_small.gguf', urlKey: 'crnn_mobilenet_v3_small_url' }
482
+ }
483
+
484
+ const urlConfig = _loadMobileUrlConfig()
485
+ fs.mkdirSync(GGML_MODELS_DIR, { recursive: true })
486
+
487
+ const paths = {}
488
+ for (const [key, { filename, urlKey }] of Object.entries(mobileModels)) {
489
+ const destPath = path.join(GGML_MODELS_DIR, filename)
490
+ if (fs.existsSync(destPath)) {
491
+ paths[key] = destPath
492
+ continue
493
+ }
494
+ if (!urlConfig || !urlConfig[urlKey]) {
495
+ console.log(`[ensureDoctrModels] No URL for ${filename} — DocTR tests will be skipped`)
496
+ return null
497
+ }
498
+ const maxAttempts = 5
499
+ let downloaded = false
500
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
501
+ try {
502
+ await downloadFile(urlConfig[urlKey], destPath)
503
+ downloaded = true
504
+ break
505
+ } catch (e) {
506
+ if (attempt < maxAttempts) {
507
+ const delayMs = attempt * 10000
508
+ console.log(` Attempt ${attempt}/${maxAttempts} failed: ${e.message}. Retrying in ${delayMs / 1000}s...`)
509
+ await new Promise(resolve => setTimeout(resolve, delayMs))
510
+ } else {
511
+ console.log(`[ensureDoctrModels] Failed to download ${filename}: ${e.message}`)
512
+ console.log('[ensureDoctrModels] Returning null — DocTR tests will be skipped on this device')
513
+ return null
514
+ }
515
+ }
516
+ }
517
+ if (downloaded) paths[key] = destPath
518
+ }
519
+ return paths
520
+ }
521
+
522
+ /**
523
+ * Formats OCR performance metrics for test output.
524
+ *
525
+ * @param {string} label - Test label prefix (e.g., '[OCR] [GPU]')
526
+ * @param {Object} stats - Stats object from response.stats
527
+ * @param {Array} outputTexts - Array of detected texts
528
+ * @param {Object} [opts] - Optional settings
529
+ * @param {string} [opts.imagePath] - Path to the source image (triggers quality evaluation)
530
+ * @param {Object} [opts.groundTruth] - Explicit ground truth (overrides auto-discovery)
531
+ * @returns {string} Formatted performance metrics string
532
+ */
533
+ function formatOCRPerformanceMetrics (label, stats, outputTexts = [], opts) {
534
+ const totalTimeMs = stats.totalTime ? stats.totalTime * 1000 : 0
535
+ const detectionTimeMs = stats.detectionTime ? stats.detectionTime * 1000 : 0
536
+ const recognitionTimeMs = stats.recognitionTime ? stats.recognitionTime * 1000 : 0
537
+ const textRegionsCount = stats.textRegionsCount || 0
538
+ const totalSeconds = (totalTimeMs / 1000).toFixed(2)
539
+
540
+ const device = /\[gpu\]/i.test(label) ? 'gpu' : /\[cpu\]/i.test(label) ? 'cpu' : null
541
+
542
+ let quality = null
543
+ const gt = (opts && opts.groundTruth) || (opts && opts.imagePath ? findGroundTruth(opts.imagePath) : null)
544
+ if (gt && outputTexts.length > 0) {
545
+ try {
546
+ quality = evaluateQuality(outputTexts, gt)
547
+ } catch (err) {
548
+ console.log(`[quality] evaluation failed: ${err.message}`)
549
+ }
550
+ }
551
+
552
+ if (!(opts && opts.skipReport)) {
553
+ _perfReporter.record(label, {
554
+ total_time_ms: Math.round(totalTimeMs),
555
+ detection_time_ms: Math.round(detectionTimeMs),
556
+ recognition_time_ms: Math.round(recognitionTimeMs),
557
+ text_regions: textRegionsCount
558
+ }, {
559
+ execution_provider: device,
560
+ output: JSON.stringify(outputTexts),
561
+ quality,
562
+ image_path: (opts && opts.imagePath) || null
563
+ })
564
+ _scheduleReportWrite()
565
+
566
+ if (isMobile) {
567
+ _perfReporter.writeReport()
568
+ const isCheckpoint = _perfReporter.length % 6 === 0
569
+ _perfReporter.writeToConsole({ lightweight: !isCheckpoint })
570
+ }
571
+ }
572
+
573
+ let out = `${label} Performance Metrics:
574
+ - Total time: ${totalTimeMs.toFixed(0)}ms (${totalSeconds}s)
575
+ - Detection time: ${detectionTimeMs.toFixed(0)}ms
576
+ - Recognition time: ${recognitionTimeMs.toFixed(0)}ms
577
+ - Text regions detected: ${textRegionsCount}
578
+ - Detected texts: ${JSON.stringify(outputTexts)}`
579
+
580
+ if (quality) {
581
+ out += '\n --- Quality ---'
582
+ if (quality.cer !== undefined) out += `\n - CER: ${(quality.cer * 100).toFixed(1)}%`
583
+ if (quality.wer !== undefined) out += `\n - WER: ${(quality.wer * 100).toFixed(1)}%`
584
+ if (quality.word_recognition_rate !== undefined) {
585
+ out += `\n - Word Recognition: ${quality.words_recognized}/${quality.words_total} (${(quality.word_recognition_rate * 100).toFixed(1)}%)`
586
+ }
587
+ if (quality.keyword_detection_rate !== undefined) {
588
+ out += `\n - Keywords: ${quality.keywords_found}/${quality.keywords_total} (${(quality.keyword_detection_rate * 100).toFixed(1)}%)`
589
+ }
590
+ if (quality.key_value_accuracy !== undefined) {
591
+ out += `\n - KV Accuracy: ${quality.key_values_matched}/${quality.key_values_total} (${(quality.key_value_accuracy * 100).toFixed(1)}%)`
592
+ }
593
+ if (quality.keywords_missing && quality.keywords_missing.length > 0) {
594
+ out += `\n - Missing keywords: ${JSON.stringify(quality.keywords_missing)}`
595
+ }
596
+ if (quality.key_values_unmatched && quality.key_values_unmatched.length > 0) {
597
+ const unmatchedKeys = quality.key_values_unmatched.map(u => u.key)
598
+ out += `\n - Unmatched KV keys: ${JSON.stringify(unmatchedKeys)}`
599
+ }
600
+ }
601
+
602
+ return out
603
+ }
604
+
605
+ /**
606
+ * Safely unloads an OCR instance with a timeout to prevent hangs.
607
+ *
608
+ * @param {Object} ocrInstance - The OcrGgml instance to unload
609
+ * @param {number} [timeoutMs=10000] - Max time to wait for unload
610
+ * @returns {Promise<void>}
611
+ */
612
+ async function safeUnload (ocrInstance, timeoutMs = 10000) {
613
+ try {
614
+ let timeoutId
615
+ const unloadPromise = ocrInstance.unload()
616
+ const timeoutPromise = new Promise((resolve) => {
617
+ timeoutId = setTimeout(() => {
618
+ console.log('Warning: unload() did not complete within ' + timeoutMs + 'ms, continuing...')
619
+ resolve()
620
+ }, timeoutMs)
621
+ })
622
+ await Promise.race([unloadPromise, timeoutPromise])
623
+ clearTimeout(timeoutId)
624
+ } catch (e) {
625
+ console.log('unload() error: ' + e.message)
626
+ }
627
+ }
628
+
629
+ /**
630
+ * Helper to run a single DocTR OCR pass using the GGML backend and return results.
631
+ * @param {Object} t - brittle test handle
632
+ * @param {Object} params - OCR params (pathDetector, pathRecognizer, etc.)
633
+ * @param {string} imagePath - Path to the image file
634
+ * @returns {Promise<{results: Array, stats: Object}>}
635
+ */
636
+ async function runDoctrOCR (t, params, imagePath) {
637
+ const { OcrGgml } = require('../..')
638
+
639
+ const ocrGgml = new OcrGgml({
640
+ params: {
641
+ langList: ['en'],
642
+ pipelineType: 'doctr',
643
+ nThreads: 4,
644
+ ...params
645
+ },
646
+ opts: { stats: true }
647
+ })
648
+
649
+ await ocrGgml.load()
650
+ console.log('[runDoctrOCR] loaded, starting run...')
651
+
652
+ try {
653
+ const response = await ocrGgml.run({
654
+ path: imagePath,
655
+ options: { paragraph: false }
656
+ })
657
+ console.log('[runDoctrOCR] run() returned, awaiting results...')
658
+
659
+ let results = []
660
+
661
+ await response
662
+ .onUpdate(output => {
663
+ t.ok(Array.isArray(output), 'output should be an array')
664
+ console.log('[runDoctrOCR] onUpdate: got ' + output.length + ' items')
665
+ results = output.map(o => ({ text: o[1], confidence: o[2], bbox: o[0] }))
666
+ console.log('[runDoctrOCR] onUpdate: mapped ' + results.length + ' results')
667
+ })
668
+ .onError(error => {
669
+ t.fail('unexpected error: ' + JSON.stringify(error))
670
+ })
671
+ .await()
672
+
673
+ console.log('[runDoctrOCR] await() completed, returning results')
674
+ return { results, stats: response.stats || {} }
675
+ } finally {
676
+ await safeUnload(ocrGgml)
677
+ await new Promise(resolve => setTimeout(resolve, 2000))
678
+ }
679
+ }
680
+
681
+ module.exports = {
682
+ isMobile,
683
+ isWindows,
684
+ platform,
685
+ PERF_RUNS,
686
+ getImagePath,
687
+ ensureModelPath,
688
+ ensureDoctrModels,
689
+ GGML_MODELS_DIR,
690
+ formatOCRPerformanceMetrics,
691
+ safeUnload,
692
+ runDoctrOCR,
693
+ flushPerfReport: _flushPerfReport
694
+ }