omnius 1.0.639 → 1.0.641
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 +1512 -887
- package/dist/scripts/audio-speaker-embedding-worker.py +137 -31
- package/dist/scripts/live-whisper.py +41 -0
- package/dist/scripts/ocr-advanced.py +401 -139
- package/dist/scripts/transcribe-file.py +61 -6
- package/dist/update-worker.js +0 -1
- package/docs/DISCOVERY.json +33 -6
- package/docs/rest/endpoints/chat.md +6 -1
- package/docs/rest/endpoints/voice-vision.md +34 -23
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
|
@@ -37,6 +37,9 @@ import os
|
|
|
37
37
|
import json
|
|
38
38
|
import csv
|
|
39
39
|
import argparse
|
|
40
|
+
import signal
|
|
41
|
+
import time
|
|
42
|
+
from collections import OrderedDict
|
|
40
43
|
from pathlib import Path
|
|
41
44
|
|
|
42
45
|
def check_deps():
|
|
@@ -168,45 +171,175 @@ PSM_MODES = {
|
|
|
168
171
|
11: "sparse",
|
|
169
172
|
}
|
|
170
173
|
|
|
174
|
+
# Advanced OCR is a bounded recovery pipeline, not a blind Cartesian product.
|
|
175
|
+
# A 327x333 crop previously paid for 24 variants/PSMs and two Tesseract child
|
|
176
|
+
# processes per attempt. Small inputs now begin with two high-yield attempts;
|
|
177
|
+
# larger/low-evidence images expand only while budget remains.
|
|
178
|
+
SMALL_CROP_AREA_PX = 512_000
|
|
179
|
+
MEDIUM_IMAGE_AREA_PX = 2_000_000
|
|
180
|
+
MAX_PIPELINE_DEADLINE_MS = 80_000
|
|
181
|
+
MAX_TESSERACT_ATTEMPT_SECONDS = 12.0
|
|
182
|
+
MIN_TESSERACT_ATTEMPT_SECONDS = 0.25
|
|
183
|
+
MIN_ACCEPTED_CONFIDENCE = 50.0
|
|
184
|
+
MIN_SUBSTANTIVE_TEXT_CHARS = 12
|
|
185
|
+
HIGH_VOLUME_GARBAGE_CHARS = 64
|
|
186
|
+
HIGH_VOLUME_GARBAGE_CONFIDENCE = 35.0
|
|
187
|
+
ACTIVE_DEADLINE = None
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class OcrPipelineTimeout(RuntimeError):
|
|
191
|
+
pass
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class OcrPipelineCancelled(RuntimeError):
|
|
195
|
+
pass
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def cancellation_signal_handler(signum, _frame):
|
|
199
|
+
raise OcrPipelineCancelled(f"received signal {signum}")
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class OcrDeadline:
|
|
203
|
+
def __init__(self, deadline_ms):
|
|
204
|
+
self.deadline_ms = max(1_000, min(int(deadline_ms), MAX_PIPELINE_DEADLINE_MS))
|
|
205
|
+
self.started = time.monotonic()
|
|
206
|
+
|
|
207
|
+
def remaining_seconds(self):
|
|
208
|
+
return self.deadline_ms / 1000.0 - (time.monotonic() - self.started)
|
|
209
|
+
|
|
210
|
+
def check(self, stage):
|
|
211
|
+
if self.remaining_seconds() <= 0:
|
|
212
|
+
raise OcrPipelineTimeout(f"OCR deadline exceeded during {stage}")
|
|
213
|
+
|
|
214
|
+
def tesseract_timeout_seconds(self):
|
|
215
|
+
self.check("Tesseract scheduling")
|
|
216
|
+
return max(
|
|
217
|
+
MIN_TESSERACT_ATTEMPT_SECONDS,
|
|
218
|
+
min(MAX_TESSERACT_ATTEMPT_SECONDS, self.remaining_seconds()),
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def diagnostic(code, message, deadline, stage, attempts_completed=0, attempts_planned=0):
|
|
223
|
+
return {
|
|
224
|
+
"schema": "omnius.ocr-diagnostic.v1",
|
|
225
|
+
"code": code,
|
|
226
|
+
"message": message,
|
|
227
|
+
"stage": stage,
|
|
228
|
+
"deadline_ms": deadline.deadline_ms,
|
|
229
|
+
"attempts_completed": attempts_completed,
|
|
230
|
+
"attempts_planned": attempts_planned,
|
|
231
|
+
}
|
|
232
|
+
|
|
171
233
|
|
|
172
234
|
# ---------------------------------------------------------------------------
|
|
173
235
|
# OCR execution
|
|
174
236
|
# ---------------------------------------------------------------------------
|
|
175
237
|
|
|
176
|
-
def
|
|
177
|
-
"""
|
|
178
|
-
|
|
238
|
+
def text_from_tesseract_data(data):
|
|
239
|
+
"""Reconstruct line breaks from one TSV pass; do not spawn Tesseract twice."""
|
|
240
|
+
lines = OrderedDict()
|
|
241
|
+
texts = data.get("text", [])
|
|
242
|
+
for index, raw_text in enumerate(texts):
|
|
243
|
+
text = str(raw_text or "").strip()
|
|
244
|
+
if not text:
|
|
245
|
+
continue
|
|
246
|
+
key = tuple(
|
|
247
|
+
int(data.get(field, [0] * len(texts))[index] or 0)
|
|
248
|
+
for field in ("block_num", "par_num", "line_num")
|
|
249
|
+
)
|
|
250
|
+
lines.setdefault(key, []).append(text)
|
|
251
|
+
return "\n".join(" ".join(words) for words in lines.values()).strip()
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def run_tesseract(binary_img, deadline, language="eng", psm=6):
|
|
255
|
+
"""Run one bounded Tesseract TSV pass and derive text plus confidence."""
|
|
256
|
+
deadline.check("Tesseract")
|
|
179
257
|
pil_img = Image.fromarray(binary_img)
|
|
180
258
|
config = f"--psm {psm}"
|
|
259
|
+
data = pytesseract.image_to_data(
|
|
260
|
+
pil_img,
|
|
261
|
+
lang=language,
|
|
262
|
+
config=config,
|
|
263
|
+
output_type=pytesseract.Output.DICT,
|
|
264
|
+
timeout=deadline.tesseract_timeout_seconds(),
|
|
265
|
+
)
|
|
266
|
+
text = text_from_tesseract_data(data)
|
|
267
|
+
confs = []
|
|
268
|
+
for value in data.get("conf", []):
|
|
269
|
+
try:
|
|
270
|
+
confidence = float(value)
|
|
271
|
+
except (TypeError, ValueError):
|
|
272
|
+
continue
|
|
273
|
+
if confidence >= 0:
|
|
274
|
+
confs.append(confidence)
|
|
275
|
+
avg_conf = sum(confs) / len(confs) if confs else 0.0
|
|
276
|
+
line_count = len([line for line in text.split("\n") if line.strip()])
|
|
277
|
+
return text, avg_conf, line_count
|
|
181
278
|
|
|
182
|
-
# Engine failures are not blank documents. Let callers distinguish a real
|
|
183
|
-
# low-information image from a missing language pack or broken Tesseract.
|
|
184
|
-
text = pytesseract.image_to_string(pil_img, lang=language, config=config).strip()
|
|
185
|
-
|
|
186
|
-
line_count = len([l for l in text.split("\n") if l.strip()])
|
|
187
279
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
280
|
+
def assess_ocr_evidence(text, confidence, line_count):
|
|
281
|
+
"""Classify OCR output before it can become agent-visible evidence."""
|
|
282
|
+
normalized = str(text or "").strip()
|
|
283
|
+
chars = len(normalized)
|
|
284
|
+
if chars == 0:
|
|
285
|
+
return {
|
|
286
|
+
"state": "low_information",
|
|
287
|
+
"accepted": False,
|
|
288
|
+
"reason": "no_readable_text",
|
|
289
|
+
"chars": 0,
|
|
290
|
+
"confidence": round(float(confidence), 1),
|
|
291
|
+
"lines": int(line_count),
|
|
292
|
+
}
|
|
293
|
+
alnum_ratio = sum(character.isalnum() for character in normalized) / max(1, chars)
|
|
294
|
+
if chars < MIN_SUBSTANTIVE_TEXT_CHARS and confidence < MIN_ACCEPTED_CONFIDENCE:
|
|
295
|
+
return {
|
|
296
|
+
"state": "low_information",
|
|
297
|
+
"accepted": False,
|
|
298
|
+
"reason": "insufficient_low_confidence_text",
|
|
299
|
+
"chars": chars,
|
|
300
|
+
"confidence": round(float(confidence), 1),
|
|
301
|
+
"lines": int(line_count),
|
|
302
|
+
"alnum_ratio": round(alnum_ratio, 3),
|
|
303
|
+
}
|
|
304
|
+
if (
|
|
305
|
+
(chars >= HIGH_VOLUME_GARBAGE_CHARS and confidence < HIGH_VOLUME_GARBAGE_CONFIDENCE)
|
|
306
|
+
or confidence < MIN_ACCEPTED_CONFIDENCE
|
|
307
|
+
or alnum_ratio < 0.45
|
|
308
|
+
):
|
|
309
|
+
reason = (
|
|
310
|
+
"high_volume_low_confidence_text"
|
|
311
|
+
if chars >= HIGH_VOLUME_GARBAGE_CHARS and confidence < HIGH_VOLUME_GARBAGE_CONFIDENCE
|
|
312
|
+
else "low_confidence_or_symbol_heavy_text"
|
|
193
313
|
)
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
314
|
+
return {
|
|
315
|
+
"state": "rejected",
|
|
316
|
+
"accepted": False,
|
|
317
|
+
"reason": reason,
|
|
318
|
+
"chars": chars,
|
|
319
|
+
"confidence": round(float(confidence), 1),
|
|
320
|
+
"lines": int(line_count),
|
|
321
|
+
"alnum_ratio": round(alnum_ratio, 3),
|
|
322
|
+
}
|
|
323
|
+
return {
|
|
324
|
+
"state": "accepted",
|
|
325
|
+
"accepted": True,
|
|
326
|
+
"reason": "confidence_and_text_quality_met",
|
|
327
|
+
"chars": chars,
|
|
328
|
+
"confidence": round(float(confidence), 1),
|
|
329
|
+
"lines": int(line_count),
|
|
330
|
+
"alnum_ratio": round(alnum_ratio, 3),
|
|
331
|
+
}
|
|
200
332
|
|
|
201
333
|
|
|
202
|
-
def compute_score(text, confidence, line_count):
|
|
334
|
+
def compute_score(text, confidence, line_count, evidence=None):
|
|
203
335
|
"""Combined scoring heuristic:
|
|
204
336
|
- confidence * sqrt(char_count) — rewards quality and coverage
|
|
205
337
|
- + line_count * 10 — bonus for structured output (more lines = better parse)
|
|
206
338
|
The agent discovered that line-count is a strong proxy for successful parsing
|
|
207
339
|
on structured documents like invoices and forms."""
|
|
340
|
+
quality = evidence or assess_ocr_evidence(text, confidence, line_count)
|
|
208
341
|
char_count = len(text)
|
|
209
|
-
if char_count == 0:
|
|
342
|
+
if not quality["accepted"] or char_count == 0:
|
|
210
343
|
return 0
|
|
211
344
|
return confidence * (char_count ** 0.5) + line_count * 10
|
|
212
345
|
|
|
@@ -226,6 +359,29 @@ def extract_pixel_region(gray, x, y, w, h):
|
|
|
226
359
|
return gray[y:y+h, x:x+w]
|
|
227
360
|
|
|
228
361
|
|
|
362
|
+
def build_ocr_plan(image_area_px, single_psm=None):
|
|
363
|
+
"""Return the smallest credible variant/PSM plan for the effective image."""
|
|
364
|
+
psm_modes = [single_psm] if single_psm else (
|
|
365
|
+
[6] if image_area_px <= SMALL_CROP_AREA_PX
|
|
366
|
+
else [6, 11] if image_area_px <= MEDIUM_IMAGE_AREA_PX
|
|
367
|
+
else [6, 11, 4]
|
|
368
|
+
)
|
|
369
|
+
variants = (
|
|
370
|
+
["otsu", "adaptive_fine"] if image_area_px <= SMALL_CROP_AREA_PX
|
|
371
|
+
else ["otsu", "adaptive_fine", "denoise", "sharpen_unsharp"]
|
|
372
|
+
if image_area_px <= MEDIUM_IMAGE_AREA_PX
|
|
373
|
+
else list(ALL_VARIANTS.keys())
|
|
374
|
+
)
|
|
375
|
+
# OTSU/PSM6 is deliberately first: a legible result ends the search
|
|
376
|
+
# instead of paying for redundant variants that cannot improve the answer.
|
|
377
|
+
return [(variant, psm) for psm in psm_modes for variant in variants]
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def has_sufficient_evidence(text, confidence, line_count):
|
|
381
|
+
quality = assess_ocr_evidence(text, confidence, line_count)
|
|
382
|
+
return quality["accepted"] and confidence >= 70.0 and line_count >= 1
|
|
383
|
+
|
|
384
|
+
|
|
229
385
|
# ---------------------------------------------------------------------------
|
|
230
386
|
# Output writers
|
|
231
387
|
# ---------------------------------------------------------------------------
|
|
@@ -295,142 +451,208 @@ def write_all_outputs(text, base_name, output_dir):
|
|
|
295
451
|
# Main pipeline
|
|
296
452
|
# ---------------------------------------------------------------------------
|
|
297
453
|
|
|
298
|
-
def
|
|
299
|
-
|
|
300
|
-
"""Run the full multi-variant, multi-PSM OCR pipeline."""
|
|
301
|
-
|
|
302
|
-
# Load image
|
|
303
|
-
img = cv2.imread(image_path)
|
|
304
|
-
if img is None:
|
|
305
|
-
return {"error": f"Could not load image: {image_path}"}
|
|
306
|
-
|
|
307
|
-
h_orig, w_orig = img.shape[:2]
|
|
308
|
-
gray = to_grayscale(img)
|
|
309
|
-
|
|
310
|
-
# Upscale 2x
|
|
311
|
-
gray_2x = upscale_2x(gray)
|
|
312
|
-
|
|
313
|
-
# If a pixel region is specified, crop before processing
|
|
314
|
-
if pixel_region:
|
|
315
|
-
rx, ry, rw, rh = pixel_region
|
|
316
|
-
# Scale region coords to match 2x upscale
|
|
317
|
-
gray_2x = extract_pixel_region(gray_2x, rx * 2, ry * 2, rw * 2, rh * 2)
|
|
318
|
-
|
|
319
|
-
# Determine PSM modes to test
|
|
320
|
-
psm_modes = [single_psm] if single_psm else [4, 6, 11]
|
|
321
|
-
|
|
322
|
-
# Generate all variants and run OCR
|
|
454
|
+
def run_variant_plan(gray, plan, deadline, language, debug_dir=None, debug_prefix="full"):
|
|
455
|
+
"""Run a bounded plan, stopping early once a legible result is proven."""
|
|
323
456
|
all_results = {}
|
|
324
457
|
ocr_errors = []
|
|
325
458
|
best_key = None
|
|
326
459
|
best_score = -1
|
|
460
|
+
early_exit = False
|
|
327
461
|
|
|
328
|
-
for vname,
|
|
462
|
+
for vname, psm in plan:
|
|
463
|
+
deadline.check("preprocessing")
|
|
464
|
+
key = f"{vname}_psm{psm}"
|
|
329
465
|
try:
|
|
330
|
-
binary =
|
|
331
|
-
except Exception:
|
|
466
|
+
binary = ALL_VARIANTS[vname](gray)
|
|
467
|
+
except Exception as error:
|
|
468
|
+
ocr_errors.append(f"{key}: preprocessing failed: {error}")
|
|
332
469
|
continue
|
|
333
|
-
|
|
334
|
-
# Save debug images
|
|
335
470
|
if debug_dir:
|
|
336
471
|
os.makedirs(debug_dir, exist_ok=True)
|
|
337
|
-
cv2.imwrite(os.path.join(debug_dir, f"
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
if score > best_score:
|
|
358
|
-
best_score = score
|
|
359
|
-
best_key = key
|
|
360
|
-
|
|
361
|
-
if not best_key:
|
|
362
|
-
detail = ocr_errors[-1] if ocr_errors else "no preprocessing variant completed"
|
|
363
|
-
return {"error": f"Tesseract failed for every OCR variant: {detail}"}
|
|
364
|
-
|
|
365
|
-
best = all_results[best_key]
|
|
366
|
-
result = {
|
|
367
|
-
"text": best["text"],
|
|
368
|
-
"confidence": best["confidence"],
|
|
369
|
-
"variant": best_key,
|
|
370
|
-
"chars": best["chars"],
|
|
371
|
-
"lines": best["lines"],
|
|
372
|
-
"score": best["score"],
|
|
373
|
-
"image_size": f"{w_orig}x{h_orig}",
|
|
374
|
-
"variants_tested": len(all_results),
|
|
375
|
-
"all_variants": all_results,
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
# Region-based OCR
|
|
379
|
-
if do_regions:
|
|
380
|
-
regions = {}
|
|
381
|
-
region_defs = {
|
|
382
|
-
"header": (0, 35),
|
|
383
|
-
"body": (30, 80),
|
|
384
|
-
"footer": (75, 100),
|
|
472
|
+
cv2.imwrite(os.path.join(debug_dir, f"{debug_prefix}_{vname}.png"), binary)
|
|
473
|
+
try:
|
|
474
|
+
text, confidence, line_count = run_tesseract(binary, deadline, language, psm)
|
|
475
|
+
except OcrPipelineTimeout:
|
|
476
|
+
raise
|
|
477
|
+
except OcrPipelineCancelled:
|
|
478
|
+
raise
|
|
479
|
+
except Exception as error:
|
|
480
|
+
ocr_errors.append(f"{key}: {error}")
|
|
481
|
+
continue
|
|
482
|
+
char_count = len(text)
|
|
483
|
+
evidence = assess_ocr_evidence(text, confidence, line_count)
|
|
484
|
+
score = compute_score(text, confidence, line_count, evidence)
|
|
485
|
+
all_results[key] = {
|
|
486
|
+
"text": text,
|
|
487
|
+
"chars": char_count,
|
|
488
|
+
"lines": line_count,
|
|
489
|
+
"confidence": round(confidence, 1),
|
|
490
|
+
"score": round(score, 1),
|
|
491
|
+
"evidence": evidence,
|
|
385
492
|
}
|
|
493
|
+
if evidence["accepted"] and score > best_score:
|
|
494
|
+
best_score = score
|
|
495
|
+
best_key = key
|
|
496
|
+
if has_sufficient_evidence(text, confidence, line_count):
|
|
497
|
+
early_exit = True
|
|
498
|
+
break
|
|
386
499
|
|
|
387
|
-
|
|
388
|
-
region_gray = extract_region(gray_2x, y_start, y_end)
|
|
389
|
-
|
|
390
|
-
if debug_dir:
|
|
391
|
-
cv2.imwrite(os.path.join(debug_dir, f"region_{rname}.png"), region_gray)
|
|
392
|
-
|
|
393
|
-
# Test all variants on each region for best accuracy
|
|
394
|
-
region_best = ""
|
|
395
|
-
region_best_score = -1
|
|
396
|
-
|
|
397
|
-
for vname in ["otsu", "denoise", "adaptive_fine", "sharpen_unsharp"]:
|
|
398
|
-
if vname not in ALL_VARIANTS:
|
|
399
|
-
continue
|
|
400
|
-
try:
|
|
401
|
-
binary = ALL_VARIANTS[vname](region_gray)
|
|
402
|
-
except Exception:
|
|
403
|
-
continue
|
|
500
|
+
return all_results, ocr_errors, best_key, early_exit
|
|
404
501
|
|
|
405
|
-
if debug_dir:
|
|
406
|
-
cv2.imwrite(os.path.join(debug_dir, f"region_{rname}_{vname}.png"), binary)
|
|
407
502
|
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
region_best = text
|
|
416
|
-
|
|
417
|
-
regions[rname] = region_best
|
|
503
|
+
def run_pipeline(image_path, deadline, language="eng", do_regions=False, debug_dir=None,
|
|
504
|
+
single_psm=None, pixel_region=None, output_dir=None):
|
|
505
|
+
"""Run bounded OCR. The effective crop controls plan size, not the full frame."""
|
|
506
|
+
deadline.check("image load")
|
|
507
|
+
img = cv2.imread(image_path)
|
|
508
|
+
if img is None:
|
|
509
|
+
return {"error": f"Could not load image: {image_path}"}
|
|
418
510
|
|
|
419
|
-
|
|
511
|
+
h_orig, w_orig = img.shape[:2]
|
|
512
|
+
gray = to_grayscale(img)
|
|
513
|
+
if pixel_region:
|
|
514
|
+
rx, ry, rw, rh = pixel_region
|
|
515
|
+
if rx >= w_orig or ry >= h_orig:
|
|
516
|
+
return {"error": "Requested OCR region lies outside the image"}
|
|
517
|
+
gray = extract_pixel_region(gray, rx, ry, rw, rh)
|
|
518
|
+
if gray.size == 0:
|
|
519
|
+
return {"error": "Requested OCR region is empty"}
|
|
520
|
+
|
|
521
|
+
effective_area = int(gray.shape[0] * gray.shape[1])
|
|
522
|
+
deadline.check("image conditioning")
|
|
523
|
+
gray_2x = upscale_2x(gray)
|
|
524
|
+
plan = build_ocr_plan(effective_area, single_psm)
|
|
525
|
+
attempts_completed = 0
|
|
526
|
+
try:
|
|
527
|
+
all_results, ocr_errors, best_key, early_exit = run_variant_plan(
|
|
528
|
+
gray_2x, plan, deadline, language, debug_dir
|
|
529
|
+
)
|
|
530
|
+
attempts_completed = len(all_results)
|
|
531
|
+
if not best_key:
|
|
532
|
+
if not all_results:
|
|
533
|
+
detail = ocr_errors[-1] if ocr_errors else "no preprocessing variant completed"
|
|
534
|
+
return {"error": f"Tesseract failed for every bounded OCR attempt: {detail}"}
|
|
535
|
+
rejected = [item for item in all_results.values() if item["evidence"]["state"] == "rejected"]
|
|
536
|
+
if rejected:
|
|
537
|
+
worst = max(rejected, key=lambda item: (item["chars"], -item["confidence"]))
|
|
538
|
+
evidence = worst["evidence"]
|
|
539
|
+
message = (
|
|
540
|
+
"OCR text was suppressed because it did not meet evidence-quality requirements "
|
|
541
|
+
f"({evidence['reason']}; {evidence['chars']} chars at {evidence['confidence']}% confidence)."
|
|
542
|
+
)
|
|
543
|
+
return {
|
|
544
|
+
"error": message,
|
|
545
|
+
"diagnostic": {
|
|
546
|
+
**diagnostic("ocr_evidence_rejected", message, deadline, "evidence_quality", attempts_completed, len(plan)),
|
|
547
|
+
"evidence": evidence,
|
|
548
|
+
},
|
|
549
|
+
}
|
|
550
|
+
# Empty or tiny non-substantive detections are valid observations:
|
|
551
|
+
# do not invent text and do not report them as a pipeline error.
|
|
552
|
+
result = {
|
|
553
|
+
"text": "",
|
|
554
|
+
"confidence": 0.0,
|
|
555
|
+
"variant": "none",
|
|
556
|
+
"chars": 0,
|
|
557
|
+
"lines": 0,
|
|
558
|
+
"score": 0.0,
|
|
559
|
+
"image_size": f"{w_orig}x{h_orig}",
|
|
560
|
+
"variants_tested": len(all_results),
|
|
561
|
+
"all_variants": {},
|
|
562
|
+
"quality": {
|
|
563
|
+
"schema": "omnius.ocr-evidence.v1",
|
|
564
|
+
"state": "low_information",
|
|
565
|
+
"accepted": False,
|
|
566
|
+
"reason": "no_accepted_readable_text",
|
|
567
|
+
"low_information_variants": len(all_results),
|
|
568
|
+
},
|
|
569
|
+
"diagnostic": diagnostic(
|
|
570
|
+
"ocr_low_information",
|
|
571
|
+
"OCR produced no accepted readable text; the result is low-information rather than evidence.",
|
|
572
|
+
deadline,
|
|
573
|
+
"evidence_quality",
|
|
574
|
+
attempts_completed,
|
|
575
|
+
len(plan),
|
|
576
|
+
),
|
|
577
|
+
"strategy": {
|
|
578
|
+
"effective_area_px": effective_area,
|
|
579
|
+
"attempts_planned": len(plan),
|
|
580
|
+
"attempts_completed": attempts_completed,
|
|
581
|
+
"early_exit": False,
|
|
582
|
+
"deadline_ms": deadline.deadline_ms,
|
|
583
|
+
},
|
|
584
|
+
}
|
|
585
|
+
return result
|
|
420
586
|
|
|
421
|
-
|
|
422
|
-
|
|
587
|
+
best = all_results[best_key]
|
|
588
|
+
accepted_results = {
|
|
589
|
+
key: value for key, value in all_results.items()
|
|
590
|
+
if value["evidence"]["state"] == "accepted"
|
|
591
|
+
}
|
|
592
|
+
result = {
|
|
593
|
+
"text": best["text"],
|
|
594
|
+
"confidence": best["confidence"],
|
|
595
|
+
"variant": best_key,
|
|
596
|
+
"chars": best["chars"],
|
|
597
|
+
"lines": best["lines"],
|
|
598
|
+
"score": best["score"],
|
|
599
|
+
"image_size": f"{w_orig}x{h_orig}",
|
|
600
|
+
"variants_tested": len(all_results),
|
|
601
|
+
# Never leak rejected raw OCR as alternate evidence to agents.
|
|
602
|
+
"all_variants": accepted_results,
|
|
603
|
+
"quality": {
|
|
604
|
+
"schema": "omnius.ocr-evidence.v1",
|
|
605
|
+
**best["evidence"],
|
|
606
|
+
"rejected_variants": sum(
|
|
607
|
+
1 for item in all_results.values() if item["evidence"]["state"] == "rejected"
|
|
608
|
+
),
|
|
609
|
+
"low_information_variants": sum(
|
|
610
|
+
1 for item in all_results.values() if item["evidence"]["state"] == "low_information"
|
|
611
|
+
),
|
|
612
|
+
},
|
|
613
|
+
"strategy": {
|
|
614
|
+
"effective_area_px": effective_area,
|
|
615
|
+
"attempts_planned": len(plan),
|
|
616
|
+
"attempts_completed": attempts_completed,
|
|
617
|
+
"early_exit": early_exit,
|
|
618
|
+
"deadline_ms": deadline.deadline_ms,
|
|
619
|
+
},
|
|
620
|
+
}
|
|
423
621
|
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
622
|
+
if do_regions:
|
|
623
|
+
regions = {}
|
|
624
|
+
region_defs = {"header": (0, 35), "body": (30, 80), "footer": (75, 100)}
|
|
625
|
+
for rname, (y_start, y_end) in region_defs.items():
|
|
626
|
+
deadline.check(f"region {rname}")
|
|
627
|
+
region_gray = extract_region(gray_2x, y_start, y_end)
|
|
628
|
+
# Region requests use at most two high-yield attempts. The
|
|
629
|
+
# main result already provides full-frame coverage.
|
|
630
|
+
region_plan = build_ocr_plan(region_gray.shape[0] * region_gray.shape[1], single_psm)[:2]
|
|
631
|
+
region_results, _errors, region_best_key, _early_exit = run_variant_plan(
|
|
632
|
+
region_gray, region_plan, deadline, language, debug_dir, f"region_{rname}"
|
|
633
|
+
)
|
|
634
|
+
regions[rname] = region_results[region_best_key]["text"] if region_best_key else ""
|
|
635
|
+
result["regions"] = regions
|
|
429
636
|
|
|
430
|
-
|
|
637
|
+
if debug_dir:
|
|
638
|
+
result["debug_dir"] = debug_dir
|
|
639
|
+
if output_dir:
|
|
640
|
+
base_name = Path(image_path).stem
|
|
641
|
+
result["output_files"] = write_all_outputs(best["text"], base_name, output_dir)
|
|
642
|
+
return result
|
|
643
|
+
except OcrPipelineTimeout as error:
|
|
644
|
+
return {
|
|
645
|
+
"error": str(error),
|
|
646
|
+
"diagnostic": diagnostic("ocr_timeout", str(error), deadline, "pipeline", attempts_completed, len(plan)),
|
|
647
|
+
}
|
|
648
|
+
except OcrPipelineCancelled as error:
|
|
649
|
+
return {
|
|
650
|
+
"error": "Advanced OCR cancelled",
|
|
651
|
+
"diagnostic": diagnostic("ocr_cancelled", str(error), deadline, "pipeline", attempts_completed, len(plan)),
|
|
652
|
+
}
|
|
431
653
|
|
|
432
654
|
|
|
433
|
-
def run_batch(images_dir, language="eng", do_regions=False, debug_dir=None,
|
|
655
|
+
def run_batch(images_dir, deadline, language="eng", do_regions=False, debug_dir=None,
|
|
434
656
|
output_dir=None):
|
|
435
657
|
"""Process all images in a directory."""
|
|
436
658
|
images_dir = os.path.abspath(images_dir)
|
|
@@ -450,10 +672,18 @@ def run_batch(images_dir, language="eng", do_regions=False, debug_dir=None,
|
|
|
450
672
|
return {"error": f"No image files found in {images_dir}"}
|
|
451
673
|
|
|
452
674
|
for img_file in image_files:
|
|
675
|
+
try:
|
|
676
|
+
deadline.check("batch scheduling")
|
|
677
|
+
except OcrPipelineTimeout as error:
|
|
678
|
+
return {
|
|
679
|
+
"error": str(error),
|
|
680
|
+
"diagnostic": diagnostic("ocr_timeout", str(error), deadline, "batch", len(batch_results), len(image_files)),
|
|
681
|
+
}
|
|
453
682
|
img_path = os.path.join(images_dir, img_file)
|
|
454
683
|
img_debug = os.path.join(debug_dir, Path(img_file).stem) if debug_dir else None
|
|
455
684
|
result = run_pipeline(
|
|
456
685
|
img_path,
|
|
686
|
+
deadline,
|
|
457
687
|
language=language,
|
|
458
688
|
do_regions=do_regions,
|
|
459
689
|
debug_dir=img_debug,
|
|
@@ -469,6 +699,14 @@ def run_batch(images_dir, language="eng", do_regions=False, debug_dir=None,
|
|
|
469
699
|
"output_files": result.get("output_files"),
|
|
470
700
|
"error": result.get("error"),
|
|
471
701
|
}
|
|
702
|
+
if result.get("diagnostic", {}).get("code") in {"ocr_timeout", "ocr_cancelled"}:
|
|
703
|
+
return {
|
|
704
|
+
"error": result["error"],
|
|
705
|
+
"diagnostic": result["diagnostic"],
|
|
706
|
+
"batch": True,
|
|
707
|
+
"images_processed": len(batch_results),
|
|
708
|
+
"results": batch_results,
|
|
709
|
+
}
|
|
472
710
|
|
|
473
711
|
# Write summary
|
|
474
712
|
summary_path = os.path.join(out_dir, "OCR_PROCESSING_SUMMARY.md")
|
|
@@ -497,6 +735,7 @@ def run_batch(images_dir, language="eng", do_regions=False, debug_dir=None,
|
|
|
497
735
|
|
|
498
736
|
|
|
499
737
|
def main():
|
|
738
|
+
global ACTIVE_DEADLINE
|
|
500
739
|
parser = argparse.ArgumentParser(
|
|
501
740
|
description="Advanced multi-variant OCR pipeline for omnius"
|
|
502
741
|
)
|
|
@@ -520,13 +759,20 @@ def main():
|
|
|
520
759
|
help="Write TXT + CSV + PDF outputs to this directory")
|
|
521
760
|
parser.add_argument("--batch", action="store_true",
|
|
522
761
|
help="Process all images in a directory")
|
|
762
|
+
parser.add_argument("--deadline-ms", type=int, default=MAX_PIPELINE_DEADLINE_MS,
|
|
763
|
+
help="Bound total OCR work; clamped to the managed maximum")
|
|
523
764
|
|
|
524
765
|
args = parser.parse_args()
|
|
766
|
+
deadline = OcrDeadline(args.deadline_ms)
|
|
767
|
+
ACTIVE_DEADLINE = deadline
|
|
768
|
+
signal.signal(signal.SIGTERM, cancellation_signal_handler)
|
|
769
|
+
signal.signal(signal.SIGINT, cancellation_signal_handler)
|
|
525
770
|
|
|
526
771
|
# Batch mode
|
|
527
772
|
if args.batch or os.path.isdir(args.image):
|
|
528
773
|
result = run_batch(
|
|
529
774
|
args.image,
|
|
775
|
+
deadline,
|
|
530
776
|
language=args.language,
|
|
531
777
|
do_regions=args.regions,
|
|
532
778
|
debug_dir=args.debug_dir,
|
|
@@ -558,6 +804,7 @@ def main():
|
|
|
558
804
|
|
|
559
805
|
result = run_pipeline(
|
|
560
806
|
args.image,
|
|
807
|
+
deadline,
|
|
561
808
|
language=args.language,
|
|
562
809
|
do_regions=args.regions,
|
|
563
810
|
debug_dir=args.debug_dir,
|
|
@@ -578,4 +825,19 @@ def main():
|
|
|
578
825
|
|
|
579
826
|
|
|
580
827
|
if __name__ == "__main__":
|
|
581
|
-
|
|
828
|
+
try:
|
|
829
|
+
main()
|
|
830
|
+
except OcrPipelineTimeout as error:
|
|
831
|
+
deadline = ACTIVE_DEADLINE or OcrDeadline(MAX_PIPELINE_DEADLINE_MS)
|
|
832
|
+
print(json.dumps({
|
|
833
|
+
"error": str(error),
|
|
834
|
+
"diagnostic": diagnostic("ocr_timeout", str(error), deadline, "entrypoint"),
|
|
835
|
+
}))
|
|
836
|
+
sys.exit(124)
|
|
837
|
+
except OcrPipelineCancelled as error:
|
|
838
|
+
deadline = ACTIVE_DEADLINE or OcrDeadline(MAX_PIPELINE_DEADLINE_MS)
|
|
839
|
+
print(json.dumps({
|
|
840
|
+
"error": "Advanced OCR cancelled",
|
|
841
|
+
"diagnostic": diagnostic("ocr_cancelled", str(error), deadline, "entrypoint"),
|
|
842
|
+
}))
|
|
843
|
+
sys.exit(130)
|