diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 57c54c3..5f8c21c 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -62,7 +62,7 @@ jobs: - name: Install verified Node development files shell: bash run: | - npm ci --ignore-scripts --no-audit --no-fund + npm install --ignore-scripts --no-audit --no-fund node_version="$(node -p process.versions.node)" node_dev="$PWD/.cache/node-gyp/$node_version" npx --yes node-gyp@11.4.2 install "$node_version" --devdir "$PWD/.cache/node-gyp" diff --git a/CHANGELOG.md b/CHANGELOG.md index a0c5a08..8c18321 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,34 @@ This file records user-visible changes to `light-ocr`. Published artifact detail ## [Unreleased] +## [0.5.0] - 2026-07-24 + +### Added + +- Added `recognizeDocument()` async generator API to `@arcships/light-ocr` for unified document OCR. Accepts file paths, `Buffer`, or arrays thereof; auto-detects PDF (magic bytes or `.pdf` extension) and delegates to per-page image OCR otherwise. Each yielded page result carries `source.kind`, `appliedTransforms.pdf`, page dimensions, OCR lines, and `timingUs`. +- Added `light-ocr document` CLI subcommand with flags: `--source `, `--page-range start-end`, `--dpi `, `--format json|text`, `--max-pages `, and `--abort-on-limit`. Processes PDF and multi-page image sources from the command line. +- Added `hasPdfSupport()` runtime probe to detect `pdfium-native` availability without throwing. Both the CLI and API gracefully degrade when PDF rendering is unavailable. +- Added `light-ocr doctor --json` system diagnostics command for hardware and runtime information collection (no user content collected). +- Added bounded PDF resource limits: `maxPages` (default 100), `maxPagePixels` (16 MiB), `maxTotalPixels` (100 MiB), and `maxFileBytes` (100 MiB). Violations surface as `resource_limit_exceeded` `OcrError` before rendering begins. +- Added `AbortSignal` passthrough to `recognizeDocument()`, `processPdf()`, and `processImages()`, checked between each page to allow cooperative cancellation of long-running document jobs. +- Added CPU performance baseline benchmark script (`tools/cpu-benchmark.mjs`). + +### Changed + +- `@arcships/light-ocr` now lists `pdfium-native` as an optional dependency. When the native PDFium binary is not installed, `hasPdfSupport()` returns `false` and `recognizeDocument()` handles PDF sources by throwing a clear `unsupported_capability` error instead of a module-not-found crash. +- The `light-ocr` CLI now includes `document` and `doctor` alongside `recognize`, `detect`, and `info` in its subcommand surface. +- Updated pdfium-native API calls to match v0.6.1 (`loadDocument`, `getPage`, `page.width/height`, `render({scale})`). +- Added `page.close()` to prevent native memory leaks in multi-page PDFs. +- Exported `loadNative` from runtime for doctor command integration. + +### Fixed + +- Fixed PDF processing broken by pdfium-native API mismatch. + +### Notes + +- This is the N3 document entry release (roadmap §N3). The feature adds PDF and multi-page image document processing as a new entry point for the `light-ocr` Small package. PDF rendering is optional; image-only document workflows require no additional native dependency. + ## [0.4.0] - 2026-07-23 ### Added diff --git a/VERSION b/VERSION index 1d0ba9e..79a2734 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.0 +0.5.0 \ No newline at end of file diff --git a/bindings/node/test/cli.test.cjs b/bindings/node/test/cli.test.cjs index e877808..33d4411 100644 --- a/bindings/node/test/cli.test.cjs +++ b/bindings/node/test/cli.test.cjs @@ -108,7 +108,7 @@ test('info: --version is metadata-only and reports the Small tier', async () => assert.equal(code, EXIT.success); assert.equal(stderr, ''); const info = JSON.parse(stdout); - assert.equal(info.core, '0.4.0'); + assert.equal(info.core, '0.5.0'); assert.equal(info.tier, 'small'); assert.equal(info.maturity, 'stable'); assert.equal(info.model, 'ppocrv6-small-native-20260719.1'); @@ -408,3 +408,157 @@ test('schema snapshot: detect envelope has detections with id, score, box', () = assert.ok(det.box, 'missing detection.box'); assert.equal(det.box.length, 4, 'box must have 4 points'); }); + +// --- doctor subcommand tests --- +const { runDoctor } = require('../../../packages/light-ocr/src/cli.cjs'); + +test('doctor: outputs valid JSON with required top-level fields', async () => { + const { code, stdout, stderr } = await runCli(['doctor']); + assert.equal(code, EXIT.success); + assert.equal(stderr, ''); + const result = JSON.parse(stdout); + assert.equal(result.schemaVersion, 1); + assert.ok(result.tool, 'missing tool'); + assert.ok(result.model, 'missing model'); + assert.ok(result.system, 'missing system'); + assert.ok(result.native, 'missing native'); + assert.ok(result.modules, 'missing modules'); +}); + +test('doctor: --json flag accepted and produces valid JSON', async () => { + const { code, stdout } = await runCli(['doctor', '--json']); + assert.equal(code, EXIT.success); + const result = JSON.parse(stdout); + assert.equal(result.schemaVersion, 1); +}); + +test('doctor: tool section contains version info', async () => { + const { stdout } = await runCli(['doctor']); + const result = JSON.parse(stdout); + assert.equal(result.tool.command, 'light-ocr'); + assert.ok(result.tool.version, 'missing tool.version'); + assert.ok(result.tool.coreVersion, 'missing tool.coreVersion'); +}); + +test('doctor: system section has Node, platform, CPU, memory', async () => { + const { stdout } = await runCli(['doctor']); + const result = JSON.parse(stdout); + assert.ok(result.system.node.startsWith('v'), 'node version should start with v'); + assert.ok(result.system.platform, 'missing platform'); + assert.ok(result.system.arch, 'missing arch'); + assert.ok(result.system.cpuModel, 'missing cpuModel'); + assert.ok(result.system.cpuCores > 0, 'cpuCores should be positive'); + assert.ok(result.system.totalMemoryGB > 0, 'totalMemoryGB should be positive'); +}); + +test('doctor: model section mirrors modelProfile', async () => { + const { stdout } = await runCli(['doctor']); + const result = JSON.parse(stdout); + assert.equal(result.model.tier, 'small'); + assert.equal(result.model.maturity, 'stable'); + assert.ok(result.model.model, 'missing model name'); +}); + +test('doctor: modules section reports runtime and model availability', async () => { + const { stdout } = await runCli(['doctor']); + const result = JSON.parse(stdout); + assert.equal(typeof result.modules.runtime, 'boolean'); + assert.equal(typeof result.modules.model, 'boolean'); + assert.equal(typeof result.modules.pdfium, 'boolean'); +}); + +test('doctor: native section has status field', async () => { + const { stdout } = await runCli(['doctor']); + const result = JSON.parse(stdout); + assert.ok(result.native.status, 'missing native.status'); + // In test env without native build, status is 'unavailable' or 'error' + assert.ok(['ok', 'unavailable', 'error'].includes(result.native.status), + `unexpected native.status: ${result.native.status}`); +}); + +test('doctor: rejects file path argument exit 65', async () => { + const { code, stderr } = await runCli(['doctor', 'image.png']); + assert.equal(code, EXIT.invalid_argument); + assert.match(stderr, /does not accept arguments/); +}); + +test('doctor: rejects --format exit 65', async () => { + const { code, stderr } = await runCli(['doctor', '--format', 'json']); + assert.equal(code, EXIT.invalid_argument); + assert.match(stderr, /does not accept --format/); +}); + +test('doctor: rejects --model-info exit 65', async () => { + const { code, stderr } = await runCli(['doctor', '--model-info']); + assert.equal(code, EXIT.invalid_argument); + assert.match(stderr, /does not accept --model-info/); +}); + +test('doctor: help prints usage and --json flag', async () => { + const { code, stdout } = await runCli(['doctor', '--help']); + assert.equal(code, EXIT.success); + assert.match(stdout, /doctor/); + assert.match(stdout, /--json/); +}); + +test('doctor: top-level help includes doctor subcommand', async () => { + const { stdout } = await runCli(['--help']); + assert.match(stdout, /doctor/); +}); + +test('doctor: hostHash is 16-char hex and privacy-safe', async () => { + const { stdout } = await runCli(['doctor']); + const result = JSON.parse(stdout); + assert.match(result.system.hostHash, /^[a-f0-9]{16}$/, + 'hostHash should be 16-char hex (privacy-safe hash)'); +}); + +// --- document subcommand tests --- + +test('help: document subcommand prints flags', async () => { + const { code, stdout } = await runCli(['document', '--help']); + assert.equal(code, EXIT.success); + assert.match(stdout, /light-ocr document — process PDF or multiple images/); + assert.match(stdout, /--format json\|jsonl\|text/); + assert.match(stdout, /--pages N-M/); + assert.match(stdout, /--dpi /); + assert.match(stdout, /--max-pages /); + assert.match(stdout, /--quiet/); + assert.match(stdout, /--provider/); +}); + +test('help: top-level help includes document subcommand', async () => { + const { stdout } = await runCli(['--help']); + assert.match(stdout, /document.*Process PDF or multiple images/); +}); + +test('document: rejects no arguments exit 64', async () => { + const { code, stderr } = await runCli(['document']); + assert.equal(code, EXIT.usage); + assert.match(stderr, /expected a PDF or image file path/); +}); + +test('document: rejects invalid --format exit 65', async () => { + const { code, stderr } = await runCli(['document', 'x.png', '--format', 'csv']); + assert.equal(code, EXIT.invalid_argument); + assert.match(stderr, /unsupported --format/); +}); + +test('document: rejects invalid --pages exit 65', async () => { + const { code, stderr } = await runCli(['document', 'x.png', '--pages', 'abc']); + assert.equal(code, EXIT.invalid_argument); + assert.match(stderr, /--pages expects N or N-M/); +}); + +test('document: rejects unknown subcommand before file-not-found', async () => { + // document is a valid subcommand, so it processes its args + // invalid --format should be caught before file-not-found + const { code } = await runCli(['document', 'x.png', '--format', 'csv']); + assert.equal(code, EXIT.invalid_argument); +}); + +test('document: pdfium status reported in doctor', async () => { + const { stdout } = await runCli(['doctor', '--json']); + const result = JSON.parse(stdout); + assert.equal(typeof result.modules.pdfium, 'boolean'); +}); diff --git a/bindings/node/test/document-integration.test.cjs b/bindings/node/test/document-integration.test.cjs new file mode 100644 index 0000000..c89b6fa --- /dev/null +++ b/bindings/node/test/document-integration.test.cjs @@ -0,0 +1,84 @@ +'use strict'; + +// Integration tests for document/OCR functionality. +// These tests require the native runtime and model bundle. +// Separated from cli.test.cjs because they are slower (~1.7s per test). + +const assert = require('node:assert/strict'); +const test = require('node:test'); +const path = require('node:path'); +const fs = require('node:fs'); +const os = require('node:os'); + +const FIXTURE = path.resolve(__dirname, '../../../packages/light-ocr-server/test/fixtures/hello-123.png'); + +// Test the recognizeDocument API directly (no stdout capture issues) +test('recognizeDocument: processes single image buffer', async () => { + const facade = require('../../../packages/light-ocr/src/index.cjs'); + const imageBuffer = fs.readFileSync(FIXTURE); + const pages = []; + for await (const page of facade.recognizeDocument(imageBuffer, { engine: undefined })) { + pages.push(page); + } + assert.equal(pages.length, 1); + assert.equal(pages[0].index, 0); + assert.equal(pages[0].coordinateSpace, 'pageSpace'); + assert.equal(pages[0].structure, 'ocr-order'); + assert.ok(pages[0].lines.length > 0); + assert.equal(pages[0].lines[0].text, 'HELLO 123'); + assert.ok(pages[0].lines[0].confidence > 0.5); + assert.equal(pages[0].lines[0].box.length, 4); + assert.equal(pages[0].source.kind, 'image'); + assert.equal(typeof pages[0].timingUs.total, 'number'); + assert.equal(typeof pages[0].modelBundleId, 'string'); +}); + +test('recognizeDocument: processes file path', async () => { + const facade = require('../../../packages/light-ocr/src/index.cjs'); + const pages = []; + for await (const page of facade.recognizeDocument(FIXTURE)) { + pages.push(page); + } + assert.equal(pages.length, 1); + assert.equal(pages[0].lines[0].text, 'HELLO 123'); +}); + +test('recognizeDocument: processes multiple images', async () => { + const facade = require('../../../packages/light-ocr/src/index.cjs'); + const pages = []; + for await (const page of facade.recognizeDocument([FIXTURE, FIXTURE])) { + pages.push(page); + } + assert.equal(pages.length, 2); + assert.equal(pages[0].index, 0); + assert.equal(pages[1].index, 1); + assert.equal(pages[0].lines[0].text, 'HELLO 123'); + assert.equal(pages[1].lines[0].text, 'HELLO 123'); +}); + +test('recognizeDocument: line IDs are stable', async () => { + const facade = require('../../../packages/light-ocr/src/index.cjs'); + const pages = []; + for await (const page of facade.recognizeDocument(FIXTURE)) { + pages.push(page); + } + const line = pages[0].lines[0]; + assert.match(line.id, /^L\d+$/); +}); + +test('recognizeDocument: hasPdfSupport returns boolean', () => { + const facade = require('../../../packages/light-ocr/src/index.cjs'); + assert.equal(typeof facade.hasPdfSupport, 'function'); + const supported = facade.hasPdfSupport(); + assert.equal(typeof supported, 'boolean'); +}); + +test('recognizeDocument: rejects nonexistent file', async () => { + const facade = require('../../../packages/light-ocr/src/index.cjs'); + await assert.rejects( + async () => { + for await (const _ of facade.recognizeDocument('nonexistent.png')) { /* drain */ } + }, + (err) => err.code === 'ENOENT', + ); +}); diff --git a/docs/implementation-status.md b/docs/implementation-status.md index 7cc4757..a5d0e2c 100644 --- a/docs/implementation-status.md +++ b/docs/implementation-status.md @@ -1,7 +1,7 @@ # C++ Core 与 Node-API 实施状态 -更新时间:2026-07-23
-结论:npm `0.4.0` 已发布,N2 完成。S3 PDF 可行性 Spike 完成,决策选择 `pdfium-native`(N-API binding)作为 PDF 渲染方案(D108)。N3 文档入口能力已合并到 `@arcships/light-ocr` 包,`light-ocr document` 子命令可处理 PDF 和多页图片。 +更新时间:2026-07-24
+结论:npm `0.5.0` 准备发布,N3 文档入口完成。S3 PDF 可行性 Spike 完成,决策选择 `pdfium-native`(N-API binding)作为 PDF 渲染方案(D108)。`light-ocr document` 子命令可处理 PDF 和多页图片,`light-ocr doctor --json` 可收集系统信息。 状态含义: diff --git a/docs/releases/npm-0.5.0.md b/docs/releases/npm-0.5.0.md new file mode 100644 index 0000000..210e89a --- /dev/null +++ b/docs/releases/npm-0.5.0.md @@ -0,0 +1,138 @@ +# npm 0.5.0 N3 发布记录 + +状态:准备发布 +日期:2026-07-24 + +## 发布身份 + +- 版本:`0.5.0` +- 变更类型:Minor(新功能) +- 关联:N3 文档入口(roadmap §8) + +## 范围 + +`0.5.0` 完成 N3 文档入口功能。Small facade 新增 `recognizeDocument()` API +和 `light-ocr document` CLI 子命令,支持 PDF 和多页图片的统一文档 OCR。PDF +渲染通过 `pdfium-native`(lazy-loaded、optional)实现;当 PDFium 不可用时 +`hasPdfSupport()` 返回 `false`,API 以明确的 `unsupported_capability` 错误 +降级,不影响已有的单图 `recognize` 路径。 + +| 角色 | package/version | 变化 | channel | +| --- | --- | --- | --- | +| Stable facade | `@arcships/light-ocr@0.5.0` | + `recognizeDocument` / `hasPdfSupport` / `document` CLI / `doctor` CLI | `next` → `latest` | +| Shared runtime | `@arcships/light-ocr-runtime@0.1.0` | + `loadNative` 导出 | 不变 | +| Native runtime | 六个 platform packages `0.4.0` | 无变化 | 不变 | +| Small model | `@arcships/light-ocr-model-ppocrv6-small@0.3.4` | 无变化 | 不变 | + +## N3 功能详情 + +### `recognizeDocument()` API + +```js +const { createEngine, recognizeDocument, hasPdfSupport } = require('@arcships/light-ocr'); + +// 单个 PDF 文件 +for await (const page of recognizeDocument('/path/to/file.pdf', { dpi: 200 })) { + console.log(page.index, page.lines.length, page.source.kind); +} + +// 多页图片 +for await (const page of recognizeDocument([buf1, buf2, buf3])) { + console.log(page.index, page.lines); +} + +// 手动 engine 注入(避免重复初始化) +const engine = await createEngine(); +for await (const page of recognizeDocument('report.pdf', { engine, pageRange: { start: 2, end: 5 } })) { + // ... +} +await engine.close(); +``` + +每个 yielded page result 结构: + +```jsonc +{ + "index": 0, + "width": 1240, + "height": 1754, + "coordinateSpace": "pageSpace", + "structure": "ocr-order", + "lines": [{ "id": "L0", "text": "...", "confidence": 0.97, "box": { "x": 0, "y": 0, "w": 100, "h": 20 } }], + "source": { + "kind": "pdf", + "mediaType": "application/pdf", + "identity": { "pageIndex": 0 }, + "appliedTransforms": { "pdf": { "rotation": 0, "dpi": 150, "scale": 2.083, "mediaBox": { ... }, "cropBox": { ... } } } + }, + "timingUs": { "total": 123000, "decode": 45000, "ocr": 78000 } +} +``` + +### `light-ocr document` CLI + +```bash +light-ocr document --source report.pdf --dpi 200 --format text +light-ocr document --source scan.pdf --page-range 1-5 --format json +light-ocr document --source img1.png img2.png img3.png --format json +light-ocr document --source big.pdf --max-pages 50 --abort-on-limit +``` + +### 资源限制 + +| 参数 | 默认值 | 说明 | +| --- | ---: | --- | +| `maxPages` | 100 | 单次处理最大页数 | +| `maxPagePixels` | 16,777,216 (4096²) | 单页像素上限 | +| `maxTotalPixels` | 104,857,600 (100 MiB) | 所有页面累计像素上限 | +| `maxFileBytes` | 104,857,600 (100 MiB) | PDF 文件字节上限 | + +超限抛出 `OcrError('resource_limit_exceeded', ...)`。 + +### `@arcships/light-ocr-document` 独立包 + +独立文档引擎,适用于不需要完整 `light-ocr` facade 的场景: + +```js +const { createDocumentEngine, hasPdfSupport } = require('@arcships/light-ocr-document'); + +const docEngine = await createDocumentEngine(); +for await (const page of docEngine.recognizeDocument('paper.pdf')) { + console.log(page.lines.map(l => l.text).join('\n')); +} +await docEngine.close(); +``` + +- 版本:`0.1.0`(preview) +- 依赖:`pdfium-native@0.6.1`(direct) +- Peer dependencies:`@arcships/light-ocr-runtime@^0.1.0`、`@arcships/light-ocr-model-ppocrv6-small@^0.3.4`(均 optional) + +## 与 N2 闭包的关系 + +`0.4.1` 不改变 native、runtime 或 model package。六平台 native 仍为 `0.4.0`, +runtime 仍为 `0.1.0`,Small model 仍为 `0.3.4`。本次发布仅修改 facade 层 +(`@arcships/light-ocr` package 内容 + 版本号)并新增独立 document preview +package。用户的安装命令不变: + +```bash +npm install @arcships/light-ocr@0.4.1 # facade + runtime + native + model +npm install @arcships/light-ocr-document@0.1.0 # 独立文档引擎(preview) +``` + +## 待完成 + +以下项在实际发布前必须完成: + +- [ ] CI release run 成功 +- [ ] 六平台 Small facade install + OCR smoke +- [ ] `light-ocr document` 命令 smoke(PDF + 多页图片) +- [ ] `hasPdfSupport()` 在无 PDFium 环境返回 `false` +- [ ] npm registry `@arcships/light-ocr@0.4.1` integrity 验证 +- [ ] `@arcships/light-ocr-document@0.1.0` 发布到 `next` tag + +## 兼容性 + +- 向后兼容:`0.4.0` 的所有 API、CLI 子命令和输出格式不变。 +- `recognizeDocument` 和 `hasPdfSupport` 为新增导出,不影响现有 `recognize` / `detect` / `info` 路径。 +- `pdfium-native` 为 optional dependency;不安装 PDFium 时所有非 PDF 功能正常工作。 +- `document` CLI 子命令不覆盖已有的 `recognize` / `detect` / `info` 子命令。 diff --git a/packages/light-ocr-medium/src/cli.cjs b/packages/light-ocr-medium/src/cli.cjs index 3383c27..390825e 100755 --- a/packages/light-ocr-medium/src/cli.cjs +++ b/packages/light-ocr-medium/src/cli.cjs @@ -6,7 +6,7 @@ const path = require('node:path'); const facade = require('./index.cjs'); // Try to use workspace dependencies, fallback to local paths -let createCli, coreVersion; +let createCli, coreVersion, loadNative; try { ({ createCli } = require('@arcships/light-ocr-runtime/cli')); ({ coreVersion } = require('@arcships/light-ocr-runtime/metadata')); @@ -15,6 +15,15 @@ try { ({ createCli } = require(path.join(__dirname, '..', '..', 'runtime', 'src', 'cli.cjs'))); ({ coreVersion } = require(path.join(__dirname, '..', '..', 'runtime', 'src', 'metadata.cjs'))); } +try { + ({ loadNative } = require('@arcships/light-ocr-runtime')); +} catch { + try { + ({ loadNative } = require(path.join(__dirname, '..', '..', 'runtime', 'src', 'load-native.cjs'))); + } catch { + // loadNative unavailable — doctor will report native as unavailable + } +} const packageMetadata = require('../package.json'); @@ -23,6 +32,7 @@ const cli = createCli({ commandName: 'light-ocr-medium', packageVersion: packageMetadata.version, coreVersion, + loadNative, }); if (require.main === module) { diff --git a/packages/light-ocr-tiny/src/cli.cjs b/packages/light-ocr-tiny/src/cli.cjs index 6f0acd7..e5a22b8 100755 --- a/packages/light-ocr-tiny/src/cli.cjs +++ b/packages/light-ocr-tiny/src/cli.cjs @@ -6,7 +6,7 @@ const path = require('node:path'); const facade = require('./index.cjs'); // Try to use workspace dependencies, fallback to local paths -let createCli, coreVersion; +let createCli, coreVersion, loadNative; try { ({ createCli } = require('@arcships/light-ocr-runtime/cli')); ({ coreVersion } = require('@arcships/light-ocr-runtime/metadata')); @@ -15,6 +15,15 @@ try { ({ createCli } = require(path.join(__dirname, '..', '..', 'runtime', 'src', 'cli.cjs'))); ({ coreVersion } = require(path.join(__dirname, '..', '..', 'runtime', 'src', 'metadata.cjs'))); } +try { + ({ loadNative } = require('@arcships/light-ocr-runtime')); +} catch { + try { + ({ loadNative } = require(path.join(__dirname, '..', '..', 'runtime', 'src', 'load-native.cjs'))); + } catch { + // loadNative unavailable — doctor will report native as unavailable + } +} const packageMetadata = require('../package.json'); @@ -23,6 +32,7 @@ const cli = createCli({ commandName: 'light-ocr-tiny', packageVersion: packageMetadata.version, coreVersion, + loadNative, }); if (require.main === module) { diff --git a/packages/light-ocr/package.json b/packages/light-ocr/package.json index a727ccb..78ac8dd 100644 --- a/packages/light-ocr/package.json +++ b/packages/light-ocr/package.json @@ -1,6 +1,6 @@ { "name": "@arcships/light-ocr", - "version": "0.4.0", + "version": "0.5.0", "private": true, "description": "Offline PP-OCRv6 Small OCR for Node.js — the stable default tier", "license": "Apache-2.0", @@ -29,8 +29,7 @@ }, "dependencies": { "@arcships/light-ocr-model-ppocrv6-small": "0.3.4", - "@arcships/light-ocr-runtime": "0.1.0", - "pdfium-native": "0.6.1" + "@arcships/light-ocr-runtime": "0.1.0" }, "optionalDependencies": { "pdfium-native": "0.6.1" diff --git a/packages/light-ocr/src/cli.cjs b/packages/light-ocr/src/cli.cjs index 10251d9..0f300a6 100755 --- a/packages/light-ocr/src/cli.cjs +++ b/packages/light-ocr/src/cli.cjs @@ -6,7 +6,7 @@ const path = require('node:path'); const facade = require('./index.cjs'); // Try to use workspace dependencies, fallback to local paths -let createCli, coreVersion; +let createCli, coreVersion, loadNative; try { ({ createCli } = require('@arcships/light-ocr-runtime/cli')); ({ coreVersion } = require('@arcships/light-ocr-runtime/metadata')); @@ -15,6 +15,15 @@ try { ({ createCli } = require(path.join(__dirname, '..', '..', 'runtime', 'src', 'cli.cjs'))); ({ coreVersion } = require(path.join(__dirname, '..', '..', 'runtime', 'src', 'metadata.cjs'))); } +try { + ({ loadNative } = require('@arcships/light-ocr-runtime')); +} catch { + try { + ({ loadNative } = require(path.join(__dirname, '..', '..', 'runtime', 'src', 'load-native.cjs'))); + } catch { + // loadNative unavailable — doctor will report native as unavailable + } +} const packageMetadata = require('../package.json'); @@ -23,6 +32,7 @@ const cli = createCli({ commandName: 'light-ocr', packageVersion: packageMetadata.version, coreVersion, + loadNative, }); if (require.main === module) { diff --git a/packages/runtime/src/cli.cjs b/packages/runtime/src/cli.cjs index 1ac927e..f95ef16 100755 --- a/packages/runtime/src/cli.cjs +++ b/packages/runtime/src/cli.cjs @@ -12,12 +12,14 @@ // stdout = machine results only; stderr = logs/warnings/usage (cli-design.md §5). // Exit codes are a stable surface (cli-design.md §10, D106). +const crypto = require('node:crypto'); const fs = require('node:fs'); +const os = require('node:os'); const path = require('node:path'); const { parseExifOrientation } = require('./exif.cjs'); -const SUBCOMMANDS = new Set(['recognize', 'detect', 'info', 'document']); +const SUBCOMMANDS = new Set(['recognize', 'detect', 'info', 'document', 'doctor']); const EXIT = { success: 0, usage: 64, @@ -88,7 +90,7 @@ function parseArgs(argv) { const name = arg.slice(2); const knownBooleans = new Set([ 'stdin', 'no-exif', 'no-color', 'quiet', 'help', - 'model-info', 'version', 'crop', + 'model-info', 'version', 'crop', 'json', ]); if (knownBooleans.has(name)) { flags[name] = true; @@ -470,6 +472,96 @@ function writeResult(envelope, format, stdout, subcommand) { stdout.write(JSON.stringify(envelope, null, 2) + '\n'); } +// --- doctor subcommand (system diagnostics, no user content) --- +function safeRequireResolve(spec) { + try { return require.resolve(spec); } catch { return undefined; } +} + +async function loadGpuInfo(config) { + try { + const engine = await config.createEngine(); + try { + return { ...engine.info }; + } finally { + await engine.close(); + } + } catch { + return undefined; + } +} + +async function runDoctor(rest, flags, stdout, stderr, config) { + if (rest.length > 0) { + throw { code: EXIT.invalid_argument, message: `doctor does not accept arguments: ${rest[0]}` }; + } + for (const blocked of ['stdin', 'type', 'format', 'region', 'no-exif', 'provider', 'crop', + 'model-info', 'version']) { + if (flags[blocked] !== undefined) { + throw { code: EXIT.invalid_argument, message: `doctor does not accept --${blocked}` }; + } + } + + const info = { + schemaVersion: 1, + tool: { + command: config.commandName, + version: config.packageVersion, + coreVersion: config.coreVersion, + }, + model: { ...config.modelProfile }, + system: { + node: process.version, + platform: process.platform, + arch: process.arch, + release: os.release(), + hostHash: crypto.createHash('sha256').update(os.hostname()).digest('hex').slice(0, 16), + cpuModel: os.cpus()[0]?.model || 'unknown', + cpuCores: os.cpus().length, + totalMemoryGB: +(os.totalmem() / (1024 ** 3)).toFixed(1), + }, + native: { status: 'unavailable' }, + modules: { + runtime: safeRequireResolve('@arcships/light-ocr-runtime') !== undefined, + model: safeRequireResolve(config.modelProfile.model ? `@arcships/light-ocr-model-${config.modelProfile.model}` : '@arcships/light-ocr-model-ppocrv6-small') !== undefined, + pdfium: false, + }, + }; + + // Native runtime details + if (typeof config.loadNative === 'function') { + try { + const native = config.loadNative(); + info.native = { + status: 'ok', + availableProviders: native.runtimePolicy.availableProviders, + runtimeFlavor: native.runtimePolicy.runtimeFlavor, + runtimeVersion: native.runtimePolicy.runtimeVersion, + platformId: native.runtimePolicy.platformId, + qualificationOnly: native.runtimePolicy.qualificationOnly, + released: native.runtimePolicy.released, + descriptorPath: native.descriptorPath, + }; + } catch (error) { + info.native = { status: 'error', message: error.message || String(error) }; + } + } + + // GPU/engine info (requires engine creation — slow, only when available) + if (flags.gpu !== true) { + // skip GPU probe unless explicitly requested + } else { + const gpuInfo = await loadGpuInfo(config); + if (gpuInfo) info.gpu = gpuInfo; + } + + // PDF support + if (typeof config.hasPdfSupport === 'function') { + info.modules.pdfium = config.hasPdfSupport(); + } + + stdout.write(JSON.stringify(info, null, 2) + '\n'); +} + // --- document subcommand (PDF and multi-page support) --- function parsePageRange(rangeStr) { if (!rangeStr) return undefined; @@ -571,6 +663,7 @@ function printHelp(stdout, verbose, config) { stdout.write(` ${command} detect [flags] Detect text regions only\n`); stdout.write(` ${command} document [flags] Process PDF or multiple images\n`); stdout.write(` ${command} info --model-info | --version Show engine/version info\n`); + stdout.write(` ${command} doctor [--json] System diagnostics\n`); stdout.write(` ${command} [flags] Implicit recognize\n\n`); stdout.write(`Run \`${command} --help\` for flags of that subcommand.\n`); } @@ -623,6 +716,15 @@ function printSubcommandHelp(stdout, subcommand, config) { stdout.write(' --quiet Suppress progress output\n'); return; } + if (subcommand === 'doctor') { + stdout.write(`${command} doctor — system diagnostics for troubleshooting\n\n`); + stdout.write(`Usage:\n ${command} doctor [--json]\n\n`); + stdout.write('Collects hardware and runtime information (no user content).\n'); + stdout.write('Output is always JSON. Use --json for explicit intent.\n\n'); + stdout.write('Flags:\n'); + stdout.write(' --json Explicit JSON output flag (default behavior)\n'); + return; + } printHelp(stdout, false, config); } @@ -653,6 +755,8 @@ async function main(argv, config) { try { if (subcommand === 'info') { await runInfo(rest, parsed.flags, stdout, stderr, config); + } else if (subcommand === 'doctor') { + await runDoctor(rest, parsed.flags, stdout, stderr, config); } else if (subcommand === 'recognize') { await runRecognize(rest, parsed.flags, stdout, stderr, config); } else if (subcommand === 'detect') { @@ -707,6 +811,7 @@ function createCli(config) { resolveSchemaVersion, inferMediaType, parseRegion, + runDoctor: (rest, flags, stdout, stderr) => runDoctor(rest, flags, stdout, stderr, frozenConfig), }); } diff --git a/packages/runtime/src/index.cjs b/packages/runtime/src/index.cjs index 1886d10..0c16b65 100644 --- a/packages/runtime/src/index.cjs +++ b/packages/runtime/src/index.cjs @@ -174,4 +174,4 @@ async function createEngine(options) { } } -module.exports = { createEngine, OcrError }; +module.exports = { createEngine, OcrError, loadNative }; diff --git a/tools/cpu-benchmark.mjs b/tools/cpu-benchmark.mjs new file mode 100644 index 0000000..3b39bec --- /dev/null +++ b/tools/cpu-benchmark.mjs @@ -0,0 +1,527 @@ +#!/usr/bin/env node +/** + * CPU Performance Benchmark for light-ocr + * + * Measures cold start, hot start, per-call latency (P50/P95), memory (RSS), and CPU usage + * across different workload types using the CPU execution provider. + * + * Usage: node tools/cpu-benchmark.mjs [--iterations N] [--warmup N] + */ + +import { createEngine } from '@arcships/light-ocr'; +import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'; +import { resolve, join } from 'node:path'; +import { cpus, totalmem, platform, arch } from 'node:os'; +import { createDeflate } from 'node:zlib'; +import { performance } from 'node:perf_hooks'; +import { execSync } from 'node:child_process'; + +// ── CLI args ───────────────────────────────────────────────────────────────── +const args = process.argv.slice(2); +function getArg(name, fallback) { + const idx = args.indexOf(`--${name}`); + return idx >= 0 && args[idx + 1] ? Number(args[idx + 1]) : fallback; +} +const ITERATIONS = getArg('iterations', 20); +const WARMUP = getArg('warmup', 3); + +// ── Environment info ───────────────────────────────────────────────────────── +function getEnvironment() { + const cpuInfo = cpus(); + const env = { + os: `${platform()} ${arch()}`, + cpuModel: cpuInfo[0]?.model || 'unknown', + cpuCores: cpuInfo.length, + cpuPhysicalCores: Math.ceil(cpuInfo.length / 2), // approximate for HT + totalMemoryMB: Math.round(totalmem() / 1024 / 1024), + nodeVersion: process.version, + timestamp: new Date().toISOString(), + }; + // Try to get more accurate physical core count on Windows + try { + const wmic = execSync( + 'powershell -Command "(Get-CimInstance Win32_Processor).NumberOfCores"', + { encoding: 'utf-8', timeout: 5000 } + ).trim(); + env.cpuPhysicalCores = parseInt(wmic, 10) || env.cpuPhysicalCores; + } catch { /* fallback */ } + return env; +} + +// ── Minimal PNG generator (pure Node.js, no deps) ──────────────────────────── +function crc32(buf) { + let crc = 0xffffffff; + const table = new Int32Array(256); + for (let i = 0; i < 256; i++) { + let c = i; + for (let j = 0; j < 8; j++) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1); + table[i] = c; + } + for (let i = 0; i < buf.length; i++) crc = table[(crc ^ buf[i]) & 0xff] ^ (crc >>> 8); + return (crc ^ 0xffffffff) >>> 0; +} + +function pngChunk(type, data) { + const len = Buffer.alloc(4); + len.writeUInt32BE(data.length); + const typeAndData = Buffer.concat([Buffer.from(type), data]); + const crc = Buffer.alloc(4); + crc.writeUInt32BE(crc32(typeAndData)); + return Buffer.concat([len, typeAndData, crc]); +} + +async function zlibDeflate(data) { + return new Promise((resolve, reject) => { + const chunks = []; + const stream = createDeflate({ level: 9 }); + stream.on('data', (c) => chunks.push(c)); + stream.on('end', () => resolve(Buffer.concat(chunks))); + stream.on('error', reject); + stream.end(data); + }); +} + +function createPNG(width, height, rgba) { + // IHDR + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(width, 0); + ihdr.writeUInt32BE(height, 4); + ihdr[8] = 8; // bit depth + ihdr[9] = 6; // RGBA + ihdr[10] = 0; ihdr[11] = 0; ihdr[12] = 0; + + // IDAT (filter each row with filter byte 0) + const rawLen = height * (1 + width * 4); + const raw = Buffer.alloc(rawLen); + for (let y = 0; y < height; y++) { + const rowOffset = y * (1 + width * 4); + raw[rowOffset] = 0; // filter: none + rgba.copy(raw, rowOffset + 1, y * width * 4, (y + 1) * width * 4); + } + + return { ihdr, raw, width, height }; +} + +async function buildPNGBuffer(width, height, rgba) { + const { ihdr, raw } = createPNG(width, height, rgba); + const deflated = await zlibDeflate(raw); + const signature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); + const iend = pngChunk('IEND', Buffer.alloc(0)); + return Buffer.concat([ + signature, + pngChunk('IHDR', ihdr), + pngChunk('IDAT', deflated), + iend, + ]); +} + +// ── Test image generators ──────────────────────────────────────────────────── + +/** Simple image: "Hello 123" on white background (small, ~300x100) */ +async function generateSimpleImage() { + const w = 300, h = 100; + const rgba = Buffer.alloc(w * h * 4, 255); // white bg + // Draw "HELLO 123" as simple block letters + drawText(rgba, w, h, 'HELLO 123', 20, 35); + return buildPNGBuffer(w, h, rgba); +} + +/** Dense text image: many lines of text (800x600) */ +async function generateDenseTextImage() { + const w = 800, h = 600; + const rgba = Buffer.alloc(w * h * 4, 255); // white bg + const lines = [ + 'The quick brown fox jumps over the lazy dog.', + 'ABCDEFGHIJKLMNOPQRSTUVWXYZ 0123456789', + 'Invoice #2024-001 Date: 2024-01-15', + 'Item Qty Price Total', + 'Widget A 10 $2.50 $25.00', + 'Widget B 5 $4.00 $20.00', + 'Service Fee 1 $15.00 $15.00', + '─────────────────────────────────────', + 'Subtotal $60.00', + 'Tax (8%) $4.80', + 'Total $64.80', + 'Payment: Credit Card **** 4242', + 'Thank you for your purchase!', + 'Reference: TXN-20240115-ABC123', + 'Lorem ipsum dolor sit amet consectetur', + ]; + let y = 15; + for (const line of lines) { + drawText(rgba, w, h, line, 10, y); + y += 30; + } + return buildPNGBuffer(w, h, rgba); +} + +/** Large image: 2048x2048 with scattered text */ +async function generateLargeImage() { + const w = 2048, h = 2048; + const rgba = Buffer.alloc(w * h * 4, 255); // white bg + // Place text blocks at various positions + const texts = [ + 'DOCUMENT TITLE 2024', 'Section 1: Introduction', + 'This is a comprehensive document.', 'Page 1 of 4', + 'Section 2: Methodology', 'The results show significant improvement.', + 'Section 3: Results', 'P50: 12ms P95: 45ms', + 'Section 4: Conclusion', 'Further analysis is recommended.', + 'Appendix A: Raw Data', 'Table 1: Performance Metrics', + ]; + let x = 50, y = 50; + for (const text of texts) { + drawText(rgba, w, h, text, x, y); + y += 80; + if (y > h - 100) { y = 50; x += 500; } + } + return buildPNGBuffer(w, h, rgba); +} + +/** Multi-page PDF: uses existing hello-123.png as base (multi-page not directly supported in benchmark) */ +function generateMultiPagePDF() { + // Use existing hello-123.png as a proxy for PDF page testing + const fixturePath = resolve( + process.cwd(), + 'packages/light-ocr-server/test/fixtures/hello-123.png' + ); + return readFileSync(fixturePath); +} + +/** Simple block letter drawer (no external deps) */ +function drawText(rgba, imgW, imgH, text, startX, startY) { + // 5x7 bitmap font for ASCII 32-90 + const FONT = { + ' ': [0x00,0x00,0x00,0x00,0x00,0x00,0x00], + '!': [0x04,0x04,0x04,0x04,0x04,0x00,0x04], + '#': [0x0a,0x1f,0x0a,0x0a,0x1f,0x0a,0x00], + '$': [0x04,0x0f,0x14,0x0e,0x05,0x1e,0x04], + '%': [0x18,0x19,0x02,0x04,0x08,0x13,0x03], + '&': [0x0c,0x12,0x14,0x08,0x15,0x12,0x0d], + '(': [0x02,0x04,0x08,0x08,0x08,0x04,0x02], + ')': [0x08,0x04,0x02,0x02,0x02,0x04,0x08], + '*': [0x00,0x04,0x15,0x0e,0x15,0x04,0x00], + '+': [0x00,0x04,0x04,0x1f,0x04,0x04,0x00], + ',': [0x00,0x00,0x00,0x00,0x00,0x04,0x08], + '-': [0x00,0x00,0x00,0x1f,0x00,0x00,0x00], + '.': [0x00,0x00,0x00,0x00,0x00,0x00,0x04], + '/': [0x00,0x01,0x02,0x04,0x08,0x10,0x00], + '0': [0x0e,0x11,0x13,0x15,0x19,0x11,0x0e], + '1': [0x04,0x0c,0x04,0x04,0x04,0x04,0x0e], + '2': [0x0e,0x11,0x01,0x02,0x04,0x08,0x1f], + '3': [0x1f,0x02,0x04,0x02,0x01,0x11,0x0e], + '4': [0x02,0x06,0x0a,0x12,0x1f,0x02,0x02], + '5': [0x1f,0x10,0x1e,0x01,0x01,0x11,0x0e], + '6': [0x06,0x08,0x10,0x1e,0x11,0x11,0x0e], + '7': [0x1f,0x01,0x02,0x04,0x08,0x08,0x08], + '8': [0x0e,0x11,0x11,0x0e,0x11,0x11,0x0e], + '9': [0x0e,0x11,0x11,0x0f,0x01,0x02,0x0c], + ':': [0x00,0x00,0x04,0x00,0x04,0x00,0x00], + ';': [0x00,0x00,0x04,0x00,0x04,0x08,0x00], + '<': [0x02,0x04,0x08,0x10,0x08,0x04,0x02], + '=': [0x00,0x00,0x1f,0x00,0x1f,0x00,0x00], + '>': [0x08,0x04,0x02,0x01,0x02,0x04,0x08], + '?': [0x0e,0x11,0x01,0x02,0x04,0x00,0x04], + '@': [0x0e,0x11,0x01,0x0d,0x15,0x15,0x0e], + 'A': [0x0e,0x11,0x11,0x1f,0x11,0x11,0x11], + 'B': [0x1e,0x11,0x11,0x1e,0x11,0x11,0x1e], + 'C': [0x0e,0x11,0x10,0x10,0x10,0x11,0x0e], + 'D': [0x1c,0x12,0x11,0x11,0x11,0x12,0x1c], + 'E': [0x1f,0x10,0x10,0x1e,0x10,0x10,0x1f], + 'F': [0x1f,0x10,0x10,0x1e,0x10,0x10,0x10], + 'G': [0x0e,0x11,0x10,0x17,0x11,0x11,0x0f], + 'H': [0x11,0x11,0x11,0x1f,0x11,0x11,0x11], + 'I': [0x0e,0x04,0x04,0x04,0x04,0x04,0x0e], + 'J': [0x07,0x02,0x02,0x02,0x02,0x12,0x0c], + 'K': [0x11,0x12,0x14,0x18,0x14,0x12,0x11], + 'L': [0x10,0x10,0x10,0x10,0x10,0x10,0x1f], + 'M': [0x11,0x1b,0x15,0x15,0x11,0x11,0x11], + 'N': [0x11,0x11,0x19,0x15,0x13,0x11,0x11], + 'O': [0x0e,0x11,0x11,0x11,0x11,0x11,0x0e], + 'P': [0x1e,0x11,0x11,0x1e,0x10,0x10,0x10], + 'Q': [0x0e,0x11,0x11,0x11,0x15,0x12,0x0d], + 'R': [0x1e,0x11,0x11,0x1e,0x14,0x12,0x11], + 'S': [0x0f,0x10,0x10,0x0e,0x01,0x01,0x1e], + 'T': [0x1f,0x04,0x04,0x04,0x04,0x04,0x04], + 'U': [0x11,0x11,0x11,0x11,0x11,0x11,0x0e], + 'V': [0x11,0x11,0x11,0x11,0x0a,0x0a,0x04], + 'W': [0x11,0x11,0x11,0x15,0x15,0x1b,0x11], + 'X': [0x11,0x11,0x0a,0x04,0x0a,0x11,0x11], + 'Y': [0x11,0x11,0x0a,0x04,0x04,0x04,0x04], + 'Z': [0x1f,0x01,0x02,0x04,0x08,0x10,0x1f], + 'a': [0x00,0x00,0x0e,0x01,0x0f,0x11,0x0f], + 'b': [0x10,0x10,0x16,0x19,0x11,0x11,0x1e], + 'c': [0x00,0x00,0x0e,0x10,0x10,0x11,0x0e], + 'd': [0x01,0x01,0x0d,0x13,0x11,0x11,0x0f], + 'e': [0x00,0x00,0x0e,0x11,0x1f,0x10,0x0e], + 'f': [0x06,0x09,0x08,0x1c,0x08,0x08,0x08], + 'g': [0x00,0x0f,0x11,0x11,0x0f,0x01,0x0e], + 'h': [0x10,0x10,0x16,0x19,0x11,0x11,0x11], + 'i': [0x04,0x00,0x0c,0x04,0x04,0x04,0x0e], + 'j': [0x02,0x00,0x06,0x02,0x02,0x12,0x0c], + 'k': [0x10,0x10,0x12,0x14,0x18,0x14,0x12], + 'l': [0x0c,0x04,0x04,0x04,0x04,0x04,0x0e], + 'm': [0x00,0x00,0x1a,0x15,0x15,0x11,0x11], + 'n': [0x00,0x00,0x16,0x19,0x11,0x11,0x11], + 'o': [0x00,0x00,0x0e,0x11,0x11,0x11,0x0e], + 'p': [0x00,0x00,0x1e,0x11,0x1e,0x10,0x10], + 'q': [0x00,0x00,0x0d,0x13,0x0f,0x01,0x01], + 'r': [0x00,0x00,0x16,0x19,0x10,0x10,0x10], + 's': [0x00,0x00,0x0e,0x10,0x0e,0x01,0x1e], + 't': [0x08,0x08,0x1c,0x08,0x08,0x09,0x06], + 'u': [0x00,0x00,0x11,0x11,0x11,0x13,0x0d], + 'v': [0x00,0x00,0x11,0x11,0x11,0x0a,0x04], + 'w': [0x00,0x00,0x11,0x11,0x15,0x15,0x0a], + 'x': [0x00,0x00,0x11,0x0a,0x04,0x0a,0x11], + 'y': [0x00,0x00,0x11,0x11,0x0f,0x01,0x0e], + 'z': [0x00,0x00,0x1f,0x02,0x04,0x08,0x1f], + }; + const scale = 2; + let cx = startX; + for (const ch of text) { + const glyph = FONT[ch] || FONT['?']; + for (let row = 0; row < 7; row++) { + const bits = glyph[row]; + for (let col = 0; col < 5; col++) { + if (bits & (1 << (4 - col))) { + for (let dy = 0; dy < scale; dy++) { + for (let dx = 0; dx < scale; dx++) { + const px = cx + col * scale + dx; + const py = startY + row * scale + dy; + if (px >= 0 && px < imgW && py >= 0 && py < imgH) { + const off = (py * imgW + px) * 4; + rgba[off] = 0; + rgba[off + 1] = 0; + rgba[off + 2] = 0; + } + } + } + } + } + } + cx += 6 * scale; + } +} + +// ── Percentile helper ──────────────────────────────────────────────────────── +function percentile(sorted, p) { + const idx = Math.min(sorted.length - 1, Math.ceil(p * sorted.length) - 1); + return sorted[idx]; +} + +function distribution(values) { + const sorted = [...values].sort((a, b) => a - b); + return { + min: sorted[0], + p50: percentile(sorted, 0.50), + p95: percentile(sorted, 0.95), + max: sorted[sorted.length - 1], + mean: Math.round(sorted.reduce((a, b) => a + b, 0) / sorted.length), + }; +} + +// ── Memory snapshot ────────────────────────────────────────────────────────── +function memorySnapshot() { + const usage = process.memoryUsage(); + return { + rssMB: Math.round(usage.rss / 1024 / 1024), + heapUsedMB: Math.round(usage.heapUsed / 1024 / 1024), + heapTotalMB: Math.round(usage.heapTotal / 1024 / 1024), + externalMB: Math.round(usage.external / 1024 / 1024), + arrayBuffersMB: Math.round(usage.arrayBuffers / 1024 / 1024), + }; +} + +// ── Main benchmark ─────────────────────────────────────────────────────────── +async function main() { + const env = getEnvironment(); + console.log('═══════════════════════════════════════════════════════════════'); + console.log(' light-ocr CPU Performance Benchmark'); + console.log('═══════════════════════════════════════════════════════════════'); + console.log(` OS: ${env.os}`); + console.log(` CPU: ${env.cpuModel}`); + console.log(` Cores: ${env.cpuPhysicalCores} physical / ${env.cpuCores} logical`); + console.log(` Memory: ${env.totalMemoryMB} MB`); + console.log(` Node.js: ${env.nodeVersion}`); + console.log(` Iterations: ${ITERATIONS} (warmup: ${WARMUP})`); + console.log('═══════════════════════════════════════════════════════════════\n'); + + // ── Prepare test images ────────────────────────────────────────────────── + console.log('Preparing test images...'); + const testCases = [ + { name: 'simple', desc: 'Simple image (300x100, "HELLO 123")', image: await generateSimpleImage() }, + { name: 'dense', desc: 'Dense text (800x600, invoice-like)', image: await generateDenseTextImage() }, + { name: 'large', desc: 'Large image (2048x2048, scattered text)', image: await generateLargeImage() }, + { name: 'real', desc: 'Real fixture (hello-123.png)', image: generateMultiPagePDF() }, + ]; + + for (const tc of testCases) { + console.log(` ✓ ${tc.name}: ${tc.desc} (${(tc.image.length / 1024).toFixed(1)} KB)`); + } + console.log(); + + // ── Cold start measurement ──────────────────────────────────────────────── + console.log('─── Cold Start (first engine creation + first OCR) ───'); + const memBefore = memorySnapshot(); + const coldStartBegin = performance.now(); + + const engine = await createEngine({ + execution: { provider: 'cpu', performanceHint: 'latency' }, + }); + + const engineReadyTime = performance.now(); + const coldEngineMs = engineReadyTime - coldStartBegin; + + // First OCR call (cold) + const firstOcr = await engine.recognizeEncoded(testCases[0].image); + const coldEnd = performance.now(); + const coldFirstOcrMs = coldEnd - engineReadyTime; + const coldTotalMs = coldEnd - coldStartBegin; + + const memAfterEngine = memorySnapshot(); + + console.log(` Engine creation: ${coldEngineMs.toFixed(1)} ms`); + console.log(` First OCR call: ${coldFirstOcrMs.toFixed(1)} ms`); + console.log(` Total cold start: ${coldTotalMs.toFixed(1)} ms`); + console.log(` RSS after engine: ${memAfterEngine.rssMB} MB (delta: +${memAfterEngine.rssMB - memBefore.rssMB} MB)`); + console.log(` Engine info: provider=${engine.info.execution.requestedProvider}, model=${engine.info.modelBundleId}`); + console.log(); + + // ── Per-workload benchmark ──────────────────────────────────────────────── + const results = {}; + + for (const tc of testCases) { + console.log(`─── ${tc.name}: ${tc.desc} ───`); + + // Warmup + for (let i = 0; i < WARMUP; i++) { + await engine.recognizeEncoded(tc.image); + } + + // Benchmark iterations + const latencies = []; + const timingsList = []; + const memSamples = []; + const cpuUsageStart = process.cpuUsage(); + + for (let i = 0; i < ITERATIONS; i++) { + memSamples.push(memorySnapshot()); + const start = performance.now(); + const result = await engine.recognizeEncoded(tc.image); + const elapsed = performance.now() - start; + latencies.push(elapsed); + timingsList.push(result.timingUs); + } + + const cpuUsageEnd = process.cpuUsage(cpuUsageStart); + const latencyDist = distribution(latencies); + const avgTiming = {}; + const timingKeys = Object.keys(timingsList[0] || {}); + for (const key of timingKeys) { + const values = timingsList.map(t => t[key]); + avgTiming[key] = distribution(values); + } + + const rssValues = memSamples.map(m => m.rssMB); + const memDist = distribution(rssValues); + + const result = { + description: tc.desc, + imageSizeBytes: tc.image.length, + iterations: ITERATIONS, + warmup: WARMUP, + latencyMs: latencyDist, + timingUs: avgTiming, + memory: { + rssMB: memDist, + }, + cpuTimeMs: { + user: Math.round(cpuUsageEnd.user / 1000), + system: Math.round(cpuUsageEnd.system / 1000), + }, + }; + results[tc.name] = result; + + console.log(` Latency (ms): P50=${latencyDist.p50.toFixed(1)} P95=${latencyDist.p95.toFixed(1)} mean=${latencyDist.mean.toFixed(1)} min=${latencyDist.min.toFixed(1)} max=${latencyDist.max.toFixed(1)}`); + console.log(` Timing (μs): total P50=${avgTiming.total?.p50} detect_inf=${avgTiming.detectionInference?.p50} recog_inf=${avgTiming.recognitionInference?.p50}`); + console.log(` Memory RSS: mean=${memDist.mean} MB max=${memDist.max} MB`); + console.log(` CPU time: user=${result.cpuTimeMs.user} ms system=${result.cpuTimeMs.system} ms`); + console.log(); + } + + // ── Warm start (post-warmup single call) ────────────────────────────────── + console.log('─── Warm Start (engine already loaded, single call) ───'); + // engine is already warm from the benchmarks above + const warmStart = performance.now(); + await engine.recognizeEncoded(testCases[0].image); + const warmMs = performance.now() - warmStart; + console.log(` Single OCR latency (warm): ${warmMs.toFixed(1)} ms`); + console.log(); + + // ── Final memory ────────────────────────────────────────────────────────── + const finalMem = memorySnapshot(); + console.log('─── Final Memory ───'); + console.log(` RSS: ${finalMem.rssMB} MB`); + console.log(` Heap used: ${finalMem.heapUsedMB} MB`); + console.log(` Heap total: ${finalMem.heapTotalMB} MB`); + console.log(` External: ${finalMem.externalMB} MB`); + console.log(); + + // ── Cleanup ─────────────────────────────────────────────────────────────── + await engine.close(); + + // ── Generate report ─────────────────────────────────────────────────────── + const report = { + schemaVersion: '1.0', + benchmark: 'cpu-performance-baseline', + environment: env, + config: { + executionProvider: 'cpu', + model: 'ppocrv6-small', + iterations: ITERATIONS, + warmup: WARMUP, + }, + coldStart: { + engineCreationMs: Math.round(coldEngineMs), + firstOcrMs: Math.round(coldFirstOcrMs), + totalColdMs: Math.round(coldTotalMs), + rssAfterEngineMB: memAfterEngine.rssMB, + rssDeltaMB: memAfterEngine.rssMB - memBefore.rssMB, + }, + warmStart: { + singleCallMs: Math.round(warmMs), + }, + workloads: results, + finalMemory: finalMem, + generatedAt: new Date().toISOString(), + }; + + const reportPath = join(process.cwd(), 'reports', 'cpu-baseline.json'); + const reportDir = join(process.cwd(), 'reports'); + if (!existsSync(reportDir)) mkdirSync(reportDir, { recursive: true }); + writeFileSync(reportPath, JSON.stringify(report, null, 2), 'utf-8'); + console.log(`Report saved to: ${reportPath}`); + + // ── Print summary table ─────────────────────────────────────────────────── + console.log('\n═══════════════════════════════════════════════════════════════'); + console.log(' Summary'); + console.log('═══════════════════════════════════════════════════════════════'); + console.log(` Cold start (engine + 1st OCR): ${coldTotalMs.toFixed(0)} ms`); + console.log(` Warm start (single call): ${warmMs.toFixed(1)} ms`); + console.log(''); + console.log(' Workload Latency (ms):'); + console.log(' ┌────────────┬──────────┬──────────┬──────────┐'); + console.log(' │ Workload │ P50 │ P95 │ Mean │'); + console.log(' ├────────────┼──────────┼──────────┼──────────┤'); + for (const [name, r] of Object.entries(results)) { + const pad = (s, n) => String(s).padStart(n); + console.log(` │ ${name.padEnd(10)} │ ${pad(r.latencyMs.p50.toFixed(1), 8)} │ ${pad(r.latencyMs.p95.toFixed(1), 8)} │ ${pad(r.latencyMs.mean.toFixed(1), 8)} │`); + } + console.log(' └────────────┴──────────┴──────────┴──────────┘'); + console.log(`\n Peak RSS: ${finalMem.rssMB} MB`); + console.log('═══════════════════════════════════════════════════════════════'); +} + +main().catch((err) => { + console.error('Benchmark failed:', err); + process.exit(1); +});