@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.
- package/LICENSE +179 -0
- package/NOTICE +63 -0
- package/README.md +330 -0
- package/addonLogging.d.ts +7 -0
- package/addonLogging.js +4 -0
- package/binding.js +21 -0
- package/index.d.ts +145 -0
- package/index.js +383 -0
- package/lib/error.js +80 -0
- package/ocr-ggml.js +121 -0
- package/package.json +96 -7
- package/prebuilds/android-arm64/qvac__ocr-ggml/libqvac-ggml-cpu-android_armv8.0_1.so +0 -0
- package/prebuilds/android-arm64/qvac__ocr-ggml/libqvac-ggml-cpu-android_armv8.2_1.so +0 -0
- package/prebuilds/android-arm64/qvac__ocr-ggml/libqvac-ggml-cpu-android_armv8.2_2.so +0 -0
- package/prebuilds/android-arm64/qvac__ocr-ggml/libqvac-ggml-cpu-android_armv8.6_1.so +0 -0
- package/prebuilds/android-arm64/qvac__ocr-ggml/libqvac-ggml-cpu-android_armv9.0_1.so +0 -0
- package/prebuilds/android-arm64/qvac__ocr-ggml/libqvac-ggml-cpu-android_armv9.2_1.so +0 -0
- package/prebuilds/android-arm64/qvac__ocr-ggml/libqvac-ggml-cpu-android_armv9.2_2.so +0 -0
- package/prebuilds/android-arm64/qvac__ocr-ggml.bare +0 -0
- package/prebuilds/darwin-arm64/qvac__ocr-ggml.bare +0 -0
- package/prebuilds/darwin-arm64/qvac__ocr-ggml.bare.exports +4277 -0
- package/prebuilds/darwin-x64/qvac__ocr-ggml.bare +0 -0
- package/prebuilds/darwin-x64/qvac__ocr-ggml.bare.exports +4322 -0
- package/prebuilds/ios-arm64/qvac__ocr-ggml.bare +0 -0
- package/prebuilds/ios-arm64/qvac__ocr-ggml.bare.exports +4262 -0
- package/prebuilds/ios-arm64-simulator/qvac__ocr-ggml.bare +0 -0
- package/prebuilds/ios-arm64-simulator/qvac__ocr-ggml.bare.exports +4262 -0
- package/prebuilds/ios-x64-simulator/qvac__ocr-ggml.bare +0 -0
- package/prebuilds/ios-x64-simulator/qvac__ocr-ggml.bare.exports +4311 -0
- package/prebuilds/linux-arm64/qvac__ocr-ggml.bare +0 -0
- package/prebuilds/linux-x64/qvac__ocr-ggml.bare +0 -0
- package/prebuilds/win32-x64/qvac__ocr-ggml.bare +0 -0
- package/prebuilds/win32-x64/qvac__ocr-ggml.bare.exports +0 -0
- package/test/integration/canvas-size.test.js +75 -0
- package/test/integration/doctr-basic.test.js +124 -0
- package/test/integration/doctr-clinical-chemistry.test.js +64 -0
- package/test/integration/doctr-ct-scan.test.js +66 -0
- package/test/integration/doctr-lab-results.test.js +65 -0
- package/test/integration/doctr-liver-function.test.js +67 -0
- package/test/integration/doctr-models.test.js +132 -0
- package/test/integration/doctr-param-validation.test.js +83 -0
- package/test/integration/error-handling.test.js +302 -0
- package/test/integration/full-coverage.test.js +292 -0
- package/test/integration/full-ocr-suite.test.js +83 -0
- package/test/integration/image-formats.test.js +168 -0
- package/test/integration/large-images.test.js +81 -0
- package/test/integration/lifecycle.test.js +269 -0
- package/test/integration/ocr-basic.test.js +70 -0
- package/test/integration/param-validation.test.js +86 -0
- package/test/integration/pipeline.test.js +61 -0
- package/test/integration/run-internal-ordering.test.js +75 -0
- package/test/integration/run-tests.sh +43 -0
- package/test/integration/run-with-exit.js +71 -0
- package/test/integration/utils.js +694 -0
- package/test/mobile/integration-runtime.cjs +71 -0
- package/test/mobile/integration.auto.cjs +102 -0
- 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
|
+
}
|