@mlx-node/vlm 0.0.13 → 0.0.15

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mlx-node/vlm",
3
- "version": "0.0.13",
3
+ "version": "0.0.15",
4
4
  "homepage": "https://github.com/mlx-node/mlx-node",
5
5
  "bugs": {
6
6
  "url": "https://github.com/mlx-node/mlx-node/issues"
@@ -12,13 +12,15 @@
12
12
  "directory": "packages/vlm"
13
13
  },
14
14
  "files": [
15
- "dist"
15
+ "dist",
16
+ "src"
16
17
  ],
17
18
  "type": "module",
18
19
  "main": "./dist/index.js",
19
20
  "types": "./dist/index.d.ts",
20
21
  "exports": {
21
22
  ".": {
23
+ "@mlx-node/source": "./src/index.ts",
22
24
  "types": "./dist/index.d.ts",
23
25
  "import": "./dist/index.js"
24
26
  }
@@ -28,8 +30,8 @@
28
30
  "test": "vite test run"
29
31
  },
30
32
  "dependencies": {
31
- "@mlx-node/core": "0.0.13",
32
- "@mlx-node/lm": "0.0.13",
33
+ "@mlx-node/core": "0.0.15",
34
+ "@mlx-node/lm": "0.0.15",
33
35
  "@napi-rs/image": "^1.14.0"
34
36
  },
