open-agents-ai 0.31.0 → 0.31.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +144 -13
- package/dist/scripts/ocr-advanced.py +285 -51
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -7719,13 +7719,13 @@ var init_ocr_image_advanced = __esm({
|
|
|
7719
7719
|
OcrImageAdvancedTool = class {
|
|
7720
7720
|
workingDir;
|
|
7721
7721
|
name = "ocr_image_advanced";
|
|
7722
|
-
description = "Advanced OCR for images using multi-variant preprocessing and multi-PSM Tesseract pipeline. Generates
|
|
7722
|
+
description = "Advanced OCR for images using multi-variant preprocessing and multi-PSM Tesseract pipeline. Generates 8 image preprocessing variants (2 adaptive windows, OTSU, 2 fixed thresholds, Laplacian sharpen, unsharp mask sharpen, denoise) and runs Tesseract with 3 PSM modes on each (up to 24 passes), picking the best by combined confidence + line-count score. Much more accurate than basic OCR for photos, scans, invoices, and documents with uneven lighting. Supports region extraction (header/body/footer), batch directory processing, and multi-format output (TXT + CSV + PDF).";
|
|
7723
7723
|
parameters = {
|
|
7724
7724
|
type: "object",
|
|
7725
7725
|
properties: {
|
|
7726
7726
|
image: {
|
|
7727
7727
|
type: "string",
|
|
7728
|
-
description: "Path to
|
|
7728
|
+
description: "Path to image file or directory (for batch mode). Supports JPEG, PNG, TIFF, BMP, WebP."
|
|
7729
7729
|
},
|
|
7730
7730
|
language: {
|
|
7731
7731
|
type: "string",
|
|
@@ -7743,6 +7743,14 @@ var init_ocr_image_advanced = __esm({
|
|
|
7743
7743
|
type: "number",
|
|
7744
7744
|
description: "Use a single PSM mode instead of testing all 3. Options: 4 (single block), 6 (default), 11 (sparse)"
|
|
7745
7745
|
},
|
|
7746
|
+
output_dir: {
|
|
7747
|
+
type: "string",
|
|
7748
|
+
description: "Write TXT + CSV + PDF outputs to this directory (in addition to returning text)"
|
|
7749
|
+
},
|
|
7750
|
+
batch: {
|
|
7751
|
+
type: "boolean",
|
|
7752
|
+
description: "Process all images in the directory specified by 'image'. Writes results + summary to output_dir."
|
|
7753
|
+
},
|
|
7746
7754
|
debug: {
|
|
7747
7755
|
type: "boolean",
|
|
7748
7756
|
description: "Save preprocessed variants to a debug directory (default: false)"
|
|
@@ -7761,6 +7769,8 @@ var init_ocr_image_advanced = __esm({
|
|
|
7761
7769
|
const region = args["region"];
|
|
7762
7770
|
const psm = args["psm"];
|
|
7763
7771
|
const debug = args["debug"] === true;
|
|
7772
|
+
const outputDir = args["output_dir"];
|
|
7773
|
+
const batch = args["batch"] === true;
|
|
7764
7774
|
if (!rawPath) {
|
|
7765
7775
|
return { success: false, output: "", error: "image path is required", durationMs: 0 };
|
|
7766
7776
|
}
|
|
@@ -7768,10 +7778,18 @@ var init_ocr_image_advanced = __esm({
|
|
|
7768
7778
|
if (!existsSync15(fullPath)) {
|
|
7769
7779
|
return { success: false, output: "", error: `File not found: ${rawPath}`, durationMs: performance.now() - start };
|
|
7770
7780
|
}
|
|
7771
|
-
|
|
7772
|
-
|
|
7773
|
-
|
|
7781
|
+
if (!batch) {
|
|
7782
|
+
const stat5 = statSync8(fullPath);
|
|
7783
|
+
if (stat5.isDirectory()) {
|
|
7784
|
+
return this.executeBatchOrSingle(fullPath, language, doRegions, region, psm, debug, outputDir, true, start);
|
|
7785
|
+
}
|
|
7786
|
+
if (stat5.size > 50 * 1024 * 1024) {
|
|
7787
|
+
return { success: false, output: "", error: `Image too large: ${(stat5.size / 1024 / 1024).toFixed(0)}MB (max 50MB)`, durationMs: performance.now() - start };
|
|
7788
|
+
}
|
|
7774
7789
|
}
|
|
7790
|
+
return this.executeBatchOrSingle(fullPath, language, doRegions, region, psm, debug, outputDir, batch, start);
|
|
7791
|
+
}
|
|
7792
|
+
executeBatchOrSingle(fullPath, language, doRegions, region, psm, debug, outputDir, batch, start) {
|
|
7775
7793
|
const tesCheck = ensureCommand("tesseract");
|
|
7776
7794
|
if (!tesCheck.available) {
|
|
7777
7795
|
return {
|
|
@@ -7784,11 +7802,19 @@ var init_ocr_image_advanced = __esm({
|
|
|
7784
7802
|
const python = findPython();
|
|
7785
7803
|
const script = findOcrScript();
|
|
7786
7804
|
if (python && script) {
|
|
7787
|
-
return this.runPythonPipeline(python, script, fullPath, language, doRegions, region, psm, debug, start);
|
|
7805
|
+
return this.runPythonPipeline(python, script, fullPath, language, doRegions, region, psm, debug, outputDir, batch, start);
|
|
7806
|
+
}
|
|
7807
|
+
if (batch) {
|
|
7808
|
+
return {
|
|
7809
|
+
success: false,
|
|
7810
|
+
output: "",
|
|
7811
|
+
error: "Batch mode requires the Python OCR pipeline (pytesseract, opencv-python-headless, Pillow, numpy). Install them in ~/.open-agents/venv.",
|
|
7812
|
+
durationMs: performance.now() - start
|
|
7813
|
+
};
|
|
7788
7814
|
}
|
|
7789
7815
|
return this.runBasicTesseract(fullPath, language, region, psm, start);
|
|
7790
7816
|
}
|
|
7791
|
-
runPythonPipeline(python, script, imagePath, language, regions, region, psm, debug, start) {
|
|
7817
|
+
runPythonPipeline(python, script, imagePath, language, regions, region, psm, debug, outputDir, batch, start) {
|
|
7792
7818
|
const cmdParts = [
|
|
7793
7819
|
JSON.stringify(python),
|
|
7794
7820
|
JSON.stringify(script),
|
|
@@ -7804,6 +7830,10 @@ var init_ocr_image_advanced = __esm({
|
|
|
7804
7830
|
cmdParts.push("--region", region);
|
|
7805
7831
|
if (psm)
|
|
7806
7832
|
cmdParts.push("--psm", String(psm));
|
|
7833
|
+
if (batch)
|
|
7834
|
+
cmdParts.push("--batch");
|
|
7835
|
+
if (outputDir)
|
|
7836
|
+
cmdParts.push("--output-dir", JSON.stringify(resolve18(this.workingDir, outputDir)));
|
|
7807
7837
|
let debugDir;
|
|
7808
7838
|
if (debug) {
|
|
7809
7839
|
debugDir = join18(tmpdir5(), `oa-ocr-debug-${Date.now()}`);
|
|
@@ -7826,10 +7856,34 @@ var init_ocr_image_advanced = __esm({
|
|
|
7826
7856
|
durationMs: performance.now() - start
|
|
7827
7857
|
};
|
|
7828
7858
|
}
|
|
7859
|
+
if (result.batch && result.results) {
|
|
7860
|
+
const parts2 = [];
|
|
7861
|
+
parts2.push(`Batch OCR: processed ${result.images_processed} images`);
|
|
7862
|
+
parts2.push(`Output directory: ${result.output_dir}`);
|
|
7863
|
+
parts2.push(`Summary: ${result.summary}`);
|
|
7864
|
+
parts2.push("");
|
|
7865
|
+
for (const [imgName, imgResult] of Object.entries(result.results)) {
|
|
7866
|
+
if (imgResult.error) {
|
|
7867
|
+
parts2.push(` ${imgName}: ERROR \u2014 ${imgResult.error}`);
|
|
7868
|
+
} else {
|
|
7869
|
+
parts2.push(` ${imgName}: ${imgResult.lines} lines, ${imgResult.chars} chars, ${imgResult.confidence}% confidence (${imgResult.variant})`);
|
|
7870
|
+
}
|
|
7871
|
+
}
|
|
7872
|
+
return {
|
|
7873
|
+
success: true,
|
|
7874
|
+
output: parts2.join("\n"),
|
|
7875
|
+
durationMs: performance.now() - start
|
|
7876
|
+
};
|
|
7877
|
+
}
|
|
7829
7878
|
const parts = [];
|
|
7830
7879
|
parts.push(`OCR extracted from ${basename7(imagePath)} (${result.image_size})`);
|
|
7831
|
-
parts.push(`Best variant: ${result.variant} (confidence: ${result.confidence}%, ${result.chars} chars)`);
|
|
7880
|
+
parts.push(`Best variant: ${result.variant} (confidence: ${result.confidence}%, ${result.chars} chars, ${result.lines} lines, score: ${result.score})`);
|
|
7832
7881
|
parts.push(`Variants tested: ${result.variants_tested}`);
|
|
7882
|
+
if (result.output_files) {
|
|
7883
|
+
const files = result.output_files;
|
|
7884
|
+
const saved = [files.txt, files.csv, files.pdf].filter(Boolean);
|
|
7885
|
+
parts.push(`Output files: ${saved.join(", ")}`);
|
|
7886
|
+
}
|
|
7833
7887
|
parts.push("");
|
|
7834
7888
|
parts.push("--- Extracted Text ---");
|
|
7835
7889
|
parts.push(result.text);
|
|
@@ -7845,12 +7899,12 @@ var init_ocr_image_advanced = __esm({
|
|
|
7845
7899
|
}
|
|
7846
7900
|
}
|
|
7847
7901
|
if (result.all_variants) {
|
|
7848
|
-
const sorted = Object.entries(result.all_variants).filter(([_, v]) => v.chars > 0).sort((a, b) => b[1].
|
|
7902
|
+
const sorted = Object.entries(result.all_variants).filter(([_, v]) => v.chars > 0).sort((a, b) => b[1].score - a[1].score).slice(0, 5);
|
|
7849
7903
|
if (sorted.length > 1) {
|
|
7850
7904
|
parts.push("");
|
|
7851
7905
|
parts.push("--- Top Variants ---");
|
|
7852
7906
|
for (const [key, v] of sorted) {
|
|
7853
|
-
parts.push(` ${key}: ${v.confidence}% confidence, ${v.chars} chars`);
|
|
7907
|
+
parts.push(` ${key}: ${v.confidence}% confidence, ${v.chars} chars, ${v.lines} lines (score: ${v.score})`);
|
|
7854
7908
|
}
|
|
7855
7909
|
}
|
|
7856
7910
|
}
|
|
@@ -11608,7 +11662,9 @@ ${tail}`;
|
|
|
11608
11662
|
return sum;
|
|
11609
11663
|
}, 0);
|
|
11610
11664
|
const estimatedTokens = totalChars / 4;
|
|
11611
|
-
|
|
11665
|
+
const ctxWinThreshold = this.options.contextWindowSize > 0 ? Math.floor(this.options.contextWindowSize * 0.75) : Infinity;
|
|
11666
|
+
const effectiveThreshold = Math.min(this.options.compactionThreshold, ctxWinThreshold);
|
|
11667
|
+
if (estimatedTokens < effectiveThreshold) {
|
|
11612
11668
|
return messages;
|
|
11613
11669
|
}
|
|
11614
11670
|
const ctxWin = this.options.contextWindowSize;
|
|
@@ -20294,6 +20350,35 @@ var init_status_bar = __esm({
|
|
|
20294
20350
|
setInputStateProvider(provider) {
|
|
20295
20351
|
this.inputStateProvider = provider;
|
|
20296
20352
|
}
|
|
20353
|
+
/** Sorted list of slash command/skill completions (e.g. ["/help", "/model", ...]) */
|
|
20354
|
+
_completions = [];
|
|
20355
|
+
/**
|
|
20356
|
+
* Set the list of available slash commands and skills for ghost-text autocomplete.
|
|
20357
|
+
* Should include the leading "/" (e.g. "/help", "/model", "/ralph").
|
|
20358
|
+
*/
|
|
20359
|
+
setCompletions(completions) {
|
|
20360
|
+
this._completions = completions.slice().sort();
|
|
20361
|
+
}
|
|
20362
|
+
/**
|
|
20363
|
+
* Find the best completion match for current input.
|
|
20364
|
+
* Returns the suffix to show as ghost text, or empty string if no match.
|
|
20365
|
+
* Only shows ghost when cursor is at end of input.
|
|
20366
|
+
*/
|
|
20367
|
+
getGhostText(inputLine, cursorPos) {
|
|
20368
|
+
if (!inputLine.startsWith("/") || inputLine.length < 2)
|
|
20369
|
+
return "";
|
|
20370
|
+
if (cursorPos !== void 0 && cursorPos < inputLine.length)
|
|
20371
|
+
return "";
|
|
20372
|
+
if (inputLine.includes(" "))
|
|
20373
|
+
return "";
|
|
20374
|
+
const lower = inputLine.toLowerCase();
|
|
20375
|
+
for (const cmd of this._completions) {
|
|
20376
|
+
if (cmd.toLowerCase().startsWith(lower) && cmd.length > inputLine.length) {
|
|
20377
|
+
return cmd.slice(inputLine.length);
|
|
20378
|
+
}
|
|
20379
|
+
}
|
|
20380
|
+
return "";
|
|
20381
|
+
}
|
|
20297
20382
|
/** Set recording indicator state (blinking red ●) */
|
|
20298
20383
|
setRecording(active) {
|
|
20299
20384
|
this._recording = active;
|
|
@@ -20625,9 +20710,11 @@ var init_status_bar = __esm({
|
|
|
20625
20710
|
const inputState = this.inputStateProvider?.();
|
|
20626
20711
|
const fullLine = inputState?.line ?? "";
|
|
20627
20712
|
const cursorPos = inputState?.cursor ?? 0;
|
|
20713
|
+
const ghost = this.getGhostText(fullLine, cursorPos);
|
|
20628
20714
|
if (fullLine.length <= availWidth) {
|
|
20715
|
+
const displayLine = ghost ? fullLine + `\x1B[2m\x1B[38;5;240m${ghost}\x1B[0m` : fullLine;
|
|
20629
20716
|
return {
|
|
20630
|
-
lines: [
|
|
20717
|
+
lines: [displayLine],
|
|
20631
20718
|
cursorRow: 0,
|
|
20632
20719
|
cursorCol: this.promptWidth + cursorPos + 1
|
|
20633
20720
|
};
|
|
@@ -21454,14 +21541,58 @@ async function startInteractive(config, repoPath) {
|
|
|
21454
21541
|
const idlePrompt = `${c2.bold(c2.white("\u276F "))}`;
|
|
21455
21542
|
const activePrompt = `${c2.bold(c2.white("+ "))}`;
|
|
21456
21543
|
const pausedPrompt = `${c2.bold(c2.yellow("| "))}`;
|
|
21544
|
+
const BUILTIN_COMMANDS = [
|
|
21545
|
+
"/help",
|
|
21546
|
+
"/quit",
|
|
21547
|
+
"/exit",
|
|
21548
|
+
"/clear",
|
|
21549
|
+
"/verbose",
|
|
21550
|
+
"/config",
|
|
21551
|
+
"/cost",
|
|
21552
|
+
"/evaluate",
|
|
21553
|
+
"/eval",
|
|
21554
|
+
"/task-type",
|
|
21555
|
+
"/stats",
|
|
21556
|
+
"/metrics",
|
|
21557
|
+
"/dashboard",
|
|
21558
|
+
"/model",
|
|
21559
|
+
"/models",
|
|
21560
|
+
"/endpoint",
|
|
21561
|
+
"/update",
|
|
21562
|
+
"/upgrade",
|
|
21563
|
+
"/voice",
|
|
21564
|
+
"/stream",
|
|
21565
|
+
"/dream",
|
|
21566
|
+
"/listen",
|
|
21567
|
+
"/bruteforce",
|
|
21568
|
+
"/brute",
|
|
21569
|
+
"/emojis",
|
|
21570
|
+
"/colors",
|
|
21571
|
+
"/tools",
|
|
21572
|
+
"/skills",
|
|
21573
|
+
"/pause",
|
|
21574
|
+
"/stop",
|
|
21575
|
+
"/resume"
|
|
21576
|
+
];
|
|
21577
|
+
const discoveredSkillNames = discoverSkills(repoRoot).map((s) => `/${s.name}`);
|
|
21578
|
+
const allCompletions = [.../* @__PURE__ */ new Set([...BUILTIN_COMMANDS, ...discoveredSkillNames])].sort();
|
|
21579
|
+
function completer(line) {
|
|
21580
|
+
if (!line.startsWith("/"))
|
|
21581
|
+
return [[], line];
|
|
21582
|
+
const lower = line.toLowerCase();
|
|
21583
|
+
const hits = allCompletions.filter((c3) => c3.toLowerCase().startsWith(lower));
|
|
21584
|
+
return [hits, line];
|
|
21585
|
+
}
|
|
21457
21586
|
const rl = readline2.createInterface({
|
|
21458
21587
|
input: process.stdin,
|
|
21459
21588
|
output: process.stdout,
|
|
21460
21589
|
prompt: idlePrompt,
|
|
21461
21590
|
terminal: true,
|
|
21462
|
-
historySize: 100
|
|
21591
|
+
historySize: 100,
|
|
21592
|
+
completer
|
|
21463
21593
|
});
|
|
21464
21594
|
statusBar.setPromptText(idlePrompt, 2);
|
|
21595
|
+
statusBar.setCompletions(allCompletions);
|
|
21465
21596
|
if (statusBar.isActive) {
|
|
21466
21597
|
rl.output = new Writable({ write: (_c, _e, cb) => cb() });
|
|
21467
21598
|
statusBar.setInputStateProvider(() => ({
|
|
@@ -2,33 +2,42 @@
|
|
|
2
2
|
"""
|
|
3
3
|
ocr-advanced.py — Multi-variant, multi-PSM OCR pipeline for open-agents.
|
|
4
4
|
|
|
5
|
-
Implements
|
|
5
|
+
Implements a full preprocessing + OCR + cross-reference pipeline:
|
|
6
6
|
1. Load image → grayscale → 2x upscale
|
|
7
|
-
2. Generate
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
2. Generate 7 preprocessing variants (two adaptive windows, OTSU, two fixed
|
|
8
|
+
thresholds, two sharpen kernels, denoise)
|
|
9
|
+
3. Run Tesseract with PSM 4, 6, 11 on each variant (up to 21 passes)
|
|
10
|
+
4. Score results using combined heuristic (confidence * coverage + line bonus)
|
|
11
|
+
5. Optionally extract regions (header/body/footer) with cross-reference
|
|
12
|
+
6. Output as JSON, text, CSV, or write all formats to an output directory
|
|
11
13
|
|
|
12
14
|
Usage:
|
|
13
|
-
python3 ocr-advanced.py <
|
|
14
|
-
|
|
15
|
-
|
|
15
|
+
python3 ocr-advanced.py <image_or_dir> [options]
|
|
16
|
+
|
|
17
|
+
Single image:
|
|
18
|
+
python3 ocr-advanced.py photo.jpg --output json
|
|
19
|
+
python3 ocr-advanced.py scan.png --output-dir ./ocr_out --regions
|
|
20
|
+
|
|
21
|
+
Batch directory:
|
|
22
|
+
python3 ocr-advanced.py ./images/ --output-dir ./ocr_out --batch
|
|
16
23
|
|
|
17
24
|
Output (JSON to stdout):
|
|
18
25
|
{
|
|
19
26
|
"text": "best extracted text",
|
|
20
27
|
"confidence": 85.2,
|
|
21
28
|
"variant": "otsu_psm6",
|
|
22
|
-
"
|
|
23
|
-
"
|
|
24
|
-
"
|
|
29
|
+
"lines": 42,
|
|
30
|
+
"all_variants": { ... },
|
|
31
|
+
"regions": { ... }
|
|
25
32
|
}
|
|
26
33
|
"""
|
|
27
34
|
|
|
28
35
|
import sys
|
|
29
36
|
import os
|
|
30
37
|
import json
|
|
38
|
+
import csv
|
|
31
39
|
import argparse
|
|
40
|
+
from pathlib import Path
|
|
32
41
|
|
|
33
42
|
def check_deps():
|
|
34
43
|
"""Check that required Python packages are available."""
|
|
@@ -52,8 +61,9 @@ def check_deps():
|
|
|
52
61
|
|
|
53
62
|
if missing:
|
|
54
63
|
print(json.dumps({
|
|
55
|
-
"error": f"Missing Python packages: {', '.join(missing)}.
|
|
56
|
-
|
|
64
|
+
"error": f"Missing Python packages: {', '.join(missing)}. "
|
|
65
|
+
f"Install with: pip install {' '.join(missing)}",
|
|
66
|
+
"missing": missing,
|
|
57
67
|
}))
|
|
58
68
|
sys.exit(1)
|
|
59
69
|
|
|
@@ -64,6 +74,8 @@ import numpy as np
|
|
|
64
74
|
import pytesseract
|
|
65
75
|
from PIL import Image
|
|
66
76
|
|
|
77
|
+
IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".tiff", ".tif", ".bmp", ".webp"}
|
|
78
|
+
|
|
67
79
|
|
|
68
80
|
# ---------------------------------------------------------------------------
|
|
69
81
|
# Image preprocessing variants
|
|
@@ -82,47 +94,72 @@ def upscale_2x(gray):
|
|
|
82
94
|
return cv2.resize(gray, (w * 2, h * 2), interpolation=cv2.INTER_CUBIC)
|
|
83
95
|
|
|
84
96
|
|
|
85
|
-
def
|
|
86
|
-
"""Adaptive Gaussian threshold — handles
|
|
97
|
+
def variant_adaptive_wide(gray):
|
|
98
|
+
"""Adaptive Gaussian threshold — wide window (31px), handles gradual lighting."""
|
|
87
99
|
return cv2.adaptiveThreshold(
|
|
88
100
|
gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
|
|
89
101
|
cv2.THRESH_BINARY, 31, 10
|
|
90
102
|
)
|
|
91
103
|
|
|
92
104
|
|
|
105
|
+
def variant_adaptive_fine(gray):
|
|
106
|
+
"""Adaptive Gaussian threshold — fine window (11px), catches small text detail."""
|
|
107
|
+
return cv2.adaptiveThreshold(
|
|
108
|
+
gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
|
|
109
|
+
cv2.THRESH_BINARY, 11, 2
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
93
113
|
def variant_otsu(gray):
|
|
94
114
|
"""OTSU threshold — optimal global threshold for bimodal images."""
|
|
95
115
|
_, binary = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
|
|
96
116
|
return binary
|
|
97
117
|
|
|
98
118
|
|
|
99
|
-
def
|
|
100
|
-
"""Fixed threshold —
|
|
101
|
-
_, binary = cv2.threshold(gray,
|
|
119
|
+
def variant_fixed_140(gray):
|
|
120
|
+
"""Fixed threshold 140 — standard cutoff for dark text on light paper."""
|
|
121
|
+
_, binary = cv2.threshold(gray, 140, 255, cv2.THRESH_BINARY)
|
|
122
|
+
return binary
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def variant_fixed_150(gray):
|
|
126
|
+
"""Fixed threshold 150 — slightly brighter cutoff for lighter scans."""
|
|
127
|
+
_, binary = cv2.threshold(gray, 150, 255, cv2.THRESH_BINARY)
|
|
102
128
|
return binary
|
|
103
129
|
|
|
104
130
|
|
|
105
|
-
def
|
|
106
|
-
"""
|
|
131
|
+
def variant_sharpen_laplacian_otsu(gray):
|
|
132
|
+
"""Laplacian sharpen + OTSU — aggressive edge enhancement."""
|
|
107
133
|
kernel = np.array([[-1, -1, -1], [-1, 9, -1], [-1, -1, -1]])
|
|
108
134
|
sharpened = cv2.filter2D(gray, -1, kernel)
|
|
109
135
|
_, binary = cv2.threshold(sharpened, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
|
|
110
136
|
return binary
|
|
111
137
|
|
|
112
138
|
|
|
139
|
+
def variant_sharpen_unsharp_otsu(gray):
|
|
140
|
+
"""Unsharp mask sharpen + OTSU — gentler enhancement, better for photos."""
|
|
141
|
+
kernel = np.array([[0, -1, 0], [-1, 5, -1], [0, -1, 0]])
|
|
142
|
+
sharpened = cv2.filter2D(gray, -1, kernel)
|
|
143
|
+
_, binary = cv2.threshold(sharpened, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
|
|
144
|
+
return binary
|
|
145
|
+
|
|
146
|
+
|
|
113
147
|
def variant_denoise_otsu(gray):
|
|
114
|
-
"""Denoise + OTSU — removes JPEG artifacts
|
|
148
|
+
"""Denoise + OTSU — removes JPEG artifacts and photo noise."""
|
|
115
149
|
denoised = cv2.fastNlMeansDenoising(gray, h=10, templateWindowSize=7, searchWindowSize=21)
|
|
116
150
|
_, binary = cv2.threshold(denoised, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
|
|
117
151
|
return binary
|
|
118
152
|
|
|
119
153
|
|
|
120
154
|
ALL_VARIANTS = {
|
|
121
|
-
"
|
|
122
|
-
"
|
|
123
|
-
"
|
|
124
|
-
"
|
|
125
|
-
"
|
|
155
|
+
"adaptive_wide": variant_adaptive_wide,
|
|
156
|
+
"adaptive_fine": variant_adaptive_fine,
|
|
157
|
+
"otsu": variant_otsu,
|
|
158
|
+
"fixed_140": variant_fixed_140,
|
|
159
|
+
"fixed_150": variant_fixed_150,
|
|
160
|
+
"sharpen_lap": variant_sharpen_laplacian_otsu,
|
|
161
|
+
"sharpen_unsharp": variant_sharpen_unsharp_otsu,
|
|
162
|
+
"denoise": variant_denoise_otsu,
|
|
126
163
|
}
|
|
127
164
|
|
|
128
165
|
PSM_MODES = {
|
|
@@ -137,24 +174,42 @@ PSM_MODES = {
|
|
|
137
174
|
# ---------------------------------------------------------------------------
|
|
138
175
|
|
|
139
176
|
def run_tesseract(binary_img, language="eng", psm=6):
|
|
140
|
-
"""Run Tesseract on a preprocessed binary image.
|
|
177
|
+
"""Run Tesseract on a preprocessed binary image.
|
|
178
|
+
Returns (text, confidence, line_count)."""
|
|
141
179
|
pil_img = Image.fromarray(binary_img)
|
|
142
180
|
config = f"--psm {psm}"
|
|
143
181
|
|
|
144
182
|
try:
|
|
145
183
|
text = pytesseract.image_to_string(pil_img, lang=language, config=config).strip()
|
|
146
|
-
except Exception
|
|
147
|
-
return "", 0.0
|
|
184
|
+
except Exception:
|
|
185
|
+
return "", 0.0, 0
|
|
186
|
+
|
|
187
|
+
line_count = len([l for l in text.split("\n") if l.strip()])
|
|
148
188
|
|
|
149
189
|
# Get confidence via image_to_data
|
|
150
190
|
try:
|
|
151
|
-
data = pytesseract.image_to_data(
|
|
191
|
+
data = pytesseract.image_to_data(
|
|
192
|
+
pil_img, lang=language, config=config,
|
|
193
|
+
output_type=pytesseract.Output.DICT,
|
|
194
|
+
)
|
|
152
195
|
confs = [int(c) for c in data["conf"] if int(c) >= 0]
|
|
153
196
|
avg_conf = sum(confs) / len(confs) if confs else 0.0
|
|
154
197
|
except Exception:
|
|
155
198
|
avg_conf = 0.0
|
|
156
199
|
|
|
157
|
-
return text, avg_conf
|
|
200
|
+
return text, avg_conf, line_count
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def compute_score(text, confidence, line_count):
|
|
204
|
+
"""Combined scoring heuristic:
|
|
205
|
+
- confidence * sqrt(char_count) — rewards quality and coverage
|
|
206
|
+
- + line_count * 10 — bonus for structured output (more lines = better parse)
|
|
207
|
+
The agent discovered that line-count is a strong proxy for successful parsing
|
|
208
|
+
on structured documents like invoices and forms."""
|
|
209
|
+
char_count = len(text)
|
|
210
|
+
if char_count == 0:
|
|
211
|
+
return 0
|
|
212
|
+
return confidence * (char_count ** 0.5) + line_count * 10
|
|
158
213
|
|
|
159
214
|
|
|
160
215
|
def extract_region(gray, y_start_pct, y_end_pct, x_start_pct=0, x_end_pct=100):
|
|
@@ -172,12 +227,77 @@ def extract_pixel_region(gray, x, y, w, h):
|
|
|
172
227
|
return gray[y:y+h, x:x+w]
|
|
173
228
|
|
|
174
229
|
|
|
230
|
+
# ---------------------------------------------------------------------------
|
|
231
|
+
# Output writers
|
|
232
|
+
# ---------------------------------------------------------------------------
|
|
233
|
+
|
|
234
|
+
def write_txt(text, output_path):
|
|
235
|
+
"""Write plain text output."""
|
|
236
|
+
with open(output_path, "w", encoding="utf-8") as f:
|
|
237
|
+
f.write(text)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def write_csv(text, output_path):
|
|
241
|
+
"""Write CSV with line numbers."""
|
|
242
|
+
lines = [l for l in text.split("\n") if l.strip()]
|
|
243
|
+
with open(output_path, "w", newline="", encoding="utf-8") as f:
|
|
244
|
+
writer = csv.writer(f)
|
|
245
|
+
writer.writerow(["Line_Number", "Extracted_Text"])
|
|
246
|
+
for i, line in enumerate(lines, 1):
|
|
247
|
+
writer.writerow([i, line])
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def write_pdf(text, output_path):
|
|
251
|
+
"""Write searchable PDF using reportlab (if available)."""
|
|
252
|
+
try:
|
|
253
|
+
from reportlab.lib.pagesizes import letter
|
|
254
|
+
from reportlab.pdfgen import canvas as pdf_canvas
|
|
255
|
+
from reportlab.lib.units import inch
|
|
256
|
+
except ImportError:
|
|
257
|
+
return False
|
|
258
|
+
|
|
259
|
+
lines = [l for l in text.split("\n") if l.strip()]
|
|
260
|
+
c = pdf_canvas.Canvas(output_path, pagesize=letter)
|
|
261
|
+
width, height = letter
|
|
262
|
+
y = height - 1 * inch
|
|
263
|
+
|
|
264
|
+
for line in lines:
|
|
265
|
+
if y < 1 * inch:
|
|
266
|
+
c.showPage()
|
|
267
|
+
y = height - 1 * inch
|
|
268
|
+
c.drawString(1 * inch, y, line)
|
|
269
|
+
y -= 14
|
|
270
|
+
|
|
271
|
+
c.save()
|
|
272
|
+
return True
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def write_all_outputs(text, base_name, output_dir):
|
|
276
|
+
"""Write TXT + CSV + PDF to output directory."""
|
|
277
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
278
|
+
|
|
279
|
+
txt_path = os.path.join(output_dir, f"{base_name}.txt")
|
|
280
|
+
write_txt(text, txt_path)
|
|
281
|
+
|
|
282
|
+
csv_path = os.path.join(output_dir, f"{base_name}.csv")
|
|
283
|
+
write_csv(text, csv_path)
|
|
284
|
+
|
|
285
|
+
pdf_path = os.path.join(output_dir, f"{base_name}.pdf")
|
|
286
|
+
pdf_ok = write_pdf(text, pdf_path)
|
|
287
|
+
|
|
288
|
+
return {
|
|
289
|
+
"txt": txt_path,
|
|
290
|
+
"csv": csv_path,
|
|
291
|
+
"pdf": pdf_path if pdf_ok else None,
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
|
|
175
295
|
# ---------------------------------------------------------------------------
|
|
176
296
|
# Main pipeline
|
|
177
297
|
# ---------------------------------------------------------------------------
|
|
178
298
|
|
|
179
299
|
def run_pipeline(image_path, language="eng", do_regions=False, debug_dir=None,
|
|
180
|
-
single_psm=None, pixel_region=None):
|
|
300
|
+
single_psm=None, pixel_region=None, output_dir=None):
|
|
181
301
|
"""Run the full multi-variant, multi-PSM OCR pipeline."""
|
|
182
302
|
|
|
183
303
|
# Load image
|
|
@@ -208,7 +328,7 @@ def run_pipeline(image_path, language="eng", do_regions=False, debug_dir=None,
|
|
|
208
328
|
for vname, vfunc in ALL_VARIANTS.items():
|
|
209
329
|
try:
|
|
210
330
|
binary = vfunc(gray_2x)
|
|
211
|
-
except Exception
|
|
331
|
+
except Exception:
|
|
212
332
|
continue
|
|
213
333
|
|
|
214
334
|
# Save debug images
|
|
@@ -218,16 +338,18 @@ def run_pipeline(image_path, language="eng", do_regions=False, debug_dir=None,
|
|
|
218
338
|
|
|
219
339
|
for psm in psm_modes:
|
|
220
340
|
key = f"{vname}_psm{psm}"
|
|
221
|
-
text, confidence = run_tesseract(binary, language, psm)
|
|
341
|
+
text, confidence, line_count = run_tesseract(binary, language, psm)
|
|
222
342
|
char_count = len(text)
|
|
343
|
+
score = compute_score(text, confidence, line_count)
|
|
344
|
+
|
|
223
345
|
all_results[key] = {
|
|
224
346
|
"text": text,
|
|
225
347
|
"chars": char_count,
|
|
348
|
+
"lines": line_count,
|
|
226
349
|
"confidence": round(confidence, 1),
|
|
350
|
+
"score": round(score, 1),
|
|
227
351
|
}
|
|
228
352
|
|
|
229
|
-
# Score: confidence * sqrt(char_count) — rewards both quality and coverage
|
|
230
|
-
score = confidence * (char_count ** 0.5) if char_count > 0 else 0
|
|
231
353
|
if score > best_score:
|
|
232
354
|
best_score = score
|
|
233
355
|
best_key = key
|
|
@@ -235,11 +357,14 @@ def run_pipeline(image_path, language="eng", do_regions=False, debug_dir=None,
|
|
|
235
357
|
if not best_key:
|
|
236
358
|
return {"error": "All OCR variants failed to produce output"}
|
|
237
359
|
|
|
360
|
+
best = all_results[best_key]
|
|
238
361
|
result = {
|
|
239
|
-
"text":
|
|
240
|
-
"confidence":
|
|
362
|
+
"text": best["text"],
|
|
363
|
+
"confidence": best["confidence"],
|
|
241
364
|
"variant": best_key,
|
|
242
|
-
"chars":
|
|
365
|
+
"chars": best["chars"],
|
|
366
|
+
"lines": best["lines"],
|
|
367
|
+
"score": best["score"],
|
|
243
368
|
"image_size": f"{w_orig}x{h_orig}",
|
|
244
369
|
"variants_tested": len(all_results),
|
|
245
370
|
"all_variants": all_results,
|
|
@@ -260,11 +385,13 @@ def run_pipeline(image_path, language="eng", do_regions=False, debug_dir=None,
|
|
|
260
385
|
if debug_dir:
|
|
261
386
|
cv2.imwrite(os.path.join(debug_dir, f"region_{rname}.png"), region_gray)
|
|
262
387
|
|
|
263
|
-
#
|
|
388
|
+
# Test all variants on each region for best accuracy
|
|
264
389
|
region_best = ""
|
|
265
390
|
region_best_score = -1
|
|
266
391
|
|
|
267
|
-
for vname in ["otsu", "denoise"]:
|
|
392
|
+
for vname in ["otsu", "denoise", "adaptive_fine", "sharpen_unsharp"]:
|
|
393
|
+
if vname not in ALL_VARIANTS:
|
|
394
|
+
continue
|
|
268
395
|
try:
|
|
269
396
|
binary = ALL_VARIANTS[vname](region_gray)
|
|
270
397
|
except Exception:
|
|
@@ -273,8 +400,8 @@ def run_pipeline(image_path, language="eng", do_regions=False, debug_dir=None,
|
|
|
273
400
|
if debug_dir:
|
|
274
401
|
cv2.imwrite(os.path.join(debug_dir, f"region_{rname}_{vname}.png"), binary)
|
|
275
402
|
|
|
276
|
-
text, conf = run_tesseract(binary, language, 6)
|
|
277
|
-
score =
|
|
403
|
+
text, conf, lc = run_tesseract(binary, language, 6)
|
|
404
|
+
score = compute_score(text, conf, lc)
|
|
278
405
|
if score > region_best_score:
|
|
279
406
|
region_best_score = score
|
|
280
407
|
region_best = text
|
|
@@ -286,21 +413,127 @@ def run_pipeline(image_path, language="eng", do_regions=False, debug_dir=None,
|
|
|
286
413
|
if debug_dir:
|
|
287
414
|
result["debug_dir"] = debug_dir
|
|
288
415
|
|
|
416
|
+
# Write output files if output_dir specified
|
|
417
|
+
if output_dir:
|
|
418
|
+
base_name = Path(image_path).stem
|
|
419
|
+
files = write_all_outputs(best["text"], base_name, output_dir)
|
|
420
|
+
result["output_files"] = files
|
|
421
|
+
|
|
289
422
|
return result
|
|
290
423
|
|
|
291
424
|
|
|
425
|
+
def run_batch(images_dir, language="eng", do_regions=False, debug_dir=None,
|
|
426
|
+
output_dir=None):
|
|
427
|
+
"""Process all images in a directory."""
|
|
428
|
+
images_dir = os.path.abspath(images_dir)
|
|
429
|
+
if not os.path.isdir(images_dir):
|
|
430
|
+
return {"error": f"Not a directory: {images_dir}"}
|
|
431
|
+
|
|
432
|
+
out_dir = output_dir or os.path.join(images_dir, "ocr_out")
|
|
433
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
434
|
+
|
|
435
|
+
batch_results = {}
|
|
436
|
+
image_files = sorted(
|
|
437
|
+
f for f in os.listdir(images_dir)
|
|
438
|
+
if Path(f).suffix.lower() in IMAGE_EXTENSIONS
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
if not image_files:
|
|
442
|
+
return {"error": f"No image files found in {images_dir}"}
|
|
443
|
+
|
|
444
|
+
for img_file in image_files:
|
|
445
|
+
img_path = os.path.join(images_dir, img_file)
|
|
446
|
+
img_debug = os.path.join(debug_dir, Path(img_file).stem) if debug_dir else None
|
|
447
|
+
result = run_pipeline(
|
|
448
|
+
img_path,
|
|
449
|
+
language=language,
|
|
450
|
+
do_regions=do_regions,
|
|
451
|
+
debug_dir=img_debug,
|
|
452
|
+
output_dir=out_dir,
|
|
453
|
+
)
|
|
454
|
+
# Compact per-image result (omit all_variants for batch summary)
|
|
455
|
+
batch_results[img_file] = {
|
|
456
|
+
"text": result.get("text", ""),
|
|
457
|
+
"confidence": result.get("confidence", 0),
|
|
458
|
+
"variant": result.get("variant", ""),
|
|
459
|
+
"chars": result.get("chars", 0),
|
|
460
|
+
"lines": result.get("lines", 0),
|
|
461
|
+
"output_files": result.get("output_files"),
|
|
462
|
+
"error": result.get("error"),
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
# Write summary
|
|
466
|
+
summary_path = os.path.join(out_dir, "OCR_PROCESSING_SUMMARY.md")
|
|
467
|
+
with open(summary_path, "w", encoding="utf-8") as f:
|
|
468
|
+
f.write("# OCR Processing Summary Report\n\n")
|
|
469
|
+
f.write(f"**Source:** `{images_dir}`\n\n")
|
|
470
|
+
f.write("## Processed Documents\n\n")
|
|
471
|
+
f.write("| Document | Lines | Chars | Confidence | Variant |\n")
|
|
472
|
+
f.write("|----------|-------|-------|------------|----------|\n")
|
|
473
|
+
for img, data in batch_results.items():
|
|
474
|
+
if data.get("error"):
|
|
475
|
+
f.write(f"| {img} | ERROR | - | - | {data['error']} |\n")
|
|
476
|
+
else:
|
|
477
|
+
f.write(
|
|
478
|
+
f"| {img} | {data['lines']} | {data['chars']} "
|
|
479
|
+
f"| {data['confidence']}% | {data['variant']} |\n"
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
return {
|
|
483
|
+
"batch": True,
|
|
484
|
+
"images_processed": len(batch_results),
|
|
485
|
+
"output_dir": out_dir,
|
|
486
|
+
"summary": summary_path,
|
|
487
|
+
"results": batch_results,
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
|
|
292
491
|
def main():
|
|
293
|
-
parser = argparse.ArgumentParser(
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
parser.add_argument(
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
parser.add_argument("--
|
|
492
|
+
parser = argparse.ArgumentParser(
|
|
493
|
+
description="Advanced multi-variant OCR pipeline for open-agents"
|
|
494
|
+
)
|
|
495
|
+
parser.add_argument(
|
|
496
|
+
"image",
|
|
497
|
+
help="Path to image file, or directory for --batch mode",
|
|
498
|
+
)
|
|
499
|
+
parser.add_argument("--language", "-l", default="eng",
|
|
500
|
+
help="OCR language (default: eng)")
|
|
501
|
+
parser.add_argument("--regions", action="store_true",
|
|
502
|
+
help="Also OCR header/body/footer regions")
|
|
503
|
+
parser.add_argument("--debug-dir",
|
|
504
|
+
help="Save preprocessed images to this directory")
|
|
505
|
+
parser.add_argument("--psm", type=int, choices=[4, 6, 11],
|
|
506
|
+
help="Use single PSM mode instead of all 3")
|
|
507
|
+
parser.add_argument("--region",
|
|
508
|
+
help="Crop region before OCR: x,y,w,h in pixels")
|
|
509
|
+
parser.add_argument("--output", choices=["json", "text"], default="json",
|
|
510
|
+
help="Stdout output format (default: json)")
|
|
511
|
+
parser.add_argument("--output-dir",
|
|
512
|
+
help="Write TXT + CSV + PDF outputs to this directory")
|
|
513
|
+
parser.add_argument("--batch", action="store_true",
|
|
514
|
+
help="Process all images in a directory")
|
|
301
515
|
|
|
302
516
|
args = parser.parse_args()
|
|
303
517
|
|
|
518
|
+
# Batch mode
|
|
519
|
+
if args.batch or os.path.isdir(args.image):
|
|
520
|
+
result = run_batch(
|
|
521
|
+
args.image,
|
|
522
|
+
language=args.language,
|
|
523
|
+
do_regions=args.regions,
|
|
524
|
+
debug_dir=args.debug_dir,
|
|
525
|
+
output_dir=args.output_dir,
|
|
526
|
+
)
|
|
527
|
+
if args.output == "text":
|
|
528
|
+
if "error" in result:
|
|
529
|
+
print(f"ERROR: {result['error']}", file=sys.stderr)
|
|
530
|
+
sys.exit(1)
|
|
531
|
+
print(f"Processed {result['images_processed']} images → {result['output_dir']}")
|
|
532
|
+
else:
|
|
533
|
+
print(json.dumps(result, indent=2))
|
|
534
|
+
sys.exit(0)
|
|
535
|
+
|
|
536
|
+
# Single image mode
|
|
304
537
|
if not os.path.isfile(args.image):
|
|
305
538
|
print(json.dumps({"error": f"File not found: {args.image}"}))
|
|
306
539
|
sys.exit(1)
|
|
@@ -322,6 +555,7 @@ def main():
|
|
|
322
555
|
debug_dir=args.debug_dir,
|
|
323
556
|
single_psm=args.psm,
|
|
324
557
|
pixel_region=pixel_region,
|
|
558
|
+
output_dir=args.output_dir,
|
|
325
559
|
)
|
|
326
560
|
|
|
327
561
|
if args.output == "text":
|
package/package.json
CHANGED