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 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 5 image preprocessing variants (adaptive threshold, OTSU, fixed threshold, sharpen+OTSU, denoise+OTSU) and runs Tesseract with 3 page segmentation modes on each, picking the best result by confidence score. Much more accurate than basic OCR for photos, scans, and documents with uneven lighting. Supports region extraction (header/body/footer) for structured documents like invoices.";
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 the image file (JPEG, PNG, TIFF, BMP, WebP)"
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
- const stat5 = statSync8(fullPath);
7772
- if (stat5.size > 50 * 1024 * 1024) {
7773
- return { success: false, output: "", error: `Image too large: ${(stat5.size / 1024 / 1024).toFixed(0)}MB (max 50MB)`, durationMs: performance.now() - start };
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].confidence - a[1].confidence).slice(0, 5);
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
- if (estimatedTokens < this.options.compactionThreshold) {
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: [fullLine],
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 the full preprocessing + OCR + cross-reference pipeline:
5
+ Implements a full preprocessing + OCR + cross-reference pipeline:
6
6
  1. Load image → grayscale → 2x upscale
7
- 2. Generate 5 preprocessing variants (adaptive, OTSU, fixed, sharpen, denoise)
8
- 3. Run Tesseract with PSM 4, 6, 11 on each variant
9
- 4. Pick best result by character count and confidence
10
- 5. Optionally extract regions and cross-reference
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 <image_path> [--language eng] [--output json|text|csv]
14
- [--regions] [--debug-dir <path>]
15
- [--psm <mode>] [--region <x,y,w,h>]
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
- "all_variants": { "otsu_psm6": { "text": "...", "chars": 1234, "confidence": 85 }, ... },
23
- "regions": { "header": "...", "body": "...", "footer": "..." }, // if --regions
24
- "debug_dir": "/path/to/debug" // if --debug-dir
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)}. Install with: pip install {' '.join(missing)}",
56
- "missing": missing
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 variant_adaptive(gray):
86
- """Adaptive Gaussian threshold — handles uneven lighting."""
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 variant_fixed(gray, threshold=140):
100
- """Fixed threshold — simple cutoff for dark text on light paper."""
101
- _, binary = cv2.threshold(gray, threshold, 255, cv2.THRESH_BINARY)
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 variant_sharpen_otsu(gray):
106
- """Sharpen + OTSU — enhances character edges before thresholding."""
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 before thresholding."""
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
- "adaptive": variant_adaptive,
122
- "otsu": variant_otsu,
123
- "fixed": variant_fixed,
124
- "sharpen": variant_sharpen_otsu,
125
- "denoise": variant_denoise_otsu,
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. Returns (text, confidence)."""
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 as e:
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(pil_img, lang=language, config=config, output_type=pytesseract.Output.DICT)
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 as e:
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": all_results[best_key]["text"],
240
- "confidence": all_results[best_key]["confidence"],
362
+ "text": best["text"],
363
+ "confidence": best["confidence"],
241
364
  "variant": best_key,
242
- "chars": all_results[best_key]["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
- # Use best 2 variants on each region
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 = conf * (len(text) ** 0.5) if text else 0
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(description="Advanced multi-variant OCR pipeline")
294
- parser.add_argument("image", help="Path to image file")
295
- parser.add_argument("--language", "-l", default="eng", help="OCR language (default: eng)")
296
- parser.add_argument("--regions", action="store_true", help="Also OCR header/body/footer regions")
297
- parser.add_argument("--debug-dir", help="Save preprocessed images to this directory")
298
- parser.add_argument("--psm", type=int, choices=[4, 6, 11], help="Use single PSM mode instead of all 3")
299
- parser.add_argument("--region", help="Crop region before OCR: x,y,w,h in pixels")
300
- parser.add_argument("--output", choices=["json", "text"], default="json", help="Output format")
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "open-agents-ai",
3
- "version": "0.31.0",
3
+ "version": "0.31.2",
4
4
  "description": "AI coding agent powered by open-source models (Ollama/vLLM) — interactive TUI with agentic tool-calling loop",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",