runonweb 0.0.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/README.md +165 -0
- package/package.json +126 -0
- package/src/caption/index.ts +310 -0
- package/src/clean/index.ts +263 -0
- package/src/core/cache.ts +248 -0
- package/src/core/device.ts +34 -0
- package/src/core/index.ts +18 -0
- package/src/core/pipeline.ts +108 -0
- package/src/core/progress.ts +20 -0
- package/src/depth/index.ts +127 -0
- package/src/detect/index.ts +274 -0
- package/src/embed/index.ts +172 -0
- package/src/emoji/index.ts +132 -0
- package/src/image/engine.d.ts +57 -0
- package/src/image/engine.js +14090 -0
- package/src/image/index.ts +230 -0
- package/src/image/sizes.ts +28 -0
- package/src/ocr/index.ts +416 -0
- package/src/ocr/sizes.ts +12 -0
- package/src/remove-bg/index.ts +170 -0
- package/src/stt/index.ts +320 -0
- package/src/translate/bergamot.ts +280 -0
- package/src/translate/index.ts +240 -0
- package/src/translate/registry.ts +133 -0
- package/src/translate/worker.ts +181 -0
- package/src/tts/index.ts +220 -0
- package/src/tts/kitten.ts +280 -0
- package/src/tts/kokoro.ts +140 -0
- package/src/tts/phonemes.ts +150 -0
- package/src/tts/sizes.ts +30 -0
- package/src/tts/split.ts +31 -0
- package/src/tts/supertonic.ts +126 -0
- package/src/tts/types.ts +11 -0
- package/src/tts/voices.ts +131 -0
- package/src/tts/wav.ts +52 -0
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import type { ProgressCallback, ProgressInfo } from '../core/index.ts'
|
|
2
|
+
import { isWebGPUAvailable } from '../core/device.ts'
|
|
3
|
+
import type { BonsaiImagePipeline } from './engine.js'
|
|
4
|
+
import {
|
|
5
|
+
DEFAULT_IMAGE_SIZE,
|
|
6
|
+
IMAGE_MODELS,
|
|
7
|
+
IMAGE_SIZES,
|
|
8
|
+
type ImageSize,
|
|
9
|
+
} from './sizes.ts'
|
|
10
|
+
|
|
11
|
+
export { DEFAULT_IMAGE_SIZE, IMAGE_MODELS, IMAGE_SIZES }
|
|
12
|
+
export type { ImageSize }
|
|
13
|
+
export type { ProgressInfo }
|
|
14
|
+
|
|
15
|
+
const LOAD_WEIGHTS: Record<string, number> = {
|
|
16
|
+
text_encoder: 0.58,
|
|
17
|
+
transformer: 0.38,
|
|
18
|
+
vae: 0.04,
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const MIN_SIDE = 256
|
|
22
|
+
const MAX_SIDE = 1280
|
|
23
|
+
|
|
24
|
+
export type ImageGeneratorOptions = {
|
|
25
|
+
/** Weight variant. `binary` is smaller; `ternary` is closer to FLUX.2 Klein. */
|
|
26
|
+
size?: ImageSize
|
|
27
|
+
/** Hugging Face repo id. Overrides `size`. Must be an `-mlx-` Bonsai Image variant. */
|
|
28
|
+
model?: string
|
|
29
|
+
/** Default width in pixels (multiple of 16). Default 512. */
|
|
30
|
+
width?: number
|
|
31
|
+
/** Default height in pixels (multiple of 16). Default 512. */
|
|
32
|
+
height?: number
|
|
33
|
+
/** FlowMatch-Euler steps. The model is tuned for 4. */
|
|
34
|
+
steps?: number
|
|
35
|
+
/** Fixed seed. Omit for a random seed on each generate. */
|
|
36
|
+
seed?: number
|
|
37
|
+
onProgress?: ProgressCallback
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type GenerateOptions = {
|
|
41
|
+
width?: number
|
|
42
|
+
height?: number
|
|
43
|
+
steps?: number
|
|
44
|
+
seed?: number
|
|
45
|
+
signal?: AbortSignal
|
|
46
|
+
onStep?: (info: { step: number; steps: number }) => void
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type ImageResult = {
|
|
50
|
+
/** PNG Blob. */
|
|
51
|
+
image: Blob
|
|
52
|
+
width: number
|
|
53
|
+
height: number
|
|
54
|
+
prompt: string
|
|
55
|
+
seed: number
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function alignSide(value: number, label: string): number {
|
|
59
|
+
if (!Number.isFinite(value)) throw new Error(`${label} must be a number`)
|
|
60
|
+
const aligned = Math.round(value / 16) * 16
|
|
61
|
+
if (aligned < MIN_SIDE || aligned > MAX_SIDE) {
|
|
62
|
+
throw new Error(`${label} must be between ${MIN_SIDE} and ${MAX_SIDE} (got ${value})`)
|
|
63
|
+
}
|
|
64
|
+
return aligned
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function randomSeed(): number {
|
|
68
|
+
return Math.floor(Math.random() * 1_000_000_000)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function mapLoadProgress(onProgress?: ProgressCallback): (status: Record<string, unknown>) => void {
|
|
72
|
+
const parts: Record<string, number> = {}
|
|
73
|
+
return (status) => {
|
|
74
|
+
const component = typeof status.component === 'string' ? status.component : undefined
|
|
75
|
+
const loaded = typeof status.loaded === 'number' ? status.loaded : 0
|
|
76
|
+
const total = typeof status.total === 'number' ? status.total : 0
|
|
77
|
+
if (component && total > 0) parts[component] = loaded / total
|
|
78
|
+
|
|
79
|
+
let progress = 0
|
|
80
|
+
for (const [name, weight] of Object.entries(LOAD_WEIGHTS)) {
|
|
81
|
+
progress += (parts[name] ?? 0) * weight
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
onProgress?.({
|
|
85
|
+
status: component ? `loading ${component}` : 'loading',
|
|
86
|
+
progress: progress * 100,
|
|
87
|
+
file: component,
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Text-to-image in the browser (Bonsai Image 4B, WebGPU).
|
|
94
|
+
*
|
|
95
|
+
* @example
|
|
96
|
+
* ```ts
|
|
97
|
+
* import { ImageGenerator } from 'runonweb/image'
|
|
98
|
+
*
|
|
99
|
+
* const gen = new ImageGenerator()
|
|
100
|
+
* await gen.load()
|
|
101
|
+
* const { image } = await gen.generate('A bonsai tree in a ceramic studio')
|
|
102
|
+
* ```
|
|
103
|
+
*/
|
|
104
|
+
export class ImageGenerator {
|
|
105
|
+
#modelId: string
|
|
106
|
+
#width: number
|
|
107
|
+
#height: number
|
|
108
|
+
#steps: number
|
|
109
|
+
#seed?: number
|
|
110
|
+
#onProgress?: ProgressCallback
|
|
111
|
+
#pipeline: BonsaiImagePipeline | null = null
|
|
112
|
+
#loading: Promise<void> | null = null
|
|
113
|
+
#resolvedDevice: 'webgpu' | null = null
|
|
114
|
+
|
|
115
|
+
constructor(options: ImageGeneratorOptions = {}) {
|
|
116
|
+
const size = options.size ?? DEFAULT_IMAGE_SIZE
|
|
117
|
+
this.#modelId = options.model ?? IMAGE_SIZES[size].model
|
|
118
|
+
this.#width = options.width ?? 512
|
|
119
|
+
this.#height = options.height ?? 512
|
|
120
|
+
this.#steps = options.steps ?? 4
|
|
121
|
+
this.#seed = options.seed
|
|
122
|
+
this.#onProgress = options.onProgress
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
get device(): 'webgpu' | null {
|
|
126
|
+
return this.#resolvedDevice
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
get model(): string {
|
|
130
|
+
return this.#modelId
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async load(): Promise<void> {
|
|
134
|
+
if (this.#pipeline) return
|
|
135
|
+
if (this.#loading) return this.#loading
|
|
136
|
+
|
|
137
|
+
this.#loading = (async () => {
|
|
138
|
+
const { BonsaiImagePipeline } = await import('./engine.js')
|
|
139
|
+
if (!(await isWebGPUAvailable()) || !(await BonsaiImagePipeline.isSupported())) {
|
|
140
|
+
throw new Error('ImageGenerator requires WebGPU. This model has no WASM fallback.')
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
this.#onProgress?.({ status: 'loading', progress: 0 })
|
|
144
|
+
|
|
145
|
+
const pipeline = await BonsaiImagePipeline.from_pretrained(this.#modelId, {
|
|
146
|
+
onProgress: mapLoadProgress(this.#onProgress),
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
this.#pipeline = pipeline
|
|
150
|
+
this.#resolvedDevice = 'webgpu'
|
|
151
|
+
this.#onProgress?.({ status: 'ready', progress: 100 })
|
|
152
|
+
})()
|
|
153
|
+
|
|
154
|
+
try {
|
|
155
|
+
await this.#loading
|
|
156
|
+
} finally {
|
|
157
|
+
this.#loading = null
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async generate(prompt: string, options?: GenerateOptions): Promise<ImageResult> {
|
|
162
|
+
await this.load()
|
|
163
|
+
const pipeline = this.#pipeline
|
|
164
|
+
if (!pipeline) throw new Error('ImageGenerator model failed to load')
|
|
165
|
+
|
|
166
|
+
const text = prompt.trim()
|
|
167
|
+
if (!text) throw new Error('Prompt is empty')
|
|
168
|
+
|
|
169
|
+
const width = alignSide(options?.width ?? this.#width, 'width')
|
|
170
|
+
const height = alignSide(options?.height ?? this.#height, 'height')
|
|
171
|
+
const steps = options?.steps ?? this.#steps
|
|
172
|
+
if (!Number.isInteger(steps) || steps < 1 || steps > 50) {
|
|
173
|
+
throw new Error('steps must be an integer between 1 and 50')
|
|
174
|
+
}
|
|
175
|
+
const seed = options?.seed ?? this.#seed ?? randomSeed()
|
|
176
|
+
|
|
177
|
+
this.#onProgress?.({ status: 'generating', progress: 0 })
|
|
178
|
+
|
|
179
|
+
const result = await pipeline.generate({
|
|
180
|
+
prompt: text,
|
|
181
|
+
width,
|
|
182
|
+
height,
|
|
183
|
+
numInferenceSteps: steps,
|
|
184
|
+
seed,
|
|
185
|
+
signal: options?.signal,
|
|
186
|
+
callbackOnStepEnd: (_pipeline, step) => {
|
|
187
|
+
const done = step + 1
|
|
188
|
+
this.#onProgress?.({ status: 'generating', progress: (done / steps) * 100 })
|
|
189
|
+
options?.onStep?.({ step: done, steps })
|
|
190
|
+
},
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
this.#onProgress?.({ status: 'done', progress: 100 })
|
|
194
|
+
return {
|
|
195
|
+
image: result.toBlob(),
|
|
196
|
+
width: result.width,
|
|
197
|
+
height: result.height,
|
|
198
|
+
prompt: result.prompt,
|
|
199
|
+
seed: result.seed,
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
dispose(): void {
|
|
204
|
+
const pipeline = this.#pipeline
|
|
205
|
+
this.#pipeline = null
|
|
206
|
+
this.#resolvedDevice = null
|
|
207
|
+
void pipeline?.destroy()
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export async function generateImage(
|
|
212
|
+
prompt: string,
|
|
213
|
+
options?: ImageGeneratorOptions
|
|
214
|
+
): Promise<ImageResult> {
|
|
215
|
+
const generator = new ImageGenerator(options)
|
|
216
|
+
try {
|
|
217
|
+
return await generator.generate(prompt)
|
|
218
|
+
} finally {
|
|
219
|
+
generator.dispose()
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export async function isImageGenerationSupported(): Promise<boolean> {
|
|
224
|
+
return isWebGPUAvailable()
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export async function clearImageCache(): Promise<void> {
|
|
228
|
+
const { BonsaiImagePipeline } = await import('./engine.js')
|
|
229
|
+
await BonsaiImagePipeline.clearCache()
|
|
230
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export type ImageSize = 'binary' | 'ternary'
|
|
2
|
+
|
|
3
|
+
export const IMAGE_MODELS = {
|
|
4
|
+
binary: 'prism-ml/bonsai-image-binary-4B-mlx-1bit',
|
|
5
|
+
ternary: 'prism-ml/bonsai-image-ternary-4B-mlx-2bit',
|
|
6
|
+
} as const
|
|
7
|
+
|
|
8
|
+
export const IMAGE_SIZES: Record<
|
|
9
|
+
ImageSize,
|
|
10
|
+
{ label: string; model: string; download: string; quality: string; bits: string }
|
|
11
|
+
> = {
|
|
12
|
+
binary: {
|
|
13
|
+
label: 'Fast',
|
|
14
|
+
model: IMAGE_MODELS.binary,
|
|
15
|
+
download: '~3.4 GB',
|
|
16
|
+
quality: '1-bit transformer, quicker download',
|
|
17
|
+
bits: '1-bit',
|
|
18
|
+
},
|
|
19
|
+
ternary: {
|
|
20
|
+
label: 'Quality',
|
|
21
|
+
model: IMAGE_MODELS.ternary,
|
|
22
|
+
download: '~3.9 GB',
|
|
23
|
+
quality: '1.58-bit transformer, closer to FLUX.2 Klein',
|
|
24
|
+
bits: '1.58-bit',
|
|
25
|
+
},
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const DEFAULT_IMAGE_SIZE: ImageSize = 'binary'
|
package/src/ocr/index.ts
ADDED
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
import type { Device, ProgressCallback, ProgressInfo, ResolvedDevice } from '../core/index.ts'
|
|
2
|
+
import { resolveDevice } from '../core/device.ts'
|
|
3
|
+
import { PaddleOcrService } from 'paddleocr'
|
|
4
|
+
import { DEFAULT_OCR_SIZE, type OCRSize } from './sizes.ts'
|
|
5
|
+
|
|
6
|
+
export { DEFAULT_OCR_SIZE, OCR_SIZES, type OCRSize } from './sizes.ts'
|
|
7
|
+
|
|
8
|
+
const HF = {
|
|
9
|
+
tiny: {
|
|
10
|
+
det: 'https://huggingface.co/PaddlePaddle/PP-OCRv6_tiny_det_onnx/resolve/main/inference.onnx',
|
|
11
|
+
rec: 'https://huggingface.co/PaddlePaddle/PP-OCRv6_tiny_rec_onnx/resolve/main/inference.onnx',
|
|
12
|
+
yml: 'https://huggingface.co/PaddlePaddle/PP-OCRv6_tiny_rec_onnx/resolve/main/inference.yml',
|
|
13
|
+
},
|
|
14
|
+
small: {
|
|
15
|
+
det: 'https://huggingface.co/PaddlePaddle/PP-OCRv6_small_det_onnx/resolve/main/inference.onnx',
|
|
16
|
+
rec: 'https://huggingface.co/PaddlePaddle/PP-OCRv6_small_rec_onnx/resolve/main/inference.onnx',
|
|
17
|
+
yml: 'https://huggingface.co/PaddlePaddle/PP-OCRv6_small_rec_onnx/resolve/main/inference.yml',
|
|
18
|
+
},
|
|
19
|
+
medium: {
|
|
20
|
+
det: 'https://huggingface.co/PaddlePaddle/PP-OCRv6_medium_det_onnx/resolve/main/inference.onnx',
|
|
21
|
+
rec: 'https://huggingface.co/PaddlePaddle/PP-OCRv6_medium_rec_onnx/resolve/main/inference.onnx',
|
|
22
|
+
yml: 'https://huggingface.co/PaddlePaddle/PP-OCRv6_medium_rec_onnx/resolve/main/inference.yml',
|
|
23
|
+
},
|
|
24
|
+
} as const
|
|
25
|
+
|
|
26
|
+
const PRESET = {
|
|
27
|
+
tiny: 'PP-OCRv6_tiny',
|
|
28
|
+
small: 'PP-OCRv6_small',
|
|
29
|
+
medium: 'PP-OCRv6_medium',
|
|
30
|
+
} as const
|
|
31
|
+
|
|
32
|
+
const CACHE = 'runonweb-ppocrv6'
|
|
33
|
+
const ORT_WASM = 'https://cdn.jsdelivr.net/npm/onnxruntime-web@1.30.0/dist/'
|
|
34
|
+
|
|
35
|
+
export type OCROptions = {
|
|
36
|
+
/** Model tier. Defaults to `small` (best size/quality). */
|
|
37
|
+
size?: OCRSize
|
|
38
|
+
/** Inference device. Defaults to `auto` (WebGPU when available, else WASM). */
|
|
39
|
+
device?: Device
|
|
40
|
+
onProgress?: ProgressCallback
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type OCRImageInput = Blob | File | string | HTMLImageElement | ImageData | HTMLCanvasElement
|
|
44
|
+
|
|
45
|
+
export type OCRBox = {
|
|
46
|
+
xmin: number
|
|
47
|
+
ymin: number
|
|
48
|
+
xmax: number
|
|
49
|
+
ymax: number
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type OCRLine = {
|
|
53
|
+
text: string
|
|
54
|
+
score: number
|
|
55
|
+
box: OCRBox
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type OCRResult = {
|
|
59
|
+
text: string
|
|
60
|
+
lines: OCRLine[]
|
|
61
|
+
confidence: number
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* On-device OCR with PP-OCRv6 (PaddleOCR, Apache-2.0).
|
|
66
|
+
*
|
|
67
|
+
* @example
|
|
68
|
+
* ```ts
|
|
69
|
+
* import { OCR } from 'runonweb/ocr'
|
|
70
|
+
*
|
|
71
|
+
* const ocr = new OCR() // size: 'small'
|
|
72
|
+
* await ocr.load()
|
|
73
|
+
* const { text, lines } = await ocr.read(imageFile)
|
|
74
|
+
* ```
|
|
75
|
+
*/
|
|
76
|
+
export class OCR {
|
|
77
|
+
#size: OCRSize
|
|
78
|
+
#device: Device
|
|
79
|
+
#onProgress?: ProgressCallback
|
|
80
|
+
#service: PaddleOcrService | null = null
|
|
81
|
+
#loading: Promise<void> | null = null
|
|
82
|
+
#pending: Promise<void> = Promise.resolve()
|
|
83
|
+
#resolvedDevice: ResolvedDevice | null = null
|
|
84
|
+
#disposed = false
|
|
85
|
+
#disposeDone: Promise<void> | null = null
|
|
86
|
+
|
|
87
|
+
constructor(options: OCROptions = {}) {
|
|
88
|
+
this.#size = options.size ?? DEFAULT_OCR_SIZE
|
|
89
|
+
this.#device = options.device ?? 'auto'
|
|
90
|
+
this.#onProgress = options.onProgress
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
get size(): OCRSize {
|
|
94
|
+
return this.#size
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
get device(): ResolvedDevice | null {
|
|
98
|
+
return this.#resolvedDevice
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async load(): Promise<void> {
|
|
102
|
+
if (this.#disposed) throw new Error('OCR model was disposed')
|
|
103
|
+
if (this.#service) return
|
|
104
|
+
if (this.#loading) return this.#loading
|
|
105
|
+
|
|
106
|
+
this.#loading = (async () => {
|
|
107
|
+
const urls = HF[this.#size]
|
|
108
|
+
this.#onProgress?.({ status: 'loading', progress: 0 })
|
|
109
|
+
|
|
110
|
+
const [det, rec, yml] = await Promise.all([
|
|
111
|
+
fetchCached(urls.det, 'det.onnx', this.#onProgress),
|
|
112
|
+
fetchCached(urls.rec, 'rec.onnx', this.#onProgress),
|
|
113
|
+
fetchCachedText(urls.yml),
|
|
114
|
+
])
|
|
115
|
+
|
|
116
|
+
const charactersDictionary = parseCharacterDict(yml)
|
|
117
|
+
if (charactersDictionary[charactersDictionary.length - 1] !== ' ') {
|
|
118
|
+
charactersDictionary.push(' ')
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const ort = await loadOrt()
|
|
122
|
+
const preferred = await resolveDevice(this.#device)
|
|
123
|
+
const service = await createService({
|
|
124
|
+
ort,
|
|
125
|
+
size: this.#size,
|
|
126
|
+
det,
|
|
127
|
+
rec,
|
|
128
|
+
charactersDictionary,
|
|
129
|
+
device: preferred,
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
if (this.#disposed) {
|
|
133
|
+
await takeTurn(() => service.ocr.destroy())
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
this.#service = service.ocr
|
|
138
|
+
this.#resolvedDevice = service.device
|
|
139
|
+
this.#onProgress?.({ status: 'ready', progress: 100 })
|
|
140
|
+
})()
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
await this.#loading
|
|
144
|
+
} finally {
|
|
145
|
+
this.#loading = null
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
read(image: OCRImageInput): Promise<OCRResult> {
|
|
150
|
+
if (this.#disposed) return Promise.reject(new Error('OCR model was disposed'))
|
|
151
|
+
const job = this.#read(image)
|
|
152
|
+
this.#pending = this.#pending.then(
|
|
153
|
+
() => job.then(
|
|
154
|
+
() => undefined,
|
|
155
|
+
() => undefined,
|
|
156
|
+
),
|
|
157
|
+
)
|
|
158
|
+
return job
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Drop the sessions. Waits for an in-flight `read` so `destroy` cannot
|
|
163
|
+
* release a session that is still inside OrtRun.
|
|
164
|
+
*/
|
|
165
|
+
async dispose(): Promise<void> {
|
|
166
|
+
if (this.#disposeDone) return this.#disposeDone
|
|
167
|
+
this.#disposed = true
|
|
168
|
+
this.#disposeDone = (async () => {
|
|
169
|
+
await this.#pending
|
|
170
|
+
await this.#loading
|
|
171
|
+
const service = this.#service
|
|
172
|
+
this.#service = null
|
|
173
|
+
this.#resolvedDevice = null
|
|
174
|
+
if (!service) return
|
|
175
|
+
await takeTurn(() => service.destroy())
|
|
176
|
+
})()
|
|
177
|
+
return this.#disposeDone
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async #read(image: OCRImageInput): Promise<OCRResult> {
|
|
181
|
+
if (this.#disposed) throw new Error('OCR model was disposed')
|
|
182
|
+
await this.load()
|
|
183
|
+
if (this.#disposed) throw new Error('OCR model was disposed')
|
|
184
|
+
const service = this.#service
|
|
185
|
+
if (!service) throw new Error('OCR model failed to load')
|
|
186
|
+
|
|
187
|
+
this.#onProgress?.({ status: 'recognizing' })
|
|
188
|
+
const pixels = await imageToPixels(image)
|
|
189
|
+
const raw = await takeTurn(() => {
|
|
190
|
+
if (this.#disposed) throw new Error('OCR model was disposed')
|
|
191
|
+
return service.recognize(pixels)
|
|
192
|
+
})
|
|
193
|
+
const processed = service.processRecognition(raw)
|
|
194
|
+
|
|
195
|
+
const lines: OCRLine[] = raw.map((item) => ({
|
|
196
|
+
text: item.text,
|
|
197
|
+
score: item.confidence,
|
|
198
|
+
box: {
|
|
199
|
+
xmin: item.box.x,
|
|
200
|
+
ymin: item.box.y,
|
|
201
|
+
xmax: item.box.x + item.box.width,
|
|
202
|
+
ymax: item.box.y + item.box.height,
|
|
203
|
+
},
|
|
204
|
+
}))
|
|
205
|
+
|
|
206
|
+
this.#onProgress?.({ status: 'done' })
|
|
207
|
+
return {
|
|
208
|
+
text: processed.text,
|
|
209
|
+
lines,
|
|
210
|
+
confidence: processed.confidence,
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export async function read(image: OCRImageInput, options?: OCROptions): Promise<OCRResult> {
|
|
216
|
+
const ocr = new OCR(options)
|
|
217
|
+
try {
|
|
218
|
+
return await ocr.read(image)
|
|
219
|
+
} finally {
|
|
220
|
+
await ocr.dispose()
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* onnxruntime-web's WebGPU backend keeps a single global OrtRun slot.
|
|
226
|
+
* A second run throws "Session already started", and its `finally` clears
|
|
227
|
+
* that slot, so the first run then throws "Session mismatch".
|
|
228
|
+
*/
|
|
229
|
+
let ortTurn: Promise<void> = Promise.resolve()
|
|
230
|
+
|
|
231
|
+
function takeTurn<T>(fn: () => Promise<T>): Promise<T> {
|
|
232
|
+
const run = ortTurn.then(fn, fn)
|
|
233
|
+
ortTurn = run.then(
|
|
234
|
+
() => undefined,
|
|
235
|
+
() => undefined,
|
|
236
|
+
)
|
|
237
|
+
return run
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export type { ProgressInfo, Device }
|
|
241
|
+
|
|
242
|
+
type OrtApi = {
|
|
243
|
+
env: { wasm: { wasmPaths: string; numThreads: number } }
|
|
244
|
+
Tensor: unknown
|
|
245
|
+
InferenceSession: {
|
|
246
|
+
create: (model: ArrayBuffer, options?: { executionProviders?: string[] }) => Promise<unknown>
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
let ortModule: OrtApi | null = null
|
|
251
|
+
|
|
252
|
+
async function loadOrt(): Promise<OrtApi> {
|
|
253
|
+
if (ortModule) return ortModule
|
|
254
|
+
const ort = (await import('onnxruntime-web')) as unknown as OrtApi
|
|
255
|
+
ort.env.wasm.wasmPaths = ORT_WASM
|
|
256
|
+
ort.env.wasm.numThreads = 1
|
|
257
|
+
ortModule = ort
|
|
258
|
+
return ort
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function createService(opts: {
|
|
262
|
+
ort: OrtApi
|
|
263
|
+
size: OCRSize
|
|
264
|
+
det: ArrayBuffer
|
|
265
|
+
rec: ArrayBuffer
|
|
266
|
+
charactersDictionary: string[]
|
|
267
|
+
device: ResolvedDevice
|
|
268
|
+
}): Promise<{ ocr: PaddleOcrService; device: ResolvedDevice }> {
|
|
269
|
+
const tryDevice = async (device: ResolvedDevice) => {
|
|
270
|
+
const providers = device === 'webgpu' ? ['webgpu', 'wasm'] : ['wasm']
|
|
271
|
+
const wrapped = {
|
|
272
|
+
Tensor: opts.ort.Tensor,
|
|
273
|
+
InferenceSession: {
|
|
274
|
+
create: (modelBuffer: ArrayBuffer) =>
|
|
275
|
+
opts.ort.InferenceSession.create(modelBuffer, { executionProviders: providers }),
|
|
276
|
+
},
|
|
277
|
+
}
|
|
278
|
+
return PaddleOcrService.createInstance({
|
|
279
|
+
ort: wrapped as never,
|
|
280
|
+
modelPreset: PRESET[opts.size],
|
|
281
|
+
detection: { modelBuffer: opts.det.slice(0) },
|
|
282
|
+
recognition: {
|
|
283
|
+
modelBuffer: opts.rec.slice(0),
|
|
284
|
+
charactersDictionary: opts.charactersDictionary,
|
|
285
|
+
},
|
|
286
|
+
})
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
try {
|
|
290
|
+
return { ocr: await tryDevice(opts.device), device: opts.device }
|
|
291
|
+
} catch (err) {
|
|
292
|
+
if (opts.device === 'wasm') throw err
|
|
293
|
+
return { ocr: await tryDevice('wasm'), device: 'wasm' }
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async function fetchCached(
|
|
298
|
+
url: string,
|
|
299
|
+
file: string,
|
|
300
|
+
onProgress?: ProgressCallback
|
|
301
|
+
): Promise<ArrayBuffer> {
|
|
302
|
+
const cache = await caches.open(CACHE).catch(() => null)
|
|
303
|
+
const hit = await cache?.match(url)
|
|
304
|
+
if (hit) {
|
|
305
|
+
onProgress?.({ status: 'progress', progress: 100, file })
|
|
306
|
+
return hit.arrayBuffer()
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const res = await fetch(url)
|
|
310
|
+
if (!res.ok) throw new Error(`Could not load ${file} (${res.status})`)
|
|
311
|
+
|
|
312
|
+
const total = Number(res.headers.get('content-length') ?? 0)
|
|
313
|
+
if (!res.body || !total) {
|
|
314
|
+
const buf = await res.arrayBuffer()
|
|
315
|
+
await cache?.put(url, new Response(buf.slice(0)))
|
|
316
|
+
return buf
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const reader = res.body.getReader()
|
|
320
|
+
const chunks: Uint8Array[] = []
|
|
321
|
+
let received = 0
|
|
322
|
+
for (;;) {
|
|
323
|
+
const { done, value } = await reader.read()
|
|
324
|
+
if (done) break
|
|
325
|
+
chunks.push(value)
|
|
326
|
+
received += value.byteLength
|
|
327
|
+
onProgress?.({ status: 'progress', progress: (received / total) * 100, file })
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const out = new Uint8Array(received)
|
|
331
|
+
let offset = 0
|
|
332
|
+
for (const chunk of chunks) {
|
|
333
|
+
out.set(chunk, offset)
|
|
334
|
+
offset += chunk.byteLength
|
|
335
|
+
}
|
|
336
|
+
const buf = out.buffer
|
|
337
|
+
await cache?.put(url, new Response(buf.slice(0)))
|
|
338
|
+
return buf
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async function fetchCachedText(url: string): Promise<string> {
|
|
342
|
+
const cache = await caches.open(CACHE).catch(() => null)
|
|
343
|
+
const hit = await cache?.match(url)
|
|
344
|
+
if (hit) return hit.text()
|
|
345
|
+
const res = await fetch(url)
|
|
346
|
+
if (!res.ok) throw new Error(`Could not load dictionary (${res.status})`)
|
|
347
|
+
const text = await res.text()
|
|
348
|
+
await cache?.put(url, new Response(text))
|
|
349
|
+
return text
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function parseCharacterDict(yml: string): string[] {
|
|
353
|
+
const start = yml.indexOf('character_dict:')
|
|
354
|
+
if (start < 0) throw new Error('PP-OCRv6 dictionary missing character_dict')
|
|
355
|
+
const chars: string[] = []
|
|
356
|
+
for (const rawLine of yml.slice(start).split('\n').slice(1)) {
|
|
357
|
+
const item = rawLine.match(/^\s*-\s+(.*)$/)
|
|
358
|
+
if (!item) {
|
|
359
|
+
if (chars.length && /^\S/.test(rawLine)) break
|
|
360
|
+
continue
|
|
361
|
+
}
|
|
362
|
+
chars.push(unquoteYaml(item[1] ?? ''))
|
|
363
|
+
}
|
|
364
|
+
if (chars.length < 100) throw new Error('PP-OCRv6 dictionary looks empty')
|
|
365
|
+
return chars
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function unquoteYaml(value: string): string {
|
|
369
|
+
const v = value.trim()
|
|
370
|
+
if (v === "''" || v === '""') return ''
|
|
371
|
+
if ((v.startsWith("'") && v.endsWith("'")) || (v.startsWith('"') && v.endsWith('"'))) {
|
|
372
|
+
const inner = v.slice(1, -1)
|
|
373
|
+
if (v.startsWith("'")) return inner.replace(/''/g, "'")
|
|
374
|
+
return inner.replace(/\\"/g, '"')
|
|
375
|
+
}
|
|
376
|
+
return v
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async function imageToPixels(image: OCRImageInput): Promise<{ width: number; height: number; data: Uint8Array }> {
|
|
380
|
+
if (image instanceof ImageData) {
|
|
381
|
+
return { width: image.width, height: image.height, data: new Uint8Array(image.data) }
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
let source: CanvasImageSource
|
|
385
|
+
if (typeof image === 'string') {
|
|
386
|
+
source = await loadHtmlImage(image)
|
|
387
|
+
} else if (image instanceof HTMLImageElement) {
|
|
388
|
+
source = image
|
|
389
|
+
} else if (image instanceof HTMLCanvasElement) {
|
|
390
|
+
source = image
|
|
391
|
+
} else {
|
|
392
|
+
source = await createImageBitmap(image)
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const width = 'naturalWidth' in source ? source.naturalWidth || source.width : source.width
|
|
396
|
+
const height = 'naturalHeight' in source ? source.naturalHeight || source.height : source.height
|
|
397
|
+
const canvas = document.createElement('canvas')
|
|
398
|
+
canvas.width = width
|
|
399
|
+
canvas.height = height
|
|
400
|
+
const ctx = canvas.getContext('2d')
|
|
401
|
+
if (!ctx) throw new Error('Could not get 2d context')
|
|
402
|
+
ctx.drawImage(source, 0, 0)
|
|
403
|
+
const { data } = ctx.getImageData(0, 0, width, height)
|
|
404
|
+
if (typeof ImageBitmap !== 'undefined' && source instanceof ImageBitmap) source.close()
|
|
405
|
+
return { width, height, data: new Uint8Array(data) }
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function loadHtmlImage(src: string): Promise<HTMLImageElement> {
|
|
409
|
+
return new Promise((resolve, reject) => {
|
|
410
|
+
const img = new Image()
|
|
411
|
+
img.crossOrigin = 'anonymous'
|
|
412
|
+
img.onload = () => resolve(img)
|
|
413
|
+
img.onerror = () => reject(new Error(`Could not load image: ${src}`))
|
|
414
|
+
img.src = src
|
|
415
|
+
})
|
|
416
|
+
}
|
package/src/ocr/sizes.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export type OCRSize = 'tiny' | 'small' | 'medium'
|
|
2
|
+
|
|
3
|
+
export const OCR_SIZES: Record<
|
|
4
|
+
OCRSize,
|
|
5
|
+
{ label: string; params: string; downloadMB: string; detHmean: string; recAcc: string }
|
|
6
|
+
> = {
|
|
7
|
+
tiny: { label: 'Tiny', params: '1.5M', downloadMB: '~6 MB', detHmean: '80.6%', recAcc: '73.5%' },
|
|
8
|
+
small: { label: 'Small', params: '7.7M', downloadMB: '~31 MB', detHmean: '84.1%', recAcc: '81.3%' },
|
|
9
|
+
medium: { label: 'Medium', params: '34.5M', downloadMB: '~139 MB', detHmean: '86.2%', recAcc: '83.2%' },
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const DEFAULT_OCR_SIZE: OCRSize = 'small'
|