@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
package/index.d.ts
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import type { QvacResponse } from '@qvac/infer-base'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* OCR pipeline backing the addon.
|
|
5
|
+
* - `easyocr` (default): CRAFT detector + CRNN gen-2 recognizer.
|
|
6
|
+
* Uses `langList`, `magRatio`, `defaultRotationAngles`, `contrastRetry`,
|
|
7
|
+
* `lowConfidenceThreshold`, `recognizerBatchSize`.
|
|
8
|
+
* - `doctr`: DBNet detector + doctr recognizer.
|
|
9
|
+
* Language-agnostic; EasyOCR-specific knobs (`magRatio`, etc.) are
|
|
10
|
+
* ignored.
|
|
11
|
+
*/
|
|
12
|
+
export type OcrGgmlPipelineType = 'easyocr' | 'doctr'
|
|
13
|
+
|
|
14
|
+
export interface OcrGgmlParams {
|
|
15
|
+
/**
|
|
16
|
+
* Path to the detector GGUF file.
|
|
17
|
+
* - easyocr: CRAFT model (e.g. `craft_mlt_25k.gguf`)
|
|
18
|
+
* - doctr: DBNet model (e.g. `db_mobilenet_v3_large.gguf`)
|
|
19
|
+
*/
|
|
20
|
+
pathDetector: string
|
|
21
|
+
/**
|
|
22
|
+
* Path to the recognizer GGUF file.
|
|
23
|
+
* - easyocr: CRNN gen-2 (e.g. `english_g2.gguf`, `latin_g2.gguf`)
|
|
24
|
+
* - doctr: doctr recognition model (e.g. `crnn_mobilenet_v3_small.gguf`)
|
|
25
|
+
*/
|
|
26
|
+
pathRecognizer: string
|
|
27
|
+
/** Languages handled by the recognizer (e.g. `['en']`, `['en', 'fr']`). */
|
|
28
|
+
langList: string[]
|
|
29
|
+
|
|
30
|
+
/** Pipeline backing the addon. Default: `'easyocr'`. */
|
|
31
|
+
pipelineType?: OcrGgmlPipelineType
|
|
32
|
+
/** Detection magnification ratio (easyocr only). Default: 1.5. */
|
|
33
|
+
magRatio?: number
|
|
34
|
+
/**
|
|
35
|
+
* EasyOCR `canvas_size`: detection canvas cap (long side, px) applied after
|
|
36
|
+
* `magRatio` scaling. Bounds CRAFT peak memory on dense/high-resolution
|
|
37
|
+
* pages; lower it (e.g. 1280) on memory-constrained targets such as mobile.
|
|
38
|
+
* Default: 2560 (matches `@qvac/ocr-onnx` / EasyOCR). easyocr only.
|
|
39
|
+
*/
|
|
40
|
+
canvasSize?: number
|
|
41
|
+
/** Rotation angles tried when the primary pass is low-confidence (easyocr only). Default: [90, 270]. */
|
|
42
|
+
defaultRotationAngles?: number[]
|
|
43
|
+
/** Retry low-confidence boxes with contrast adjustment (easyocr only). Default: false. */
|
|
44
|
+
contrastRetry?: boolean
|
|
45
|
+
/** Threshold below which contrast-retry kicks in (easyocr only). Default: 0.4. */
|
|
46
|
+
lowConfidenceThreshold?: number
|
|
47
|
+
/** Recognizer batch size (easyocr only). Default: 32. */
|
|
48
|
+
recognizerBatchSize?: number
|
|
49
|
+
/**
|
|
50
|
+
* GGML CPU thread count:
|
|
51
|
+
* - `0` (default): auto-detect physical cores (hardware_concurrency / 2, floor 1)
|
|
52
|
+
* - `> 0`: explicit override
|
|
53
|
+
* - `< 0`: leave GGML's CPU backend default unchanged
|
|
54
|
+
*/
|
|
55
|
+
nThreads?: number
|
|
56
|
+
/** Directory holding ggml backend shared libraries. Default: `<package>/prebuilds`. */
|
|
57
|
+
backendsDir?: string
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface OcrGgmlArgs {
|
|
61
|
+
params: OcrGgmlParams
|
|
62
|
+
opts?: { stats?: boolean }
|
|
63
|
+
logger?: any
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface OcrGgmlRunOptions {
|
|
67
|
+
/** Merge nearby boxes into paragraph-style regions. Default: false. */
|
|
68
|
+
paragraph?: boolean
|
|
69
|
+
/** Extra padding around detected boxes, as a fraction of box size. Default: 0.1. */
|
|
70
|
+
boxMarginMultiplier?: number
|
|
71
|
+
/** Override `defaultRotationAngles` for this single call. */
|
|
72
|
+
rotationAngles?: number[]
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface OcrGgmlRunInput {
|
|
76
|
+
/** Path to a JPEG, PNG, or BMP file. */
|
|
77
|
+
path: string
|
|
78
|
+
options?: OcrGgmlRunOptions
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* One detected text region. Shape matches `@qvac/ocr-onnx` so downstream
|
|
83
|
+
* consumers can swap backends without changing data handling code.
|
|
84
|
+
*/
|
|
85
|
+
export type InferredText = [
|
|
86
|
+
/** Bounding box: [[x,y], [x,y], [x,y], [x,y]]. */
|
|
87
|
+
[[number, number], [number, number], [number, number], [number, number]],
|
|
88
|
+
/** Recognized text. */
|
|
89
|
+
string,
|
|
90
|
+
/** Confidence in [0, 1]. */
|
|
91
|
+
number
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
export interface InferenceClientState {
|
|
95
|
+
configLoaded: boolean
|
|
96
|
+
weightsLoaded: boolean
|
|
97
|
+
destroyed: boolean
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface RuntimeStats {
|
|
101
|
+
/** Total wall-clock time for the run (seconds). */
|
|
102
|
+
totalTime: number
|
|
103
|
+
/** Detection step duration (seconds). */
|
|
104
|
+
detectionTime: number
|
|
105
|
+
/** Recognition step duration (seconds). */
|
|
106
|
+
recognitionTime: number
|
|
107
|
+
/** Number of detected boxes (aligned + unaligned). */
|
|
108
|
+
numBoxes: number
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export class OcrGgml {
|
|
112
|
+
constructor(args: OcrGgmlArgs)
|
|
113
|
+
getState(): InferenceClientState
|
|
114
|
+
load(): Promise<void>
|
|
115
|
+
run(input: OcrGgmlRunInput): Promise<QvacResponse<InferredText[]>>
|
|
116
|
+
unload(): Promise<void>
|
|
117
|
+
destroy(): Promise<void>
|
|
118
|
+
|
|
119
|
+
static readonly inferenceManagerConfig: { noAdditionalDownload: boolean }
|
|
120
|
+
static getModelKey(): string
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export const modelClass: typeof OcrGgml
|
|
124
|
+
export const modelFile: unknown
|
|
125
|
+
|
|
126
|
+
export const ERR_CODES: {
|
|
127
|
+
readonly FAILED_TO_LOAD_WEIGHTS: number
|
|
128
|
+
readonly FAILED_TO_CANCEL: number
|
|
129
|
+
readonly FAILED_TO_RUN_JOB: number
|
|
130
|
+
readonly FAILED_TO_GET_STATUS: number
|
|
131
|
+
readonly FAILED_TO_DESTROY: number
|
|
132
|
+
readonly FAILED_TO_ACTIVATE: number
|
|
133
|
+
readonly MISSING_REQUIRED_PARAMETER: number
|
|
134
|
+
readonly UNSUPPORTED_LANGUAGE: number
|
|
135
|
+
readonly INVALID_IMAGE_OR_INSUFFICIENT_DATA: number
|
|
136
|
+
readonly UNSUPPORTED_IMAGE_FORMAT: number
|
|
137
|
+
readonly NOT_LOADED: number
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export class QvacErrorAddonOcrGgml extends Error {
|
|
141
|
+
code: number
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export const binding: unknown
|
|
145
|
+
export const addonLogging: unknown
|
package/index.js
ADDED
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const path = require('bare-path')
|
|
4
|
+
const fs = require('bare-fs')
|
|
5
|
+
const QvacLogger = require('@qvac/logging')
|
|
6
|
+
const { createJobHandler, exclusiveRunQueue } = require('@qvac/infer-base')
|
|
7
|
+
|
|
8
|
+
const { QvacErrorAddonOcrGgml, ERR_CODES } = require('./lib/error')
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* GGML-backed OCR implementation.
|
|
12
|
+
*
|
|
13
|
+
* Public surface matches `@qvac/ocr-onnx` so a downstream caller can switch
|
|
14
|
+
* inference engines by swapping the import.
|
|
15
|
+
*/
|
|
16
|
+
class OcrGgml {
|
|
17
|
+
/**
|
|
18
|
+
* @param {Object} args
|
|
19
|
+
* @param {Object} args.params
|
|
20
|
+
* @param {string} args.params.pathDetector - path to the CRAFT .gguf
|
|
21
|
+
* @param {string} args.params.pathRecognizer - path to the recognizer .gguf
|
|
22
|
+
* @param {string[]} args.params.langList - e.g. `['en']`
|
|
23
|
+
* @param {number} [args.params.magRatio=1.5]
|
|
24
|
+
* @param {number} [args.params.canvasSize=2560] - EasyOCR `canvas_size`: detection canvas cap (long side, px). Lower it (e.g. 1280) on memory-constrained targets to bound CRAFT peak memory. EasyOCR only.
|
|
25
|
+
* @param {number[]} [args.params.defaultRotationAngles]
|
|
26
|
+
* @param {boolean} [args.params.contrastRetry]
|
|
27
|
+
* @param {number} [args.params.lowConfidenceThreshold]
|
|
28
|
+
* @param {number} [args.params.recognizerBatchSize]
|
|
29
|
+
* @param {number} [args.params.nThreads] - 0=auto (physical cores), >0=explicit, <0=leave default
|
|
30
|
+
* @param {string} [args.params.backendsDir] - override directory for ggml backend shared libs
|
|
31
|
+
* @param {Object} [args.opts]
|
|
32
|
+
* @param {boolean} [args.opts.stats] - emit timing stats on finish
|
|
33
|
+
* @param {Object} [args.logger]
|
|
34
|
+
*/
|
|
35
|
+
constructor ({ params, opts = {}, logger = null }) {
|
|
36
|
+
this.opts = opts
|
|
37
|
+
this.logger = new QvacLogger(logger)
|
|
38
|
+
this.addon = null
|
|
39
|
+
this.params = params
|
|
40
|
+
this._packageName = '@qvac/ocr-ggml'
|
|
41
|
+
this._packageVersion = require('./package.json').version
|
|
42
|
+
this._job = createJobHandler({ cancel: () => this.addon && this.addon.cancel() })
|
|
43
|
+
this._run = exclusiveRunQueue()
|
|
44
|
+
|
|
45
|
+
this.state = {
|
|
46
|
+
configLoaded: false,
|
|
47
|
+
weightsLoaded: false,
|
|
48
|
+
destroyed: false
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @returns {{configLoaded: boolean, weightsLoaded: boolean, destroyed: boolean}}
|
|
54
|
+
*/
|
|
55
|
+
getState () {
|
|
56
|
+
return this.state
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async load () {
|
|
60
|
+
if (this.state.configLoaded || this.state.weightsLoaded) {
|
|
61
|
+
this.logger.info('Reload requested - unloading existing model first')
|
|
62
|
+
await this.unload()
|
|
63
|
+
}
|
|
64
|
+
await this._load()
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @param {{ path: string, options?: Object }} input
|
|
69
|
+
* @returns {Promise<import('@qvac/infer-base').QvacResponse>}
|
|
70
|
+
*/
|
|
71
|
+
async run (input) {
|
|
72
|
+
return this._run(() => this._runInternal(input))
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async unload () {
|
|
76
|
+
if (this.addon) {
|
|
77
|
+
await this.addon.destroy()
|
|
78
|
+
this.addon = null
|
|
79
|
+
}
|
|
80
|
+
this.state.configLoaded = false
|
|
81
|
+
this.state.weightsLoaded = false
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async destroy () {
|
|
85
|
+
await this.unload()
|
|
86
|
+
this.state.destroyed = true
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async _load () {
|
|
90
|
+
if (!this.params || typeof this.params !== 'object') {
|
|
91
|
+
throw new QvacErrorAddonOcrGgml({
|
|
92
|
+
code: ERR_CODES.MISSING_REQUIRED_PARAMETER,
|
|
93
|
+
adds: 'params object is required'
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (!this.params.pathDetector) {
|
|
98
|
+
throw new QvacErrorAddonOcrGgml({
|
|
99
|
+
code: ERR_CODES.MISSING_REQUIRED_PARAMETER,
|
|
100
|
+
adds: 'pathDetector'
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
if (!this.params.pathRecognizer) {
|
|
104
|
+
throw new QvacErrorAddonOcrGgml({
|
|
105
|
+
code: ERR_CODES.MISSING_REQUIRED_PARAMETER,
|
|
106
|
+
adds: 'pathRecognizer'
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
if (!Array.isArray(this.params.langList) || this.params.langList.length === 0) {
|
|
110
|
+
throw new QvacErrorAddonOcrGgml({
|
|
111
|
+
code: ERR_CODES.MISSING_REQUIRED_PARAMETER,
|
|
112
|
+
adds: 'langList (non-empty array)'
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const SUPPORTED_LANGUAGES = new Set(['en'])
|
|
117
|
+
const hasSupported = this.params.langList.some(l => SUPPORTED_LANGUAGES.has(l))
|
|
118
|
+
if (!hasSupported) {
|
|
119
|
+
throw new QvacErrorAddonOcrGgml({
|
|
120
|
+
code: ERR_CODES.UNSUPPORTED_LANGUAGE,
|
|
121
|
+
adds: `none of the requested languages are supported: ${this.params.langList.join(', ')}`
|
|
122
|
+
})
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const configurationParams = {
|
|
126
|
+
pathDetector: this.params.pathDetector,
|
|
127
|
+
pathRecognizer: this.params.pathRecognizer,
|
|
128
|
+
langList: this.params.langList
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Forward optional config knobs only when explicitly set so the C++
|
|
132
|
+
// defaults (in OcrConfig) win otherwise.
|
|
133
|
+
const optionalFields = [
|
|
134
|
+
'magRatio',
|
|
135
|
+
'canvasSize',
|
|
136
|
+
'defaultRotationAngles',
|
|
137
|
+
'contrastRetry',
|
|
138
|
+
'lowConfidenceThreshold',
|
|
139
|
+
'recognizerBatchSize',
|
|
140
|
+
'nThreads',
|
|
141
|
+
'pipelineType'
|
|
142
|
+
]
|
|
143
|
+
for (const field of optionalFields) {
|
|
144
|
+
if (this.params[field] !== undefined) {
|
|
145
|
+
configurationParams[field] = this.params[field]
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
configurationParams.backendsDir =
|
|
150
|
+
this.params.backendsDir !== undefined
|
|
151
|
+
? this.params.backendsDir
|
|
152
|
+
: path.join(__dirname, 'prebuilds')
|
|
153
|
+
|
|
154
|
+
this.logger.info('Creating ocr-ggml addon')
|
|
155
|
+
this.addon = this._createAddon(configurationParams)
|
|
156
|
+
await this.addon.activate()
|
|
157
|
+
this.state.configLoaded = true
|
|
158
|
+
this.state.weightsLoaded = true
|
|
159
|
+
this.logger.info('ocr-ggml model loaded')
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
_createAddon (configurationParams) {
|
|
163
|
+
const binding = require('./binding')
|
|
164
|
+
const { OcrGgmlInterface } = require('./ocr-ggml')
|
|
165
|
+
return new OcrGgmlInterface(
|
|
166
|
+
binding,
|
|
167
|
+
configurationParams,
|
|
168
|
+
this._addonOutputCallback.bind(this),
|
|
169
|
+
this.logger
|
|
170
|
+
)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async _runInternal (input) {
|
|
174
|
+
this.logger.info('Starting OCR inference')
|
|
175
|
+
|
|
176
|
+
if (!this.addon) {
|
|
177
|
+
throw new QvacErrorAddonOcrGgml({
|
|
178
|
+
code: ERR_CODES.NOT_LOADED,
|
|
179
|
+
adds: 'call load() before run()'
|
|
180
|
+
})
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const response = this._job.start()
|
|
184
|
+
try {
|
|
185
|
+
const imageInput = this._readImage(input.path)
|
|
186
|
+
await this.addon.runJob({
|
|
187
|
+
type: 'image',
|
|
188
|
+
input: imageInput,
|
|
189
|
+
options: input.options
|
|
190
|
+
})
|
|
191
|
+
} catch (err) {
|
|
192
|
+
this._job.fail(err)
|
|
193
|
+
throw err
|
|
194
|
+
}
|
|
195
|
+
return response
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Read an image file from disk and prepare it for the addon. Mirrors the
|
|
200
|
+
* ocr-onnx convention: JPEG / PNG are passed encoded to the addon (decoded
|
|
201
|
+
* by OpenCV in C++); BMP is decoded in JS to raw RGB.
|
|
202
|
+
*
|
|
203
|
+
* @param {string} imagePath
|
|
204
|
+
* @returns {{ data: Buffer, isEncoded?: boolean, width?: number, height?: number, bitsPerPixel?: number }}
|
|
205
|
+
*/
|
|
206
|
+
_readImage (imagePath) {
|
|
207
|
+
this.logger.debug('Reading image from path:', imagePath)
|
|
208
|
+
const contents = fs.readFileSync(imagePath)
|
|
209
|
+
if (!contents || contents.length < 4) {
|
|
210
|
+
this.logger.error('Invalid image file or insufficient data')
|
|
211
|
+
throw new QvacErrorAddonOcrGgml({
|
|
212
|
+
code: ERR_CODES.INVALID_IMAGE_OR_INSUFFICIENT_DATA,
|
|
213
|
+
adds: imagePath
|
|
214
|
+
})
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// JPEG: starts with 0xFF 0xD8
|
|
218
|
+
if (contents[0] === 0xFF && contents[1] === 0xD8) {
|
|
219
|
+
return { data: contents, isEncoded: true }
|
|
220
|
+
}
|
|
221
|
+
// PNG: 0x89 P N G
|
|
222
|
+
if (contents[0] === 0x89 && contents[1] === 0x50 && contents[2] === 0x4E && contents[3] === 0x47) {
|
|
223
|
+
return { data: contents, isEncoded: true }
|
|
224
|
+
}
|
|
225
|
+
// BMP: 'BM'
|
|
226
|
+
if (contents[0] === 0x42 && contents[1] === 0x4D) {
|
|
227
|
+
return this._decodeBmp(contents, imagePath)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
this.logger.error('Unsupported image format')
|
|
231
|
+
throw new QvacErrorAddonOcrGgml({
|
|
232
|
+
code: ERR_CODES.UNSUPPORTED_IMAGE_FORMAT,
|
|
233
|
+
adds: imagePath
|
|
234
|
+
})
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
_decodeBmp (contents, imagePath) {
|
|
238
|
+
if (contents.length < 14 + 4) {
|
|
239
|
+
throw new QvacErrorAddonOcrGgml({
|
|
240
|
+
code: ERR_CODES.INVALID_IMAGE_OR_INSUFFICIENT_DATA,
|
|
241
|
+
adds: imagePath
|
|
242
|
+
})
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const infoHeaderSize = contents.readUInt32LE(14)
|
|
246
|
+
if (contents.length < 14 + infoHeaderSize) {
|
|
247
|
+
throw new QvacErrorAddonOcrGgml({
|
|
248
|
+
code: ERR_CODES.INVALID_IMAGE_OR_INSUFFICIENT_DATA,
|
|
249
|
+
adds: imagePath
|
|
250
|
+
})
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
let width, height, bitsPerPixel
|
|
254
|
+
if (infoHeaderSize >= 40) {
|
|
255
|
+
width = contents.readInt32LE(18)
|
|
256
|
+
height = contents.readInt32LE(22)
|
|
257
|
+
bitsPerPixel = contents.readUInt16LE(28)
|
|
258
|
+
} else if (infoHeaderSize >= 12) {
|
|
259
|
+
width = contents.readUInt16LE(18)
|
|
260
|
+
height = contents.readInt16LE(20)
|
|
261
|
+
bitsPerPixel = 24
|
|
262
|
+
} else {
|
|
263
|
+
throw new QvacErrorAddonOcrGgml({
|
|
264
|
+
code: ERR_CODES.UNSUPPORTED_IMAGE_FORMAT,
|
|
265
|
+
adds: `BMP header size ${infoHeaderSize}`
|
|
266
|
+
})
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (width <= 0 || height === 0) {
|
|
270
|
+
throw new QvacErrorAddonOcrGgml({
|
|
271
|
+
code: ERR_CODES.INVALID_IMAGE_OR_INSUFFICIENT_DATA,
|
|
272
|
+
adds: `${imagePath} (invalid BMP dimensions ${width}x${height})`
|
|
273
|
+
})
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const SUPPORTED_BITS = new Set([8, 24, 32])
|
|
277
|
+
if (!SUPPORTED_BITS.has(bitsPerPixel)) {
|
|
278
|
+
throw new QvacErrorAddonOcrGgml({
|
|
279
|
+
code: ERR_CODES.UNSUPPORTED_IMAGE_FORMAT,
|
|
280
|
+
adds: `BMP bitsPerPixel ${bitsPerPixel} (supported: 8, 24, 32)`
|
|
281
|
+
})
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const pixelDataOffset = contents.readUInt32LE(10)
|
|
285
|
+
if (pixelDataOffset >= contents.length) {
|
|
286
|
+
throw new QvacErrorAddonOcrGgml({
|
|
287
|
+
code: ERR_CODES.INVALID_IMAGE_OR_INSUFFICIENT_DATA,
|
|
288
|
+
adds: `${imagePath} (BMP pixelDataOffset ${pixelDataOffset} out of range)`
|
|
289
|
+
})
|
|
290
|
+
}
|
|
291
|
+
const pixelDataBuffer = contents.slice(pixelDataOffset)
|
|
292
|
+
|
|
293
|
+
const bytesPerPixel = bitsPerPixel / 8
|
|
294
|
+
const unpaddedRowSize = width * bytesPerPixel
|
|
295
|
+
const paddedRowSize = Math.ceil(unpaddedRowSize / 4) * 4
|
|
296
|
+
const rows = Math.abs(height)
|
|
297
|
+
|
|
298
|
+
if (pixelDataBuffer.length < rows * paddedRowSize) {
|
|
299
|
+
throw new QvacErrorAddonOcrGgml({
|
|
300
|
+
code: ERR_CODES.INVALID_IMAGE_OR_INSUFFICIENT_DATA,
|
|
301
|
+
adds: imagePath
|
|
302
|
+
})
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const unpaddedData = Buffer.alloc(rows * unpaddedRowSize)
|
|
306
|
+
for (let row = 0; row < rows; row++) {
|
|
307
|
+
const srcStart = row * paddedRowSize
|
|
308
|
+
const srcEnd = srcStart + unpaddedRowSize
|
|
309
|
+
let destRow = row
|
|
310
|
+
if (height > 0) destRow = rows - row - 1
|
|
311
|
+
const destStart = destRow * unpaddedRowSize
|
|
312
|
+
pixelDataBuffer.copy(unpaddedData, destStart, srcStart, srcEnd)
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return { width, height: rows, data: unpaddedData, bitsPerPixel }
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
_addonOutputCallback (addon, event, data, error) {
|
|
319
|
+
if (event && event.includes('Error')) {
|
|
320
|
+
return this._job.fail(error)
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// JobEnded may arrive with stats payload, with null (stats disabled), or
|
|
324
|
+
// not at all on some platforms - handle the event name explicitly so
|
|
325
|
+
// await() doesn't hang when data is null.
|
|
326
|
+
if (event === 'JobEnded') {
|
|
327
|
+
const isStatsObject =
|
|
328
|
+
typeof data === 'object' &&
|
|
329
|
+
data !== null &&
|
|
330
|
+
!Array.isArray(data) &&
|
|
331
|
+
('totalTime' in data || 'detectionTime' in data)
|
|
332
|
+
if (isStatsObject) {
|
|
333
|
+
this.logger.info('OCR inference completed. Stats:', JSON.stringify(data))
|
|
334
|
+
}
|
|
335
|
+
return this._job.end(this.opts?.stats && isStatsObject ? data : null)
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Some addon paths surface stats without a 'JobEnded' event name; keep
|
|
339
|
+
// the legacy heuristic as a fallback so we still close the job.
|
|
340
|
+
const isStatsObject =
|
|
341
|
+
typeof data === 'object' &&
|
|
342
|
+
data !== null &&
|
|
343
|
+
!Array.isArray(data) &&
|
|
344
|
+
('totalTime' in data || 'detectionTime' in data)
|
|
345
|
+
if (isStatsObject) {
|
|
346
|
+
this.logger.info('OCR inference completed. Stats:', JSON.stringify(data))
|
|
347
|
+
return this._job.end(this.opts?.stats ? data : null)
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (Array.isArray(data)) {
|
|
351
|
+
return this._job.output(data)
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Inference Manager diagnostics hook (parity with ocr-onnx). */
|
|
356
|
+
_getDiagnosticsJSON () {
|
|
357
|
+
return JSON.stringify({
|
|
358
|
+
status: this.state.destroyed
|
|
359
|
+
? 'destroyed'
|
|
360
|
+
: (this.state.configLoaded ? 'loaded' : 'not_loaded'),
|
|
361
|
+
params: this.params
|
|
362
|
+
})
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** Inference Manager hooks (parity with ocr-onnx) */
|
|
366
|
+
static inferenceManagerConfig = {
|
|
367
|
+
noAdditionalDownload: true
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
static getModelKey () {
|
|
371
|
+
return 'ocr-ggml'
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
module.exports = {
|
|
376
|
+
OcrGgml,
|
|
377
|
+
modelClass: OcrGgml,
|
|
378
|
+
get modelFile () { return require.addon.resolve('.') },
|
|
379
|
+
QvacErrorAddonOcrGgml,
|
|
380
|
+
ERR_CODES,
|
|
381
|
+
get binding () { return require('./binding') },
|
|
382
|
+
get addonLogging () { return require('./addonLogging') }
|
|
383
|
+
}
|
package/lib/error.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { QvacErrorBase, addCodes } = require('@qvac/error')
|
|
4
|
+
const { name, version } = require('../package.json')
|
|
5
|
+
|
|
6
|
+
class QvacErrorAddonOcrGgml extends QvacErrorBase {}
|
|
7
|
+
|
|
8
|
+
// Allocated error code range: 8101..8200
|
|
9
|
+
// (translation-nmtcpp uses 8001..9000 broadly; ocr-onnx claims its own block.)
|
|
10
|
+
const ERR_CODES = Object.freeze({
|
|
11
|
+
FAILED_TO_LOAD_WEIGHTS: 8101,
|
|
12
|
+
FAILED_TO_CANCEL: 8102,
|
|
13
|
+
FAILED_TO_RUN_JOB: 8103,
|
|
14
|
+
FAILED_TO_GET_STATUS: 8104,
|
|
15
|
+
FAILED_TO_DESTROY: 8105,
|
|
16
|
+
FAILED_TO_ACTIVATE: 8106,
|
|
17
|
+
MISSING_REQUIRED_PARAMETER: 8107,
|
|
18
|
+
UNSUPPORTED_LANGUAGE: 8108,
|
|
19
|
+
INVALID_IMAGE_OR_INSUFFICIENT_DATA: 8109,
|
|
20
|
+
UNSUPPORTED_IMAGE_FORMAT: 8110,
|
|
21
|
+
NOT_LOADED: 8111
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
addCodes(
|
|
25
|
+
{
|
|
26
|
+
[ERR_CODES.FAILED_TO_LOAD_WEIGHTS]: {
|
|
27
|
+
name: 'FAILED_TO_LOAD_WEIGHTS',
|
|
28
|
+
message: message => `Failed to load weights, error: ${message}`
|
|
29
|
+
},
|
|
30
|
+
[ERR_CODES.FAILED_TO_CANCEL]: {
|
|
31
|
+
name: 'FAILED_TO_CANCEL',
|
|
32
|
+
message: message => `Failed to cancel inference, error: ${message}`
|
|
33
|
+
},
|
|
34
|
+
[ERR_CODES.FAILED_TO_RUN_JOB]: {
|
|
35
|
+
name: 'FAILED_TO_RUN_JOB',
|
|
36
|
+
message: message => `Failed to run OCR job, error: ${message}`
|
|
37
|
+
},
|
|
38
|
+
[ERR_CODES.FAILED_TO_GET_STATUS]: {
|
|
39
|
+
name: 'FAILED_TO_GET_STATUS',
|
|
40
|
+
message: message => `Failed to get addon status, error: ${message}`
|
|
41
|
+
},
|
|
42
|
+
[ERR_CODES.FAILED_TO_DESTROY]: {
|
|
43
|
+
name: 'FAILED_TO_DESTROY',
|
|
44
|
+
message: message => `Failed to destroy instance, error: ${message}`
|
|
45
|
+
},
|
|
46
|
+
[ERR_CODES.FAILED_TO_ACTIVATE]: {
|
|
47
|
+
name: 'FAILED_TO_ACTIVATE',
|
|
48
|
+
message: message => `Failed to activate model, error: ${message}`
|
|
49
|
+
},
|
|
50
|
+
[ERR_CODES.MISSING_REQUIRED_PARAMETER]: {
|
|
51
|
+
name: 'MISSING_REQUIRED_PARAMETER',
|
|
52
|
+
message: message => `Missing required parameter: ${message}`
|
|
53
|
+
},
|
|
54
|
+
[ERR_CODES.UNSUPPORTED_LANGUAGE]: {
|
|
55
|
+
name: 'UNSUPPORTED_LANGUAGE',
|
|
56
|
+
message: message => `Unsupported language(s): ${message}`
|
|
57
|
+
},
|
|
58
|
+
[ERR_CODES.INVALID_IMAGE_OR_INSUFFICIENT_DATA]: {
|
|
59
|
+
name: 'INVALID_IMAGE_OR_INSUFFICIENT_DATA',
|
|
60
|
+
message: message => `Invalid image file or insufficient data: ${message}`
|
|
61
|
+
},
|
|
62
|
+
[ERR_CODES.UNSUPPORTED_IMAGE_FORMAT]: {
|
|
63
|
+
name: 'UNSUPPORTED_IMAGE_FORMAT',
|
|
64
|
+
message: message => `Unsupported image format: ${message}`
|
|
65
|
+
},
|
|
66
|
+
[ERR_CODES.NOT_LOADED]: {
|
|
67
|
+
name: 'NOT_LOADED',
|
|
68
|
+
message: message => `OCR model is not loaded: ${message}`
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
name,
|
|
73
|
+
version
|
|
74
|
+
}
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
module.exports = {
|
|
78
|
+
ERR_CODES,
|
|
79
|
+
QvacErrorAddonOcrGgml
|
|
80
|
+
}
|