35
37
  "devDependencies": {
package/src/index.ts ADDED
@@ -0,0 +1,94 @@
1
+ /**
2
+ * @mlx-node/vlm - Vision Language Model support for MLX-Node
3
+ *
4
+ * This package provides VLM capabilities including:
5
+ * - VLModel for OCR and document understanding tasks
6
+ *
7
+ * @example
8
+ * ```typescript
9
+ * import { VLModel } from '@mlx-node/vlm';
10
+ *
11
+ * // Load a model
12
+ * const model = await VLModel.load('./models/paddleocr-vl');
13
+ *
14
+ * // Chat with images
15
+ * const imageBuffer = readFileSync('./photo.jpg');
16
+ * const result = await model.chat(
17
+ * [{ role: 'user', content: 'What is in this image?' }],
18
+ * { images: [imageBuffer] }
19
+ * );
20
+ * console.log(result.text);
21
+ *
22
+ * // Simple OCR
23
+ * const text = await model.ocr(readFileSync('./document.jpg'));
24
+ *
25
+ * // Batch OCR (multiple images)
26
+ * const texts = await model.ocrBatch([readFileSync('page1.jpg'), readFileSync('page2.jpg')]);
27
+ * ```
28
+ */
29
+
30
+ // ============== PUBLIC API ==============
31
+
32
+ // Main model class and factory functions (exposed directly from Rust)
33
+ export { VLModel, createPaddleocrVlConfig } from '@mlx-node/core';
34
+
35
+ // Document layout analysis model
36
+ export { DocLayoutModel, type LayoutElement } from '@mlx-node/core';
37
+
38
+ // Text detection and recognition models (PP-OCRv5)
39
+ export { TextDetModel, type TextBox, TextRecModel, type RecResult } from '@mlx-node/core';
40
+
41
+ // Document preprocessing models
42
+ export {
43
+ DocOrientationModel,
44
+ type OrientationResult,
45
+ type ClassifyRotateResult,
46
+ DocUnwarpModel,
47
+ type UnwarpResult,
48
+ } from '@mlx-node/core';
49
+
50
+ // Document understanding pipeline (PP-StructureV3)
51
+ export {
52
+ StructureV3Pipeline,
53
+ type StructureV3Config,
54
+ type AnalyzeOptions,
55
+ type StructuredElement,
56
+ type StructuredDocument,
57
+ type TextLine,
58
+ } from './pipeline/structure-v3.js';
59
+
60
+ // Configuration types
61
+ export type {
62
+ VisionConfig,
63
+ TextConfig,
64
+ ModelConfig,
65
+ VlmChatConfig,
66
+ VlmChatMessage,
67
+ VlmBatchItem,
68
+ } from '@mlx-node/core';
69
+
70
+ // Qianfan-OCR model (InternVL architecture)
71
+ export { QianfanOCRModel } from './models/qianfan-ocr.js';
72
+ export { createQianfanOcrConfig } from '@mlx-node/core';
73
+ export type { QianfanOcrConfig, InternVisionConfig, Qwen3LmConfig } from '@mlx-node/core';
74
+
75
+ // Chat result type
76
+ export { VlmChatResult, type VLMChatResult } from '@mlx-node/core';
77
+
78
+ // Output parsing and formatting (Rust implementation)
79
+ export {
80
+ parsePaddleResponse,
81
+ parseVlmOutput,
82
+ formatDocument,
83
+ type ParsedDocument,
84
+ type DocumentElement,
85
+ type Table,
86
+ type TableRow,
87
+ type TableCell,
88
+ type Paragraph,
89
+ type ParserConfig,
90
+ OutputFormat,
91
+ } from '@mlx-node/core';
92
+
93
+ // XLSX export (Rust implementation)
94
+ export { documentToXlsx, saveToXlsx } from '@mlx-node/core';
@@ -0,0 +1,45 @@
1
+ import { QianfanOCRModel as QianfanOCRModelNative } from '@mlx-node/core';
2
+ import { makeStreamingModel } from '@mlx-node/lm';
3
+ import type { SessionCapableModel } from '@mlx-node/lm';
4
+
5
+ /**
6
+ * Qianfan-OCR Vision-Language Model wrapper.
7
+ *
8
+ * Built from the shared {@link makeStreamingModel} factory in
9
+ * `@mlx-node/lm` — the empty `extends` inherits the AsyncGenerator
10
+ * session-streaming overrides (`chatStreamSessionStart` /
11
+ * `chatStreamSessionContinue` / `chatStreamSessionContinueTool`) so the
12
+ * wrapper structurally satisfies `SessionCapableModel` and can be
13
+ * passed to `ChatSession<QianfanOCRModel>`. Importing the factory from
14
+ * `@mlx-node/lm` is one-directional (vlm → lm), matching the existing
15
+ * `_runChatStream` dependency, so it introduces no circular dependency.
16
+ *
17
+ * Qianfan-OCR is a VLM (InternViT + Qwen3 language model). The continue
18
+ * path cannot splice new vision features into a live KV cache — image
19
+ * changes always require a fresh session start, which the high-level
20
+ * `ChatSession` wrapper handles via its `lastImagesKey` check.
21
+ *
22
+ * Qianfan-OCR records its model path so `applyChatTemplate` uses the exact
23
+ * tokenizer/template asset required by native inference and token counting.
24
+ */
25
+ export class QianfanOCRModel extends makeStreamingModel(QianfanOCRModelNative, {
26
+ recordModelPath: true,
27
+ templateContentPolicy: {
28
+ order: 'imagesThenText',
29
+ existingImagePlaceholder: '<image>',
30
+ },
31
+ }) {}
32
+
33
+ // -------------------------------------------------------------------
34
+ // Compile-time conformance check
35
+ // -------------------------------------------------------------------
36
+ //
37
+ // Ensures the wrapper structurally satisfies `SessionCapableModel` so
38
+ // `ChatSession<QianfanOCRModel>` will type-check in downstream code.
39
+ // The assignment is compile-only — the `null as unknown as T`
40
+ // placeholder never runs.
41
+ function _assertSessionCapable(): void {
42
+ const _qianfan: SessionCapableModel = null as unknown as QianfanOCRModel;
43
+ void _qianfan;
44
+ }
45
+ void _assertSessionCapable;
@@ -0,0 +1,435 @@
1
+ /**
2
+ * PP-StructureV3 Document Understanding Pipeline
3
+ *
4
+ * Combines PP-DocLayoutV3 (layout detection) with PP-OCRv5 (text detection + recognition)
5
+ * for fast, accurate document understanding without a VLM.
6
+ *
7
+ * Pipeline:
8
+ * 1. DocLayoutModel detects layout elements (titles, text, tables, figures...)
9
+ * 2. For text/title/list elements: TextDetModel detects text lines → TextRecModel recognizes text
10
+ * 3. For table/formula/chart: VLM fallback (optional)
11
+ * 4. Results assembled into structured markdown in reading order
12
+ *
13
+ * @example
14
+ * ```typescript
15
+ * import { StructureV3Pipeline } from '@mlx-node/vlm';
16
+ *
17
+ * const pipeline = StructureV3Pipeline.load({
18
+ * layoutModelPath: './models/PP-DocLayoutV3',
19
+ * textDetModelPath: './models/PP-OCRv5_server_det',
20
+ * textRecModelPath: './models/PP-OCRv5_server_rec',
21
+ * dictPath: './models/PP-OCRv5_server_rec/ppocr_keys_v1.txt',
22
+ * });
23
+ *
24
+ * const result = pipeline.analyze('./document.png');
25
+ * console.log(result.markdown);
26
+ * ```
27
+ */
28
+
29
+ import { readFileSync } from 'node:fs';
30
+
31
+ import {
32
+ DocLayoutModel,
33
+ TextDetModel,
34
+ TextRecModel,
35
+ DocOrientationModel,
36
+ DocUnwarpModel,
37
+ type LayoutElement,
38
+ } from '@mlx-node/core';
39
+
40
+ // ============================================================================
41
+ // Types
42
+ // ============================================================================
43
+
44
+ /** Configuration for loading the StructureV3 pipeline. */
45
+ export interface StructureV3Config {
46
+ /** Path to PP-DocLayoutV3 model directory */
47
+ layoutModelPath: string;
48
+ /** Path to PP-OCRv5 text detection model directory */
49
+ textDetModelPath: string;
50
+ /** Path to PP-OCRv5 text recognition model directory */
51
+ textRecModelPath: string;
52
+ /** Path to character dictionary file (e.g., ppocr_keys_v1.txt) */
53
+ dictPath: string;
54
+ /** Path to doc orientation classification model directory (optional) */
55
+ docOrientationModelPath?: string;
56
+ /** Path to doc unwarping model directory (optional) */
57
+ docUnwarpModelPath?: string;
58
+ }
59
+
60
+ /** Options for document analysis. */
61
+ export interface AnalyzeOptions {
62
+ /** Layout detection confidence threshold (default: 0.5) */
63
+ layoutThreshold?: number;
64
+ /** Text detection confidence threshold (default: 0.3) */
65
+ textDetThreshold?: number;
66
+ /** Whether to include element-level details in output (default: false) */
67
+ includeDetails?: boolean;
68
+ /** Whether to run document orientation classification (default: true if model loaded) */
69
+ useDocOrientationClassify?: boolean;
70
+ /** Whether to run document unwarping (default: true if model loaded) */
71
+ useDocUnwarping?: boolean;
72
+ }
73
+
74
+ /** A recognized text line within a layout element. */
75
+ export interface TextLine {
76
+ /** Bounding box [x1, y1, x2, y2] relative to the element crop */
77
+ bbox: number[];
78
+ /** Recognized text */
79
+ text: string;
80
+ /** Recognition confidence */
81
+ score: number;
82
+ }
83
+
84
+ /** A structured document element with recognized content. */
85
+ export interface StructuredElement {
86
+ /** Element type from layout detection */
87
+ label: string;
88
+ /** Detection confidence */
89
+ score: number;
90
+ /** Bounding box [x1, y1, x2, y2] in original image coordinates */
91
+ bbox: number[];
92
+ /** Reading order index */
93
+ order: number;
94
+ /** Recognized text content */
95
+ text: string;
96
+ /** Individual text lines (if includeDetails is true) */
97
+ lines?: TextLine[];
98
+ }
99
+
100
+ /** Result of document analysis. */
101
+ export interface StructuredDocument {
102
+ /** Structured elements in reading order */
103
+ elements: StructuredElement[];
104
+ /** Assembled markdown output */
105
+ markdown: string;
106
+ }
107
+
108
+ // ============================================================================
109
+ // Element type sets
110
+ // ============================================================================
111
+
112
+ /** Elements that contain text and should be processed with OCR */
113
+ const TEXT_LABELS = new Set([
114
+ 'title',
115
+ 'doc_title',
116
+ 'paragraph_title',
117
+ 'text',
118
+ 'abstract',
119
+ 'list',
120
+ 'table_caption',
121
+ 'table_footnote',
122
+ 'figure_caption',
123
+ 'chart_caption',
124
+ 'formula_caption',
125
+ 'code_txt',
126
+ 'header',
127
+ 'footer',
128
+ 'footnote',
129
+ 'margin_note',
130
+ 'reference',
131
+ 'content',
132
+ 'index',
133
+ 'handwriting',
134
+ ]);
135
+
136
+ /** Elements that are non-content (skipped) */
137
+ const SKIP_LABELS = new Set(['abandon']);
138
+
139
+ // ============================================================================
140
+ // Pipeline
141
+ // ============================================================================
142
+
143
+ /**
144
+ * PP-StructureV3 document understanding pipeline.
145
+ *
146
+ * Uses dedicated OCR models (TextDet + TextRec) instead of a VLM,
147
+ * providing ~4-5x faster text extraction with ~6x lower memory usage.
148
+ */
149
+ export class StructureV3Pipeline {
150
+ private layout: DocLayoutModel;
151
+ private textDet: TextDetModel;
152
+ private textRec: TextRecModel;
153
+ private docOrientation: DocOrientationModel | null;
154
+ private docUnwarp: DocUnwarpModel | null;
155
+
156
+ private constructor(
157
+ layout: DocLayoutModel,
158
+ textDet: TextDetModel,
159
+ textRec: TextRecModel,
160
+ docOrientation: DocOrientationModel | null,
161
+ docUnwarp: DocUnwarpModel | null,
162
+ ) {
163
+ this.layout = layout;
164
+ this.textDet = textDet;
165
+ this.textRec = textRec;
166
+ this.docOrientation = docOrientation;
167
+ this.docUnwarp = docUnwarp;
168
+ }
169
+
170
+ /**
171
+ * Load all models and create the pipeline.
172
+ */
173
+ static load(config: StructureV3Config): StructureV3Pipeline {
174
+ const layout = DocLayoutModel.load(config.layoutModelPath);
175
+ const textDet = TextDetModel.load(config.textDetModelPath);
176
+ const textRec = TextRecModel.load(config.textRecModelPath, config.dictPath);
177
+
178
+ const docOrientation = config.docOrientationModelPath
179
+ ? DocOrientationModel.load(config.docOrientationModelPath)
180
+ : null;
181
+ const docUnwarp = config.docUnwarpModelPath ? DocUnwarpModel.load(config.docUnwarpModelPath) : null;
182
+
183
+ return new StructureV3Pipeline(layout, textDet, textRec, docOrientation, docUnwarp);
184
+ }
185
+
186
+ /**
187
+ * Analyze a document image and extract structured content.
188
+ *
189
+ * @param imageData - Buffer with encoded image bytes, or a file path string
190
+ * @param options - Analysis options
191
+ * @returns Structured document with elements and markdown
192
+ */
193
+ async analyze(imageData: Uint8Array | string, options: AnalyzeOptions = {}): Promise<StructuredDocument> {
194
+ const { layoutThreshold = 0.5, textDetThreshold, includeDetails = false } = options;
195
+
196
+ let imageBuffer: Uint8Array = typeof imageData === 'string' ? readFileSync(imageData) : imageData;
197
+
198
+ // Step 0a: Document orientation correction
199
+ if (this.docOrientation && (options.useDocOrientationClassify ?? true)) {
200
+ const rotateResult = this.docOrientation.classifyAndRotate(imageBuffer);
201
+ if (rotateResult.angle !== 0) {
202
+ imageBuffer = Buffer.from(rotateResult.image);
203
+ }
204
+ }
205
+
206
+ // Step 0b: Document unwarping
207
+ if (this.docUnwarp && (options.useDocUnwarping ?? true)) {
208
+ const unwarpResult = this.docUnwarp.unwarp(imageBuffer);
209
+ imageBuffer = Buffer.from(unwarpResult.image);
210
+ }
211
+
212
+ // Step 1: Layout detection (on preprocessed image)
213
+ const layoutElements = this.layout.detect(imageBuffer, layoutThreshold);
214
+
215
+ if (layoutElements.length === 0) {
216
+ return { elements: [], markdown: '' };
217
+ }
218
+
219
+ // Step 2: Process each element
220
+ const elements: StructuredElement[] = [];
221
+
222
+ for (const el of layoutElements) {
223
+ const label = el.labelName;
224
+
225
+ if (SKIP_LABELS.has(label)) {
226
+ continue;
227
+ }
228
+
229
+ if (TEXT_LABELS.has(label)) {
230
+ // OCR path: detect text lines then recognize
231
+ const cropBuffer = await this.cropElement(imageBuffer, el);
232
+ const textLines = await this.ocrRegion(cropBuffer, textDetThreshold);
233
+
234
+ const fullText = textLines.map((l) => l.text).join('\n');
235
+
236
+ elements.push({
237
+ label,
238
+ score: el.score,
239
+ bbox: el.bbox,
240
+ order: el.order,
241
+ text: fullText,
242
+ lines: includeDetails ? textLines : undefined,
243
+ });
244
+ } else if (label === 'table') {
245
+ // Table: detect text lines in each cell (simplified — no cell-level structure yet)
246
+ const cropBuffer = await this.cropElement(imageBuffer, el);
247
+ const textLines = await this.ocrRegion(cropBuffer, textDetThreshold);
248
+ const fullText = textLines.map((l) => l.text).join('\n');
249
+
250
+ elements.push({
251
+ label,
252
+ score: el.score,
253
+ bbox: el.bbox,
254
+ order: el.order,
255
+ text: fullText,
256
+ lines: includeDetails ? textLines : undefined,
257
+ });
258
+ } else if (label === 'isolate_formula') {
259
+ // Formula: basic OCR (no LaTeX recognition yet)
260
+ const cropBuffer = await this.cropElement(imageBuffer, el);
261
+ const textLines = await this.ocrRegion(cropBuffer, textDetThreshold);
262
+ const fullText = textLines.map((l) => l.text).join(' ');
263
+
264
+ elements.push({
265
+ label,
266
+ score: el.score,
267
+ bbox: el.bbox,
268
+ order: el.order,
269
+ text: fullText,
270
+ });
271
+ } else {
272
+ // figure, chart, seal, etc. - placeholder
273
+ elements.push({
274
+ label,
275
+ score: el.score,
276
+ bbox: el.bbox,
277
+ order: el.order,
278
+ text: '',
279
+ });
280
+ }
281
+ }
282
+
283
+ // Step 3: Assemble markdown
284
+ const markdown = assembleMarkdown(elements);
285
+
286
+ return { elements, markdown };
287
+ }
288
+
289
+ /**
290
+ * Run text detection + recognition on a single image (no layout detection).
291
+ *
292
+ * Useful for processing pre-cropped text regions.
293
+ */
294
+ async ocrImage(imageData: Buffer, textDetThreshold?: number): Promise<TextLine[]> {
295
+ return this.ocrRegion(imageData, textDetThreshold);
296
+ }
297
+
298
+ /**
299
+ * Detect text lines and recognize text in a cropped region.
300
+ *
301
+ * For each detected text line bounding box, sub-crops that line from the
302
+ * crop image and passes the individual line image to text recognition.
303
+ */
304
+ private async ocrRegion(imageData: Buffer, textDetThreshold?: number): Promise<TextLine[]> {
305
+ // Detect text lines within the crop
306
+ const textBoxes = this.textDet.detect(imageData, textDetThreshold);
307
+
308
+ if (textBoxes.length === 0) {
309
+ // Fall back to recognizing the entire crop as one text line
310
+ const result = this.textRec.recognize(imageData);
311
+ if (result.text.trim()) {
312
+ return [{ bbox: [0, 0, 0, 0], text: result.text, score: result.score }];
313
+ }
314
+ return [];
315
+ }
316
+
317
+ // Sort text boxes by vertical position (top to bottom, left to right)
318
+ const sorted = [...textBoxes].sort((a, b) => {
319
+ const yDiff = a.bbox[1] - b.bbox[1];
320
+ if (Math.abs(yDiff) > 10) return yDiff;
321
+ return a.bbox[0] - b.bbox[0];
322
+ });
323
+
324
+ // Single detected line: recognize the full crop directly (no sub-crop needed)
325
+ if (sorted.length === 1) {
326
+ const result = this.textRec.recognize(imageData);
327
+ return [
328
+ {
329
+ bbox: sorted[0].bbox,
330
+ text: result.text,
331
+ score: result.score,
332
+ },
333
+ ];
334
+ }
335
+
336
+ // Multiple detected lines: sub-crop each line from the crop image
337
+ const { Transformer } = await import('@napi-rs/image');
338
+
339
+ const lineBuffers: Buffer[] = [];
340
+ for (let i = 0; i < sorted.length; i++) {
341
+ const [x1, y1, x2, y2] = sorted[i].bbox;
342
+ const x = Math.max(0, Math.round(x1));
343
+ const y = Math.max(0, Math.round(y1));
344
+ const w = Math.max(1, Math.round(x2 - x1));
345
+ const h = Math.max(1, Math.round(y2 - y1));
346
+
347
+ const linePng = await new Transformer(imageData).crop(x, y, w, h).png();
348
+ lineBuffers.push(Buffer.from(linePng));
349
+ }
350
+
351
+ const results = this.textRec.recognizeBatch(lineBuffers);
352
+
353
+ return sorted.map((box, i) => ({
354
+ bbox: box.bbox,
355
+ text: results[i]?.text ?? '',
356
+ score: results[i]?.score ?? 0,
357
+ }));
358
+ }
359
+
360
+ /**
361
+ * Crop a layout element from the source image and return PNG bytes.
362
+ */
363
+ private async cropElement(imageBuffer: Uint8Array, el: LayoutElement): Promise<Buffer> {
364
+ const [x1, y1, x2, y2] = el.bbox;
365
+ const x = Math.max(0, Math.round(x1));
366
+ const y = Math.max(0, Math.round(y1));
367
+ const w = Math.max(1, Math.round(x2 - x1));
368
+ const h = Math.max(1, Math.round(y2 - y1));
369
+
370
+ const { Transformer } = await import('@napi-rs/image');
371
+ const cropped = await new Transformer(imageBuffer).crop(x, y, w, h).png();
372
+ return cropped;
373
+ }
374
+ }
375
+
376
+ // ============================================================================
377
+ // Markdown assembly
378
+ // ============================================================================
379
+
380
+ /** Format a single element as markdown. */
381
+ function formatElement(label: string, text: string, order: number): string {
382
+ const trimmed = text.trim();
383
+ if (!trimmed) return '';
384
+
385
+ switch (label) {
386
+ case 'doc_title':
387
+ return `# ${trimmed}\n`;
388
+ case 'title':
389
+ return `## ${trimmed}\n`;
390
+ case 'paragraph_title':
391
+ return `### ${trimmed}\n`;
392
+ case 'abstract':
393
+ return `> ${trimmed}\n`;
394
+ case 'table':
395
+ return `${trimmed}\n`;
396
+ case 'table_caption':
397
+ case 'figure_caption':
398
+ case 'chart_caption':
399
+ case 'formula_caption':
400
+ return `*${trimmed}*\n`;
401
+ case 'isolate_formula':
402
+ return `$$\n${trimmed}\n$$\n`;
403
+ case 'code_txt':
404
+ return `\`\`\`\n${trimmed}\n\`\`\`\n`;
405
+ case 'figure':
406
+ case 'chart':
407
+ return trimmed ? `[${label}: ${trimmed}]\n` : `[${label}]\n`;
408
+ case 'header':
409
+ case 'footer':
410
+ return `<!-- ${label}: ${trimmed} -->\n`;
411
+ case 'footnote':
412
+ case 'table_footnote':
413
+ return `[^note-${order}]: ${trimmed}\n`;
414
+ case 'list':
415
+ return `${trimmed}\n`;
416
+ case 'seal':
417
+ return `[seal: ${trimmed}]\n`;
418
+ default:
419
+ return `${trimmed}\n`;
420
+ }
421
+ }
422
+
423
+ /** Assemble structured elements into markdown. */
424
+ function assembleMarkdown(elements: StructuredElement[]): string {
425
+ const parts: string[] = [];
426
+
427
+ for (const el of elements) {
428
+ const formatted = formatElement(el.label, el.text, el.order);
429
+ if (formatted) {
430
+ parts.push(formatted);
431
+ }
432
+ }
433
+
434
+ return parts.join('\n');
435
+ }