@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
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { OcrGgml } = require('../..')
|
|
4
|
+
const test = require('brittle')
|
|
5
|
+
const { isMobile, getImagePath, ensureModelPath } = require('./utils')
|
|
6
|
+
|
|
7
|
+
// 10 minutes: the dense page is heavy on slow CI runners.
|
|
8
|
+
const TEST_TIMEOUT = 600 * 1000
|
|
9
|
+
|
|
10
|
+
// Regression for QVAC-19340: dense high-resolution pages drove CRAFT detection
|
|
11
|
+
// peak memory to ~13 GB (canvas capped at the 2560 default), OOM-killing the
|
|
12
|
+
// host on memory-constrained Android devices. `canvasSize` caps the detection
|
|
13
|
+
// canvas (EasyOCR's `canvas_size`) so callers can bound peak memory. A smaller
|
|
14
|
+
// canvas must still configure, run, and return text on a dense page.
|
|
15
|
+
// Desktop-only: this regression deliberately drives a dense page through the
|
|
16
|
+
// detector to exercise the canvas cap. Even at canvasSize=1280 the CRAFT peak
|
|
17
|
+
// (~3.6 GB) exceeds iOS/Android jetsam limits on Device Farm phones, so running
|
|
18
|
+
// it there would re-trigger the very OOM it guards against. The cap behaviour
|
|
19
|
+
// is deterministic and fully validated on desktop CI.
|
|
20
|
+
test('canvasSize bounds the detection canvas and still recognizes a dense page', { timeout: TEST_TIMEOUT, skip: isMobile }, async function (t) {
|
|
21
|
+
const detectorPath = await ensureModelPath('detector_craft')
|
|
22
|
+
const recognizerPath = await ensureModelPath('recognizer_latin')
|
|
23
|
+
|
|
24
|
+
// lab_results.png is a dense 1414x2000 page (~100+ text regions): the fixture
|
|
25
|
+
// that triggered the original Android OOM.
|
|
26
|
+
const imagePath = getImagePath('/test/images/lab_results.png')
|
|
27
|
+
|
|
28
|
+
const ocrGgml = new OcrGgml({
|
|
29
|
+
params: {
|
|
30
|
+
pathDetector: detectorPath,
|
|
31
|
+
pathRecognizer: recognizerPath,
|
|
32
|
+
langList: ['en'],
|
|
33
|
+
canvasSize: 1280
|
|
34
|
+
},
|
|
35
|
+
opts: { stats: true }
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
await ocrGgml.load()
|
|
39
|
+
t.pass('Loaded with reduced canvasSize=1280')
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
// Disable rotation retry: it triples recognition work on this dense page
|
|
43
|
+
// (each box re-run at 90/270) without adding value to the memory-cap
|
|
44
|
+
// assertion, and would otherwise blow the desktop test budget on slow CI.
|
|
45
|
+
const response = await ocrGgml.run({
|
|
46
|
+
path: imagePath,
|
|
47
|
+
options: { paragraph: false, rotationAngles: [] }
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
let texts = []
|
|
51
|
+
await response
|
|
52
|
+
.onUpdate(output => {
|
|
53
|
+
t.ok(Array.isArray(output), 'output should be an array')
|
|
54
|
+
t.ok(output.length > 0, 'dense page should still produce text regions with a smaller canvas')
|
|
55
|
+
texts = output.map(o => String(o[1]).toLowerCase())
|
|
56
|
+
t.comment('Detected ' + output.length + ' regions (canvasSize=1280)')
|
|
57
|
+
})
|
|
58
|
+
.onError(error => {
|
|
59
|
+
t.fail('unexpected error: ' + JSON.stringify(error))
|
|
60
|
+
})
|
|
61
|
+
.await()
|
|
62
|
+
|
|
63
|
+
// Recognition content varies slightly with canvas size; assert that at
|
|
64
|
+
// least one stable keyword from this lab report is still recognized.
|
|
65
|
+
const expected = ['medivista', 'hospital', 'clinical', 'biochemistry', 'patient']
|
|
66
|
+
const matched = expected.filter(w => texts.some(line => line.includes(w)))
|
|
67
|
+
t.comment('Matched keywords: ' + JSON.stringify(matched))
|
|
68
|
+
t.ok(matched.length > 0, 'should still recognize at least one expected keyword with a reduced canvas')
|
|
69
|
+
|
|
70
|
+
t.pass('canvasSize regression test completed successfully')
|
|
71
|
+
} finally {
|
|
72
|
+
await ocrGgml.unload()
|
|
73
|
+
await new Promise(resolve => setTimeout(resolve, 1000))
|
|
74
|
+
}
|
|
75
|
+
})
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const test = require('brittle')
|
|
4
|
+
const { getImagePath, formatOCRPerformanceMetrics, ensureDoctrModels, runDoctrOCR } = require('./utils')
|
|
5
|
+
|
|
6
|
+
const TEST_TIMEOUT = 300 * 1000
|
|
7
|
+
|
|
8
|
+
// Words from english.bmp (WHO coronavirus infographic). At least 7 of 10 must be recognized
|
|
9
|
+
// to catch OCR accuracy regressions without being overly strict.
|
|
10
|
+
const ENGLISH_RECOGNITION_WORDS = [
|
|
11
|
+
'health', 'world', 'cook', 'soap', 'water', 'hands', 'reduce', 'risk', 'avoid', 'symptoms'
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Assert at least minMatch of expectedWords appear in recognition results (substring match).
|
|
16
|
+
* Catches accuracy regressions while tolerating minor OCR variation.
|
|
17
|
+
*/
|
|
18
|
+
function assertRecognitionAccuracy (t, texts, expectedWords, minMatch, label) {
|
|
19
|
+
const lowerTexts = texts.map(w => w.toLowerCase())
|
|
20
|
+
const found = expectedWords.filter(word =>
|
|
21
|
+
lowerTexts.some(txt => txt.includes(word.toLowerCase()))
|
|
22
|
+
)
|
|
23
|
+
t.ok(
|
|
24
|
+
found.length >= minMatch,
|
|
25
|
+
`${label}: at least ${minMatch}/${expectedWords.length} words recognized (got ${found.length}: ${JSON.stringify(found)})`
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let DB_MOBILENET
|
|
30
|
+
let CRNN_MOBILENET
|
|
31
|
+
|
|
32
|
+
test('DocTR basic - download models', { timeout: TEST_TIMEOUT }, async function (t) {
|
|
33
|
+
const models = await ensureDoctrModels()
|
|
34
|
+
DB_MOBILENET = models.db_mobilenet_v3_large
|
|
35
|
+
CRNN_MOBILENET = models.crnn_mobilenet_v3_small
|
|
36
|
+
t.ok(DB_MOBILENET, 'db_mobilenet model available')
|
|
37
|
+
t.ok(CRNN_MOBILENET, 'crnn_mobilenet model available')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('DocTR basic - BMP image', { timeout: TEST_TIMEOUT }, async function (t) {
|
|
41
|
+
const imagePath = getImagePath('/test/images/basic_test.bmp')
|
|
42
|
+
t.comment('Detector: ' + DB_MOBILENET)
|
|
43
|
+
t.comment('Recognizer: ' + CRNN_MOBILENET)
|
|
44
|
+
|
|
45
|
+
const params = {
|
|
46
|
+
pathDetector: DB_MOBILENET,
|
|
47
|
+
pathRecognizer: CRNN_MOBILENET
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const { results, stats } = await runDoctrOCR(t, params, imagePath)
|
|
51
|
+
|
|
52
|
+
const outputTexts = results.map(r => r.text)
|
|
53
|
+
t.ok(results.length > 0, `BMP: should detect text regions, got ${results.length}`)
|
|
54
|
+
// DocTR on basic_test: only "normal" (horizontal) is reliably detected across CI (Linux, Windows, macOS);
|
|
55
|
+
// tilted/vertical vary by platform and DocTR lacks per-crop rotation handling (unlike EasyOCR).
|
|
56
|
+
t.ok(outputTexts.some(w => w.toLowerCase().includes('normal')), 'BMP should detect "normal"')
|
|
57
|
+
t.comment('BMP detected texts: ' + JSON.stringify(outputTexts))
|
|
58
|
+
t.comment(formatOCRPerformanceMetrics('[DocTR BMP]', stats, outputTexts, { skipReport: true }))
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('DocTR basic - JPEG image', { timeout: TEST_TIMEOUT }, async function (t) {
|
|
62
|
+
const imagePath = getImagePath('/test/images/basic_test.jpg')
|
|
63
|
+
|
|
64
|
+
const params = {
|
|
65
|
+
pathDetector: DB_MOBILENET,
|
|
66
|
+
pathRecognizer: CRNN_MOBILENET
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const { results, stats } = await runDoctrOCR(t, params, imagePath)
|
|
70
|
+
|
|
71
|
+
const outputTexts = results.map(r => r.text)
|
|
72
|
+
t.ok(results.length > 0, `JPEG: should detect text regions, got ${results.length}`)
|
|
73
|
+
t.ok(outputTexts.some(w => w.toLowerCase().includes('normal')), 'JPEG should detect "normal"')
|
|
74
|
+
t.comment('JPEG detected texts: ' + JSON.stringify(outputTexts))
|
|
75
|
+
t.comment(formatOCRPerformanceMetrics('[DocTR JPEG]', stats, outputTexts, { skipReport: true }))
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('DocTR basic - PNG image', { timeout: TEST_TIMEOUT }, async function (t) {
|
|
79
|
+
const imagePath = getImagePath('/test/images/basic_test.png')
|
|
80
|
+
|
|
81
|
+
const params = {
|
|
82
|
+
pathDetector: DB_MOBILENET,
|
|
83
|
+
pathRecognizer: CRNN_MOBILENET
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const { results, stats } = await runDoctrOCR(t, params, imagePath)
|
|
87
|
+
|
|
88
|
+
const outputTexts = results.map(r => r.text)
|
|
89
|
+
t.ok(results.length > 0, `PNG: should detect text regions, got ${results.length}`)
|
|
90
|
+
t.ok(outputTexts.some(w => w.toLowerCase().includes('normal')), 'PNG should detect "normal"')
|
|
91
|
+
t.comment('PNG detected texts: ' + JSON.stringify(outputTexts))
|
|
92
|
+
t.comment(formatOCRPerformanceMetrics('[DocTR PNG]', stats, outputTexts, { skipReport: true }))
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('DocTR basic - English image', { timeout: TEST_TIMEOUT }, async function (t) {
|
|
96
|
+
const imagePath = getImagePath('/test/images/english.bmp')
|
|
97
|
+
|
|
98
|
+
const params = {
|
|
99
|
+
pathDetector: DB_MOBILENET,
|
|
100
|
+
pathRecognizer: CRNN_MOBILENET
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const { results, stats } = await runDoctrOCR(t, params, imagePath)
|
|
104
|
+
|
|
105
|
+
const outputTexts = results.map(r => r.text)
|
|
106
|
+
t.ok(results.length > 0, `English: should detect text regions, got ${results.length}`)
|
|
107
|
+
|
|
108
|
+
// Recognition accuracy: at least 7 of 10 expected words to catch OCR regressions
|
|
109
|
+
assertRecognitionAccuracy(t, outputTexts, ENGLISH_RECOGNITION_WORDS, 7, 'English')
|
|
110
|
+
|
|
111
|
+
// english.bmp is 905x480 — verify coordinates are in original image space
|
|
112
|
+
let coordsInBounds = true
|
|
113
|
+
for (const r of results) {
|
|
114
|
+
for (const point of r.bbox) {
|
|
115
|
+
if (point[0] < 0 || point[0] > 905 || point[1] < 0 || point[1] > 480) {
|
|
116
|
+
coordsInBounds = false
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
t.ok(coordsInBounds, 'All bbox coordinates within image bounds (905x480)')
|
|
121
|
+
|
|
122
|
+
t.comment('English detected texts: ' + JSON.stringify(outputTexts))
|
|
123
|
+
t.comment(formatOCRPerformanceMetrics('[DocTR English]', stats, outputTexts, { skipReport: true }))
|
|
124
|
+
})
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const test = require('brittle')
|
|
4
|
+
const { getImagePath, formatOCRPerformanceMetrics, runDoctrOCR, ensureDoctrModels, PERF_RUNS } = require('./utils')
|
|
5
|
+
|
|
6
|
+
const DOCTR_TEST_TIMEOUT = 180 * 1000
|
|
7
|
+
|
|
8
|
+
let DB_MOBILENET
|
|
9
|
+
let CRNN_MOBILENET
|
|
10
|
+
let modelsAvailable = false
|
|
11
|
+
|
|
12
|
+
test('DocTR clinical chemistry - download models', { timeout: DOCTR_TEST_TIMEOUT }, async function (t) {
|
|
13
|
+
const models = await ensureDoctrModels()
|
|
14
|
+
if (!models) {
|
|
15
|
+
t.comment('DocTR models unavailable (download failed) — remaining tests will be skipped')
|
|
16
|
+
return
|
|
17
|
+
}
|
|
18
|
+
DB_MOBILENET = models.db_mobilenet_v3_large
|
|
19
|
+
CRNN_MOBILENET = models.crnn_mobilenet_v3_small
|
|
20
|
+
modelsAvailable = true
|
|
21
|
+
t.ok(DB_MOBILENET, 'db_mobilenet model available')
|
|
22
|
+
t.ok(CRNN_MOBILENET, 'crnn_mobilenet model available')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
const EXPECTED_WORDS = [
|
|
26
|
+
'clinical', 'chemistry', 'alkaline', 'phosphatase',
|
|
27
|
+
'hemoglobin', 'creatinine', 'cholesterol', 'triglycerides',
|
|
28
|
+
'bilirubin', 'albumin', 'protein', 'lipid'
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
function runClinicalChemistryTest (device, run) {
|
|
32
|
+
const tag = device.toUpperCase()
|
|
33
|
+
|
|
34
|
+
test(`DocTR clinical chemistry [${tag}] run ${run} - db_mobilenet + crnn_mobilenet`, { timeout: DOCTR_TEST_TIMEOUT }, async function (t) {
|
|
35
|
+
if (!modelsAvailable) { t.comment('Skipped — models unavailable'); return }
|
|
36
|
+
const imagePath = getImagePath('/test/images/clinical_chemistry.png')
|
|
37
|
+
|
|
38
|
+
t.comment(`Testing DocTR on clinical chemistry lab result image [${tag}] (run ${run}/${PERF_RUNS})`)
|
|
39
|
+
t.comment('Detector: db_mobilenet_v3_large, Recognizer: crnn_mobilenet_v3_small (CTC)')
|
|
40
|
+
|
|
41
|
+
const { results, stats } = await runDoctrOCR(t, {
|
|
42
|
+
pathDetector: DB_MOBILENET,
|
|
43
|
+
pathRecognizer: CRNN_MOBILENET
|
|
44
|
+
}, imagePath)
|
|
45
|
+
|
|
46
|
+
const texts = results.map(r => r.text)
|
|
47
|
+
t.comment('Detected texts: ' + JSON.stringify(texts))
|
|
48
|
+
t.comment(formatOCRPerformanceMetrics(`[DocTR clinical_chemistry] [${tag}]`, stats, texts, { imagePath }))
|
|
49
|
+
|
|
50
|
+
t.ok(results.length > 0, `should detect text regions, got ${results.length}`)
|
|
51
|
+
|
|
52
|
+
const lowerTexts = texts.map(w => w.toLowerCase())
|
|
53
|
+
for (const word of EXPECTED_WORDS) {
|
|
54
|
+
t.ok(
|
|
55
|
+
lowerTexts.some(w => w.includes(word)),
|
|
56
|
+
`should detect "${word}" in clinical chemistry report`
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
t.pass(`DocTR clinical chemistry [${tag}] run ${run} completed successfully`)
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
for (let i = 1; i <= PERF_RUNS; i++) runClinicalChemistryTest('cpu', i)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const test = require('brittle')
|
|
4
|
+
const { getImagePath, formatOCRPerformanceMetrics, runDoctrOCR, ensureDoctrModels, PERF_RUNS } = require('./utils')
|
|
5
|
+
|
|
6
|
+
const DOCTR_TEST_TIMEOUT = 180 * 1000
|
|
7
|
+
|
|
8
|
+
let DB_MOBILENET
|
|
9
|
+
let CRNN_MOBILENET
|
|
10
|
+
let modelsAvailable = false
|
|
11
|
+
|
|
12
|
+
test('DocTR CT scan - download models', { timeout: DOCTR_TEST_TIMEOUT }, async function (t) {
|
|
13
|
+
const models = await ensureDoctrModels()
|
|
14
|
+
if (!models) {
|
|
15
|
+
t.comment('DocTR models unavailable (download failed) — remaining tests will be skipped')
|
|
16
|
+
return
|
|
17
|
+
}
|
|
18
|
+
DB_MOBILENET = models.db_mobilenet_v3_large
|
|
19
|
+
CRNN_MOBILENET = models.crnn_mobilenet_v3_small
|
|
20
|
+
modelsAvailable = true
|
|
21
|
+
t.ok(DB_MOBILENET, 'db_mobilenet model available')
|
|
22
|
+
t.ok(CRNN_MOBILENET, 'crnn_mobilenet model available')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
const EXPECTED_WORDS = [
|
|
26
|
+
'diagnostic', 'imaging', 'computed', 'tomography',
|
|
27
|
+
'chest', 'abdomen', 'lung', 'liver', 'pancreas',
|
|
28
|
+
'gallbladder', 'spleen', 'radiologist', 'allied',
|
|
29
|
+
'medical', 'center', 'patient', 'heart', 'trachea',
|
|
30
|
+
'vascular', 'normal'
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
function runCtScanTest (device, run) {
|
|
34
|
+
const tag = device.toUpperCase()
|
|
35
|
+
|
|
36
|
+
test(`DocTR CT scan [${tag}] run ${run} - db_mobilenet + crnn_mobilenet`, { timeout: DOCTR_TEST_TIMEOUT }, async function (t) {
|
|
37
|
+
if (!modelsAvailable) { t.comment('Skipped — models unavailable'); return }
|
|
38
|
+
const imagePath = getImagePath('/test/images/ct_scan_report.png')
|
|
39
|
+
|
|
40
|
+
t.comment(`Testing DocTR on CT scan diagnostic report image [${tag}] (run ${run}/${PERF_RUNS})`)
|
|
41
|
+
t.comment('Detector: db_mobilenet_v3_large, Recognizer: crnn_mobilenet_v3_small (CTC)')
|
|
42
|
+
|
|
43
|
+
const { results, stats } = await runDoctrOCR(t, {
|
|
44
|
+
pathDetector: DB_MOBILENET,
|
|
45
|
+
pathRecognizer: CRNN_MOBILENET
|
|
46
|
+
}, imagePath)
|
|
47
|
+
|
|
48
|
+
const texts = results.map(r => r.text)
|
|
49
|
+
t.comment('Detected texts: ' + JSON.stringify(texts))
|
|
50
|
+
t.comment(formatOCRPerformanceMetrics(`[DocTR ct_scan_report] [${tag}]`, stats, texts, { imagePath }))
|
|
51
|
+
|
|
52
|
+
t.ok(results.length > 0, `should detect text regions, got ${results.length}`)
|
|
53
|
+
|
|
54
|
+
const lowerTexts = texts.map(w => w.toLowerCase())
|
|
55
|
+
for (const word of EXPECTED_WORDS) {
|
|
56
|
+
t.ok(
|
|
57
|
+
lowerTexts.some(w => w.includes(word)),
|
|
58
|
+
`should detect "${word}" in CT scan report`
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
t.pass(`DocTR CT scan report [${tag}] run ${run} completed successfully`)
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
for (let i = 1; i <= PERF_RUNS; i++) runCtScanTest('cpu', i)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const test = require('brittle')
|
|
4
|
+
const { getImagePath, formatOCRPerformanceMetrics, runDoctrOCR, ensureDoctrModels, PERF_RUNS } = require('./utils')
|
|
5
|
+
|
|
6
|
+
const DOCTR_TEST_TIMEOUT = 180 * 1000
|
|
7
|
+
|
|
8
|
+
let DB_MOBILENET
|
|
9
|
+
let CRNN_MOBILENET
|
|
10
|
+
let modelsAvailable = false
|
|
11
|
+
|
|
12
|
+
test('DocTR lab results - download models', { timeout: DOCTR_TEST_TIMEOUT }, async function (t) {
|
|
13
|
+
const models = await ensureDoctrModels()
|
|
14
|
+
if (!models) {
|
|
15
|
+
t.comment('DocTR models unavailable (download failed) — remaining tests will be skipped')
|
|
16
|
+
return
|
|
17
|
+
}
|
|
18
|
+
DB_MOBILENET = models.db_mobilenet_v3_large
|
|
19
|
+
CRNN_MOBILENET = models.crnn_mobilenet_v3_small
|
|
20
|
+
modelsAvailable = true
|
|
21
|
+
t.ok(DB_MOBILENET, 'db_mobilenet model available')
|
|
22
|
+
t.ok(CRNN_MOBILENET, 'crnn_mobilenet model available')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
const EXPECTED_WORDS = [
|
|
26
|
+
'parameter', 'results', 'calculated', 'direct', 'values',
|
|
27
|
+
'clinical', 'blood', 'patient', 'medivista', 'hospital',
|
|
28
|
+
'biochemistry', 'department', 'arterial', 'gases',
|
|
29
|
+
'oxygen', 'electrolyte', 'metabolite', 'oximetry'
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
function runLabResultsTest (device, run) {
|
|
33
|
+
const tag = device.toUpperCase()
|
|
34
|
+
|
|
35
|
+
test(`DocTR lab results [${tag}] run ${run} - db_mobilenet + crnn_mobilenet`, { timeout: DOCTR_TEST_TIMEOUT }, async function (t) {
|
|
36
|
+
if (!modelsAvailable) { t.comment('Skipped — models unavailable'); return }
|
|
37
|
+
const imagePath = getImagePath('/test/images/lab_results.png')
|
|
38
|
+
|
|
39
|
+
t.comment(`Testing DocTR on medical lab results image [${tag}] (run ${run}/${PERF_RUNS})`)
|
|
40
|
+
t.comment('Detector: db_mobilenet_v3_large, Recognizer: crnn_mobilenet_v3_small (CTC)')
|
|
41
|
+
|
|
42
|
+
const { results, stats } = await runDoctrOCR(t, {
|
|
43
|
+
pathDetector: DB_MOBILENET,
|
|
44
|
+
pathRecognizer: CRNN_MOBILENET
|
|
45
|
+
}, imagePath)
|
|
46
|
+
|
|
47
|
+
const texts = results.map(r => r.text)
|
|
48
|
+
t.comment('Detected texts: ' + JSON.stringify(texts))
|
|
49
|
+
t.comment(formatOCRPerformanceMetrics(`[DocTR lab_results] [${tag}]`, stats, texts, { imagePath }))
|
|
50
|
+
|
|
51
|
+
t.ok(results.length > 0, `should detect text regions, got ${results.length}`)
|
|
52
|
+
|
|
53
|
+
const lowerTexts = texts.map(w => w.toLowerCase())
|
|
54
|
+
for (const word of EXPECTED_WORDS) {
|
|
55
|
+
t.ok(
|
|
56
|
+
lowerTexts.some(w => w.includes(word)),
|
|
57
|
+
`should detect "${word}" in lab results`
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
t.pass(`DocTR lab results [${tag}] run ${run} completed successfully`)
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
for (let i = 1; i <= PERF_RUNS; i++) runLabResultsTest('cpu', i)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const test = require('brittle')
|
|
4
|
+
const { getImagePath, formatOCRPerformanceMetrics, runDoctrOCR, ensureDoctrModels, PERF_RUNS } = require('./utils')
|
|
5
|
+
|
|
6
|
+
const DOCTR_TEST_TIMEOUT = 180 * 1000
|
|
7
|
+
|
|
8
|
+
let DB_MOBILENET
|
|
9
|
+
let CRNN_MOBILENET
|
|
10
|
+
let modelsAvailable = false
|
|
11
|
+
|
|
12
|
+
test('DocTR liver function - download models', { timeout: DOCTR_TEST_TIMEOUT }, async function (t) {
|
|
13
|
+
const models = await ensureDoctrModels()
|
|
14
|
+
if (!models) {
|
|
15
|
+
t.comment('DocTR models unavailable (download failed) — remaining tests will be skipped')
|
|
16
|
+
return
|
|
17
|
+
}
|
|
18
|
+
DB_MOBILENET = models.db_mobilenet_v3_large
|
|
19
|
+
CRNN_MOBILENET = models.crnn_mobilenet_v3_small
|
|
20
|
+
modelsAvailable = true
|
|
21
|
+
t.ok(DB_MOBILENET, 'db_mobilenet model available')
|
|
22
|
+
t.ok(CRNN_MOBILENET, 'crnn_mobilenet model available')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
const EXPECTED_WORDS = [
|
|
26
|
+
'bilirubin', 'sgot', 'sgpt', 'alkaline', 'phosphatase',
|
|
27
|
+
'albumin', 'globulin', 'protein', 'serum', 'pathology',
|
|
28
|
+
'biochemistry', 'hospital', 'conjugated', 'unconjugated',
|
|
29
|
+
// TODO: crnn_mobilenet_v3_small misreads "INVESTIGATION" as "INVESTIGATIIN"/"investiaation"
|
|
30
|
+
// strengthen back to 'investigation' when model quality improves
|
|
31
|
+
'ratio', 'specimen', 'investig', 'total'
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
function runLiverFunctionTest (device, run) {
|
|
35
|
+
const tag = device.toUpperCase()
|
|
36
|
+
|
|
37
|
+
test(`DocTR liver function [${tag}] run ${run} - db_mobilenet + crnn_mobilenet`, { timeout: DOCTR_TEST_TIMEOUT }, async function (t) {
|
|
38
|
+
if (!modelsAvailable) { t.comment('Skipped — models unavailable'); return }
|
|
39
|
+
const imagePath = getImagePath('/test/images/liver_function_test.png')
|
|
40
|
+
|
|
41
|
+
t.comment(`Testing DocTR on liver function test (LFT) image [${tag}] (run ${run}/${PERF_RUNS})`)
|
|
42
|
+
t.comment('Detector: db_mobilenet_v3_large, Recognizer: crnn_mobilenet_v3_small (CTC)')
|
|
43
|
+
|
|
44
|
+
const { results, stats } = await runDoctrOCR(t, {
|
|
45
|
+
pathDetector: DB_MOBILENET,
|
|
46
|
+
pathRecognizer: CRNN_MOBILENET
|
|
47
|
+
}, imagePath)
|
|
48
|
+
|
|
49
|
+
const texts = results.map(r => r.text)
|
|
50
|
+
t.comment('Detected texts: ' + JSON.stringify(texts))
|
|
51
|
+
t.comment(formatOCRPerformanceMetrics(`[DocTR liver_function_test] [${tag}]`, stats, texts, { imagePath }))
|
|
52
|
+
|
|
53
|
+
t.ok(results.length > 0, `should detect text regions, got ${results.length}`)
|
|
54
|
+
|
|
55
|
+
const lowerTexts = texts.map(w => w.toLowerCase())
|
|
56
|
+
for (const word of EXPECTED_WORDS) {
|
|
57
|
+
t.ok(
|
|
58
|
+
lowerTexts.some(w => w.includes(word)),
|
|
59
|
+
`should detect "${word}" in liver function test report`
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
t.pass(`DocTR liver function test [${tag}] run ${run} completed successfully`)
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
for (let i = 1; i <= PERF_RUNS; i++) runLiverFunctionTest('cpu', i)
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const test = require('brittle')
|
|
4
|
+
const fs = require('bare-fs')
|
|
5
|
+
const { getImagePath, formatOCRPerformanceMetrics, runDoctrOCR, ensureDoctrModels } = require('./utils')
|
|
6
|
+
|
|
7
|
+
const TEST_TIMEOUT = 180 * 1000
|
|
8
|
+
|
|
9
|
+
// Words reliably detected by db_mobilenet_v3_large + crnn_mobilenet_v3_small on english.bmp (case-insensitive).
|
|
10
|
+
// english.bmp is a WHO coronavirus infographic with known text.
|
|
11
|
+
const ENGLISH_EXPECTED_WORDS = [
|
|
12
|
+
'health', 'world', 'animals', 'farm', 'unprotected', 'wild',
|
|
13
|
+
'eggs', 'meat', 'cook', 'symptoms', 'cold', 'anyone',
|
|
14
|
+
'avoid', 'sneezing', 'nose', 'coughing', 'mouth', 'cover',
|
|
15
|
+
'hand', 'rub', 'soap', 'water', 'hands', 'clean',
|
|
16
|
+
'your', 'reduce', 'risk'
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
// Model paths (set after download)
|
|
20
|
+
let DB_MOBILENET
|
|
21
|
+
let CRNN_MOBILENET
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Assert that all expected words appear in the detected texts (case-insensitive)
|
|
25
|
+
*/
|
|
26
|
+
function assertExpectedWords (t, texts, expectedWords, label) {
|
|
27
|
+
const lowerTexts = texts.map(w => w.toLowerCase())
|
|
28
|
+
for (const word of expectedWords) {
|
|
29
|
+
t.ok(
|
|
30
|
+
lowerTexts.includes(word.toLowerCase()),
|
|
31
|
+
`${label} should detect "${word}" (got: ${JSON.stringify(texts)})`
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// -------------------------------------------------------------------
|
|
37
|
+
// Download models before tests
|
|
38
|
+
// -------------------------------------------------------------------
|
|
39
|
+
test('DocTR models - download all models', { timeout: TEST_TIMEOUT }, async function (t) {
|
|
40
|
+
const models = await ensureDoctrModels()
|
|
41
|
+
DB_MOBILENET = models.db_mobilenet_v3_large
|
|
42
|
+
CRNN_MOBILENET = models.crnn_mobilenet_v3_small
|
|
43
|
+
t.ok(fs.existsSync(DB_MOBILENET), 'db_mobilenet_v3_large exists')
|
|
44
|
+
t.ok(fs.existsSync(CRNN_MOBILENET), 'crnn_mobilenet_v3_small exists')
|
|
45
|
+
t.pass('All models available')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
// -------------------------------------------------------------------
|
|
49
|
+
// 1. Default combo: db_mobilenet_v3_large + crnn_mobilenet_v3_small
|
|
50
|
+
// -------------------------------------------------------------------
|
|
51
|
+
test('DocTR CTC - db_mobilenet + crnn_mobilenet on english.bmp', { timeout: TEST_TIMEOUT }, async function (t) {
|
|
52
|
+
const imagePath = getImagePath('/test/images/english.bmp')
|
|
53
|
+
t.comment('Detector: db_mobilenet_v3_large, Recognizer: crnn_mobilenet_v3_small')
|
|
54
|
+
|
|
55
|
+
const { results, stats } = await runDoctrOCR(t, {
|
|
56
|
+
pathDetector: DB_MOBILENET,
|
|
57
|
+
pathRecognizer: CRNN_MOBILENET
|
|
58
|
+
}, imagePath)
|
|
59
|
+
|
|
60
|
+
const texts = results.map(r => r.text)
|
|
61
|
+
t.comment('Detected: ' + JSON.stringify(texts))
|
|
62
|
+
t.comment(formatOCRPerformanceMetrics('[CTC mobilenet]', stats, texts, { skipReport: true }))
|
|
63
|
+
|
|
64
|
+
// Should detect many text regions from the infographic
|
|
65
|
+
t.ok(results.length >= 30, `should detect >= 30 text regions, got ${results.length}`)
|
|
66
|
+
|
|
67
|
+
// All confidences should be valid numbers in [0, 1]
|
|
68
|
+
for (const r of results) {
|
|
69
|
+
t.ok(r.confidence >= 0 && r.confidence <= 1, `confidence ${r.confidence.toFixed(3)} in [0,1]`)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Verify expected words are detected
|
|
73
|
+
assertExpectedWords(t, texts, ENGLISH_EXPECTED_WORDS, '[CTC mobilenet]')
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
// -------------------------------------------------------------------
|
|
77
|
+
// 2. repeated run — same image produces valid output
|
|
78
|
+
// -------------------------------------------------------------------
|
|
79
|
+
test('DocTR repeated run - should not crash and produce valid output', { timeout: TEST_TIMEOUT }, async function (t) {
|
|
80
|
+
const imagePath = getImagePath('/test/images/english.bmp')
|
|
81
|
+
t.comment('Testing repeated DocTR run on english.bmp')
|
|
82
|
+
|
|
83
|
+
const { results, stats } = await runDoctrOCR(t, {
|
|
84
|
+
pathDetector: DB_MOBILENET,
|
|
85
|
+
pathRecognizer: CRNN_MOBILENET
|
|
86
|
+
}, imagePath)
|
|
87
|
+
|
|
88
|
+
const texts = results.map(r => r.text)
|
|
89
|
+
t.comment('Detected texts: ' + JSON.stringify(texts))
|
|
90
|
+
t.comment(formatOCRPerformanceMetrics('[DocTR repeated]', stats, texts, { skipReport: true }))
|
|
91
|
+
|
|
92
|
+
t.ok(results.length >= 30, `should detect >= 30 text regions, got ${results.length}`)
|
|
93
|
+
assertExpectedWords(t, texts, ENGLISH_EXPECTED_WORDS, '[DocTR repeated]')
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
// -------------------------------------------------------------------
|
|
97
|
+
// 3. recognizerBatchSize — different batch sizes produce valid output
|
|
98
|
+
// -------------------------------------------------------------------
|
|
99
|
+
test('DocTR recognizerBatchSize - batch=1 vs batch=16 both produce valid output', { timeout: TEST_TIMEOUT * 2 }, async function (t) {
|
|
100
|
+
const imagePath = getImagePath('/test/images/english.bmp')
|
|
101
|
+
t.comment('Testing recognizerBatchSize=1 vs recognizerBatchSize=16')
|
|
102
|
+
|
|
103
|
+
const { results: resultsBatch1 } = await runDoctrOCR(t, {
|
|
104
|
+
pathDetector: DB_MOBILENET,
|
|
105
|
+
pathRecognizer: CRNN_MOBILENET,
|
|
106
|
+
recognizerBatchSize: 1
|
|
107
|
+
}, imagePath)
|
|
108
|
+
|
|
109
|
+
const { results: resultsBatch16 } = await runDoctrOCR(t, {
|
|
110
|
+
pathDetector: DB_MOBILENET,
|
|
111
|
+
pathRecognizer: CRNN_MOBILENET,
|
|
112
|
+
recognizerBatchSize: 16
|
|
113
|
+
}, imagePath)
|
|
114
|
+
|
|
115
|
+
const textsBatch1 = resultsBatch1.map(r => r.text)
|
|
116
|
+
const textsBatch16 = resultsBatch16.map(r => r.text)
|
|
117
|
+
t.comment('Batch=1 texts (' + textsBatch1.length + '): ' + JSON.stringify(textsBatch1))
|
|
118
|
+
t.comment('Batch=16 texts (' + textsBatch16.length + '): ' + JSON.stringify(textsBatch16))
|
|
119
|
+
|
|
120
|
+
t.ok(resultsBatch1.length > 0, 'Batch=1 should detect text')
|
|
121
|
+
t.ok(resultsBatch16.length > 0, 'Batch=16 should detect text')
|
|
122
|
+
t.is(resultsBatch1.length, resultsBatch16.length, 'Both batch sizes should detect same number of regions')
|
|
123
|
+
|
|
124
|
+
// Texts should be identical regardless of batch size
|
|
125
|
+
for (let i = 0; i < Math.min(resultsBatch1.length, resultsBatch16.length); i++) {
|
|
126
|
+
t.is(resultsBatch1[i].text, resultsBatch16[i].text, 'Text at index ' + i + ' should match across batch sizes')
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
assertExpectedWords(t, textsBatch1, ENGLISH_EXPECTED_WORDS, '[batch=1]')
|
|
130
|
+
assertExpectedWords(t, textsBatch16, ENGLISH_EXPECTED_WORDS, '[batch=16]')
|
|
131
|
+
t.pass('recognizerBatchSize does not affect output accuracy')
|
|
132
|
+
})
|