@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,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
+ })