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.
@@ -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 run_tesseract(binary_img, language="eng", psm=6):
177
- """Run Tesseract on a preprocessed binary image.
178
- Returns (text, confidence, line_count)."""
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
- # Get confidence via image_to_data
189
- try:
190
- data = pytesseract.image_to_data(
191
- pil_img, lang=language, config=config,
192
- output_type=pytesseract.Output.DICT,
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
- confs = [int(c) for c in data["conf"] if int(c) >= 0]
195
- avg_conf = sum(confs) / len(confs) if confs else 0.0
196
- except Exception:
197
- avg_conf = 0.0
198
-
199
- return text, avg_conf, line_count
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 run_pipeline(image_path, language="eng", do_regions=False, debug_dir=None,
299
- single_psm=None, pixel_region=None, output_dir=None):
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, vfunc in ALL_VARIANTS.items():
462
+ for vname, psm in plan:
463
+ deadline.check("preprocessing")
464
+ key = f"{vname}_psm{psm}"
329
465
  try:
330
- binary = vfunc(gray_2x)
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"full_{vname}.png"), binary)
338
-
339
- for psm in psm_modes:
340
- key = f"{vname}_psm{psm}"
341
- try:
342
- text, confidence, line_count = run_tesseract(binary, language, psm)
343
- except Exception as error:
344
- ocr_errors.append(f"{key}: {error}")
345
- continue
346
- char_count = len(text)
347
- score = compute_score(text, confidence, line_count)
348
-
349
- all_results[key] = {
350
- "text": text,
351
- "chars": char_count,
352
- "lines": line_count,
353
- "confidence": round(confidence, 1),
354
- "score": round(score, 1),
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
- for rname, (y_start, y_end) in region_defs.items():
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
- try:
409
- text, conf, lc = run_tesseract(binary, language, 6)
410
- except Exception:
411
- continue
412
- score = compute_score(text, conf, lc)
413
- if score > region_best_score:
414
- region_best_score = score
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
- result["regions"] = regions
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
- if debug_dir:
422
- result["debug_dir"] = debug_dir
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
- # Write output files if output_dir specified
425
- if output_dir:
426
- base_name = Path(image_path).stem
427
- files = write_all_outputs(best["text"], base_name, output_dir)
428
- result["output_files"] = files
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
- return result
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
- main()
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)