pro-ledin-ocr 0.1.0__py3-none-any.whl
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.
- pro/ledin/ocr/__init__.py +52 -0
- pro/ledin/ocr/__main__.py +5 -0
- pro/ledin/ocr/cli.py +276 -0
- pro/ledin/ocr/core.py +1364 -0
- pro/ledin/ocr/probe.sh +186 -0
- pro_ledin_ocr-0.1.0.dist-info/METADATA +127 -0
- pro_ledin_ocr-0.1.0.dist-info/RECORD +10 -0
- pro_ledin_ocr-0.1.0.dist-info/WHEEL +4 -0
- pro_ledin_ocr-0.1.0.dist-info/entry_points.txt +3 -0
- pro_ledin_ocr-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""pro.ledin.ocr — layered OCR workhorse.
|
|
2
|
+
|
|
3
|
+
Public library API (import as ``from pro.ledin import ocr``):
|
|
4
|
+
|
|
5
|
+
pages = ocr.recognize("scan.pdf", ocr.RecognizeOptions(engine="tesseract"))
|
|
6
|
+
markdown = ocr.to_markdown(pages, "scan.pdf")
|
|
7
|
+
|
|
8
|
+
Catch ``ocr.OcrError`` for recoverable failures (missing binaries/packages,
|
|
9
|
+
unsupported input, vision-api config). The library never calls ``sys.exit()``.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from .core import (
|
|
15
|
+
Cache,
|
|
16
|
+
Caps,
|
|
17
|
+
DEFAULT_MIN_CONF,
|
|
18
|
+
DEFAULT_PSM,
|
|
19
|
+
EXIT_BAD_ARGS,
|
|
20
|
+
EXIT_MISSING_BINARY,
|
|
21
|
+
EXIT_OK,
|
|
22
|
+
EXIT_UNSUPPORTED,
|
|
23
|
+
OcrError,
|
|
24
|
+
RecognizeOptions,
|
|
25
|
+
__version__,
|
|
26
|
+
probe_script_path,
|
|
27
|
+
process_file,
|
|
28
|
+
recognize,
|
|
29
|
+
to_json,
|
|
30
|
+
to_markdown,
|
|
31
|
+
to_text,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"Cache",
|
|
36
|
+
"Caps",
|
|
37
|
+
"DEFAULT_MIN_CONF",
|
|
38
|
+
"DEFAULT_PSM",
|
|
39
|
+
"EXIT_BAD_ARGS",
|
|
40
|
+
"EXIT_MISSING_BINARY",
|
|
41
|
+
"EXIT_OK",
|
|
42
|
+
"EXIT_UNSUPPORTED",
|
|
43
|
+
"OcrError",
|
|
44
|
+
"RecognizeOptions",
|
|
45
|
+
"__version__",
|
|
46
|
+
"probe_script_path",
|
|
47
|
+
"process_file",
|
|
48
|
+
"recognize",
|
|
49
|
+
"to_json",
|
|
50
|
+
"to_markdown",
|
|
51
|
+
"to_text",
|
|
52
|
+
]
|
pro/ledin/ocr/cli.py
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Command-line interface for the `pro.ledin.ocr` package (console script: ocr).
|
|
3
|
+
|
|
4
|
+
The recognition logic lives in `core`; this module owns only argument parsing
|
|
5
|
+
and output writing (formats, files, searchable PDF, quality report).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
import tempfile
|
|
14
|
+
import time
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from .core import (
|
|
18
|
+
Cache,
|
|
19
|
+
Caps,
|
|
20
|
+
DEFAULT_MIN_CONF,
|
|
21
|
+
DEFAULT_PSM,
|
|
22
|
+
OcrError,
|
|
23
|
+
RecognizeOptions,
|
|
24
|
+
_log,
|
|
25
|
+
process_file,
|
|
26
|
+
to_json,
|
|
27
|
+
to_markdown,
|
|
28
|
+
to_text,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
# ── output writing ────────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
caps_global: Caps # set in main()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def write_outputs(
|
|
37
|
+
pages_data: list[dict],
|
|
38
|
+
input_path: str,
|
|
39
|
+
args: argparse.Namespace,
|
|
40
|
+
lang: str,
|
|
41
|
+
dpi: int,
|
|
42
|
+
) -> None:
|
|
43
|
+
filename = os.path.basename(input_path)
|
|
44
|
+
meta = {
|
|
45
|
+
"file": filename,
|
|
46
|
+
"engine": args.engine,
|
|
47
|
+
"lang": lang,
|
|
48
|
+
"dpi": dpi,
|
|
49
|
+
"preprocess": args.preprocess,
|
|
50
|
+
"min_conf": args.min_conf,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
formats = args.format.split(",") if "," in args.format else [args.format]
|
|
54
|
+
if "all" in formats:
|
|
55
|
+
formats = ["md", "txt", "json"]
|
|
56
|
+
|
|
57
|
+
for fmt in formats:
|
|
58
|
+
if fmt == "md":
|
|
59
|
+
content = to_markdown(pages_data, filename)
|
|
60
|
+
elif fmt == "txt":
|
|
61
|
+
content = to_text(pages_data)
|
|
62
|
+
elif fmt == "json":
|
|
63
|
+
content = to_json(pages_data, meta)
|
|
64
|
+
else:
|
|
65
|
+
continue
|
|
66
|
+
|
|
67
|
+
if args.out:
|
|
68
|
+
out_path = args.out
|
|
69
|
+
if os.path.isdir(out_path) or "all" in [args.format] or len(formats) > 1:
|
|
70
|
+
os.makedirs(out_path, exist_ok=True)
|
|
71
|
+
stem = Path(input_path).stem
|
|
72
|
+
out_file = os.path.join(out_path, f"{stem}.{fmt}")
|
|
73
|
+
else:
|
|
74
|
+
out_file = out_path
|
|
75
|
+
with open(out_file, "w", encoding="utf-8") as f:
|
|
76
|
+
f.write(content)
|
|
77
|
+
print(f"[ocr] wrote {fmt} → {out_file}", file=sys.stderr)
|
|
78
|
+
else:
|
|
79
|
+
if len(formats) > 1:
|
|
80
|
+
print(f"\n{'='*60}\n[{fmt.upper()}]\n{'='*60}", flush=True)
|
|
81
|
+
print(content, flush=True)
|
|
82
|
+
|
|
83
|
+
# Optional JSON report
|
|
84
|
+
if args.json_report:
|
|
85
|
+
report_content = to_json(pages_data, meta)
|
|
86
|
+
with open(args.json_report, "w", encoding="utf-8") as f:
|
|
87
|
+
f.write(report_content)
|
|
88
|
+
print(f"[ocr] quality report → {args.json_report}", file=sys.stderr)
|
|
89
|
+
|
|
90
|
+
# Searchable PDF
|
|
91
|
+
if args.searchable_pdf:
|
|
92
|
+
if not caps_global.bin_ocrmypdf:
|
|
93
|
+
print("[ocr] WARNING: ocrmypdf not found. Install: brew install ocrmypdf", file=sys.stderr)
|
|
94
|
+
else:
|
|
95
|
+
cmd = [caps_global.bin_ocrmypdf, "-l", lang,
|
|
96
|
+
"--rotate-pages", "--deskew", "--force-ocr",
|
|
97
|
+
input_path, args.searchable_pdf]
|
|
98
|
+
try:
|
|
99
|
+
import subprocess
|
|
100
|
+
subprocess.run(cmd, check=True)
|
|
101
|
+
print(f"[ocr] searchable PDF → {args.searchable_pdf}", file=sys.stderr)
|
|
102
|
+
except Exception as e:
|
|
103
|
+
print(f"[ocr] ocrmypdf failed: {e}", file=sys.stderr)
|
|
104
|
+
|
|
105
|
+
# Print quality report summary
|
|
106
|
+
flagged = [p for p in pages_data if p.get("flag")]
|
|
107
|
+
if flagged:
|
|
108
|
+
print(f"\n[ocr] Quality report: {len(flagged)} page(s) flagged for review", file=sys.stderr)
|
|
109
|
+
for p in flagged:
|
|
110
|
+
print(f" Page {p['n']}: conf={p.get('mean_conf', '?')}, flag={p['flag']}", file=sys.stderr)
|
|
111
|
+
print("[ocr] Suggestion: re-run with --engine vision --pages "
|
|
112
|
+
+ ",".join(str(p["n"]) for p in flagged), file=sys.stderr)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# ── argparse ──────────────────────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
118
|
+
p = argparse.ArgumentParser(
|
|
119
|
+
prog="ocr",
|
|
120
|
+
description="Extract text from scanned PDFs and images using layered OCR.",
|
|
121
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
122
|
+
epilog="""
|
|
123
|
+
Examples:
|
|
124
|
+
ocr scan.pdf --format all
|
|
125
|
+
ocr photo.jpg --format md
|
|
126
|
+
ocr doc.pdf --lang rus+eng --preprocess full --format all
|
|
127
|
+
ocr slides.pdf --engine vision --pages 9,12
|
|
128
|
+
ocr *.pdf --cache cache.json --format txt
|
|
129
|
+
""",
|
|
130
|
+
)
|
|
131
|
+
p.add_argument("inputs", nargs="+", metavar="INPUT", help="PDF or image file(s)")
|
|
132
|
+
p.add_argument("--engine", default="auto",
|
|
133
|
+
choices=["auto", "tesseract", "easyocr", "paddleocr", "vision", "vision-api"],
|
|
134
|
+
help="OCR engine (default: auto)")
|
|
135
|
+
p.add_argument("--lang", default="auto",
|
|
136
|
+
help="Tesseract language code(s), e.g. rus+eng (default: auto via OSD)")
|
|
137
|
+
p.add_argument("--format", default="md",
|
|
138
|
+
help="Output format: md|txt|json|all (default: md)")
|
|
139
|
+
p.add_argument("--out", default="",
|
|
140
|
+
help="Output file or directory (default: stdout)")
|
|
141
|
+
p.add_argument("--dpi", type=int, default=0,
|
|
142
|
+
help="Rendering DPI (default: auto — 300 A4, 150 wide canvas)")
|
|
143
|
+
p.add_argument("--preprocess", default="auto",
|
|
144
|
+
choices=["none", "basic", "enhanced", "full", "auto"],
|
|
145
|
+
help="Image preprocessing level (default: auto)")
|
|
146
|
+
p.add_argument("--pages", default="",
|
|
147
|
+
help="Page range to process, e.g. 1-3,5 (default: all)")
|
|
148
|
+
p.add_argument("--max-pages", type=int, default=0,
|
|
149
|
+
help="Maximum pages per file (default: all)")
|
|
150
|
+
p.add_argument("--psm", type=int, default=DEFAULT_PSM,
|
|
151
|
+
help=f"Tesseract PSM (default: {DEFAULT_PSM}; 6 for single-block)")
|
|
152
|
+
p.add_argument("--min-conf", type=float, default=DEFAULT_MIN_CONF,
|
|
153
|
+
help=f"Confidence threshold for flagging pages (default: {DEFAULT_MIN_CONF})")
|
|
154
|
+
p.add_argument("--cache", default="",
|
|
155
|
+
help="Cache file path (JSON) for skipping already-processed files")
|
|
156
|
+
p.add_argument("--force", action="store_true",
|
|
157
|
+
help="Ignore cache and re-process all files")
|
|
158
|
+
p.add_argument("--skip-ocr", action="store_true",
|
|
159
|
+
help="Only process files with a real text layer; skip OCR")
|
|
160
|
+
p.add_argument("--no-cleanup", action="store_true",
|
|
161
|
+
help="Skip whitespace / ligature cleanup of OCR output")
|
|
162
|
+
p.add_argument("--vision-api-url", default="",
|
|
163
|
+
help="OpenAI-compatible base URL for --engine vision-api")
|
|
164
|
+
p.add_argument("--vision-api-key", default="",
|
|
165
|
+
help="API key for --engine vision-api (required; env vars are not read)")
|
|
166
|
+
p.add_argument("--vision-model", default="",
|
|
167
|
+
help="Model name for --engine vision-api (required; no default)")
|
|
168
|
+
p.add_argument("--searchable-pdf", default="",
|
|
169
|
+
help="Path for searchable PDF output (requires ocrmypdf)")
|
|
170
|
+
p.add_argument("--json-report", default="",
|
|
171
|
+
help="Path to write JSON quality report")
|
|
172
|
+
p.add_argument("--verbose", "-v", action="store_true",
|
|
173
|
+
help="Verbose logging to stderr")
|
|
174
|
+
return p
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
# ── entry point ───────────────────────────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
def run(argv: list[str] | None = None) -> None:
|
|
180
|
+
global caps_global
|
|
181
|
+
parser = build_parser()
|
|
182
|
+
args = parser.parse_args(argv)
|
|
183
|
+
|
|
184
|
+
options = RecognizeOptions(
|
|
185
|
+
engine=args.engine,
|
|
186
|
+
lang=args.lang,
|
|
187
|
+
dpi=args.dpi,
|
|
188
|
+
preprocess=args.preprocess,
|
|
189
|
+
pages=args.pages,
|
|
190
|
+
max_pages=args.max_pages,
|
|
191
|
+
psm=args.psm,
|
|
192
|
+
min_conf=args.min_conf,
|
|
193
|
+
no_cleanup=args.no_cleanup,
|
|
194
|
+
force=args.force,
|
|
195
|
+
vision_api_url=args.vision_api_url,
|
|
196
|
+
vision_api_key=args.vision_api_key,
|
|
197
|
+
vision_model=args.vision_model,
|
|
198
|
+
verbose=args.verbose,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
caps = Caps(verbose=args.verbose)
|
|
202
|
+
caps_global = caps
|
|
203
|
+
|
|
204
|
+
# Validate binary requirements for the chosen engine
|
|
205
|
+
if args.engine in ("auto", "tesseract"):
|
|
206
|
+
caps.require_ocr()
|
|
207
|
+
|
|
208
|
+
cache = Cache(args.cache if args.cache else None)
|
|
209
|
+
|
|
210
|
+
# Vision handoff must persist rendered PNGs after this process exits so the
|
|
211
|
+
# calling agent (Claude, GPT, or any multimodal model) can read them.
|
|
212
|
+
# Other engines use a throwaway temp dir.
|
|
213
|
+
if args.engine == "vision":
|
|
214
|
+
tmpdir = tempfile.mkdtemp(prefix="ocr_skill_vision_")
|
|
215
|
+
print(f"[ocr] vision PNGs will persist in: {tmpdir}", file=sys.stderr)
|
|
216
|
+
cleanup_tmp = False
|
|
217
|
+
else:
|
|
218
|
+
tmp_ctx = tempfile.TemporaryDirectory(prefix="ocr_skill_")
|
|
219
|
+
tmpdir = tmp_ctx.name
|
|
220
|
+
cleanup_tmp = True
|
|
221
|
+
|
|
222
|
+
try:
|
|
223
|
+
for input_path in args.inputs:
|
|
224
|
+
if not os.path.exists(input_path):
|
|
225
|
+
print(f"[ocr] WARNING: file not found: {input_path}", file=sys.stderr)
|
|
226
|
+
continue
|
|
227
|
+
|
|
228
|
+
_log(f"processing: {input_path}", args.verbose)
|
|
229
|
+
t_start = time.time()
|
|
230
|
+
|
|
231
|
+
pages_data = process_file(input_path, options, caps, cache, tmpdir)
|
|
232
|
+
|
|
233
|
+
# Resolve effective lang for output meta (might have been auto-detected)
|
|
234
|
+
effective_lang = args.lang
|
|
235
|
+
if effective_lang == "auto" and pages_data:
|
|
236
|
+
# Best we can do without re-running OSD
|
|
237
|
+
effective_lang = "auto-detected"
|
|
238
|
+
|
|
239
|
+
effective_dpi = args.dpi or 0
|
|
240
|
+
|
|
241
|
+
write_outputs(pages_data, input_path, args, effective_lang, effective_dpi)
|
|
242
|
+
|
|
243
|
+
elapsed = time.time() - t_start
|
|
244
|
+
total_chars = sum(len(p.get("text", "")) for p in pages_data)
|
|
245
|
+
_log(f"done: {len(pages_data)} pages, {total_chars} chars, {elapsed:.1f}s total",
|
|
246
|
+
args.verbose)
|
|
247
|
+
finally:
|
|
248
|
+
if cleanup_tmp:
|
|
249
|
+
tmp_ctx.cleanup()
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def main(argv: list[str] | None = None) -> int:
|
|
253
|
+
"""Console-script entry point. Returns a process exit code."""
|
|
254
|
+
try:
|
|
255
|
+
run(argv)
|
|
256
|
+
except OcrError as exc:
|
|
257
|
+
print(f"[ocr] ERROR: {exc}", file=sys.stderr)
|
|
258
|
+
return exc.code
|
|
259
|
+
return 0
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def probe_main(argv: list[str] | None = None) -> int:
|
|
263
|
+
"""Console-script entry point (`ocr-probe`): run the bundled probe.sh helper.
|
|
264
|
+
|
|
265
|
+
Triages a single file for OCR need and prints one-line JSON to stdout.
|
|
266
|
+
"""
|
|
267
|
+
import subprocess
|
|
268
|
+
|
|
269
|
+
from .core import probe_script_path
|
|
270
|
+
|
|
271
|
+
args = list(sys.argv[1:] if argv is None else argv)
|
|
272
|
+
return subprocess.run(["bash", probe_script_path(), *args]).returncode
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
if __name__ == "__main__":
|
|
276
|
+
sys.exit(main())
|