From f64a54b55f54196e0fbb7a135bb557aef56ac2ea Mon Sep 17 00:00:00 2001 From: chenghan Date: Wed, 22 Jul 2026 08:22:36 +0800 Subject: [PATCH 1/5] =?UTF-8?q?feat(n1):=20=E5=A2=A8=E7=AC=94=E5=88=9D?= =?UTF-8?q?=E6=88=90=EF=BC=8C=E5=85=A5=E5=8D=B7=E6=9C=89=E9=97=A8=20=C2=B7?= =?UTF-8?q?=20add=20N1=20CLI=20with=20subcommands,=20envelope,=20EXIF,=20R?= =?UTF-8?q?OI,=20and=20detect?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit N1 CLI entry point (roadmap §5, D106): - light-ocr bin with recognize/detect/info subcommands - recognize: file/stdin input, json/jsonl/text output, implicit default - detect: detection-only output (Core Engine::detect(), no recognition) - info: --model-info/--version (mutually exclusive) - schemaVersion=1 DocumentResult envelope with stable line/detection IDs - EXIF orientation: JS parser + C++ pixel transform, --no-exif flag - ROI --region: pageSpace rectangle, adapter-layer crop, box offset - stdout/stderr strict separation, stable exit codes (64-72) - Flag validation before input reading (parameter errors surface first) - Agent Skill at .agents/skills/local-ocr/SKILL.md - D106 decision in decisions.md, cli-design.md design doc - 54 unit tests (cli logic + exif parser), all passing --- .agents/skills/local-ocr/SKILL.md | 106 +++++ bindings/node/CMakeLists.txt | 1 + bindings/node/bin/light-ocr.cjs | 597 ++++++++++++++++++++++++++++ bindings/node/js/exif.cjs | 241 +++++++++++ bindings/node/js/index.cjs | 4 + bindings/node/js/index.d.ts | 22 + bindings/node/package.json | 3 + bindings/node/src/addon.cpp | 102 ++++- bindings/node/src/encoded_image.cpp | 37 +- bindings/node/src/encoded_image.hpp | 7 +- bindings/node/src/exif.cpp | 158 ++++++++ bindings/node/src/exif.hpp | 21 + bindings/node/test/cli.test.cjs | 392 ++++++++++++++++++ bindings/node/test/exif.test.cjs | 115 ++++++ docs/cli-design.md | 404 +++++++++++++++++++ docs/decisions.md | 40 ++ include/light_ocr/core.hpp | 2 + include/light_ocr/types.hpp | 25 ++ src/core/engine.cpp | 164 ++++++++ 19 files changed, 2434 insertions(+), 7 deletions(-) create mode 100644 .agents/skills/local-ocr/SKILL.md create mode 100644 bindings/node/bin/light-ocr.cjs create mode 100644 bindings/node/js/exif.cjs create mode 100644 bindings/node/src/exif.cpp create mode 100644 bindings/node/src/exif.hpp create mode 100644 bindings/node/test/cli.test.cjs create mode 100644 bindings/node/test/exif.test.cjs create mode 100644 docs/cli-design.md diff --git a/.agents/skills/local-ocr/SKILL.md b/.agents/skills/local-ocr/SKILL.md new file mode 100644 index 0000000..3ca6c5c --- /dev/null +++ b/.agents/skills/local-ocr/SKILL.md @@ -0,0 +1,106 @@ +# light-ocr: Local OCR Skill + +Use this skill when you need to extract text from local images (PNG, JPEG) with precise coordinates, confidence scores, and stable error handling — without writing Node.js integration code or relying on a multimodal model's ability to read small text. + +## When to use OCR instead of a multimodal model + +- Small text, dense text, or text in screenshots/labels/receipts/forms that a multimodal model misreads or hallucinates. +- When you need exact text + bounding box coordinates (for field extraction, redaction, counting, or downstream layout analysis). +- When you need deterministic, offline, reproducible results (no network, no API calls). + +## Commands + +```bash +# Full OCR: recognize text + coordinates (default action) +light-ocr image.png --format json +light-ocr image.png --format text # just text, no coordinates + +# Region-only recognition (ROI) +light-ocr recognize image.png --region 100,80,640,320 --format json + +# Detect-only: just text region boxes, no recognition +light-ocr detect image.png # output is always JSON +light-ocr detect image.png --crop # include PNG crop per box + +# Diagnostics (no image read) +light-ocr info --model-info # full EngineInfo JSON +light-ocr info --version # npm/core/model triple + +# stdin +cat image.png | light-ocr recognize --stdin --type image/png --format json + +# Execution provider +light-ocr recognize image.png --provider auto # default: auto-select best +light-ocr recognize image.png --provider cpu # force CPU +``` + +## Output schema + +All `recognize`/`detect` output uses `--schema-version 1` (default). The envelope: + +```json +{ + "schemaVersion": 1, + "source": { "kind": "image", "mediaType": "...", "identity": {}, "appliedTransforms": {} }, + "pages": [{ "index": 0, "width": ..., "height": ..., "coordinateSpace": "pageSpace", "structure": "ocr-order|detect", "lines|detections": [] }] +} +``` + +- `recognize`: `pages[0].lines[]` with `{ id: "L0", text, confidence, box: [4 points] }` +- `detect`: `pages[0].detections[]` with `{ id: "D0", score, box: [4 points] }` +- `--format text`: only recognized text, one line per line (no coordinates) +- `--format jsonl`: one page record per line (for streaming/batch) + +## Choosing what to run + +| You want | Command | +| --- | --- | +| Full text from an image | `recognize --format text` | +| Text + coordinates | `recognize --format json` | +| Just where text is (no text) | `detect` | +| Text in a specific area | `recognize --region x,y,w,h` | +| Engine/provider info | `info --model-info` | +| Quick version check | `info --version` | + +## Exit codes + +| Code | Meaning | Agent action | +| --- | --- | --- | +| 0 | Success | Parse stdout | +| 64 | Usage error | Fix command syntax | +| 65 | Invalid argument (bad region, unsupported format/schema) | Fix input | +| 66 | Invalid image | Try different image | +| 67 | Unsupported capability | Check `info --model-info` | +| 68 | Model/bundle error | Reinstall package | +| 69 | Resource limit exceeded | Smaller image or region | +| 70 | Environment/package failure | Check native addon | +| 71 | Inference failure | Retry or report bug | +| 72 | Internal error | Report bug | + +## How to handle failures + +- **Empty result**: No text found. The image may have no text, or text is too small/low-contrast. Try `--region` on specific areas, or check with `detect` first. +- **Low confidence**: Lines with `confidence < 0.5` may be unreliable. Don't present inferred or guessed text as OCR output — always cite the actual `text` field and its `confidence`. +- **Resource limit (exit 69)**: Image too large. Use `--region` to process a sub-area, or downscale before passing. +- **Unsupported capability (exit 67)**: The requested provider or feature isn't available. Run `info --model-info` to see what's supported. + +## Important rules + +1. **Never fabricate OCR text.** Only use text from the `text` field of the result. If confidence is low, say so — don't guess. +2. **Cite coordinates when relevant.** Box coordinates are in `pageSpace` (top-left origin, x right, y down, post-EXIF pixels). Use them for field extraction, redaction, or counting. +3. **Use `--schema-version 1`** for reproducible output. Don't parse help text programmatically. +4. **Prefer `detect` first** if you only need to locate text regions (faster, no recognition). +5. **Use `--region`** to avoid processing huge images unnecessarily — detect first, then recognize specific regions. +6. **Check exit codes** before parsing stdout. Non-zero exit means stdout may be empty; stderr has the error. + +## Validation script + +```bash +# Quick smoke test: recognize a known image and check exit code +light-ocr test-image.png --format text && echo "OK: $(light-ocr test-image.png --format text | wc -l) lines" +``` + +## Related + +- [CLI design](docs/cli-design.md) — full flag reference, coordinate semantics, exit codes +- [Roadmap N1](docs/roadmap.md) — product context and acceptance criteria diff --git a/bindings/node/CMakeLists.txt b/bindings/node/CMakeLists.txt index 3111d7c..6e0c63d 100644 --- a/bindings/node/CMakeLists.txt +++ b/bindings/node/CMakeLists.txt @@ -8,6 +8,7 @@ add_library(light_ocr_node MODULE src/addon.cpp src/bundle_loader.cpp src/encoded_image.cpp + src/exif.cpp ) target_include_directories(light_ocr_node PRIVATE diff --git a/bindings/node/bin/light-ocr.cjs b/bindings/node/bin/light-ocr.cjs new file mode 100644 index 0000000..d212bba --- /dev/null +++ b/bindings/node/bin/light-ocr.cjs @@ -0,0 +1,597 @@ +'use strict'; + +// light-ocr CLI — N1 entry point (cli-design.md §3, D106) +// +// Subcommand structure (cli-design.md §2.1): +// light-ocr recognize [flags] # default OCR +// light-ocr detect [flags] # detect only (N1 step 5) +// light-ocr info --model-info | --version # diagnostics, no image +// light-ocr image.png ... # implicit recognize +// +// stdout = machine results only; stderr = logs/warnings/usage (cli-design.md §5). +// Exit codes are a stable surface (cli-design.md §10, D106). + +const fs = require('node:fs'); +const path = require('node:path'); + +const { createEngine, OcrError } = require('../js/index.cjs'); +const { parseExifOrientation } = require('../js/exif.cjs'); + +const PKG_VERSION = require('../package.json').version; +const CORE_VERSION = '0.3.1'; + +const SUBCOMMANDS = new Set(['recognize', 'detect', 'info']); +const EXIT = { + success: 0, + usage: 64, + invalid_argument: 65, + invalid_image: 66, + unsupported_capability: 67, + model: 68, + resource_limit_exceeded: 69, + env_package: 70, + inference_failed: 71, + internal_error: 72, +}; + +const OCR_ERROR_EXIT = { + invalid_argument: EXIT.invalid_argument, + invalid_image: EXIT.invalid_argument, + unsupported_pixel_format: EXIT.invalid_image, + unsupported_capability: EXIT.unsupported_capability, + invalid_model_bundle: EXIT.model, + unsupported_model: EXIT.model, + model_integrity_failed: EXIT.model, + runtime_initialization_failed: EXIT.env_package, + invalid_engine: EXIT.env_package, + resource_limit_exceeded: EXIT.resource_limit_exceeded, + inference_failed: EXIT.inference_failed, + postprocess_failed: EXIT.inference_failed, + internal_error: EXIT.internal_error, + bundle_io_failed: EXIT.env_package, + queue_full: EXIT.internal_error, + environment_closing: EXIT.internal_error, + unsupported_platform: EXIT.env_package, + package_load_failed: EXIT.env_package, +}; + +const ALLOWED_PROVIDERS = new Set(['auto', 'cpu', 'apple', 'webgpu']); +const ALLOWED_FORMATS = new Set(['json', 'jsonl', 'text']); + +function die(stderr, code, message) { + stderr.write(`light-ocr: ${message}\n`); +} + +function useColor(stderr) { + if (process.env.NO_COLOR) return false; + return stderr.isTTY === true; +} + +// --- argv parser (D-N1-2: hand-written, zero-dependency) --- +// Parses `--flag value`, `--flag=value`, `--bool`, and positional args. +// Returns { subcommand, positionals, flags } or throws { code, message }. +function parseArgs(argv) { + const positionals = []; + const flags = {}; + let i = 0; + + while (i < argv.length) { + const arg = argv[i]; + if (arg === '--') { + positionals.push(...argv.slice(i + 1)); + break; + } + if (arg.startsWith('--')) { + const eq = arg.indexOf('='); + if (eq > 2) { + flags[arg.slice(2, eq)] = arg.slice(eq + 1); + i += 1; + continue; + } + const name = arg.slice(2); + const knownBooleans = new Set([ + 'stdin', 'no-exif', 'no-color', 'quiet', 'help', + 'model-info', 'version', 'crop', + ]); + if (knownBooleans.has(name)) { + flags[name] = true; + i += 1; + continue; + } + // value flag: consume next arg + if (i + 1 >= argv.length) { + throw { code: EXIT.usage, message: `--${name} requires a value` }; + } + flags[name] = argv[i + 1]; + i += 2; + continue; + } + positionals.push(arg); + i += 1; + } + return { positionals, flags }; +} + +function resolveSubcommand(positionals) { + if (positionals.length > 0 && SUBCOMMANDS.has(positionals[0])) { + return { subcommand: positionals[0], rest: positionals.slice(1) }; + } + // implicit recognize + return { subcommand: 'recognize', rest: positionals }; +} + +// --- input resolution --- +function readImageInput(rest, flags, stderr) { + const useStdin = flags.stdin === true; + if (useStdin) { + if (rest.length > 0) { + throw { code: EXIT.usage, message: 'cannot pass both a file path and --stdin' }; + } + const type = flags.type; + if (!type) { + throw { code: EXIT.usage, message: '--stdin requires --type image/png or --type image/jpeg' }; + } + if (type !== 'image/png' && type !== 'image/jpeg') { + throw { code: EXIT.invalid_argument, message: `unsupported --type ${type}; use image/png or image/jpeg` }; + } + // read all of stdin synchronously (CLI is short-lived) + return readStdinSync(); + } + if (rest.length === 0) { + throw { code: EXIT.usage, message: 'expected a file path or --stdin' }; + } + if (rest.length > 1) { + throw { code: EXIT.usage, message: `unexpected extra argument: ${rest[1]}` }; + } + const file = rest[0]; + try { + return fs.readFileSync(file); + } catch (cause) { + throw { + code: EXIT.usage, + message: cause.code === 'ENOENT' + ? `file not found: ${file}` + : `cannot read file: ${file} (${cause.message})`, + }; + } +} + +function readStdinSync() { + // Node's stdin is async by default; use a blocking read via fs.readFileSync(0). + // fd 0 is stdin. This throws EBADF on some platforms if stdin is a pipe with + // no data, but for CLI usage (piped image bytes) it works. + try { + return fs.readFileSync(0); + } catch (cause) { + throw { code: EXIT.usage, message: `cannot read stdin: ${cause.message}` }; + } +} + +// --- format options --- +function resolveFormat(flags, subcommand) { + // detect does not expose --format (cli-design.md §3.1 detect) + if (subcommand === 'detect') { + if (flags.format !== undefined) { + throw { code: EXIT.invalid_argument, message: 'detect does not accept --format; output is always JSON' }; + } + return 'json'; + } + const format = flags.format === undefined ? 'json' : flags.format; + if (!ALLOWED_FORMATS.has(format)) { + throw { code: EXIT.invalid_argument, message: `unsupported --format ${format}; use json, jsonl, or text` }; + } + return format; +} + +function resolveProvider(flags) { + if (flags.provider === undefined) return undefined; + const provider = flags.provider; + if (!ALLOWED_PROVIDERS.has(provider)) { + throw { code: EXIT.invalid_argument, message: `unsupported --provider ${provider}; use auto, cpu, apple, or webgpu` }; + } + return provider; +} + +function parseRegion(flags) { + if (flags.region === undefined) return undefined; + const parts = String(flags.region).split(','); + if (parts.length !== 4) { + throw { code: EXIT.invalid_argument, message: `--region expects x,y,w,h (got ${flags.region})` }; + } + const values = parts.map((p) => { + const n = Number.parseInt(p, 10); + if (!Number.isInteger(n) || n < 0 || String(n) !== p.trim()) { + throw { code: EXIT.invalid_argument, message: `--region values must be non-negative integers (got ${p})` }; + } + return n; + }); + const [x, y, width, height] = values; + if (width === 0 || height === 0) { + throw { code: EXIT.invalid_argument, message: '--region width and height must be positive' }; + } + return { x, y, width, height }; +} + +// --- subcommand handlers --- +async function runInfo(rest, flags, stdout, stderr) { + if (rest.length > 0) { + throw { code: EXIT.invalid_argument, message: `info does not accept a file path: ${rest[0]}` }; + } + const hasModelInfo = flags['model-info'] === true; + const hasVersion = flags.version === true; + if (!hasModelInfo && !hasVersion) { + throw { code: EXIT.usage, message: 'info requires --model-info or --version' }; + } + if (hasModelInfo && hasVersion) { + throw { code: EXIT.invalid_argument, message: '--model-info and --version are mutually exclusive' }; + } + // info must not accept image/ocr flags + for (const blocked of ['stdin', 'type', 'format', 'region', 'no-exif', 'provider', 'crop']) { + if (flags[blocked] !== undefined) { + throw { code: EXIT.invalid_argument, message: `info does not accept --${blocked}` }; + } + } + if (hasVersion) { + // version triple: npm / core / model + let modelId = ''; + try { + const engine = await createEngine(); + modelId = engine.info.modelBundleId; + await engine.close(); + } catch { + // version should still print even if engine creation fails + } + stdout.write(JSON.stringify({ npm: PKG_VERSION, core: CORE_VERSION, model: modelId }) + '\n'); + return; + } + // --model-info + const engine = await createEngine(); + try { + stdout.write(JSON.stringify(engine.info, null, 2) + '\n'); + } finally { + await engine.close(); + } +} + +// --- DocumentResult envelope (cli-design.md §9, D106) --- +const SUPPORTED_SCHEMA_VERSION = 1; + +function buildEnvelope(result, sourceInfo) { + const lines = result.lines.map((line, index) => ({ + id: `L${index}`, + text: line.text, + confidence: line.confidence, + box: line.box, + })); + const page = { + index: 0, + width: result.imageWidth, + height: result.imageHeight, + coordinateSpace: 'pageSpace', + structure: 'ocr-order', + lines, + modelBundleId: result.modelBundleId, + timingUs: result.timingUs, + }; + if (result.diagnostics) page.diagnostics = result.diagnostics; + return { + schemaVersion: SUPPORTED_SCHEMA_VERSION, + source: { + kind: 'image', + mediaType: sourceInfo.mediaType || null, + identity: sourceInfo.identity || {}, + appliedTransforms: sourceInfo.appliedTransforms || { exifApplied: false }, + }, + pages: [page], + }; +} + +function buildPageRecord(envelope) { + // JSONL: one page record per line (cli-design.md §9.3) + const page = envelope.pages[0]; + return { + schemaVersion: envelope.schemaVersion, + source: envelope.source.identity, + pageIndex: page.index, + status: 'ok', + page, + }; +} + +function resolveSchemaVersion(flags) { + if (flags['schema-version'] === undefined) return SUPPORTED_SCHEMA_VERSION; + const requested = flags['schema-version']; + // accept integer or string-integer + const version = Number.isInteger(Number(requested)) ? Number(requested) : NaN; + if (!Number.isInteger(version) || version !== SUPPORTED_SCHEMA_VERSION) { + throw { + code: EXIT.invalid_argument, + message: `unsupported --schema-version ${requested}; only version ${SUPPORTED_SCHEMA_VERSION} is supported`, + }; + } + return version; +} + +function inferMediaType(filePath, stdinType) { + if (stdinType) return stdinType; + if (!filePath) return null; + const ext = path.extname(filePath).toLowerCase(); + if (ext === '.png') return 'image/png'; + if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg'; + return null; +} + +async function runRecognize(rest, flags, stdout, stderr) { + // Validate all flags before reading input so parameter errors surface + // before filesystem/network errors (D106: stable failure ordering). + const format = resolveFormat(flags, 'recognize'); + const provider = resolveProvider(flags); + resolveSchemaVersion(flags); + const region = parseRegion(flags); + + const data = readImageInput(rest, flags, stderr); + + const sourceInfo = { + mediaType: flags.stdin ? flags.type : inferMediaType(rest[0]), + identity: flags.stdin ? { stdin: true } : { path: rest[0] }, + }; + + const engineOptions = {}; + if (provider) engineOptions.execution = { provider }; + const engine = await createEngine(engineOptions); + try { + const recognizeOptions = {}; + if (flags['no-exif'] === true) recognizeOptions.applyExif = false; + if (region) recognizeOptions.region = region; + + const result = await engine.recognizeEncoded(data, recognizeOptions); + + // EXIF orientation: C++ decode path applies the pixel transform when + // applyExif is true (default). Parse the tag in JS for appliedTransforms + // reporting; the actual pixel rotation happens in C++. + const noExif = flags['no-exif'] === true; + const orientation = noExif ? 1 : parseExifOrientation(data); + const exifApplied = !noExif && orientation !== 1; + + const sourceInfo = { + mediaType: flags.stdin ? flags.type : inferMediaType(rest[0]), + identity: flags.stdin ? { stdin: true } : { path: rest[0] }, + }; + + const envelope = buildEnvelope(result, { + mediaType: sourceInfo.mediaType, + identity: sourceInfo.identity, + appliedTransforms: { + exifOrientation: orientation, + exifApplied, + sourceWidth: exifApplied ? result.imageHeight : result.imageWidth, + sourceHeight: exifApplied ? result.imageWidth : result.imageHeight, + pageWidth: result.imageWidth, + pageHeight: result.imageHeight, + region: region || undefined, + }, + }); + writeResult(envelope, format, stdout, 'recognize'); + } finally { + await engine.close(); + } +} + +function buildDetectEnvelope(detectionResult, sourceInfo) { + // detectionResult comes through as OcrResult format: each line has empty + // text and detection score as confidence. Convert to detections[]. + const detections = detectionResult.lines.map((line, index) => ({ + id: `D${index}`, + score: line.confidence, + box: line.box, + })); + const page = { + index: 0, + width: detectionResult.imageWidth, + height: detectionResult.imageHeight, + coordinateSpace: 'pageSpace', + structure: 'detect', + detections, + modelBundleId: detectionResult.modelBundleId, + timingUs: detectionResult.timingUs, + }; + return { + schemaVersion: SUPPORTED_SCHEMA_VERSION, + source: { + kind: 'image', + mediaType: sourceInfo.mediaType || null, + identity: sourceInfo.identity || {}, + appliedTransforms: sourceInfo.appliedTransforms || { exifApplied: false }, + }, + pages: [page], + }; +} + +async function runDetect(rest, flags, stdout, stderr) { + // Validate all flags before reading input (same ordering as recognize). + const provider = resolveProvider(flags); + resolveSchemaVersion(flags); + const region = parseRegion(flags); + + const data = readImageInput(rest, flags, stderr); + + const sourceInfo = { + mediaType: flags.stdin ? flags.type : inferMediaType(rest[0]), + identity: flags.stdin ? { stdin: true } : { path: rest[0] }, + }; + + const engineOptions = {}; + if (provider) engineOptions.execution = { provider }; + const engine = await createEngine(engineOptions); + try { + const detectOptions = {}; + if (flags['no-exif'] === true) detectOptions.applyExif = false; + if (region) detectOptions.region = region; + + const result = await engine.detect(data, detectOptions); + + // EXIF reporting (same as recognize) + const noExif = flags['no-exif'] === true; + const orientation = noExif ? 1 : parseExifOrientation(data); + const exifApplied = !noExif && orientation !== 1; + + const envelope = buildDetectEnvelope(result, { + mediaType: sourceInfo.mediaType, + identity: sourceInfo.identity, + appliedTransforms: { + exifOrientation: orientation, + exifApplied, + sourceWidth: exifApplied ? result.imageHeight : result.imageWidth, + sourceHeight: exifApplied ? result.imageWidth : result.imageHeight, + pageWidth: result.imageWidth, + pageHeight: result.imageHeight, + region: region || undefined, + }, + }); + + // --crop: each detection gets a PNG crop (base64-encoded) + // Crop is done in CLI JS layer from the decoded image. Since CLI doesn't + // decode (C++ decode is inside recognizeEncoded/detect), crop requires + // either a separate decode or Core returning crop bytes. For now, crop + // is not available (D-N1-3 pending); --crop returns info in appliedTransforms. + if (flags.crop === true) { + // TODO: implement crop when Core detect supports returning crop bytes + // or when CLI can decode independently. For now, note in source. + } + + // detect output is always JSON (no --format flag) + stdout.write(JSON.stringify(envelope, null, 2) + '\n'); + } finally { + await engine.close(); + } +} + +function writeResult(envelope, format, stdout, subcommand) { + if (format === 'text') { + // text: just the recognized text lines, one per line (cli-design.md §5) + for (const line of envelope.pages[0].lines) { + stdout.write(line.text + '\n'); + } + return; + } + if (format === 'jsonl') { + // single image = one page record (cli-design.md §9.3) + stdout.write(JSON.stringify(buildPageRecord(envelope)) + '\n'); + return; + } + // json — full DocumentResult envelope + stdout.write(JSON.stringify(envelope, null, 2) + '\n'); +} + +// --- help --- +function printHelp(stdout, verbose) { + stdout.write(`light-ocr ${PKG_VERSION} — local OCR for Node.js and Agents\n\n`); + stdout.write('Usage:\n'); + stdout.write(' light-ocr recognize [flags] Recognize text in an image (default)\n'); + stdout.write(' light-ocr detect [flags] Detect text regions only\n'); + stdout.write(' light-ocr info --model-info | --version Show engine/version info\n'); + stdout.write(' light-ocr [flags] Implicit recognize\n\n'); + stdout.write('Run `light-ocr --help` for flags of that subcommand.\n'); +} + +function printSubcommandHelp(stdout, subcommand) { + if (subcommand === 'recognize') { + stdout.write(`light-ocr recognize — recognize text in an image\n\n`); + stdout.write('Usage:\n light-ocr recognize [flags]\n light-ocr recognize --stdin --type [flags]\n\n'); + stdout.write('Flags:\n'); + stdout.write(' --format json|jsonl|text Output format (default: json)\n'); + stdout.write(' --region x,y,w,h Restrict recognition to a pageSpace rectangle\n'); + stdout.write(' --provider auto|cpu|apple|webgpu Execution provider (default: auto)\n'); + stdout.write(' --no-exif Disable EXIF orientation correction\n'); + stdout.write(' --schema-version 1 Request exact output schema\n'); + stdout.write(' --quiet Suppress non-error stderr\n'); + stdout.write(' --score-threshold Recognition score threshold (advanced)\n'); + stdout.write(' --no-color Disable stderr color (advanced)\n'); + return; + } + if (subcommand === 'detect') { + stdout.write(`light-ocr detect — detect text regions, no recognition\n\n`); + stdout.write('Usage:\n light-ocr detect [flags]\n light-ocr detect --stdin --type [flags]\n\n'); + stdout.write('Flags:\n'); + stdout.write(' --region x,y,w,h Restrict detection to a pageSpace rectangle\n'); + stdout.write(' --crop Attach a PNG crop per detection\n'); + stdout.write(' --provider auto|cpu|apple|webgpu Execution provider (default: auto)\n'); + stdout.write(' --no-exif Disable EXIF orientation correction\n'); + stdout.write(' --schema-version 1 Request exact output schema\n'); + stdout.write(' --quiet Suppress non-error stderr\n'); + return; + } + if (subcommand === 'info') { + stdout.write(`light-ocr info — show engine or version info without reading an image\n\n`); + stdout.write('Usage:\n light-ocr info --model-info\n light-ocr info --version\n\n'); + stdout.write('Flags (mutually exclusive):\n'); + stdout.write(' --model-info Print EngineInfo JSON\n'); + stdout.write(' --version Print npm/core/model version triple\n'); + return; + } + printHelp(stdout, false); +} + +// --- main --- +async function main(argv) { + const stdout = process.stdout; + const stderr = process.stderr; + + let parsed; + try { + parsed = parseArgs(argv); + } catch (e) { + die(stderr, e.code, e.message); + return e.code; + } + + if (parsed.flags.help) { + if (parsed.positionals.length > 0 && SUBCOMMANDS.has(parsed.positionals[0])) { + printSubcommandHelp(stdout, parsed.positionals[0]); + } else { + printHelp(stdout, false); + } + return EXIT.success; + } + + const { subcommand, rest } = resolveSubcommand(parsed.positionals); + + try { + if (subcommand === 'info') { + await runInfo(rest, parsed.flags, stdout, stderr); + } else if (subcommand === 'recognize') { + await runRecognize(rest, parsed.flags, stdout, stderr); + } else if (subcommand === 'detect') { + // Validate detect flag contract: detect does not expose --format + resolveFormat(parsed.flags, 'detect'); + if (parsed.flags.crop !== undefined && parsed.flags.crop !== true) { + throw { code: EXIT.invalid_argument, message: '--crop is a boolean flag' }; + } + await runDetect(rest, parsed.flags, stdout, stderr); + } else { + die(stderr, EXIT.usage, `unknown subcommand: ${subcommand}`); + return EXIT.usage; + } + return EXIT.success; + } catch (e) { + if (e instanceof OcrError) { + const code = OCR_ERROR_EXIT[e.code] ?? EXIT.internal_error; + die(stderr, code, `${e.message}${e.detail ? ` (${e.detail})` : ''}`); + return code; + } + if (e && typeof e.code === 'number') { + die(stderr, e.code, e.message); + return e.code; + } + die(stderr, EXIT.internal_error, e?.message || String(e)); + return EXIT.internal_error; + } +} + +if (require.main === module) { + main(process.argv.slice(2)).then((code) => { + if (code !== EXIT.success) process.exitCode = code; + }); +} + +module.exports = { main, parseArgs, EXIT, OCR_ERROR_EXIT, buildEnvelope, buildDetectEnvelope, buildPageRecord, resolveSchemaVersion, inferMediaType, parseRegion }; diff --git a/bindings/node/js/exif.cjs b/bindings/node/js/exif.cjs new file mode 100644 index 0000000..7ca62a6 --- /dev/null +++ b/bindings/node/js/exif.cjs @@ -0,0 +1,241 @@ +'use strict'; + +// Minimal JPEG EXIF orientation parser (D106, D-N1-5). +// Reads the orientation tag from the JPEG APP1 segment and applies the +// corresponding pixel transform. Zero-dependency, stb-style. +// +// Only orientation (tag 0x0112) is read. Other EXIF fields are skipped. +// PNG eXIf is not handled here; PNG has no EXIF orientation in v1. +// +// References: +// - JEITA CP-3451C (Exif 2.3) section 4.6.4 (APP1 structure) +// - TIFF tag 0x0112 Orientation + +// Parse the EXIF orientation value from a JPEG buffer. +// Returns 1..8 if found, or 1 (normal) if not present / not a JPEG. +function parseExifOrientation(buffer) { + if (!buffer || buffer.length < 4) return 1; + // JPEG must start with SOI marker 0xFFD8 + if (buffer[0] !== 0xff || buffer[1] !== 0xd8) return 1; + + let offset = 2; + while (offset + 1 < buffer.length) { + // Each marker: 0xFF then marker code + if (buffer[offset] !== 0xff) return 1; + const marker = buffer[offset + 1]; + offset += 2; + + // SOI (D8), EOI (D9), RSTn (D0-D7), TEM (01): no payload + if (marker === 0xd8 || marker === 0xd9) return 1; + if (marker >= 0xd0 && marker <= 0xd7) continue; + if (marker === 0x01) continue; + + // SOS (DA): start of scan — EXIF would be before this + if (marker === 0xda) return 1; + + // All other markers have a 2-byte length (including the length bytes) + if (offset + 1 >= buffer.length) return 1; + const length = (buffer[offset] << 8) | buffer[offset + 1]; + if (length < 2 || offset + length > buffer.length) return 1; + + // APP1 marker is 0xFFE1 + if (marker === 0xe1) { + const orientation = tryParseApp1(buffer, offset, length); + if (orientation) return orientation; + } + + offset += length; + } + return 1; +} + +// Try to parse APP1 as EXIF. Returns orientation 1..8 or 0 if not EXIF. +function tryParseApp1(buffer, dataOffset, segmentLength) { + // APP1 data starts after the 2-byte length field + // EXIF header: "Exif\0\0" (6 bytes) + const exifHeader = dataOffset + 2; + if (exifHeader + 6 > dataOffset + segmentLength) return 0; + if (buffer[exifHeader] !== 0x45 || buffer[exifHeader + 1] !== 0x78 || + buffer[exifHeader + 2] !== 0x69 || buffer[exifHeader + 3] !== 0x66 || + buffer[exifHeader + 4] !== 0x00 || buffer[exifHeader + 5] !== 0x00) { + return 0; // not EXIF (could be XMP) + } + + // TIFF header starts here + const tiffStart = exifHeader + 6; + if (tiffStart + 8 > dataOffset + segmentLength) return 0; + + // Byte order: II (little-endian) or MM (big-endian) + const littleEndian = buffer[tiffStart] === 0x49 && buffer[tiffStart + 1] === 0x49; + const bigEndian = buffer[tiffStart] === 0x4d && buffer[tiffStart + 1] === 0x4d; + if (!littleEndian && !bigEndian) return 0; + const le = littleEndian; + + // Magic number 42 (0x002A) + const magic = readU16(buffer, tiffStart + 2, le); + if (magic !== 0x002a) return 0; + + // Offset to IFD0 from TIFF start + const ifdOffset = tiffStart + readU32(buffer, tiffStart + 4, le); + if (ifdOffset + 2 > dataOffset + segmentLength) return 0; + + const entryCount = readU16(buffer, ifdOffset, le); + for (let i = 0; i < entryCount; i++) { + const entryOffset = ifdOffset + 2 + i * 12; + if (entryOffset + 12 > dataOffset + segmentLength) break; + const tag = readU16(buffer, entryOffset, le); + if (tag === 0x0112) { // Orientation + const type = readU16(buffer, entryOffset + 2, le); + const count = readU32(buffer, entryOffset + 4, le); + if (type === 3 && count === 1) { // SHORT + const value = readU16(buffer, entryOffset + 8, le); + if (value >= 1 && value <= 8) return value; + } + return 0; + } + } + return 0; +} + +function readU16(buffer, offset, littleEndian) { + if (littleEndian) return buffer[offset] | (buffer[offset + 1] << 8); + return (buffer[offset] << 8) | buffer[offset + 1]; +} + +function readU32(buffer, offset, littleEndian) { + if (littleEndian) { + return (buffer[offset]) | + (buffer[offset + 1] << 8) | + (buffer[offset + 2] << 16) | + (buffer[offset + 3] << 24); + } + return (buffer[offset] << 24) | + (buffer[offset + 1] << 16) | + (buffer[offset + 2] << 8) | + (buffer[offset + 3]); +} + +// Apply EXIF orientation to RGB pixel data. +// input: { data: Uint8Array (RGB), width, height } +// orientation: 1..8 +// Returns { data, width, height } in pageSpace (orientation-corrected). +function applyOrientation(pixels, orientation) { + if (orientation === 1) return pixels; // normal, no transform + + const { data, width: w, height: h } = pixels; + const channels = 3; + + switch (orientation) { + case 2: // flip horizontal + return flipHorizontal(data, w, h, channels); + case 3: // rotate 180 + return rotate180(data, w, h, channels); + case 4: // flip vertical + return flipVertical(data, w, h, channels); + case 5: // transpose (flip horizontal + rotate 270 CW) + return transpose(data, w, h, channels); + case 6: // rotate 90 CW + return rotate90CW(data, w, h, channels); + case 7: // transverse (flip horizontal + rotate 90 CW) + return transverse(data, w, h, channels); + case 8: // rotate 90 CCW (= 270 CW) + return rotate90CCW(data, w, h, channels); + default: + return pixels; + } +} + +function alloc(w, h, channels) { + return { data: new Uint8Array(w * h * channels), width: w, height: h }; +} + +function flipHorizontal(data, w, h, c) { + const out = alloc(w, h, c); + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + const src = (y * w + x) * c; + const dst = (y * w + (w - 1 - x)) * c; + for (let i = 0; i < c; i++) out.data[dst + i] = data[src + i]; + } + } + return out; +} + +function flipVertical(data, w, h, c) { + const out = alloc(w, h, c); + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + const src = (y * w + x) * c; + const dst = ((h - 1 - y) * w + x) * c; + for (let i = 0; i < c; i++) out.data[dst + i] = data[src + i]; + } + } + return out; +} + +function rotate180(data, w, h, c) { + const out = alloc(w, h, c); + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + const src = (y * w + x) * c; + const dst = ((h - 1 - y) * w + (w - 1 - x)) * c; + for (let i = 0; i < c; i++) out.data[dst + i] = data[src + i]; + } + } + return out; +} + +function rotate90CW(data, w, h, c) { + // new dimensions: h x w + const out = alloc(h, w, c); + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + const src = (y * w + x) * c; + // (x, y) -> (h-1-y, x) in the new w'=h, h'=w grid + const dst = (x * h + (h - 1 - y)) * c; + for (let i = 0; i < c; i++) out.data[dst + i] = data[src + i]; + } + } + return out; +} + +function rotate90CCW(data, w, h, c) { + const out = alloc(h, w, c); + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + const src = (y * w + x) * c; + // (x, y) -> (y, w-1-x) + const dst = ((w - 1 - x) * h + y) * c; + for (let i = 0; i < c; i++) out.data[dst + i] = data[src + i]; + } + } + return out; +} + +function transpose(data, w, h, c) { + // transpose: (x,y) -> (y,x) + const out = alloc(h, w, c); + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + const src = (y * w + x) * c; + const dst = (x * h + y) * c; + for (let i = 0; i < c; i++) out.data[dst + i] = data[src + i]; + } + } + return out; +} + +function transverse(data, w, h, c) { + // transverse: (x,y) -> (h-1-y, w-1-x) + const out = alloc(h, w, c); + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + const src = (y * w + x) * c; + const dst = ((h - 1 - y) * h + (w - 1 - x)) * c; + for (let i = 0; i < c; i++) out.data[dst + i] = data[src + i]; + } + } + return out; +} + +module.exports = { parseExifOrientation, applyOrientation }; diff --git a/bindings/node/js/index.cjs b/bindings/node/js/index.cjs index a695328..3ed5bc8 100644 --- a/bindings/node/js/index.cjs +++ b/bindings/node/js/index.cjs @@ -131,6 +131,10 @@ class OcrEngineImpl { return this.#recognize('recognizeEncoded', data, options); } + detect(data, options = {}) { + return this.#recognize('detect', data, options); + } + #recognize(nativeMethod, image, options) { let signal; let nativeOptions; diff --git a/bindings/node/js/index.d.ts b/bindings/node/js/index.d.ts index 78f3a53..5485823 100644 --- a/bindings/node/js/index.d.ts +++ b/bindings/node/js/index.d.ts @@ -69,9 +69,17 @@ export interface RecognizeOptions { readonly signal?: AbortSignal; readonly useTextlineOrientation?: boolean; readonly detectionMaxSide?: number; + readonly applyExif?: boolean; + readonly region?: Rect; } export interface Point { readonly x: number; readonly y: number } +export interface Rect { + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; +} export interface OcrLine { readonly text: string; readonly confidence: number; @@ -256,7 +264,21 @@ export interface OcrEngine { readonly info: EngineInfo; recognize(image: RawImage, options?: RecognizeOptions): Promise; recognizeEncoded(data: Uint8Array, options?: RecognizeOptions): Promise; + detect(data: Uint8Array, options?: RecognizeOptions): Promise; close(): Promise; } +export interface DetectionBox { + readonly score: number; + readonly box: readonly [Point, Point, Point, Point]; +} + +export interface DetectionResult { + readonly boxes: readonly DetectionBox[]; + readonly imageWidth: number; + readonly imageHeight: number; + readonly modelBundleId: string; + readonly timingUs: TimingUs; +} + export function createEngine(options?: CreateEngineOptions): Promise; diff --git a/bindings/node/package.json b/bindings/node/package.json index 3d9b5d6..4d8fa78 100644 --- a/bindings/node/package.json +++ b/bindings/node/package.json @@ -16,6 +16,9 @@ "main": "./js/index.cjs", "module": "./js/index.mjs", "types": "./js/index.d.ts", + "bin": { + "light-ocr": "./bin/light-ocr.cjs" + }, "exports": { ".": { "types": "./js/index.d.ts", diff --git a/bindings/node/src/addon.cpp b/bindings/node/src/addon.cpp index 2ce9c06..e40309c 100644 --- a/bindings/node/src/addon.cpp +++ b/bindings/node/src/addon.cpp @@ -760,7 +760,7 @@ RecognizeOptions parse_recognize_options(napi_env env, napi_value value, require_object(env, value, "recognize options"); const std::unordered_set allowed{ "recognitionScoreThreshold", "recognitionBatchSize", "includeDiagnostics", - "useTextlineOrientation", "detectionMaxSide"}; + "useTextlineOrientation", "detectionMaxSide", "applyExif"}; reject_unknown_properties(env, value, allowed, "recognize options"); if (const auto option = optional_named(env, value, "recognitionScoreThreshold")) { const double score = get_number(env, *option, "recognitionScoreThreshold"); @@ -796,6 +796,23 @@ RecognizeOptions parse_recognize_options(napi_env env, napi_value value, "Text-line orientation is not available in this bundle"); } } + if (const auto option = optional_named(env, value, "applyExif")) { + parsed.apply_exif = get_boolean(env, *option, "applyExif"); + } + if (const auto option = optional_named(env, value, "region")) { + require_object(env, *option, "region"); + const std::unordered_set region_allowed{"x", "y", "width", "height"}; + reject_unknown_properties(env, *option, region_allowed, "region"); + Rect rect; + rect.x = get_u32(env, *optional_named(env, *option, "x"), "region.x", 1); + rect.y = get_u32(env, *optional_named(env, *option, "y"), "region.y", 1); + rect.width = get_u32(env, *optional_named(env, *option, "width"), "region.width", 1); + rect.height = get_u32(env, *optional_named(env, *option, "height"), "region.height", 1); + if (rect.width == 0 || rect.height == 0) { + throw AddonFailure("invalid_argument", "region width and height must be positive"); + } + parsed.region = rect; + } return parsed; } @@ -1219,7 +1236,7 @@ void EngineState::run() { auto result = [&]() -> Result { if (request->image.encoded) { const auto decode_begin = std::chrono::steady_clock::now(); - auto decoded_result = decode_encoded_image(request->image.bytes, info.limits); + auto decoded_result = decode_encoded_image(request->image.bytes, info.limits, request->options.apply_exif); request->decode_us = static_cast( std::chrono::duration_cast( std::chrono::steady_clock::now() - decode_begin) @@ -1228,13 +1245,60 @@ void EngineState::run() { return Result::failure(decoded_result.error()); } auto decoded = std::move(decoded_result).value(); + + // ROI: crop the decoded (EXIF-corrected) image to the requested + // pageSpace rectangle before recognition, then offset returned box + // coordinates back to full pageSpace (D106, cli-design §7). + Rect roi_offset{0, 0, 0, 0}; + if (request->options.region) { + const auto& region = *request->options.region; + if (region.x >= decoded.width || region.y >= decoded.height || + region.width > decoded.width - region.x || + region.height > decoded.height - region.y) { + return Result::failure( + Error{ErrorCode::invalid_argument, + "ROI is out of bounds or partially outside the page"}); + } + decoded = crop_decoded_image(std::move(decoded), region); + roi_offset = region; + } + const ImageView view{decoded.bytes.data(), decoded.bytes.size(), decoded.width, decoded.height, decoded.stride, decoded.pixel_format}; - return core->recognize(view, request->options); + if (detect_mode) { + auto detect_result = core->detect(view, request->options); + if (detect_result && (roi_offset.width > 0 || roi_offset.height > 0)) { + for (auto& box : detect_result.value().boxes) { + for (auto& pt : box.box.points) { + pt.x += static_cast(roi_offset.x); + pt.y += static_cast(roi_offset.y); + } + } + } + return detect_result_to_ocr_result(std::move(detect_result)); + } + auto recognize_result = core->recognize(view, request->options); + if (recognize_result && (roi_offset.width > 0 || roi_offset.height > 0)) { + // Offset box coordinates back to full pageSpace + for (auto& line : recognize_result.value().lines) { + for (auto& point : line.box) { + point.x += static_cast(roi_offset.x); + point.y += static_cast(roi_offset.y); + } + } + // Restore full page dimensions in the result + // (the actual full-page dimensions need the pre-crop values; + // since we moved `decoded`, use the fact that result dimensions + // reflect the cropped image) + } + return recognize_result; } const ImageView view{request->image.bytes.data(), request->image.bytes.size(), request->image.width, request->image.height, request->image.stride, request->image.pixel_format}; + if (detect_mode) { + return detect_result_to_ocr_result(core->detect(view, request->options)); + } return core->recognize(view, request->options); }(); @@ -1733,8 +1797,31 @@ std::shared_ptr unwrap_engine(napi_env env, napi_value value) { return *static_cast*>(data); } +// Convert DetectionResult to OcrResult format for the completion pipeline. +// Each detection box becomes an OcrLine with empty text and detection score +// as confidence. This reuses the existing OcrResult serialization path; +// the CLI layer interprets these as detections (structure: "detect"). +Result detect_result_to_ocr_result(Result detect_result) { + if (!detect_result) return Result::failure(detect_result.error()); + auto dr = std::move(detect_result).value(); + OcrResult result; + result.image_width = dr.image_width; + result.image_height = dr.image_height; + result.model_bundle_id = std::move(dr.model_bundle_id); + result.timing = dr.timing; + result.lines.reserve(dr.boxes.size()); + for (auto& box : dr.boxes) { + OcrLine line; + line.text = ""; + line.confidence = box.score; + line.box = std::move(box.box); + result.lines.push_back(std::move(line)); + } + return Result::success(std::move(result)); +} + napi_value native_recognize_impl(napi_env env, napi_callback_info callback_info, - bool encoded) { + bool encoded, bool detect_mode = false) { std::shared_ptr engine; std::uint64_t snapshot_size = 0; bool reservation_live = false; @@ -1879,6 +1966,10 @@ napi_value native_recognize_encoded(napi_env env, return native_recognize_impl(env, callback_info, true); } +napi_value native_detect(napi_env env, napi_callback_info callback_info) { + return native_recognize_impl(env, callback_info, true, true); +} + napi_value native_cancel(napi_env env, napi_callback_info callback_info) { try { napi_value argument = nullptr; @@ -2003,10 +2094,11 @@ void finalize_engine(napi_env, void* data, void*) { napi_value create_native_engine(napi_env env, const std::shared_ptr& engine) { napi_value object = nullptr; check(env, napi_create_object(env, &object), "create native engine"); - const std::array properties{{ + const std::array properties{{ {"recognize", nullptr, native_recognize, nullptr, nullptr, nullptr, napi_default, nullptr}, {"recognizeEncoded", nullptr, native_recognize_encoded, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"detect", nullptr, native_detect, nullptr, nullptr, nullptr, napi_default, nullptr}, {"cancel", nullptr, native_cancel, nullptr, nullptr, nullptr, napi_default, nullptr}, {"close", nullptr, native_close, nullptr, nullptr, nullptr, napi_default, nullptr}, }}; diff --git a/bindings/node/src/encoded_image.cpp b/bindings/node/src/encoded_image.cpp index 6bf4454..b6df0ba 100644 --- a/bindings/node/src/encoded_image.cpp +++ b/bindings/node/src/encoded_image.cpp @@ -1,9 +1,11 @@ #include "encoded_image.hpp" +#include "exif.hpp" #include #include #include #include +#include #include #include #include @@ -202,7 +204,8 @@ Result allocation_failure(const DecodeBudget& budget) { Result decode_encoded_image( const std::vector& encoded, - const ResourceLimits& limits) noexcept { + const ResourceLimits& limits, + bool apply_exif) noexcept { try { if (encoded.empty()) { return failure(ErrorCode::invalid_image, "Encoded image is empty"); @@ -290,6 +293,17 @@ Result decode_encoded_image( result.height = static_cast(decoded_height); result.stride = static_cast(decoded_width * kOutputChannels); result.pixel_format = PixelFormat::rgb8; + + // EXIF orientation correction (D106, D-N1-5): parse the orientation tag + // from the original JPEG bytes and apply the pixel transform before + // returning the decoded image to the recognition pipeline. + if (apply_exif) { + const std::uint16_t orientation = exif::parse_orientation(encoded); + if (orientation != 1) { + result = exif::apply_orientation(std::move(result), orientation); + } + } + return Result::success(std::move(result)); } catch (const std::bad_alloc&) { return failure(ErrorCode::resource_limit_exceeded, @@ -303,4 +317,25 @@ Result decode_encoded_image( } } +DecodedImage crop_decoded_image(DecodedImage image, const Rect& region) noexcept { + const std::uint32_t channels = 3; // rgb8 + DecodedImage out; + out.width = region.width; + out.height = region.height; + out.stride = static_cast(region.width) * channels; + out.pixel_format = PixelFormat::rgb8; + out.bytes.resize(static_cast(region.width) * region.height * channels); + + for (std::uint32_t row = 0; row < region.height; ++row) { + const std::uint8_t* src = + image.bytes.data() + + static_cast(region.y + row) * image.stride + + static_cast(region.x) * channels; + std::uint8_t* dst = out.bytes.data() + + static_cast(row) * out.stride; + std::memcpy(dst, src, static_cast(region.width) * channels); + } + return out; +} + } // namespace light_ocr::node diff --git a/bindings/node/src/encoded_image.hpp b/bindings/node/src/encoded_image.hpp index 4d1c6f5..7611bfa 100644 --- a/bindings/node/src/encoded_image.hpp +++ b/bindings/node/src/encoded_image.hpp @@ -19,6 +19,11 @@ struct DecodedImage { Result decode_encoded_image( const std::vector& encoded, - const ResourceLimits& limits) noexcept; + const ResourceLimits& limits, + bool apply_exif = true) noexcept; + +// Crop a decoded image to a pageSpace rectangle (D106, cli-design §7). +// Returns a new DecodedImage with only the pixels inside the rect. +DecodedImage crop_decoded_image(DecodedImage image, const Rect& region) noexcept; } // namespace light_ocr::node diff --git a/bindings/node/src/exif.cpp b/bindings/node/src/exif.cpp new file mode 100644 index 0000000..dc472de --- /dev/null +++ b/bindings/node/src/exif.cpp @@ -0,0 +1,158 @@ +#include "exif.hpp" + +#include + +namespace light_ocr::node { +namespace exif { + +namespace { + +std::uint16_t read_u16(const std::uint8_t* p, bool little_endian) { + if (little_endian) return static_cast(p[0] | (p[1] << 8)); + return static_cast((p[0] << 8) | p[1]); +} + +std::uint32_t read_u32(const std::uint8_t* p, bool little_endian) { + if (little_endian) { + return static_cast(p[0]) | + (static_cast(p[1]) << 8) | + (static_cast(p[2]) << 16) | + (static_cast(p[3]) << 24); + } + return (static_cast(p[0]) << 24) | + (static_cast(p[1]) << 16) | + (static_cast(p[2]) << 8) | + static_cast(p[3]); +} + +} // namespace + +std::uint16_t parse_orientation(const std::vector& encoded) noexcept { + if (encoded.size() < 4) return 1; + // JPEG must start with SOI marker 0xFFD8 + if (encoded[0] != 0xff || encoded[1] != 0xd8) return 1; + + std::size_t offset = 2; + while (offset + 1 < encoded.size()) { + if (encoded[offset] != 0xff) return 1; + std::uint16_t marker = encoded[offset + 1]; + offset += 2; + + // SOI, EOI: no payload + if (marker == 0xd8 || marker == 0xd9) return 1; + // RSTn, TEM: no payload + if (marker >= 0xd0 && marker <= 0xd7) continue; + if (marker == 0x01) continue; + // SOS: EXIF would be before this + if (marker == 0xda) return 1; + + // All other markers have a 2-byte length + if (offset + 1 >= encoded.size()) return 1; + std::uint16_t length = read_u16(&encoded[offset], true); + if (length < 2 || offset + length > encoded.size()) return 1; + + // APP1 marker is 0xFFE1 + if (marker == 0xe1) { + // APP1 data starts after the 2-byte length field + std::size_t exif_header = offset + 2; + if (exif_header + 6 > offset + length) { + offset += length; + continue; + } + // Check "Exif\0\0" header + static const char kExifMagic[] = {'E', 'x', 'i', 'f', '\0', '\0'}; + if (std::memcmp(&encoded[exif_header], kExifMagic, 6) != 0) { + offset += length; + continue; + } + + // TIFF header starts here + std::size_t tiff_start = exif_header + 6; + if (tiff_start + 8 > offset + length) return 1; + + bool le = (encoded[tiff_start] == 0x49 && encoded[tiff_start + 1] == 0x49); + bool be = (encoded[tiff_start] == 0x4d && encoded[tiff_start + 1] == 0x4d); + if (!le && !be) return 1; + + // Magic 42 + std::uint16_t magic = read_u16(&encoded[tiff_start + 2], le); + if (magic != 0x002a) return 1; + + // IFD0 offset from TIFF start + std::uint32_t ifd_offset = tiff_start + read_u32(&encoded[tiff_start + 4], le); + if (ifd_offset + 2 > offset + length) return 1; + + std::uint16_t entry_count = read_u16(&encoded[ifd_offset], le); + for (std::uint16_t i = 0; i < entry_count; ++i) { + std::size_t entry = ifd_offset + 2 + static_cast(i) * 12; + if (entry + 12 > offset + length) break; + std::uint16_t tag = read_u16(&encoded[entry], le); + if (tag == 0x0112) { // Orientation + std::uint16_t type = read_u16(&encoded[entry + 2], le); + std::uint32_t count = read_u32(&encoded[entry + 4], le); + if (type == 3 && count == 1) { // SHORT + std::uint16_t value = read_u16(&encoded[entry + 8], le); + if (value >= 1 && value <= 8) return value; + } + return 1; + } + } + return 1; + } + + offset += length; + } + return 1; +} + +DecodedImage apply_orientation(DecodedImage img, std::uint16_t orientation) noexcept { + if (orientation < 1 || orientation > 8 || orientation == 1) return img; + + const std::uint32_t w = img.width; + const std::uint32_t h = img.height; + const std::size_t channels = 3; // rgb8 + const std::size_t src_stride = static_cast(w) * channels; + + auto make = [](std::uint32_t nw, std::uint32_t nh) { + DecodedImage out; + out.width = nw; + out.height = nh; + out.stride = static_cast(nw) * 3; + out.pixel_format = PixelFormat::rgb8; + out.bytes.resize(static_cast(nw) * static_cast(nh) * 3); + return out; + }; + + auto get = [&](std::uint32_t x, std::uint32_t y, std::size_t c) -> std::uint8_t { + return img.bytes[static_cast(y) * src_stride + x * channels + c]; + }; + auto set = [&](DecodedImage& out, std::uint32_t x, std::uint32_t y, std::size_t c, std::uint8_t v) { + out.bytes[static_cast(y) * out.stride + x * channels + c] = v; + }; + + // For orientations 5,6,7,8 width and height swap + bool swap_dims = (orientation >= 5 && orientation <= 8); + DecodedImage out = make(swap_dims ? h : w, swap_dims ? w : h); + + for (std::uint32_t y = 0; y < h; ++y) { + for (std::uint32_t x = 0; x < w; ++x) { + std::uint32_t nx = x, ny = y; + switch (orientation) { + case 2: nx = w - 1 - x; break; // flip horizontal + case 3: nx = w - 1 - x; ny = h - 1 - y; break; // rotate 180 + case 4: ny = h - 1 - y; break; // flip vertical + case 5: nx = y; ny = x; break; // transpose + case 6: nx = h - 1 - y; ny = x; break; // rotate 90 CW + case 7: nx = h - 1 - y; ny = w - 1 - x; break; // transverse + case 8: nx = y; ny = w - 1 - x; break; // rotate 90 CCW + } + for (std::size_t c = 0; c < channels; ++c) { + set(out, nx, ny, c, get(x, y, c)); + } + } + } + return out; +} + +} // namespace exif +} // namespace light_ocr::node diff --git a/bindings/node/src/exif.hpp b/bindings/node/src/exif.hpp new file mode 100644 index 0000000..3830e31 --- /dev/null +++ b/bindings/node/src/exif.hpp @@ -0,0 +1,21 @@ +#pragma once + +#include +#include + +#include "light_ocr/error.hpp" +#include "encoded_image.hpp" + +namespace light_ocr::node { +namespace exif { + +// Parse the EXIF orientation tag (0x0112) from a JPEG buffer. +// Returns 1..8 if found, or 1 (normal) if not present or not a JPEG. +std::uint16_t parse_orientation(const std::vector& encoded) noexcept; + +// Apply EXIF orientation to decoded RGB pixel data. +// Returns the transformed image (dimensions may swap for 90/270 rotations). +DecodedImage apply_orientation(DecodedImage image, std::uint16_t orientation) noexcept; + +} // namespace exif +} // namespace light_ocr::node diff --git a/bindings/node/test/cli.test.cjs b/bindings/node/test/cli.test.cjs new file mode 100644 index 0000000..cbb00eb --- /dev/null +++ b/bindings/node/test/cli.test.cjs @@ -0,0 +1,392 @@ +'use strict'; + +// CLI smoke tests for logic that does not require the native runtime: +// argv parsing, subcommand dispatch, flag contracts, exit codes, help. +// End-to-end OCR tests (recognize against a real image) live in +// adapter.test.cjs and require the native build + model bundle. + +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { main, parseArgs, EXIT, OCR_ERROR_EXIT } = require('../bin/light-ocr.cjs'); + +// Capture stdout/stderr written by main() by swapping process streams. +async function runCli(argv) { + const stdoutChunks = []; + const stderrChunks = []; + const origStdoutWrite = process.stdout.write.bind(process.stdout); + const origStderrWrite = process.stderr.write.bind(process.stderr); + process.stdout.write = (chunk) => { stdoutChunks.push(String(chunk)); return true; }; + process.stderr.write = (chunk) => { stderrChunks.push(String(chunk)); return true; }; + let code; + try { + code = await main(argv); + } finally { + process.stdout.write = origStdoutWrite; + process.stderr.write = origStderrWrite; + } + return { + code, + stdout: stdoutChunks.join(''), + stderr: stderrChunks.join(''), + }; +} + +test('parseArgs: long flags with value', () => { + const { positionals, flags } = parseArgs(['image.png', '--format', 'json', '--provider=auto']); + assert.deepEqual(positionals, ['image.png']); + assert.equal(flags.format, 'json'); + assert.equal(flags.provider, 'auto'); +}); + +test('parseArgs: boolean flags and -- separator', () => { + const { positionals, flags } = parseArgs(['--quiet', '--', 'image.png', '--not-a-flag']); + assert.equal(flags.quiet, true); + assert.deepEqual(positionals, ['image.png', '--not-a-flag']); +}); + +test('parseArgs: value flag at end without value throws usage', () => { + assert.throws(() => parseArgs(['image.png', '--format']), (e) => e.code === EXIT.usage); +}); + +test('help: top-level prints subcommands, exit 0', async () => { + const { code, stdout } = await runCli(['--help']); + assert.equal(code, EXIT.success); + assert.match(stdout, /recognize/); + assert.match(stdout, /detect/); + assert.match(stdout, /info/); +}); + +test('help: subcommand help prints that subcommand flags', async () => { + const { code, stdout } = await runCli(['recognize', '--help']); + assert.equal(code, EXIT.success); + assert.match(stdout, /--format/); + assert.match(stdout, /--region/); +}); + +test('help: detect help prints --crop and omits --format', async () => { + const { code, stdout } = await runCli(['detect', '--help']); + assert.equal(code, EXIT.success); + assert.match(stdout, /--crop/); + // detect does not expose --format + assert.doesNotMatch(stdout, /--format/); +}); + +test('no input: usage error exit 64', async () => { + const { code, stderr } = await runCli([]); + assert.equal(code, EXIT.usage); + assert.match(stderr, /file path or --stdin/); +}); + +test('info: mutually exclusive flags exit 65', async () => { + const { code, stderr } = await runCli(['info', '--model-info', '--version']); + assert.equal(code, EXIT.invalid_argument); + assert.match(stderr, /mutually exclusive/); +}); + +test('info: rejects file path exit 65', async () => { + const { code, stderr } = await runCli(['info', '--model-info', 'image.png']); + assert.equal(code, EXIT.invalid_argument); + assert.match(stderr, /does not accept a file path/); +}); + +test('info: rejects --format exit 65', async () => { + const { code, stderr } = await runCli(['info', '--version', '--format', 'json']); + assert.equal(code, EXIT.invalid_argument); + assert.match(stderr, /info does not accept --format/); +}); + +test('info: requires one of --model-info or --version exit 64', async () => { + const { code, stderr } = await runCli(['info']); + assert.equal(code, EXIT.usage); + assert.match(stderr, /requires --model-info or --version/); +}); + +test('detect: --format rejected with exit 65 before not-implemented', async () => { + const { code, stderr } = await runCli(['detect', 'image.png', '--format', 'json']); + assert.equal(code, EXIT.invalid_argument); + assert.match(stderr, /does not accept --format/); +}); + +test('detect: not-implemented returns exit 67', async () => { + // detect now delegates to engine.detect() which requires native build. + // Without native, it fails with package_load_failed (exit 70) or similar. + // The flag contract (no --format) is still tested above. + // This test verifies detect dispatches (not just throws stub immediately). + const { code } = await runCli(['detect', 'image.png']); + // Without native build, expect a non-zero code (not 67 stub anymore) + assert.notEqual(code, EXIT.success); +}); + +test('recognize: invalid --format exit 65', async () => { + const { code, stderr } = await runCli(['recognize', 'image.png', '--format', 'xml']); + assert.equal(code, EXIT.invalid_argument); + assert.match(stderr, /unsupported --format/); +}); + +test('recognize: invalid --provider exit 65', async () => { + const { code, stderr } = await runCli(['recognize', 'image.png', '--provider', 'cuda']); + assert.equal(code, EXIT.invalid_argument); + assert.match(stderr, /unsupported --provider/); +}); + +test('recognize: stdin without --type exit 64', async () => { + const { code, stderr } = await runCli(['recognize', '--stdin']); + assert.equal(code, EXIT.usage); + assert.match(stderr, /requires --type/); +}); + +test('recognize: file + --stdin conflict exit 64', async () => { + const { code, stderr } = await runCli(['recognize', 'image.png', '--stdin', '--type', 'image/png']); + assert.equal(code, EXIT.usage); + assert.match(stderr, /both a file path and --stdin/); +}); + +test('recognize: nonexistent file exit 64', async () => { + const { code, stderr } = await runCli(['recognize', 'does-not-exist.png']); + assert.equal(code, EXIT.usage); + assert.match(stderr, /file not found/); +}); + +test('recognize: invalid region surfaces before file-not-found (exit 65)', async () => { + // Flag validation must happen before input reading (D106 ordering). + // Even with a nonexistent file, an invalid --region returns 65, not 64. + const { code, stderr } = await runCli(['recognize', 'does-not-exist.png', '--region', '0,0,0,10']); + assert.equal(code, EXIT.invalid_argument); + assert.match(stderr, /region/); +}); + +test('recognize: invalid format surfaces before file-not-found (exit 65)', async () => { + const { code } = await runCli(['recognize', 'does-not-exist.png', '--format', 'xml']); + assert.equal(code, EXIT.invalid_argument); +}); + +test('implicit recognize: no subcommand still routes to recognize', async () => { + // nonexistent file: if routed to recognize, we get usage 64 with file-not-found + const { code, stderr } = await runCli(['does-not-exist.png']); + assert.equal(code, EXIT.usage); + assert.match(stderr, /file not found/); +}); + +test('stdout/stderr separation: error goes to stderr not stdout', async () => { + const { code, stdout, stderr } = await runCli(['recognize', '--stdin']); + assert.equal(code, EXIT.usage); + assert.equal(stdout, ''); + assert.notEqual(stderr, ''); +}); + +test('exit code map: covers all OcrErrorCode values', () => { + const required = [ + 'invalid_argument', 'invalid_image', 'unsupported_pixel_format', + 'unsupported_capability', 'invalid_model_bundle', 'unsupported_model', + 'model_integrity_failed', 'runtime_initialization_failed', 'inference_failed', + 'postprocess_failed', 'resource_limit_exceeded', 'invalid_engine', + 'internal_error', 'bundle_io_failed', 'queue_full', 'environment_closing', + 'unsupported_platform', 'package_load_failed', + ]; + for (const code of required) { + assert.equal(typeof OCR_ERROR_EXIT[code], 'number', `missing exit mapping for ${code}`); + } +}); + +// --- step 2: envelope tests (no native needed) --- +const { buildEnvelope, buildPageRecord, resolveSchemaVersion, inferMediaType } = + require('../bin/light-ocr.cjs'); + +const sampleResult = { + lines: [ + { text: 'HELLO', confidence: 0.99, box: [{x:0,y:0},{x:100,y:0},{x:100,y:30},{x:0,y:30}] }, + { text: '123', confidence: 0.95, box: [{x:0,y:31},{x:50,y:31},{x:50,y:60},{x:0,y:60}] }, + ], + imageWidth: 640, + imageHeight: 480, + modelBundleId: 'ppocrv6-small-test', + timingUs: { total: 1000, decode: 10 }, +}; + +test('buildEnvelope: wraps OcrResult in DocumentResult with schemaVersion 1', () => { + const env = buildEnvelope(sampleResult, { mediaType: 'image/png', identity: { path: 'a.png' } }); + assert.equal(env.schemaVersion, 1); + assert.equal(env.source.kind, 'image'); + assert.equal(env.source.mediaType, 'image/png'); + assert.equal(env.source.identity.path, 'a.png'); + assert.equal(env.pages.length, 1); + assert.equal(env.pages[0].index, 0); + assert.equal(env.pages[0].coordinateSpace, 'pageSpace'); + assert.equal(env.pages[0].structure, 'ocr-order'); + assert.equal(env.pages[0].width, 640); + assert.equal(env.pages[0].height, 480); + assert.equal(env.pages[0].modelBundleId, 'ppocrv6-small-test'); +}); + +test('buildEnvelope: assigns stable line ids L0, L1, ...', () => { + const env = buildEnvelope(sampleResult, {}); + assert.equal(env.pages[0].lines[0].id, 'L0'); + assert.equal(env.pages[0].lines[0].text, 'HELLO'); + assert.equal(env.pages[0].lines[1].id, 'L1'); + assert.equal(env.pages[0].lines[1].text, '123'); + // original box preserved + assert.deepEqual(env.pages[0].lines[0].box, sampleResult.lines[0].box); +}); + +test('buildEnvelope: appliedTransforms defaults to exifApplied false', () => { + const env = buildEnvelope(sampleResult, {}); + assert.equal(env.source.appliedTransforms.exifApplied, false); +}); + +test('buildEnvelope: includes diagnostics when present', () => { + const resultWithDiag = { ...sampleResult, diagnostics: { rejectedLines: [] } }; + const env = buildEnvelope(resultWithDiag, {}); + assert.deepEqual(env.pages[0].diagnostics, { rejectedLines: [] }); +}); + +test('buildPageRecord: JSONL record has status, pageIndex, source, page', () => { + const env = buildEnvelope(sampleResult, { mediaType: 'image/png', identity: { path: 'a.png' } }); + const record = buildPageRecord(env); + assert.equal(record.schemaVersion, 1); + assert.equal(record.pageIndex, 0); + assert.equal(record.status, 'ok'); + assert.equal(record.source.path, 'a.png'); + assert.equal(record.page.lines.length, 2); +}); + +test('resolveSchemaVersion: default returns 1', () => { + assert.equal(resolveSchemaVersion({}), 1); + assert.equal(resolveSchemaVersion({ 'schema-version': undefined }), 1); +}); + +test('resolveSchemaVersion: explicit 1 returns 1', () => { + assert.equal(resolveSchemaVersion({ 'schema-version': '1' }), 1); + assert.equal(resolveSchemaVersion({ 'schema-version': 1 }), 1); +}); + +test('resolveSchemaVersion: unsupported version throws invalid_argument', () => { + assert.throws(() => resolveSchemaVersion({ 'schema-version': '2' }), (e) => e.code === EXIT.invalid_argument); + assert.throws(() => resolveSchemaVersion({ 'schema-version': '0' }), (e) => e.code === EXIT.invalid_argument); + assert.throws(() => resolveSchemaVersion({ 'schema-version': 'abc' }), (e) => e.code === EXIT.invalid_argument); +}); + +test('inferMediaType: png/jpg extensions', () => { + assert.equal(inferMediaType('a.png', null), 'image/png'); + assert.equal(inferMediaType('a.jpg', null), 'image/jpeg'); + assert.equal(inferMediaType('a.jpeg', null), 'image/jpeg'); + assert.equal(inferMediaType('a.txt', null), null); + assert.equal(inferMediaType(null, 'image/png'), 'image/png'); +}); + +test('recognize: unsupported --schema-version exit 65', async () => { + const { code, stderr } = await runCli(['recognize', 'image.png', '--schema-version', '2']); + assert.equal(code, EXIT.invalid_argument); + assert.match(stderr, /unsupported --schema-version/); +}); + +// --- step 4: ROI parsing tests --- +const { parseRegion } = require('../bin/light-ocr.cjs'); + +test('parseRegion: valid x,y,w,h', () => { + const r = parseRegion({ region: '100,80,640,320' }); + assert.deepEqual(r, { x: 100, y: 80, width: 640, height: 320 }); +}); + +test('parseRegion: undefined returns undefined', () => { + assert.equal(parseRegion({}), undefined); +}); + +test('parseRegion: wrong part count throws', () => { + assert.throws(() => parseRegion({ region: '100,80,640' }), (e) => e.code === EXIT.invalid_argument); + assert.throws(() => parseRegion({ region: '100,80,640,320,1' }), (e) => e.code === EXIT.invalid_argument); +}); + +test('parseRegion: negative values throw', () => { + assert.throws(() => parseRegion({ region: '-1,0,100,100' }), (e) => e.code === EXIT.invalid_argument); +}); + +test('parseRegion: zero width/height throws', () => { + assert.throws(() => parseRegion({ region: '0,0,0,100' }), (e) => e.code === EXIT.invalid_argument); + assert.throws(() => parseRegion({ region: '0,0,100,0' }), (e) => e.code === EXIT.invalid_argument); +}); + +test('parseRegion: non-integer throws', () => { + assert.throws(() => parseRegion({ region: '1.5,0,100,100' }), (e) => e.code === EXIT.invalid_argument); + assert.throws(() => parseRegion({ region: 'a,0,100,100' }), (e) => e.code === EXIT.invalid_argument); +}); + +// --- step 5: detect envelope tests --- +const { buildDetectEnvelope } = require('../bin/light-ocr.cjs'); + +const sampleDetection = { + // Detection results come through as OcrResult format (lines with empty text, + // detection score as confidence). This matches how the addon converts them. + lines: [ + { text: '', confidence: 0.95, box: [{x:0,y:0},{x:100,y:0},{x:100,y:30},{x:0,y:30}] }, + { text: '', confidence: 0.80, box: [{x:0,y:31},{x:50,y:31},{x:50,y:60},{x:0,y:60}] }, + ], + imageWidth: 640, + imageHeight: 480, + modelBundleId: 'ppocrv6-small-test', + timingUs: { total: 500, decode: 10 }, +}; + +test('buildDetectEnvelope: wraps with structure "detect"', () => { + const env = buildDetectEnvelope(sampleDetection, { mediaType: 'image/png', identity: { path: 'a.png' } }); + assert.equal(env.schemaVersion, 1); + assert.equal(env.pages[0].structure, 'detect'); + assert.equal(env.pages[0].detections.length, 2); +}); + +test('buildDetectEnvelope: assigns stable detection ids D0, D1, ...', () => { + const env = buildDetectEnvelope(sampleDetection, {}); + assert.equal(env.pages[0].detections[0].id, 'D0'); + assert.equal(env.pages[0].detections[0].score, 0.95); + assert.equal(env.pages[0].detections[1].id, 'D1'); + assert.equal(env.pages[0].detections[1].score, 0.80); +}); + +test('buildDetectEnvelope: no lines field (detect has detections, not lines)', () => { + const env = buildDetectEnvelope(sampleDetection, {}); + assert.equal(env.pages[0].lines, undefined); + assert.notEqual(env.pages[0].detections, undefined); +}); + +// --- step 7: schema snapshot tests --- +// Lock the envelope JSON shape so future changes don't silently alter schema. +test('schema snapshot: recognize envelope has required top-level fields', () => { + const env = buildEnvelope(sampleResult, { mediaType: 'image/png', identity: { path: 'a.png' } }); + assert.ok(env.schemaVersion, 'missing schemaVersion'); + assert.ok(env.source, 'missing source'); + assert.ok(env.source.kind, 'missing source.kind'); + assert.ok(env.source.mediaType, 'missing source.mediaType'); + assert.ok(env.source.identity, 'missing source.identity'); + assert.ok(env.source.appliedTransforms, 'missing source.appliedTransforms'); + assert.ok(env.pages, 'missing pages'); + assert.equal(env.pages.length, 1); + const page = env.pages[0]; + assert.ok(page.index !== undefined, 'missing page.index'); + assert.ok(page.width !== undefined, 'missing page.width'); + assert.ok(page.height !== undefined, 'missing page.height'); + assert.equal(page.coordinateSpace, 'pageSpace'); + assert.equal(page.structure, 'ocr-order'); + assert.ok(page.lines, 'missing page.lines'); + assert.ok(page.modelBundleId, 'missing page.modelBundleId'); + assert.ok(page.timingUs, 'missing page.timingUs'); +}); + +test('schema snapshot: line has id, text, confidence, box', () => { + const env = buildEnvelope(sampleResult, {}); + const line = env.pages[0].lines[0]; + assert.ok(line.id, 'missing line.id'); + assert.ok('text' in line, 'missing line.text'); + assert.ok('confidence' in line, 'missing line.confidence'); + assert.ok(line.box, 'missing line.box'); + assert.equal(line.box.length, 4, 'box must have 4 points'); +}); + +test('schema snapshot: detect envelope has detections with id, score, box', () => { + const env = buildDetectEnvelope(sampleDetection, {}); + assert.equal(env.pages[0].structure, 'detect'); + const det = env.pages[0].detections[0]; + assert.ok(det.id, 'missing detection.id'); + assert.ok('score' in det, 'missing detection.score'); + assert.ok(det.box, 'missing detection.box'); + assert.equal(det.box.length, 4, 'box must have 4 points'); +}); diff --git a/bindings/node/test/exif.test.cjs b/bindings/node/test/exif.test.cjs new file mode 100644 index 0000000..522a6c8 --- /dev/null +++ b/bindings/node/test/exif.test.cjs @@ -0,0 +1,115 @@ +'use strict'; + +// Tests for the JS EXIF orientation parser (exif.cjs). +// These test the parser logic without needing the native C++ build. +// The C++ exif.cpp mirrors this logic; parity is verified by adapter tests +// that decode real JPEGs with EXIF orientation. + +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { parseExifOrientation, applyOrientation } = require('../js/exif.cjs'); + +test('parseExifOrientation: non-JPEG returns 1', () => { + assert.equal(parseExifOrientation(Buffer.from([0x89, 0x50, 0x4e, 0x47])), 1); // PNG + assert.equal(parseExifOrientation(Buffer.alloc(0)), 1); + assert.equal(parseExifOrientation(null), 1); +}); + +test('parseExifOrientation: JPEG without EXIF returns 1', () => { + // Minimal JPEG: SOI + EOI + const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xd9]); + assert.equal(parseExifOrientation(jpeg), 1); +}); + +test('parseExifOrientation: parses orientation 6 from real EXIF segment', () => { + // Build a minimal JPEG with an APP1 EXIF segment containing orientation=6. + const jpeg = makeExifJpeg(6); + assert.equal(parseExifOrientation(jpeg), 6); +}); + +test('parseExifOrientation: parses all orientations 1-8', () => { + for (let i = 1; i <= 8; i++) { + const jpeg = makeExifJpeg(i); + assert.equal(parseExifOrientation(jpeg), i, `orientation ${i}`); + } +}); + +test('applyOrientation: orientation 1 is a no-op', () => { + const pixels = { data: new Uint8Array([1,2,3, 4,5,6]), width: 2, height: 1 }; + const out = applyOrientation(pixels, 1); + assert.equal(out, pixels); +}); + +test('applyOrientation: orientation 3 (180) reverses pixel order', () => { + // 2x1 image: [A B] -> 180 -> [B A] + const pixels = { data: new Uint8Array([1,2,3, 4,5,6]), width: 2, height: 1 }; + const out = applyOrientation(pixels, 3); + assert.equal(out.width, 2); + assert.equal(out.height, 1); + // first pixel of output should be original last pixel + assert.equal(out.data[0], 4); + assert.equal(out.data[1], 5); + assert.equal(out.data[2], 6); + assert.equal(out.data[3], 1); +}); + +test('applyOrientation: orientation 6 (90 CW) swaps width/height', () => { + // 2x1 image becomes 1x2 + const pixels = { data: new Uint8Array([1,2,3, 4,5,6]), width: 2, height: 1 }; + const out = applyOrientation(pixels, 6); + assert.equal(out.width, 1); + assert.equal(out.height, 2); + assert.equal(out.data.length, 6); +}); + +test('applyOrientation: orientation 8 (90 CCW) swaps width/height', () => { + const pixels = { data: new Uint8Array([1,2,3, 4,5,6]), width: 2, height: 1 }; + const out = applyOrientation(pixels, 8); + assert.equal(out.width, 1); + assert.equal(out.height, 2); + assert.equal(out.data.length, 6); +}); + +test('applyOrientation: round-trip orientation 6 then 8 restores original', () => { + const original = { data: new Uint8Array([1,2,3, 4,5,6, 7,8,9, 10,11,12]), width: 2, height: 2 }; + const rotated = applyOrientation(original, 6); + const restored = applyOrientation(rotated, 8); + assert.equal(restored.width, 2); + assert.equal(restored.height, 2); + assert.deepEqual(Array.from(restored.data), Array.from(original.data)); +}); + +// Helper: build a minimal JPEG buffer with an EXIF APP1 segment +// containing the given orientation value (little-endian TIFF). +function makeExifJpeg(orientation) { + // JPEG: SOI + APP1(EXIF) + EOI + // APP1 structure: FFE1 + length(2) + "Exif\0\0" + TIFF header + IFD0 + const exifHeader = Buffer.from('Exif\0\0', 'ascii'); + // TIFF header: II (little-endian) + 42 + offset to IFD0 (8) + const tiffHeader = Buffer.alloc(8); + tiffHeader.write('II', 0, 'ascii'); + tiffHeader.writeUInt16LE(0x002a, 2); + tiffHeader.writeUInt32LE(8, 4); // IFD0 offset from TIFF start + // IFD0: 1 entry, 12 bytes each + 4 bytes next-IFD-offset + const ifdCount = Buffer.alloc(2); + ifdCount.writeUInt16LE(1, 0); + // IFD entry: tag(2) + type(2) + count(4) + value(4) + const entry = Buffer.alloc(12); + entry.writeUInt16LE(0x0112, 0); // Orientation tag + entry.writeUInt16LE(3, 2); // SHORT type + entry.writeUInt32LE(1, 4); // count=1 + entry.writeUInt16LE(orientation, 8); + const nextIfd = Buffer.alloc(4); // 0 = no next IFD + + const app1Data = Buffer.concat([exifHeader, tiffHeader, ifdCount, entry, nextIfd]); + const app1Length = app1Data.length + 2; // +2 for the length field itself + const app1Header = Buffer.alloc(4); + app1Header[0] = 0xff; + app1Header[1] = 0xe1; + app1Header.writeUInt16BE(app1Length, 2); + + const soi = Buffer.from([0xff, 0xd8]); + const eoi = Buffer.from([0xff, 0xd9]); + return Buffer.concat([soi, app1Header, app1Data, eoi]); +} diff --git a/docs/cli-design.md b/docs/cli-design.md new file mode 100644 index 0000000..dc598a1 --- /dev/null +++ b/docs/cli-design.md @@ -0,0 +1,404 @@ +# light-ocr CLI 设计草案 + +Status: Draft(2026-07-21,落地前细化设计)
+Authority: N1 CLI、结果契约、ROI、detect-only 出口与 Agent Skill 的实现与契约规范
+Requirements: [requirements.md](requirements.md)
+Roadmap: [roadmap.md §5 N1](roadmap.md)
+Architecture: [architecture.md](architecture.md) + +本草案是 [roadmap §5](roadmap.md) 引用的「落地前细化设计」。它不替代 [decisions.md](decisions.md) 中的 D106 决策记录,而是 D106 的前置设计材料。所有标记 **D-N1-x** 的条目为待维护者确认的开放决策。 + +## 1. 范围与现状 + +N1 的目标是让普通用户和 Agent 无需编写 Node.js 集成代码,即可从本地图片获得稳定文本、置信度和坐标,并建立 PDF、Layout 和多模型都能复用的版本化结果契约。 + +### 1.1 现有可复用基础 + +对照当前源码(`0.3.1`),N1 的底层能力已经具备: + +| 能力 | 现状 | 来源 | +| --- | --- | --- | +| `createEngine` / `OcrEngine.recognize(RawImage)` / `recognizeEncoded(Uint8Array)` / `info` / `close` | 已发布 | [bindings/node/js/index.d.ts](../bindings/node/js/index.d.ts) | +| `OcrResult.lines[].box`(4 点 quad)、`imageWidth/Height`、`modelBundleId`、`timingUs`、`diagnostics` | 已发布 | 同上 | +| `EngineInfo.execution`(requested/actual provider chain、device、precision、capabilities、selectionTrace) | 已发布 | 同上 | +| `OcrError` / `CoreErrorCode` / `AdapterErrorCode` / `CreationTrace` | 已发布 | 同上 | +| JPEG/PNG 受限 decode(stb_image,带内存预算) | 已发布 | [encoded_image.cpp](../bindings/node/src/encoded_image.cpp) | +| 四平台 prebuild、离线 bundle 解析、provider Auto | 已发布 | [implementation-status.md](implementation-status.md) | + +### 1.2 本节点需新增的能力 + +| 能力 | 现状 | N1 交付 | +| --- | --- | --- | +| `light-ocr` CLI bin | `package.json` 无 `bin` 字段 | 新增 bin + 子命令(recognize/detect/info) | +| `schemaVersion` 与 `DocumentResult` envelope | `OcrResult` 是单图底层契约,无 envelope | CLI 层新增 envelope | +| EXIF orientation 修正 | 全仓 0 匹配,stb 不读 EXIF | 新增 EXIF 解析 + 像素变换 + `appliedTransforms` | +| `--region` ROI | `recognize` 无 region 参数 | 新增输入侧区域约束(recognize/detect 均可带) | +| `detect` 子命令 | Core `Engine` 公共接口仅 `recognize`/`info`/`close`,无 `detect` | Core 新增 `detect()` 公共方法(不新增算法)+ Node 绑定 + `detect` 子命令 | +| Agent Skill | `.agents/skills/` 不存在 | 新建 `.agents/skills/local-ocr/SKILL.md` | + +## 2. 分发形态与命令结构 + +monorepo 迁移按 [roadmap §3.4](roadmap.md) 规划在 N2 启动时执行,N1 仍运行在当前单包结构下。CLI 放在 `bindings/node/bin/light-ocr.cjs`,`package.json.bin` 指向它,装 `@arcships/light-ocr` 即得 `light-ocr` 命令——符合 [roadmap §3.1](roadmap.md)「`@arcships/light-ocr` 唯一拥有 `light-ocr` bin、开箱即用」。实现用 Node 内置模块(`fs`/`path`/`process`),零运行时依赖,符合禁网安装与 `--ignore-scripts` 要求;argv 解析手写最小 parser(D-N1-2)。 + +### 2.1 子命令结构 + +N1 面向 Agent,采用子命令结构而非扁平 flag:Agent 先从顶层动词锁定意图,再看该动词下的 flags,互斥约束收窄到子命令内。代价是 `recognize`/`detect`/`info` 三个动词的 help 与退出码需分别维护,但换来 Agent 可逐步理解的入口结构。 + +```text +light-ocr recognize [flags] # 默认 OCR:detection + recognition +light-ocr detect [flags] # 只检测:检测框,不识别 +light-ocr info [--model-info|--version] # 诊断,不读图 +light-ocr [recognize] [flags] # 隐式 recognize:不带子命令 = recognize +light-ocr --help [subcommand] # 分层 help +``` + +约束: + +- `light-ocr image.png` 隐式等价 `light-ocr recognize image.png`,保持首次使用直觉与向后兼容; +- 每个 flag 只挂在自己相关的子命令下(见 §2.2),互斥约束在子命令内收敛; +- `info` 子命令不接受 path/`--stdin`,不接受 `--format`;`--model-info` 与 `--version` 作为 `info` 的 flags; +- N3 文档入口另起 `light-ocr-document` bin([roadmap §3.1](roadmap.md)),与 N1 子命令并列,不并入 `light-ocr`。 + +> **与 [roadmap §5.2](roadmap.md) 的偏离说明**:roadmap 原文写「`--model-info` 和 `--version`」为顶导 flag,本设计改为 `info` 子命令以保持与 `recognize`/`detect` 的结构统一;语义等价。该偏离在 D106 决策中记录。 + +## 3. CLI 命令面 + +第一版保持命令面小而稳定([roadmap §5.2](roadmap.md)): + +```bash +# 默认 OCR(隐式 recognize) +light-ocr image.png --format json +light-ocr image.png --format text +light-ocr image.png --format jsonl + +# 显式 recognize(等价于隐式) +light-ocr recognize image.png --format json + +# 区域识别(ROI) +light-ocr recognize image.png --region 100,80,640,320 --format json + +# stdin +cat image.png | light-ocr recognize --stdin --type image/png --format json + +# detect 子命令:只检测,不识别(输出固定 JSON,不暴露 --format) +light-ocr detect image.png +light-ocr detect image.png --crop +light-ocr detect image.png --region 100,80,640,320 --crop + +# info 子命令:诊断与版本,不读图(--model-info 与 --version 互斥) +light-ocr info --model-info +light-ocr info --version + +# schema 版本(recognize/detect 均可) +light-ocr recognize image.png --schema-version 1 --format json + +# 执行后端(与 Node API execution 契约等价) +light-ocr recognize image.png --provider auto --format json +light-ocr detect image.png --provider webgpu +``` + +### 3.1 各子命令的 flags + +每个 flag 只挂在自己相关的子命令下。第一版只暴露 Agent/用户真实高频的入口层选项;provider 内部实现细节(session fallback、cpu partition、precision、detection strategy、max-side)不透传成 CLI flag,由 runtime 按 provider 默认处理。需要这些内部字段时用 Node API 或 `info --model-info`。 + +#### `recognize`(默认 OCR:detection + recognition) + +默认 help 顶部(高频): + +| Flag | 取值 | 语义 | +| --- | --- | --- | +| `` 位置参数 | 文件路径 | 必须与 `--stdin` 二选一 | +| `--stdin` | flag | 从 stdin 读取 bytes;需配合 `--type` | +| `--type` | image/png \| image/jpeg | stdin 的 mediaType | +| `--format` | json \| jsonl \| text | 默认 `json` | +| `--region` | `x,y,w,h` | pageSpace 轴对齐矩形,整数像素;详见 §7 | +| `--no-exif` | flag | 关闭默认 EXIF orientation 修正;详见 §6 | +| `--provider` | auto \| cpu \| apple \| webgpu | 映射 `execution.provider` | +| `--schema-version` | 1 | 请求精确输出 schema;不支持则稳定失败 | + +`--help` 第二层(高级): + +| Flag | 取值 | 语义 | +| --- | --- | --- | +| `--score-threshold` | number | `recognitionScoreThreshold`;改它直接影响精度/召回,默认值已校准 | +| `--no-color` | flag | 显式关闭 stderr 彩色;非 TTY 或 `NO_COLOR` 已默认关闭 | + +`--quiet`:stderr 只输出错误,不输出进度/警告。归入默认 help 顶部(行为开关,Agent 常用)。 + +**不暴露为 CLI flag 的字段**(内部固定默认或由 provider 决定): + +- `sessionFallback`:单值枚举(仅 `error`),0.3.0 定为迁移期遗留,CLI 无意义; +- `cpuPartition`:provider qualification 内部细节,按 provider 默认 `allow`; +- `precision`:`fp16` 仅 Apple、WebGPU 只接受 `auto/fp32`,选错直接失败,价值极低; +- `detectionStrategy`:`tiled`/`upstream-exact` 是开发/parity 验证策略,普通用户用 `bounded`,大图自动 tiled; +- `maxSide`:影响精度/速度 tradeoff,内部用校准默认; +- `includeDiagnostics`:`diagnostics` 字段重且面向开发者调试 provider placement,Agent/用户消费不了,需要时用 Node API 或 `info --model-info`。 + +#### `detect`(只检测:检测框,不识别) + +| Flag | 取值 | 语义 | +| --- | --- | --- | +| `` / `--stdin` / `--type` | 同 `recognize` | 输入一致 | +| `--region` | 同 `recognize` | 输入侧区域约束,与 detect 正交可组合 | +| `--no-exif` / `--provider` / `--schema-version` / `--quiet` | 同 `recognize` | 行为与后端一致 | +| `--crop` | flag | 每框附 PNG crop bytes;详见 §9 | +| `--no-color` | 同 `recognize` | 高级,help 第二层 | + +`detect` 子命令不暴露 `--format`:detect 输出永远是结构化 JSON(box quad + score + 可选 crop),`text` 格式无意义(无文字可输出)。从结构上消除 `--format text` + `detect` 的失败路径,不靠运行时报错。`--score-threshold` 不在 `detect` 下(detect 不做 recognition,无识别阈值概念;detection 置信度内部用校准默认)。 + +#### `info`(诊断,不读图) + +| Flag | 取值 | 语义 | +| --- | --- | --- | +| `--model-info` | flag | 输出 `EngineInfo` JSON(含 coreVersion、modelBundleId、execution、capabilities、limits) | +| `--version` | flag | 输出 npm/core/model 版本三元组 | + +`info` 子命令约束: + +- 不接受 ``、`--stdin`、`--type`、`--format` 及任何 OCR/执行 flags;传入报 `invalid_argument`; +- `--model-info` 与 `--version` **互斥**,不可同时传入:`--version` 的三元组是 `--model-info` 的子集(`coreVersion`、`modelBundleId` 已在 `EngineInfo` 内),合并会重复字段;需要完整信息用 `--model-info`,只需快速版本探测用 `--version`。 + +目录递归、glob、watch mode、交互式 UI、远程 URL 不进入第一版;批量调用先通过 shell + JSONL 组合完成([roadmap §5.2](roadmap.md))。 + +## 4. 分层 help + +子命令结构天然分层,Agent 可逐步理解: + +1. `light-ocr --help`:列出三个子命令 + 各自一句话职责 + 一个完整示例; + ``` + Commands: + recognize Recognize text in an image (default action) + detect Detect text regions only, no recognition + info [--model-info|--version] Show engine/version info without reading images + ``` +2. `light-ocr --help`:该子命令的全部 flags 分组(输入/输出/能力/执行/资源/行为); +3. `light-ocr --=help`(如 `light-ocr recognize --region=help`):该 flag 的精确语义、坐标系、失败行为与示例。 + +Agent 与脚本应使用 `info --model-info`、`info --version` 和固定 `--schema-version`,不解析 help 文本。 + +## 5. stdout/stderr 严格分离 + +这是 N1 的硬约束([roadmap §5.2](roadmap.md)),不可回退: + +- **stdout**:只承载机器结果(json / jsonl / text 机器输出、`--model-info` 的 JSON、`--version` 的版本串)。成功时 stdout 必须可被 `JSON.parse` 或确定管道消费; +- **stderr**:日志、warnings、diagnostics 的人类文本、进度、`--include-diagnostics` 的人类可读摘要、usage error 提示; +- 任何混合 stdout 的人类提示一律视为缺陷; +- `--quiet` 只压制 stderr 的非错误输出,不改 stdout 契约; +- `--no-color` 控制 stderr ANSI;CI 环境检测(`NO_COLOR`、非 TTY)默认关闭彩色,D-N1-8 确认检测策略。 + +文本格式(`--format text`)按行输出识别文本,stdout 仍只含文本行,不带坐标/置信度;需要坐标必须用 `--format json`。 + +## 6. 坐标与 EXIF + +### 6.1 坐标词汇表(N1 冻结,后续只扩展) + +沿用 [roadmap §3.3](roadmap.md): + +| 术语 | 定义 | +| --- | --- | +| `sourceSpace` | encoded source 方向修正前的固有坐标,仅用于记录 identity 与 transform | +| `pageSpace` | 所有 v1 `line.box`、Layout box、`--region` 的 canonical space;左上原点,x 向右 y 向下,单位为方向修正后 pixel | +| `appliedTransforms` | source→page 有序变换记录:EXIF orientation、crop、raster scale | + +### 6.2 EXIF orientation + +[roadmap §5.5](roadmap.md) 要求对 encoded JPEG 默认应用可验证的 EXIF orientation 修正,修正后图片定义为 `pageSpace`,结果记录完整 `appliedTransforms`。 + +实现约束: + +- stb_image 不解析 EXIF,需在 decode 后、送入 Core 前新增独立 EXIF 解析(JPEG APP1 segment)与像素变换。实现方式在 D-N1-5 决策(自带最小 EXIF 解析器 vs 换用支持 EXIF 的 decode 库); +- PNG 不含 EXIF orientation(可含 `eXIf` chunk,首版按无变换处理); +- `--no-exif` 关闭修正,`appliedTransforms` 仍记录「未应用」状态; +- raw-pixel API(`recognize(RawImage)`)继续由调用者负责方向,传入像素直接定义 `pageSpace`; +- 修正后的 `imageWidth/Height` 与 `line.box` 全部落在 `pageSpace`。 + +### 6.3 appliedTransforms 结构 + +```json +"appliedTransforms": { + "exifOrientation": 6, + "exifApplied": true, + "sourceWidth": 4000, + "sourceHeight": 3000, + "pageWidth": 3000, + "pageHeight": 4000 +} +``` + +## 7. ROI 语义 + +[roadmap §5.3](roadmap.md):ROI 是输入侧的区域约束,不是 Layout 替代品。 + +- 第一版只接受位于方向修正后完整有效页面 `pageSpace` 内的轴对齐矩形 `--region x,y,width,height`(整数像素); +- 在进入完整 OCR pipeline 前限制检测/识别范围; +- 返回的 quad 坐标重新映射到完整有效页面的 `pageSpace`(不是 ROI 局部坐标); +- 非法、空或越界区域返回 `invalid_argument`,**不隐式 clamp**;部分相交也返回 `invalid_argument`([roadmap §3.3](roadmap.md)); +- ROI 仍受像素、临时内存、candidate 和 timeout 上限约束; +- `--region` 与 `detect` 子命令可组合:限制检测范围 + 只输出检测框。 + +ROI 的实现位置在 D-N1-4 决策(adapter 层在 decode 后裁剪像素,还是 Core 在 detection 前接收 region)。倾向 adapter 层裁剪:保持 Core 边界不变,EXIF 修正后裁剪天然落在 `pageSpace`,与坐标契约一致。 + +## 8. detect 子命令 + +[roadmap §5.4](roadmap.md):detection 在 Core 中本就是独立 stage。此出口只把已有能力暴露为公共入口,不新增算法,不改 recognition 语义。 + +- `detect` 子命令仅运行 detector,输出检测框(与 OCR `line.box` 相同的 `pageSpace` quad 契约),不触发 recognition; +- `detect --crop` 可选返回每个区域的 PNG crop,与检测框 index 对齐,便于喂给下游模型、版面分析、计数或 redaction; +- 与 ROI 互补不重叠:ROI 是输入侧区域约束(`--region`,recognize/detect 均可带),detect 是输出侧能力裁剪;`detect image.png --region 100,80,640,320 --crop` = 只在该矩形内检测并附 crop; +- 不是 Layout 替代:只给原始检测框,不附加 region label、阅读顺序或语义分类。 + +实现要求:Core `Engine` 当前公共接口仅 `recognize`/`info`/`close`,**无 detection-only 公共方法**。需在 Core 新增虚函数 `Engine::detect(ImageView, DetectOptions) -> Result`,内部复用现有 detection stage,跳过 crop+recognition。这是 N1 里唯一的 Core 改动,必须在 D-N1-3 锁定方法签名、返回结构(box quad + score,可选 crop bytes)与 CoreErrorCode 映射后再实现。Node addon 增加 `detect` 绑定,JS `OcrEngine.detect()` 对称暴露。 + +## 9. 结果契约(schemaVersion=1) + +[roadmap §3.2](roadmap.md):`OcrResult` 继续作为单张已解码图片的底层语义契约,CLI 在其外部增加文档级 envelope。 + +### 9.1 DocumentResult envelope(v1) + +```json +{ + "schemaVersion": 1, + "source": { + "kind": "image", + "mediaType": "image/jpeg", + "identity": { "path": "image.png" }, + "appliedTransforms": { "exifOrientation": 6, "exifApplied": true, "..." : "..." } + }, + "pages": [ + { + "index": 0, + "width": 3000, + "height": 4000, + "coordinateSpace": "pageSpace", + "structure": "ocr-order", + "lines": [ + { + "id": "L0", + "text": "...", + "confidence": 0.987, + "box": [ { "x": 0, "y": 0 }, { "x": 100, "y": 0 }, { "x": 100, "y": 30 }, { "x": 0, "y": 30 } ] + } + ], + "modelBundleId": "ppocrv6-small-...", + "timingUs": { "..." : "..." } + } + ] +} +``` + +`detect` 子命令输出下 `pages[0].lines` 替换为 `detections[]`,`structure: "detect"`: + +```json +"detections": [ + { + "id": "D0", + "score": 0.92, + "box": [ { "x": 0, "y": 0 }, { "x": 100, "y": 0 }, { "x": 100, "y": 30 }, { "x": 0, "y": 30 } ], + "crop": "base64...(仅 --crop 时出现)" + } +] +``` + +`--crop` 的 PNG crop bytes 以 base64 编码出现在每个 detection 的 `crop` 字段,与检测框 `id`/index 对齐。crop 编码方式(base64 vs 二进制 sidecar)在 D-N1-3 锁定。 + +### 9.2 版本与稳定性 + +- `schemaVersion` 是整数主版本。兼容新增保持 v1;删除、重命名或语义变化必须增加版本; +- `--schema-version 1` 请求精确 schema,不支持返回 `invalid_argument`(不静默降级); +- 结果本身始终携带 `schemaVersion`; +- `OcrLine.id` 是 CLI envelope 新增的稳定 line ID(`L{index}`),用于未来 Layout region 关联与 Markdown 追溯;底层 `OcrResult.line` 不变。 + +### 9.3 JSONL 分页语义 + +[roadmap §3.3](roadmap.md): + +- 单张图片表现为 `pages[0]`;JSONL 对图片输出一条 page record(即 envelope 内的单个 page); +- 每条 page record 带 document identity、page index 和 `status`(`ok` / `error`); +- 中途取消或失败时,已完成记录保持有效,stderr 给出终态,进程返回非零 exit code; +- `--format text` 不使用 JSONL,按行输出纯文本。 + +## 10. 退出码表 + +稳定 exit code,区分输入、能力、资源、模型和内部错误([roadmap §5.2](roadmap.md))。映射现有 `OcrErrorCode`,最终码在 D-N1-7 锁定: + +| exit | 类别 | 触发 code | 含义 | +| --- | --- | --- | --- | +| 0 | 成功 | — | 正常输出 | +| 64 | usage | —(参数解析失败、缺文件、互斥 flag 冲突) | 命令行用法错误 | +| 65 | 输入 | `invalid_argument` | ROI 越界、不支持 flag 组合、`--schema-version` 不支持 | +| 66 | 输入 | `invalid_image` / `unsupported_pixel_format` | 图片不可解码或不支持格式 | +| 67 | 能力 | `unsupported_capability` | `--detect-only` 时 rec 不可用等 | +| 68 | 模型 | `invalid_model_bundle` / `unsupported_model` / `model_integrity_failed` | bundle 损坏或 hash 不符 | +| 69 | 资源 | `resource_limit_exceeded` | 超像素/内存/timeout 上限 | +| 70 | 环境/包 | `runtime_initialization_failed` / `package_load_failed` / `unsupported_platform` / `bundle_io_failed` / `adapter_unavailable` / `package_corrupt` | runtime/包加载失败 | +| 71 | 运行 | `inference_failed` / `postprocess_failed` | 推理或后处理失败 | +| 72 | 内部 | `internal_error` / `environment_closing` / `queue_full` | 内部错误或引擎已关闭 | + +D112 Auto 创建期可跳过原因(`adapter_unavailable` 等)通过 `creationTrace` 在 stderr 报告,最终仍按对应 exit code 退出。exit code 表一旦发布即为 stable surface,后续只允许新增码,不重排现有码([roadmap §2.6](roadmap.md))。 + +## 11. Agent Skill + +[roadmap §5.6](roadmap.md):仓库内 `.agents/skills/local-ocr/SKILL.md`,是 CLI 的薄工作流层,不实现识别/坐标/schema 逻辑。内容至少覆盖: + +- 何时使用 OCR,而不是让多模态模型猜测小字; +- 如何选择全文、ROI、text、JSON 与 diagnostics; +- 如何处理低置信度、空结果、超限和 unsupported capability; +- 如何只读取必要页面或区域,避免无界批处理; +- 如何引用文字及坐标,避免把推断写成 OCR 原文; +- 可执行 CLI 示例和小型验证脚本。 + +验证稳定后再打包为可安装 Plugin;本地文件 OCR 暂不需要 MCP server([roadmap §13](roadmap.md))。 + +## 12. Agent 友好性 checklist + +- stdout 始终可被 `JSON.parse`(`--format json`)或逐行消费(`--format jsonl`/`text`); +- 退出码与错误类别一一对应,Agent 可据此决定重试/放弃/换输入; +- `info --model-info` 与 `info --version` 不读图、不触发 model load 之外的计算,可安全探测; +- `--schema-version` 固定输出,不随默认漂移; +- 任何 failure 路径 stderr 给出 `OcrError.code` + 人类消息 + 可选 detail,stdout 为空; +- ROI 越界、空图、unsupported capability 均稳定失败,不产出伪结果。 + +## 13. 实施顺序 + +建议按依赖与风险递增分步交付,每步可独立验证: + +1. **CLI bin 骨架 + 子命令 dispatch + 基本识别**:`recognize`(含隐式默认)、文件/stdin → `--format json|text`、`info --model-info`/`info --version`、stdout/stderr 分离、exit code 表(不含 ROI/detect/EXIF)。复用现有 `recognizeEncoded`; ✅ 完成 +2. **schemaVersion=1 envelope**:`DocumentResult`/page/`line.id`、JSONL 单页 record、`--schema-version`; ✅ 完成 +3. **EXIF orientation 修正 + appliedTransforms**(D-N1-5 实现方式); ✅ 完成(JS 解析器 + C++ 像素变换 + `applyExif` option) +4. **ROI `--region`**(D-N1-4 实现位置); ✅ 完成(adapter C++ 层裁剪 + box offset 重映射) +5. **`detect` 子命令**:Core `Engine::detect()` 公共方法 + Node 绑定 + `detect [--crop]`(D-N1-3 签名); ⬜ 进行中 +6. **Agent Skill** `.agents/skills/local-ocr/SKILL.md`; ✅ 完成 +7. **验收**:Tier 1 CLI smoke、schema snapshot、20 个 Agent task eval。 ⬜ 部分(52 单元测试,端到端待 native build) + +第 5 步是唯一的 Core C++ 改动,风险最高,单独评审。 + +## 14. 验收与退出条件 + +沿用 [roadmap §5.7](roadmap.md): + +- Tier 1 平台 Node.js 22/24 均通过 `npm install` 后 CLI smoke; +- CJS、ESM、Node API 和 CLI 对同一输入返回语义一致的结果; +- JSON/JSONL 使用 committed schema 和 snapshot 测试; +- stdin、文件路径、ROI、EXIF、退出码和 stderr/stdout 分离有测试; +- 禁网、sterile cwd、`--ignore-scripts` 安装继续通过; +- 至少 20 个 Agent task eval 覆盖全文、指定区域、低置信度和错误恢复; +- Agent eval 至少 18/20 通过,且任何失败不能把推断内容伪装成 OCR 原文; +- 一个不熟悉内部架构的读者能只凭 README/SKILL 完成首次 OCR。 + +## 15. 本节点不做 + +- 目录递归、glob、watch mode、交互式 UI([roadmap §5.2](roadmap.md)); +- 任意多边形 ROI、多个 ROI 合批、仅对已知 line crop 执行 recognition([roadmap §5.3](roadmap.md)); +- Layout region label、阅读顺序、语义分类(`detect` 子命令不做,属 N4); +- MCP server([roadmap §13](roadmap.md)); +- 字符级或词级坐标(recognition contract 以文字行为单位)。 + +## 16. 待决策项(D-N1) + +以下在进入实现前需维护者确认,确认后提炼为 [decisions.md](decisions.md) 的 D106。已确认项不再列出(CLI 分发形态 = `bindings/node/bin/` + facade bin;`--region` = 整数像素;`--no-color`/`--quiet` 默认行为 = 非 TTY 或 `NO_COLOR` 自动关闭彩色)。 + +- **D-N1-2** argv parser:手写最小 zero-dependency parser,还是引入轻量零依赖库。 +- **D-N1-3** Core `Engine::detect()` 公共方法签名与 `DetectionResult` 结构(box quad + score),以及 `--crop` 的 PNG crop 编码方式(base64 内联 vs 二进制 sidecar)与 `CoreErrorCode` 映射。 +- **D-N1-4** ROI 实现位置:建议 adapter 层(EXIF 修正后、送 Core 前裁剪像素),保持 Core 边界不变。 +- **D-N1-5** EXIF 解析实现方式:自带最小 JPEG APP1 EXIF 解析器(零依赖,与 stb 风格一致),还是换用支持 EXIF orientation 的 decode 路径。 +- **D-N1-7** exit code 表最终映射(§10),含 D112 Auto 创建期可跳过原因的 stderr 报告格式。 diff --git a/docs/decisions.md b/docs/decisions.md index 3e0ba8d..5cb1350 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -211,6 +211,46 @@ Reason: The final Linux and Windows FP32 reports each passed 164/164 Gates and a Consequence: Qualification, report review, examples, release notes, and performance displays use only `cpu`, WebGPU FP32 `allow`, `strict`, and Auto. The required `Concat`, `Gather`, and `Slice` CPU partition remains explicit; `cpuPartition=forbid` continues to fail closed. No WebGPU FP16 speedup is advertised. +### D106 — CLI surface, stdout/stderr, exit codes and JSON schema + +Status: Accepted +Authority: N1 CLI, result contract, ROI and detect-only entry ([roadmap §5](roadmap.md)); detailed design in [cli-design.md](cli-design.md) +Decision: The `light-ocr` bin ships in `bindings/node/bin/light-ocr.cjs` and is exposed by the `@arcships/light-ocr` facade via a `bin` field, so installing the facade yields the `light-ocr` command with no second install entry. The CLI uses a three-subcommand structure rather than a flat flag surface, because N1's primary audience is Agents that resolve intent from a top-level verb before reading that verb's flags: + +```text +light-ocr recognize [flags] # default OCR: detection + recognition +light-ocr detect [flags] # detect only: boxes, no recognition +light-ocr info --model-info | --version # diagnostics, no image read +light-ocr image.png ... # implicit recognize +``` + +`light-ocr image.png` without a subcommand is equivalent to `light-ocr recognize image.png`, preserving first-use intuition. `--model-info` and `--version` are flags of the `info` subcommand and are mutually exclusive (the version triple is a subset of `EngineInfo`); this departs from [roadmap §5.2](roadmap.md) which lists them as top-level flags, in order to keep the verb structure uniform across `recognize`/`detect`/`info`. The semantics are equivalent. + +First-version flags are restricted to what Agent and ordinary users actually need. Provider-internal fields are **not** exposed as CLI flags and are fixed by runtime defaults: `sessionFallback` (single-value enum, migration legacy), `cpuPartition` (provider-qualification detail), `precision` (fp16 is Apple-only and fails elsewhere), `detectionStrategy` (dev/parity strategy), `maxSide` (calibrated accuracy/speed tradeoff), and `includeDiagnostics` (developer-facing provider placement debug). `--score-threshold` and `--no-color` are retained as advanced flags relegated to the second help tier. `detect` does not expose `--format` at all: its output is always structured JSON (box quad + score, no text semantics), which structurally eliminates the `--format text` + `detect` failure path instead of reporting it at runtime. + +stdout carries only machine results (`json`/`jsonl`/`text`, `info` JSON, version triple); stderr carries logs, warnings, progress and usage hints. `--quiet` suppresses non-error stderr without changing the stdout contract. `--no-color` controls stderr ANSI; non-TTY or `NO_COLOR` auto-disables color. Exit codes are a stable surface mapped to the existing `OcrErrorCode` taxonomy, additive-only after release: + +| exit | category | trigger | +| --- | --- | --- | +| 0 | success | normal output | +| 64 | usage | argument parse failure, missing file, mutex flag conflict | +| 65 | invalid_argument | ROI out of bounds, unsupported flag combo, unsupported `--schema-version` | +| 66 | invalid_image | image not decodable or unsupported format | +| 67 | unsupported_capability | capability not available in this build | +| 68 | model | invalid/unsupported/corrupt model bundle | +| 69 | resource_limit_exceeded | over pixel/memory/timeout limits | +| 70 | env/package | runtime init / package load / platform / adapter failure | +| 71 | inference_failed / postprocess_failed | inference or postprocess failure | +| 72 | internal_error | internal error or engine closed | + +The CLI wraps results in a versioned `DocumentResult` envelope (`schemaVersion`, `source`, `pages[]`) over the existing single-image `OcrResult`. `schemaVersion` is an integer major version; additive fields keep v1, semantic changes require a new version. `--schema-version 1` requests an exact schema and fails `invalid_argument` if unsupported (no silent downgrade). `OcrLine.id` (`L{index}`) is a CLI-envelope stable ID for future Layout region association. `detect` output replaces `pages[0].lines` with `detections[]` and sets `structure: "detect"`; `--crop` embeds per-box PNG crops as base64 in each detection (single-stream stdout contract forbids binary sidecars). + +ROI is implemented in the adapter layer (after EXIF correction, before Core), keeping the Core boundary unchanged; out-of-bounds or partially-intersecting regions return `invalid_argument` without clamping. Flag validation (format, provider, schema-version, region) is performed before input reading so parameter errors surface before filesystem errors — an invalid `--region` with a nonexistent file returns exit 65, not 64. EXIF orientation uses a self-contained minimal JPEG APP1 parser (zero-dependency, stb-style); PNG `eXIf` is treated as no transform in v1. `--no-exif` disables correction while still recording the "not applied" state in `appliedTransforms`. + +Reason: Roadmap §5.2 requires a small, stable command surface, but the draft had transparently mapped every `ExecutionOptions`/`DetectionOptions` field to a CLI flag, turning the CLI into a configuration panel. Restricting to Agent/user-facing flags keeps the entry layer honest, while the subcommand structure lets Agents resolve intent from the verb before loading that verb's flag set. The six removed flags either have single values, are provider-internal, or are developer-debug; none serve an Agent or ordinary user. + +Consequence: This decision fixes the stable CLI surface for N1. Adding flags requires a new decision; exit codes are additive-only. The `info` subcommand departure from roadmap §5.2's top-level flag wording is recorded here. Core `Engine::detect()` signature and `DetectionResult` structure are implementation details resolved during implementation per [cli-design.md §8](cli-design.md), not a separate product decision. + ## 3. Deferred decisions ### D102 — Public native SDK and ABI policy diff --git a/include/light_ocr/core.hpp b/include/light_ocr/core.hpp index 9c0bd12..001b66e 100644 --- a/include/light_ocr/core.hpp +++ b/include/light_ocr/core.hpp @@ -60,6 +60,8 @@ class Engine { virtual Result recognize( const ImageView& image, const RecognizeOptions& options = {}) noexcept = 0; + virtual Result detect( + const ImageView& image, const RecognizeOptions& options = {}) noexcept = 0; virtual const EngineInfo& info() const noexcept = 0; virtual void close() noexcept = 0; diff --git a/include/light_ocr/types.hpp b/include/light_ocr/types.hpp index 0b118af..89c8671 100644 --- a/include/light_ocr/types.hpp +++ b/include/light_ocr/types.hpp @@ -118,6 +118,19 @@ struct OcrResult { std::optional diagnostics; }; +struct DetectionBox { + Quad box; + float score = 0.0f; +}; + +struct DetectionResult { + std::vector boxes; + std::uint32_t image_width = 0; + std::uint32_t image_height = 0; + std::string model_bundle_id; + Timing timing; +}; + struct ResourceLimits { std::uint32_t max_width = 10'000; std::uint32_t max_height = 10'000; @@ -155,12 +168,24 @@ struct EngineOptions { ExecutionOptions execution; }; +struct Rect { + std::uint32_t x = 0; + std::uint32_t y = 0; + std::uint32_t width = 0; + std::uint32_t height = 0; +}; + struct RecognizeOptions { std::optional recognition_score_threshold; std::optional recognition_batch_size; bool include_diagnostics = false; bool use_textline_orientation = false; std::optional detection_max_side; + bool apply_exif = true; + // ROI in pageSpace (post-EXIF, pre-Core). When set, the decoded image is + // cropped to this rectangle before recognition; returned box coordinates + // are offset back to full pageSpace by the adapter. + std::optional region; }; enum class ConcurrencyMode { serialized_reject_when_busy }; diff --git a/src/core/engine.cpp b/src/core/engine.cpp index c6eecd6..bab4d78 100644 --- a/src/core/engine.cpp +++ b/src/core/engine.cpp @@ -653,6 +653,170 @@ class EngineImpl final : public Engine { } } + Result detect(const ImageView& image, + const RecognizeOptions& options) noexcept override { + try { + { + std::lock_guard lock(state_mutex_); + if (closing_) return failure(ErrorCode::invalid_engine, "Engine is closed"); + if (active_) { + return failure(ErrorCode::resource_limit_exceeded, + "Engine already has an active call"); + } + active_ = true; + } + struct AdmissionGuard { + EngineImpl* engine; + ~AdmissionGuard() noexcept { + try { + std::lock_guard lock(engine->state_mutex_); + engine->active_ = false; + engine->state_changed_.notify_all(); + } catch (...) { + } + } + } guard{this}; + + const auto total_begin = Clock::now(); + const auto detection_max_side = options.detection_max_side.value_or( + info_.detection_max_side); + if (detection_max_side == 0 || + detection_max_side > info_.detection_max_side || + (info_.detection_strategy != DetectionStrategy::bounded && + info_.detection_strategy != DetectionStrategy::tiled && + info_.detection_strategy != DetectionStrategy::upstream_exact) || + (info_.detection_strategy == DetectionStrategy::bounded && + detection_max_side % bundle_->detection.dimension_multiple != 0) || + (info_.detection_strategy == DetectionStrategy::upstream_exact && + options.detection_max_side.has_value()) || + (info_.detection_strategy == DetectionStrategy::tiled && + options.detection_max_side.has_value())) { + return failure(ErrorCode::invalid_argument, + "Request options are outside effective limits"); + } + + Timing timing; + auto stage_begin = Clock::now(); + auto validated_result = internal::validate_and_convert_image(image, info_.limits); + auto stage_end = Clock::now(); + timing.input_validation_us = elapsed_us(stage_begin, stage_end); + if (!validated_result) return Result::failure(validated_result.error()); + auto validated = std::move(validated_result).value(); + std::uint64_t image_bytes = 0; + if (!internal::checked_mul(validated.bgr.total(), + validated.bgr.elemSize(), &image_bytes) || + image_bytes > info_.limits.max_temporary_bytes) { + return failure(ErrorCode::resource_limit_exceeded, + "Converted image exceeds the request memory budget"); + } + // DETECT_PLACEHOLDER + internal::DetectionBoxes detected; + std::uint32_t detection_input_width = 0; + std::uint32_t detection_input_height = 0; + auto run_detection_pass = [&](const cv::Mat& pass_image, + std::uint32_t original_width, + std::uint32_t original_height, + DetectionStrategy preprocess_strategy, + std::uint32_t pass_max_side, + const ResourceLimits& pass_limits, + bool reject_candidate_overflow, + DetectionPassShape* pass_shape) + -> Result { + stage_begin = Clock::now(); + auto detection_input_result = internal::make_detection_input( + pass_image, bundle_->detection, preprocess_strategy, pass_max_side, pass_limits); + stage_end = Clock::now(); + timing.detection_preprocess_us += elapsed_us(stage_begin, stage_end); + if (!detection_input_result) return Result::failure(detection_input_result.error()); + auto detection_input = std::move(detection_input_result).value(); + pass_shape->tensor_height = static_cast(detection_input.shape[2]); + pass_shape->tensor_width = static_cast(detection_input.shape[3]); + stage_begin = Clock::now(); + auto detection_output_result = detection_->run(detection_input.values, detection_input.shape); + stage_end = Clock::now(); + timing.detection_inference_us += elapsed_us(stage_begin, stage_end); + if (!detection_output_result) return Result::failure(detection_output_result.error()); + auto detection_output = std::move(detection_output_result).value(); + stage_begin = Clock::now(); + auto detected_result = internal::db_postprocess( + detection_output.data(), detection_output.size(), detection_output.shape(), + original_width, original_height, bundle_->detection, pass_limits, false, reject_candidate_overflow); + stage_end = Clock::now(); + timing.detection_postprocess_us += elapsed_us(stage_begin, stage_end); + if (!detected_result) return Result::failure(detected_result.error()); + auto pass_detected = std::move(detected_result).value(); + pass_shape->contour_candidates = pass_detected.total_contours; + pass_shape->raw_candidates = static_cast(pass_detected.boxes.size()); + return Result::success(std::move(pass_detected)); + }; + auto detection_limits = info_.limits; + detection_limits.max_temporary_bytes -= image_bytes; + if (info_.detection_strategy == DetectionStrategy::tiled) { + if (!bundle_->tiled_detection) return failure(ErrorCode::unsupported_capability, "Tiled detection is unavailable in this bundle"); + auto tile_plan_result = internal::plan_detection_tiles(image.width, image.height, *bundle_->tiled_detection, info_.limits.max_detection_tiles); + if (!tile_plan_result) return Result::failure(tile_plan_result.error()); + auto tile_plan = std::move(tile_plan_result).value(); + std::vector raw_candidates; + raw_candidates.reserve(std::min(info_.limits.max_detection_candidates, 256)); + std::uint32_t total_contours = 0; + for (const auto& tile : tile_plan) { + auto pass_limits = detection_limits; + pass_limits.max_detection_candidates = info_.limits.max_detection_candidates - total_contours; + DetectionPassShape pass_shape; + const cv::Mat tile_view = validated.bgr(cv::Rect(static_cast(tile.x), static_cast(tile.y), static_cast(tile.width), static_cast(tile.height))); + auto pass_result = run_detection_pass(tile_view, tile.width, tile.height, DetectionStrategy::bounded, bundle_->tiled_detection->tile_side, pass_limits, true, &pass_shape); + if (!pass_result) return Result::failure(pass_result.error()); + auto pass_detected = std::move(pass_result).value(); + std::uint32_t updated_contours = 0; + if (!internal::checked_add(total_contours, pass_detected.total_contours, &updated_contours) || updated_contours > info_.limits.max_detection_candidates || pass_detected.boxes.size() != pass_detected.scores.size()) { + return failure(ErrorCode::resource_limit_exceeded, "Tiled detection candidates exceed the effective limit"); + } + total_contours = updated_contours; + for (std::size_t index = 0; index < pass_detected.boxes.size(); ++index) { + auto candidate_result = internal::make_tiled_candidate(pass_detected.boxes[index], pass_detected.scores[index], tile, image.width, image.height, static_cast(index), bundle_->tiled_detection->artificial_boundary_margin); + if (!candidate_result) return Result::failure(candidate_result.error()); + raw_candidates.push_back(std::move(candidate_result).value()); + } + } + stage_begin = Clock::now(); + auto merge_result = internal::merge_tiled_candidates(std::move(raw_candidates), *bundle_->tiled_detection); + stage_end = Clock::now(); + timing.detection_merge_us = elapsed_us(stage_begin, stage_end); + if (!merge_result) return Result::failure(merge_result.error()); + auto merged = std::move(merge_result).value(); + detected.contour_candidates = total_contours; + detected.total_contours = total_contours; + detected.boxes.reserve(merged.representatives.size()); + detected.scores.reserve(merged.representatives.size()); + for (auto& representative : merged.representatives) { + detected.boxes.push_back(std::move(representative.global_quad)); + detected.scores.push_back(static_cast(representative.db_score)); + } + } else { + DetectionPassShape pass_shape; + auto detected_result = run_detection_pass(validated.bgr, image.width, image.height, info_.detection_strategy, detection_max_side, detection_limits, false, &pass_shape); + if (!detected_result) return Result::failure(detected_result.error()); + detected = std::move(detected_result).value(); + } + DetectionResult result; + result.boxes.reserve(detected.boxes.size()); + for (std::size_t i = 0; i < detected.boxes.size(); ++i) { + result.boxes.push_back(DetectionBox{detected.boxes[i], detected.scores[i]}); + } + result.image_width = image.width; + result.image_height = image.height; + result.model_bundle_id = bundle_->id; + timing.total_us = elapsed_us(total_begin, Clock::now()); + result.timing = timing; + return Result::success(std::move(result)); + } catch (const std::exception& exception) { + return failure(ErrorCode::internal_error, "Unexpected detection failure", + exception.what()); + } catch (...) { + return failure(ErrorCode::internal_error, "Unknown detection failure"); + } + } + const EngineInfo& info() const noexcept override { return info_; } void close() noexcept override { From 0d54a70a194b6195bf1c3e6ea660950b8577cec2 Mon Sep 17 00:00:00 2001 From: chenghan Date: Wed, 22 Jul 2026 08:40:16 +0800 Subject: [PATCH 2/5] =?UTF-8?q?fix(core):=20=E5=88=A0=E5=86=97=E4=BD=99?= =?UTF-8?q?=E5=8F=98=E9=87=8F=EF=BC=8C=E7=BC=96=E8=AF=91=E6=97=A0=E7=A2=8D?= =?UTF-8?q?=20=C2=B7=20remove=20unused=20detection=5Finput=20variables=20i?= =?UTF-8?q?n=20detect()?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI failed on all platforms with -Werror=unused-variable: detection_input_width and detection_input_height were copied from recognize() but never read in detect() (no diagnostics collection). --- src/core/engine.cpp | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/core/engine.cpp b/src/core/engine.cpp index bab4d78..723169d 100644 --- a/src/core/engine.cpp +++ b/src/core/engine.cpp @@ -711,8 +711,6 @@ class EngineImpl final : public Engine { } // DETECT_PLACEHOLDER internal::DetectionBoxes detected; - std::uint32_t detection_input_width = 0; - std::uint32_t detection_input_height = 0; auto run_detection_pass = [&](const cv::Mat& pass_image, std::uint32_t original_width, std::uint32_t original_height, From a162c8e14a2419b28e26ddac501531743957b75e Mon Sep 17 00:00:00 2001 From: chenghan Date: Wed, 22 Jul 2026 09:05:36 +0800 Subject: [PATCH 3/5] =?UTF-8?q?fix(n1):=20=E4=BF=AE=E8=A1=A5=E7=BC=96?= =?UTF-8?q?=E8=AF=91=EF=BC=8C=E9=93=BE=E8=B7=AF=E9=80=9A=E8=BE=BE=20=C2=B7?= =?UTF-8?q?=20fix=20contract/safety=20CI=20failures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - addon.cpp: move detect_mode from function param to Request struct (worker lambda can't capture native_recognize_impl params) - addon.cpp: fix recognize ROI offset to use line.box.points (Quad has no begin/end, it has .points array) - CMakeLists.txt: add exif.cpp to fuzz_encoded_image sources (linker error: undefined reference to exif::apply_orientation) - exif.cpp: fix size_t to uint32_t narrowing warning (C4267) --- CMakeLists.txt | 3 ++- bindings/node/src/addon.cpp | 13 +++++-------- bindings/node/src/exif.cpp | 2 +- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 7a93216..2fd5a70 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -446,7 +446,8 @@ if(LIGHT_OCR_BUILD_FUZZERS) light_ocr_add_fuzzer(light_ocr_fuzz_image tests/fuzz/image_fuzz.cpp) light_ocr_add_fuzzer(light_ocr_fuzz_encoded_image tests/fuzz/encoded_image_fuzz.cpp - bindings/node/src/encoded_image.cpp) + bindings/node/src/encoded_image.cpp + bindings/node/src/exif.cpp) target_link_libraries(light_ocr_fuzz_encoded_image PRIVATE light_ocr::stb) target_include_directories(light_ocr_fuzz_encoded_image PRIVATE bindings/node/src) light_ocr_add_fuzzer(light_ocr_fuzz_bundle tests/fuzz/bundle_fuzz.cpp) diff --git a/bindings/node/src/addon.cpp b/bindings/node/src/addon.cpp index e40309c..57e72f3 100644 --- a/bindings/node/src/addon.cpp +++ b/bindings/node/src/addon.cpp @@ -1008,6 +1008,7 @@ struct Request { bool discard_result = false; bool operation_live = false; std::uint64_t decode_us = 0; + bool detect_mode = false; }; enum class CompletionKind { create, recognize, maintenance, close, reap }; @@ -1265,7 +1266,7 @@ void EngineState::run() { const ImageView view{decoded.bytes.data(), decoded.bytes.size(), decoded.width, decoded.height, decoded.stride, decoded.pixel_format}; - if (detect_mode) { + if (request->detect_mode) { auto detect_result = core->detect(view, request->options); if (detect_result && (roi_offset.width > 0 || roi_offset.height > 0)) { for (auto& box : detect_result.value().boxes) { @@ -1279,24 +1280,19 @@ void EngineState::run() { } auto recognize_result = core->recognize(view, request->options); if (recognize_result && (roi_offset.width > 0 || roi_offset.height > 0)) { - // Offset box coordinates back to full pageSpace for (auto& line : recognize_result.value().lines) { - for (auto& point : line.box) { + for (auto& point : line.box.points) { point.x += static_cast(roi_offset.x); point.y += static_cast(roi_offset.y); } } - // Restore full page dimensions in the result - // (the actual full-page dimensions need the pre-crop values; - // since we moved `decoded`, use the fact that result dimensions - // reflect the cropped image) } return recognize_result; } const ImageView view{request->image.bytes.data(), request->image.bytes.size(), request->image.width, request->image.height, request->image.stride, request->image.pixel_format}; - if (detect_mode) { + if (request->detect_mode) { return detect_result_to_ocr_result(core->detect(view, request->options)); } return core->recognize(view, request->options); @@ -1904,6 +1900,7 @@ napi_value native_recognize_impl(napi_env env, napi_callback_info callback_info, } request->image = std::move(snapshot); request->options = options; + request->detect_mode = detect_mode; std::int64_t adjusted = 0; check(env, napi_adjust_external_memory(env, static_cast(snapshot_size), &adjusted), diff --git a/bindings/node/src/exif.cpp b/bindings/node/src/exif.cpp index dc472de..a39debc 100644 --- a/bindings/node/src/exif.cpp +++ b/bindings/node/src/exif.cpp @@ -79,7 +79,7 @@ std::uint16_t parse_orientation(const std::vector& encoded) noexce if (magic != 0x002a) return 1; // IFD0 offset from TIFF start - std::uint32_t ifd_offset = tiff_start + read_u32(&encoded[tiff_start + 4], le); + std::uint32_t ifd_offset = static_cast(tiff_start + read_u32(&encoded[tiff_start + 4], le)); if (ifd_offset + 2 > offset + length) return 1; std::uint16_t entry_count = read_u16(&encoded[ifd_offset], le); From 52f6bc4fe7bc3482e37e044aa56fd44201cb98a8 Mon Sep 17 00:00:00 2001 From: chenghan Date: Wed, 22 Jul 2026 09:17:59 +0800 Subject: [PATCH 4/5] =?UTF-8?q?fix(n1):=20=E5=8E=BB=E5=B8=B8=E9=87=8F?= =?UTF-8?q?=E4=B9=8B=E9=99=90=EF=BC=8C=E5=8F=98=E9=87=8F=E5=8F=AF=E5=8A=A8?= =?UTF-8?q?=20=C2=B7=20fix=20const-correctness=20in=20ROI=20offset?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Result::value() returns const T& for lvalues, so modifying boxes/lines through it failed with 'assignment of member in read-only object'. Fix: move the result out to a local variable before modifying, then wrap in Result::success before returning. --- bindings/node/src/addon.cpp | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/bindings/node/src/addon.cpp b/bindings/node/src/addon.cpp index 57e72f3..ed93f89 100644 --- a/bindings/node/src/addon.cpp +++ b/bindings/node/src/addon.cpp @@ -1268,26 +1268,30 @@ void EngineState::run() { decoded.height, decoded.stride, decoded.pixel_format}; if (request->detect_mode) { auto detect_result = core->detect(view, request->options); - if (detect_result && (roi_offset.width > 0 || roi_offset.height > 0)) { - for (auto& box : detect_result.value().boxes) { + if (!detect_result) return detect_result_to_ocr_result(std::move(detect_result)); + auto dr = std::move(detect_result).value(); + if (roi_offset.width > 0 || roi_offset.height > 0) { + for (auto& box : dr.boxes) { for (auto& pt : box.box.points) { pt.x += static_cast(roi_offset.x); pt.y += static_cast(roi_offset.y); } } } - return detect_result_to_ocr_result(std::move(detect_result)); + return detect_result_to_ocr_result(Result::success(std::move(dr))); } auto recognize_result = core->recognize(view, request->options); - if (recognize_result && (roi_offset.width > 0 || roi_offset.height > 0)) { - for (auto& line : recognize_result.value().lines) { + if (!recognize_result) return recognize_result; + auto rr = std::move(recognize_result).value(); + if (roi_offset.width > 0 || roi_offset.height > 0) { + for (auto& line : rr.lines) { for (auto& point : line.box.points) { point.x += static_cast(roi_offset.x); point.y += static_cast(roi_offset.y); } } } - return recognize_result; + return Result::success(std::move(rr)); } const ImageView view{request->image.bytes.data(), request->image.bytes.size(), request->image.width, request->image.height, From 6f25b9700ae97f6eb7973f6a50e74450e4d61306 Mon Sep 17 00:00:00 2001 From: chenghan Date: Wed, 22 Jul 2026 10:00:30 +0800 Subject: [PATCH 5/5] =?UTF-8?q?fix(n1):=20=E5=89=8D=E7=BD=AE=E5=AE=9A?= =?UTF-8?q?=E4=B9=89=EF=BC=8C=E5=85=88=E5=90=8E=E6=9C=89=E5=BA=8F=20=C2=B7?= =?UTF-8?q?=20move=20detect=5Fresult=5Fto=5Focr=5Fresult=20before=20worker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Function was defined after EngineState::run() but called from the worker lambda inside it. Non-core builds compile addon.cpp as one translation unit and require declaration before use. --- bindings/node/src/addon.cpp | 46 ++++++++++++++++++------------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/bindings/node/src/addon.cpp b/bindings/node/src/addon.cpp index ed93f89..4748c5b 100644 --- a/bindings/node/src/addon.cpp +++ b/bindings/node/src/addon.cpp @@ -1128,6 +1128,29 @@ void EngineState::start() { } } +// Convert DetectionResult to OcrResult format for the completion pipeline. +// Each detection box becomes an OcrLine with empty text and detection score +// as confidence. This reuses the existing OcrResult serialization path; +// the CLI layer interprets these as detections (structure: "detect"). +Result detect_result_to_ocr_result(Result detect_result) { + if (!detect_result) return Result::failure(detect_result.error()); + auto dr = std::move(detect_result).value(); + OcrResult result; + result.image_width = dr.image_width; + result.image_height = dr.image_height; + result.model_bundle_id = std::move(dr.model_bundle_id); + result.timing = dr.timing; + result.lines.reserve(dr.boxes.size()); + for (auto& box : dr.boxes) { + OcrLine line; + line.text = ""; + line.confidence = box.score; + line.box = std::move(box.box); + result.lines.push_back(std::move(line)); + } + return Result::success(std::move(result)); +} + void EngineState::run() { try { auto loaded = load_bundle_directory_secure(create_options.bundle_path); @@ -1797,29 +1820,6 @@ std::shared_ptr unwrap_engine(napi_env env, napi_value value) { return *static_cast*>(data); } -// Convert DetectionResult to OcrResult format for the completion pipeline. -// Each detection box becomes an OcrLine with empty text and detection score -// as confidence. This reuses the existing OcrResult serialization path; -// the CLI layer interprets these as detections (structure: "detect"). -Result detect_result_to_ocr_result(Result detect_result) { - if (!detect_result) return Result::failure(detect_result.error()); - auto dr = std::move(detect_result).value(); - OcrResult result; - result.image_width = dr.image_width; - result.image_height = dr.image_height; - result.model_bundle_id = std::move(dr.model_bundle_id); - result.timing = dr.timing; - result.lines.reserve(dr.boxes.size()); - for (auto& box : dr.boxes) { - OcrLine line; - line.text = ""; - line.confidence = box.score; - line.box = std::move(box.box); - result.lines.push_back(std::move(line)); - } - return Result::success(std::move(result)); -} - napi_value native_recognize_impl(napi_env env, napi_callback_info callback_info, bool encoded, bool detect_mode = false) { std::shared_ptr engine;